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) }