Cybersecurity-Projects/PROJECTS/beginner/canary-token-generator/backend/internal/event/service_test.go

823 lines
18 KiB
Go

// ©AngelaMos | 2026
// service_test.go
package event_test
import (
"context"
"errors"
"log/slog"
"strconv"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/alicebob/miniredis/v2"
"github.com/redis/go-redis/v9"
"github.com/stretchr/testify/require"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/event"
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/geoip"
)
const testTokenID = "tokevtsvc001"
type fakeStore struct {
mu sync.Mutex
inserted []*event.Event
insertErr error
statusUpdates []statusUpdate
statusErr error
pruneCount int64
pruneErr error
pruneLastLimit int
}
type statusUpdate struct {
id int64
status event.NotifyStatus
sentAt *time.Time
}
func (f *fakeStore) Insert(_ context.Context, e *event.Event) error {
f.mu.Lock()
defer f.mu.Unlock()
if f.insertErr != nil {
return f.insertErr
}
e.ID = int64(len(f.inserted) + 1)
if e.TriggeredAt.IsZero() {
e.TriggeredAt = time.Now().UTC()
}
if e.NotifyStatus == "" {
e.NotifyStatus = event.NotifyPending
}
f.inserted = append(f.inserted, e)
return nil
}
func (f *fakeStore) UpdateNotifyStatus(
_ context.Context,
id int64,
status event.NotifyStatus,
sentAt *time.Time,
) error {
f.mu.Lock()
defer f.mu.Unlock()
if f.statusErr != nil {
return f.statusErr
}
f.statusUpdates = append(f.statusUpdates, statusUpdate{id, status, sentAt})
return nil
}
func (f *fakeStore) PruneToLimit(
_ context.Context,
perTokenLimit int,
) (int64, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.pruneLastLimit = perTokenLimit
if f.pruneErr != nil {
return 0, f.pruneErr
}
return f.pruneCount, nil
}
func (f *fakeStore) snapshot() ([]*event.Event, []statusUpdate) {
f.mu.Lock()
defer f.mu.Unlock()
ev := make([]*event.Event, len(f.inserted))
copy(ev, f.inserted)
su := make([]statusUpdate, len(f.statusUpdates))
copy(su, f.statusUpdates)
return ev, su
}
type fakeIncrementer struct {
mu sync.Mutex
calls []string
err error
}
func (f *fakeIncrementer) IncrementTriggerCount(
_ context.Context,
id string,
) error {
f.mu.Lock()
defer f.mu.Unlock()
f.calls = append(f.calls, id)
return f.err
}
func (f *fakeIncrementer) callCount() int {
f.mu.Lock()
defer f.mu.Unlock()
return len(f.calls)
}
type fakeLookuper struct {
mu sync.Mutex
called []string
result geoip.Lookup
}
func (f *fakeLookuper) Lookup(ip string) geoip.Lookup {
f.mu.Lock()
defer f.mu.Unlock()
f.called = append(f.called, ip)
return f.result
}
func (f *fakeLookuper) calls() []string {
f.mu.Lock()
defer f.mu.Unlock()
out := make([]string, len(f.called))
copy(out, f.called)
return out
}
type fakeNotifier struct {
mu sync.Mutex
calls []notifyCall
}
type notifyCall struct {
info event.NotifyInfo
evt *event.Event
}
func (f *fakeNotifier) Notify(info event.NotifyInfo, evt *event.Event) {
f.mu.Lock()
defer f.mu.Unlock()
f.calls = append(f.calls, notifyCall{info, evt})
}
func (f *fakeNotifier) callCount() int {
f.mu.Lock()
defer f.mu.Unlock()
return len(f.calls)
}
func setupRedis(t *testing.T) (*redis.Client, *miniredis.Miniredis) {
t.Helper()
mr, err := miniredis.Run()
require.NoError(t, err)
t.Cleanup(mr.Close)
rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()})
t.Cleanup(func() {
if cErr := rdb.Close(); cErr != nil {
t.Logf("redis close: %v", cErr)
}
})
return rdb, mr
}
func sampleInfo() event.NotifyInfo {
return event.NotifyInfo{
TokenID: testTokenID,
ManageID: "abcd-1234",
Type: "webbug",
Memo: "test",
AlertChannel: "telegram",
TelegramBot: "bot",
TelegramChat: "chat",
}
}
func sampleEvent(ip string) *event.Event {
return &event.Event{TokenID: testTokenID, SourceIP: ip}
}
func newSvc(
t *testing.T,
store event.Store,
tokens event.TokenIncrementer,
rdb *redis.Client,
notifier event.Notifier,
) *event.Service {
t.Helper()
return event.NewService(store, tokens, rdb, notifier, event.ServiceConfig{
DedupTTL: 15 * time.Minute,
Logger: slog.New(slog.NewTextHandler(testWriter{t}, nil)),
})
}
func newSvcWithGeo(
t *testing.T,
store event.Store,
tokens event.TokenIncrementer,
rdb *redis.Client,
geo geoip.Lookuper,
) *event.Service {
t.Helper()
return event.NewService(store, tokens, rdb, nil, event.ServiceConfig{
DedupTTL: 15 * time.Minute,
Logger: slog.New(slog.NewTextHandler(testWriter{t}, nil)),
GeoIP: geo,
})
}
type testWriter struct{ t *testing.T }
func (w testWriter) Write(
p []byte,
) (int, error) {
w.t.Log(string(p))
return len(p), nil
}
func TestService_Record_InsertsEvent(t *testing.T) {
t.Parallel()
store := &fakeStore{}
inc := &fakeIncrementer{}
rdb, _ := setupRedis(t)
notifier := &fakeNotifier{}
svc := newSvc(t, store, inc, rdb, notifier)
evt := sampleEvent("203.0.113.1")
require.NoError(t, svc.Record(context.Background(), sampleInfo(), evt))
inserted, _ := store.snapshot()
require.Len(t, inserted, 1)
require.Equal(t, "203.0.113.1", inserted[0].SourceIP)
require.NotZero(t, evt.ID, "Insert assigns ID")
}
func TestService_Record_IncrementsTriggerCount(t *testing.T) {
t.Parallel()
store := &fakeStore{}
inc := &fakeIncrementer{}
rdb, _ := setupRedis(t)
svc := newSvc(t, store, inc, rdb, nil)
require.NoError(
t,
svc.Record(
context.Background(),
sampleInfo(),
sampleEvent("203.0.113.1"),
),
)
require.Equal(t, 1, inc.callCount())
}
func TestService_Record_FirstTriggerNotifies(t *testing.T) {
t.Parallel()
store := &fakeStore{}
inc := &fakeIncrementer{}
rdb, _ := setupRedis(t)
notifier := &fakeNotifier{}
svc := newSvc(t, store, inc, rdb, notifier)
require.NoError(
t,
svc.Record(
context.Background(),
sampleInfo(),
sampleEvent("203.0.113.1"),
),
)
require.Equal(t, 1, notifier.callCount())
_, statusUpdates := store.snapshot()
require.Empty(
t,
statusUpdates,
"first trigger should not write 'deduped' status; notify.Service handles sent/failed writeback async",
)
}
func TestService_Record_DuplicateMarksDeduped(t *testing.T) {
t.Parallel()
store := &fakeStore{}
inc := &fakeIncrementer{}
rdb, mr := setupRedis(t)
notifier := &fakeNotifier{}
svc := newSvc(t, store, inc, rdb, notifier)
require.NoError(
t,
svc.Record(
context.Background(),
sampleInfo(),
sampleEvent("203.0.113.1"),
),
)
require.NoError(
t,
svc.Record(
context.Background(),
sampleInfo(),
sampleEvent("203.0.113.1"),
),
)
require.Equal(t, 1, notifier.callCount(), "duplicate must not notify")
inserted, statusUpdates := store.snapshot()
require.Len(t, inserted, 2, "both events still recorded")
require.Len(t, statusUpdates, 1, "duplicate writes deduped status")
require.Equal(t, event.NotifyDeduped, statusUpdates[0].status)
require.Equal(t, inserted[1].ID, statusUpdates[0].id)
dedupKey := "dedup:trigger:" + testTokenID + ":203.0.113.1"
val, err := mr.Get(dedupKey)
require.NoError(t, err)
require.Equal(t, "2", val, "INCR bumps counter")
}
func TestService_Record_DifferentIPsBothNotify(t *testing.T) {
t.Parallel()
store := &fakeStore{}
inc := &fakeIncrementer{}
rdb, _ := setupRedis(t)
notifier := &fakeNotifier{}
svc := newSvc(t, store, inc, rdb, notifier)
require.NoError(
t,
svc.Record(
context.Background(),
sampleInfo(),
sampleEvent("203.0.113.1"),
),
)
require.NoError(
t,
svc.Record(
context.Background(),
sampleInfo(),
sampleEvent("203.0.113.2"),
),
)
require.Equal(t, 2, notifier.callCount(), "different IPs each notify")
}
func TestService_Record_DedupTTLExpiry(t *testing.T) {
t.Parallel()
store := &fakeStore{}
inc := &fakeIncrementer{}
rdb, mr := setupRedis(t)
notifier := &fakeNotifier{}
svc := event.NewService(store, inc, rdb, notifier, event.ServiceConfig{
DedupTTL: 1 * time.Second,
Logger: slog.New(slog.NewTextHandler(testWriter{t}, nil)),
})
require.NoError(
t,
svc.Record(
context.Background(),
sampleInfo(),
sampleEvent("203.0.113.1"),
),
)
mr.FastForward(2 * time.Second)
require.NoError(
t,
svc.Record(
context.Background(),
sampleInfo(),
sampleEvent("203.0.113.1"),
),
)
require.Equal(
t,
2,
notifier.callCount(),
"after TTL expiry second trigger notifies again",
)
}
func TestService_Record_DedupKeyShape(t *testing.T) {
t.Parallel()
store := &fakeStore{}
inc := &fakeIncrementer{}
rdb, mr := setupRedis(t)
svc := newSvc(t, store, inc, rdb, nil)
require.NoError(
t,
svc.Record(
context.Background(),
sampleInfo(),
sampleEvent("203.0.113.99"),
),
)
keys := mr.Keys()
require.Contains(t, keys, "dedup:trigger:"+testTokenID+":203.0.113.99")
}
func TestService_Record_RedisDownFailsOpen(t *testing.T) {
t.Parallel()
store := &fakeStore{}
inc := &fakeIncrementer{}
rdb, mr := setupRedis(t)
notifier := &fakeNotifier{}
svc := newSvc(t, store, inc, rdb, notifier)
mr.Close()
require.NoError(
t,
svc.Record(
context.Background(),
sampleInfo(),
sampleEvent("203.0.113.1"),
),
)
require.Equal(t, 1, notifier.callCount(),
"redis down → fail open → still notify so we don't miss alerts")
}
func TestService_Record_InsertErrorReturns(t *testing.T) {
t.Parallel()
store := &fakeStore{insertErr: errors.New("db down")}
inc := &fakeIncrementer{}
rdb, _ := setupRedis(t)
notifier := &fakeNotifier{}
svc := newSvc(t, store, inc, rdb, notifier)
err := svc.Record(
context.Background(),
sampleInfo(),
sampleEvent("203.0.113.1"),
)
require.Error(t, err)
require.Equal(
t,
0,
notifier.callCount(),
"insert failure prevents notify — we don't have an event id to write back to",
)
}
func TestService_Record_IncrementErrorDoesNotPropagate(t *testing.T) {
t.Parallel()
store := &fakeStore{}
inc := &fakeIncrementer{err: errors.New("update failed")}
rdb, _ := setupRedis(t)
notifier := &fakeNotifier{}
svc := newSvc(t, store, inc, rdb, notifier)
require.NoError(
t,
svc.Record(
context.Background(),
sampleInfo(),
sampleEvent("203.0.113.1"),
),
"increment failure is best-effort; record should still succeed",
)
require.Equal(t, 1, notifier.callCount())
}
func TestService_Record_NilNotifierNoCrash(t *testing.T) {
t.Parallel()
store := &fakeStore{}
inc := &fakeIncrementer{}
rdb, _ := setupRedis(t)
svc := newSvc(t, store, inc, rdb, nil)
require.NotPanics(t, func() {
if err := svc.Record(
context.Background(),
sampleInfo(),
sampleEvent("203.0.113.1"),
); err != nil {
t.Logf("record: %v", err)
}
})
}
func TestService_Record_ConcurrentSafe(t *testing.T) {
t.Parallel()
store := &fakeStore{}
inc := &fakeIncrementer{}
rdb, _ := setupRedis(t)
notifier := &fakeNotifier{}
svc := newSvc(t, store, inc, rdb, notifier)
const n = 20
var wg sync.WaitGroup
var notified atomic.Int32
for i := range n {
wg.Add(1)
go func(i int) {
defer wg.Done()
ip := "203.0.113." + strconv.Itoa(i+1)
if err := svc.Record(
context.Background(),
sampleInfo(),
sampleEvent(ip),
); err == nil {
notified.Add(1)
}
}(i)
}
wg.Wait()
require.Equal(t, int32(n), notified.Load())
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), n,
"two distinct IPs were silenced (203.0.113.1 and 203.0.113.2)")
}
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(1), n,
"only this token's silenced IPs counted (one distinct IP)")
}
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}
rdb, _ := setupRedis(t)
svc := newSvc(t, store, &fakeIncrementer{}, rdb, nil)
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() {
svc.RunRetentionLoop(ctx, 25*time.Millisecond, 100)
close(done)
}()
require.Eventually(t, func() bool {
store.mu.Lock()
defer store.mu.Unlock()
return store.pruneLastLimit == 100
}, 1*time.Second, 5*time.Millisecond)
cancel()
<-done
}
func TestService_RunRetentionLoop_StopsOnContextCancel(t *testing.T) {
t.Parallel()
store := &fakeStore{}
rdb, _ := setupRedis(t)
svc := newSvc(t, store, &fakeIncrementer{}, rdb, nil)
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() {
svc.RunRetentionLoop(ctx, 10*time.Millisecond, 50)
close(done)
}()
time.Sleep(20 * time.Millisecond)
cancel()
select {
case <-done:
case <-time.After(500 * time.Millisecond):
t.Fatal("retention loop did not stop on cancel")
}
}
func TestService_RunRetentionLoop_ContinuesOnPruneError(t *testing.T) {
t.Parallel()
store := &fakeStore{pruneErr: errors.New("db down")}
rdb, _ := setupRedis(t)
svc := newSvc(t, store, &fakeIncrementer{}, rdb, nil)
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() {
svc.RunRetentionLoop(ctx, 10*time.Millisecond, 50)
close(done)
}()
time.Sleep(60 * time.Millisecond)
cancel()
<-done
}
func TestService_RunRetentionLoop_DisabledOnInvalidConfig(t *testing.T) {
t.Parallel()
store := &fakeStore{}
rdb, _ := setupRedis(t)
svc := newSvc(t, store, &fakeIncrementer{}, rdb, nil)
ctx := context.Background()
done := make(chan struct{})
go func() {
svc.RunRetentionLoop(ctx, 0, 100)
close(done)
}()
select {
case <-done:
case <-time.After(500 * time.Millisecond):
t.Fatal(
"retention loop should have returned immediately on invalid interval",
)
}
require.Equal(t, 0, store.pruneLastLimit)
}
func TestService_Record_EnrichesGeoBeforeInsert(t *testing.T) {
t.Parallel()
store := &fakeStore{}
inc := &fakeIncrementer{}
rdb, _ := setupRedis(t)
geo := &fakeLookuper{result: geoip.Lookup{
Country: "US", Region: "California", City: "Mountain View",
ASN: 15169, ASNOrg: "Google LLC",
}}
svc := newSvcWithGeo(t, store, inc, rdb, geo)
evt := sampleEvent("203.0.113.1")
require.NoError(t, svc.Record(context.Background(), sampleInfo(), evt))
require.Equal(t, []string{"203.0.113.1"}, geo.calls(),
"Record must invoke Lookup exactly once with the event's source IP")
inserted, _ := store.snapshot()
require.Len(t, inserted, 1)
got := inserted[0]
require.NotNil(t, got.GeoCountry)
require.Equal(t, "US", *got.GeoCountry)
require.NotNil(t, got.GeoCity)
require.Equal(t, "Mountain View", *got.GeoCity)
require.NotNil(t, got.GeoASN)
require.Equal(t, 15169, *got.GeoASN)
}
func TestService_Record_NoGeoConfigured_LeavesGeoNil(t *testing.T) {
t.Parallel()
store := &fakeStore{}
inc := &fakeIncrementer{}
rdb, _ := setupRedis(t)
svc := newSvc(t, store, inc, rdb, nil)
evt := sampleEvent("203.0.113.1")
require.NoError(t, svc.Record(context.Background(), sampleInfo(), evt))
inserted, _ := store.snapshot()
require.Len(t, inserted, 1)
require.Nil(t, inserted[0].GeoCountry)
require.Nil(t, inserted[0].GeoCity)
require.Nil(t, inserted[0].GeoASN)
}
func TestService_Record_EmptySourceIP_SkipsLookup(t *testing.T) {
t.Parallel()
store := &fakeStore{}
inc := &fakeIncrementer{}
rdb, _ := setupRedis(t)
geo := &fakeLookuper{result: geoip.Lookup{Country: "ZZ"}}
svc := newSvcWithGeo(t, store, inc, rdb, geo)
evt := &event.Event{TokenID: testTokenID}
require.NoError(t, svc.Record(context.Background(), sampleInfo(), evt))
require.Empty(t, geo.calls(),
"empty source IP must short-circuit the geo lookup "+
"(no useful enrichment possible)")
inserted, _ := store.snapshot()
require.Nil(t, inserted[0].GeoCountry)
}
func TestService_Record_NopLookuper_LeavesGeoNil(t *testing.T) {
t.Parallel()
store := &fakeStore{}
inc := &fakeIncrementer{}
rdb, _ := setupRedis(t)
svc := newSvcWithGeo(t, store, inc, rdb, geoip.NopService())
evt := sampleEvent("203.0.113.1")
require.NoError(t, svc.Record(context.Background(), sampleInfo(), evt))
inserted, _ := store.snapshot()
require.Nil(t, inserted[0].GeoCountry,
"NopService returns empty Lookup; AttachGeoIP leaves all fields nil")
}
func TestService_Record_GeoEnrichmentBestEffort_InsertErrorBubbles(
t *testing.T,
) {
t.Parallel()
store := &fakeStore{insertErr: errors.New("db down")}
inc := &fakeIncrementer{}
rdb, _ := setupRedis(t)
geo := &fakeLookuper{result: geoip.Lookup{Country: "US"}}
svc := newSvcWithGeo(t, store, inc, rdb, geo)
err := svc.Record(context.Background(), sampleInfo(),
sampleEvent("203.0.113.1"))
require.Error(t, err, "insert error path is unchanged by geo enrichment")
require.Equal(t, []string{"203.0.113.1"}, geo.calls(),
"enrichment runs even when insert later fails "+
"(no upstream side-effect)")
}