package blocklist import ( "context" "fmt" "log/slog" "net/http" "net/http/httptest" "os" "path/filepath" "sync/atomic" "testing" "time" "git.k8s-git-k6n.net/mats/fancy-dns/internal/config" ) type listFixture struct { t *testing.T srv *httptest.Server hits atomic.Int32 fail atomic.Bool body atomic.Value // string path string mgr *Manager cfgSrc *config.Store } func newListFixture(t *testing.T, refresh time.Duration) *listFixture { t.Helper() f := &listFixture{t: t, path: filepath.Join(t.TempDir(), "lists", "ads")} f.body.Store("0.0.0.0 first.example\n") f.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { f.hits.Add(1) if f.fail.Load() { http.Error(w, "down", http.StatusServiceUnavailable) return } fmt.Fprint(w, f.body.Load().(string)) })) t.Cleanup(f.srv.Close) cfg := config.Default() cfg.Block.Enabled = true cfg.Block.Lists = []config.ListSource{{ Name: "ads", Format: "hosts", URL: f.srv.URL, Path: f.path, Enabled: true, Refresh: config.Duration(refresh), MaxBytes: 1 << 20, Timeout: config.Duration(2 * time.Second), }} f.cfgSrc = config.NewStore(cfg, "") f.mgr = NewManager(ManagerOptions{Config: f.cfgSrc, Logger: slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError}))}) return f } func (f *listFixture) blocks(name string) bool { set := f.mgr.Holder().Get() return set != nil && set.Match(name) != nil } func (f *listFixture) age(d time.Duration) { f.t.Helper() old := time.Now().Add(-d) if err := os.Chtimes(f.path, old, old); err != nil { f.t.Fatal(err) } } // A restart should come up with the list from disk, not wait for the network: // the network may be this resolver's own cluster DNS, which is not up yet. func TestFreshCopyStartsWithoutTheNetwork(t *testing.T) { f := newListFixture(t, 24*time.Hour) if _, err := f.mgr.RefreshAll(context.Background()); err != nil { // first start: downloads and keeps a copy t.Fatal(err) } if f.hits.Load() != 1 || !f.blocks("first.example") { t.Fatalf("setup: hits=%d", f.hits.Load()) } f.fail.Store(true) again := NewManager(ManagerOptions{Config: f.cfgSrc, Logger: slog.Default()}) if _, err := again.RefreshAll(context.Background()); err != nil { t.Fatal(err) } if f.hits.Load() != 1 { t.Errorf("a fresh copy still went to the network (%d requests)", f.hits.Load()) } if set := again.Holder().Get(); set == nil || set.Match("first.example") == nil { t.Error("the restarted manager did not load the copy from disk") } } // A copy past its refresh interval must be replaced; treating any copy as // current froze a list at its first download for good. func TestStaleCopyIsDownloadedAgain(t *testing.T) { f := newListFixture(t, time.Hour) if _, err := f.mgr.RefreshAll(context.Background()); err != nil { t.Fatal(err) } f.body.Store("0.0.0.0 second.example\n") f.age(2 * time.Hour) if _, err := f.mgr.RefreshAll(context.Background()); err != nil { t.Fatal(err) } if f.hits.Load() != 2 || !f.blocks("second.example") || f.blocks("first.example") { t.Errorf("hits=%d second=%v first=%v: the stale copy was not replaced", f.hits.Load(), f.blocks("second.example"), f.blocks("first.example")) } if b, _ := os.ReadFile(f.path); string(b) != "0.0.0.0 second.example\n" { t.Errorf("the copy on disk was not updated: %q", b) } } // And when the download fails, the stale copy still stands in: a stale list // beats no list. func TestStaleCopyStandsInWhenTheDownloadFails(t *testing.T) { f := newListFixture(t, time.Hour) if _, err := f.mgr.RefreshAll(context.Background()); err != nil { t.Fatal(err) } f.age(2 * time.Hour) f.fail.Store(true) again := NewManager(ManagerOptions{Config: f.cfgSrc, Logger: slog.Default()}) if _, err := again.RefreshAll(context.Background()); err != nil { t.Fatalf("refresh with a stale copy and a dead server: %v", err) } if set := again.Holder().Get(); set == nil || set.Match("first.example") == nil { t.Error("the stale copy was not used") } } func TestForceRefreshDownloadsAFreshCopy(t *testing.T) { f := newListFixture(t, 24*time.Hour) if _, err := f.mgr.RefreshAll(context.Background()); err != nil { t.Fatal(err) } f.body.Store("0.0.0.0 second.example\n") if _, err := f.mgr.RefreshAll(context.Background()); err != nil || f.hits.Load() != 1 { t.Fatalf("a plain refresh of a fresh copy should not download: hits=%d err=%v", f.hits.Load(), err) } if _, err := f.mgr.ForceRefresh(context.Background()); err != nil { t.Fatal(err) } if f.hits.Load() != 2 || !f.blocks("second.example") { t.Errorf("a forced refresh did not download: hits=%d", f.hits.Load()) } } func TestNoCopyAndNoNetworkIsAnError(t *testing.T) { f := newListFixture(t, time.Hour) f.fail.Store(true) if _, err := f.mgr.RefreshAll(context.Background()); err == nil && f.blocks("first.example") { t.Fatal("blocked a name with no list data") } } // The source ages from its file, so the scheduled refresh is due when the copy // is, not a whole interval after a restart. func TestSourceAgeFollowsTheCopy(t *testing.T) { f := newListFixture(t, 10*time.Hour) if _, err := f.mgr.RefreshAll(context.Background()); err != nil { t.Fatal(err) } f.age(9 * time.Hour) again := NewManager(ManagerOptions{Config: f.cfgSrc, Logger: slog.Default()}) if _, err := again.RefreshAll(context.Background()); err != nil { t.Fatal(err) } src, ok := again.previous("ads") if !ok { t.Fatal("no source") } if age := time.Since(src.Meta.UpdatedAt); age < 8*time.Hour || age > 10*time.Hour { t.Errorf("source age = %v, want about 9h (the copy's)", age) } } // Startup must not wait for the network: the offline pass serves what is on // disk, however old, and skips what was never downloaded; a normal pass then // updates it. func TestOfflinePassNeverTouchesTheNetwork(t *testing.T) { f := newListFixture(t, time.Hour) st, _ := f.mgr.RefreshOffline(context.Background()) if st.Errors["ads"] == "" { t.Errorf("a list with no copy should be reported as not downloaded yet: %+v", st.Errors) } if f.hits.Load() != 0 || f.blocks("first.example") { t.Fatalf("hits=%d: an offline pass reached for the network", f.hits.Load()) } if _, err := f.mgr.RefreshAll(context.Background()); err != nil { // the background pass t.Fatal(err) } f.body.Store("0.0.0.0 second.example\n") f.age(5 * time.Hour) // stale again := NewManager(ManagerOptions{Config: f.cfgSrc, Logger: slog.Default()}) if _, err := again.RefreshOffline(context.Background()); err != nil { t.Fatal(err) } if f.hits.Load() != 1 { t.Errorf("hits=%d: the offline pass downloaded a stale copy", f.hits.Load()) } if set := again.Holder().Get(); set == nil || set.Match("first.example") == nil { t.Error("the stale copy was not served while offline") } // ...and the pass that follows brings it up to date. if _, err := again.RefreshAll(context.Background()); err != nil { t.Fatal(err) } if f.hits.Load() != 2 || again.Holder().Get().Match("second.example") == nil { t.Errorf("hits=%d: the follow-up pass did not refresh the stale copy", f.hits.Load()) } }