543 lines
21 KiB
Python
543 lines
21 KiB
Python
"""Integration tests: the document-content API — the viewer's data source.
|
||
|
||
Uses the real compose Postgres (``db`` fixture) and FastAPI's TestClient:
|
||
* GET: 200 with the full field set for a seeded document (all formats);
|
||
* GET: ``summary`` surfaced for summarized docs, ``null`` for markdown
|
||
(phase 36);
|
||
* GET: anonymous access stays 200 (phase 16 soft rule — public viewer);
|
||
* GET: 404 for an unknown (source, path) pair;
|
||
* GET: 404 for traversal-style ``path`` values (no filesystem access → no
|
||
leak).
|
||
|
||
PATCH /api/documents/summary (phase 57, task 01) — the admin summary
|
||
editor, on the same DB-backed fixtures:
|
||
* update: ``documents.summary`` + the ``is_summary`` chunk's content
|
||
replaced, fresh embedding, content chunks untouched (count unchanged —
|
||
D4 re-embed scope);
|
||
* clear: empty/whitespace text → ``summary`` NULL + ``is_summary`` chunk
|
||
deleted (idempotent, no embed call);
|
||
* markdown doc (no prior ``is_summary`` chunk) → one created at
|
||
position −1 with the new embedding;
|
||
* 404 unknown (source, path) (incl. traversal strings); 403 anonymous;
|
||
* embed failure → 503 sanitized detail, DB byte-for-byte untouched
|
||
(fail-before-write — embed before any mutation).
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
from datetime import UTC, datetime
|
||
|
||
import pytest
|
||
from fastapi.testclient import TestClient
|
||
from sqlalchemy import select, text
|
||
from sqlalchemy.orm import Session
|
||
|
||
import app.api.docs as docs_api
|
||
from app.models import Chunk, Document
|
||
from app.rag.llm import EmbeddingError
|
||
from tests.fakes import FakeEmbedder
|
||
|
||
|
||
def _seed_doc(
|
||
db,
|
||
source: str = "Homelab",
|
||
path: str = "kubernetes.md",
|
||
title: str = "Kubernetes Homelab Cluster",
|
||
content: str = "# Kubernetes\n\nTalos on 3 nodes.",
|
||
chunks: int = 2,
|
||
summary: str | None = None,
|
||
summary_chunk: bool = False,
|
||
) -> None:
|
||
"""Truncate the KB and insert one document with ``chunks`` chunk rows.
|
||
|
||
``summary_chunk`` additionally indexes the phase-30 ``is_summary``
|
||
chunk at position −1 with the seeded vector (requires ``summary`` —
|
||
the phase-30 invariant: the chunk mirrors ``documents.summary``).
|
||
"""
|
||
db.execute(text("TRUNCATE chunks, documents"))
|
||
db.commit()
|
||
doc = Document(
|
||
source=source,
|
||
path=path,
|
||
full_path=f"/tmp/{path}",
|
||
title=title,
|
||
content=content,
|
||
content_hash="a" * 64,
|
||
indexed_at=datetime.now(UTC),
|
||
summary=summary,
|
||
)
|
||
db.add(doc)
|
||
db.flush()
|
||
if chunks:
|
||
db.add_all(
|
||
Chunk(document_id=doc.id, position=i, content=f"chunk {i}", embedding=[0.01] * 768)
|
||
for i in range(chunks)
|
||
)
|
||
if summary_chunk:
|
||
assert summary is not None
|
||
db.add(
|
||
Chunk(
|
||
document_id=doc.id,
|
||
position=-1,
|
||
content=summary,
|
||
embedding=[0.01] * 768,
|
||
is_summary=True,
|
||
)
|
||
)
|
||
db.commit()
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# PATCH /api/documents/summary (phase 57, task 01)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class _DeadEmbedder:
|
||
"""An ``LLMClient`` stand-in whose ``embed`` always fails — the
|
||
dead-endpoint path (phase 57 fail-before-write). The message mirrors
|
||
``LLMClient.embed``'s transport wrap, with embedded credentials that
|
||
the sanitizer must mask."""
|
||
|
||
def __init__(self) -> None:
|
||
self.calls: list[list[str]] = []
|
||
|
||
async def embed(self, texts: list[str]) -> list[list[float]]:
|
||
self.calls.append(list(texts))
|
||
raise EmbeddingError(
|
||
"embeddings request to https://user:secret@aipi.example.com/v1 "
|
||
"failed: connection refused"
|
||
)
|
||
|
||
|
||
def _seed_yaml_doc(db, *, summary: str, summary_chunk: bool) -> None:
|
||
"""One phase-30-style non-markdown doc: 2 content chunks + (optionally)
|
||
its ``is_summary`` chunk."""
|
||
_seed_doc(
|
||
db,
|
||
path="container_gitlab/gitlab-compose.yaml",
|
||
title="gitlab-compose",
|
||
content="services:\n gitlab:\n image: gitlab/gitlab-ce",
|
||
summary=summary,
|
||
summary_chunk=summary_chunk,
|
||
)
|
||
|
||
|
||
def _doc_id(db, path: str):
|
||
return db.scalar(select(Document.id).where(Document.path == path))
|
||
|
||
|
||
def _chunk_snapshots(db, doc_id) -> dict:
|
||
"""``{chunk id: (position, content, embedding, is_summary)}``.
|
||
|
||
Before/after comparisons prove exactly which rows a PATCH touched.
|
||
Embeddings are compared read-back to read-back: pgvector stores
|
||
float4, so raw Python floats do not round-trip exactly (the house
|
||
style elsewhere is ``is not None`` + dimension checks)."""
|
||
rows = db.scalars(select(Chunk).where(Chunk.document_id == doc_id)).all()
|
||
return {
|
||
c.id: (
|
||
c.position,
|
||
c.content,
|
||
list(c.embedding) if c.embedding is not None else None,
|
||
c.is_summary,
|
||
)
|
||
for c in rows
|
||
}
|
||
|
||
|
||
def test_summary_patch_update_reembeds_summary_chunk(
|
||
admin_client: TestClient, client: TestClient, monkeypatch: pytest.MonkeyPatch, db: Session
|
||
) -> None:
|
||
"""Admin PATCH with new text (phase 57, D4): ``documents.summary`` and
|
||
the ``is_summary`` chunk's content are replaced and the chunk gets a
|
||
fresh embedding — in place (same row id); the content chunks are
|
||
untouched (same ids, positions, contents, vectors) and the total
|
||
count is unchanged. One ``embed`` call, only with the new text."""
|
||
old = "GitLab CE runs in a Podman compose stack on the homelab NAS."
|
||
new = "GitLab CE is now backed by external PostgreSQL and MinIO."
|
||
_seed_yaml_doc(db, summary=old, summary_chunk=True)
|
||
doc_id = _doc_id(db, "container_gitlab/gitlab-compose.yaml")
|
||
db.expire_all()
|
||
before = _chunk_snapshots(db, doc_id)
|
||
assert len(before) == 3
|
||
try:
|
||
fake = FakeEmbedder()
|
||
monkeypatch.setattr(docs_api, "LLMClient", lambda: fake)
|
||
r = admin_client.patch(
|
||
"/api/documents/summary",
|
||
json={
|
||
"source": "Homelab",
|
||
"path": "container_gitlab/gitlab-compose.yaml",
|
||
"summary": new,
|
||
},
|
||
)
|
||
assert r.status_code == 200, r.text
|
||
body = r.json()
|
||
assert set(body) == {"source", "path", "summary", "chunks"}
|
||
assert body["source"] == "Homelab"
|
||
assert body["path"] == "container_gitlab/gitlab-compose.yaml"
|
||
assert body["summary"] == new
|
||
assert body["chunks"] == 3 # 2 content + 1 is_summary — unchanged by an update
|
||
|
||
db.expire_all()
|
||
row = db.get(Document, doc_id)
|
||
assert row is not None
|
||
assert row.summary == new
|
||
after = _chunk_snapshots(db, doc_id)
|
||
assert set(after) == set(before) # same rows: nothing added or deleted
|
||
sc = next(cid for cid, v in after.items() if v[3])
|
||
assert sc in before # replaced in place — the same row id
|
||
pos, content, vec, _ = after[sc]
|
||
assert pos == -1
|
||
assert content == new
|
||
assert vec is not None and len(vec) == 768
|
||
assert vec != before[sc][2] # fresh embedding, not the seeded one
|
||
for cid, v in after.items():
|
||
if not v[3]:
|
||
assert v == before[cid] # content chunks untouched, byte for byte
|
||
assert fake.calls == [[new]] # one embed call, the new text only
|
||
# The public viewer's data source now carries the new summary
|
||
# verbatim (anonymous — the viewer stays public, phase 16).
|
||
g = client.get(
|
||
"/api/documents/content",
|
||
params={"source": "Homelab", "path": "container_gitlab/gitlab-compose.yaml"},
|
||
)
|
||
assert g.status_code == 200
|
||
assert g.json()["summary"] == new
|
||
finally:
|
||
db.execute(text("TRUNCATE chunks, documents"))
|
||
db.commit()
|
||
|
||
|
||
def test_summary_patch_clear(
|
||
admin_client: TestClient, monkeypatch: pytest.MonkeyPatch, db: Session
|
||
) -> None:
|
||
"""PATCH with empty/whitespace text clears (phase 57, D4):
|
||
``documents.summary`` → NULL and the ``is_summary`` chunk is deleted;
|
||
the content chunks are untouched. A second clear is an idempotent 200
|
||
and clearing never calls the embedder."""
|
||
old = "GitLab CE runs in a Podman compose stack on the homelab NAS."
|
||
_seed_yaml_doc(db, summary=old, summary_chunk=True)
|
||
doc_id = _doc_id(db, "container_gitlab/gitlab-compose.yaml")
|
||
db.expire_all()
|
||
before = _chunk_snapshots(db, doc_id)
|
||
assert len(before) == 3
|
||
try:
|
||
fake = FakeEmbedder()
|
||
monkeypatch.setattr(docs_api, "LLMClient", lambda: fake)
|
||
for body_summary in (" ", ""): # whitespace, then a true empty string
|
||
r = admin_client.patch(
|
||
"/api/documents/summary",
|
||
json={
|
||
"source": "Homelab",
|
||
"path": "container_gitlab/gitlab-compose.yaml",
|
||
"summary": body_summary,
|
||
},
|
||
)
|
||
assert r.status_code == 200, r.text
|
||
assert r.json() == {
|
||
"source": "Homelab",
|
||
"path": "container_gitlab/gitlab-compose.yaml",
|
||
"summary": None,
|
||
"chunks": 2,
|
||
}
|
||
db.expire_all()
|
||
row = db.get(Document, doc_id)
|
||
assert row is not None
|
||
assert row.summary is None
|
||
after = _chunk_snapshots(db, doc_id)
|
||
assert len(after) == 2 # the is_summary chunk row is gone (count −1)
|
||
for cid, v in after.items():
|
||
assert not v[3]
|
||
assert v == before[cid] # content chunks untouched, byte for byte
|
||
assert fake.calls == [] # clearing never embeds
|
||
finally:
|
||
db.execute(text("TRUNCATE chunks, documents"))
|
||
db.commit()
|
||
|
||
|
||
def test_summary_patch_markdown_doc_creates_summary_chunk(
|
||
admin_client: TestClient, monkeypatch: pytest.MonkeyPatch, db: Session
|
||
) -> None:
|
||
"""A markdown doc (no ``is_summary`` chunk by construction — phase 30)
|
||
gets exactly one created at position −1 with the new embedding; the
|
||
content chunks are untouched and the count goes 2 → 3. This is also
|
||
the recovery path for a phase-30 fail-soft import (document indexed
|
||
without its summary chunk)."""
|
||
new = "Talos Kubernetes on 3 nodes with a CNI of choice."
|
||
_seed_doc(db, summary=None, summary_chunk=False) # the default kubernetes.md
|
||
doc_id = _doc_id(db, "kubernetes.md")
|
||
db.expire_all()
|
||
before = _chunk_snapshots(db, doc_id)
|
||
assert len(before) == 2
|
||
try:
|
||
fake = FakeEmbedder()
|
||
monkeypatch.setattr(docs_api, "LLMClient", lambda: fake)
|
||
r = admin_client.patch(
|
||
"/api/documents/summary",
|
||
json={"source": "Homelab", "path": "kubernetes.md", "summary": new},
|
||
)
|
||
assert r.status_code == 200, r.text
|
||
body = r.json()
|
||
assert body["source"] == "Homelab"
|
||
assert body["path"] == "kubernetes.md"
|
||
assert body["summary"] == new
|
||
assert body["chunks"] == 3 # 2 content + the new is_summary
|
||
db.expire_all()
|
||
row = db.get(Document, doc_id)
|
||
assert row is not None
|
||
assert row.summary == new
|
||
after = _chunk_snapshots(db, doc_id)
|
||
assert len(after) == 3
|
||
for cid, v in before.items():
|
||
assert after[cid] == v # content chunks untouched, byte for byte
|
||
sc = next(cid for cid in after if cid not in before)
|
||
pos, content, vec, is_summary = after[sc]
|
||
assert (pos, is_summary) == (-1, True)
|
||
assert content == new
|
||
assert vec is not None and len(vec) == 768
|
||
assert fake.calls == [[new]]
|
||
finally:
|
||
db.execute(text("TRUNCATE chunks, documents"))
|
||
db.commit()
|
||
|
||
|
||
def test_summary_patch_404_unknown_pair(admin_client: TestClient, db: Session) -> None:
|
||
"""Unknown (source, path) pairs — including traversal strings — are
|
||
just missing rows: 404 ``document not found`` (the same shape as the
|
||
public GET). A pair that exists under a DIFFERENT source is 404 too."""
|
||
_seed_yaml_doc(db, summary="old", summary_chunk=True)
|
||
try:
|
||
for source, path in (
|
||
("Homelab", "nope/missing.md"),
|
||
("Deployments", "container_gitlab/gitlab-compose.yaml"),
|
||
("Homelab", "../../etc/passwd"),
|
||
):
|
||
r = admin_client.patch(
|
||
"/api/documents/summary",
|
||
json={"source": source, "path": path, "summary": "whatever"},
|
||
)
|
||
assert r.status_code == 404, (source, path)
|
||
assert r.json() == {"detail": "document not found"}, (source, path)
|
||
finally:
|
||
db.execute(text("TRUNCATE chunks, documents"))
|
||
db.commit()
|
||
|
||
|
||
def test_summary_patch_403_anonymous(client: TestClient, db: Session) -> None:
|
||
"""The edit affordance is admin-only (phase 57, D4 — the viewer stays
|
||
public): an anonymous PATCH gets 403 ``admin only`` and touches
|
||
nothing."""
|
||
old = "GitLab CE runs in a Podman compose stack on the homelab NAS."
|
||
_seed_yaml_doc(db, summary=old, summary_chunk=True)
|
||
doc_id = _doc_id(db, "container_gitlab/gitlab-compose.yaml")
|
||
try:
|
||
r = client.patch(
|
||
"/api/documents/summary",
|
||
json={
|
||
"source": "Homelab",
|
||
"path": "container_gitlab/gitlab-compose.yaml",
|
||
"summary": "not allowed",
|
||
},
|
||
)
|
||
assert r.status_code == 403
|
||
assert r.json() == {"detail": "admin only"}
|
||
db.expire_all()
|
||
row = db.get(Document, doc_id)
|
||
assert row is not None
|
||
assert row.summary == old # untouched
|
||
assert len(_chunk_snapshots(db, doc_id)) == 3
|
||
finally:
|
||
db.execute(text("TRUNCATE chunks, documents"))
|
||
db.commit()
|
||
|
||
|
||
def test_summary_patch_embed_failure_503_db_untouched(
|
||
admin_client: TestClient, monkeypatch: pytest.MonkeyPatch, db: Session
|
||
) -> None:
|
||
"""Fail-before-write (phase 57 locked decision): a dead embedding
|
||
endpoint → 503 with a sanitized detail (credentials masked, the
|
||
reason survives — the ``git_sources.py`` ``ModelUnavailableError``
|
||
style) and the row + every chunk byte-for-byte as before."""
|
||
old = "GitLab CE runs in a Podman compose stack on the homelab NAS."
|
||
_seed_yaml_doc(db, summary=old, summary_chunk=True)
|
||
doc_id = _doc_id(db, "container_gitlab/gitlab-compose.yaml")
|
||
db.expire_all()
|
||
before = _chunk_snapshots(db, doc_id)
|
||
try:
|
||
dead = _DeadEmbedder()
|
||
monkeypatch.setattr(docs_api, "LLMClient", lambda: dead)
|
||
r = admin_client.patch(
|
||
"/api/documents/summary",
|
||
json={
|
||
"source": "Homelab",
|
||
"path": "container_gitlab/gitlab-compose.yaml",
|
||
"summary": "new text",
|
||
},
|
||
)
|
||
assert r.status_code == 503
|
||
detail = r.json()["detail"]
|
||
assert "*****@aipi.example.com" in detail # credentials masked
|
||
assert "user:secret" not in detail
|
||
assert "connection refused" in detail # the reason survives
|
||
assert dead.calls == [["new text"]] # the embed was attempted…
|
||
db.expire_all()
|
||
row = db.get(Document, doc_id)
|
||
assert row is not None
|
||
assert row.summary == old # …and failed before any mutation
|
||
assert _chunk_snapshots(db, doc_id) == before # every row, byte for byte
|
||
finally:
|
||
db.execute(text("TRUNCATE chunks, documents"))
|
||
db.commit()
|
||
|
||
|
||
def test_content_200_all_fields(client, db) -> None:
|
||
_seed_doc(db)
|
||
try:
|
||
r = client.get(
|
||
"/api/documents/content",
|
||
params={"source": "Homelab", "path": "kubernetes.md"},
|
||
)
|
||
assert r.status_code == 200
|
||
body = r.json()
|
||
assert set(body) == {
|
||
"source", "path", "title", "format", "summary", "content", "indexed_at", "chunks"
|
||
}
|
||
assert body["source"] == "Homelab"
|
||
assert body["path"] == "kubernetes.md"
|
||
assert body["title"] == "Kubernetes Homelab Cluster"
|
||
assert body["format"] == "md"
|
||
assert body["summary"] is None # markdown doc → no summary (phase 36)
|
||
assert body["content"] == "# Kubernetes\n\nTalos on 3 nodes."
|
||
assert body["chunks"] == 2
|
||
datetime.fromisoformat(body["indexed_at"]) # raises if not ISO-8601
|
||
finally:
|
||
db.execute(text("TRUNCATE chunks, documents"))
|
||
db.commit()
|
||
|
||
|
||
def test_content_summary_surfaced_for_summarized_doc(client, db) -> None:
|
||
"""A non-markdown document with a phase-30 summary returns it verbatim
|
||
(phase 36 — the viewer's data contract gains the nullable field).
|
||
|
||
Anonymous by design: the ``client`` fixture carries no admin cookie,
|
||
so the 200 here re-confirms the phase-16 soft rule (public viewer).
|
||
"""
|
||
summary = (
|
||
"GitLab CE runs in a Podman compose stack on the homelab NAS with a "
|
||
"persistent volume for data and a backup job."
|
||
)
|
||
_seed_doc(
|
||
db,
|
||
path="container_gitlab/gitlab-compose.yaml",
|
||
title="gitlab-compose",
|
||
content="services:\n gitlab:\n image: gitlab/gitlab-ce",
|
||
summary=summary,
|
||
)
|
||
try:
|
||
r = client.get(
|
||
"/api/documents/content",
|
||
params={"source": "Homelab", "path": "container_gitlab/gitlab-compose.yaml"},
|
||
)
|
||
assert r.status_code == 200 # anonymous (no cookie) — public viewer
|
||
body = r.json()
|
||
assert body["summary"] == summary # verbatim, no wrapping
|
||
assert body["content"] == "services:\n gitlab:\n image: gitlab/gitlab-ce"
|
||
finally:
|
||
db.execute(text("TRUNCATE chunks, documents"))
|
||
db.commit()
|
||
|
||
|
||
def test_content_summary_null_for_markdown_doc(client, db) -> None:
|
||
"""Markdown documents carry no summary (phase 30) → JSON ``null``, and
|
||
anonymous access still returns 200 (phase 16 soft rule)."""
|
||
_seed_doc(
|
||
db,
|
||
path="kubernetes.md",
|
||
content="# Kubernetes\n\nTalos on 3 nodes.",
|
||
summary=None,
|
||
)
|
||
try:
|
||
r = client.get(
|
||
"/api/documents/content",
|
||
params={"source": "Homelab", "path": "kubernetes.md"},
|
||
)
|
||
assert r.status_code == 200 # anonymous (no cookie) — public viewer
|
||
body = r.json()
|
||
assert "summary" in body
|
||
assert body["summary"] is None
|
||
assert body["content"] == "# Kubernetes\n\nTalos on 3 nodes."
|
||
finally:
|
||
db.execute(text("TRUNCATE chunks, documents"))
|
||
db.commit()
|
||
|
||
|
||
def test_content_404_unknown_path(client, db) -> None:
|
||
_seed_doc(db)
|
||
try:
|
||
r = client.get(
|
||
"/api/documents/content",
|
||
params={"source": "Homelab", "path": "nope/missing.md"},
|
||
)
|
||
assert r.status_code == 404
|
||
assert r.json() == {"detail": "document not found"}
|
||
# A pair that exists under a DIFFERENT source is also 404 — both
|
||
# values must match the row.
|
||
r = client.get(
|
||
"/api/documents/content",
|
||
params={"source": "Deployments", "path": "kubernetes.md"},
|
||
)
|
||
assert r.status_code == 404
|
||
finally:
|
||
db.execute(text("TRUNCATE chunks, documents"))
|
||
db.commit()
|
||
|
||
|
||
def test_content_404_traversal_style_path_no_leak(client, db) -> None:
|
||
"""DB-only lookup: traversal strings are just non-existent rows — 404,
|
||
and the response must not carry anything from the filesystem."""
|
||
_seed_doc(db)
|
||
try:
|
||
for path in ("../../etc/passwd", "../kubernetes.md", "..%2F..%2Fetc%2Fpasswd"):
|
||
r = client.get(
|
||
"/api/documents/content",
|
||
params={"source": "Homelab", "path": path},
|
||
)
|
||
assert r.status_code == 404, path
|
||
assert r.json() == {"detail": "document not found"}, path
|
||
assert "root:" not in r.text, path
|
||
finally:
|
||
db.execute(text("TRUNCATE chunks, documents"))
|
||
db.commit()
|
||
|
||
|
||
def test_content_format_from_suffix(client, db) -> None:
|
||
"""format = lowercased path suffix: yaml documents (phase 09 corpus) and
|
||
the no-suffix fallback both flow through the same endpoint."""
|
||
try:
|
||
_seed_doc(
|
||
db,
|
||
path="container_gitlab/gitlab-compose.yaml",
|
||
title="gitlab-compose",
|
||
content="services:\n gitlab:\n image: gitlab/gitlab-ce",
|
||
chunks=0,
|
||
)
|
||
r = client.get(
|
||
"/api/documents/content",
|
||
params={"source": "Homelab", "path": "container_gitlab/gitlab-compose.yaml"},
|
||
)
|
||
assert r.status_code == 200
|
||
body = r.json()
|
||
assert body["format"] == "yaml"
|
||
assert body["chunks"] == 0 # outerjoin → zero, not missing
|
||
|
||
_seed_doc(db, path="README", title="README", content="plain text, no suffix", chunks=0)
|
||
r = client.get(
|
||
"/api/documents/content",
|
||
params={"source": "Homelab", "path": "README"},
|
||
)
|
||
assert r.status_code == 200
|
||
assert r.json()["format"] == "text" # no-suffix fallback
|
||
finally:
|
||
db.execute(text("TRUNCATE chunks, documents"))
|
||
db.commit()
|