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