@bevel-software/platform-core-backend 0.10.0 → 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/dist/core/create-core-server.d.ts.map +1 -1
- package/dist/core/create-core-server.js +12 -1
- package/dist/core/create-core-server.js.map +1 -1
- package/dist/core/create-core-services.d.ts +22 -0
- package/dist/core/create-core-services.d.ts.map +1 -1
- package/dist/core/create-core-services.js +34 -1
- package/dist/core/create-core-services.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/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 +165 -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/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 +3 -3
- package/src/core/create-core-server.ts +13 -0
- package/src/core/create-core-services.ts +68 -0
- 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/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 +174 -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
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
|
|
6
|
+
import type { WorkspaceService } from '../../workspace/workspace.service.js';
|
|
7
|
+
import type { WorkflowService } from '../../workflow/workflow.service.js';
|
|
8
|
+
import type { AuthUser, FileTreeEntry } from '@bevel-software/platform-shared';
|
|
9
|
+
import { DEFAULT_BRANCH } from '@bevel-software/platform-shared';
|
|
10
|
+
import { AccessControlService } from '../access-control.service.js';
|
|
11
|
+
import { GroupsAdminService, GroupsAdminError } from '../groups-admin.service.js';
|
|
12
|
+
import { createGroup, addGroupMember, GroupsEditError } from '../groups-edit.js';
|
|
13
|
+
|
|
14
|
+
const KB = 'knowledge-base';
|
|
15
|
+
const ADMIN: AuthUser = { id: 'u-admin', email: 'admin@x.io', name: 'Admin' } as AuthUser;
|
|
16
|
+
|
|
17
|
+
const ROLES = `roles:\n Admin:\n - admin@x.io\n Reviewer:\n - rev@x.io\n`;
|
|
18
|
+
const GROUPS = `groups:\n Product:\n - felix@x.io\n GTM Team:\n - sara@x.io\n`;
|
|
19
|
+
|
|
20
|
+
async function write(repo: string, rel: string, contents: string): Promise<void> {
|
|
21
|
+
const abs = path.join(repo, rel);
|
|
22
|
+
await fs.mkdir(path.dirname(abs), { recursive: true });
|
|
23
|
+
await fs.writeFile(abs, contents);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function stubWorkspace(workspaceDir: string): WorkspaceService {
|
|
27
|
+
const resolve = (wsRel: string) => path.join(workspaceDir, wsRel);
|
|
28
|
+
const buildTree = async (absDir: string): Promise<FileTreeEntry> => {
|
|
29
|
+
const rel = path.relative(workspaceDir, absDir).replace(/\\/g, '/');
|
|
30
|
+
const children: FileTreeEntry[] = [];
|
|
31
|
+
for (const e of await fs.readdir(absDir, { withFileTypes: true })) {
|
|
32
|
+
if (e.name === '.git') continue;
|
|
33
|
+
const childAbs = path.join(absDir, e.name);
|
|
34
|
+
if (e.isDirectory()) children.push(await buildTree(childAbs));
|
|
35
|
+
else if (e.isFile()) {
|
|
36
|
+
children.push({
|
|
37
|
+
name: e.name,
|
|
38
|
+
relativePath: path.relative(workspaceDir, childAbs).replace(/\\/g, '/'),
|
|
39
|
+
type: 'file',
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return { name: path.basename(absDir), relativePath: rel || '.', type: 'directory', children };
|
|
44
|
+
};
|
|
45
|
+
return {
|
|
46
|
+
getWorkspacePath: async () => workspaceDir,
|
|
47
|
+
getOrCreateForBranch: async () => ({}) as unknown,
|
|
48
|
+
listFiles: async () => buildTree(workspaceDir),
|
|
49
|
+
readFile: async (_id: string, wsRel: string) => fs.readFile(resolve(wsRel), 'utf-8'),
|
|
50
|
+
writeFile: async (_id: string, wsRel: string, content: string) => {
|
|
51
|
+
await fs.mkdir(path.dirname(resolve(wsRel)), { recursive: true });
|
|
52
|
+
await fs.writeFile(resolve(wsRel), content);
|
|
53
|
+
},
|
|
54
|
+
deleteFile: async (_id: string, wsRel: string) => {
|
|
55
|
+
await fs.rm(resolve(wsRel));
|
|
56
|
+
},
|
|
57
|
+
} as unknown as WorkspaceService;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function stubWorkflow() {
|
|
61
|
+
const commits: { summary: string }[] = [];
|
|
62
|
+
// Strict per-path locks — a live row is contended even for the same user,
|
|
63
|
+
// exactly like FileLockService (nesting bugs must fail here too).
|
|
64
|
+
const locks = new Map<string, AuthUser>();
|
|
65
|
+
const lockRow = (h: AuthUser) => ({ holderUserId: h.id, holderName: h.name });
|
|
66
|
+
const svc = {
|
|
67
|
+
getLock: async (_w: string, _b: string, p: string) => {
|
|
68
|
+
const h = locks.get(p);
|
|
69
|
+
return h ? lockRow(h) : null;
|
|
70
|
+
},
|
|
71
|
+
acquireLock: async (_w: string, _b: string, p: string, user: AuthUser) => {
|
|
72
|
+
const h = locks.get(p);
|
|
73
|
+
if (h) return { acquired: false, lock: lockRow(h) };
|
|
74
|
+
locks.set(p, user);
|
|
75
|
+
return { acquired: true, lock: lockRow(user) };
|
|
76
|
+
},
|
|
77
|
+
releaseLock: async (_w: string, _b: string, p: string) => {
|
|
78
|
+
locks.delete(p);
|
|
79
|
+
},
|
|
80
|
+
releaseLockNoCommit: async (_w: string, _b: string, p: string) => {
|
|
81
|
+
locks.delete(p);
|
|
82
|
+
},
|
|
83
|
+
commitChanges: async (_ws: string, _user: AuthUser, summary: string) => {
|
|
84
|
+
commits.push({ summary });
|
|
85
|
+
return {} as unknown;
|
|
86
|
+
},
|
|
87
|
+
} as unknown as WorkflowService;
|
|
88
|
+
return { svc, commits };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
describe('GroupsAdminService', () => {
|
|
92
|
+
let root: string;
|
|
93
|
+
let repo: string;
|
|
94
|
+
let service: GroupsAdminService;
|
|
95
|
+
let commits: { summary: string }[];
|
|
96
|
+
|
|
97
|
+
beforeEach(async () => {
|
|
98
|
+
root = await fs.mkdtemp(path.join(os.tmpdir(), 'bevel-groups-admin-'));
|
|
99
|
+
repo = path.join(root, KB);
|
|
100
|
+
await write(repo, 'roles.yaml', ROLES);
|
|
101
|
+
await write(repo, 'groups.yaml', GROUPS);
|
|
102
|
+
await write(repo, 'access.md', '---\nwrite:\n - Admin\n---\n');
|
|
103
|
+
const workspace = stubWorkspace(root);
|
|
104
|
+
const workflow = stubWorkflow();
|
|
105
|
+
commits = workflow.commits;
|
|
106
|
+
service = new GroupsAdminService(
|
|
107
|
+
workspace,
|
|
108
|
+
workflow.svc,
|
|
109
|
+
new AccessControlService(workspace as never, KB),
|
|
110
|
+
KB,
|
|
111
|
+
() => DEFAULT_BRANCH,
|
|
112
|
+
);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
afterEach(async () => {
|
|
116
|
+
await fs.rm(root, { recursive: true, force: true });
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
const groupsYaml = () => fs.readFile(path.join(repo, 'groups.yaml'), 'utf-8');
|
|
120
|
+
|
|
121
|
+
it('roster: manual mode, groups with members, grant references, role assignments, health', async () => {
|
|
122
|
+
await write(repo, 'Knowledge/Sales/access.md', '---\nread:\n - GTM Team\n---\n');
|
|
123
|
+
await write(
|
|
124
|
+
repo,
|
|
125
|
+
'roles.yaml',
|
|
126
|
+
'roles:\n Admin:\n - admin@x.io\n Reviewer:\n - rev@x.io\n - group:gtm team\n',
|
|
127
|
+
);
|
|
128
|
+
const roster = await service.getRoster();
|
|
129
|
+
expect(roster.mode).toBe('manual');
|
|
130
|
+
expect(roster.groupsHealth).toEqual({ ok: true });
|
|
131
|
+
expect(roster.groups.map((g) => g.displayName).sort()).toEqual(['GTM Team', 'Product']);
|
|
132
|
+
const gtm = roster.groups.find((g) => g.canonical === 'gtm team');
|
|
133
|
+
expect(gtm?.members).toEqual(['sara@x.io']);
|
|
134
|
+
expect(gtm?.referencedBy).toEqual([{ path: 'Knowledge/Sales/access.md', verb: 'read' }]);
|
|
135
|
+
// Which roles carry a group:<canonical> assignment — feeds the delete
|
|
136
|
+
// confirm's "these roles will lose members" warning.
|
|
137
|
+
expect(gtm?.assignedToRoles).toEqual(['reviewer']);
|
|
138
|
+
expect(roster.groups.find((g) => g.canonical === 'product')?.assignedToRoles).toEqual([]);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('a broken MANUAL groups.yaml answers the roster with 422 + the parse message', async () => {
|
|
142
|
+
await write(repo, 'groups.yaml', 'groups:\n - not\n - a\n - mapping\n');
|
|
143
|
+
const err = await service.getRoster().catch((e: unknown) => e);
|
|
144
|
+
expect(err).toBeInstanceOf(GroupsAdminError);
|
|
145
|
+
expect((err as GroupsAdminError).status).toBe(422);
|
|
146
|
+
expect((err as GroupsAdminError).message).toMatch(/groups.yaml/);
|
|
147
|
+
expect((err as GroupsAdminError).payload).toMatchObject({ kind: 'broken-groups' });
|
|
148
|
+
// The message carries WHAT to fix — never a dead Groups page.
|
|
149
|
+
expect((err as GroupsAdminError).payload?.reason).toBeTruthy();
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('a broken SYNCED file keeps IdP mode: empty roster + groupsHealth marker (no manual fallback)', async () => {
|
|
153
|
+
await write(repo, 'synced-groups.yaml', 'groups:\n - not\n - a\n - mapping\n');
|
|
154
|
+
const roster = await service.getRoster();
|
|
155
|
+
expect(roster.mode).toBe('idp');
|
|
156
|
+
// Falling back to groups.yaml would resurrect retired manual groups.
|
|
157
|
+
expect(roster.groups).toEqual([]);
|
|
158
|
+
expect(roster.groupsHealth).toMatchObject({ ok: false, file: 'synced-groups.yaml' });
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('deleteGroup unassigns roles.yaml group refs in the SAME atomic commit', async () => {
|
|
162
|
+
await write(
|
|
163
|
+
repo,
|
|
164
|
+
'roles.yaml',
|
|
165
|
+
'roles:\n Admin:\n - admin@x.io\n Reviewer:\n - rev@x.io\n - group:gtm team\n Ops:\n - group:GTM Team\n',
|
|
166
|
+
);
|
|
167
|
+
const roster = await service.deleteGroup(ADMIN, 'gtm team');
|
|
168
|
+
expect(roster.groups.some((g) => g.canonical === 'gtm team')).toBe(false);
|
|
169
|
+
expect(await groupsYaml()).not.toContain('GTM Team');
|
|
170
|
+
const roles = await fs.readFile(path.join(repo, 'roles.yaml'), 'utf-8');
|
|
171
|
+
// Every ref gone — including the un-normalized hand-written one — with
|
|
172
|
+
// the rest of each role intact, in ONE commit with the groups.yaml edit.
|
|
173
|
+
expect(roles).not.toContain('gtm');
|
|
174
|
+
expect(roles).toContain('- rev@x.io');
|
|
175
|
+
expect(commits).toHaveLength(1);
|
|
176
|
+
expect(commits[0].summary).toContain('Delete group gtm team');
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("createGroup refuses the reserved 'role/' prefix (name-safety)", async () => {
|
|
180
|
+
await expect(service.createGroup(ADMIN, 'role/ops')).rejects.toMatchObject({
|
|
181
|
+
status: 422,
|
|
182
|
+
message: expect.stringContaining("role/"),
|
|
183
|
+
});
|
|
184
|
+
expect(await groupsYaml()).not.toContain('role/');
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('create → add → remove → delete lifecycle lands on disk', async () => {
|
|
188
|
+
await service.createGroup(ADMIN, 'Contractors');
|
|
189
|
+
await service.addMember(ADMIN, 'contractors', 'temp@x.io');
|
|
190
|
+
expect(await groupsYaml()).toContain('Contractors:\n - temp@x.io');
|
|
191
|
+
|
|
192
|
+
await service.removeMember(ADMIN, 'contractors', 'temp@x.io');
|
|
193
|
+
expect(await groupsYaml()).toContain('Contractors: []');
|
|
194
|
+
|
|
195
|
+
const roster = await service.deleteGroup(ADMIN, 'contractors');
|
|
196
|
+
expect(roster.groups.some((g) => g.canonical === 'contractors')).toBe(false);
|
|
197
|
+
expect(await groupsYaml()).not.toContain('Contractors');
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('refuses a group name that shadows a role (deliberate friction — IdP collisions resolve by precedence instead)', async () => {
|
|
201
|
+
await expect(service.createGroup(ADMIN, 'Reviewer')).rejects.toMatchObject({
|
|
202
|
+
status: 422,
|
|
203
|
+
message: expect.stringContaining('role name'),
|
|
204
|
+
});
|
|
205
|
+
await expect(service.renameGroup(ADMIN, 'product', 'Admin')).rejects.toMatchObject({
|
|
206
|
+
status: 422,
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('IdP mode: mutations refuse with the typed 409; reads still work', async () => {
|
|
211
|
+
await write(repo, 'synced-groups.yaml', 'groups:\n Engineering:\n - ada@x.io\n');
|
|
212
|
+
const roster = await service.getRoster();
|
|
213
|
+
expect(roster.mode).toBe('idp');
|
|
214
|
+
// The roster IS the synced file now — not the retired manual list.
|
|
215
|
+
expect(roster.groups.map((g) => g.canonical)).toEqual(['engineering']);
|
|
216
|
+
expect(roster.groups[0].members).toEqual(['ada@x.io']);
|
|
217
|
+
|
|
218
|
+
for (const call of [
|
|
219
|
+
() => service.createGroup(ADMIN, 'New Team'),
|
|
220
|
+
() => service.addMember(ADMIN, 'product', 'x@x.io'),
|
|
221
|
+
() => service.removeMember(ADMIN, 'product', 'felix@x.io'),
|
|
222
|
+
() => service.deleteGroup(ADMIN, 'product'),
|
|
223
|
+
() => service.renameGroup(ADMIN, 'product', 'Products'),
|
|
224
|
+
]) {
|
|
225
|
+
const err = await call().catch((e: unknown) => e);
|
|
226
|
+
expect(err).toBeInstanceOf(GroupsAdminError);
|
|
227
|
+
expect((err as GroupsAdminError).status).toBe(409);
|
|
228
|
+
expect((err as GroupsAdminError).payload).toMatchObject({ kind: 'idp-mode' });
|
|
229
|
+
}
|
|
230
|
+
// The connect dialog still needs the manual names in IdP-adjacent states.
|
|
231
|
+
expect((await service.listManualGroupNames()).sort()).toEqual(['GTM Team', 'Product']);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('canonical-changing rename rewrites grant references in ONE commit', async () => {
|
|
235
|
+
await write(repo, 'Knowledge/Sales/access.md', '---\nread:\n - GTM Team\nwrite:\n - deny GTM Team\n---\n');
|
|
236
|
+
await service.renameGroup(ADMIN, 'gtm team', 'Go To Market');
|
|
237
|
+
|
|
238
|
+
expect(await groupsYaml()).toContain('Go To Market:');
|
|
239
|
+
const rewritten = await fs.readFile(path.join(repo, 'Knowledge/Sales/access.md'), 'utf-8');
|
|
240
|
+
expect(rewritten).toContain('- Go To Market');
|
|
241
|
+
expect(rewritten).toContain('- deny Go To Market');
|
|
242
|
+
expect(rewritten).not.toContain('GTM Team');
|
|
243
|
+
expect(commits).toHaveLength(1);
|
|
244
|
+
expect(commits[0].summary).toContain('gtm team → Go To Market');
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it('rename rewrites a BODY-GOVERNED access.md (rules live below the frontmatter)', async () => {
|
|
248
|
+
// New-format file: frontmatter governs access.md itself, the body is the
|
|
249
|
+
// folder's rules — exactly what the resolver enforces.
|
|
250
|
+
await write(
|
|
251
|
+
repo,
|
|
252
|
+
'Knowledge/Sales/access.md',
|
|
253
|
+
'---\nwrite:\n - Admin\n---\nread:\n - GTM Team\nwrite:\n - deny GTM Team\n',
|
|
254
|
+
);
|
|
255
|
+
// The scan must see the body rule too (delete warnings / referencedBy).
|
|
256
|
+
const roster = await service.getRoster();
|
|
257
|
+
const gtm = roster.groups.find((g) => g.canonical === 'gtm team');
|
|
258
|
+
expect(gtm?.referencedBy).toContainEqual({ path: 'Knowledge/Sales/access.md', verb: 'read' });
|
|
259
|
+
|
|
260
|
+
await service.renameGroup(ADMIN, 'gtm team', 'Go To Market');
|
|
261
|
+
const rewritten = await fs.readFile(path.join(repo, 'Knowledge/Sales/access.md'), 'utf-8');
|
|
262
|
+
expect(rewritten).toContain('- Go To Market');
|
|
263
|
+
expect(rewritten).toContain('- deny Go To Market');
|
|
264
|
+
expect(rewritten).not.toContain('GTM Team');
|
|
265
|
+
// The frontmatter self-rule is a rule source too, and Admin is untouched.
|
|
266
|
+
expect(rewritten).toContain('- Admin');
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it('rename rewrites role→group assignments (group:<ref> in roles.yaml) in the SAME commit', async () => {
|
|
270
|
+
await write(
|
|
271
|
+
repo,
|
|
272
|
+
'roles.yaml',
|
|
273
|
+
'roles:\n Admin:\n - admin@x.io\n Reviewer:\n - rev@x.io\n - group:gtm team\n',
|
|
274
|
+
);
|
|
275
|
+
await service.renameGroup(ADMIN, 'gtm team', 'Go To Market');
|
|
276
|
+
|
|
277
|
+
const roles = await fs.readFile(path.join(repo, 'roles.yaml'), 'utf-8');
|
|
278
|
+
// The stored ref is canonical — it must follow the rename, or the role
|
|
279
|
+
// silently stops expanding to the group's members.
|
|
280
|
+
expect(roles).toContain('group:go to market');
|
|
281
|
+
expect(roles).not.toContain('group:gtm team');
|
|
282
|
+
// Atomic: groups.yaml + roles.yaml land as ONE commit.
|
|
283
|
+
expect(commits).toHaveLength(1);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it('rollback of a failed commit DELETES a file this edit created (no empty artifact)', async () => {
|
|
287
|
+
await fs.rm(path.join(repo, 'groups.yaml'));
|
|
288
|
+
const workspace = stubWorkspace(root);
|
|
289
|
+
const workflow = stubWorkflow();
|
|
290
|
+
(workflow.svc as unknown as { commitChanges: unknown }).commitChanges = async () => {
|
|
291
|
+
throw new Error('commit exploded');
|
|
292
|
+
};
|
|
293
|
+
const svc = new GroupsAdminService(
|
|
294
|
+
workspace,
|
|
295
|
+
workflow.svc,
|
|
296
|
+
new AccessControlService(workspace as never, KB),
|
|
297
|
+
KB,
|
|
298
|
+
() => DEFAULT_BRANCH,
|
|
299
|
+
);
|
|
300
|
+
await expect(svc.createGroup(ADMIN, 'Contractors')).rejects.toThrow('commit exploded');
|
|
301
|
+
// groups.yaml did not exist before the edit → the rollback removes it
|
|
302
|
+
// instead of leaving an empty file behind as a new artifact.
|
|
303
|
+
await expect(groupsYaml()).rejects.toMatchObject({ code: 'ENOENT' });
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it('a path whose restore failed is released WITHOUT commit (discard, never partial publish)', async () => {
|
|
307
|
+
const workspace = stubWorkspace(root);
|
|
308
|
+
const workflow = stubWorkflow();
|
|
309
|
+
const wf = workflow.svc as unknown as {
|
|
310
|
+
commitChanges: (...a: unknown[]) => Promise<unknown>;
|
|
311
|
+
releaseLock: (...a: unknown[]) => Promise<void>;
|
|
312
|
+
releaseLockNoCommit: (...a: unknown[]) => Promise<void>;
|
|
313
|
+
};
|
|
314
|
+
wf.commitChanges = async () => {
|
|
315
|
+
throw new Error('commit exploded');
|
|
316
|
+
};
|
|
317
|
+
const committingReleases: string[] = [];
|
|
318
|
+
const discardingReleases: string[] = [];
|
|
319
|
+
const origRelease = wf.releaseLock.bind(workflow.svc);
|
|
320
|
+
const origNoCommit = wf.releaseLockNoCommit.bind(workflow.svc);
|
|
321
|
+
wf.releaseLock = async (...a) => {
|
|
322
|
+
committingReleases.push(a[2] as string);
|
|
323
|
+
return origRelease(...a);
|
|
324
|
+
};
|
|
325
|
+
wf.releaseLockNoCommit = async (...a) => {
|
|
326
|
+
discardingReleases.push(a[2] as string);
|
|
327
|
+
return origNoCommit(...a);
|
|
328
|
+
};
|
|
329
|
+
// The edit's own write lands; the rollback write then fails — the tree
|
|
330
|
+
// holds known-partial bytes, so the release must DISCARD, not commit.
|
|
331
|
+
const ws = workspace as unknown as { writeFile: (...a: unknown[]) => Promise<void> };
|
|
332
|
+
const origWrite = ws.writeFile.bind(workspace);
|
|
333
|
+
let writes = 0;
|
|
334
|
+
ws.writeFile = async (...a) => {
|
|
335
|
+
writes += 1;
|
|
336
|
+
if (writes >= 2) throw new Error('disk full');
|
|
337
|
+
return origWrite(...a);
|
|
338
|
+
};
|
|
339
|
+
const svc = new GroupsAdminService(
|
|
340
|
+
workspace,
|
|
341
|
+
workflow.svc,
|
|
342
|
+
new AccessControlService(workspace as never, KB),
|
|
343
|
+
KB,
|
|
344
|
+
() => DEFAULT_BRANCH,
|
|
345
|
+
);
|
|
346
|
+
await expect(svc.addMember(ADMIN, 'product', 'new@x.io')).rejects.toThrow('commit exploded');
|
|
347
|
+
expect(discardingReleases).toEqual([`${KB}/groups.yaml`]);
|
|
348
|
+
expect(committingReleases).not.toContain(`${KB}/groups.yaml`);
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
it('retireManualGroups deletes the file once; git history is the recovery', async () => {
|
|
352
|
+
expect(await service.retireManualGroups(ADMIN)).toBe(true);
|
|
353
|
+
await expect(groupsYaml()).rejects.toMatchObject({ code: 'ENOENT' });
|
|
354
|
+
expect(commits.some((c) => c.summary.includes('Retire manual groups'))).toBe(true);
|
|
355
|
+
// Already gone → honest no-op.
|
|
356
|
+
expect(await service.retireManualGroups(ADMIN)).toBe(false);
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
it('retireManualGroups checks existence UNDER the lock — a create that races in still gets retired', async () => {
|
|
360
|
+
// groups.yaml is absent when retire is called, but a concurrent createGroup
|
|
361
|
+
// lands it right as the retire acquires the lock. Checked before the lock
|
|
362
|
+
// (the old shape) retire would answer false and the manual file would
|
|
363
|
+
// linger into IdP mode, ready to resurrect if the synced file ever goes
|
|
364
|
+
// away. Checked under the lock, the just-created file is seen and retired.
|
|
365
|
+
await fs.rm(path.join(repo, 'groups.yaml'));
|
|
366
|
+
const workspace = stubWorkspace(root);
|
|
367
|
+
const workflow = stubWorkflow();
|
|
368
|
+
const wf = workflow.svc as unknown as {
|
|
369
|
+
acquireLock: (w: string, b: string, p: string, u: AuthUser) => Promise<unknown>;
|
|
370
|
+
};
|
|
371
|
+
const origAcquire = wf.acquireLock.bind(workflow.svc);
|
|
372
|
+
wf.acquireLock = async (w, b, p, u) => {
|
|
373
|
+
if (p === `${KB}/groups.yaml`) {
|
|
374
|
+
await write(repo, 'groups.yaml', 'groups:\n Late Team:\n - late@x.io\n');
|
|
375
|
+
}
|
|
376
|
+
return origAcquire(w, b, p, u);
|
|
377
|
+
};
|
|
378
|
+
const svc = new GroupsAdminService(
|
|
379
|
+
workspace,
|
|
380
|
+
workflow.svc,
|
|
381
|
+
new AccessControlService(workspace as never, KB),
|
|
382
|
+
KB,
|
|
383
|
+
() => DEFAULT_BRANCH,
|
|
384
|
+
);
|
|
385
|
+
expect(await svc.retireManualGroups(ADMIN)).toBe(true);
|
|
386
|
+
await expect(groupsYaml()).rejects.toMatchObject({ code: 'ENOENT' });
|
|
387
|
+
expect(workflow.commits.some((c) => c.summary.includes('Retire manual groups'))).toBe(true);
|
|
388
|
+
});
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
describe('groups-edit guardrails', () => {
|
|
392
|
+
it('refuses reserved and structurally-unsafe names', () => {
|
|
393
|
+
expect(() => createGroup('', 'everyone')).toThrow(GroupsEditError);
|
|
394
|
+
expect(() => createGroup('', 'Ops: West')).toThrow(GroupsEditError);
|
|
395
|
+
expect(() => createGroup('', '-lead')).toThrow(GroupsEditError);
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
it('refuses malformed member emails', () => {
|
|
399
|
+
const base = createGroup('', 'Team').text;
|
|
400
|
+
expect(() => addGroupMember(base, 'team', 'not-an-email')).toThrow(GroupsEditError);
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
it('refuses a group:-prefixed member (groups do not nest; a member is always an email)', () => {
|
|
404
|
+
const base = createGroup('', 'Team').text;
|
|
405
|
+
// `group:lee@x.io` passes the email regex, so without the explicit refusal
|
|
406
|
+
// this would land a dead never-matching entry instead of an actionable 422.
|
|
407
|
+
expect(() => addGroupMember(base, 'team', 'group:lee@x.io')).toThrow(
|
|
408
|
+
/a group member is always a person's email/,
|
|
409
|
+
);
|
|
410
|
+
expect(() => addGroupMember(base, 'team', 'group:GTM Team')).toThrow(GroupsEditError);
|
|
411
|
+
});
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
describe('groups roster referencedBy — the mirror of the roles roster attribution', () => {
|
|
415
|
+
it('a group shadowed BY a role name still owns its bare-token hits; role/<name> hits are never the group’s', async () => {
|
|
416
|
+
const root2 = await fs.mkdtemp(path.join(os.tmpdir(), 'bevel-groups-shadow-'));
|
|
417
|
+
try {
|
|
418
|
+
const repo2 = path.join(root2, KB);
|
|
419
|
+
// A group named Reviewer shares the Reviewer role's name. Group-first
|
|
420
|
+
// precedence gives the GROUP the bare key, so the bare hit is its
|
|
421
|
+
// reference; the explicit role/ hit belongs to the ROLE roster only.
|
|
422
|
+
await write(repo2, 'roles.yaml', ROLES); // declares role Reviewer
|
|
423
|
+
await write(repo2, 'groups.yaml', 'groups:\n Reviewer:\n - pat@x.io\n');
|
|
424
|
+
await write(repo2, 'team/access.md', '---\nread:\n - Reviewer\nwrite:\n - role/Reviewer\n---\n');
|
|
425
|
+
const workspace = stubWorkspace(root2);
|
|
426
|
+
const svc = new GroupsAdminService(
|
|
427
|
+
workspace,
|
|
428
|
+
stubWorkflow().svc,
|
|
429
|
+
new AccessControlService(workspace as never, KB),
|
|
430
|
+
KB,
|
|
431
|
+
() => DEFAULT_BRANCH,
|
|
432
|
+
);
|
|
433
|
+
const roster = await svc.getRoster();
|
|
434
|
+
const reviewer = roster.groups.find((g) => g.canonical === 'reviewer')!;
|
|
435
|
+
expect(reviewer.referencedBy).toEqual([{ path: 'team/access.md', verb: 'read' }]);
|
|
436
|
+
} finally {
|
|
437
|
+
await fs.rm(root2, { recursive: true, force: true });
|
|
438
|
+
}
|
|
439
|
+
});
|
|
440
|
+
});
|