package gateway import ( "context" "database/sql" "encoding/json" "io" "log/slog" "path/filepath" "testing" "time" "github.com/gorilla/websocket" "git.yeij.top/AskaEth/Cyrene_v2/internal/auth" "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/restrict" "git.yeij.top/AskaEth/Cyrene_v2/internal/db" ) // 本文件锁定裁决 13.4.3 的对外侧:敏感操作经代审后**落 audit_logs** // (who/what/when/result)并向 owner/admin 广播 design/11 §4 的小字帧 // `approval_notice`。判定/代审本身由 internal/capability/restrict 的 Gate // 钉住(restrict/approve_test.go),这里钉住「记录 + 呈现」,以及 // design/11 §7.1 的帧门(viewer 收不到这条内容帧)。 // approvalReviewer 是 restrict.Reviewer 的桩:记录调用并固定批准。 type approvalReviewer struct { calls int allowed bool } func (r *approvalReviewer) ReviewAndApprove(context.Context, string, string, string) (bool, error) { r.calls++ return r.allowed, nil } // newApprovalEnv 接线一个带**真实 SQLite 审计存储**的网关 —— 用例要断言 // audit_logs 里真的有行,因此不能用内存回退存储。 func newApprovalEnv(t *testing.T) (*Server, string, *auth.JWTService, *sql.DB) { t.Helper() ctx := context.Background() conn, err := db.Open(ctx, filepath.Join(t.TempDir(), "cyrene.db"), slog.New(slog.NewTextHandler(io.Discard, nil))) if err != nil { t.Fatalf("open sqlite: %v", err) } t.Cleanup(func() { conn.Close() }) auditStore, err := NewSQLiteAuditStore(ctx, conn, nil) if err != nil { t.Fatalf("audit store: %v", err) } srv, ts, jwt := buildEnv(t, nil, Deps{AuditStore: auditStore}) return srv, ts.URL, jwt, conn } // ownerWSConn 建立一条 owner 连接,并用一次 mode_switch 往返确认它已注册 // 到 hub(与既有的 flow_update/role-gate 用例同法)。 func ownerWSConn(t *testing.T, url, token string) *websocket.Conn { t.Helper() conn := dialWS(t, url, token) t.Cleanup(func() { conn.Close() }) if err := conn.WriteJSON(wsInbound{Type: "mode_switch", Data: json.RawMessage(`{"mode":"chat"}`)}); err != nil { t.Fatalf("write mode_switch: %v", err) } if typ, raw := readWire(t, conn, "mode_switch ack"); typ != "mode_switch" { t.Fatalf("first frame = %q, want mode_switch (%s)", typ, raw) } return conn } // TestRecordApproval_TerminalTriggersApprovalAndLandsInAuditLogs 是裁决 13.4.3 // 的反向用例(gateway 侧):terminal 这类敏感调用**走代审**——判定敏感 → // 代审放行 → audit_logs 落一行 → 前端收到小字帧。此前 terminal 直接执行、 // 审计链里没有「代审」痕迹,本用例就是把那条链钉死。 func TestRecordApproval_TerminalTriggersApprovalAndLandsInAuditLogs(t *testing.T) { t.Parallel() ctx := context.Background() srv, url, jwt, conn := newApprovalEnv(t) wsConn := ownerWSConn(t, url, signAny(t, jwt, "acct", auth.RoleOwner)) args := map[string]any{"command": "echo hi"} if _, _, sensitive := restrict.SensitiveCall("terminal", args); !sensitive { t.Fatal("terminal 必须被判定为敏感操作(否则代审链根本不会触发)") } reviewer := &approvalReviewer{allowed: true} gate := restrict.NewGate(reviewer, func(context.Context) (string, string) { return "web", "昔涟" }) decision := gate.Admit(ctx, "terminal", args) if !decision.Allow || decision.Approval == nil { t.Fatalf("代审批准后应放行并留下记录: %+v", decision) } if reviewer.calls != 1 { t.Fatalf("代审者调用次数 = %d, want 1", reviewer.calls) } srv.RecordApproval(ctx, *decision.Approval) // ① 前端小字帧(design/11 §4 的固定句式)。 typ, raw := readWire(t, wsConn, "approval_notice") if typ != wsApprovalNotice { t.Fatalf("frame type = %q, want %q (%s)", typ, wsApprovalNotice, raw) } var frame struct { Type string `json:"type"` SessionID string `json:"sessionId"` Data struct { Actor string `json:"actor"` Tool string `json:"tool"` Op string `json:"op"` Detail string `json:"detail"` Explain string `json:"explain"` Allowed bool `json:"allowed"` At string `json:"at"` } `json:"data"` } if err := json.Unmarshal(raw, &frame); err != nil { t.Fatalf("decode frame %s: %v", raw, err) } if frame.SessionID != "web" { t.Fatalf("frame sessionId = %q, want web(小字要投到发生代审的那个会话)", frame.SessionID) } if frame.Data.Explain != "「昔涟 想要干什么,我审查后批准了」" { t.Fatalf("小字 = %q, want design/11 §4 的固定文案", frame.Data.Explain) } if frame.Data.Tool != "terminal" || frame.Data.Op != string(restrict.SensitiveExecCommand) || !frame.Data.Allowed { t.Fatalf("小字帧字段 = %+v", frame.Data) } if frame.Data.Detail == "" { t.Fatal("小字帧应带动作摘要(detail),前端可作副文本") } if _, err := time.Parse(time.RFC3339Nano, frame.Data.At); err != nil { t.Fatalf("帧时间戳 %q 不是 RFC3339Nano: %v", frame.Data.At, err) } // ② 审计链里有行(design/11 §8:代审动作批准/拒绝各落一行)。 rows := readAuditRows(t, ctx, conn) if len(rows) != 1 { t.Fatalf("audit_logs 行数 = %d, want 1 (%+v)", len(rows), rows) } got := rows[0] if got.who != "昔涟" || got.what != "approval.terminal" || got.result != approvalResultGrant { t.Fatalf("审计行 = %+v, want {昔涟 approval.terminal granted}", got) } } // TestRecordApproval_DeniedLandsAsDeniedRow 锁定拒绝路径同样落一行 // (result=denied),且小字换成「我审查后拒绝了」。 func TestRecordApproval_DeniedLandsAsDeniedRow(t *testing.T) { t.Parallel() ctx := context.Background() srv, url, jwt, conn := newApprovalEnv(t) wsConn := ownerWSConn(t, url, signAny(t, jwt, "acct", auth.RoleOwner)) gate := restrict.NewGate(&approvalReviewer{allowed: false}, func(context.Context) (string, string) { return "web", "昔涟" }) decision := gate.Admit(ctx, "terminal", map[string]any{"command": "rm -rf /"}) if decision.Allow { t.Fatalf("代审拒绝却放行: %+v", decision) } srv.RecordApproval(ctx, *decision.Approval) if typ, raw := readWire(t, wsConn, "denied notice"); typ != wsApprovalNotice { t.Fatalf("frame type = %q, want %q (%s)", typ, wsApprovalNotice, raw) } rows := readAuditRows(t, ctx, conn) if len(rows) != 1 || rows[0].result != approvalResultDeny { t.Fatalf("审计行 = %+v, want 一行 result=denied", rows) } } // TestRecordApproval_NoticeReachesOwnerNotViewer 锁定 design/11 §7.1 的帧门: // 代审小字是**内容帧**(带角色名与动作),因此只有 owner/admin 收得到, // viewer(只读监控档)收不到——新帧默认 fail-closed,未进状态机信号白名单。 func TestRecordApproval_NoticeReachesOwnerNotViewer(t *testing.T) { t.Parallel() ctx := context.Background() srv, url, jwt, _ := newApprovalEnv(t) viewer := dialWS(t, url, signAny(t, jwt, "viewer", auth.RoleViewer)) t.Cleanup(func() { viewer.Close() }) owner := ownerWSConn(t, url, signAny(t, jwt, "acct", auth.RoleOwner)) waitConnCount(t, srv, 2) srv.RecordApproval(ctx, restrict.ApprovalEvent{ SessionID: "web", Actor: "昔涟", Tool: "terminal", Op: restrict.SensitiveExecCommand, Detail: "terminal echo hi", Explain: "「昔涟 想要干什么,我审查后批准了」", Allowed: true, At: time.Now(), }) if typ, raw := readWire(t, owner, "owner notice"); typ != wsApprovalNotice { t.Fatalf("owner 未收到小字帧: %q (%s)", typ, raw) } // viewer 侧:本就不该有帧。给一个短窗口,任何帧都算失败 // (连接在读超时后会失效,所以这必须是该连接的最后一次读)。 _ = viewer.SetReadDeadline(time.Now().Add(wsRoleGateNoFrameWait)) if _, msg, err := viewer.ReadMessage(); err == nil { t.Fatalf("viewer 收到了代审小字帧,违反 design/11 §7.1: %s", msg) } } // auditRow 是 audit_logs 的一行(who/what/result)。 type auditRow struct { who string what string result string } // readAuditRows 读取 audit_logs 全部行(用例只写很少几行,无需分页)。 func readAuditRows(t *testing.T, ctx context.Context, conn *sql.DB) []auditRow { t.Helper() rows, err := conn.QueryContext(ctx, `SELECT who, what, result FROM audit_logs ORDER BY id`) if err != nil { t.Fatalf("query audit_logs: %v", err) } defer rows.Close() out := make([]auditRow, 0, 2) for rows.Next() { var r auditRow if err := rows.Scan(&r.who, &r.what, &r.result); err != nil { t.Fatalf("scan audit_logs: %v", err) } out = append(out, r) } if err := rows.Err(); err != nil { t.Fatalf("iterate audit_logs: %v", err) } return out }