From c920266f32d7a02dc71bfbe1ad119b826234f423 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 29 Oct 2026 21:50:34 +0100 Subject: [PATCH] fix(backup): explain a second restore of one file into another account Restored rows keep the ids they had in the file. Once an erased account's copy had gone into one account, the same file uploaded into a second account restored into a primary-key conflict, reported only as the generic "the database rejected a record" failure. For a copy put into a chosen account, a primary-key conflict now ends the restore with backup.restore.rows_exist_elsewhere and a sentence that says the file was most likely restored into another account already. The transaction still rolls back, so neither account changes. The code is in the catalogue, the console names it, and the runbook says a file goes into one account only. --- docs/api/openapi.yaml | 69 ++++++++++--------- docs/ops/backup-restore.md | 8 +++ messages/de.json | 1 + messages/en.json | 1 + messages/es.json | 1 + messages/fr.json | 1 + messages/it.json | 1 + messages/ko.json | 1 + messages/nl.json | 1 + messages/pl.json | 1 + messages/pt-BR.json | 1 + .../admin/backup-restore-status.tsx | 2 + .../__tests__/restore-failure-cause.test.ts | 41 +++++++++++ src/lib/export/restore-backup.ts | 24 ++++++- src/lib/export/restore-failure-cause.ts | 19 +++++ .../__tests__/backup-restore-outcome.test.ts | 1 + src/lib/openapi/error-codes.ts | 1 + src/lib/openapi/routes/admin-backups.ts | 2 +- .../backup-erased-account-restore.test.ts | 41 +++++++++++ tests/integration/restore-job-driver.ts | 1 + 20 files changed, 183 insertions(+), 35 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index a4b9df983..0dbcde4a0 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -38716,36 +38716,37 @@ components: auth.reproof.too_weak, auth.scope.insufficient, auth.session.insecure_transport, auth.session.unavailable, auth.stepup.required, auth.token.expired, auth.token.invalid; backup — backup.foreign_reference, backup.key.missing, backup.owner.exists, backup.owner.missing, - backup.payload.undecryptable, backup.restore.active, backup.section.missing, - backup.target.admin_account, backup.target.holds_records, backup.target.managed_profile, - backup.target.not_found, backup.target.pending_deletion, backup.target.unconfirmed, - backup.target.username_taken, backup.upload.too_large; biomarkers — biomarkers.unit.locked; body-site — - body-site.invalid; coach — coach.budget.exceeded, coach.conversation.invalidTitle, - coach.conversation.notFound, coach.fenced.attachmentConflict, coach.fenced.attachmentLimit, - coach.fenced.attachmentNotFound, coach.fenced.attachmentNotIndexed, coach.fenced.attachmentRequired, - coach.fenced.attachmentUnavailable, coach.fenced.invalid, coach.request.invalid; coach-prefs — - coach-prefs.body.invalid_json, coach-prefs.body.invalid_shape; consent — consent.ai.confirmRequired, - consent.ai.required; cycle — cycle.bulk.invalid, cycle.bulk.too_large, cycle.calendar.invalid, - cycle.calendar.range, cycle.cycles.invalid, cycle.day-log.conflict, cycle.day-log.invalid, - cycle.disabled, cycle.period.invalid, cycle.symptom.custom.invalid, cycle.symptom.custom.limit; - cycle-prefs — cycle-prefs.body.invalid_json, cycle-prefs.invalid; disable-coach — - disable-coach.body.invalid_json; data_wipe — data_wipe.copy_failed; dawarich — - dawarich.credentials_rejected, dawarich.home_place_unknown, dawarich.invalid, dawarich.not_connected, - dawarich.rate_limited, dawarich.unreachable; day — day.invalid; demo — demo.readOnly; documents — - documents.chat.conversationNotFound, documents.chat.invalid, documents.inbound.alreadyConfirmed, - documents.inbound.alreadyPartlyConfirmed, documents.inbound.budgetExceeded, - documents.inbound.decryptFailed, documents.inbound.encounterNotFound, documents.inbound.episodeNotFound, - documents.inbound.extractFailed, documents.inbound.factNotFound, documents.inbound.factNotPending, - documents.inbound.factTypeMismatch, documents.inbound.fileTooLarge, documents.inbound.fileType, - documents.inbound.invalidMetadata, documents.inbound.invalidQuery, documents.inbound.invalidUpdate, - documents.inbound.localOcrDisabled, documents.inbound.notDeleted, documents.inbound.notFound, - documents.inbound.notIndexed, documents.inbound.outputLimit, documents.inbound.pdfNeedsAnthropic, - documents.inbound.providerUnsupported, documents.inbound.quotaExceeded, documents.inbound.rateLimited, - documents.inbound.restoreDuplicate, documents.inbound.restoreGone, documents.inbound.sourceAliasLimit, - documents.inbound.uploadBusy, documents.inbound.uploadTimeout, documents.inbound.vaccinationNotFound, - documents.sources.authRefused, documents.sources.badResponse, documents.sources.browserOnly, - documents.sources.invalidAddress, documents.sources.linkTargetNotFound, documents.sources.notConnected, - documents.sources.notFound, documents.sources.organizationRequired, documents.sources.originNotAllowed, + backup.payload.undecryptable, backup.restore.active, backup.restore.rows_exist_elsewhere, + backup.section.missing, backup.target.admin_account, backup.target.holds_records, + backup.target.managed_profile, backup.target.not_found, backup.target.pending_deletion, + backup.target.unconfirmed, backup.target.username_taken, backup.upload.too_large; biomarkers — + biomarkers.unit.locked; body-site — body-site.invalid; coach — coach.budget.exceeded, + coach.conversation.invalidTitle, coach.conversation.notFound, coach.fenced.attachmentConflict, + coach.fenced.attachmentLimit, coach.fenced.attachmentNotFound, coach.fenced.attachmentNotIndexed, + coach.fenced.attachmentRequired, coach.fenced.attachmentUnavailable, coach.fenced.invalid, + coach.request.invalid; coach-prefs — coach-prefs.body.invalid_json, coach-prefs.body.invalid_shape; + consent — consent.ai.confirmRequired, consent.ai.required; cycle — cycle.bulk.invalid, + cycle.bulk.too_large, cycle.calendar.invalid, cycle.calendar.range, cycle.cycles.invalid, + cycle.day-log.conflict, cycle.day-log.invalid, cycle.disabled, cycle.period.invalid, + cycle.symptom.custom.invalid, cycle.symptom.custom.limit; cycle-prefs — cycle-prefs.body.invalid_json, + cycle-prefs.invalid; disable-coach — disable-coach.body.invalid_json; data_wipe — data_wipe.copy_failed; + dawarich — dawarich.credentials_rejected, dawarich.home_place_unknown, dawarich.invalid, + dawarich.not_connected, dawarich.rate_limited, dawarich.unreachable; day — day.invalid; demo — + demo.readOnly; documents — documents.chat.conversationNotFound, documents.chat.invalid, + documents.inbound.alreadyConfirmed, documents.inbound.alreadyPartlyConfirmed, + documents.inbound.budgetExceeded, documents.inbound.decryptFailed, documents.inbound.encounterNotFound, + documents.inbound.episodeNotFound, documents.inbound.extractFailed, documents.inbound.factNotFound, + documents.inbound.factNotPending, documents.inbound.factTypeMismatch, documents.inbound.fileTooLarge, + documents.inbound.fileType, documents.inbound.invalidMetadata, documents.inbound.invalidQuery, + documents.inbound.invalidUpdate, documents.inbound.localOcrDisabled, documents.inbound.notDeleted, + documents.inbound.notFound, documents.inbound.notIndexed, documents.inbound.outputLimit, + documents.inbound.pdfNeedsAnthropic, documents.inbound.providerUnsupported, + documents.inbound.quotaExceeded, documents.inbound.rateLimited, documents.inbound.restoreDuplicate, + documents.inbound.restoreGone, documents.inbound.sourceAliasLimit, documents.inbound.uploadBusy, + documents.inbound.uploadTimeout, documents.inbound.vaccinationNotFound, documents.sources.authRefused, + documents.sources.badResponse, documents.sources.browserOnly, documents.sources.invalidAddress, + documents.sources.linkTargetNotFound, documents.sources.notConnected, documents.sources.notFound, + documents.sources.organizationRequired, documents.sources.originNotAllowed, documents.sources.permissionMissing, documents.sources.rateLimited, documents.sources.redirected, documents.sources.tokenRequired, documents.sources.unavailable, documents.sources.unreachable, documents.sources.versionTooOld; encounter — encounter.invalid, encounter.link.invalid, @@ -90612,9 +90613,11 @@ components: type: string description: "Stable reason: `backup_not_found`, `backup_changed`, `backup.payload.undecryptable`, `schema_invalid`, `incompatible_schema_version`, `owner_mismatch`, `owner_not_found`, `backup.section.missing`, - `document_ciphertext_missing`, `backup.key.missing`, `backup.foreign_reference`, `time_budget`, - `transaction_failed`, `interrupted`, `not_started`, `enqueue_failed`, `failed_after_commit`, `unexpected`. - Every code but `failed_after_commit` means the account was not changed." + `document_ciphertext_missing`, `backup.key.missing`, `backup.foreign_reference`, + `backup.restore.rows_exist_elsewhere` (a copy put into a chosen account whose records another account + already holds, because the same file was restored there before), `time_budget`, `transaction_failed`, + `interrupted`, `not_started`, `enqueue_failed`, `failed_after_commit`, `unexpected`. Every code but + `failed_after_commit` means the account was not changed." message: type: string sections: diff --git a/docs/ops/backup-restore.md b/docs/ops/backup-restore.md index 8779d947a..1f8d5dd98 100644 --- a/docs/ops/backup-restore.md +++ b/docs/ops/backup-restore.md @@ -143,6 +143,14 @@ restored managed profile is an ordinary account. Once stored, the copy restores like any other: a restore run later, after the account has gained records, replaces those too. +A file goes into one account only. Its records keep the ids they had in the +erased account, so once it has been restored into one account, restoring the +same file into a second account would write those ids twice. The upload still +stores the second copy, but its restore is refused with +`backup.restore.rows_exist_elsewhere` and rolled back, and nothing changes in +either account. To move the record to another account, delete the first +account (or its records) before restoring into the second. + ### What a restore does not touch API tokens, trusted devices, step-up elevations, known devices, clinician share diff --git a/messages/de.json b/messages/de.json index e5963fe33..1614425f1 100644 --- a/messages/de.json +++ b/messages/de.json @@ -8345,6 +8345,7 @@ "restoreFailureUnexpected": "Die Wiederherstellung ist an einem unerwarteten Fehler gescheitert und wurde zurückgerollt. Das Server-Log nennt den Fehler.", "restoreFailureKeyMissing": "Die Sicherung braucht den Verschlüsselungsschlüssel {keys}, mit dem dieser Server sie nicht öffnen kann. Nimm den Schlüssel wieder in ENCRYPTION_KEYS auf, starte neu und starte die Wiederherstellung erneut.", "restoreFailureForeignReference": "Die Datei verweist auf Einträge, die zu einem anderen Konto gehören. Es wurde nichts geschrieben.", + "restoreFailureRowsElsewhere": "Die Datensätze dieser Datei liegen schon in einem anderen Konto auf diesem Server: Dieselbe Datei wurde dort bereits wiederhergestellt. Eine Datei lässt sich nur in ein Konto wiederherstellen. Es wurde nichts geschrieben.", "previewKeyMissing": "Diese Sicherung lässt sich auf diesem Server nicht wiederherstellen: Sie braucht den Verschlüsselungsschlüssel {keys}. Nimm den Schlüssel zuerst wieder in ENCRYPTION_KEYS auf und starte neu.", "restoreSkippedSymptomEpisodeReference": "Symptom-Eintrag mit Verknüpfung zu einer Erkrankung, die die Datei nicht enthält (ohne Verknüpfung behalten)", "restoreSkippedCycleCiphertext": "Zyklus-Notiz oder -Bezeichnung mit einem Schlüssel verschlüsselt, den dieser Server nicht hat (Tag oder Symptom ohne sie behalten)", diff --git a/messages/en.json b/messages/en.json index 9b02a81b9..0b9a78b16 100644 --- a/messages/en.json +++ b/messages/en.json @@ -8345,6 +8345,7 @@ "restoreFailureUnexpected": "The restore stopped on an unexpected error and was rolled back. The server log names the error.", "restoreFailureKeyMissing": "The backup needs encryption key {keys}, which this server cannot open it with. Add the key back to ENCRYPTION_KEYS, restart, and start the restore again.", "restoreFailureForeignReference": "The file points at records that belong to another account. Nothing was written.", + "restoreFailureRowsElsewhere": "The records in this file are already in another account on this server: the same file was restored there before. A file can be restored into one account only. Nothing was written.", "previewKeyMissing": "This backup cannot be restored on this server: it needs encryption key {keys}. Add the key back to ENCRYPTION_KEYS and restart first.", "restoreSkippedSymptomEpisodeReference": "Symptom entry linked to an illness the file does not contain (kept without the link)", "restoreSkippedCycleCiphertext": "Cycle note or label sealed with a key this server does not hold (day or symptom kept without it)", diff --git a/messages/es.json b/messages/es.json index 203b75ef5..494161998 100644 --- a/messages/es.json +++ b/messages/es.json @@ -8345,6 +8345,7 @@ "restoreFailureUnexpected": "La restauración se detuvo por un error inesperado y se deshizo. El registro del servidor indica el error.", "restoreFailureKeyMissing": "La copia necesita la clave de cifrado {keys}, con la que este servidor no puede abrirla. Vuelve a añadir la clave a ENCRYPTION_KEYS, reinicia e inicia de nuevo la restauración.", "restoreFailureForeignReference": "El archivo apunta a registros que pertenecen a otra cuenta. No se escribió nada.", + "restoreFailureRowsElsewhere": "Los registros de este archivo ya están en otra cuenta de este servidor: el mismo archivo ya se restauró allí. Un archivo solo puede restaurarse en una cuenta. No se escribió nada.", "previewKeyMissing": "Esta copia no se puede restaurar en este servidor: necesita la clave de cifrado {keys}. Vuelve a añadir la clave a ENCRYPTION_KEYS y reinicia primero.", "restoreSkippedSymptomEpisodeReference": "Entrada de síntoma vinculada a una enfermedad que el archivo no contiene (se conserva sin el vínculo)", "restoreSkippedCycleCiphertext": "Nota o etiqueta del ciclo cifrada con una clave que este servidor no tiene (el día o el síntoma se conserva sin ella)", diff --git a/messages/fr.json b/messages/fr.json index afb6a3634..3cf6c27ec 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -8345,6 +8345,7 @@ "restoreFailureUnexpected": "La restauration s'est arrêtée sur une erreur inattendue et a été annulée. Le journal du serveur indique l'erreur.", "restoreFailureKeyMissing": "La sauvegarde a besoin de la clé de chiffrement {keys}, avec laquelle ce serveur ne peut pas l'ouvrir. Remettez la clé dans ENCRYPTION_KEYS, redémarrez, puis relancez la restauration.", "restoreFailureForeignReference": "Le fichier renvoie à des éléments qui appartiennent à un autre compte. Rien n'a été écrit.", + "restoreFailureRowsElsewhere": "Les enregistrements de ce fichier se trouvent déjà dans un autre compte de ce serveur : le même fichier y a déjà été restauré. Un fichier ne peut être restauré que dans un seul compte. Rien n’a été écrit.", "previewKeyMissing": "Cette sauvegarde ne peut pas être restaurée sur ce serveur : elle a besoin de la clé de chiffrement {keys}. Remettez d'abord la clé dans ENCRYPTION_KEYS et redémarrez.", "restoreSkippedSymptomEpisodeReference": "Entrée de symptôme liée à une maladie absente du fichier (conservée sans le lien)", "restoreSkippedCycleCiphertext": "Note ou libellé du cycle chiffré avec une clé que ce serveur ne possède pas (jour ou symptôme conservé sans)", diff --git a/messages/it.json b/messages/it.json index 599c25ce2..f72bbde59 100644 --- a/messages/it.json +++ b/messages/it.json @@ -8345,6 +8345,7 @@ "restoreFailureUnexpected": "Il ripristino si è fermato per un errore imprevisto ed è stato annullato. Il log del server indica l'errore.", "restoreFailureKeyMissing": "Il backup richiede la chiave di cifratura {keys}, con cui questo server non riesce ad aprirlo. Aggiungi di nuovo la chiave a ENCRYPTION_KEYS, riavvia e avvia di nuovo il ripristino.", "restoreFailureForeignReference": "Il file rimanda a voci che appartengono a un altro account. Non è stato scritto nulla.", + "restoreFailureRowsElsewhere": "I record di questo file sono già in un altro account di questo server: lo stesso file è già stato ripristinato lì. Un file può essere ripristinato in un solo account. Non è stato scritto nulla.", "previewKeyMissing": "Questo backup non può essere ripristinato su questo server: richiede la chiave di cifratura {keys}. Aggiungi prima di nuovo la chiave a ENCRYPTION_KEYS e riavvia.", "restoreSkippedSymptomEpisodeReference": "Voce di sintomo collegata a una malattia assente dal file (mantenuta senza collegamento)", "restoreSkippedCycleCiphertext": "Nota o etichetta del ciclo cifrata con una chiave che questo server non possiede (giorno o sintomo mantenuto senza)", diff --git a/messages/ko.json b/messages/ko.json index 3d24bf68e..fa858cc6a 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -8320,6 +8320,7 @@ "restoreFailureUnexpected": "예상하지 못한 오류로 복원이 멈췄고 되돌려졌어요. 서버 로그에 오류가 나와 있어요.", "restoreFailureKeyMissing": "이 백업에는 암호화 키 {keys}가 필요하지만 이 서버는 그 키로 백업을 열 수 없습니다. 키를 ENCRYPTION_KEYS에 다시 추가하고 재시작한 뒤 복원을 다시 시작하세요.", "restoreFailureForeignReference": "파일이 다른 계정에 속한 기록을 가리키고 있습니다. 아무것도 기록되지 않았습니다.", + "restoreFailureRowsElsewhere": "이 파일의 기록은 이미 이 서버의 다른 계정에 있습니다. 같은 파일이 그 계정에 먼저 복원되었습니다. 파일은 한 계정에만 복원할 수 있습니다. 아무것도 기록되지 않았습니다.", "previewKeyMissing": "이 서버에서는 이 백업을 복원할 수 없습니다. 암호화 키 {keys}가 필요합니다. 먼저 키를 ENCRYPTION_KEYS에 다시 추가하고 재시작하세요.", "restoreSkippedSymptomEpisodeReference": "파일에 없는 질병과 연결된 증상 기록(연결 없이 유지됨)", "restoreSkippedCycleCiphertext": "이 서버에 없는 키로 암호화된 주기 메모 또는 이름(해당 날짜나 증상은 그 내용 없이 유지됨)", diff --git a/messages/nl.json b/messages/nl.json index 2fabb5ad6..0d7a0de24 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -8345,6 +8345,7 @@ "restoreFailureUnexpected": "Het terugzetten stopte door een onverwachte fout en is teruggedraaid. In het serverlog staat de fout.", "restoreFailureKeyMissing": "De back-up heeft versleutelingssleutel {keys} nodig, waarmee deze server hem niet kan openen. Voeg de sleutel weer toe aan ENCRYPTION_KEYS, herstart en start het terugzetten opnieuw.", "restoreFailureForeignReference": "Het bestand verwijst naar records die bij een ander account horen. Er is niets geschreven.", + "restoreFailureRowsElsewhere": "De records in dit bestand staan al in een ander account op deze server: hetzelfde bestand is daar al teruggezet. Een bestand kan maar in één account worden teruggezet. Er is niets geschreven.", "previewKeyMissing": "Deze back-up kan niet op deze server worden teruggezet: er is versleutelingssleutel {keys} voor nodig. Voeg de sleutel eerst weer toe aan ENCRYPTION_KEYS en herstart.", "restoreSkippedSymptomEpisodeReference": "Symptoomregistratie gekoppeld aan een ziekte die niet in het bestand staat (behouden zonder de koppeling)", "restoreSkippedCycleCiphertext": "Cyclusnotitie of -label versleuteld met een sleutel die deze server niet heeft (dag of symptoom behouden zonder)", diff --git a/messages/pl.json b/messages/pl.json index 528d445bd..03b936c1b 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -8345,6 +8345,7 @@ "restoreFailureUnexpected": "Przywracanie zatrzymało się na nieoczekiwanym błędzie i zostało wycofane. Dziennik serwera podaje błąd.", "restoreFailureKeyMissing": "Kopia wymaga klucza szyfrowania {keys}, którym ten serwer nie może jej otworzyć. Dodaj klucz z powrotem do ENCRYPTION_KEYS, uruchom ponownie i rozpocznij przywracanie jeszcze raz.", "restoreFailureForeignReference": "Plik wskazuje na wpisy należące do innego konta. Nic nie zostało zapisane.", + "restoreFailureRowsElsewhere": "Rekordy z tego pliku są już na innym koncie na tym serwerze: ten sam plik został tam wcześniej przywrócony. Plik można przywrócić tylko na jedno konto. Nic nie zostało zapisane.", "previewKeyMissing": "Tej kopii nie da się przywrócić na tym serwerze: wymaga klucza szyfrowania {keys}. Najpierw dodaj klucz z powrotem do ENCRYPTION_KEYS i uruchom ponownie.", "restoreSkippedSymptomEpisodeReference": "Wpis objawu powiązany z chorobą, której nie ma w pliku (zachowany bez powiązania)", "restoreSkippedCycleCiphertext": "Notatka lub etykieta cyklu zaszyfrowana kluczem, którego ten serwer nie ma (dzień lub objaw zachowany bez niej)", diff --git a/messages/pt-BR.json b/messages/pt-BR.json index 15bbc8816..d3a4ecb84 100644 --- a/messages/pt-BR.json +++ b/messages/pt-BR.json @@ -8345,6 +8345,7 @@ "restoreFailureUnexpected": "A restauração parou em um erro inesperado e foi revertida. O log do servidor nomeia o erro.", "restoreFailureKeyMissing": "O backup precisa da chave de criptografia {keys}, que este servidor não consegue usar para abri-lo. Adicione a chave de volta a ENCRYPTION_KEYS, reinicie e inicie a restauração novamente.", "restoreFailureForeignReference": "O arquivo aponta para registros que pertencem a outra conta. Nada foi gravado.", + "restoreFailureRowsElsewhere": "Os registros deste arquivo já estão em outra conta deste servidor: o mesmo arquivo já foi restaurado nela. Um arquivo só pode ser restaurado em uma conta. Nada foi gravado.", "previewKeyMissing": "Este backup não pode ser restaurado neste servidor: ele precisa da chave de criptografia {keys}. Adicione a chave de volta a ENCRYPTION_KEYS e reinicie primeiro.", "restoreSkippedSymptomEpisodeReference": "Entrada de sintoma vinculada a uma doença que o arquivo não contém (mantida sem o link)", "restoreSkippedCycleCiphertext": "Nota ou rótulo do ciclo selado com uma chave que este servidor não possui (dia ou sintoma mantido sem ele)", diff --git a/src/components/admin/backup-restore-status.tsx b/src/components/admin/backup-restore-status.tsx index 359c26351..7562b7ef0 100644 --- a/src/components/admin/backup-restore-status.tsx +++ b/src/components/admin/backup-restore-status.tsx @@ -98,6 +98,8 @@ function failureText( }); case "backup.foreign_reference": return t("admin.section.backups.restoreFailureForeignReference"); + case "backup.restore.rows_exist_elsewhere": + return t("admin.section.backups.restoreFailureRowsElsewhere"); case "time_budget": return t("admin.section.backups.restoreFailureTimeBudget"); case "transaction_failed": diff --git a/src/lib/export/__tests__/restore-failure-cause.test.ts b/src/lib/export/__tests__/restore-failure-cause.test.ts index 6c6807510..667ffac09 100644 --- a/src/lib/export/__tests__/restore-failure-cause.test.ts +++ b/src/lib/export/__tests__/restore-failure-cause.test.ts @@ -15,6 +15,7 @@ import { describe, expect, it } from "vitest"; import { classifyRestoreFailure, + isPrimaryKeyConflict, RESTORE_FAILURE_CAUSE_MESSAGES, RESTORE_FAILURE_CAUSES, } from "@/lib/export/restore-failure-cause"; @@ -147,3 +148,43 @@ describe("classifyRestoreFailure", () => { } }); }); + +describe("isPrimaryKeyConflict", () => { + it("names a duplicate primary key, as the driver reports it", () => { + expect( + isPrimaryKeyConflict( + adapterError("P2010", "23505", { + kind: "UniqueConstraintViolation", + constraint: { index: "measurements_pkey" }, + }), + ), + ).toBe(true); + // Without the structured name, read from the message. + expect( + isPrimaryKeyConflict( + adapterError("P2010", "23505", { + originalMessage: + 'duplicate key value violates unique constraint "mood_entries_pkey"', + }), + ), + ).toBe(true); + }); + + it("does not name another unique rule, or another failure", () => { + expect( + isPrimaryKeyConflict( + adapterError("P2002", "23505", { + constraint: { index: "measurements_user_id_external_id_key" }, + }), + ), + ).toBe(false); + expect( + isPrimaryKeyConflict( + adapterError("P2010", "23503", { + constraint: { index: "measurements_pkey" }, + }), + ), + ).toBe(false); + expect(isPrimaryKeyConflict(new Error("measurements_pkey"))).toBe(false); + }); +}); diff --git a/src/lib/export/restore-backup.ts b/src/lib/export/restore-backup.ts index b5e779d99..64bf060d1 100644 --- a/src/lib/export/restore-backup.ts +++ b/src/lib/export/restore-backup.ts @@ -49,6 +49,7 @@ import { import { classifyRestoreFailure, RESTORE_FAILURE_CAUSE_MESSAGES, + isPrimaryKeyConflict, } from "@/lib/export/restore-failure-cause"; import { takeRestoreLock } from "@/lib/export/restore-lock"; import { @@ -135,6 +136,9 @@ export type RestoreProgressSink = ( * before the first delete or rolls the whole transaction back, so each means * the account is unchanged. */ +/** A retargeted copy whose rows another account already holds. */ +const ROWS_EXIST_ELSEWHERE_CODE = "backup.restore.rows_exist_elsewhere"; + export type RestoreFailureCode = | "backup_not_found" | "backup_changed" @@ -147,6 +151,7 @@ export type RestoreFailureCode = | "document_ciphertext_missing" | "backup.key.missing" | "backup.foreign_reference" + | "backup.restore.rows_exist_elsewhere" | "time_budget" | "transaction_failed" | "interrupted" @@ -345,6 +350,7 @@ async function transactionRefusal( input: RestoreBackupInput, ownerId: string, transactionTimeoutMs: number, + retargeted: boolean, err: unknown, ): Promise { const verbose = err instanceof Error ? err.message : String(err); @@ -375,6 +381,16 @@ async function transactionRefusal( references: err.edges, }); } + if (retargeted && isPrimaryKeyConflict(err)) { + // Rows keep their ids from the file, so the copy of an erased account + // can live in one account only. Rolled back, so nothing was changed. + return refused( + 409, + ROWS_EXIST_ELSEWHERE_CODE, + "Records in this copy are already in this database, so the file was most likely already restored into another account. A file can be restored into one account only. Nothing was changed.", + { errorCode: ROWS_EXIST_ELSEWHERE_CODE }, + ); + } if (isStoredBackupReadError(err)) { // The second read, inside the transaction, found the copy replaced or // altered. The transaction rolled back, so nothing was changed. @@ -614,7 +630,13 @@ export async function restoreBackup( prepass, ); } catch (err) { - return transactionRefusal(input, owner.id, transactionTimeoutMs, err); + return transactionRefusal( + input, + owner.id, + transactionTimeoutMs, + retargeted, + err, + ); } const response = await afterCommit(input, read, owner, result, tracker); diff --git a/src/lib/export/restore-failure-cause.ts b/src/lib/export/restore-failure-cause.ts index cb1659618..083f00166 100644 --- a/src/lib/export/restore-failure-cause.ts +++ b/src/lib/export/restore-failure-cause.ts @@ -108,6 +108,25 @@ export function classifyRestoreFailure(err: unknown): ClassifiedRestoreFailure { return { cause: "other", code: prisma }; } +/** + * Whether a restore failed because a row it inserted carries a primary key + * already taken. The restore deletes the receiving account's own rows first, + * so the key belongs to a row of another account: for a copy put into a + * chosen account, the same file restored into a different account before. + */ +export function isPrimaryKeyConflict(err: unknown): boolean { + if (sqlState(err) !== "23505") return false; + const cause = field(field(field(err, "meta"), "driverAdapterError"), "cause"); + const index = field(field(cause, "constraint"), "index"); + const name = + typeof index === "string" + ? index + : /unique constraint "([^"]+)"/.exec( + String(field(cause, "originalMessage") ?? field(err, "message")), + )?.[1]; + return typeof name === "string" && name.endsWith("_pkey"); +} + /** The sentence the job row and the audit trail carry for each cause. */ export const RESTORE_FAILURE_CAUSE_MESSAGES: Record< RestoreFailureCause, diff --git a/src/lib/jobs/__tests__/backup-restore-outcome.test.ts b/src/lib/jobs/__tests__/backup-restore-outcome.test.ts index b1c8f82d7..db7e72379 100644 --- a/src/lib/jobs/__tests__/backup-restore-outcome.test.ts +++ b/src/lib/jobs/__tests__/backup-restore-outcome.test.ts @@ -39,6 +39,7 @@ const CODE_TABLE: Record = { document_ciphertext_missing: true, "backup.key.missing": true, "backup.foreign_reference": true, + "backup.restore.rows_exist_elsewhere": true, time_budget: true, transaction_failed: true, interrupted: true, diff --git a/src/lib/openapi/error-codes.ts b/src/lib/openapi/error-codes.ts index 748c75ca4..ac9131f44 100644 --- a/src/lib/openapi/error-codes.ts +++ b/src/lib/openapi/error-codes.ts @@ -104,6 +104,7 @@ export const ERROR_CODE_CATALOGUE: Readonly> = "backup.owner.missing", "backup.payload.undecryptable", "backup.restore.active", + "backup.restore.rows_exist_elsewhere", "backup.section.missing", "backup.target.admin_account", "backup.target.holds_records", diff --git a/src/lib/openapi/routes/admin-backups.ts b/src/lib/openapi/routes/admin-backups.ts index d30b0254b..b5a06680b 100644 --- a/src/lib/openapi/routes/admin-backups.ts +++ b/src/lib/openapi/routes/admin-backups.ts @@ -96,7 +96,7 @@ const restoreFailure = z .object({ code: z.string().meta({ description: - "Stable reason: `backup_not_found`, `backup_changed`, `backup.payload.undecryptable`, `schema_invalid`, `incompatible_schema_version`, `owner_mismatch`, `owner_not_found`, `backup.section.missing`, `document_ciphertext_missing`, `backup.key.missing`, `backup.foreign_reference`, `time_budget`, `transaction_failed`, `interrupted`, `not_started`, `enqueue_failed`, `failed_after_commit`, `unexpected`. Every code but `failed_after_commit` means the account was not changed.", + "Stable reason: `backup_not_found`, `backup_changed`, `backup.payload.undecryptable`, `schema_invalid`, `incompatible_schema_version`, `owner_mismatch`, `owner_not_found`, `backup.section.missing`, `document_ciphertext_missing`, `backup.key.missing`, `backup.foreign_reference`, `backup.restore.rows_exist_elsewhere` (a copy put into a chosen account whose records another account already holds, because the same file was restored there before), `time_budget`, `transaction_failed`, `interrupted`, `not_started`, `enqueue_failed`, `failed_after_commit`, `unexpected`. Every code but `failed_after_commit` means the account was not changed.", }), message: z.string(), sections: z.array(z.string()).optional(), diff --git a/tests/integration/backup-erased-account-restore.test.ts b/tests/integration/backup-erased-account-restore.test.ts index 9ab37b93a..3b2ea8c7a 100644 --- a/tests/integration/backup-erased-account-restore.test.ts +++ b/tests/integration/backup-erased-account-restore.test.ts @@ -899,6 +899,47 @@ describe("restoring the backup of an erased account into a chosen account", () = SLOW, ); + it( + "refuses a second restore of the same file into another account, explained and with nothing changed", + async () => { + const prisma = getPrismaClient(); + const { json, counts } = await eraseAfterExport(prisma); + + const first = await uploadInto(json, { + into: "new", + username: "first-home", + }); + expect(first.status, JSON.stringify(first.body)).toBe(201); + expect((await restore(first.body.data!.id)).status).toBe(200); + const home = await prisma.user.findUniqueOrThrow({ + where: { username: "first-home" }, + }); + + await prisma.rateLimit.deleteMany(); + const second = await uploadInto(json, { + into: "new", + username: "second-home", + }); + expect(second.status, JSON.stringify(second.body)).toBe(201); + const again = await restore(second.body.data!.id); + expect(again.status, JSON.stringify(again.body)).toBe(409); + expect( + (again.body as { meta?: { errorCode?: string } }).meta?.errorCode, + ).toBe("backup.restore.rows_exist_elsewhere"); + expect(again.body.error).toMatch(/already restored into another account/); + + // Rolled back: the second account holds nothing, the first all of it. + const twin = await prisma.user.findUniqueOrThrow({ + where: { username: "second-home" }, + }); + expect( + Object.values(await countsOf(prisma, twin.id)).every((n) => n === 0), + ).toBe(true); + expect(await countsOf(prisma, home.id)).toEqual(counts); + }, + SLOW, + ); + it( "leaves a trace of an account made for a refused file when it cannot be removed again", async () => { diff --git a/tests/integration/restore-job-driver.ts b/tests/integration/restore-job-driver.ts index 94dddd6b0..4e65a9db3 100644 --- a/tests/integration/restore-job-driver.ts +++ b/tests/integration/restore-job-driver.ts @@ -30,6 +30,7 @@ const LEGACY_STATUS: Record = { owner_mismatch: 409, owner_not_found: 422, "backup.section.missing": 422, + "backup.restore.rows_exist_elsewhere": 409, document_ciphertext_missing: 422, time_budget: 503, transaction_failed: 500,