package provider import ( "context" "encoding/hex" "net/http" "net/http/httptest" "os" "strings" "sync" "testing" "time" "github.com/gorilla/websocket" ) // TestEdgeAvailable: Edge braucht nur einen Endpunkt, keinen Key — der // Konstruktor bäkt den Default-Endpunkt ein, daher ist ein über NewEdge // gebauter Edge immer verfügbar; nur der Nullwert (direkt gebaut, ohne // Endpunkt) ist es nicht. func TestEdgeAvailable(t *testing.T) { if (&Edge{}).Available(context.Background()) { t.Error("Available ohne Endpunkt (Nullwert) = true, want false") } p := NewEdge(ChainConfig{Getenv: envMap(map[string]string{ EnvEdgeEndpoint: "wss://edge.example.com", })}) if !p.Available(context.Background()) { t.Error("Available mit Endpunkt = false, want true") } if !NewEdge(ChainConfig{Getenv: envMap(nil)}).Available(context.Background()) { t.Error("Available mit Default-Endpunkt = false, want true (edge ist der Key-freie Fallback)") } } // TestEdgeVerbindungFehlschlaegt (Fehlerpfad-Mock): ein garantiert toter // Loopback-Port liefert einen klaren Fehler — sofort, ohne Netz. func TestEdgeVerbindungFehlschlaegt(t *testing.T) { p := NewEdge(ChainConfig{Getenv: envMap(map[string]string{ EnvEdgeEndpoint: "ws://127.0.0.1:1/", })}) _, err := p.Synthesize(context.Background(), "Hallo", "de") if err == nil { t.Fatal("Synthesize gegen toten Port: kein Fehler") } if !strings.Contains(err.Error(), "edge") || !strings.Contains(err.Error(), "WebSocket") { t.Errorf("Fehler = %v, will klaren edge/WebSocket-Fehler", err) } } // TestEdgeUngueltigesSchema: ein Nicht-w(s)-Endpunkt ist ein klarer Fehler. func TestEdgeUngueltigesSchema(t *testing.T) { p := NewEdge(ChainConfig{Getenv: envMap(map[string]string{ EnvEdgeEndpoint: "http://127.0.0.1:1234/", })}) _, err := p.Synthesize(context.Background(), "Hallo", "de") if err == nil || !strings.Contains(err.Error(), "ws://") { t.Errorf("Fehler = %v, will Schema-Hinweis ws://", err) } } // TestEdgeSSML: die SSML-Anfrage enthält Stimme und den von Steuerzeichen // befreiten, XML-escapten Text; xml:lang ist wie in der Referenz fest // „en-US". func TestEdgeSSML(t *testing.T) { got := edgeSSML("de-DE-KatjaNeural", "AT&T „fertig“") for _, want := range []string{ "voice name='de-DE-KatjaNeural'", "xml:lang='en-US'", "AT&T <Kunde>", } { if !strings.Contains(got, want) { t.Errorf("SSML = %q, will %q", got, want) } } if strings.Contains(got, "AT&T") { t.Errorf("SSML enthält unescaptes &: %q", got) } } // TestEdgeEscape: Steuerzeichen (u. a. Vertical Tab aus OCR-Texten) werden // durch Leerzeichen ersetzt, & < > escapt. func TestEdgeEscape(t *testing.T) { in := "a\x0bb&c\x08e" if want := "a b&c<d> e"; edgeEscape(in) != want { t.Errorf("edgeEscape(%q) = %q, want %q", in, edgeEscape(in), want) } } // TestEdgeSpeechConfigMsg: Output-Format (mono-mp3) und Pfad stimmen. func TestEdgeSpeechConfigMsg(t *testing.T) { msg := edgeSpeechConfigMsg(time.Unix(0, 0).UTC()) if !strings.Contains(msg, "Path:speech.config") { t.Errorf("speech.config-Nachricht ohne Path: %q", msg) } if !strings.Contains(msg, edgeOutputFormat) { t.Errorf("speech.config-Nachricht ohne Ausgabeformat %q: %q", edgeOutputFormat, msg) } } // TestEdgeRequestURL: Query-Parameter (Token, Sec-MS-GEC, Version) werden // ergänzt; der Token landet nicht in der URL-Debug-Ausgabe. func TestEdgeRequestURL(t *testing.T) { p := &Edge{Endpoint: "wss://edge.example.com/path"} raw, err := p.requestURL(time.Unix(1700000000, 0).UTC()) if err != nil { t.Fatalf("requestURL: %v", err) } for _, want := range []string{ "TrustedClientToken=" + edgeTrustedClientToken, "Sec-MS-GEC=", "Sec-MS-GEC-Version=" + edgeSecMSGECVersion, } { if !strings.Contains(raw, want) { t.Errorf("requestURL = %q, will %q", raw, want) } } if got := redactURL(raw); strings.Contains(got, edgeTrustedClientToken) { t.Errorf("redactURL leakt den Token: %q", got) } } // TestEdgeSecMSGEC: Format „<16 Hex>-<64 Hex>" (groß), 5-Minuten-Raster // (innerhalb des Fensters stabil, über die Grenze anders) und exakte // Referenzvektoren der edge-tts-DRM-Implementierung (v2). func TestEdgeSecMSGEC(t *testing.T) { // Referenzvektoren: (Unix-Sekunden, erwarteter Token) — erzeugt mit der // Python-Referenz (edge-tts src/edge_tts/drm.py, Stand 2026-10). vectors := []struct { ts int64 want string }{ {1700000400, "73C7C85FE76CF347-452672184D494A9F80E179C2FBFEE2E98480C4D4AAFC1F4A577E43B023BEEEC9"}, {1700000599, "73C7C85FE76CF347-452672184D494A9F80E179C2FBFEE2E98480C4D4AAFC1F4A577E43B023BEEEC9"}, {1765800000, "B9B5447115B4FD1A-6BB36279F5B3D1B53F84F786AF05C81A86909ABE30E695496BBE88FFAE2E3E83"}, {0, "52BC7A17B50DFEC9-28C4394775C8751CD2F6859C030C06DB1489ACAB3B3A7651831FC26895177ED6"}, } for _, v := range vectors { if got := edgeSecMSGEC(time.Unix(v.ts, 0).UTC()); got != v.want { t.Errorf("edgeSecMSGEC(%d) =\n %s\nwant\n %s", v.ts, got, v.want) } } // Fenster-Stabilität (Basis am Fensterbeginn, mod 300 == 0). base := time.Unix(1700000400, 0).UTC() token := edgeSecMSGEC(base) if edgeSecMSGEC(base.Add(4*time.Minute)) != token { t.Error("Sec-MS-GEC weicht innerhalb des 5-Minuten-Fensters ab") } if edgeSecMSGEC(base.Add(6*time.Minute)) == token { t.Error("Sec-MS-GEC ändert sich über die Fenster-Grenze nicht") } if len(token) != 16+1+64 || !strings.Contains(token, "-") { t.Errorf("Token-Format = %q, want <16 Hex>-<64 Hex>", token) } } // TestEdgeRequestID: 32 Hex-Zeichen, eindeutig. func TestEdgeRequestID(t *testing.T) { a, b := edgeRequestID(), edgeRequestID() if len(a) != 32 { t.Errorf("RequestID-Länge = %d, want 32", len(a)) } if _, err := hex.DecodeString(a); err != nil { t.Errorf("RequestID ist kein Hex: %v", err) } if a == b { t.Error("RequestID nicht eindeutig") } } // TestEdgeSplitBinary: Binär-Rahmen: 2 Bytes big-endian Header-Länge, dann // Header (Path:audio), dann Payload. func TestEdgeSplitBinary(t *testing.T) { msg := []byte{0, 12, 'P', 'a', 't', 'h', ':', 'a', 'u', 'd', 'i', 'o', '\r', '\n', 1, 2, 3} header, payload, ok := splitEdgeBinary(msg) if !ok || header != "Path:audio\r\n" || string(payload) != "\x01\x02\x03" { t.Errorf("splitEdgeBinary = %q, %x, %v", header, payload, ok) } if _, _, ok := splitEdgeBinary([]byte{0, 99, 'x'}); ok { t.Error("übergroßer Header-Längenangabe soll abgelehnt werden") } } // fakeEdgeServer ist ein Loopback-Fake des Edge-WSS-Protokolls (kein // echter Netz-Call — alles bleibt auf 127.0.0.1). Er prüft die beiden // Client-Nachrichten und antwortet nach Protokoll: Audio-Binär-Rahmen mit // Path:audio, danach turn.end. audio ist die zu liefernde Payload; // closing schließt die Verbindung vor turn.end (Fehlerpfad). func fakeEdgeServer(t *testing.T, audio []byte, closing bool) (*httptest.Server, *fakeEdgeSeen) { t.Helper() seen := &fakeEdgeSeen{} upgrader := websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }} ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if got := r.URL.Query().Get("TrustedClientToken"); got != edgeTrustedClientToken { t.Errorf("TrustedClientToken = %q", got) } if r.Header.Get("Origin") != edgeOrigin { t.Errorf("Origin = %q", r.Header.Get("Origin")) } conn, err := upgrader.Upgrade(w, r, nil) if err != nil { t.Errorf("Upgrade: %v", err) return } defer conn.Close() if err := conn.SetReadDeadline(time.Now().Add(5 * time.Second)); err != nil { return } if _, cfg, err := conn.ReadMessage(); err != nil { t.Errorf("speech.config lesen: %v", err) } else { seen.mu.Lock() seen.config = string(cfg) seen.mu.Unlock() } if _, req, err := conn.ReadMessage(); err != nil { t.Errorf("ssml lesen: %v", err) } else { seen.mu.Lock() seen.ssml = string(req) seen.mu.Unlock() } if closing { return // Verbindung dichtmachen, ohne zu antworten } header := "X-RequestId:dummy\r\nPath:audio\r\n" frame := make([]byte, 0, 2+len(header)+len(audio)) frame = append(frame, byte(len(header)>>8), byte(len(header))) frame = append(frame, header...) frame = append(frame, audio...) if err := conn.WriteMessage(websocket.BinaryMessage, frame); err != nil { return } if err := conn.WriteMessage(websocket.TextMessage, []byte("X-RequestId:dummy\r\nPath:turn.end\r\n\r\n")); err != nil { return } })) t.Cleanup(ts.Close) return ts, seen } type fakeEdgeSeen struct { mu sync.Mutex config string ssml string } func (s *fakeEdgeSeen) snapshot() (configMsg, ssml string) { s.mu.Lock() defer s.mu.Unlock() return s.config, s.ssml } // TestEdgeLoopbackFakeServer: kompletter Protokoll-Durchlauf gegen den // Loopback-Fake (fake-bar getestet: Handshake-Parameter, beide // Client-Nachrichten, Binär-Rahmen-Parsing, turn.end-Ende) — siehe // Task-03-Report für die Abgrenzung zum echten Endpoint. func TestEdgeLoopbackFakeServer(t *testing.T) { wantAudio := []byte("\xff\xfb\x90\x00" + "edge-audio") ts, seen := fakeEdgeServer(t, wantAudio, false) p := NewEdge(ChainConfig{Getenv: envMap(map[string]string{ EnvEdgeEndpoint: "ws://" + ts.Listener.Addr().String(), })}) data, err := p.Synthesize(context.Background(), "Hallo & fertig", "de") if err != nil { t.Fatalf("Synthesize gegen Loopback-Fake: %v", err) } if string(data) != string(wantAudio) { t.Errorf("Synthesize = %x, want %x", data, wantAudio) } configMsg, ssml := seen.snapshot() if !strings.Contains(configMsg, "Path:speech.config") { t.Errorf("speech.config = %q", configMsg) } if !strings.Contains(ssml, "Path:ssml") { t.Errorf("ssml-Anfrage ohne Path:ssml: %q", ssml) } if !strings.Contains(ssml, "voice name='de-DE-KatjaNeural'") { t.Errorf("ssml ohne Default-Stimme de: %q", ssml) } if !strings.Contains(ssml, "Hallo & fertig") { t.Errorf("ssml ohne escapten Text: %q", ssml) } } // TestEdgeLoopbackVerbindungsAbbruch: Server schließt nach den Anfragen // ohne Audio → klarer „Protokoll geändert?"-Fehler. func TestEdgeLoopbackVerbindungsAbbruch(t *testing.T) { ts, _ := fakeEdgeServer(t, nil, true) p := NewEdge(ChainConfig{Getenv: envMap(map[string]string{ EnvEdgeEndpoint: "ws://" + ts.Listener.Addr().String(), })}) _, err := p.Synthesize(context.Background(), "Hallo", "de") if err == nil || !strings.Contains(err.Error(), "Protokoll") { t.Errorf("Fehler = %v, will Protokoll-Änderungs-Hinweis", err) } } // TestEdgeIntegrationEcht ist die als testing.Short überspringbare // Integrations-Markierung gegen den ECHTEN Microsoft-Endpunkt (kostenlos, // aber echtes Netz — Standard-Testläufe und -short überspringen; einmalige // manuelle Abnahme mit TALK_TEST_REAL_EDGE=1). func TestEdgeIntegrationEcht(t *testing.T) { if testing.Short() { t.Skip("Übersprungen (-short)") } if os.Getenv("TALK_TEST_REAL_EDGE") != "1" { t.Skip("Übersprungen (echtes Netz) — mit TALK_TEST_REAL_EDGE=1 ausführen") } p := NewEdge(ChainConfig{}) data, err := p.Synthesize(context.Background(), "Kurzer Sprachtest über Edge.", "de") if err != nil { t.Fatalf("Synthesize gegen echten Endpunkt: %v", err) } if len(data) == 0 { t.Fatal("Synthesize gegen echten Endpunkt: 0 Bytes") } t.Logf("Edge-Integration: %d Bytes Audio", len(data)) }