Skip to content

Commit db9c4b9

Browse files
committed
fix: restore drawing auth failures and health checks
1 parent 786b558 commit db9c4b9

6 files changed

Lines changed: 257 additions & 33 deletions

File tree

backend/src/__tests__/link-sharing-public.integration.ts

Lines changed: 168 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,20 @@ import { StringValue } from "ms";
66
import { PrismaClient } from "../generated/client";
77
import { config } from "../config";
88
import { getTestPrisma, setupTestDb } from "./testUtils";
9+
import {
10+
ACCESS_TOKEN_COOKIE_NAME,
11+
REFRESH_TOKEN_COOKIE_NAME,
12+
} from "../auth/cookies";
913

1014
describe("Link Sharing - Public By Drawing ID", () => {
1115
const userAgent = "vitest-link-sharing-public";
16+
const createRefreshToken = (user: { id: string; email: string }) =>
17+
jwt.sign(
18+
{ userId: user.id, email: user.email, type: "refresh" },
19+
config.jwtSecret,
20+
{ expiresIn: config.jwtRefreshExpiresIn as StringValue }
21+
);
22+
1223
let prisma: PrismaClient;
1324
let app: any;
1425

@@ -34,6 +45,34 @@ describe("Link Sharing - Public By Drawing ID", () => {
3445
});
3546
};
3647

48+
const createLinkShare = async (
49+
drawingId: string,
50+
permission: "view" | "edit"
51+
) => {
52+
const response = await ownerAgent
53+
.post(`/drawings/${drawingId}/link-shares`)
54+
.set("User-Agent", userAgent)
55+
.set("Authorization", `Bearer ${ownerToken}`)
56+
.set(ownerCsrfHeaderName, ownerCsrfToken)
57+
.send({ permission });
58+
59+
expect(response.status).toBe(200);
60+
return response;
61+
};
62+
63+
const createAnonymousAgentWithCsrf = async () => {
64+
const anonAgent = request.agent(app);
65+
const anonCsrfRes = await anonAgent
66+
.get("/csrf-token")
67+
.set("User-Agent", userAgent);
68+
69+
return {
70+
anonAgent,
71+
anonCsrfHeaderName: anonCsrfRes.body.header as string,
72+
anonCsrfToken: anonCsrfRes.body.token as string,
73+
};
74+
};
75+
3776
beforeAll(async () => {
3877
setupTestDb();
3978
prisma = getTestPrisma();
@@ -88,14 +127,7 @@ describe("Link Sharing - Public By Drawing ID", () => {
88127

89128
it("allows anonymous GET when link-share policy is view", async () => {
90129
const drawing = await createDrawing();
91-
92-
const linkRes = await ownerAgent
93-
.post(`/drawings/${drawing.id}/link-shares`)
94-
.set("User-Agent", userAgent)
95-
.set("Authorization", `Bearer ${ownerToken}`)
96-
.set(ownerCsrfHeaderName, ownerCsrfToken)
97-
.send({ permission: "view" });
98-
expect(linkRes.status).toBe(200);
130+
await createLinkShare(drawing.id, "view");
99131

100132
const anonGet = await request(app)
101133
.get(`/drawings/${drawing.id}`)
@@ -104,12 +136,8 @@ describe("Link Sharing - Public By Drawing ID", () => {
104136
expect(anonGet.body?.id).toBe(drawing.id);
105137
expect(anonGet.body?.accessLevel).toBe("view");
106138

107-
const anonAgent = request.agent(app);
108-
const anonCsrfRes = await anonAgent
109-
.get("/csrf-token")
110-
.set("User-Agent", userAgent);
111-
const anonCsrfHeaderName = anonCsrfRes.body.header;
112-
const anonCsrfToken = anonCsrfRes.body.token;
139+
const { anonAgent, anonCsrfHeaderName, anonCsrfToken } =
140+
await createAnonymousAgentWithCsrf();
113141

114142
const anonPut = await anonAgent
115143
.put(`/drawings/${drawing.id}`)
@@ -119,23 +147,45 @@ describe("Link Sharing - Public By Drawing ID", () => {
119147
expect(anonPut.status).toBe(404);
120148
});
121149

122-
it("allows anonymous PUT when link-share policy is edit", async () => {
150+
it("still allows anonymous GET when a stale access-token cookie is present", async () => {
123151
const drawing = await createDrawing();
152+
await createLinkShare(drawing.id, "view");
124153

125-
const linkRes = await ownerAgent
126-
.post(`/drawings/${drawing.id}/link-shares`)
154+
const response = await request(app)
155+
.get(`/drawings/${drawing.id}`)
127156
.set("User-Agent", userAgent)
128-
.set("Authorization", `Bearer ${ownerToken}`)
129-
.set(ownerCsrfHeaderName, ownerCsrfToken)
130-
.send({ permission: "edit" });
131-
expect(linkRes.status).toBe(200);
157+
.set(
158+
"Cookie",
159+
`${ACCESS_TOKEN_COOKIE_NAME}=${createRefreshToken(ownerUser)}`
160+
);
132161

133-
const anonAgent = request.agent(app);
134-
const anonCsrfRes = await anonAgent
135-
.get("/csrf-token")
136-
.set("User-Agent", userAgent);
137-
const anonCsrfHeaderName = anonCsrfRes.body.header;
138-
const anonCsrfToken = anonCsrfRes.body.token;
162+
expect(response.status).toBe(200);
163+
expect(response.body?.id).toBe(drawing.id);
164+
expect(response.body?.accessLevel).toBe("view");
165+
});
166+
167+
it("still allows anonymous GET when only a refresh-token cookie is present", async () => {
168+
const drawing = await createDrawing();
169+
await createLinkShare(drawing.id, "view");
170+
171+
const response = await request(app)
172+
.get(`/drawings/${drawing.id}`)
173+
.set("User-Agent", userAgent)
174+
.set(
175+
"Cookie",
176+
`${REFRESH_TOKEN_COOKIE_NAME}=${createRefreshToken(ownerUser)}`
177+
);
178+
179+
expect(response.status).toBe(200);
180+
expect(response.body?.id).toBe(drawing.id);
181+
expect(response.body?.accessLevel).toBe("view");
182+
});
183+
184+
it("allows anonymous PUT when link-share policy is edit", async () => {
185+
const drawing = await createDrawing();
186+
await createLinkShare(drawing.id, "edit");
187+
const { anonAgent, anonCsrfHeaderName, anonCsrfToken } =
188+
await createAnonymousAgentWithCsrf();
139189

140190
const anonPut = await anonAgent
141191
.put(`/drawings/${drawing.id}`)
@@ -147,6 +197,97 @@ describe("Link Sharing - Public By Drawing ID", () => {
147197
expect(anonPut.body?.name).toBe("Renamed By Anonymous");
148198
});
149199

200+
it("still allows anonymous PUT when edit link-share is active and a stale access-token cookie is present", async () => {
201+
const drawing = await createDrawing();
202+
await createLinkShare(drawing.id, "edit");
203+
const { anonAgent, anonCsrfHeaderName, anonCsrfToken } =
204+
await createAnonymousAgentWithCsrf();
205+
206+
const response = await anonAgent
207+
.put(`/drawings/${drawing.id}`)
208+
.set("User-Agent", userAgent)
209+
.set(
210+
"Cookie",
211+
`${ACCESS_TOKEN_COOKIE_NAME}=${createRefreshToken(ownerUser)}`
212+
)
213+
.set(anonCsrfHeaderName, anonCsrfToken)
214+
.send({ name: "Edited With Stale Cookie" });
215+
216+
expect(response.status).toBe(200);
217+
expect(response.body?.id).toBe(drawing.id);
218+
expect(response.body?.name).toBe("Edited With Stale Cookie");
219+
});
220+
221+
it("still allows anonymous PUT when only a refresh-token cookie is present", async () => {
222+
const drawing = await createDrawing();
223+
await createLinkShare(drawing.id, "edit");
224+
const { anonAgent, anonCsrfHeaderName, anonCsrfToken } =
225+
await createAnonymousAgentWithCsrf();
226+
227+
const response = await anonAgent
228+
.put(`/drawings/${drawing.id}`)
229+
.set("User-Agent", userAgent)
230+
.set(
231+
"Cookie",
232+
`${REFRESH_TOKEN_COOKIE_NAME}=${createRefreshToken(ownerUser)}`
233+
)
234+
.set(anonCsrfHeaderName, anonCsrfToken)
235+
.send({ name: "Edited With Refresh Cookie" });
236+
237+
expect(response.status).toBe(200);
238+
expect(response.body?.id).toBe(drawing.id);
239+
expect(response.body?.name).toBe("Edited With Refresh Cookie");
240+
});
241+
242+
it("returns 401 for a private drawing when a stale access-token cookie is present", async () => {
243+
const drawing = await createDrawing();
244+
245+
const response = await request(app)
246+
.get(`/drawings/${drawing.id}`)
247+
.set("User-Agent", userAgent)
248+
.set(
249+
"Cookie",
250+
`${ACCESS_TOKEN_COOKIE_NAME}=${createRefreshToken(ownerUser)}`
251+
);
252+
253+
expect(response.status).toBe(401);
254+
expect(response.body?.message).toBe("Invalid or expired token");
255+
});
256+
257+
it("returns 401 for a private drawing when only a refresh-token cookie is present", async () => {
258+
const drawing = await createDrawing();
259+
260+
const response = await request(app)
261+
.get(`/drawings/${drawing.id}`)
262+
.set("User-Agent", userAgent)
263+
.set(
264+
"Cookie",
265+
`${REFRESH_TOKEN_COOKIE_NAME}=${createRefreshToken(ownerUser)}`
266+
);
267+
268+
expect(response.status).toBe(401);
269+
expect(response.body?.message).toBe("Invalid or expired token");
270+
});
271+
272+
it("returns 401 for a private drawing PUT when a stale access-token cookie is present", async () => {
273+
const drawing = await createDrawing();
274+
const { anonAgent, anonCsrfHeaderName, anonCsrfToken } =
275+
await createAnonymousAgentWithCsrf();
276+
277+
const response = await anonAgent
278+
.put(`/drawings/${drawing.id}`)
279+
.set("User-Agent", userAgent)
280+
.set(
281+
"Cookie",
282+
`${ACCESS_TOKEN_COOKIE_NAME}=${createRefreshToken(ownerUser)}`
283+
)
284+
.set(anonCsrfHeaderName, anonCsrfToken)
285+
.send({ name: "Should Fail" });
286+
287+
expect(response.status).toBe(401);
288+
expect(response.body?.message).toBe("Invalid or expired token");
289+
});
290+
150291
it("revokes previous active link-share when creating a new one", async () => {
151292
const drawing = await createDrawing();
152293

@@ -180,4 +321,3 @@ describe("Link Sharing - Public By Drawing ID", () => {
180321
expect(activeCount).toBe(1);
181322
});
182323
});
183-

backend/src/auth/cookies.test.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import type { Request, Response } from "express";
2+
import ms, { type StringValue } from "ms";
3+
import { describe, expect, it, vi } from "vitest";
4+
import { config } from "../config";
5+
import {
6+
ACCESS_TOKEN_COOKIE_NAME,
7+
REFRESH_TOKEN_COOKIE_NAME,
8+
setAuthCookies,
9+
} from "./cookies";
10+
11+
const createRequest = (): Request =>
12+
({
13+
secure: false,
14+
headers: {},
15+
app: {
16+
get: vi.fn().mockReturnValue(false),
17+
},
18+
}) as unknown as Request;
19+
20+
const createResponse = (): Response =>
21+
({
22+
cookie: vi.fn().mockReturnThis(),
23+
}) as unknown as Response;
24+
25+
describe("auth cookies", () => {
26+
it("keeps the access-token cookie aligned with the access-token lifetime", () => {
27+
const req = createRequest();
28+
const res = createResponse();
29+
30+
setAuthCookies(req, res, {
31+
accessToken: "access-token",
32+
refreshToken: "refresh-token",
33+
});
34+
35+
expect(res.cookie).toHaveBeenCalledTimes(2);
36+
37+
const accessCall = (res.cookie as any).mock.calls.find(
38+
(call: unknown[]) => call[0] === ACCESS_TOKEN_COOKIE_NAME
39+
);
40+
const refreshCall = (res.cookie as any).mock.calls.find(
41+
(call: unknown[]) => call[0] === REFRESH_TOKEN_COOKIE_NAME
42+
);
43+
44+
expect(accessCall).toBeTruthy();
45+
expect(refreshCall).toBeTruthy();
46+
expect(accessCall[2].maxAge).toBe(ms(config.jwtAccessExpiresIn as StringValue));
47+
expect(refreshCall[2].maxAge).toBe(ms(config.jwtRefreshExpiresIn as StringValue));
48+
expect(accessCall[2].maxAge).not.toBe(refreshCall[2].maxAge);
49+
});
50+
});

backend/src/middleware/auth.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,11 @@ import { config } from "../config";
44
import { PrismaClient } from "../generated/client";
55
import { prisma as defaultPrisma } from "../db/prisma";
66
import { createAuthModeService, type AuthModeService } from "../auth/authMode";
7-
import { ACCESS_TOKEN_COOKIE_NAME, readCookie } from "../auth/cookies";
7+
import {
8+
ACCESS_TOKEN_COOKIE_NAME,
9+
REFRESH_TOKEN_COOKIE_NAME,
10+
readCookie,
11+
} from "../auth/cookies";
812

913
declare global {
1014
namespace Express {
@@ -22,6 +26,9 @@ declare global {
2226
kind: "user";
2327
userId: string;
2428
};
29+
authError?: {
30+
code: "INVALID_ACCESS_TOKEN" | "ACCESS_TOKEN_MISSING";
31+
};
2532
}
2633
}
2734
}
@@ -60,6 +67,9 @@ const extractToken = (req: Request): string | null => {
6067
return readCookie(req, ACCESS_TOKEN_COOKIE_NAME);
6168
};
6269

70+
const hasRefreshTokenCookie = (req: Request): boolean =>
71+
readCookie(req, REFRESH_TOKEN_COOKIE_NAME) !== null;
72+
6373
const verifyToken = (token: string): JwtPayload | null => {
6474
try {
6575
const decoded = jwt.verify(token, config.jwtSecret);
@@ -227,12 +237,17 @@ export const createAuthMiddleware = ({
227237
const token = extractToken(req);
228238

229239
if (!token) {
240+
if (hasRefreshTokenCookie(req)) {
241+
req.authError = { code: "ACCESS_TOKEN_MISSING" };
242+
return next();
243+
}
230244
return next();
231245
}
232246

233247
const payload = verifyToken(token);
234248

235249
if (!payload) {
250+
req.authError = { code: "INVALID_ACCESS_TOKEN" };
236251
return next();
237252
}
238253

backend/src/routes/dashboard/drawings.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,19 @@ export const registerDrawingRoutes = (
6767
return 90 * 24 * 60 * 60 * 1000;
6868
};
6969

70+
const respondWithAuthErrorIfPresent = (
71+
req: express.Request,
72+
res: express.Response
73+
): boolean => {
74+
if (!req.authError) return false;
75+
76+
res.status(401).json({
77+
error: "Unauthorized",
78+
message: "Invalid or expired token",
79+
});
80+
return true;
81+
};
82+
7083
app.get("/drawings", requireAuth, asyncHandler(async (req, res) => {
7184
if (!req.user) {
7285
return res.status(401).json({ error: "Unauthorized" });
@@ -325,6 +338,7 @@ export const registerDrawingRoutes = (
325338
const { id } = req.params;
326339
const access = await getDrawingAccess({ prisma, principal, drawingId: id });
327340
if (!canViewDrawing(access)) {
341+
if (respondWithAuthErrorIfPresent(req, res)) return;
328342
return res.status(404).json({ error: "Drawing not found", message: "Drawing does not exist" });
329343
}
330344

@@ -412,6 +426,7 @@ export const registerDrawingRoutes = (
412426
const { id } = req.params;
413427
const access = await getDrawingAccess({ prisma, principal, drawingId: id });
414428
if (!canEditDrawing(access)) {
429+
if (respondWithAuthErrorIfPresent(req, res)) return;
415430
return res.status(404).json({ error: "Drawing not found" });
416431
}
417432

0 commit comments

Comments
 (0)