2914 lines
115 KiB
TypeScript
2914 lines
115 KiB
TypeScript
// Comprehensive API auth tests — uses the shared TestServer started by
|
|
// vitest.e2e.full.config.ts's globalSetup. Family subtasks under TRI-8731
|
|
// add nested describe blocks here:
|
|
//
|
|
// describe("API", () => {
|
|
// describe("Trigger task", () => { ... }) // TRI-8733
|
|
// describe("Runs — resource routes", () => { ... }) // TRI-8734
|
|
// ...
|
|
// })
|
|
//
|
|
// See test/helpers/sharedTestServer.ts for `getTestServer()`.
|
|
|
|
import { generateJWT } from "@trigger.dev/core/v3/jwt";
|
|
import { describe, expect, it } from "vitest";
|
|
import { getTestServer } from "./helpers/sharedTestServer";
|
|
import { seedTestEnvironment } from "./helpers/seedTestEnvironment";
|
|
import { seedTestPAT, seedTestUser } from "./helpers/seedTestPAT";
|
|
import { seedTestRun } from "./helpers/seedTestRun";
|
|
import { seedTestApiSession } from "./helpers/seedTestApiSession";
|
|
import { seedTestUserProject } from "./helpers/seedTestUserProject";
|
|
import { seedTestWaitpoint } from "./helpers/seedTestWaitpoint";
|
|
|
|
describe("API", () => {
|
|
// Placeholder until family subtasks add their describes (TRI-8733+).
|
|
// Verifies the shared container is reachable from this worker.
|
|
it("shared webapp container responds to /healthcheck", async () => {
|
|
const server = getTestServer();
|
|
const res = await server.webapp.fetch("/healthcheck");
|
|
expect(res.ok).toBe(true);
|
|
});
|
|
|
|
// PAT-authenticated routes (TRI-8741). The smoke matrix in
|
|
// test/api-auth.e2e.test.ts covers basic 401 cases (missing auth,
|
|
// wrong-prefix, unknown PAT, revoked PAT, valid-PAT-on-nonexistent-
|
|
// project). This describe extends the matrix to the cases that
|
|
// require seeding the full user → org → project → env graph:
|
|
// valid-PAT-on-real-project, cross-org isolation, soft-deleted
|
|
// project, and the global-admin-flag-doesn't-grant-cross-org carve-
|
|
// out.
|
|
//
|
|
// Target route: GET /api/v1/projects/:projectRef/runs (the only
|
|
// createLoaderPATApiRoute consumer at time of writing — re-grep
|
|
// before extending if more PAT-only routes appear).
|
|
describe("PAT-authenticated routes — comprehensive", () => {
|
|
const pathFor = (ref: string) => `/api/v1/projects/${ref}/runs`;
|
|
|
|
it("JWT on PAT-only route: 401", async () => {
|
|
const server = getTestServer();
|
|
const { environment } = await seedTestEnvironment(server.prisma);
|
|
const jwt = await generateJWT({
|
|
secretKey: environment.apiKey,
|
|
payload: { pub: true, sub: environment.id, scopes: ["read:runs"] },
|
|
expirationTime: "15m",
|
|
});
|
|
const res = await server.webapp.fetch(pathFor("nonexistent"), {
|
|
headers: { Authorization: `Bearer ${jwt}` },
|
|
});
|
|
// PAT route doesn't accept JWTs — auth rejects before resource lookup.
|
|
expect(res.status).toBe(401);
|
|
});
|
|
|
|
it("valid PAT, project exists in user's org: auth passes", async () => {
|
|
const server = getTestServer();
|
|
const { project, pat } = await seedTestUserProject(server.prisma);
|
|
const res = await server.webapp.fetch(pathFor(project.externalRef), {
|
|
headers: { Authorization: `Bearer ${pat.token}` },
|
|
});
|
|
// Auth + scoping pass. The route's run-list presenter hits
|
|
// ClickHouse which isn't reachable in tests — accept any status
|
|
// that isn't an auth failure.
|
|
expect(res.status).not.toBe(401);
|
|
expect(res.status).not.toBe(403);
|
|
});
|
|
|
|
it("valid PAT, project belongs to a different user's org: 404", async () => {
|
|
const server = getTestServer();
|
|
// Two completely isolated graphs. Both projects exist; the PAT
|
|
// belongs to userA, the project to userB's org. findProjectByRef
|
|
// scopes by `members: { some: { userId } }`, so userA's PAT
|
|
// sees userB's project as nonexistent → 404 (not 403).
|
|
const a = await seedTestUserProject(server.prisma);
|
|
const b = await seedTestUserProject(server.prisma);
|
|
const res = await server.webapp.fetch(pathFor(b.project.externalRef), {
|
|
headers: { Authorization: `Bearer ${a.pat.token}` },
|
|
});
|
|
// Lock in the 404 — the access check inside findProjectByRef
|
|
// returns null for cross-org and the route maps null to 404.
|
|
expect(res.status).toBe(404);
|
|
});
|
|
|
|
it("valid PAT, project soft-deleted (deletedAt != null): 200 (route does not filter)", async () => {
|
|
const server = getTestServer();
|
|
// findProjectByRef (apps/webapp/app/models/project.server.ts)
|
|
// does NOT filter on deletedAt — it scopes only by externalRef
|
|
// and the user's org membership. So a soft-deleted project is
|
|
// still findable here; the run-list presenter just returns
|
|
// data:[] (or whatever survived). The ticket lists this as a
|
|
// 404 case but that's not the route's actual contract; lock in
|
|
// observed behaviour and call out the gap so a future change
|
|
// (either tightening findProjectByRef or filtering at the route)
|
|
// is conscious.
|
|
const { project, pat } = await seedTestUserProject(server.prisma, {
|
|
projectDeleted: true,
|
|
});
|
|
const res = await server.webapp.fetch(pathFor(project.externalRef), {
|
|
headers: { Authorization: `Bearer ${pat.token}` },
|
|
});
|
|
// ClickHouse-dependent run-list — auth-passed assertion.
|
|
expect(res.status).not.toBe(401);
|
|
expect(res.status).not.toBe(403);
|
|
});
|
|
|
|
it("valid PAT for a global-admin user: still per-user (no cross-org access)", async () => {
|
|
const server = getTestServer();
|
|
// user.admin = true is the legacy super-admin flag. The PAT
|
|
// route's access check is per-user (members: { some: { userId } }),
|
|
// not admin-aware — so admin doesn't unlock cross-org visibility.
|
|
// Lock in that behaviour: an admin's PAT can't read another
|
|
// org's project either.
|
|
const admin = await seedTestUser(server.prisma, { admin: true });
|
|
const adminPat = await seedTestPAT(server.prisma, admin.id);
|
|
const otherOrg = await seedTestUserProject(server.prisma);
|
|
|
|
const res = await server.webapp.fetch(pathFor(otherOrg.project.externalRef), {
|
|
headers: { Authorization: `Bearer ${adminPat.token}` },
|
|
});
|
|
expect(res.status).toBe(404);
|
|
});
|
|
|
|
it("valid PAT, admin user accessing their OWN project: auth passes", async () => {
|
|
const server = getTestServer();
|
|
// Companion to the above — confirm admin=true users can still
|
|
// access their own org's projects (the admin flag isn't
|
|
// accidentally subtracting permission).
|
|
const { project, pat } = await seedTestUserProject(server.prisma, {
|
|
userAdmin: true,
|
|
});
|
|
const res = await server.webapp.fetch(pathFor(project.externalRef), {
|
|
headers: { Authorization: `Bearer ${pat.token}` },
|
|
});
|
|
// ClickHouse-dependent run-list — auth-passed assertion.
|
|
expect(res.status).not.toBe(401);
|
|
expect(res.status).not.toBe(403);
|
|
});
|
|
});
|
|
|
|
// Resource-scoped writes (TRI-8740). Two routes:
|
|
// - POST /api/v1/waitpoints/tokens/:friendlyId/complete
|
|
// resource: { type: "waitpoints", id: friendlyId }
|
|
// - POST /realtime/v1/streams/:runId/input/:streamId
|
|
// resource: { type: "inputStreams", id: runId }
|
|
//
|
|
// The smoke matrix (api-auth.e2e.test.ts "JWT bearer auth — resource-
|
|
// scoped scopes") already covers waitpoints comprehensively for JWT
|
|
// resource-id matching, type-level scopes, action mismatches, admin
|
|
// super-scope, etc. This block fills the gaps:
|
|
// - Private API key (not JWT) on the route.
|
|
// - JWT with `write:all` super-scope.
|
|
// - Cross-env (env A's JWT trying env B's resource).
|
|
// Plus the equivalent full matrix for input-streams which the smoke
|
|
// matrix doesn't touch.
|
|
describe("Resource-scoped writes — waitpoints (gap-fill)", () => {
|
|
const pathFor = (friendlyId: string) => `/api/v1/waitpoints/tokens/${friendlyId}/complete`;
|
|
const completeRequest = (path: string, headers: Record<string, string>) =>
|
|
getTestServer().webapp.fetch(path, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json", ...headers },
|
|
body: JSON.stringify({}),
|
|
});
|
|
|
|
async function seedEnvAndWaitpoint() {
|
|
const server = getTestServer();
|
|
const seed = await seedTestEnvironment(server.prisma);
|
|
const waitpoint = await seedTestWaitpoint(server.prisma, {
|
|
environmentId: seed.environment.id,
|
|
projectId: seed.project.id,
|
|
});
|
|
return { ...seed, waitpoint };
|
|
}
|
|
|
|
it("private API key (tr_dev_*): auth passes (200)", async () => {
|
|
const { apiKey, waitpoint } = await seedEnvAndWaitpoint();
|
|
const res = await completeRequest(pathFor(waitpoint.friendlyId), {
|
|
Authorization: `Bearer ${apiKey}`,
|
|
});
|
|
// Waitpoint is COMPLETED, so the handler short-circuits with 200
|
|
// once auth passes. Auth-passed assertion: NOT 401 / 403.
|
|
expect(res.status).toBe(200);
|
|
});
|
|
|
|
it("JWT with write:all super-scope: auth passes (200)", async () => {
|
|
const { environment, waitpoint } = await seedEnvAndWaitpoint();
|
|
const jwt = await generateJWT({
|
|
secretKey: environment.apiKey,
|
|
payload: { pub: true, sub: environment.id, scopes: ["write:all"] },
|
|
expirationTime: "15m",
|
|
});
|
|
const res = await completeRequest(pathFor(waitpoint.friendlyId), {
|
|
Authorization: `Bearer ${jwt}`,
|
|
});
|
|
expect(res.status).toBe(200);
|
|
});
|
|
|
|
it("cross-env: env A's JWT cannot complete env B's waitpoint: not 200", async () => {
|
|
const server = getTestServer();
|
|
const a = await seedTestEnvironment(server.prisma);
|
|
const b = await seedEnvAndWaitpoint();
|
|
const jwt = await generateJWT({
|
|
secretKey: a.apiKey,
|
|
payload: {
|
|
pub: true,
|
|
sub: a.environment.id,
|
|
scopes: [`write:waitpoints:${b.waitpoint.friendlyId}`],
|
|
},
|
|
expirationTime: "15m",
|
|
});
|
|
// The JWT is signed by env A and its sub claim says env A. The
|
|
// route resolves env from the sub claim and the waitpoint is
|
|
// env B's, so the lookup misses. The exact code depends on
|
|
// whether auth or the resource lookup fires first — both
|
|
// outcomes are correct, just NOT 200.
|
|
const res = await completeRequest(pathFor(b.waitpoint.friendlyId), {
|
|
Authorization: `Bearer ${jwt}`,
|
|
});
|
|
expect(res.status).not.toBe(200);
|
|
});
|
|
});
|
|
|
|
describe("Resource-scoped writes — input streams (full matrix)", () => {
|
|
const pathFor = (runId: string, streamId: string) =>
|
|
`/realtime/v1/streams/${runId}/input/${streamId}`;
|
|
const postRequest = (path: string, headers: Record<string, string>) =>
|
|
getTestServer().webapp.fetch(path, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json", ...headers },
|
|
body: JSON.stringify({ data: { hello: "world" } }),
|
|
});
|
|
|
|
async function seedEnvAndRun() {
|
|
const server = getTestServer();
|
|
const seed = await seedTestEnvironment(server.prisma);
|
|
const { runFriendlyId } = await seedTestRun(server.prisma, {
|
|
environmentId: seed.environment.id,
|
|
projectId: seed.project.id,
|
|
});
|
|
return { ...seed, runFriendlyId, streamId: "test-stream" };
|
|
}
|
|
|
|
it("missing auth: 401", async () => {
|
|
const server = getTestServer();
|
|
const res = await server.webapp.fetch(pathFor("run_doesnotexist", "stream-x"), {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ data: {} }),
|
|
});
|
|
expect(res.status).toBe(401);
|
|
});
|
|
|
|
it("private API key: auth passes (not 401/403)", async () => {
|
|
const { apiKey, runFriendlyId, streamId } = await seedEnvAndRun();
|
|
const res = await postRequest(pathFor(runFriendlyId, streamId), {
|
|
Authorization: `Bearer ${apiKey}`,
|
|
});
|
|
// Route may return any 2xx/4xx based on stream state — we only
|
|
// care that auth passed (NOT 401/403).
|
|
expect(res.status).not.toBe(401);
|
|
expect(res.status).not.toBe(403);
|
|
});
|
|
|
|
it("JWT with exact-id scope: auth passes", async () => {
|
|
const { environment, runFriendlyId, streamId } = await seedEnvAndRun();
|
|
const jwt = await generateJWT({
|
|
secretKey: environment.apiKey,
|
|
payload: {
|
|
pub: true,
|
|
sub: environment.id,
|
|
scopes: [`write:inputStreams:${runFriendlyId}`],
|
|
},
|
|
expirationTime: "15m",
|
|
});
|
|
const res = await postRequest(pathFor(runFriendlyId, streamId), {
|
|
Authorization: `Bearer ${jwt}`,
|
|
});
|
|
expect(res.status).not.toBe(401);
|
|
expect(res.status).not.toBe(403);
|
|
});
|
|
|
|
it("JWT with type-level scope: auth passes", async () => {
|
|
const { environment, runFriendlyId, streamId } = await seedEnvAndRun();
|
|
const jwt = await generateJWT({
|
|
secretKey: environment.apiKey,
|
|
payload: { pub: true, sub: environment.id, scopes: ["write:inputStreams"] },
|
|
expirationTime: "15m",
|
|
});
|
|
const res = await postRequest(pathFor(runFriendlyId, streamId), {
|
|
Authorization: `Bearer ${jwt}`,
|
|
});
|
|
expect(res.status).not.toBe(401);
|
|
expect(res.status).not.toBe(403);
|
|
});
|
|
|
|
it("JWT with wrong resource id: 403", async () => {
|
|
const { environment, runFriendlyId, streamId } = await seedEnvAndRun();
|
|
const jwt = await generateJWT({
|
|
secretKey: environment.apiKey,
|
|
payload: {
|
|
pub: true,
|
|
sub: environment.id,
|
|
scopes: ["write:inputStreams:run_someoneelse00000000000000"],
|
|
},
|
|
expirationTime: "15m",
|
|
});
|
|
const res = await postRequest(pathFor(runFriendlyId, streamId), {
|
|
Authorization: `Bearer ${jwt}`,
|
|
});
|
|
expect(res.status).toBe(403);
|
|
});
|
|
|
|
it("JWT with read action on write route: 403", async () => {
|
|
const { environment, runFriendlyId, streamId } = await seedEnvAndRun();
|
|
const jwt = await generateJWT({
|
|
secretKey: environment.apiKey,
|
|
payload: {
|
|
pub: true,
|
|
sub: environment.id,
|
|
scopes: [`read:inputStreams:${runFriendlyId}`],
|
|
},
|
|
expirationTime: "15m",
|
|
});
|
|
const res = await postRequest(pathFor(runFriendlyId, streamId), {
|
|
Authorization: `Bearer ${jwt}`,
|
|
});
|
|
expect(res.status).toBe(403);
|
|
});
|
|
|
|
it("JWT with write:all super-scope: auth passes", async () => {
|
|
const { environment, runFriendlyId, streamId } = await seedEnvAndRun();
|
|
const jwt = await generateJWT({
|
|
secretKey: environment.apiKey,
|
|
payload: { pub: true, sub: environment.id, scopes: ["write:all"] },
|
|
expirationTime: "15m",
|
|
});
|
|
const res = await postRequest(pathFor(runFriendlyId, streamId), {
|
|
Authorization: `Bearer ${jwt}`,
|
|
});
|
|
expect(res.status).not.toBe(401);
|
|
expect(res.status).not.toBe(403);
|
|
});
|
|
|
|
it("JWT with admin super-scope: auth passes", async () => {
|
|
const { environment, runFriendlyId, streamId } = await seedEnvAndRun();
|
|
const jwt = await generateJWT({
|
|
secretKey: environment.apiKey,
|
|
payload: { pub: true, sub: environment.id, scopes: ["admin"] },
|
|
expirationTime: "15m",
|
|
});
|
|
const res = await postRequest(pathFor(runFriendlyId, streamId), {
|
|
Authorization: `Bearer ${jwt}`,
|
|
});
|
|
expect(res.status).not.toBe(401);
|
|
expect(res.status).not.toBe(403);
|
|
});
|
|
|
|
it("cross-env: env A's JWT cannot write to env B's run: not 200", async () => {
|
|
const server = getTestServer();
|
|
const a = await seedTestEnvironment(server.prisma);
|
|
const b = await seedEnvAndRun();
|
|
const jwt = await generateJWT({
|
|
secretKey: a.apiKey,
|
|
payload: {
|
|
pub: true,
|
|
sub: a.environment.id,
|
|
scopes: [`write:inputStreams:${b.runFriendlyId}`],
|
|
},
|
|
expirationTime: "15m",
|
|
});
|
|
const res = await postRequest(pathFor(b.runFriendlyId, b.streamId), {
|
|
Authorization: `Bearer ${jwt}`,
|
|
});
|
|
// Either auth fails outright or the run lookup misses (env A's
|
|
// view of the run doesn't include env B's data). Critical
|
|
// security property: NOT 200.
|
|
expect(res.status).not.toBe(200);
|
|
});
|
|
});
|
|
|
|
// Trigger task routes (TRI-8733). The single-task route uses
|
|
// action: "trigger" with a single resource { type: "tasks", id };
|
|
// batch v1/v2 use action: "batchTrigger" with a body-derived array
|
|
// [{type:"tasks", id}, ...] under AND semantics — every task in the
|
|
// batch must be authorized, not just any one (otherwise a JWT scoped
|
|
// to one task could submit a batch with arbitrary other tasks).
|
|
// v3 batches use a collection-level resource { type: "tasks" }
|
|
// (no id — items are validated per-row when streamed).
|
|
//
|
|
// ACTION_ALIASES (from packages/core/src/v3/jwt.ts) maps write→trigger
|
|
// and write→batchTrigger so write:tasks scopes also satisfy these
|
|
// routes. The smoke matrix already verifies write:tasks → trigger
|
|
// alias works; we re-test it here per-route so scope misconfig in
|
|
// one route doesn't slip past.
|
|
describe("Trigger task — single (api.v1.tasks.$taskId.trigger)", () => {
|
|
const TASK_ID = "test-task";
|
|
const path = `/api/v1/tasks/${TASK_ID}/trigger`;
|
|
it("missing auth: 401", async () => {
|
|
const server = getTestServer();
|
|
const res = await server.webapp.fetch(path, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ payload: {} }),
|
|
});
|
|
expect(res.status).toBe(401);
|
|
});
|
|
|
|
it("private API key: auth passes (handler may 4xx — not 401/403)", async () => {
|
|
const server = getTestServer();
|
|
const seed = await seedTestEnvironment(server.prisma);
|
|
const res = await server.webapp.fetch(path, {
|
|
method: "POST",
|
|
headers: {
|
|
Authorization: `Bearer ${seed.apiKey}`,
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({ payload: {} }),
|
|
});
|
|
// Auth passed; the handler may 404 because the task doesn't
|
|
// actually exist in the BackgroundWorker. Anything not 401/403
|
|
// is "auth passed" for this test.
|
|
expect(res.status).not.toBe(401);
|
|
expect(res.status).not.toBe(403);
|
|
});
|
|
|
|
it("JWT with write:tasks (type-level, ACTION_ALIASES write→trigger): auth passes", async () => {
|
|
const server = getTestServer();
|
|
const seed = await seedTestEnvironment(server.prisma);
|
|
const jwt = await generateJWT({
|
|
secretKey: seed.apiKey,
|
|
payload: { pub: true, sub: seed.environment.id, scopes: ["write:tasks"] },
|
|
expirationTime: "15m",
|
|
});
|
|
const res = await server.webapp.fetch(path, {
|
|
method: "POST",
|
|
headers: { Authorization: `Bearer ${jwt}`, "Content-Type": "application/json" },
|
|
body: JSON.stringify({ payload: {} }),
|
|
});
|
|
expect(res.status).not.toBe(401);
|
|
expect(res.status).not.toBe(403);
|
|
});
|
|
|
|
it("JWT with trigger:tasks:<exact taskId>: auth passes", async () => {
|
|
const server = getTestServer();
|
|
const seed = await seedTestEnvironment(server.prisma);
|
|
const jwt = await generateJWT({
|
|
secretKey: seed.apiKey,
|
|
payload: {
|
|
pub: true,
|
|
sub: seed.environment.id,
|
|
scopes: [`trigger:tasks:${TASK_ID}`],
|
|
},
|
|
expirationTime: "15m",
|
|
});
|
|
const res = await server.webapp.fetch(path, {
|
|
method: "POST",
|
|
headers: { Authorization: `Bearer ${jwt}`, "Content-Type": "application/json" },
|
|
body: JSON.stringify({ payload: {} }),
|
|
});
|
|
expect(res.status).not.toBe(401);
|
|
expect(res.status).not.toBe(403);
|
|
});
|
|
|
|
it("JWT with trigger:tasks:<other>: 403", async () => {
|
|
const server = getTestServer();
|
|
const seed = await seedTestEnvironment(server.prisma);
|
|
const jwt = await generateJWT({
|
|
secretKey: seed.apiKey,
|
|
payload: {
|
|
pub: true,
|
|
sub: seed.environment.id,
|
|
scopes: ["trigger:tasks:some-other-task"],
|
|
},
|
|
expirationTime: "15m",
|
|
});
|
|
const res = await server.webapp.fetch(path, {
|
|
method: "POST",
|
|
headers: { Authorization: `Bearer ${jwt}`, "Content-Type": "application/json" },
|
|
body: JSON.stringify({ payload: {} }),
|
|
});
|
|
expect(res.status).toBe(403);
|
|
});
|
|
|
|
it("JWT with read:tasks: 403 (read NOT aliased to trigger)", async () => {
|
|
const server = getTestServer();
|
|
const seed = await seedTestEnvironment(server.prisma);
|
|
const jwt = await generateJWT({
|
|
secretKey: seed.apiKey,
|
|
payload: { pub: true, sub: seed.environment.id, scopes: ["read:tasks"] },
|
|
expirationTime: "15m",
|
|
});
|
|
const res = await server.webapp.fetch(path, {
|
|
method: "POST",
|
|
headers: { Authorization: `Bearer ${jwt}`, "Content-Type": "application/json" },
|
|
body: JSON.stringify({ payload: {} }),
|
|
});
|
|
expect(res.status).toBe(403);
|
|
});
|
|
|
|
it("JWT with empty scopes: 403", async () => {
|
|
const server = getTestServer();
|
|
const seed = await seedTestEnvironment(server.prisma);
|
|
const jwt = await generateJWT({
|
|
secretKey: seed.apiKey,
|
|
payload: { pub: true, sub: seed.environment.id, scopes: [] },
|
|
expirationTime: "15m",
|
|
});
|
|
const res = await server.webapp.fetch(path, {
|
|
method: "POST",
|
|
headers: { Authorization: `Bearer ${jwt}`, "Content-Type": "application/json" },
|
|
body: JSON.stringify({ payload: {} }),
|
|
});
|
|
expect(res.status).toBe(403);
|
|
});
|
|
|
|
it("JWT signed with wrong key: 401", async () => {
|
|
const server = getTestServer();
|
|
const a = await seedTestEnvironment(server.prisma);
|
|
const b = await seedTestEnvironment(server.prisma);
|
|
const jwt = await generateJWT({
|
|
secretKey: b.apiKey, // wrong key for env A's sub
|
|
payload: {
|
|
pub: true,
|
|
sub: a.environment.id,
|
|
scopes: [`trigger:tasks:${TASK_ID}`],
|
|
},
|
|
expirationTime: "15m",
|
|
});
|
|
const res = await server.webapp.fetch(path, {
|
|
method: "POST",
|
|
headers: { Authorization: `Bearer ${jwt}`, "Content-Type": "application/json" },
|
|
body: JSON.stringify({ payload: {} }),
|
|
});
|
|
expect(res.status).toBe(401);
|
|
});
|
|
|
|
it("JWT with admin super-scope: auth passes", async () => {
|
|
const server = getTestServer();
|
|
const seed = await seedTestEnvironment(server.prisma);
|
|
const jwt = await generateJWT({
|
|
secretKey: seed.apiKey,
|
|
payload: { pub: true, sub: seed.environment.id, scopes: ["admin"] },
|
|
expirationTime: "15m",
|
|
});
|
|
const res = await server.webapp.fetch(path, {
|
|
method: "POST",
|
|
headers: { Authorization: `Bearer ${jwt}`, "Content-Type": "application/json" },
|
|
body: JSON.stringify({ payload: {} }),
|
|
});
|
|
expect(res.status).not.toBe(401);
|
|
expect(res.status).not.toBe(403);
|
|
});
|
|
});
|
|
|
|
describe("Trigger task — batch v1 (api.v1.tasks.batch)", () => {
|
|
const path = "/api/v1/tasks/batch";
|
|
const buildBody = (taskIds: string[]) => ({
|
|
items: taskIds.map((task) => ({ task, payload: {} })),
|
|
});
|
|
|
|
it("missing auth: 401", async () => {
|
|
const server = getTestServer();
|
|
const res = await server.webapp.fetch(path, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(buildBody(["taskA"])),
|
|
});
|
|
expect(res.status).toBe(401);
|
|
});
|
|
|
|
it("private API key: auth passes", async () => {
|
|
const server = getTestServer();
|
|
const seed = await seedTestEnvironment(server.prisma);
|
|
const res = await server.webapp.fetch(path, {
|
|
method: "POST",
|
|
headers: {
|
|
Authorization: `Bearer ${seed.apiKey}`,
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify(buildBody(["taskA"])),
|
|
});
|
|
expect(res.status).not.toBe(401);
|
|
expect(res.status).not.toBe(403);
|
|
});
|
|
|
|
it("JWT with write:tasks (type-level): auth passes", async () => {
|
|
const server = getTestServer();
|
|
const seed = await seedTestEnvironment(server.prisma);
|
|
const jwt = await generateJWT({
|
|
secretKey: seed.apiKey,
|
|
payload: { pub: true, sub: seed.environment.id, scopes: ["write:tasks"] },
|
|
expirationTime: "15m",
|
|
});
|
|
const res = await server.webapp.fetch(path, {
|
|
method: "POST",
|
|
headers: { Authorization: `Bearer ${jwt}`, "Content-Type": "application/json" },
|
|
body: JSON.stringify(buildBody(["taskA", "taskB"])),
|
|
});
|
|
expect(res.status).not.toBe(401);
|
|
expect(res.status).not.toBe(403);
|
|
});
|
|
|
|
it("JWT with batchTrigger:tasks:taskA + body has [taskA, taskB]: 403 (every-task semantics)", async () => {
|
|
// Batch trigger uses AND semantics — every task in the body must
|
|
// be authorized, not just any one of them. A JWT scoped to only
|
|
// taskA cannot submit a batch that also includes taskB, otherwise
|
|
// the caller would be triggering tasks they have no scope for.
|
|
const server = getTestServer();
|
|
const seed = await seedTestEnvironment(server.prisma);
|
|
const jwt = await generateJWT({
|
|
secretKey: seed.apiKey,
|
|
payload: {
|
|
pub: true,
|
|
sub: seed.environment.id,
|
|
scopes: ["batchTrigger:tasks:taskA"],
|
|
},
|
|
expirationTime: "15m",
|
|
});
|
|
const res = await server.webapp.fetch(path, {
|
|
method: "POST",
|
|
headers: { Authorization: `Bearer ${jwt}`, "Content-Type": "application/json" },
|
|
body: JSON.stringify(buildBody(["taskA", "taskB"])),
|
|
});
|
|
expect(res.status).toBe(403);
|
|
});
|
|
|
|
it("JWT with batchTrigger:tasks:taskA + body has [taskA] only: auth passes", async () => {
|
|
// Per-task scope grants per-task access — a batch containing
|
|
// only the authorized task is allowed.
|
|
const server = getTestServer();
|
|
const seed = await seedTestEnvironment(server.prisma);
|
|
const jwt = await generateJWT({
|
|
secretKey: seed.apiKey,
|
|
payload: {
|
|
pub: true,
|
|
sub: seed.environment.id,
|
|
scopes: ["batchTrigger:tasks:taskA"],
|
|
},
|
|
expirationTime: "15m",
|
|
});
|
|
const res = await server.webapp.fetch(path, {
|
|
method: "POST",
|
|
headers: { Authorization: `Bearer ${jwt}`, "Content-Type": "application/json" },
|
|
body: JSON.stringify(buildBody(["taskA"])),
|
|
});
|
|
expect(res.status).not.toBe(401);
|
|
expect(res.status).not.toBe(403);
|
|
});
|
|
|
|
it("JWT with batchTrigger:tasks:<unrelated> + body has only taskA: 403", async () => {
|
|
const server = getTestServer();
|
|
const seed = await seedTestEnvironment(server.prisma);
|
|
const jwt = await generateJWT({
|
|
secretKey: seed.apiKey,
|
|
payload: {
|
|
pub: true,
|
|
sub: seed.environment.id,
|
|
scopes: ["batchTrigger:tasks:not-in-body"],
|
|
},
|
|
expirationTime: "15m",
|
|
});
|
|
const res = await server.webapp.fetch(path, {
|
|
method: "POST",
|
|
headers: { Authorization: `Bearer ${jwt}`, "Content-Type": "application/json" },
|
|
body: JSON.stringify(buildBody(["taskA"])),
|
|
});
|
|
expect(res.status).toBe(403);
|
|
});
|
|
|
|
it("JWT with read:tasks: 403 (action mismatch)", async () => {
|
|
const server = getTestServer();
|
|
const seed = await seedTestEnvironment(server.prisma);
|
|
const jwt = await generateJWT({
|
|
secretKey: seed.apiKey,
|
|
payload: { pub: true, sub: seed.environment.id, scopes: ["read:tasks"] },
|
|
expirationTime: "15m",
|
|
});
|
|
const res = await server.webapp.fetch(path, {
|
|
method: "POST",
|
|
headers: { Authorization: `Bearer ${jwt}`, "Content-Type": "application/json" },
|
|
body: JSON.stringify(buildBody(["taskA"])),
|
|
});
|
|
expect(res.status).toBe(403);
|
|
});
|
|
|
|
it("JWT with admin: auth passes", async () => {
|
|
const server = getTestServer();
|
|
const seed = await seedTestEnvironment(server.prisma);
|
|
const jwt = await generateJWT({
|
|
secretKey: seed.apiKey,
|
|
payload: { pub: true, sub: seed.environment.id, scopes: ["admin"] },
|
|
expirationTime: "15m",
|
|
});
|
|
const res = await server.webapp.fetch(path, {
|
|
method: "POST",
|
|
headers: { Authorization: `Bearer ${jwt}`, "Content-Type": "application/json" },
|
|
body: JSON.stringify(buildBody(["taskA"])),
|
|
});
|
|
expect(res.status).not.toBe(401);
|
|
expect(res.status).not.toBe(403);
|
|
});
|
|
});
|
|
|
|
// v2 batch shares the exact same authorization config as v1 — same
|
|
// body-derived array resource, same batchTrigger action. We don't
|
|
// duplicate the full matrix here; the v1 tests cover the wrapper
|
|
// behaviour. If v2's authorization config ever diverges from v1's,
|
|
// add a targeted test here. For now just sanity-check that the v2
|
|
// route's wiring is alive.
|
|
describe("Trigger task — batch v2 (api.v2.tasks.batch) sanity", () => {
|
|
const path = "/api/v2/tasks/batch";
|
|
|
|
it("missing auth: 401", async () => {
|
|
const server = getTestServer();
|
|
const res = await server.webapp.fetch(path, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ items: [{ task: "t", payload: {} }] }),
|
|
});
|
|
expect(res.status).toBe(401);
|
|
});
|
|
|
|
it("JWT with write:tasks: auth passes", async () => {
|
|
const server = getTestServer();
|
|
const seed = await seedTestEnvironment(server.prisma);
|
|
const jwt = await generateJWT({
|
|
secretKey: seed.apiKey,
|
|
payload: { pub: true, sub: seed.environment.id, scopes: ["write:tasks"] },
|
|
expirationTime: "15m",
|
|
});
|
|
const res = await server.webapp.fetch(path, {
|
|
method: "POST",
|
|
headers: { Authorization: `Bearer ${jwt}`, "Content-Type": "application/json" },
|
|
body: JSON.stringify({ items: [{ task: "t", payload: {} }] }),
|
|
});
|
|
expect(res.status).not.toBe(401);
|
|
expect(res.status).not.toBe(403);
|
|
});
|
|
});
|
|
|
|
// v3 batches use a collection-level resource { type: "tasks" } with
|
|
// no id — items are validated per-row when streamed. So id-specific
|
|
// scopes (write:tasks:foo) shouldn't grant blanket access; only
|
|
// type-level write:tasks (or admin/write:all) should.
|
|
describe("Trigger task — batch v3 (api.v3.batches) collection-level", () => {
|
|
const path = "/api/v3/batches";
|
|
const buildBody = () => ({ runCount: 1 });
|
|
|
|
it("missing auth: 401", async () => {
|
|
const server = getTestServer();
|
|
const res = await server.webapp.fetch(path, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(buildBody()),
|
|
});
|
|
expect(res.status).toBe(401);
|
|
});
|
|
|
|
it("JWT with write:tasks (type-level): auth passes", async () => {
|
|
const server = getTestServer();
|
|
const seed = await seedTestEnvironment(server.prisma);
|
|
const jwt = await generateJWT({
|
|
secretKey: seed.apiKey,
|
|
payload: { pub: true, sub: seed.environment.id, scopes: ["write:tasks"] },
|
|
expirationTime: "15m",
|
|
});
|
|
const res = await server.webapp.fetch(path, {
|
|
method: "POST",
|
|
headers: { Authorization: `Bearer ${jwt}`, "Content-Type": "application/json" },
|
|
body: JSON.stringify(buildBody()),
|
|
});
|
|
expect(res.status).not.toBe(401);
|
|
expect(res.status).not.toBe(403);
|
|
});
|
|
|
|
it("JWT with read:tasks: 403", async () => {
|
|
const server = getTestServer();
|
|
const seed = await seedTestEnvironment(server.prisma);
|
|
const jwt = await generateJWT({
|
|
secretKey: seed.apiKey,
|
|
payload: { pub: true, sub: seed.environment.id, scopes: ["read:tasks"] },
|
|
expirationTime: "15m",
|
|
});
|
|
const res = await server.webapp.fetch(path, {
|
|
method: "POST",
|
|
headers: { Authorization: `Bearer ${jwt}`, "Content-Type": "application/json" },
|
|
body: JSON.stringify(buildBody()),
|
|
});
|
|
expect(res.status).toBe(403);
|
|
});
|
|
|
|
it("JWT with admin: auth passes", async () => {
|
|
const server = getTestServer();
|
|
const seed = await seedTestEnvironment(server.prisma);
|
|
const jwt = await generateJWT({
|
|
secretKey: seed.apiKey,
|
|
payload: { pub: true, sub: seed.environment.id, scopes: ["admin"] },
|
|
expirationTime: "15m",
|
|
});
|
|
const res = await server.webapp.fetch(path, {
|
|
method: "POST",
|
|
headers: { Authorization: `Bearer ${jwt}`, "Content-Type": "application/json" },
|
|
body: JSON.stringify(buildBody()),
|
|
});
|
|
expect(res.status).not.toBe(401);
|
|
expect(res.status).not.toBe(403);
|
|
});
|
|
});
|
|
|
|
// 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 }, { type: "tasks", id: B }
|
|
// - GET /realtime/v1/runs: ?tags=foo,bar → +{ type: "tags", id: "foo" }, { type: "tags", id: "bar" }
|
|
//
|
|
// Multi-key any-match contract from TRI-8719: a JWT with a scope
|
|
// matching ANY element of the resource array grants access. So:
|
|
// - read:runs → matches the collection key → passes
|
|
// - read:tasks:A (with A in filter) → matches an array element → passes
|
|
// - read:tasks:Z (with A in filter) → no match → 403
|
|
describe("Run list — api.v1.runs (multi-key tasks)", () => {
|
|
const path = "/api/v1/runs";
|
|
|
|
async function get(query: string, headers: Record<string, string>) {
|
|
return getTestServer().webapp.fetch(`${path}${query}`, { headers });
|
|
}
|
|
|
|
it("missing auth: 401", async () => {
|
|
const res = await getTestServer().webapp.fetch(path);
|
|
expect(res.status).toBe(401);
|
|
});
|
|
|
|
// Pass cases on api.v1.runs assert "auth passed" (not 401/403)
|
|
// rather than strict 200. The handler hits ClickHouse which isn't
|
|
// reachable from the test container — the endpoint can 500 in
|
|
// tests even when auth is fine. The auth layer is what we're
|
|
// verifying here.
|
|
it("private API key: auth passes", async () => {
|
|
const server = getTestServer();
|
|
const seed = await seedTestEnvironment(server.prisma);
|
|
const res = await get("", { Authorization: `Bearer ${seed.apiKey}` });
|
|
expect(res.status).not.toBe(401);
|
|
expect(res.status).not.toBe(403);
|
|
});
|
|
|
|
it("JWT with read:runs (collection-level): auth passes", async () => {
|
|
const server = getTestServer();
|
|
const seed = await seedTestEnvironment(server.prisma);
|
|
const jwt = await generateJWT({
|
|
secretKey: seed.apiKey,
|
|
payload: { pub: true, sub: seed.environment.id, scopes: ["read:runs"] },
|
|
expirationTime: "15m",
|
|
});
|
|
const res = await get("", { Authorization: `Bearer ${jwt}` });
|
|
expect(res.status).not.toBe(401);
|
|
expect(res.status).not.toBe(403);
|
|
});
|
|
|
|
it("JWT with read:all super-scope: auth passes", async () => {
|
|
const server = getTestServer();
|
|
const seed = await seedTestEnvironment(server.prisma);
|
|
const jwt = await generateJWT({
|
|
secretKey: seed.apiKey,
|
|
payload: { pub: true, sub: seed.environment.id, scopes: ["read:all"] },
|
|
expirationTime: "15m",
|
|
});
|
|
const res = await get("", { Authorization: `Bearer ${jwt}` });
|
|
expect(res.status).not.toBe(401);
|
|
expect(res.status).not.toBe(403);
|
|
});
|
|
|
|
it("JWT with admin: auth passes", async () => {
|
|
const server = getTestServer();
|
|
const seed = await seedTestEnvironment(server.prisma);
|
|
const jwt = await generateJWT({
|
|
secretKey: seed.apiKey,
|
|
payload: { pub: true, sub: seed.environment.id, scopes: ["admin"] },
|
|
expirationTime: "15m",
|
|
});
|
|
const res = await get("", { Authorization: `Bearer ${jwt}` });
|
|
expect(res.status).not.toBe(401);
|
|
expect(res.status).not.toBe(403);
|
|
});
|
|
|
|
it("JWT with empty scopes: 403", async () => {
|
|
const server = getTestServer();
|
|
const seed = await seedTestEnvironment(server.prisma);
|
|
const jwt = await generateJWT({
|
|
secretKey: seed.apiKey,
|
|
payload: { pub: true, sub: seed.environment.id, scopes: [] },
|
|
expirationTime: "15m",
|
|
});
|
|
const res = await get("", { Authorization: `Bearer ${jwt}` });
|
|
expect(res.status).toBe(403);
|
|
});
|
|
|
|
it("JWT with write:runs (action mismatch — read route): 403", async () => {
|
|
const server = getTestServer();
|
|
const seed = await seedTestEnvironment(server.prisma);
|
|
const jwt = await generateJWT({
|
|
secretKey: seed.apiKey,
|
|
payload: { pub: true, sub: seed.environment.id, scopes: ["write:runs"] },
|
|
expirationTime: "15m",
|
|
});
|
|
const res = await get("", { Authorization: `Bearer ${jwt}` });
|
|
expect(res.status).toBe(403);
|
|
});
|
|
|
|
it("filter[taskIdentifier]=task_a,task_b + JWT read:tasks:task_a → passes (array match)", async () => {
|
|
const server = getTestServer();
|
|
const seed = await seedTestEnvironment(server.prisma);
|
|
const jwt = await generateJWT({
|
|
secretKey: seed.apiKey,
|
|
payload: {
|
|
pub: true,
|
|
sub: seed.environment.id,
|
|
scopes: ["read:tasks:task_a"],
|
|
},
|
|
expirationTime: "15m",
|
|
});
|
|
const res = await get("?filter%5BtaskIdentifier%5D=task_a%2Ctask_b", {
|
|
Authorization: `Bearer ${jwt}`,
|
|
});
|
|
// Resource array is [{type:"runs"}, {type:"tasks",id:"task_a"}, {type:"tasks",id:"task_b"}].
|
|
// The scope read:tasks:task_a matches the second element → access granted.
|
|
// Handler may 500 (ClickHouse unreachable in tests) but auth passed.
|
|
expect(res.status).not.toBe(401);
|
|
expect(res.status).not.toBe(403);
|
|
});
|
|
|
|
it("filter[taskIdentifier]=task_a + JWT read:tasks:task_z → 403 (no array match)", async () => {
|
|
const server = getTestServer();
|
|
const seed = await seedTestEnvironment(server.prisma);
|
|
const jwt = await generateJWT({
|
|
secretKey: seed.apiKey,
|
|
payload: {
|
|
pub: true,
|
|
sub: seed.environment.id,
|
|
scopes: ["read:tasks:task_z"],
|
|
},
|
|
expirationTime: "15m",
|
|
});
|
|
const res = await get("?filter%5BtaskIdentifier%5D=task_a", {
|
|
Authorization: `Bearer ${jwt}`,
|
|
});
|
|
// Resource is [{runs}, {tasks:task_a}]. JWT scope says
|
|
// read:tasks:task_z which doesn't match the runs collection
|
|
// (wrong type) or the task_a element (wrong id). 403.
|
|
expect(res.status).toBe(403);
|
|
});
|
|
});
|
|
|
|
describe("Run list — realtime.v1.runs (multi-key tags)", () => {
|
|
const path = "/realtime/v1/runs";
|
|
|
|
async function get(query: string, headers: Record<string, string>) {
|
|
return getTestServer().webapp.fetch(`${path}${query}`, { headers });
|
|
}
|
|
|
|
it("missing auth: 401", async () => {
|
|
const res = await getTestServer().webapp.fetch(path);
|
|
expect(res.status).toBe(401);
|
|
});
|
|
|
|
it("JWT with read:runs (collection-level): auth passes", async () => {
|
|
const server = getTestServer();
|
|
const seed = await seedTestEnvironment(server.prisma);
|
|
const jwt = await generateJWT({
|
|
secretKey: seed.apiKey,
|
|
payload: { pub: true, sub: seed.environment.id, scopes: ["read:runs"] },
|
|
expirationTime: "15m",
|
|
});
|
|
const res = await get("", { Authorization: `Bearer ${jwt}` });
|
|
// Realtime endpoints stream — the route may return 200 (streaming
|
|
// OK) or other status codes depending on streams setup. We only
|
|
// care that auth passed: NOT 401/403.
|
|
expect(res.status).not.toBe(401);
|
|
expect(res.status).not.toBe(403);
|
|
});
|
|
|
|
it("JWT with read:tags:foo + ?tags=foo,bar → passes (array match)", async () => {
|
|
const server = getTestServer();
|
|
const seed = await seedTestEnvironment(server.prisma);
|
|
const jwt = await generateJWT({
|
|
secretKey: seed.apiKey,
|
|
payload: {
|
|
pub: true,
|
|
sub: seed.environment.id,
|
|
scopes: ["read:tags:foo"],
|
|
},
|
|
expirationTime: "15m",
|
|
});
|
|
const res = await get("?tags=foo,bar", { Authorization: `Bearer ${jwt}` });
|
|
// Resource array is [{type:"runs"}, {type:"tags",id:"foo"}, {type:"tags",id:"bar"}].
|
|
// Scope matches the foo element → access granted.
|
|
expect(res.status).not.toBe(401);
|
|
expect(res.status).not.toBe(403);
|
|
});
|
|
|
|
it("JWT with read:tags:baz + ?tags=foo → 403 (no array match)", async () => {
|
|
const server = getTestServer();
|
|
const seed = await seedTestEnvironment(server.prisma);
|
|
const jwt = await generateJWT({
|
|
secretKey: seed.apiKey,
|
|
payload: {
|
|
pub: true,
|
|
sub: seed.environment.id,
|
|
scopes: ["read:tags:baz"],
|
|
},
|
|
expirationTime: "15m",
|
|
});
|
|
const res = await get("?tags=foo", { Authorization: `Bearer ${jwt}` });
|
|
expect(res.status).toBe(403);
|
|
});
|
|
|
|
it("JWT with admin: auth passes", async () => {
|
|
const server = getTestServer();
|
|
const seed = await seedTestEnvironment(server.prisma);
|
|
const jwt = await generateJWT({
|
|
secretKey: seed.apiKey,
|
|
payload: { pub: true, sub: seed.environment.id, scopes: ["admin"] },
|
|
expirationTime: "15m",
|
|
});
|
|
const res = await get("", { Authorization: `Bearer ${jwt}` });
|
|
expect(res.status).not.toBe(401);
|
|
expect(res.status).not.toBe(403);
|
|
});
|
|
|
|
it("JWT with write:runs (action mismatch): 403", async () => {
|
|
const server = getTestServer();
|
|
const seed = await seedTestEnvironment(server.prisma);
|
|
const jwt = await generateJWT({
|
|
secretKey: seed.apiKey,
|
|
payload: { pub: true, sub: seed.environment.id, scopes: ["write:runs"] },
|
|
expirationTime: "15m",
|
|
});
|
|
const res = await get("", { Authorization: `Bearer ${jwt}` });
|
|
expect(res.status).toBe(403);
|
|
});
|
|
});
|
|
|
|
// Run mutations (TRI-8735). Two routes:
|
|
// - POST /api/v2/runs/:runParam/cancel
|
|
// action: write, resource: { type: "runs", id: params.runParam }
|
|
// — single id-keyed resource, supports id-specific scopes.
|
|
// - POST /api/v1/idempotencyKeys/:key/reset
|
|
// action: write, resource: { type: "runs" } (collection-level)
|
|
// — id-specific scopes don't grant blanket access; only
|
|
// type-level write:runs (or super-scopes) work.
|
|
//
|
|
// The legacy idempotencyKeys/:key/reset rejected ALL JWTs due to an
|
|
// empty-resource bug. Post TRI-8719 the empty-resource resolution
|
|
// lets write:runs JWTs through. Tests here lock in the new behaviour.
|
|
describe("Run mutations — cancel (api.v2.runs.$runParam.cancel)", () => {
|
|
const pathFor = (runId: string) => `/api/v2/runs/${runId}/cancel`;
|
|
const post = (path: string, headers: Record<string, string>) =>
|
|
getTestServer().webapp.fetch(path, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json", ...headers },
|
|
body: JSON.stringify({}),
|
|
});
|
|
|
|
it("missing auth: 401", async () => {
|
|
const res = await post(pathFor("run_anything"), {});
|
|
expect(res.status).toBe(401);
|
|
});
|
|
|
|
it("invalid API key: 401", async () => {
|
|
const res = await post(pathFor("run_anything"), {
|
|
Authorization: "Bearer tr_dev_definitely_not_real_key",
|
|
});
|
|
expect(res.status).toBe(401);
|
|
});
|
|
|
|
it("private API key on real run: auth passes", async () => {
|
|
const server = getTestServer();
|
|
const seed = await seedTestEnvironment(server.prisma);
|
|
const { runFriendlyId } = await seedTestRun(server.prisma, {
|
|
environmentId: seed.environment.id,
|
|
projectId: seed.project.id,
|
|
});
|
|
const res = await post(pathFor(runFriendlyId), {
|
|
Authorization: `Bearer ${seed.apiKey}`,
|
|
});
|
|
// Auth + findResource passed; handler may return any 2xx/4xx
|
|
// depending on run state. We only care: not 401/403.
|
|
expect(res.status).not.toBe(401);
|
|
expect(res.status).not.toBe(403);
|
|
});
|
|
|
|
it("JWT with write:runs (type-level): auth passes", async () => {
|
|
const server = getTestServer();
|
|
const seed = await seedTestEnvironment(server.prisma);
|
|
const { runFriendlyId } = await seedTestRun(server.prisma, {
|
|
environmentId: seed.environment.id,
|
|
projectId: seed.project.id,
|
|
});
|
|
const jwt = await generateJWT({
|
|
secretKey: seed.apiKey,
|
|
payload: { pub: true, sub: seed.environment.id, scopes: ["write:runs"] },
|
|
expirationTime: "15m",
|
|
});
|
|
const res = await post(pathFor(runFriendlyId), {
|
|
Authorization: `Bearer ${jwt}`,
|
|
});
|
|
expect(res.status).not.toBe(401);
|
|
expect(res.status).not.toBe(403);
|
|
});
|
|
|
|
it("JWT with write:runs:<exact runId>: auth passes", async () => {
|
|
const server = getTestServer();
|
|
const seed = await seedTestEnvironment(server.prisma);
|
|
const { runFriendlyId } = await seedTestRun(server.prisma, {
|
|
environmentId: seed.environment.id,
|
|
projectId: seed.project.id,
|
|
});
|
|
const jwt = await generateJWT({
|
|
secretKey: seed.apiKey,
|
|
payload: {
|
|
pub: true,
|
|
sub: seed.environment.id,
|
|
scopes: [`write:runs:${runFriendlyId}`],
|
|
},
|
|
expirationTime: "15m",
|
|
});
|
|
const res = await post(pathFor(runFriendlyId), {
|
|
Authorization: `Bearer ${jwt}`,
|
|
});
|
|
expect(res.status).not.toBe(401);
|
|
expect(res.status).not.toBe(403);
|
|
});
|
|
|
|
it("JWT with write:runs:<other>: 403", async () => {
|
|
const server = getTestServer();
|
|
const seed = await seedTestEnvironment(server.prisma);
|
|
const { runFriendlyId } = await seedTestRun(server.prisma, {
|
|
environmentId: seed.environment.id,
|
|
projectId: seed.project.id,
|
|
});
|
|
const jwt = await generateJWT({
|
|
secretKey: seed.apiKey,
|
|
payload: {
|
|
pub: true,
|
|
sub: seed.environment.id,
|
|
scopes: ["write:runs:run_someoneelse00000000000"],
|
|
},
|
|
expirationTime: "15m",
|
|
});
|
|
const res = await post(pathFor(runFriendlyId), {
|
|
Authorization: `Bearer ${jwt}`,
|
|
});
|
|
expect(res.status).toBe(403);
|
|
});
|
|
|
|
it("JWT with read:runs (action mismatch): 403", async () => {
|
|
const server = getTestServer();
|
|
const seed = await seedTestEnvironment(server.prisma);
|
|
const { runFriendlyId } = await seedTestRun(server.prisma, {
|
|
environmentId: seed.environment.id,
|
|
projectId: seed.project.id,
|
|
});
|
|
const jwt = await generateJWT({
|
|
secretKey: seed.apiKey,
|
|
payload: {
|
|
pub: true,
|
|
sub: seed.environment.id,
|
|
scopes: [`read:runs:${runFriendlyId}`],
|
|
},
|
|
expirationTime: "15m",
|
|
});
|
|
const res = await post(pathFor(runFriendlyId), {
|
|
Authorization: `Bearer ${jwt}`,
|
|
});
|
|
expect(res.status).toBe(403);
|
|
});
|
|
|
|
it("JWT with write:all super-scope: auth passes", async () => {
|
|
const server = getTestServer();
|
|
const seed = await seedTestEnvironment(server.prisma);
|
|
const { runFriendlyId } = await seedTestRun(server.prisma, {
|
|
environmentId: seed.environment.id,
|
|
projectId: seed.project.id,
|
|
});
|
|
const jwt = await generateJWT({
|
|
secretKey: seed.apiKey,
|
|
payload: { pub: true, sub: seed.environment.id, scopes: ["write:all"] },
|
|
expirationTime: "15m",
|
|
});
|
|
const res = await post(pathFor(runFriendlyId), {
|
|
Authorization: `Bearer ${jwt}`,
|
|
});
|
|
expect(res.status).not.toBe(401);
|
|
expect(res.status).not.toBe(403);
|
|
});
|
|
|
|
it("JWT with admin: auth passes", async () => {
|
|
const server = getTestServer();
|
|
const seed = await seedTestEnvironment(server.prisma);
|
|
const { runFriendlyId } = await seedTestRun(server.prisma, {
|
|
environmentId: seed.environment.id,
|
|
projectId: seed.project.id,
|
|
});
|
|
const jwt = await generateJWT({
|
|
secretKey: seed.apiKey,
|
|
payload: { pub: true, sub: seed.environment.id, scopes: ["admin"] },
|
|
expirationTime: "15m",
|
|
});
|
|
const res = await post(pathFor(runFriendlyId), {
|
|
Authorization: `Bearer ${jwt}`,
|
|
});
|
|
expect(res.status).not.toBe(401);
|
|
expect(res.status).not.toBe(403);
|
|
});
|
|
});
|
|
|
|
describe("Run mutations — idempotencyKeys.reset (api.v1.idempotencyKeys.$key.reset)", () => {
|
|
// Collection-level resource { type: "runs" } — id-specific
|
|
// write:runs:<runId> scopes don't help here (no id to match).
|
|
// The legacy version of this route rejected ALL JWTs due to an
|
|
// empty-resource bug; the post-TRI-8719 path lets write:runs
|
|
// through. Tests below pin that down.
|
|
const path = "/api/v1/idempotencyKeys/some-key/reset";
|
|
const validBody = JSON.stringify({ taskIdentifier: "test-task" });
|
|
|
|
const post = (headers: Record<string, string>, body = validBody) =>
|
|
getTestServer().webapp.fetch(path, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json", ...headers },
|
|
body,
|
|
});
|
|
|
|
it("missing auth: 401", async () => {
|
|
const res = await post({});
|
|
expect(res.status).toBe(401);
|
|
});
|
|
|
|
it("invalid API key: 401", async () => {
|
|
const res = await post({ Authorization: "Bearer tr_dev_invalid" });
|
|
expect(res.status).toBe(401);
|
|
});
|
|
|
|
it("private API key: auth passes", async () => {
|
|
const server = getTestServer();
|
|
const seed = await seedTestEnvironment(server.prisma);
|
|
const res = await post({ Authorization: `Bearer ${seed.apiKey}` });
|
|
// Handler may 404/204 depending on whether the idempotency key
|
|
// exists. Auth-passed assertion only.
|
|
expect(res.status).not.toBe(401);
|
|
expect(res.status).not.toBe(403);
|
|
});
|
|
|
|
it("JWT with write:runs (type-level): auth passes — locks in TRI-8719 fix", async () => {
|
|
const server = getTestServer();
|
|
const seed = await seedTestEnvironment(server.prisma);
|
|
const jwt = await generateJWT({
|
|
secretKey: seed.apiKey,
|
|
payload: { pub: true, sub: seed.environment.id, scopes: ["write:runs"] },
|
|
expirationTime: "15m",
|
|
});
|
|
const res = await post({ Authorization: `Bearer ${jwt}` });
|
|
// PRE-TRI-8719: this returned 403 (legacy empty-resource bug
|
|
// rejected all JWTs). POST-TRI-8719: write:runs grants access.
|
|
// Locking in the new behaviour.
|
|
expect(res.status).not.toBe(401);
|
|
expect(res.status).not.toBe(403);
|
|
});
|
|
|
|
it("JWT with read:runs (action mismatch): 403", async () => {
|
|
const server = getTestServer();
|
|
const seed = await seedTestEnvironment(server.prisma);
|
|
const jwt = await generateJWT({
|
|
secretKey: seed.apiKey,
|
|
payload: { pub: true, sub: seed.environment.id, scopes: ["read:runs"] },
|
|
expirationTime: "15m",
|
|
});
|
|
const res = await post({ Authorization: `Bearer ${jwt}` });
|
|
expect(res.status).toBe(403);
|
|
});
|
|
|
|
it("JWT with write:all: auth passes", async () => {
|
|
const server = getTestServer();
|
|
const seed = await seedTestEnvironment(server.prisma);
|
|
const jwt = await generateJWT({
|
|
secretKey: seed.apiKey,
|
|
payload: { pub: true, sub: seed.environment.id, scopes: ["write:all"] },
|
|
expirationTime: "15m",
|
|
});
|
|
const res = await post({ Authorization: `Bearer ${jwt}` });
|
|
expect(res.status).not.toBe(401);
|
|
expect(res.status).not.toBe(403);
|
|
});
|
|
|
|
it("JWT with admin: auth passes", async () => {
|
|
const server = getTestServer();
|
|
const seed = await seedTestEnvironment(server.prisma);
|
|
const jwt = await generateJWT({
|
|
secretKey: seed.apiKey,
|
|
payload: { pub: true, sub: seed.environment.id, scopes: ["admin"] },
|
|
expirationTime: "15m",
|
|
});
|
|
const res = await post({ Authorization: `Bearer ${jwt}` });
|
|
expect(res.status).not.toBe(401);
|
|
expect(res.status).not.toBe(403);
|
|
});
|
|
});
|
|
|
|
// Run resource routes (TRI-8734). Every read-side `$runId` route
|
|
// computes its authorization resource from the loaded TaskRun:
|
|
// [
|
|
// { type: "runs", id: run.friendlyId },
|
|
// { type: "tasks", id: run.taskIdentifier },
|
|
// ...run.runTags.map(tag => ({ type: "tags", id: tag })),
|
|
// run.batch?.friendlyId && { type: "batch", id: run.batch.friendlyId },
|
|
// ]
|
|
//
|
|
// A JWT scope matching ANY array element grants access. We test the
|
|
// full matrix against the canonical route (api.v3.runs.$runId), and
|
|
// a sanity check on one of the others to confirm the wiring isn't
|
|
// route-local. If a future route's resource shape diverges, add a
|
|
// targeted describe.
|
|
describe("Run resource — GET /api/v3/runs/:runId (multi-key array)", () => {
|
|
const pathFor = (runId: string) => `/api/v3/runs/${runId}`;
|
|
|
|
async function seedRunWithBatchAndTags() {
|
|
const server = getTestServer();
|
|
const seed = await seedTestEnvironment(server.prisma);
|
|
const seeded = await seedTestRun(server.prisma, {
|
|
environmentId: seed.environment.id,
|
|
projectId: seed.project.id,
|
|
runTags: ["alpha", "beta"],
|
|
withBatch: true,
|
|
});
|
|
return { ...seed, ...seeded };
|
|
}
|
|
|
|
const get = (path: string, headers: Record<string, string>) =>
|
|
getTestServer().webapp.fetch(path, { headers });
|
|
|
|
it("missing auth: 401", async () => {
|
|
const res = await get(pathFor("run_anything"), {});
|
|
expect(res.status).toBe(401);
|
|
});
|
|
|
|
it("invalid API key: 401", async () => {
|
|
const res = await get(pathFor("run_anything"), {
|
|
Authorization: "Bearer tr_dev_invalid",
|
|
});
|
|
expect(res.status).toBe(401);
|
|
});
|
|
|
|
it("private API key on real run: auth passes", async () => {
|
|
const { runFriendlyId, apiKey } = await seedRunWithBatchAndTags();
|
|
const res = await get(pathFor(runFriendlyId), {
|
|
Authorization: `Bearer ${apiKey}`,
|
|
});
|
|
expect(res.status).not.toBe(401);
|
|
expect(res.status).not.toBe(403);
|
|
});
|
|
|
|
it("JWT read:runs (type-level): auth passes", async () => {
|
|
const { runFriendlyId, apiKey, environment } = await seedRunWithBatchAndTags();
|
|
const jwt = await generateJWT({
|
|
secretKey: apiKey,
|
|
payload: { pub: true, sub: environment.id, scopes: ["read:runs"] },
|
|
expirationTime: "15m",
|
|
});
|
|
const res = await get(pathFor(runFriendlyId), { Authorization: `Bearer ${jwt}` });
|
|
expect(res.status).not.toBe(401);
|
|
expect(res.status).not.toBe(403);
|
|
});
|
|
|
|
it("JWT read:runs:<exact friendlyId>: auth passes (id match)", async () => {
|
|
const { runFriendlyId, apiKey, environment } = await seedRunWithBatchAndTags();
|
|
const jwt = await generateJWT({
|
|
secretKey: apiKey,
|
|
payload: {
|
|
pub: true,
|
|
sub: environment.id,
|
|
scopes: [`read:runs:${runFriendlyId}`],
|
|
},
|
|
expirationTime: "15m",
|
|
});
|
|
const res = await get(pathFor(runFriendlyId), { Authorization: `Bearer ${jwt}` });
|
|
expect(res.status).not.toBe(401);
|
|
expect(res.status).not.toBe(403);
|
|
});
|
|
|
|
it("JWT read:runs:<other>: 403", async () => {
|
|
const { runFriendlyId, apiKey, environment } = await seedRunWithBatchAndTags();
|
|
const jwt = await generateJWT({
|
|
secretKey: apiKey,
|
|
payload: {
|
|
pub: true,
|
|
sub: environment.id,
|
|
scopes: ["read:runs:run_someoneelse00000000000"],
|
|
},
|
|
expirationTime: "15m",
|
|
});
|
|
const res = await get(pathFor(runFriendlyId), { Authorization: `Bearer ${jwt}` });
|
|
expect(res.status).toBe(403);
|
|
});
|
|
|
|
it("JWT read:tags:<tag the run has>: auth passes (array element match)", async () => {
|
|
const { runFriendlyId, apiKey, environment } = await seedRunWithBatchAndTags();
|
|
// run was seeded with runTags=["alpha","beta"]; scope matches "alpha".
|
|
const jwt = await generateJWT({
|
|
secretKey: apiKey,
|
|
payload: { pub: true, sub: environment.id, scopes: ["read:tags:alpha"] },
|
|
expirationTime: "15m",
|
|
});
|
|
const res = await get(pathFor(runFriendlyId), { Authorization: `Bearer ${jwt}` });
|
|
expect(res.status).not.toBe(401);
|
|
expect(res.status).not.toBe(403);
|
|
});
|
|
|
|
it("JWT read:tags:<tag the run does not have>: 403", async () => {
|
|
const { runFriendlyId, apiKey, environment } = await seedRunWithBatchAndTags();
|
|
const jwt = await generateJWT({
|
|
secretKey: apiKey,
|
|
payload: { pub: true, sub: environment.id, scopes: ["read:tags:gamma"] },
|
|
expirationTime: "15m",
|
|
});
|
|
const res = await get(pathFor(runFriendlyId), { Authorization: `Bearer ${jwt}` });
|
|
expect(res.status).toBe(403);
|
|
});
|
|
|
|
it("JWT read:batch:<run's batchFriendlyId>: auth passes", async () => {
|
|
const { runFriendlyId, batchFriendlyId, apiKey, environment } =
|
|
await seedRunWithBatchAndTags();
|
|
const jwt = await generateJWT({
|
|
secretKey: apiKey,
|
|
payload: {
|
|
pub: true,
|
|
sub: environment.id,
|
|
scopes: [`read:batch:${batchFriendlyId}`],
|
|
},
|
|
expirationTime: "15m",
|
|
});
|
|
const res = await get(pathFor(runFriendlyId), { Authorization: `Bearer ${jwt}` });
|
|
expect(res.status).not.toBe(401);
|
|
expect(res.status).not.toBe(403);
|
|
});
|
|
|
|
it("JWT read:batch:<other>: 403", async () => {
|
|
const { runFriendlyId, apiKey, environment } = await seedRunWithBatchAndTags();
|
|
const jwt = await generateJWT({
|
|
secretKey: apiKey,
|
|
payload: {
|
|
pub: true,
|
|
sub: environment.id,
|
|
scopes: ["read:batch:batch_someoneelse00000000"],
|
|
},
|
|
expirationTime: "15m",
|
|
});
|
|
const res = await get(pathFor(runFriendlyId), { Authorization: `Bearer ${jwt}` });
|
|
expect(res.status).toBe(403);
|
|
});
|
|
|
|
it("JWT read:tasks:<run's taskIdentifier>: auth passes", async () => {
|
|
const { runFriendlyId, apiKey, environment } = await seedRunWithBatchAndTags();
|
|
// seedTestRun uses taskIdentifier "test-task" by default.
|
|
const jwt = await generateJWT({
|
|
secretKey: apiKey,
|
|
payload: { pub: true, sub: environment.id, scopes: ["read:tasks:test-task"] },
|
|
expirationTime: "15m",
|
|
});
|
|
const res = await get(pathFor(runFriendlyId), { Authorization: `Bearer ${jwt}` });
|
|
expect(res.status).not.toBe(401);
|
|
expect(res.status).not.toBe(403);
|
|
});
|
|
|
|
it("JWT read:all: auth passes", async () => {
|
|
const { runFriendlyId, apiKey, environment } = await seedRunWithBatchAndTags();
|
|
const jwt = await generateJWT({
|
|
secretKey: apiKey,
|
|
payload: { pub: true, sub: environment.id, scopes: ["read:all"] },
|
|
expirationTime: "15m",
|
|
});
|
|
const res = await get(pathFor(runFriendlyId), { Authorization: `Bearer ${jwt}` });
|
|
expect(res.status).not.toBe(401);
|
|
expect(res.status).not.toBe(403);
|
|
});
|
|
|
|
it("JWT admin: auth passes", async () => {
|
|
const { runFriendlyId, apiKey, environment } = await seedRunWithBatchAndTags();
|
|
const jwt = await generateJWT({
|
|
secretKey: apiKey,
|
|
payload: { pub: true, sub: environment.id, scopes: ["admin"] },
|
|
expirationTime: "15m",
|
|
});
|
|
const res = await get(pathFor(runFriendlyId), { Authorization: `Bearer ${jwt}` });
|
|
expect(res.status).not.toBe(401);
|
|
expect(res.status).not.toBe(403);
|
|
});
|
|
|
|
it("JWT write:runs:<friendlyId>: 403 (action mismatch — read route)", async () => {
|
|
const { runFriendlyId, apiKey, environment } = await seedRunWithBatchAndTags();
|
|
const jwt = await generateJWT({
|
|
secretKey: apiKey,
|
|
payload: {
|
|
pub: true,
|
|
sub: environment.id,
|
|
scopes: [`write:runs:${runFriendlyId}`],
|
|
},
|
|
expirationTime: "15m",
|
|
});
|
|
const res = await get(pathFor(runFriendlyId), { Authorization: `Bearer ${jwt}` });
|
|
expect(res.status).toBe(403);
|
|
});
|
|
|
|
it("cross-env: env A's JWT cannot read env B's run: not 200", async () => {
|
|
const server = getTestServer();
|
|
const a = await seedTestEnvironment(server.prisma);
|
|
const b = await seedRunWithBatchAndTags();
|
|
const jwt = await generateJWT({
|
|
secretKey: a.apiKey,
|
|
payload: {
|
|
pub: true,
|
|
sub: a.environment.id,
|
|
scopes: [`read:runs:${b.runFriendlyId}`],
|
|
},
|
|
expirationTime: "15m",
|
|
});
|
|
const res = await get(pathFor(b.runFriendlyId), { Authorization: `Bearer ${jwt}` });
|
|
// Either auth fails or the run lookup misses (env A's view of
|
|
// the run doesn't include env B's data). Critical: NOT 200.
|
|
expect(res.status).not.toBe(200);
|
|
});
|
|
});
|
|
|
|
// Sanity check: same multi-key pattern wired the same way on the
|
|
// events sub-route. If this drifts in the future the divergence
|
|
// gets a dedicated describe.
|
|
describe("Run resource — GET /api/v1/runs/:runId/events (sanity)", () => {
|
|
const pathFor = (runId: string) => `/api/v1/runs/${runId}/events`;
|
|
|
|
it("missing auth: 401", async () => {
|
|
const res = await getTestServer().webapp.fetch(pathFor("run_anything"));
|
|
expect(res.status).toBe(401);
|
|
});
|
|
|
|
it("JWT read:runs (type-level): auth passes on a real run", async () => {
|
|
const server = getTestServer();
|
|
const seed = await seedTestEnvironment(server.prisma);
|
|
const { runFriendlyId } = await seedTestRun(server.prisma, {
|
|
environmentId: seed.environment.id,
|
|
projectId: seed.project.id,
|
|
});
|
|
const jwt = await generateJWT({
|
|
secretKey: seed.apiKey,
|
|
payload: { pub: true, sub: seed.environment.id, scopes: ["read:runs"] },
|
|
expirationTime: "15m",
|
|
});
|
|
const res = await getTestServer().webapp.fetch(pathFor(runFriendlyId), {
|
|
headers: { Authorization: `Bearer ${jwt}` },
|
|
});
|
|
expect(res.status).not.toBe(401);
|
|
expect(res.status).not.toBe(403);
|
|
});
|
|
});
|
|
|
|
// Batch resources (TRI-8737). Per-batch retrieve + realtime
|
|
// endpoints — single-id resource `{ type: "batch", id: batch.friendlyId }`.
|
|
// The list endpoint (`GET /api/v1/batches`) is currently absent
|
|
// from this branch (deleted in s3-switchover), so the list-
|
|
// section of the matrix is N/A here. If/when the list endpoint
|
|
// returns, add a list-side describe.
|
|
//
|
|
// Notable behaviour: the route's resource is `{ type: "batch" }`,
|
|
// NOT `{ type: "runs" }`. The legacy literal-match escape that
|
|
// let `read:runs` JWTs hit batch endpoints no longer applies.
|
|
// Tests pin this down (a `read:runs` scope on a `{ type: "batch" }`
|
|
// resource is a type mismatch → 403).
|
|
describe("Batch retrieve — GET /api/v1/batches/:batchId", () => {
|
|
const pathFor = (batchId: string) => `/api/v1/batches/${batchId}`;
|
|
|
|
async function seedRunWithBatch() {
|
|
const server = getTestServer();
|
|
const seed = await seedTestEnvironment(server.prisma);
|
|
const seeded = await seedTestRun(server.prisma, {
|
|
environmentId: seed.environment.id,
|
|
projectId: seed.project.id,
|
|
withBatch: true,
|
|
});
|
|
// batchFriendlyId is guaranteed when withBatch is set.
|
|
if (!seeded.batchFriendlyId) {
|
|
throw new Error("seedTestRun({ withBatch: true }) didn't return a batchFriendlyId");
|
|
}
|
|
return { ...seed, batchFriendlyId: seeded.batchFriendlyId };
|
|
}
|
|
|
|
const get = (path: string, headers: Record<string, string>) =>
|
|
getTestServer().webapp.fetch(path, { headers });
|
|
|
|
it("missing auth: 401", async () => {
|
|
const res = await get(pathFor("batch_anything"), {});
|
|
expect(res.status).toBe(401);
|
|
});
|
|
|
|
it("invalid API key: 401", async () => {
|
|
const res = await get(pathFor("batch_anything"), {
|
|
Authorization: "Bearer tr_dev_invalid",
|
|
});
|
|
expect(res.status).toBe(401);
|
|
});
|
|
|
|
it("private API key on real batch: auth passes", async () => {
|
|
const { batchFriendlyId, apiKey } = await seedRunWithBatch();
|
|
const res = await get(pathFor(batchFriendlyId), {
|
|
Authorization: `Bearer ${apiKey}`,
|
|
});
|
|
expect(res.status).not.toBe(401);
|
|
expect(res.status).not.toBe(403);
|
|
});
|
|
|
|
it("JWT read:batch:<friendlyId> matching: auth passes", async () => {
|
|
const { batchFriendlyId, apiKey, environment } = await seedRunWithBatch();
|
|
const jwt = await generateJWT({
|
|
secretKey: apiKey,
|
|
payload: {
|
|
pub: true,
|
|
sub: environment.id,
|
|
scopes: [`read:batch:${batchFriendlyId}`],
|
|
},
|
|
expirationTime: "15m",
|
|
});
|
|
const res = await get(pathFor(batchFriendlyId), { Authorization: `Bearer ${jwt}` });
|
|
expect(res.status).not.toBe(401);
|
|
expect(res.status).not.toBe(403);
|
|
});
|
|
|
|
it("JWT read:batch:<other>: 403", async () => {
|
|
const { batchFriendlyId, apiKey, environment } = await seedRunWithBatch();
|
|
const jwt = await generateJWT({
|
|
secretKey: apiKey,
|
|
payload: {
|
|
pub: true,
|
|
sub: environment.id,
|
|
scopes: ["read:batch:batch_someoneelse00000000"],
|
|
},
|
|
expirationTime: "15m",
|
|
});
|
|
const res = await get(pathFor(batchFriendlyId), { Authorization: `Bearer ${jwt}` });
|
|
expect(res.status).toBe(403);
|
|
});
|
|
|
|
it("JWT read:batch (type-level): auth passes", async () => {
|
|
const { batchFriendlyId, apiKey, environment } = await seedRunWithBatch();
|
|
const jwt = await generateJWT({
|
|
secretKey: apiKey,
|
|
payload: { pub: true, sub: environment.id, scopes: ["read:batch"] },
|
|
expirationTime: "15m",
|
|
});
|
|
const res = await get(pathFor(batchFriendlyId), { Authorization: `Bearer ${jwt}` });
|
|
expect(res.status).not.toBe(401);
|
|
expect(res.status).not.toBe(403);
|
|
});
|
|
|
|
it("JWT read:runs: auth passes (backwards-compat for legacy SDKs)", async () => {
|
|
const { batchFriendlyId, apiKey, environment } = await seedRunWithBatch();
|
|
const jwt = await generateJWT({
|
|
secretKey: apiKey,
|
|
payload: { pub: true, sub: environment.id, scopes: ["read:runs"] },
|
|
expirationTime: "15m",
|
|
});
|
|
const res = await get(pathFor(batchFriendlyId), { Authorization: `Bearer ${jwt}` });
|
|
// Pre-RBAC the batch-retrieve route's superScopes included
|
|
// `read:runs`, so JWTs minted with read:runs could read batches.
|
|
// The post-migration backwards-compat fix adds a `{type: "runs"}`
|
|
// element to the route's anyResource(...) list so those
|
|
// SDK-issued JWTs in the wild keep working — avoids a silent
|
|
// 401/403 regression for callers we never told to switch scopes.
|
|
// Per-id `read:batch:<id>` and type-level `read:batch` still
|
|
// grant via the route's first resource element.
|
|
expect(res.status).not.toBe(401);
|
|
expect(res.status).not.toBe(403);
|
|
});
|
|
|
|
it("JWT read:all super-scope: auth passes", async () => {
|
|
const { batchFriendlyId, apiKey, environment } = await seedRunWithBatch();
|
|
const jwt = await generateJWT({
|
|
secretKey: apiKey,
|
|
payload: { pub: true, sub: environment.id, scopes: ["read:all"] },
|
|
expirationTime: "15m",
|
|
});
|
|
const res = await get(pathFor(batchFriendlyId), { Authorization: `Bearer ${jwt}` });
|
|
expect(res.status).not.toBe(401);
|
|
expect(res.status).not.toBe(403);
|
|
});
|
|
|
|
it("JWT admin: auth passes", async () => {
|
|
const { batchFriendlyId, apiKey, environment } = await seedRunWithBatch();
|
|
const jwt = await generateJWT({
|
|
secretKey: apiKey,
|
|
payload: { pub: true, sub: environment.id, scopes: ["admin"] },
|
|
expirationTime: "15m",
|
|
});
|
|
const res = await get(pathFor(batchFriendlyId), { Authorization: `Bearer ${jwt}` });
|
|
expect(res.status).not.toBe(401);
|
|
expect(res.status).not.toBe(403);
|
|
});
|
|
|
|
it("cross-env: env A's JWT cannot read env B's batch: not 200", async () => {
|
|
const server = getTestServer();
|
|
const a = await seedTestEnvironment(server.prisma);
|
|
const b = await seedRunWithBatch();
|
|
const jwt = await generateJWT({
|
|
secretKey: a.apiKey,
|
|
payload: {
|
|
pub: true,
|
|
sub: a.environment.id,
|
|
scopes: [`read:batch:${b.batchFriendlyId}`],
|
|
},
|
|
expirationTime: "15m",
|
|
});
|
|
const res = await get(pathFor(b.batchFriendlyId), { Authorization: `Bearer ${jwt}` });
|
|
// Critical: env A's JWT can't see env B's batch (env-scoped
|
|
// findResource returns null). NOT 200.
|
|
expect(res.status).not.toBe(200);
|
|
});
|
|
});
|
|
|
|
// Sanity: api.v2 and realtime.v1 share the exact same authorization
|
|
// config as v1. Don't duplicate the full matrix; just verify the
|
|
// wiring is alive on each.
|
|
describe("Batch retrieve — GET /api/v2/batches/:batchId (sanity)", () => {
|
|
it("missing auth: 401", async () => {
|
|
const res = await getTestServer().webapp.fetch("/api/v2/batches/batch_anything");
|
|
expect(res.status).toBe(401);
|
|
});
|
|
|
|
it("JWT read:batch (type-level): auth passes on real batch", async () => {
|
|
const server = getTestServer();
|
|
const seed = await seedTestEnvironment(server.prisma);
|
|
const seeded = await seedTestRun(server.prisma, {
|
|
environmentId: seed.environment.id,
|
|
projectId: seed.project.id,
|
|
withBatch: true,
|
|
});
|
|
const jwt = await generateJWT({
|
|
secretKey: seed.apiKey,
|
|
payload: { pub: true, sub: seed.environment.id, scopes: ["read:batch"] },
|
|
expirationTime: "15m",
|
|
});
|
|
const res = await getTestServer().webapp.fetch(`/api/v2/batches/${seeded.batchFriendlyId}`, {
|
|
headers: { Authorization: `Bearer ${jwt}` },
|
|
});
|
|
expect(res.status).not.toBe(401);
|
|
expect(res.status).not.toBe(403);
|
|
});
|
|
});
|
|
|
|
// Prompts routes (TRI-8738). Resource shapes:
|
|
// - List resource: { type: "prompts", id: "all" } action: read
|
|
// - Retrieve resource: { type: "prompts", id: params.slug } action: read
|
|
// - Override resource: { type: "prompts", id: params.slug } action: update
|
|
// (multi-method: POST/PUT/PATCH/DELETE)
|
|
// - Promote resource: { type: "prompts", id: params.slug } action: update
|
|
// - Reactivate resource: { type: "prompts", id: params.slug } action: update
|
|
//
|
|
// ACTION_ALIASES: update ← write, so write:prompts also satisfies
|
|
// the update-action routes.
|
|
//
|
|
// Auth happens before any DB lookup, so we test against
|
|
// non-existent slugs — handler will 404 but we assert "not 401/403"
|
|
// for pass cases.
|
|
describe("Prompts list — GET /api/v1/prompts (collection-level)", () => {
|
|
const path = "/api/v1/prompts";
|
|
const get = (headers: Record<string, string>) =>
|
|
getTestServer().webapp.fetch(path, { headers });
|
|
|
|
it("missing auth: 401", async () => {
|
|
const res = await getTestServer().webapp.fetch(path);
|
|
expect(res.status).toBe(401);
|
|
});
|
|
|
|
it("private API key: auth passes", async () => {
|
|
const server = getTestServer();
|
|
const seed = await seedTestEnvironment(server.prisma);
|
|
const res = await get({ Authorization: `Bearer ${seed.apiKey}` });
|
|
expect(res.status).not.toBe(401);
|
|
expect(res.status).not.toBe(403);
|
|
});
|
|
|
|
it("JWT read:prompts (type-level): auth passes", async () => {
|
|
const server = getTestServer();
|
|
const seed = await seedTestEnvironment(server.prisma);
|
|
const jwt = await generateJWT({
|
|
secretKey: seed.apiKey,
|
|
payload: { pub: true, sub: seed.environment.id, scopes: ["read:prompts"] },
|
|
expirationTime: "15m",
|
|
});
|
|
const res = await get({ Authorization: `Bearer ${jwt}` });
|
|
expect(res.status).not.toBe(401);
|
|
expect(res.status).not.toBe(403);
|
|
});
|
|
|
|
it("JWT read:runs: 403 (type mismatch)", async () => {
|
|
const server = getTestServer();
|
|
const seed = await seedTestEnvironment(server.prisma);
|
|
const jwt = await generateJWT({
|
|
secretKey: seed.apiKey,
|
|
payload: { pub: true, sub: seed.environment.id, scopes: ["read:runs"] },
|
|
expirationTime: "15m",
|
|
});
|
|
const res = await get({ Authorization: `Bearer ${jwt}` });
|
|
expect(res.status).toBe(403);
|
|
});
|
|
|
|
it("JWT admin: auth passes", async () => {
|
|
const server = getTestServer();
|
|
const seed = await seedTestEnvironment(server.prisma);
|
|
const jwt = await generateJWT({
|
|
secretKey: seed.apiKey,
|
|
payload: { pub: true, sub: seed.environment.id, scopes: ["admin"] },
|
|
expirationTime: "15m",
|
|
});
|
|
const res = await get({ Authorization: `Bearer ${jwt}` });
|
|
expect(res.status).not.toBe(401);
|
|
expect(res.status).not.toBe(403);
|
|
});
|
|
});
|
|
|
|
describe("Prompts retrieve — GET /api/v1/prompts/:slug (id-keyed read)", () => {
|
|
const SLUG = "test-prompt";
|
|
const path = `/api/v1/prompts/${SLUG}`;
|
|
const get = (headers: Record<string, string>) =>
|
|
getTestServer().webapp.fetch(path, { headers });
|
|
|
|
it("missing auth: 401", async () => {
|
|
const res = await getTestServer().webapp.fetch(path);
|
|
expect(res.status).toBe(401);
|
|
});
|
|
|
|
it("private API key: auth passes", async () => {
|
|
const server = getTestServer();
|
|
const seed = await seedTestEnvironment(server.prisma);
|
|
const res = await get({ Authorization: `Bearer ${seed.apiKey}` });
|
|
expect(res.status).not.toBe(401);
|
|
expect(res.status).not.toBe(403);
|
|
});
|
|
|
|
it("JWT read:prompts (type-level): auth passes", async () => {
|
|
const server = getTestServer();
|
|
const seed = await seedTestEnvironment(server.prisma);
|
|
const jwt = await generateJWT({
|
|
secretKey: seed.apiKey,
|
|
payload: { pub: true, sub: seed.environment.id, scopes: ["read:prompts"] },
|
|
expirationTime: "15m",
|
|
});
|
|
const res = await get({ Authorization: `Bearer ${jwt}` });
|
|
expect(res.status).not.toBe(401);
|
|
expect(res.status).not.toBe(403);
|
|
});
|
|
|
|
it("JWT read:prompts:<exact slug>: auth passes", async () => {
|
|
const server = getTestServer();
|
|
const seed = await seedTestEnvironment(server.prisma);
|
|
const jwt = await generateJWT({
|
|
secretKey: seed.apiKey,
|
|
payload: {
|
|
pub: true,
|
|
sub: seed.environment.id,
|
|
scopes: [`read:prompts:${SLUG}`],
|
|
},
|
|
expirationTime: "15m",
|
|
});
|
|
const res = await get({ Authorization: `Bearer ${jwt}` });
|
|
expect(res.status).not.toBe(401);
|
|
expect(res.status).not.toBe(403);
|
|
});
|
|
|
|
it("JWT read:prompts:<other>: not 200 (no access)", async () => {
|
|
// Note: the prompts retrieve route has a findResource callback
|
|
// that runs BEFORE authorization. Since we don't seed a Prompt
|
|
// fixture, the route 404s before reaching the auth check —
|
|
// assert "not 200" to capture the no-access semantic without
|
|
// depending on whether the guard that fires first is auth (403)
|
|
// or findResource (404). Both block the user.
|
|
const server = getTestServer();
|
|
const seed = await seedTestEnvironment(server.prisma);
|
|
const jwt = await generateJWT({
|
|
secretKey: seed.apiKey,
|
|
payload: {
|
|
pub: true,
|
|
sub: seed.environment.id,
|
|
scopes: ["read:prompts:some-other-slug"],
|
|
},
|
|
expirationTime: "15m",
|
|
});
|
|
const res = await get({ Authorization: `Bearer ${jwt}` });
|
|
expect(res.status).not.toBe(200);
|
|
});
|
|
|
|
it("JWT read:runs: not 200 (type mismatch — no access)", async () => {
|
|
// Same caveat as above re: findResource ordering.
|
|
const server = getTestServer();
|
|
const seed = await seedTestEnvironment(server.prisma);
|
|
const jwt = await generateJWT({
|
|
secretKey: seed.apiKey,
|
|
payload: { pub: true, sub: seed.environment.id, scopes: ["read:runs"] },
|
|
expirationTime: "15m",
|
|
});
|
|
const res = await get({ Authorization: `Bearer ${jwt}` });
|
|
expect(res.status).not.toBe(200);
|
|
});
|
|
|
|
it("JWT admin: auth passes", async () => {
|
|
const server = getTestServer();
|
|
const seed = await seedTestEnvironment(server.prisma);
|
|
const jwt = await generateJWT({
|
|
secretKey: seed.apiKey,
|
|
payload: { pub: true, sub: seed.environment.id, scopes: ["admin"] },
|
|
expirationTime: "15m",
|
|
});
|
|
const res = await get({ Authorization: `Bearer ${jwt}` });
|
|
expect(res.status).not.toBe(401);
|
|
expect(res.status).not.toBe(403);
|
|
});
|
|
});
|
|
|
|
describe("Prompts override — POST /api/v1/prompts/:slug/override (update action)", () => {
|
|
const SLUG = "test-prompt";
|
|
const path = `/api/v1/prompts/${SLUG}/override`;
|
|
const post = (headers: Record<string, string>) =>
|
|
getTestServer().webapp.fetch(path, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json", ...headers },
|
|
body: JSON.stringify({ content: "test" }),
|
|
});
|
|
|
|
it("missing auth: 401", async () => {
|
|
const res = await post({});
|
|
expect(res.status).toBe(401);
|
|
});
|
|
|
|
it("JWT write:prompts:<slug> matching (ACTION_ALIASES write→update): passes", async () => {
|
|
const server = getTestServer();
|
|
const seed = await seedTestEnvironment(server.prisma);
|
|
const jwt = await generateJWT({
|
|
secretKey: seed.apiKey,
|
|
payload: {
|
|
pub: true,
|
|
sub: seed.environment.id,
|
|
scopes: [`write:prompts:${SLUG}`],
|
|
},
|
|
expirationTime: "15m",
|
|
});
|
|
const res = await post({ Authorization: `Bearer ${jwt}` });
|
|
expect(res.status).not.toBe(401);
|
|
expect(res.status).not.toBe(403);
|
|
});
|
|
|
|
it("JWT write:prompts (type-level): passes", async () => {
|
|
const server = getTestServer();
|
|
const seed = await seedTestEnvironment(server.prisma);
|
|
const jwt = await generateJWT({
|
|
secretKey: seed.apiKey,
|
|
payload: { pub: true, sub: seed.environment.id, scopes: ["write:prompts"] },
|
|
expirationTime: "15m",
|
|
});
|
|
const res = await post({ Authorization: `Bearer ${jwt}` });
|
|
expect(res.status).not.toBe(401);
|
|
expect(res.status).not.toBe(403);
|
|
});
|
|
|
|
it("JWT read:prompts: 403 (action mismatch — read NOT aliased to update)", async () => {
|
|
const server = getTestServer();
|
|
const seed = await seedTestEnvironment(server.prisma);
|
|
const jwt = await generateJWT({
|
|
secretKey: seed.apiKey,
|
|
payload: {
|
|
pub: true,
|
|
sub: seed.environment.id,
|
|
scopes: [`read:prompts:${SLUG}`],
|
|
},
|
|
expirationTime: "15m",
|
|
});
|
|
const res = await post({ Authorization: `Bearer ${jwt}` });
|
|
expect(res.status).toBe(403);
|
|
});
|
|
|
|
it("JWT write:prompts:<other>: 403", async () => {
|
|
const server = getTestServer();
|
|
const seed = await seedTestEnvironment(server.prisma);
|
|
const jwt = await generateJWT({
|
|
secretKey: seed.apiKey,
|
|
payload: {
|
|
pub: true,
|
|
sub: seed.environment.id,
|
|
scopes: ["write:prompts:some-other-slug"],
|
|
},
|
|
expirationTime: "15m",
|
|
});
|
|
const res = await post({ Authorization: `Bearer ${jwt}` });
|
|
expect(res.status).toBe(403);
|
|
});
|
|
|
|
it("JWT admin: passes", async () => {
|
|
const server = getTestServer();
|
|
const seed = await seedTestEnvironment(server.prisma);
|
|
const jwt = await generateJWT({
|
|
secretKey: seed.apiKey,
|
|
payload: { pub: true, sub: seed.environment.id, scopes: ["admin"] },
|
|
expirationTime: "15m",
|
|
});
|
|
const res = await post({ Authorization: `Bearer ${jwt}` });
|
|
expect(res.status).not.toBe(401);
|
|
expect(res.status).not.toBe(403);
|
|
});
|
|
});
|
|
|
|
describe("Prompts promote/reactivate (sanity, update action)", () => {
|
|
it("promote: JWT write:prompts (type-level): auth passes", async () => {
|
|
const server = getTestServer();
|
|
const seed = await seedTestEnvironment(server.prisma);
|
|
const jwt = await generateJWT({
|
|
secretKey: seed.apiKey,
|
|
payload: { pub: true, sub: seed.environment.id, scopes: ["write:prompts"] },
|
|
expirationTime: "15m",
|
|
});
|
|
const res = await server.webapp.fetch("/api/v1/prompts/some-slug/promote", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json", Authorization: `Bearer ${jwt}` },
|
|
body: JSON.stringify({}),
|
|
});
|
|
expect(res.status).not.toBe(401);
|
|
expect(res.status).not.toBe(403);
|
|
});
|
|
|
|
it("reactivate: JWT read:prompts: 403 (action mismatch)", async () => {
|
|
const server = getTestServer();
|
|
const seed = await seedTestEnvironment(server.prisma);
|
|
const jwt = await generateJWT({
|
|
secretKey: seed.apiKey,
|
|
payload: { pub: true, sub: seed.environment.id, scopes: ["read:prompts"] },
|
|
expirationTime: "15m",
|
|
});
|
|
// Body must satisfy the route's schema ({ version: positive int })
|
|
// — otherwise body validation 400s before authorization runs.
|
|
const res = await server.webapp.fetch("/api/v1/prompts/some-slug/override/reactivate", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json", Authorization: `Bearer ${jwt}` },
|
|
body: JSON.stringify({ version: 1 }),
|
|
});
|
|
expect(res.status).toBe(403);
|
|
});
|
|
});
|
|
|
|
// Deployments + query routes (TRI-8739). Read-only family with
|
|
// distinct resource types per route:
|
|
// - GET /api/v1/deployments { type: "deployments", id: "list" }
|
|
// - GET /api/v1/query/schema { type: "query", id: "schema" }
|
|
// - GET /api/v1/query/dashboards { type: "query", id: "dashboards" }
|
|
// - POST /api/v1/query body-derived: detectTables(query) →
|
|
// [{ type: "query", id }] or
|
|
// { type: "query", id: "all" } if none
|
|
describe("Deployments list — GET /api/v1/deployments", () => {
|
|
const path = "/api/v1/deployments";
|
|
const get = (headers: Record<string, string>) =>
|
|
getTestServer().webapp.fetch(path, { headers });
|
|
|
|
it("missing auth: 401", async () => {
|
|
const res = await getTestServer().webapp.fetch(path);
|
|
expect(res.status).toBe(401);
|
|
});
|
|
|
|
it("private API key: auth passes", async () => {
|
|
const server = getTestServer();
|
|
const seed = await seedTestEnvironment(server.prisma);
|
|
const res = await get({ Authorization: `Bearer ${seed.apiKey}` });
|
|
expect(res.status).not.toBe(401);
|
|
expect(res.status).not.toBe(403);
|
|
});
|
|
|
|
it("JWT read:deployments: auth passes", async () => {
|
|
const server = getTestServer();
|
|
const seed = await seedTestEnvironment(server.prisma);
|
|
const jwt = await generateJWT({
|
|
secretKey: seed.apiKey,
|
|
payload: { pub: true, sub: seed.environment.id, scopes: ["read:deployments"] },
|
|
expirationTime: "15m",
|
|
});
|
|
const res = await get({ Authorization: `Bearer ${jwt}` });
|
|
expect(res.status).not.toBe(401);
|
|
expect(res.status).not.toBe(403);
|
|
});
|
|
|
|
it("JWT read:all: auth passes", async () => {
|
|
const server = getTestServer();
|
|
const seed = await seedTestEnvironment(server.prisma);
|
|
const jwt = await generateJWT({
|
|
secretKey: seed.apiKey,
|
|
payload: { pub: true, sub: seed.environment.id, scopes: ["read:all"] },
|
|
expirationTime: "15m",
|
|
});
|
|
const res = await get({ Authorization: `Bearer ${jwt}` });
|
|
expect(res.status).not.toBe(401);
|
|
expect(res.status).not.toBe(403);
|
|
});
|
|
|
|
it("JWT admin: auth passes", async () => {
|
|
const server = getTestServer();
|
|
const seed = await seedTestEnvironment(server.prisma);
|
|
const jwt = await generateJWT({
|
|
secretKey: seed.apiKey,
|
|
payload: { pub: true, sub: seed.environment.id, scopes: ["admin"] },
|
|
expirationTime: "15m",
|
|
});
|
|
const res = await get({ Authorization: `Bearer ${jwt}` });
|
|
expect(res.status).not.toBe(401);
|
|
expect(res.status).not.toBe(403);
|
|
});
|
|
|
|
it("JWT read:runs (type mismatch): 403", async () => {
|
|
const server = getTestServer();
|
|
const seed = await seedTestEnvironment(server.prisma);
|
|
const jwt = await generateJWT({
|
|
secretKey: seed.apiKey,
|
|
payload: { pub: true, sub: seed.environment.id, scopes: ["read:runs"] },
|
|
expirationTime: "15m",
|
|
});
|
|
const res = await get({ Authorization: `Bearer ${jwt}` });
|
|
expect(res.status).toBe(403);
|
|
});
|
|
|
|
it("JWT write:deployments (action mismatch — read route): 403", async () => {
|
|
const server = getTestServer();
|
|
const seed = await seedTestEnvironment(server.prisma);
|
|
const jwt = await generateJWT({
|
|
secretKey: seed.apiKey,
|
|
payload: { pub: true, sub: seed.environment.id, scopes: ["write:deployments"] },
|
|
expirationTime: "15m",
|
|
});
|
|
const res = await get({ Authorization: `Bearer ${jwt}` });
|
|
expect(res.status).toBe(403);
|
|
});
|
|
});
|
|
|
|
describe("Query schema — GET /api/v1/query/schema (sanity)", () => {
|
|
const path = "/api/v1/query/schema";
|
|
|
|
it("missing auth: 401", async () => {
|
|
const res = await getTestServer().webapp.fetch(path);
|
|
expect(res.status).toBe(401);
|
|
});
|
|
|
|
it("JWT read:query (type-level): auth passes", async () => {
|
|
const server = getTestServer();
|
|
const seed = await seedTestEnvironment(server.prisma);
|
|
const jwt = await generateJWT({
|
|
secretKey: seed.apiKey,
|
|
payload: { pub: true, sub: seed.environment.id, scopes: ["read:query"] },
|
|
expirationTime: "15m",
|
|
});
|
|
const res = await server.webapp.fetch(path, {
|
|
headers: { Authorization: `Bearer ${jwt}` },
|
|
});
|
|
expect(res.status).not.toBe(401);
|
|
expect(res.status).not.toBe(403);
|
|
});
|
|
|
|
it("JWT read:deployments (type mismatch): 403", async () => {
|
|
const server = getTestServer();
|
|
const seed = await seedTestEnvironment(server.prisma);
|
|
const jwt = await generateJWT({
|
|
secretKey: seed.apiKey,
|
|
payload: { pub: true, sub: seed.environment.id, scopes: ["read:deployments"] },
|
|
expirationTime: "15m",
|
|
});
|
|
const res = await server.webapp.fetch(path, {
|
|
headers: { Authorization: `Bearer ${jwt}` },
|
|
});
|
|
expect(res.status).toBe(403);
|
|
});
|
|
});
|
|
|
|
describe("Query dashboards — GET /api/v1/query/dashboards (sanity)", () => {
|
|
const path = "/api/v1/query/dashboards";
|
|
|
|
it("missing auth: 401", async () => {
|
|
const res = await getTestServer().webapp.fetch(path);
|
|
expect(res.status).toBe(401);
|
|
});
|
|
|
|
it("JWT read:query: auth passes", async () => {
|
|
const server = getTestServer();
|
|
const seed = await seedTestEnvironment(server.prisma);
|
|
const jwt = await generateJWT({
|
|
secretKey: seed.apiKey,
|
|
payload: { pub: true, sub: seed.environment.id, scopes: ["read:query"] },
|
|
expirationTime: "15m",
|
|
});
|
|
const res = await server.webapp.fetch(path, {
|
|
headers: { Authorization: `Bearer ${jwt}` },
|
|
});
|
|
expect(res.status).not.toBe(401);
|
|
expect(res.status).not.toBe(403);
|
|
});
|
|
});
|
|
|
|
describe("Query ad-hoc — POST /api/v1/query (body-derived resource)", () => {
|
|
const path = "/api/v1/query";
|
|
const post = (body: object, headers: Record<string, string>) =>
|
|
getTestServer().webapp.fetch(path, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json", ...headers },
|
|
body: JSON.stringify(body),
|
|
});
|
|
|
|
it("missing auth: 401", async () => {
|
|
const res = await post({ query: "SELECT * FROM runs" }, {});
|
|
expect(res.status).toBe(401);
|
|
});
|
|
|
|
it("body with table 'runs' + JWT read:query:runs: auth passes (any-match)", async () => {
|
|
// detectTables pulls 'runs' from FROM-clause. Resource becomes
|
|
// [{ type: "query", id: "runs" }]. Scope read:query:runs matches.
|
|
const server = getTestServer();
|
|
const seed = await seedTestEnvironment(server.prisma);
|
|
const jwt = await generateJWT({
|
|
secretKey: seed.apiKey,
|
|
payload: { pub: true, sub: seed.environment.id, scopes: ["read:query:runs"] },
|
|
expirationTime: "15m",
|
|
});
|
|
const res = await post(
|
|
{ query: "SELECT * FROM runs" },
|
|
{
|
|
Authorization: `Bearer ${jwt}`,
|
|
}
|
|
);
|
|
expect(res.status).not.toBe(401);
|
|
expect(res.status).not.toBe(403);
|
|
});
|
|
|
|
it("body with no detectable tables (defaults id='all') + JWT read:query: auth passes", async () => {
|
|
// A query with no FROM clause → detectTables returns [] →
|
|
// resource is { type: "query", id: "all" }. Type-level read:query
|
|
// matches.
|
|
const server = getTestServer();
|
|
const seed = await seedTestEnvironment(server.prisma);
|
|
const jwt = await generateJWT({
|
|
secretKey: seed.apiKey,
|
|
payload: { pub: true, sub: seed.environment.id, scopes: ["read:query"] },
|
|
expirationTime: "15m",
|
|
});
|
|
const res = await post({ query: "SELECT 1" }, { Authorization: `Bearer ${jwt}` });
|
|
expect(res.status).not.toBe(401);
|
|
expect(res.status).not.toBe(403);
|
|
});
|
|
|
|
it("multi-table body + JWT scoped to only one of them: 403 (every-table semantics)", async () => {
|
|
// detectTables matches `\bFROM\s+<name>\b` per query-schema, so
|
|
// a query with two FROM clauses (e.g. UNION) yields a multi-
|
|
// entry resource list. The route wraps it in everyResource so
|
|
// AND semantics apply: a JWT scoped to one detected table
|
|
// cannot submit a query that also reads the other.
|
|
const server = getTestServer();
|
|
const seed = await seedTestEnvironment(server.prisma);
|
|
const jwt = await generateJWT({
|
|
secretKey: seed.apiKey,
|
|
payload: { pub: true, sub: seed.environment.id, scopes: ["read:query:runs"] },
|
|
expirationTime: "15m",
|
|
});
|
|
const res = await post(
|
|
{
|
|
query: "SELECT count() FROM runs UNION ALL SELECT count() FROM metrics",
|
|
},
|
|
{ Authorization: `Bearer ${jwt}` }
|
|
);
|
|
expect(res.status).toBe(403);
|
|
});
|
|
|
|
it("multi-table body + JWT scoped to all detected tables: auth passes", async () => {
|
|
// Companion to the every-table 403 above — when the JWT covers
|
|
// every detected table the AND-check passes.
|
|
const server = getTestServer();
|
|
const seed = await seedTestEnvironment(server.prisma);
|
|
const jwt = await generateJWT({
|
|
secretKey: seed.apiKey,
|
|
payload: {
|
|
pub: true,
|
|
sub: seed.environment.id,
|
|
scopes: ["read:query:runs", "read:query:metrics"],
|
|
},
|
|
expirationTime: "15m",
|
|
});
|
|
const res = await post(
|
|
{
|
|
query: "SELECT count() FROM runs UNION ALL SELECT count() FROM metrics",
|
|
},
|
|
{ Authorization: `Bearer ${jwt}` }
|
|
);
|
|
expect(res.status).not.toBe(401);
|
|
expect(res.status).not.toBe(403);
|
|
});
|
|
|
|
it("body with table 'runs' + JWT read:query:other_table: 403", async () => {
|
|
const server = getTestServer();
|
|
const seed = await seedTestEnvironment(server.prisma);
|
|
const jwt = await generateJWT({
|
|
secretKey: seed.apiKey,
|
|
payload: {
|
|
pub: true,
|
|
sub: seed.environment.id,
|
|
scopes: ["read:query:other_table"],
|
|
},
|
|
expirationTime: "15m",
|
|
});
|
|
const res = await post(
|
|
{ query: "SELECT * FROM runs" },
|
|
{
|
|
Authorization: `Bearer ${jwt}`,
|
|
}
|
|
);
|
|
expect(res.status).toBe(403);
|
|
});
|
|
|
|
it("JWT admin: auth passes regardless of body", async () => {
|
|
const server = getTestServer();
|
|
const seed = await seedTestEnvironment(server.prisma);
|
|
const jwt = await generateJWT({
|
|
secretKey: seed.apiKey,
|
|
payload: { pub: true, sub: seed.environment.id, scopes: ["admin"] },
|
|
expirationTime: "15m",
|
|
});
|
|
const res = await post(
|
|
{ query: "SELECT * FROM runs" },
|
|
{
|
|
Authorization: `Bearer ${jwt}`,
|
|
}
|
|
);
|
|
expect(res.status).not.toBe(401);
|
|
expect(res.status).not.toBe(403);
|
|
});
|
|
|
|
it("JWT write:query (action mismatch): 403", async () => {
|
|
const server = getTestServer();
|
|
const seed = await seedTestEnvironment(server.prisma);
|
|
const jwt = await generateJWT({
|
|
secretKey: seed.apiKey,
|
|
payload: { pub: true, sub: seed.environment.id, scopes: ["write:query"] },
|
|
expirationTime: "15m",
|
|
});
|
|
const res = await post({ query: "SELECT 1" }, { Authorization: `Bearer ${jwt}` });
|
|
expect(res.status).toBe(403);
|
|
});
|
|
});
|
|
|
|
describe("Batch retrieve — GET /realtime/v1/batches/:batchId (sanity)", () => {
|
|
it("missing auth: 401", async () => {
|
|
const res = await getTestServer().webapp.fetch("/realtime/v1/batches/batch_anything");
|
|
expect(res.status).toBe(401);
|
|
});
|
|
|
|
it("JWT read:batch:<friendlyId>: auth passes on real batch", async () => {
|
|
const server = getTestServer();
|
|
const seed = await seedTestEnvironment(server.prisma);
|
|
const seeded = await seedTestRun(server.prisma, {
|
|
environmentId: seed.environment.id,
|
|
projectId: seed.project.id,
|
|
withBatch: true,
|
|
});
|
|
const jwt = await generateJWT({
|
|
secretKey: seed.apiKey,
|
|
payload: {
|
|
pub: true,
|
|
sub: seed.environment.id,
|
|
scopes: [`read:batch:${seeded.batchFriendlyId}`],
|
|
},
|
|
expirationTime: "15m",
|
|
});
|
|
const res = await getTestServer().webapp.fetch(
|
|
`/realtime/v1/batches/${seeded.batchFriendlyId}`,
|
|
{ headers: { Authorization: `Bearer ${jwt}` } }
|
|
);
|
|
expect(res.status).not.toBe(401);
|
|
expect(res.status).not.toBe(403);
|
|
});
|
|
});
|
|
|
|
// Sessions — JWT scope matrix.
|
|
//
|
|
// The session routes were authored against the pre-RBAC apiBuilder
|
|
// and used the legacy `superScopes: [...]` field to whitelist broad
|
|
// access. After TRI-8719 superScopes is dead code; the equivalent
|
|
// bypass is expressed via:
|
|
// - multi-key resource arrays (one element per addressable key,
|
|
// plus a collection-level `{ type: "sessions" }` for type-only
|
|
// scopes)
|
|
// - the JWT ability's `*:all` and `admin*` wildcard branches
|
|
//
|
|
// These tests lock in that the migration's "no JWT regresses"
|
|
// promise holds for sessions. Each historical superScope becomes a
|
|
// positive test, and per-task narrowing gets negative coverage.
|
|
describe("Sessions — JWT scope matrix", () => {
|
|
// ---- List sessions: GET /api/v1/sessions
|
|
//
|
|
// Resource: [{ type: "tasks", id: <filter> } per filter id, { type: "sessions" }]
|
|
// Old superScopes: ["read:sessions", "read:all", "admin"]
|
|
describe("List sessions — GET /api/v1/sessions", () => {
|
|
const path = (taskFilter?: string) =>
|
|
taskFilter ? `/api/v1/sessions?filter[taskIdentifier]=${taskFilter}` : "/api/v1/sessions";
|
|
|
|
const fetchWithJwt = async (jwt: string, taskFilter?: string) =>
|
|
getTestServer().webapp.fetch(path(taskFilter), {
|
|
headers: { Authorization: `Bearer ${jwt}` },
|
|
});
|
|
|
|
const mintJwt = async (apiKey: string, envId: string, scopes: string[]) =>
|
|
generateJWT({
|
|
secretKey: apiKey,
|
|
payload: { pub: true, sub: envId, scopes },
|
|
expirationTime: "15m",
|
|
});
|
|
|
|
// The handler reads from ClickHouse via SessionsRepository, which
|
|
// isn't wired up in the e2e webapp container — so successful auth
|
|
// surfaces as 5xx after the handler errors. Assert "not 401, not
|
|
// 403" rather than 200 for the auth-passes paths.
|
|
|
|
it("read:tasks:foo on filter=foo: auth passes", async () => {
|
|
const seed = await seedTestEnvironment(getTestServer().prisma);
|
|
const jwt = await mintJwt(seed.apiKey, seed.environment.id, ["read:tasks:foo"]);
|
|
const res = await fetchWithJwt(jwt, "foo");
|
|
expect(res.status).not.toBe(401);
|
|
expect(res.status).not.toBe(403);
|
|
});
|
|
|
|
it("read:tasks:bar on filter=foo: 403 (per-task narrowing)", async () => {
|
|
const seed = await seedTestEnvironment(getTestServer().prisma);
|
|
const jwt = await mintJwt(seed.apiKey, seed.environment.id, ["read:tasks:bar"]);
|
|
const res = await fetchWithJwt(jwt, "foo");
|
|
expect(res.status).toBe(403);
|
|
});
|
|
|
|
it("read:sessions on filter=foo: auth passes (was a superScope)", async () => {
|
|
const seed = await seedTestEnvironment(getTestServer().prisma);
|
|
const jwt = await mintJwt(seed.apiKey, seed.environment.id, ["read:sessions"]);
|
|
const res = await fetchWithJwt(jwt, "foo");
|
|
expect(res.status).not.toBe(401);
|
|
expect(res.status).not.toBe(403);
|
|
});
|
|
|
|
it("read:sessions on no-filter list: auth passes", async () => {
|
|
const seed = await seedTestEnvironment(getTestServer().prisma);
|
|
const jwt = await mintJwt(seed.apiKey, seed.environment.id, ["read:sessions"]);
|
|
const res = await fetchWithJwt(jwt);
|
|
expect(res.status).not.toBe(401);
|
|
expect(res.status).not.toBe(403);
|
|
});
|
|
|
|
it("read:all: auth passes (was a superScope)", async () => {
|
|
const seed = await seedTestEnvironment(getTestServer().prisma);
|
|
const jwt = await mintJwt(seed.apiKey, seed.environment.id, ["read:all"]);
|
|
const res = await fetchWithJwt(jwt, "foo");
|
|
expect(res.status).not.toBe(401);
|
|
expect(res.status).not.toBe(403);
|
|
});
|
|
|
|
it("admin: auth passes (was a superScope)", async () => {
|
|
const seed = await seedTestEnvironment(getTestServer().prisma);
|
|
const jwt = await mintJwt(seed.apiKey, seed.environment.id, ["admin"]);
|
|
const res = await fetchWithJwt(jwt, "foo");
|
|
expect(res.status).not.toBe(401);
|
|
expect(res.status).not.toBe(403);
|
|
});
|
|
|
|
it("read:tasks (type-only) on no-filter list: 403 (filter is sessions, not tasks)", async () => {
|
|
// No filter → resource is `{ type: "sessions" }` only. read:tasks
|
|
// doesn't match the sessions type, so 403 — explicit narrowing.
|
|
const seed = await seedTestEnvironment(getTestServer().prisma);
|
|
const jwt = await mintJwt(seed.apiKey, seed.environment.id, ["read:tasks"]);
|
|
const res = await fetchWithJwt(jwt);
|
|
expect(res.status).toBe(403);
|
|
});
|
|
|
|
it("write:tasks:foo (wrong action) on filter=foo: 403", async () => {
|
|
const seed = await seedTestEnvironment(getTestServer().prisma);
|
|
const jwt = await mintJwt(seed.apiKey, seed.environment.id, ["write:tasks:foo"]);
|
|
const res = await fetchWithJwt(jwt, "foo");
|
|
expect(res.status).toBe(403);
|
|
});
|
|
});
|
|
|
|
// ---- Create session: POST /api/v1/sessions
|
|
//
|
|
// Resource: [{ type: "tasks", id: body.taskIdentifier }, { type: "sessions" }]
|
|
// Old superScopes: ["write:sessions", "admin"]
|
|
describe("Create session — POST /api/v1/sessions", () => {
|
|
const path = "/api/v1/sessions";
|
|
|
|
const post = async (jwt: string, taskIdentifier: string) =>
|
|
getTestServer().webapp.fetch(path, {
|
|
method: "POST",
|
|
headers: {
|
|
Authorization: `Bearer ${jwt}`,
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({
|
|
type: "chat.agent",
|
|
taskIdentifier,
|
|
triggerConfig: { basePayload: {} },
|
|
}),
|
|
});
|
|
|
|
const mintJwt = async (apiKey: string, envId: string, scopes: string[]) =>
|
|
generateJWT({
|
|
secretKey: apiKey,
|
|
payload: { pub: true, sub: envId, scopes },
|
|
expirationTime: "15m",
|
|
});
|
|
|
|
it("write:tasks:foo matching body: auth passes", async () => {
|
|
const seed = await seedTestEnvironment(getTestServer().prisma);
|
|
const jwt = await mintJwt(seed.apiKey, seed.environment.id, ["write:tasks:foo"]);
|
|
const res = await post(jwt, "foo");
|
|
// Body validation / handler can fail later (404 if task is
|
|
// missing, 400 for invalid body) — we only care that auth
|
|
// didn't reject.
|
|
expect(res.status).not.toBe(401);
|
|
expect(res.status).not.toBe(403);
|
|
});
|
|
|
|
it("write:tasks:bar mismatching body: 403", async () => {
|
|
const seed = await seedTestEnvironment(getTestServer().prisma);
|
|
const jwt = await mintJwt(seed.apiKey, seed.environment.id, ["write:tasks:bar"]);
|
|
const res = await post(jwt, "foo");
|
|
expect(res.status).toBe(403);
|
|
});
|
|
|
|
it("write:sessions: auth passes (was a superScope)", async () => {
|
|
const seed = await seedTestEnvironment(getTestServer().prisma);
|
|
const jwt = await mintJwt(seed.apiKey, seed.environment.id, ["write:sessions"]);
|
|
const res = await post(jwt, "foo");
|
|
expect(res.status).not.toBe(401);
|
|
expect(res.status).not.toBe(403);
|
|
});
|
|
|
|
it("write:all: auth passes", async () => {
|
|
const seed = await seedTestEnvironment(getTestServer().prisma);
|
|
const jwt = await mintJwt(seed.apiKey, seed.environment.id, ["write:all"]);
|
|
const res = await post(jwt, "foo");
|
|
expect(res.status).not.toBe(401);
|
|
expect(res.status).not.toBe(403);
|
|
});
|
|
|
|
it("admin: auth passes", async () => {
|
|
const seed = await seedTestEnvironment(getTestServer().prisma);
|
|
const jwt = await mintJwt(seed.apiKey, seed.environment.id, ["admin"]);
|
|
const res = await post(jwt, "foo");
|
|
expect(res.status).not.toBe(401);
|
|
expect(res.status).not.toBe(403);
|
|
});
|
|
|
|
it("read:tasks:foo (wrong action): 403", async () => {
|
|
const seed = await seedTestEnvironment(getTestServer().prisma);
|
|
const jwt = await mintJwt(seed.apiKey, seed.environment.id, ["read:tasks:foo"]);
|
|
const res = await post(jwt, "foo");
|
|
expect(res.status).toBe(403);
|
|
});
|
|
});
|
|
|
|
// ---- Retrieve session: GET /api/v1/sessions/:session
|
|
//
|
|
// Resource: multi-key array of `{ type: "sessions", id }` entries
|
|
// for friendlyId and externalId (when set).
|
|
// Old superScopes: ["read:sessions", "read:all", "admin"]
|
|
describe("Retrieve session — GET /api/v1/sessions/:session", () => {
|
|
const get = async (sessionParam: string, jwt: string) =>
|
|
getTestServer().webapp.fetch(`/api/v1/sessions/${sessionParam}`, {
|
|
headers: { Authorization: `Bearer ${jwt}` },
|
|
});
|
|
|
|
const mintJwt = async (apiKey: string, envId: string, scopes: string[]) =>
|
|
generateJWT({
|
|
secretKey: apiKey,
|
|
payload: { pub: true, sub: envId, scopes },
|
|
expirationTime: "15m",
|
|
});
|
|
|
|
it("read:sessions:<friendlyId>: 200", async () => {
|
|
const server = getTestServer();
|
|
const seed = await seedTestEnvironment(server.prisma);
|
|
const session = await seedTestApiSession(server.prisma, seed.environment);
|
|
const jwt = await mintJwt(seed.apiKey, seed.environment.id, [
|
|
`read:sessions:${session.friendlyId}`,
|
|
]);
|
|
const res = await get(session.friendlyId, jwt);
|
|
expect(res.status).toBe(200);
|
|
});
|
|
|
|
it("read:sessions:<externalId> on externalId URL form: 200 (multi-key)", async () => {
|
|
const server = getTestServer();
|
|
const seed = await seedTestEnvironment(server.prisma);
|
|
const session = await seedTestApiSession(server.prisma, seed.environment);
|
|
const jwt = await mintJwt(seed.apiKey, seed.environment.id, [
|
|
`read:sessions:${session.externalId}`,
|
|
]);
|
|
const res = await get(session.externalId!, jwt);
|
|
expect(res.status).toBe(200);
|
|
});
|
|
|
|
it("read:sessions (type-only): 200", async () => {
|
|
const server = getTestServer();
|
|
const seed = await seedTestEnvironment(server.prisma);
|
|
const session = await seedTestApiSession(server.prisma, seed.environment);
|
|
const jwt = await mintJwt(seed.apiKey, seed.environment.id, ["read:sessions"]);
|
|
const res = await get(session.friendlyId, jwt);
|
|
expect(res.status).toBe(200);
|
|
});
|
|
|
|
it("read:sessions:other (non-matching id): 403", async () => {
|
|
const server = getTestServer();
|
|
const seed = await seedTestEnvironment(server.prisma);
|
|
const session = await seedTestApiSession(server.prisma, seed.environment);
|
|
const jwt = await mintJwt(seed.apiKey, seed.environment.id, [
|
|
"read:sessions:not-this-session",
|
|
]);
|
|
const res = await get(session.friendlyId, jwt);
|
|
expect(res.status).toBe(403);
|
|
});
|
|
});
|
|
|
|
// ---- Update session: PATCH /api/v1/sessions/:session
|
|
//
|
|
// action: "admin" — only admin-tier scopes (or wildcards) satisfy.
|
|
// Old superScopes: ["admin:sessions", "admin:all", "admin"]
|
|
describe("Update session — PATCH /api/v1/sessions/:session", () => {
|
|
const patch = async (sessionParam: string, jwt: string) =>
|
|
getTestServer().webapp.fetch(`/api/v1/sessions/${sessionParam}`, {
|
|
method: "PATCH",
|
|
headers: {
|
|
Authorization: `Bearer ${jwt}`,
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({ tags: ["updated"] }),
|
|
});
|
|
|
|
const mintJwt = async (apiKey: string, envId: string, scopes: string[]) =>
|
|
generateJWT({
|
|
secretKey: apiKey,
|
|
payload: { pub: true, sub: envId, scopes },
|
|
expirationTime: "15m",
|
|
});
|
|
|
|
it("admin:sessions:<friendlyId>: 200", async () => {
|
|
const server = getTestServer();
|
|
const seed = await seedTestEnvironment(server.prisma);
|
|
const session = await seedTestApiSession(server.prisma, seed.environment);
|
|
const jwt = await mintJwt(seed.apiKey, seed.environment.id, [
|
|
`admin:sessions:${session.friendlyId}`,
|
|
]);
|
|
const res = await patch(session.friendlyId, jwt);
|
|
expect(res.status).toBe(200);
|
|
});
|
|
|
|
it("admin:sessions (type-only): 200", async () => {
|
|
const server = getTestServer();
|
|
const seed = await seedTestEnvironment(server.prisma);
|
|
const session = await seedTestApiSession(server.prisma, seed.environment);
|
|
const jwt = await mintJwt(seed.apiKey, seed.environment.id, ["admin:sessions"]);
|
|
const res = await patch(session.friendlyId, jwt);
|
|
expect(res.status).toBe(200);
|
|
});
|
|
|
|
it("admin:all: 200", async () => {
|
|
const server = getTestServer();
|
|
const seed = await seedTestEnvironment(server.prisma);
|
|
const session = await seedTestApiSession(server.prisma, seed.environment);
|
|
const jwt = await mintJwt(seed.apiKey, seed.environment.id, ["admin:all"]);
|
|
const res = await patch(session.friendlyId, jwt);
|
|
expect(res.status).toBe(200);
|
|
});
|
|
|
|
it("admin (bare): 200", async () => {
|
|
const server = getTestServer();
|
|
const seed = await seedTestEnvironment(server.prisma);
|
|
const session = await seedTestApiSession(server.prisma, seed.environment);
|
|
const jwt = await mintJwt(seed.apiKey, seed.environment.id, ["admin"]);
|
|
const res = await patch(session.friendlyId, jwt);
|
|
expect(res.status).toBe(200);
|
|
});
|
|
|
|
it("write:sessions (wrong action — admin not aliased from write): 403", async () => {
|
|
const server = getTestServer();
|
|
const seed = await seedTestEnvironment(server.prisma);
|
|
const session = await seedTestApiSession(server.prisma, seed.environment);
|
|
const jwt = await mintJwt(seed.apiKey, seed.environment.id, ["write:sessions"]);
|
|
const res = await patch(session.friendlyId, jwt);
|
|
expect(res.status).toBe(403);
|
|
});
|
|
});
|
|
|
|
// ---- Close session: POST /api/v1/sessions/:session/close
|
|
//
|
|
// action: "admin" — same matrix as PATCH.
|
|
describe("Close session — POST /api/v1/sessions/:session/close", () => {
|
|
const close = async (sessionParam: string, jwt: string) =>
|
|
getTestServer().webapp.fetch(`/api/v1/sessions/${sessionParam}/close`, {
|
|
method: "POST",
|
|
headers: {
|
|
Authorization: `Bearer ${jwt}`,
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({ reason: "test" }),
|
|
});
|
|
|
|
const mintJwt = async (apiKey: string, envId: string, scopes: string[]) =>
|
|
generateJWT({
|
|
secretKey: apiKey,
|
|
payload: { pub: true, sub: envId, scopes },
|
|
expirationTime: "15m",
|
|
});
|
|
|
|
it("admin:sessions: auth passes", async () => {
|
|
const server = getTestServer();
|
|
const seed = await seedTestEnvironment(server.prisma);
|
|
const session = await seedTestApiSession(server.prisma, seed.environment);
|
|
const jwt = await mintJwt(seed.apiKey, seed.environment.id, ["admin:sessions"]);
|
|
const res = await close(session.friendlyId, jwt);
|
|
expect(res.status).not.toBe(401);
|
|
expect(res.status).not.toBe(403);
|
|
});
|
|
|
|
it("admin: auth passes", async () => {
|
|
const server = getTestServer();
|
|
const seed = await seedTestEnvironment(server.prisma);
|
|
const session = await seedTestApiSession(server.prisma, seed.environment);
|
|
const jwt = await mintJwt(seed.apiKey, seed.environment.id, ["admin"]);
|
|
const res = await close(session.friendlyId, jwt);
|
|
expect(res.status).not.toBe(401);
|
|
expect(res.status).not.toBe(403);
|
|
});
|
|
|
|
it("write:sessions: 403 (admin action)", async () => {
|
|
const server = getTestServer();
|
|
const seed = await seedTestEnvironment(server.prisma);
|
|
const session = await seedTestApiSession(server.prisma, seed.environment);
|
|
const jwt = await mintJwt(seed.apiKey, seed.environment.id, ["write:sessions"]);
|
|
const res = await close(session.friendlyId, jwt);
|
|
expect(res.status).toBe(403);
|
|
});
|
|
});
|
|
|
|
// ---- End-and-continue: POST /api/v1/sessions/:session/end-and-continue
|
|
//
|
|
// action: "write" — multi-key sessions resource.
|
|
describe("End-and-continue — POST /api/v1/sessions/:session/end-and-continue", () => {
|
|
const endAndContinue = async (sessionParam: string, jwt: string) =>
|
|
getTestServer().webapp.fetch(`/api/v1/sessions/${sessionParam}/end-and-continue`, {
|
|
method: "POST",
|
|
headers: {
|
|
Authorization: `Bearer ${jwt}`,
|
|
"Content-Type": "application/json",
|
|
},
|
|
// Body shape doesn't matter for auth — handler runs after
|
|
// the auth check so any 4xx here means auth passed.
|
|
body: JSON.stringify({
|
|
reason: "test",
|
|
callingRunId: "run_does_not_exist",
|
|
}),
|
|
});
|
|
|
|
const mintJwt = async (apiKey: string, envId: string, scopes: string[]) =>
|
|
generateJWT({
|
|
secretKey: apiKey,
|
|
payload: { pub: true, sub: envId, scopes },
|
|
expirationTime: "15m",
|
|
});
|
|
|
|
it("write:sessions: auth passes (was a superScope)", async () => {
|
|
const server = getTestServer();
|
|
const seed = await seedTestEnvironment(server.prisma);
|
|
const session = await seedTestApiSession(server.prisma, seed.environment);
|
|
const jwt = await mintJwt(seed.apiKey, seed.environment.id, ["write:sessions"]);
|
|
const res = await endAndContinue(session.friendlyId, jwt);
|
|
expect(res.status).not.toBe(401);
|
|
expect(res.status).not.toBe(403);
|
|
});
|
|
|
|
it("write:all: auth passes", async () => {
|
|
const server = getTestServer();
|
|
const seed = await seedTestEnvironment(server.prisma);
|
|
const session = await seedTestApiSession(server.prisma, seed.environment);
|
|
const jwt = await mintJwt(seed.apiKey, seed.environment.id, ["write:all"]);
|
|
const res = await endAndContinue(session.friendlyId, jwt);
|
|
expect(res.status).not.toBe(401);
|
|
expect(res.status).not.toBe(403);
|
|
});
|
|
|
|
it("read:sessions (wrong action): 403", async () => {
|
|
const server = getTestServer();
|
|
const seed = await seedTestEnvironment(server.prisma);
|
|
const session = await seedTestApiSession(server.prisma, seed.environment);
|
|
const jwt = await mintJwt(seed.apiKey, seed.environment.id, ["read:sessions"]);
|
|
const res = await endAndContinue(session.friendlyId, jwt);
|
|
expect(res.status).toBe(403);
|
|
});
|
|
});
|
|
|
|
// ---- Realtime IO: GET (subscribe) and PUT (initialize)
|
|
//
|
|
// Both go through createLoaderApiRoute / createActionApiRoute — same
|
|
// multi-key sessions resource. No deep matrix here; one positive
|
|
// test per old superScope per method is enough.
|
|
describe("Realtime IO — /realtime/v1/sessions/:session/:io", () => {
|
|
const ioPath = (sessionParam: string) => `/realtime/v1/sessions/${sessionParam}/in`;
|
|
|
|
const mintJwt = async (apiKey: string, envId: string, scopes: string[]) =>
|
|
generateJWT({
|
|
secretKey: apiKey,
|
|
payload: { pub: true, sub: envId, scopes },
|
|
expirationTime: "15m",
|
|
});
|
|
|
|
it("GET with read:sessions (was a superScope): auth passes", async () => {
|
|
const server = getTestServer();
|
|
const seed = await seedTestEnvironment(server.prisma);
|
|
const session = await seedTestApiSession(server.prisma, seed.environment);
|
|
const jwt = await mintJwt(seed.apiKey, seed.environment.id, ["read:sessions"]);
|
|
const res = await server.webapp.fetch(ioPath(session.friendlyId), {
|
|
method: "HEAD",
|
|
headers: { Authorization: `Bearer ${jwt}` },
|
|
});
|
|
expect(res.status).not.toBe(401);
|
|
expect(res.status).not.toBe(403);
|
|
});
|
|
|
|
it("GET with read:all: auth passes", async () => {
|
|
const server = getTestServer();
|
|
const seed = await seedTestEnvironment(server.prisma);
|
|
const session = await seedTestApiSession(server.prisma, seed.environment);
|
|
const jwt = await mintJwt(seed.apiKey, seed.environment.id, ["read:all"]);
|
|
const res = await server.webapp.fetch(ioPath(session.friendlyId), {
|
|
method: "HEAD",
|
|
headers: { Authorization: `Bearer ${jwt}` },
|
|
});
|
|
expect(res.status).not.toBe(401);
|
|
expect(res.status).not.toBe(403);
|
|
});
|
|
|
|
it("PUT with write:sessions (was a superScope): auth passes", async () => {
|
|
const server = getTestServer();
|
|
const seed = await seedTestEnvironment(server.prisma);
|
|
const session = await seedTestApiSession(server.prisma, seed.environment);
|
|
const jwt = await mintJwt(seed.apiKey, seed.environment.id, ["write:sessions"]);
|
|
const res = await server.webapp.fetch(ioPath(session.friendlyId), {
|
|
method: "PUT",
|
|
headers: { Authorization: `Bearer ${jwt}` },
|
|
});
|
|
expect(res.status).not.toBe(401);
|
|
expect(res.status).not.toBe(403);
|
|
});
|
|
});
|
|
|
|
// ---- Realtime append: POST /realtime/v1/sessions/:session/:io/append
|
|
//
|
|
// action: "write" — multi-key sessions resource.
|
|
describe("Realtime append — POST /realtime/v1/sessions/:session/:io/append", () => {
|
|
const appendPath = (sessionParam: string) =>
|
|
`/realtime/v1/sessions/${sessionParam}/in/append`;
|
|
|
|
const mintJwt = async (apiKey: string, envId: string, scopes: string[]) =>
|
|
generateJWT({
|
|
secretKey: apiKey,
|
|
payload: { pub: true, sub: envId, scopes },
|
|
expirationTime: "15m",
|
|
});
|
|
|
|
it("write:sessions (was a superScope): auth passes", async () => {
|
|
const server = getTestServer();
|
|
const seed = await seedTestEnvironment(server.prisma);
|
|
const session = await seedTestApiSession(server.prisma, seed.environment);
|
|
const jwt = await mintJwt(seed.apiKey, seed.environment.id, ["write:sessions"]);
|
|
const res = await server.webapp.fetch(appendPath(session.friendlyId), {
|
|
method: "POST",
|
|
headers: {
|
|
Authorization: `Bearer ${jwt}`,
|
|
"Content-Type": "application/octet-stream",
|
|
},
|
|
body: new Uint8Array([1, 2, 3]),
|
|
});
|
|
expect(res.status).not.toBe(401);
|
|
expect(res.status).not.toBe(403);
|
|
});
|
|
|
|
it("write:all: auth passes", async () => {
|
|
const server = getTestServer();
|
|
const seed = await seedTestEnvironment(server.prisma);
|
|
const session = await seedTestApiSession(server.prisma, seed.environment);
|
|
const jwt = await mintJwt(seed.apiKey, seed.environment.id, ["write:all"]);
|
|
const res = await server.webapp.fetch(appendPath(session.friendlyId), {
|
|
method: "POST",
|
|
headers: {
|
|
Authorization: `Bearer ${jwt}`,
|
|
"Content-Type": "application/octet-stream",
|
|
},
|
|
body: new Uint8Array([1, 2, 3]),
|
|
});
|
|
expect(res.status).not.toBe(401);
|
|
expect(res.status).not.toBe(403);
|
|
});
|
|
});
|
|
});
|
|
});
|