test_makerworld_apikey_auth.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324
  1. """Integration tests for #1777 — API-keyed callers on /makerworld/*.
  2. The contract being pinned (mirrors the slice path's #1182 follow-up):
  3. When auth is enabled and the request carries an X-API-Key whose owner
  4. has a stored Bambu Cloud token, the makerworld routes must resolve
  5. identity via ``resolve_api_key_cloud_owner`` (instead of always seeing
  6. ``current_user=None``) so:
  7. - /status reports ``has_cloud_token=True`` for keys whose owner has a token
  8. - /resolve builds a MakerWorldService seeded with that token
  9. - /import succeeds end-to-end and attributes the resulting LibraryFile
  10. to the API-key owner
  11. The fail-closed path is preserved: keys without ``can_access_cloud=True``
  12. still surface the "requires Bambu Cloud login" experience (no auth gap).
  13. """
  14. from __future__ import annotations
  15. from unittest.mock import AsyncMock, patch
  16. import pytest
  17. from httpx import AsyncClient
  18. from sqlalchemy import select
  19. from sqlalchemy.ext.asyncio import AsyncSession
  20. from backend.app.core.auth import generate_api_key
  21. from backend.app.models.api_key import APIKey
  22. from backend.app.models.library import LibraryFile
  23. from backend.app.models.user import User
  24. from backend.app.services.model_providers.base import (
  25. ProviderDownload,
  26. ProviderDownloadInfo,
  27. ProviderResolvedModel,
  28. ProviderResourceRef,
  29. )
  30. async def _setup_auth_with_admin(client: AsyncClient) -> str:
  31. await client.post(
  32. "/api/v1/auth/setup",
  33. json={
  34. "auth_enabled": True,
  35. "admin_username": "mwadmin",
  36. "admin_password": "AdminPass1!",
  37. },
  38. )
  39. login = await client.post(
  40. "/api/v1/auth/login",
  41. json={"username": "mwadmin", "password": "AdminPass1!"},
  42. )
  43. return login.json()["access_token"]
  44. async def _store_admin_cloud_token(db: AsyncSession, username: str, token: str) -> User:
  45. result = await db.execute(select(User).where(User.username == username))
  46. user = result.scalar_one()
  47. user.cloud_token = token
  48. user.cloud_email = "owner@example.com"
  49. user.cloud_region = "global"
  50. await db.commit()
  51. await db.refresh(user)
  52. return user
  53. async def _make_key(
  54. db: AsyncSession,
  55. *,
  56. owner: User,
  57. name: str,
  58. can_access_cloud: bool = True,
  59. can_read_status: bool = True,
  60. can_manage_library: bool = True,
  61. ) -> str:
  62. """Mint an API key with the scopes /makerworld/* expects.
  63. /status + /resolve gate on ``Permission.MAKERWORLD_VIEW`` which maps
  64. to the ``can_read_status`` scope (see ``_APIKEY_SCOPE_BY_PERMISSION``
  65. in core/auth.py). /import gates on ``Permission.MAKERWORLD_IMPORT``
  66. which maps to ``can_manage_library``. ``can_access_cloud`` is what
  67. ``resolve_api_key_cloud_owner`` checks before returning the owner —
  68. the separate field this PR's fix actually depends on.
  69. """
  70. full_key, key_hash, key_prefix = generate_api_key()
  71. row = APIKey(
  72. name=name,
  73. key_hash=key_hash,
  74. key_prefix=key_prefix,
  75. user_id=owner.id,
  76. can_access_cloud=can_access_cloud,
  77. can_read_status=can_read_status,
  78. can_manage_library=can_manage_library,
  79. )
  80. db.add(row)
  81. await db.commit()
  82. return full_key
  83. def _fake_service(**stubs):
  84. """Mirror of the fixture in test_makerworld_routes.py — AsyncMock with
  85. method stubs that return the supplied payloads."""
  86. svc = AsyncMock()
  87. svc.close = AsyncMock()
  88. for name, value in stubs.items():
  89. if callable(value) and not isinstance(value, AsyncMock):
  90. setattr(svc, name, AsyncMock(side_effect=value))
  91. else:
  92. setattr(svc, name, AsyncMock(return_value=value))
  93. return svc
  94. def _download_info(
  95. model_id: int = 1400373,
  96. profile_id: int = 298919107,
  97. name: str = "cube.3mf",
  98. ) -> ProviderDownloadInfo:
  99. """What ``service.get_download`` hands the route — signed URL + raw
  100. upstream name + enriched ref (sub_id carries the resolved profile)."""
  101. return ProviderDownloadInfo(
  102. ref=ProviderResourceRef(source_type="makerworld", external_id=str(model_id), sub_id=str(profile_id)),
  103. url="https://makerworld.bblmw.com/makerworld/model/X/Y/cube.3mf?exp=1&key=k",
  104. suggested_filename=name,
  105. )
  106. class TestStatusEndpoint:
  107. @pytest.mark.asyncio
  108. @pytest.mark.integration
  109. async def test_api_key_owner_with_token_sees_has_cloud_token_true(
  110. self, async_client: AsyncClient, db_session: AsyncSession
  111. ):
  112. await _setup_auth_with_admin(async_client)
  113. admin = await _store_admin_cloud_token(db_session, "mwadmin", token="fake-bambu-token")
  114. key = await _make_key(db_session, owner=admin, name="status-cloud")
  115. resp = await async_client.get(
  116. "/api/v1/makerworld/status",
  117. headers={"X-API-Key": key},
  118. )
  119. assert resp.status_code == 200, resp.text
  120. assert resp.json() == {"has_cloud_token": True, "can_download": True, "sign_in_expired": False}
  121. @pytest.mark.asyncio
  122. @pytest.mark.integration
  123. async def test_api_key_without_cloud_scope_reports_no_token(
  124. self, async_client: AsyncClient, db_session: AsyncSession
  125. ):
  126. """Key has the per-route scope (can_read_status) but NOT can_access_cloud.
  127. Before this PR, both these conditions reported has_cloud_token=False.
  128. After the PR the per-route scope alone still doesn't grant cloud
  129. access — the resolver fences on can_access_cloud — so the response
  130. is unchanged for this case. Pinning so a future change can't
  131. accidentally widen the gate.
  132. """
  133. await _setup_auth_with_admin(async_client)
  134. admin = await _store_admin_cloud_token(db_session, "mwadmin", token="fake-bambu-token")
  135. key = await _make_key(db_session, owner=admin, name="status-no-cloud", can_access_cloud=False)
  136. resp = await async_client.get(
  137. "/api/v1/makerworld/status",
  138. headers={"X-API-Key": key},
  139. )
  140. assert resp.status_code == 200
  141. assert resp.json() == {"has_cloud_token": False, "can_download": False, "sign_in_expired": False}
  142. class TestResolveEndpoint:
  143. @pytest.mark.asyncio
  144. @pytest.mark.integration
  145. async def test_api_key_owner_with_token_builds_authed_service(
  146. self, async_client: AsyncClient, db_session: AsyncSession
  147. ):
  148. """The route must reach ``_build_service`` with the API-key owner's
  149. User, which is what ultimately seeds MakerWorldService.auth_token.
  150. We assert on the resolved user argument the route passes through —
  151. the upstream MakerWorld API call is mocked so the test stays offline.
  152. """
  153. await _setup_auth_with_admin(async_client)
  154. admin = await _store_admin_cloud_token(db_session, "mwadmin", token="fake-bambu-token")
  155. key = await _make_key(db_session, owner=admin, name="resolve-cloud")
  156. svc = _fake_service(
  157. resolve=ProviderResolvedModel(
  158. ref=ProviderResourceRef(source_type="makerworld", external_id="1400373"),
  159. design={"id": 1400373, "title": "Cube"},
  160. instances=[],
  161. )
  162. )
  163. build = AsyncMock(return_value=svc)
  164. with patch("backend.app.api.routes.makerworld._build_service", build):
  165. resp = await async_client.post(
  166. "/api/v1/makerworld/resolve",
  167. json={"url": "https://makerworld.com/en/models/1400373"},
  168. headers={"X-API-Key": key},
  169. )
  170. assert resp.status_code == 200, resp.text
  171. # _build_service receives (db, provider, current_user, api_key_cloud_owner).
  172. # Identity resolution lives in the provider now: for an API-keyed
  173. # call current_user is None by design and the key's owner must arrive
  174. # via api_key_cloud_owner — without the fix it'd be dropped entirely.
  175. assert build.await_count == 1
  176. jwt_user = (
  177. build.await_args.args[2] if len(build.await_args.args) > 2 else build.await_args.kwargs.get("current_user")
  178. )
  179. key_owner = (
  180. build.await_args.args[3]
  181. if len(build.await_args.args) > 3
  182. else build.await_args.kwargs.get("api_key_cloud_owner")
  183. )
  184. assert jwt_user is None, "API-keyed callers present no JWT user"
  185. assert key_owner is not None, "resolve_url must forward the API-key owner to the provider"
  186. assert key_owner.id == admin.id
  187. class TestImportEndpoint:
  188. @pytest.mark.asyncio
  189. @pytest.mark.integration
  190. async def test_api_key_owner_import_succeeds_and_stamps_owner_id(
  191. self, async_client: AsyncClient, db_session: AsyncSession
  192. ):
  193. """End-to-end: /import via X-API-Key downloads the 3MF and saves it
  194. with the API-key owner's id on the LibraryFile row, not NULL."""
  195. await _setup_auth_with_admin(async_client)
  196. admin = await _store_admin_cloud_token(db_session, "mwadmin", token="fake-bambu-token")
  197. key = await _make_key(db_session, owner=admin, name="import-cloud")
  198. # The 3MF bytes don't have to be a valid zip —
  199. # save_3mf_bytes_to_library stores them as-is and the downstream
  200. # thumbnail extractor swallows errors.
  201. svc = _fake_service(
  202. get_download=_download_info(),
  203. download=ProviderDownload(file_bytes=b"PK\x03\x04fake-3mf-bytes", filename="cube.3mf"),
  204. )
  205. with patch("backend.app.api.routes.makerworld._build_service", AsyncMock(return_value=svc)):
  206. resp = await async_client.post(
  207. "/api/v1/makerworld/import",
  208. json={"model_id": 1400373},
  209. headers={"X-API-Key": key},
  210. )
  211. assert resp.status_code == 200, resp.text
  212. body = resp.json()
  213. assert body["was_existing"] is False
  214. # The library row was attributed to the API-key owner.
  215. # save_3mf_bytes_to_library translates owner_id → created_by_id on the
  216. # LibraryFile column (see library.py:534).
  217. result = await db_session.execute(select(LibraryFile).where(LibraryFile.id == body["library_file_id"]))
  218. saved = result.scalar_one()
  219. assert saved.created_by_id == admin.id, "Import via API key must attribute the row to the key's owner, not NULL"
  220. @pytest.mark.asyncio
  221. @pytest.mark.integration
  222. async def test_api_key_without_cloud_scope_still_imports_but_owner_is_none(
  223. self, async_client: AsyncClient, db_session: AsyncSession
  224. ):
  225. """Fail-closed parity: a key with can_manage_library but NOT
  226. can_access_cloud reaches the route (permission gate passes) but
  227. the cloud-token resolver returns None, so the service is built
  228. without a token. The MakerWorldService itself would 401 on
  229. get_profile_download in production — here we just confirm the
  230. route doesn't suddenly grant cloud identity from a non-cloud key,
  231. and that the library row's owner_id stays NULL when there's no
  232. resolved cloud-scoped owner.
  233. """
  234. await _setup_auth_with_admin(async_client)
  235. admin = await _store_admin_cloud_token(db_session, "mwadmin", token="fake-bambu-token")
  236. key = await _make_key(db_session, owner=admin, name="import-no-cloud", can_access_cloud=False)
  237. svc = _fake_service(
  238. get_download=_download_info(),
  239. download=ProviderDownload(file_bytes=b"PK\x03\x04fake", filename="cube.3mf"),
  240. )
  241. with patch("backend.app.api.routes.makerworld._build_service", AsyncMock(return_value=svc)) as build:
  242. resp = await async_client.post(
  243. "/api/v1/makerworld/import",
  244. json={"model_id": 1400373},
  245. headers={"X-API-Key": key},
  246. )
  247. assert resp.status_code == 200, resp.text
  248. body = resp.json()
  249. # Both identity slots are None — same as before the PR for non-cloud
  250. # keys: no JWT user, and the cloud-scope fence keeps the key's owner
  251. # back, so the provider builds an anonymous service.
  252. jwt_user = (
  253. build.await_args.args[2] if len(build.await_args.args) > 2 else build.await_args.kwargs.get("current_user")
  254. )
  255. key_owner = (
  256. build.await_args.args[3]
  257. if len(build.await_args.args) > 3
  258. else build.await_args.kwargs.get("api_key_cloud_owner")
  259. )
  260. assert jwt_user is None
  261. assert key_owner is None
  262. # And owner_id is NULL because the cloud-scope fence said no.
  263. result = await db_session.execute(select(LibraryFile).where(LibraryFile.id == body["library_file_id"]))
  264. saved = result.scalar_one()
  265. assert saved.created_by_id is None
  266. class TestJwtPathUnchanged:
  267. """Parity check — the existing JWT-authed flow must keep behaving as
  268. it did. The added Depends(resolve_api_key_cloud_owner) returns None
  269. for JWT callers so current_user from RequirePermissionIfAuthEnabled
  270. wins the ``or`` and nothing about the JWT path changes."""
  271. @pytest.mark.asyncio
  272. @pytest.mark.integration
  273. async def test_status_with_jwt_admin_token(self, async_client: AsyncClient, db_session: AsyncSession):
  274. admin_token = await _setup_auth_with_admin(async_client)
  275. await _store_admin_cloud_token(db_session, "mwadmin", token="fake-bambu-token")
  276. resp = await async_client.get(
  277. "/api/v1/makerworld/status",
  278. headers={"Authorization": f"Bearer {admin_token}"},
  279. )
  280. assert resp.status_code == 200
  281. assert resp.json() == {"has_cloud_token": True, "can_download": True, "sign_in_expired": False}