Compare commits

..

6 Commits

Author SHA1 Message Date
5be5ad2398 Release 0.1.7 — sidebar step dots track status live (hc-l0j)
After an AJAX status save, repaint the matching sidebar step-list dot instead
of leaving it stale until the next page load. Recolouring the dot's class is
enough for the link text too: the existing `li:has(.dot-done) a` rule turns it
green when done and back to the default blue otherwise.

- Sidebar dots gained a data-step attribute so the save script can find them.
- applyStatus() now also sets the sidebar dot class alongside the header badge
  and escalation banner it already updated.

Plugin-only. Bumps header, ATT_HC_VERSION and updates.json.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-27 08:42:16 +01:00
bf226e4174 Release 0.1.6 — fix AJAX step-save listeners never attaching (hc-5yy)
The 0.1.5 auto-save never fired: the inline <script> is emitted before the
step cards in the markup, so document.querySelectorAll('form.att-hc-step-save')
ran at parse time against a DOM with no forms yet and attached no listeners —
changing status or blurring notes produced no request at all.

The pre-existing step-history loader had the identical latent defect (its
"Previous notes" expander was also dead on live for the same reason).

Fix: both inline scripts now defer their querySelectorAll wiring to
DOMContentLoaded, so it runs after the cards are parsed. No reordering of the
markup, so the <style> blocks stay put and there's no flash of unstyled UI.

Bumps header, ATT_HC_VERSION and updates.json so PUC offers it to the sites
already on the broken 0.1.5. Plugin-only; server untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-27 08:37:07 +01:00
a3d2a7807f Release 0.1.5 — AJAX auto-save on the reporting page (hc-66c)
The step cards saved via a full-page POST, and the reload broke the
technician's flow. Save in place instead:

- Status saves on change; notes save on blur (only when actually edited).
- Inline per-card feedback (Saving… / Saved ✓ / error), no reload.
- On a status change the header badge updates live and the escalation
  banner shows/hides itself, matching a full server render.

Progressive enhancement: a new wp_ajax_att_hc_save_step_ajax handler reuses
the same capability + nonce + step validation + write-through as the form
handler, which is kept as the no-JS fallback. Mirrors the existing
step-history AJAX loader.

Bumps the plugin header, ATT_HC_VERSION and updates.json so PUC offers this
to installed sites. Server-side unaffected — this is plugin-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-27 08:27:34 +01:00
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 1301 additions and 23 deletions

View File

@@ -1,3 +1,4 @@
{"_type":"issue","id":"hc-5yy","title":"AJAX step-save listeners never attach (script runs before cards render)","description":"0.1.5 shipped the AJAX auto-save, but the inline script is emitted before the step-card markup, so document.querySelectorAll('form.att-hc-step-save') matches nothing and no change/blur listeners attach — no network request fires. The pre-existing step-history loader shared the same defect. Fixed by deferring both scripts' wiring to DOMContentLoaded. Released in 0.1.6.","status":"closed","priority":1,"issue_type":"bug","owner":"steve@hanlon.co.uk","created_at":"2026-07-27T07:36:54Z","created_by":"Steve Hanlon","updated_at":"2026-07-27T07:37:07Z","closed_at":"2026-07-27T07:37:07Z","close_reason":"Fixed in 0.1.6 — both inline scripts now defer listener wiring to DOMContentLoaded.","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"hc-4m5","title":"Plugin: allow ATT_HC_API_URL / ATT_HC_API_KEY via settings screen (not just wp-config)","description":"Some managed hosts make editing wp-config.php impractical or explicitly forbid it (WPE, some resellers, some clients' own operations teams). Add a settings-screen alternative to the existing constants.\n\nDesign (mirror the pattern already used for the Gitea recovery config on the same settings page):\n\n- Two new WP options: att_hc_api_url and att_hc_api_key (autoload=false on the key).\n- ATT_HC_Api::url() and ATT_HC_Api::key() accessors: return the constant if defined + non-empty, else the option. All existing consumers switch to these accessors instead of the constants directly.\n- config_error() message updates to say 'add to wp-config.php OR set via Tools -\u003e Site Healthcheck -\u003e Settings'.\n- Settings page gets a new 'Central history server' card above the existing Gitea card, with:\n - URL input (type=url) — locked with 'Set via ATT_HC_API_URL constant' description when constant is defined\n - Key input (type=password, autocomplete=new-password) — same lock behaviour\n- Save handler extends the existing att_hc_save_settings to persist the two options.\n- No migration needed — sites using constants keep working untouched.\n\nSecurity note to include in the description on the settings page: the option-stored key is visible to any WP admin (options.php, DB) — the constant path is stronger. Recommend constant when possible.\n\nUser asked for this because some sites can't edit wp-config.php.","status":"closed","priority":1,"issue_type":"feature","assignee":"Steve Hanlon","owner":"steve@hanlon.co.uk","created_at":"2026-07-07T13:51:13Z","created_by":"Steve Hanlon","updated_at":"2026-07-07T13:54:15Z","started_at":"2026-07-07T13:51:24Z","closed_at":"2026-07-07T13:54:15Z","close_reason":"Added a 'Central history server' card at the top of the settings page (Tools → Site Healthcheck → Settings) with URL + API key fields. Storage: att_hc_api_url and att_hc_api_key options (autoload=false on the key). Resolver: ATT_HC_Api::url() / ATT_HC_Api::key() return the constant when defined and non-empty, else the option, else ''. url_from_constant() / key_from_constant() drive the per-field lock on the settings page. All existing consumers (request(), config_error(), is_configured(), the config-error notice) switched to the accessors. Separate action + nonce (att_hc_save_api_settings) so it doesn't tangle with the existing gitea save. Handler ignores submitted values for fields locked by a constant — belt-and-braces with the disabled input. 18 assertions verified: no-config / options-only / http-blocked / loopback-http-ok / constant-wins-over-option. Both PHP 8.3 and PHP 7.4 parse cleanly.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"hc-4m5","title":"Plugin: allow ATT_HC_API_URL / ATT_HC_API_KEY via settings screen (not just wp-config)","description":"Some managed hosts make editing wp-config.php impractical or explicitly forbid it (WPE, some resellers, some clients' own operations teams). Add a settings-screen alternative to the existing constants.\n\nDesign (mirror the pattern already used for the Gitea recovery config on the same settings page):\n\n- Two new WP options: att_hc_api_url and att_hc_api_key (autoload=false on the key).\n- ATT_HC_Api::url() and ATT_HC_Api::key() accessors: return the constant if defined + non-empty, else the option. All existing consumers switch to these accessors instead of the constants directly.\n- config_error() message updates to say 'add to wp-config.php OR set via Tools -\u003e Site Healthcheck -\u003e Settings'.\n- Settings page gets a new 'Central history server' card above the existing Gitea card, with:\n - URL input (type=url) — locked with 'Set via ATT_HC_API_URL constant' description when constant is defined\n - Key input (type=password, autocomplete=new-password) — same lock behaviour\n- Save handler extends the existing att_hc_save_settings to persist the two options.\n- No migration needed — sites using constants keep working untouched.\n\nSecurity note to include in the description on the settings page: the option-stored key is visible to any WP admin (options.php, DB) — the constant path is stronger. Recommend constant when possible.\n\nUser asked for this because some sites can't edit wp-config.php.","status":"closed","priority":1,"issue_type":"feature","assignee":"Steve Hanlon","owner":"steve@hanlon.co.uk","created_at":"2026-07-07T13:51:13Z","created_by":"Steve Hanlon","updated_at":"2026-07-07T13:54:15Z","started_at":"2026-07-07T13:51:24Z","closed_at":"2026-07-07T13:54:15Z","close_reason":"Added a 'Central history server' card at the top of the settings page (Tools → Site Healthcheck → Settings) with URL + API key fields. Storage: att_hc_api_url and att_hc_api_key options (autoload=false on the key). Resolver: ATT_HC_Api::url() / ATT_HC_Api::key() return the constant when defined and non-empty, else the option, else ''. url_from_constant() / key_from_constant() drive the per-field lock on the settings page. All existing consumers (request(), config_error(), is_configured(), the config-error notice) switched to the accessors. Separate action + nonce (att_hc_save_api_settings) so it doesn't tangle with the existing gitea save. Handler ignores submitted values for fields locked by a constant — belt-and-braces with the disabled input. 18 assertions verified: no-config / options-only / http-blocked / loopback-http-ok / constant-wins-over-option. Both PHP 8.3 and PHP 7.4 parse cleanly.","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"hc-eff","title":"Plugin fatals on PHP 7.4 — PHP 8 syntax in central-server work","description":"The central history server work (epic hc-0p1) introduced four PHP 8.0+ syntax sites in the plugin codebase. PHP 7.4 fails to parse the affected files, so on a host running PHP 7.4 (eg one user reported Ubuntu 7.4.3-4ubuntu2.29) the plugin throws a fatal on load.\n\nSites:\n- includes/class-att-hc-session.php:244 — str_starts_with()\n- includes/class-att-hc-api.php:52 — named argument requires_auth: false\n- includes/class-att-hc-session.php:126 — named arguments include_steps:, limit:\n- includes/admin-page.php:165 — named arguments include_steps:, limit:\n\nPlugin main file header still says 'Requires PHP: 7.4', so the expectation is PHP 7.4 support. Backport: replace str_starts_with with substr === comparison; convert named args to positional (signatures accept positional already).\n\nAffects every plugin user on PHP \u003c 8.0.","status":"closed","priority":1,"issue_type":"bug","assignee":"Steve Hanlon","owner":"steve@hanlon.co.uk","created_at":"2026-06-30T08:27:51Z","created_by":"Steve Hanlon","updated_at":"2026-06-30T08:29:22Z","started_at":"2026-06-30T08:27:57Z","closed_at":"2026-06-30T08:29:22Z","close_reason":"Backported four PHP 8.0+ syntax sites to PHP 7.4-compatible equivalents. str_starts_with replaced with substr(...) === literal. Three named-argument call sites converted to positional — signatures already accept positional with the same values, no semantic change. Verified by linting all 16 plugin files (att-site-healthcheck.php + includes/) against PHP 7.4.33 on sjh: no syntax errors, no residual PHP 8+ patterns. Plugin's 'Requires PHP: 7.4' header is now actually true.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"hc-eff","title":"Plugin fatals on PHP 7.4 — PHP 8 syntax in central-server work","description":"The central history server work (epic hc-0p1) introduced four PHP 8.0+ syntax sites in the plugin codebase. PHP 7.4 fails to parse the affected files, so on a host running PHP 7.4 (eg one user reported Ubuntu 7.4.3-4ubuntu2.29) the plugin throws a fatal on load.\n\nSites:\n- includes/class-att-hc-session.php:244 — str_starts_with()\n- includes/class-att-hc-api.php:52 — named argument requires_auth: false\n- includes/class-att-hc-session.php:126 — named arguments include_steps:, limit:\n- includes/admin-page.php:165 — named arguments include_steps:, limit:\n\nPlugin main file header still says 'Requires PHP: 7.4', so the expectation is PHP 7.4 support. Backport: replace str_starts_with with substr === comparison; convert named args to positional (signatures accept positional already).\n\nAffects every plugin user on PHP \u003c 8.0.","status":"closed","priority":1,"issue_type":"bug","assignee":"Steve Hanlon","owner":"steve@hanlon.co.uk","created_at":"2026-06-30T08:27:51Z","created_by":"Steve Hanlon","updated_at":"2026-06-30T08:29:22Z","started_at":"2026-06-30T08:27:57Z","closed_at":"2026-06-30T08:29:22Z","close_reason":"Backported four PHP 8.0+ syntax sites to PHP 7.4-compatible equivalents. str_starts_with replaced with substr(...) === literal. Three named-argument call sites converted to positional — signatures already accept positional with the same values, no semantic change. Verified by linting all 16 plugin files (att-site-healthcheck.php + includes/) against PHP 7.4.33 on sjh: no syntax errors, no residual PHP 8+ patterns. Plugin's 'Requires PHP: 7.4' header is now actually true.","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"hc-cc8","title":"Plugin: per-step 'view previous notes' panel","description":"Each step card gets a small link/disclosure: 'Previous notes for this step (N)' where N is the count from the server.\n\nOn expand: render a list of past notes from GET /healthchecks/steps/{step_id}?site_key=\u003ccurrent\u003e\u0026limit=5. Each entry shows:\n- started_at (formatted)\n- status badge\n- notes (or 'no notes')\n- reporting_url if different from the current session\n\nLoaded lazily on first expand (one request per step) to avoid hammering the server on page load. Cache result for the page lifetime.","status":"closed","priority":1,"issue_type":"feature","assignee":"Steve Hanlon","owner":"steve@hanlon.co.uk","created_at":"2026-06-29T11:10:17Z","created_by":"Steve Hanlon","updated_at":"2026-06-29T11:41:06Z","started_at":"2026-06-29T11:36:10Z","closed_at":"2026-06-29T11:41:06Z","close_reason":"Per-step history disclosure shipped end-to-end.\n\nServer:\n- New GET /step-counts?site_key=...\u0026exclude_id=... returns {counts: {step_id: int}} for the (N) badge in one round-trip\n- stepHistory + stepCountsForSite both grew an exclude_id param so the active session doesn't appear in its own 'previous notes' panel\n- Routes: /step-counts moved to top-level path to avoid the /healthchecks/{id} pattern claiming 'step-counts' as an id\n\nPlugin:\n- ATT_HC_Api::step_counts() and step_history() pass exclude_id when available\n- att_hc_render_active_session pre-fetches step counts once and passes per-step count to the card renderer\n- Step card now renders a \u003cdetails class=att-hc-history data-step-id=…\u003e with summary 'Previous notes for this step (N)' when N\u003e0\n- New att_hc_print_step_history_assets prints the inline CSS + vanilla JS that hooks the details.toggle event: lazy-fetches on first expand via admin-ajax (action=att_hc_step_history), renders entries with date + status badge + notes + 'Reported from \u003curl\u003e' when different from current reporting_url. Marks data-loaded=yes to cache for page lifetime; resets to no on error so user can retry.\n- New wp_ajax_att_hc_step_history admin-ajax handler returns wp_send_json_success/error with nonce verification (att_hc_step_history nonce)\n\nVerified end-to-end against live server: counts correct with + without exclude_id (4 vs 3 for active vs prior), step_history filters active session, empty step / unknown site return cleanly, reordered routes still work.","dependencies":[{"issue_id":"hc-cc8","depends_on_id":"hc-0p1","type":"parent-child","created_at":"2026-06-29T12:13:24Z","created_by":"Steve Hanlon","metadata":"{}"},{"issue_id":"hc-cc8","depends_on_id":"hc-9jl","type":"blocks","created_at":"2026-06-29T12:10:37Z","created_by":"Steve Hanlon","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"hc-cc8","title":"Plugin: per-step 'view previous notes' panel","description":"Each step card gets a small link/disclosure: 'Previous notes for this step (N)' where N is the count from the server.\n\nOn expand: render a list of past notes from GET /healthchecks/steps/{step_id}?site_key=\u003ccurrent\u003e\u0026limit=5. Each entry shows:\n- started_at (formatted)\n- status badge\n- notes (or 'no notes')\n- reporting_url if different from the current session\n\nLoaded lazily on first expand (one request per step) to avoid hammering the server on page load. Cache result for the page lifetime.","status":"closed","priority":1,"issue_type":"feature","assignee":"Steve Hanlon","owner":"steve@hanlon.co.uk","created_at":"2026-06-29T11:10:17Z","created_by":"Steve Hanlon","updated_at":"2026-06-29T11:41:06Z","started_at":"2026-06-29T11:36:10Z","closed_at":"2026-06-29T11:41:06Z","close_reason":"Per-step history disclosure shipped end-to-end.\n\nServer:\n- New GET /step-counts?site_key=...\u0026exclude_id=... returns {counts: {step_id: int}} for the (N) badge in one round-trip\n- stepHistory + stepCountsForSite both grew an exclude_id param so the active session doesn't appear in its own 'previous notes' panel\n- Routes: /step-counts moved to top-level path to avoid the /healthchecks/{id} pattern claiming 'step-counts' as an id\n\nPlugin:\n- ATT_HC_Api::step_counts() and step_history() pass exclude_id when available\n- att_hc_render_active_session pre-fetches step counts once and passes per-step count to the card renderer\n- Step card now renders a \u003cdetails class=att-hc-history data-step-id=…\u003e with summary 'Previous notes for this step (N)' when N\u003e0\n- New att_hc_print_step_history_assets prints the inline CSS + vanilla JS that hooks the details.toggle event: lazy-fetches on first expand via admin-ajax (action=att_hc_step_history), renders entries with date + status badge + notes + 'Reported from \u003curl\u003e' when different from current reporting_url. Marks data-loaded=yes to cache for page lifetime; resets to no on error so user can retry.\n- New wp_ajax_att_hc_step_history admin-ajax handler returns wp_send_json_success/error with nonce verification (att_hc_step_history nonce)\n\nVerified end-to-end against live server: counts correct with + without exclude_id (4 vs 3 for active vs prior), step_history filters active session, empty step / unknown site return cleanly, reordered routes still work.","dependencies":[{"issue_id":"hc-cc8","depends_on_id":"hc-0p1","type":"parent-child","created_at":"2026-06-29T12:13:24Z","created_by":"Steve Hanlon","metadata":"{}"},{"issue_id":"hc-cc8","depends_on_id":"hc-9jl","type":"blocks","created_at":"2026-06-29T12:10:37Z","created_by":"Steve Hanlon","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0}
@@ -15,6 +16,8 @@
{"_type":"issue","id":"hc-5ix.4","title":"Session data model: option-backed in-progress healthcheck record","description":"One in-progress session per site at a time. Stored in a custom option (or a CPT — pick during implementation). Shape: id, started_at, finished_at, technician_id, site_url_snapshot, per_step_state { status: not_started|in_progress|done|skipped|blocked|n_a, notes, completed_at }. Designed so phase-3 automation can attach structured findings later.","notes":"Built in phase-1 scaffold; passing lint + WP-eval end-to-end smoke test on testsite.","status":"closed","priority":1,"issue_type":"task","owner":"steve@hanlon.co.uk","created_at":"2026-06-11T14:36:11Z","created_by":"Steve Hanlon","updated_at":"2026-06-11T14:49:27Z","started_at":"2026-06-11T14:41:27Z","closed_at":"2026-06-11T14:49:27Z","labels":["phase-1"],"dependencies":[{"issue_id":"hc-5ix.4","depends_on_id":"hc-5ix","type":"parent-child","created_at":"2026-06-11T15:36:11Z","created_by":"Steve Hanlon","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"hc-5ix.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-66c","title":"AJAX step saving on the healthcheck reporting page","description":"The step cards currently save via a full-page POST, which reloads the page and breaks the technician's flow. Switch to AJAX: notes save on blur, status saves on change, with inline save feedback and no reload.","status":"closed","priority":2,"issue_type":"feature","owner":"steve@hanlon.co.uk","created_at":"2026-07-27T07:21:00Z","created_by":"Steve Hanlon","updated_at":"2026-07-27T07:27:23Z","closed_at":"2026-07-27T07:27:23Z","close_reason":"Implemented in 0.1.5: AJAX auto-save on the step cards — status saves on change, notes on blur, inline feedback, no reload. Progressive enhancement; no-JS form POST retained as fallback.","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"hc-fbd","title":"Next healthcheck due date per site","description":"Technician can set 'next healthcheck due' while working a session; stored on the server against the healthcheck row, surfaced in the dashboard site list and session views so it is visible when planning work.","acceptance_criteria":"Plugin UI sets/clears a date on the active session; server persists + validates YYYY-MM-DD; dashboard shows next-due per site (derived from latest session) and per session; report includes it; tests pass.","status":"closed","priority":2,"issue_type":"feature","owner":"steve@hanlon.co.uk","created_at":"2026-07-23T10:50:25Z","created_by":"Steve Hanlon","updated_at":"2026-07-23T11:04:08Z","closed_at":"2026-07-23T11:04:08Z","close_reason":"Implemented and released in 0.1.4: server column + validation + dashboard views, plugin date control, 42-test suite. Commits 6313857, 55990be.","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"hc-lh6","title":"Dashboard: reopen a finished healthcheck","description":"Techs sometimes finish a healthcheck and later need to add a note (client came back with something after signoff). Currently there's no way to unfinish — the session is read-only on the dashboard.\n\nAdd a 'Reopen' button on the session-detail dashboard view that clears finished_at on the server, plus tighten the PUT /healthchecks/{id} controller so it doesn't accidentally reopen sessions when a client sends an empty body (latent bug: Validate::optionalInt returns null for missing keys, so an empty PUT currently sets finished_at=NULL).","design":"**Backend:**\n1. Tighten Controllers/Healthchecks::update() to use array_key_exists('finished_at', $body) instead of optionalInt. Missing key = no-op; present-with-null = reopen; present-with-int = finish/update.\n2. Add POST /dashboard/healthchecks/{id}/reopen — session-authenticated (mirrors the GET /dashboard auth pattern), CSRF-protected via a per-session token.\n3. On success, redirect back to the session detail page.\n\n**Frontend (dashboard):**\n4. In renderSessionDetail(), when finished_at !== null, add a small form with the reopen button + hidden CSRF token.\n5. Confirm dialog via inline JS onsubmit so the button isn't a one-click landmine.\n\n**CSRF token pattern:** stored in $_SESSION['att_hc_csrf'], generated on first use with random_bytes(16); verified with hash_equals().\n\n**Not doing (option (b) from the discussion):** preserving original finished_at in a separate column. If we want that later it's a separate migration + Store change.","acceptance_criteria":"- [ ] Empty PUT /healthchecks/{id} does not reopen (returns 200 no-op)\n- [ ] PUT /healthchecks/{id} with {\"finished_at\": null} reopens\n- [ ] PUT /healthchecks/{id} with {\"finished_at\": \u003cint\u003e} still finishes\n- [ ] Dashboard session-detail view shows Reopen button only when finished\n- [ ] Clicking Reopen (with confirm) clears finished_at and reloads the page\n- [ ] Reopen POST rejected without valid CSRF token\n- [ ] Reopen POST rejected without valid dashboard session","status":"closed","priority":2,"issue_type":"feature","assignee":"Steve Hanlon","owner":"steve@hanlon.co.uk","created_at":"2026-07-21T10:50:06Z","created_by":"Steve Hanlon","updated_at":"2026-07-21T10:52:00Z","started_at":"2026-07-21T10:50:12Z","closed_at":"2026-07-21T10:52:00Z","close_reason":"Implemented: dashboard reopen button + CSRF + tightened Healthchecks::update controller so empty PUT no longer accidentally reopens sessions.","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"hc-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}
@@ -26,6 +29,7 @@
{"_type":"issue","id":"hc-5ix.9","title":"Smoke test on testsite: full end-to-end run through the checklist","notes":"Built in phase-1 scaffold; passing lint + WP-eval end-to-end smoke test on testsite.","status":"closed","priority":2,"issue_type":"task","owner":"steve@hanlon.co.uk","created_at":"2026-06-11T14:36:15Z","created_by":"Steve Hanlon","updated_at":"2026-06-11T14:49:29Z","closed_at":"2026-06-11T14:49:29Z","labels":["phase-1"],"dependencies":[{"issue_id":"hc-5ix.9","depends_on_id":"hc-5ix","type":"parent-child","created_at":"2026-06-11T15:36:14Z","created_by":"Steve Hanlon","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"hc-5ix.9","title":"Smoke test on testsite: full end-to-end run through the checklist","notes":"Built in phase-1 scaffold; passing lint + WP-eval end-to-end smoke test on testsite.","status":"closed","priority":2,"issue_type":"task","owner":"steve@hanlon.co.uk","created_at":"2026-06-11T14:36:15Z","created_by":"Steve Hanlon","updated_at":"2026-06-11T14:49:29Z","closed_at":"2026-06-11T14:49:29Z","labels":["phase-1"],"dependencies":[{"issue_id":"hc-5ix.9","depends_on_id":"hc-5ix","type":"parent-child","created_at":"2026-06-11T15:36:14Z","created_by":"Steve Hanlon","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"hc-5ix.8","title":"README + install instructions + screenshots placeholder","notes":"Built in phase-1 scaffold; passing lint + WP-eval end-to-end smoke test on testsite.","status":"closed","priority":2,"issue_type":"chore","owner":"steve@hanlon.co.uk","created_at":"2026-06-11T14:36:14Z","created_by":"Steve Hanlon","updated_at":"2026-06-11T14:49:29Z","closed_at":"2026-06-11T14:49:29Z","labels":["phase-1"],"dependencies":[{"issue_id":"hc-5ix.8","depends_on_id":"hc-5ix","type":"parent-child","created_at":"2026-06-11T15:36:14Z","created_by":"Steve Hanlon","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"hc-5ix.8","title":"README + install instructions + screenshots placeholder","notes":"Built in phase-1 scaffold; passing lint + WP-eval end-to-end smoke test on testsite.","status":"closed","priority":2,"issue_type":"chore","owner":"steve@hanlon.co.uk","created_at":"2026-06-11T14:36:14Z","created_by":"Steve Hanlon","updated_at":"2026-06-11T14:49:29Z","closed_at":"2026-06-11T14:49:29Z","labels":["phase-1"],"dependencies":[{"issue_id":"hc-5ix.8","depends_on_id":"hc-5ix","type":"parent-child","created_at":"2026-06-11T15:36:14Z","created_by":"Steve Hanlon","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"hc-5ix","title":"Build WordPress Healthcheck plugin (stepper through steps.md)","description":"Plugin that walks a technician through the WordPress healthcheck steps documented in steps.md. Independent of the recovery plugin (which it depends on as step 0).","status":"open","priority":2,"issue_type":"epic","owner":"steve@hanlon.co.uk","created_at":"2026-06-11T11:26:32Z","created_by":"Steve Hanlon","updated_at":"2026-06-11T11:26:32Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"hc-5ix","title":"Build WordPress Healthcheck plugin (stepper through steps.md)","description":"Plugin that walks a technician through the WordPress healthcheck steps documented in steps.md. Independent of the recovery plugin (which it depends on as step 0).","status":"open","priority":2,"issue_type":"epic","owner":"steve@hanlon.co.uk","created_at":"2026-06-11T11:26:32Z","created_by":"Steve Hanlon","updated_at":"2026-06-11T11:26:32Z","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"hc-l0j","title":"Sidebar step dots track status changes live","description":"When a step status is saved via AJAX, recolour the sidebar step-list dot (and, via the existing :has() rule, the link text green/blue) live instead of only on page refresh. Extends applyStatus() in the step-save script; sidebar dots gained a data-step attribute. Released in 0.1.7.","status":"open","priority":3,"issue_type":"feature","owner":"steve@hanlon.co.uk","created_at":"2026-07-27T07:42:04Z","created_by":"Steve Hanlon","updated_at":"2026-07-27T07:42:04Z","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"hc-ufl","title":"Server: add rate limiting + lockout on API + /dashboard, rotate error_log","description":"Central healthcheck history server has no rate limiting or account lockout:\n\n- `server/src/Auth.php` — unlimited Bearer-key attempts, no delay, no lockout\n- `server/src/Dashboard.php:46-71` — unlimited login attempts on the /dashboard cookie-key form\n\nCombined with the shared-API-key exposure (see companion issue), a leaked or brute-forced key can be abused indefinitely with no signal.\n\nAlso: `server/src/bootstrap.php:25` calls `error_log()` on every uncaught exception with no rotation — the log grows unbounded on a busy or attacked server.","design":"**Rate limiting**: simplest workable option is a small in-DB counter table (key = client IP + endpoint, value = attempt count + window start). Reject when \u003e N attempts / window. No Redis dependency needed at current scale.\n\n**Lockout on /dashboard**: after e.g. 5 failed attempts from one IP within 15 min, return 429 with Retry-After. Keep it IP-scoped (not global) so a random attacker can't lock out real users.\n\n**Auth on API endpoints**: constant-time compare is already correct (`Auth.php:20` uses `hash_equals`). Add: increment failed-attempt counter, refuse to serve after threshold. Consider structured logging of each rejection with IP + user-agent for later review.\n\n**Log rotation**: either delegate to logrotate on the host (document in DEPLOY.md) OR self-rotate via a size check + rename in `bootstrap.php`'s handler.\n\nNice-to-have: session hardening on /dashboard — call `session_regenerate_id(true)` after successful auth (currently missing at `Dashboard.php:53-70`).","acceptance_criteria":"- [ ] API + /dashboard rate-limited per IP with a documented threshold\n- [ ] 429 with Retry-After returned when limit hit\n- [ ] error_log rotation strategy in place (either OS-level via DEPLOY.md docs, or in-app)\n- [ ] session_regenerate_id after /dashboard auth\n- [ ] Rejections logged with enough context to spot abuse patterns","notes":"Reported in 2026-07-16 security review. Flagged as risk #3 of 3 — lower priority than the API-key and recovery-installer issues but worth tackling in the same sweep since it's server-side and cheap.\n\nAlso noted (not required for close): session fixation on /dashboard is theoretically possible but requires attacker to first set a victim's PHPSESSID — high bar. Adding session_regenerate_id closes it cheaply.","status":"open","priority":3,"issue_type":"bug","owner":"steve@hanlon.co.uk","created_at":"2026-07-16T10:14:51Z","created_by":"Steve Hanlon","updated_at":"2026-07-16T10:14:51Z","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"hc-ufl","title":"Server: add rate limiting + lockout on API + /dashboard, rotate error_log","description":"Central healthcheck history server has no rate limiting or account lockout:\n\n- `server/src/Auth.php` — unlimited Bearer-key attempts, no delay, no lockout\n- `server/src/Dashboard.php:46-71` — unlimited login attempts on the /dashboard cookie-key form\n\nCombined with the shared-API-key exposure (see companion issue), a leaked or brute-forced key can be abused indefinitely with no signal.\n\nAlso: `server/src/bootstrap.php:25` calls `error_log()` on every uncaught exception with no rotation — the log grows unbounded on a busy or attacked server.","design":"**Rate limiting**: simplest workable option is a small in-DB counter table (key = client IP + endpoint, value = attempt count + window start). Reject when \u003e N attempts / window. No Redis dependency needed at current scale.\n\n**Lockout on /dashboard**: after e.g. 5 failed attempts from one IP within 15 min, return 429 with Retry-After. Keep it IP-scoped (not global) so a random attacker can't lock out real users.\n\n**Auth on API endpoints**: constant-time compare is already correct (`Auth.php:20` uses `hash_equals`). Add: increment failed-attempt counter, refuse to serve after threshold. Consider structured logging of each rejection with IP + user-agent for later review.\n\n**Log rotation**: either delegate to logrotate on the host (document in DEPLOY.md) OR self-rotate via a size check + rename in `bootstrap.php`'s handler.\n\nNice-to-have: session hardening on /dashboard — call `session_regenerate_id(true)` after successful auth (currently missing at `Dashboard.php:53-70`).","acceptance_criteria":"- [ ] API + /dashboard rate-limited per IP with a documented threshold\n- [ ] 429 with Retry-After returned when limit hit\n- [ ] error_log rotation strategy in place (either OS-level via DEPLOY.md docs, or in-app)\n- [ ] session_regenerate_id after /dashboard auth\n- [ ] Rejections logged with enough context to spot abuse patterns","notes":"Reported in 2026-07-16 security review. Flagged as risk #3 of 3 — lower priority than the API-key and recovery-installer issues but worth tackling in the same sweep since it's server-side and cheap.\n\nAlso noted (not required for close): session fixation on /dashboard is theoretically possible but requires attacker to first set a victim's PHPSESSID — high bar. Adding session_regenerate_id closes it cheaply.","status":"open","priority":3,"issue_type":"bug","owner":"steve@hanlon.co.uk","created_at":"2026-07-16T10:14:51Z","created_by":"Steve Hanlon","updated_at":"2026-07-16T10:14:51Z","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"hc-u5c","title":"Add Google Analytics and Search Console healthcheck steps","description":"Add two new healthcheck steps between performance (Step 7) and security (Step 8): a Google Analytics check that sniffs the homepage for GA4/GTM/UA snippets and detects known analytics plugins, and a Google Search Console check that looks for verification meta tags, sitemap reachability, robots.txt Disallow rules, and the WP 'Discourage search engines' setting. Updates steps.md to match.","status":"closed","priority":3,"issue_type":"feature","assignee":"Steve Hanlon","owner":"steve@hanlon.co.uk","created_at":"2026-07-16T08:44:39Z","created_by":"Steve Hanlon","updated_at":"2026-07-16T08:44:56Z","started_at":"2026-07-16T08:44:44Z","closed_at":"2026-07-16T08:44:56Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"hc-u5c","title":"Add Google Analytics and Search Console healthcheck steps","description":"Add two new healthcheck steps between performance (Step 7) and security (Step 8): a Google Analytics check that sniffs the homepage for GA4/GTM/UA snippets and detects known analytics plugins, and a Google Search Console check that looks for verification meta tags, sitemap reachability, robots.txt Disallow rules, and the WP 'Discourage search engines' setting. Updates steps.md to match.","status":"closed","priority":3,"issue_type":"feature","assignee":"Steve Hanlon","owner":"steve@hanlon.co.uk","created_at":"2026-07-16T08:44:39Z","created_by":"Steve Hanlon","updated_at":"2026-07-16T08:44:56Z","started_at":"2026-07-16T08:44:44Z","closed_at":"2026-07-16T08:44:56Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"hc-5ix.27","title":"Self-hosted update channel: plugin checks a private URL for new releases and offers one-click update from wp-admin","description":"Lightweight wrapper around the WP plugin update transient: site_transient_update_plugins filter that adds an entry for ourselves if a newer release exists at a configured URL. URL hosts a JSON manifest + zip. Lets technicians keep clients up to date without manually re-uploading the zip every check.","status":"open","priority":3,"issue_type":"task","owner":"steve@hanlon.co.uk","created_at":"2026-06-11T14:40:30Z","created_by":"Steve Hanlon","updated_at":"2026-06-11T14:40:30Z","labels":["phase-3"],"dependencies":[{"issue_id":"hc-5ix.27","depends_on_id":"hc-5ix","type":"parent-child","created_at":"2026-06-11T15:40:29Z","created_by":"Steve Hanlon","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"hc-5ix.27","title":"Self-hosted update channel: plugin checks a private URL for new releases and offers one-click update from wp-admin","description":"Lightweight wrapper around the WP plugin update transient: site_transient_update_plugins filter that adds an entry for ourselves if a newer release exists at a configured URL. URL hosts a JSON manifest + zip. Lets technicians keep clients up to date without manually re-uploading the zip every check.","status":"open","priority":3,"issue_type":"task","owner":"steve@hanlon.co.uk","created_at":"2026-06-11T14:40:30Z","created_by":"Steve Hanlon","updated_at":"2026-06-11T14:40:30Z","labels":["phase-3"],"dependencies":[{"issue_id":"hc-5ix.27","depends_on_id":"hc-5ix","type":"parent-child","created_at":"2026-06-11T15:40:29Z","created_by":"Steve Hanlon","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}

1
.gitattributes vendored
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.7
* 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.7');
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

@@ -5,8 +5,10 @@ add_action('admin_menu', 'att_hc_register_menu');
add_action('admin_post_att_hc_start', 'att_hc_handle_start'); add_action('admin_post_att_hc_start', 'att_hc_handle_start');
add_action('admin_post_att_hc_resume', 'att_hc_handle_resume'); 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('wp_ajax_att_hc_save_step_ajax', 'att_hc_handle_save_step_ajax');
add_action('admin_post_att_hc_save_step', 'att_hc_handle_save_step'); add_action('admin_post_att_hc_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 +55,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 +138,12 @@ function att_hc_render_admin_page(): void {
echo '<div class="notice notice-error"><p><strong>Central history server:</strong> ' . esc_html($err) . '</p></div>'; 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 +266,7 @@ function att_hc_render_active_session(ATT_HC_Session $session): void {
<span class="att-hc-progress"><?php echo (int) $progress['done']; ?> / <?php echo (int) $progress['total']; ?> steps</span> <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'); ?>
@@ -285,6 +297,7 @@ function att_hc_render_active_session(ATT_HC_Session $session): void {
} }
att_hc_print_step_history_assets($session); att_hc_print_step_history_assets($session);
att_hc_print_step_save_assets($session);
echo '<div class="att-hc-layout">'; echo '<div class="att-hc-layout">';
att_hc_render_sidebar($session); att_hc_render_sidebar($session);
@@ -295,6 +308,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'),
@@ -350,6 +387,12 @@ function att_hc_print_step_history_assets(ATT_HC_Session $session): void {
'</div>'; '</div>';
}).join(''); }).join('');
} }
// Emitted before the step cards, so defer wiring until the DOM exists.
function ready(fn) {
if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', fn); }
else { fn(); }
}
ready(function () {
document.querySelectorAll('details.att-hc-history').forEach(function (det) { document.querySelectorAll('details.att-hc-history').forEach(function (det) {
det.addEventListener('toggle', function () { det.addEventListener('toggle', function () {
if (!det.open || det.dataset.loaded === 'yes') return; if (!det.open || det.dataset.loaded === 'yes') return;
@@ -377,6 +420,125 @@ function att_hc_print_step_history_assets(ATT_HC_Session $session): void {
}); });
}); });
}); });
});
})();
</script>
<?php
}
/**
* Client-side auto-save for the step cards: status saves on change, notes save
* on blur, in place via AJAX. Progressive enhancement — the server-rendered
* form still works with JS off, and the explicit "Save step" button is only
* hidden once this wires up.
*/
function att_hc_print_step_save_assets(ATT_HC_Session $session): void {
$cfg = [
'ajaxUrl' => admin_url('admin-ajax.php'),
'nonce' => wp_create_nonce('att_hc_save_step_ajax'),
];
?>
<style>
.att-hc-step-save.js-live button[type="submit"],
.att-hc-step-save.js-live button:not([type]) { display: none; }
.att-hc-save-status.is-saving { color: #646970; }
.att-hc-save-status.is-saved { color: #1a8917; font-style: normal; }
.att-hc-save-status.is-error { color: #b32d2e; font-style: normal; }
.att-hc-step-save select.is-saving,
.att-hc-step-save textarea.is-saving { opacity: .6; }
</style>
<script>
(function () {
var cfg = <?php echo wp_json_encode($cfg); ?>;
function statusLabel(s) { return s.replace(/_/g, ' '); }
// These assets are emitted before the step cards in the markup, so the
// forms don't exist yet when this inline script first runs. Wait for the
// DOM before wiring listeners.
function ready(fn) {
if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', fn); }
else { fn(); }
}
ready(function () {
document.querySelectorAll('form.att-hc-step-save').forEach(function (form) {
var stepId = form.dataset.step;
var select = form.querySelector('select[name="status"]');
var notes = form.querySelector('textarea[name="notes"]');
var feedback = form.querySelector('.att-hc-save-status');
var card = form.closest('.att-hc-step');
if (!stepId || !select || !notes || !feedback) return;
// Signals JS is active: CSS hides the now-redundant Save button.
form.classList.add('js-live');
// Last value we've successfully persisted, so a blur with no edit is a no-op.
var savedNotes = notes.value;
function setFeedback(cls, text) {
feedback.className = 'att-hc-save-status description ' + cls;
feedback.textContent = text;
}
function save(field) {
var body = new FormData();
body.append('action', 'att_hc_save_step_ajax');
body.append('nonce', cfg.nonce);
body.append('step', stepId);
body.append('status', select.value);
body.append('notes', notes.value);
field.classList.add('is-saving');
setFeedback('is-saving', 'Saving…');
return fetch(cfg.ajaxUrl, { method: 'POST', credentials: 'same-origin', body: body })
.then(function (r) { return r.json(); })
.then(function (j) {
field.classList.remove('is-saving');
if (!j || !j.success) {
setFeedback('is-error', (j && j.data) || 'Save failed.');
return;
}
savedNotes = notes.value;
applyStatus(j.data.status, j.data.status_label);
setFeedback('is-saved', 'Saved ✓');
})
.catch(function (e) {
field.classList.remove('is-saving');
setFeedback('is-error', 'Network error — not saved: ' + e.message);
});
}
// Reflect a status change in the header badge, escalation banner and
// sidebar dot, matching what a full server render would have produced.
// Recolouring the sidebar dot's class is enough for the link text too:
// the `li:has(.dot-done) a` CSS rule turns it green / back to default.
function applyStatus(status, label) {
if (card) {
var badge = card.querySelector('header .att-hc-step-status');
if (badge) {
badge.className = 'att-hc-step-status att-hc-status-' + status;
badge.textContent = label || statusLabel(status);
}
var esc = card.querySelector('[data-role="escalation"]');
if (esc) esc.hidden = (status !== 'blocked');
}
var dot = document.querySelector('.att-hc-sidebar .dot[data-step="' + stepId + '"]');
if (dot) dot.className = 'dot dot-' + status;
}
select.addEventListener('change', function () { save(select); });
notes.addEventListener('blur', function () {
if (notes.value === savedNotes) return; // nothing changed
save(notes);
});
// Enter / clicking the fallback button (if somehow visible): save in place.
form.addEventListener('submit', function (e) {
e.preventDefault();
save(notes);
});
});
});
})(); })();
</script> </script>
<?php <?php
@@ -388,9 +550,10 @@ function att_hc_render_sidebar(ATT_HC_Session $session): void {
$state = $session->step_state($step->id()); $state = $session->step_state($step->id());
$cls = 'dot-' . $state['status']; $cls = 'dot-' . $state['status'];
printf( printf(
'<li><span class="dot %s"></span><a href="#step-%s">%s</a></li>', '<li><span class="dot %s" data-step="%s"></span><a href="#step-%s">%s</a></li>',
esc_attr($cls), esc_attr($cls),
esc_attr($step->id()), esc_attr($step->id()),
esc_attr($step->id()),
esc_html($step->title()) esc_html($step->title())
); );
} }
@@ -498,8 +661,9 @@ function att_hc_render_step_card(ATT_HC_Session $session, ATT_HC_Step $step, int
</ul> </ul>
</div> </div>
<?php endif; ?> <?php endif; ?>
<?php if ($status === ATT_HC_Session::STATUS_BLOCKED && ($esc = $step->escalation())): ?> <?php if ($esc = $step->escalation()): ?>
<div class="att-hc-escalation"><?php echo esc_html($esc); ?></div> <?php // Rendered always (hidden unless blocked) so an AJAX status change can toggle it without a reload. ?>
<div class="att-hc-escalation" data-role="escalation"<?php echo $status === ATT_HC_Session::STATUS_BLOCKED ? '' : ' hidden'; ?>><?php echo esc_html($esc); ?></div>
<?php endif; ?> <?php endif; ?>
<?php <?php
@@ -510,7 +674,7 @@ function att_hc_render_step_card(ATT_HC_Session $session, ATT_HC_Step $step, int
$step->render_extra($session->data()); $step->render_extra($session->data());
?> ?>
<form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>" class="att-hc-step-save"> <form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>" class="att-hc-step-save" data-step="<?php echo esc_attr($step->id()); ?>">
<?php wp_nonce_field('att_hc_save_step_' . $step->id()); ?> <?php wp_nonce_field('att_hc_save_step_' . $step->id()); ?>
<input type="hidden" name="action" value="att_hc_save_step"> <input type="hidden" name="action" value="att_hc_save_step">
<input type="hidden" name="step" value="<?php echo esc_attr($step->id()); ?>"> <input type="hidden" name="step" value="<?php echo esc_attr($step->id()); ?>">
@@ -529,10 +693,13 @@ function att_hc_render_step_card(ATT_HC_Session $session, ATT_HC_Step $step, int
<textarea name="notes" placeholder="What did you check, find, fix, or flag?"><?php echo esc_textarea($notes); ?></textarea> <textarea name="notes" placeholder="What did you check, find, fix, or flag?"><?php echo esc_textarea($notes); ?></textarea>
</p> </p>
<p> <p>
<?php // JS hides this button (saves happen on change/blur); it stays as the no-JS fallback. ?>
<button class="button button-primary">Save step</button> <button class="button button-primary">Save step</button>
<?php if ($state['updated_at']): ?> <span class="att-hc-save-status description" aria-live="polite"><?php
<span class="description">Last saved <?php echo esc_html(human_time_diff($state['updated_at'], time())); ?> ago</span> if ($state['updated_at']) {
<?php endif; ?> echo 'Last saved ' . esc_html(human_time_diff($state['updated_at'], time())) . ' ago';
}
?></span>
</p> </p>
</form> </form>
</div> </div>
@@ -546,6 +713,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 +769,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)) {
@@ -654,6 +857,45 @@ function att_hc_handle_save_step(): void {
exit; exit;
} }
/**
* AJAX twin of att_hc_handle_save_step(). Same validation and write-through,
* but returns JSON instead of redirecting, so the reporting page can save a
* step in place (on status change / notes blur) without a full reload.
*
* The non-AJAX form POST above is kept as the no-JS fallback.
*/
function att_hc_handle_save_step_ajax(): void {
if (!current_user_can('manage_options')) wp_send_json_error('Forbidden', 403);
if (!check_ajax_referer('att_hc_save_step_ajax', 'nonce', false)) {
wp_send_json_error('Bad nonce — reload the page and try again.', 403);
}
$step_id = isset($_POST['step']) ? sanitize_key((string) $_POST['step']) : '';
if ($step_id === '') wp_send_json_error('Missing step id', 400);
$session = ATT_HC_Session::current();
if (!$session || $session->is_finished()) wp_send_json_error('No active session', 409);
if (!ATT_HC_Steps::instance()->get($step_id)) wp_send_json_error('Unknown step', 400);
$status = isset($_POST['status']) ? sanitize_key((string) $_POST['status']) : ATT_HC_Session::STATUS_NOT_STARTED;
$notes = isset($_POST['notes']) ? wp_unslash((string) $_POST['notes']) : '';
try {
// update_step() re-validates the status and falls back to NOT_STARTED
// for anything unexpected, so we report back whatever was actually stored.
$session->update_step($step_id, $status, $notes);
} catch (ATT_HC_Api_Exception $e) {
wp_send_json_error('Not saved — the central server rejected the write: ' . $e->getMessage(), 502);
}
$state = $session->step_state($step_id);
wp_send_json_success([
'step' => $step_id,
'status' => (string) $state['status'],
'status_label' => str_replace('_', ' ', (string) $state['status']),
'updated_at' => (int) $state['updated_at'],
]);
}
function att_hc_handle_finish(): void { function att_hc_handle_finish(): void {
if (!current_user_can('manage_options')) wp_die('Forbidden'); if (!current_user_can('manage_options')) wp_die('Forbidden');
check_admin_referer('att_hc_finish'); check_admin_referer('att_hc_finish');

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.7",
"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-27 08:42:03",
"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.",