Skip to content

Commit 7c49164

Browse files
d-csclaude
andcommitted
test(webapp): cover createLoaderPATApiRoute tenant context enrichment
Behavioural test that imports the real builder, runs a handler under stubbed PAT auth, and asserts the handler observes `tenantContext.get().userId` matching the auth result. Catches the regression where the builder used to call `handler(...)` without stamping the scope (verified by reverting the fix locally — test fails). `rbac.authenticatePat` and `updateLastAccessedAtIfStale` are stubbed via `vi.mock` so the test stays unit-scoped (no testcontainer). The rest of the builder runs for real. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 90d64a9 commit 7c49164

1 file changed

Lines changed: 75 additions & 0 deletions

File tree

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
// Behavioural test for `createLoaderPATApiRoute` to confirm PAT-authenticated
2+
// requests stamp `userId` onto the tenant context (so Sentry events from
3+
// PAT routes get user-level attribution).
4+
//
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.
9+
10+
import { describe, it, expect, vi } from "vitest";
11+
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+
}));
23+
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+
};
30+
});
31+
32+
import { tenantContext } from "../app/services/tenantContext.server";
33+
import { createLoaderPATApiRoute } from "../app/services/routeBuilders/apiBuilder.server";
34+
35+
describe("createLoaderPATApiRoute", () => {
36+
it("enriches tenant context with `userId` from the PAT auth result", async () => {
37+
let observedUserId: string | undefined;
38+
39+
const loader = createLoaderPATApiRoute({}, async () => {
40+
observedUserId = tenantContext.get()?.userId;
41+
return new Response(null, { status: 200 });
42+
});
43+
44+
await tenantContext.run({}, async () => {
45+
await loader({
46+
request: new Request("http://localhost/api/test", {
47+
headers: { Authorization: "Bearer pat_irrelevant_for_this_test" },
48+
}),
49+
params: {},
50+
context: {},
51+
});
52+
});
53+
54+
expect(observedUserId).toBe("usr_test_42");
55+
});
56+
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 });
60+
});
61+
62+
await tenantContext.run({}, async () => {
63+
await loader({
64+
request: new Request("http://localhost/api/test", {
65+
headers: { Authorization: "Bearer pat_irrelevant_for_this_test" },
66+
}),
67+
params: {},
68+
context: {},
69+
});
70+
});
71+
72+
// Outside the run() scope, the enrich is gone with the scope.
73+
expect(tenantContext.get()).toBeUndefined();
74+
});
75+
});

0 commit comments

Comments
 (0)