"""The publish cliff: an app with a FastAPI backend works in preview and 404s on its public URL, because publishing uploads a static bundle and the edge has no /api/* route. Before this gate, nothing in the publish path noticed.""" import uuid import pytest from backend.apps.outputs import publish_common from backend.apps.outputs.models import Output, PublishReview from backend.apps.outputs.publish_capability import ( check_publish_capability, merge_capability, ) @pytest.fixture def p_ws_root(tmp_path, monkeypatch): root = tmp_path / "ws" root.mkdir() monkeypatch.setattr(publish_common, "OUTPUTS_WORKSPACE_DIR", str(root)) return root def p_app(ws_root, *, workspace: bool = True) -> Output: wsid = uuid.uuid4().hex if workspace else None if wsid: (ws_root / wsid).mkdir() return Output( name="Demo", description="", icon="view_quilt", input_schema={"type": "object", "properties": {}, "required": []}, files={}, workspace_id=wsid, session_id=None, ) def p_seed(ws_root, output, *, env: str, frontend: str = "", backend_main: bool = False): root = ws_root / (output.workspace_id or "") (root / ".env").write_text(env) if frontend: fe = root / "frontend" / "src" fe.mkdir(parents=True) (fe / "api.ts").write_text(frontend) if backend_main: be = root / "backend" be.mkdir() (be / "main.py").write_text("app = 1\n") return root def test_flat_app_has_no_capability_problem(p_ws_root): out = p_app(p_ws_root, workspace=False) assert check_publish_capability(out).findings == [] def test_frontend_only_workspace_is_clean(p_ws_root): out = p_app(p_ws_root) p_seed(p_ws_root, out, env="BACKEND_PORT=NONE\n", frontend="fetch('/data.json')\n") report = check_publish_capability(out) assert report.backend_enabled is False assert report.findings == [] def test_backend_plus_api_calls_is_blocked(p_ws_root): out = p_app(p_ws_root) p_seed( p_ws_root, out, env="BACKEND_PORT=8123 # chosen by backend_init.sh\nFRONTEND_PORT=4949\n", frontend="export const JOBS = '/api/jobs';\nfetch(JOBS);\n", backend_main=True, ) report = check_publish_capability(out) assert report.backend_enabled is True assert report.backend_port == "8123" assert report.api_callers == ["frontend/src/api.ts"] assert len(report.findings) == 2 joined = " ".join(report.findings) assert "frontend/src/api.ts" in joined assert "OUTPUT_COMPUTE" in joined def test_backend_with_no_callers_loses_nothing(p_ws_root): """A backend nothing calls is dead weight, not broken functionality.""" out = p_app(p_ws_root) p_seed( p_ws_root, out, env="BACKEND_PORT=8123\n", frontend="const x = 1;\n", backend_main=True, ) report = check_publish_capability(out) assert report.backend_enabled is True assert report.findings == [] def test_backend_dir_without_port_still_counts(p_ws_root): """backend_init.sh calls this state inconsistent; publishing must not shrug.""" out = p_app(p_ws_root) p_seed( p_ws_root, out, env="BACKEND_PORT=NONE\n", frontend="fetch('/api/things')\n", backend_main=True, ) assert check_publish_capability(out).findings != [] def test_apiary_is_not_an_api_call(p_ws_root): """Prefix matching would flag /apiary and /rapid; the boundary is load-bearing.""" out = p_app(p_ws_root) p_seed( p_ws_root, out, env="BACKEND_PORT=8123\n", frontend="fetch('/apiary/bees'); fetch('/rapid');\n", backend_main=True, ) assert check_publish_capability(out).findings == [] def test_backend_dir_is_not_scanned_for_callers(p_ws_root): """The backend's own source mentioning /api must not count as a frontend caller.""" out = p_app(p_ws_root) root = p_seed(p_ws_root, out, env="BACKEND_PORT=8123\n", backend_main=True) (root / "backend" / "routes.js").write_text("// mounts /api/jobs\n") assert check_publish_capability(out).findings == [] def test_merge_escalates_a_clean_security_review_to_block(p_ws_root): out = p_app(p_ws_root) p_seed( p_ws_root, out, env="BACKEND_PORT=8123\n", frontend="fetch('/api/x')\n", backend_main=True, ) merged = merge_capability(out, PublishReview(verdict="clean", findings=[])) assert merged.verdict == "block" assert len(merged.findings) == 2 def test_merge_preserves_security_findings_and_order(p_ws_root): out = p_app(p_ws_root) p_seed( p_ws_root, out, env="BACKEND_PORT=8123\n", frontend="fetch('/api/x')\n", backend_main=True, ) merged = merge_capability( out, PublishReview(verdict="warn", findings=["reads os.environ"], scanned_files=["a.py"]), ) assert merged.findings[-1] == "reads os.environ" assert merged.scanned_files == ["a.py"] def test_merge_is_a_passthrough_when_nothing_is_lost(p_ws_root): out = p_app(p_ws_root) p_seed(p_ws_root, out, env="BACKEND_PORT=NONE\n") review = PublishReview(verdict="warn", findings=["something else"]) assert merge_capability(out, review) is review def test_missing_env_file_does_not_explode(p_ws_root): """A half-seeded workspace must read as 'no backend', not raise.""" out = p_app(p_ws_root) report = check_publish_capability(out) assert report.backend_enabled is False assert report.findings == [] @pytest.mark.asyncio async def test_publish_route_refuses_to_ship_a_broken_app(p_ws_root, monkeypatch): """The route is where the loss was silent: it built and uploaded regardless.""" from backend.apps.outputs import outputs as outputs_mod from backend.apps.outputs.models import PublishRequest out = p_app(p_ws_root) p_seed( p_ws_root, out, env="BACKEND_PORT=8123\n", frontend="fetch('/api/jobs')\n", backend_main=True, ) built = [] monkeypatch.setattr(outputs_mod, "load", lambda _: out) monkeypatch.setattr(outputs_mod, "load_settings", lambda: None) monkeypatch.setattr(outputs_mod, "build_static", lambda o: built.append(o)) res = await outputs_mod.publish_output(PublishRequest(output_id=out.id)) assert res["ok"] is False assert res["blocked"] is True assert res["review"]["verdict"] == "block" assert built == [], "publish must not build once the gate has fired" @pytest.mark.asyncio async def test_force_is_still_the_escape_hatch(p_ws_root, monkeypatch): """A user who read the finding can still ship; the gate informs, it does not trap.""" from backend.apps.outputs import outputs as outputs_mod from backend.apps.outputs.models import PublishRequest out = p_app(p_ws_root) p_seed( p_ws_root, out, env="BACKEND_PORT=8123\n", frontend="fetch('/api/jobs')\n", backend_main=True, ) reached = [] async def p_boom(_): reached.append(True) raise publish_common.PublishError("stopped past the gate") monkeypatch.setattr(outputs_mod, "load", lambda _: out) monkeypatch.setattr(outputs_mod, "save", lambda _: None) monkeypatch.setattr(outputs_mod, "load_settings", lambda: None) monkeypatch.setattr(outputs_mod, "build_static", p_boom) res = await outputs_mod.publish_output(PublishRequest(output_id=out.id, force=True)) assert reached == [True], "force must skip the gate and reach the build" assert res["ok"] is False