/** * AI Software Factory — Integration: Spaces (Confluence expansion T-136/T-137) * * Space directory (filter/sort/search), space type + archive/restore, and * space stats — with the security-critical negative cases: * - a non-member can NEVER read/list/stats another workspace (no-leak) * - archived spaces are hidden from the directory by default * - archive/restore is owner/admin-only */ import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { createTestApp, type TestAppHandle } from "./helpers/api"; import { addMemberViaSeam, authHeaders, countAuditEvents, createWorkspace, createUser } from "./helpers/seed"; let app: TestAppHandle; beforeAll(async () => { app = await createTestApp(); }, 180_000); afterAll(async () => { await app?.close(); }); const json = async (res: Response): Promise> => (await res.json().catch(() => ({}))) as Record; /** Create a workspace owned by `user` (returns its id). */ async function mkWs(user: { userId: string; accessToken: string }, name: string) { const { workspaceId } = await createWorkspace( app.request, authHeaders(user.accessToken!), name ); return workspaceId; } describe("Spaces: directory (T-136/T-137)", () => { it("lists only the caller's workspaces (non-member leak check)", async () => { const alice = await createUser(app.request, { name: "Dir Alice" }); const bob = await createUser(app.request, { name: "Dir Bob" }); const aliceWs = await mkWs(alice, "Alice Only Space"); await mkWs(alice, "Alice Second"); await mkWs(bob, "Bob Secret Space"); const res = await app.request("GET", "/api/v1/workspaces", { headers: authHeaders(alice.accessToken!) }); expect(res.status).toBe(200); const body = await json(res); const names = (body.data as Array<{ name: string }>).map((w) => w.name); expect(names).toContain("Alice Only Space"); expect(names).toContain("Alice Second"); expect(names).not.toContain("Bob Secret Space"); }); it("search + type + sort filters", async () => { const u = await createUser(app.request, { name: "Dir Filter" }); const wsA = await mkWs(u, "Alpha Dir"); const wsB = await mkWs(u, "Beta Dir"); // set types directly (PATCH is admin-gated; this is the owner) for (const [id, type] of [[wsA, "project"], [wsB, "kb"]] as const) { const r = await app.request("PATCH", `/api/v1/workspaces/${id}`, { headers: authHeaders(u.accessToken!), body: { type } }); expect(r.status).toBe(200); } const search = await app.request("GET", "/api/v1/workspaces?search=Alpha", { headers: authHeaders(u.accessToken!) }); const searchBody = await json(search); const searchNames = (searchBody.data as Array<{ name: string }>).map((w) => w.name); expect(searchNames).toContain("Alpha Dir"); expect(searchNames).not.toContain("Beta Dir"); const byType = await app.request("GET", "/api/v1/workspaces?type=kb", { headers: authHeaders(u.accessToken!) }); const typeBody = await json(byType); const typeNames = (typeBody.data as Array<{ name: string; type: string }>).map((w) => w.name); expect(typeNames).toContain("Beta Dir"); expect(typeNames).not.toContain("Alpha Dir"); const sorted = await app.request("GET", "/api/v1/workspaces?sort=name", { headers: authHeaders(u.accessToken!) }); const sortedBody = await json(sorted); const sortedNames = (sortedBody.data as Array<{ name: string }>).map((w) => w.name); const filtered = sortedNames.filter((n) => n === "Alpha Dir" || n === "Beta Dir"); expect(filtered).toEqual(["Alpha Dir", "Beta Dir"]); }); it("requires auth", async () => { const res = await app.request("GET", "/api/v1/workspaces"); expect(res.status).toBe(401); }); }); describe("Spaces: archive / restore (T-136)", () => { it("owner archives + restores; hidden from directory when archived", async () => { const u = await createUser(app.request, { name: "Archive Owner" }); const ws = await mkWs(u, "Archivable Space"); const arch = await app.request("POST", `/api/v1/workspaces/${ws}/archive`, { headers: authHeaders(u.accessToken!) }); // POST create-style → 201 (repo convention, e.g. workspace.create). expect(arch.status).toBe(201); const archBody = await json(arch); expect(archBody.archived).toBe(true); // hidden from the default directory listing const list = await app.request("GET", "/api/v1/workspaces", { headers: authHeaders(u.accessToken!) }); const listBody = await json(list); const listNames = (listBody.data as Array<{ name: string }>).map((w) => w.name); expect(listNames).not.toContain("Archivable Space"); // still present when explicitly requesting archived const withArchived = await app.request("GET", "/api/v1/workspaces?archived=true", { headers: authHeaders(u.accessToken!) }); const withArchivedBody = await json(withArchived); const withArchivedNames = (withArchivedBody.data as Array<{ name: string }>).map((w) => w.name); expect(withArchivedNames).toContain("Archivable Space"); const restore = await app.request("POST", `/api/v1/workspaces/${ws}/archive/restore`, { headers: authHeaders(u.accessToken!) }); expect(restore.status).toBe(201); const restoreBody = await json(restore); expect(restoreBody.archived).toBe(false); const db = await app.prisma.workspace.findUnique({ where: { id: ws } }); expect(db?.archivedAt).toBeNull(); }); it("archive is owner/admin-only (member → 403) + audit event", async () => { const owner = await createUser(app.request, { name: "Arch Owner" }); const member = await createUser(app.request, { name: "Arch Member" }); const ws = await mkWs(owner, "Archive Guard"); // The internal seam is service-token guarded (not user-auth) and takes the // member's EMAIL + role (mirrors workspace.integration.test.ts). await addMemberViaSeam(app.request, app.internalServiceToken, ws, member.email, "MEMBER"); const before = await countAuditEvents(app.prisma, { action: "workspace.archived" }); const res = await app.request("POST", `/api/v1/workspaces/${ws}/archive`, { headers: authHeaders(member.accessToken!) }); expect(res.status).toBe(403); // owner can, and it is audited const ok = await app.request("POST", `/api/v1/workspaces/${ws}/archive`, { headers: authHeaders(owner.accessToken!) }); expect(ok.status).toBe(201); const after = await countAuditEvents(app.prisma, { action: "workspace.archived" }); expect(after).toBe(before + 1); }); }); describe("Spaces: stats (T-137)", () => { it("member sees stats; non-member gets 403 (no-leak)", async () => { const owner = await createUser(app.request, { name: "Stats Owner" }); const outsider = await createUser(app.request, { name: "Stats Outsider" }); const ws = await mkWs(owner, "Stats Space"); const res = await app.request("GET", `/api/v1/workspaces/${ws}/stats`, { headers: authHeaders(owner.accessToken!) }); expect(res.status).toBe(200); const body = await json(res); expect(body.type).toBe("team"); expect((body.content as { members: number }).members).toBeGreaterThanOrEqual(1); const leak = await app.request("GET", `/api/v1/workspaces/${ws}/stats`, { headers: authHeaders(outsider.accessToken!) }); expect(leak.status).toBe(403); }); });