Skip to content
221 linesCodeBlameRaw
1import assert from "node:assert/strict";
2import { test } from "node:test";
3
4import {
5 aclChain,
6 aclRootOf,
7 canShare,
8 effectiveRole,
9 explicitAccess,
10 folioPathOf,
11 folioReadableByWorkspace,
12 folioScope,
13 generalRoleCap,
14 inheritsSpace,
15 isPrivateFolio,
16 materialize,
17 roleOf,
18 type FolioAclNode,
19 type FolioGrant,
20 type Person,
21 type SpaceRules,
22} from "./access.ts";
23
24// People: Ana (team web), Bo (no team), Cy (team design), Wes (workspace owner).
25const ana: Person = { user_id: "ana", owner: false, teams: new Set(["web"]) };
26const bo: Person = { user_id: "bo", owner: false, teams: new Set() };
27const cy: Person = { user_id: "cy", owner: false, teams: new Set(["design"]) };
28const wes: Person = { user_id: "wes", owner: true, teams: new Set() };
29
30// Spaces.
31const open: SpaceRules = { kind: "workspace", team: null, default_role: "edit", members: [] };
32const openView: SpaceRules = { kind: "workspace", team: null, default_role: "view", members: [] };
33const team: SpaceRules = { kind: "team", team: "web", default_role: "comment", members: [] };
34const membersOnly: SpaceRules = { kind: "private", team: null, default_role: null, members: [{ principal: "user:cy", role: "manage" }] };
35const SPACES: Record<string, SpaceRules> = { open, openView, team, membersOnly };
36
37function node(id: string, over: Partial<FolioAclNode> = {}): FolioAclNode {
38 return { id, owner: "user:ana", parent_id: null, space_id: null, inherit: true, general_access: "none", general_role: null, created_at: "2026-10-01T00:00:00Z", ...over };
39}
40
41/** The role a person has on `id`, given every node and grant. */
42function roleIn(nodes: FolioAclNode[], grants: Record<string, FolioGrant[]>, id: string, person: Person, visited = false) {
43 const byId = new Map(nodes.map((n) => [n.id, n]));
44 const chain = aclChain(id, byId);
45 const root = chain[chain.length - 1]!;
46 const space = root.space_id ? SPACES[root.space_id]! : null;
47 return effectiveRole(chain, new Map(Object.entries(grants)), space ? roleOf(space, person) : null, person, { visited });
48}
49
50test("private: only the owner, and not the workspace owner", () => {
51 const nodes = [node("f")];
52 assert.equal(roleIn(nodes, {}, "f", ana), "manage");
53 assert.equal(roleIn(nodes, {}, "f", bo), null);
54 assert.equal(roleIn(nodes, {}, "f", wes), null);
55 const byId = new Map(nodes.map((n) => [n.id, n]));
56 assert.equal(isPrivateFolio(aclChain("f", byId), new Map()), true);
57});
58
59test("an open space gives every member its base role; owners manage", () => {
60 const nodes = [node("f", { space_id: "open", owner: "user:cy" })];
61 assert.equal(roleIn(nodes, {}, "f", ana), "edit");
62 assert.equal(roleIn(nodes, {}, "f", bo), "edit");
63 assert.equal(roleIn(nodes, {}, "f", cy), "manage");
64 assert.equal(roleIn(nodes, {}, "f", wes), "manage");
65 const byId = new Map(nodes.map((n) => [n.id, n]));
66 assert.equal(isPrivateFolio(aclChain("f", byId), new Map()), false);
67});
68
69test("a team space: its team gets the base role; others nothing; workspace owners manage", () => {
70 const nodes = [node("f", { space_id: "team", owner: "user:cy" })];
71 assert.equal(roleIn(nodes, {}, "f", ana), "comment");
72 assert.equal(roleIn(nodes, {}, "f", bo), null);
73 assert.equal(roleIn(nodes, {}, "f", wes), "manage");
74});
75
76test("a members-only space: only its members, never the workspace owner", () => {
77 const nodes = [node("f", { space_id: "membersOnly", owner: "user:cy" })];
78 assert.equal(roleIn(nodes, {}, "f", cy), "manage");
79 assert.equal(roleIn(nodes, {}, "f", ana), null);
80 assert.equal(roleIn(nodes, {}, "f", wes), null);
81});
82
83test("grants to a person, an agent and a team", () => {
84 const nodes = [node("f")];
85 const grants = { f: [{ principal: "user:bo", role: "comment" as const }, { principal: "team:design", role: "edit" as const }, { principal: "agent:ag1", role: "edit" as const }] };
86 assert.equal(roleIn(nodes, grants, "f", bo), "comment");
87 assert.equal(roleIn(nodes, grants, "f", cy), "edit");
88 // An agent grant gives no person anything.
89 assert.equal(roleIn(nodes, grants, "f", wes), null);
90 const byId = new Map(nodes.map((n) => [n.id, n]));
91 assert.equal(isPrivateFolio(aclChain("f", byId), new Map(Object.entries(grants))), false);
92});
93
94test("a grant raises a space role but never lowers it", () => {
95 const nodes = [node("f", { space_id: "openView", owner: "user:cy" })];
96 assert.equal(roleIn(nodes, { f: [{ principal: "user:bo", role: "edit" }] }, "f", bo), "edit");
97 assert.equal(roleIn(nodes, { f: [{ principal: "user:ana", role: "view" }] }, "f", ana), "view");
98 const open2 = [node("f", { space_id: "open", owner: "user:cy" })];
99 assert.equal(roleIn(open2, { f: [{ principal: "user:ana", role: "view" }] }, "f", ana), "edit");
100});
101
102test("general access: workspace gives every member its role, never manage", () => {
103 const nodes = [node("f", { general_access: "workspace", general_role: "comment" })];
104 assert.equal(roleIn(nodes, {}, "f", bo), "comment");
105 assert.equal(roleIn(nodes, {}, "f", wes), "comment");
106 const capped = [node("f", { general_access: "workspace", general_role: "manage" })];
107 assert.equal(roleIn(capped, {}, "f", bo), "edit");
108 assert.equal(generalRoleCap(null), "view");
109});
110
111test("general access: link gives its role only to people who opened it", () => {
112 const nodes = [node("f", { general_access: "link", general_role: "view" })];
113 assert.equal(roleIn(nodes, {}, "f", bo), null);
114 assert.equal(roleIn(nodes, {}, "f", bo, true), "view");
115});
116
117test("nested docs inherit their parent's grants, space and general access", () => {
118 const nodes = [
119 node("top", { space_id: "team", owner: "user:cy" }),
120 node("mid", { space_id: "team", owner: "user:cy", parent_id: "top" }),
121 node("leaf", { space_id: "team", owner: "user:cy", parent_id: "mid" }),
122 ];
123 const grants = { top: [{ principal: "user:bo", role: "view" as const }] };
124 assert.equal(roleIn(nodes, grants, "leaf", ana), "comment");
125 assert.equal(roleIn(nodes, grants, "leaf", bo), "view");
126 assert.equal(roleIn(nodes, grants, "leaf", wes), "manage");
127 const byId = new Map(nodes.map((n) => [n.id, n]));
128 assert.deepEqual(
129 aclChain("leaf", byId).map((n) => n.id),
130 ["leaf", "mid", "top"],
131 );
132 assert.equal(inheritsSpace(aclChain("leaf", byId)), true);
133});
134
135test("a parent's owner keeps full access to what others add under it", () => {
136 const nodes = [node("top"), node("child", { parent_id: "top", owner: "user:bo" })];
137 assert.equal(roleIn(nodes, {}, "child", ana), "manage");
138 assert.equal(roleIn(nodes, {}, "child", bo), "manage");
139 assert.equal(roleIn(nodes, {}, "child", cy), null);
140});
141
142test("restricting stops the space, the parent's grants and general access", () => {
143 const nodes = [
144 node("top", { space_id: "open", owner: "user:cy", general_access: "workspace", general_role: "view" }),
145 node("secret", { space_id: "open", owner: "user:cy", parent_id: "top", inherit: false }),
146 node("under", { space_id: "open", owner: "user:cy", parent_id: "secret" }),
147 ];
148 const grants = { top: [{ principal: "user:bo", role: "edit" as const }], secret: [{ principal: "user:ana", role: "comment" as const }] };
149 assert.equal(roleIn(nodes, grants, "secret", bo), null);
150 assert.equal(roleIn(nodes, grants, "secret", ana), "comment");
151 assert.equal(roleIn(nodes, grants, "under", ana), "comment");
152 assert.equal(roleIn(nodes, grants, "under", wes), null);
153 assert.equal(roleIn(nodes, grants, "under", cy), "manage");
154 // A restricted top-level folio in a space leaves the space's people out too.
155 const top = [node("t", { space_id: "open", owner: "user:cy", inherit: false })];
156 assert.equal(roleIn(top, {}, "t", ana), null);
157 assert.equal(roleIn(top, {}, "t", wes), null);
158});
159
160test("the access root and path of a new folio", () => {
161 assert.equal(aclRootOf({ id: "a", inherit: true, parent_id: null }, null), "a");
162 assert.equal(aclRootOf({ id: "b", inherit: true, parent_id: "a" }, "a"), "a");
163 assert.equal(aclRootOf({ id: "c", inherit: false, parent_id: "b" }, "a"), "c");
164 assert.equal(folioPathOf("a", null), "/a/");
165 assert.equal(folioPathOf("b", "/a/"), "/a/b/");
166});
167
168test("materialize writes the owner, ancestor owners and grants up to the access root, highest role each", () => {
169 const nodes = [
170 node("top", { owner: "user:ana" }),
171 node("child", { parent_id: "top", owner: "user:bo" }),
172 node("restricted", { parent_id: "child", owner: "user:bo", inherit: false }),
173 ];
174 const byId = new Map(nodes.map((n) => [n.id, n]));
175 const grants = new Map<string, FolioGrant[]>([
176 ["top", [{ principal: "user:cy", role: "view", granted_at: "2026-10-02T00:00:00Z" }]],
177 ["child", [{ principal: "user:cy", role: "edit", granted_at: "2026-10-03T00:00:00Z" }]],
178 ]);
179 const rows = materialize(["top", "child", "restricted"], byId, grants);
180 const of = (id: string) => Object.fromEntries(rows.filter((r) => r.folio_id === id).map((r) => [r.principal, `${r.role}@${r.via}`]));
181 assert.deepEqual(of("top"), { "user:ana": "manage@owner", "user:cy": "view@top" });
182 assert.deepEqual(of("child"), { "user:bo": "manage@owner", "user:cy": "edit@child", "user:ana": "manage@top" });
183 assert.deepEqual(of("restricted"), { "user:bo": "manage@owner" });
184 assert.equal(explicitAccess(aclChain("child", byId), grants).get("user:cy")?.since, "2026-10-03T00:00:00Z");
185});
186
187test("the index scope is the space's only when access is exactly the space's", () => {
188 const byId = (nodes: FolioAclNode[]) => new Map(nodes.map((n) => [n.id, n]));
189 const plain = byId([node("a", { space_id: "open" })]);
190 assert.equal(folioScope(aclChain("a", plain), new Map()), "space:open");
191 assert.equal(folioScope(aclChain("a", plain), new Map([["a", [{ principal: "user:bo", role: "view" }]]])), "folio:a");
192 const general = byId([node("a", { space_id: "open", general_access: "workspace", general_role: "view" })]);
193 assert.equal(folioScope(aclChain("a", general), new Map()), "folio:a");
194 const nested = byId([node("a", { space_id: "open" }), node("b", { space_id: "open", parent_id: "a", owner: "user:bo" })]);
195 assert.equal(folioScope(aclChain("b", nested), new Map()), "space:open");
196 const restricted = byId([node("a", { space_id: "open" }), node("b", { space_id: "open", parent_id: "a", inherit: false })]);
197 assert.equal(folioScope(aclChain("b", restricted), new Map()), "folio:b");
198 const priv = byId([node("p")]);
199 assert.equal(folioScope(aclChain("p", priv), new Map()), "folio:p");
200});
201
202test("readable by the whole workspace: open spaces and workspace general access only", () => {
203 const byId = (nodes: FolioAclNode[]) => new Map(nodes.map((n) => [n.id, n]));
204 assert.equal(folioReadableByWorkspace(aclChain("a", byId([node("a", { space_id: "open" })])), open), true);
205 assert.equal(folioReadableByWorkspace(aclChain("a", byId([node("a", { space_id: "team" })])), team), false);
206 assert.equal(folioReadableByWorkspace(aclChain("a", byId([node("a", { space_id: "membersOnly" })])), membersOnly), false);
207 assert.equal(folioReadableByWorkspace(aclChain("a", byId([node("a")])), null), false);
208 assert.equal(folioReadableByWorkspace(aclChain("a", byId([node("a", { general_access: "workspace", general_role: "view" })])), null), true);
209 // A link is never the workspace's, even for people who opened it.
210 assert.equal(folioReadableByWorkspace(aclChain("a", byId([node("a", { general_access: "link", general_role: "view" })])), null), false);
211 // Restricted inside an open space: not the workspace's.
212 const restricted = byId([node("a", { space_id: "open" }), node("b", { space_id: "open", parent_id: "a", inherit: false })]);
213 assert.equal(folioReadableByWorkspace(aclChain("b", restricted), open), false);
214});
215
216test("who may share", () => {
217 assert.equal(canShare("manage"), true);
218 assert.equal(canShare("edit"), false);
219 assert.equal(canShare("edit", true), true);
220 assert.equal(canShare(null, true), false);
221});