CI(studio): new Studio API & Auth Tests workflow + integration test
HTTP-level integration smoke for the Studio FastAPI surface, no
Playwright. ~30 s per run on warm cache. Boots a fresh Studio, then
asserts:
1. CORS hardening -- no wildcard-origin + credentials=true; cross-
origin GET / does not leak the bootstrap password to evil.example.
2. /api/system + /api/system/hardware + /api/system/gpu-visibility
all require auth (closes the info-disclosure leak).
3. Auth state machine -- rotation invariants (old=401, new=200),
refresh-without-body returns 4xx, login burst documents the
current "no rate-limit" behaviour so future hardening updates the
test in the same PR.
4. JWT-expiry forgery -- mint a JWT with exp=now-1 using the install's
own secret + assert it returns 401.
5. API key lifecycle E2E -- create -> list -> use against
/v1/chat/completions -> delete -> verify 401.
6. Auth file-mode hardening (Linux only): auth/ is 0700, auth.db +
-wal + -shm + .bootstrap_password are 0600.
7. Inference lifecycle gaps -- /v1/models lists the loaded model,
/v1/embeddings + /v1/responses return 200 OR structured 4xx,
bogus gguf_variant rejected, force-reload swaps the llama-server
PID.
8. Endpoint-by-endpoint auth audit -- pins the EXPECTED auth posture
for known routes; an unauthenticated /api/shutdown is rejected
BEFORE the shutdown trigger fires.
Reuses the same GGUF cache key as studio-ui-smoke.yml so the model
download is one cache-hit across CI.
Random per-run rotated passwords + ::add-mask:: pattern matches
studio-ui-smoke.yml + studio-inference-smoke.yml.
This commit is contained in:
parent
0e9888645f
commit
29f2829582
2 changed files with 706 additions and 0 deletions
153
.github/workflows/studio-api-smoke.yml
vendored
Normal file
153
.github/workflows/studio-api-smoke.yml
vendored
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
|
||||
|
||||
# Studio API & Auth Tests -- HTTP-level integration tests for the
|
||||
# FastAPI surface. No Playwright, no model UI; tests/studio/test_studio_api_smoke.py
|
||||
# runs ~30 s and asserts:
|
||||
# - CORS hardening (no wildcard + credentials, no bootstrap leak)
|
||||
# - /api/system + /api/system/hardware require auth
|
||||
# - Auth state machine + JWT expiry
|
||||
# - API key lifecycle E2E (create / list / use / delete / reject)
|
||||
# - Auth file-mode hardening (Linux only)
|
||||
# - Inference lifecycle (force reload, bogus variant, /v1/models, /v1/embeddings, /v1/responses)
|
||||
# - Endpoint-by-endpoint auth audit
|
||||
#
|
||||
# Reuses the GGUF cache key from studio-ui-smoke.yml so the model
|
||||
# download is one cache-hit on the second job.
|
||||
|
||||
name: Studio API CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'studio/**'
|
||||
- 'unsloth/**'
|
||||
- 'unsloth_cli/**'
|
||||
- 'install.sh'
|
||||
- 'pyproject.toml'
|
||||
- 'tests/studio/**'
|
||||
- '.github/workflows/studio-api-smoke.yml'
|
||||
push:
|
||||
branches: [main, pip]
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
api-smoke:
|
||||
name: Studio API & Auth Tests
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 12
|
||||
env:
|
||||
GGUF_REPO: unsloth/gemma-3-270m-it-GGUF
|
||||
GGUF_VARIANT: UD-Q4_K_XL
|
||||
GGUF_FILE: gemma-3-270m-it-UD-Q4_K_XL.gguf
|
||||
STUDIO_PORT: '18893'
|
||||
HF_HOME: ${{ github.workspace }}/hf-cache
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
|
||||
- name: Linux deps
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
libcurl4-openssl-dev libssl-dev jq
|
||||
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: studio/frontend/package-lock.json
|
||||
|
||||
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
|
||||
with:
|
||||
python-version: '3.12'
|
||||
cache: 'pip'
|
||||
|
||||
- name: Cache HF_HOME for ${{ env.GGUF_REPO }}
|
||||
id: cache-hf
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: hf-cache
|
||||
# Same key as studio-ui-smoke.yml so the two jobs share a
|
||||
# single GGUF download across CI.
|
||||
key: ${{ runner.os }}-hf-${{ env.GGUF_REPO }}-${{ env.GGUF_VARIANT }}-v1
|
||||
|
||||
- name: Prime HF_HOME with the GGUF
|
||||
if: steps.cache-hf.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
python -m pip install --upgrade huggingface_hub hf_transfer
|
||||
mkdir -p hf-cache
|
||||
HF_HUB_ENABLE_HF_TRANSFER=1 \
|
||||
hf download "$GGUF_REPO" "$GGUF_FILE"
|
||||
|
||||
- name: Install Studio (--local, --no-torch)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
mkdir -p logs
|
||||
set -o pipefail
|
||||
bash install.sh --local --no-torch 2>&1 | tee logs/install.log
|
||||
|
||||
- name: Install pyjwt for the JWT-expiry forge test
|
||||
run: pip install 'pyjwt>=2.6'
|
||||
|
||||
- name: Reset auth + boot Studio (API-only)
|
||||
run: |
|
||||
unsloth studio reset-password
|
||||
mkdir -p logs
|
||||
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \
|
||||
> logs/studio.log 2>&1 &
|
||||
echo "STUDIO_PID=$!" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Wait for /api/health
|
||||
run: |
|
||||
for i in $(seq 1 180); do
|
||||
if curl -fs "http://127.0.0.1:${STUDIO_PORT}/api/health" > /tmp/health.json; then
|
||||
jq -e '.status == "healthy"' /tmp/health.json && break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
jq -e '.status == "healthy"' /tmp/health.json
|
||||
|
||||
- name: Pass bootstrap password + rotated targets to the test
|
||||
# The test does its own bootstrap-login + rotation to exercise
|
||||
# the auth state machine; we just pre-mint two random rotated
|
||||
# passwords for it. Mask them so the log is clean.
|
||||
run: |
|
||||
OLD=$(cat ~/.unsloth/studio/auth/.bootstrap_password)
|
||||
NEW="ApiSmoke-$(python -c 'import secrets; print(secrets.token_urlsafe(16))')"
|
||||
NEW2="ApiSmoke-$(python -c 'import secrets; print(secrets.token_urlsafe(16))')"
|
||||
echo "::add-mask::$OLD"
|
||||
echo "::add-mask::$NEW"
|
||||
echo "::add-mask::$NEW2"
|
||||
echo "STUDIO_OLD_PW=$OLD" >> "$GITHUB_ENV"
|
||||
echo "STUDIO_NEW_PW=$NEW" >> "$GITHUB_ENV"
|
||||
echo "STUDIO_NEW2_PW=$NEW2" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Run Studio API & Auth tests
|
||||
env:
|
||||
BASE_URL: http://127.0.0.1:18893
|
||||
STUDIO_AUTH_DIR: /home/runner/.unsloth/studio/auth
|
||||
run: python tests/studio/test_studio_api_smoke.py
|
||||
|
||||
- name: Stop Studio
|
||||
if: always()
|
||||
run: |
|
||||
kill "${STUDIO_PID}" 2>/dev/null || true
|
||||
sleep 2
|
||||
|
||||
- name: Upload API smoke logs
|
||||
if: always()
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: studio-api-smoke-log
|
||||
path: |
|
||||
logs/install.log
|
||||
logs/studio.log
|
||||
retention-days: 7
|
||||
553
tests/studio/test_studio_api_smoke.py
Normal file
553
tests/studio/test_studio_api_smoke.py
Normal file
|
|
@ -0,0 +1,553 @@
|
|||
# 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 tests.
|
||||
|
||||
Boots a fresh Studio externally (CI workflow handles install + boot)
|
||||
and runs a battery of HTTP-level integration tests against it. No
|
||||
Playwright, no model load by this test (the workflow loads gemma-3-270m
|
||||
beforehand if needed).
|
||||
|
||||
Sections:
|
||||
1. CORS hardening (no wildcard + credentials, no bootstrap leak)
|
||||
2. /api/system + /api/system/hardware require auth
|
||||
3. Auth state machine (rotation invariants, body validation, login burst)
|
||||
4. JWT-expiry rejection (forge an expired token using the install's secret)
|
||||
5. API key lifecycle E2E (create -> list -> use -> delete -> reject)
|
||||
6. Auth file-mode hardening (Linux only)
|
||||
7. Inference lifecycle gaps (force reload, bogus variant, /v1/models,
|
||||
/v1/embeddings, /v1/responses)
|
||||
8. Endpoint-by-endpoint auth audit (pin EXPECTED auth posture per route)
|
||||
|
||||
Env:
|
||||
BASE_URL http://127.0.0.1:18893 (or wherever Studio is)
|
||||
STUDIO_OLD_PW the bootstrap password (must rotate it)
|
||||
STUDIO_NEW_PW what to rotate to
|
||||
STUDIO_NEW2_PW out-of-band rotation target
|
||||
STUDIO_AUTH_DIR (optional) path to the auth dir for file-mode checks
|
||||
GGUF_REPO (optional) the model the workflow loaded for /v1 tests
|
||||
"""
|
||||
|
||||
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] = []
|
||||
|
||||
|
||||
def section(title: str) -> None:
|
||||
_section[0] += 1
|
||||
print(f"\n=== {_section[0]}. {title} ===", flush = True)
|
||||
|
||||
|
||||
def ok(msg: str) -> None:
|
||||
print(f" OK {msg}", flush = True)
|
||||
|
||||
|
||||
def fail(msg: str) -> None:
|
||||
"""Record a failure but keep running so we report ALL failures, not just the first."""
|
||||
print(f" FAIL {msg}", flush = True)
|
||||
_failed.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. FastAPI explicitly forbids
|
||||
# Access-Control-Allow-Origin: <origin> together with
|
||||
# Access-Control-Allow-Credentials: true. (Wildcard + credentials is
|
||||
# also forbidden by the browser.) Either response is acceptable; the
|
||||
# bad pattern is a wildcard origin echoed alongside credentials.
|
||||
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 / from a cross-origin Origin header. The response body must NOT
|
||||
# contain the literal bootstrap password (the security audit flagged
|
||||
# that __UNSLOTH_BOOTSTRAP__ injection in the served HTML can be
|
||||
# fetched cross-origin under wildcard CORS).
|
||||
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:
|
||||
fail("CORS: GET / leaks bootstrap password 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 so we have a working bearer for the rest.
|
||||
# (Bootstrap login -> change-password -> login with 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}: {body!r}")
|
||||
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: must succeed now.
|
||||
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)")
|
||||
|
||||
# Load the model. 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}: {body!r}")
|
||||
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)")
|
||||
|
||||
# Login burst with wrong password must keep returning 401, NOT 429.
|
||||
# Documents that no rate-limit / brute-force lockout exists today.
|
||||
# When/if we add one, this assertion updates in the same PR.
|
||||
all_401 = True
|
||||
for i in range(5):
|
||||
code, _ = login("definitely-wrong-password")
|
||||
if code != 401:
|
||||
all_401 = False
|
||||
fail(f"login burst attempt {i+1} returned {code} (expected 401)")
|
||||
break
|
||||
if all_401:
|
||||
ok("login burst (5x wrong pw) -> 401 each (no rate-limit, documented)")
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# 4. JWT-expiry rejection
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
section("JWT expiry")
|
||||
# Forge a JWT with exp=now-1 using the install's signing secret.
|
||||
# auth/storage.py: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}: {body!r}")
|
||||
else:
|
||||
api_key = body.get("api_key") or body.get("key")
|
||||
api_id = body.get("id")
|
||||
if not api_key or not api_id:
|
||||
fail(f"create-key missing key/id: {body!r}")
|
||||
else:
|
||||
ok(f"created key id={api_id}")
|
||||
# The API key may use sk-unsloth-* or another prefix; we don't
|
||||
# pin the literal prefix.
|
||||
# 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}: {body!r}")
|
||||
|
||||
# Use the key against /v1/chat/completions (the workflow has
|
||||
# already loaded gemma-3-270m).
|
||||
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}: {body!r}")
|
||||
|
||||
# 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:
|
||||
fail(
|
||||
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}: {body!r}")
|
||||
|
||||
# /v1/embeddings either returns embedding OR structured 4xx.
|
||||
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 < 500:
|
||||
ok(f"/v1/embeddings -> {code} (model not embedding-capable, structured)")
|
||||
else:
|
||||
fail(f"/v1/embeddings -> {code} (expected 200 or 4xx)")
|
||||
|
||||
# /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.
|
||||
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}")
|
||||
else:
|
||||
fail(f"bogus gguf_variant returned {code} (expected 4xx)")
|
||||
|
||||
# Force-reload of the same repo: child PID must change.
|
||||
# Read the inference status before.
|
||||
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 for known routes. A new route added
|
||||
# without an entry here fails the audit, forcing the author to make
|
||||
# the auth decision explicit.
|
||||
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 -- verify auth check by sending
|
||||
# an empty body / no auth header. If the check happens BEFORE the
|
||||
# shutdown trigger (which is the design), we get a 401/403 without
|
||||
# any side effects.
|
||||
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 either 200 or 4xx (bad input), never connection-refused
|
||||
ok(f"{method} {path} public -> {code}")
|
||||
else:
|
||||
fail(f"{method} {path} public returned unexpected {code}")
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# Summary
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
print()
|
||||
if _failed:
|
||||
print(f"FAILED: {len(_failed)} assertion(s)")
|
||||
for f in _failed:
|
||||
print(f" - {f}")
|
||||
sys.exit(1)
|
||||
print("PASS all Studio API & Auth assertions")
|
||||
Loading…
Add table
Add a link
Reference in a new issue