1import { ApiError, ErrorCodes } from "@factory/shared/errors";
2import { describe, expect, it } from "vitest";
3
4import {
5 can,
6 check,
7 resolvePermission,
8 type AncestorRef,
9 type Grant,
10 type PermissionRequest,
11} from "./resolve.js";
12import type { Share } from "./rules.js";
13import type { WorkspaceRole } from "./levels.js";
14
15const WS = "wsp_1";
16const TS = "tms_a";
17const PG = "pg_1";
18const U1 = "u_1";
19
20
21function req(overrides: Partial<PermissionRequest> = {}): PermissionRequest {
22 return {
23 userId: U1,
24 userName: "Ada",
25 userTeams: ["tms_1"],
26 workspaceRole: "member",
27 workspaceId: WS,
28 resourceType: "page",
29 resourceId: PG,
30 ancestorChain: [
31 { resourceType: "workspace", id: WS },
32 { resourceType: "teamspace", id: TS },
33 { resourceType: "page", id: PG },
34 ],
35 grants: [],
36 shares: [],
37 ...overrides,
38 };
39}
40
41function chainOf(pageId: string, teamspaceId: string = TS): AncestorRef[] {
42 return [
43 { resourceType: "workspace", id: WS },
44 { resourceType: "teamspace", id: teamspaceId },
45 { resourceType: "page", id: pageId },
46 ];
47}
48
49const grant = (partial: Partial<Grant> = {}): Grant => ({
50 granteeType: "user",
51 granteeId: U1,
52 resourceType: "page",
53 resourceId: PG,
54 level: "edit",
55 ...partial,
56});
57
58describe("role baselines (no grants, no shares)", () => {
59 const root: Partial<PermissionRequest> = {
60 resourceType: "workspace",
61 resourceId: WS,
62 };
63
64 it.each([
65 ["owner", "full_access"],
66 ["admin", "full_access"],
67 ["member", "edit"],
68 ["guest", "view"],
69 ] as [WorkspaceRole, string][])("role %s at workspace root → %s", (role, expected) => {
70 const r = resolvePermission(req({ ...root, workspaceRole: role as WorkspaceRole }));
71 expect(r.level).toBe(expected);
72 });
73
74 it.each([
75 ["owner", "full_access"],
76 ["admin", "full_access"],
77 ["member", "view"],
78 ["guest", "none"],
79 ] as [WorkspaceRole, string][])("role %s on a page (no grants) → %s", (role, expected) => {
80 const r = resolvePermission(req({ workspaceRole: role as WorkspaceRole }));
81 expect(r.level).toBe(expected);
82 });
83});
84
85describe("explicit grants on the exact resource", () => {
86 it("user grant raises a member's level to full_access", () => {
87 const r = resolvePermission(req({ grants: [grant({ level: "full_access" })] }));
88 expect(r.level).toBe("full_access");
89 expect(r.reasons.some((reason) => reason.includes("explicit grant"))).toBe(true);
90 });
91
92 it("explicit grant can RESTRICT below the role baseline (member edit → view)", () => {
93 const r = resolvePermission(req({ grants: [grant({ level: "view" })] }));
94 expect(r.level).toBe("view");
95 expect(r.reasons.some((reason) => reason.includes("overrides"))).toBe(true);
96 });
97
98 it("explicit grant can RESTRICT an owner below full_access", () => {
99 const r = resolvePermission(
100 req({ workspaceRole: "owner", grants: [grant({ level: "comment" })] }),
101 );
102 expect(r.level).toBe("comment");
103 });
104
105 it("takes the max when several user grants match", () => {
106 const r = resolvePermission(
107 req({
108 grants: [grant({ level: "view" }), grant({ level: "comment" }), grant({ level: "view" })],
109 }),
110 );
111 expect(r.level).toBe("comment");
112 });
113
114 it("role grants apply to everyone holding that role", () => {
115 const r = resolvePermission(
116 req({ grants: [grant({ granteeType: "role", granteeId: "member", level: "comment" })] }),
117 );
118 expect(r.level).toBe("comment");
119 });
120
121 it("team grants apply when the user is in the team, and are ignored otherwise", () => {
122 const inTeam = req({
123 grants: [grant({ granteeType: "team", granteeId: "tms_1", level: "full_access" })],
124 });
125 expect(resolvePermission(inTeam).level).toBe("full_access");
126 const outOfTeam = req({
127 userTeams: ["tms_9"],
128 grants: [grant({ granteeType: "team", granteeId: "tms_1", level: "full_access" })],
129 });
130 expect(resolvePermission(outOfTeam).level).toBe("view");
131 });
132
133 it("grants for other users / other resources never apply", () => {
134 const r = resolvePermission(
135 req({
136 grants: [grant({ granteeId: "u_2" }), grant({ resourceId: "pg_999" })],
137 }),
138 );
139 expect(r.level).toBe("view");
140 });
141
142 it("grants with level none apply to no one", () => {
143 const r = resolvePermission(req({ grants: [grant({ level: "none" })] }));
144 expect(r.level).toBe("view");
145 expect(r.reasons.some((reason) => reason.includes("explicit grant"))).toBe(false);
146 });
147});
148
149describe("inheritance (nearest ancestor wins)", () => {
150 it("a grant on the nearest ancestor (teamspace) applies to the page", () => {
151 const r = resolvePermission(
152 req({ grants: [grant({ resourceType: "teamspace", resourceId: TS, level: "full_access" })] }),
153 );
154 expect(r.level).toBe("full_access");
155 expect(r.reasons.some((reason) => reason.includes("inherited from teamspace"))).toBe(true);
156 });
157
158 it("nearest ancestor beats a farther one (teamspace view beats workspace full_access)", () => {
159 const r = resolvePermission(
160 req({
161 grants: [
162 grant({ resourceType: "teamspace", resourceId: TS, level: "view" }),
163 grant({ resourceType: "workspace", resourceId: WS, level: "full_access" }),
164 ],
165 }),
166 );
167 expect(r.level).toBe("view");
168 expect(r.reasons.some((reason) => reason.includes("inherited from teamspace"))).toBe(true);
169 expect(r.reasons.some((reason) => reason.includes("inherited from workspace"))).toBe(false);
170 });
171
172 it("a grant on a nearer-but-unmatched ancestor does not block a farther matched one", () => {
173
174 const r = resolvePermission(
175 req({
176 grants: [
177 grant({ resourceType: "teamspace", resourceId: TS, granteeId: "u_2", level: "view" }),
178 grant({ resourceType: "workspace", resourceId: WS, level: "comment" }),
179 ],
180 }),
181 );
182 expect(r.level).toBe("comment");
183 expect(r.reasons.some((reason) => reason.includes("inherited from workspace"))).toBe(true);
184 });
185
186 it("with a 3-level chain, an inherited level maxes with the share floor", () => {
187
188 const r = resolvePermission(
189 req({
190 grants: [grant({ resourceType: "workspace", resourceId: WS, level: "view" })],
191 shares: [{ scope: "team", level: "comment", teamId: "tms_1" }],
192 }),
193 );
194 expect(r.level).toBe("comment");
195 });
196
197 it("explicit grant on the exact resource beats any ancestor grant", () => {
198 const r = resolvePermission(
199 req({
200 grants: [
201 grant({ resourceType: "workspace", resourceId: WS, level: "full_access" }),
202 grant({ level: "view" }),
203 ],
204 }),
205 );
206 expect(r.level).toBe("view");
207 });
208
209 it("no ancestor grant → none from inheritance", () => {
210 const r = resolvePermission(req({ grants: [] }));
211 expect(r.reasons).toContain("no applicable grant on any ancestor → no inheritance");
212 });
213
214 it("works for database/record/file resource types too", () => {
215 const r = resolvePermission(
216 req({
217 resourceType: "record",
218 resourceId: "rec_1",
219 ancestorChain: [
220 { resourceType: "workspace", id: WS },
221 { resourceType: "database", id: "db_1" },
222 { resourceType: "record", id: "rec_1" },
223 ],
224 grants: [grant({ resourceType: "database", resourceId: "db_1", level: "edit" })],
225 }),
226 );
227 expect(r.level).toBe("edit");
228 expect(r.reasons.some((reason) => reason.includes("inherited from database"))).toBe(true);
229 });
230});
231
232describe("shares", () => {
233 it("workspace share is a floor for every member", () => {
234 const r = resolvePermission(req({ shares: [{ scope: "workspace", level: "comment" }] }));
235 expect(r.level).toBe("comment");
236 });
237
238 it("team share applies only to in-team users", () => {
239 const inTeam = resolvePermission(
240 req({ shares: [{ scope: "team", level: "edit", teamId: "tms_1" }] }),
241 );
242 expect(inTeam.level).toBe("edit");
243 const outOfTeam = resolvePermission(
244 req({ userTeams: ["tms_9"], shares: [{ scope: "team", level: "edit", teamId: "tms_1" }] }),
245 );
246 expect(outOfTeam.level).toBe("view");
247 });
248
249 it("users share applies only to listed users", () => {
250 const listed = resolvePermission(
251 req({ shares: [{ scope: "users", level: "full_access", userIds: [U1, "u_9"] }] }),
252 );
253 expect(listed.level).toBe("full_access");
254 const unlisted = resolvePermission(
255 req({ shares: [{ scope: "users", level: "full_access", userIds: ["u_9"] }] }),
256 );
257 expect(unlisted.level).toBe("view");
258 });
259
260 it("public share is a floor even for authenticated users", () => {
261 const r = resolvePermission(
262 req({ shares: [{ scope: "public", level: "view", slug: "pg_1", password: "secret" }] }),
263 );
264 expect(r.level).toBe("view");
265 expect(r.reasons.some((reason) => reason.includes("public share"))).toBe(true);
266 });
267
268 it("a public share can raise a guest (none → view) on a page", () => {
269 const r = resolvePermission(
270 req({ workspaceRole: "guest", shares: [{ scope: "public", level: "view" }] }),
271 );
272 expect(r.level).toBe("view");
273 });
274
275 it("private shares never contribute", () => {
276 const r = resolvePermission(req({ shares: [{ scope: "private", level: "full_access" }] }));
277 expect(r.level).toBe("view");
278 expect(r.reasons).toContain("no shares visible to requester → no share contribution");
279 });
280
281 it("takes the max over several visible shares", () => {
282 const r = resolvePermission(
283 req({
284 shares: [
285 { scope: "workspace", level: "view" },
286 { scope: "team", level: "comment", teamId: "tms_1" },
287 { scope: "users", level: "edit", userIds: [U1] },
288 ],
289 }),
290 );
291 expect(r.level).toBe("edit");
292 });
293
294 it("a share can raise a guest above none on the workspace root", () => {
295 const r = resolvePermission(
296 req({
297 workspaceRole: "guest",
298 resourceType: "workspace",
299 resourceId: WS,
300 ancestorChain: [],
301 shares: [{ scope: "public", level: "comment" }],
302 }),
303 );
304 expect(r.level).toBe("comment");
305 });
306});
307
308describe("can() / check()", () => {
309 it("can() is true when the resolved level satisfies the requirement", () => {
310 expect(can(req({ grants: [grant({ level: "full_access" })] }), "edit")).toBe(true);
311 expect(can(req({ grants: [grant({ level: "full_access" })] }), "full_access")).toBe(true);
312 expect(can(req({ grants: [grant({ level: "view" })] }), "edit")).toBe(false);
313 expect(can(req({ workspaceRole: "guest" }), "view")).toBe(false);
314 expect(can(req(), "view")).toBe(true);
315 });
316
317 it("check() passes silently when allowed", () => {
318 expect(() => check(req({ grants: [grant({ level: "edit" })] }), "edit")).not.toThrow();
319 });
320
321 it("check() throws ApiError FORBIDDEN with required/granted/reasons details", () => {
322 try {
323 check(req({ workspaceRole: "guest" }), "comment");
324 throw new Error("expected check() to throw");
325 } catch (err) {
326 expect(err).toBeInstanceOf(ApiError);
327 const apiErr = err as ApiError;
328 expect(apiErr.code).toBe(ErrorCodes.FORBIDDEN);
329 expect(apiErr.status).toBe(403);
330 expect(apiErr.details).toEqual(
331 expect.objectContaining({ required: "comment", granted: "none" }),
332 );
333 const details = apiErr.details as { required: string; granted: string; reasons: string[] };
334 expect(details.reasons.length).toBeGreaterThan(0);
335 expect(
336 details.reasons.every((reason) => typeof reason === "string" && reason.length > 0),
337 ).toBe(true);
338 }
339 });
340});
341
342describe("reasons (auditability)", () => {
343 it("always returns at least one reason (baseline)", () => {
344 for (const role of ["owner", "admin", "member", "guest"] as WorkspaceRole[]) {
345 expect(resolvePermission(req({ workspaceRole: role })).reasons.length).toBeGreaterThan(0);
346 }
347 });
348
349 it("explains the full chain for an explicit override case", () => {
350 const r = resolvePermission(
351 req({
352 workspaceRole: "member",
353 shares: [{ scope: "workspace", level: "view" }],
354 grants: [
355 grant({ resourceType: "teamspace", resourceId: TS, level: "full_access" }),
356 grant({ level: "comment" }),
357 ],
358 }),
359 );
360 expect(r.level).toBe("comment");
361 expect(r.reasons.some((reason) => reason.includes("baseline"))).toBe(true);
362 expect(r.reasons.some((reason) => reason.includes("workspace share"))).toBe(true);
363 expect(r.reasons.some((reason) => reason.includes("inherited from teamspace"))).toBe(true);
364 expect(r.reasons.some((reason) => reason.includes("explicit grant"))).toBe(true);
365 expect(r.reasons).toContain(
366 "final = explicit comment (overrides inherited full_access, baseline view, shares view)",
367 );
368 });
369});
370