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,