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:
+145
-5
@@ -57,14 +57,72 @@ 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 {
|
||||
/** The authenticated dashboard session cookie (key → cookie handshake done once). */
|
||||
function dash_cookie(): string {
|
||||
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]);
|
||||
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. */
|
||||
@@ -366,14 +424,96 @@ test('a session with no date shows a dash in the detail view', function () {
|
||||
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 ───────────────────────────────────────────────────────────────
|
||||
|
||||
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]);
|
||||
$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 () {
|
||||
|
||||
Reference in New Issue
Block a user