test_makerworld_permission_gate.py 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180
  1. """The /makerworld/* permission gate with auth enabled.
  2. The gate moved out of the route signature and into the handler: the provider
  3. that a request actually uses comes from the body (``source_type`` on import,
  4. the pasted URL on resolve), and FastAPI resolves dependencies before the body
  5. exists, so a dependency could only ever name one provider's permission. What
  6. must not change is the enforcement itself, so these pin the outcomes rather
  7. than the wiring: anonymous callers are still refused before the body is read,
  8. and a signed-in user without the permission still gets a 403.
  9. """
  10. from __future__ import annotations
  11. from unittest.mock import AsyncMock, patch
  12. import pytest
  13. from httpx import AsyncClient
  14. from backend.app.services.model_providers.base import (
  15. ProviderDownload,
  16. ProviderDownloadInfo,
  17. ProviderResolvedModel,
  18. ProviderResourceRef,
  19. )
  20. async def _setup_auth_with_admin(client: AsyncClient) -> str:
  21. await client.post(
  22. "/api/v1/auth/setup",
  23. json={"auth_enabled": True, "admin_username": "mwadmin", "admin_password": "AdminPass1!"},
  24. )
  25. login = await client.post("/api/v1/auth/login", json={"username": "mwadmin", "password": "AdminPass1!"})
  26. assert login.status_code == 200, login.text
  27. return login.json()["access_token"]
  28. async def _make_user(client: AsyncClient, admin_jwt: str, *, username: str, permissions: list[str]) -> str:
  29. """Create a user in a fresh group holding exactly *permissions*."""
  30. group = await client.post(
  31. "/api/v1/groups/",
  32. headers={"Authorization": f"Bearer {admin_jwt}"},
  33. json={"name": f"grp_{username}", "permissions": permissions},
  34. )
  35. assert group.status_code in (200, 201), group.text
  36. created = await client.post(
  37. "/api/v1/users/",
  38. headers={"Authorization": f"Bearer {admin_jwt}"},
  39. json={"username": username, "password": "UserPass1!", "group_ids": [group.json()["id"]]},
  40. )
  41. assert created.status_code in (200, 201), created.text
  42. login = await client.post("/api/v1/auth/login", json={"username": username, "password": "UserPass1!"})
  43. assert login.status_code == 200, login.text
  44. return login.json()["access_token"]
  45. def _fake_service(**stubs):
  46. svc = AsyncMock()
  47. svc.close = AsyncMock()
  48. for name, value in stubs.items():
  49. setattr(svc, name, AsyncMock(return_value=value))
  50. return svc
  51. def _import_service():
  52. return _fake_service(
  53. get_download=ProviderDownloadInfo(
  54. ref=ProviderResourceRef(source_type="makerworld", external_id="1400373", sub_id="298919107"),
  55. url="https://makerworld.bblmw.com/makerworld/model/X/Y/cube.3mf?exp=1&key=k",
  56. suggested_filename="cube.3mf",
  57. ),
  58. download=ProviderDownload(file_bytes=b"PK\x03\x04fake-3mf-bytes", filename="cube.3mf"),
  59. )
  60. class TestAnonymousIsRefusedFirst:
  61. @pytest.mark.asyncio
  62. @pytest.mark.integration
  63. async def test_anonymous_import_is_401(self, async_client: AsyncClient):
  64. await _setup_auth_with_admin(async_client)
  65. resp = await async_client.post("/api/v1/makerworld/import", json={"model_id": 1400373})
  66. assert resp.status_code == 401, resp.text
  67. @pytest.mark.asyncio
  68. @pytest.mark.integration
  69. async def test_anonymous_resolve_is_401(self, async_client: AsyncClient):
  70. await _setup_auth_with_admin(async_client)
  71. resp = await async_client.post(
  72. "/api/v1/makerworld/resolve",
  73. json={"url": "https://makerworld.com/en/models/1400373"},
  74. )
  75. assert resp.status_code == 401, resp.text
  76. @pytest.mark.asyncio
  77. @pytest.mark.integration
  78. async def test_anonymous_with_a_malformed_body_is_still_401_not_422(self, async_client: AsyncClient):
  79. """The permission moved into the handler, but authentication stayed a
  80. route dependency precisely so an unauthenticated caller cannot probe
  81. the request schema through validation errors."""
  82. await _setup_auth_with_admin(async_client)
  83. resp = await async_client.post("/api/v1/makerworld/import", json={"nonsense": True})
  84. assert resp.status_code == 401, resp.text
  85. class TestPermissionStillBites:
  86. @pytest.mark.asyncio
  87. @pytest.mark.integration
  88. async def test_view_only_user_cannot_import(self, async_client: AsyncClient):
  89. admin = await _setup_auth_with_admin(async_client)
  90. jwt = await _make_user(async_client, admin, username="mwviewer", permissions=["makerworld:view"])
  91. with patch(
  92. "backend.app.api.routes.makerworld._build_service",
  93. AsyncMock(return_value=_import_service()),
  94. ):
  95. resp = await async_client.post(
  96. "/api/v1/makerworld/import",
  97. json={"model_id": 1400373},
  98. headers={"Authorization": f"Bearer {jwt}"},
  99. )
  100. assert resp.status_code == 403, resp.text
  101. assert "makerworld:import" in resp.json()["detail"]
  102. @pytest.mark.asyncio
  103. @pytest.mark.integration
  104. async def test_user_without_view_cannot_resolve(self, async_client: AsyncClient):
  105. admin = await _setup_auth_with_admin(async_client)
  106. jwt = await _make_user(async_client, admin, username="mwnoview", permissions=["printers:read"])
  107. resp = await async_client.post(
  108. "/api/v1/makerworld/resolve",
  109. json={"url": "https://makerworld.com/en/models/1400373"},
  110. headers={"Authorization": f"Bearer {jwt}"},
  111. )
  112. assert resp.status_code == 403, resp.text
  113. assert "makerworld:view" in resp.json()["detail"]
  114. @pytest.mark.asyncio
  115. @pytest.mark.integration
  116. async def test_user_holding_the_permission_gets_through(self, async_client: AsyncClient):
  117. admin = await _setup_auth_with_admin(async_client)
  118. jwt = await _make_user(
  119. async_client,
  120. admin,
  121. username="mwimporter",
  122. permissions=["makerworld:view", "makerworld:import"],
  123. )
  124. with patch(
  125. "backend.app.api.routes.makerworld._build_service",
  126. AsyncMock(return_value=_import_service()),
  127. ):
  128. resp = await async_client.post(
  129. "/api/v1/makerworld/import",
  130. json={"model_id": 1400373},
  131. headers={"Authorization": f"Bearer {jwt}"},
  132. )
  133. assert resp.status_code == 200, resp.text
  134. assert resp.json()["was_existing"] is False
  135. @pytest.mark.asyncio
  136. @pytest.mark.integration
  137. async def test_resolve_passes_for_a_viewer(self, async_client: AsyncClient):
  138. admin = await _setup_auth_with_admin(async_client)
  139. jwt = await _make_user(async_client, admin, username="mwviewer2", permissions=["makerworld:view"])
  140. svc = _fake_service(
  141. resolve=ProviderResolvedModel(
  142. ref=ProviderResourceRef(source_type="makerworld", external_id="1400373"),
  143. design={"id": 1400373},
  144. instances=[],
  145. )
  146. )
  147. with patch("backend.app.api.routes.makerworld._build_service", AsyncMock(return_value=svc)):
  148. resp = await async_client.post(
  149. "/api/v1/makerworld/resolve",
  150. json={"url": "https://makerworld.com/en/models/1400373"},
  151. headers={"Authorization": f"Bearer {jwt}"},
  152. )
  153. assert resp.status_code == 200, resp.text
  154. assert resp.json()["model_id"] == 1400373