test_model_provider_interface.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250
  1. """Tests for the MakerWorld provider descriptor (``provider.py``).
  2. The descriptor is the interface between the route layer and the per-request
  3. service: identity, URL routing, auth requirements, and the factory that seeds
  4. a service with the caller's stored Bambu Cloud bearer token.
  5. """
  6. from __future__ import annotations
  7. from unittest.mock import AsyncMock, patch
  8. import pytest
  9. from backend.app.core.permissions import Permission
  10. from backend.app.services.model_providers import makerworld_provider
  11. from backend.app.services.model_providers.base import ModelProvider
  12. from backend.app.services.model_providers.makerworld.service import MakerWorldService
  13. class TestMakerWorldProviderDescriptor:
  14. def test_identity_fields(self):
  15. assert makerworld_provider.source_type == "makerworld"
  16. assert makerworld_provider.display_name == "MakerWorld"
  17. assert makerworld_provider.host_patterns == ("makerworld.com",)
  18. assert makerworld_provider.default_folder_name == "MakerWorld"
  19. def test_permissions(self):
  20. assert makerworld_provider.view_permission == Permission.MAKERWORLD_VIEW
  21. assert makerworld_provider.import_permission == Permission.MAKERWORLD_IMPORT
  22. def test_auth_descriptor(self):
  23. assert makerworld_provider.auth is not None
  24. assert makerworld_provider.auth.auth_type == "bambu_cloud_bearer"
  25. assert makerworld_provider.auth.credential_fields == ()
  26. def test_supports_url_host_suffix_match(self):
  27. assert makerworld_provider.supports_url("https://makerworld.com/en/models/1400373")
  28. assert makerworld_provider.supports_url("https://www.makerworld.com/models/1400373")
  29. assert makerworld_provider.supports_url("makerworld.com/models/1")
  30. def test_rejects_foreign_hosts_and_garbage(self):
  31. assert not makerworld_provider.supports_url("https://thingiverse.com/thing/123")
  32. assert not makerworld_provider.supports_url("https://makerworld.com.evil.example/x")
  33. assert not makerworld_provider.supports_url("")
  34. assert not makerworld_provider.supports_url(None) # type: ignore[arg-type]
  35. assert not makerworld_provider.supports_url(123) # type: ignore[arg-type]
  36. def test_thumbnail_hosts_is_the_cdn_allowlist(self):
  37. assert "makerworld.bblmw.com" in makerworld_provider.thumbnail_hosts()
  38. assert "public-cdn.bblmw.com" in makerworld_provider.thumbnail_hosts()
  39. def test_download_hosts_is_the_cdn_allowlist(self):
  40. """The download-guard SSRF seam mirrors the thumbnail one — a provider
  41. that fetches files server-side declares the hosts its service may
  42. fetch from (review round 3, note 2)."""
  43. assert "makerworld.bblmw.com" in makerworld_provider.download_hosts()
  44. assert "public-cdn.bblmw.com" in makerworld_provider.download_hosts()
  45. def test_parse_and_canonical_roundtrip(self):
  46. ref = makerworld_provider.parse_url("https://makerworld.com/en/models/1400373-slug#profileId-1452154")
  47. assert ref.external_id == "1400373"
  48. assert ref.sub_id == "1452154"
  49. assert ref.source_type == "makerworld"
  50. assert makerworld_provider.canonical_url(ref) == "https://makerworld.com/models/1400373#profileId-1452154"
  51. def test_canonical_plate_less_shape(self):
  52. ref = makerworld_provider.parse_url("https://makerworld.com/models/999")
  53. assert makerworld_provider.canonical_url(ref) == "https://makerworld.com/models/999"
  54. def test_source_url_filter_matches_model_and_any_plate(self):
  55. """The already-imported predicate must cover the whole-model key and
  56. every per-plate key — MakerWorld's canonical shape appends
  57. ``#profileId-{n}`` (review round 3: shape knowledge belongs to the
  58. provider, not the route)."""
  59. from sqlalchemy import String, column as sa_column
  60. expr = makerworld_provider.source_url_filter(sa_column("source_url", String), "1400373")
  61. sql = str(expr.compile(compile_kwargs={"literal_binds": True}))
  62. assert "source_url = 'https://makerworld.com/models/1400373'" in sql
  63. assert "LIKE 'https://makerworld.com/models/1400373#profileId-%'" in sql
  64. def test_default_source_url_filter_is_exact_match_only(self):
  65. """A provider without plate-shaped keys inherits the default: exact
  66. match on its whole-model canonical URL, nothing else."""
  67. from sqlalchemy import String, column as sa_column
  68. expr = _WholeModelProvider().source_url_filter(sa_column("source_url", String), "42")
  69. sql = str(expr.compile(compile_kwargs={"literal_binds": True}))
  70. assert sql == "source_url = 'https://example.com/models/42'"
  71. assert "LIKE" not in sql
  72. class _WholeModelProvider(ModelProvider):
  73. """Minimal concrete provider that keys dedupe at whole-model granularity
  74. only — exercises the inherited default ``source_url_filter``."""
  75. source_type = "wholemodel"
  76. display_name = "WholeModel"
  77. async def build_service(self, *, db, user, api_key_owner=None, client=None):
  78. raise NotImplementedError
  79. def parse_url(self, url):
  80. raise NotImplementedError
  81. def canonical_url(self, ref):
  82. return f"https://example.com/models/{ref.external_id}"
  83. class TestBuildService:
  84. """build_service must reproduce exactly what the old route helper did:
  85. read the caller's stored Bambu Cloud token and wire the rejected-token
  86. callback so a 401 invalidates the shared credential app-wide."""
  87. @pytest.fixture(autouse=True)
  88. def _auth_off(self):
  89. """These describe the auth-off install, the only one whose sign-in is
  90. stored without a user (see the auth-on test below)."""
  91. with patch(
  92. "backend.app.services.model_providers.makerworld.provider.is_auth_enabled",
  93. AsyncMock(return_value=False),
  94. ):
  95. yield
  96. @pytest.mark.asyncio
  97. async def test_with_auth_on_no_identity_borrows_no_stored_sign_in(self):
  98. """With auth on, a caller without a user (an API key without Allow
  99. Cloud Access) gets no token: the sign-in stored without a user is the
  100. auth-off install's, and may be left over after auth was turned on."""
  101. stored = AsyncMock(return_value=("global-tok", "admin@x.com", "global"))
  102. with (
  103. patch(
  104. "backend.app.services.model_providers.makerworld.provider.is_auth_enabled",
  105. AsyncMock(return_value=True),
  106. ),
  107. patch("backend.app.services.model_providers.makerworld.provider.get_stored_token", stored),
  108. ):
  109. svc = await makerworld_provider.build_service(db=AsyncMock(), user=None)
  110. assert svc._auth_token is None
  111. stored.assert_not_awaited()
  112. await svc.close()
  113. @pytest.mark.asyncio
  114. async def test_seeds_token_and_auth_failure_callback(self):
  115. db = AsyncMock()
  116. user = AsyncMock()
  117. user.id = 7
  118. with (
  119. patch(
  120. "backend.app.services.model_providers.makerworld.provider.get_stored_token",
  121. AsyncMock(return_value=("tok-abc", "e@x.com", "global")),
  122. ),
  123. patch(
  124. "backend.app.services.model_providers.makerworld.provider.mark_cloud_token_invalid",
  125. AsyncMock(),
  126. ) as mark_invalid,
  127. ):
  128. svc = await makerworld_provider.build_service(db=db, user=user)
  129. assert isinstance(svc, MakerWorldService)
  130. assert svc._auth_token == "tok-abc"
  131. assert svc._user is user
  132. await svc._on_auth_failure()
  133. mark_invalid.assert_awaited_once_with(7)
  134. await svc.close()
  135. @pytest.mark.asyncio
  136. async def test_anonymous_user_still_gets_auth_callback(self):
  137. """No user ≠ nothing to invalidate. Auth-disabled single-user installs
  138. hold their token in global Settings (``get_stored_token(db, None)``
  139. reads it), so a rejection must still be recorded — ``user_id=None``
  140. writes the global flag (review blocker 5). The callback stays wired;
  141. only a *stray* non-expiry 401 keeps it a no-op."""
  142. db = AsyncMock()
  143. with (
  144. patch(
  145. "backend.app.services.model_providers.makerworld.provider.get_stored_token",
  146. AsyncMock(return_value=(None, None, "global")),
  147. ),
  148. patch(
  149. "backend.app.services.model_providers.makerworld.provider.mark_cloud_token_invalid",
  150. AsyncMock(),
  151. ) as mark_invalid,
  152. ):
  153. svc = await makerworld_provider.build_service(db=db, user=None)
  154. assert svc._auth_token is None
  155. assert svc._on_auth_failure is not None
  156. # Firing it records the *global* flag (user_id=None), not a per-user row.
  157. await svc._on_auth_failure()
  158. mark_invalid.assert_awaited_once_with(None)
  159. await svc.close()
  160. @pytest.mark.asyncio
  161. async def test_build_service_passes_declared_thumbnail_hosts(self):
  162. """The SSRF seam contract: ``fetch_thumbnail``'s allowlist must come
  163. from ``ModelProvider.thumbnail_hosts()`` via build_service — not from
  164. a hardcoded copy inside the service (review round 2, item 1)."""
  165. db = AsyncMock()
  166. with patch(
  167. "backend.app.services.model_providers.makerworld.provider.get_stored_token",
  168. AsyncMock(return_value=(None, None, "global")),
  169. ):
  170. svc = await makerworld_provider.build_service(db=db, user=None)
  171. assert svc._thumbnail_hosts == makerworld_provider.thumbnail_hosts()
  172. assert len(svc._thumbnail_hosts) > 0
  173. await svc.close()
  174. @pytest.mark.asyncio
  175. async def test_build_service_passes_declared_download_hosts(self):
  176. """Symmetric SSRF seam contract: ``download``'s allowlist must come
  177. from ``ModelProvider.download_hosts()`` via build_service — not from
  178. a hardcoded copy inside the service (review round 3, note 2)."""
  179. db = AsyncMock()
  180. with patch(
  181. "backend.app.services.model_providers.makerworld.provider.get_stored_token",
  182. AsyncMock(return_value=(None, None, "global")),
  183. ):
  184. svc = await makerworld_provider.build_service(db=db, user=None)
  185. assert svc._download_hosts == makerworld_provider.download_hosts()
  186. assert len(svc._download_hosts) > 0
  187. await svc.close()
  188. @pytest.mark.asyncio
  189. async def test_api_key_owner_is_the_fallback_identity(self):
  190. """API-keyed callers carry identity on the key (#1777) — build_service
  191. must use the key's owner when ``user`` is None."""
  192. db = AsyncMock()
  193. owner = AsyncMock()
  194. owner.id = 11
  195. with (
  196. patch(
  197. "backend.app.services.model_providers.makerworld.provider.get_stored_token",
  198. AsyncMock(return_value=("owner-tok", "owner@x.com", "global")),
  199. ),
  200. patch(
  201. "backend.app.services.model_providers.makerworld.provider.mark_cloud_token_invalid",
  202. AsyncMock(),
  203. ) as mark_invalid,
  204. ):
  205. svc = await makerworld_provider.build_service(db=db, user=None, api_key_owner=owner)
  206. assert svc._auth_token == "owner-tok"
  207. assert svc._user is owner
  208. await svc._on_auth_failure()
  209. mark_invalid.assert_awaited_once_with(11)
  210. await svc.close()