diff --git a/.github/workflows/test-e2e.yml b/.github/workflows/test-e2e.yml index 03d1b1b11c..57e56d8623 100644 --- a/.github/workflows/test-e2e.yml +++ b/.github/workflows/test-e2e.yml @@ -12,6 +12,8 @@ on: - 'tests/integration/**' - 'Dockerfile' - '.github/workflows/test-e2e.yml' + - 'scripts/playwright-apt.conf' + - 'scripts/tests/test_playwright_acquisition.py' push: branches: - main @@ -24,6 +26,8 @@ on: - 'tests/integration/**' - 'Dockerfile' - '.github/workflows/test-e2e.yml' + - 'scripts/playwright-apt.conf' + - 'scripts/tests/test_playwright_acquisition.py' workflow_dispatch: # Let branch runs finish so busy main and release branches retain verdicts. @@ -51,6 +55,9 @@ jobs: - name: Check shipped docs before dependency work run: python3 scripts/check_docs_mirror.py + - name: Validate browser dependency acquisition policy + run: python3 scripts/tests/test_playwright_acquisition.py + - name: Set up Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: @@ -103,13 +110,21 @@ jobs: uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: go-version-file: go.mod + # Apply only to this disposable hosted runner, never the maintainer host. + # Keep the provider's repositories and apt authentication unchanged. + - name: Bound browser OS dependency acquisition + run: sudo install -m 0644 scripts/playwright-apt.conf /etc/apt/apt.conf.d/99pulse-playwright-acquire + - name: Install locked dependencies run: | npm ci --prefix tests/integration npm ci --prefix frontend-modern npm ci --prefix internal/cloudcp/portal/frontend - cd tests/integration - npx playwright install --with-deps chromium + + - name: Install Playwright browser and OS dependencies + timeout-minutes: 10 + working-directory: tests/integration + run: npx playwright install --with-deps chromium - name: Validate offline issuer boundaries working-directory: tests/integration run: >- @@ -156,7 +171,13 @@ jobs: cache: 'npm' cache-dependency-path: tests/integration/package-lock.json + # Apply only to this disposable hosted runner, never the maintainer host. + # Keep the provider's repositories and apt authentication unchanged. + - name: Bound browser OS dependency acquisition + run: sudo install -m 0644 scripts/playwright-apt.conf /etc/apt/apt.conf.d/99pulse-playwright-acquire + - name: Install Playwright dependencies + timeout-minutes: 10 working-directory: tests/integration run: | npm ci @@ -315,7 +336,13 @@ jobs: node-version: '24' package-manager-cache: false + # Apply only to this disposable hosted runner, never the maintainer host. + # Keep the provider's repositories and apt authentication unchanged. + - name: Bound browser OS dependency acquisition + run: sudo install -m 0644 scripts/playwright-apt.conf /etc/apt/apt.conf.d/99pulse-playwright-acquire + - name: Install Playwright dependencies + timeout-minutes: 10 working-directory: tests/integration run: | npm ci diff --git a/scripts/playwright-apt.conf b/scripts/playwright-apt.conf new file mode 100644 index 0000000000..f5625baa2b --- /dev/null +++ b/scripts/playwright-apt.conf @@ -0,0 +1,6 @@ +// Bound apt transport inside disposable GitHub E2E runners. Do not replace +// repositories, disable TLS/signatures or accept an incomplete index as success. +Acquire::http::Timeout "30"; +Acquire::https::Timeout "30"; +Acquire::Retries "2"; +APT::Update::Error-Mode "any"; diff --git a/scripts/tests/test_playwright_acquisition.py b/scripts/tests/test_playwright_acquisition.py new file mode 100644 index 0000000000..a3e3030bc4 --- /dev/null +++ b/scripts/tests/test_playwright_acquisition.py @@ -0,0 +1,157 @@ +#!/usr/bin/env python3 +"""Keep failed browser setup out of the E2E test budget, without stale apt success.""" + +import os +from pathlib import Path +import re +import socket +import subprocess +import tempfile +import unittest + + +ROOT = Path(__file__).resolve().parents[2] +WORKFLOW = ROOT / ".github/workflows/test-e2e.yml" +POLICY = ROOT / "scripts/playwright-apt.conf" +INSTALL_POLICY = ( + "sudo install -m 0644 scripts/playwright-apt.conf " + "/etc/apt/apt.conf.d/99pulse-playwright-acquire" +) + + +def job_block(workflow, job): + return re.split(r"(?m)^ \S", workflow.split(f"\n {job}:\n", 1)[1], maxsplit=1)[0] + + +def step_blocks(job): + return re.split(r"(?m)^ - name: ", job)[1:] + + +class PlaywrightAcquisitionTest(unittest.TestCase): + def assert_workflow_policy(self, workflow): + for job in ("offline-org-provisioning", "e2e", "agent-registration"): + with self.subTest(job=job): + block = job_block(workflow, job) + steps = step_blocks(block) + policies = [i for i, step in enumerate(steps) if INSTALL_POLICY in step] + installers = [ + (i, step) for i, step in enumerate(steps) + if "npx playwright install --with-deps " in step + ] + self.assertEqual(len(policies), 1) + self.assertEqual(len(installers), 1) + i, install = installers[0] + self.assertLess(policies[0], i) + for step in (steps[policies[0]], install): + self.assertNotIn("continue-on-error", step) + self.assertNotIn("if:", step) + self.assertNotIn("||", step) + self.assertIn("timeout-minutes: 10", install) + self.assertIn("working-directory: tests/integration", install) + browsers = "chromium webkit" if job == "e2e" else "chromium" + self.assertIn(f"npx playwright install --with-deps {browsers}\n", install) + # Browser setup must finish successfully before real journeys. + self.assertLess(i, next( + n for n, step in enumerate(steps) + if step.startswith("Run stable-tier E2E suite") + or step.startswith("Prove authenticated default") + or step.startswith("Run agent registration lifecycle") + )) + + def test_all_browser_jobs_bound_setup_without_weakening_gates(self): + self.assert_workflow_policy(WORKFLOW.read_text()) + + def test_policy_and_tests_trigger_both_pr_and_main_validation(self): + workflow = WORKFLOW.read_text() + for event in ("pull_request", "push"): + block = re.split(r"(?m)^ \S", workflow.split(f" {event}:\n", 1)[1], maxsplit=1)[0] + self.assertIn("'scripts/playwright-apt.conf'", block) + self.assertIn("'scripts/tests/test_playwright_acquisition.py'", block) + selection = job_block(workflow, "tier-selection") + self.assertLess( + selection.index("python3 scripts/tests/test_playwright_acquisition.py"), + selection.index("npm ci"), + ) + + def test_unbounded_and_ignored_setup_mutants_are_rejected(self): + workflow = WORKFLOW.read_text() + # Each mutation targets the fault, not an unrelated YAML assertion. + for original, replacement in ( + (" timeout-minutes: 10\n", ""), + (INSTALL_POLICY, "echo no acquisition policy"), + ("run: npx playwright install --with-deps chromium\n", + "run: npx playwright install --with-deps chromium || true\n"), + ): + with self.subTest(mutation=replacement): + # subTest failures cannot be caught as an ordinary assertion, + # so collect the mutant's real unittest result independently. + class Mutant(PlaywrightAcquisitionTest): + def runTest(inner): + inner.assert_workflow_policy(workflow.replace(original, replacement)) + + result = unittest.TestResult() + Mutant().run(result) + self.assertFalse(result.wasSuccessful()) + self.assertTrue(result.failures) + self.assertFalse(result.errors) + + def test_real_apt_parser_loads_only_the_four_expected_controls(self): + with tempfile.TemporaryDirectory() as temp: + config = Path(temp) / "apt.conf" + config.write_text( + 'Dir::Etc::parts "-";\nDir::Etc::main "-";\n' + POLICY.read_text() + ) + parsed = subprocess.run( + ["apt-config", "dump"], env={**os.environ, "APT_CONFIG": str(config)}, + capture_output=True, text=True, timeout=10, check=True, + ).stdout + expected = { + "Acquire::http::Timeout": "30", + "Acquire::https::Timeout": "30", + "Acquire::Retries": "2", + "APT::Update::Error-Mode": "any", + } + entries = dict(re.findall(r'^(\S+) "([^"\n]*)";', POLICY.read_text(), re.M)) + self.assertEqual(entries, expected) + for key, value in expected.items(): + self.assertIn(f'{key} "{value}";', parsed) + self.assertNotIn("AllowUnauthenticated", parsed) + self.assertNotIn("Verify-Peer", parsed) + + def test_incomplete_index_is_a_real_apt_failure_not_a_warning_pass(self): + # An unlistening reserved loopback port gives a definite transport + # failure. No external request, privileged apt operation, host list, + # post-update hook or authentication bypass is involved. + with socket.socket() as sock, tempfile.TemporaryDirectory() as temp: + sock.bind(("127.0.0.1", 0)) + root = Path(temp) + (root / "lists/partial").mkdir(parents=True) + (root / "cache/archives/partial").mkdir(parents=True) + sources = root / "sources.list" + sources.write_text(f"deb http://127.0.0.1:{sock.getsockname()[1]} stable main\n") + config = root / "apt.conf" + prefix = ( + 'Dir::Etc::parts "-";\nDir::Etc::main "-";\n' + f'Dir::Etc::sourcelist "{sources}";\nDir::Etc::sourceparts "-";\n' + f'Dir::State::lists "{root / "lists"}";\n' + f'Dir::Cache "{root / "cache"}";\n' + 'Dir::State::status "/dev/null";\nDebug::NoLocking "true";\n' + ) + for strict, expected_exit in ((True, 100), (False, 0)): + policy = POLICY.read_text() + if not strict: + policy = policy.replace('APT::Update::Error-Mode "any";', "") + config.write_text(prefix + policy) + result = subprocess.run( + ["apt-get", "update"], + env={**os.environ, "APT_CONFIG": str(config), "LC_ALL": "C"}, + capture_output=True, text=True, timeout=20, + ) + with self.subTest(strict=strict): + self.assertEqual(result.returncode, expected_exit, result.stderr) + self.assertIn("Failed to fetch http://127.0.0.1:", result.stderr) + self.assertIn("Connection refused", result.stdout + result.stderr) + + +if __name__ == "__main__": + unittest.main()