Rebuild profile router as secure recoverable v2
This commit is contained in:
@@ -9,5 +9,11 @@ case "${1:-}" in
|
||||
*) echo "Usage: fake-llama-profile {fast|medium|long}"; exit 1 ;;
|
||||
esac
|
||||
|
||||
if [ -f /tmp/fake-profile-fail ] \
|
||||
&& [ "$(cat /tmp/fake-profile-fail)" = "$1" ]; then
|
||||
echo "fake: simulierter Profilfehler für '$1'"
|
||||
exit 42
|
||||
fi
|
||||
|
||||
cp "$D/profile-$1.conf.disabled" "$D/override.conf"
|
||||
echo "fake: Profil '$1' gesetzt"
|
||||
|
||||
@@ -58,6 +58,9 @@ class Handler(BaseHTTPRequestHandler):
|
||||
self._json(404, {"error": {"message": "not found"}})
|
||||
|
||||
def _completion(self, body: dict) -> dict:
|
||||
delay = body.get("mock_delay", 0)
|
||||
if isinstance(delay, (int, float)) and 0 < delay <= 10:
|
||||
time.sleep(delay)
|
||||
model = body.get("model")
|
||||
if body.get("tools"):
|
||||
name = body["tools"][0]["function"]["name"]
|
||||
@@ -84,6 +87,8 @@ class Handler(BaseHTTPRequestHandler):
|
||||
"finish_reason": finish}],
|
||||
"usage": {"prompt_tokens": 10, "completion_tokens": 5,
|
||||
"total_tokens": 15},
|
||||
"mock_ctx": current_ctx(),
|
||||
"mock_authorization": self.headers.get("Authorization"),
|
||||
}
|
||||
|
||||
def _stream(self, body: dict) -> None:
|
||||
|
||||
@@ -176,10 +176,17 @@ def cleanup() -> None:
|
||||
|
||||
def main() -> None:
|
||||
global procs
|
||||
try:
|
||||
os.unlink("/tmp/test-chunked-router-state.json")
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
env = os.environ.copy()
|
||||
env.update({
|
||||
"ROUTER_HOST": "127.0.0.1",
|
||||
"ROUTER_PORT": str(PORTS["router"]),
|
||||
"ROUTER_AUTH_MODE": "off",
|
||||
"ROUTER_PROFILES_FILE": "",
|
||||
"ROUTER_STATE_FILE": "/tmp/test-chunked-router-state.json",
|
||||
"UPSTREAM_URL": f"http://127.0.0.1:{PORTS['llama']}",
|
||||
"PROFILE_SCRIPT": FAKE_PROFILE,
|
||||
"PROFILE_DIR": FAKE_PROFILE_DIR,
|
||||
|
||||
+91
-13
@@ -4,18 +4,27 @@
|
||||
set -uo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
UP_PORT=18080
|
||||
RT_PORT=18081
|
||||
TTS_PORT=18082
|
||||
STT_PORT=18083
|
||||
UP_PORT="${UP_PORT:-18080}"
|
||||
RT_PORT="${RT_PORT:-18081}"
|
||||
TTS_PORT="${TTS_PORT:-18082}"
|
||||
STT_PORT="${STT_PORT:-18083}"
|
||||
BASE="http://127.0.0.1:$RT_PORT"
|
||||
FAKE_DIR="$PWD/dev/fake-profile-dir"
|
||||
TEST_ROUTER_KEY="test-router-key-0123456789-abcdefghijklmnopqrstuvwxyz"
|
||||
PASS=0
|
||||
FAIL=0
|
||||
|
||||
# Produktive Authentifizierung für alle Integrationstests. `command curl`
|
||||
# umgeht diese Funktion bei den gezielten anonymen Negativtests.
|
||||
curl() { command curl -H "Authorization: Bearer $TEST_ROUTER_KEY" "$@"; }
|
||||
|
||||
cleanup() {
|
||||
kill "${MOCK_PID:-}" "${ROUTER_PID:-}" "${TTS_PID:-}" "${STT_PID:-}" 2>/dev/null || true
|
||||
rm -f /tmp/mock_pid2 /tmp/mock_upstream_pid
|
||||
CURRENT_MOCK_PID="$(cat /tmp/mock_upstream_pid 2>/dev/null || true)"
|
||||
kill "${MOCK_PID:-}" "${CURRENT_MOCK_PID:-}" "${ROUTER_PID:-}" \
|
||||
"${TTS_PID:-}" "${STT_PID:-}" 2>/dev/null || true
|
||||
rm -f /tmp/mock_pid2 /tmp/mock_upstream_pid /tmp/test-router-state.json \
|
||||
/tmp/fake-profile-fail
|
||||
cp "$FAKE_DIR/profile-fast.conf.disabled" "$FAKE_DIR/override.conf"
|
||||
wait 2>/dev/null || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
@@ -23,8 +32,20 @@ trap cleanup EXIT
|
||||
ok() { echo " PASS: $1"; PASS=$((PASS+1)); }
|
||||
bad() { echo " FAIL: $1"; FAIL=$((FAIL+1)); }
|
||||
|
||||
wait_http() {
|
||||
local url="$1" name="$2"
|
||||
for _ in $(seq 1 50); do
|
||||
curl -sf "$url" >/dev/null 2>&1 && return 0
|
||||
sleep 0.1
|
||||
done
|
||||
echo "FEHLER: $name wurde nicht bereit: $url" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
# --- Mock-llama.cpp starten (über Fake-systemctl) ------------------------------
|
||||
echo "== Starte Mock-llama.cpp (Port $UP_PORT)"
|
||||
cp "$FAKE_DIR/profile-fast.conf.disabled" "$FAKE_DIR/override.conf"
|
||||
rm -f /tmp/test-router-state.json /tmp/mock_upstream_pid
|
||||
FAKE_SYSTEMD_PIDFILE=/tmp/mock_upstream_pid \
|
||||
FAKE_SYSTEMD_PORT="$UP_PORT" \
|
||||
FAKE_SYSTEMD_PROFILE_DIR="$FAKE_DIR" \
|
||||
@@ -33,11 +54,15 @@ FAKE_SYSTEMD_LOG=/tmp/mock_upstream.log \
|
||||
bash dev/fake-systemctl.sh start
|
||||
sleep 0.5
|
||||
MOCK_PID=$(cat /tmp/mock_upstream_pid 2>/dev/null || echo "")
|
||||
wait_http "http://127.0.0.1:$UP_PORT/health" "Mock-llama.cpp" || exit 1
|
||||
|
||||
# --- Router starten -----------------------------------------------------------
|
||||
echo "== Starte Router (Port $RT_PORT)"
|
||||
rm -rf /tmp/test-images
|
||||
ROUTER_HOST=127.0.0.1 ROUTER_PORT="$RT_PORT" \
|
||||
ROUTER_AUTH_MODE=required ROUTER_API_KEY="$TEST_ROUTER_KEY" \
|
||||
ROUTER_PROFILES_FILE= \
|
||||
ROUTER_STATE_FILE=/tmp/test-router-state.json \
|
||||
UPSTREAM_URL="http://127.0.0.1:$UP_PORT" \
|
||||
PROFILE_SCRIPT="$PWD/dev/fake-llama-profile.sh" \
|
||||
PROFILE_DIR="$FAKE_DIR" \
|
||||
@@ -58,16 +83,27 @@ TTS_WORKER_URL="http://127.0.0.1:$TTS_PORT" \
|
||||
STT_WORKER_URL="http://127.0.0.1:$STT_PORT" \
|
||||
python3 router/ai_profile_router.py >/tmp/router_test.log 2>&1 &
|
||||
ROUTER_PID=$!
|
||||
sleep 0.5
|
||||
wait_http "$BASE/health" "Router" || {
|
||||
cat /tmp/router_test.log >&2
|
||||
exit 1
|
||||
}
|
||||
rm -f /tmp/test_worker_requests.jsonl
|
||||
|
||||
echo "== Test 0: Authentifizierung + Health/Readiness"
|
||||
CODE=$(command curl -s -o /tmp/err0.json -w "%{http_code}" "$BASE/status")
|
||||
[ "$CODE" = "401" ] && ok "Status ohne Key → 401" || bad "Status ohne Key: HTTP $CODE"
|
||||
CODE=$(command curl -s -o /dev/null -w "%{http_code}" "$BASE/health")
|
||||
[ "$CODE" = "200" ] && ok "öffentliche Liveness → 200" || bad "Liveness: HTTP $CODE"
|
||||
CODE=$(command curl -s -o /dev/null -w "%{http_code}" "$BASE/ready")
|
||||
[ "$CODE" = "200" ] && ok "öffentliche Readiness → 200" || bad "Readiness: HTTP $CODE"
|
||||
|
||||
# --- Mock-TTS-Worker starten ----------------------------------------------------
|
||||
echo "== Starte Mock-TTS-Worker (Port $TTS_PORT)"
|
||||
MOCK_TTS_PORT="$TTS_PORT" MOCK_TTS_DELAY=0.1 \
|
||||
MOCK_TTS_LOG=/tmp/test_tts_requests.jsonl \
|
||||
python3 dev/mock_tts_worker.py >/tmp/mock_tts.log 2>&1 &
|
||||
TTS_PID=$!
|
||||
sleep 0.5
|
||||
wait_http "http://127.0.0.1:$TTS_PORT/status" "Mock-TTS" || exit 1
|
||||
rm -f /tmp/test_tts_requests.jsonl
|
||||
|
||||
# --- Mock-STT-Worker starten ----------------------------------------------------
|
||||
@@ -76,7 +112,7 @@ MOCK_STT_PORT="$STT_PORT" MOCK_STT_DELAY=0.1 \
|
||||
MOCK_STT_LOG=/tmp/test_stt_requests.jsonl \
|
||||
python3 dev/mock_stt_worker.py >/tmp/mock_stt.log 2>&1 &
|
||||
STT_PID=$!
|
||||
sleep 0.5
|
||||
wait_http "http://127.0.0.1:$STT_PORT/status" "Mock-STT" || exit 1
|
||||
rm -f /tmp/test_stt_requests.jsonl
|
||||
|
||||
# --- 1. /v1/models -------------------------------------------------------------
|
||||
@@ -115,6 +151,7 @@ import json,sys
|
||||
d=json.load(sys.stdin)
|
||||
assert d["model"].startswith("mock-model-"), d
|
||||
assert "Mock-Antwort" in d["choices"][0]["message"]["content"], d
|
||||
assert d.get("mock_authorization") is None, d
|
||||
' && ok "Request wurde weitergeleitet, Modell ersetzt" || bad "Forwarding"
|
||||
|
||||
# --- 4. Streaming ----------------------------------------------------------------
|
||||
@@ -176,17 +213,34 @@ d=json.load(sys.stdin)
|
||||
assert d["model"]=="mock-model-131072", d
|
||||
' && ok "qwen-long hat Profil long aktiviert und weitergeleitet" || bad "virtuelles Modell"
|
||||
|
||||
# --- 9. Ungültiges Profil ------------------------------------------------------------------
|
||||
echo "== Test 9: Ungültiges Profil"
|
||||
CODE=$(curl -s -o /tmp/err9.json -w "%{http_code}" -X POST "$BASE/huge")
|
||||
# --- 9. Methoden und ungültiges virtuelles Modell -----------------------------------------
|
||||
echo "== Test 9: sichere Profilmethoden + ungültiges virtuelles Modell"
|
||||
CODE=$(curl -s -o /tmp/err9.json -w "%{http_code}" "$BASE/long")
|
||||
cat /tmp/err9.json; echo
|
||||
[ "$CODE" = "400" ] && ok "400 bei unbekanntem Profil (POST /huge)" || bad "erwartet 400, bekam $CODE"
|
||||
[ "$CODE" = "405" ] && ok "GET /long verändert kein Profil" || bad "erwartet 405, bekam $CODE"
|
||||
|
||||
CODE=$(curl -s -o /tmp/err9b.json -w "%{http_code}" -X POST "$BASE/v1/chat/completions" \
|
||||
-H "Content-Type: application/json" -d '{"model":"qwen-huge","messages":[]}')
|
||||
cat /tmp/err9b.json; echo
|
||||
[ "$CODE" = "400" ] && ok "400 bei unbekanntem virtuellen Modell (qwen-huge)" || bad "erwartet 400, bekam $CODE"
|
||||
|
||||
# Unbekannte einteilige POST-Pfade gehören dem Upstream, nicht dem Profilrouter.
|
||||
CODE=$(curl -s -o /tmp/err9c.json -w "%{http_code}" -X POST "$BASE/tokenize" \
|
||||
-H "Content-Type: application/json" -d '{"content":"Hallo"}')
|
||||
[ "$CODE" = "404" ] && ok "POST /tokenize wurde transparent weitergeleitet" \
|
||||
|| bad "erwartet Upstream-404, bekam $CODE"
|
||||
|
||||
echo medium >/tmp/fake-profile-fail
|
||||
CODE=$(curl -s -o /tmp/err9d.json -w "%{http_code}" -X POST "$BASE/medium")
|
||||
[ "$CODE" = "503" ] && ok "fehlgeschlagenes Profilskript → 503" \
|
||||
|| bad "Profilskript-Fehler: HTTP $CODE"
|
||||
rm -f /tmp/fake-profile-fail
|
||||
CODE=$(curl -s -o /tmp/chat9e.json -w "%{http_code}" "$BASE/v1/chat/completions" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"model":"qwen-fast","messages":[{"role":"user","content":"noch da?"}]}')
|
||||
[ "$CODE" = "200" ] && ok "vorheriges Profil bleibt nach Skriptfehler verfügbar" \
|
||||
|| bad "Qwen nach Profilskript-Fehler: HTTP $CODE"
|
||||
|
||||
# --- 10. llama.cpp down -> 502, danach Recovery ---------------------------------------------------
|
||||
echo "== Test 10: Upstream down -> 502, danach Recovery"
|
||||
# Profil auf fast setzen (aus Test 8 ist long aktiv)
|
||||
@@ -195,6 +249,10 @@ curl -sf -X POST "$BASE/fast" >/dev/null
|
||||
FAKE_SYSTEMD_PIDFILE=/tmp/mock_upstream_pid FAKE_SYSTEMD_PORT="$UP_PORT" \
|
||||
bash dev/fake-systemctl.sh stop
|
||||
sleep 0.5
|
||||
CODE=$(command curl -s -o /dev/null -w "%{http_code}" "$BASE/health")
|
||||
[ "$CODE" = "200" ] && ok "Liveness bleibt bei downem Modell 200" || bad "Liveness down: HTTP $CODE"
|
||||
CODE=$(command curl -s -o /dev/null -w "%{http_code}" "$BASE/ready")
|
||||
[ "$CODE" = "503" ] && ok "Readiness zeigt downes Modell mit 503" || bad "Readiness down: HTTP $CODE"
|
||||
CODE=$(curl -s -o /tmp/err10.json -w "%{http_code}" -X POST "$BASE/v1/chat/completions" \
|
||||
-H "Content-Type: application/json" -d '{"model":"qwen-fast","messages":[]}')
|
||||
cat /tmp/err10.json; echo
|
||||
@@ -641,6 +699,26 @@ wait $STT_PID43
|
||||
[ "$CODE" = "200" ] && [ -s /tmp/tts43.mp3 ] \
|
||||
&& ok "STT + TTS parallel (beide 200)" || bad "STT + TTS parallel (TTS Code $CODE)"
|
||||
|
||||
# --- 44. Zwei konkurrierende Profilanfragen ------------------------------------------------
|
||||
echo "== Test 44: Profil-Lease verhindert Wechsel während eines Chats"
|
||||
curl -sf "$BASE/v1/chat/completions" -H "Content-Type: application/json" \
|
||||
-d '{"model":"qwen-long","mock_delay":1.0,"messages":[{"role":"user","content":"Lang"}]}' \
|
||||
>/tmp/chat44-long.json &
|
||||
CHAT44_PID=$!
|
||||
sleep 0.2
|
||||
curl -sf "$BASE/v1/chat/completions" -H "Content-Type: application/json" \
|
||||
-d '{"model":"qwen-medium","messages":[{"role":"user","content":"Mittel"}]}' \
|
||||
>/tmp/chat44-medium.json
|
||||
wait "$CHAT44_PID"
|
||||
python3 -c '
|
||||
import json
|
||||
long=json.load(open("/tmp/chat44-long.json"))
|
||||
medium=json.load(open("/tmp/chat44-medium.json"))
|
||||
assert long["mock_ctx"] == 131072, long
|
||||
assert medium["mock_ctx"] == 94208, medium
|
||||
' && ok "konkurrierende Chats behielten jeweils ihr Profil" \
|
||||
|| bad "Profil-Lease bei konkurrierenden Chats"
|
||||
|
||||
# --- Ergebnis --------------------------------------------------------------------------------------------
|
||||
echo
|
||||
echo "== Ergebnis: $PASS bestanden, $FAIL fehlgeschlagen =="
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Unit tests for security, runtime persistence and artifact retention."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "router"))
|
||||
os.environ.setdefault("ROUTER_PROFILES_FILE", "")
|
||||
|
||||
from router_support import ( # noqa: E402
|
||||
AuthPolicy,
|
||||
ConfigurationError,
|
||||
RuntimeStore,
|
||||
enforce_artifact_retention,
|
||||
load_profile_registry,
|
||||
)
|
||||
from ai_profile_router import _normalize_vision_image # noqa: E402
|
||||
|
||||
|
||||
class AuthPolicyTests(unittest.TestCase):
|
||||
def test_required_mode_fails_closed_without_key(self) -> None:
|
||||
with patch.dict(os.environ, {
|
||||
"ROUTER_AUTH_MODE": "required",
|
||||
"ROUTER_API_KEY": "",
|
||||
"ROUTER_API_KEY_FILE": "/definitely/missing"}, clear=False):
|
||||
with self.assertRaises(ConfigurationError):
|
||||
AuthPolicy.from_environment()
|
||||
|
||||
def test_bearer_and_x_api_key(self) -> None:
|
||||
key = "k" * 48
|
||||
policy = AuthPolicy("required", key)
|
||||
self.assertTrue(policy.accepts(f"Bearer {key}", None))
|
||||
self.assertTrue(policy.accepts(None, key))
|
||||
self.assertFalse(policy.accepts("Bearer wrong", None))
|
||||
|
||||
|
||||
class RuntimeStoreTests(unittest.TestCase):
|
||||
def test_atomic_roundtrip_and_delete(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
store = RuntimeStore(str(Path(temp) / "state.json"))
|
||||
store.save(worker="vision", worker_pid=123, last_profile="fast")
|
||||
self.assertEqual(store.load()["worker_pid"], 123)
|
||||
store.clear_worker("vision")
|
||||
state = store.load()
|
||||
self.assertNotIn("worker", state)
|
||||
self.assertEqual(state["last_profile"], "fast")
|
||||
|
||||
def test_concurrent_updates_are_not_lost(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
store = RuntimeStore(str(Path(temp) / "state.json"))
|
||||
threads = [threading.Thread(target=store.save,
|
||||
kwargs={f"key_{index}": index})
|
||||
for index in range(20)]
|
||||
for thread in threads:
|
||||
thread.start()
|
||||
for thread in threads:
|
||||
thread.join()
|
||||
state = store.load()
|
||||
for index in range(20):
|
||||
self.assertEqual(state[f"key_{index}"], index)
|
||||
|
||||
|
||||
class ProfileRegistryTests(unittest.TestCase):
|
||||
def test_explicit_missing_registry_fails_closed(self) -> None:
|
||||
with self.assertRaises(ConfigurationError):
|
||||
load_profile_registry("/definitely/missing/profiles.json")
|
||||
|
||||
|
||||
class VisionInputTests(unittest.TestCase):
|
||||
def test_small_png_data_url_is_accepted(self) -> None:
|
||||
value = "data:image/png;base64,iVBORw0KGgo="
|
||||
self.assertEqual(_normalize_vision_image(value), value)
|
||||
|
||||
def test_remote_url_is_denied_by_default(self) -> None:
|
||||
with self.assertRaisesRegex(ValueError, "deaktiviert"):
|
||||
_normalize_vision_image("https://example.com/private.png")
|
||||
|
||||
def test_invalid_base64_is_rejected(self) -> None:
|
||||
with self.assertRaisesRegex(ValueError, "Base64"):
|
||||
_normalize_vision_image("data:image/png;base64,not!base64")
|
||||
|
||||
|
||||
class RetentionTests(unittest.TestCase):
|
||||
def test_oldest_pairs_are_removed(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
root = Path(temp)
|
||||
for index in range(3):
|
||||
png = root / f"image-{index}.png"
|
||||
png.write_bytes(b"x" * 10)
|
||||
png.with_suffix(".json").write_text("{}", encoding="utf-8")
|
||||
stamp = time.time() - (30 - index)
|
||||
os.utime(png, (stamp, stamp))
|
||||
removed = enforce_artifact_retention(
|
||||
temp, max_files=2, max_bytes=0, max_age_days=0)
|
||||
self.assertEqual(removed, ["image-0.png"])
|
||||
self.assertFalse((root / "image-0.json").exists())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user