package acp_test import ( "context" "encoding/json" "testing" "time" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/yan1h/agent-coding-workflow/internal/acp" "github.com/yan1h/agent-coding-workflow/internal/audit" "github.com/yan1h/agent-coding-workflow/internal/infra/crypto" "github.com/yan1h/agent-coding-workflow/internal/infra/errs" ) // fakeAgentKindRepo 是仅覆盖 AgentKind 方法的 fake;其他方法 panic // (隔离单测,不引 testcontainers)。 type fakeAgentKindRepo struct { store map[uuid.UUID]*acp.AgentKind files map[string]*acp.ConfigFile // key: kindID/relPath } func newFakeAgentKindRepo() *fakeAgentKindRepo { return &fakeAgentKindRepo{ store: map[uuid.UUID]*acp.AgentKind{}, files: map[string]*acp.ConfigFile{}, } } func (f *fakeAgentKindRepo) CreateAgentKind(_ context.Context, k *acp.AgentKind) (*acp.AgentKind, error) { cp := *k f.store[k.ID] = &cp return &cp, nil } func (f *fakeAgentKindRepo) GetAgentKindByID(_ context.Context, id uuid.UUID) (*acp.AgentKind, error) { if k, ok := f.store[id]; ok { cp := *k return &cp, nil } return nil, errs.New(errs.CodeAcpAgentKindNotFound, "not found") } func (f *fakeAgentKindRepo) GetAgentKindByName(_ context.Context, name string) (*acp.AgentKind, error) { for _, k := range f.store { if k.Name == name { cp := *k return &cp, nil } } return nil, errs.New(errs.CodeAcpAgentKindNotFound, "not found") } func (f *fakeAgentKindRepo) ListAgentKinds(_ context.Context) ([]*acp.AgentKind, error) { out := make([]*acp.AgentKind, 0, len(f.store)) for _, k := range f.store { cp := *k out = append(out, &cp) } return out, nil } func (f *fakeAgentKindRepo) ListEnabledAgentKinds(_ context.Context) ([]*acp.AgentKind, error) { out := make([]*acp.AgentKind, 0) for _, k := range f.store { if k.Enabled { cp := *k out = append(out, &cp) } } return out, nil } func (f *fakeAgentKindRepo) UpdateAgentKind(_ context.Context, k *acp.AgentKind) (*acp.AgentKind, error) { if _, ok := f.store[k.ID]; !ok { return nil, errs.New(errs.CodeAcpAgentKindNotFound, "not found") } cp := *k f.store[k.ID] = &cp return &cp, nil } func (f *fakeAgentKindRepo) DeleteAgentKind(_ context.Context, id uuid.UUID) error { delete(f.store, id) return nil } func (f *fakeAgentKindRepo) CountAgentKindUsage(_ context.Context, _ uuid.UUID) (int64, error) { return 0, nil } func (f *fakeAgentKindRepo) ListConfigFiles(_ context.Context, kindID uuid.UUID) ([]*acp.ConfigFile, error) { out := make([]*acp.ConfigFile, 0) for _, cf := range f.files { if cf.AgentKindID == kindID { cp := *cf out = append(out, &cp) } } return out, nil } func (f *fakeAgentKindRepo) UpsertConfigFile(_ context.Context, cf *acp.ConfigFile) (*acp.ConfigFile, error) { cp := *cf f.files[cf.AgentKindID.String()+"/"+cf.RelPath] = &cp return &cp, nil } func (f *fakeAgentKindRepo) DeleteConfigFile(_ context.Context, kindID uuid.UUID, relPath string) (bool, error) { key := kindID.String() + "/" + relPath if _, ok := f.files[key]; !ok { return false, nil } delete(f.files, key) return true, nil } // 其他方法 panic(不该被 AgentKindService 调用) func (f *fakeAgentKindRepo) InsertSession(context.Context, *acp.Session) (*acp.Session, error) { panic("n/a") } func (f *fakeAgentKindRepo) GetSessionByID(context.Context, uuid.UUID) (*acp.Session, error) { panic("n/a") } func (f *fakeAgentKindRepo) ListSessionsByUser(context.Context, uuid.UUID, *uuid.UUID) ([]*acp.Session, error) { panic("n/a") } func (f *fakeAgentKindRepo) ListAllSessions(context.Context, *uuid.UUID) ([]*acp.Session, error) { panic("n/a") } func (f *fakeAgentKindRepo) UpdateSessionRunning(context.Context, uuid.UUID, string, int32) error { panic("n/a") } func (f *fakeAgentKindRepo) UpdateSessionFinished(context.Context, uuid.UUID, acp.SessionStatus, *int32, *string) error { panic("n/a") } func (f *fakeAgentKindRepo) MarkSessionFailedIfActive(context.Context, uuid.UUID, string) (bool, error) { panic("n/a") } func (f *fakeAgentKindRepo) CountActiveSessions(context.Context) (int64, error) { panic("n/a") } func (f *fakeAgentKindRepo) CountActiveSessionsByUser(context.Context, uuid.UUID) (int64, error) { panic("n/a") } func (f *fakeAgentKindRepo) ResetStuckSessionsOnRestart(context.Context) ([]*acp.Session, error) { panic("n/a") } func (f *fakeAgentKindRepo) AcquireMainWorktree(context.Context, uuid.UUID, uuid.UUID) (bool, error) { panic("n/a") } func (f *fakeAgentKindRepo) ReleaseMainWorktree(context.Context, uuid.UUID, uuid.UUID) (bool, error) { panic("n/a") } func (f *fakeAgentKindRepo) InsertEvent(context.Context, *acp.Event) (*acp.Event, error) { panic("n/a") } func (f *fakeAgentKindRepo) ListEventsSince(context.Context, uuid.UUID, int64, int32) ([]*acp.Event, error) { panic("n/a") } func (f *fakeAgentKindRepo) PurgeEventsBefore(context.Context, time.Time) (int64, error) { panic("n/a") } func (f *fakeAgentKindRepo) InTx(context.Context, func(context.Context, pgx.Tx) error) error { panic("n/a") } func (f *fakeAgentKindRepo) WithTx(pgx.Tx) acp.Repository { return f } // agentKindRecordingRecorder:accumulate audit entries type agentKindRecordingRecorder struct{ entries []audit.Entry } func (r *agentKindRecordingRecorder) Record(_ context.Context, e audit.Entry) error { r.entries = append(r.entries, e) return nil } // testEncryptor returns a real *crypto.Encryptor with a fixed test key. func testEncryptor(t *testing.T) *crypto.Encryptor { t.Helper() key := []byte("0123456789abcdef0123456789abcdef") // 32 bytes enc, err := crypto.NewEncryptor(key) require.NoError(t, err) return enc } func TestAgentKindService_Create(t *testing.T) { t.Parallel() ctx := context.Background() repo := newFakeAgentKindRepo() rec := &agentKindRecordingRecorder{} svc := acp.NewAgentKindService(repo, testEncryptor(t), rec) admin := acp.Caller{UserID: uuid.New(), IsAdmin: true} k, err := svc.Create(ctx, admin, acp.CreateAgentKindInput{ Name: "claude_code", DisplayName: "Claude Code", BinaryPath: "claude-code", Args: []string{"--acp"}, Env: map[string]string{"K": "v"}, Enabled: true, }) require.NoError(t, err) assert.Equal(t, "claude_code", k.Name) assert.NotEmpty(t, k.EncryptedEnv) // env 已加密 require.Len(t, rec.entries, 1) assert.Equal(t, "acp.agent_kind.create", rec.entries[0].Action) } func TestAgentKindService_Create_NonAdmin(t *testing.T) { t.Parallel() ctx := context.Background() svc := acp.NewAgentKindService(newFakeAgentKindRepo(), testEncryptor(t), &agentKindRecordingRecorder{}) user := acp.Caller{UserID: uuid.New(), IsAdmin: false} _, err := svc.Create(ctx, user, acp.CreateAgentKindInput{ Name: "x", DisplayName: "x", BinaryPath: "x", Enabled: true, }) ae, ok := errs.As(err) require.True(t, ok) assert.Equal(t, errs.CodeForbidden, ae.Code) } func TestAgentKindService_List_NonAdmin_OnlyEnabled(t *testing.T) { t.Parallel() ctx := context.Background() repo := newFakeAgentKindRepo() svc := acp.NewAgentKindService(repo, testEncryptor(t), nil) admin := acp.Caller{UserID: uuid.New(), IsAdmin: true} user := acp.Caller{UserID: uuid.New(), IsAdmin: false} _, _ = svc.Create(ctx, admin, acp.CreateAgentKindInput{ Name: "k1", DisplayName: "k1", BinaryPath: "x", Enabled: true, }) _, _ = svc.Create(ctx, admin, acp.CreateAgentKindInput{ Name: "k2", DisplayName: "k2", BinaryPath: "x", Enabled: false, }) adminList, _ := svc.List(ctx, admin) userList, _ := svc.List(ctx, user) assert.Len(t, adminList, 2) assert.Len(t, userList, 1) } func TestAgentKindService_Update_PatchSemantics(t *testing.T) { t.Parallel() ctx := context.Background() repo := newFakeAgentKindRepo() rec := &agentKindRecordingRecorder{} svc := acp.NewAgentKindService(repo, testEncryptor(t), rec) admin := acp.Caller{UserID: uuid.New(), IsAdmin: true} k, _ := svc.Create(ctx, admin, acp.CreateAgentKindInput{ Name: "k", DisplayName: "K", BinaryPath: "/x", Enabled: true, }) disp := "Renamed" updated, err := svc.Update(ctx, admin, k.ID, acp.UpdateAgentKindInput{DisplayName: &disp}) require.NoError(t, err) assert.Equal(t, "Renamed", updated.DisplayName) assert.Equal(t, "/x", updated.BinaryPath) // 未改 last := rec.entries[len(rec.entries)-1] assert.Equal(t, "acp.agent_kind.update", last.Action) } func TestSupervisor_BuildSpawnEnv_ExtraEnvOverrides(t *testing.T) { t.Parallel() enc := testEncryptor(t) plain, err := json.Marshal(map[string]string{"ACW_MCP_TOKEN": "kind-value", "OTHER": "kept"}) require.NoError(t, err) encrypted, err := enc.Encrypt(plain) require.NoError(t, err) kind := &acp.AgentKind{EncryptedEnv: encrypted} sup := acp.NewSupervisor(nil, nil, nil, nil, nil, nil, enc, acp.SupervisorConfig{}, nil) env, err := sup.BuildSpawnEnv(kind, map[string]string{ "ACW_MCP_TOKEN": "system-value", "ACW_MCP_URL": "http://x", }) require.NoError(t, err) assert.Contains(t, env, "ACW_MCP_TOKEN=system-value") var hasOld bool for _, e := range env { if e == "ACW_MCP_TOKEN=kind-value" { hasOld = true } } assert.False(t, hasOld, "kind value must be overridden") assert.Contains(t, env, "OTHER=kept") assert.Contains(t, env, "ACW_MCP_URL=http://x") } // ===== PermissionRequest(权限审批框架,测试桩) ===== func (f *fakeAgentKindRepo) InsertPermissionRequest(context.Context, *acp.PermissionRequest) (*acp.PermissionRequest, error) { return &acp.PermissionRequest{}, nil } func (f *fakeAgentKindRepo) GetPermissionRequestByID(context.Context, uuid.UUID) (*acp.PermissionRequest, error) { return nil, nil } func (f *fakeAgentKindRepo) ListPendingPermissionRequestsBySession(context.Context, uuid.UUID) ([]*acp.PermissionRequest, error) { return nil, nil } func (f *fakeAgentKindRepo) ListPendingPermissionRequestsByUser(context.Context, uuid.UUID) ([]*acp.PermissionRequest, error) { return nil, nil } func (f *fakeAgentKindRepo) DecidePermissionRequest(context.Context, uuid.UUID, acp.PermissionStatus, *string, *uuid.UUID) (*acp.PermissionRequest, bool, error) { return nil, false, nil } func (f *fakeAgentKindRepo) ExpirePendingPermissionRequestsBySession(context.Context, uuid.UUID) (int64, error) { return 0, nil } func (f *fakeAgentKindRepo) ExpireStalePermissionRequests(context.Context, time.Time) (int64, error) { return 0, nil }