"""Tests for ifcurl.bcf and the /bcf service endpoint.""" from __future__ import annotations import io import zipfile from unittest.mock import patch import pytest from fastapi.testclient import TestClient from ifcurl.bcf import build_bcf from ifcurl.service import _t2_cache, _t3_cache, app, configure_allowed_hosts client = TestClient(app) FAKE_HEXSHA = "b" * 40 MUTABLE_URL = "ifc://example.com/org/repo@heads/main?path=model.ifc" CAMERA_URL = ( "ifc://example.com/org/repo@heads/main" "?path=model.ifc&camera=1,2,3,0,0,-1,0,1,0&fov=60" ) CLIP_URL = ( "ifc://example.com/org/repo@heads/main" "?path=model.ifc&camera=0,0,5,0,0,-1,0,1,0&fov=60" "&clip=0,0,2,0,0,-1" ) @pytest.fixture(autouse=True) def reset_service(tmp_path, monkeypatch): monkeypatch.setattr("ifcurl.service.user_cache_dir", lambda *a, **kw: str(tmp_path)) _t2_cache.clear() _t3_cache.clear() configure_allowed_hosts(None) yield _t2_cache.clear() _t3_cache.clear() configure_allowed_hosts(None) def _zip_names(data: bytes) -> set[str]: with zipfile.ZipFile(io.BytesIO(data)) as zf: return set(zf.namelist()) def _zip_read(data: bytes, name: str) -> str: with zipfile.ZipFile(io.BytesIO(data)) as zf: return zf.read(name).decode() # --------------------------------------------------------------------------- # build_bcf unit tests # --------------------------------------------------------------------------- class TestBuildBcf: def test_returns_valid_zip(self): data = build_bcf() assert zipfile.is_zipfile(io.BytesIO(data)) def test_contains_version_file(self): data = build_bcf() assert "bcf.version" in _zip_names(data) def test_contains_markup(self): data = build_bcf(title="Test topic") names = _zip_names(data) markup_files = [n for n in names if n.endswith("markup.bcf")] assert len(markup_files) == 1 content = _zip_read(data, markup_files[0]) assert "Test topic" in content def test_no_viewpoint_without_camera(self): data = build_bcf() names = _zip_names(data) assert not any(n.endswith("viewpoint.bcfv") for n in names) def test_viewpoint_present_with_camera(self): cam = (1.0, 2.0, 3.0, 0.0, 0.0, -1.0, 0.0, 1.0, 0.0) data = build_bcf(camera=cam, fov=60.0) names = _zip_names(data) assert any(n.endswith("viewpoint.bcfv") for n in names) def test_perspective_camera_in_viewpoint(self): cam = (1.0, 2.0, 3.0, 0.0, 0.0, -1.0, 0.0, 1.0, 0.0) data = build_bcf(camera=cam, fov=60.0) vp_file = next(n for n in _zip_names(data) if n.endswith("viewpoint.bcfv")) content = _zip_read(data, vp_file) assert "PerspectiveCamera" in content assert "60.0000" in content def test_orthographic_camera_in_viewpoint(self): cam = (0.0, 0.0, 10.0, 0.0, 0.0, -1.0, 0.0, 1.0, 0.0) data = build_bcf(camera=cam, scale=50.0) vp_file = next(n for n in _zip_names(data) if n.endswith("viewpoint.bcfv")) content = _zip_read(data, vp_file) assert "OrthogonalCamera" in content assert "ViewToWorldScale" in content def test_clipping_planes_in_viewpoint(self): cam = (0.0, 0.0, 5.0, 0.0, 0.0, -1.0, 0.0, 1.0, 0.0) data = build_bcf(camera=cam, fov=60.0, clips=[(0.0, 0.0, 2.0, 0.0, 0.0, -1.0)]) vp_file = next(n for n in _zip_names(data) if n.endswith("viewpoint.bcfv")) content = _zip_read(data, vp_file) assert "ClippingPlane" in content def test_comment_in_markup(self): cam = (0.0, 0.0, 5.0, 0.0, 0.0, -1.0, 0.0, 1.0, 0.0) data = build_bcf(camera=cam, fov=60.0, comment="Look at this crack") markup = next(n for n in _zip_names(data) if n.endswith("markup.bcf")) content = _zip_read(data, markup) assert "Look at this crack" in content def test_guids_in_viewpoint_selection(self): cam = (0.0, 0.0, 5.0, 0.0, 0.0, -1.0, 0.0, 1.0, 0.0) data = build_bcf(camera=cam, fov=60.0, guids=["abc123", "def456"]) vp_file = next(n for n in _zip_names(data) if n.endswith("viewpoint.bcfv")) content = _zip_read(data, vp_file) assert 'IfcGuid="abc123"' in content assert 'IfcGuid="def456"' in content def test_isolate_visibility_uses_exceptions(self): cam = (0.0, 0.0, 5.0, 0.0, 0.0, -1.0, 0.0, 1.0, 0.0) data = build_bcf(camera=cam, fov=60.0, guids=["abc123"], visibility="isolate") vp_file = next(n for n in _zip_names(data) if n.endswith("viewpoint.bcfv")) content = _zip_read(data, vp_file) assert 'DefaultVisibility="false"' in content assert "Exceptions" in content # --------------------------------------------------------------------------- # /bcf service endpoint tests # --------------------------------------------------------------------------- class TestBcfEndpoint: def test_returns_zip_bytes(self): r = client.post("/bcf", json={"url": CAMERA_URL}) assert r.status_code == 200 assert zipfile.is_zipfile(io.BytesIO(r.content)) def test_content_disposition_header(self): r = client.post("/bcf", json={"url": CAMERA_URL}) assert "view.bcf" in r.headers["content-disposition"] def test_contains_viewpoint_when_camera_in_url(self): r = client.post("/bcf", json={"url": CAMERA_URL}) names = _zip_names(r.content) assert any(n.endswith("viewpoint.bcfv") for n in names) def test_no_viewpoint_when_no_camera(self): r = client.post("/bcf", json={"url": MUTABLE_URL}) assert r.status_code == 200 names = _zip_names(r.content) assert not any(n.endswith("viewpoint.bcfv") for n in names) def test_title_and_comment_in_markup(self): r = client.post("/bcf", json={ "url": CAMERA_URL, "title": "My issue", "comment": "Fix this" }) markup = next(n for n in _zip_names(r.content) if n.endswith("markup.bcf")) content = _zip_read(r.content, markup) assert "My issue" in content assert "Fix this" in content def test_clip_plane_in_viewpoint(self): r = client.post("/bcf", json={"url": CLIP_URL}) vp = next(n for n in _zip_names(r.content) if n.endswith("viewpoint.bcfv")) assert "ClippingPlane" in _zip_read(r.content, vp) def test_ssrf_local_transport_rejected(self): r = client.post("/bcf", json={"url": "ifc:///some/path@HEAD?path=m.ifc"}) assert r.status_code == 403 def test_ssrf_private_ip_rejected(self): r = client.post("/bcf", json={"url": "ifc://192.168.1.1/org/repo@HEAD?path=m.ifc"}) assert r.status_code == 403 def test_invalid_url_returns_400(self): r = client.post("/bcf", json={"url": "https://example.com/model.ifc"}) assert r.status_code == 400 def test_selector_resolves_guids(self, model_with_geometry): ifc_bytes = model_with_geometry.to_string().encode() def mock_fetch(ifc_url, token=None): return FAKE_HEXSHA, ifc_bytes selector_url = MUTABLE_URL.replace("?path=model.ifc", "?path=model.ifc&selector=IfcWall") with patch("ifcurl.service.fetch_ifc", mock_fetch): r = client.post("/bcf", json={"url": selector_url}) assert r.status_code == 200 vp_files = [n for n in _zip_names(r.content) if n.endswith("viewpoint.bcfv")] # No camera → no viewpoint even with selector assert len(vp_files) == 0