diff --git a/e2e/a11y.spec.ts b/e2e/a11y.spec.ts index febb55d52..3e4509716 100644 --- a/e2e/a11y.spec.ts +++ b/e2e/a11y.spec.ts @@ -707,6 +707,39 @@ async function installA11yMocks(page: Page) { }, ], retentionDays: 30, + // The off-host card, with one row in each state — the scan has to reach + // the status washes, and a card that paints only its header proves + // nothing about the rows an operator actually reads. + offhost: { + configured: true, + periodHours: 24, + rows: [ + { + userId: "a11y-user", + username: "a11y-backup-user", + lastSuccessAt: "2026-07-20T03:00:00.000Z", + sizeBytes: 4096, + ageHours: 3, + freshness: "fresh", + }, + { + userId: "a11y-user-2", + username: "a11y-backup-user-2", + lastSuccessAt: "2026-07-14T03:00:00.000Z", + sizeBytes: 2048, + ageHours: 150, + freshness: "stale", + }, + { + userId: "a11y-user-3", + username: "a11y-backup-user-3", + lastSuccessAt: null, + sizeBytes: null, + ageHours: null, + freshness: "never", + }, + ], + }, }), ); } diff --git a/src/components/admin/__tests__/offhost-backups-section.test.tsx b/src/components/admin/__tests__/offhost-backups-section.test.tsx new file mode 100644 index 000000000..7d7847414 --- /dev/null +++ b/src/components/admin/__tests__/offhost-backups-section.test.tsx @@ -0,0 +1,95 @@ +/** + * The off-host card paints what the response says, including the case an + * operator most needs stated out loud: this host is not sending anything + * anywhere. A card that renders an empty list on an unconfigured host reads + * as "no problems". + */ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import type { BackupsList } from "@/types/backups"; + +const queryResult = vi.hoisted(() => ({ + current: { + data: null as BackupsList | null, + isLoading: false, + isError: false, + }, +})); + +vi.mock("@tanstack/react-query", () => ({ + useQuery: () => ({ ...queryResult.current, refetch: vi.fn() }), +})); + +import { I18nProvider } from "@/lib/i18n/context"; +import { OffhostBackupsSection } from "../offhost-backups-section"; + +function render(data: BackupsList["offhost"] | null): string { + queryResult.current = { + data: + data === null + ? null + : ({ + rows: [], + retentionDays: 90, + schedule: null, + offhost: data, + } as unknown as BackupsList), + isLoading: false, + isError: false, + }; + return renderToStaticMarkup( + + + , + ); +} + +describe("", () => { + it("says off-host backup is not configured rather than showing an empty list", () => { + const html = render({ configured: false, periodHours: 24, rows: [] }); + expect(html).toContain("Off-host backup is not configured"); + expect(html).not.toContain('data-slot="offhost-backup-rows"'); + }); + + it("paints one row per account with its verdict", () => { + const html = render({ + configured: true, + periodHours: 24, + rows: [ + { + userId: "u1", + username: "account-one", + lastSuccessAt: "2026-09-10T06:00:00.000Z", + sizeBytes: 4096, + ageHours: 3, + freshness: "fresh", + }, + { + userId: "u2", + username: "account-two", + lastSuccessAt: "2026-09-06T06:00:00.000Z", + sizeBytes: 2048, + ageHours: 99, + freshness: "stale", + }, + { + userId: "u3", + username: "account-three", + lastSuccessAt: null, + sizeBytes: null, + ageHours: null, + freshness: "never", + }, + ], + }); + + expect(html).toContain('data-offhost-freshness="fresh"'); + expect(html).toContain('data-offhost-freshness="stale"'); + expect(html).toContain('data-offhost-freshness="never"'); + expect(html).toContain("account-two"); + // The account with no object says so instead of showing a blank cell. + expect(html).toContain("No copy has ever reached the bucket"); + // Never the bucket's own coordinates. + expect(html).not.toMatch(/secret|access[- ]key/i); + }); +}); diff --git a/src/components/admin/glitchtip-section.tsx b/src/components/admin/glitchtip-section.tsx index 1a7bdfc93..e5e7fd903 100644 --- a/src/components/admin/glitchtip-section.tsx +++ b/src/components/admin/glitchtip-section.tsx @@ -112,13 +112,18 @@ export function GlitchtipSection() { } disabled={updateSettings.isPending} /> -

- {reporting && targetHost - ? t("admin.glitchtipTargetOn", { host: targetHost }) - : settings?.glitchtipEnabled - ? t("admin.glitchtipTargetNoTarget") - : t("admin.glitchtipTargetOff")} -

+ {/* Only once the settings are in hand: a line that says "nothing is + sent" while the answer is still loading is a claim, not a + placeholder. */} + {settings ? ( +

+ {reporting && targetHost + ? t("admin.glitchtipTargetOn", { host: targetHost }) + : settings.glitchtipEnabled + ? t("admin.glitchtipTargetNoTarget") + : t("admin.glitchtipTargetOff")} +

+ ) : null}