diff --git a/backend/.env.example b/backend/.env.example index 6db6f52..d81339b 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -54,6 +54,10 @@ CALORIEAPP_CLIENT_ID=calorieapp-backend # Allowed local-only value: local CALORIEAPP_ENV=local +# Non-secret source/build identifier returned by GET /health. Set both backend +# and frontend identifiers to the same release commit or manifest id. +CALORIEAPP_BUILD_ID=development + # Where the frontend should land after successful callback finalization. # Must be a local app path beginning with one slash; external or scheme-relative URLs are rejected. CALORIEAPP_POST_LOGIN_REDIRECT=/ diff --git a/backend/app/main.py b/backend/app/main.py index 90cd623..6509453 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -83,6 +83,7 @@ _SESSION_COOKIE_SAMESITE = os.getenv("SESSION_COOKIE_SAMESITE", "lax").strip().lower() _CALORIEAPP_ENV_RAW = os.getenv("CALORIEAPP_ENV") _CALORIEAPP_ENV = _CALORIEAPP_ENV_RAW.strip().lower() if _CALORIEAPP_ENV_RAW and _CALORIEAPP_ENV_RAW.strip() else None +_CALORIEAPP_BUILD_ID = os.getenv("CALORIEAPP_BUILD_ID", "development").strip() _BRIDGE_AUTH_MAX_AGE_SECONDS = int(os.getenv("BRIDGE_AUTH_MAX_AGE_SECONDS", "300")) _BRIDGE_AUTH_MAX_FUTURE_SECONDS = int(os.getenv("BRIDGE_AUTH_MAX_FUTURE_SECONDS", "30")) _BRIDGE_NONCE_RETENTION_SECONDS = int( @@ -93,6 +94,11 @@ ) _IDENTITY_PROVIDER = "wordpress_xumm" +if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]{0,63}", _CALORIEAPP_BUILD_ID): + raise RuntimeError( + "CALORIEAPP_BUILD_ID must be 1-64 letters, digits, dots, underscores or hyphens" + ) + if not _WORDPRESS_BRIDGE_SECRET: logger.warning( "WORDPRESS_BRIDGE_SECRET not set. " @@ -674,7 +680,11 @@ def _exchange_code_for_claims(code: str, state: str) -> IdentityClaimsResponse: @app.get("/health") def health() -> dict[str, str]: - return {"status": "ok", "service": "calorieapp-backend"} + return { + "status": "ok", + "service": "calorieapp-backend", + "build_id": _CALORIEAPP_BUILD_ID, + } # ========================================================================= diff --git a/backend/tests/test_endpoints.py b/backend/tests/test_endpoints.py index 2bac8cd..7bf1192 100644 --- a/backend/tests/test_endpoints.py +++ b/backend/tests/test_endpoints.py @@ -56,6 +56,7 @@ def test_health_response_schema(client: TestClient) -> None: data = client.get("/health").json() assert data["status"] == "ok" assert data["service"] == "calorieapp-backend" + assert data["build_id"] == "development" def test_health_is_not_marked_as_private_session_data(client: TestClient) -> None: diff --git a/backend/tests/test_frontend_configuration.py b/backend/tests/test_frontend_configuration.py index 19ae302..a1e5ce7 100644 --- a/backend/tests/test_frontend_configuration.py +++ b/backend/tests/test_frontend_configuration.py @@ -17,3 +17,16 @@ def test_frontend_uses_canonical_wordpress_calorieapp_route(): for source in (panel_source, env_example): assert CANONICAL_WORDPRESS_APP_URL in source assert NON_CANONICAL_WORDPRESS_APP_URL not in source + + +def test_frontend_exposes_a_non_secret_build_identifier(): + layout_source = (REPO_ROOT / "frontend" / "app" / "layout.tsx").read_text( + encoding="utf-8" + ) + env_example = (REPO_ROOT / "frontend" / ".env.example").read_text( + encoding="utf-8" + ) + + assert "NEXT_PUBLIC_CALORIEAPP_BUILD_ID" in layout_source + assert "data-calorieapp-build-id" in layout_source + assert "NEXT_PUBLIC_CALORIEAPP_BUILD_ID=development" in env_example diff --git a/docs/public/deployment.md b/docs/public/deployment.md index 4fefa0c..8fb0dbf 100644 --- a/docs/public/deployment.md +++ b/docs/public/deployment.md @@ -3,3 +3,10 @@ Deploy the frontend and backend only after the repository tests and release checks pass. Production environments require HTTPS, explicit origins and redirects, securely managed environment values, protected databases, restricted administrative access, monitoring and a tested rollback process. Never commit production credentials, database contents, cookies, authorization codes or deployment-specific private configuration. Provider-specific procedures and live endpoints are maintained privately. + +Set `CALORIEAPP_BUILD_ID` on the backend and +`NEXT_PUBLIC_CALORIEAPP_BUILD_ID` on the frontend to the same non-secret release +commit or manifest identifier. The backend exposes it through `/health`; the +frontend renders it as `data-calorieapp-build-id` on the HTML root. Verify both +with `tools/deployment_smoke_test.py --expected-build-id ` before the +integrated manual acceptance round. diff --git a/frontend/.env.example b/frontend/.env.example index 19ac54b..c688628 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -13,6 +13,10 @@ NEXT_PUBLIC_BACKEND_WAKE_URL=http://localhost:8000 # [calorieapp_embed] page. Keep this on the canonical CalorieToken site. NEXT_PUBLIC_WORDPRESS_APP_URL=https://calorietoken.net/index.php/calorieapp/ +# Non-secret source/build identifier exposed in the rendered HTML so an +# automated smoke test can prove which frontend release is live. +NEXT_PUBLIC_CALORIEAPP_BUILD_ID=development + # Optional: frontend-only post-login fallback route if you need custom UX. # Keep this app-local (starts with /) and do not put secrets here. # NEXT_PUBLIC_POST_LOGIN_FALLBACK=/ diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx index 576dad5..d0b2f19 100644 --- a/frontend/app/layout.tsx +++ b/frontend/app/layout.tsx @@ -6,13 +6,19 @@ export const metadata: Metadata = { description: "Non-financial food and nutrition tracking MVP", }; +const configuredBuildId = process.env.NEXT_PUBLIC_CALORIEAPP_BUILD_ID?.trim(); +const buildId = + configuredBuildId && /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(configuredBuildId) + ? configuredBuildId + : "development"; + export default function RootLayout({ children, }: Readonly<{ children: React.ReactNode; }>) { return ( - + {children} ); diff --git a/tools/build_wordpress_plugin_release.py b/tools/build_wordpress_plugin_release.py index 6e7a845..17cfb0f 100644 --- a/tools/build_wordpress_plugin_release.py +++ b/tools/build_wordpress_plugin_release.py @@ -160,6 +160,15 @@ def verify_archive(archive: Path, expected_files: list[Path]) -> None: raise ValueError(f"Corrupt archive member: {bad_member}") +def display_path(artifact: Path) -> Path: + """Return a stable CLI path for artifacts inside or outside the repository.""" + resolved = artifact.resolve() + try: + return resolved.relative_to(ROOT) + except ValueError: + return resolved + + def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--output-dir", type=Path, default=ROOT / "dist") @@ -171,7 +180,7 @@ def main() -> int: print(f"release build failed: {exc}", file=sys.stderr) return 1 for artifact in artifacts: - print(artifact.relative_to(ROOT)) + print(display_path(artifact)) return 0 diff --git a/tools/deployment_smoke_test.py b/tools/deployment_smoke_test.py index 1ddd5a4..c886bf6 100644 --- a/tools/deployment_smoke_test.py +++ b/tools/deployment_smoke_test.py @@ -5,6 +5,7 @@ import argparse import json +import re import sys from urllib.error import HTTPError, URLError from urllib.parse import urlsplit @@ -20,6 +21,15 @@ def origin(value: str) -> str: return f"https://{parsed.netloc}" +def build_identifier(value: str) -> str: + candidate = value.strip() + if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]{0,63}", candidate): + raise argparse.ArgumentTypeError( + "build id must be 1-64 letters, digits, dots, underscores or hyphens" + ) + return candidate + + def request(url: str, *, method: str = "GET", headers: dict[str, str] | None = None): req = Request(url, method=method, headers=headers or {}) with urlopen(req, timeout=30) as response: # noqa: S310 - validated HTTPS origins only @@ -30,13 +40,17 @@ def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("--backend-url", required=True, type=origin) parser.add_argument("--frontend-url", required=True, type=origin) + parser.add_argument("--expected-build-id", type=build_identifier) args = parser.parse_args() checks: list[tuple[str, bool, str]] = [] try: status, _, body = request(f"{args.backend_url}/health") payload = json.loads(body) - checks.append(("backend health", status == 200 and payload.get("status") == "ok", str(payload))) + backend_ready = status == 200 and payload.get("status") == "ok" + if args.expected_build_id: + backend_ready = backend_ready and payload.get("build_id") == args.expected_build_id + checks.append(("backend health", backend_ready, str(payload))) status, headers, _ = request( f"{args.backend_url}/health", @@ -53,6 +67,15 @@ def main() -> int: status, _, body = request(args.frontend_url) html = body.decode("utf-8", errors="replace") checks.append(("frontend page", status == 200 and "Calorie" in html, f"HTTP {status}, {len(body)} bytes")) + if args.expected_build_id: + build_attribute = f'data-calorieapp-build-id="{args.expected_build_id}"' + checks.append( + ( + "frontend build id", + build_attribute in html, + args.expected_build_id, + ) + ) except (HTTPError, URLError, TimeoutError, json.JSONDecodeError) as exc: print(f"[FAIL] deployment request failed: {exc}", file=sys.stderr) return 1 diff --git a/tools/tests/calorieapp_embed_readiness.test.mjs b/tools/tests/calorieapp_embed_readiness.test.mjs index 628c262..2fe1e75 100644 --- a/tools/tests/calorieapp_embed_readiness.test.mjs +++ b/tools/tests/calorieapp_embed_readiness.test.mjs @@ -32,10 +32,11 @@ function element(hidden = true) { }; } -test("Xaman remains hidden until the CalorieApp state is ready", async () => { +test("Xaman waits for CalorieApp readiness and completion closes the dialog", async () => { const source = await readFile(SCRIPT_PATH, "utf8"); const appOrigin = "https://calorieapp-frontend.onrender.com"; const windowListeners = {}; + const scheduledTimeouts = []; const iframeWindow = { postMessage() {} }; const iframe = element(false); iframe.contentWindow = iframeWindow; @@ -79,8 +80,9 @@ test("Xaman remains hidden until the CalorieApp state is ready", async () => { windowListeners[type] = listener; }, clearTimeout() {}, - setTimeout() { - return 1; + setTimeout(callback, delay) { + scheduledTimeouts.push({ callback, delay }); + return scheduledTimeouts.length; }, }; let websocketCount = 0; @@ -159,6 +161,7 @@ test("Xaman remains hidden until the CalorieApp state is ready", async () => { }); await new Promise((resolve) => setImmediate(resolve)); + assert.equal(modal.hidden, false); assert.equal(openLink.hidden, true); assert.equal(qrImage.hidden, true); assert.equal(websocketCount, 0); @@ -205,6 +208,23 @@ test("Xaman remains hidden until the CalorieApp state is ready", async () => { locale: "nl", }); + windowListeners.message({ + data: { + type: "calorieapp:login:complete", + requestId, + locale: "nl", + }, + origin: appOrigin, + source: iframeWindow, + }); + assert.equal(modal.hidden, false); + assert.match(status.textContent, /Signed in to WordPress and CalorieApp/); + + const closeDialog = scheduledTimeouts.find(({ delay }) => delay === 1400); + assert.ok(closeDialog, "successful joint sign-in schedules the dialog close"); + closeDialog.callback(); + assert.equal(modal.hidden, true); + windowListeners.message({ data: { type: "calorieapp:login:backend-error", diff --git a/tools/tests/test_build_wordpress_plugin_release.py b/tools/tests/test_build_wordpress_plugin_release.py index f9e4eba..a051eed 100644 --- a/tools/tests/test_build_wordpress_plugin_release.py +++ b/tools/tests/test_build_wordpress_plugin_release.py @@ -44,6 +44,17 @@ def test_expected_version_must_match(self) -> None: with self.assertRaisesRegex(ValueError, "plugin header declares"): release.build(Path(output), "9.9.9") + def test_artifact_display_path_supports_internal_and_external_outputs(self) -> None: + internal = release.ROOT / "dist" / "identity-bridge.zip" + self.assertEqual( + release.display_path(internal), + Path("dist/identity-bridge.zip"), + ) + + with tempfile.TemporaryDirectory() as output: + external = Path(output) / "identity-bridge.zip" + self.assertEqual(release.display_path(external), external.resolve()) + def test_embed_waits_for_calorieapp_state_before_exposing_xaman(self) -> None: source = ( release.PLUGIN_DIR / "assets" / "calorieapp-embed.js"