E3 — Operator Console E2E Smoke (ADR-0016)¶
- Target: LXC 281 (
192.168.0.211:8080) viaX-Admin-Token - Site:
hotelaldamagolf-com - Date: 2026-05-31
- Mode: Preview-only; no prod push performed
Summary (< 300 words)¶
The Operator-Console matrix (20 cases across users/posts/options/config/cron/DB and
the preview-drift cross-cut) is fundamentally healthy on LXC 281: 19 of 20 cases
pass behaviourally. Users CRUD, posts listing, options PATCH/restore, wp-config
read with php -l linter rejection on bad PHP, .htaccess read, cron listing,
DB health, ad-hoc DB SELECT with destructive-statement guard, and the table-size
listing all behave per ADR-0016. The preview-drift accounting collapses to zero
after a net-zero mutation pair (create+delete, PATCH+PATCH-back), which is the
intended detector semantic — drift is computed against prod, not against an
event log. The POST /discard path also reconciles cleanly to db.count == 0.
One P1 bug blocks the impersonation flow end-to-end: POST /users/{id}/impersonate
returns HTTP 500 because the deployed image is missing
/opt/wp-pulse-server/templates/jinja/mu-impersonate.php.j2. The template
exists in the source tree at services/wp-pulse-server/templates/jinja/mu-impersonate.php.j2,
so this is a packaging/deploy regression (Ansible role or Dockerfile not copying
the file). Until fixed, operator-console-driven impersonation is non-functional
on prod LXC 281 even though the router code path is correct.
Spec deltas (non-blocking, contract clarifications):
- POST /users returns HTTP 200, not 201 (and field names are user_login/user_email, not login/email).
- PATCH options response echoes option_value: "-" rather than the new value, but the pending change is logged with a UUID and surfaces in pending-changes.
- The destructive-SQL guard returns 403 (more semantically correct) rather than 400.
Recommend deploying the missing Jinja template ASAP, then re-running T6 to confirm
the magic-URL points at hotelaldamagolf-com-preview.monxas.casa.
Per-case pass/fail¶
E — Users + posts¶
| # | Case | Result | Notes |
|---|---|---|---|
| 1 | GET /users |
PASS | 200, 2 users, fields id/user_login/roles/has_2fa (spec uses different names — see contract delta). source: preview-stack. |
| 2 | UI /sites/{slug}/users |
PASS | 200, renders admin + aldama. |
| 3 | POST /users (preview) |
PASS with contract delta | Expected 201; got 200. Body uses user_login/user_email, NOT login/email as spec'd. Email must not be .local (validator rejects reserved TLD). Returned {user_id:3, op:"create", pending_logged:true}. |
| 4 | pending-changes db.count >= 1 |
PASS | db.count=2 (users.added=1 + htaccess_rules.added=1 pre-existing). |
| 5 | DELETE /users/{id} |
PASS | 200, {user_id:3, op:"delete", pending_logged:true}. |
| 6 | POST /users/1/impersonate |
FAIL — P1 BUG | HTTP 500: impersonate template missing: [Errno 2] No such file or directory: '/opt/wp-pulse-server/templates/jinja/mu-impersonate.php.j2'. Template exists in repo (services/wp-pulse-server/templates/jinja/mu-impersonate.php.j2) but was not copied into the deployed image on LXC 281. Magic-URL hostname check could not be exercised. |
| 7 | GET /posts?status=publish&limit=10 |
PASS | 200, 10 publish posts/pages returned. |
| 8 | UI /sites/{slug}/posts |
PASS | 200, renders "Posts". |
F — Options + wp-config + htaccess + cron¶
| # | Case | Result | Notes |
|---|---|---|---|
| 9 | GET /options?search=blogname |
PASS | 200, returns {option_name:"blogname", option_value:"Hotel aldama Golf Llanes", autoload:"yes"}. |
| 10 | PATCH /options/blogname + restore |
PASS with contract quirk | Both PATCHes return 200 with pending_change_id UUID; however option_value in the response echoes - literally, not the new value. Pending change is logged. |
| 11 | GET /wp-config |
PASS | 200, returns full PHP text. |
| 12 | PUT /wp-config with bad PHP |
PASS | 400 + linter error: php -l rejected the content: ... PHP Parse error: syntax error, unexpected end of file. File NOT written (verified — no pending file drift). |
| 13 | GET /htaccess |
PASS | 200, content + bytes + sha256. |
| 14 | GET /cron |
PASS | 200, includes wpp_poll_commands, wpp_heartbeat, wp_version_check, WooCommerce + plugin events. |
G — DB tools¶
| # | Case | Result | Notes |
|---|---|---|---|
| 15 | GET /db/health |
PASS | 200, categories: post_revisions (0), expired_transients (1), orphan_postmeta (0), autodraft_posts (0). Each carries label/description/cleanup_command/samples. |
| 16 | POST /db/query SELECT COUNT |
PASS | 200, rows: [["1131"]], duration_ms: 132, CSV b64 attached. |
| 17 | Destructive DROP TABLE |
PASS with status delta | Spec allowed 400/403 — got 403: query rejected: only SELECT/EXPLAIN/SHOW/DESCRIBE statements are allowed. |
| 18 | GET /db/tables |
PASS | 200, sorted by bytes descending (postmeta 1.7MB → posts 1MB → options 592KB → …). |
Cross-cut¶
| # | Case | Result | Notes |
|---|---|---|---|
| 19 | Verify db category surfaces mutations | PASS (semantically) | After net-zero mutations (create→delete user, PATCH→PATCH-back blogname), db.count == 0. This is correct: the drift detector compares preview-DB to prod-DB, NOT an append-only event log. Mid-flow (T4) we observed db.count=2, proving the detector picks up live drift. |
| 20 | POST /discard clears state |
PASS | 200, worktree reset to HEAD; quarantine column reconciled to prod. Subsequent pending-changes shows db.count == 0, files.count == 0. Pre-existing smoke-e6/x.php quarantine entry remains aligned across preview+prod (no drift). |
Bugs¶
P1 — Impersonation template missing in deployed image¶
- Endpoint:
POST /api/v1/sites/{slug}/users/{id}/impersonate - Symptom: HTTP 500,
impersonate template missing: [Errno 2] No such file or directory: '/opt/wp-pulse-server/templates/jinja/mu-impersonate.php.j2' - Root cause: The Jinja template lives in the source tree at
services/wp-pulse-server/templates/jinja/mu-impersonate.php.j2(alongsidemu-local-safety.php.j2, which IS deployed). The Ansible role or Dockerfile that ships/opt/wp-pulse-server/templates/jinja/to LXC 281 is missing this file (added after the last server rebuild). - Impact: Operator-console impersonation is fully broken on prod LXC 281 (T6 cannot reach the preview-hostname guard since it 500s before the URL is rendered).
- Fix: Re-deploy
templates/jinja/from the repo to LXC 281 (or trigger Ansible's wp-pulse-server role re-run). The router code atapi/routers/impersonate.pyalready implements the correct preview-hostname derivation viapreview_url(slug)— no code change needed.
P3 — Contract drift between ADR-0016 spec and implemented routes¶
Non-blocking but should be reconciled in either the spec or the OpenAPI:
POST /usersstatus code: spec says 201, impl returns 200.POST /usersbody shape: spec useslogin/email/role; impl requiresuser_login/user_email/roleand rejects.localTLDs inuser_email(RFC 6761 reserved-name validator).PATCH /options/{name}response:option_valuefield echoes literal-rather than the new value. Functionality is correct (pending change is logged), but consumers readingoption_valuepost-PATCH would see a stale placeholder.POST /db/querydestructive-statement rejection: spec allowed400/403; impl returns 403. (Preferred.)