All gates green. Verification complete. **Phase 123 — final verification pass (all 4 tasks already in `complete/`)** - Verified the full implementation is in the working tree: `app/api/chat_images.py` (upload/serve pair), `ChatRequest.image`/`ChatMessage.image` (path-validated, omitted-when-None), toggle-off + stale-file hinted error frames, `build_user_content` multimodal build at both sites (chat.py deflected branch + `run_agent`), config-gated composer attach/preview/upload-then-send, restore + shared rendering, CSP `img-src 'self' data:` carve-out, mock-LLM capture buffer. - `uv run pytest` → **2796 passed**, exit 0 (unit + integration). - `uv run pytest --cov=app --cov-report=term-missing` → **TOTAL 99%** (29/4615 missed; phase-123 modules 99–100%). - `uv run pytest tests/e2e/test_chat_image_questions.py -v --no-cov` → **5 passed** in isolation. - `uv run ruff check . && uv run pyright` → clean (0 errors). **Completion criteria:** (1) attach→send→multimodal text+image to the model, bubble/reload/shared all render it, saved chat stores the PATH with `"base64" not in json.dumps(stored)` — **verified** (E2E tests 1–4 + integration round-trip); (2) `BOR_IMAGES=false` — control hidden, exact hinted error frame, zero model calls / no query_log row — **verified** (E2E test 5 + integration); (3) text-only byte-identical (`content` stays a plain `str`) — **verified** (unit + integration); (4) all gates green — **verified**; (5) commit + phase move — left to the harness per pipeline rules (no `git add`/`commit` run). No defects found; no live-infrastructure changes (repo + local dev DB only). **Next pending phase: none** — 123 is the last phase in `todo/`.
134 lines
5.6 KiB
Python
134 lines
5.6 KiB
Python
"""Phase 82 E2E (Playwright): the security headers reach a real browser and
|
|
the strict CSP does not break the page.
|
|
|
|
Story: n/a — security hardening, audit SEC-04 (``.agents/remediation_plan.md``).
|
|
Run in isolation (DB must be up: ``podman compose up -d db``):
|
|
|
|
uv run pytest tests/e2e/test_security_headers.py -v --no-cov
|
|
|
|
No LLM interaction: the page load IS the contract. The suite never touches
|
|
the chat stream and never depends on the aipi endpoint — the shared
|
|
``app_server`` fixture's deterministic LOCAL mock LLM is simply unused.
|
|
|
|
Test → task mapping (phase 82, task 03):
|
|
1. ``test_chat_page_navigation_headers_and_csp_clean`` — a real ``page.goto``
|
|
of the chat shell (``/``): the navigation response carries all three
|
|
headers (CSP exactly the A1 string), the page painted under the CSP
|
|
(the dark-tech canvas applied → the stylesheet loaded), the shell's
|
|
``#main`` booted, and zero console CSP violations.
|
|
2. ``test_sources_page_navigation_headers_and_csp_clean`` — the same header
|
|
+ no-violation contract for the second page, ``/sources.html``.
|
|
|
|
Paint-check note (vs. the task text): the task suggested asserting the
|
|
``body`` background-color is not transparent, but the pinned design
|
|
contract (phase 08, ``test_dark_tech_theme.py``) puts the visible canvas on
|
|
``<html>`` (``html { background: var(--bg) }``) and keeps ``<body>``
|
|
transparent on purpose (the z-index:-1 grid layer must show). The
|
|
stylesheet-loaded-under-the-CSP contract is therefore pinned on the
|
|
``<html>`` canvas color — the same value phase 08 pins.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
|
|
from playwright.sync_api import ConsoleMessage, Page, expect
|
|
|
|
#: The exact owner-approved policy (phase 82, decision A1), with the
|
|
#: phase-123 img-src carve-out (the question-image composer's data-URL
|
|
#: preview + live bubble — ``data:`` is allowed for images ONLY).
|
|
CSP = (
|
|
"default-src 'self'; base-uri 'none'; frame-ancestors 'none'; "
|
|
"img-src 'self' data:"
|
|
)
|
|
|
|
#: Chromium reports CSP denials to the console with this phrasing
|
|
#: ("Refused to … because it violates the following Content Security
|
|
#: Policy directive …" / "The Content Security Policy directive …
|
|
#: prevents execution …").
|
|
_CSP_CONSOLE = re.compile(r"Content Security Policy", re.IGNORECASE)
|
|
|
|
#: The dark-tech canvas — ``frontend/assets/styles.css`` ``--bg: #0f0a0a``,
|
|
#: pinned by phase 08 (``test_dark_tech_theme.py``).
|
|
CANVAS_BG = "rgb(15, 10, 10)"
|
|
|
|
|
|
def _attach_csp_watch(page: Page) -> list[str]:
|
|
"""Record every console message that mentions a CSP denial.
|
|
|
|
Attached BEFORE navigation (the contract — denials of the main
|
|
document's resources are logged while it loads). Returns the live
|
|
list; assert it is empty after the load.
|
|
"""
|
|
violations: list[str] = []
|
|
|
|
def _on_console(msg: ConsoleMessage) -> None:
|
|
if _CSP_CONSOLE.search(msg.text):
|
|
violations.append(msg.text)
|
|
|
|
page.on("console", _on_console)
|
|
return violations
|
|
|
|
|
|
def _assert_navigation_headers(page: Page, url: str) -> None:
|
|
"""A real navigation: the response carries all three headers, CSP exact."""
|
|
response = page.goto(url)
|
|
assert response is not None, f"no navigation response for {url}"
|
|
assert response.status == 200, f"{url} → {response.status}"
|
|
headers = response.headers # lowercase keys
|
|
assert headers.get("content-security-policy") == CSP, (
|
|
f"{url}: CSP is {headers.get('content-security-policy')!r}, "
|
|
f"expected the exact A1 string {CSP!r}"
|
|
)
|
|
assert headers.get("x-frame-options") == "DENY", (
|
|
f"{url}: X-Frame-Options is {headers.get('x-frame-options')!r}"
|
|
)
|
|
assert headers.get("x-content-type-options") == "nosniff", (
|
|
f"{url}: X-Content-Type-Options is {headers.get('x-content-type-options')!r}"
|
|
)
|
|
|
|
|
|
def _assert_chat_page_painted(page: Page) -> None:
|
|
"""The page painted under the CSP — styles loaded, shell booted.
|
|
|
|
The visible canvas is ``<html>`` (see the module docstring): its
|
|
computed color can only be the dark-tech palette if ``/assets/*.css``
|
|
was fetched and applied under ``default-src 'self'`` — a CSP-broken
|
|
document would be a white/default canvas without ``#main`` styled.
|
|
"""
|
|
bg = page.evaluate(
|
|
"() => getComputedStyle(document.documentElement).backgroundColor"
|
|
)
|
|
assert bg == CANVAS_BG, (
|
|
f"dark-tech canvas not applied under the CSP (stylesheet not loaded?): {bg}"
|
|
)
|
|
expect(page.locator("#main")).to_be_attached()
|
|
box = page.locator("#main").bounding_box()
|
|
assert box is not None and box["width"] > 0 and box["height"] > 0, (
|
|
"shell <main> is present but invisible/empty — the page did not boot"
|
|
)
|
|
|
|
|
|
def test_chat_page_navigation_headers_and_csp_clean(
|
|
page: Page, app_url: str, db_ready: None
|
|
) -> None:
|
|
"""AC1: real navigation of the chat shell → three headers (CSP exact),
|
|
painted page, zero CSP violations on the console."""
|
|
violations = _attach_csp_watch(page)
|
|
_assert_navigation_headers(page, app_url + "/")
|
|
_assert_chat_page_painted(page)
|
|
# Console delivery is async — let pending messages settle before the
|
|
# zero-violation gate (the load event already fired).
|
|
page.wait_for_timeout(250)
|
|
assert violations == [], f"CSP violations on the chat page: {violations}"
|
|
|
|
|
|
def test_sources_page_navigation_headers_and_csp_clean(
|
|
page: Page, app_url: str, db_ready: None
|
|
) -> None:
|
|
"""AC2: the second page (Sources) — same three headers, no CSP
|
|
violations."""
|
|
violations = _attach_csp_watch(page)
|
|
_assert_navigation_headers(page, app_url + "/sources.html")
|
|
page.wait_for_timeout(250)
|
|
assert violations == [], f"CSP violations on the sources page: {violations}"
|