"""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 "Confluence Research" 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")