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