Compare commits

...

4 Commits

Author SHA1 Message Date
d9a446cd36 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.
2026-08-03 21:05:33 +00:00
04ae8f55c8 Merge pull request 'test(lasuite-meet): update stale meeting-flow test for meet v1.22.0+ API auth hardening' (#13) from test/lasuite-meet-stale-test-20260803 into main
Some checks failed
continuous-integration/drone/push Build is failing
continuous-integration/drone Build is failing
2026-08-03 20:50:37 +00:00
304b1610b5 Merge pull request 'test(lasuite-docs): update stale OIDC tests for impress v5.4.0 Bearer-auth removal' (#12) from test/lasuite-docs-stale-test-20260803 into main
Some checks failed
continuous-integration/drone/push Build is failing
2026-08-03 20:47:50 +00:00
a1a6790c9b test(lasuite-docs): update stale OIDC tests for impress v5.4.0 Bearer-auth removal
Some checks are pending
continuous-integration/drone/push Build is running
test_oidc_login_via_keycloak and test_create_doc_and_read_back authenticated with
'Authorization: Bearer <password-grant JWT>'; impress v5.4.0 removed Bearer/JWT
auth on the API (SessionAuthentication only), so both went RED with 401 on the
v5.4.1 upgrade.

Updated to the successor auth path: a new recipe-local _oidc_session.py drives the
real OIDC authorization-code flow (app -> keycloak login form -> callback ->
Django session cookie, with CSRF headers on unsafe methods).
- test_oidc_login: still asserts the unauth challenge redirect; NOW also asserts a
  raw Bearer JWT is REJECTED (401/403 - the v5.4.0 hardening, asserted as the new
  correct behavior); then asserts the session-authenticated whoami returns the
  provisioned user. No assertion weakened - the auth proof is stronger than before.
- test_create_doc: same create+read-back round-trip assertions, now over the
  session-authenticated API.

Stale-test fix for recipe PR
recipe-maintainers/lasuite-docs#7
(carry-over from /upgrade-all 2026-07-24).
2026-08-03 20:43:07 +00:00
10 changed files with 459 additions and 49 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

View File

@ -0,0 +1,158 @@
"""Recipe-local OIDC *session* login helper (authorization-code flow + session cookie).
impress v5.4.0 removed Bearer-token (JWT) authentication on the API — the app now accepts only
its own session cookie, established through the standard OIDC authorization-code browser flow
(app login URL → keycloak login form → callback → Django session). This helper drives that flow
with urllib + a CookieJar so the custom tests can exercise the API the way a real client does.
Kept recipe-local (cf. tests/ghost/custom/_ghost.py precedent) rather than in runner/harness —
promote it there if a third recipe needs it.
Usage:
sess = OidcSession(f"https://{live_app}")
me = sess.login(kc["user"], kc["password"]) # asserts whoami 200; returns the user dict
status, body = sess.post("/api/v1.0/documents/", {"title": "x"}) # CSRF handled
"""
from __future__ import annotations
import contextlib
import html
import http.cookiejar
import json
import re
import ssl
import urllib.error
import urllib.parse
import urllib.request
# Per-run *.ci.commoninternet.net domains serve the operator's wildcard cert via the Traefik file
# provider; chain verification is done once in the install tier (generic.served_cert).
_CTX = ssl.create_default_context()
_CTX.check_hostname = False
_CTX.verify_mode = ssl.CERT_NONE
_LOGIN_PATHS = ("/api/v1.0/authenticate/", "/oidc/authenticate/", "/api/v1.0/users/me/")
_WHOAMI = "/api/v1.0/users/me/"
class OidcSession:
"""A cookie-carrying HTTP session logged in via the app's OIDC authorization-code flow."""
def __init__(self, base: str):
self.base = base.rstrip("/")
self.jar = http.cookiejar.CookieJar()
self.opener = urllib.request.build_opener(
urllib.request.HTTPCookieProcessor(self.jar),
urllib.request.HTTPSHandler(context=_CTX),
)
# -- low-level ---------------------------------------------------------------------------
def _open(
self,
url: str,
data: bytes | None = None,
headers: dict[str, str] | None = None,
method: str | None = None,
timeout: int = 30,
) -> tuple[int, str, bytes]:
"""Open a URL (following redirects, carrying cookies). Returns (status, final_url, body)."""
req = urllib.request.Request(url, data=data, method=method)
for k, v in (headers or {}).items():
req.add_header(k, v)
try:
with self.opener.open(req, timeout=timeout) as resp:
return resp.getcode(), resp.geturl(), resp.read()
except urllib.error.HTTPError as e:
body = b""
with contextlib.suppress(Exception):
body = e.read()
return e.code, e.filename or url, body
def _csrf_token(self) -> str | None:
for c in self.jar:
if "csrftoken" in c.name.lower():
return c.value
return None
# -- login -------------------------------------------------------------------------------
def login(
self,
username: str,
password: str,
login_paths: tuple[str, ...] = _LOGIN_PATHS,
whoami: str = _WHOAMI,
) -> dict:
"""OIDC authorization-code login: app → keycloak form → callback → session cookie.
Asserts the resulting session GETs `whoami` with HTTP 200 and returns the parsed user.
"""
page, page_url, last = None, None, (0, "", b"")
for path in login_paths:
status, final_url, body = self._open(self.base + path)
last = (status, final_url, body)
text = body.decode(errors="replace")
if "kc-form-login" in text or (
"/protocol/openid-connect/" in final_url and "<form" in text
):
page, page_url = text, final_url
break
assert page is not None, (
f"could not reach the keycloak login form via {login_paths}: last URL "
f"{last[1]!r} HTTP {last[0]} body[:200]={last[2][:200]!r}"
)
m = re.search(r'<form[^>]*id="kc-form-login"[^>]*action="([^"]+)"', page) or re.search(
r'<form[^>]*action="([^"]+)"[^>]*method=["\']?post', page, re.I
)
assert m, f"no login form action on keycloak page {page_url!r}: {page[:300]!r}"
action = html.unescape(m.group(1))
form = urllib.parse.urlencode(
{"username": username, "password": password, "credentialId": ""}
).encode()
status, landed, body = self._open(
action, data=form, headers={"Content-Type": "application/x-www-form-urlencoded"}
)
status, _, who = self._open(self.base + whoami)
assert status == 200, (
f"OIDC session login failed: GET {whoami} -> HTTP {status} after submitting the "
f"keycloak form (landed at {landed!r}; excerpt: {body[:200]!r})"
)
parsed = json.loads(who)
assert isinstance(parsed, dict), f"unexpected whoami payload: {who[:200]!r}"
return parsed
# -- API calls with the session ----------------------------------------------------------
def request(self, method: str, path: str, data: dict | None = None) -> tuple[int, object]:
"""Issue an API call with the session cookie (+ CSRF header on unsafe methods)."""
url = path if path.startswith("http") else self.base + path
headers: dict[str, str] = {}
body: bytes | None = None
if data is not None:
body = json.dumps(data).encode()
headers["Content-Type"] = "application/json"
if method.upper() not in ("GET", "HEAD", "OPTIONS"):
tok = self._csrf_token()
if tok:
headers["X-CSRFToken"] = tok
headers["Referer"] = self.base + "/"
headers["Origin"] = self.base
status, _, raw = self._open(url, data=body, headers=headers, method=method.upper())
try:
return status, json.loads(raw)
except (json.JSONDecodeError, ValueError):
return status, None
def get(self, path: str) -> tuple[int, object]:
return self.request("GET", path)
def post(self, path: str, data: dict | None = None) -> tuple[int, object]:
return self.request("POST", path, data)
def delete(self, path: str) -> tuple[int, object]:
return self.request("DELETE", path)

View File

@ -3,12 +3,13 @@
Plan §4.3 explicitly names this test for lasuite-docs: "create a doc, edit via the API, confirm
persistence". This is the canonical create-an-object + read-it-back for lasuite-docs.
Flow (uses an OIDC token from the dep keycloak):
1. Obtain a JWT via OIDC password grant against the dep keycloak (the test user is provisioned
by the orchestrator's dep-provisioning step).
2. POST `/api/v1.0/documents/` with `Authorization: Bearer <jwt>` to create a new doc with a
Flow (updated for impress v5.4.0, which removed Bearer/JWT auth on the API — the doc CRUD now
runs on the app's session cookie from the real OIDC authorization-code login):
1. Log in via the OIDC authorization-code flow against the dep keycloak (the test user is
provisioned by the orchestrator's dep-provisioning step) → session cookie.
2. POST `/api/v1.0/documents/` with the session (+ CSRF header) to create a new doc with a
unique title; capture the returned `id`.
3. GET `/api/v1.0/documents/<id>/` with the same Bearer token; assert the returned title and
3. GET `/api/v1.0/documents/<id>/` with the same session; assert the returned title and
id match.
Non-vacuous: a misconfigured OIDC, broken backend, or missing endpoint fails at the layer it's
@ -26,9 +27,9 @@ import uuid
import pytest
sys.path.insert(0, os.path.dirname(__file__))
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", "runner"))
from harness import http as harness_http # noqa: E402
from harness import sso
from _oidc_session import OidcSession # noqa: E402 (recipe-local helper, same dir)
@pytest.mark.requires_deps
@ -36,43 +37,29 @@ def test_create_doc_and_read_back(live_app, deps):
"""Create a doc via the authenticated API; fetch it back; assert round-trip."""
kc = deps["keycloak"]
# Obtain a JWT via OIDC password grant
access_token = sso.oidc_password_grant(
{
"client_id": kc["client_id"],
"client_secret": kc["client_secret"],
"user": kc["user"],
"password": kc["password"],
"token_url": kc["token_url"],
}
)
auth = {"Authorization": f"Bearer {access_token}"}
# Session login via the OIDC authorization-code flow (impress v5.4.0+ rejects Bearer JWTs)
sess = OidcSession(f"https://{live_app}")
sess.login(kc["user"], kc["password"])
# Create a doc with a unique title
title = f"ccci-doc-{uuid.uuid4().hex[:8]}"
s, body = harness_http.http_post(
f"https://{live_app}/api/v1.0/documents/",
data={"title": title},
headers=auth,
)
s, body = sess.post("/api/v1.0/documents/", {"title": title})
assert s in (200, 201), f"POST /api/v1.0/documents/ HTTP {s}: {body!r}"
assert isinstance(body, dict), f"unexpected response shape: {body!r}"
doc_id = body.get("id")
assert doc_id, f"created doc has no id: {body!r}"
assert (
body.get("title") == title
), f"created doc title mismatch: created={title!r}, response={body.get('title')!r}"
assert body.get("title") == title, (
f"created doc title mismatch: created={title!r}, response={body.get('title')!r}"
)
# Fetch it back via the dedicated GET endpoint
s, fetched = harness_http.http_get(
f"https://{live_app}/api/v1.0/documents/{doc_id}/", headers=auth
)
s, fetched = sess.get(f"/api/v1.0/documents/{doc_id}/")
assert s == 200, f"GET /api/v1.0/documents/{doc_id}/ HTTP {s}: {fetched!r}"
assert isinstance(fetched, dict), f"unexpected GET response: {fetched!r}"
assert fetched.get("id") in (
doc_id,
str(doc_id),
), f"fetched id mismatch: created={doc_id!r}, fetched={fetched.get('id')!r}"
assert (
fetched.get("title") == title
), f"fetched title mismatch: created={title!r}, fetched={fetched.get('title')!r}"
assert fetched.get("title") == title, (
f"fetched title mismatch: created={title!r}, fetched={fetched.get('title')!r}"
)

View File

@ -2,13 +2,16 @@
SOURCE: references/recipe-maintainer/recipe-info/lasuite-docs/tests/oidc_login.py
End-to-end flow:
End-to-end flow (updated for impress v5.4.0, which REMOVED Bearer/JWT auth on the API —
the app now only accepts its own session cookie from the OIDC authorization-code flow):
1. GET `/api/v1.0/users/me/` without auth → asserts the response REDIRECTS to the dep
keycloak's realm auth endpoint (the recipe is correctly configured to challenge
unauthenticated callers — wired via install_steps.sh).
2. Obtain an OIDC token from the dep keycloak via password grant
(the test user provisioned by the orchestrator's realm setup).
3. Call `/api/v1.0/users/me/` with `Authorization: Bearer <jwt>` → asserts 200 and the
2. Obtain an OIDC token from the dep keycloak via password grant, and assert the API
now REJECTS it as a Bearer credential (the v5.4.0 auth hardening — a 200 here would
mean the hardening regressed).
3. Log in via the real OIDC authorization-code flow (app → keycloak form → callback →
session cookie) and call `/api/v1.0/users/me/` with the session → asserts 200 and the
returned user's email matches the provisioned test user.
Marked @pytest.mark.requires_deps — skips with `deps-not-ready` if dep provisioning failed.
@ -24,9 +27,11 @@ import urllib.request
import pytest
sys.path.insert(0, os.path.dirname(__file__))
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", "runner"))
from _oidc_session import OidcSession # noqa: E402 (recipe-local helper, same dir)
from harness import http as harness_http # noqa: E402
from harness import sso
from harness import sso # noqa: E402
_CTX = ssl.create_default_context()
_CTX.check_hostname = False
@ -62,16 +67,18 @@ def test_oidc_login_via_keycloak(live_app, deps):
# 302 redirect. Both are valid "auth-required" indicators — accept either, but if a
# redirect is returned it must point at the dep keycloak realm.
if status in (301, 302, 303, 307, 308):
assert expected_prefix in (
redirect or ""
), f"Docs redirected to {redirect!r}, expected to start with {expected_prefix!r}"
assert expected_prefix in (redirect or ""), (
f"Docs redirected to {redirect!r}, expected to start with {expected_prefix!r}"
)
else:
assert status in (401, 403), (
f"GET /api/v1.0/users/me/ unauth: HTTP {status}; expected redirect to keycloak "
f"OR 401/403. (200 would be an auth leak.)"
)
# Step 2: obtain an OIDC token via password grant against the dep keycloak
# Step 2: obtain an OIDC token via password grant against the dep keycloak, and assert
# the API REJECTS it as Bearer — impress v5.4.0 removed Bearer/JWT auth (SessionAuthentication
# only); a 200 here would mean the auth hardening regressed.
creds = {
"client_id": kc["client_id"],
"client_secret": kc["client_secret"],
@ -81,14 +88,19 @@ def test_oidc_login_via_keycloak(live_app, deps):
}
access_token = sso.oidc_password_grant(creds)
assert isinstance(access_token, str) and access_token.count(".") == 2, "expected JWT"
# Step 3: call the protected API with the Bearer token; assert 200 + user email
status, body = harness_http.http_get(
f"https://{live_app}/api/v1.0/users/me/",
headers={"Authorization": f"Bearer {access_token}"},
)
assert status == 200, f"GET /api/v1.0/users/me/ with token HTTP {status}: {body!r}"
assert isinstance(body, dict), f"unexpected response: {body!r}"
assert (
body.get("email") == kc["email"]
), f"unexpected user email: got {body.get('email')!r}, expected {kc['email']!r}"
assert status in (401, 403), (
f"GET /api/v1.0/users/me/ with a Bearer JWT returned HTTP {status} — impress >= v5.4.0 "
f"must reject raw Bearer tokens (got body {body!r})"
)
# Step 3: the successor auth path — real OIDC authorization-code login (session cookie);
# the session-authenticated whoami must return the provisioned user.
sess = OidcSession(f"https://{live_app}")
me = sess.login(kc["user"], kc["password"])
assert me.get("email") == kc["email"], (
f"unexpected user email: got {me.get('email')!r}, expected {kc['email']!r}"
)

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