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:
2026-09-08 01:11:45 -04:00
parent fa189dede7
commit f4150421bb
23 changed files with 732 additions and 29 deletions
+61
View File
@@ -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: