reviewflow 3.36.1 → 3.37.1

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 (41) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/dist/main/server.d.ts +18 -0
  3. package/dist/main/server.d.ts.map +1 -1
  4. package/dist/main/server.js +24 -5
  5. package/dist/main/server.js.map +1 -1
  6. package/dist/modules/platform-integration/interface-adapters/controllers/webhook/gitlab.controller.d.ts.map +1 -1
  7. package/dist/modules/platform-integration/interface-adapters/controllers/webhook/gitlab.controller.js +12 -2
  8. package/dist/modules/platform-integration/interface-adapters/controllers/webhook/gitlab.controller.js.map +1 -1
  9. package/dist/modules/platform-integration/services/pinnedThreadFetchTarget.d.ts +22 -1
  10. package/dist/modules/platform-integration/services/pinnedThreadFetchTarget.d.ts.map +1 -1
  11. package/dist/modules/platform-integration/services/pinnedThreadFetchTarget.js +21 -1
  12. package/dist/modules/platform-integration/services/pinnedThreadFetchTarget.js.map +1 -1
  13. package/dist/tests/acceptance/196-least-privilege-platform-token.acceptance.test.d.ts +2 -0
  14. package/dist/tests/acceptance/196-least-privilege-platform-token.acceptance.test.d.ts.map +1 -0
  15. package/dist/tests/acceptance/196-least-privilege-platform-token.acceptance.test.js +197 -0
  16. package/dist/tests/acceptance/196-least-privilege-platform-token.acceptance.test.js.map +1 -0
  17. package/dist/tests/acceptance/197-trusted-actor-provenance-gate.acceptance.test.d.ts +2 -0
  18. package/dist/tests/acceptance/197-trusted-actor-provenance-gate.acceptance.test.d.ts.map +1 -0
  19. package/dist/tests/acceptance/197-trusted-actor-provenance-gate.acceptance.test.js +413 -0
  20. package/dist/tests/acceptance/197-trusted-actor-provenance-gate.acceptance.test.js.map +1 -0
  21. package/dist/tests/acceptance/198-constrained-action-surface.acceptance.test.d.ts +2 -0
  22. package/dist/tests/acceptance/198-constrained-action-surface.acceptance.test.d.ts.map +1 -0
  23. package/dist/tests/acceptance/198-constrained-action-surface.acceptance.test.js +185 -0
  24. package/dist/tests/acceptance/198-constrained-action-surface.acceptance.test.js.map +1 -0
  25. package/dist/tests/acceptance/199-review-output-egress-scan.acceptance.test.d.ts +2 -0
  26. package/dist/tests/acceptance/199-review-output-egress-scan.acceptance.test.d.ts.map +1 -0
  27. package/dist/tests/acceptance/199-review-output-egress-scan.acceptance.test.js +159 -0
  28. package/dist/tests/acceptance/199-review-output-egress-scan.acceptance.test.js.map +1 -0
  29. package/dist/tests/acceptance/200-webhook-event-idempotency.acceptance.test.d.ts +2 -0
  30. package/dist/tests/acceptance/200-webhook-event-idempotency.acceptance.test.d.ts.map +1 -0
  31. package/dist/tests/acceptance/200-webhook-event-idempotency.acceptance.test.js +244 -0
  32. package/dist/tests/acceptance/200-webhook-event-idempotency.acceptance.test.js.map +1 -0
  33. package/dist/tests/acceptance/201-transport-provenance-hardening.acceptance.test.d.ts +2 -0
  34. package/dist/tests/acceptance/201-transport-provenance-hardening.acceptance.test.d.ts.map +1 -0
  35. package/dist/tests/acceptance/201-transport-provenance-hardening.acceptance.test.js +72 -0
  36. package/dist/tests/acceptance/201-transport-provenance-hardening.acceptance.test.js.map +1 -0
  37. package/dist/tests/units/interface-adapters/controllers/webhook/gitlab.controller.test.js +96 -0
  38. package/dist/tests/units/interface-adapters/controllers/webhook/gitlab.controller.test.js.map +1 -1
  39. package/dist/tests/units/modules/platform-integration/services/pinnedThreadFetchTarget.test.js +45 -2
  40. package/dist/tests/units/modules/platform-integration/services/pinnedThreadFetchTarget.test.js.map +1 -1
  41. package/package.json +1 -1
@@ -0,0 +1,197 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { AUTO_EXECUTOR_CAPABILITIES, EXECUTOR_CAPABILITY_TABLE, } from '../../modules/platform-integration/entities/executorToken/executorCapability.js';
3
+ import { resolvePinnedThreadFetchTarget, resolvePinnedThreads, } from '../../modules/platform-integration/services/pinnedThreadFetchTarget.js';
4
+ import { buildScopedExecutorEnvironment, ENV_ALLOWLIST, MissingExecutorTokenError, } from '../../modules/platform-integration/services/scopedExecutorEnvironment.js';
5
+ import { executeActionsFromContext } from '../../modules/review-execution/services/contextActionsExecutor.js';
6
+ import { dispatchConstrainedActions } from '../../modules/review-execution/services/dispatchConstrainedActions.js';
7
+ const TOKEN = 'glpat-service-token-acceptance';
8
+ const TEMP_ROOT = '/tmp/reviewflow-executor-acceptance';
9
+ class RecordingFileWriter {
10
+ writes = [];
11
+ ensuredDirs = [];
12
+ write(path, contents) {
13
+ this.writes.push({ path, contents });
14
+ }
15
+ ensureDir(path) {
16
+ this.ensuredDirs.push(path);
17
+ }
18
+ }
19
+ class RecordingExecutor {
20
+ calls = [];
21
+ run = (command, args) => {
22
+ this.calls.push({ command, args });
23
+ };
24
+ }
25
+ class RecordingCliExecutor {
26
+ commands = [];
27
+ run = (command) => {
28
+ this.commands.push(command);
29
+ return '';
30
+ };
31
+ }
32
+ class RecordingPostGateway {
33
+ posts = [];
34
+ postComment = async (input) => {
35
+ this.posts.push(input);
36
+ };
37
+ }
38
+ class RecordingThreadFetch {
39
+ calls = [];
40
+ fetchThreads = (projectPath, mrNumber) => {
41
+ this.calls.push({ projectPath, mrNumber });
42
+ return [];
43
+ };
44
+ }
45
+ class StubInventoryGateway {
46
+ pages = [];
47
+ setPages(pages) {
48
+ this.pages = pages;
49
+ }
50
+ fetchPage(_projectPath, _mergeRequestNumber, page) {
51
+ const found = this.pages.find((candidate) => candidate.page === page);
52
+ if (!found)
53
+ throw new Error(`no page ${page}`);
54
+ return found;
55
+ }
56
+ }
57
+ class RecordingLogger {
58
+ warnings = [];
59
+ errors = [];
60
+ info() { }
61
+ warn(_obj, message) {
62
+ this.warnings.push(message);
63
+ }
64
+ debug() { }
65
+ error(_obj, message) {
66
+ this.errors.push(message);
67
+ }
68
+ }
69
+ const dispatchContext = {
70
+ platform: 'gitlab',
71
+ projectPath: 'group/project',
72
+ mrNumber: 42,
73
+ localPath: '/tmp/repo',
74
+ };
75
+ function resolveDiscussionWrites(executor) {
76
+ return executor.calls.filter((call) => call.args.includes('PUT') && call.args.some((arg) => arg.includes('/discussions/')));
77
+ }
78
+ function buildContextWith(actions, threadIds) {
79
+ return {
80
+ version: '1',
81
+ mergeRequestId: 'gitlab-group/project-42',
82
+ platform: 'gitlab',
83
+ projectPath: 'group/project',
84
+ mergeRequestNumber: 42,
85
+ createdAt: '2026-01-01T00:00:00.000Z',
86
+ threads: threadIds.map((id) => ({
87
+ id,
88
+ file: null,
89
+ line: null,
90
+ status: 'open',
91
+ body: 'thread',
92
+ })),
93
+ actions,
94
+ progress: { phase: 'completed', currentStep: null },
95
+ };
96
+ }
97
+ describe('SPEC-196 least-privilege platform token (acceptance)', () => {
98
+ describe('AC1 — dedicated service token, fail-closed', () => {
99
+ it('refuses to construct the executor environment when the token is absent (zero file writes)', () => {
100
+ const fileWriter = new RecordingFileWriter();
101
+ expect(() => buildScopedExecutorEnvironment({
102
+ parentEnv: { PATH: '/usr/bin' },
103
+ isolatedDir: TEMP_ROOT,
104
+ fileWriter,
105
+ })).toThrow(MissingExecutorTokenError);
106
+ expect(fileWriter.writes).toHaveLength(0);
107
+ expect(fileWriter.ensuredDirs).toHaveLength(0);
108
+ });
109
+ });
110
+ describe('AC2/AC3 — env built by allowlist, token never in env', () => {
111
+ it('keeps the child env keyset within the allowlist, drops the canary, and never carries the token', () => {
112
+ const fileWriter = new RecordingFileWriter();
113
+ const { env } = buildScopedExecutorEnvironment({
114
+ parentEnv: {
115
+ REVIEWFLOW_EXECUTOR_TOKEN: TOKEN,
116
+ PATH: '/usr/bin',
117
+ LANG: 'en_US.UTF-8',
118
+ AMBIENT_ADMIN_TOKEN: 'canary',
119
+ },
120
+ isolatedDir: TEMP_ROOT,
121
+ fileWriter,
122
+ });
123
+ for (const key of Object.keys(env)) {
124
+ expect(ENV_ALLOWLIST).toContain(key);
125
+ }
126
+ expect('AMBIENT_ADMIN_TOKEN' in env).toBe(false);
127
+ for (const value of Object.values(env)) {
128
+ expect(value).not.toBe(TOKEN);
129
+ }
130
+ expect(fileWriter.writes[0]?.contents).toContain(TOKEN);
131
+ expect(fileWriter.writes[0]?.path.startsWith(TEMP_ROOT)).toBe(true);
132
+ });
133
+ });
134
+ describe('AC5 — minimal role frozen per action', () => {
135
+ it('locks the auto-executor capability set to exactly {readMr, postComment}', () => {
136
+ expect([...AUTO_EXECUTOR_CAPABILITIES].toSorted()).toEqual(['postComment', 'readMr']);
137
+ expect(EXECUTOR_CAPABILITY_TABLE.threadResolve.autoPath).toBe(false);
138
+ expect(EXECUTOR_CAPABILITY_TABLE.revoke.autoPath).toBe(false);
139
+ });
140
+ });
141
+ describe('AC6/AC7 — removed write verbs are inert, postComment still fires', () => {
142
+ it('through the stdout chokepoint: postComment fires once, THREAD_RESOLVE records zero write calls, no throw', async () => {
143
+ const executor = new RecordingExecutor();
144
+ const postGateway = new RecordingPostGateway();
145
+ const inventory = new StubInventoryGateway();
146
+ inventory.setPages([{ page: 1, totalPages: 1, threadIds: ['10'] }]);
147
+ const actions = [
148
+ { type: 'POST_COMMENT', body: 'review summary' },
149
+ { type: 'THREAD_RESOLVE', threadId: '10' },
150
+ { type: 'FETCH_THREADS' },
151
+ ];
152
+ await dispatchConstrainedActions(actions, {
153
+ context: dispatchContext,
154
+ provenance: 'untrusted',
155
+ inventoryGateway: inventory,
156
+ logger: new RecordingLogger(),
157
+ executor: executor.run,
158
+ postGateway,
159
+ });
160
+ expect(postGateway.posts).toHaveLength(1);
161
+ expect(postGateway.posts[0]?.body).toBe('review summary');
162
+ expect(resolveDiscussionWrites(executor)).toHaveLength(0);
163
+ });
164
+ it('through the context-file path: a THREAD_RESOLVE on an empty authenticated inventory records zero write calls', async () => {
165
+ const executor = new RecordingCliExecutor();
166
+ const context = buildContextWith([{ type: 'THREAD_RESOLVE', threadId: '10' }], []);
167
+ await executeActionsFromContext(context, '/tmp/repo', new RecordingLogger(), executor.run);
168
+ expect(executor.commands).toHaveLength(0);
169
+ });
170
+ });
171
+ describe('AC9 — action-target identity pinned to trusted provenance, fail-closed', () => {
172
+ it('an unrecognized projectPath resolves to no target (fail-closed)', () => {
173
+ const target = resolvePinnedThreadFetchTarget({
174
+ payloadProjectPath: 'attacker/unknown',
175
+ payloadMrNumber: 5,
176
+ findRepository: () => null,
177
+ gatedMrNumber: 5,
178
+ });
179
+ expect(target).toBeNull();
180
+ });
181
+ it('the followup path never calls fetchThreads for an unrecognized project (empty action surface)', () => {
182
+ const fetch = new RecordingThreadFetch();
183
+ const logger = new RecordingLogger();
184
+ const threads = resolvePinnedThreads({
185
+ payloadProjectPath: 'attacker/unknown',
186
+ payloadMrNumber: 5,
187
+ findRepository: () => null,
188
+ gatedMrNumber: 5,
189
+ fetchThreads: fetch.fetchThreads,
190
+ logger,
191
+ });
192
+ expect(threads).toEqual([]);
193
+ expect(fetch.calls).toHaveLength(0);
194
+ });
195
+ });
196
+ });
197
+ //# sourceMappingURL=196-least-privilege-platform-token.acceptance.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"196-least-privilege-platform-token.acceptance.test.js","sourceRoot":"","sources":["../../../src/tests/acceptance/196-least-privilege-platform-token.acceptance.test.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=197-trusted-actor-provenance-gate.acceptance.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"197-trusted-actor-provenance-gate.acceptance.test.d.ts","sourceRoot":"","sources":["../../../src/tests/acceptance/197-trusted-actor-provenance-gate.acceptance.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,413 @@
1
+ import { vi } from 'vitest';
2
+ const mockConfig = {
3
+ server: { port: 3000 },
4
+ user: { gitlabUsername: 'claude-bot', githubUsername: 'claude-bot' },
5
+ queue: { maxConcurrent: 1, deduplicationWindowMs: 60000 },
6
+ repositories: [],
7
+ };
8
+ const mockRepoConfig = {
9
+ name: 'test-project',
10
+ platform: 'gitlab',
11
+ localPath: '/home/user/projects/test-project',
12
+ remoteUrl: 'https://gitlab.com/test-org/test-project.git',
13
+ skill: 'review-front',
14
+ enabled: true,
15
+ };
16
+ vi.mock('@/config/loader.js', () => ({
17
+ loadConfig: vi.fn(() => mockConfig),
18
+ findRepositoryByProjectPath: vi.fn(() => mockRepoConfig),
19
+ }));
20
+ vi.mock('@/security/verifier.js', () => ({
21
+ verifyGitLabSignature: vi.fn(() => ({ valid: true })),
22
+ getGitLabEventType: vi.fn(() => 'Merge Request Hook'),
23
+ getGitLabEventUuid: vi.fn(() => undefined),
24
+ }));
25
+ vi.mock('@/frameworks/queue/pQueueAdapter.js', () => ({
26
+ createJobId: vi.fn(() => 'gitlab-test-org/test-project-42'),
27
+ enqueueReview: vi.fn(() => Promise.resolve(true)),
28
+ updateJobProgress: vi.fn(),
29
+ cancelJob: vi.fn(),
30
+ }));
31
+ vi.mock('@/claude/invoker.js', () => ({
32
+ invokeClaudeReview: vi.fn(),
33
+ sendNotification: vi.fn(),
34
+ }));
35
+ vi.mock('@/main/websocket.js', () => ({
36
+ startWatchingReviewContext: vi.fn(),
37
+ stopWatchingReviewContext: vi.fn(),
38
+ }));
39
+ vi.mock('@/config/projectConfig.js', () => ({
40
+ loadProjectConfig: vi.fn(() => null),
41
+ getProjectAgents: vi.fn(() => null),
42
+ getProjectAgentsOrFocusDefaults: vi.fn(() => null),
43
+ getFollowupAgents: vi.fn(() => null),
44
+ getProjectLanguage: vi.fn(() => 'en'),
45
+ }));
46
+ vi.mock('@/modules/review-execution/interface-adapters/gateways/reviewContext.fileSystem.gateway.js', () => ({
47
+ ReviewContextFileSystemGateway: vi.fn().mockImplementation(() => ({
48
+ create: vi.fn(),
49
+ read: vi.fn(() => null),
50
+ delete: vi.fn(() => ({ deleted: true })),
51
+ updateProgress: vi.fn(),
52
+ })),
53
+ }));
54
+ vi.mock('@/modules/platform-integration/interface-adapters/gateways/threadFetch.gitlab.gateway.js', () => ({
55
+ GitLabThreadFetchGateway: vi.fn().mockImplementation(() => ({
56
+ fetchThreads: vi.fn(() => []),
57
+ })),
58
+ defaultGitLabExecutor: vi.fn(),
59
+ }));
60
+ vi.mock('@/modules/platform-integration/interface-adapters/gateways/diffMetadataFetch.gitlab.gateway.js', () => ({
61
+ GitLabDiffMetadataFetchGateway: vi.fn().mockImplementation(() => ({
62
+ fetchDiffMetadata: vi.fn(() => undefined),
63
+ })),
64
+ }));
65
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
66
+ import { enqueueReview } from '../../frameworks/queue/pQueueAdapter.js';
67
+ import { MEMBER_ACCESS_LEVELS } from '../../modules/platform-integration/entities/memberAccess/memberAccess.js';
68
+ import { handleGitLabWebhook } from '../../modules/platform-integration/interface-adapters/controllers/webhook/gitlab.controller.js';
69
+ import { IsTrustedActorUseCase } from '../../modules/platform-integration/usecases/isTrustedActor.usecase.js';
70
+ import { GateClaudeInvocationUseCase } from '../../modules/review-execution/usecases/gateClaudeInvocation.usecase.js';
71
+ import { CheckFollowupNeededUseCase } from '../../modules/tracking/usecases/tracking/checkFollowupNeeded.usecase.js';
72
+ import { HandlePlatformApprovalUseCase } from '../../modules/tracking/usecases/tracking/handlePlatformApproval.usecase.js';
73
+ import { RecordBypassUseCase } from '../../modules/tracking/usecases/tracking/recordBypass.usecase.js';
74
+ import { RecordPushUseCase } from '../../modules/tracking/usecases/tracking/recordPush.usecase.js';
75
+ import { RecordReviewCompletionUseCase } from '../../modules/tracking/usecases/tracking/recordReviewCompletion.usecase.js';
76
+ import { SyncThreadsUseCase } from '../../modules/tracking/usecases/tracking/syncThreads.usecase.js';
77
+ import { TrackAssignmentUseCase } from '../../modules/tracking/usecases/tracking/trackAssignment.usecase.js';
78
+ import { TransitionStateUseCase } from '../../modules/tracking/usecases/tracking/transitionState.usecase.js';
79
+ import { verifyGitLabSignature, getGitLabEventType } from '../../security/verifier.js';
80
+ import { GitLabEventFactory } from '../../tests/factories/gitLabEvent.factory.js';
81
+ import { TrackedMrFactory } from '../../tests/factories/trackedMr.factory.js';
82
+ import { StubApprovalRevocationGateway } from '../../tests/stubs/approvalRevocation.stub.js';
83
+ import { createStubLogger } from '../../tests/stubs/logger.stub.js';
84
+ import { StubMemberAccessGateway } from '../../tests/stubs/memberAccess.stub.js';
85
+ import { StubNoteCommentPostGateway } from '../../tests/stubs/noteCommentPost.stub.js';
86
+ import { StubPendingReviewRequestGateway } from '../../tests/stubs/pendingReviewRequest.stub.js';
87
+ const logger = createStubLogger();
88
+ const TRACKED_MR_ID = 'gitlab-test-org/test-project-42';
89
+ const PROJECT_PATH = 'test-org/test-project';
90
+ function createMockTrackingGateway(initialMr) {
91
+ return {
92
+ getById: vi.fn(() => initialMr),
93
+ getByNumber: vi.fn(() => null),
94
+ create: vi.fn(),
95
+ update: vi.fn(),
96
+ getByState: vi.fn(() => []),
97
+ getActiveMrs: vi.fn(() => []),
98
+ remove: vi.fn(() => true),
99
+ archive: vi.fn(() => true),
100
+ recordReviewEvent: vi.fn(),
101
+ recordPush: vi.fn(() => null),
102
+ loadTracking: vi.fn(() => null),
103
+ saveTracking: vi.fn(),
104
+ };
105
+ }
106
+ function createStubContextGateway() {
107
+ return {
108
+ create: vi.fn(() => ({ success: true, filePath: '' })),
109
+ read: vi.fn(() => null),
110
+ delete: vi.fn(() => ({ success: true, deleted: true })),
111
+ exists: vi.fn(() => false),
112
+ getFilePath: vi.fn(() => ''),
113
+ appendAction: vi.fn(() => ({ success: true })),
114
+ updateProgress: vi.fn(() => ({ success: true })),
115
+ setResult: vi.fn(() => ({ success: true })),
116
+ listAll: vi.fn(() => []),
117
+ };
118
+ }
119
+ function createAcceptAllEnforceBudget() {
120
+ return {
121
+ execute: vi.fn(async () => ({
122
+ accepted: true,
123
+ status: {
124
+ limitUsd: 200,
125
+ consumedUsd: 0,
126
+ remainingUsd: 200,
127
+ percentUsed: 0,
128
+ exceeded: false,
129
+ periodStart: '2026-05-01T00:00:00.000Z',
130
+ },
131
+ })),
132
+ };
133
+ }
134
+ function buildBaseDeps(trackingGateway) {
135
+ const threadFetchGateway = { fetchThreads: vi.fn(() => []) };
136
+ return {
137
+ reviewContextGateway: createStubContextGateway(),
138
+ threadFetchGateway,
139
+ diffMetadataFetchGateway: {
140
+ fetchDiffMetadata: vi.fn(() => ({ baseSha: 'abc', headSha: 'def', startSha: 'ghi' })),
141
+ },
142
+ diffStatsFetchGateway: { fetchDiffStats: vi.fn(() => null) },
143
+ trackAssignment: new TrackAssignmentUseCase(trackingGateway),
144
+ recordCompletion: new RecordReviewCompletionUseCase(trackingGateway),
145
+ recordPush: new RecordPushUseCase(trackingGateway),
146
+ transitionState: new TransitionStateUseCase(trackingGateway),
147
+ checkFollowupNeeded: new CheckFollowupNeededUseCase(trackingGateway),
148
+ syncThreads: new SyncThreadsUseCase(trackingGateway, threadFetchGateway),
149
+ enforceBudget: createAcceptAllEnforceBudget(),
150
+ broadcastBudgetExceeded: vi.fn(),
151
+ getRepositories: vi.fn(() => []),
152
+ removeWorktree: vi.fn(async () => ({ status: 'removed' })),
153
+ recordBypass: new RecordBypassUseCase(trackingGateway),
154
+ noteCommentPostGateway: new StubNoteCommentPostGateway(),
155
+ handlePlatformApproval: new HandlePlatformApprovalUseCase(trackingGateway),
156
+ approvalRevocationGateway: new StubApprovalRevocationGateway(),
157
+ getQualityThreshold: () => null,
158
+ now: () => '2026-05-26T12:00:00.000Z',
159
+ };
160
+ }
161
+ /**
162
+ * Wires the SPEC-197 chokepoint: a real IsTrustedActorUseCase backed by the recording
163
+ * StubMemberAccessGateway, and a real full-auto GateClaudeInvocationUseCase whose park
164
+ * branch saves into the StubPendingReviewRequestGateway. Observable job state is the only
165
+ * thing asserted — enqueue call count, pending saveCount, and the HTTP reply.
166
+ */
167
+ function buildGatedDeps(trackingGateway, memberAccess, pendingGateway) {
168
+ const gateClaudeInvocation = new GateClaudeInvocationUseCase({
169
+ getTriggerMode: () => 'full-auto',
170
+ pendingReviewRequestGateway: pendingGateway,
171
+ enqueue: enqueueReview,
172
+ broadcastPendingChanged: () => { },
173
+ logger,
174
+ });
175
+ return {
176
+ ...buildBaseDeps(trackingGateway),
177
+ gateClaudeInvocation,
178
+ isTrustedActor: new IsTrustedActorUseCase(memberAccess),
179
+ };
180
+ }
181
+ function buildFollowupMr() {
182
+ return TrackedMrFactory.create({
183
+ id: TRACKED_MR_ID,
184
+ mrNumber: 42,
185
+ platform: 'gitlab',
186
+ project: PROJECT_PATH,
187
+ state: 'pending-review',
188
+ openThreads: 3,
189
+ autoFollowup: true,
190
+ lastPushAt: '2026-05-26T12:00:00.000Z',
191
+ lastReviewAt: '2026-05-25T12:00:00.000Z',
192
+ });
193
+ }
194
+ function buildNoteEvent(note) {
195
+ return {
196
+ object_kind: 'note',
197
+ event_type: 'note',
198
+ user: { username: 'note-author', name: 'Note Author' },
199
+ project: {
200
+ id: 1,
201
+ name: 'test-project',
202
+ path_with_namespace: PROJECT_PATH,
203
+ web_url: 'https://gitlab.com/test-org/test-project',
204
+ git_http_url: 'https://gitlab.com/test-org/test-project.git',
205
+ },
206
+ object_attributes: {
207
+ id: 7,
208
+ note,
209
+ noteable_type: 'MergeRequest',
210
+ noteable_id: 99,
211
+ },
212
+ merge_request: {
213
+ iid: 42,
214
+ title: 'Test MR',
215
+ state: 'opened',
216
+ source_branch: 'feature/test',
217
+ target_branch: 'main',
218
+ url: 'https://gitlab.com/test-org/test-project/-/merge_requests/42',
219
+ },
220
+ };
221
+ }
222
+ function asRequest(body) {
223
+ return { body, headers: {} };
224
+ }
225
+ describe('SPEC-197 trusted-actor trigger provenance gate (acceptance — full chokepoint handleGitLabWebhook)', () => {
226
+ let mockReply;
227
+ beforeEach(() => {
228
+ vi.clearAllMocks();
229
+ mockReply = {
230
+ status: vi.fn().mockReturnThis(),
231
+ send: vi.fn().mockReturnThis(),
232
+ };
233
+ });
234
+ afterEach(() => {
235
+ vi.resetAllMocks();
236
+ });
237
+ describe('AC1 — reviewer-added gate', () => {
238
+ it('parks a reviewer-added trigger from a Reporter and enqueues one from a Developer', async () => {
239
+ const reporterTracking = createMockTrackingGateway(null);
240
+ const reporterMembers = new StubMemberAccessGateway();
241
+ reporterMembers.setAccess('reporter-actor', MEMBER_ACCESS_LEVELS.reporter);
242
+ const reporterPending = new StubPendingReviewRequestGateway();
243
+ const reporterDeps = buildGatedDeps(reporterTracking, reporterMembers, reporterPending);
244
+ const reporterEvent = GitLabEventFactory.createWithReviewerAdded('claude-bot');
245
+ reporterEvent.user = { username: 'reporter-actor', name: 'Reporter Actor' };
246
+ await handleGitLabWebhook(asRequest(reporterEvent), mockReply, logger, reporterTracking, reporterDeps);
247
+ expect(enqueueReview).not.toHaveBeenCalled();
248
+ expect(reporterPending.saveCount).toBe(1);
249
+ const developerTracking = createMockTrackingGateway(null);
250
+ const developerMembers = new StubMemberAccessGateway();
251
+ developerMembers.setAccess('dev-actor', MEMBER_ACCESS_LEVELS.developer);
252
+ const developerPending = new StubPendingReviewRequestGateway();
253
+ const developerDeps = buildGatedDeps(developerTracking, developerMembers, developerPending);
254
+ const developerEvent = GitLabEventFactory.createWithReviewerAdded('claude-bot');
255
+ developerEvent.user = { username: 'dev-actor', name: 'Dev Actor' };
256
+ await handleGitLabWebhook(asRequest(developerEvent), mockReply, logger, developerTracking, developerDeps);
257
+ expect(enqueueReview).toHaveBeenCalledTimes(1);
258
+ expect(developerPending.saveCount).toBe(0);
259
+ });
260
+ });
261
+ describe('AC2 — followup / MR-update gate', () => {
262
+ it('parks a followup from a Reporter and enqueues one from a Developer (payloads differ only by username)', async () => {
263
+ const reporterTracking = createMockTrackingGateway(buildFollowupMr());
264
+ reporterTracking.getByNumber.mockReturnValue(buildFollowupMr());
265
+ reporterTracking.recordPush.mockReturnValue(buildFollowupMr());
266
+ const reporterMembers = new StubMemberAccessGateway();
267
+ reporterMembers.setAccess('reporter-actor', MEMBER_ACCESS_LEVELS.reporter);
268
+ const reporterPending = new StubPendingReviewRequestGateway();
269
+ const reporterDeps = buildGatedDeps(reporterTracking, reporterMembers, reporterPending);
270
+ const reporterEvent = GitLabEventFactory.createMrUpdate();
271
+ reporterEvent.user = { username: 'reporter-actor', name: 'Reporter Actor' };
272
+ await handleGitLabWebhook(asRequest(reporterEvent), mockReply, logger, reporterTracking, reporterDeps);
273
+ expect(enqueueReview).not.toHaveBeenCalled();
274
+ expect(reporterPending.saveCount).toBe(1);
275
+ const developerTracking = createMockTrackingGateway(buildFollowupMr());
276
+ developerTracking.getByNumber.mockReturnValue(buildFollowupMr());
277
+ developerTracking.recordPush.mockReturnValue(buildFollowupMr());
278
+ const developerMembers = new StubMemberAccessGateway();
279
+ developerMembers.setAccess('dev-actor', MEMBER_ACCESS_LEVELS.developer);
280
+ const developerPending = new StubPendingReviewRequestGateway();
281
+ const developerDeps = buildGatedDeps(developerTracking, developerMembers, developerPending);
282
+ const developerEvent = GitLabEventFactory.createMrUpdate();
283
+ developerEvent.user = { username: 'dev-actor', name: 'Dev Actor' };
284
+ await handleGitLabWebhook(asRequest(developerEvent), mockReply, logger, developerTracking, developerDeps);
285
+ expect(enqueueReview).toHaveBeenCalledTimes(1);
286
+ expect(developerPending.saveCount).toBe(0);
287
+ });
288
+ });
289
+ describe('AC3 — note / comment gate', () => {
290
+ it('parks a note from a Reporter with 202 untrusted-actor and lets a Developer note proceed', async () => {
291
+ vi.mocked(getGitLabEventType).mockReturnValue('Note Hook');
292
+ const reporterTracking = createMockTrackingGateway(TrackedMrFactory.create({
293
+ id: TRACKED_MR_ID,
294
+ mrNumber: 42,
295
+ platform: 'gitlab',
296
+ project: PROJECT_PATH,
297
+ }));
298
+ const reporterMembers = new StubMemberAccessGateway();
299
+ reporterMembers.setAccess('note-author', MEMBER_ACCESS_LEVELS.reporter);
300
+ const reporterPending = new StubPendingReviewRequestGateway();
301
+ const reporterDeps = buildGatedDeps(reporterTracking, reporterMembers, reporterPending);
302
+ await handleGitLabWebhook(asRequest(buildNoteEvent('/bypass-quality "reason here"')), mockReply, logger, reporterTracking, reporterDeps);
303
+ expect(mockReply.status).toHaveBeenCalledWith(202);
304
+ expect(mockReply.send).toHaveBeenCalledWith(expect.objectContaining({ status: 'pending-confirmation', reason: 'untrusted-actor' }));
305
+ expect(reporterTracking.update).not.toHaveBeenCalled();
306
+ const developerTracking = createMockTrackingGateway(TrackedMrFactory.create({
307
+ id: TRACKED_MR_ID,
308
+ mrNumber: 42,
309
+ platform: 'gitlab',
310
+ project: PROJECT_PATH,
311
+ }));
312
+ const developerMembers = new StubMemberAccessGateway();
313
+ developerMembers.setAccess('note-author', MEMBER_ACCESS_LEVELS.developer);
314
+ const developerPending = new StubPendingReviewRequestGateway();
315
+ const developerDeps = buildGatedDeps(developerTracking, developerMembers, developerPending);
316
+ const developerReply = {
317
+ status: vi.fn().mockReturnThis(),
318
+ send: vi.fn().mockReturnThis(),
319
+ };
320
+ await handleGitLabWebhook(asRequest(buildNoteEvent('/bypass-quality "reason here"')), developerReply, logger, developerTracking, developerDeps);
321
+ expect(developerReply.status).not.toHaveBeenCalledWith(202);
322
+ expect(developerReply.send).toHaveBeenCalledWith(expect.objectContaining({ status: 'bypass-recorded' }));
323
+ });
324
+ });
325
+ describe('AC4 — fail-closed membership resolution', () => {
326
+ it('parks every trigger type when membership resolution throws', async () => {
327
+ const reviewerTracking = createMockTrackingGateway(null);
328
+ const reviewerMembers = new StubMemberAccessGateway();
329
+ reviewerMembers.setShouldFail(true);
330
+ const reviewerPending = new StubPendingReviewRequestGateway();
331
+ const reviewerDeps = buildGatedDeps(reviewerTracking, reviewerMembers, reviewerPending);
332
+ const reviewerEvent = GitLabEventFactory.createWithReviewerAdded('claude-bot');
333
+ reviewerEvent.user = { username: 'dev-actor', name: 'Dev Actor' };
334
+ await handleGitLabWebhook(asRequest(reviewerEvent), mockReply, logger, reviewerTracking, reviewerDeps);
335
+ expect(enqueueReview).not.toHaveBeenCalled();
336
+ expect(reviewerPending.saveCount).toBe(1);
337
+ const followupTracking = createMockTrackingGateway(buildFollowupMr());
338
+ followupTracking.getByNumber.mockReturnValue(buildFollowupMr());
339
+ followupTracking.recordPush.mockReturnValue(buildFollowupMr());
340
+ const followupMembers = new StubMemberAccessGateway();
341
+ followupMembers.setShouldFail(true);
342
+ const followupPending = new StubPendingReviewRequestGateway();
343
+ const followupDeps = buildGatedDeps(followupTracking, followupMembers, followupPending);
344
+ const followupEvent = GitLabEventFactory.createMrUpdate();
345
+ followupEvent.user = { username: 'dev-actor', name: 'Dev Actor' };
346
+ await handleGitLabWebhook(asRequest(followupEvent), mockReply, logger, followupTracking, followupDeps);
347
+ expect(enqueueReview).not.toHaveBeenCalled();
348
+ expect(followupPending.saveCount).toBe(1);
349
+ vi.mocked(getGitLabEventType).mockReturnValue('Note Hook');
350
+ const noteTracking = createMockTrackingGateway(TrackedMrFactory.create({
351
+ id: TRACKED_MR_ID,
352
+ mrNumber: 42,
353
+ platform: 'gitlab',
354
+ project: PROJECT_PATH,
355
+ }));
356
+ const noteMembers = new StubMemberAccessGateway();
357
+ noteMembers.setShouldFail(true);
358
+ const notePending = new StubPendingReviewRequestGateway();
359
+ const noteDeps = buildGatedDeps(noteTracking, noteMembers, notePending);
360
+ const noteReply = {
361
+ status: vi.fn().mockReturnThis(),
362
+ send: vi.fn().mockReturnThis(),
363
+ };
364
+ await handleGitLabWebhook(asRequest(buildNoteEvent('/bypass-quality "reason here"')), noteReply, logger, noteTracking, noteDeps);
365
+ expect(noteReply.status).toHaveBeenCalledWith(202);
366
+ expect(noteReply.send).toHaveBeenCalledWith(expect.objectContaining({ status: 'pending-confirmation', reason: 'untrusted-actor' }));
367
+ expect(noteTracking.update).not.toHaveBeenCalled();
368
+ });
369
+ });
370
+ describe('AC5 — cache does not widen trust', () => {
371
+ it('parks a trigger from an unprimed username even after another username resolved Developer', async () => {
372
+ const memberAccess = new StubMemberAccessGateway();
373
+ memberAccess.setAccess('dev-actor', MEMBER_ACCESS_LEVELS.developer);
374
+ const pendingGateway = new StubPendingReviewRequestGateway();
375
+ const trustedTracking = createMockTrackingGateway(null);
376
+ const trustedDeps = buildGatedDeps(trustedTracking, memberAccess, pendingGateway);
377
+ const trustedEvent = GitLabEventFactory.createWithReviewerAdded('claude-bot');
378
+ trustedEvent.user = { username: 'dev-actor', name: 'Dev Actor' };
379
+ await handleGitLabWebhook(asRequest(trustedEvent), mockReply, logger, trustedTracking, trustedDeps);
380
+ expect(enqueueReview).toHaveBeenCalledTimes(1);
381
+ expect(pendingGateway.saveCount).toBe(0);
382
+ const malloryTracking = createMockTrackingGateway(null);
383
+ const malloryDeps = buildGatedDeps(malloryTracking, memberAccess, pendingGateway);
384
+ const malloryEvent = GitLabEventFactory.createWithReviewerAdded('claude-bot');
385
+ malloryEvent.user = { username: 'mallory', name: 'Mallory' };
386
+ await handleGitLabWebhook(asRequest(malloryEvent), mockReply, logger, malloryTracking, malloryDeps);
387
+ expect(enqueueReview).toHaveBeenCalledTimes(1);
388
+ expect(pendingGateway.saveCount).toBe(1);
389
+ expect(memberAccess.calls).toEqual([
390
+ { projectPath: PROJECT_PATH, username: 'dev-actor' },
391
+ { projectPath: PROJECT_PATH, username: 'mallory' },
392
+ ]);
393
+ });
394
+ });
395
+ describe('AC6 — token-boundary ordering', () => {
396
+ it('rejects an invalid-token trigger with 401 and never queries the membership gateway', async () => {
397
+ vi.mocked(verifyGitLabSignature).mockReturnValueOnce({ valid: false, error: 'bad-token' });
398
+ const tracking = createMockTrackingGateway(null);
399
+ const memberAccess = new StubMemberAccessGateway();
400
+ memberAccess.setAccess('dev-actor', MEMBER_ACCESS_LEVELS.developer);
401
+ const pendingGateway = new StubPendingReviewRequestGateway();
402
+ const deps = buildGatedDeps(tracking, memberAccess, pendingGateway);
403
+ const event = GitLabEventFactory.createWithReviewerAdded('claude-bot');
404
+ event.user = { username: 'dev-actor', name: 'Dev Actor' };
405
+ await handleGitLabWebhook(asRequest(event), mockReply, logger, tracking, deps);
406
+ expect(mockReply.status).toHaveBeenCalledWith(401);
407
+ expect(mockReply.send).toHaveBeenCalledWith({ error: 'bad-token' });
408
+ expect(enqueueReview).not.toHaveBeenCalled();
409
+ expect(memberAccess.calls.length).toBe(0);
410
+ });
411
+ });
412
+ });
413
+ //# sourceMappingURL=197-trusted-actor-provenance-gate.acceptance.test.js.map