Compare commits
6 Commits
f551b64e2e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 5be5ad2398 | |||
| bf226e4174 | |||
| a3d2a7807f | |||
| 84dd00f3ec | |||
| 55990bea49 | |||
| 631385721f |
@@ -1,3 +1,4 @@
|
||||
{"_type":"issue","id":"hc-5yy","title":"AJAX step-save listeners never attach (script runs before cards render)","description":"0.1.5 shipped the AJAX auto-save, but the inline script is emitted before the step-card markup, so document.querySelectorAll('form.att-hc-step-save') matches nothing and no change/blur listeners attach — no network request fires. The pre-existing step-history loader shared the same defect. Fixed by deferring both scripts' wiring to DOMContentLoaded. Released in 0.1.6.","status":"closed","priority":1,"issue_type":"bug","owner":"steve@hanlon.co.uk","created_at":"2026-07-27T07:36:54Z","created_by":"Steve Hanlon","updated_at":"2026-07-27T07:37:07Z","closed_at":"2026-07-27T07:37:07Z","close_reason":"Fixed in 0.1.6 — both inline scripts now defer listener wiring to DOMContentLoaded.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"hc-4m5","title":"Plugin: allow ATT_HC_API_URL / ATT_HC_API_KEY via settings screen (not just wp-config)","description":"Some managed hosts make editing wp-config.php impractical or explicitly forbid it (WPE, some resellers, some clients' own operations teams). Add a settings-screen alternative to the existing constants.\n\nDesign (mirror the pattern already used for the Gitea recovery config on the same settings page):\n\n- Two new WP options: att_hc_api_url and att_hc_api_key (autoload=false on the key).\n- ATT_HC_Api::url() and ATT_HC_Api::key() accessors: return the constant if defined + non-empty, else the option. All existing consumers switch to these accessors instead of the constants directly.\n- config_error() message updates to say 'add to wp-config.php OR set via Tools -\u003e Site Healthcheck -\u003e Settings'.\n- Settings page gets a new 'Central history server' card above the existing Gitea card, with:\n - URL input (type=url) — locked with 'Set via ATT_HC_API_URL constant' description when constant is defined\n - Key input (type=password, autocomplete=new-password) — same lock behaviour\n- Save handler extends the existing att_hc_save_settings to persist the two options.\n- No migration needed — sites using constants keep working untouched.\n\nSecurity note to include in the description on the settings page: the option-stored key is visible to any WP admin (options.php, DB) — the constant path is stronger. Recommend constant when possible.\n\nUser asked for this because some sites can't edit wp-config.php.","status":"closed","priority":1,"issue_type":"feature","assignee":"Steve Hanlon","owner":"steve@hanlon.co.uk","created_at":"2026-07-07T13:51:13Z","created_by":"Steve Hanlon","updated_at":"2026-07-07T13:54:15Z","started_at":"2026-07-07T13:51:24Z","closed_at":"2026-07-07T13:54:15Z","close_reason":"Added a 'Central history server' card at the top of the settings page (Tools → Site Healthcheck → Settings) with URL + API key fields. Storage: att_hc_api_url and att_hc_api_key options (autoload=false on the key). Resolver: ATT_HC_Api::url() / ATT_HC_Api::key() return the constant when defined and non-empty, else the option, else ''. url_from_constant() / key_from_constant() drive the per-field lock on the settings page. All existing consumers (request(), config_error(), is_configured(), the config-error notice) switched to the accessors. Separate action + nonce (att_hc_save_api_settings) so it doesn't tangle with the existing gitea save. Handler ignores submitted values for fields locked by a constant — belt-and-braces with the disabled input. 18 assertions verified: no-config / options-only / http-blocked / loopback-http-ok / constant-wins-over-option. Both PHP 8.3 and PHP 7.4 parse cleanly.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"hc-eff","title":"Plugin fatals on PHP 7.4 — PHP 8 syntax in central-server work","description":"The central history server work (epic hc-0p1) introduced four PHP 8.0+ syntax sites in the plugin codebase. PHP 7.4 fails to parse the affected files, so on a host running PHP 7.4 (eg one user reported Ubuntu 7.4.3-4ubuntu2.29) the plugin throws a fatal on load.\n\nSites:\n- includes/class-att-hc-session.php:244 — str_starts_with()\n- includes/class-att-hc-api.php:52 — named argument requires_auth: false\n- includes/class-att-hc-session.php:126 — named arguments include_steps:, limit:\n- includes/admin-page.php:165 — named arguments include_steps:, limit:\n\nPlugin main file header still says 'Requires PHP: 7.4', so the expectation is PHP 7.4 support. Backport: replace str_starts_with with substr === comparison; convert named args to positional (signatures accept positional already).\n\nAffects every plugin user on PHP \u003c 8.0.","status":"closed","priority":1,"issue_type":"bug","assignee":"Steve Hanlon","owner":"steve@hanlon.co.uk","created_at":"2026-06-30T08:27:51Z","created_by":"Steve Hanlon","updated_at":"2026-06-30T08:29:22Z","started_at":"2026-06-30T08:27:57Z","closed_at":"2026-06-30T08:29:22Z","close_reason":"Backported four PHP 8.0+ syntax sites to PHP 7.4-compatible equivalents. str_starts_with replaced with substr(...) === literal. Three named-argument call sites converted to positional — signatures already accept positional with the same values, no semantic change. Verified by linting all 16 plugin files (att-site-healthcheck.php + includes/) against PHP 7.4.33 on sjh: no syntax errors, no residual PHP 8+ patterns. Plugin's 'Requires PHP: 7.4' header is now actually true.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"hc-cc8","title":"Plugin: per-step 'view previous notes' panel","description":"Each step card gets a small link/disclosure: 'Previous notes for this step (N)' where N is the count from the server.\n\nOn expand: render a list of past notes from GET /healthchecks/steps/{step_id}?site_key=\u003ccurrent\u003e\u0026limit=5. Each entry shows:\n- started_at (formatted)\n- status badge\n- notes (or 'no notes')\n- reporting_url if different from the current session\n\nLoaded lazily on first expand (one request per step) to avoid hammering the server on page load. Cache result for the page lifetime.","status":"closed","priority":1,"issue_type":"feature","assignee":"Steve Hanlon","owner":"steve@hanlon.co.uk","created_at":"2026-06-29T11:10:17Z","created_by":"Steve Hanlon","updated_at":"2026-06-29T11:41:06Z","started_at":"2026-06-29T11:36:10Z","closed_at":"2026-06-29T11:41:06Z","close_reason":"Per-step history disclosure shipped end-to-end.\n\nServer:\n- New GET /step-counts?site_key=...\u0026exclude_id=... returns {counts: {step_id: int}} for the (N) badge in one round-trip\n- stepHistory + stepCountsForSite both grew an exclude_id param so the active session doesn't appear in its own 'previous notes' panel\n- Routes: /step-counts moved to top-level path to avoid the /healthchecks/{id} pattern claiming 'step-counts' as an id\n\nPlugin:\n- ATT_HC_Api::step_counts() and step_history() pass exclude_id when available\n- att_hc_render_active_session pre-fetches step counts once and passes per-step count to the card renderer\n- Step card now renders a \u003cdetails class=att-hc-history data-step-id=…\u003e with summary 'Previous notes for this step (N)' when N\u003e0\n- New att_hc_print_step_history_assets prints the inline CSS + vanilla JS that hooks the details.toggle event: lazy-fetches on first expand via admin-ajax (action=att_hc_step_history), renders entries with date + status badge + notes + 'Reported from \u003curl\u003e' when different from current reporting_url. Marks data-loaded=yes to cache for page lifetime; resets to no on error so user can retry.\n- New wp_ajax_att_hc_step_history admin-ajax handler returns wp_send_json_success/error with nonce verification (att_hc_step_history nonce)\n\nVerified end-to-end against live server: counts correct with + without exclude_id (4 vs 3 for active vs prior), step_history filters active session, empty step / unknown site return cleanly, reordered routes still work.","dependencies":[{"issue_id":"hc-cc8","depends_on_id":"hc-0p1","type":"parent-child","created_at":"2026-06-29T12:13:24Z","created_by":"Steve Hanlon","metadata":"{}"},{"issue_id":"hc-cc8","depends_on_id":"hc-9jl","type":"blocks","created_at":"2026-06-29T12:10:37Z","created_by":"Steve Hanlon","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}
|
||||
@@ -15,6 +16,8 @@
|
||||
{"_type":"issue","id":"hc-5ix.4","title":"Session data model: option-backed in-progress healthcheck record","description":"One in-progress session per site at a time. Stored in a custom option (or a CPT — pick during implementation). Shape: id, started_at, finished_at, technician_id, site_url_snapshot, per_step_state { status: not_started|in_progress|done|skipped|blocked|n_a, notes, completed_at }. Designed so phase-3 automation can attach structured findings later.","notes":"Built in phase-1 scaffold; passing lint + WP-eval end-to-end smoke test on testsite.","status":"closed","priority":1,"issue_type":"task","owner":"steve@hanlon.co.uk","created_at":"2026-06-11T14:36:11Z","created_by":"Steve Hanlon","updated_at":"2026-06-11T14:49:27Z","started_at":"2026-06-11T14:41:27Z","closed_at":"2026-06-11T14:49:27Z","labels":["phase-1"],"dependencies":[{"issue_id":"hc-5ix.4","depends_on_id":"hc-5ix","type":"parent-child","created_at":"2026-06-11T15:36:11Z","created_by":"Steve Hanlon","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"hc-5ix.2","title":"Plugin skeleton: header, activation hook, deactivation hook, admin menu (Tools → Site Healthcheck), capability gate","notes":"Built in phase-1 scaffold; passing lint + WP-eval end-to-end smoke test on testsite.","status":"closed","priority":1,"issue_type":"task","owner":"steve@hanlon.co.uk","created_at":"2026-06-11T14:36:10Z","created_by":"Steve Hanlon","updated_at":"2026-06-11T14:49:26Z","started_at":"2026-06-11T14:41:27Z","closed_at":"2026-06-11T14:49:26Z","labels":["phase-1"],"dependencies":[{"issue_id":"hc-5ix.2","depends_on_id":"hc-5ix","type":"parent-child","created_at":"2026-06-11T15:36:09Z","created_by":"Steve Hanlon","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"hc-5ix.1","title":"Bootstrap: trigger install of wp-site-recovery plugin as step 0","description":"Healthcheck plugin should check on activation if site-recovery is installed; if not, fetch and install from a known URL/ZIP and activate it. Block stepper from starting until recovery is in place.","notes":"Detection half done (status panel + manual link). Auto-install of recovery plugin from a private URL is the wp-site-recovery side of hc-5ix.27 — closing this as 'detection complete'.","status":"closed","priority":1,"issue_type":"task","owner":"steve@hanlon.co.uk","created_at":"2026-06-11T11:26:40Z","created_by":"Steve Hanlon","updated_at":"2026-06-11T15:03:02Z","closed_at":"2026-06-11T15:03:02Z","labels":["phase-1"],"dependencies":[{"issue_id":"hc-5ix.1","depends_on_id":"hc-5ix","type":"parent-child","created_at":"2026-06-11T12:26:39Z","created_by":"Steve Hanlon","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"hc-66c","title":"AJAX step saving on the healthcheck reporting page","description":"The step cards currently save via a full-page POST, which reloads the page and breaks the technician's flow. Switch to AJAX: notes save on blur, status saves on change, with inline save feedback and no reload.","status":"closed","priority":2,"issue_type":"feature","owner":"steve@hanlon.co.uk","created_at":"2026-07-27T07:21:00Z","created_by":"Steve Hanlon","updated_at":"2026-07-27T07:27:23Z","closed_at":"2026-07-27T07:27:23Z","close_reason":"Implemented in 0.1.5: AJAX auto-save on the step cards — status saves on change, notes on blur, inline feedback, no reload. Progressive enhancement; no-JS form POST retained as fallback.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"hc-fbd","title":"Next healthcheck due date per site","description":"Technician can set 'next healthcheck due' while working a session; stored on the server against the healthcheck row, surfaced in the dashboard site list and session views so it is visible when planning work.","acceptance_criteria":"Plugin UI sets/clears a date on the active session; server persists + validates YYYY-MM-DD; dashboard shows next-due per site (derived from latest session) and per session; report includes it; tests pass.","status":"closed","priority":2,"issue_type":"feature","owner":"steve@hanlon.co.uk","created_at":"2026-07-23T10:50:25Z","created_by":"Steve Hanlon","updated_at":"2026-07-23T11:04:08Z","closed_at":"2026-07-23T11:04:08Z","close_reason":"Implemented and released in 0.1.4: server column + validation + dashboard views, plugin date control, 42-test suite. Commits 6313857, 55990be.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"hc-lh6","title":"Dashboard: reopen a finished healthcheck","description":"Techs sometimes finish a healthcheck and later need to add a note (client came back with something after signoff). Currently there's no way to unfinish — the session is read-only on the dashboard.\n\nAdd a 'Reopen' button on the session-detail dashboard view that clears finished_at on the server, plus tighten the PUT /healthchecks/{id} controller so it doesn't accidentally reopen sessions when a client sends an empty body (latent bug: Validate::optionalInt returns null for missing keys, so an empty PUT currently sets finished_at=NULL).","design":"**Backend:**\n1. Tighten Controllers/Healthchecks::update() to use array_key_exists('finished_at', $body) instead of optionalInt. Missing key = no-op; present-with-null = reopen; present-with-int = finish/update.\n2. Add POST /dashboard/healthchecks/{id}/reopen — session-authenticated (mirrors the GET /dashboard auth pattern), CSRF-protected via a per-session token.\n3. On success, redirect back to the session detail page.\n\n**Frontend (dashboard):**\n4. In renderSessionDetail(), when finished_at !== null, add a small form with the reopen button + hidden CSRF token.\n5. Confirm dialog via inline JS onsubmit so the button isn't a one-click landmine.\n\n**CSRF token pattern:** stored in $_SESSION['att_hc_csrf'], generated on first use with random_bytes(16); verified with hash_equals().\n\n**Not doing (option (b) from the discussion):** preserving original finished_at in a separate column. If we want that later it's a separate migration + Store change.","acceptance_criteria":"- [ ] Empty PUT /healthchecks/{id} does not reopen (returns 200 no-op)\n- [ ] PUT /healthchecks/{id} with {\"finished_at\": null} reopens\n- [ ] PUT /healthchecks/{id} with {\"finished_at\": \u003cint\u003e} still finishes\n- [ ] Dashboard session-detail view shows Reopen button only when finished\n- [ ] Clicking Reopen (with confirm) clears finished_at and reloads the page\n- [ ] Reopen POST rejected without valid CSRF token\n- [ ] Reopen POST rejected without valid dashboard session","status":"closed","priority":2,"issue_type":"feature","assignee":"Steve Hanlon","owner":"steve@hanlon.co.uk","created_at":"2026-07-21T10:50:06Z","created_by":"Steve Hanlon","updated_at":"2026-07-21T10:52:00Z","started_at":"2026-07-21T10:50:12Z","closed_at":"2026-07-21T10:52:00Z","close_reason":"Implemented: dashboard reopen button + CSRF + tightened Healthchecks::update controller so empty PUT no longer accidentally reopens sessions.","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"hc-adg","title":"Recovery installer pulls unpinned 'latest' from Gitea + auto-activates without signature check","description":"`recovery-installer.php` (`resolve_latest_ref()` L67-92, install L148) fetches the recovery plugin from Gitea using an unpinned reference (releases → tags → `main` branch archive fallback), then feeds the downloaded ZIP straight to WP's `Plugin_Upgrader::install()` which activates the code.\n\nTrust anchor is Gitea repo integrity: whoever controls the recovery repo (or a leaked Gitea deploy token) controls what runs on every install at next tech click.\n\nTLS verification is on (default `wp_remote_get` behaviour, not disabled anywhere), so MITM is out of scope — but supply-chain compromise of the Gitea repo is not.\n\nNote: this is the same trust model as the new PUC auto-update flow (hc-8gb) — so it's not a new risk introduced by that change, but it's worth naming and mitigating in the same pass.","design":"Options:\n\n**a) Pin to signed tags only.** Require the recovery installer to reject anything that isn't a signed tag (verify via Gitea API's tag object). Only works if we start GPG-signing releases.\n\n**b) Verify a SHA-256 hash from a second source.** Ship an expected-hash constant in the plugin (updated at release time). Installer fetches ZIP + verifies hash before install. Simple, effective, but couples plugin releases to recovery releases.\n\n**c) Detached signature file alongside the ZIP.** e.g. `recovery-1.2.3.zip.sig` verified against a public key baked into the healthcheck plugin. Best long-term option, most setup.\n\n**d) Reduce blast radius via read-only, single-repo Gitea tokens.** Doesn't stop repo compromise but shrinks the credential footprint.\n\nRecommendation: start with **(b)** — quick win. Consider **(c)** if we ever distribute recovery outside the internal network.","acceptance_criteria":"- [ ] Recovery installer refuses to install a ZIP whose SHA-256 doesn't match an expected value shipped in the plugin (or a similar integrity check)\n- [ ] Recovery repo Gitea token is read-only + scoped to just that repo\n- [ ] README documents the release-integrity flow","notes":"Reported in 2026-07-16 security review. Flagged as risk #2 of 3.\n\nRelated: hc-8gb introduced PUC-based auto-updates for THIS plugin with the same trust model. Any signing/hashing scheme we build for recovery should be reusable for the healthcheck plugin's own updates.","status":"open","priority":2,"issue_type":"bug","owner":"steve@hanlon.co.uk","created_at":"2026-07-16T10:14:40Z","created_by":"Steve Hanlon","updated_at":"2026-07-16T10:14:40Z","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"hc-gp3","title":"Harden API key handling — remove settings-page echo, add DB encryption or force constants","description":"Central-server API key is currently exposed in two ways that widen the blast radius of a compromise:\n\n1. `admin-page.php:749` renders the key back into an `\u003cinput type=\"password\"\u003e` on the settings page — trivially unmasked via DOM inspector by any user with `manage_options`.\n2. When set via the settings UI (not a wp-config constant), the key is stored plaintext in `wp_options` under `att_hc_api_key` — readable by any user or process with DB access.\n\nBecause the same key is shared across every WP install that talks to the central server, a leak from one site gives read/write on every other site's healthcheck history.","design":"Options (pick one or combine):\n\n**a) Never render the key back into the input.** Show only a masked placeholder like `••••••••abc3` (last 4 chars) with a 'change' button that swaps in an empty input. Won't help against DB reads but blocks the easiest exfiltration path.\n\n**b) Force wp-config constants only.** Delete the settings-page input for the key entirely; require `ATT_HC_API_KEY` in `wp-config.php`. Simple, most secure, but loses the managed-host convenience the original design called out (`class-att-hc-api.php:14-19`).\n\n**c) Encrypt the option at rest.** Use a key derived from `AUTH_KEY` / `SECURE_AUTH_KEY` (already in wp-config) to encrypt the DB value. Still readable by anyone who can read both DB + wp-config, but that's a higher bar than DB-only.\n\n**d) Per-site keys instead of a shared key.** Bigger change on the server side (would need a keys table + rotation). Correct long-term fix. Files as a follow-up if we do (a)/(b) now.\n\nRecommendation: **(a) + prefer (b)** for new installs, document (b) as the strong default in README, keep (c)/(d) as future work.","acceptance_criteria":"- [ ] Settings page no longer echoes the key value into the DOM\n- [ ] README + wp-config docs recommend the constant path as the strong default\n- [ ] Existing option-based configs continue to work (no breakage on client sites)\n- [ ] Consider filing a follow-up for (d) per-site keys","notes":"Reported in 2026-07-16 security review. Flagged as risk #1 of 3.\n\nRelated risks (separate issues):\n- Recovery installer unpinned 'latest' pull\n- No rate limiting on server API + dashboard","status":"open","priority":2,"issue_type":"bug","owner":"steve@hanlon.co.uk","created_at":"2026-07-16T10:14:21Z","created_by":"Steve Hanlon","updated_at":"2026-07-16T10:14:21Z","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
@@ -26,6 +29,7 @@
|
||||
{"_type":"issue","id":"hc-5ix.9","title":"Smoke test on testsite: full end-to-end run through the checklist","notes":"Built in phase-1 scaffold; passing lint + WP-eval end-to-end smoke test on testsite.","status":"closed","priority":2,"issue_type":"task","owner":"steve@hanlon.co.uk","created_at":"2026-06-11T14:36:15Z","created_by":"Steve Hanlon","updated_at":"2026-06-11T14:49:29Z","closed_at":"2026-06-11T14:49:29Z","labels":["phase-1"],"dependencies":[{"issue_id":"hc-5ix.9","depends_on_id":"hc-5ix","type":"parent-child","created_at":"2026-06-11T15:36:14Z","created_by":"Steve Hanlon","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"hc-5ix.8","title":"README + install instructions + screenshots placeholder","notes":"Built in phase-1 scaffold; passing lint + WP-eval end-to-end smoke test on testsite.","status":"closed","priority":2,"issue_type":"chore","owner":"steve@hanlon.co.uk","created_at":"2026-06-11T14:36:14Z","created_by":"Steve Hanlon","updated_at":"2026-06-11T14:49:29Z","closed_at":"2026-06-11T14:49:29Z","labels":["phase-1"],"dependencies":[{"issue_id":"hc-5ix.8","depends_on_id":"hc-5ix","type":"parent-child","created_at":"2026-06-11T15:36:14Z","created_by":"Steve Hanlon","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"hc-5ix","title":"Build WordPress Healthcheck plugin (stepper through steps.md)","description":"Plugin that walks a technician through the WordPress healthcheck steps documented in steps.md. Independent of the recovery plugin (which it depends on as step 0).","status":"open","priority":2,"issue_type":"epic","owner":"steve@hanlon.co.uk","created_at":"2026-06-11T11:26:32Z","created_by":"Steve Hanlon","updated_at":"2026-06-11T11:26:32Z","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"hc-l0j","title":"Sidebar step dots track status changes live","description":"When a step status is saved via AJAX, recolour the sidebar step-list dot (and, via the existing :has() rule, the link text green/blue) live instead of only on page refresh. Extends applyStatus() in the step-save script; sidebar dots gained a data-step attribute. Released in 0.1.7.","status":"open","priority":3,"issue_type":"feature","owner":"steve@hanlon.co.uk","created_at":"2026-07-27T07:42:04Z","created_by":"Steve Hanlon","updated_at":"2026-07-27T07:42:04Z","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"hc-ufl","title":"Server: add rate limiting + lockout on API + /dashboard, rotate error_log","description":"Central healthcheck history server has no rate limiting or account lockout:\n\n- `server/src/Auth.php` — unlimited Bearer-key attempts, no delay, no lockout\n- `server/src/Dashboard.php:46-71` — unlimited login attempts on the /dashboard cookie-key form\n\nCombined with the shared-API-key exposure (see companion issue), a leaked or brute-forced key can be abused indefinitely with no signal.\n\nAlso: `server/src/bootstrap.php:25` calls `error_log()` on every uncaught exception with no rotation — the log grows unbounded on a busy or attacked server.","design":"**Rate limiting**: simplest workable option is a small in-DB counter table (key = client IP + endpoint, value = attempt count + window start). Reject when \u003e N attempts / window. No Redis dependency needed at current scale.\n\n**Lockout on /dashboard**: after e.g. 5 failed attempts from one IP within 15 min, return 429 with Retry-After. Keep it IP-scoped (not global) so a random attacker can't lock out real users.\n\n**Auth on API endpoints**: constant-time compare is already correct (`Auth.php:20` uses `hash_equals`). Add: increment failed-attempt counter, refuse to serve after threshold. Consider structured logging of each rejection with IP + user-agent for later review.\n\n**Log rotation**: either delegate to logrotate on the host (document in DEPLOY.md) OR self-rotate via a size check + rename in `bootstrap.php`'s handler.\n\nNice-to-have: session hardening on /dashboard — call `session_regenerate_id(true)` after successful auth (currently missing at `Dashboard.php:53-70`).","acceptance_criteria":"- [ ] API + /dashboard rate-limited per IP with a documented threshold\n- [ ] 429 with Retry-After returned when limit hit\n- [ ] error_log rotation strategy in place (either OS-level via DEPLOY.md docs, or in-app)\n- [ ] session_regenerate_id after /dashboard auth\n- [ ] Rejections logged with enough context to spot abuse patterns","notes":"Reported in 2026-07-16 security review. Flagged as risk #3 of 3 — lower priority than the API-key and recovery-installer issues but worth tackling in the same sweep since it's server-side and cheap.\n\nAlso noted (not required for close): session fixation on /dashboard is theoretically possible but requires attacker to first set a victim's PHPSESSID — high bar. Adding session_regenerate_id closes it cheaply.","status":"open","priority":3,"issue_type":"bug","owner":"steve@hanlon.co.uk","created_at":"2026-07-16T10:14:51Z","created_by":"Steve Hanlon","updated_at":"2026-07-16T10:14:51Z","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"hc-u5c","title":"Add Google Analytics and Search Console healthcheck steps","description":"Add two new healthcheck steps between performance (Step 7) and security (Step 8): a Google Analytics check that sniffs the homepage for GA4/GTM/UA snippets and detects known analytics plugins, and a Google Search Console check that looks for verification meta tags, sitemap reachability, robots.txt Disallow rules, and the WP 'Discourage search engines' setting. Updates steps.md to match.","status":"closed","priority":3,"issue_type":"feature","assignee":"Steve Hanlon","owner":"steve@hanlon.co.uk","created_at":"2026-07-16T08:44:39Z","created_by":"Steve Hanlon","updated_at":"2026-07-16T08:44:56Z","started_at":"2026-07-16T08:44:44Z","closed_at":"2026-07-16T08:44:56Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
{"_type":"issue","id":"hc-5ix.27","title":"Self-hosted update channel: plugin checks a private URL for new releases and offers one-click update from wp-admin","description":"Lightweight wrapper around the WP plugin update transient: site_transient_update_plugins filter that adds an entry for ourselves if a newer release exists at a configured URL. URL hosts a JSON manifest + zip. Lets technicians keep clients up to date without manually re-uploading the zip every check.","status":"open","priority":3,"issue_type":"task","owner":"steve@hanlon.co.uk","created_at":"2026-06-11T14:40:30Z","created_by":"Steve Hanlon","updated_at":"2026-06-11T14:40:30Z","labels":["phase-3"],"dependencies":[{"issue_id":"hc-5ix.27","depends_on_id":"hc-5ix","type":"parent-child","created_at":"2026-06-11T15:40:29Z","created_by":"Steve Hanlon","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
|
||||
|
||||
1
.gitattributes
vendored
1
.gitattributes
vendored
@@ -13,3 +13,4 @@ AGENTS.md export-ignore
|
||||
CLAUDE.md export-ignore
|
||||
steps.md export-ignore
|
||||
server/ export-ignore
|
||||
tests/ export-ignore
|
||||
|
||||
31
CLAUDE.md
31
CLAUDE.md
@@ -52,13 +52,36 @@ bd close <id> # Complete work
|
||||
|
||||
## Build & Test
|
||||
|
||||
There is **no build step, no package manager, and no test suite**. Plain PHP on both
|
||||
sides; the only third-party code is a vendored copy of Plugin Update Checker under
|
||||
There is **no build step and no package manager**. Plain PHP on both sides; the only
|
||||
third-party code is a vendored copy of Plugin Update Checker under
|
||||
`vendor/plugin-update-checker/` (do not hand-edit it — see README for the refresh
|
||||
procedure).
|
||||
|
||||
Quality gate for a change is a syntax lint of the files you touched, **against the
|
||||
right PHP version for that half of the repo** (see Conventions):
|
||||
```bash
|
||||
php tests/run.php # everything
|
||||
php tests/run.php plugin # plugin unit tests only (runs on PHP 7.4+)
|
||||
php tests/run.php server # server integration tests only (needs PHP 8.1+)
|
||||
```
|
||||
|
||||
The suite is a hand-rolled harness in `tests/lib/` — no PHPUnit, no composer,
|
||||
matching the rest of the repo. Two kinds of test:
|
||||
|
||||
- `tests/plugin_test.php` — unit tests for the pure static helpers on
|
||||
`ATT_HC_Session`. That class calls no WP functions at load time, so defining
|
||||
`ABSPATH` is enough to require it; anything needing a real WordPress runtime is
|
||||
out of scope here.
|
||||
- `tests/server_test.php` — **end-to-end over real HTTP.** The runner copies
|
||||
`server/` to a temp dir, gives it a throwaway SQLite DB and config, and starts
|
||||
`php -S` against it. A developer's real `server/config.php` (gitignored, may
|
||||
point at live MySQL) is never read or touched. Skips itself with a clear message
|
||||
if `pdo_sqlite` is missing.
|
||||
|
||||
Exit code is non-zero on failure, so it works as a pre-push gate. Add a test with
|
||||
`test('name', function () { ... })` and the `assert_*` helpers in
|
||||
`tests/lib/harness.php`.
|
||||
|
||||
Also lint the files you touched, **against the right PHP version for that half of
|
||||
the repo** (see Conventions):
|
||||
|
||||
```bash
|
||||
# Plugin — must parse on PHP 7.4
|
||||
|
||||
@@ -16,8 +16,12 @@ Then activate from *Plugins*. Settings appear under *Tools → Site Healthcheck*
|
||||
2. Confirm the recovery plugin status panel shows ✓ active.
|
||||
3. Click **Start new healthcheck**.
|
||||
4. Work through each step card. For each: choose a status (done / skipped / blocked / n/a) and add notes.
|
||||
5. Click **Finish & generate report**.
|
||||
6. Download the Markdown report or copy it to clipboard.
|
||||
5. Set **Next healthcheck due** to when the site should be looked at again. It's
|
||||
stored centrally and shows against the site on the dashboard, flagged when it's
|
||||
within a fortnight or overdue. Settable before or after finishing; leave it
|
||||
empty (or clear it) if nothing is scheduled.
|
||||
6. Click **Finish & generate report**.
|
||||
7. Download the Markdown report or copy it to clipboard.
|
||||
|
||||
One in-progress session per site at a time. Reports are not stored in the database (the plugin is meant to be uninstalled at the end of each engagement) — download them.
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
/**
|
||||
* Plugin Name: ATT Site Healthcheck
|
||||
* Description: Walks a technician through a structured WordPress site healthcheck. Steps are drop-in PHP files so adding/removing one is a single file change.
|
||||
* Version: 0.1.3
|
||||
* Version: 0.1.7
|
||||
* Author: Steve Hanlon
|
||||
* License: Proprietary
|
||||
* License URI: https://git.h12e.com/steve/wp-healthcheck/raw/branch/main/LICENSE
|
||||
@@ -15,7 +15,7 @@ if (!defined('ABSPATH')) {
|
||||
exit;
|
||||
}
|
||||
|
||||
define('ATT_HC_VERSION', '0.1.3');
|
||||
define('ATT_HC_VERSION', '0.1.7');
|
||||
define('ATT_HC_PLUGIN_FILE', __FILE__);
|
||||
define('ATT_HC_PLUGIN_DIR', plugin_dir_path(__FILE__));
|
||||
define('ATT_HC_PLUGIN_URL', plugin_dir_url(__FILE__));
|
||||
|
||||
@@ -5,8 +5,10 @@ add_action('admin_menu', 'att_hc_register_menu');
|
||||
add_action('admin_post_att_hc_start', 'att_hc_handle_start');
|
||||
add_action('admin_post_att_hc_resume', 'att_hc_handle_resume');
|
||||
add_action('wp_ajax_att_hc_step_history', 'att_hc_handle_step_history_ajax');
|
||||
add_action('wp_ajax_att_hc_save_step_ajax', 'att_hc_handle_save_step_ajax');
|
||||
add_action('admin_post_att_hc_save_step', 'att_hc_handle_save_step');
|
||||
add_action('admin_post_att_hc_finish', 'att_hc_handle_finish');
|
||||
add_action('admin_post_att_hc_save_next_due', 'att_hc_handle_save_next_due');
|
||||
add_action('admin_post_att_hc_discard', 'att_hc_handle_discard');
|
||||
add_action('admin_post_att_hc_download_report', 'att_hc_handle_download_report');
|
||||
add_action('admin_post_att_hc_refresh_checks', 'att_hc_handle_refresh_checks');
|
||||
@@ -53,6 +55,9 @@ function att_hc_inline_css(): string {
|
||||
.att-hc-status-skipped { background:#fff3cd; color:#856404; }
|
||||
.att-hc-status-blocked { background:#fbeae8; color:#721c24; }
|
||||
.att-hc-status-n_a { background:#e2e3e5; color:#41464b; }
|
||||
.att-hc-next-due { margin:.5rem 0 .75rem; padding:.5rem .75rem; background:#f6f7f7; border:1px solid #dcdcde; border-radius:4px; }
|
||||
.att-hc-next-due form { display:flex; flex-wrap:wrap; align-items:center; gap:.5rem; }
|
||||
.att-hc-next-due .description { flex-basis:100%; }
|
||||
.att-hc-step { padding:1rem 1.25rem; border:1px solid #dcdcde; border-radius:6px; margin-bottom:.75rem; background:#fff; }
|
||||
.att-hc-step header { display:flex; justify-content:space-between; align-items:center; gap:1rem; margin-bottom:.5rem; }
|
||||
.att-hc-step header h2 { margin:0; font-size:1.1rem; }
|
||||
@@ -133,6 +138,12 @@ function att_hc_render_admin_page(): void {
|
||||
echo '<div class="notice notice-error"><p><strong>Central history server:</strong> ' . esc_html($err) . '</p></div>';
|
||||
}
|
||||
|
||||
// Local (non-server) problems — bad input and the like.
|
||||
if ($err = get_transient('att_hc_error')) {
|
||||
delete_transient('att_hc_error');
|
||||
echo '<div class="notice notice-error is-dismissible"><p>' . esc_html($err) . '</p></div>';
|
||||
}
|
||||
|
||||
if (!$session) {
|
||||
att_hc_render_start_panel();
|
||||
echo '</div>';
|
||||
@@ -255,6 +266,7 @@ function att_hc_render_active_session(ATT_HC_Session $session): void {
|
||||
<span class="att-hc-progress"><?php echo (int) $progress['done']; ?> / <?php echo (int) $progress['total']; ?> steps</span>
|
||||
</p>
|
||||
<p>WP <code><?php echo esc_html($session->wp_version()); ?></code> · PHP <code><?php echo esc_html($session->php_version()); ?></code> · Site <code><?php echo esc_html($session->site_url()); ?></code></p>
|
||||
<?php att_hc_render_next_due_form($session); ?>
|
||||
<div class="att-hc-actions">
|
||||
<form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>" onsubmit="return confirm('Mark this healthcheck as finished?');" style="display:inline">
|
||||
<?php wp_nonce_field('att_hc_finish'); ?>
|
||||
@@ -285,6 +297,7 @@ function att_hc_render_active_session(ATT_HC_Session $session): void {
|
||||
}
|
||||
|
||||
att_hc_print_step_history_assets($session);
|
||||
att_hc_print_step_save_assets($session);
|
||||
|
||||
echo '<div class="att-hc-layout">';
|
||||
att_hc_render_sidebar($session);
|
||||
@@ -295,6 +308,30 @@ function att_hc_render_active_session(ATT_HC_Session $session): void {
|
||||
echo '</div></div>';
|
||||
}
|
||||
|
||||
/**
|
||||
* "Next healthcheck due" control. Shown on both the active and the finished
|
||||
* panel — the point at which you know when to come back is often wrap-up, which
|
||||
* may be after the report has already been generated.
|
||||
*/
|
||||
function att_hc_render_next_due_form(ATT_HC_Session $session): void {
|
||||
$due = $session->next_due();
|
||||
?>
|
||||
<div class="att-hc-next-due">
|
||||
<form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>">
|
||||
<?php wp_nonce_field('att_hc_save_next_due'); ?>
|
||||
<input type="hidden" name="action" value="att_hc_save_next_due">
|
||||
<label for="att-hc-next-due"><strong>Next healthcheck due:</strong></label>
|
||||
<input type="date" id="att-hc-next-due" name="next_due" value="<?php echo esc_attr((string) $due); ?>">
|
||||
<button class="button">Save date</button>
|
||||
<?php if ($due !== null) : ?>
|
||||
<button class="button button-link" name="next_due_clear" value="1">Clear</button>
|
||||
<?php endif; ?>
|
||||
<span class="description">Shown against this site on the healthcheck dashboard.</span>
|
||||
</form>
|
||||
</div>
|
||||
<?php
|
||||
}
|
||||
|
||||
function att_hc_print_step_history_assets(ATT_HC_Session $session): void {
|
||||
$cfg = [
|
||||
'ajaxUrl' => admin_url('admin-ajax.php'),
|
||||
@@ -350,6 +387,12 @@ function att_hc_print_step_history_assets(ATT_HC_Session $session): void {
|
||||
'</div>';
|
||||
}).join('');
|
||||
}
|
||||
// Emitted before the step cards, so defer wiring until the DOM exists.
|
||||
function ready(fn) {
|
||||
if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', fn); }
|
||||
else { fn(); }
|
||||
}
|
||||
ready(function () {
|
||||
document.querySelectorAll('details.att-hc-history').forEach(function (det) {
|
||||
det.addEventListener('toggle', function () {
|
||||
if (!det.open || det.dataset.loaded === 'yes') return;
|
||||
@@ -377,6 +420,125 @@ function att_hc_print_step_history_assets(ATT_HC_Session $session): void {
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
<?php
|
||||
}
|
||||
|
||||
/**
|
||||
* Client-side auto-save for the step cards: status saves on change, notes save
|
||||
* on blur, in place via AJAX. Progressive enhancement — the server-rendered
|
||||
* form still works with JS off, and the explicit "Save step" button is only
|
||||
* hidden once this wires up.
|
||||
*/
|
||||
function att_hc_print_step_save_assets(ATT_HC_Session $session): void {
|
||||
$cfg = [
|
||||
'ajaxUrl' => admin_url('admin-ajax.php'),
|
||||
'nonce' => wp_create_nonce('att_hc_save_step_ajax'),
|
||||
];
|
||||
?>
|
||||
<style>
|
||||
.att-hc-step-save.js-live button[type="submit"],
|
||||
.att-hc-step-save.js-live button:not([type]) { display: none; }
|
||||
.att-hc-save-status.is-saving { color: #646970; }
|
||||
.att-hc-save-status.is-saved { color: #1a8917; font-style: normal; }
|
||||
.att-hc-save-status.is-error { color: #b32d2e; font-style: normal; }
|
||||
.att-hc-step-save select.is-saving,
|
||||
.att-hc-step-save textarea.is-saving { opacity: .6; }
|
||||
</style>
|
||||
<script>
|
||||
(function () {
|
||||
var cfg = <?php echo wp_json_encode($cfg); ?>;
|
||||
function statusLabel(s) { return s.replace(/_/g, ' '); }
|
||||
|
||||
// These assets are emitted before the step cards in the markup, so the
|
||||
// forms don't exist yet when this inline script first runs. Wait for the
|
||||
// DOM before wiring listeners.
|
||||
function ready(fn) {
|
||||
if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', fn); }
|
||||
else { fn(); }
|
||||
}
|
||||
ready(function () {
|
||||
document.querySelectorAll('form.att-hc-step-save').forEach(function (form) {
|
||||
var stepId = form.dataset.step;
|
||||
var select = form.querySelector('select[name="status"]');
|
||||
var notes = form.querySelector('textarea[name="notes"]');
|
||||
var feedback = form.querySelector('.att-hc-save-status');
|
||||
var card = form.closest('.att-hc-step');
|
||||
if (!stepId || !select || !notes || !feedback) return;
|
||||
|
||||
// Signals JS is active: CSS hides the now-redundant Save button.
|
||||
form.classList.add('js-live');
|
||||
|
||||
// Last value we've successfully persisted, so a blur with no edit is a no-op.
|
||||
var savedNotes = notes.value;
|
||||
|
||||
function setFeedback(cls, text) {
|
||||
feedback.className = 'att-hc-save-status description ' + cls;
|
||||
feedback.textContent = text;
|
||||
}
|
||||
|
||||
function save(field) {
|
||||
var body = new FormData();
|
||||
body.append('action', 'att_hc_save_step_ajax');
|
||||
body.append('nonce', cfg.nonce);
|
||||
body.append('step', stepId);
|
||||
body.append('status', select.value);
|
||||
body.append('notes', notes.value);
|
||||
|
||||
field.classList.add('is-saving');
|
||||
setFeedback('is-saving', 'Saving…');
|
||||
|
||||
return fetch(cfg.ajaxUrl, { method: 'POST', credentials: 'same-origin', body: body })
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (j) {
|
||||
field.classList.remove('is-saving');
|
||||
if (!j || !j.success) {
|
||||
setFeedback('is-error', (j && j.data) || 'Save failed.');
|
||||
return;
|
||||
}
|
||||
savedNotes = notes.value;
|
||||
applyStatus(j.data.status, j.data.status_label);
|
||||
setFeedback('is-saved', 'Saved ✓');
|
||||
})
|
||||
.catch(function (e) {
|
||||
field.classList.remove('is-saving');
|
||||
setFeedback('is-error', 'Network error — not saved: ' + e.message);
|
||||
});
|
||||
}
|
||||
|
||||
// Reflect a status change in the header badge, escalation banner and
|
||||
// sidebar dot, matching what a full server render would have produced.
|
||||
// Recolouring the sidebar dot's class is enough for the link text too:
|
||||
// the `li:has(.dot-done) a` CSS rule turns it green / back to default.
|
||||
function applyStatus(status, label) {
|
||||
if (card) {
|
||||
var badge = card.querySelector('header .att-hc-step-status');
|
||||
if (badge) {
|
||||
badge.className = 'att-hc-step-status att-hc-status-' + status;
|
||||
badge.textContent = label || statusLabel(status);
|
||||
}
|
||||
var esc = card.querySelector('[data-role="escalation"]');
|
||||
if (esc) esc.hidden = (status !== 'blocked');
|
||||
}
|
||||
var dot = document.querySelector('.att-hc-sidebar .dot[data-step="' + stepId + '"]');
|
||||
if (dot) dot.className = 'dot dot-' + status;
|
||||
}
|
||||
|
||||
select.addEventListener('change', function () { save(select); });
|
||||
notes.addEventListener('blur', function () {
|
||||
if (notes.value === savedNotes) return; // nothing changed
|
||||
save(notes);
|
||||
});
|
||||
|
||||
// Enter / clicking the fallback button (if somehow visible): save in place.
|
||||
form.addEventListener('submit', function (e) {
|
||||
e.preventDefault();
|
||||
save(notes);
|
||||
});
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
<?php
|
||||
@@ -388,9 +550,10 @@ function att_hc_render_sidebar(ATT_HC_Session $session): void {
|
||||
$state = $session->step_state($step->id());
|
||||
$cls = 'dot-' . $state['status'];
|
||||
printf(
|
||||
'<li><span class="dot %s"></span><a href="#step-%s">%s</a></li>',
|
||||
'<li><span class="dot %s" data-step="%s"></span><a href="#step-%s">%s</a></li>',
|
||||
esc_attr($cls),
|
||||
esc_attr($step->id()),
|
||||
esc_attr($step->id()),
|
||||
esc_html($step->title())
|
||||
);
|
||||
}
|
||||
@@ -498,8 +661,9 @@ function att_hc_render_step_card(ATT_HC_Session $session, ATT_HC_Step $step, int
|
||||
</ul>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php if ($status === ATT_HC_Session::STATUS_BLOCKED && ($esc = $step->escalation())): ?>
|
||||
<div class="att-hc-escalation"><?php echo esc_html($esc); ?></div>
|
||||
<?php if ($esc = $step->escalation()): ?>
|
||||
<?php // Rendered always (hidden unless blocked) so an AJAX status change can toggle it without a reload. ?>
|
||||
<div class="att-hc-escalation" data-role="escalation"<?php echo $status === ATT_HC_Session::STATUS_BLOCKED ? '' : ' hidden'; ?>><?php echo esc_html($esc); ?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php
|
||||
@@ -510,7 +674,7 @@ function att_hc_render_step_card(ATT_HC_Session $session, ATT_HC_Step $step, int
|
||||
$step->render_extra($session->data());
|
||||
?>
|
||||
|
||||
<form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>" class="att-hc-step-save">
|
||||
<form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>" class="att-hc-step-save" data-step="<?php echo esc_attr($step->id()); ?>">
|
||||
<?php wp_nonce_field('att_hc_save_step_' . $step->id()); ?>
|
||||
<input type="hidden" name="action" value="att_hc_save_step">
|
||||
<input type="hidden" name="step" value="<?php echo esc_attr($step->id()); ?>">
|
||||
@@ -529,10 +693,13 @@ function att_hc_render_step_card(ATT_HC_Session $session, ATT_HC_Step $step, int
|
||||
<textarea name="notes" placeholder="What did you check, find, fix, or flag?"><?php echo esc_textarea($notes); ?></textarea>
|
||||
</p>
|
||||
<p>
|
||||
<?php // JS hides this button (saves happen on change/blur); it stays as the no-JS fallback. ?>
|
||||
<button class="button button-primary">Save step</button>
|
||||
<?php if ($state['updated_at']): ?>
|
||||
<span class="description">Last saved <?php echo esc_html(human_time_diff($state['updated_at'], time())); ?> ago</span>
|
||||
<?php endif; ?>
|
||||
<span class="att-hc-save-status description" aria-live="polite"><?php
|
||||
if ($state['updated_at']) {
|
||||
echo 'Last saved ' . esc_html(human_time_diff($state['updated_at'], time())) . ' ago';
|
||||
}
|
||||
?></span>
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
@@ -546,6 +713,7 @@ function att_hc_render_finished_panel(ATT_HC_Session $session): void {
|
||||
<div class="att-hc-card">
|
||||
<h2>Healthcheck finished</h2>
|
||||
<p>Started <?php echo esc_html(date('Y-m-d H:i', $session->started_at())); ?> · Finished <?php echo esc_html(date('Y-m-d H:i', (int) $session->finished_at())); ?></p>
|
||||
<?php att_hc_render_next_due_form($session); ?>
|
||||
<div class="att-hc-actions">
|
||||
<form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>" style="display:inline">
|
||||
<?php wp_nonce_field('att_hc_download_report'); ?>
|
||||
@@ -601,6 +769,41 @@ function att_hc_handle_start(): void {
|
||||
exit;
|
||||
}
|
||||
|
||||
function att_hc_handle_save_next_due(): void {
|
||||
if (!current_user_can('manage_options')) wp_die('Forbidden');
|
||||
check_admin_referer('att_hc_save_next_due');
|
||||
|
||||
$redirect = admin_url('tools.php?page=att-site-healthcheck');
|
||||
$session = ATT_HC_Session::current();
|
||||
if (!$session) wp_die('No active session.');
|
||||
|
||||
$clear = !empty($_POST['next_due_clear']);
|
||||
$raw = isset($_POST['next_due']) ? sanitize_text_field(wp_unslash((string) $_POST['next_due'])) : '';
|
||||
$date = $clear ? null : ATT_HC_Session::sanitise_due_date($raw);
|
||||
|
||||
// Distinguish "cleared the field" (legitimate) from "typed something that
|
||||
// isn't a date" — silently clearing on bad input would lose the existing date.
|
||||
if (!$clear && $raw !== '' && $date === null) {
|
||||
set_transient('att_hc_error', 'Could not save the next healthcheck date: "' . $raw . '" is not a valid date (expected YYYY-MM-DD).', 60);
|
||||
wp_safe_redirect($redirect);
|
||||
exit;
|
||||
}
|
||||
|
||||
try {
|
||||
$session->set_next_due($date);
|
||||
set_transient(
|
||||
'att_hc_install_message',
|
||||
$date === null ? 'Next healthcheck date cleared.' : 'Next healthcheck due ' . $date . '.',
|
||||
60
|
||||
);
|
||||
} catch (ATT_HC_Api_Exception $e) {
|
||||
set_transient('att_hc_api_error', 'Could not save the next healthcheck date: ' . $e->getMessage(), 60);
|
||||
}
|
||||
|
||||
wp_safe_redirect($redirect);
|
||||
exit;
|
||||
}
|
||||
|
||||
function att_hc_handle_step_history_ajax(): void {
|
||||
if (!current_user_can('manage_options')) wp_send_json_error('Forbidden', 403);
|
||||
if (!check_ajax_referer('att_hc_step_history', 'nonce', false)) {
|
||||
@@ -654,6 +857,45 @@ function att_hc_handle_save_step(): void {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* AJAX twin of att_hc_handle_save_step(). Same validation and write-through,
|
||||
* but returns JSON instead of redirecting, so the reporting page can save a
|
||||
* step in place (on status change / notes blur) without a full reload.
|
||||
*
|
||||
* The non-AJAX form POST above is kept as the no-JS fallback.
|
||||
*/
|
||||
function att_hc_handle_save_step_ajax(): void {
|
||||
if (!current_user_can('manage_options')) wp_send_json_error('Forbidden', 403);
|
||||
if (!check_ajax_referer('att_hc_save_step_ajax', 'nonce', false)) {
|
||||
wp_send_json_error('Bad nonce — reload the page and try again.', 403);
|
||||
}
|
||||
$step_id = isset($_POST['step']) ? sanitize_key((string) $_POST['step']) : '';
|
||||
if ($step_id === '') wp_send_json_error('Missing step id', 400);
|
||||
|
||||
$session = ATT_HC_Session::current();
|
||||
if (!$session || $session->is_finished()) wp_send_json_error('No active session', 409);
|
||||
if (!ATT_HC_Steps::instance()->get($step_id)) wp_send_json_error('Unknown step', 400);
|
||||
|
||||
$status = isset($_POST['status']) ? sanitize_key((string) $_POST['status']) : ATT_HC_Session::STATUS_NOT_STARTED;
|
||||
$notes = isset($_POST['notes']) ? wp_unslash((string) $_POST['notes']) : '';
|
||||
|
||||
try {
|
||||
// update_step() re-validates the status and falls back to NOT_STARTED
|
||||
// for anything unexpected, so we report back whatever was actually stored.
|
||||
$session->update_step($step_id, $status, $notes);
|
||||
} catch (ATT_HC_Api_Exception $e) {
|
||||
wp_send_json_error('Not saved — the central server rejected the write: ' . $e->getMessage(), 502);
|
||||
}
|
||||
|
||||
$state = $session->step_state($step_id);
|
||||
wp_send_json_success([
|
||||
'step' => $step_id,
|
||||
'status' => (string) $state['status'],
|
||||
'status_label' => str_replace('_', ' ', (string) $state['status']),
|
||||
'updated_at' => (int) $state['updated_at'],
|
||||
]);
|
||||
}
|
||||
|
||||
function att_hc_handle_finish(): void {
|
||||
if (!current_user_can('manage_options')) wp_die('Forbidden');
|
||||
check_admin_referer('att_hc_finish');
|
||||
|
||||
@@ -70,6 +70,7 @@ final class ATT_HC_Session {
|
||||
'site_url' => $reporting_url,
|
||||
'wp_version' => get_bloginfo('version'),
|
||||
'php_version' => PHP_VERSION,
|
||||
'next_due' => null,
|
||||
'steps' => [],
|
||||
];
|
||||
|
||||
@@ -180,6 +181,12 @@ final class ATT_HC_Session {
|
||||
public function php_version(): string { return (string) ($this->data['php_version'] ?? ''); }
|
||||
public function data(): array { return $this->data; }
|
||||
|
||||
/** Scheduled date of the next healthcheck for this site, as 'YYYY-MM-DD', or null. */
|
||||
public function next_due(): ?string {
|
||||
$raw = $this->data['next_due'] ?? null;
|
||||
return is_string($raw) && $raw !== '' ? $raw : null;
|
||||
}
|
||||
|
||||
public function step_state(string $step_id): array {
|
||||
return $this->data['steps'][$step_id] ?? [
|
||||
'status' => self::STATUS_NOT_STARTED,
|
||||
@@ -214,6 +221,40 @@ final class ATT_HC_Session {
|
||||
update_option(ATT_HC_OPT_SESSION, $this->data, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule (or clear, with null) the next healthcheck for this site.
|
||||
*
|
||||
* Write-through like every other mutation: the server PUT has to succeed
|
||||
* before the local cache moves. Allowed on a finished session too — the
|
||||
* natural moment to decide when to come back is at wrap-up, which may be
|
||||
* after the report has been generated.
|
||||
*
|
||||
* @param string|null $date 'YYYY-MM-DD', or null to clear.
|
||||
* @throws ATT_HC_Api_Exception
|
||||
*/
|
||||
public function set_next_due(?string $date): void {
|
||||
$date = $date === null ? null : self::sanitise_due_date($date);
|
||||
ATT_HC_Api::update_healthcheck($this->id(), ['next_due' => $date]);
|
||||
$this->data['next_due'] = $date;
|
||||
update_option(ATT_HC_OPT_SESSION, $this->data, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalise a user-entered date to 'YYYY-MM-DD', or null if it isn't a real
|
||||
* calendar date in that format.
|
||||
*
|
||||
* The round-trip comparison is what makes this strict: createFromFormat
|
||||
* happily accepts '2026-2-3' and rolls '2026-02-30' forward into March, and
|
||||
* both re-format to something other than what was typed.
|
||||
*/
|
||||
public static function sanitise_due_date(string $raw): ?string {
|
||||
$raw = trim($raw);
|
||||
if ($raw === '') return null;
|
||||
$date = DateTimeImmutable::createFromFormat('!Y-m-d', $raw);
|
||||
if ($date === false || $date->format('Y-m-d') !== $raw) return null;
|
||||
return $raw;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark the session finished. Server PUT first, then cache update.
|
||||
*
|
||||
@@ -294,6 +335,9 @@ final class ATT_HC_Session {
|
||||
'site_url' => (string) $remote['reporting_url'],
|
||||
'wp_version' => (string) ($remote['wp_version'] ?? ''),
|
||||
'php_version' => (string) ($remote['php_version'] ?? ''),
|
||||
'next_due' => isset($remote['next_due']) && $remote['next_due'] !== null && $remote['next_due'] !== ''
|
||||
? (string) $remote['next_due']
|
||||
: null,
|
||||
'steps' => [],
|
||||
'autocheck' => [],
|
||||
];
|
||||
|
||||
@@ -23,6 +23,9 @@ function att_hc_build_markdown_report(ATT_HC_Session $session): string {
|
||||
$lines[] = '- **Site:** ' . $session->site_url();
|
||||
$lines[] = '- **WordPress:** ' . $session->wp_version();
|
||||
$lines[] = '- **PHP:** ' . $session->php_version();
|
||||
if ($next_due = $session->next_due()) {
|
||||
$lines[] = '- **Next healthcheck due:** ' . $next_due;
|
||||
}
|
||||
$lines[] = '- **Session ID:** ' . $session->id();
|
||||
$lines[] = '';
|
||||
|
||||
@@ -144,6 +147,9 @@ function att_hc_build_html_report(ATT_HC_Session $session): string {
|
||||
<tr><th>Technician</th><td><?php echo esc_html($tech_name); ?></td></tr>
|
||||
<tr><th>WordPress</th><td><?php echo esc_html($session->wp_version()); ?></td></tr>
|
||||
<tr><th>PHP</th><td><?php echo esc_html($session->php_version()); ?></td></tr>
|
||||
<?php if ($next_due = $session->next_due()) : ?>
|
||||
<tr><th>Next healthcheck due</th><td><?php echo esc_html($next_due); ?></td></tr>
|
||||
<?php endif; ?>
|
||||
</table>
|
||||
|
||||
<h2>Summary</h2>
|
||||
|
||||
18
server/migrations/0002_next_due.sql
Normal file
18
server/migrations/0002_next_due.sql
Normal file
@@ -0,0 +1,18 @@
|
||||
-- Next healthcheck due date, recorded against the session that scheduled it.
|
||||
--
|
||||
-- Stored as a VARCHAR(10) 'YYYY-MM-DD' calendar date rather than a BIGINT unix
|
||||
-- timestamp on purpose: this is a diary date a human picked ("look at this site
|
||||
-- again in October"), not an instant. A timestamp would drag timezone handling
|
||||
-- into something that has no time-of-day component, and would render as the
|
||||
-- wrong day for anyone east or west of the server.
|
||||
--
|
||||
-- Kept per-session (not on a separate sites table) so the history of what was
|
||||
-- scheduled when is preserved. A site's *current* next-due is the value on its
|
||||
-- most recent session — see Store::allSitesSummary().
|
||||
--
|
||||
-- Portable: both MySQL 8 and SQLite accept ALTER TABLE ... ADD COLUMN with a
|
||||
-- nullable typed column and no default. Neither supports ADD COLUMN IF NOT
|
||||
-- EXISTS in a form the other understands, which is fine — Migrations.php only
|
||||
-- ever applies each file once.
|
||||
|
||||
ALTER TABLE healthchecks ADD COLUMN next_due VARCHAR(10) NULL;
|
||||
@@ -149,6 +149,7 @@ final class Dashboard {
|
||||
$rows .= '<tr>
|
||||
<td><a href="' . $url . '">' . htmlspecialchars($s['site_key']) . '</a></td>
|
||||
<td>' . $badge . '</td>
|
||||
<td>' . self::nextDueCell($s['next_due'] ?? null) . '</td>
|
||||
<td>' . $lastDate . '</td>
|
||||
<td>' . $sessionStr . '</td>
|
||||
<td class="muted small">' . htmlspecialchars($s['last_reporting_url']) . '</td>
|
||||
@@ -158,7 +159,7 @@ final class Dashboard {
|
||||
<h2>All sites <span class="muted small">(' . count($sites) . ')</span></h2>
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th>Site key</th><th>Latest</th><th>Last session started</th>
|
||||
<th>Site key</th><th>Latest</th><th>Next due</th><th>Last session started</th>
|
||||
<th>Sessions</th><th>Last reporting URL</th>
|
||||
</tr></thead>
|
||||
<tbody>' . $rows . '</tbody>
|
||||
@@ -187,6 +188,7 @@ final class Dashboard {
|
||||
<td>' . $badge . '</td>
|
||||
<td>' . ($finished ?? '<span class="muted">—</span>') . '</td>
|
||||
<td>' . ($duration !== '' ? $duration : '<span class="muted">—</span>') . '</td>
|
||||
<td>' . self::nextDueCell(isset($hc['next_due']) ? (string) $hc['next_due'] : null) . '</td>
|
||||
<td class="muted small">' . htmlspecialchars((string) $hc['reporting_url']) . '</td>
|
||||
</tr>';
|
||||
}
|
||||
@@ -196,7 +198,7 @@ final class Dashboard {
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th>Started</th><th>Status</th><th>Finished</th>
|
||||
<th>Duration</th><th>Reporting URL</th>
|
||||
<th>Duration</th><th>Next due</th><th>Reporting URL</th>
|
||||
</tr></thead>
|
||||
<tbody>' . $rows . '</tbody>
|
||||
</table>';
|
||||
@@ -232,6 +234,7 @@ final class Dashboard {
|
||||
<span>Started: <strong>' . $started . '</strong></span>'
|
||||
. ($finished !== null ? '<span>Finished: <strong>' . $finished . '</strong></span>' : '')
|
||||
. ($duration !== null ? '<span>Duration: <strong>' . $duration . '</strong></span>' : '')
|
||||
. '<span>Next due: ' . self::nextDueCell(isset($hc['next_due']) ? (string) $hc['next_due'] : null) . '</span>'
|
||||
. '<span class="muted small">ID: ' . htmlspecialchars($hcId) . '</span>'
|
||||
. $reopenForm
|
||||
. '</div>';
|
||||
@@ -286,6 +289,32 @@ final class Dashboard {
|
||||
return '<span class="badge ' . $cls . '">' . $label . '</span>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Next-due date with urgency styling, or an em-dash when nothing is scheduled.
|
||||
*
|
||||
* Comparison is a plain string compare on YYYY-MM-DD — that sorts correctly
|
||||
* by construction and keeps timezone maths out of a value that has no
|
||||
* time-of-day component.
|
||||
*/
|
||||
private static function nextDueCell(?string $nextDue): string {
|
||||
if ($nextDue === null || $nextDue === '') {
|
||||
return '<span class="muted">—</span>';
|
||||
}
|
||||
$safe = htmlspecialchars($nextDue);
|
||||
$today = date('Y-m-d');
|
||||
|
||||
if ($nextDue < $today) {
|
||||
return '<span class="badge due-overdue">' . $safe . ' · overdue</span>';
|
||||
}
|
||||
if ($nextDue === $today) {
|
||||
return '<span class="badge due-soon">' . $safe . ' · today</span>';
|
||||
}
|
||||
if ($nextDue <= date('Y-m-d', strtotime('+14 days'))) {
|
||||
return '<span class="badge due-soon">' . $safe . ' · soon</span>';
|
||||
}
|
||||
return '<span class="badge due-ok">' . $safe . '</span>';
|
||||
}
|
||||
|
||||
private static function formatStepId(string $id): string {
|
||||
return ucwords(str_replace(['-', '_'], ' ', $id));
|
||||
}
|
||||
@@ -333,6 +362,9 @@ a:hover { color: #135e96; text-decoration: underline; }
|
||||
.status-blocked { background: #fce8e8; color: #8c2020; }
|
||||
.status-na { background: #f0f0f1; color: #646970; }
|
||||
.status-not-started { background: #f6f7f7; color: #8c8f94; border: 1px solid #dcdcde; }
|
||||
.due-overdue { background: #fce8e8; color: #8c2020; }
|
||||
.due-soon { background: #fef9e7; color: #8a6d01; }
|
||||
.due-ok { background: #eef3fb; color: #1c4f8c; }
|
||||
.muted { color: #646970; }
|
||||
.small { font-size: .85em; }
|
||||
.error { color: #b32d2e; }
|
||||
|
||||
@@ -19,6 +19,7 @@ final class Healthchecks {
|
||||
'technician_id' => Validate::optionalInt($body, 'technician_id'),
|
||||
'wp_version' => Validate::optionalString($body, 'wp_version', 32),
|
||||
'php_version' => Validate::optionalString($body, 'php_version', 32),
|
||||
'next_due' => Validate::optionalDate($body, 'next_due'),
|
||||
];
|
||||
|
||||
try {
|
||||
@@ -49,6 +50,11 @@ final class Healthchecks {
|
||||
$finishedAt = Validate::optionalInt($body, 'finished_at');
|
||||
Store::updateHealthcheck($id, $finishedAt);
|
||||
}
|
||||
// Same missing-vs-null contract as finished_at: absent leaves the
|
||||
// scheduled date alone, explicit null (or '') clears it.
|
||||
if (array_key_exists('next_due', $body)) {
|
||||
Store::setNextDue($id, Validate::optionalDate($body, 'next_due'));
|
||||
}
|
||||
Http::json(200, ['ok' => true]);
|
||||
}
|
||||
|
||||
|
||||
@@ -11,8 +11,8 @@ final class Store {
|
||||
public static function insertHealthcheck(array $row): void {
|
||||
$now = time();
|
||||
$stmt = Db::pdo()->prepare('INSERT INTO healthchecks
|
||||
(id, site_key, started_at, finished_at, technician_id, reporting_url, wp_version, php_version, created_at, updated_at)
|
||||
VALUES (:id, :site_key, :started_at, NULL, :technician_id, :reporting_url, :wp_version, :php_version, :created_at, :updated_at)');
|
||||
(id, site_key, started_at, finished_at, technician_id, reporting_url, wp_version, php_version, next_due, created_at, updated_at)
|
||||
VALUES (:id, :site_key, :started_at, NULL, :technician_id, :reporting_url, :wp_version, :php_version, :next_due, :created_at, :updated_at)');
|
||||
$stmt->execute([
|
||||
':id' => $row['id'],
|
||||
':site_key' => $row['site_key'],
|
||||
@@ -21,6 +21,7 @@ final class Store {
|
||||
':reporting_url' => $row['reporting_url'],
|
||||
':wp_version' => $row['wp_version'] ?? null,
|
||||
':php_version' => $row['php_version'] ?? null,
|
||||
':next_due' => $row['next_due'] ?? null,
|
||||
':created_at' => $now,
|
||||
':updated_at' => $now,
|
||||
]);
|
||||
@@ -43,6 +44,22 @@ final class Store {
|
||||
return $stmt->rowCount() > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set (or clear, with null) the next-due date on one healthcheck.
|
||||
*
|
||||
* Deliberately separate from updateHealthcheck() so that finishing or
|
||||
* reopening a session never touches the scheduled date, and vice versa.
|
||||
*/
|
||||
public static function setNextDue(string $id, ?string $nextDue): bool {
|
||||
$stmt = Db::pdo()->prepare('UPDATE healthchecks SET next_due = :next_due, updated_at = :updated_at WHERE id = :id');
|
||||
$stmt->execute([
|
||||
':id' => $id,
|
||||
':next_due' => $nextDue,
|
||||
':updated_at' => time(),
|
||||
]);
|
||||
return $stmt->rowCount() > 0;
|
||||
}
|
||||
|
||||
public static function upsertStep(string $healthcheckId, string $stepId, array $row): void {
|
||||
$pdo = Db::pdo();
|
||||
$driver = $pdo->getAttribute(\PDO::ATTR_DRIVER_NAME);
|
||||
@@ -180,7 +197,12 @@ final class Store {
|
||||
/**
|
||||
* Summary of all sites for the dashboard: one row per site_key,
|
||||
* ordered by most-recently-active first.
|
||||
* @return array<int, array{site_key:string, total:int, finished:int, last_started_at:int, last_finished_at:int|null, last_reporting_url:string}>
|
||||
* `next_due` is taken from the most recent session, not from the most recent
|
||||
* session that happens to have one set. If the latest visit didn't schedule a
|
||||
* return, the site genuinely has nothing scheduled — carrying an older date
|
||||
* forward would show a visit as overdue immediately after it was completed.
|
||||
*
|
||||
* @return array<int, array{site_key:string, total:int, finished:int, last_started_at:int, last_finished_at:int|null, last_reporting_url:string, next_due:string|null}>
|
||||
*/
|
||||
public static function allSitesSummary(): array {
|
||||
$stmt = Db::pdo()->query(
|
||||
@@ -198,7 +220,7 @@ final class Store {
|
||||
$out = [];
|
||||
foreach ($rows as $r) {
|
||||
$detail = Db::pdo()->prepare(
|
||||
'SELECT reporting_url, finished_at FROM healthchecks
|
||||
'SELECT reporting_url, finished_at, next_due FROM healthchecks
|
||||
WHERE site_key = ? ORDER BY started_at DESC LIMIT 1'
|
||||
);
|
||||
$detail->execute([$r['site_key']]);
|
||||
@@ -210,6 +232,9 @@ final class Store {
|
||||
'last_started_at' => (int) $r['last_started_at'],
|
||||
'last_finished_at' => isset($last['finished_at']) && $last['finished_at'] !== null ? (int) $last['finished_at'] : null,
|
||||
'last_reporting_url' => (string) ($last['reporting_url'] ?? ''),
|
||||
'next_due' => isset($last['next_due']) && $last['next_due'] !== null && $last['next_due'] !== ''
|
||||
? (string) $last['next_due']
|
||||
: null,
|
||||
];
|
||||
}
|
||||
return $out;
|
||||
|
||||
@@ -38,6 +38,29 @@ final class Validate {
|
||||
return $body[$key];
|
||||
}
|
||||
|
||||
/**
|
||||
* A calendar date as 'YYYY-MM-DD', or null.
|
||||
*
|
||||
* Absent, null, and '' all mean "no date" (the plugin sends '' when the tech
|
||||
* clears the field). Anything else must be a real date in exactly that
|
||||
* format — the round-trip comparison rejects both sloppy input ('2026-2-3')
|
||||
* and impossible dates ('2026-02-30', which createFromFormat would silently
|
||||
* roll forward to March 2nd).
|
||||
*/
|
||||
public static function optionalDate(array $body, string $key): ?string {
|
||||
if (!isset($body[$key])) return null;
|
||||
if (!is_string($body[$key])) self::fail("{$key} must be a YYYY-MM-DD date string or null");
|
||||
|
||||
$value = trim($body[$key]);
|
||||
if ($value === '') return null;
|
||||
|
||||
$date = \DateTimeImmutable::createFromFormat('!Y-m-d', $value);
|
||||
if ($date === false || $date->format('Y-m-d') !== $value) {
|
||||
self::fail("{$key} must be a valid calendar date in YYYY-MM-DD format");
|
||||
}
|
||||
return $value;
|
||||
}
|
||||
|
||||
public static function status(string $status): string {
|
||||
$valid = ['not_started', 'done', 'skipped', 'blocked', 'n_a'];
|
||||
if (!in_array($status, $valid, true)) self::fail('status must be one of ' . implode(', ', $valid));
|
||||
|
||||
109
tests/lib/harness.php
Normal file
109
tests/lib/harness.php
Normal file
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Minimal test harness — no composer, no PHPUnit, matching the rest of the repo.
|
||||
*
|
||||
* Usage:
|
||||
* test('description', function () { assert_same($expected, $actual, 'why'); });
|
||||
*
|
||||
* Failures are recorded and reported at the end; the process exits non-zero if
|
||||
* anything failed, so this is usable as a CI/pre-push gate.
|
||||
*/
|
||||
|
||||
final class TestRun {
|
||||
/** @var array<int, array{name:string, error:string}> */
|
||||
public static array $failures = [];
|
||||
public static int $passed = 0;
|
||||
public static int $assertions = 0;
|
||||
public static string $group = '';
|
||||
}
|
||||
|
||||
final class AssertionFailed extends RuntimeException {}
|
||||
|
||||
function group(string $name): void {
|
||||
TestRun::$group = $name;
|
||||
echo "\n\033[1m" . $name . "\033[0m\n";
|
||||
}
|
||||
|
||||
function test(string $name, callable $fn): void {
|
||||
try {
|
||||
$fn();
|
||||
TestRun::$passed++;
|
||||
echo " \033[32m✓\033[0m " . $name . "\n";
|
||||
} catch (AssertionFailed $e) {
|
||||
TestRun::$failures[] = ['name' => TestRun::$group . ' › ' . $name, 'error' => $e->getMessage()];
|
||||
echo " \033[31m✗ " . $name . "\033[0m\n " . str_replace("\n", "\n ", $e->getMessage()) . "\n";
|
||||
} catch (Throwable $e) {
|
||||
$where = basename($e->getFile()) . ':' . $e->getLine();
|
||||
TestRun::$failures[] = ['name' => TestRun::$group . ' › ' . $name, 'error' => get_class($e) . ': ' . $e->getMessage() . ' @ ' . $where];
|
||||
echo " \033[31m✗ " . $name . "\033[0m\n " . get_class($e) . ': ' . $e->getMessage() . ' @ ' . $where . "\n";
|
||||
}
|
||||
}
|
||||
|
||||
function assert_same($expected, $actual, string $message = ''): void {
|
||||
TestRun::$assertions++;
|
||||
if ($expected !== $actual) {
|
||||
throw new AssertionFailed(
|
||||
($message !== '' ? $message . "\n" : '')
|
||||
. 'expected: ' . compact_export($expected) . "\n"
|
||||
. 'actual: ' . compact_export($actual)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function assert_true($actual, string $message = ''): void {
|
||||
assert_same(true, $actual, $message !== '' ? $message : 'expected true');
|
||||
}
|
||||
|
||||
function assert_null($actual, string $message = ''): void {
|
||||
assert_same(null, $actual, $message !== '' ? $message : 'expected null');
|
||||
}
|
||||
|
||||
function assert_contains(string $needle, string $haystack, string $message = ''): void {
|
||||
TestRun::$assertions++;
|
||||
if (strpos($haystack, $needle) === false) {
|
||||
throw new AssertionFailed(
|
||||
($message !== '' ? $message . "\n" : '')
|
||||
. 'expected to find: ' . $needle . "\n"
|
||||
. 'in: ' . truncate($haystack, 600)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function assert_not_contains(string $needle, string $haystack, string $message = ''): void {
|
||||
TestRun::$assertions++;
|
||||
if (strpos($haystack, $needle) !== false) {
|
||||
throw new AssertionFailed(
|
||||
($message !== '' ? $message . "\n" : '')
|
||||
. 'did NOT expect to find: ' . $needle . "\n"
|
||||
. 'in: ' . truncate($haystack, 600)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function compact_export($value): string {
|
||||
if (is_string($value)) return "'" . $value . "'";
|
||||
if ($value === null) return 'null';
|
||||
if (is_bool($value)) return $value ? 'true' : 'false';
|
||||
if (is_array($value)) return truncate(json_encode($value, JSON_UNESCAPED_SLASHES), 400);
|
||||
return (string) $value;
|
||||
}
|
||||
|
||||
function truncate(string $s, int $max): string {
|
||||
return strlen($s) <= $max ? $s : substr($s, 0, $max) . '… (' . strlen($s) . ' bytes)';
|
||||
}
|
||||
|
||||
function report_and_exit(): void {
|
||||
$failed = count(TestRun::$failures);
|
||||
echo "\n" . str_repeat('─', 60) . "\n";
|
||||
if ($failed === 0) {
|
||||
echo "\033[32mPASS\033[0m — " . TestRun::$passed . ' tests, ' . TestRun::$assertions . " assertions\n";
|
||||
exit(0);
|
||||
}
|
||||
echo "\033[31mFAIL\033[0m — " . $failed . ' failed, ' . TestRun::$passed . " passed\n\n";
|
||||
foreach (TestRun::$failures as $f) {
|
||||
echo " • " . $f['name'] . "\n " . str_replace("\n", "\n ", $f['error']) . "\n\n";
|
||||
}
|
||||
exit(1);
|
||||
}
|
||||
213
tests/lib/test-server.php
Normal file
213
tests/lib/test-server.php
Normal file
@@ -0,0 +1,213 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Boots a throwaway instance of the history server for integration tests.
|
||||
*
|
||||
* The whole server/ tree is copied to a temp directory and given its own
|
||||
* config.php + SQLite file, so a developer's real server/config.php (which is
|
||||
* gitignored and may point at a live MySQL database) is never touched or read.
|
||||
*
|
||||
* Requires pdo_sqlite. Tests that need it are skipped with a clear message if
|
||||
* the extension is missing, rather than failing misleadingly.
|
||||
*/
|
||||
final class TestServer {
|
||||
|
||||
public const API_KEY = 'test-api-key-0123456789abcdef0123456789abcdef';
|
||||
|
||||
private string $tmpDir;
|
||||
private string $baseUrl;
|
||||
private string $dbPath;
|
||||
/** @var resource|null */
|
||||
private $process = null;
|
||||
/** @var array<int, resource> */
|
||||
private array $pipes = [];
|
||||
private string $logPath;
|
||||
private string $repoRoot;
|
||||
|
||||
public static function sqliteAvailable(): bool {
|
||||
return class_exists('PDO') && in_array('sqlite', PDO::getAvailableDrivers(), true);
|
||||
}
|
||||
|
||||
// No constructor promotion: the harness stays PHP 7.4-runnable so the plugin
|
||||
// unit tests can be executed on the oldest version the plugin supports.
|
||||
public function __construct(string $repoRoot) {
|
||||
$this->repoRoot = $repoRoot;
|
||||
$this->tmpDir = sys_get_temp_dir() . '/att-hc-tests-' . getmypid() . '-' . bin2hex(random_bytes(4));
|
||||
$this->dbPath = $this->tmpDir . '/data/test.sqlite';
|
||||
$this->logPath = $this->tmpDir . '/php-server.log';
|
||||
}
|
||||
|
||||
public function baseUrl(): string { return $this->baseUrl; }
|
||||
public function dbPath(): string { return $this->dbPath; }
|
||||
|
||||
public function start(): void {
|
||||
$this->copyTree($this->repoRoot . '/server', $this->tmpDir, ['data', 'config.php']);
|
||||
@mkdir($this->tmpDir . '/data', 0777, true);
|
||||
|
||||
file_put_contents($this->tmpDir . '/config.php', "<?php\nreturn " . var_export([
|
||||
'api_key' => self::API_KEY,
|
||||
'db_dsn' => 'sqlite:' . $this->dbPath,
|
||||
'db_user' => null,
|
||||
'db_pass' => null,
|
||||
'version' => 'test',
|
||||
], true) . ";\n");
|
||||
|
||||
$port = $this->freePort();
|
||||
$this->baseUrl = 'http://127.0.0.1:' . $port;
|
||||
|
||||
$cmd = sprintf(
|
||||
'%s -S 127.0.0.1:%d -t %s %s',
|
||||
escapeshellarg(PHP_BINARY),
|
||||
$port,
|
||||
escapeshellarg($this->tmpDir . '/public'),
|
||||
escapeshellarg($this->tmpDir . '/dev-router.php')
|
||||
);
|
||||
|
||||
$this->process = proc_open(
|
||||
$cmd,
|
||||
[0 => ['file', '/dev/null', 'r'], 1 => ['file', $this->logPath, 'a'], 2 => ['file', $this->logPath, 'a']],
|
||||
$this->pipes
|
||||
);
|
||||
if (!is_resource($this->process)) {
|
||||
throw new RuntimeException('could not start php -S');
|
||||
}
|
||||
|
||||
$this->waitUntilUp();
|
||||
}
|
||||
|
||||
public function stop(): void {
|
||||
if (is_resource($this->process)) {
|
||||
proc_terminate($this->process);
|
||||
proc_close($this->process);
|
||||
$this->process = null;
|
||||
}
|
||||
$this->deleteTree($this->tmpDir);
|
||||
}
|
||||
|
||||
/** Server stdout/stderr — useful when a test fails for a non-obvious reason. */
|
||||
public function log(): string {
|
||||
return is_file($this->logPath) ? (string) file_get_contents($this->logPath) : '';
|
||||
}
|
||||
|
||||
private function waitUntilUp(): void {
|
||||
$deadline = microtime(true) + 15.0;
|
||||
while (microtime(true) < $deadline) {
|
||||
$ctx = stream_context_create(['http' => ['timeout' => 1, 'ignore_errors' => true]]);
|
||||
$body = @file_get_contents($this->baseUrl . '/', false, $ctx);
|
||||
if ($body !== false && strpos($body, '"ok"') !== false) {
|
||||
return;
|
||||
}
|
||||
usleep(100_000);
|
||||
}
|
||||
throw new RuntimeException("test server did not come up at {$this->baseUrl}\n" . $this->log());
|
||||
}
|
||||
|
||||
private function freePort(): int {
|
||||
$sock = @stream_socket_server('tcp://127.0.0.1:0', $errno, $errstr);
|
||||
if ($sock === false) {
|
||||
throw new RuntimeException("could not find a free port: {$errstr}");
|
||||
}
|
||||
$name = (string) stream_socket_get_name($sock, false);
|
||||
fclose($sock);
|
||||
return (int) substr($name, strrpos($name, ':') + 1);
|
||||
}
|
||||
|
||||
/** @param array<int,string> $skip basenames to skip at the top level */
|
||||
private function copyTree(string $src, string $dst, array $skip = []): void {
|
||||
@mkdir($dst, 0777, true);
|
||||
foreach (scandir($src) ?: [] as $entry) {
|
||||
if ($entry === '.' || $entry === '..' || in_array($entry, $skip, true)) continue;
|
||||
$from = $src . '/' . $entry;
|
||||
$to = $dst . '/' . $entry;
|
||||
if (is_dir($from)) {
|
||||
$this->copyTree($from, $to);
|
||||
} else {
|
||||
copy($from, $to);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function deleteTree(string $dir): void {
|
||||
if (!is_dir($dir)) return;
|
||||
foreach (scandir($dir) ?: [] as $entry) {
|
||||
if ($entry === '.' || $entry === '..') continue;
|
||||
$path = $dir . '/' . $entry;
|
||||
is_dir($path) ? $this->deleteTree($path) : @unlink($path);
|
||||
}
|
||||
@rmdir($dir);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tiny HTTP client. Returns ['status'=>int, 'body'=>string, 'json'=>array|null,
|
||||
* 'headers'=>array<int,string>].
|
||||
*
|
||||
* Streams rather than curl so the suite has no extension requirements beyond
|
||||
* what the server itself needs. follow_location is off so redirects (the
|
||||
* dashboard's key → cookie handoff) are observable.
|
||||
*/
|
||||
function http_call(
|
||||
string $baseUrl,
|
||||
string $method,
|
||||
string $path,
|
||||
?array $body = null,
|
||||
?string $apiKey = TestServer::API_KEY,
|
||||
array $extraHeaders = []
|
||||
): array {
|
||||
$headers = ['Accept: application/json'];
|
||||
if ($apiKey !== null) $headers[] = 'Authorization: Bearer ' . $apiKey;
|
||||
foreach ($extraHeaders as $h) $headers[] = $h;
|
||||
|
||||
$http = [
|
||||
'method' => $method,
|
||||
'ignore_errors' => true,
|
||||
'follow_location' => 0,
|
||||
'max_redirects' => 1,
|
||||
'timeout' => 10,
|
||||
];
|
||||
if ($body !== null) {
|
||||
$headers[] = 'Content-Type: application/json';
|
||||
$http['content'] = json_encode($body, JSON_UNESCAPED_SLASHES);
|
||||
}
|
||||
$http['header'] = implode("\r\n", $headers);
|
||||
|
||||
$raw = @file_get_contents($baseUrl . $path, false, stream_context_create(['http' => $http]));
|
||||
$responseHeaders = $http_response_header ?? [];
|
||||
|
||||
$status = 0;
|
||||
foreach ($responseHeaders as $h) {
|
||||
if (preg_match('#^HTTP/\S+\s+(\d{3})#', $h, $m)) $status = (int) $m[1];
|
||||
}
|
||||
|
||||
$raw = $raw === false ? '' : $raw;
|
||||
$json = json_decode($raw, true);
|
||||
|
||||
return [
|
||||
'status' => $status,
|
||||
'body' => $raw,
|
||||
'json' => is_array($json) ? $json : null,
|
||||
'headers' => $responseHeaders,
|
||||
];
|
||||
}
|
||||
|
||||
/** http_call() against the running test server (set up by run.php). */
|
||||
function api(
|
||||
string $method,
|
||||
string $path,
|
||||
?array $body = null,
|
||||
?string $apiKey = TestServer::API_KEY,
|
||||
array $extraHeaders = []
|
||||
): array {
|
||||
return http_call($GLOBALS['att_hc_base_url'], $method, $path, $body, $apiKey, $extraHeaders);
|
||||
}
|
||||
|
||||
/** Pull the PHP session cookie out of a response's Set-Cookie headers. */
|
||||
function session_cookie(array $response): ?string {
|
||||
foreach ($response['headers'] as $h) {
|
||||
if (stripos($h, 'Set-Cookie:') === 0 && preg_match('/(PHPSESSID=[^;]+)/i', $h, $m)) {
|
||||
return $m[1];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
78
tests/plugin_test.php
Normal file
78
tests/plugin_test.php
Normal file
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Plugin-side unit tests.
|
||||
*
|
||||
* These cover the pure static helpers on ATT_HC_Session, which is the only part
|
||||
* of the plugin that can be exercised without a WordPress runtime — the class
|
||||
* body itself calls no WP functions at load time, so defining ABSPATH is enough
|
||||
* to require it.
|
||||
*/
|
||||
|
||||
if (!defined('ABSPATH')) {
|
||||
define('ABSPATH', __DIR__ . '/');
|
||||
}
|
||||
|
||||
require_once dirname(__DIR__) . '/includes/class-att-hc-session.php';
|
||||
|
||||
group('plugin: ATT_HC_Session::sanitise_due_date()');
|
||||
|
||||
test('accepts a well-formed date', function () {
|
||||
assert_same('2026-10-01', ATT_HC_Session::sanitise_due_date('2026-10-01'));
|
||||
});
|
||||
|
||||
test('trims surrounding whitespace', function () {
|
||||
assert_same('2026-10-01', ATT_HC_Session::sanitise_due_date(" 2026-10-01\n"));
|
||||
});
|
||||
|
||||
test('accepts a real leap day', function () {
|
||||
assert_same('2028-02-29', ATT_HC_Session::sanitise_due_date('2028-02-29'));
|
||||
});
|
||||
|
||||
test('rejects an empty string', function () {
|
||||
assert_null(ATT_HC_Session::sanitise_due_date(''));
|
||||
assert_null(ATT_HC_Session::sanitise_due_date(' '));
|
||||
});
|
||||
|
||||
test('rejects unpadded components rather than silently normalising them', function () {
|
||||
// '2026-2-3' parses, but re-formats to '2026-02-03'. Accepting it would mean
|
||||
// the stored value differs from what the user believes they typed.
|
||||
assert_null(ATT_HC_Session::sanitise_due_date('2026-2-3'));
|
||||
});
|
||||
|
||||
test('rejects a date that does not exist', function () {
|
||||
// createFromFormat would roll this forward to 2026-03-02 without complaint.
|
||||
assert_null(ATT_HC_Session::sanitise_due_date('2026-02-30'));
|
||||
assert_null(ATT_HC_Session::sanitise_due_date('2027-02-29'), 'not a leap year');
|
||||
assert_null(ATT_HC_Session::sanitise_due_date('2026-13-01'), 'month 13');
|
||||
assert_null(ATT_HC_Session::sanitise_due_date('2026-00-10'), 'month 0');
|
||||
});
|
||||
|
||||
test('rejects other date formats', function () {
|
||||
assert_null(ATT_HC_Session::sanitise_due_date('01/10/2026'));
|
||||
assert_null(ATT_HC_Session::sanitise_due_date('1 Oct 2026'));
|
||||
assert_null(ATT_HC_Session::sanitise_due_date('2026-10-01T00:00:00Z'));
|
||||
assert_null(ATT_HC_Session::sanitise_due_date('20261001'));
|
||||
});
|
||||
|
||||
test('rejects free text', function () {
|
||||
assert_null(ATT_HC_Session::sanitise_due_date('next tuesday'));
|
||||
assert_null(ATT_HC_Session::sanitise_due_date('soon'));
|
||||
assert_null(ATT_HC_Session::sanitise_due_date('<script>alert(1)</script>'));
|
||||
});
|
||||
|
||||
group('plugin: ATT_HC_Session::normalise_site_url()');
|
||||
|
||||
test('strips scheme, www and trailing slash', function () {
|
||||
assert_same('example.com', ATT_HC_Session::normalise_site_url('https://www.example.com/'));
|
||||
assert_same('example.com', ATT_HC_Session::normalise_site_url('http://example.com'));
|
||||
});
|
||||
|
||||
test('lowercases the host', function () {
|
||||
assert_same('example.com', ATT_HC_Session::normalise_site_url('https://EXAMPLE.com'));
|
||||
});
|
||||
|
||||
test('keeps a subdirectory install path', function () {
|
||||
assert_same('example.com/blog', ATT_HC_Session::normalise_site_url('https://example.com/blog/'));
|
||||
});
|
||||
67
tests/run.php
Normal file
67
tests/run.php
Normal file
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Test entry point.
|
||||
*
|
||||
* php tests/run.php # everything
|
||||
* php tests/run.php plugin # plugin unit tests only (runs on PHP 7.4+)
|
||||
* php tests/run.php server # server integration tests only (needs PHP 8.1+)
|
||||
*
|
||||
* Exits non-zero if anything fails, so it works as a pre-push gate.
|
||||
*/
|
||||
|
||||
$root = dirname(__DIR__);
|
||||
|
||||
require __DIR__ . '/lib/harness.php';
|
||||
require __DIR__ . '/lib/test-server.php';
|
||||
|
||||
$suite = $argv[1] ?? 'all';
|
||||
if (!in_array($suite, ['all', 'plugin', 'server'], true)) {
|
||||
fwrite(STDERR, "usage: php tests/run.php [all|plugin|server]\n");
|
||||
exit(2);
|
||||
}
|
||||
|
||||
if ($suite === 'all' || $suite === 'plugin') {
|
||||
require __DIR__ . '/plugin_test.php';
|
||||
}
|
||||
|
||||
if ($suite === 'all' || $suite === 'server') {
|
||||
if (!TestServer::sqliteAvailable()) {
|
||||
echo "\n\033[33mSKIPPED\033[0m server tests — this PHP build has no pdo_sqlite.\n"
|
||||
. " Install php-sqlite3 (or run the suite on a box that has it).\n";
|
||||
} elseif (PHP_VERSION_ID < 80100) {
|
||||
echo "\n\033[33mSKIPPED\033[0m server tests — the server needs PHP 8.1+, this is " . PHP_VERSION . ".\n";
|
||||
} else {
|
||||
$server = new TestServer($root);
|
||||
|
||||
// Shutdown hook as well as the finally below: a fatal error skips
|
||||
// finally, and an orphaned `php -S` would hold its port.
|
||||
register_shutdown_function(static function () use ($server) { $server->stop(); });
|
||||
|
||||
$server->start();
|
||||
$GLOBALS['att_hc_base_url'] = $server->baseUrl();
|
||||
$GLOBALS['att_hc_db_path'] = $server->dbPath();
|
||||
|
||||
try {
|
||||
require __DIR__ . '/server_test.php';
|
||||
} finally {
|
||||
// Only on failure, and only the interesting lines — the built-in
|
||||
// server logs two access lines per request, which buries anything real.
|
||||
if (count(TestRun::$failures) > 0) {
|
||||
$lines = array_filter(
|
||||
explode("\n", $server->log()),
|
||||
static function (string $l): bool {
|
||||
return trim($l) !== '' && strpos($l, 'Accepted') === false && strpos($l, 'Closing') === false;
|
||||
}
|
||||
);
|
||||
if ($lines) {
|
||||
echo "\n\033[33mtest server log:\033[0m\n" . implode("\n", array_slice($lines, -40)) . "\n";
|
||||
}
|
||||
}
|
||||
$server->stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
report_and_exit();
|
||||
383
tests/server_test.php
Normal file
383
tests/server_test.php
Normal file
@@ -0,0 +1,383 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* End-to-end tests for the history server, driven over real HTTP against a
|
||||
* throwaway instance (see lib/test-server.php).
|
||||
*
|
||||
* Focus is the next_due feature plus the surfaces it touches: create/update
|
||||
* validation, the missing-vs-null PUT contract, the dashboard views, and the
|
||||
* migration that adds the column.
|
||||
*/
|
||||
|
||||
// ── Fixtures ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Monotonic started_at values — (site_key, started_at) is UNIQUE. */
|
||||
function next_started_at(): int {
|
||||
static $t = 1750000000;
|
||||
return $t += 3600;
|
||||
}
|
||||
|
||||
function unique_site(string $label): string {
|
||||
static $n = 0;
|
||||
return $label . '-' . (++$n) . '.example.com';
|
||||
}
|
||||
|
||||
/** Create a healthcheck. Returns ['id' => string, 'response' => array]. */
|
||||
function create_hc(string $siteKey, array $overrides = []): array {
|
||||
static $n = 0;
|
||||
$payload = array_merge([
|
||||
// Dotted, like the plugin's uniqid() ids — also exercises the dev-router
|
||||
// path handling that plain `php -S -t public` gets wrong.
|
||||
'id' => 'att_hc_test_' . (++$n) . '.' . bin2hex(random_bytes(3)),
|
||||
'site_key' => $siteKey,
|
||||
'started_at' => next_started_at(),
|
||||
'reporting_url' => 'https://' . $siteKey,
|
||||
'technician_id' => 1,
|
||||
'wp_version' => '6.6',
|
||||
'php_version' => '8.2',
|
||||
], $overrides);
|
||||
|
||||
$res = api('POST', '/healthchecks', $payload);
|
||||
return ['id' => (string) $payload['id'], 'response' => $res];
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a field, distinguishing "present and null" from "absent".
|
||||
*
|
||||
* `$row['k'] ?? null` collapses those two cases, which would make every
|
||||
* "the date was cleared" assertion pass even if the field vanished entirely.
|
||||
*/
|
||||
function field(?array $row, string $key) {
|
||||
if ($row === null) return '<<no json>>';
|
||||
return array_key_exists($key, $row) ? $row[$key] : '<<missing: ' . $key . '>>';
|
||||
}
|
||||
|
||||
function get_hc(string $id): array {
|
||||
return api('GET', '/healthchecks/' . rawurlencode($id));
|
||||
}
|
||||
|
||||
/** Authenticated dashboard GET (key → cookie handshake done once). */
|
||||
function dash(string $path): array {
|
||||
static $cookie = null;
|
||||
if ($cookie === null) {
|
||||
$login = api('GET', '/dashboard?key=' . rawurlencode(TestServer::API_KEY), null, null);
|
||||
$cookie = session_cookie($login) ?? '';
|
||||
}
|
||||
return api('GET', $path, null, null, ['Cookie: ' . $cookie]);
|
||||
}
|
||||
|
||||
/** The <tr> from a dashboard table that mentions $needle — for precise assertions. */
|
||||
function row_containing(string $html, string $needle): string {
|
||||
if (preg_match_all('#<tr>.*?</tr>#s', $html, $m)) {
|
||||
foreach ($m[0] as $row) {
|
||||
if (strpos($row, htmlspecialchars($needle)) !== false) return $row;
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* The session-meta block of a session detail page.
|
||||
*
|
||||
* Assertions have to be scoped to it: the page's inline stylesheet mentions
|
||||
* every status and due-state class name, so a whole-page assert_not_contains()
|
||||
* can never fail.
|
||||
*/
|
||||
function session_meta(string $html): string {
|
||||
return preg_match('#<div class="session-meta">.*?</div>#s', $html, $m) ? $m[0] : '';
|
||||
}
|
||||
|
||||
function days_from_today(int $days): string {
|
||||
return date('Y-m-d', strtotime($days . ' days'));
|
||||
}
|
||||
|
||||
// ── Health + auth ────────────────────────────────────────────────────────────
|
||||
|
||||
group('server: health + auth');
|
||||
|
||||
test('GET / is public and reports service identity', function () {
|
||||
$r = api('GET', '/', null, null);
|
||||
assert_same(200, $r['status']);
|
||||
assert_same(true, $r['json']['ok'] ?? null);
|
||||
assert_same('att-site-healthcheck-server', $r['json']['service'] ?? null);
|
||||
});
|
||||
|
||||
test('writes require a bearer token', function () {
|
||||
$r = api('POST', '/healthchecks', ['id' => 'nope'], null);
|
||||
assert_same(401, $r['status']);
|
||||
});
|
||||
|
||||
test('a wrong bearer token is rejected', function () {
|
||||
$r = api('GET', '/sites', null, 'not-the-key');
|
||||
assert_same(401, $r['status']);
|
||||
});
|
||||
|
||||
// ── next_due at creation ─────────────────────────────────────────────────────
|
||||
|
||||
group('server: next_due on create');
|
||||
|
||||
test('a healthcheck can be created with a next-due date', function () {
|
||||
$site = unique_site('create-with-date');
|
||||
$hc = create_hc($site, ['next_due' => '2026-10-01']);
|
||||
assert_same(201, $hc['response']['status'], 'create should succeed');
|
||||
|
||||
$got = get_hc($hc['id']);
|
||||
assert_same(200, $got['status']);
|
||||
assert_same('2026-10-01', field($got['json'], 'next_due'));
|
||||
});
|
||||
|
||||
test('next_due defaults to null when omitted', function () {
|
||||
$hc = create_hc(unique_site('create-no-date'));
|
||||
$got = get_hc($hc['id']);
|
||||
assert_same(201, $hc['response']['status']);
|
||||
assert_null(field($got['json'], 'next_due'), 'column should exist and be null');
|
||||
});
|
||||
|
||||
test('an empty next_due string is stored as null', function () {
|
||||
$hc = create_hc(unique_site('create-empty-date'), ['next_due' => '']);
|
||||
assert_same(201, $hc['response']['status']);
|
||||
assert_null(field(get_hc($hc['id'])['json'], 'next_due'));
|
||||
});
|
||||
|
||||
test('an impossible date is rejected and nothing is created', function () {
|
||||
$hc = create_hc(unique_site('create-bad-date'), ['next_due' => '2026-02-30']);
|
||||
assert_same(422, $hc['response']['status']);
|
||||
assert_same('invalid', $hc['response']['json']['code'] ?? null);
|
||||
assert_same(404, get_hc($hc['id'])['status'], 'rejected create must not persist');
|
||||
});
|
||||
|
||||
test('free text and other formats are rejected', function () {
|
||||
foreach (['soon', '01/10/2026', '2026-2-3', '2026-13-01', '20261001'] as $bad) {
|
||||
$hc = create_hc(unique_site('create-bad'), ['next_due' => $bad]);
|
||||
assert_same(422, $hc['response']['status'], 'should reject: ' . $bad);
|
||||
}
|
||||
});
|
||||
|
||||
test('a non-string next_due is rejected', function () {
|
||||
$hc = create_hc(unique_site('create-int-date'), ['next_due' => 20261001]);
|
||||
assert_same(422, $hc['response']['status']);
|
||||
});
|
||||
|
||||
// ── next_due updates ─────────────────────────────────────────────────────────
|
||||
|
||||
group('server: next_due updates');
|
||||
|
||||
test('PUT sets a next-due date', function () {
|
||||
$hc = create_hc(unique_site('update-set'));
|
||||
$r = api('PUT', '/healthchecks/' . rawurlencode($hc['id']), ['next_due' => '2027-01-15']);
|
||||
assert_same(200, $r['status']);
|
||||
assert_same('2027-01-15', field(get_hc($hc['id'])['json'], 'next_due'));
|
||||
});
|
||||
|
||||
test('PUT with no next_due key leaves the date untouched', function () {
|
||||
// The missing-vs-null contract: an unrelated PUT must not wipe the schedule.
|
||||
$hc = create_hc(unique_site('update-noop'), ['next_due' => '2027-03-03']);
|
||||
$r = api('PUT', '/healthchecks/' . rawurlencode($hc['id']), []);
|
||||
assert_same(200, $r['status']);
|
||||
assert_same('2027-03-03', field(get_hc($hc['id'])['json'], 'next_due'));
|
||||
});
|
||||
|
||||
test('finishing a session preserves the date', function () {
|
||||
$hc = create_hc(unique_site('update-finish'), ['next_due' => '2027-04-04']);
|
||||
$now = 1750500000;
|
||||
$r = api('PUT', '/healthchecks/' . rawurlencode($hc['id']), ['finished_at' => $now]);
|
||||
assert_same(200, $r['status']);
|
||||
|
||||
$got = get_hc($hc['id'])['json'];
|
||||
assert_same($now, (int) ($got['finished_at'] ?? 0), 'finished_at should be set');
|
||||
assert_same('2027-04-04', field($got, 'next_due'), 'finishing must not clear the date');
|
||||
});
|
||||
|
||||
test('reopening a session preserves the date', function () {
|
||||
$hc = create_hc(unique_site('update-reopen'), ['next_due' => '2027-05-05']);
|
||||
api('PUT', '/healthchecks/' . rawurlencode($hc['id']), ['finished_at' => 1750600000]);
|
||||
api('PUT', '/healthchecks/' . rawurlencode($hc['id']), ['finished_at' => null]);
|
||||
|
||||
$got = get_hc($hc['id'])['json'];
|
||||
assert_null(field($got, 'finished_at'), 'should be reopened');
|
||||
assert_same('2027-05-05', field($got, 'next_due'));
|
||||
});
|
||||
|
||||
test('setting the date does not disturb finished_at', function () {
|
||||
$hc = create_hc(unique_site('update-date-only'));
|
||||
api('PUT', '/healthchecks/' . rawurlencode($hc['id']), ['finished_at' => 1750700000]);
|
||||
api('PUT', '/healthchecks/' . rawurlencode($hc['id']), ['next_due' => '2027-06-06']);
|
||||
|
||||
$got = get_hc($hc['id'])['json'];
|
||||
assert_same(1750700000, (int) ($got['finished_at'] ?? 0));
|
||||
assert_same('2027-06-06', field($got, 'next_due'));
|
||||
});
|
||||
|
||||
test('an explicit null clears the date', function () {
|
||||
$hc = create_hc(unique_site('update-clear-null'), ['next_due' => '2027-07-07']);
|
||||
api('PUT', '/healthchecks/' . rawurlencode($hc['id']), ['next_due' => null]);
|
||||
assert_null(field(get_hc($hc['id'])['json'], 'next_due'));
|
||||
});
|
||||
|
||||
test('an empty string clears the date', function () {
|
||||
$hc = create_hc(unique_site('update-clear-empty'), ['next_due' => '2027-08-08']);
|
||||
api('PUT', '/healthchecks/' . rawurlencode($hc['id']), ['next_due' => '']);
|
||||
assert_null(field(get_hc($hc['id'])['json'], 'next_due'));
|
||||
});
|
||||
|
||||
test('an invalid update is rejected and leaves the existing date intact', function () {
|
||||
$hc = create_hc(unique_site('update-bad'), ['next_due' => '2027-09-09']);
|
||||
$r = api('PUT', '/healthchecks/' . rawurlencode($hc['id']), ['next_due' => 'whenever']);
|
||||
assert_same(422, $r['status']);
|
||||
assert_same('2027-09-09', field(get_hc($hc['id'])['json'], 'next_due'), 'bad input must not clobber');
|
||||
});
|
||||
|
||||
test('updating an unknown healthcheck is a 404', function () {
|
||||
$r = api('PUT', '/healthchecks/does-not-exist', ['next_due' => '2027-10-10']);
|
||||
assert_same(404, $r['status']);
|
||||
});
|
||||
|
||||
// ── Listing + step regression ────────────────────────────────────────────────
|
||||
|
||||
group('server: listing and steps');
|
||||
|
||||
test('the list endpoint exposes next_due', function () {
|
||||
$site = unique_site('list-dates');
|
||||
create_hc($site, ['next_due' => '2027-11-11']);
|
||||
|
||||
$r = api('GET', '/healthchecks?' . http_build_query(['site_key' => $site, 'limit' => 10]));
|
||||
assert_same(200, $r['status']);
|
||||
$rows = $r['json']['healthchecks'] ?? [];
|
||||
assert_same(1, count($rows));
|
||||
assert_same('2027-11-11', field($rows[0], 'next_due'));
|
||||
});
|
||||
|
||||
test('step upsert and readback still work', function () {
|
||||
// Regression guard: insertHealthcheck gained a column, steps must be unaffected.
|
||||
$site = unique_site('steps');
|
||||
$hc = create_hc($site, ['next_due' => '2027-12-12']);
|
||||
|
||||
$r = api('PUT', '/healthchecks/' . rawurlencode($hc['id']) . '/steps/backup', [
|
||||
'status' => 'done',
|
||||
'notes' => 'Full backup taken',
|
||||
'reporting_url' => 'https://' . $site,
|
||||
]);
|
||||
assert_same(200, $r['status']);
|
||||
|
||||
$got = get_hc($hc['id'])['json'];
|
||||
$steps = $got['steps'] ?? [];
|
||||
assert_same(1, count($steps));
|
||||
assert_same('backup', $steps[0]['step_id'] ?? null);
|
||||
assert_same('done', $steps[0]['status'] ?? null);
|
||||
assert_same('2027-12-12', field($got, 'next_due'), 'a step write must not disturb the date');
|
||||
});
|
||||
|
||||
// ── Dashboard ────────────────────────────────────────────────────────────────
|
||||
|
||||
group('server: dashboard');
|
||||
|
||||
test('the dashboard requires the key', function () {
|
||||
$r = api('GET', '/dashboard', null, null);
|
||||
assert_contains('API key', $r['body'], 'should render the login form');
|
||||
assert_not_contains('All sites <span', $r['body'], 'must not render the site list');
|
||||
});
|
||||
|
||||
test('the site list shows the scheduled date', function () {
|
||||
$site = unique_site('dash-scheduled');
|
||||
$due = days_from_today(90);
|
||||
create_hc($site, ['next_due' => $due]);
|
||||
|
||||
$row = row_containing(dash('/dashboard')['body'], $site);
|
||||
assert_contains($due, $row, 'the date should appear on this site row');
|
||||
assert_contains('due-ok', $row, 'a distant date is not urgent');
|
||||
assert_not_contains('overdue', $row);
|
||||
});
|
||||
|
||||
test('a past date is flagged overdue', function () {
|
||||
$site = unique_site('dash-overdue');
|
||||
create_hc($site, ['next_due' => days_from_today(-10)]);
|
||||
|
||||
$row = row_containing(dash('/dashboard')['body'], $site);
|
||||
assert_contains('due-overdue', $row);
|
||||
assert_contains('overdue', $row);
|
||||
});
|
||||
|
||||
test('a date within a fortnight is flagged as soon', function () {
|
||||
$site = unique_site('dash-soon');
|
||||
create_hc($site, ['next_due' => days_from_today(5)]);
|
||||
|
||||
$row = row_containing(dash('/dashboard')['body'], $site);
|
||||
assert_contains('due-soon', $row);
|
||||
assert_not_contains('overdue', $row);
|
||||
});
|
||||
|
||||
test('a site with nothing scheduled shows a dash', function () {
|
||||
$site = unique_site('dash-none');
|
||||
create_hc($site);
|
||||
|
||||
$row = row_containing(dash('/dashboard')['body'], $site);
|
||||
assert_contains('—', $row);
|
||||
assert_not_contains('due-ok', $row);
|
||||
assert_not_contains('due-overdue', $row);
|
||||
});
|
||||
|
||||
test('the newest session wins — a stale date is not carried forward', function () {
|
||||
// The visit that was scheduled has since happened; the site should read as
|
||||
// "nothing scheduled", not as permanently overdue.
|
||||
$site = unique_site('dash-superseded');
|
||||
create_hc($site, ['next_due' => days_from_today(-30)]); // older session
|
||||
create_hc($site); // newer, no date
|
||||
|
||||
$row = row_containing(dash('/dashboard')['body'], $site);
|
||||
assert_not_contains('overdue', $row, 'the completed visit must not still show as due');
|
||||
assert_not_contains(days_from_today(-30), $row);
|
||||
assert_contains('—', $row);
|
||||
});
|
||||
|
||||
test('the per-site session table shows each session\'s date', function () {
|
||||
$site = unique_site('dash-site-detail');
|
||||
$due = days_from_today(45);
|
||||
create_hc($site, ['next_due' => $due]);
|
||||
|
||||
$body = dash('/dashboard?site=' . rawurlencode($site))['body'];
|
||||
assert_contains('<th>Next due</th>', $body);
|
||||
assert_contains($due, $body);
|
||||
});
|
||||
|
||||
test('the session detail view shows the date', function () {
|
||||
$site = unique_site('dash-session-detail');
|
||||
$due = days_from_today(60);
|
||||
$hc = create_hc($site, ['next_due' => $due]);
|
||||
api('PUT', '/healthchecks/' . rawurlencode($hc['id']) . '/steps/backup', [
|
||||
'status' => 'done',
|
||||
'notes' => 'ok',
|
||||
'reporting_url' => 'https://' . $site,
|
||||
]);
|
||||
|
||||
$meta = session_meta(dash('/dashboard?site=' . rawurlencode($site) . '&hc=' . rawurlencode($hc['id']))['body']);
|
||||
assert_contains('Next due:', $meta);
|
||||
assert_contains($due, $meta);
|
||||
assert_contains('due-ok', $meta, 'a date two months out is not urgent');
|
||||
});
|
||||
|
||||
test('a session with no date shows a dash in the detail view', function () {
|
||||
$site = unique_site('dash-session-nodate');
|
||||
$hc = create_hc($site);
|
||||
$meta = session_meta(dash('/dashboard?site=' . rawurlencode($site) . '&hc=' . rawurlencode($hc['id']))['body']);
|
||||
assert_contains('Next due:', $meta);
|
||||
assert_contains('—', $meta);
|
||||
assert_not_contains('due-overdue', $meta);
|
||||
assert_not_contains('due-ok', $meta);
|
||||
});
|
||||
|
||||
// ── Migrations ───────────────────────────────────────────────────────────────
|
||||
|
||||
group('server: migrations');
|
||||
|
||||
test('both migrations are recorded exactly once', function () {
|
||||
$pdo = new PDO('sqlite:' . $GLOBALS['att_hc_db_path'], null, null, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
|
||||
$applied = $pdo->query('SELECT filename FROM migrations ORDER BY filename')->fetchAll(PDO::FETCH_COLUMN);
|
||||
assert_same(['0001_initial.sql', '0002_next_due.sql'], $applied);
|
||||
});
|
||||
|
||||
test('the next_due column exists on healthchecks', function () {
|
||||
$pdo = new PDO('sqlite:' . $GLOBALS['att_hc_db_path'], null, null, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
|
||||
$cols = array_column($pdo->query('PRAGMA table_info(healthchecks)')->fetchAll(PDO::FETCH_ASSOC), 'name');
|
||||
assert_true(in_array('next_due', $cols, true), 'columns: ' . implode(', ', $cols));
|
||||
});
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "ATT Site Healthcheck",
|
||||
"slug": "att-site-healthcheck",
|
||||
"version": "0.1.3",
|
||||
"version": "0.1.7",
|
||||
"author": "Steve Hanlon",
|
||||
"requires": "6.0",
|
||||
"tested": "6.6",
|
||||
"requires_php": "7.4",
|
||||
"last_updated": "2026-07-16 13:00:00",
|
||||
"last_updated": "2026-07-27 08:42:03",
|
||||
"download_url": "https://git.h12e.com/steve/wp-healthcheck/archive/main.zip",
|
||||
"sections": {
|
||||
"description": "Walks a technician through a structured WordPress site healthcheck. Steps are drop-in PHP files so adding/removing one is a single file change.",
|
||||
|
||||
Reference in New Issue
Block a user