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:
parent
ac334b14f9
commit
b3fd75b680
|
|
@ -6,6 +6,8 @@ package event
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/geoip"
|
||||||
)
|
)
|
||||||
|
|
||||||
type NotifyStatus string
|
type NotifyStatus string
|
||||||
|
|
@ -41,3 +43,23 @@ type Event struct {
|
||||||
NotifyStatus NotifyStatus `db:"notify_status" json:"notify_status"`
|
NotifyStatus NotifyStatus `db:"notify_status" json:"notify_status"`
|
||||||
NotifiedAt *time.Time `db:"notified_at" json:"notified_at"`
|
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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
}
|
||||||
|
|
@ -24,6 +24,7 @@ import (
|
||||||
"github.com/stretchr/testify/require"
|
"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/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/middleware"
|
||||||
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/notify"
|
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/notify"
|
||||||
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/testutil"
|
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/testutil"
|
||||||
|
|
@ -555,3 +556,120 @@ func TestIntegration_RetentionLoopPrunes(t *testing.T) {
|
||||||
cancel()
|
cancel()
|
||||||
<-done
|
<-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)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,8 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/redis/go-redis/v9"
|
"github.com/redis/go-redis/v9"
|
||||||
|
|
||||||
|
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/geoip"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|
@ -25,6 +27,7 @@ type Service struct {
|
||||||
tokens TokenIncrementer
|
tokens TokenIncrementer
|
||||||
rdb *redis.Client
|
rdb *redis.Client
|
||||||
notifier Notifier
|
notifier Notifier
|
||||||
|
geo geoip.Lookuper
|
||||||
dedupTTL time.Duration
|
dedupTTL time.Duration
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
}
|
}
|
||||||
|
|
@ -32,6 +35,7 @@ type Service struct {
|
||||||
type ServiceConfig struct {
|
type ServiceConfig struct {
|
||||||
DedupTTL time.Duration
|
DedupTTL time.Duration
|
||||||
Logger *slog.Logger
|
Logger *slog.Logger
|
||||||
|
GeoIP geoip.Lookuper
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewService(
|
func NewService(
|
||||||
|
|
@ -52,6 +56,7 @@ func NewService(
|
||||||
tokens: tokens,
|
tokens: tokens,
|
||||||
rdb: rdb,
|
rdb: rdb,
|
||||||
notifier: notifier,
|
notifier: notifier,
|
||||||
|
geo: cfg.GeoIP,
|
||||||
dedupTTL: cfg.DedupTTL,
|
dedupTTL: cfg.DedupTTL,
|
||||||
logger: cfg.Logger,
|
logger: cfg.Logger,
|
||||||
}
|
}
|
||||||
|
|
@ -66,6 +71,8 @@ func (s *Service) Record(
|
||||||
info NotifyInfo,
|
info NotifyInfo,
|
||||||
evt *Event,
|
evt *Event,
|
||||||
) error {
|
) error {
|
||||||
|
s.enrichGeo(evt)
|
||||||
|
|
||||||
if err := s.repo.Insert(ctx, evt); err != nil {
|
if err := s.repo.Insert(ctx, evt); err != nil {
|
||||||
return fmt.Errorf("insert event: %w", err)
|
return fmt.Errorf("insert event: %w", err)
|
||||||
}
|
}
|
||||||
|
|
@ -97,6 +104,13 @@ func (s *Service) Record(
|
||||||
return nil
|
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(
|
func (s *Service) dedupGate(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
tokenID, sourceIP string,
|
tokenID, sourceIP string,
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import (
|
||||||
"github.com/stretchr/testify/require"
|
"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/event"
|
||||||
|
"github.com/CarterPerez-dev/cybersecurity-projects/canary-token-generator/backend/internal/geoip"
|
||||||
)
|
)
|
||||||
|
|
||||||
const testTokenID = "tokevtsvc001"
|
const testTokenID = "tokevtsvc001"
|
||||||
|
|
@ -116,6 +117,27 @@ func (f *fakeIncrementer) callCount() int {
|
||||||
return len(f.calls)
|
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 {
|
type fakeNotifier struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
calls []notifyCall
|
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 }
|
type testWriter struct{ t *testing.T }
|
||||||
|
|
||||||
func (w testWriter) Write(
|
func (w testWriter) Write(
|
||||||
|
|
@ -686,3 +723,99 @@ func TestService_RunRetentionLoop_DisabledOnInvalidConfig(t *testing.T) {
|
||||||
|
|
||||||
require.Equal(t, 0, store.pruneLastLimit)
|
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)")
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue