phase: 121_git_source_tokens
Build and Push Containers / build-and-push-app (push) Successful in 2m3s
Build and Push Containers / build-and-push-db (push) Failing after 14s

**Phase 121 final verification pass — all green** (all 4 tasks already in `complete/`; verified, no defects found, no changes needed)

- Verified implementation vs phase design: migration `0021` (reversible, round-tripped via `alembic downgrade base` + `upgrade head` → head `0021`), `GitSource.token` column, `normalize_credential`/`clone_url_for`/`sanitize_url`, clone callers switched (`sync.py`, `import_docs.py`), masked token fields in add form + editor, `extra="forbid"` output shapes
- Tests: `uv run pytest` → 2662 passed, 0 failed (exit 0); `uv run pytest --cov=app --cov-report=term-missing` → TOTAL **99%** (≥90% gate)
- Lint/types: `uv run ruff check .` → All checks passed; `uv run pyright` → 0 errors, 0 warnings
- E2E in isolation: `uv run pytest tests/e2e/test_git_source_tokens.py -v --no-cov` → **4 passed**

Completion criteria:
1. Private repo (UI add or pasted embedded-token URL) clones with injected token; token absent from every API response, page text, title attr, and full HTML — **PASS** (integration raw-JSON assertions + E2E `_assert_token_nowhere`)
2. Legacy embedded-token rows still clone from stored URL; output sanitized — **PASS** (`test_sync_legacy_row_clones_with_original_stored_url`, `test_get_masks_legacy_embedded_token_row`, env-fallback masking)
3. Public/local sources byte-identical — **PASS** (verbatim-URL + no-userinfo-unchanged tests)
4. pytest / coverage / ruff / pyright — **PASS** (see above)
5. Commit + phase move — harness responsibility; task files already in `complete/`, changes left in working tree (no commit made, per protocol)

Notable: no deviations; DB left at head, functional. Next pending phase: **122_image_documents** (then 123_chat_image_questions).
This commit is contained in:
2026-09-24 20:51:39 -04:00
parent 3a0fc3db05
commit 0f77e9a876
35 changed files with 2894 additions and 48 deletions
+429
View File
@@ -0,0 +1,429 @@
"""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)
+472
View File
@@ -45,9 +45,23 @@ Contract under test:
unchanged.
``git_sources`` is global state: truncated around every test.
Phase 121 (task 02 — clone-time credential + output sanitization):
POST normalizes an old-style embedded ``user:pass@`` URL into the bare
URL + ``token`` column (explicit ``token`` wins — LOCKED A6), the
duplicate check runs on the bare URL, the PATCH token is tri-state
(absent/None = no change, non-empty = replace, "" = clear) and
re-normalizes a legacy row's URL, and every response (list DB rows,
list env rows, POST 201, PATCH 200) is token-free — asserted on the
RAW response text — while a token row's SYNC clone receives the
injected ``https://x-access-token:<token>@…`` URL with a
credential-free checkout path (mock ``clone_or_pull``) and a legacy
row's clone still receives its ORIGINAL stored URL (the credential
keeps working).
"""
from __future__ import annotations
import time
import uuid
from collections.abc import Iterator
from datetime import UTC, datetime, timedelta
@@ -60,8 +74,12 @@ from sqlalchemy import select, text
from sqlalchemy.orm import Session
from app.api import git_sources as git_sources_api
from app.api import sync as sync_api
from app.config import Settings
from app.main import app as fastapi_app
from app.models import GitSource
from app.rag import git_sources as git_sources_resolver
from app.rag.importer import ImportSummary
@pytest.fixture(autouse=True)
@@ -949,3 +967,457 @@ def test_patch_422_does_not_half_apply_flag(admin_client: TestClient) -> None:
row = admin_client.get("/api/git-sources").json()["sources"][0]
assert row["ignore_paths"] == ["keep"]
assert row["include_hidden"] is False
# --- token: write-path normalization (phase 121, task 02) ------------------
TOKEN = "ghp_phase121secrettoken"
def test_post_with_token_stores_column_and_never_echoes(
admin_client: TestClient, db: Session
) -> None:
"""Bare URL + masked token → the token is stored in the dedicated
column and appears in NO API response — asserted on the RAW text of
both the 201 and the GET list (the response shapes have no token
field by contract; this pins that nothing else smuggles it out)."""
r = admin_client.post(
"/api/git-sources",
json={"url": "https://github.com/owner/private.git", "token": TOKEN},
)
assert r.status_code == 201, r.text
body = r.json()
assert body["url"] == "https://github.com/owner/private.git"
assert set(body) == {"id", "url", "added_at", "ignore_paths", "include_hidden"}
assert TOKEN not in r.text
row = db.scalars(select(GitSource)).one()
assert row.url == "https://github.com/owner/private.git" # bare
assert row.token == TOKEN # the column, not the URL
r = admin_client.get("/api/git-sources")
assert r.status_code == 200
assert TOKEN not in r.text
assert r.json()["sources"][0]["url"] == "https://github.com/owner/private.git"
def test_post_embedded_token_url_normalizes_to_bare_plus_column(
admin_client: TestClient, db: Session
) -> None:
"""The TODO L5 paste — old-style ``user:token@`` URL with no token
field: stored BARE, the embedded PASSWORD part moved to the token
column, and every response is token-free (raw text)."""
r = admin_client.post(
"/api/git-sources",
json={"url": f"https://myuser:{TOKEN}@github.com/owner/private-repo.git"},
)
assert r.status_code == 201, r.text
assert r.json()["url"] == "https://github.com/owner/private-repo.git"
assert TOKEN not in r.text
assert "myuser" not in r.text
row = db.scalars(select(GitSource)).one()
assert row.url == "https://github.com/owner/private-repo.git"
assert row.token == TOKEN # the password part, not the whole userinfo
r = admin_client.get("/api/git-sources")
assert TOKEN not in r.text
assert r.json()["sources"][0]["url"] == "https://github.com/owner/private-repo.git"
def test_post_explicit_token_wins_over_embedded(
admin_client: TestClient, db: Session
) -> None:
"""LOCKED A6 — the masked field and the pasted URL disagree: the
explicit field is the intent and beats the embedded credential."""
r = admin_client.post(
"/api/git-sources",
json={
"url": f"https://myuser:{TOKEN}@github.com/owner/private.git",
"token": "ghp_explicitwins",
},
)
assert r.status_code == 201, r.text
assert r.json()["url"] == "https://github.com/owner/private.git"
row = db.scalars(select(GitSource)).one()
assert row.url == "https://github.com/owner/private.git"
assert row.token == "ghp_explicitwins"
def test_post_username_as_token_form_moves_whole_run(
admin_client: TestClient, db: Session
) -> None:
"""The documented GitHub shape (no colon) — the whole userinfo run
is the credential."""
r = admin_client.post(
"/api/git-sources",
json={"url": f"https://{TOKEN}@github.com/owner/private.git"},
)
assert r.status_code == 201, r.text
row = db.scalars(select(GitSource)).one()
assert row.url == "https://github.com/owner/private.git"
assert row.token == TOKEN
def test_post_same_repo_different_token_is_409_on_bare_url(
admin_client: TestClient, db: Session
) -> None:
"""The duplicate check runs on the NORMALIZED bare URL: the same
repo pasted with a different credential is the same source — 409,
not a second row (both via embedded and via explicit token)."""
assert (
admin_client.post(
"/api/git-sources",
json={"url": f"https://user1:{TOKEN}@github.com/owner/dup.git"},
).status_code
== 201
)
r = admin_client.post(
"/api/git-sources",
json={"url": "https://user2:other-cred@github.com/owner/dup.git"},
)
assert r.status_code == 409
assert r.json()["detail"] == "a git source with this URL already exists"
assert TOKEN not in r.text
# And the bare form of the same repo 409s too.
r = admin_client.post(
"/api/git-sources",
json={"url": "https://github.com/owner/dup.git", "token": "another"},
)
assert r.status_code == 409
assert db.execute(text("SELECT count(*) FROM git_sources")).scalar_one() == 1
def test_post_local_kind_token_is_inert_and_never_echoed(
admin_client: TestClient, tmp_path: Path, db: Session
) -> None:
"""Local-kind sources have no URL credential (the design does not
touch them): a token on a local row is stored inert (never used —
local rows are walked, not cloned) and, like on git rows, never
echoed."""
real_dir = tmp_path / "local-tok"
real_dir.mkdir()
r = admin_client.post(
"/api/git-sources",
json={"kind": "local", "path": str(real_dir), "token": TOKEN},
)
assert r.status_code == 201, r.text
assert TOKEN not in r.text
assert r.json()["url"] == str(real_dir)
row = db.scalars(select(GitSource)).one()
assert row.token == TOKEN # inert — clone_url_for never sees it
assert TOKEN not in admin_client.get("/api/git-sources").text
# --- token: PATCH tri-state (phase 121, task 02) ---------------------------
def test_patch_token_replace_clear_and_noop(
admin_client: TestClient, db: Session
) -> None:
"""The tri-state on a clean-URL row: absent/None = no change,
non-empty = replace, "" = clear (stored NULL) — the token is never
in any response (raw text)."""
created = admin_client.post(
"/api/git-sources",
json={"url": "https://github.com/owner/patch.git", "token": TOKEN},
)
assert created.status_code == 201
sid = created.json()["id"]
# Absent (and explicit None) = no change.
# The endpoint commits in its own session — expire this one's
# identity map before reading the row back (the house pattern for
# cross-session reads).
def stored() -> GitSource:
db.expire_all()
row = db.get(GitSource, uuid.UUID(sid))
assert row is not None
return row
r = admin_client.patch(f"/api/git-sources/{sid}", json={"ignore_paths": ["a"]})
assert r.status_code == 200, r.text
assert TOKEN not in r.text
assert r.json()["url"] == "https://github.com/owner/patch.git"
assert stored().token == TOKEN
r = admin_client.patch(
f"/api/git-sources/{sid}", json={"ignore_paths": None, "token": None}
)
assert r.status_code == 200
assert stored().token == TOKEN
# Non-empty = replace (the URL is untouched — a clean URL comes
# back byte-identical).
r = admin_client.patch(f"/api/git-sources/{sid}", json={"token": "ghp_replaced"})
assert r.status_code == 200, r.text
assert TOKEN not in r.text
assert "ghp_replaced" not in r.text
assert r.json()["url"] == "https://github.com/owner/patch.git"
row = stored()
assert row.url == "https://github.com/owner/patch.git"
assert row.token == "ghp_replaced"
# "" = clear — stored NULL.
r = admin_client.patch(f"/api/git-sources/{sid}", json={"token": ""})
assert r.status_code == 200, r.text
row = stored()
assert row.token is None
assert r.json()["url"] == "https://github.com/owner/patch.git"
def test_patch_token_on_legacy_row_renormalizes_url(
admin_client: TestClient, db: Session
) -> None:
"""A pre-phase row (credential embedded in the stored URL, token
NULL) gets its userinfo stripped (moved to the column) the first
time an explicit credential is written — and a "" clear also moves
it (a cleared credential is gone from both the column and the URL;
the owner explicitly asked for no credential)."""
legacy = f"https://user:{TOKEN}@github.com/owner/legacy.git"
db.add(GitSource(url=legacy))
db.commit()
row = db.scalars(select(GitSource)).one()
sid = row.id
r = admin_client.patch(f"/api/git-sources/{sid}", json={"token": "ghp_new"})
assert r.status_code == 200, r.text
assert TOKEN not in r.text
assert "ghp_new" not in r.text
assert r.json()["url"] == "https://github.com/owner/legacy.git"
db.expire_all() # the endpoint committed in its own session
row = db.get(GitSource, sid)
assert row is not None
assert row.url == "https://github.com/owner/legacy.git" # normalized bare
assert row.token == "ghp_new"
# "" clears the (now column) credential — the URL stays bare.
r = admin_client.patch(f"/api/git-sources/{sid}", json={"token": ""})
assert r.status_code == 200, r.text
db.expire_all()
row = db.get(GitSource, sid)
assert row is not None
assert row.url == "https://github.com/owner/legacy.git"
assert row.token is None
def test_patch_token_409_backstop_on_renormalized_collision(
admin_client: TestClient, db: Session
) -> None:
"""A legacy embedded row + a bare row for the same repo can only
coexist pre-phase; re-normalizing the legacy row's URL on a token
PATCH makes the stored URLs collide — the unique index yields the
generic 409 (never a 500) and the failed PATCH leaves the row
untouched."""
legacy = f"https://user:{TOKEN}@github.com/owner/collide.git"
bare = "https://github.com/owner/collide.git"
db.add_all([GitSource(url=legacy), GitSource(url=bare)])
db.commit()
legacy_row = db.scalars(select(GitSource).where(GitSource.url == legacy)).one()
r = admin_client.patch(f"/api/git-sources/{legacy_row.id}", json={"token": "ghp_x"})
assert r.status_code == 409
assert r.json()["detail"] == "a git source with this URL already exists"
assert TOKEN not in r.text
# The rollback left the legacy row exactly as it was.
db.expire_all()
row = db.get(GitSource, legacy_row.id)
assert row is not None
assert row.url == legacy
assert row.token is None
# --- token: output sanitization (phase 121, task 02) ------------------------
def test_get_masks_legacy_embedded_token_row(admin_client: TestClient, db: Session) -> None:
"""Completion criterion: a legacy row (token embedded in the
stored URL, token column NULL) clones with its original URL but
its API output is token-free — the stored DB value is untouched,
the response is bare."""
legacy = f"https://user:{TOKEN}@github.com/owner/legacy.git"
db.add(GitSource(url=legacy))
db.commit()
r = admin_client.get("/api/git-sources")
assert r.status_code == 200
assert TOKEN not in r.text
assert "user:" not in r.text
assert r.json()["sources"][0]["url"] == "https://github.com/owner/legacy.git"
# The stored value is untouched (the clone still authenticates).
assert db.scalars(select(GitSource)).one().url == legacy
def test_get_masks_env_fallback_rows_with_embedded_token(
admin_client: TestClient, monkeypatch: pytest.MonkeyPatch
) -> None:
"""An env URL can embed a token too — the ENV VALUE itself is
untouched (the config is the operator's), only the response is
masked."""
monkeypatch.setattr(
git_sources_api,
"get_settings",
lambda: _settings(f"https://user:{TOKEN}@env.example.com/env.git"),
)
r = admin_client.get("/api/git-sources")
assert r.status_code == 200
assert TOKEN not in r.text
assert r.json()["from_env"] is True
assert r.json()["sources"][0]["url"] == "https://env.example.com/env.git"
def test_get_clean_urls_are_byte_identical(admin_client: TestClient, db: Session) -> None:
"""The phase-50/35 contract through the mask: credential-free
stored URLs (including ``git@`` and local paths) surface verbatim."""
urls = [
"https://github.com/owner/clean.git",
"git@github.com:owner/scp.git",
"ssh://git@example.com/repo.git",
]
base = datetime.now(UTC) - timedelta(hours=1)
# Distinct added_at: same-timestamp rows order by random uuid4 id.
db.add_all(
GitSource(url=u, added_at=base + timedelta(minutes=i)) for i, u in enumerate(urls)
)
db.commit()
body = admin_client.get("/api/git-sources").json()
assert [s["url"] for s in body["sources"]] == urls
# --- token: the sync clone URL (phase 121, task 02) -------------------------
@pytest.fixture()
def sync_admin_client() -> Iterator[TestClient]:
"""A context-managed, logged-in client — one app event loop across
requests (the sync background task must survive between the POST
and the polls; the test_sync_api.py pattern)."""
with TestClient(fastapi_app) as client:
r = client.post("/api/login", json={"password": "test-admin-password"})
assert r.status_code == 204
yield client
def _stub_sync_stack(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> list[tuple[str, Path]]:
"""The sync pipeline minus the real git/import/LLM layers (the
test_sync_api.py pattern): a fresh settings dir, an empty env list
(the DB row wins), a no-op model probe, a zero-count import
(change-gated steps skipped), and a recording clone.
Returns the clone call list."""
monkeypatch.setattr(
sync_api,
"get_settings",
lambda: Settings(
_env_file=None, # pyright: ignore[reportCallIssue]
sources_dir=str(tmp_path / "sources"),
),
)
monkeypatch.setattr(
git_sources_resolver,
"get_settings",
lambda: Settings(_env_file=None, git_sources=""), # pyright: ignore[reportCallIssue]
)
async def fake_check_models(llm: object) -> None:
return None
monkeypatch.setattr(sync_api, "check_models", fake_check_models)
clone_calls: list[tuple[str, Path]] = []
def fake_clone_or_pull(url: str, dest: Path | str) -> Path:
dest = Path(dest)
dest.mkdir(parents=True, exist_ok=True)
clone_calls.append((url, dest))
return dest
monkeypatch.setattr(sync_api, "clone_or_pull", fake_clone_or_pull)
async def fake_import_sources(*args: object, **kwargs: object) -> ImportSummary:
return ImportSummary() # all-zero: the change-gated steps skip
monkeypatch.setattr(sync_api, "import_sources", fake_import_sources)
# The unchanged-walk gap probe: no candidates → no generator call
# (hermetic — the real probe reads the global KB state, and the
# generator would burn real lite calls).
monkeypatch.setattr(sync_api, "missing_folder_summaries", lambda db: [])
return clone_calls
def _poll_sync(client: TestClient, want: str, timeout: float = 5.0) -> dict:
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
body = client.get("/api/sync/status").json()
if body["state"] == want:
return body
assert body["state"] == "running", body
time.sleep(0.02)
raise AssertionError(f"sync did not reach {want!r} in {timeout}s")
def test_sync_token_row_clones_with_injected_url_and_bare_checkout(
sync_admin_client: TestClient, db: Session, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""A token row's sync: ``clone_or_pull`` receives the injected
``https://x-access-token:<token>@…`` URL, the checkout directory is
derived from the BARE URL (credential-free), and no token leaks
into the status surface (raw text)."""
db.add(GitSource(url="https://github.com/owner/priv.git", token=TOKEN))
db.commit()
clone_calls = _stub_sync_stack(monkeypatch, tmp_path)
assert sync_admin_client.post("/api/sync").status_code == 202
body = _poll_sync(sync_admin_client, "success")
assert body["error"] is None
assert TOKEN not in str(body)
assert len(clone_calls) == 1
clone_url, dest = clone_calls[0]
assert clone_url == f"https://x-access-token:{TOKEN}@github.com/owner/priv.git"
# The checkout name is the bare repo name — no userinfo, no token.
assert dest.name == "priv"
assert dest == tmp_path / "sources" / "priv"
assert TOKEN not in str(dest)
def test_sync_legacy_row_clones_with_original_stored_url(
sync_admin_client: TestClient, db: Session, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""Completion criterion: a pre-phase row (credential embedded in
the stored URL, token NULL) produces the ORIGINAL stored URL at
clone time — the credential keeps working — and the checkout name
is still the credential-free repo name (repo_name on the bare-URL
semantics: the basename after the last / of the stored URL)."""
stored = f"https://user:{TOKEN}@github.com/owner/priv.git"
db.add(GitSource(url=stored))
db.commit()
clone_calls = _stub_sync_stack(monkeypatch, tmp_path)
assert sync_admin_client.post("/api/sync").status_code == 202
body = _poll_sync(sync_admin_client, "success")
assert body["error"] is None
assert len(clone_calls) == 1
clone_url, dest = clone_calls[0]
assert clone_url == stored # the original stored URL, verbatim
assert dest.name == "priv"
def test_sync_public_row_clones_with_verbatim_url(
sync_admin_client: TestClient, db: Session, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""Public repos behave byte-identically to pre-phase: a NULL-token
row's clone URL is the stored URL itself (no injection)."""
url = "https://github.com/owner/public.git"
db.add(GitSource(url=url))
db.commit()
clone_calls = _stub_sync_stack(monkeypatch, tmp_path)
assert sync_admin_client.post("/api/sync").status_code == 202
assert _poll_sync(sync_admin_client, "success")["error"] is None
assert clone_calls == [(url, tmp_path / "sources" / "public")]
+280
View File
@@ -0,0 +1,280 @@
"""Integration: migration 0021 (git_sources.token) schema contract
(phase 121, task 01).
Drives the **real Alembic engine** against the live dev database
(``podman compose up -d db``), mirroring the house pattern of
``test_migration_0020.py`` (information_schema assertions on the state
the migration must leave). The tests target the 0020 → 0021 step
explicitly so later migrations cannot break the pins:
* upgrade 0020 → 0021 → ``token`` exists with the full contract —
TEXT, NULLABLE, no server default — while the 0020 ``git_sources``
schema (``url`` NOT NULL + the unique index, ``kind``, ``path``,
``ignore_paths``, ``include_hidden``, ``added_at``) survives;
* a ``git_sources`` row inserted while the DB is at 0020 backfills
``token`` to NULL (a pre-phase-121 row is a public repo — or a
legacy embedded-token row whose credential lives in ``url``);
* the ORM contract agrees: a freshly inserted ``GitSource`` with an
explicit ``token`` round-trips it through a fresh session, and a row
without one reads ``token is None``;
* downgrade to 0020 → the column is GONE (A13) while the row + its
``url`` survive; upgrade back to 0021 → the column is back
(round-trip).
The ``alembic`` fixture guarantees the DB ends at head even if a test
fails or the process is interrupted.
"""
from __future__ import annotations
import uuid
from collections.abc import Iterator
from typing import Any
import pytest
from alembic.config import Config
from sqlalchemy import text
from sqlalchemy.orm import Session
from alembic import command
from app.db import SessionLocal, db_available
from app.models import GitSource
URL_BARE = "https://github.com/mig0021/bare.git"
URL_TOKENED = "https://github.com/mig0021/tokened.git"
TOKEN = "ghp_mig0021secret"
@pytest.fixture()
def alembic(db: Session) -> Iterator[Config]:
"""Real Alembic config bound to the dev DB (URL from app settings).
Starts at head (repairs an interrupted earlier run); teardown
upgrades to head no matter what happened, so the dev DB is never
left below head.
"""
if not db_available():
pytest.skip("Postgres not reachable — run `podman compose up -d db` first")
cfg = Config() # no alembic.ini file — env.py gets the URL from app config
cfg.set_main_option("script_location", "alembic")
command.upgrade(cfg, "head")
try:
yield cfg
finally:
# Release the test session's open transaction BEFORE the repair
# DDL: an idle-in-transaction SELECT holds an ACCESS SHARE lock
# on ``git_sources``, which would deadlock the repair's
# ``ALTER TABLE`` (0021) forever.
db.rollback()
command.upgrade(cfg, "head")
def _version(db: Session) -> str | None:
return db.execute(text("SELECT version_num FROM alembic_version")).scalar()
def _column(db: Session, column: str) -> tuple[Any, ...] | None:
"""(data_type, is_nullable, column_default) for one git_sources
column."""
row = db.execute(
text(
"SELECT data_type, is_nullable, column_default"
" FROM information_schema.columns"
" WHERE table_name = 'git_sources' AND column_name = :c"
),
{"c": column},
).fetchone()
return tuple(row) if row is not None else None
def _insert_sql(db: Session, url: str) -> uuid.UUID:
"""Insert one git_sources row with the PRE-0021 column set (the
0020 shape — the token column, when present, is omitted so a NULL
backfill is what the row reads)."""
row_id = uuid.uuid4()
db.execute(
text(
"INSERT INTO git_sources (id, url, kind, path, ignore_paths,"
" include_hidden)"
" VALUES (:id, :u, 'git', NULL, '[]', false)"
),
{"id": row_id, "u": url},
)
db.commit()
return row_id
def _delete(db: Session, row_id: uuid.UUID) -> None:
db.execute(text("DELETE FROM git_sources WHERE id = :id"), {"id": row_id})
db.commit()
def test_upgrade_to_0021_adds_token(db: Session, alembic: Config) -> None:
"""Upgrade 0020 → 0021: ``token`` exists with the full contract
(TEXT, NULLABLE, no server default — NULL = public/legacy row), is
ABSENT at 0020, a pre-0021 row backfills ``token`` to NULL, and an
omitted token on a new row stays NULL — while the 0020 table
contract (``url`` + unique index, ``kind``, ``ignore_paths``,
``include_hidden``, ``added_at``) survives."""
command.downgrade(alembic, "0020") # start from the pre-0021 state
assert _version(db) == "0020"
assert _column(db, "token") is None, "token must be absent at 0020"
pre_id = _insert_sql(db, URL_BARE) # the 0020 column set
try:
command.upgrade(alembic, "0021")
assert _version(db) == "0021", "alembic_version must be at 0021"
token = _column(db, "token")
assert token is not None, "git_sources.token is missing"
assert token[0] == "text", "token must be TEXT"
assert token[1] == "YES", "token must be NULLABLE"
assert token[2] is None, (
"token must carry NO server default — NULL is the"
" public/legacy value"
)
# The pre-0021 row backfilled to NULL (a public repo, or a
# legacy embedded-token row whose credential lives in url).
row = db.execute(
text("SELECT url, token FROM git_sources WHERE id = :id"),
{"id": pre_id},
).fetchone()
assert row is not None and row[0] == URL_BARE, (
"the pre-0021 row must survive the upgrade"
)
assert row[1] is None, "the backfilled token must be NULL"
# A row written without the column stays NULL (no default to
# fill it in — the Python-side default is None, same value).
new_id = _insert_sql(db, URL_TOKENED)
try:
tokened = db.execute(
text("SELECT token FROM git_sources WHERE id = :id"),
{"id": new_id},
).scalar()
assert tokened is None, "an omitted token must stay NULL"
finally:
_delete(db, new_id)
# The 0020 schema survives the additive upgrade.
url = _column(db, "url")
assert url is not None and url[0] == "text" and url[1] == "NO", (
"git_sources.url (0006) must keep its 0020 contract"
)
kind = _column(db, "kind")
assert kind is not None and kind[0] == "text" and kind[1] == "NO", (
"git_sources.kind (0007) must survive the upgrade"
)
ignore = _column(db, "ignore_paths")
assert ignore is not None and ignore[0] == "jsonb" and ignore[1] == "NO", (
"git_sources.ignore_paths (0013) must survive the upgrade"
)
hidden = _column(db, "include_hidden")
assert hidden is not None and hidden[0] == "boolean" and hidden[1] == "NO", (
"git_sources.include_hidden (0019) must survive the upgrade"
)
added = _column(db, "added_at")
assert added is not None and added[0] == "timestamp with time zone", (
"git_sources.added_at (0006) must survive the upgrade"
)
assert added[1] == "NO" and "now()" in str(added[2]), (
"git_sources.added_at must keep its `now()` server default"
)
index = db.execute(
text(
"SELECT indexname FROM pg_indexes"
" WHERE tablename = 'git_sources'"
" AND indexname = 'uq_git_sources_url'"
)
).scalar()
assert index is not None, (
"the uq_git_sources_url unique index must survive the upgrade"
)
finally:
_delete(db, pre_id)
def test_orm_token_round_trips(db: Session, alembic: Config) -> None:
"""The ORM contract agrees with the column contract: a freshly
inserted ``GitSource`` with an explicit ``token`` round-trips the
credential through a FRESH session, and a row without one reads
``token is None`` (the NULL public/legacy state)."""
command.upgrade(alembic, "head")
row_tokened = GitSource(url=URL_TOKENED, kind="git", token=TOKEN)
row_bare = GitSource(url=URL_BARE, kind="git")
db.add(row_tokened)
db.add(row_bare)
db.commit()
try:
with SessionLocal() as fresh:
reloaded_tokened = fresh.get(GitSource, row_tokened.id)
assert reloaded_tokened is not None, "the tokened row must be readable"
assert reloaded_tokened.token == TOKEN, (
"the explicit token must round-trip through the DB"
)
reloaded_bare = fresh.get(GitSource, row_bare.id)
assert reloaded_bare is not None, "the bare row must be readable"
assert reloaded_bare.token is None, (
"a row without a token must read token is None"
)
finally:
_delete(db, row_tokened.id)
_delete(db, row_bare.id)
def test_downgrade_to_0020_drops_the_column(db: Session, alembic: Config) -> None:
"""Downgrade 0021 → 0020: the token column is gone (A13 — fully
reversible) while the row + its ``url`` survive, and the rest of
the 0020 table contract (``url`` unique index, ``kind``,
``added_at``) is intact."""
command.upgrade(alembic, "head")
row = GitSource(url=URL_TOKENED, kind="git", token=TOKEN)
db.add(row)
db.commit()
try:
command.downgrade(alembic, "0020")
assert _version(db) == "0020"
assert _column(db, "token") is None, "token must be dropped"
surviving = db.execute(
text(
"SELECT url, kind, ignore_paths, include_hidden, added_at"
" FROM git_sources WHERE id = :id"
),
{"id": row.id},
).fetchone()
assert surviving is not None and surviving[0] == URL_TOKENED, (
"the row must survive the column drop"
)
assert surviving[1] == "git" and surviving[2] == [] and surviving[3] is False, (
"kind + ignore_paths + include_hidden must survive the drop"
)
assert surviving[4] is not None, "added_at must survive the drop"
index = db.execute(
text(
"SELECT indexname FROM pg_indexes"
" WHERE tablename = 'git_sources'"
" AND indexname = 'uq_git_sources_url'"
)
).scalar()
assert index is not None, "the unique index must survive the downgrade"
finally:
_delete(db, row.id)
# Repair: the fixture teardown re-upgrades to head.
def test_upgrade_round_trip_restores_the_column(db: Session, alembic: Config) -> None:
"""Downgrade to 0020, then upgrade back to 0021: ``token`` is back
with the full contract (TEXT, NULLABLE, no server default)."""
command.downgrade(alembic, "0020")
command.upgrade(alembic, "0021")
assert _version(db) == "0021", "round-trip upgrade must land at 0021"
token = _column(db, "token")
assert token is not None, "git_sources.token must be back"
assert token[0] == "text", "token must be TEXT after the round-trip"
assert token[1] == "YES", "token must be NULLABLE after the round-trip"
assert token[2] is None, (
"token must still carry NO server default after the round-trip"
)
+641
View File
@@ -0,0 +1,641 @@
"""Unit: the git-source token schema surface (phase 121, task 01).
Task 01 lands the STORAGE only: the write-side input shapes can carry
the masked credential, and the output shapes structurally cannot.
* ``GitSourceIn.token`` / ``GitSourcePatchIn.token`` — optional
``str | None`` (absent/None = no credential / no change), trimmed
*before* the max-500 length constraint runs (the ``_trim_url``
precedent), ``None`` passing through untouched;
* ``GitSourcePatchIn.token`` — the tri-state documented contract:
absent/None = no change, non-empty = replace, empty string = clear;
* ``GitSourceOut`` / ``GitSourceRow`` — NO ``token`` field (LOCKED A2):
the credential never reaches the UI or any API output, and
``extra="forbid"`` makes the omission structural — constructing an
output model with a ``token`` key (kwarg OR dict) raises, so a
regression that tries to echo the credential back cannot even build
the shape (task 04 finalizes this file with the E2E-level pins).
Task 02 lands the CLONE + SANITIZATION mechanics (pure helpers, no
DB):
* ``sanitize_url`` — strips the userinfo of ``https?://`` URLs
(``user:pass@`` and the username-as-token form), leaves ``ssh://`` /
``git@`` / local paths untouched, is idempotent, and is
byte-identical for credential-free URLs (anchored regex, never a
URL parser re-serialization);
* ``clone_url_for`` — NULL/falsy token → the bare stored URL verbatim
(public + legacy rows clone exactly as pre-phase), https? row with
a token → ``https://x-access-token:<token>@…`` (any existing
userinfo replaced by the column credential), non-https row with a
token → the URL unchanged + a warning log (no crash);
* ``normalize_credential`` — embedded userinfo moves to the token
column (password part of ``user:pass``; the whole run for the
username-as-token form), an explicit token (even "") wins (LOCKED
A6), clean URLs + ssh/``git@``/local paths come back untouched.
"""
from __future__ import annotations
import re
import uuid
from pathlib import Path
import pytest
from pydantic import ValidationError
from app.models import GitSource
from app.rag.git_sources import clone_url_for, normalize_credential, sanitize_url
from app.schemas import GitSourceIn, GitSourceOut, GitSourcePatchIn, GitSourceRow
URL = "https://github.com/owner/repo.git"
# ---------------------------------------------------------------------------
# GitSourceIn — write side: accepts + trims the token
# ---------------------------------------------------------------------------
def test_git_source_in_token_defaults_to_none() -> None:
"""Absent token = no credential (public repo) — the pre-phase-121
body shape still validates unchanged."""
payload = GitSourceIn(url=URL)
assert payload.token is None
def test_git_source_in_token_accepted() -> None:
payload = GitSourceIn(url=URL, token="ghp_secret123")
assert payload.token == "ghp_secret123"
def test_git_source_in_token_trimmed_before_length_constraints() -> None:
"""The ``_trim_url`` precedent: surrounding whitespace is stripped
before the max-500 constraint runs."""
payload = GitSourceIn(url=URL, token=" ghp_secret123 ")
assert payload.token == "ghp_secret123"
def test_git_source_in_token_whitespace_only_becomes_empty() -> None:
"""Trimmed to ``""`` — valid (create-time has no min_length): the
caller sent a blank masked field, i.e. no credential."""
payload = GitSourceIn(url=URL, token=" ")
assert payload.token == ""
def test_git_source_in_token_at_cap_validates() -> None:
token = "x" * 500
payload = GitSourceIn(url=URL, token=token)
assert payload.token == token
def test_git_source_in_token_one_over_cap_rejects() -> None:
with pytest.raises(ValidationError) as exc:
GitSourceIn(url=URL, token="x" * 501)
assert exc.value.errors()[0]["loc"] == ("token",)
def test_git_source_in_pre_phase_fields_still_validate() -> None:
"""The pre-phase-121 body (url + ignore_paths + include_hidden) is
unchanged — the new field is purely additive."""
payload = GitSourceIn(url=URL, ignore_paths=["docs/private"], include_hidden=True)
assert payload.url == URL
assert payload.ignore_paths == ["docs/private"]
assert payload.include_hidden is True
assert payload.token is None
# ---------------------------------------------------------------------------
# GitSourcePatchIn — tri-state: absent/None no change, non-empty replace,
# empty string clear
# ---------------------------------------------------------------------------
def test_git_source_patch_in_token_defaults_to_none() -> None:
"""Absent/None = NO CHANGE — the row's stored credential survives
an edit that does not touch the masked field (the edit modal sends
it blank → the router omits the key → None here)."""
payload = GitSourcePatchIn()
assert payload.token is None
def test_git_source_patch_in_token_replace_value() -> None:
payload = GitSourcePatchIn(token="new-secret")
assert payload.token == "new-secret"
def test_git_source_patch_in_token_trimmed() -> None:
payload = GitSourcePatchIn(token=" new-secret ")
assert payload.token == "new-secret"
def test_git_source_patch_in_token_empty_string_clears() -> None:
"""Empty string = CLEAR (the UI offers replace; clear exists for
API completeness)."""
payload = GitSourcePatchIn(token="")
assert payload.token == ""
def test_git_source_patch_in_token_whitespace_only_clears() -> None:
"""Whitespace-only trims to the clear value — a pasted space in the
masked field is a clear, not a five-character credential."""
payload = GitSourcePatchIn(token=" ")
assert payload.token == ""
def test_git_source_patch_in_token_at_cap_validates() -> None:
token = "x" * 500
payload = GitSourcePatchIn(token=token)
assert payload.token == token
def test_git_source_patch_in_token_one_over_cap_rejects() -> None:
with pytest.raises(ValidationError) as exc:
GitSourcePatchIn(token="x" * 501)
assert exc.value.errors()[0]["loc"] == ("token",)
def test_git_source_patch_in_other_fields_unaffected() -> None:
"""ignore_paths / include_hidden keep their independent optionality
— the new field adds a third state, not a coupling."""
payload = GitSourcePatchIn(ignore_paths=["a"], include_hidden=True)
assert payload.ignore_paths == ["a"]
assert payload.include_hidden is True
assert payload.token is None
# ---------------------------------------------------------------------------
# GitSourceOut / GitSourceRow — NO token field, ever (LOCKED A2)
# ---------------------------------------------------------------------------
def _out_kwargs() -> dict:
return {
"id": uuid.uuid4(),
"url": URL,
"added_at": None,
"ignore_paths": [],
"include_hidden": False,
}
def _row_kwargs() -> dict:
return {
"id": uuid.uuid4(),
"kind": "git",
"url": URL,
"path": None,
"added_at": None,
"ignore_paths": [],
"include_hidden": False,
}
def test_output_models_have_no_token_field() -> None:
"""The omission is a documented contract, not an accident: neither
response shape declares a token field at all."""
assert "token" not in GitSourceOut.model_fields
assert "token" not in GitSourceRow.model_fields
def test_git_source_out_rejects_token_kwarg() -> None:
"""``extra="forbid"`` — a regression that tries to echo the stored
credential back cannot even build the shape. (The kwarg is a
deliberate type error — pyright statically knows the shape has no
token parameter; that is the contract under test.)"""
with pytest.raises(ValidationError) as exc:
GitSourceOut(token="ghp_must_never_leak", **_out_kwargs()) # type: ignore[reportCallIssue]
assert ("token",) in [tuple(e["loc"]) for e in exc.value.errors()]
def test_git_source_out_rejects_token_key() -> None:
with pytest.raises(ValidationError) as exc:
GitSourceOut.model_validate({**_out_kwargs(), "token": "ghp_must_never_leak"})
assert ("token",) in [tuple(e["loc"]) for e in exc.value.errors()]
def test_git_source_row_rejects_token_kwarg() -> None:
with pytest.raises(ValidationError) as exc:
# Same deliberate type error as the GitSourceOut pin above — the
# shape has no token parameter (pyright knows; runtime forbids).
GitSourceRow(token="ghp_must_never_leak", **_row_kwargs()) # type: ignore[reportCallIssue]
assert ("token",) in [tuple(e["loc"]) for e in exc.value.errors()]
def test_git_source_row_rejects_token_key() -> None:
with pytest.raises(ValidationError) as exc:
GitSourceRow.model_validate({**_row_kwargs(), "token": "ghp_must_never_leak"})
assert ("token",) in [tuple(e["loc"]) for e in exc.value.errors()]
def test_output_models_still_build_with_declared_fields() -> None:
"""The forbid boundary rejects the credential, not the row: the
declared-field construction every endpoint uses still validates."""
out = GitSourceOut(**_out_kwargs())
assert out.url == URL
row = GitSourceRow(**_row_kwargs())
assert row.url == URL and row.kind == "git"
# ---------------------------------------------------------------------------
# sanitize_url (task 02) — the OUTPUT mask: userinfo stripped from
# https? URLs only, byte-identical for everything else
# ---------------------------------------------------------------------------
def test_sanitize_strips_user_pass_userinfo() -> None:
"""The TODO L5 shape — the embedded credential is gone, the bare
host/path survive character for character."""
assert (
sanitize_url("https://myuser:ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx@github.com/owner/private-repo.git")
== "https://github.com/owner/private-repo.git"
)
def test_sanitize_strips_username_as_token_form() -> None:
"""The documented GitHub shape (no colon in the userinfo): the
whole run is stripped too."""
assert sanitize_url("https://ghp_onlytoken@github.com/owner/repo.git") == (
"https://github.com/owner/repo.git"
)
def test_sanitize_handles_http_scheme() -> None:
assert sanitize_url("http://user:pass@git.local/repo.git") == "http://git.local/repo.git"
def test_sanitize_leaves_ssh_scp_and_local_untouched() -> None:
"""``ssh://``, scp-style ``git@``, and local paths carry no
userinfo (or are not secrets in this shape) — untouched."""
for url in (
"ssh://git@example.com/repo.git",
"git@github.com:owner/repo.git",
"/home/owner/bor-sources/repo",
"~/Homelab",
):
assert sanitize_url(url) == url, url
def test_sanitize_is_byte_identical_for_credential_free_urls() -> None:
"""The phase-50/35 contract: stored URLs surface verbatim when they
carry no credential — an anchored regex replace, never a URL
parser re-serialization (a parser would rewrite the path)."""
url = "https://github.com/owner/repo.git?ref=main#readme"
assert sanitize_url(url) == url
def test_sanitize_at_in_path_is_not_userinfo() -> None:
"""A ``@`` inside the *path* is not userinfo — the anchor requires
the run to come right after the scheme."""
url = "https://github.com/owner/repo@v1/blob/x"
assert sanitize_url(url) == url
def test_sanitize_is_idempotent() -> None:
embedded = "https://user:pass@github.com/owner/repo.git"
once = sanitize_url(embedded)
assert sanitize_url(once) == once
def test_sanitize_empty_and_scheme_only_urls() -> None:
assert sanitize_url("") == ""
assert sanitize_url("https://") == "https://"
# ---------------------------------------------------------------------------
# clone_url_for (task 02) — the CLONE-time credential injection
# ---------------------------------------------------------------------------
def test_clone_url_null_token_returns_stored_url_verbatim() -> None:
"""Public repos (and local rows) clone byte-identically to
pre-phase-121 — no injection, no logging."""
row = GitSource(url="https://github.com/owner/public.git", token=None)
assert clone_url_for(row) == "https://github.com/owner/public.git"
def test_clone_url_empty_token_is_falsy_no_injection() -> None:
row = GitSource(url="https://github.com/owner/public.git", token="")
assert clone_url_for(row) == "https://github.com/owner/public.git"
def test_clone_url_injects_x_access_token_for_https() -> None:
"""The task-02 assumption (step 4): ``x-access-token`` as the
userinfo username — GitHub-agnostic, reads as non-identifying."""
row = GitSource(url="https://github.com/owner/private.git", token="ghp_secret123")
assert (
clone_url_for(row) == "https://x-access-token:ghp_secret123@github.com/owner/private.git"
)
def test_clone_url_injects_for_http_scheme() -> None:
row = GitSource(url="http://git.local/repo.git", token="tok")
assert clone_url_for(row) == "http://x-access-token:tok@git.local/repo.git"
def test_clone_url_replaces_existing_userinfo_with_column_credential() -> None:
"""A legacy stored URL that still embeds userinfo (only reachable
via direct-DB writes now — the API normalizes at write time) gets
the column credential, not the embedded one."""
row = GitSource(url="https://user:oldpass@github.com/owner/repo.git", token="newpass")
assert clone_url_for(row) == "https://x-access-token:newpass@github.com/owner/repo.git"
def test_clone_url_legacy_row_clones_with_original_stored_url() -> None:
"""Completion criterion: a pre-phase row (credential embedded in
the stored URL, token NULL) produces the ORIGINAL stored URL at
clone time — the credential keeps working."""
stored = "https://user:ghp_secret@github.com/owner/repo.git"
row = GitSource(url=stored, token=None)
assert clone_url_for(row) == stored
@pytest.mark.parametrize(
("url", "token"),
[
("ssh://git@example.com/repo.git", "sekrit-value"),
("git@github.com:owner/repo.git", "sekrit-value"),
("/home/owner/bor-sources/repo", "sekrit-value"),
],
)
def test_clone_url_non_https_token_is_noop_with_warning(
caplog: pytest.LogCaptureFixture, url: str, token: str
) -> None:
"""A token cannot authenticate ssh/scp/local — the URL is returned
unchanged, a warning is logged (no crash), and the token VALUE
never leaks into the log line."""
row = GitSource(url=url, token=token)
with caplog.at_level("WARNING", logger="app.rag.git_sources"):
assert clone_url_for(row) == url
warnings = [r for r in caplog.records if r.levelname == "WARNING"]
assert len(warnings) == 1
assert "sekrit-value" not in warnings[0].getMessage()
# ---------------------------------------------------------------------------
# normalize_credential (task 02) — the WRITE-path normalizer
# ---------------------------------------------------------------------------
def test_normalize_moves_embedded_password_to_column() -> None:
"""The TODO L5 paste: ``user:pass@`` → bare URL + the PASSWORD part
as the token (the username is an identifier, not the credential —
the clone-time injection ``x-access-token:<password>@`` must
authenticate)."""
bare, effective = normalize_credential(
"https://myuser:ghp_secret123@github.com/owner/private-repo.git", None
)
assert bare == "https://github.com/owner/private-repo.git"
assert effective == "ghp_secret123"
def test_normalize_password_may_contain_colons() -> None:
"""The split is on the FIRST colon only — ``user:a:b`` moves
``a:b`` wholesale."""
bare, effective = normalize_credential("https://user:a:b@github.com/x/y.git", None)
assert bare == "https://github.com/x/y.git"
assert effective == "a:b"
def test_normalize_username_as_token_form_moves_whole_run() -> None:
"""The username-as-token form (no colon in the userinfo) — the
whole run is the credential."""
bare, effective = normalize_credential("https://ghp_onlytoken@github.com/x/y.git", None)
assert bare == "https://github.com/x/y.git"
assert effective == "ghp_onlytoken"
def test_normalize_explicit_token_wins_over_embedded() -> None:
"""LOCKED A6 — explicit beats embedded (the masked field and the
pasted URL disagree: the field is the intent)."""
bare, effective = normalize_credential(
"https://user:ghp_embedded@github.com/x/y.git", "ghp_explicit"
)
assert bare == "https://github.com/x/y.git"
assert effective == "ghp_explicit"
def test_normalize_explicit_empty_token_wins_and_clears() -> None:
"""An explicit "" (blank masked field) is a deliberate no-credential
declaration: it beats the embedded one and stores NULL (the caller
stores ``effective or None``)."""
bare, effective = normalize_credential("https://user:ghp_x@github.com/x/y.git", "")
assert bare == "https://github.com/x/y.git"
assert effective == ""
def test_normalize_clean_url_with_explicit_token_untouched() -> None:
"""The common UI path (bare URL + masked field): the URL is
byte-identical, the token passes through."""
bare, effective = normalize_credential("https://github.com/x/y.git", "ghp_t")
assert bare == "https://github.com/x/y.git"
assert effective == "ghp_t"
def test_normalize_clean_url_without_token_untouched() -> None:
assert normalize_credential("https://github.com/x/y.git", None) == (
"https://github.com/x/y.git",
None,
)
def test_normalize_non_https_urls_untouched() -> None:
"""ssh/scp/local carry no userinfo — returned untouched, token
(whatever it is) passing through for the caller's tri-state."""
for url in ("ssh://git@example.com/repo.git", "git@github.com:x/y.git", "/local/dir"):
assert normalize_credential(url, None) == (url, None)
assert normalize_credential(url, "t") == (url, "t")
def test_normalize_idempotent_on_bare_url() -> None:
bare, _ = normalize_credential("https://user:pass@github.com/x/y.git", None)
again, effective = normalize_credential(bare, "tok")
assert again == bare
assert effective == "tok"
# ---------------------------------------------------------------------------
# Frontend source pins (task 03) — the masked token field: add form +
# per-row editor; the display sites stay on the server-sanitized s.url
# ---------------------------------------------------------------------------
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
SHELL_HTML = FRONTEND / "index.html"
GIT_SOURCES_JS = FRONTEND / "assets" / "git-sources.js"
STYLES_CSS = FRONTEND / "assets" / "styles.css"
ADD_TOKEN_PLACEHOLDER = "ghp_… or another PAT"
EDIT_TOKEN_PLACEHOLDER = "leave blank to keep the current token"
ADD_BODY_EXPR = "(url, token) => ({ url, ...(token ? { token } : {}) })"
PATCH_BODY_EXPR = "JSON.stringify({ ignore_paths: lines, ...(token ? { token } : {}) })"
def _read(path: Path) -> str:
return path.read_text(encoding="utf-8")
def _js_text() -> str:
return _read(GIT_SOURCES_JS)
def _fn(js: str, name: str) -> str:
"""The source of a (possibly async) top-level function via
balanced-brace counting (the test_source_ignore_paths.py helper)."""
for prefix in ("async function ", "function "):
start = js.find(f"{prefix}{name}(")
if start != -1:
depth = 0
for i in range(js.find("{", start), len(js)):
if js[i] == "{":
depth += 1
elif js[i] == "}":
depth -= 1
if depth == 0:
return js[start : i + 1]
raise AssertionError(f"unbalanced braces in {name}()")
raise AssertionError(f"{name}() must exist in git-sources.js")
def _input_tag(html: str, id_attr: str) -> str:
m = re.search(rf"<input[^>]*id=\"{id_attr}\"[^>]*>", html, re.S)
assert m, f"missing <input id={id_attr}> in the shell"
return m.group(0)
def test_add_form_token_field_is_masked_and_optional() -> None:
"""The add form's `#git-source-token` (task 03 step 1, LOCKED A2):
``type="password"`` (masked — the credential is never a visible-text
field in the DOM), ``autocomplete="off"`` (a PAT is not a site
credential — no browser save offer), the 500-char cap mirrored, the
placeholder, INSIDE `#git-source-form` before the submit button,
and a visible `<label for=…>` (WCAG — never aria-label-only)
carrying the "optional" hint span."""
html = _read(SHELL_HTML)
tag = _input_tag(html, "git-source-token")
assert 'type="password"' in tag, "the token is masked (type=password)"
assert 'autocomplete="off"' in tag, "no browser save offer (ASSUMPTION, task 03)"
assert 'maxlength="500"' in tag, "the schema cap, mirrored in the form"
assert ADD_TOKEN_PLACEHOLDER in tag
form_i = html.find('id="git-source-form"')
btn_i = html.find('id="git-source-add"')
tok_i = html.find('id="git-source-token"')
assert -1 < form_i < tok_i < btn_i, "the field sits in the add form, before the submit"
label = re.search(r"<label[^>]*for=\"git-source-token\"[^>]*>(.*?)</label>", html, re.S)
assert label, "a visible <label for=…> (never aria-label-only)"
assert "Token" in label.group(1)
hint = re.search(r"<span[^>]*class=\"field-hint\"[^>]*>(.*?)</span>", label.group(1))
assert hint and "optional" in hint.group(1), "the visible 'optional' hint span"
def test_add_form_submit_omits_a_blank_token() -> None:
"""The submit body is `(url, token) => ({ url, ...(token ?
{ token } : {}) })` — a BLANK token omits the key (None = no
credential); wireAddForm reads the masked field and passes it to
the body callback, and 201 clears BOTH inputs (the credential is
stored — write-only)."""
js = _js_text()
assert f"body: {ADD_BODY_EXPR}" in js, "the submit body omits the key when blank"
wire = _fn(js, "wireAddForm")
read_i = wire.find('tokenInput ? tokenInput.value.trim() : ""')
call_i = wire.find("opts.body(value, token)", read_i)
assert -1 < read_i < call_i, "the token is read from the field and passed to the body"
clear_url_i = wire.find('input.value = ""')
clear_tok_i = wire.find("tokenInput.value = \"\"", clear_url_i)
assert -1 < clear_url_i < clear_tok_i, "201: both inputs clear"
def test_editor_token_field_is_masked_and_blanks_to_keep() -> None:
"""The per-row editor's `#ignore-editor-token` (task 03 step 2):
``type="password"`` + ``autocomplete="off"`` + the "leave blank to
keep the current token" placeholder, INSIDE `#ignore-editor-dialog`
(after the textarea, before the error line), with a visible label.
The JS always opens the field BLANK (the API has no token field to
prefill from — LOCKED A2) and resets it on close."""
html = _read(SHELL_HTML)
tag = _input_tag(html, "ignore-editor-token")
assert 'type="password"' in tag
assert 'autocomplete="off"' in tag
assert f'placeholder="{EDIT_TOKEN_PLACEHOLDER}"' in tag
dialog_i = html.find('id="ignore-editor-dialog"')
ta_i = html.find('id="ignore-editor-textarea"')
tok_i = html.find('id="ignore-editor-token"')
err_i = html.find('id="ignore-editor-error"')
assert -1 < dialog_i < ta_i < tok_i < err_i, ("the field sits in the editor, after the box")
label = re.search(r"<label[^>]*for=\"ignore-editor-token\"[^>]*>(.*?)</label>", html, re.S)
assert label and "Token" in label.group(1), "a visible label (WCAG)"
js = _js_text()
open_body = _fn(js, "openIgnoreEditor")
close_body = _fn(js, "closeIgnoreEditor")
assert 'ignoreTokenInput.value = ""' in open_body, ("opens BLANK — never prefilled")
assert 'ignoreTokenInput.value = ""' in close_body, ("resets on close")
def test_editor_patch_omits_a_blank_token() -> None:
"""saveIgnorePaths: the token is read AFTER the line parse and the
PATCH body is `{ ignore_paths: lines, ...(token ? { token } : {}) }`
— a BLANK token omits the key (the task-01 tri-state: absent = no
change, the row's stored credential is kept)."""
js = _js_text()
save = _fn(js, "saveIgnorePaths")
drop_i = save.find(".filter(Boolean)")
read_i = save.find('ignoreTokenInput ? ignoreTokenInput.value.trim() : ""', drop_i)
body_i = save.find(PATCH_BODY_EXPR, read_i)
assert -1 < drop_i < read_i < body_i, ("blank token → key omitted (tri-state no-change)")
def test_display_sites_render_the_server_bare_url_only() -> None:
"""Task 03 step 3: every display site keeps rendering `s.url`
UNCHANGED (the server now returns bare URLs — sanitize_url, phase
121), the one-line source note pins that contract, and NO display
site ever reads a token off the row (the response has no token
field — and even if one did, the UI must never re-embed it)."""
js = _js_text()
make = _fn(js, "makeRow")
assert "const value = isLocal ? (s.path ?? s.url) : s.url;" in make, (
"the display value is the server row's url/path — UNCHANGED"
)
assert "sanitized server-side" in make, ("the one-line phase-121 note at the display site")
assert "never re-embed a credential" in make
assert "s.token" not in js, "the UI never re-embeds a credential (LOCKED A2)"
def test_module_docstring_carries_the_phase_121_token_contract() -> None:
"""The git-sources.js module docstring gained the phase-121 bullet:
the masked add field (type=password, autocomplete=off, the "optional
— private repos" hint), the blank-omits-key submit body, the
editor's "leave blank to keep the current token" mirror, and the
display-sites-unchanged (sanitize_url) contract."""
doc = _js_text().split("*/", 2)[0] # the module docstring (first block)
for frag in (
"Phase 121 (task 03)",
"#git-source-token",
"type=password",
"optional — private repos",
ADD_BODY_EXPR,
"leave blank to keep the current token",
"sanitize_url",
):
assert frag in doc, f"the module docstring lost: {frag!r}"
def test_token_field_css_rules_are_house_styled() -> None:
"""styles.css (task 03 step 2 — no new CSS beyond the theme's
input treatment): the add form's token input shares the URL
input's box (grouped selector — mono, >=44px floor); the
`.field-hint` rule is muted (ink-soft — 5.1:1 on the label's
surface, AA) and small; the editor's token field is full panel
width at the 44px floor; the mobile media query groups it in the
same min-width:0 rule as the URL input."""
css = _read(STYLES_CSS)
grouped = css.find("#git-source-url,\n#git-source-token {")
assert grouped != -1, "#git-source-token shares #git-source-url's box"
rule = css[grouped : css.find("}", grouped)]
assert "min-height: 44px" in rule and "var(--mono)" in rule
hint = css.find(".field-hint {")
assert hint != -1, "the .field-hint rule"
hint_rule = css[hint : css.find("}", hint)]
assert "var(--ink-soft)" in hint_rule and "font-size: 0.8rem" in hint_rule
tok = css.find(".ignore-editor-token {")
assert tok != -1, "the editor's token field rule"
tok_rule = css[tok : css.find("}", tok)]
assert "width: 100%" in tok_rule and "min-height: 44px" in tok_rule
mobile = css.find("#git-source-url,\n #git-source-token,")
assert mobile != -1, "the mobile squeeze groups the token input"
+15 -6
View File
@@ -68,6 +68,7 @@ DIALOG_IDS = (
"ignore-editor-source",
"ignore-editor-copy",
"ignore-editor-textarea",
"ignore-editor-token", # phase 121 (task 03): the masked token field
"ignore-editor-error",
"ignore-editor-cancel",
"ignore-editor-save",
@@ -359,7 +360,10 @@ def test_save_runs_the_inflight_never_stale_lifecycle() -> None:
A4). The §7.4 in-flight state precedes the PATCH: both buttons
disable + the save relabels "Saving…" — one
``PATCH /api/git-sources/{id}`` with the lines as the whole
body list (A5 replace). 200 → close (focus return) →
body list (A5 replace). Phase 121 (task 03): the masked token is
read AFTER the line parse and included in the body ONLY when
non-blank (the tri-state: blank → key omitted → no change, the
row's stored credential is kept). 200 → close (focus return) →
loadSources (the count tag lands) → announce (the update
confirmation is the LAST announcement). Non-2xx: the in-dialog
role=alert line (apiDetail, 422 shape-aware), the dialog STAYS
@@ -371,18 +375,23 @@ def test_save_runs_the_inflight_never_stale_lifecycle() -> None:
parse_i = body.find('.split("\\n")')
trim_i = body.find(".map((l) => l.trim())", parse_i)
drop_i = body.find(".filter(Boolean)", trim_i)
inflight_i = body.find("ignoreInFlight = true", drop_i)
# Phase 121 (task 03): the token is read between the line parse
# and the in-flight flag (blank = the key is omitted).
token_i = body.find('ignoreTokenInput ? ignoreTokenInput.value.trim() : ""', drop_i)
inflight_i = body.find("ignoreInFlight = true", token_i)
dis_c = body.find("ignoreCancelBtn.disabled = true", inflight_i)
dis_s = body.find("ignoreSaveBtn.disabled = true", inflight_i)
label_i = body.find(f'"{SAVING_LABEL}"', dis_s)
fetch_i = body.find("`/api/git-sources/${", label_i)
method_i = body.find('method: "PATCH"', fetch_i)
body_i = body.find("JSON.stringify({ ignore_paths: lines })", method_i)
assert -1 < guard_i < parse_i < trim_i < drop_i < inflight_i, (
"guard → split + trim + drop blank lines → in-flight"
body_i = body.find(
"JSON.stringify({ ignore_paths: lines, ...(token ? { token } : {}) })", method_i
)
assert -1 < guard_i < parse_i < trim_i < drop_i < token_i < inflight_i, (
"guard → split + trim + drop blank lines → read the token → in-flight"
)
assert -1 < dis_c < dis_s < label_i < fetch_i < method_i < body_i, (
"disable both + 'Saving…' → the PATCH with the lines"
"disable both + 'Saving…' → the PATCH (lines + token only when non-blank)"
)
# Success: close → reload → announce (the exact order) — the
# update confirmation is the LAST announcement: the reload's