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) GetSessionByStepID(context.Context, uuid.UUID) (*acp.Session, error) { return nil, errs.New(errs.CodeAcpSessionNotFound, "not found") } func (r *permFakeRepo) GetCrashedSessionForResume(context.Context, uuid.UUID) (*acp.Session, error) { return nil, errs.New(errs.CodeAcpSessionNotFound, "not found") } func (r *permFakeRepo) ResetSessionForResume(context.Context, uuid.UUID) error { return 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) ListPendingPermissionRequestsByUser(_ context.Context, userID uuid.UUID) ([]*acp.PermissionRequest, error) { r.mu.Lock() defer r.mu.Unlock() var out []*acp.PermissionRequest for _, p := range r.reqs { if p.Status != acp.PermissionPending { continue } sess, ok := r.sessions[p.SessionID] if !ok || sess.UserID != userID { continue } cp := *p out = append(out, &cp) } return out, 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_AutoAllow_Wildcard(t *testing.T) { repo := newPermFakeRepo() svc := acp.NewPermissionService(repo, nil, time.Minute, nil) sess := handlers.SessionContext{ SessionID: uuid.New(), UserID: uuid.New(), ToolAllowlist: []string{"*"}, } chosen := svc.Decide(context.Background(), sess, "req-1", []byte(`{"name":"any.tool"}`), optionsAllowReject()) require.Equal(t, "allow_once", chosen) require.Equal(t, acp.PermissionAuto, repo.statusOf(repo.onlyReqID())) } func TestPermission_Wildcard_UnknownToolName_Pending(t *testing.T) { // allowlist 含 "*" 但 toolCall 无法识别名称 → 不自动放行,走人工审批。 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, ToolAllowlist: []string{"*"}} resCh := make(chan string, 1) go func() { resCh <- svc.Decide(context.Background(), sess, "req-1", []byte(`{"unknownField":1}`), 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")) 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_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())) } // fakeMaintainer 满足 acp.ProjectMaintainer:仅当 projectID 命中 allow 集合时放行。 type fakeMaintainer struct { allow map[uuid.UUID]bool } func (f fakeMaintainer) CanWriteProject(_ context.Context, callerID uuid.UUID, isAdmin bool, projectID uuid.UUID) (bool, error) { if isAdmin { return true, nil } return f.allow[projectID], nil } // 注入 ProjectMaintainer 后:会话所属 project 的任意 maintainer(非会话 owner) // 也可处理待审权限请求(autonomy roadmap §11)。 func TestPermission_Resolve_ProjectMaintainer_Allowed(t *testing.T) { repo := newPermFakeRepo() owner := uuid.New() maintainer := uuid.New() pid := uuid.New() sid := uuid.New() repo.sessions[sid] = &acp.Session{ID: sid, UserID: owner, ProjectID: pid} svc := acp.NewPermissionService(repo, nil, time.Minute, nil) svc.SetProjectMaintainer(fakeMaintainer{allow: map[uuid.UUID]bool{pid: true}}) 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) // 非 owner 但是 project maintainer → 可处理。 require.NoError(t, svc.Resolve(context.Background(), acp.Caller{UserID: maintainer}, reqID, true, "allow_once")) require.Equal(t, acp.PermissionApproved, repo.statusOf(reqID)) } // 非 maintainer 的外部用户仍被拒(fail-closed);项目不在 allow 集合时也拒。 func TestPermission_Resolve_NonMaintainer_Forbidden(t *testing.T) { repo := newPermFakeRepo() owner := uuid.New() pid := uuid.New() otherPid := uuid.New() sid := uuid.New() repo.sessions[sid] = &acp.Session{ID: sid, UserID: owner, ProjectID: pid} svc := acp.NewPermissionService(repo, nil, time.Minute, nil) // maintainer 仅对 otherPid 有权,而会话属于 pid。 svc.SetProjectMaintainer(fakeMaintainer{allow: map[uuid.UUID]bool{otherPid: true}}) 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) }