Skip to content

Commit ef552e1

Browse files
authored
feat(api): boxes for user endpoints (#573)
1 parent 8a721f7 commit ef552e1

File tree

7 files changed

+331
-9
lines changed

7 files changed

+331
-9
lines changed

app/lib/jwt.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,6 @@ export const createToken = (
5050
invariant(typeof JWT_SECRET === "string");
5151

5252
invariant(typeof REFRESH_TOKEN_VALIDITY_MS === "string");
53-
5453
const payload = { role: user.role };
5554
const signOptions = Object.assign(
5655
{ subject: user.email, jwtid: uuidv4() },

app/models/device.server.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,11 @@ export function getDevice({ id }: Pick<Device, 'id'>) {
2525
expiresAt: true,
2626
},
2727
with: {
28+
user: {
29+
columns: {
30+
id: true
31+
}
32+
},
2833
logEntries: {
2934
where: (entry, { eq }) => eq(entry.public, true),
3035
columns: {

app/routes/api.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -101,14 +101,14 @@ const routes: { noauth: RouteInfo[]; auth: RouteInfo[] } = {
101101
path: `users/me`,
102102
method: "PUT",
103103
},
104-
// {
105-
// path: `users/me/boxes`,
106-
// method: "GET",
107-
// },
108-
// {
109-
// path: `users/me/boxes/:boxId`,
110-
// method: "GET",
111-
// },
104+
{
105+
path: `users/me/boxes`,
106+
method: "GET",
107+
},
108+
{
109+
path: `users/me/boxes/:boxId`,
110+
method: "GET",
111+
},
112112
// {
113113
// path: `boxes/:boxId/script`,
114114
// method: "GET",
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { type LoaderFunction, type LoaderFunctionArgs } from "react-router";
2+
import { getUserFromJwt } from "~/lib/jwt";
3+
import { getDevice } from "~/models/device.server";
4+
import { user } from "~/schema";
5+
6+
export const loader: LoaderFunction = async ({
7+
request,
8+
params,
9+
}: LoaderFunctionArgs) => {
10+
try {
11+
const jwtResponse = await getUserFromJwt(request);
12+
13+
if (typeof jwtResponse === "string")
14+
return Response.json(
15+
{
16+
code: "Forbidden",
17+
message:
18+
"Invalid JWT authorization. Please sign in to obtain new JWT.",
19+
},
20+
{
21+
status: 403,
22+
},
23+
);
24+
const user = jwtResponse;
25+
26+
const deviceId = params.deviceId;
27+
if (deviceId === undefined)
28+
return Response.json(
29+
{
30+
code: "Bad Request",
31+
message: "Invalid device id specified",
32+
},
33+
{
34+
status: 400,
35+
},
36+
);
37+
38+
const box = await getDevice({ id: deviceId });
39+
if (box === undefined)
40+
return Response.json(
41+
{
42+
code: "Bad Request",
43+
message: "There is no such device with the given id",
44+
},
45+
{
46+
status: 400,
47+
headers: { "Content-Type": "application/json; charset=utf-8" },
48+
},
49+
);
50+
51+
if (box.user.id !== user.id)
52+
return Response.json(
53+
{ code: "Forbidden", message: "User does not own this senseBox" },
54+
{
55+
status: 403,
56+
headers: { "Content-Type": "application/json; charset=utf-8" },
57+
},
58+
);
59+
60+
return Response.json(
61+
{ code: "Ok", data: { box: box } },
62+
{
63+
status: 200,
64+
headers: { "Content-Type": "application/json; charset=utf-8" },
65+
},
66+
);
67+
} catch (err) {
68+
console.warn(err);
69+
return Response.json(
70+
{
71+
error: "Internal Server Error",
72+
message:
73+
"The server was unable to complete your request. Please try again later.",
74+
},
75+
{
76+
status: 500,
77+
},
78+
);
79+
}
80+
};

app/routes/api.users.me.boxes.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { type LoaderFunction, type LoaderFunctionArgs } from "react-router";
2+
import { getUserFromJwt } from "~/lib/jwt";
3+
import { getUserDevices } from "~/models/device.server";
4+
5+
export const loader: LoaderFunction = async ({
6+
request,
7+
}: LoaderFunctionArgs) => {
8+
try {
9+
const jwtResponse = await getUserFromJwt(request);
10+
11+
if (typeof jwtResponse === "string")
12+
return Response.json(
13+
{
14+
code: "Forbidden",
15+
message:
16+
"Invalid JWT authorization. Please sign in to obtain new JWT.",
17+
},
18+
{
19+
status: 403,
20+
},
21+
);
22+
23+
const userBoxes = await getUserDevices(jwtResponse.id);
24+
25+
return Response.json(
26+
{
27+
code: "Ok",
28+
data: {
29+
boxes: userBoxes,
30+
boxes_count: userBoxes.length,
31+
sharedBoxes: [],
32+
},
33+
},
34+
{
35+
status: 200,
36+
headers: { "Content-Type": "application/json; charset=utf-8" },
37+
},
38+
);
39+
} catch (err) {
40+
console.warn(err);
41+
return Response.json(
42+
{
43+
error: "Internal Server Error",
44+
message:
45+
"The server was unable to complete your request. Please try again later.",
46+
},
47+
{
48+
status: 500,
49+
},
50+
);
51+
}
52+
};
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import { Params, type LoaderFunctionArgs } from "react-router";
2+
import { BASE_URL } from "vitest.setup";
3+
import { createToken } from "~/lib/jwt";
4+
import { registerUser } from "~/lib/user-service.server";
5+
import { createDevice } from "~/models/device.server";
6+
import { deleteUserByEmail } from "~/models/user.server";
7+
import { loader } from "~/routes/api.users.me.boxes.$deviceId";
8+
import { device, type User } from "~/schema";
9+
10+
const BOX_TEST_USER = {
11+
name: "testing my individual box",
12+
13+
password: "some secure password",
14+
};
15+
const BOX_TEST_USER_BOX = {
16+
name: `${BOX_TEST_USER}s Box`,
17+
exposure: "outdoor",
18+
expiresAt: null,
19+
tags: [],
20+
latitude: 0,
21+
longitude: 0,
22+
model: "luftdaten.info",
23+
mqttEnabled: false,
24+
ttnEnabled: false,
25+
};
26+
27+
const OTHER_TEST_USER = {
28+
name: "dont steal my box",
29+
30+
password: "some secure password",
31+
};
32+
33+
// TODO Give the users some boxes to test with
34+
35+
describe("openSenseMap API Routes: /users", () => {
36+
describe("/me/boxes/:deviceId", () => {
37+
describe("GET", async () => {
38+
let jwt: string = "";
39+
let otherJwt: string = "";
40+
let deviceId: string = "";
41+
42+
beforeAll(async () => {
43+
const user = await registerUser(
44+
BOX_TEST_USER.name,
45+
BOX_TEST_USER.email,
46+
BOX_TEST_USER.password,
47+
"en_US",
48+
);
49+
const { token: t } = await createToken(user as User);
50+
jwt = t;
51+
52+
const otherUser = await registerUser(
53+
OTHER_TEST_USER.name,
54+
OTHER_TEST_USER.email,
55+
OTHER_TEST_USER.password,
56+
"en_US",
57+
);
58+
const { token: t2 } = await createToken(otherUser as User);
59+
otherJwt = t2;
60+
61+
const device = await createDevice(BOX_TEST_USER_BOX, (user as User).id);
62+
deviceId = device.id;
63+
});
64+
65+
it("should let users retrieve one of their boxes with all fields", async () => {
66+
// Act: Get single box
67+
const singleBoxRequest = new Request(
68+
`${BASE_URL}/users/me/boxes/${deviceId}`,
69+
{ method: "GET", headers: { Authorization: `Bearer ${jwt}` } },
70+
);
71+
const params: Params<string> = { deviceId: deviceId };
72+
const singleBoxResponse = (await loader({
73+
request: singleBoxRequest,
74+
params,
75+
} as LoaderFunctionArgs)) as Response;
76+
await singleBoxResponse.json();
77+
// Assert: Response for single box
78+
expect(singleBoxResponse.status).toBe(200);
79+
});
80+
it("should deny to retrieve a box of other user", async () => {
81+
// Arrange
82+
const forbiddenRequest = new Request(
83+
`${BASE_URL}/users/me/boxes/${deviceId}`,
84+
{
85+
headers: { Authorization: `Bearer ${otherJwt}` },
86+
},
87+
);
88+
const params: Params<string> = { deviceId: deviceId };
89+
90+
// Act: Try to get the original users box with the other user's JWT
91+
const forbiddenResponse = (await loader({
92+
request: forbiddenRequest,
93+
params,
94+
} as LoaderFunctionArgs)) as Response;
95+
const forbiddenBody = await forbiddenResponse.json();
96+
// Assert: Forbidden response
97+
expect(forbiddenResponse.status).toBe(403);
98+
expect(forbiddenBody).toEqual({
99+
code: "Forbidden",
100+
message: "User does not own this senseBox",
101+
});
102+
});
103+
104+
afterAll(async () => {
105+
// delete the valid test user
106+
await deleteUserByEmail(BOX_TEST_USER.email);
107+
await deleteUserByEmail(OTHER_TEST_USER.email);
108+
});
109+
});
110+
});
111+
});
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { type LoaderFunctionArgs } from "react-router";
2+
import { BASE_URL } from "vitest.setup";
3+
import { createToken } from "~/lib/jwt";
4+
import { registerUser } from "~/lib/user-service.server";
5+
import { createDevice, deleteDevice } from "~/models/device.server";
6+
import { deleteUserByEmail } from "~/models/user.server";
7+
import { loader } from "~/routes/api.users.me.boxes";
8+
import { type User } from "~/schema";
9+
10+
const BOXES_TEST_USER = {
11+
name: "testing all my boxes",
12+
13+
password: "some secure password",
14+
};
15+
const TEST_BOX = {
16+
name: `'${BOXES_TEST_USER.name}'s Box`,
17+
exposure: "outdoor",
18+
expiresAt: null,
19+
tags: [],
20+
latitude: 0,
21+
longitude: 0,
22+
model: "luftdaten.info",
23+
mqttEnabled: false,
24+
ttnEnabled: false,
25+
};
26+
27+
describe("openSenseMap API Routes: /users", () => {
28+
let jwt: string = "";
29+
let deviceId = "";
30+
31+
describe("/me/boxes", () => {
32+
describe("GET", async () => {
33+
beforeAll(async () => {
34+
const user = await registerUser(
35+
BOXES_TEST_USER.name,
36+
BOXES_TEST_USER.email,
37+
BOXES_TEST_USER.password,
38+
"en_US",
39+
);
40+
const { token } = await createToken(user as User);
41+
jwt = token;
42+
const device = await createDevice(TEST_BOX, (user as User).id);
43+
deviceId = device.id;
44+
});
45+
it("should let users retrieve their boxes and sharedBoxes with all fields", async () => {
46+
// Arrange
47+
const request = new Request(`${BASE_URL}/users/me/boxes`, {
48+
method: "GET",
49+
headers: { Authorization: `Bearer ${jwt}` },
50+
});
51+
52+
// Act
53+
const response = (await loader({
54+
request,
55+
} as LoaderFunctionArgs)) as Response;
56+
const body = await response?.json();
57+
58+
// Assert
59+
expect(response.status).toBe(200);
60+
expect(body.data.boxes[0].integrations.mqtt).toEqual({
61+
enabled: false,
62+
});
63+
expect(body.data.sharedBoxes[0].integrations.mqtt).toEqual({
64+
enabled: false,
65+
});
66+
});
67+
68+
afterAll(async () => {
69+
// delete the valid test user
70+
await deleteUserByEmail(BOXES_TEST_USER.email);
71+
await deleteDevice({ id: deviceId });
72+
});
73+
});
74+
});
75+
});

0 commit comments

Comments
 (0)