test_manyfold_api.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378
  1. """The /manyfold routes (#1471), against a fake Manyfold install."""
  2. from __future__ import annotations
  3. import pytest
  4. from httpx import AsyncClient
  5. from sqlalchemy import select
  6. from backend.app.api.routes import manyfold as routes
  7. from backend.app.core import database as _database_module
  8. from backend.app.core.database import seed_default_groups
  9. from backend.app.models.group import Group
  10. from backend.app.models.library import LibraryFile, LibraryFolder
  11. from backend.app.models.settings import Settings
  12. from backend.app.services.model_providers.manyfold import service as svc
  13. from backend.tests._fixtures.manyfold import BASE, PNG, STL, THREE_MF, FakeFile, FakeManyfold
  14. API = "/api/v1/manyfold"
  15. SECRET = "app-secret"
  16. @pytest.fixture
  17. def manyfold(monkeypatch) -> FakeManyfold:
  18. fake = FakeManyfold(client_secret=SECRET)
  19. fake.add_model(
  20. "cube01",
  21. "Calibration Cube",
  22. {
  23. "f1": FakeFile("cube.stl", "model/stl", STL, render=PNG),
  24. "f2": FakeFile("cube.3mf", "model/3mf", THREE_MF),
  25. "f4": FakeFile("notes.pdf", "application/pdf", b"%PDF-1.7"),
  26. "f5": FakeFile("broken.3mf", "model/3mf", b"not a zip at all"),
  27. "f6": FakeFile("bad:name?.stl", "model/stl", STL),
  28. },
  29. preview="f1",
  30. )
  31. fake.add_model("boat02", "Benchy", {"f9": FakeFile("benchy.stl", "model/stl", STL)})
  32. real = routes.ManyfoldService
  33. monkeypatch.setattr(routes, "ManyfoldService", lambda config: real(config, client=fake.client()))
  34. svc.clear_token_cache()
  35. yield fake
  36. svc.clear_token_cache()
  37. async def _connect(client: AsyncClient, fake: FakeManyfold, headers: dict | None = None) -> None:
  38. resp = await client.put(
  39. f"{API}/config",
  40. json={"url": f"{BASE}/", "client_id": fake.client_id, "client_secret": SECRET},
  41. headers=headers or {},
  42. )
  43. assert resp.status_code == 200, resp.text
  44. class TestConnection:
  45. @pytest.mark.asyncio
  46. @pytest.mark.integration
  47. async def test_store_read_and_disconnect_without_ever_returning_the_secret(self, async_client, manyfold):
  48. empty = (await async_client.get(f"{API}/config")).json()
  49. assert empty == {"url": "", "client_id": "", "has_client_secret": False, "configured": False}
  50. await _connect(async_client, manyfold)
  51. stored = await async_client.get(f"{API}/config")
  52. assert stored.json() == {
  53. "url": BASE,
  54. "client_id": "app-id",
  55. "has_client_secret": True,
  56. "configured": True,
  57. }
  58. for response in (stored, await async_client.get("/api/v1/settings/")):
  59. # The stored rows must not break the general settings response either.
  60. assert response.status_code == 200, response.text
  61. assert SECRET not in response.text
  62. assert (await async_client.delete(f"{API}/config")).status_code == 204
  63. assert (await async_client.get(f"{API}/config")).json()["configured"] is False
  64. assert (await async_client.get(f"{API}/status")).json() == {"configured": False, "url": ""}
  65. @pytest.mark.asyncio
  66. @pytest.mark.integration
  67. async def test_an_empty_secret_keeps_the_stored_one(self, async_client, manyfold, db_session):
  68. await _connect(async_client, manyfold)
  69. resp = await async_client.put(f"{API}/config", json={"url": BASE, "client_id": "app-id", "client_secret": ""})
  70. assert resp.status_code == 200
  71. row = (await db_session.execute(select(Settings).where(Settings.key == "manyfold_client_secret"))).scalar_one()
  72. assert row.value == SECRET
  73. @pytest.mark.asyncio
  74. @pytest.mark.integration
  75. async def test_the_first_save_needs_a_secret(self, async_client, manyfold):
  76. resp = await async_client.put(f"{API}/config", json={"url": BASE, "client_id": "app-id"})
  77. assert resp.status_code == 400
  78. assert resp.json()["detail"]["code"] == "manyfold_secret_required"
  79. @pytest.mark.asyncio
  80. @pytest.mark.integration
  81. @pytest.mark.parametrize("url", ["docker:3214", "http://169.254.169.254/"])
  82. async def test_bad_url(self, async_client, manyfold, url):
  83. for path, method in ((f"{API}/config", "put"), (f"{API}/config/test", "post")):
  84. resp = await getattr(async_client, method)(path, json={"url": url, "client_id": "a", "client_secret": "b"})
  85. assert resp.status_code == 400
  86. assert resp.json()["detail"]["code"] == "manyfold_bad_url"
  87. assert manyfold.requests == []
  88. @pytest.mark.asyncio
  89. @pytest.mark.integration
  90. async def test_test_counts_models_and_stores_nothing(self, async_client, manyfold):
  91. resp = await async_client.post(
  92. f"{API}/config/test", json={"url": BASE, "client_id": "app-id", "client_secret": SECRET}
  93. )
  94. assert resp.status_code == 200, resp.text
  95. assert resp.json() == {"model_count": 2}
  96. assert (await async_client.get(f"{API}/config")).json()["configured"] is False
  97. @pytest.mark.asyncio
  98. @pytest.mark.integration
  99. async def test_a_refused_secret_is_not_answered_with_401(self, async_client, manyfold):
  100. # A 401 would read as "your Bambuddy session ended" to the frontend.
  101. resp = await async_client.post(
  102. f"{API}/config/test", json={"url": BASE, "client_id": "app-id", "client_secret": "wrong"}
  103. )
  104. assert resp.status_code == 502
  105. assert resp.json()["detail"]["code"] == "manyfold_credentials"
  106. @pytest.mark.asyncio
  107. @pytest.mark.integration
  108. async def test_test_with_an_empty_secret_uses_the_stored_one(self, async_client, manyfold):
  109. await _connect(async_client, manyfold)
  110. resp = await async_client.post(f"{API}/config/test", json={"url": BASE, "client_id": "app-id"})
  111. assert resp.status_code == 200, resp.text
  112. class TestBrowsing:
  113. @pytest.mark.asyncio
  114. @pytest.mark.integration
  115. async def test_not_configured_is_409(self, async_client, manyfold):
  116. resp = await async_client.get(f"{API}/models")
  117. assert resp.status_code == 409
  118. assert resp.json()["detail"]["code"] == "manyfold_not_configured"
  119. @pytest.mark.asyncio
  120. @pytest.mark.integration
  121. async def test_list_search_and_details(self, async_client, manyfold):
  122. await _connect(async_client, manyfold)
  123. assert (await async_client.get(f"{API}/status")).json() == {"configured": True, "url": BASE}
  124. listing = (await async_client.get(f"{API}/models")).json()
  125. assert [m["name"] for m in listing["models"]] == ["Calibration Cube", "Benchy"]
  126. found = (await async_client.get(f"{API}/models", params={"q": "bench"})).json()
  127. assert found["total"] == 1
  128. model = (await async_client.get(f"{API}/models/cube01")).json()
  129. assert model["has_preview"] is True
  130. assert model["url"] == f"{BASE}/models/cube01"
  131. assert {f["id"]: f["importable"] for f in model["files"]} == {
  132. "f1": True,
  133. "f2": True,
  134. "f4": False,
  135. "f5": True,
  136. "f6": True,
  137. }
  138. assert all(f["library_file"] is None for f in model["files"])
  139. @pytest.mark.asyncio
  140. @pytest.mark.integration
  141. async def test_missing_model_and_malformed_id(self, async_client, manyfold):
  142. await _connect(async_client, manyfold)
  143. assert (await async_client.get(f"{API}/models/gone99")).status_code == 404
  144. assert (await async_client.get(f"{API}/models/a.b")).status_code == 404
  145. @pytest.mark.asyncio
  146. @pytest.mark.integration
  147. async def test_preview(self, async_client, manyfold):
  148. await _connect(async_client, manyfold)
  149. resp = await async_client.get(f"{API}/models/cube01/preview")
  150. assert resp.status_code == 200
  151. assert resp.content == PNG
  152. assert resp.headers["content-type"] == "image/png"
  153. assert (await async_client.get(f"{API}/models/boat02/preview")).status_code == 404
  154. class TestImport:
  155. @pytest.mark.asyncio
  156. @pytest.mark.integration
  157. async def test_the_makerworld_import_route_does_not_serve_manyfold(self, async_client, manyfold):
  158. # Manyfold's own route checks file types and names; the pasted-URL route must not bypass that.
  159. await _connect(async_client, manyfold)
  160. resp = await async_client.post(
  161. "/api/v1/makerworld/import", json={"model_id": 1, "profile_id": 2, "source_type": "manyfold"}
  162. )
  163. assert resp.status_code == 400
  164. assert manyfold.requests == []
  165. @pytest.mark.asyncio
  166. @pytest.mark.integration
  167. async def test_an_underscore_in_an_id_matches_only_itself(self, async_client, manyfold):
  168. # "_" is LIKE's single-character wildcard: model a_c must not see abc's import.
  169. manyfold.add_model("abc", "ABC", {"f1": FakeFile("abc.stl", "model/stl", STL)})
  170. manyfold.add_model("a_c", "A C", {"f1": FakeFile("ac.stl", "model/stl", STL)})
  171. await _connect(async_client, manyfold)
  172. assert (await async_client.post(f"{API}/import", json={"model_id": "abc", "file_id": "f1"})).status_code == 200
  173. other = (await async_client.get(f"{API}/models/a_c")).json()
  174. assert other["files"][0]["library_file"] is None
  175. mine = (await async_client.get(f"{API}/models/abc")).json()
  176. assert mine["files"][0]["library_file"] is not None
  177. @pytest.mark.asyncio
  178. @pytest.mark.integration
  179. async def test_import_lands_in_the_manyfold_folder_once(self, async_client, manyfold, db_session):
  180. await _connect(async_client, manyfold)
  181. resp = await async_client.post(f"{API}/import", json={"model_id": "cube01", "file_id": "f1"})
  182. assert resp.status_code == 200, resp.text
  183. first = resp.json()
  184. assert first["filename"] == "cube.stl" and first["was_existing"] is False
  185. row = await db_session.get(LibraryFile, first["library_file_id"])
  186. folder = await db_session.get(LibraryFolder, row.folder_id)
  187. assert folder.name == "Manyfold" and folder.parent_id is None
  188. assert row.source_type == "manyfold"
  189. assert row.source_url == "manyfold:cube01/f1"
  190. assert row.file_type == "stl"
  191. downloads = sum(1 for r in manyfold.requests if "/raw/" in r.url.path)
  192. again = (await async_client.post(f"{API}/import", json={"model_id": "cube01", "file_id": "f1"})).json()
  193. assert again == {**first, "was_existing": True}
  194. assert sum(1 for r in manyfold.requests if "/raw/" in r.url.path) == downloads
  195. model = (await async_client.get(f"{API}/models/cube01")).json()
  196. assert {f["id"]: f["library_file"] for f in model["files"]}["f1"] == {
  197. "id": first["library_file_id"],
  198. "filename": "cube.stl",
  199. "folder_id": first["folder_id"],
  200. }
  201. @pytest.mark.asyncio
  202. @pytest.mark.integration
  203. async def test_a_deleted_import_can_be_imported_again(self, async_client, manyfold):
  204. await _connect(async_client, manyfold)
  205. first = (await async_client.post(f"{API}/import", json={"model_id": "cube01", "file_id": "f2"})).json()
  206. assert (await async_client.delete(f"/api/v1/library/files/{first['library_file_id']}")).status_code in (
  207. 200,
  208. 204,
  209. )
  210. again = (await async_client.post(f"{API}/import", json={"model_id": "cube01", "file_id": "f2"})).json()
  211. assert again["was_existing"] is False
  212. assert again["library_file_id"] != first["library_file_id"]
  213. @pytest.mark.asyncio
  214. @pytest.mark.integration
  215. async def test_only_printable_files(self, async_client, manyfold, db_session):
  216. await _connect(async_client, manyfold)
  217. resp = await async_client.post(f"{API}/import", json={"model_id": "cube01", "file_id": "f4"})
  218. assert resp.status_code == 400
  219. assert resp.json()["detail"]["code"] == "manyfold_not_importable"
  220. # Refused before anything was created.
  221. folders = (await db_session.execute(select(LibraryFolder).where(LibraryFolder.name == "Manyfold"))).all()
  222. assert folders == []
  223. @pytest.mark.asyncio
  224. @pytest.mark.integration
  225. async def test_a_3mf_that_is_not_a_zip_is_refused(self, async_client, manyfold):
  226. await _connect(async_client, manyfold)
  227. resp = await async_client.post(f"{API}/import", json={"model_id": "cube01", "file_id": "f5"})
  228. assert resp.status_code == 400
  229. @pytest.mark.asyncio
  230. @pytest.mark.integration
  231. async def test_names_the_printer_cannot_store_are_cleaned(self, async_client, manyfold):
  232. await _connect(async_client, manyfold)
  233. resp = await async_client.post(f"{API}/import", json={"model_id": "cube01", "file_id": "f6"})
  234. assert resp.status_code == 200, resp.text
  235. assert resp.json()["filename"] == "bad_name_.stl"
  236. @pytest.mark.asyncio
  237. @pytest.mark.integration
  238. async def test_into_a_chosen_folder(self, async_client, manyfold):
  239. await _connect(async_client, manyfold)
  240. folder = (await async_client.post("/api/v1/library/folders", json={"name": "Calibration"})).json()
  241. resp = await async_client.post(
  242. f"{API}/import", json={"model_id": "boat02", "file_id": "f9", "folder_id": folder["id"]}
  243. )
  244. assert resp.json()["folder_id"] == folder["id"]
  245. # ---- permissions -------------------------------------------------------
  246. async def _admin(client: AsyncClient) -> dict:
  247. await client.post(
  248. "/api/v1/auth/setup",
  249. json={"auth_enabled": True, "admin_username": "mfadmin", "admin_password": "AdminPass1!"},
  250. )
  251. login = await client.post("/api/v1/auth/login", json={"username": "mfadmin", "password": "AdminPass1!"})
  252. assert login.status_code == 200, login.text
  253. return {"Authorization": f"Bearer {login.json()['access_token']}"}
  254. async def _user(client: AsyncClient, admin: dict, username: str, permissions: list[str]) -> dict:
  255. group = await client.post(
  256. "/api/v1/groups/", headers=admin, json={"name": f"g_{username}", "permissions": permissions}
  257. )
  258. assert group.status_code in (200, 201), group.text
  259. created = await client.post(
  260. "/api/v1/users/",
  261. headers=admin,
  262. json={"username": username, "password": "UserPass1!", "group_ids": [group.json()["id"]]},
  263. )
  264. assert created.status_code in (200, 201), created.text
  265. login = await client.post("/api/v1/auth/login", json={"username": username, "password": "UserPass1!"})
  266. return {"Authorization": f"Bearer {login.json()['access_token']}"}
  267. class TestPermissions:
  268. @pytest.mark.asyncio
  269. @pytest.mark.integration
  270. async def test_view_only_can_browse_but_not_import_or_configure(self, async_client, manyfold):
  271. admin = await _admin(async_client)
  272. await _connect(async_client, manyfold, admin)
  273. viewer = await _user(async_client, admin, "mfviewer", ["manyfold:view", "settings:read"])
  274. assert (await async_client.get(f"{API}/models", headers=viewer)).status_code == 200
  275. resp = await async_client.post(f"{API}/import", headers=viewer, json={"model_id": "cube01", "file_id": "f1"})
  276. assert resp.status_code == 403
  277. resp = await async_client.put(
  278. f"{API}/config", headers=viewer, json={"url": BASE, "client_id": "x", "client_secret": "y"}
  279. )
  280. assert resp.status_code == 403
  281. assert (await async_client.delete(f"{API}/config", headers=viewer)).status_code == 403
  282. assert SECRET not in (await async_client.get(f"{API}/config", headers=viewer)).text
  283. @pytest.mark.asyncio
  284. @pytest.mark.integration
  285. async def test_makerworld_access_alone_does_not_open_manyfold(self, async_client, manyfold):
  286. admin = await _admin(async_client)
  287. await _connect(async_client, manyfold, admin)
  288. mw = await _user(async_client, admin, "mwonly", ["makerworld:view", "makerworld:import"])
  289. assert (await async_client.get(f"{API}/models", headers=mw)).status_code == 403
  290. assert (await async_client.get(f"{API}/status", headers=mw)).status_code == 403
  291. @pytest.mark.asyncio
  292. @pytest.mark.integration
  293. async def test_anonymous_preview_is_refused(self, async_client, manyfold):
  294. admin = await _admin(async_client)
  295. await _connect(async_client, manyfold, admin)
  296. assert (await async_client.get(f"{API}/models/cube01/preview")).status_code == 401
  297. class TestPermissionBackfill:
  298. @pytest.mark.asyncio
  299. @pytest.mark.integration
  300. async def test_groups_get_what_they_have_on_makerworld_once(self, async_client):
  301. async with _database_module.async_session() as session:
  302. await session.execute(
  303. Settings.__table__.delete().where(Settings.key == "_backfill_1471_manyfold_permissions_done")
  304. )
  305. for name, perms in (
  306. ("mf_importers", ["library:read_own", "makerworld:view", "makerworld:import"]),
  307. ("mf_browsers", ["makerworld:view"]),
  308. ("mf_nothing", ["library:read_own"]),
  309. ):
  310. session.add(Group(name=name, permissions=perms))
  311. await session.commit()
  312. await seed_default_groups()
  313. async with _database_module.async_session() as session:
  314. groups = {g.name: set(g.permissions) for g in (await session.execute(select(Group))).scalars().all()}
  315. assert {"manyfold:view", "manyfold:import"} <= groups["mf_importers"]
  316. assert "manyfold:view" in groups["mf_browsers"] and "manyfold:import" not in groups["mf_browsers"]
  317. assert not {"manyfold:view", "manyfold:import"} & groups["mf_nothing"]
  318. # An admin takes it away again; the next start must not hand it back.
  319. async with _database_module.async_session() as session:
  320. grp = (await session.execute(select(Group).where(Group.name == "mf_browsers"))).scalar_one()
  321. grp.permissions = ["makerworld:view"]
  322. await session.commit()
  323. await seed_default_groups()
  324. async with _database_module.async_session() as session:
  325. grp = (await session.execute(select(Group).where(Group.name == "mf_browsers"))).scalar_one()
  326. assert grp.permissions == ["makerworld:view"]