Files
Cyrene_v2/internal/services/gateway/ws_frame_scope_test.go
T
AskaEth b9e042d07d
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
docs(api): api-client v0.51 补管理面契约 + api-reference 去假陈述 + 帧名口径统一(裁决 15.4.2/15.4.3/10.4.5/08.4.2)
- api-client.md v0.51 同版补记 D 组:X-API-Key 已接线(管理面与用户侧已认证端点同时接受该头,恒 owner 级、fail-closed)、新增 owner-profile GET/PUT(owner-only,写带审计)、persona systemPrompt 改必填;§2.0 owner-only 档 35 → 37 条
- api-client.md 修正 /admin/users 条(签发/吊销 key 已实现,POST 创建用户仍诚实 501)与 M10 待办(主人档案保存已落地)
- api-reference.md §0.1 与 §8.4 两处「X-API-Key 未接入」假陈述改为指针(契约以 api-client 为准),版本指针 v0.49 → v0.51
- gateway 帧可见性测试的虚项改为真实帧名常量 wsSubagentResult(此前写的是 bus 主题拼法 subagent.result),测试说明帧名与 topic 的分工
2026-10-04 12:10:21 +08:00

328 lines
12 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 gateway
import (
"context"
"encoding/json"
"fmt"
"sort"
"testing"
"time"
"github.com/gorilla/websocket"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// 这些测试锁住 design/11 §7.1 的帧门:viewer 连接可以跟随会话状态机,
// 但绝不可收到内容,而 owner 与 admin 两类都保留。frameScopeCases 是
// design/11 §7.1 表在代码中的体现 —— 表中点名的每种帧类型,都发布在
// 真正产生它的主题上 —— 因此分类错误(内容帧被公开,或状态信号被
// 私有化)会在这里失败,而不是静默泄露。
// frameScopeCase 是 design/11 §7.1 表的一行。
type frameScopeCase struct {
typ string // 下行帧类型
subject string // 产生它的 bus 主题
payload any // 发布在该主题上的载荷
public bool // 「状态机信号」:不含内容 → 每个已认证连接
}
// scopeSentinel 最后发布:bus 每个订阅跑一个 worker 且按序处理,因此
// 已经看到哨兵的连接必定已经被投递过它之前发布的每一个帧。这把
// 「viewer 没有收到别的」从猜测的空闲窗口变成精确计数。
const scopeSentinel = EventSessionIdle
// publishFrameScopeCases 使用的批次节流,远低于 hub 的 wsSendBuffer
// 深度,因此一次突发永不触发慢客户端丢弃。
const (
scopePaceFrames = 4
scopePacePause = 50 * time.Millisecond
)
// scopeRead 是一个连接读取 goroutine 的结果。
type scopeRead struct {
role auth.Role
types []string
err error
}
// frameScopeCases 返回 design/11 §7.1 表。转发帧使用它们真实的 ws.<type>
// 主题;流帧发布 loop 在 ws.stream 上发出的真实 model.StreamEvent,因此
// 测试驱动的是生产路径 bus → relay → broadcast,而不是手工构造的帧。
func frameScopeCases() []frameScopeCase {
relayed := func(typ string, public bool) frameScopeCase {
return frameScopeCase{typ: typ, subject: wsPrefix + typ, payload: map[string]any{"sessionId": "web"}, public: public}
}
streamed := func(kind model.StreamKind, typ string) frameScopeCase {
return frameScopeCase{
typ: typ,
subject: wsStreamSubject,
payload: model.StreamEvent{Kind: kind, Delta: "x", SessionID: "web", TurnID: "turn-scope"},
}
}
return []frameScopeCase{
// 状态机信号(白名单,不含任何内容)→ 所有已认证连接。
relayed(EventSessionBusy, true),
relayed(EventPendingAck, true),
relayed(EventPendingDrain, true),
relayed(EventSessionIdle, true),
relayed(EventSessionInterrupt, true),
relayed(EventPendingFull, true),
relayed(wsSessionRead, true),
// 流式内容(含角色回复正文与推理内容)→ 仅 owner/admin。
streamed(model.StreamKindTextStart, "message.start"),
streamed(model.StreamKindTextDelta, "message.delta"),
streamed(model.StreamKindTextEnd, "message.end"),
streamed(model.StreamKindThinkingStart, "thinking.start"),
streamed(model.StreamKindThinkingDelta, "thinking.delta"),
streamed(model.StreamKindThinkingEnd, "thinking.end"),
streamed(model.StreamKindToolCallStart, "tool_call.start"),
streamed(model.StreamKindToolCallDelta, "tool_call.delta"),
streamed(model.StreamKindToolCallEnd, "tool_call.end"),
streamed(model.StreamKindToolExecutionStart, "tool_execution.start"),
streamed(model.StreamKindToolExecutionUpdate, "tool_execution.update"),
streamed(model.StreamKindToolExecutionEnd, "tool_execution.end"),
streamed(model.StreamKindTurnStart, "turn.start"),
streamed(model.StreamKindTurnEnd, "turn.end"),
relayed("stream_chunk", false),
relayed("stream_end", false),
relayed("thinking", false),
// 持久化内容 → 仅 owner/admin。
relayed("message_persisted", false),
relayed("message_edited", false),
relayed("message_deleted", false),
relayed("channel_persisted", false),
// 系统内部 → 仅 owner/admin。
relayed("graph_update", false),
relayed("subagent_event", false),
// 后台子代理批次结果:这里断言的是**帧名**(常量,不是字面量,避免下次
// 再漂移);它真实的产生主题是 orchestration.SubagentResultSubject
// ("subagent.result",带点,那是 bus topic 的拼法),该端点对端路径
// 由 ws_bridge_subagent_result_test.go 单独驱动。
relayed(wsSubagentResult, false),
relayed("task_output", false),
relayed("task_done", false),
relayed("automation_result", false),
relayed("tool_used", false),
relayed("capacity_awaken", false),
relayed("mode_switch", false),
// 私有状态 → 仅 owner/admin。
relayed("mood", false),
relayed("device_update", false),
relayed("reminder", false),
}
}
// TestWSFrameScopePerRole 用真实角色 token 经真实 WebSocket 连接驱动
// 整张 design/11 §7.1 表:owner 与 admin 收到表中的每一帧,而 viewer
// 恰好收到白名单状态信号,且按发布顺序。设计点名的 viewer 断言
// (message.delta / thinking.delta / message_persisted / graph_update /
// mood 被拒,session_idle 被投递)在此显式复述,使契约在测试中可读。
func TestWSFrameScopePerRole(t *testing.T) {
t.Parallel()
cases := frameScopeCases()
env := newEnv(t, nil, false)
conns := map[auth.Role]*websocket.Conn{}
for _, role := range []auth.Role{auth.RoleOwner, auth.RoleAdmin, auth.RoleViewer} {
conn := dialWS(t, env.ts.URL, env.token(t, role))
defer conn.Close()
conns[role] = conn
}
waitConnCount(t, env.srv, len(conns))
all := append(frameTypes(cases, false), scopeSentinel)
public := append(frameTypes(cases, true), scopeSentinel)
wants := map[auth.Role]int{
auth.RoleOwner: len(all),
auth.RoleAdmin: len(all),
auth.RoleViewer: len(public),
}
// 各客户端在批次发布期间于自己的 goroutine 上排空:hub 会丢弃
// 发送缓冲已满的客户端的帧(其慢客户端策略),因此之后才开始
// 读取的客户端会丢掉尾部。
results := make(chan scopeRead, len(conns))
for role, want := range wants {
role, want := role, want
go func() {
types, err := readFrameTypes(conns[role], want)
results <- scopeRead{role: role, types: types, err: err}
}()
}
publishFrameScopeCases(t, env, cases)
got := make(map[auth.Role][]string, len(conns))
for range conns {
res := <-results
if res.err != nil {
t.Fatalf("%s reader: %v", res.role, res.err)
}
got[res.role] = res.types
}
for _, role := range []auth.Role{auth.RoleOwner, auth.RoleAdmin} {
assertFrameTypes(t, string(role), got[role], all)
}
gotViewer := got[auth.RoleViewer]
assertFrameTypes(t, string(auth.RoleViewer), gotViewer, public)
for _, denied := range []string{"message.delta", "thinking.delta", "message_persisted", "graph_update", "mood"} {
if containsFrameType(gotViewer, denied) {
t.Fatalf("viewer received %q; it must not (design/11 §7.1)", denied)
}
}
if !containsFrameType(gotViewer, EventSessionIdle) {
t.Fatalf("viewer missing %q, a whitelisted state signal (got %v)", EventSessionIdle, gotViewer)
}
}
// TestHubBroadcastScopeMatrix 是「每种帧类型 × 每种角色」矩阵,直接经
// 投递入口驱动,使表中每一行都被孤立断言(否则泄露的帧难以归因)。
// claims 按角色显式设置,因此默认拒绝的回归会表现为 viewer 丢掉它
// 本应收到的帧。
func TestHubBroadcastScopeMatrix(t *testing.T) {
t.Parallel()
hb := newTestHub(nil, nil)
owner := newFakeConn(hb, 4)
owner.claims = &auth.Claims{Role: auth.RoleOwner}
admin := newFakeConn(hb, 4)
admin.claims = &auth.Claims{Role: auth.RoleAdmin}
viewer := newFakeConn(hb, 4)
viewer.claims = &auth.Claims{Role: auth.RoleViewer}
for _, c := range []*wsConn{owner, admin, viewer} {
hb.register(c)
}
for _, tc := range frameScopeCases() {
tc := tc
t.Run(tc.typ, func(t *testing.T) {
hb.broadcast(wsEvent{Type: tc.typ, SessionID: "web"})
for _, c := range []*wsConn{owner, admin} {
if ev := readFakeFrame(t, c); ev.Type != tc.typ {
t.Fatalf("owner/admin frame = %q, want %q", ev.Type, tc.typ)
}
}
if tc.public {
if ev := readFakeFrame(t, viewer); ev.Type != tc.typ {
t.Fatalf("viewer frame = %q, want %q (whitelisted state signal)", ev.Type, tc.typ)
}
return
}
if err := connHasNoFrame(viewer); err != nil {
t.Fatalf("viewer received the restricted frame %q: %v", tc.typ, err)
}
})
}
}
// TestFrameScopeAllowsExactlyTheStateSignals 钉住两条使帧门不漂移的
// 性质:未分类的帧类型永不是公开的(因此后续里程碑新增的帧不会因
// 漏分类而泄露),且公开集合恰好是 design/11 §7.1 登记的状态信号
// —— 扩大它意味着要同时改设计、帧门和这张表。
func TestFrameScopeAllowsExactlyTheStateSignals(t *testing.T) {
t.Parallel()
if frameReachesAll("frame_type_nobody_classified") {
t.Fatal("an unclassified frame type reached every connection; the default must be owner/admin only")
}
public := make([]string, 0, len(frameScopeCases()))
for _, tc := range frameScopeCases() {
if got := frameReachesAll(tc.typ); got != tc.public {
t.Fatalf("frame %q: gate says public=%v, design/11 §7.1 says public=%v", tc.typ, got, tc.public)
}
if tc.public {
public = append(public, tc.typ)
}
}
want := []string{
EventSessionBusy,
EventPendingAck,
EventPendingDrain,
EventSessionIdle,
EventSessionInterrupt,
EventPendingFull,
wsSessionRead,
}
sort.Strings(public)
sort.Strings(want)
assertFrameTypes(t, "design/11 §7.1 whitelist", public, want)
}
// frameTypes 按表顺序返回表中的帧类型(全部,或仅公开的那些)。
func frameTypes(cases []frameScopeCase, publicOnly bool) []string {
out := make([]string, 0, len(cases))
for _, tc := range cases {
if publicOnly && !tc.public {
continue
}
out = append(out, tc.typ)
}
return out
}
// publishFrameScopeCases 在每行各自的生产主题上发布它,随后发布关闭
// 批次的哨兵。批次是节流的:发送缓冲已满的客户端其帧会被丢弃
// (hub 有文档的慢客户端策略),因此 37 帧的突发放送会去考验丢弃
// 策略而不是帧门。
func publishFrameScopeCases(t *testing.T, env *testEnv, cases []frameScopeCase) {
t.Helper()
for i, tc := range cases {
if err := env.bus.Publish(context.Background(), tc.subject, tc.payload); err != nil {
t.Fatalf("publish %s: %v", tc.subject, err)
}
if (i+1)%scopePaceFrames == 0 {
time.Sleep(scopePacePause)
}
}
if err := env.bus.Publish(context.Background(), wsPrefix+scopeSentinel, map[string]any{"sessionId": "web"}); err != nil {
t.Fatalf("publish sentinel: %v", err)
}
}
// readFrameTypes 恰好读取 want 个帧并按到达顺序返回其类型。它返回错误
// 而不是让测试失败,以便能跑在批次发布期间排空各客户端的读取
// goroutine 上。
func readFrameTypes(conn *websocket.Conn, want int) ([]string, error) {
got := make([]string, 0, want)
for len(got) < want {
_ = conn.SetReadDeadline(time.Now().Add(3 * time.Second))
_, msg, err := conn.ReadMessage()
if err != nil {
return got, fmt.Errorf("read frame %d/%d: %w (got %v)", len(got)+1, want, err, got)
}
var ev wsEvent
if err := json.Unmarshal(msg, &ev); err != nil {
return got, fmt.Errorf("decode frame %s: %w", msg, err)
}
got = append(got, ev.Type)
}
return got, nil
}
// assertFrameTypes 比较收到的帧类型与期望序列。
func assertFrameTypes(t *testing.T, what string, got, want []string) {
t.Helper()
if len(got) != len(want) {
t.Fatalf("%s frame count = %d, want %d (got %v)", what, len(got), len(want), got)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("%s frame[%d] = %q, want %q (got %v)", what, i, got[i], want[i], got)
}
}
}
// containsFrameType 报告 typ 是否在收到的帧类型之中。
func containsFrameType(types []string, typ string) bool {
for _, got := range types {
if got == typ {
return true
}
}
return false
}