Files
talk/test/cors_test.go
T
m3ta-chiron 150c374bdc test: Prozessgrenzen-Tests für CORS-Tür und MCP-Handshake
CORS: Preflight 204+Header von gelisteter Origin, kein ACAO für ungeliste/
leere Liste, Wildcard, nur-/speak, cors-Feld in /status. MCP: kompletter
Handshake als literal JSON-RPC über HTTP, tools/call enqueue sichtbar am
Fake-Provider, muted/rate_limit/quiet_hours als isError:false-Ergebnisse,
Fehlerpfade (-32700/-32600/-32601/-32602), GET /mcp 405.
2026-10-18 20:06:11 +02:00

196 lines
6.9 KiB
Go

package test
// Prozessgrenzen-Tests für die CORS-Tür (Task 06): OPTIONS-Preflight und
// echter POST auf /speak mit Headern aus der Config-Liste
// TALK_ALLOWED_ORIGINS — beobachtet als Black Box über rohe HTTP-Requests.
// Bindend: exakte Origins; "*" nur explizit; leere Liste = gar keine
// CORS-Header (der Browser blockt); CORS nur auf /speak.
import (
"net/http"
"testing"
)
const azOrigin = "https://chat.az-gruppe.com"
// TestCORSPraeflightGelisted (Abnahmekriterium): Preflight von gelisteter
// Origin → 204 mit Freigabe-Headern; ungeliste Origin → 204 OHNE
// Access-Control-Allow-Origin.
func TestCORSPraeflightGelisted(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 0)
d := startDaemon(t, root, fake.srv.URL, map[string]string{
"TALK_ALLOWED_ORIGINS": azOrigin,
})
raw := preflight(t, d, azOrigin)
if raw.status != http.StatusNoContent {
t.Fatalf("Preflight-Status = %d, want 204", raw.status)
}
if got := raw.header.Get("Access-Control-Allow-Origin"); got != azOrigin {
t.Errorf("ACAO = %q, want %q", got, azOrigin)
}
if methods := raw.header.Get("Access-Control-Allow-Methods"); methods != "POST, OPTIONS" {
t.Errorf("Access-Control-Allow-Methods = %q, want %q", methods, "POST, OPTIONS")
}
if headers := raw.header.Get("Access-Control-Allow-Headers"); headers != "Content-Type" {
t.Errorf("Access-Control-Allow-Headers = %q, want %q", headers, "Content-Type")
}
if vary := raw.header.Get("Vary"); vary != "Origin" {
t.Errorf("Vary = %q, want Origin (Cache-Korrektheit)", vary)
}
// Ungelistete Origin: sauberer 204, aber bewusst ohne CORS-Header —
// der Browser verwirft den geplanten POST, der Daemon bleibt erreichbar.
raw = preflight(t, d, "https://evil.example")
if raw.status != http.StatusNoContent {
t.Fatalf("Preflight (ungelisted) Status = %d, want 204", raw.status)
}
if got := raw.header.Get("Access-Control-Allow-Origin"); got != "" {
t.Errorf("ACAO für ungeliste Origin = %q, want leer", got)
}
}
// TestCORSPostGelisted: Auch der echte (non-preflight) POST trägt ACAO,
// wenn die Anfrage-Origin gelistet ist — sonst nicht.
func TestCORSPostGelisted(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 0)
d := startDaemon(t, root, fake.srv.URL, map[string]string{
"TALK_ALLOWED_ORIGINS": azOrigin,
})
raw := doRequest(t, http.MethodPost, d.baseURL+"/speak", map[string]string{
"Origin": azOrigin,
"Content-Type": "application/json",
}, `{"text":"browser sagt hallo"}`)
if raw.status != http.StatusAccepted {
t.Fatalf("POST /speak Status = %d, want 202", raw.status)
}
if got := raw.header.Get("Access-Control-Allow-Origin"); got != azOrigin {
t.Errorf("ACAO auf POST = %q, want %q", got, azOrigin)
}
fake.waitCompleted(t, 1)
raw = doRequest(t, http.MethodPost, d.baseURL+"/speak", map[string]string{
"Origin": "https://evil.example",
"Content-Type": "application/json",
}, `{"text":"böser browser"}`)
if raw.status != http.StatusAccepted {
t.Fatalf("POST /speak (ungelisted) Status = %d, want 202 (HTTP bleibt sauber)", raw.status)
}
if got := raw.header.Get("Access-Control-Allow-Origin"); got != "" {
t.Errorf("ACAO für ungeliste Origin = %q, want leer", got)
}
}
// TestCORSLeereListe: Ohne allowed_origins (Default) sendet der Daemon
// GAR KEINE CORS-Header — Tür zu, der Browser blockt.
func TestCORSLeereListe(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 0)
d := startDaemon(t, root, fake.srv.URL, nil) // keine Origins konfiguriert
raw := preflight(t, d, azOrigin)
if raw.status != http.StatusNoContent {
t.Fatalf("Preflight-Status = %d, want 204", raw.status)
}
for _, h := range []string{
"Access-Control-Allow-Origin",
"Access-Control-Allow-Methods",
"Access-Control-Allow-Headers",
} {
if got := raw.header.Get(h); got != "" {
t.Errorf("%s = %q bei leerer Liste, want nicht gesetzt", h, got)
}
}
raw = doRequest(t, http.MethodPost, d.baseURL+"/speak", map[string]string{
"Origin": azOrigin,
"Content-Type": "application/json",
}, `{"text":"ohne cors"}`)
if raw.status != http.StatusAccepted {
t.Fatalf("POST /speak Status = %d, want 202", raw.status)
}
if got := raw.header.Get("Access-Control-Allow-Origin"); got != "" {
t.Errorf("ACAO auf POST bei leerer Liste = %q, want leer", got)
}
}
// TestCORSWildcard: Der Eintrag "*" erlaubt jede Origin ausdrücklich —
// ACAO ist dann das literale "*".
func TestCORSWildcard(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 0)
d := startDaemon(t, root, fake.srv.URL, map[string]string{
"TALK_ALLOWED_ORIGINS": "*",
})
for _, origin := range []string{"https://irgendwo.example", "http://localhost:5173"} {
raw := preflight(t, d, origin)
if raw.status != http.StatusNoContent {
t.Fatalf("Preflight von %s: Status = %d, want 204", origin, raw.status)
}
if got := raw.header.Get("Access-Control-Allow-Origin"); got != "*" {
t.Errorf("ACAO von %s = %q, want *", origin, got)
}
}
}
// TestCORSNurAufSpeak: Kein anderer Endpunkt trägt CORS-Header — OPTIONS
// auf /status ist 405 (kein Preflight-Support), GET /status ohne ACAO.
func TestCORSNurAufSpeak(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 0)
d := startDaemon(t, root, fake.srv.URL, map[string]string{
"TALK_ALLOWED_ORIGINS": azOrigin,
})
raw := doRequest(t, http.MethodOptions, d.baseURL+"/status", map[string]string{
"Origin": azOrigin,
"Access-Control-Request-Method": http.MethodGet,
}, "")
if raw.status != http.StatusMethodNotAllowed {
t.Fatalf("OPTIONS /status Status = %d, want 405 (kein CORS-Support)", raw.status)
}
if got := raw.header.Get("Access-Control-Allow-Origin"); got != "" {
t.Errorf("OPTIONS /status ACAO = %q, want leer", got)
}
raw = doRequest(t, http.MethodGet, d.baseURL+"/status", map[string]string{
"Origin": azOrigin,
}, "")
if raw.status != http.StatusOK {
t.Fatalf("GET /status Status = %d, want 200", raw.status)
}
if got := raw.header.Get("Access-Control-Allow-Origin"); got != "" {
t.Errorf("GET /status ACAO = %q, want leer (CORS nur auf /speak)", got)
}
}
// TestStatusCorsFeld: GET /status trägt die CORS-Origins als cors-Feld —
// gelistet als [], wenn die Tür zu ist (Compat-Anforderung Task 06).
func TestStatusCorsFeld(t *testing.T) {
t.Run("gelistet", func(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 0)
d := startDaemon(t, root, fake.srv.URL, map[string]string{
"TALK_ALLOWED_ORIGINS": azOrigin + ", https://grid.ag",
})
st := getStatus(t, d)
if len(st.Cors) != 2 || st.Cors[0] != azOrigin || st.Cors[1] != "https://grid.ag" {
t.Errorf("cors = %v, want beide gelisteten Origins", st.Cors)
}
})
t.Run("leer = [] (nicht null)", func(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 0)
d := startDaemon(t, root, fake.srv.URL, nil)
st := getStatus(t, d)
if st.Cors == nil || len(st.Cors) != 0 {
t.Errorf("cors = %v, want [] bei geschlossener Tür", st.Cors)
}
})
}