Artur Mukhamadiev e65fbf4b67 backend: FastAPI backend track handoff (contract revision 1)
FastAPI app, upstream Confluence/model adapters, authoritative history,
rootless container lifecycle, artifact storage and downloads, fake peers
under backend/dev, tests under tests/backend. Root pytest.ini deselects
the live marker by default; requirements gain the backend dependencies.
2026-09-14 21:57:54 +03:00

368 lines
14 KiB
Python

"""Unit and integration tests for FastAPI application, HTTP contracts, and security."""
import asyncio
from pathlib import Path
import pytest
import httpx
from httpx import ASGITransport
from backend.app import create_app, SESSION_COOKIE_NAME, CSP_POLICY
from backend.artifacts import ArtifactStore
from backend.confluence import ConfluenceClient
from backend.containers import FakeContainerManager
from backend.dev.fake_peer import ScriptedContainerPeer
from backend.model import FakeModelAdapter
from backend.settings import Settings
from tests.backend.conftest import make_test_confluence_client_factory
@pytest.fixture
def test_app_env(tmp_path: Path):
settings = Settings(
approved_confluence_origins=["https://approved.example.com"],
query_timeout_seconds=30.0,
cleanup_timeout_seconds=5.0,
)
store = ArtifactStore(tmp_path / "artifacts")
container_mgr = FakeContainerManager(lambda: ScriptedContainerPeer(scenario="standard"))
model_adapter = FakeModelAdapter()
conf_factory = make_test_confluence_client_factory()
app = create_app(
settings=settings,
container_manager=container_mgr,
artifact_store=store,
model_adapter=model_adapter,
confluence_client_factory=conf_factory,
)
return app, store, container_mgr
@pytest.mark.asyncio
async def test_root_endpoint(test_app_env):
app, _, _ = test_app_env
transport = ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client:
resp = await client.get("/")
assert resp.status_code == 200
assert SESSION_COOKIE_NAME in resp.cookies
cookie = resp.cookies[SESSION_COOKIE_NAME]
assert len(cookie) >= 16
# Security headers
assert resp.headers.get("Content-Security-Policy") == CSP_POLICY
assert resp.headers.get("Referrer-Policy") == "no-referrer"
@pytest.mark.asyncio
async def test_auth_verify_origin_enforcement(test_app_env):
app, _, _ = test_app_env
transport = ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client:
# 1. Missing Origin header -> 403 origin_denied
resp = await client.post(
"/api/v1/auth/verify",
json={"url": "https://approved.example.com", "pat": "valid-pat"},
)
assert resp.status_code == 403
data = resp.json()
assert data["error"]["code"] == "origin_denied"
# 2. Mismatched Origin header -> 403 origin_denied
resp = await client.post(
"/api/v1/auth/verify",
json={"url": "https://approved.example.com", "pat": "valid-pat"},
headers={"Origin": "https://evil.com"},
)
assert resp.status_code == 403
data = resp.json()
assert data["error"]["code"] == "origin_denied"
@pytest.mark.asyncio
async def test_auth_verify_success_and_failures(test_app_env):
app, _, _ = test_app_env
transport = ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client:
headers = {"Origin": "http://testserver"}
# 1. Valid credentials
resp = await client.post(
"/api/v1/auth/verify",
json={"url": "https://approved.example.com", "pat": "valid-pat"},
headers=headers,
)
assert resp.status_code == 200
assert resp.json() == {"valid": True}
assert resp.headers.get("Cache-Control") == "no-store"
assert SESSION_COOKIE_NAME in resp.cookies
# 2. Invalid PAT -> 403 confluence_auth_failed
resp = await client.post(
"/api/v1/auth/verify",
json={"url": "https://approved.example.com", "pat": "wrong-pat"},
headers=headers,
)
assert resp.status_code == 403
assert resp.json()["error"]["code"] == "confluence_auth_failed"
# 3. Disapproved Confluence URL -> 403 destination_denied
resp = await client.post(
"/api/v1/auth/verify",
json={"url": "https://unapproved.example.com", "pat": "valid-pat"},
headers=headers,
)
assert resp.status_code == 403
assert resp.json()["error"]["code"] == "destination_denied"
@pytest.mark.asyncio
async def test_query_and_artifact_download(test_app_env):
app, store, container_mgr = test_app_env
transport = ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client:
headers = {"Origin": "http://testserver"}
# 1. Execute query
payload = {
"prompt": "How do I deploy service X?",
"credentials": {
"url": "https://approved.example.com",
"pat": "valid-pat",
},
}
resp = await client.post("/api/v1/query", json=payload, headers=headers)
assert resp.status_code == 200
assert resp.headers.get("Cache-Control") == "no-store"
assert SESSION_COOKIE_NAME in client.cookies
res_data = resp.json()
assert res_data["session_id"]
assert "Deployment Guide" in res_data["markdown"]
assert len(res_data["pages_accessed"]) == 1
assert len(res_data["artifacts"]) == 1
artifact_meta = res_data["artifacts"][0]
aid = artifact_meta["id"]
assert artifact_meta["name"] == "checklist.md"
assert artifact_meta["size_bytes"] == 32
# 2. Download artifact with same session cookie
dl_resp = await client.get(f"/api/v1/artifacts/{aid}")
assert dl_resp.status_code == 200
assert dl_resp.headers.get("Cache-Control") == "no-store"
assert dl_resp.headers.get("X-Content-Type-Options") == "nosniff"
assert "attachment" in dl_resp.headers.get("Content-Disposition", "")
assert dl_resp.content == b"# Checklist\n\n- Deploy service X\n"
# 3. Download with wrong session cookie -> 404
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as other_client:
# New client has no or different session cookie
other_resp = await other_client.get(f"/api/v1/artifacts/{aid}")
assert other_resp.status_code == 404
assert other_resp.json()["error"]["code"] == "artifact_not_found"
@pytest.mark.asyncio
async def test_framework_validation_error_sanitization(test_app_env):
app, _, _ = test_app_env
transport = ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client:
headers = {"Origin": "http://testserver"}
# Send payload missing required 'prompt' field and containing a fake PAT
bad_payload = {"credentials": {"url": "https://approved.example.com", "pat": "secret-secret-token"}}
resp = await client.post("/api/v1/query", json=bad_payload, headers=headers)
assert resp.status_code == 400
data = resp.json()
assert data["error"]["code"] == "invalid_input"
# Secret token must NEVER be echoed in error response!
assert "secret-secret-token" not in str(data)
@pytest.mark.asyncio
async def test_query_origin_enforcement(test_app_env):
app, _, _ = test_app_env
transport = ASGITransport(app=app)
payload = {
"prompt": "Test query",
"credentials": {"url": "https://approved.example.com", "pat": "valid-pat"},
}
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client:
# 1. Missing Origin header
resp = await client.post("/api/v1/query", json=payload)
assert resp.status_code == 403
assert resp.json()["error"]["code"] == "origin_denied"
assert resp.headers.get("Cache-Control") == "no-store"
# 2. Mismatched Origin header
resp = await client.post(
"/api/v1/query",
json=payload,
headers={"Origin": "https://malicious.org"},
)
assert resp.status_code == 403
assert resp.json()["error"]["code"] == "origin_denied"
assert resp.headers.get("Cache-Control") == "no-store"
# 3. Scheme mismatch (https Origin for http request)
resp = await client.post(
"/api/v1/query",
json=payload,
headers={"Origin": "https://testserver"},
)
assert resp.status_code == 403
assert resp.json()["error"]["code"] == "origin_denied"
@pytest.mark.asyncio
async def test_body_size_limits_and_chunked_streaming(test_app_env):
app, _, _ = test_app_env
transport = ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client:
# 1. Malformed Content-Length -> 400 invalid_input
resp = await client.post(
"/api/v1/auth/verify",
content=b"{}",
headers={"Origin": "http://testserver", "Content-Length": "not-an-int"},
)
assert resp.status_code == 400
assert resp.json()["error"]["code"] == "invalid_input"
assert resp.headers.get("Cache-Control") == "no-store"
# 2. Declared Content-Length exceeding 128 KiB -> 413 request_too_large
resp = await client.post(
"/api/v1/auth/verify",
content=b"{}",
headers={"Origin": "http://testserver", "Content-Length": str(129 * 1024)},
)
assert resp.status_code == 413
assert resp.json()["error"]["code"] == "request_too_large"
assert resp.headers.get("Cache-Control") == "no-store"
# 3. Chunked/streamed body exceeding limit -> 413 request_too_large
async def oversized_stream():
chunk = b"x" * 1024
for _ in range(129): # 129 KiB > 128 KiB
yield chunk
resp = await client.post(
"/api/v1/auth/verify",
content=oversized_stream(),
headers={"Origin": "http://testserver", "Content-Type": "application/json"},
)
assert resp.status_code == 413
assert resp.json()["error"]["code"] == "request_too_large"
assert resp.headers.get("Cache-Control") == "no-store"
@pytest.mark.asyncio
async def test_unexpected_fields_rejection(test_app_env):
app, _, _ = test_app_env
transport = ASGITransport(app=app)
headers = {"Origin": "http://testserver"}
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client:
# 1. Extra field in auth verify payload
resp = await client.post(
"/api/v1/auth/verify",
json={"url": "https://approved.example.com", "pat": "valid-pat", "unexpected": "bad"},
headers=headers,
)
assert resp.status_code == 400
assert resp.json()["error"]["code"] == "invalid_input"
# 2. Extra field in query payload
resp = await client.post(
"/api/v1/query",
json={
"prompt": "test",
"credentials": {"url": "https://approved.example.com", "pat": "valid-pat"},
"extra_key": 123,
},
headers=headers,
)
assert resp.status_code == 400
assert resp.json()["error"]["code"] == "invalid_input"
@pytest.mark.asyncio
async def test_route_404_and_envelope_codes(test_app_env):
app, _, _ = test_app_env
transport = ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client:
# Unknown API route maps to invalid_input (not artifact_not_found)
resp = await client.get("/api/v1/nonexistent")
assert resp.status_code == 404
assert resp.json()["error"]["code"] == "invalid_input"
assert resp.headers.get("Cache-Control") == "no-store"
# Unknown artifact maps to artifact_not_found
resp = await client.get("/api/v1/artifacts/missing-id")
assert resp.status_code == 404
assert resp.json()["error"]["code"] == "artifact_not_found"
assert resp.headers.get("Cache-Control") == "no-store"
@pytest.mark.asyncio
async def test_rfc6266_non_ascii_filename_download(test_app_env):
app, store, _ = test_app_env
transport = ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client:
# 1. Obtain a server-issued session
root_resp = await client.get("/")
assert root_resp.status_code == 200
session_id = root_resp.cookies[SESSION_COOKIE_NAME]
# 2. Stage and commit an artifact with non-ASCII Unicode characters (e.g. 'clé')
import base64
staging = store.create_staging_session("q-unicode")
decision, _ = staging.handle_begin("t-1", "rapport_clé_2026.pdf", 14)
assert decision == "accept"
b64_data = base64.b64encode(b"PDF Content OK").decode("utf-8")
staging.handle_chunk("t-1", 0, b64_data)
staging.handle_end("t-1", 14, 1)
committed = staging.commit(session_id=session_id)
assert len(committed) == 1
art_id = committed[0]["id"]
# 3. Download the artifact and verify RFC 6266 Content-Disposition header
resp = await client.get(f"/api/v1/artifacts/{art_id}")
assert resp.status_code == 200
assert resp.headers.get("Cache-Control") == "no-store"
assert resp.headers.get("X-Content-Type-Options") == "nosniff"
cd = resp.headers.get("Content-Disposition", "")
# Must contain ASCII fallback
assert 'filename="rapport_cl__2026.pdf"' in cd
# Must contain UTF-8 percent-encoded filename
assert "filename*=UTF-8''rapport_cl%C3%A9_2026.pdf" in cd
assert resp.content == b"PDF Content OK"
def test_dev_mode_wires_fake_container_manager(tmp_path: Path):
settings = Settings(dev_mode=True, artifact_storage_dir=tmp_path)
app = create_app(settings=settings)
assert app is not None
def test_production_model_validation_failures(tmp_path: Path):
# 1. Unknown provider
settings_bad_provider = Settings(
dev_mode=False,
model_provider="unsupported_provider",
artifact_storage_dir=tmp_path,
)
with pytest.raises(RuntimeError, match="Unknown model_provider"):
create_app(settings=settings_bad_provider)
# 2. Missing OpenAI key in production mode
settings_no_key = Settings(
dev_mode=False,
model_provider="openai",
model_api_key="",
artifact_storage_dir=tmp_path,
)
with pytest.raises(RuntimeError, match="Missing model_api_key"):
create_app(settings=settings_no_key)