funifier-mcp 0.3.10 → 0.3.12

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.
Files changed (44) hide show
  1. package/.cursor/rules/funifier.mdc +1 -0
  2. package/.github/copilot-instructions.md +1 -0
  3. package/AGENTS.md +46 -1
  4. package/README.md +13 -2
  5. package/datasource-funifier-docs/.coverage.json +1 -1
  6. package/dist/cli/config-writers.d.ts +3 -3
  7. package/dist/cli/config-writers.d.ts.map +1 -1
  8. package/dist/cli/config-writers.js +22 -24
  9. package/dist/cli/config-writers.js.map +1 -1
  10. package/dist/cli/config-writers.test.js +7 -2
  11. package/dist/cli/config-writers.test.js.map +1 -1
  12. package/dist/cli/init.d.ts.map +1 -1
  13. package/dist/cli/init.js +19 -11
  14. package/dist/cli/init.js.map +1 -1
  15. package/dist/cli/init.test.js +46 -0
  16. package/dist/cli/init.test.js.map +1 -1
  17. package/dist/core/api-client.d.ts +4 -0
  18. package/dist/core/api-client.d.ts.map +1 -1
  19. package/dist/core/api-client.js +23 -0
  20. package/dist/core/api-client.js.map +1 -1
  21. package/dist/mcp/bundle.js +110 -107
  22. package/dist/mcp/tools/_backup.d.ts +89 -0
  23. package/dist/mcp/tools/_backup.d.ts.map +1 -0
  24. package/dist/mcp/tools/_backup.js +325 -0
  25. package/dist/mcp/tools/_backup.js.map +1 -0
  26. package/dist/mcp/tools/_backup.test.d.ts +2 -0
  27. package/dist/mcp/tools/_backup.test.d.ts.map +1 -0
  28. package/dist/mcp/tools/_backup.test.js +342 -0
  29. package/dist/mcp/tools/_backup.test.js.map +1 -0
  30. package/dist/mcp/tools/database.d.ts.map +1 -1
  31. package/dist/mcp/tools/database.js +14 -0
  32. package/dist/mcp/tools/database.js.map +1 -1
  33. package/dist/mcp/tools/database.test.js +40 -0
  34. package/dist/mcp/tools/database.test.js.map +1 -1
  35. package/dist/mcp/tools/list-tools.d.ts.map +1 -1
  36. package/dist/mcp/tools/list-tools.js +3 -2
  37. package/dist/mcp/tools/list-tools.js.map +1 -1
  38. package/dist/mcp/tools/permissions.d.ts.map +1 -1
  39. package/dist/mcp/tools/permissions.js +231 -4
  40. package/dist/mcp/tools/permissions.js.map +1 -1
  41. package/dist/mcp/tools/permissions.test.js +562 -12
  42. package/dist/mcp/tools/permissions.test.js.map +1 -1
  43. package/package.json +1 -1
  44. package/skills/funifier/references/configure-security.md +18 -0
@@ -1,31 +1,94 @@
1
1
  "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
2
5
  Object.defineProperty(exports, "__esModule", { value: true });
3
6
  const vitest_1 = require("vitest");
7
+ const fs_1 = __importDefault(require("fs"));
8
+ const os_1 = __importDefault(require("os"));
9
+ const path_1 = __importDefault(require("path"));
4
10
  const permissions_1 = require("./permissions");
11
+ const _backup_1 = require("./_backup");
12
+ // Every mutating action now writes a snapshot to backupRoot(); redirect it to a temp dir
13
+ // (via the FUNIFIER_BACKUP_ROOT seam) so tests never touch the real working tree.
14
+ let backupDir;
15
+ (0, vitest_1.beforeEach)(() => {
16
+ backupDir = fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), "funifier-perm-test-"));
17
+ process.env.FUNIFIER_BACKUP_ROOT = backupDir;
18
+ });
19
+ (0, vitest_1.afterEach)(() => {
20
+ delete process.env.FUNIFIER_BACKUP_ROOT;
21
+ fs_1.default.rmSync(backupDir, { recursive: true, force: true });
22
+ });
23
+ function readNewestSnapshot() {
24
+ const items = (0, _backup_1.listSnapshots)(backupDir);
25
+ if (items.length === 0)
26
+ throw new Error("no snapshot written");
27
+ return JSON.parse(fs_1.default.readFileSync(items[0].path, "utf8"));
28
+ }
5
29
  const SECURITY = {
6
30
  _id: "game1",
7
31
  apps: [{ name: "Default", app_secret: "old-secret", scope: "read_all" }],
8
32
  roles: [{ name: "player", timeout: "1d", scope: "read_all,write_actionlog" }],
9
33
  };
10
34
  function fakeApi(overrides = {}) {
11
- return {
12
- queryCollection: vitest_1.vi.fn().mockResolvedValue([structuredClone(SECURITY)]),
13
- updateDocument: vitest_1.vi.fn().mockImplementation((_collection, doc) => Promise.resolve(doc)),
14
- listRoles: vitest_1.vi.fn().mockResolvedValue([
15
- { _id: "r1", name: "Admin", type: "system", item: "system", permissions: [] },
16
- { _id: "r2", name: "Game Editor", type: "gamification", item: "game1", permissions: [] },
17
- ]),
35
+ const roles = [
36
+ { _id: "r1", name: "Admin", type: "system", item: "system", permissions: [] },
37
+ { _id: "r2", name: "Game Editor", type: "gamification", item: "game1", permissions: [] },
38
+ ];
39
+ const assignments = [{ type: "user", item: "u1", role: "r1" }];
40
+ // Stateful security doc: updateDocument stores the new state so re-reads (verifySecurityPersisted)
41
+ // see the post-mutation document by default.
42
+ let securityDoc = structuredClone(SECURITY);
43
+ const defaultUpdateDocument = vitest_1.vi.fn().mockImplementation(async (collection, doc) => {
44
+ if (collection === "security")
45
+ securityDoc = structuredClone(doc);
46
+ return doc;
47
+ });
48
+ // If the caller overrides updateDocument, chain the state update so reads stay consistent.
49
+ const updateDocument = overrides.updateDocument
50
+ ? vitest_1.vi.fn().mockImplementation(async (collection, doc) => {
51
+ if (collection === "security")
52
+ securityDoc = structuredClone(doc);
53
+ return overrides.updateDocument(collection, doc);
54
+ })
55
+ : defaultUpdateDocument;
56
+ const base = {
57
+ aggregateCollection: vitest_1.vi.fn().mockImplementation(async (collection) => {
58
+ if (collection === "security")
59
+ return [{ ...securityDoc, _id: String(securityDoc._id) }];
60
+ return [];
61
+ }),
62
+ queryCollection: vitest_1.vi.fn().mockImplementation(async (collection) => {
63
+ if (collection === "security")
64
+ return [securityDoc];
65
+ return [];
66
+ }),
67
+ updateDocument,
68
+ listRoles: vitest_1.vi.fn().mockResolvedValue(roles),
69
+ listRolesStrict: vitest_1.vi.fn().mockResolvedValue(roles),
70
+ getRoleById: vitest_1.vi.fn().mockResolvedValue({ _id: "r1", name: "Admin", type: "system", item: "system", permissions: [] }),
18
71
  saveRole: vitest_1.vi.fn().mockImplementation((role) => Promise.resolve({ ...role, _id: role._id ?? "new-role" })),
19
72
  deleteRole: vitest_1.vi.fn().mockResolvedValue({ ok: 1 }),
20
- listRoleAssignments: vitest_1.vi.fn().mockResolvedValue([{ type: "user", item: "u1", role: "r1" }]),
73
+ listRoleAssignments: vitest_1.vi.fn().mockResolvedValue(assignments),
74
+ listRoleAssignmentsStrict: vitest_1.vi.fn().mockResolvedValue(assignments),
21
75
  assignRole: vitest_1.vi.fn().mockImplementation((payload) => Promise.resolve(payload)),
22
76
  unassignRole: vitest_1.vi.fn().mockResolvedValue({ ok: 1 }),
23
- ...overrides,
24
77
  };
78
+ // Apply remaining overrides (except updateDocument which was handled above).
79
+ const { updateDocument: _ud, ...otherOverrides } = overrides;
80
+ return { ...base, ...otherOverrides };
25
81
  }
26
- function makeInvoke(overrides = {}) {
82
+ function makeInvoke(overrides = {}, connection = { serverUrl: "https://test.funifier.com" }) {
27
83
  const api = fakeApi(overrides);
28
- const holder = { requireClient: () => api };
84
+ const holder = {
85
+ requireClient: () => api,
86
+ getConnectionInfo: () => ({
87
+ connected: true,
88
+ serverUrl: connection.serverUrl ?? null,
89
+ name: null,
90
+ }),
91
+ };
29
92
  let handler;
30
93
  const server = {
31
94
  registerTool: (_name, _config, h) => { handler = h; },
@@ -38,7 +101,7 @@ function makeInvoke(overrides = {}) {
38
101
  const { invoke, api } = makeInvoke();
39
102
  const result = await invoke({ action: "list_api_apps" });
40
103
  (0, vitest_1.expect)(result.isError).toBeUndefined();
41
- (0, vitest_1.expect)(api.queryCollection).toHaveBeenCalledWith("security", {}, { limit: 1, skip: 0 });
104
+ (0, vitest_1.expect)(api.aggregateCollection).toHaveBeenCalledWith("security", vitest_1.expect.arrayContaining([vitest_1.expect.objectContaining({ $addFields: { _id: { $toString: "$_id" } } })]));
42
105
  (0, vitest_1.expect)(result.content[0].text).toContain("old-…cret");
43
106
  (0, vitest_1.expect)(result.content[0].text).not.toContain("old-secret");
44
107
  });
@@ -96,4 +159,491 @@ function makeInvoke(overrides = {}) {
96
159
  (0, vitest_1.expect)(result.content[0].text).toContain("Error in funifier_permissions");
97
160
  });
98
161
  });
162
+ (0, vitest_1.describe)("funifier_permissions native pre-write backup (no LLM in the loop)", () => {
163
+ (0, vitest_1.it)("writes a snapshot of the prior security doc BEFORE the mutation fires", async () => {
164
+ // The mutation mock records how many snapshots exist at the moment it is invoked.
165
+ let snapshotsWhenMutated = -1;
166
+ const { invoke, api } = makeInvoke({
167
+ updateDocument: vitest_1.vi.fn().mockImplementation((_collection, doc) => {
168
+ snapshotsWhenMutated = (0, _backup_1.listSnapshots)(backupDir).length;
169
+ return Promise.resolve(doc);
170
+ }),
171
+ });
172
+ // A genuinely new app, so the mutated doc (2 apps) differs from the prior doc (1 app).
173
+ const result = await invoke({
174
+ action: "save_api_app",
175
+ data: JSON.stringify({ name: "New App", app_secret: "brand-new", scope: "read_all" }),
176
+ });
177
+ (0, vitest_1.expect)(result.isError).toBeUndefined();
178
+ (0, vitest_1.expect)(api.updateDocument).toHaveBeenCalled();
179
+ (0, vitest_1.expect)(snapshotsWhenMutated).toBe(1); // backup existed before updateDocument ran
180
+ const snap = readNewestSnapshot();
181
+ (0, vitest_1.expect)(snap.category).toBe("security");
182
+ (0, vitest_1.expect)(snap.existedBefore).toBe(true);
183
+ (0, vitest_1.expect)(snap.preImage).toEqual(SECURITY); // whole PRIOR doc, not the mutated 2-app version
184
+ });
185
+ (0, vitest_1.it)("aborts the mutation and returns isError when the backup write fails", async () => {
186
+ // Point the backup root at a path blocked by a file → mkdir for the category dir throws.
187
+ const blocker = path_1.default.join(backupDir, "blocker");
188
+ fs_1.default.writeFileSync(blocker, "not-a-directory");
189
+ process.env.FUNIFIER_BACKUP_ROOT = path_1.default.join(blocker, "nested");
190
+ const { invoke, api } = makeInvoke();
191
+ const result = await invoke({
192
+ action: "save_api_app",
193
+ data: JSON.stringify({ name: "Default", app_secret: "old-secret", scope: "read_all" }),
194
+ });
195
+ (0, vitest_1.expect)(result.isError).toBe(true);
196
+ (0, vitest_1.expect)(result.content[0].text).toContain("Backup write failed (mutation aborted)");
197
+ (0, vitest_1.expect)(api.updateDocument).not.toHaveBeenCalled(); // the literal "native, not LLM" guarantee
198
+ });
199
+ (0, vitest_1.it)("records existedBefore=false / preImage=null when creating a brand-new studio role", async () => {
200
+ const { invoke, api } = makeInvoke();
201
+ const role = {
202
+ name: "Brand New",
203
+ type: "gamification",
204
+ item: "game1",
205
+ permissions: [{ type: "page", object: "/x", operations: { read: true } }],
206
+ };
207
+ const result = await invoke({ action: "save_studio_role", data: JSON.stringify(role) });
208
+ (0, vitest_1.expect)(result.isError).toBeUndefined();
209
+ (0, vitest_1.expect)(api.saveRole).toHaveBeenCalledWith(role);
210
+ const snap = readNewestSnapshot(); // built by a real captureBackup, not a hand-faked fixture
211
+ (0, vitest_1.expect)(snap.category).toBe("studio-role");
212
+ (0, vitest_1.expect)(snap.existedBefore).toBe(false);
213
+ (0, vitest_1.expect)(snap.preImage).toBeNull();
214
+ (0, vitest_1.expect)(snap.naturalKey).toEqual({ name: "Brand New", type: "gamification", item: "game1" });
215
+ });
216
+ vitest_1.it.each([
217
+ ["save_api_app", { data: JSON.stringify({ name: "X", app_secret: "s", scope: "read_all" }) }],
218
+ ["delete_api_app", { app_secret: "old-secret" }],
219
+ ["save_security_role", { data: JSON.stringify({ name: "X", timeout: "1d", scope: "read_all" }) }],
220
+ ["delete_security_role", { name: "player" }],
221
+ ["save_studio_role", { data: JSON.stringify({ name: "X", type: "system", item: "system", permissions: [] }) }],
222
+ ["delete_studio_role", { role_id: "r1" }],
223
+ ["assign_studio_role", { principal_type: "user", principal_item: "u1", role_id: "r1" }],
224
+ ["unassign_studio_role", { principal_type: "user", principal_item: "u1", role_id: "r1" }],
225
+ ])("%s writes exactly one pre-write snapshot", async (action, args) => {
226
+ const { invoke } = makeInvoke();
227
+ const result = await invoke({ action, ...args });
228
+ (0, vitest_1.expect)(result.isError).toBeUndefined();
229
+ (0, vitest_1.expect)((0, _backup_1.listSnapshots)(backupDir).length).toBe(1);
230
+ });
231
+ (0, vitest_1.it)("restore_backup reapplies a security snapshot via updateDocument", async () => {
232
+ // 1. A real mutation captures the prior security doc.
233
+ const writer = makeInvoke();
234
+ await writer.invoke({
235
+ action: "save_api_app",
236
+ data: JSON.stringify({ name: "Default", app_secret: "old-secret", scope: "read_all" }),
237
+ });
238
+ (0, vitest_1.expect)((0, _backup_1.listSnapshots)(backupDir).length).toBe(1);
239
+ const backupPath = (0, _backup_1.listSnapshots)(backupDir)[0].path;
240
+ // 2. A fresh handler restores it — restore writes its own pre-write backup (BKP-11).
241
+ const { invoke, api } = makeInvoke();
242
+ const result = await invoke({ action: "restore_backup", backup_path: backupPath });
243
+ (0, vitest_1.expect)(result.isError).toBeUndefined();
244
+ (0, vitest_1.expect)(api.updateDocument).toHaveBeenCalledWith("security", vitest_1.expect.objectContaining({ _id: "game1" }));
245
+ (0, vitest_1.expect)((0, _backup_1.listSnapshots)(backupDir).length).toBe(2);
246
+ });
247
+ (0, vitest_1.it)("restore_backup rejects cross-server snapshots when serverUrl differs", async () => {
248
+ const writer = makeInvoke({}, { serverUrl: "https://tenant-a.funifier.com" });
249
+ await writer.invoke({
250
+ action: "save_api_app",
251
+ data: JSON.stringify({ name: "Default", app_secret: "old-secret", scope: "read_all" }),
252
+ });
253
+ const backupPath = (0, _backup_1.listSnapshots)(backupDir)[0].path;
254
+ const { invoke, api } = makeInvoke({}, { serverUrl: "https://tenant-b.funifier.com" });
255
+ const result = await invoke({ action: "restore_backup", backup_path: backupPath });
256
+ (0, vitest_1.expect)(result.isError).toBe(true);
257
+ (0, vitest_1.expect)(result.content[0].text).toMatch(/serverUrl mismatch/i);
258
+ (0, vitest_1.expect)(api.updateDocument).not.toHaveBeenCalled();
259
+ });
260
+ (0, vitest_1.it)("delete_studio_role aborts when getRoleById fails to read the role", async () => {
261
+ const { invoke, api } = makeInvoke({
262
+ getRoleById: vitest_1.vi.fn().mockResolvedValue(undefined),
263
+ });
264
+ const result = await invoke({ action: "delete_studio_role", role_id: "r1" });
265
+ (0, vitest_1.expect)(result.isError).toBe(true);
266
+ (0, vitest_1.expect)(result.content[0].text).toContain("not found or unreadable");
267
+ (0, vitest_1.expect)(api.deleteRole).not.toHaveBeenCalled();
268
+ (0, vitest_1.expect)((0, _backup_1.listSnapshots)(backupDir).length).toBe(0);
269
+ });
270
+ (0, vitest_1.it)("save_studio_role aborts when listRolesStrict fails (avoids false absent snapshot)", async () => {
271
+ const { invoke, api } = makeInvoke({
272
+ listRolesStrict: vitest_1.vi.fn().mockRejectedValue(new Error("network down")),
273
+ });
274
+ const result = await invoke({
275
+ action: "save_studio_role",
276
+ data: JSON.stringify({
277
+ name: "Editor",
278
+ type: "gamification",
279
+ item: "game1",
280
+ permissions: [{ type: "page", object: "/x", operations: { read: true } }],
281
+ }),
282
+ });
283
+ (0, vitest_1.expect)(result.isError).toBe(true);
284
+ (0, vitest_1.expect)(result.content[0].text).toContain("Failed to capture pre-image");
285
+ (0, vitest_1.expect)(api.saveRole).not.toHaveBeenCalled();
286
+ (0, vitest_1.expect)((0, _backup_1.listSnapshots)(backupDir).length).toBe(0);
287
+ });
288
+ (0, vitest_1.it)("assign_studio_role aborts when listRoleAssignmentsStrict fails (avoids false absent snapshot)", async () => {
289
+ const { invoke, api } = makeInvoke({
290
+ listRoleAssignmentsStrict: vitest_1.vi.fn().mockRejectedValue(new Error("network down")),
291
+ });
292
+ const result = await invoke({
293
+ action: "assign_studio_role",
294
+ principal_type: "user",
295
+ principal_item: "u1",
296
+ role_id: "r1",
297
+ });
298
+ (0, vitest_1.expect)(result.isError).toBe(true);
299
+ (0, vitest_1.expect)(result.content[0].text).toContain("Failed to capture pre-image");
300
+ (0, vitest_1.expect)(api.assignRole).not.toHaveBeenCalled();
301
+ (0, vitest_1.expect)((0, _backup_1.listSnapshots)(backupDir).length).toBe(0);
302
+ });
303
+ (0, vitest_1.it)("restore_backup of an absent studio role resolves the _id via natural key and deletes it", async () => {
304
+ // 1. Create-path snapshot from a real save of a role absent in listRoles → existedBefore=false.
305
+ const writer = makeInvoke();
306
+ await writer.invoke({
307
+ action: "save_studio_role",
308
+ data: JSON.stringify({
309
+ name: "Brand New",
310
+ type: "gamification",
311
+ item: "game1",
312
+ permissions: [{ type: "page", object: "/x", operations: { read: true } }],
313
+ }),
314
+ });
315
+ const backupPath = (0, _backup_1.listSnapshots)(backupDir)[0].path;
316
+ // 2. At restore time the role now exists with a server-assigned _id.
317
+ const { invoke, api } = makeInvoke({
318
+ listRoles: vitest_1.vi.fn().mockResolvedValue([
319
+ { _id: "created-id", name: "Brand New", type: "gamification", item: "game1", permissions: [] },
320
+ ]),
321
+ });
322
+ const result = await invoke({ action: "restore_backup", backup_path: backupPath });
323
+ (0, vitest_1.expect)(result.isError).toBeUndefined();
324
+ (0, vitest_1.expect)(api.deleteRole).toHaveBeenCalledWith("created-id");
325
+ });
326
+ (0, vitest_1.it)("restore_backup with a missing path errors and performs no remote write", async () => {
327
+ const { invoke, api } = makeInvoke();
328
+ const result = await invoke({ action: "restore_backup", backup_path: path_1.default.join(backupDir, "missing.json") });
329
+ (0, vitest_1.expect)(result.isError).toBe(true);
330
+ (0, vitest_1.expect)(api.updateDocument).not.toHaveBeenCalled();
331
+ (0, vitest_1.expect)(api.deleteRole).not.toHaveBeenCalled();
332
+ });
333
+ (0, vitest_1.it)("restore_backup rejects path traversal outside the backup root", async () => {
334
+ const outside = path_1.default.join(backupDir, "..", "outside-backup.json");
335
+ fs_1.default.writeFileSync(outside, JSON.stringify({
336
+ version: 1,
337
+ action: "save_api_app",
338
+ category: "security",
339
+ resourceId: "game1",
340
+ existedBefore: true,
341
+ capturedAt: "2026-05-28T14:31:02.123Z",
342
+ serverUrl: null,
343
+ preImage: SECURITY,
344
+ }));
345
+ const { invoke, api } = makeInvoke();
346
+ const result = await invoke({ action: "restore_backup", backup_path: outside });
347
+ (0, vitest_1.expect)(result.isError).toBe(true);
348
+ (0, vitest_1.expect)(result.content[0].text).toMatch(/must be within/i);
349
+ (0, vitest_1.expect)(api.updateDocument).not.toHaveBeenCalled();
350
+ fs_1.default.rmSync(outside, { force: true });
351
+ });
352
+ (0, vitest_1.it)("restore_backup succeeds when the live security document is truncated (no apps/roles)", async () => {
353
+ // Critical #1: a truncated live doc must NOT block restore — that is exactly the recovery scenario.
354
+ // Step 1: capture a healthy snapshot via a normal mutation.
355
+ const writer = makeInvoke();
356
+ await writer.invoke({
357
+ action: "save_api_app",
358
+ data: JSON.stringify({ name: "Default", app_secret: "old-secret", scope: "read_all" }),
359
+ });
360
+ const backupPath = (0, _backup_1.listSnapshots)(backupDir)[0].path;
361
+ // Step 2: stateful mock — starts with a truncated doc, reflects the write so verifySecurityPersisted passes.
362
+ let currentDoc = { _id: "game1" };
363
+ const { invoke, api } = makeInvoke({
364
+ aggregateCollection: vitest_1.vi.fn().mockImplementation(async (collection) => {
365
+ if (collection === "security")
366
+ return [{ ...currentDoc, _id: String(currentDoc._id) }];
367
+ return [];
368
+ }),
369
+ queryCollection: vitest_1.vi.fn().mockImplementation(async (collection) => {
370
+ if (collection === "security")
371
+ return [currentDoc];
372
+ return [];
373
+ }),
374
+ updateDocument: vitest_1.vi.fn().mockImplementation(async (collection, doc) => {
375
+ if (collection === "security")
376
+ currentDoc = structuredClone(doc);
377
+ return doc;
378
+ }),
379
+ });
380
+ const result = await invoke({ action: "restore_backup", backup_path: backupPath });
381
+ (0, vitest_1.expect)(result.isError).toBeUndefined();
382
+ (0, vitest_1.expect)(api.updateDocument).toHaveBeenCalledWith("security", vitest_1.expect.objectContaining({ _id: "game1" }));
383
+ // Two snapshots: original mutation + restore's own pre-backup of the truncated state.
384
+ (0, vitest_1.expect)((0, _backup_1.listSnapshots)(backupDir).length).toBe(2);
385
+ });
386
+ (0, vitest_1.it)("restore_backup rejects a security snapshot whose preImage is missing apps/roles", async () => {
387
+ // Critical #2: a malformed snapshot must be refused before any write side effects.
388
+ const malformedPath = path_1.default.join(backupDir, "security", "malformed.json");
389
+ fs_1.default.mkdirSync(path_1.default.dirname(malformedPath), { recursive: true });
390
+ fs_1.default.writeFileSync(malformedPath, JSON.stringify({
391
+ version: 1,
392
+ action: "save_api_app",
393
+ category: "security",
394
+ resourceId: "game1",
395
+ existedBefore: true,
396
+ capturedAt: new Date().toISOString(),
397
+ serverUrl: "https://test.funifier.com",
398
+ preImage: { _id: "game1" }, // missing apps and roles
399
+ }));
400
+ const { invoke, api } = makeInvoke();
401
+ // listSnapshots counts the malformed file itself — record before so we can assert no NEW snapshot.
402
+ const snapshotsBefore = (0, _backup_1.listSnapshots)(backupDir).length;
403
+ const result = await invoke({ action: "restore_backup", backup_path: malformedPath });
404
+ (0, vitest_1.expect)(result.isError).toBe(true);
405
+ (0, vitest_1.expect)(result.content[0].text).toMatch(/malformed|missing.*apps|missing.*roles/i);
406
+ (0, vitest_1.expect)(api.updateDocument).not.toHaveBeenCalled();
407
+ // Fast-fail fires before backupBeforeMutation — no new snapshot written.
408
+ (0, vitest_1.expect)((0, _backup_1.listSnapshots)(backupDir).length).toBe(snapshotsBefore);
409
+ });
410
+ (0, vitest_1.it)("restore_backup errors when a studio-role snapshot lacks naturalKey", async () => {
411
+ const badPath = path_1.default.join(backupDir, "studio-role", "missing-key.json");
412
+ fs_1.default.mkdirSync(path_1.default.dirname(badPath), { recursive: true });
413
+ fs_1.default.writeFileSync(badPath, JSON.stringify({
414
+ version: 1,
415
+ action: "save_studio_role",
416
+ category: "studio-role",
417
+ resourceId: "r1",
418
+ existedBefore: false,
419
+ capturedAt: "2026-05-28T14:31:02.123Z",
420
+ serverUrl: null,
421
+ preImage: null,
422
+ }));
423
+ const { invoke, api } = makeInvoke();
424
+ const result = await invoke({ action: "restore_backup", backup_path: badPath });
425
+ (0, vitest_1.expect)(result.isError).toBe(true);
426
+ (0, vitest_1.expect)(result.content[0].text).toContain("Studio-role snapshot missing naturalKey");
427
+ (0, vitest_1.expect)(api.saveRole).not.toHaveBeenCalled();
428
+ (0, vitest_1.expect)(api.deleteRole).not.toHaveBeenCalled();
429
+ });
430
+ (0, vitest_1.it)("list_backups returns captured snapshots newest-first", async () => {
431
+ const writer = makeInvoke();
432
+ await writer.invoke({ action: "delete_security_role", name: "player" });
433
+ const { invoke } = makeInvoke();
434
+ const result = await invoke({ action: "list_backups" });
435
+ (0, vitest_1.expect)(result.isError).toBeUndefined();
436
+ (0, vitest_1.expect)(result.content[0].text).toContain("Backups (1)");
437
+ (0, vitest_1.expect)(result.content[0].text).toContain("security");
438
+ });
439
+ });
440
+ (0, vitest_1.describe)("funifier_permissions — T3: loadSecurityDocument normalization", () => {
441
+ (0, vitest_1.it)("succeeds when aggregateCollection returns a string _id", async () => {
442
+ const { invoke, api } = makeInvoke({
443
+ aggregateCollection: vitest_1.vi.fn().mockResolvedValue([{ ...SECURITY, _id: "game1" }]),
444
+ });
445
+ const result = await invoke({ action: "list_api_apps" });
446
+ (0, vitest_1.expect)(result.isError).toBeUndefined();
447
+ (0, vitest_1.expect)(api.aggregateCollection).toHaveBeenCalledWith("security", vitest_1.expect.any(Array));
448
+ });
449
+ (0, vitest_1.it)("falls back to queryCollection when aggregateCollection returns empty", async () => {
450
+ const { invoke, api } = makeInvoke({
451
+ aggregateCollection: vitest_1.vi.fn().mockResolvedValue([]),
452
+ });
453
+ const result = await invoke({ action: "list_api_apps" });
454
+ (0, vitest_1.expect)(result.isError).toBeUndefined();
455
+ (0, vitest_1.expect)(api.queryCollection).toHaveBeenCalled();
456
+ });
457
+ (0, vitest_1.it)("falls back to queryCollection when aggregateCollection throws", async () => {
458
+ const { invoke, api } = makeInvoke({
459
+ aggregateCollection: vitest_1.vi.fn().mockRejectedValue(new Error("aggregate not supported")),
460
+ });
461
+ const result = await invoke({ action: "list_api_apps" });
462
+ (0, vitest_1.expect)(result.isError).toBeUndefined();
463
+ (0, vitest_1.expect)(api.queryCollection).toHaveBeenCalled();
464
+ });
465
+ (0, vitest_1.it)("aborts with isError when _id cannot be resolved to a string after both read paths", async () => {
466
+ const { invoke, api } = makeInvoke({
467
+ aggregateCollection: vitest_1.vi.fn().mockResolvedValue([{ ...SECURITY, _id: { timestamp: 1 } }]),
468
+ queryCollection: vitest_1.vi.fn().mockResolvedValue([{ ...SECURITY, _id: { timestamp: 1 } }]),
469
+ });
470
+ const result = await invoke({ action: "list_api_apps" });
471
+ (0, vitest_1.expect)(result.isError).toBe(true);
472
+ (0, vitest_1.expect)(result.content[0].text).toContain("Could not resolve a string _id");
473
+ (0, vitest_1.expect)(api.updateDocument).not.toHaveBeenCalled();
474
+ });
475
+ });
476
+ (0, vitest_1.describe)("funifier_permissions — T4: backup integrity gate", () => {
477
+ (0, vitest_1.it)("aborts with integrity-check message when security preImage is missing apps/roles", async () => {
478
+ // Security doc without apps/roles array — integrity check will fail after snapshot write.
479
+ const { invoke, api } = makeInvoke({
480
+ aggregateCollection: vitest_1.vi.fn().mockResolvedValue([{ _id: "game1" }]), // no apps or roles
481
+ queryCollection: vitest_1.vi.fn().mockResolvedValue([{ _id: "game1" }]),
482
+ });
483
+ const result = await invoke({
484
+ action: "save_api_app",
485
+ data: JSON.stringify({ name: "X", app_secret: "s", scope: "read_all" }),
486
+ });
487
+ (0, vitest_1.expect)(result.isError).toBe(true);
488
+ (0, vitest_1.expect)(result.content[0].text).toContain("Backup integrity check failed (mutation aborted)");
489
+ (0, vitest_1.expect)(api.updateDocument).not.toHaveBeenCalled();
490
+ });
491
+ (0, vitest_1.it)("preserves 'Backup write failed' message for genuine IO failures", async () => {
492
+ const blocker = path_1.default.join(backupDir, "blocker2");
493
+ fs_1.default.writeFileSync(blocker, "not-a-directory");
494
+ process.env.FUNIFIER_BACKUP_ROOT = path_1.default.join(blocker, "nested");
495
+ const { invoke, api } = makeInvoke();
496
+ const result = await invoke({ action: "save_security_role", data: JSON.stringify({ name: "X", timeout: "1d", scope: "read_all" }) });
497
+ (0, vitest_1.expect)(result.isError).toBe(true);
498
+ (0, vitest_1.expect)(result.content[0].text).toContain("Backup write failed (mutation aborted)");
499
+ (0, vitest_1.expect)(api.updateDocument).not.toHaveBeenCalled();
500
+ });
501
+ });
502
+ (0, vitest_1.describe)("funifier_permissions — T5: post-write verification", () => {
503
+ (0, vitest_1.it)("returns isError naming the backup path when post-write re-read is missing an app", async () => {
504
+ // Sequence: 1st aggregate call → full SECURITY, 2nd call (verification re-read) → truncated (no apps).
505
+ let callCount = 0;
506
+ const { invoke } = makeInvoke({
507
+ aggregateCollection: vitest_1.vi.fn().mockImplementation(async (collection) => {
508
+ if (collection !== "security")
509
+ return [];
510
+ callCount++;
511
+ if (callCount === 1)
512
+ return [{ ...SECURITY, _id: SECURITY._id }]; // initial read
513
+ return [{ _id: SECURITY._id, roles: SECURITY.roles }]; // truncated: no apps
514
+ }),
515
+ });
516
+ const result = await invoke({
517
+ action: "save_api_app",
518
+ data: JSON.stringify({ name: "New", app_secret: "new-s", scope: "read_all" }),
519
+ });
520
+ (0, vitest_1.expect)(result.isError).toBe(true);
521
+ const text = result.content[0].text;
522
+ (0, vitest_1.expect)(text).toContain("Post-write verification FAILED");
523
+ (0, vitest_1.expect)(text).toContain("apps");
524
+ (0, vitest_1.expect)(text).toContain("restore with funifier_permissions restore_backup");
525
+ });
526
+ (0, vitest_1.it)("returns isError naming backup path when post-write re-read is missing a role", async () => {
527
+ let callCount = 0;
528
+ const { invoke } = makeInvoke({
529
+ aggregateCollection: vitest_1.vi.fn().mockImplementation(async (collection) => {
530
+ if (collection !== "security")
531
+ return [];
532
+ callCount++;
533
+ if (callCount === 1)
534
+ return [{ ...SECURITY, _id: SECURITY._id }];
535
+ return [{ _id: SECURITY._id, apps: SECURITY.apps }]; // no roles
536
+ }),
537
+ });
538
+ const result = await invoke({
539
+ action: "save_security_role",
540
+ data: JSON.stringify({ name: "newrole", timeout: "1d", scope: "read_all" }),
541
+ });
542
+ (0, vitest_1.expect)(result.isError).toBe(true);
543
+ (0, vitest_1.expect)(result.content[0].text).toContain("Post-write verification FAILED");
544
+ (0, vitest_1.expect)(result.content[0].text).toContain("roles");
545
+ });
546
+ (0, vitest_1.it)("detects missing app when name matches but app_secret differs (secret corruption)", async () => {
547
+ let callCount = 0;
548
+ const { invoke } = makeInvoke({
549
+ aggregateCollection: vitest_1.vi.fn().mockImplementation(async (collection) => {
550
+ if (collection !== "security")
551
+ return [];
552
+ callCount++;
553
+ if (callCount === 1)
554
+ return [{ ...SECURITY, _id: SECURITY._id }];
555
+ // Post-write re-read: same name, different secret — stable key mismatch.
556
+ return [{ _id: SECURITY._id, apps: [{ name: "Default", app_secret: "tampered-secret", scope: "read_all" }], roles: SECURITY.roles }];
557
+ }),
558
+ });
559
+ const result = await invoke({
560
+ action: "save_api_app",
561
+ data: JSON.stringify({ name: "Default", app_secret: "old-secret", scope: "read_all" }),
562
+ });
563
+ (0, vitest_1.expect)(result.isError).toBe(true);
564
+ (0, vitest_1.expect)(result.content[0].text).toContain("Post-write verification FAILED");
565
+ (0, vitest_1.expect)(result.content[0].text).toContain("apps");
566
+ });
567
+ (0, vitest_1.it)("detects role count shortfall when expected name appears via duplicate (cardinality)", async () => {
568
+ let callCount = 0;
569
+ const { invoke } = makeInvoke({
570
+ aggregateCollection: vitest_1.vi.fn().mockImplementation(async (collection) => {
571
+ if (collection !== "security")
572
+ return [];
573
+ callCount++;
574
+ // Initial doc has two distinct roles.
575
+ if (callCount === 1)
576
+ return [{ _id: SECURITY._id, apps: SECURITY.apps, roles: [{ name: "player", timeout: "1d", scope: "all" }, { name: "admin", timeout: "1d", scope: "all" }] }];
577
+ // Post-write: "admin" silently replaced by a second "player" — same names present but count drops.
578
+ return [{ _id: SECURITY._id, apps: SECURITY.apps, roles: [{ name: "player", timeout: "1d", scope: "all" }] }];
579
+ }),
580
+ });
581
+ const result = await invoke({
582
+ action: "save_security_role",
583
+ data: JSON.stringify({ name: "admin", timeout: "1d", scope: "all" }),
584
+ });
585
+ (0, vitest_1.expect)(result.isError).toBe(true);
586
+ (0, vitest_1.expect)(result.content[0].text).toContain("Post-write verification FAILED");
587
+ (0, vitest_1.expect)(result.content[0].text).toContain("roles");
588
+ });
589
+ (0, vitest_1.it)("does not false-alarm when expected apps is empty after delete", async () => {
590
+ const { invoke } = makeInvoke({
591
+ aggregateCollection: vitest_1.vi.fn().mockImplementation(async (collection) => {
592
+ if (collection !== "security")
593
+ return [];
594
+ // Both reads return a doc with no apps (the delete made it empty).
595
+ return [{ _id: SECURITY._id, apps: [], roles: SECURITY.roles }];
596
+ }),
597
+ });
598
+ const result = await invoke({ action: "delete_api_app", app_secret: "old-secret" });
599
+ (0, vitest_1.expect)(result.isError).toBeUndefined();
600
+ });
601
+ (0, vitest_1.it)("returns isError with 'could not verify' when post-write re-read throws", async () => {
602
+ let callCount = 0;
603
+ const { invoke } = makeInvoke({
604
+ aggregateCollection: vitest_1.vi.fn().mockImplementation(async (collection) => {
605
+ if (collection !== "security")
606
+ return [];
607
+ callCount++;
608
+ if (callCount === 1)
609
+ return [{ ...SECURITY, _id: SECURITY._id }];
610
+ throw new Error("network failure");
611
+ }),
612
+ queryCollection: vitest_1.vi.fn().mockImplementation(async (collection) => {
613
+ if (collection !== "security")
614
+ return [];
615
+ throw new Error("network failure");
616
+ }),
617
+ });
618
+ const result = await invoke({ action: "save_security_role", data: JSON.stringify({ name: "X", timeout: "1d", scope: "x" }) });
619
+ (0, vitest_1.expect)(result.isError).toBe(true);
620
+ (0, vitest_1.expect)(result.content[0].text).toContain("Could not verify the write persisted");
621
+ });
622
+ });
623
+ (0, vitest_1.describe)("funifier_permissions — T8: restore_backup post-write verification", () => {
624
+ (0, vitest_1.it)("returns isError when restored security doc is missing apps on re-read", async () => {
625
+ // Write a security snapshot to restore from.
626
+ const writer = makeInvoke();
627
+ await writer.invoke({ action: "save_security_role", data: JSON.stringify({ name: "player", timeout: "1d", scope: "read_all" }) });
628
+ const backupPath = (0, _backup_1.listSnapshots)(backupDir)[0].path;
629
+ let restoreCallCount = 0;
630
+ const { invoke } = makeInvoke({
631
+ aggregateCollection: vitest_1.vi.fn().mockImplementation(async (collection) => {
632
+ if (collection !== "security")
633
+ return [];
634
+ restoreCallCount++;
635
+ // Call 1: loadSecurityDocument in currentPreImage (pre-restore backup)
636
+ // Call 2: applySecuritySnapshot reads live doc for _id match
637
+ // Call 3: verifySecurityPersisted re-read → return truncated to trigger failure
638
+ if (restoreCallCount < 3)
639
+ return [{ ...SECURITY, _id: SECURITY._id }];
640
+ return [{ _id: SECURITY._id, roles: SECURITY.roles }]; // no apps
641
+ }),
642
+ });
643
+ const result = await invoke({ action: "restore_backup", backup_path: backupPath });
644
+ (0, vitest_1.expect)(result.isError).toBe(true);
645
+ (0, vitest_1.expect)(result.content[0].text).toContain("Post-write verification FAILED");
646
+ (0, vitest_1.expect)(result.content[0].text).toContain("apps");
647
+ });
648
+ });
99
649
  //# sourceMappingURL=permissions.test.js.map