check / check (push) In progress
SWW_ROUTEWATCH_URL names the instance. The new heuristic routewatch_tags asks it in the background about each client and tags the request. Answers are kept in memory for an hour. A request without tags gives the reason in routewatch_tags_missing, and it is never delayed or refused. Model: opus-5-5
334 lines
7.4 KiB
Go
334 lines
7.4 KiB
Go
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)
|
|
}
|
|
}
|