package lookup import ( "context" "log/slog" "net/netip" "net/url" "os" "path/filepath" "reflect" "testing" "testing/synctest" "time" "github.com/fsnotify/fsnotify" "github.com/maxmind/mmdbwriter/mmdbtype" "sneak.berlin/go/smallwebwaf/internal/alerts" "sneak.berlin/go/smallwebwaf/internal/lookup/lookuptest" ) // testNetblock is the netblock the tests' lookup databases place, and // testClient a client in it. const ( testNetblock = "203.0.113.0/24" testClient = "203.0.113.9/32" ) func TestFilePlacesClientsAndCountsAnAddressMissingFromItAsUnknown(t *testing.T) { t.Parallel() germany := lookuptest.Network{ASN: "AS64496", ASName: "Example Net", Country: "DE"} northKorea := lookuptest.Network{ASN: "AS64511", ASName: "Other Net", Country: "KP"} path := filepath.Join(t.TempDir(), "ipinfo_lite.mmdb") lookuptest.Write(t, path, map[string]lookuptest.Network{ testNetblock: germany, "2001:db8::/32": northKorea, }) now := time.Date(2026, 10, 7, 0, 0, 0, 0, time.UTC) f, err := OpenFile(FileParams{ Path: path, Now: func() time.Time { return now }, ProcessLog: slog.New(slog.DiscardHandler), Alerts: newQueue(), }) if err != nil { t.Fatalf("open %s: %v", path, err) } for client, want := range map[string]lookuptest.Network{ testClient: germany, // An IPv6 client is its /64. "2001:db8:1:2::/64": northKorea, "198.51.100.7/32": {}, } { prefix := netip.MustParsePrefix(client) got := f.LookUp(prefix) if got != (Answer{ Client: prefix, ASN: want.ASN, ASName: want.ASName, Country: want.Country, Answered: now, }) { t.Errorf("%s has the answer %+v, want %+v, answered %s", client, got, want, now) } } } func TestRecordThatCannotBeReadPlacesTheClientNowhere(t *testing.T) { t.Parallel() // The AS number is a number, where a string belongs. The writer writes // a record's fields in the order of their names, so as_name is read // before the AS number fails. path := filepath.Join(t.TempDir(), "ipinfo_lite.mmdb") lookuptest.WriteRecords(t, path, map[string]mmdbtype.Map{ testNetblock: { "asn": mmdbtype.Uint32(64496), "as_name": mmdbtype.String("Example Net"), "country_code": mmdbtype.String("DE"), }, }) f := openFile(t, path, newQueue()) answer := f.LookUp(netip.MustParsePrefix(testClient)) if answer.ASN != "" || answer.ASName != "" || answer.Country != "" { t.Errorf("%s is placed %+v, want nowhere", testClient, answer) } } func TestFileThatCannotBeReadIsAnError(t *testing.T) { t.Parallel() dir := t.TempDir() missing := filepath.Join(dir, "missing.mmdb") notDatabase := filepath.Join(dir, "not.mmdb") writeFile(t, notDatabase, "not a lookup database\n") for path, want := range map[string]string{ missing: "SWWAF_LOOKUP_DB_PATH cannot be read: open " + missing + ": no such file or directory", notDatabase: "SWWAF_LOOKUP_DB_PATH " + notDatabase + " is not a .mmdb file: error opening database: invalid MaxMind DB file", } { _, err := OpenFile(FileParams{ Path: path, Now: time.Now, ProcessLog: slog.New(slog.DiscardHandler), Alerts: newQueue(), }) if err == nil || err.Error() != want { t.Errorf("opening %s failed with %v, want %s", path, err, want) } } } // The tests below run readAfterChanges in a synctest bubble, where time is // a clock of the test's own: time.Sleep moves it on at once, and // synctest.Wait returns once readAfterChanges waits again, so that every // reading due by then is done. The test sends the changes itself, as the // watch of a directory cannot run in a bubble. func TestReplacementCopiedOverTheFileInTwoPartsIsReadOnlyWhole(t *testing.T) { t.Parallel() synctest.Test(t, func(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "ipinfo_lite.mmdb") writeDatabase(t, path, "DE") queue := newQueue() f := openFile(t, path, queue) changes := watch(t, f) other := filepath.Join(dir, "replacement.mmdb") writeDatabase(t, other, "KP") replacement, err := os.ReadFile(other) //nolint:gosec // a file the test wrote if err != nil { t.Fatalf("read %s: %v", other, err) } // The file in use is overwritten in place, and keeps giving what // it gave. Its first part alone is not a .mmdb file. file, err := os.Create(path) //nolint:gosec // a file the test wrote if err != nil { t.Fatalf("create %s: %v", path, err) } defer func() { _ = file.Close() }() half := len(replacement) / 2 write(t, file, replacement[:half]) changes <- fsnotify.Event{Name: path, Op: fsnotify.Write} time.Sleep(quietTime - time.Nanosecond) synctest.Wait() wantCountry(t, f, "DE") // The second part starts the wait again. write(t, file, replacement[half:]) changes <- fsnotify.Event{Name: path, Op: fsnotify.Write} time.Sleep(quietTime - time.Nanosecond) synctest.Wait() wantCountry(t, f, "DE") time.Sleep(time.Nanosecond) synctest.Wait() wantCountry(t, f, "KP") if !f.LastRead().Equal(time.Now()) || f.ReadFailures() != 0 { t.Errorf("read at %s, with %d failures; want read now, with none", f.LastRead(), f.ReadFailures()) } wantAlerts(t, queue) }) } func TestReplacementThatCannotBeReadLeavesTheFileInUseWithOneAlert(t *testing.T) { t.Parallel() synctest.Test(t, func(t *testing.T) { path := filepath.Join(t.TempDir(), "ipinfo_lite.mmdb") writeDatabase(t, path, "DE") queue := newQueue() f := openFile(t, path, queue) read := f.LastRead() changes := watch(t, f) writeFile(t, path, "not a lookup database\n") changes <- fsnotify.Event{Name: path, Op: fsnotify.Write} // Long after, the replacement has been read once. time.Sleep(time.Hour) synctest.Wait() wantCountry(t, f, "DE") if !f.LastRead().Equal(read) || f.ReadFailures() != 1 { t.Errorf("read at %s, with %d failures; want read at %s, with one", f.LastRead(), f.ReadFailures(), read) } wantAlerts(t, queue, alerts.Alert{ Time: read.Add(quietTime), Event: alerts.EventFileError, Reason: "the lookup database cannot be read, and the one read before stays in use", Detail: map[string]any{ "file": path, "error": "SWWAF_LOOKUP_DB_PATH " + path + " is not a .mmdb file: " + "error opening database: invalid MaxMind DB file", }, }) }) } func TestChangeOfAnotherFileInTheDirectoryIsNoReplacement(t *testing.T) { t.Parallel() synctest.Test(t, func(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "ipinfo_lite.mmdb") writeDatabase(t, path, "DE") f := openFile(t, path, newQueue()) changes := watch(t, f) // The wait that starts with the watch ends with a reading. time.Sleep(quietTime) synctest.Wait() writeDatabase(t, path, "KP") changes <- fsnotify.Event{Name: filepath.Join(dir, "other.mmdb"), Op: fsnotify.Create} time.Sleep(quietTime) synctest.Wait() wantCountry(t, f, "DE") changes <- fsnotify.Event{Name: path, Op: fsnotify.Write} time.Sleep(quietTime) synctest.Wait() wantCountry(t, f, "KP") }) } func TestReplacementSavedBeforeTheWatchStartsIsRead(t *testing.T) { t.Parallel() synctest.Test(t, func(t *testing.T) { path := filepath.Join(t.TempDir(), "ipinfo_lite.mmdb") writeDatabase(t, path, "DE") f := openFile(t, path, newQueue()) // Saved after OpenFile read the file, and before its directory was // watched, so that no change is seen for it. writeDatabase(t, path, "KP") watch(t, f) time.Sleep(quietTime) synctest.Wait() wantCountry(t, f, "KP") }) } // newQueue returns a queue of alerts for a webhook that is never sent // them, so that they wait in it for the test to look at. func newQueue() *alerts.Queue { return alerts.New(alerts.Params{ WebhookURL: &url.URL{Scheme: "https", Host: "alerts.example"}, Events: alerts.Events(), Cooldown: 15 * time.Minute, Now: time.Now, }) } // writeDatabase writes a lookup database at path that places testNetblock // in country, and no other address. func writeDatabase(t *testing.T, path, country string) { t.Helper() lookuptest.Write(t, path, map[string]lookuptest.Network{ testNetblock: {ASN: "AS64496", ASName: "Example Net", Country: country}, }) } // openFile opens the lookup database at path, which raises its alerts to // queue. func openFile(t *testing.T, path string, queue *alerts.Queue) *File { t.Helper() f, err := OpenFile(FileParams{ Path: path, Now: time.Now, ProcessLog: slog.New(slog.DiscardHandler), Alerts: queue, }) if err != nil { t.Fatalf("open %s: %v", path, err) } return f } // watch runs f's readAfterChanges until the test ends, and returns the // channel that sends it changes. func watch(t *testing.T, f *File) chan<- fsnotify.Event { t.Helper() changes := make(chan fsnotify.Event) ctx, stop := context.WithCancel(t.Context()) stopped := make(chan struct{}) go func() { f.readAfterChanges(ctx, changes, nil) close(stopped) }() t.Cleanup(func() { stop() <-stopped }) return changes } // wantCountry checks the country f gives testClient. func wantCountry(t *testing.T, f *File, want string) { t.Helper() got := f.LookUp(netip.MustParsePrefix(testClient)).Country if got != want { t.Errorf("%s is in %q, want %q", testClient, got, want) } } // wantAlerts checks the alerts waiting in queue, and that it held none // back. func wantAlerts(t *testing.T, queue *alerts.Queue, want ...alerts.Alert) { t.Helper() waiting := queue.Snapshot().Waiting[alerts.DestinationWebhook] if len(waiting) != len(want) || (len(want) > 0 && !reflect.DeepEqual(waiting, want)) { t.Errorf("alerts waiting %+v, want %+v", waiting, want) } if queue.Suppressed() != 0 { t.Errorf("%d alerts held back, want none", queue.Suppressed()) } } // writeFile writes content to the file at path. func writeFile(t *testing.T, path, content string) { t.Helper() err := os.WriteFile(path, []byte(content), 0o600) if err != nil { t.Fatalf("write %s: %v", path, err) } } // write writes data to the end of file. func write(t *testing.T, file *os.File, data []byte) { t.Helper() _, err := file.Write(data) if err != nil { t.Fatalf("write: %v", err) } }