Files
2026-06-22 08:55:57 +08:00

154 lines
4.6 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/notify"
)
// spyNotifier captures Dispatch calls for the HITL pending path.
type spyNotifier struct {
mu sync.Mutex
msgs []notify.Message
}
func (s *spyNotifier) Dispatch(_ context.Context, m notify.Message) error {
s.mu.Lock()
defer s.mu.Unlock()
s.msgs = append(s.msgs, m)
return nil
}
func (s *spyNotifier) count() int {
s.mu.Lock()
defer s.mu.Unlock()
return len(s.msgs)
}
func (s *spyNotifier) first() notify.Message {
s.mu.Lock()
defer s.mu.Unlock()
return s.msgs[0]
}
// spyMetrics captures permission decision outcomes.
type spyMetrics struct {
mu sync.Mutex
outcomes []string
}
func (s *spyMetrics) RecordPermissionDecision(outcome string) {
s.mu.Lock()
defer s.mu.Unlock()
s.outcomes = append(s.outcomes, outcome)
}
func (s *spyMetrics) snapshot() []string {
s.mu.Lock()
defer s.mu.Unlock()
out := make([]string, len(s.outcomes))
copy(out, s.outcomes)
return out
}
func contains(ss []string, want string) bool {
for _, s := range ss {
if s == want {
return true
}
}
return false
}
func TestPermission_Pending_DispatchesNotifyBeforeBlocking(t *testing.T) {
repo := newPermFakeRepo()
uid := uuid.New()
sid := uuid.New()
repo.sessions[sid] = &acp.Session{ID: sid, UserID: uid}
spy := &spyNotifier{}
met := &spyMetrics{}
svc := acp.NewPermissionService(repo, nil, time.Minute, nil)
svc.SetNotifier(spy)
svc.SetMetrics(met)
sess := handlers.SessionContext{SessionID: sid, UserID: uid} // no allowlist -> pending
resCh := make(chan string, 1)
go func() {
resCh <- svc.Decide(context.Background(), sess, "req-1",
[]byte(`{"name":"danger.tool"}`), optionsAllowReject())
}()
// Notification must fire while Decide is still blocked (before resolve).
require.Eventually(t, func() bool { return spy.count() == 1 }, time.Second, 5*time.Millisecond)
m := spy.first()
require.Equal(t, "acp.permission_pending", m.Topic)
require.Equal(t, uid, m.UserID)
require.Equal(t, notify.SeverityWarning, m.Severity)
require.Equal(t, "/approvals", m.Link)
require.Equal(t, sid.String(), m.Metadata["session_id"])
require.Equal(t, "danger.tool", m.Metadata["tool_name"])
// Resolve so Decide unblocks; exactly one dispatch (not on resolve).
var reqID uuid.UUID
require.Eventually(t, func() bool {
reqID = repo.onlyReqID()
return reqID != uuid.Nil
}, time.Second, 5*time.Millisecond)
require.NoError(t, svc.Resolve(context.Background(), acp.Caller{UserID: uid}, reqID, true, "allow_once"))
<-resCh
require.Equal(t, 1, spy.count(), "dispatch must fire once (pending), not on resolve")
// Metrics: approved recorded.
require.True(t, contains(met.snapshot(), "approved"))
}
func TestPermission_Metrics_AutoAndExpired(t *testing.T) {
repo := newPermFakeRepo()
uid := uuid.New()
sid := uuid.New()
repo.sessions[sid] = &acp.Session{ID: sid, UserID: uid}
met := &spyMetrics{}
svc := acp.NewPermissionService(repo, nil, 30*time.Millisecond, nil)
svc.SetMetrics(met)
// auto path
svc.Decide(context.Background(), handlers.SessionContext{
SessionID: sid, UserID: uid, ToolAllowlist: []string{"*"},
}, "req-auto", []byte(`{"name":"safe.tool"}`), optionsAllowReject())
require.True(t, contains(met.snapshot(), "auto"))
// expired path (no allowlist, times out)
chosen := svc.Decide(context.Background(), handlers.SessionContext{
SessionID: sid, UserID: uid,
}, "req-exp", []byte(`{"name":"danger.tool"}`), optionsAllowReject())
require.Equal(t, "", chosen)
require.True(t, contains(met.snapshot(), "expired"))
}
func TestPermission_ListPendingForUser(t *testing.T) {
repo := newPermFakeRepo()
uid := uuid.New()
other := uuid.New()
sidA := uuid.New()
sidB := uuid.New()
repo.sessions[sidA] = &acp.Session{ID: sidA, UserID: uid}
repo.sessions[sidB] = &acp.Session{ID: sidB, UserID: other}
// Two pending for uid (across sessions), one for other.
repo.reqs[uuid.New()] = &acp.PermissionRequest{ID: uuid.New(), SessionID: sidA, Status: acp.PermissionPending}
repo.reqs[uuid.New()] = &acp.PermissionRequest{ID: uuid.New(), SessionID: sidA, Status: acp.PermissionPending}
repo.reqs[uuid.New()] = &acp.PermissionRequest{ID: uuid.New(), SessionID: sidB, Status: acp.PermissionPending}
svc := acp.NewPermissionService(repo, nil, time.Minute, nil)
got, err := svc.ListPendingForUser(context.Background(), acp.Caller{UserID: uid})
require.NoError(t, err)
require.Len(t, got, 2)
}