You've already forked agentic-coding-workflow
feat(acp): AgentKindService + unit tests (fake repo + real encryptor)
This commit is contained in:
@@ -0,0 +1,182 @@
|
|||||||
|
package acp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"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"
|
||||||
|
)
|
||||||
|
|
||||||
|
type agentKindService struct {
|
||||||
|
repo Repository
|
||||||
|
enc *crypto.Encryptor
|
||||||
|
audit audit.Recorder
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAgentKindService 构造 AgentKindService。
|
||||||
|
func NewAgentKindService(repo Repository, enc *crypto.Encryptor, rec audit.Recorder) AgentKindService {
|
||||||
|
return &agentKindService{repo: repo, enc: enc, audit: rec}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *agentKindService) Create(ctx context.Context, c Caller, in CreateAgentKindInput) (*AgentKind, error) {
|
||||||
|
if !c.IsAdmin {
|
||||||
|
return nil, errs.New(errs.CodeForbidden, "admin only")
|
||||||
|
}
|
||||||
|
if in.Name == "" || in.DisplayName == "" || in.BinaryPath == "" {
|
||||||
|
return nil, errs.New(errs.CodeInvalidInput, "name / display_name / binary_path required")
|
||||||
|
}
|
||||||
|
encrypted, err := encryptEnv(s.enc, in.Env)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
args := in.Args
|
||||||
|
if args == nil {
|
||||||
|
args = []string{}
|
||||||
|
}
|
||||||
|
k := &AgentKind{
|
||||||
|
ID: uuid.New(), Name: in.Name, DisplayName: in.DisplayName, Description: in.Description,
|
||||||
|
BinaryPath: in.BinaryPath, Args: args, EncryptedEnv: encrypted, Enabled: in.Enabled,
|
||||||
|
CreatedBy: c.UserID,
|
||||||
|
}
|
||||||
|
out, err := s.repo.CreateAgentKind(ctx, k)
|
||||||
|
if err != nil {
|
||||||
|
if IsUniqueViolation(err) {
|
||||||
|
return nil, errs.New(errs.CodeConflict, "agent kind name already exists")
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
s.recordAudit(ctx, c, "acp.agent_kind.create", out.ID.String(),
|
||||||
|
map[string]any{"name": out.Name, "binary_path": out.BinaryPath})
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *agentKindService) List(ctx context.Context, c Caller) ([]*AgentKind, error) {
|
||||||
|
if c.IsAdmin {
|
||||||
|
return s.repo.ListAgentKinds(ctx)
|
||||||
|
}
|
||||||
|
return s.repo.ListEnabledAgentKinds(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *agentKindService) Get(ctx context.Context, c Caller, id uuid.UUID) (*AgentKind, error) {
|
||||||
|
k, err := s.repo.GetAgentKindByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !c.IsAdmin && !k.Enabled {
|
||||||
|
return nil, errs.New(errs.CodeAcpAgentKindNotFound, "agent kind not found")
|
||||||
|
}
|
||||||
|
return k, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *agentKindService) Update(ctx context.Context, c Caller, id uuid.UUID, in UpdateAgentKindInput) (*AgentKind, error) {
|
||||||
|
if !c.IsAdmin {
|
||||||
|
return nil, errs.New(errs.CodeForbidden, "admin only")
|
||||||
|
}
|
||||||
|
cur, err := s.repo.GetAgentKindByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
changed := map[string]any{}
|
||||||
|
if in.DisplayName != nil && *in.DisplayName != cur.DisplayName {
|
||||||
|
cur.DisplayName = *in.DisplayName
|
||||||
|
changed["display_name"] = "<changed>"
|
||||||
|
}
|
||||||
|
if in.Description != nil && *in.Description != cur.Description {
|
||||||
|
cur.Description = *in.Description
|
||||||
|
changed["description"] = "<changed>"
|
||||||
|
}
|
||||||
|
if in.BinaryPath != nil && *in.BinaryPath != cur.BinaryPath {
|
||||||
|
cur.BinaryPath = *in.BinaryPath
|
||||||
|
changed["binary_path"] = "<changed>"
|
||||||
|
}
|
||||||
|
if in.Args != nil {
|
||||||
|
cur.Args = in.Args
|
||||||
|
changed["args"] = "<changed>"
|
||||||
|
}
|
||||||
|
if in.Env != nil {
|
||||||
|
encrypted, eerr := encryptEnv(s.enc, in.Env)
|
||||||
|
if eerr != nil {
|
||||||
|
return nil, eerr
|
||||||
|
}
|
||||||
|
cur.EncryptedEnv = encrypted
|
||||||
|
changed["env"] = "<changed>"
|
||||||
|
}
|
||||||
|
if in.Enabled != nil && *in.Enabled != cur.Enabled {
|
||||||
|
cur.Enabled = *in.Enabled
|
||||||
|
changed["enabled"] = *in.Enabled
|
||||||
|
}
|
||||||
|
if len(changed) == 0 {
|
||||||
|
return cur, nil
|
||||||
|
}
|
||||||
|
out, err := s.repo.UpdateAgentKind(ctx, cur)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
s.recordAudit(ctx, c, "acp.agent_kind.update", out.ID.String(),
|
||||||
|
map[string]any{"changed_fields": changed})
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *agentKindService) Delete(ctx context.Context, c Caller, id uuid.UUID) error {
|
||||||
|
if !c.IsAdmin {
|
||||||
|
return errs.New(errs.CodeForbidden, "admin only")
|
||||||
|
}
|
||||||
|
cur, err := s.repo.GetAgentKindByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := s.repo.DeleteAgentKind(ctx, id); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.recordAudit(ctx, c, "acp.agent_kind.delete", id.String(),
|
||||||
|
map[string]any{"name": cur.Name})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *agentKindService) recordAudit(ctx context.Context, c Caller, action, targetID string, meta map[string]any) {
|
||||||
|
if s.audit == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
uid := c.UserID
|
||||||
|
_ = s.audit.Record(context.WithoutCancel(ctx), audit.Entry{
|
||||||
|
UserID: &uid, Action: action, TargetType: "acp_agent_kind", TargetID: targetID, Metadata: meta,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// encryptEnv 把 map[string]string 序列化后加密。nil → 返回 nil(清空)。
|
||||||
|
func encryptEnv(enc *crypto.Encryptor, env map[string]string) ([]byte, error) {
|
||||||
|
if env == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if len(env) == 0 {
|
||||||
|
return enc.Encrypt([]byte(`{}`))
|
||||||
|
}
|
||||||
|
b, err := json.Marshal(env)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errs.Wrap(err, errs.CodeInternal, "marshal env")
|
||||||
|
}
|
||||||
|
return enc.Encrypt(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// decryptEnv 解密并反序列化 env map。Part 2 的 supervisor.Spawn 用。
|
||||||
|
func decryptEnv(enc *crypto.Encryptor, encrypted []byte) (map[string]string, error) {
|
||||||
|
if len(encrypted) == 0 {
|
||||||
|
return map[string]string{}, nil
|
||||||
|
}
|
||||||
|
plain, err := enc.Decrypt(encrypted)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errs.Wrap(err, errs.CodeInternal, "decrypt env")
|
||||||
|
}
|
||||||
|
var m map[string]string
|
||||||
|
if err := json.Unmarshal(plain, &m); err != nil {
|
||||||
|
return nil, errs.Wrap(err, errs.CodeInternal, "unmarshal env")
|
||||||
|
}
|
||||||
|
if m == nil {
|
||||||
|
m = map[string]string{}
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
package acp_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
|
||||||
|
func newFakeAgentKindRepo() *fakeAgentKindRepo {
|
||||||
|
return &fakeAgentKindRepo{store: map[uuid.UUID]*acp.AgentKind{}}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 其他方法 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) ([]*acp.Session, error) {
|
||||||
|
panic("n/a")
|
||||||
|
}
|
||||||
|
func (f *fakeAgentKindRepo) ListAllSessions(context.Context) ([]*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) 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")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user