"""Phase 173 E2E (Playwright): the docs-push credentials — the LEAK CLASS is dead in the real UI. Source: owner audit of the production admin Settings panel (2026-10-10) — ``BOR_DOCS_REPO`` was a plain-text row whose production value was a git URL with an embedded username + Gitea token: a live credential leak on the settings page, the raw HTML, and the settings API. This phase splits the credentials into ``docs_user`` (plain) + ``docs_token`` (write-only, ``secret=True`` — the ``llm_api_key``-style row), guards the ``docs_repo`` PUT against embedded credentials (422), and migrates a legacy value once at startup (C1–C5, phase-locked). Run in isolation (DB must be up: ``podman compose up -d db``; the suite needs NO LLM — the mock is the house default, A16): uv run pytest tests/e2e/test_docs_push_creds.py -v --no-cov Test → contract mapping (one phase, one isolated file — AGENTS rule 4): 1. ``test_rows_render_token_write_only`` — the split shape in the real UI: the ``Docs push`` group shows a ``Docs repo`` TEXT row (the clean URL, seeded through the admin API), a ``Docs user`` TEXT row, and a ``Docs token`` row that is WRITE-ONLY — its input is ``type=password``, NOT pre-filled (value ``""``), rendered in the phase-168 secret-row style (the ``•••••• (stored)`` placeholder, the ``BOR_DOCS_TOKEN`` aria-label), with the is-set state visible (DB badge + a live Reset) — and the seeded token text is ABSENT from the initial page DOM, the served HTML, and the raw ``GET /api/admin/settings`` body. 2. ``test_save_stays_masked`` — the core assertion: typing a unique sentinel token into the ``Docs token`` row and saving re-renders the row masked; a reload of ``/settings.html`` keeps it masked with the is-set state visible (DB badge, live Reset, API ``has_value``), and the **sentinel is absent from the entire page DOM** — ``page.content()`` must not contain it (nor must the served HTML or the raw API body). 3. ``test_reset_unsets`` — the row's Reset hands control back to the environment: the status line, the Reset back to DISABLED (the "no saved override" title — the phase-168 not-set marker for a secret row; the placeholder is the constant ``•••••• (stored)`` for every secret row, set or not), the badge back to ``env`` (the env base is set-but-empty in the module app), the input empty, the sentinel gone from the DOM, and the API reporting ``has_value=false`` / ``has_override=false``. 4. ``test_creds_url_guard_surfaces_and_stays_clean`` — the C4 guard UI-side: with a clean ``docs_repo`` saved, typing a credentials-bearing URL (``https://u:pass@e2e.invalid/o/r.git``) and saving 422s — the ``role="alert"`` line names the field and the replacement (the ``Docs user`` / ``Docs token`` fields) — the input echoes the typed value (K4, the pre-save state, fine), and after the failed save + reload the row keeps the PREVIOUS saved value with the typed URL's ``pass`` segment ABSENT from the page DOM and the API still reporting the clean URL. DB isolation: the shared e2e Postgres keeps ``app_settings`` (the overlay rows — a leftover row would retune every later suite's app through the conftest pin's failure mode) and ``ui_settings`` (the single row the caching middleware reads for EVERY served page) across suites. An autouse fixture truncates ``app_settings`` and ``ui_settings`` and deletes the ``e2e-``-labeled tokens before AND after every test (never a TRUNCATE on ``api_tokens`` — the shared DB may hold the owner's real tokens); the module teardown pins ``app_settings`` empty. The module app opts BACK IN over the conftest's global ``BOR_SETTINGS_DB=0`` pin (``env["BOR_SETTINGS_DB"] = "1"`` — the ``test_pictorial_pdf.py`` opt-in pattern) and pins the docs-push base vars to DISTINCTIVE empties (``BOR_DOCS_REPO`` / ``BOR_DOCS_USER`` / ``BOR_DOCS_TOKEN`` = ``""``) so an operator's local (gitignored) ``.env`` — loaded by pydantic from ``cwd=REPO`` — cannot leak a credentials-bearing repo (the startup migration's subject) or a real token into the suite's app: process env ranks above the ``.env`` file. A module-scope ``.env`` mtime guard pins LOCKED A1: no code path the suite drives opens the env file for writing. """ from __future__ import annotations import json import os import subprocess import sys import uuid from collections.abc import Iterator from pathlib import Path from typing import Any import httpx import pytest from playwright.sync_api import Page, expect from sqlalchemy import text from app.config import Settings from app.db import SessionLocal 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] #: The module app's OWN port (the test_response_to_docs.py pattern — #: the conftest session app owns 8123 in a combined run; a same-port #: second uvicorn dies on bind and would drive the wrong server). #: Env-overridable. APP_PORT = int(os.environ.get("E2E_APP_PORT_CREDS", "8190")) APP_URL = f"http://127.0.0.1:{APP_PORT}" #: The test-seed values (test 1) — distinctive so "the DB row now #: wins" is visible, and the seeded token is the value that must #: appear NOWHERE outside the DB. SEED_REPO = "https://gitea.example.com/e2e/docs.git" SEED_USER = "e2e-docs-push-user" SEED_TOKEN = "sk-e2e-docs-seed-0123456789abcd" #: Test 4 — the clean value the row KEEPS, and the credentials-bearing #: URL the guard must refuse (the ``pass`` segment must never persist). CLEAN_REPO = "https://gitea.example.com/e2e/docs.git" CRED_URL = "https://u:pass@e2e.invalid/o/r.git" #: The phase-168 secret-row placeholder (the frontend's constant for #: every ``secret=True`` row — settings.js's makeControl). STORED_PLACEHOLDER = "•••••• (stored)" # --------------------------------------------------------------------------- # Per-module app env (the phase-168 tuning/theme pattern) # --------------------------------------------------------------------------- def _boot_app() -> subprocess.Popen[bytes]: """The module app (the shared env block + the phase-173 pins): the overlay ON (``BOR_SETTINGS_DB=1`` — the module opts back in over the conftest's global pin) and the docs-push base vars pinned to DISTINCTIVE empties (an operator's local ``.env`` must not leak a credentials-bearing repo or a real token in).""" 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_PORT}/v1" ) # The E2E-calibrated gate pins (the conftest pattern — explicit so # an operator's local .env cannot leak a different gate into the # suite's app; the startup validator needs floor ≤ threshold). env["BOR_RELEVANCE_THRESHOLD"] = "0.30" env["BOR_LEXICAL_SUPPORT_FLOOR"] = "0.15" env["BOR_SOURCE_USEFULNESS_FLOOR"] = "0.15" env["BOR_LLM_RETRY_DELAY"] = "0" env["BOR_LLM_RETRIES"] = str(Settings.model_fields["llm_retries"].default) 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 173 (this suite): the docs-push base pinned to empties — # no credentials-bearing repo for the startup migration to act on, # no real token in effect, and the "env" source badge deterministic # (the var is set-but-empty → source "env", not "default"). env["BOR_DOCS_REPO"] = "" env["BOR_DOCS_USER"] = "" env["BOR_DOCS_TOKEN"] = "" env["BOR_SUGGESTIONS"] = json.dumps( Settings.model_fields["suggestions"].default ) # Phase 168: the module opts BACK IN over the conftest's global # BOR_SETTINGS_DB=0 pin (the test_pictorial_pdf.py pattern). env["BOR_SETTINGS_DB"] = "1" env["BOR_GIT_SOURCES"] = "" return 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, ) def _stop_app(proc: subprocess.Popen[bytes]) -> None: proc.terminate() try: proc.wait(timeout=10) except subprocess.TimeoutExpired: proc.kill() @pytest.fixture(scope="module") def app_server(mock_llm: int) -> Iterator[str]: """The real app under test — the overlay ON (``BOR_SETTINGS_DB=1``) and the docs-push base pinned to empties (the phase-173 pins).""" global _MOCK_PORT _MOCK_PORT = mock_llm proc = _boot_app() try: _wait_http(f"{APP_URL}/api/health") yield APP_URL finally: _stop_app(proc) @pytest.fixture(scope="module") def app_url(app_server: str) -> str: return app_server # --------------------------------------------------------------------------- # DB isolation + helpers # --------------------------------------------------------------------------- def _clean_settings_state() -> None: """Fresh settings + theme state per test: truncate ``app_settings`` (a leftover row is the cross-suite poison this phase's overlay guard exists to prevent) and the single-row ``ui_settings`` (the middleware reads it for EVERY page), and delete this suite's issued tokens (label-scoped on ``e2e-`` — never a TRUNCATE: the shared DB may hold the owner's real tokens).""" with SessionLocal() as db: db.execute(text("TRUNCATE app_settings")) db.execute(text("TRUNCATE ui_settings")) db.execute(text("DELETE FROM api_tokens WHERE label LIKE 'e2e-%'")) db.commit() @pytest.fixture(autouse=True) def _clean(db_ready: None) -> Iterator[None]: _clean_settings_state() yield _clean_settings_state() # The module's final state: app_settings EMPTY (the poison guard — # the next suite's app must boot with no overrides). with SessionLocal() as db: assert db.execute(text("SELECT count(*) FROM app_settings")).scalar() == 0 @pytest.fixture(scope="module", autouse=True) def _env_never_written() -> Iterator[None]: """LOCKED A1, mtime-pinned: no code path the suite drives opens ``.env`` for writing (the file is gitignored — a ``git status`` check cannot see it, so ``stat``'s mtime is the assertion).""" env_file = REPO / ".env" before = env_file.stat().st_mtime_ns if env_file.exists() else None yield after = env_file.stat().st_mtime_ns if env_file.exists() else None assert before == after, "the app under test must never write .env" def _admin_client(app_url: str) -> httpx.Client: """The API side of the form login (the 204 + the signed session cookie in the jar).""" client = httpx.Client(timeout=60.0) r = client.post(f"{app_url}/api/login", json={"password": ADMIN_PASSWORD}) assert r.status_code == 204, f"admin login → {r.status_code}: {r.text[:120]}" return client def _put_setting( client: httpx.Client, app_url: str, key: str, value: str ) -> httpx.Response: r = client.put(f"{app_url}/api/admin/settings/{key}", json={"value": value}) assert r.status_code == 200, f"PUT {key} → {r.status_code}: {r.text[:200]}" return r def _catalog_item(client: httpx.Client, app_url: str, key: str) -> dict[str, Any]: r = client.get(f"{app_url}/api/admin/settings", timeout=30.0) assert r.status_code == 200, r.text[:200] for item in r.json()["items"]: if item["key"] == key: return item raise AssertionError(f"{key} missing from the catalogue") def _unique_sentinel() -> str: """The task's unique sentinel (a per-run uuid4 — "a saved token never appears anywhere in the page DOM" is asserted against a value that is unguessable, not a fixed constant).""" return f"e2e-sentinel-{uuid.uuid4().hex}" # --------------------------------------------------------------------------- # 1. The rows render: the clean-URL text row, the user text row, and # the WRITE-ONLY token row — masked, not pre-filled, absent from # the initial DOM # --------------------------------------------------------------------------- def test_rows_render_token_write_only(page: Page, app_url: str, db_ready: None) -> None: page.set_default_timeout(30_000) # Seed all three rows THROUGH THE ADMIN API (before the initial # page read — the panel's first GET must already see the stored # values): the clean repo URL, the plain user, and the token # (whose PUT response is the write-only contract — masked, # value null). client = _admin_client(app_url) _put_setting(client, app_url, "docs_repo", SEED_REPO) _put_setting(client, app_url, "docs_user", SEED_USER) r = _put_setting(client, app_url, "docs_token", SEED_TOKEN) assert r.json()["masked"] is True and r.json()["value"] is None # The API's read side for the new key (the C1 pin — the phase-168 # secret contract, zero extra code): group / label / secret / # value=null / has_value, and the plain user's value. tok = _catalog_item(client, app_url, "docs_token") assert tok["group"] == "Docs push" and tok["label"] == "Docs token" assert tok["secret"] is True and tok["value"] is None assert tok["has_value"] is True and tok["has_override"] is True user = _catalog_item(client, app_url, "docs_user") assert user["secret"] is False and user["value"] == SEED_USER repo = _catalog_item(client, app_url, "docs_repo") assert repo["value"] == SEED_REPO # The UI: sign in → the ``Docs push`` group renders with the # three rows in the catalogue's order… login(page, app_url, next="/settings.html") expect(page.locator("#settings-content")).to_be_visible(timeout=15_000) expect( page.locator("tr.settings-group-row", has_text="Docs push") ).to_be_visible() # …the ``Docs repo`` TEXT row (the clean URL, DB badge)… repo_row = page.locator('tr[data-key="docs_repo"]') expect(repo_row.locator(".cell-clip")).to_have_text("Docs repo") repo_input = repo_row.locator(".settings-control") assert repo_input.get_attribute("type") == "text" expect(repo_input).to_have_value(SEED_REPO) expect(repo_row.locator(".settings-src")).to_have_text("DB") # …the ``Docs user`` TEXT row (the test-seed value, DB badge)… user_row = page.locator('tr[data-key="docs_user"]') expect(user_row.locator(".cell-clip")).to_have_text("Docs user") user_input = user_row.locator(".settings-control") assert user_input.get_attribute("type") == "text" expect(user_input).to_have_value(SEED_USER) expect(user_row.locator(".settings-src")).to_have_text("DB") # …and the ``Docs token`` row that is WRITE-ONLY: type=password, # NOT pre-filled (the stored value never reaches the input), # rendered in the phase-168 secret-row style (the "(stored)" # placeholder, the aria-label naming the env var), with the # is-set state visible (the DB badge + a live Reset). tok_row = page.locator('tr[data-key="docs_token"]') expect(tok_row.locator(".cell-clip")).to_have_text("Docs token") tok_input = tok_row.locator(".settings-control") assert tok_input.get_attribute("type") == "password" assert tok_input.input_value() == "" assert tok_input.get_attribute("placeholder") == STORED_PLACEHOLDER assert tok_input.get_attribute("aria-label") == "BOR_DOCS_TOKEN" expect(tok_row.locator(".settings-src")).to_have_text("DB") expect(tok_row.locator(".settings-reset")).to_be_enabled() # The seeded token text is ABSENT from the initial DOM, the # served HTML, and the raw API body (the LEAK CLASS — asserted # on the raw text, not the parsed model). assert SEED_TOKEN not in page.content(), "the seeded token leaked into the page" raw_html = client.get(app_url + "/settings.html", timeout=10).text assert SEED_TOKEN not in raw_html, "the seeded token leaked into the served HTML" raw_api = client.get(f"{app_url}/api/admin/settings", timeout=30.0) assert SEED_TOKEN not in raw_api.text, "the seeded token leaked into the API body" # --------------------------------------------------------------------------- # 2. The core assertion: a saved token stays masked — save, reload, # the sentinel is absent from the entire page DOM # --------------------------------------------------------------------------- def test_save_stays_masked(page: Page, app_url: str, db_ready: None) -> None: page.set_default_timeout(30_000) login(page, app_url, next="/settings.html") expect(page.locator("#settings-content")).to_be_visible(timeout=15_000) client = _admin_client(app_url) # The row starts NOT set (the module's docs base is set-but- # empty): an empty password input, the constant "(stored)" # placeholder, and a DISABLED Reset (the "no saved override" # title — the phase-168 not-set marker). tok_row = page.locator('tr[data-key="docs_token"]') tok_input = tok_row.locator(".settings-control") assert tok_input.input_value() == "" expect(tok_row.locator(".settings-reset")).to_be_disabled() expect(tok_row.locator(".settings-src")).to_have_text("env") # Type a unique sentinel into the ``Docs token`` row → Save… sentinel = _unique_sentinel() page.fill('tr[data-key="docs_token"] .settings-control', sentinel) expect(page.locator("#settings-dirty")).to_have_text("1 unsaved changes") page.click("#settings-save") expect(page.locator("#settings-status")).to_contain_text( "Saved 1 settings.", timeout=30_000 ) # …the row re-renders masked: the input is EMPTY again (the # typed value was sent, never painted back), the placeholder # stands, and the is-set state is visible (DB badge + a live # Reset). assert tok_input.input_value() == "" assert tok_input.get_attribute("placeholder") == STORED_PLACEHOLDER expect(tok_row.locator(".settings-src")).to_have_text("DB") expect(tok_row.locator(".settings-reset")).to_be_enabled() assert sentinel not in page.content(), "the sentinel leaked into the page" # Reload /settings.html (the panel re-reads the settled server # state): the row is STILL masked… page.goto(app_url + "/settings.html") expect(page.locator("#settings-content")).to_be_visible(timeout=15_000) tok_input = page.locator('tr[data-key="docs_token"] .settings-control') assert tok_input.input_value() == "" assert tok_input.get_attribute("placeholder") == STORED_PLACEHOLDER # …with the is-set state visible (the DB badge + the live # Reset)… tok_row = page.locator('tr[data-key="docs_token"]') expect(tok_row.locator(".settings-src")).to_have_text("DB") expect(tok_row.locator(".settings-reset")).to_be_enabled() # …and the API agrees (has_value, value null, no override # plaintext). tok = _catalog_item(client, app_url, "docs_token") assert tok["has_value"] is True and tok["value"] is None # THE core assertion: the sentinel is ABSENT from the entire # page DOM — and from the served HTML and the raw API body. assert sentinel not in page.content(), "the sentinel leaked into the reloaded page" raw_html = client.get(app_url + "/settings.html", timeout=10).text assert sentinel not in raw_html, "the sentinel leaked into the served HTML" raw_api = client.get(f"{app_url}/api/admin/settings", timeout=30.0) assert sentinel not in raw_api.text, "the sentinel leaked into the API body" # --------------------------------------------------------------------------- # 3. The Reset unsets: the row returns to the not-set state # --------------------------------------------------------------------------- def test_reset_unsets(page: Page, app_url: str, db_ready: None) -> None: page.set_default_timeout(30_000) login(page, app_url, next="/settings.html") expect(page.locator("#settings-content")).to_be_visible(timeout=15_000) client = _admin_client(app_url) # Set the token from the panel (the same dirty-row flow)… sentinel = _unique_sentinel() page.fill('tr[data-key="docs_token"] .settings-control', sentinel) page.click("#settings-save") expect(page.locator("#settings-status")).to_contain_text( "Saved 1 settings.", timeout=30_000 ) # …then the row's Reset: the status line… page.click('tr[data-key="docs_token"] .settings-reset') expect(page.locator("#settings-status")).to_contain_text( "BOR_DOCS_TOKEN is back to the environment value.", timeout=30_000 ) # …and the row returns to the NOT-SET state: the Reset is # DISABLED with the "no saved override" title (the phase-168 # not-set marker for a secret row — the "(stored)" placeholder # is the frontend's constant for every secret row, set or not, # so the badge + the Reset ARE the state), the badge is back # to env (the module's set-but-empty base), the input is empty, # and the sentinel is gone from the DOM. tok_row = page.locator('tr[data-key="docs_token"]') reset = tok_row.locator(".settings-reset") expect(reset).to_be_disabled() assert "no saved override" in (reset.get_attribute("title") or "") expect(tok_row.locator(".settings-src")).to_have_text("env") assert tok_row.locator(".settings-control").input_value() == "" assert sentinel not in page.content(), "the sentinel survived the reset" # The API agrees: the row is gone (has_override false, the # set-but-empty env base → has_value false, value null). tok = _catalog_item(client, app_url, "docs_token") assert tok["has_override"] is False and tok["has_value"] is False assert tok["value"] is None and tok["source"] == "env" # --------------------------------------------------------------------------- # 4. The C4 guard, UI-side: a credentials-bearing URL 422s, the row # keeps the previous value, and the typed URL never persists # --------------------------------------------------------------------------- def test_creds_url_guard_surfaces_and_stays_clean( page: Page, app_url: str, db_ready: None ) -> None: page.set_default_timeout(30_000) login(page, app_url, next="/settings.html") expect(page.locator("#settings-content")).to_be_visible(timeout=15_000) client = _admin_client(app_url) # Save a CLEAN docs_repo through the panel (the "previous # value")… page.fill('tr[data-key="docs_repo"] .settings-control', CLEAN_REPO) page.click("#settings-save") expect(page.locator("#settings-status")).to_contain_text( "Saved 1 settings.", timeout=30_000 ) # …then type a CREDENTIALS-BEARING URL into the same row and # save: the batch's single PUT hits the phase-173 guard (C4)… page.fill('tr[data-key="docs_repo"] .settings-control', CRED_URL) expect(page.locator("#settings-dirty")).to_have_text("1 unsaved changes") page.click("#settings-save") # …and the 422 surfaces (the phase-168 error-display contract — # the role=alert line names the field AND the replacement). error = page.locator("#settings-error") expect(error).to_have_attribute("role", "alert") expect(error).to_be_visible(timeout=30_000) expect(error).to_contain_text("docs_repo") expect(error).to_contain_text("Docs user / Docs token") # K4: the input echoes the typed value (the pre-save state — # that is fine; the assertion is on the saved/persisted state # after the failed save + reload). expect( page.locator('tr[data-key="docs_repo"] .settings-control') ).to_have_value(CRED_URL) # The failed save stored NOTHING: the API still reports the # clean URL as the value (db — the previously saved row). repo = _catalog_item(client, app_url, "docs_repo") assert repo["value"] == CLEAN_REPO and repo["source"] == "db" # Reload: the row KEEPS the previous (clean) value, and the # typed URL — its ``pass`` segment above all — is ABSENT from # the page DOM and the raw API body (never shown as a saved # value). page.goto(app_url + "/settings.html") expect(page.locator("#settings-content")).to_be_visible(timeout=15_000) expect( page.locator('tr[data-key="docs_repo"] .settings-control') ).to_have_value(CLEAN_REPO) assert CRED_URL not in page.content(), "the credentials-bearing URL persisted" assert "u:pass@" not in page.content(), "the URL's pass segment persisted" raw_api = client.get(f"{app_url}/api/admin/settings", timeout=30.0) assert CRED_URL not in raw_api.text assert "u:pass@" not in raw_api.text