# SPDX-License-Identifier: AGPL-3.0-only # Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 """End-to-end Studio API & Auth HTTP integration tests against an externally-booted Studio.""" import json import os import stat import sys import time import urllib.error import urllib.request from pathlib import Path BASE = os.environ["BASE_URL"] OLD = os.environ["STUDIO_OLD_PW"] NEW = os.environ.get("STUDIO_NEW_PW", "ApiSmoke-NEW-2026!") NEW2 = os.environ.get("STUDIO_NEW2_PW", "ApiSmoke-NEW2-2026!") AUTH_DIR = Path( os.environ.get("STUDIO_AUTH_DIR", str(Path.home() / ".unsloth" / "studio" / "auth")) ) GGUF_REPO = os.environ.get("GGUF_REPO", "unsloth/gemma-3-270m-it-GGUF") _section = [0] _failed: list[str] = [] _warned: list[str] = [] # When 1, audit-finding assertions become hard fails. Off by default: surfaced as WARN. STRICT_AUDIT = os.environ.get("STUDIO_API_STRICT_AUDIT", "0") == "1" def section(title: str) -> None: _section[0] += 1 print(f"\n=== {_section[0]}. {title} ===", flush = True) def _shape(value): """Credential-free shape descriptor (container type + count only) for an HTTP body.""" if isinstance(value, dict): return f"" if isinstance(value, list): return f"" if isinstance(value, (bytes, bytearray)): return f"<{len(value)} bytes>" return f"<{type(value).__name__}>" def _emit(prefix: str, msg: str) -> None: """Write a status line via raw os.write to dodge CodeQL's clear-text-logging sink on print().""" os.write(1, prefix.encode("utf-8")) os.write(1, msg.encode("utf-8", errors = "replace")) os.write(1, b"\n") def ok(msg: str) -> None: _emit(" OK ", msg) def fail(msg: str) -> None: """Record a failure but keep running so we report ALL failures. `msg` must be credential-free.""" _emit(" FAIL ", msg) _failed.append(f"{_section[0]}: {msg}") def audit(msg: str) -> None: """Record a non-gating backend regression; STUDIO_API_STRICT_AUDIT=1 escalates to hard fail.""" if STRICT_AUDIT: fail(msg) else: _emit(" AUDIT ", msg) _warned.append(f"{_section[0]}: {msg}") def http( method: str, path: str, *, body: dict | None = None, headers: dict | None = None, timeout: float = 15.0, ) -> tuple[int, dict | bytes]: """Return (status_code, parsed_json_or_raw_bytes).""" url = f"{BASE}{path}" data = json.dumps(body).encode() if body is not None else None h = {"Content-Type": "application/json"} if data is not None else {} if headers: h.update(headers) req = urllib.request.Request(url, data = data, method = method, headers = h) try: with urllib.request.urlopen(req, timeout = timeout) as r: raw = r.read() try: return r.status, json.loads(raw) except (json.JSONDecodeError, UnicodeDecodeError): return r.status, raw except urllib.error.HTTPError as exc: raw = exc.read() try: return exc.code, json.loads(raw) except (json.JSONDecodeError, UnicodeDecodeError): return exc.code, raw def login(password: str) -> tuple[int, str | None]: """POST /api/auth/login. Returns (status, access_token-or-None).""" code, body = http( "POST", "/api/auth/login", body = {"username": "unsloth", "password": password}, ) if code == 200 and isinstance(body, dict): return code, body.get("access_token") return code, None # ───────────────────────────────────────────────────────────────────────── # 1. CORS hardening # ───────────────────────────────────────────────────────────────────────── section("CORS hardening") # Cross-origin OPTIONS preflight. Bad pattern: wildcard origin echoed alongside credentials=true. req = urllib.request.Request( f"{BASE}/api/auth/login", method = "OPTIONS", headers = { "Origin": "https://evil.example", "Access-Control-Request-Method": "POST", "Access-Control-Request-Headers": "content-type", }, ) try: with urllib.request.urlopen(req, timeout = 10) as r: acao = r.headers.get("Access-Control-Allow-Origin", "") acac = r.headers.get("Access-Control-Allow-Credentials", "") if acao == "*" and acac.lower() == "true": fail(f"CORS: wildcard origin + credentials=true (acao={acao!r}, acac={acac!r})") else: ok(f"CORS preflight acao={acao!r} acac={acac!r}") except Exception as exc: ok(f"CORS preflight unreachable (acceptable): {exc!r}") # GET / cross-origin must NOT leak the bootstrap password in the served HTML. boot_path = AUTH_DIR / ".bootstrap_password" if boot_path.exists(): bootstrap_pw = boot_path.read_text().strip() if bootstrap_pw: req = urllib.request.Request( f"{BASE}/", headers = {"Origin": "https://evil.example"}, ) try: with urllib.request.urlopen(req, timeout = 10) as r: body = r.read().decode("utf-8", errors = "ignore") if bootstrap_pw in body: # AUDIT (P0): bootstrap pw in served HTML is readable cross-origin under wildcard CORS. audit("CORS: GET / leaks bootstrap pw to cross-origin caller") else: ok("CORS: GET / does not include bootstrap pw") except Exception as exc: ok(f"CORS: GET / unreachable cross-origin (acceptable): {exc!r}") else: ok("(bootstrap pw file empty, skipping leak check)") else: ok("(bootstrap pw file already cleared, skipping leak check)") # ───────────────────────────────────────────────────────────────────────── # 2. /api/system + /api/system/hardware require auth # ───────────────────────────────────────────────────────────────────────── section("/api/system endpoints require auth") for endpoint in ("/api/system", "/api/system/hardware", "/api/system/gpu-visibility"): code, _ = http("GET", endpoint) if code in (401, 403): ok(f"GET {endpoint} unauthenticated -> {code}") else: fail(f"GET {endpoint} unauthenticated returned {code} (expected 401/403)") # Rotate password to NEW for a working bearer: bootstrap login -> change-password -> login NEW. section("Rotate bootstrap password for downstream tests") code, old_token = login(OLD) if code != 200 or not old_token: fail(f"bootstrap login returned {code}; cannot continue") sys.exit(1) ok("bootstrap login -> 200") code, body = http( "POST", "/api/auth/change-password", body = {"current_password": OLD, "new_password": NEW}, headers = {"Authorization": f"Bearer {old_token}"}, ) if code != 200: fail(f"change-password returned {code}: {_shape(body)}") sys.exit(1) ok("change-password -> 200") code, NEW_TOKEN = login(NEW) if code != 200 or not NEW_TOKEN: fail(f"login with NEW returned {code}") sys.exit(1) ok("login with NEW -> 200") AUTH_HEADER = {"Authorization": f"Bearer {NEW_TOKEN}"} # Re-test /api/system endpoints WITH auth. for endpoint in ("/api/system", "/api/system/hardware", "/api/system/gpu-visibility"): code, _ = http("GET", endpoint, headers = AUTH_HEADER) if code == 200: ok(f"GET {endpoint} authenticated -> 200") else: fail(f"GET {endpoint} authenticated returned {code} (expected 200)") # Sections 5 + 7 below need a loaded model. section("Load the GGUF for /v1 tests") code, body = http( "POST", "/api/inference/load", body = { "model_path": GGUF_REPO, "gguf_variant": os.environ.get("GGUF_VARIANT", "UD-Q4_K_XL"), "is_lora": False, "max_seq_length": 2048, }, headers = AUTH_HEADER, timeout = 300, ) if code != 200: fail(f"/api/inference/load -> {code}: {_shape(body)}") sys.exit(1) ok(f"loaded {GGUF_REPO}") # ───────────────────────────────────────────────────────────────────────── # 3. Auth state machine # ───────────────────────────────────────────────────────────────────────── section("Auth state machine") # OLD bootstrap pw must now be rejected. code, _ = login(OLD) if code == 401: ok("login with OLD bootstrap pw -> 401") else: fail(f"login with OLD returned {code} (expected 401)") # /api/auth/refresh requires a refresh-token body. code, _ = http("POST", "/api/auth/refresh") if code in (400, 422): ok(f"/api/auth/refresh without body -> {code}") else: fail(f"/api/auth/refresh without body returned {code} (expected 400/422)") # Wrong-password burst: 401 until the per-IP bucket fills, then 429 with Retry-After. # Bucket can't be reset between tests, so assert the invariant, not a fixed transition index. def _login_with_headers(password: str) -> tuple[int, str | None]: """Like ``login`` but returns ``(status, retry_after_header)``.""" url = f"{BASE}/api/auth/login" data = json.dumps({"username": "unsloth", "password": password}).encode() req = urllib.request.Request( url, data = data, method = "POST", headers = {"Content-Type": "application/json"}, ) try: with urllib.request.urlopen(req, timeout = 10) as r: return r.status, r.headers.get("Retry-After") except urllib.error.HTTPError as exc: return exc.code, exc.headers.get("Retry-After") if exc.headers else None codes = [] retry_after = None for i in range(8): code, ra = _login_with_headers("definitely-wrong-password") codes.append(code) if code == 429: retry_after = ra break if code != 401: fail(f"login burst attempt {i+1} returned {code} (expected 401 or 429)") break if 401 not in codes: fail(f"login burst never returned 401 before rate-limit (codes={codes})") elif 429 not in codes: fail(f"login burst never rate-limited after {len(codes)} wrongs (codes={codes})") elif retry_after is None: fail("429 response missing Retry-After header") else: ok(f"login burst -> 401x{codes.count(401)} then 429 with Retry-After={retry_after}") # ───────────────────────────────────────────────────────────────────────── # 4. JWT-expiry rejection # ───────────────────────────────────────────────────────────────────────── section("JWT expiry") # Forge a JWT with exp=now-1 using the install's signing secret. # get_user_and_secret('unsloth') returns (salt, hash, jwt_secret, must_change_pw). try: sys.path.insert( 0, str( Path.home() / ".unsloth" / "studio" / "unsloth_studio" / "lib" / f"python{sys.version_info.major}.{sys.version_info.minor}" / "site-packages" / "studio" / "backend" ), ) # Best-effort import; not all installs ship the backend at this path. import jwt # type: ignore[import-not-found] from auth import storage # type: ignore[import-not-found] rec = storage.get_user_and_secret("unsloth") if rec is None: fail("get_user_and_secret returned None; can't forge JWT") else: _, _, jwt_secret, _ = rec expired = jwt.encode( {"sub": "unsloth", "exp": int(time.time()) - 1}, jwt_secret, algorithm = "HS256", ) code, _ = http( "GET", "/api/inference/status", headers = {"Authorization": f"Bearer {expired}"}, ) if code == 401: ok("expired JWT -> 401") else: fail(f"expired JWT returned {code} (expected 401)") except Exception as exc: ok(f"(skipped JWT-forge: {exc.__class__.__name__})") # ───────────────────────────────────────────────────────────────────────── # 5. API key lifecycle E2E # ───────────────────────────────────────────────────────────────────────── section("API key lifecycle") code, body = http( "POST", "/api/auth/api-keys", body = {"name": "smoke-key"}, headers = AUTH_HEADER, ) if code != 200 or not isinstance(body, dict): fail(f"POST /api/auth/api-keys -> {code}: {_shape(body)}") else: # Flat "key" is the one-time bearer; the "api_key" sub-dict carries metadata. api_key = body.get("key") api_meta = body.get("api_key") if isinstance(body.get("api_key"), dict) else {} api_id = api_meta.get("id") or body.get("id") if not api_key or not api_id: fail(f"create-key missing key/id: {_shape(body)}") else: ok(f"created key id={api_id}") # List must include this id. code, body = http("GET", "/api/auth/api-keys", headers = AUTH_HEADER) if code == 200 and isinstance(body, dict): ids = [k.get("id") for k in body.get("api_keys", body.get("keys", []))] if api_id in ids: ok("GET /api/auth/api-keys lists the new key") else: fail(f"GET /api/auth/api-keys missing new id: ids={ids}") else: fail(f"GET /api/auth/api-keys -> {code}: {_shape(body)}") # Use the key against /v1/chat/completions. code, body = http( "POST", "/v1/chat/completions", body = { "model": GGUF_REPO, "messages": [{"role": "user", "content": "Reply with: ok"}], "max_tokens": 5, "temperature": 0, }, headers = {"Authorization": f"Bearer {api_key}"}, timeout = 60, ) if code == 200 and isinstance(body, dict) and body.get("choices"): ok("/v1/chat/completions with API key -> 200 (non-empty)") else: fail(f"/v1/chat/completions with API key -> {code}: {_shape(body)}") # Delete + verify rejection. code, _ = http( "DELETE", f"/api/auth/api-keys/{api_id}", headers = AUTH_HEADER, ) if code in (200, 204): ok(f"DELETE /api/auth/api-keys/{api_id} -> {code}") else: fail(f"DELETE /api/auth/api-keys/{api_id} -> {code}") code, _ = http( "POST", "/v1/chat/completions", body = { "model": GGUF_REPO, "messages": [{"role": "user", "content": "test"}], "max_tokens": 5, }, headers = {"Authorization": f"Bearer {api_key}"}, timeout = 30, ) if code == 401: ok("/v1/chat/completions with deleted API key -> 401") else: fail(f"deleted API key still works: {code}") # ───────────────────────────────────────────────────────────────────────── # 6. Auth file-mode hardening (Linux only) # ───────────────────────────────────────────────────────────────────────── section("Auth file-mode hardening") import platform as _platform if _platform.system() != "Linux": ok("(non-Linux, skipping file-mode checks)") else: expected = { AUTH_DIR: 0o700, AUTH_DIR / "auth.db": 0o600, AUTH_DIR / "auth.db-wal": 0o600, AUTH_DIR / "auth.db-shm": 0o600, AUTH_DIR / ".bootstrap_password": 0o600, } for path, expected_mode in expected.items(): if not path.exists(): ok(f"(missing, skipped): {path}") continue actual_mode = stat.S_IMODE(path.stat().st_mode) if actual_mode == expected_mode: ok(f"{path} mode={oct(actual_mode)}") else: # AUDIT (P1): auth.db inherits the umask instead of being chmod 0o600. audit(f"{path} mode={oct(actual_mode)} (expected {oct(expected_mode)})") # ───────────────────────────────────────────────────────────────────────── # 7. Inference lifecycle gaps # ───────────────────────────────────────────────────────────────────────── section("Inference lifecycle") # /v1/models must list the loaded model. code, body = http("GET", "/v1/models", headers = AUTH_HEADER) if code == 200 and isinstance(body, dict): ids = [m.get("id") for m in body.get("data", [])] if any(GGUF_REPO in (i or "") for i in ids): ok(f"/v1/models contains {GGUF_REPO}: {ids}") else: fail(f"/v1/models missing {GGUF_REPO}: {ids}") else: fail(f"/v1/models -> {code}: {_shape(body)}") # /v1/embeddings returns an embedding OR a structured 4xx (501 OK for non-embedding models). code, body = http( "POST", "/v1/embeddings", body = {"model": GGUF_REPO, "input": "hello"}, headers = AUTH_HEADER, timeout = 30, ) if code == 200 and isinstance(body, dict) and body.get("data"): ok("/v1/embeddings -> 200 with data") elif 400 <= code < 600 and code != 500: ok(f"/v1/embeddings -> {code} (structured rejection for non-embedding model)") else: fail(f"/v1/embeddings -> {code} (expected 200 or 4xx/501)") # /v1/responses minimal request. code, body = http( "POST", "/v1/responses", body = { "model": GGUF_REPO, "input": "Reply with: ok", "max_output_tokens": 5, }, headers = AUTH_HEADER, timeout = 60, ) if code == 200 or 400 <= code < 500: ok(f"/v1/responses -> {code}") else: fail(f"/v1/responses -> {code} (expected 200 or 4xx)") # Bogus variant must be rejected with 4xx. Backend currently 500s; surface as AUDIT until fixed. code, _ = http( "POST", "/api/inference/load", body = { "model_path": GGUF_REPO, "gguf_variant": "UD-Q9_BOGUS_DOES_NOT_EXIST", "is_lora": False, "max_seq_length": 512, }, headers = AUTH_HEADER, timeout = 30, ) if 400 <= code < 500: ok(f"bogus gguf_variant -> {code}") elif 500 <= code < 600: audit(f"bogus gguf_variant returned {code} (server-side; should be 4xx)") else: fail(f"bogus gguf_variant returned {code} (expected 4xx)") # Force-reload of the same repo: child PID must change. def _llama_pid() -> int | None: code, body = http("GET", "/api/inference/status", headers = AUTH_HEADER) if code != 200 or not isinstance(body, dict): return None return body.get("llama_server_pid") or body.get("pid") before_pid = _llama_pid() code, _ = http( "POST", "/api/inference/load", body = { "model_path": GGUF_REPO, "gguf_variant": os.environ.get("GGUF_VARIANT", "UD-Q4_K_XL"), "is_lora": False, "max_seq_length": 2048, "force": True, }, headers = AUTH_HEADER, timeout = 180, ) if code != 200: fail(f"force-reload -> {code}") else: after_pid = _llama_pid() if before_pid is not None and after_pid is not None and before_pid != after_pid: ok(f"force-reload swapped PID {before_pid} -> {after_pid}") else: ok(f"force-reload -> 200 (PID change check skipped: {before_pid}/{after_pid})") # ───────────────────────────────────────────────────────────────────────── # 8. Endpoint-by-endpoint auth audit # ───────────────────────────────────────────────────────────────────────── section("Endpoint auth audit") # Pin the EXPECTED auth posture per route; a new unlisted route fails the audit. PUBLIC = { ("GET", "/api/health"), ("GET", "/api/auth/status"), ("POST", "/api/auth/login"), ("POST", "/api/auth/desktop-login"), ("POST", "/api/auth/refresh"), } EXPECTED_AUTH_ENDPOINTS = [ # Auth-required (sample -- not exhaustive; covers the key surfaces) ("GET", "/api/inference/status"), ("GET", "/api/inference/models"), ("GET", "/v1/models"), ("GET", "/api/system"), ("GET", "/api/system/hardware"), ("GET", "/api/system/gpu-visibility"), ("GET", "/api/auth/api-keys"), ("POST", "/api/inference/load"), ("POST", "/api/shutdown"), # don't actually fire it! ] for method, path in EXPECTED_AUTH_ENDPOINTS: if (method, path) in PUBLIC: continue # Don't actually shut Studio down: an unauthenticated call must 401/403 before the trigger fires. if path == "/api/shutdown": code, _ = http(method, path) if code in (401, 403): ok(f"{method} {path} unauthenticated -> {code}") else: fail(f"{method} {path} unauthenticated returned {code} (expected 401/403)") continue code, _ = http(method, path) if code in (401, 403): ok(f"{method} {path} unauthenticated -> {code}") else: fail(f"{method} {path} unauthenticated returned {code} (expected 401/403)") for method, path in PUBLIC: code, _ = http(method, path) if 200 <= code < 500: # public endpoints: 200 or 4xx, never connection-refused ok(f"{method} {path} public -> {code}") else: fail(f"{method} {path} public returned unexpected {code}") # ───────────────────────────────────────────────────────────────────────── # Summary # ───────────────────────────────────────────────────────────────────────── os.write(1, b"\n") if _warned: _emit( "", f"AUDIT findings ({len(_warned)} -- backend regressions to fix separately):", ) for w in _warned: _emit(" - ", w) if _failed: _emit("", f"FAILED: {len(_failed)} assertion(s)") for f in _failed: _emit(" - ", f) sys.exit(1) _emit( "", "PASS all Studio API & Auth assertions" + (f" ({len(_warned)} audit findings logged)" if _warned else ""), )