From b3fd75b6802cd3f0db32f5f0fd409f6a078a08c4 Mon Sep 17 00:00:00 2001 From: CarterPerez-dev Date: Sun, 17 May 2026 05:24:17 -0400 Subject: [PATCH] feat(canary): Event.AttachGeoIP + event.Service geo enrichment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - entity: (*Event).AttachGeoIP(geoip.Lookup) sets Geo* nullable pointer fields with overwrite semantics (empty/zero collapses to nil so DB column stays NULL). Single-arg signature matches the call shape in design spec §9 (line 1437) and keeps the helper symmetric with the geoip.Lookup value type. - service: ServiceConfig.GeoIP geoip.Lookuper plumbed through to the Service.geo field. Record now calls enrichGeo(evt) BEFORE s.repo.Insert so geo_country/region/city/asn/asn_org persist in the same row. enrichGeo is nil-safe on three axes (geo, evt, evt.SourceIP) — geo enrichment is best-effort, never an error path. - service_test: fakeLookuper + newSvcWithGeo helper; five new tests (enriches-before-insert, no-geo-leaves-nil, empty-IP- skips-lookup, NopService-leaves-nil, best-effort-when-insert- fails). All pre-existing tests pass unchanged because GeoIP defaults to nil. - entity_test: seven AttachGeoIP unit tests covering populate-all, empty Lookup, partial fields, zero/negative ASN guards, overwrite semantics, and ASN-pointer independence from the input value. - integration_test: two real-Postgres tests (stub Lookuper writes populated geo_* columns; NopService leaves them NULL) verify the wiring round-trips through the Insert SQL. --- .../backend/internal/event/entity.go | 22 +++ .../backend/internal/event/entity_test.go | 115 +++++++++++++++ .../internal/event/integration_test.go | 118 ++++++++++++++++ .../backend/internal/event/service.go | 14 ++ .../backend/internal/event/service_test.go | 133 ++++++++++++++++++ 5 files changed, 402 insertions(+) create mode 100644 PROJECTS/beginner/canary-token-generator/backend/internal/event/entity_test.go diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/event/entity.go b/PROJECTS/beginner/canary-token-generator/backend/internal/event/entity.go index 990cc96d..15ea6c58 100644 --- a/PROJECTS/beginner/canary-token-generator/backend/internal/event/entity.go +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/event/entity.go @@ -6,6 +6,8 @@ package event import ( "encoding/json" "time" + + "github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/geoip" ) type NotifyStatus string @@ -41,3 +43,23 @@ type Event struct { NotifyStatus NotifyStatus `db:"notify_status" json:"notify_status"` NotifiedAt *time.Time `db:"notified_at" json:"notified_at"` } + +func (e *Event) AttachGeoIP(l geoip.Lookup) { + e.GeoCountry = nonEmptyPtr(l.Country) + e.GeoRegion = nonEmptyPtr(l.Region) + e.GeoCity = nonEmptyPtr(l.City) + e.GeoASNOrg = nonEmptyPtr(l.ASNOrg) + if l.ASN > 0 { + asn := l.ASN + e.GeoASN = &asn + return + } + e.GeoASN = nil +} + +func nonEmptyPtr(s string) *string { + if s == "" { + return nil + } + return &s +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/event/entity_test.go b/PROJECTS/beginner/canary-token-generator/backend/internal/event/entity_test.go new file mode 100644 index 00000000..a1abbd08 --- /dev/null +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/event/entity_test.go @@ -0,0 +1,115 @@ +// ©AngelaMos | 2026 +// entity_test.go + +package event_test + +import ( + "testing" + + "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" +) + +func TestEvent_AttachGeoIP_PopulatesAllFields(t *testing.T) { + t.Parallel() + e := &event.Event{} + e.AttachGeoIP(geoip.Lookup{ + Country: "US", + Region: "California", + City: "Mountain View", + ASN: 15169, + ASNOrg: "Google LLC", + }) + + require.NotNil(t, e.GeoCountry) + require.Equal(t, "US", *e.GeoCountry) + require.NotNil(t, e.GeoRegion) + require.Equal(t, "California", *e.GeoRegion) + require.NotNil(t, e.GeoCity) + require.Equal(t, "Mountain View", *e.GeoCity) + require.NotNil(t, e.GeoASN) + require.Equal(t, 15169, *e.GeoASN) + require.NotNil(t, e.GeoASNOrg) + require.Equal(t, "Google LLC", *e.GeoASNOrg) +} + +func TestEvent_AttachGeoIP_EmptyLookupLeavesAllNil(t *testing.T) { + t.Parallel() + e := &event.Event{} + e.AttachGeoIP(geoip.Lookup{}) + + require.Nil(t, e.GeoCountry) + require.Nil(t, e.GeoRegion) + require.Nil(t, e.GeoCity) + require.Nil(t, e.GeoASN) + require.Nil(t, e.GeoASNOrg) +} + +func TestEvent_AttachGeoIP_PartialFieldsPopulated(t *testing.T) { + t.Parallel() + e := &event.Event{} + e.AttachGeoIP(geoip.Lookup{Country: "JP", City: "Tokyo"}) + + require.NotNil(t, e.GeoCountry) + require.Equal(t, "JP", *e.GeoCountry) + require.Nil(t, e.GeoRegion) + require.NotNil(t, e.GeoCity) + require.Equal(t, "Tokyo", *e.GeoCity) + require.Nil(t, e.GeoASN) + require.Nil(t, e.GeoASNOrg) +} + +func TestEvent_AttachGeoIP_ZeroASNStaysNil(t *testing.T) { + t.Parallel() + e := &event.Event{} + e.AttachGeoIP(geoip.Lookup{Country: "FR", ASN: 0, ASNOrg: ""}) + require.Nil(t, e.GeoASN, + "ASN=0 (sentinel for missing) must not produce a pointer") +} + +func TestEvent_AttachGeoIP_NegativeASNStaysNil(t *testing.T) { + t.Parallel() + e := &event.Event{} + e.AttachGeoIP(geoip.Lookup{ASN: -1}) + require.Nil(t, e.GeoASN, + "defensive: negative ASN is invalid and must not produce a pointer") +} + +func TestEvent_AttachGeoIP_OverwritesPriorValues(t *testing.T) { + t.Parallel() + prior := "OLD" + priorASN := 99 + e := &event.Event{ + GeoCountry: &prior, + GeoRegion: &prior, + GeoCity: &prior, + GeoASNOrg: &prior, + GeoASN: &priorASN, + } + e.AttachGeoIP(geoip.Lookup{Country: "DE", City: "Berlin"}) + + require.NotNil(t, e.GeoCountry) + require.Equal(t, "DE", *e.GeoCountry) + require.Nil(t, e.GeoRegion, + "AttachGeoIP must overwrite a prior value with nil when "+ + "the new lookup is empty") + require.NotNil(t, e.GeoCity) + require.Equal(t, "Berlin", *e.GeoCity) + require.Nil(t, e.GeoASN) + require.Nil(t, e.GeoASNOrg) +} + +func TestEvent_AttachGeoIP_AddressableASNPointer(t *testing.T) { + t.Parallel() + e := &event.Event{} + e.AttachGeoIP(geoip.Lookup{ASN: 64512}) + require.NotNil(t, e.GeoASN) + require.Equal(t, 64512, *e.GeoASN) + + *e.GeoASN = 1 + require.Equal(t, 1, *e.GeoASN, + "ASN pointer must be independently mutable, "+ + "not aliasing the input Lookup") +} diff --git a/PROJECTS/beginner/canary-token-generator/backend/internal/event/integration_test.go b/PROJECTS/beginner/canary-token-generator/backend/internal/event/integration_test.go index d145bd3c..25be1ffc 100644 --- a/PROJECTS/beginner/canary-token-generator/backend/internal/event/integration_test.go +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/event/integration_test.go @@ -24,6 +24,7 @@ import ( "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" "github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/middleware" "github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/notify" "github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/testutil" @@ -555,3 +556,120 @@ func TestIntegration_RetentionLoopPrunes(t *testing.T) { cancel() <-done } + +type stubLookuper struct{ result geoip.Lookup } + +func (s stubLookuper) Lookup(string) geoip.Lookup { return s.result } + +func TestIntegration_GeoEnrichmentPopulatesColumns(t *testing.T) { + t.Parallel() + db := sqlx.NewDb(testutil.NewTestDB(t), "pgx") + mr, err := miniredis.Run() + require.NoError(t, err) + t.Cleanup(mr.Close) + rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()}) + t.Cleanup(func() { _ = rdb.Close() }) + + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + tokenRepo := token.NewRepository(db) + eventRepo := event.NewRepository(db) + + geo := stubLookuper{result: geoip.Lookup{ + Country: "US", + Region: "California", + City: "Mountain View", + ASN: 15169, + ASNOrg: "Google LLC", + }} + + eventSvc := event.NewService(eventRepo, tokenRepo, rdb, nil, event.ServiceConfig{ + DedupTTL: 15 * time.Minute, + Logger: logger, + GeoIP: geo, + }) + + tok := &token.Token{ + ID: "intggeo00001", + ManageID: "33333333-3333-3333-3333-333333333333", + Type: token.TypeWebbug, + Memo: "geo-test", + AlertChannel: token.ChannelTelegram, + TelegramBot: testutil.Ptr("111:AAA"), + TelegramChat: testutil.Ptr("12345"), + CreatedIP: "203.0.113.1", + CreatedFP: "fp", + Metadata: json.RawMessage(`{}`), + Enabled: true, + } + require.NoError(t, tokenRepo.Insert(context.Background(), tok)) + + evt := &event.Event{TokenID: tok.ID, SourceIP: "203.0.113.99"} + require.NoError(t, eventSvc.Record(context.Background(), tok.NotifyInfo(), evt)) + + list, err := eventRepo.ListByToken(context.Background(), tok.ID, + event.ListOptions{Limit: 1}) + require.NoError(t, err) + require.Len(t, list.Events, 1) + + row := list.Events[0] + require.NotNil(t, row.GeoCountry) + require.Equal(t, "US", *row.GeoCountry) + require.NotNil(t, row.GeoRegion) + require.Equal(t, "California", *row.GeoRegion) + require.NotNil(t, row.GeoCity) + require.Equal(t, "Mountain View", *row.GeoCity) + require.NotNil(t, row.GeoASN) + require.Equal(t, 15169, *row.GeoASN) + require.NotNil(t, row.GeoASNOrg) + require.Equal(t, "Google LLC", *row.GeoASNOrg) +} + +func TestIntegration_GeoEnrichmentLeavesColumnsNullWhenLookupEmpty(t *testing.T) { + t.Parallel() + db := sqlx.NewDb(testutil.NewTestDB(t), "pgx") + mr, err := miniredis.Run() + require.NoError(t, err) + t.Cleanup(mr.Close) + rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()}) + t.Cleanup(func() { _ = rdb.Close() }) + + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + tokenRepo := token.NewRepository(db) + eventRepo := event.NewRepository(db) + + eventSvc := event.NewService(eventRepo, tokenRepo, rdb, nil, event.ServiceConfig{ + DedupTTL: 15 * time.Minute, + Logger: logger, + GeoIP: geoip.NopService(), + }) + + tok := &token.Token{ + ID: "intggeo00002", + ManageID: "44444444-4444-4444-4444-444444444444", + Type: token.TypeWebbug, + Memo: "geo-nop", + AlertChannel: token.ChannelTelegram, + TelegramBot: testutil.Ptr("111:AAA"), + TelegramChat: testutil.Ptr("12345"), + CreatedIP: "203.0.113.1", + CreatedFP: "fp", + Metadata: json.RawMessage(`{}`), + Enabled: true, + } + require.NoError(t, tokenRepo.Insert(context.Background(), tok)) + + evt := &event.Event{TokenID: tok.ID, SourceIP: "203.0.113.42"} + require.NoError(t, eventSvc.Record(context.Background(), tok.NotifyInfo(), evt)) + + list, err := eventRepo.ListByToken(context.Background(), tok.ID, + event.ListOptions{Limit: 1}) + require.NoError(t, err) + require.Len(t, list.Events, 1) + + row := list.Events[0] + require.Nil(t, row.GeoCountry) + require.Nil(t, row.GeoRegion) + require.Nil(t, row.GeoCity) + require.Nil(t, row.GeoASN) + require.Nil(t, row.GeoASNOrg) +} 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 d1b4dd64..5bd64765 100644 --- a/PROJECTS/beginner/canary-token-generator/backend/internal/event/service.go +++ b/PROJECTS/beginner/canary-token-generator/backend/internal/event/service.go @@ -12,6 +12,8 @@ import ( "time" "github.com/redis/go-redis/v9" + + "github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/geoip" ) const ( @@ -25,6 +27,7 @@ type Service struct { tokens TokenIncrementer rdb *redis.Client notifier Notifier + geo geoip.Lookuper dedupTTL time.Duration logger *slog.Logger } @@ -32,6 +35,7 @@ type Service struct { type ServiceConfig struct { DedupTTL time.Duration Logger *slog.Logger + GeoIP geoip.Lookuper } func NewService( @@ -52,6 +56,7 @@ func NewService( tokens: tokens, rdb: rdb, notifier: notifier, + geo: cfg.GeoIP, dedupTTL: cfg.DedupTTL, logger: cfg.Logger, } @@ -66,6 +71,8 @@ func (s *Service) Record( info NotifyInfo, evt *Event, ) error { + s.enrichGeo(evt) + if err := s.repo.Insert(ctx, evt); err != nil { return fmt.Errorf("insert event: %w", err) } @@ -97,6 +104,13 @@ func (s *Service) Record( return nil } +func (s *Service) enrichGeo(evt *Event) { + if s.geo == nil || evt == nil || evt.SourceIP == "" { + return + } + evt.AttachGeoIP(s.geo.Lookup(evt.SourceIP)) +} + func (s *Service) dedupGate( ctx context.Context, tokenID, sourceIP string, 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 cd2604d3..1202edd3 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 @@ -18,6 +18,7 @@ import ( "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" @@ -116,6 +117,27 @@ func (f *fakeIncrementer) callCount() int { 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 @@ -182,6 +204,21 @@ func newSvc( }) } +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( @@ -686,3 +723,99 @@ func TestService_RunRetentionLoop_DisabledOnInvalidConfig(t *testing.T) { 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)") +}