"""Phase 121 story E2E (Playwright): private git sources — a token that never reaches the UI or the API (LOCKED A2/A6). Source: ``TODO.md:5`` — "Need a way to add private repos without exposing the token in the UI (like when adding an https repo ``https://myuser:ghp_xxx@github.com/myuser/my-private-repo.git``)". Run in isolation (DB must be up: ``podman compose up -d db``): uv run pytest tests/e2e/test_git_source_tokens.py -v --no-cov **No git, no network** — nothing in this suite triggers a sync (the clone-time credential injection is pinned at the integration level with a mocked ``clone_or_pull``); this suite drives the real Sources page and the real admin-only CRUD API and asserts the credential contract on every surface the owner can see: the rendered row, the page text, every ``title`` attribute, the page HTML, and the raw JSON of ``GET /api/git-sources`` (via the logged-in page context's request client — the same cookie as the UI). Per-module app env (the conftest pattern, module-scoped — as in ``test_git_sources_admin.py``): this story's app boots on its own port (a same-port second uvicorn would die on bind and drive the wrong server) with ``BOR_GIT_SOURCES`` pinned EMPTY (the phase-61 leak guard — the operator's local gitignored ``.env`` carries the owner's real source list, and process env ranks above the ``.env`` file; a leaked env list would flip the empty-after-removal assertions) and ``BOR_SOURCES_DIR`` / ``BOR_UPLOAD_DIR`` pinned to fresh tmp dirs (the cleanup DELETE must never aim a rmtree at the operator's real checkouts — the integration ``_settings`` pattern). The mock-calibrated lexical floor is pinned like the conftest (the 0.30 mock threshold + the code-default 0.35 floor violate the startup validator). Contract under test (the phase's completion criteria, UI side): * add a private repo through the Sources UI (bare URL + the masked ``#git-source-token`` field) → 201 → the row renders the BARE URL (mono cell + the cell's ``title`` attribute), the token is absent from ``document.body.innerText``, every ``title`` attribute, the page HTML, and the ``GET /api/git-sources`` raw JSON; the DB row stores the bare URL + the token column (the DB is the trusted store); both inputs clear and the button re-enables (§7.4 never-stale); * pasting the old-style embedded-token URL (``user:token@``, no token field — the TODO L5 shape) is accepted and normalized server-side (LOCKED A6): stored bare + the token column populated, and the token (and the username) is nowhere in the UI or the API; * the per-row editor's ``#ignore-editor-token`` is ``type=password`` and opens BLANK (a password is never echoed back — there is no token field to prefill from); saving it blank sends a PATCH that OMITS the key (the tri-state no-change), gets 200, and the row's stored token is KEPT (DB assertion) — the row is intact; * removing the source (the phase-69 confirmation modal) → 204, the list is empty again, and the token is gone from every surface. Test → mapping: 1. ``test_admin_add_private_repo_token_stays_out_of_ui_and_api`` 2. ``test_admin_embedded_token_url_paste_is_normalized_and_hidden`` 3. ``test_admin_editor_token_opens_blank_and_save_keeps_token`` 4. ``test_admin_remove_private_repo_leaves_empty_list`` """ from __future__ import annotations import json import os import subprocess import sys import tempfile from collections.abc import Iterator from pathlib import Path from typing import Any import pytest from playwright.sync_api import Page, expect from sqlalchemy import select, text from app.db import SessionLocal from app.models import GitSource from e2e.auth_helpers import login from e2e.conftest import ADMIN_PASSWORD, SESSION_SECRET, USE_REAL_LLM, _wait_http REPO = Path(__file__).resolve().parents[2] # Phase 79 (task 04, full inventory): the conftest session app owns its # port in a combined run — this module app binds its own port instead # (env-overridable, as in test_git_sources_admin.py). APP_PORT = int(os.environ.get("E2E_APP_PORT_GITTOKEN", "8146")) APP_URL = f"http://127.0.0.1:{APP_PORT}" GIT_SOURCES_URL = "/git-sources.html" #: Deterministic fixtures: a private repo, a distinctive fake token (the #: "nowhere" assertions key on this exact string), and the old-style #: embedded-token paste of a SECOND repo (the TODO L5 shape). PRIVATE_URL = "https://github.com/acme/e2e-private-token.git" TOKEN = "ghp_e2esecrettoken0123456789" LEGACY_URL = f"https://myuser:{TOKEN}@github.com/acme/e2e-legacy.git" LEGACY_BARE_URL = "https://github.com/acme/e2e-legacy.git" # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- @pytest.fixture(scope="module") def app_server(mock_llm: int) -> Iterator[str]: """The real app under test — per-module env: ``BOR_GIT_SOURCES`` pinned EMPTY (the phase-61 leak guard — see the module docstring) and ``BOR_SOURCES_DIR`` / ``BOR_UPLOAD_DIR`` pinned to fresh tmp dirs (the cleanup DELETE never rmtrees the operator's real checkouts). No git, no sync in this suite.""" 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 gate (conftest pattern) — no chat turn is sent, # but the app boots with the same env shape. The floor is pinned # explicitly: the code default (0.35) violates the startup # validator against the mock threshold (0.30) and would refuse the # boot if the operator's .env did not carry a valid one. env["BOR_RELEVANCE_THRESHOLD"] = "0.30" env["BOR_LEXICAL_SUPPORT_FLOOR"] = "0.15" 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 # The empty-table env fallback list — pinned EMPTY: the operator's # local (gitignored) .env must not leak the owner's real sources # into the empty-state assertions. env["BOR_GIT_SOURCES"] = "" # Fresh scratch dirs: the cleanup DELETE (test 4) must never aim a # rmtree at the operator's real checkouts (the integration # ``_settings`` pattern). scratch = Path(tempfile.mkdtemp(prefix="bor-e2e-gittoken-")) env["BOR_SOURCES_DIR"] = str(scratch / "sources") env["BOR_UPLOAD_DIR"] = str(scratch / "uploads") 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, as in ``test_git_sources_admin.py``); emptied BOTH before and after every test (the suites share one Postgres and run in isolation — a leftover row would flip another suite's app from the env fallback to the DB list).""" _truncate_git_sources() yield _truncate_git_sources() # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _seed_token_row(url: str, token: str) -> None: """Store a token row directly (deterministic; the page loads the list on boot, so seed BEFORE navigating).""" with SessionLocal() as db: db.add(GitSource(url=url, token=token)) db.commit() def _stored_row() -> tuple[str, str | None]: """The stored row's ``(url, token)`` — read INSIDE the session (detached instances would defer-load on attribute access).""" with SessionLocal() as db: row = db.scalars(select(GitSource)).one() return row.url, row.token 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 _assert_token_nowhere(page: Page, app_url: str, token: str) -> None: """The phase's credential contract (LOCKED A2): the token is absent from EVERY surface the owner can see — the rendered body text, every ``title`` attribute, the whole page HTML (every serialized attribute value), and the raw JSON text of ``GET /api/git-sources`` (the page context's request client carries the logged-in cookie).""" body_text = page.evaluate("() => document.body.innerText") assert token not in body_text, "the token leaks into the page text" titles = page.evaluate( "() => [...document.querySelectorAll('[title]')].map((el) => el.getAttribute('title'))" ) for t in titles: assert t is None or token not in t, f"the token leaks into a title attribute: {t!r}" assert token not in page.content(), "the token leaks into the page HTML" r = page.request.get(f"{app_url}/api/git-sources") assert r.status == 200, r.text assert token not in r.text(), "the token leaks into GET /api/git-sources" # --------------------------------------------------------------------------- # 1. Add: bare URL + masked token → the row is bare, the token is # nowhere (page text, title attributes, HTML, API JSON) # --------------------------------------------------------------------------- def test_admin_add_private_repo_token_stays_out_of_ui_and_api( page: Page, app_url: str, db_ready: None ) -> None: page.set_default_timeout(30_000) _admin_git_sources_page(page, app_url) expect(page.locator("#git-sources-tbody tr")).to_have_count(0) page.fill("#git-source-url", PRIVATE_URL) page.fill("#git-source-token", TOKEN) page.click("#git-source-add") # 201 → the row lands rendering the BARE URL — the mono code cell # AND the cell's hover title attribute are both bare. row = page.locator("#git-sources-tbody tr", has_text=PRIVATE_URL) expect(row).to_have_count(1, timeout=30_000) expect(row.locator("td.git-source-url-cell code")).to_have_text(PRIVATE_URL) expect(row.locator("td.git-source-url-cell")).to_have_attribute("title", PRIVATE_URL) # Never-stale (§7.4): BOTH inputs clear (the credential is stored — # write-only, never round-trips), the button re-enables with its # idle label, no inline error. expect(page.locator("#git-source-url")).to_have_value("") expect(page.locator("#git-source-token")).to_have_value("") expect(page.locator("#git-source-add")).to_be_enabled() expect(page.locator("#git-source-add")).to_have_text("Add source") expect(page.locator("#git-source-error")).to_be_hidden() # The DB is the trusted store (LOCKED A2): bare URL + the token # column — the token lives in exactly one place. stored_url, stored_token = _stored_row() assert stored_url == PRIVATE_URL assert stored_token == TOKEN _assert_token_nowhere(page, app_url, TOKEN) # --------------------------------------------------------------------------- # 2. Paste the old-style embedded-token URL (TODO L5): normalized # server-side (LOCKED A6), token + username nowhere # --------------------------------------------------------------------------- def test_admin_embedded_token_url_paste_is_normalized_and_hidden( page: Page, app_url: str, db_ready: None ) -> None: page.set_default_timeout(30_000) _admin_git_sources_page(page, app_url) expect(page.locator("#git-sources-tbody tr")).to_have_count(0) # The exact TODO L5 paste: ``user:token@`` in the URL, the masked # token field BLANK (the key is omitted from the POST). page.fill("#git-source-url", LEGACY_URL) page.click("#git-source-add") # 201 → the row is the BARE URL (the userinfo was stripped on # write), in the code cell AND the title attribute. row = page.locator("#git-sources-tbody tr", has_text=LEGACY_BARE_URL) expect(row).to_have_count(1, timeout=30_000) expect(row.locator("td.git-source-url-cell code")).to_have_text(LEGACY_BARE_URL) expect(row.locator("td.git-source-url-cell")).to_have_attribute("title", LEGACY_BARE_URL) expect(page.locator("#git-source-error")).to_be_hidden() # The paste is normalized, not rejected: stored bare + the embedded # PASSWORD part in the token column (the username is an identifier, # not the credential). stored_url, stored_token = _stored_row() assert stored_url == LEGACY_BARE_URL assert stored_token == TOKEN # The token is nowhere — and the username is gone from the page # text too (the whole userinfo was stripped, not just the secret). _assert_token_nowhere(page, app_url, TOKEN) body_text = page.evaluate("() => document.body.innerText") assert "myuser" not in body_text, "the username leaks into the page text" # --------------------------------------------------------------------------- # 3. Edit: the editor's token field opens BLANK (never prefilled) and a # blank save is a 200 no-change — the stored token is kept # --------------------------------------------------------------------------- def test_admin_editor_token_opens_blank_and_save_keeps_token( page: Page, app_url: str, db_ready: None ) -> None: page.set_default_timeout(30_000) _seed_token_row(PRIVATE_URL, TOKEN) # before the page loads its list _admin_git_sources_page(page, app_url) row = page.locator("#git-sources-tbody tr", has_text=PRIVATE_URL) expect(row).to_have_count(1, timeout=30_000) # Open the per-row editor ("Ignore paths" — the row's settings). row.locator(".git-source-ignore").click() dialog = page.locator("#ignore-editor-dialog") expect(dialog).to_be_visible(timeout=30_000) expect(page.locator("#ignore-editor-source")).to_have_text(PRIVATE_URL) # The token field: masked, and BLANK — a password must never be # echoed back (there is no token field to prefill from — LOCKED A2). tok = page.locator("#ignore-editor-token") expect(tok).to_have_value("") assert tok.get_attribute("type") == "password" patch_bodies: list[dict[str, Any] | None] = [] patch_statuses: list[int] = [] def track_request(r: Any) -> None: if r.method == "PATCH" and "/api/git-sources/" in r.url: try: patch_bodies.append(json.loads(r.post_data)) except (TypeError, ValueError): patch_bodies.append(None) def track_response(r: Any) -> None: if r.request.method == "PATCH" and "/api/git-sources/" in r.url: patch_statuses.append(r.status) page.on("request", track_request) page.on("response", track_response) try: # Save with the token field blank → 200 (no 4xx), the dialog # closes, no inline error, the row is intact. page.click("#ignore-editor-save") expect(dialog).to_be_hidden(timeout=30_000) expect(page.locator("#ignore-editor-error")).to_be_hidden() expect(page.locator("#git-sources-tbody tr", has_text=PRIVATE_URL)).to_have_count(1) finally: page.remove_listener("request", track_request) page.remove_listener("response", track_response) # One PATCH, 200, and the body OMITS the token key (blank = the # tri-state no-change — the key is not sent, let alone empty). assert patch_statuses == [200], f"the blank-token save did not 200: {patch_statuses}" assert len(patch_bodies) == 1 and patch_bodies[0] is not None assert "token" not in patch_bodies[0], "a blank token must OMIT the PATCH key" assert patch_bodies[0] == {"ignore_paths": []} # The row is intact and the stored token is KEPT (the no-change # tri-state, verified in the trusted store). stored_url, stored_token = _stored_row() assert stored_url == PRIVATE_URL assert stored_token == TOKEN _assert_token_nowhere(page, app_url, TOKEN) # --------------------------------------------------------------------------- # 4. Cleanup: the confirmation-modal removal → 204, the list is empty # again, and the token is gone from every surface # --------------------------------------------------------------------------- def test_admin_remove_private_repo_leaves_empty_list( page: Page, app_url: str, db_ready: None ) -> None: page.set_default_timeout(30_000) _seed_token_row(PRIVATE_URL, TOKEN) # before the page loads its list _admin_git_sources_page(page, app_url) row = page.locator("#git-sources-tbody tr", has_text=PRIVATE_URL) expect(row).to_have_count(1, timeout=30_000) delete_statuses: list[int] = [] def track_response(r: Any) -> None: if r.request.method == "DELETE" and "/api/git-sources/" in r.url: delete_statuses.append(r.status) page.on("response", track_response) try: row.locator(".git-source-remove").click() dialog = page.locator("#remove-confirm-dialog") expect(dialog).to_be_visible(timeout=30_000) # The modal names the source BARE (the same sanitized value the # row shows — a credential is never rendered here either). expect(page.locator("#remove-confirm-source")).to_have_text(PRIVATE_URL) page.click("#remove-confirm-remove") expect(page.locator("#git-sources-tbody tr")).to_have_count(0, timeout=30_000) expect(dialog).to_be_hidden() finally: page.remove_listener("response", track_response) # Total removal: one DELETE, 204, and the list is empty again — # the API agrees (and the row's token left with the row). assert delete_statuses == [204], f"the removal did not 204: {delete_statuses}" r = page.request.get(f"{app_url}/api/git-sources") assert r.status == 200, r.text assert r.json()["sources"] == [] assert TOKEN not in r.text() _assert_token_nowhere(page, app_url, TOKEN)