Dashboard: archive healthchecks + Archived section on homepage (hc-b8r)

Some audits are no longer needed. Add an archive toggle so they can be filed
away without deleting the history.

- Migration 0003 adds healthchecks.archived (INTEGER 0/1, NOT NULL DEFAULT 0
  — portable across MySQL/SQLite).
- Per-site page (/dashboard?site=X): each session row gets an Archive /
  Unarchive button and archived rows carry a muted "archived" badge.
- Home page: archived healthchecks are gathered under an "Archived" heading
  at the foot, each with an Unarchive button.
- POST /dashboard/healthchecks/{id}/archive, mirroring the reopen flow:
  session-cookie auth + CSRF token. archived=1 archives, else un-archives.
  The redirect-back target is validated to a same-origin /dashboard path
  (no open-redirect / header injection).
- setArchived() deliberately leaves updated_at alone, so filing a session
  doesn't jump its site to the top of the dashboard ordering.
- Archived items are NOT hidden from their normal listings, per the request.

Dashboard-only; not exposed through the plugin API. 7 new tests (49 total).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-24 12:02:58 +01:00
co-authored by Claude Opus 4.8
parent 5be5ad2398
commit 85b4edc4f2
6 changed files with 289 additions and 11 deletions
+2 -1
View File
@@ -16,6 +16,7 @@
{"_type":"issue","id":"hc-5ix.4","title":"Session data model: option-backed in-progress healthcheck record","description":"One in-progress session per site at a time. Stored in a custom option (or a CPT — pick during implementation). Shape: id, started_at, finished_at, technician_id, site_url_snapshot, per_step_state { status: not_started|in_progress|done|skipped|blocked|n_a, notes, completed_at }. Designed so phase-3 automation can attach structured findings later.","notes":"Built in phase-1 scaffold; passing lint + WP-eval end-to-end smoke test on testsite.","status":"closed","priority":1,"issue_type":"task","owner":"steve@hanlon.co.uk","created_at":"2026-06-11T14:36:11Z","created_by":"Steve Hanlon","updated_at":"2026-06-11T14:49:27Z","started_at":"2026-06-11T14:41:27Z","closed_at":"2026-06-11T14:49:27Z","labels":["phase-1"],"dependencies":[{"issue_id":"hc-5ix.4","depends_on_id":"hc-5ix","type":"parent-child","created_at":"2026-06-11T15:36:11Z","created_by":"Steve Hanlon","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"hc-5ix.4","title":"Session data model: option-backed in-progress healthcheck record","description":"One in-progress session per site at a time. Stored in a custom option (or a CPT — pick during implementation). Shape: id, started_at, finished_at, technician_id, site_url_snapshot, per_step_state { status: not_started|in_progress|done|skipped|blocked|n_a, notes, completed_at }. Designed so phase-3 automation can attach structured findings later.","notes":"Built in phase-1 scaffold; passing lint + WP-eval end-to-end smoke test on testsite.","status":"closed","priority":1,"issue_type":"task","owner":"steve@hanlon.co.uk","created_at":"2026-06-11T14:36:11Z","created_by":"Steve Hanlon","updated_at":"2026-06-11T14:49:27Z","started_at":"2026-06-11T14:41:27Z","closed_at":"2026-06-11T14:49:27Z","labels":["phase-1"],"dependencies":[{"issue_id":"hc-5ix.4","depends_on_id":"hc-5ix","type":"parent-child","created_at":"2026-06-11T15:36:11Z","created_by":"Steve Hanlon","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"hc-5ix.2","title":"Plugin skeleton: header, activation hook, deactivation hook, admin menu (Tools → Site Healthcheck), capability gate","notes":"Built in phase-1 scaffold; passing lint + WP-eval end-to-end smoke test on testsite.","status":"closed","priority":1,"issue_type":"task","owner":"steve@hanlon.co.uk","created_at":"2026-06-11T14:36:10Z","created_by":"Steve Hanlon","updated_at":"2026-06-11T14:49:26Z","started_at":"2026-06-11T14:41:27Z","closed_at":"2026-06-11T14:49:26Z","labels":["phase-1"],"dependencies":[{"issue_id":"hc-5ix.2","depends_on_id":"hc-5ix","type":"parent-child","created_at":"2026-06-11T15:36:09Z","created_by":"Steve Hanlon","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"hc-5ix.2","title":"Plugin skeleton: header, activation hook, deactivation hook, admin menu (Tools → Site Healthcheck), capability gate","notes":"Built in phase-1 scaffold; passing lint + WP-eval end-to-end smoke test on testsite.","status":"closed","priority":1,"issue_type":"task","owner":"steve@hanlon.co.uk","created_at":"2026-06-11T14:36:10Z","created_by":"Steve Hanlon","updated_at":"2026-06-11T14:49:26Z","started_at":"2026-06-11T14:41:27Z","closed_at":"2026-06-11T14:49:26Z","labels":["phase-1"],"dependencies":[{"issue_id":"hc-5ix.2","depends_on_id":"hc-5ix","type":"parent-child","created_at":"2026-06-11T15:36:09Z","created_by":"Steve Hanlon","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"hc-5ix.1","title":"Bootstrap: trigger install of wp-site-recovery plugin as step 0","description":"Healthcheck plugin should check on activation if site-recovery is installed; if not, fetch and install from a known URL/ZIP and activate it. Block stepper from starting until recovery is in place.","notes":"Detection half done (status panel + manual link). Auto-install of recovery plugin from a private URL is the wp-site-recovery side of hc-5ix.27 — closing this as 'detection complete'.","status":"closed","priority":1,"issue_type":"task","owner":"steve@hanlon.co.uk","created_at":"2026-06-11T11:26:40Z","created_by":"Steve Hanlon","updated_at":"2026-06-11T15:03:02Z","closed_at":"2026-06-11T15:03:02Z","labels":["phase-1"],"dependencies":[{"issue_id":"hc-5ix.1","depends_on_id":"hc-5ix","type":"parent-child","created_at":"2026-06-11T12:26:39Z","created_by":"Steve Hanlon","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"hc-5ix.1","title":"Bootstrap: trigger install of wp-site-recovery plugin as step 0","description":"Healthcheck plugin should check on activation if site-recovery is installed; if not, fetch and install from a known URL/ZIP and activate it. Block stepper from starting until recovery is in place.","notes":"Detection half done (status panel + manual link). Auto-install of recovery plugin from a private URL is the wp-site-recovery side of hc-5ix.27 — closing this as 'detection complete'.","status":"closed","priority":1,"issue_type":"task","owner":"steve@hanlon.co.uk","created_at":"2026-06-11T11:26:40Z","created_by":"Steve Hanlon","updated_at":"2026-06-11T15:03:02Z","closed_at":"2026-06-11T15:03:02Z","labels":["phase-1"],"dependencies":[{"issue_id":"hc-5ix.1","depends_on_id":"hc-5ix","type":"parent-child","created_at":"2026-06-11T12:26:39Z","created_by":"Steve Hanlon","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0}
{"_type":"issue","id":"hc-b8r","title":"Dashboard: archive healthchecks + Archived section on homepage","description":"Some audits are no longer needed. Add an archive toggle on the per-site dashboard page (/dashboard?site=X); archived healthchecks are also listed on the homepage under an 'Archived' heading at the bottom. Server/dashboard only — not exposed to the plugin. No hiding of archived items elsewhere for now.","status":"closed","priority":2,"issue_type":"feature","owner":"steve@hanlon.co.uk","created_at":"2026-08-24T10:57:26Z","created_by":"Steve Hanlon","updated_at":"2026-08-24T11:02:58Z","closed_at":"2026-08-24T11:02:58Z","close_reason":"Implemented: archive toggle on the per-site dashboard page + Archived section on the homepage. Migration 0003, Store::setArchived/archivedHealthchecks, CSRF-guarded POST /dashboard/healthchecks/{id}/archive. 7 new tests.","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-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-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}
@@ -29,7 +30,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-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":"closed","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:17Z","closed_at":"2026-07-27T07:42:17Z","close_reason":"Implemented in 0.1.7 — applyStatus() repaints the sidebar dot on each AJAX save; :has() rule handles the link colour.","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}
+12
View File
@@ -0,0 +1,12 @@
-- Archive flag for healthchecks the technician no longer needs surfaced.
--
-- Boolean stored as INTEGER 0/1 (portable: MySQL has no native BOOLEAN, it's a
-- TINYINT alias, and SQLite has no boolean type at all — both store 0/1 in an
-- INTEGER column). NOT NULL DEFAULT 0 so every existing row is "not archived"
-- and SQLite's ALTER TABLE ADD COLUMN is satisfied (it requires a default for a
-- NOT NULL column).
--
-- Archived rows are NOT hidden from their normal listings for now; the dashboard
-- just additionally gathers them under an "Archived" heading on the home page.
ALTER TABLE healthchecks ADD COLUMN archived INTEGER NOT NULL DEFAULT 0;
+104 -5
View File
@@ -38,6 +38,39 @@ final class Dashboard {
exit; exit;
} }
public static function archive(array $params): void {
self::requireAuth();
self::requireCsrf();
$hcId = (string) ($params['id'] ?? '');
$hc = Store::getHealthcheck($hcId);
if ($hc === null) {
self::html('Not found', '<p class="error">Session not found.</p>');
return;
}
// archived=1 archives, anything else un-archives — so the same endpoint
// drives both the "Archive" and "Unarchive" buttons.
$archived = isset($_POST['archived']) && $_POST['archived'] === '1';
Store::setArchived($hcId, $archived);
header('Location: ' . self::safeRedirect($_POST['redirect'] ?? null, (string) $hc['site_key']));
exit;
}
/**
* A local dashboard path to redirect back to, or the site page as a fallback.
* Only same-origin /dashboard paths are allowed — never an absolute URL or a
* value carrying CR/LF — so this can't be turned into an open redirect or
* header-injection vector.
*/
private static function safeRedirect($candidate, string $siteKey): string {
if (is_string($candidate) && preg_match('#^/dashboard(\?[^\s]*)?$#', $candidate)) {
return $candidate;
}
return '/dashboard?site=' . rawurlencode($siteKey);
}
public static function index(array $params = []): void { public static function index(array $params = []): void {
self::requireAuth(); self::requireAuth();
@@ -55,8 +88,9 @@ final class Dashboard {
$hcs = Store::listHealthchecksForSite($siteKey, 100); $hcs = Store::listHealthchecksForSite($siteKey, 100);
self::html(htmlspecialchars($siteKey), self::renderSiteDetail($siteKey, $hcs)); self::html(htmlspecialchars($siteKey), self::renderSiteDetail($siteKey, $hcs));
} else { } else {
$sites = Store::allSitesSummary(); $sites = Store::allSitesSummary();
self::html('All sites', self::renderSiteList($sites)); $archived = Store::archivedHealthchecks();
self::html('All sites', self::renderSiteList($sites) . self::renderArchivedSection($archived));
} }
} }
@@ -171,6 +205,7 @@ final class Dashboard {
if (empty($hcs)) { if (empty($hcs)) {
return $back . '<p class="muted">No sessions found for this site.</p>'; return $back . '<p class="muted">No sessions found for this site.</p>';
} }
$siteUrl = '/dashboard?site=' . rawurlencode($siteKey);
$rows = ''; $rows = '';
foreach ($hcs as $hc) { foreach ($hcs as $hc) {
$started = date('Y-m-d H:i', (int) $hc['started_at']); $started = date('Y-m-d H:i', (int) $hc['started_at']);
@@ -179,17 +214,22 @@ final class Dashboard {
if ($finished !== null) { if ($finished !== null) {
$duration = self::humanDuration((int) $hc['finished_at'] - (int) $hc['started_at']); $duration = self::humanDuration((int) $hc['finished_at'] - (int) $hc['started_at']);
} }
$archived = (int) ($hc['archived'] ?? 0) === 1;
$badge = $finished === null $badge = $finished === null
? '<span class="badge status-in-progress">in progress</span>' ? '<span class="badge status-in-progress">in progress</span>'
: '<span class="badge status-done">complete</span>'; : '<span class="badge status-done">complete</span>';
$url = '/dashboard?site=' . rawurlencode($siteKey) . '&hc=' . rawurlencode((string) $hc['id']); if ($archived) {
$rows .= '<tr> $badge .= ' <span class="badge status-archived">archived</span>';
}
$url = $siteUrl . '&hc=' . rawurlencode((string) $hc['id']);
$rows .= '<tr' . ($archived ? ' class="archived-row"' : '') . '>
<td><a href="' . $url . '">' . $started . '</a></td> <td><a href="' . $url . '">' . $started . '</a></td>
<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>' . 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>
<td>' . self::archiveButton($hc, $siteUrl) . '</td>
</tr>'; </tr>';
} }
return $back . ' return $back . '
@@ -198,7 +238,57 @@ 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>Next due</th><th>Reporting URL</th> <th>Duration</th><th>Next due</th><th>Reporting URL</th><th></th>
</tr></thead>
<tbody>' . $rows . '</tbody>
</table>';
}
/**
* A one-button form that toggles a healthcheck's archived state. $redirect is
* where to return afterwards (validated server-side by safeRedirect()).
*/
private static function archiveButton(array $hc, string $redirect): string {
$archived = (int) ($hc['archived'] ?? 0) === 1;
$target = $archived ? '0' : '1';
$label = $archived ? 'Unarchive' : 'Archive';
return '<form method="post" action="/dashboard/healthchecks/' . rawurlencode((string) $hc['id']) . '/archive" class="inline-action">'
. '<input type="hidden" name="csrf" value="' . htmlspecialchars(self::csrfToken(), ENT_QUOTES) . '">'
. '<input type="hidden" name="archived" value="' . $target . '">'
. '<input type="hidden" name="redirect" value="' . htmlspecialchars($redirect, ENT_QUOTES) . '">'
. '<button type="submit" class="btn-archive">' . $label . '</button>'
. '</form>';
}
/** The "Archived" section shown at the foot of the home page. Empty string when nothing is archived. */
private static function renderArchivedSection(array $archived): string {
if (empty($archived)) {
return '';
}
$rows = '';
foreach ($archived as $hc) {
$siteKey = (string) $hc['site_key'];
$started = date('Y-m-d H:i', (int) $hc['started_at']);
$badge = $hc['finished_at'] !== null
? '<span class="badge status-done">complete</span>'
: '<span class="badge status-in-progress">in progress</span>';
$siteUrl = '/dashboard?site=' . rawurlencode($siteKey);
$sessionUrl = $siteUrl . '&hc=' . rawurlencode((string) $hc['id']);
$rows .= '<tr>
<td><a href="' . $siteUrl . '">' . htmlspecialchars($siteKey) . '</a></td>
<td><a href="' . $sessionUrl . '">' . $started . '</a></td>
<td>' . $badge . '</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>' . self::archiveButton($hc, '/dashboard') . '</td>
</tr>';
}
return '
<h2 class="archived-heading">Archived <span class="muted small">(' . count($archived) . ')</span></h2>
<table>
<thead><tr>
<th>Site key</th><th>Session started</th><th>Status</th>
<th>Next due</th><th>Reporting URL</th><th></th>
</tr></thead> </tr></thead>
<tbody>' . $rows . '</tbody> <tbody>' . $rows . '</tbody>
</table>'; </table>';
@@ -391,6 +481,15 @@ pre.step-notes { margin: 0; font-family: inherit; white-space: pre-wrap;
border-radius: 4px; padding: .25rem .7rem; font-size: .85em; border-radius: 4px; padding: .25rem .7rem; font-size: .85em;
cursor: pointer; font-weight: 500; } cursor: pointer; font-weight: 500; }
.btn-reopen:hover { background: #2271b1; color: #fff; } .btn-reopen:hover { background: #2271b1; color: #fff; }
/* archive */
.inline-action { margin: 0; display: inline; }
.btn-archive { background: #fff; color: #646970; border: 1px solid #c3c4c7;
border-radius: 4px; padding: .25rem .7rem; font-size: .85em;
cursor: pointer; }
.btn-archive:hover { background: #f6f7f7; color: #1d2327; border-color: #8c8f94; }
.badge.status-archived { background: #ede9fe; color: #5b21b6; }
.archived-row td { opacity: .65; }
.archived-heading { margin-top: 2.5rem; }
</style> </style>
</head> </head>
<body> <body>
+25
View File
@@ -60,6 +60,31 @@ final class Store {
return $stmt->rowCount() > 0; return $stmt->rowCount() > 0;
} }
/**
* Archive or un-archive one healthcheck.
*
* Deliberately does not touch updated_at: archiving is a filing action, not
* audit activity, and bumping it would jump the site to the top of the
* dashboard's most-recently-active ordering.
*/
public static function setArchived(string $id, bool $archived): bool {
$stmt = Db::pdo()->prepare('UPDATE healthchecks SET archived = :archived WHERE id = :id');
$stmt->execute([':archived' => $archived ? 1 : 0, ':id' => $id]);
return $stmt->rowCount() > 0;
}
/**
* Every archived healthcheck, most recent first, across all sites — for the
* dashboard's "Archived" section.
* @return array<int, array>
*/
public static function archivedHealthchecks(int $limit = 500): array {
$stmt = Db::pdo()->prepare('SELECT * FROM healthchecks WHERE archived = 1 ORDER BY started_at DESC LIMIT ?');
$stmt->bindValue(1, $limit, \PDO::PARAM_INT);
$stmt->execute();
return $stmt->fetchAll();
}
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);
+1
View File
@@ -30,5 +30,6 @@ $router->add('GET', '/step-counts', [Steps::class
$router->add('GET', '/sites', [Sites::class, 'recent']); $router->add('GET', '/sites', [Sites::class, 'recent']);
$router->add('GET', '/dashboard', [Dashboard::class, 'index'], requiresAuth: false); $router->add('GET', '/dashboard', [Dashboard::class, 'index'], requiresAuth: false);
$router->add('POST', '/dashboard/healthchecks/{id}/reopen', [Dashboard::class, 'reopen'], requiresAuth: false); $router->add('POST', '/dashboard/healthchecks/{id}/reopen', [Dashboard::class, 'reopen'], requiresAuth: false);
$router->add('POST', '/dashboard/healthchecks/{id}/archive', [Dashboard::class, 'archive'], requiresAuth: false);
return $router; return $router;
+145 -5
View File
@@ -57,14 +57,72 @@ function get_hc(string $id): array {
return api('GET', '/healthchecks/' . rawurlencode($id)); return api('GET', '/healthchecks/' . rawurlencode($id));
} }
/** Authenticated dashboard GET (key → cookie handshake done once). */ /** The authenticated dashboard session cookie (key → cookie handshake done once). */
function dash(string $path): array { function dash_cookie(): string {
static $cookie = null; static $cookie = null;
if ($cookie === null) { if ($cookie === null) {
$login = api('GET', '/dashboard?key=' . rawurlencode(TestServer::API_KEY), null, null); $login = api('GET', '/dashboard?key=' . rawurlencode(TestServer::API_KEY), null, null);
$cookie = session_cookie($login) ?? ''; $cookie = session_cookie($login) ?? '';
} }
return api('GET', $path, null, null, ['Cookie: ' . $cookie]); return $cookie;
}
/** Authenticated dashboard GET. */
function dash(string $path): array {
return api('GET', $path, null, null, ['Cookie: ' . dash_cookie()]);
}
/** The per-session CSRF token, scraped from a form on the given dashboard page. */
function dash_csrf(string $path): string {
if (preg_match('/name="csrf"\s+value="([^"]+)"/', dash($path)['body'], $m)) {
return $m[1];
}
return '';
}
/**
* Form-encoded dashboard POST on the same session as dash() — that's what the
* archive/reopen buttons submit, and what populates $_POST server-side (a JSON
* body would not). Redirects are not followed, so the 302 is observable.
*/
function dash_post(string $path, array $fields): array {
$http = [
'method' => 'POST',
'ignore_errors' => true,
'follow_location' => 0,
'max_redirects' => 1,
'timeout' => 10,
'header' => implode("\r\n", [
'Content-Type: application/x-www-form-urlencoded',
'Cookie: ' . dash_cookie(),
]),
'content' => http_build_query($fields),
];
$raw = @file_get_contents($GLOBALS['att_hc_base_url'] . $path, false, stream_context_create(['http' => $http]));
$headers = $http_response_header ?? [];
$status = 0;
foreach ($headers as $h) {
if (preg_match('#^HTTP/\S+\s+(\d{3})#', $h, $m)) $status = (int) $m[1];
}
return ['status' => $status, 'body' => $raw === false ? '' : $raw, 'headers' => $headers];
}
/** POST to the archive endpoint for a healthcheck id. */
function archive_hc(string $id, bool $archived, string $csrf): array {
return dash_post(
'/dashboard/healthchecks/' . rawurlencode($id) . '/archive',
['archived' => $archived ? '1' : '0', 'csrf' => $csrf]
);
}
/**
* The home page's "Archived" section only (from its heading to the end).
* Anchored on the <h2> tag, not the bare class name — the latter also appears
* in the page's <style> block (.archived-heading { … }).
*/
function archived_section(string $html): string {
$pos = strpos($html, '<h2 class="archived-heading"');
return $pos === false ? '' : substr($html, $pos);
} }
/** The <tr> from a dashboard table that mentions $needle — for precise assertions. */ /** The <tr> from a dashboard table that mentions $needle — for precise assertions. */
@@ -366,14 +424,96 @@ test('a session with no date shows a dash in the detail view', function () {
assert_not_contains('due-ok', $meta); assert_not_contains('due-ok', $meta);
}); });
// ── Archive ──────────────────────────────────────────────────────────────────
group('server: archive');
test('archiving requires a valid CSRF token', function () {
$site = unique_site('archive-csrf');
$hc = create_hc($site);
$r = archive_hc($hc['id'], true, 'not-the-real-token');
assert_same(403, $r['status']);
// And the row must be untouched.
assert_not_contains($site, archived_section(dash('/dashboard')['body']));
});
test('a healthcheck can be archived and shows under Archived on the home page', function () {
$site = unique_site('archive-set');
$hc = create_hc($site);
$token = dash_csrf('/dashboard?site=' . rawurlencode($site));
$r = archive_hc($hc['id'], true, $token);
assert_same(302, $r['status'], 'archive should redirect');
$home = dash('/dashboard')['body'];
assert_contains('archived-heading', $home, 'the Archived section should appear');
assert_contains($site, archived_section($home), 'this site should be listed under Archived');
});
test('unarchiving removes it from the Archived section', function () {
$site = unique_site('archive-toggle');
$hc = create_hc($site);
$token = dash_csrf('/dashboard?site=' . rawurlencode($site));
archive_hc($hc['id'], true, $token);
assert_contains($site, archived_section(dash('/dashboard')['body']), 'precondition: archived');
$r = archive_hc($hc['id'], false, $token);
assert_same(302, $r['status']);
assert_not_contains($site, archived_section(dash('/dashboard')['body']));
});
test('the per-site page marks an archived session and offers Unarchive', function () {
$site = unique_site('archive-persite');
$hc = create_hc($site);
$token = dash_csrf('/dashboard?site=' . rawurlencode($site));
archive_hc($hc['id'], true, $token);
$page = dash('/dashboard?site=' . rawurlencode($site))['body'];
assert_contains('Unarchive', $page, 'the toggle should now read Unarchive');
assert_contains('status-archived', $page, 'the row should carry the archived badge');
});
test('an unarchived session still shows the normal listing (not hidden)', function () {
// "No need to hide them" — an archived session stays in its site's session list.
$site = unique_site('archive-not-hidden');
$hc = create_hc($site, ['next_due' => '2027-01-01']);
$token = dash_csrf('/dashboard?site=' . rawurlencode($site));
archive_hc($hc['id'], true, $token);
$page = dash('/dashboard?site=' . rawurlencode($site))['body'];
assert_contains('2027-01-01', $page, 'the archived session is still in the per-site table');
});
test('archiving an unknown healthcheck reports not found', function () {
$site = unique_site('archive-unknown');
create_hc($site); // gives us a page to read a CSRF token from
$token = dash_csrf('/dashboard?site=' . rawurlencode($site));
$r = archive_hc('does-not-exist', true, $token);
assert_contains('Session not found', $r['body']);
});
// ── Migrations ─────────────────────────────────────────────────────────────── // ── Migrations ───────────────────────────────────────────────────────────────
group('server: migrations'); group('server: migrations');
test('both migrations are recorded exactly once', function () { test('all migrations are recorded exactly once', function () {
$pdo = new PDO('sqlite:' . $GLOBALS['att_hc_db_path'], null, null, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]); $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); $applied = $pdo->query('SELECT filename FROM migrations ORDER BY filename')->fetchAll(PDO::FETCH_COLUMN);
assert_same(['0001_initial.sql', '0002_next_due.sql'], $applied); assert_same(['0001_initial.sql', '0002_next_due.sql', '0003_archive.sql'], $applied);
});
test('the archived column exists on healthchecks and defaults to 0', function () {
$pdo = new PDO('sqlite:' . $GLOBALS['att_hc_db_path'], null, null, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
$cols = $pdo->query('PRAGMA table_info(healthchecks)')->fetchAll(PDO::FETCH_ASSOC);
$archived = null;
foreach ($cols as $c) {
if ($c['name'] === 'archived') $archived = $c;
}
assert_true($archived !== null, 'archived column should exist');
assert_same('0', (string) $archived['dflt_value'], 'default should be 0');
}); });
test('the next_due column exists on healthchecks', function () { test('the next_due column exists on healthchecks', function () {