201 lines
7.0 KiB
Python
201 lines
7.0 KiB
Python
"""Integration tests: GET /api/documents/content — the viewer's data source.
|
|
|
|
Uses the real compose Postgres (``db`` fixture) and FastAPI's TestClient:
|
|
* 200 with the full field set for a seeded document (all formats);
|
|
* ``summary`` surfaced for summarized docs, ``null`` for markdown (phase 36);
|
|
* anonymous access stays 200 (phase 16 soft rule — public viewer);
|
|
* 404 for an unknown (source, path) pair;
|
|
* 404 for traversal-style ``path`` values (no filesystem access → no leak).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from datetime import UTC, datetime
|
|
|
|
from sqlalchemy import text
|
|
|
|
from app.models import Chunk, Document
|
|
|
|
|
|
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,
|
|
) -> None:
|
|
"""Truncate the KB and insert one document with ``chunks`` chunk rows."""
|
|
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)
|
|
)
|
|
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()
|