From 4d6935f4faec1762eb750825107c1e5ecc1fc05c Mon Sep 17 00:00:00 2001 From: rcourtman Date: Wed, 8 Jul 2026 01:06:00 +0100 Subject: [PATCH] Restore update-flow coverage as a v6 Playwright spec, retire test-updates workflow The Update Integration Tests workflow lost its Go test (tests/integration/api) in the v6 release commit and was reduced to a diagnostic smoke test that duplicated the test-e2e stack boot. Replace it with tests/79-update-flow.spec.ts in the main suite, which runs via test-e2e.yml on the same trigger paths: - stable-channel check returns the mock v99.0.0 release and filters the v99.1.0-rc.1 prerelease (regression guard for the auto-update prerelease bug); rc-channel check surfaces the prerelease - update plan reports honest manual instructions for the docker deployment with readiness attached - apply refuses prerelease download URLs on the stable channel (409) - apply of an unsigned artifact fails closed at SSHSIG verification; a completed update against the unsigned mock artifact would mean the pinned-key trust root was bypassed The old happy-path apply test is intentionally not revived: v6 made SSHSIG verification against the pinned pulse-installer key mandatory, so completing an apply would require shipping the real signing key to the harness or weakening the trust root. mock-github-server now serves v-prefixed asset names and download paths like real Pulse releases (pulse-v99.0.0-linux-amd64.tar.gz); the in-app updater only recognizes v-prefixed versions in download URLs, so the old unprefixed shape made every apply fail validation before reaching the paths under test. Unknown non-tarball sidecar files (e.g. .sshsig) now 404 instead of falling back to tarball bytes. The spec self-skips when the update check is not served by the mock server, so managed-local-backend runs are unaffected. --- .github/workflows/test-updates.yml | 126 --------------- tests/integration/mock-github-server/main.go | 24 ++- .../integration/tests/79-update-flow.spec.ts | 144 ++++++++++++++++++ 3 files changed, 161 insertions(+), 133 deletions(-) delete mode 100644 .github/workflows/test-updates.yml create mode 100644 tests/integration/tests/79-update-flow.spec.ts diff --git a/.github/workflows/test-updates.yml b/.github/workflows/test-updates.yml deleted file mode 100644 index e46ffe9cc0..0000000000 --- a/.github/workflows/test-updates.yml +++ /dev/null @@ -1,126 +0,0 @@ -name: Update Integration Tests - -on: - pull_request: - paths: - # Trigger on changes to update-related code - - 'internal/updates/**' - - 'internal/api/updates.go' - - 'internal/api/rate_limit*.go' - - 'frontend-modern/src/components/Update*.tsx' - - 'frontend-modern/src/api/updates.ts' - - 'frontend-modern/src/stores/updates.ts' - - 'tests/integration/**' - - '.github/workflows/test-updates.yml' - push: - branches: - - main - - master - paths: - - 'internal/updates/**' - - 'internal/api/updates.go' - - 'frontend-modern/src/components/Update*.tsx' - - 'tests/integration/**' - workflow_dispatch: # Allow manual triggering - - - -permissions: - contents: read - -jobs: - integration-tests: - name: Update Flow Integration Tests - runs-on: ubuntu-24.04 - timeout-minutes: 30 - - steps: - - name: Checkout code - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - - - name: Set up Go - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 - with: - go-version-file: go.mod - cache: true - - - name: Set up Node.js - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 - with: - node-version: '20' - cache: 'npm' - cache-dependency-path: tests/integration/package-lock.json - - - name: Install Playwright dependencies - working-directory: tests/integration - run: | - npm ci - npx playwright install --with-deps chromium - - - name: Install frontend dependencies - working-directory: frontend-modern - run: npm ci - - - name: Build Pulse for testing - run: | - make build || go build -o pulse ./cmd/pulse - - - name: Build Docker images for test environment - working-directory: tests/integration - run: | - # Build mock GitHub server - docker build -t pulse-mock-github:test ./mock-github-server - - # Build Pulse test image - cd ../../ - docker build -t pulse:test --target runtime . - - - name: Run diagnostic smoke test - working-directory: tests/integration - env: - MOCK_CHECKSUM_ERROR: "false" - MOCK_NETWORK_ERROR: "false" - MOCK_RATE_LIMIT: "false" - MOCK_STALE_RELEASE: "false" - # The diagnostic spec skips itself unless explicitly enabled. - PULSE_E2E_DIAGNOSTIC: "1" - run: | - docker compose -f docker-compose.test.yml up -d --wait - npx playwright test tests/00-diagnostic.spec.ts --reporter=list,html - docker compose -f docker-compose.test.yml down -v - - - name: Upload test results - if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: playwright-report - path: tests/integration/playwright-report/ - retention-days: 30 - - - name: Upload test videos and screenshots - if: failure() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: test-failures - path: | - tests/integration/test-results/ - retention-days: 7 - - - name: Cleanup Docker resources - if: always() - working-directory: tests/integration - run: | - docker compose -f docker-compose.test.yml down -v || true - docker system prune -f || true - - - name: Comment PR with test results - if: github.event_name == 'pull_request' && failure() - uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 - with: - script: | - github.rest.issues.createComment({ - issue_number: context.issue.number, - owner: context.repo.owner, - repo: context.repo.repo, - body: '❌ Update integration tests failed. Please check the [workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) for details.' - }) diff --git a/tests/integration/mock-github-server/main.go b/tests/integration/mock-github-server/main.go index 02a4914eb8..786d9b6ff6 100644 --- a/tests/integration/mock-github-server/main.go +++ b/tests/integration/mock-github-server/main.go @@ -159,11 +159,14 @@ func main() { }, } - // Generate tarballs and checksums for each release + // Generate tarballs and checksums for each release. Asset names and + // download paths mirror real Pulse releases, which keep the leading "v" + // (e.g. pulse-v6.0.4-linux-amd64.tar.gz) — the in-app updater infers the + // target version from the URL and only recognizes v-prefixed versions. for i := range releases { rel := &releases[i] version := strings.TrimPrefix(rel.TagName, "v") - filename := fmt.Sprintf("pulse-%s-linux-amd64.tar.gz", version) + filename := fmt.Sprintf("pulse-%s-linux-amd64.tar.gz", rel.TagName) // Create dummy tarball tarball := createDummyTarball(version) @@ -190,11 +193,11 @@ func main() { }{ { Name: filename, - BrowserDownloadURL: fmt.Sprintf("%s/download/%s/%s", baseURL, version, filename), + BrowserDownloadURL: fmt.Sprintf("%s/download/%s/%s", baseURL, rel.TagName, filename), }, { Name: "checksums.txt", - BrowserDownloadURL: fmt.Sprintf("%s/download/%s/checksums.txt", baseURL, version), + BrowserDownloadURL: fmt.Sprintf("%s/download/%s/checksums.txt", baseURL, rel.TagName), }, } } @@ -283,9 +286,16 @@ func main() { // Serve tarball (strictly match requested filename first) tarball, ok := tarballs[file] if !ok { - // Fallback to canonical filename derived from version (without leading v) - trimmedVersion := strings.TrimPrefix(version, "v") - canonical := fmt.Sprintf("pulse-%s-linux-amd64.tar.gz", trimmedVersion) + // Only fall back for tarball requests; sidecar files like + // .sshsig must 404 like real GitHub when they were never + // published, so signature verification stays fail-closed. + if !strings.HasSuffix(file, ".tar.gz") { + w.WriteHeader(http.StatusNotFound) + log.Printf("Asset not found: %s", file) + return + } + // Fallback to canonical filename derived from the version tag + canonical := fmt.Sprintf("pulse-v%s-linux-amd64.tar.gz", strings.TrimPrefix(version, "v")) tarball, ok = tarballs[canonical] if !ok { w.WriteHeader(http.StatusNotFound) diff --git a/tests/integration/tests/79-update-flow.spec.ts b/tests/integration/tests/79-update-flow.spec.ts new file mode 100644 index 0000000000..1fdfc6f7e3 --- /dev/null +++ b/tests/integration/tests/79-update-flow.spec.ts @@ -0,0 +1,144 @@ +/** + * Update-flow coverage against the mock GitHub server. + * + * The compose stack (docker-compose.test.yml) points PULSE_UPDATE_SERVER at + * tests/integration/mock-github-server, which serves sentinel releases + * v99.0.0 (stable) and v99.1.0-rc.1 (prerelease). This spec replaces the + * pre-v6 Go test tests/integration/api/update_flow_test.go at the same + * surface: /api/updates/{check,plan,apply,status}. + * + * v6 made SSHSIG verification against the pinned pulse-installer key + * mandatory and fail-closed (internal/updates/signature.go), so an apply can + * no longer complete against unsigned mock artifacts. The apply test asserts + * the fail-closed rejection instead of a completed update; a "completed" + * status here would mean an unsigned artifact was installed. + * + * Self-skips when the update check does not surface the mock sentinel + * release (e.g. managed local backend without PULSE_UPDATE_SERVER). + */ + +import { expect, test, type Page } from '@playwright/test'; +import { apiRequest, ensureAuthenticated } from './helpers'; + +const MOCK_STABLE_VERSION = '99.0.0'; +const MOCK_RC_VERSION = '99.1.0-rc.1'; + +type UpdateInfo = { + available: boolean; + currentVersion: string; + latestVersion: string; + downloadUrl: string; + isPrerelease: boolean; +}; + +type UpdateStatus = { + status: string; + progress: number; + message: string; + error?: string; +}; + +let mockUpdateServerWired: boolean | null = null; + +async function checkUpdates(page: Page, channel: 'stable' | 'rc'): Promise { + const res = await apiRequest(page, `/api/updates/check?channel=${channel}`); + expect(res.ok(), await res.text()).toBeTruthy(); + return (await res.json()) as UpdateInfo; +} + +async function fetchUpdateStatus(page: Page): Promise { + const res = await apiRequest(page, '/api/updates/status'); + expect(res.ok(), await res.text()).toBeTruthy(); + return (await res.json()) as UpdateStatus; +} + +test.describe.serial('update flow against the mock update server', () => { + test.beforeEach(async ({ page }) => { + await ensureAuthenticated(page); + if (mockUpdateServerWired === null) { + const res = await apiRequest(page, '/api/updates/check?channel=stable'); + if (!res.ok()) { + mockUpdateServerWired = false; + } else { + const info = (await res.json()) as UpdateInfo; + mockUpdateServerWired = info.latestVersion === MOCK_STABLE_VERSION; + } + } + test.skip( + !mockUpdateServerWired, + 'update check is not served by the mock GitHub server (PULSE_UPDATE_SERVER not wired)', + ); + }); + + test('stable channel offers the latest stable release, not the prerelease', async ({ page }) => { + const info = await checkUpdates(page, 'stable'); + expect(info.available, JSON.stringify(info)).toBeTruthy(); + expect(info.latestVersion).toBe(MOCK_STABLE_VERSION); + expect(info.isPrerelease).toBeFalsy(); + expect(info.downloadUrl).toContain(`pulse-v${MOCK_STABLE_VERSION}-linux-amd64.tar.gz`); + }); + + test('rc channel surfaces the newer prerelease', async ({ page }) => { + const info = await checkUpdates(page, 'rc'); + expect(info.latestVersion, JSON.stringify(info)).toBe(MOCK_RC_VERSION); + expect(info.isPrerelease).toBeTruthy(); + expect(info.downloadUrl).toContain(`pulse-v${MOCK_RC_VERSION}-linux-amd64.tar.gz`); + }); + + test('update plan reports manual instructions for the docker deployment', async ({ page }) => { + // The test image is a release build, where PULSE_MOCK_MODE fails closed + // for deployment-type detection: the container is honestly a "docker" + // deployment, which cannot replace its own image, so the plan offers + // instructions with readiness attached instead of an auto update. + const res = await apiRequest( + page, + `/api/updates/plan?version=${MOCK_STABLE_VERSION}&channel=stable`, + ); + expect(res.ok(), await res.text()).toBeTruthy(); + const plan = await res.json(); + expect(plan.canAutoUpdate, JSON.stringify(plan)).toBeFalsy(); + expect(plan.rollbackSupport).toBeTruthy(); + expect(Array.isArray(plan.instructions) && plan.instructions.length > 0).toBeTruthy(); + expect(plan.readiness?.status, JSON.stringify(plan.readiness)).toBeTruthy(); + }); + + test('stable channel refuses to apply a prerelease download URL', async ({ page }) => { + const rcInfo = await checkUpdates(page, 'rc'); + const res = await apiRequest(page, '/api/updates/apply?channel=stable', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + data: { downloadUrl: rcInfo.downloadUrl }, + }); + const body = await res.text(); + expect(res.status(), body).toBe(409); + expect(body).toMatch(/prerelease/i); + }); + + test('apply of an unsigned artifact fails closed at signature verification', async ({ page }) => { + const info = await checkUpdates(page, 'stable'); + const applyRes = await apiRequest(page, '/api/updates/apply?channel=stable', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + data: { downloadUrl: info.downloadUrl }, + }); + // A fast failure inside the start-ack window surfaces as a 5xx with a + // generic message; a slower one returns "started". Either way the real + // failure reason lands in /api/updates/status. + if (!applyRes.ok()) { + expect(applyRes.status(), await applyRes.text()).toBeGreaterThanOrEqual(500); + } + + let last: UpdateStatus | null = null; + const deadline = Date.now() + 60_000; + while (Date.now() < deadline) { + last = await fetchUpdateStatus(page); + expect(last.status, JSON.stringify(last)).not.toBe('completed'); + if (last.status === 'error') { + break; + } + await page.waitForTimeout(500); + } + expect(last?.status, JSON.stringify(last)).toBe('error'); + expect(`${last?.error ?? ''} ${last?.message ?? ''}`).toMatch(/signature/i); + }); +});