fix(backup): carry the owner across, or restored archives are invisible (#2656)

Neither _collect_archives nor _restore_archives touched created_by_id, so every
restored archive row landed NULL. That column is not attribution, it is what the
access check runs on: _ensure_archive_visible (api/routes/archives.py) fails
closed on NULL — a 404 for any caller without archives:read_all — and the list
paths filter created_by_id == user.id. On a multi-user instance the tally
therefore reported archives restored while the person who owns them could
neither list nor open them.

Same shape as the deleted_at fix, and the same remedy: the collector records the
key next to deleted_at, the restore mirrors the printer_id/project_id pattern
exactly — one hoisted select(User.id), a membership test per row, an unknown id
coerced to None rather than failing the row, and one de-duplicated note. It is in
the overwrite setattr loop too, so overwrite keeps meaning "make local match the
backup". Additive on the backup side, so older backups still restore; they just
cannot know the owner.

Clearing the id is not silent-safe, so the note says what it costs: those
archives are visible only to users with archives:read_all until an admin
reassigns them.

Caveat recorded in a comment and raised in the PR, not decided here: this is the
one place the module reuses a raw backup id, against its own rule. Validating it
means a *stale* id clears rather than pointing somewhere wrong, but a live id
belonging to a different person on a different instance would still collide.
Collecting username and resolving on that would close it.

6 unit tests and 1 integration test that all fail against the parent commit,
plus 2 controls that pass either way — a backup with no created_by_id key still
restores, and a second operator still gets a 404.
This commit is contained in:
jmoore-skild
2026-08-04 08:57:33 -04:00
parent ca93d4cf44
commit cfa82bfcfb
4 changed files with 257 additions and 0 deletions
@@ -4,6 +4,9 @@ from unittest.mock import AsyncMock, patch
import pytest
from httpx import AsyncClient
from sqlalchemy import select
from backend.tests.integration.test_ownership_permissions import TestOwnershipPermissionsSetup
@pytest.fixture(autouse=True)
@@ -276,6 +279,83 @@ class TestStatusExposesRestoreState:
assert response.json()["restore_running"] is False
class TestRestoredArchivesAreVisibleToTheirOwner(TestOwnershipPermissionsSetup):
"""The archive-ownership blocker, proved through the route that enforces it.
``_ensure_archive_visible`` fails closed on a NULL ``created_by_id`` — 404 for
any caller without ``archives:read_all`` — so before the collector and the
restore carried the column across, a multi-user instance got archives the
tally called restored and their owner could not open.
"""
@pytest.mark.asyncio
@pytest.mark.integration
async def test_the_owning_non_admin_can_open_a_restored_archive(
self, async_client: AsyncClient, auth_setup, db_session
):
from backend.app.models.archive import PrintArchive
from backend.app.services.github_restore import _CategoryTally, github_restore_service
owner_id = auth_setup["operator_user"]["id"]
payload = {
"archives": [
{
"id": 77,
"filename": "benchy.3mf",
"file_size": 2048,
"content_hash": "abc123",
"print_name": "Benchy",
"started_at": "2026-03-01 10:00:00",
"created_at": "2026-03-01 10:00:00",
"created_by_id": owner_id,
}
]
}
await github_restore_service._restore_archives(db_session, payload, False, _CategoryTally(), {})
await db_session.commit()
restored = (await db_session.execute(select(PrintArchive))).scalar_one()
assert restored.id != 77, "the backup's primary key must not be reused"
response = await async_client.get(
f"/api/v1/archives/{restored.id}",
headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
)
assert response.status_code == 200, "the owner cannot see their own restored archive"
assert response.json()["print_name"] == "Benchy"
@pytest.mark.asyncio
@pytest.mark.integration
async def test_a_different_operator_still_cannot(self, async_client: AsyncClient, auth_setup, db_session):
"""Control: carrying the owner across must not widen who can read it."""
from backend.app.models.archive import PrintArchive
from backend.app.services.github_restore import _CategoryTally, github_restore_service
payload = {
"archives": [
{
"id": 77,
"filename": "benchy.3mf",
"file_size": 2048,
"content_hash": "abc123",
"started_at": "2026-03-01 10:00:00",
"created_by_id": auth_setup["operator_user"]["id"],
}
]
}
await github_restore_service._restore_archives(db_session, payload, False, _CategoryTally(), {})
await db_session.commit()
restored = (await db_session.execute(select(PrintArchive))).scalar_one()
response = await async_client.get(
f"/api/v1/archives/{restored.id}",
headers={"Authorization": f"Bearer {auth_setup['operator2_token']}"},
)
assert response.status_code == 404
class TestRestoreDoesNotOpenTheMetricsEndpoint:
"""The companion-credential rule, proved against the endpoint it protects.