Jelajahi Sumber

fix(archives): an archive edit cannot move it to a printer outside the user's groups

The archive edit checked only the printer the archive was on, then wrote the
new printer_id and project_id unchecked. A member of a group limited to
printer A could move an archive of A to printer B, into the history of a team
they have no access to (measured: the other team's member then listed it),
although uploading to such a printer was already refused. Any caller could
also link an archive to a printer or project that does not exist: stored
dangling on SQLite, a foreign key error (500) on PostgreSQL.

A changed printer now has to be in the caller's printer scope (404 "Printer
not found", as for any hidden printer) and has to exist (400), and a changed
project has to exist (400 "Project not found"). Null still clears either; a
printer-less archive is in every scope, as one uploaded without a printer.
Values the edit resends unchanged are not re-checked, so an archive whose
stored printer or project is dangling can still be edited.
maziggy 1 Minggu lalu
induk
melakukan
e0e2799f40

+ 1 - 0
CHANGELOG.md

@@ -162,6 +162,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **The frontend build no longer warns about `path` and `crypto` being externalized for the STEP previewer (#2976)** — `occt-import-js`, the Emscripten build behind STEP previews, requires both modules, but only inside its `ENVIRONMENT_IS_NODE` branches; in the browser it loads its `.wasm` from the URL the preview worker passes and draws randomness from `crypto.getRandomValues`. Vite still externalized both and printed two warnings on every build. `vite.config.ts` now drops exactly those two warnings for that one package through `build.rolldownOptions.onLog`, so an externalization anywhere else, or of any other module, still shows.
 
 ### Fixed
+- **Editing an archive can no longer move it to a printer outside the user's groups** — A member of a group limited to some printers could move an archive of one of their printers to any other printer through the archive edit, into the history of a team they have no access to, although uploading an archive to such a printer was already refused. Any edit could also link an archive to a printer or project that does not exist (a server error on PostgreSQL, a broken link on SQLite). Such an edit is now refused with "Printer not found" or "Project not found". Moving an archive between the user's own printers, and removing its printer or project, work as before.
 - **A smart plug or virtual printer can no longer be linked to printer 0** — Adding or editing a smart plug and adding a virtual printer skipped the "Printer not found" check when the printer id was `0`. On PostgreSQL the save then failed with a server error; on SQLite the plug or virtual printer was saved linked to a printer that does not exist. Such a request is now refused with "Printer not found", as for any other unknown printer.
 - **No more firmware-check errors in the log while the internet is down** — Without an internet connection, every firmware check of every printer logged a full error for each Bambu Lab page it tried (measured: 12 errors and 9 warnings for three printers checked three times). The outage is now logged once per Bambu Lab site as a warning, the checks keep retrying quietly, and a line in the log says when the site is reachable again.
 - **Bambu Cloud being unreachable is reported as such** — When Bambu Cloud failed (no internet, a blocked or failing connection, an error or an error page such as a Cloudflare challenge), the cloud preset, device and firmware views answered with a server error (500), and pasting an access token answered as if the token were wrong (401). Signing in or entering the email code without a connection to Bambu Cloud also answered 401, as if the password or code were wrong. These now answer 502 (Bambu Cloud unreachable) with the cause, as the preset lookups already did; a pasted token counts as invalid only when Bambu Cloud itself refuses it. An expired session is answered as such (401, sign in again) instead of with a server error.

+ 13 - 0
backend/app/api/routes/archives.py

@@ -33,6 +33,7 @@ from backend.app.core.printer_scope import PrinterScope
 from backend.app.models.archive import PrintArchive
 from backend.app.models.filament import Filament
 from backend.app.models.printer import Printer
+from backend.app.models.project import Project
 from backend.app.models.spool_usage_history import SpoolUsageHistory
 from backend.app.models.user import User
 from backend.app.schemas.archive import ArchiveResponse, ArchiveSlim, ArchiveStats, ArchiveUpdate
@@ -1901,6 +1902,18 @@ async def update_archive(
     previous_filament_grams = archive.filament_used_grams
 
     update_payload = update_data.model_dump(exclude_unset=True)
+    # A new printer must be one the caller may use (as on upload), and both
+    # targets must exist. None clears either: a printer-less archive is in
+    # every scope, like one uploaded without a printer.
+    new_printer_id = update_payload.get("printer_id")
+    if new_printer_id is not None and new_printer_id != archive.printer_id:
+        printer_scope.ensure(new_printer_id)
+        if await db.get(Printer, new_printer_id) is None:
+            raise HTTPException(400, "Printer not found")
+    new_project_id = update_payload.get("project_id")
+    if new_project_id is not None and new_project_id != archive.project_id:
+        if await db.get(Project, new_project_id) is None:
+            raise HTTPException(400, "Project not found")
     # #1898: how the verdict arrived is recorded with it, never on its own.
     verdict_source = update_payload.pop("user_verdict_source", None)
     for field, value in update_payload.items():

+ 122 - 0
backend/tests/integration/test_archive_update_targets.py

@@ -0,0 +1,122 @@
+"""Editing an archive cannot move it to a printer or project the caller may not use.
+
+The archive edit checked only the printer the archive was on. A member of a
+group limited to printer A could move an archive of A to printer B, into the
+history of a team they have no access to, and any caller could link an
+archive to a printer or project that does not exist (stored dangling on
+SQLite, a server error on PostgreSQL). Upload already checked the printer.
+"""
+
+from __future__ import annotations
+
+import pytest
+from httpx import AsyncClient
+
+from backend.tests.integration.test_printer_scope_1727 import TEAM_PERMISSIONS, _admin_token, _auth, _group, _user
+
+pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
+
+
+async def _member(client: AsyncClient, admin: str, name: str, printer_ids: list[int]) -> dict[str, str]:
+    perms = await _group(client, admin, f"perms_{name}", permissions=[*TEAM_PERMISSIONS, "archives:update_all"])
+    team = await _group(client, admin, f"team_{name}", printer_ids=printer_ids)
+    return _auth((await _user(client, admin, name, [perms, team]))[0])
+
+
+async def _printer_of(client: AsyncClient, headers: dict[str, str], archive_id: int) -> int | None:
+    response = await client.get(f"/api/v1/archives/{archive_id}", headers=headers)
+    assert response.status_code == 200, response.text
+    return response.json()["printer_id"]
+
+
+async def test_a_member_cannot_move_an_archive_to_a_printer_outside_the_team(
+    async_client: AsyncClient, printer_factory, archive_factory
+):
+    a, b = await printer_factory(name="A"), await printer_factory(name="B")
+    admin = await _admin_token(async_client)
+    member = await _member(async_client, admin, "member", [a.id])
+    other_team = await _member(async_client, admin, "other", [b.id])
+    archive = await archive_factory(a.id, print_name="on-a")
+
+    response = await async_client.patch(f"/api/v1/archives/{archive.id}", headers=member, json={"printer_id": b.id})
+
+    assert response.status_code == 404, response.text
+    assert response.json()["detail"] == "Printer not found"
+    assert await _printer_of(async_client, _auth(admin), archive.id) == a.id
+    listed = await async_client.get("/api/v1/archives/", headers=other_team)
+    assert [x["print_name"] for x in listed.json()] == []
+
+
+async def test_a_member_can_move_an_archive_between_team_printers(
+    async_client: AsyncClient, printer_factory, archive_factory
+):
+    a, a2 = await printer_factory(name="A"), await printer_factory(name="A2")
+    admin = await _admin_token(async_client)
+    member = await _member(async_client, admin, "member", [a.id, a2.id])
+    archive = await archive_factory(a.id)
+
+    response = await async_client.patch(f"/api/v1/archives/{archive.id}", headers=member, json={"printer_id": a2.id})
+
+    assert response.status_code == 200, response.text
+    assert await _printer_of(async_client, member, archive.id) == a2.id
+
+
+async def test_a_member_can_still_clear_the_printer(async_client: AsyncClient, printer_factory, archive_factory):
+    # As an upload without a printer: a printer-less archive is in every scope.
+    a = await printer_factory(name="A")
+    admin = await _admin_token(async_client)
+    member = await _member(async_client, admin, "member", [a.id])
+    archive = await archive_factory(a.id)
+
+    response = await async_client.patch(f"/api/v1/archives/{archive.id}", headers=member, json={"printer_id": None})
+
+    assert response.status_code == 200, response.text
+    assert await _printer_of(async_client, member, archive.id) is None
+
+
+async def test_an_admin_can_move_an_archive_to_any_printer(async_client: AsyncClient, printer_factory, archive_factory):
+    a, b = await printer_factory(name="A"), await printer_factory(name="B")
+    archive = await archive_factory(a.id)
+
+    response = await async_client.patch(f"/api/v1/archives/{archive.id}", json={"printer_id": b.id})
+
+    assert response.status_code == 200, response.text
+    assert response.json()["printer_id"] == b.id
+
+
+@pytest.mark.parametrize("printer_id", [0, 9999])
+async def test_an_unknown_printer_is_refused(async_client: AsyncClient, printer_factory, archive_factory, printer_id):
+    a = await printer_factory(name="A")
+    archive = await archive_factory(a.id)
+
+    response = await async_client.patch(f"/api/v1/archives/{archive.id}", json={"printer_id": printer_id})
+
+    assert response.status_code == 400, response.text
+    assert response.json()["detail"] == "Printer not found"
+    assert (await async_client.get(f"/api/v1/archives/{archive.id}")).json()["printer_id"] == a.id
+
+
+@pytest.mark.parametrize("project_id", [0, 9999])
+async def test_an_unknown_project_is_refused(async_client: AsyncClient, printer_factory, archive_factory, project_id):
+    a = await printer_factory(name="A")
+    archive = await archive_factory(a.id)
+
+    response = await async_client.patch(f"/api/v1/archives/{archive.id}", json={"project_id": project_id})
+
+    assert response.status_code == 400, response.text
+    assert response.json()["detail"] == "Project not found"
+    assert (await async_client.get(f"/api/v1/archives/{archive.id}")).json()["project_id"] is None
+
+
+async def test_a_known_project_can_be_set_and_cleared(async_client: AsyncClient, printer_factory, archive_factory):
+    a = await printer_factory(name="A")
+    archive = await archive_factory(a.id)
+    project = (await async_client.post("/api/v1/projects/", json={"name": "P"})).json()
+
+    linked = await async_client.patch(f"/api/v1/archives/{archive.id}", json={"project_id": project["id"]})
+    cleared = await async_client.patch(f"/api/v1/archives/{archive.id}", json={"project_id": None})
+
+    assert linked.status_code == 200, linked.text
+    assert linked.json()["project_id"] == project["id"]
+    assert cleared.status_code == 200, cleared.text
+    assert cleared.json()["project_id"] is None