1
10import { afterAll, beforeAll, describe, expect, it } from "vitest";
11import { createTestApp, type TestAppHandle } from "./helpers/api";
12import {
13 addMemberViaSeam,
14 authHeaders,
15 countAuditEvents,
16 createWorkspace,
17 createUser
18} from "./helpers/seed";
19
20let app: TestAppHandle;
21
22beforeAll(async () => {
23 app = await createTestApp();
24}, 180_000);
25afterAll(async () => {
26 await app?.close();
27});
28
29const json = async (res: Response): Promise<Record<string, unknown>> =>
30 (await res.json().catch(() => ({}))) as Record<string, unknown>;
31
32
33async function mkWs(user: { userId: string; accessToken: string }, name: string) {
34 const { workspaceId } = await createWorkspace(
35 app.request,
36 authHeaders(user.accessToken!),
37 name
38 );
39 return workspaceId;
40}
41
42describe("Spaces: directory (T-136/T-137)", () => {
43 it("lists only the caller's workspaces (non-member leak check)", async () => {
44 const alice = await createUser(app.request, { name: "Dir Alice" });
45 const bob = await createUser(app.request, { name: "Dir Bob" });
46 const aliceWs = await mkWs(alice, "Alice Only Space");
47 await mkWs(alice, "Alice Second");
48 await mkWs(bob, "Bob Secret Space");
49
50 const res = await app.request("GET", "/api/v1/workspaces", {
51 headers: authHeaders(alice.accessToken!)
52 });
53 expect(res.status).toBe(200);
54 const body = await json(res);
55 const names = (body.data as Array<{ name: string }>).map((w) => w.name);
56 expect(names).toContain("Alice Only Space");
57 expect(names).toContain("Alice Second");
58 expect(names).not.toContain("Bob Secret Space");
59 });
60
61 it("search + type + sort filters", async () => {
62 const u = await createUser(app.request, { name: "Dir Filter" });
63 const wsA = await mkWs(u, "Alpha Dir");
64 const wsB = await mkWs(u, "Beta Dir");
65
66
67 for (const [id, type] of [[wsA, "project"], [wsB, "kb"]] as const) {
68 const r = await app.request("PATCH", `/api/v1/workspaces/${id}`, {
69 headers: authHeaders(u.accessToken!),
70 body: { type }
71 });
72 expect(r.status).toBe(200);
73 }
74
75 const search = await app.request("GET", "/api/v1/workspaces?search=Alpha", {
76 headers: authHeaders(u.accessToken!)
77 });
78 const searchBody = await json(search);
79 const searchNames = (searchBody.data as Array<{ name: string }>).map((w) => w.name);
80 expect(searchNames).toContain("Alpha Dir");
81 expect(searchNames).not.toContain("Beta Dir");
82
83 const byType = await app.request("GET", "/api/v1/workspaces?type=kb", {
84 headers: authHeaders(u.accessToken!)
85 });
86 const typeBody = await json(byType);
87 const typeNames = (typeBody.data as Array<{ name: string; type: string }>).map((w) => w.name);
88 expect(typeNames).toContain("Beta Dir");
89 expect(typeNames).not.toContain("Alpha Dir");
90
91 const sorted = await app.request("GET", "/api/v1/workspaces?sort=name", {
92 headers: authHeaders(u.accessToken!)
93 });
94 const sortedBody = await json(sorted);
95 const sortedNames = (sortedBody.data as Array<{ name: string }>).map((w) => w.name);
96 const filtered = sortedNames.filter((n) => n === "Alpha Dir" || n === "Beta Dir");
97 expect(filtered).toEqual(["Alpha Dir", "Beta Dir"]);
98 });
99
100 it("requires auth", async () => {
101 const res = await app.request("GET", "/api/v1/workspaces");
102 expect(res.status).toBe(401);
103 });
104});
105
106describe("Spaces: archive / restore (T-136)", () => {
107 it("owner archives + restores; hidden from directory when archived", async () => {
108 const u = await createUser(app.request, { name: "Archive Owner" });
109 const ws = await mkWs(u, "Archivable Space");
110
111 const arch = await app.request("POST", `/api/v1/workspaces/${ws}/archive`, {
112 headers: authHeaders(u.accessToken!)
113 });
114
115 expect(arch.status).toBe(201);
116 const archBody = await json(arch);
117 expect(archBody.archived).toBe(true);
118
119
120 const list = await app.request("GET", "/api/v1/workspaces", {
121 headers: authHeaders(u.accessToken!)
122 });
123 const listBody = await json(list);
124 const listNames = (listBody.data as Array<{ name: string }>).map((w) => w.name);
125 expect(listNames).not.toContain("Archivable Space");
126
127
128 const withArchived = await app.request("GET", "/api/v1/workspaces?archived=true", {
129 headers: authHeaders(u.accessToken!)
130 });
131 const withArchivedBody = await json(withArchived);
132 const withArchivedNames = (withArchivedBody.data as Array<{ name: string }>).map((w) => w.name);
133 expect(withArchivedNames).toContain("Archivable Space");
134
135 const restore = await app.request("POST", `/api/v1/workspaces/${ws}/archive/restore`, {
136 headers: authHeaders(u.accessToken!)
137 });
138 expect(restore.status).toBe(201);
139 const restoreBody = await json(restore);
140 expect(restoreBody.archived).toBe(false);
141
142 const db = await app.prisma.workspace.findUnique({ where: { id: ws } });
143 expect(db?.archivedAt).toBeNull();
144 });
145
146 it("archive is owner/admin-only (member → 403) + audit event", async () => {
147 const owner = await createUser(app.request, { name: "Arch Owner" });
148 const member = await createUser(app.request, { name: "Arch Member" });
149 const ws = await mkWs(owner, "Archive Guard");
150
151
152 await addMemberViaSeam(app.request, app.internalServiceToken, ws, member.email, "MEMBER");
153
154 const before = await countAuditEvents(app.prisma, { action: "workspace.archived" });
155 const res = await app.request("POST", `/api/v1/workspaces/${ws}/archive`, {
156 headers: authHeaders(member.accessToken!)
157 });
158 expect(res.status).toBe(403);
159
160
161 const ok = await app.request("POST", `/api/v1/workspaces/${ws}/archive`, {
162 headers: authHeaders(owner.accessToken!)
163 });
164 expect(ok.status).toBe(201);
165 const after = await countAuditEvents(app.prisma, { action: "workspace.archived" });
166 expect(after).toBe(before + 1);
167 });
168});
169
170describe("Spaces: stats (T-137)", () => {
171 it("member sees stats; non-member gets 403 (no-leak)", async () => {
172 const owner = await createUser(app.request, { name: "Stats Owner" });
173 const outsider = await createUser(app.request, { name: "Stats Outsider" });
174 const ws = await mkWs(owner, "Stats Space");
175
176 const res = await app.request("GET", `/api/v1/workspaces/${ws}/stats`, {
177 headers: authHeaders(owner.accessToken!)
178 });
179 expect(res.status).toBe(200);
180 const body = await json(res);
181 expect(body.type).toBe("team");
182 expect((body.content as { members: number }).members).toBeGreaterThanOrEqual(1);
183
184 const leak = await app.request("GET", `/api/v1/workspaces/${ws}/stats`, {
185 headers: authHeaders(outsider.accessToken!)
186 });
187 expect(leak.status).toBe(403);
188 });
189});
190