@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,288 @@
|
|
|
1
|
+
import { describe, it, expect } 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 { FileTreeEntry, IWorkspaceService } from '@bevel-software/platform-shared';
|
|
7
|
+
import {
|
|
8
|
+
KbReferenceScanner,
|
|
9
|
+
findRoleRefsInText,
|
|
10
|
+
rewriteRoleTokensInText,
|
|
11
|
+
} from '../reference-scan.js';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Unit tests for the shared reference scanner's parse rules (comments,
|
|
15
|
+
* nested-mapping verb keys) and its candidate/freshness machinery (`.tool`
|
|
16
|
+
* frontmatter coverage, event-bus cache invalidation).
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
describe('findRoleRefsInText / rewriteRoleTokensInText — comment handling', () => {
|
|
20
|
+
it('a trailing comment is stripped for MATCHING (the resolver’s rule)', () => {
|
|
21
|
+
const md = '---\nread:\n - GTM Team # sales folks\n---\n';
|
|
22
|
+
// The token is `gtm team`, never `gtm team # sales folks`.
|
|
23
|
+
expect(findRoleRefsInText(md)).toEqual([{ role: 'gtm team', verb: 'read' }]);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('a trailing comment is PRESERVED by the rewrite', () => {
|
|
27
|
+
const md = '---\nread:\n - GTM Team # sales folks\nwrite:\n - deny GTM Team # blocked\n---\n';
|
|
28
|
+
const out = rewriteRoleTokensInText(md, 'gtm team', 'Go To Market');
|
|
29
|
+
expect(out).toContain(' - Go To Market # sales folks');
|
|
30
|
+
expect(out).toContain(' - deny Go To Market # blocked');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('an inline scalar with a trailing comment matches and rewrites (comment kept)', () => {
|
|
34
|
+
const md = '---\nowner: Sales # the owning team\n---\n';
|
|
35
|
+
expect(findRoleRefsInText(md)).toEqual([{ role: 'sales', verb: 'owner' }]);
|
|
36
|
+
const out = rewriteRoleTokensInText(md, 'sales', 'Revenue');
|
|
37
|
+
expect(out).toContain('owner: Revenue # the owning team');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('a full-line comment inside a block neither ends the block nor is an entry', () => {
|
|
41
|
+
const md = '---\nread:\n - Sales\n # a note between items\n - Support\n---\n';
|
|
42
|
+
expect(findRoleRefsInText(md)).toEqual([
|
|
43
|
+
{ role: 'sales', verb: 'read' },
|
|
44
|
+
{ role: 'support', verb: 'read' },
|
|
45
|
+
]);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('a verb key whose value is only a comment is not a reference', () => {
|
|
49
|
+
const md = '---\nread: # to be filled in\n - Sales\n---\n';
|
|
50
|
+
expect(findRoleRefsInText(md)).toEqual([{ role: 'sales', verb: 'read' }]);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe('findRoleRefsInText / rewriteRoleTokensInText — nested-mapping verb keys are not rules', () => {
|
|
55
|
+
// The resolver only parses verbs at the rule mapping's ROOT (indent 0) —
|
|
56
|
+
// an indented `read:` inside some unrelated config mapping must never be
|
|
57
|
+
// scanned as a rule or rewritten during a rename.
|
|
58
|
+
const md = [
|
|
59
|
+
'---',
|
|
60
|
+
'read:',
|
|
61
|
+
' - Sales',
|
|
62
|
+
'someConfig:',
|
|
63
|
+
' nested:',
|
|
64
|
+
' read:',
|
|
65
|
+
' - Sales',
|
|
66
|
+
'---',
|
|
67
|
+
'',
|
|
68
|
+
].join('\n');
|
|
69
|
+
|
|
70
|
+
it('scan skips the nested verb-looking key’s items', () => {
|
|
71
|
+
expect(findRoleRefsInText(md)).toEqual([{ role: 'sales', verb: 'read' }]);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('rename rewrites only the root-level rule, never the nested config', () => {
|
|
75
|
+
const out = rewriteRoleTokensInText(md, 'sales', 'Revenue');
|
|
76
|
+
const lines = out.split('\n');
|
|
77
|
+
expect(lines).toContain(' - Revenue'); // the real rule, rewritten
|
|
78
|
+
expect(lines).toContain(' - Sales'); // the nested config, untouched
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe('findRoleRefsInText / rewriteRoleTokensInText — indented ROOT mappings are live rules', () => {
|
|
83
|
+
// The resolver's YAML-subset parser accepts a root mapping at ANY indent
|
|
84
|
+
// (its root frame sits at indent -1), so ` read:` at column 2 with nothing
|
|
85
|
+
// enclosing it is a rule the resolver ENFORCES. A column-zero-only scanner
|
|
86
|
+
// would skip it — and a rename would strand the live grant.
|
|
87
|
+
const md = ['---', ' read:', ' - Sales', '---', ''].join('\n');
|
|
88
|
+
|
|
89
|
+
it('scan sees a uniformly-indented root verb key', () => {
|
|
90
|
+
expect(findRoleRefsInText(md)).toEqual([{ role: 'sales', verb: 'read' }]);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('rename rewrites the indented root rule, preserving indentation', () => {
|
|
94
|
+
const out = rewriteRoleTokensInText(md, 'sales', 'Revenue');
|
|
95
|
+
expect(out.split('\n')).toContain(' - Revenue');
|
|
96
|
+
expect(out).not.toContain('Sales');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('an indented root inline scalar matches and rewrites', () => {
|
|
100
|
+
const scalar = '---\n owner: Sales # team\n---\n';
|
|
101
|
+
expect(findRoleRefsInText(scalar)).toEqual([{ role: 'sales', verb: 'owner' }]);
|
|
102
|
+
expect(rewriteRoleTokensInText(scalar, 'sales', 'Revenue')).toContain(
|
|
103
|
+
' owner: Revenue # team',
|
|
104
|
+
);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('a verb key nested under an INDENTED root mapping is still not a rule', () => {
|
|
108
|
+
// `config:` owns the root at indent 2; `read:` at indent 4 is ITS nested
|
|
109
|
+
// key, not a root verb — the resolver never parses it as a rule.
|
|
110
|
+
const nested = ['---', ' config:', ' read:', ' - Sales', '---', ''].join('\n');
|
|
111
|
+
expect(findRoleRefsInText(nested)).toEqual([]);
|
|
112
|
+
expect(rewriteRoleTokensInText(nested, 'sales', 'Revenue')).toBe(nested);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('a structurally-broken region (the resolver rejects it) yields no refs and no rewrites', () => {
|
|
116
|
+
// `- Sales` at the SAME indent as its key is a parse error to the subset
|
|
117
|
+
// parser ("list item with no enclosing list") — the resolver enforces
|
|
118
|
+
// nothing from this file, so the scan must not report a live grant.
|
|
119
|
+
const broken = '---\nread:\n- Sales\n---\n';
|
|
120
|
+
expect(findRoleRefsInText(broken)).toEqual([]);
|
|
121
|
+
expect(rewriteRoleTokensInText(broken, 'sales', 'Revenue')).toBe(broken);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// ---------------------------------------------------------------------------
|
|
126
|
+
// KbReferenceScanner — candidates + freshness
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
const KB = 'knowledge-base';
|
|
130
|
+
|
|
131
|
+
function stubWorkspace(workspaceDir: string): IWorkspaceService {
|
|
132
|
+
const buildTree = async (absDir: string): Promise<FileTreeEntry> => {
|
|
133
|
+
const rel = path.relative(workspaceDir, absDir).replace(/\\/g, '/');
|
|
134
|
+
const children: FileTreeEntry[] = [];
|
|
135
|
+
for (const e of await fs.readdir(absDir, { withFileTypes: true })) {
|
|
136
|
+
if (e.name === '.git') continue;
|
|
137
|
+
const childAbs = path.join(absDir, e.name);
|
|
138
|
+
if (e.isDirectory()) children.push(await buildTree(childAbs));
|
|
139
|
+
else if (e.isFile()) {
|
|
140
|
+
children.push({
|
|
141
|
+
name: e.name,
|
|
142
|
+
relativePath: path.relative(workspaceDir, childAbs).replace(/\\/g, '/'),
|
|
143
|
+
type: 'file',
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return { name: path.basename(absDir), relativePath: rel || '.', type: 'directory', children };
|
|
148
|
+
};
|
|
149
|
+
return {
|
|
150
|
+
getWorkspacePath: async () => workspaceDir,
|
|
151
|
+
listFiles: async () => buildTree(workspaceDir),
|
|
152
|
+
} as unknown as IWorkspaceService;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function write(repo: string, rel: string, contents: string): Promise<void> {
|
|
156
|
+
const abs = path.join(repo, rel);
|
|
157
|
+
await fs.mkdir(path.dirname(abs), { recursive: true });
|
|
158
|
+
await fs.writeFile(abs, contents);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
describe('KbReferenceScanner — .tool frontmatter is scanned and rewritten like .md', () => {
|
|
162
|
+
it('finds a .tool frontmatter grant and a rename rewrites it (body untouched)', async () => {
|
|
163
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'bevel-refscan-'));
|
|
164
|
+
try {
|
|
165
|
+
const repo = path.join(root, KB);
|
|
166
|
+
const toolBody = 'name: crm\ntype: mcp\n# not frontmatter below\n';
|
|
167
|
+
await write(repo, 'Plugins/GTM/crm.tool', `---\nread:\n - GTM Team\n---\n${toolBody}`);
|
|
168
|
+
await write(repo, 'Plugins/GTM/notes.md', '---\nowner: GTM Team\n---\n# notes\n');
|
|
169
|
+
const scanner = new KbReferenceScanner(stubWorkspace(root), KB);
|
|
170
|
+
|
|
171
|
+
const byToken = await scanner.scan('ws-1');
|
|
172
|
+
const hits = (byToken.get('gtm team') ?? []).map((h) => h.path).sort();
|
|
173
|
+
expect(hits).toEqual(['Plugins/GTM/crm.tool', 'Plugins/GTM/notes.md']);
|
|
174
|
+
|
|
175
|
+
const writes = await scanner.rewriteReferences('ws-1', 'gtm team', 'Go To Market', (m) => new Error(m));
|
|
176
|
+
const toolWrite = writes.find((w) => w.repoRelativePath === 'Plugins/GTM/crm.tool')!;
|
|
177
|
+
expect(toolWrite.content).toContain(' - Go To Market');
|
|
178
|
+
// The .tool body below the frontmatter is byte-for-byte intact.
|
|
179
|
+
expect(toolWrite.content).toContain(toolBody);
|
|
180
|
+
expect(writes.some((w) => w.repoRelativePath === 'Plugins/GTM/notes.md')).toBe(true);
|
|
181
|
+
} finally {
|
|
182
|
+
await fs.rm(root, { recursive: true, force: true });
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
describe('KbReferenceScanner — event-bus cache invalidation', () => {
|
|
188
|
+
it('drops the cached scan when a scanned-extension file changes in the workspace', async () => {
|
|
189
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'bevel-refscan-bus-'));
|
|
190
|
+
try {
|
|
191
|
+
const repo = path.join(root, KB);
|
|
192
|
+
await write(repo, 'team/access.md', '---\nread:\n - Sales\n---\n');
|
|
193
|
+
const listeners: ((e: { kind: string; workspaceId?: string; path?: string }) => void)[] = [];
|
|
194
|
+
const bus = { onEmit: (l: (typeof listeners)[number]) => (listeners.push(l), () => undefined) };
|
|
195
|
+
const scanner = new KbReferenceScanner(stubWorkspace(root), KB, bus);
|
|
196
|
+
|
|
197
|
+
expect((await scanner.scan('ws-1')).get('sales')).toHaveLength(1);
|
|
198
|
+
|
|
199
|
+
// A grant lands through the share dialog (outside the admin services):
|
|
200
|
+
// the write itself, then its file-changed event.
|
|
201
|
+
await write(repo, 'team/access.md', '---\nread:\n - Sales\n - Support\n---\n');
|
|
202
|
+
// Without invalidation the cached scan (30s TTL) would still miss Support.
|
|
203
|
+
expect((await scanner.scan('ws-1')).get('support')).toBeUndefined();
|
|
204
|
+
|
|
205
|
+
for (const l of listeners) {
|
|
206
|
+
l({ kind: 'file-changed', workspaceId: 'ws-1', path: `${KB}/team/access.md` });
|
|
207
|
+
}
|
|
208
|
+
expect((await scanner.scan('ws-1')).get('support')).toHaveLength(1);
|
|
209
|
+
} finally {
|
|
210
|
+
await fs.rm(root, { recursive: true, force: true });
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('an invalidation landing MID-SCAN keeps the stale snapshot out of the cache', async () => {
|
|
215
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'bevel-refscan-race-'));
|
|
216
|
+
try {
|
|
217
|
+
const repo = path.join(root, KB);
|
|
218
|
+
await write(repo, 'team/access.md', '---\nread:\n - Sales\n---\n');
|
|
219
|
+
// The workspace stub invalidates DURING the first scan's tree listing —
|
|
220
|
+
// modelling a write that lands after the scan started reading.
|
|
221
|
+
const base = stubWorkspace(root);
|
|
222
|
+
let armed = true;
|
|
223
|
+
const ws = {
|
|
224
|
+
...base,
|
|
225
|
+
listFiles: async (id: string) => {
|
|
226
|
+
const tree = await base.listFiles(id);
|
|
227
|
+
if (armed) {
|
|
228
|
+
armed = false;
|
|
229
|
+
scanner.invalidate('ws-1');
|
|
230
|
+
}
|
|
231
|
+
return tree;
|
|
232
|
+
},
|
|
233
|
+
} as unknown as IWorkspaceService;
|
|
234
|
+
const scanner = new KbReferenceScanner(ws, KB);
|
|
235
|
+
|
|
236
|
+
// The caller still gets the snapshot it asked for...
|
|
237
|
+
expect((await scanner.scan('ws-1')).get('sales')).toHaveLength(1);
|
|
238
|
+
// ...but the snapshot must not have STUCK in the cache: the write the
|
|
239
|
+
// invalidation announced is visible on the very next scan, well inside
|
|
240
|
+
// the TTL and with no further events.
|
|
241
|
+
await write(repo, 'team/access.md', '---\nread:\n - Sales\n - Support\n---\n');
|
|
242
|
+
expect((await scanner.scan('ws-1')).get('support')).toHaveLength(1);
|
|
243
|
+
} finally {
|
|
244
|
+
await fs.rm(root, { recursive: true, force: true });
|
|
245
|
+
}
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it('ignores a scanned-extension change OUTSIDE the KB directory', async () => {
|
|
249
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'bevel-refscan-bus3-'));
|
|
250
|
+
try {
|
|
251
|
+
const repo = path.join(root, KB);
|
|
252
|
+
await write(repo, 'team/access.md', '---\nread:\n - Sales\n---\n');
|
|
253
|
+
const listeners: ((e: { kind: string; workspaceId?: string; path?: string }) => void)[] = [];
|
|
254
|
+
const bus = { onEmit: (l: (typeof listeners)[number]) => (listeners.push(l), () => undefined) };
|
|
255
|
+
const scanner = new KbReferenceScanner(stubWorkspace(root), KB, bus);
|
|
256
|
+
|
|
257
|
+
const first = await scanner.scan('ws-1');
|
|
258
|
+
for (const l of listeners) {
|
|
259
|
+
// Right extension, wrong tree: not scan material.
|
|
260
|
+
l({ kind: 'file-changed', workspaceId: 'ws-1', path: 'scratch/notes.md' });
|
|
261
|
+
}
|
|
262
|
+
expect(await scanner.scan('ws-1')).toBe(first);
|
|
263
|
+
} finally {
|
|
264
|
+
await fs.rm(root, { recursive: true, force: true });
|
|
265
|
+
}
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it('ignores events for non-scanned extensions and other kinds', async () => {
|
|
269
|
+
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'bevel-refscan-bus2-'));
|
|
270
|
+
try {
|
|
271
|
+
const repo = path.join(root, KB);
|
|
272
|
+
await write(repo, 'team/access.md', '---\nread:\n - Sales\n---\n');
|
|
273
|
+
const listeners: ((e: { kind: string; workspaceId?: string; path?: string }) => void)[] = [];
|
|
274
|
+
const bus = { onEmit: (l: (typeof listeners)[number]) => (listeners.push(l), () => undefined) };
|
|
275
|
+
const scanner = new KbReferenceScanner(stubWorkspace(root), KB, bus);
|
|
276
|
+
|
|
277
|
+
const first = await scanner.scan('ws-1');
|
|
278
|
+
for (const l of listeners) {
|
|
279
|
+
l({ kind: 'file-changed', workspaceId: 'ws-1', path: `${KB}/roles.yaml` });
|
|
280
|
+
l({ kind: 'fs-tree-changed', workspaceId: 'ws-1' });
|
|
281
|
+
}
|
|
282
|
+
// Cache retained — same map instance served.
|
|
283
|
+
expect(await scanner.scan('ws-1')).toBe(first);
|
|
284
|
+
} finally {
|
|
285
|
+
await fs.rm(root, { recursive: true, force: true });
|
|
286
|
+
}
|
|
287
|
+
});
|
|
288
|
+
});
|
|
@@ -9,7 +9,9 @@ import type { WorkflowEventBus } from '../../workflow/event-bus.js';
|
|
|
9
9
|
import type { FileTreeEntry, WorkflowEventPayload } from '@bevel-software/platform-shared';
|
|
10
10
|
import { workspaceIdForBranch } from '../../workspace/workspace.service.js';
|
|
11
11
|
import { AccessControlService } from '../access-control.service.js';
|
|
12
|
-
import { RolesAdminService
|
|
12
|
+
import { RolesAdminService } from '../roles-admin.service.js';
|
|
13
|
+
import { rewriteRoleTokensInText, findRoleRefsInText } from '../reference-scan.js';
|
|
14
|
+
import { PushNeedsAgentResolutionError } from '../../workflow/workflow.errors.js';
|
|
13
15
|
import type { AuthUser } from '@bevel-software/platform-shared';
|
|
14
16
|
import { DEFAULT_BRANCH } from '@bevel-software/platform-shared';
|
|
15
17
|
|
|
@@ -89,24 +91,30 @@ function stubWorkspace(workspaceDir: string): WorkspaceService {
|
|
|
89
91
|
function stubWorkflow() {
|
|
90
92
|
const commits: { summary: string }[] = [];
|
|
91
93
|
const resets: string[] = [];
|
|
92
|
-
/**
|
|
93
|
-
|
|
94
|
+
/** Per-path lock rows with STRICT acquire — mirrors FileLockService: a
|
|
95
|
+
* live lock is contended EVEN FOR THE SAME USER (only expired rows are
|
|
96
|
+
* reclaimed), so a service nesting a second acquire under its own hold
|
|
97
|
+
* fails here exactly like production. */
|
|
98
|
+
const locks = new Map<string, AuthUser>();
|
|
94
99
|
const lockRow = (h: AuthUser) => ({ holderUserId: h.id, holderName: h.name });
|
|
95
100
|
const svc = {
|
|
96
|
-
getLock: async (
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
101
|
+
getLock: async (_w: string, _b: string, p: string) => {
|
|
102
|
+
const h = locks.get(p);
|
|
103
|
+
return h ? lockRow(h) : null;
|
|
104
|
+
},
|
|
105
|
+
acquireLock: async (_w: string, _b: string, p: string, user: AuthUser) => {
|
|
106
|
+
const h = locks.get(p);
|
|
107
|
+
if (h) return { acquired: false, lock: lockRow(h) };
|
|
108
|
+
locks.set(p, user);
|
|
109
|
+
return { acquired: true, lock: lockRow(user) };
|
|
102
110
|
},
|
|
103
111
|
// LockingFilesystem's success path: the LocalFilesystem write already hit
|
|
104
112
|
// disk, so releasing just drops the lock (the "commit" is a no-op in-memory).
|
|
105
|
-
releaseLock: async () => {
|
|
106
|
-
|
|
113
|
+
releaseLock: async (_w: string, _b: string, p: string) => {
|
|
114
|
+
locks.delete(p);
|
|
107
115
|
},
|
|
108
|
-
releaseLockNoCommit: async () => {
|
|
109
|
-
|
|
116
|
+
releaseLockNoCommit: async (_w: string, _b: string, p: string) => {
|
|
117
|
+
locks.delete(p);
|
|
110
118
|
},
|
|
111
119
|
// The rename's atomic batch flows through LockingFilesystem.writeFiles, which
|
|
112
120
|
// writes every file to disk (real LocalFilesystem) then calls commitChanges
|
|
@@ -237,8 +245,10 @@ describe('RolesAdminService', () => {
|
|
|
237
245
|
await write(repo, 'roles.yaml', corrupt);
|
|
238
246
|
access.invalidate(WS);
|
|
239
247
|
const adminless = new RolesAdminService(ws, workflow.svc, access, KB, () => DEFAULT_BRANCH, bus.bus);
|
|
248
|
+
// 409, not 500: the route helper hides >=500 bodies behind a generic
|
|
249
|
+
// message, and this break-glass refusal must reach the operator verbatim.
|
|
240
250
|
await expect(adminless.recover(ADMIN)).rejects.toMatchObject({
|
|
241
|
-
status:
|
|
251
|
+
status: 409,
|
|
242
252
|
payload: { kind: 'no-recovery-admins' },
|
|
243
253
|
});
|
|
244
254
|
// Nothing parked, nothing overwritten.
|
|
@@ -292,42 +302,29 @@ describe('RolesAdminService', () => {
|
|
|
292
302
|
expect(await readRoles()).not.toContain('juan@bevel.software');
|
|
293
303
|
});
|
|
294
304
|
|
|
295
|
-
it('
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
expect(
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
it('rename: Admin canonical change is refused (400); casing-only allowed', async () => {
|
|
304
|
-
await expect(svc.renameRole(ADMIN, 'admin', 'Administrators')).rejects.toMatchObject({ status: 400 });
|
|
305
|
-
await svc.renameRole(ADMIN, 'admin', 'ADMIN'); // casing-only OK
|
|
306
|
-
const roster = await svc.getRoster();
|
|
307
|
-
expect(roster.find((r) => r.canonical === 'admin')!.displayName).toBe('ADMIN');
|
|
305
|
+
it('role CRUD is GONE from the service — roles are app-defined capabilities', () => {
|
|
306
|
+
// The editors were removed outright, not just un-routed: legacy roles stay
|
|
307
|
+
// membership-editable, but nothing can mint/rename/delete a role anymore.
|
|
308
|
+
const anySvc = svc as unknown as Record<string, unknown>;
|
|
309
|
+
expect(anySvc.createRole).toBeUndefined();
|
|
310
|
+
expect(anySvc.renameRole).toBeUndefined();
|
|
311
|
+
expect(anySvc.deleteRole).toBeUndefined();
|
|
308
312
|
});
|
|
309
313
|
|
|
310
|
-
it(
|
|
311
|
-
await
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
await svc.renameRole(ADMIN, 'sales', 'Sales Team');
|
|
317
|
-
|
|
318
|
-
// roles.yaml + both referencing files, all in a single commit (verified by
|
|
319
|
-
// commits.length += 1; the per-file content is asserted on disk below).
|
|
320
|
-
expect(workflow.commits.length).toBe(commitsBefore + 1);
|
|
321
|
-
expect(await fs.readFile(path.join(repo, 'Sales/access.md'), 'utf-8')).toContain('- Sales Team');
|
|
322
|
-
expect(await fs.readFile(path.join(repo, 'Sales/access.md'), 'utf-8')).toContain('- Other Team'); // untouched
|
|
323
|
-
expect(await fs.readFile(path.join(repo, 'Sales/Deal.md'), 'utf-8')).toContain('owner: Sales Team');
|
|
324
|
-
expect(await readRoles()).toContain('Sales Team:');
|
|
314
|
+
it("addMember refuses a 'group:'-prefixed member with an actionable message", async () => {
|
|
315
|
+
await expect(svc.addMember(ADMIN, 'sales', 'group:lee@x.io')).rejects.toMatchObject({
|
|
316
|
+
status: 422,
|
|
317
|
+
});
|
|
318
|
+
await expect(svc.addMember(ADMIN, 'sales', 'group:gtm team')).rejects.toThrow(/group assignment/);
|
|
319
|
+
expect(await readRoles()).not.toContain('group:');
|
|
325
320
|
});
|
|
326
321
|
|
|
327
322
|
it('LOCKOUT-IMPOSSIBILITY: the validator rejects garbage in isolation', async () => {
|
|
328
323
|
expect(access.validateRolesYaml('roles:\n : []\n').ok).toBe(false);
|
|
324
|
+
// Group-only Admin is garbage too — the kept invariant.
|
|
325
|
+
expect(access.validateRolesYaml('roles:\n Admin:\n - group:ops\n').ok).toBe(false);
|
|
329
326
|
// Happy path still keeps admins resolving (sanity baseline for the gate).
|
|
330
|
-
await svc.
|
|
327
|
+
await svc.addMember(ADMIN, 'sales', 'marketing@example.com');
|
|
331
328
|
expect(await access.canWrite(WS, 'razvan@bevel.software', 'roles.yaml')).toBe(true);
|
|
332
329
|
});
|
|
333
330
|
|
|
@@ -342,7 +339,7 @@ describe('RolesAdminService', () => {
|
|
|
342
339
|
const realValidate = access.validateRolesYaml.bind(access);
|
|
343
340
|
access.validateRolesYaml = () => ({ ok: false, errors: ['forced parse failure'] });
|
|
344
341
|
try {
|
|
345
|
-
await expect(svc.
|
|
342
|
+
await expect(svc.addMember(ADMIN, 'sales', 'x@example.com')).rejects.toMatchObject({ status: 422 });
|
|
346
343
|
} finally {
|
|
347
344
|
access.validateRolesYaml = realValidate;
|
|
348
345
|
}
|
|
@@ -351,31 +348,30 @@ describe('RolesAdminService', () => {
|
|
|
351
348
|
expect(await access.canWrite(WS, 'razvan@bevel.software', 'roles.yaml')).toBe(true);
|
|
352
349
|
});
|
|
353
350
|
|
|
354
|
-
it('
|
|
355
|
-
//
|
|
356
|
-
//
|
|
357
|
-
//
|
|
358
|
-
//
|
|
359
|
-
//
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
const
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
{ name: 'does-not-exist.md', relativePath: `${KB}/Sales/does-not-exist.md`, type: 'file' },
|
|
372
|
-
],
|
|
351
|
+
it('a post-commit push failure does NOT revert the landed edit (saved; retried later)', async () => {
|
|
352
|
+
// workflowService.commitChanges throwing PushNeedsAgentResolutionError
|
|
353
|
+
// means the COMMIT landed and only the push needs help. The old twins
|
|
354
|
+
// restored pre-edit bytes here — publishing a compensating revert of a
|
|
355
|
+
// landed commit. The shared helper must keep the new bytes and surface
|
|
356
|
+
// the typed 409 as-is.
|
|
357
|
+
const failing = workflow.svc as unknown as {
|
|
358
|
+
commitChanges: (...args: unknown[]) => Promise<unknown>;
|
|
359
|
+
};
|
|
360
|
+
const realCommit = failing.commitChanges.bind(workflow.svc);
|
|
361
|
+
failing.commitChanges = async (...args: unknown[]) => {
|
|
362
|
+
await realCommit(...args); // the commit records — it "landed"
|
|
363
|
+
throw new PushNeedsAgentResolutionError(DEFAULT_BRANCH, 'roles.yaml', 'rejected', 'conflict');
|
|
364
|
+
};
|
|
365
|
+
await expect(svc.addMember(ADMIN, 'sales', 'pushed@example.com')).rejects.toMatchObject({
|
|
366
|
+
status: 409,
|
|
367
|
+
payload: { kind: 'push-needs-resolution' },
|
|
373
368
|
});
|
|
374
|
-
|
|
375
|
-
await
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
369
|
+
// The edit SURVIVES on disk — no compensating restore of pre-edit bytes.
|
|
370
|
+
expect(await readRoles()).toContain('pushed@example.com');
|
|
371
|
+
// And the lock was released normally (a follow-up edit works).
|
|
372
|
+
failing.commitChanges = realCommit;
|
|
373
|
+
await svc.addMember(ADMIN, 'sales', 'after@example.com');
|
|
374
|
+
expect(await readRoles()).toContain('after@example.com');
|
|
379
375
|
});
|
|
380
376
|
|
|
381
377
|
it('concurrent edit is serialized — a mutation 409s while another admin holds the lock', async () => {
|
|
@@ -399,21 +395,6 @@ describe('RolesAdminService', () => {
|
|
|
399
395
|
expect(bus.events).toHaveLength(0);
|
|
400
396
|
});
|
|
401
397
|
|
|
402
|
-
it('rename emits file-changed for EVERY rewritten file (roles.yaml + refs)', async () => {
|
|
403
|
-
await write(repo, 'Sales/access.md', `---\nwrite:\n - Sales\n---\n# Sales\n`);
|
|
404
|
-
await write(repo, 'Sales/Deal.md', `---\nowner: Sales\n---\n# A deal\n`);
|
|
405
|
-
access.invalidate(WS);
|
|
406
|
-
|
|
407
|
-
await svc.renameRole(ADMIN, 'sales', 'Sales Team');
|
|
408
|
-
|
|
409
|
-
const paths = bus.events
|
|
410
|
-
.filter((e) => e.kind === 'file-changed')
|
|
411
|
-
.map((e) => (e as { path: string }).path)
|
|
412
|
-
.sort();
|
|
413
|
-
expect(paths).toEqual(
|
|
414
|
-
[`${KB}/roles.yaml`, `${KB}/Sales/access.md`, `${KB}/Sales/Deal.md`].sort(),
|
|
415
|
-
);
|
|
416
|
-
});
|
|
417
398
|
});
|
|
418
399
|
|
|
419
400
|
describe('rewriteRoleTokensInText (reference-aware, frontmatter-only)', () => {
|
|
@@ -476,6 +457,57 @@ describe('rewriteRoleTokensInText (reference-aware, frontmatter-only)', () => {
|
|
|
476
457
|
});
|
|
477
458
|
});
|
|
478
459
|
|
|
460
|
+
describe('rewriteRoleTokensInText — body-governed access.md (isAccessMd)', () => {
|
|
461
|
+
it('rewrites BODY rules and frontmatter self-rules of a body-governed access.md', () => {
|
|
462
|
+
const text = [
|
|
463
|
+
'---',
|
|
464
|
+
'write:',
|
|
465
|
+
' - Sales', // self-rule: who may edit access.md itself
|
|
466
|
+
'---',
|
|
467
|
+
'read:',
|
|
468
|
+
' - Sales', // the FOLDER rule the resolver enforces
|
|
469
|
+
' - deny Sales',
|
|
470
|
+
'write:',
|
|
471
|
+
' - Admin',
|
|
472
|
+
].join('\n');
|
|
473
|
+
const out = rewriteRoleTokensInText(text, 'sales', 'Sales Team', true, true);
|
|
474
|
+
const lines = out.split('\n');
|
|
475
|
+
expect(lines[2]).toBe(' - Sales Team'); // frontmatter self-rule
|
|
476
|
+
expect(lines[5]).toBe(' - Sales Team'); // body rule
|
|
477
|
+
expect(lines[6]).toBe(' - deny Sales Team'); // deny preserved in body
|
|
478
|
+
expect(out).toContain(' - Admin'); // other roles untouched
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
it('a LEGACY access.md (prose body) keeps its body untouched even with isAccessMd', () => {
|
|
482
|
+
const text = [
|
|
483
|
+
'---',
|
|
484
|
+
'read:',
|
|
485
|
+
' - Sales',
|
|
486
|
+
'---',
|
|
487
|
+
'# About this folder',
|
|
488
|
+
'- Sales', // prose bullet, not a rule — the body does not parse as rules
|
|
489
|
+
].join('\n');
|
|
490
|
+
const out = rewriteRoleTokensInText(text, 'sales', 'Sales Team', true, true);
|
|
491
|
+
const lines = out.split('\n');
|
|
492
|
+
expect(lines[2]).toBe(' - Sales Team'); // legacy frontmatter rules rewritten
|
|
493
|
+
expect(lines[5]).toBe('- Sales'); // prose untouched
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
it('a fence-less access.md is untouched — the resolver hard-errors it, so it is no rule source', () => {
|
|
497
|
+
const text = 'read:\n - Sales\n';
|
|
498
|
+
expect(rewriteRoleTokensInText(text, 'sales', 'Sales Team', true, true)).toBe(text);
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
it('findRoleRefsInText sees body rules of a body-governed access.md', () => {
|
|
502
|
+
const text = ['---', 'write:', ' - Admin', '---', 'read:', ' - Sales'].join('\n');
|
|
503
|
+
const refs = findRoleRefsInText(text, true, true);
|
|
504
|
+
expect(refs).toContainEqual({ role: 'sales', verb: 'read' });
|
|
505
|
+
expect(refs).toContainEqual({ role: 'admin', verb: 'write' });
|
|
506
|
+
// Without the access.md flag the body stays invisible (node files, prose).
|
|
507
|
+
expect(findRoleRefsInText(text, true, false)).not.toContainEqual({ role: 'sales', verb: 'read' });
|
|
508
|
+
});
|
|
509
|
+
});
|
|
510
|
+
|
|
479
511
|
describe('findRoleRefsInText (sound reference scan — shared with the rewrite)', () => {
|
|
480
512
|
it('reports every genuine role ref with its enclosing verb, ignoring users/prose', () => {
|
|
481
513
|
const text = [
|
|
@@ -501,3 +533,49 @@ describe('findRoleRefsInText (sound reference scan — shared with the rewrite)'
|
|
|
501
533
|
expect(findRoleRefsInText('# Doc\nread:\n - Sales\n')).toEqual([]);
|
|
502
534
|
});
|
|
503
535
|
});
|
|
536
|
+
|
|
537
|
+
describe('roster referencedBy under group shadowing', () => {
|
|
538
|
+
let root: string;
|
|
539
|
+
let repo: string;
|
|
540
|
+
let svc: RolesAdminService;
|
|
541
|
+
|
|
542
|
+
beforeEach(async () => {
|
|
543
|
+
root = await mkTmpRoot();
|
|
544
|
+
const workspaceDir = path.join(root, WS);
|
|
545
|
+
repo = path.join(workspaceDir, KB);
|
|
546
|
+
await write(repo, 'roles.yaml', ROLES);
|
|
547
|
+
const ws = stubWorkspace(workspaceDir);
|
|
548
|
+
svc = new RolesAdminService(
|
|
549
|
+
ws,
|
|
550
|
+
stubWorkflow().svc,
|
|
551
|
+
new AccessControlService(ws, KB),
|
|
552
|
+
KB,
|
|
553
|
+
() => DEFAULT_BRANCH,
|
|
554
|
+
stubEventBus().bus,
|
|
555
|
+
);
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
afterEach(async () => {
|
|
559
|
+
await fs.rm(root, { recursive: true, force: true });
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
it('a bare-token hit is the GROUP’s when a group shadows the role name; role/<name> hits stay the role’s', async () => {
|
|
563
|
+
// A group named Sales shadows the Sales role: bare tokens resolve to the
|
|
564
|
+
// GROUP, so the roles roster must not claim them as role references.
|
|
565
|
+
await write(repo, 'groups.yaml', 'groups:\n Sales:\n - pat@x.io\n');
|
|
566
|
+
await write(repo, 'team/access.md', '---\nread:\n - Sales\nwrite:\n - role/Sales\n---\n');
|
|
567
|
+
|
|
568
|
+
const sales = (await svc.getRoster()).find((r) => r.canonical === 'sales')!;
|
|
569
|
+
// Only the explicit spelling counts for the ROLE.
|
|
570
|
+
expect(sales.referencedBy).toEqual([{ path: 'team/access.md', verb: 'write' }]);
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
it('unshadowed: bare hits still count for the role (both spellings)', async () => {
|
|
574
|
+
await write(repo, 'team/access.md', '---\nread:\n - Sales\nwrite:\n - role/Sales\n---\n');
|
|
575
|
+
const sales = (await svc.getRoster()).find((r) => r.canonical === 'sales')!;
|
|
576
|
+
expect(sales.referencedBy).toEqual([
|
|
577
|
+
{ path: 'team/access.md', verb: 'read' },
|
|
578
|
+
{ path: 'team/access.md', verb: 'write' },
|
|
579
|
+
]);
|
|
580
|
+
});
|
|
581
|
+
});
|