You've already forked agentic-coding-workflow
修改
This commit is contained in:
@@ -0,0 +1,267 @@
|
||||
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()))
|
||||
}
|
||||
Reference in New Issue
Block a user