Compare commits

...

3 Commits

Author SHA1 Message Date
84dd00f3ec beads: close hc-fbd (next healthcheck due date)
The feature commit 6313857 cites hc-nkq in its subject; the real id is
hc-fbd — the id was guessed from a truncated bd create output.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 12:04:17 +01:00
55990bea49 Release 0.1.4 — next healthcheck due date
Bumps the plugin header, ATT_HC_VERSION and updates.json so PUC offers the
next-due feature to installed sites on their next update check.

NOTE: the history server must be deployed before this reaches sites. An old
server ignores the next_due key on PUT and returns 200, so the plugin would
report the date as saved while the dashboard never shows it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 12:03:54 +01:00
631385721f Next healthcheck due date, plus a test suite (hc-nkq)
Lets a technician record when a site should next be looked at, and surfaces
that in the dashboard so it can be used for planning.

Server:
- migration 0002 adds healthchecks.next_due, a VARCHAR(10) 'YYYY-MM-DD'
  calendar date rather than a timestamp — it's a diary date with no
  time-of-day, and a timestamp would render as the wrong day off-server.
- Kept per-session rather than on a 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: if the latest visit scheduled nothing, the site reads
  as unscheduled rather than showing the just-completed visit as overdue.
- Validate::optionalDate() round-trips through createFromFormat, which
  rejects both '2026-2-3' and '2026-02-30' (silently rolled to March 2nd).
- next_due follows the same missing-vs-null PUT contract as finished_at, and
  lives in its own Store::setNextDue() so finishing or reopening a session
  never disturbs the date and vice versa.
- Dashboard shows it on the site list, the per-site session table and the
  session detail, flagged overdue / today / soon (within a fortnight).

Plugin:
- Date control on both the active and finished panels — the moment you know
  when to return is often wrap-up, after the report is generated.
- Write-through like every other mutation. Bad input is rejected with a
  notice rather than silently clearing an existing date.
- Included in both the Markdown and HTML reports.

Tests (new — tests/, export-ignored from the plugin zip):
- Hand-rolled harness, no composer/PHPUnit, in keeping with the repo.
- 42 tests: plugin-side date parsing units, plus server integration tests
  driven over real HTTP against a temp `php -S` instance with a throwaway
  SQLite DB, so a real server/config.php is never touched.
- Mutation-checked: dropping the array_key_exists guard on PUT fails three
  tests, as intended.
2026-07-23 12:02:39 +01:00
19 changed files with 1120 additions and 16 deletions

View File

@@ -15,6 +15,7 @@
{"_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.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.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-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-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-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-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} {"_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}

1
.gitattributes vendored
View File

@@ -13,3 +13,4 @@ AGENTS.md export-ignore
CLAUDE.md export-ignore CLAUDE.md export-ignore
steps.md export-ignore steps.md export-ignore
server/ export-ignore server/ export-ignore
tests/ export-ignore

View File

@@ -52,13 +52,36 @@ bd close <id> # Complete work
## Build & Test ## Build & Test
There is **no build step, no package manager, and no test suite**. Plain PHP on both There is **no build step and no package manager**. Plain PHP on both sides; the only
sides; the only third-party code is a vendored copy of Plugin Update Checker under 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 `vendor/plugin-update-checker/` (do not hand-edit it — see README for the refresh
procedure). procedure).
Quality gate for a change is a syntax lint of the files you touched, **against the ```bash
right PHP version for that half of the repo** (see Conventions): 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 ```bash
# Plugin — must parse on PHP 7.4 # Plugin — must parse on PHP 7.4

View File

@@ -16,8 +16,12 @@ Then activate from *Plugins*. Settings appear under *Tools → Site Healthcheck*
2. Confirm the recovery plugin status panel shows ✓ active. 2. Confirm the recovery plugin status panel shows ✓ active.
3. Click **Start new healthcheck**. 3. Click **Start new healthcheck**.
4. Work through each step card. For each: choose a status (done / skipped / blocked / n/a) and add notes. 4. Work through each step card. For each: choose a status (done / skipped / blocked / n/a) and add notes.
5. Click **Finish & generate report**. 5. Set **Next healthcheck due** to when the site should be looked at again. It's
6. Download the Markdown report or copy it to clipboard. 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. 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.

View File

@@ -2,7 +2,7 @@
/** /**
* Plugin Name: ATT Site Healthcheck * 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. * 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.4
* Author: Steve Hanlon * Author: Steve Hanlon
* License: Proprietary * License: Proprietary
* License URI: https://git.h12e.com/steve/wp-healthcheck/raw/branch/main/LICENSE * License URI: https://git.h12e.com/steve/wp-healthcheck/raw/branch/main/LICENSE
@@ -15,7 +15,7 @@ if (!defined('ABSPATH')) {
exit; exit;
} }
define('ATT_HC_VERSION', '0.1.3'); define('ATT_HC_VERSION', '0.1.4');
define('ATT_HC_PLUGIN_FILE', __FILE__); define('ATT_HC_PLUGIN_FILE', __FILE__);
define('ATT_HC_PLUGIN_DIR', plugin_dir_path(__FILE__)); define('ATT_HC_PLUGIN_DIR', plugin_dir_path(__FILE__));
define('ATT_HC_PLUGIN_URL', plugin_dir_url(__FILE__)); define('ATT_HC_PLUGIN_URL', plugin_dir_url(__FILE__));

View File

@@ -7,6 +7,7 @@ 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_step_history', 'att_hc_handle_step_history_ajax');
add_action('admin_post_att_hc_save_step', 'att_hc_handle_save_step'); 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_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_discard', 'att_hc_handle_discard');
add_action('admin_post_att_hc_download_report', 'att_hc_handle_download_report'); 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'); add_action('admin_post_att_hc_refresh_checks', 'att_hc_handle_refresh_checks');
@@ -53,6 +54,9 @@ function att_hc_inline_css(): string {
.att-hc-status-skipped { background:#fff3cd; color:#856404; } .att-hc-status-skipped { background:#fff3cd; color:#856404; }
.att-hc-status-blocked { background:#fbeae8; color:#721c24; } .att-hc-status-blocked { background:#fbeae8; color:#721c24; }
.att-hc-status-n_a { background:#e2e3e5; color:#41464b; } .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 { 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 { 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; } .att-hc-step header h2 { margin:0; font-size:1.1rem; }
@@ -133,6 +137,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>'; 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) { if (!$session) {
att_hc_render_start_panel(); att_hc_render_start_panel();
echo '</div>'; echo '</div>';
@@ -255,6 +265,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> <span class="att-hc-progress"><?php echo (int) $progress['done']; ?> / <?php echo (int) $progress['total']; ?> steps</span>
</p> </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> <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"> <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"> <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'); ?> <?php wp_nonce_field('att_hc_finish'); ?>
@@ -295,6 +306,30 @@ function att_hc_render_active_session(ATT_HC_Session $session): void {
echo '</div></div>'; 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 { function att_hc_print_step_history_assets(ATT_HC_Session $session): void {
$cfg = [ $cfg = [
'ajaxUrl' => admin_url('admin-ajax.php'), 'ajaxUrl' => admin_url('admin-ajax.php'),
@@ -546,6 +581,7 @@ function att_hc_render_finished_panel(ATT_HC_Session $session): void {
<div class="att-hc-card"> <div class="att-hc-card">
<h2>Healthcheck finished</h2> <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> <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"> <div class="att-hc-actions">
<form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>" style="display:inline"> <form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>" style="display:inline">
<?php wp_nonce_field('att_hc_download_report'); ?> <?php wp_nonce_field('att_hc_download_report'); ?>
@@ -601,6 +637,41 @@ function att_hc_handle_start(): void {
exit; 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 { function att_hc_handle_step_history_ajax(): void {
if (!current_user_can('manage_options')) wp_send_json_error('Forbidden', 403); if (!current_user_can('manage_options')) wp_send_json_error('Forbidden', 403);
if (!check_ajax_referer('att_hc_step_history', 'nonce', false)) { if (!check_ajax_referer('att_hc_step_history', 'nonce', false)) {

View File

@@ -70,6 +70,7 @@ final class ATT_HC_Session {
'site_url' => $reporting_url, 'site_url' => $reporting_url,
'wp_version' => get_bloginfo('version'), 'wp_version' => get_bloginfo('version'),
'php_version' => PHP_VERSION, 'php_version' => PHP_VERSION,
'next_due' => null,
'steps' => [], 'steps' => [],
]; ];
@@ -180,6 +181,12 @@ final class ATT_HC_Session {
public function php_version(): string { return (string) ($this->data['php_version'] ?? ''); } public function php_version(): string { return (string) ($this->data['php_version'] ?? ''); }
public function data(): array { return $this->data; } 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 { public function step_state(string $step_id): array {
return $this->data['steps'][$step_id] ?? [ return $this->data['steps'][$step_id] ?? [
'status' => self::STATUS_NOT_STARTED, 'status' => self::STATUS_NOT_STARTED,
@@ -214,6 +221,40 @@ final class ATT_HC_Session {
update_option(ATT_HC_OPT_SESSION, $this->data, false); 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. * 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'], 'site_url' => (string) $remote['reporting_url'],
'wp_version' => (string) ($remote['wp_version'] ?? ''), 'wp_version' => (string) ($remote['wp_version'] ?? ''),
'php_version' => (string) ($remote['php_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' => [], 'steps' => [],
'autocheck' => [], 'autocheck' => [],
]; ];

View File

@@ -23,6 +23,9 @@ function att_hc_build_markdown_report(ATT_HC_Session $session): string {
$lines[] = '- **Site:** ' . $session->site_url(); $lines[] = '- **Site:** ' . $session->site_url();
$lines[] = '- **WordPress:** ' . $session->wp_version(); $lines[] = '- **WordPress:** ' . $session->wp_version();
$lines[] = '- **PHP:** ' . $session->php_version(); $lines[] = '- **PHP:** ' . $session->php_version();
if ($next_due = $session->next_due()) {
$lines[] = '- **Next healthcheck due:** ' . $next_due;
}
$lines[] = '- **Session ID:** ' . $session->id(); $lines[] = '- **Session ID:** ' . $session->id();
$lines[] = ''; $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>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>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> <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> </table>
<h2>Summary</h2> <h2>Summary</h2>

View 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;

View File

@@ -149,6 +149,7 @@ final class Dashboard {
$rows .= '<tr> $rows .= '<tr>
<td><a href="' . $url . '">' . htmlspecialchars($s['site_key']) . '</a></td> <td><a href="' . $url . '">' . htmlspecialchars($s['site_key']) . '</a></td>
<td>' . $badge . '</td> <td>' . $badge . '</td>
<td>' . self::nextDueCell($s['next_due'] ?? null) . '</td>
<td>' . $lastDate . '</td> <td>' . $lastDate . '</td>
<td>' . $sessionStr . '</td> <td>' . $sessionStr . '</td>
<td class="muted small">' . htmlspecialchars($s['last_reporting_url']) . '</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> <h2>All sites <span class="muted small">(' . count($sites) . ')</span></h2>
<table> <table>
<thead><tr> <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> <th>Sessions</th><th>Last reporting URL</th>
</tr></thead> </tr></thead>
<tbody>' . $rows . '</tbody> <tbody>' . $rows . '</tbody>
@@ -187,6 +188,7 @@ final class Dashboard {
<td>' . $badge . '</td> <td>' . $badge . '</td>
<td>' . ($finished ?? '<span class="muted">—</span>') . '</td> <td>' . ($finished ?? '<span class="muted">—</span>') . '</td>
<td>' . ($duration !== '' ? $duration : '<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> <td class="muted small">' . htmlspecialchars((string) $hc['reporting_url']) . '</td>
</tr>'; </tr>';
} }
@@ -196,7 +198,7 @@ final class Dashboard {
<table> <table>
<thead><tr> <thead><tr>
<th>Started</th><th>Status</th><th>Finished</th> <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> </tr></thead>
<tbody>' . $rows . '</tbody> <tbody>' . $rows . '</tbody>
</table>'; </table>';
@@ -232,6 +234,7 @@ final class Dashboard {
<span>Started: <strong>' . $started . '</strong></span>' <span>Started: <strong>' . $started . '</strong></span>'
. ($finished !== null ? '<span>Finished: <strong>' . $finished . '</strong></span>' : '') . ($finished !== null ? '<span>Finished: <strong>' . $finished . '</strong></span>' : '')
. ($duration !== null ? '<span>Duration: <strong>' . $duration . '</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>' . '<span class="muted small">ID: ' . htmlspecialchars($hcId) . '</span>'
. $reopenForm . $reopenForm
. '</div>'; . '</div>';
@@ -286,6 +289,32 @@ final class Dashboard {
return '<span class="badge ' . $cls . '">' . $label . '</span>'; 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 { private static function formatStepId(string $id): string {
return ucwords(str_replace(['-', '_'], ' ', $id)); return ucwords(str_replace(['-', '_'], ' ', $id));
} }
@@ -333,6 +362,9 @@ a:hover { color: #135e96; text-decoration: underline; }
.status-blocked { background: #fce8e8; color: #8c2020; } .status-blocked { background: #fce8e8; color: #8c2020; }
.status-na { background: #f0f0f1; color: #646970; } .status-na { background: #f0f0f1; color: #646970; }
.status-not-started { background: #f6f7f7; color: #8c8f94; border: 1px solid #dcdcde; } .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; } .muted { color: #646970; }
.small { font-size: .85em; } .small { font-size: .85em; }
.error { color: #b32d2e; } .error { color: #b32d2e; }

View File

@@ -19,6 +19,7 @@ final class Healthchecks {
'technician_id' => Validate::optionalInt($body, 'technician_id'), 'technician_id' => Validate::optionalInt($body, 'technician_id'),
'wp_version' => Validate::optionalString($body, 'wp_version', 32), 'wp_version' => Validate::optionalString($body, 'wp_version', 32),
'php_version' => Validate::optionalString($body, 'php_version', 32), 'php_version' => Validate::optionalString($body, 'php_version', 32),
'next_due' => Validate::optionalDate($body, 'next_due'),
]; ];
try { try {
@@ -49,6 +50,11 @@ final class Healthchecks {
$finishedAt = Validate::optionalInt($body, 'finished_at'); $finishedAt = Validate::optionalInt($body, 'finished_at');
Store::updateHealthcheck($id, $finishedAt); 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]); Http::json(200, ['ok' => true]);
} }

View File

@@ -11,8 +11,8 @@ final class Store {
public static function insertHealthcheck(array $row): void { public static function insertHealthcheck(array $row): void {
$now = time(); $now = time();
$stmt = Db::pdo()->prepare('INSERT INTO healthchecks $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) (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, :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([ $stmt->execute([
':id' => $row['id'], ':id' => $row['id'],
':site_key' => $row['site_key'], ':site_key' => $row['site_key'],
@@ -21,6 +21,7 @@ final class Store {
':reporting_url' => $row['reporting_url'], ':reporting_url' => $row['reporting_url'],
':wp_version' => $row['wp_version'] ?? null, ':wp_version' => $row['wp_version'] ?? null,
':php_version' => $row['php_version'] ?? null, ':php_version' => $row['php_version'] ?? null,
':next_due' => $row['next_due'] ?? null,
':created_at' => $now, ':created_at' => $now,
':updated_at' => $now, ':updated_at' => $now,
]); ]);
@@ -43,6 +44,22 @@ final class Store {
return $stmt->rowCount() > 0; 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 { public static function upsertStep(string $healthcheckId, string $stepId, array $row): void {
$pdo = Db::pdo(); $pdo = Db::pdo();
$driver = $pdo->getAttribute(\PDO::ATTR_DRIVER_NAME); $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, * Summary of all sites for the dashboard: one row per site_key,
* ordered by most-recently-active first. * 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 { public static function allSitesSummary(): array {
$stmt = Db::pdo()->query( $stmt = Db::pdo()->query(
@@ -198,7 +220,7 @@ final class Store {
$out = []; $out = [];
foreach ($rows as $r) { foreach ($rows as $r) {
$detail = Db::pdo()->prepare( $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' WHERE site_key = ? ORDER BY started_at DESC LIMIT 1'
); );
$detail->execute([$r['site_key']]); $detail->execute([$r['site_key']]);
@@ -210,6 +232,9 @@ final class Store {
'last_started_at' => (int) $r['last_started_at'], '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_finished_at' => isset($last['finished_at']) && $last['finished_at'] !== null ? (int) $last['finished_at'] : null,
'last_reporting_url' => (string) ($last['reporting_url'] ?? ''), '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; return $out;

View File

@@ -38,6 +38,29 @@ final class Validate {
return $body[$key]; 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 { public static function status(string $status): string {
$valid = ['not_started', 'done', 'skipped', 'blocked', 'n_a']; $valid = ['not_started', 'done', 'skipped', 'blocked', 'n_a'];
if (!in_array($status, $valid, true)) self::fail('status must be one of ' . implode(', ', $valid)); if (!in_array($status, $valid, true)) self::fail('status must be one of ' . implode(', ', $valid));

109
tests/lib/harness.php Normal file
View 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
View 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
View 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
View 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
View 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));
});

View File

@@ -1,12 +1,12 @@
{ {
"name": "ATT Site Healthcheck", "name": "ATT Site Healthcheck",
"slug": "att-site-healthcheck", "slug": "att-site-healthcheck",
"version": "0.1.3", "version": "0.1.4",
"author": "Steve Hanlon", "author": "Steve Hanlon",
"requires": "6.0", "requires": "6.0",
"tested": "6.6", "tested": "6.6",
"requires_php": "7.4", "requires_php": "7.4",
"last_updated": "2026-07-16 13:00:00", "last_updated": "2026-07-23 12:03:38",
"download_url": "https://git.h12e.com/steve/wp-healthcheck/archive/main.zip", "download_url": "https://git.h12e.com/steve/wp-healthcheck/archive/main.zip",
"sections": { "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.", "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.",