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.
This commit is contained in:
m3ta-chiron committed 2026-10-18 20:06:11 +02:00
1 parent a577597714
commit 150c374bdc
3 files changed
+563

No files matched your search

+195
View File
@@ -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)
}
})
}
+68
View File
@@ -13,6 +13,7 @@ import (
"bytes"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/http/httptest"
@@ -403,6 +404,8 @@ type statusBody struct {
} `json:"current"`
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
}
+300
View File
@@ -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: <id>" 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: <id>\"", 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)")
}
}