diff --git a/backend/src/__tests__/git-source-routes.test.ts b/backend/src/__tests__/git-source-routes.test.ts index b160c431..b2ab015a 100644 --- a/backend/src/__tests__/git-source-routes.test.ts +++ b/backend/src/__tests__/git-source-routes.test.ts @@ -2412,6 +2412,80 @@ describe('git-source policy compatibility', () => { } }); + it('PUT on an existing source with explicit source_policy persists the resolved policy', async () => { + const stackName = 'policy-put-existing-review'; + seedStackDir(stackName); + seedGitSource(stackName); + seedApp(stackName, 'automatic'); + 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(200); + // The stored row carries the new policy, not just the response. + const application = GitOpsStore.getInstance().getLiveDirectApplication(stackName); + expect(application?.source_policy).toBe('review'); + // The projection flips with it: review never auto-applies. + expect(res.body.auto_apply_on_webhook).toBe(false); + const after = await request(app) + .get(`/api/stacks/${stackName}/git-source`) + .set('Authorization', `Bearer ${adminToken()}`); + expect(after.body.auto_apply_on_webhook).toBe(false); + } finally { + fetchFromGit.mockRestore(); + deleteRows(stackName); + } + }); + + it('PUT on an existing source with auto_apply_on_webhook true upgrades review to automatic', async () => { + const stackName = 'policy-put-existing-automatic'; + seedStackDir(stackName); + seedGitSource(stackName); + seedApp(stackName, 'review'); + 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: true })); + expect(res.status).toBe(200); + const application = GitOpsStore.getInstance().getLiveDirectApplication(stackName); + expect(application?.source_policy).toBe('automatic'); + expect(res.body.auto_apply_on_webhook).toBe(true); + } finally { + fetchFromGit.mockRestore(); + deleteRows(stackName); + } + }); + + it.each([ + ['false', false], + ['omitted', undefined], + ])('PUT on an existing automatic source with %s auto_apply_on_webhook stays automatic', async (_name, flag) => { + const stackName = `policy-put-existing-keeps-auto-${_name}`; + seedStackDir(stackName); + seedGitSource(stackName); + seedApp(stackName, 'automatic'); + 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: flag })); + expect(res.status).toBe(200); + // Boolean 0 or absent must not silently convert an existing + // automatic policy; the operator has to ask explicitly. + const application = GitOpsStore.getInstance().getLiveDirectApplication(stackName); + expect(application?.source_policy).toBe('automatic'); + expect(res.body.auto_apply_on_webhook).toBe(true); + } finally { + fetchFromGit.mockRestore(); + deleteRows(stackName); + } + }); + it('explicit source_policy wins over the boolean', async () => { const stackName = 'policy-explicit-wins'; seedStackDir(stackName); diff --git a/backend/src/services/GitSourceService.ts b/backend/src/services/GitSourceService.ts index 1dbae804..f490af1f 100644 --- a/backend/src/services/GitSourceService.ts +++ b/backend/src/services/GitSourceService.ts @@ -983,6 +983,15 @@ export class GitSourceService { envelope, }); } + // A policy change on an existing application persists durably: the + // create path stamps the policy through activateDirect, and this is + // the existing-app counterpart, so explicit PUT policy edits (and + // boolean-true upgrades) survive the save. A boolean 0 or absent + // resolves to the existing policy here and never silently converts + // it. + if (app && app.source_policy !== effectivePolicy) { + GitOpsTransitions.getInstance().sourcePolicyChanged(app.id, effectivePolicy, envelope); + } })(); if ( diff --git a/backend/src/services/gitops/history.ts b/backend/src/services/gitops/history.ts index f7e8a5b3..6318d6f7 100644 --- a/backend/src/services/gitops/history.ts +++ b/backend/src/services/gitops/history.ts @@ -71,6 +71,7 @@ export type GitOpsHistoryStage = | 'source_accepted' | 'source_conflict_blocker' | 'source_poll_scheduled' + | 'source_policy_changed' | 'source_reconcile_started' | 'source_reconcile_settled' | 'source_retry_scheduled' diff --git a/backend/src/services/gitops/transitions.ts b/backend/src/services/gitops/transitions.ts index 8decfc39..db95200d 100644 --- a/backend/src/services/gitops/transitions.ts +++ b/backend/src/services/gitops/transitions.ts @@ -20,6 +20,7 @@ import type { GitOpsIntentRevisionRow, GitOpsRolloutCandidateRow, GitOpsTargetCurrentRow, + SourcePolicy, } from './types'; export type EventEnvelope = { @@ -894,6 +895,23 @@ export class GitOpsTransitions { }); } + /** + * Persist a resolved source policy onto a live application. The create path + * stamps the policy through activateDirect; this is the existing-app path, + * so an operator who changes the policy on an already-linked source gets a + * durable row change (and an audit line) instead of a silently discarded + * PUT field. + */ + sourcePolicyChanged(applicationId: string, sourcePolicy: SourcePolicy, envelope: EventEnvelope): TransitionResult { + return this.mutateApp(applicationId, envelope, 'source_policy_changed', 'committed', (app) => { + if (app.suspended_at) throw new GitOpsTransitionError('source is suspended'); + if (app.active_operation_stage) { + throw new GitOpsTransitionError('cannot change the source policy while an operation is in flight'); + } + app.source_policy = sourcePolicy; + }); + } + /** * Stop acting on a source without forgetting anything about it. *