Skip to content

E3 — Operator Console E2E Smoke (ADR-0016)

  • Target: LXC 281 (192.168.0.211:8080) via X-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 (alongside mu-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 at api/routers/impersonate.py already implements the correct preview-hostname derivation via preview_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 /users status code: spec says 201, impl returns 200.
  • POST /users body shape: spec uses login/email/role; impl requires user_login/user_email/role and rejects .local TLDs in user_email (RFC 6761 reserved-name validator).
  • PATCH /options/{name} response: option_value field echoes literal - rather than the new value. Functionality is correct (pending change is logged), but consumers reading option_value post-PATCH would see a stale placeholder.
  • POST /db/query destructive-statement rejection: spec allowed 400/403; impl returns 403. (Preferred.)