diff --git a/backend/src/__tests__/git-source-routes.test.ts b/backend/src/__tests__/git-source-routes.test.ts index a93b579b..43672532 100644 --- a/backend/src/__tests__/git-source-routes.test.ts +++ b/backend/src/__tests__/git-source-routes.test.ts @@ -2550,6 +2550,36 @@ describe('git-source policy compatibility', () => { expect(res.body.error).toMatch(/source_policy/i); }); + it('PUT racing an in-flight operation maps the refusal to 409', async () => { + const stackName = 'policy-put-in-flight'; + seedStackDir(stackName); + seedGitSource(stackName); + seedApp(stackName, 'automatic'); + // Open a fetch on the application so the policy transition refuses. + // The PUT transaction must roll back whole, and the specific refusal + // must reach the operator as 409, not as a generic 500. + GitOpsTransitions.getInstance().fetchStarted( + `policy-app-${stackName}`, + { operationId: 'fetch-policy-put', actor: 'test', trigger: 'poll', at: Date.now() }, + ); + const fetchFromGit = stubFetchFromGit(); + try { + const res = await request(app) + .put(`/api/stacks/${stackName}/git-source`) + .set('Authorization', `Bearer ${adminToken()}`) + .send(putBody({ auto_apply_on_webhook: false, source_policy: 'review' })); + expect(res.status).toBe(409); + expect(res.body.error).toMatch(/in flight/); + // The whole save rolled back: the policy did not change under the + // refused PUT. + const application = GitOpsStore.getInstance().getLiveDirectApplication(stackName); + expect(application?.source_policy).toBe('automatic'); + } finally { + fetchFromGit.mockRestore(); + deleteRows(stackName); + } + }); + it('read projects auto_apply_on_webhook true only for automatic', async () => { const stackName = 'policy-read-projection'; seedStackDir(stackName); diff --git a/backend/src/services/GitSourceService.ts b/backend/src/services/GitSourceService.ts index 8ed26310..76496dda 100644 --- a/backend/src/services/GitSourceService.ts +++ b/backend/src/services/GitSourceService.ts @@ -911,8 +911,11 @@ export class GitSourceService { // The source row, the pending clear, and the GitOps transition commit // together. Clearing pending without invalidating the candidate would // leave the model offering an apply for files the operator can no - // longer produce. - db.getDb().transaction(() => { + // longer produce. A refused transition (an operation went in flight) + // aborts the whole save; it is mapped below so the operator sees the + // specific refusal, not a generic 500. + try { + db.getDb().transaction(() => { db.upsertGitSource({ stack_name: input.stackName, repo_url: input.repoUrl, @@ -990,6 +993,12 @@ export class GitSourceService { GitOpsTransitions.getInstance().sourcePolicyChanged(app.id, effectivePolicy, envelope); } })(); + } catch (error) { + if (error instanceof GitOpsTransitionError) { + throw new GitSourceError('OPERATION_IN_FLIGHT', `Cannot save the Git source for ${input.stackName}: ${error.message}`); + } + throw error; + } if ( input.authType === 'deploy_key'