Phase 47 (owner permission 2026-08-27, TODO.md L10–11, roadmap R1): the
full Podman quadlet family (.container, .network, .volume, .image,
.pod, .kube, .swap, .os, .endpoint) and .j2 Jinja templates join the
allowed + default A9 import formats, chunked as plain text (owner
decision — no TOML/Jinja-aware splitter). No env configuration needed:
a default import now indexes them.
- app/config.py: _ALLOWED_IMPORT_EXTENSIONS + the default
import_extensions CSV gain the ten names (the original seven first);
the never-widen BOR_IMPORT_EXTENSIONS validator is untouched and
still rejects truly unknown extensions.
- app/rag/chunker.py: ten _FORMAT_CHUNKERS entries -> chunk_text
(HARD_MAX_CHARS 1200 honored, unknown-suffix fallback unchanged);
docstring/comments cite the A9 revision 2026-08-27.
- tests/fixtures/docs/homelab/: quadlet/compose.container (realistic
quadlet TOML, >1500 chars, [Unit]/[Service]/[Container] sections,
RESE-QUADLET-SENTINEL-77aa), quadlet/lan.network,
quadlet/cache.volume, templates/deploy.j2 (for/set/if Jinja
constructs + RESE-JINJA-SENTINEL-33dd). Every suite that seeds the
fixture tree updates its 9 -> 13 document-count constants.
- tests/unit/test_config.py: allowed set carries all seventeen formats,
default CSV + dotted import_extension_set include the ten, the
validator accepts the new names and still rejects unknowns.
- tests/unit/test_chunker.py: dispatch parity with chunk_text for every
new suffix (parametrized), the .container fixture chunks >=2 under
the cap with the sentinel surviving, the .j2 fixture keeps {{ }}
verbatim, the unknown-suffix fallback is unchanged.
- tests/unit/test_importer.py: a default-extensions walk over a temp
tree indexes exactly the ten new files (unknown/hidden/excluded
filtered), the original seven still walk, stem-title fallback holds.
- tests/integration/test_import_quadlet_jinja.py (new): import_sources
over a temp tree with .container/.volume/.j2 -> documents + chunks
rows with stem titles; delta re-import updates only the changed .j2
doc; prune drops the deleted .volume doc with cascade.
- tests/e2e/test_quadlet_jinja_import.py (new, story suite, mock-only,
isolation): GET /api/docs (admin session) lists the four new-format
docs with non-zero chunk counts and stem titles; the Sources table
renders a row + .doc-link per file; the phase-26 modal shows the
.container TOML ([Container] section + sentinel) with stem title and
the container format badge; a RESE-JINJA-SENTINEL-33dd question
FTS-matches the .j2 chunk -> honest-positive (A8: LOW requires zero
FTS hits) — the bubble is not .is-deflected and a source chip names
templates/deploy.j2.
- README.md + .env.example: the extended default format set (A9
revised 2026-08-27, plain-text chunking, narrow-only rule intact).
- .agent/PLAN.md: the A9 revision (owner-locked R1) — A9 row status,
the revision note under the anchors table, and the §5 chunking-policy
+ §11 workflow lines. The only PLAN edit this phase.
Gates: uv run pytest 795 passed; app/ coverage TOTAL 99% (>90%);
ruff check + pyright clean; story E2E 4/4 in isolation (DB up);
regression E2E suites test_import_documents (3) / test_sync_button
(3) / test_git_sources_admin (6) green in isolation.
Also records the 47_quadlet_jinja_import task-file moves (01–03)
todo/ -> complete/.
193 lines
6.9 KiB
Python
193 lines
6.9 KiB
Python
"""Integration: the auth surface (phase 16) + the public-API regression
|
|
guards.
|
|
|
|
Covers the full login/logout lifecycle against the real app (TestClient
|
|
keeps the cookie jar): wrong password → 401 + still-gated; correct →
|
|
204 + cookie → admin everywhere gated; logout → 403 again. And the
|
|
**anonymous** guarantees that phase 16 must not break: the document
|
|
viewer stays public (soft rule) and ``POST /api/chat`` still streams.
|
|
|
|
Requires: podman compose up -d db
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
from collections.abc import Iterator
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
from sqlalchemy import text
|
|
from test_chat_api import FakeRagLLM, _stream_chat
|
|
|
|
from app.api import chat as chat_api
|
|
from app.main import app as fastapi_app
|
|
from app.models import Document
|
|
from app.rag.importer import import_sources
|
|
from tests.conftest import ADMIN_PASSWORD
|
|
|
|
FIXTURES = Path(__file__).resolve().parents[1] / "fixtures" / "docs"
|
|
QUESTION = "How is my Kubernetes cluster set up?"
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def clean_tables(db) -> Iterator[None]:
|
|
"""Docs + steering + query log are global state: reset around tests."""
|
|
db.execute(text("TRUNCATE chunks, documents, query_log, steering_notes"))
|
|
db.commit()
|
|
yield
|
|
db.execute(text("TRUNCATE chunks, documents, query_log, steering_notes"))
|
|
db.commit()
|
|
|
|
|
|
@pytest.fixture()
|
|
def seeded_kb(db) -> Iterator[FakeRagLLM]:
|
|
"""Fresh Postgres with the fixture docs imported (real pipeline)."""
|
|
db.execute(text("TRUNCATE chunks, documents, query_log"))
|
|
db.commit()
|
|
llm = FakeRagLLM()
|
|
summary = asyncio.run(import_sources([FIXTURES], llm, session=db))
|
|
assert summary.added == 13 # A9 formats (phase 47 added quadlet+j2); .hidden/ skipped
|
|
yield llm
|
|
db.execute(text("TRUNCATE chunks, documents, query_log"))
|
|
db.commit()
|
|
|
|
|
|
def _seed_one_doc(db) -> Document:
|
|
"""One minimal document (for the public document-content endpoint)."""
|
|
doc = Document(
|
|
source="docs",
|
|
path="homelab/kubernetes.md",
|
|
full_path="/tmp/kubernetes.md",
|
|
title="Kubernetes Homelab Cluster",
|
|
content="# Kubernetes Homelab Cluster\n\nTalos on 3 nodes.",
|
|
content_hash="c" * 64,
|
|
)
|
|
db.add(doc)
|
|
db.commit()
|
|
db.refresh(doc)
|
|
return doc
|
|
|
|
|
|
def test_wrong_password_401_and_still_gated(client: TestClient) -> None:
|
|
r = client.post("/api/login", json={"password": "not-the-password"})
|
|
assert r.status_code == 401
|
|
assert r.json() == {"detail": "invalid password"}
|
|
# No session state was created by the failed attempt.
|
|
assert "bor_session" not in client.cookies
|
|
|
|
r = client.post("/api/login", json={"password": ""}) # empty → same 401
|
|
assert r.status_code == 401
|
|
assert r.json() == {"detail": "invalid password"}
|
|
|
|
# The gated surface stays closed (anonymous).
|
|
assert client.get("/api/docs").status_code == 403
|
|
r = client.get("/api/steering")
|
|
assert r.status_code == 403
|
|
assert r.json() == {"detail": "admin only"}
|
|
assert client.post("/api/steering", json={"note": "x"}).status_code == 403
|
|
assert client.get("/api/whoami").json() == {
|
|
"authenticated": False,
|
|
"role": "anonymous",
|
|
}
|
|
|
|
|
|
def test_login_logout_lifecycle(client: TestClient) -> None:
|
|
# Anonymous shape before anything.
|
|
who = client.get("/api/whoami").json()
|
|
assert who == {"authenticated": False, "role": "anonymous"}
|
|
|
|
# Wrong first, right second — one generic 401, then success.
|
|
assert client.post("/api/login", json={"password": "nope"}).status_code == 401
|
|
r = client.post("/api/login", json={"password": ADMIN_PASSWORD})
|
|
assert r.status_code == 204
|
|
assert "bor_session" in client.cookies # the signed session cookie
|
|
|
|
# Admin: whoami + the gated endpoints all open up.
|
|
assert client.get("/api/whoami").json() == {
|
|
"authenticated": True,
|
|
"role": "admin",
|
|
}
|
|
assert client.get("/api/docs").status_code == 200
|
|
|
|
created = client.post("/api/steering", json={"note": " be terse "})
|
|
assert created.status_code == 201
|
|
note = created.json()
|
|
assert note["note"] == "be terse"
|
|
listing = client.get("/api/steering")
|
|
assert listing.status_code == 200
|
|
assert [n["note"] for n in listing.json()["notes"]] == ["be terse"]
|
|
assert client.delete(f"/api/steering/{note['id']}").status_code == 204
|
|
assert client.get("/api/steering").json() == {"notes": []}
|
|
|
|
# Logout: 204, cookie gone, gated again.
|
|
assert client.post("/api/logout").status_code == 204
|
|
assert "bor_session" not in client.cookies
|
|
assert client.get("/api/whoami").json() == {
|
|
"authenticated": False,
|
|
"role": "anonymous",
|
|
}
|
|
assert client.get("/api/docs").status_code == 403
|
|
r = client.get("/api/steering")
|
|
assert r.status_code == 403
|
|
assert r.json() == {"detail": "admin only"}
|
|
# Logout is idempotent (anonymous logout is still a clean 204).
|
|
assert client.post("/api/logout").status_code == 204
|
|
assert client.get("/api/whoami").json()["authenticated"] is False
|
|
|
|
|
|
def test_forged_cookie_is_rejected(client: TestClient) -> None:
|
|
client.cookies.set("bor_session", "tampered-session-blob")
|
|
assert client.get("/api/whoami").json() == {
|
|
"authenticated": False,
|
|
"role": "anonymous",
|
|
}
|
|
assert client.get("/api/docs").status_code == 403
|
|
|
|
|
|
def test_anonymous_document_content_stays_public(client: TestClient, db) -> None:
|
|
"""Soft rule (phase 16): the catalog is gated, the viewer is not."""
|
|
_seed_one_doc(db)
|
|
|
|
r = client.get(
|
|
"/api/documents/content", params={"source": "docs", "path": "homelab/kubernetes.md"}
|
|
)
|
|
assert r.status_code == 200
|
|
body = r.json()
|
|
assert body["title"] == "Kubernetes Homelab Cluster"
|
|
assert "Talos" in body["content"]
|
|
assert set(body) == {
|
|
"source",
|
|
"path",
|
|
"title",
|
|
"format",
|
|
"summary", # nullable field added in phase 36 (null here — markdown)
|
|
"content",
|
|
"indexed_at",
|
|
"chunks",
|
|
}
|
|
assert body["summary"] is None
|
|
|
|
# Unknown docs still 404 anonymously (no enumeration of titles).
|
|
r = client.get("/api/documents/content", params={"source": "docs", "path": "nope.md"})
|
|
assert r.status_code == 404
|
|
|
|
|
|
def test_anonymous_chat_still_streams(
|
|
client: TestClient, seeded_kb: FakeRagLLM
|
|
) -> None:
|
|
"""Regression guard: sign-in must not have locked chat (A10 public)."""
|
|
fastapi_app.dependency_overrides[chat_api.get_llm] = lambda: seeded_kb
|
|
try:
|
|
status, content_type, frames = _stream_chat(client, QUESTION)
|
|
finally:
|
|
fastapi_app.dependency_overrides.clear()
|
|
|
|
assert status == 200
|
|
assert content_type.startswith("text/event-stream")
|
|
deltas = [f for f in frames if f.get("type") == "delta"]
|
|
assert len(deltas) >= 2 # genuinely streamed
|
|
assert frames[-1]["type"] == "done"
|
|
assert frames[-1]["deflected"] is False
|
|
assert frames[-1]["sources"][0]["path"] == "homelab/kubernetes.md"
|