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:
+11
@@ -0,0 +1,11 @@
|
|||||||
|
**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**
|
||||||
+85
@@ -0,0 +1,85 @@
|
|||||||
|
........................................................................ [ 4%]
|
||||||
|
........................................................................ [ 8%]
|
||||||
|
........................................................................ [ 12%]
|
||||||
|
........................................................................ [ 16%]
|
||||||
|
........................................................................ [ 21%]
|
||||||
|
........................................................................ [ 25%]
|
||||||
|
........................................................................ [ 29%]
|
||||||
|
........................................................................ [ 33%]
|
||||||
|
........................................................................ [ 37%]
|
||||||
|
........................................................................ [ 42%]
|
||||||
|
........................................................................ [ 46%]
|
||||||
|
........................................................................ [ 50%]
|
||||||
|
........................................................................ [ 54%]
|
||||||
|
........................................................................ [ 58%]
|
||||||
|
........................................................................ [ 63%]
|
||||||
|
........................................................................ [ 67%]
|
||||||
|
........................................................................ [ 71%]
|
||||||
|
........................................................................ [ 75%]
|
||||||
|
........................................................................ [ 79%]
|
||||||
|
........................................................................ [ 84%]
|
||||||
|
........................................................................ [ 88%]
|
||||||
|
........................................................................ [ 92%]
|
||||||
|
........................................................................ [ 96%]
|
||||||
|
.......................................................... [100%]
|
||||||
|
=============================== warnings summary ===============================
|
||||||
|
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
|
||||||
|
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
|
||||||
|
from starlette.testclient import TestClient as TestClient # noqa
|
||||||
|
|
||||||
|
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
|
||||||
|
================================ tests coverage ================================
|
||||||
|
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
|
||||||
|
|
||||||
|
Name Stmts Miss Cover
|
||||||
|
--------------------------------------------------
|
||||||
|
app/__init__.py 1 0 100%
|
||||||
|
app/api/__init__.py 0 0 100%
|
||||||
|
app/api/auth.py 52 0 100%
|
||||||
|
app/api/chat.py 178 0 100%
|
||||||
|
app/api/chats.py 110 0 100%
|
||||||
|
app/api/config.py 7 0 100%
|
||||||
|
app/api/doc_drafts.py 94 0 100%
|
||||||
|
app/api/docs.py 50 0 100%
|
||||||
|
app/api/git_sources.py 212 0 100%
|
||||||
|
app/api/health.py 10 0 100%
|
||||||
|
app/api/steering.py 42 0 100%
|
||||||
|
app/api/suggestions.py 29 0 100%
|
||||||
|
app/api/sync.py 98 0 100%
|
||||||
|
app/api/tokens.py 28 0 100%
|
||||||
|
app/config.py 141 0 100%
|
||||||
|
app/core/__init__.py 0 0 100%
|
||||||
|
app/core/auth.py 45 0 100%
|
||||||
|
app/core/caching.py 108 0 100%
|
||||||
|
app/core/debugging.py 29 2 93%
|
||||||
|
app/core/docs_push.py 39 0 100%
|
||||||
|
app/core/errors.py 5 0 100%
|
||||||
|
app/core/logging.py 13 0 100%
|
||||||
|
app/core/rate_limit.py 44 0 100%
|
||||||
|
app/core/security_headers.py 19 0 100%
|
||||||
|
app/core/tokens.py 33 0 100%
|
||||||
|
app/db.py 21 0 100%
|
||||||
|
app/main.py 64 0 100%
|
||||||
|
app/models.py 94 0 100%
|
||||||
|
app/rag/__init__.py 0 0 100%
|
||||||
|
app/rag/agent.py 222 0 100%
|
||||||
|
app/rag/archive_upload.py 128 0 100%
|
||||||
|
app/rag/chunker.py 206 4 98%
|
||||||
|
app/rag/git_sources.py 14 0 100%
|
||||||
|
app/rag/importer.py 180 3 98%
|
||||||
|
app/rag/llm.py 217 0 100%
|
||||||
|
app/rag/overview.py 71 0 100%
|
||||||
|
app/rag/prompts.py 88 0 100%
|
||||||
|
app/rag/retriever.py 150 3 98%
|
||||||
|
app/rag/scaffolding.py 55 0 100%
|
||||||
|
app/rag/source_removal.py 41 0 100%
|
||||||
|
app/rag/sources_meta.py 16 0 100%
|
||||||
|
app/rag/suggestions.py 27 0 100%
|
||||||
|
app/rag/summarizer.py 24 0 100%
|
||||||
|
app/schemas.py 232 0 100%
|
||||||
|
--------------------------------------------------
|
||||||
|
TOTAL 3237 12 99%
|
||||||
|
coverage gate: app/ 99% (>90%) OK
|
||||||
|
All checks passed!
|
||||||
|
0 errors, 0 warnings, 0 informations
|
||||||
|
validation OK
|
||||||
+18
@@ -0,0 +1,18 @@
|
|||||||
|
All criteria verified. Final report:
|
||||||
|
|
||||||
|
**Task 01 — shared sanitizer module + sync alias refactor: complete**
|
||||||
|
|
||||||
|
- `app/core/errors.py` (new): owns `_CREDS_RE` (exact phase-32 pattern) + `sanitize_error()` (`sub("*****@")`), with module docstring covering SEC-08/SEC-13 audit basis, narrow-userinfo byte-identity contract, and the `*****@` replacement shape.
|
||||||
|
- `app/api/sync.py`: local `_CREDS_RE`/`_sanitize_error` deleted; now `from app.core.errors import sanitize_error as _sanitize_error`; one-line module-docstring note added; now-unused `import re` removed. `_run_sync` failure path byte-identical; `git_sources.py`/`docs.py` alias imports untouched.
|
||||||
|
- `tests/unit/test_error_sanitization.py` (new, 9 tests): single/multiple userinfo masking, SEC-08 git push-failure line (token masked, `fatal:`/`exit 128`/host intact), byte-identity pins (bare https URL, colon+space prose, email `owner@example.com` — pattern doesn't match it, pinned as unchanged, empty string), idempotence on all masked forms, and `sync_api._sanitize_error is sanitize_error` alias identity.
|
||||||
|
|
||||||
|
Results:
|
||||||
|
- `uv run pytest tests/unit/test_error_sanitization.py -v` → 9 passed
|
||||||
|
- `uv run pytest tests/integration/test_git_sources_upload.py tests/integration/test_sync_api.py` → 44 passed (no test edits)
|
||||||
|
- `uv run pytest --cov=app --cov-report=term-missing` → 1711 passed; `app/core/errors.py` 100%, app/ total 99%
|
||||||
|
- `uv run ruff check .` → clean; `uv run pyright` → 0 errors
|
||||||
|
- `grep -rn _CREDS_RE app/` → exactly one definition, in `app/core/errors.py`
|
||||||
|
|
||||||
|
Decisions: none — verbatim lift per design block. No pre-existing defects found. No git commits made (left for harness).
|
||||||
|
|
||||||
|
Next pending task: `.agents/phases/todo/84_docs_push_error_sanitization/02_apply_docs_push_and_llm.md`
|
||||||
+85
@@ -0,0 +1,85 @@
|
|||||||
|
........................................................................ [ 4%]
|
||||||
|
........................................................................ [ 8%]
|
||||||
|
........................................................................ [ 12%]
|
||||||
|
........................................................................ [ 16%]
|
||||||
|
........................................................................ [ 21%]
|
||||||
|
........................................................................ [ 25%]
|
||||||
|
........................................................................ [ 29%]
|
||||||
|
........................................................................ [ 33%]
|
||||||
|
........................................................................ [ 37%]
|
||||||
|
........................................................................ [ 42%]
|
||||||
|
........................................................................ [ 46%]
|
||||||
|
........................................................................ [ 50%]
|
||||||
|
........................................................................ [ 54%]
|
||||||
|
........................................................................ [ 58%]
|
||||||
|
........................................................................ [ 63%]
|
||||||
|
........................................................................ [ 67%]
|
||||||
|
........................................................................ [ 71%]
|
||||||
|
........................................................................ [ 75%]
|
||||||
|
........................................................................ [ 79%]
|
||||||
|
........................................................................ [ 84%]
|
||||||
|
........................................................................ [ 88%]
|
||||||
|
........................................................................ [ 92%]
|
||||||
|
........................................................................ [ 96%]
|
||||||
|
....................................................... [100%]
|
||||||
|
=============================== warnings summary ===============================
|
||||||
|
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
|
||||||
|
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
|
||||||
|
from starlette.testclient import TestClient as TestClient # noqa
|
||||||
|
|
||||||
|
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
|
||||||
|
================================ tests coverage ================================
|
||||||
|
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
|
||||||
|
|
||||||
|
Name Stmts Miss Cover
|
||||||
|
--------------------------------------------------
|
||||||
|
app/__init__.py 1 0 100%
|
||||||
|
app/api/__init__.py 0 0 100%
|
||||||
|
app/api/auth.py 52 0 100%
|
||||||
|
app/api/chat.py 178 0 100%
|
||||||
|
app/api/chats.py 110 0 100%
|
||||||
|
app/api/config.py 7 0 100%
|
||||||
|
app/api/doc_drafts.py 93 0 100%
|
||||||
|
app/api/docs.py 50 0 100%
|
||||||
|
app/api/git_sources.py 212 0 100%
|
||||||
|
app/api/health.py 10 0 100%
|
||||||
|
app/api/steering.py 42 0 100%
|
||||||
|
app/api/suggestions.py 29 0 100%
|
||||||
|
app/api/sync.py 98 0 100%
|
||||||
|
app/api/tokens.py 28 0 100%
|
||||||
|
app/config.py 141 0 100%
|
||||||
|
app/core/__init__.py 0 0 100%
|
||||||
|
app/core/auth.py 45 0 100%
|
||||||
|
app/core/caching.py 108 0 100%
|
||||||
|
app/core/debugging.py 29 2 93%
|
||||||
|
app/core/docs_push.py 39 0 100%
|
||||||
|
app/core/errors.py 5 0 100%
|
||||||
|
app/core/logging.py 13 0 100%
|
||||||
|
app/core/rate_limit.py 44 0 100%
|
||||||
|
app/core/security_headers.py 19 0 100%
|
||||||
|
app/core/tokens.py 33 0 100%
|
||||||
|
app/db.py 21 0 100%
|
||||||
|
app/main.py 64 0 100%
|
||||||
|
app/models.py 94 0 100%
|
||||||
|
app/rag/__init__.py 0 0 100%
|
||||||
|
app/rag/agent.py 222 0 100%
|
||||||
|
app/rag/archive_upload.py 128 0 100%
|
||||||
|
app/rag/chunker.py 206 4 98%
|
||||||
|
app/rag/git_sources.py 14 0 100%
|
||||||
|
app/rag/importer.py 180 3 98%
|
||||||
|
app/rag/llm.py 216 0 100%
|
||||||
|
app/rag/overview.py 71 0 100%
|
||||||
|
app/rag/prompts.py 88 0 100%
|
||||||
|
app/rag/retriever.py 150 3 98%
|
||||||
|
app/rag/scaffolding.py 55 0 100%
|
||||||
|
app/rag/source_removal.py 41 0 100%
|
||||||
|
app/rag/sources_meta.py 16 0 100%
|
||||||
|
app/rag/suggestions.py 27 0 100%
|
||||||
|
app/rag/summarizer.py 24 0 100%
|
||||||
|
app/schemas.py 232 0 100%
|
||||||
|
--------------------------------------------------
|
||||||
|
TOTAL 3235 12 99%
|
||||||
|
coverage gate: app/ 99% (>90%) OK
|
||||||
|
All checks passed!
|
||||||
|
0 errors, 0 warnings, 0 informations
|
||||||
|
validation OK
|
||||||
+18
@@ -0,0 +1,18 @@
|
|||||||
|
All green. Final report:
|
||||||
|
|
||||||
|
**Phase 84, Task 02 — docs-push 502 + LLM error sanitization**
|
||||||
|
|
||||||
|
Implemented:
|
||||||
|
- `app/api/doc_drafts.py`: `push_doc_draft` 502 detail now `sanitize_error(str(exc))` (SEC-08); route + module docstrings updated to name the SANITIZED stderr
|
||||||
|
- `app/rag/llm.py`: all five error f-strings (embed, chat×3, chat_stream) wrap `self.settings.llm_base_url` in `sanitize_error(...)` (SEC-13) — surrounding copy byte-identical
|
||||||
|
- `tests/integration/test_doc_drafts_api.py`: new pin — faked `DocsPushError` with `bot:ghp_LEAKTOKEN@github.com` → 502 detail has `*****@github.com`, `exit 128`, `fatal: Authentication failed`, never `ghp_LEAKTOKEN`; row untouched
|
||||||
|
- `tests/unit/test_llm_client.py`: new pins — embed + chat transport failures with `https://svc:topsecret@llm.local/v1` → masked `https://*****@llm.local/v1`, no `topsecret`; plus a byte-identical credential-free message assertion
|
||||||
|
|
||||||
|
Results:
|
||||||
|
- `uv run pytest tests/integration/test_doc_drafts_api.py -v` — 28 passed; `uv run pytest tests/unit/test_llm_client.py -v` — 57 passed (pre-existing error-string pins green unchanged)
|
||||||
|
- `uv run pytest --cov=app --cov-report=term-missing` — full suite green, TOTAL 99% (>90%); doc_drafts.py / llm.py / errors.py 100%
|
||||||
|
- `uv run pytest tests/e2e/test_smoke.py -v --no-cov` — 3 passed in isolation
|
||||||
|
- `uv run ruff check .` — clean; `uv run pyright` — 0 errors
|
||||||
|
- No deviations; diff limited to the four task files (task 01's files remain in the tree for the harness's atomic phase commit)
|
||||||
|
|
||||||
|
Next pending task: `.agents/phases/todo/84_docs_push_error_sanitization/03_verify_and_commit.md`
|
||||||
+85
@@ -0,0 +1,85 @@
|
|||||||
|
........................................................................ [ 4%]
|
||||||
|
........................................................................ [ 8%]
|
||||||
|
........................................................................ [ 12%]
|
||||||
|
........................................................................ [ 16%]
|
||||||
|
........................................................................ [ 21%]
|
||||||
|
........................................................................ [ 25%]
|
||||||
|
........................................................................ [ 29%]
|
||||||
|
........................................................................ [ 33%]
|
||||||
|
........................................................................ [ 37%]
|
||||||
|
........................................................................ [ 42%]
|
||||||
|
........................................................................ [ 46%]
|
||||||
|
........................................................................ [ 50%]
|
||||||
|
........................................................................ [ 54%]
|
||||||
|
........................................................................ [ 58%]
|
||||||
|
........................................................................ [ 63%]
|
||||||
|
........................................................................ [ 67%]
|
||||||
|
........................................................................ [ 71%]
|
||||||
|
........................................................................ [ 75%]
|
||||||
|
........................................................................ [ 79%]
|
||||||
|
........................................................................ [ 84%]
|
||||||
|
........................................................................ [ 88%]
|
||||||
|
........................................................................ [ 92%]
|
||||||
|
........................................................................ [ 96%]
|
||||||
|
.......................................................... [100%]
|
||||||
|
=============================== warnings summary ===============================
|
||||||
|
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
|
||||||
|
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
|
||||||
|
from starlette.testclient import TestClient as TestClient # noqa
|
||||||
|
|
||||||
|
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
|
||||||
|
================================ tests coverage ================================
|
||||||
|
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
|
||||||
|
|
||||||
|
Name Stmts Miss Cover
|
||||||
|
--------------------------------------------------
|
||||||
|
app/__init__.py 1 0 100%
|
||||||
|
app/api/__init__.py 0 0 100%
|
||||||
|
app/api/auth.py 52 0 100%
|
||||||
|
app/api/chat.py 178 0 100%
|
||||||
|
app/api/chats.py 110 0 100%
|
||||||
|
app/api/config.py 7 0 100%
|
||||||
|
app/api/doc_drafts.py 94 0 100%
|
||||||
|
app/api/docs.py 50 0 100%
|
||||||
|
app/api/git_sources.py 212 0 100%
|
||||||
|
app/api/health.py 10 0 100%
|
||||||
|
app/api/steering.py 42 0 100%
|
||||||
|
app/api/suggestions.py 29 0 100%
|
||||||
|
app/api/sync.py 98 0 100%
|
||||||
|
app/api/tokens.py 28 0 100%
|
||||||
|
app/config.py 141 0 100%
|
||||||
|
app/core/__init__.py 0 0 100%
|
||||||
|
app/core/auth.py 45 0 100%
|
||||||
|
app/core/caching.py 108 0 100%
|
||||||
|
app/core/debugging.py 29 2 93%
|
||||||
|
app/core/docs_push.py 39 0 100%
|
||||||
|
app/core/errors.py 5 0 100%
|
||||||
|
app/core/logging.py 13 0 100%
|
||||||
|
app/core/rate_limit.py 44 0 100%
|
||||||
|
app/core/security_headers.py 19 0 100%
|
||||||
|
app/core/tokens.py 33 0 100%
|
||||||
|
app/db.py 21 0 100%
|
||||||
|
app/main.py 64 0 100%
|
||||||
|
app/models.py 94 0 100%
|
||||||
|
app/rag/__init__.py 0 0 100%
|
||||||
|
app/rag/agent.py 222 0 100%
|
||||||
|
app/rag/archive_upload.py 128 0 100%
|
||||||
|
app/rag/chunker.py 206 4 98%
|
||||||
|
app/rag/git_sources.py 14 0 100%
|
||||||
|
app/rag/importer.py 180 3 98%
|
||||||
|
app/rag/llm.py 217 0 100%
|
||||||
|
app/rag/overview.py 71 0 100%
|
||||||
|
app/rag/prompts.py 88 0 100%
|
||||||
|
app/rag/retriever.py 150 3 98%
|
||||||
|
app/rag/scaffolding.py 55 0 100%
|
||||||
|
app/rag/source_removal.py 41 0 100%
|
||||||
|
app/rag/sources_meta.py 16 0 100%
|
||||||
|
app/rag/suggestions.py 27 0 100%
|
||||||
|
app/rag/summarizer.py 24 0 100%
|
||||||
|
app/schemas.py 232 0 100%
|
||||||
|
--------------------------------------------------
|
||||||
|
TOTAL 3237 12 99%
|
||||||
|
coverage gate: app/ 99% (>90%) OK
|
||||||
|
All checks passed!
|
||||||
|
0 errors, 0 warnings, 0 informations
|
||||||
|
validation OK
|
||||||
+21
@@ -0,0 +1,21 @@
|
|||||||
|
All gates verified. Every task-03 completion criterion is met in the working tree; no defects found in prior tasks' work.
|
||||||
|
|
||||||
|
**Phase 84 gate report — task 03 (verify) complete**
|
||||||
|
|
||||||
|
Implemented (by tasks 01–02, verified by me):
|
||||||
|
- `app/core/errors.py` (new): `sanitize_error` — verbatim lift of the sync `user:pass@` masker
|
||||||
|
- `app/api/sync.py`: local regex+function deleted, private alias import (callers `git_sources.py`/`docs.py` untouched)
|
||||||
|
- `app/api/doc_drafts.py`: docs-push 502 detail runs through `sanitize_error` (SEC-08)
|
||||||
|
- `app/rag/llm.py`: all five LLM error f-strings sanitize `llm_base_url` (SEC-13)
|
||||||
|
- New/extended pins: `tests/unit/test_error_sanitization.py`, `test_llm_client.py` (mask + byte-identical), `test_doc_drafts_api.py` (502 masks `ghp_LEAKTOKEN`, row untouched)
|
||||||
|
|
||||||
|
Gate results (all green):
|
||||||
|
- `uv run pytest` → 1714 passed, exit 0 (regression anchors incl. sync/upload, doc-drafts, LLM)
|
||||||
|
- `uv run pytest --cov=app --cov-report=term-missing` → `app/` TOTAL **99%** (>90% required)
|
||||||
|
- `uv run pytest tests/e2e/test_smoke.py -v --no-cov` → 3 passed in isolation
|
||||||
|
- `uv run ruff check . && uv run pyright` → clean (0 errors/warnings)
|
||||||
|
- `grep -rn _CREDS_RE app/ scripts/` → only `app/core/errors.py`; diff scope limited to the 7 code/test files + phase files; NO `scripts/`, `docs_push.py`, `frontend/`, `pyproject.toml`/`uv.lock`
|
||||||
|
|
||||||
|
Deviations (per harness executor rules, overriding task step 2–3): no `git add`/`commit` and no phase-dir move by me — all changes left in the working tree for the harness's one atomic `fix(security)` commit + todo→complete move.
|
||||||
|
|
||||||
|
Next pending task: none in phase 84 (03 was last); next phase: `85_mobile_menu_gate_overlap`.
|
||||||
+85
@@ -0,0 +1,85 @@
|
|||||||
|
........................................................................ [ 4%]
|
||||||
|
........................................................................ [ 8%]
|
||||||
|
........................................................................ [ 12%]
|
||||||
|
........................................................................ [ 16%]
|
||||||
|
........................................................................ [ 21%]
|
||||||
|
........................................................................ [ 25%]
|
||||||
|
........................................................................ [ 29%]
|
||||||
|
........................................................................ [ 33%]
|
||||||
|
........................................................................ [ 37%]
|
||||||
|
........................................................................ [ 42%]
|
||||||
|
........................................................................ [ 46%]
|
||||||
|
........................................................................ [ 50%]
|
||||||
|
........................................................................ [ 54%]
|
||||||
|
........................................................................ [ 58%]
|
||||||
|
........................................................................ [ 63%]
|
||||||
|
........................................................................ [ 67%]
|
||||||
|
........................................................................ [ 71%]
|
||||||
|
........................................................................ [ 75%]
|
||||||
|
........................................................................ [ 79%]
|
||||||
|
........................................................................ [ 84%]
|
||||||
|
........................................................................ [ 88%]
|
||||||
|
........................................................................ [ 92%]
|
||||||
|
........................................................................ [ 96%]
|
||||||
|
.......................................................... [100%]
|
||||||
|
=============================== warnings summary ===============================
|
||||||
|
.venv/lib/python3.13/site-packages/fastapi/testclient.py:1
|
||||||
|
/var/home/ducoterra/Projects/Personal/brain_of_reese/.venv/lib/python3.13/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
|
||||||
|
from starlette.testclient import TestClient as TestClient # noqa
|
||||||
|
|
||||||
|
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
|
||||||
|
================================ tests coverage ================================
|
||||||
|
_______________ coverage: platform linux, python 3.13.13-final-0 _______________
|
||||||
|
|
||||||
|
Name Stmts Miss Cover
|
||||||
|
--------------------------------------------------
|
||||||
|
app/__init__.py 1 0 100%
|
||||||
|
app/api/__init__.py 0 0 100%
|
||||||
|
app/api/auth.py 52 0 100%
|
||||||
|
app/api/chat.py 178 0 100%
|
||||||
|
app/api/chats.py 110 0 100%
|
||||||
|
app/api/config.py 7 0 100%
|
||||||
|
app/api/doc_drafts.py 94 0 100%
|
||||||
|
app/api/docs.py 50 0 100%
|
||||||
|
app/api/git_sources.py 212 0 100%
|
||||||
|
app/api/health.py 10 0 100%
|
||||||
|
app/api/steering.py 42 0 100%
|
||||||
|
app/api/suggestions.py 29 0 100%
|
||||||
|
app/api/sync.py 98 0 100%
|
||||||
|
app/api/tokens.py 28 0 100%
|
||||||
|
app/config.py 141 0 100%
|
||||||
|
app/core/__init__.py 0 0 100%
|
||||||
|
app/core/auth.py 45 0 100%
|
||||||
|
app/core/caching.py 108 0 100%
|
||||||
|
app/core/debugging.py 29 2 93%
|
||||||
|
app/core/docs_push.py 39 0 100%
|
||||||
|
app/core/errors.py 5 0 100%
|
||||||
|
app/core/logging.py 13 0 100%
|
||||||
|
app/core/rate_limit.py 44 0 100%
|
||||||
|
app/core/security_headers.py 19 0 100%
|
||||||
|
app/core/tokens.py 33 0 100%
|
||||||
|
app/db.py 21 0 100%
|
||||||
|
app/main.py 64 0 100%
|
||||||
|
app/models.py 94 0 100%
|
||||||
|
app/rag/__init__.py 0 0 100%
|
||||||
|
app/rag/agent.py 222 0 100%
|
||||||
|
app/rag/archive_upload.py 128 0 100%
|
||||||
|
app/rag/chunker.py 206 4 98%
|
||||||
|
app/rag/git_sources.py 14 0 100%
|
||||||
|
app/rag/importer.py 180 3 98%
|
||||||
|
app/rag/llm.py 217 0 100%
|
||||||
|
app/rag/overview.py 71 0 100%
|
||||||
|
app/rag/prompts.py 88 0 100%
|
||||||
|
app/rag/retriever.py 150 3 98%
|
||||||
|
app/rag/scaffolding.py 55 0 100%
|
||||||
|
app/rag/source_removal.py 41 0 100%
|
||||||
|
app/rag/sources_meta.py 16 0 100%
|
||||||
|
app/rag/suggestions.py 27 0 100%
|
||||||
|
app/rag/summarizer.py 24 0 100%
|
||||||
|
app/schemas.py 232 0 100%
|
||||||
|
--------------------------------------------------
|
||||||
|
TOTAL 3237 12 99%
|
||||||
|
coverage gate: app/ 99% (>90%) OK
|
||||||
|
All checks passed!
|
||||||
|
0 errors, 0 warnings, 0 informations
|
||||||
|
validation OK
|
||||||
+20
-8
@@ -26,8 +26,10 @@ triggers — commit + ``git push`` the draft's file to the
|
|||||||
:func:`app.core.docs_push.push_document`; success records
|
:func:`app.core.docs_push.push_document`; success records
|
||||||
``status`` / ``branch`` / ``commit_sha`` on the row and returns
|
``status`` / ``branch`` / ``commit_sha`` on the row and returns
|
||||||
``DocDraftPushed``; 409 while unconfigured, 422 on a path that no
|
``DocDraftPushed``; 409 while unconfigured, 422 on a path that no
|
||||||
longer passes the guard-rails, 502 on git failure with git's stderr
|
longer passes the guard-rails, 502 on git failure with the
|
||||||
in the detail — the row untouched).
|
SANITIZED git stderr in the detail (credential ``user:pass@``
|
||||||
|
userinfo masked by :func:`app.core.errors.sanitize_error` — phase
|
||||||
|
84, SEC-08) — the row untouched).
|
||||||
|
|
||||||
Every ``path`` (create, update **and** push) passes the shared
|
Every ``path`` (create, update **and** push) passes the shared
|
||||||
:func:`validate_draft_path` guard, so no draft can ever be created,
|
:func:`validate_draft_path` guard, so no draft can ever be created,
|
||||||
@@ -45,6 +47,7 @@ from sqlalchemy.orm import Session
|
|||||||
from app.config import Settings, get_settings
|
from app.config import Settings, get_settings
|
||||||
from app.core.auth import require_admin
|
from app.core.auth import require_admin
|
||||||
from app.core.docs_push import DocsPushError, push_document
|
from app.core.docs_push import DocsPushError, push_document
|
||||||
|
from app.core.errors import sanitize_error
|
||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
from app.models import DocDraft
|
from app.models import DocDraft
|
||||||
from app.schemas import DocDraft as DocDraftOut
|
from app.schemas import DocDraft as DocDraftOut
|
||||||
@@ -251,11 +254,16 @@ def push_doc_draft(
|
|||||||
3. the stored ``path`` re-runs :func:`validate_draft_path` → 422
|
3. the stored ``path`` re-runs :func:`validate_draft_path` → 422
|
||||||
on the first violated rule (a row must not be pushable into a
|
on the first violated rule (a row must not be pushable into a
|
||||||
bad path, whatever wrote it);
|
bad path, whatever wrote it);
|
||||||
4. :class:`DocsPushError` → 502 with ``detail=str(exc)`` — git's
|
4. :class:`DocsPushError` → 502 with the SANITIZED git stderr in
|
||||||
stderr, the ``GitSyncError`` → ``detail`` mapping from
|
the detail (phase 84, SEC-08): ``str(exc)`` runs through
|
||||||
:mod:`app.api.git_sources`. Only a SUCCESS mutates the row:
|
:func:`app.core.errors.sanitize_error`, which masks any
|
||||||
the failed push leaves ``status`` / ``branch`` / ``commit_sha``
|
``user:pass@`` userinfo git's stderr may echo (a
|
||||||
exactly as found (no partial commit).
|
``BOR_DOCS_REPO`` URL with embedded credentials) — the failing
|
||||||
|
repo and git's reason stay readable, and the
|
||||||
|
``GitSyncError`` → ``detail`` mapping from
|
||||||
|
:mod:`app.api.git_sources` is otherwise kept. Only a SUCCESS
|
||||||
|
mutates the row: the failed push leaves ``status`` / ``branch``
|
||||||
|
/ ``commit_sha`` exactly as found (no partial commit).
|
||||||
|
|
||||||
On success the row becomes ``status = "pushed"`` with the landed
|
On success the row becomes ``status = "pushed"`` with the landed
|
||||||
``branch`` and ``commit_sha`` (must equal ``git rev-parse
|
``branch`` and ``commit_sha`` (must equal ``git rev-parse
|
||||||
@@ -283,7 +291,11 @@ def push_doc_draft(
|
|||||||
commit_message=f"docs: {row.title}",
|
commit_message=f"docs: {row.title}",
|
||||||
)
|
)
|
||||||
except DocsPushError as exc:
|
except DocsPushError as exc:
|
||||||
raise HTTPException(status_code=502, detail=str(exc)) from None
|
# Phase 84 (SEC-08): git's stderr may echo the remote URL with
|
||||||
|
# embedded credentials — the RESPONSE is where the secret would
|
||||||
|
# leak, so it is sanitized here (the DocsPushError itself keeps
|
||||||
|
# carrying the full stderr for logs/inspection).
|
||||||
|
raise HTTPException(status_code=502, detail=sanitize_error(str(exc))) from None
|
||||||
row.status = "pushed"
|
row.status = "pushed"
|
||||||
row.branch = branch
|
row.branch = branch
|
||||||
row.commit_sha = sha
|
row.commit_sha = sha
|
||||||
|
|||||||
+5
-15
@@ -57,12 +57,15 @@ carries the phase-64 per-file progress — ``current_file`` (the
|
|||||||
``files_done`` / ``files_total`` — null/0/0 before the import starts
|
``files_done`` / ``files_total`` — null/0/0 before the import starts
|
||||||
(clone/pull reports no file yet) and in terminal states, which clear
|
(clone/pull reports no file yet) and in terminal states, which clear
|
||||||
``current_file`` but keep the run's final counts.
|
``current_file`` but keep the run's final counts.
|
||||||
|
|
||||||
|
The ``failed`` state's ``error`` string is masked by the shared
|
||||||
|
sanitizer — the ``user:pass@`` masker now lives in :mod:`app.core.errors`
|
||||||
|
(imported here under the private name ``_sanitize_error``).
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import re
|
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -72,6 +75,7 @@ from fastapi import APIRouter, Depends, HTTPException
|
|||||||
|
|
||||||
from app.config import get_settings
|
from app.config import get_settings
|
||||||
from app.core.auth import require_admin
|
from app.core.auth import require_admin
|
||||||
|
from app.core.errors import sanitize_error as _sanitize_error
|
||||||
from app.db import SessionLocal
|
from app.db import SessionLocal
|
||||||
from app.rag.git_sources import effective_sources
|
from app.rag.git_sources import effective_sources
|
||||||
from app.rag.importer import ImportSummary, import_sources
|
from app.rag.importer import ImportSummary, import_sources
|
||||||
@@ -89,20 +93,6 @@ router = APIRouter(
|
|||||||
dependencies=[Depends(require_admin)], # phase 16 pattern: admin-only surface
|
dependencies=[Depends(require_admin)], # phase 16 pattern: admin-only surface
|
||||||
)
|
)
|
||||||
|
|
||||||
#: ``user:pass@`` inside any error text (git stderr, endpoint URLs) —
|
|
||||||
#: masked so a sync failure can never leak credentials into the UI.
|
|
||||||
_CREDS_RE = re.compile(r"[A-Za-z0-9._~%*-]+:[A-Za-z0-9._~%*-]+@")
|
|
||||||
|
|
||||||
|
|
||||||
def _sanitize_error(message: str) -> str:
|
|
||||||
"""Mask credentials embedded in an error string (no secrets in the UI).
|
|
||||||
|
|
||||||
Git's stderr is otherwise surfaced verbatim (phase locked decisions) —
|
|
||||||
it names the failing repo and git's reason, which is what the admin
|
|
||||||
needs to fix things.
|
|
||||||
"""
|
|
||||||
return _CREDS_RE.sub("*****@", message)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class SyncStatus:
|
class SyncStatus:
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
"""Shared error-string sanitizer (phase 84 — audit SEC-08 / SEC-13).
|
||||||
|
|
||||||
|
``sanitize_error`` masks ``user:pass@`` userinfo in an error string so
|
||||||
|
no error surface ever ships an embedded credential into the UI, a
|
||||||
|
response body, or an SSE frame. It is the verbatim lift of the phase-32
|
||||||
|
sync masker (same regex, same ``*****@`` replacement — byte-identical
|
||||||
|
behavior); ``app/api/sync.py`` keeps the private name ``_sanitize_error``
|
||||||
|
as an alias import, and the docs-push 502 detail (SEC-08) and the LLM
|
||||||
|
error messages (SEC-13) run through the same function.
|
||||||
|
|
||||||
|
Audit basis (``.agents/remediation_plan.md``, security audit 2026-09-07):
|
||||||
|
|
||||||
|
* SEC-08 (Medium) — the docs-push 502 surfaced git's stderr verbatim;
|
||||||
|
``BOR_DOCS_REPO`` is documented as "any remote (URL or local path)",
|
||||||
|
so an ``https://user:token@host/...`` URL is a normal config shape,
|
||||||
|
and a failed push (revoked token, network) echoes the remote URL in
|
||||||
|
git's stderr straight into the browser and the logs. The sync path
|
||||||
|
already solved exactly this problem — the docs-push and LLM surfaces
|
||||||
|
simply never got the treatment.
|
||||||
|
* SEC-13 (Low) — the LLM error f-strings interpolated
|
||||||
|
``settings.llm_base_url`` raw; a base URL configured with embedded
|
||||||
|
credentials would echo into SSE ``error`` frames and logs. Same class
|
||||||
|
of leak, same fix.
|
||||||
|
|
||||||
|
Contract (narrow userinfo-regex only — byte-identical for
|
||||||
|
credential-free text):
|
||||||
|
|
||||||
|
* Only the git/HTTP ``user:pass@`` userinfo shape is rewritten — a run
|
||||||
|
of userinfo characters, the separating colon, a second run, and the
|
||||||
|
``@``. Ordinary text survives character for character: plain hosts,
|
||||||
|
``https://host/...`` URLs without userinfo, prose with a colon +
|
||||||
|
space (``fatal: ...``), emails in prose (no colon userinfo run
|
||||||
|
before the ``@``). The existing sync / git-sources / LLM error-string
|
||||||
|
tests are the tripwire proving the move is behavior-identical.
|
||||||
|
* The replacement shape is ``*****@`` — the existing sync copy, so
|
||||||
|
every already-pinned masked error stays byte-identical.
|
||||||
|
* Idempotent for the masked forms the surfaces produce (sanitizing an
|
||||||
|
already-sanitized git/URL failure string is a no-op).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
#: ``user:pass@`` inside any error text (git stderr, endpoint URLs) —
|
||||||
|
#: the narrow userinfo run (git/HTTP convention) that carries
|
||||||
|
#: credentials; everything else is left untouched.
|
||||||
|
_CREDS_RE = re.compile(r"[A-Za-z0-9._~%*-]+:[A-Za-z0-9._~%*-]+@")
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_error(message: str) -> str:
|
||||||
|
"""Mask credentials embedded in an error string (no secrets in the UI).
|
||||||
|
|
||||||
|
Git's stderr is otherwise surfaced verbatim (the admin needs the
|
||||||
|
failing repo and git's reason to fix things) — only the ``user:pass@``
|
||||||
|
userinfo shape is rewritten, to ``*****@``.
|
||||||
|
"""
|
||||||
|
return _CREDS_RE.sub("*****@", message)
|
||||||
+16
-6
@@ -29,6 +29,7 @@ from openai import AsyncOpenAI, AsyncStream
|
|||||||
from openai.types.chat import ChatCompletionChunk, ChatCompletionMessageParam
|
from openai.types.chat import ChatCompletionChunk, ChatCompletionMessageParam
|
||||||
|
|
||||||
from app.config import Settings, get_settings
|
from app.config import Settings, get_settings
|
||||||
|
from app.core.errors import sanitize_error
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
# Phase 71: the filter type is only needed for typing (the module
|
# Phase 71: the filter type is only needed for typing (the module
|
||||||
@@ -285,7 +286,11 @@ class LLMClient:
|
|||||||
raise
|
raise
|
||||||
except Exception as e: # noqa: BLE001 — wrap transport-level failures
|
except Exception as e: # noqa: BLE001 — wrap transport-level failures
|
||||||
raise EmbeddingError(
|
raise EmbeddingError(
|
||||||
f"embeddings request to {self.settings.llm_base_url} failed: {e}"
|
# Phase 84 (SEC-13): a base URL configured with
|
||||||
|
# embedded ``user:pass@`` credentials must not reach
|
||||||
|
# the error string — sanitized at construction.
|
||||||
|
f"embeddings request to {sanitize_error(self.settings.llm_base_url)} "
|
||||||
|
f"failed: {e}"
|
||||||
) from e
|
) from e
|
||||||
self.embed_batches += 1
|
self.embed_batches += 1
|
||||||
self._check_dims(vecs)
|
self._check_dims(vecs)
|
||||||
@@ -324,18 +329,20 @@ class LLMClient:
|
|||||||
raise
|
raise
|
||||||
except Exception as e: # noqa: BLE001 — wrap transport-level failures
|
except Exception as e: # noqa: BLE001 — wrap transport-level failures
|
||||||
raise LLMError(
|
raise LLMError(
|
||||||
f"chat completion from {self.settings.llm_base_url} failed: {e}"
|
# Phase 84 (SEC-13): sanitize the base URL (see embed).
|
||||||
|
f"chat completion from {sanitize_error(self.settings.llm_base_url)} "
|
||||||
|
f"failed: {e}"
|
||||||
) from e
|
) from e
|
||||||
if not resp.choices:
|
if not resp.choices:
|
||||||
raise LLMError(
|
raise LLMError(
|
||||||
f"chat completion from {self.settings.llm_base_url} "
|
f"chat completion from {sanitize_error(self.settings.llm_base_url)} "
|
||||||
"returned no choices"
|
"returned no choices"
|
||||||
)
|
)
|
||||||
content = resp.choices[0].message.content
|
content = resp.choices[0].message.content
|
||||||
if content is None or not content.strip():
|
if content is None or not content.strip():
|
||||||
raise LLMError(
|
raise LLMError(
|
||||||
f"chat completion from {self.settings.llm_base_url} returned "
|
f"chat completion from {sanitize_error(self.settings.llm_base_url)} "
|
||||||
"empty content — refusing to store a silent summary"
|
"returned empty content — refusing to store a silent summary"
|
||||||
)
|
)
|
||||||
return content.strip()
|
return content.strip()
|
||||||
|
|
||||||
@@ -483,7 +490,10 @@ class LLMClient:
|
|||||||
except LLMError:
|
except LLMError:
|
||||||
raise
|
raise
|
||||||
except Exception as e: # noqa: BLE001 — wrap transport-level failures
|
except Exception as e: # noqa: BLE001 — wrap transport-level failures
|
||||||
raise LLMError(f"chat stream from {self.settings.llm_base_url} failed: {e}") from e
|
# Phase 84 (SEC-13): sanitize the base URL (see embed).
|
||||||
|
raise LLMError(
|
||||||
|
f"chat stream from {sanitize_error(self.settings.llm_base_url)} failed: {e}"
|
||||||
|
) from e
|
||||||
finally:
|
finally:
|
||||||
# Phase 48: deterministic teardown — whenever ``create()``
|
# Phase 48: deterministic teardown — whenever ``create()``
|
||||||
# succeeded, close the endpoint's stream on every subsequent
|
# succeeded, close the endpoint's stream on every subsequent
|
||||||
|
|||||||
@@ -23,7 +23,9 @@ import pytest
|
|||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
from sqlalchemy import select, text
|
from sqlalchemy import select, text
|
||||||
|
|
||||||
|
import app.api.doc_drafts as doc_drafts
|
||||||
from app.config import Settings, get_settings
|
from app.config import Settings, get_settings
|
||||||
|
from app.core.docs_push import DocsPushError
|
||||||
from app.main import app as fastapi_app
|
from app.main import app as fastapi_app
|
||||||
from app.models import DocDraft
|
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
|
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(
|
def test_push_unknown_token_returns_404(
|
||||||
admin_client: TestClient, docs_push_settings: Settings
|
admin_client: TestClient, docs_push_settings: Settings
|
||||||
) -> None:
|
) -> 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
|
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:
|
def test_missing_vector_row_is_rejected() -> None:
|
||||||
llm, _ = _make_client(_FakeEmbeddingsService(drop_index=1))
|
llm, _ = _make_client(_FakeEmbeddingsService(drop_index=1))
|
||||||
with pytest.raises(EmbeddingError, match="returned 1 vectors for 2 inputs"):
|
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)
|
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:
|
def test_chat_llm_error_passes_through_unwrapped() -> None:
|
||||||
llm, _ = _make_chat_client(fail=LLMError("already wrapped"))
|
llm, _ = _make_chat_client(fail=LLMError("already wrapped"))
|
||||||
with pytest.raises(LLMError, match="already wrapped"):
|
with pytest.raises(LLMError, match="already wrapped"):
|
||||||
|
|||||||
Reference in New Issue
Block a user