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 fastapi.middleware.cors import CORSMiddleware
from app.api import audio, events, export, history, sessions from app.api import audio, events, export, history, sessions
from app.config import settings
logging.basicConfig( logging.basicConfig(
level=logging.INFO, level=logging.INFO,
@ -20,19 +21,38 @@ app = FastAPI(
version="0.1.0", version="0.1.0",
) )
# CORS: allow localhost frontend dev server and same-origin in production # ── Mode middleware (applied before CORS so headers are always present) ──────
_frontend_port = os.getenv("LINNET_FRONTEND_PORT", "8521") 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( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=[ allow_origins=_origins,
f"http://localhost:{_frontend_port}",
"http://127.0.0.1:" + _frontend_port,
],
allow_credentials=True, allow_credentials=True,
allow_methods=["*"], allow_methods=["*"],
allow_headers=["*"], allow_headers=["*"],
) )
# ── Routers ───────────────────────────────────────────────────────────────────
app.include_router(sessions.router) app.include_router(sessions.router)
app.include_router(events.router) app.include_router(events.router)
app.include_router(history.router) app.include_router(history.router)
@ -42,4 +62,5 @@ app.include_router(export.router)
@app.get("/health") @app.get("/health")
def health() -> dict: 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) 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: def end_session(session_id: str) -> bool:
"""Stop and remove a session. Returns True if it existed.""" """Stop and remove a session. Returns True if it existed."""
session = _sessions.pop(session_id, None) 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 #!/usr/bin/env bash
# manage.sh — Linnet dev management script # manage.sh — Linnet dev/demo/cloud management script
set -euo pipefail set -euo pipefail
CMD=${1:-help} CMD=${1:-help}
PROFILE=${2:-dev} # dev | demo | cloud
API_PORT=${LINNET_PORT:-8522} API_PORT=${LINNET_PORT:-8522}
FE_PORT=${LINNET_FRONTEND_PORT:-8521} 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() { _check_env() {
if [[ ! -f .env ]]; then if [[ ! -f .env ]]; then
echo "No .env found — copying .env.example" warn "No .env found — copying .env.example"
cp .env.example .env cp .env.example .env
fi 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 case "$CMD" in
start) start)
_check_env _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 \ CF_VOICE_MOCK=1 conda run -n cf uvicorn app.main:app \
--host 0.0.0.0 --port "$API_PORT" --reload & --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 & cd frontend && npm install --silent && npm run dev &
echo "API: http://localhost:${API_PORT}" info "API: http://localhost:${API_PORT}"
echo "UI: http://localhost:${FE_PORT}" info "UI: http://localhost:${FE_PORT}"
wait 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) 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 "uvicorn app.main:app" 2>/dev/null || true
pkill -f "vite.*${FE_PORT}" 2>/dev/null || true pkill -f "vite" 2>/dev/null || true
echo "Stopped." else
FLAGS=$(_compose_cmd "$PROFILE")
# shellcheck disable=SC2086
docker compose $FLAGS down
fi
info "Stopped ($PROFILE)."
;;
restart)
"$0" stop "$PROFILE"
"$0" start "$PROFILE"
;; ;;
status) status)
case "$PROFILE" in
dev)
echo -n "API: " 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: " echo -n "Frontend: "
curl -sf "http://localhost:${FE_PORT}" -o /dev/null && echo "running" || echo "not running" 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) test)
_check_env _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) 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) build)
_check_env info "Building Docker images ($PROFILE)…"
docker compose up -d FLAGS=$(_compose_cmd "$PROFILE")
echo "API: http://localhost:${API_PORT}" # shellcheck disable=SC2086
echo "UI: http://localhost:${FE_PORT}" docker compose $FLAGS build
;; info "Build complete."
docker-stop)
docker compose down
;; ;;
open) open)
xdg-open "http://localhost:${FE_PORT}" 2>/dev/null \ case "$PROFILE" in
|| open "http://localhost:${FE_PORT}" 2>/dev/null \ demo) URL="http://localhost:8524" ;;
|| echo "Open http://localhost:${FE_PORT} in your browser" 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"
;; ;;
*) help|*)
echo "Usage: $0 {start|stop|status|test|logs|docker-start|docker-stop|open}" 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 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"