design/11 §7.1(主人 2026-10-03 拍板)。此前 hub.broadcast 是全连接广播,而任意角色
(含 viewer)都能连 /ws/chat ⇒ viewer 能收到角色回复正文(message.*)、推理内容
(thinking.*)、消息增量、graph_update、mood 等。这与 design/19 §1/§3「flow 可视化仅
owner/admin 可见」冲突,也与 GET /sessions/{id}/messages 的 owner/admin 档位不一致。
收口:唯一分类点 internal/services/gateway/ws_frame_scope.go 的 frameReachesAll(typ)
(fail-closed switch,默认 false);hub.broadcast 投递前查表,白名单外对非 owner/admin
连接直接跳过。**不给 broadcast 加 bool 参数、不让调用点自己判断** —— 那正是"漏一处就是
泄漏"的形状。全包只有一个 fan-out 循环,14 个 .broadcast( 调用点全部落进该入口。
- 白名单只有 7 个状态机信号帧(不含任何内容):session_busy / pending_ack / pending_drain /
session_idle / session_interrupted / pending_full / session_read
- 其余全部(流式 message/thinking/tool_call、message_persisted/edited/deleted、graph_update、
subagent_event/result、task_output/task_done、automation_result、tool_used、capacity_awaken、
mode_switch、mood、device_update、reminder)仅 owner+admin
- 语义澄清:本门管「谁被主动推送」,数据可见性仍由 REST 决定(viewer 的 12 条只读监控端点
不变,含 GET /personas 的 mood);/ws/chat 连接鉴权与 voice_output 定向帧不动
契约:Docs/api-client.md → v0.48(对 viewer 是收紧;owner/admin 零变化;无端点/字段形状变更)
验证:go build/vet 绿;go test ./... = 58 包 ok / 0 FAIL;-race ./internal/services/gateway/... ok;
linux/amd64 + darwin/arm64 交叉编译绿。真 httptest WS + 真 JWT 按角色逐帧实测 37 帧:
viewer 只收到 7 个状态信号,owner/admin 37/37。
变异验证:① graph_update 加回白名单 → 真 WS 抓到 viewer 收到 graph_update,3 个测试 FAIL;
② session_idle 移出白名单 → 3 个测试 FAIL(viewer 等不到哨兵帧而超时)。均已还原。
遗留(已登记,待主人裁决):① 握手 history 回放对任意角色照发(viewer 连上即得主会话最近
50 条正文);② /ws/chat 上行未按角色授权(viewer 可发 chat = 真驱动模型并落库;
subagent_ask / task_output_query 的定向应答绕过本门)。
327 lines
12 KiB
Go
327 lines
12 KiB
Go
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"
|
||
)
|
||
|
||
// These tests lock the design/11 §7.1 frame gate: a viewer connection may follow
|
||
// the session state machine but must never receive content, while owner and admin
|
||
// keep both classes. frameScopeCases is the design/11 §7.1 table in code — every
|
||
// frame type named there, published on the subject that really produces it — so a
|
||
// classification mistake (a content frame made public, or a state signal made
|
||
// private) fails here instead of leaking silently.
|
||
|
||
// frameScopeCase is one row of the design/11 §7.1 table.
|
||
type frameScopeCase struct {
|
||
typ string // downstream frame type
|
||
subject string // bus subject that produces it
|
||
payload any // payload published on that subject
|
||
public bool // 「状态机信号」: no content → every authenticated connection
|
||
}
|
||
|
||
// scopeSentinel is published last: the bus runs one worker per subscription
|
||
// sequentially, so a connection that has seen the sentinel has already been
|
||
// offered every frame published before it. That turns "the viewer received nothing
|
||
// else" into an exact count instead of a guessed idle window.
|
||
const scopeSentinel = EventSessionIdle
|
||
|
||
// The batch pacing used by publishFrameScopeCases, kept well under the hub's
|
||
// wsSendBuffer depth so a burst never trips the slow-client drop.
|
||
const (
|
||
scopePaceFrames = 4
|
||
scopePacePause = 50 * time.Millisecond
|
||
)
|
||
|
||
// scopeRead is the outcome of one connection's reader goroutine.
|
||
type scopeRead struct {
|
||
role auth.Role
|
||
types []string
|
||
err error
|
||
}
|
||
|
||
// frameScopeCases returns the design/11 §7.1 table. Relayed frames use their real
|
||
// ws.<type> subject; stream frames publish the real model.StreamEvent the loop
|
||
// emits on ws.stream, so the test drives the production path
|
||
// bus → relay → broadcast instead of a hand-built frame.
|
||
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.StreamKindToolExecution, "tool_execution.update"),
|
||
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),
|
||
relayed("subagent.result", 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 drives the whole design/11 §7.1 table through real
|
||
// WebSocket connections with real role tokens: owner and admin receive every frame
|
||
// of the table, while the viewer receives exactly the whitelisted state signals, in
|
||
// publish order. The viewer assertions the design calls out by name (message.delta /
|
||
// thinking.delta / message_persisted / graph_update / mood denied, session_idle
|
||
// delivered) are re-stated explicitly so the contract is readable in the test.
|
||
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),
|
||
}
|
||
|
||
// The clients drain on their own goroutines while the batch is published: the
|
||
// hub drops frames for a client whose send buffer is full (its slow-client
|
||
// policy), so a client that only started reading afterwards would lose the tail.
|
||
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 is the per-frame-type × per-role matrix, driven
|
||
// directly through the delivery entry so every row of the table is asserted in
|
||
// isolation (a leaked frame would otherwise be hard to attribute). claims are set
|
||
// explicitly per role, so a default-deny regression shows up as the viewer losing
|
||
// the frame it is entitled to.
|
||
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 pins the two properties that keep the
|
||
// gate from drifting: an unclassified frame type is never public (so a frame added
|
||
// by a later milestone cannot leak by omission), and the public set is exactly the
|
||
// state signals design/11 §7.1 registers — widening it means editing the design, the
|
||
// gate and this table together.
|
||
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 returns the table's frame types in table order (all of them, or only
|
||
// the public ones).
|
||
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 publishes every row on its production subject, followed by
|
||
// the sentinel that closes the batch. The batch is paced: a client whose send buffer
|
||
// is full has its frames dropped (the hub's documented slow-client policy), so a
|
||
// 37-frame burst would exercise the drop policy rather than the gate.
|
||
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 reads exactly want frames and returns their types in arrival order.
|
||
// It reports an error rather than failing the test so it can run on the reader
|
||
// goroutines that drain each client while the batch is published.
|
||
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 compares the received frame types to the expected sequence.
|
||
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 reports whether typ is among the received frame types.
|
||
func containsFrameType(types []string, typ string) bool {
|
||
for _, got := range types {
|
||
if got == typ {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|