Files
Agent-System-Backend/internal/runmgr/crossread_test.go
T
weichen f1302ed5b3 Enable canonical versioned Go module consumption
Why: Allow consumers to download an immutable module revision without nested source checkouts or local path replacements.

Evidence: SELF package tests and the prior canonical-path rejection are recorded in the Dev dependency-workspace task card; remote consumption is replayed against the committed candidate.

Impact: Change the module identity and its Go imports while preserving API behavior. Existing historical submodule revisions remain buildable.

Refs: Versioned dependencies U1/U3; feature/dependency-workspace
2026-10-13 15:47:16 +00:00

190 lines
7.0 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.
// 跨会话读取门禁矩阵(提案 2026-09-30-cross-session-reading 验收表):
// 项目开关 × 出站 allowCrossRead × 入站 crossPrivate × 跨项目/跨租户 × 自读 × 归档。
// 门禁实现在 toolCtx(本包),经真实 store + settings 覆盖链验证,fail-closed。
package runmgr
import (
"errors"
"path/filepath"
"testing"
"git-ssh.u1680832.nyat.app/weichen/Agent-System-Backend.git/internal/settings"
"git-ssh.u1680832.nyat.app/weichen/Agent-System-Backend.git/internal/store"
core "git-ssh.u1680832.nyat.app/weichen/Agent-System-Core.git"
)
type crossEnv struct {
m *Manager
svc *settings.Service
st *store.Store
projA core.Project
projA2 core.Project // alice 的另一项目(跨项目负例)
projB core.Project // bob 的项目(跨租户负例)
convA core.Conversation
convB core.Conversation // 普通同伴
convC core.Conversation // 私有同伴
convD core.Conversation // 归档同伴
convE core.Conversation // bob 名下
convF core.Conversation // alice 另一项目下
}
func newCrossEnv(t *testing.T) *crossEnv {
t.Helper()
st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "cross.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
e := &crossEnv{m: &Manager{st: st}, svc: settings.NewService(st), st: st}
for i, mk := range []struct {
owner string
dst *core.Project
}{
{"alice", &e.projA}, {"alice", &e.projA2}, {"bob", &e.projB},
} {
p, err := st.CreateProject(mk.owner, "p"+string(rune('a'+i)))
if err != nil {
t.Fatal(err)
}
*mk.dst = p
}
newConv := func(owner string, p core.Project, title string) core.Conversation {
c, err := st.CreateConversation(owner, p.ID, title, "")
if err != nil {
t.Fatal(err)
}
return c
}
e.convA = newConv("alice", e.projA, "A 调用方")
e.convB = newConv("alice", e.projA, "B 同伴")
e.convC = newConv("alice", e.projA, "C 私有")
e.convD = newConv("alice", e.projA, "D 归档")
e.convE = newConv("bob", e.projB, "E 他租户")
e.convF = newConv("alice", e.projA2, "F 他项目")
yes := true
if err := st.SetConversationFlags("alice", e.convC.ID, nil, nil, nil, &yes); err != nil {
t.Fatal(err)
}
if err := st.SetConversationFlags("alice", e.convD.ID, nil, &yes, nil, nil); err != nil {
t.Fatal(err)
}
addMsgs := func(c core.Conversation, texts ...string) {
for _, txt := range texts {
m := core.Message{ConversationID: c.ID, Role: core.RoleUser, Parts: []core.Part{{Type: core.PartText, Text: txt}}}
if err := st.AppendMessage(&m); err != nil {
t.Fatal(err)
}
}
}
addMsgs(e.convA, "A-1")
addMsgs(e.convB, "B-1", "B-2", "B-3")
return e
}
// peerCtx 构造门禁视角:owner=alice 在 projA 下经 convA 发起读取。
func (e *crossEnv) peerCtx(t *testing.T) *toolCtx {
t.Helper()
eff := e.svc.Effective("alice", e.projA.ID)
return &toolCtx{rc: &runCtx{mgr: e.m, eff: eff, run: core.Run{Owner: "alice", ProjectID: e.projA.ID, ConversationID: e.convA.ID}}}
}
func (e *crossEnv) setGate(t *testing.T, on bool) {
t.Helper()
if err := e.svc.MergeProject("alice", e.projA.ID, map[string]any{"crossSession": map[string]any{"read": on}}); err != nil {
t.Fatal(err)
}
}
func TestCrossReadGateMatrix(t *testing.T) {
e := newCrossEnv(t)
tru, fls := true, false
// 1) 全关默认:项目闸门关,读与列均拒绝。
if _, err := e.peerCtx(t).PeerConversationMessages(e.convB.ID, 0); !errors.Is(err, ErrCrossReadDisabled) {
t.Fatalf("gate off must disable read, got %v", err)
}
if _, err := e.peerCtx(t).PeerConversations(); !errors.Is(err, ErrCrossReadDisabled) {
t.Fatalf("gate off must disable list, got %v", err)
}
// 2) 正常放行:开闸后读 B 最近记录(默认 limit 50 ≥ 3 条全量)。
e.setGate(t, true)
msgs, err := e.peerCtx(t).PeerConversationMessages(e.convB.ID, 0)
if err != nil {
t.Fatal(err)
}
if len(msgs) != 3 || msgs[0].Parts[0].Text != "B-1" || msgs[2].Parts[0].Text != "B-3" {
t.Fatalf("expected 3 ordered messages, got %+v", msgs)
}
// 3) 出站关闭:A 不读他人(列表与读取都拒;自读仍可,见 7)。
if err := e.st.SetConversationFlags("alice", e.convA.ID, nil, nil, &fls, nil); err != nil {
t.Fatal(err)
}
if _, err := e.peerCtx(t).PeerConversationMessages(e.convB.ID, 0); !errors.Is(err, ErrCrossReadDisabled) {
t.Fatalf("outbound off must disable read, got %v", err)
}
if _, err := e.peerCtx(t).PeerConversations(); !errors.Is(err, ErrCrossReadDisabled) {
t.Fatalf("outbound off must disable list, got %v", err)
}
if err := e.st.SetConversationFlags("alice", e.convA.ID, nil, nil, &tru, nil); err != nil { // 恢复出站
t.Fatal(err)
}
// 4) 目标私有:C 拒读且不出现在列表;列表含归档 D、不含自身。
if _, err := e.peerCtx(t).PeerConversationMessages(e.convC.ID, 0); !errors.Is(err, ErrCrossReadForbidden) {
t.Fatalf("private target must be forbidden, got %v", err)
}
convs, err := e.peerCtx(t).PeerConversations()
if err != nil {
t.Fatal(err)
}
ids := map[string]bool{}
for _, c := range convs {
ids[c.ID] = true
}
if !ids[e.convB.ID] || !ids[e.convD.ID] || ids[e.convC.ID] || ids[e.convA.ID] {
t.Fatalf("list must include B/D, exclude private C and self A, got %v", ids)
}
// 5) 跨项目:同租户另一项目的 F 拒读。
if _, err := e.peerCtx(t).PeerConversationMessages(e.convF.ID, 0); !errors.Is(err, ErrCrossReadForbidden) {
t.Fatalf("cross-project target must be forbidden, got %v", err)
}
// 6) 跨租户:bob 名下 E 一律 NotFound(存在性不泄露)。
if _, err := e.peerCtx(t).PeerConversationMessages(e.convE.ID, 0); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("cross-tenant target must be NotFound, got %v", err)
}
// 7) 自读:A 读自身(含窗口外历史)放行;A 设为私有后自读仍放行(入站豁免)。
selfMsgs, err := e.peerCtx(t).PeerConversationMessages(e.convA.ID, 0)
if err != nil || len(selfMsgs) != 1 {
t.Fatalf("self read must pass, got %d msgs err %v", len(selfMsgs), err)
}
if err := e.st.SetConversationFlags("alice", e.convA.ID, nil, nil, nil, &tru); err != nil {
t.Fatal(err)
}
if _, err := e.peerCtx(t).PeerConversationMessages(e.convA.ID, 0); err != nil {
t.Fatalf("self read of a private self must pass, got %v", err)
}
if err := e.st.SetConversationFlags("alice", e.convA.ID, nil, nil, nil, &fls); err != nil { // 还原非私有
t.Fatal(err)
}
// 8) limit:正数生效;越界钳制。
last2, err := e.peerCtx(t).PeerConversationMessages(e.convB.ID, 2)
if err != nil || len(last2) != 2 || last2[1].Parts[0].Text != "B-3" {
t.Fatalf("limit=2 must return last two, got %d err %v", len(last2), err)
}
if msgs, err := e.peerCtx(t).PeerConversationMessages(e.convB.ID, 99999); err != nil || len(msgs) != 3 {
t.Fatalf("oversized limit must clamp without error, got %d err %v", len(msgs), err)
}
// 9) 闸门经设置覆盖链关闭后回 1) 行为。
e.setGate(t, false)
if _, err := e.peerCtx(t).PeerConversationMessages(e.convB.ID, 0); !errors.Is(err, ErrCrossReadDisabled) {
t.Fatalf("re-closed gate must disable read, got %v", err)
}
}