test_makerworld_apikey_auth.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302
  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. async def _setup_auth_with_admin(client: AsyncClient) -> str:
  25. await client.post(
  26. "/api/v1/auth/setup",
  27. json={
  28. "auth_enabled": True,
  29. "admin_username": "mwadmin",
  30. "admin_password": "AdminPass1!",
  31. },
  32. )
  33. login = await client.post(
  34. "/api/v1/auth/login",
  35. json={"username": "mwadmin", "password": "AdminPass1!"},
  36. )
  37. return login.json()["access_token"]
  38. async def _store_admin_cloud_token(db: AsyncSession, username: str, token: str) -> User:
  39. result = await db.execute(select(User).where(User.username == username))
  40. user = result.scalar_one()
  41. user.cloud_token = token
  42. user.cloud_email = "owner@example.com"
  43. user.cloud_region = "global"
  44. await db.commit()
  45. await db.refresh(user)
  46. return user
  47. async def _make_key(
  48. db: AsyncSession,
  49. *,
  50. owner: User,
  51. name: str,
  52. can_access_cloud: bool = True,
  53. can_read_status: bool = True,
  54. can_manage_library: bool = True,
  55. ) -> str:
  56. """Mint an API key with the scopes /makerworld/* expects.
  57. /status + /resolve gate on ``Permission.MAKERWORLD_VIEW`` which maps
  58. to the ``can_read_status`` scope (see ``_APIKEY_SCOPE_BY_PERMISSION``
  59. in core/auth.py). /import gates on ``Permission.MAKERWORLD_IMPORT``
  60. which maps to ``can_manage_library``. ``can_access_cloud`` is what
  61. ``resolve_api_key_cloud_owner`` checks before returning the owner —
  62. the separate field this PR's fix actually depends on.
  63. """
  64. full_key, key_hash, key_prefix = generate_api_key()
  65. row = APIKey(
  66. name=name,
  67. key_hash=key_hash,
  68. key_prefix=key_prefix,
  69. user_id=owner.id,
  70. can_access_cloud=can_access_cloud,
  71. can_read_status=can_read_status,
  72. can_manage_library=can_manage_library,
  73. )
  74. db.add(row)
  75. await db.commit()
  76. return full_key
  77. def _fake_service(**stubs):
  78. """Mirror of the fixture in test_makerworld_routes.py — AsyncMock with
  79. method stubs that return the supplied payloads."""
  80. svc = AsyncMock()
  81. svc.close = AsyncMock()
  82. for name, value in stubs.items():
  83. if callable(value) and not isinstance(value, AsyncMock):
  84. setattr(svc, name, AsyncMock(side_effect=value))
  85. else:
  86. setattr(svc, name, AsyncMock(return_value=value))
  87. return svc
  88. class TestStatusEndpoint:
  89. @pytest.mark.asyncio
  90. @pytest.mark.integration
  91. async def test_api_key_owner_with_token_sees_has_cloud_token_true(
  92. self, async_client: AsyncClient, db_session: AsyncSession
  93. ):
  94. await _setup_auth_with_admin(async_client)
  95. admin = await _store_admin_cloud_token(db_session, "mwadmin", token="fake-bambu-token")
  96. key = await _make_key(db_session, owner=admin, name="status-cloud")
  97. resp = await async_client.get(
  98. "/api/v1/makerworld/status",
  99. headers={"X-API-Key": key},
  100. )
  101. assert resp.status_code == 200, resp.text
  102. assert resp.json() == {"has_cloud_token": True, "can_download": True}
  103. @pytest.mark.asyncio
  104. @pytest.mark.integration
  105. async def test_api_key_without_cloud_scope_reports_no_token(
  106. self, async_client: AsyncClient, db_session: AsyncSession
  107. ):
  108. """Key has the per-route scope (can_read_status) but NOT can_access_cloud.
  109. Before this PR, both these conditions reported has_cloud_token=False.
  110. After the PR the per-route scope alone still doesn't grant cloud
  111. access — the resolver fences on can_access_cloud — so the response
  112. is unchanged for this case. Pinning so a future change can't
  113. accidentally widen the gate.
  114. """
  115. await _setup_auth_with_admin(async_client)
  116. admin = await _store_admin_cloud_token(db_session, "mwadmin", token="fake-bambu-token")
  117. key = await _make_key(db_session, owner=admin, name="status-no-cloud", can_access_cloud=False)
  118. resp = await async_client.get(
  119. "/api/v1/makerworld/status",
  120. headers={"X-API-Key": key},
  121. )
  122. assert resp.status_code == 200
  123. assert resp.json() == {"has_cloud_token": False, "can_download": False}
  124. class TestResolveEndpoint:
  125. @pytest.mark.asyncio
  126. @pytest.mark.integration
  127. async def test_api_key_owner_with_token_builds_authed_service(
  128. self, async_client: AsyncClient, db_session: AsyncSession
  129. ):
  130. """The route must reach ``_build_service`` with the API-key owner's
  131. User, which is what ultimately seeds MakerWorldService.auth_token.
  132. We assert on the resolved user argument the route passes through —
  133. the upstream MakerWorld API call is mocked so the test stays offline.
  134. """
  135. await _setup_auth_with_admin(async_client)
  136. admin = await _store_admin_cloud_token(db_session, "mwadmin", token="fake-bambu-token")
  137. key = await _make_key(db_session, owner=admin, name="resolve-cloud")
  138. design = {"id": 1400373, "modelId": "US2bb73b106683e5", "title": "Cube", "instances": []}
  139. instances = {"total": 0, "hits": []}
  140. svc = _fake_service(get_design=design, get_design_instances=instances)
  141. build = AsyncMock(return_value=svc)
  142. with patch("backend.app.api.routes.makerworld._build_service", build):
  143. resp = await async_client.post(
  144. "/api/v1/makerworld/resolve",
  145. json={"url": "https://makerworld.com/en/models/1400373"},
  146. headers={"X-API-Key": key},
  147. )
  148. assert resp.status_code == 200, resp.text
  149. # _build_service receives (db, user); the user arg must be the owning admin.
  150. # Without the fix it'd be None (the API-key dep value).
  151. assert build.await_count == 1
  152. passed_user = (
  153. build.await_args.args[1] if len(build.await_args.args) > 1 else build.await_args.kwargs.get("user")
  154. )
  155. assert passed_user is not None, "resolve_url must pass the API-key owner, not None"
  156. assert passed_user.id == admin.id
  157. class TestImportEndpoint:
  158. @pytest.mark.asyncio
  159. @pytest.mark.integration
  160. async def test_api_key_owner_import_succeeds_and_stamps_owner_id(
  161. self, async_client: AsyncClient, db_session: AsyncSession
  162. ):
  163. """End-to-end: /import via X-API-Key downloads the 3MF and saves it
  164. with the API-key owner's id on the LibraryFile row, not NULL."""
  165. await _setup_auth_with_admin(async_client)
  166. admin = await _store_admin_cloud_token(db_session, "mwadmin", token="fake-bambu-token")
  167. key = await _make_key(db_session, owner=admin, name="import-cloud")
  168. design = {
  169. "id": 1400373,
  170. "modelId": "US2bb73b106683e5",
  171. "title": "Cube",
  172. "instances": [{"profileId": 298919107, "title": "default"}],
  173. }
  174. manifest = {
  175. "name": "cube.3mf",
  176. "url": "https://makerworld.bblmw.com/makerworld/model/X/Y/cube.3mf?exp=1&key=k",
  177. }
  178. # 3MF download returns (bytes, filename). The bytes don't have to be a
  179. # valid zip — save_3mf_bytes_to_library stores them as-is and the
  180. # downstream thumbnail extractor swallows errors.
  181. svc = _fake_service(
  182. get_design=design,
  183. get_profile_download=manifest,
  184. download_3mf=(b"PK\x03\x04fake-3mf-bytes", "cube.3mf"),
  185. )
  186. with patch("backend.app.api.routes.makerworld._build_service", AsyncMock(return_value=svc)):
  187. resp = await async_client.post(
  188. "/api/v1/makerworld/import",
  189. json={"model_id": 1400373},
  190. headers={"X-API-Key": key},
  191. )
  192. assert resp.status_code == 200, resp.text
  193. body = resp.json()
  194. assert body["was_existing"] is False
  195. # The library row was attributed to the API-key owner.
  196. # save_3mf_bytes_to_library translates owner_id → created_by_id on the
  197. # LibraryFile column (see library.py:534).
  198. result = await db_session.execute(select(LibraryFile).where(LibraryFile.id == body["library_file_id"]))
  199. saved = result.scalar_one()
  200. assert saved.created_by_id == admin.id, "Import via API key must attribute the row to the key's owner, not NULL"
  201. @pytest.mark.asyncio
  202. @pytest.mark.integration
  203. async def test_api_key_without_cloud_scope_still_imports_but_owner_is_none(
  204. self, async_client: AsyncClient, db_session: AsyncSession
  205. ):
  206. """Fail-closed parity: a key with can_manage_library but NOT
  207. can_access_cloud reaches the route (permission gate passes) but
  208. the cloud-token resolver returns None, so the service is built
  209. without a token. The MakerWorldService itself would 401 on
  210. get_profile_download in production — here we just confirm the
  211. route doesn't suddenly grant cloud identity from a non-cloud key,
  212. and that the library row's owner_id stays NULL when there's no
  213. resolved cloud-scoped owner.
  214. """
  215. await _setup_auth_with_admin(async_client)
  216. admin = await _store_admin_cloud_token(db_session, "mwadmin", token="fake-bambu-token")
  217. key = await _make_key(db_session, owner=admin, name="import-no-cloud", can_access_cloud=False)
  218. design = {
  219. "id": 1400373,
  220. "modelId": "US2bb73b106683e5",
  221. "instances": [{"profileId": 298919107}],
  222. }
  223. manifest = {"name": "cube.3mf", "url": "https://makerworld.bblmw.com/x.3mf"}
  224. svc = _fake_service(
  225. get_design=design,
  226. get_profile_download=manifest,
  227. download_3mf=(b"PK\x03\x04fake", "cube.3mf"),
  228. )
  229. with patch("backend.app.api.routes.makerworld._build_service", AsyncMock(return_value=svc)) as build:
  230. resp = await async_client.post(
  231. "/api/v1/makerworld/import",
  232. json={"model_id": 1400373},
  233. headers={"X-API-Key": key},
  234. )
  235. assert resp.status_code == 200, resp.text
  236. body = resp.json()
  237. # _build_service got None — same as before the PR for non-cloud keys.
  238. passed_user = (
  239. build.await_args.args[1] if len(build.await_args.args) > 1 else build.await_args.kwargs.get("user")
  240. )
  241. assert passed_user is None
  242. # And owner_id is NULL because the cloud-scope fence said no.
  243. result = await db_session.execute(select(LibraryFile).where(LibraryFile.id == body["library_file_id"]))
  244. saved = result.scalar_one()
  245. assert saved.created_by_id is None
  246. class TestJwtPathUnchanged:
  247. """Parity check — the existing JWT-authed flow must keep behaving as
  248. it did. The added Depends(resolve_api_key_cloud_owner) returns None
  249. for JWT callers so current_user from RequirePermissionIfAuthEnabled
  250. wins the ``or`` and nothing about the JWT path changes."""
  251. @pytest.mark.asyncio
  252. @pytest.mark.integration
  253. async def test_status_with_jwt_admin_token(self, async_client: AsyncClient, db_session: AsyncSession):
  254. admin_token = await _setup_auth_with_admin(async_client)
  255. await _store_admin_cloud_token(db_session, "mwadmin", token="fake-bambu-token")
  256. resp = await async_client.get(
  257. "/api/v1/makerworld/status",
  258. headers={"Authorization": f"Bearer {admin_token}"},
  259. )
  260. assert resp.status_code == 200
  261. assert resp.json() == {"has_cloud_token": True, "can_download": True}