@bevel-software/platform-core-backend 0.28.1 → 0.30.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/agent-guide/access-control.md +15 -0
- package/agent-guide/skills.md +8 -0
- package/dist/__tests__/test-repositories.d.ts +3 -0
- package/dist/__tests__/test-repositories.d.ts.map +1 -0
- package/dist/__tests__/test-repositories.js +18 -0
- package/dist/__tests__/test-repositories.js.map +1 -0
- package/dist/core/catalog-revision.d.ts.map +1 -1
- package/dist/core/catalog-revision.js +4 -2
- package/dist/core/catalog-revision.js.map +1 -1
- package/dist/core/compose-embed.d.ts +35 -0
- package/dist/core/compose-embed.d.ts.map +1 -0
- package/dist/core/compose-embed.js +14 -0
- package/dist/core/compose-embed.js.map +1 -0
- package/dist/core/core-ports.d.ts +10 -0
- package/dist/core/core-ports.d.ts.map +1 -1
- package/dist/core/core-ports.js.map +1 -1
- package/dist/core/create-core-server.d.ts.map +1 -1
- package/dist/core/create-core-server.js +8 -2
- package/dist/core/create-core-server.js.map +1 -1
- package/dist/core/create-core-services.d.ts +4 -1
- package/dist/core/create-core-services.d.ts.map +1 -1
- package/dist/core/create-core-services.js +57 -18
- package/dist/core/create-core-services.js.map +1 -1
- package/dist/core/lifecycle.d.ts +19 -6
- package/dist/core/lifecycle.d.ts.map +1 -1
- package/dist/core/lifecycle.js +17 -6
- package/dist/core/lifecycle.js.map +1 -1
- package/dist/modules/access/access-control.interface.d.ts +20 -0
- package/dist/modules/access/access-control.interface.d.ts.map +1 -1
- package/dist/modules/access/access-control.service.d.ts +68 -15
- package/dist/modules/access/access-control.service.d.ts.map +1 -1
- package/dist/modules/access/access-control.service.js +282 -58
- package/dist/modules/access/access-control.service.js.map +1 -1
- package/dist/modules/access/access.routes.d.ts.map +1 -1
- package/dist/modules/access/access.routes.js +20 -6
- package/dist/modules/access/access.routes.js.map +1 -1
- package/dist/modules/agent-instructions/shared-file-rules.d.ts +1 -1
- package/dist/modules/agent-instructions/shared-file-rules.js +1 -1
- package/dist/modules/auth/index.d.ts +15 -0
- package/dist/modules/auth/index.d.ts.map +1 -0
- package/dist/modules/auth/index.js +20 -0
- package/dist/modules/auth/index.js.map +1 -0
- package/dist/modules/diff/diff.service.d.ts.map +1 -1
- package/dist/modules/diff/diff.service.js +7 -1
- package/dist/modules/diff/diff.service.js.map +1 -1
- package/dist/modules/embed/embed.interface.d.ts +22 -0
- package/dist/modules/embed/embed.interface.d.ts.map +1 -1
- package/dist/modules/embed/embed.service.d.ts +28 -14
- package/dist/modules/embed/embed.service.d.ts.map +1 -1
- package/dist/modules/embed/embed.service.js +38 -17
- package/dist/modules/embed/embed.service.js.map +1 -1
- package/dist/modules/embed/embed.tools.d.ts.map +1 -1
- package/dist/modules/embed/embed.tools.js +10 -2
- package/dist/modules/embed/embed.tools.js.map +1 -1
- package/dist/modules/kb-fs/git-dir.d.ts +21 -0
- package/dist/modules/kb-fs/git-dir.d.ts.map +1 -0
- package/dist/modules/kb-fs/git-dir.js +58 -0
- package/dist/modules/kb-fs/git-dir.js.map +1 -0
- package/dist/modules/kb-fs/index.d.ts +9 -0
- package/dist/modules/kb-fs/index.d.ts.map +1 -0
- package/dist/modules/kb-fs/index.js +3 -0
- package/dist/modules/kb-fs/index.js.map +1 -0
- package/dist/modules/kb-fs/ref-files.d.ts +63 -0
- package/dist/modules/kb-fs/ref-files.d.ts.map +1 -0
- package/dist/modules/kb-fs/ref-files.js +249 -0
- package/dist/modules/kb-fs/ref-files.js.map +1 -0
- package/dist/modules/plugins/join-request-jobs.service.d.ts.map +1 -1
- package/dist/modules/plugins/join-request-jobs.service.js +43 -8
- package/dist/modules/plugins/join-request-jobs.service.js.map +1 -1
- package/dist/modules/plugins/teams.routes.d.ts +4 -0
- package/dist/modules/plugins/teams.routes.d.ts.map +1 -1
- package/dist/modules/plugins/teams.routes.js +9 -1
- package/dist/modules/plugins/teams.routes.js.map +1 -1
- package/dist/modules/skills/skills.contract.d.ts.map +1 -1
- package/dist/modules/skills/skills.tools.d.ts +8 -1
- package/dist/modules/skills/skills.tools.d.ts.map +1 -1
- package/dist/modules/skills/skills.tools.js +115 -11
- package/dist/modules/skills/skills.tools.js.map +1 -1
- package/dist/modules/tool-auth/internal-token.service.d.ts +1 -1
- package/dist/modules/tool-auth/tool-auth.middleware.d.ts +1 -2
- package/dist/modules/tool-auth/tool-auth.middleware.d.ts.map +1 -1
- package/dist/modules/tool-auth/tool-auth.middleware.js +1 -2
- package/dist/modules/tool-auth/tool-auth.middleware.js.map +1 -1
- package/dist/modules/tool-helpers/default-branch-write.d.ts +69 -0
- package/dist/modules/tool-helpers/default-branch-write.d.ts.map +1 -0
- package/dist/modules/tool-helpers/default-branch-write.js +63 -0
- package/dist/modules/tool-helpers/default-branch-write.js.map +1 -0
- package/dist/modules/tool-helpers/index.d.ts +1 -0
- package/dist/modules/tool-helpers/index.d.ts.map +1 -1
- package/dist/modules/tool-helpers/index.js +1 -0
- package/dist/modules/tool-helpers/index.js.map +1 -1
- package/dist/modules/tool-helpers/tool-handler.d.ts +2 -2
- package/dist/modules/tool-helpers/tool.contract.d.ts +2 -2
- package/dist/modules/workflow/file-lock.service.d.ts +27 -5
- package/dist/modules/workflow/file-lock.service.d.ts.map +1 -1
- package/dist/modules/workflow/file-lock.service.js +70 -18
- package/dist/modules/workflow/file-lock.service.js.map +1 -1
- package/dist/modules/workflow/git/git.service.d.ts +42 -0
- package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
- package/dist/modules/workflow/git/git.service.js +201 -24
- package/dist/modules/workflow/git/git.service.js.map +1 -1
- package/dist/modules/workflow/index.d.ts +8 -0
- package/dist/modules/workflow/index.d.ts.map +1 -0
- package/dist/modules/workflow/index.js +8 -0
- package/dist/modules/workflow/index.js.map +1 -0
- package/dist/modules/workflow/pending-commits.service.d.ts +2 -17
- package/dist/modules/workflow/pending-commits.service.d.ts.map +1 -1
- package/dist/modules/workflow/pending-commits.service.js +4 -24
- package/dist/modules/workflow/pending-commits.service.js.map +1 -1
- package/dist/modules/workflow/workflow-hooks.d.ts +1 -1
- package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.routes.js +20 -1
- package/dist/modules/workflow/workflow.routes.js.map +1 -1
- package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.service.js +5 -3
- package/dist/modules/workflow/workflow.service.js.map +1 -1
- package/dist/modules/workspace/agent-access.gate.d.ts +2 -2
- package/dist/modules/workspace/agent-access.gate.js +2 -2
- package/dist/modules/workspace/bounded-matcher.d.ts +79 -0
- package/dist/modules/workspace/bounded-matcher.d.ts.map +1 -0
- package/dist/modules/workspace/bounded-matcher.js +261 -0
- package/dist/modules/workspace/bounded-matcher.js.map +1 -0
- package/dist/modules/workspace/fetch-instant.d.ts +9 -0
- package/dist/modules/workspace/fetch-instant.d.ts.map +1 -0
- package/dist/modules/workspace/fetch-instant.js +13 -0
- package/dist/modules/workspace/fetch-instant.js.map +1 -0
- package/dist/modules/workspace/index.d.ts +17 -0
- package/dist/modules/workspace/index.d.ts.map +1 -0
- package/dist/modules/workspace/index.js +16 -0
- package/dist/modules/workspace/index.js.map +1 -0
- package/dist/modules/workspace/kb-repository.contract.d.ts +124 -0
- package/dist/modules/workspace/kb-repository.contract.d.ts.map +1 -0
- package/dist/modules/workspace/kb-repository.contract.js +19 -0
- package/dist/modules/workspace/kb-repository.contract.js.map +1 -0
- package/dist/modules/workspace/kb-repository.d.ts +236 -0
- package/dist/modules/workspace/kb-repository.d.ts.map +1 -0
- package/dist/modules/workspace/kb-repository.js +811 -0
- package/dist/modules/workspace/kb-repository.js.map +1 -0
- package/dist/modules/workspace/pattern-matcher.contract.d.ts +50 -0
- package/dist/modules/workspace/pattern-matcher.contract.d.ts.map +1 -0
- package/dist/modules/workspace/pattern-matcher.contract.js +27 -0
- package/dist/modules/workspace/pattern-matcher.contract.js.map +1 -0
- package/dist/modules/workspace/routine-write-policy.d.ts +0 -7
- package/dist/modules/workspace/routine-write-policy.d.ts.map +1 -1
- package/dist/modules/workspace/routine-write-policy.js +0 -5
- package/dist/modules/workspace/routine-write-policy.js.map +1 -1
- package/dist/modules/workspace/set-aside-clone.d.ts +40 -0
- package/dist/modules/workspace/set-aside-clone.d.ts.map +1 -1
- package/dist/modules/workspace/set-aside-clone.js +139 -0
- package/dist/modules/workspace/set-aside-clone.js.map +1 -1
- package/dist/modules/workspace/startup/kb-startup-runner.d.ts +99 -0
- package/dist/modules/workspace/startup/kb-startup-runner.d.ts.map +1 -1
- package/dist/modules/workspace/startup/kb-startup-runner.js +407 -29
- package/dist/modules/workspace/startup/kb-startup-runner.js.map +1 -1
- package/dist/modules/workspace/workspace.service.d.ts +68 -32
- package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.service.js +240 -161
- package/dist/modules/workspace/workspace.service.js.map +1 -1
- package/dist/modules/workspace/workspace.tools.d.ts +13 -4
- package/dist/modules/workspace/workspace.tools.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.tools.js +268 -356
- package/dist/modules/workspace/workspace.tools.js.map +1 -1
- package/dist/shared/workspace-id.d.ts +17 -0
- package/dist/shared/workspace-id.d.ts.map +1 -1
- package/dist/shared/workspace-id.js +24 -0
- package/dist/shared/workspace-id.js.map +1 -1
- package/dist/test-setup.js +4 -0
- package/dist/test-setup.js.map +1 -1
- package/mcp-app/page.html +142 -8
- package/package.json +3 -3
- package/src/__tests__/test-repositories.ts +22 -0
- package/src/__tests__/workspace-service.ts +69 -0
- package/src/core/__tests__/compose-embed.test.ts +65 -0
- package/src/core/__tests__/lifecycle.test.ts +45 -26
- package/src/core/catalog-revision.ts +4 -2
- package/src/core/compose-embed.ts +50 -0
- package/src/core/core-ports.ts +10 -0
- package/src/core/create-core-server.ts +8 -2
- package/src/core/create-core-services.ts +63 -24
- package/src/core/lifecycle.ts +34 -12
- package/src/modules/access/__tests__/access-control.atref-cache.test.ts +443 -25
- package/src/modules/access/__tests__/access-control.group-read.test.ts +73 -0
- package/src/modules/access/__tests__/access.routes.group-grant.test.ts +66 -0
- package/src/modules/access/__tests__/access.routes.unknown-email.test.ts +2 -0
- package/src/modules/access/access-control.interface.ts +22 -0
- package/src/modules/access/access-control.service.ts +302 -58
- package/src/modules/access/access.routes.ts +22 -6
- package/src/modules/agent-instructions/shared-file-rules.ts +1 -1
- package/src/modules/auth/index.ts +29 -0
- package/src/modules/diff/__tests__/diff.service.seed-atomicity.test.ts +2 -1
- package/src/modules/diff/__tests__/diff.service.test.ts +2 -1
- package/src/modules/diff/__tests__/diff.service.worktree.test.ts +76 -0
- package/src/modules/diff/diff.service.ts +6 -4
- package/src/modules/embed/__tests__/embed.service.test.ts +76 -3
- package/src/modules/embed/__tests__/embed.tools.test.ts +29 -0
- package/src/modules/embed/embed.interface.ts +24 -0
- package/src/modules/embed/embed.service.ts +44 -19
- package/src/modules/embed/embed.tools.ts +10 -2
- package/src/modules/kb-fs/__tests__/git-dir.test.ts +77 -0
- package/src/modules/kb-fs/__tests__/ref-files.test.ts +338 -0
- package/src/modules/kb-fs/git-dir.ts +53 -0
- package/src/modules/kb-fs/index.ts +8 -0
- package/src/modules/kb-fs/ref-files.ts +279 -0
- package/src/modules/plugins/__tests__/join-request-jobs.service.test.ts +73 -6
- package/src/modules/plugins/__tests__/teams.routes.test.ts +55 -0
- package/src/modules/plugins/join-request-jobs.service.ts +45 -8
- package/src/modules/plugins/teams.routes.ts +14 -1
- package/src/modules/settings/__tests__/setup.routes.layout-phase.test.ts +2 -2
- package/src/modules/settings/__tests__/setup.routes.managed-phase.test.ts +2 -2
- package/src/modules/skills/__tests__/branch-skills.tools.test.ts +1 -1
- package/src/modules/skills/skills.contract.ts +4 -0
- package/src/modules/skills/skills.tools.ts +120 -10
- package/src/modules/tool-auth/internal-token.service.ts +1 -1
- package/src/modules/tool-auth/tool-auth.middleware.ts +1 -2
- package/src/modules/tool-helpers/__tests__/branch-declaration.test.ts +2 -2
- package/src/modules/tool-helpers/__tests__/can-write.tools.test.ts +581 -0
- package/src/modules/tool-helpers/default-branch-write.ts +90 -0
- package/src/modules/tool-helpers/index.ts +5 -0
- package/src/modules/tool-helpers/tool-handler.ts +2 -2
- package/src/modules/tool-helpers/tool.contract.ts +2 -2
- package/src/modules/tool-registry/__tests__/tool-description-length.test.ts +42 -3
- package/src/modules/workflow/__tests__/fake-file-lock-db.ts +13 -2
- package/src/modules/workflow/__tests__/workflow.routes.lock-path.test.ts +168 -1
- package/src/modules/workflow/__tests__/workflow.service.delete-branch.test.ts +102 -6
- package/src/modules/workflow/file-lock.service.ts +79 -18
- package/src/modules/workflow/git/__tests__/git.service.changedPathsMemo.test.ts +175 -0
- package/src/modules/workflow/git/__tests__/git.service.readFileAtRef.test.ts +42 -0
- package/src/modules/workflow/git/__tests__/git.service.worktrees.test.ts +284 -0
- package/src/modules/workflow/git/git.service.ts +207 -22
- package/src/modules/workflow/index.ts +7 -0
- package/src/modules/workflow/pending-commits.service.ts +4 -23
- package/src/modules/workflow/workflow-hooks.ts +1 -1
- package/src/modules/workflow/workflow.routes.ts +24 -1
- package/src/modules/workflow/workflow.service.ts +3 -2
- package/src/modules/workspace/__tests__/agent-downloads.test.ts +2 -2
- package/src/modules/workspace/__tests__/bounded-matcher.test.ts +213 -0
- package/src/modules/workspace/__tests__/git-internals.security.test.ts +22 -7
- package/src/modules/workspace/__tests__/kb-repository.test.ts +481 -0
- package/src/modules/workspace/__tests__/rest-tool-arguments.test.ts +14 -0
- package/src/modules/workspace/__tests__/routine-write-policy.test.ts +0 -11
- package/src/modules/workspace/__tests__/workspace-paths-inside-the-repo.test.ts +2 -2
- package/src/modules/workspace/__tests__/workspace.routes.folder-zip-access.test.ts +2 -2
- package/src/modules/workspace/__tests__/workspace.routes.folders.test.ts +4 -3
- package/src/modules/workspace/__tests__/workspace.service.any-workspace-credentials.test.ts +2 -2
- package/src/modules/workspace/__tests__/workspace.service.ensure-remotes-fetched.test.ts +2 -2
- package/src/modules/workspace/__tests__/workspace.service.forget-clone-races.test.ts +12 -5
- package/src/modules/workspace/__tests__/workspace.service.list-cloned.test.ts +6 -6
- package/src/modules/workspace/__tests__/workspace.service.move-never-overwrites.test.ts +2 -1
- package/src/modules/workspace/__tests__/workspace.service.read-filter.test.ts +2 -1
- package/src/modules/workspace/__tests__/workspace.service.replaced-repository.test.ts +2 -1
- package/src/modules/workspace/__tests__/workspace.service.test.ts +24 -23
- package/src/modules/workspace/__tests__/workspace.service.unknown-branch.test.ts +2 -1
- package/src/modules/workspace/__tests__/workspace.service.worktrees.test.ts +620 -0
- package/src/modules/workspace/__tests__/workspace.tools.agents-file.test.ts +15 -1
- package/src/modules/workspace/__tests__/workspace.tools.branch-errors.test.ts +71 -5
- package/src/modules/workspace/__tests__/workspace.tools.test.ts +174 -309
- package/src/modules/workspace/agent-access.gate.ts +2 -2
- package/src/modules/workspace/bounded-matcher.ts +328 -0
- package/src/modules/workspace/fetch-instant.ts +13 -0
- package/src/modules/workspace/index.ts +26 -0
- package/src/modules/workspace/kb-repository.contract.ts +124 -0
- package/src/modules/workspace/kb-repository.ts +844 -0
- package/src/modules/workspace/pattern-matcher.contract.ts +69 -0
- package/src/modules/workspace/routine-write-policy.ts +0 -15
- package/src/modules/workspace/set-aside-clone.ts +140 -0
- package/src/modules/workspace/startup/__tests__/kb-startup-runner.degraded.test.ts +3 -2
- package/src/modules/workspace/startup/__tests__/kb-startup-runner.test.ts +12 -10
- package/src/modules/workspace/startup/__tests__/kb-startup-runner.worktrees.test.ts +613 -0
- package/src/modules/workspace/startup/kb-startup-runner.ts +422 -29
- package/src/modules/workspace/startup/steps/__tests__/steps.test.ts +4 -3
- package/src/modules/workspace/workspace.service.ts +247 -169
- package/src/modules/workspace/workspace.tools.ts +299 -385
- package/src/shared/workspace-id.ts +24 -0
- package/src/test-setup.ts +5 -0
|
@@ -18,11 +18,16 @@ import { AccessUnreadableError } from '../../access-model/access-errors.js';
|
|
|
18
18
|
* the process boundary, which is the cost this cache exists to remove.
|
|
19
19
|
* `injected.failNext` makes ONE matching spawn fail the way a transient git
|
|
20
20
|
* error does, so the cache's behaviour under a failed read can be pinned
|
|
21
|
-
* without a flaky fixture.
|
|
21
|
+
* without a flaky fixture. `injected.rewriteNext` lets ONE matching spawn
|
|
22
|
+
* run for real and rewrites what it printed before the service reads it,
|
|
23
|
+
* for replies a healthy git cannot be made to give.
|
|
22
24
|
*/
|
|
23
25
|
const { spawnLog, injected } = vi.hoisted(() => ({
|
|
24
26
|
spawnLog: [] as string[][],
|
|
25
|
-
injected: {
|
|
27
|
+
injected: {
|
|
28
|
+
failNext: null as null | ((argv: string[]) => boolean),
|
|
29
|
+
rewriteNext: null as null | ((argv: string[]) => null | ((stdout: Buffer, stdin: string) => Buffer)),
|
|
30
|
+
},
|
|
26
31
|
}));
|
|
27
32
|
|
|
28
33
|
vi.mock('node:child_process', async (importOriginal) => {
|
|
@@ -64,6 +69,41 @@ vi.mock('node:child_process', async (importOriginal) => {
|
|
|
64
69
|
});
|
|
65
70
|
return child as unknown as ReturnType<typeof actual.spawn>;
|
|
66
71
|
};
|
|
72
|
+
type Child = ReturnType<typeof actual.spawn>;
|
|
73
|
+
const realSpawn = actual.spawn as unknown as (...a: unknown[]) => Child;
|
|
74
|
+
/**
|
|
75
|
+
* The real child, with what it was fed on stdin recorded and its stdout
|
|
76
|
+
* handed to `fn` whole, once it has exited, before the service sees it.
|
|
77
|
+
*/
|
|
78
|
+
const rewriting = (file: string, args: string[], options: unknown, fn: (stdout: Buffer, stdin: string) => Buffer) => {
|
|
79
|
+
const real = realSpawn(file, args, options);
|
|
80
|
+
const fed: Buffer[] = [];
|
|
81
|
+
const out: Buffer[] = [];
|
|
82
|
+
const child = Object.assign(new EventEmitter(), {
|
|
83
|
+
pid: real.pid,
|
|
84
|
+
exitCode: null as number | null,
|
|
85
|
+
signalCode: null,
|
|
86
|
+
stdin: Object.assign(new EventEmitter(), {
|
|
87
|
+
write: (chunk: string | Buffer) => {
|
|
88
|
+
fed.push(Buffer.from(chunk));
|
|
89
|
+
return real.stdin!.write(chunk);
|
|
90
|
+
},
|
|
91
|
+
end: () => real.stdin!.end(),
|
|
92
|
+
}),
|
|
93
|
+
stdout: new PassThrough(),
|
|
94
|
+
stderr: new PassThrough(),
|
|
95
|
+
kill: (signal?: NodeJS.Signals | number) => real.kill(signal),
|
|
96
|
+
});
|
|
97
|
+
real.stdout!.on('data', (chunk: Buffer) => out.push(chunk));
|
|
98
|
+
real.stderr!.pipe(child.stderr);
|
|
99
|
+
real.on('error', (err) => child.emit('error', err));
|
|
100
|
+
real.on('close', (code, signal) => {
|
|
101
|
+
child.stdout.end(fn(Buffer.concat(out), Buffer.concat(fed).toString('utf-8')));
|
|
102
|
+
child.exitCode = code;
|
|
103
|
+
child.emit('close', code, signal);
|
|
104
|
+
});
|
|
105
|
+
return child as unknown as Child;
|
|
106
|
+
};
|
|
67
107
|
const spawn = ((file: string, args: string[], options: unknown) => {
|
|
68
108
|
if (file === 'git') {
|
|
69
109
|
spawnLog.push(args ?? []);
|
|
@@ -71,8 +111,13 @@ vi.mock('node:child_process', async (importOriginal) => {
|
|
|
71
111
|
injected.failNext = null;
|
|
72
112
|
return failing();
|
|
73
113
|
}
|
|
114
|
+
const rewrite = injected.rewriteNext?.(args);
|
|
115
|
+
if (rewrite) {
|
|
116
|
+
injected.rewriteNext = null;
|
|
117
|
+
return rewriting(file, args, options, rewrite);
|
|
118
|
+
}
|
|
74
119
|
}
|
|
75
|
-
return (
|
|
120
|
+
return realSpawn(file, args, options);
|
|
76
121
|
}) as unknown as typeof actual.spawn;
|
|
77
122
|
return { ...actual, execFile, spawn };
|
|
78
123
|
});
|
|
@@ -84,20 +129,63 @@ function lsTreeBuilds(): number {
|
|
|
84
129
|
return spawnLog.filter((argv) => argv.includes('ls-tree')).length;
|
|
85
130
|
}
|
|
86
131
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
132
|
+
/** Ref resolutions through git: none, since the ref files answer. */
|
|
133
|
+
function revParses(): number {
|
|
134
|
+
return spawnLog.filter((argv) => argv.includes('rev-parse')).length;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Batched reads of a commit's access tree: one per build. */
|
|
138
|
+
function catFileBatches(): number {
|
|
139
|
+
return spawnLog.filter((argv) => argv.includes('cat-file')).length;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Single-file reads of the MODEL's files at a ref (roles, groups, any
|
|
144
|
+
* access.md, any plugin manifest): none, since the build reads them in one
|
|
145
|
+
* batch. The one `show` a single-path gate makes afterwards, for the asked
|
|
146
|
+
* file's own frontmatter, is not the model's and is not counted.
|
|
147
|
+
*/
|
|
148
|
+
function fileShows(): number {
|
|
149
|
+
return spawnLog.filter(
|
|
150
|
+
(argv) =>
|
|
151
|
+
argv.includes('show') &&
|
|
152
|
+
argv.some((a) => /:(roles\.yaml|groups\.yaml|synced-groups\.yaml|(.*\/)?access\.md|.*\/plugin\.json)$/.test(a)),
|
|
153
|
+
).length;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Rewrite ONE record of a `git cat-file --batch` reply — the one answering
|
|
158
|
+
* the stdin spec `which` matches — as `<spec> missing`, or as the same
|
|
159
|
+
* object reported with type `tree`. The reply is index-aligned with the
|
|
160
|
+
* specs fed on stdin, each record `<oid> <type> <size>\n<size bytes>\n` or
|
|
161
|
+
* `<spec> missing\n`, so the records are walked by the specs.
|
|
162
|
+
*/
|
|
163
|
+
function rewriteBatchRecord(which: RegExp, as: 'missing' | 'tree') {
|
|
164
|
+
return (stdout: Buffer, stdin: string): Buffer => {
|
|
165
|
+
const parts: Buffer[] = [];
|
|
166
|
+
let off = 0;
|
|
167
|
+
for (const spec of stdin.split('\n').filter(Boolean)) {
|
|
168
|
+
const nl = stdout.indexOf(0x0a, off);
|
|
169
|
+
const header = stdout.subarray(off, nl).toString('utf-8');
|
|
170
|
+
const end = header.endsWith(' missing') ? nl + 1 : nl + 1 + Number(header.split(' ')[2]) + 1;
|
|
171
|
+
if (!which.test(spec)) parts.push(stdout.subarray(off, end));
|
|
172
|
+
else if (as === 'missing') parts.push(Buffer.from(`${spec} missing\n`));
|
|
173
|
+
else parts.push(Buffer.from(header.replace(' blob ', ' tree ')), stdout.subarray(nl, end));
|
|
174
|
+
off = end;
|
|
175
|
+
}
|
|
176
|
+
return Buffer.concat(parts);
|
|
177
|
+
};
|
|
90
178
|
}
|
|
91
179
|
|
|
92
180
|
/**
|
|
93
181
|
* The at-ref access model — `canWriteAtRef` / `canWriteBatchAtRef` /
|
|
94
182
|
* `eligibleWritersForPathsAtRef`, the gates every change-request read and
|
|
95
183
|
* every approval click run — is a pure function of the tree at the commit
|
|
96
|
-
* the ref resolves to. Building it costs one `ls-tree` plus
|
|
97
|
-
*
|
|
98
|
-
* gate call that lands on that commit,
|
|
99
|
-
* still seen on the very next call, and a
|
|
100
|
-
* is never pinned.
|
|
184
|
+
* the ref resolves to. Building it costs two git calls, one `ls-tree` plus
|
|
185
|
+
* one `cat-file --batch` for every file the model reads, so it must be built
|
|
186
|
+
* ONCE per commit and shared by every gate call that lands on that commit,
|
|
187
|
+
* while a push that moves the ref is still seen on the very next call, and a
|
|
188
|
+
* build that saw a git read fail is never pinned.
|
|
101
189
|
*
|
|
102
190
|
* Every test seeds its own bare origin, workspace clone, and second clone
|
|
103
191
|
* (`other`, standing in for another user's push), so each one holds alone.
|
|
@@ -107,9 +195,12 @@ describe('AccessControlService — at-ref model cache', () => {
|
|
|
107
195
|
let repo: string;
|
|
108
196
|
let other: string;
|
|
109
197
|
let svc: AccessControlService;
|
|
198
|
+
let stub: WorkspaceService;
|
|
110
199
|
const workspaceId = 'ws-atref-cache';
|
|
111
200
|
const admin = 'razvan@bevel.software';
|
|
112
201
|
const DOC = 'Team/Doc.md';
|
|
202
|
+
const QUOTED_DIR = 'Équipe';
|
|
203
|
+
const QUOTED_DOC = `${QUOTED_DIR}/Doc.md`;
|
|
113
204
|
|
|
114
205
|
async function git(cwd: string, ...args: string[]): Promise<string> {
|
|
115
206
|
const { stdout } = await execFileAsync('git', ['-C', cwd, ...args], { encoding: 'utf-8' });
|
|
@@ -137,6 +228,14 @@ describe('AccessControlService — at-ref model cache', () => {
|
|
|
137
228
|
await fs.writeFile(path.join(repo, 'access.md'), '---\nwrite:\n - Admin\n---\n');
|
|
138
229
|
await fs.writeFile(path.join(repo, 'Team/access.md'), '---\nwrite:\n - Admin\n---\n');
|
|
139
230
|
await fs.writeFile(path.join(repo, DOC), '# doc\n');
|
|
231
|
+
// A folder git would C-quote in its default listing, with an access.md
|
|
232
|
+
// that DENIES the admin: read as it is, or the denial is missed.
|
|
233
|
+
await fs.mkdir(path.join(repo, QUOTED_DIR), { recursive: true });
|
|
234
|
+
await fs.writeFile(path.join(repo, QUOTED_DIR, 'access.md'), '---\nwrite:\n - deny Admin\n---\n');
|
|
235
|
+
await fs.writeFile(path.join(repo, QUOTED_DOC), '# doc\n');
|
|
236
|
+
// A plugin, whose manifest the model reads too.
|
|
237
|
+
await fs.mkdir(path.join(repo, 'Plugins/Demo'), { recursive: true });
|
|
238
|
+
await fs.writeFile(path.join(repo, 'Plugins/Demo/plugin.json'), '{ "name": "Demo" }\n');
|
|
140
239
|
await execFileAsync('git', ['init', '-b', 'main', repo]);
|
|
141
240
|
await git(repo, 'config', 'user.email', 'test@example.com');
|
|
142
241
|
await git(repo, 'config', 'user.name', 'Test');
|
|
@@ -149,7 +248,7 @@ describe('AccessControlService — at-ref model cache', () => {
|
|
|
149
248
|
await git(other, 'config', 'user.email', 'other@example.com');
|
|
150
249
|
await git(other, 'config', 'user.name', 'Other');
|
|
151
250
|
|
|
152
|
-
|
|
251
|
+
stub = {
|
|
153
252
|
getWorkspacePath: async () => workspaceDir,
|
|
154
253
|
// The real service TTLs this at 30s; the stub always fetches so a push
|
|
155
254
|
// from `other` is visible on the next gate call.
|
|
@@ -160,10 +259,12 @@ describe('AccessControlService — at-ref model cache', () => {
|
|
|
160
259
|
svc = new AccessControlService(stub, PROCESS_MAP_DIR, new NodeFs());
|
|
161
260
|
spawnLog.length = 0;
|
|
162
261
|
injected.failNext = null;
|
|
262
|
+
injected.rewriteNext = null;
|
|
163
263
|
});
|
|
164
264
|
|
|
165
265
|
afterEach(async () => {
|
|
166
266
|
injected.failNext = null;
|
|
267
|
+
injected.rewriteNext = null;
|
|
167
268
|
// Spies are restored here, not at the end of the test that made them:
|
|
168
269
|
// a failing assertion would otherwise leave a console.warn mock in place
|
|
169
270
|
// for every test after it (this config sets no `restoreMocks`).
|
|
@@ -233,10 +334,13 @@ describe('AccessControlService — at-ref model cache', () => {
|
|
|
233
334
|
const map = await svc.eligibleWritersForPathsAtRef(workspaceId, 'feature', [DOC]);
|
|
234
335
|
expect(map).not.toBeNull();
|
|
235
336
|
expect(map!.get(DOC)!.roles).toContain('Admin');
|
|
236
|
-
|
|
237
|
-
//
|
|
337
|
+
// Two commits were read: the local tip, found to carry no roles.yaml,
|
|
338
|
+
// then origin/feature's.
|
|
339
|
+
expect(lsTreeBuilds()).toBe(2);
|
|
340
|
+
// ...and both answers are remembered: the fallthrough result under
|
|
341
|
+
// origin/feature's commit, the local tip as a commit with no roles.
|
|
238
342
|
expect(await svc.canWriteAtRef(workspaceId, 'feature', admin, DOC)).toBe(true);
|
|
239
|
-
expect(lsTreeBuilds()).toBe(
|
|
343
|
+
expect(lsTreeBuilds()).toBe(2);
|
|
240
344
|
});
|
|
241
345
|
|
|
242
346
|
it('refuses to decide from a build that lost a git read, and never caches it', async () => {
|
|
@@ -246,18 +350,19 @@ describe('AccessControlService — at-ref model cache', () => {
|
|
|
246
350
|
fs.writeFile(path.join(other, 'Team/access.md'), '---\nwrite:\n - deny Admin\n---\n'),
|
|
247
351
|
);
|
|
248
352
|
|
|
249
|
-
// Lose
|
|
250
|
-
// gate must not answer from
|
|
251
|
-
// lost file denies): it fails closed with an
|
|
252
|
-
|
|
353
|
+
// Lose the batched read of the tree, once, to a simulated transient
|
|
354
|
+
// failure. The gate must not answer from a partial tree (which would
|
|
355
|
+
// grant what the lost file denies): it fails closed with an
|
|
356
|
+
// AccessUnreadableError...
|
|
357
|
+
injected.failNext = (argv) => argv.includes('cat-file');
|
|
253
358
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
|
254
359
|
await expect(svc.canWriteAtRef(workspaceId, 'origin/main', admin, DOC)).rejects.toBeInstanceOf(
|
|
255
360
|
AccessUnreadableError,
|
|
256
361
|
);
|
|
257
362
|
expect(injected.failNext).toBeNull();
|
|
258
363
|
expect(lsTreeBuilds()).toBe(1);
|
|
259
|
-
// ...and says so in the logs
|
|
260
|
-
expect(warn).toHaveBeenCalledWith(expect.stringMatching(/
|
|
364
|
+
// ...and says so in the logs.
|
|
365
|
+
expect(warn).toHaveBeenCalledWith(expect.stringMatching(/access tree failed; refusing to decide/));
|
|
261
366
|
|
|
262
367
|
// Nothing was pinned: the next call rebuilds, answers correctly, and
|
|
263
368
|
// that healthy build is the one that gets cached.
|
|
@@ -267,7 +372,7 @@ describe('AccessControlService — at-ref model cache', () => {
|
|
|
267
372
|
expect(lsTreeBuilds()).toBe(2);
|
|
268
373
|
});
|
|
269
374
|
|
|
270
|
-
it('
|
|
375
|
+
it('remembers a commit with no roles.yaml, and keeps answering null for it without another read', async () => {
|
|
271
376
|
await git(other, 'checkout', '-qb', 'bare');
|
|
272
377
|
await git(other, 'rm', '-q', 'roles.yaml');
|
|
273
378
|
await git(other, 'commit', '-qm', 'no roles here');
|
|
@@ -276,9 +381,322 @@ describe('AccessControlService — at-ref model cache', () => {
|
|
|
276
381
|
spawnLog.length = 0;
|
|
277
382
|
expect(await svc.canWriteAtRef(workspaceId, 'origin/bare', admin, DOC)).toBeNull();
|
|
278
383
|
expect(await svc.canWriteAtRef(workspaceId, 'origin/bare', admin, DOC)).toBeNull();
|
|
279
|
-
//
|
|
280
|
-
//
|
|
281
|
-
expect(
|
|
384
|
+
// Git said "missing" for roles.yaml at that commit once, which is as
|
|
385
|
+
// final as the commit; the second call read nothing.
|
|
386
|
+
expect(lsTreeBuilds()).toBe(1);
|
|
387
|
+
expect(catFileBatches()).toBe(1);
|
|
388
|
+
expect(fileShows()).toBe(0);
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
it('reads the whole access tree at a commit in two git calls, never one per file', async () => {
|
|
392
|
+
// The seed holds roles.yaml and two access.md files; a read per file was
|
|
393
|
+
// what the build used to cost, and what a server forking a large heap
|
|
394
|
+
// cannot afford.
|
|
395
|
+
// One listing and one batch build the model; a second batch reads the
|
|
396
|
+
// asked path's own entries, once per commit and path (see the memo test).
|
|
397
|
+
expect(await svc.canWriteAtRef(workspaceId, 'origin/main', admin, DOC)).toBe(true);
|
|
398
|
+
expect(lsTreeBuilds()).toBe(1);
|
|
399
|
+
expect(catFileBatches()).toBe(2);
|
|
400
|
+
expect(fileShows()).toBe(0);
|
|
401
|
+
// Both access files were read through the batch: Team's denial is seen.
|
|
402
|
+
await pushFromOther('deny Admin in Team', () =>
|
|
403
|
+
fs.writeFile(path.join(other, 'Team/access.md'), '---\nwrite:\n - deny Admin\n---\n'),
|
|
404
|
+
);
|
|
405
|
+
expect(await svc.canWriteAtRef(workspaceId, 'origin/main', admin, DOC)).toBe(false);
|
|
406
|
+
expect(lsTreeBuilds()).toBe(2);
|
|
407
|
+
expect(catFileBatches()).toBe(4);
|
|
408
|
+
expect(fileShows()).toBe(0);
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
it('honours an access.md under a folder git would quote in its listing', async () => {
|
|
412
|
+
// `git ls-tree` prints "\303\211quipe/access.md" unless told `-z`; read
|
|
413
|
+
// under that spelling nothing comes back, and a denial would be missed.
|
|
414
|
+
expect(await svc.canWriteAtRef(workspaceId, 'origin/main', admin, QUOTED_DOC)).toBe(false);
|
|
415
|
+
expect(await svc.canWriteBatchAtRef(workspaceId, 'origin/main', admin, [QUOTED_DOC, DOC])).toEqual(
|
|
416
|
+
new Map([
|
|
417
|
+
[QUOTED_DOC, false],
|
|
418
|
+
[DOC, true],
|
|
419
|
+
]),
|
|
420
|
+
);
|
|
421
|
+
expect(lsTreeBuilds()).toBe(1);
|
|
422
|
+
expect(fileShows()).toBe(0);
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
it('answers null for a ref whose roles.yaml is a directory, and never falls through to origin for it', async () => {
|
|
426
|
+
// `feature` on origin grants the admin; the LOCAL `feature` replaced
|
|
427
|
+
// roles.yaml with a directory of that name. That is malformed config, as
|
|
428
|
+
// a roles file that does not parse is: the gate answers null for the
|
|
429
|
+
// ref, rather than reading the next candidate and granting from it.
|
|
430
|
+
await git(repo, 'checkout', '-q', '-b', 'feature');
|
|
431
|
+
await git(repo, 'push', '-q', '-u', 'origin', 'feature');
|
|
432
|
+
await git(repo, 'rm', '-q', 'roles.yaml');
|
|
433
|
+
await fs.mkdir(path.join(repo, 'roles.yaml'));
|
|
434
|
+
await fs.writeFile(path.join(repo, 'roles.yaml/README.md'), 'not roles\n');
|
|
435
|
+
await git(repo, 'add', '-A');
|
|
436
|
+
await git(repo, 'commit', '-qm', 'roles.yaml is a folder now');
|
|
437
|
+
|
|
438
|
+
spawnLog.length = 0;
|
|
439
|
+
expect(await svc.canWriteAtRef(workspaceId, 'feature', admin, DOC)).toBeNull();
|
|
440
|
+
expect(lsTreeBuilds()).toBe(1);
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
it('refuses to decide, twice, for a ref whose roles.yaml blob is lost, and never falls through to a granting origin', async () => {
|
|
444
|
+
// `feature` on origin carries a roles.yaml that grants the admin. The
|
|
445
|
+
// LOCAL `feature` is one commit ahead, with a roles.yaml of its own
|
|
446
|
+
// whose blob is then lost from the object store (real git: the loose
|
|
447
|
+
// object file removed). The tree still names roles.yaml, so the batch's
|
|
448
|
+
// "missing" is an unreadable file, not an absent one: the build must
|
|
449
|
+
// fail closed on both attempts, remember nothing for the commit, and
|
|
450
|
+
// never read the next candidate, origin/feature, which would grant.
|
|
451
|
+
await git(repo, 'checkout', '-q', '-b', 'feature');
|
|
452
|
+
await fs.writeFile(path.join(repo, 'roles.yaml'), 'roles:\n Admin:\n - razvan@bevel.software\n# on origin\n');
|
|
453
|
+
await git(repo, 'commit', '-qam', 'roles on origin/feature');
|
|
454
|
+
await git(repo, 'push', '-q', '-u', 'origin', 'feature');
|
|
455
|
+
await fs.writeFile(path.join(repo, 'roles.yaml'), 'roles:\n Admin:\n - razvan@bevel.software\n# local only\n');
|
|
456
|
+
await git(repo, 'commit', '-qam', 'roles on the local feature tip');
|
|
457
|
+
const lost = await git(repo, 'rev-parse', 'HEAD:roles.yaml');
|
|
458
|
+
await fs.rm(path.join(repo, '.git/objects', lost.slice(0, 2), lost.slice(2)));
|
|
459
|
+
|
|
460
|
+
spawnLog.length = 0;
|
|
461
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
|
462
|
+
for (const attempt of [1, 2]) {
|
|
463
|
+
await expect(svc.canWriteAtRef(workspaceId, 'feature', admin, DOC)).rejects.toBeInstanceOf(
|
|
464
|
+
AccessUnreadableError,
|
|
465
|
+
);
|
|
466
|
+
// One listing per attempt: the local tip, and never origin/feature.
|
|
467
|
+
expect(lsTreeBuilds()).toBe(attempt);
|
|
468
|
+
expect(catFileBatches()).toBe(attempt);
|
|
469
|
+
}
|
|
470
|
+
expect(warn).toHaveBeenCalledWith(expect.stringMatching(/roles\.yaml is in the tree but its blob could not be read/));
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
it.each(['synced-groups.yaml', 'groups.yaml'])(
|
|
474
|
+
'refuses to decide, twice, for a ref whose %s blob is lost, and never reads it as absent',
|
|
475
|
+
async (file) => {
|
|
476
|
+
// The groups source the model reads at origin/main — the synced file,
|
|
477
|
+
// which supersedes a manual one still naming a retired team, or the
|
|
478
|
+
// manual file alone — has its blob lost from the workspace's object
|
|
479
|
+
// store (real git: the loose object file removed after the fetch).
|
|
480
|
+
// The tree still names it, so the batch's "missing" is an unreadable
|
|
481
|
+
// file, not an absent one: the build fails closed on both attempts and
|
|
482
|
+
// remembers nothing. Reading the synced file as absent would revive
|
|
483
|
+
// the manual groups; reading the manual one as absent would drop what
|
|
484
|
+
// it grants.
|
|
485
|
+
await pushFromOther(`${file} on main`, async () => {
|
|
486
|
+
if (file === 'synced-groups.yaml') {
|
|
487
|
+
await fs.writeFile(path.join(other, 'synced-groups.yaml'), 'groups:\n Platform:\n - zoe@x.io\n');
|
|
488
|
+
}
|
|
489
|
+
await fs.writeFile(path.join(other, 'groups.yaml'), 'groups:\n Retired Team:\n - ghost@x.io\n');
|
|
490
|
+
});
|
|
491
|
+
await git(repo, 'fetch', '-q', 'origin');
|
|
492
|
+
const lost = await git(repo, 'rev-parse', `origin/main:${file}`);
|
|
493
|
+
await fs.rm(path.join(repo, '.git/objects', lost.slice(0, 2), lost.slice(2)));
|
|
494
|
+
|
|
495
|
+
spawnLog.length = 0;
|
|
496
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
|
497
|
+
for (const attempt of [1, 2]) {
|
|
498
|
+
await expect(svc.canWriteAtRef(workspaceId, 'origin/main', admin, DOC)).rejects.toBeInstanceOf(
|
|
499
|
+
AccessUnreadableError,
|
|
500
|
+
);
|
|
501
|
+
expect(lsTreeBuilds()).toBe(attempt);
|
|
502
|
+
expect(catFileBatches()).toBe(attempt);
|
|
503
|
+
}
|
|
504
|
+
expect(warn).toHaveBeenCalledWith(
|
|
505
|
+
expect.stringMatching(new RegExp(`${file.replaceAll('.', '\\.')} is in the tree but its blob could not be read`)),
|
|
506
|
+
);
|
|
507
|
+
},
|
|
508
|
+
);
|
|
509
|
+
|
|
510
|
+
it('refuses to decide when a groups source is a directory rather than a file', async () => {
|
|
511
|
+
await pushFromOther('groups.yaml as a folder', async () => {
|
|
512
|
+
await fs.mkdir(path.join(other, 'groups.yaml'));
|
|
513
|
+
await fs.writeFile(path.join(other, 'groups.yaml/README.md'), 'not groups\n');
|
|
514
|
+
});
|
|
515
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
|
516
|
+
await expect(svc.canWriteAtRef(workspaceId, 'origin/main', admin, DOC)).rejects.toBeInstanceOf(
|
|
517
|
+
AccessUnreadableError,
|
|
518
|
+
);
|
|
519
|
+
expect(warn).toHaveBeenCalledWith(expect.stringMatching(/groups\.yaml is a tree, not a file; refusing to decide/));
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
it.each([
|
|
523
|
+
['access.md', 'missing', /:Team\/access\.md$/, /Team\/access\.md is in the tree but its blob could not be read/],
|
|
524
|
+
['access.md', 'a tree', /:Team\/access\.md$/, /Team\/access\.md is a tree, not a file/],
|
|
525
|
+
['plugin manifest', 'missing', /:Plugins\/Demo\/plugin\.json$/, /Plugins\/Demo\/plugin\.json is in the tree but its blob could not be read/],
|
|
526
|
+
['plugin manifest', 'a tree', /:Plugins\/Demo\/plugin\.json$/, /Plugins\/Demo\/plugin\.json is a tree, not a file/],
|
|
527
|
+
])('refuses to decide when a listed %s comes back %s from the batch, and never caches it', async (_what, how, spec, warned) => {
|
|
528
|
+
// A healthy git cannot be made to answer this: the listing names blobs
|
|
529
|
+
// only, and a commit's objects do not change between two reads of it.
|
|
530
|
+
// The batch's reply is rewritten at the process boundary instead, so the
|
|
531
|
+
// build's refusal of a listed path it could not read is pinned all the
|
|
532
|
+
// same: a model missing that file could grant what the file denies.
|
|
533
|
+
injected.rewriteNext = (argv) =>
|
|
534
|
+
argv.includes('cat-file') ? rewriteBatchRecord(spec, how === 'missing' ? 'missing' : 'tree') : null;
|
|
535
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
|
536
|
+
const batchesBefore = catFileBatches();
|
|
537
|
+
await expect(svc.canWriteAtRef(workspaceId, 'origin/main', admin, DOC)).rejects.toBeInstanceOf(
|
|
538
|
+
AccessUnreadableError,
|
|
539
|
+
);
|
|
540
|
+
expect(injected.rewriteNext).toBeNull();
|
|
541
|
+
expect(warn).toHaveBeenCalledWith(expect.stringMatching(warned));
|
|
542
|
+
// ...and nothing was pinned: the next call, with git answering in full,
|
|
543
|
+
// builds again and answers.
|
|
544
|
+
expect(await svc.canWriteAtRef(workspaceId, 'origin/main', admin, DOC)).toBe(true);
|
|
545
|
+
// Three batches: the refused build, then the build that answered and the
|
|
546
|
+
// asked path's own entries.
|
|
547
|
+
expect(catFileBatches()).toBe(batchesBefore + 3);
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
it('resolves refs from the ref files and remembers own entries at a commit: a repeated gate call starts no git', async () => {
|
|
551
|
+
// First call at this commit: the model is built (two git calls) and the
|
|
552
|
+
// asked path's own entries are read (one batch). The refs themselves —
|
|
553
|
+
// `origin/main`, and the candidates a short name tries — come from the
|
|
554
|
+
// ref files, so no `rev-parse` runs at all.
|
|
555
|
+
expect(await svc.canWriteBatchAtRef(workspaceId, 'origin/main', admin, [DOC])).toEqual(new Map([[DOC, true]]));
|
|
556
|
+
expect(revParses()).toBe(0);
|
|
557
|
+
expect(lsTreeBuilds()).toBe(1);
|
|
558
|
+
expect(catFileBatches()).toBe(2);
|
|
559
|
+
|
|
560
|
+
// The same gate again, and the single-path gate for the same path: the
|
|
561
|
+
// model and the own entries are both remembered, so apart from the
|
|
562
|
+
// fixture's own fetch nothing is started.
|
|
563
|
+
spawnLog.length = 0;
|
|
564
|
+
expect(await svc.canWriteBatchAtRef(workspaceId, 'origin/main', admin, [DOC])).toEqual(new Map([[DOC, true]]));
|
|
565
|
+
expect(await svc.canWriteAtRef(workspaceId, 'origin/main', admin, DOC)).toBe(true);
|
|
566
|
+
expect(await svc.canWriteAtRef(workspaceId, 'main', admin, DOC)).toBe(true);
|
|
567
|
+
expect(spawnLog.filter((argv) => !argv.includes('fetch'))).toEqual([]);
|
|
568
|
+
|
|
569
|
+
// A path not asked before at this commit is read, and only that path.
|
|
570
|
+
spawnLog.length = 0;
|
|
571
|
+
expect(await svc.canWriteBatchAtRef(workspaceId, 'origin/main', admin, [DOC, QUOTED_DOC])).toEqual(
|
|
572
|
+
new Map([
|
|
573
|
+
[DOC, true],
|
|
574
|
+
[QUOTED_DOC, false],
|
|
575
|
+
]),
|
|
576
|
+
);
|
|
577
|
+
expect(catFileBatches()).toBe(1);
|
|
578
|
+
expect(revParses()).toBe(0);
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
it('asks git for a ref the files cannot decide, instead of reading it as missing', async () => {
|
|
582
|
+
// An annotated tag names a tag object, not a commit; the reader steps
|
|
583
|
+
// aside and the gate resolves it through `rev-parse <tag>^{commit}` —
|
|
584
|
+
// one process, the answer git gives, never "no such ref".
|
|
585
|
+
await git(repo, 'tag', '-a', 'v1', '-m', 'v1', 'origin/main');
|
|
586
|
+
expect(await svc.canWriteAtRef(workspaceId, 'v1', admin, DOC)).toBe(true);
|
|
587
|
+
expect(revParses()).toBe(1);
|
|
588
|
+
expect(lsTreeBuilds()).toBe(1);
|
|
589
|
+
// The same commit under its branch name: resolved from the files, the
|
|
590
|
+
// model already held.
|
|
591
|
+
spawnLog.length = 0;
|
|
592
|
+
expect(await svc.canWriteAtRef(workspaceId, 'origin/main', admin, DOC)).toBe(true);
|
|
593
|
+
expect(revParses()).toBe(0);
|
|
594
|
+
expect(lsTreeBuilds()).toBe(0);
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
it('knows from the tree that a path is absent at a commit, and reads nothing for it', async () => {
|
|
598
|
+
// A change request that adds a file asks about it at the base, where it
|
|
599
|
+
// does not exist yet. The model's listing already named every path the
|
|
600
|
+
// commit carries, so the absence is a fact about the commit: remembered,
|
|
601
|
+
// and not read — on the first call or any later one.
|
|
602
|
+
const NEW = 'Team/New.md';
|
|
603
|
+
expect(await svc.canWriteAtRef(workspaceId, 'origin/main', admin, DOC)).toBe(true);
|
|
604
|
+
spawnLog.length = 0;
|
|
605
|
+
expect(await svc.canWriteBatchAtRef(workspaceId, 'origin/main', admin, [NEW])).toEqual(new Map([[NEW, true]]));
|
|
606
|
+
expect(await svc.canWriteAtRef(workspaceId, 'origin/main', admin, NEW)).toBe(true);
|
|
607
|
+
expect(catFileBatches()).toBe(0);
|
|
608
|
+
expect(spawnLog.filter((argv) => !argv.includes('fetch'))).toEqual([]);
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
it("keeps a commit's tree up to the cap and not past it, as an absent path's cost shows", async () => {
|
|
612
|
+
// The seed carries seven paths. A service that may hold seven keeps the
|
|
613
|
+
// tree, so an absent path is answered without a read; one that may hold
|
|
614
|
+
// six does not keep it, so the same path is read on every call.
|
|
615
|
+
const NEW = 'Team/New.md';
|
|
616
|
+
const limited = (treeIndexMaxPaths: number) =>
|
|
617
|
+
new AccessControlService(stub, PROCESS_MAP_DIR, new NodeFs(), [], undefined, undefined, { treeIndexMaxPaths });
|
|
618
|
+
const keeps = limited(7);
|
|
619
|
+
expect(await keeps.canWriteAtRef(workspaceId, 'origin/main', admin, DOC)).toBe(true);
|
|
620
|
+
spawnLog.length = 0;
|
|
621
|
+
expect(await keeps.canWriteAtRef(workspaceId, 'origin/main', admin, NEW)).toBe(true);
|
|
622
|
+
expect(await keeps.canWriteAtRef(workspaceId, 'origin/main', admin, NEW)).toBe(true);
|
|
623
|
+
expect(catFileBatches()).toBe(0);
|
|
624
|
+
const drops = limited(6);
|
|
625
|
+
expect(await drops.canWriteAtRef(workspaceId, 'origin/main', admin, DOC)).toBe(true);
|
|
626
|
+
spawnLog.length = 0;
|
|
627
|
+
expect(await drops.canWriteAtRef(workspaceId, 'origin/main', admin, NEW)).toBe(true);
|
|
628
|
+
expect(await drops.canWriteAtRef(workspaceId, 'origin/main', admin, NEW)).toBe(true);
|
|
629
|
+
expect(catFileBatches()).toBe(2);
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
it('lets the oldest models give their tree up past the budget, and they still answer', async () => {
|
|
633
|
+
// Seven paths at the first commit, eight at the second, a budget of ten:
|
|
634
|
+
// remembering the second puts the first past the budget. The first
|
|
635
|
+
// model keeps answering gates without a rebuild; only an absent path at
|
|
636
|
+
// it is read again, while at the second it costs nothing.
|
|
637
|
+
const NEW = 'Team/New.md';
|
|
638
|
+
const budgeted = new AccessControlService(stub, PROCESS_MAP_DIR, new NodeFs(), [], undefined, undefined, {
|
|
639
|
+
treeIndexBudget: 10,
|
|
640
|
+
});
|
|
641
|
+
const first = await git(repo, 'rev-parse', 'origin/main');
|
|
642
|
+
expect(await budgeted.canWriteAtRef(workspaceId, 'origin/main', admin, DOC)).toBe(true);
|
|
643
|
+
await pushFromOther('a later page', () => fs.writeFile(path.join(other, 'Team/Later.md'), '# later\n'));
|
|
644
|
+
expect(await budgeted.canWriteAtRef(workspaceId, 'origin/main', admin, DOC)).toBe(true);
|
|
645
|
+
const second = await git(repo, 'rev-parse', 'origin/main');
|
|
646
|
+
expect(second).not.toBe(first);
|
|
647
|
+
spawnLog.length = 0;
|
|
648
|
+
expect(await budgeted.canWriteAtRef(workspaceId, second, admin, NEW)).toBe(true);
|
|
649
|
+
expect(lsTreeBuilds()).toBe(0);
|
|
650
|
+
expect(catFileBatches()).toBe(0);
|
|
651
|
+
spawnLog.length = 0;
|
|
652
|
+
expect(await budgeted.canWriteAtRef(workspaceId, first, admin, NEW)).toBe(true);
|
|
282
653
|
expect(lsTreeBuilds()).toBe(0);
|
|
654
|
+
expect(catFileBatches()).toBe(1);
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
it('remembers what a blob declares for itself, never a blob the batch could not read', async () => {
|
|
658
|
+
const SECRET = 'Team/Secret.md';
|
|
659
|
+
await pushFromOther('a page that denies the admin for itself', () =>
|
|
660
|
+
fs.writeFile(path.join(other, SECRET), '---\nwrite:\n - deny Admin\n---\n# secret\n'),
|
|
661
|
+
);
|
|
662
|
+
// Build the model first, so the next batch is the own-entries read.
|
|
663
|
+
expect(await svc.canWriteAtRef(workspaceId, 'origin/main', admin, DOC)).toBe(true);
|
|
664
|
+
// The own-entries batch loses the blob (a lost object reads as missing,
|
|
665
|
+
// exactly like an absent path): answered as no own entries, as before —
|
|
666
|
+
// the inherited write stands — but NOT remembered for the commit.
|
|
667
|
+
injected.rewriteNext = (argv) => (argv.includes('cat-file') ? rewriteBatchRecord(/:Team\/Secret\.md$/, 'missing') : null);
|
|
668
|
+
expect(await svc.canWriteAtRef(workspaceId, 'origin/main', admin, SECRET)).toBe(true);
|
|
669
|
+
expect(injected.rewriteNext).toBeNull();
|
|
670
|
+
// The next call reads the blob again, and its own denial now holds...
|
|
671
|
+
spawnLog.length = 0;
|
|
672
|
+
expect(await svc.canWriteAtRef(workspaceId, 'origin/main', admin, SECRET)).toBe(false);
|
|
673
|
+
expect(catFileBatches()).toBe(1);
|
|
674
|
+
// ...and that answer, a fact about the commit, is remembered.
|
|
675
|
+
spawnLog.length = 0;
|
|
676
|
+
expect(await svc.canWriteAtRef(workspaceId, 'origin/main', admin, SECRET)).toBe(false);
|
|
677
|
+
expect(catFileBatches()).toBe(0);
|
|
283
678
|
});
|
|
679
|
+
|
|
680
|
+
it('holds the models of far more commits than one listing resolves', async () => {
|
|
681
|
+
// A listing resolves access at every open change request's two refs: a
|
|
682
|
+
// knowledge base with a hundred open requests spans a hundred commits.
|
|
683
|
+
// Under the old bound of 64 that evicted in a cycle, so every listing
|
|
684
|
+
// rebuilt every model. Each commit here is a distinct tip; after seventy
|
|
685
|
+
// of them, past that old bound, the first is still held.
|
|
686
|
+
const shas: string[] = [];
|
|
687
|
+
for (let i = 0; i < 70; i++) {
|
|
688
|
+
await fs.writeFile(path.join(other, 'Team/Doc.md'), `# doc ${i}\n`);
|
|
689
|
+
await git(other, 'add', '-A');
|
|
690
|
+
await git(other, 'commit', '-qm', `edit ${i}`);
|
|
691
|
+
shas.push(await git(other, 'rev-parse', 'HEAD'));
|
|
692
|
+
}
|
|
693
|
+
await git(other, 'push', '-q', 'origin', 'HEAD');
|
|
694
|
+
await git(repo, 'fetch', '-q', 'origin');
|
|
695
|
+
spawnLog.length = 0;
|
|
696
|
+
for (const sha of shas) expect(await svc.canWriteAtRef(workspaceId, sha, admin, DOC)).toBe(true);
|
|
697
|
+
expect(lsTreeBuilds()).toBe(70);
|
|
698
|
+
expect(await svc.canWriteAtRef(workspaceId, shas[0]!, admin, DOC)).toBe(true);
|
|
699
|
+
expect(await svc.canWriteAtRef(workspaceId, shas[35]!, admin, DOC)).toBe(true);
|
|
700
|
+
expect(lsTreeBuilds()).toBe(70);
|
|
701
|
+
}, 120_000);
|
|
284
702
|
});
|
|
@@ -161,3 +161,76 @@ describe('canReadAsGroupBatch', () => {
|
|
|
161
161
|
expect(verdict?.get('Plugins/GTM')).toBe(false);
|
|
162
162
|
});
|
|
163
163
|
});
|
|
164
|
+
|
|
165
|
+
describe('groupsOfUser', () => {
|
|
166
|
+
let root: string;
|
|
167
|
+
const workspaceId = 'ws-groups-of-user-1';
|
|
168
|
+
|
|
169
|
+
beforeEach(async () => {
|
|
170
|
+
root = await fs.mkdtemp(path.join(os.tmpdir(), 'bevel-groups-of-user-'));
|
|
171
|
+
});
|
|
172
|
+
afterEach(async () => {
|
|
173
|
+
await fs.rm(root, { recursive: true, force: true });
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
async function makeService() {
|
|
177
|
+
const workspaceDir = path.join(root, workspaceId);
|
|
178
|
+
const repo = path.join(workspaceDir, KB_DIR);
|
|
179
|
+
await fs.mkdir(repo, { recursive: true });
|
|
180
|
+
// `lee@x.io` holds Leads, the role that lists `group:Sales Team`, without
|
|
181
|
+
// being in the group: the role reaches the group, not the other way round.
|
|
182
|
+
await fs.writeFile(path.join(repo, 'roles.yaml'), ROLES_YAML.replace(' - group:Sales Team\n', ' - group:Sales Team\n - lee@x.io\n'));
|
|
183
|
+
await fs.writeFile(
|
|
184
|
+
path.join(repo, 'groups.yaml'),
|
|
185
|
+
`groups:\n Sales Team:\n - sam@x.io\n - sue@x.io\n Design:\n - dee@x.io\n - sue@x.io\n`,
|
|
186
|
+
);
|
|
187
|
+
await fs.writeFile(path.join(repo, 'access.md'), rules('write:\n - Admin\n'));
|
|
188
|
+
return new AccessControlService(stubWorkspaceService(workspaceId, workspaceDir), KB_DIR, new NodeFs());
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
it('names the groups a person is in, as groups.yaml spells them', async () => {
|
|
192
|
+
const svc = await makeService();
|
|
193
|
+
expect(await svc.groupsOfUser(workspaceId, 'sam@x.io')).toEqual(['Sales Team']);
|
|
194
|
+
expect((await svc.groupsOfUser(workspaceId, 'sue@x.io')).sort()).toEqual(['Design', 'Sales Team']);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('matches the email in any case', async () => {
|
|
198
|
+
const svc = await makeService();
|
|
199
|
+
expect(await svc.groupsOfUser(workspaceId, 'Sam@X.io')).toEqual(['Sales Team']);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('is empty for someone in no group, however many roles they hold', async () => {
|
|
203
|
+
const svc = await makeService();
|
|
204
|
+
expect(await svc.groupsOfUser(workspaceId, 'eng@x.io')).toEqual([]);
|
|
205
|
+
expect(await svc.groupsOfUser(workspaceId, 'admin@x.io')).toEqual([]);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('does not count holding a role that names a group as being in it', async () => {
|
|
209
|
+
const svc = await makeService();
|
|
210
|
+
expect(await svc.groupsOfUser(workspaceId, 'lee@x.io')).toEqual([]);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
describe('visibleGroups: the one answer the Groups tab, the share dialog and a grant use', () => {
|
|
214
|
+
// The active source's groups, in the order a caller happens to hold them.
|
|
215
|
+
const listed = ['Sales Team', 'Design'];
|
|
216
|
+
|
|
217
|
+
it('is every group, as given, for an admin', async () => {
|
|
218
|
+
const svc = await makeService();
|
|
219
|
+
expect(await svc.visibleGroups(workspaceId, 'admin@x.io', listed)).toEqual(listed);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('is only the groups a person is in, in the order given, for anyone else', async () => {
|
|
223
|
+
const svc = await makeService();
|
|
224
|
+
expect(await svc.visibleGroups(workspaceId, 'sue@x.io', listed)).toEqual(['Sales Team', 'Design']);
|
|
225
|
+
expect(await svc.visibleGroups(workspaceId, 'sam@x.io', listed)).toEqual(['Sales Team']);
|
|
226
|
+
expect(await svc.visibleGroups(workspaceId, 'dee@x.io', listed)).toEqual(['Design']);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it('is empty for someone in no group, a role that names a group included', async () => {
|
|
230
|
+
const svc = await makeService();
|
|
231
|
+
expect(await svc.visibleGroups(workspaceId, 'eng@x.io', listed)).toEqual([]);
|
|
232
|
+
expect(await svc.visibleGroups(workspaceId, 'lee@x.io', listed)).toEqual([]);
|
|
233
|
+
expect(await svc.visibleGroups(workspaceId, 'admin@x.io', [])).toEqual([]);
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
});
|