* studio: redesign chat composer Reworks the new-chat composer and the compare composer into a single rounded pill surface with a softer, lighter look. - New welcome screen with a time-of-day sloth mascot and a lighter heading. - One rounded composer surface with a soft drop shadow. The input grows inline as you type and collapses back to a single row when cleared. - Tools and attachments live in a single plus menu; the thinking control is a compact pill with a reasoning-effort submenu. - Inlined glyphs for the thinking, send, and dictate controls, kept in sync across the main and compare composers. - Toast notifications match the composer surface: no border line, the same drop shadow, and the same dark surface color, with a ring-less close button. - Dark mode: the side-menu shadow blends into the background, hovered menu rows read clearly, and their roundness matches light mode. - Composer styles use dedicated unsloth- prefixed classes so compare mode keeps its own stacked layout. * studio: sync compare-composer reasoning state and harden compare id - Compare composer: keep "Preserve thinking" consistent with reasoning, matching the main composer. Enabling it now turns reasoning on, and disabling reasoning (the None option or the Thinking toggle) turns it off, so the invalid "preserve on while thinking off" state can't occur. - Guard crypto.randomUUID in the Compare action. It is undefined in non-secure contexts (HTTP over a LAN IP) and would throw; fall back to a timestamped random id, matching createNavigationNonce. * studio: reflect pre-selected Search/Code tools when no model is loaded The Search and Code pills only lit up when the tool was usable right now (a model loaded and capable), so a tool turned on from the + menu showed as off in the pill while the menu showed it on. toolsEnabled is persisted and takes effect once a capable model loads, so the pill should reflect it. The pills now disable only when a loaded model lacks the capability, and otherwise reflect the selected state. Applied to the main and compare composers. * Studio: link MCP Servers heading to its PR and fix composer pill cursors Make the "MCP Servers" heading in the chat Configuration sheet link to the MCP PR, keeping the chevron as the toggle. The label and chevron are rendered as siblings so we don't nest an <a> inside a <button>. Also add cursor-pointer to the composer pills and the thinking pill so hovering a clickable pill shows the hand cursor instead of the default arrow. * Studio: refine chat composer and add compare-mode parity - Composer expands to two rows only once the input wraps to a second line, not on the first keystroke. Re-measure the autosize textarea on the width swap so expanding no longer leaves a stray blank row. - Light-mode composer shadow now matches Gemini's soft elevation. - Plus menu: replace Canvas with a More submenu (Canvas, Compare chat, RAG) and add Code above MCP. Active Web search/Code items use medium weight. - Compare mode: the plus side menu, Search/Code toggles, and a Compare exit pill now match single chat, with the thinking control on the right. - Projects menu entries link to their tracking PR (#5725). - Add cursor-pointer to the composer plus button. * studio: refine composer controls and chat search shadow - Active tool pills show an x on hover to signal click-to-disable - Plus button rotates into an x when the tools menu opens - Composer surface uses a 32px radius and a taller single-line height - Even, ChatGPT-style spacing between the plus and tool pills in both the single and compare composers - Send and mic circles resized and spaced, with the arrow centered in the circle - Chat search box gets a borderless, soft Gemini-style shadow * studio: size the pill hover x to match the icon it replaces Cross-engine checks (Chromium, Firefox, WebKit) flagged the active-pill hover x as a fixed 14px, so it popped smaller than the 19px Code icon. Fill the glyph slot instead so the x tracks whatever icon it covers. * studio: do not persist Kimi search/thinking mutual-exclusion in single composer The single-chat composer flipped the other control off when toggling search or thinking on Kimi, but without { persist: false }, so it overwrote the user's saved preference. Match shared-composer and keep the side effect session-only. * studio: pointer cursor on model selector trigger and menu items Add scoped marker classes so the model picker trigger and every clickable element in its menu (tabs, model rows, delete, eject) show a pointer cursor; disabled items stay not-allowed. * studio: pass baseUrl when resolving reasoning caps in single composer The docked composer omitted baseUrl, so a custom Gemini OpenAI-compat gateway still advertised the native thinking ladder the backend cannot honor. Pass selectedExternalProvider.baseUrl like the compare composer so the resolver hides it. * studio: grey side-menu hover, green pill hover, thinking hover x - Plus side-menu items hover grey in light mode, not the green accent - Thinking pill hovers green like the Search and Code pills - The plain Thinking toggle shows an x on hover when active, matching Search and Code; the effort dropdown trigger keeps its bulb * studio: make the pill hover x a uniform size The x filled the icon slot, so the wider Code chevron gave a bigger x than Search and Compare. Pin it to a fixed 15px, centered, so every pill's x matches. * studio: broaden chat attachments, fix active hover color, gemini shadow - Accept svg, source code and many text/config files as drag-and-drop or picked attachments, matched by extension since their MIME is unreliable; html keeps its own adapter - Active (green) side-menu items keep their text and icon color on hover instead of switching to the accent color - Composer surface uses Gemini's soft centered shadow 0 0 20px rgba(0,0,0,0.04) * studio: keep the thinking pill full height when icon-only The inactive thinking pill has no label, so its flex row collapsed to the icon height and the hover box looked short. Reserve one text line (min-height: 1lh + padding) so it matches the Search and Code pills. * studio: refine composer menu, drop overlay and greetings - Open the MCP servers dialog directly from the composer plus menu - Redesign the drag-and-drop affordance Gemini style, drop the badge and border, make the whole chat page a drop target - Swap in Hugeicons for the RAG, attachment chip and new project icons - Add time-based randomized welcome greetings, each matched to a fitting sloth * studio: rename artifacts toggle to Canvas and make it opt-in - Label the toggle Canvas everywhere, matching the plus menu - Stop greying out the Canvas menu item; it toggles like the other items - Only show the Canvas pill in the composer row once it is turned on, since it is less central than Search and Code * studio: wire Canvas and MCP composer toggles, even out the pill row - Open the MCP servers dialog from the menu, or toggle MCP on/off once a server is enabled - Force MCP off when no server is enabled, so the toggle stays honest - Show Canvas and MCP as opt-in pills that appear in the order they were toggled on - Expand the composer and light up the pill when Canvas or MCP is on, like Search and Code - Keep Compare directly after Code in the compare composer - Use the same Code icon on both composers and give every pill an even icon slot * studio: tidy composer toggle row and fix MCP enable/disable lifecycle - Enable MCP automatically after a server is configured via the toggle flow - Force MCP off everywhere once the last enabled server is removed - Collapse the pill labels to icons only when more than 4 pills show, keeping Compare labelled - Order Compare first in compare mode, before Search and Code - Use the same Code icon and an even 19px icon slot across both composers - Match the compare composer surface padding and send button inset to normal chat * studio: revert compare composer padding change that cramped the input Matching the surface padding to normal chat clipped the textarea text and left a white strip on top. Restore the compare composer's own padding, which gives proper top spacing. The send button inset fix stays. * studio: center welcome greeting and soften composer scrollbar Center the sloth and title together over the composer instead of shifting the row left, which left the greeting sitting off to the side. Keep the composer textarea scroll thumb faint by default and only darken it when the thumb is hovered or dragged, so a tall draft no longer shows a heavy dark rail. * studio: match composer plus-menu tool gating to the pills The new plus-menu tool entries did not carry the gating the visible pills already enforce, so the menu and pills could disagree about a loaded model's capabilities. - Web search and Code menu items now disable when a loaded model lacks the capability, while still allowing preselection with no model loaded. - Enabling Web search from the menu on a Kimi model now flips thinking off as a session-only change, since Kimi forbids search and thinking together. This matches the Search pill. - Added an Images menu item, shown only for image-generation models and disabled until a model loads, so a short prompt has an entry point. Applied to both the single-chat and compare composers. * studio: round the active-pill hover x and even out pill padding The hover x sat bare and the trailing label was tighter to the pill edge than the leading icon, so the pill looked lopsided. - Give the hover x a soft circular background that fills the icon slot, matching the ChatGPT-style toggle and the icon it replaces. - Add a little more trailing padding so the label and the leading icon have even breathing room, and keep icon-only compact pills symmetric. * studio: nudge the thinking bulb icon up by 0.5px Bump the thinking lightbulb from 15px to 15.5px in the single-chat and compare composers so it sits a touch larger next to the other controls. * studio: drop the hover x circle on icon-only pills When pills collapse to icon-only, the circle around the hover x is too cramped in the small chip, so show a bare x there and keep the circle only on the full-width labelled pills. * studio: space the compare send button like normal chat In compare mode the Thinking control sat right against the send button. Match the normal composer's control spacing (gap-1.5 plus a send margin) so Thinking has the same breathing room before send. The send button keeps its 14px inset, so its position is unchanged. * studio: make collapsed pill hover a circle, not a wide pill Icon-only pills were wider than tall, so their rounded-full hover highlight read as a fat rounded rectangle. Make the compact button a square and center the glyph so the hover (and the x it reveals) sits in a clean circle. * studio: fix compare pane drops and audio picker lifetime - Skip the page-level drop handler when the composer is hidden, so files dropped on a compare pane are not swallowed by a hidden composer; the shared compare composer keeps handling drops through its own dropzone. - Build the audio file input on document.body instead of inside the plus menu, so the menu closing on select no longer unmounts the input before the OS picker returns and drops the file. * studio/chat: stop projects list from white-screening on older backends The projects list API returned data.projects directly, so a backend that omits the field handed back undefined. useChatProjects cached that value, then the next mount read undefined.length and crashed the whole chat page. Default the projects and threads list APIs to an empty array and keep the hook null-safe so a bad response can never poison the cache. * studio/chat: align MCP dropdown with the + menu and add a chevron Reuse the + menu surface (unsloth-plus-menu) for the MCP dropdown: rounded corners, narrower width, neutral grey hover, and enabled rows shown as green text with a right-aligned check instead of the emerald underlay. Add a chevron to the MCP pill so it reads as openable, matching the Thinking pill. * studio/chat: make MCP an opt-in pill and fix its dropdown placement - MCP is back in the + menu as a toggle. The pill now only shows in the composer when MCP is on, matching Canvas, instead of always sitting there. - The dropdown follows the composer side like the + menu (opens down in the welcome composer, up when docked) rather than always opening upward. - Drop the dropdown caret when pills collapse so the icon is not squished. - Stop force-syncing mcpEnabledForChat to the server count; the + menu owns it. * studio/chat: MCP expands the composer, drop sidebar Compare, tidy scrollbars - Toggling MCP now expands the composer and shows the tool pills, the same as Canvas, instead of leaving the row collapsed. - Remove the Compare item from the sidebar now that it lives in the + menu, and point the compare tour step at the side-by-side view instead of the old button. - Both sidebars only show their scrollbar on hover, and run settings reserves the scrollbar gutter so the close button no longer shifts when it appears. * studio/chat: tighten toggle gap, fix run-settings close button, collapsed Train - Reduce the composer toggle gap by 2px (gap-1 to gap-0.5) in both composers. - Move the run settings header out of the scroll area so the close button keeps its position whether or not the scrollbar shows, and sits flush with the topbar open button again instead of shifting left. - Surface Train as an icon in the collapsed sidebar (it already has a labelled section when expanded). * studio/chat: tighten Thinking pill X padding, create projects inline - The Thinking pill used px-2.5, so the hover X sat further in than the left pills. Match their pl-2 so the X lines up. - The + menu New project now opens a create dialog and jumps straight to the new project, instead of routing to the projects list. Shared by both composers via a small NewProjectDialog. * studio/chat: soften account menu, hover scrollbars, show collapsed chevrons - Account menu drops its border ring for the composer's soft shadow and opens centered over its trigger. - Settings and search reuse the hover-only scrollbar via a shared hover-scrollbar class, matching the sidebars. - Train and Recents keep their chevron visible while collapsed so it is clear they can be expanded. * studio/chat: roomier, more rounded account menu Widen the account menu, add more left and right padding on the rows, bump the row height and text a touch, and round the corners more, closer to the GPT account menu. * studio/chat: trim account menu width and nudge it up 2px Pull the account menu in slightly on the left and right (narrower box, a touch less row padding) and lift it 2px higher above the trigger. * studio/settings: drop outline ring, circular close hover, pointer cursors - Remove the settings dialog outline ring, keeping just the soft shadow. - The close button hover is now a circle instead of a rounded rectangle. - Every clickable control in the settings dialog uses a pointer cursor. * studio/chat: bump MCP pill icon to 14.5px Nudge the MCP icon up 0.5px so it sits even with the other pill glyphs. * studio/chat: bump MCP pill icon to 15px Nudge the MCP icon up another 0.5px. * studio/settings: add a Settings title above the tabs Put a Settings heading at the top of the sidebar so the tabs sit below it, matching the Claude settings layout. Hidden on mobile where the nav is a row. * studio/settings: rounder tab hover, bigger title, less-round search dialog * studio/sidebar: round nav row hover boxes 2px more (10px to 12px) * studio: drop settings dark shadow + divider, add tab left padding, tune hover roundness * studio/model-selector: roomier padding, borderless box, rounder hover rows; settings divider light-only * studio/search: match chat box shadow (soft light, none dark) * studio/sidebar: borderless chat context menus, rename submenu to Projects with folder-export icon * studio/model-selector: match light corner radius in dark, drop dark shadow, more visible dark hover * studio/sidebar: chat context menu matches + side menu styling; relabel submenu Move to project * studio: borderless message export menu (no dark shadow), match dark corner radius to light on export menu and settings * studio/sidebar: open chat options menu GPT-style (down-right) and widen so Move to project fits one line * studio/chat: message export menu uses the chatbox shadow in light mode * studio/sidebar: narrow chat options menu slightly (w-60 to w-56) * studio: unify all download icons to Hugeicons download-01; round profile button hover 1px more * studio/run-settings: bump header to 16px * studio/sidebar: trim chat options menu width slightly (w-56 to 216px) * studio/sidebar: trim chat options menu width to w-52 * studio/profile: camera-01 Hugeicons glyph and chatbox shadow on avatar button * studio: match dark-mode corner radius to light globally (single --radius token) * studio/recipes: borderless New Recipe menu with chatbox shadow in light, none in dark * studio: borderless dropdowns globally, chatbox shadow in light, none in dark * studio: extend borderless + chatbox/none shadow to select, combobox and popover overlays * studio/mcp: nudge MCP dropdown radius to 20px so its wider box reads as round as the + menu * studio: restore dark dropdown shadow to avoid same-color merge; greet name ~1/3 of lines; bigger sloth + more gap * studio/train: active tab is a borderless pill (no underline), roomier padding, more tab gap and bottom spacing * studio/chat: nudge welcome up ~5px (still vh-based) and trim sloth image to 44px * studio/train: active tab pill is white with chatbox shadow in light, taller padding * studio/chat: welcome offset to calc(30vh - 10px) * studio/chat: welcome offset to 28vh (drop the -10px) * studio/chat: tighten sloth-to-text gap by 1px (16px to 15px) * studio/train: revert light active pill to grey fill, drop white bg + shadow * studio: app-wide hand cursor on every clickable control (disabled excluded) * studio/chat: welcome offset to 26vh * studio/chat: welcome offset to 28vh * studio/chat: harden project and thread list guards against non-array payloads * studio/sidebar: give the profile row more height and breathing room * studio/sidebar: trim the profile row top and bottom padding slightly * studio/sidebar: reduce Train and Recents section label size slightly * studio/sidebar: trim the profile row top and bottom padding a touch more * studio/sidebar: enlarge the profile hover area top and bottom * studio/sidebar: increase profile hover roundness by 1px * studio/sidebar: trim the profile row top and bottom padding slightly * studio/sidebar: trim the profile row top and bottom padding slightly * studio/chat: cache composer line metrics so wrap detection runs once, not per keystroke * studio/chat: restore the prior view when exiting compare opened from the + menu * studio/tests: drive Compare from the composer + menu after it moved out of the sidebar * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * studio/tests: open Compare from the composer + menu in the extra UI suite too * studio: fix chat dictation microphone access * studio: snappier plus-to-x spin and steady composer expand gap Speed up the composer plus icon morph from 480ms to 300ms. Add row-gap on the expanded composer line so the space between the text and the controls row stays the same whether the box expanded from wrapped text or from a toggle being on. The gap sits on the line, not the input, so the placeholder max-height clamp never crops it. * studio: only show composer tool pills once a model is loaded Persisted Search/Code/Canvas/MCP toggles were surfacing the composer pill row on a fresh page load before any model was selected, so an empty composer looked different from the clean just-ejected state. Gate the composerExpanded tool checks on modelLoaded so a model-less composer stays collapsed, while saved preferences still apply the moment a model loads. * studio: hide RAG composer menu item temporarily Hide the placeholder RAG entry from the composer plus menu in both single chat and compare until the feature is ready, and drop the now-unused DatabaseIcon import. * studio: let composer tools pre-select before a model loads Selecting Web search, Code, Canvas or MCP from the + menu with no model loaded did nothing visible: the toggle turned on but the composer never expanded, so the pill stayed hidden. Drop the model-loaded gate from the expand check so an active tool always surfaces its pill. Align MCP with the Search/Code pattern too: grey it out only when a loaded model lacks tool support, so MCP stays toggleable and the pill stays clickable before a model is loaded instead of looking disabled. --------- Co-authored-by: Unsloth <michaelhan@Michaels-MacBook-Pro.local> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: wasimysaid <wasimysdev@gmail.com> Co-authored-by: Lee Jackson <130007945+Imagineer99@users.noreply.github.com> Co-authored-by: Daniel Han <23090290+danielhanchen@users.noreply.github.com>
399 lines
14 KiB
Python
399 lines
14 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_src_allows_google_favicons(self, main_module):
|
|
# sources.tsx fetches https://www.google.com/s2/favicons?... ; without
|
|
# this allowlist entry citation favicons fall back to gray initials.
|
|
csp = main_module._build_csp()
|
|
img_directive = next(
|
|
chunk.strip()
|
|
for chunk in csp.split(";")
|
|
if chunk.strip().startswith("img-src ")
|
|
)
|
|
# Tokenise and compare with `==` so CodeQL's URL-substring rule does
|
|
# not read directive-string `in` membership as URL sanitisation.
|
|
img_sources = img_directive.split()
|
|
assert any(src == "https://www.google.com" for src in img_sources)
|
|
# Pre-existing favicon CDNs stay allowed.
|
|
for host in (
|
|
"https://t0.gstatic.com",
|
|
"https://t1.gstatic.com",
|
|
"https://t2.gstatic.com",
|
|
"https://t3.gstatic.com",
|
|
):
|
|
assert any(src == host for src in img_sources)
|
|
|
|
|
|
# =====================================================================
|
|
# /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 available 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 made 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}"
|