test_project_row_visibility.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227
  1. """A project shows each caller the archives and queue jobs they may see.
  2. Projects are shared, but the archives and queue jobs in them belong to the
  3. users who made them. The project's archive list, queue list and timeline show
  4. the caller the rows the archives and the queue themselves would show them:
  5. their own, unless they may see everyone's, on their printers. An API key sees
  6. what its owner could. With auth off every row shows.
  7. """
  8. from __future__ import annotations
  9. import pytest
  10. from httpx import AsyncClient
  11. from backend.app.models.print_queue import PrintQueueItem
  12. from backend.tests.integration.test_printer_scope_1727 import _admin_token, _auth
  13. from backend.tests.integration.test_project_batch_scope import _archive_projects, _member, _project
  14. pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
  15. BASE = ["printers:read", "api_keys:create", "projects:read", "projects:update"]
  16. OWN = [*BASE, "archives:read_own", "archives:update_own", "queue:read_own", "library:read_own", "library:upload"]
  17. async def _job(db_session, printer_id: int, archive_id: int, project_id: int, created_by_id: int | None) -> int:
  18. item = PrintQueueItem(
  19. printer_id=printer_id,
  20. archive_id=archive_id,
  21. project_id=project_id,
  22. created_by_id=created_by_id,
  23. status="pending",
  24. )
  25. db_session.add(item)
  26. await db_session.commit()
  27. return item.id
  28. @pytest.fixture
  29. async def shared(async_client: AsyncClient, printer_factory, archive_factory, db_session):
  30. """A project holding one archive and one job of each of two users, and an ownerless pair."""
  31. a = await printer_factory(name="A")
  32. admin = await _admin_token(async_client)
  33. first, first_id = await _member(async_client, admin, "first", None, OWN)
  34. second, second_id = await _member(async_client, admin, "second", None, OWN)
  35. project = await _project(async_client, admin)
  36. rows = {}
  37. for who, user_id in (("first", first_id), ("second", second_id), ("nobody", None)):
  38. archive = await archive_factory(
  39. a.id, created_by_id=user_id, project_id=project, print_name=f"{who} part", status="completed"
  40. )
  41. rows[who] = (archive.id, await _job(db_session, a.id, archive.id, project, user_id))
  42. return {"admin": _auth(admin), "first": first, "second": second, "project": project, "rows": rows}
  43. async def _lists(client: AsyncClient, headers: dict, project: int):
  44. archives = await client.get(f"/api/v1/projects/{project}/archives", headers=headers)
  45. queue = await client.get(f"/api/v1/projects/{project}/queue", headers=headers)
  46. timeline = await client.get(f"/api/v1/projects/{project}/timeline", headers=headers)
  47. for response in (archives, queue, timeline):
  48. assert response.status_code == 200, response.text
  49. events = timeline.json()
  50. return (
  51. sorted(a["id"] for a in archives.json()),
  52. sorted(q["id"] for q in queue.json()),
  53. sorted(e["metadata"]["archive_id"] for e in events if "archive_id" in (e.get("metadata") or {})),
  54. sorted(e["metadata"]["queue_item_id"] for e in events if "queue_item_id" in (e.get("metadata") or {})),
  55. )
  56. async def test_a_user_sees_only_their_own_rows(async_client: AsyncClient, shared):
  57. archive, job = shared["rows"]["first"]
  58. assert await _lists(async_client, shared["first"], shared["project"]) == ([archive], [job], [archive], [job])
  59. async def test_so_does_the_other_one(async_client: AsyncClient, shared):
  60. archive, job = shared["rows"]["second"]
  61. assert await _lists(async_client, shared["second"], shared["project"]) == ([archive], [job], [archive], [job])
  62. async def test_an_admin_sees_every_row(async_client: AsyncClient, shared):
  63. archives = sorted(a for a, _ in shared["rows"].values())
  64. jobs = sorted(j for _, j in shared["rows"].values())
  65. assert await _lists(async_client, shared["admin"], shared["project"]) == (archives, jobs, archives, jobs)
  66. async def test_without_archive_or_queue_rights_nothing_shows(async_client: AsyncClient, admin_and_editor):
  67. headers, project = admin_and_editor
  68. assert await _lists(async_client, headers, project) == ([], [], [], [])
  69. @pytest.fixture
  70. async def admin_and_editor(async_client: AsyncClient, shared):
  71. admin = shared["admin"]["Authorization"].removeprefix("Bearer ")
  72. editor, _ = await _member(async_client, admin, "editor", None, BASE)
  73. return editor, shared["project"]
  74. async def test_a_key_sees_what_its_owner_could(async_client: AsyncClient, shared):
  75. created = await async_client.post(
  76. "/api/v1/api-keys/", headers=shared["first"], json={"name": "k", "can_manage_projects": True}
  77. )
  78. assert created.status_code in (200, 201), created.text
  79. archive, job = shared["rows"]["first"]
  80. key = {"X-API-Key": created.json()["key"]}
  81. assert await _lists(async_client, key, shared["project"]) == ([archive], [job], [archive], [job])
  82. async def test_a_user_cannot_unlink_anothers_archive(async_client: AsyncClient, shared, db_session):
  83. mine, _ = shared["rows"]["first"]
  84. theirs, _ = shared["rows"]["second"]
  85. response = await async_client.post(
  86. f"/api/v1/projects/{shared['project']}/remove-archives",
  87. headers=shared["first"],
  88. json={"archive_ids": [mine, theirs]},
  89. )
  90. assert response.json()["count"] == 1
  91. assert await _archive_projects(db_session, mine, theirs) == [None, shared["project"]]
  92. async def test_auth_off_shows_every_row(async_client: AsyncClient, printer_factory, archive_factory, db_session):
  93. a = await printer_factory(name="A")
  94. project = (await async_client.post("/api/v1/projects/", json={"name": "P"})).json()["id"]
  95. first = await archive_factory(a.id, project_id=project, status="completed")
  96. second = await archive_factory(a.id, project_id=project, status="completed")
  97. jobs = sorted(
  98. [await _job(db_session, a.id, first.id, project, None), await _job(db_session, a.id, second.id, project, None)]
  99. )
  100. assert await _lists(async_client, {}, project) == (
  101. sorted([first.id, second.id]),
  102. jobs,
  103. sorted([first.id, second.id]),
  104. jobs,
  105. )
  106. async def test_the_project_list_previews_only_visible_archives(async_client: AsyncClient, shared):
  107. response = await async_client.get("/api/v1/projects/", headers=shared["first"])
  108. assert response.status_code == 200, response.text
  109. (project,) = [p for p in response.json() if p["id"] == shared["project"]]
  110. assert [a["id"] for a in project["archives"]] == [shared["rows"]["first"][0]]
  111. async def test_a_bom_names_only_visible_archives(async_client: AsyncClient, shared):
  112. project = shared["project"]
  113. mine, theirs = shared["rows"]["first"][0], shared["rows"]["second"][0]
  114. linked = await async_client.post(
  115. f"/api/v1/projects/{project}/bom", headers=shared["admin"], json={"name": "b", "archive_id": theirs}
  116. )
  117. assert linked.status_code == 200, linked.text
  118. item = linked.json()["id"]
  119. listed = await async_client.get(f"/api/v1/projects/{project}/bom", headers=shared["first"])
  120. relinked = await async_client.patch(
  121. f"/api/v1/projects/{project}/bom/{item}", headers=shared["first"], json={"archive_id": theirs}
  122. )
  123. created = await async_client.post(
  124. f"/api/v1/projects/{project}/bom", headers=shared["first"], json={"name": "c", "archive_id": theirs}
  125. )
  126. own = await async_client.post(
  127. f"/api/v1/projects/{project}/bom", headers=shared["first"], json={"name": "d", "archive_id": mine}
  128. )
  129. assert [(b["archive_id"], b["archive_name"]) for b in listed.json()] == [(None, None)]
  130. assert relinked.status_code == created.status_code == 400
  131. assert (own.json()["archive_id"], own.json()["archive_name"]) == (mine, "first part")
  132. async def test_a_library_folder_links_only_visible_archives(
  133. async_client: AsyncClient, shared, archive_factory, db_session
  134. ):
  135. from datetime import datetime
  136. from backend.app.models.archive import PrintArchive
  137. mine, theirs = shared["rows"]["first"][0], shared["rows"]["second"][0]
  138. trashed = await db_session.get(PrintArchive, mine)
  139. folder = lambda archive_id: async_client.post( # noqa: E731
  140. "/api/v1/library/folders/", headers=shared["first"], json={"name": f"f{archive_id}", "archive_id": archive_id}
  141. )
  142. other = await folder(theirs)
  143. own = await folder(mine)
  144. trashed.deleted_at = datetime.now()
  145. await db_session.commit()
  146. gone = await folder(mine)
  147. assert other.status_code == 404
  148. assert (own.json()["archive_id"], own.json()["archive_name"]) == (mine, "first part")
  149. assert gone.status_code == 404
  150. async def test_a_key_of_a_whole_library_reader_still_sees_only_its_owners_archives(async_client: AsyncClient, shared):
  151. admin = shared["admin"]["Authorization"].removeprefix("Bearer ")
  152. reader, _ = await _member(
  153. async_client, admin, "reader", None, [*BASE, "archives:read_own", "library:read_all", "library:update_all"]
  154. )
  155. theirs = shared["rows"]["second"][0]
  156. folder = await async_client.post(
  157. "/api/v1/library/folders/", headers=shared["admin"], json={"name": "shared", "archive_id": theirs}
  158. )
  159. assert folder.status_code == 200, folder.text
  160. created = await async_client.post("/api/v1/api-keys/", headers=reader, json={"name": "k"})
  161. assert created.status_code in (200, 201), created.text
  162. key = {"X-API-Key": created.json()["key"]}
  163. listed = await async_client.get("/api/v1/library/folders/", headers=key)
  164. one = await async_client.get(f"/api/v1/library/folders/{folder.json()['id']}", headers=key)
  165. by_archive = await async_client.get(f"/api/v1/library/folders/by-archive/{theirs}", headers=key)
  166. bulk = await async_client.get("/api/v1/bulk/archive-folders", params={"ids": str(theirs)}, headers=key)
  167. relinked = await async_client.put(
  168. f"/api/v1/library/folders/{folder.json()['id']}", headers=key, json={"archive_id": theirs}
  169. )
  170. assert [(f["archive_id"], f["archive_name"]) for f in listed.json() if f["name"] == "shared"] == [(None, None)]
  171. assert (one.json()["archive_id"], one.json()["archive_name"]) == (None, None)
  172. assert by_archive.json() == []
  173. assert bulk.json() == {str(theirs): []}
  174. assert relinked.status_code == 404