- tests/integration: frontend served by backend, runtime error mapping, backend + real pi image with scripted model/Confluence (shared example, variants, failure paths, cancellation/busy gate, isolation canaries, HTTP download ownership, 16 MiB prompt round trip), real OpenAI-compatible adapter + real image over a scripted transport, real Chrome against the real backend with a scripted runtime peer, backend crash/restart reconciliation, and an opt-in live model check (marker: live). - backend: map runtime terminal codes (model_output_limit, model_context_exceeded, query_timeout, connectivity_failed) to the contract's HTTP statuses; make the artifact 404 body identical for no-session, wrong-session, unknown and expired IDs. - Makefile, scripts/run-backend.sh, deploy/confluence-web.env.example, root README for the integrated application; integration pytest marker.
99 lines
4.9 KiB
Python
99 lines
4.9 KiB
Python
"""Browser-facing surface: the backend serves the real frontend tree on one origin.
|
|
|
|
Runs without Docker (explicit dev-mode fakes) and checks the exact requests
|
|
the shipped frontend JavaScript makes, plus that only production asset
|
|
directories are exposed.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
|
|
import httpx
|
|
import pytest
|
|
from httpx import ASGITransport
|
|
|
|
from backend.app import CSP_POLICY, SESSION_COOKIE_NAME, create_app
|
|
from backend.artifacts import ArtifactStore
|
|
from backend.settings import Settings
|
|
|
|
from tests.integration.conftest import CHECKLIST_BYTES, FRONTEND_DIR
|
|
|
|
pytestmark = pytest.mark.integration
|
|
|
|
ORIGIN = "http://127.0.0.1:8000"
|
|
|
|
|
|
@pytest.fixture
|
|
def app(tmp_path: Path):
|
|
settings = Settings(
|
|
dev_mode=True,
|
|
frontend_dist_dir=FRONTEND_DIR,
|
|
artifact_storage_dir=tmp_path / "artifacts",
|
|
query_timeout_seconds=30.0,
|
|
)
|
|
return create_app(settings=settings, artifact_store=ArtifactStore(settings.artifact_storage_dir))
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_index_and_assets_served_with_headers(app):
|
|
async with httpx.AsyncClient(transport=ASGITransport(app=app), base_url=ORIGIN) as c:
|
|
index = await c.get("/")
|
|
assert index.status_code == 200
|
|
assert index.headers["content-type"].startswith("text/html")
|
|
assert "<title>Confluence Research</title>" in index.text
|
|
assert index.headers["content-security-policy"] == CSP_POLICY
|
|
assert index.headers["referrer-policy"] == "no-referrer"
|
|
assert index.headers["x-content-type-options"] == "nosniff"
|
|
cookie = index.headers["set-cookie"]
|
|
assert cookie.startswith(f"{SESSION_COOKIE_NAME}=") and "HttpOnly" in cookie and "SameSite=strict" in cookie.replace("Strict", "strict") and "Path=/" in cookie
|
|
assert "Secure" not in cookie # plain HTTP loopback deployment
|
|
|
|
for path, ctype in [
|
|
("/css/style.css", "text/css"),
|
|
("/js/app.js", "javascript"),
|
|
("/js/api.js", "javascript"),
|
|
("/js/render.js", "javascript"),
|
|
("/js/history.js", "javascript"),
|
|
("/vendor/marked.min.js", "javascript"),
|
|
("/vendor/purify.min.js", "javascript"),
|
|
]:
|
|
r = await c.get(path)
|
|
assert r.status_code == 200, path
|
|
assert ctype in r.headers["content-type"], (path, r.headers["content-type"])
|
|
assert r.headers["content-security-policy"] == CSP_POLICY
|
|
assert r.headers["x-content-type-options"] == "nosniff"
|
|
assert len(r.content) == (FRONTEND_DIR / path.lstrip("/")).stat().st_size
|
|
|
|
for hidden in ["/dev/mock-server.js", "/tests/api.test.js", "/HANDOFF.md", "/README.md", "/package.json", "/static/js/app.js", "/js/../HANDOFF.md", "/index.html/../package.json"]:
|
|
r = await c.get(hidden)
|
|
assert r.status_code == 404, hidden
|
|
assert r.json()["error"]["code"] in {"invalid_input", "artifact_not_found"}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_frontend_request_shapes_against_backend(app):
|
|
"""Replays the exact bodies/headers frontend/js/api.js sends, in dev mode."""
|
|
async with httpx.AsyncClient(transport=ASGITransport(app=app), base_url=ORIGIN) as c:
|
|
await c.get("/")
|
|
headers = {"Origin": ORIGIN, "Content-Type": "application/json"}
|
|
bad = await c.post("/api/v1/auth/verify", json={"url": "https://approved.example.com", "pat": "wrong"}, headers=headers)
|
|
assert bad.status_code == 403 and bad.json()["error"]["code"] == "confluence_auth_failed"
|
|
ok = await c.post("/api/v1/auth/verify", json={"url": "https://approved.example.com", "pat": "dev-pat"}, headers=headers)
|
|
assert ok.status_code == 200 and ok.json() == {"valid": True}
|
|
denied = await c.post("/api/v1/auth/verify", json={"url": "https://elsewhere.example.org", "pat": "dev-pat"}, headers=headers)
|
|
assert denied.status_code == 403 and denied.json()["error"]["code"] == "destination_denied"
|
|
no_origin = await c.post("/api/v1/auth/verify", json={"url": "https://approved.example.com", "pat": "dev-pat"})
|
|
assert no_origin.status_code == 403 and no_origin.json()["error"]["code"] == "origin_denied"
|
|
extra = await c.post("/api/v1/query", json={"prompt": "x", "credentials": {"url": "https://approved.example.com", "pat": "dev-pat"}, "extra": 1}, headers=headers)
|
|
assert extra.status_code == 400 and extra.json()["error"]["code"] == "invalid_input"
|
|
|
|
q = await c.post("/api/v1/query", json={"prompt": "How do I deploy service X?", "credentials": {"url": "https://approved.example.com", "pat": "dev-pat"}}, headers=headers)
|
|
assert q.status_code == 200, q.text
|
|
body = q.json()
|
|
assert len(body["tool_history"]) == 2 and len(body["pages_accessed"]) == 1
|
|
art = body["artifacts"][0]
|
|
dl = await c.get(f"/api/v1/artifacts/{art['id']}")
|
|
assert dl.status_code == 200 and dl.content == CHECKLIST_BYTES
|
|
assert dl.headers["content-disposition"].startswith("attachment")
|