You've already forked agentic-coding-workflow
403 lines
13 KiB
Go
403 lines
13 KiB
Go
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)
|
|
}
|