diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index c187491..aadd78e 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -45,6 +45,10 @@ jobs: API_PORT: "28000" STORAGE_PORT: "29000" STORAGE_CONSOLE_PORT: "29001" + # Presigned URLs are signed against this endpoint, so it must be reachable + # by whoever follows them. The suites run inside the network, so it has to + # be the service name, not a published port on the host. + S3_PUBLIC_ENDPOINT: http://storage:9000 COMPOSE: docker compose -f compose.local.yaml steps: - name: Checkout @@ -60,12 +64,20 @@ jobs: $COMPOSE up --build -d --wait --wait-timeout 600 $COMPOSE ps + # Run inside the stack's own network. The runner is itself a container, so + # ports published on the host's loopback are in a different namespace and + # unreachable from here. SITE_HOST_HEADER keeps the Host the gateway and + # TrustedHostMiddleware expect, so the configuration under test is the same + # one a developer exercises on localhost. - name: API and workflow regressions run: | - python3 -m local.smoke_test - python3 -m local.workflow_test - python3 -m local.security_test - python3 -m local.scanning_test + for suite in smoke_test workflow_test security_test scanning_test; do + echo "--- $suite" + $COMPOSE exec -T \ + -e SITE_BASE_URL=http://site \ + -e SITE_HOST_HEADER=localhost \ + api python -m "local.$suite" + done - name: Runtime and retention regressions run: | @@ -90,6 +102,14 @@ jobs: echo "Install google-chrome-stable or set CHROME_BIN to gate on them." exit 0 fi + # Chrome runs here, in the runner container, and reaches the stack only + # through ports published on the host. When the runner is itself a + # container those are in another namespace, so check before running + # rather than failing with a bare connection error. See ROADMAP 5.10. + if ! wget -q -T 5 -O /dev/null "http://localhost:${SITE_PORT}/health"; then + echo "::warning::Stack not reachable from the runner; browser regressions were NOT run." + exit 0 + fi echo "Using $CHROME_BIN" node local/artwork_browser_test.mjs node local/browser_test.mjs diff --git a/ROADMAP.md b/ROADMAP.md index e001393..3faa244 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -364,6 +364,14 @@ charges. Fix as part of 1.1. table and the packer so 3.2 has somewhere to land. - `[ ]` 5.7 — Commercial rules duplicated between `FAIXAS` (JS) and `TIERS` (Python) `(F26)`. `test_pricing` guards parity; generate one from the other instead. +- `[ ]` 5.10 — The browser suites do not run in CI. Chrome runs in the runner + container and can only reach the stack through ports published on the host, which + is a different network namespace when the runner is itself a container. The API, + workflow, security, scanning, retention and runtime suites were moved inside the + stack's network and do gate. The browser suites are the only coverage for the + artwork editor and the full customer journey, so they need either Chrome in a + container on that network, or a runner with host networking. Until then they gate + locally only, and CI warns when it skips them. - `[ ]` 5.9 — `local/browser_test.mjs` failed once and passed on an immediate re-run, with no code change in between (2026-09-21). It is a deploy gate when the runner has Chrome, so an intermittent failure there blocks releases for no reason. diff --git a/local/security_test.py b/local/security_test.py index 0ffa0ab..a4e5377 100644 --- a/local/security_test.py +++ b/local/security_test.py @@ -5,10 +5,10 @@ from urllib.error import HTTPError from urllib.request import Request, urlopen from urllib.parse import urlparse, parse_qs from uuid import uuid4 -from .smoke_test import Client, BASE +from .smoke_test import Client, BASE, with_host def raw(path, expected, headers=None, body=None): - request=Request(BASE+path, data=body, headers=headers or {}) + request=Request(BASE+path, data=body, headers=with_host(headers)) try: with urlopen(request,timeout=10) as response: assert response.status==expected diff --git a/local/smoke_test.py b/local/smoke_test.py index fbc4eef..158cf47 100644 --- a/local/smoke_test.py +++ b/local/smoke_test.py @@ -18,7 +18,19 @@ if Path('.env').exists(): if line.strip() and not line.startswith('#') and '=' in line: key,value=line.split('=',1) os.environ.setdefault(key,value) -BASE='http://localhost:'+os.environ.get('SITE_PORT','8080') +# CI runs these from a container on the stack's own network, because a runner +# container cannot reach ports published on the host's loopback. SITE_BASE_URL +# points at the gateway by service name; SITE_HOST_HEADER keeps the Host the +# gateway and TrustedHostMiddleware expect, so the security configuration under +# test stays identical to a developer's localhost run. +BASE=os.environ.get('SITE_BASE_URL') or 'http://localhost:'+os.environ.get('SITE_PORT','8080') +HOST_HEADER=os.environ.get('SITE_HOST_HEADER') + +def with_host(headers=None): + headers=dict(headers or {}) + if HOST_HEADER and not any(k.lower()=='host' for k in headers): + headers['Host']=HOST_HEADER + return headers class Client: def __init__(self): @@ -26,7 +38,7 @@ class Client: self.opener=build_opener(HTTPCookieProcessor(self.jar)) self.operator_client=None def call(self,path,body=None,operator=False,expected=200): - headers={'Content-Type':'application/json'} + headers=with_host({'Content-Type':'application/json'}) if operator: if self.operator_client is None: self.operator_client=Client()