/** * Authorization rules integration tests: three perspectives — owner / member / no * access; permission boundaries for model config and member management; access control * for Session-level routes via index lookup; default_project deletion protection. */ import { afterEach, beforeEach, describe, expect, it } from "vitest"; import type { MembersResponse, ModelsResponse, ProjectCreateResponse, ProjectsResponse, SessionCreateResponse, } from "../src/api/types.js"; import { apiClient, createTestApp, loginAdmin, provisionUser } from "./helpers.js"; import type { TestApp } from "./helpers.js"; describe("authz", () => { let t: TestApp; let owner: ReturnType; let member: ReturnType; let outsider: ReturnType; let projectId: string; beforeEach(async () => { t = await createTestApp(); const a = await provisionUser(t.app, "owner_a"); const b = await provisionUser(t.app, "member_b"); const c = await provisionUser(t.app, "outsider_c"); owner = apiClient(t.app, a.cookie); member = apiClient(t.app, b.cookie); outsider = apiClient(t.app, c.cookie); const created = (await ( await owner.post("/api/projects", { projectId: "owner_a-shared", name: "Shared project" }) ).json()) as ProjectCreateResponse; projectId = created.project.projectId; const add = await owner.post(`/api/projects/${projectId}/members`, { userId: "member_b" }); expect(add.status).toBe(201); }); afterEach(async () => { await t.cleanup(); }); it("non-member access returns 404 (does not leak existence)", async () => { expect((await outsider.get(`/api/projects/${projectId}/models`)).status).toBe(404); expect((await outsider.get(`/api/projects/${projectId}/agents`)).status).toBe(404); expect((await outsider.get(`/api/projects/${projectId}/members`)).status).toBe(404); expect((await outsider.get(`/api/projects/${projectId}/usage`)).status).toBe(404); // The error table's paging route is checked separately from /usage: it is the one place a // reader can walk past the dashboard's first page, so it must refuse the same way. expect((await outsider.get(`/api/projects/${projectId}/usage/errors`)).status).toBe(404); }); it("member can read models (masked) but not write; owner can write", async () => { const put = await owner.put(`/api/projects/${projectId}/models`, { defaultModel: { provider: "custom", modelId: "m-1" }, models: [{ provider: "custom", modelId: "m-1", apiKey: "sk-super-secret-key-123456" }], }); expect(put.status).toBe(200); const res = await member.get(`/api/projects/${projectId}/models`); expect(res.status).toBe(200); const models = (await res.json()) as ModelsResponse; expect(models.defaultModel).toEqual({ provider: "custom", modelId: "m-1" }); const cred = models.models[0]!.credential!; expect(cred.apiKeyMasked).toBe("sk-s…3456"); expect(JSON.stringify(models)).not.toContain("sk-super-secret-key-123456"); const denied = await member.put(`/api/projects/${projectId}/models`, { models: [{ provider: "custom", modelId: "m-2" }], }); expect(denied.status).toBe(403); }); it("member management owner-only: duplicate and self-grant 409; unknown user 404", async () => { const list = (await ( await member.get(`/api/projects/${projectId}/members`) ).json()) as MembersResponse; expect(list.members.map((m) => `${m.userId}:${m.role}`)).toEqual([ "owner_a:owner", "member_b:member", ]); expect( (await member.post(`/api/projects/${projectId}/members`, { userId: "outsider_c" })).status, ).toBe(403); expect( (await owner.post(`/api/projects/${projectId}/members`, { userId: "member_b" })).status, ).toBe(409); expect( (await owner.post(`/api/projects/${projectId}/members`, { userId: "owner_a" })).status, ).toBe(409); expect( (await owner.post(`/api/projects/${projectId}/members`, { userId: "ghost" })).status, ).toBe(404); // After removing authorization, the member loses access. expect((await owner.delete(`/api/projects/${projectId}/members/member_b`)).status).toBe(204); expect((await member.get(`/api/projects/${projectId}/models`)).status).toBe(404); }); it("Session-level routes authorize via index lookup: member can see, outsider 404", async () => { await owner.put(`/api/projects/${projectId}/models`, { defaultModel: { provider: "anthropic", modelId: "claude-sonnet-4-6" }, models: [{ provider: "anthropic", modelId: "claude-sonnet-4-6" }], }); const created = await owner.post( `/api/projects/${projectId}/agents/default_agent/sessions`, {}, ); expect(created.status).toBe(201); const { session } = (await created.json()) as SessionCreateResponse; expect((await owner.get(`/api/sessions/${session.sessionId}`)).status).toBe(200); expect((await member.get(`/api/sessions/${session.sessionId}`)).status).toBe(200); expect((await outsider.get(`/api/sessions/${session.sessionId}`)).status).toBe(404); expect((await owner.get("/api/sessions/session-unknown")).status).toBe(404); }); it("deleting Projects: owner-only; default_project and the last accessible refuse", async () => { expect((await member.delete(`/api/projects/${projectId}`)).status).toBe(403); expect((await outsider.delete(`/api/projects/${projectId}`)).status).toBe(404); // default_project is managed by admin: non-owners always get 404, and the owner (admin) is refused with 409. expect((await owner.delete("/api/projects/default_project")).status).toBe(404); const admin = apiClient(t.app, (await loginAdmin(t.app)).cookie); expect((await admin.delete("/api/projects/default_project")).status).toBe(409); // outsider-c only has the initial Project created at account setup: deleting it would // leave the account with no usable Project (stuck on the skeleton screen on the Web // side) -> refused with 409. const cProjects = (await (await outsider.get("/api/projects")).json()) as ProjectsResponse; expect(cProjects.projects).toHaveLength(1); expect( (await outsider.delete(`/api/projects/${cProjects.projects[0]!.projectId}`)).status, ).toBe(409); expect((await owner.delete(`/api/projects/${projectId}`)).status).toBe(204); expect((await owner.get(`/api/projects/${projectId}/models`)).status).toBe(404); }); it("PUT models is a full replace: omitted apiKey kept, clearApiKey clears", async () => { await owner.put(`/api/projects/${projectId}/models`, { defaultModel: { provider: "custom", modelId: "m-1" }, models: [ { provider: "custom", modelId: "m-1", contextWindow: 200000, pricing: { cacheRead: 0.3, cacheWrite: 3.75, output: 15 }, apiKey: "sk-original-key-000111", baseUrl: "https://example.com/v1", }, { provider: "custom", modelId: "m-2" }, ], }); // Omitting apiKey: preserves the original credential and created_at. const second = (await ( await owner.put(`/api/projects/${projectId}/models`, { defaultModel: { provider: "custom", modelId: "m-1" }, models: [{ provider: "custom", modelId: "m-1", contextWindow: 100000 }], }) ).json()) as ModelsResponse; expect(second.models).toHaveLength(1); // m-2 was removed by the full-table replace expect(second.models[0]!.credential?.apiKeyMasked).toBe("sk-o…0111"); expect(second.models[0]!.credential?.baseUrl).toBe("https://example.com/v1"); expect(second.models[0]!.credential?.createdAt).toBeTruthy(); expect(second.models[0]!.contextWindow).toBe(100000); expect(second.models[0]!.pricing).toBeUndefined(); // Fields not resubmitted are removed by the replace // clearApiKey + baseUrl null clears the value. const third = (await ( await owner.put(`/api/projects/${projectId}/models`, { defaultModel: { provider: "custom", modelId: "m-1" }, models: [{ provider: "custom", modelId: "m-1", clearApiKey: true, baseUrl: null }], }) ).json()) as ModelsResponse; expect(third.models[0]!.credential).toBeUndefined(); // defaultModel not present in models -> 400. const bad = await owner.put(`/api/projects/${projectId}/models`, { defaultModel: { provider: "custom", modelId: "ghost" }, models: [{ provider: "custom", modelId: "m-1" }], }); expect(bad.status).toBe(400); }); });