diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index e8bd3b56f..f9bad6c6b 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -9,7 +9,7 @@ on: required: true type: string release_notes: - description: 'Release notes (markdown) - generated by Claude' + description: 'Release notes (markdown)' required: true type: string promoted_from_tag: @@ -529,6 +529,19 @@ jobs: node scripts/apply-entitlement-profile.mjs + echo "Validating seeded bootstrap token..." + BOOTSTRAP_STATUS=$(curl -s -o /tmp/bootstrap-token-validation.txt -w "%{http_code}" \ + -X POST \ + -H "Content-Type: application/json" \ + --data "{\"token\":\"${PULSE_E2E_BOOTSTRAP_TOKEN}\"}" \ + http://localhost:7655/api/security/validate-bootstrap-token || true) + echo "Bootstrap token validation endpoint returned HTTP ${BOOTSTRAP_STATUS}" + if [ "${BOOTSTRAP_STATUS}" != "204" ]; then + cat /tmp/bootstrap-token-validation.txt || true + docker logs pulse-test-server || true + exit 1 + fi + echo "Running update API route smoke check..." STATUS=$(curl -s -o /tmp/update-status.json -w "%{http_code}" http://localhost:7655/api/updates/status || true) echo "Update status endpoint returned HTTP ${STATUS}" diff --git a/tests/integration/docker-compose.test.yml b/tests/integration/docker-compose.test.yml index dd514ce17..8ef594664 100644 --- a/tests/integration/docker-compose.test.yml +++ b/tests/integration/docker-compose.test.yml @@ -6,10 +6,17 @@ services: container_name: pulse-test-seed-bootstrap-token environment: - PULSE_E2E_BOOTSTRAP_TOKEN=${PULSE_E2E_BOOTSTRAP_TOKEN:-0123456789abcdef0123456789abcdef0123456789abcdef} + - PUID=${PUID:-1000} + - PGID=${PGID:-1000} volumes: - test-data:/data command: > - sh -c "set -e; umask 077; echo \"$$PULSE_E2E_BOOTSTRAP_TOKEN\" > /data/.bootstrap_token" + sh -c "set -e; + umask 077; + printf '%s\n' \"$$PULSE_E2E_BOOTSTRAP_TOKEN\" > /data/.bootstrap_token; + chown \"$${PUID:-1000}:$${PGID:-1000}\" /data /data/.bootstrap_token; + chmod 700 /data; + chmod 600 /data/.bootstrap_token" networks: - test-network @@ -59,6 +66,8 @@ services: - PULSE_MULTI_TENANT_ENABLED=${PULSE_MULTI_TENANT_ENABLED:-false} - PULSE_ALLOW_DOCKER_UPDATES=true - PULSE_UPDATE_STAGE_DELAY_MS=250 + - PUID=${PUID:-1000} + - PGID=${PGID:-1000} volumes: - test-data:/data extra_hosts: diff --git a/tests/integration/scripts/pretest.mjs b/tests/integration/scripts/pretest.mjs index 6e60d5617..cd358ca69 100644 --- a/tests/integration/scripts/pretest.mjs +++ b/tests/integration/scripts/pretest.mjs @@ -31,6 +31,7 @@ const truthy = (value) => { if (!value) return false; return ['1', 'true', 'yes', 'on'].includes(String(value).trim().toLowerCase()); }; +const trim = (value) => String(value ?? '').trim(); const shouldSkipDocker = truthy(process.env.PULSE_E2E_SKIP_DOCKER); const shouldSkipPlaywrightInstall = truthy(process.env.PULSE_E2E_SKIP_PLAYWRIGHT_INSTALL); @@ -145,6 +146,54 @@ export const waitForHealth = async ( throw new Error(`Timed out waiting for ${healthURL} after ${attempt} attempts`); }; +export const validateBootstrapToken = async ( + baseURL, + bootstrapToken, + { fetchImpl = fetch } = {}, +) => { + const token = trim(bootstrapToken); + if (token === '') { + return; + } + + const response = await fetchImpl(`${baseURL.replace(/\/+$/, '')}/api/security/validate-bootstrap-token`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ token }), + }); + + if (!response.ok) { + const detail = typeof response.text === 'function' ? await response.text() : ''; + throw new Error( + `Bootstrap token validation failed: HTTP ${response.status}${detail ? ` ${detail}` : ''}`, + ); + } +}; + +export const validateBootstrapTokenForFirstRun = async ( + baseURL, + bootstrapToken, + { fetchImpl = fetch } = {}, +) => { + const token = trim(bootstrapToken); + if (token === '') { + return; + } + + const normalizedBaseURL = baseURL.replace(/\/+$/, ''); + const statusResponse = await fetchImpl(`${normalizedBaseURL}/api/security/status`); + if (statusResponse.ok && typeof statusResponse.json === 'function') { + const status = await statusResponse.json().catch(() => null); + if (status?.hasAuthentication !== false) { + return; + } + } + + await validateBootstrapToken(normalizedBaseURL, token, { fetchImpl }); +}; + export const main = async () => { if (!shouldSkipPlaywrightInstall) { await run(npxCmd, ['playwright', 'install', 'chromium']); @@ -191,6 +240,7 @@ export const main = async () => { try { await waitForHealth(`${baseURL}/api/health`); console.log('[pretest] Health check passed!'); + await validateBootstrapTokenForFirstRun(baseURL, process.env.PULSE_E2E_BOOTSTRAP_TOKEN); await applyRequestedEntitlementProfile(); } catch (error) { console.error('[pretest] Health check failed:', error.message); diff --git a/tests/integration/scripts/pretest.test.mjs b/tests/integration/scripts/pretest.test.mjs index 738497119..3c3fd4f7d 100644 --- a/tests/integration/scripts/pretest.test.mjs +++ b/tests/integration/scripts/pretest.test.mjs @@ -1,7 +1,11 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { resolveHealthCheckStrategy } from './pretest.mjs'; +import { + resolveHealthCheckStrategy, + validateBootstrapToken, + validateBootstrapTokenForFirstRun, +} from './pretest.mjs'; test('resolveHealthCheckStrategy uses plain HTTP mode for HTTP health URLs', async () => { const strategy = await resolveHealthCheckStrategy('http://localhost:7655/api/health'); @@ -49,3 +53,92 @@ test('resolveHealthCheckStrategy scopes insecure HTTPS mode to curl when availab assert.equal(strategy.mode, 'curl'); assert.equal(strategy.parsedURL.protocol, 'https:'); }); + +test('validateBootstrapToken posts the deterministic token to the public validation route', async () => { + const calls = []; + await validateBootstrapToken('http://localhost:7655/', ' token-123 ', { + fetchImpl: async (url, options) => { + calls.push({ url, options }); + return { + ok: true, + status: 204, + text: async () => '', + }; + }, + }); + + assert.deepEqual(calls, [ + { + url: 'http://localhost:7655/api/security/validate-bootstrap-token', + options: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ token: 'token-123' }), + }, + }, + ]); +}); + +test('validateBootstrapToken fails with response detail when validation is rejected', async () => { + await assert.rejects( + () => validateBootstrapToken('http://localhost:7655', 'bad-token', { + fetchImpl: async () => ({ + ok: false, + status: 409, + text: async () => 'Bootstrap token unavailable', + }), + }), + /HTTP 409 Bootstrap token unavailable/, + ); +}); + +test('validateBootstrapTokenForFirstRun skips validation once auth is configured', async () => { + const calls = []; + await validateBootstrapTokenForFirstRun('http://localhost:7655', 'token-123', { + fetchImpl: async (url, options) => { + calls.push({ url, options }); + return { + ok: true, + status: 200, + json: async () => ({ hasAuthentication: true }), + text: async () => '', + }; + }, + }); + + assert.deepEqual(calls, [ + { + url: 'http://localhost:7655/api/security/status', + options: undefined, + }, + ]); +}); + +test('validateBootstrapTokenForFirstRun validates the seed token before first-run setup', async () => { + const calls = []; + await validateBootstrapTokenForFirstRun('http://localhost:7655', 'token-123', { + fetchImpl: async (url, options) => { + calls.push({ url, options }); + if (url.endsWith('/api/security/status')) { + return { + ok: true, + status: 200, + json: async () => ({ hasAuthentication: false }), + text: async () => '', + }; + } + return { + ok: true, + status: 204, + text: async () => '', + }; + }, + }); + + assert.equal(calls.length, 2); + assert.equal(calls[0].url, 'http://localhost:7655/api/security/status'); + assert.equal(calls[1].url, 'http://localhost:7655/api/security/validate-bootstrap-token'); + assert.equal(calls[1].options.body, JSON.stringify({ token: 'token-123' })); +});