Why: the user ruled out self-registration (2026-09-30): accounts are created only by admins who set an initial password; first login (and first login after any admin reset) must force a password change. Evidence: - migration v10 auth_accounts (users password/must_change/is_admin + sessions table; upgrade test walks v1<->v10 both directions). - argon2id hash/verify with PHC encoding; store tests cover hash roundtrip, duplicate-owner conflict, must-change lifecycle, session keep/except semantics. - e2e TestAdminAccountFlow: static login page reachable without auth, wrong password 401, login sets HttpOnly cookie + mustChangePassword, admin creates user (409 on duplicate), non-admin create 403, forced change clears flag and old password dies, admin reset revokes sessions and re-arms forced change, API-key channel untouched, 5-fail backoff 429, logout kills session. Full go test ./... green. Impact: middleware now accepts Bearer key OR session cookie and exempts static + /api/auth/login (also fixes the latent case where the login page itself could 401). Embedded no-auth mode unchanged. Refs: proposal 2026-09-30-ui-redesign-and-auth.md section 5 (admin-issued variant, user decision 2026-09-30)
214 lines
8.2 KiB
Go
214 lines
8.2 KiB
Go
package cmdbus
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
agentcommand "agent-command"
|
|
"agent-system-core"
|
|
)
|
|
|
|
// memAudit / memCards 测试替身。
|
|
type memAudit struct {
|
|
mu sync.Mutex
|
|
logs []agentcommand.AuditEnvelope
|
|
fail bool
|
|
}
|
|
|
|
func (m *memAudit) AppendAudit(env agentcommand.AuditEnvelope) error {
|
|
if m.fail {
|
|
return errors.New("audit down")
|
|
}
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
m.logs = append(m.logs, env)
|
|
return nil
|
|
}
|
|
|
|
type memCards struct{ created []core.ActivityCard }
|
|
|
|
func (m *memCards) Create(owner string, c core.ActivityCard, ref string) (core.ActivityCard, error) {
|
|
c.ID = core.ID("card_x1")
|
|
m.created = append(m.created, c)
|
|
return c, nil
|
|
}
|
|
func (m *memCards) SetStatus(owner, id string, st core.CardStatus, tone, note string) (core.ActivityCard, error) {
|
|
return core.ActivityCard{ID: core.ID(id), Status: st}, nil
|
|
}
|
|
|
|
func newTestBus(a AuditStore, c CardPort) *Bus {
|
|
b := New(a, c, func() time.Time { return time.Unix(1700000000, 0).UTC() })
|
|
_ = b.RegisterBuiltin(agentcommand.CommandSpec{ID: "demo.ping", Title: "Ping"}, "/ping",
|
|
func(ctx context.Context, req Request) (any, error) { return map[string]any{"pong": true}, nil })
|
|
_ = b.RegisterBuiltin(agentcommand.CommandSpec{
|
|
ID: "demo.risky", Title: "危险演示", Danger: agentcommand.DangerHigh,
|
|
Description: "高危命令演示",
|
|
ParamsSchema: json.RawMessage(`{"type":"object","required":["target"],"properties":{"target":{"type":"string"}}}`),
|
|
}, "/risky", func(ctx context.Context, req Request) (any, error) {
|
|
t, _ := req.Params["target"].(string)
|
|
if t == "boom" {
|
|
return nil, errors.New("handler rejected")
|
|
}
|
|
return map[string]any{"did": t}, nil
|
|
})
|
|
return b
|
|
}
|
|
|
|
func TestDispatchFourEntrancesSameAudit(t *testing.T) {
|
|
a := &memAudit{}
|
|
b := newTestBus(a, &memCards{})
|
|
for _, ent := range []string{"button", "slash", "chat", "card", "api", "weird-unknown"} {
|
|
resp := b.Dispatch(context.Background(), Request{ID: "demo.ping", Entrance: ent})
|
|
if resp.Receipt.State != agentcommand.StateCommitted {
|
|
t.Fatalf("entrance %s: state=%s reason=%s", ent, resp.Receipt.State, resp.Receipt.Reason)
|
|
}
|
|
}
|
|
if len(a.logs) != 6 {
|
|
t.Fatalf("audit logs = %d, want 6", len(a.logs))
|
|
}
|
|
want := map[string]string{"button": "button", "slash": "slash", "chat": "chat", "card": "card", "api": "api", "weird-unknown": "api"}
|
|
for i, env := range a.logs {
|
|
if env.Source != agentcommand.SourceBuiltin {
|
|
t.Fatalf("log %d source = %s", i, env.Source)
|
|
}
|
|
}
|
|
_ = want
|
|
// 入口归一:未知名归 api
|
|
if got := a.logs[5].Entrance; got != "api" {
|
|
t.Fatalf("unknown entrance canonicalized to %s, want api", got)
|
|
}
|
|
if a.logs[0].OperationID == "" {
|
|
t.Fatal("committed receipt must carry operationID")
|
|
}
|
|
}
|
|
|
|
func TestDispatchUnknownAndBadParams(t *testing.T) {
|
|
a := &memAudit{}
|
|
b := newTestBus(a, &memCards{})
|
|
resp := b.Dispatch(context.Background(), Request{ID: "nope.x"})
|
|
if resp.Receipt.State != agentcommand.StateRejected || !strings.Contains(resp.Receipt.Reason, "unknown") {
|
|
t.Fatalf("unknown: %+v", resp.Receipt)
|
|
}
|
|
resp = b.Dispatch(context.Background(), Request{ID: "demo.risky", Confirmed: true, Params: map[string]any{}})
|
|
if resp.Receipt.State != agentcommand.StateRejected || !strings.Contains(resp.Receipt.Reason, "required param target") {
|
|
t.Fatalf("missing required: %+v", resp.Receipt)
|
|
}
|
|
}
|
|
|
|
func TestHighDangerConfirmFlow(t *testing.T) {
|
|
a := &memAudit{}
|
|
cards := &memCards{}
|
|
b := newTestBus(a, cards)
|
|
|
|
// 未确认:创建内联确认卡,回执 accepted + operationID=卡 ID。
|
|
resp := b.Dispatch(context.Background(), Request{ID: "demo.risky", Params: map[string]any{"target": "prod"}, Entrance: "button", ConversationID: "cnv_1", AnchorMessageID: "msg_1"})
|
|
if resp.Receipt.State != agentcommand.StateAccepted || resp.Receipt.OperationID != "card_x1" {
|
|
t.Fatalf("pre-confirm: %+v", resp.Receipt)
|
|
}
|
|
if resp.Card == nil || resp.Card.Kind != core.CardKindConfirm || resp.Card.Status != core.CardActionRequired {
|
|
t.Fatalf("confirm card not created properly: %+v", resp.Card)
|
|
}
|
|
if resp.Card.ConversationID != "cnv_1" || resp.Card.AnchorMessageID == "" {
|
|
t.Fatalf("card anchoring missing: %+v", resp.Card)
|
|
}
|
|
|
|
// 确认:执行挂起请求,committed。
|
|
resp2 := b.Confirm("owner", "card_x1", "confirm")
|
|
if resp2.Receipt.State != agentcommand.StateCommitted {
|
|
t.Fatalf("confirm: %+v", resp2.Receipt)
|
|
}
|
|
if m, _ := resp2.Payload.(map[string]any); m == nil || m["did"] != "prod" {
|
|
t.Fatalf("confirm payload: %+v", resp2.Payload)
|
|
}
|
|
|
|
// 二次确认(已消费):rejected expired。
|
|
resp3 := b.Confirm("owner", "card_x1", "confirm")
|
|
if resp3.Receipt.State != agentcommand.StateRejected || !strings.Contains(resp3.Receipt.Reason, "expired") {
|
|
t.Fatalf("re-confirm: %+v", resp3.Receipt)
|
|
}
|
|
|
|
// 审计链:accepted(button) → committed(card);过期确认未触碰命令,不产生审计。
|
|
if len(a.logs) != 2 {
|
|
t.Fatalf("audit logs = %d, want 2", len(a.logs))
|
|
}
|
|
if a.logs[0].State != agentcommand.StateAccepted || a.logs[0].Entrance != "button" ||
|
|
a.logs[1].State != agentcommand.StateCommitted || a.logs[1].Entrance != "card" {
|
|
t.Fatalf("audit chain wrong: %+v", a.logs)
|
|
}
|
|
}
|
|
|
|
func TestHighDangerCancelAndHandlerReject(t *testing.T) {
|
|
a := &memAudit{}
|
|
b := newTestBus(a, &memCards{})
|
|
b.Dispatch(context.Background(), Request{ID: "demo.risky", Params: map[string]any{"target": "x"}, ConversationID: "cnv_1"})
|
|
if r := b.Confirm("o", "card_x1", "cancel"); r.Receipt.State != agentcommand.StateRejected || !strings.Contains(r.Receipt.Reason, "cancelled") {
|
|
t.Fatalf("cancel: %+v", r.Receipt)
|
|
}
|
|
// handler 拒绝路径(确认后执行失败)→ rejected + 理由。
|
|
b.Dispatch(context.Background(), Request{ID: "demo.risky", Params: map[string]any{"target": "boom"}, ConversationID: "cnv_1"})
|
|
r := b.Confirm("o", "card_x1", "confirm")
|
|
if r.Receipt.State != agentcommand.StateRejected || !strings.Contains(r.Receipt.Reason, "handler rejected") {
|
|
t.Fatalf("handler reject: %+v", r.Receipt)
|
|
}
|
|
}
|
|
|
|
func TestHighDangerWithoutCardPortRejected(t *testing.T) {
|
|
b := newTestBus(&memAudit{}, nil)
|
|
resp := b.Dispatch(context.Background(), Request{ID: "demo.risky", Params: map[string]any{"target": "x"}, ConversationID: "cnv_1"})
|
|
if resp.Receipt.State != agentcommand.StateRejected || !strings.Contains(resp.Receipt.Reason, "conversation context") {
|
|
t.Fatalf("no card port: %+v", resp.Receipt)
|
|
}
|
|
}
|
|
|
|
func TestAuditUnavailableYieldsUnknown(t *testing.T) {
|
|
a := &memAudit{fail: true}
|
|
b := newTestBus(a, &memCards{})
|
|
resp := b.Dispatch(context.Background(), Request{ID: "demo.ping"})
|
|
if resp.Receipt.State != agentcommand.StateUnknown || !strings.Contains(resp.Receipt.Reason, "audit unavailable") {
|
|
t.Fatalf("audit down: %+v", resp.Receipt)
|
|
}
|
|
}
|
|
|
|
func TestPluginCommandsAndSlash(t *testing.T) {
|
|
b := newTestBus(&memAudit{}, &memCards{})
|
|
if err := b.RegisterPluginCommands([]json.RawMessage{json.RawMessage(`{"id":"plug.do","title":"插件命令"}`)}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := b.RegisterPluginCommands([]json.RawMessage{json.RawMessage(`{"id":"bad id","title":"x"}`)}); err == nil {
|
|
t.Fatal("invalid plugin spec must be rejected")
|
|
}
|
|
if id, ok := b.ResolveSlash("/risky"); !ok || id != "demo.risky" {
|
|
t.Fatalf("slash resolve: %s %v", id, ok)
|
|
}
|
|
if _, ok := b.ResolveSlash("/nope"); ok {
|
|
t.Fatal("unknown slash must not resolve")
|
|
}
|
|
if !b.HasCommand("plug.do") || b.HasCommand("plug.missing") {
|
|
t.Fatal("HasCommand wrong")
|
|
}
|
|
}
|
|
|
|
func TestValidateParamsScalars(t *testing.T) {
|
|
schema := json.RawMessage(`{"type":"object","required":["n"],"properties":{"n":{"type":"number"},"s":{"type":"string"},"b":{"type":"boolean"},"i":{"type":"integer"}}}`)
|
|
if err := validateParams(schema, map[string]any{"n": 1.5, "s": "x", "b": true, "i": 3}); err != nil {
|
|
t.Fatalf("valid params rejected: %v", err)
|
|
}
|
|
if err := validateParams(schema, map[string]any{}); err == nil || !strings.Contains(err.Error(), "required") {
|
|
t.Fatalf("missing required not caught: %v", err)
|
|
}
|
|
if err := validateParams(schema, map[string]any{"n": "not-a-number"}); err == nil {
|
|
t.Fatal("type mismatch not caught")
|
|
}
|
|
if err := validateParams(schema, map[string]any{"n": 1, "i": 2.5}); err == nil {
|
|
t.Fatal("integer fraction not caught")
|
|
}
|
|
if err := validateParams(nil, map[string]any{"any": 1}); err != nil {
|
|
t.Fatalf("nil schema must pass: %v", err)
|
|
}
|
|
}
|