unsloth/studio/backend/tests/test_rag_preview_routes.py
Daniel Han d1348cac3f Studio: tighten RAG code comments
Shorten and condense comments across the RAG backend, frontend, and
tests for readability. Comment text only; no code, strings, identifiers,
or logic changed. License headers and lint/type pragmas are preserved.
2026-05-31 08:31:08 +00:00

626 lines
24 KiB
Python

# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Tests for GET /api/rag/documents/{id}/preview-target and /file.
Acceptance criteria covered (contracts.md §1, §2, PLAN.md T1/T2, Risk #1-3):
/preview-target:
- 200 with chunk data when chunk_id present and belongs to doc.
- 200 with all-null chunk fields when chunk_id absent (document-row preview).
- 404 when document missing (collapsed existence + auth).
- 404 when chunk_id does not belong to document_id (cross-doc probe collapsed).
- 401 when no bearer token.
/file:
- 200 with correct Content-Type and nosniff header.
- X-Content-Type-Options: nosniff present on every 200.
- Cache-Control: private present on every 200.
- HTML extension served as text/plain + attachment (Risk #3).
- DOCX extension served with attachment disposition.
- 404 when document missing or wrong subject.
- 404 when file deleted from disk (DB row exists, subject authorized).
- Outside-root stored_path returns 404 (path containment, Risk #2).
Auth is injected via dependency override (mock at the boundary, not the
implementation target). We do NOT mock document_for_subject_or_404 itself.
"""
from __future__ import annotations
import os
import uuid
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
import storage.studio_db as studio_db
from auth.authentication import get_current_subject
# ── App import (deferred to avoid import-time side-effects) ───────────
@pytest.fixture(scope = "module")
def app():
import sys
backend_dir = str(Path(__file__).resolve().parent.parent)
if backend_dir not in sys.path:
sys.path.insert(0, backend_dir)
from main import app as _app
return _app
# ── Test-level DB + auth fixtures ─────────────────────────────────────
@pytest.fixture
def db_env(tmp_path, monkeypatch):
"""Point studio_db at a fresh temp DB for each test."""
monkeypatch.setenv("UNSLOTH_STUDIO_HOME", str(tmp_path))
monkeypatch.setattr(studio_db, "_schema_ready", False)
return tmp_path
def _uid() -> str:
return str(uuid.uuid4())
def _make_client(app, subject: str = "alice"):
"""Return a TestClient with get_current_subject overridden to return subject."""
app.dependency_overrides[get_current_subject] = lambda: subject
client = TestClient(app, raise_server_exceptions = True)
return client
def _clear_overrides(app):
app.dependency_overrides.clear()
def _insert_kb(conn, kb_id: str, owner: str = "alice") -> None:
conn.execute(
"INSERT INTO rag_knowledge_bases (id, name, embedding_model, owner_user_id, created_at) "
"VALUES (?, ?, ?, ?, ?)",
(kb_id, f"KB-{kb_id[:6]}", "bge-small", owner, 1_700_000_000),
)
def _insert_doc(
conn,
doc_id: str,
kb_id: str,
stored_path: str,
filename: str = "report.pdf",
content_type: str | None = "application/pdf",
status: str = "completed",
) -> None:
conn.execute(
"INSERT INTO rag_documents "
"(id, kb_id, thread_id, filename, content_type, stored_path, status, "
"num_chunks, byte_size, created_at) "
"VALUES (?, ?, NULL, ?, ?, ?, ?, 0, 1024, ?)",
(doc_id, kb_id, filename, content_type, stored_path, status, 1_700_000_000),
)
def _insert_chunk(
conn,
chunk_id: str,
doc_id: str,
text: str = "The margin rose to 18.2% in Q3.",
page_number: int | None = 7,
chunk_index: int = 14,
) -> None:
conn.execute(
"INSERT INTO rag_chunks "
"(id, document_id, chunk_index, text, token_count, page_number) "
"VALUES (?, ?, ?, ?, ?, ?)",
(chunk_id, doc_id, chunk_index, text, 30, page_number),
)
# ── /preview-target tests ─────────────────────────────────────────────
class TestPreviewTarget:
def test_with_chunk_id_returns_full_metadata(self, app, db_env, monkeypatch):
"""GET /preview-target?chunk_id=<id> returns page + snippet when chunk valid."""
doc_id, kb_id, chunk_id = _uid(), _uid(), _uid()
stored = db_env / "rag" / "uploads" / "report.pdf"
stored.parent.mkdir(parents = True, exist_ok = True)
stored.write_bytes(b"%PDF-1.4 dummy")
monkeypatch.setenv("UNSLOTH_STUDIO_HOME", str(db_env))
with studio_db.get_connection() as conn:
_insert_kb(conn, kb_id)
_insert_doc(conn, doc_id, kb_id, str(stored))
_insert_chunk(conn, chunk_id, doc_id, page_number = 7, chunk_index = 14)
client = _make_client(app, "alice")
try:
resp = client.get(
f"/api/rag/documents/{doc_id}/preview-target?chunk_id={chunk_id}"
)
finally:
_clear_overrides(app)
assert resp.status_code == 200
body = resp.json()
assert body["documentId"] == doc_id
assert body["chunkId"] == chunk_id
assert body["targetPage"] == 7
assert body["chunkIndex"] == 14
assert body["snippet"] is not None and len(body["snippet"]) > 0
assert body["mediaKind"] == "pdf"
def test_preview_target_returns_pdf_regions_when_present(
self, app, db_env, monkeypatch
):
"""Chunk preview includes only stored confident PDF regions."""
doc_id, kb_id, chunk_id = _uid(), _uid(), _uid()
stored = db_env / "rag" / "uploads" / "report.pdf"
stored.parent.mkdir(parents = True, exist_ok = True)
stored.write_bytes(b"%PDF-1.4 dummy")
monkeypatch.setenv("UNSLOTH_STUDIO_HOME", str(db_env))
with studio_db.get_connection() as conn:
_insert_kb(conn, kb_id)
_insert_doc(conn, doc_id, kb_id, str(stored))
_insert_chunk(conn, chunk_id, doc_id, page_number = 7, chunk_index = 14)
conn.execute(
"""
UPDATE rag_chunks
SET pdf_regions_json = ?
WHERE id = ?
""",
(
'[{"pageIndex":6,"pageNumber":7,"x":0.1,"y":0.2,'
'"width":0.3,"height":0.04,"confidence":"exact",'
'"source":"pymupdf-search"}]',
chunk_id,
),
)
client = _make_client(app, "alice")
try:
resp = client.get(
f"/api/rag/documents/{doc_id}/preview-target?chunk_id={chunk_id}"
)
finally:
_clear_overrides(app)
assert resp.status_code == 200
body = resp.json()
assert body["pdfRegions"] == [
{
"pageIndex": 6,
"pageNumber": 7,
"x": 0.1,
"y": 0.2,
"width": 0.3,
"height": 0.04,
"confidence": "exact",
"source": "pymupdf-search",
}
]
def test_without_chunk_id_returns_all_null_chunk_fields(
self, app, db_env, monkeypatch
):
"""GET /preview-target without chunk_id returns metadata-only (decision Q2)."""
doc_id, kb_id = _uid(), _uid()
stored = db_env / "rag" / "uploads" / "annual.pdf"
stored.parent.mkdir(parents = True, exist_ok = True)
stored.write_bytes(b"%PDF-1.4 dummy")
monkeypatch.setenv("UNSLOTH_STUDIO_HOME", str(db_env))
with studio_db.get_connection() as conn:
_insert_kb(conn, kb_id)
_insert_doc(conn, doc_id, kb_id, str(stored))
client = _make_client(app, "alice")
try:
resp = client.get(f"/api/rag/documents/{doc_id}/preview-target")
finally:
_clear_overrides(app)
assert resp.status_code == 200
body = resp.json()
# All chunk fields null — no first-chunk guess.
assert body["chunkId"] is None
assert body["chunkIndex"] is None
assert body["targetPage"] is None
assert body["snippet"] is None
assert body["kind"] is None
assert body["imageUrl"] is None
assert body["documentId"] == doc_id
def test_missing_document_returns_404(self, app, db_env, monkeypatch):
"""Nonexistent document_id returns 404 to both existence and auth probes."""
monkeypatch.setenv("UNSLOTH_STUDIO_HOME", str(db_env))
client = _make_client(app, "alice")
try:
resp = client.get(f"/api/rag/documents/{_uid()}/preview-target")
finally:
_clear_overrides(app)
assert resp.status_code == 404
assert resp.json()["detail"] == "Document not found"
def test_wrong_subject_returns_404(self, app, db_env, monkeypatch):
"""Document owned by alice returns 404 when accessed by mallory."""
doc_id, kb_id = _uid(), _uid()
stored = db_env / "rag" / "uploads" / "secret.pdf"
stored.parent.mkdir(parents = True, exist_ok = True)
stored.write_bytes(b"data")
monkeypatch.setenv("UNSLOTH_STUDIO_HOME", str(db_env))
with studio_db.get_connection() as conn:
_insert_kb(conn, kb_id, owner = "alice")
_insert_doc(conn, doc_id, kb_id, str(stored))
client = _make_client(app, "mallory")
try:
resp = client.get(f"/api/rag/documents/{doc_id}/preview-target")
finally:
_clear_overrides(app)
assert resp.status_code == 404
def test_cross_doc_chunk_id_returns_404(self, app, db_env, monkeypatch):
"""chunk_id from a different document returns 404 — not 400 (opaque)."""
kb_id = _uid()
doc_a, doc_b = _uid(), _uid()
chunk_a = _uid()
stored_a = db_env / "rag" / "uploads" / "a.pdf"
stored_b = db_env / "rag" / "uploads" / "b.pdf"
stored_a.parent.mkdir(parents = True, exist_ok = True)
stored_a.write_bytes(b"data")
stored_b.write_bytes(b"data")
monkeypatch.setenv("UNSLOTH_STUDIO_HOME", str(db_env))
with studio_db.get_connection() as conn:
_insert_kb(conn, kb_id)
_insert_doc(conn, doc_a, kb_id, str(stored_a), "a.pdf")
_insert_doc(conn, doc_b, kb_id, str(stored_b), "b.pdf")
_insert_chunk(conn, chunk_a, doc_a)
client = _make_client(app, "alice")
try:
# Probe doc_b with chunk_a (belongs to doc_a)
resp = client.get(
f"/api/rag/documents/{doc_b}/preview-target?chunk_id={chunk_a}"
)
finally:
_clear_overrides(app)
# 404, not 200 with doc_a's chunk data
assert resp.status_code == 404
def test_unauthenticated_returns_401(self, app, db_env, monkeypatch):
"""No bearer token → 401."""
monkeypatch.setenv("UNSLOTH_STUDIO_HOME", str(db_env))
# No override: let the real dependency raise.
client = TestClient(app, raise_server_exceptions = False)
resp = client.get(f"/api/rag/documents/{_uid()}/preview-target")
assert resp.status_code == 401
# ── /file tests ───────────────────────────────────────────────────────
class TestFileRoute:
def test_pdf_200_with_correct_headers(self, app, db_env, monkeypatch):
"""GET /file for a PDF returns 200 with nosniff, Cache-Control, inline disposition."""
doc_id, kb_id = _uid(), _uid()
uploads = db_env / "rag" / "uploads"
uploads.mkdir(parents = True, exist_ok = True)
stored = uploads / "annual.pdf"
stored.write_bytes(b"%PDF-1.4\n%%EOF")
monkeypatch.setenv("UNSLOTH_STUDIO_HOME", str(db_env))
with studio_db.get_connection() as conn:
_insert_kb(conn, kb_id)
_insert_doc(
conn, doc_id, kb_id, str(stored), "annual.pdf", "application/pdf"
)
client = _make_client(app, "alice")
try:
resp = client.get(f"/api/rag/documents/{doc_id}/file")
finally:
_clear_overrides(app)
assert resp.status_code == 200
assert resp.headers.get("x-content-type-options") == "nosniff"
assert "private" in (resp.headers.get("cache-control") or "")
ct = resp.headers.get("content-type", "")
assert "pdf" in ct.lower()
def test_signed_file_url_supports_range_without_bearer_query(
self, app, db_env, monkeypatch
):
"""Short-lived signed URL is redeemable without Authorization and supports ranges."""
doc_id, kb_id = _uid(), _uid()
uploads = db_env / "rag" / "uploads"
uploads.mkdir(parents = True, exist_ok = True)
stored = uploads / "annual.pdf"
stored.write_bytes(b"%PDF-1.4\n%%EOF")
monkeypatch.setenv("UNSLOTH_STUDIO_HOME", str(db_env))
monkeypatch.setattr("routes.rag.get_jwt_secret", lambda subject: "test-secret")
with studio_db.get_connection() as conn:
_insert_kb(conn, kb_id)
_insert_doc(
conn, doc_id, kb_id, str(stored), "annual.pdf", "application/pdf"
)
client = _make_client(app, "alice")
try:
url_resp = client.get(f"/api/rag/documents/{doc_id}/file-url")
assert url_resp.status_code == 200
signed_url = url_resp.json()["url"]
assert "Bearer" not in signed_url
assert "Authorization" not in signed_url
file_resp = client.get(signed_url, headers = {"Range": "bytes=0-3"})
finally:
_clear_overrides(app)
assert file_resp.status_code == 206
assert file_resp.content == b"%PDF"
assert (
file_resp.headers.get("content-range")
== f"bytes 0-3/{stored.stat().st_size}"
)
assert file_resp.headers.get("accept-ranges") == "bytes"
assert file_resp.headers.get("x-content-type-options") == "nosniff"
def test_signed_file_route_rejects_forged_token(self, app, db_env, monkeypatch):
"""Signed file route is not public without a valid preview token."""
doc_id, kb_id = _uid(), _uid()
uploads = db_env / "rag" / "uploads"
uploads.mkdir(parents = True, exist_ok = True)
stored = uploads / "annual.pdf"
stored.write_bytes(b"%PDF-1.4\n%%EOF")
monkeypatch.setenv("UNSLOTH_STUDIO_HOME", str(db_env))
monkeypatch.setattr("routes.rag.get_jwt_secret", lambda subject: "test-secret")
with studio_db.get_connection() as conn:
_insert_kb(conn, kb_id)
_insert_doc(
conn, doc_id, kb_id, str(stored), "annual.pdf", "application/pdf"
)
client = TestClient(app, raise_server_exceptions = False)
resp = client.get(f"/api/rag/documents/{doc_id}/file-signed?token=bogus")
assert resp.status_code == 401
def test_html_file_served_as_text_plain_with_attachment(
self, app, db_env, monkeypatch
):
"""HTML uploads must be served as text/plain + attachment (Risk #3 — no XSS)."""
doc_id, kb_id = _uid(), _uid()
uploads = db_env / "rag" / "uploads"
uploads.mkdir(parents = True, exist_ok = True)
stored = uploads / "malicious.html"
stored.write_bytes(b"<script>alert(1)</script>")
monkeypatch.setenv("UNSLOTH_STUDIO_HOME", str(db_env))
with studio_db.get_connection() as conn:
_insert_kb(conn, kb_id)
_insert_doc(conn, doc_id, kb_id, str(stored), "malicious.html", "text/html")
client = _make_client(app, "alice")
try:
resp = client.get(f"/api/rag/documents/{doc_id}/file")
finally:
_clear_overrides(app)
assert resp.status_code == 200
ct = resp.headers.get("content-type", "").lower()
# Must be text/plain, not text/html.
assert "text/html" not in ct, f"HTML executed inline! content-type={ct}"
assert "text/plain" in ct
disp = resp.headers.get("content-disposition", "").lower()
assert "attachment" in disp, f"HTML not forced to attachment: {disp}"
assert resp.headers.get("x-content-type-options") == "nosniff"
def test_docx_served_as_attachment(self, app, db_env, monkeypatch):
"""DOCX files must be served with Content-Disposition: attachment."""
doc_id, kb_id = _uid(), _uid()
uploads = db_env / "rag" / "uploads"
uploads.mkdir(parents = True, exist_ok = True)
stored = uploads / "report.docx"
stored.write_bytes(b"PK\x03\x04fake-docx")
monkeypatch.setenv("UNSLOTH_STUDIO_HOME", str(db_env))
with studio_db.get_connection() as conn:
_insert_kb(conn, kb_id)
_insert_doc(
conn,
doc_id,
kb_id,
str(stored),
"report.docx",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
)
client = _make_client(app, "alice")
try:
resp = client.get(f"/api/rag/documents/{doc_id}/file")
finally:
_clear_overrides(app)
assert resp.status_code == 200
disp = resp.headers.get("content-disposition", "").lower()
assert "attachment" in disp
def test_missing_document_returns_404(self, app, db_env, monkeypatch):
"""Nonexistent document returns 404."""
monkeypatch.setenv("UNSLOTH_STUDIO_HOME", str(db_env))
client = _make_client(app, "alice")
try:
resp = client.get(f"/api/rag/documents/{_uid()}/file")
finally:
_clear_overrides(app)
assert resp.status_code == 404
def test_wrong_subject_returns_404(self, app, db_env, monkeypatch):
"""Document accessible to alice is 404 for mallory (auth-collapse)."""
doc_id, kb_id = _uid(), _uid()
uploads = db_env / "rag" / "uploads"
uploads.mkdir(parents = True, exist_ok = True)
stored = uploads / "private.pdf"
stored.write_bytes(b"data")
monkeypatch.setenv("UNSLOTH_STUDIO_HOME", str(db_env))
with studio_db.get_connection() as conn:
_insert_kb(conn, kb_id, owner = "alice")
_insert_doc(conn, doc_id, kb_id, str(stored))
client = _make_client(app, "mallory")
try:
resp = client.get(f"/api/rag/documents/{doc_id}/file")
finally:
_clear_overrides(app)
assert resp.status_code == 404
def test_deleted_file_returns_404_with_doc_file_not_found(
self, app, db_env, monkeypatch
):
"""File gone from disk returns 404 with 'Document file not found' detail."""
doc_id, kb_id = _uid(), _uid()
uploads = db_env / "rag" / "uploads"
uploads.mkdir(parents = True, exist_ok = True)
stored = uploads / "gone.pdf"
stored.write_bytes(b"data")
monkeypatch.setenv("UNSLOTH_STUDIO_HOME", str(db_env))
with studio_db.get_connection() as conn:
_insert_kb(conn, kb_id)
_insert_doc(conn, doc_id, kb_id, str(stored))
# Delete the file after the row exists.
stored.unlink()
client = _make_client(app, "alice")
try:
resp = client.get(f"/api/rag/documents/{doc_id}/file")
finally:
_clear_overrides(app)
assert resp.status_code == 404
detail = resp.json().get("detail", "")
assert "file not found" in detail.lower() or "not found" in detail.lower()
def test_outside_root_stored_path_returns_404(
self, app, db_env, monkeypatch, tmp_path
):
"""stored_path outside rag_uploads_root returns 404 — path containment (Risk #2)."""
doc_id, kb_id = _uid(), _uid()
uploads = db_env / "rag" / "uploads"
uploads.mkdir(parents = True, exist_ok = True)
# A plausible path outside the RAG uploads root.
outside = tmp_path / "etc" / "passwd"
outside.parent.mkdir(parents = True, exist_ok = True)
outside.write_bytes(b"root:x:0:0")
monkeypatch.setenv("UNSLOTH_STUDIO_HOME", str(db_env))
with studio_db.get_connection() as conn:
_insert_kb(conn, kb_id)
# stored_path points outside root.
conn.execute(
"INSERT INTO rag_documents "
"(id, kb_id, thread_id, filename, content_type, stored_path, status, "
"num_chunks, byte_size, created_at) "
"VALUES (?, ?, NULL, 'passwd', 'text/plain', ?, 'completed', 0, 10, ?)",
(doc_id, kb_id, str(outside), 1_700_000_000),
)
client = _make_client(app, "alice")
try:
resp = client.get(f"/api/rag/documents/{doc_id}/file")
finally:
_clear_overrides(app)
# Containment violation: 404, never serve the file.
assert resp.status_code == 404
def test_nosniff_and_cache_headers_on_txt_file(self, app, db_env, monkeypatch):
"""Safety headers present on every 200 response, including plain text."""
doc_id, kb_id = _uid(), _uid()
uploads = db_env / "rag" / "uploads"
uploads.mkdir(parents = True, exist_ok = True)
stored = uploads / "notes.txt"
stored.write_bytes(b"hello world")
monkeypatch.setenv("UNSLOTH_STUDIO_HOME", str(db_env))
with studio_db.get_connection() as conn:
_insert_kb(conn, kb_id)
_insert_doc(conn, doc_id, kb_id, str(stored), "notes.txt", "text/plain")
client = _make_client(app, "alice")
try:
resp = client.get(f"/api/rag/documents/{doc_id}/file")
finally:
_clear_overrides(app)
assert resp.status_code == 200
assert resp.headers.get("x-content-type-options") == "nosniff"
cc = resp.headers.get("cache-control", "")
assert "private" in cc
# ── /images tests ─────────────────────────────────────────────────────
class TestImageRoute:
def test_image_route_wrong_subject_returns_404(self, app, db_env, monkeypatch):
"""Extracted images require the same document authorization as /file."""
doc_id, kb_id = _uid(), _uid()
uploads = db_env / "rag" / "uploads"
images = uploads / "images" / doc_id
images.mkdir(parents = True, exist_ok = True)
image = images / "figure.png"
image.write_bytes(b"\x89PNG\r\n\x1a\n")
stored = uploads / "report.pdf"
stored.write_bytes(b"%PDF-1.4")
monkeypatch.setenv("UNSLOTH_STUDIO_HOME", str(db_env))
with studio_db.get_connection() as conn:
_insert_kb(conn, kb_id, owner = "alice")
_insert_doc(conn, doc_id, kb_id, str(stored), "report.pdf")
client = _make_client(app, "mallory")
try:
resp = client.get(f"/api/rag/images/{doc_id}/figure.png")
finally:
_clear_overrides(app)
assert resp.status_code == 404
def test_image_route_authorized_subject_gets_image(self, app, db_env, monkeypatch):
"""Authorized subject can still fetch an extracted image."""
doc_id, kb_id = _uid(), _uid()
uploads = db_env / "rag" / "uploads"
images = uploads / "images" / doc_id
images.mkdir(parents = True, exist_ok = True)
image = images / "figure.png"
image.write_bytes(b"\x89PNG\r\n\x1a\n")
stored = uploads / "report.pdf"
stored.write_bytes(b"%PDF-1.4")
monkeypatch.setenv("UNSLOTH_STUDIO_HOME", str(db_env))
with studio_db.get_connection() as conn:
_insert_kb(conn, kb_id, owner = "alice")
_insert_doc(conn, doc_id, kb_id, str(stored), "report.pdf")
client = _make_client(app, "alice")
try:
resp = client.get(f"/api/rag/images/{doc_id}/figure.png")
finally:
_clear_overrides(app)
assert resp.status_code == 200
assert resp.content.startswith(b"\x89PNG")