Files
bambuddy/backend/tests/unit/services/test_manyfold_service.py
T
maziggy 3480d39434 Browse a Manyfold library and import its files (issue #1471)
The MakerWorld page becomes Model Sources, with a MakerWorld tab and a
Manyfold tab. Bambuddy signs in to a self-hosted Manyfold with an OAuth
application (public read scopes), lists and searches its models with their
previews, and imports 3MF, STL and STEP files into the library, from where
they are sliced and printed like any other file.

New permissions manyfold:view and manyfold:import are granted once to the
groups that hold the matching MakerWorld permissions. The client secret is
never returned and is left out of GitHub backups. The Manyfold URL and every
redirect pass the LAN-service URL check. Old /makerworld links open the
MakerWorld tab.

Several files import at once: tick files in a model, or Select all, then
Import selected or Import all; or tick models in the grid and import all
their printable files. Files import one after another, failures don't stop
the rest, and a summary reports the outcome.
2026-10-04 13:09:25 +02:00

419 lines
17 KiB
Python

"""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)