mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-06 14:11:35 +02:00
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:
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user