Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=/
Expand Down
12 changes: 11 additions & 1 deletion backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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. "
Expand Down Expand Up @@ -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,
}


# =========================================================================
Expand Down
1 change: 1 addition & 0 deletions backend/tests/test_endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
13 changes: 13 additions & 0 deletions backend/tests/test_frontend_configuration.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
7 changes: 7 additions & 0 deletions docs/public/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id>` before the
integrated manual acceptance round.
4 changes: 4 additions & 0 deletions frontend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=/
8 changes: 7 additions & 1 deletion frontend/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<html lang="en">
<html lang="en" data-calorieapp-build-id={buildId}>
<body>{children}</body>
</html>
);
Expand Down
11 changes: 10 additions & 1 deletion tools/build_wordpress_plugin_release.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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


Expand Down
25 changes: 24 additions & 1 deletion tools/deployment_smoke_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

import argparse
import json
import re
import sys
from urllib.error import HTTPError, URLError
from urllib.parse import urlsplit
Expand All @@ -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
Expand All @@ -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",
Expand All @@ -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
Expand Down
26 changes: 23 additions & 3 deletions tools/tests/calorieapp_embed_readiness.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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",
Expand Down
11 changes: 11 additions & 0 deletions tools/tests/test_build_wordpress_plugin_release.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down