test_archive_scope_per_run.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427
  1. """A caller limited to some printers sees an archive by its prints (#1727, archive redesign).
  2. Plate 1 of one archive printed on printer A, then plate 2 on B: the archive
  3. names B. A user of A sees the archive by plate 1 and only plate 1 of it: its
  4. outcome, video and finish photos. What changes the whole archive is refused
  5. to them, as it would change B's print too.
  6. """
  7. from __future__ import annotations
  8. from datetime import datetime, timedelta
  9. from types import SimpleNamespace
  10. import pytest
  11. from backend.app.core.config import settings as app_settings
  12. from backend.app.models.archive import PrintArchive
  13. from backend.app.models.print_log import RUN_OPEN, PrintLogEntry
  14. from backend.tests.integration.test_archives_last_per_printer import _admin_token, _auth, _user
  15. pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
  16. T1 = datetime(2026, 10, 30, 9)
  17. T2 = T1 + timedelta(hours=1)
  18. DIR = "archives/scoped"
  19. PERMISSIONS = ["archives:read_all", "archives:update_all", "archives:delete_all", "printers:read", "projects:read"]
  20. @pytest.fixture
  21. def files(tmp_path, monkeypatch):
  22. monkeypatch.setattr(app_settings, "base_dir", tmp_path)
  23. monkeypatch.setattr(app_settings, "archive_dir", tmp_path / "archive")
  24. (tmp_path / DIR / "photos").mkdir(parents=True)
  25. for name in ("a.mp4", "b.mp4", "photos/finish_a.jpg", "photos/finish_b.jpg", "photos/0a1b2c3d.jpg"):
  26. (tmp_path / DIR / name).write_bytes(b"x")
  27. return tmp_path
  28. @pytest.fixture
  29. async def plates(async_client, db_session, printer_factory, files):
  30. """Plate 1 completed on A with its video and photo, then plate 2 failed
  31. on B with its own; one photo uploaded by hand. Users scoped to A, to B
  32. and to a printer C that printed nothing."""
  33. a, b, c = await printer_factory(name="A"), await printer_factory(name="B"), await printer_factory(name="C")
  34. archive = PrintArchive(
  35. printer_id=b.id,
  36. filename="two.gcode.3mf",
  37. file_path=f"{DIR}/two.gcode.3mf",
  38. file_size=1,
  39. print_name="two",
  40. status="failed",
  41. started_at=T2,
  42. timelapse_path=f"{DIR}/b.mp4",
  43. photos=["finish_a.jpg", "finish_b.jpg", "0a1b2c3d.jpg"],
  44. )
  45. db_session.add(archive)
  46. await db_session.flush()
  47. first = PrintLogEntry(
  48. archive_id=archive.id,
  49. printer_id=a.id,
  50. plate_id=1,
  51. status="completed",
  52. started_at=T1,
  53. photos=["finish_a.jpg"],
  54. timelapse_path=f"{DIR}/a.mp4",
  55. )
  56. second = PrintLogEntry(
  57. archive_id=archive.id,
  58. printer_id=b.id,
  59. plate_id=2,
  60. status="failed",
  61. started_at=T2,
  62. photos=["finish_b.jpg"],
  63. timelapse_path=f"{DIR}/b.mp4",
  64. )
  65. db_session.add_all([first, second])
  66. await db_session.commit()
  67. admin = await _admin_token(async_client)
  68. users = {}
  69. for name, printer in (("of_a", a), ("of_b", b), ("of_c", c)):
  70. jwt, _ = await _user(async_client, admin, name, PERMISSIONS, printer_ids=[printer.id])
  71. users[name] = _auth(jwt)
  72. return SimpleNamespace(
  73. archive=archive.id, a=a.id, b=b.id, c=c.id, first=first.id, second=second.id, admin=_auth(admin), **users
  74. )
  75. def _url(plates, path=""):
  76. return f"/api/v1/archives/{plates.archive}{path}"
  77. async def _row(db_session, model, row_id):
  78. db_session.expire_all()
  79. return await db_session.get(model, row_id)
  80. class TestSeeing:
  81. async def test_a_user_of_a_sees_the_archive_by_its_plate(self, async_client, plates):
  82. listed = (await async_client.get("/api/v1/archives/", headers=plates.of_a)).json()
  83. assert [a["id"] for a in listed] == [plates.archive]
  84. shown = listed[0]
  85. assert (shown["printer_id"], shown["status"], shown["run_id"]) == (plates.a, "completed", plates.first)
  86. assert shown["timelapse_path"] == f"{DIR}/a.mp4"
  87. assert shown["photos"] == ["finish_a.jpg"]
  88. assert shown["run_count"] == 1
  89. async def test_the_archive_page_shows_their_plate(self, async_client, plates):
  90. response = await async_client.get(_url(plates), headers=plates.of_a)
  91. assert response.status_code == 200, response.text
  92. body = response.json()
  93. assert (body["printer_id"], body["status"], body["run_id"]) == (plates.a, "completed", plates.first)
  94. async def test_the_other_plate_is_not_theirs(self, async_client, plates):
  95. response = await async_client.get(_url(plates, f"?run_id={plates.second}"), headers=plates.of_a)
  96. assert response.status_code == 404
  97. async def test_its_print_log_lists_their_plate_only(self, async_client, plates):
  98. runs = (await async_client.get(_url(plates, "/runs"), headers=plates.of_a)).json()
  99. assert [r["id"] for r in runs["items"]] == [plates.first]
  100. async def test_a_user_of_b_sees_its_own_and_the_uploads(self, async_client, plates):
  101. body = (await async_client.get(_url(plates), headers=plates.of_b)).json()
  102. assert (body["printer_id"], body["status"]) == (plates.b, "failed")
  103. assert body["timelapse_path"] == f"{DIR}/b.mp4"
  104. assert body["photos"] == ["finish_b.jpg", "0a1b2c3d.jpg"]
  105. async def test_a_printer_it_never_printed_on_sees_nothing(self, async_client, plates):
  106. assert (await async_client.get("/api/v1/archives/", headers=plates.of_c)).json() == []
  107. assert (await async_client.get(_url(plates), headers=plates.of_c)).status_code == 404
  108. async def test_a_plate_printing_now_counts(self, async_client, db_session, plates):
  109. db_session.add(
  110. PrintLogEntry(
  111. archive_id=plates.archive, printer_id=plates.c, status=RUN_OPEN, started_at=T2 + timedelta(hours=1)
  112. )
  113. )
  114. await db_session.commit()
  115. listed = (await async_client.get("/api/v1/archives/", headers=plates.of_c)).json()
  116. assert [a["id"] for a in listed] == [plates.archive]
  117. assert (await async_client.get(_url(plates), headers=plates.of_c)).status_code == 200
  118. async def test_search_finds_it_by_their_plate(self, async_client, plates):
  119. response = await async_client.get("/api/v1/archives/search?q=two", headers=plates.of_a)
  120. assert response.status_code == 200, response.text
  121. assert [a["id"] for a in response.json()] == [plates.archive]
  122. assert response.json()[0]["photos"] == ["finish_a.jpg"]
  123. async def test_the_admin_sees_it_whole(self, async_client, plates):
  124. body = (await async_client.get(_url(plates), headers=plates.admin)).json()
  125. assert (body["printer_id"], body["run_count"]) == (plates.b, 2)
  126. assert body["photos"] == ["finish_a.jpg", "finish_b.jpg", "0a1b2c3d.jpg"]
  127. class TestMedia:
  128. async def test_the_other_plates_video_is_not_theirs(self, async_client, plates):
  129. assert (await async_client.get(_url(plates, "/timelapse"), headers=plates.of_a)).status_code == 404
  130. assert (await async_client.get(_url(plates, "/timelapse"), headers=plates.of_b)).status_code == 200
  131. async def test_the_other_plates_photo_is_not_theirs(self, async_client, plates):
  132. assert (await async_client.get(_url(plates, "/photos/finish_b.jpg"), headers=plates.of_a)).status_code == 404
  133. assert (await async_client.get(_url(plates, "/photos/finish_a.jpg"), headers=plates.of_a)).status_code == 200
  134. assert (await async_client.get(_url(plates, "/photos/0a1b2c3d.jpg"), headers=plates.of_a)).status_code == 200
  135. async def test_nor_theirs_to_delete(self, async_client, db_session, plates):
  136. response = await async_client.delete(_url(plates, "/photos/finish_b.jpg"), headers=plates.of_a)
  137. assert response.status_code == 404
  138. assert "finish_b.jpg" in (await _row(db_session, PrintArchive, plates.archive)).photos
  139. async def test_a_finish_photo_on_no_runs_list_is_the_archives_latest(self, async_client, db_session, plates):
  140. """Logged before runs kept their own photos: the archive's, here B's."""
  141. archive = await _row(db_session, PrintArchive, plates.archive)
  142. archive.photos = [*archive.photos, "finish_old.jpg"]
  143. await db_session.commit()
  144. assert "finish_old.jpg" not in (await async_client.get(_url(plates), headers=plates.of_a)).json()["photos"]
  145. assert "finish_old.jpg" in (await async_client.get(_url(plates), headers=plates.of_b)).json()["photos"]
  146. async def test_a_plate_logged_before_runs_kept_media_shows_none_of_the_others(
  147. self, async_client, db_session, files, plates
  148. ):
  149. """Its run has no video or photo list of its own: the archive's are
  150. B's latest, and only the photo uploaded by hand is everyone's."""
  151. first = await _row(db_session, PrintLogEntry, plates.first)
  152. first.photos, first.timelapse_path = None, None
  153. await db_session.commit()
  154. body = (await async_client.get(_url(plates), headers=plates.of_a)).json()
  155. assert (body["run_id"], body["timelapse_path"]) == (plates.first, None)
  156. assert body["photos"] == ["0a1b2c3d.jpg"]
  157. assert (await async_client.get(_url(plates, "/photos/finish_a.jpg"), headers=plates.of_a)).status_code == 404
  158. class TestWholeArchive:
  159. async def test_deleting_it_is_refused(self, async_client, db_session, plates):
  160. response = await async_client.delete(_url(plates), headers=plates.of_a)
  161. assert response.status_code == 403, response.text
  162. assert (await _row(db_session, PrintArchive, plates.archive)).deleted_at is None
  163. async def test_also_to_the_user_of_its_own_printer(self, async_client, plates):
  164. """B is the archive's printer; plate 1 printed on A."""
  165. assert (await async_client.delete(_url(plates), headers=plates.of_b)).status_code == 403
  166. async def test_deleting_its_video_is_refused(self, async_client, db_session, plates):
  167. response = await async_client.delete(_url(plates, "/timelapse"), headers=plates.of_b)
  168. assert response.status_code == 403
  169. assert (await _row(db_session, PrintArchive, plates.archive)).timelapse_path == f"{DIR}/b.mp4"
  170. async def test_its_notes_are_theirs_to_edit(self, async_client, db_session, plates):
  171. response = await async_client.patch(_url(plates), headers=plates.of_a, json={"notes": "nice", "tags": "x"})
  172. assert response.status_code == 200, response.text
  173. assert (await _row(db_session, PrintArchive, plates.archive)).notes == "nice"
  174. async def test_its_cost_is_not(self, async_client, db_session, plates):
  175. response = await async_client.patch(_url(plates), headers=plates.of_a, json={"cost": 9.0})
  176. assert response.status_code == 403
  177. assert (await _row(db_session, PrintArchive, plates.archive)).cost is None
  178. async def test_nor_its_outcome_without_their_run(self, async_client, db_session, plates):
  179. response = await async_client.patch(_url(plates), headers=plates.of_a, json={"user_verdict": "good"})
  180. assert response.status_code == 403
  181. async def test_their_plates_outcome_is(self, async_client, db_session, plates):
  182. response = await async_client.patch(
  183. _url(plates), headers=plates.of_a, json={"user_verdict": "good", "run_id": plates.first}
  184. )
  185. assert response.status_code == 200, response.text
  186. assert (await _row(db_session, PrintLogEntry, plates.first)).user_verdict == "good"
  187. async def test_the_admin_may_delete_it(self, async_client, db_session, plates):
  188. response = await async_client.delete(_url(plates), headers=plates.admin)
  189. assert response.status_code == 200, response.text
  190. @pytest.mark.parametrize(
  191. ("method", "path", "kwargs"),
  192. [
  193. ("post", "/timelapse/select?filename=v.mp4", {}),
  194. ("post", "/timelapse/upload", {"files": {"file": ("v.mp4", b"x", "video/mp4")}}),
  195. ("post", "/timelapse/process", {"data": {"speed": "2"}}),
  196. ("delete", "/timelapse/job", {}),
  197. ("post", "/source", {"files": {"file": ("s.3mf", b"x", "application/octet-stream")}}),
  198. ("delete", "/source", {}),
  199. ("post", "/f3d", {"files": {"file": ("d.f3d", b"x", "application/octet-stream")}}),
  200. ("delete", "/f3d", {}),
  201. ],
  202. )
  203. async def test_its_file_and_video_are_not_theirs_to_change(self, async_client, plates, method, path, kwargs):
  204. """B's user: the archive's own printer, but plate 1 printed on A."""
  205. response = await getattr(async_client, method)(_url(plates, path), headers=plates.of_b, **kwargs)
  206. assert response.status_code == 403, response.text
  207. async def test_scanning_for_its_video_is_not_theirs(self, async_client, db_session, plates):
  208. """A scan attaches a video to the archive: only without one."""
  209. archive = await _row(db_session, PrintArchive, plates.archive)
  210. archive.timelapse_path = None
  211. await db_session.commit()
  212. response = await async_client.post(_url(plates, "/timelapse/scan"), headers=plates.of_b)
  213. assert response.status_code == 403, response.text
  214. class TestTheOtherPlatesVideo:
  215. @pytest.mark.parametrize(
  216. ("method", "path"),
  217. [
  218. ("get", "/timelapse/info"),
  219. ("get", "/timelapse/thumbnails"),
  220. ("post", "/media-download-token"),
  221. ],
  222. )
  223. async def test_is_missing_for_them(self, async_client, plates, method, path):
  224. response = await getattr(async_client, method)(_url(plates, path), headers=plates.of_a)
  225. assert response.status_code == 404, response.text
  226. async def test_is_not_offered_as_attached(self, async_client, plates):
  227. response = await async_client.get(_url(plates, "/printer-media"), headers=plates.of_a)
  228. assert response.status_code == 200, response.text
  229. assert response.json()["local_timelapse"] is None
  230. class TestReviewFixes:
  231. async def test_a_null_run_id_is_no_run(self, async_client, db_session, plates):
  232. response = await async_client.patch(
  233. _url(plates), headers=plates.of_a, json={"run_id": None, "status": "completed", "failure_reason": "x"}
  234. )
  235. assert response.status_code == 403, response.text
  236. assert (await _row(db_session, PrintArchive, plates.archive)).status == "failed"
  237. async def test_its_project_page_is_its_file(self, async_client, plates):
  238. response = await async_client.patch(_url(plates, "/project-page"), headers=plates.of_a, json={"title": "x"})
  239. assert response.status_code == 403, response.text
  240. async def test_compare_and_similar_leave_it_out(self, async_client, db_session, plates):
  241. """Both show each archive's latest print as it is: B's."""
  242. other = PrintArchive(
  243. printer_id=plates.a, filename="two.gcode.3mf", file_path=f"{DIR}/o.3mf", file_size=1, print_name="two"
  244. )
  245. db_session.add(other)
  246. await db_session.commit()
  247. other_id = other.id
  248. compare = await async_client.get(
  249. f"/api/v1/archives/compare?archive_ids={plates.archive},{other_id}", headers=plates.of_a
  250. )
  251. assert compare.status_code == 404, compare.text
  252. similar = (await async_client.get(f"/api/v1/archives/{other_id}/similar", headers=plates.of_a)).json()
  253. assert plates.archive not in [s["archive"]["id"] for s in similar]
  254. async def test_the_project_previews_leave_it_out(self, async_client, db_session, plates):
  255. from backend.app.models.project import Project
  256. project = Project(name="P")
  257. db_session.add(project)
  258. await db_session.flush()
  259. (await db_session.get(PrintArchive, plates.archive)).project_id = project.id
  260. await db_session.commit()
  261. projects = (await async_client.get("/api/v1/projects/", headers=plates.of_a)).json()
  262. assert [p.get("archives", []) for p in projects] == [[]]
  263. async def test_its_photo_responses_show_theirs(self, async_client, plates):
  264. uploaded = await async_client.post(
  265. _url(plates, "/photos"), headers=plates.of_a, files={"file": ("p.jpg", b"\xff\xd8\xff", "image/jpeg")}
  266. )
  267. assert uploaded.status_code == 200, uploaded.text
  268. assert "finish_b.jpg" not in uploaded.json()["photos"]
  269. deleted = await async_client.delete(_url(plates, "/photos/0a1b2c3d.jpg"), headers=plates.of_a)
  270. assert deleted.status_code == 200, deleted.text
  271. assert "finish_b.jpg" not in deleted.json()["photos"]
  272. async def test_printer_media_does_not_name_the_other_printer(self, async_client, plates):
  273. response = await async_client.get(_url(plates, "/printer-media"), headers=plates.of_a)
  274. assert response.json()["printer_id"] is None
  275. async def test_its_energy_and_time_are_their_prints(self, async_client, db_session, plates):
  276. archive = await _row(db_session, PrintArchive, plates.archive)
  277. archive.energy_kwh, archive.wear_cost = 5.0, 2.0
  278. archive.status, archive.completed_at, archive.print_time_seconds = "completed", T2 + timedelta(hours=1), 3600
  279. first = await db_session.get(PrintLogEntry, plates.first)
  280. first.energy_kwh, first.wear_cost = 1.0, 0.5
  281. await db_session.commit()
  282. body = (await async_client.get(_url(plates), headers=plates.of_a)).json()
  283. assert (body["energy_kwh"], body["wear_cost"]) == (1.0, 0.5)
  284. assert body["actual_time_seconds"] is None
  285. whole = (await async_client.get(_url(plates), headers=plates.admin)).json()
  286. assert (whole["energy_kwh"], whole["wear_cost"]) == (5.0, 2.0)
  287. async def test_queueing_it_is_theirs(db_session, plates, test_engine):
  288. """It prints on their printer (archive redesign)."""
  289. from unittest.mock import patch
  290. from fastapi import HTTPException
  291. from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
  292. from backend.app.api.routes.print_queue import _assert_can_queue_archive
  293. from backend.app.core.printer_scope import PrinterScope
  294. archive = await db_session.get(PrintArchive, plates.archive)
  295. maker = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
  296. with patch("backend.app.core.database.async_session", maker):
  297. await _assert_can_queue_archive(archive, None, PrinterScope(frozenset({plates.a})))
  298. with pytest.raises(HTTPException) as refused:
  299. await _assert_can_queue_archive(archive, None, PrinterScope(frozenset({plates.c})))
  300. assert refused.value.status_code == 404
  301. class TestLiveUpdates:
  302. async def test_reach_the_printers_its_prints_ran_on(self, db_session, plates, test_engine):
  303. from unittest.mock import patch
  304. from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
  305. from backend.app.core import websocket
  306. db_session.add(
  307. PrintLogEntry(
  308. archive_id=plates.archive, printer_id=plates.c, status=RUN_OPEN, started_at=T2 + timedelta(hours=1)
  309. )
  310. )
  311. await db_session.commit()
  312. maker = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
  313. with patch("backend.app.core.database.async_session", maker):
  314. assert await websocket._archive_printer_ids(plates.archive) == (plates.b, {plates.a, plates.b, plates.c})
  315. async def test_the_project_list_shows_their_plate(async_client, db_session, plates):
  316. from backend.app.models.project import Project
  317. project = Project(name="P")
  318. db_session.add(project)
  319. await db_session.flush()
  320. archive = await db_session.get(PrintArchive, plates.archive)
  321. archive.project_id = project.id
  322. project_id = project.id
  323. await db_session.commit()
  324. response = await async_client.get(f"/api/v1/projects/{project_id}/archives", headers=plates.of_a)
  325. assert response.status_code == 200, response.text
  326. assert [(a["id"], a["printer_id"], a["photos"]) for a in response.json()] == [
  327. (plates.archive, plates.a, ["finish_a.jpg"])
  328. ]