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
190 lines
7.0 KiB
Go
190 lines
7.0 KiB
Go
// 跨会话读取门禁矩阵(提案 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)
|
||
}
|
||
}
|