feat(canary): Event.AttachGeoIP + event.Service geo enrichment

- 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.
This commit is contained in:
CarterPerez-dev 2026-05-17 05:24:17 -04:00
parent ac334b14f9
commit b3fd75b680
5 changed files with 402 additions and 0 deletions

View File

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

View File

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

View File

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

View File

@ -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,

View File

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