feat(sources): removing a source deletes its files and index entries behind a confirmation modal
Build and Push Containers / build-and-push-app (push) Successful in 1m29s
Build and Push Containers / build-and-push-db (push) Successful in 11s

This commit is contained in:
2026-09-02 15:55:33 -04:00
parent 265e736b3d
commit 137d5fa1a5
24 changed files with 3489 additions and 118 deletions
+20 -17
View File
@@ -35,8 +35,10 @@ Contract under test:
render as a full-width table (mono URL, added date, per-row Remove)
with the env note hidden while the DB has rows; add (201 → row, input
cleared, button re-enabled), duplicate (inline role=alert, no new
row), invalid shape (inline 422, no new row), remove (confirm → gone;
cancel → stays); with the table truncated the env rows render with
row), invalid shape (inline 422, no new row), remove (the
confirmation modal: accept → gone; cancel → stays — the native
confirm() retired, phase 69); with the table truncated the env rows
render with
"from .env" tags and ``#git-sources-env-note`` visible;
* the page a11y / no-CDN basics (UI Structure Check, AGENTS.md rule 5):
landmarks, labeled form control, full-width table, ≥44px targets,
@@ -64,7 +66,7 @@ from pathlib import Path
from typing import Any
import pytest
from playwright.sync_api import Dialog, Page, expect
from playwright.sync_api import Page, expect
from sqlalchemy import text
from app.db import SessionLocal
@@ -425,42 +427,43 @@ def test_admin_add_then_remove_lifecycle(
expect(add_btn).to_be_enabled()
expect(add_btn).to_have_text("Add source")
# --- remove: accept the confirm → the row disappears --------------
dialog_action: dict[str, bool] = {"accept": True}
# --- remove: accept the confirmation modal → the row disappears ---
# (phase 69: the native confirm() is retired — a row's Remove opens
# the page-local alertdialog; "Remove source" accepts, Cancel
# cancels without a request.)
deletes: list[str] = []
def handle_dialog(dialog: Dialog) -> None:
if dialog_action["accept"]:
dialog.accept()
else:
dialog.dismiss()
def track_delete(r: Any) -> None:
if r.method == "DELETE" and "/api/git-sources/" in r.url:
deletes.append(r.url)
page.on("dialog", handle_dialog)
page.on("request", track_delete)
try:
_row(page, NEW_REPO_URL).locator(".git-source-remove").click()
dialog = page.locator("#remove-confirm-dialog")
expect(dialog).to_be_visible(timeout=30_000)
expect(page.locator("#remove-confirm-source")).to_have_text(NEW_REPO_URL)
page.locator("#remove-confirm-remove").click()
expect(page.locator("#git-sources-tbody tr")).to_have_count(1, timeout=30_000)
expect(_row(page, NEW_REPO_URL)).to_have_count(0)
expect(dialog).to_be_hidden()
# The seed row survived — and exactly one DELETE went out.
expect(_row(page, SEED_URL)).to_have_count(1)
assert len(deletes) == 1, f"expected one DELETE, saw: {deletes}"
# --- remove: cancel the confirm → the row stays, NO DELETE ----
dialog_action["accept"] = False
# --- remove: cancel the modal → the row stays, NO DELETE ------
_row(page, SEED_URL).locator(".git-source-remove").click()
# Wait for the dialog round-trip to settle (dismiss → the JS
# returns without fetching) so the "no second DELETE" claim is
expect(page.locator("#remove-confirm-dialog")).to_be_visible(timeout=30_000)
page.locator("#remove-confirm-cancel").click()
expect(page.locator("#remove-confirm-dialog")).to_be_hidden()
# Wait for the cancel round-trip to settle (Cancel closes the
# dialog without fetching) so the "no second DELETE" claim is
# made on a settled page.
page.wait_for_timeout(500)
assert len(deletes) == 1, f"canceled removal still deleted: {deletes}"
expect(_row(page, SEED_URL)).to_have_count(1)
expect(page.locator("#git-sources-tbody tr")).to_have_count(1)
finally:
page.remove_listener("dialog", handle_dialog)
page.remove_listener("request", track_delete)
+738
View File
@@ -0,0 +1,738 @@
"""Phase 69 story E2E (Playwright): total source removal — the
confirmation modal, the row + index prune, and the app-managed disk
cleanup (``/git-sources.html``).
Story: n/a (owner request from chat, 2026-09-02)
Run in isolation (DB must be up: ``podman compose up -d db``):
uv run pytest tests/e2e/test_source_removal_cleanup.py -v --no-cov
The story gate for TOTAL source removal (phase 69): removing a source
removes, in one action, the stored row, every indexed document of that
source (chunks + embeddings), and — for app-managed sources — the files
on disk (the git checkout under ``BOR_SOURCES_DIR`` or the unpacked
upload folder under ``BOR_UPLOAD_DIR``), immediately (not deferred to
the next sync). The page confirms first through a page-local
``role="alertdialog"`` modal (the native ``confirm()`` is retired) that
names the source and states the removal policy; Cancel/Escape close
without a request, and only "Remove source" sends the DELETE.
**Disk paths are resolved exactly like the app**:
``Path(get_settings().sources_dir).expanduser()`` /
``Path(get_settings().upload_dir).expanduser()`` — the E2E conftest
does NOT override those two vars and pytest runs with ``cwd=REPO``, so
the test process and the app subprocess resolve the same ``.env``
(``BOR_SOURCES_DIR`` / ``BOR_UPLOAD_DIR``) and the same-host ``pathlib``
assertions hit the very directories the DELETE handler cleans.
The suite triggers **no sync** (the git rows are ``example.com`` URLs
that are never cloned); the only real artifact is the API-driven
upload of one small archive with a unique name (``phase69-<8-hex>.tar.gz``,
one ``.md`` file) — its background scan runs the mock-LLM pipeline
(no network beyond the app itself). Seeded ``Document`` rows
(``SessionLocal``, the ``test_git_sources_admin.py`` pattern) give the
prune assertions a deterministic KB.
Per-module app env (the conftest pattern, module-scoped): the same env
shape as ``test_git_sources_admin.py`` with ``BOR_GIT_SOURCES`` forced
empty (a dev ``.env`` fallback URL must never render as an env row on
the table); **no** ``BOR_SOURCES_DIR`` / ``BOR_UPLOAD_DIR`` override
(deliberate — see the disk-path note above).
Contract under test:
* **upload → modal → total removal**: the uploaded folder exists on
disk and its document is in ``GET /api/docs``; the row's Remove →
the alertdialog opens (``#remove-confirm-source`` = the upload path,
focus on ``#remove-confirm-cancel``) → "Remove source" → the
"Removing…" in-flight state (both buttons disabled) → settled: the
row is gone, the document is pruned, **the folder is gone from
disk**, exactly one DELETE went out, and the announcer carries the
success line;
* **git checkout removal**: a seeded git row + a hand-made checkout
dir (marker file) + a seeded document → modal removal → the row is
gone, **the checkout dir is gone from disk** (marker included) and
the document is pruned;
* **no-checkout no-op**: a git row with NO checkout dir (never synced)
removes cleanly — the absent-dir path, no error state anywhere on
the page;
* **foreign local directories are never touched**: a seeded
``kind='local'`` row pointing at a test-owned dir (marker file) + a
seeded document → modal removal → the row is gone and the document
is pruned, **but the dir + marker file are still present**;
* **cancel + Escape keep everything**: Cancel click → dialog hidden,
focus back on the trigger button, **zero DELETE requests**, row +
document remain; re-open → Escape → the same;
* **modal a11y + no CDN** (UI Structure Check, AGENTS.md rule 5 +
rule 6): the dialog's aria attributes, the 3px ``:focus-visible``
outline computable on BOTH buttons (keyboard focus), both buttons
≥44px tall, the success line on ``#git-sources-announcer``
(role=status) after a real removal, and the page loads only
same-origin resources.
Test → story mapping (Playwright Mapping Rule):
1. ``test_uploaded_source_removal_cleans_index_and_disk``
2. ``test_git_source_removal_removes_checkout_and_index``
3. ``test_git_source_removal_without_checkout_succeeds``
4. ``test_local_directory_source_files_never_deleted``
5. ``test_remove_modal_cancel_and_esc_keep_everything``
6. ``test_remove_modal_a11y_and_no_cdn``
"""
from __future__ import annotations
import hashlib
import io
import os
import shutil
import subprocess
import sys
import tarfile
import time
import uuid
from collections.abc import Iterator
from pathlib import Path
from typing import Any
import pytest
from playwright.sync_api import Page, Route, expect
from sqlalchemy import text
from app.config import get_settings
from app.db import SessionLocal
from app.models import Document, GitSource
from e2e.auth_helpers import login
from e2e.conftest import (
ADMIN_PASSWORD,
APP_PORT,
SESSION_SECRET,
USE_REAL_LLM,
_wait_http,
)
REPO = Path(__file__).resolve().parents[2]
APP_URL = f"http://127.0.0.1:{APP_PORT}"
GIT_SOURCES_URL = "/git-sources.html"
#: The success line the page announces on a total removal (the
#: git-sources.js announce() string).
REMOVAL_ANNOUNCE = "Source removed — its files and index entries were cleaned up."
#: Deterministic, never-cloned example.com URLs — the suite never
#: triggers a sync, so the only on-disk artifact a git row can have is
#: the checkout dir this suite itself creates (test 2).
GONE_URL = "https://example.com/reese/phase69-gone.git"
GONE_REPO = "phase69-gone" # repo_name(GONE_URL)
NO_CHECKOUT_URL = "https://example.com/reese/phase69-nodir.git"
KEPT_URL = "https://example.com/reese/phase69-kept.git"
KEPT_REPO = "phase69-kept"
A11Y_URL = "https://example.com/reese/phase69-a11y.git"
A11Y_REPO = "phase69-a11y"
#: The upload sentinel — one markdown file in the unique archive.
UPLOAD_SENTINEL = "PHASE69-UPLOAD-9c2d"
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture(scope="module")
def app_server(mock_llm: int) -> Iterator[str]:
"""The real app under test — per-module env: ``BOR_GIT_SOURCES``
forced empty (no env fallback rows on the table). Deliberately NO
``BOR_SOURCES_DIR`` / ``BOR_UPLOAD_DIR`` override: the disk
assertions must resolve the dirs EXACTLY like the app (the module
docstring explains why the same ``.env`` resolves on both sides)."""
env = dict(os.environ)
env.pop("DEBUGPY", None)
env["BOR_ENVIRONMENT"] = "e2e"
env["BOR_STATIC_DIR"] = str(REPO / "frontend")
env["BOR_LLM_BASE_URL"] = (
"https://aipi.reeseapps.com/v1"
if USE_REAL_LLM
else f"http://127.0.0.1:{mock_llm}/v1"
)
# Mock-calibrated threshold (conftest pattern) — no chat turn is
# ever sent in this suite, but the app boots with the same env shape.
env["BOR_RELEVANCE_THRESHOLD"] = "0.30"
env.setdefault(
"BOR_DATABASE_URL",
"postgresql+psycopg://reese:reese@localhost:5432/brain_of_reese",
)
# Phase 16: admin auth must be set or create_app() refuses to boot.
env["BOR_ADMIN_PASSWORD"] = ADMIN_PASSWORD
env["BOR_SESSION_SECRET"] = SESSION_SECRET
# No env fallback rows: the table state (seeded per test) is the
# only row source, so row-count assertions are deterministic.
env["BOR_GIT_SOURCES"] = ""
proc = subprocess.Popen(
[sys.executable, "-m", "uvicorn", "app.main:app",
"--host", "127.0.0.1", "--port", str(APP_PORT), "--log-level", "warning"],
cwd=REPO,
env=env,
)
try:
_wait_http(f"{APP_URL}/api/health")
yield APP_URL
finally:
proc.terminate()
try:
proc.wait(timeout=10)
except subprocess.TimeoutExpired:
proc.kill()
@pytest.fixture(scope="module")
def app_url(app_server: str) -> str:
return app_server
@pytest.fixture(scope="module")
def sources_dir() -> Path:
"""The app's git-checkout root — resolved EXACTLY like the app
(same ``.env``, same ``cwd=REPO``; the conftest does not override
``BOR_SOURCES_DIR``)."""
return Path(get_settings().sources_dir).expanduser()
@pytest.fixture(scope="module")
def upload_dir() -> Path:
"""The app's upload root — resolved EXACTLY like the app (see
``sources_dir``)."""
return Path(get_settings().upload_dir).expanduser()
def _truncate_all() -> None:
"""Fresh registry + KB per test (the E2E isolation pattern, the
``test_archive_upload_sources.py`` set): the row/doc assertions
must be this test's own doing — a leftover document under one of
the deterministic source names would survive the prune and flip an
assertion."""
with SessionLocal() as db:
db.execute(text("TRUNCATE chunks, documents, query_log, kb_overview, git_sources"))
db.commit()
@pytest.fixture(autouse=True)
def _clean(db_ready: None) -> Iterator[None]:
_truncate_all()
yield
_truncate_all()
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _seed_git_row(url: str) -> None:
"""Store a git row directly (deterministic — the
``test_git_sources_admin.py`` SessionLocal seeding pattern)."""
with SessionLocal() as db:
db.add(GitSource(url=url, kind="git"))
db.commit()
def _seed_local_row(path: str) -> None:
"""Store a kind=local row (the phase-38 shape: the expanded
absolute path in BOTH ``path`` and the NOT-NULL ``url``)."""
with SessionLocal() as db:
db.add(GitSource(url=path, kind="local", path=path))
db.commit()
def _seed_doc(source: str, path: str, full_path: str, title: str, content: str) -> None:
"""One indexed document (the prune subject) — a full row so the
cascade + the /api/docs listing behave exactly like a scanned doc."""
with SessionLocal() as db:
db.add(
Document(
source=source,
path=path,
full_path=full_path,
title=title,
content=content,
content_hash=hashlib.sha256(content.encode("utf-8")).hexdigest(),
)
)
db.commit()
def _admin_git_sources_page(page: Page, app_url: str) -> None:
"""Real form login landing on the git sources page (admin settled:
Sign out visible, the manager revealed by the page module)."""
login(page, app_url, next=GIT_SOURCES_URL)
expect(page).to_have_url(app_url + GIT_SOURCES_URL, timeout=30_000)
expect(page.locator("#sign-out-btn")).to_be_visible(timeout=15_000)
expect(page.locator("#git-sources-gate")).to_be_hidden()
expect(page.locator("#git-sources-content")).to_be_visible()
def _row(page: Page, value: str) -> Any:
"""The table row whose mono cell shows ``value`` (git URL or
local path — the row's rendered value)."""
return page.locator("#git-sources-tbody tr", has_text=value)
def _docs(page: Page, app_url: str) -> list[tuple[str, str]]:
"""``GET /api/docs`` as the signed-in page → sorted (source, path)
pairs (the admin cookie rides the browser context)."""
r = page.request.get(f"{app_url}/api/docs")
assert r.status == 200, r.text
return sorted((d["source"], d["path"]) for d in r.json()["documents"])
def _stored_sources(page: Page, app_url: str) -> list[dict[str, Any]]:
"""``GET /api/git-sources`` as the signed-in page → the row list."""
r = page.request.get(f"{app_url}/api/git-sources")
assert r.status == 200, r.text
return r.json()["sources"]
def _build_targz(path: Path, files: dict[str, str]) -> Path:
"""A deterministic ``.tar.gz`` (mtime 0) over the given files (the
``test_archive_upload_sources.py`` pattern)."""
with tarfile.open(path, "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 path
def _upload_and_wait_success(page: Page, app_url: str, archive: Path) -> dict[str, Any]:
"""POST the archive through the logged-in page's request context
(the admin cookie rides along) and poll the phase-64 status
endpoint to ``success`` — returns the terminal status body."""
r = page.request.post(
f"{app_url}/api/git-sources/upload",
multipart={
"file": {
"name": archive.name,
"mimeType": "application/gzip",
"buffer": archive.read_bytes(),
}
},
)
assert r.status == 202, f"upload POST failed: {r.status} {r.text}"
deadline = time.monotonic() + 60.0
body: dict[str, Any] = {}
while time.monotonic() < deadline:
r = page.request.get(f"{app_url}/api/git-sources/upload/status")
assert r.status == 200, r.text
body = r.json()
if body["state"] == "success":
return body
if body["state"] == "failed":
raise AssertionError(f"the upload scan failed: {body}")
time.sleep(0.2)
raise AssertionError(f"the upload scan never settled: {body}")
def _open_remove_modal(page: Page, value: str) -> None:
"""Click the row's Remove for ``value`` and assert the opened
alertdialog: it names the source and focus lands on Cancel (the
safe default)."""
_row(page, value).locator(".git-source-remove").click()
dialog = page.locator("#remove-confirm-dialog")
expect(dialog).to_be_visible(timeout=15_000)
assert dialog.get_attribute("role") == "alertdialog"
expect(page.locator("#remove-confirm-source")).to_have_text(value)
assert page.evaluate("() => document.activeElement.id") == "remove-confirm-cancel"
def _confirm_removal(page: Page) -> None:
"""Click "Remove source" and wait for the settled removal: the
(single) row is gone and the dialog is closed."""
page.locator("#remove-confirm-remove").click()
expect(page.locator("#git-sources-tbody tr")).to_have_count(0, timeout=30_000)
expect(page.locator("#remove-confirm-dialog")).to_be_hidden()
def _hold_delete_requests(page: Page, hold_s: float) -> None:
"""Intercept DELETEs to the git-sources API and hold the REQUEST in
the browser for ``hold_s`` seconds (the
``test_archive_upload_sources.py`` ``_hold_upload_request``
pattern) — the modal's "Removing…" in-flight state (§7.4) becomes
deterministic instead of racing the (fast) mock-LLM cleanup.
Every other request (the list GETs, the upload status poll) passes
through untouched."""
def handle(route: Route) -> None:
if route.request.method == "DELETE":
time.sleep(hold_s)
route.continue_()
page.route("**/api/git-sources/**", handle)
# ---------------------------------------------------------------------------
# 1. Upload → modal → total removal: index pruned AND the upload
# folder is gone from disk
# ---------------------------------------------------------------------------
def test_uploaded_source_removal_cleans_index_and_disk(
page: Page, app_url: str, db_ready: None, upload_dir: Path, tmp_path: Path
) -> None:
"""A uniquely named archive uploaded through the API (202 → status
success): the folder exists on disk and its document is in the KB;
then the row's Remove → the alertdialog (the upload path named,
focus on Cancel) → "Remove source" → the "Removing…" in-flight
state → settled: row gone, document pruned, **the folder is gone
from disk**, one DELETE, the announcer's success line."""
page.set_default_timeout(30_000)
# Unique per run — never collides with a real (or a crashed-run's)
# upload, so the disk assertions are safe on the shared dir.
name = f"phase69-{uuid.uuid4().hex[:8]}"
archive = _build_targz(
tmp_path / f"{name}.tar.gz",
{
"note.md": (
"# Upload note\n"
"\n"
f"Phase 69 removal subject. Marker: {UPLOAD_SENTINEL}\n"
)
},
)
_admin_git_sources_page(page, app_url)
# The API-driven upload (202) + the background scan (success).
status = _upload_and_wait_success(page, app_url, archive)
assert status["detail"]["source"] == name
assert status["detail"]["added"] == 1
# Preconditions — the artifact is real: the folder on disk (the
# app's resolved upload dir — this process resolved the same one),
# the document in the KB, the row in the registry.
folder = upload_dir / name
assert (folder / "note.md").is_file(), f"{folder}/note.md missing on disk"
assert _docs(page, app_url) == [(name, "note.md")]
r = page.request.get(f"{app_url}/api/git-sources")
assert r.status == 200, r.text
assert [(s["kind"], s["path"]) for s in r.json()["sources"]] == [
("local", str(folder))
]
# The page's table still shows the pre-upload (empty) state — the
# upload went through the API, not the form (whose 202-success
# path would have called loadSources): a reload runs the page
# module's loadSources() and the new row lands.
page.reload()
expect(page.locator("#sign-out-btn")).to_be_visible(timeout=15_000)
expect(page.locator("#git-sources-content")).to_be_visible()
row = _row(page, str(folder))
expect(row).to_have_count(1, timeout=15_000)
expect(row.locator("span.git-source-kind")).to_have_text("Local")
# Track the DELETEs; hold the one the modal sends so the in-flight
# state is observable deterministically.
deletes: list[str] = []
page.on(
"request",
lambda r: deletes.append(r.url)
if r.method == "DELETE" and "/api/git-sources/" in r.url
else None,
)
_hold_delete_requests(page, hold_s=0.8)
# The modal: the upload path is named (textContent — the row's
# value), focus on Cancel.
_open_remove_modal(page, str(folder))
# "Remove source" → the §7.4 in-flight state (both buttons
# disabled, the confirm relabeled) while the held DELETE is out.
page.locator("#remove-confirm-remove").click()
expect(page.locator("#remove-confirm-remove")).to_have_text("Removing…")
expect(page.locator("#remove-confirm-remove")).to_be_disabled()
expect(page.locator("#remove-confirm-cancel")).to_be_disabled()
expect(page.locator("#remove-confirm-error")).to_be_hidden()
# Settled: the row is gone, the dialog closed and reset…
expect(page.locator("#git-sources-tbody tr")).to_have_count(0, timeout=30_000)
expect(page.locator("#remove-confirm-dialog")).to_be_hidden()
expect(page.locator("#remove-confirm-remove")).to_have_text("Remove source")
expect(page.locator("#remove-confirm-remove")).to_be_enabled()
# …the announcer carries the success line…
expect(page.locator("#git-sources-announcer")).to_have_text(REMOVAL_ANNOUNCE)
# …and exactly one DELETE went out.
assert len(deletes) == 1, f"expected one DELETE, saw: {deletes}"
# The TOTAL removal, proven outside the UI: the registry is empty,
# the document is pruned from the KB, and the folder is GONE from
# the app's upload dir (same-host pathlib, the app's resolved dir).
assert _stored_sources(page, app_url) == []
assert _docs(page, app_url) == []
assert not folder.exists(), f"{folder} still on disk after removal"
# ---------------------------------------------------------------------------
# 2. Git row → the checkout dir is removed from disk + the index pruned
# ---------------------------------------------------------------------------
def test_git_source_removal_removes_checkout_and_index(
page: Page, app_url: str, db_ready: None, sources_dir: Path
) -> None:
"""A seeded git row + a hand-made checkout dir (marker file, the
sync's clone target layout) + a seeded document: the modal removal
leaves the row gone, **the checkout dir gone from disk** (marker
included), and the document pruned from the KB."""
page.set_default_timeout(30_000)
checkout = sources_dir / GONE_REPO
shutil.rmtree(checkout, ignore_errors=True) # defensive: no leftover
(checkout / "notes").mkdir(parents=True)
(checkout / "notes" / "readme.md").write_text(
f"# Readme\n\nCheckout marker for {GONE_REPO}.\n", encoding="utf-8"
)
_seed_git_row(GONE_URL)
_seed_doc(
GONE_REPO,
"notes/readme.md",
str(checkout / "notes" / "readme.md"),
"Readme",
"Readme content for the phase69 checkout-removal test.",
)
_admin_git_sources_page(page, app_url)
expect(page.locator("#git-sources-tbody tr")).to_have_count(1)
assert _docs(page, app_url) == [(GONE_REPO, "notes/readme.md")]
_open_remove_modal(page, GONE_URL)
_confirm_removal(page)
# Row gone from the registry…
assert _stored_sources(page, app_url) == []
# …the checkout dir is GONE from disk (the marker with it)…
assert not checkout.exists(), f"{checkout} still on disk after removal"
# …and the document is pruned from the KB.
assert _docs(page, app_url) == []
# ---------------------------------------------------------------------------
# 3. Git row, no checkout dir (never synced) → clean no-op removal
# ---------------------------------------------------------------------------
def test_git_source_removal_without_checkout_succeeds(
page: Page, app_url: str, db_ready: None, sources_dir: Path
) -> None:
"""The absent-dir path: a seeded git row whose checkout dir does
not exist (the repo was never cloned/synced) still removes
cleanly — row gone, and NO error state anywhere on the page."""
page.set_default_timeout(30_000)
shutil.rmtree(sources_dir / "phase69-nodir", ignore_errors=True) # ensure absent
_seed_git_row(NO_CHECKOUT_URL)
_admin_git_sources_page(page, app_url)
expect(page.locator("#git-sources-tbody tr")).to_have_count(1)
_open_remove_modal(page, NO_CHECKOUT_URL)
_confirm_removal(page)
assert _stored_sources(page, app_url) == []
# No error state anywhere on the page (in-modal, load, or announcer
# — the announcer carries the success line, not an error).
expect(page.locator("#remove-confirm-error")).to_be_hidden()
expect(page.locator("#git-sources-load-error")).to_be_hidden()
expect(page.locator("#git-source-error")).to_be_hidden()
expect(page.locator("#git-sources-announcer")).to_have_text(REMOVAL_ANNOUNCE)
# ---------------------------------------------------------------------------
# 4. Foreign local directory → row + index go, the files NEVER do
# ---------------------------------------------------------------------------
def test_local_directory_source_files_never_deleted(
page: Page, app_url: str, db_ready: None, tmp_path: Path
) -> None:
"""A seeded kind=local row pointing at the owner's OWN directory
(outside the app-managed roots) + a seeded document: the modal
removal deletes the row and prunes the document, **but the dir and
its marker file are still present** (locked decision: foreign
local directories are never touched on disk)."""
page.set_default_timeout(30_000)
user_dir = tmp_path / "my-notes"
user_dir.mkdir()
marker = user_dir / "keep-me.md"
marker.write_text("The owner's own file — removal must not touch it.\n", encoding="utf-8")
_seed_local_row(str(user_dir))
_seed_doc(
user_dir.name,
"keep-me.md",
str(marker),
"Keep me",
"Document of the foreign local dir (the prune subject).",
)
_admin_git_sources_page(page, app_url)
expect(page.locator("#git-sources-tbody tr")).to_have_count(1)
assert _docs(page, app_url) == [(user_dir.name, "keep-me.md")]
_open_remove_modal(page, str(user_dir))
_confirm_removal(page)
# Row gone, document pruned…
assert _stored_sources(page, app_url) == []
assert _docs(page, app_url) == []
# …but the foreign directory + its file are STILL on disk.
assert user_dir.is_dir(), f"{user_dir} was deleted — foreign dirs are never touched"
assert marker.is_file(), f"{marker} was deleted — foreign files are never touched"
# ---------------------------------------------------------------------------
# 5. Cancel + Escape → zero DELETEs, everything stays
# ---------------------------------------------------------------------------
def test_remove_modal_cancel_and_esc_keep_everything(
page: Page, app_url: str, db_ready: None
) -> None:
"""(a) Remove → modal → Cancel: the dialog closes, focus returns
to the row's Remove button, **zero DELETE requests**, row + doc
remain. (b) Re-open → Escape: the same (the keyboard cancel path —
the request tracker stays empty through both)."""
page.set_default_timeout(30_000)
_seed_git_row(KEPT_URL)
_seed_doc(
KEPT_REPO,
"a.md",
f"/nonexistent-but-fine/{KEPT_REPO}/a.md",
"A",
"Document that must survive both cancels.",
)
deletes: list[str] = []
page.on(
"request",
lambda r: deletes.append(r.url)
if r.method == "DELETE" and "/api/git-sources/" in r.url
else None,
)
_admin_git_sources_page(page, app_url)
expect(page.locator("#git-sources-tbody tr")).to_have_count(1)
assert _docs(page, app_url) == [(KEPT_REPO, "a.md")]
# (a) Cancel click — no request, focus back on the trigger.
_open_remove_modal(page, KEPT_URL)
page.locator("#remove-confirm-cancel").click()
expect(page.locator("#remove-confirm-dialog")).to_be_hidden()
# Focus returned to the row's Remove button (the trigger)…
assert page.evaluate(
"() => document.activeElement.classList.contains('git-source-remove')"
), "focus did not return to the row's Remove button"
# Let the page settle (a cancel sends nothing) before the
# "zero DELETEs" claim.
page.wait_for_timeout(500)
assert deletes == [], f"cancel sent a request: {deletes}"
expect(page.locator("#git-sources-tbody tr")).to_have_count(1)
assert _docs(page, app_url) == [(KEPT_REPO, "a.md")]
# (b) Escape — the keyboard cancel path, same guarantees.
_open_remove_modal(page, KEPT_URL)
page.keyboard.press("Escape")
expect(page.locator("#remove-confirm-dialog")).to_be_hidden()
assert page.evaluate(
"() => document.activeElement.classList.contains('git-source-remove')"
), "focus did not return to the row's Remove button after Escape"
page.wait_for_timeout(500)
assert deletes == [], f"Escape sent a request: {deletes}"
expect(page.locator("#git-sources-tbody tr")).to_have_count(1)
expect(_row(page, KEPT_URL)).to_have_count(1)
assert _docs(page, app_url) == [(KEPT_REPO, "a.md")]
# ---------------------------------------------------------------------------
# 6. Modal a11y (AGENTS.md rule 5) + no CDN (rule 6) + the success
# announcer after a real removal
# ---------------------------------------------------------------------------
def test_remove_modal_a11y_and_no_cdn(page: Page, app_url: str, db_ready: None) -> None:
"""The dialog's aria wiring (alertdialog + labelled + described),
the 3px ``:focus-visible`` outline computable on BOTH buttons
(keyboard focus), both buttons ≥44px tall, the success line on
``#git-sources-announcer`` (role=status) after a real removal, and
the page loading only same-origin resources (rule 6)."""
page.set_default_timeout(30_000)
_seed_git_row(A11Y_URL)
_seed_doc(
A11Y_REPO,
"a.md",
f"/nonexistent-but-fine/{A11Y_REPO}/a.md",
"A",
"Document removed by the a11y test's successful removal.",
)
_admin_git_sources_page(page, app_url)
expect(page.locator("#git-sources-tbody tr")).to_have_count(1)
# The static dialog wiring (the markup ships with the page).
dialog = page.locator("#remove-confirm-dialog")
assert dialog.get_attribute("role") == "alertdialog"
assert dialog.get_attribute("aria-modal") == "true"
assert dialog.get_attribute("aria-labelledby") == "remove-confirm-title"
assert dialog.get_attribute("aria-describedby") == "remove-confirm-copy"
expect(page.locator("#remove-confirm-title")).to_have_text("Remove this source?")
assert page.locator("#remove-confirm-error").get_attribute("role") == "alert"
expect(page.locator("#remove-confirm-error")).to_be_hidden()
expect(page.locator("#remove-confirm-copy")).to_contain_text("permanently removes")
# Open: focus lands on Cancel (the safe default)…
_row(page, A11Y_URL).locator(".git-source-remove").click()
expect(dialog).to_be_visible(timeout=15_000)
assert page.evaluate("() => document.activeElement.id") == "remove-confirm-cancel"
# …and KEYBOARD focus moves between the two buttons, each drawing
# the house 3px :focus-visible outline (Tab Cancel→Remove,
# Shift+Tab back).
page.keyboard.press("Tab")
assert page.evaluate("() => document.activeElement.id") == "remove-confirm-remove"
outline = page.evaluate(
"() => getComputedStyle(document.activeElement).outlineWidth"
)
assert outline == "3px", f"focus-visible outline missing on Remove: {outline!r}"
page.keyboard.press("Shift+Tab")
assert page.evaluate("() => document.activeElement.id") == "remove-confirm-cancel"
outline = page.evaluate(
"() => getComputedStyle(document.activeElement).outlineWidth"
)
assert outline == "3px", f"focus-visible outline missing on Cancel: {outline!r}"
# Touch targets ≥44px (both buttons).
for btn in ("#remove-confirm-cancel", "#remove-confirm-remove"):
box = page.locator(btn).bounding_box()
assert box is not None and box["height"] >= 44, f"target too small: {box}"
# A real removal: the success line lands in the announcer
# (role=status, aria-live=polite).
page.locator("#remove-confirm-remove").click()
expect(page.locator("#git-sources-tbody tr")).to_have_count(0, timeout=30_000)
expect(dialog).to_be_hidden()
announcer = page.locator("#git-sources-announcer")
assert announcer.get_attribute("role") == "status"
assert announcer.get_attribute("aria-live") == "polite"
expect(announcer).to_have_text(REMOVAL_ANNOUNCE)
assert _stored_sources(page, app_url) == []
assert _docs(page, app_url) == []
# No CDN (rule 6): no https:// asset tags; every script/link ref is
# same-origin or a data: URI (the test_git_sources_admin.py pin).
html = page.content()
assert 'src="https://' not in html and 'href="https://' not in html
refs = page.evaluate(
"""() => [...document.querySelectorAll("script[src], link[href]")]
.map((el) => el.src || el.href)"""
)
assert refs, "expected local asset references"
for ref in refs:
assert ref.startswith(app_url) or ref.startswith("data:"), (
f"non-local asset reference: {ref}"
)
+28 -6
View File
@@ -60,10 +60,19 @@ def clean_git_sources(db: Session) -> Iterator[None]:
db.commit()
def _settings(git_sources: str = "") -> Settings:
"""Fresh settings with the ``.env`` file ignored; the explicit kwarg
beats any process env leaks (test_sync_api pattern)."""
return Settings(_env_file=None, git_sources=git_sources) # pyright: ignore[reportCallIssue]
def _settings(git_sources: str = "", **overrides: object) -> Settings:
"""Fresh settings with the ``.env`` file ignored; the explicit
kwargs beat any process env leaks (test_sync_api pattern).
``**overrides`` carries the per-test dir pins (``sources_dir`` /
``upload_dir`` — the phase-69 total-removal DELETE must never aim a
rmtree at the operator's real ``~/bor-sources``).
"""
return Settings(
_env_file=None, # pyright: ignore[reportCallIssue]
git_sources=git_sources,
**overrides, # pyright: ignore[reportCallIssue]
)
# --- anonymous -------------------------------------------------------------
@@ -493,12 +502,23 @@ def test_db_rows_win_over_env(admin_client: TestClient, db: Session, monkeypatch
def test_delete_removes_row_and_falls_back_to_env(
admin_client: TestClient, monkeypatch: pytest.MonkeyPatch
admin_client: TestClient, db: Session, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
# Phase 69: the DELETE is a total removal — the (absent) checkout
# dir it would rmtree is pinned at fresh tmp dirs, so the test never
# depends on the operator's real ``~/bor-sources`` being clean; the
# document prune it performs owns the KB tables the same way the
# other suites do (no row's source name may shadow real docs).
db.execute(text("TRUNCATE chunks, documents"))
db.commit()
monkeypatch.setattr(
git_sources_api,
"get_settings",
lambda: _settings("https://env.example.com/env.git"),
lambda: _settings(
"https://env.example.com/env.git",
sources_dir=str(tmp_path / "sources"),
upload_dir=str(tmp_path / "uploads"),
),
)
created = admin_client.post("/api/git-sources", json={"url": "https://new.example.com/x.git"})
assert created.status_code == 201
@@ -518,6 +538,8 @@ def test_delete_removes_row_and_falls_back_to_env(
"added_at": None,
}
]
db.execute(text("TRUNCATE chunks, documents"))
db.commit()
def test_delete_unknown_id_returns_404(admin_client: TestClient) -> None:
@@ -0,0 +1,528 @@
"""Integration: total source removal (phase 69, task 01).
Real Postgres (``podman compose up -d db``); the settings the router
reads are monkeypatched at fresh ``tmp_path`` dirs per test (the
``test_git_sources_upload.py`` ``_point_at`` pattern — the dev ``.env``
never leaks in, and no rmtree can ever aim at a real checkout or
upload), and the LLM is never hit: the endpoint's
``regenerate_overview`` becomes a counting spy (faked/spied per the
house pattern — the best-effort branch gets a spy that raises
``LLMError``).
Contract under test — ``DELETE /api/git-sources/{id}`` is a total
removal (owner request 2026-09-02), in the locked order:
* row + the source's documents (chunks + embeddings via the
``all, delete-orphan`` cascade) commit **first**; the app-managed
on-disk dir (git checkout / unpacked upload folder) is deleted
**after** the commit — absent dir is a no-op, foreign local dirs
(the owner's own) are never touched on disk;
* sibling guard — another stored row resolving to the same source name
(``https://e.com/r`` vs ``https://e.com/r.git``) keeps the shared
documents + files: only the row goes;
* ``sources_version`` bumps exactly once when documents were pruned
(the phase-53 saved-chat invalidation, same gate as sync) — no docs,
no bump;
* the overview refresh runs when pruned > 0 and is best-effort: a
failing spy still lands the 204 (and the bump — overview first, bump
second, mirroring ``_run_sync``);
* the 204 no-body contract and the 404/422 pins stay unchanged (the
404/422 pins live in ``test_git_sources_api.py``);
* one per-operation INFO line (PLAN §9) —
``source removed: kind=… name=… docs_pruned=… files_removed=yes|no|
skipped overview=… total_ms=…`` (``skipped`` for the sibling guard
and for foreign local dirs).
``git_sources`` / ``documents`` / ``chunks`` / ``kb_overview`` are
global state: reset around every test; the single-row ``sources_meta``
counter resets to 0 (the ``test_sync_api.py`` pattern).
"""
from __future__ import annotations
import logging
import re
import uuid
from collections.abc import Iterator
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.api import git_sources as git_sources_api
from app.config import Settings
from app.models import Chunk, Document, GitSource
from app.rag.llm import LLMError
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture(autouse=True)
def clean_state(db: Session) -> Iterator[None]:
"""The registry + KB tables are global state: reset around every
test; the seeded ``sources_meta`` row resets to version 0."""
db.execute(text("TRUNCATE chunks, documents, kb_overview, git_sources"))
db.execute(text("UPDATE sources_meta SET version = 0 WHERE id = 1"))
db.commit()
yield
db.execute(text("TRUNCATE chunks, documents, kb_overview, git_sources"))
db.execute(text("UPDATE sources_meta SET version = 0 WHERE id = 1"))
db.commit()
class _OverviewSpy:
"""The endpoint's ``regenerate_overview`` seam: counts calls and
(optionally) raises — the LLM outage the best-effort branch must
swallow."""
def __init__(self, fail: BaseException | None = None) -> None:
self.calls = 0
self.fail = fail
async def __call__(self, llm: object, session: object = None) -> bool: # noqa: ARG002
self.calls += 1
if self.fail is not None:
raise self.fail
return True
@pytest.fixture(autouse=True)
def overview_spy(monkeypatch: pytest.MonkeyPatch) -> _OverviewSpy:
"""The LLM is never hit: by default the spy succeeds (the log line
pins ``overview=True``); the best-effort test swaps in a spy that
raises ``LLMError``."""
spy = _OverviewSpy()
monkeypatch.setattr(git_sources_api, "regenerate_overview", spy)
return spy
def _point_at(monkeypatch: pytest.MonkeyPatch, sources_dir: Path, upload_dir: Path) -> None:
"""Fresh settings on the router's module: the tmp sources dir and
upload dir — the dev ``.env`` never leaks in, and a rmtree can
never aim at a real checkout/upload."""
monkeypatch.setattr(
git_sources_api,
"get_settings",
lambda: Settings(
_env_file=None, # pyright: ignore[reportCallIssue]
sources_dir=str(sources_dir),
upload_dir=str(upload_dir),
),
)
def _seed_doc(db: Session, source: str, path: str = "alpha.md") -> None:
"""One indexed document (+ two chunks) under ``source`` — the prune
+ cascade target. Seed directly (the task allows it): the import
pipeline is out of scope here."""
doc = Document(
source=source,
path=path,
full_path=f"/srv/brain/{source}/{path}",
title="Alpha",
content="# Alpha\nsentinel content\n",
content_hash=uuid.uuid4().hex,
)
db.add(doc)
db.flush()
db.add_all(
[
Chunk(document_id=doc.id, position=0, content="chunk zero"),
Chunk(document_id=doc.id, position=1, content="chunk one"),
]
)
db.commit()
def _seed_git_row(db: Session, url: str) -> GitSource:
row = GitSource(url=url, kind="git")
db.add(row)
db.commit()
return row
def _seed_local_row(db: Session, path: Path) -> GitSource:
"""A ``kind='local'`` row exactly as the create endpoint stores it
(phase 38: the expanded path mirrored in the NOT-NULL ``url``)."""
row = GitSource(url=str(path), kind="local", path=str(path))
db.add(row)
db.commit()
return row
def _version(db: Session) -> int:
"""The raw counter — a text query dodges the session identity map
(the bump commits in the endpoint's own short-lived session)."""
row = db.execute(text("SELECT version FROM sources_meta WHERE id = 1")).first()
return row[0] if row is not None else 0
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 _rows(client: TestClient) -> list[dict[str, object]]:
"""The stored rows (the settings carry no env URLs, so the
empty-table fallback also reports ``[]`` — the assertions only ever
rely on the DB rows' ids/urls)."""
return client.get("/api/git-sources").json()["sources"]
def _removal_lines(caplog: pytest.LogCaptureFixture) -> list[str]:
return [
rec.getMessage()
for rec in caplog.records
if rec.getMessage().startswith("source removed: ")
]
# ---------------------------------------------------------------------------
# git row: row + docs + checkout dir, one 204
# ---------------------------------------------------------------------------
def test_git_removal_deletes_row_docs_and_checkout_dir(
admin_client: TestClient,
db: Session,
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
caplog: pytest.LogCaptureFixture,
overview_spy: _OverviewSpy,
) -> None:
sources = tmp_path / "sources"
_point_at(monkeypatch, sources, tmp_path / "uploads")
url = "https://gitlab.example.com/reese/homelab.git"
row = _seed_git_row(db, url)
checkout = sources / "homelab"
checkout.mkdir(parents=True)
marker = checkout / "marker.md"
marker.write_text("sentinel\n", encoding="utf-8")
_seed_doc(db, "homelab")
_seed_doc(db, "homelab", path="bravo.md")
version_before = _version(db)
with caplog.at_level(logging.INFO, logger="app.api.git_sources"):
r = admin_client.delete(f"/api/git-sources/{row.id}")
assert r.status_code == 204
assert r.content == b"" # the 204 no-body contract, unchanged
# The row is gone (and the table is empty → the env fallback shape,
# no env URLs set here).
body = admin_client.get("/api/git-sources").json()
assert body["from_env"] is True
assert body["sources"] == []
# The app-managed checkout dir is gone from disk — marker included.
assert not checkout.exists()
assert not marker.exists()
# The source's documents are gone from the index (admin /api/docs)…
assert _docs(admin_client) == []
# …and every chunk row too (the ``all, delete-orphan`` cascade).
assert db.execute(text("SELECT count(*) FROM chunks")).scalar_one() == 0
assert db.execute(text("SELECT count(*) FROM documents")).scalar_one() == 0
# The KB changed → exactly one version bump (phase 53) and exactly
# one (spied) overview call.
assert _version(db) == version_before + 1
assert overview_spy.calls == 1
# The per-operation INFO line (PLAN §9) with the counts.
lines = _removal_lines(caplog)
assert len(lines) == 1, lines
match = re.match(
r"^source removed: kind=git name=homelab docs_pruned=2 files_removed=yes "
r"overview=True total_ms=\d+$",
lines[0],
)
assert match, lines[0]
def test_git_removal_without_checkout_dir_is_a_noop_on_disk(
admin_client: TestClient,
db: Session,
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
caplog: pytest.LogCaptureFixture,
overview_spy: _OverviewSpy,
) -> None:
"""No checkout yet (never cloned) → the disk step is a silent no-op;
the row + index removal still lands, 204."""
sources = tmp_path / "sources"
_point_at(monkeypatch, sources, tmp_path / "uploads")
row = _seed_git_row(db, "https://gitlab.example.com/reese/ops.git")
_seed_doc(db, "ops")
version_before = _version(db)
with caplog.at_level(logging.INFO, logger="app.api.git_sources"):
assert admin_client.delete(f"/api/git-sources/{row.id}").status_code == 204
assert not (sources / "ops").exists()
assert _docs(admin_client) == []
assert db.execute(text("SELECT count(*) FROM git_sources")).scalar_one() == 0
# Docs were pruned → the bump + the overview still run.
assert _version(db) == version_before + 1
assert overview_spy.calls == 1
line = _removal_lines(caplog)[0]
assert "files_removed=no" in line # the absent dir is the "no" case
# ---------------------------------------------------------------------------
# local rows: upload folder removed, foreign dir never touched
# ---------------------------------------------------------------------------
def test_local_upload_removal_deletes_row_docs_and_folder(
admin_client: TestClient,
db: Session,
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
caplog: pytest.LogCaptureFixture,
) -> None:
upload = tmp_path / "uploads"
_point_at(monkeypatch, tmp_path / "sources", upload)
folder = upload / "my-notes"
folder.mkdir(parents=True)
marker = folder / "readme.md"
marker.write_text("sentinel\n", encoding="utf-8")
row = _seed_local_row(db, folder)
_seed_doc(db, "my-notes")
version_before = _version(db)
with caplog.at_level(logging.INFO, logger="app.api.git_sources"):
assert admin_client.delete(f"/api/git-sources/{row.id}").status_code == 204
# Row + index entries gone…
assert _rows(admin_client) == []
assert _docs(admin_client) == []
# …and the app-managed upload folder is gone from disk.
assert not folder.exists()
assert not marker.exists()
assert _version(db) == version_before + 1
line = _removal_lines(caplog)[0]
assert re.match(
r"^source removed: kind=local name=my-notes docs_pruned=1 files_removed=yes "
r"overview=True total_ms=\d+$",
line,
), line
def test_local_foreign_dir_removal_never_touches_disk(
admin_client: TestClient,
db: Session,
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
caplog: pytest.LogCaptureFixture,
) -> None:
"""A ``kind='local'`` row pointing at the owner's own directory:
row + index entries go, the directory NEVER does (the locked
decision) — and the KB change still bumps the version."""
upload = tmp_path / "uploads"
_point_at(monkeypatch, tmp_path / "sources", upload)
foreign = tmp_path / "own" / "docs"
foreign.mkdir(parents=True)
marker = foreign / "precious.md"
marker.write_text("keep me\n", encoding="utf-8")
row = _seed_local_row(db, foreign)
_seed_doc(db, "docs") # the local source label = the dir's name
version_before = _version(db)
with caplog.at_level(logging.INFO, logger="app.api.git_sources"):
assert admin_client.delete(f"/api/git-sources/{row.id}").status_code == 204
assert _rows(admin_client) == []
assert _docs(admin_client) == []
# The foreign directory — and its files — are exactly as they were.
assert foreign.is_dir()
assert marker.read_text(encoding="utf-8") == "keep me\n"
# Index change → the bump still lands; the disk step was skipped.
assert _version(db) == version_before + 1
line = _removal_lines(caplog)[0]
assert "files_removed=skipped" in line, line
def test_local_prefix_sibling_dir_is_not_contained(
admin_client: TestClient,
db: Session,
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
caplog: pytest.LogCaptureFixture,
) -> None:
"""The containment edge end-to-end: ``upload_dir = tmp/'u'`` and a
local row at ``tmp/'u-evil'`` — the prefix-sharing sibling is NOT
under the upload dir, so its files are never deleted (the unit test
pins ``managed_dir_for``; this pins the endpoint honoring it)."""
upload = tmp_path / "u"
upload.mkdir()
_point_at(monkeypatch, tmp_path / "sources", upload)
evil = tmp_path / "u-evil"
evil.mkdir()
marker = evil / "marker.md"
marker.write_text("not an upload\n", encoding="utf-8")
row = _seed_local_row(db, evil)
_seed_doc(db, "u-evil")
with caplog.at_level(logging.INFO, logger="app.api.git_sources"):
assert admin_client.delete(f"/api/git-sources/{row.id}").status_code == 204
assert _rows(admin_client) == []
assert _docs(admin_client) == []
assert evil.is_dir()
assert marker.read_text(encoding="utf-8") == "not an upload\n"
assert "files_removed=skipped" in _removal_lines(caplog)[0]
# ---------------------------------------------------------------------------
# sibling guard
# ---------------------------------------------------------------------------
def test_sibling_guard_keeps_shared_docs_and_files_until_last_row(
admin_client: TestClient,
db: Session,
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
caplog: pytest.LogCaptureFixture,
overview_spy: _OverviewSpy,
) -> None:
"""``https://example.com/reese/r`` and ``https://example.com/reese/
r.git`` both index under ``r`` — the first removal deletes ONLY the
row (docs + shared checkout stay, loudly logged, no bump/overview);
the second is the total removal."""
sources = tmp_path / "sources"
_point_at(monkeypatch, sources, tmp_path / "uploads")
row_a = _seed_git_row(db, "https://example.com/reese/r")
row_b = _seed_git_row(db, "https://example.com/reese/r.git")
checkout = sources / "r"
checkout.mkdir(parents=True)
marker = checkout / "shared.md"
marker.write_text("belongs to both rows\n", encoding="utf-8")
_seed_doc(db, "r")
version_before = _version(db)
# --- first row: sibling guard — only the row goes -----------------
# INFO level so both the loudly-logged warning and the per-operation
# INFO line of this removal are captured.
with caplog.at_level(logging.INFO, logger="app.api.git_sources"):
assert admin_client.delete(f"/api/git-sources/{row_a.id}").status_code == 204
rows = _rows(admin_client)
assert [s["id"] for s in rows] == [str(row_b.id)]
assert rows[0]["url"] == "https://example.com/reese/r.git"
assert rows[0]["kind"] == "git" and rows[0]["path"] is None
# The shared documents + files still belong to the sibling…
assert _docs(admin_client) == [("r", "alpha.md")]
assert checkout.is_dir()
assert marker.read_text(encoding="utf-8") == "belongs to both rows\n"
# …and nothing KB-side happened: no prune, no bump, no overview.
assert _version(db) == version_before
assert overview_spy.calls == 0
line = _removal_lines(caplog)[0]
assert re.match(
r"^source removed: kind=git name=r docs_pruned=0 files_removed=skipped "
r"overview=False total_ms=\d+$",
line,
), line
# …and the guard is logged loudly (naming the row + the shared name).
warnings = [
rec.getMessage() for rec in caplog.records if rec.levelno == logging.WARNING
]
assert any(
"shares source name r" in w and str(row_a.id) in w for w in warnings
), warnings
# --- second row: the total removal, at last ------------------------
with caplog.at_level(logging.INFO, logger="app.api.git_sources"):
assert admin_client.delete(f"/api/git-sources/{row_b.id}").status_code == 204
assert _rows(admin_client) == []
assert _docs(admin_client) == []
assert not checkout.exists()
assert _version(db) == version_before + 1
assert overview_spy.calls == 1
assert "files_removed=yes" in _removal_lines(caplog)[1]
# ---------------------------------------------------------------------------
# version bump + best-effort overview gates
# ---------------------------------------------------------------------------
def test_overview_failure_is_best_effort_still_204(
admin_client: TestClient,
db: Session,
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
caplog: pytest.LogCaptureFixture,
overview_spy: _OverviewSpy,
) -> None:
"""A failing overview (LLM outage) never fails the delete: the 204
lands, the docs stay pruned, and the bump still lands — overview
first, bump second (mirroring ``_run_sync``)."""
sources = tmp_path / "sources"
_point_at(monkeypatch, sources, tmp_path / "uploads")
row = _seed_git_row(db, "https://gitlab.example.com/reese/notes.git")
checkout = sources / "notes"
checkout.mkdir(parents=True)
(checkout / "n.md").write_text("x\n", encoding="utf-8")
_seed_doc(db, "notes")
version_before = _version(db)
failing = _OverviewSpy(fail=LLMError("simulated lite-model outage"))
monkeypatch.setattr(git_sources_api, "regenerate_overview", failing)
# INFO level — the per-operation line (INFO) and the best-effort
# failure (ERROR) are both captured.
with caplog.at_level(logging.INFO, logger="app.api.git_sources"):
assert admin_client.delete(f"/api/git-sources/{row.id}").status_code == 204
assert failing.calls == 1
assert _docs(admin_client) == []
assert not checkout.exists()
# The bump lands even though the best-effort overview failed…
assert _version(db) == version_before + 1
# …and the failure is logged, never fatal.
line = _removal_lines(caplog)[0]
assert "overview=False" in line, line
errors = [
rec.getMessage() for rec in caplog.records if rec.levelno >= logging.ERROR
]
assert any("overview regeneration failed" in e for e in errors), errors
def test_no_docs_pruned_means_no_overview_and_no_bump(
admin_client: TestClient,
db: Session,
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
caplog: pytest.LogCaptureFixture,
overview_spy: _OverviewSpy,
) -> None:
"""A row with no indexed documents: the checkout dir is still
removed (files_removed=yes) but the KB is unchanged — no overview
call, no version bump (the gate is docs pruned, not files)."""
sources = tmp_path / "sources"
_point_at(monkeypatch, sources, tmp_path / "uploads")
row = _seed_git_row(db, "https://gitlab.example.com/reese/bare.git")
checkout = sources / "bare"
checkout.mkdir(parents=True)
(checkout / "b.md").write_text("never imported\n", encoding="utf-8")
version_before = _version(db)
with caplog.at_level(logging.INFO, logger="app.api.git_sources"):
assert admin_client.delete(f"/api/git-sources/{row.id}").status_code == 204
assert not checkout.exists()
assert _rows(admin_client) == []
assert _version(db) == version_before
assert overview_spy.calls == 0
line = _removal_lines(caplog)[0]
assert re.match(
r"^source removed: kind=git name=bare docs_pruned=0 files_removed=yes "
r"overview=False total_ms=\d+$",
line,
), line
+7 -9
View File
@@ -118,16 +118,16 @@ def test_agent_tools_names_and_parameters() -> None:
"Add the full content of one more indexed document to your context"
)
# Phase 63 (A2): the parameter descriptions point the LLM at the
# labeled `source:` / `path:` fields of the list_documents output.
# labeled `source:` / `path:` fields of the list_documents output
# (the example was dropped by the phase-68 description fix — the
# wording stays pinned, the model saw invented paths in calls).
assert read_params["properties"]["source"]["description"] == (
"The document's source, as shown after 'source: ' in the "
"list_documents output (e.g. 'Homelab' from "
"'source: Homelab | path: homelab/aws-route53.md')."
"list_documents output."
)
assert read_params["properties"]["path"]["description"] == (
"The document's path, as shown after 'path: ' in the "
"list_documents output (e.g. 'homelab/aws-route53.md' from "
"'source: Homelab | path: homelab/aws-route53.md')."
"list_documents output."
)
# Phase 68: search_documents — the third tool, a locator (locked A5).
search = by_name["search_documents"]["function"]
@@ -149,13 +149,11 @@ def test_agent_tools_names_and_parameters() -> None:
# Phase 63 labeled-field wording, same as read_document's parameters.
assert search_params["properties"]["source"]["description"] == (
"The document's source, as shown after 'source: ' in the "
"list_documents output (e.g. 'Homelab' from "
"'source: Homelab | path: homelab/aws-route53.md')."
"list_documents output."
)
assert search_params["properties"]["path"]["description"] == (
"The document's path, as shown after 'path: ' in the "
"list_documents output (e.g. 'homelab/aws-route53.md' from "
"'source: Homelab | path: homelab/aws-route53.md')."
"list_documents output."
)
+472
View File
@@ -0,0 +1,472 @@
"""Unit: the remove confirmation modal on /git-sources.html (phase 69,
task 02).
Removal is a TOTAL removal (owner request 2026-09-02): the row, the
source's indexed documents (chunks + embeddings), and — for git clones
and uploaded archives — the files on the server's disk, all immediately
(task 01's ``DELETE /api/git-sources/{id}`` rewire). The page must
confirm that through a real in-app ``role="alertdialog"`` modal —
``window.confirm`` is retired — that names the source and states the
policy BEFORE the request goes out.
The browser behavior itself is E2E-gated by the phase-69 story suite
(``tests/e2e/test_source_removal_cleanup.py`` — task 03: focus,
Escape, ≥44px targets, the a11y interaction layer); like the other
frontend-adjacent unit files (``test_stale_ui_copy.py`` /
``test_summary_edit_ui.py`` house pattern), this module pins the
source-level contract a silent regression would break:
* ``window.confirm`` is GONE from the whole frontend (the only
native confirm the app ever shipped);
* the static ``#remove-confirm-dialog`` markup — ``role="alertdialog"``,
``aria-modal``, ``aria-labelledby``/``aria-describedby``, hidden by
default, the six child ids, the ``role="alert"`` error line, real
``type="button"`` buttons, the locked modal copy (verbatim);
* the JS lifecycle — ``openRemoveConfirm`` (textContent-only source
population with makeRow's ``value`` expression, error cleared, focus
on Cancel, the trigger recorded), cancel = Escape / Cancel button /
backdrop (no request; focus returns to the trigger; a no-op while a
DELETE is in flight), ``confirmRemove`` (§7.4 in-flight state: both
buttons disable + "Removing…", success → close/reload/announce —
the removal confirmation is the LAST announcement, non-2xx → the
in-modal alert line + dialog stays open, network →
the fixed reachable? line, re-enable in the finally);
* the stale "prunes on the next sync" removal copy is GONE from
``git-sources.js`` + ``git-sources.html``; the new hint copy is
PRESENT (the README pins are task 03's);
* styles.css — the modal classes on the house dark-tech palette
(phase-08 tokens only, no CDN, no blur), ≥44px buttons, the
``[hidden]`` override, the err-token destructive pair.
"""
from __future__ import annotations
import re
from pathlib import Path
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
HTML = FRONTEND / "git-sources.html"
JS = FRONTEND / "assets" / "git-sources.js"
CSS = FRONTEND / "assets" / "styles.css"
#: The locked modal copy (phase 69, 00_phase.md) — one fixed paragraph
#: for both kinds (the UI cannot tell an upload from the owner's own
#: directory; naming the source above it makes the target
#: unambiguous). Pinned verbatim against the normalized text.
MODAL_COPY = (
"This permanently removes the source entry, all of its indexed "
"documents from the knowledge base, and — for git clones and "
"uploaded archives — the files on the server's disk. Files in "
"your own local directories are never touched. This cannot be undone."
)
#: The new hint-box contract (phase 69): removal is immediate and
#: total; the modal spells it out; the Sync button mirrors the
#: remaining sources (upstream churn is still pruned on that run).
HINT_TOTAL_REMOVAL = "Removing a source is a total removal, done immediately"
HINT_MODAL_SPELLS_OUT = (
"the confirmation modal spells out exactly what will be deleted"
)
HINT_FOREVER_SAFE = "files in your own local directories are never touched"
#: The success announce (the existing #git-sources-announcer live
#: region).
ANNOUNCE_OK = "Source removed — its files and index entries were cleaned up."
#: The retired confirm copy (task 02 stale-copy pins).
OLD_STAY_INDEXED = "stays indexed until the next sync"
# "prunes/pruned … on the next sync" in any shape (the old hint +
# confirm message).
OLD_NEXT_SYNC = re.compile(r"prun\w+[^.]{0,120}?next sync")
def _text(path: Path) -> str:
return path.read_text(encoding="utf-8")
def _norm(text: str) -> str:
"""Collapse whitespace runs — the markup wraps long lines, so the
locked copy is pinned against the normalized text."""
return re.sub(r"\s+", " ", text).strip()
def _js() -> str:
return _text(JS)
def _css() -> str:
return _text(CSS)
def _fn(js: str, name: str) -> str:
"""The source of a (possibly async) top-level function via
balanced-brace counting (the test_summary_edit_ui.py helper)."""
for prefix in ("async function ", "function "):
start = js.find(f"{prefix}{name}(")
if start != -1:
depth = 0
for i in range(js.find("{", start), len(js)):
if js[i] == "{":
depth += 1
elif js[i] == "}":
depth -= 1
if depth == 0:
return js[start : i + 1]
raise AssertionError(f"unbalanced braces in {name}()")
raise AssertionError(f"{name}() must exist in git-sources.js")
def _element_block(html: str, id_attr: str, tag: str = "div") -> str:
"""The <tag … id=…> element's full markup (a balanced-tag walk —
the dialog nests the backdrop / panel / actions divs; the hint is
a <p>)."""
marker = f'id="{id_attr}"'
i = html.find(marker)
assert i != -1, f"missing id={id_attr} in git-sources.html"
opens = [m.start() for m in re.finditer(rf"<{tag}\b", html[:i])]
assert opens, f"no <{tag}> owns id={id_attr}"
open_i = opens[-1]
depth = 0
for m in re.finditer(rf"<{tag}\b[^>]*>|</{tag}>", html[open_i :]):
if m.group(0).startswith(f"</{tag}>"):
depth -= 1
else:
depth += 1
if depth == 0:
return html[open_i : open_i + m.end()]
raise AssertionError(f"unbalanced <{tag}> for id={id_attr}")
# ---------- window.confirm is gone (the whole frontend) ----------
def test_window_confirm_is_gone_from_the_whole_frontend() -> None:
"""``window.confirm`` — the only native confirm the app ever
shipped — is absent from EVERY frontend file (markup, scripts,
styles — comments included: the retirement note must not keep the
literal)."""
for path in sorted(FRONTEND.rglob("*")):
if path.is_file():
assert "window.confirm" not in path.read_text(encoding="utf-8"), (
f"window.confirm survived in {path.relative_to(FRONTEND)}"
)
# ---------- the static dialog markup ----------
def test_dialog_markup_is_the_locked_alertdialog() -> None:
"""#remove-confirm-dialog: role="alertdialog" + aria-modal + the
labelled/describedby pair, hidden by default, inside the manager
(#git-sources-content — the static-markup convention, stable E2E
selectors); all six child ids present; the error line is
role="alert"; both buttons are real type="button"; the title is
the locked h2; the modal copy is the locked paragraph verbatim."""
html = _text(HTML)
frag = _element_block(html, "remove-confirm-dialog")
open_tag = frag[: frag.find(">") + 1]
assert 'role="alertdialog"' in open_tag
assert 'aria-modal="true"' in open_tag
assert 'aria-labelledby="remove-confirm-title"' in open_tag
assert 'aria-describedby="remove-confirm-copy"' in open_tag
assert "hidden" in open_tag, "the dialog ships hidden"
# Static markup INSIDE the manager (the #git-sources-hint / gate
# convention) — E2E can wait for the content to be revealed.
assert html.find('id="git-sources-content"') < html.find("remove-confirm-dialog")
for child in (
"remove-confirm-title",
"remove-confirm-source",
"remove-confirm-copy",
"remove-confirm-error",
"remove-confirm-cancel",
"remove-confirm-remove",
):
assert f'id="{child}"' in frag, f"missing #{child} in the dialog"
title = re.search(r"<h2[^>]*id=\"remove-confirm-title\"[^>]*>(.*?)</h2>", frag, re.S)
assert title and _norm(title.group(1)) == "Remove this source?"
err = re.search(r"<p[^>]*id=\"remove-confirm-error\"[^>]*>", frag)
assert err and 'role="alert"' in err.group(0), "the in-modal error is role=alert"
for btn in ("remove-confirm-cancel", "remove-confirm-remove"):
m = re.search(rf"<button[^>]*id=\"{btn}\"[^>]*>", frag)
assert m and 'type="button"' in m.group(0), f"#{btn} is a real type=button"
cancel = re.search(r'<button[^>]*id="remove-confirm-cancel"[^>]*>(.*?)</button>', frag, re.S)
remove = re.search(r'<button[^>]*id="remove-confirm-remove"[^>]*>(.*?)</button>', frag, re.S)
assert cancel and _norm(cancel.group(1)) == "Cancel"
assert remove and _norm(remove.group(1)) == "Remove source"
assert _norm(MODAL_COPY) in _norm(frag), "the locked modal copy, verbatim"
# The source value is a <code> (mono) — textContent-only in JS.
src = re.search(r"<code[^>]*id=\"remove-confirm-source\"[^>]*>", frag)
assert src, "#remove-confirm-source is a <code>"
# ---------- the JS lifecycle ----------
def test_open_populates_source_via_text_content_and_focuses_cancel() -> None:
"""openRemoveConfirm(s, triggerBtn): the source value is
textContent ONLY (never innerHTML — the credential-masking
discipline, phase 32) with makeRow's exact ``value`` expression
(``s.path ?? s.url`` for local rows, ``s.url`` for git); the error
line clears; the dialog unhides; the trigger is recorded; the
keydown handler attaches; and focus lands on Cancel — the safe
default for a destructive action (AFTER the unhide)."""
body = _fn(_js(), "openRemoveConfirm")
assert "s.kind === \"local\"" in body, "the kind-typed value branch"
assert (
"removeSourceEl.textContent = isLocal ? s.path ?? s.url : s.url" in body
), "the same `value` expression makeRow uses, via textContent"
assert "removeSourceEl.innerHTML" not in _js(), "XSS contract: textContent only"
assert "removeError.hidden = true" in body, "a new attempt starts clean"
assert "removeTriggerBtn = triggerBtn" in body, "the trigger is recorded"
trigger_i = body.find("removeTriggerBtn = triggerBtn")
unhide_i = body.find("removeDialog.hidden = false")
attach_i = body.find('document.addEventListener("keydown", onRemoveDialogKeydown)')
focus_i = body.find("removeCancelBtn.focus()")
assert -1 < trigger_i < unhide_i < attach_i < focus_i, (
"record the trigger → unhide → attach keydown → focus Cancel"
)
def test_row_button_opens_the_modal_and_the_row_error_span_is_retired() -> None:
"""makeRow's per-row Remove button opens the modal
(openRemoveConfirm(s, btn)) — no confirm call, no per-row error
span (the modal carries the in-flight error line); the retired
.git-source-row-error class is gone from the JS AND the CSS."""
js = _js()
make = _fn(js, "makeRow")
assert "openRemoveConfirm(s, btn)" in make, "the row's Remove opens the modal"
assert "removeSource(" not in js, "the window.confirm-era flow is gone"
assert "git-source-row-error" not in js
assert "git-source-row-error" not in _css(), "the dead per-row error CSS is retired"
def test_cancel_paths_close_without_a_request() -> None:
"""Cancel (Cancel button / Escape / backdrop) closes as cancel:
the dialog hides, the error line clears, the buttons reset
("Remove source"), the keydown handler detaches, and focus
RETURNS to the recorded trigger — and the cancel path never
fetches. A cancel while a DELETE is in flight is a no-op (no
half-cancel of an in-progress server-side removal)."""
js = _js()
cancel = _fn(js, "cancelRemoveConfirm")
guard_i = cancel.find("if (removeInFlight) return")
close_i = cancel.find("closeRemoveConfirm()")
assert -1 < guard_i < close_i, "the in-flight guard precedes the close"
assert "fetch" not in cancel, "cancel never sends a request"
close = _fn(js, "closeRemoveConfirm")
hide_i = close.find("removeDialog.hidden = true")
clear_i = close.find("removeError.hidden = true")
reset_i = close.find('removeRemoveBtn.textContent = "Remove source"')
detach_i = close.find('document.removeEventListener("keydown", onRemoveDialogKeydown)')
save_i = close.find("const trigger = removeTriggerBtn")
null_i = close.find("removeTriggerBtn = null")
focus_i = close.find("trigger.focus()")
assert -1 < hide_i < clear_i < reset_i < detach_i < save_i < null_i < focus_i, (
"hide → clear → reset → detach → save trigger → focus return"
)
assert "removeCancelBtn.disabled = false" in close
assert "removeRemoveBtn.disabled = false" in close
def test_escape_and_backdrop_and_cancel_button_all_cancel() -> None:
"""While open (handler attached on document in openRemoveConfirm,
detached in closeRemoveConfirm): Escape → preventDefault +
cancelRemoveConfirm; the dim backdrop AND the Cancel button wire
to cancelRemoveConfirm (only "Remove source" wires to
confirmRemove). Tab/Shift+Tab stay inside the two-button modal
(aria-modal honored for keyboard users)."""
js = _js()
keydown = _fn(js, "onRemoveDialogKeydown")
esc_i = keydown.find('e.key === "Escape"')
prevent_i = keydown.find("e.preventDefault()", esc_i)
cancel_i = keydown.find("cancelRemoveConfirm()", esc_i)
assert -1 < esc_i < prevent_i < cancel_i, "Escape: prevent + cancel"
assert 'e.key === "Tab"' in keydown, "the two-button focus cycle"
assert 'removeCancelBtn.addEventListener("click", cancelRemoveConfirm)' in js
assert 'removeBackdrop.addEventListener("click", cancelRemoveConfirm)' in js
assert 'removeRemoveBtn.addEventListener("click", confirmRemove)' in js
def test_confirm_runs_the_inflight_never_stale_lifecycle() -> None:
"""confirmRemove: the §7.4 in-flight state BEFORE the fetch —
both buttons disable + the confirm relabels "Removing…"; one
DELETE /api/git-sources/{id}. Success (204): close (focus return)
→ loadSources → announce (the removal confirmation is the LAST
announcement — the reload's "N sources listed." must not
overwrite it). Non-2xx: the in-modal role=alert line
(apiDetail, 422 shape-aware) and the dialog STAYS open (no
closeRemoveConfirm in the failure slice). Network failure: the
fixed reachable? line. The finally re-enables BOTH buttons +
relabels "Remove source" — never stale on any outcome."""
body = _fn(_js(), "confirmRemove")
guard_i = body.find("if (!removingId || removeInFlight) return")
inflight_i = body.find("removeInFlight = true")
clear_i = body.find("removeError.hidden = true")
dis_c = body.find("removeCancelBtn.disabled = true")
dis_r = body.find("removeRemoveBtn.disabled = true")
label_i = body.find('"Removing…"')
fetch_i = body.find("fetch(`/api/git-sources/${encodeURIComponent(removingId)}`")
method_i = body.find('method: "DELETE"', fetch_i)
assert -1 < guard_i < inflight_i < clear_i < dis_c < dis_r < label_i < fetch_i < method_i, (
"guard → in-flight → clear error → disable both + label → DELETE"
)
# Success: close → reload → announce (the exact order, the exact
# announce string) — the removal confirmation is the LAST
# announcement: the reload's "N sources listed." must not
# overwrite it (phase 69 task 03's E2E pins the success line on
# the announcer after a real removal).
ok_i = body.find("if (r.ok)")
close_i = body.find("closeRemoveConfirm()", ok_i)
reload_i = body.find("await loadSources()", close_i)
announce_i = body.find(f'announce("{ANNOUNCE_OK}")', reload_i)
assert -1 < ok_i < close_i < reload_i < announce_i
assert body.count("closeRemoveConfirm()") == 1, (
"only the success path closes — failures stay open for one retry"
)
# Non-2xx: the in-modal alert line, dialog stays open.
nonok_i = body.find("Could not remove the source — try again.")
catch_i = body.find("} catch {")
assert -1 < nonok_i < catch_i
assert "await apiDetail(r," in body[body.find("if (r.ok)"):catch_i], (
"the server detail is apiDetail-extracted (422 shape-aware)"
)
failure_slice = body[body.rfind("// non-2xx", 0, catch_i):catch_i]
assert "closeRemoveConfirm" not in failure_slice, "failure keeps the dialog open"
# Network: the fixed reachable? line.
net_i = body.find("Could not remove the source — is the app reachable?", catch_i)
assert -1 < net_i < body.find("finally"), "the network copy lands in the catch"
# Never stale: the finally re-enables BOTH buttons + relabels.
fin_i = body.find("finally")
fin = body[fin_i:]
assert "removeInFlight = false" in fin
assert "removeCancelBtn.disabled = false" in fin
assert "removeRemoveBtn.disabled = false" in fin
assert 'removeRemoveBtn.textContent = "Remove source"' in fin
# ---------- the stale copy is gone; the new hint is present ----------
def test_stale_next_sync_removal_copy_is_gone() -> None:
"""The phase-35 "prunes on the next sync" removal contract is
superseded: the retired confirm copy AND any 'prune(s/d) … next
sync' shape are absent from git-sources.js + git-sources.html
(code AND comments — the docstring copy moved with the flow).
The README pins are task 03's."""
for path in (JS, HTML):
raw = _text(path)
norm = _norm(raw)
assert OLD_STAY_INDEXED not in raw, (
f"retired confirm copy still in {path.name}"
)
m = OLD_NEXT_SYNC.search(norm)
assert m is None, f"'next sync' removal copy survived in {path.name}: {m.group(0)!r}"
def test_new_hint_copy_is_present_in_the_html() -> None:
"""The #git-sources-hint carries the new contract: removal is a
total removal, done immediately (the modal spells it out; foreign
local directories are never touched), and the Sync button still
mirrors the remaining sources (upstream churn is pruned on that
run — not 'on the next sync')."""
hint = _norm(_element_block(_text(HTML), "git-sources-hint", tag="p"))
for frag in (HINT_TOTAL_REMOVAL, HINT_MODAL_SPELLS_OUT, HINT_FOREVER_SAFE):
assert frag in hint, f"the new hint copy is missing: {frag!r}"
assert "pruned on that run" in hint, "the Sync-mirror clause (upstream churn)"
assert "next sync" not in hint, "no 'next sync' removal claim in the hint"
def test_module_docstring_carries_the_new_contract() -> None:
"""The git-sources.js module docstring's remove bullet + scope
boundary moved with the flow: the modal names the source + states
the policy, and removal performs the FULL cleanup server-side
(row + index + app-managed files) — with the navigating-away
note (the row + index commit first; an interrupted file step
leaves an inert orphan dir)."""
doc = _js().split("*/", 2)[0] # the module docstring (first block)
for frag in (
'role="alertdialog"',
"textContent ONLY",
'relabels "Removing…"',
"not recommended",
"inert orphan",
"FULL cleanup server-side",
"foreign local directories are never touched",
):
assert frag in doc, f"the module docstring lost: {frag!r}"
# ---------- styles.css ----------
def test_modal_css_classes_present_and_house_tokens_only() -> None:
"""styles.css carries the modal class family on the house
dark-tech palette (phase-08 tokens): the overlay + backdrop +
panel, the title / source / copy / error / actions / two-button
chrome; the [hidden] override (the documented, testable
contract); the global 3px :focus-visible outline is NOT
suppressed; no CDN; no blur (the phase-08 no-blur perf anchor)."""
css = _css()
for cls in (
".remove-confirm",
".remove-confirm-backdrop",
".remove-confirm-panel",
".remove-confirm-title",
".remove-confirm-source",
".remove-confirm-copy",
".remove-confirm-error",
".remove-confirm-actions",
".remove-confirm-btn",
".remove-confirm-cancel",
".remove-confirm-remove",
):
assert f"{cls} " in css or f"{cls}." in css or f"{cls}[" in css, (
f"styles.css must style {cls}"
)
hidden = css.find(".remove-confirm[hidden]")
assert hidden != -1 and "display: none" in css[hidden : hidden + 60], (
"the hidden attr must beat the display rule"
)
assert ":focus-visible {" in css and "outline: 3px solid var(--brand)" in css
# No blur (the phase-08 no-blur perf anchor) — the backdrop rule
# itself must not carry backdrop-filter.
backdrop = css[css.find(".remove-confirm-backdrop {") :]
backdrop = backdrop[: backdrop.find("\n}")]
# Comments stripped — the note "no backdrop-filter (no-blur)" must
# not trip the pin; only a real declaration may.
backdrop = re.sub(r"/\*.*?\*/", "", backdrop, flags=re.S)
assert "backdrop-filter" not in backdrop, "no blur (phase-08 perf anchor)"
assert "url(http" not in css and "@import url(" not in css, (
"no CDN (AGENTS.md rule 6)"
)
def test_modal_css_targets_and_contrast_pairs() -> None:
"""The WCAG 2.1 AA basics in CSS: both buttons >=44px; the
destructive button rides the err token family (err-ink on err-bg
9.3:1, the err-line border — the .tuning-delete / .steering-delete
convention; the hover inverts to --bg on --err-line, 5.2:1);
Cancel is the ghost ink-soft family (5.1:1 on --surface); the
error line is the err pair; the panel caps at the 46rem
chat-column width or the viewport."""
css = _css()
btn = css[css.find(".remove-confirm-btn {"):]
btn = btn[: btn.find("\n}")]
assert "min-height: 44px" in btn and "min-width: 44px" in btn
remove = css[css.find(".remove-confirm-remove {"):]
remove = remove[: remove.find("\n}")]
for prop in ("var(--err-bg)", "var(--err-ink)", "var(--err-line)"):
assert prop in remove, f"the destructive pair must keep {prop}"
hover = css[css.find(".remove-confirm-remove:hover:not(:disabled) {"):]
hover = hover[: hover.find("\n}")]
assert "var(--err-line)" in hover and "var(--bg)" in hover, (
"the hover inversion: dark --bg on --err-line (5.2:1)"
)
cancel = css[css.find(".remove-confirm-cancel {"):]
cancel = cancel[: cancel.find("\n}")]
assert "var(--ink-soft)" in cancel and "transparent" in cancel
err = css[css.find(".remove-confirm-error {"):]
err = err[: err.find("\n}")]
assert "var(--err-ink)" in err and "var(--err-bg)" in err
panel = css[css.find(".remove-confirm-panel {"):]
panel = panel[: panel.find("\n}")]
assert "min(46rem" in panel, "the 46rem chat-column cap (or the viewport)"
+310
View File
@@ -0,0 +1,310 @@
"""Unit: the total-removal helpers (phase 69, task 01).
Covers ``app.rag.source_removal`` with plain objects and ``tmp_path``
(no FastAPI, no database):
* ``resolve_source_name`` — exactly the sync/importer document labels:
git URL shapes (https ``.git`` / bare, scp-style ``git@host:repo.git``,
``ssh://``) and local paths (``~`` expansion, trailing slash, the
``path or url`` fallback);
* ``managed_dir_for`` — git → ``sources_dir/<repo>/``; local → the
stored dir only when it is ``upload_dir`` itself or nested under it
(the containment check, so a sibling named ``uploads-foo`` never
counts); any other local path → ``None`` (owner's own dir, never
touched);
* ``remove_managed_dir`` — ``None``/absent no-op (no filesystem write),
present tree removed → ``True``, ``OSError`` logged (``logger.
exception``) and returned as ``False`` — never raises;
* ``has_sibling`` — same-name sibling (git ``…/r`` vs ``…/r.git``, and
across kinds) → ``True``; different names → ``False``; self-excluded.
"""
from __future__ import annotations
import logging
import uuid
from pathlib import Path
from typing import cast
import pytest
from sqlalchemy.orm import Session
from app.models import GitSource
from app.rag import source_removal
from app.rag.source_removal import (
has_sibling,
managed_dir_for,
remove_managed_dir,
resolve_source_name,
)
def _git(url: str) -> GitSource:
return GitSource(id=uuid.uuid4(), url=url, kind="git")
def _local(path: str, path_column: str | None = None) -> GitSource:
# Phase 38 mirrors the expanded path in the NOT-NULL ``url`` column;
# ``path_column=None`` exercises the ``row.path or row.url`` fallback.
return GitSource(id=uuid.uuid4(), url=path, kind="local", path=path_column)
# ---------------------------------------------------------------------------
# resolve_source_name
# ---------------------------------------------------------------------------
def test_resolve_git_url_https_dot_git_suffix() -> None:
assert resolve_source_name(_git("https://example.com/reese/homelab.git")) == "homelab"
def test_resolve_git_url_https_bare() -> None:
assert resolve_source_name(_git("https://example.com/reese/homelab")) == "homelab"
def test_resolve_git_url_scp_style_git_at() -> None:
"""``git@host:repo.git`` — the ``:`` basename split (the phase-28
``repo_name`` behavior, reused not re-implemented)."""
assert resolve_source_name(_git("git@github.com:reese/deployments.git")) == "deployments"
assert resolve_source_name(_git("git@github.com:reese/deployments")) == "deployments"
def test_resolve_git_url_ssh_scheme() -> None:
assert resolve_source_name(_git("ssh://git@example.com/reese/ops.git")) == "ops"
def test_resolve_git_url_strips_whitespace() -> None:
assert resolve_source_name(_git(" https://example.com/reese/x.git ")) == "x"
def test_resolve_local_expands_tilde(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
monkeypatch.setenv("HOME", str(tmp_path / "home"))
(tmp_path / "home" / "notes").mkdir(parents=True)
assert resolve_source_name(_local("~/notes")) == "notes"
def test_resolve_local_trailing_slash_and_nested() -> None:
assert resolve_source_name(_local("/srv/docs/notes/")) == "notes"
assert resolve_source_name(_local("/srv/a/b/c")) == "c"
def test_resolve_local_falls_back_to_url_column_when_path_null() -> None:
"""Phase 38 mirrors the path in ``url`` — the ``or`` fallback keeps a
NULL ``path`` row resolvable the same way."""
assert resolve_source_name(_local("/srv/docs/notes", path_column=None)) == "notes"
# ---------------------------------------------------------------------------
# managed_dir_for
# ---------------------------------------------------------------------------
def test_managed_dir_git_maps_to_sources_dir_repo_name(tmp_path: Path) -> None:
sources = tmp_path / "sources"
row = _git("https://example.com/reese/homelab.git")
assert managed_dir_for(row, sources, tmp_path / "uploads") == sources / "homelab"
def test_managed_dir_git_expands_tilde_sources_dir(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
monkeypatch.setenv("HOME", str(tmp_path / "home"))
(tmp_path / "home" / "bor-sources").mkdir(parents=True)
row = _git("git@github.com:reese/ops.git")
got = managed_dir_for(row, Path("~/bor-sources"), tmp_path / "uploads")
assert got == tmp_path / "home" / "bor-sources" / "ops"
def test_managed_dir_local_upload_dir_itself(tmp_path: Path) -> None:
upload = tmp_path / "uploads"
upload.mkdir()
row = _local(str(upload))
assert managed_dir_for(row, tmp_path / "sources", upload) == upload
def test_managed_dir_local_nested_under_upload_dir(tmp_path: Path) -> None:
upload = tmp_path / "uploads"
folder = upload / "my-notes"
folder.mkdir(parents=True)
row = _local(str(folder))
assert managed_dir_for(row, tmp_path / "sources", upload) == folder
def test_managed_dir_local_deeply_nested_under_upload_dir(tmp_path: Path) -> None:
upload = tmp_path / "uploads"
folder = upload / "a" / "b"
folder.mkdir(parents=True)
row = _local(str(folder))
assert managed_dir_for(row, tmp_path / "sources", upload) == folder
def test_managed_dir_unresolvable_path_is_none(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""Containment that cannot be established (an unresolvable path) is
``None`` — never delete when in doubt (the defensive branch)."""
row = _local("/srv/docs/notes")
def boom(self: Path, strict: bool = False) -> Path: # noqa: ARG001
raise OSError("simulated resolve failure")
monkeypatch.setattr(Path, "resolve", boom)
assert managed_dir_for(row, tmp_path / "sources", tmp_path / "uploads") is None
def test_managed_dir_local_foreign_path_is_none(tmp_path: Path) -> None:
"""The owner's own directory — never app-managed, never touched."""
foreign = tmp_path / "own" / "docs"
foreign.mkdir(parents=True)
row = _local(str(foreign))
assert managed_dir_for(row, tmp_path / "sources", tmp_path / "uploads") is None
def test_managed_dir_local_prefix_sibling_never_counts(tmp_path: Path) -> None:
"""The containment edge: ``…/u-evil`` is NOT under ``…/u`` — a
prefix-sharing sibling name must never map into the upload dir."""
evil = tmp_path / "u-evil"
evil.mkdir()
row = _local(str(evil))
assert managed_dir_for(row, tmp_path / "sources", tmp_path / "u") is None
def test_managed_dir_local_symlink_escaping_upload_dir_is_none(tmp_path: Path) -> None:
"""A symlink stored under the upload dir that points at the owner's
dir resolves OUTSIDE — containment fails → ``None`` (never delete
through a link)."""
upload = tmp_path / "uploads"
foreign = tmp_path / "foreign"
upload.mkdir()
foreign.mkdir()
link = upload / "sneaky"
link.symlink_to(foreign)
row = _local(str(link))
assert managed_dir_for(row, tmp_path / "sources", upload) is None
def test_managed_dir_local_under_upload_dir_symlink_still_maps(tmp_path: Path) -> None:
"""Mirror image: a link that stays under the upload dir resolves
inside it — the stored path is still the app-managed dir."""
upload = tmp_path / "uploads"
real = upload / "real"
upload.mkdir()
real.mkdir()
link = upload / "alias"
link.symlink_to(real)
row = _local(str(link))
assert managed_dir_for(row, tmp_path / "sources", upload) == link
# ---------------------------------------------------------------------------
# remove_managed_dir
# ---------------------------------------------------------------------------
def test_remove_managed_dir_none_is_noop_false() -> None:
assert remove_managed_dir(None) is False
def test_remove_managed_dir_absent_is_noop_false(tmp_path: Path) -> None:
missing = tmp_path / "never-created"
assert remove_managed_dir(missing) is False
assert not missing.exists() # no filesystem write
def test_remove_managed_dir_present_tree_removed_true(tmp_path: Path) -> None:
tree = tmp_path / "homelab"
(tree / "sub").mkdir(parents=True)
(tree / "alpha.md").write_text("one")
(tree / "sub" / "bravo.md").write_text("two")
assert remove_managed_dir(tree) is True
assert not tree.exists()
def test_remove_managed_dir_oserror_logged_not_fatal(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
caplog: pytest.LogCaptureFixture,
) -> None:
"""A permission/busy-dir failure is ``logger.exception`` + ``False``
— removal never raises (the DB removal is already committed)."""
stuck = tmp_path / "stuck"
stuck.mkdir()
(stuck / "alpha.md").write_text("one")
def boom(directory: Path) -> None:
raise OSError(13, "Permission denied", str(directory))
monkeypatch.setattr(source_removal.shutil, "rmtree", boom)
with caplog.at_level(logging.ERROR, logger="app.rag.source_removal"):
assert remove_managed_dir(stuck) is False
# The exception was logged (with the traceback) and the dir is as it
# was (the rmtree never ran) — inert, self-heals on re-add.
assert stuck.is_dir()
errors = [r for r in caplog.records if r.levelno >= logging.ERROR]
assert len(errors) == 1
assert "could not remove the on-disk directory" in errors[0].getMessage()
assert errors[0].exc_info is not None
# ---------------------------------------------------------------------------
# has_sibling
# ---------------------------------------------------------------------------
class _FakeScalars:
def __init__(self, rows: list[GitSource]) -> None:
self._rows = rows
def all(self) -> list[GitSource]:
return list(self._rows)
class _FakeSession:
"""The duck-typed seam: ``has_sibling`` only calls
``db.scalars(select(GitSource))`` (the statement builds fine without
a connection) — the fake returns the registry rows it was given."""
def __init__(self, rows: list[GitSource]) -> None:
self._rows = rows
def scalars(self, statement: object) -> _FakeScalars: # noqa: ARG002
return _FakeScalars(self._rows)
def _sibling(rows: list[GitSource], row: GitSource) -> bool:
"""``has_sibling`` against a fake registry session — the duck-typed
fake goes through the seam via ``cast`` (the ``test_agent.py``
pattern)."""
return has_sibling(cast("Session", _FakeSession(rows)), row)
def test_has_sibling_same_name_git_dot_git_pair() -> None:
a = _git("https://example.com/reese/r")
b = _git("https://example.com/reese/r.git")
assert _sibling([a, b], a) is True
assert _sibling([a, b], b) is True
def test_has_sibling_different_names_is_false() -> None:
a = _git("https://example.com/reese/one.git")
b = _git("https://example.com/reese/two.git")
assert _sibling([a, b], a) is False
assert _sibling([a, b], b) is False
def test_has_sibling_self_excluded() -> None:
a = _git("https://example.com/reese/only.git")
assert _sibling([a], a) is False
def test_has_sibling_empty_registry_is_false() -> None:
a = _git("https://example.com/reese/only.git")
assert _sibling([], a) is False
def test_has_sibling_across_kinds_same_resolved_name() -> None:
"""A git URL whose ``repo_name`` equals a local dir name is a
sibling too — both index under the same label."""
git_row = _git("https://example.com/reese/notes.git")
local_row = _local("/srv/docs/notes")
assert _sibling([git_row, local_row], git_row) is True
assert _sibling([git_row, local_row], local_row) is True