app/config.py: centralized Settings (DEMO_MODE, CLOUD_MODE, ports, etc.) app/middleware/demo.py: DemoModeMiddleware — caps sessions (429), blocks export (403), adds X-Linnet-Mode header app/middleware/cloud.py: CloudAuthMiddleware — requires X-CF-Session on /session/* routes, 401 without it app/services/session_store.py: active_session_count() for demo cap app/main.py: wires middleware conditionally, extends CORS for cloud origins compose.test.yml: hermetic pytest runner in Docker (CF_VOICE_MOCK=1) compose.demo.yml: DEMO_MODE=true, ports 8523/8524, demo.circuitforge.tech/linnet compose.cloud.yml: CLOUD_MODE=true, ports 8522/8527, menagerie.circuitforge.tech/linnet docker/web/Dockerfile: two-stage build (node:20 → nginx:alpine), VITE_BASE_URL/VITE_API_BASE ARGs docker/web/nginx.conf: SSE + WS proxy, SPA routing (dev/demo) docker/web/nginx.cloud.conf: adds X-CF-Session forwarding, /linnet/ alias for path-strip Caddy routing manage.sh: profile arg (dev|demo|cloud|test), start/stop/restart/status/test/logs/build/open per profile tests/test_profiles.py: 8 tests — demo export block, session cap, cloud auth gate, mode headers
114 lines
3.5 KiB
Python
114 lines
3.5 KiB
Python
# tests/test_profiles.py — DEMO_MODE and CLOUD_MODE middleware behaviour
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
|
|
@pytest.fixture()
|
|
def demo_client():
|
|
"""TestClient with DEMO_MODE active."""
|
|
os.environ["DEMO_MODE"] = "true"
|
|
os.environ["DEMO_MAX_SESSIONS"] = "2"
|
|
# Re-import to pick up new env
|
|
import importlib
|
|
import app.config as cfg
|
|
importlib.reload(cfg)
|
|
import app.main as main_mod
|
|
importlib.reload(main_mod)
|
|
with TestClient(main_mod.app) as c:
|
|
yield c
|
|
os.environ.pop("DEMO_MODE", None)
|
|
os.environ.pop("DEMO_MAX_SESSIONS", None)
|
|
importlib.reload(cfg)
|
|
importlib.reload(main_mod)
|
|
|
|
|
|
@pytest.fixture()
|
|
def cloud_client():
|
|
"""TestClient with CLOUD_MODE active."""
|
|
os.environ["CLOUD_MODE"] = "true"
|
|
import importlib
|
|
import app.config as cfg
|
|
importlib.reload(cfg)
|
|
import app.main as main_mod
|
|
importlib.reload(main_mod)
|
|
with TestClient(main_mod.app) as c:
|
|
yield c
|
|
os.environ.pop("CLOUD_MODE", None)
|
|
importlib.reload(cfg)
|
|
importlib.reload(main_mod)
|
|
|
|
|
|
# ── Demo mode ─────────────────────────────────────────────────────────────────
|
|
|
|
def test_demo_health_mode(demo_client):
|
|
resp = demo_client.get("/health")
|
|
assert resp.json()["mode"] == "demo"
|
|
|
|
|
|
def test_demo_export_blocked(demo_client):
|
|
"""Export must return 403 in demo mode."""
|
|
# Start a session first so the export route can match
|
|
start = demo_client.post("/session/start")
|
|
assert start.status_code == 200
|
|
sid = start.json()["session_id"]
|
|
resp = demo_client.get(f"/session/{sid}/export")
|
|
assert resp.status_code == 403
|
|
|
|
|
|
def test_demo_header_present(demo_client):
|
|
resp = demo_client.get("/health")
|
|
assert resp.headers.get("x-linnet-mode") == "demo"
|
|
|
|
|
|
def test_demo_session_cap(demo_client):
|
|
"""Creating sessions beyond DEMO_MAX_SESSIONS returns 429."""
|
|
# Create up to the cap
|
|
sessions = []
|
|
for _ in range(2):
|
|
r = demo_client.post("/session/start")
|
|
assert r.status_code == 200
|
|
sessions.append(r.json()["session_id"])
|
|
|
|
# One more should be rejected
|
|
overflow = demo_client.post("/session/start")
|
|
assert overflow.status_code == 429
|
|
|
|
# Clean up
|
|
for sid in sessions:
|
|
demo_client.delete(f"/session/{sid}/end")
|
|
|
|
|
|
# ── Cloud mode ────────────────────────────────────────────────────────────────
|
|
|
|
def test_cloud_health_no_auth(cloud_client):
|
|
"""Health endpoint should not require auth."""
|
|
resp = cloud_client.get("/health")
|
|
assert resp.status_code == 200
|
|
assert resp.json()["mode"] == "cloud"
|
|
|
|
|
|
def test_cloud_session_requires_auth(cloud_client):
|
|
"""Session start without X-CF-Session should be 401."""
|
|
resp = cloud_client.post("/session/start")
|
|
assert resp.status_code == 401
|
|
|
|
|
|
def test_cloud_session_with_auth(cloud_client):
|
|
"""Valid X-CF-Session header should pass through."""
|
|
resp = cloud_client.post(
|
|
"/session/start",
|
|
headers={"X-CF-Session": "test-user-token"},
|
|
)
|
|
assert resp.status_code == 200
|
|
|
|
|
|
def test_cloud_header_present(cloud_client):
|
|
resp = cloud_client.get(
|
|
"/session/ghost",
|
|
headers={"X-CF-Session": "user"},
|
|
)
|
|
assert resp.headers.get("x-linnet-mode") == "cloud"
|