Files
triggerdotdev--trigger.dev/apps/webapp/test/dashboardAgentClientMetadata.test.ts
Katia Bulatova d0f06d5c5e fix(webapp,dashboard-agent): address a branch environment by name and branch
The agent could not read anything on a preview or dev branch. Its environment name
is derived from the environment's type, and every branch shares its parent's type —
so the name identifies a family, not a row. The API's env routes are name-addressed,
so a bare "preview" or "dev" resolved to the parent, and the delegated token's
`environmentId` claim then correctly refused it. The guard was the detector, not the
defect: the exchange was never given enough identity to resolve the environment the
dashboard had selected.

Name and branch are now resolved together into one address, so no caller can take
the name without the branch, and both mint sites share the one type map instead of
keeping a copy each. The address travels to the JWT exchange and to the three
delegated-token reads that resolve by name (list_tasks, correlate_version, the repo
snapshot). The env-JWT reads address the environment by id and are untouched.

Second, and why nobody saw the first: the exchange reported the same "no
environment" for a genuine absence and for any failure, and cached the failure for
the whole turn. Following the queue live-read precedent, only a missing environment
is stated as one; anything else says the read didn't land and carries its status.
2026-08-08 16:21:48 +00:00

163 lines
5.6 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
fetch: vi.fn(),
findEnvironmentBySlug: vi.fn<(...args: any[]) => Promise<any>>(),
}));
vi.mock("~/db.server", () => ({ $replica: {} }));
vi.mock("~/env.server", () => ({ env: { SESSION_SECRET: "test-session-secret" } }));
vi.mock("~/services/session.server", () => ({
requireUser: async () => ({ id: "usr_real", admin: false, isImpersonating: false }),
}));
vi.mock("~/v3/canAccessDashboardAgent.server", () => ({
canAccessDashboardAgent: async () => true,
}));
vi.mock("~/models/project.server", () => ({
findProjectBySlug: async () => ({
id: "proj_real",
organizationId: "org_real",
externalRef: "proj_ref_real",
}),
}));
vi.mock("~/models/runtimeEnvironment.server", () => ({
findEnvironmentBySlug: mocks.findEnvironmentBySlug,
}));
vi.mock("~/services/dashboardAgent.server", () => ({
dashboardAgentApiOrigin: () => "https://api.trigger.dev",
mintDashboardAgentUserActorToken: async () => "tr_uat_real",
resolveDashboardAgentRepoSnapshot: async () => null,
}));
vi.mock("~/services/logger.server", () => ({
logger: { debug: vi.fn(), error: vi.fn(), warn: vi.fn(), info: vi.fn() },
}));
import { action } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboard-agent.in.$";
async function appendTurn(metadata: Record<string, unknown>): Promise<Record<string, unknown>> {
const request = new Request(
"https://app.trigger.dev/resources/orgs/acme/projects/api/env/dev/dashboard-agent/in/realtime/v1/sessions/chat_1/in/append",
{
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
kind: "message",
payload: { metadata, message: { parts: [{ type: "text", text: "hi" }] } },
}),
}
);
const response = await action({
request,
params: {
organizationSlug: "acme",
projectParam: "api",
envParam: "dev",
"*": "realtime/v1/sessions/chat_1/in/append",
},
context: {},
} as any);
expect(response.status).toBe(200);
expect(mocks.fetch).toHaveBeenCalledTimes(1);
const forwarded = JSON.parse(mocks.fetch.mock.calls[0][1].body as string);
return forwarded.payload.metadata as Record<string, unknown>;
}
describe("dashboard agent `in` proxy — client metadata", () => {
beforeEach(() => {
mocks.findEnvironmentBySlug.mockReset();
mocks.findEnvironmentBySlug.mockResolvedValue({
id: "env_real",
type: "DEVELOPMENT",
branchName: null,
});
mocks.fetch.mockReset();
mocks.fetch.mockResolvedValue(
new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: { "content-type": "application/json" },
})
);
vi.stubGlobal("fetch", mocks.fetch);
});
it("keeps the whitelisted page context", async () => {
const metadata = await appendTurn({
currentPage: "/orgs/acme/projects/api/env/dev/runs",
pageContext: { kind: "runs" },
});
expect(metadata.currentPage).toBe("/orgs/acme/projects/api/env/dev/runs");
expect(metadata.pageContext).toEqual({ kind: "runs" });
});
it("ignores a client-sent copy of every server-owned field", async () => {
const metadata = await appendTurn({
currentPage: "/runs",
organizationId: "org_evil",
userId: "usr_evil",
projectId: "proj_evil",
projectRef: "proj_ref_evil",
environmentId: "env_evil",
environmentName: "prod",
environmentBranch: "evil-branch",
apiOrigin: "https://evil.example.com",
userActorToken: "tr_uat_evil",
repoSnapshot: { tarballUrl: "https://evil.example.com/x.tar.gz" },
});
expect(metadata.organizationId).toBe("org_real");
expect(metadata.userId).toBe("usr_real");
expect(metadata.projectId).toBe("proj_real");
expect(metadata.projectRef).toBe("proj_ref_real");
expect(metadata.environmentId).toBe("env_real");
expect(metadata.environmentName).toBe("dev");
expect(metadata.environmentBranch).toBeUndefined();
expect(metadata.apiOrigin).toBe("https://api.trigger.dev");
expect(metadata.userActorToken).toBe("tr_uat_real");
expect(metadata.repoSnapshot).toBeUndefined();
});
// The whole address the proxy hands the agent, for each of the four environment shapes. The
// name is shared by a parent and all its branches, so a branch is only addressable when its
// branch travels with the name.
it.each([
["production", { id: "env_prod", type: "PRODUCTION", branchName: null }, "prod", undefined],
["staging", { id: "env_stg", type: "STAGING", branchName: null }, "staging", undefined],
[
"a preview branch",
{ id: "env_preview_branch", type: "PREVIEW", branchName: "feat/checkout" },
"preview",
"feat/checkout",
],
[
"a development branch",
{ id: "env_dev_branch", type: "DEVELOPMENT", branchName: "katia/spike" },
"dev",
"katia/spike",
],
])("addresses %s by the environment it resolved", async (_name, env, expectedName, branch) => {
mocks.findEnvironmentBySlug.mockResolvedValue(env);
const metadata = await appendTurn({ currentPage: "/runs" });
expect(metadata.environmentId).toBe(env.id);
expect(metadata.environmentName).toBe(expectedName);
expect(metadata.environmentBranch).toBe(branch);
});
it("drops any field the server doesn't own", async () => {
const metadata = await appendTurn({
currentPage: "/runs",
evalOptOut: false,
cap: ["admin"],
somethingNew: "smuggled",
});
expect(metadata).not.toHaveProperty("evalOptOut");
expect(metadata).not.toHaveProperty("cap");
expect(metadata).not.toHaveProperty("somethingNew");
});
});