主人 2026-10-03 拍板"这一类要收紧"后,补齐 §7.1 下行门之外的两处同类缺口:
viewer 是只读监控档,却能通过 WS 拿到内容、甚至发起写入。
A. 握手 history 回放仅 owner/admin(ws_history.go)
连接建立时回放主会话最近 50 条消息正文,此前对任意角色照发 ⇒ viewer 一连上就
拿走主人对话内容。门放在 hub.replayHistory 的第一行 —— 它是唯一发出 history 帧的
函数,将来多一个调用者也绕不过去。
B. 上行 JSON 帧按角色授权(ws_route.go,fail-closed)
此前 route 完全没有角色检查。现在 route 的第一条语句即:非 owner/admin → 回定向
{type:"error",data:{code:"forbidden"}} 并 return,因此"不广播、不驱动模型、不落库、
不返回任何内容"是结构性的,而不是每个 handler 各自记得检查。写成默认拒而非黑名单 ——
chat / interrupt / subagent_ask / task_output_query / voice_input / mode_switch 以及
将来任何新帧都自动被拒。协议级帧(ping/pong/close)由 gorilla 在 ReadMessage 内处理,
不受影响(已实测:被拒后 ping→pong 正常、close 后服务端注销连接)。
被修掉的真实越权:viewer 发 chat 会真的驱动模型并把消息落库;subagent_ask /
task_output_query 的定向应答会绕过 §7.1 下行门,把子代理上下文/任务输出回给 viewer。
契约:Docs/api-client.md → v0.49(对 viewer 收紧;owner/admin 逐帧形状不变)
验证:go build/vet 绿;go test ./... = 58 包 ok / 0 FAIL;-race ./internal/services/gateway/...
ok;linux/amd64 + darwin/arm64 交叉编译绿;新测试 -count=3 稳定。
真 httptest WS + 真 JWT 逐帧实测:viewer 收不到 history 回放、上行一律定向 forbidden
且 runner 计数 = 0 / 落库 = 0;owner 侧 chat 驱动 runner = 1 且落库 2 条。
变异验证:① 去掉 A 的门 → viewer 收到 {"type":"history",...,"content":"主人的私密提问"},FAIL;
② 去掉 B 的门 → viewer 帧驱动 runner 1 次并落库 2 条、且直接收到 subagent_event / task_output,
FAIL。均已还原;且撤掉 B 时 A 的测试仍独立 PASS(两把门不互相代偿)。
遗留(如实登记):非 JSON 的上行字节不经过 route 的角色门(零副作用:不带 type、不触发
handler、不落库);admin 上行未单独测一格(与 owner 共用同一分支)。
184 lines
6.0 KiB
Go
184 lines
6.0 KiB
Go
package gateway
|
|
|
|
import (
|
|
"context"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"net/http"
|
|
"testing"
|
|
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/multimodal/voice"
|
|
)
|
|
|
|
// signalVoice is a VoiceService that also implements voiceProviderReporter, so a
|
|
// test can control both the STT/TTS result and the reported provider rung.
|
|
type signalVoice struct {
|
|
text string
|
|
audio voice.Audio
|
|
rung string
|
|
sttErr error
|
|
ttsErr error
|
|
}
|
|
|
|
func (s *signalVoice) STT(_ context.Context, _ voice.Audio) (string, error) {
|
|
return s.text, s.sttErr
|
|
}
|
|
|
|
func (s *signalVoice) TTS(_ context.Context, _ string) (voice.Audio, error) {
|
|
return s.audio, s.ttsErr
|
|
}
|
|
|
|
func (s *signalVoice) CloneVoice(_ context.Context, _, _ string) (string, error) { return "", nil }
|
|
|
|
func (s *signalVoice) STTWithProvider(_ context.Context, _ voice.Audio) (string, string, error) {
|
|
return s.text, s.rung, s.sttErr
|
|
}
|
|
|
|
func (s *signalVoice) TTSWithProvider(_ context.Context, _ string) (voice.Audio, string, error) {
|
|
return s.audio, s.rung, s.ttsErr
|
|
}
|
|
|
|
func TestHandleTranscribe_SilentSignal(t *testing.T) {
|
|
env := newEnv(t, nil, false)
|
|
env.srv.SetVoice(&signalVoice{rung: "silent"})
|
|
|
|
token := env.token(t, auth.RoleOwner)
|
|
resp, body := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/audio/transcribe", map[string]any{
|
|
"audio": base64.StdEncoding.EncodeToString([]byte("raw")),
|
|
}, token)
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("status = %d, body = %s", resp.StatusCode, body)
|
|
}
|
|
var data struct {
|
|
Text string `json:"text"`
|
|
Provider string `json:"provider"`
|
|
}
|
|
if err := json.Unmarshal(decodeBody(t, body)["data"], &data); err != nil {
|
|
t.Fatalf("decode data: %v (body=%s)", err, body)
|
|
}
|
|
if data.Text != "" || data.Provider != "silent" {
|
|
t.Errorf("data = %+v, want empty text + provider silent", data)
|
|
}
|
|
}
|
|
|
|
func TestHandleTranscribe_SilentFallbackWithoutReporter(t *testing.T) {
|
|
env := newEnv(t, nil, false)
|
|
// audioStubVoice does not implement voiceProviderReporter, so the handler
|
|
// must still flag an empty transcription as silent.
|
|
env.srv.SetVoice(&audioStubVoice{})
|
|
|
|
token := env.token(t, auth.RoleOwner)
|
|
resp, body := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/audio/transcribe", map[string]any{
|
|
"audio": base64.StdEncoding.EncodeToString([]byte("raw")),
|
|
}, token)
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("status = %d, body = %s", resp.StatusCode, body)
|
|
}
|
|
var data struct {
|
|
Provider string `json:"provider"`
|
|
}
|
|
if err := json.Unmarshal(decodeBody(t, body)["data"], &data); err != nil {
|
|
t.Fatalf("decode data: %v (body=%s)", err, body)
|
|
}
|
|
if data.Provider != "silent" {
|
|
t.Errorf("provider = %q, want silent (body=%s)", data.Provider, body)
|
|
}
|
|
}
|
|
|
|
func TestHandleTranscribe_SuccessOmitsProvider(t *testing.T) {
|
|
env := newEnv(t, nil, false)
|
|
env.srv.SetVoice(&signalVoice{text: "你好", rung: "cloud"})
|
|
|
|
token := env.token(t, auth.RoleOwner)
|
|
resp, body := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/audio/transcribe", map[string]any{
|
|
"audio": base64.StdEncoding.EncodeToString([]byte("raw")),
|
|
}, token)
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("status = %d, body = %s", resp.StatusCode, body)
|
|
}
|
|
var raw map[string]json.RawMessage
|
|
if err := json.Unmarshal(decodeBody(t, body)["data"], &raw); err != nil {
|
|
t.Fatalf("decode data: %v (body=%s)", err, body)
|
|
}
|
|
if _, present := raw["provider"]; present {
|
|
t.Errorf("provider must be omitted on a non-empty transcription: %s", body)
|
|
}
|
|
}
|
|
|
|
func TestHandleSpeech_EmptyAudioSignal(t *testing.T) {
|
|
env := newEnv(t, nil, false)
|
|
env.srv.SetVoice(&signalVoice{rung: "silent"})
|
|
|
|
token := env.token(t, auth.RoleOwner)
|
|
resp, body := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/audio/speech", map[string]any{
|
|
"text": "你好",
|
|
}, token)
|
|
if resp.StatusCode != http.StatusInternalServerError {
|
|
t.Fatalf("status = %d, want 500 (body=%s)", resp.StatusCode, body)
|
|
}
|
|
if got := resp.Header.Get(voiceProviderHeader); got != "silent" {
|
|
t.Errorf("%s = %q, want silent", voiceProviderHeader, got)
|
|
}
|
|
var env2 struct {
|
|
Error struct {
|
|
Code string `json:"code"`
|
|
} `json:"error"`
|
|
}
|
|
if err := json.Unmarshal(body, &env2); err != nil {
|
|
t.Fatalf("decode error: %v (body=%s)", err, body)
|
|
}
|
|
if env2.Error.Code != "tts_empty" {
|
|
t.Errorf("error code = %q, want tts_empty (body=%s)", env2.Error.Code, body)
|
|
}
|
|
}
|
|
|
|
func TestHubVoiceOutput_SilentSignal(t *testing.T) {
|
|
t.Parallel()
|
|
hb := newTestHub(&stubRunner{res: &loop.Result{Text: "你好", Steps: 1, StopReason: "stop"}}, nil)
|
|
hb.voice = &signalVoice{text: "你好", rung: "silent"}
|
|
// voice_input is an owner-facing instruction since design/11 §7.2 B, so the
|
|
// connection that drives it has to carry owner/admin claims.
|
|
c := newOwnerConn(hb, 32)
|
|
hb.register(c)
|
|
|
|
c.route(wsInbound{Type: "voice_input", Data: json.RawMessage(`{"audio":"aGVsbG8=","format":"wav"}`)})
|
|
ev := readFakeFrame(t, c)
|
|
if ev.Type != wsVoiceOutput {
|
|
t.Fatalf("frame type = %q, want voice_output (frame=%+v)", ev.Type, ev)
|
|
}
|
|
r, ok := ev.Data.(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("data = %T, want map[string]any", ev.Data)
|
|
}
|
|
if r["provider"] != "silent" {
|
|
t.Errorf("provider = %v, want silent (frame=%+v)", r["provider"], ev.Data)
|
|
}
|
|
}
|
|
|
|
func TestHubVoiceOutput_SuccessOmitsProvider(t *testing.T) {
|
|
t.Parallel()
|
|
hb := newTestHub(&stubRunner{res: &loop.Result{Text: "你好", Steps: 1, StopReason: "stop"}}, nil)
|
|
hb.voice = &signalVoice{
|
|
text: "你好",
|
|
audio: voice.Audio{Format: "wav", Data: []byte("audio-bytes")},
|
|
rung: "edge-tts",
|
|
}
|
|
c := newOwnerConn(hb, 32)
|
|
hb.register(c)
|
|
|
|
c.route(wsInbound{Type: "voice_input", Data: json.RawMessage(`{"audio":"aGVsbG8=","format":"wav"}`)})
|
|
ev := readFakeFrame(t, c)
|
|
if ev.Type != wsVoiceOutput {
|
|
t.Fatalf("frame type = %q, want voice_output (frame=%+v)", ev.Type, ev)
|
|
}
|
|
r, ok := ev.Data.(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("data = %T, want map[string]any", ev.Data)
|
|
}
|
|
if _, present := r["provider"]; present {
|
|
t.Errorf("provider must be omitted for non-empty audio: %+v", ev.Data)
|
|
}
|
|
}
|