phase: 123_chat_image_questions
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/`.
This commit is contained in:
@@ -1083,6 +1083,16 @@ _HTTPS_PER_DEAD_ATTEMPT = 3
|
||||
#: re-drives the failure sequence from zero.
|
||||
_fail_posts: dict[str, int] = {}
|
||||
|
||||
#: Phase 123 (task 04 — question images): a small ring buffer of the
|
||||
#: recent ``/v1/chat/completions`` request bodies, exposed on the
|
||||
#: ``/v1/e2e/captured`` pair below. Purely OBSERVATIONAL (the buffer
|
||||
#: never influences an answer — determinism is untouched): it lets a
|
||||
#: story suite assert on the EXACT request the app built — e.g. that a
|
||||
#: question-image turn delivered the multimodal user content list
|
||||
#: (text part + ``image_url`` data URL) to the model.
|
||||
_CAPTURED: list[dict[str, Any]] = []
|
||||
_CAPTURE_MAX = 100
|
||||
|
||||
|
||||
def _llm_500(why: str) -> JSONResponse:
|
||||
"""A dead-proxy 500 with a JSON error body (phase 67 injection)."""
|
||||
@@ -2416,6 +2426,23 @@ def compose_thinking_paragraphs(body: dict[str, Any]) -> str:
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
@app.get("/v1/e2e/captured")
|
||||
def e2e_captured() -> list[dict[str, Any]]:
|
||||
"""Phase 123 (task 04): the recent chat-completions request bodies
|
||||
(oldest first, capped at ``_CAPTURED_MAX``) — the mock's capture
|
||||
for request-shape assertions (see ``_CAPTURED``)."""
|
||||
return _CAPTURED
|
||||
|
||||
|
||||
@app.post("/v1/e2e/captured/reset")
|
||||
def e2e_captured_reset() -> dict[str, int]:
|
||||
"""Phase 123 (task 04): clear the capture so a suite can assert on
|
||||
exactly the requests of the turn it is about to drive (e.g. the
|
||||
toggle-off rejection proves ZERO chat calls — an empty capture)."""
|
||||
_CAPTURED.clear()
|
||||
return {"cleared": True}
|
||||
|
||||
|
||||
@app.post("/__shutdown__")
|
||||
def shutdown() -> dict[str, Any]:
|
||||
"""Test hook (loading-feedback story): terminate this mock process to
|
||||
@@ -2619,6 +2646,12 @@ def _tool_call_stream(name: str, arguments: dict[str, Any], call_id: str) -> Any
|
||||
|
||||
@app.post("/v1/chat/completions")
|
||||
def chat_completions(body: dict[str, Any]) -> Any:
|
||||
# Phase 123 (task 04): the OBSERVATIONAL capture (the ring buffer —
|
||||
# recorded before any flow decision, so a 500-injected request is
|
||||
# captured too; the buffer never touches the answer path).
|
||||
_CAPTURED.append(body)
|
||||
if len(_CAPTURED) > _CAPTURE_MAX:
|
||||
del _CAPTURED[: len(_CAPTURED) - _CAPTURE_MAX]
|
||||
user_lower = _user(body).lower()
|
||||
# Phase 37 (agent document tools): the deterministic marker flow.
|
||||
# The app's chat path is the only streaming consumer of this mock, so
|
||||
|
||||
@@ -0,0 +1,626 @@
|
||||
"""Phase 123 E2E (Playwright): chat image questions — attach an image to a
|
||||
question (TODO L6).
|
||||
|
||||
Run in isolation (DB must be up: ``podman compose up -d db``):
|
||||
|
||||
uv run pytest tests/e2e/test_chat_image_questions.py -v --no-cov
|
||||
|
||||
The suite's app instance runs with ``BOR_IMAGES=true`` (module env
|
||||
override — the conftest per-suite-app pattern, leak guards included;
|
||||
the question-image store is a suite-private scratch dir so the app
|
||||
under test never writes into the owner's real ``~/bor-sources``), and
|
||||
a SECOND module app runs with ``BOR_IMAGES`` forced ``false`` (the
|
||||
default contract — both apps pin the toggle EXPLICITLY, so an
|
||||
operator's local ``.env`` cannot leak it in either direction).
|
||||
|
||||
The KB is deliberately NOT seeded (each test truncates it): the
|
||||
question-image turn is a DETERMINISTIC deflection (no chunks → LOW
|
||||
→ the mock's honest "I haven't done anything like that" answer) —
|
||||
the deflected branch is a construction site for the multimodal user
|
||||
message (pinned by ``app/api/chat.py``'s docstring), and the answer
|
||||
the mock streams is independent of the image part (the mock's
|
||||
``_content_text`` maps a part list to its text parts).
|
||||
|
||||
* attach a fixture PNG in the composer → the preview strip shows
|
||||
(thumbnail ≤48px + the filename) → remove → the strip clears and
|
||||
the file state is gone (a fresh pick re-renders in place);
|
||||
* re-attach → send → exactly ONE upload, the user bubble shows the
|
||||
image (the live data URL, alt = the filename), the preview strip
|
||||
must not linger into the turn, the mock's (text-only) answer
|
||||
streams, and — via the mock's capture (``/v1/e2e/captured``) —
|
||||
the model RECEIVED the multimodal user content list (the text
|
||||
part == the question + the ``image_url`` data URL that decodes to
|
||||
the uploaded bytes);
|
||||
* the saved record carries the stored PATH (A5: never base64 —
|
||||
nothing base64 crosses the localStorage boundary);
|
||||
* ``page.reload()`` → the user bubble restores WITH its image from
|
||||
the stored path (the image route's request count confirms a PATH
|
||||
fetch, not an inline data URL);
|
||||
* share the chat → a fresh anonymous context opening the shared link
|
||||
sees the user's image on the shared page (the serve route is
|
||||
public — the shared view is faithful);
|
||||
* default-off negative (the flag-off app): ``#attach-btn`` stays
|
||||
hidden for good (the default-off contract) and a direct
|
||||
``POST /api/chat`` with an ``image`` settles the hinted error
|
||||
frame with ZERO model calls (the capture stays empty).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from playwright.sync_api import Page, expect
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.config import Settings
|
||||
from app.db import SessionLocal
|
||||
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]
|
||||
|
||||
#: A real 1×1 transparent PNG (the unit/integration suites' fixture —
|
||||
#: the pipeline is content-agnostic; the well-formed bytes keep the
|
||||
#: upload + serve + render + capture pins honest).
|
||||
PNG_1X1 = base64.b64decode(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJ"
|
||||
"AAAAC0lEQVR4nGP4DwQACfsD/fteaysAAAAASUVORK5CYII="
|
||||
)
|
||||
PNG_NAME = "diagram.png"
|
||||
|
||||
QUESTION = "What is in this screenshot? (chat-images)"
|
||||
TEXT_ONLY_QUESTION = "How is my Kubernetes cluster set up? (chat-images text-only)"
|
||||
STORAGE_KEY = "bor.chat.v1"
|
||||
DEFLECT_PHRASE = r"haven't done anything like that"
|
||||
IMAGE_PATH_RE = re.compile(r"^/api/chat-images/[0-9a-f]{32}\.png$")
|
||||
SHARE_URL_RE = re.compile(r"^/shared/[0-9a-f-]{36}$")
|
||||
TURN_TIMEOUT_MS = 30_000
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Module apps: images ON (the story) and images forced OFF (the default
|
||||
# contract). Each owns its port + its scratch question-image dir.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _app_env(mock_llm: int, app_port: int, *, images: bool, scratch: Path) -> dict[str, str]:
|
||||
"""The conftest app env (leak guards included) with the phase-123
|
||||
knobs: ``BOR_IMAGES`` pinned EXPLICITLY (true for the story app,
|
||||
false for the default app — process env ranks above an operator's
|
||||
local gitignored ``.env``, so the contract under test cannot leak
|
||||
in either direction) and the question-image store in the suite's
|
||||
scratch dir (the app under test must not write into the owner's
|
||||
real ``~/bor-sources``)."""
|
||||
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) — with an UNSEEDED KB
|
||||
# (this suite's determinism) every turn is a deflection anyway;
|
||||
# the pins keep the gate's quadrant stable if the dev KB leaks in.
|
||||
env["BOR_RELEVANCE_THRESHOLD"] = "0.30"
|
||||
env["BOR_LEXICAL_SUPPORT_FLOOR"] = "0.15"
|
||||
env["BOR_SOURCE_USEFULNESS_FLOOR"] = "0.15"
|
||||
# Phase 67: instant retry waits + the code-default budget.
|
||||
env["BOR_LLM_RETRY_DELAY"] = "0"
|
||||
env["BOR_LLM_RETRIES"] = str(Settings.model_fields["llm_retries"].default)
|
||||
env.setdefault(
|
||||
"BOR_DATABASE_URL",
|
||||
"postgresql+psycopg://reese:reese@localhost:5432/brain_of_reese",
|
||||
)
|
||||
env["BOR_ADMIN_PASSWORD"] = ADMIN_PASSWORD
|
||||
env["BOR_SESSION_SECRET"] = SESSION_SECRET
|
||||
# Leak guards (conftest pattern).
|
||||
env["BOR_DOCS_REPO"] = ""
|
||||
env["BOR_SUGGESTIONS"] = json.dumps(Settings.model_fields["suggestions"].default)
|
||||
env["BOR_INPUT_PLACEHOLDER"] = Settings.model_fields["input_placeholder"].default
|
||||
env["BOR_FOOTER_TEXT"] = Settings.model_fields["footer_text"].default
|
||||
# Phase 123: the toggle under test + the suite-private image store.
|
||||
env["BOR_IMAGES"] = "true" if images else "false"
|
||||
env["BOR_CHAT_IMAGE_DIR"] = str(scratch / "chat-images")
|
||||
return env
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def app_server(mock_llm: int, tmp_path_factory: pytest.TempPathFactory) -> Iterator[str]:
|
||||
"""The story app — ``BOR_IMAGES=true`` (the module env override;
|
||||
the conftest session app is never started in this isolated run,
|
||||
so no port clash)."""
|
||||
scratch = tmp_path_factory.mktemp("bor_chat_image_on")
|
||||
port = int(os.environ.get("E2E_APP_PORT_CHAT_IMAGES", "8160"))
|
||||
proc = subprocess.Popen(
|
||||
[sys.executable, "-m", "uvicorn", "app.main:app",
|
||||
"--host", "127.0.0.1", "--port", str(port), "--log-level", "warning"],
|
||||
cwd=REPO,
|
||||
env=_app_env(mock_llm, port, images=True, scratch=scratch),
|
||||
)
|
||||
try:
|
||||
_wait_http(f"http://127.0.0.1:{port}/api/health")
|
||||
yield f"http://127.0.0.1:{port}"
|
||||
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
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def default_app_server(
|
||||
mock_llm: int, tmp_path_factory: pytest.TempPathFactory
|
||||
) -> Iterator[str]:
|
||||
"""The default-contract app — ``BOR_IMAGES=false`` (only the
|
||||
negative test starts it)."""
|
||||
scratch = tmp_path_factory.mktemp("bor_chat_image_off")
|
||||
port = int(os.environ.get("E2E_APP_PORT_CHAT_IMAGES_OFF", "8161"))
|
||||
proc = subprocess.Popen(
|
||||
[sys.executable, "-m", "uvicorn", "app.main:app",
|
||||
"--host", "127.0.0.1", "--port", str(port), "--log-level", "warning"],
|
||||
cwd=REPO,
|
||||
env=_app_env(mock_llm, port, images=False, scratch=scratch),
|
||||
)
|
||||
try:
|
||||
_wait_http(f"http://127.0.0.1:{port}/api/health")
|
||||
yield f"http://127.0.0.1:{port}"
|
||||
finally:
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def default_app_url(default_app_server: str) -> str:
|
||||
return default_app_server
|
||||
|
||||
|
||||
def _reset_db(mock_port: int, seed: bool) -> None:
|
||||
"""Truncate the KB (and the turn log) so every turn in this suite
|
||||
is a deterministic deflection. ``seed`` is always False here — a
|
||||
question image is a separate concern from document ingestion (it
|
||||
is NEVER indexed as a document), so the suite never imports."""
|
||||
with SessionLocal() as db:
|
||||
db.execute(text("TRUNCATE chunks, documents, query_log, steering_notes"))
|
||||
db.commit()
|
||||
assert seed is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Browser helpers (the composer's attach flow + the turn's settle)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _png_file(tmp_path: Path) -> Path:
|
||||
png = tmp_path / PNG_NAME
|
||||
png.write_bytes(PNG_1X1)
|
||||
return png
|
||||
|
||||
|
||||
def _attach(page: Page, png: Path) -> None:
|
||||
"""One file pick in the composer's HIDDEN file input (the
|
||||
paperclip button's backend — the native picker is replaced by
|
||||
``set_input_files``, the E2E's standard input simulation): the
|
||||
preview strip must reveal with the data-URL thumbnail + name."""
|
||||
page.set_input_files("#attach-file", str(png))
|
||||
strip = page.locator("#attach-preview")
|
||||
expect(strip).to_be_visible(timeout=TURN_TIMEOUT_MS)
|
||||
expect(page.locator("#attach-preview .attach-preview-name")).to_have_text(PNG_NAME)
|
||||
thumb = page.locator("#attach-preview img")
|
||||
src = thumb.get_attribute("src") or ""
|
||||
assert src.startswith("data:image/png;base64,"), "the thumbnail is the live data URL"
|
||||
|
||||
|
||||
def _wait_deflected_turn(page: Page) -> None:
|
||||
"""Wait until the (unseeded-KB) turn has fully settled — the
|
||||
deflected answer streamed and the Send button is back (the
|
||||
``done`` frame restored it)."""
|
||||
bubble = page.locator(".msg.brain.is-deflected .bubble").first
|
||||
bubble.wait_for(state="visible", timeout=TURN_TIMEOUT_MS)
|
||||
expect(bubble).to_contain_text(
|
||||
re.compile(DEFLECT_PHRASE, re.IGNORECASE), timeout=TURN_TIMEOUT_MS
|
||||
)
|
||||
expect(page.locator("#send-btn")).to_be_enabled()
|
||||
expect(page.locator("#send-label")).to_have_text("Send")
|
||||
|
||||
|
||||
def _stored(page: Page) -> dict[str, Any] | None:
|
||||
raw = page.evaluate(f"() => localStorage.getItem('{STORAGE_KEY}')")
|
||||
return json.loads(raw) if raw else None
|
||||
|
||||
|
||||
def _admin_cookies(page: Page) -> dict[str, str]:
|
||||
return {
|
||||
c["name"]: c["value"]
|
||||
for c in page.context.cookies()
|
||||
if "name" in c and "value" in c
|
||||
}
|
||||
|
||||
|
||||
def _chats(app_url: str, cookies: dict[str, str]) -> list[dict[str, Any]]:
|
||||
r = httpx.get(f"{app_url}/api/chats", timeout=10, cookies=cookies)
|
||||
assert r.status_code == 200
|
||||
return r.json()["chats"]
|
||||
|
||||
|
||||
def _find_row(rows: list[dict[str, Any]], title: str) -> dict[str, Any] | None:
|
||||
return next((c for c in rows if c["title"] == title), None)
|
||||
|
||||
|
||||
def _auto_title(question: str) -> str:
|
||||
"""The phase-50 auto-title convention: the first question,
|
||||
whitespace-collapsed, capped at 120 chars."""
|
||||
return " ".join(question.split())[:120]
|
||||
|
||||
|
||||
def _wait_saved_row(
|
||||
app_url: str,
|
||||
cookies: dict[str, str],
|
||||
title: str,
|
||||
messages: int = 2,
|
||||
) -> dict[str, Any]:
|
||||
"""Wait for the auto-saved row (phase 55: auto-saves are SILENT —
|
||||
A2 — so there is no status line to wait on)."""
|
||||
deadline = time.monotonic() + 15
|
||||
last: dict[str, Any] | None = None
|
||||
while time.monotonic() < deadline:
|
||||
last = _find_row(_chats(app_url, cookies), title)
|
||||
if last is not None and last["message_count"] >= messages:
|
||||
return last
|
||||
time.sleep(0.2)
|
||||
raise AssertionError(f"no auto-saved row for {title!r} (last: {last!r})")
|
||||
|
||||
|
||||
def _delete_row(app_url: str, cookies: dict[str, str], chat_id: str) -> None:
|
||||
"""Best-effort row cleanup (a 404 — already deleted — is fine)."""
|
||||
httpx.delete(f"{app_url}/api/chats/{chat_id}", timeout=10, cookies=cookies)
|
||||
|
||||
|
||||
def _send_with_attachment(page: Page, png: Path) -> tuple[str, list[str]]:
|
||||
"""Attach → type → send → wait for the deflected settle. Returns
|
||||
(the record's stored image PATH, the upload request URLs)."""
|
||||
uploads: list[str] = []
|
||||
|
||||
def _on_request(req: Any) -> None:
|
||||
if req.method == "POST" and req.url.endswith("/api/chat-images"):
|
||||
uploads.append(req.url)
|
||||
|
||||
page.on("request", _on_request)
|
||||
_attach(page, png)
|
||||
page.fill("#message-input", QUESTION)
|
||||
page.click("#send-btn")
|
||||
img = page.locator(".msg.user .msg-image").first
|
||||
expect(img).to_be_visible(timeout=TURN_TIMEOUT_MS)
|
||||
_wait_deflected_turn(page)
|
||||
stored = _stored(page)
|
||||
assert stored is not None, "the conversation must be persisted (save point 1)"
|
||||
path = stored["messages"][0]["image"]
|
||||
assert IMAGE_PATH_RE.fullmatch(path), f"the record must carry the stored PATH: {path!r}"
|
||||
return path, uploads
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The mock's capture (phase 123, task 04 — the request the SERVER built)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _mock_base(mock_llm: int) -> str:
|
||||
return f"http://127.0.0.1:{mock_llm}"
|
||||
|
||||
|
||||
def _reset_capture(mock_llm: int) -> None:
|
||||
r = httpx.post(f"{_mock_base(mock_llm)}/v1/e2e/captured/reset", timeout=10)
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
def _captured(mock_llm: int) -> list[dict[str, Any]]:
|
||||
r = httpx.get(f"{_mock_base(mock_llm)}/v1/e2e/captured", timeout=10)
|
||||
assert r.status_code == 200
|
||||
return r.json()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. Attach → preview → remove (the composer's draft state)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_attach_preview_shows_and_remove_clears(
|
||||
page: Page, app_url: str, mock_llm: int, db_ready: None, tmp_path: Path
|
||||
) -> None:
|
||||
_reset_db(mock_llm, seed=False)
|
||||
page.set_default_timeout(30_000)
|
||||
login(page, app_url, next="/")
|
||||
|
||||
# Flag on (the story app): the paperclip is revealed at boot, with
|
||||
# its accessible name (the SVG is decorative).
|
||||
btn = page.locator("#attach-btn")
|
||||
expect(btn).to_be_visible()
|
||||
expect(btn).to_have_attribute("aria-label", "Attach an image")
|
||||
|
||||
png = _png_file(tmp_path)
|
||||
_attach(page, png)
|
||||
|
||||
# The strip: the data-URL thumbnail + the filename (the readable
|
||||
# label) + the remove ✕ (its accessible name).
|
||||
expect(page.locator("#attach-preview img")).to_be_visible()
|
||||
remove = page.locator("#attach-remove")
|
||||
expect(remove).to_be_visible()
|
||||
expect(remove).to_have_attribute("aria-label", "Remove the attached image")
|
||||
|
||||
# Remove: the strip clears and the file state is GONE — a fresh
|
||||
# pick re-renders the strip in place (the state was truly reset,
|
||||
# not merely hidden under a stale one).
|
||||
page.click("#attach-remove")
|
||||
expect(page.locator("#attach-preview")).to_be_hidden()
|
||||
_attach(page, png)
|
||||
expect(page.locator("#attach-preview")).to_be_visible()
|
||||
expect(page.locator("#attach-preview .attach-preview-name")).to_have_text(PNG_NAME)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. Send with an attachment: one upload, the image in the bubble, the
|
||||
# multimodal request at the mock, the PATH (never base64) in storage
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_send_with_attached_image_delivers_the_multimodal_request(
|
||||
page: Page, app_url: str, mock_llm: int, db_ready: None, tmp_path: Path
|
||||
) -> None:
|
||||
_reset_db(mock_llm, seed=False)
|
||||
page.set_default_timeout(30_000)
|
||||
login(page, app_url, next="/")
|
||||
png = _png_file(tmp_path)
|
||||
|
||||
_reset_capture(mock_llm) # exactly the requests of THIS turn
|
||||
path, uploads = _send_with_attachment(page, png)
|
||||
|
||||
# A8's ordering at the wire level: EXACTLY one upload (the double-
|
||||
# fire guard never let a second through) and it preceded the turn.
|
||||
assert len(uploads) == 1
|
||||
|
||||
# The live user bubble: the data URL (no fetch), alt = the
|
||||
# filename; the preview strip must not linger into the turn.
|
||||
img = page.locator(".msg.user .msg-image").first
|
||||
src = img.get_attribute("src") or ""
|
||||
assert src.startswith("data:image/png;base64,"), "the live bubble uses the data URL"
|
||||
assert base64.b64decode(src.split(",", 1)[1]) == PNG_1X1
|
||||
assert img.get_attribute("alt") == PNG_NAME
|
||||
expect(page.locator("#attach-preview")).to_be_hidden()
|
||||
|
||||
# The mock (text-only) answer streamed normally (the mock ignores
|
||||
# the image part) — and the REQUEST it received is the multimodal
|
||||
# user content list: text part == the question + the image_url
|
||||
# data URL that decodes to the uploaded bytes (the server built
|
||||
# it from the stored file + the phase-122 mime map).
|
||||
captured = _captured(mock_llm)
|
||||
assert len(captured) == 1, "the deflected turn is exactly one model request"
|
||||
user = captured[0]["messages"][-1]
|
||||
assert user["role"] == "user"
|
||||
assert user["content"] == [
|
||||
{"type": "text", "text": QUESTION},
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {
|
||||
"url": f"data:image/png;base64,{base64.b64encode(PNG_1X1).decode('ascii')}"
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
# A5 at the storage boundary: the record carries the PATH, and
|
||||
# NOTHING base64 crossed into the localStorage payload.
|
||||
stored = _stored(page)
|
||||
assert stored is not None
|
||||
assert stored["messages"][0]["image"] == path
|
||||
assert "base64" not in json.dumps(stored)
|
||||
|
||||
# Cleanup: drop the auto-saved row (the dev DB is shared).
|
||||
cookies = _admin_cookies(page)
|
||||
row = _wait_saved_row(app_url, cookies, _auto_title(QUESTION))
|
||||
_delete_row(app_url, cookies, row["id"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. Reload: the user bubble restores WITH its image (from the stored
|
||||
# path — the image route's request count confirms the path fetch)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_reload_restores_the_image_from_the_stored_path(
|
||||
page: Page, app_url: str, mock_llm: int, db_ready: None, tmp_path: Path
|
||||
) -> None:
|
||||
_reset_db(mock_llm, seed=False)
|
||||
page.set_default_timeout(30_000)
|
||||
login(page, app_url, next="/")
|
||||
png = _png_file(tmp_path)
|
||||
|
||||
path, _uploads = _send_with_attachment(page, png)
|
||||
|
||||
# Track the image route's fetches from here on — the restored
|
||||
# bubble must load the image by FETCHING the stored path (not an
|
||||
# inline data URL).
|
||||
fetches: list[str] = []
|
||||
|
||||
def _on_request(req: Any) -> None:
|
||||
if "/api/chat-images/" in req.url:
|
||||
fetches.append(req.url)
|
||||
|
||||
page.on("request", _on_request)
|
||||
page.reload()
|
||||
expect(page.locator("#empty-state")).to_be_hidden(timeout=30_000)
|
||||
|
||||
# The restored user bubble carries the image — its src is the
|
||||
# STORED PATH (the record's key), not the live data URL.
|
||||
img = page.locator(".msg.user .msg-image").first
|
||||
expect(img).to_be_visible(timeout=30_000)
|
||||
assert (img.get_attribute("src") or "") == path, "the restore renders from the stored path"
|
||||
|
||||
# The path fetch must actually fire (the lazy img loading it) —
|
||||
# poll with a deadline. ``wait_for_timeout`` (not ``time.sleep``)
|
||||
# is the tick: the sync API dispatches the ``request`` events
|
||||
# queued during it, and a bare sleep would starve the listener.
|
||||
deadline = time.monotonic() + 10
|
||||
while not any(u.rstrip("/").endswith(path) for u in fetches):
|
||||
if time.monotonic() > deadline:
|
||||
raise AssertionError(
|
||||
f"no fetch of the stored path ({len(fetches)} image requests: {fetches!r})"
|
||||
)
|
||||
page.wait_for_timeout(100)
|
||||
|
||||
# The rest of the conversation is unchanged.
|
||||
expect(page.locator(".msg.user .bubble")).to_have_count(1)
|
||||
expect(page.locator(".msg.user .bubble")).to_contain_text(QUESTION)
|
||||
expect(page.locator(".msg.brain .bubble")).to_have_count(1)
|
||||
expect(page.locator(".msg.brain .bubble")).to_contain_text(
|
||||
re.compile(DEFLECT_PHRASE, re.IGNORECASE)
|
||||
)
|
||||
|
||||
# Cleanup.
|
||||
cookies = _admin_cookies(page)
|
||||
row = _wait_saved_row(app_url, cookies, _auto_title(QUESTION))
|
||||
_delete_row(app_url, cookies, row["id"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. Share: a fresh anonymous context sees the user's image on the
|
||||
# shared page (the public serve route — the shared view is faithful)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_shared_page_shows_the_user_image(
|
||||
page: Page, browser, app_url: str, mock_llm: int, db_ready: None, tmp_path: Path
|
||||
) -> None:
|
||||
_reset_db(mock_llm, seed=False)
|
||||
page.set_default_timeout(30_000)
|
||||
login(page, app_url, next="/")
|
||||
png = _png_file(tmp_path)
|
||||
|
||||
path, _uploads = _send_with_attachment(page, png)
|
||||
cookies = _admin_cookies(page)
|
||||
row = _wait_saved_row(app_url, cookies, _auto_title(QUESTION))
|
||||
|
||||
# The saved row's user record carries the PATH (the share's source
|
||||
# of truth — A5: never base64).
|
||||
detail = httpx.get(f"{app_url}/api/chats/{row['id']}", timeout=10, cookies=cookies)
|
||||
assert detail.status_code == 200
|
||||
saved_user = detail.json()["messages"][0]
|
||||
assert saved_user["image"] == path
|
||||
|
||||
# Share (the chat page's pill — the owner-locked one action):
|
||||
# grant the clipboard so the copy path runs (the status line is
|
||||
# the assertion surface).
|
||||
page.context.grant_permissions(
|
||||
["clipboard-read", "clipboard-write"], origin=app_url
|
||||
)
|
||||
page.locator("#share-chat-btn").click()
|
||||
expect(page.locator("#send-status")).to_have_text("Share link copied.", timeout=15_000)
|
||||
|
||||
row = _find_row(_chats(app_url, cookies), _auto_title(QUESTION))
|
||||
assert row is not None and row.get("share_url")
|
||||
share_url: str = row["share_url"]
|
||||
assert SHARE_URL_RE.fullmatch(share_url)
|
||||
try:
|
||||
# A FRESH context (no cookies, no localStorage): the shared
|
||||
# page renders the user's image (public route, same bubble
|
||||
# treatment — alt = the record's text).
|
||||
anon = browser.new_context()
|
||||
try:
|
||||
anon_page = anon.new_page()
|
||||
anon_page.goto(app_url + share_url)
|
||||
anon_img = anon_page.locator(".msg.user .msg-image").first
|
||||
expect(anon_img).to_be_visible(timeout=30_000)
|
||||
assert (anon_img.get_attribute("src") or "") == path
|
||||
# The answer text is there too (the shared view is the
|
||||
# full conversation, read-only).
|
||||
expect(anon_page.locator(".msg.brain .bubble")).to_contain_text(
|
||||
re.compile(DEFLECT_PHRASE, re.IGNORECASE)
|
||||
)
|
||||
finally:
|
||||
anon.close()
|
||||
finally:
|
||||
_delete_row(app_url, cookies, row["id"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5. Default-off negative: the control stays hidden; an API request
|
||||
# with an image gets the hinted error frame, with NO model call
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _post_chat_sse(
|
||||
app_url: str, cookies: dict[str, str], body: dict[str, Any]
|
||||
) -> list[dict[str, Any]]:
|
||||
"""``POST /api/chat`` straight from the test process (the hand-
|
||||
crafted request the absent composer control would otherwise make)."""
|
||||
frames: list[dict[str, Any]] = []
|
||||
with httpx.stream(
|
||||
"POST", f"{app_url}/api/chat", json=body, cookies=cookies, timeout=30
|
||||
) as r:
|
||||
assert r.status_code == 200, r.read()
|
||||
buf = ""
|
||||
for part in r.iter_text():
|
||||
buf += part
|
||||
while "\n\n" in buf:
|
||||
frame, buf = buf.split("\n\n", 1)
|
||||
frame = frame.strip()
|
||||
if frame.startswith("data:"):
|
||||
frames.append(json.loads(frame.removeprefix("data:").strip()))
|
||||
return frames
|
||||
|
||||
|
||||
def test_flag_off_hides_the_control_and_rejects_the_request(
|
||||
page: Page,
|
||||
default_app_url: str,
|
||||
mock_llm: int,
|
||||
db_ready: None,
|
||||
) -> None:
|
||||
_reset_db(mock_llm, seed=False)
|
||||
page.set_default_timeout(30_000)
|
||||
login(page, default_app_url, next="/")
|
||||
|
||||
# The config says images off — and the control stays hidden for
|
||||
# GOOD (the default-off contract: the static markup ships hidden,
|
||||
# the reveal gate never fires, the rendered DOM is pre-phase).
|
||||
cfg = httpx.get(f"{default_app_url}/api/config", timeout=10).json()
|
||||
assert cfg["images"] is False
|
||||
expect(page.locator("#attach-btn")).to_be_hidden()
|
||||
expect(page.locator("#attach-file")).to_be_hidden()
|
||||
expect(page.locator("#attach-preview")).to_be_hidden()
|
||||
|
||||
# The server-side contract (the API is the authority — a hand-
|
||||
# crafted request with an image): the phase-114 error frame with
|
||||
# the EXACT detail + hint, ONE terminal frame (no ``done``), and
|
||||
# ZERO model calls (the capture stays empty — the embed never ran).
|
||||
_reset_capture(mock_llm)
|
||||
cookies = _admin_cookies(page)
|
||||
frames = _post_chat_sse(
|
||||
default_app_url,
|
||||
cookies,
|
||||
{"message": "What is in this image?", "image": "/api/chat-images/" + "b" * 32 + ".png"},
|
||||
)
|
||||
assert [f["type"] for f in frames] == ["error"]
|
||||
assert frames[0] == {
|
||||
"type": "error",
|
||||
"detail": "Image support is turned off on this server.",
|
||||
"hint": "Enable BOR_IMAGES in the server's .env (and restart) to ask with an image.",
|
||||
}
|
||||
assert _captured(mock_llm) == [] # no model call (the rejected turn)
|
||||
@@ -33,8 +33,13 @@ import re
|
||||
|
||||
from playwright.sync_api import ConsoleMessage, Page, expect
|
||||
|
||||
#: The exact owner-approved policy (phase 82, decision A1).
|
||||
CSP = "default-src 'self'; base-uri 'none'; frame-ancestors 'none'"
|
||||
#: 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
|
||||
|
||||
@@ -11,7 +11,9 @@ Requires: podman compose up -d db
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
@@ -29,6 +31,7 @@ from sqlalchemy import delete, func, select, text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api import chat as chat_api
|
||||
from app.api import chat_images
|
||||
from app.config import Settings, get_settings
|
||||
from app.main import app as fastapi_app
|
||||
from app.models import Chunk, Document, GitSource, QueryLog
|
||||
@@ -2197,3 +2200,310 @@ def test_text_only_grounded_turn_frame_has_no_image_url_key_anywhere(
|
||||
]
|
||||
for ref in done["sources"] + done["related"]:
|
||||
assert set(ref) == {"source", "path", "title"} # the pre-122 key set
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 123 (task 01): image questions — the upload → chat flow delivers
|
||||
# the multimodal user message to the model (both construction sites), the
|
||||
# toggle-off / stale frames settle before any model call (no record),
|
||||
# text-only turns stay byte-identical, and the saved/shared chat round-
|
||||
# trips the stored PATH (never base64 — LOCKED A5).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
#: A real 1×1 transparent PNG (the phase-122 fixture bytes) — the server
|
||||
#: is content-agnostic (the extension + size gates only), but a well-
|
||||
#: formed fixture keeps the data-URL pin honest.
|
||||
PNG_1X1 = base64.b64decode(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR4nGP4DwQACfsD/fteaysAAAAASUVORK5CYII="
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def chat_image_store(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
||||
"""Point the question-image store (the upload/serve pair) AND the
|
||||
turn pipeline at a tmp dir, with the ``images`` toggle ON — the
|
||||
env-calibrated settings (the conftest mock thresholds) plus the
|
||||
phase-123 pair (``images`` + ``chat_image_dir``); the monkeypatch
|
||||
fixture reverts both patches."""
|
||||
store = tmp_path / "chat-images"
|
||||
store.mkdir()
|
||||
settings = Settings(
|
||||
_env_file=None, # pyright: ignore[reportCallIssue]
|
||||
images=True,
|
||||
chat_image_dir=str(store),
|
||||
)
|
||||
monkeypatch.setattr(chat_images, "get_settings", lambda: settings)
|
||||
monkeypatch.setattr(chat_api, "get_settings", lambda: settings)
|
||||
return store
|
||||
|
||||
|
||||
def _upload_image(client: TestClient, name: str = "screenshot.png") -> str:
|
||||
"""One question-image upload (the composer's step before send) —
|
||||
returns the served path (the value the chat request accepts)."""
|
||||
r = client.post(
|
||||
"/api/chat-images",
|
||||
files={"file": (name, io.BytesIO(PNG_1X1), "image/png")},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
return r.json()["path"]
|
||||
|
||||
|
||||
def _stream_chat_with(
|
||||
client: TestClient, body: dict[str, Any]
|
||||
) -> tuple[int, str, list[dict[str, Any]]]:
|
||||
"""``_stream_chat`` with an arbitrary body (the ``image`` key)."""
|
||||
with client.stream("POST", "/api/chat", json=body) as r:
|
||||
assert r.status_code == 200
|
||||
assert r.headers["content-type"].startswith("text/event-stream")
|
||||
buf = ""
|
||||
frames: list[dict[str, Any]] = []
|
||||
for part in r.iter_text():
|
||||
buf += part
|
||||
while "\n\n" in buf:
|
||||
frame, buf = buf.split("\n\n", 1)
|
||||
frame = frame.strip()
|
||||
if frame.startswith("data:"):
|
||||
frames.append(json.loads(frame.removeprefix("data:").strip()))
|
||||
assert buf.strip() == "", "stream must end on a frame boundary"
|
||||
return r.status_code, r.headers["content-type"], frames
|
||||
|
||||
|
||||
def _expected_image_content(question: str) -> list[dict[str, Any]]:
|
||||
"""The multimodal user content list the model must receive for a
|
||||
question that carried the fixture image (the text part + the
|
||||
image_url part — a data URL built server-side from the stored bytes
|
||||
+ the phase-122 mime map)."""
|
||||
return [
|
||||
{"type": "text", "text": question},
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {
|
||||
"url": f"data:image/png;base64,{base64.b64encode(PNG_1X1).decode('ascii')}"
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def test_image_turn_delivers_the_multimodal_user_message(
|
||||
client, db, seeded_kb: FakeRagLLM, chat_image_store: Path
|
||||
) -> None:
|
||||
"""The full flow (LOCKED A5): the client uploads FIRST, then the
|
||||
turn request carries the returned path — and the GROUNDED branch
|
||||
(construction site 2: ``run_agent`` builds its own ``[system,
|
||||
*history, user]`` from the value chat.py passes — the pinned flow)
|
||||
delivers the multimodal content list to the model: the text part +
|
||||
the image_url data URL that decodes to the stored bytes."""
|
||||
fastapi_app.dependency_overrides[chat_api.get_llm] = lambda: seeded_kb
|
||||
try:
|
||||
path = _upload_image(client)
|
||||
_, _, frames = _stream_chat_with(
|
||||
client, {"message": QUESTION, "image": path}
|
||||
)
|
||||
finally:
|
||||
fastapi_app.dependency_overrides.clear()
|
||||
|
||||
assert frames[-1]["type"] == "done"
|
||||
assert frames[-1]["deflected"] is False
|
||||
assert len(seeded_kb.seen_messages) == 1
|
||||
assert seeded_kb.seen_messages[0][-1] == {
|
||||
"role": "user",
|
||||
"content": _expected_image_content(QUESTION),
|
||||
}
|
||||
# the data URL really is the stored bytes (decode + compare)
|
||||
url = seeded_kb.seen_messages[0][-1]["content"][1]["image_url"]["url"]
|
||||
assert base64.b64decode(url.split(",", 1)[1]) == PNG_1X1
|
||||
|
||||
|
||||
def test_deflected_image_turn_delivers_the_multimodal_user_message(
|
||||
client, db, seeded_kb: FakeRagLLM, chat_image_store: Path
|
||||
) -> None:
|
||||
"""Construction site 1 (the deflected branch consumes chat.py's
|
||||
``messages`` list directly): an off-topic question + image still
|
||||
delivers the SAME multimodal list — the LOW prompt path never
|
||||
drops the attachment."""
|
||||
fastapi_app.dependency_overrides[chat_api.get_llm] = lambda: seeded_kb
|
||||
try:
|
||||
path = _upload_image(client)
|
||||
_, _, frames = _stream_chat_with(
|
||||
client, {"message": OFF_TOPIC, "image": path}
|
||||
)
|
||||
finally:
|
||||
fastapi_app.dependency_overrides.clear()
|
||||
|
||||
assert frames[-1]["type"] == "done"
|
||||
assert frames[-1]["deflected"] is True
|
||||
assert len(seeded_kb.seen_messages) == 1
|
||||
assert seeded_kb.seen_messages[0][-1] == {
|
||||
"role": "user",
|
||||
"content": _expected_image_content(OFF_TOPIC),
|
||||
}
|
||||
|
||||
|
||||
def test_image_turn_with_toggle_off_yields_the_hinted_frame_and_calls_nothing(
|
||||
client, db, seeded_kb: FakeRagLLM, chat_image_store: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""The ``BOR_IMAGES`` gate: off with an image set → the phase-114
|
||||
error frame with the EXACT detail + hint (ONE terminal frame — no
|
||||
``done``), and NO model call (zero embeds, zero requests) and NO
|
||||
record (the rejected turn saves nothing — the existing error-path
|
||||
convention)."""
|
||||
monkeypatch.setattr(
|
||||
chat_api,
|
||||
"get_settings",
|
||||
lambda: Settings(
|
||||
_env_file=None, # pyright: ignore[reportCallIssue]
|
||||
images=False,
|
||||
chat_image_dir=str(chat_image_store),
|
||||
),
|
||||
)
|
||||
fastapi_app.dependency_overrides[chat_api.get_llm] = lambda: seeded_kb
|
||||
try:
|
||||
path = _upload_image(client)
|
||||
_, _, frames = _stream_chat_with(
|
||||
client, {"message": "What is in this image?", "image": path}
|
||||
)
|
||||
finally:
|
||||
fastapi_app.dependency_overrides.clear()
|
||||
|
||||
assert [f["type"] for f in frames] == ["error"]
|
||||
assert frames[0] == {
|
||||
"type": "error",
|
||||
"detail": "Image support is turned off on this server.",
|
||||
"hint": "Enable BOR_IMAGES in the server's .env (and restart) to ask with an image.",
|
||||
}
|
||||
# NO model call: the QUESTION embed never ran (``question_embeds``
|
||||
# counts ``embed_one`` calls — the import's batch embeds are a
|
||||
# different counter) and the answer endpoint was never asked.
|
||||
assert seeded_kb.question_embeds == []
|
||||
assert seeded_kb.seen_messages == []
|
||||
assert db.scalars(select(QueryLog)).all() == [] # NO query_log row
|
||||
|
||||
|
||||
def test_image_turn_with_a_missing_stored_file_yields_the_stale_frame(
|
||||
client, db, seeded_kb: FakeRagLLM, chat_image_store: Path
|
||||
) -> None:
|
||||
"""The stale-path edge (the file was deleted out-of-band): the SAME
|
||||
frame shape with the "no longer available" detail and NO hint (the
|
||||
banner's default copy is the honest fallback) — again before any
|
||||
model call, no record."""
|
||||
fastapi_app.dependency_overrides[chat_api.get_llm] = lambda: seeded_kb
|
||||
try:
|
||||
stale = "/api/chat-images/" + "e" * 32 + ".png" # well-formed, absent
|
||||
_, _, frames = _stream_chat_with(
|
||||
client, {"message": "What is in this image?", "image": stale}
|
||||
)
|
||||
finally:
|
||||
fastapi_app.dependency_overrides.clear()
|
||||
|
||||
assert [f["type"] for f in frames] == ["error"]
|
||||
assert frames[0] == {
|
||||
"type": "error",
|
||||
"detail": "That image is no longer available.",
|
||||
"hint": None,
|
||||
}
|
||||
assert seeded_kb.question_embeds == [] # NO model call (see the toggle-off test)
|
||||
assert seeded_kb.seen_messages == []
|
||||
assert db.scalars(select(QueryLog)).all() == []
|
||||
|
||||
|
||||
def test_text_only_turn_is_byte_identical_with_images_enabled(
|
||||
client, db, seeded_kb: FakeRagLLM, chat_image_store: Path
|
||||
) -> None:
|
||||
"""``image=None`` with the toggle ON: the user message is the PLAIN
|
||||
STRING (not a content list) — the multimodal branch is inert, and
|
||||
the turn behaves exactly as pre-phase (done frame, the question
|
||||
embedded whole)."""
|
||||
fastapi_app.dependency_overrides[chat_api.get_llm] = lambda: seeded_kb
|
||||
try:
|
||||
_, _, frames = _stream_chat_with(client, {"message": QUESTION})
|
||||
finally:
|
||||
fastapi_app.dependency_overrides.clear()
|
||||
|
||||
assert frames[-1]["type"] == "done"
|
||||
assert seeded_kb.seen_messages[0][-1] == {"role": "user", "content": QUESTION}
|
||||
assert isinstance(seeded_kb.seen_messages[0][-1]["content"], str)
|
||||
assert seeded_kb.question_embeds == [QUESTION[: 1200]]
|
||||
|
||||
|
||||
def test_saved_chat_round_trips_the_user_image_path(
|
||||
client, db, chat_image_store: Path
|
||||
) -> None:
|
||||
"""Persistence (LOCKED A5): the user record carries the stored
|
||||
PATH — the stored JSONB and the saved-chat response round-trip it,
|
||||
the brain record carries NO image key, and NOTHING base64 crosses
|
||||
the storage boundary. A text-only record stays without the key
|
||||
(the phase-50 byte-identical contract for text-only chats)."""
|
||||
db.execute(text("TRUNCATE saved_chats"))
|
||||
db.commit()
|
||||
try:
|
||||
path = _upload_image(client)
|
||||
r = client.post(
|
||||
"/api/chats",
|
||||
json={
|
||||
"messages": [
|
||||
{"who": "user", "text": "What is in this image?", "image": path},
|
||||
{"who": "brain", "text": "A cat, by the look of it."},
|
||||
]
|
||||
},
|
||||
)
|
||||
assert r.status_code == 201, r.text
|
||||
body = r.json()
|
||||
assert body["messages"][0]["image"] == path
|
||||
assert "image" not in body["messages"][1] # brain records never carry it
|
||||
|
||||
# The RAW stored JSONB: the path, never base64 (LOCKED A5)
|
||||
raw = db.execute(
|
||||
text("SELECT messages FROM saved_chats WHERE id = :id"),
|
||||
{"id": body["id"]},
|
||||
).scalar_one()
|
||||
assert raw[0]["image"] == path
|
||||
assert "base64" not in json.dumps(raw)
|
||||
|
||||
# The admin GET round-trips it losslessly
|
||||
got = client.get(f"/api/chats/{body['id']}")
|
||||
assert got.status_code == 200
|
||||
assert got.json()["messages"][0]["image"] == path
|
||||
assert "image" not in got.json()["messages"][1]
|
||||
finally:
|
||||
db.execute(text("TRUNCATE saved_chats"))
|
||||
db.commit()
|
||||
|
||||
|
||||
def test_shared_chat_serves_the_user_image_path(
|
||||
client, db, chat_image_store: Path
|
||||
) -> None:
|
||||
"""The shared view is faithful: the public snapshot carries the
|
||||
user's image path (the public ``messages`` shape already includes
|
||||
the optional key), and the image bytes themselves are publicly
|
||||
servable (the image is part of the chat's content — phase 55 A1)."""
|
||||
db.execute(text("TRUNCATE saved_chats"))
|
||||
db.commit()
|
||||
try:
|
||||
path = _upload_image(client)
|
||||
r = client.post(
|
||||
"/api/chats",
|
||||
json={
|
||||
"share": True,
|
||||
"messages": [
|
||||
{"who": "user", "text": "What is in this image?", "image": path},
|
||||
{"who": "brain", "text": "A cat, by the look of it."},
|
||||
],
|
||||
},
|
||||
)
|
||||
assert r.status_code == 201, r.text
|
||||
share_url = r.json()["share_url"]
|
||||
token = share_url.rsplit("/", 1)[-1]
|
||||
|
||||
anon = TestClient(fastapi_app)
|
||||
got = anon.get(f"/api/shared/{token}")
|
||||
assert got.status_code == 200
|
||||
messages = got.json()["messages"]
|
||||
assert messages[0]["image"] == path
|
||||
assert "image" not in messages[1]
|
||||
|
||||
img = anon.get(path)
|
||||
assert img.status_code == 200
|
||||
assert img.content == PNG_1X1
|
||||
finally:
|
||||
db.execute(text("TRUNCATE saved_chats"))
|
||||
db.commit()
|
||||
|
||||
@@ -24,6 +24,7 @@ Requires: podman compose up -d db
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
import uuid
|
||||
@@ -799,6 +800,105 @@ def test_public_read_returns_snapshot_without_private_keys(
|
||||
assert body["messages"] == _expect([_user(FIRST_QUESTION), FULL_BRAIN])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 123 (tasks 03/04): the question's attached image — the saved
|
||||
# and shared shape carries the stored PATH (LOCKED A5: never base64),
|
||||
# on the USER record only (the attachment belongs to the question — a
|
||||
# brain record never carries the key). Text-only records stay WITHOUT
|
||||
# the key (absent, never null — the phase-50 byte-identical contract,
|
||||
# pinned by ``_expect`` above; the ``ChatMessage`` wrap serializer does
|
||||
# the dropping, so every surface — the stored JSONB, the admin GET,
|
||||
# the public shared read — inherits it).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
#: A well-formed stored path (the ``POST /api/chat-images`` response's
|
||||
#: shape — the chat-images router names the file
|
||||
#: ``<uuid4().hex>.<ext>``; the API boundary itself only bounds the
|
||||
#: string to 500 chars, the pattern gate is the chat request's).
|
||||
IMAGE_PATH = "/api/chat-images/" + "a" * 32 + ".png"
|
||||
|
||||
|
||||
def _user_with_image(text: str) -> dict[str, Any]:
|
||||
return {"who": "user", "text": text, "image": IMAGE_PATH}
|
||||
|
||||
|
||||
def test_create_round_trips_the_user_image_path(
|
||||
admin_client: TestClient, db: Session
|
||||
) -> None:
|
||||
"""The user record carries the PATH at every boundary: the 201
|
||||
body, the RAW stored JSONB (never base64 — the A5 contract), and
|
||||
the admin GET — and the brain record stays WITHOUT the key.
|
||||
A text-only record in the SAME chat stays key-free too."""
|
||||
r = admin_client.post(
|
||||
"/api/chats",
|
||||
json={
|
||||
"messages": [
|
||||
_user(FIRST_QUESTION), # text-only — stays WITHOUT the key
|
||||
_user_with_image("What is in this image?"),
|
||||
{"who": "brain", "text": "A cat, by the look of it."},
|
||||
]
|
||||
},
|
||||
)
|
||||
assert r.status_code == 201, r.text
|
||||
body = r.json()
|
||||
assert "image" not in body["messages"][0]
|
||||
assert body["messages"][1]["image"] == IMAGE_PATH
|
||||
assert "image" not in body["messages"][2]
|
||||
|
||||
# The RAW stored JSONB (the DB is the durable boundary — the
|
||||
# served bodies could in principle re-derive it): the path, and
|
||||
# NOTHING base64 anywhere in the payload.
|
||||
raw = db.execute(
|
||||
text("SELECT messages FROM saved_chats WHERE id = :id"),
|
||||
{"id": body["id"]},
|
||||
).scalar_one()
|
||||
assert "image" not in raw[0]
|
||||
assert raw[1]["image"] == IMAGE_PATH
|
||||
assert "image" not in raw[2]
|
||||
assert "base64" not in json.dumps(raw)
|
||||
|
||||
got = admin_client.get(f"/api/chats/{body['id']}")
|
||||
assert got.status_code == 200
|
||||
assert "image" not in got.json()["messages"][0]
|
||||
assert got.json()["messages"][1]["image"] == IMAGE_PATH
|
||||
assert "image" not in got.json()["messages"][2]
|
||||
|
||||
|
||||
def test_shared_serve_includes_the_user_image_path(
|
||||
admin_client: TestClient,
|
||||
) -> None:
|
||||
"""The shared view is faithful (task 03's pin): the PUBLIC
|
||||
snapshot's user record carries the image path — the public
|
||||
``messages`` shape already gains the one optional key, so no new
|
||||
shared-shape field exists — and the brain / text-only records
|
||||
stay WITHOUT it. The image bytes themselves ride the public
|
||||
serve route (phase 55 A1 — the image is part of the chat's
|
||||
content, the token is the credential): pinned in
|
||||
``test_chat_api.py`` alongside the upload."""
|
||||
r = admin_client.post(
|
||||
"/api/chats",
|
||||
json={
|
||||
"messages": [
|
||||
_user(FIRST_QUESTION), # text-only — stays WITHOUT the key
|
||||
_user_with_image("What is in this image?"),
|
||||
{"who": "brain", "text": "A cat, by the look of it."},
|
||||
]
|
||||
},
|
||||
)
|
||||
assert r.status_code == 201, r.text
|
||||
share_url = _share(admin_client, r.json()["id"])["share_url"]
|
||||
|
||||
anon = TestClient(fastapi_app) # fresh jar: truly anonymous
|
||||
got = anon.get(f"/api{share_url}")
|
||||
assert got.status_code == 200
|
||||
body = got.json()
|
||||
assert set(body) == SHARED_OUT_KEYS # no new shared-shape field
|
||||
messages = body["messages"]
|
||||
assert "image" not in messages[0]
|
||||
assert messages[1]["image"] == IMAGE_PATH
|
||||
assert "image" not in messages[2]
|
||||
|
||||
|
||||
def test_public_read_wrong_and_revoked_tokens_404_with_one_detail(
|
||||
admin_client: TestClient,
|
||||
) -> None:
|
||||
|
||||
@@ -8,7 +8,11 @@ API JSON, static assets, even the static catch-all's 404s — carries:
|
||||
|
||||
* ``Content-Security-Policy`` — the exact A1 string (``default-src 'self';
|
||||
base-uri 'none'; frame-ancestors 'none'`` → clickjacking closed, no
|
||||
inline anything because the No-CDN frontend has none);
|
||||
inline anything because the No-CDN frontend has none), extended by
|
||||
the phase-123 ``img-src 'self' data:`` carve-out (the question-image
|
||||
composer's data-URL preview + live bubble — see ``CSP`` in
|
||||
``app/core/security_headers.py``; the ``data:`` allowance is scoped
|
||||
to img-src only);
|
||||
* ``X-Frame-Options: DENY`` — the legacy no-framing fallback;
|
||||
* ``X-Content-Type-Options: nosniff`` — the MIME-confusion belt.
|
||||
|
||||
@@ -69,9 +73,7 @@ def test_page_carries_all_three_headers(client: TestClient, db: Session) -> None
|
||||
response = client.get("/")
|
||||
assert response.status_code == 200
|
||||
_assert_security_headers(response)
|
||||
assert response.headers["content-security-policy"] == (
|
||||
"default-src 'self'; base-uri 'none'; frame-ancestors 'none'"
|
||||
)
|
||||
assert response.headers["content-security-policy"] == CSP
|
||||
|
||||
|
||||
def test_api_health_carries_all_three_headers(client: TestClient) -> None:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -126,10 +126,24 @@ def test_raw_text_only_stored_and_re_rendered_on_restore() -> None:
|
||||
def test_save_points_user_on_send_and_brain_on_done() -> None:
|
||||
"""Save points: the user message is stored the moment it is sent (BEFORE
|
||||
the fetch — a failed turn keeps the question); the brain message is
|
||||
stored on `done` with the done metadata (sources/deflected/suggestions)."""
|
||||
stored on `done` with the done metadata (sources/deflected/suggestions).
|
||||
|
||||
Phase 123 (task 02): the user push is conditional — the record gains
|
||||
the optional `image` key (the STORED path from the upload step,
|
||||
A5: never base64) only when an attachment exists; the text-only
|
||||
branch is the pre-phase object verbatim. The save point (push +
|
||||
save before the turn starts) is the contract."""
|
||||
js = _js()
|
||||
user_push = js.find('conversation.push({ who: "user", text })')
|
||||
assert user_push != -1
|
||||
run_turn = js.find("async function runTurn")
|
||||
assert run_turn != -1, "runTurn must exist (the phase-49 extraction)"
|
||||
user_push = js.find("conversation.push(", run_turn)
|
||||
push_block = js[user_push : js.find("saveConversation()", user_push)]
|
||||
assert '{ who: "user", text, image: image.path }' in push_block, (
|
||||
"an attached question stores the image path (A5)"
|
||||
)
|
||||
assert '{ who: "user", text }' in push_block, (
|
||||
"a text-only question keeps the pre-phase record shape"
|
||||
)
|
||||
assert user_push < js.find('fetch("/api/chat"'), (
|
||||
"the user message must be saved before the turn starts"
|
||||
)
|
||||
|
||||
@@ -378,15 +378,22 @@ def test_composer_form_is_novalidate() -> None:
|
||||
def test_run_turn_is_the_extracted_turn_handler() -> None:
|
||||
"""Phase 49 (owner-locked 2026-08-29, TODO.md L4): the turn
|
||||
machinery is extracted from handleSend into
|
||||
`runTurn(text, { reask = false })`. handleSend keeps only the
|
||||
form-level pre-work (the in-flight stop guard, the !text guard, the
|
||||
composer pre-work) and delegates; the user append + persistence
|
||||
save point 1 (push + save) sit in runTurn's `!reask` block — the
|
||||
redo-in-place retry path skips both, because the question is
|
||||
already in the DOM and in `conversation`."""
|
||||
`runTurn(text, { reask = false, image = null })` (the `image`
|
||||
argument is phase 123 task 02's optional attachment). handleSend
|
||||
keeps the form-level pre-work (the in-flight stop guard, the
|
||||
!text guard, the composer pre-work) plus — since phase 123 — the
|
||||
locked-A8 upload step (an attached image uploads BEFORE the
|
||||
input is cleared; a failed upload blocks the send) and delegates;
|
||||
the user append + persistence save point 1 (push + save) sit in
|
||||
runTurn's `!reask` block — the redo-in-place retry path skips
|
||||
both, because the question is already in the DOM and in
|
||||
`conversation`."""
|
||||
js = _js()
|
||||
assert re.search(r"async function runTurn\(text, \{ reask = false \} = \{\}\)", js), (
|
||||
"runTurn(text, { reask = false }) must be the extracted turn handler"
|
||||
assert re.search(
|
||||
r"async function runTurn\(text, \{ reask = false, image = null \} = \{\}\)", js
|
||||
), (
|
||||
"runTurn(text, { reask = false, image = null }) must be the extracted "
|
||||
"turn handler"
|
||||
)
|
||||
handle = js.find("async function handleSend")
|
||||
turn = js.find("async function runTurn")
|
||||
@@ -397,7 +404,12 @@ def test_run_turn_is_the_extracted_turn_handler() -> None:
|
||||
assert 'input.value = ""' in handle_body
|
||||
assert "autoGrow()" in handle_body
|
||||
assert "clearErrorBanner()" in handle_body
|
||||
assert "runTurn(text, { reask: false })" in handle_body, ("handleSend delegates the turn")
|
||||
# Phase 123 (task 02, locked A8): the delegation carries the
|
||||
# upload step's `image` ({ path, src, alt } | null) — null for a
|
||||
# text-only send (the pre-phase shape).
|
||||
assert "runTurn(text, { reask: false, image })" in handle_body, (
|
||||
"handleSend delegates the turn (with the attached image's path)"
|
||||
)
|
||||
assert 'addMessage("user"' not in handle_body, (
|
||||
"the user append moved with the turn into runTurn"
|
||||
)
|
||||
@@ -412,8 +424,21 @@ def test_run_turn_is_the_extracted_turn_handler() -> None:
|
||||
)
|
||||
turn_top = js[turn:wrap_idx]
|
||||
assert "if (!reask) {" in turn_top, "the reask gate guards the append + push"
|
||||
assert 'addMessage("user", renderMarkdown(text), true)' in turn_top
|
||||
assert 'conversation.push({ who: "user", text })' in turn_top
|
||||
# Phase 123 (task 02): the user append + push carry the optional
|
||||
# attachment — the bubble gets { src, alt } (the data URL live; the
|
||||
# stored path is the fallback) and the record gains the `image`
|
||||
# key (the STORED path — A5: never base64) only when one exists;
|
||||
# a null image keeps the pre-phase shapes verbatim. The strip must
|
||||
# not linger into the turn (cleared after the bubble renders).
|
||||
assert re.search(
|
||||
r'addMessage\(\s*"user",\s*renderMarkdown\(text\),\s*true', turn_top
|
||||
), "the submit must reveal the user message (scroll intent true)"
|
||||
assert "image ? { src: image.src || image.path, alt: image.alt } : null" in turn_top
|
||||
assert "{ who: \"user\", text, image: image.path }" in turn_top
|
||||
assert "{ who: \"user\", text }" in turn_top
|
||||
assert "clearAttachedImage()" in turn_top, (
|
||||
"the preview strip must not linger into the turn"
|
||||
)
|
||||
assert "saveConversation()" in turn_top
|
||||
|
||||
|
||||
|
||||
@@ -93,12 +93,16 @@ def test_scroll_helper_is_unconditional() -> None:
|
||||
|
||||
|
||||
def test_add_message_takes_explicit_scroll_intent() -> None:
|
||||
"""addMessage(who, html, scroll = false): the phase-18
|
||||
"""addMessage(who, html, scroll = false, image = null): the phase-18
|
||||
scrollBehavior/force parameters are gone; the bubble scrolls only
|
||||
when the caller explicitly asks (submit reveal, restore landing)."""
|
||||
when the caller explicitly asks (submit reveal, restore landing).
|
||||
Phase 123 (task 02) appended the optional `image` argument (the
|
||||
question's attached image — { src, alt } on a user bubble, the ONE
|
||||
renderer for live + restore + shared); the scroll contract is
|
||||
untouched."""
|
||||
js = _js()
|
||||
body = _fn_body(js, "addMessage")
|
||||
assert "function addMessage(who, html, scroll = false)" in body
|
||||
assert "function addMessage(who, html, scroll = false, image = null)" in body
|
||||
assert "if (scroll) scrollReveal(wrap)" in body
|
||||
assert "force" not in body
|
||||
assert "scrollBehavior" not in body
|
||||
@@ -119,9 +123,13 @@ def test_submit_reveals_user_message() -> None:
|
||||
assert turn != -1, "runTurn must exist (phase 49 extraction)"
|
||||
body = js[turn : js.find("\n}\n", turn)]
|
||||
assert "if (!reask) {" in body, "the user append is gated on !reask"
|
||||
assert 'addMessage("user", renderMarkdown(text), true)' in body, (
|
||||
"the submit must reveal the user message (scroll intent true)"
|
||||
)
|
||||
# Phase 123 (task 02): the user append gained the optional image
|
||||
# argument (the attached image's { src, alt }) — the call is
|
||||
# multi-line now; the contract is the same: user + the raw text +
|
||||
# the explicit scroll intent true.
|
||||
assert re.search(
|
||||
r'addMessage\(\s*"user",\s*renderMarkdown\(text\),\s*true', body
|
||||
), "the submit must reveal the user message (scroll intent true)"
|
||||
for call in re.findall(r'addMessage\("brain"([^)]*)\)', body):
|
||||
assert "true" not in call, (
|
||||
f"streaming brain bubbles must not scroll the page: {call!r}"
|
||||
@@ -168,7 +176,16 @@ def test_restore_landing_is_one_shot() -> None:
|
||||
assert 'addMessage("brain", renderMarkdown(m.text), true)' in body
|
||||
assert js.count('"auto", true') == 0, "the old forced 'auto' landing must be gone"
|
||||
# Submit reveal + the two restore landings — nothing else scrolls.
|
||||
assert js.count(", true)") == 3, "only submit + the two restore calls may scroll"
|
||||
# Phase 123 (task 02): the submit call is multi-line (the optional
|
||||
# image argument follows the scroll intent), so it no longer ends
|
||||
# in the single-line ", true)" literal — the two restore calls do;
|
||||
# the submit reveal is counted by its own (multi-line) shape.
|
||||
assert js.count(", true)") == 2, (
|
||||
"only the two restore calls may scroll (single-line shape)"
|
||||
)
|
||||
assert len(re.findall(r'addMessage\(\s*"user",\s*renderMarkdown\(text\),\s*true', js)) == 1, (
|
||||
"the submit reveal may scroll (multi-line since phase 123's image argument)"
|
||||
)
|
||||
# The marker comment documents the one-shot, load-time contract.
|
||||
assert "restore landing" in body
|
||||
assert "one-shot" in body
|
||||
|
||||
@@ -313,9 +313,11 @@ def test_chat_bottom_unit_is_last_child_of_the_chat_shell() -> None:
|
||||
`.chat-shell` is the `.chat-bottom` wrapper — NO id (nothing in JS
|
||||
binds it; the bindings live on the inner elements, the move is pure
|
||||
HTML/CSS) — holding the `.chat-actions` row, the phase-104
|
||||
`#char-count` counter, and the `#composer` form, in that order: the
|
||||
row + counter + composer are ONE sticky unit, and the wrapper owns
|
||||
the shell's bottom slot, so the sticky shift range is still that
|
||||
`#char-count` counter, the phase-123 `#attach-preview` strip (hidden
|
||||
by default — zero height at rest, the sticky geometry untouched),
|
||||
and the `#composer` form, in that order: the row + counter +
|
||||
preview + composer are ONE sticky unit, and the wrapper owns the
|
||||
shell's bottom slot, so the sticky shift range is still that
|
||||
column's box (a sibling after it would carve the range away and
|
||||
re-break the pin). The composer form keeps `novalidate` and its
|
||||
contract ids."""
|
||||
@@ -331,11 +333,12 @@ def test_chat_bottom_unit_is_last_child_of_the_chat_shell() -> None:
|
||||
"elements"
|
||||
)
|
||||
kids = last["children"]
|
||||
assert len(kids) == 3, (
|
||||
"the unit holds exactly three element children: .chat-actions, "
|
||||
"then #char-count (phase 104), then #composer"
|
||||
assert len(kids) == 4, (
|
||||
"the unit holds exactly four element children: .chat-actions, "
|
||||
"then #char-count (phase 104), then #attach-preview (phase 123), "
|
||||
"then #composer"
|
||||
)
|
||||
row, counter, form = kids
|
||||
row, counter, preview, form = kids
|
||||
assert row["tag"] == "div" and (
|
||||
row["attrs"].get("class") or ""
|
||||
).split() == ["chat-actions"], (
|
||||
@@ -354,6 +357,18 @@ def test_chat_bottom_unit_is_last_child_of_the_chat_shell() -> None:
|
||||
"the counter ships hidden — it appears only from 80% of the "
|
||||
"4,000-char cap (app.js updateCharCount)"
|
||||
)
|
||||
# Phase 123 (task 02, TODO L6): the attach preview strip — a
|
||||
# hidden-by-default div between the counter and the composer (the
|
||||
# selected image above the input row; zero height while hidden, the
|
||||
# pinned-cluster geometry untouched).
|
||||
assert preview["tag"] == "div" and (
|
||||
preview["attrs"].get("id") == "attach-preview"
|
||||
), "the third child is the phase-123 #attach-preview strip"
|
||||
assert (preview["attrs"].get("class") or "") == "attach-preview"
|
||||
assert "hidden" in preview["attrs"], (
|
||||
"the strip ships hidden — it appears only while a file is "
|
||||
"attached (app.js attachedImage)"
|
||||
)
|
||||
assert form["tag"] == "form" and form["attrs"].get("id") == "composer"
|
||||
assert "novalidate" in form["attrs"], (
|
||||
"phase 48: the composer form stays `novalidate` (a `required` "
|
||||
|
||||
@@ -696,11 +696,11 @@ def test_chat_actions_wrapper_holds_both_pills_in_order() -> None:
|
||||
from the top of the column to the bottom: below the ``#messages``
|
||||
section, directly above the composer; nothing but the row's own
|
||||
comment lands between ``#messages`` and the row, and nothing but the
|
||||
phase-104 ``#char-count`` counter + the composer comment lands
|
||||
between the row and the composer (the counter is hidden by default —
|
||||
zero height, the pinned-cluster geometry untouched). No
|
||||
other page carries ``.chat-actions`` (chat-page only, like the
|
||||
pills)."""
|
||||
phase-104 ``#char-count`` counter + the phase-123 attach preview
|
||||
strip + the composer comment lands between the row and the composer
|
||||
(both hidden by default — zero height at rest, the pinned-cluster
|
||||
geometry untouched). No other page carries ``.chat-actions``
|
||||
(chat-page only, like the pills)."""
|
||||
html = _index()
|
||||
start = html.find('<div class="chat-actions">')
|
||||
assert start != -1, "index.html must carry the .chat-actions wrapper"
|
||||
@@ -734,8 +734,20 @@ def test_chat_actions_wrapper_holds_both_pills_in_order() -> None:
|
||||
after = html[end:composer_idx]
|
||||
# Phase 104 (owner 2026-09-12): the ONE permitted child between the
|
||||
# row and the composer is the hidden-by-default question-length
|
||||
# counter — everything else (ids, buttons, sections, forms) is
|
||||
# still excluded from the gap.
|
||||
# counter; phase 123 (task 02, TODO L6) added the second — the
|
||||
# attach preview strip (the selected image above the input row:
|
||||
# thumbnail + filename + remove button), also `hidden` by default
|
||||
# (the global [hidden] rule — zero height at rest, the
|
||||
# pinned-cluster geometry untouched). Everything else (ids,
|
||||
# buttons, sections, forms) is still excluded from the gap — the
|
||||
# strip's own element block is stripped (like the counter) so the
|
||||
# exclusions below stay meaningful.
|
||||
strip_start = after.find('<div class="attach-preview"')
|
||||
assert strip_start != -1, (
|
||||
"the phase-123 attach preview strip sits above the composer"
|
||||
)
|
||||
strip_end = after.find("</div>", strip_start) + len("</div>")
|
||||
after = after[:strip_start] + after[strip_end:]
|
||||
after_minus_counter = after.replace(
|
||||
'<p class="char-count" id="char-count" hidden></p>', ""
|
||||
)
|
||||
@@ -746,8 +758,9 @@ def test_chat_actions_wrapper_holds_both_pills_in_order() -> None:
|
||||
and "<section" not in after_minus_counter
|
||||
and "<form" not in after_minus_counter
|
||||
), (
|
||||
"nothing but the phase-104 counter + the composer comment lands "
|
||||
"between the row and the composer"
|
||||
"nothing but the phase-104 counter + the phase-123 attach "
|
||||
"preview + the composer comment lands between the row and the "
|
||||
"composer"
|
||||
)
|
||||
# Phase 76 (task 02): the folded view files are gone (the shell's
|
||||
# chat view is the one and only carrier of the row — pinned above);
|
||||
|
||||
@@ -29,7 +29,10 @@ from app.core.security_headers import CSP, SecurityHeadersMiddleware
|
||||
#: The exact expected header set (decision A1 for the CSP, A4 for the
|
||||
#: other two).
|
||||
EXPECTED_HEADERS = {
|
||||
"content-security-policy": "default-src 'self'; base-uri 'none'; frame-ancestors 'none'",
|
||||
"content-security-policy": (
|
||||
"default-src 'self'; base-uri 'none'; frame-ancestors 'none'; "
|
||||
"img-src 'self' data:"
|
||||
),
|
||||
"x-frame-options": "DENY",
|
||||
"x-content-type-options": "nosniff",
|
||||
}
|
||||
@@ -111,10 +114,19 @@ def _assert_security_headers(start: Message, expected_extra: dict[str, str] | No
|
||||
|
||||
|
||||
def test_csp_constant_is_the_exact_a1_policy() -> None:
|
||||
"""The owner-approved A1 string, verbatim: same-origin default, no
|
||||
base-tag hijack, no framing — no 'unsafe-inline', no report sink."""
|
||||
assert CSP == "default-src 'self'; base-uri 'none'; frame-ancestors 'none'"
|
||||
"""The owner-approved A1 string (phase 82), verbatim, with the
|
||||
phase-123 ``img-src`` carve-out (the question-image composer's
|
||||
data-URL preview + live bubble — see ``security_headers.CSP``):
|
||||
same-origin default, no base-tag hijack, no framing — no
|
||||
'unsafe-inline', no report sink, and the ``data:`` allowance is
|
||||
SCOPED to img-src (never script/style/fetch)."""
|
||||
assert CSP == (
|
||||
"default-src 'self'; base-uri 'none'; frame-ancestors 'none'; "
|
||||
"img-src 'self' data:"
|
||||
)
|
||||
assert "unsafe-inline" not in CSP
|
||||
# the carve-out is img-src ONLY — no other directive gains data:
|
||||
assert CSP.count("data:") == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -158,6 +170,7 @@ def test_pre_existing_csp_from_an_inner_layer_is_preserved() -> None:
|
||||
while the other two headers are still added."""
|
||||
themed = (
|
||||
"default-src 'self'; base-uri 'none'; frame-ancestors 'none'; "
|
||||
"img-src 'self' data:; "
|
||||
"style-src 'self' 'sha256-2rm3wPcQfXmE8q1s9vBzK7hN4tY5uJ6gW3oR0cAeDfH='"
|
||||
)
|
||||
wrapped = SecurityHeadersMiddleware(
|
||||
|
||||
@@ -101,10 +101,15 @@ def test_persisted_on_leave_flag_is_module_scoped_and_turn_reset() -> None:
|
||||
|
||||
# Reset at the top of the turn handler (runTurn — phase 49 extracted
|
||||
# the turn from handleSend) — before the turn's fetch, where the
|
||||
# other turn locals are initialized.
|
||||
# other turn locals are initialized. The pin is the ORDER (resets
|
||||
# before the fetch), not a char window: phase 123 task 02 grew the
|
||||
# save-point-1 region above the resets (the attached image's bubble
|
||||
# + record + strip clear) without moving the resets.
|
||||
turn = js.find("async function runTurn")
|
||||
assert turn != -1
|
||||
top = js[turn : turn + 1500]
|
||||
fetch_idx = js.find('fetch("/api/chat"', turn)
|
||||
assert fetch_idx != -1
|
||||
top = js[turn:fetch_idx]
|
||||
assert "persistedOnLeave = false;" in top, (
|
||||
"persistedOnLeave must be reset per turn, at the top of the turn handler"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user