@bevel-software/platform-core-backend 0.13.6 → 0.14.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 +2 -1
- package/dist/core/create-core-server.js.map +1 -1
- package/dist/core/create-core-services.d.ts +2 -0
- package/dist/core/create-core-services.d.ts.map +1 -1
- package/dist/core/create-core-services.js +9 -0
- package/dist/core/create-core-services.js.map +1 -1
- package/dist/core-config.d.ts.map +1 -1
- package/dist/core-config.js +12 -5
- package/dist/core-config.js.map +1 -1
- package/dist/modules/access-model/kb-read-filter.d.ts +12 -0
- package/dist/modules/access-model/kb-read-filter.d.ts.map +1 -1
- package/dist/modules/access-model/kb-read-filter.js +15 -0
- package/dist/modules/access-model/kb-read-filter.js.map +1 -1
- package/dist/modules/connection-probe/connection-probe.contract.d.ts +43 -0
- package/dist/modules/connection-probe/connection-probe.contract.d.ts.map +1 -0
- package/dist/modules/connection-probe/connection-probe.contract.js +2 -0
- package/dist/modules/connection-probe/connection-probe.contract.js.map +1 -0
- package/dist/modules/connection-probe/connection-probe.service.d.ts +94 -0
- package/dist/modules/connection-probe/connection-probe.service.d.ts.map +1 -0
- package/dist/modules/connection-probe/connection-probe.service.js +684 -0
- package/dist/modules/connection-probe/connection-probe.service.js.map +1 -0
- package/dist/modules/connection-probe/index.d.ts +3 -0
- package/dist/modules/connection-probe/index.d.ts.map +1 -0
- package/dist/modules/connection-probe/index.js +3 -0
- package/dist/modules/connection-probe/index.js.map +1 -0
- package/dist/modules/diff/diff.routes.d.ts.map +1 -1
- package/dist/modules/diff/diff.routes.js +3 -5
- package/dist/modules/diff/diff.routes.js.map +1 -1
- package/dist/modules/kb-fs/mutex.d.ts +37 -0
- package/dist/modules/kb-fs/mutex.d.ts.map +1 -1
- package/dist/modules/kb-fs/mutex.js +48 -5
- package/dist/modules/kb-fs/mutex.js.map +1 -1
- package/dist/modules/secrets-vault/db-secrets-vault.service.js +1 -1
- package/dist/modules/secrets-vault/db-secrets-vault.service.js.map +1 -1
- package/dist/modules/secrets-vault/secrets-vault.routes.d.ts +6 -0
- package/dist/modules/secrets-vault/secrets-vault.routes.d.ts.map +1 -1
- package/dist/modules/secrets-vault/secrets-vault.routes.js +31 -1
- package/dist/modules/secrets-vault/secrets-vault.routes.js.map +1 -1
- package/dist/modules/tool-manuals/mcp-json-discovery.d.ts.map +1 -1
- package/dist/modules/tool-manuals/mcp-json-discovery.js +10 -1
- package/dist/modules/tool-manuals/mcp-json-discovery.js.map +1 -1
- package/dist/modules/tool-manuals/mcp-server-edit.service.d.ts.map +1 -1
- package/dist/modules/tool-manuals/mcp-server-edit.service.js +10 -4
- package/dist/modules/tool-manuals/mcp-server-edit.service.js.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.contract.d.ts +83 -0
- package/dist/modules/tool-manuals/tool-manuals.contract.d.ts.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.service.d.ts +9 -1
- package/dist/modules/tool-manuals/tool-manuals.service.d.ts.map +1 -1
- package/dist/modules/tool-manuals/tool-manuals.service.js +181 -13
- package/dist/modules/tool-manuals/tool-manuals.service.js.map +1 -1
- package/dist/modules/workflow/git/git.service.d.ts +18 -1
- package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
- package/dist/modules/workflow/git/git.service.js +98 -6
- package/dist/modules/workflow/git/git.service.js.map +1 -1
- package/dist/modules/workflow/workflow.routes.d.ts +2 -1
- package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.routes.js +98 -9
- package/dist/modules/workflow/workflow.routes.js.map +1 -1
- package/dist/modules/workflow/workflow.service.d.ts +81 -8
- package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
- package/dist/modules/workflow/workflow.service.js +220 -43
- package/dist/modules/workflow/workflow.service.js.map +1 -1
- package/dist/modules/workspace/workspace.routes.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.routes.js +2 -5
- package/dist/modules/workspace/workspace.routes.js.map +1 -1
- package/dist/modules/workspace/workspace.service.d.ts +5 -1
- package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
- package/dist/modules/workspace/workspace.service.js +21 -4
- package/dist/modules/workspace/workspace.service.js.map +1 -1
- package/dist/shared/token-crypto.d.ts +17 -2
- package/dist/shared/token-crypto.d.ts.map +1 -1
- package/dist/shared/token-crypto.js +25 -8
- package/dist/shared/token-crypto.js.map +1 -1
- package/package.json +3 -3
- package/src/__tests__/core-config.admin.test.ts +21 -0
- package/src/core/create-core-server.ts +3 -0
- package/src/core/create-core-services.ts +11 -0
- package/src/core-config.ts +14 -7
- package/src/modules/access-model/kb-read-filter.ts +22 -0
- package/src/modules/connection-probe/__tests__/connection-probe.service.test.ts +685 -0
- package/src/modules/connection-probe/connection-probe.contract.ts +44 -0
- package/src/modules/connection-probe/connection-probe.service.ts +734 -0
- package/src/modules/connection-probe/index.ts +2 -0
- package/src/modules/diff/diff.routes.ts +9 -5
- package/src/modules/kb-fs/__tests__/mutex.test.ts +107 -0
- package/src/modules/kb-fs/mutex.ts +50 -5
- package/src/modules/secrets-vault/__tests__/connect-pending.route.test.ts +12 -0
- package/src/modules/secrets-vault/__tests__/oauth-return-to.route.test.ts +10 -3
- package/src/modules/secrets-vault/__tests__/tool-owner-gate.route.test.ts +26 -0
- package/src/modules/secrets-vault/db-secrets-vault.service.ts +1 -1
- package/src/modules/secrets-vault/secrets-vault.routes.ts +35 -1
- package/src/modules/tool-manuals/__tests__/mcp-json-discovery.test.ts +11 -0
- package/src/modules/tool-manuals/__tests__/tool-manuals.health-check.test.ts +104 -0
- package/src/modules/tool-manuals/__tests__/tool-manuals.service.test.ts +56 -0
- package/src/modules/tool-manuals/mcp-json-discovery.ts +13 -1
- package/src/modules/tool-manuals/mcp-server-edit.service.ts +13 -4
- package/src/modules/tool-manuals/tool-manuals.contract.ts +89 -0
- package/src/modules/tool-manuals/tool-manuals.service.ts +196 -14
- package/src/modules/workflow/__tests__/workflow.routes.history-read-gate.test.ts +139 -0
- package/src/modules/workflow/__tests__/workflow.service.branch-in-use.test.ts +337 -0
- package/src/modules/workflow/__tests__/workflow.service.deleted-branch-sweep.test.ts +7 -1
- package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +95 -8
- package/src/modules/workflow/git/__tests__/git.service.history-guards.test.ts +86 -0
- package/src/modules/workflow/git/git.service.ts +115 -7
- package/src/modules/workflow/workflow.routes.ts +104 -4
- package/src/modules/workflow/workflow.service.ts +245 -55
- package/src/modules/workspace/workspace.routes.ts +8 -4
- package/src/modules/workspace/workspace.service.ts +21 -3
- package/src/shared/__tests__/token-crypto.test.ts +34 -0
- package/src/shared/token-crypto.ts +28 -10
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { drizzle } from 'drizzle-orm/pg-proxy';
|
|
3
|
+
import { configureBranchModel } from '@bevel-software/platform-shared';
|
|
4
|
+
import type { GitService } from '../git/git.service.js';
|
|
5
|
+
import type { PullRequestService } from '../git/pull-request.service.js';
|
|
6
|
+
import type { IReviewWorkflowService } from '../review-workflow/review-workflow.interface.js';
|
|
7
|
+
import type { WorkspaceService } from '../../workspace/workspace.service.js';
|
|
8
|
+
import type { IAccessControl } from '../../access/access-control.interface.js';
|
|
9
|
+
import type { FileLockService } from '../file-lock.service.js';
|
|
10
|
+
import type { PendingCommitsService } from '../pending-commits.service.js';
|
|
11
|
+
import type { Database } from '../../database/connection.js';
|
|
12
|
+
import { WorkflowService } from '../workflow.service.js';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A branch is load-bearing for a change request at EITHER end.
|
|
16
|
+
*
|
|
17
|
+
* The regression: merging `X -> main` retired X while a second open request
|
|
18
|
+
* `Y -> X` still proposed INTO it. Every guard asked only "is X anybody's
|
|
19
|
+
* SOURCE?", so X was deleted and `Y -> X` became permanently unusable — it
|
|
20
|
+
* could not be read (the detail read resolves the target first), declined, or
|
|
21
|
+
* deleted, and the boot sweep looked at sources only, so it survived every
|
|
22
|
+
* restart.
|
|
23
|
+
*
|
|
24
|
+
* These tests run the REAL drizzle query against seeded rows (via the pg-proxy
|
|
25
|
+
* driver, which hands the generated SQL and its parameters to a callback)
|
|
26
|
+
* rather than a stub that answers by call order. That is deliberate: a mock
|
|
27
|
+
* blind to the `where` clause returns the same row whether the guard asks
|
|
28
|
+
* about one branch end or both, so it would pass just as happily against the
|
|
29
|
+
* source-only code this fixes — it could not detect the regression it exists
|
|
30
|
+
* for. Here, reverting `openChangeRequestOn` to a source-only lookup makes the
|
|
31
|
+
* seeded `Y -> X` row stop matching, and these tests fail.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
interface CrRow {
|
|
35
|
+
number: number;
|
|
36
|
+
source_branch: string;
|
|
37
|
+
target_branch: string;
|
|
38
|
+
state: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* A `change_requests` table that answers the query it is actually given.
|
|
43
|
+
*
|
|
44
|
+
* The driver receives real SQL and real parameters, so the predicate is read
|
|
45
|
+
* back off the statement: every `"column" = $n` becomes a (column, value)
|
|
46
|
+
* pair. One value bound to SEVERAL columns is the either-end test and ORs
|
|
47
|
+
* across them (`source_branch = $1 or target_branch = $1`); distinct values
|
|
48
|
+
* AND together (`… and state = $2`). Non-`select` statements report one
|
|
49
|
+
* affected row, which is the guarded update in the sweep succeeding.
|
|
50
|
+
*/
|
|
51
|
+
function fakeDb(rows: CrRow[]) {
|
|
52
|
+
const statements: string[] = [];
|
|
53
|
+
const db = drizzle(async (sql: string, params: unknown[]) => {
|
|
54
|
+
statements.push(sql);
|
|
55
|
+
if (!/^\s*select/i.test(sql)) return { rows: [['row-id']] };
|
|
56
|
+
|
|
57
|
+
const projection = (/^\s*select\s+(.+?)\s+from\s/is.exec(sql)?.[1] ?? '')
|
|
58
|
+
.split(',')
|
|
59
|
+
.map((item) => item.trim().replace(/.*"(\w+)"$/, '$1'));
|
|
60
|
+
|
|
61
|
+
const byValue = new Map<unknown, string[]>();
|
|
62
|
+
for (const m of sql.matchAll(/"(\w+)"\s*=\s*\$(\d+)/g)) {
|
|
63
|
+
const value = params[Number(m[2]) - 1];
|
|
64
|
+
byValue.set(value, [...(byValue.get(value) ?? []), m[1]!]);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Reading the predicate off the statement means the reader can go stale
|
|
68
|
+
// against a future drizzle that quotes, aliases or parameterises
|
|
69
|
+
// differently. That is survivable ONLY if it fails loudly: an unparsed
|
|
70
|
+
// WHERE would leave `byValue` empty, `every` over nothing is vacuously
|
|
71
|
+
// true, and every seeded row would match — which is exactly the blind
|
|
72
|
+
// mock this harness replaced, silently restored. So refuse to answer a
|
|
73
|
+
// statement this cannot actually read.
|
|
74
|
+
if (!projection.length || projection.some((c) => !/^\w+$/.test(c))) {
|
|
75
|
+
throw new Error(`fakeDb could not read the projection of: ${sql}`);
|
|
76
|
+
}
|
|
77
|
+
if (/\bwhere\b/i.test(sql) && byValue.size === 0) {
|
|
78
|
+
throw new Error(`fakeDb could not read the predicate of: ${sql}`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const matched = rows.filter((row) =>
|
|
82
|
+
[...byValue].every(([value, columns]) =>
|
|
83
|
+
columns.some((c) => row[c as keyof CrRow] === value),
|
|
84
|
+
),
|
|
85
|
+
);
|
|
86
|
+
return { rows: matched.map((row) => projection.map((c) => row[c as keyof CrRow])) };
|
|
87
|
+
});
|
|
88
|
+
return { db: db as unknown as Database, statements };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function makeService(db: Database, git: Partial<GitService>) {
|
|
92
|
+
return new WorkflowService(
|
|
93
|
+
db,
|
|
94
|
+
git as GitService,
|
|
95
|
+
{ invalidateDetailCache: vi.fn() } as unknown as PullRequestService,
|
|
96
|
+
{} as IReviewWorkflowService,
|
|
97
|
+
{
|
|
98
|
+
getOrCreateForBranch: vi.fn(async () => ({ id: 'ws-target' })),
|
|
99
|
+
ensureRemotesFetched: vi.fn(async () => undefined),
|
|
100
|
+
hasBootstrappedWorkspace: vi.fn(async () => false),
|
|
101
|
+
} as unknown as WorkspaceService,
|
|
102
|
+
{} as IAccessControl,
|
|
103
|
+
{} as FileLockService,
|
|
104
|
+
{} as PendingCommitsService,
|
|
105
|
+
'knowledge-base',
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const USER = { email: 'juan@example.com', name: 'Juan', id: 'u1' };
|
|
110
|
+
|
|
111
|
+
/** The merged request, and a second one still proposing INTO its source. */
|
|
112
|
+
const MERGED_23: CrRow = {
|
|
113
|
+
number: 23,
|
|
114
|
+
source_branch: 'juan/rfi',
|
|
115
|
+
target_branch: 'target-company-state',
|
|
116
|
+
state: 'merged',
|
|
117
|
+
};
|
|
118
|
+
const OPEN_24_INTO_RFI: CrRow = {
|
|
119
|
+
number: 24,
|
|
120
|
+
source_branch: 'juan/rfi-mock',
|
|
121
|
+
target_branch: 'juan/rfi',
|
|
122
|
+
state: 'open',
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
function retire(svc: WorkflowService, number: number, base: string) {
|
|
126
|
+
return (
|
|
127
|
+
svc as unknown as {
|
|
128
|
+
retireMergedSourceBranch(n: number, base: string, u: unknown): Promise<void>;
|
|
129
|
+
}
|
|
130
|
+
).retireMergedSourceBranch(number, base, USER);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
beforeEach(() => {
|
|
134
|
+
configureBranchModel({
|
|
135
|
+
defaultBranch: 'target-company-state',
|
|
136
|
+
protectedBranches: ['current-company-state', 'target-company-state'],
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
describe('post-merge branch retirement', () => {
|
|
141
|
+
it('keeps a branch another open request proposes INTO', async () => {
|
|
142
|
+
const deleted: string[] = [];
|
|
143
|
+
const { db, statements } = fakeDb([MERGED_23, OPEN_24_INTO_RFI]);
|
|
144
|
+
const svc = makeService(db, {
|
|
145
|
+
deleteBranch: vi.fn(async (_ws: string, name: string) => void deleted.push(name)),
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
await retire(svc, 23, 'target-company-state');
|
|
149
|
+
|
|
150
|
+
expect(deleted).toEqual([]);
|
|
151
|
+
// The guard must ask about both ends — a statement naming only
|
|
152
|
+
// `source_branch` is the regression.
|
|
153
|
+
expect(statements.some((q) => /target_branch/.test(q))).toBe(true);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('still retires a branch nothing open needs', async () => {
|
|
157
|
+
const deleted: string[] = [];
|
|
158
|
+
// #24 is gone; only the merged request remains, so nothing needs the branch.
|
|
159
|
+
const { db } = fakeDb([MERGED_23]);
|
|
160
|
+
const svc = makeService(db, {
|
|
161
|
+
deleteBranch: vi.fn(async (_ws: string, name: string) => void deleted.push(name)),
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
await retire(svc, 23, 'target-company-state');
|
|
165
|
+
|
|
166
|
+
expect(deleted).toEqual(['juan/rfi']);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('keeps a branch another open request proposes FROM', async () => {
|
|
170
|
+
const deleted: string[] = [];
|
|
171
|
+
const { db } = fakeDb([
|
|
172
|
+
MERGED_23,
|
|
173
|
+
{ number: 25, source_branch: 'juan/rfi', target_branch: 'other', state: 'open' },
|
|
174
|
+
]);
|
|
175
|
+
const svc = makeService(db, {
|
|
176
|
+
deleteBranch: vi.fn(async (_ws: string, name: string) => void deleted.push(name)),
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
await retire(svc, 23, 'target-company-state');
|
|
180
|
+
|
|
181
|
+
expect(deleted).toEqual([]);
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
describe('deleteBranch', () => {
|
|
186
|
+
it('refuses a branch an open request proposes INTO, naming who can act', async () => {
|
|
187
|
+
const { db } = fakeDb([OPEN_24_INTO_RFI]);
|
|
188
|
+
const svc = makeService(db, { deleteBranch: vi.fn() });
|
|
189
|
+
|
|
190
|
+
// Not "withdraw or decline it": the request belongs to someone else, and
|
|
191
|
+
// the branch owner may be authorized to do neither. The message has to
|
|
192
|
+
// say what the situation is and who can clear it.
|
|
193
|
+
await expect(svc.deleteBranch('ws', 'juan/rfi', USER)).rejects.toThrow(
|
|
194
|
+
/change request #24 proposes changes into "juan\/rfi"/,
|
|
195
|
+
);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('refuses a branch an open request proposes FROM, offering the withdraw verb', async () => {
|
|
199
|
+
const { db } = fakeDb([
|
|
200
|
+
{ number: 26, source_branch: 'juan/rfi', target_branch: 'target-company-state', state: 'open' },
|
|
201
|
+
]);
|
|
202
|
+
const svc = makeService(db, { deleteBranch: vi.fn() });
|
|
203
|
+
|
|
204
|
+
await expect(svc.deleteBranch('ws', 'juan/rfi', USER)).rejects.toThrow(
|
|
205
|
+
/open change request \(#26\)\. Withdraw or decline it/,
|
|
206
|
+
);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it('allows a branch no open request names', async () => {
|
|
210
|
+
const deleted: string[] = [];
|
|
211
|
+
const { db } = fakeDb([OPEN_24_INTO_RFI]);
|
|
212
|
+
const svc = makeService(db, {
|
|
213
|
+
deleteBranch: vi.fn(async (_ws: string, name: string) => void deleted.push(name)),
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
await svc.deleteBranch('ws', 'juan/unrelated', USER);
|
|
217
|
+
|
|
218
|
+
expect(deleted).toEqual(['juan/unrelated']);
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
describe('runOnBranchPair', () => {
|
|
223
|
+
function deferred() {
|
|
224
|
+
let resolve!: () => void;
|
|
225
|
+
const promise = new Promise<void>((r) => { resolve = r; });
|
|
226
|
+
return { promise, resolve };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
type LockInternals = {
|
|
230
|
+
branchLifecycle: { run(key: string, fn: () => Promise<void>): Promise<void> };
|
|
231
|
+
runOnBranchPair(source: string, target: string, fn: () => Promise<void>): Promise<void>;
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
function locks(svc: WorkflowService) {
|
|
235
|
+
return svc as unknown as LockInternals;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
it('waits on an unprotected target: its key really is reserved', async () => {
|
|
239
|
+
const { db } = fakeDb([]);
|
|
240
|
+
const svc = locks(makeService(db, {}));
|
|
241
|
+
const order: string[] = [];
|
|
242
|
+
const release = deferred();
|
|
243
|
+
|
|
244
|
+
const holder = svc.branchLifecycle.run('branch:juan/rfi', async () => {
|
|
245
|
+
await release.promise;
|
|
246
|
+
order.push('holder');
|
|
247
|
+
});
|
|
248
|
+
await new Promise((r) => setImmediate(r));
|
|
249
|
+
|
|
250
|
+
const pair = svc.runOnBranchPair('juan/other', 'juan/rfi', async () => {
|
|
251
|
+
order.push('pair');
|
|
252
|
+
});
|
|
253
|
+
await new Promise((r) => setImmediate(r));
|
|
254
|
+
expect(order).toEqual([]);
|
|
255
|
+
|
|
256
|
+
release.resolve();
|
|
257
|
+
await Promise.all([holder, pair]);
|
|
258
|
+
expect(order).toEqual(['holder', 'pair']);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it('does not reserve a PROTECTED target key', async () => {
|
|
262
|
+
// No path in the system can delete a protected branch, so the key would
|
|
263
|
+
// exclude nothing while serializing every open into the default branch,
|
|
264
|
+
// which is where nearly all requests point. The pair operation must run
|
|
265
|
+
// even while something else holds the protected branch's key.
|
|
266
|
+
const { db } = fakeDb([]);
|
|
267
|
+
const svc = locks(makeService(db, {}));
|
|
268
|
+
const order: string[] = [];
|
|
269
|
+
const release = deferred();
|
|
270
|
+
|
|
271
|
+
const holder = svc.branchLifecycle.run('branch:target-company-state', async () => {
|
|
272
|
+
await release.promise;
|
|
273
|
+
order.push('holder');
|
|
274
|
+
});
|
|
275
|
+
await new Promise((r) => setImmediate(r));
|
|
276
|
+
|
|
277
|
+
await svc.runOnBranchPair('juan/other', 'target-company-state', async () => {
|
|
278
|
+
order.push('pair');
|
|
279
|
+
});
|
|
280
|
+
expect(order).toEqual(['pair']);
|
|
281
|
+
|
|
282
|
+
release.resolve();
|
|
283
|
+
await holder;
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it('always reserves the source key', async () => {
|
|
287
|
+
const { db } = fakeDb([]);
|
|
288
|
+
const svc = locks(makeService(db, {}));
|
|
289
|
+
const order: string[] = [];
|
|
290
|
+
const release = deferred();
|
|
291
|
+
|
|
292
|
+
const holder = svc.branchLifecycle.run('branch:juan/other', async () => {
|
|
293
|
+
await release.promise;
|
|
294
|
+
order.push('holder');
|
|
295
|
+
});
|
|
296
|
+
await new Promise((r) => setImmediate(r));
|
|
297
|
+
|
|
298
|
+
const pair = svc.runOnBranchPair('juan/other', 'target-company-state', async () => {
|
|
299
|
+
order.push('pair');
|
|
300
|
+
});
|
|
301
|
+
await new Promise((r) => setImmediate(r));
|
|
302
|
+
expect(order).toEqual([]);
|
|
303
|
+
|
|
304
|
+
release.resolve();
|
|
305
|
+
await Promise.all([holder, pair]);
|
|
306
|
+
expect(order).toEqual(['holder', 'pair']);
|
|
307
|
+
});
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
describe('closeChangeRequestsWithDeletedBranches', () => {
|
|
311
|
+
it('closes a request whose TARGET branch is gone', async () => {
|
|
312
|
+
const { db } = fakeDb([OPEN_24_INTO_RFI]);
|
|
313
|
+
const svc = makeService(db, {
|
|
314
|
+
// #24's source lives on; its target `juan/rfi` is gone.
|
|
315
|
+
listBranches: vi.fn(async () => [
|
|
316
|
+
{ name: 'target-company-state' },
|
|
317
|
+
{ name: 'juan/rfi-mock' },
|
|
318
|
+
]),
|
|
319
|
+
} as unknown as Partial<GitService>);
|
|
320
|
+
|
|
321
|
+
await expect(svc.closeChangeRequestsWithDeletedBranches()).resolves.toBe(1);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
it('leaves a request whose branches both exist', async () => {
|
|
325
|
+
const { db } = fakeDb([
|
|
326
|
+
{ ...OPEN_24_INTO_RFI, target_branch: 'target-company-state' },
|
|
327
|
+
]);
|
|
328
|
+
const svc = makeService(db, {
|
|
329
|
+
listBranches: vi.fn(async () => [
|
|
330
|
+
{ name: 'target-company-state' },
|
|
331
|
+
{ name: 'juan/rfi-mock' },
|
|
332
|
+
]),
|
|
333
|
+
} as unknown as Partial<GitService>);
|
|
334
|
+
|
|
335
|
+
await expect(svc.closeChangeRequestsWithDeletedBranches()).resolves.toBe(0);
|
|
336
|
+
});
|
|
337
|
+
});
|
|
@@ -114,7 +114,13 @@ describe('closeChangeRequestsWithDeletedBranches', () => {
|
|
|
114
114
|
{ name: 'x' },
|
|
115
115
|
]);
|
|
116
116
|
await svc.closeChangeRequestsWithDeletedBranches();
|
|
117
|
-
expect(git.listBranches).toHaveBeenCalledWith('target-company-state', {
|
|
117
|
+
expect(git.listBranches).toHaveBeenCalledWith('target-company-state', {
|
|
118
|
+
freshFetch: true,
|
|
119
|
+
// Strict: a fetch that cannot be proven fresh throws instead of serving
|
|
120
|
+
// stale refs, and the sweep's catch closes nothing. Without this, a
|
|
121
|
+
// branch created after the clone's last good fetch reads as "deleted".
|
|
122
|
+
strictFetch: true,
|
|
123
|
+
});
|
|
118
124
|
});
|
|
119
125
|
|
|
120
126
|
/**
|
|
@@ -16,14 +16,61 @@ import { PullRebaseConflictError } from '../../../shared/domain-errors.js';
|
|
|
16
16
|
import type { Database } from '../../database/connection.js';
|
|
17
17
|
|
|
18
18
|
// `deleteBranch`'s open-request guard is the only DB touch these tests
|
|
19
|
-
// exercise
|
|
20
|
-
//
|
|
21
|
-
|
|
19
|
+
// exercise. The stub ANSWERS FROM THE WHERE-CLAUSE rather than returning the
|
|
20
|
+
// rows unfiltered — a guard that dropped the `state = 'open'` filter or asked
|
|
21
|
+
// about only one branch end used to pass these tests anyway, because any row
|
|
22
|
+
// came back regardless of the predicate.
|
|
23
|
+
//
|
|
24
|
+
// The evaluator walks the drizzle condition tree (duck-typing the
|
|
25
|
+
// SQL/Column/Param/StringChunk shapes) and evaluates it against the row:
|
|
26
|
+
// an `eq(column, value)` leaf compares the row's field, and a node combines
|
|
27
|
+
// its children with whichever connector its literal chunks carry (`and` /
|
|
28
|
+
// `or` — drizzle nests them, so one node never mixes both).
|
|
29
|
+
function rowMatches(cond: unknown, row: Record<string, unknown>): boolean {
|
|
30
|
+
// snake_case column → camelCase row field (the stubs carry row objects in
|
|
31
|
+
// the service's own field names).
|
|
32
|
+
const field = (col: string) => col.replace(/_([a-z])/g, (_, ch: string) => ch.toUpperCase());
|
|
33
|
+
const evalNode = (node: unknown): boolean => {
|
|
34
|
+
const chunks = (node as { queryChunks?: unknown[] }).queryChunks;
|
|
35
|
+
if (!Array.isArray(chunks)) return true;
|
|
36
|
+
const results: boolean[] = [];
|
|
37
|
+
let anyOr = false;
|
|
38
|
+
let pendingColumn: string | null = null;
|
|
39
|
+
for (const c of chunks) {
|
|
40
|
+
if (!c || typeof c !== 'object') continue;
|
|
41
|
+
if ('queryChunks' in c) {
|
|
42
|
+
results.push(evalNode(c));
|
|
43
|
+
} else if ('name' in c && 'keyAsName' in c) {
|
|
44
|
+
pendingColumn = String((c as { name: unknown }).name);
|
|
45
|
+
} else if ('encoder' in c && 'value' in c) {
|
|
46
|
+
if (pendingColumn !== null) {
|
|
47
|
+
results.push(row[field(pendingColumn)] === (c as { value: unknown }).value);
|
|
48
|
+
pendingColumn = null;
|
|
49
|
+
}
|
|
50
|
+
} else if ('value' in c) {
|
|
51
|
+
if (String((c as { value: unknown }).value).includes(' or ')) anyOr = true;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
// A node this walker cannot decompose (`inArray`, `isNull`, a future
|
|
55
|
+
// rewrite of the query) must NOT read as a match — `true` here would be
|
|
56
|
+
// the permissive direction, returning rows the real query excludes and
|
|
57
|
+
// masking exactly the guard regressions these tests exist to catch.
|
|
58
|
+
if (results.length === 0) return false;
|
|
59
|
+
return anyOr ? results.some(Boolean) : results.every(Boolean);
|
|
60
|
+
};
|
|
61
|
+
return evalNode(cond);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function makeDb(rows: Record<string, unknown>[] = []): Database {
|
|
65
|
+
let lastWhere: unknown;
|
|
22
66
|
const chain = {
|
|
23
67
|
select: vi.fn(() => chain),
|
|
24
68
|
from: vi.fn(() => chain),
|
|
25
|
-
where: vi.fn(() =>
|
|
26
|
-
|
|
69
|
+
where: vi.fn((cond: unknown) => {
|
|
70
|
+
lastWhere = cond;
|
|
71
|
+
return chain;
|
|
72
|
+
}),
|
|
73
|
+
limit: vi.fn(async () => rows.filter((r) => rowMatches(lastWhere, r))),
|
|
27
74
|
};
|
|
28
75
|
return chain as unknown as Database;
|
|
29
76
|
}
|
|
@@ -435,11 +482,27 @@ describe('WorkflowService — file lock delegation', () => {
|
|
|
435
482
|
it('deleteBranch refuses while the branch carries an open change request', async () => {
|
|
436
483
|
const git = makeGit();
|
|
437
484
|
// One open request rides the branch — deleting it would strand the request.
|
|
438
|
-
|
|
485
|
+
// `sourceBranch` matters to the guard now: it decides whether the refusal
|
|
486
|
+
// says "withdraw yours" (source) or names the other request's actors (target).
|
|
487
|
+
const svc = new WorkflowService(makeDb([{ number: 7, sourceBranch: 'feat/x', targetBranch: 'dev', state: 'open' }]), git, makePrs(), makeReviewWorkflow(), makeWorkspaceService(), makeAccessControl(), makeFileLockService(), makePendingCommits(), 'knowledge-base');
|
|
439
488
|
await expect(svc.deleteBranch('w1', 'feat/x', makeUser())).rejects.toThrow(/open change request \(#7\)/);
|
|
440
489
|
expect(git.deleteBranch).not.toHaveBeenCalled();
|
|
441
490
|
});
|
|
442
491
|
|
|
492
|
+
it('deleteBranch refuses when an open request proposes INTO the branch (target end)', async () => {
|
|
493
|
+
const git = makeGit();
|
|
494
|
+
const svc = new WorkflowService(makeDb([{ number: 9, sourceBranch: 'other/y', targetBranch: 'feat/x', state: 'open' }]), git, makePrs(), makeReviewWorkflow(), makeWorkspaceService(), makeAccessControl(), makeFileLockService(), makePendingCommits(), 'knowledge-base');
|
|
495
|
+
await expect(svc.deleteBranch('w1', 'feat/x', makeUser())).rejects.toThrow(/proposes changes into/);
|
|
496
|
+
expect(git.deleteBranch).not.toHaveBeenCalled();
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
it('deleteBranch proceeds past a CLOSED request — the guard filters on state, not mere mention', async () => {
|
|
500
|
+
const git = makeGit();
|
|
501
|
+
const svc = new WorkflowService(makeDb([{ number: 7, sourceBranch: 'feat/x', targetBranch: 'dev', state: 'closed' }]), git, makePrs(), makeReviewWorkflow(), makeWorkspaceService(), makeAccessControl(), makeFileLockService(), makePendingCommits(), 'knowledge-base');
|
|
502
|
+
await expect(svc.deleteBranch('w1', 'feat/x', makeUser())).resolves.toBeUndefined();
|
|
503
|
+
expect(git.deleteBranch).toHaveBeenCalled();
|
|
504
|
+
});
|
|
505
|
+
|
|
443
506
|
it('deleteBranch deletes when the branch has no open change request', async () => {
|
|
444
507
|
const git = makeGit();
|
|
445
508
|
const svc = new WorkflowService(makeDb(), git, makePrs(), makeReviewWorkflow(), makeWorkspaceService(), makeAccessControl(), makeFileLockService(), makePendingCommits(), 'knowledge-base');
|
|
@@ -656,8 +719,9 @@ describe('WorkflowService — deleteChangeRequest (admin moderation verb)', () =
|
|
|
656
719
|
set: vi.fn(() => chain),
|
|
657
720
|
returning: vi.fn(async () => [{ id: 1 }]),
|
|
658
721
|
});
|
|
659
|
-
const
|
|
660
|
-
|
|
722
|
+
const workspaces = makeWorkspaceService();
|
|
723
|
+
const svc = new WorkflowService(chain as unknown as Database, git, prs, makeReviewWorkflow(), workspaces, access, makeFileLockService(), makePendingCommits(), 'knowledge-base');
|
|
724
|
+
return { svc, prs, db: chain, access, workspaces };
|
|
661
725
|
}
|
|
662
726
|
|
|
663
727
|
it('refuses a non-admin with 403 and touches nothing', async () => {
|
|
@@ -678,4 +742,27 @@ describe('WorkflowService — deleteChangeRequest (admin moderation verb)', () =
|
|
|
678
742
|
await expect(svc.deleteChangeRequest(9, makeUser())).rejects.toMatchObject({ status: 422 });
|
|
679
743
|
expect(db.update).not.toHaveBeenCalled();
|
|
680
744
|
});
|
|
745
|
+
|
|
746
|
+
it('kicks the deleted-branch sweep when the base workspace cannot be resolved', async () => {
|
|
747
|
+
const { svc, db, workspaces } = makeDeleteHarness({ isAdmin: true });
|
|
748
|
+
(workspaces.getOrCreateForBranch as ReturnType<typeof vi.fn>).mockRejectedValue(
|
|
749
|
+
new Error('Remote branch main not found in upstream origin'),
|
|
750
|
+
);
|
|
751
|
+
const sweep = vi.spyOn(svc, 'closeChangeRequestsWithDeletedBranches').mockResolvedValue(0);
|
|
752
|
+
|
|
753
|
+
// The failure still surfaces (it is not proven absence, so the verb must
|
|
754
|
+
// not pretend to succeed), but the sweep is kicked so a genuinely
|
|
755
|
+
// stranded request is closed without waiting for a restart.
|
|
756
|
+
await expect(svc.deleteChangeRequest(9, makeUser())).rejects.toThrow(/not found/);
|
|
757
|
+
expect(sweep).toHaveBeenCalledTimes(1);
|
|
758
|
+
expect(db.update).not.toHaveBeenCalled();
|
|
759
|
+
});
|
|
760
|
+
|
|
761
|
+
it('does not kick the sweep on a plain authorization refusal', async () => {
|
|
762
|
+
const { svc, db } = makeDeleteHarness({ isAdmin: false });
|
|
763
|
+
const sweep = vi.spyOn(svc, 'closeChangeRequestsWithDeletedBranches').mockResolvedValue(0);
|
|
764
|
+
await expect(svc.deleteChangeRequest(9, makeUser())).rejects.toMatchObject({ status: 403 });
|
|
765
|
+
expect(sweep).not.toHaveBeenCalled();
|
|
766
|
+
expect(db.update).not.toHaveBeenCalled();
|
|
767
|
+
});
|
|
681
768
|
});
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import { GitService } from '../git.service.js';
|
|
6
|
+
import { runGit, gitOut, stubWorkflowHooks, stubWorkspaceService } from './git-test-helpers.js';
|
|
7
|
+
|
|
8
|
+
// The history surfaces are per-file: their read gate checked exactly one
|
|
9
|
+
// path, but a directory pathspec walks every child — and `show <ref>:<dir>`
|
|
10
|
+
// even prints the tree listing. A tree at the ref is refused; a file, and a
|
|
11
|
+
// path absent at the ref, are served.
|
|
12
|
+
|
|
13
|
+
describe('GitService history guards', () => {
|
|
14
|
+
let root: string;
|
|
15
|
+
const workspaceId = 'target-company-state';
|
|
16
|
+
let repo: string;
|
|
17
|
+
let sha: string;
|
|
18
|
+
let svc: GitService;
|
|
19
|
+
|
|
20
|
+
beforeEach(async () => {
|
|
21
|
+
root = await fs.mkdtemp(path.join(os.tmpdir(), 'bevel-git-histguard-'));
|
|
22
|
+
const workspaceDir = path.join(root, workspaceId);
|
|
23
|
+
repo = path.join(workspaceDir, 'knowledge-base');
|
|
24
|
+
await fs.mkdir(repo, { recursive: true });
|
|
25
|
+
await runGit(root, ['init', '-b', 'target-company-state', repo]);
|
|
26
|
+
await runGit(repo, ['config', 'user.email', 'workspace@bevel.test']);
|
|
27
|
+
await runGit(repo, ['config', 'user.name', 'bevel Workspace']);
|
|
28
|
+
await fs.mkdir(path.join(repo, 'docs'));
|
|
29
|
+
await fs.writeFile(path.join(repo, 'docs', 'child.md'), 'secret child\n');
|
|
30
|
+
await fs.writeFile(path.join(repo, 'top.md'), 'top\n');
|
|
31
|
+
await runGit(repo, ['add', '.']);
|
|
32
|
+
await runGit(repo, ['commit', '-m', 'seed']);
|
|
33
|
+
sha = await gitOut(repo, ['rev-parse', 'HEAD']);
|
|
34
|
+
svc = new GitService(
|
|
35
|
+
stubWorkspaceService({ [workspaceId]: workspaceDir }),
|
|
36
|
+
stubWorkflowHooks(),
|
|
37
|
+
'knowledge-base',
|
|
38
|
+
);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
afterEach(async () => {
|
|
42
|
+
await fs.rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('diffFileAtCommit refuses a directory and serves a file', async () => {
|
|
46
|
+
await expect(svc.diffFileAtCommit(workspaceId, 'knowledge-base/docs', sha)).rejects.toThrow(
|
|
47
|
+
/history is served per file/,
|
|
48
|
+
);
|
|
49
|
+
const diff = await svc.diffFileAtCommit(workspaceId, 'knowledge-base/top.md', sha);
|
|
50
|
+
expect(diff).toContain('top');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('logForFile refuses a directory even when it was deleted before HEAD, and lists a file', async () => {
|
|
54
|
+
// A directory removed before HEAD is absent NOW — a HEAD-shape check
|
|
55
|
+
// passes — but `git log -- <dir>` still traverses its historical
|
|
56
|
+
// children. The proof comes from what the log touched.
|
|
57
|
+
await fs.rm(path.join(repo, 'docs'), { recursive: true });
|
|
58
|
+
await runGit(repo, ['add', '-A']);
|
|
59
|
+
await runGit(repo, ['commit', '-m', 'remove docs']);
|
|
60
|
+
await expect(svc.logForFile(workspaceId, 'knowledge-base/docs')).rejects.toThrow(
|
|
61
|
+
/history is served per file/,
|
|
62
|
+
);
|
|
63
|
+
const log = await svc.logForFile(workspaceId, 'knowledge-base/top.md');
|
|
64
|
+
expect(log.length).toBe(1);
|
|
65
|
+
expect(log[0]!.subject).toBe('seed');
|
|
66
|
+
expect(log[0]!.sha).toMatch(/^[0-9a-f]{40}$/);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('fileContentsAtCommit refuses a directory and still serves a deleted file', async () => {
|
|
70
|
+
await expect(svc.fileContentsAtCommit(workspaceId, 'knowledge-base/docs', sha)).rejects.toThrow(
|
|
71
|
+
/history is served per file/,
|
|
72
|
+
);
|
|
73
|
+
// Delete the file; its history stays its own — absent is not a tree.
|
|
74
|
+
await fs.rm(path.join(repo, 'top.md'));
|
|
75
|
+
await runGit(repo, ['add', '-A']);
|
|
76
|
+
await runGit(repo, ['commit', '-m', 'remove top']);
|
|
77
|
+
const delSha = await gitOut(repo, ['rev-parse', 'HEAD']);
|
|
78
|
+
const { baseline, current } = await svc.fileContentsAtCommit(
|
|
79
|
+
workspaceId,
|
|
80
|
+
'knowledge-base/top.md',
|
|
81
|
+
delSha,
|
|
82
|
+
);
|
|
83
|
+
expect(baseline).toContain('top');
|
|
84
|
+
expect(current).toBeNull();
|
|
85
|
+
});
|
|
86
|
+
});
|