# SPDX-License-Identifier: AGPL-3.0-only # Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 """Security smoke for the public /p preview routes. Exercises the route layer with a real ``preview_router`` while stubbing the expensive model calls (``load_model`` / ``openai_chat_completions``). Covers the public-surface guarantees: path-traversal rejection, request sanitization (tools / provider routing / use_adapter), asset-path containment, the page CSP header + HTML escaping, and that the preview lock is held until a streaming response is fully drained. """ import asyncio import json from pathlib import Path import sys import types as _types import pytest _BACKEND_DIR = str(Path(__file__).resolve().parent.parent) if _BACKEND_DIR not in sys.path: sys.path.insert(0, _BACKEND_DIR) # Mirror test_preview.py: the real `loggers` package pulls in heavy handlers. _loggers_stub = _types.ModuleType("loggers") _loggers_stub.get_logger = lambda name: __import__("logging").getLogger(name) sys.modules.setdefault("loggers", _loggers_stub) from fastapi import FastAPI from fastapi.responses import StreamingResponse from fastapi.testclient import TestClient import routes.preview as preview from models.inference import ChatCompletionRequest def _make_run(outputs: Path, name: str = "demorun") -> Path: run = outputs / name run.mkdir(parents = True) (run / "adapter_config.json").write_text( json.dumps({"base_model_name_or_path": "HuggingFaceTB/SmolLM-135M"}) ) ckpt = run / "checkpoint-1" ckpt.mkdir() (ckpt / "adapter_config.json").write_text("{}") return run @pytest.fixture def captured(): return {} @pytest.fixture def client(tmp_path, monkeypatch, captured): outputs = tmp_path / "outputs" _make_run(outputs) # resolve_preview_checkpoint -> resolve_output_dir -> outputs_root(). from utils.paths import storage_roots as _sr monkeypatch.setattr(_sr, "outputs_root", lambda: outputs) async def _fake_load_model(load_req, request, subject): captured["load_path"] = load_req.model_path return None async def _fake_chat(payload, request, subject): captured["payload"] = payload return {"ok": True} monkeypatch.setattr(preview, "load_model", _fake_load_model) monkeypatch.setattr(preview, "openai_chat_completions", _fake_chat) app = FastAPI() app.include_router(preview.router, prefix = "/p") app.dependency_overrides[preview.get_current_subject] = lambda: "admin" # raise_server_exceptions=False so a 5xx surfaces as a response, not a throw. return TestClient(app, raise_server_exceptions = False) # ── Page rendering ──────────────────────────────────────────────────────── def test_page_renders_with_csp(client): r = client.get("/p/demorun") assert r.status_code == 200 assert "text/html" in r.headers["content-type"] csp = r.headers.get("content-security-policy", "") assert "default-src 'self'" in csp assert "base-uri 'none'" in csp def test_page_escapes_title(tmp_path, monkeypatch, captured): outputs = tmp_path / "outputs" # Run dir name carries an HTML-special char; the page must escape it. _make_run(outputs, name = "a None. outputs = tmp_path / "outputs" merged = outputs / "mergedrun" merged.mkdir(parents = True) (merged / "config.json").write_text(json.dumps({"_name_or_path": "some/base"})) from utils.paths import storage_roots as _sr monkeypatch.setattr(_sr, "outputs_root", lambda: outputs) async def _fake_load(load_req, request, subject): return None async def _fake_chat(payload, request, subject): captured["payload"] = payload return {"ok": True} monkeypatch.setattr(preview, "load_model", _fake_load) monkeypatch.setattr(preview, "openai_chat_completions", _fake_chat) app = FastAPI() app.include_router(preview.router, prefix = "/p") c = TestClient(app, raise_server_exceptions = False) r = c.post( "/p/mergedrun/v1/chat/completions", json = {"messages": [{"role": "user", "content": "hi"}], "use_adapter": False}, ) assert r.status_code == 200 assert captured["payload"].use_adapter is None # ── Streaming lock lifetime ────────────────────────────────────────────────── def test_streaming_holds_lock_until_drained(tmp_path, monkeypatch, captured): outputs = tmp_path / "outputs" _make_run(outputs) from utils.paths import storage_roots as _sr monkeypatch.setattr(_sr, "outputs_root", lambda: outputs) async def _fake_load_model(load_req, request, subject): return None async def _gen(): yield b"data: {}\n\n" yield b"data: [DONE]\n\n" async def _fake_chat(payload, request, subject): return StreamingResponse(_gen()) monkeypatch.setattr(preview, "load_model", _fake_load_model) monkeypatch.setattr(preview, "openai_chat_completions", _fake_chat) async def _run(): assert not preview._preview_lock.locked() payload = ChatCompletionRequest(messages = [{"role": "user", "content": "hi"}]) resp = await preview._serve_chat("demorun", None, payload, request = None) # Lock must still be held: a second checkpoint must not swap the backend # mid-stream. assert preview._preview_lock.locked() chunks = [c async for c in resp.body_iterator] # Released only after the stream fully drains. assert not preview._preview_lock.locked() return chunks chunks = asyncio.run(_run()) assert any(b"[DONE]" in c for c in chunks) assert not preview._preview_lock.locked()