@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,325 @@
|
|
|
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 { RolesAdminService } from '../roles-admin.service.js';
|
|
12
|
+
|
|
13
|
+
const KB = 'knowledge-base';
|
|
14
|
+
const ADMIN: AuthUser = { id: 'u-admin', email: 'admin@x.io', name: 'Admin' } as AuthUser;
|
|
15
|
+
|
|
16
|
+
const ROLES = `roles:
|
|
17
|
+
Admin:
|
|
18
|
+
- admin@x.io
|
|
19
|
+
Plugin Creator:
|
|
20
|
+
- jane@x.io
|
|
21
|
+
- group:engineering
|
|
22
|
+
Product:
|
|
23
|
+
- felix@x.io
|
|
24
|
+
- sara@x.io
|
|
25
|
+
`;
|
|
26
|
+
|
|
27
|
+
async function write(repo: string, rel: string, contents: string): Promise<void> {
|
|
28
|
+
const abs = path.join(repo, rel);
|
|
29
|
+
await fs.mkdir(path.dirname(abs), { recursive: true });
|
|
30
|
+
await fs.writeFile(abs, contents);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function stubWorkspace(workspaceDir: string): WorkspaceService {
|
|
34
|
+
const resolve = (wsRel: string) => path.join(workspaceDir, wsRel);
|
|
35
|
+
const buildTree = async (absDir: string): Promise<FileTreeEntry> => {
|
|
36
|
+
const rel = path.relative(workspaceDir, absDir).replace(/\\/g, '/');
|
|
37
|
+
const children: FileTreeEntry[] = [];
|
|
38
|
+
for (const e of await fs.readdir(absDir, { withFileTypes: true })) {
|
|
39
|
+
if (e.name === '.git') continue;
|
|
40
|
+
const childAbs = path.join(absDir, e.name);
|
|
41
|
+
if (e.isDirectory()) children.push(await buildTree(childAbs));
|
|
42
|
+
else if (e.isFile()) {
|
|
43
|
+
children.push({
|
|
44
|
+
name: e.name,
|
|
45
|
+
relativePath: path.relative(workspaceDir, childAbs).replace(/\\/g, '/'),
|
|
46
|
+
type: 'file',
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return { name: path.basename(absDir), relativePath: rel || '.', type: 'directory', children };
|
|
51
|
+
};
|
|
52
|
+
return {
|
|
53
|
+
getWorkspacePath: async () => workspaceDir,
|
|
54
|
+
getOrCreateForBranch: async () => ({}) as unknown,
|
|
55
|
+
listFiles: async () => buildTree(workspaceDir),
|
|
56
|
+
readFile: async (_id: string, wsRel: string) => fs.readFile(resolve(wsRel), 'utf-8'),
|
|
57
|
+
writeFile: async (_id: string, wsRel: string, content: string) => {
|
|
58
|
+
await fs.mkdir(path.dirname(resolve(wsRel)), { recursive: true });
|
|
59
|
+
await fs.writeFile(resolve(wsRel), content);
|
|
60
|
+
},
|
|
61
|
+
deleteFile: async (_id: string, wsRel: string) => {
|
|
62
|
+
await fs.rm(resolve(wsRel));
|
|
63
|
+
},
|
|
64
|
+
} as unknown as WorkspaceService;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function stubWorkflow() {
|
|
68
|
+
const commits: { summary: string }[] = [];
|
|
69
|
+
// Strict per-path locks — a live row is contended even for the same user,
|
|
70
|
+
// exactly like FileLockService (nesting bugs must fail here too).
|
|
71
|
+
const locks = new Map<string, AuthUser>();
|
|
72
|
+
const lockRow = (h: AuthUser) => ({ holderUserId: h.id, holderName: h.name });
|
|
73
|
+
const acquireCalls: { path: string; coordination: boolean }[] = [];
|
|
74
|
+
const svc = {
|
|
75
|
+
getLock: async (_w: string, _b: string, p: string) => {
|
|
76
|
+
const h = locks.get(p);
|
|
77
|
+
return h ? lockRow(h) : null;
|
|
78
|
+
},
|
|
79
|
+
acquireLock: async (
|
|
80
|
+
_w: string,
|
|
81
|
+
_b: string,
|
|
82
|
+
p: string,
|
|
83
|
+
user: AuthUser,
|
|
84
|
+
opts?: { coordination?: boolean },
|
|
85
|
+
) => {
|
|
86
|
+
acquireCalls.push({ path: p, coordination: !!opts?.coordination });
|
|
87
|
+
// Mirror the production lock-time write gate on the machine-owned file:
|
|
88
|
+
// synced-groups.yaml refuses every non-bot WRITE-intent acquire on the
|
|
89
|
+
// (always protected) default branch. Only a coordination acquire — a
|
|
90
|
+
// pure mutex claiming no write authority — may hold it for a human.
|
|
91
|
+
if (p === `${KB}/synced-groups.yaml` && !opts?.coordination) {
|
|
92
|
+
throw new Error(`Access denied: no write permission on "${p}" (machine-owned)`);
|
|
93
|
+
}
|
|
94
|
+
const h = locks.get(p);
|
|
95
|
+
if (h) return { acquired: false, lock: lockRow(h) };
|
|
96
|
+
locks.set(p, user);
|
|
97
|
+
return { acquired: true, lock: lockRow(user) };
|
|
98
|
+
},
|
|
99
|
+
releaseLock: async (_w: string, _b: string, p: string) => {
|
|
100
|
+
locks.delete(p);
|
|
101
|
+
},
|
|
102
|
+
releaseLockNoCommit: async (_w: string, _b: string, p: string) => {
|
|
103
|
+
locks.delete(p);
|
|
104
|
+
},
|
|
105
|
+
commitChanges: async (_ws: string, _user: AuthUser, summary: string) => {
|
|
106
|
+
commits.push({ summary });
|
|
107
|
+
return {} as unknown;
|
|
108
|
+
},
|
|
109
|
+
} as unknown as WorkflowService;
|
|
110
|
+
return { svc, commits, acquireCalls };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
describe('RolesAdminService — capabilities, group assignment, conversion', () => {
|
|
114
|
+
let root: string;
|
|
115
|
+
let repo: string;
|
|
116
|
+
let service: RolesAdminService;
|
|
117
|
+
let commits: { summary: string }[];
|
|
118
|
+
|
|
119
|
+
beforeEach(async () => {
|
|
120
|
+
root = await fs.mkdtemp(path.join(os.tmpdir(), 'bevel-roles-cap-'));
|
|
121
|
+
repo = path.join(root, KB);
|
|
122
|
+
await write(repo, 'roles.yaml', ROLES);
|
|
123
|
+
// Assignable groups must exist in the ACTIVE source — assignGroup
|
|
124
|
+
// validates against it (a ref to a missing group is a dead grant).
|
|
125
|
+
await write(repo, 'groups.yaml', 'groups:\n GTM Team:\n - sara@x.io\n');
|
|
126
|
+
await write(repo, 'access.md', '---\nwrite:\n - Admin\nread:\n - Product\n---\n');
|
|
127
|
+
const workspace = stubWorkspace(root);
|
|
128
|
+
const workflow = stubWorkflow();
|
|
129
|
+
commits = workflow.commits;
|
|
130
|
+
service = new RolesAdminService(
|
|
131
|
+
workspace,
|
|
132
|
+
workflow.svc,
|
|
133
|
+
new AccessControlService(workspace as never, KB),
|
|
134
|
+
KB,
|
|
135
|
+
() => DEFAULT_BRANCH,
|
|
136
|
+
);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
afterEach(async () => {
|
|
140
|
+
await fs.rm(root, { recursive: true, force: true });
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
const rolesYaml = () => fs.readFile(path.join(repo, 'roles.yaml'), 'utf-8');
|
|
144
|
+
const groupsYaml = () => fs.readFile(path.join(repo, 'groups.yaml'), 'utf-8');
|
|
145
|
+
|
|
146
|
+
it('roster: capability metadata, legacy flag, and members/groups split', async () => {
|
|
147
|
+
const roster = await service.getRoster();
|
|
148
|
+
const admin = roster.find((r) => r.canonical === 'admin')!;
|
|
149
|
+
expect(admin.capability).toMatchObject({ groupAssignable: true });
|
|
150
|
+
expect(admin.capability?.description).toContain('configuration');
|
|
151
|
+
|
|
152
|
+
const creator = roster.find((r) => r.canonical === 'plugin creator')!;
|
|
153
|
+
expect(creator.members).toEqual(['jane@x.io']); // group ref split out
|
|
154
|
+
expect(creator.groups).toEqual(['engineering']);
|
|
155
|
+
expect(creator.capability).toBeNull(); // not in the registry (yet)
|
|
156
|
+
|
|
157
|
+
const product = roster.find((r) => r.canonical === 'product')!;
|
|
158
|
+
expect(product.capability).toBeNull(); // legacy people-set role
|
|
159
|
+
expect(product.referencedBy).toEqual([{ path: 'access.md', verb: 'read' }]);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('assigns and unassigns a group on a non-Admin role', async () => {
|
|
163
|
+
await service.assignGroup(ADMIN, 'product', 'GTM Team');
|
|
164
|
+
expect(await rolesYaml()).toContain('- group:gtm team');
|
|
165
|
+
|
|
166
|
+
const roster = await service.unassignGroup(ADMIN, 'product', 'GTM Team');
|
|
167
|
+
expect(await rolesYaml()).not.toContain('group:gtm team');
|
|
168
|
+
expect(roster.find((r) => r.canonical === 'product')?.groups).toEqual([]);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('refuses to assign a group the active source does not know (dead grant)', async () => {
|
|
172
|
+
const before = await rolesYaml();
|
|
173
|
+
await expect(service.assignGroup(ADMIN, 'product', 'Ghost Team')).rejects.toMatchObject({
|
|
174
|
+
status: 404,
|
|
175
|
+
payload: { kind: 'unknown-group', group: 'Ghost Team' },
|
|
176
|
+
});
|
|
177
|
+
expect(await rolesYaml()).toBe(before);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('assigns a group to Admin (NEW) — the invariant lives in the direct-email rule, not a ban', async () => {
|
|
181
|
+
await service.assignGroup(ADMIN, 'admin', 'GTM Team');
|
|
182
|
+
expect(await rolesYaml()).toContain('- group:gtm team');
|
|
183
|
+
const roster = await service.getRoster();
|
|
184
|
+
expect(roster.find((r) => r.canonical === 'admin')?.groups).toEqual(['gtm team']);
|
|
185
|
+
// But Admin can never drop its LAST direct email — group refs don't count.
|
|
186
|
+
await expect(
|
|
187
|
+
service.removeMember(ADMIN, 'admin', 'admin@x.io', true),
|
|
188
|
+
).rejects.toMatchObject({ status: 422, message: expect.stringContaining('direct email') });
|
|
189
|
+
expect(await rolesYaml()).toContain('- admin@x.io');
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('converts a legacy role to a group atomically; grants keep the name', async () => {
|
|
193
|
+
const roster = await service.convertRoleToGroup(ADMIN, 'product');
|
|
194
|
+
|
|
195
|
+
expect(roster.some((r) => r.canonical === 'product')).toBe(false);
|
|
196
|
+
expect(await rolesYaml()).not.toContain('Product');
|
|
197
|
+
expect(await groupsYaml()).toContain('Product:\n - felix@x.io\n - sara@x.io');
|
|
198
|
+
expect(commits).toHaveLength(1);
|
|
199
|
+
expect(commits[0].summary).toContain('Convert role Product to a group');
|
|
200
|
+
// The grant text was never touched — the name carries over.
|
|
201
|
+
const accessMd = await fs.readFile(path.join(repo, 'access.md'), 'utf-8');
|
|
202
|
+
expect(accessMd).toContain('- Product');
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('manual-mode conversion works for a HUMAN admin: the synced file is held as a COORDINATION lock', async () => {
|
|
206
|
+
// synced-groups.yaml is machine-owned — the lock-time write gate refuses
|
|
207
|
+
// every human actor (the stub's acquireLock enforces this exactly like
|
|
208
|
+
// production). The conversion still needs the file's lock to serialize
|
|
209
|
+
// with a provisioning push, so it must take it as a coordination hold
|
|
210
|
+
// that claims no write authority. A write-intent acquire here would fail
|
|
211
|
+
// EVERY manual-mode conversion at lock acquisition.
|
|
212
|
+
const workspace = stubWorkspace(root);
|
|
213
|
+
const workflow = stubWorkflow();
|
|
214
|
+
const svc = new RolesAdminService(
|
|
215
|
+
workspace,
|
|
216
|
+
workflow.svc,
|
|
217
|
+
new AccessControlService(workspace as never, KB),
|
|
218
|
+
KB,
|
|
219
|
+
() => DEFAULT_BRANCH,
|
|
220
|
+
);
|
|
221
|
+
const roster = await svc.convertRoleToGroup(ADMIN, 'product');
|
|
222
|
+
expect(roster.some((r) => r.canonical === 'product')).toBe(false);
|
|
223
|
+
expect(await groupsYaml()).toContain('Product:');
|
|
224
|
+
// The synced file WAS locked (the serialization is real), via coordination;
|
|
225
|
+
// the written files keep their write-gated acquires.
|
|
226
|
+
expect(workflow.acquireCalls).toContainEqual({
|
|
227
|
+
path: `${KB}/synced-groups.yaml`,
|
|
228
|
+
coordination: true,
|
|
229
|
+
});
|
|
230
|
+
expect(workflow.acquireCalls).toContainEqual({ path: `${KB}/roles.yaml`, coordination: false });
|
|
231
|
+
expect(workflow.acquireCalls).toContainEqual({ path: `${KB}/groups.yaml`, coordination: false });
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('conversion refusals: capability role, group-assigned role, IdP mode', async () => {
|
|
235
|
+
await expect(service.convertRoleToGroup(ADMIN, 'admin')).rejects.toMatchObject({
|
|
236
|
+
status: 422,
|
|
237
|
+
message: expect.stringContaining('capability role'),
|
|
238
|
+
});
|
|
239
|
+
await expect(service.convertRoleToGroup(ADMIN, 'plugin creator')).rejects.toMatchObject({
|
|
240
|
+
status: 422,
|
|
241
|
+
message: expect.stringContaining('assigned to groups'),
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
await write(repo, 'synced-groups.yaml', 'groups:\n Engineering:\n - ada@x.io\n');
|
|
245
|
+
await expect(service.convertRoleToGroup(ADMIN, 'product')).rejects.toMatchObject({
|
|
246
|
+
status: 409,
|
|
247
|
+
payload: { kind: 'idp-mode' },
|
|
248
|
+
});
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it('conversion re-checks IdP mode UNDER the synced file lock (TOCTOU: a sync landing mid-flight refuses)', async () => {
|
|
252
|
+
// The pre-lock IdP check passes (no synced file yet). Directory sync then
|
|
253
|
+
// materializes synced-groups.yaml just before the conversion gets its
|
|
254
|
+
// locks — the same interleaving the synced file's lock serializes in
|
|
255
|
+
// production. The under-lock recheck must refuse with the typed 409 and
|
|
256
|
+
// write nothing (committing a groups.yaml group in IdP mode writes a
|
|
257
|
+
// retired file).
|
|
258
|
+
const workspace = stubWorkspace(root);
|
|
259
|
+
const workflow = stubWorkflow();
|
|
260
|
+
const wf = workflow.svc as unknown as {
|
|
261
|
+
acquireLock: (
|
|
262
|
+
w: string,
|
|
263
|
+
b: string,
|
|
264
|
+
p: string,
|
|
265
|
+
u: AuthUser,
|
|
266
|
+
opts?: { coordination?: boolean },
|
|
267
|
+
) => Promise<unknown>;
|
|
268
|
+
};
|
|
269
|
+
const origAcquire = wf.acquireLock.bind(workflow.svc);
|
|
270
|
+
wf.acquireLock = async (w, b, p, u, opts) => {
|
|
271
|
+
if (p === `${KB}/synced-groups.yaml`) {
|
|
272
|
+
// The sync's provisioning push won the race for this lock and landed.
|
|
273
|
+
await write(repo, 'synced-groups.yaml', 'groups:\n Engineering:\n - ada@x.io\n');
|
|
274
|
+
}
|
|
275
|
+
return origAcquire(w, b, p, u, opts);
|
|
276
|
+
};
|
|
277
|
+
const svc = new RolesAdminService(
|
|
278
|
+
workspace,
|
|
279
|
+
workflow.svc,
|
|
280
|
+
new AccessControlService(workspace as never, KB),
|
|
281
|
+
KB,
|
|
282
|
+
() => DEFAULT_BRANCH,
|
|
283
|
+
);
|
|
284
|
+
await expect(svc.convertRoleToGroup(ADMIN, 'product')).rejects.toMatchObject({
|
|
285
|
+
status: 409,
|
|
286
|
+
payload: { kind: 'idp-mode' },
|
|
287
|
+
});
|
|
288
|
+
// Nothing moved.
|
|
289
|
+
expect(await rolesYaml()).toBe(ROLES);
|
|
290
|
+
expect(await groupsYaml()).not.toContain('Product');
|
|
291
|
+
expect(workflow.commits).toHaveLength(0);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it('a failed conversion with NO pre-existing groups.yaml leaves no groups.yaml behind', async () => {
|
|
295
|
+
await fs.rm(path.join(repo, 'groups.yaml'));
|
|
296
|
+
const workspace = stubWorkspace(root);
|
|
297
|
+
const workflow = stubWorkflow();
|
|
298
|
+
(workflow.svc as unknown as { commitChanges: unknown }).commitChanges = async () => {
|
|
299
|
+
throw new Error('commit exploded');
|
|
300
|
+
};
|
|
301
|
+
const svc = new RolesAdminService(
|
|
302
|
+
workspace,
|
|
303
|
+
workflow.svc,
|
|
304
|
+
new AccessControlService(workspace as never, KB),
|
|
305
|
+
KB,
|
|
306
|
+
() => DEFAULT_BRANCH,
|
|
307
|
+
);
|
|
308
|
+
await expect(svc.convertRoleToGroup(ADMIN, 'product')).rejects.toThrow('commit exploded');
|
|
309
|
+
// groups.yaml was CREATED by this conversion → rollback deletes it
|
|
310
|
+
// instead of publishing an empty file as a brand-new artifact.
|
|
311
|
+
await expect(groupsYaml()).rejects.toMatchObject({ code: 'ENOENT' });
|
|
312
|
+
// …and roles.yaml got its original bytes back: the role survives.
|
|
313
|
+
expect(await rolesYaml()).toBe(ROLES);
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
it('conversion refuses a groups.yaml name collision', async () => {
|
|
317
|
+
await write(repo, 'groups.yaml', 'groups:\n Product:\n - other@x.io\n');
|
|
318
|
+
await expect(service.convertRoleToGroup(ADMIN, 'product')).rejects.toMatchObject({
|
|
319
|
+
status: 422,
|
|
320
|
+
message: expect.stringContaining('already exists'),
|
|
321
|
+
});
|
|
322
|
+
// Nothing moved: the role survives an aborted conversion.
|
|
323
|
+
expect(await rolesYaml()).toContain('Product:');
|
|
324
|
+
});
|
|
325
|
+
});
|
|
@@ -3,12 +3,12 @@ import { describe, it, expect } from 'vitest';
|
|
|
3
3
|
import {
|
|
4
4
|
parseRolesModel,
|
|
5
5
|
emitRolesModel,
|
|
6
|
-
createRole,
|
|
7
6
|
deleteRole,
|
|
8
7
|
addMember,
|
|
9
8
|
removeMember,
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
removeGroupRefsEverywhere,
|
|
10
|
+
removeRoleGroupRef,
|
|
11
|
+
renameGroupRefs,
|
|
12
12
|
RolesEditError,
|
|
13
13
|
} from '../roles-edit.js';
|
|
14
14
|
import { parseYamlSubset } from '../access-control.service.js';
|
|
@@ -64,19 +64,6 @@ describe('roles-edit: parse + emit', () => {
|
|
|
64
64
|
});
|
|
65
65
|
});
|
|
66
66
|
|
|
67
|
-
describe('roles-edit: name guard', () => {
|
|
68
|
-
it('rejects structurally unsafe names', () => {
|
|
69
|
-
for (const bad of ['Sales: West', '#temp', 'a<b', 'a>b', '-leading', '']) {
|
|
70
|
-
expect(() => assertSafeRoleDisplayName(bad)).toThrow(RolesEditError);
|
|
71
|
-
}
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
it('allows ordinary multi-word names', () => {
|
|
75
|
-
expect(() => assertSafeRoleDisplayName('Product Team')).not.toThrow();
|
|
76
|
-
expect(() => assertSafeRoleDisplayName('GTM Team')).not.toThrow();
|
|
77
|
-
});
|
|
78
|
-
});
|
|
79
|
-
|
|
80
67
|
describe('roles-edit: mutations', () => {
|
|
81
68
|
it('addMember is idempotent (no-op when already a member)', () => {
|
|
82
69
|
const r = addMember(BASE, 'admin', 'JUAN@bevel.software');
|
|
@@ -100,12 +87,11 @@ describe('roles-edit: mutations', () => {
|
|
|
100
87
|
expect(removeMember(BASE, 'admin', 'ghost@x.eu').changed).toBe(false);
|
|
101
88
|
});
|
|
102
89
|
|
|
103
|
-
it(
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
expect(() =>
|
|
107
|
-
expect(() =>
|
|
108
|
-
expect(() => createRole(BASE, 'admin')).toThrow(RolesEditError); // duplicate canonical
|
|
90
|
+
it("addMember rejects a 'group:'-prefixed value with an actionable message", () => {
|
|
91
|
+
// 'group:lee@x.io' passes the email regex — without the guard it would
|
|
92
|
+
// land a dead group ref the resolver reads as an unknown group.
|
|
93
|
+
expect(() => addMember(BASE, 'admin', 'group:lee@x.io')).toThrow(/group assignment/);
|
|
94
|
+
expect(() => addMember(BASE, 'admin', 'group:gtm team')).toThrow(RolesEditError);
|
|
109
95
|
});
|
|
110
96
|
|
|
111
97
|
it('deleteRole removes the role; 404 when absent', () => {
|
|
@@ -114,14 +100,104 @@ describe('roles-edit: mutations', () => {
|
|
|
114
100
|
expect(() => deleteRole(BASE, 'ghost')).toThrow(/not found/);
|
|
115
101
|
});
|
|
116
102
|
|
|
117
|
-
it('
|
|
118
|
-
const
|
|
119
|
-
|
|
120
|
-
|
|
103
|
+
it('removeGroupRefsEverywhere strips every ref to the group across roles', () => {
|
|
104
|
+
const text = [
|
|
105
|
+
'roles:',
|
|
106
|
+
' Admin:',
|
|
107
|
+
' - razvan@bevel.software',
|
|
108
|
+
' Product Team:',
|
|
109
|
+
' - felix@example.com',
|
|
110
|
+
' - group:gtm team',
|
|
111
|
+
' Ops:',
|
|
112
|
+
' - group:GTM Team', // un-normalized hand edit — canonical matching must catch it
|
|
113
|
+
'',
|
|
114
|
+
].join('\n');
|
|
115
|
+
const r = removeGroupRefsEverywhere(text, 'gtm team');
|
|
116
|
+
expect(r.changed).toBe(true);
|
|
117
|
+
expect(r.text).not.toContain('gtm');
|
|
118
|
+
const model = parseRolesModel(r.text);
|
|
119
|
+
expect(model.find((x) => x.displayName === 'Product Team')?.members).toEqual(['felix@example.com']);
|
|
120
|
+
expect(model.find((x) => x.displayName === 'Ops')?.members).toEqual([]);
|
|
121
|
+
expect(removeGroupRefsEverywhere(BASE, 'gtm team').changed).toBe(false);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('renameGroupRefs: rewrites group:<ref> members across roles, deduping an existing target', () => {
|
|
125
|
+
const text = [
|
|
126
|
+
'roles:',
|
|
127
|
+
' Admin:',
|
|
128
|
+
' - razvan@bevel.software',
|
|
129
|
+
' Product Team:',
|
|
130
|
+
' - felix@example.com',
|
|
131
|
+
' - group:gtm team',
|
|
132
|
+
' Ops:',
|
|
133
|
+
' - group:gtm team',
|
|
134
|
+
' - group:go to market', // target already present → dedupe, not duplicate
|
|
135
|
+
'',
|
|
136
|
+
].join('\n');
|
|
137
|
+
const r = renameGroupRefs(text, 'gtm team', 'go to market');
|
|
138
|
+
expect(r.changed).toBe(true);
|
|
139
|
+
expect(r.text).not.toContain('group:gtm team');
|
|
140
|
+
const model = parseRolesModel(r.text);
|
|
141
|
+
expect(model.find((x) => x.displayName === 'Product Team')?.members).toContain('group:go to market');
|
|
142
|
+
expect(
|
|
143
|
+
model.find((x) => x.displayName === 'Ops')?.members.filter((m) => m === 'group:go to market'),
|
|
144
|
+
).toHaveLength(1);
|
|
145
|
+
// Untouched role stays untouched.
|
|
146
|
+
expect(model.find((x) => x.displayName === 'Admin')?.members).toEqual(['razvan@bevel.software']);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('renameGroupRefs: no-op when nothing references the old name', () => {
|
|
150
|
+
const r = renameGroupRefs(BASE, 'gtm team', 'go to market');
|
|
151
|
+
expect(r.changed).toBe(false);
|
|
121
152
|
});
|
|
122
153
|
|
|
123
|
-
it('
|
|
124
|
-
|
|
154
|
+
it('renameGroupRefs rewrites EVERY matching ref in a role, collapsing to one', () => {
|
|
155
|
+
// Two differently-formatted refs to the same group: both must follow the
|
|
156
|
+
// rename (a straggler would point at the renamed-away name forever), and
|
|
157
|
+
// only one ref to the new name survives.
|
|
158
|
+
const text = 'roles:\n Ops:\n - group:gtm team\n - group:gtm team\n';
|
|
159
|
+
const r = renameGroupRefs(text, 'gtm team', 'go to market');
|
|
160
|
+
expect(r.changed).toBe(true);
|
|
161
|
+
const ops = parseRolesModel(r.text).find((x) => x.displayName === 'Ops')!;
|
|
162
|
+
expect(ops.members.filter((m) => m === 'group:go to market')).toHaveLength(1);
|
|
163
|
+
expect(ops.members.some((m) => m.includes('gtm'))).toBe(false);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('group-ref matching is canonical: an un-normalized hand-written ref still matches', () => {
|
|
167
|
+
// The resolver canonicalizes the ref's suffix (collapsing runs of
|
|
168
|
+
// spaces), so a hand-written `group:gtm team` grants — the editors must
|
|
169
|
+
// find it too, or unassign/rename no-op while the roster still shows the
|
|
170
|
+
// group. (Parse lowercases members but keeps the double space.)
|
|
171
|
+
const text = 'roles:\n Ops:\n - group:GTM Team\n';
|
|
172
|
+
const removed = removeMember(text, 'ops', 'nobody@x.io'); // sanity: parse keeps the un-collapsed ref
|
|
173
|
+
expect(removed.text).toContain('group:gtm team');
|
|
174
|
+
const renamed = renameGroupRefs(text, 'gtm team', 'go to market');
|
|
175
|
+
expect(renamed.changed).toBe(true);
|
|
176
|
+
expect(renamed.text).toContain('group:go to market');
|
|
177
|
+
expect(renamed.text).not.toContain('gtm team');
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('removeRoleGroupRef strips EVERY duplicate ref to the group, not just the first', () => {
|
|
181
|
+
// A hand-edited file may carry several differently-formatted refs to the
|
|
182
|
+
// same group. Removing only the first leaves stragglers the resolver
|
|
183
|
+
// still honors — the role silently keeps the group after an
|
|
184
|
+
// apparently-successful unassign.
|
|
185
|
+
const text = [
|
|
186
|
+
'roles:',
|
|
187
|
+
' Ops:',
|
|
188
|
+
' - lead@x.io',
|
|
189
|
+
' - group:gtm team',
|
|
190
|
+
' - group:GTM Team', // un-normalized duplicate — must go too
|
|
191
|
+
'',
|
|
192
|
+
].join('\n');
|
|
193
|
+
const r = removeRoleGroupRef(text, 'ops', 'gtm team');
|
|
194
|
+
expect(r.changed).toBe(true);
|
|
195
|
+
const ops = parseRolesModel(r.text).find((x) => x.displayName === 'Ops')!;
|
|
196
|
+
expect(ops.members).toEqual(['lead@x.io']);
|
|
197
|
+
// Idempotent afterwards.
|
|
198
|
+
expect(removeRoleGroupRef(r.text, 'ops', 'gtm team').changed).toBe(false);
|
|
199
|
+
// Other roles/refs untouched.
|
|
200
|
+
expect(() => removeRoleGroupRef(text, 'ghost', 'gtm team')).toThrow(/not found/);
|
|
125
201
|
});
|
|
126
202
|
|
|
127
203
|
it('only the changed role moves in the diff (other roles untouched)', () => {
|
|
@@ -17,10 +17,11 @@ import { createAccessRoutes } from '../access.routes.js';
|
|
|
17
17
|
|
|
18
18
|
/**
|
|
19
19
|
* HTTP-level contract tests for the /api/access/roles routes: the admin gate
|
|
20
|
-
* (non-admin → 403), the GET roster shape,
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
20
|
+
* (non-admin → 403), the GET roster shape, the REMOVED role-CRUD endpoints
|
|
21
|
+
* (404 — roles are app-defined capabilities), and that service-layer
|
|
22
|
+
* invariant errors (self-admin-removal → 409 { kind }) surface with the
|
|
23
|
+
* right status. The membership logic itself is covered by
|
|
24
|
+
* roles-admin.service.test; here we pin the route wiring + error mapping.
|
|
24
25
|
*/
|
|
25
26
|
|
|
26
27
|
const KB = 'knowledge-base';
|
|
@@ -64,7 +65,6 @@ async function makeHarness(opts: { isAdmin?: boolean } = {}): Promise<{ server:
|
|
|
64
65
|
eligibleWriters: vi.fn(async () => ({ roles: ['Admin'], users: [] })),
|
|
65
66
|
invalidate: vi.fn(),
|
|
66
67
|
validateRolesYaml: (t: string) => realAccess.validateRolesYaml(t),
|
|
67
|
-
referencesToRole: vi.fn(async () => []),
|
|
68
68
|
} as unknown as IAccessControl;
|
|
69
69
|
|
|
70
70
|
const resolve = (wsRel: string) => path.join(workspaceDir, wsRel);
|
|
@@ -103,17 +103,23 @@ async function makeHarness(opts: { isAdmin?: boolean } = {}): Promise<{ server:
|
|
|
103
103
|
|
|
104
104
|
// Lock holder tracked so the service's getLock pre-check passes; the single-
|
|
105
105
|
// file write itself lands on disk via LockingFilesystem → LocalFilesystem.
|
|
106
|
-
|
|
106
|
+
// Strict per-path locks — a live row is contended even for the same user,
|
|
107
|
+
// exactly like FileLockService (nesting bugs must fail here too).
|
|
108
|
+
const locks = new Map<string, { id: string; name: string }>();
|
|
107
109
|
const lockRow = (h: { id: string; name: string }) => ({ holderUserId: h.id, holderName: h.name });
|
|
108
110
|
const workflowService = {
|
|
109
|
-
getLock: vi.fn(async (
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
111
|
+
getLock: vi.fn(async (_w: string, _b: string, p: string) => {
|
|
112
|
+
const h = locks.get(p);
|
|
113
|
+
return h ? lockRow(h) : null;
|
|
114
|
+
}),
|
|
115
|
+
acquireLock: vi.fn(async (_w: string, _b: string, p: string, user: { id: string; name: string }) => {
|
|
116
|
+
const h = locks.get(p);
|
|
117
|
+
if (h) return { acquired: false, lock: lockRow(h) };
|
|
118
|
+
locks.set(p, user);
|
|
119
|
+
return { acquired: true, lock: lockRow(user) };
|
|
120
|
+
}),
|
|
121
|
+
releaseLock: vi.fn(async (_w: string, _b: string, p: string) => void locks.delete(p)),
|
|
122
|
+
releaseLockNoCommit: vi.fn(async (_w: string, _b: string, p: string) => void locks.delete(p)),
|
|
117
123
|
// Write-free: the lock-aware filesystem writes to disk; this just commits the
|
|
118
124
|
// dirty tree. Not exercised by these route tests (no rename), but kept honest.
|
|
119
125
|
commitChanges: vi.fn(async () => ({})),
|
|
@@ -166,35 +172,58 @@ describe('/api/access/roles routes', () => {
|
|
|
166
172
|
expect(res.status).toBe(403);
|
|
167
173
|
});
|
|
168
174
|
|
|
169
|
-
it('a non-admin is refused on
|
|
175
|
+
it('a non-admin is refused on the surviving mutating routes (403)', async () => {
|
|
170
176
|
const h = await makeHarness({ isAdmin: false });
|
|
171
177
|
server = h.server;
|
|
172
|
-
const
|
|
178
|
+
const addMember = await fetch(`${h.baseUrl}/api/access/roles/sales/members`, {
|
|
173
179
|
method: 'POST',
|
|
174
180
|
headers: { 'Content-Type': 'application/json' },
|
|
175
|
-
body: JSON.stringify({
|
|
181
|
+
body: JSON.stringify({ email: 'x@example.com' }),
|
|
176
182
|
});
|
|
177
|
-
expect(
|
|
178
|
-
const
|
|
179
|
-
expect(del.status).toBe(403);
|
|
180
|
-
});
|
|
181
|
-
|
|
182
|
-
it('create with a reserved name → 422', async () => {
|
|
183
|
-
const h = await makeHarness();
|
|
184
|
-
server = h.server;
|
|
185
|
-
const res = await fetch(`${h.baseUrl}/api/access/roles`, {
|
|
183
|
+
expect(addMember.status).toBe(403);
|
|
184
|
+
const assignGroup = await fetch(`${h.baseUrl}/api/access/roles/sales/groups`, {
|
|
186
185
|
method: 'POST',
|
|
187
186
|
headers: { 'Content-Type': 'application/json' },
|
|
188
|
-
body: JSON.stringify({
|
|
187
|
+
body: JSON.stringify({ group: 'GTM Team' }),
|
|
188
|
+
});
|
|
189
|
+
expect(assignGroup.status).toBe(403);
|
|
190
|
+
const removeMember = await fetch(
|
|
191
|
+
`${h.baseUrl}/api/access/roles/sales/members/${encodeURIComponent('felix@example.com')}`,
|
|
192
|
+
{ method: 'DELETE' },
|
|
193
|
+
);
|
|
194
|
+
expect(removeMember.status).toBe(403);
|
|
195
|
+
const unassignGroup = await fetch(
|
|
196
|
+
`${h.baseUrl}/api/access/roles/sales/groups/${encodeURIComponent('GTM Team')}`,
|
|
197
|
+
{ method: 'DELETE' },
|
|
198
|
+
);
|
|
199
|
+
expect(unassignGroup.status).toBe(403);
|
|
200
|
+
const convert = await fetch(`${h.baseUrl}/api/access/roles/sales/convert-to-group`, {
|
|
201
|
+
method: 'POST',
|
|
189
202
|
});
|
|
190
|
-
expect(
|
|
203
|
+
expect(convert.status).toBe(403);
|
|
191
204
|
});
|
|
192
205
|
|
|
193
|
-
it('delete
|
|
206
|
+
it('role CRUD endpoints are GONE: create/rename/delete answer 404 even for admins', async () => {
|
|
194
207
|
const h = await makeHarness();
|
|
195
208
|
server = h.server;
|
|
196
|
-
const
|
|
197
|
-
|
|
209
|
+
const created = await fetch(`${h.baseUrl}/api/access/roles`, {
|
|
210
|
+
method: 'POST',
|
|
211
|
+
headers: { 'Content-Type': 'application/json' },
|
|
212
|
+
body: JSON.stringify({ displayName: 'Marketing' }),
|
|
213
|
+
});
|
|
214
|
+
expect(created.status).toBe(404);
|
|
215
|
+
const renamed = await fetch(`${h.baseUrl}/api/access/roles/sales`, {
|
|
216
|
+
method: 'PATCH',
|
|
217
|
+
headers: { 'Content-Type': 'application/json' },
|
|
218
|
+
body: JSON.stringify({ newDisplayName: 'Sales Team' }),
|
|
219
|
+
});
|
|
220
|
+
expect(renamed.status).toBe(404);
|
|
221
|
+
const deleted = await fetch(`${h.baseUrl}/api/access/roles/sales`, { method: 'DELETE' });
|
|
222
|
+
expect(deleted.status).toBe(404);
|
|
223
|
+
// The roster is untouched by the dead endpoints.
|
|
224
|
+
const roster = await fetch(`${h.baseUrl}/api/access/roles`);
|
|
225
|
+
const body = (await roster.json()) as { roles: { canonical: string }[] };
|
|
226
|
+
expect(body.roles.map((r) => r.canonical)).toEqual(['admin', 'sales']);
|
|
198
227
|
});
|
|
199
228
|
|
|
200
229
|
it('removing own last Admin membership → 409 { kind: self-admin-removal }', async () => {
|
|
@@ -213,22 +242,16 @@ describe('/api/access/roles routes', () => {
|
|
|
213
242
|
expect(body.kind).toBe('self-admin-removal');
|
|
214
243
|
});
|
|
215
244
|
|
|
216
|
-
it('
|
|
245
|
+
it('add a member to an existing (legacy) role → 200 fresh roster', async () => {
|
|
217
246
|
const h = await makeHarness();
|
|
218
247
|
server = h.server;
|
|
219
|
-
const
|
|
220
|
-
method: 'POST',
|
|
221
|
-
headers: { 'Content-Type': 'application/json' },
|
|
222
|
-
body: JSON.stringify({ displayName: 'Marketing' }),
|
|
223
|
-
});
|
|
224
|
-
expect(created.status).toBe(200);
|
|
225
|
-
const add = await fetch(`${h.baseUrl}/api/access/roles/marketing/members`, {
|
|
248
|
+
const add = await fetch(`${h.baseUrl}/api/access/roles/sales/members`, {
|
|
226
249
|
method: 'POST',
|
|
227
250
|
headers: { 'Content-Type': 'application/json' },
|
|
228
251
|
body: JSON.stringify({ email: 'mkt@example.com' }),
|
|
229
252
|
});
|
|
230
253
|
expect(add.status).toBe(200);
|
|
231
254
|
const body = (await add.json()) as { roles: { canonical: string; members: string[] }[] };
|
|
232
|
-
expect(body.roles.find((r) => r.canonical === '
|
|
255
|
+
expect(body.roles.find((r) => r.canonical === 'sales')!.members).toContain('mkt@example.com');
|
|
233
256
|
});
|
|
234
257
|
});
|