package network import ( "context" "errors" "io" "math" "net/http" "net/http/httptest" "os" "sync/atomic" "testing" "time" ) func TestHexIP(t *testing.T) { // /proc/net/route prints each address as a host-order word: on a little-endian machine // 10.0.0.1 is written "0100000A". These cases are that machine, which is all of ours. for hex, want := range map[string]string{ "0100000A": "10.0.0.1", "00000000": "0.0.0.0", "0101A8C0": "192.168.1.1", } { got, err := hexIP(hex) if err != nil || got != want { t.Errorf("hexIP(%q) = %q, %v; want %q", hex, got, err, want) } } if _, err := hexIP("nonsense"); err == nil { t.Error("hexIP accepted nonsense") } } func TestServerTimeIsNotLatency(t *testing.T) { h := http.Header{} h.Add("Server-Timing", "cfSpeedEdge;dur=7, cfSpeedWorker;dur=36") h.Add("Server-Timing", `cfL4;desc="?proto=TCP&rtt=3505&min_rtt=2856"`) if got := serverMs(h); got != 43 { t.Errorf("serverMs = %v, want 43 (7+36, and nothing from the cfL4 description)", got) } if got := serverMs(http.Header{}); got != 0 { t.Errorf("serverMs with no header = %v, want 0", got) } h2 := http.Header{"Cf-Ray": {"a41f6720f966a36c-SEA"}, "Cf-Meta-Ip": {"50.47.252.221"}} if m := readMeta(h2); m.Colo != "SEA" || m.IP != "50.47.252.221" { t.Errorf("readMeta = %+v, want the edge SEA and the address", m) } } func TestRates(t *testing.T) { // a gibibyte in eight seconds is 1073.7 Mbps if got := mbps(1<<30, 8); math.Abs(got-1073.7) > 0.1 { t.Errorf("mbps(1GiB, 8s) = %v, want ~1073.7", got) } if got := best([]float64{12.5, 9.2, 40, 9.9}); got != 9.2 { t.Errorf("best = %v, want 9.2 (the least of them)", got) } if got := jitter([]float64{10, 10, 10}); got != 0 { t.Errorf("jitter of a steady line = %v, want 0", got) } if got := jitter([]float64{10}); got != 0 { t.Errorf("jitter of one sample = %v, want 0", got) } if got := jitter([]float64{5, 50, 5, 50}); got != 45 { t.Errorf("jitter of a line alternating by 45 = %v, want 45", got) } // one slow probe among steady ones moves it a little, not enormously if got := jitter([]float64{10, 10, 10, 60, 10, 10}); got > 25 { t.Errorf("jitter with a single outlier = %v, want it not to run away", got) } } // The body an upload sends is a fixed size, counts itself, and costs nothing to make. func TestZerosBodyCountsItself(t *testing.T) { var n atomic.Int64 z := &zeros{left: 100, n: &n} buf := make([]byte, 30) total := 0 for { got, err := z.Read(buf) total += got if err != nil { break } } if total != 100 || n.Load() != 100 { t.Errorf("read %d bytes and counted %d, want 100 and 100", total, n.Load()) } } // Interfaces has to work on whatever machine this runs on, and always name a loopback. func TestInterfacesHasLoopback(t *testing.T) { ifs := Interfaces() if len(ifs) == 0 { t.Fatal("no interfaces at all") } lo := false for _, f := range ifs { if f.Kind == "loopback" { lo = true if !f.Up || len(f.Addrs) == 0 { t.Errorf("loopback %s looks wrong: up=%v addrs=%v", f.Name, f.Up, f.Addrs) } } if f.Name == "" { t.Error("an interface with no name") } } if !lo { t.Error("no loopback among the interfaces") } } // The real thing, against the real endpoint: skipped by default, because `make test` must // not need the internet. HAMLAB_NET_TEST=1 go test ./internal/network/ -run Live -v func TestLiveSpeedTest(t *testing.T) { if os.Getenv("HAMLAB_NET_TEST") == "" { t.Skip("set HAMLAB_NET_TEST=1 to measure the actual line") } tr := NewTester(nil) if _, err := tr.Start(); err != nil { t.Fatal(err) } if _, err := tr.Start(); err == nil { t.Error("a second test started while the first was running") } deadline := time.Now().Add(90 * time.Second) for tr.Running() && time.Now().Before(deadline) { time.Sleep(250 * time.Millisecond) } st := tr.State() if st.Result == nil { t.Fatal("no result") } r := *st.Result t.Logf("down %s up %s latency %.1f ms jitter %.1f ms via %s ip %s %.0f MB moved in %.1fs", Mbits(r.DownMbps), Mbits(r.UpMbps), r.LatencyMs, r.JitterMs, r.Colo, r.IP, float64(r.DownBytes+r.UpBytes)/1e6, r.TookSec) if r.Error != "" { t.Fatalf("test failed: %s", r.Error) } if r.DownMbps <= 0 || r.UpMbps <= 0 || r.LatencyMs <= 0 { t.Errorf("a figure came back zero: %+v", r) } if r.IP == "" { t.Error("no public address reported") } if len(tr.History()) != 1 { t.Errorf("history has %d results, want 1", len(tr.History())) } } // A phase measured against a local server, at a rate the test controls. This is the part of // the speed test with arithmetic in it — the window after the ramp, the byte cap that ends it // early, and the rate that comes out — and none of it needs the internet to check. func TestPhaseMeasuresTheWindow(t *testing.T) { const perTick = 1 << 20 // a mebibyte every 5 ms ≈ 1678 Mbps, whatever the machine is doing srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/up" { _, _ = io.Copy(io.Discard, r.Body) w.WriteHeader(204) return } buf := make([]byte, perTick) fl, _ := w.(http.Flusher) for { if _, err := w.Write(buf); err != nil { return } if fl != nil { fl.Flush() } select { case <-time.After(5 * time.Millisecond): case <-r.Context().Done(): return } } })) defer srv.Close() defer func(up string, w, d, mw time.Duration, m int64) { upURL, warmUp, downFor, minWindow, measureBytes = up, w, d, mw, m }(upURL, warmUp, downFor, minWindow, measureBytes) upURL = srv.URL + "/up" warmUp, downFor, minWindow, measureBytes = 100*time.Millisecond, 3*time.Second, 200*time.Millisecond, 8<<20 here := Source{"local", func(int) string { return srv.URL + "/down" }} tr := NewTester(nil) began := time.Now() mbps, moved, note, err := tr.phase(context.Background(), srv.Client(), "download", 2, downFor, 0, 1, here) took := time.Since(began) if err != nil { t.Fatalf("phase: %v", err) } if note != "" { t.Errorf("note = %q, want none — nothing went wrong", note) } // The cap must have ended it early rather than the clock: the phase was allowed three // seconds and should be done in well under one, having moved at least the cap. if moved < measureBytes { t.Errorf("moved %d bytes, want at least the %d-byte cap", moved, measureBytes) } if took > time.Second { t.Errorf("phase took %v of the %v it was allowed; the byte cap should have ended it", took.Round(time.Millisecond), downFor) } // The server hands out a mebibyte per 5 ms per stream, so the rate is in the hundreds of // Mbps. The point is that it is a real quotient of what moved over how long, not that it // hits a particular figure on a loaded test machine. if mbps < 50 || mbps > 20000 { t.Errorf("mbps = %v, which is not a believable quotient", mbps) } t.Logf("measured %.0f Mbps over %.1f MiB", mbps, float64(moved)/(1<<20)) // A phase whose server refuses outright fails, and says what it was told. bad := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(429) })) defer bad.Close() refuser := Source{"refuser", func(int) string { return bad.URL + "/down" }} _, _, _, err = tr.phase(context.Background(), bad.Client(), "download", 2, 300*time.Millisecond, 0, 1, refuser) if err == nil { t.Error("a phase against a server that refuses everything should fail") } else if !refused(err) { t.Errorf("error = %q, want the refusal that makes the fallback kick in", err) } } // Stopping a test mid-flight is reported as cancelled, not as whatever the streams said on // their way out. func TestCancelIsReportedAsCancelled(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { buf := make([]byte, 1<<16) for { if _, err := w.Write(buf); err != nil { return } select { case <-time.After(time.Millisecond): case <-r.Context().Done(): return } } })) defer srv.Close() defer func(w, d time.Duration) { warmUp, downFor = w, d }(warmUp, downFor) warmUp, downFor = 10*time.Millisecond, 10*time.Second here := Source{"local", func(int) string { return srv.URL + "/down" }} tr := NewTester(nil) ctx, cancel := context.WithCancel(context.Background()) go func() { time.Sleep(200 * time.Millisecond); cancel() }() _, _, _, err := tr.phase(ctx, srv.Client(), "download", 1, downFor, 0, 1, here) // Cancelled after bytes moved: the figure stands, and the phase does not claim an error. if err != nil && !errors.Is(err, context.Canceled) { t.Errorf("err = %v, want nil or a cancellation", err) } } // When the first source refuses, the download is taken from the next one and the result says // so. This is the whole reason there is more than one. func TestDownloadFallsBackWhenRefused(t *testing.T) { serving := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/up" { _, _ = io.Copy(io.Discard, r.Body) w.WriteHeader(204) return } // paced, so the phase is governed by its window and not by a loopback moving a // gibibyte before the first tick buf := make([]byte, 1<<20) fl, _ := w.(http.Flusher) for i := 0; i < 64; i++ { if _, err := w.Write(buf); err != nil { return } if fl != nil { fl.Flush() } select { case <-time.After(2 * time.Millisecond): case <-r.Context().Done(): return } } })) defer serving.Close() refusing := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(429) })) defer refusing.Close() defer func(srcs []Source, up string, w, d, mw time.Duration, m int64) { downSources, upURL, warmUp, downFor, minWindow, measureBytes = srcs, up, w, d, mw, m }(downSources, upURL, warmUp, downFor, minWindow, measureBytes) downSources = []Source{ {"refuser", func(int) string { return refusing.URL + "/down" }}, {"the-other-one", func(int) string { return serving.URL + "/down" }}, } upURL = serving.URL + "/up" warmUp, downFor, upFor, minWindow, measureBytes = 50*time.Millisecond, time.Second, 300*time.Millisecond, 100*time.Millisecond, 4<<20 tr := NewTester(nil) if _, err := tr.Start(); err != nil { t.Fatal(err) } deadline := time.Now().Add(30 * time.Second) for tr.Running() && time.Now().Before(deadline) { time.Sleep(50 * time.Millisecond) } res := tr.State().Result if res == nil { t.Fatal("no result") } if res.Source != "the-other-one" { t.Errorf("source = %q, want the second one — the first refused", res.Source) } if res.DownMbps <= 0 { t.Errorf("download = %v, want a figure from the source that did serve: %+v", res.DownMbps, res) } }