From e9fb138f3507fd0446900973d3f3fb845dd654c1 Mon Sep 17 00:00:00 2001 From: Anso Date: Wed, 9 Sep 2026 18:25:31 -0400 Subject: [PATCH] test(gitops): pin suspended-source policy edits, audit row, and index rebuild Three coverage gaps from the test-analysis pass: a policy PUT on a suspended source must succeed (suspension gates work, not configuration, and a future hardening refactor that re-adds the guard would 500 operator edits); the source_policy_changed history row is asserted so a dropped audit insert fails loudly; and a PATCH with a backoff row exercises the rescheduleAll skip through the HTTP route. The poll due index is dropped before creation because CREATE INDEX IF NOT EXISTS never updates an existing index, so upgraded installs would keep the pre-retry_at WHERE clause; the schema SQL now forces the rebuild. --- .../src/__tests__/git-source-routes.test.ts | 59 +++++++++++++++++++ backend/src/services/gitops/schema.ts | 7 ++- 2 files changed, 65 insertions(+), 1 deletion(-) diff --git a/backend/src/__tests__/git-source-routes.test.ts b/backend/src/__tests__/git-source-routes.test.ts index b2ab015a..a93b579b 100644 --- a/backend/src/__tests__/git-source-routes.test.ts +++ b/backend/src/__tests__/git-source-routes.test.ts @@ -2433,6 +2433,40 @@ describe('git-source policy compatibility', () => { .get(`/api/stacks/${stackName}/git-source`) .set('Authorization', `Bearer ${adminToken()}`); expect(after.body.auto_apply_on_webhook).toBe(false); + // The transition writes an audit line: the history row is half + // the feature, so its absence must fail this test. + const history = DatabaseService.getInstance().getDb() + .prepare("SELECT COUNT(*) AS n FROM gitops_history WHERE application_id = ? AND stage = 'source_policy_changed'") + .get(`policy-app-${stackName}`) as { n: number }; + expect(history.n).toBe(1); + } finally { + fetchFromGit.mockRestore(); + deleteRows(stackName); + } + }); + + it('PUT persists a policy change on a suspended source', async () => { + const stackName = 'policy-put-suspended'; + seedStackDir(stackName); + seedGitSource(stackName); + seedApp(stackName, 'review'); + GitOpsTransitions.getInstance().sourceSuspended( + `policy-app-${stackName}`, + 'test suspension', + { operationId: 'suspend-policy-put', actor: 'test', trigger: 'config_change', 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: true })); + expect(res.status).toBe(200); + // Suspension gates fetching and applying, not configuration: an + // operator must be able to re-policy a paused source before + // resuming it, so the guard is deliberately absent here. + const application = GitOpsStore.getInstance().getLiveDirectApplication(stackName); + expect(application?.source_policy).toBe('automatic'); } finally { fetchFromGit.mockRestore(); deleteRows(stackName); @@ -2736,6 +2770,31 @@ describe('git-source polling settings', () => { } }); + it('PATCH does not arm a poll cursor over a live retry backoff', async () => { + const stackName = 'polling-patch-backoff'; + seedPollApp(stackName, 'automatic'); + const retryAt = Date.now() + 10 * 60_000; + DatabaseService.getInstance().getDb() + .prepare('UPDATE gitops_applications SET retry_at = ? WHERE id = ?') + .run(retryAt, `poll-app-${stackName}`); + try { + const res = await request(app) + .patch('/api/git-sources/polling') + .set('Authorization', `Bearer ${adminToken()}`) + .send({ poll_interval_mins: 5 }); + expect(res.status).toBe(200); + // The retry cursor stays the next wake: the row projects with no + // new poll cursor, and the stored backoff is untouched. + const row = res.body.per_source.find((r: { stack_name: string }) => r.stack_name === stackName); + expect(row.next_poll_at).toBeNull(); + const stored = GitOpsStore.getInstance().getApplication(`poll-app-${stackName}`); + expect(stored?.retry_at).toBe(retryAt); + } finally { + DatabaseService.getInstance().updateGlobalSetting('gitops_poll_interval_mins', '0'); + deletePollRows(stackName); + } + }); + it('PATCH 0 leaves existing next_poll_at values alone (off means off)', async () => { const stackName = 'polling-patch-off'; seedPollApp(stackName, 'automatic'); diff --git a/backend/src/services/gitops/schema.ts b/backend/src/services/gitops/schema.ts index 96520ceb..8cd656d3 100644 --- a/backend/src/services/gitops/schema.ts +++ b/backend/src/services/gitops/schema.ts @@ -168,7 +168,12 @@ CREATE INDEX IF NOT EXISTS idx_gitops_app_detached_direct -- it serves (listSourcesDueForPoll / listApplicationsDueForRetry), keeping -- the index to the handful of rows actually waiting on a cursor. The poll -- index also excludes rows holding a retry cursor: retry-due rows arrive --- via the retry scan, and a backoff row must not be refetched early. +-- via the retry scan, and a backoff row must not be refetched early. The +-- DROP precedes the CREATE because CREATE INDEX IF NOT EXISTS never updates +-- an index that already exists under the same name: a database created +-- before the retry_at term was added would otherwise keep serving the old +-- WHERE clause on every upgraded install. +DROP INDEX IF EXISTS idx_gitops_app_poll_due; CREATE INDEX IF NOT EXISTS idx_gitops_app_poll_due ON gitops_applications(next_poll_at) WHERE target_mode = 'direct'