@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.
Files changed (111) hide show
  1. package/dist/core/create-core-server.d.ts.map +1 -1
  2. package/dist/core/create-core-server.js +2 -1
  3. package/dist/core/create-core-server.js.map +1 -1
  4. package/dist/core/create-core-services.d.ts +2 -0
  5. package/dist/core/create-core-services.d.ts.map +1 -1
  6. package/dist/core/create-core-services.js +9 -0
  7. package/dist/core/create-core-services.js.map +1 -1
  8. package/dist/core-config.d.ts.map +1 -1
  9. package/dist/core-config.js +12 -5
  10. package/dist/core-config.js.map +1 -1
  11. package/dist/modules/access-model/kb-read-filter.d.ts +12 -0
  12. package/dist/modules/access-model/kb-read-filter.d.ts.map +1 -1
  13. package/dist/modules/access-model/kb-read-filter.js +15 -0
  14. package/dist/modules/access-model/kb-read-filter.js.map +1 -1
  15. package/dist/modules/connection-probe/connection-probe.contract.d.ts +43 -0
  16. package/dist/modules/connection-probe/connection-probe.contract.d.ts.map +1 -0
  17. package/dist/modules/connection-probe/connection-probe.contract.js +2 -0
  18. package/dist/modules/connection-probe/connection-probe.contract.js.map +1 -0
  19. package/dist/modules/connection-probe/connection-probe.service.d.ts +94 -0
  20. package/dist/modules/connection-probe/connection-probe.service.d.ts.map +1 -0
  21. package/dist/modules/connection-probe/connection-probe.service.js +684 -0
  22. package/dist/modules/connection-probe/connection-probe.service.js.map +1 -0
  23. package/dist/modules/connection-probe/index.d.ts +3 -0
  24. package/dist/modules/connection-probe/index.d.ts.map +1 -0
  25. package/dist/modules/connection-probe/index.js +3 -0
  26. package/dist/modules/connection-probe/index.js.map +1 -0
  27. package/dist/modules/diff/diff.routes.d.ts.map +1 -1
  28. package/dist/modules/diff/diff.routes.js +3 -5
  29. package/dist/modules/diff/diff.routes.js.map +1 -1
  30. package/dist/modules/kb-fs/mutex.d.ts +37 -0
  31. package/dist/modules/kb-fs/mutex.d.ts.map +1 -1
  32. package/dist/modules/kb-fs/mutex.js +48 -5
  33. package/dist/modules/kb-fs/mutex.js.map +1 -1
  34. package/dist/modules/secrets-vault/db-secrets-vault.service.js +1 -1
  35. package/dist/modules/secrets-vault/db-secrets-vault.service.js.map +1 -1
  36. package/dist/modules/secrets-vault/secrets-vault.routes.d.ts +6 -0
  37. package/dist/modules/secrets-vault/secrets-vault.routes.d.ts.map +1 -1
  38. package/dist/modules/secrets-vault/secrets-vault.routes.js +31 -1
  39. package/dist/modules/secrets-vault/secrets-vault.routes.js.map +1 -1
  40. package/dist/modules/tool-manuals/mcp-json-discovery.d.ts.map +1 -1
  41. package/dist/modules/tool-manuals/mcp-json-discovery.js +10 -1
  42. package/dist/modules/tool-manuals/mcp-json-discovery.js.map +1 -1
  43. package/dist/modules/tool-manuals/mcp-server-edit.service.d.ts.map +1 -1
  44. package/dist/modules/tool-manuals/mcp-server-edit.service.js +10 -4
  45. package/dist/modules/tool-manuals/mcp-server-edit.service.js.map +1 -1
  46. package/dist/modules/tool-manuals/tool-manuals.contract.d.ts +83 -0
  47. package/dist/modules/tool-manuals/tool-manuals.contract.d.ts.map +1 -1
  48. package/dist/modules/tool-manuals/tool-manuals.service.d.ts +9 -1
  49. package/dist/modules/tool-manuals/tool-manuals.service.d.ts.map +1 -1
  50. package/dist/modules/tool-manuals/tool-manuals.service.js +181 -13
  51. package/dist/modules/tool-manuals/tool-manuals.service.js.map +1 -1
  52. package/dist/modules/workflow/git/git.service.d.ts +18 -1
  53. package/dist/modules/workflow/git/git.service.d.ts.map +1 -1
  54. package/dist/modules/workflow/git/git.service.js +98 -6
  55. package/dist/modules/workflow/git/git.service.js.map +1 -1
  56. package/dist/modules/workflow/workflow.routes.d.ts +2 -1
  57. package/dist/modules/workflow/workflow.routes.d.ts.map +1 -1
  58. package/dist/modules/workflow/workflow.routes.js +98 -9
  59. package/dist/modules/workflow/workflow.routes.js.map +1 -1
  60. package/dist/modules/workflow/workflow.service.d.ts +81 -8
  61. package/dist/modules/workflow/workflow.service.d.ts.map +1 -1
  62. package/dist/modules/workflow/workflow.service.js +220 -43
  63. package/dist/modules/workflow/workflow.service.js.map +1 -1
  64. package/dist/modules/workspace/workspace.routes.d.ts.map +1 -1
  65. package/dist/modules/workspace/workspace.routes.js +2 -5
  66. package/dist/modules/workspace/workspace.routes.js.map +1 -1
  67. package/dist/modules/workspace/workspace.service.d.ts +5 -1
  68. package/dist/modules/workspace/workspace.service.d.ts.map +1 -1
  69. package/dist/modules/workspace/workspace.service.js +21 -4
  70. package/dist/modules/workspace/workspace.service.js.map +1 -1
  71. package/dist/shared/token-crypto.d.ts +17 -2
  72. package/dist/shared/token-crypto.d.ts.map +1 -1
  73. package/dist/shared/token-crypto.js +25 -8
  74. package/dist/shared/token-crypto.js.map +1 -1
  75. package/package.json +3 -3
  76. package/src/__tests__/core-config.admin.test.ts +21 -0
  77. package/src/core/create-core-server.ts +3 -0
  78. package/src/core/create-core-services.ts +11 -0
  79. package/src/core-config.ts +14 -7
  80. package/src/modules/access-model/kb-read-filter.ts +22 -0
  81. package/src/modules/connection-probe/__tests__/connection-probe.service.test.ts +685 -0
  82. package/src/modules/connection-probe/connection-probe.contract.ts +44 -0
  83. package/src/modules/connection-probe/connection-probe.service.ts +734 -0
  84. package/src/modules/connection-probe/index.ts +2 -0
  85. package/src/modules/diff/diff.routes.ts +9 -5
  86. package/src/modules/kb-fs/__tests__/mutex.test.ts +107 -0
  87. package/src/modules/kb-fs/mutex.ts +50 -5
  88. package/src/modules/secrets-vault/__tests__/connect-pending.route.test.ts +12 -0
  89. package/src/modules/secrets-vault/__tests__/oauth-return-to.route.test.ts +10 -3
  90. package/src/modules/secrets-vault/__tests__/tool-owner-gate.route.test.ts +26 -0
  91. package/src/modules/secrets-vault/db-secrets-vault.service.ts +1 -1
  92. package/src/modules/secrets-vault/secrets-vault.routes.ts +35 -1
  93. package/src/modules/tool-manuals/__tests__/mcp-json-discovery.test.ts +11 -0
  94. package/src/modules/tool-manuals/__tests__/tool-manuals.health-check.test.ts +104 -0
  95. package/src/modules/tool-manuals/__tests__/tool-manuals.service.test.ts +56 -0
  96. package/src/modules/tool-manuals/mcp-json-discovery.ts +13 -1
  97. package/src/modules/tool-manuals/mcp-server-edit.service.ts +13 -4
  98. package/src/modules/tool-manuals/tool-manuals.contract.ts +89 -0
  99. package/src/modules/tool-manuals/tool-manuals.service.ts +196 -14
  100. package/src/modules/workflow/__tests__/workflow.routes.history-read-gate.test.ts +139 -0
  101. package/src/modules/workflow/__tests__/workflow.service.branch-in-use.test.ts +337 -0
  102. package/src/modules/workflow/__tests__/workflow.service.deleted-branch-sweep.test.ts +7 -1
  103. package/src/modules/workflow/__tests__/workflow.service.facade.test.ts +95 -8
  104. package/src/modules/workflow/git/__tests__/git.service.history-guards.test.ts +86 -0
  105. package/src/modules/workflow/git/git.service.ts +115 -7
  106. package/src/modules/workflow/workflow.routes.ts +104 -4
  107. package/src/modules/workflow/workflow.service.ts +245 -55
  108. package/src/modules/workspace/workspace.routes.ts +8 -4
  109. package/src/modules/workspace/workspace.service.ts +21 -3
  110. package/src/shared/__tests__/token-crypto.test.ts +34 -0
  111. 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', { freshFetch: true });
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: a chainable select→from→where→limit stub resolving `rows` covers
20
- // it (rows default to "no open request").
21
- function makeDb(rows: unknown[] = []): Database {
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(() => chain),
26
- limit: vi.fn(async () => rows),
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
- const svc = new WorkflowService(makeDb([{ number: 7 }]), git, makePrs(), makeReviewWorkflow(), makeWorkspaceService(), makeAccessControl(), makeFileLockService(), makePendingCommits(), 'knowledge-base');
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 svc = new WorkflowService(chain as unknown as Database, git, prs, makeReviewWorkflow(), makeWorkspaceService(), access, makeFileLockService(), makePendingCommits(), 'knowledge-base');
660
- return { svc, prs, db: chain, access };
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
+ });