Files
unslothai--unsloth/studio/backend/tests/test_middleware.py
T
wehub-resource-sync e93507a09c
Lockfile supply-chain audit / lockfile supply-chain audit (push) Has been cancelled
Windows Studio GGUF CI / GPU prebuilt resolves without Visual Studio (push) Has been cancelled
Windows Studio GGUF CI / setup.ps1 unit tests (VS 2026 / CMake guard) (push) Has been cancelled
Windows Studio GGUF CI / real-VS detection (VS 2022) (push) Has been cancelled
Windows Studio GGUF CI / real-VS detection (VS 2026) (push) Has been cancelled
Windows Studio GGUF CI / VC++ runtime detect + install round-trip (windows-2025-vs2026) (push) Has been cancelled
Windows Studio GGUF CI / VC++ runtime detect + install round-trip (windows-latest) (push) Has been cancelled
Windows Studio Update CI / Studio Updating Tests (push) Has been cancelled
Wheel CI / Wheel build + content sanity + import smoke (push) Has been cancelled
Lint CI / Source lint (Python + shell + YAML + JSON + safety nets) (push) Has been cancelled
MLX CI on Mac M1 / dispatch (push) Has been cancelled
Security audit / advisory audit (pip + npm + cargo) (push) Has been cancelled
Security audit / pip scan-packages :: extras (push) Has been cancelled
Security audit / pip scan-packages :: studio (push) Has been cancelled
Security audit / pip scan-packages :: hf-stack (push) Has been cancelled
Security audit / npm scan-packages (Studio frontend tarballs) (push) Has been cancelled
Security audit / workflow-trigger lint (pull_request_target / cache-poisoning) (push) Has been cancelled
Security audit / pytest tests/security (push) Has been cancelled
Security audit / npm provenance + new install-script diff (push) Has been cancelled
Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Backend CI / (Python 3.10) (push) Has been cancelled
Backend CI / (Python 3.11) (push) Has been cancelled
Backend CI / (Python 3.12) (push) Has been cancelled
Backend CI / (Python 3.13) (push) Has been cancelled
Backend CI / Repo tests (CPU) (push) Has been cancelled
Frontend CI / Frontend build + bundle sanity (push) Has been cancelled
Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Studio GGUF CI / JSON, images (push) Has been cancelled
Mac Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Mac Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Mac Studio GGUF CI / JSON, images (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-14) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-15) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-26) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-15-intel) (push) Has been cancelled
Mac Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-26-intel) (push) Has been cancelled
Mac Studio UI CI / Chat UI Tests (push) Has been cancelled
Studio Tauri CI / Tauri Linux debug build (no codesign) (push) Has been cancelled
Mac Studio Update CI / Studio Updating Tests (push) Has been cancelled
Studio UI CI / Chat UI Tests (push) Has been cancelled
Windows Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Windows Studio UI CI / Chat UI Tests (push) Has been cancelled
Studio Update CI / Studio Updating Tests (push) Has been cancelled
Core / Core (HF=default + TRL=default) (push) Has been cancelled
Core / Core (HF=4.57.6 + TRL<1) (push) Has been cancelled
Core / Core (HF=latest + TRL=latest) (push) Has been cancelled
Core / llama.cpp build + smoke (push) Has been cancelled
Windows Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Windows Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Windows Studio GGUF CI / JSON, images (push) Has been cancelled
Windows Studio GGUF CI / Studio install + inference without Visual Studio (push) Has been cancelled
Studio export capability / capability (macos-latest) (push) Has been cancelled
Studio export capability / capability (ubuntu-latest) (push) Has been cancelled
Studio export capability / capability (windows-latest) (push) Has been cancelled
Cross-platform parity / parity (macos-latest) (push) Has been cancelled
Cross-platform parity / parity (windows-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
Studio load-orchestrator CI / test (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:59:56 +08:00

561 lines
20 KiB
Python

# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
"""Tests for MaxBodyMiddleware, SecurityHeadersMiddleware, and the /api/health auth gate."""
import asyncio
import importlib.util
import json
import os
import sys
from pathlib import Path
import pytest
from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import Response
from fastapi.testclient import TestClient
_BACKEND_ROOT = Path(__file__).resolve().parents[1]
if str(_BACKEND_ROOT) not in sys.path:
sys.path.insert(0, str(_BACKEND_ROOT))
@pytest.fixture(scope = "module")
def main_module():
import main as _main # noqa: F401
return _main
# MaxBodyMiddleware
def _make_protected_app(
max_bytes: int,
main_module,
upload_passthrough_prefixes: tuple = (),
upload_passthrough_max_bytes_getter = None,
):
app = FastAPI()
app.add_middleware(
main_module.MaxBodyMiddleware,
max_bytes_getter = lambda: max_bytes,
protected_prefixes = ("/v1/chat/completions", "/api/settings", "/api/train"),
upload_passthrough_prefixes = upload_passthrough_prefixes,
upload_passthrough_max_bytes_getter = upload_passthrough_max_bytes_getter,
)
@app.post("/v1/chat/completions")
async def chat(payload: dict):
return {"ok": True, "n": len(payload.get("text", ""))}
@app.post("/api/other")
async def other(payload: dict):
return {"ok": True, "unprotected": True}
@app.put("/api/settings/upload-limit")
async def update_upload_limit(payload: dict):
return {"ok": True, "limit": payload.get("max_upload_size_mb")}
@app.post("/api/train/upload")
async def upload(request: Request):
total = 0
chunks = 0
async for chunk in request.stream():
if chunk:
chunks += 1
total += len(chunk)
return {"ok": True, "chunks": chunks, "total": total}
@app.get("/api/train/status")
async def status_get():
return {"ok": True, "get": True}
return app
class TestMaxBodyMiddleware:
def test_small_protected_body_passes(self, main_module):
app = _make_protected_app(1024, main_module)
c = TestClient(app)
r = c.post("/v1/chat/completions", json = {"text": "x" * 100})
assert r.status_code == 200
assert r.json()["n"] == 100
def test_large_declared_content_length_rejected(self, main_module):
app = _make_protected_app(1024, main_module)
c = TestClient(app)
r = c.post("/v1/chat/completions", json = {"text": "x" * 5000})
assert r.status_code == 413
assert "too large" in r.json()["detail"].lower()
def test_unprotected_prefix_passes_large_body(self, main_module):
app = _make_protected_app(1024, main_module)
c = TestClient(app)
r = c.post("/api/other", json = {"text": "x" * 5000})
assert r.status_code == 200
assert r.json()["unprotected"] is True
def test_settings_put_body_over_cap_rejected(self, main_module):
app = _make_protected_app(1024, main_module)
c = TestClient(app)
r = c.put(
"/api/settings/upload-limit",
json = {"max_upload_size_mb": 500, "padding": "x" * 5000},
)
assert r.status_code == 413
assert "too large" in r.json()["detail"].lower()
def test_chunked_upload_over_cap_rejected(self, main_module):
# Regression: declared-Content-Length-only check could be bypassed by
# chunked transfer-encoding.
app = _make_protected_app(1024, main_module)
c = TestClient(app)
def gen():
yield b'{"text":"'
yield b"x" * 800
yield b'"}'
yield b"\n" + b"y" * 500
r = c.post(
"/v1/chat/completions",
content = gen(),
headers = {"content-type": "application/json"},
)
assert r.status_code == 413
assert "too large" in r.json()["detail"].lower()
def test_chunked_upload_under_cap_passes(self, main_module):
app = _make_protected_app(1024, main_module)
c = TestClient(app)
def gen():
yield b'{"text":"'
yield b"x" * 50
yield b'"}'
r = c.post(
"/v1/chat/completions",
content = gen(),
headers = {"content-type": "application/json"},
)
assert r.status_code == 200
assert r.json()["n"] == 50
def test_get_not_subject_to_cap(self, main_module):
app = _make_protected_app(1024, main_module)
c = TestClient(app)
r = c.get("/api/train/status")
assert r.status_code == 200
def test_upload_passthrough_uses_dedicated_declared_cap(self, main_module):
app = _make_protected_app(
128,
main_module,
upload_passthrough_prefixes = ("/api/train/upload",),
upload_passthrough_max_bytes_getter = lambda: 1024,
)
c = TestClient(app)
r = c.post(
"/api/train/upload",
content = b"x" * 512,
headers = {"content-type": "application/octet-stream"},
)
assert r.status_code == 200
assert r.json()["total"] == 512
def test_upload_passthrough_rejects_declared_body_over_dedicated_cap(self, main_module):
app = _make_protected_app(
128,
main_module,
upload_passthrough_prefixes = ("/api/train/upload",),
upload_passthrough_max_bytes_getter = lambda: 256,
)
c = TestClient(app)
r = c.post(
"/api/train/upload",
content = b"x" * 512,
headers = {"content-type": "application/octet-stream"},
)
assert r.status_code == 413
assert "256" in r.json()["detail"]
def test_upload_passthrough_requires_content_length(self, main_module):
app = _make_protected_app(
128,
main_module,
upload_passthrough_prefixes = ("/api/train/upload",),
upload_passthrough_max_bytes_getter = lambda: 1024,
)
c = TestClient(app)
def gen():
yield b"x" * 64
yield b"y" * 64
r = c.post(
"/api/train/upload",
content = gen(),
headers = {"content-type": "application/octet-stream"},
)
assert r.status_code == 411
assert "Content-Length" in r.json()["detail"]
# SecurityHeadersMiddleware / CSP
def _make_csp_app(main_module, attach_nonce: str | None = None):
app = FastAPI()
app.add_middleware(main_module.SecurityHeadersMiddleware)
@app.get("/plain")
async def plain():
return {"ok": True}
@app.get("/with-nonce")
async def with_nonce():
headers = {}
if attach_nonce:
headers[main_module._CSP_SCRIPT_NONCE_HEADER] = attach_nonce
return Response(
content = b"<html></html>",
media_type = "text/html",
headers = headers,
)
return app
class TestSecurityHeadersMiddleware:
def test_csp_has_no_unsafe_inline_for_script_src(self, main_module):
app = _make_csp_app(main_module)
c = TestClient(app)
r = c.get("/plain")
assert r.status_code == 200
csp = r.headers["content-security-policy"]
# Parse per-directive so style-src unsafe-inline does not false-match.
directives = {
chunk.strip().split(" ", 1)[0]: chunk.strip()
for chunk in csp.split(";")
if chunk.strip()
}
assert "script-src" in directives
assert "'unsafe-inline'" not in directives["script-src"]
# style-src keeps unsafe-inline for Vite-injected styles.
assert "'unsafe-inline'" in directives["style-src"]
def test_default_security_headers_present(self, main_module):
app = _make_csp_app(main_module)
c = TestClient(app)
r = c.get("/plain")
assert r.headers["x-frame-options"] == "DENY"
assert r.headers["x-content-type-options"] == "nosniff"
assert r.headers["referrer-policy"] == "no-referrer"
permissions_policy = r.headers["permissions-policy"]
assert "camera=()" in permissions_policy
assert "microphone=(self)" in permissions_policy
assert "geolocation=()" in permissions_policy
assert r.headers["server"] == "unsloth-studio"
def test_internal_nonce_header_is_spliced_into_csp_and_stripped(self, main_module):
nonce = "test-nonce-abc"
app = _make_csp_app(main_module, attach_nonce = nonce)
c = TestClient(app)
r = c.get("/with-nonce")
csp = r.headers["content-security-policy"]
assert f"'nonce-{nonce}'" in csp
# Internal handoff header must not leak to clients.
assert main_module._CSP_SCRIPT_NONCE_HEADER not in {k.lower() for k in r.headers.keys()}
def test_build_csp_helper_shape(self, main_module):
plain = main_module._build_csp()
assert "script-src 'self';" in plain
assert "'unsafe-inline'" not in plain.split("script-src", 1)[1].split(";", 1)[0]
nonced = main_module._build_csp("XYZ")
assert "script-src 'self' 'nonce-XYZ';" in nonced
def test_img_and_media_allow_https_sources(self, main_module):
# Model-card READMEs and citation favicons pull images/media from many
# https origins (HF LFS/XET CDNs, shields/badge hosts, GitHub-hosted
# assets, audio/video samples). img-src/media-src allow any https source
# so they render; this mirrors the desktop CSP in tauri.conf.json.
csp = main_module._build_csp()
directives = {
chunk.strip().split()[0]: chunk.strip().split()
for chunk in csp.split(";")
if chunk.strip()
}
for name in ("img-src", "media-src"):
assert name in directives, f"missing {name} directive"
# Tokenise and compare with `==` so CodeQL's URL-substring rule does
# not read directive-string `in` membership as URL sanitisation.
assert any(src == "https:" for src in directives[name])
def test_headers_applied_to_streaming_response(self, main_module):
# The ASGI middleware must set headers on streaming responses too.
from fastapi.responses import StreamingResponse
app = FastAPI()
app.add_middleware(main_module.SecurityHeadersMiddleware)
@app.get("/stream")
async def stream():
async def gen():
yield b"a"
yield b"b"
return StreamingResponse(gen(), media_type = "text/plain")
r = TestClient(app).get("/stream")
assert r.status_code == 200
assert r.text == "ab"
assert r.headers["x-content-type-options"] == "nosniff"
assert r.headers["server"] == "unsloth-studio"
assert "content-security-policy" in r.headers
def test_artifact_preview_frame_omits_x_frame_options(self, main_module):
app = FastAPI()
app.add_middleware(main_module.SecurityHeadersMiddleware)
@app.get(main_module._ARTIFACT_PREVIEW_FRAME_PATH)
async def frame():
return Response(content = b"<html></html>", media_type = "text/html")
r = TestClient(app).get(main_module._ARTIFACT_PREVIEW_FRAME_PATH)
assert r.status_code == 200
assert "x-frame-options" not in {k.lower() for k in r.headers.keys()}
assert r.headers["referrer-policy"] == "no-referrer"
def test_response_start_with_tuple_headers_is_hardened(self, main_module):
# An ASGI server may emit tuple-valued raw headers; the middleware must
# coerce to a list and still inject security headers without crashing.
import asyncio
async def _inner_app(scope, receive, send):
await send(
{
"type": "http.response.start",
"status": 200,
"headers": ((b"content-type", b"text/plain"),), # tuple, not list
}
)
await send({"type": "http.response.body", "body": b"ok"})
captured = {}
async def _send(message):
if message["type"] == "http.response.start":
captured["headers"] = dict(message["headers"])
async def _receive():
return {"type": "http.request"}
mw = main_module.SecurityHeadersMiddleware(_inner_app)
asyncio.run(mw({"type": "http", "path": "/plain"}, _receive, _send))
hdrs = captured["headers"]
assert hdrs[b"server"] == b"unsloth-studio"
assert b"content-security-policy" in hdrs
assert hdrs[b"x-frame-options"] == b"DENY"
def test_is_pure_asgi_not_basehttp_middleware(self, main_module):
# Regression: as a BaseHTTPMiddleware this wrapped the SSE stream in its
# own anyio task group, breaking disconnect detection (GPU stuck at 100%)
# and raising cancel scope errors. Must stay pure ASGI.
from starlette.middleware.base import BaseHTTPMiddleware
cls = main_module.SecurityHeadersMiddleware
assert not issubclass(cls, BaseHTTPMiddleware)
assert not hasattr(cls, "dispatch")
def test_forwards_receive_channel_unchanged(self, main_module):
# Must forward the ASGI receive channel untouched so client disconnects
# reach the streaming handler (BaseHTTPMiddleware swapped in its own).
seen = {}
async def inner_app(scope, receive, send):
seen["receive"] = receive
await send({"type": "http.response.start", "status": 200, "headers": []})
await send({"type": "http.response.body", "body": b"ok", "more_body": False})
mw = main_module.SecurityHeadersMiddleware(inner_app)
sentinel_receive = object() # forwarded verbatim, never wrapped/awaited
sent = []
async def send(message):
sent.append(message)
async def run():
await mw(
{"type": "http", "path": "/plain", "headers": []},
sentinel_receive,
send,
)
asyncio.run(run())
assert seen["receive"] is sentinel_receive
start = next(m for m in sent if m["type"] == "http.response.start")
names = {n.lower() for n, _ in start["headers"]}
assert b"content-security-policy" in names
assert b"server" in names
def test_streaming_response_survives_client_disconnect(self, main_module):
# A StreamingResponse that polls is_disconnected() (like gguf_tool_stream)
# must unwind cleanly on client disconnect: no cancel scope error, the
# generator's finally runs, and security headers are still applied.
from fastapi import FastAPI, Request
from fastapi.responses import StreamingResponse
state = {"cleaned_up": False}
app = FastAPI()
app.add_middleware(main_module.SecurityHeadersMiddleware)
@app.get("/v1/chat/completions")
async def stream(request: Request):
async def gen():
try:
for i in range(1000):
if await request.is_disconnected():
break
yield f"data: {i}\n\n".encode()
await asyncio.sleep(0.01)
finally:
state["cleaned_up"] = True
return StreamingResponse(gen(), media_type = "text/event-stream")
scope = {
"type": "http",
"asgi": {"version": "3.0", "spec_version": "2.3"},
"http_version": "1.1",
"method": "GET",
"path": "/v1/chat/completions",
"raw_path": b"/v1/chat/completions",
"query_string": b"",
"root_path": "",
"scheme": "http",
"headers": [(b"host", b"testserver")],
"client": ("127.0.0.1", 50000),
"server": ("127.0.0.1", 80),
}
async def run():
body_started = asyncio.Event()
calls = {"n": 0}
async def receive():
calls["n"] += 1
if calls["n"] == 1:
return {"type": "http.request", "body": b"", "more_body": False}
await body_started.wait() # client clicks Stop after tokens stream
return {"type": "http.disconnect"}
sent = []
async def send(message):
sent.append(message)
if message["type"] == "http.response.body" and message.get("body"):
body_started.set()
# Must return without raising the anyio cancel-scope RuntimeError.
await asyncio.wait_for(app(scope, receive, send), timeout = 5.0)
return sent
sent = asyncio.run(run())
assert state["cleaned_up"] is True
start = next(m for m in sent if m["type"] == "http.response.start")
names = {n.lower() for n, _ in start["headers"]}
assert b"content-security-policy" in names
assert b"server" in names
# /api/health auth gate
@pytest.fixture
def health_app(tmp_path, monkeypatch):
"""Mount /api/health on a fresh app against an isolated auth db."""
from auth import storage
monkeypatch.setattr(storage, "DB_PATH", tmp_path / "auth.db")
monkeypatch.setattr(storage, "_BOOTSTRAP_PW_PATH", tmp_path / ".bootstrap_password")
monkeypatch.setattr(storage, "_bootstrap_password", None)
import main as _main
app = FastAPI()
app.add_api_route("/api/health", _main.health_check, methods = ["GET"])
import secrets as _secrets
storage.create_initial_user(
username = storage.DEFAULT_ADMIN_USERNAME,
password = "human-password-123",
jwt_secret = _secrets.token_urlsafe(64),
must_change_password = False,
)
return app
class TestHealthAuthGate:
# Launcher / frontend bootstrap fields are unauth so the Tauri watchdog can
# re-adopt a sibling backend and the SPA can detect chat-only mode before
# any token exists. Version / device_type still require a bearer.
LAUNCHER_BITS = (
"service",
"studio_root_id",
"chat_only",
"desktop_protocol_version",
"desktop_manageability_version",
"supports_desktop_auth",
"supports_desktop_backend_ownership",
"native_path_leases_supported",
)
FINGERPRINT_FIELDS = ("version", "studio_version", "device_type")
def test_no_auth_exposes_launcher_bits(self, health_app):
c = TestClient(health_app)
r = c.get("/api/health")
assert r.status_code == 200
body = r.json()
assert body["status"] == "healthy"
assert "timestamp" in body
for field in self.LAUNCHER_BITS:
assert field in body, f"missing launcher bit: {field}"
assert body["service"] == "Unsloth UI Backend"
for forbidden in self.FINGERPRINT_FIELDS:
assert forbidden not in body
def test_invalid_bearer_returns_launcher_bits_only(self, health_app):
# Regression: calling the async dep without await let any Bearer header pass.
c = TestClient(health_app)
r = c.get(
"/api/health",
headers = {"Authorization": "Bearer not-a-real-token"},
)
assert r.status_code == 200
body = r.json()
assert body["status"] == "healthy"
for field in self.LAUNCHER_BITS:
assert field in body
for forbidden in self.FINGERPRINT_FIELDS:
assert forbidden not in body
def test_valid_bearer_returns_full_payload(self, health_app):
from auth import storage
from auth.authentication import create_access_token
token = create_access_token(storage.DEFAULT_ADMIN_USERNAME)
c = TestClient(health_app)
r = c.get(
"/api/health",
headers = {"Authorization": f"Bearer {token}"},
)
assert r.status_code == 200
body = r.json()
assert body["status"] == "healthy"
for field in self.LAUNCHER_BITS + self.FINGERPRINT_FIELDS:
assert field in body, f"missing: {field}"