Files
pangolin/server/internal/scheduler/probe/ingest_test.go
T
wangjia f76e1797c2 merge: maestro/tsk_rBPr0Xuy10bz [tsk_rBPr0Xuy10bz] 探针汇聚入口 + Redis 探针存储 (tsk_eu2qVvni1HJR)
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>
2026-06-13 14:35:24 +08:00

625 lines
19 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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())
}
}