Files
triggerdotdev--trigger.dev/apps/webapp/test/auth-cross-cutting.e2e.full.test.ts
2026-07-13 13:32:57 +08:00

217 lines
9.0 KiB
TypeScript

// Cross-cutting auth-layer behaviours that aren't tied to a specific route
// family — see TRI-8743. Soft-deleted projects, revoked keys, expired JWTs,
// cross-env mismatch, force-fallback toggle.
//
// Strategy: pick one representative API-key route
// (GET /api/v1/runs/run_doesnotexist/result) and one representative JWT
// route (POST /api/v1/waitpoints/tokens/<id>/complete) and exercise the
// edge cases against those. The route choice doesn't matter — the
// auth layer is shared across every API route via apiBuilder.server.ts.
// Smoke matrix (api-auth.e2e.test.ts) already covers the trivial
// cases (missing/invalid key, basic JWT pass, soft-deleted project);
// this file adds cases that need explicit fixture setup.
import { generateJWT } from "@trigger.dev/core/v3/jwt";
import { SignJWT } from "jose";
import { describe, expect, it } from "vitest";
import { getTestServer } from "./helpers/sharedTestServer";
import { seedTestEnvironment } from "./helpers/seedTestEnvironment";
describe("Cross-cutting", () => {
it("shared prisma client can read from the postgres container", async () => {
const server = getTestServer();
const count = await server.prisma.user.count();
expect(count).toBeGreaterThanOrEqual(0);
});
// The auth path falls back to RevokedApiKey when a key isn't found
// in RuntimeEnvironment — letting customers continue to use a key
// for a configurable grace window after rotation. See
// models/runtimeEnvironment.server.ts. The grace lookup matches by
// (apiKey AND expiresAt > now) and rehydrates the env via the FK.
describe("Revoked API key grace window", () => {
const route = "/api/v1/runs/run_doesnotexist/result";
it("revoked key within grace (expiresAt > now): auth passes", async () => {
const server = getTestServer();
const { environment } = await seedTestEnvironment(server.prisma);
// Mint a fresh "rotated" key that doesn't exist on any env, then
// record it as recently revoked with a future grace expiry.
const rotatedKey = `tr_dev_rotated_${Math.random().toString(36).slice(2)}`;
await server.prisma.revokedApiKey.create({
data: {
apiKey: rotatedKey,
runtimeEnvironmentId: environment.id,
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // +1 day
},
});
const res = await server.webapp.fetch(route, {
headers: { Authorization: `Bearer ${rotatedKey}` },
});
// Auth passed — the route's resource lookup just doesn't find
// run_doesnotexist. The point is NOT 401.
expect(res.status).not.toBe(401);
});
it("revoked key past grace (expiresAt < now): 401", async () => {
const server = getTestServer();
const { environment } = await seedTestEnvironment(server.prisma);
const expiredKey = `tr_dev_expired_${Math.random().toString(36).slice(2)}`;
await server.prisma.revokedApiKey.create({
data: {
apiKey: expiredKey,
runtimeEnvironmentId: environment.id,
expiresAt: new Date(Date.now() - 60 * 1000), // -1 minute
},
});
const res = await server.webapp.fetch(route, {
headers: { Authorization: `Bearer ${expiredKey}` },
});
expect(res.status).toBe(401);
});
});
// JWT edge cases beyond what the smoke matrix covers (which only
// checks "wrong key" and "missing scope"). All target the same
// representative JWT route — the JWT validator is shared across
// routes via apiBuilder, so coverage here generalises.
describe("JWT edge cases", () => {
const route = "/api/v1/waitpoints/tokens/wp_does_not_exist/complete";
async function postWithJwt(jwt: string) {
const server = getTestServer();
return server.webapp.fetch(route, {
method: "POST",
headers: {
Authorization: `Bearer ${jwt}`,
"Content-Type": "application/json",
},
body: JSON.stringify({}),
});
}
it("JWT with expirationTime in the past: 401", async () => {
const server = getTestServer();
const { environment } = await seedTestEnvironment(server.prisma);
// generateJWT only accepts string expirationTimes (relative, like
// "15m"). To create a definitively-expired token use jose
// directly with an absolute past timestamp.
const secret = new TextEncoder().encode(environment.apiKey);
const jwt = await new SignJWT({
pub: true,
sub: environment.id,
scopes: ["write:waitpoints"],
})
.setIssuer("https://id.trigger.dev")
.setAudience("https://api.trigger.dev")
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt(0)
.setExpirationTime(1) // 1970-01-01 — definitively expired
.sign(secret);
const res = await postWithJwt(jwt);
expect(res.status).toBe(401);
});
it("JWT with pub: false: 401", async () => {
const server = getTestServer();
const { environment } = await seedTestEnvironment(server.prisma);
const jwt = await generateJWT({
secretKey: environment.apiKey,
payload: { pub: false, sub: environment.id, scopes: ["write:waitpoints"] },
expirationTime: "15m",
});
// pub: false means "this token isn't meant for client-side use"
// — the auth layer rejects it for the same-class JWT routes.
const res = await postWithJwt(jwt);
expect(res.status).toBe(401);
});
it("JWT with no sub claim: 401", async () => {
const server = getTestServer();
const { environment } = await seedTestEnvironment(server.prisma);
const jwt = await generateJWT({
secretKey: environment.apiKey,
payload: { pub: true, scopes: ["write:waitpoints"] },
expirationTime: "15m",
});
// No sub claim — auth can't resolve which env the token belongs
// to, so it must reject. (sub carries the env id.)
const res = await postWithJwt(jwt);
expect(res.status).toBe(401);
});
it("JWT signed with another env's apiKey (cross-env): 401", async () => {
const server = getTestServer();
// env A's id but signed with env B's apiKey — sub-vs-signature
// mismatch the auth layer must catch.
const a = await seedTestEnvironment(server.prisma);
const b = await seedTestEnvironment(server.prisma);
const jwt = await generateJWT({
secretKey: b.apiKey, // <-- WRONG key relative to the sub claim
payload: { pub: true, sub: a.environment.id, scopes: ["write:waitpoints"] },
expirationTime: "15m",
});
const res = await postWithJwt(jwt);
expect(res.status).toBe(401);
});
it("JWT malformed (three parts but invalid base64 in payload): 401", async () => {
// Three "."-separated parts so the JWT shape gate sees it as a
// candidate, but the payload segment is non-base64 garbage.
// Validator must surface this as 401, not 500.
const malformed = "eyJhbGciOiJIUzI1NiJ9.@@@notbase64@@@.signature";
const res = await postWithJwt(malformed);
expect(res.status).toBe(401);
});
});
// The auth layer resolves the JWT's env from the `sub` claim — NOT
// from the route path. So a JWT for env A hitting a route that
// fetches a resource from env B should never accidentally see env
// B's data. Test by minting a JWT for env A and asking for a
// resource that lives in env B — expect 404 (not 200).
describe("Cross-environment: JWT auth resolves env from sub, not URL", () => {
it("env A's JWT cannot read env B's resource: 404", async () => {
const server = getTestServer();
const a = await seedTestEnvironment(server.prisma);
const b = await seedTestEnvironment(server.prisma);
// Seed a real-ish run row in env B so the route would have
// something to find IF auth resolved the env from the URL.
const friendlyId = `run_${Math.random().toString(36).slice(2, 10)}`;
await server.prisma.taskRun.create({
data: {
friendlyId,
taskIdentifier: "test-task",
payload: "{}",
payloadType: "application/json",
traceId: `trace_${Math.random().toString(36).slice(2)}`,
spanId: `span_${Math.random().toString(36).slice(2)}`,
runtimeEnvironmentId: b.environment.id,
projectId: b.project.id,
organizationId: b.organization.id,
engine: "V2",
status: "COMPLETED_SUCCESSFULLY",
queue: "task/test-task",
},
});
const jwt = await generateJWT({
secretKey: a.apiKey,
payload: { pub: true, sub: a.environment.id, scopes: ["read:runs"] },
expirationTime: "15m",
});
const res = await server.webapp.fetch(`/api/v1/runs/${friendlyId}/result`, {
headers: { Authorization: `Bearer ${jwt}` },
});
// The route resolves runs scoped to the JWT's env (env A). The
// run lives in env B, so env A's view returns "not found" —
// critically, NOT 200.
expect(res.status).not.toBe(200);
expect([401, 404]).toContain(res.status);
});
});
});