| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418 |
- """The Manyfold client (#1471), against a fake Manyfold install."""
- from __future__ import annotations
- import httpx
- import pytest
- from backend.app.services.model_providers.manyfold import service as svc
- from backend.app.services.model_providers.manyfold.config import ManyfoldConfig, normalize_url
- from backend.app.services.model_providers.manyfold.service import (
- ManyfoldAuthError,
- ManyfoldNotFoundError,
- ManyfoldService,
- ManyfoldUnavailableError,
- )
- from backend.tests._fixtures.manyfold import BASE, PNG, STL, THREE_MF, FakeFile, FakeManyfold
- pytestmark = pytest.mark.unit
- @pytest.fixture(autouse=True)
- def fresh_tokens():
- svc.clear_token_cache()
- yield
- svc.clear_token_cache()
- @pytest.fixture
- def manyfold() -> FakeManyfold:
- fake = FakeManyfold()
- fake.add_model(
- "cube01",
- "Calibration Cube",
- {
- "f1": FakeFile("cube.stl", "model/stl", STL, render=PNG),
- "f2": FakeFile("cube.3mf", "model/3mf", THREE_MF),
- "f3": FakeFile("photo.jpg", "image/jpeg", b"\xff\xd8\xff" + b"\x00" * 40),
- "f4": FakeFile("notes.pdf", "application/pdf", b"%PDF-1.7"),
- },
- preview="f1",
- )
- fake.add_model("boat02", "Benchy", {"f9": FakeFile("benchy.stl", "model/stl", STL)})
- fake.add_model("vase03", "Spiral Vase", {})
- return fake
- def _service(fake: FakeManyfold, **overrides) -> ManyfoldService:
- config = ManyfoldConfig(
- url=overrides.get("url", BASE),
- client_id=overrides.get("client_id", fake.client_id),
- client_secret=overrides.get("client_secret", fake.client_secret),
- )
- return ManyfoldService(config, client=fake.client())
- class TestSignIn:
- @pytest.mark.asyncio
- async def test_one_token_serves_many_requests(self, manyfold):
- # Manyfold allows 10 sign-ins in 3 minutes; a page of previews must not use them up.
- service = _service(manyfold)
- for _ in range(5):
- await service.list_models()
- await _service(manyfold).get_model("cube01")
- assert manyfold.token_requests == 1
- @pytest.mark.asyncio
- async def test_asks_only_for_read_access(self, manyfold):
- await _service(manyfold).list_models()
- token_request = next(r for r in manyfold.requests if r.url.path == "/oauth/token")
- assert "scope=public+read" in token_request.content.decode()
- assert "grant_type=client_credentials" in token_request.content.decode()
- @pytest.mark.asyncio
- async def test_a_refused_token_is_renewed_once(self, manyfold):
- service = _service(manyfold)
- await service.list_models()
- manyfold.revoke_tokens() # e.g. Manyfold restarted, or the token was revoked
- result = await service.list_models()
- assert result["total"] == 3
- assert manyfold.token_requests == 2
- @pytest.mark.asyncio
- async def test_an_expired_token_is_renewed_before_use(self, manyfold):
- manyfold.token_lifetime = 60 # shorter than the renewal margin
- service = _service(manyfold)
- await service.list_models()
- await service.list_models()
- assert manyfold.token_requests == 2
- @pytest.mark.asyncio
- async def test_wrong_secret(self, manyfold):
- with pytest.raises(ManyfoldAuthError) as err:
- await _service(manyfold, client_secret="nope").list_models()
- assert err.value.code == "manyfold_credentials"
- @pytest.mark.asyncio
- async def test_a_refused_secret_is_not_tried_again_at_once(self, manyfold):
- # A page or a bulk import must not use up Manyfold's 10 sign-ins in 3 minutes.
- for _ in range(5):
- with pytest.raises(ManyfoldAuthError) as err:
- await _service(manyfold, client_secret="nope").list_models()
- assert err.value.code == "manyfold_credentials"
- assert manyfold.token_requests == 1
- # The right secret is a different sign-in and goes through at once.
- assert (await _service(manyfold).list_models())["total"] == 3
- assert manyfold.token_requests == 2
- @pytest.mark.asyncio
- async def test_saving_the_connection_forgets_a_refusal(self, manyfold):
- with pytest.raises(ManyfoldAuthError):
- await _service(manyfold, client_secret="nope").list_models()
- svc.clear_token_cache()
- with pytest.raises(ManyfoldAuthError):
- await _service(manyfold, client_secret="nope").list_models()
- assert manyfold.token_requests == 2
- @pytest.mark.asyncio
- async def test_a_401_from_object_storage_does_not_renew_the_token(self, manyfold):
- def answer(request: httpx.Request) -> httpx.Response:
- if request.url.host == "storage.example.com":
- return httpx.Response(401)
- if request.url.path == "/models/cube01/raw/cube.stl":
- return httpx.Response(302, headers={"Location": "https://storage.example.com/cube.stl"})
- return manyfold.handle(request)
- service = ManyfoldService(
- ManyfoldConfig(BASE, manyfold.client_id, manyfold.client_secret),
- client=httpx.AsyncClient(transport=httpx.MockTransport(answer)),
- )
- file = await service.get_file("cube01", "f1")
- with pytest.raises(ManyfoldAuthError):
- await service.download_file(file)
- assert manyfold.token_requests == 1
- @pytest.mark.asyncio
- async def test_application_without_read_scope(self, manyfold):
- manyfold.scopes = "public upload"
- with pytest.raises(ManyfoldAuthError) as err:
- await _service(manyfold).list_models()
- assert err.value.code == "manyfold_scope"
- @pytest.mark.asyncio
- async def test_rate_limited_sign_in(self):
- transport = httpx.MockTransport(lambda request: httpx.Response(429))
- service = ManyfoldService(ManyfoldConfig(BASE, "a", "b"), client=httpx.AsyncClient(transport=transport))
- with pytest.raises(ManyfoldUnavailableError) as err:
- await service.list_models()
- assert err.value.code == "manyfold_rate_limited"
- @pytest.mark.asyncio
- async def test_unreachable(self):
- def refuse(request):
- raise httpx.ConnectError("connection refused")
- service = ManyfoldService(
- ManyfoldConfig(BASE, "a", "b"), client=httpx.AsyncClient(transport=httpx.MockTransport(refuse))
- )
- with pytest.raises(ManyfoldUnavailableError) as err:
- await service.list_models()
- assert err.value.code == "manyfold_unreachable"
- @pytest.mark.asyncio
- async def test_not_configured(self, manyfold):
- with pytest.raises(ManyfoldAuthError) as err:
- await _service(manyfold, client_secret="").list_models()
- assert err.value.code == "manyfold_not_configured"
- assert manyfold.requests == []
- @pytest.mark.asyncio
- async def test_changed_credentials_get_their_own_token(self, manyfold):
- await _service(manyfold).list_models()
- other = FakeManyfold(client_id="other-id", client_secret="other-secret")
- other.models = manyfold.models
- await _service(other).list_models()
- assert other.token_requests == 1
- class TestBrowsing:
- @pytest.mark.asyncio
- async def test_list_pages_and_search(self, manyfold):
- service = _service(manyfold)
- first = await service.list_models()
- assert first == {
- "total": 3,
- "page": 1,
- "has_next": True,
- "has_previous": False,
- "models": [{"id": "cube01", "name": "Calibration Cube"}, {"id": "boat02", "name": "Benchy"}],
- }
- second = await service.list_models(page=2)
- assert second["models"] == [{"id": "vase03", "name": "Spiral Vase"}]
- assert second["has_previous"] and not second["has_next"]
- found = await service.list_models(query=" bench ")
- assert [m["id"] for m in found["models"]] == ["boat02"]
- assert manyfold.requests[-1].url.params["q"] == "bench"
- @pytest.mark.asyncio
- async def test_model_details(self, manyfold):
- model = await _service(manyfold).get_model("cube01")
- assert model["name"] == "Calibration Cube"
- assert model["license"] == "CC-BY-4.0"
- assert model["tags"] == ["test", "cube"]
- assert model["url"] == f"{BASE}/models/cube01"
- assert model["preview_file_id"] == "f1"
- assert [(f["id"], f["importable"]) for f in model["files"]] == [
- ("f1", True),
- ("f2", True),
- ("f3", False),
- ("f4", False),
- ]
- @pytest.mark.asyncio
- async def test_missing_model(self, manyfold):
- with pytest.raises(ManyfoldNotFoundError):
- await _service(manyfold).get_model("gone99")
- @pytest.mark.asyncio
- @pytest.mark.parametrize("bad", ["../etc", "a/b", "", "x" * 65, "a b", "a?b=1"])
- async def test_malformed_ids_never_reach_manyfold(self, manyfold, bad):
- with pytest.raises(ManyfoldNotFoundError):
- await _service(manyfold).get_model(bad)
- assert manyfold.requests == []
- @pytest.mark.asyncio
- async def test_sub_path_installs(self, manyfold):
- # Behind a reverse proxy at /manyfold: every request keeps the prefix.
- seen = []
- def strip_prefix(request: httpx.Request) -> httpx.Response:
- seen.append(request.url.path)
- stripped = request.url.copy_with(raw_path=request.url.raw_path.replace(b"/manyfold", b"", 1))
- return manyfold.handle(
- httpx.Request(request.method, stripped, headers=request.headers, content=request.content)
- )
- service = ManyfoldService(
- ManyfoldConfig(f"{BASE}/manyfold", manyfold.client_id, manyfold.client_secret),
- client=httpx.AsyncClient(transport=httpx.MockTransport(strip_prefix)),
- )
- await service.get_model("cube01")
- assert seen and all(path.startswith("/manyfold/") for path in seen)
- class TestFiles:
- @pytest.mark.asyncio
- async def test_download_uses_the_raw_link(self, manyfold):
- service = _service(manyfold)
- file = await service.get_file("cube01", "f1")
- assert file["filename"] == "cube.stl"
- assert file["raw_path"] == "/models/cube01/raw/cube.stl"
- assert await service.download_file(file) == STL
- @pytest.mark.asyncio
- async def test_names_with_spaces_and_folders(self, manyfold):
- manyfold.add_model("parts04", "Parts", {"p1": FakeFile("sub dir/My Part.stl", "model/stl", STL)})
- service = _service(manyfold)
- file = await service.get_file("parts04", "p1")
- assert file["filename"] == "My Part.stl"
- assert await service.download_file(file) == STL
- @pytest.mark.asyncio
- async def test_a_download_link_pointing_elsewhere_is_not_followed(self, manyfold):
- # contentUrl is only read for the path under /models/<id>/raw/.
- def answer(request: httpx.Request) -> httpx.Response:
- if request.url.path == "/models/cube01/model_files/f1":
- return httpx.Response(200, json={"contentUrl": "http://evil.test/models/other/raw/x.stl", "name": "x"})
- return manyfold.handle(request)
- service = ManyfoldService(
- ManyfoldConfig(BASE, manyfold.client_id, manyfold.client_secret),
- client=httpx.AsyncClient(transport=httpx.MockTransport(answer)),
- )
- with pytest.raises(ManyfoldUnavailableError):
- await service.get_file("cube01", "f1")
- @pytest.mark.asyncio
- async def test_dot_dot_in_the_link_is_refused(self, manyfold):
- def answer(request: httpx.Request) -> httpx.Response:
- if request.url.path == "/models/cube01/model_files/f1":
- return httpx.Response(200, json={"contentUrl": f"{BASE}/models/cube01/raw/..%2F..%2Fsecrets"})
- return manyfold.handle(request)
- service = ManyfoldService(
- ManyfoldConfig(BASE, manyfold.client_id, manyfold.client_secret),
- client=httpx.AsyncClient(transport=httpx.MockTransport(answer)),
- )
- with pytest.raises(ManyfoldUnavailableError):
- await service.get_file("cube01", "f1")
- @pytest.mark.asyncio
- async def test_object_storage_redirect_is_followed_without_the_token(self, manyfold):
- # Manyfold on S3-style storage answers a download with a redirect.
- seen: list[httpx.Request] = []
- def answer(request: httpx.Request) -> httpx.Response:
- seen.append(request)
- if request.url.host == "storage.example.com":
- return httpx.Response(200, content=STL)
- if request.url.path == "/models/cube01/raw/cube.stl":
- return httpx.Response(302, headers={"Location": "https://storage.example.com/bucket/cube.stl?sig=x"})
- return manyfold.handle(request)
- service = ManyfoldService(
- ManyfoldConfig(BASE, manyfold.client_id, manyfold.client_secret),
- client=httpx.AsyncClient(transport=httpx.MockTransport(answer)),
- )
- file = await service.get_file("cube01", "f1")
- assert await service.download_file(file) == STL
- storage = [r for r in seen if r.url.host == "storage.example.com"]
- assert storage and "Authorization" not in storage[0].headers
- @pytest.mark.asyncio
- @pytest.mark.parametrize(
- "target", ["http://169.254.169.254/latest/meta-data/", "http://metadata.google.internal/", "file:///etc/passwd"]
- )
- async def test_a_redirect_to_a_dangerous_target_is_refused(self, manyfold, target):
- seen: list[httpx.Request] = []
- def answer(request: httpx.Request) -> httpx.Response:
- seen.append(request)
- if request.url.path == "/models/cube01/raw/cube.stl":
- return httpx.Response(302, headers={"Location": target})
- return manyfold.handle(request)
- service = ManyfoldService(
- ManyfoldConfig(BASE, manyfold.client_id, manyfold.client_secret),
- client=httpx.AsyncClient(transport=httpx.MockTransport(answer)),
- )
- file = await service.get_file("cube01", "f1")
- with pytest.raises(ManyfoldUnavailableError):
- await service.download_file(file)
- assert all(r.url.host == "manyfold.test" for r in seen)
- @pytest.mark.asyncio
- async def test_endless_redirects_stop(self, manyfold):
- def answer(request: httpx.Request) -> httpx.Response:
- if "/raw/" in request.url.path:
- return httpx.Response(302, headers={"Location": f"{BASE}/models/cube01/raw/cube.stl"})
- return manyfold.handle(request)
- service = ManyfoldService(
- ManyfoldConfig(BASE, manyfold.client_id, manyfold.client_secret),
- client=httpx.AsyncClient(transport=httpx.MockTransport(answer)),
- )
- file = await service.get_file("cube01", "f1")
- with pytest.raises(ManyfoldUnavailableError):
- await service.download_file(file)
- @pytest.mark.asyncio
- async def test_oversized_file_is_refused(self, manyfold, monkeypatch):
- monkeypatch.setattr(svc, "MAX_FILE_BYTES", 50)
- service = _service(manyfold)
- file = await service.get_file("cube01", "f1")
- with pytest.raises(ManyfoldUnavailableError) as err:
- await service.download_file(file)
- assert err.value.code == "manyfold_too_large"
- class TestPreviews:
- @pytest.mark.asyncio
- async def test_rendered_preview_of_a_3d_file(self, manyfold):
- data, content_type = await _service(manyfold).fetch_preview("cube01")
- assert (data, content_type) == (PNG, "image/png")
- preview_request = manyfold.requests[-1]
- assert preview_request.url.path == "/models/cube01/model_files/f1.stl"
- assert preview_request.url.params["derivative"] == "render"
- @pytest.mark.asyncio
- async def test_image_preview_asks_for_the_small_copy(self, manyfold):
- manyfold.models["cube01"]["preview"] = "f3"
- data, content_type = await _service(manyfold).fetch_preview("cube01")
- assert content_type == "image/jpeg"
- assert manyfold.requests[-1].url.params["derivative"] == "preview"
- @pytest.mark.asyncio
- async def test_no_render_means_no_preview_not_the_whole_stl(self, manyfold):
- # Without a render Manyfold sends the original file: refuse it, don't pass an STL off as an image.
- manyfold.models["cube01"]["files"]["f1"].render = None
- with pytest.raises(ManyfoldNotFoundError):
- await _service(manyfold).fetch_preview("cube01")
- @pytest.mark.asyncio
- async def test_model_without_preview(self, manyfold):
- with pytest.raises(ManyfoldNotFoundError):
- await _service(manyfold).fetch_preview("boat02")
- class TestUrl:
- @pytest.mark.parametrize(
- ("raw", "expected"),
- [
- ("http://docker:3214", "http://docker:3214"),
- (" https://models.example.com/ ", "https://models.example.com"),
- ("https://example.com/manyfold/", "https://example.com/manyfold"),
- ],
- )
- def test_accepted(self, raw, expected):
- assert normalize_url(raw) == expected
- @pytest.mark.parametrize(
- "raw",
- [
- "docker:3214",
- "ftp://docker",
- "http://",
- "https://user:pw@host",
- "http://host/?a=1",
- "http://host/#x",
- "",
- # The LAN-service tier: never a Manyfold, under any topology.
- "http://169.254.169.254/",
- "http://metadata.google.internal/",
- "http://2130706433/",
- ],
- )
- def test_refused(self, raw):
- with pytest.raises(ValueError):
- normalize_url(raw)
|