"""Phase 35 story E2E (Playwright): the admin page to add / remove git sources (``/git-sources.html``). Story: ``.agent/user_stories/git-sources-admin.md`` Run in isolation (DB must be up: ``podman compose up -d db``): uv run pytest tests/e2e/test_git_sources_admin.py -v --no-cov The story gate for the admin-managed git source list. **No git, no network** — this suite is UI + API only (the clone/import pipeline is mocked at the integration level, phase 35 task 03; a real sync is deliberately never triggered here). The stored list (``git_sources`` table, migration 0006) is exercised through the real page and the real admin-only CRUD API (phase 35 task 02). Per-module app env (the conftest pattern, module-scoped — as in ``test_sync_button.py``): this story's app boots with a fixed ``BOR_GIT_SOURCES`` CSV (two deterministic URLs that are NEVER cloned — nothing in this suite triggers a sync) so the empty-table env fallback (the phase's locked decision: ``BOR_GIT_SOURCES`` only applies while the ``git_sources`` table is empty) can be asserted against real env rows. The session app (no git sources) is never started in this isolated run, so no port clash. Contract under test: * anonymous: the sign-in gate (the exact ``#sources-gate`` pattern), the manager hidden (list + add form inert), NO ``/api/git-sources`` call, 403 on all three routes (asserted with the page context's request client, the ``test_admin_auth.py``/``test_sync_button.py`` pattern), and ``#nav-git-sources`` hidden on all five pages; * admin: ``#nav-git-sources`` visible on all five pages (revealed by the shared header on the cached whoami), clicking it from the chat lands on ``/git-sources.html`` with the link ``is-active``; the stored rows 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 "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, 3px focus-visible outline, the aria-live list announcer, same-origin assets only (rule 6). Test → story mapping (Playwright Mapping Rule): 1. ``test_anonymous_gate_no_api_calls_and_403s`` 2. ``test_admin_nav_link_on_all_five_pages_and_click_navigates`` 3. ``test_admin_sees_seeded_rows_with_dates_and_no_env_note`` 4. ``test_admin_add_then_remove_lifecycle`` 5. ``test_admin_env_fallback_rows_and_note`` 6. ``test_admin_page_a11y_and_no_cdn`` """ from __future__ import annotations import os import re import subprocess import sys import uuid from collections.abc import Iterator from datetime import UTC, datetime, timedelta from pathlib import Path from typing import Any import pytest from playwright.sync_api import Dialog, Page, expect from sqlalchemy import text from app.db import SessionLocal from app.models import 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}" #: The five pages that ship the header (phase 34 contract) — the pages #: task 05 gave the admin-only "Git sources" nav link. CHAT_URL = "/" SOURCES_URL = "/sources.html" VIEWER_URL = "/document.html?source=docs&path=homelab%2Fkubernetes.md" TUNING_URL = "/tuning.html" GIT_SOURCES_URL = "/git-sources.html" FIVE_PAGES = ( ("chat", CHAT_URL), ("sources", SOURCES_URL), ("viewer", VIEWER_URL), ("tuning", TUNING_URL), ("git-sources", GIT_SOURCES_URL), ) #: The module app's ``BOR_GIT_SOURCES`` — two deterministic URLs that #: are NEVER cloned (no sync is triggered in this suite; the fallback #: list is what is under test). ENV_SOURCE_A = "https://github.com/reese/env-alpha.git" ENV_SOURCE_B = "https://github.com/reese/env-beta.git" ENV_SOURCES_CSV = ",".join((ENV_SOURCE_A, ENV_SOURCE_B)) #: ``is-active`` as a word-boundary regex — to_have_class() matches the #: WHOLE class string, so the current-page marker is asserted the same #: way test_tuning_nav_link.py does it. IS_ACTIVE = re.compile(r"\bis-active\b") #: Deterministic URLs for the UI-driven assertions. SEED_URL = "https://gitlab.example.com/reese/seeded.git" NEW_REPO_URL = "https://example.com/reese/new-repo.git" # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- @pytest.fixture(scope="module") def app_server(mock_llm: int) -> Iterator[str]: """The real app under test — per-module env: a fixed two-URL ``BOR_GIT_SOURCES`` CSV so the empty-table env fallback (the phase's locked decision) is live. No ``BOR_SOURCES_DIR``, no git: nothing in this suite clones or syncs.""" 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 # Phase 35: the empty-table fallback list (never cloned here). env["BOR_GIT_SOURCES"] = ENV_SOURCES_CSV 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 def _truncate_git_sources() -> None: with SessionLocal() as db: db.execute(text("TRUNCATE git_sources")) db.commit() @pytest.fixture(autouse=True) def _clean_git_sources(db_ready: None) -> Iterator[None]: """Fresh ``git_sources`` table per test — this suite owns the table (the E2E isolation pattern); the KB tables are irrelevant here and are left untouched (the viewer page may show its not-found state — no document assertion is made about it). The table is emptied BOTH before and after every test: suites run in isolation but share one Postgres, and a leftover row would flip another suite's app from the ``BOR_GIT_SOURCES`` env fallback to the DB list (the sync story's ``effective_git_sources`` resolver reads the DB first).""" _truncate_git_sources() yield _truncate_git_sources() # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _seed_rows(urls: list[str]) -> None: """Store rows directly (deterministic, ordered added_at) — the task allows seeding via the API *or* SessionLocal; direct inserts keep the list-rendering test independent of the add/remove lifecycle test.""" base = datetime(2026, 8, 26, 9, 0, 0, tzinfo=UTC) with SessionLocal() as db: for i, url in enumerate(urls): db.add(GitSource(url=url, added_at=base + timedelta(minutes=i))) 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, url: str) -> Any: """The table row whose mono URL cell shows ``url``.""" return page.locator("#git-sources-tbody tr", has_text=url) def _assert_settled(page: Page, admin: bool) -> None: """The shared header's whoami toggle has landed (one of Sign in / Sign out visible) — the same settled-state gate as test_nav_consistency.py.""" if admin: expect(page.locator("#sign-out-btn")).to_be_visible(timeout=15_000) expect(page.locator("#sign-in-link")).to_be_hidden() else: expect(page.locator("#sign-in-link")).to_be_visible(timeout=15_000) expect(page.locator("#sign-out-btn")).to_be_hidden() # --------------------------------------------------------------------------- # 1. Anonymous: gate, inert manager, no API calls, 403s, hidden link # --------------------------------------------------------------------------- def test_anonymous_gate_no_api_calls_and_403s( page: Page, app_url: str, db_ready: None ) -> None: page.set_default_timeout(30_000) # Track every /api/git-sources request the page itself makes — the # gate must be reached WITHOUT touching the admin API (the # test_admin_auth.py pattern for the Sources page's /api/docs). api_calls: list[str] = [] page.on( "request", lambda r: api_calls.append(r.url) if "/api/git-sources" in r.url else None, ) page.goto(app_url + GIT_SOURCES_URL) _assert_settled(page, admin=False) # The sign-in gate (the #sources-gate pattern, phase 16)… gate = page.locator("#git-sources-gate") expect(gate).to_be_visible() expect(gate).to_contain_text("Sign in to manage the git sources") link = gate.locator("a[href='/login.html?next=/git-sources.html']") expect(link).to_have_count(1) box = link.bounding_box() assert box is not None and box["height"] >= 44, f"gate link too small: {box}" # …and the manager is absent/inert: list, add form, env note — all # inside the hidden #git-sources-content. expect(page.locator("#git-sources-content")).to_be_hidden() expect(page.locator("#git-sources-table")).to_be_hidden() expect(page.locator("#git-source-form")).to_be_hidden() expect(page.locator("#git-sources-env-note")).to_be_hidden() # The admin-only nav link is hidden on this page… expect(page.locator("#nav-git-sources")).to_be_hidden() # …and on the other four pages (all five ship it hidden by default). for _name, path in FIVE_PAGES: if path == GIT_SOURCES_URL: continue page.goto(app_url + path) _assert_settled(page, admin=False) expect(page.locator("#nav-git-sources")).to_be_hidden() # The gate never called the admin API… assert api_calls == [], f"anonymous page called the git sources API: {api_calls}" # …and the API 403s anonymous callers on all three routes (the # test_sync_button.py pattern — the page context has no cookie). assert page.request.get(f"{app_url}/api/git-sources").status == 403 assert ( page.request.post( f"{app_url}/api/git-sources", data={"url": NEW_REPO_URL} ).status == 403 ) assert page.request.delete(f"{app_url}/api/git-sources/{uuid.uuid4()}").status == 403 # --------------------------------------------------------------------------- # 2. Admin: the nav link on all five pages; the click lands + is-active # --------------------------------------------------------------------------- def test_admin_nav_link_on_all_five_pages_and_click_navigates( page: Page, app_url: str, db_ready: None ) -> None: page.set_default_timeout(30_000) login(page, app_url, next=CHAT_URL) expect(page).to_have_url(app_url + CHAT_URL, timeout=30_000) # The link is revealed (admin) on every one of the five pages, # pointing at the git sources page, labeled "Git sources" — and it # is NOT the current page on the four non-git-sources pages. for _name, path in FIVE_PAGES: if path != CHAT_URL: page.goto(app_url + path) _assert_settled(page, admin=True) link = page.locator("#nav-git-sources") expect(link).to_be_visible(timeout=15_000) expect(link).to_have_attribute("href", GIT_SOURCES_URL) expect(link).to_have_text("Git sources") if path != GIT_SOURCES_URL: expect(link).not_to_have_class(IS_ACTIVE) # From the chat: a real click navigates… page.goto(app_url + CHAT_URL) _assert_settled(page, admin=True) page.click("#nav-git-sources") expect(page).to_have_url(app_url + GIT_SOURCES_URL, timeout=30_000) # …where the page's own link is the current-page marker. link = page.locator("#nav-git-sources") expect(link).to_be_visible(timeout=15_000) expect(link).to_have_class(IS_ACTIVE) expect(link).to_have_attribute("aria-current", "page") # --------------------------------------------------------------------------- # 3. Admin: two seeded rows render (mono URL + added date); env note # hidden while the DB has rows # --------------------------------------------------------------------------- def test_admin_sees_seeded_rows_with_dates_and_no_env_note( page: Page, app_url: str, db_ready: None ) -> None: page.set_default_timeout(30_000) _seed_rows([SEED_URL, ENV_SOURCE_A]) # ordered by added_at _admin_git_sources_page(page, app_url) # Both rows render, oldest first… expect(page.locator("#git-sources-tbody tr")).to_have_count(2) first = _row(page, SEED_URL) second = _row(page, ENV_SOURCE_A) expect(first.locator("td.git-source-url-cell code")).to_have_text(SEED_URL) expect(second.locator("td.git-source-url-cell code")).to_have_text(ENV_SOURCE_A) expect(page.locator("#git-sources-tbody tr").first).to_contain_text(SEED_URL) expect(page.locator("#git-sources-tbody tr").last).to_contain_text(ENV_SOURCE_A) # …each with the mono URL cell, a rendered added date (not the "—" # env-fallback placeholder), and a labeled per-row Remove button. for row, url in ((first, SEED_URL), (second, ENV_SOURCE_A)): added = row.locator("td").nth(1) expect(added).not_to_be_empty() expect(added).not_to_have_text("—") remove = row.locator(".git-source-remove") expect(remove).to_have_count(1) expect(remove).to_have_attribute("aria-label", f"Remove git source: {url}") # DB rows exist → from_env is false: the env note is hidden and the # empty state too. expect(page.locator("#git-sources-env-note")).to_be_hidden() expect(page.locator("#git-sources-empty")).to_be_hidden() # --------------------------------------------------------------------------- # 4. Admin: add → duplicate → invalid → remove (accept + cancel) — the # full never-stale lifecycle # --------------------------------------------------------------------------- def test_admin_add_then_remove_lifecycle( page: Page, app_url: str, db_ready: None ) -> None: page.set_default_timeout(30_000) _seed_rows([SEED_URL]) _admin_git_sources_page(page, app_url) expect(page.locator("#git-sources-tbody tr")).to_have_count(1) error = page.locator("#git-source-error") add_btn = page.locator("#git-source-add") # --- add: 201 → the row appears, the input clears, the button # re-enables (never stale) --------------------------------------- page.fill("#git-source-url", NEW_REPO_URL) add_btn.click() expect(page.locator("#git-sources-tbody tr")).to_have_count(2, timeout=30_000) expect(_row(page, NEW_REPO_URL).locator("td.git-source-url-cell code")).to_have_text( NEW_REPO_URL ) expect(page.locator("#git-source-url")).to_have_value("") expect(add_btn).to_be_enabled() expect(add_btn).to_have_text("Add source") # A stored row exists → the env note stays hidden. expect(page.locator("#git-sources-env-note")).to_be_hidden() # --- duplicate: inline role=alert, NO new row, button re-enabled, # the (failed) input survives for one edit ------------------------ page.fill("#git-source-url", NEW_REPO_URL) add_btn.click() expect(error).to_be_visible(timeout=30_000) assert error.get_attribute("role") == "alert" expect(error).to_contain_text("already exists") expect(page.locator("#git-sources-tbody tr")).to_have_count(2) expect(add_btn).to_be_enabled() expect(add_btn).to_have_text("Add source") expect(page.locator("#git-source-url")).to_have_value(NEW_REPO_URL) # --- invalid shape: inline 422 (the server detail, never the # echoed input), NO new row, button re-enabled -------------------- page.fill("#git-source-url", "not a valid url") add_btn.click() expect(error).to_be_visible(timeout=30_000) expect(error).to_contain_text("not a valid git URL") expect(page.locator("#git-sources-tbody tr")).to_have_count(2) 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} 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() expect(page.locator("#git-sources-tbody tr")).to_have_count(1, timeout=30_000) expect(_row(page, NEW_REPO_URL)).to_have_count(0) # 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 _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 # 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) # --------------------------------------------------------------------------- # 5. Admin: empty table → the env rows render + the env note is visible # (BOR_GIT_SOURCES is the empty-table fallback) # --------------------------------------------------------------------------- def test_admin_env_fallback_rows_and_note( page: Page, app_url: str, db_ready: None ) -> None: page.set_default_timeout(30_000) # The autouse fixture truncated git_sources — the table is EMPTY, so # the module app's BOR_GIT_SOURCES CSV is the effective list. _admin_git_sources_page(page, app_url) # The two env rows render (CSV order), each tagged "from .env" with # no Remove (nothing is stored to remove) and the "—" added date. expect(page.locator("#git-sources-tbody tr")).to_have_count(2) expect(page.locator("#git-sources-tbody tr").first).to_contain_text(ENV_SOURCE_A) expect(page.locator("#git-sources-tbody tr").last).to_contain_text(ENV_SOURCE_B) for row in page.locator("#git-sources-tbody tr").all(): expect(row.locator(".git-source-env-tag")).to_have_count(1) expect(row.locator(".git-source-env-tag")).to_have_text("from .env") expect(row.locator(".git-source-remove")).to_have_count(0) expect(row.locator("td").nth(1)).to_have_text("—") # The env-fallback note explains the active list's origin… expect(page.locator("#git-sources-env-note")).to_be_visible() # …and the API agrees: from_env true, null ids, the env URLs. r = page.request.get(f"{app_url}/api/git-sources") assert r.status == 200, r.text body = r.json() assert body["from_env"] is True assert [s["url"] for s in body["sources"]] == [ENV_SOURCE_A, ENV_SOURCE_B] assert all(s["id"] is None and s["added_at"] is None for s in body["sources"]) # --------------------------------------------------------------------------- # 6. UI Structure Check (AGENTS.md rule 5) + no CDN (rule 6) # --------------------------------------------------------------------------- def test_admin_page_a11y_and_no_cdn( page: Page, app_url: str, db_ready: None ) -> None: page.set_default_timeout(30_000) _seed_rows([SEED_URL]) _admin_git_sources_page(page, app_url) expect(page.locator("#git-sources-tbody tr")).to_have_count(1) # Standard app frame: landmarks + skip link (PLAN §7.2). expect(page.locator("header.app-header")).to_have_count(1) expect(page.locator('nav[aria-label="Primary"]')).to_have_count(1) expect(page.locator("main#main")).to_have_count(1) expect(page.locator("footer.app-footer")).to_have_count(1) expect(page.locator(".skip-link")).to_have_count(1) # No CDN (rule 6): no https:// asset tags; every script/link ref is # same-origin or a data: URI. 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}" ) # The form input is labeled (visible