From 631385721fa84f3cc35ba0c211032eb20f155aa6 Mon Sep 17 00:00:00 2001
From: Steve Hanlon
Date: Thu, 23 Jul 2026 12:02:39 +0100
Subject: [PATCH] Next healthcheck due date, plus a test suite (hc-nkq)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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.
---
.beads/issues.jsonl | 1 +
.gitattributes | 1 +
CLAUDE.md | 31 +-
README.md | 8 +-
includes/admin-page.php | 71 +++++
includes/class-att-hc-session.php | 44 +++
includes/report.php | 6 +
server/migrations/0002_next_due.sql | 18 ++
server/src/Controllers/Dashboard.php | 36 ++-
server/src/Controllers/Healthchecks.php | 6 +
server/src/Store.php | 33 +-
server/src/Validate.php | 23 ++
tests/lib/harness.php | 109 +++++++
tests/lib/test-server.php | 213 +++++++++++++
tests/plugin_test.php | 78 +++++
tests/run.php | 67 +++++
tests/server_test.php | 383 ++++++++++++++++++++++++
17 files changed, 1116 insertions(+), 12 deletions(-)
create mode 100644 server/migrations/0002_next_due.sql
create mode 100644 tests/lib/harness.php
create mode 100644 tests/lib/test-server.php
create mode 100644 tests/plugin_test.php
create mode 100644 tests/run.php
create mode 100644 tests/server_test.php
diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl
index bd39fc6..e6630f8 100644
--- a/.beads/issues.jsonl
+++ b/.beads/issues.jsonl
@@ -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.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-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":"open","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-23T10:50:25Z","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}
diff --git a/.gitattributes b/.gitattributes
index be0e9b6..aaffd60 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -13,3 +13,4 @@ AGENTS.md export-ignore
CLAUDE.md export-ignore
steps.md export-ignore
server/ export-ignore
+tests/ export-ignore
diff --git a/CLAUDE.md b/CLAUDE.md
index f18cc2b..bb6822d 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -52,13 +52,36 @@ bd close # 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
diff --git a/README.md b/README.md
index dd621ab..7d0bdfa 100644
--- a/README.md
+++ b/README.md
@@ -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.
diff --git a/includes/admin-page.php b/includes/admin-page.php
index 55e4486..f382125 100644
--- a/includes/admin-page.php
+++ b/includes/admin-page.php
@@ -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('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 +54,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 +137,12 @@ function att_hc_render_admin_page(): void {
echo 'Central history server: ' . esc_html($err) . '
';
}
+ // Local (non-server) problems — bad input and the like.
+ if ($err = get_transient('att_hc_error')) {
+ delete_transient('att_hc_error');
+ echo '';
+ }
+
if (!$session) {
att_hc_render_start_panel();
echo '';
@@ -255,6 +265,7 @@ function att_hc_render_active_session(ATT_HC_Session $session): void {
/ steps
WP wp_version()); ?> · PHP php_version()); ?> · Site site_url()); ?>
+
';
}
+/**
+ * "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();
+ ?>
+
+
+
+ admin_url('admin-ajax.php'),
@@ -546,6 +581,7 @@ function att_hc_render_finished_panel(ATT_HC_Session $session): void {
Healthcheck finished
Started started_at())); ?> · Finished finished_at())); ?>
+