diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index 6728fa0a9..59462751d 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -474,6 +474,13 @@ TLS floor in the dynamic config. `PULSE_PROXMOX_GUEST_DOCKER_INVENTORY_VMIDS`) so live dev verification of host-side LXC Docker inventory does not silently restart into default-off monitoring. + Browser authentication helpers used by release and managed-runtime E2E must + keep session creation below the backend login limiter. Shared helpers must + treat an HTTP 429 response from `POST /api/login` as the retryable + `Too many requests` outcome instead of collapsing it into a generic + connection failure, and release suites that run many scenarios against one + compose backend must prefer worker-scoped authenticated storage state over + repeated per-test password logins. 6. Add or change governed release-promotion workflow inputs, operator-facing promotion metadata, the canonical version file, prerelease feedback intake prompts, artifact publication lineage enforcement, release note or changelog packet composition, or stable-promotion rehearsal summaries through `.github/workflows/create-release.yml`, `.github/workflows/helm-pages.yml`, `.github/workflows/publish-docker.yml`, `.github/workflows/publish-helm-chart.yml`, `.github/workflows/promote-floating-tags.yml`, `.github/workflows/release-dry-run.yml`, `.github/workflows/update-demo-server.yml`, `.github/ISSUE_TEMPLATE/v6_rc_feedback.yml`, `docs/RELEASE_NOTES.md`, `docs/releases/`, `docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md`, `docs/release-control/v6/internal/PRE_RELEASE_CHECKLIST.md`, `docs/release-control/v6/internal/RC_TO_GA_REHEARSAL_TEMPLATE.md`, `scripts/check-workflow-dispatch-inputs.py`, `scripts/release_control/mobile_release_gate.py`, `scripts/release_control/mobile_release_gate_test.py`, `scripts/release_control/render_release_body.py`, `scripts/release_control/validate_artifact_release_line.py`, `scripts/release_control/record_rc_to_ga_rehearsal.py`, `scripts/release_control/internal/record_rc_to_ga_rehearsal.py`, `scripts/release_control/release_promotion_policy_support.py`, `scripts/trigger-release.sh`, and `scripts/trigger-release-dry-run.sh` That release-promotion boundary also owns prerelease note packet lineage: shipped RC notes must remain historically accurate, the top-level @@ -675,6 +682,12 @@ TLS floor in the dynamic config. the cut, and the release-asset proof must pin the current packet to those runtime fixes so a patch that includes support work cannot ship as a metadata-only release note. + Release integration failures must leave enough evidence to classify the + failure after the compose stack is torn down. `create-release.yml` must + upload the Playwright report and a `release-integration-failures` artifact + containing Playwright `test-results/` plus + `release-integration-diagnostics/docker.log`; that Docker log must capture + container state and the Pulse test server plus mock GitHub server logs. 7. Preserve release-matched installer and Helm operator documentation links through `scripts/install.sh`, `.github/workflows/helm-pages.yml`, `.github/workflows/publish-helm-chart.yml`, and the chart metadata itself so deployment guidance and packaged chart metadata do not drift back to branch-tip `main` docs when a release line or promoted tag already exists. The same governed Helm boundary also owns `deploy/helm/pulse/` itself: chart metadata, default values, templates, and generated chart docs must @@ -812,6 +825,10 @@ TLS floor in the dynamic config. use backend-owned dev reset, admin-bypass, session-login, or token-auth paths instead of deleting runtime files, rebuilding bootstrap state, or accepting the retired dashboard route as proof of authentication. + Release E2E suites that use those helpers must avoid turning scenario count + into repeated password-login pressure: worker-scoped authenticated storage + state is the canonical multi-scenario shape, and helper retry proof must + preserve explicit 429 login-rate classification. 8. Keep root-level Playwright wrapper routing on the canonical managed browser truth. `playwright.config.ts`, `tests/integration/playwright.config.ts`, and `tests/integration/tests/runtime-defaults.ts` must resolve the same diff --git a/scripts/release_control/release_promotion_policy_test.py b/scripts/release_control/release_promotion_policy_test.py index c5e1fa3e4..bfd5a5a2d 100644 --- a/scripts/release_control/release_promotion_policy_test.py +++ b/scripts/release_control/release_promotion_policy_test.py @@ -621,6 +621,21 @@ class ReleasePromotionPolicyTest(unittest.TestCase): self.assertIn('gh release upload "$@"', content) self.assertIn('gh release upload failed on attempt ${attempt}/${max_attempts}; retrying in ${wait_seconds}s', content) self.assertIn('gh release upload failed after ${max_attempts} attempts', content) + self.assertIn("Running multi-tenant E2E suite...", content) + self.assertIn( + "npx playwright test tests/03-multi-tenant.spec.ts --project=chromium --reporter=list", + content, + ) + self.assertIn('PULSE_E2E_ENTITLEMENT_PROFILE: "multi-tenant"', content) + self.assertIn("Collect integration diagnostics", content) + self.assertIn("release-integration-diagnostics/docker.log", content) + self.assertIn("docker ps -a || true", content) + self.assertIn("docker logs pulse-test-server", content) + self.assertIn("docker logs pulse-mock-github", content) + self.assertIn("Upload integration Playwright report", content) + self.assertIn("Upload integration failures", content) + self.assertIn("tests/integration/test-results/", content) + self.assertIn("tests/integration/release-integration-diagnostics/", content) self.assertIn("provenance: mode=max", content) self.assertIn("sbom: true", content) self.assertIn("id-token: write", content) diff --git a/tests/integration/scripts/managed-local-backend.test.mjs b/tests/integration/scripts/managed-local-backend.test.mjs index 094ccd390..5c77d44ec 100644 --- a/tests/integration/scripts/managed-local-backend.test.mjs +++ b/tests/integration/scripts/managed-local-backend.test.mjs @@ -3,6 +3,7 @@ import fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import test from 'node:test'; +import { fileURLToPath } from 'node:url'; import { buildManagedLocalBackendEnv, @@ -11,6 +12,9 @@ import { shouldBuildManagedLocalBackendBinary, } from './managed-local-backend.mjs'; +const scriptsDir = path.dirname(fileURLToPath(import.meta.url)); +const integrationRoot = path.resolve(scriptsDir, '..'); + test('buildManagedLocalBackendState uses deterministic defaults', () => { const state = buildManagedLocalBackendState({}); assert.equal(state.repoRoot.endsWith('/repos/pulse'), true); @@ -143,6 +147,26 @@ test('buildManagedLocalBackendEnv seeds deterministic auth in demo mode', () => assert.equal(env.PULSE_AUTH_PASS, 'adminadminadmin'); }); +test('multi-tenant release auth reuses storage state and classifies login rate limits', async () => { + const helpers = await fs.readFile(path.join(integrationRoot, 'tests', 'helpers.ts'), 'utf8'); + const multiTenantSpec = await fs.readFile( + path.join(integrationRoot, 'tests', '03-multi-tenant.spec.ts'), + 'utf8', + ); + + assert.match(multiTenantSpec, /createAuthenticatedStorageState/); + assert.match(multiTenantSpec, /storageState:\s*async\s*\(\{\s*authStorageStatePath\s*\},\s*use\)/); + assert.match(multiTenantSpec, /authStorageStatePath:\s*\[/); + assert.match(multiTenantSpec, /multi-tenant-\$\{workerInfo\.project\.name\}\.json/); + assert.match(multiTenantSpec, /\{\s*scope:\s*'worker'\s*\}/); + + assert.match(helpers, /new URL\(response\.url\(\)\)\.pathname === "\/api\/login"/); + assert.match(helpers, /response\.request\(\)\.method\(\)\.toUpperCase\(\) === "POST"/); + assert.match(helpers, /loginResponse\?\.status\(\) === 429/); + assert.match(helpers, /return "error:Too many requests"/); + assert.match(helpers, /\/too many\/i\.test\(lastOutcome\) \? 15_000/); +}); + test('shouldBuildManagedLocalBackendBinary returns true when binary is missing', async () => { const repoRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'pulse-managed-backend-')); const state = { diff --git a/tests/integration/tests/03-multi-tenant.spec.ts b/tests/integration/tests/03-multi-tenant.spec.ts index 4d45371c3..30500a285 100644 --- a/tests/integration/tests/03-multi-tenant.spec.ts +++ b/tests/integration/tests/03-multi-tenant.spec.ts @@ -1,6 +1,10 @@ -import { expect, test } from '@playwright/test'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { test as base, expect } from '@playwright/test'; import { apiRequest, + createAuthenticatedStorageState, createOrg, deleteOrg, ensureAuthenticated, @@ -10,6 +14,38 @@ import { waitForAppShell, } from './helpers'; +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +type WorkerFixtures = { + authStorageStatePath: string; +}; + +const test = base.extend<{}, WorkerFixtures>({ + storageState: async ({ authStorageStatePath }, use) => { + await use(authStorageStatePath); + }, + authStorageStatePath: [ + async ({ browser }, use, workerInfo) => { + const storageStatePath = path.resolve( + __dirname, + '..', + '..', + 'tmp', + 'playwright-auth', + `multi-tenant-${workerInfo.project.name}.json`, + ); + fs.mkdirSync(path.dirname(storageStatePath), { recursive: true }); + await createAuthenticatedStorageState(browser, storageStatePath); + try { + await use(storageStatePath); + } finally { + fs.rmSync(storageStatePath, { force: true }); + } + }, + { scope: 'worker' }, + ], +}); + type Organization = { id: string; displayName?: string; diff --git a/tests/integration/tests/helpers.ts b/tests/integration/tests/helpers.ts index e1dc0f3d2..afd17a78a 100644 --- a/tests/integration/tests/helpers.ts +++ b/tests/integration/tests/helpers.ts @@ -925,6 +925,15 @@ export async function login(page: Page, credentials = E2E_CREDENTIALS) { .first(); const submitAndAwaitOutcome = async (): Promise => { + const loginResponsePromise = page + .waitForResponse( + (response) => + new URL(response.url()).pathname === "/api/login" && + response.request().method().toUpperCase() === "POST", + { timeout: 30_000 }, + ) + .catch(() => null); + await page.fill('input[name="username"]', credentials.username); await page.fill('input[name="password"]', credentials.password); await page.click('button[type="submit"]'); @@ -941,6 +950,13 @@ export async function login(page: Page, credentials = E2E_CREDENTIALS) { const message = ( (await loginErrorText.textContent()) || "login_error" ).trim(); + const loginResponse = await Promise.race([ + loginResponsePromise, + page.waitForTimeout(100).then(() => null), + ]); + if (loginResponse?.status() === 429) { + return "error:Too many requests"; + } return `error:${message}`; } await page.waitForTimeout(250);