Files
m3ta-chironandClaude Fable 5.1 85fa64cfe6 refactor: switch the whole project to English
talk is going to be published. Code comments, CLI output, error messages,
daemon log lines, MCP descriptions and results, test names and assertions,
README, spec, skill, build scripts and flake are now English. Only the
spoken announcement texts stay bilingual: the wrap result and the test
sentence are German or English depending on the detected language.

Language fallback is now configurable: `lang = "en" | "de"` (env
TALK_LANG, default en) is used when detection cannot tell (numbers, single
words, ties) and for talk's own announcements. langdetect.Detect takes the
fallback as an argument instead of hard-coding German. `talk setup` asks for
it; `talk doctor` and `talk status` show it.

Vocabulary pinned across packages so the process-boundary tests observe
stable strings: chain log lines ("chain: tier X skipped (not available)",
"falling back to the next tier", "spoken via X"), queue lines ("discarded:
quiet hours ... active", "replaced by toast", "max_age"), CLI notes
("speaking directly", "daemon started at", "no daemon", "nothing to cancel"),
status block with aligned English labels, MCP messages, origin guard 403.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-24 11:15:35 +02:00

263 lines
9.9 KiB
Go

package test
// Process-boundary tests for the CORS door (Task 06): OPTIONS preflight and
// real POST to /speak with headers from the config list
// TALK_ALLOWED_ORIGINS — observed as a black box via raw HTTP requests.
// Binding: exact origins; "*" only explicitly; empty list = no CORS headers
// at all; CORS only on /speak. The door is closed SERVER-SIDE: every request
// with a foreign Origin header outside the permitted path (OPTIONS /speak,
// POST /speak from a listed origin) is rejected with 403 before it has any
// effect — CORS alone only prevents reading the response.
import (
"net/http"
"testing"
"time"
)
const azOrigin = "https://chat.az-gruppe.com"
// TestCORSPreflightListed (acceptance criterion): preflight from a listed
// origin → 204 with allow headers; unlisted origin → 204 WITHOUT
// Access-Control-Allow-Origin.
func TestCORSPreflightListed(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 correctness)", vary)
}
// Unlisted origin: a clean 204, but deliberately without CORS headers —
// the browser drops the planned POST, the daemon stays reachable.
raw = preflight(t, d, "https://evil.example")
if raw.status != http.StatusNoContent {
t.Fatalf("preflight (unlisted) status = %d, want 204", raw.status)
}
if got := raw.header.Get("Access-Control-Allow-Origin"); got != "" {
t.Errorf("ACAO for unlisted origin = %q, want empty", got)
}
}
// TestCORSPostListed: the real (non-preflight) POST also carries ACAO when
// the request origin is listed — otherwise not.
func TestCORSPostListed(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 says hello"}`)
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 on POST = %q, want %q", got, azOrigin)
}
fake.waitCompleted(t, 1)
// Unlisted origin: 403 WITHOUT effect — the fake provider must not see
// a second hit. (Formerly 202 without CORS headers: the browser could
// not read the response, but it was spoken anyway.)
raw = doRequest(t, http.MethodPost, d.baseURL+"/speak", map[string]string{
"Origin": "https://evil.example",
"Content-Type": "application/json",
}, `{"text":"evil browser"}`)
if raw.status != http.StatusForbidden {
t.Fatalf("POST /speak (unlisted) status = %d, want 403", raw.status)
}
if got := raw.header.Get("Access-Control-Allow-Origin"); got != "" {
t.Errorf("ACAO for unlisted origin = %q, want empty", got)
}
fake.assertNoFurtherArrivals(t, 300*time.Millisecond)
}
// TestCORSEmptyList: without allowed_origins (default) the daemon sends NO
// CORS headers at all — door closed, the browser blocks.
func TestCORSEmptyList(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 0)
d := startDaemon(t, root, fake.srv.URL, nil) // no origins configured
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 with an empty list, want not set", h, got)
}
}
raw = doRequest(t, http.MethodPost, d.baseURL+"/speak", map[string]string{
"Origin": azOrigin,
"Content-Type": "application/json",
}, `{"text":"without cors"}`)
if raw.status != http.StatusForbidden {
t.Fatalf("POST /speak with Origin and an empty list: status = %d, want 403", raw.status)
}
fake.assertNoHits(t, 300*time.Millisecond)
// Without an Origin header (CLI, curl) the same POST remains allowed.
raw = doRequest(t, http.MethodPost, d.baseURL+"/speak", map[string]string{
"Content-Type": "application/json",
}, `{"text":"without origin"}`)
if raw.status != http.StatusAccepted {
t.Fatalf("POST /speak without Origin: status = %d, want 202", raw.status)
}
fake.waitCompleted(t, 1)
}
// TestCORSWildcard: the entry "*" explicitly allows every origin — ACAO is
// then the literal "*".
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://somewhere.example", "http://localhost:5173"} {
raw := preflight(t, d, origin)
if raw.status != http.StatusNoContent {
t.Fatalf("preflight from %s: status = %d, want 204", origin, raw.status)
}
if got := raw.header.Get("Access-Control-Allow-Origin"); got != "*" {
t.Errorf("ACAO from %s = %q, want *", origin, got)
}
}
}
// TestCORSOnlyOnSpeak: no other endpoint is reachable from the browser — not
// even from a listed origin: OPTIONS and GET on /status with an Origin
// header are 403 without CORS headers.
func TestCORSOnlyOnSpeak(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.StatusForbidden {
t.Fatalf("OPTIONS /status status = %d, want 403 (no browser access)", raw.status)
}
if got := raw.header.Get("Access-Control-Allow-Origin"); got != "" {
t.Errorf("OPTIONS /status ACAO = %q, want empty", got)
}
raw = doRequest(t, http.MethodGet, d.baseURL+"/status", map[string]string{
"Origin": azOrigin,
}, "")
if raw.status != http.StatusForbidden {
t.Fatalf("GET /status with Origin: status = %d, want 403", raw.status)
}
if got := raw.header.Get("Access-Control-Allow-Origin"); got != "" {
t.Errorf("GET /status ACAO = %q, want empty (CORS only on /speak)", got)
}
}
// TestBrowserCannotControl: the actual attack case. Any web page can send
// "simple requests" without preflight (POST with a text/plain body, form
// POST without body) — the browser only blocks reading the response, not
// sending. talkd must neither speak nor mute nor cancel nor run the MCP tool
// on these: all 403, state unchanged.
func TestBrowserCannotControl(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 0)
d := startDaemon(t, root, fake.srv.URL, map[string]string{
"TALK_ALLOWED_ORIGINS": azOrigin, // door open for ONE origin — others still closed
})
evil := "https://evil.example"
// Simple request: POST with a text/plain body, no preflight.
raw := doRequest(t, http.MethodPost, d.baseURL+"/speak", map[string]string{
"Origin": evil,
"Content-Type": "text/plain",
}, `{"text":"hijacked","now":true}`)
if raw.status != http.StatusForbidden {
t.Fatalf("simple POST /speak from %s: status = %d, want 403", evil, raw.status)
}
// Control endpoints need no body — a form POST is enough.
for _, path := range []string{"/mute", "/unmute", "/cancel"} {
raw = doRequest(t, http.MethodPost, d.baseURL+path, map[string]string{"Origin": evil}, "")
if raw.status != http.StatusForbidden {
t.Errorf("POST %s from %s: status = %d, want 403", path, evil, raw.status)
}
}
// Even the listed origin must NOT control — only speak.
raw = doRequest(t, http.MethodPost, d.baseURL+"/mute", map[string]string{"Origin": azOrigin}, "")
if raw.status != http.StatusForbidden {
t.Errorf("POST /mute from listed origin: status = %d, want 403 (CORS only on /speak)", raw.status)
}
// MCP from the browser: closed as well.
raw = doRequest(t, http.MethodPost, d.baseURL+"/mcp", map[string]string{
"Origin": evil,
"Content-Type": "text/plain",
}, `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"speak","arguments":{"text":"hijacked"}}}`)
if raw.status != http.StatusForbidden {
t.Errorf("POST /mcp from %s: status = %d, want 403", evil, raw.status)
}
// None of it had any effect: no hit at the provider, not muted.
fake.assertNoHits(t, 300*time.Millisecond)
if st := getStatus(t, d); st.Muted {
t.Errorf("daemon is muted — browser POST /mute took effect")
}
}
// TestStatusCorsField: GET /status carries the CORS origins as the cors
// field — listed as [] when the door is closed (compat requirement Task 06).
func TestStatusCorsField(t *testing.T) {
t.Run("listed", 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 both listed origins", st.Cors)
}
})
t.Run("empty = [] (not 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 [] with the door closed", st.Cors)
}
})
}