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 '

' . esc_html($err) . '

'; + } + 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()); ?>

+
@@ -295,6 +306,30 @@ function att_hc_render_active_session(ATT_HC_Session $session): void { echo '
'; } +/** + * "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(); + ?> +
+ + + + + + + + + + Shown against this site on the healthcheck dashboard. + +
+ 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())); ?>

+
@@ -601,6 +637,41 @@ function att_hc_handle_start(): void { exit; } +function att_hc_handle_save_next_due(): void { + if (!current_user_can('manage_options')) wp_die('Forbidden'); + check_admin_referer('att_hc_save_next_due'); + + $redirect = admin_url('tools.php?page=att-site-healthcheck'); + $session = ATT_HC_Session::current(); + if (!$session) wp_die('No active session.'); + + $clear = !empty($_POST['next_due_clear']); + $raw = isset($_POST['next_due']) ? sanitize_text_field(wp_unslash((string) $_POST['next_due'])) : ''; + $date = $clear ? null : ATT_HC_Session::sanitise_due_date($raw); + + // Distinguish "cleared the field" (legitimate) from "typed something that + // isn't a date" — silently clearing on bad input would lose the existing date. + if (!$clear && $raw !== '' && $date === null) { + set_transient('att_hc_error', 'Could not save the next healthcheck date: "' . $raw . '" is not a valid date (expected YYYY-MM-DD).', 60); + wp_safe_redirect($redirect); + exit; + } + + try { + $session->set_next_due($date); + set_transient( + 'att_hc_install_message', + $date === null ? 'Next healthcheck date cleared.' : 'Next healthcheck due ' . $date . '.', + 60 + ); + } catch (ATT_HC_Api_Exception $e) { + set_transient('att_hc_api_error', 'Could not save the next healthcheck date: ' . $e->getMessage(), 60); + } + + wp_safe_redirect($redirect); + exit; +} + function att_hc_handle_step_history_ajax(): void { if (!current_user_can('manage_options')) wp_send_json_error('Forbidden', 403); if (!check_ajax_referer('att_hc_step_history', 'nonce', false)) { diff --git a/includes/class-att-hc-session.php b/includes/class-att-hc-session.php index fdda1eb..58c7183 100644 --- a/includes/class-att-hc-session.php +++ b/includes/class-att-hc-session.php @@ -70,6 +70,7 @@ final class ATT_HC_Session { 'site_url' => $reporting_url, 'wp_version' => get_bloginfo('version'), 'php_version' => PHP_VERSION, + 'next_due' => null, 'steps' => [], ]; @@ -180,6 +181,12 @@ final class ATT_HC_Session { public function php_version(): string { return (string) ($this->data['php_version'] ?? ''); } public function data(): array { return $this->data; } + /** Scheduled date of the next healthcheck for this site, as 'YYYY-MM-DD', or null. */ + public function next_due(): ?string { + $raw = $this->data['next_due'] ?? null; + return is_string($raw) && $raw !== '' ? $raw : null; + } + public function step_state(string $step_id): array { return $this->data['steps'][$step_id] ?? [ 'status' => self::STATUS_NOT_STARTED, @@ -214,6 +221,40 @@ final class ATT_HC_Session { update_option(ATT_HC_OPT_SESSION, $this->data, false); } + /** + * Schedule (or clear, with null) the next healthcheck for this site. + * + * Write-through like every other mutation: the server PUT has to succeed + * before the local cache moves. Allowed on a finished session too — the + * natural moment to decide when to come back is at wrap-up, which may be + * after the report has been generated. + * + * @param string|null $date 'YYYY-MM-DD', or null to clear. + * @throws ATT_HC_Api_Exception + */ + public function set_next_due(?string $date): void { + $date = $date === null ? null : self::sanitise_due_date($date); + ATT_HC_Api::update_healthcheck($this->id(), ['next_due' => $date]); + $this->data['next_due'] = $date; + update_option(ATT_HC_OPT_SESSION, $this->data, false); + } + + /** + * Normalise a user-entered date to 'YYYY-MM-DD', or null if it isn't a real + * calendar date in that format. + * + * The round-trip comparison is what makes this strict: createFromFormat + * happily accepts '2026-2-3' and rolls '2026-02-30' forward into March, and + * both re-format to something other than what was typed. + */ + public static function sanitise_due_date(string $raw): ?string { + $raw = trim($raw); + if ($raw === '') return null; + $date = DateTimeImmutable::createFromFormat('!Y-m-d', $raw); + if ($date === false || $date->format('Y-m-d') !== $raw) return null; + return $raw; + } + /** * Mark the session finished. Server PUT first, then cache update. * @@ -294,6 +335,9 @@ final class ATT_HC_Session { 'site_url' => (string) $remote['reporting_url'], 'wp_version' => (string) ($remote['wp_version'] ?? ''), 'php_version' => (string) ($remote['php_version'] ?? ''), + 'next_due' => isset($remote['next_due']) && $remote['next_due'] !== null && $remote['next_due'] !== '' + ? (string) $remote['next_due'] + : null, 'steps' => [], 'autocheck' => [], ]; diff --git a/includes/report.php b/includes/report.php index 6dc99e6..e78c923 100644 --- a/includes/report.php +++ b/includes/report.php @@ -23,6 +23,9 @@ function att_hc_build_markdown_report(ATT_HC_Session $session): string { $lines[] = '- **Site:** ' . $session->site_url(); $lines[] = '- **WordPress:** ' . $session->wp_version(); $lines[] = '- **PHP:** ' . $session->php_version(); + if ($next_due = $session->next_due()) { + $lines[] = '- **Next healthcheck due:** ' . $next_due; + } $lines[] = '- **Session ID:** ' . $session->id(); $lines[] = ''; @@ -144,6 +147,9 @@ function att_hc_build_html_report(ATT_HC_Session $session): string { Technician WordPresswp_version()); ?> PHPphp_version()); ?> + next_due()) : ?> + Next healthcheck due +

Summary

diff --git a/server/migrations/0002_next_due.sql b/server/migrations/0002_next_due.sql new file mode 100644 index 0000000..1468577 --- /dev/null +++ b/server/migrations/0002_next_due.sql @@ -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; diff --git a/server/src/Controllers/Dashboard.php b/server/src/Controllers/Dashboard.php index f06b4ea..d7a2305 100644 --- a/server/src/Controllers/Dashboard.php +++ b/server/src/Controllers/Dashboard.php @@ -149,6 +149,7 @@ final class Dashboard { $rows .= ' ' . htmlspecialchars($s['site_key']) . ' ' . $badge . ' + ' . self::nextDueCell($s['next_due'] ?? null) . ' ' . $lastDate . ' ' . $sessionStr . ' ' . htmlspecialchars($s['last_reporting_url']) . ' @@ -158,7 +159,7 @@ final class Dashboard {

All sites (' . count($sites) . ')

- + ' . $rows . ' @@ -187,6 +188,7 @@ final class Dashboard { + '; } @@ -196,7 +198,7 @@ final class Dashboard {
Site keyLatestLast session startedSite keyLatestNext dueLast session started SessionsLast reporting URL
' . $badge . ' ' . ($finished ?? '') . ' ' . ($duration !== '' ? $duration : '') . '' . self::nextDueCell(isset($hc['next_due']) ? (string) $hc['next_due'] : null) . ' ' . htmlspecialchars((string) $hc['reporting_url']) . '
- + ' . $rows . '
StartedStatusFinishedDurationReporting URLDurationNext dueReporting URL
'; @@ -232,6 +234,7 @@ final class Dashboard { Started: ' . $started . '' . ($finished !== null ? 'Finished: ' . $finished . '' : '') . ($duration !== null ? 'Duration: ' . $duration . '' : '') + . 'Next due: ' . self::nextDueCell(isset($hc['next_due']) ? (string) $hc['next_due'] : null) . '' . 'ID: ' . htmlspecialchars($hcId) . '' . $reopenForm . '
'; @@ -286,6 +289,32 @@ final class Dashboard { return '' . $label . ''; } + /** + * 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 ''; + } + $safe = htmlspecialchars($nextDue); + $today = date('Y-m-d'); + + if ($nextDue < $today) { + return '' . $safe . ' · overdue'; + } + if ($nextDue === $today) { + return '' . $safe . ' · today'; + } + if ($nextDue <= date('Y-m-d', strtotime('+14 days'))) { + return '' . $safe . ' · soon'; + } + return '' . $safe . ''; + } + private static function formatStepId(string $id): string { return ucwords(str_replace(['-', '_'], ' ', $id)); } @@ -333,6 +362,9 @@ a:hover { color: #135e96; text-decoration: underline; } .status-blocked { background: #fce8e8; color: #8c2020; } .status-na { background: #f0f0f1; color: #646970; } .status-not-started { background: #f6f7f7; color: #8c8f94; border: 1px solid #dcdcde; } +.due-overdue { background: #fce8e8; color: #8c2020; } +.due-soon { background: #fef9e7; color: #8a6d01; } +.due-ok { background: #eef3fb; color: #1c4f8c; } .muted { color: #646970; } .small { font-size: .85em; } .error { color: #b32d2e; } diff --git a/server/src/Controllers/Healthchecks.php b/server/src/Controllers/Healthchecks.php index 6c39ee4..749fbdb 100644 --- a/server/src/Controllers/Healthchecks.php +++ b/server/src/Controllers/Healthchecks.php @@ -19,6 +19,7 @@ final class Healthchecks { 'technician_id' => Validate::optionalInt($body, 'technician_id'), 'wp_version' => Validate::optionalString($body, 'wp_version', 32), 'php_version' => Validate::optionalString($body, 'php_version', 32), + 'next_due' => Validate::optionalDate($body, 'next_due'), ]; try { @@ -49,6 +50,11 @@ final class Healthchecks { $finishedAt = Validate::optionalInt($body, 'finished_at'); Store::updateHealthcheck($id, $finishedAt); } + // Same missing-vs-null contract as finished_at: absent leaves the + // scheduled date alone, explicit null (or '') clears it. + if (array_key_exists('next_due', $body)) { + Store::setNextDue($id, Validate::optionalDate($body, 'next_due')); + } Http::json(200, ['ok' => true]); } diff --git a/server/src/Store.php b/server/src/Store.php index 2c89afa..8757942 100644 --- a/server/src/Store.php +++ b/server/src/Store.php @@ -11,8 +11,8 @@ final class Store { public static function insertHealthcheck(array $row): void { $now = time(); $stmt = Db::pdo()->prepare('INSERT INTO healthchecks - (id, site_key, started_at, finished_at, technician_id, reporting_url, wp_version, php_version, created_at, updated_at) - VALUES (:id, :site_key, :started_at, NULL, :technician_id, :reporting_url, :wp_version, :php_version, :created_at, :updated_at)'); + (id, site_key, started_at, finished_at, technician_id, reporting_url, wp_version, php_version, next_due, created_at, updated_at) + VALUES (:id, :site_key, :started_at, NULL, :technician_id, :reporting_url, :wp_version, :php_version, :next_due, :created_at, :updated_at)'); $stmt->execute([ ':id' => $row['id'], ':site_key' => $row['site_key'], @@ -21,6 +21,7 @@ final class Store { ':reporting_url' => $row['reporting_url'], ':wp_version' => $row['wp_version'] ?? null, ':php_version' => $row['php_version'] ?? null, + ':next_due' => $row['next_due'] ?? null, ':created_at' => $now, ':updated_at' => $now, ]); @@ -43,6 +44,22 @@ final class Store { return $stmt->rowCount() > 0; } + /** + * Set (or clear, with null) the next-due date on one healthcheck. + * + * Deliberately separate from updateHealthcheck() so that finishing or + * reopening a session never touches the scheduled date, and vice versa. + */ + public static function setNextDue(string $id, ?string $nextDue): bool { + $stmt = Db::pdo()->prepare('UPDATE healthchecks SET next_due = :next_due, updated_at = :updated_at WHERE id = :id'); + $stmt->execute([ + ':id' => $id, + ':next_due' => $nextDue, + ':updated_at' => time(), + ]); + return $stmt->rowCount() > 0; + } + public static function upsertStep(string $healthcheckId, string $stepId, array $row): void { $pdo = Db::pdo(); $driver = $pdo->getAttribute(\PDO::ATTR_DRIVER_NAME); @@ -180,7 +197,12 @@ final class Store { /** * Summary of all sites for the dashboard: one row per site_key, * ordered by most-recently-active first. - * @return array + * `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 */ public static function allSitesSummary(): array { $stmt = Db::pdo()->query( @@ -198,7 +220,7 @@ final class Store { $out = []; foreach ($rows as $r) { $detail = Db::pdo()->prepare( - 'SELECT reporting_url, finished_at FROM healthchecks + 'SELECT reporting_url, finished_at, next_due FROM healthchecks WHERE site_key = ? ORDER BY started_at DESC LIMIT 1' ); $detail->execute([$r['site_key']]); @@ -210,6 +232,9 @@ final class Store { 'last_started_at' => (int) $r['last_started_at'], 'last_finished_at' => isset($last['finished_at']) && $last['finished_at'] !== null ? (int) $last['finished_at'] : null, 'last_reporting_url' => (string) ($last['reporting_url'] ?? ''), + 'next_due' => isset($last['next_due']) && $last['next_due'] !== null && $last['next_due'] !== '' + ? (string) $last['next_due'] + : null, ]; } return $out; diff --git a/server/src/Validate.php b/server/src/Validate.php index 08bfb0e..869154f 100644 --- a/server/src/Validate.php +++ b/server/src/Validate.php @@ -38,6 +38,29 @@ final class Validate { return $body[$key]; } + /** + * A calendar date as 'YYYY-MM-DD', or null. + * + * Absent, null, and '' all mean "no date" (the plugin sends '' when the tech + * clears the field). Anything else must be a real date in exactly that + * format — the round-trip comparison rejects both sloppy input ('2026-2-3') + * and impossible dates ('2026-02-30', which createFromFormat would silently + * roll forward to March 2nd). + */ + public static function optionalDate(array $body, string $key): ?string { + if (!isset($body[$key])) return null; + if (!is_string($body[$key])) self::fail("{$key} must be a YYYY-MM-DD date string or null"); + + $value = trim($body[$key]); + if ($value === '') return null; + + $date = \DateTimeImmutable::createFromFormat('!Y-m-d', $value); + if ($date === false || $date->format('Y-m-d') !== $value) { + self::fail("{$key} must be a valid calendar date in YYYY-MM-DD format"); + } + return $value; + } + public static function status(string $status): string { $valid = ['not_started', 'done', 'skipped', 'blocked', 'n_a']; if (!in_array($status, $valid, true)) self::fail('status must be one of ' . implode(', ', $valid)); diff --git a/tests/lib/harness.php b/tests/lib/harness.php new file mode 100644 index 0000000..543ef48 --- /dev/null +++ b/tests/lib/harness.php @@ -0,0 +1,109 @@ + */ + 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); +} diff --git a/tests/lib/test-server.php b/tests/lib/test-server.php new file mode 100644 index 0000000..4ff5689 --- /dev/null +++ b/tests/lib/test-server.php @@ -0,0 +1,213 @@ + */ + 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', " 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 $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]. + * + * 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; +} diff --git a/tests/plugin_test.php b/tests/plugin_test.php new file mode 100644 index 0000000..8bc383f --- /dev/null +++ b/tests/plugin_test.php @@ -0,0 +1,78 @@ +alert(1)')); +}); + +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/')); +}); diff --git a/tests/run.php b/tests/run.php new file mode 100644 index 0000000..f8da42c --- /dev/null +++ b/tests/run.php @@ -0,0 +1,67 @@ +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(); diff --git a/tests/server_test.php b/tests/server_test.php new file mode 100644 index 0000000..85c8728 --- /dev/null +++ b/tests/server_test.php @@ -0,0 +1,383 @@ + 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 '<>'; + return array_key_exists($key, $row) ? $row[$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 from a dashboard table that mentions $needle — for precise assertions. */ +function row_containing(string $html, string $needle): string { + if (preg_match_all('#.*?#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('#
.*?
#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 $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('Next due', $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)); +});