Run the GeoJS lookup tests on a clock the test controls (closes #73)
check / check (push) Successful in 4m59s

The tests that have GeoJS asked now run in a synctest bubble, so a wait
lasts exactly as long as it should however slowly the test process runs:
a new client's wait is checked to be exactly one second, and its next
request exactly no wait. A request waiting on the network would stop the
bubble's clock, so the stand-in for GeoJS now answers in place of the
network, through a transport that a test-only file lets the tests set.
The test of the failure log uses an abandoned request instead of a
closed port.

Model: opus-5-5
This commit is contained in:
2026-10-06 06:55:14 +00:00
parent df2c5042d2
commit 513c90d04e
3 changed files with 283 additions and 242 deletions
+9
View File
@@ -0,0 +1,9 @@
package lookup
import "net/http"
// SetTransport has g's requests to GeoJS go through transport instead of
// the network.
func (g *GeoJS) SetTransport(transport http.RoundTripper) {
g.httpClient.Transport = transport
}
+82 -53
View File
@@ -10,6 +10,7 @@ import (
"strings" "strings"
"sync" "sync"
"testing" "testing"
"testing/synctest"
"time" "time"
"sneak.berlin/go/smallwebwaf/internal/lookup" "sneak.berlin/go/smallwebwaf/internal/lookup"
@@ -26,18 +27,22 @@ const (
leftOut = "203.0.113.7" leftOut = "203.0.113.7"
// timeout is how long a new client waits for its answer. // timeout is how long a new client waits for its answer.
timeout = time.Second timeout = time.Second
// waitLimit bounds how long a test waits for what should happen.
waitLimit = 10 * time.Second
// pollInterval is how often a test looks again.
pollInterval = 10 * time.Millisecond
// week is how long an answer is kept. // week is how long an answer is kept.
week = 7 * 24 * time.Hour week = 7 * 24 * time.Hour
) )
// The tests that have GeoJS asked run in a synctest bubble, where the time
// package runs on a clock of the test's own: a wait lasts exactly as long
// as it should, however slowly the test process runs, and synctest.Wait
// returns once g has done all it can before time passes. The stand-in for
// GeoJS answers without the network, since a request waiting on the
// network would keep that clock from moving on.
func TestKeptAnswerIsUsedFor7DaysThenAskedAgain(t *testing.T) { func TestKeptAnswerIsUsedFor7DaysThenAskedAgain(t *testing.T) {
t.Parallel() t.Parallel()
geojs, clock, g := start(t) synctest.Test(t, func(t *testing.T) {
geojs, clock, g := start()
placed := netip.MustParsePrefix("203.0.113.9/32") placed := netip.MustParsePrefix("203.0.113.9/32")
notPlaced := netip.MustParsePrefix(unplaced + "/32") notPlaced := netip.MustParsePrefix(unplaced + "/32")
@@ -55,12 +60,14 @@ func TestKeptAnswerIsUsedFor7DaysThenAskedAgain(t *testing.T) {
wantCountry(t, g, placed, germany) wantCountry(t, g, placed, germany)
wantRequests(t, geojs, 3) wantRequests(t, geojs, 3)
wantAsked(t, geojs, 2, "203.0.113.9") wantAsked(t, geojs, 2, "203.0.113.9")
})
} }
func TestNewClientWaitsAtMostOneSecondThenCountsAsNotFound(t *testing.T) { func TestNewClientWaitsAtMostOneSecondThenCountsAsNotFound(t *testing.T) {
t.Parallel() t.Parallel()
geojs, clock, g := start(t) synctest.Test(t, func(t *testing.T) {
geojs, clock, g := start()
client := netip.MustParsePrefix("203.0.113.9/32") client := netip.MustParsePrefix("203.0.113.9/32")
// The client comes while GeoJS is asked about an earlier client, which // The client comes while GeoJS is asked about an earlier client, which
@@ -82,7 +89,7 @@ func TestNewClientWaitsAtMostOneSecondThenCountsAsNotFound(t *testing.T) {
wantCountry(t, g, client, "") wantCountry(t, g, client, "")
took := time.Since(began) took := time.Since(began)
if took < timeout || took > timeout+timeout/2 { if took != timeout {
t.Errorf("waited %s for the answer, want %s", took, timeout) t.Errorf("waited %s for the answer, want %s", took, timeout)
} }
@@ -92,7 +99,7 @@ func TestNewClientWaitsAtMostOneSecondThenCountsAsNotFound(t *testing.T) {
wantCountry(t, g, client, "") wantCountry(t, g, client, "")
took = time.Since(began) took = time.Since(began)
if took > timeout/2 { if took != 0 {
t.Errorf("waited %s again, want no wait", took) t.Errorf("waited %s again, want no wait", took)
} }
@@ -100,6 +107,7 @@ func TestNewClientWaitsAtMostOneSecondThenCountsAsNotFound(t *testing.T) {
// background, and has its country. // background, and has its country.
geojs.set(answering) geojs.set(answering)
waitForCountry(t, g, clock, client, germany) waitForCountry(t, g, clock, client, germany)
})
} }
func TestAddressLeftOutOfAnAnswerIsAskedAboutAgain(t *testing.T) { func TestAddressLeftOutOfAnAnswerIsAskedAboutAgain(t *testing.T) {
@@ -118,7 +126,8 @@ func TestAddressLeftOutOfAnAnswerIsAskedAboutAgain(t *testing.T) {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
t.Parallel() t.Parallel()
geojs, clock, g := start(t) synctest.Test(t, func(t *testing.T) {
geojs, clock, g := start()
other := netip.MustParsePrefix("203.0.113.1/32") other := netip.MustParsePrefix("203.0.113.1/32")
client := netip.MustParsePrefix(leftOut + "/32") client := netip.MustParsePrefix(leftOut + "/32")
@@ -146,53 +155,64 @@ func TestAddressLeftOutOfAnAnswerIsAskedAboutAgain(t *testing.T) {
wantAsked(t, geojs, 2, leftOut, "203.0.113.1") wantAsked(t, geojs, 2, leftOut, "203.0.113.1")
} }
}) })
})
} }
} }
func TestRedirectCountsAsFailure(t *testing.T) { func TestRedirectCountsAsFailure(t *testing.T) {
t.Parallel() t.Parallel()
geojs, _, g := start(t) synctest.Test(t, func(t *testing.T) {
geojs, _, g := start()
geojs.set(redirecting) geojs.set(redirecting)
wantCountry(t, g, netip.MustParsePrefix("203.0.113.9/32"), "") wantCountry(t, g, netip.MustParsePrefix("203.0.113.9/32"), "")
wantRequests(t, geojs, 1) wantRequests(t, geojs, 1)
})
} }
func TestCountryIsKeptInCapitals(t *testing.T) { func TestCountryIsKeptInCapitals(t *testing.T) {
t.Parallel() t.Parallel()
geojs, _, g := start(t) synctest.Test(t, func(t *testing.T) {
geojs, _, g := start()
geojs.set(answeringInLowerCase) geojs.set(answeringInLowerCase)
wantCountry(t, g, netip.MustParsePrefix("203.0.113.9/32"), germany) wantCountry(t, g, netip.MustParsePrefix("203.0.113.9/32"), germany)
})
} }
func TestFailureIsLoggedWithoutTheAddressesAskedAbout(t *testing.T) { func TestFailureIsLoggedWithoutTheAddressesAskedAbout(t *testing.T) {
t.Parallel() t.Parallel()
synctest.Test(t, func(t *testing.T) {
var log strings.Builder var log strings.Builder
// Nothing listens on port 1, so asking GeoJS fails. // GeoJS does not answer, so the request to it is abandoned, and fails.
geojs := &standIn{answers: hanging}
g := lookup.New(lookup.Params{ g := lookup.New(lookup.Params{
URL: "http://127.0.0.1:1", URL: lookup.URL,
Now: time.Now, Now: time.Now,
ProcessLog: slog.New(slog.NewTextHandler(&log, nil)), ProcessLog: slog.New(slog.NewTextHandler(&log, nil)),
}) })
g.SetTransport(geojs)
wantCountry(t, g, netip.MustParsePrefix("203.0.113.9/32"), "") wantCountry(t, g, netip.MustParsePrefix("203.0.113.9/32"), "")
synctest.Wait()
logged := log.String() logged := log.String()
if !strings.Contains(logged, "asking GeoJS failed") || if !strings.Contains(logged, "asking GeoJS failed") ||
strings.Contains(logged, "203.0.113.9") { strings.Contains(logged, "203.0.113.9") {
t.Errorf("logged %q, want the failure without the address asked about", logged) t.Errorf("logged %q, want the failure without the address asked about", logged)
} }
})
} }
func TestWaitingClientsAreAskedAboutInOneRequest(t *testing.T) { func TestWaitingClientsAreAskedAboutInOneRequest(t *testing.T) {
t.Parallel() t.Parallel()
geojs, clock, g := start(t) synctest.Test(t, func(t *testing.T) {
geojs, clock, g := start()
// GeoJS fails, and is then left alone for a second, while three more // GeoJS fails, and is then left alone for a second, while three more
// clients come. An IPv6 client is a /64, and GeoJS is asked about its // clients come. An IPv6 client is a /64, and GeoJS is asked about its
@@ -208,12 +228,14 @@ func TestWaitingClientsAreAskedAboutInOneRequest(t *testing.T) {
wantCountry(t, g, netip.MustParsePrefix("203.0.113.3/32"), germany) wantCountry(t, g, netip.MustParsePrefix("203.0.113.3/32"), germany)
wantRequests(t, geojs, 2) wantRequests(t, geojs, 2)
wantAsked(t, geojs, 1, "203.0.113.1", "203.0.113.2", "2001:db8:1:2::", "203.0.113.3") wantAsked(t, geojs, 1, "203.0.113.1", "203.0.113.2", "2001:db8:1:2::", "203.0.113.3")
})
} }
func TestKeptAnswersUnaffectedWhileGeoJSFailsAndAskedAgainWithBackoff(t *testing.T) { func TestKeptAnswersUnaffectedWhileGeoJSFailsAndAskedAgainWithBackoff(t *testing.T) {
t.Parallel() t.Parallel()
geojs, clock, g := start(t) synctest.Test(t, func(t *testing.T) {
geojs, clock, g := start()
clients := newClients() clients := newClients()
kept := clients() kept := clients()
@@ -256,12 +278,14 @@ func TestKeptAnswersUnaffectedWhileGeoJSFailsAndAskedAgainWithBackoff(t *testing
if len(asked[requests]) != 23 { if len(asked[requests]) != 23 {
t.Errorf("GeoJS was asked about %d clients, want 23", len(asked[requests])) t.Errorf("GeoJS was asked about %d clients, want 23", len(asked[requests]))
} }
})
} }
func TestAtMost200AddressesInOneRequest(t *testing.T) { func TestAtMost200AddressesInOneRequest(t *testing.T) {
t.Parallel() t.Parallel()
geojs, clock, g := start(t) synctest.Test(t, func(t *testing.T) {
geojs, clock, g := start()
clients := newClients() clients := newClients()
first := clients() first := clients()
@@ -283,12 +307,14 @@ func TestAtMost200AddressesInOneRequest(t *testing.T) {
t.Errorf("GeoJS was asked about %d and then %d clients, want 200 and 1", t.Errorf("GeoJS was asked about %d and then %d clients, want 200 and 1",
len(asked[1]), len(asked[2])) len(asked[1]), len(asked[2]))
} }
})
} }
func TestAtMost10000ClientsWait(t *testing.T) { func TestAtMost10000ClientsWait(t *testing.T) {
t.Parallel() t.Parallel()
geojs, clock, g := start(t) synctest.Test(t, func(t *testing.T) {
geojs, clock, g := start()
clients := newClients() clients := newClients()
first := clients() first := clients()
@@ -319,6 +345,7 @@ func TestAtMost10000ClientsWait(t *testing.T) {
// With room among those waiting, it is asked about. // With room among those waiting, it is asked about.
wantCountry(t, g, extra, germany) wantCountry(t, g, extra, germany)
})
} }
// How the stand-in for GeoJS answers. // How the stand-in for GeoJS answers.
@@ -337,13 +364,25 @@ const (
// standIn is a stand-in for GeoJS. It notes the addresses each request // standIn is a stand-in for GeoJS. It notes the addresses each request
// asks about. // asks about.
type standIn struct { type standIn struct {
server *httptest.Server
mu sync.Mutex mu sync.Mutex
answers int answers int
requests [][]string requests [][]string
} }
// RoundTrip has the stand-in answer req, in place of the network. A request
// abandoned before the stand-in answers fails, as over the network.
func (s *standIn) RoundTrip(req *http.Request) (*http.Response, error) {
answer := httptest.NewRecorder()
s.ServeHTTP(answer, req)
err := req.Context().Err()
if err != nil {
return nil, err
}
return answer.Result(), nil
}
// ServeHTTP answers a request about the addresses in its ip parameter. // ServeHTTP answers a request about the addresses in its ip parameter.
func (s *standIn) ServeHTTP(w http.ResponseWriter, r *http.Request) { func (s *standIn) ServeHTTP(w http.ResponseWriter, r *http.Request) {
addrs := strings.Split(r.URL.Query().Get("ip"), ",") addrs := strings.Split(r.URL.Query().Get("ip"), ",")
@@ -422,7 +461,8 @@ func (s *standIn) asked() [][]string {
return slices.Clone(s.requests) return slices.Clone(s.requests)
} }
// testClock is a clock the test sets. // testClock is a clock the test sets. GeoJS tells the time by it, while
// waits run on the bubble's clock.
type testClock struct { type testClock struct {
mu sync.Mutex mu sync.Mutex
now time.Time now time.Time
@@ -444,21 +484,17 @@ func (c *testClock) advance(d time.Duration) {
c.now = c.now.Add(d) c.now = c.now.Add(d)
} }
// start starts a stand-in for GeoJS that answers, and returns it, a // start returns a stand-in for GeoJS that answers, a clock, and a GeoJS
// clock, and a GeoJS asking it by that clock. // asking the stand-in by that clock.
func start(t *testing.T) (*standIn, *testClock, *lookup.GeoJS) { func start() (*standIn, *testClock, *lookup.GeoJS) {
t.Helper()
geojs := &standIn{} geojs := &standIn{}
geojs.server = httptest.NewServer(geojs)
t.Cleanup(geojs.server.Close)
clock := &testClock{now: time.Date(2026, 10, 4, 0, 0, 0, 0, time.UTC)} clock := &testClock{now: time.Date(2026, 10, 4, 0, 0, 0, 0, time.UTC)}
g := lookup.New(lookup.Params{ g := lookup.New(lookup.Params{
URL: geojs.server.URL, URL: lookup.URL,
Now: clock.Now, Now: clock.Now,
ProcessLog: slog.New(slog.DiscardHandler), ProcessLog: slog.New(slog.DiscardHandler),
}) })
g.SetTransport(geojs)
return geojs, clock, g return geojs, clock, g
} }
@@ -513,41 +549,34 @@ func wantAsked(t *testing.T, geojs *standIn, i int, want ...string) {
} }
} }
// waitForRequests waits for GeoJS to have had count requests, and returns // waitForRequests waits until g has done all it can before time passes,
// the addresses each asked about. // checks that GeoJS has had count requests, and returns the addresses each
// asked about.
func waitForRequests(t *testing.T, geojs *standIn, count int) [][]string { func waitForRequests(t *testing.T, geojs *standIn, count int) [][]string {
t.Helper() t.Helper()
deadline := time.Now().Add(waitLimit) synctest.Wait()
for time.Now().Before(deadline) {
asked := geojs.asked() asked := geojs.asked()
if len(asked) >= count { if len(asked) != count {
t.Fatalf("GeoJS had %d requests, want %d", len(asked), count)
}
return asked return asked
}
time.Sleep(pollInterval)
}
t.Fatalf("fewer than %d requests to GeoJS after %s", count, waitLimit)
return nil
} }
// waitForCountry waits for g to give client the country want, moving the // waitForCountry lets a request to GeoJS under way be abandoned, and moves
// clock on a minute at a time, so that GeoJS is asked again after a // the clock on a minute, so that GeoJS may be asked again after a failure.
// failure. // It then checks that client's next request does not wait but has it asked
// about again in the background, after which g gives it the country want.
func waitForCountry( func waitForCountry(
t *testing.T, g *lookup.GeoJS, clock *testClock, client netip.Prefix, want string, t *testing.T, g *lookup.GeoJS, clock *testClock, client netip.Prefix, want string,
) { ) {
t.Helper() t.Helper()
deadline := time.Now().Add(waitLimit) time.Sleep(timeout)
for g.Country(t.Context(), client) != want {
if time.Now().After(deadline) {
t.Fatalf("%s is not in %q after %s", client, want, waitLimit)
}
clock.advance(time.Minute) clock.advance(time.Minute)
time.Sleep(pollInterval) wantCountry(t, g, client, "")
} synctest.Wait()
wantCountry(t, g, client, want)
} }
+6 -3
View File
@@ -4,6 +4,7 @@ import (
"net/netip" "net/netip"
"slices" "slices"
"testing" "testing"
"testing/synctest"
"time" "time"
"sneak.berlin/go/smallwebwaf/internal/lookup" "sneak.berlin/go/smallwebwaf/internal/lookup"
@@ -12,7 +13,8 @@ import (
func TestSnapshotHoldsEachAnswerAndWhenItWasLastUsed(t *testing.T) { func TestSnapshotHoldsEachAnswerAndWhenItWasLastUsed(t *testing.T) {
t.Parallel() t.Parallel()
_, clock, g := start(t) synctest.Test(t, func(t *testing.T) {
_, clock, g := start()
placed := netip.MustParsePrefix("203.0.113.9/32") placed := netip.MustParsePrefix("203.0.113.9/32")
notPlaced := netip.MustParsePrefix(unplaced + "/32") notPlaced := netip.MustParsePrefix(unplaced + "/32")
asked := clock.Now() asked := clock.Now()
@@ -30,12 +32,13 @@ func TestSnapshotHoldsEachAnswerAndWhenItWasLastUsed(t *testing.T) {
if got := g.Snapshot(); !slices.Equal(got, want) { if got := g.Snapshot(); !slices.Equal(got, want) {
t.Errorf("snapshot\n%+v\nwant\n%+v", got, want) t.Errorf("snapshot\n%+v\nwant\n%+v", got, want)
} }
})
} }
func TestLoadedAnswersAreKeptFor7DaysFromWhenGeoJSGaveThem(t *testing.T) { func TestLoadedAnswersAreKeptFor7DaysFromWhenGeoJSGaveThem(t *testing.T) {
t.Parallel() t.Parallel()
geojs, clock, g := start(t) geojs, clock, g := start()
now := clock.Now() now := clock.Now()
kept := lookup.Answer{ kept := lookup.Answer{
Client: netip.MustParsePrefix("203.0.113.9/32"), Client: netip.MustParsePrefix("203.0.113.9/32"),
@@ -65,7 +68,7 @@ func TestLoadDropsTheAnswerUsedLongestAgoFirst(t *testing.T) {
const maxAnswers = 100000 const maxAnswers = 100000
_, clock, g := start(t) _, clock, g := start()
now := clock.Now() now := clock.Now()
// lookups.json lists the answers by client. Here each was last used a // lookups.json lists the answers by client. Here each was last used a