test_makerworld_routes.py 40 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946
  1. """Tests for the /makerworld/* route handlers.
  2. Mocks ``MakerWorldService`` so tests don't hit the real MakerWorld API. We
  3. still cover: URL validation, metadata passthrough, already-imported detection,
  4. source-URL-based dedupe on import, auto-creation of the MakerWorld default
  5. folder, canonical URL shape, filename basenaming, and the ``/recent-imports``
  6. listing endpoint.
  7. """
  8. from __future__ import annotations
  9. from datetime import datetime, timedelta
  10. from unittest.mock import AsyncMock, patch
  11. import pytest
  12. from backend.app.api.routes import makerworld as makerworld_routes
  13. from backend.app.core.permissions import Permission
  14. from backend.app.models.library import LibraryFile, LibraryFolder
  15. from backend.app.services.model_providers.base import (
  16. ModelProvider,
  17. ProviderDownload,
  18. ProviderDownloadInfo,
  19. ProviderResolvedModel,
  20. ProviderResourceRef,
  21. ProviderStatus,
  22. )
  23. from backend.app.services.model_providers.makerworld import makerworld_provider
  24. def _download_info(
  25. model_id: int = 1400373,
  26. profile_id: int = 298919107,
  27. name: str = "benchy.3mf",
  28. url: str = "https://makerworld.bblmw.com/makerworld/model/X/Y/f.3mf?exp=1&key=k",
  29. ) -> ProviderDownloadInfo:
  30. """What ``service.get_download`` hands the route: signed URL + raw upstream
  31. name + the enriched resource ref (``sub_id`` carries the resolved profile)."""
  32. return ProviderDownloadInfo(
  33. ref=ProviderResourceRef(source_type="makerworld", external_id=str(model_id), sub_id=str(profile_id)),
  34. url=url,
  35. suggested_filename=name,
  36. )
  37. def _fake_service(**stubs):
  38. """Build an AsyncMock MakerWorldService with the given async method stubs."""
  39. svc = AsyncMock()
  40. svc.close = AsyncMock()
  41. for name, value in stubs.items():
  42. if callable(value) and not isinstance(value, AsyncMock):
  43. setattr(svc, name, AsyncMock(side_effect=value))
  44. else:
  45. setattr(svc, name, AsyncMock(return_value=value))
  46. return svc
  47. class _DummyProvider(ModelProvider):
  48. """Stand-in for a second registered model provider.
  49. Lets the route tests exercise behaviour that differs from the MakerWorld
  50. singleton — a provider-specific default folder name (or none at all), and
  51. its own permissions — without registering anything in the app-wide
  52. registry. The permissions deliberately are *not* the MakerWorld ones: the
  53. routes must gate on whichever provider the request resolved to.
  54. """
  55. source_type = "dummy"
  56. display_name = "Dummy"
  57. def __init__(
  58. self,
  59. default_folder_name: str | None = "Dummy Imports",
  60. view_permission: Permission | None = Permission.LIBRARY_READ,
  61. import_permission: Permission | None = Permission.LIBRARY_UPLOAD,
  62. ):
  63. self.default_folder_name = default_folder_name
  64. self.view_permission = view_permission
  65. self.import_permission = import_permission
  66. async def build_service(self, *, db, user, api_key_owner=None, client=None):
  67. raise NotImplementedError
  68. def parse_url(self, url):
  69. return ProviderResourceRef(source_type=self.source_type, external_id="1400373", original_url=url)
  70. def canonical_url(self, ref):
  71. return f"https://dummy.example.com/models/{ref.external_id}"
  72. def _permission_spy():
  73. """Record which permission the route hands the shared gate.
  74. The gate itself still runs — the spy delegates to the real factory — so a
  75. test using it proves the wiring without loosening the check.
  76. """
  77. seen: list = []
  78. real = makerworld_routes.require_permission_if_auth_enabled
  79. def factory(*permissions):
  80. seen.extend(permissions)
  81. return real(*permissions)
  82. return seen, factory
  83. class TestThumbnail:
  84. """GET /makerworld/thumbnail — the anonymous CDN image proxy."""
  85. def _patch_service(self, svc):
  86. return patch("backend.app.api.routes.makerworld.MakerWorldService", return_value=svc)
  87. @pytest.mark.asyncio
  88. async def test_proxies_image_with_immutable_cache(self, async_client):
  89. from unittest.mock import MagicMock
  90. svc = MagicMock()
  91. svc.fetch_thumbnail = AsyncMock(return_value=(b"png-bytes", "image/png"))
  92. svc.close = AsyncMock()
  93. with self._patch_service(svc):
  94. resp = await async_client.get(
  95. "/api/v1/makerworld/thumbnail",
  96. params={"url": "https://makerworld.bblmw.com/img/x.png"},
  97. )
  98. assert resp.status_code == 200, resp.text
  99. assert resp.content == b"png-bytes"
  100. assert resp.headers["content-type"] == "image/png"
  101. assert "immutable" in resp.headers["cache-control"]
  102. # The SSRF allowlist is the provider's declared seam, not a local
  103. # copy inside the route (review round 2).
  104. assert svc.fetch_thumbnail.await_args.args[0] == "https://makerworld.bblmw.com/img/x.png"
  105. svc.close.assert_awaited_once()
  106. @pytest.mark.asyncio
  107. async def test_allowlist_comes_from_the_provider_descriptor(self, async_client):
  108. from unittest.mock import MagicMock
  109. svc = MagicMock()
  110. svc.fetch_thumbnail = AsyncMock(return_value=(b"x", "image/png"))
  111. svc.close = AsyncMock()
  112. with self._patch_service(svc) as cls:
  113. await async_client.get(
  114. "/api/v1/makerworld/thumbnail",
  115. params={"url": "https://makerworld.bblmw.com/img/x.png"},
  116. )
  117. assert cls.call_args.kwargs["thumbnail_hosts"] == makerworld_provider.thumbnail_hosts()
  118. @pytest.mark.asyncio
  119. async def test_non_cdn_host_is_a_clean_400(self, async_client):
  120. from unittest.mock import MagicMock
  121. from backend.app.services.model_providers.makerworld.errors import MakerWorldUrlError
  122. svc = MagicMock()
  123. svc.fetch_thumbnail = AsyncMock(
  124. side_effect=MakerWorldUrlError("Refusing to fetch thumbnail from non-MakerWorld host: 'evil.example'")
  125. )
  126. svc.close = AsyncMock()
  127. with self._patch_service(svc):
  128. resp = await async_client.get(
  129. "/api/v1/makerworld/thumbnail",
  130. params={"url": "https://evil.example/x.png"},
  131. )
  132. assert resp.status_code == 400
  133. svc.close.assert_awaited_once()
  134. class TestStatus:
  135. @pytest.mark.asyncio
  136. async def test_status_reports_no_token_by_default(self, async_client, db_session):
  137. resp = await async_client.get("/api/v1/makerworld/status")
  138. assert resp.status_code == 200
  139. body = resp.json()
  140. # Fresh in-memory DB has no stored token, so can_download must be false.
  141. # sign_in_expired is False, not True: there is no sign-in to have expired.
  142. assert body == {"has_cloud_token": False, "can_download": False, "sign_in_expired": False}
  143. @pytest.mark.asyncio
  144. async def test_rejected_token_blocks_download_and_reports_expired(self, async_client, db_session):
  145. """A token Bambu has already rejected downloads nothing. ``can_download``
  146. used to be a bare alias for ``has_cloud_token``, so the import button
  147. stayed live against a dead credential and the user only found out via a
  148. 401 toast."""
  149. from backend.app.models.settings import Settings
  150. from backend.app.services.bambu_cloud_credentials import CLOUD_TOKEN_INVALID_KEY, CLOUD_TOKEN_KEY
  151. db_session.add(Settings(key=CLOUD_TOKEN_KEY, value="dead-token"))
  152. db_session.add(Settings(key=CLOUD_TOKEN_INVALID_KEY, value="2026-07-14T07:00:00+00:00"))
  153. await db_session.commit()
  154. resp = await async_client.get("/api/v1/makerworld/status")
  155. assert resp.status_code == 200
  156. assert resp.json() == {
  157. "has_cloud_token": True,
  158. "can_download": False,
  159. "sign_in_expired": True,
  160. }
  161. @pytest.mark.asyncio
  162. async def test_sign_in_expired_reads_credential_rejected_not_auth_error(self, async_client):
  163. """The route keys ``sign_in_expired`` off the machine-readable
  164. ``credential_rejected`` flag, not ``auth_error`` (review round 3 note 1):
  165. ``auth_error`` is the human-readable reason and may be set for non-
  166. credential failures too. A service reporting an expired credential
  167. *without* a message must still surface ``sign_in_expired=True``."""
  168. svc = AsyncMock()
  169. svc.close = AsyncMock()
  170. svc.get_status = AsyncMock(
  171. return_value=ProviderStatus(
  172. authenticated=True,
  173. can_download=False,
  174. auth_error=None,
  175. credential_rejected=True,
  176. )
  177. )
  178. with patch("backend.app.api.routes.makerworld._build_service", AsyncMock(return_value=svc)):
  179. resp = await async_client.get("/api/v1/makerworld/status")
  180. assert resp.status_code == 200
  181. assert resp.json() == {
  182. "has_cloud_token": True,
  183. "can_download": False,
  184. "sign_in_expired": True,
  185. }
  186. class TestResolve:
  187. @pytest.mark.asyncio
  188. async def test_rejects_non_makerworld_url(self, async_client):
  189. resp = await async_client.post(
  190. "/api/v1/makerworld/resolve",
  191. json={"url": "https://thingiverse.com/thing/1"},
  192. )
  193. # A pasted link for an unsupported host is a clean client-input 400,
  194. # never a 500 — the registry guard runs before any provider call.
  195. assert resp.status_code == 400
  196. assert "provider" in resp.json()["detail"].lower()
  197. @pytest.mark.asyncio
  198. async def test_happy_path_returns_design_and_instances(self, async_client):
  199. design_payload = {"id": 1400373, "title": "Seed Starter"}
  200. instances_payload = [
  201. {"id": 1452154, "profileId": 298919107, "title": "9 cells"},
  202. {"id": 1452158, "profileId": 298919564, "title": "12 cells"},
  203. ]
  204. svc = _fake_service(
  205. resolve=ProviderResolvedModel(
  206. ref=ProviderResourceRef(source_type="makerworld", external_id="1400373", sub_id="1452154"),
  207. design=design_payload,
  208. instances=instances_payload,
  209. )
  210. )
  211. with patch("backend.app.api.routes.makerworld._build_service", AsyncMock(return_value=svc)):
  212. resp = await async_client.post(
  213. "/api/v1/makerworld/resolve",
  214. json={"url": "https://makerworld.com/en/models/1400373-slug#profileId-1452154"},
  215. )
  216. assert resp.status_code == 200, resp.text
  217. body = resp.json()
  218. assert body["model_id"] == 1400373
  219. assert body["profile_id"] == 1452154
  220. assert body["design"] == design_payload
  221. assert len(body["instances"]) == 2
  222. assert body["already_imported_library_ids"] == []
  223. @pytest.mark.asyncio
  224. async def test_flags_already_imported_library_ids(self, async_client, db_session):
  225. """Both dedupe shapes must be found through the provider's
  226. ``source_url_filter``: the whole-model canonical URL *and* any
  227. plate-level ``#profileId-`` row."""
  228. model_row = LibraryFile(
  229. filename="prev.3mf",
  230. file_path="library/files/prev.3mf",
  231. file_type="3mf",
  232. file_size=100,
  233. source_type="makerworld",
  234. source_url="https://makerworld.com/models/1400373",
  235. )
  236. plate_row = LibraryFile(
  237. filename="prev-plate.3mf",
  238. file_path="library/files/prev-plate.3mf",
  239. file_type="3mf",
  240. file_size=100,
  241. source_type="makerworld",
  242. source_url="https://makerworld.com/models/1400373#profileId-298919107",
  243. )
  244. db_session.add_all([model_row, plate_row])
  245. await db_session.commit()
  246. await db_session.refresh(model_row)
  247. await db_session.refresh(plate_row)
  248. svc = _fake_service(
  249. resolve=ProviderResolvedModel(
  250. ref=ProviderResourceRef(source_type="makerworld", external_id="1400373"),
  251. design={"id": 1400373},
  252. instances=[],
  253. )
  254. )
  255. with patch("backend.app.api.routes.makerworld._build_service", AsyncMock(return_value=svc)):
  256. resp = await async_client.post(
  257. "/api/v1/makerworld/resolve",
  258. json={"url": "https://makerworld.com/en/models/1400373"},
  259. )
  260. assert resp.status_code == 200, resp.text
  261. assert sorted(resp.json()["already_imported_library_ids"]) == sorted([model_row.id, plate_row.id])
  262. @pytest.mark.asyncio
  263. async def test_gate_uses_the_permission_of_the_provider_the_url_routes_to(self, async_client):
  264. """Same rule as import, keyed off the pasted URL instead of
  265. ``source_type``: a link that routes to another provider is gated on
  266. that provider's view permission, not ``makerworld:view``."""
  267. seen, factory = _permission_spy()
  268. dummy = _DummyProvider()
  269. svc = _fake_service(
  270. resolve=ProviderResolvedModel(
  271. ref=ProviderResourceRef(source_type="dummy", external_id="1400373"),
  272. design={"id": 1400373},
  273. instances=[],
  274. )
  275. )
  276. with (
  277. patch("backend.app.api.routes.makerworld.require_permission_if_auth_enabled", factory),
  278. patch("backend.app.api.routes.makerworld._provider_for_url", return_value=dummy),
  279. patch("backend.app.api.routes.makerworld._build_service", AsyncMock(return_value=svc)),
  280. ):
  281. resp = await async_client.post(
  282. "/api/v1/makerworld/resolve",
  283. json={"url": "https://dummy.example.com/models/1400373"},
  284. )
  285. assert resp.status_code == 200, resp.text
  286. assert seen == [Permission.LIBRARY_READ]
  287. class TestImport:
  288. """End-to-end of POST /makerworld/import — mocks the service but exercises
  289. real DB writes, real ``save_3mf_bytes_to_library``, real folder auto-creation."""
  290. _FAKE_3MF_BYTES = b"PK\x03\x04not-a-real-3mf"
  291. @pytest.mark.asyncio
  292. async def test_returns_existing_on_source_url_match(self, async_client, db_session):
  293. """Re-importing a model we already have must NOT re-download.
  294. Dedupe key is ``{model_id}#profileId-{profile_id}`` — matches the
  295. canonical URL the route constructs, not the legacy model-only shape.
  296. """
  297. existing = LibraryFile(
  298. filename="already-here.3mf",
  299. file_path="library/files/already.3mf",
  300. file_type="3mf",
  301. file_size=500,
  302. source_type="makerworld",
  303. source_url="https://makerworld.com/models/1400373#profileId-298919107",
  304. )
  305. db_session.add(existing)
  306. await db_session.commit()
  307. await db_session.refresh(existing)
  308. svc = _fake_service(get_download=_download_info())
  309. svc.download = AsyncMock() # must remain uncalled
  310. with patch("backend.app.api.routes.makerworld._build_service", AsyncMock(return_value=svc)):
  311. resp = await async_client.post(
  312. "/api/v1/makerworld/import",
  313. json={"model_id": 1400373, "profile_id": 298919107},
  314. )
  315. assert resp.status_code == 200, resp.text
  316. body = resp.json()
  317. assert body["library_file_id"] == existing.id
  318. assert body["was_existing"] is True
  319. assert body["profile_id"] == 298919107
  320. svc.download.assert_not_called()
  321. @pytest.mark.asyncio
  322. async def test_unknown_source_type_is_a_clean_400(self, async_client, db_session):
  323. """``source_type`` names the provider (there is no URL to route on);
  324. an unregistered value is a client-input problem — 400 before any
  325. service is built or bytes downloaded."""
  326. svc = _fake_service(
  327. get_download=_download_info(),
  328. download=ProviderDownload(file_bytes=self._FAKE_3MF_BYTES, filename="benchy.3mf"),
  329. )
  330. svc.download = AsyncMock()
  331. with patch("backend.app.api.routes.makerworld._build_service", AsyncMock(return_value=svc)):
  332. resp = await async_client.post(
  333. "/api/v1/makerworld/import",
  334. json={"model_id": 1400373, "source_type": "thingiverse"},
  335. )
  336. assert resp.status_code == 400, resp.text
  337. assert "thingiverse" in resp.json()["detail"].lower()
  338. svc.download.assert_not_called()
  339. @pytest.mark.asyncio
  340. async def test_unknown_source_type_creates_no_folder_side_effect(self, async_client, db_session):
  341. """Provider resolution must precede destination handling — a rejected
  342. request must not leave an auto-created default folder behind."""
  343. from sqlalchemy import select
  344. svc = _fake_service(get_download=_download_info())
  345. with patch("backend.app.api.routes.makerworld._build_service", AsyncMock(return_value=svc)):
  346. await async_client.post(
  347. "/api/v1/makerworld/import",
  348. json={"model_id": 1400373, "source_type": "bogus"},
  349. )
  350. result = await db_session.execute(select(LibraryFolder))
  351. assert result.scalars().all() == []
  352. @pytest.mark.asyncio
  353. async def test_autocreates_makerworld_folder_when_folder_id_none(self, async_client, db_session):
  354. """Default destination — a top-level "MakerWorld" folder — is created
  355. on first import so users don't have to set it up."""
  356. svc = _fake_service(
  357. get_download=_download_info(),
  358. download=ProviderDownload(file_bytes=self._FAKE_3MF_BYTES, filename="benchy.3mf"),
  359. )
  360. with patch("backend.app.api.routes.makerworld._build_service", AsyncMock(return_value=svc)):
  361. resp = await async_client.post(
  362. "/api/v1/makerworld/import",
  363. json={"model_id": 1400373, "profile_id": 298919107, "folder_id": None},
  364. )
  365. assert resp.status_code == 200, resp.text
  366. # The new folder should exist, at the root.
  367. from sqlalchemy import select
  368. result = await db_session.execute(
  369. select(LibraryFolder).where(LibraryFolder.name == "MakerWorld", LibraryFolder.parent_id.is_(None))
  370. )
  371. folder = result.scalar_one()
  372. assert resp.json()["folder_id"] == folder.id
  373. @pytest.mark.asyncio
  374. async def test_default_folder_comes_from_resolved_provider(self, async_client, db_session):
  375. """``import_instance`` must read ``default_folder_name`` off the provider
  376. it resolved — not the MakerWorld singleton (review round 3 fix). Latent
  377. with one provider, but the difference is visible behind a stand-in: a
  378. second provider's import lands in *its* folder, not "MakerWorld"."""
  379. dummy = _DummyProvider(default_folder_name="Dummy Imports")
  380. svc = _fake_service(
  381. get_download=_download_info(),
  382. download=ProviderDownload(file_bytes=self._FAKE_3MF_BYTES, filename="benchy.3mf"),
  383. )
  384. with (
  385. patch("backend.app.api.routes.makerworld._provider_for_source", return_value=dummy),
  386. patch("backend.app.api.routes.makerworld._build_service", AsyncMock(return_value=svc)),
  387. ):
  388. resp = await async_client.post(
  389. "/api/v1/makerworld/import",
  390. json={"model_id": 1400373, "profile_id": 298919107, "source_type": "dummy"},
  391. )
  392. assert resp.status_code == 200, resp.text
  393. from sqlalchemy import select
  394. result = await db_session.execute(
  395. select(LibraryFolder).where(LibraryFolder.name == "Dummy Imports", LibraryFolder.parent_id.is_(None))
  396. )
  397. assert result.scalar_one_or_none() is not None
  398. # The MakerWorld singleton's folder must NOT be auto-created instead.
  399. assert (
  400. await db_session.execute(
  401. select(LibraryFolder).where(LibraryFolder.name == "MakerWorld", LibraryFolder.parent_id.is_(None))
  402. )
  403. ).scalar_one_or_none() is None
  404. @pytest.mark.asyncio
  405. async def test_none_default_folder_name_imports_to_library_root(self, async_client, db_session):
  406. """A provider that leaves ``default_folder_name`` unset imports into the
  407. library root rather than minting a NULL-named folder (review round 3,
  408. note 3)."""
  409. dummy = _DummyProvider(default_folder_name=None)
  410. svc = _fake_service(
  411. get_download=_download_info(),
  412. download=ProviderDownload(file_bytes=self._FAKE_3MF_BYTES, filename="benchy.3mf"),
  413. )
  414. with (
  415. patch("backend.app.api.routes.makerworld._provider_for_source", return_value=dummy),
  416. patch("backend.app.api.routes.makerworld._build_service", AsyncMock(return_value=svc)),
  417. ):
  418. resp = await async_client.post(
  419. "/api/v1/makerworld/import",
  420. json={"model_id": 1400373, "profile_id": 298919107, "source_type": "dummy", "folder_id": None},
  421. )
  422. assert resp.status_code == 200, resp.text
  423. assert resp.json()["folder_id"] is None
  424. from sqlalchemy import select
  425. result = await db_session.execute(select(LibraryFolder))
  426. assert result.scalars().all() == []
  427. @pytest.mark.asyncio
  428. async def test_gate_uses_the_makerworld_permission_for_makerworld(self, async_client):
  429. """Control for the test below: the default ``source_type`` still gates
  430. on ``makerworld:import``, exactly as the route decorator used to."""
  431. seen, factory = _permission_spy()
  432. svc = _fake_service(
  433. get_download=_download_info(),
  434. download=ProviderDownload(file_bytes=self._FAKE_3MF_BYTES, filename="benchy.3mf"),
  435. )
  436. with (
  437. patch("backend.app.api.routes.makerworld.require_permission_if_auth_enabled", factory),
  438. patch("backend.app.api.routes.makerworld._build_service", AsyncMock(return_value=svc)),
  439. ):
  440. resp = await async_client.post(
  441. "/api/v1/makerworld/import",
  442. json={"model_id": 1400373, "profile_id": 298919107},
  443. )
  444. assert resp.status_code == 200, resp.text
  445. assert seen == [Permission.MAKERWORLD_IMPORT]
  446. @pytest.mark.asyncio
  447. async def test_gate_uses_the_resolved_providers_permission(self, async_client):
  448. """The permission is the resolved provider's, not the MakerWorld
  449. singleton's. It cannot be a route dependency — FastAPI resolves those
  450. before the body exists, so the decorator could only ever name one
  451. provider, and importing from a second one would be gated on
  452. ``makerworld:import``."""
  453. seen, factory = _permission_spy()
  454. dummy = _DummyProvider()
  455. svc = _fake_service(
  456. get_download=_download_info(),
  457. download=ProviderDownload(file_bytes=self._FAKE_3MF_BYTES, filename="benchy.3mf"),
  458. )
  459. with (
  460. patch("backend.app.api.routes.makerworld.require_permission_if_auth_enabled", factory),
  461. patch("backend.app.api.routes.makerworld._provider_for_source", return_value=dummy),
  462. patch("backend.app.api.routes.makerworld._build_service", AsyncMock(return_value=svc)),
  463. ):
  464. resp = await async_client.post(
  465. "/api/v1/makerworld/import",
  466. json={"model_id": 1400373, "profile_id": 298919107, "source_type": "dummy"},
  467. )
  468. assert resp.status_code == 200, resp.text
  469. assert seen == [Permission.LIBRARY_UPLOAD]
  470. assert Permission.MAKERWORLD_IMPORT not in seen
  471. @pytest.mark.asyncio
  472. async def test_provider_without_a_permission_is_refused_not_waved_through(self, async_client, db_session):
  473. """``import_permission`` is optional on the descriptor, so "unset" must
  474. fail closed rather than read as "unrestricted"."""
  475. dummy = _DummyProvider(import_permission=None)
  476. svc = _fake_service(
  477. get_download=_download_info(),
  478. download=ProviderDownload(file_bytes=self._FAKE_3MF_BYTES, filename="benchy.3mf"),
  479. )
  480. with (
  481. patch("backend.app.api.routes.makerworld._provider_for_source", return_value=dummy),
  482. patch("backend.app.api.routes.makerworld._build_service", AsyncMock(return_value=svc)),
  483. ):
  484. resp = await async_client.post(
  485. "/api/v1/makerworld/import",
  486. json={"model_id": 1400373, "profile_id": 298919107, "source_type": "dummy"},
  487. )
  488. assert resp.status_code == 500
  489. assert "declares no permission" in resp.json()["detail"]
  490. from sqlalchemy import select
  491. assert (await db_session.execute(select(LibraryFile))).scalars().all() == []
  492. @pytest.mark.asyncio
  493. async def test_uses_existing_folder_when_folder_id_provided(self, async_client, db_session):
  494. """Caller-supplied ``folder_id`` must be honoured even if the default
  495. ``MakerWorld`` folder also exists — no silent hijacking."""
  496. folder = LibraryFolder(name="MyCustomFolder", parent_id=None)
  497. db_session.add(folder)
  498. await db_session.commit()
  499. await db_session.refresh(folder)
  500. svc = _fake_service(
  501. get_download=_download_info(),
  502. download=ProviderDownload(file_bytes=self._FAKE_3MF_BYTES, filename="benchy.3mf"),
  503. )
  504. with patch("backend.app.api.routes.makerworld._build_service", AsyncMock(return_value=svc)):
  505. resp = await async_client.post(
  506. "/api/v1/makerworld/import",
  507. json={"model_id": 1400373, "profile_id": 298919107, "folder_id": folder.id},
  508. )
  509. assert resp.status_code == 200, resp.text
  510. assert resp.json()["folder_id"] == folder.id
  511. @pytest.mark.asyncio
  512. async def test_canonical_source_url_includes_profile_id(self, async_client, db_session):
  513. """The saved row's ``source_url`` must include ``#profileId-`` so two
  514. plates of the same model become two library rows (dedupe is per-plate)."""
  515. svc = _fake_service(
  516. get_download=_download_info(),
  517. download=ProviderDownload(file_bytes=self._FAKE_3MF_BYTES, filename="benchy.3mf"),
  518. )
  519. with patch("backend.app.api.routes.makerworld._build_service", AsyncMock(return_value=svc)):
  520. resp = await async_client.post(
  521. "/api/v1/makerworld/import",
  522. json={"model_id": 1400373, "profile_id": 298919107},
  523. )
  524. assert resp.status_code == 200, resp.text
  525. from sqlalchemy import select
  526. row = (
  527. await db_session.execute(select(LibraryFile).where(LibraryFile.id == resp.json()["library_file_id"]))
  528. ).scalar_one()
  529. assert row.source_url == "https://makerworld.com/models/1400373#profileId-298919107"
  530. @pytest.mark.asyncio
  531. async def test_filename_from_upstream_is_basenamed(self, async_client, db_session):
  532. """Defence-in-depth: a malicious ``name`` from the upstream manifest
  533. (e.g. ``"../../evil.3mf"``) must not persist path components into the
  534. library row. On-disk storage uses a UUID already, this is belt-and-
  535. braces protection for the human-readable field."""
  536. svc = _fake_service(
  537. get_download=_download_info(name="../../evil.3mf"),
  538. download=ProviderDownload(file_bytes=self._FAKE_3MF_BYTES, filename="fallback.3mf"),
  539. )
  540. with patch("backend.app.api.routes.makerworld._build_service", AsyncMock(return_value=svc)):
  541. resp = await async_client.post(
  542. "/api/v1/makerworld/import",
  543. json={"model_id": 1400373, "profile_id": 298919107},
  544. )
  545. assert resp.status_code == 200, resp.text
  546. assert resp.json()["filename"] == "evil.3mf"
  547. @pytest.mark.asyncio
  548. async def test_response_includes_profile_id(self, async_client, db_session):
  549. """UI matches imports back to the plate row via ``profile_id`` — the
  550. response field must always be populated, even when the caller provided
  551. it explicitly (rather than the backend falling back to design defaults)."""
  552. svc = _fake_service(
  553. get_download=_download_info(),
  554. download=ProviderDownload(file_bytes=self._FAKE_3MF_BYTES, filename="benchy.3mf"),
  555. )
  556. with patch("backend.app.api.routes.makerworld._build_service", AsyncMock(return_value=svc)):
  557. resp = await async_client.post(
  558. "/api/v1/makerworld/import",
  559. json={"model_id": 1400373, "profile_id": 298919107},
  560. )
  561. assert resp.status_code == 200, resp.text
  562. assert resp.json()["profile_id"] == 298919107
  563. @pytest.mark.asyncio
  564. async def test_import_to_writable_external_writes_bytes_to_mount(self, async_client, db_session, tmp_path):
  565. """#1645: importing into a writable external folder writes the bytes to
  566. ``<external_path>/<filename>`` and tags the row ``is_external=True`` —
  567. same shape as the multipart-upload path (#1112). Previously the bytes
  568. landed in the internal library dir under a UUID name while the row
  569. showed up under the external folder in the UI, leaving a NAS/SMB user
  570. unable to find their file on the mount."""
  571. ext_dir = tmp_path / "nas-makerworld"
  572. ext_dir.mkdir()
  573. folder = LibraryFolder(
  574. name="NAS Imports",
  575. parent_id=None,
  576. is_external=True,
  577. external_path=str(ext_dir),
  578. external_readonly=False,
  579. )
  580. db_session.add(folder)
  581. await db_session.commit()
  582. await db_session.refresh(folder)
  583. svc = _fake_service(
  584. get_download=_download_info(name="seed-starter.3mf"),
  585. download=ProviderDownload(file_bytes=self._FAKE_3MF_BYTES, filename="seed-starter.3mf"),
  586. )
  587. with patch("backend.app.api.routes.makerworld._build_service", AsyncMock(return_value=svc)):
  588. resp = await async_client.post(
  589. "/api/v1/makerworld/import",
  590. json={"model_id": 1400373, "profile_id": 298919107, "folder_id": folder.id},
  591. )
  592. assert resp.status_code == 200, resp.text
  593. from sqlalchemy import select
  594. row = (
  595. await db_session.execute(select(LibraryFile).where(LibraryFile.id == resp.json()["library_file_id"]))
  596. ).scalar_one()
  597. assert row.folder_id == folder.id
  598. assert row.is_external is True, "Row must be tagged external so re-scan can reconcile it"
  599. # External rows persist the absolute mount path (matches scan + upload paths).
  600. assert row.file_path == str(ext_dir / "seed-starter.3mf")
  601. on_disk = ext_dir / "seed-starter.3mf"
  602. assert on_disk.is_file(), "Bytes must land on the external mount, not in the internal library dir"
  603. assert on_disk.read_bytes() == self._FAKE_3MF_BYTES
  604. @pytest.mark.asyncio
  605. async def test_import_to_readonly_external_rejected_at_route(self, async_client, db_session, tmp_path):
  606. """The route-layer gate in ``import_instance`` rejects read-only
  607. external folders with 403 before any download happens — so MakerWorld
  608. credentials and the upstream download bandwidth aren't wasted."""
  609. ext_dir = tmp_path / "nas-readonly"
  610. ext_dir.mkdir()
  611. folder = LibraryFolder(
  612. name="NAS read-only",
  613. parent_id=None,
  614. is_external=True,
  615. external_path=str(ext_dir),
  616. external_readonly=True,
  617. )
  618. db_session.add(folder)
  619. await db_session.commit()
  620. await db_session.refresh(folder)
  621. svc = _fake_service(get_download=_download_info())
  622. svc.download = AsyncMock()
  623. with patch("backend.app.api.routes.makerworld._build_service", AsyncMock(return_value=svc)):
  624. resp = await async_client.post(
  625. "/api/v1/makerworld/import",
  626. json={"model_id": 1400373, "profile_id": 298919107, "folder_id": folder.id},
  627. )
  628. assert resp.status_code == 403, resp.text
  629. svc.download.assert_not_called()
  630. @pytest.mark.asyncio
  631. async def test_import_to_external_with_missing_path_returns_400(self, async_client, db_session, tmp_path):
  632. """If the external folder's mount has gone away (NAS unplugged, SMB
  633. share down), ``_resolve_upload_destination`` returns 400 before the
  634. write so we don't silently fall back to the internal library dir."""
  635. missing_dir = tmp_path / "vanished-mount" # NOTE: deliberately not created
  636. folder = LibraryFolder(
  637. name="NAS gone",
  638. parent_id=None,
  639. is_external=True,
  640. external_path=str(missing_dir),
  641. external_readonly=False,
  642. )
  643. db_session.add(folder)
  644. await db_session.commit()
  645. await db_session.refresh(folder)
  646. svc = _fake_service(
  647. get_download=_download_info(),
  648. download=ProviderDownload(file_bytes=self._FAKE_3MF_BYTES, filename="benchy.3mf"),
  649. )
  650. with patch("backend.app.api.routes.makerworld._build_service", AsyncMock(return_value=svc)):
  651. resp = await async_client.post(
  652. "/api/v1/makerworld/import",
  653. json={"model_id": 1400373, "profile_id": 298919107, "folder_id": folder.id},
  654. )
  655. assert resp.status_code == 400, resp.text
  656. assert "not accessible" in resp.text.lower()
  657. @pytest.mark.asyncio
  658. async def test_import_to_external_with_name_collision_returns_409(self, async_client, db_session, tmp_path):
  659. """A user-visible 409 fires when the filename already exists on the
  660. external mount, instead of silently overwriting a file the user put
  661. there outside Bambuddy."""
  662. ext_dir = tmp_path / "nas-collide"
  663. ext_dir.mkdir()
  664. (ext_dir / "benchy.3mf").write_bytes(b"pre-existing")
  665. folder = LibraryFolder(
  666. name="NAS collide",
  667. parent_id=None,
  668. is_external=True,
  669. external_path=str(ext_dir),
  670. external_readonly=False,
  671. )
  672. db_session.add(folder)
  673. await db_session.commit()
  674. await db_session.refresh(folder)
  675. svc = _fake_service(
  676. get_download=_download_info(name="benchy.3mf"),
  677. download=ProviderDownload(file_bytes=self._FAKE_3MF_BYTES, filename="benchy.3mf"),
  678. )
  679. with patch("backend.app.api.routes.makerworld._build_service", AsyncMock(return_value=svc)):
  680. resp = await async_client.post(
  681. "/api/v1/makerworld/import",
  682. json={"model_id": 1400373, "profile_id": 298919107, "folder_id": folder.id},
  683. )
  684. assert resp.status_code == 409, resp.text
  685. # Pre-existing file's contents must not be clobbered by the failed write.
  686. assert (ext_dir / "benchy.3mf").read_bytes() == b"pre-existing"
  687. class TestRecentImports:
  688. """GET /makerworld/recent-imports — sidebar feed on the MakerWorld page."""
  689. @pytest.mark.asyncio
  690. async def test_empty_when_no_makerworld_imports(self, async_client):
  691. resp = await async_client.get("/api/v1/makerworld/recent-imports")
  692. assert resp.status_code == 200
  693. assert resp.json() == []
  694. @pytest.mark.asyncio
  695. async def test_returns_items_newest_first(self, async_client, db_session):
  696. # Seed three rows with explicit, decreasing created_at timestamps so
  697. # ordering doesn't depend on auto-increment PK ordering.
  698. base = datetime(2025, 1, 1, 12, 0, 0)
  699. older = LibraryFile(
  700. filename="older.3mf",
  701. file_path="library/older.3mf",
  702. file_type="3mf",
  703. file_size=10,
  704. source_type="makerworld",
  705. source_url="https://makerworld.com/models/1",
  706. created_at=base,
  707. )
  708. middle = LibraryFile(
  709. filename="middle.3mf",
  710. file_path="library/middle.3mf",
  711. file_type="3mf",
  712. file_size=10,
  713. source_type="makerworld",
  714. source_url="https://makerworld.com/models/2",
  715. created_at=base + timedelta(hours=1),
  716. )
  717. newer = LibraryFile(
  718. filename="newer.3mf",
  719. file_path="library/newer.3mf",
  720. file_type="3mf",
  721. file_size=10,
  722. source_type="makerworld",
  723. source_url="https://makerworld.com/models/3",
  724. created_at=base + timedelta(hours=2),
  725. )
  726. # Unrelated non-MakerWorld file must NOT show up.
  727. other = LibraryFile(
  728. filename="manual.3mf",
  729. file_path="library/manual.3mf",
  730. file_type="3mf",
  731. file_size=10,
  732. source_type=None,
  733. source_url=None,
  734. created_at=base + timedelta(hours=3),
  735. )
  736. db_session.add_all([older, middle, newer, other])
  737. await db_session.commit()
  738. resp = await async_client.get("/api/v1/makerworld/recent-imports")
  739. assert resp.status_code == 200, resp.text
  740. body = resp.json()
  741. names = [row["filename"] for row in body]
  742. assert names == ["newer.3mf", "middle.3mf", "older.3mf"]
  743. @pytest.mark.asyncio
  744. async def test_response_matches_pydantic_shape(self, async_client, db_session):
  745. """Lock the exact key set so the frontend's typed ``MakerworldRecentImport``
  746. doesn't silently fall out of sync with the backend schema."""
  747. row = LibraryFile(
  748. filename="x.3mf",
  749. file_path="library/x.3mf",
  750. file_type="3mf",
  751. file_size=10,
  752. source_type="makerworld",
  753. source_url="https://makerworld.com/models/1#profileId-2",
  754. )
  755. db_session.add(row)
  756. await db_session.commit()
  757. resp = await async_client.get("/api/v1/makerworld/recent-imports")
  758. assert resp.status_code == 200, resp.text
  759. item = resp.json()[0]
  760. assert set(item.keys()) == {
  761. "library_file_id",
  762. "filename",
  763. "folder_id",
  764. "thumbnail_path",
  765. "source_url",
  766. "created_at",
  767. }
  768. assert item["source_url"] == "https://makerworld.com/models/1#profileId-2"
  769. @pytest.mark.asyncio
  770. async def test_limit_is_honoured(self, async_client, db_session):
  771. for i in range(5):
  772. db_session.add(
  773. LibraryFile(
  774. filename=f"f{i}.3mf",
  775. file_path=f"library/f{i}.3mf",
  776. file_type="3mf",
  777. file_size=10,
  778. source_type="makerworld",
  779. source_url=f"https://makerworld.com/models/{i}",
  780. )
  781. )
  782. await db_session.commit()
  783. resp = await async_client.get("/api/v1/makerworld/recent-imports?limit=2")
  784. assert resp.status_code == 200
  785. assert len(resp.json()) == 2
  786. @pytest.mark.asyncio
  787. async def test_limit_clamped_to_minimum(self, async_client, db_session):
  788. """``limit=0`` or negative must clamp to 1 — a zero limit would be
  789. silently swallowed by SQL and return nothing, which is surprising."""
  790. db_session.add(
  791. LibraryFile(
  792. filename="one.3mf",
  793. file_path="library/one.3mf",
  794. file_type="3mf",
  795. file_size=10,
  796. source_type="makerworld",
  797. source_url="https://makerworld.com/models/1",
  798. )
  799. )
  800. await db_session.commit()
  801. resp = await async_client.get("/api/v1/makerworld/recent-imports?limit=0")
  802. assert resp.status_code == 200
  803. assert len(resp.json()) == 1
  804. @pytest.mark.asyncio
  805. async def test_limit_clamped_to_maximum(self, async_client, db_session):
  806. """``limit`` is clamped to 50 so a pathological client can't request
  807. the whole table. We seed 60 rows and assert the response is capped."""
  808. for i in range(60):
  809. db_session.add(
  810. LibraryFile(
  811. filename=f"f{i}.3mf",
  812. file_path=f"library/f{i}.3mf",
  813. file_type="3mf",
  814. file_size=10,
  815. source_type="makerworld",
  816. source_url=f"https://makerworld.com/models/{i}",
  817. )
  818. )
  819. await db_session.commit()
  820. resp = await async_client.get("/api/v1/makerworld/recent-imports?limit=9999")
  821. assert resp.status_code == 200
  822. assert len(resp.json()) == 50