package client import ( "bytes" "compress/gzip" "context" "encoding/json" "errors" "net/http" "net/http/httptest" "strconv" "strings" "testing" "time" "internetpressure.io/probe-agent/internal/protocol" "internetpressure.io/probe-agent/internal/signer" ) const key = "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f" // fakeServer verifies signatures exactly like the Python ingest API should. func fakeServer(t *testing.T, skew time.Duration) (*httptest.Server, *int) { verifier, _ := signer.New("ca-qc-01", key) calls := 0 h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { calls++ body, _ := readAll(r) ts, _ := strconv.ParseInt(r.Header.Get(signer.HeaderTimestamp), 10, 64) if r.Header.Get(signer.HeaderProbe) != "ca-qc-01" || !verifier.Verify(r.Method, r.URL.Path, ts, body, r.Header.Get(signer.HeaderSignature)) { http.Error(w, `{"detail":"bad signature"}`, 401) return } if !strings.HasPrefix(r.UserAgent(), "InternetPressureProbe/0.1.0 (+https://www.internetpressure.io/probes)") { http.Error(w, "ua", 400) return } serverNow := time.Now().Add(-skew) w.Header().Set("Date", serverNow.UTC().Format(http.TimeFormat)) if abs(time.Duration(ts)*time.Second-time.Duration(serverNow.Unix())*time.Second) > 300*time.Second { http.Error(w, `{"detail":"timestamp skew too large"}`, 401) return } w.Header().Set("Content-Type", "application/json") switch r.URL.Path { case "/ingest/v1/config": json.NewEncoder(w).Encode(map[string]any{"server_time": protocol.FormatTime(serverNow), "config_version": "v1", "probe": map[string]any{"probe_id": "ca-qc-01", "enabled": true}, "schedule": map[string]any{"tiers": map[string]int{"1": 20}}, "targets": []any{}}) case "/ingest/v1/batch": if r.Header.Get("Content-Encoding") != "gzip" { http.Error(w, "not gzip", 400) return } zr, err := gzip.NewReader(bytes.NewReader(body)) if err != nil { http.Error(w, "bad gzip", 400) return } var b protocol.Batch if err := json.NewDecoder(zr).Decode(&b); err != nil { http.Error(w, "bad json", 400) return } json.NewEncoder(w).Encode(protocol.BatchResponse{Accepted: len(b.Measurements), ConfigVersion: "v2", ServerTime: protocol.FormatTime(serverNow)}) default: http.NotFound(w, r) } }) return httptest.NewServer(h), &calls } func abs(d time.Duration) time.Duration { if d < 0 { return -d } return d } func readAll(r *http.Request) ([]byte, error) { var buf bytes.Buffer _, err := buf.ReadFrom(r.Body) return buf.Bytes(), err } func newClient(t *testing.T, base string) *Client { s, _ := signer.New("ca-qc-01", key) c, err := New(base+"/ingest/v1", s, "0.1.0") if err != nil { t.Fatal(err) } return c } func TestConfigAndBatchSigned(t *testing.T) { srv, _ := fakeServer(t, 0) defer srv.Close() c := newClient(t, srv.URL) cfg, err := c.GetConfig(context.Background()) if err != nil { t.Fatal(err) } if cfg.ConfigVersion != "v1" || !cfg.Probe.Enabled { t.Fatalf("config: %+v", cfg) } if c.Clock.Samples() != 1 || abs(c.Clock.Offset()) > time.Second { t.Fatalf("clock not fed: samples=%d offset=%v", c.Clock.Samples(), c.Clock.Offset()) } var buf bytes.Buffer zw := gzip.NewWriter(&buf) json.NewEncoder(zw).Encode(protocol.Batch{ProbeID: "ca-qc-01", AgentVersion: "0.1.0", Measurements: []protocol.Measurement{{Kind: "http"}, {Kind: "dns"}}}) zw.Close() resp, err := c.PostBatch(context.Background(), buf.Bytes(), 0) if err != nil { t.Fatal(err) } if resp.Accepted != 2 || resp.ConfigVersion != "v2" { t.Fatalf("batch resp: %+v", resp) } } func TestSkewResyncViaDateHeader(t *testing.T) { // Server clock is 10 minutes behind us → first request is rejected with "skew", Date header resyncs us. srv, calls := fakeServer(t, 10*time.Minute) defer srv.Close() c := newClient(t, srv.URL) _, err := c.GetConfig(context.Background()) var he *HTTPError if !errors.As(err, &he) || !he.IsSkew() { t.Fatalf("expected skew 401, got %v", err) } if off := c.Clock.Offset(); off < 9*time.Minute || off > 11*time.Minute { t.Fatalf("offset after resync = %v", off) } if _, err := c.GetConfig(context.Background()); err != nil { t.Fatalf("retry after resync should pass: %v", err) } if *calls != 2 { t.Fatalf("calls = %d", *calls) } } func TestRetryable(t *testing.T) { if !Retryable(errors.New("dial tcp: connection refused")) { t.Error("network error should be retryable") } if !Retryable(&HTTPError{Status: 503}) || Retryable(&HTTPError{Status: 401}) || Retryable(&HTTPError{Status: 400}) { t.Error("status classification wrong") } if Retryable(nil) { t.Error("nil is not retryable") } } func TestClockEWMA(t *testing.T) { c := NewClock() now := time.Now() c.Observe(now, 100*time.Millisecond, now.Add(50*time.Millisecond).Add(-2*time.Second)) // local ahead by 2 s if got := c.Offset(); got < 1900*time.Millisecond || got > 2100*time.Millisecond { t.Fatalf("first sample should be taken as-is: %v", got) } c.Observe(now, 100*time.Millisecond, now.Add(50*time.Millisecond)) // offset 0 sample if got := c.Offset(); got < 1300*time.Millisecond || got > 1500*time.Millisecond { // 2 s × 0.7 t.Fatalf("ewma: %v", got) } }