Skip to content

Commit 3911b7a

Browse files
committed
RBAC tests: run lists (TRI-8736)
Two routes share the same multi-key resource pattern — collection- level { type: "runs" } always present, plus an array of secondary keys derived from search params: GET /api/v1/runs: filter[taskIdentifier]=A,B → +{ type: "tasks", id: A/B } GET /realtime/v1/runs: ?tags=foo,bar → +{ type: "tags", id: foo/bar } Locks in the multi-key any-match contract from TRI-8719: a JWT scope matching any element of the resource array grants access. api.v1.runs (9 cases): - missing auth → 401 - private API key → 200 - JWT read:runs (collection) → 200 - JWT read:all → 200 - JWT admin → 200 - JWT empty scopes → 403 - JWT write:runs → 403 (action mismatch) - filter taskIdentifier=task_a,task_b + JWT read:tasks:task_a → 200 (array element match) - filter taskIdentifier=task_a + JWT read:tasks:task_z → 403 (no match) realtime.v1.runs (6 cases — uses "auth passes" assertion since streaming responses can vary): - missing auth → 401 - JWT read:runs → auth passes - JWT read:tags:foo + ?tags=foo,bar → auth passes (array match) - JWT read:tags:baz + ?tags=foo → 403 (no match) - JWT admin → auth passes - JWT write:runs → 403 Verification: typecheck clean. Test execution still blocked by the e2e.full webapp-boot issue noted on TRI-8731. No new helpers — reuses seedTestEnvironment.
1 parent 7b98d52 commit 3911b7a

1 file changed

Lines changed: 224 additions & 0 deletions

File tree

apps/webapp/test/auth-api.e2e.full.test.ts

Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -795,4 +795,228 @@ describe("API", () => {
795795
expect(res.status).not.toBe(403);
796796
});
797797
});
798+
799+
// Run lists (TRI-8736). Two routes share the same multi-key
800+
// resource pattern — collection-level `{ type: "runs" }` always
801+
// present, plus an array of secondary keys derived from search
802+
// params:
803+
// - GET /api/v1/runs: filter[taskIdentifier]=A,B → +{ type: "tasks", id: A }, { type: "tasks", id: B }
804+
// - GET /realtime/v1/runs: ?tags=foo,bar → +{ type: "tags", id: "foo" }, { type: "tags", id: "bar" }
805+
//
806+
// Multi-key any-match contract from TRI-8719: a JWT with a scope
807+
// matching ANY element of the resource array grants access. So:
808+
// - read:runs → matches the collection key → passes
809+
// - read:tasks:A (with A in filter) → matches an array element → passes
810+
// - read:tasks:Z (with A in filter) → no match → 403
811+
describe("Run list — api.v1.runs (multi-key tasks)", () => {
812+
const path = "/api/v1/runs";
813+
814+
async function get(query: string, headers: Record<string, string>) {
815+
return getTestServer().webapp.fetch(`${path}${query}`, { headers });
816+
}
817+
818+
it("missing auth: 401", async () => {
819+
const res = await getTestServer().webapp.fetch(path);
820+
expect(res.status).toBe(401);
821+
});
822+
823+
it("private API key: 200", async () => {
824+
const server = getTestServer();
825+
const seed = await seedTestEnvironment(server.prisma);
826+
const res = await get("", { Authorization: `Bearer ${seed.apiKey}` });
827+
expect(res.status).toBe(200);
828+
});
829+
830+
it("JWT with read:runs (collection-level): 200", async () => {
831+
const server = getTestServer();
832+
const seed = await seedTestEnvironment(server.prisma);
833+
const jwt = await generateJWT({
834+
secretKey: seed.apiKey,
835+
payload: { pub: true, sub: seed.environment.id, scopes: ["read:runs"] },
836+
expirationTime: "15m",
837+
});
838+
const res = await get("", { Authorization: `Bearer ${jwt}` });
839+
expect(res.status).toBe(200);
840+
});
841+
842+
it("JWT with read:all super-scope: 200", async () => {
843+
const server = getTestServer();
844+
const seed = await seedTestEnvironment(server.prisma);
845+
const jwt = await generateJWT({
846+
secretKey: seed.apiKey,
847+
payload: { pub: true, sub: seed.environment.id, scopes: ["read:all"] },
848+
expirationTime: "15m",
849+
});
850+
const res = await get("", { Authorization: `Bearer ${jwt}` });
851+
expect(res.status).toBe(200);
852+
});
853+
854+
it("JWT with admin: 200", async () => {
855+
const server = getTestServer();
856+
const seed = await seedTestEnvironment(server.prisma);
857+
const jwt = await generateJWT({
858+
secretKey: seed.apiKey,
859+
payload: { pub: true, sub: seed.environment.id, scopes: ["admin"] },
860+
expirationTime: "15m",
861+
});
862+
const res = await get("", { Authorization: `Bearer ${jwt}` });
863+
expect(res.status).toBe(200);
864+
});
865+
866+
it("JWT with empty scopes: 403", async () => {
867+
const server = getTestServer();
868+
const seed = await seedTestEnvironment(server.prisma);
869+
const jwt = await generateJWT({
870+
secretKey: seed.apiKey,
871+
payload: { pub: true, sub: seed.environment.id, scopes: [] },
872+
expirationTime: "15m",
873+
});
874+
const res = await get("", { Authorization: `Bearer ${jwt}` });
875+
expect(res.status).toBe(403);
876+
});
877+
878+
it("JWT with write:runs (action mismatch — read route): 403", async () => {
879+
const server = getTestServer();
880+
const seed = await seedTestEnvironment(server.prisma);
881+
const jwt = await generateJWT({
882+
secretKey: seed.apiKey,
883+
payload: { pub: true, sub: seed.environment.id, scopes: ["write:runs"] },
884+
expirationTime: "15m",
885+
});
886+
const res = await get("", { Authorization: `Bearer ${jwt}` });
887+
expect(res.status).toBe(403);
888+
});
889+
890+
it("filter[taskIdentifier]=task_a,task_b + JWT read:tasks:task_a → passes (array match)", async () => {
891+
const server = getTestServer();
892+
const seed = await seedTestEnvironment(server.prisma);
893+
const jwt = await generateJWT({
894+
secretKey: seed.apiKey,
895+
payload: {
896+
pub: true,
897+
sub: seed.environment.id,
898+
scopes: ["read:tasks:task_a"],
899+
},
900+
expirationTime: "15m",
901+
});
902+
const res = await get(
903+
"?filter%5BtaskIdentifier%5D=task_a%2Ctask_b",
904+
{ Authorization: `Bearer ${jwt}` }
905+
);
906+
// Resource array is [{type:"runs"}, {type:"tasks",id:"task_a"}, {type:"tasks",id:"task_b"}].
907+
// The scope read:tasks:task_a matches the second element → access granted.
908+
expect(res.status).toBe(200);
909+
});
910+
911+
it("filter[taskIdentifier]=task_a + JWT read:tasks:task_z → 403 (no array match)", async () => {
912+
const server = getTestServer();
913+
const seed = await seedTestEnvironment(server.prisma);
914+
const jwt = await generateJWT({
915+
secretKey: seed.apiKey,
916+
payload: {
917+
pub: true,
918+
sub: seed.environment.id,
919+
scopes: ["read:tasks:task_z"],
920+
},
921+
expirationTime: "15m",
922+
});
923+
const res = await get(
924+
"?filter%5BtaskIdentifier%5D=task_a",
925+
{ Authorization: `Bearer ${jwt}` }
926+
);
927+
// Resource is [{runs}, {tasks:task_a}]. JWT scope says
928+
// read:tasks:task_z which doesn't match the runs collection
929+
// (wrong type) or the task_a element (wrong id). 403.
930+
expect(res.status).toBe(403);
931+
});
932+
});
933+
934+
describe("Run list — realtime.v1.runs (multi-key tags)", () => {
935+
const path = "/realtime/v1/runs";
936+
937+
async function get(query: string, headers: Record<string, string>) {
938+
return getTestServer().webapp.fetch(`${path}${query}`, { headers });
939+
}
940+
941+
it("missing auth: 401", async () => {
942+
const res = await getTestServer().webapp.fetch(path);
943+
expect(res.status).toBe(401);
944+
});
945+
946+
it("JWT with read:runs (collection-level): auth passes", async () => {
947+
const server = getTestServer();
948+
const seed = await seedTestEnvironment(server.prisma);
949+
const jwt = await generateJWT({
950+
secretKey: seed.apiKey,
951+
payload: { pub: true, sub: seed.environment.id, scopes: ["read:runs"] },
952+
expirationTime: "15m",
953+
});
954+
const res = await get("", { Authorization: `Bearer ${jwt}` });
955+
// Realtime endpoints stream — the route may return 200 (streaming
956+
// OK) or other status codes depending on streams setup. We only
957+
// care that auth passed: NOT 401/403.
958+
expect(res.status).not.toBe(401);
959+
expect(res.status).not.toBe(403);
960+
});
961+
962+
it("JWT with read:tags:foo + ?tags=foo,bar → passes (array match)", async () => {
963+
const server = getTestServer();
964+
const seed = await seedTestEnvironment(server.prisma);
965+
const jwt = await generateJWT({
966+
secretKey: seed.apiKey,
967+
payload: {
968+
pub: true,
969+
sub: seed.environment.id,
970+
scopes: ["read:tags:foo"],
971+
},
972+
expirationTime: "15m",
973+
});
974+
const res = await get("?tags=foo,bar", { Authorization: `Bearer ${jwt}` });
975+
// Resource array is [{type:"runs"}, {type:"tags",id:"foo"}, {type:"tags",id:"bar"}].
976+
// Scope matches the foo element → access granted.
977+
expect(res.status).not.toBe(401);
978+
expect(res.status).not.toBe(403);
979+
});
980+
981+
it("JWT with read:tags:baz + ?tags=foo → 403 (no array match)", async () => {
982+
const server = getTestServer();
983+
const seed = await seedTestEnvironment(server.prisma);
984+
const jwt = await generateJWT({
985+
secretKey: seed.apiKey,
986+
payload: {
987+
pub: true,
988+
sub: seed.environment.id,
989+
scopes: ["read:tags:baz"],
990+
},
991+
expirationTime: "15m",
992+
});
993+
const res = await get("?tags=foo", { Authorization: `Bearer ${jwt}` });
994+
expect(res.status).toBe(403);
995+
});
996+
997+
it("JWT with admin: auth passes", async () => {
998+
const server = getTestServer();
999+
const seed = await seedTestEnvironment(server.prisma);
1000+
const jwt = await generateJWT({
1001+
secretKey: seed.apiKey,
1002+
payload: { pub: true, sub: seed.environment.id, scopes: ["admin"] },
1003+
expirationTime: "15m",
1004+
});
1005+
const res = await get("", { Authorization: `Bearer ${jwt}` });
1006+
expect(res.status).not.toBe(401);
1007+
expect(res.status).not.toBe(403);
1008+
});
1009+
1010+
it("JWT with write:runs (action mismatch): 403", async () => {
1011+
const server = getTestServer();
1012+
const seed = await seedTestEnvironment(server.prisma);
1013+
const jwt = await generateJWT({
1014+
secretKey: seed.apiKey,
1015+
payload: { pub: true, sub: seed.environment.id, scopes: ["write:runs"] },
1016+
expirationTime: "15m",
1017+
});
1018+
const res = await get("", { Authorization: `Bearer ${jwt}` });
1019+
expect(res.status).toBe(403);
1020+
});
1021+
});
7981022
});

0 commit comments

Comments
 (0)