Skip to content

Commit d6debe8

Browse files
committed
test(webapp): cover the user-actor token's environment scope on every route that accepts one
1 parent 5b5a258 commit d6debe8

1 file changed

Lines changed: 313 additions & 0 deletions

File tree

Lines changed: 313 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,313 @@
1+
import { beforeEach, describe, expect, it, vi } from "vitest";
2+
3+
/**
4+
* A user-actor token minted for one environment must not be honoured against another, even when
5+
* its user is a member of both. These drive each UAT-accepting route with a real token through the
6+
* real preamble and the real environment resolution; only the database and the RBAC plugin are stubbed.
7+
*/
8+
9+
const { SESSION_SECRET } = vi.hoisted(() => ({
10+
SESSION_SECRET: "test-session-secret-for-uat-environment-claim",
11+
}));
12+
13+
const mocks = vi.hoisted(() => ({
14+
can: vi.fn<(...args: any[]) => boolean>(),
15+
resolveRunCommit: vi.fn<(...args: any[]) => Promise<any>>(),
16+
resolveDashboardAgentRepoSnapshot: vi.fn<(...args: any[]) => Promise<any>>(),
17+
findCurrentWorkerFromEnvironment: vi.fn<(...args: any[]) => Promise<any>>(),
18+
}));
19+
20+
vi.mock("@internal/tracing", () => ({
21+
getMeter: () => ({
22+
createCounter: () => ({ add: vi.fn() }),
23+
createHistogram: () => ({ record: vi.fn() }),
24+
createObservableGauge: () => ({ addCallback: vi.fn() }),
25+
}),
26+
}));
27+
vi.mock("~/env.server", () => ({
28+
env: { SESSION_SECRET, APP_ORIGIN: "https://example.com" },
29+
}));
30+
vi.mock("~/services/logger.server", () => ({
31+
logger: { debug: vi.fn(), error: vi.fn(), warn: vi.fn(), info: vi.fn() },
32+
}));
33+
vi.mock("~/services/rbac.server", () => ({
34+
rbac: {
35+
authenticateBearer: vi.fn(),
36+
authenticateUserActor: async () => ({ ok: true, ability: { can: mocks.can } }),
37+
authenticatePat: async () => ({ ok: true, ability: { can: mocks.can } }),
38+
},
39+
}));
40+
vi.mock("~/services/personalAccessToken.server", () => ({
41+
authenticateApiRequestWithPersonalAccessToken: vi.fn(),
42+
isPersonalAccessToken: () => false,
43+
}));
44+
vi.mock("~/services/organizationAccessToken.server", () => ({
45+
authenticateApiRequestWithOrganizationAccessToken: vi.fn(),
46+
isOrganizationAccessToken: () => false,
47+
}));
48+
vi.mock("~/services/realtime/jwtAuth.server", () => ({
49+
isPublicJWT: () => false,
50+
validatePublicJwtKey: vi.fn(),
51+
}));
52+
vi.mock("~/models/project.server", () => ({
53+
findProjectByRef: async (externalRef: string, userId: string) =>
54+
externalRef === PROJECT.externalRef && MEMBER_USER_IDS.includes(userId) ? PROJECT : null,
55+
}));
56+
vi.mock("~/models/runtimeEnvironment.server", () => ({
57+
authIncludeBase: {},
58+
authIncludeWithParent: {},
59+
findEnvironmentByApiKey: vi.fn(),
60+
findEnvironmentByApiKeyWithResolution: vi.fn(),
61+
findEnvironmentByPublicApiKey: vi.fn(),
62+
toAuthenticated: (environment: any) => environment,
63+
}));
64+
vi.mock("~/db.server", () => ({
65+
prisma: {},
66+
$replica: {
67+
user: {
68+
findUnique: async ({ where }: any) =>
69+
MEMBER_USER_IDS.includes(where.id) ? { id: where.id } : null,
70+
},
71+
runtimeEnvironment: {
72+
findFirst: async ({ where }: any) =>
73+
ENVIRONMENTS.find((env) => env.projectId === where.projectId && env.slug === where.slug) ??
74+
null,
75+
},
76+
workerDeployment: { findFirst: async () => null },
77+
backgroundWorkerTask: { findMany: async () => [] },
78+
},
79+
}));
80+
vi.mock("~/services/dashboardAgent.server", () => ({
81+
resolveRunCommit: mocks.resolveRunCommit,
82+
resolveDashboardAgentRepoSnapshot: mocks.resolveDashboardAgentRepoSnapshot,
83+
}));
84+
vi.mock("~/v3/models/workerDeployment.server", () => ({
85+
findCurrentWorkerFromEnvironment: mocks.findCurrentWorkerFromEnvironment,
86+
}));
87+
88+
import { signUserActorToken } from "@trigger.dev/rbac";
89+
import { action as jwtAction } from "~/routes/api.v1.projects.$projectRef.$env.jwt";
90+
import { loader as commitLoader } from "~/routes/api.v1.projects.$projectRef.$env.runs.$runId.commit";
91+
import { loader as snapshotLoader } from "~/routes/api.v1.projects.$projectRef.$env.repo.snapshot";
92+
import { loader as workersLoader } from "~/routes/api.v1.projects.$projectRef.$env.workers.$tagName";
93+
import { authenticatedEnvironmentForAuthentication } from "~/services/apiAuth.server";
94+
import { assertUserActorEnvironment } from "~/services/userActorEnvironment.server";
95+
96+
const ORGANIZATION = { id: "org_1234", slug: "test-org" };
97+
const PROJECT = { id: "proj_1234", externalRef: "proj_ref_1234", slug: "test-project" };
98+
const USER_ID = "usr_member";
99+
const MEMBER_USER_IDS = [USER_ID];
100+
101+
function environment(id: string, slug: string, type: "PRODUCTION" | "STAGING") {
102+
return {
103+
id,
104+
slug,
105+
type,
106+
apiKey: `tr_${slug}_abcdefghijklmnop`,
107+
organizationId: ORGANIZATION.id,
108+
organization: ORGANIZATION,
109+
projectId: PROJECT.id,
110+
project: PROJECT,
111+
};
112+
}
113+
114+
// Two environments of the same project, both reachable by the same member.
115+
const ENV_A = environment("env_aaaa", "prod", "PRODUCTION");
116+
const ENV_B = environment("env_bbbb", "stg", "STAGING");
117+
const ENVIRONMENTS = [ENV_A, ENV_B];
118+
119+
function mintToken(opts: { environmentId?: string } = {}) {
120+
return signUserActorToken(SESSION_SECRET, {
121+
userId: USER_ID,
122+
client: "dashboard-agent",
123+
...(opts.environmentId ? { environmentId: opts.environmentId } : {}),
124+
cap: ["read:apiKeys", "read:runs", "read:deployments"],
125+
});
126+
}
127+
128+
/** A route throws its json Response for the failures it doesn't build itself. */
129+
async function respond(call: () => Promise<Response>): Promise<Response> {
130+
try {
131+
return await call();
132+
} catch (error) {
133+
if (error instanceof Response) return error;
134+
throw error;
135+
}
136+
}
137+
138+
function requestFor(token: string, url: string, init?: RequestInit) {
139+
return new Request(`https://example.com${url}`, {
140+
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
141+
...init,
142+
});
143+
}
144+
145+
type RouteCase = {
146+
name: string;
147+
/** `env` is the URL slug the route resolves from, not the environment id. */
148+
call: (token: string, env: string) => Promise<Response>;
149+
};
150+
151+
const ROUTE_CASES: RouteCase[] = [
152+
{
153+
name: "env JWT exchange",
154+
call: (token, env) =>
155+
respond(
156+
() =>
157+
jwtAction({
158+
request: requestFor(token, `/api/v1/projects/${PROJECT.externalRef}/${env}/jwt`, {
159+
method: "POST",
160+
body: JSON.stringify({}),
161+
}),
162+
params: { projectRef: PROJECT.externalRef, env },
163+
context: {} as any,
164+
}) as Promise<Response>
165+
),
166+
},
167+
{
168+
name: "repo snapshot",
169+
call: (token, env) =>
170+
respond(
171+
() =>
172+
snapshotLoader({
173+
request: requestFor(
174+
token,
175+
`/api/v1/projects/${PROJECT.externalRef}/${env}/repo/snapshot`
176+
),
177+
params: { projectRef: PROJECT.externalRef, env },
178+
context: {} as any,
179+
}) as Promise<Response>
180+
),
181+
},
182+
{
183+
name: "worker by tag",
184+
call: (token, env) =>
185+
respond(
186+
() =>
187+
workersLoader({
188+
request: requestFor(
189+
token,
190+
`/api/v1/projects/${PROJECT.externalRef}/${env}/workers/current`
191+
),
192+
params: { projectRef: PROJECT.externalRef, env, tagName: "current" },
193+
context: {} as any,
194+
}) as Promise<Response>
195+
),
196+
},
197+
{
198+
name: "run commit",
199+
call: (token, env) =>
200+
respond(
201+
() =>
202+
commitLoader({
203+
request: requestFor(
204+
token,
205+
`/api/v1/projects/${PROJECT.externalRef}/${env}/runs/run_1234/commit`
206+
),
207+
params: { projectRef: PROJECT.externalRef, env, runId: "run_1234" },
208+
context: {} as any,
209+
}) as Promise<Response>
210+
),
211+
},
212+
];
213+
214+
describe("user-actor token environment scope", () => {
215+
beforeEach(() => {
216+
mocks.can.mockReset();
217+
mocks.can.mockReturnValue(true);
218+
mocks.resolveRunCommit.mockResolvedValue({
219+
sha: "abc123",
220+
version: "20240101.1",
221+
dirty: false,
222+
});
223+
mocks.resolveDashboardAgentRepoSnapshot.mockResolvedValue({
224+
url: "https://example.com/archive.tar.gz",
225+
});
226+
mocks.findCurrentWorkerFromEnvironment.mockResolvedValue({
227+
id: "worker_1",
228+
friendlyId: "worker_1234",
229+
version: "20240101.1",
230+
engine: "V2",
231+
sdkVersion: "4.0.0",
232+
cliVersion: "4.0.0",
233+
});
234+
});
235+
236+
describe.each(ROUTE_CASES)("$name", ({ call }) => {
237+
it("403s a token minted for another environment", async () => {
238+
const token = await mintToken({ environmentId: ENV_A.id });
239+
240+
const response = await call(token, "staging");
241+
242+
expect(response.status).toBe(403);
243+
expect(await response.json()).toMatchObject({ code: "forbidden_environment" });
244+
});
245+
246+
it("allows the environment the token was minted for", async () => {
247+
const token = await mintToken({ environmentId: ENV_A.id });
248+
249+
const response = await call(token, "prod");
250+
251+
expect(response.status).toBe(200);
252+
});
253+
254+
it("allows an environment-agnostic token (no claim)", async () => {
255+
const token = await mintToken();
256+
257+
const response = await call(token, "staging");
258+
259+
expect(response.status).toBe(200);
260+
});
261+
});
262+
263+
it("leaves a caller with no user-actor token alone", async () => {
264+
const resolved = await authenticatedEnvironmentForAuthentication(
265+
{ type: "personalAccessToken", result: { userId: USER_ID } },
266+
PROJECT.externalRef,
267+
"stg"
268+
);
269+
270+
expect(resolved.id).toBe(ENV_B.id);
271+
});
272+
273+
it("throws only on a mismatch", () => {
274+
expect(() => assertUserActorEnvironment(undefined, ENV_A.id)).not.toThrow();
275+
expect(() => assertUserActorEnvironment({ userId: USER_ID }, ENV_A.id)).not.toThrow();
276+
expect(() =>
277+
assertUserActorEnvironment({ userId: USER_ID, environmentId: ENV_A.id }, ENV_A.id)
278+
).not.toThrow();
279+
expect(() =>
280+
assertUserActorEnvironment({ userId: USER_ID, environmentId: ENV_A.id }, ENV_B.id)
281+
).toThrow();
282+
});
283+
});
284+
285+
describe("repo snapshot authorization", () => {
286+
beforeEach(() => {
287+
mocks.can.mockReset();
288+
mocks.resolveDashboardAgentRepoSnapshot.mockReset();
289+
mocks.resolveDashboardAgentRepoSnapshot.mockResolvedValue({
290+
url: "https://example.com/archive.tar.gz",
291+
});
292+
});
293+
294+
it("403s a role that can't read the environment's secrets", async () => {
295+
mocks.can.mockReturnValue(false);
296+
const token = await mintToken({ environmentId: ENV_A.id });
297+
298+
const response = await ROUTE_CASES[1].call(token, "prod");
299+
300+
expect(response.status).toBe(403);
301+
expect(mocks.resolveDashboardAgentRepoSnapshot).not.toHaveBeenCalled();
302+
});
303+
304+
it("serves the archive pointer to a role that can", async () => {
305+
mocks.can.mockReturnValue(true);
306+
const token = await mintToken({ environmentId: ENV_A.id });
307+
308+
const response = await ROUTE_CASES[1].call(token, "prod");
309+
310+
expect(response.status).toBe(200);
311+
expect(await response.json()).toMatchObject({ url: "https://example.com/archive.tar.gz" });
312+
});
313+
});

0 commit comments

Comments
 (0)