enroll(wordpress): test suite + bridge POLL_REPOS entry
Some checks failed
continuous-integration/drone/push Build is failing

Enrolls wordpress as a maintained recipe (operator request 2026-08-03):
- tests/wordpress/: recipe_meta (install-wizard-aware health 200/302, 900s deploy
  timeout for mariadb+core-copy first boot, WARM_CANONICAL), custom suite:
  health check, install-wizard completion + REST API round-trip (?rest_route= vs
  /wp-json/ splits DB vs .htaccess failure layers), and the sec4.3 post round-trip
  (XML-RPC write -> REST read -> permalink HTML, unique marker). PARITY.md documents
  the baseline (no recipe-maintainer parity corpus for wordpress).
- nix/modules/bridge.nix: POLL_REPOS += recipe-maintainers/wordpress (!testme bridge
  enrollment; deploy to the cc-ci host follows separately after the in-flight
  /upgrade-all run - test-before-switch policy).

Mirror recipe-maintainers/wordpress created + main synced to coopcloud upstream
(adcd0e9f) with published tags. used-recipes.md gains 'wordpress weekly' in the
orchestrator repo.
This commit is contained in:
2026-08-03 21:05:33 +00:00
parent 04ae8f55c8
commit d9a446cd36
7 changed files with 254 additions and 1 deletions

View File

@ -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/<recipe>/ 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

29
tests/wordpress/PARITY.md Normal file
View File

@ -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/<id>`, title must contain the marker), then fetch the public permalink `/?p=<id>` 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.

View File

@ -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

View File

@ -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)"

View File

@ -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}"
)

View File

@ -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/<id>`) — a different
subsystem than the one that wrote it — asserting id + rendered title match.
4. Fetch the public permalink `/?p=<id>` 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}"

View File

@ -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