feat: test/demo/cloud profiles — middleware, compose files, nginx, manage.sh

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
This commit is contained in:
pyr0ball 2026-04-06 18:39:07 -07:00
parent 7e14f9135e
commit 321abe0646
14 changed files with 636 additions and 41 deletions

29
app/config.py Normal file
View file

@ -0,0 +1,29 @@
# app/config.py — runtime mode settings
from __future__ import annotations
import os
class Settings:
"""Read-once settings from environment variables."""
demo_mode: bool = os.getenv("DEMO_MODE", "").lower() in ("1", "true", "yes")
cloud_mode: bool = os.getenv("CLOUD_MODE", "").lower() in ("1", "true", "yes")
# DEMO: max simultaneous active sessions (prevents resource abuse on the demo server)
demo_max_sessions: int = int(os.getenv("DEMO_MAX_SESSIONS", "3"))
# DEMO: auto-kill sessions after this many seconds of inactivity
demo_session_ttl_s: int = int(os.getenv("DEMO_SESSION_TTL_S", "300")) # 5 min
# CLOUD: where Caddy injects the cf_session user token
cloud_session_header: str = os.getenv("CLOUD_SESSION_HEADER", "X-CF-Session")
cloud_data_root: str = os.getenv("CLOUD_DATA_ROOT", "/devl/linnet-cloud-data")
heimdall_url: str = os.getenv("HEIMDALL_URL", "https://license.circuitforge.tech")
linnet_port: int = int(os.getenv("LINNET_PORT", "8522"))
linnet_frontend_port: int = int(os.getenv("LINNET_FRONTEND_PORT", "8521"))
linnet_base_url: str = os.getenv("LINNET_BASE_URL", "")
settings = Settings()

View file

@ -8,6 +8,7 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.api import audio, events, export, history, sessions
from app.config import settings
logging.basicConfig(
level=logging.INFO,
@ -20,19 +21,38 @@ app = FastAPI(
version="0.1.0",
)
# CORS: allow localhost frontend dev server and same-origin in production
_frontend_port = os.getenv("LINNET_FRONTEND_PORT", "8521")
# ── Mode middleware (applied before CORS so headers are always present) ──────
if settings.demo_mode:
from app.middleware.demo import DemoModeMiddleware
app.add_middleware(DemoModeMiddleware)
logging.getLogger(__name__).info("DEMO_MODE active")
if settings.cloud_mode:
from app.middleware.cloud import CloudAuthMiddleware
app.add_middleware(CloudAuthMiddleware)
logging.getLogger(__name__).info("CLOUD_MODE active")
# ── CORS ─────────────────────────────────────────────────────────────────────
_frontend_port = str(settings.linnet_frontend_port)
_origins = [
f"http://localhost:{_frontend_port}",
f"http://127.0.0.1:{_frontend_port}",
]
if settings.cloud_mode:
_origins += [
"https://menagerie.circuitforge.tech",
"https://circuitforge.tech",
]
app.add_middleware(
CORSMiddleware,
allow_origins=[
f"http://localhost:{_frontend_port}",
"http://127.0.0.1:" + _frontend_port,
],
allow_origins=_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# ── Routers ───────────────────────────────────────────────────────────────────
app.include_router(sessions.router)
app.include_router(events.router)
app.include_router(history.router)
@ -42,4 +62,5 @@ app.include_router(export.router)
@app.get("/health")
def health() -> dict:
return {"status": "ok", "service": "linnet"}
mode = "demo" if settings.demo_mode else ("cloud" if settings.cloud_mode else "dev")
return {"status": "ok", "service": "linnet", "mode": mode}

View file

41
app/middleware/cloud.py Normal file
View file

@ -0,0 +1,41 @@
# app/middleware/cloud.py — CLOUD_MODE auth
#
# When CLOUD_MODE=true, all /session/* routes require the X-CF-Session header
# (injected by Caddy from the cf_session cookie set by the website auth flow).
# The header value is forwarded opaquely — Linnet trusts it as an opaque user ID.
# Full Heimdall JWT validation is a v1.0 addition (tracked in linnet#16).
from __future__ import annotations
import logging
from fastapi import Request, Response
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import JSONResponse
from app.config import settings
logger = logging.getLogger(__name__)
# Paths that don't require auth even in cloud mode
_PUBLIC_PATHS = {"/health", "/docs", "/openapi.json", "/redoc"}
class CloudAuthMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next) -> Response:
path = request.url.path
if path in _PUBLIC_PATHS or not path.startswith("/session"):
return await call_next(request)
session_token = request.headers.get(settings.cloud_session_header, "").strip()
if not session_token:
return JSONResponse(
status_code=401,
content={"detail": "Authentication required. Sign in at circuitforge.tech."},
)
# Attach the user identity to request state so endpoints can use it
request.state.cf_user = session_token
response = await call_next(request)
response.headers["X-Linnet-Mode"] = "cloud"
return response

45
app/middleware/demo.py Normal file
View file

@ -0,0 +1,45 @@
# app/middleware/demo.py — DEMO_MODE restrictions
#
# When DEMO_MODE=true:
# - Session creation is capped at DEMO_MAX_SESSIONS concurrent sessions
# - Export endpoint returns 403 (no personal data leaves the demo server)
# - Response header X-Linnet-Mode: demo on all responses
# - CF_VOICE_MOCK is forced on (see compose.demo.yml)
from __future__ import annotations
from fastapi import Request, Response
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.responses import JSONResponse
from app.config import settings
from app.services import session_store
class DemoModeMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next) -> Response:
path = request.url.path
# Block export in demo mode — no data leaves the demo server
if path.endswith("/export"):
return JSONResponse(
status_code=403,
content={"detail": "Export is disabled in demo mode."},
)
# Cap concurrent session creation
if path == "/session/start" and request.method == "POST":
active = session_store.active_session_count()
if active >= settings.demo_max_sessions:
return JSONResponse(
status_code=429,
content={
"detail": (
f"Demo server is at capacity ({settings.demo_max_sessions} "
"active sessions). Please try again in a moment."
)
},
)
response = await call_next(request)
response.headers["X-Linnet-Mode"] = "demo"
return response

View file

@ -35,6 +35,11 @@ def get_session(session_id: str) -> Session | None:
return _sessions.get(session_id)
def active_session_count() -> int:
"""Return the number of currently running sessions."""
return sum(1 for s in _sessions.values() if s.state == "running")
def end_session(session_id: str) -> bool:
"""Stop and remove a session. Returns True if it existed."""
session = _sessions.pop(session_id, None)

56
compose.cloud.yml Normal file
View file

@ -0,0 +1,56 @@
# compose.cloud.yml — Cloud managed instance
#
# Project: linnet-cloud (docker compose -f compose.cloud.yml -p linnet-cloud ...)
# Web: http://127.0.0.1:8527 → menagerie.circuitforge.tech/linnet (via Caddy + JWT)
# API: internal only on linnet-cloud-net (nginx proxies /session/ → linnet-api:8522)
#
# Requires in .env:
# CLOUD_MODE=true
# LINNET_LICENSE_KEY=CFG-LNNT-... (or per-request key via API)
# HEIMDALL_URL=https://license.circuitforge.tech
# DIRECTUS_JWT_SECRET=... (Caddy injects X-CF-Session from cf_session cookie)
# HF_TOKEN=... (needed for real cf-voice inference; omit for mock)
# CF_VOICE_MOCK=0 (set to 1 during staged rollout)
services:
linnet-api:
build:
context: .
dockerfile: Dockerfile
restart: unless-stopped
env_file: .env
environment:
CLOUD_MODE: "true"
LINNET_PORT: "8522"
LINNET_FRONTEND_PORT: "8527"
LINNET_BASE_URL: "https://menagerie.circuitforge.tech/linnet"
CLOUD_DATA_ROOT: /devl/linnet-cloud-data
CF_ORCH_URL: http://host.docker.internal:7700
extra_hosts:
- "host.docker.internal:host-gateway"
volumes:
- /devl/linnet-cloud-data:/devl/linnet-cloud-data
- ${HOME}/.config/circuitforge:/root/.config/circuitforge:ro
networks:
- linnet-cloud-net
linnet-web:
build:
context: .
dockerfile: docker/web/Dockerfile
args:
VITE_BASE_URL: /linnet/
VITE_API_BASE: /linnet
restart: unless-stopped
ports:
- "8527:80"
volumes:
- ./docker/web/nginx.cloud.conf:/etc/nginx/conf.d/default.conf:ro
networks:
- linnet-cloud-net
depends_on:
- linnet-api
networks:
linnet-cloud-net:
driver: bridge

51
compose.demo.yml Normal file
View file

@ -0,0 +1,51 @@
# compose.demo.yml — Public demo instance
#
# Runs a capped, mock-only Linnet session for demo.circuitforge.tech/linnet.
# - DEMO_MODE=true: caps concurrent sessions, disables export
# - CF_VOICE_MOCK=1: synthetic ToneEvent stream (no inference, no HF_TOKEN needed)
# - Port 8523 (API internal), 8524 (frontend via nginx)
#
# Usage:
# docker compose -f compose.demo.yml -p linnet-demo up -d
# docker compose -f compose.demo.yml -p linnet-demo down
#
# Caddy: demo.circuitforge.tech/linnet* → host port 8524
services:
linnet-api:
build:
context: .
dockerfile: Dockerfile
environment:
DEMO_MODE: "true"
CF_VOICE_MOCK: "1"
LINNET_PORT: "8523"
LINNET_FRONTEND_PORT: "8524"
LINNET_BASE_URL: "/linnet"
DEMO_MAX_SESSIONS: "3"
DEMO_SESSION_TTL_S: "300"
networks:
- linnet-demo-net
restart: unless-stopped
# API is internal-only — frontend nginx proxies to it
linnet-web:
build:
context: .
dockerfile: docker/web/Dockerfile
args:
VITE_BASE_URL: /linnet/
VITE_API_BASE: /linnet
ports:
- "8524:80"
volumes:
- ./docker/web/nginx.conf:/etc/nginx/conf.d/default.conf:ro
networks:
- linnet-demo-net
depends_on:
- linnet-api
restart: unless-stopped
networks:
linnet-demo-net:
driver: bridge

23
compose.test.yml Normal file
View file

@ -0,0 +1,23 @@
# compose.test.yml — CI / automated test runner
#
# Builds the API image and runs the pytest suite inside the container.
# No frontend. No inference (CF_VOICE_MOCK=1 always).
#
# Usage:
# docker compose -f compose.test.yml run --rm linnet-test
# docker compose -f compose.test.yml run --rm linnet-test -- tests/test_tiers.py
services:
linnet-test:
build:
context: .
dockerfile: Dockerfile
command: >
sh -c "pip install -e '.[dev]' -q &&
python -m pytest tests/ -v --tb=short ${@}"
environment:
CF_VOICE_MOCK: "1"
DEMO_MODE: ""
CLOUD_MODE: ""
PYTHONUNBUFFERED: "1"
# No ports, no volumes — hermetic

22
docker/web/Dockerfile Normal file
View file

@ -0,0 +1,22 @@
# Stage 1: build Vue 3 frontend
FROM node:20-alpine AS build
WORKDIR /app
COPY frontend/package*.json ./
RUN npm ci --prefer-offline
COPY frontend/ ./
# Vite bakes these as static strings into the bundle.
# VITE_BASE_URL: path prefix the app is served under (/ for dev, /linnet for cloud)
# VITE_API_BASE: prefix for all /session/* fetch calls (empty for direct, /linnet for cloud)
ARG VITE_BASE_URL=/
ARG VITE_API_BASE=
ENV VITE_BASE_URL=$VITE_BASE_URL
ENV VITE_API_BASE=$VITE_API_BASE
RUN npm run build
# Stage 2: serve via nginx
FROM nginx:alpine
COPY docker/web/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80

View file

@ -0,0 +1,51 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Proxy to FastAPI container on the cloud network
location /session/ {
proxy_pass http://linnet-api:8522;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
# Forward the session header injected by Caddy from cf_session cookie
proxy_set_header X-CF-Session $http_x_cf_session;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_buffering off;
proxy_read_timeout 3600s;
}
location /health {
proxy_pass http://linnet-api:8522;
proxy_set_header Host $host;
}
# When accessed via Caddy at /linnet (path-strip), assets are at /linnet/assets/...
# but stored at /assets/... in nginx's root. Alias so direct port access still works.
location ^~ /linnet/ {
alias /usr/share/nginx/html/;
try_files $uri $uri/ /index.html;
}
location = /index.html {
add_header Cache-Control "no-cache, no-store, must-revalidate";
try_files $uri /index.html;
}
location / {
try_files $uri $uri/ /index.html;
}
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}

43
docker/web/nginx.conf Normal file
View file

@ -0,0 +1,43 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Proxy API + WebSocket requests to the FastAPI container
location /session/ {
proxy_pass http://linnet-api:8522;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# WebSocket upgrade for the audio WS endpoint
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
# SSE: disable buffering so events reach the client immediately
proxy_buffering off;
proxy_read_timeout 3600s;
}
location /health {
proxy_pass http://linnet-api:8522;
proxy_set_header Host $host;
}
location = /index.html {
add_header Cache-Control "no-cache, no-store, must-revalidate";
try_files $uri /index.html;
}
location / {
try_files $uri $uri/ /index.html;
}
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}

146
manage.sh
View file

@ -1,72 +1,166 @@
#!/usr/bin/env bash
# manage.sh — Linnet dev management script
# manage.sh — Linnet dev/demo/cloud management script
set -euo pipefail
CMD=${1:-help}
PROFILE=${2:-dev} # dev | demo | cloud
API_PORT=${LINNET_PORT:-8522}
FE_PORT=${LINNET_FRONTEND_PORT:-8521}
# ── Helpers ───────────────────────────────────────────────────────────────────
GREEN='\033[0;32m'; YELLOW='\033[1;33m'; RED='\033[0;31m'; NC='\033[0m'
info() { echo -e "${GREEN}[linnet]${NC} $*"; }
warn() { echo -e "${YELLOW}[linnet]${NC} $*"; }
error() { echo -e "${RED}[linnet]${NC} $*" >&2; exit 1; }
_check_env() {
if [[ ! -f .env ]]; then
echo "No .env found — copying .env.example"
warn "No .env found — copying .env.example"
cp .env.example .env
fi
}
_compose_cmd() {
# Returns the correct compose file flags for a given profile
case "$1" in
demo) echo "-f compose.demo.yml -p linnet-demo" ;;
cloud) echo "-f compose.cloud.yml -p linnet-cloud" ;;
test) echo "-f compose.test.yml" ;;
dev) echo "-f compose.yml" ;;
*) error "Unknown profile: $1. Use dev|demo|cloud|test." ;;
esac
}
# ── Commands ──────────────────────────────────────────────────────────────────
case "$CMD" in
start)
_check_env
echo "Starting Linnet API on :${API_PORT} (mock mode)…"
if [[ "$PROFILE" == "dev" ]]; then
info "Starting Linnet API on :${API_PORT} (mock mode, dev)…"
CF_VOICE_MOCK=1 conda run -n cf uvicorn app.main:app \
--host 0.0.0.0 --port "$API_PORT" --reload &
echo "Starting Linnet frontend on :${FE_PORT}"
info "Starting Linnet frontend on :${FE_PORT}"
cd frontend && npm install --silent && npm run dev &
echo "API: http://localhost:${API_PORT}"
echo "UI: http://localhost:${FE_PORT}"
info "API: http://localhost:${API_PORT}"
info "UI: http://localhost:${FE_PORT}"
wait
else
FLAGS=$(_compose_cmd "$PROFILE")
info "Starting Linnet ($PROFILE profile)…"
# shellcheck disable=SC2086
docker compose $FLAGS up -d --build
case "$PROFILE" in
demo) info "Frontend: http://localhost:8524" ;;
cloud) info "Frontend: http://localhost:8527 → menagerie.circuitforge.tech/linnet" ;;
esac
fi
;;
stop)
echo "Stopping Linnet processes…"
if [[ "$PROFILE" == "dev" ]]; then
info "Stopping Linnet dev processes…"
pkill -f "uvicorn app.main:app" 2>/dev/null || true
pkill -f "vite.*${FE_PORT}" 2>/dev/null || true
echo "Stopped."
pkill -f "vite" 2>/dev/null || true
else
FLAGS=$(_compose_cmd "$PROFILE")
# shellcheck disable=SC2086
docker compose $FLAGS down
fi
info "Stopped ($PROFILE)."
;;
restart)
"$0" stop "$PROFILE"
"$0" start "$PROFILE"
;;
status)
case "$PROFILE" in
dev)
echo -n "API: "
curl -sf "http://localhost:${API_PORT}/health" 2>/dev/null | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['status'])" || echo "not running"
curl -sf "http://localhost:${API_PORT}/health" 2>/dev/null \
| python3 -c "import sys,json; d=json.load(sys.stdin); print(d['status'], '|', d.get('mode','dev'))" \
|| echo "not running"
echo -n "Frontend: "
curl -sf "http://localhost:${FE_PORT}" -o /dev/null && echo "running" || echo "not running"
;;
demo)
echo -n "Demo API: "; curl -sf "http://localhost:8523/health" 2>/dev/null | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['status'])" || echo "not running"
echo -n "Demo Web: "; curl -sf "http://localhost:8524" -o /dev/null && echo "running" || echo "not running"
;;
cloud)
echo -n "Cloud API: "; curl -sf "http://localhost:8522/health" 2>/dev/null | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['status'])" || echo "not running"
echo -n "Cloud Web: "; curl -sf "http://localhost:8527" -o /dev/null && echo "running" || echo "not running"
;;
esac
;;
test)
_check_env
CF_VOICE_MOCK=1 conda run -n cf python -m pytest tests/ -v "${@:2}"
if [[ "$PROFILE" == "dev" ]]; then
info "Running test suite (local)…"
CF_VOICE_MOCK=1 conda run -n cf python -m pytest tests/ -v "${@:3}"
else
info "Running test suite (Docker)…"
docker compose -f compose.test.yml run --rm linnet-test "${@:3}"
fi
;;
logs)
echo "Use 'docker compose logs -f' in Docker mode, or check terminal for dev mode."
if [[ "$PROFILE" == "dev" ]]; then
warn "Dev mode: check terminal output."
else
FLAGS=$(_compose_cmd "$PROFILE")
SERVICE=${3:-}
# shellcheck disable=SC2086
docker compose $FLAGS logs -f $SERVICE
fi
;;
docker-start)
_check_env
docker compose up -d
echo "API: http://localhost:${API_PORT}"
echo "UI: http://localhost:${FE_PORT}"
;;
docker-stop)
docker compose down
build)
info "Building Docker images ($PROFILE)…"
FLAGS=$(_compose_cmd "$PROFILE")
# shellcheck disable=SC2086
docker compose $FLAGS build
info "Build complete."
;;
open)
xdg-open "http://localhost:${FE_PORT}" 2>/dev/null \
|| open "http://localhost:${FE_PORT}" 2>/dev/null \
|| echo "Open http://localhost:${FE_PORT} in your browser"
case "$PROFILE" in
demo) URL="http://localhost:8524" ;;
cloud) URL="http://localhost:8527" ;;
*) URL="http://localhost:${FE_PORT}" ;;
esac
xdg-open "$URL" 2>/dev/null || open "$URL" 2>/dev/null || info "Open $URL in your browser"
;;
*)
echo "Usage: $0 {start|stop|status|test|logs|docker-start|docker-stop|open}"
help|*)
echo ""
echo " Usage: $0 <command> [profile]"
echo ""
echo " Commands:"
echo -e " ${GREEN}start${NC} [profile] Start API + frontend"
echo -e " ${GREEN}stop${NC} [profile] Stop services"
echo -e " ${GREEN}restart${NC} [profile] Stop then start"
echo -e " ${GREEN}status${NC} [profile] Check running services"
echo -e " ${GREEN}test${NC} [profile] Run pytest suite"
echo -e " ${GREEN}logs${NC} [profile] Tail logs (Docker profiles only)"
echo -e " ${GREEN}build${NC} [profile] Build Docker images"
echo -e " ${GREEN}open${NC} [profile] Open UI in browser"
echo ""
echo " Profiles:"
echo " dev (default) Local uvicorn + Vite dev server, mock mode"
echo " demo Docker: DEMO_MODE=true, port 8524"
echo " cloud Docker: CLOUD_MODE=true, port 8527 → menagerie.circuitforge.tech/linnet"
echo " test Docker: pytest suite, hermetic"
echo ""
echo " Examples:"
echo " $0 start # dev mode"
echo " $0 start demo # demo stack"
echo " $0 test # local pytest"
echo " $0 test docker # pytest in Docker"
echo " $0 logs demo # demo logs"
echo ""
;;
esac

114
tests/test_profiles.py Normal file
View file

@ -0,0 +1,114 @@
# 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"