From d1ec718a1bdfb8058742d96caf42dc75fe495830 Mon Sep 17 00:00:00 2001 From: alex Date: Thu, 22 Oct 2026 15:37:59 +1100 Subject: [PATCH 1/8] test: add v0.6.0 release audit routing and scheduler coverage Independent end-to-end checks through real Git, SQLite and the worker: Unicode/space/parenthesis paths and root-prefix traps, repository failure isolation, fetch exhaustion before routing (fail-closed, cursor kept), signing rotation between accept and route, credential rotation and disabling with queued work, automation off/on re-baselining, unknown outcome hold and manual recovery, and cross-workspace ingress. Also resolves the two open Kody test findings (#63 exact scoped set, #64 cleanup no longer masks the original e2e failure) and adds a scheduler test for worker/server-disabled automatic fetching. Co-Authored-By: Claude Opus 5.5 --- tests/e2e/git-webhooks.spec.ts | 14 +- .../dashboard-draft-counts.test.ts | 3 + .../integration/release-audit-routing.test.ts | 376 ++++++++++++++++++ tests/unit/repository-auto-fetch.test.ts | 20 +- 4 files changed, 405 insertions(+), 8 deletions(-) create mode 100644 tests/integration/release-audit-routing.test.ts diff --git a/tests/e2e/git-webhooks.spec.ts b/tests/e2e/git-webhooks.spec.ts index 6220f78..340af44 100644 --- a/tests/e2e/git-webhooks.spec.ts +++ b/tests/e2e/git-webhooks.spec.ts @@ -34,6 +34,7 @@ test('signing secret saves, rotates with confirmation and disables', async ({ pa test.skip(configured, 'Existing signing secrets cannot be restored; leave configuration untouched.'); await page.goto(`/w/${workspaceId}/repositories/${repositoryId}`); const region = page.getByRole('region', { name: 'Git webhooks', exact: true }); + let bodyPassed = false; try { await region .getByLabel('Signing secret', { exact: true }) @@ -89,13 +90,14 @@ test('signing secret saves, rotates with confirmation and disables', async ({ pa await region.getByRole('button', { name: 'Disable webhook…' }).click(); await region.getByRole('button', { name: 'Confirm disable' }).click(); await expect(region.getByText('Disabled', { exact: true })).toBeVisible(); + bodyPassed = true; } finally { - const cleanup = await page.request.delete( - `/api/workspaces/${workspaceId}/repositories/${repositoryId}/webhook`, - { + // Never let cleanup mask the original failure; assert cleanup only after a passing body. + const cleanup = await page.request + .delete(`/api/workspaces/${workspaceId}/repositories/${repositoryId}/webhook`, { headers: { origin: new URL(page.url()).origin }, - }, - ); - expect(cleanup.ok()).toBe(true); + }) + .catch(() => null); + if (bodyPassed) expect(cleanup?.ok()).toBe(true); } }); diff --git a/tests/integration/dashboard-draft-counts.test.ts b/tests/integration/dashboard-draft-counts.test.ts index b649a6c..95c0a16 100644 --- a/tests/integration/dashboard-draft-counts.test.ts +++ b/tests/integration/dashboard-draft-counts.test.ts @@ -74,13 +74,16 @@ describe('dashboard draft metadata query', () => { } const query = await h.container.source.listDraftPaths(['r1', 'r2']); + // Pin the exact set: three in-scope rows and never the r3 draft. expect(query).toEqual( expect.arrayContaining([ { repositoryId: 'r1', path: 'apps/wiki/compose.yaml' }, + { repositoryId: 'r1', path: 'apps/wiki-extra/compose.yaml' }, { repositoryId: 'r2', path: 'apps/web/compose.yaml' }, ]), ); expect(query).toHaveLength(3); + expect(query.some((row) => row.repositoryId === 'r3')).toBe(false); expect(JSON.stringify(query)).not.toContain('sensitive content'); const counts = await loadDashboardDraftCounts( diff --git a/tests/integration/release-audit-routing.test.ts b/tests/integration/release-audit-routing.test.ts new file mode 100644 index 0000000..ef33afa --- /dev/null +++ b/tests/integration/release-audit-routing.test.ts @@ -0,0 +1,376 @@ +/** + * v0.6.0 release audit (#57): independent end-to-end checks of selective routing through real Git, + * SQLite and the durable worker. These cover combinations not exercised by deployment-routing.test.ts. + */ +import { createHmac, randomUUID } from 'node:crypto'; +import fs from 'node:fs'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import * as setup from '@/app/api/setup/route'; +import { DEFAULT_ROUTING_POLICY } from '@/shared/deployment-routing'; +import { DeploymentService } from '@/server/application/deployment-service'; +import { openSqlite } from '@/server/persistence/sqlite/database'; +import { createHarness, type TestHarness } from '../support/container'; +import { startGitServer, gitServerOverrides, type GitServer } from '../support/git-server'; +import { call } from '../support/http'; + +let h: TestHarness, server: GitServer, actor: string; +const workspaceId = 'home'; +// Random per run: no credential-shaped literals in the repository. +const secrets: Record = {}; +const trigger = vi.fn(); +const policy = { ...DEFAULT_ROUTING_POLICY, dependencies: ['shared/proxy/**', 'shared/ünï cødé/**'] }; + +function sql>(statement: string, ...values: unknown[]): T[] { + const handle = openSqlite(h.config.databasePath); + try { + const prepared = handle.db.$client.prepare(statement); + if (prepared.reader) return prepared.all(...values) as T[]; + prepared.run(...values); + return []; + } finally { + handle.close(); + } +} +async function drain() { + for (let i = 0; i < 40 && (await h.container.worker.runOnce()); i++) { + /* bounded test drain */ + } +} +async function optIn(stackId: string, deployOnPush = true, enabled = true) { + const b = (await h.container.deployments.settings(workspaceId, stackId)).binding; + return h.container.deployments.configure( + workspaceId, + stackId, + { + providerType: 'generic-webhook', + enabled, + expectedRevision: b?.revision ?? null, + ...(b ? {} : { configuration: { url: `https://hooks.example.test/deploy/${stackId}` } }), + deployOnPush, + routingPolicy: policy, + }, + actor, + ); +} +async function deliver(repositoryId: string, sha: string, id = randomUUID()) { + const body = JSON.stringify({ + ref: 'refs/heads/main', + after: sha, + deleted: false, + repository: { clone_url: `https://git.test/${repositoryId}.git` }, + }); + const headers = new Headers({ + 'x-github-event': 'push', + 'x-github-delivery': id, + 'x-hub-signature-256': `sha256=${createHmac('sha256', secrets[repositoryId]!).update(body).digest('hex')}`, + }); + return h.container.gitWebhooks.receive(workspaceId, repositoryId, headers, Buffer.from(body)); +} +const change = (repo: string, files: Record) => + server.commitFiles(`${repo}.git`, 'main', files); +const decisions = (stackId: string) => h.container.repos.routing.history(workspaceId, stackId); +const attempts = async (stackId: string) => + (await h.container.deployments.history(workspaceId, stackId)).attempts; +const binding = async (stackId: string) => + (await h.container.deployments.settings(workspaceId, stackId)).binding!; + +function mockSend() { + const provider = { + type: 'generic-webhook' as const, + validateConfiguration: (v: unknown) => v as { url: string }, + validateDestination: async () => {}, + trigger, + }; + const service = new DeploymentService( + h.container.repos, + h.container.credentials, + new Map([['generic-webhook', provider]]), + h.container.jobs, + h.container.audit, + h.clock, + randomUUID, + ); + vi.spyOn(h.container.deployments, 'execute').mockImplementation((job, ctx) => service.execute(job, ctx)); +} + +async function connect(repositoryId: string) { + const now = h.clock.now(); + await h.container.repos.gitRepositories.insert({ + id: repositoryId, + workspaceId, + name: repositoryId, + gitProviderType: 'github', + remoteUrl: `https://git.test/${repositoryId}.git`, + defaultBranch: 'main', + credentialId: null, + webhookCredentialId: null, + localClonePath: `repos/${repositoryId}`, + syncStatus: 'pending', + headSha: null, + lastSyncError: null, + lastFetchedAt: null, + lastSyncAttemptAt: null, + lastSyncFailedAt: null, + lastHeadChangedAt: null, + autoAddStacks: false, + autoFetchEnabled: false, + autoFetchIntervalMinutes: null, + createdAt: now, + updatedAt: now, + }); + await h.container.repositories.sync(repositoryId); + await h.container.gitWebhooks.configure( + workspaceId, + repositoryId, + { secret: secrets[repositoryId]! }, + actor, + ); +} +async function stack(id: string, repositoryId: string, root: string) { + const now = h.clock.now(); + await h.container.repos.stacks.insert({ + id, + workspaceId, + repositoryId, + name: id, + slug: id, + rootPath: root, + composePath: `${root}/compose.yaml`, + createdAt: now, + updatedAt: now, + }); +} + +beforeEach(async () => { + secrets.infra = `audit-${randomUUID()}`; + secrets.media = `audit-${randomUUID()}`; + server = await startGitServer(); + server.createRepo('infra.git'); + server.createRepo('media.git'); + change('infra', { + 'apps/wiki/compose.yaml': 'services: {}', + 'apps/notes/compose.yaml': 'services: {}', + 'apps/wiki café/compose.yaml': 'services: {}', + }); + change('media', { 'photos/compose.yaml': 'services: {}' }); + h = await createHarness({ overrides: gitServerOverrides(server) }); + const user = await call(setup.POST, { + method: 'POST', + body: { username: 'admin', password: 'correct horse battery staple' }, + }); + actor = user.json.user.id; + const now = h.clock.now(); + await h.container.repos.workspaces.insert({ + id: workspaceId, + name: 'Home', + slug: 'audit-home', + createdAt: now, + updatedAt: now, + }); + await connect('infra'); + await connect('media'); + await stack('wiki', 'infra', 'apps/wiki'); + await stack('notes', 'infra', 'apps/notes'); + await stack('cafe', 'infra', 'apps/wiki café'); + await stack('photos', 'media', 'photos'); + trigger.mockReset().mockResolvedValue({ status: 'accepted', httpStatus: 204 }); + mockSend(); +}); +afterEach(async () => { + vi.restoreAllMocks(); + const dir = h.dataDir; + await h.cleanup(); + fs.rmSync(dir, { recursive: true, force: true }); + await server.close(); +}); + +describe('release audit: selective routing matrix', () => { + it('routes Unicode, spaces and unusual characters through real Git without prefix confusion', async () => { + await optIn('wiki'); + await optIn('cafe'); + await optIn('notes'); + const sha = change('infra', { + 'apps/wiki café/config/日本 #1 (ü).yml': 'x', + 'shared/ünï cødé/proxy.conf': 'y', + }); + expect(await deliver('infra', sha)).toBe('accepted'); + await drain(); + // `apps/wiki café` must not match the `apps/wiki` root; the shared dependency matches all three. + expect((await decisions('cafe'))[0]).toMatchObject({ + decision: 'deploy', + matchedPaths: ['apps/wiki café/config/日本 #1 (ü).yml', 'shared/ünï cødé/proxy.conf'], + }); + expect((await decisions('wiki'))[0]!.matchedPaths).toEqual(['shared/ünï cødé/proxy.conf']); + expect(trigger).toHaveBeenCalledTimes(3); + }); + + it('isolates repositories: a failing fetch in one repository does not block another', async () => { + await optIn('wiki'); + await optIn('photos'); + const real = h.container.repositories.sync.bind(h.container.repositories); + vi.spyOn(h.container.repositories, 'sync').mockImplementation(async (id, signal) => { + if (id === 'infra') throw new Error('simulated network failure'); + return real(id, signal); + }); + await deliver('infra', change('infra', { 'apps/wiki/config/a': 'a' })); + await deliver('media', change('media', { 'photos/config/b': 'b' })); + await drain(); + expect(await attempts('wiki')).toEqual([]); + expect((await attempts('photos'))[0]).toMatchObject({ status: 'accepted', triggerType: 'automatic' }); + expect(trigger).toHaveBeenCalledTimes(1); + }); + + it('never deploys when a verified delivery’s fetch exhausts retries, even after a later polled fetch', async () => { + await optIn('wiki'); + const baseline = (await binding('wiki')).routingHeadSha; + const real = h.container.repositories.sync.bind(h.container.repositories); + const sync = vi + .spyOn(h.container.repositories, 'sync') + .mockRejectedValue(new Error('simulated forge outage')); + const first = change('infra', { 'apps/wiki/config/a': 'a' }); + await deliver('infra', first); + for (let i = 0; i < 8; i++) { + await drain(); + h.clock.advance(16 * 60_000); + } + expect(sql<{ status: string }>("SELECT status FROM jobs WHERE type = 'repository_sync'")).toEqual([ + { status: 'dead' }, + ]); + sync.mockImplementation((id, signal) => real(id, signal)); + // Polling/manual fetch alone grants no deployment authority, so the orphaned source stays unrouted. + await h.container.repositories.requestSync(workspaceId, 'infra', actor); + await drain(); + expect(trigger).not.toHaveBeenCalled(); + expect(await decisions('wiki')).toEqual([]); + expect((await binding('wiki')).routingHeadSha).toBe(baseline); + // The next verified push covers the earlier change because the cursor never advanced. + h.clock.advance(1000); + await deliver('infra', change('infra', { 'apps/wiki/config/b': 'b' })); + await drain(); + expect((await decisions('wiki'))[0]).toMatchObject({ + decision: 'deploy', + matchedPaths: ['apps/wiki/config/a', 'apps/wiki/config/b'], + }); + expect(trigger).toHaveBeenCalledTimes(1); + }); + + it('blocks a source signed before rotation without advancing the cursor; the next push covers it', async () => { + await optIn('wiki'); + const before = (await binding('wiki')).routingHeadSha; + await deliver('infra', change('infra', { 'apps/wiki/config/a': 'a' })); + await h.container.gitWebhooks.configure( + workspaceId, + 'infra', + { secret: (secrets.infra = `audit-${randomUUID()}`) }, + actor, + ); + await drain(); + expect((await decisions('wiki'))[0]).toMatchObject({ + decision: 'blocked', + reasonCode: 'signing_changed', + }); + expect((await binding('wiki')).routingHeadSha).toBe(before); + expect(trigger).not.toHaveBeenCalled(); + h.clock.advance(1000); + await deliver('infra', change('infra', { 'apps/wiki/config/b': 'b' })); + await drain(); + expect((await decisions('wiki'))[0]).toMatchObject({ + decision: 'deploy', + matchedPaths: ['apps/wiki/config/a', 'apps/wiki/config/b'], + }); + expect(trigger).toHaveBeenCalledTimes(1); + }); + + it('rejects queued automatic work after deployment credential rotation or disabling, without sending', async () => { + await optIn('wiki'); + await optIn('notes'); + await deliver('infra', change('infra', { 'apps/wiki/config/a': 'a', 'apps/notes/config/a': 'a' })); + await h.container.worker.runOnce(); // fetch + await h.container.worker.runOnce(); // route: both attempts queued + expect((await attempts('wiki'))[0]!.status).toBe('queued'); + const wiki = await binding('wiki'); + await h.container.deployments.configure( + workspaceId, + 'wiki', + { + providerType: 'generic-webhook', + enabled: true, + expectedRevision: wiki.revision, + configuration: { url: 'https://hooks.example.test/deploy/rotated' }, + deployOnPush: true, + routingPolicy: policy, + }, + actor, + ); + await optIn('notes', false, false); + await drain(); + expect(trigger).not.toHaveBeenCalled(); + expect((await attempts('wiki'))[0]!.status).toBe('rejected'); + expect((await attempts('notes'))[0]!.status).toBe('rejected'); + }); + + it('turning automatic deployment off stops later pushes; turning it on re-baselines without replay', async () => { + await optIn('wiki'); + await optIn('wiki', false); + await deliver('infra', change('infra', { 'apps/wiki/config/a': 'a' })); + await drain(); + expect((await decisions('wiki'))[0]!.reasonCode).toBe('deploy_on_push_disabled'); + h.clock.advance(1000); + await optIn('wiki'); + expect((await binding('wiki')).routingHeadSha).toBe( + (await h.container.repos.gitRepositories.findById(workspaceId, 'infra'))!.headSha, + ); + await drain(); + expect(trigger).not.toHaveBeenCalled(); + }); + + it('holds automation after an unknown provider outcome until a deliberate manual request', async () => { + await optIn('wiki'); + trigger.mockResolvedValueOnce({ status: 'unknown', reason: 'request_failed' }); + await deliver('infra', change('infra', { 'apps/wiki/config/a': 'a' })); + await drain(); + expect((await attempts('wiki'))[0]!.status).toBe('unknown'); + expect((await binding('wiki')).automationHold).toBe(true); + h.clock.advance(1000); + await deliver('infra', change('infra', { 'apps/wiki/config/b': 'b' })); + await drain(); + expect((await decisions('wiki'))[0]).toMatchObject({ + decision: 'blocked', + reasonCode: 'outcome_unknown', + }); + expect(trigger).toHaveBeenCalledTimes(1); + // Re-saving settings does not clear the hold. + await optIn('wiki'); + expect((await binding('wiki')).automationHold).toBe(true); + await h.container.deployments.trigger(workspaceId, 'wiki', actor); + await drain(); + expect((await binding('wiki')).automationHold).toBe(false); + h.clock.advance(1000); + await deliver('infra', change('infra', { 'apps/wiki/config/c': 'c' })); + await drain(); + expect((await decisions('wiki'))[0]!.decision).toBe('deploy'); + expect(trigger).toHaveBeenCalledTimes(3); + expect(sql("SELECT count(*) AS n FROM deployment_attempts WHERE status = 'unknown'")).toEqual([{ n: 1 }]); + }); + + it('never routes a verified push for another workspace’s repository identifier', async () => { + await optIn('wiki'); + const sha = change('infra', { 'apps/wiki/config/a': 'a' }); + const body = JSON.stringify({ + ref: 'refs/heads/main', + after: sha, + deleted: false, + repository: { clone_url: 'https://git.test/infra.git' }, + }); + const headers = new Headers({ + 'x-github-event': 'push', + 'x-github-delivery': randomUUID(), + 'x-hub-signature-256': `sha256=${createHmac('sha256', secrets.infra!).update(body).digest('hex')}`, + }); + await expect( + h.container.gitWebhooks.receive('other-workspace', 'infra', headers, Buffer.from(body)), + ).rejects.toThrow('Webhook authentication failed.'); + await drain(); + expect(trigger).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/repository-auto-fetch.test.ts b/tests/unit/repository-auto-fetch.test.ts index bf6f54a..8448f6f 100644 --- a/tests/unit/repository-auto-fetch.test.ts +++ b/tests/unit/repository-auto-fetch.test.ts @@ -60,7 +60,10 @@ describe('isRepositoryAutoFetchDue', () => { }); describe('RepositoryAutoFetch', () => { - function make(overrides: Partial = {}) { + function make( + overrides: Partial = {}, + policy = { workerEnabled: true, autoFetchEnabled: true, autoFetchIntervalMinutes: 15 }, + ) { const listWorkspaces = vi.fn(async () => [{ id: 'w1' } as Workspace]); const listRepositories = vi.fn(async () => [repository(overrides)]); const enqueue = vi.fn(async () => ({ created: true, job: {} as Job })); @@ -72,7 +75,7 @@ describe('RepositoryAutoFetch', () => { { enqueue }, { now: () => now }, { warn, debug }, - { workerEnabled: true, autoFetchEnabled: true, autoFetchIntervalMinutes: 15 }, + policy, ); return { scheduler, enqueue, warn, listRepositories, listWorkspaces }; } @@ -87,6 +90,19 @@ describe('RepositoryAutoFetch', () => { ); }); + it.each([ + ['worker disabled', { workerEnabled: false, autoFetchEnabled: true, autoFetchIntervalMinutes: 15 }], + [ + 'server auto-fetch disabled', + { workerEnabled: true, autoFetchEnabled: false, autoFetchIntervalMinutes: 15 }, + ], + ])('never scans or enqueues when the %s, even for an overdue repository', async (_label, policy) => { + const { scheduler, enqueue, listWorkspaces } = make({ lastFetchedAt: minutesAgo(600) }, policy); + expect(await scheduler.runOnce()).toBe(0); + expect(listWorkspaces).not.toHaveBeenCalled(); + expect(enqueue).not.toHaveBeenCalled(); + }); + it('skips repositories that are not due', async () => { const { scheduler, enqueue } = make({ lastFetchedAt: minutesAgo(1) }); expect(await scheduler.runOnce()).toBe(0); From 38fa1164bbf936fe022e31c3ae6b65932b6091f3 Mon Sep 17 00:00:00 2001 From: alex Date: Thu, 22 Oct 2026 15:37:59 +1100 Subject: [PATCH 2/8] fix(audit): label deployment and routing activity events Dashboard and Settings > Activity showed raw ids such as deployment.unknown for all 13 v0.6.0 deployment audit actions. Labels state receiver acceptance rather than health, and a typed completeness test fails when a future audit action has no label. Co-Authored-By: Claude Opus 5.5 --- src/shared/audit/labels.ts | 14 +++++++++ tests/unit/audit-labels.test.ts | 52 +++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+) diff --git a/src/shared/audit/labels.ts b/src/shared/audit/labels.ts index 8d35c64..cd14833 100644 --- a/src/shared/audit/labels.ts +++ b/src/shared/audit/labels.ts @@ -28,6 +28,20 @@ const LABELS: Record = { 'git.push': 'Git commit pushed', 'git.push_rejected': 'Git push blocked', 'git.commit_validation_overridden': 'Commit warnings acknowledged', + // HTTP acceptance is provider acceptance, never verified container health. + 'deployment.binding_created': 'Deployment webhook configured', + 'deployment.binding_updated': 'Deployment webhook updated', + 'deployment.binding_disabled': 'Deployment webhook disabled', + 'deployment.binding_removed': 'Deployment webhook removed', + 'deployment.secret_rotated': 'Deployment webhook secret replaced', + 'deployment.destination_validated': 'Deployment destination checked (no request sent)', + 'deployment.requested': 'Manual deployment requested', + 'deployment.automatic_requested': 'Automatic deployment requested', + 'deployment.accepted': 'Deployment request accepted by receiver', + 'deployment.rejected': 'Deployment request rejected', + 'deployment.unknown': 'Deployment outcome unknown', + 'deployment.routing_evaluated': 'Git push routing evaluated', + 'deployment.stack_skipped': 'Stack not deployed for Git push', }; export function auditActionLabel(action: string): string { diff --git a/tests/unit/audit-labels.test.ts b/tests/unit/audit-labels.test.ts index 3566048..977143d 100644 --- a/tests/unit/audit-labels.test.ts +++ b/tests/unit/audit-labels.test.ts @@ -1,6 +1,52 @@ import { describe, expect, it } from 'vitest'; import { auditActionLabel } from '@/shared/audit/labels'; import { auditEntityHref } from '@/shared/audit/links'; +import type { AuditAction } from '@/server/domain/audit'; + +// Typed as a complete record so adding an AuditAction without updating this list fails typecheck. +const ALL_ACTIONS: Record = { + 'deployment.routing_evaluated': true, + 'deployment.stack_skipped': true, + 'deployment.automatic_requested': true, + 'deployment.binding_created': true, + 'deployment.binding_updated': true, + 'deployment.destination_validated': true, + 'deployment.binding_disabled': true, + 'deployment.binding_removed': true, + 'deployment.secret_rotated': true, + 'deployment.requested': true, + 'deployment.accepted': true, + 'deployment.rejected': true, + 'deployment.unknown': true, + 'auth.setup': true, + 'auth.login': true, + 'auth.login_failed': true, + 'auth.logout': true, + 'auth.password_changed': true, + 'auth.password_change_failed': true, + 'auth.git_identity_updated': true, + 'workspace.create': true, + 'workspace.update': true, + 'credential.create': true, + 'credential.update': true, + 'credential.replace_secret': true, + 'credential.delete': true, + 'repository.create': true, + 'repository.update': true, + 'repository.test': true, + 'repository.sync_requested': true, + 'repository.sync': true, + 'repository.delete': true, + 'stack.create': true, + 'stack.update': true, + 'stack.delete': true, + 'draft.save': true, + 'draft.discard': true, + 'git.commit': true, + 'git.push': true, + 'git.push_rejected': true, + 'git.commit_validation_overridden': true, +}; describe('auditActionLabel', () => { it('maps known audit actions to human labels', () => { @@ -12,6 +58,12 @@ describe('auditActionLabel', () => { expect(auditActionLabel('stack.create')).toBe('Stack added'); }); + it('labels every audit action, including v0.6.0 deployment events', () => { + for (const action of Object.keys(ALL_ACTIONS)) expect(auditActionLabel(action), action).not.toBe(action); + expect(auditActionLabel('deployment.accepted')).toBe('Deployment request accepted by receiver'); + expect(auditActionLabel('deployment.destination_validated')).toContain('no request sent'); + }); + it('falls back to the raw action for unknown values', () => { expect(auditActionLabel('future.unknown_action')).toBe('future.unknown_action'); }); From 9111309285444fd1aa9368d1ee3df10e04c6639b Mon Sep 17 00:00:00 2001 From: alex Date: Thu, 22 Oct 2026 15:37:59 +1100 Subject: [PATCH 3/8] docs: add v0.6.0 upgrade, rollback and release readiness guidance Rewrite backup and restore with tested WAL-safe procedures, key handling, failure modes, rollback and replay-ledger guidance. Add an Upgrading to v0.6.0 page with the operator acceptance checklist, document routing pattern character limits and unrouted pushes after fetch exhaustion, update the release gate, and record the internal readiness report. Co-Authored-By: Claude Opus 5.5 --- docs/internal/INDEX.md | 4 +- docs/internal/releases/v0.6.0-readiness.md | 102 ++++++++++++++++ docs/manifest.json | 7 ++ docs/public/BACKUP_RESTORE.md | 129 ++++++++++++++++----- docs/public/DEPLOYMENT_WEBHOOKS.md | 17 ++- docs/public/GIT_WEBHOOKS.md | 2 + docs/public/RELEASING.md | 15 ++- docs/public/SECURITY.md | 2 +- docs/public/SELF_HOSTING.md | 6 +- docs/public/UPGRADING_0_6.md | 56 +++++++++ docs/public/installation.md | 3 +- tests/unit/docs-layout.test.ts | 1 + 12 files changed, 302 insertions(+), 42 deletions(-) create mode 100644 docs/internal/releases/v0.6.0-readiness.md create mode 100644 docs/public/UPGRADING_0_6.md diff --git a/docs/internal/INDEX.md b/docs/internal/INDEX.md index 297f229..759d7c5 100644 --- a/docs/internal/INDEX.md +++ b/docs/internal/INDEX.md @@ -56,7 +56,9 @@ are listed in [manifest.json](../manifest.json) (see [docs-sync](../../.github/d | [AUTH_AND_CREDENTIALS.md](../public/AUTH_AND_CREDENTIALS.md) | Auth, encryption at rest | | [SECURITY.md](../public/SECURITY.md) | Threat model and controls | | [UX.md](UX.md) | Desktop / mobile / PWA | -| [BACKUP_RESTORE.md](../public/BACKUP_RESTORE.md) | Backup and restore | +| [BACKUP_RESTORE.md](../public/BACKUP_RESTORE.md) | Backup, restore and rollback | +| [UPGRADING_0_6.md](../public/UPGRADING_0_6.md) | v0.6.0 upgrade notes and operator acceptance checklist | +| [releases/v0.6.0-readiness.md](releases/v0.6.0-readiness.md) | v0.6.0 release audit: verification matrix, findings, risks, Go/No-Go | | [TESTING.md](../public/TESTING.md) | Testing strategy | ## Architecture decision records diff --git a/docs/internal/releases/v0.6.0-readiness.md b/docs/internal/releases/v0.6.0-readiness.md new file mode 100644 index 0000000..f55c5f6 --- /dev/null +++ b/docs/internal/releases/v0.6.0-readiness.md @@ -0,0 +1,102 @@ +# v0.6.0 release readiness (#57) + +Independent release audit of Stack Manager v0.6.0, performed 2026-10-22 for +[#57](https://github.com/uniskela/stack-manager/issues/57) (parent +[#51](https://github.com/uniskela/stack-manager/issues/51)). Nothing was merged, tagged, released, published +or deployed. No real deployment webhook or production data was used. + +## Release summary + +| Item | Value | +| --- | --- | +| Audited `main` | `8333dd88d87834890dfcd58e7745e4c8d9994f20` (`feat(deployment): add selective Git change routing and idempotency (#66)`) | +| Target version | 0.6.0 (Release Please PR [#41](https://github.com/uniskela/stack-manager/pull/41), head `d7ced04`) | +| Previous release | v0.5.0 (migrations `0000`–`0003`) | +| New migrations | `0004_sync-follow-up`, `0005_sync-timestamps`, `0006_repository_auto_fetch_policy`, `0007_git_webhook_deliveries`, `0008_git_webhook_body_replay`, `0009_flashy_turbo` (deployment bindings/attempts), `0010_large_salo` (routing). All additive. | +| Features | Scheduled and post-push fetch (#59, #62), security hardening (#61), dashboard (#63), signed GitHub/Gitea/Forgejo push webhooks (#64), per-stack Portainer/generic deployment webhooks and manual Deploy now (#65), selective routing and idempotency (#66). | +| Hardening PR | Branch `uniskela/adhd-v0.6.0-release-verification-and-documentati` (this audit; see PR linked from #57). | +| Open issues | #51 parent, #57 this gate. #52 and #56 verified on `main` (see below). | + +## Verification matrix + +"Local" results ran on the audited SHA (or that SHA plus this PR's test/doc changes) on the development VPS. + +| Category | Result | Evidence | +| --- | --- | --- | +| Git synchronization | **PASS** | `pnpm check` 656/656 on `main`; scheduler unit tests incl. new worker/server-disabled scan test; per-repository settings Playwright (desktop/mobile) and the server-disabled scenario run separately with `STACK_MANAGER_AUTO_FETCH_ENABLED=false` (passed). Fetch only updates `refs/remotes/origin/*`; drafts (DB) and unpushed commits (`refs/stack-manager/heads/*`) survive failure and upgrade. New audit test: one repository's failing fetch does not block another's routing. | +| Inbound webhooks | **PASS** (automated) | Code review of `providers/git/webhooks.ts`, `git-webhook-service.ts`, `http/route.ts`: raw-byte HMAC-SHA256 with constant-time compare before JSON parse, native headers only, SHA-1/empty/alternate formats rejected, repository/branch identity checked against the stored remote, delivery-ID and body-fingerprint ledger committed in the same transaction as the fetch job, credential/branch/remote fenced inside that transaction. Route-coverage test allowlists the single `signed-webhook` route; all other mutations keep same-origin checks. Existing 3-provider unit and integration suites pass. New test: cross-workspace repository id is rejected. Live forge delivery: **not verified** (operator). | +| Deployment bindings | **PASS** (automated) | Encrypted URL/token, fixed mask, workspace-scoped credential checks, revision CAS, managed-credential triggers, per-stack active-attempt unique index, intent before send, `begin`/`finish` lease fences, interrupted sends → unknown. Outbound: HTTPS only, no embedded credentials, DNS answers all checked, socket pinned to the validated address with original hostname for TLS, no redirects, 5 s connect / 15 s total deadlines, response bodies discarded. Local TLS receiver tests (invalid cert, hostname mismatch, rebinding resolver, redirect, timeout) pass. New tests: credential rotation and disabling reject queued automatic work without sending. | +| Selective routing | **PASS** (automated) | Scenario table independently re-run through real Git/SQLite/worker in `tests/integration/release-audit-routing.test.ts` plus existing suite: stack-only compose change, shared dependency to two stacks, README-only skip, unrelated stack skip, unsafe/rewritten history blocked, duplicate delivery no resend, automation off no deploy, unknown outcome holds until manual Deploy now. Unicode, spaces, `#`, `(`, `)` and a `apps/wiki café` vs `apps/wiki` prefix trap route correctly. | +| Recovery/idempotency | **PASS** with documented risk R1 | Restart before/after fetch and after routing, lease expiry, late-enqueue rollback, signing rotation between accept and route (blocked, cursor kept, next push covers it). Verified fetch exhaustion leaves the push unrouted (fail-closed; see R1). | +| Database migration | **PASS** | Seeded a database with the **actual v0.5.0 code** (admin, workspace, Gitea token credential, repo clone, 2 stacks, draft, unpushed commit, pending job, audit), upgraded with the candidate: 11 migrations, every pre-existing row byte-identical across 9 tables, token decrypts and drives an authenticated fetch, v0.5.0's pending job runs, new defaults correct (auto-fetch on/inherit, webhooks off, no bindings). Reopen is a no-op; upgraded schema equals a fresh schema; `pnpm db:generate` reports no drift. | +| Backup/restore | **PASS** (disposable data) | Online `sqlite3 .backup` (integrity `ok`) + clone archive; stopped-volume `tar` round trip byte-identical with uid 1000 preserved; clean stop leaves no WAL. Pre-upgrade backup restored and run with v0.5.0: 4 migrations, data intact, token decrypts, fetch works. Wrong key: starts, decryption fails, signed webhook rejected. Missing/placeholder key: refuses to start (exit 1). v0.5.0 on an upgraded DB starts but leaves v0.6.0 queued work dormant → documented as unsupported. | +| Security | **PASS** | Gitleaks over `main` history (70 commits): no leaks. Prod `pnpm audit`: no known vulnerabilities. Code-scanning: 20 open Sonar alerts, all previously dispositioned in `docs/internal/security/2026-10-20-sonar-dispositions.md` (manual Sonar status change pending). Secret-scanning alerts: 0. Dependabot alerts are disabled for the repository (R12). Two remaining unresolved Kody threads fixed in this PR. No credential exposure, SSRF, workspace isolation, CSRF or replay regression found. | +| Accessibility | **PASS** | Full Playwright + axe on the audited production build: 43 passed, 13 intentional skips (duplicate-viewport shared mutations; worker/server-disabled scenarios). Server-disabled auto-fetch scenario run separately: passed. Modified webhook spec re-run: passed. | +| Container and CI | **PASS** (CI) / local image **not run** | [CI on `8333dd8`](https://github.com/uniskela/stack-manager/actions/runs/39720529649): actionlint/shellcheck, check, build, smoke, Playwright, audit, Docker build, healthy start, same-volume restart, missing-key refusal. [Container security on `main`](https://github.com/uniskela/stack-manager/actions/runs/39722448520) (dispatched by this audit, scan-only): passed. [Secret scan](https://github.com/uniskela/stack-manager/actions/runs/39720529616): passed. Locally: actionlint, shellcheck, standalone production smoke, same-data restart, missing-key refusal passed. A local Docker image build was **not run**: the root disk had ~450 MB free and broad Docker cleanup was out of scope. | +| Live operator acceptance | **UNVERIFIED** | Checklist in [Upgrading to v0.6.0](../../public/UPGRADING_0_6.md#acceptance-checklist-before-enabling-automatic-deployment). Requires the real forge, reverse proxy, TLS chain and a disposable receiver; not authorized in this audit. | + +### Commands run + +```text +pnpm install --frozen-lockfile --ignore-scripts && pnpm rebuild better-sqlite3 esbuild # ok +pnpm check # main: 656 passed; with this PR: see PR checks +pnpm build # ok (audited SHA, built on tmpfs) +pnpm test:e2e (E2E_GIT_REMOTE=https://github.com/docker/awesome-compose.git E2E_STACK_ROOT=nginx-golang) + # 43 passed, 13 skipped +STACK_MANAGER_AUTO_FETCH_ENABLED=false playwright test repository-auto-fetch.spec.ts # passed +scripts/smoke.sh against .next/standalone/server.js; restart on same data; empty key # ok / ok / exit 1 +pnpm audit --prod --audit-level high # no vulnerabilities +pnpm db:generate # no schema changes +actionlint 1.7.12, shellcheck scripts/*.sh scripts/ci/*.sh # clean +gitleaks 8.30.1 git (origin/main) # no leaks +v0.5.0 → candidate upgrade, backup, restore and wrong/missing-key checks (scratch harness) # passed +``` + +The upgrade/restore harness ran from disposable scratch copies of `v0.5.0` and `8333dd8`; it is not part of the +repository because it needs the old release tree. Its assertions are summarised in the matrix above. + +## Findings and dispositions + +| # | Finding | Risk | Blocker? | Disposition | +| --- | --- | --- | --- | --- | +| F1 | All 13 deployment audit actions had no human label; Dashboard and Settings → Activity showed raw ids such as `deployment.unknown`. | Operators misread the audit trail during an incident. | No | **Fixed**: labels that state HTTP acceptance, not health; typed completeness test fails if a future action lacks a label. | +| F2 | Kody [r4325951812](https://github.com/uniskela/stack-manager/pull/63#discussion_r4325951812): repository-scope test could pass with an out-of-scope row. | Weak regression test. | No | **Fixed**: exact set asserted, r3 explicitly excluded. | +| F3 | Kody [r4332700230](https://github.com/uniskela/stack-manager/pull/64#discussion_r4332700230): e2e cleanup assertion in `finally` can mask the original failure. | Misleading CI diagnosis. | No | **Fixed**: cleanup is asserted only after a passing body and never throws over an earlier failure. | +| F4 | Verified push whose fetch exhausts retries is never routed (R1). | Missed automatic deployment; fail-closed. | No | **Documented** in Deployment/Git webhook troubleshooting; regression test pins the safe behaviour. Follow-up recommended. | +| F5 | Backup guide described backups as "planned" and lacked WAL, permissions, key and rollback guidance; release doc said ingress never deploys. | Unsafe upgrade/rollback practice. | Yes (docs) | **Fixed**: tested procedures, failure-mode table, rollback and replay-ledger guidance, v0.6.0 release gate, upgrade page and operator checklist. | +| F6 | Routing patterns reject `{ } [ ] ( ) ! :` even as literals; undocumented. | Confusing configuration. | No | **Documented** with a verified `?` workaround. | +| F7 | Changelog in #41 repeats "explain automatic fetch and manual fallback" four times and lists #59's six intermediate `feat(sync)` commits separately. | Cosmetic, noisy release notes. | No | **Recommendation** only (Release Please owns the file): consolidate in the GitHub Release text after publication, and add the upgrade/backup note. | + +Kody's PR #66 items were re-checked against the code: the repository-lookup failure still propagates for job +retry without advancing shared checkpoints (correct: skipping would move cursors past unrouted sources), shared +reads are hoisted while per-stack guarded enqueue stays inside one immediate transaction (no unsafe batching), +and the SQLite smoke helper closes its handle on failure paths. Those dispositions stand. + +## Remaining risks + +| ID | Risk | Severity | Likelihood | Impact | Mitigation | +| --- | --- | --- | --- | --- | --- | +| R1 | A verified push is not routed if its fetch fails all 5 retries (~75 s of backoff, e.g. forge restarting). A later polled fetch does not route it. | Medium | Low–medium | Missed automatic deployment; no unsafe deploy. | Next verified push includes the changes (baseline unchanged); Deploy now; documented. Follow-up: route unprocessed verified sources after a later successful fetch, with an age bound. | +| R2 | Exactly-once delivery is impossible across HTTP; Portainer ignores `Idempotency-Key`. | Medium | Low | Possible duplicate redeploy after a crash/timeout. | Intent before send, unknown outcome, persistent automation hold, operator inspection. | +| R3 | Live forge versions, reverse proxies and receivers may differ from fixtures (byte rewriting, redirects, TLS chains). | Medium | Medium | 401/415 ingress or unknown/rejected sends. | Operator checklist steps 2–3, 5–12 before production opt-in. | +| R4 | Git remote host check happens before `git` resolves DNS (rebinding TOCTOU), already listed in SECURITY.md. | Medium | Low | SSRF to a private address via a hostile DNS name. | HTTPS only, no redirects, private networks denied by default. Deployment webhooks are pinned; Git is not. | +| R5 | In-memory ingress rate limit resets on restart and is skipped without a trustworthy client IP. | Low | Medium | Weak flood protection. | Proxy-level limits (documented). | +| R6 | Restoring an older backup restores an older replay/routing ledger. | Medium | Low | Redeliveries can fetch/route again; sent requests are not undone. | Documented: keep automation off until receivers are inspected. | +| R7 | Delivery, decision and attempt history grows until repository/stack removal. | Low | Medium | Database growth on busy repositories. | Monitor size; retention is future work. | +| R8 | 20 Sonar alerts await manual status changes; 25 reviewed advisories remain from #66. | Low | n/a | Noise in security dashboards. | Dispositions recorded; apply in Sonar. | +| R9 | Dependabot alerts disabled. | Low | Low | Slower notice of new advisories. | `pnpm audit` and Trivy run in CI; consider enabling alerts. | +| R10 | Local Docker build not exercised on the audit host. | Low | Low | — | CI Docker job and container-security pass on `main`. | +| R11 | After R1 occurs, every later successful fetch queues a no-op routing job for that repository. | Low | Low | Negligible worker load. | Covered by the R1 follow-up. | + +## Go/No-Go + +**CONDITIONAL GO.** The code on `8333dd8` plus this hardening PR is ready: all automated gates pass, no +critical or high defect was found, and the remaining risks fail closed or are documented. Before merging +release PR #41 (and in any case before enabling automatic deployment in production), the operator must: + +1. Merge this hardening PR after review so its fixes and docs ship in v0.6.0, then let Release Please update #41. +2. Approve the pending workflow runs on #41 (they show `action_required`) and confirm they pass on its head. +3. Complete the [acceptance checklist](../../public/UPGRADING_0_6.md#acceptance-checklist-before-enabling-automatic-deployment) + on a disposable stack, recording results on #57. Steps 2, 3, 6, 9–12 and 14 are the minimum. + +If any checklist step fails, treat it as a release blocker and record it on #57. diff --git a/docs/manifest.json b/docs/manifest.json index 626c5a3..d976906 100644 --- a/docs/manifest.json +++ b/docs/manifest.json @@ -62,6 +62,13 @@ "group": "Deploy & operate", "kind": "doc" }, + { + "source": "docs/public/UPGRADING_0_6.md", + "slug": "upgrading-0-6", + "title": "Upgrading to v0.6.0", + "group": "Deploy & operate", + "kind": "doc" + }, { "source": "docs/public/AUTH_AND_CREDENTIALS.md", "slug": "auth-and-credentials", diff --git a/docs/public/BACKUP_RESTORE.md b/docs/public/BACKUP_RESTORE.md index 3f1db62..3777686 100644 --- a/docs/public/BACKUP_RESTORE.md +++ b/docs/public/BACKUP_RESTORE.md @@ -1,53 +1,122 @@ # Backup and restore -## What to back up +A complete backup has two parts that you store **separately**: -Application data directory (indicative layout): +1. **The data volume** (`/data` in the container): the SQLite database plus local Git clones. +2. **The configuration secrets**: `STACK_MANAGER_ENCRYPTION_KEY` (and its `STACK_MANAGER_ENCRYPTION_KEY_VERSION`) + and `STACK_MANAGER_SESSION_SECRET`, kept in your host's secret management, never inside the data backup. + +Without the matching encryption key, a restored database starts but cannot decrypt any stored Git token, +Git webhook signing secret or deployment webhook URL. Without the session secret, everyone is signed out. + +## What is in the data volume ```text -data/ - stack-manager.sqlite # includes credential ciphertext, session digests, jobs, audit - stack-manager.sqlite-wal # WAL mode — back up together with the main file (or checkpoint first) +/data/ + stack-manager.sqlite # users, session digests, workspaces, credential ciphertext, jobs, audit, + # webhook delivery ledger, deployment bindings, attempts and routing decisions + stack-manager.sqlite-wal # write-ahead log while the app runs (absent after a clean stop) stack-manager.sqlite-shm - repos/ # local clones (re-creatable from the forge) - git-home/ # isolated git HOME (re-creatable) + repos// # local clones, including unpushed commits under refs/stack-manager/heads/* + git-home/ # isolated HOME for git (re-created automatically) ``` -See [SELF_HOSTING.md](SELF_HOSTING.md#data-directory) for ownership and permissions. +Drafts live in the database. Commits made in the app but not yet pushed live only in `repos/`, so back up +the whole volume, not just the database file. + +## Take a consistent backup + +The database runs in WAL mode. Copying `stack-manager.sqlite` while the app runs, without its WAL, can +produce an inconsistent or out-of-date backup. Use one of these methods. + +### Stop, then archive the volume (recommended) + +A clean stop checkpoints the WAL into the main database file. + +```sh +docker compose stop stack-manager +docker run --rm -v _stack-manager-data:/data:ro -v "$PWD":/backup alpine \ + tar -C /data -czf /backup/stack-manager-data-$(date +%F).tgz . +chmod 600 stack-manager-data-*.tgz +docker compose start stack-manager +``` + +Replace `_stack-manager-data` with the volume name shown by `docker volume ls`. Snapshots of the +underlying volume or filesystem taken while the app is stopped are equally good. + +### Online backup of the database (bind mounts) + +If the data directory is a host bind mount and the `sqlite3` CLI is installed on the host, its backup command +is consistent while the app runs: -Also back up **configuration env** separately (encryption key, session secret) via host secret management — **not** inside the DB dump alone. +```sh +sqlite3 /srv/stack-manager/data/stack-manager.sqlite ".backup '/backups/stack-manager.sqlite'" +sqlite3 /backups/stack-manager.sqlite "PRAGMA integrity_check;" # expect: ok +tar -C /srv/stack-manager/data -czf /backups/stack-manager-repos.tgz repos +``` -## Backup methods (planned) +The `repos/` archive taken this way can be slightly newer or older than the database. Prefer the stopped +method before an upgrade. -1. **Volume snapshot** of Docker named volume (preferred operationally) -2. **In-app export** (PR #8): consistent SQLite checkpoint + manifest of version + keyVersion (not the key itself) -3. Document `sqlite` online backup API usage for consistency +Backups contain credential ciphertext and session digests. Keep them owner-readable only (`chmod 600`) and +treat them as sensitive even though they are encrypted. ## Restore -1. Stop stack-manager -2. Restore volume / files -3. Ensure same `STACK_MANAGER_ENCRYPTION_KEY` -4. Start and run migrations if needed -5. Verify Git remotes fetch; re-test provider connections +1. Stop Stack Manager: `docker compose stop stack-manager`. +2. Restore into an **empty** volume (or move the old one aside): + + ```sh + docker volume create _stack-manager-data + docker run --rm -v _stack-manager-data:/data -v "$PWD":/backup:ro alpine \ + tar -C /data -xzpf /backup/stack-manager-data-YYYY-MM-DD.tgz --numeric-owner + ``` + + Files must stay owned by uid/gid 1000 (`node`). `-p --numeric-owner` preserves that. +3. Set the **same** `STACK_MANAGER_ENCRYPTION_KEY` and key version that were in use when the backup was taken. +4. Run the **application version that created the backup** (set `STACK_MANAGER_VERSION`), then start it. + A newer version migrates the restored database forward on start; an older version must not be used on a + database that a newer version has already migrated (see [Rolling back an upgrade](#rolling-back-an-upgrade)). +5. Sign in, open each repository and choose **Fetch now**; check that clone credentials still work. ## Failure modes | Situation | Result | | --- | --- | -| DB restored, wrong encryption key | App starts but credentials unusable — show clear error | -| Key restored, empty DB | Fresh setup; old credentials gone | -| Clone dirs missing | Re-clone from forge on demand | +| Encryption key missing, empty or a placeholder | The server refuses to start (`invalid configuration; refusing to start`). | +| Database restored with a different key | The server starts, but each credential fails to decrypt: fetches fail, signed Git webhooks return 401 and deployment requests are rejected before anything is sent. Restore the correct key. | +| Key restored, database empty or missing | A fresh first-run setup; old credentials are gone. | +| `repos/` missing | Clones are re-created on the next fetch, but unpushed in-app commits are lost. | -## What not to back up into Git +## Rolling back an upgrade -- Entire `data/` directory -- Encryption keys -- Session secrets -- Decrypted credential material +Database migrations only move forward and there are no down migrations. To roll back: + +1. Stop Stack Manager. +2. If automatic deployment was enabled, inspect your deployment receivers (Portainer, generic endpoints) for + requests that were sent after the backup. A restore cannot undo a request that was already sent. +3. Restore the **pre-upgrade** backup, keep the same encryption key, set `STACK_MANAGER_VERSION` back to the + previous release and start it. + +Do not run an older version against a database that a newer version has migrated, even if it appears to +start: newer tables and queued jobs remain in the database without the code that manages them. For example, +deployment requests queued by v0.6.0 stay dormant under v0.5.0 and can be sent if you later upgrade again. -## Tests (PR #8) +### Restoring an older replay and routing ledger -- Export/import round-trip on fixture workspace -- Wrong-key detection -- Redaction: export archive must not contain plaintext tokens +v0.6.0 stores which Git webhook deliveries it has accepted, which pushes were routed to which stacks and +which deployment requests were sent. Restoring an older backup restores that older ledger: + +- Forge redeliveries of pushes received after the backup are no longer recognised as duplicates and can be + fetched and, for opted-in stacks, routed again. +- Deployment requests sent after the backup are not undone and no longer appear in history. + +After restoring a backup older than your last deployments, keep automatic deployment **off** (or keep forge +webhooks disabled) until you have inspected the receivers. Then re-enable it deliberately; saving the +automatic settings sets a new baseline at the current fetched commit. + +## What not to put in Git + +- The data volume or any part of it +- Encryption keys and session secrets +- Decrypted credential material diff --git a/docs/public/DEPLOYMENT_WEBHOOKS.md b/docs/public/DEPLOYMENT_WEBHOOKS.md index 80bd461..cefa254 100644 --- a/docs/public/DEPLOYMENT_WEBHOOKS.md +++ b/docs/public/DEPLOYMENT_WEBHOOKS.md @@ -118,7 +118,9 @@ The initial form includes standard Compose filenames, the selected Compose file, an explicit dependency such as `shared/proxy/**`. Exclude a shared file with its repository-relative path. Supported wildcards are `*` (within one path segment), `?` (one character) and whole-segment `**` -(zero or more segments). Brace expansion, character classes, negation and extended globs are unsupported. +(zero or more segments). Brace expansion, character classes, negation and extended globs are unsupported, +so patterns cannot contain `{ } [ ] ( ) ! :` even as literal characters. Match such a character with `?` +(for example `config/?prod?/**` for files under `config/(prod)/`). Unicode and spaces in paths are matched exactly. Paths must be relative POSIX paths without traversal, `.git`, backslashes or control characters. Each list accepts at most **32 patterns**, each at most **256 characters**. Comparisons accept at most **1,000 commits** and **10,000 changed paths**, with bounded Git execution, output and wildcard matching work (50 million estimated steps per stack, 100 million per source). @@ -175,6 +177,14 @@ concurrent saves must reload rather than silently overwrite a newer binding. - **Unknown:** check the receiver before retrying. A failure response does not prove a deployment did not start. A new Deploy now confirmation is required for another attempt. - **Settings changed:** reload the latest settings, review the provider and replace configuration again. +- **No routing decision after a push:** a verified push is routed only after its own fetch succeeds. If that + fetch fails on every retry (the forge is unreachable for a minute or more), the push is not routed, and a + later scheduled or manual fetch does not route it either. Nothing is deployed and the binding's baseline does + not move, so the next verified push includes those changes. Check the fetch error in Overview and use + **Deploy now** if the change should not wait. Redelivering the same push from the forge does not help: + an identical body is recognised as a duplicate. +- **Blocked: history unavailable:** the repository was not ready when routing ran, for example because a + later fetch failed. Fetch, inspect, then use **Deploy now** if the change needs deploying. ## Migration, backups and rollback @@ -191,4 +201,7 @@ that backup: restoring encrypted webhook URLs requires the same key material. Te relying on it. Migrations run on startup; do not edit previous migrations or apply hand-written down migrations. To roll back, stop all workers, inspect destinations for any in-flight requests, restore the pre-upgrade data backup and matching encryption configuration, then run the prior pinned application -version. Restoring a database cannot undo a deployment request already sent to another system. +version. Restoring a database cannot undo a deployment request already sent to another system. Do not run +the prior version on the migrated database instead of restoring: queued deployment jobs stay dormant there +and can be sent after a later re-upgrade. See [Backup and restore](BACKUP_RESTORE.md#rolling-back-an-upgrade), +including what restoring an older replay ledger means for automation. diff --git a/docs/public/GIT_WEBHOOKS.md b/docs/public/GIT_WEBHOOKS.md index 39ebc54..814c0d5 100644 --- a/docs/public/GIT_WEBHOOKS.md +++ b/docs/public/GIT_WEBHOOKS.md @@ -98,6 +98,8 @@ Regular Stack Manager mutations still require a browser session and same-origin Last accepted time appears in Overview. Failed unauthenticated deliveries are not stored as delivery records or reflected in the UI: inspect the forge's HTTP delivery history. Fetch failures appear in Overview and Settings → Activity. Never paste signing secrets or raw webhook bodies into support logs. +A 202 means the fetch was queued, not that it succeeded. If that fetch then fails on every retry, the push +is not routed to deployments; see [Deployment webhooks troubleshooting](DEPLOYMENT_WEBHOOKS.md#troubleshooting). Delivery IDs are persisted uniquely per repository/provider across process restarts. Recording an accepted delivery and inserting/coalescing its fetch are one SQLite transaction; a queue error rolls diff --git a/docs/public/RELEASING.md b/docs/public/RELEASING.md index fbf818b..ff07b2c 100644 --- a/docs/public/RELEASING.md +++ b/docs/public/RELEASING.md @@ -47,9 +47,14 @@ All third-party actions are pinned to commit SHAs; Dependabot proposes weekly up - Images are linux/amd64. arm64 (e.g. Raspberry Pi) needs emulated or arm64 runners and can be added to `publish-image.yml` later. -## v0.6.0 inbound webhook validation +## v0.6.0 release gate -Issue #53 adds signed GitHub/Gitea/Forgejo ingress and additive delivery-ledger and accepted-body-fingerprint migrations (0007/0008). -Before release, verify the provider/security/replay/queue/restart tests, production UI and rootless -Docker smoke checks, plus live forge delivery from supported operator versions. No deployment is -triggered by ingress. Follow [Git push webhooks](GIT_WEBHOOKS.md) for backup and rollback. +v0.6.0 adds scheduled and post-push fetching, signed GitHub/Gitea/Forgejo push webhooks, per-stack Portainer and +generic deployment webhooks with manual **Deploy now**, and opt-in selective deployment routing. Migrations +`0004`–`0010` are additive. Verified pushes can trigger deployments only for stacks that explicitly enable +automatic deployment. + +Before merging the release PR, the maintained CI checks above must pass on the release head and the operator +acceptance checklist in [Upgrading to v0.6.0](UPGRADING_0_6.md) must be completed on a disposable stack with +the real forge, reverse proxy and deployment receiver. Back up first and follow +[Backup and restore](BACKUP_RESTORE.md) for rollback. diff --git a/docs/public/SECURITY.md b/docs/public/SECURITY.md index c22d312..18f78fd 100644 --- a/docs/public/SECURITY.md +++ b/docs/public/SECURITY.md @@ -58,7 +58,7 @@ Assume: operator places stack-manager on a trusted homelab network behind auth a - [x] Encryption key required at boot (PR #2 — process exits on missing/malformed key) - [x] Secret redaction tests (PR #2 — logger, audit, job payloads, API responses, data dir scan) -- [ ] Webhook signature tests +- [x] Webhook signature tests (v0.6.0 — GitHub/Gitea/Forgejo native HMAC-SHA256, SHA-1 and header substitution rejected) - [x] SSRF unit tests (PR #2 — Git remote host policy; deploy webhook URLs in PR #5) - [ ] Log viewer XSS tests - [x] Dependency vulnerability scanning in CI (PR #2 — `pnpm audit --prod`; Trivy on `pnpm-lock.yaml`) diff --git a/docs/public/SELF_HOSTING.md b/docs/public/SELF_HOSTING.md index 2b12a17..d39b9ed 100644 --- a/docs/public/SELF_HOSTING.md +++ b/docs/public/SELF_HOSTING.md @@ -104,8 +104,10 @@ once; afterwards `/setup` redirects to sign-in. ## Upgrades -Back up the data volume, change `STACK_MANAGER_VERSION`, then `docker compose up -d`. Migrations run automatically. Back up the data volume first -(see [BACKUP_RESTORE.md](BACKUP_RESTORE.md)). +Stop the app and back up the data volume (see [BACKUP_RESTORE.md](BACKUP_RESTORE.md)), change +`STACK_MANAGER_VERSION`, then `docker compose up -d`. Migrations run automatically and only move forward: to roll +back, restore the pre-upgrade backup and run the previous version. See [Upgrading to v0.6.0](UPGRADING_0_6.md) +for the v0.6.0 notes and acceptance checklist. ## Scaling notes diff --git a/docs/public/UPGRADING_0_6.md b/docs/public/UPGRADING_0_6.md new file mode 100644 index 0000000..b0530f3 --- /dev/null +++ b/docs/public/UPGRADING_0_6.md @@ -0,0 +1,56 @@ +# Upgrading to v0.6.0 + +v0.6.0 adds automatic Git fetching, signed GitHub/Gitea/Forgejo push webhooks, per-stack Portainer and +generic deployment webhooks, and opt-in selective deployment after verified pushes. Nothing deploys +automatically after the upgrade: every stack starts with no deployment binding and automatic deployment off. + +## Before you upgrade + +1. Note your current `STACK_MANAGER_VERSION` (you need it to roll back). +2. Stop Stack Manager and back up the data volume; keep the encryption key and session secret separately. + Follow [Backup and restore](BACKUP_RESTORE.md#take-a-consistent-backup). +3. Read the [release notes](https://github.com/uniskela/stack-manager/releases). + +## Upgrade + +1. Set `STACK_MANAGER_VERSION=0.6.0` in `.env` and run `docker compose up -d`. +2. Migrations `0004`–`0010` run on start. They only add tables and columns; existing users, workspaces, + repositories, credentials, stacks, drafts, unpushed commits, jobs and audit history are kept unchanged. +3. Background fetching starts every 15 minutes by default. Change it with + `STACK_MANAGER_AUTO_FETCH_INTERVAL_MINUTES` (5–1440) or `STACK_MANAGER_AUTO_FETCH_ENABLED=false`, or per + repository in **Overview → Automatic fetching**. +4. Optional features stay off until you configure them: [Git push webhooks](GIT_WEBHOOKS.md) and + [Deployment webhooks](DEPLOYMENT_WEBHOOKS.md). + +To roll back, follow [Rolling back an upgrade](BACKUP_RESTORE.md#rolling-back-an-upgrade): restore the +pre-upgrade backup and run the previous version. Do not run the previous version on the upgraded database. + +## Acceptance checklist before enabling automatic deployment + +Automated tests cover these behaviours with mock forges and receivers. They cannot prove that your forge +version, reverse proxy, TLS chain and deployment receiver behave the same way. Run this checklist once on a +**disposable** stack before opting real stacks into automatic deployment. Record each result. + +Use a throwaway repository folder and a throwaway receiver stack (for example a Portainer stack running a +`traefik/whoami` container, or a generic HTTPS endpoint you control that logs requests). Never use a +production stack for steps 6–12. + +| # | Check | How | Expected result | +| --- | --- | --- | --- | +| 1 | Backup | Stop, archive the volume, start again ([guide](BACKUP_RESTORE.md#take-a-consistent-backup)). Store keys separately. | Archive exists, `chmod 600`, keys stored elsewhere. | +| 2 | Webhook ingress through the reverse proxy | Configure the repository's **Git webhooks** signing secret and the forge hook (POST, JSON, push only). Push a commit to the tracked branch. | Forge delivery history shows HTTP 202 `accepted`; Overview shows **Last accepted delivery**. GitHub's initial ping returns 200 `ignored`, which is expected. Any 401, 413, 415 or redirect means the secret, proxy or hook settings need fixing. | +| 3 | Provider signature format | In the forge delivery details, check the headers. | GitHub: `X-Hub-Signature-256: sha256=…`. Gitea: `X-Gitea-Signature`. Forgejo: `X-Forgejo-Signature`. A wrong secret returns 401. | +| 4 | Automatic fetch | Set the repository's **Check interval** to 5 minutes, disable the forge hook, push a commit from outside Stack Manager and wait. | The new commit appears without **Fetch now**; last-checked time updates. Re-enable the hook. | +| 5 | Outbound webhook configuration | On the disposable stack's **Deployment** tab, save the Portainer or generic HTTPS URL, then choose **Validate destination**. | Settings show only a mask. Validation passes without sending a request. Private addresses need `STACK_MANAGER_ALLOW_PRIVATE_NETWORKS=true`. | +| 6 | Manual deployment | Choose **Deploy now** and confirm. | **Recent deployments** shows **Accepted** with an HTTP status, and the receiver shows exactly one request. Accepted means the receiver answered 2xx, not that containers are healthy: check them in Portainer. | +| 7 | Routing rules and preview | Review the include/exclude/dependency patterns. Enter `/compose.yaml` and `/README.md` as sample paths and choose **Preview routing**. | Compose change: deploy; README-only: no deployment. No request is sent. | +| 8 | Opt in | Select **Deploy automatically after verified Git pushes** and save. | Saved. No request is sent on save; the baseline is the current fetched commit. | +| 9 | Relevant push | Change the disposable stack's Compose file and push to the tracked branch. | **Recent routing decisions** shows **deploy** for this stack only; one new **Automatic** attempt; the receiver shows one request. Other opted-in stacks show skip. | +| 10 | Irrelevant push | Change only a Markdown file in the stack folder and push. | Decision **skip** (`no_relevant_changes`); no new attempt; no receiver request. | +| 11 | Repeated delivery | In the forge, **Redeliver** the push from step 9. | Forge shows HTTP 200 with `duplicate`; no new decision or attempt; no receiver request. | +| 12 | Unknown outcome recovery | Make the receiver slow or unreachable so a request times out (for example a generic endpoint that waits longer than 15 seconds, or stop the receiver's proxy route), then push a relevant change. | Attempt shows **Unknown**; automation is on hold; a further relevant push is **blocked** (`outcome_unknown`). Check whether the receiver acted, restore it, then use **Deploy now**: the hold clears and the next relevant push deploys. | +| 13 | Audit and history | Open **Settings → Activity** and the stack's **Deployment** tab. | Entries for webhook configured, destination checked, manual and automatic requests, accepted, unknown and stack not deployed. No URLs, tokens or secrets appear anywhere. | +| 14 | Restore on a separate instance | Restore the step 1 archive into a new volume. Start it with the same keys on another port and `STACK_MANAGER_WORKER_ENABLED=false`, so it cannot send deployment requests. Disable every deployment binding on the copy, then restart it with the worker enabled and choose **Fetch now**. | Sign-in works, repositories, stacks and drafts are present, and the fetch succeeds (credentials decrypt). The copy sends no deployment requests. Remove it afterwards. | + +When all checks pass, opt real stacks in one at a time. Keep Git polling enabled as a fallback for missed +webhooks, and keep regular backups. diff --git a/docs/public/installation.md b/docs/public/installation.md index f8f4a34..ee10155 100644 --- a/docs/public/installation.md +++ b/docs/public/installation.md @@ -93,7 +93,8 @@ provenance. 3. Set the new `STACK_MANAGER_VERSION` in `.env` and run `docker compose up -d`. Database migrations run automatically on start. Because the version is pinned in `.env`, nothing upgrades until you -change it. +change it. Upgrading from v0.5.x? Follow [Upgrading to v0.6.0](UPGRADING_0_6.md), including its acceptance +checklist before you enable automatic deployment. ## Building from source diff --git a/tests/unit/docs-layout.test.ts b/tests/unit/docs-layout.test.ts index fd38fe7..ef1c76d 100644 --- a/tests/unit/docs-layout.test.ts +++ b/tests/unit/docs-layout.test.ts @@ -15,6 +15,7 @@ const PUBLIC_SLUGS = [ 'deployment-webhooks', 'self-hosting', 'backup-restore', + 'upgrading-0-6', 'auth-and-credentials', 'security', 'product', From 11c595b916f1ad900de741cbd955b5cbc8061d66 Mon Sep 17 00:00:00 2001 From: alex Date: Thu, 22 Oct 2026 15:38:37 +1100 Subject: [PATCH 4/8] docs: link release readiness report to PR #67 Co-Authored-By: Claude Opus 5.5 --- docs/internal/releases/v0.6.0-readiness.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/internal/releases/v0.6.0-readiness.md b/docs/internal/releases/v0.6.0-readiness.md index f55c5f6..3ac8814 100644 --- a/docs/internal/releases/v0.6.0-readiness.md +++ b/docs/internal/releases/v0.6.0-readiness.md @@ -14,7 +14,7 @@ or deployed. No real deployment webhook or production data was used. | Previous release | v0.5.0 (migrations `0000`–`0003`) | | New migrations | `0004_sync-follow-up`, `0005_sync-timestamps`, `0006_repository_auto_fetch_policy`, `0007_git_webhook_deliveries`, `0008_git_webhook_body_replay`, `0009_flashy_turbo` (deployment bindings/attempts), `0010_large_salo` (routing). All additive. | | Features | Scheduled and post-push fetch (#59, #62), security hardening (#61), dashboard (#63), signed GitHub/Gitea/Forgejo push webhooks (#64), per-stack Portainer/generic deployment webhooks and manual Deploy now (#65), selective routing and idempotency (#66). | -| Hardening PR | Branch `uniskela/adhd-v0.6.0-release-verification-and-documentati` (this audit; see PR linked from #57). | +| Hardening PR | Branch `uniskela/adhd-v0.6.0-release-verification-and-documentati` ([PR #67](https://github.com/uniskela/stack-manager/pull/67)). | | Open issues | #51 parent, #57 this gate. #52 and #56 verified on `main` (see below). | ## Verification matrix From f259f9418ee6bae6a9e92f9934538257c7f71559 Mon Sep 17 00:00:00 2001 From: alex Date: Thu, 22 Oct 2026 15:39:38 +1100 Subject: [PATCH 5/8] test(e2e): verify dashboard activity link by keyboard Co-Authored-By: Claude Opus 5.5 --- docs/internal/releases/v0.6.0-readiness.md | 2 +- tests/e2e/dashboard.spec.ts | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/docs/internal/releases/v0.6.0-readiness.md b/docs/internal/releases/v0.6.0-readiness.md index 3ac8814..2d8043f 100644 --- a/docs/internal/releases/v0.6.0-readiness.md +++ b/docs/internal/releases/v0.6.0-readiness.md @@ -31,7 +31,7 @@ or deployed. No real deployment webhook or production data was used. | Database migration | **PASS** | Seeded a database with the **actual v0.5.0 code** (admin, workspace, Gitea token credential, repo clone, 2 stacks, draft, unpushed commit, pending job, audit), upgraded with the candidate: 11 migrations, every pre-existing row byte-identical across 9 tables, token decrypts and drives an authenticated fetch, v0.5.0's pending job runs, new defaults correct (auto-fetch on/inherit, webhooks off, no bindings). Reopen is a no-op; upgraded schema equals a fresh schema; `pnpm db:generate` reports no drift. | | Backup/restore | **PASS** (disposable data) | Online `sqlite3 .backup` (integrity `ok`) + clone archive; stopped-volume `tar` round trip byte-identical with uid 1000 preserved; clean stop leaves no WAL. Pre-upgrade backup restored and run with v0.5.0: 4 migrations, data intact, token decrypts, fetch works. Wrong key: starts, decryption fails, signed webhook rejected. Missing/placeholder key: refuses to start (exit 1). v0.5.0 on an upgraded DB starts but leaves v0.6.0 queued work dormant → documented as unsupported. | | Security | **PASS** | Gitleaks over `main` history (70 commits): no leaks. Prod `pnpm audit`: no known vulnerabilities. Code-scanning: 20 open Sonar alerts, all previously dispositioned in `docs/internal/security/2026-10-20-sonar-dispositions.md` (manual Sonar status change pending). Secret-scanning alerts: 0. Dependabot alerts are disabled for the repository (R12). Two remaining unresolved Kody threads fixed in this PR. No credential exposure, SSRF, workspace isolation, CSRF or replay regression found. | -| Accessibility | **PASS** | Full Playwright + axe on the audited production build: 43 passed, 13 intentional skips (duplicate-viewport shared mutations; worker/server-disabled scenarios). Server-disabled auto-fetch scenario run separately: passed. Modified webhook spec re-run: passed. | +| Accessibility | **PASS** | Full Playwright + axe on the audited production build: 43 passed, 13 intentional skips (duplicate-viewport shared mutations; worker/server-disabled scenarios). Server-disabled auto-fetch scenario run separately: passed. Modified webhook spec re-run: passed. New dashboard keyboard check (Tab to **View all activity**, Enter opens Settings → Activity) passed on desktop and mobile. | | Container and CI | **PASS** (CI) / local image **not run** | [CI on `8333dd8`](https://github.com/uniskela/stack-manager/actions/runs/39720529649): actionlint/shellcheck, check, build, smoke, Playwright, audit, Docker build, healthy start, same-volume restart, missing-key refusal. [Container security on `main`](https://github.com/uniskela/stack-manager/actions/runs/39722448520) (dispatched by this audit, scan-only): passed. [Secret scan](https://github.com/uniskela/stack-manager/actions/runs/39720529616): passed. Locally: actionlint, shellcheck, standalone production smoke, same-data restart, missing-key refusal passed. A local Docker image build was **not run**: the root disk had ~450 MB free and broad Docker cleanup was out of scope. | | Live operator acceptance | **UNVERIFIED** | Checklist in [Upgrading to v0.6.0](../../public/UPGRADING_0_6.md#acceptance-checklist-before-enabling-automatic-deployment). Requires the real forge, reverse proxy, TLS chain and a disposable receiver; not authorized in this audit. | diff --git a/tests/e2e/dashboard.spec.ts b/tests/e2e/dashboard.spec.ts index 1cb5134..b664c33 100644 --- a/tests/e2e/dashboard.spec.ts +++ b/tests/e2e/dashboard.spec.ts @@ -29,4 +29,15 @@ test('dashboard stays focused, accessible and responsive with a connected reposi expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true); await page.screenshot({ path: `test-results/dashboard-${info.project.name}.png`, fullPage: true }); + + // Keyboard: the activity link is reachable with Tab and opens the full activity log with Enter. + const activity = page.getByRole('link', { name: 'View all activity' }); + let reached = false; + for (let i = 0; i < 60 && !reached; i++) { + await page.keyboard.press('Tab'); + reached = await activity.evaluate((el) => el === document.activeElement); + } + expect(reached).toBe(true); + await page.keyboard.press('Enter'); + await expect(page).toHaveURL(new RegExp(`/w/${workspaceId}/settings/activity$`)); }); From ccda9b929208cb0ab7b589a3064514bc392798df Mon Sep 17 00:00:00 2001 From: alex Date: Thu, 22 Oct 2026 15:41:04 +1100 Subject: [PATCH 6/8] test: share routing SQLite and send-mock helpers Removes duplicated harness code flagged by the SonarCloud new-code duplication gate; behaviour of both suites is unchanged. Co-Authored-By: Claude Opus 5.5 --- tests/integration/deployment-routing.test.ts | 35 ++-------------- .../integration/release-audit-routing.test.ts | 35 ++-------------- tests/support/routing.ts | 42 +++++++++++++++++++ 3 files changed, 50 insertions(+), 62 deletions(-) create mode 100644 tests/support/routing.ts diff --git a/tests/integration/deployment-routing.test.ts b/tests/integration/deployment-routing.test.ts index d197a61..3e1f719 100644 --- a/tests/integration/deployment-routing.test.ts +++ b/tests/integration/deployment-routing.test.ts @@ -5,10 +5,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import * as setup from '@/app/api/setup/route'; import * as preview from '@/app/api/workspaces/[workspaceId]/stacks/[stackId]/deployment/preview/route'; import { DEFAULT_ROUTING_POLICY } from '@/shared/deployment-routing'; -import { DeploymentService } from '@/server/application/deployment-service'; -import { openSqlite } from '@/server/persistence/sqlite/database'; import { GitCli } from '@/server/providers/git/git-cli'; import { createHarness, type TestHarness } from '../support/container'; +import { mockDeploymentSend, sqlRows } from '../support/routing'; import { startGitServer, gitServerOverrides, type GitServer } from '../support/git-server'; import { call, cookieFrom } from '../support/http'; @@ -20,17 +19,8 @@ const trigger = vi.fn(); const policy = { ...DEFAULT_ROUTING_POLICY, dependencies: ['shared/proxy/**'] }; const params = { workspaceId, stackId: 'wiki' }; -function sql>(statement: string, ...values: unknown[]): T[] { - const handle = openSqlite(h.config.databasePath); - try { - const prepared = handle.db.$client.prepare(statement); - if (prepared.reader) return prepared.all(...values) as T[]; - prepared.run(...values); - return []; - } finally { - handle.close(); - } -} +const sql = >(statement: string, ...values: unknown[]) => + sqlRows(h, statement, ...values); async function drain() { for (let i = 0; i < 30 && (await h.container.worker.runOnce()); i++) { /* bounded test drain */ @@ -76,24 +66,7 @@ async function restart() { h = await createHarness({ dataDir, config, overrides: gitServerOverrides(server) }); mockSend(); } -function mockSend() { - const provider = { - type: 'generic-webhook' as const, - validateConfiguration: (v: unknown) => v as { url: string }, - validateDestination: async () => {}, - trigger, - }; - const service = new DeploymentService( - h.container.repos, - h.container.credentials, - new Map([['generic-webhook', provider]]), - h.container.jobs, - h.container.audit, - h.clock, - randomUUID, - ); - vi.spyOn(h.container.deployments, 'execute').mockImplementation((job, ctx) => service.execute(job, ctx)); -} +const mockSend = () => mockDeploymentSend(h, trigger); beforeEach(async () => { server = await startGitServer(); server.createRepo('infra.git'); diff --git a/tests/integration/release-audit-routing.test.ts b/tests/integration/release-audit-routing.test.ts index ef33afa..1006512 100644 --- a/tests/integration/release-audit-routing.test.ts +++ b/tests/integration/release-audit-routing.test.ts @@ -7,9 +7,8 @@ import fs from 'node:fs'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import * as setup from '@/app/api/setup/route'; import { DEFAULT_ROUTING_POLICY } from '@/shared/deployment-routing'; -import { DeploymentService } from '@/server/application/deployment-service'; -import { openSqlite } from '@/server/persistence/sqlite/database'; import { createHarness, type TestHarness } from '../support/container'; +import { mockDeploymentSend, sqlRows } from '../support/routing'; import { startGitServer, gitServerOverrides, type GitServer } from '../support/git-server'; import { call } from '../support/http'; @@ -20,17 +19,8 @@ const secrets: Record = {}; const trigger = vi.fn(); const policy = { ...DEFAULT_ROUTING_POLICY, dependencies: ['shared/proxy/**', 'shared/ünï cødé/**'] }; -function sql>(statement: string, ...values: unknown[]): T[] { - const handle = openSqlite(h.config.databasePath); - try { - const prepared = handle.db.$client.prepare(statement); - if (prepared.reader) return prepared.all(...values) as T[]; - prepared.run(...values); - return []; - } finally { - handle.close(); - } -} +const sql = >(statement: string, ...values: unknown[]) => + sqlRows(h, statement, ...values); async function drain() { for (let i = 0; i < 40 && (await h.container.worker.runOnce()); i++) { /* bounded test drain */ @@ -74,24 +64,7 @@ const attempts = async (stackId: string) => const binding = async (stackId: string) => (await h.container.deployments.settings(workspaceId, stackId)).binding!; -function mockSend() { - const provider = { - type: 'generic-webhook' as const, - validateConfiguration: (v: unknown) => v as { url: string }, - validateDestination: async () => {}, - trigger, - }; - const service = new DeploymentService( - h.container.repos, - h.container.credentials, - new Map([['generic-webhook', provider]]), - h.container.jobs, - h.container.audit, - h.clock, - randomUUID, - ); - vi.spyOn(h.container.deployments, 'execute').mockImplementation((job, ctx) => service.execute(job, ctx)); -} +const mockSend = () => mockDeploymentSend(h, trigger); async function connect(repositoryId: string) { const now = h.clock.now(); diff --git a/tests/support/routing.ts b/tests/support/routing.ts new file mode 100644 index 0000000..e67cc65 --- /dev/null +++ b/tests/support/routing.ts @@ -0,0 +1,42 @@ +import { randomUUID } from 'node:crypto'; +import { vi, type Mock } from 'vitest'; +import { DeploymentService } from '@/server/application/deployment-service'; +import { openSqlite } from '@/server/persistence/sqlite/database'; +import type { TestHarness } from './container'; + +/** Runs one statement on an independent SQLite connection, returning rows for readers. */ +export function sqlRows>( + h: TestHarness, + statement: string, + ...values: unknown[] +): T[] { + const handle = openSqlite(h.config.databasePath); + try { + const prepared = handle.db.$client.prepare(statement); + if (prepared.reader) return prepared.all(...values) as T[]; + prepared.run(...values); + return []; + } finally { + handle.close(); + } +} + +/** Routes deployment jobs through a real DeploymentService whose only provider is the `trigger` mock. */ +export function mockDeploymentSend(h: TestHarness, trigger: Mock) { + const provider = { + type: 'generic-webhook' as const, + validateConfiguration: (v: unknown) => v as { url: string }, + validateDestination: async () => {}, + trigger, + }; + const service = new DeploymentService( + h.container.repos, + h.container.credentials, + new Map([['generic-webhook', provider]]), + h.container.jobs, + h.container.audit, + h.clock, + randomUUID, + ); + vi.spyOn(h.container.deployments, 'execute').mockImplementation((job, ctx) => service.execute(job, ctx)); +} From 70ded326f1cbb5432b31a2211bf55eb8110eb213 Mon Sep 17 00:00:00 2001 From: alex Date: Thu, 22 Oct 2026 15:42:54 +1100 Subject: [PATCH 7/8] docs: mark deployment routing implemented for v0.6.0 in roadmap Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 9 +++++---- docs/internal/releases/v0.6.0-readiness.md | 1 + docs/public/plans/MVP_PLAN.md | 12 +++++++----- 3 files changed, 13 insertions(+), 9 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 595edff..c0e8075 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,10 +10,11 @@ This app manages Git-backed **source/configuration** for Compose stacks. It is * ## Current phase -**PR #5 — Deployment routing** is the next active product phase (DeploymentProvider, Portainer/generic webhooks, -forge webhook receiver, relevance rules, selective deploy). PR #2 (application foundation), PR #3 (source workspace) -and PR #4 (Git workflow: commit, safe push, conflict protection, stack history, Changes UI; released in v0.5.0) are -done. Do not build secrets providers, runtime watch or PWA work ahead of their phases. +**PR #5 — Deployment routing** (DeploymentProvider, Portainer/generic webhooks, forge webhook receiver, relevance +rules, selective deploy) is implemented on `main` for v0.6.0 and is in its release gate (#57; release PR #41). +PR #2 (application foundation), PR #3 (source workspace) and PR #4 (Git workflow: commit, safe push, conflict +protection, stack history, Changes UI; released in v0.5.0) are done. The maintainer picks the next phase from the +roadmap. Do not build secrets providers, runtime watch or PWA work ahead of their phases. See [docs/public/plans/MVP_PLAN.md](docs/public/plans/MVP_PLAN.md) and the code layout in [docs/public/ARCHITECTURE.md](docs/public/ARCHITECTURE.md#code-layout-implemented-in-pr-2-extended-through-v050). diff --git a/docs/internal/releases/v0.6.0-readiness.md b/docs/internal/releases/v0.6.0-readiness.md index 2d8043f..d0a11b4 100644 --- a/docs/internal/releases/v0.6.0-readiness.md +++ b/docs/internal/releases/v0.6.0-readiness.md @@ -65,6 +65,7 @@ repository because it needs the old release tree. Its assertions are summarised | F4 | Verified push whose fetch exhausts retries is never routed (R1). | Missed automatic deployment; fail-closed. | No | **Documented** in Deployment/Git webhook troubleshooting; regression test pins the safe behaviour. Follow-up recommended. | | F5 | Backup guide described backups as "planned" and lacked WAL, permissions, key and rollback guidance; release doc said ingress never deploys. | Unsafe upgrade/rollback practice. | Yes (docs) | **Fixed**: tested procedures, failure-mode table, rollback and replay-ledger guidance, v0.6.0 release gate, upgrade page and operator checklist. | | F6 | Routing patterns reject `{ } [ ] ( ) ! :` even as literals; undocumented. | Confusing configuration. | No | **Documented** with a verified `?` workaround. | +| F8 | Roadmap (`MVP_PLAN.md`) and `AGENTS.md` still called deployment routing the next active phase. | Misleading project status for contributors and agents. | No | **Fixed**: both say it is implemented on `main` for v0.6.0 and in its release gate. | | F7 | Changelog in #41 repeats "explain automatic fetch and manual fallback" four times and lists #59's six intermediate `feat(sync)` commits separately. | Cosmetic, noisy release notes. | No | **Recommendation** only (Release Please owns the file): consolidate in the GitHub Release text after publication, and add the upgrade/backup note. | Kody's PR #66 items were re-checked against the code: the repository-lookup failure still propagates for job diff --git a/docs/public/plans/MVP_PLAN.md b/docs/public/plans/MVP_PLAN.md index fe85ffb..f0ce209 100644 --- a/docs/public/plans/MVP_PLAN.md +++ b/docs/public/plans/MVP_PLAN.md @@ -1,7 +1,8 @@ # Phased MVP implementation plan PR #1 (documentation), PR #2 (application foundation), PR #3 (source workspace) and PR #4 (Git workflow, released as -v0.5.0) are complete. **PR #5 (deployment routing) is the next active phase.** Subsequent PRs implement the product. Boundaries may shift for strong technical reasons; avoid giant cross-cutting PRs. +v0.5.0) are complete. **PR #5 (deployment routing) is implemented on `main` for v0.6.0** and is in its release gate +([#57](https://github.com/uniskela/stack-manager/issues/57)). Subsequent PRs implement the product. Boundaries may shift for strong technical reasons; avoid giant cross-cutting PRs. ## PR sequence @@ -84,11 +85,12 @@ Shipped across repository PRs #29, #31, #32, #34, #35, #36 and #37. User guide a **Safe defaults:** automatic Git fetch enabled at 15-minute fallback; auto-deployment off for all stacks until operator opt-in; manual fetch retained; no draft deletion, merge/rebase or force-push. Release version is controlled by Release Please, not this plan. Keep PR #6 environment/secrets and PR #7 read-only runtime observation in their existing later sequence. -## PR #5 (next): deployment routing +## PR #5: deployment routing (implemented for v0.6.0) -Next active phase. Scope as in the table above: `DeploymentProvider` with Portainer and generic webhooks, a forge -webhook receiver, relevance rules, selective deploy, idempotency and audit. Do not expand it into Portainer management -UI. +Delivered in sub-PRs #59, #62, #64, #65 and #66 (issues #52–#56): `DeploymentProvider` with Portainer and generic +webhooks, a signed forge webhook receiver, relevance rules, opt-in selective deploy, idempotency and audit. Release +verification and the operator acceptance checklist are tracked in [#57](https://github.com/uniskela/stack-manager/issues/57) +and [Upgrading to v0.6.0](../UPGRADING_0_6.md). Do not expand it into Portainer management UI. ## Dependency graph From 75c9a38ab20c07853e6f3b0d3d7880675e44d53c Mon Sep 17 00:00:00 2001 From: alex Date: Thu, 22 Oct 2026 15:51:38 +1100 Subject: [PATCH 8/8] test(e2e): report webhook cleanup failures without masking the original error Addresses Kody review 4344658479. Also links follow-up #68 from the readiness report. Co-Authored-By: Claude Opus 5.5 --- docs/internal/releases/v0.6.0-readiness.md | 4 ++-- tests/e2e/git-webhooks.spec.ts | 24 +++++++++++++++------- 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/docs/internal/releases/v0.6.0-readiness.md b/docs/internal/releases/v0.6.0-readiness.md index d0a11b4..27a40c3 100644 --- a/docs/internal/releases/v0.6.0-readiness.md +++ b/docs/internal/releases/v0.6.0-readiness.md @@ -65,8 +65,8 @@ repository because it needs the old release tree. Its assertions are summarised | F4 | Verified push whose fetch exhausts retries is never routed (R1). | Missed automatic deployment; fail-closed. | No | **Documented** in Deployment/Git webhook troubleshooting; regression test pins the safe behaviour. Follow-up recommended. | | F5 | Backup guide described backups as "planned" and lacked WAL, permissions, key and rollback guidance; release doc said ingress never deploys. | Unsafe upgrade/rollback practice. | Yes (docs) | **Fixed**: tested procedures, failure-mode table, rollback and replay-ledger guidance, v0.6.0 release gate, upgrade page and operator checklist. | | F6 | Routing patterns reject `{ } [ ] ( ) ! :` even as literals; undocumented. | Confusing configuration. | No | **Documented** with a verified `?` workaround. | -| F8 | Roadmap (`MVP_PLAN.md`) and `AGENTS.md` still called deployment routing the next active phase. | Misleading project status for contributors and agents. | No | **Fixed**: both say it is implemented on `main` for v0.6.0 and in its release gate. | | F7 | Changelog in #41 repeats "explain automatic fetch and manual fallback" four times and lists #59's six intermediate `feat(sync)` commits separately. | Cosmetic, noisy release notes. | No | **Recommendation** only (Release Please owns the file): consolidate in the GitHub Release text after publication, and add the upgrade/backup note. | +| F8 | Roadmap (`MVP_PLAN.md`) and `AGENTS.md` still called deployment routing the next active phase. | Misleading project status for contributors and agents. | No | **Fixed**: both say it is implemented on `main` for v0.6.0 and in its release gate. | Kody's PR #66 items were re-checked against the code: the repository-lookup failure still propagates for job retry without advancing shared checkpoints (correct: skipping would move cursors past unrouted sources), shared @@ -77,7 +77,7 @@ and the SQLite smoke helper closes its handle on failure paths. Those dispositio | ID | Risk | Severity | Likelihood | Impact | Mitigation | | --- | --- | --- | --- | --- | --- | -| R1 | A verified push is not routed if its fetch fails all 5 retries (~75 s of backoff, e.g. forge restarting). A later polled fetch does not route it. | Medium | Low–medium | Missed automatic deployment; no unsafe deploy. | Next verified push includes the changes (baseline unchanged); Deploy now; documented. Follow-up: route unprocessed verified sources after a later successful fetch, with an age bound. | +| R1 | A verified push is not routed if its fetch fails all 5 retries (~75 s of backoff, e.g. forge restarting). A later polled fetch does not route it. | Medium | Low–medium | Missed automatic deployment; no unsafe deploy. | Next verified push includes the changes (baseline unchanged); Deploy now; documented. Follow-up [#68](https://github.com/uniskela/stack-manager/issues/68). | | R2 | Exactly-once delivery is impossible across HTTP; Portainer ignores `Idempotency-Key`. | Medium | Low | Possible duplicate redeploy after a crash/timeout. | Intent before send, unknown outcome, persistent automation hold, operator inspection. | | R3 | Live forge versions, reverse proxies and receivers may differ from fixtures (byte rewriting, redirects, TLS chains). | Medium | Medium | 401/415 ingress or unknown/rejected sends. | Operator checklist steps 2–3, 5–12 before production opt-in. | | R4 | Git remote host check happens before `git` resolves DNS (rebinding TOCTOU), already listed in SECURITY.md. | Medium | Low | SSRF to a private address via a hostile DNS name. | HTTPS only, no redirects, private networks denied by default. Deployment webhooks are pinned; Git is not. | diff --git a/tests/e2e/git-webhooks.spec.ts b/tests/e2e/git-webhooks.spec.ts index 340af44..fce4bb6 100644 --- a/tests/e2e/git-webhooks.spec.ts +++ b/tests/e2e/git-webhooks.spec.ts @@ -92,12 +92,22 @@ test('signing secret saves, rotates with confirmation and disables', async ({ pa await expect(region.getByText('Disabled', { exact: true })).toBeVisible(); bodyPassed = true; } finally { - // Never let cleanup mask the original failure; assert cleanup only after a passing body. - const cleanup = await page.request - .delete(`/api/workspaces/${workspaceId}/repositories/${repositoryId}/webhook`, { - headers: { origin: new URL(page.url()).origin }, - }) - .catch(() => null); - if (bodyPassed) expect(cleanup?.ok()).toBe(true); + // Never let cleanup mask the original failure: assert it after a passing body, otherwise report it + // alongside the original error (a leftover signing secret makes later runs skip this test). + let cleanupProblem: string | null = null; + try { + const cleanup = await page.request.delete( + `/api/workspaces/${workspaceId}/repositories/${repositoryId}/webhook`, + { headers: { origin: new URL(page.url()).origin } }, + ); + if (!cleanup.ok()) cleanupProblem = `webhook cleanup returned HTTP ${cleanup.status()}`; + } catch (error) { + cleanupProblem = `webhook cleanup failed: ${error instanceof Error ? error.message : String(error)}`; + } + if (bodyPassed) expect(cleanupProblem).toBeNull(); + else if (cleanupProblem) { + info.annotations.push({ type: 'cleanup-failed', description: cleanupProblem }); + console.warn(`${cleanupProblem}; the sample repository may keep a signing secret.`); + } } });