feat(sources): upload tarball/zipfile archives as sources — unpack, scan, and replace in place

Phase 49 (owner request, chat 2026-08-28: "The git sources page should
remove local directory and should instead accept a tarball or zipfile
upload which it will unpack and scan … reuploading the same tarball
should not create a new folder, but should unpack and overwrite the
previously unpacked content" — design confirmed in the same
conversation):

* POST /api/git-sources/upload (admin-only, require_admin): accepts
  .tar/.tar.gz/.tgz/.zip, streams it with the BOR_UPLOAD_MAX_MB cap
  (bounds BOTH the compressed upload and the total extracted bytes —
  zip-bomb guard), safely unpacks (absolute/traversal/symlink/hardlink
  escape and device/FIFO members rejected), and atomically swaps the
  content in over BOR_UPLOAD_DIR/<name>/ (name = filename minus the
  archive suffix — no missing window, a failed upload never touches the
  existing folder/row/KB). The git_sources row is upserted by path
  (kind='local', no duplicates, added_at preserved), the models are
  checked fail-fast (503 sanitized when down — the folder/row stay
  committed and the next sync/re-upload retries idempotently), and the
  source is scanned synchronously in the request (single-source
  import_sources prune=True + change-gated KB overview), answering 200
  with the sync-style counts. One upload at a time (409); the request
  session is released before the scan so a concurrent TRUNCATE cannot
  deadlock against it.
* app/rag/archive_upload.py: ArchiveUploadError, ARCHIVE_SUFFIXES,
  archive_source_name (safe-name derivation), unpack_archive (guarded
  zip/tar extraction with the extracted-byte cap, no partial state),
  swap_in (atomic replace with restore-on-failure) — fully unit-tested.
* app/config.py + .env.example: BOR_UPLOAD_DIR (default
  ~/bor-sources/uploads, deliberately separate from the git checkouts)
  and BOR_UPLOAD_MAX_MB (default 512; a validator fails loud at
  startup on <= 0).
* python-multipart added to the dependencies — FastAPI's required
  multipart parser (an A2 implementation detail, phase locked decision).
* The Sources page: the phase-38 "Add a local directory" form is
  removed; #archive-upload-form takes its place (labeled file input,
  "Upload & scan" button, the §7.4 never-stale lifecycle, inline
  role=alert error, role=status count line); hint + table caption
  updated. The POST /api/git-sources kind=local API contract is
  UNCHANGED — a plain directory is still registrable via the API, and
  existing Local rows list/remove/sync exactly as before.
* The phase-38 story E2E (test_local_directory_sources.py) is rewritten
  API-driven — the form it drove is gone; its acceptance stands.
* The story E2E (test_archive_upload_sources.py): the swap,
  upload→scan→list (the deterministic "Uploading…" in-flight state, the
  Local row, /api/docs + the RAG catalog), same-filename re-upload
  (in-place replace, prune, no duplicate row, v2-only folder), the
  422 inline error + recovery (the form is not wedged), and the
  anonymous gate + 403.
* README: the archive-upload section (formats, naming rule, in-place
  replace, both new settings), the local-directory form removal noted,
  config reference rows for BOR_UPLOAD_DIR / BOR_UPLOAD_MAX_MB.

Gates: unit+integration green, app/ coverage 99%, the story E2E green
in isolation, the regression suites (git sources admin, local
directory sources, sync button, import documents, nav rename, smoke,
shared header) green in isolation, ruff + pyright clean.

Note: per this phase's file-level staging, frontend/assets/styles.css
also carries the small same-day in-flight owner rework already in the
working tree (the .sign-in-mobile companion rule for the phase-48
mobile sign-in copy); the phase-49 change is the upload form's block.
This commit is contained in:
2026-08-28 15:57:59 -04:00
parent 872a07cee7
commit 03d26255c6
21 changed files with 3280 additions and 233 deletions
@@ -0,0 +1,788 @@
"""Integration: the admin archive-upload API (phase 49, task 02).
Real Postgres (``podman compose up -d db``); the upload dir is pointed
at a fresh tmp dir per test by monkeypatching the router's
``get_settings`` (the ``test_git_sources_api.py`` pattern — the dev
``.env`` never leaks in), and the scan uses the deterministic
in-process ``FakeEmbedder`` (``test_sync_api.py``'s ``_real_llm``
pattern — real import, no network).
Contract under test:
* anonymous → 403 ``{"detail": "admin only"}`` (the router's
``require_admin`` covers the new route);
* name/format gate → 422: a non-archive extension names the accepted
formats; a ``..`` / separator / empty-stem name (including a bare
``tar.gz``) is rejected with the task-01 message — and the upload
dir is never created for a rejected name (control characters never
reach the app: the multipart transport percent-encodes them — the
task-01 branch for them is covered in ``test_archive_upload.py``);
* one upload at a time → 409 ``an upload is already in progress``
(while a run is in flight — the first request holds the flag through
its scan — and while the module-level flag seam is held);
* streaming cap → 413 naming the ``upload_max_mb`` cap; the temp
``.upload`` file is removed (no stray ``.`` files in the upload dir);
* unpack safety → 422 (zip-slip member, tar symlink escape, corrupt
archive, zero-entry archive) — and a failed upload **never** touches
the previous folder, row, or KB of an earlier good upload (the
no-partial-state locked decision, asserted explicitly);
* happy path → 200 with the sync-detail count keys (``source`` +
``files/added/updated/unchanged/pruned/errors/chunks/overview``), a
``kind=local`` row under the tmp ``upload_dir`` (the NOT-NULL
``url`` column carries the path — the phase-38 convention), the
unpacked folder, the KB via ``GET /api/docs``, and the per-upload
log line (PLAN §9 / AGENTS.md rule 10);
* re-upload, same name → the swap replaces the folder in place, the
row is NOT duplicated (``added_at`` preserved), dropped files are
pruned from the KB, added/changed files are indexed;
* an archive with only non-A9 files is a VALID replacement (indexes
nothing, prunes the previous docs, no overview refresh);
* dead models → 503 with the sanitized model-unavailable message; the
folder and row are already committed (the next sync/re-upload
retries idempotently).
``git_sources`` / ``documents`` / ``chunks`` / ``kb_overview`` are
global state: truncated around every test.
"""
from __future__ import annotations
import asyncio
import io
import logging
import os
import re
import tarfile
import threading
import zipfile
from collections.abc import Iterator
from pathlib import Path
from typing import Literal
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import select, text
from sqlalchemy.orm import Session
from app.api import git_sources as git_sources_api
from app.config import Settings
from app.db import SessionLocal
from app.main import app as fastapi_app
from app.models import GitSource
from app.rag.archive_upload import ArchiveUploadError
from app.rag.importer import ImportSummary
from app.rag.llm import ModelUnavailableError
from tests.conftest import ADMIN_PASSWORD
from tests.fakes import FakeEmbedder
@pytest.fixture(autouse=True)
def clean_git_sources(db: Session) -> Iterator[None]:
"""The stored list is global state: reset around every test."""
db.execute(text("TRUNCATE git_sources"))
db.commit()
yield
db.execute(text("TRUNCATE git_sources"))
db.commit()
@pytest.fixture(autouse=True)
def clean_documents(db: Session) -> Iterator[None]:
"""The happy path writes ``documents``/``chunks`` and (via the
change-gated overview refresh) ``kb_overview`` — global, truncated
around every test."""
db.execute(text("TRUNCATE chunks, documents, kb_overview"))
db.commit()
yield
db.execute(text("TRUNCATE chunks, documents, kb_overview"))
db.commit()
@pytest.fixture()
def upload_client() -> Iterator[TestClient]:
"""Admin-signed client (the context-manager form keeps one event
loop across requests — the in-flight 409 test needs the first
request's run to survive while the second lands)."""
with TestClient(fastapi_app) as client:
r = client.post("/api/login", json={"password": ADMIN_PASSWORD})
assert r.status_code == 204, f"admin login failed: {r.status_code} {r.text}"
yield client
def _point_at(monkeypatch: pytest.MonkeyPatch, upload_dir: Path, upload_max_mb: int = 512) -> None:
"""Fresh settings on the router's module: the tmp upload dir and
(optionally) a shrunk cap — the dev ``.env`` never leaks in."""
monkeypatch.setattr(
git_sources_api,
"get_settings",
lambda: Settings(
_env_file=None, # pyright: ignore[reportCallIssue]
upload_dir=str(upload_dir),
upload_max_mb=upload_max_mb,
),
)
def _real_llm(monkeypatch: pytest.MonkeyPatch) -> None:
"""The pipeline's ``LLMClient`` becomes the deterministic
in-process ``FakeEmbedder`` (real import, no network); it also
implements ``embed_one``/``chat``, so the phase-41 probe passes."""
monkeypatch.setattr(git_sources_api, "LLMClient", lambda: FakeEmbedder())
#: tarfile write modes used by the tests (uncompressed + gzip).
_TAR_WRITE_MODES = Literal["w", "w:gz"]
def _tarball(path: Path, files: dict[str, str], compress: _TAR_WRITE_MODES = "w:gz") -> Path:
with tarfile.open(path, compress) as tf:
for rel, content in files.items():
data = content.encode("utf-8")
info = tarfile.TarInfo(rel)
info.size = len(data)
info.mtime = 0
tf.addfile(info, io.BytesIO(data))
return path
def _zip(path: Path, files: dict[str, str]) -> Path:
with zipfile.ZipFile(path, "w") as zf:
for rel, content in files.items():
zf.writestr(rel, content)
return path
def _targz_bytes(files: dict[str, str]) -> bytes:
"""A tarball in memory (no temp file on disk)."""
buf = io.BytesIO()
with tarfile.open(fileobj=buf, mode="w:gz") as tf:
for rel, content in files.items():
data = content.encode("utf-8")
info = tarfile.TarInfo(rel)
info.size = len(data)
info.mtime = 0
tf.addfile(info, io.BytesIO(data))
return buf.getvalue()
def _post(client: TestClient, filename: str, payload: bytes):
"""One upload request (multipart, the page's exact shape)."""
return client.post(
"/api/git-sources/upload",
files={"file": (filename, payload, "application/octet-stream")},
)
def _row(db: Session, path: str) -> GitSource | None:
db.expire_all()
return db.scalar(select(GitSource).where(GitSource.path == path))
def _docs(client: TestClient) -> list[tuple[str, str]]:
body = client.get("/api/docs").json()
return [(d["source"], d["path"]) for d in body["documents"]]
def _count_rows(db: Session) -> int:
return db.execute(text("SELECT count(*) FROM git_sources")).scalar_one()
def _good_upload(
client: TestClient, name: str = "safe", files: dict[str, str] | None = None
) -> None:
"""A 200 upload of a two-sentinel tarball — the baseline state the
no-partial-state tests protect."""
payload_files = files or {
"alpha.md": "# Alpha\noriginal sentinel one\n",
"bravo.md": "# Bravo\noriginal sentinel two\n",
}
r = _post(client, f"{name}.tar.gz", _targz_bytes(payload_files))
assert r.status_code == 200, r.text
def _assert_previous_intact(
client: TestClient, db: Session, upload_dir: Path, name: str, files: dict[str, str]
) -> None:
"""The no-partial-state locked decision, asserted explicitly: after a
FAILED upload the previous folder's content, the row (same
``added_at``), and the KB are all exactly as the good upload left
them — and no temp file survived."""
folder = upload_dir / name
assert {p.name: p.read_text(encoding="utf-8") for p in folder.iterdir()} == files
assert _row(db, str(folder)) is not None # the row is still there
assert _count_rows(db) == 1 # …and no second row appeared
assert _docs(client) == [(name, "alpha.md"), (name, "bravo.md")]
assert [p.name for p in upload_dir.iterdir()] == [name] # no stray temp
# --- anonymous -------------------------------------------------------------
def test_anonymous_upload_gets_403(client: TestClient, db: Session) -> None:
r = _post(client, "homelab.tar.gz", b"not an archive at all")
assert r.status_code == 403
assert r.json() == {"detail": "admin only"}
assert _count_rows(db) == 0
# --- name / format gate -----------------------------------------------------
def test_non_archive_extension_gets_422_naming_formats(
upload_client: TestClient, monkeypatch: pytest.MonkeyPatch, db: Session, tmp_path: Path
) -> None:
uploads = tmp_path / "uploads"
_point_at(monkeypatch, uploads)
r = _post(upload_client, "notes.txt", b"hello world")
assert r.status_code == 422
assert r.json()["detail"] == "only .tar, .tar.gz, .tgz or .zip archives are accepted"
assert _count_rows(db) == 0
# The name gate runs before the dir is created — nothing on disk.
assert not uploads.exists()
@pytest.mark.parametrize(
"filename",
[
"../evil.zip", # ``..`` + separator
"sub/dir.tar.gz", # separator
"tar.gz", # bare suffix → empty stem
".tar.gz", # empty stem
# (control characters in the filename are percent-encoded by the
# multipart transport before the app ever sees them — the
# ``archive_source_name`` branch for them is covered in
# tests/unit/test_archive_upload.py)
],
)
def test_unsafe_name_gets_422(
upload_client: TestClient,
monkeypatch: pytest.MonkeyPatch,
db: Session,
tmp_path: Path,
filename: str,
) -> None:
uploads = tmp_path / "uploads"
_point_at(monkeypatch, uploads)
r = _post(upload_client, filename, b"content")
assert r.status_code == 422, f"{filename!r} must be rejected: {r.text}"
assert r.json()["detail"] != "only .tar, .tar.gz, .tgz or .zip archives are accepted"
assert _count_rows(db) == 0
assert not uploads.exists()
# --- one at a time (409) ----------------------------------------------------
def test_second_upload_while_one_is_in_flight_returns_409(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""The flag is held from the name gate through the scan response:
while the first run is inside its (blocked) scan, the second upload
is 409 — and after the first finishes, uploads are accepted again."""
uploads = tmp_path / "uploads"
_point_at(monkeypatch, uploads)
_real_llm(monkeypatch)
archive = _tarball(tmp_path / "homelab.tar.gz", {"alpha.md": "# Alpha\nx\n"})
started = threading.Event()
release = threading.Event()
class BlockingImport:
async def __call__(self, sources, llm, **kwargs) -> ImportSummary:
started.set() # the run is in flight (the flag was set earlier)
await asyncio.to_thread(release.wait, 15.0)
return ImportSummary(files=1, unchanged=1)
monkeypatch.setattr(git_sources_api, "import_sources", BlockingImport())
first = {}
def do_first() -> None:
# A separate client/cookie jar: the two requests run on separate
# TestClient portals; only the module-level flag is shared.
with TestClient(fastapi_app) as c:
assert c.post("/api/login", json={"password": ADMIN_PASSWORD}).status_code == 204
first["r"] = _post(c, "homelab.tar.gz", archive.read_bytes())
thread = threading.Thread(target=do_first)
thread.start()
try:
assert started.wait(15.0), "first upload did not reach its scan"
with TestClient(fastapi_app) as second:
assert second.post("/api/login", json={"password": ADMIN_PASSWORD}).status_code == 204
r = _post(second, "homelab.tar.gz", archive.read_bytes())
assert r.status_code == 409
assert r.json() == {"detail": "an upload is already in progress"}
finally:
release.set()
thread.join(20)
assert first["r"].status_code == 200, first["r"].text
# The flag was released: the next upload goes through for real.
with TestClient(fastapi_app) as third:
assert third.post("/api/login", json={"password": ADMIN_PASSWORD}).status_code == 204
r = _post(third, "homelab.tar.gz", archive.read_bytes())
assert r.status_code == 200, r.text
def test_upload_refused_while_flag_held(
upload_client: TestClient, monkeypatch: pytest.MonkeyPatch, db: Session, tmp_path: Path
) -> None:
"""The smallest test seam: the module-level flag itself. Held →
409, nothing happens (no dir, no row)."""
_point_at(monkeypatch, tmp_path / "uploads")
monkeypatch.setattr(git_sources_api, "_upload_in_progress", True)
r = _post(upload_client, "a.tar.gz", b"x")
assert r.status_code == 409
assert r.json() == {"detail": "an upload is already in progress"}
assert _count_rows(db) == 0
assert not (tmp_path / "uploads").exists()
# --- in-flight upload must not hold the request session (regression, -----
# --- phase 49 task 03) ----------------------------------------------------
def test_in_flight_upload_does_not_block_a_concurrent_truncate(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""While the scan is in flight, a concurrent TRUNCATE of the KB +
registry tables (the E2E isolation-fixture pattern) must complete.
Regression for the phase-49 task-03 deadlock: the handler used to
keep its request ``db`` session open across the scan, and the
uncommitted ``_commit_new`` refresh transaction held
``git_sources`` locks for the whole scan. A concurrent TRUNCATE
(documents locked, git_sources pending) then deadlocked with the
scan's own document locks — a cycle spanning three connections
that Postgres's detector cannot see, hanging the app and the test
run forever. The handler now releases the session before the scan;
this pins it: with the scan held in flight, the TRUNCATE completes
well inside its 5 s ``lock_timeout`` (without the fix it times out
with a lock-not-available error)."""
uploads = tmp_path / "uploads"
_point_at(monkeypatch, uploads)
_real_llm(monkeypatch)
archive = _tarball(tmp_path / "homelab.tar.gz", {"alpha.md": "# Alpha\nx\n"})
started = threading.Event()
release = threading.Event()
class BlockingImport:
async def __call__(self, sources, llm, **kwargs) -> ImportSummary:
started.set() # the run is in flight (the flag was set earlier)
await asyncio.to_thread(release.wait, 15.0)
return ImportSummary(files=1, unchanged=1)
monkeypatch.setattr(git_sources_api, "import_sources", BlockingImport())
first = {}
def do_first() -> None:
with TestClient(fastapi_app) as c:
assert c.post("/api/login", json={"password": ADMIN_PASSWORD}).status_code == 204
first["r"] = _post(c, "homelab.tar.gz", archive.read_bytes())
thread = threading.Thread(target=do_first)
thread.start()
try:
assert started.wait(15.0), "first upload did not reach its scan"
# The E2E isolation TRUNCATE, exactly as the story fixtures run
# it — must complete while the scan is held in flight.
with SessionLocal() as tr, tr.begin():
tr.execute(text("SET LOCAL lock_timeout = '5s'"))
tr.execute(
text("TRUNCATE chunks, documents, query_log, kb_overview, git_sources")
)
finally:
release.set()
thread.join(20)
# The scan finished once released — the 200 stands (the TRUNCATE ran
# after the row upsert and dropped it; the next re-upload is
# idempotent, and the autouse fixtures reset the tables anyway).
assert first["r"].status_code == 200, first["r"].text
# --- streaming cap (413) ----------------------------------------------------
def test_oversized_upload_gets_413_and_leaves_no_temp(
upload_client: TestClient, monkeypatch: pytest.MonkeyPatch, db: Session, tmp_path: Path
) -> None:
uploads = tmp_path / "uploads"
_point_at(monkeypatch, uploads, upload_max_mb=1)
# 2 MiB of incompressible bytes with a 1 MiB cap → 413 naming the cap.
big = os.urandom(2 * 1024 * 1024)
r = _post(upload_client, "big.zip", big)
assert r.status_code == 413
assert "1 MiB" in r.json()["detail"]
assert _count_rows(db) == 0
assert uploads.exists() # the dir was created before streaming
assert list(uploads.iterdir()) == [] # the temp .upload file is gone
# Boundary: EXACTLY the cap is not 413 (the check is strictly >) —
# the stream completes and the garbage bytes fail at unpack instead.
r = _post(upload_client, "exact.zip", os.urandom(1024 * 1024))
assert r.status_code == 422
assert "could not unpack the archive" in r.json()["detail"]
assert list(uploads.iterdir()) == []
# --- unpack safety: failed uploads leave the previous state intact ----------
def test_zip_slip_archive_gets_422_and_previous_state_is_intact(
upload_client: TestClient, monkeypatch: pytest.MonkeyPatch, db: Session, tmp_path: Path
) -> None:
uploads = tmp_path / "uploads"
_point_at(monkeypatch, uploads)
_real_llm(monkeypatch)
files = {
"alpha.md": "# Alpha\noriginal sentinel one\n",
"bravo.md": "# Bravo\noriginal sentinel two\n",
}
_good_upload(upload_client, "safe", files)
evil = _zip(tmp_path / "safe.zip", {"../evil.txt": "pwned"})
r = _post(upload_client, "safe.zip", evil.read_bytes())
assert r.status_code == 422
assert "traversal" in r.json()["detail"]
assert not (tmp_path / "evil.txt").exists() # the escape never landed
_assert_previous_intact(upload_client, db, uploads, "safe", files)
def test_tar_symlink_escape_gets_422_and_previous_state_is_intact(
upload_client: TestClient, monkeypatch: pytest.MonkeyPatch, db: Session, tmp_path: Path
) -> None:
uploads = tmp_path / "uploads"
_point_at(monkeypatch, uploads)
_real_llm(monkeypatch)
files = {
"alpha.md": "# Alpha\noriginal sentinel one\n",
"bravo.md": "# Bravo\noriginal sentinel two\n",
}
_good_upload(upload_client, "safe", files)
evil = tmp_path / "safe.tar"
with tarfile.open(evil, "w") as tf:
info = tarfile.TarInfo("link")
info.type = tarfile.SYMTYPE
info.linkname = "/etc/passwd"
tf.addfile(info)
r = _post(upload_client, "safe.tar", evil.read_bytes())
assert r.status_code == 422
assert "escape" in r.json()["detail"]
_assert_previous_intact(upload_client, db, uploads, "safe", files)
def test_corrupt_archive_gets_422_and_previous_state_is_intact(
upload_client: TestClient, monkeypatch: pytest.MonkeyPatch, db: Session, tmp_path: Path
) -> None:
uploads = tmp_path / "uploads"
_point_at(monkeypatch, uploads)
_real_llm(monkeypatch)
files = {
"alpha.md": "# Alpha\noriginal sentinel one\n",
"bravo.md": "# Bravo\noriginal sentinel two\n",
}
_good_upload(upload_client, "safe", files)
# A truncated zip (the EOCD is cut off) is not a zip and not a tar.
good_zip = _zip(tmp_path / "good.zip", {"alpha.md": "# Alpha\nx\n"})
good_bytes = good_zip.read_bytes()
truncated = good_bytes[: len(good_bytes) // 2]
r = _post(upload_client, "safe.zip", truncated)
assert r.status_code == 422
assert "could not unpack the archive" in r.json()["detail"]
# A zero-byte "archive" fails the same way.
r = _post(upload_client, "safe.tar", b"")
assert r.status_code == 422
assert "could not unpack the archive" in r.json()["detail"]
_assert_previous_intact(upload_client, db, uploads, "safe", files)
def test_swap_failure_gets_422_and_previous_state_is_intact(
upload_client: TestClient, monkeypatch: pytest.MonkeyPatch, db: Session, tmp_path: Path
) -> None:
"""A rename failure in ``swap_in`` (OS error) → 422 with its
message; the previous folder/row/KB are untouched and no temp
survives (the handler's finally cleans the temp sibling)."""
uploads = tmp_path / "uploads"
_point_at(monkeypatch, uploads)
_real_llm(monkeypatch)
files = {
"alpha.md": "# Alpha\noriginal sentinel one\n",
"bravo.md": "# Bravo\noriginal sentinel two\n",
}
_good_upload(upload_client, "safe", files)
def failing_swap(new_dir: Path, final_dir: Path) -> None:
raise ArchiveUploadError("could not replace the previous folder")
monkeypatch.setattr(git_sources_api, "swap_in", failing_swap)
r = _post(upload_client, "safe.tar.gz", _targz_bytes({"alpha.md": "# A\nx\n"}))
assert r.status_code == 422
assert r.json()["detail"] == "could not replace the previous folder"
_assert_previous_intact(upload_client, db, uploads, "safe", files)
def test_zero_entry_archive_gets_422(
upload_client: TestClient, monkeypatch: pytest.MonkeyPatch, db: Session, tmp_path: Path
) -> None:
"""A completely empty archive (zero entries) is 422 — both
containers — with no folder, row, or temp file left behind."""
uploads = tmp_path / "uploads"
_point_at(monkeypatch, uploads)
_real_llm(monkeypatch)
empty_targz = tmp_path / "empty.tar.gz"
with tarfile.open(empty_targz, "w:gz"):
pass
r = _post(upload_client, "empty.tar.gz", empty_targz.read_bytes())
assert r.status_code == 422
assert r.json()["detail"] == "the archive contains no files"
empty_zip = tmp_path / "empty.zip"
with zipfile.ZipFile(empty_zip, "w"):
pass
r = _post(upload_client, "empty.zip", empty_zip.read_bytes())
assert r.status_code == 422
assert r.json()["detail"] == "the archive contains no files"
assert _count_rows(db) == 0
assert not (uploads / "empty").exists()
assert list(uploads.iterdir()) == []
# --- happy path -------------------------------------------------------------
def test_happy_path_tar_gz(
upload_client: TestClient,
monkeypatch: pytest.MonkeyPatch,
db: Session,
tmp_path: Path,
caplog: pytest.LogCaptureFixture,
) -> None:
uploads = tmp_path / "uploads"
_point_at(monkeypatch, uploads)
_real_llm(monkeypatch)
files = {"alpha.md": "# Alpha\nfirst sentinel\n", "bravo.md": "# Bravo\nsecond sentinel\n"}
payload = _tarball(tmp_path / "homelab.tar.gz", files).read_bytes()
with caplog.at_level(logging.INFO, logger="app.api.git_sources"):
r = _post(upload_client, "homelab.tar.gz", payload)
assert r.status_code == 200, r.text
body = r.json()
assert set(body) == {
"source", "files", "added", "updated", "unchanged", "pruned", "errors", "chunks",
"overview",
}
assert body["source"] == "homelab" # filename minus the archive suffix
assert body["files"] == 2
assert body["added"] == 2
assert body["updated"] == 0
assert body["unchanged"] == 0
assert body["pruned"] == 0
assert body["errors"] == 0
assert body["chunks"] >= 2
assert body["overview"] is True # the KB changed → the overview refreshed
# Unpacked under the tmp upload dir, dotfile temps cleaned up.
folder = uploads / "homelab"
assert folder.is_dir()
assert {p.name: p.read_text(encoding="utf-8") for p in folder.iterdir()} == files
assert [p.name for p in uploads.iterdir()] == ["homelab"]
# One kind=local row; the NOT-NULL url column carries the path.
row = _row(db, str(folder))
assert row is not None
assert row.kind == "local"
assert row.url == str(folder)
assert row.path == str(folder)
assert _count_rows(db) == 1
# The KB lists both files under the source name.
assert _docs(upload_client) == [("homelab", "alpha.md"), ("homelab", "bravo.md")]
# The per-upload log line (PLAN §9 / AGENTS.md rule 10).
lines = [rec.getMessage() for rec in caplog.records if rec.getMessage().startswith("upload: ")]
assert len(lines) == 1, lines
match = re.match(
r"^upload: name=homelab file=homelab\.tar\.gz bytes_in=(\d+) files=2 added=2 "
r"updated=0 unchanged=0 pruned=0 errors=0 overview=True total_ms=\d+$",
lines[0],
)
assert match, lines[0]
assert int(match.group(1)) == len(payload) # the compressed bytes in
@pytest.mark.parametrize(
("filename", "builder"),
[
("notes.zip", "zip"),
("plain.tar", "tar"),
("tgz.tgz", "targz"),
],
)
def test_happy_path_other_accepted_formats(
upload_client: TestClient,
monkeypatch: pytest.MonkeyPatch,
db: Session,
tmp_path: Path,
filename: str,
builder: str,
) -> None:
uploads = tmp_path / "uploads"
_point_at(monkeypatch, uploads)
_real_llm(monkeypatch)
files = {"alpha.md": "# Alpha\nsentinel\n"}
make = {"zip": _zip, "tar": lambda p, f: _tarball(p, f, "w"), "targz": _tarball}[builder]
payload = make(tmp_path / filename, files).read_bytes()
r = _post(upload_client, filename, payload)
assert r.status_code == 200, r.text
body = r.json()
expected_name = {"notes.zip": "notes", "plain.tar": "plain", "tgz.tgz": "tgz"}[filename]
assert body["source"] == expected_name
assert body["added"] == 1
assert _docs(upload_client) == [(expected_name, "alpha.md")]
assert _count_rows(db) == 1
# --- re-upload, same name: in-place replace ---------------------------------
def test_reupload_same_name_replaces_in_place(
upload_client: TestClient, monkeypatch: pytest.MonkeyPatch, db: Session, tmp_path: Path
) -> None:
uploads = tmp_path / "uploads"
_point_at(monkeypatch, uploads)
_real_llm(monkeypatch)
v1 = {
"alpha.md": "# Alpha\nv1 content\n",
"bravo.md": "# Bravo\nv1 content\n",
"charlie.md": "# Charlie\nv1 content\n",
}
r = _post(upload_client, "homelab.tar.gz", _tarball(tmp_path / "v1.tar.gz", v1).read_bytes())
assert r.status_code == 200, r.text
assert r.json()["added"] == 3
row_before = _row(db, str(uploads / "homelab"))
assert row_before is not None
added_at_before = row_before.added_at
# v2: alpha changed, bravo dropped, delta new.
v2 = {"alpha.md": "# Alpha\nv2 CHANGED content\n", "delta.md": "# Delta\nbrand new\n"}
r = _post(upload_client, "homelab.tar.gz", _tarball(tmp_path / "v2.tar.gz", v2).read_bytes())
assert r.status_code == 200, r.text
body = r.json()
assert body["source"] == "homelab"
assert body["files"] == 2
assert body["added"] == 1 # delta.md
assert body["updated"] == 1 # alpha.md (hash changed)
assert body["unchanged"] == 0
assert body["pruned"] == 2 # bravo.md + charlie.md left the folder → pruned
assert body["overview"] is True
# Exactly ONE row for the path, and its added_at survived (the
# upsert left the existing row alone).
assert _count_rows(db) == 1
row_after = _row(db, str(uploads / "homelab"))
assert row_after is not None
assert row_after.id == row_before.id
assert row_after.added_at == added_at_before
# The folder holds ONLY the new archive's files (full replacement —
# no stale files from v1), and the KB mirrors it.
folder = uploads / "homelab"
assert {p.name: p.read_text(encoding="utf-8") for p in folder.iterdir()} == v2
assert [p.name for p in uploads.iterdir()] == ["homelab"]
assert _docs(upload_client) == [("homelab", "alpha.md"), ("homelab", "delta.md")]
def test_non_a9_archive_is_a_valid_replacement(
upload_client: TestClient, monkeypatch: pytest.MonkeyPatch, db: Session, tmp_path: Path
) -> None:
"""An archive with only non-A9 files is a valid replacement: the
swap happens, the scan indexes nothing, prune removes the source's
docs, and the overview is NOT refreshed (no KB change)."""
uploads = tmp_path / "uploads"
_point_at(monkeypatch, uploads)
_real_llm(monkeypatch)
r = _post(
upload_client,
"notes.tar.gz",
_tarball(tmp_path / "a.tar.gz", {"readme.md": "# Readme\nv1\n"}).read_bytes(),
)
assert r.status_code == 200, r.text
assert _docs(upload_client) == [("notes", "readme.md")]
r = _post(
upload_client,
"notes.tar.gz",
_tarball(tmp_path / "b.tar.gz", {"binary.bin": "not an importable format"}).read_bytes(),
)
assert r.status_code == 200, r.text
body = r.json()
assert body["files"] == 0 # nothing matches the A9 filter
assert body["added"] == 0
assert body["updated"] == 0
assert body["pruned"] == 1 # readme.md left the KB
assert body["overview"] is False # added + updated == 0 → no refresh
assert {p.name for p in (uploads / "notes").iterdir()} == {"binary.bin"}
assert _count_rows(db) == 1
assert _docs(upload_client) == []
# --- fail-fast models (503) ---------------------------------------------------
def test_models_down_gets_503_and_leaves_folder_and_row(
upload_client: TestClient, monkeypatch: pytest.MonkeyPatch, db: Session, tmp_path: Path
) -> None:
"""The folder and row are committed BEFORE the model probe: a dead
endpoint answers 503 (sanitized — credentials masked) and the next
sync/re-upload retries idempotently; the scan never ran."""
uploads = tmp_path / "uploads"
_point_at(monkeypatch, uploads)
_real_llm(monkeypatch)
async def dead_probe(llm: object) -> None:
raise ModelUnavailableError(
"The embedding model ('embed') is not available — check the model "
"endpoint and retry. (embeddings request to "
"https://user:secret@aipi.reeseapps.com/v1 failed: connection refused)"
)
monkeypatch.setattr(git_sources_api, "check_models", dead_probe)
r = _post(
upload_client,
"homelab.tar.gz",
_tarball(tmp_path / "h.tar.gz", {"alpha.md": "# Alpha\nx\n"}).read_bytes(),
)
assert r.status_code == 503
detail = r.json()["detail"]
assert "The embedding model ('embed') is not available" in detail
assert "*****@aipi.reeseapps.com" in detail # the sanitizer masked the credentials
assert "user:secret" not in detail
assert "connection refused" in detail # the reason survives
# The folder and row are already committed (idempotent retry path).
folder = uploads / "homelab"
assert folder.is_dir()
row = _row(db, str(folder))
assert row is not None
assert row.kind == "local"
assert row.url == str(folder)
# The scan never ran: no docs, no stray temps.
assert _docs(upload_client) == []
assert [p.name for p in uploads.iterdir()] == ["homelab"]