d0f06d5c5e
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.
163 lines
5.6 KiB
TypeScript
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");
|
|
});
|
|
});
|