Просмотр исходного кода

fix(backup): say when a restored archive lands without an owner (#2656)

`created_by_id` is not attribution, it is the column the access check runs
on: `_ensure_archive_visible` fails closed on NULL, so an ownerless archive
is a 404 for every caller without `archives:read_all` and never appears in
the ownership-scoped list queries.

On the overwrite path an absent key correctly leaves the local owner alone
— that rule is deliberate and unchanged. On the insert path there is no
local row to fall back on, so the archive lands ownerless, and nothing said
so. The restore reported N archives restored while the user who asked for
them saw none. Two ways in, both silent: a commit taken before the
collector recorded the column (every pre-#2656 backup), and an archive that
genuinely had no owner on the source instance.

Adds `archivesOwnerUnknown`, emitted on insert only, and suppressed when
the stale-id branch has already spoken for that row so one cause does not
produce two notes. Wording mirrors `archivesOwnerCleared` because the
consequence and the remedy are the same; the cause is not, so it is a
separate code rather than a reuse.

Five tests, plus the existing `test_a_backup_without_the_key_still_restores`
renamed and tightened — it asserted the silence this fixes. 13 locales back
in parity at 5772 leaves. No modal change: notes render through
`translateCoded`, which resolves by code.
jmoore-skild 1 месяц назад
Родитель
Сommit
a85fa66dcc

+ 20 - 0
backend/app/services/github_restore.py

@@ -1017,6 +1017,7 @@ class GitHubRestoreService:
             # column was added to fix) and silently un-delete a row the user
             # deleted. So only carry a column the backup actually knows about;
             # on insert, an absent key just takes the model default.
+            owner_cleared = False
             if "created_by_id" in entry:
                 created_by_id = entry.get("created_by_id")
                 if created_by_id is not None and created_by_id not in valid_users:
@@ -1030,6 +1031,7 @@ class GitHubRestoreService:
                         "visible only to users with the archives:read_all permission until an admin reassigns them",
                     )
                     created_by_id = None
+                    owner_cleared = True
                 fields["created_by_id"] = created_by_id
             if "deleted_at" in entry:
                 # A soft-deleted archive is still in the backup (its row is kept
@@ -1065,6 +1067,24 @@ class GitHubRestoreService:
                 )
                 warned_files = True
 
+            # Insert-only, and the mirror of the "absent is not null" rule above:
+            # on overwrite an unknown owner correctly leaves the local one alone,
+            # but there is no local row here to fall back on, so the archive
+            # lands ownerless — a 404 for everyone without archives:read_all.
+            # Two ways to get here: a commit taken before the collector recorded
+            # the column (every pre-#2656 backup), or an archive that genuinely
+            # had no owner on the source instance. Both restore fine and both
+            # were silent, so the tally said "N archives restored" while the user
+            # who asked for them saw none. The stale-id case above already said
+            # its piece; don't say it twice for the same row.
+            if fields.get("created_by_id") is None and not owner_cleared:
+                tally.note(
+                    "archivesOwnerUnknown",
+                    "Some archives were restored without an owner — this backup does not record one, so they "
+                    "are visible only to users with the archives:read_all permission until an admin reassigns "
+                    "them",
+                )
+
             row = PrintArchive(
                 filename=entry.get("filename") or "restored-from-backup",
                 file_path="",

+ 71 - 2
backend/tests/unit/test_github_restore.py

@@ -2063,8 +2063,14 @@ class TestRestoredArchiveOwnership:
         assert sum(1 for note in _messages(tally) if "owner cleared" in note) == 1
 
     @pytest.mark.asyncio
-    async def test_a_backup_without_the_key_still_restores(self, db_session):
-        """Backups taken before the collector recorded it just can't know the owner."""
+    async def test_a_backup_without_the_key_still_restores_and_says_so(self, db_session):
+        """Backups taken before the collector recorded it just can't know the owner.
+
+        The archive is worth restoring anyway, but it lands ownerless — which is
+        a 404 for everyone without ``archives:read_all``. Reporting N restored
+        while the user who asked for them sees none is the failure mode the note
+        exists to prevent.
+        """
         tally = _CategoryTally()
 
         await _service()._restore_archives(db_session, {"archives": [self._entry()]}, False, tally, {})
@@ -2072,7 +2078,70 @@ class TestRestoredArchiveOwnership:
 
         row = (await db_session.execute(select(PrintArchive))).scalar_one()
         assert row.created_by_id is None
+        assert tally.restored == 1
         assert not any("owner cleared" in note for note in _messages(tally))
+        assert any("without an owner" in note and "archives:read_all" in note for note in _messages(tally))
+
+    @pytest.mark.asyncio
+    async def test_an_explicitly_ownerless_archive_is_reported_too(self, db_session):
+        """Same consequence, so the same note: the source row had no owner either."""
+        tally = _CategoryTally()
+
+        await _service()._restore_archives(
+            db_session, {"archives": [self._entry(created_by_id=None)]}, False, tally, {}
+        )
+        await db_session.commit()
+
+        assert any("without an owner" in note for note in _messages(tally))
+
+    @pytest.mark.asyncio
+    async def test_a_stale_owner_is_not_reported_twice(self, db_session):
+        """One row, one cause, one note — the cleared-owner branch already spoke."""
+        tally = _CategoryTally()
+
+        await _service()._restore_archives(
+            db_session, {"archives": [self._entry(created_by_id=4242)]}, False, tally, {}
+        )
+        await db_session.commit()
+
+        assert any("owner cleared" in note for note in _messages(tally))
+        assert not any("without an owner" in note for note in _messages(tally))
+
+    @pytest.mark.asyncio
+    async def test_a_known_owner_is_not_reported(self, db_session):
+        user = await self._user(db_session)
+        tally = _CategoryTally()
+
+        await _service()._restore_archives(
+            db_session, {"archives": [self._entry(created_by_id=user.id)]}, False, tally, {}
+        )
+        await db_session.commit()
+
+        assert not any("without an owner" in note for note in _messages(tally))
+
+    @pytest.mark.asyncio
+    async def test_the_unknown_owner_note_is_not_emitted_on_overwrite(self, db_session):
+        """Overwrite keeps the local owner, so there is nothing to warn about."""
+        bob = await self._user(db_session, "bob")
+        db_session.add(
+            PrintArchive(
+                filename="benchy.3mf",
+                file_path="/data/benchy.3mf",
+                file_size=2048,
+                content_hash="abc123",
+                started_at=datetime(2026, 3, 1, 10, 0, 0),
+                created_by_id=bob.id,
+            )
+        )
+        await db_session.commit()
+        tally = _CategoryTally()
+
+        await _service()._restore_archives(db_session, {"archives": [self._entry()]}, True, tally, {})
+        await db_session.commit()
+
+        row = (await db_session.execute(select(PrintArchive))).scalar_one()
+        assert row.created_by_id == bob.id
+        assert not any("without an owner" in note for note in _messages(tally))
 
     @pytest.mark.asyncio
     async def test_overwrite_makes_the_local_owner_match_the_backup(self, db_session):

+ 1 - 0
frontend/src/i18n/locales/de.ts

@@ -4912,6 +4912,7 @@ export default {
         archivesPrinterMissing: 'Einige Archive verwiesen auf nicht mehr vorhandene Drucker - Verknüpfung entfernt',
         archivesProjectMissing: 'Einige Archive verwiesen auf nicht mehr vorhandene Projekte - Verknüpfung entfernt',
         archivesOwnerCleared: 'Einige Archive verwiesen auf nicht mehr vorhandene Benutzer - Eigentümer entfernt. Sie sind daher nur für Benutzer mit der Berechtigung archives:read_all sichtbar, bis ein Administrator sie neu zuweist',
+        archivesOwnerUnknown: 'Einige Archive wurden ohne Eigentümer wiederhergestellt - diese Sicherung enthält keinen, daher sind sie nur für Benutzer mit der Berechtigung archives:read_all sichtbar, bis ein Administrator sie neu zuweist',
         archivesUndeleted: 'Seit dem Backup gelöschte Archive sind wieder sichtbar - Überschreiben war aktiv',
         archivesMetadataOnly: 'Wiederhergestellte Archive enthalten nur Metadaten - die 3MF- und Vorschaudateien sind nicht im Git-Backup enthalten',
         spoolUsageUnresolved: '{{count}} Verbrauchseinträge übersprungen - ihre Spule ist nicht in der Spulenliste dieses Backups, es gibt also nichts, woran sie hängen könnten.',

+ 1 - 0
frontend/src/i18n/locales/en.ts

@@ -4960,6 +4960,7 @@ export default {
         archivesPrinterMissing: 'Some archives referenced printers that no longer exist - link cleared',
         archivesProjectMissing: 'Some archives referenced projects that no longer exist - link cleared',
         archivesOwnerCleared: 'Some archives referenced users that no longer exist - owner cleared, so they are visible only to users with the archives:read_all permission until an admin reassigns them',
+        archivesOwnerUnknown: 'Some archives were restored without an owner - this backup does not record one, so they are visible only to users with the archives:read_all permission until an admin reassigns them',
         archivesUndeleted: 'Archive(s) deleted since the backup are visible again - overwrite was on',
         archivesMetadataOnly: 'Restored archives carry metadata only - the 3MF and thumbnail files are not in a Git backup',
         spoolUsageUnresolved: '{{count}} usage record(s) skipped - their spool is not in this backup\'s spool list, so there is nothing to attach them to.',

+ 1 - 0
frontend/src/i18n/locales/es.ts

@@ -4920,6 +4920,7 @@ export default {
         archivesPrinterMissing: 'Algunos archivos hacían referencia a impresoras que ya no existen - enlace eliminado',
         archivesProjectMissing: 'Algunos archivos hacían referencia a proyectos que ya no existen - enlace eliminado',
         archivesOwnerCleared: 'Algunos archivos hacían referencia a usuarios que ya no existen - se ha borrado el propietario, por lo que solo son visibles para usuarios con el permiso archives:read_all hasta que un administrador los reasigne',
+        archivesOwnerUnknown: 'Algunos archivos se restauraron sin propietario - esta copia de seguridad no registra ninguno, por lo que solo son visibles para usuarios con el permiso archives:read_all hasta que un administrador los reasigne',
         archivesUndeleted: 'Los archivos eliminados desde la copia vuelven a estar visibles - la sobrescritura estaba activada',
         archivesMetadataOnly: 'Los archivos restaurados solo contienen metadatos - los ficheros 3MF y las miniaturas no están en una copia de Git',
         spoolUsageUnresolved: '{{count}} registros de consumo omitidos - su bobina no está en la lista de bobinas de esta copia, así que no hay nada a lo que asociarlos.',

+ 1 - 0
frontend/src/i18n/locales/fr.ts

@@ -4901,6 +4901,7 @@ export default {
         archivesPrinterMissing: 'Certaines archives référençaient des imprimantes qui n\'existent plus - lien effacé',
         archivesProjectMissing: 'Certaines archives référençaient des projets qui n\'existent plus - lien effacé',
         archivesOwnerCleared: 'Certaines archives référençaient des utilisateurs qui n\'existent plus - propriétaire effacé, elles ne sont donc visibles que par les utilisateurs disposant de la permission archives:read_all jusqu\'à ce qu\'un administrateur les réattribue',
+        archivesOwnerUnknown: 'Certaines archives ont été restaurées sans propriétaire - cette sauvegarde n\'en enregistre aucun, elles ne sont donc visibles que par les utilisateurs disposant de la permission archives:read_all jusqu\'à ce qu\'un administrateur les réattribue',
         archivesUndeleted: 'Les archives supprimées depuis la sauvegarde sont de nouveau visibles - l\'écrasement était activé',
         archivesMetadataOnly: 'Les archives restaurées ne contiennent que des métadonnées - les fichiers 3MF et les miniatures ne sont pas dans une sauvegarde Git',
         spoolUsageUnresolved: '{{count}} enregistrements de consommation ignorés - leur bobine ne figure pas dans la liste des bobines de cette sauvegarde, il n\'y a donc rien à quoi les rattacher.',

+ 1 - 0
frontend/src/i18n/locales/it.ts

@@ -4900,6 +4900,7 @@ export default {
         archivesPrinterMissing: 'Alcuni archivi facevano riferimento a stampanti non più esistenti - collegamento rimosso',
         archivesProjectMissing: 'Alcuni archivi facevano riferimento a progetti non più esistenti - collegamento rimosso',
         archivesOwnerCleared: 'Alcuni archivi facevano riferimento a utenti non più esistenti - proprietario rimosso, quindi sono visibili solo agli utenti con il permesso archives:read_all finché un amministratore non li riassegna',
+        archivesOwnerUnknown: 'Alcuni archivi sono stati ripristinati senza proprietario - questo backup non ne registra alcuno, quindi sono visibili solo agli utenti con il permesso archives:read_all finché un amministratore non li riassegna',
         archivesUndeleted: 'Gli archivi eliminati dopo il backup sono di nuovo visibili - la sovrascrittura era attiva',
         archivesMetadataOnly: 'Gli archivi ripristinati contengono solo metadati - i file 3MF e le miniature non sono in un backup Git',
         spoolUsageUnresolved: '{{count}} record di consumo saltati - la loro bobina non è nell\'elenco bobine di questo backup, quindi non c\'è nulla a cui collegarli.',

+ 1 - 0
frontend/src/i18n/locales/ja.ts

@@ -4912,6 +4912,7 @@ export default {
         archivesPrinterMissing: '一部のアーカイブが存在しないプリンターを参照していました - リンクを解除しました',
         archivesProjectMissing: '一部のアーカイブが存在しないプロジェクトを参照していました - リンクを解除しました',
         archivesOwnerCleared: '一部のアーカイブが存在しないユーザーを参照していました - 所有者を解除したため、管理者が割り当て直すまで archives:read_all 権限を持つユーザーにしか表示されません',
+        archivesOwnerUnknown: '一部のアーカイブは所有者なしで復元されました - このバックアップに所有者が記録されていないため、管理者が割り当てるまで archives:read_all 権限を持つユーザーにしか表示されません',
         archivesUndeleted: 'バックアップ後に削除されたアーカイブが再び表示されます - 上書きが有効でした',
         archivesMetadataOnly: '復元されたアーカイブはメタデータのみです - 3MF ファイルとサムネイルは Git バックアップに含まれません',
         spoolUsageUnresolved: '使用履歴 {{count}} 件をスキップしました - 対応するスプールがこのバックアップのスプール一覧にないため、紐付ける先がありません。',

+ 1 - 0
frontend/src/i18n/locales/ko.ts

@@ -4677,6 +4677,7 @@ export default {
         archivesPrinterMissing: '일부 아카이브가 더 이상 존재하지 않는 프린터를 참조했습니다 - 연결을 해제했습니다',
         archivesProjectMissing: '일부 아카이브가 더 이상 존재하지 않는 프로젝트를 참조했습니다 - 연결을 해제했습니다',
         archivesOwnerCleared: '일부 아카이브가 더 이상 존재하지 않는 사용자를 참조했습니다 - 소유자를 비웠으므로 관리자가 다시 지정하기 전까지 archives:read_all 권한이 있는 사용자에게만 보입니다',
+        archivesOwnerUnknown: '일부 아카이브가 소유자 없이 복원되었습니다 - 이 백업에 소유자가 기록되어 있지 않으므로 관리자가 지정하기 전까지 archives:read_all 권한이 있는 사용자에게만 보입니다',
         archivesUndeleted: '백업 이후 삭제된 아카이브가 다시 표시됩니다 - 덮어쓰기가 켜져 있었습니다',
         archivesMetadataOnly: '복원된 아카이브에는 메타데이터만 있습니다 - 3MF 파일과 썸네일은 Git 백업에 포함되지 않습니다',
         spoolUsageUnresolved: '사용 기록 {{count}}건을 건너뛰었습니다 - 해당 스풀이 이 백업의 스풀 목록에 없어 연결할 대상이 없습니다.',

+ 1 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -4900,6 +4900,7 @@ export default {
         archivesPrinterMissing: 'Alguns arquivos referenciavam impressoras que não existem mais - vínculo removido',
         archivesProjectMissing: 'Alguns arquivos referenciavam projetos que não existem mais - vínculo removido',
         archivesOwnerCleared: 'Alguns arquivos referenciavam usuários que não existem mais - o proprietário foi limpo, então eles só ficam visíveis para usuários com a permissão archives:read_all até que um administrador os reatribua',
+        archivesOwnerUnknown: 'Alguns arquivos foram restaurados sem proprietário - este backup não registra nenhum, então eles só ficam visíveis para usuários com a permissão archives:read_all até que um administrador os atribua',
         archivesUndeleted: 'Arquivos excluídos desde o backup voltaram a ficar visíveis - a sobrescrita estava ligada',
         archivesMetadataOnly: 'Os arquivos restaurados contêm apenas metadados - os arquivos 3MF e as miniaturas não ficam em um backup Git',
         spoolUsageUnresolved: '{{count}} registros de consumo ignorados - o carretel deles não está na lista de carretéis deste backup, então não há a que vinculá-los.',

+ 1 - 0
frontend/src/i18n/locales/ru.ts

@@ -4669,6 +4669,7 @@ export default {
         archivesPrinterMissing: 'Некоторые архивы ссылались на несуществующие принтеры - связь очищена',
         archivesProjectMissing: 'Некоторые архивы ссылались на несуществующие проекты - связь очищена',
         archivesOwnerCleared: 'Некоторые архивы ссылались на несуществующих пользователей - владелец очищен, поэтому они видны только пользователям с правом archives:read_all, пока администратор не назначит владельца заново',
+        archivesOwnerUnknown: 'Некоторые архивы восстановлены без владельца - в этой резервной копии он не записан, поэтому они видны только пользователям с правом archives:read_all, пока администратор не назначит владельца',
         archivesUndeleted: 'Архивы, удалённые после резервного копирования, снова видны - перезапись была включена',
         archivesMetadataOnly: 'Восстановленные архивы содержат только метаданные - файлы 3MF и миниатюры не входят в резервную копию Git',
         spoolUsageUnresolved: 'Записей расхода пропущено: {{count}} - их катушки нет в списке катушек этой резервной копии, поэтому привязать их не к чему.',

+ 1 - 0
frontend/src/i18n/locales/tr.ts

@@ -4890,6 +4890,7 @@ export default {
         archivesPrinterMissing: 'Bazı arşivler artık var olmayan yazıcılara işaret ediyordu - bağlantı temizlendi',
         archivesProjectMissing: 'Bazı arşivler artık var olmayan projelere işaret ediyordu - bağlantı temizlendi',
         archivesOwnerCleared: 'Bazı arşivler artık var olmayan kullanıcılara işaret ediyordu - sahip temizlendi, bu yüzden bir yönetici yeniden atayana kadar yalnızca archives:read_all iznine sahip kullanıcılara görünürler',
+        archivesOwnerUnknown: 'Bazı arşivler sahipsiz geri yüklendi - bu yedek sahip bilgisi içermiyor, bu yüzden bir yönetici atama yapana kadar yalnızca archives:read_all iznine sahip kullanıcılara görünürler',
         archivesUndeleted: 'Yedekten sonra silinen arşivler yeniden görünür oldu - üzerine yazma açıktı',
         archivesMetadataOnly: 'Geri yüklenen arşivler yalnızca üst veri içerir - 3MF dosyaları ve küçük resimler Git yedeğinde yer almaz',
         spoolUsageUnresolved: '{{count}} kullanım kaydı atlandı - makaraları bu yedeğin makara listesinde olmadığı için bağlanacak bir şey yok.',

+ 1 - 0
frontend/src/i18n/locales/uk.ts

@@ -4955,6 +4955,7 @@ export default {
         archivesPrinterMissing: "Деякі архіви посилалися на принтери, яких більше немає - зв'язок очищено",
         archivesProjectMissing: "Деякі архіви посилалися на проєкти, яких більше немає - зв'язок очищено",
         archivesOwnerCleared: "Деякі архіви посилалися на користувачів, яких більше немає - власника очищено, тому вони видимі лише користувачам із дозволом archives:read_all, доки адміністратор не призначить власника знову",
+        archivesOwnerUnknown: "Деякі архіви відновлено без власника - у цій резервній копії його не записано, тому вони видимі лише користувачам із дозволом archives:read_all, доки адміністратор не призначить власника",
         archivesUndeleted: "Архіви, видалені після резервного копіювання, знову видимі - перезапис був увімкнений",
         archivesMetadataOnly: "Відновлені архіви містять лише метадані - файли 3MF і мініатюри не входять до резервної копії Git",
         spoolUsageUnresolved: "Записів використання пропущено: {{count}} - їхньої котушки немає у списку котушок цієї резервної копії, тож немає до чого їх прив'язати.",

+ 1 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -4900,6 +4900,7 @@ export default {
         archivesPrinterMissing: '部分归档引用了已不存在的打印机 - 已清除关联',
         archivesProjectMissing: '部分归档引用了已不存在的项目 - 已清除关联',
         archivesOwnerCleared: '部分归档引用了已不存在的用户 - 已清除归属,因此在管理员重新指派之前,只有拥有 archives:read_all 权限的用户才能看到它们',
+        archivesOwnerUnknown: '部分归档在恢复时没有归属 - 此备份未记录归属,因此在管理员指派之前,只有拥有 archives:read_all 权限的用户才能看到它们',
         archivesUndeleted: '备份之后被删除的归档重新可见 - 当时启用了覆盖',
         archivesMetadataOnly: '恢复的归档仅含元数据 - 3MF 文件和缩略图不在 Git 备份中',
         spoolUsageUnresolved: '已跳过 {{count}} 条使用记录 - 其耗材卷不在此备份的耗材列表中,没有可挂接的对象。',

+ 1 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -4900,6 +4900,7 @@ export default {
         archivesPrinterMissing: '部分封存參照了已不存在的印表機 - 已清除連結',
         archivesProjectMissing: '部分封存參照了已不存在的專案 - 已清除連結',
         archivesOwnerCleared: '部分封存參照了已不存在的使用者 - 已清除擁有者,因此在管理員重新指派之前,只有具備 archives:read_all 權限的使用者才看得到',
+        archivesOwnerUnknown: '部分封存還原時沒有擁有者 - 此備份未記錄擁有者,因此在管理員指派之前,只有具備 archives:read_all 權限的使用者才看得到',
         archivesUndeleted: '備份之後刪除的封存重新可見 - 當時啟用了覆寫',
         archivesMetadataOnly: '還原的封存僅含中繼資料 - 3MF 檔案與縮圖不在 Git 備份中',
         spoolUsageUnresolved: '已略過 {{count}} 筆使用紀錄 - 其耗材捲不在此備份的耗材清單中,沒有可掛接的對象。',