mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 22:02:16 +00:00
Merge branch 'fix/backup-rotation-and-deletion' into release/v1.39.3
# Conflicts: # docs/api/openapi.yaml # src/lib/jobs/apple-health-import-worker.ts
This commit is contained in:
commit
4409529c89
74 files changed
+4022
-307
No files matched your search
@@ -23,6 +23,11 @@ vi.mock("@tanstack/react-query", () => ({
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { OffhostBackupsSection } from "../offhost-backups-section";
|
||||
|
||||
const QUIET = {
|
||||
pendingDeletions: { count: 0, oldestRequestedAt: null, lastFailure: null },
|
||||
lifecycle: { state: "configured" as const, expirationDays: 30 },
|
||||
};
|
||||
|
||||
function render(data: BackupsList["offhost"] | null): string {
|
||||
queryResult.current = {
|
||||
data:
|
||||
@@ -46,7 +51,12 @@ function render(data: BackupsList["offhost"] | null): string {
|
||||
|
||||
describe("<OffhostBackupsSection>", () => {
|
||||
it("says off-host backup is not configured rather than showing an empty list", () => {
|
||||
const html = render({ configured: false, periodHours: 24, rows: [] });
|
||||
const html = render({
|
||||
configured: false,
|
||||
periodHours: 24,
|
||||
rows: [],
|
||||
...QUIET,
|
||||
});
|
||||
expect(html).toContain("Off-host backup is not configured");
|
||||
expect(html).not.toContain('data-slot="offhost-backup-rows"');
|
||||
});
|
||||
@@ -55,6 +65,7 @@ describe("<OffhostBackupsSection>", () => {
|
||||
const html = render({
|
||||
configured: true,
|
||||
periodHours: 24,
|
||||
...QUIET,
|
||||
rows: [
|
||||
{
|
||||
userId: "u1",
|
||||
@@ -109,4 +120,33 @@ describe("<OffhostBackupsSection>", () => {
|
||||
// Never the bucket's own coordinates.
|
||||
expect(html).not.toMatch(/secret|access[- ]key/i);
|
||||
});
|
||||
|
||||
it("says when the bucket keeps copies forever, and when deletions are stuck", () => {
|
||||
const html = render({
|
||||
configured: true,
|
||||
periodHours: 24,
|
||||
rows: [],
|
||||
pendingDeletions: {
|
||||
count: 2,
|
||||
oldestRequestedAt: "2026-09-20T10:00:00.000Z",
|
||||
lastFailure: "AccessDenied",
|
||||
},
|
||||
lifecycle: { state: "missing", expirationDays: null },
|
||||
});
|
||||
expect(html).toContain('data-lifecycle-state="missing"');
|
||||
expect(html).toContain("no lifecycle rule");
|
||||
expect(html).toContain('data-slot="offhost-pending-deletions"');
|
||||
expect(html).toContain("AccessDenied");
|
||||
});
|
||||
|
||||
it("states the expiry the bucket enforces", () => {
|
||||
const html = render({
|
||||
configured: true,
|
||||
periodHours: 24,
|
||||
rows: [],
|
||||
...QUIET,
|
||||
});
|
||||
expect(html).toContain("after 30 days");
|
||||
expect(html).not.toContain('data-slot="offhost-pending-deletions"');
|
||||
});
|
||||
});
|
||||
@@ -71,6 +71,7 @@ function restoredCount(job: BackupRestoreJobView): number {
|
||||
function failureText(
|
||||
code: BackupRestoreFailureCode,
|
||||
t: ReturnType<typeof useTranslations>["t"],
|
||||
keyIds: readonly string[] = [],
|
||||
): string {
|
||||
switch (code) {
|
||||
case "backup_not_found":
|
||||
@@ -91,6 +92,12 @@ function failureText(
|
||||
return t("admin.section.backups.restoreFailureSectionMissing");
|
||||
case "document_ciphertext_missing":
|
||||
return t("admin.section.backups.restoreFailureDocument");
|
||||
case "backup.key.missing":
|
||||
return t("admin.section.backups.restoreFailureKeyMissing", {
|
||||
keys: keyIds.join(", "),
|
||||
});
|
||||
case "backup.foreign_reference":
|
||||
return t("admin.section.backups.restoreFailureForeignReference");
|
||||
case "time_budget":
|
||||
return t("admin.section.backups.restoreFailureTimeBudget");
|
||||
case "transaction_failed":
|
||||
@@ -289,7 +296,9 @@ export function RestoreJobStatus({
|
||||
<p className="font-medium">
|
||||
{t("admin.section.backups.restoreJobFailedTitle", { username })}
|
||||
</p>
|
||||
<p className="text-xs">{failureText(code, t)}</p>
|
||||
<p className="text-xs">
|
||||
{failureText(code, t, job.failure?.keyIds)}
|
||||
</p>
|
||||
{changedNothing ? (
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
{t("admin.section.backups.restoreJobFailedKept")}
|
||||
|
||||
@@ -238,11 +238,23 @@ function RestoreRowDialog({
|
||||
{t("admin.section.backups.previewLoading")}
|
||||
</p>
|
||||
)}
|
||||
{preview.isError && (
|
||||
<p className="text-muted-foreground">
|
||||
{t("admin.section.backups.previewUnavailable")}
|
||||
</p>
|
||||
)}
|
||||
{preview.isError &&
|
||||
(preview.error instanceof ApiError &&
|
||||
preview.error.meta?.errorCode === "backup.key.missing" ? (
|
||||
// The one preview failure the restore would repeat: say which
|
||||
// key, so the operator can put it back before trying.
|
||||
<p role="alert" className="text-destructive">
|
||||
{t("admin.section.backups.previewKeyMissing", {
|
||||
keys: Array.isArray(preview.error.meta.keyIds)
|
||||
? preview.error.meta.keyIds.map(String).join(", ")
|
||||
: "",
|
||||
})}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-muted-foreground">
|
||||
{t("admin.section.backups.previewUnavailable")}
|
||||
</p>
|
||||
))}
|
||||
{preview.isSuccess && (
|
||||
<ul className="grid list-none grid-cols-1 gap-x-4 gap-y-0.5 p-0 sm:grid-cols-2">
|
||||
{previewRows.map((entry) => (
|
||||
|
||||
@@ -13,7 +13,13 @@
|
||||
*/
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { KeyRound, Loader2, RotateCw, ShieldCheck } from "lucide-react";
|
||||
import {
|
||||
Archive,
|
||||
KeyRound,
|
||||
Loader2,
|
||||
RotateCw,
|
||||
ShieldCheck,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -32,7 +38,7 @@ import { StatTile } from "@/components/admin/_stat-tile";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
|
||||
import { getApiErrorMessage } from "./_shared";
|
||||
@@ -60,10 +66,25 @@ interface EncryptionStatus {
|
||||
lastCompletedAt: string | null;
|
||||
lastResult: { scanned: number; rotated: number; errors: number } | null;
|
||||
};
|
||||
/** Which keys the backups still need; see the status route. */
|
||||
backups: {
|
||||
stored: Array<{ keyId: string; copies: number; oldestAt: string }>;
|
||||
unrecorded: { copies: number; oldestAt: string | null };
|
||||
offhost: Array<{
|
||||
keyId: string;
|
||||
firstWrittenAt: string;
|
||||
lastWrittenAt: string;
|
||||
neededUntil: string | null;
|
||||
}>;
|
||||
offhostExpirationDays: number | null;
|
||||
retiredKeysStillNeeded: string[];
|
||||
};
|
||||
safeToDropRetiredKeys: boolean;
|
||||
}
|
||||
|
||||
export function EncryptionSection() {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const statusQuery = useQuery({
|
||||
@@ -166,10 +187,20 @@ export function EncryptionSection() {
|
||||
value={s.staleRows.toLocaleString()}
|
||||
/>
|
||||
</div>
|
||||
{s.rotationComplete ? (
|
||||
{s.safeToDropRetiredKeys ? (
|
||||
<Badge className="border-success/40 bg-success/15 text-success">
|
||||
{t("admin.section.encryption.safeToDropLegacy")}
|
||||
</Badge>
|
||||
) : s.rotationComplete ? (
|
||||
// Every row is on the active key, and that is not the whole
|
||||
// answer: a backup keeps the key its content was written under.
|
||||
<Badge variant="secondary" data-slot="encryption-backups-need-keys">
|
||||
{s.backups.retiredKeysStillNeeded.length > 0
|
||||
? t("admin.section.encryption.backupsStillNeed", {
|
||||
keys: s.backups.retiredKeysStillNeeded.join(", "),
|
||||
})
|
||||
: t("admin.section.encryption.backupsUnrecordedBadge")}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary">
|
||||
{t("admin.section.encryption.rotationIncomplete", {
|
||||
@@ -179,6 +210,56 @@ export function EncryptionSection() {
|
||||
)}
|
||||
</SettingsCard>
|
||||
|
||||
{/* ── Keys the backups still need ───────────────────────────── */}
|
||||
<SettingsCard>
|
||||
<SettingsCardHeader
|
||||
icon={Archive}
|
||||
title={t("admin.section.encryption.backupsTitle")}
|
||||
description={t("admin.section.encryption.backupsDescription")}
|
||||
/>
|
||||
<p className="text-sm">{t("admin.section.encryption.backupsDetail")}</p>
|
||||
<ul className="space-y-1 text-sm" data-slot="encryption-backup-keys">
|
||||
{s.backups.stored.map((row) => (
|
||||
<li key={`stored-${row.keyId}`}>
|
||||
{t("admin.section.encryption.backupsStoredLine", {
|
||||
key: row.keyId,
|
||||
count: row.copies,
|
||||
when: fmt.dateTime(row.oldestAt),
|
||||
})}
|
||||
</li>
|
||||
))}
|
||||
{s.backups.unrecorded.copies > 0 && s.backups.unrecorded.oldestAt ? (
|
||||
<li>
|
||||
{t("admin.section.encryption.backupsUnrecordedLine", {
|
||||
count: s.backups.unrecorded.copies,
|
||||
when: fmt.dateTime(s.backups.unrecorded.oldestAt),
|
||||
})}
|
||||
</li>
|
||||
) : null}
|
||||
{s.backups.offhost.map((row) => (
|
||||
<li key={`offhost-${row.keyId}`}>
|
||||
{row.neededUntil
|
||||
? t("admin.section.encryption.backupsOffhostLine", {
|
||||
key: row.keyId,
|
||||
when: fmt.dateTime(row.lastWrittenAt),
|
||||
until: fmt.dateTime(row.neededUntil),
|
||||
})
|
||||
: t("admin.section.encryption.backupsOffhostLineUnknown", {
|
||||
key: row.keyId,
|
||||
when: fmt.dateTime(row.lastWrittenAt),
|
||||
})}
|
||||
</li>
|
||||
))}
|
||||
{s.backups.stored.length === 0 &&
|
||||
s.backups.unrecorded.copies === 0 &&
|
||||
s.backups.offhost.length === 0 ? (
|
||||
<li className="text-muted-foreground">
|
||||
{t("admin.section.encryption.backupsNone")}
|
||||
</li>
|
||||
) : null}
|
||||
</ul>
|
||||
</SettingsCard>
|
||||
|
||||
{/* ── Rotation status + trigger ──────────────────────────────── */}
|
||||
<SettingsCard>
|
||||
<SettingsCardHeader
|
||||
|
||||
@@ -11,11 +11,12 @@
|
||||
* been refusing the worker's signature since March.
|
||||
*
|
||||
* Every verdict is computed server-side from the ledger the worker writes. The
|
||||
* page never lists the bucket: the worker's grant is deliberately PutObject,
|
||||
* GetObject and AbortMultipartUpload — the three the runbook documents, the
|
||||
* abort being what the multipart path needs — and putting a credentialed
|
||||
* listing call on the render path of an admin page would be a poor trade for
|
||||
* a number the database already holds.
|
||||
* page never lists the bucket: putting a credentialed listing call on the
|
||||
* render path of an admin page would be a poor trade for a number the database
|
||||
* already holds. It does read the bucket's lifecycle rule (one small call,
|
||||
* cached for ten minutes on the server), because whether old copies expire at
|
||||
* all is something only the bucket knows, and it states deletions of deleted
|
||||
* or wiped accounts that the bucket has not carried out yet.
|
||||
*/
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
@@ -191,6 +192,35 @@ export function OffhostBackupsSection() {
|
||||
hours: offhost.periodHours,
|
||||
})}
|
||||
</p>
|
||||
<p
|
||||
className="text-xs"
|
||||
data-slot="offhost-lifecycle"
|
||||
data-lifecycle-state={offhost.lifecycle.state}
|
||||
>
|
||||
{offhost.lifecycle.state === "configured"
|
||||
? t("admin.section.backups.offhost.lifecycleConfigured", {
|
||||
days: offhost.lifecycle.expirationDays ?? 0,
|
||||
})
|
||||
: offhost.lifecycle.state === "missing"
|
||||
? t("admin.section.backups.offhost.lifecycleMissing")
|
||||
: t("admin.section.backups.offhost.lifecycleUnknown")}
|
||||
</p>
|
||||
{offhost.pendingDeletions.count > 0 ? (
|
||||
<p
|
||||
role="status"
|
||||
className="text-xs"
|
||||
data-slot="offhost-pending-deletions"
|
||||
>
|
||||
{offhost.pendingDeletions.lastFailure
|
||||
? t("admin.section.backups.offhost.pendingDeletionsFailing", {
|
||||
count: offhost.pendingDeletions.count,
|
||||
error: offhost.pendingDeletions.lastFailure,
|
||||
})
|
||||
: t("admin.section.backups.offhost.pendingDeletions", {
|
||||
count: offhost.pendingDeletions.count,
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</SettingsCard>
|
||||
|
||||
Reference in new issue
Block a user