diff --git a/nix/modules/bridge.nix b/nix/modules/bridge.nix index be91738..07c00e3 100644 --- a/nix/modules/bridge.nix +++ b/nix/modules/bridge.nix @@ -40,7 +40,7 @@ let # admin-registered push optimization deduped against the poller (§4.1). Enrollment = add # the repo to POLL_REPOS (csv) + ensure tests// exists. - POLL_INTERVAL=30 - - POLL_REPOS=recipe-maintainers/cc-ci,recipe-maintainers/custom-html,recipe-maintainers/custom-html-tiny,recipe-maintainers/keycloak,recipe-maintainers/cryptpad,recipe-maintainers/matrix-synapse,recipe-maintainers/lasuite-docs,recipe-maintainers/lasuite-meet,recipe-maintainers/n8n,recipe-maintainers/hedgedoc,recipe-maintainers/uptime-kuma,recipe-maintainers/bluesky-pds,recipe-maintainers/discourse,recipe-maintainers/ghost,recipe-maintainers/immich,recipe-maintainers/lasuite-drive,recipe-maintainers/mailu,recipe-maintainers/mattermost-lts,recipe-maintainers/mumble,recipe-maintainers/plausible,recipe-maintainers/drone,recipe-maintainers/gitea + - POLL_REPOS=recipe-maintainers/cc-ci,recipe-maintainers/custom-html,recipe-maintainers/custom-html-tiny,recipe-maintainers/keycloak,recipe-maintainers/cryptpad,recipe-maintainers/matrix-synapse,recipe-maintainers/lasuite-docs,recipe-maintainers/lasuite-meet,recipe-maintainers/n8n,recipe-maintainers/hedgedoc,recipe-maintainers/uptime-kuma,recipe-maintainers/bluesky-pds,recipe-maintainers/discourse,recipe-maintainers/ghost,recipe-maintainers/immich,recipe-maintainers/lasuite-drive,recipe-maintainers/mailu,recipe-maintainers/mattermost-lts,recipe-maintainers/mumble,recipe-maintainers/plausible,recipe-maintainers/drone,recipe-maintainers/gitea,recipe-maintainers/wordpress - HMAC_FILE=/run/secrets/webhook_hmac - DRONE_TOKEN_FILE=/run/secrets/drone_token - GITEA_TOKEN_FILE=/run/secrets/gitea_token diff --git a/tests/wordpress/PARITY.md b/tests/wordpress/PARITY.md new file mode 100644 index 0000000..04e2810 --- /dev/null +++ b/tests/wordpress/PARITY.md @@ -0,0 +1,29 @@ +# Parity — wordpress + +The recipe-maintainer corpus has **no** `recipe-info/wordpress/tests/` directory — wordpress was +not in the recipe-maintainer parity suite. This PARITY.md documents the Phase-2-style +recipe-specific tests + health_check as the parity-aligned baseline (enrolled 2026-08-03 on +operator request). + +## Recipe-specific tests (≥2 beyond parity) + +wordpress is a classic PHP CMS on mariadb. A fresh CI deploy serves the install wizard (the CI +env sets no `POST_DEPLOY_CMDS core_install`); the custom tier completes the wizard itself with +run-scoped credentials (`custom/_wp.py`) and then exercises the installed site's real APIs. + +| cc-ci file | what's verified | rationale | +|---|---|---| +| `custom/test_health_check.py` | GET `/` → 200 or 302 (install-wizard redirect). | traefik → app wiring floor. | +| `custom/test_install_and_api.py` | Completes the install wizard (writes site options + admin user to mariadb), then asserts `/?rest_route=/` AND `/wp-json/` both return the configured site name. | Non-vacuous: the name only comes back if the install round-tripped through the DB. The two REST routes split failure layers: `?rest_route=` isolates "REST + DB", `/wp-json/` additionally proves the recipe's `.htaccess` rewrites are live. A DB-wiring failure is caught earlier by the installer's own "database connection" error, asserted in `_wp.ensure_installed`. | +| `custom/test_post_roundtrip.py` | §4.3 create-an-object + read-it-back: publish a post with a unique marker via **XML-RPC** `wp.newPost` (admin user/pass), read it back via the **public REST API** (`/wp/v2/posts/`, title must contain the marker), then fetch the public permalink `/?p=` and assert the marker in the served HTML. | The marker round-trips app → mariadb → app across three distinct subsystems (XML-RPC write, REST read, themed HTML render). A post that didn't persist, a broken DB, or a wedged PHP fails at the layer that broke. | + +## Backup data-integrity (P4) + +The recipe stores content in the `wordpress_content` volume + mariadb; backup-capable detection is +automatic (compose.yml `backupbot.backup` labels via the standard recipe mechanism). Lifecycle +overlays not yet authored — catch-up if backup data-integrity proves needed for this recipe. + +## Playwright (P6) + +Not authored. The install + post round-trip is API-driven; a Playwright pass over wp-admin would +add browser coverage of the dashboard. Follow-up if wanted. diff --git a/tests/wordpress/custom/_wp.py b/tests/wordpress/custom/_wp.py new file mode 100644 index 0000000..a9d3c92 --- /dev/null +++ b/tests/wordpress/custom/_wp.py @@ -0,0 +1,85 @@ +"""Shared wordpress test helper — install-wizard client + admin credentials. + +A fresh CI deploy of the recipe does NOT run `core_install` (no POST_DEPLOY_CMDS in the CI +env), so the app serves the WP install wizard until something completes it. The custom tests +complete it here (run-scoped class-B credentials; the whole app — DB volume + secrets — is +destroyed at teardown) and then exercise the real APIs (wp-json + XML-RPC) as an installed +site. `ensure_installed` is idempotent so any custom test can call it first regardless of +alphabetical ordering. +""" + +from __future__ import annotations + +import ssl +import urllib.error +import urllib.parse +import urllib.request + +# Per-run *.ci.commoninternet.net domains use the operator wildcard cert via the Traefik file +# provider; the real-cert chain check is done once in the generic install assertion. +_CTX = ssl.create_default_context() +_CTX.check_hostname = False +_CTX.verify_mode = ssl.CERT_NONE + +ADMIN_USER = "ccci-admin" +ADMIN_PW = "Ccci-Wp-Test-Pw-2026!x" # strong so the wizard needs no pw_weak confirmation +ADMIN_EMAIL = "ccci-admin@ccci.example.com" +BLOG_TITLE = "CCCI Test Site" + + +def _open(url: str, data: bytes | None = None, timeout: int = 60) -> tuple[int, str]: + req = urllib.request.Request(url, data=data) + if data is not None: + req.add_header("Content-Type", "application/x-www-form-urlencoded") + try: + with urllib.request.urlopen(req, timeout=timeout, context=_CTX) as resp: + return resp.getcode(), resp.read().decode(errors="replace") + except urllib.error.HTTPError as e: + try: + return e.code, e.read().decode(errors="replace") + except Exception: # noqa: BLE001 + return e.code, "" + + +def fetch_text(url: str, timeout: int = 60) -> tuple[int, str]: + """GET a URL and return (status, body-text) — for HTML-content assertions.""" + return _open(url, timeout=timeout) + + +def ensure_installed(domain: str) -> bool: + """Complete the WP install wizard if it hasn't been completed yet. + + Returns True if THIS call ran the install, False if the site was already installed. + Fails the calling test (assert) if the wizard is reachable but the install POST fails. + """ + base = f"https://{domain}" + status, body = _open(f"{base}/wp-admin/install.php") + assert status == 200, f"GET /wp-admin/install.php HTTP {status} (body[:200]={body[:200]!r})" + if "already installed" in body.lower(): + return False + assert "wordpress" in body.lower(), ( + f"/wp-admin/install.php does not look like the WP installer: {body[:300]!r}" + ) + assert "database connection" not in body.lower(), ( + "WP installer reports a database connection problem — app→mariadb wiring is broken" + ) + + form = urllib.parse.urlencode( + { + "weblog_title": BLOG_TITLE, + "user_name": ADMIN_USER, + "admin_password": ADMIN_PW, + "admin_password2": ADMIN_PW, + "admin_email": ADMIN_EMAIL, + "blog_public": "1", + "Submit": "Install WordPress", + "language": "", + } + ).encode() + status, body = _open(f"{base}/wp-admin/install.php?step=2", data=form, timeout=180) + assert status == 200, f"install POST HTTP {status} (body[:300]={body[:300]!r})" + lowered = body.lower() + assert "success" in lowered or "wordpress has been installed" in lowered, ( + f"install POST did not report success: {body[:400]!r}" + ) + return True diff --git a/tests/wordpress/custom/test_health_check.py b/tests/wordpress/custom/test_health_check.py new file mode 100644 index 0000000..4e2433c --- /dev/null +++ b/tests/wordpress/custom/test_health_check.py @@ -0,0 +1,21 @@ +"""wordpress — Phase-2 health_check (no recipe-maintainer parity corpus for wordpress). + +Asserts the served root responds: 200 (installed site) or 302 (redirect to the install +wizard on a fresh deploy). Either proves traefik → app wiring; the deeper DB/API proofs +live in test_install_and_api / test_post_roundtrip. +""" + +from __future__ import annotations + +import os +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", "runner")) +from harness import http as harness_http # noqa: E402 + + +def test_wordpress_root_serves(live_app): + """GET / → 200 or 302 (install-wizard redirect on a fresh deploy).""" + url = f"https://{live_app}/" + status, _ = harness_http.retry_http_get(url, expect_status=(200, 302), max_wait=90, interval=5) + assert status in (200, 302), f"GET {url} HTTP {status} (expected 200 or 302)" diff --git a/tests/wordpress/custom/test_install_and_api.py b/tests/wordpress/custom/test_install_and_api.py new file mode 100644 index 0000000..240afe1 --- /dev/null +++ b/tests/wordpress/custom/test_install_and_api.py @@ -0,0 +1,41 @@ +"""wordpress — complete the install wizard, then read the site back via the REST API. + +Non-vacuous: the install POST writes the site options + admin user to mariadb through the +app's DB wiring; `/wp-json/` only returns the site name after WP can read those options back +from the DB, and the pretty-permalink REST route additionally proves the recipe's .htaccess +rewrites are live. A wedged DB fails the install; a missing htaccess breaks /wp-json/ (the +`?rest_route=` fallback is asserted separately so the failure names the broken layer). +""" + +from __future__ import annotations + +import os +import sys + +sys.path.insert(0, os.path.dirname(__file__)) +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", "runner")) +from _wp import BLOG_TITLE, ensure_installed # noqa: E402 +from harness import http as harness_http # noqa: E402 + + +def test_install_and_rest_api_roundtrip(live_app): + ensure_installed(live_app) + + # ?rest_route= works regardless of rewrites — isolates "REST API up + DB readable" + status, body = harness_http.http_get(f"https://{live_app}/?rest_route=/", timeout=60) + assert status == 200, f"GET /?rest_route=/ HTTP {status}" + assert isinstance(body, dict), f"REST index is not JSON: {body!r}" + assert body.get("name") == BLOG_TITLE, ( + f"site name mismatch: got {body.get('name')!r}, expected {BLOG_TITLE!r} — " + "install options did not round-trip through the DB" + ) + + # /wp-json/ additionally requires the recipe's .htaccess rewrite rules + status, body = harness_http.http_get(f"https://{live_app}/wp-json/", timeout=60) + assert status == 200, ( + f"GET /wp-json/ HTTP {status} — REST works via ?rest_route= but the pretty route " + "fails: the recipe's .htaccess rewrites are not active" + ) + assert isinstance(body, dict) and body.get("name") == BLOG_TITLE, ( + f"unexpected /wp-json/ payload: {body!r}" + ) diff --git a/tests/wordpress/custom/test_post_roundtrip.py b/tests/wordpress/custom/test_post_roundtrip.py new file mode 100644 index 0000000..010c145 --- /dev/null +++ b/tests/wordpress/custom/test_post_roundtrip.py @@ -0,0 +1,65 @@ +"""wordpress — §4.3 create-an-object + read-it-back: publish a post, read it back twice. + +Flow: +1. `ensure_installed` (idempotent — run-scoped admin credentials from _wp.py). +2. Create a post with a unique marker title via **XML-RPC** `wp.newPost` (stdlib + xmlrpc.client; XML-RPC ships enabled in WP and authenticates with the admin + user/password directly — no cookie/nonce dance). +3. Read it back via the **public REST API** (`?rest_route=/wp/v2/posts/`) — a different + subsystem than the one that wrote it — asserting id + rendered title match. +4. Fetch the public permalink `/?p=` and assert the marker is in the served HTML. + +Non-vacuous: the marker round-trips app → mariadb → app across three distinct read paths; +a post that didn't persist, a broken DB, or a wedged PHP-FPM fails at the layer that broke. +""" + +from __future__ import annotations + +import os +import ssl +import sys +import uuid +import xmlrpc.client + +sys.path.insert(0, os.path.dirname(__file__)) +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", "runner")) +from _wp import ADMIN_PW, ADMIN_USER, ensure_installed, fetch_text # noqa: E402 +from harness import http as harness_http # noqa: E402 + +_CTX = ssl.create_default_context() +_CTX.check_hostname = False +_CTX.verify_mode = ssl.CERT_NONE + + +def test_create_post_and_read_back(live_app): + ensure_installed(live_app) + + marker = f"ccci-post-{uuid.uuid4().hex[:8]}" + proxy = xmlrpc.client.ServerProxy(f"https://{live_app}/xmlrpc.php", context=_CTX) + post_id = proxy.wp.newPost( + 0, + ADMIN_USER, + ADMIN_PW, + { + "post_title": marker, + "post_content": f"cc-ci round-trip body for {marker}", + "post_status": "publish", + }, + ) + assert post_id and str(post_id).isdigit(), f"wp.newPost returned no post id: {post_id!r}" + + # Read back via the public REST API (different subsystem than XML-RPC) + status, body = harness_http.http_get( + f"https://{live_app}/?rest_route=/wp/v2/posts/{post_id}", timeout=60 + ) + assert status == 200, f"GET rest_route /wp/v2/posts/{post_id} HTTP {status}: {body!r}" + assert isinstance(body, dict) and str(body.get("id")) == str(post_id), ( + f"read-back id mismatch: {body!r}" + ) + rendered = (body.get("title") or {}).get("rendered", "") + assert marker in rendered, f"read-back title {rendered!r} missing marker {marker!r}" + + # And the public permalink serves the marker in HTML + status, raw = fetch_text(f"https://{live_app}/?p={post_id}") + assert status == 200, f"GET /?p={post_id} HTTP {status}" + assert marker in raw, f"permalink page does not contain the marker {marker!r}" diff --git a/tests/wordpress/recipe_meta.py b/tests/wordpress/recipe_meta.py new file mode 100644 index 0000000..226c04d --- /dev/null +++ b/tests/wordpress/recipe_meta.py @@ -0,0 +1,12 @@ +# Per-recipe harness config for wordpress (classic PHP app + mariadb). A fresh deploy +# (no POST_DEPLOY_CMDS core_install in the CI env) serves the WP install wizard: GET / +# redirects 302 to /wp-admin/install.php until the custom tier completes the install. +HEALTH_PATH = "/" +HEALTH_OK = (200, 302) +# First boot copies the WP core into the content volume and waits for mariadb init; +# the recipe's own healthcheck has start_period 1m — give the deploy headroom. +DEPLOY_TIMEOUT = 900 +HTTP_TIMEOUT = 300 + +# canon §2.B: enroll as a DATA-WARM canonical (all recipes enrolled — operator 2026-06-17). +WARM_CANONICAL = True