f76e1797c2
Manually apply changes from maestro/tsk_rBPr0Xuy10bz that could not be auto-merged due to uncommitted changes on main at merge time. Added: - server/internal/scheduler/probe/types.go – frozen schema types (ReportRequest, VantagePoint, NodeReport, L1/L2/L3Result, ProbeSnapshot) - server/internal/scheduler/probe/store.go – Redis Store: SaveReports, CheckAndMarkSeen, SnapshotsByNode, AliveProbes with TTL constants and key-schema docs - server/internal/scheduler/probe/ingest.go – IngestHandler (POST /probe/report), HMAC-SHA256 auth + timestamp window + replay prevention via SetNX - server/internal/scheduler/probe/ingest_test.go – 17 tests (auth failures, integration, Store unit tests); all pass with miniredis Updated: - server/cmd/server/main.go – register /probe/report route when PROBE_SECRETS env is set Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
625 lines
19 KiB
Go
625 lines
19 KiB
Go
package probe_test
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"crypto/hmac"
|
||
"crypto/sha256"
|
||
"encoding/hex"
|
||
"encoding/json"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"strconv"
|
||
"testing"
|
||
"time"
|
||
|
||
"github.com/alicebob/miniredis/v2"
|
||
"github.com/redis/go-redis/v9"
|
||
"github.com/wangjia/pangolin/server/internal/scheduler/probe"
|
||
)
|
||
|
||
// --------------------------------------------------------------------------
|
||
// Test helpers
|
||
// --------------------------------------------------------------------------
|
||
|
||
const (
|
||
testProbeID = "probe-test-01"
|
||
testSecret = "s3cr3t-for-testing"
|
||
)
|
||
|
||
// newTestSetup creates a fresh miniredis instance, a Store, and an
|
||
// IngestHandler pre-configured with testProbeID/testSecret.
|
||
// The miniredis server is automatically closed when the test ends.
|
||
func newTestSetup(t *testing.T) (*probe.IngestHandler, *probe.Store, *miniredis.Miniredis) {
|
||
t.Helper()
|
||
mr := miniredis.RunT(t)
|
||
rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()})
|
||
st := probe.NewStore(rdb)
|
||
reg := probe.NewMapRegistry(map[string]string{testProbeID: testSecret})
|
||
h := probe.NewIngestHandler(reg, st)
|
||
return h, st, mr
|
||
}
|
||
|
||
// sign computes the HMAC-SHA256 hex digest expected by IngestHandler.
|
||
// Message: ts + "\n" + rawBody
|
||
func sign(secret, tsStr string, body []byte) string {
|
||
mac := hmac.New(sha256.New, []byte(secret))
|
||
mac.Write([]byte(tsStr))
|
||
mac.Write([]byte("\n"))
|
||
mac.Write(body)
|
||
return hex.EncodeToString(mac.Sum(nil))
|
||
}
|
||
|
||
// sampleRequest builds a minimal valid ReportRequest.
|
||
func sampleRequest() probe.ReportRequest {
|
||
return probe.ReportRequest{
|
||
ProbeID: testProbeID,
|
||
Vantage: probe.VantagePoint{
|
||
ISP: "China Telecom",
|
||
Region: "Guangdong",
|
||
Country: "CN",
|
||
},
|
||
Reports: []probe.NodeReport{
|
||
{
|
||
NodeID: "node-hk-01",
|
||
Ts: time.Now().Unix(),
|
||
L1: probe.L1Result{OK: true, RttMs: 42},
|
||
L2: &probe.L2Result{
|
||
OK: true,
|
||
ALPN: "h3",
|
||
},
|
||
L3: &probe.L3Result{OK: true},
|
||
},
|
||
},
|
||
}
|
||
}
|
||
|
||
// buildHTTPRequest encodes req as JSON and attaches the three required auth
|
||
// headers with the current timestamp and a valid HMAC.
|
||
func buildHTTPRequest(t *testing.T, req probe.ReportRequest, overrideTS ...string) *http.Request {
|
||
t.Helper()
|
||
body, err := json.Marshal(req)
|
||
if err != nil {
|
||
t.Fatalf("marshal request: %v", err)
|
||
}
|
||
tsStr := strconv.FormatInt(time.Now().Unix(), 10)
|
||
if len(overrideTS) > 0 {
|
||
tsStr = overrideTS[0]
|
||
}
|
||
r := httptest.NewRequest(http.MethodPost, "/probe/report", bytes.NewReader(body))
|
||
r.Header.Set("Content-Type", "application/json")
|
||
r.Header.Set("X-Probe-Id", testProbeID)
|
||
r.Header.Set("X-Probe-Ts", tsStr)
|
||
r.Header.Set("X-Probe-Sign", sign(testSecret, tsStr, body))
|
||
return r
|
||
}
|
||
|
||
// --------------------------------------------------------------------------
|
||
// Auth failure tests (no Redis touch needed, but we still pass a valid setup)
|
||
// --------------------------------------------------------------------------
|
||
|
||
// TestIngestBadSignature verifies that a wrong HMAC returns 401.
|
||
func TestIngestBadSignature(t *testing.T) {
|
||
h, _, _ := newTestSetup(t)
|
||
|
||
req := sampleRequest()
|
||
body, _ := json.Marshal(req)
|
||
tsStr := strconv.FormatInt(time.Now().Unix(), 10)
|
||
|
||
r := httptest.NewRequest(http.MethodPost, "/probe/report", bytes.NewReader(body))
|
||
r.Header.Set("X-Probe-Id", testProbeID)
|
||
r.Header.Set("X-Probe-Ts", tsStr)
|
||
// Deliberately wrong secret.
|
||
r.Header.Set("X-Probe-Sign", sign("wrong-secret", tsStr, body))
|
||
|
||
w := httptest.NewRecorder()
|
||
h.ServeHTTP(w, r)
|
||
|
||
if w.Code != http.StatusUnauthorized {
|
||
t.Errorf("bad signature: got %d, want 401", w.Code)
|
||
}
|
||
}
|
||
|
||
// TestIngestMissingHeaders verifies that missing any of the three auth headers
|
||
// results in 401.
|
||
func TestIngestMissingHeaders(t *testing.T) {
|
||
h, _, _ := newTestSetup(t)
|
||
req := sampleRequest()
|
||
body, _ := json.Marshal(req)
|
||
tsStr := strconv.FormatInt(time.Now().Unix(), 10)
|
||
validSign := sign(testSecret, tsStr, body)
|
||
|
||
cases := []struct {
|
||
name string
|
||
id string
|
||
ts string
|
||
sigHdr string
|
||
}{
|
||
{"no X-Probe-Id", "", tsStr, validSign},
|
||
{"no X-Probe-Ts", testProbeID, "", validSign},
|
||
{"no X-Probe-Sign", testProbeID, tsStr, ""},
|
||
}
|
||
for _, tc := range cases {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
r := httptest.NewRequest(http.MethodPost, "/probe/report", bytes.NewReader(body))
|
||
if tc.id != "" {
|
||
r.Header.Set("X-Probe-Id", tc.id)
|
||
}
|
||
if tc.ts != "" {
|
||
r.Header.Set("X-Probe-Ts", tc.ts)
|
||
}
|
||
if tc.sigHdr != "" {
|
||
r.Header.Set("X-Probe-Sign", tc.sigHdr)
|
||
}
|
||
w := httptest.NewRecorder()
|
||
h.ServeHTTP(w, r)
|
||
if w.Code != http.StatusUnauthorized {
|
||
t.Errorf("%s: got %d, want 401", tc.name, w.Code)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// TestIngestUnknownProbeID verifies that an unregistered probe ID returns 401.
|
||
func TestIngestUnknownProbeID(t *testing.T) {
|
||
h, _, _ := newTestSetup(t)
|
||
req := sampleRequest()
|
||
body, _ := json.Marshal(req)
|
||
tsStr := strconv.FormatInt(time.Now().Unix(), 10)
|
||
|
||
r := httptest.NewRequest(http.MethodPost, "/probe/report", bytes.NewReader(body))
|
||
r.Header.Set("X-Probe-Id", "not-registered")
|
||
r.Header.Set("X-Probe-Ts", tsStr)
|
||
r.Header.Set("X-Probe-Sign", sign(testSecret, tsStr, body))
|
||
|
||
w := httptest.NewRecorder()
|
||
h.ServeHTTP(w, r)
|
||
|
||
if w.Code != http.StatusUnauthorized {
|
||
t.Errorf("unknown probe: got %d, want 401", w.Code)
|
||
}
|
||
}
|
||
|
||
// TestIngestTimestampExpired verifies that a stale timestamp (> 300s old)
|
||
// returns 401.
|
||
func TestIngestTimestampExpired(t *testing.T) {
|
||
h, _, _ := newTestSetup(t)
|
||
req := sampleRequest()
|
||
body, _ := json.Marshal(req)
|
||
|
||
// 10 minutes in the past – well outside the ±300 s window.
|
||
staleTS := strconv.FormatInt(time.Now().Add(-10*time.Minute).Unix(), 10)
|
||
|
||
r := httptest.NewRequest(http.MethodPost, "/probe/report", bytes.NewReader(body))
|
||
r.Header.Set("X-Probe-Id", testProbeID)
|
||
r.Header.Set("X-Probe-Ts", staleTS)
|
||
r.Header.Set("X-Probe-Sign", sign(testSecret, staleTS, body))
|
||
|
||
w := httptest.NewRecorder()
|
||
h.ServeHTTP(w, r)
|
||
|
||
if w.Code != http.StatusUnauthorized {
|
||
t.Errorf("stale ts: got %d, want 401", w.Code)
|
||
}
|
||
}
|
||
|
||
// TestIngestTimestampFuture verifies that a far-future timestamp is also
|
||
// rejected (outside the ±300 s window).
|
||
func TestIngestTimestampFuture(t *testing.T) {
|
||
h, _, _ := newTestSetup(t)
|
||
req := sampleRequest()
|
||
body, _ := json.Marshal(req)
|
||
|
||
futureTS := strconv.FormatInt(time.Now().Add(10*time.Minute).Unix(), 10)
|
||
|
||
r := httptest.NewRequest(http.MethodPost, "/probe/report", bytes.NewReader(body))
|
||
r.Header.Set("X-Probe-Id", testProbeID)
|
||
r.Header.Set("X-Probe-Ts", futureTS)
|
||
r.Header.Set("X-Probe-Sign", sign(testSecret, futureTS, body))
|
||
|
||
w := httptest.NewRecorder()
|
||
h.ServeHTTP(w, r)
|
||
|
||
if w.Code != http.StatusUnauthorized {
|
||
t.Errorf("future ts: got %d, want 401", w.Code)
|
||
}
|
||
}
|
||
|
||
// TestIngestHMACConstantTime verifies that an HMAC of correct length but with
|
||
// one byte flipped is still rejected. This guards against a non-constant-time
|
||
// comparison that might accept a zero-byte suffix match.
|
||
func TestIngestHMACConstantTime(t *testing.T) {
|
||
h, _, _ := newTestSetup(t)
|
||
req := sampleRequest()
|
||
body, _ := json.Marshal(req)
|
||
tsStr := strconv.FormatInt(time.Now().Unix(), 10)
|
||
|
||
// Produce a valid MAC then flip the last byte.
|
||
mac := hmac.New(sha256.New, []byte(testSecret))
|
||
mac.Write([]byte(tsStr))
|
||
mac.Write([]byte("\n"))
|
||
mac.Write(body)
|
||
correctMAC := mac.Sum(nil)
|
||
correctMAC[len(correctMAC)-1] ^= 0xFF
|
||
tamperedSign := hex.EncodeToString(correctMAC)
|
||
|
||
r := httptest.NewRequest(http.MethodPost, "/probe/report", bytes.NewReader(body))
|
||
r.Header.Set("X-Probe-Id", testProbeID)
|
||
r.Header.Set("X-Probe-Ts", tsStr)
|
||
r.Header.Set("X-Probe-Sign", tamperedSign)
|
||
|
||
w := httptest.NewRecorder()
|
||
h.ServeHTTP(w, r)
|
||
|
||
if w.Code != http.StatusUnauthorized {
|
||
t.Errorf("tampered HMAC: got %d, want 401", w.Code)
|
||
}
|
||
}
|
||
|
||
// --------------------------------------------------------------------------
|
||
// Integration tests – valid requests + Redis state
|
||
// --------------------------------------------------------------------------
|
||
|
||
// TestIngestValidReport is the full happy-path integration test.
|
||
// After a valid POST /probe/report the handler must:
|
||
// 1. Return 202 Accepted.
|
||
// 2. Write probe:{nodeID}:{vantage} in Redis with TTL ≈ 30 min.
|
||
// 3. Write probe:hb:{probeID} in Redis with TTL ≈ 15 min.
|
||
func TestIngestValidReport(t *testing.T) {
|
||
h, _, mr := newTestSetup(t)
|
||
|
||
req := sampleRequest()
|
||
httpReq := buildHTTPRequest(t, req)
|
||
w := httptest.NewRecorder()
|
||
h.ServeHTTP(w, httpReq)
|
||
|
||
if w.Code != http.StatusAccepted {
|
||
t.Fatalf("valid report: got %d, want 202; body=%s", w.Code, w.Body.String())
|
||
}
|
||
|
||
// --- Verify Redis snapshot key ---
|
||
// Expected key: probe:node-hk-01:CN:Guangdong:China_Telecom
|
||
snapKey := "probe:node-hk-01:CN:Guangdong:China_Telecom"
|
||
val, err := mr.Get(snapKey)
|
||
if err != nil {
|
||
t.Fatalf("snapshot key %q not found in Redis: %v", snapKey, err)
|
||
}
|
||
|
||
var snap probe.ProbeSnapshot
|
||
if err := json.Unmarshal([]byte(val), &snap); err != nil {
|
||
t.Fatalf("unmarshal snapshot: %v", err)
|
||
}
|
||
if snap.ProbeID != testProbeID {
|
||
t.Errorf("snapshot.ProbeID = %q, want %q", snap.ProbeID, testProbeID)
|
||
}
|
||
if snap.Report.NodeID != "node-hk-01" {
|
||
t.Errorf("snapshot.Report.NodeID = %q, want node-hk-01", snap.Report.NodeID)
|
||
}
|
||
if !snap.Report.L1.OK {
|
||
t.Error("snapshot L1.OK should be true")
|
||
}
|
||
if snap.ReceivedAt == 0 {
|
||
t.Error("snapshot.ReceivedAt should be non-zero")
|
||
}
|
||
|
||
// TTL should be close to 30 minutes (1800s). Allow ±5s for test latency.
|
||
snapTTL := mr.TTL(snapKey)
|
||
if snapTTL < 1795*time.Second || snapTTL > 1800*time.Second {
|
||
t.Errorf("snapshot TTL = %v, want ≈30 min", snapTTL)
|
||
}
|
||
|
||
// --- Verify heartbeat key ---
|
||
hbKey := "probe:hb:" + testProbeID
|
||
hbVal, err := mr.Get(hbKey)
|
||
if err != nil {
|
||
t.Fatalf("heartbeat key %q not found in Redis: %v", hbKey, err)
|
||
}
|
||
if hbVal == "" {
|
||
t.Error("heartbeat value should not be empty")
|
||
}
|
||
|
||
// TTL should be close to 15 minutes (900s).
|
||
hbTTL := mr.TTL(hbKey)
|
||
if hbTTL < 895*time.Second || hbTTL > 900*time.Second {
|
||
t.Errorf("heartbeat TTL = %v, want ≈15 min", hbTTL)
|
||
}
|
||
}
|
||
|
||
// TestIngestReplayIdempotent verifies that submitting the same (probeID, ts)
|
||
// a second time is treated as an idempotent replay:
|
||
// - First request: 202, Redis written.
|
||
// - Second request: 202, no error (no double-write).
|
||
func TestIngestReplayIdempotent(t *testing.T) {
|
||
h, _, mr := newTestSetup(t)
|
||
|
||
req := sampleRequest()
|
||
body, _ := json.Marshal(req)
|
||
tsStr := strconv.FormatInt(time.Now().Unix(), 10)
|
||
|
||
buildReq := func() *http.Request {
|
||
r := httptest.NewRequest(http.MethodPost, "/probe/report", bytes.NewReader(body))
|
||
r.Header.Set("Content-Type", "application/json")
|
||
r.Header.Set("X-Probe-Id", testProbeID)
|
||
r.Header.Set("X-Probe-Ts", tsStr) // same ts both times
|
||
r.Header.Set("X-Probe-Sign", sign(testSecret, tsStr, body))
|
||
return r
|
||
}
|
||
|
||
// First request.
|
||
w1 := httptest.NewRecorder()
|
||
h.ServeHTTP(w1, buildReq())
|
||
if w1.Code != http.StatusAccepted {
|
||
t.Fatalf("first request: got %d, want 202", w1.Code)
|
||
}
|
||
|
||
// Snapshot key must exist after first request.
|
||
snapKey := "probe:node-hk-01:CN:Guangdong:China_Telecom"
|
||
if _, err := mr.Get(snapKey); err != nil {
|
||
t.Fatalf("snapshot key missing after first request: %v", err)
|
||
}
|
||
|
||
// Second request (same ts – replay).
|
||
w2 := httptest.NewRecorder()
|
||
h.ServeHTTP(w2, buildReq())
|
||
if w2.Code != http.StatusAccepted {
|
||
t.Fatalf("replay request: got %d, want 202 (idempotent)", w2.Code)
|
||
}
|
||
|
||
// Replay prevention key should exist.
|
||
seenKey := "probe:seen:" + testProbeID + ":" + tsStr
|
||
if _, err := mr.Get(seenKey); err != nil {
|
||
t.Errorf("seen key %q not found: %v", seenKey, err)
|
||
}
|
||
}
|
||
|
||
// TestIngestMultipleReports verifies that a batch with multiple node reports
|
||
// writes one snapshot key per node.
|
||
func TestIngestMultipleReports(t *testing.T) {
|
||
h, _, mr := newTestSetup(t)
|
||
|
||
req := probe.ReportRequest{
|
||
ProbeID: testProbeID,
|
||
Vantage: probe.VantagePoint{Country: "CN", Region: "Shanghai", ISP: "ChinaNet"},
|
||
Reports: []probe.NodeReport{
|
||
{NodeID: "node-sg-01", Ts: time.Now().Unix(), L1: probe.L1Result{OK: true, RttMs: 80}},
|
||
{NodeID: "node-jp-01", Ts: time.Now().Unix(), L1: probe.L1Result{OK: false}},
|
||
},
|
||
}
|
||
httpReq := buildHTTPRequest(t, req)
|
||
w := httptest.NewRecorder()
|
||
h.ServeHTTP(w, httpReq)
|
||
|
||
if w.Code != http.StatusAccepted {
|
||
t.Fatalf("multi-report: got %d, want 202", w.Code)
|
||
}
|
||
|
||
for _, nodeID := range []string{"node-sg-01", "node-jp-01"} {
|
||
key := "probe:" + nodeID + ":CN:Shanghai:ChinaNet"
|
||
if _, err := mr.Get(key); err != nil {
|
||
t.Errorf("snapshot for %s not found: %v", nodeID, err)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestIngestL3Optional verifies that a report without L3 (third-party probe)
|
||
// is accepted normally.
|
||
func TestIngestL3Optional(t *testing.T) {
|
||
h, _, _ := newTestSetup(t)
|
||
|
||
req := probe.ReportRequest{
|
||
ProbeID: testProbeID,
|
||
Vantage: probe.VantagePoint{Country: "JP", Region: "Tokyo", ISP: "NTT"},
|
||
Reports: []probe.NodeReport{
|
||
{
|
||
NodeID: "node-jp-02",
|
||
Ts: time.Now().Unix(),
|
||
L1: probe.L1Result{OK: true, RttMs: 15},
|
||
L2: &probe.L2Result{OK: true, ALPN: "h3"},
|
||
// L3 intentionally absent.
|
||
},
|
||
},
|
||
}
|
||
httpReq := buildHTTPRequest(t, req)
|
||
w := httptest.NewRecorder()
|
||
h.ServeHTTP(w, httpReq)
|
||
|
||
if w.Code != http.StatusAccepted {
|
||
t.Errorf("L3-absent report: got %d, want 202", w.Code)
|
||
}
|
||
}
|
||
|
||
// --------------------------------------------------------------------------
|
||
// Store unit tests
|
||
// --------------------------------------------------------------------------
|
||
|
||
// TestStoreSnapshotsByNode verifies that SnapshotsByNode returns the correct
|
||
// map and that a missing key (no data) is represented as an empty map, not
|
||
// as an error or failure.
|
||
func TestStoreSnapshotsByNode(t *testing.T) {
|
||
mr := miniredis.RunT(t)
|
||
rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()})
|
||
st := probe.NewStore(rdb)
|
||
ctx := context.Background()
|
||
|
||
// No data yet – must return empty map, not error.
|
||
snaps, err := st.SnapshotsByNode(ctx, "node-xx-01")
|
||
if err != nil {
|
||
t.Fatalf("SnapshotsByNode with no data: unexpected error: %v", err)
|
||
}
|
||
if len(snaps) != 0 {
|
||
t.Errorf("expected empty map, got %d entries", len(snaps))
|
||
}
|
||
|
||
// Write one snapshot via SaveReports.
|
||
reports := []probe.NodeReport{
|
||
{NodeID: "node-xx-01", Ts: time.Now().Unix(), L1: probe.L1Result{OK: true, RttMs: 10}},
|
||
}
|
||
if err := st.SaveReports(ctx, "probe-a", probe.VantagePoint{Country: "SG", Region: "Central", ISP: "Singtel"}, reports); err != nil {
|
||
t.Fatalf("SaveReports: %v", err)
|
||
}
|
||
|
||
snaps, err = st.SnapshotsByNode(ctx, "node-xx-01")
|
||
if err != nil {
|
||
t.Fatalf("SnapshotsByNode after write: %v", err)
|
||
}
|
||
if len(snaps) != 1 {
|
||
t.Fatalf("expected 1 snapshot, got %d", len(snaps))
|
||
}
|
||
// The vantage key in the map should be Country:Region:ISP.
|
||
if _, ok := snaps["SG:Central:Singtel"]; !ok {
|
||
t.Errorf("expected key SG:Central:Singtel in snapshots; got keys: %v", mapKeys(snaps))
|
||
}
|
||
}
|
||
|
||
// TestStoreAliveProbes verifies that AliveProbes returns probes that have an
|
||
// active heartbeat and an empty slice (not an error) when none have reported.
|
||
func TestStoreAliveProbes(t *testing.T) {
|
||
mr := miniredis.RunT(t)
|
||
rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()})
|
||
st := probe.NewStore(rdb)
|
||
ctx := context.Background()
|
||
|
||
// No heartbeats yet – empty list, not error.
|
||
ids, err := st.AliveProbes(ctx)
|
||
if err != nil {
|
||
t.Fatalf("AliveProbes with no data: unexpected error: %v", err)
|
||
}
|
||
if len(ids) != 0 {
|
||
t.Errorf("expected empty list, got %v", ids)
|
||
}
|
||
|
||
// Write a heartbeat for two probes.
|
||
for _, pid := range []string{"probe-a", "probe-b"} {
|
||
reports := []probe.NodeReport{
|
||
{NodeID: "node-xx-01", Ts: time.Now().Unix(), L1: probe.L1Result{OK: true}},
|
||
}
|
||
if err := st.SaveReports(ctx, pid, probe.VantagePoint{Country: "HK"}, reports); err != nil {
|
||
t.Fatalf("SaveReports(%s): %v", pid, err)
|
||
}
|
||
}
|
||
|
||
ids, err = st.AliveProbes(ctx)
|
||
if err != nil {
|
||
t.Fatalf("AliveProbes after writes: %v", err)
|
||
}
|
||
if len(ids) != 2 {
|
||
t.Errorf("expected 2 alive probes, got %d: %v", len(ids), ids)
|
||
}
|
||
}
|
||
|
||
// TestStoreMissingHeartbeatIsNotFailure documents the "missing key = no data,
|
||
// not failure" contract for probe:hb:{probeID}.
|
||
func TestStoreMissingHeartbeatIsNotFailure(t *testing.T) {
|
||
mr := miniredis.RunT(t)
|
||
rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()})
|
||
st := probe.NewStore(rdb)
|
||
ctx := context.Background()
|
||
|
||
// AliveProbes on an empty Redis must NOT return an error or non-nil slice.
|
||
ids, err := st.AliveProbes(ctx)
|
||
if err != nil {
|
||
t.Fatalf("AliveProbes must not error on empty Redis: %v", err)
|
||
}
|
||
// An empty/nil slice is acceptable; a non-nil error is not.
|
||
// Callers (15D) must treat this as "unknown", not "all probes down".
|
||
_ = ids // zero-length is fine
|
||
}
|
||
|
||
// --------------------------------------------------------------------------
|
||
// Helper
|
||
// --------------------------------------------------------------------------
|
||
|
||
func mapKeys(m map[string]probe.ProbeSnapshot) []string {
|
||
keys := make([]string, 0, len(m))
|
||
for k := range m {
|
||
keys = append(keys, k)
|
||
}
|
||
return keys
|
||
}
|
||
|
||
// TestIngestMethodNotAllowed verifies GET returns 405.
|
||
func TestIngestMethodNotAllowed(t *testing.T) {
|
||
h, _, _ := newTestSetup(t)
|
||
r := httptest.NewRequest(http.MethodGet, "/probe/report", nil)
|
||
w := httptest.NewRecorder()
|
||
h.ServeHTTP(w, r)
|
||
if w.Code != http.StatusMethodNotAllowed {
|
||
t.Errorf("GET: got %d, want 405", w.Code)
|
||
}
|
||
}
|
||
|
||
// TestIngestEmptyReports verifies that a request with zero reports is rejected
|
||
// with 400.
|
||
func TestIngestEmptyReports(t *testing.T) {
|
||
h, _, _ := newTestSetup(t)
|
||
|
||
req := probe.ReportRequest{
|
||
ProbeID: testProbeID,
|
||
Vantage: probe.VantagePoint{Country: "CN", Region: "BJ", ISP: "Unicom"},
|
||
Reports: []probe.NodeReport{}, // empty
|
||
}
|
||
httpReq := buildHTTPRequest(t, req)
|
||
w := httptest.NewRecorder()
|
||
h.ServeHTTP(w, httpReq)
|
||
|
||
if w.Code != http.StatusBadRequest {
|
||
t.Errorf("empty reports: got %d, want 400", w.Code)
|
||
}
|
||
}
|
||
|
||
// TestVantageKeyEncoding verifies that vantage values with spaces and colons
|
||
// are sanitized and produce a valid Redis key.
|
||
func TestVantageKeyEncoding(t *testing.T) {
|
||
h, _, mr := newTestSetup(t)
|
||
|
||
req := probe.ReportRequest{
|
||
ProbeID: testProbeID,
|
||
Vantage: probe.VantagePoint{
|
||
ISP: "China Telecom:CN2", // contains space and colon
|
||
Region: "Inner Mongolia",
|
||
Country: "CN",
|
||
},
|
||
Reports: []probe.NodeReport{
|
||
{NodeID: "node-bj-01", Ts: time.Now().Unix(), L1: probe.L1Result{OK: true}},
|
||
},
|
||
}
|
||
httpReq := buildHTTPRequest(t, req)
|
||
w := httptest.NewRecorder()
|
||
h.ServeHTTP(w, httpReq)
|
||
|
||
if w.Code != http.StatusAccepted {
|
||
t.Fatalf("vantage encoding: got %d, want 202", w.Code)
|
||
}
|
||
|
||
// Space → _ and colon → _ in the ISP field.
|
||
expectedKey := "probe:node-bj-01:CN:Inner_Mongolia:China_Telecom_CN2"
|
||
if _, err := mr.Get(expectedKey); err != nil {
|
||
// List all keys to help diagnose.
|
||
keys := mr.Keys()
|
||
t.Errorf("expected key %q not found; existing keys: %v", expectedKey, keys)
|
||
}
|
||
}
|
||
|
||
// TestIngestSignatureUsesRawBody verifies that the HMAC is computed over the
|
||
// exact raw bytes sent on the wire, not a re-serialised form.
|
||
func TestIngestSignatureUsesRawBody(t *testing.T) {
|
||
h, _, _ := newTestSetup(t)
|
||
|
||
// Build raw body with extra whitespace (valid JSON, different bytes).
|
||
rawBody := []byte(`{ "probeId": "` + testProbeID + `", "vantage":{"isp":"ISP1","region":"R1","country":"CN"}, "reports":[{"nodeId":"n1","ts":1234567890,"l1":{"ok":true,"rttMs":1}}] }`)
|
||
tsStr := strconv.FormatInt(time.Now().Unix(), 10)
|
||
|
||
r := httptest.NewRequest(http.MethodPost, "/probe/report", bytes.NewReader(rawBody))
|
||
r.Header.Set("Content-Type", "application/json")
|
||
r.Header.Set("X-Probe-Id", testProbeID)
|
||
r.Header.Set("X-Probe-Ts", tsStr)
|
||
r.Header.Set("X-Probe-Sign", sign(testSecret, tsStr, rawBody))
|
||
|
||
w := httptest.NewRecorder()
|
||
h.ServeHTTP(w, r)
|
||
|
||
if w.Code != http.StatusAccepted {
|
||
t.Errorf("raw body sign: got %d, want 202; body=%s", w.Code, w.Body.String())
|
||
}
|
||
}
|