Files
brain-of-reese/tests/e2e/test_docs_push_creds.py
T
ducoterra a86caec821
Build and Push Containers / build-and-push-app (push) Successful in 1m41s
Build and Push Containers / build-and-push-db (push) Successful in 11s
phase: 173_docs_push_creds_split
All gates green. Final report:

**Phase 173 — final verification pass (all tasks already complete in `complete/`)**

- Verified the full implementation is in the working tree: `docs_user`/`docs_token` settings + catalogue rows (task 01), `docs_repo` PUT 422 guard + `docs_token` write-only pin (task 02), `run_git(env)` + `push_document(user, token)` credential env (task 03), `settings_migrate.py` wired into `create_app()` (task 04), E2E suite (task 05).
- **Completion criteria:**
  1. GET `/api/admin/settings` lists `docs_user` (plain) + `docs_token` (secret, `value=null`), token never in any body — ✅ `test_docs_token_is_write_only_masked_end_to_end` + E2E sentinel-absent-from-DOM
  2. PUT `docs_repo` with `://user:pass@` → 422 naming the fields; clean URL passes — ✅ integration + E2E UI-side
  3. Token set → `GIT_CURL_USERNAME`/`GIT_CURL_PASSWORD`/`GIT_TERMINAL_PROMPT=0` on every git step; empty → `env=None` — ✅ mocked-git unit tests
  4. Legacy env split once at startup, idempotent, one INFO line, `.env` untouched — ✅ `test_settings_migrate.py` (4 shapes) + `test_docs_push_migrate.py`
  5. Full suite green, coverage >90%, lint clean — ✅
  6. E2E isolated: `test_docs_push_creds.py` 4/4; regression set (panel + layout + response_to_docs) 21/21 — ✅
  7. Commit + phase move — harness's job (per instructions, not mine).
  8. README/AGENTS consistent — ✅ (below)
- Tests/lint: `uv run pytest` exit 0 (4112 passed) · `uv run pytest --cov=app --cov-report=term-missing` TOTAL 99% · `uv run ruff check .` clean · `uv run pyright` 0 errors
- Docs: README.md (incl. Docs-push config section, guard/migration paragraph, `.env.example` block) and AGENTS.md verified consistent in full; fixed two stale docstrings in `app/config.py` + `app/core/settings_catalog.py` (`is_set` → the API's actual `has_value` field).
- No live-infrastructure changes (no live step in this phase). No next pending phase — `todo/` held only 173.
2026-10-10 16:47:39 -04:00

532 lines
24 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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