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>
This commit is contained in:
m3ta-chironandClaude Fable 5.1 committed 2026-10-24 11:15:35 +02:00
1 parent 4d30bd95ad
commit 85fa64cfe6
95 files changed
+4707 -4854

No files matched your search

+79 -80
View File
@@ -1,13 +1,13 @@
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; CORS nur auf /speak. Die Tür ist SERVERSEITIG zu: Jede
// Anfrage mit fremdem Origin-Header außerhalb des erlaubten Wegs (OPTIONS
// /speak, POST /speak von gelisteter Origin) wird mit 403 abgelehnt, bevor
// sie Wirkung hat — CORS allein verhindert nur das Lesen der Antwort.
// 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"
@@ -17,10 +17,10 @@ import (
const azOrigin = "https://chat.az-gruppe.com"
// TestCORSPraeflightGelisted (Abnahmekriterium): Preflight von gelisteter
// Origin → 204 mit Freigabe-Headern; ungeliste Origin → 204 OHNE
// TestCORSPreflightListed (acceptance criterion): preflight from a listed
// origin → 204 with allow headers; unlisted origin → 204 WITHOUT
// Access-Control-Allow-Origin.
func TestCORSPraeflightGelisted(t *testing.T) {
func TestCORSPreflightListed(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 0)
d := startDaemon(t, root, fake.srv.URL, map[string]string{
@@ -29,7 +29,7 @@ func TestCORSPraeflightGelisted(t *testing.T) {
raw := preflight(t, d, azOrigin)
if raw.status != http.StatusNoContent {
t.Fatalf("Preflight-Status = %d, want 204", raw.status)
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)
@@ -41,23 +41,23 @@ func TestCORSPraeflightGelisted(t *testing.T) {
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)
t.Errorf("Vary = %q, want Origin (cache correctness)", vary)
}
// Ungelistete Origin: sauberer 204, aber bewusst ohne CORS-Header —
// der Browser verwirft den geplanten POST, der Daemon bleibt erreichbar.
// 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 (ungelisted) Status = %d, want 204", raw.status)
t.Fatalf("preflight (unlisted) 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)
t.Errorf("ACAO for unlisted origin = %q, want empty", got)
}
}
// TestCORSPostGelisted: Auch der echte (non-preflight) POST trägt ACAO,
// wenn die Anfrage-Origin gelistet ist — sonst nicht.
func TestCORSPostGelisted(t *testing.T) {
// 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{
@@ -67,41 +67,41 @@ func TestCORSPostGelisted(t *testing.T) {
raw := doRequest(t, http.MethodPost, d.baseURL+"/speak", map[string]string{
"Origin": azOrigin,
"Content-Type": "application/json",
}, `{"text":"browser sagt hallo"}`)
}, `{"text":"browser says hello"}`)
if raw.status != http.StatusAccepted {
t.Fatalf("POST /speak Status = %d, want 202", raw.status)
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)
t.Errorf("ACAO on POST = %q, want %q", got, azOrigin)
}
fake.waitCompleted(t, 1)
// Ungelistete Origin: 403 OHNE Wirkung — der Fake-Provider darf keinen
// zweiten Treffer sehen. (Früher 202 ohne CORS-Header: der Browser
// konnte die Antwort nicht lesen, gesprochen wurde trotzdem.)
// 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":"böser browser"}`)
}, `{"text":"evil browser"}`)
if raw.status != http.StatusForbidden {
t.Fatalf("POST /speak (ungelisted) Status = %d, want 403", raw.status)
t.Fatalf("POST /speak (unlisted) status = %d, want 403", raw.status)
}
if got := raw.header.Get("Access-Control-Allow-Origin"); got != "" {
t.Errorf("ACAO für ungeliste Origin = %q, want leer", got)
t.Errorf("ACAO for unlisted origin = %q, want empty", got)
}
fake.assertNoFurtherArrivals(t, 300*time.Millisecond)
}
// TestCORSLeereListe: Ohne allowed_origins (Default) sendet der Daemon
// GAR KEINE CORS-Header — Tür zu, der Browser blockt.
func TestCORSLeereListe(t *testing.T) {
// 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) // keine Origins konfiguriert
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)
t.Fatalf("preflight status = %d, want 204", raw.status)
}
for _, h := range []string{
"Access-Control-Allow-Origin",
@@ -109,31 +109,31 @@ func TestCORSLeereListe(t *testing.T) {
"Access-Control-Allow-Headers",
} {
if got := raw.header.Get(h); got != "" {
t.Errorf("%s = %q bei leerer Liste, want nicht gesetzt", 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":"ohne cors"}`)
}, `{"text":"without cors"}`)
if raw.status != http.StatusForbidden {
t.Fatalf("POST /speak mit Origin bei leerer Liste: Status = %d, want 403", raw.status)
t.Fatalf("POST /speak with Origin and an empty list: status = %d, want 403", raw.status)
}
fake.assertNoHits(t, 300*time.Millisecond)
// Ohne Origin-Header (CLI, curl) bleibt derselbe POST erlaubt.
// 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":"ohne origin"}`)
}, `{"text":"without origin"}`)
if raw.status != http.StatusAccepted {
t.Fatalf("POST /speak ohne Origin: Status = %d, want 202", raw.status)
t.Fatalf("POST /speak without Origin: status = %d, want 202", raw.status)
}
fake.waitCompleted(t, 1)
}
// TestCORSWildcard: Der Eintrag "*" erlaubt jede Origin ausdrücklich —
// ACAO ist dann das literale "*".
// TestCORSWildcard: the entry "*" explicitly allows every origin — ACAO is
// then the literal "*".
func TestCORSWildcard(t *testing.T) {
root := t.TempDir()
fake := newFakeProvider(t, 0)
@@ -141,21 +141,21 @@ func TestCORSWildcard(t *testing.T) {
"TALK_ALLOWED_ORIGINS": "*",
})
for _, origin := range []string{"https://irgendwo.example", "http://localhost:5173"} {
for _, origin := range []string{"https://somewhere.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)
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 von %s = %q, want *", origin, got)
t.Errorf("ACAO from %s = %q, want *", origin, got)
}
}
}
// TestCORSNurAufSpeak: Kein anderer Endpunkt ist aus dem Browser erreichbar
// — auch nicht von einer gelisteten Origin: OPTIONS und GET auf /status mit
// Origin-Header sind 403 ohne CORS-Header.
func TestCORSNurAufSpeak(t *testing.T) {
// 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{
@@ -167,79 +167,78 @@ func TestCORSNurAufSpeak(t *testing.T) {
"Access-Control-Request-Method": http.MethodGet,
}, "")
if raw.status != http.StatusForbidden {
t.Fatalf("OPTIONS /status Status = %d, want 403 (kein Browser-Zugriff)", raw.status)
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 leer", 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 mit Origin: Status = %d, want 403", raw.status)
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 leer (CORS nur auf /speak)", got)
t.Errorf("GET /status ACAO = %q, want empty (CORS only on /speak)", got)
}
}
// TestBrowserKannNichtSteuern: Der eigentliche Angriffsfall. Eine beliebige
// Webseite kann ohne Preflight „simple requests" absetzen (POST mit
// text/plain-Body, Formular-POST ohne Body) — der Browser blockt nur das
// Lesen der Antwort, nicht das Senden. talkd darf darauf weder sprechen noch
// muten noch abbrechen noch das MCP-Tool ausführen: alles 403, Zustand
// unverändert.
func TestBrowserKannNichtSteuern(t *testing.T) {
// 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, // Tür für EINE Origin offen — andere trotzdem zu
"TALK_ALLOWED_ORIGINS": azOrigin, // door open for ONE origin — others still closed
})
evil := "https://evil.example"
// Simple request: POST mit text/plain-Body, kein Preflight.
// 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":"gekapert","now":true}`)
}, `{"text":"hijacked","now":true}`)
if raw.status != http.StatusForbidden {
t.Fatalf("simple POST /speak von %s: Status = %d, want 403", evil, raw.status)
t.Fatalf("simple POST /speak from %s: status = %d, want 403", evil, raw.status)
}
// Steuer-Endpunkte brauchen keinen Body — ein Formular-POST reicht.
// 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 von %s: Status = %d, want 403", path, evil, raw.status)
t.Errorf("POST %s from %s: status = %d, want 403", path, evil, raw.status)
}
}
// Auch die gelistete Origin darf NICHT steuern — nur sprechen.
// 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 von gelisteter Origin: Status = %d, want 403 (CORS nur auf /speak)", raw.status)
t.Errorf("POST /mute from listed origin: status = %d, want 403 (CORS only on /speak)", raw.status)
}
// MCP aus dem Browser: ebenfalls zu.
// 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":"gekapert"}}}`)
}, `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"speak","arguments":{"text":"hijacked"}}}`)
if raw.status != http.StatusForbidden {
t.Errorf("POST /mcp von %s: Status = %d, want 403", evil, raw.status)
t.Errorf("POST /mcp from %s: status = %d, want 403", evil, raw.status)
}
// Nichts davon hatte Wirkung: kein Treffer beim Provider, nicht gemutet.
// 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 ist gemutet — Browser-POST /mute hat gewirkt")
t.Errorf("daemon is muted — browser POST /mute took effect")
}
}
// 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) {
// 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{
@@ -247,17 +246,17 @@ func TestStatusCorsFeld(t *testing.T) {
})
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.Errorf("cors = %v, want both listed origins", st.Cors)
}
})
t.Run("leer = [] (nicht null)", func(t *testing.T) {
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 [] bei geschlossener Tür", st.Cors)
t.Errorf("cors = %v, want [] with the door closed", st.Cors)
}
})
}