diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/event/service.go b/PROJECTS/beginner/canary-token-generator/backend/internal/event/service.go index da7bcce5..d1b4dd64 100644 --- a/PROJECTS/beginner/canary-token-generator/backend/internal/event/service.go +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/event/service.go @@ -5,8 +5,10 @@ package event import ( "context" + "errors" "fmt" "log/slog" + "strconv" "time" "github.com/redis/go-redis/v9" @@ -15,6 +17,7 @@ import ( const ( dedupKeyPrefix = "dedup:trigger:" defaultDedupTTL = 15 * time.Minute + dedupScanBatch = 100 ) type Service struct { @@ -118,6 +121,49 @@ func (s *Service) dedupGate( return false } +func (s *Service) CountActiveDedup( + ctx context.Context, + tokenID string, +) (int64, error) { + if s.rdb == nil { + return 0, nil + } + pattern := dedupKeyPrefix + tokenID + ":*" + var total int64 + var cursor uint64 + for { + keys, next, err := s.rdb.Scan( + ctx, cursor, pattern, dedupScanBatch, + ).Result() + if err != nil { + return 0, fmt.Errorf("dedup scan: %w", err) + } + for _, key := range keys { + v, gErr := s.rdb.Get(ctx, key).Result() + if errors.Is(gErr, redis.Nil) { + continue + } + if gErr != nil { + s.logger.WarnContext(ctx, "dedup count: get key", + "error", gErr, "key", key) + continue + } + n, pErr := strconv.ParseInt(v, 10, 64) + if pErr != nil { + continue + } + if n > 1 { + total += n - 1 + } + } + if next == 0 { + break + } + cursor = next + } + return total, nil +} + func (s *Service) RunRetentionLoop( ctx context.Context, interval time.Duration, diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/event/service_test.go b/PROJECTS/beginner/canary-token-generator/backend/internal/event/service_test.go index f3773605..cd2604d3 100644 --- a/PROJECTS/beginner/canary-token-generator/backend/internal/event/service_test.go +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/event/service_test.go @@ -487,6 +487,118 @@ func TestService_Record_ConcurrentSafe(t *testing.T) { require.Equal(t, n, notifier.callCount()) } +func TestService_CountActiveDedup_NoKeysReturnsZero(t *testing.T) { + t.Parallel() + rdb, _ := setupRedis(t) + svc := newSvc(t, &fakeStore{}, &fakeIncrementer{}, rdb, nil) + + n, err := svc.CountActiveDedup(context.Background(), testTokenID) + require.NoError(t, err) + require.Equal(t, int64(0), n) +} + +func TestService_CountActiveDedup_FirstTriggerOnlyCountsZero(t *testing.T) { + t.Parallel() + rdb, _ := setupRedis(t) + svc := newSvc(t, &fakeStore{}, &fakeIncrementer{}, rdb, nil) + + require.NoError( + t, + svc.Record( + context.Background(), + sampleInfo(), + sampleEvent("203.0.113.1"), + ), + ) + + n, err := svc.CountActiveDedup(context.Background(), testTokenID) + require.NoError(t, err) + require.Equal(t, int64(0), n, + "first trigger fires the notification; nothing silenced yet") +} + +func TestService_CountActiveDedup_CountsSilencedAcrossIPs(t *testing.T) { + t.Parallel() + rdb, _ := setupRedis(t) + svc := newSvc(t, &fakeStore{}, &fakeIncrementer{}, rdb, nil) + + for range 3 { + require.NoError( + t, + svc.Record( + context.Background(), + sampleInfo(), + sampleEvent("203.0.113.1"), + ), + ) + } + for range 5 { + require.NoError( + t, + svc.Record( + context.Background(), + sampleInfo(), + sampleEvent("203.0.113.2"), + ), + ) + } + + n, err := svc.CountActiveDedup(context.Background(), testTokenID) + require.NoError(t, err) + require.Equal(t, int64(2+4), n, + "key1=3 (silenced 2) + key2=5 (silenced 4)") +} + +func TestService_CountActiveDedup_IgnoresOtherTokens(t *testing.T) { + t.Parallel() + rdb, _ := setupRedis(t) + svc := newSvc(t, &fakeStore{}, &fakeIncrementer{}, rdb, nil) + + for range 3 { + require.NoError( + t, + svc.Record( + context.Background(), + sampleInfo(), + sampleEvent("203.0.113.1"), + ), + ) + } + otherInfo := sampleInfo() + otherInfo.TokenID = "tokother0001" + for range 4 { + require.NoError( + t, + svc.Record( + context.Background(), + otherInfo, + &event.Event{TokenID: "tokother0001", SourceIP: "203.0.113.5"}, + ), + ) + } + + n, err := svc.CountActiveDedup(context.Background(), testTokenID) + require.NoError(t, err) + require.Equal(t, int64(2), n, "only this token's keys counted") +} + +func TestService_CountActiveDedup_NilRedisReturnsZero(t *testing.T) { + t.Parallel() + svc := event.NewService( + &fakeStore{}, + &fakeIncrementer{}, + nil, + nil, + event.ServiceConfig{ + DedupTTL: 15 * time.Minute, + Logger: slog.New(slog.NewTextHandler(testWriter{t}, nil)), + }, + ) + n, err := svc.CountActiveDedup(context.Background(), testTokenID) + require.NoError(t, err) + require.Equal(t, int64(0), n) +} + func TestService_RunRetentionLoop_PrunesAtInterval(t *testing.T) { t.Parallel() store := &fakeStore{pruneCount: 5} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/token/service.go b/PROJECTS/beginner/canary-token-generator/backend/internal/token/service.go index eba9dbed..b402d5b8 100644 --- a/PROJECTS/beginner/canary-token-generator/backend/internal/token/service.go +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/token/service.go @@ -45,6 +45,7 @@ type ServiceRepository interface { Insert(ctx context.Context, t *Token) error GetByID(ctx context.Context, id string) (*Token, error) GetByManageID(ctx context.Context, manageID string) (*Token, error) + DeleteByManageID(ctx context.Context, manageID string) error IncrementTriggerCount(ctx context.Context, id string) error } @@ -181,6 +182,13 @@ func (s *Service) IncrementTriggerCount( return s.repo.IncrementTriggerCount(ctx, id) } +func (s *Service) DeleteByManageID( + ctx context.Context, + manageID string, +) error { + return s.repo.DeleteByManageID(ctx, manageID) +} + func (s *Service) TriggerURL(id string) string { return s.baseURL + "/c/" + id } diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/token/service_test.go b/PROJECTS/beginner/canary-token-generator/backend/internal/token/service_test.go index 686c7742..5dc4258e 100644 --- a/PROJECTS/beginner/canary-token-generator/backend/internal/token/service_test.go +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/token/service_test.go @@ -73,6 +73,21 @@ func (f *fakeRepo) IncrementTriggerCount(_ context.Context, _ string) error { return nil } +func (f *fakeRepo) DeleteByManageID( + _ context.Context, + manageID string, +) error { + f.mu.Lock() + defer f.mu.Unlock() + t, ok := f.byManage[manageID] + if !ok { + return token.ErrNotFound + } + delete(f.byManage, manageID) + delete(f.byID, t.ID) + return nil +} + type fakeGenerator struct { tokenType token.Type artifact generators.Artifact