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:
Marc-André Bombeck committed 2026-09-26 21:48:20 +02:00
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"');
});
});
+10 -1
View File
@@ -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")}
+17 -5
View File
@@ -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) => (
+84 -3
View File
@@ -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>