package app // 临时会话的内部命令分发门禁(U2):组合根在命令总线的项目预检入口上拒绝 // “基座项目是临时会话”的正式项目命令,宿主与插件处理器共用这一条分发面。 import ( "context" "errors" "path/filepath" "strings" "testing" "time" agentcommand "agent-command" "agent-system-backend/internal/cmdbus" "agent-system-backend/internal/store" core "agent-system-core" ) func newScratchGateStore(t *testing.T) (*store.Store, core.Project, core.Conversation) { t.Helper() st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "scratch-gate.db")) if err != nil { t.Fatal(err) } t.Cleanup(func() { st.Close() }) if err := st.CreateAccount("alice", "Alice", "", false); err != nil { t.Fatal(err) } project, conversation, err := st.CreateScratchSessionAccountChecked("alice", "临时会话", "临时会话", "", false) if err != nil { t.Fatal(err) } return st, project, conversation } // TestScratchCommandHostGateDecidesOwnership 门禁本身:属主的临时会话拒绝, // 普通项目与无基座项目的分发不受影响;非属主按存储判定(NotFound)。 func TestScratchCommandHostGateDecidesOwnership(t *testing.T) { st, project, _ := newScratchGateStore(t) if err := rejectScratchCommandHost(st, cmdbus.Request{ ID: "rdm.reqs.add", Owner: "alice", HostProjectID: string(project.ID), }); !errors.Is(err, ErrScratchCommandHost) { t.Fatalf("scratch host project accepted a formal command: %v", err) } normal, err := st.CreateProject("alice", "普通项目") if err != nil { t.Fatal(err) } if err := rejectScratchCommandHost(st, cmdbus.Request{ ID: "rdm.reqs.add", Owner: "alice", HostProjectID: string(normal.ID), }); err != nil { t.Fatalf("normal host project refused: %v", err) } if err := rejectScratchCommandHost(st, cmdbus.Request{ID: "rdm.reqs.add", Owner: "alice"}); err != nil { t.Fatalf("hostless dispatch refused: %v", err) } // 非属主不能把自己的“属主身份”带进临时会话。 if err := rejectScratchCommandHost(st, cmdbus.Request{ ID: "rdm.reqs.add", Owner: "bob", HostProjectID: string(project.ID), }); !errors.Is(err, store.ErrNotFound) { t.Fatalf("non-owner scratch dispatch = %v", err) } } // TestScratchCommandHostGateOnBusPreflight 门禁在真实分发面上生效:正式项目命令 // 在临时会话宿主上被拒,普通项目宿主不受影响(失败原因来自后续的会话来源校验, // 不是临时会话门禁)。 func TestScratchCommandHostGateOnBusPreflight(t *testing.T) { st, project, conversation := newScratchGateStore(t) normal, err := st.CreateProject("alice", "普通项目") if err != nil { t.Fatal(err) } normalConv, err := st.CreateConversation("alice", string(normal.ID), "会话", "") if err != nil { t.Fatal(err) } bus := cmdbus.New(st, nil, func() time.Time { return time.Unix(1700000000, 0).UTC() }) bus.SetProjectWorkPreflight(func(ctx context.Context, req cmdbus.Request) error { if req.Actor == "" || req.Actor != req.Owner || req.ConversationID == "" || req.AnchorMessageID == "" || req.HostProjectID == "" { return errors.New("formal project command requires an authenticated conversation source") } if err := rejectScratchCommandHost(st, req); err != nil { return err } conv, err := st.GetConversation(req.Owner, req.ConversationID) if err != nil || string(conv.ProjectID) != req.HostProjectID { return errors.New("formal project command conversation binding changed") } return errors.New("source analysis did not commit a proceed decision") }) if err := bus.RegisterPluginCommand(agentcommand.CommandSpec{ID: "rdm.reqs.add", Title: "登记需求"}, func(context.Context, cmdbus.Request) (any, error) { return map[string]any{"ok": true}, nil }); err != nil { t.Fatal(err) } scratchResp := bus.Dispatch(context.Background(), cmdbus.Request{ ID: "rdm.reqs.add", Entrance: "api", Actor: "alice", Owner: "alice", ConversationID: string(conversation.ID), AnchorMessageID: "msg-1", HostProjectID: string(project.ID), }) if scratchResp.Receipt.State != agentcommand.StateRejected || !strings.Contains(scratchResp.Receipt.Reason, ErrScratchCommandHost.Error()) { t.Fatalf("scratch host dispatch = %+v (want %s)", scratchResp.Receipt, ErrScratchCommandHost) } normalResp := bus.Dispatch(context.Background(), cmdbus.Request{ ID: "rdm.reqs.add", Entrance: "api", Actor: "alice", Owner: "alice", ConversationID: string(normalConv.ID), AnchorMessageID: "msg-1", HostProjectID: string(normal.ID), }) if normalResp.Receipt.State != agentcommand.StateRejected || strings.Contains(normalResp.Receipt.Reason, ErrScratchCommandHost.Error()) { t.Fatalf("normal host dispatch was blocked by the scratch gate: %+v", normalResp.Receipt) } }