package routewatch_test import ( "bytes" "context" "log/slog" "net/http" "net/http/httptest" "net/netip" "strings" "sync" "sync/atomic" "testing" "time" "sneak.berlin/go/smallwebwaf/internal/routewatch" ) const ( clientAddr = "192.0.2.1" routedAnswer = `{"status":"ok","data":{"results":[` + `{"ip":"192.0.2.1","netblock":"192.0.2.0/24","asn":64496}]}}` noRouteAnswer = `{"status":"ok","data":{"results":[{"ip":"192.0.2.1"}]}}` // slowAnswer is longer than routewatch allows a reverse DNS lookup. slowAnswer = 2500 * time.Millisecond // waitLimit is longer than the Client allows a query. waitLimit = 10 * time.Second ) // clock is a time the tests move by hand. type clock struct { mu sync.Mutex now time.Time } func (c *clock) Now() time.Time { c.mu.Lock() defer c.mu.Unlock() return c.now } func (c *clock) advance(by time.Duration) { c.mu.Lock() defer c.mu.Unlock() c.now = c.now.Add(by) } // fixture is a Client asking a stand-in for routewatch. queries counts what the // stand-in was asked, paths holds the last path it was asked for, and log holds // the process log. type fixture struct { t *testing.T server *httptest.Server client *routewatch.Client clk *clock queries *atomic.Int32 path *atomic.Value log *syncBuffer } // syncBuffer is a buffer the background queries can log to while a test reads it. type syncBuffer struct { mu sync.Mutex buf bytes.Buffer } func (b *syncBuffer) Write(p []byte) (int, error) { b.mu.Lock() defer b.mu.Unlock() return b.buf.Write(p) } func (b *syncBuffer) String() string { b.mu.Lock() defer b.mu.Unlock() return b.buf.String() } func newFixture(t *testing.T, standIn http.HandlerFunc) *fixture { t.Helper() f := &fixture{ t: t, clk: &clock{now: time.Date(2026, 11, 9, 0, 0, 0, 0, time.UTC)}, queries: &atomic.Int32{}, path: &atomic.Value{}, log: &syncBuffer{}, } f.server = httptest.NewServer(http.HandlerFunc( func(w http.ResponseWriter, r *http.Request) { f.queries.Add(1) f.path.Store(r.URL.Path) standIn(w, r) })) t.Cleanup(f.server.Close) f.client = routewatch.New(routewatch.Params{ URL: f.server.URL + "/", Now: f.clk.Now, ProcessLog: slog.New(slog.NewTextHandler(f.log, nil)), }) return f } func answering(body string) http.HandlerFunc { return func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(body)) } } func (f *fixture) route() (routewatch.Route, routewatch.Missing) { addr := netip.MustParseAddr(clientAddr) client := netip.PrefixFrom(addr, addr.BitLen()) return f.client.Route(context.Background(), client, addr) } // waitFor asks until the answer is why, or, while why is "", until there is a // route, and returns the route. func (f *fixture) waitFor(why routewatch.Missing) routewatch.Route { f.t.Helper() deadline := time.Now().Add(waitLimit) for { route, missing := f.route() if missing == why { return route } if time.Now().After(deadline) { f.t.Fatalf("still %q after %s, want %q", missing, waitLimit, why) } time.Sleep(time.Millisecond) } } // wantFailureLoggedWithoutTheClient waits for the process log to name a failed // query, and checks that it does not name the client. func (f *fixture) wantFailureLoggedWithoutTheClient() { f.t.Helper() // The failure is noted before it is logged. deadline := time.Now().Add(waitLimit) for !strings.Contains(f.log.String(), "asking routewatch failed") { if time.Now().After(deadline) { f.t.Fatalf("process log %q names no failure", f.log.String()) } time.Sleep(time.Millisecond) } if strings.Contains(f.log.String(), clientAddr) { f.t.Errorf("process log %q names the client", f.log.String()) } } func TestRouteGivesRoutewatchsAnswerAndAsksByAddress(t *testing.T) { t.Parallel() f := newFixture(t, answering(routedAnswer)) if _, missing := f.route(); missing != routewatch.NoAnswerYet { t.Errorf("before the answer: %q, want %q", missing, routewatch.NoAnswerYet) } route := f.waitFor("") want := routewatch.Route{Netblock: netip.MustParsePrefix("192.0.2.0/24"), ASN: 64496} if route != want { t.Errorf("route %+v, want %+v", route, want) } if got := f.path.Load(); got != "/api/v1/ip/"+clientAddr { t.Errorf("asked for path %v", got) } } func TestAnswerAfterASlowReverseDNSLookupIsKept(t *testing.T) { t.Parallel() f := newFixture(t, func(w http.ResponseWriter, _ *http.Request) { time.Sleep(slowAnswer) _, _ = w.Write([]byte(routedAnswer)) }) f.route() route := f.waitFor("") want := routewatch.Route{Netblock: netip.MustParsePrefix("192.0.2.0/24"), ASN: 64496} if route != want { t.Errorf("route %+v, want %+v", route, want) } } func TestAddressWithoutARouteIsKeptAsNoRoute(t *testing.T) { t.Parallel() f := newFixture(t, answering(noRouteAnswer)) f.route() f.waitFor(routewatch.NoRoute) if got := f.queries.Load(); got != 1 { t.Errorf("%d queries, want 1", got) } } func TestAnswerRoutewatchCannotBeTakenFromFails(t *testing.T) { t.Parallel() tests := map[string]http.HandlerFunc{ "500": func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusInternalServerError) }, "not JSON": answering("no"), "no results": answering(`{"status":"ok","data":{"results":[]}}`), "bad netblock": answering( `{"status":"ok","data":{"results":[{"netblock":"nowhere","asn":1}]}}`), } for name, standIn := range tests { t.Run(name, func(t *testing.T) { t.Parallel() f := newFixture(t, standIn) f.route() f.waitFor(routewatch.RequestFailed) f.wantFailureLoggedWithoutTheClient() }) } } func TestRefusedConnectionFailsTheQueryAndTheLogOmitsTheClient(t *testing.T) { t.Parallel() f := newFixture(t, answering(routedAnswer)) f.server.Close() f.route() f.waitFor(routewatch.RequestFailed) f.wantFailureLoggedWithoutTheClient() } func TestFailureIsKeptForAMinuteSoRoutewatchIsNotAskedPerRequest(t *testing.T) { t.Parallel() f := newFixture(t, func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusInternalServerError) }) f.route() f.waitFor(routewatch.RequestFailed) f.route() if got := f.queries.Load(); got != 1 { t.Errorf("%d queries while the failure is kept, want 1", got) } f.clk.advance(time.Minute) f.route() deadline := time.Now().Add(waitLimit) for f.queries.Load() != 2 { if time.Now().After(deadline) { t.Fatalf("%d queries after the failure expired, want 2", f.queries.Load()) } time.Sleep(time.Millisecond) } } func TestHangingRoutewatchNeverDelaysAndFailsTheQuery(t *testing.T) { t.Parallel() f := newFixture(t, func(_ http.ResponseWriter, r *http.Request) { <-r.Context().Done() }) start := time.Now() f.route() if elapsed := time.Since(start); elapsed > time.Second { t.Errorf("Route took %s while routewatch hangs", elapsed) } f.waitFor(routewatch.RequestFailed) f.wantFailureLoggedWithoutTheClient() } func TestRepeatedRequestsMakeOneQueryAndTheAnswerExpires(t *testing.T) { t.Parallel() release := make(chan struct{}) f := newFixture(t, func(w http.ResponseWriter, _ *http.Request) { <-release _, _ = w.Write([]byte(routedAnswer)) }) for range 50 { f.route() } close(release) f.waitFor("") for range 50 { f.route() } if got := f.queries.Load(); got != 1 { t.Errorf("%d queries for repeated requests, want 1", got) } f.clk.advance(time.Hour) f.waitFor(routewatch.NoAnswerYet) f.waitFor("") if got := f.queries.Load(); got != 2 { t.Errorf("%d queries after the answer expired, want 2", got) } }