Skip to content

Commit 253c5e0

Browse files
d-csclaude
andcommitted
test(webapp): replace stubs with real DB seed in PAT tenant-context test
The previous version stubbed `rbac.authenticatePat` and `updateLastAccessedAtIfStale` to keep the test unit-scoped, but that violated the "never mock — use a real database" guidance in CLAUDE.md. The webapp's vitest setup already points `~/db.server` at the local postgres (`apps/webapp/test/setup.ts` loads `apps/webapp/.env`), so the test can seed a real User + PAT via Prisma and let `rbac.authenticatePat` validate end-to-end. No mocks. Cleanup runs in `afterAll` — deletes the PATs and the user. Verified to fail if the `enrich` call is removed from `createLoaderPATApiRoute`. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 7c49164 commit 253c5e0

1 file changed

Lines changed: 56 additions & 35 deletions

File tree

Lines changed: 56 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,41 +1,48 @@
1-
// Behavioural test for `createLoaderPATApiRoute` to confirm PAT-authenticated
1+
// Integration test for `createLoaderPATApiRoute` — confirms PAT-authenticated
22
// requests stamp `userId` onto the tenant context (so Sentry events from
33
// PAT routes get user-level attribution).
44
//
5-
// PAT auth normally hits the DB via `rbac.authenticatePat`. To keep this
6-
// a unit test, we stub the two DB-touching dependencies — narrow enough
7-
// that the test exercises the wrapping behaviour without bringing up a
8-
// real database.
5+
// Runs against the local postgres the webapp test setup already targets
6+
// (`apps/webapp/.env` → `DATABASE_URL`). Seeds a real User + PersonalAccessToken
7+
// via Prisma, calls the real loader with the real bearer, and lets
8+
// `rbac.authenticatePat` validate against the DB end-to-end. No stubs.
9+
//
10+
// Cleans up the rows it creates so the test is repeatable.
911

10-
import { describe, it, expect, vi } from "vitest";
12+
import { afterAll, describe, expect, it } from "vitest";
13+
import { prisma } from "../app/db.server";
14+
import { createPersonalAccessToken } from "../app/services/personalAccessToken.server";
15+
import { tenantContext } from "../app/services/tenantContext.server";
16+
import { createLoaderPATApiRoute } from "../app/services/routeBuilders/apiBuilder.server";
1117

12-
vi.mock("~/services/rbac.server", () => ({
13-
rbac: {
14-
authenticatePat: vi.fn(async () => ({
15-
ok: true,
16-
userId: "usr_test_42",
17-
ability: {},
18-
tokenId: "tok_1",
19-
lastAccessedAt: new Date(),
20-
})),
21-
},
22-
}));
18+
const cleanup: Array<() => Promise<unknown>> = [];
2319

24-
vi.mock("~/services/personalAccessToken.server", async (orig) => {
25-
const actual = (await orig()) as Record<string, unknown>;
26-
return {
27-
...actual,
28-
updateLastAccessedAtIfStale: vi.fn(async () => undefined),
29-
};
20+
afterAll(async () => {
21+
for (const fn of cleanup) {
22+
await fn().catch(() => {});
23+
}
3024
});
3125

32-
import { tenantContext } from "../app/services/tenantContext.server";
33-
import { createLoaderPATApiRoute } from "../app/services/routeBuilders/apiBuilder.server";
34-
3526
describe("createLoaderPATApiRoute", () => {
36-
it("enriches tenant context with `userId` from the PAT auth result", async () => {
37-
let observedUserId: string | undefined;
27+
it("enriches tenant context with the authenticated PAT's userId", async () => {
28+
const suffix = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
29+
const user = await prisma.user.create({
30+
data: {
31+
email: `pat-tenant-test-${suffix}@test.local`,
32+
authenticationMethod: "MAGIC_LINK",
33+
},
34+
});
35+
cleanup.push(async () => {
36+
await prisma.personalAccessToken.deleteMany({ where: { userId: user.id } });
37+
await prisma.user.delete({ where: { id: user.id } });
38+
});
3839

40+
const created = await createPersonalAccessToken({
41+
name: `pat-tenant-test-${suffix}`,
42+
userId: user.id,
43+
});
44+
45+
let observedUserId: string | undefined;
3946
const loader = createLoaderPATApiRoute({}, async () => {
4047
observedUserId = tenantContext.get()?.userId;
4148
return new Response(null, { status: 200 });
@@ -44,32 +51,46 @@ describe("createLoaderPATApiRoute", () => {
4451
await tenantContext.run({}, async () => {
4552
await loader({
4653
request: new Request("http://localhost/api/test", {
47-
headers: { Authorization: "Bearer pat_irrelevant_for_this_test" },
54+
headers: { Authorization: `Bearer ${created.token}` },
4855
}),
4956
params: {},
5057
context: {},
5158
});
5259
});
5360

54-
expect(observedUserId).toBe("usr_test_42");
61+
expect(observedUserId).toBe(user.id);
5562
});
5663

57-
it("does not leak the enrich across requests once the scope ends", async () => {
58-
const loader = createLoaderPATApiRoute({}, async () => {
59-
return new Response(null, { status: 200 });
64+
it("does not leave the enrich behind once the request scope ends", async () => {
65+
const suffix = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
66+
const user = await prisma.user.create({
67+
data: {
68+
email: `pat-tenant-leak-${suffix}@test.local`,
69+
authenticationMethod: "MAGIC_LINK",
70+
},
6071
});
72+
cleanup.push(async () => {
73+
await prisma.personalAccessToken.deleteMany({ where: { userId: user.id } });
74+
await prisma.user.delete({ where: { id: user.id } });
75+
});
76+
77+
const created = await createPersonalAccessToken({
78+
name: `pat-tenant-leak-${suffix}`,
79+
userId: user.id,
80+
});
81+
82+
const loader = createLoaderPATApiRoute({}, async () => new Response(null, { status: 200 }));
6183

6284
await tenantContext.run({}, async () => {
6385
await loader({
6486
request: new Request("http://localhost/api/test", {
65-
headers: { Authorization: "Bearer pat_irrelevant_for_this_test" },
87+
headers: { Authorization: `Bearer ${created.token}` },
6688
}),
6789
params: {},
6890
context: {},
6991
});
7092
});
7193

72-
// Outside the run() scope, the enrich is gone with the scope.
7394
expect(tenantContext.get()).toBeUndefined();
7495
});
7596
});

0 commit comments

Comments
 (0)