Files
Cyrene_v2/internal/runtime/voice/tts/client_test.go
T
AskaEth 8820b0b316 fix(voice): 修 r1 复审四条(正文进日志的隐私泄漏 / format 假开关 / Stats 无消费者 / redactURL 兜底)
上一版把 r1 的三条 finding 静默漏了(我在交付前没读 inbox),本提交逐条补齐:

1. 🔴 隐私:upstreamStatus 不再读也不记响应体
   FastAPI 的 422 会把请求体里的 input 整段回显,原来「错误体前 512 字节进 WARN」
   等于把她的原话写进日志。现在只记 status / content_type / response_bytes
   (字节数优先取 Content-Length,缺失时有界排空取上界,读到即丢)。
   新用例 TestSpeechRejectLogOmitsRequestText 用**回显正文**的假上游抓它;
   变异(把 body 塞回 WARN)实测打红。

2. format 键彻底删除(假开关)
   上游只拿 response_format 改自报 media_type,产物恒 .wav 无转码,我方只按字节
   嗅探裁决 ⇒ wav/mp3/ogg 交付字节与响应头完全相同。删 tts.Config.Format 与
   VoiceTTSTuning.Format,encodeSpeech 硬编码 requestFormat="wav"(不可配置)。
   用例钉住请求体恒为 wav;变异(改成 mp3)打红。旧文件里残留的 format 键被忽略。

3. Stats 接进 degrade 的 WARN(attempts/failures)
   否则统计在生产日志里没有消费者。TestServiceDegradeLogsCounters 钉住;
   变异(去掉两个字段)打红。

4. redactURL 兜底含 "@" 整体打码
   解析失败/无 host 时原来原样返回,user:pass@ 会漏凭据。TestRedactURLCredentials
   覆盖合法 URL 去 userinfo、非法含 @ 打码;变异(退回原样返回)打红。

另:关闭态零行为变化的**当前 tip 重跑**——
- handleSpeech 的 persona 解析改为只在语音服务实现 personaVoice 且 PrimaryActive
  时才查库;未接 emotionTTS(既有链/stub)时零额外 I/O、ctx 不改、音频字节相同。
  用例 TestSpeechEndpointDisabledVoiceDoesNotReadPersona(含对照组证明闸不是恒关);
  变异(去掉闸)打红。
- voice.go 在 dev 基线里本就有 env.PersonaID 解析(无人格解析新增 I/O),本次未改。

Config.Normalized 去掉未使用的 logger 参数。go build/vet/test(含 -race)全绿;
真机 live 复测:cyrene 264104 B、furina 221194 B。
2026-10-26 13:06:39 +08:00

239 lines
10 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package tts
// 本文件锁定 `Client` 的五条对外口径(每条都能被"改坏即打红"):
//
// 1. 🔴 **中文正文必须以 UTF-8 送出**(塔实测:GBK 字节会让服务端回
// `400 {"detail":"There was an error parsing the body"}`)。用例既断言服务端能按
// UTF-8 解出**逐字相等**的正文,也断言请求字节里能直接搜到那段中文 —— 后者是
// 把"改成字符串拼接/转码"这类改动的代价变成红灯的钉子。
// 2. 非 200 ⇒ `ErrUpstream`(且**错误里不带正文**)。
// 3. 超时 ⇒ `ErrUnreachable` 且保留 `context.DeadlineExceeded`(可 errors.Is)。
// 4. 200 但不是音频 ⇒ `ErrNotAudio`(绝不把 JSON/HTML 错误页当音频下发)。
// 5. 未启用 / 空文本 / 超限各自有可分类的哨兵。
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
)
// chineseSpeech 是塔实测那台服务能合成的那句话(保留全角标点,逐字比对)。
const chineseSpeech = "你好,我是苏梦。今天心情特别好,我们一起出去玩吧!"
// wavBytes 造一段**魔数正确**的假 WAV(嗅探只看头,内容是什么不影响判据)。
func wavBytes(payload string) []byte {
b := make([]byte, 0, 44+len(payload))
b = append(b, []byte("RIFF")...)
b = append(b, 0, 0, 0, 0)
b = append(b, []byte("WAVEfmt ")...)
b = append(b, bytes.Repeat([]byte{0}, 16)...)
b = append(b, []byte("data")...)
b = append(b, 0, 0, 0, 0)
return append(b, []byte(payload)...)
}
// newTestClient 指向一个 httptest 服务;可变选项留给各用例。
func newTestClient(t *testing.T, handler http.HandlerFunc, opts ...Option) *Client {
t.Helper()
srv := httptest.NewServer(handler)
t.Cleanup(srv.Close)
base := []Option{WithLogger(discardLogger())}
base = append(base, opts...)
return New(Config{Enabled: true, BaseURL: srv.URL, Timeout: 2 * time.Second}, base...)
}
// TestSpeechSendsUTF8ChineseText 是**本切片最重要的一条**:中文正文必须按 UTF-8
// 字节、以 `application/json` 送出,且字段名/取值与上游 openapi 逐字一致。
func TestSpeechSendsUTF8ChineseText(t *testing.T) {
var gotPayload speechPayload
var gotRaw []byte
var gotCT string
client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
gotCT = r.Header.Get("Content-Type")
gotRaw, _ = io.ReadAll(r.Body)
if err := json.Unmarshal(gotRaw, &gotPayload); err != nil {
t.Errorf("请求体不是合法 JSON(说明有人改成了拼接/转码): %v", err)
}
w.Header().Set("Content-Type", "audio/wav")
_, _ = w.Write(wavBytes("ok"))
})
audio, err := client.Speech(context.Background(), DefaultCharacter, chineseSpeech)
if err != nil {
t.Fatalf("Speech: %v", err)
}
if gotCT != "application/json" {
t.Errorf("Content-Type = %q, want application/json", gotCT)
}
// ① 服务端按 UTF-8 解出来必须**逐字相等**(GBK 字节在这里必然对不上)。
if gotPayload.Input != chineseSpeech {
t.Fatalf("input = %q, want %q", gotPayload.Input, chineseSpeech)
}
// ② 请求字节里必须能直接搜到那段中文的 UTF-8 编码 —— 一旦有人改成 GBK 转码,
// 这里立刻红(UTF-8 的"你好"= E4BD A0 E5A5 BD)。
if !bytes.Contains(gotRaw, []byte(chineseSpeech)) {
t.Fatalf("请求体里找不到 UTF-8 正文(字节被转码了?): %q", string(gotRaw))
}
if !bytes.Contains(gotRaw, []byte("\xe4\xbd\xa0\xe5\xa5\xbd")) {
t.Fatalf("请求体里找不到「你好」的 UTF-8 字节")
}
if gotPayload.Model != modelName {
t.Errorf("model = %q, want %q", gotPayload.Model, modelName)
}
if gotPayload.Voice != DefaultCharacter {
t.Errorf("voice = %q, want %q", gotPayload.Voice, DefaultCharacter)
}
// response_format 恒为 wav 且**不可配置**(上游产物无转码,见 requestFormat)。
if gotPayload.ResponseFormat != "wav" {
t.Errorf("response_format = %q, want 硬编码的 wav", gotPayload.ResponseFormat)
}
// ③ 回发的格式由**字节嗅探**裁决,不是请求里那个字段。
if audio.Format != "wav" || len(audio.Data) == 0 {
t.Fatalf("audio = %+v, want wav with bytes", audio)
}
}
// TestSpeechHTMLEscapingDisabled 钉住"正文逐字不变":`<`/`>`/`&` 不得被改写成 \u003c。
func TestSpeechHTMLEscapingDisabled(t *testing.T) {
var gotRaw []byte
client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
gotRaw, _ = io.ReadAll(r.Body)
_, _ = w.Write(wavBytes("ok"))
})
if _, err := client.Speech(context.Background(), DefaultCharacter, "a<b & c>d"); err != nil {
t.Fatalf("Speech: %v", err)
}
if bytes.Contains(gotRaw, []byte(`\u003c`)) || bytes.Contains(gotRaw, []byte(`\u0026`)) {
t.Fatalf("正文被 HTML 转义了: %q", string(gotRaw))
}
}
// TestSpeechRejectsNon200 钉住上游拒绝:可分类、带状态码、**不带正文**。
func TestSpeechRejectsNon200(t *testing.T) {
client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"detail":"There was an error parsing the body"}`))
})
_, err := client.Speech(context.Background(), DefaultCharacter, chineseSpeech)
if !errors.Is(err, ErrUpstream) {
t.Fatalf("err = %v, want ErrUpstream", err)
}
if !strings.Contains(err.Error(), "400") {
t.Errorf("错误里应带状态码: %v", err)
}
if strings.Contains(err.Error(), "苏梦") {
t.Errorf("错误链里出现了正文(红线:日志/错误不带正文): %v", err)
}
if got := client.Stats(); got.Attempts != 1 || got.Failures != 1 {
t.Errorf("stats = %+v, want 1 attempt / 1 failure", got)
}
}
// TestSpeechTimeout 钉住超时:归 ErrUnreachable,且因果链仍能判出 deadline。
func TestSpeechTimeout(t *testing.T) {
release := make(chan struct{})
t.Cleanup(func() { close(release) })
handler := func(w http.ResponseWriter, r *http.Request) {
select {
case <-release:
case <-time.After(2 * time.Second):
}
_, _ = w.Write(wavBytes("late"))
}
client := newTestClient(t, handler, WithHTTPClient(&http.Client{Timeout: 60 * time.Millisecond}))
start := time.Now()
_, err := client.Speech(context.Background(), DefaultCharacter, chineseSpeech)
if !errors.Is(err, ErrUnreachable) {
t.Fatalf("err = %v, want ErrUnreachable", err)
}
if !errors.Is(err, context.DeadlineExceeded) {
t.Errorf("err = %v, want wraps context.DeadlineExceeded", err)
}
if elapsed := time.Since(start); elapsed > time.Second {
t.Errorf("超时没有生效(用了 %v)", elapsed)
}
}
// TestSpeechRejectsNonAudio 钉住"200 但不是音频":JSON 错误页绝不当音频。
func TestSpeechRejectsNonAudio(t *testing.T) {
client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"status":"success","audio_url":"/outputs/x.wav"}`))
})
_, err := client.Speech(context.Background(), DefaultCharacter, chineseSpeech)
if !errors.Is(err, ErrNotAudio) {
t.Fatalf("err = %v, want ErrNotAudio", err)
}
if got := client.Stats(); got.Failures != 1 {
t.Errorf("stats = %+v, want 1 failure", got)
}
}
// TestSpeechRejectsOversizeResponse 钉住体积上限。
func TestSpeechRejectsOversizeResponse(t *testing.T) {
client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write(wavBytes(strings.Repeat("x", 128)))
}, WithMaxResponseBytes(32))
_, err := client.Speech(context.Background(), DefaultCharacter, chineseSpeech)
if !errors.Is(err, ErrTooLarge) {
t.Fatalf("err = %v, want ErrTooLarge", err)
}
}
// TestSpeechGuards 钉住"不该发网络"的两条前置拒绝,以及"未启用"的可区分性。
func TestSpeechGuards(t *testing.T) {
var hits atomic.Int64
client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
hits.Add(1)
_, _ = w.Write(wavBytes("ok"))
})
if _, err := client.Speech(context.Background(), DefaultCharacter, " "); !errors.Is(err, ErrEmptyText) {
t.Errorf("空文本 err = %v, want ErrEmptyText", err)
}
disabled := New(Config{Enabled: false, BaseURL: client.cfg.BaseURL}, WithLogger(discardLogger()))
if _, err := disabled.Speech(context.Background(), DefaultCharacter, chineseSpeech); !errors.Is(err, ErrDisabled) {
t.Errorf("未启用 err = %v, want ErrDisabled", err)
}
// 被拒的两次都不该打网络;未启用也不该被计成"失败"。
if got := hits.Load(); got != 0 {
t.Errorf("上游被调用 %d 次,want 0", got)
}
if got := disabled.Stats(); got.Attempts != 0 || got.Failures != 0 {
t.Errorf("未启用却计了数: %+v", got)
}
}
// TestConfigNormalized 钉住默认值与非法格式的回落(不让一个写错的格式关掉语音面)。
//
// 🔴 它同时钉住一条**反向**口径:归一化**不得**给任何角色兜底 —— 代码里写死一个
// 默认发音人,正是主人点名的病害(换人格仍用老声音)。
func TestConfigNormalized(t *testing.T) {
got := Config{}.Normalized()
if got.Enabled {
t.Errorf("零值配置不该被悄悄启用")
}
if got.BaseURL != DefaultBaseURL || got.Timeout != DefaultTimeout {
t.Fatalf("默认值不对: %+v", got)
}
if got.Default != "" || len(got.Personas) != 0 {
t.Fatalf("归一化给发音人兜底了(红线:不许有代码默认角色): %+v", got)
}
if custom := (Config{BaseURL: " http://127.0.0.1:9880/ "}).Normalized(); custom.BaseURL != DefaultBaseURL {
t.Errorf("归一化不对: %+v", custom)
}
// persona/character 两端的空白被清掉;空 personaId 的键被丢弃。
trimmed := (Config{Personas: map[string]string{" a ": " char_1 ", "": "char_2"}, Default: " char_3 "}).Normalized()
if trimmed.Personas["a"] != "char_1" || len(trimmed.Personas) != 1 || trimmed.Default != "char_3" {
t.Fatalf("映射/默认值没有被归一化: %+v", trimmed)
}
}