|
| 1 | +import { beforeEach, describe, expect, it, vi } from "vitest"; |
| 2 | + |
| 3 | +// The `in` proxy is the one path a browser reaches the agent through, and it injects the turn's |
| 4 | +// identity, tenancy and delegated token. Whatever the browser sends must not be able to set any of |
| 5 | +// those fields — not by overwriting them, and not by smuggling in a field the server doesn't own. |
| 6 | + |
| 7 | +const mocks = vi.hoisted(() => ({ |
| 8 | + fetch: vi.fn(), |
| 9 | +})); |
| 10 | + |
| 11 | +vi.mock("~/db.server", () => ({ $replica: {} })); |
| 12 | +vi.mock("~/env.server", () => ({ env: { SESSION_SECRET: "test-session-secret" } })); |
| 13 | +vi.mock("~/services/session.server", () => ({ |
| 14 | + requireUser: async () => ({ id: "usr_real", admin: false, isImpersonating: false }), |
| 15 | +})); |
| 16 | +vi.mock("~/v3/canAccessDashboardAgent.server", () => ({ |
| 17 | + canAccessDashboardAgent: async () => true, |
| 18 | +})); |
| 19 | +vi.mock("~/models/project.server", () => ({ |
| 20 | + findProjectBySlug: async () => ({ |
| 21 | + id: "proj_real", |
| 22 | + organizationId: "org_real", |
| 23 | + externalRef: "proj_ref_real", |
| 24 | + }), |
| 25 | +})); |
| 26 | +vi.mock("~/models/runtimeEnvironment.server", () => ({ |
| 27 | + findEnvironmentBySlug: async () => ({ id: "env_real", type: "DEVELOPMENT" }), |
| 28 | +})); |
| 29 | +vi.mock("~/services/dashboardAgent.server", () => ({ |
| 30 | + dashboardAgentApiOrigin: () => "https://api.trigger.dev", |
| 31 | + dashboardAgentEnvironmentName: () => "dev", |
| 32 | + mintDashboardAgentUserActorToken: async () => "tr_uat_real", |
| 33 | + resolveDashboardAgentRepoSnapshot: async () => null, |
| 34 | +})); |
| 35 | +vi.mock("~/services/logger.server", () => ({ |
| 36 | + logger: { debug: vi.fn(), error: vi.fn(), warn: vi.fn(), info: vi.fn() }, |
| 37 | +})); |
| 38 | + |
| 39 | +import { action } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboard-agent.in.$"; |
| 40 | + |
| 41 | +async function appendTurn(metadata: Record<string, unknown>): Promise<Record<string, unknown>> { |
| 42 | + const request = new Request( |
| 43 | + "https://app.trigger.dev/resources/orgs/acme/projects/api/env/dev/dashboard-agent/in/realtime/v1/sessions/chat_1/in/append", |
| 44 | + { |
| 45 | + method: "POST", |
| 46 | + headers: { "content-type": "application/json" }, |
| 47 | + body: JSON.stringify({ |
| 48 | + kind: "message", |
| 49 | + payload: { metadata, message: { parts: [{ type: "text", text: "hi" }] } }, |
| 50 | + }), |
| 51 | + } |
| 52 | + ); |
| 53 | + |
| 54 | + const response = await action({ |
| 55 | + request, |
| 56 | + params: { |
| 57 | + organizationSlug: "acme", |
| 58 | + projectParam: "api", |
| 59 | + envParam: "dev", |
| 60 | + "*": "realtime/v1/sessions/chat_1/in/append", |
| 61 | + }, |
| 62 | + context: {}, |
| 63 | + } as any); |
| 64 | + |
| 65 | + expect(response.status).toBe(200); |
| 66 | + expect(mocks.fetch).toHaveBeenCalledTimes(1); |
| 67 | + const forwarded = JSON.parse(mocks.fetch.mock.calls[0][1].body as string); |
| 68 | + return forwarded.payload.metadata as Record<string, unknown>; |
| 69 | +} |
| 70 | + |
| 71 | +describe("dashboard agent `in` proxy — client metadata", () => { |
| 72 | + beforeEach(() => { |
| 73 | + mocks.fetch.mockReset(); |
| 74 | + mocks.fetch.mockResolvedValue( |
| 75 | + new Response(JSON.stringify({ ok: true }), { |
| 76 | + status: 200, |
| 77 | + headers: { "content-type": "application/json" }, |
| 78 | + }) |
| 79 | + ); |
| 80 | + vi.stubGlobal("fetch", mocks.fetch); |
| 81 | + }); |
| 82 | + |
| 83 | + it("keeps the whitelisted page context", async () => { |
| 84 | + const metadata = await appendTurn({ |
| 85 | + currentPage: "/orgs/acme/projects/api/env/dev/runs", |
| 86 | + pageContext: { kind: "runs" }, |
| 87 | + }); |
| 88 | + |
| 89 | + expect(metadata.currentPage).toBe("/orgs/acme/projects/api/env/dev/runs"); |
| 90 | + expect(metadata.pageContext).toEqual({ kind: "runs" }); |
| 91 | + }); |
| 92 | + |
| 93 | + it("ignores a client-sent copy of every server-owned field", async () => { |
| 94 | + const metadata = await appendTurn({ |
| 95 | + currentPage: "/runs", |
| 96 | + organizationId: "org_evil", |
| 97 | + userId: "usr_evil", |
| 98 | + projectId: "proj_evil", |
| 99 | + projectRef: "proj_ref_evil", |
| 100 | + environmentId: "env_evil", |
| 101 | + environmentName: "prod", |
| 102 | + apiOrigin: "https://evil.example.com", |
| 103 | + userActorToken: "tr_uat_evil", |
| 104 | + repoSnapshot: { tarballUrl: "https://evil.example.com/x.tar.gz" }, |
| 105 | + }); |
| 106 | + |
| 107 | + expect(metadata.organizationId).toBe("org_real"); |
| 108 | + expect(metadata.userId).toBe("usr_real"); |
| 109 | + expect(metadata.projectId).toBe("proj_real"); |
| 110 | + expect(metadata.projectRef).toBe("proj_ref_real"); |
| 111 | + expect(metadata.environmentId).toBe("env_real"); |
| 112 | + expect(metadata.environmentName).toBe("dev"); |
| 113 | + expect(metadata.apiOrigin).toBe("https://api.trigger.dev"); |
| 114 | + expect(metadata.userActorToken).toBe("tr_uat_real"); |
| 115 | + // Not resolved for this project, so the client's pointer must not stand in for it. |
| 116 | + expect(metadata.repoSnapshot).toBeUndefined(); |
| 117 | + }); |
| 118 | + |
| 119 | + it("drops any field the server doesn't own", async () => { |
| 120 | + const metadata = await appendTurn({ |
| 121 | + currentPage: "/runs", |
| 122 | + evalOptOut: false, |
| 123 | + cap: ["admin"], |
| 124 | + somethingNew: "smuggled", |
| 125 | + }); |
| 126 | + |
| 127 | + expect(metadata).not.toHaveProperty("evalOptOut"); |
| 128 | + expect(metadata).not.toHaveProperty("cap"); |
| 129 | + expect(metadata).not.toHaveProperty("somethingNew"); |
| 130 | + }); |
| 131 | +}); |
0 commit comments