@techie_doubts/tui.notes.2026 1.0.13 → 1.0.16-exp.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +50 -0
- package/dist/assets/{arc-DnT4PtPq.js → arc-CTs8vxd2.js} +1 -1
- package/dist/assets/{architectureDiagram-VXUJARFQ-BXrpe9bX.js → architectureDiagram-VXUJARFQ-t-K3l8e1.js} +1 -1
- package/dist/assets/{blockDiagram-VD42YOAC-DP5p2MqV.js → blockDiagram-VD42YOAC-DIMIytmv.js} +1 -1
- package/dist/assets/{c4Diagram-YG6GDRKO-Ct7uTaEZ.js → c4Diagram-YG6GDRKO-CW5YAiXl.js} +1 -1
- package/dist/assets/channel-DKXa9vcG.js +1 -0
- package/dist/assets/{chunk-4BX2VUAB-C8-wPxxJ.js → chunk-4BX2VUAB-DzXtIyrg.js} +1 -1
- package/dist/assets/{chunk-55IACEB6-DGtqAQIB.js → chunk-55IACEB6-BnGPND_M.js} +1 -1
- package/dist/assets/{chunk-B4BG7PRW-5xo8czGC.js → chunk-B4BG7PRW-BklBjgAV.js} +1 -1
- package/dist/assets/{chunk-DI55MBZ5-CSzvABNB.js → chunk-DI55MBZ5-BNr8lC1p.js} +1 -1
- package/dist/assets/{chunk-FMBD7UC4-B_0cCLSi.js → chunk-FMBD7UC4-kabs-F9i.js} +1 -1
- package/dist/assets/{chunk-QN33PNHL-CrWDnSsf.js → chunk-QN33PNHL-DNaN1nja.js} +1 -1
- package/dist/assets/{chunk-QZHKN3VN-HZLnZ-EP.js → chunk-QZHKN3VN-DMNT_UpY.js} +1 -1
- package/dist/assets/{chunk-TZMSLE5B-Bz9O3K8e.js → chunk-TZMSLE5B-BJ9Zykuu.js} +1 -1
- package/dist/assets/classDiagram-2ON5EDUG-DvvXKx22.js +1 -0
- package/dist/assets/classDiagram-v2-WZHVMYZB-DvvXKx22.js +1 -0
- package/dist/assets/{clone-D0V3NIis.js → clone-DgRSp238.js} +1 -1
- package/dist/assets/{cose-bilkent-S5V4N54A-Bu2u7GxM.js → cose-bilkent-S5V4N54A-LlpiAUGJ.js} +1 -1
- package/dist/assets/{dagre-6UL2VRFP-C2f5GIN3.js → dagre-6UL2VRFP-1CY5QFbC.js} +1 -1
- package/dist/assets/{diagram-PSM6KHXK-SP2awr0w.js → diagram-PSM6KHXK-CyWB67TN.js} +1 -1
- package/dist/assets/{diagram-QEK2KX5R-Bx9gVmHS.js → diagram-QEK2KX5R-D3s5Rahm.js} +1 -1
- package/dist/assets/{diagram-S2PKOQOG-DpM42zIf.js → diagram-S2PKOQOG-afP1wbQy.js} +1 -1
- package/dist/assets/{erDiagram-Q2GNP2WA-BCafPHnd.js → erDiagram-Q2GNP2WA-Cu5MC-vn.js} +1 -1
- package/dist/assets/{flowDiagram-NV44I4VS-BzDK3rTw.js → flowDiagram-NV44I4VS-Cp52X3t3.js} +1 -1
- package/dist/assets/{ganttDiagram-JELNMOA3-CKyPyucm.js → ganttDiagram-JELNMOA3-B-FV5NtV.js} +1 -1
- package/dist/assets/{gitGraphDiagram-NY62KEGX-C9dH7T48.js → gitGraphDiagram-NY62KEGX-DXjcfXw-.js} +1 -1
- package/dist/assets/{index-BilTouDJ.js → index-DT7P8Yy_.js} +529 -460
- package/dist/assets/{index-CNp6F8X7.css → index-RZ7CoTP6.css} +1 -1
- package/dist/assets/{infoDiagram-WHAUD3N6-CH3Av-mQ.js → infoDiagram-WHAUD3N6-6gXAcBpT.js} +1 -1
- package/dist/assets/{journeyDiagram-XKPGCS4Q-DIoHgl1Z.js → journeyDiagram-XKPGCS4Q-B63GyOcC.js} +1 -1
- package/dist/assets/{kanban-definition-3W4ZIXB7-CWLQr3gj.js → kanban-definition-3W4ZIXB7-BGUIjvVd.js} +1 -1
- package/dist/assets/{linear-CBhVQMz6.js → linear-CGkv28JQ.js} +1 -1
- package/dist/assets/{mindmap-definition-VGOIOE7T-Cxs-zpuQ.js → mindmap-definition-VGOIOE7T-QTeRbvXZ.js} +1 -1
- package/dist/assets/{pieDiagram-ADFJNKIX-cwQKtTba.js → pieDiagram-ADFJNKIX-BY8oRvp_.js} +1 -1
- package/dist/assets/{quadrantDiagram-AYHSOK5B-Bkz-9rDu.js → quadrantDiagram-AYHSOK5B-BYuVMrlE.js} +1 -1
- package/dist/assets/{requirementDiagram-UZGBJVZJ-WlYK0SEk.js → requirementDiagram-UZGBJVZJ-BJGSRZd3.js} +1 -1
- package/dist/assets/{sankeyDiagram-TZEHDZUN-C7_ihe8w.js → sankeyDiagram-TZEHDZUN-D4szE45R.js} +1 -1
- package/dist/assets/{sequenceDiagram-WL72ISMW-VD7vKL9M.js → sequenceDiagram-WL72ISMW-g-LURDtG.js} +1 -1
- package/dist/assets/{stateDiagram-FKZM4ZOC-ClBZturp.js → stateDiagram-FKZM4ZOC-DlfvisVh.js} +1 -1
- package/dist/assets/stateDiagram-v2-4FDKWEC3-BjHRrc7G.js +1 -0
- package/dist/assets/{timeline-definition-IT6M3QCI-BlnB3vyh.js → timeline-definition-IT6M3QCI-V2SgS8bM.js} +1 -1
- package/dist/assets/{treemap-KMMF4GRG-2Tjc4tzM.js → treemap-KMMF4GRG-BmxpkoZ5.js} +1 -1
- package/dist/assets/{xychartDiagram-PRI3JC2R-Dna9L0wE.js → xychartDiagram-PRI3JC2R-W9kGz4s7.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +8 -3
- package/server/acl-service.js +1249 -0
- package/server/acl-service.test.js +439 -0
- package/server/acl-store.js +421 -0
- package/server/auth.js +119 -0
- package/server/index.js +719 -23
- package/server/store.js +12 -0
- package/dist/assets/channel-B-LJJVbj.js +0 -1
- package/dist/assets/classDiagram-2ON5EDUG-B8hqCKwM.js +0 -1
- package/dist/assets/classDiagram-v2-WZHVMYZB-B8hqCKwM.js +0 -1
- package/dist/assets/stateDiagram-v2-4FDKWEC3-D0C_O_lb.js +0 -1
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import test from "node:test";
|
|
6
|
+
import { AclService } from "./acl-service.js";
|
|
7
|
+
import { createAclStore } from "./acl-store.js";
|
|
8
|
+
|
|
9
|
+
function createSampleState() {
|
|
10
|
+
const now = Date.now();
|
|
11
|
+
return {
|
|
12
|
+
folders: [
|
|
13
|
+
{ id: "f1", name: "Team", parentId: null, createdAt: now, updatedAt: now },
|
|
14
|
+
{ id: "f2", name: "Engineering", parentId: "f1", createdAt: now, updatedAt: now },
|
|
15
|
+
{ id: "f3", name: "Private", parentId: null, createdAt: now, updatedAt: now },
|
|
16
|
+
],
|
|
17
|
+
notes: [
|
|
18
|
+
{
|
|
19
|
+
id: "n1",
|
|
20
|
+
title: "Roadmap",
|
|
21
|
+
folderId: "f2",
|
|
22
|
+
content: "team-shared",
|
|
23
|
+
fileName: "Team/Engineering/Roadmap.md",
|
|
24
|
+
createdAt: now,
|
|
25
|
+
updatedAt: now,
|
|
26
|
+
deletedAt: null,
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
id: "n2",
|
|
30
|
+
title: "Ops",
|
|
31
|
+
folderId: "f3",
|
|
32
|
+
content: "private-note",
|
|
33
|
+
fileName: "Private/Ops.md",
|
|
34
|
+
createdAt: now,
|
|
35
|
+
updatedAt: now,
|
|
36
|
+
deletedAt: null,
|
|
37
|
+
},
|
|
38
|
+
],
|
|
39
|
+
ui: { expandedFolderIds: ["f1", "f2", "f3"] },
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function createHarness() {
|
|
44
|
+
const storageRootDir = await fs.mkdtemp(path.join(os.tmpdir(), "tui-notes-acl-test-"));
|
|
45
|
+
const store = await createAclStore({ storageRootDir });
|
|
46
|
+
const service = new AclService({
|
|
47
|
+
store,
|
|
48
|
+
authModeProvider: () => "enforce",
|
|
49
|
+
logger: { warn() {} },
|
|
50
|
+
});
|
|
51
|
+
return {
|
|
52
|
+
storageRootDir,
|
|
53
|
+
store,
|
|
54
|
+
service,
|
|
55
|
+
async dispose() {
|
|
56
|
+
await store.close?.();
|
|
57
|
+
await fs.rm(storageRootDir, { recursive: true, force: true });
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function user(email, userId = "") {
|
|
63
|
+
return {
|
|
64
|
+
id: email || userId || "anonymous",
|
|
65
|
+
userId,
|
|
66
|
+
email,
|
|
67
|
+
groups: [],
|
|
68
|
+
isAuthenticated: Boolean(email || userId),
|
|
69
|
+
displayName: email || userId || "anonymous",
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
test("bootstrap owner gets inherited owner access for full workspace", async () => {
|
|
74
|
+
const harness = await createHarness();
|
|
75
|
+
try {
|
|
76
|
+
const state = createSampleState();
|
|
77
|
+
await harness.service.syncResourcesFromState(state);
|
|
78
|
+
|
|
79
|
+
const alice = user("alice@example.com");
|
|
80
|
+
const bootstrapped = await harness.service.ensureBootstrapOwner(alice);
|
|
81
|
+
assert.equal(bootstrapped, true);
|
|
82
|
+
|
|
83
|
+
const snapshot = await harness.service.createSnapshot();
|
|
84
|
+
const actions = ["read", "write", "manage"];
|
|
85
|
+
for (const action of actions) {
|
|
86
|
+
const decision = harness.service.evaluate(snapshot, alice, {
|
|
87
|
+
action,
|
|
88
|
+
resourceType: "note",
|
|
89
|
+
resourceExternalId: "n2",
|
|
90
|
+
mode: "enforce",
|
|
91
|
+
});
|
|
92
|
+
assert.equal(decision.allowed, true, `owner should allow ${action}`);
|
|
93
|
+
}
|
|
94
|
+
} finally {
|
|
95
|
+
await harness.dispose();
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("viewer on folder sees only granted subtree", async () => {
|
|
100
|
+
const harness = await createHarness();
|
|
101
|
+
try {
|
|
102
|
+
const state = createSampleState();
|
|
103
|
+
await harness.service.syncResourcesFromState(state);
|
|
104
|
+
|
|
105
|
+
const alice = user("alice@example.com");
|
|
106
|
+
await harness.service.ensureBootstrapOwner(alice);
|
|
107
|
+
|
|
108
|
+
await harness.service.grantBinding({
|
|
109
|
+
actorUser: alice,
|
|
110
|
+
bindingInput: {
|
|
111
|
+
resourceType: "folder",
|
|
112
|
+
resourceExternalId: "f1",
|
|
113
|
+
subjectType: "user",
|
|
114
|
+
subjectId: "bob@example.com",
|
|
115
|
+
role: "viewer",
|
|
116
|
+
inherit: true,
|
|
117
|
+
},
|
|
118
|
+
mode: "enforce",
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
const filtered = await harness.service.filterStateForUser(state, user("bob@example.com"), {
|
|
122
|
+
mode: "enforce",
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
assert.deepEqual(
|
|
126
|
+
filtered.folders.map((folder) => folder.id).sort(),
|
|
127
|
+
["f1", "f2"],
|
|
128
|
+
);
|
|
129
|
+
assert.deepEqual(filtered.notes.map((note) => note.id), ["n1"]);
|
|
130
|
+
} finally {
|
|
131
|
+
await harness.dispose();
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("viewer cannot write within granted subtree", async () => {
|
|
136
|
+
const harness = await createHarness();
|
|
137
|
+
try {
|
|
138
|
+
const state = createSampleState();
|
|
139
|
+
await harness.service.syncResourcesFromState(state);
|
|
140
|
+
|
|
141
|
+
const alice = user("alice@example.com");
|
|
142
|
+
await harness.service.ensureBootstrapOwner(alice);
|
|
143
|
+
await harness.service.grantBinding({
|
|
144
|
+
actorUser: alice,
|
|
145
|
+
bindingInput: {
|
|
146
|
+
resourceType: "folder",
|
|
147
|
+
resourceExternalId: "f1",
|
|
148
|
+
subjectType: "user",
|
|
149
|
+
subjectId: "bob@example.com",
|
|
150
|
+
role: "viewer",
|
|
151
|
+
inherit: true,
|
|
152
|
+
},
|
|
153
|
+
mode: "enforce",
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
const bob = user("bob@example.com");
|
|
157
|
+
const visibleState = await harness.service.filterStateForUser(state, bob, {
|
|
158
|
+
mode: "enforce",
|
|
159
|
+
});
|
|
160
|
+
visibleState.notes[0].content = "try-write";
|
|
161
|
+
visibleState.notes[0].updatedAt = Date.now();
|
|
162
|
+
|
|
163
|
+
await assert.rejects(
|
|
164
|
+
() =>
|
|
165
|
+
harness.service.mergeScopedStateForWrite({
|
|
166
|
+
currentFullState: state,
|
|
167
|
+
incomingScopedState: visibleState,
|
|
168
|
+
user: bob,
|
|
169
|
+
mode: "enforce",
|
|
170
|
+
}),
|
|
171
|
+
(error) => Number(error?.status) === 403,
|
|
172
|
+
);
|
|
173
|
+
} finally {
|
|
174
|
+
await harness.dispose();
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("editor write merges visible changes without clobbering hidden notes", async () => {
|
|
179
|
+
const harness = await createHarness();
|
|
180
|
+
try {
|
|
181
|
+
const state = createSampleState();
|
|
182
|
+
await harness.service.syncResourcesFromState(state);
|
|
183
|
+
|
|
184
|
+
const alice = user("alice@example.com");
|
|
185
|
+
await harness.service.ensureBootstrapOwner(alice);
|
|
186
|
+
await harness.service.grantBinding({
|
|
187
|
+
actorUser: alice,
|
|
188
|
+
bindingInput: {
|
|
189
|
+
resourceType: "folder",
|
|
190
|
+
resourceExternalId: "f1",
|
|
191
|
+
subjectType: "user",
|
|
192
|
+
subjectId: "bob@example.com",
|
|
193
|
+
role: "editor",
|
|
194
|
+
inherit: true,
|
|
195
|
+
},
|
|
196
|
+
mode: "enforce",
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
const bob = user("bob@example.com");
|
|
200
|
+
const visibleState = await harness.service.filterStateForUser(state, bob, {
|
|
201
|
+
mode: "enforce",
|
|
202
|
+
});
|
|
203
|
+
visibleState.notes[0].content = "edited-by-bob";
|
|
204
|
+
visibleState.notes[0].updatedAt = Date.now();
|
|
205
|
+
|
|
206
|
+
const merged = await harness.service.mergeScopedStateForWrite({
|
|
207
|
+
currentFullState: state,
|
|
208
|
+
incomingScopedState: visibleState,
|
|
209
|
+
user: bob,
|
|
210
|
+
mode: "enforce",
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
const note1 = merged.notes.find((note) => note.id === "n1");
|
|
214
|
+
const note2 = merged.notes.find((note) => note.id === "n2");
|
|
215
|
+
assert.equal(note1?.content, "edited-by-bob");
|
|
216
|
+
assert.equal(note2?.content, "private-note");
|
|
217
|
+
} finally {
|
|
218
|
+
await harness.dispose();
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test("editor cannot manage ACL bindings", async () => {
|
|
223
|
+
const harness = await createHarness();
|
|
224
|
+
try {
|
|
225
|
+
const state = createSampleState();
|
|
226
|
+
await harness.service.syncResourcesFromState(state);
|
|
227
|
+
|
|
228
|
+
const alice = user("alice@example.com");
|
|
229
|
+
await harness.service.ensureBootstrapOwner(alice);
|
|
230
|
+
await harness.service.grantBinding({
|
|
231
|
+
actorUser: alice,
|
|
232
|
+
bindingInput: {
|
|
233
|
+
resourceType: "folder",
|
|
234
|
+
resourceExternalId: "f1",
|
|
235
|
+
subjectType: "user",
|
|
236
|
+
subjectId: "bob@example.com",
|
|
237
|
+
role: "editor",
|
|
238
|
+
inherit: true,
|
|
239
|
+
},
|
|
240
|
+
mode: "enforce",
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
await assert.rejects(
|
|
244
|
+
() =>
|
|
245
|
+
harness.service.grantBinding({
|
|
246
|
+
actorUser: user("bob@example.com"),
|
|
247
|
+
bindingInput: {
|
|
248
|
+
resourceType: "folder",
|
|
249
|
+
resourceExternalId: "f1",
|
|
250
|
+
subjectType: "user",
|
|
251
|
+
subjectId: "charlie@example.com",
|
|
252
|
+
role: "viewer",
|
|
253
|
+
inherit: true,
|
|
254
|
+
},
|
|
255
|
+
mode: "enforce",
|
|
256
|
+
}),
|
|
257
|
+
(error) => Number(error?.status) === 403,
|
|
258
|
+
);
|
|
259
|
+
} finally {
|
|
260
|
+
await harness.dispose();
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test("authenticated user gets personal Home folder with owner access", async () => {
|
|
265
|
+
const harness = await createHarness();
|
|
266
|
+
try {
|
|
267
|
+
const state = createSampleState();
|
|
268
|
+
await harness.service.syncResourcesFromState(state);
|
|
269
|
+
|
|
270
|
+
const bob = user("bob@example.com");
|
|
271
|
+
const ensured = await harness.service.ensureUserHomeFolder(state, bob, { mode: "enforce" });
|
|
272
|
+
assert.equal(Boolean(ensured.homeFolderId), true);
|
|
273
|
+
assert.equal(ensured.changed, true);
|
|
274
|
+
|
|
275
|
+
const homeFolder = ensured.state.folders.find((folder) => folder.id === ensured.homeFolderId);
|
|
276
|
+
assert.ok(homeFolder);
|
|
277
|
+
assert.equal(homeFolder.name, "bob");
|
|
278
|
+
assert.equal(homeFolder.parentId, null);
|
|
279
|
+
|
|
280
|
+
const filtered = await harness.service.filterStateForUser(ensured.state, bob, {
|
|
281
|
+
mode: "enforce",
|
|
282
|
+
});
|
|
283
|
+
assert.equal(filtered.folders.some((folder) => folder.id === ensured.homeFolderId), true);
|
|
284
|
+
|
|
285
|
+
const capabilities = await harness.service.getCapabilitiesForResource(bob, {
|
|
286
|
+
resourceType: "folder",
|
|
287
|
+
resourceExternalId: ensured.homeFolderId,
|
|
288
|
+
mode: "enforce",
|
|
289
|
+
});
|
|
290
|
+
assert.equal(capabilities.canRead, true);
|
|
291
|
+
assert.equal(capabilities.canWrite, true);
|
|
292
|
+
assert.equal(capabilities.canManage, true);
|
|
293
|
+
} finally {
|
|
294
|
+
await harness.dispose();
|
|
295
|
+
}
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
test("home folder remains stable across identity header variants", async () => {
|
|
299
|
+
const harness = await createHarness();
|
|
300
|
+
try {
|
|
301
|
+
const state = createSampleState();
|
|
302
|
+
await harness.service.syncResourcesFromState(state);
|
|
303
|
+
|
|
304
|
+
const bobWithEmail = user("bob@example.com", "bob");
|
|
305
|
+
const ensuredFirst = await harness.service.ensureUserHomeFolder(state, bobWithEmail, { mode: "enforce" });
|
|
306
|
+
assert.equal(Boolean(ensuredFirst.homeFolderId), true);
|
|
307
|
+
|
|
308
|
+
const bobUserOnly = user("", "bob");
|
|
309
|
+
const ensuredSecond = await harness.service.ensureUserHomeFolder(ensuredFirst.state, bobUserOnly, { mode: "enforce" });
|
|
310
|
+
|
|
311
|
+
assert.equal(ensuredSecond.homeFolderId, ensuredFirst.homeFolderId);
|
|
312
|
+
assert.equal(ensuredSecond.state.folders.filter((folder) => folder.name === "bob").length >= 1, true);
|
|
313
|
+
|
|
314
|
+
const filtered = await harness.service.filterStateForUser(ensuredSecond.state, bobUserOnly, {
|
|
315
|
+
mode: "enforce",
|
|
316
|
+
});
|
|
317
|
+
assert.equal(filtered.folders.some((folder) => folder.id === ensuredSecond.homeFolderId), true);
|
|
318
|
+
} finally {
|
|
319
|
+
await harness.dispose();
|
|
320
|
+
}
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
test("owner cannot revoke own owner binding", async () => {
|
|
324
|
+
const harness = await createHarness();
|
|
325
|
+
try {
|
|
326
|
+
const state = createSampleState();
|
|
327
|
+
await harness.service.syncResourcesFromState(state);
|
|
328
|
+
|
|
329
|
+
const alice = user("alice@example.com");
|
|
330
|
+
await harness.service.ensureBootstrapOwner(alice);
|
|
331
|
+
|
|
332
|
+
const bindings = await harness.store.listBindings();
|
|
333
|
+
const aliceOwnerBinding = bindings.find(
|
|
334
|
+
(binding) =>
|
|
335
|
+
binding.resourceType === "workspace" &&
|
|
336
|
+
binding.resourceExternalId === "main" &&
|
|
337
|
+
binding.subjectType === "user" &&
|
|
338
|
+
binding.subjectId === "alice@example.com" &&
|
|
339
|
+
binding.role === "owner",
|
|
340
|
+
);
|
|
341
|
+
assert.ok(aliceOwnerBinding?.id, "expected bootstrap owner binding for alice");
|
|
342
|
+
|
|
343
|
+
await assert.rejects(
|
|
344
|
+
() =>
|
|
345
|
+
harness.service.revokeBinding({
|
|
346
|
+
actorUser: alice,
|
|
347
|
+
bindingId: aliceOwnerBinding.id,
|
|
348
|
+
mode: "enforce",
|
|
349
|
+
}),
|
|
350
|
+
(error) =>
|
|
351
|
+
Number(error?.status) === 400 &&
|
|
352
|
+
String(error?.message || "").includes("cannot revoke own owner"),
|
|
353
|
+
);
|
|
354
|
+
|
|
355
|
+
const bobViewer = await harness.service.grantBinding({
|
|
356
|
+
actorUser: alice,
|
|
357
|
+
bindingInput: {
|
|
358
|
+
resourceType: "folder",
|
|
359
|
+
resourceExternalId: "f1",
|
|
360
|
+
subjectType: "user",
|
|
361
|
+
subjectId: "bob@example.com",
|
|
362
|
+
role: "viewer",
|
|
363
|
+
inherit: true,
|
|
364
|
+
},
|
|
365
|
+
mode: "enforce",
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
const deleted = await harness.service.revokeBinding({
|
|
369
|
+
actorUser: alice,
|
|
370
|
+
bindingId: bobViewer.id,
|
|
371
|
+
mode: "enforce",
|
|
372
|
+
});
|
|
373
|
+
assert.equal(deleted, true);
|
|
374
|
+
} finally {
|
|
375
|
+
await harness.dispose();
|
|
376
|
+
}
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
test("stale unchanged note payload does not clobber newer note content", async () => {
|
|
380
|
+
const harness = await createHarness();
|
|
381
|
+
try {
|
|
382
|
+
const state = createSampleState();
|
|
383
|
+
await harness.service.syncResourcesFromState(state);
|
|
384
|
+
|
|
385
|
+
const alice = user("alice@example.com");
|
|
386
|
+
await harness.service.ensureBootstrapOwner(alice);
|
|
387
|
+
await harness.service.grantBinding({
|
|
388
|
+
actorUser: alice,
|
|
389
|
+
bindingInput: {
|
|
390
|
+
resourceType: "folder",
|
|
391
|
+
resourceExternalId: "f1",
|
|
392
|
+
subjectType: "user",
|
|
393
|
+
subjectId: "bob@example.com",
|
|
394
|
+
role: "editor",
|
|
395
|
+
inherit: true,
|
|
396
|
+
},
|
|
397
|
+
mode: "enforce",
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
const bob = user("bob@example.com");
|
|
401
|
+
const visibleBaseline = await harness.service.filterStateForUser(state, bob, {
|
|
402
|
+
mode: "enforce",
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
// Simulate server moving ahead (alice edited n1 after bob took baseline).
|
|
406
|
+
const currentFullState = {
|
|
407
|
+
...state,
|
|
408
|
+
notes: state.notes.map((note) =>
|
|
409
|
+
note.id === "n1"
|
|
410
|
+
? {
|
|
411
|
+
...note,
|
|
412
|
+
content: "alice-newer-content",
|
|
413
|
+
updatedAt: note.updatedAt + 10_000,
|
|
414
|
+
}
|
|
415
|
+
: { ...note }),
|
|
416
|
+
};
|
|
417
|
+
|
|
418
|
+
// Bob submits a stale snapshot where n1 is unchanged vs his baseline.
|
|
419
|
+
// Only UI expansion changes in his payload.
|
|
420
|
+
const incomingScopedState = {
|
|
421
|
+
...visibleBaseline,
|
|
422
|
+
ui: {
|
|
423
|
+
expandedFolderIds: ["f1"],
|
|
424
|
+
},
|
|
425
|
+
};
|
|
426
|
+
|
|
427
|
+
const merged = await harness.service.mergeScopedStateForWrite({
|
|
428
|
+
currentFullState,
|
|
429
|
+
incomingScopedState,
|
|
430
|
+
user: bob,
|
|
431
|
+
mode: "enforce",
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
const note = merged.notes.find((item) => item.id === "n1");
|
|
435
|
+
assert.equal(note?.content, "alice-newer-content");
|
|
436
|
+
} finally {
|
|
437
|
+
await harness.dispose();
|
|
438
|
+
}
|
|
439
|
+
});
|