@bevel-software/platform-core-backend 0.9.1 → 0.11.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/THIRD-PARTY-NOTICES.md +2 -2
- package/dist/core/create-core-server.d.ts.map +1 -1
- package/dist/core/create-core-server.js +16 -1
- package/dist/core/create-core-server.js.map +1 -1
- package/dist/core/create-core-services.d.ts +25 -0
- package/dist/core/create-core-services.d.ts.map +1 -1
- package/dist/core/create-core-services.js +45 -1
- package/dist/core/create-core-services.js.map +1 -1
- package/dist/core-config.d.ts +19 -1
- package/dist/core-config.d.ts.map +1 -1
- package/dist/core-config.js +44 -4
- package/dist/core-config.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/modules/access/access-control.interface.d.ts +54 -28
- package/dist/modules/access/access-control.interface.d.ts.map +1 -1
- package/dist/modules/access/access-control.service.d.ts +129 -14
- package/dist/modules/access/access-control.service.d.ts.map +1 -1
- package/dist/modules/access/access-control.service.js +435 -66
- package/dist/modules/access/access-control.service.js.map +1 -1
- package/dist/modules/access/access-declarations.d.ts.map +1 -1
- package/dist/modules/access/access-declarations.js +5 -3
- package/dist/modules/access/access-declarations.js.map +1 -1
- package/dist/modules/access/access-mutation.service.d.ts +39 -6
- package/dist/modules/access/access-mutation.service.d.ts.map +1 -1
- package/dist/modules/access/access-mutation.service.js +78 -18
- package/dist/modules/access/access-mutation.service.js.map +1 -1
- package/dist/modules/access/access-splice.d.ts +31 -4
- package/dist/modules/access/access-splice.d.ts.map +1 -1
- package/dist/modules/access/access-splice.js +40 -16
- package/dist/modules/access/access-splice.js.map +1 -1
- package/dist/modules/access/access.routes.d.ts.map +1 -1
- package/dist/modules/access/access.routes.js +204 -82
- package/dist/modules/access/access.routes.js.map +1 -1
- package/dist/modules/access/admin-locked-commit.d.ts +134 -0
- package/dist/modules/access/admin-locked-commit.d.ts.map +1 -0
- package/dist/modules/access/admin-locked-commit.js +277 -0
- package/dist/modules/access/admin-locked-commit.js.map +1 -0
- package/dist/modules/access/admin-route-helpers.d.ts +32 -0
- package/dist/modules/access/admin-route-helpers.d.ts.map +1 -0
- package/dist/modules/access/admin-route-helpers.js +44 -0
- package/dist/modules/access/admin-route-helpers.js.map +1 -0
- package/dist/modules/access/capability-registry.d.ts +41 -0
- package/dist/modules/access/capability-registry.d.ts.map +1 -0
- package/dist/modules/access/capability-registry.js +46 -0
- package/dist/modules/access/capability-registry.js.map +1 -0
- package/dist/modules/access/directory-sync-bot.d.ts +13 -0
- package/dist/modules/access/directory-sync-bot.d.ts.map +1 -0
- package/dist/modules/access/directory-sync-bot.js +64 -0
- package/dist/modules/access/directory-sync-bot.js.map +1 -0
- package/dist/modules/access/group-files.d.ts +83 -0
- package/dist/modules/access/group-files.d.ts.map +1 -0
- package/dist/modules/access/group-files.js +167 -0
- package/dist/modules/access/group-files.js.map +1 -0
- package/dist/modules/access/groups-admin.routes.d.ts +19 -0
- package/dist/modules/access/groups-admin.routes.d.ts.map +1 -0
- package/dist/modules/access/groups-admin.routes.js +98 -0
- package/dist/modules/access/groups-admin.routes.js.map +1 -0
- package/dist/modules/access/groups-admin.service.d.ts +166 -0
- package/dist/modules/access/groups-admin.service.d.ts.map +1 -0
- package/dist/modules/access/groups-admin.service.js +442 -0
- package/dist/modules/access/groups-admin.service.js.map +1 -0
- package/dist/modules/access/groups-edit.d.ts +58 -0
- package/dist/modules/access/groups-edit.d.ts.map +1 -0
- package/dist/modules/access/groups-edit.js +162 -0
- package/dist/modules/access/groups-edit.js.map +1 -0
- package/dist/modules/access/reference-scan.d.ts +141 -0
- package/dist/modules/access/reference-scan.d.ts.map +1 -0
- package/dist/modules/access/reference-scan.js +440 -0
- package/dist/modules/access/reference-scan.js.map +1 -0
- package/dist/modules/access/roles-admin.service.d.ts +88 -119
- package/dist/modules/access/roles-admin.service.d.ts.map +1 -1
- package/dist/modules/access/roles-admin.service.js +230 -384
- package/dist/modules/access/roles-admin.service.js.map +1 -1
- package/dist/modules/access/roles-edit.d.ts +51 -25
- package/dist/modules/access/roles-edit.d.ts.map +1 -1
- package/dist/modules/access/roles-edit.js +133 -59
- package/dist/modules/access/roles-edit.js.map +1 -1
- package/dist/modules/access/synced-groups-committer.d.ts +28 -0
- package/dist/modules/access/synced-groups-committer.d.ts.map +1 -0
- package/dist/modules/access/synced-groups-committer.js +139 -0
- package/dist/modules/access/synced-groups-committer.js.map +1 -0
- package/dist/modules/access/synced-groups-writer.d.ts +78 -0
- package/dist/modules/access/synced-groups-writer.d.ts.map +1 -0
- package/dist/modules/access/synced-groups-writer.js +219 -0
- package/dist/modules/access/synced-groups-writer.js.map +1 -0
- package/dist/modules/database/core-schema.d.ts +17 -0
- package/dist/modules/database/core-schema.d.ts.map +1 -1
- package/dist/modules/database/core-schema.js +9 -0
- package/dist/modules/database/core-schema.js.map +1 -1
- package/dist/modules/mcp/mcp-auth.middleware.d.ts +13 -2
- package/dist/modules/mcp/mcp-auth.middleware.d.ts.map +1 -1
- package/dist/modules/mcp/mcp-auth.middleware.js +61 -2
- package/dist/modules/mcp/mcp-auth.middleware.js.map +1 -1
- package/dist/modules/mcp/mcp.routes.d.ts +9 -3
- package/dist/modules/mcp/mcp.routes.d.ts.map +1 -1
- package/dist/modules/mcp/mcp.routes.js +126 -2
- package/dist/modules/mcp/mcp.routes.js.map +1 -1
- package/dist/modules/mcp/mcp.service.d.ts +14 -0
- package/dist/modules/mcp/mcp.service.d.ts.map +1 -1
- package/dist/modules/mcp/mcp.service.js +6 -1
- package/dist/modules/mcp/mcp.service.js.map +1 -1
- package/dist/modules/update-check/update-check.routes.d.ts +13 -0
- package/dist/modules/update-check/update-check.routes.d.ts.map +1 -0
- package/dist/modules/update-check/update-check.routes.js +21 -0
- package/dist/modules/update-check/update-check.routes.js.map +1 -0
- package/dist/modules/update-check/update-check.service.d.ts +57 -0
- package/dist/modules/update-check/update-check.service.d.ts.map +1 -0
- package/dist/modules/update-check/update-check.service.js +109 -0
- package/dist/modules/update-check/update-check.service.js.map +1 -0
- package/dist/modules/workflow/file-lock.service.d.ts +11 -1
- package/dist/modules/workflow/file-lock.service.d.ts.map +1 -1
- package/dist/modules/workflow/file-lock.service.js +15 -1
- package/dist/modules/workflow/file-lock.service.js.map +1 -1
- package/dist/modules/workflow/git/git.service.d.ts +34 -11
- package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
- package/dist/modules/workflow/git/git.service.js +173 -37
- package/dist/modules/workflow/git/git.service.js.map +1 -1
- package/dist/modules/workflow/locking-filesystem.d.ts +4 -0
- package/dist/modules/workflow/locking-filesystem.d.ts.map +1 -1
- package/dist/modules/workflow/locking-filesystem.js +181 -28
- package/dist/modules/workflow/locking-filesystem.js.map +1 -1
- package/dist/modules/workflow/pending-commits.service.d.ts +10 -0
- package/dist/modules/workflow/pending-commits.service.d.ts.map +1 -1
- package/dist/modules/workflow/pending-commits.service.js +19 -1
- package/dist/modules/workflow/pending-commits.service.js.map +1 -1
- package/dist/modules/workflow/workflow.service.d.ts +17 -2
- package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.service.js +113 -19
- package/dist/modules/workflow/workflow.service.js.map +1 -1
- package/dist/version.d.ts +14 -0
- package/dist/version.d.ts.map +1 -1
- package/dist/version.js +25 -0
- package/dist/version.js.map +1 -1
- package/kb-template/AGENTS.md +4 -1
- package/migrations/0004_file_lock_mode.sql +2 -0
- package/migrations/meta/0004_snapshot.json +1564 -0
- package/migrations/meta/_journal.json +7 -0
- package/package.json +4 -4
- package/src/__tests__/core-config.domain.test.ts +92 -0
- package/src/core/create-core-server.ts +21 -0
- package/src/core/create-core-services.ts +82 -0
- package/src/core-config.ts +48 -4
- package/src/index.ts +10 -0
- package/src/modules/access/__tests__/access-control.service.test.ts +9 -3
- package/src/modules/access/__tests__/access-groups.test.ts +427 -0
- package/src/modules/access/__tests__/access-mutation.service.test.ts +171 -5
- package/src/modules/access/__tests__/access-splice.test.ts +65 -0
- package/src/modules/access/__tests__/access.routes.group-grant.test.ts +337 -0
- package/src/modules/access/__tests__/access.routes.revoke.test.ts +48 -0
- package/src/modules/access/__tests__/admin-locked-commit.test.ts +221 -0
- package/src/modules/access/__tests__/admin-route-helpers.test.ts +61 -0
- package/src/modules/access/__tests__/directory-sync-bot.test.ts +106 -0
- package/src/modules/access/__tests__/grant-sources.test.ts +67 -0
- package/src/modules/access/__tests__/groups-admin.service.test.ts +440 -0
- package/src/modules/access/__tests__/reference-scan.test.ts +288 -0
- package/src/modules/access/__tests__/roles-admin.service.test.ts +161 -83
- package/src/modules/access/__tests__/roles-capabilities.test.ts +325 -0
- package/src/modules/access/__tests__/roles-edit.test.ts +104 -28
- package/src/modules/access/__tests__/roles.routes.test.ts +63 -40
- package/src/modules/access/__tests__/synced-groups-committer.test.ts +238 -0
- package/src/modules/access/__tests__/synced-groups-writer.test.ts +249 -0
- package/src/modules/access/access-control.interface.ts +66 -32
- package/src/modules/access/access-control.service.ts +536 -73
- package/src/modules/access/access-declarations.ts +5 -2
- package/src/modules/access/access-mutation.service.ts +88 -14
- package/src/modules/access/access-splice.ts +55 -17
- package/src/modules/access/access.routes.ts +227 -93
- package/src/modules/access/admin-locked-commit.ts +331 -0
- package/src/modules/access/admin-route-helpers.ts +55 -0
- package/src/modules/access/capability-registry.ts +74 -0
- package/src/modules/access/directory-sync-bot.ts +76 -0
- package/src/modules/access/group-files.ts +212 -0
- package/src/modules/access/groups-admin.routes.ts +113 -0
- package/src/modules/access/groups-admin.service.ts +551 -0
- package/src/modules/access/groups-edit.ts +187 -0
- package/src/modules/access/reference-scan.ts +513 -0
- package/src/modules/access/roles-admin.service.ts +290 -418
- package/src/modules/access/roles-edit.ts +134 -61
- package/src/modules/access/synced-groups-committer.ts +177 -0
- package/src/modules/access/synced-groups-writer.ts +303 -0
- package/src/modules/database/core-schema.ts +9 -0
- package/src/modules/mcp/__tests__/mcp-auth.middleware.test.ts +116 -0
- package/src/modules/mcp/__tests__/mcp.e2e.test.ts +3 -1
- package/src/modules/mcp/__tests__/mcp.routes.delete.test.ts +6 -1
- package/src/modules/mcp/__tests__/mcp.routes.local-token.test.ts +301 -0
- package/src/modules/mcp/mcp-auth.middleware.ts +61 -1
- package/src/modules/mcp/mcp.routes.ts +137 -2
- package/src/modules/mcp/mcp.service.ts +6 -1
- package/src/modules/update-check/__tests__/update-check.routes.test.ts +69 -0
- package/src/modules/update-check/__tests__/update-check.service.test.ts +170 -0
- package/src/modules/update-check/update-check.routes.ts +28 -0
- package/src/modules/update-check/update-check.service.ts +130 -0
- package/src/modules/workflow/__tests__/locking-filesystem.test.ts +336 -0
- package/src/modules/workflow/__tests__/preserve-roles-yaml.test.ts +1 -1
- package/src/modules/workflow/__tests__/workflow.service.commitFileWhileLocked.test.ts +32 -0
- package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +13 -2
- package/src/modules/workflow/__tests__/workflow.service.releaseLock.test.ts +139 -1
- package/src/modules/workflow/file-lock.service.ts +15 -0
- package/src/modules/workflow/git/__tests__/git.service.accessGating.test.ts +0 -1
- package/src/modules/workflow/git/__tests__/git.service.commitChanges.test.ts +132 -0
- package/src/modules/workflow/git/__tests__/git.service.deleteBranch.test.ts +0 -1
- package/src/modules/workflow/git/__tests__/pull-request.service.test.ts +0 -1
- package/src/modules/workflow/git/git.service.ts +182 -35
- package/src/modules/workflow/locking-filesystem.ts +188 -26
- package/src/modules/workflow/pending-commits.service.ts +27 -1
- package/src/modules/workflow/review-workflow/__tests__/approval-states.test.ts +0 -1
- package/src/modules/workflow/review-workflow/__tests__/cancel-pr.test.ts +0 -1
- package/src/modules/workflow/workflow.service.ts +140 -20
- package/src/version.ts +28 -0
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { AdminLockedCommits } from '../admin-locked-commit.js';
|
|
4
|
+
import { PushNeedsAgentResolutionError, WorkflowDomainError } from '../../workflow/workflow.errors.js';
|
|
5
|
+
import type { AuthUser, IWorkspaceService, IWorkflowService } from '@bevel-software/platform-shared';
|
|
6
|
+
|
|
7
|
+
const KB = 'knowledge-base';
|
|
8
|
+
const BRANCH = 'main';
|
|
9
|
+
const WS = 'ws-locked';
|
|
10
|
+
const ACTOR: AuthUser = { id: 'u-1', email: 'admin@x.io', name: 'Admin' } as AuthUser;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Lock-release semantics of `withFileLocks` (cubic P2: a releaseLock whose
|
|
14
|
+
* commit ENQUEUE fails after a successful batch commit stranded every held
|
|
15
|
+
* lock until TTL). Success path must release via releaseLockNoCommit (no
|
|
16
|
+
* enqueue involved); the push-retry path must keep releaseLock (the enqueued
|
|
17
|
+
* commit IS the retry); and a single failed release must not strand the rest.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
function makeHarness(opts: {
|
|
21
|
+
commitError?: Error;
|
|
22
|
+
releaseLockError?: Error;
|
|
23
|
+
}) {
|
|
24
|
+
const files = new Map<string, string>();
|
|
25
|
+
const locks = new Map<string, AuthUser>();
|
|
26
|
+
const releaseLockCalls: string[] = [];
|
|
27
|
+
const releaseNoCommitCalls: string[] = [];
|
|
28
|
+
|
|
29
|
+
const workspaceService = {
|
|
30
|
+
getWorkspacePath: async () => '/tmp/nowhere',
|
|
31
|
+
readFile: async (_id: string, wsRel: string) => {
|
|
32
|
+
const v = files.get(wsRel);
|
|
33
|
+
if (v === undefined) {
|
|
34
|
+
const err = new Error(`ENOENT ${wsRel}`) as NodeJS.ErrnoException;
|
|
35
|
+
err.code = 'ENOENT';
|
|
36
|
+
throw err;
|
|
37
|
+
}
|
|
38
|
+
return v;
|
|
39
|
+
},
|
|
40
|
+
writeFile: async (_id: string, wsRel: string, content: string) => void files.set(wsRel, content),
|
|
41
|
+
deleteFile: async (_id: string, wsRel: string) => void files.delete(wsRel),
|
|
42
|
+
} as unknown as IWorkspaceService;
|
|
43
|
+
|
|
44
|
+
const workflowService = {
|
|
45
|
+
acquireLock: vi.fn(async (_w: string, _b: string, p: string, user: AuthUser) => {
|
|
46
|
+
if (locks.has(p)) return { acquired: false, lock: { holderUserId: 'x', holderName: 'X' } };
|
|
47
|
+
locks.set(p, user);
|
|
48
|
+
return { acquired: true, lock: { holderUserId: user.id, holderName: user.name } };
|
|
49
|
+
}),
|
|
50
|
+
releaseLock: vi.fn(async (_w: string, _b: string, p: string) => {
|
|
51
|
+
releaseLockCalls.push(p);
|
|
52
|
+
if (opts.releaseLockError) throw opts.releaseLockError; // enqueue failed → row NOT dropped
|
|
53
|
+
locks.delete(p);
|
|
54
|
+
}),
|
|
55
|
+
releaseLockNoCommit: vi.fn(async (_w: string, _b: string, p: string) => {
|
|
56
|
+
releaseNoCommitCalls.push(p);
|
|
57
|
+
locks.delete(p);
|
|
58
|
+
}),
|
|
59
|
+
commitChanges: vi.fn(async () => {
|
|
60
|
+
if (opts.commitError) throw opts.commitError;
|
|
61
|
+
return {};
|
|
62
|
+
}),
|
|
63
|
+
} as unknown as IWorkflowService;
|
|
64
|
+
|
|
65
|
+
const locked = new AdminLockedCommits({
|
|
66
|
+
workspaceService,
|
|
67
|
+
workflowService,
|
|
68
|
+
kbDirName: KB,
|
|
69
|
+
defaultBranchOf: () => BRANCH,
|
|
70
|
+
makeError: (message, status, payload) => new WorkflowDomainError(message, status, payload),
|
|
71
|
+
logTag: 'test-admin',
|
|
72
|
+
contendedSubject: 'Things',
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
return { locked, locks, files, releaseLockCalls, releaseNoCommitCalls };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
describe('AdminLockedCommits.withFileLocks — release semantics', () => {
|
|
79
|
+
it('SUCCESS: batch-committed paths release via releaseLockNoCommit — a broken enqueue cannot strand them', async () => {
|
|
80
|
+
// releaseLock models "the pending-commit ENQUEUE fails": it throws and the
|
|
81
|
+
// lock row stays held. If the success path still used it, both locks would
|
|
82
|
+
// strand until TTL. With the fix they must all be free afterwards.
|
|
83
|
+
const h = makeHarness({ releaseLockError: new Error('enqueue exploded: sqlite disk I/O error') });
|
|
84
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
|
85
|
+
try {
|
|
86
|
+
await h.locked.withFileLocks(WS, ACTOR, ['groups.yaml', 'roles.yaml'], async () => {
|
|
87
|
+
await h.locked.writeAndCommitLocked(
|
|
88
|
+
WS,
|
|
89
|
+
ACTOR,
|
|
90
|
+
[
|
|
91
|
+
{ repoRel: 'groups.yaml', content: 'groups: {}\n', original: null },
|
|
92
|
+
{ repoRel: 'roles.yaml', content: 'roles: {}\n', original: 'roles:\n' },
|
|
93
|
+
],
|
|
94
|
+
'test commit',
|
|
95
|
+
);
|
|
96
|
+
});
|
|
97
|
+
expect(h.locks.size).toBe(0); // ZERO held locks
|
|
98
|
+
expect(h.releaseNoCommitCalls.sort()).toEqual([`${KB}/groups.yaml`, `${KB}/roles.yaml`]);
|
|
99
|
+
expect(h.releaseLockCalls).toEqual([]); // the queue was never touched
|
|
100
|
+
} finally {
|
|
101
|
+
warn.mockRestore();
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('POST-COMMIT FAILURE: fn throwing after the batch landed still releases committed paths without the queue', async () => {
|
|
106
|
+
// The commit+push succeeded; a later step inside fn (roster read, event
|
|
107
|
+
// emission) blew up. The failure says nothing about the tree — the
|
|
108
|
+
// committed paths are clean, and enqueueing a release commit for them
|
|
109
|
+
// risks stranding a known-clean lock on a broken queue.
|
|
110
|
+
const h = makeHarness({ releaseLockError: new Error('enqueue exploded') });
|
|
111
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
|
112
|
+
try {
|
|
113
|
+
await expect(
|
|
114
|
+
h.locked.withFileLocks(WS, ACTOR, ['groups.yaml', 'roles.yaml'], async () => {
|
|
115
|
+
await h.locked.writeAndCommitLocked(
|
|
116
|
+
WS,
|
|
117
|
+
ACTOR,
|
|
118
|
+
[
|
|
119
|
+
{ repoRel: 'groups.yaml', content: 'groups: {}\n', original: null },
|
|
120
|
+
{ repoRel: 'roles.yaml', content: 'roles: {}\n', original: 'roles:\n' },
|
|
121
|
+
],
|
|
122
|
+
'test commit',
|
|
123
|
+
);
|
|
124
|
+
throw new Error('post-commit boom');
|
|
125
|
+
}),
|
|
126
|
+
).rejects.toThrow(/post-commit boom/);
|
|
127
|
+
expect(h.locks.size).toBe(0); // ZERO held locks
|
|
128
|
+
expect(h.releaseNoCommitCalls.sort()).toEqual([`${KB}/groups.yaml`, `${KB}/roles.yaml`]);
|
|
129
|
+
expect(h.releaseLockCalls).toEqual([]); // the queue was never touched
|
|
130
|
+
} finally {
|
|
131
|
+
warn.mockRestore();
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('SUCCESS: a held path the batch never committed keeps commit-on-release (releaseLock)', async () => {
|
|
136
|
+
// A prior holder's queued work could still be dirty on such a path — a
|
|
137
|
+
// discard would destroy it, so the untouched lock releases the old way.
|
|
138
|
+
const h = makeHarness({});
|
|
139
|
+
await h.locked.withFileLocks(WS, ACTOR, ['groups.yaml', 'roles.yaml'], async () => {
|
|
140
|
+
await h.locked.writeAndCommitLocked(
|
|
141
|
+
WS,
|
|
142
|
+
ACTOR,
|
|
143
|
+
[{ repoRel: 'roles.yaml', content: 'roles: {}\n', original: 'roles:\n' }],
|
|
144
|
+
'test commit',
|
|
145
|
+
);
|
|
146
|
+
});
|
|
147
|
+
expect(h.locks.size).toBe(0);
|
|
148
|
+
expect(h.releaseNoCommitCalls).toEqual([`${KB}/roles.yaml`]);
|
|
149
|
+
expect(h.releaseLockCalls).toEqual([`${KB}/groups.yaml`]);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('PUSH-RETRY: PushNeedsAgentResolutionError keeps releaseLock (the enqueued commit retries the push)', async () => {
|
|
153
|
+
const h = makeHarness({
|
|
154
|
+
commitError: new PushNeedsAgentResolutionError(BRANCH, `${KB}/roles.yaml`, 'push refused', 'no retry'),
|
|
155
|
+
});
|
|
156
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
|
157
|
+
try {
|
|
158
|
+
await expect(
|
|
159
|
+
h.locked.withFileLocks(WS, ACTOR, ['roles.yaml'], async () => {
|
|
160
|
+
await h.locked.writeAndCommitLocked(
|
|
161
|
+
WS,
|
|
162
|
+
ACTOR,
|
|
163
|
+
[{ repoRel: 'roles.yaml', content: 'roles: {}\n', original: 'roles:\n' }],
|
|
164
|
+
'test commit',
|
|
165
|
+
);
|
|
166
|
+
}),
|
|
167
|
+
).rejects.toBeInstanceOf(PushNeedsAgentResolutionError);
|
|
168
|
+
expect(h.releaseLockCalls).toEqual([`${KB}/roles.yaml`]); // releaseLock, NOT noCommit
|
|
169
|
+
expect(h.releaseNoCommitCalls).toEqual([]);
|
|
170
|
+
expect(h.locks.size).toBe(0);
|
|
171
|
+
} finally {
|
|
172
|
+
warn.mockRestore();
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('PUSH-RETRY: one failed release logs and continues — the REST of the locks are still released', async () => {
|
|
177
|
+
const h = makeHarness({
|
|
178
|
+
commitError: new PushNeedsAgentResolutionError(BRANCH, `${KB}/roles.yaml`, 'push refused', 'no retry'),
|
|
179
|
+
releaseLockError: new Error('enqueue exploded'),
|
|
180
|
+
});
|
|
181
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
|
182
|
+
try {
|
|
183
|
+
await expect(
|
|
184
|
+
h.locked.withFileLocks(WS, ACTOR, ['a.yaml', 'b.yaml'], async () => {
|
|
185
|
+
await h.locked.writeAndCommitLocked(
|
|
186
|
+
WS,
|
|
187
|
+
ACTOR,
|
|
188
|
+
[
|
|
189
|
+
{ repoRel: 'a.yaml', content: 'a\n', original: null },
|
|
190
|
+
{ repoRel: 'b.yaml', content: 'b\n', original: null },
|
|
191
|
+
],
|
|
192
|
+
'test commit',
|
|
193
|
+
);
|
|
194
|
+
}),
|
|
195
|
+
).rejects.toBeInstanceOf(PushNeedsAgentResolutionError);
|
|
196
|
+
// BOTH releases were attempted despite the first one throwing.
|
|
197
|
+
expect(h.releaseLockCalls.sort()).toEqual([`${KB}/a.yaml`, `${KB}/b.yaml`]);
|
|
198
|
+
expect(warn).toHaveBeenCalled();
|
|
199
|
+
} finally {
|
|
200
|
+
warn.mockRestore();
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it('FAILURE with restore: commit-on-release is kept (restored bytes are clean; queued work preserved)', async () => {
|
|
205
|
+
const h = makeHarness({ commitError: new Error('commit exploded') });
|
|
206
|
+
await expect(
|
|
207
|
+
h.locked.withFileLocks(WS, ACTOR, ['roles.yaml'], async () => {
|
|
208
|
+
await h.locked.writeAndCommitLocked(
|
|
209
|
+
WS,
|
|
210
|
+
ACTOR,
|
|
211
|
+
[{ repoRel: 'roles.yaml', content: 'roles: {}\n', original: 'roles:\n' }],
|
|
212
|
+
'test commit',
|
|
213
|
+
);
|
|
214
|
+
}),
|
|
215
|
+
).rejects.toThrow('commit exploded');
|
|
216
|
+
expect(h.releaseLockCalls).toEqual([`${KB}/roles.yaml`]);
|
|
217
|
+
expect(h.releaseNoCommitCalls).toEqual([]);
|
|
218
|
+
expect(h.locks.size).toBe(0);
|
|
219
|
+
expect(h.files.get(`${KB}/roles.yaml`)).toBe('roles:\n'); // restored
|
|
220
|
+
});
|
|
221
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { describe, it, expect, vi, afterEach } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { toHttpError, requireNonEmptyString } from '../admin-route-helpers.js';
|
|
4
|
+
import { WorkflowDomainError } from '../../workflow/workflow.errors.js';
|
|
5
|
+
import { AccessMutationError } from '../access-mutation.service.js';
|
|
6
|
+
|
|
7
|
+
describe('toHttpError — the shared access-family error shape', () => {
|
|
8
|
+
afterEach(() => vi.restoreAllMocks());
|
|
9
|
+
|
|
10
|
+
it('a sub-500 domain error keeps its status, message, and payload', () => {
|
|
11
|
+
const err = new WorkflowDomainError('groups are being edited', 409, { kind: 'idp-mode' });
|
|
12
|
+
expect(toHttpError(err, 'test')).toEqual({
|
|
13
|
+
status: 409,
|
|
14
|
+
body: { error: 'groups are being edited', kind: 'idp-mode' },
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('a 500-status DOMAIN error never leaks its message — generic body, full server-side log', () => {
|
|
19
|
+
const spy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
20
|
+
// AccessMutationService wraps unexpected raw errors (fs/db failures) in a
|
|
21
|
+
// 500 AccessMutationError with the RAW message — that must not reach the
|
|
22
|
+
// client.
|
|
23
|
+
const err = new AccessMutationError("ENOENT: open '/srv/kb/secret-folder/access.md'", 500);
|
|
24
|
+
const { status, body } = toHttpError(err, 'test');
|
|
25
|
+
expect(status).toBe(500);
|
|
26
|
+
expect(body).toEqual({ error: 'Internal error.' });
|
|
27
|
+
expect(JSON.stringify(body)).not.toContain('secret-folder');
|
|
28
|
+
expect(spy).toHaveBeenCalled(); // the details land server-side
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('a raw (non-domain) error is a generic 500', () => {
|
|
32
|
+
vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
33
|
+
expect(toHttpError(new Error('db connection string postgres://u:p@host'), 'test')).toEqual({
|
|
34
|
+
status: 500,
|
|
35
|
+
body: { error: 'Internal error.' },
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("a payload key named 'error' cannot overwrite the client-facing message", () => {
|
|
40
|
+
const err = new WorkflowDomainError('the real message', 422, { error: 'spoofed', kind: 'x' });
|
|
41
|
+
const { body } = toHttpError(err, 'test');
|
|
42
|
+
expect(body.error).toBe('the real message');
|
|
43
|
+
expect(body.kind).toBe('x');
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe('requireNonEmptyString', () => {
|
|
48
|
+
it('accepts a plain string and rejects non-strings with a 400 domain error', () => {
|
|
49
|
+
expect(requireNonEmptyString('ok', 'field')).toBe('ok');
|
|
50
|
+
for (const bad of [42, null, undefined, ['a'], { a: 1 }, ' ']) {
|
|
51
|
+
let caught: unknown;
|
|
52
|
+
try {
|
|
53
|
+
requireNonEmptyString(bad, 'field');
|
|
54
|
+
} catch (err) {
|
|
55
|
+
caught = err;
|
|
56
|
+
}
|
|
57
|
+
expect(caught).toBeInstanceOf(WorkflowDomainError);
|
|
58
|
+
expect((caught as WorkflowDomainError).status).toBe(400);
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
});
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import type { Database } from '../../database/connection.js';
|
|
4
|
+
import {
|
|
5
|
+
DIRECTORY_SYNC_BOT_EMAIL,
|
|
6
|
+
DIRECTORY_SYNC_BOT_NAME,
|
|
7
|
+
ensureDirectorySyncBot,
|
|
8
|
+
} from '../directory-sync-bot.js';
|
|
9
|
+
|
|
10
|
+
interface Row {
|
|
11
|
+
id: string;
|
|
12
|
+
email: string;
|
|
13
|
+
name: string;
|
|
14
|
+
avatarUrl: string | null;
|
|
15
|
+
passwordHash: string | null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Minimal fake of the two drizzle chains `ensureDirectorySyncBot` runs. */
|
|
19
|
+
function fakeDb(opts: { existing: Row | null }): Database {
|
|
20
|
+
const created: Row = {
|
|
21
|
+
id: 'bot-row',
|
|
22
|
+
email: DIRECTORY_SYNC_BOT_EMAIL,
|
|
23
|
+
name: DIRECTORY_SYNC_BOT_NAME,
|
|
24
|
+
avatarUrl: null,
|
|
25
|
+
passwordHash: null,
|
|
26
|
+
};
|
|
27
|
+
return {
|
|
28
|
+
insert: () => ({
|
|
29
|
+
values: () => ({
|
|
30
|
+
onConflictDoNothing: () => ({
|
|
31
|
+
// Conflict (row exists) → empty; otherwise the fresh bot row.
|
|
32
|
+
returning: async () => (opts.existing ? [] : [created]),
|
|
33
|
+
}),
|
|
34
|
+
}),
|
|
35
|
+
}),
|
|
36
|
+
select: () => ({
|
|
37
|
+
from: () => ({
|
|
38
|
+
where: () => ({
|
|
39
|
+
limit: async () => (opts.existing ? [opts.existing] : []),
|
|
40
|
+
}),
|
|
41
|
+
}),
|
|
42
|
+
}),
|
|
43
|
+
} as unknown as Database;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
describe('ensureDirectorySyncBot — the bot identity is exclusive', () => {
|
|
47
|
+
it('creates the bot row when the email is free', async () => {
|
|
48
|
+
const bot = await ensureDirectorySyncBot(fakeDb({ existing: null }));
|
|
49
|
+
expect(bot).toMatchObject({ email: DIRECTORY_SYNC_BOT_EMAIL, name: DIRECTORY_SYNC_BOT_NAME });
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('reuses an existing row only when it IS the bot', async () => {
|
|
53
|
+
const bot = await ensureDirectorySyncBot(
|
|
54
|
+
fakeDb({
|
|
55
|
+
existing: {
|
|
56
|
+
id: 'prev-bot',
|
|
57
|
+
email: DIRECTORY_SYNC_BOT_EMAIL,
|
|
58
|
+
name: DIRECTORY_SYNC_BOT_NAME,
|
|
59
|
+
avatarUrl: null,
|
|
60
|
+
passwordHash: null,
|
|
61
|
+
},
|
|
62
|
+
}),
|
|
63
|
+
);
|
|
64
|
+
expect(bot.id).toBe('prev-bot');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('REFUSES to bind the materializer to a pre-existing human account under the bot email', async () => {
|
|
68
|
+
// The machine-owned write rule on synced-groups.yaml authorizes by this
|
|
69
|
+
// email alone — silently adopting a human's row would attribute every
|
|
70
|
+
// sync commit to them and hand their session the bot's write authority.
|
|
71
|
+
await expect(
|
|
72
|
+
ensureDirectorySyncBot(
|
|
73
|
+
fakeDb({
|
|
74
|
+
existing: {
|
|
75
|
+
id: 'human-1',
|
|
76
|
+
email: DIRECTORY_SYNC_BOT_EMAIL,
|
|
77
|
+
name: 'Mallory Human',
|
|
78
|
+
avatarUrl: null,
|
|
79
|
+
passwordHash: null,
|
|
80
|
+
},
|
|
81
|
+
}),
|
|
82
|
+
),
|
|
83
|
+
).rejects.toThrow(/already belongs to an existing account/);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('REFUSES a password-capable row even when it mimics the bot display name', async () => {
|
|
87
|
+
// A display name is not an identity marker — anyone can carry the bot's
|
|
88
|
+
// name. A password hash IS hard evidence: the bot never signs in and is
|
|
89
|
+
// never provisioned credentials, so a login-capable row under the bot
|
|
90
|
+
// email is a person's account (or a credential a person could use) and
|
|
91
|
+
// must never be adopted as the materializer's identity.
|
|
92
|
+
await expect(
|
|
93
|
+
ensureDirectorySyncBot(
|
|
94
|
+
fakeDb({
|
|
95
|
+
existing: {
|
|
96
|
+
id: 'human-2',
|
|
97
|
+
email: DIRECTORY_SYNC_BOT_EMAIL,
|
|
98
|
+
name: DIRECTORY_SYNC_BOT_NAME,
|
|
99
|
+
avatarUrl: null,
|
|
100
|
+
passwordHash: 'scrypt:deadbeef',
|
|
101
|
+
},
|
|
102
|
+
}),
|
|
103
|
+
),
|
|
104
|
+
).rejects.toThrow(/can sign in with a password/);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
@@ -202,3 +202,70 @@ describe('AccessControlService.grantSources', () => {
|
|
|
202
202
|
expect(src.download).toEqual([{ kind: 'direct' }]);
|
|
203
203
|
});
|
|
204
204
|
});
|
|
205
|
+
|
|
206
|
+
describe('grantSources — principal kind under group shadowing', () => {
|
|
207
|
+
let root: string;
|
|
208
|
+
let repo: string;
|
|
209
|
+
let svc: AccessControlService;
|
|
210
|
+
const workspaceId = 'ws-gs-shadow';
|
|
211
|
+
|
|
212
|
+
beforeEach(async () => {
|
|
213
|
+
root = await mkTmpRoot();
|
|
214
|
+
const workspaceDir = path.join(root, workspaceId);
|
|
215
|
+
repo = path.join(workspaceDir, PROCESS_MAP_DIR);
|
|
216
|
+
await fs.mkdir(repo, { recursive: true });
|
|
217
|
+
await writeFile(repo, 'roles.yaml', ROLES_YAML);
|
|
218
|
+
// A GROUP named Engineer shadows the Engineer ROLE: the bare token is the
|
|
219
|
+
// group's, `role/engineer` is the role's.
|
|
220
|
+
await writeFile(repo, 'groups.yaml', 'groups:\n Engineer:\n - pat@x.io\n');
|
|
221
|
+
svc = new AccessControlService(stubWorkspaceService(workspaceId, workspaceDir), PROCESS_MAP_DIR);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
afterEach(async () => {
|
|
225
|
+
await fs.rm(root, { recursive: true, force: true });
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("a shadowed BARE grant belongs to the GROUP — it must not hide the role's ancestor grant", async () => {
|
|
229
|
+
// The cubic case: bare (group) grant on the target, role/ grant on the
|
|
230
|
+
// ancestor. Attributing the bare token to the role would report the role
|
|
231
|
+
// as `direct` here — hiding the real role/<Name> ancestor grant and
|
|
232
|
+
// making its revoke a false no-op.
|
|
233
|
+
await writeFile(repo, 'access.md', '---\nwrite:\n - role/Engineer\n---\n');
|
|
234
|
+
await writeFile(repo, 'Knowledge/access.md', '---\nwrite:\n - Engineer\n---\n');
|
|
235
|
+
|
|
236
|
+
const roleSrc = await svc.grantSources(workspaceId, 'folder', 'Knowledge', {
|
|
237
|
+
kind: 'role',
|
|
238
|
+
role: 'role/Engineer',
|
|
239
|
+
});
|
|
240
|
+
// The ROLE's only entry is the ancestor role/ grant.
|
|
241
|
+
expect(roleSrc.write).toEqual([{ kind: 'ancestor', path: 'access.md' }]);
|
|
242
|
+
|
|
243
|
+
const groupSrc = await svc.grantSources(workspaceId, 'folder', 'Knowledge', {
|
|
244
|
+
kind: 'role',
|
|
245
|
+
role: 'Engineer', // bare = whoever owns the bare key = the group
|
|
246
|
+
});
|
|
247
|
+
// The GROUP's only entry is the direct bare grant.
|
|
248
|
+
expect(groupSrc.write).toEqual([{ kind: 'direct' }]);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it("a shadowed role/ grant is never attributed to the GROUP principal", async () => {
|
|
252
|
+
await writeFile(repo, 'access.md', '---\nwrite:\n - Admin\n---\n');
|
|
253
|
+
await writeFile(repo, 'Knowledge/access.md', '---\nwrite:\n - role/Engineer\n---\n');
|
|
254
|
+
const groupSrc = await svc.grantSources(workspaceId, 'folder', 'Knowledge', {
|
|
255
|
+
kind: 'role',
|
|
256
|
+
role: 'Engineer',
|
|
257
|
+
});
|
|
258
|
+
expect(groupSrc).toEqual({}); // the group holds nothing here
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it('UNSHADOWED both spellings still count as the role (legacy bare grants stay removable)', async () => {
|
|
262
|
+
await fs.rm(path.join(repo, 'groups.yaml'));
|
|
263
|
+
await writeFile(repo, 'access.md', '---\nwrite:\n - role/Engineer\n---\n');
|
|
264
|
+
await writeFile(repo, 'Knowledge/access.md', '---\nwrite:\n - Engineer\n---\n');
|
|
265
|
+
const src = await svc.grantSources(workspaceId, 'folder', 'Knowledge', {
|
|
266
|
+
kind: 'role',
|
|
267
|
+
role: 'Engineer',
|
|
268
|
+
});
|
|
269
|
+
expect(src.write).toEqual([{ kind: 'direct' }, { kind: 'ancestor', path: 'access.md' }]);
|
|
270
|
+
});
|
|
271
|
+
});
|