This commit is contained in:
2026-06-09 21:19:30 +08:00
parent 8b41ff9360
commit 0484e79978
66 changed files with 4043 additions and 533 deletions
+267
View File
@@ -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()))
}