test_manyfold_service.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418
  1. """The Manyfold client (#1471), against a fake Manyfold install."""
  2. from __future__ import annotations
  3. import httpx
  4. import pytest
  5. from backend.app.services.model_providers.manyfold import service as svc
  6. from backend.app.services.model_providers.manyfold.config import ManyfoldConfig, normalize_url
  7. from backend.app.services.model_providers.manyfold.service import (
  8. ManyfoldAuthError,
  9. ManyfoldNotFoundError,
  10. ManyfoldService,
  11. ManyfoldUnavailableError,
  12. )
  13. from backend.tests._fixtures.manyfold import BASE, PNG, STL, THREE_MF, FakeFile, FakeManyfold
  14. pytestmark = pytest.mark.unit
  15. @pytest.fixture(autouse=True)
  16. def fresh_tokens():
  17. svc.clear_token_cache()
  18. yield
  19. svc.clear_token_cache()
  20. @pytest.fixture
  21. def manyfold() -> FakeManyfold:
  22. fake = FakeManyfold()
  23. fake.add_model(
  24. "cube01",
  25. "Calibration Cube",
  26. {
  27. "f1": FakeFile("cube.stl", "model/stl", STL, render=PNG),
  28. "f2": FakeFile("cube.3mf", "model/3mf", THREE_MF),
  29. "f3": FakeFile("photo.jpg", "image/jpeg", b"\xff\xd8\xff" + b"\x00" * 40),
  30. "f4": FakeFile("notes.pdf", "application/pdf", b"%PDF-1.7"),
  31. },
  32. preview="f1",
  33. )
  34. fake.add_model("boat02", "Benchy", {"f9": FakeFile("benchy.stl", "model/stl", STL)})
  35. fake.add_model("vase03", "Spiral Vase", {})
  36. return fake
  37. def _service(fake: FakeManyfold, **overrides) -> ManyfoldService:
  38. config = ManyfoldConfig(
  39. url=overrides.get("url", BASE),
  40. client_id=overrides.get("client_id", fake.client_id),
  41. client_secret=overrides.get("client_secret", fake.client_secret),
  42. )
  43. return ManyfoldService(config, client=fake.client())
  44. class TestSignIn:
  45. @pytest.mark.asyncio
  46. async def test_one_token_serves_many_requests(self, manyfold):
  47. # Manyfold allows 10 sign-ins in 3 minutes; a page of previews must not use them up.
  48. service = _service(manyfold)
  49. for _ in range(5):
  50. await service.list_models()
  51. await _service(manyfold).get_model("cube01")
  52. assert manyfold.token_requests == 1
  53. @pytest.mark.asyncio
  54. async def test_asks_only_for_read_access(self, manyfold):
  55. await _service(manyfold).list_models()
  56. token_request = next(r for r in manyfold.requests if r.url.path == "/oauth/token")
  57. assert "scope=public+read" in token_request.content.decode()
  58. assert "grant_type=client_credentials" in token_request.content.decode()
  59. @pytest.mark.asyncio
  60. async def test_a_refused_token_is_renewed_once(self, manyfold):
  61. service = _service(manyfold)
  62. await service.list_models()
  63. manyfold.revoke_tokens() # e.g. Manyfold restarted, or the token was revoked
  64. result = await service.list_models()
  65. assert result["total"] == 3
  66. assert manyfold.token_requests == 2
  67. @pytest.mark.asyncio
  68. async def test_an_expired_token_is_renewed_before_use(self, manyfold):
  69. manyfold.token_lifetime = 60 # shorter than the renewal margin
  70. service = _service(manyfold)
  71. await service.list_models()
  72. await service.list_models()
  73. assert manyfold.token_requests == 2
  74. @pytest.mark.asyncio
  75. async def test_wrong_secret(self, manyfold):
  76. with pytest.raises(ManyfoldAuthError) as err:
  77. await _service(manyfold, client_secret="nope").list_models()
  78. assert err.value.code == "manyfold_credentials"
  79. @pytest.mark.asyncio
  80. async def test_a_refused_secret_is_not_tried_again_at_once(self, manyfold):
  81. # A page or a bulk import must not use up Manyfold's 10 sign-ins in 3 minutes.
  82. for _ in range(5):
  83. with pytest.raises(ManyfoldAuthError) as err:
  84. await _service(manyfold, client_secret="nope").list_models()
  85. assert err.value.code == "manyfold_credentials"
  86. assert manyfold.token_requests == 1
  87. # The right secret is a different sign-in and goes through at once.
  88. assert (await _service(manyfold).list_models())["total"] == 3
  89. assert manyfold.token_requests == 2
  90. @pytest.mark.asyncio
  91. async def test_saving_the_connection_forgets_a_refusal(self, manyfold):
  92. with pytest.raises(ManyfoldAuthError):
  93. await _service(manyfold, client_secret="nope").list_models()
  94. svc.clear_token_cache()
  95. with pytest.raises(ManyfoldAuthError):
  96. await _service(manyfold, client_secret="nope").list_models()
  97. assert manyfold.token_requests == 2
  98. @pytest.mark.asyncio
  99. async def test_a_401_from_object_storage_does_not_renew_the_token(self, manyfold):
  100. def answer(request: httpx.Request) -> httpx.Response:
  101. if request.url.host == "storage.example.com":
  102. return httpx.Response(401)
  103. if request.url.path == "/models/cube01/raw/cube.stl":
  104. return httpx.Response(302, headers={"Location": "https://storage.example.com/cube.stl"})
  105. return manyfold.handle(request)
  106. service = ManyfoldService(
  107. ManyfoldConfig(BASE, manyfold.client_id, manyfold.client_secret),
  108. client=httpx.AsyncClient(transport=httpx.MockTransport(answer)),
  109. )
  110. file = await service.get_file("cube01", "f1")
  111. with pytest.raises(ManyfoldAuthError):
  112. await service.download_file(file)
  113. assert manyfold.token_requests == 1
  114. @pytest.mark.asyncio
  115. async def test_application_without_read_scope(self, manyfold):
  116. manyfold.scopes = "public upload"
  117. with pytest.raises(ManyfoldAuthError) as err:
  118. await _service(manyfold).list_models()
  119. assert err.value.code == "manyfold_scope"
  120. @pytest.mark.asyncio
  121. async def test_rate_limited_sign_in(self):
  122. transport = httpx.MockTransport(lambda request: httpx.Response(429))
  123. service = ManyfoldService(ManyfoldConfig(BASE, "a", "b"), client=httpx.AsyncClient(transport=transport))
  124. with pytest.raises(ManyfoldUnavailableError) as err:
  125. await service.list_models()
  126. assert err.value.code == "manyfold_rate_limited"
  127. @pytest.mark.asyncio
  128. async def test_unreachable(self):
  129. def refuse(request):
  130. raise httpx.ConnectError("connection refused")
  131. service = ManyfoldService(
  132. ManyfoldConfig(BASE, "a", "b"), client=httpx.AsyncClient(transport=httpx.MockTransport(refuse))
  133. )
  134. with pytest.raises(ManyfoldUnavailableError) as err:
  135. await service.list_models()
  136. assert err.value.code == "manyfold_unreachable"
  137. @pytest.mark.asyncio
  138. async def test_not_configured(self, manyfold):
  139. with pytest.raises(ManyfoldAuthError) as err:
  140. await _service(manyfold, client_secret="").list_models()
  141. assert err.value.code == "manyfold_not_configured"
  142. assert manyfold.requests == []
  143. @pytest.mark.asyncio
  144. async def test_changed_credentials_get_their_own_token(self, manyfold):
  145. await _service(manyfold).list_models()
  146. other = FakeManyfold(client_id="other-id", client_secret="other-secret")
  147. other.models = manyfold.models
  148. await _service(other).list_models()
  149. assert other.token_requests == 1
  150. class TestBrowsing:
  151. @pytest.mark.asyncio
  152. async def test_list_pages_and_search(self, manyfold):
  153. service = _service(manyfold)
  154. first = await service.list_models()
  155. assert first == {
  156. "total": 3,
  157. "page": 1,
  158. "has_next": True,
  159. "has_previous": False,
  160. "models": [{"id": "cube01", "name": "Calibration Cube"}, {"id": "boat02", "name": "Benchy"}],
  161. }
  162. second = await service.list_models(page=2)
  163. assert second["models"] == [{"id": "vase03", "name": "Spiral Vase"}]
  164. assert second["has_previous"] and not second["has_next"]
  165. found = await service.list_models(query=" bench ")
  166. assert [m["id"] for m in found["models"]] == ["boat02"]
  167. assert manyfold.requests[-1].url.params["q"] == "bench"
  168. @pytest.mark.asyncio
  169. async def test_model_details(self, manyfold):
  170. model = await _service(manyfold).get_model("cube01")
  171. assert model["name"] == "Calibration Cube"
  172. assert model["license"] == "CC-BY-4.0"
  173. assert model["tags"] == ["test", "cube"]
  174. assert model["url"] == f"{BASE}/models/cube01"
  175. assert model["preview_file_id"] == "f1"
  176. assert [(f["id"], f["importable"]) for f in model["files"]] == [
  177. ("f1", True),
  178. ("f2", True),
  179. ("f3", False),
  180. ("f4", False),
  181. ]
  182. @pytest.mark.asyncio
  183. async def test_missing_model(self, manyfold):
  184. with pytest.raises(ManyfoldNotFoundError):
  185. await _service(manyfold).get_model("gone99")
  186. @pytest.mark.asyncio
  187. @pytest.mark.parametrize("bad", ["../etc", "a/b", "", "x" * 65, "a b", "a?b=1"])
  188. async def test_malformed_ids_never_reach_manyfold(self, manyfold, bad):
  189. with pytest.raises(ManyfoldNotFoundError):
  190. await _service(manyfold).get_model(bad)
  191. assert manyfold.requests == []
  192. @pytest.mark.asyncio
  193. async def test_sub_path_installs(self, manyfold):
  194. # Behind a reverse proxy at /manyfold: every request keeps the prefix.
  195. seen = []
  196. def strip_prefix(request: httpx.Request) -> httpx.Response:
  197. seen.append(request.url.path)
  198. stripped = request.url.copy_with(raw_path=request.url.raw_path.replace(b"/manyfold", b"", 1))
  199. return manyfold.handle(
  200. httpx.Request(request.method, stripped, headers=request.headers, content=request.content)
  201. )
  202. service = ManyfoldService(
  203. ManyfoldConfig(f"{BASE}/manyfold", manyfold.client_id, manyfold.client_secret),
  204. client=httpx.AsyncClient(transport=httpx.MockTransport(strip_prefix)),
  205. )
  206. await service.get_model("cube01")
  207. assert seen and all(path.startswith("/manyfold/") for path in seen)
  208. class TestFiles:
  209. @pytest.mark.asyncio
  210. async def test_download_uses_the_raw_link(self, manyfold):
  211. service = _service(manyfold)
  212. file = await service.get_file("cube01", "f1")
  213. assert file["filename"] == "cube.stl"
  214. assert file["raw_path"] == "/models/cube01/raw/cube.stl"
  215. assert await service.download_file(file) == STL
  216. @pytest.mark.asyncio
  217. async def test_names_with_spaces_and_folders(self, manyfold):
  218. manyfold.add_model("parts04", "Parts", {"p1": FakeFile("sub dir/My Part.stl", "model/stl", STL)})
  219. service = _service(manyfold)
  220. file = await service.get_file("parts04", "p1")
  221. assert file["filename"] == "My Part.stl"
  222. assert await service.download_file(file) == STL
  223. @pytest.mark.asyncio
  224. async def test_a_download_link_pointing_elsewhere_is_not_followed(self, manyfold):
  225. # contentUrl is only read for the path under /models/<id>/raw/.
  226. def answer(request: httpx.Request) -> httpx.Response:
  227. if request.url.path == "/models/cube01/model_files/f1":
  228. return httpx.Response(200, json={"contentUrl": "http://evil.test/models/other/raw/x.stl", "name": "x"})
  229. return manyfold.handle(request)
  230. service = ManyfoldService(
  231. ManyfoldConfig(BASE, manyfold.client_id, manyfold.client_secret),
  232. client=httpx.AsyncClient(transport=httpx.MockTransport(answer)),
  233. )
  234. with pytest.raises(ManyfoldUnavailableError):
  235. await service.get_file("cube01", "f1")
  236. @pytest.mark.asyncio
  237. async def test_dot_dot_in_the_link_is_refused(self, manyfold):
  238. def answer(request: httpx.Request) -> httpx.Response:
  239. if request.url.path == "/models/cube01/model_files/f1":
  240. return httpx.Response(200, json={"contentUrl": f"{BASE}/models/cube01/raw/..%2F..%2Fsecrets"})
  241. return manyfold.handle(request)
  242. service = ManyfoldService(
  243. ManyfoldConfig(BASE, manyfold.client_id, manyfold.client_secret),
  244. client=httpx.AsyncClient(transport=httpx.MockTransport(answer)),
  245. )
  246. with pytest.raises(ManyfoldUnavailableError):
  247. await service.get_file("cube01", "f1")
  248. @pytest.mark.asyncio
  249. async def test_object_storage_redirect_is_followed_without_the_token(self, manyfold):
  250. # Manyfold on S3-style storage answers a download with a redirect.
  251. seen: list[httpx.Request] = []
  252. def answer(request: httpx.Request) -> httpx.Response:
  253. seen.append(request)
  254. if request.url.host == "storage.example.com":
  255. return httpx.Response(200, content=STL)
  256. if request.url.path == "/models/cube01/raw/cube.stl":
  257. return httpx.Response(302, headers={"Location": "https://storage.example.com/bucket/cube.stl?sig=x"})
  258. return manyfold.handle(request)
  259. service = ManyfoldService(
  260. ManyfoldConfig(BASE, manyfold.client_id, manyfold.client_secret),
  261. client=httpx.AsyncClient(transport=httpx.MockTransport(answer)),
  262. )
  263. file = await service.get_file("cube01", "f1")
  264. assert await service.download_file(file) == STL
  265. storage = [r for r in seen if r.url.host == "storage.example.com"]
  266. assert storage and "Authorization" not in storage[0].headers
  267. @pytest.mark.asyncio
  268. @pytest.mark.parametrize(
  269. "target", ["http://169.254.169.254/latest/meta-data/", "http://metadata.google.internal/", "file:///etc/passwd"]
  270. )
  271. async def test_a_redirect_to_a_dangerous_target_is_refused(self, manyfold, target):
  272. seen: list[httpx.Request] = []
  273. def answer(request: httpx.Request) -> httpx.Response:
  274. seen.append(request)
  275. if request.url.path == "/models/cube01/raw/cube.stl":
  276. return httpx.Response(302, headers={"Location": target})
  277. return manyfold.handle(request)
  278. service = ManyfoldService(
  279. ManyfoldConfig(BASE, manyfold.client_id, manyfold.client_secret),
  280. client=httpx.AsyncClient(transport=httpx.MockTransport(answer)),
  281. )
  282. file = await service.get_file("cube01", "f1")
  283. with pytest.raises(ManyfoldUnavailableError):
  284. await service.download_file(file)
  285. assert all(r.url.host == "manyfold.test" for r in seen)
  286. @pytest.mark.asyncio
  287. async def test_endless_redirects_stop(self, manyfold):
  288. def answer(request: httpx.Request) -> httpx.Response:
  289. if "/raw/" in request.url.path:
  290. return httpx.Response(302, headers={"Location": f"{BASE}/models/cube01/raw/cube.stl"})
  291. return manyfold.handle(request)
  292. service = ManyfoldService(
  293. ManyfoldConfig(BASE, manyfold.client_id, manyfold.client_secret),
  294. client=httpx.AsyncClient(transport=httpx.MockTransport(answer)),
  295. )
  296. file = await service.get_file("cube01", "f1")
  297. with pytest.raises(ManyfoldUnavailableError):
  298. await service.download_file(file)
  299. @pytest.mark.asyncio
  300. async def test_oversized_file_is_refused(self, manyfold, monkeypatch):
  301. monkeypatch.setattr(svc, "MAX_FILE_BYTES", 50)
  302. service = _service(manyfold)
  303. file = await service.get_file("cube01", "f1")
  304. with pytest.raises(ManyfoldUnavailableError) as err:
  305. await service.download_file(file)
  306. assert err.value.code == "manyfold_too_large"
  307. class TestPreviews:
  308. @pytest.mark.asyncio
  309. async def test_rendered_preview_of_a_3d_file(self, manyfold):
  310. data, content_type = await _service(manyfold).fetch_preview("cube01")
  311. assert (data, content_type) == (PNG, "image/png")
  312. preview_request = manyfold.requests[-1]
  313. assert preview_request.url.path == "/models/cube01/model_files/f1.stl"
  314. assert preview_request.url.params["derivative"] == "render"
  315. @pytest.mark.asyncio
  316. async def test_image_preview_asks_for_the_small_copy(self, manyfold):
  317. manyfold.models["cube01"]["preview"] = "f3"
  318. data, content_type = await _service(manyfold).fetch_preview("cube01")
  319. assert content_type == "image/jpeg"
  320. assert manyfold.requests[-1].url.params["derivative"] == "preview"
  321. @pytest.mark.asyncio
  322. async def test_no_render_means_no_preview_not_the_whole_stl(self, manyfold):
  323. # Without a render Manyfold sends the original file: refuse it, don't pass an STL off as an image.
  324. manyfold.models["cube01"]["files"]["f1"].render = None
  325. with pytest.raises(ManyfoldNotFoundError):
  326. await _service(manyfold).fetch_preview("cube01")
  327. @pytest.mark.asyncio
  328. async def test_model_without_preview(self, manyfold):
  329. with pytest.raises(ManyfoldNotFoundError):
  330. await _service(manyfold).fetch_preview("boat02")
  331. class TestUrl:
  332. @pytest.mark.parametrize(
  333. ("raw", "expected"),
  334. [
  335. ("http://docker:3214", "http://docker:3214"),
  336. (" https://models.example.com/ ", "https://models.example.com"),
  337. ("https://example.com/manyfold/", "https://example.com/manyfold"),
  338. ],
  339. )
  340. def test_accepted(self, raw, expected):
  341. assert normalize_url(raw) == expected
  342. @pytest.mark.parametrize(
  343. "raw",
  344. [
  345. "docker:3214",
  346. "ftp://docker",
  347. "http://",
  348. "https://user:pw@host",
  349. "http://host/?a=1",
  350. "http://host/#x",
  351. "",
  352. # The LAN-service tier: never a Manyfold, under any topology.
  353. "http://169.254.169.254/",
  354. "http://metadata.google.internal/",
  355. "http://2130706433/",
  356. ],
  357. )
  358. def test_refused(self, raw):
  359. with pytest.raises(ValueError):
  360. normalize_url(raw)