phase: 84_docs_push_error_sanitization
**Phase 84 — final verification pass: all green, no defects found** - Verified implementation: `app/core/errors.py` (verbatim lift of sync masker), `app/api/sync.py` alias import, docs-push 502 `detail=sanitize_error(str(exc))`, all five `llm.py` error sites sanitized; new/extended test pins in place - Tests: `uv run pytest` → **1714 passed, 0 failed**; targeted pins (new unit ×2 + integration ×1, existing 502 pin) → 13 passed; sync/git-sources regression → 67 passed - Coverage: `uv run pytest --cov=app --cov-report=term-missing` → **99%** (`app/core/errors.py` 100%, `app/rag/llm.py` 100%) — >90% met - E2E isolation: `uv run pytest tests/e2e/test_smoke.py -v --no-cov` → **3 passed** - Lint/types: `uv run ruff check .` → clean; `uv run pyright` → **0 errors** - Criteria: 502 masks `*****@`/never token + row untouched ✅; LLM base-URL masked, credential-free strings byte-identical ✅; `_CREDS_RE` only in `app/core/errors.py` (working-tree grep) ✅; full gate green ✅; `git diff --stat` limited to the 4 app files + 2 modified test files + 3 phase task files (untracked: new module, new unit test, complete/ dir, reports, audit plan) ✅ - Commit/phase move left to the harness per instructions (task files already in `complete/`) - No deviations; nothing to fix - Next pending phase: **85_mobile_menu_gate_overlap**
This commit is contained in:
@@ -23,7 +23,9 @@ import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import select, text
|
||||
|
||||
import app.api.doc_drafts as doc_drafts
|
||||
from app.config import Settings, get_settings
|
||||
from app.core.docs_push import DocsPushError
|
||||
from app.main import app as fastapi_app
|
||||
from app.models import DocDraft
|
||||
|
||||
@@ -483,6 +485,65 @@ def test_push_non_repo_dir_returns_502_with_git_stderr(
|
||||
assert body["commit_sha"] is None
|
||||
|
||||
|
||||
def test_push_502_detail_masks_git_stderr_credentials(
|
||||
admin_client: TestClient,
|
||||
db,
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Phase 84 (SEC-08): a ``DocsPushError`` whose git stderr carries a
|
||||
``user:pass@`` remote URL (a ``BOR_DOCS_REPO`` configured with
|
||||
embedded credentials) → the 502 detail is the SANITIZED stderr — the
|
||||
token never reaches the browser or the logs, while the failing repo
|
||||
and git's reason stay readable; the row is untouched (only a success
|
||||
mutates). ``push_document`` is monkeypatched, so no real git is
|
||||
needed."""
|
||||
|
||||
def failing_push(**kwargs: Any) -> tuple[str, str]:
|
||||
raise DocsPushError(
|
||||
"git push origin bor-docs failed (exit 128): "
|
||||
"fatal: Authentication failed for "
|
||||
"'https://bot:ghp_LEAKTOKEN@github.com/owner/docs.git/'"
|
||||
)
|
||||
|
||||
monkeypatch.setattr(doc_drafts, "push_document", failing_push)
|
||||
fastapi_app.dependency_overrides[
|
||||
get_settings
|
||||
] = lambda: _settings(
|
||||
docs_repo="/nonexistent/docs.git", # configured — push_document is faked
|
||||
docs_branch=DOCS_BRANCH,
|
||||
docs_base_branch=BASE_BRANCH,
|
||||
docs_work_dir=str(tmp_path / "docs-workdir"),
|
||||
)
|
||||
created = _create(admin_client)
|
||||
try:
|
||||
r = admin_client.post(f"/api/doc-drafts/{created['token']}/push")
|
||||
finally:
|
||||
fastapi_app.dependency_overrides.pop(get_settings, None)
|
||||
|
||||
assert r.status_code == 502
|
||||
detail = r.json()["detail"]
|
||||
# The credential is masked to the sync masker's shape ...
|
||||
assert "*****@github.com" in detail
|
||||
assert "ghp_LEAKTOKEN" not in detail
|
||||
# ... but the failure context stays readable (the GitSyncError →
|
||||
# detail mapping, otherwise kept).
|
||||
assert "exit 128" in detail
|
||||
assert "fatal: Authentication failed" in detail
|
||||
assert "owner/docs.git" in detail
|
||||
# The row is untouched (status/branch/commit_sha as found).
|
||||
body = admin_client.get(f"/api/doc-drafts/{created['token']}").json()
|
||||
assert body["status"] == "draft"
|
||||
assert body["branch"] is None
|
||||
assert body["commit_sha"] is None
|
||||
row = db.execute(
|
||||
select(DocDraft).where(DocDraft.token == uuid.UUID(created["token"]))
|
||||
).scalars().one()
|
||||
assert row.status == "draft"
|
||||
assert row.branch is None
|
||||
assert row.commit_sha is None
|
||||
|
||||
|
||||
def test_push_unknown_token_returns_404(
|
||||
admin_client: TestClient, docs_push_settings: Settings
|
||||
) -> None:
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
"""Unit tests: the shared error-string sanitizer (phase 84, task 01).
|
||||
|
||||
``app.core.errors.sanitize_error`` is the verbatim lift of the phase-32
|
||||
sync masker — a NARROW ``user:pass@`` userinfo regex (git/HTTP
|
||||
convention) with the ``*****@`` replacement. These pins fix both sides
|
||||
of the contract:
|
||||
|
||||
* masking — every pinned ``user:pass@`` occurrence is rewritten to
|
||||
``*****@`` (a single userinfo, two in one string, and one inside a
|
||||
realistic git push-failure line — where ``fatal:`` / ``exit 128`` /
|
||||
the host survive);
|
||||
* byte-identity — credential-free text (a bare https URL without
|
||||
userinfo, prose with a colon + space, a plain email, the empty
|
||||
string) comes back unchanged, character for character;
|
||||
* idempotence — sanitizing an already-sanitized (masked) string is a
|
||||
no-op.
|
||||
|
||||
Plus the refactor pin: ``app.api.sync._sanitize_error`` is the shared
|
||||
function under the private alias name (the phase-32 callers —
|
||||
``app/api/git_sources.py``, ``app/api/docs.py`` — see the identical
|
||||
function through the import).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import app.api.sync as sync_api
|
||||
from app.core.errors import sanitize_error
|
||||
|
||||
TWO_USERINFO = "https://alice:secret1@repo.example.com/a.git and https://bob:secret2@repo.example.com/b.git"
|
||||
GIT_PUSH_FAILURE = (
|
||||
"git push failed (exit 128): fatal: Authentication failed for "
|
||||
"'https://bot:tok@github.com/o/r.git/'"
|
||||
)
|
||||
|
||||
# --- The masked forms (the regex must fire) --------------------------------
|
||||
|
||||
|
||||
def test_masks_single_userinfo_in_url() -> None:
|
||||
"""The canonical shape: ``user:pass@`` → ``*****@``, host intact."""
|
||||
assert sanitize_error("https://user:pass@host/repo.git") == "https://*****@host/repo.git"
|
||||
|
||||
|
||||
def test_masks_every_userinfo_occurrence() -> None:
|
||||
"""Two userinfo runs in one string → both masked, nothing else touched."""
|
||||
masked = sanitize_error(TWO_USERINFO)
|
||||
assert masked == "https://*****@repo.example.com/a.git and https://*****@repo.example.com/b.git"
|
||||
assert "secret1" not in masked
|
||||
assert "secret2" not in masked
|
||||
|
||||
|
||||
def test_masks_token_in_git_push_failure_line() -> None:
|
||||
"""The SEC-08 shape: git's auth-failure stderr with a token-bearing
|
||||
remote URL → the token is masked, the actionable context survives."""
|
||||
masked = sanitize_error(GIT_PUSH_FAILURE)
|
||||
assert masked == (
|
||||
"git push failed (exit 128): fatal: Authentication failed for "
|
||||
"'https://*****@github.com/o/r.git/'"
|
||||
)
|
||||
assert "tok" not in masked
|
||||
assert "exit 128" in masked # the reason survives
|
||||
assert "fatal:" in masked
|
||||
assert "github.com/o/r.git" in masked # host + repo path survive
|
||||
|
||||
|
||||
# --- The byte-identity contract (credential-free text) ---------------------
|
||||
|
||||
|
||||
def test_plain_url_without_userinfo_is_unchanged() -> None:
|
||||
"""A bare https URL (no userinfo) plus a realistic git failure line
|
||||
→ byte-identical."""
|
||||
message = (
|
||||
"git push failed (exit 128): fatal: repository "
|
||||
"'https://github.com/owner/repo.git/' not found"
|
||||
)
|
||||
assert sanitize_error(message) == message
|
||||
|
||||
|
||||
def test_prose_with_colon_and_space_is_unchanged() -> None:
|
||||
"""Colons in prose are followed by spaces, not by userinfo runs —
|
||||
the narrow regex must not fire (the sync tests pin the same copy)."""
|
||||
model_message = (
|
||||
"The embedding model ('embed') is not available — check the model endpoint and retry."
|
||||
)
|
||||
assert sanitize_error(model_message) == model_message
|
||||
sentence = "note: the sync failed: see the log for details"
|
||||
assert sanitize_error(sentence) == sentence
|
||||
|
||||
|
||||
def test_email_in_prose_is_unchanged() -> None:
|
||||
"""``owner@example.com`` carries no colon userinfo run before the
|
||||
``@`` — the pattern does not match it, so it stays byte-identical
|
||||
(pinned for determinism: the masker only rewrites ``user:pass@``)."""
|
||||
message = "please contact owner@example.com about the failing push"
|
||||
assert sanitize_error(message) == message
|
||||
|
||||
|
||||
def test_empty_string_is_unchanged() -> None:
|
||||
assert sanitize_error("") == ""
|
||||
|
||||
|
||||
# --- Idempotence (masked forms) ---------------------------------------------
|
||||
|
||||
|
||||
def test_idempotent_on_masked_forms() -> None:
|
||||
"""Sanitizing an already-sanitized string is a no-op — the ``*****@``
|
||||
replacement never forms a new ``user:pass@`` run in the pinned cases."""
|
||||
for message in ("https://user:pass@host/repo.git", TWO_USERINFO, GIT_PUSH_FAILURE):
|
||||
once = sanitize_error(message)
|
||||
assert sanitize_error(once) == once
|
||||
|
||||
|
||||
# --- The sync alias refactor (phase 84, task 01) ----------------------------
|
||||
|
||||
|
||||
def test_sync_keeps_the_private_alias_of_the_shared_function() -> None:
|
||||
"""``app.api.sync._sanitize_error`` IS the shared sanitizer under the
|
||||
private name — the phase-32 callers (``git_sources.py``, ``docs.py``)
|
||||
import it through ``app.api.sync`` and see the identical function."""
|
||||
assert sync_api._sanitize_error is sanitize_error
|
||||
assert sync_api._sanitize_error(GIT_PUSH_FAILURE) == sanitize_error(GIT_PUSH_FAILURE)
|
||||
@@ -189,6 +189,36 @@ def test_http_error_surfaces_status() -> None:
|
||||
assert llm.embed_batches == 0
|
||||
|
||||
|
||||
def test_embed_transport_failure_masks_credentials_in_base_url() -> None:
|
||||
"""Phase 84 (SEC-13): a base URL configured with embedded
|
||||
``user:pass@`` credentials never reaches the error message — the URL
|
||||
in the raised :class:`EmbeddingError` is sanitized (``*****@``),
|
||||
while the failure context stays readable."""
|
||||
llm, _ = _make_client(
|
||||
_FakeEmbeddingsService(fail=RuntimeError("connection refused")),
|
||||
llm_base_url="https://svc:topsecret@llm.local/v1",
|
||||
)
|
||||
with pytest.raises(EmbeddingError) as exc:
|
||||
asyncio.run(llm.embed(["hello"]))
|
||||
msg = str(exc.value)
|
||||
assert "https://*****@llm.local/v1" in msg
|
||||
assert "topsecret" not in msg
|
||||
assert "connection refused" in msg
|
||||
assert llm.embed_batches == 0
|
||||
# The credential-free pin stays byte-identical (sanitize is a no-op
|
||||
# without userinfo): the default base URL appears verbatim.
|
||||
llm_plain, _ = _make_client(
|
||||
_FakeEmbeddingsService(fail=RuntimeError("connection refused"))
|
||||
)
|
||||
with pytest.raises(EmbeddingError) as exc_plain:
|
||||
asyncio.run(llm_plain.embed(["hello"]))
|
||||
assert (
|
||||
str(exc_plain.value)
|
||||
== "embeddings request to https://aipi.reeseapps.com/v1 failed: "
|
||||
"connection refused"
|
||||
)
|
||||
|
||||
|
||||
def test_missing_vector_row_is_rejected() -> None:
|
||||
llm, _ = _make_client(_FakeEmbeddingsService(drop_index=1))
|
||||
with pytest.raises(EmbeddingError, match="returned 1 vectors for 2 inputs"):
|
||||
@@ -925,6 +955,22 @@ def test_chat_transport_failure_wrapped_as_llm_error_with_base_url() -> None:
|
||||
assert "aipi.reeseapps.com" in str(exc.value)
|
||||
|
||||
|
||||
def test_chat_transport_failure_masks_credentials_in_base_url() -> None:
|
||||
"""Phase 84 (SEC-13): the chat failure f-string sanitizes the base
|
||||
URL the same way the embed path does (the five sites share the
|
||||
construction; this is the chat representative)."""
|
||||
llm, _ = _make_chat_client(
|
||||
fail=RuntimeError("HTTP 502 Bad Gateway"),
|
||||
llm_base_url="https://svc:topsecret@llm.local/v1",
|
||||
)
|
||||
with pytest.raises(LLMError) as exc:
|
||||
asyncio.run(llm.chat([{"role": "user", "content": "q"}]))
|
||||
msg = str(exc.value)
|
||||
assert "https://*****@llm.local/v1" in msg
|
||||
assert "topsecret" not in msg
|
||||
assert "HTTP 502 Bad Gateway" in msg
|
||||
|
||||
|
||||
def test_chat_llm_error_passes_through_unwrapped() -> None:
|
||||
llm, _ = _make_chat_client(fail=LLMError("already wrapped"))
|
||||
with pytest.raises(LLMError, match="already wrapped"):
|
||||
|
||||
Reference in New Issue
Block a user