package acp_test import ( "context" "sync" "testing" "time" "github.com/google/uuid" "github.com/stretchr/testify/require" "github.com/yan1h/agent-coding-workflow/internal/acp" "github.com/yan1h/agent-coding-workflow/internal/acp/handlers" "github.com/yan1h/agent-coding-workflow/internal/infra/errs" ) // permFakeRepo 内嵌 acp.Repository 接口(nil),仅实现 PermissionService 用到的方法; // 其余方法被调用会 panic(测试不应触达)。 type permFakeRepo struct { acp.Repository mu sync.Mutex reqs map[uuid.UUID]*acp.PermissionRequest sessions map[uuid.UUID]*acp.Session } func newPermFakeRepo() *permFakeRepo { return &permFakeRepo{ reqs: map[uuid.UUID]*acp.PermissionRequest{}, sessions: map[uuid.UUID]*acp.Session{}, } } func (r *permFakeRepo) InsertPermissionRequest(_ context.Context, p *acp.PermissionRequest) (*acp.PermissionRequest, error) { r.mu.Lock() defer r.mu.Unlock() cp := *p cp.CreatedAt = time.Unix(0, 0) r.reqs[cp.ID] = &cp out := cp return &out, nil } func (r *permFakeRepo) GetPermissionRequestByID(_ context.Context, id uuid.UUID) (*acp.PermissionRequest, error) { r.mu.Lock() defer r.mu.Unlock() p, ok := r.reqs[id] if !ok { return nil, errs.New(errs.CodeAcpPermissionNotFound, "not found") } out := *p return &out, nil } func (r *permFakeRepo) GetSessionByID(_ context.Context, id uuid.UUID) (*acp.Session, error) { r.mu.Lock() defer r.mu.Unlock() s, ok := r.sessions[id] if !ok { return nil, errs.New(errs.CodeAcpSessionNotFound, "not found") } out := *s return &out, nil } func (r *permFakeRepo) DecidePermissionRequest(_ context.Context, id uuid.UUID, status acp.PermissionStatus, chosen *string, decidedBy *uuid.UUID) (*acp.PermissionRequest, bool, error) { r.mu.Lock() defer r.mu.Unlock() p, ok := r.reqs[id] if !ok || p.Status != acp.PermissionPending { return nil, false, nil // CAS 失败:已非 pending } p.Status = status p.ChosenOptionID = chosen p.DecidedBy = decidedBy out := *p return &out, true, nil } func (r *permFakeRepo) ExpirePendingPermissionRequestsBySession(_ context.Context, sid uuid.UUID) (int64, error) { r.mu.Lock() defer r.mu.Unlock() var n int64 for _, p := range r.reqs { if p.SessionID == sid && p.Status == acp.PermissionPending { p.Status = acp.PermissionExpired n++ } } return n, nil } func (r *permFakeRepo) statusOf(id uuid.UUID) acp.PermissionStatus { r.mu.Lock() defer r.mu.Unlock() return r.reqs[id].Status } func (r *permFakeRepo) onlyReqID() uuid.UUID { r.mu.Lock() defer r.mu.Unlock() for id := range r.reqs { return id } return uuid.Nil } func optionsAllowReject() []handlers.PermOption { return []handlers.PermOption{{ID: "allow_once"}, {ID: "reject_once"}} } func TestPermission_AutoAllow_WhenAllowlisted(t *testing.T) { repo := newPermFakeRepo() svc := acp.NewPermissionService(repo, nil, time.Minute, nil) sess := handlers.SessionContext{ SessionID: uuid.New(), UserID: uuid.New(), ToolAllowlist: []string{"safe.tool"}, } chosen := svc.Decide(context.Background(), sess, "req-1", []byte(`{"name":"safe.tool"}`), optionsAllowReject()) require.Equal(t, "allow_once", chosen) require.Equal(t, acp.PermissionAuto, repo.statusOf(repo.onlyReqID())) } func TestPermission_Pending_ThenApprove(t *testing.T) { repo := newPermFakeRepo() uid := uuid.New() sid := uuid.New() repo.sessions[sid] = &acp.Session{ID: sid, UserID: uid} svc := acp.NewPermissionService(repo, nil, time.Minute, nil) sess := handlers.SessionContext{SessionID: sid, UserID: uid} // 无白名单 → 挂起 resCh := make(chan string, 1) go func() { resCh <- svc.Decide(context.Background(), sess, "req-1", []byte(`{"name":"danger.tool"}`), optionsAllowReject()) }() // 等待 pending 落库 var reqID uuid.UUID require.Eventually(t, func() bool { reqID = repo.onlyReqID() return reqID != uuid.Nil && repo.statusOf(reqID) == acp.PermissionPending }, time.Second, 5*time.Millisecond) require.NoError(t, svc.Resolve(context.Background(), acp.Caller{UserID: uid}, reqID, true, "allow_once")) select { case chosen := <-resCh: require.Equal(t, "allow_once", chosen) case <-time.After(time.Second): t.Fatal("Decide 未在批准后返回") } require.Equal(t, acp.PermissionApproved, repo.statusOf(reqID)) } func TestPermission_Pending_ThenDeny(t *testing.T) { repo := newPermFakeRepo() uid := uuid.New() sid := uuid.New() repo.sessions[sid] = &acp.Session{ID: sid, UserID: uid} svc := acp.NewPermissionService(repo, nil, time.Minute, nil) sess := handlers.SessionContext{SessionID: sid, UserID: uid} resCh := make(chan string, 1) go func() { resCh <- svc.Decide(context.Background(), sess, "req-1", []byte(`{"name":"danger.tool"}`), optionsAllowReject()) }() var reqID uuid.UUID require.Eventually(t, func() bool { reqID = repo.onlyReqID() return reqID != uuid.Nil && repo.statusOf(reqID) == acp.PermissionPending }, time.Second, 5*time.Millisecond) require.NoError(t, svc.Resolve(context.Background(), acp.Caller{UserID: uid}, reqID, false, "")) require.Equal(t, "reject_once", <-resCh) require.Equal(t, acp.PermissionDenied, repo.statusOf(reqID)) } func TestPermission_Timeout_DefaultsToReject(t *testing.T) { repo := newPermFakeRepo() uid := uuid.New() sid := uuid.New() repo.sessions[sid] = &acp.Session{ID: sid, UserID: uid} svc := acp.NewPermissionService(repo, nil, 30*time.Millisecond, nil) sess := handlers.SessionContext{SessionID: sid, UserID: uid} chosen := svc.Decide(context.Background(), sess, "req-1", []byte(`{"name":"danger.tool"}`), optionsAllowReject()) require.Equal(t, "", chosen) // 默认拒绝 require.Equal(t, acp.PermissionExpired, repo.statusOf(repo.onlyReqID())) } func TestPermission_DuplicateDecide_Conflict(t *testing.T) { repo := newPermFakeRepo() uid := uuid.New() sid := uuid.New() repo.sessions[sid] = &acp.Session{ID: sid, UserID: uid} svc := acp.NewPermissionService(repo, nil, time.Minute, nil) sess := handlers.SessionContext{SessionID: sid, UserID: uid} go svc.Decide(context.Background(), sess, "req-1", []byte(`{"name":"danger.tool"}`), optionsAllowReject()) var reqID uuid.UUID require.Eventually(t, func() bool { reqID = repo.onlyReqID() return reqID != uuid.Nil && repo.statusOf(reqID) == acp.PermissionPending }, time.Second, 5*time.Millisecond) require.NoError(t, svc.Resolve(context.Background(), acp.Caller{UserID: uid}, reqID, true, "allow_once")) err := svc.Resolve(context.Background(), acp.Caller{UserID: uid}, reqID, true, "allow_once") ae, ok := errs.As(err) require.True(t, ok) require.Equal(t, errs.CodeAcpPermissionAlreadyDecided, ae.Code) } func TestPermission_Resolve_NotOwner_Forbidden(t *testing.T) { repo := newPermFakeRepo() owner := uuid.New() sid := uuid.New() repo.sessions[sid] = &acp.Session{ID: sid, UserID: owner} svc := acp.NewPermissionService(repo, nil, time.Minute, nil) sess := handlers.SessionContext{SessionID: sid, UserID: owner} go svc.Decide(context.Background(), sess, "req-1", []byte(`{"name":"danger.tool"}`), optionsAllowReject()) var reqID uuid.UUID require.Eventually(t, func() bool { reqID = repo.onlyReqID() return reqID != uuid.Nil && repo.statusOf(reqID) == acp.PermissionPending }, time.Second, 5*time.Millisecond) err := svc.Resolve(context.Background(), acp.Caller{UserID: uuid.New()}, reqID, true, "allow_once") ae, ok := errs.As(err) require.True(t, ok) require.Equal(t, errs.CodeAcpSessionNotOwned, ae.Code) // admin 可处理任意会话 require.NoError(t, svc.Resolve(context.Background(), acp.Caller{UserID: uuid.New(), IsAdmin: true}, reqID, true, "allow_once")) } func TestPermission_CancelSession_UnblocksDecide(t *testing.T) { repo := newPermFakeRepo() uid := uuid.New() sid := uuid.New() repo.sessions[sid] = &acp.Session{ID: sid, UserID: uid} svc := acp.NewPermissionService(repo, nil, time.Minute, nil) sess := handlers.SessionContext{SessionID: sid, UserID: uid} resCh := make(chan string, 1) go func() { resCh <- svc.Decide(context.Background(), sess, "req-1", []byte(`{"name":"danger.tool"}`), optionsAllowReject()) }() require.Eventually(t, func() bool { id := repo.onlyReqID() return id != uuid.Nil && repo.statusOf(id) == acp.PermissionPending }, time.Second, 5*time.Millisecond) svc.CancelSession(context.Background(), sid) select { case chosen := <-resCh: require.Equal(t, "", chosen) case <-time.After(time.Second): t.Fatal("CancelSession 未解除 Decide 阻塞") } require.Equal(t, acp.PermissionExpired, repo.statusOf(repo.onlyReqID())) }