diff --git a/test/cors_test.go b/test/cors_test.go new file mode 100644 index 0000000..eb756bd --- /dev/null +++ b/test/cors_test.go @@ -0,0 +1,195 @@ +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) + } + }) +} diff --git a/test/harness_test.go b/test/harness_test.go index a0c03cc..53f7945 100644 --- a/test/harness_test.go +++ b/test/harness_test.go @@ -13,6 +13,7 @@ import ( "bytes" "encoding/json" "fmt" + "io" "net" "net/http" "net/http/httptest" @@ -401,8 +402,10 @@ type statusBody struct { Text string `json:"text"` Priority string `json:"priority"` } `json:"current"` - Muted bool `json:"muted"` - Provider string `json:"provider"` + Muted bool `json:"muted"` + Provider string `json:"provider"` + MCP bool `json:"mcp"` + Cors []string `json:"cors"` } // getStatus ruft GET /status auf (Deadline: eine Sekunde — der Daemon ist @@ -471,3 +474,68 @@ func postPath(t *testing.T, d *daemonProc, path string) (int, map[string]any) { } return resp.StatusCode, m } + +// rawResponse ist eine vollständig gelesene HTTP-Antwort (Status, Header, +// Body als String) — Beobachtungsform der rohen CORS-/MCP-Tests. +type rawResponse struct { + status int + header http.Header + body string +} + +// doRequest macht einen rohen HTTP-Request mit beliebigen Headern und +// optionalem Body und liest die Antwort komplett (CORS-Preflights und +// MCP-JSON-RPC-Payloads sind literal, kein Client-SDK dazwischen). +func doRequest(t *testing.T, method, url string, headers map[string]string, body string) rawResponse { + t.Helper() + var rd io.Reader + if body != "" { + rd = strings.NewReader(body) + } + req, err := http.NewRequest(method, url, rd) + if err != nil { + t.Fatalf("%s %s bauen: %v", method, url, err) + } + for k, v := range headers { + req.Header.Set(k, v) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("%s %s: %v", method, url, err) + } + defer resp.Body.Close() + raw, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("%s %s Antwort lesen: %v", method, url, err) + } + return rawResponse{status: resp.StatusCode, header: resp.Header, body: string(raw)} +} + +// preflight macht einen OPTIONS-Preflight gegen /speak (so wie ein Browser +// es vor einem echten POST tut). +func preflight(t *testing.T, d *daemonProc, origin string) rawResponse { + t.Helper() + return doRequest(t, http.MethodOptions, d.baseURL+"/speak", map[string]string{ + "Origin": origin, + "Access-Control-Request-Method": http.MethodPost, + }, "") +} + +// mcpPost POSTet einen literalen JSON-RPC-Payload an /mcp. +func mcpPost(t *testing.T, d *daemonProc, payload string) rawResponse { + t.Helper() + return doRequest(t, http.MethodPost, d.baseURL+"/mcp", map[string]string{ + "Content-Type": "application/json", + }, payload) +} + +// mcpDecode dekodiert eine JSON-RPC-Antwort in eine generische Map (für +// Feld-Zugriffe der literalen Payload-Tests). +func mcpDecode(t *testing.T, raw rawResponse) map[string]any { + t.Helper() + var m map[string]any + if err := json.Unmarshal([]byte(raw.body), &m); err != nil { + t.Fatalf("JSON-RPC-Antwort dekodieren: %v — Body: %s", err, raw.body) + } + return m +} diff --git a/test/mcp_test.go b/test/mcp_test.go new file mode 100644 index 0000000..a471a1d --- /dev/null +++ b/test/mcp_test.go @@ -0,0 +1,300 @@ +package test + +// Prozessgrenzen-Tests für den MCP-Endpoint (Task 06): der komplette +// Handshake (initialize → notifications/initialized → tools/list → +// tools/call) als rohe JSON-RPC-HTTP-Payloads gegen einen echten talkd — +// literal, ohne Client-SDK. tools/call muss sich exakt wie POST /speak +// verhalten: dieselbe Queue-Aufnahme inklusive Mute und Gesetz. + +import ( + "fmt" + "net/http" + "strings" + "testing" + "time" +) + +// mcpToolText liest content[0].text aus einem tools/call-Ergebnis. +func mcpToolText(t *testing.T, m map[string]any) string { + t.Helper() + result, ok := m["result"].(map[string]any) + if !ok { + t.Fatalf("tools/call ohne result: %v", m) + } + content, ok := result["content"].([]any) + if !ok || len(content) == 0 { + t.Fatalf("tools/call ohne content: %v", result) + } + first, ok := content[0].(map[string]any) + if !ok { + t.Fatalf("content[0] ist kein Objekt: %v", content[0]) + } + text, _ := first["text"].(string) + return text +} + +// mcpIsError liest isError (fehlt = false — bewusste Semantik). +func mcpIsError(m map[string]any) bool { + if result, ok := m["result"].(map[string]any); ok { + if e, ok := result["isError"].(bool); ok { + return e + } + } + return false +} + +// TestMCPHandshake (Abnahmekriterium): die curl-Sequenz initialize → +// notifications/initialized → tools/list → tools/call sieht das speak-Tool +// und enqueued erfolgreich — die Ansage läuft durch Queue und Fake-Provider. +func TestMCPHandshake(t *testing.T) { + root := t.TempDir() + fake := newFakeProvider(t, 0) + d := startDaemon(t, root, fake.srv.URL, nil) + + // 1. initialize: angefragte Version wird geechot; capabilities nennen + // tools; serverInfo ist talkd. + raw := mcpPost(t, d, `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl-test","version":"0"}}}`) + if raw.status != http.StatusOK { + t.Fatalf("initialize: Status %d — Body: %s", raw.status, raw.body) + } + if ct := raw.header.Get("Content-Type"); !strings.HasPrefix(ct, "application/json") { + t.Errorf("initialize Content-Type = %q, want application/json", ct) + } + m := mcpDecode(t, raw) + if m["jsonrpc"] != "2.0" || fmt.Sprint(m["id"]) != "1" { + t.Fatalf("initialize envelope = %v (jsonrpc/id prüfen)", m) + } + result, ok := m["result"].(map[string]any) + if !ok { + t.Fatalf("initialize ohne result: %v", m) + } + if result["protocolVersion"] != "2025-06-18" { + t.Errorf("protocolVersion = %v, want Echo der angefragten 2025-06-18", result["protocolVersion"]) + } + caps, ok := result["capabilities"].(map[string]any) + if !ok { + t.Fatalf("capabilities = %v, want Objekt", result["capabilities"]) + } + if _, ok := caps["tools"]; !ok { + t.Errorf("capabilities ohne tools: %v", caps) + } + serverInfo, _ := result["serverInfo"].(map[string]any) + if serverInfo["name"] != "talkd" { + t.Errorf("serverInfo.name = %v, want talkd", serverInfo["name"]) + } + if v, _ := serverInfo["version"].(string); v == "" { + t.Error("serverInfo.version ist leer") + } + + // Andere angefragte Version wird ebenso geechot (akzeptiert). + raw = mcpPost(t, d, `{"jsonrpc":"2.0","id":2,"method":"initialize","params":{"protocolVersion":"2025-03-26"}}`) + m = mcpDecode(t, raw) + if m["result"].(map[string]any)["protocolVersion"] != "2025-03-26" { + t.Errorf("protocolVersion = %v, want Echo 2025-03-26", m["result"]) + } + + // 2. notifications/initialized: Notification → 202 ohne Body. + raw = mcpPost(t, d, `{"jsonrpc":"2.0","method":"notifications/initialized"}`) + if raw.status != http.StatusAccepted { + t.Fatalf("notifications/initialized: Status %d, want 202", raw.status) + } + if raw.body != "" { + t.Errorf("notifications/initialized Body = %q, want leer", raw.body) + } + + // 3. tools/list: genau das speak-Tool mit dem Brief-Schema. + raw = mcpPost(t, d, `{"jsonrpc":"2.0","id":3,"method":"tools/list"}`) + m = mcpDecode(t, raw) + result = m["result"].(map[string]any) + tools, ok := result["tools"].([]any) + if !ok || len(tools) != 1 { + t.Fatalf("tools = %v, want genau ein Tool", result["tools"]) + } + tool := tools[0].(map[string]any) + if tool["name"] != "speak" { + t.Errorf("tool.name = %v, want speak", tool["name"]) + } + schema, ok := tool["inputSchema"].(map[string]any) + if !ok { + t.Fatalf("inputSchema = %v, want Objekt", tool["inputSchema"]) + } + if schema["type"] != "object" { + t.Errorf("inputSchema.type = %v, want object", schema["type"]) + } + props := schema["properties"].(map[string]any) + for _, name := range []string{"text", "priority", "provider", "lang", "now"} { + if _, ok := props[name]; !ok { + t.Errorf("inputSchema ohne property %q: %v", name, props) + } + } + if len(props) != 5 { + t.Errorf("inputSchema hat %d properties (%v), want genau die 5 aus dem Brief", len(props), props) + } + if req, ok := schema["required"].([]any); !ok || len(req) != 1 || req[0] != "text" { + t.Errorf("required = %v, want [text]", schema["required"]) + } + + // 4. tools/call: Aufnahme wie POST /speak — "queued: " und die + // Ansage taucht am Fake-Provider auf (gleiche Queue, kein Parallelweg). + raw = mcpPost(t, d, `{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"speak","arguments":{"text":"hallo über mcp"}}}`) + if raw.status != http.StatusOK { + t.Fatalf("tools/call: Status %d — Body: %s", raw.status, raw.body) + } + m = mcpDecode(t, raw) + if fmt.Sprint(m["id"]) != "4" { + t.Errorf("tools/call id = %v, want Echo 4", m["id"]) + } + text := mcpToolText(t, m) + if !strings.HasPrefix(text, "queued: ") || len(text) <= len("queued: ") { + t.Fatalf("tools/call text = %q, want \"queued: \"", text) + } + if mcpIsError(m) { + t.Errorf("tools/call isError = true bei erfolgreicher Aufnahme: %v", m) + } + hits := fake.waitCompleted(t, 1) + if hits[0].Text != "hallo über mcp" { + t.Errorf("Fake-Treffer = %q, will die MCP-Ansage", hits[0].Text) + } +} + +// TestMCPToolsCallGesetze: muted und Gesetz-Verwürfe sind Tool-ERGEBNISSE +// (isError:false mit erklärendem Text), keine Protokollfehler — und die +// Ansage bleibt stumm. +func TestMCPToolsCallGesetze(t *testing.T) { + t.Run("muted", func(t *testing.T) { + root := t.TempDir() + fake := newFakeProvider(t, 0) + d := startDaemon(t, root, fake.srv.URL, nil) + postPath(t, d, "/mute") + + m := mcpDecode(t, mcpPost(t, d, `{"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"speak","arguments":{"text":"stille bitte"}}}`)) + if mcpIsError(m) { + t.Fatalf("muted ist isError:true — soll Ergebnis sein: %v", m) + } + if text := mcpToolText(t, m); !strings.HasPrefix(text, "muted") { + t.Errorf("text = %q, will muted-Erklärung", text) + } + fake.assertNoHits(t, 300*time.Millisecond) + }) + + t.Run("rate_limit verwirft low", func(t *testing.T) { + root := t.TempDir() + fake := newFakeProvider(t, 0) + d := startDaemon(t, root, fake.srv.URL, map[string]string{"TALK_RATE_LIMIT": "30s"}) + + // Erste low-Ansage spielt (Fenster frei) und bucht es. + m := mcpDecode(t, mcpPost(t, d, `{"jsonrpc":"2.0","id":8,"method":"tools/call","params":{"name":"speak","arguments":{"text":"erste","priority":"low"}}}`)) + if text := mcpToolText(t, m); !strings.HasPrefix(text, "queued: ") { + t.Fatalf("erste low: text = %q, want queued", text) + } + fake.waitCompleted(t, 1) + + // Zweite low innerhalb des Fensters: Verwurf als Ergebnis. + m = mcpDecode(t, mcpPost(t, d, `{"jsonrpc":"2.0","id":9,"method":"tools/call","params":{"name":"speak","arguments":{"text":"zweite","priority":"low"}}}`)) + if mcpIsError(m) { + t.Fatalf("Verwurf ist isError:true — soll Ergebnis sein: %v", m) + } + if text := mcpToolText(t, m); !strings.HasPrefix(text, "discarded: rate_limit") { + t.Errorf("text = %q, want discarded: rate_limit …", text) + } + fake.assertNoFurtherArrivals(t, 300*time.Millisecond) + }) + + t.Run("quiet_hours verwirft", func(t *testing.T) { + root := t.TempDir() + fake := newFakeProvider(t, 0) + d := startDaemon(t, root, fake.srv.URL, map[string]string{ + "TALK_QUIET_HOURS": fensterUm(), + }) + + m := mcpDecode(t, mcpPost(t, d, `{"jsonrpc":"2.0","id":10,"method":"tools/call","params":{"name":"speak","arguments":{"text":"nachts"}}}`)) + if mcpIsError(m) { + t.Fatalf("Ruhezeit-Verwurf ist isError:true — soll Ergebnis sein: %v", m) + } + if text := mcpToolText(t, m); !strings.HasPrefix(text, "discarded: quiet_hours") { + t.Errorf("text = %q, want discarded: quiet_hours …", text) + } + fake.assertNoHits(t, 300*time.Millisecond) + }) +} + +// TestMCPFehlerPfade: Validierungsfehler als JSON-RPC-Fehler (-32602), +// unbekannte Methode -32601, kaputtes JSON -32700, falsche jsonrpc-Version +// und Batches -32600, GET /mcp 405 (keine server-initiierten Streams). +func TestMCPFehlerPfade(t *testing.T) { + root := t.TempDir() + fake := newFakeProvider(t, 0) + d := startDaemon(t, root, fake.srv.URL, nil) + + cases := []struct { + name string + payload string + code float64 + msgPart string + }{ + {"text fehlt", `{"jsonrpc":"2.0","id":21,"method":"tools/call","params":{"name":"speak","arguments":{}}}`, -32602, "text"}, + {"text leer", `{"jsonrpc":"2.0","id":22,"method":"tools/call","params":{"name":"speak","arguments":{"text":" "}}}`, -32602, "text"}, + {"ungültige priority", `{"jsonrpc":"2.0","id":23,"method":"tools/call","params":{"name":"speak","arguments":{"text":"x","priority":"mega"}}}`, -32602, "priority"}, + {"unbekanntes Tool", `{"jsonrpc":"2.0","id":24,"method":"tools/call","params":{"name":"sing","arguments":{"text":"x"}}}`, -32602, "sing"}, + {"unbekannte Methode", `{"jsonrpc":"2.0","id":25,"method":"resources/list"}`, -32601, "resources/list"}, + {"kaputtes JSON", `{"jsonrpc":"2.0","id":`, -32700, "JSON"}, + {"falsche jsonrpc-Version", `{"jsonrpc":"1.0","id":26,"method":"ping"}`, -32600, "2.0"}, + {"Batch (seit 2025-06-18 entfernt)", `[{"jsonrpc":"2.0","id":27,"method":"ping"}]`, -32600, "Batch"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + raw := mcpPost(t, d, tc.payload) + if raw.status != http.StatusOK { + t.Fatalf("Status = %d, want 200 (JSON-RPC-Fehler im Body)", raw.status) + } + m := mcpDecode(t, raw) + rpcErr, ok := m["error"].(map[string]any) + if !ok { + t.Fatalf("kein error-Objekt: %v", m) + } + if rpcErr["code"] != tc.code { + t.Errorf("code = %v, want %v (Body: %s)", rpcErr["code"], tc.code, raw.body) + } + if msg, _ := rpcErr["message"].(string); !strings.Contains(msg, tc.msgPart) { + t.Errorf("message = %q, will Teil %q", msg, tc.msgPart) + } + if _, hasResult := m["result"]; hasResult { + t.Errorf("error-Antwort mit result: %v", m) + } + }) + } + + t.Run("ping", func(t *testing.T) { + m := mcpDecode(t, mcpPost(t, d, `{"jsonrpc":"2.0","id":28,"method":"ping"}`)) + if _, ok := m["result"]; !ok { + t.Fatalf("ping ohne result: %v", m) + } + }) + + t.Run("GET /mcp → 405 (keine server-initiierten Streams)", func(t *testing.T) { + raw := doRequest(t, http.MethodGet, d.baseURL+"/mcp", nil, "") + if raw.status != http.StatusMethodNotAllowed { + t.Fatalf("GET /mcp Status = %d, want 405", raw.status) + } + if allow := raw.header.Get("Allow"); !strings.Contains(allow, "POST") { + t.Errorf("Allow = %q, will POST nennen", allow) + } + }) + + t.Run("Notification mit unbekannter Methode → still 202", func(t *testing.T) { + raw := mcpPost(t, d, `{"jsonrpc":"2.0","method":"notifications/roots/list_changed"}`) + if raw.status != http.StatusAccepted || raw.body != "" { + t.Fatalf("Status %d Body %q, want 202 ohne Body", raw.status, raw.body) + } + }) +} + +// TestStatusMCPFeld: /status meldet mcp:true (Compat-Feld Task 06). +func TestStatusMCPFeld(t *testing.T) { + root := t.TempDir() + fake := newFakeProvider(t, 0) + d := startDaemon(t, root, fake.srv.URL, nil) + if st := getStatus(t, d); !st.MCP { + t.Error("mcp = false, want true (Endpoint ist vorhanden)") + } +}