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.
532 lines
24 KiB
Python
532 lines
24 KiB
Python
"""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
|