/** * AI Software Factory — Integration: Workspaces (Phase 2) * * Workspace CRUD, membership + roles + invitations, and — critically — * tenant isolation: a user with no membership cannot read, list, or mutate * another user's workspace through ANY endpoint. * * Request helper contract (helpers/api.ts): * app.request(method, urlPath, { body?, headers? }) → Response */ import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { createHash } from "node:crypto"; import { createTestApp, type TestAppHandle } from "./helpers/api"; import { addMemberViaSeam, authHeaders, countAuditEvents, createWorkspace, createUser } from "./helpers/seed"; /** The API's token hash (apps/api/src/lib/crypto.ts sha256hex). */ const sha256hex = (v: string): string => createHash("sha256").update(v).digest("hex"); 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; describe("Workspace integration", () => { it("create: owner membership + audit event", async () => { const owner = await createUser(app.request, { name: "WS Owner" }); const auditBefore = await countAuditEvents(app.prisma, { action: "workspace.created", }); const { workspaceId } = await createWorkspace( app.request, authHeaders(owner.accessToken!), "Audit WS", ); const auditAfter = await countAuditEvents(app.prisma, { action: "workspace.created", }); expect(auditAfter).toBe(auditBefore + 1); const membership = await app.prisma.workspaceMember.findUnique({ where: { workspaceId_userId: { workspaceId, userId: owner.userId } }, }); expect(membership?.role).toBe("OWNER"); const ws = await app.prisma.workspace.findUnique({ where: { id: workspaceId } }); expect(ws?.name).toBe("Audit WS"); expect(ws?.slug).toBeTruthy(); }); it("create: requires auth", async () => { const res = await app.request("POST", "/api/v1/workspaces", { body: { name: "Nope" }, }); expect(res.status).toBe(401); }); it("rename: OWNER can, MEMBER cannot", async () => { const owner = await createUser(app.request, { name: "Rename Owner" }); const { workspaceId } = await createWorkspace( app.request, authHeaders(owner.accessToken!), "Rename Me", ); // Add a MEMBER (TASK-0095 S9: via the internal test seam — the D3 // unblock; the public invite flow is email-gated and the token is never // returned in the response). const member = await createUser(app.request, { name: "Rename Member" }); await addMemberViaSeam(app.request, app.internalServiceToken, workspaceId, member.email, "MEMBER"); // OWNER renames. const ok = await app.request("PATCH", `/api/v1/workspaces/${workspaceId}`, { headers: authHeaders(owner.accessToken!), body: { name: "Renamed OK" }, }); expect(ok.status).toBe(200); // MEMBER renames → denied. const denied = await app.request("PATCH", `/api/v1/workspaces/${workspaceId}`, { headers: authHeaders(member.accessToken!), body: { name: "Member Hax" }, }); expect(denied.status).toBe(403); // Name unchanged. const ws = await app.prisma.workspace.findUnique({ where: { id: workspaceId } }); expect(ws?.name).toBe("Renamed OK"); }); it("tenant isolation: outsider gets 403/404 on every read endpoint", async () => { const owner = await createUser(app.request, { name: "Iso Owner" }); const { workspaceId } = await createWorkspace( app.request, authHeaders(owner.accessToken!), "Secret WS", ); // A page inside. const pg = await app.request("POST", `/api/v1/workspaces/${workspaceId}/pages`, { headers: authHeaders(owner.accessToken!), body: { title: "Secret Page" }, }); expect(pg.status).toBe(201); const pageId = ((await json(pg)) as { id: string }).id; // bare page (D4) const outsider = await createUser(app.request, { name: "Iso Outsider" }); const h = authHeaders(outsider.accessToken!); const getWs = await app.request("GET", `/api/v1/workspaces/${workspaceId}`, { headers: h }); expect([403, 404]).toContain(getWs.status); const listPages = await app.request("GET", `/api/v1/workspaces/${workspaceId}/pages`, { headers: h, }); expect([403, 404]).toContain(listPages.status); const getPage = await app.request("GET", `/api/v1/pages/${pageId}`, { headers: h }); expect([403, 404]).toContain(getPage.status); const listMembers = await app.request("GET", `/api/v1/workspaces/${workspaceId}/members`, { headers: h, }); expect([403, 404]).toContain(listMembers.status); }); it("tenant isolation: outsider cannot create pages or delete the workspace", async () => { const owner = await createUser(app.request, { name: "Mut Owner" }); const { workspaceId } = await createWorkspace( app.request, authHeaders(owner.accessToken!), "Mut WS", ); const outsider = await createUser(app.request, { name: "Mut Outsider" }); const h = authHeaders(outsider.accessToken!); const createPage = await app.request("POST", `/api/v1/workspaces/${workspaceId}/pages`, { headers: h, body: { title: "Intruder Page" }, }); expect([403, 404]).toContain(createPage.status); const del = await app.request("DELETE", `/api/v1/workspaces/${workspaceId}`, { headers: h, }); expect([403, 404]).toContain(del.status); // Workspace still intact. const ws = await app.prisma.workspace.findUnique({ where: { id: workspaceId } }); expect(ws).not.toBeNull(); expect(ws?.deletedAt).toBeNull(); }); it("invite: wrong-acceptor email cannot accept the invite", async () => { const owner = await createUser(app.request, { name: "Invite Owner" }); const { workspaceId } = await createWorkspace( app.request, authHeaders(owner.accessToken!), "Invite WS", ); const target = await createUser(app.request, { name: "Invite Target" }); const other = await createUser(app.request, { name: "Invite Other" }); // The public invite flow hashes the token and emails it — the plaintext // token is NEVER returned to the inviter (D3), so this test seeds a // PENDING invitation with a KNOWN token hash directly in the DB (the // documented "direct-DB for state the API cannot create" pattern) and // drives the REAL accept route, preserving the email-mismatch security // semantics under test. const inviteToken = `inv_test_${Math.random().toString(36).slice(2)}`; const invitation = await app.prisma.invitation.create({ data: { workspaceId, email: target.email, role: "MEMBER", invitedById: owner.userId, tokenHash: sha256hex(inviteToken), status: "PENDING", expiresAt: new Date(Date.now() + 7 * 24 * 3600 * 1000), }, }); // A DIFFERENT authenticated user tries to accept → denied (403 // email_mismatch). const wrong = await app.request( "POST", `/api/v1/workspaces/${workspaceId}/members/invites/${invitation.id}/accept?token=${encodeURIComponent(inviteToken)}`, { headers: authHeaders(other.accessToken!) }, ); expect([400, 403, 404]).toContain(wrong.status); // The target CAN accept (the real email-mismatch check passes). const right = await app.request( "POST", `/api/v1/workspaces/${workspaceId}/members/invites/${invitation.id}/accept?token=${encodeURIComponent(inviteToken)}`, { headers: authHeaders(target.accessToken!) }, ); expect([200, 201]).toContain(right.status); const membership = await app.prisma.workspaceMember.findUnique({ where: { workspaceId_userId: { workspaceId, userId: target.userId } }, }); expect(membership?.role).toBe("MEMBER"); }); it("delete: OWNER deletes (soft), subsequent reads 404", async () => { const owner = await createUser(app.request, { name: "Del Owner" }); const { workspaceId } = await createWorkspace( app.request, authHeaders(owner.accessToken!), "Del WS", ); const pg = await app.request("POST", `/api/v1/workspaces/${workspaceId}/pages`, { headers: authHeaders(owner.accessToken!), body: { title: "Cascade Page" }, }); expect(pg.status).toBe(201); const del = await app.request("DELETE", `/api/v1/workspaces/${workspaceId}`, { headers: authHeaders(owner.accessToken!), }); expect([200, 204]).toContain(del.status); const ws = await app.prisma.workspace.findUnique({ where: { id: workspaceId } }); expect(ws?.deletedAt).not.toBeNull(); const after = await app.request("GET", `/api/v1/workspaces/${workspaceId}`, { headers: authHeaders(owner.accessToken!), }); expect([403, 404]).toContain(after.status); }); it("member list: shows roles for actual members only", async () => { const owner = await createUser(app.request, { name: "List Owner" }); const { workspaceId } = await createWorkspace( app.request, authHeaders(owner.accessToken!), "List WS", ); const res = await app.request("GET", `/api/v1/workspaces/${workspaceId}/members`, { headers: authHeaders(owner.accessToken!), }); expect(res.status).toBe(200); const body = await json(res); const members = (body.members as { role: string }[] | undefined) ?? []; expect(members.length).toBe(1); expect(members[0].role).toBe("OWNER"); }); });