test_model_provider_interface.py 10.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221
  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.mark.asyncio
  88. async def test_seeds_token_and_auth_failure_callback(self):
  89. db = AsyncMock()
  90. user = AsyncMock()
  91. user.id = 7
  92. with (
  93. patch(
  94. "backend.app.services.model_providers.makerworld.provider.get_stored_token",
  95. AsyncMock(return_value=("tok-abc", "e@x.com", "global")),
  96. ),
  97. patch(
  98. "backend.app.services.model_providers.makerworld.provider.mark_cloud_token_invalid",
  99. AsyncMock(),
  100. ) as mark_invalid,
  101. ):
  102. svc = await makerworld_provider.build_service(db=db, user=user)
  103. assert isinstance(svc, MakerWorldService)
  104. assert svc._auth_token == "tok-abc"
  105. assert svc._user is user
  106. await svc._on_auth_failure()
  107. mark_invalid.assert_awaited_once_with(7)
  108. await svc.close()
  109. @pytest.mark.asyncio
  110. async def test_anonymous_user_still_gets_auth_callback(self):
  111. """No user ≠ nothing to invalidate. Auth-disabled single-user installs
  112. hold their token in global Settings (``get_stored_token(db, None)``
  113. reads it), so a rejection must still be recorded — ``user_id=None``
  114. writes the global flag (review blocker 5). The callback stays wired;
  115. only a *stray* non-expiry 401 keeps it a no-op."""
  116. db = AsyncMock()
  117. with (
  118. patch(
  119. "backend.app.services.model_providers.makerworld.provider.get_stored_token",
  120. AsyncMock(return_value=(None, None, "global")),
  121. ),
  122. patch(
  123. "backend.app.services.model_providers.makerworld.provider.mark_cloud_token_invalid",
  124. AsyncMock(),
  125. ) as mark_invalid,
  126. ):
  127. svc = await makerworld_provider.build_service(db=db, user=None)
  128. assert svc._auth_token is None
  129. assert svc._on_auth_failure is not None
  130. # Firing it records the *global* flag (user_id=None), not a per-user row.
  131. await svc._on_auth_failure()
  132. mark_invalid.assert_awaited_once_with(None)
  133. await svc.close()
  134. @pytest.mark.asyncio
  135. async def test_build_service_passes_declared_thumbnail_hosts(self):
  136. """The SSRF seam contract: ``fetch_thumbnail``'s allowlist must come
  137. from ``ModelProvider.thumbnail_hosts()`` via build_service — not from
  138. a hardcoded copy inside the service (review round 2, item 1)."""
  139. db = AsyncMock()
  140. with patch(
  141. "backend.app.services.model_providers.makerworld.provider.get_stored_token",
  142. AsyncMock(return_value=(None, None, "global")),
  143. ):
  144. svc = await makerworld_provider.build_service(db=db, user=None)
  145. assert svc._thumbnail_hosts == makerworld_provider.thumbnail_hosts()
  146. assert len(svc._thumbnail_hosts) > 0
  147. await svc.close()
  148. @pytest.mark.asyncio
  149. async def test_build_service_passes_declared_download_hosts(self):
  150. """Symmetric SSRF seam contract: ``download``'s allowlist must come
  151. from ``ModelProvider.download_hosts()`` via build_service — not from
  152. a hardcoded copy inside the service (review round 3, note 2)."""
  153. db = AsyncMock()
  154. with patch(
  155. "backend.app.services.model_providers.makerworld.provider.get_stored_token",
  156. AsyncMock(return_value=(None, None, "global")),
  157. ):
  158. svc = await makerworld_provider.build_service(db=db, user=None)
  159. assert svc._download_hosts == makerworld_provider.download_hosts()
  160. assert len(svc._download_hosts) > 0
  161. await svc.close()
  162. @pytest.mark.asyncio
  163. async def test_api_key_owner_is_the_fallback_identity(self):
  164. """API-keyed callers carry identity on the key (#1777) — build_service
  165. must use the key's owner when ``user`` is None."""
  166. db = AsyncMock()
  167. owner = AsyncMock()
  168. owner.id = 11
  169. with (
  170. patch(
  171. "backend.app.services.model_providers.makerworld.provider.get_stored_token",
  172. AsyncMock(return_value=("owner-tok", "owner@x.com", "global")),
  173. ),
  174. patch(
  175. "backend.app.services.model_providers.makerworld.provider.mark_cloud_token_invalid",
  176. AsyncMock(),
  177. ) as mark_invalid,
  178. ):
  179. svc = await makerworld_provider.build_service(db=db, user=None, api_key_owner=owner)
  180. assert svc._auth_token == "owner-tok"
  181. assert svc._user is owner
  182. await svc._on_auth_failure()
  183. mark_invalid.assert_awaited_once_with(11)
  184. await svc.close()