Files
flvx/go-backend/internal/http/handler/tunnel_best_exit_test.go
T
2026-05-01 23:20:50 +08:00

452 lines
18 KiB
Go

package handler
import (
"errors"
"fmt"
"slices"
"strings"
"testing"
"time"
)
var errBestExitProbeForTest = errors.New("probe failed")
func TestBestExitScoreCombinesLatencyAndLoss(t *testing.T) {
exit := chainNodeRecord{NodeID: 30, NodeName: "exit-a"}
score := scoreBestExitCandidate(10, exit, 25, 2, 80, 3)
if !score.Success {
t.Fatalf("expected successful score")
}
if score.OwnerNodeID != 10 || score.ExitNodeID != 30 {
t.Fatalf("unexpected owner/exit ids: %+v", score)
}
if score.TotalLatency != 105 {
t.Fatalf("expected total latency 105, got %v", score.TotalLatency)
}
if score.TotalLoss < 4.9 || score.TotalLoss > 5.0 {
t.Fatalf("expected combined loss about 4.94, got %v", score.TotalLoss)
}
if score.Score < 599 || score.Score > 600 {
t.Fatalf("expected score about 599, got %v", score.Score)
}
}
func TestBestExitScorePenalizesLoss(t *testing.T) {
stable := scoreBestExitCandidate(10, chainNodeRecord{NodeID: 30}, 80, 0, 80, 0)
lowLatencyLossy := scoreBestExitCandidate(10, chainNodeRecord{NodeID: 31}, 10, 5, 10, 5)
if !bestExitScoreLess(stable, lowLatencyLossy) {
t.Fatalf("expected stable exit to beat low-latency lossy exit: stable=%+v lossy=%+v", stable, lowLatencyLossy)
}
}
func TestBestExitFailedCandidateSortsLast(t *testing.T) {
failed := failedBestExitCandidate(10, chainNodeRecord{NodeID: 30}, "dial timeout")
good := scoreBestExitCandidate(10, chainNodeRecord{NodeID: 31}, 100, 0, 100, 0)
scores := []bestExitCandidateScore{failed, good}
sortBestExitScores(scores)
if scores[0].ExitNodeID != 31 || scores[1].ExitNodeID != 30 {
t.Fatalf("expected good score first and failed score last, got %+v", scores)
}
}
func TestBestExitInitialObservationAppliesWithoutSwitch(t *testing.T) {
m := newBestExitManager()
key := bestExitOwnerKey{TunnelID: 7, OwnerNodeID: 10}
now := time.Unix(100, 0)
candidate := scoreBestExitCandidate(10, chainNodeRecord{NodeID: 31}, 40, 0, 60, 0)
decision := m.observeScores(key, []bestExitCandidateScore{candidate}, now)
if decision.Switch {
t.Fatalf("initial observation should not return switch: %+v", decision)
}
if m.decisions[key].AppliedExitNodeID != 31 {
t.Fatalf("expected applied exit 31, got %+v", m.decisions[key])
}
}
func TestBestExitDecisionRequiresMinimumAdvantage(t *testing.T) {
m := newBestExitManager()
key := bestExitOwnerKey{TunnelID: 7, OwnerNodeID: 10}
now := time.Unix(100, 0)
current := scoreBestExitCandidate(10, chainNodeRecord{NodeID: 30}, 100, 0, 100, 0)
candidate := scoreBestExitCandidate(10, chainNodeRecord{NodeID: 31}, 90, 0, 90, 0)
m.setApplied(key, 30, now.Add(-time.Minute))
for i := 0; i < bestExitConfirmationRounds+1; i++ {
decision := m.observeScores(key, []bestExitCandidateScore{candidate, current}, now.Add(time.Duration(i)*time.Second))
if decision.Switch {
t.Fatalf("candidate below minimum advantage should not switch after repeated observations: %+v", decision)
}
}
if m.decisions[key].AppliedExitNodeID != 30 {
t.Fatalf("expected applied exit to remain 30, got %+v", m.decisions[key])
}
}
func TestBestExitDecisionSwitchesWithMinimumAdvantage(t *testing.T) {
m := newBestExitManager()
key := bestExitOwnerKey{TunnelID: 7, OwnerNodeID: 10}
now := time.Unix(100, 0)
current := scoreBestExitCandidate(10, chainNodeRecord{NodeID: 30}, 100, 0, 100, 0)
candidate := scoreBestExitCandidate(10, chainNodeRecord{NodeID: 31}, 40, 0, 60, 0)
m.setApplied(key, 30, now.Add(-time.Minute))
for i := 0; i < bestExitConfirmationRounds-1; i++ {
decision := m.observeScores(key, []bestExitCandidateScore{candidate, current}, now.Add(time.Duration(i)*time.Second))
if decision.Switch {
t.Fatalf("candidate should wait for confirmations before switching: %+v", decision)
}
}
decision := m.observeScores(key, []bestExitCandidateScore{candidate, current}, now.Add((bestExitConfirmationRounds-1)*time.Second))
if !decision.Switch || decision.ExitNodeID != 31 {
t.Fatalf("candidate with enough advantage should switch after confirmations: %+v", decision)
}
}
func TestBestExitConfirmedSwitchDoesNotMarkAppliedUntilSetApplied(t *testing.T) {
m := newBestExitManager()
key := bestExitOwnerKey{TunnelID: 7, OwnerNodeID: 10}
now := time.Unix(100, 0)
current := scoreBestExitCandidate(10, chainNodeRecord{NodeID: 30}, 100, 0, 100, 0)
candidate := scoreBestExitCandidate(10, chainNodeRecord{NodeID: 31}, 40, 0, 60, 0)
m.setApplied(key, 30, now.Add(-time.Minute))
for i := 0; i < bestExitConfirmationRounds-1; i++ {
decision := m.observeScores(key, []bestExitCandidateScore{candidate, current}, now.Add(time.Duration(i)*time.Second))
if decision.Switch {
t.Fatalf("candidate should wait for confirmations before switching: %+v", decision)
}
}
decision := m.observeScores(key, []bestExitCandidateScore{candidate, current}, now.Add((bestExitConfirmationRounds-1)*time.Second))
if !decision.Switch || decision.ExitNodeID != 31 {
t.Fatalf("candidate with enough advantage should switch after confirmations: %+v", decision)
}
if m.decisions[key].AppliedExitNodeID != 30 {
t.Fatalf("confirmed switch should not mark applied before runtime update: %+v", m.decisions[key])
}
m.setApplied(key, decision.ExitNodeID, now.Add(time.Second))
if m.decisions[key].AppliedExitNodeID != 31 {
t.Fatalf("setApplied should commit confirmed switch: %+v", m.decisions[key])
}
}
func TestBestExitApplyFailureStartsRetryCooldownWithoutChangingAppliedExit(t *testing.T) {
m := newBestExitManager()
key := bestExitOwnerKey{TunnelID: 7, OwnerNodeID: 10}
now := time.Unix(100, 0)
current := scoreBestExitCandidate(10, chainNodeRecord{NodeID: 30}, 100, 0, 100, 0)
candidate := scoreBestExitCandidate(10, chainNodeRecord{NodeID: 31}, 40, 0, 60, 0)
m.setApplied(key, 30, now.Add(-time.Minute))
for i := 0; i < bestExitConfirmationRounds-1; i++ {
decision := m.observeScores(key, []bestExitCandidateScore{candidate, current}, now.Add(time.Duration(i)*time.Second))
if decision.Switch {
t.Fatalf("candidate should wait for confirmations before switching: %+v", decision)
}
}
confirmed := m.observeScores(key, []bestExitCandidateScore{candidate, current}, now.Add((bestExitConfirmationRounds-1)*time.Second))
if !confirmed.Switch || confirmed.ExitNodeID != 31 {
t.Fatalf("expected confirmed switch before apply failure: %+v", confirmed)
}
m.recordApplyFailure(key, confirmed.ExitNodeID, now.Add(bestExitConfirmationRounds*time.Second))
if m.decisions[key].AppliedExitNodeID != 30 {
t.Fatalf("apply failure should leave applied exit unchanged: %+v", m.decisions[key])
}
decision := m.observeScores(key, []bestExitCandidateScore{candidate, current}, now.Add((bestExitConfirmationRounds+1)*time.Second))
if decision.Switch {
t.Fatalf("apply retry cooldown should suppress immediate retry: %+v", decision)
}
if decision.Reason != "apply retry cooldown" {
t.Fatalf("expected apply retry cooldown reason, got %q", decision.Reason)
}
retry := m.observeScores(key, []bestExitCandidateScore{candidate, current}, now.Add(bestExitConfirmationRounds*time.Second+bestExitApplyRetryCooldown))
if !retry.Switch || retry.ExitNodeID != 31 {
t.Fatalf("expected retry after apply cooldown: %+v", retry)
}
}
func TestBestExitEnsureAppliedDoesNotOverrideExistingAppliedExit(t *testing.T) {
m := newBestExitManager()
key := bestExitOwnerKey{TunnelID: 7, OwnerNodeID: 10}
now := time.Unix(100, 0)
m.ensureApplied(key, 30, now)
if m.decisions[key].AppliedExitNodeID != 30 {
t.Fatalf("expected initial applied exit 30, got %+v", m.decisions[key])
}
if !m.decisions[key].LastSwitchAt.Equal(now) {
t.Fatalf("expected initial applied timestamp, got %+v", m.decisions[key])
}
m.ensureApplied(key, 31, now.Add(time.Minute))
if m.decisions[key].AppliedExitNodeID != 30 {
t.Fatalf("ensureApplied should not override existing applied exit: %+v", m.decisions[key])
}
}
func TestBestExitRoundPingerCachesByNodeHostAndPort(t *testing.T) {
publicCalls := 0
ownerCalls := 0
pinger := newBestExitRoundPinger(func(nodeID int64, ip string, port int, _ diagnosisExecOptions) (float64, float64, error) {
if ip == bestExitPublicTargetHost && port == bestExitPublicTargetPort {
publicCalls++
return float64(nodeID), 0, nil
}
ownerCalls++
return float64(ownerCalls), 0, nil
})
if lat, _, err := pinger(30, bestExitPublicTargetHost, bestExitPublicTargetPort, diagnosisExecOptions{}); err != nil || lat != 30 {
t.Fatalf("unexpected first public ping result lat=%v err=%v", lat, err)
}
if lat, _, err := pinger(30, bestExitPublicTargetHost, bestExitPublicTargetPort, diagnosisExecOptions{}); err != nil || lat != 30 {
t.Fatalf("unexpected cached public ping result lat=%v err=%v", lat, err)
}
if _, _, err := pinger(31, bestExitPublicTargetHost, bestExitPublicTargetPort, diagnosisExecOptions{}); err != nil {
t.Fatalf("unexpected second exit public ping err=%v", err)
}
if publicCalls != 2 {
t.Fatalf("expected public probes cached per exit node, got %d calls", publicCalls)
}
if _, _, err := pinger(10, "10.0.0.30", 30030, diagnosisExecOptions{}); err != nil {
t.Fatalf("unexpected owner ping err=%v", err)
}
if _, _, err := pinger(10, "10.0.0.30", 30030, diagnosisExecOptions{}); err != nil {
t.Fatalf("unexpected repeated owner ping err=%v", err)
}
if ownerCalls != 1 {
t.Fatalf("expected owner-to-exit probes cached by target, got %d calls", ownerCalls)
}
}
func TestBestExitDecisionScoresAreDefensiveCopies(t *testing.T) {
m := newBestExitManager()
key := bestExitOwnerKey{TunnelID: 7, OwnerNodeID: 10}
now := time.Unix(100, 0)
candidate := scoreBestExitCandidate(10, chainNodeRecord{NodeID: 31}, 40, 0, 60, 0)
current := scoreBestExitCandidate(10, chainNodeRecord{NodeID: 30}, 100, 0, 100, 0)
decision := m.observeScores(key, []bestExitCandidateScore{candidate, current}, now)
decision.Scores[0].ExitNodeID = 99
if m.decisions[key].Scores[0].ExitNodeID != 31 {
t.Fatalf("decision scores mutation leaked into manager state: %+v", m.decisions[key].Scores)
}
}
func TestBestExitDecisionRequiresConfirmationsAndCooldown(t *testing.T) {
m := newBestExitManager()
key := bestExitOwnerKey{TunnelID: 7, OwnerNodeID: 10}
now := time.Unix(100, 0)
current := scoreBestExitCandidate(10, chainNodeRecord{NodeID: 30}, 100, 0, 100, 0)
candidate := scoreBestExitCandidate(10, chainNodeRecord{NodeID: 31}, 40, 0, 60, 0)
m.setApplied(key, 30, now.Add(-time.Minute))
if decision := m.observeScores(key, []bestExitCandidateScore{candidate, current}, now); decision.Switch {
t.Fatalf("first observation should not switch: %+v", decision)
}
if decision := m.observeScores(key, []bestExitCandidateScore{candidate, current}, now.Add(time.Second)); decision.Switch {
t.Fatalf("second observation should not switch: %+v", decision)
}
decision := m.observeScores(key, []bestExitCandidateScore{candidate, current}, now.Add(2*time.Second))
if !decision.Switch || decision.ExitNodeID != 31 {
t.Fatalf("third confirmed observation should switch to 31: %+v", decision)
}
betterAgain := scoreBestExitCandidate(10, chainNodeRecord{NodeID: 30}, 20, 0, 20, 0)
if decision := m.observeScores(key, []bestExitCandidateScore{betterAgain, candidate}, now.Add(3*time.Second)); decision.Switch {
t.Fatalf("cooldown should block immediate switch back: %+v", decision)
}
}
func TestBestExitOrderingUsesAppliedDecision(t *testing.T) {
m := newBestExitManager()
key := bestExitOwnerKey{TunnelID: 7, OwnerNodeID: 10}
m.setApplied(key, 31, time.Unix(100, 0))
targets := []tunnelRuntimeNode{
{NodeID: 30, Strategy: tunnelStrategyBest},
{NodeID: 31, Strategy: tunnelStrategyBest},
{NodeID: 32, Strategy: tunnelStrategyBest},
}
ordered := m.orderTargets(key, targets)
if ordered[0].NodeID != 31 || ordered[1].NodeID != 30 || ordered[2].NodeID != 32 {
t.Fatalf("unexpected order: %+v", ordered)
}
if targets[0].NodeID != 30 {
t.Fatalf("orderTargets mutated input: %+v", targets)
}
}
func TestBuildTunnelChainConfigMapsBestStrategyToFIFO(t *testing.T) {
nodes := map[int64]*nodeRecord{
10: {ID: 10, ServerIP: "10.0.0.10", ServerIPv4: "10.0.0.10", TCPListenAddr: "[::]"},
30: {ID: 30, ServerIP: "10.0.0.30", ServerIPv4: "10.0.0.30", TCPListenAddr: "[::]"},
31: {ID: 31, ServerIP: "10.0.0.31", ServerIPv4: "10.0.0.31", TCPListenAddr: "[::]"},
}
targets := []tunnelRuntimeNode{
{NodeID: 30, Port: 30030, Protocol: "tls", Strategy: tunnelStrategyBest, ChainType: 3},
{NodeID: 31, Port: 30031, Protocol: "tls", Strategy: tunnelStrategyBest, ChainType: 3},
}
chainData, err := buildTunnelChainConfig(77, 10, targets, nodes, "")
if err != nil {
t.Fatalf("build chain: %v", err)
}
hops := chainData["hops"].([]map[string]interface{})
selector := hops[0]["selector"].(map[string]interface{})
if selector["strategy"] != bestExitRuntimeStrategy {
t.Fatalf("expected best to render as fifo, got %v", selector["strategy"])
}
}
func TestHandlerOrdersBestExitTargetsForOwner(t *testing.T) {
h := &Handler{bestExit: newBestExitManager()}
key := bestExitOwnerKey{TunnelID: 77, OwnerNodeID: 10}
h.bestExit.setApplied(key, 31, time.Unix(100, 0))
targets := []tunnelRuntimeNode{
{NodeID: 30, Port: 30030, Strategy: tunnelStrategyBest},
{NodeID: 31, Port: 30031, Strategy: tunnelStrategyBest},
}
ordered := h.orderBestExitTargets(77, 10, targets)
if ordered[0].NodeID != 31 || ordered[1].NodeID != 30 {
t.Fatalf("unexpected ordered targets: %+v", ordered)
}
}
func TestRuntimeStrategyForTargetsMapsBestTargetStrategyToFIFO(t *testing.T) {
owner := tunnelRuntimeNode{Strategy: "round"}
targets := []tunnelRuntimeNode{{Strategy: tunnelStrategyBest}}
if got := runtimeStrategyForTargets(owner, targets); got != bestExitRuntimeStrategy {
t.Fatalf("expected best target strategy to map to fifo, got %q", got)
}
}
func TestRuntimeStrategyForTargetsPreservesNonBestTargetStrategy(t *testing.T) {
owner := tunnelRuntimeNode{Strategy: tunnelStrategyBest}
targets := []tunnelRuntimeNode{{Strategy: "round"}}
if got := runtimeStrategyForTargets(owner, targets); got != "round" {
t.Fatalf("expected target strategy round to remain unchanged, got %q", got)
}
}
func TestRuntimeStrategyForTargetsMapsBestOwnerStrategyWhenTargetsEmpty(t *testing.T) {
owner := tunnelRuntimeNode{Strategy: tunnelStrategyBest}
if got := runtimeStrategyForTargets(owner, nil); got != bestExitRuntimeStrategy {
t.Fatalf("expected best owner fallback strategy to map to fifo, got %q", got)
}
}
func TestEvaluateBestExitOwnerScoresAllCandidates(t *testing.T) {
owner := chainNodeRecord{NodeID: 10, NodeName: "entry"}
exits := []chainNodeRecord{
{NodeID: 30, NodeName: "exit-a", Port: 30030},
{NodeID: 31, NodeName: "exit-b", Port: 30031},
}
nodes := map[int64]*nodeRecord{
10: {ID: 10, ServerIP: "10.0.0.10", ServerIPv4: "10.0.0.10", TCPListenAddr: "[::]"},
30: {ID: 30, ServerIP: "10.0.0.30", ServerIPv4: "10.0.0.30", TCPListenAddr: "[::]"},
31: {ID: 31, ServerIP: "10.0.0.31", ServerIPv4: "10.0.0.31", TCPListenAddr: "[::]"},
}
pinger := func(nodeID int64, ip string, port int, _ diagnosisExecOptions) (float64, float64, error) {
switch {
case nodeID == 10 && port == 30030:
return 60, 0, nil
case nodeID == 10 && port == 30031:
return 20, 0, nil
case nodeID == 30 && ip == bestExitPublicTargetHost:
return 60, 0, nil
case nodeID == 31 && ip == bestExitPublicTargetHost:
return 20, 0, nil
default:
t.Fatalf("unexpected ping node=%d ip=%s port=%d", nodeID, ip, port)
return 0, 100, nil
}
}
scores := evaluateBestExitOwner(owner, exits, nodes, "", diagnosisExecOptions{}, defaultTunnelProbeTarget(), pinger)
if len(scores) != 2 {
t.Fatalf("expected two scores, got %+v", scores)
}
if scores[0].ExitNodeID != 31 {
t.Fatalf("expected exit-b first, got %+v", scores)
}
}
func TestEvaluateBestExitOwnerUsesConfiguredPublicProbeTarget(t *testing.T) {
owner := chainNodeRecord{NodeID: 10, NodeName: "entry-a"}
exits := []chainNodeRecord{{NodeID: 30, NodeName: "exit-a", Port: 30001}}
nodes := map[int64]*nodeRecord{
10: {ID: 10, Name: "entry-a", ServerIP: "10.0.0.10", ServerIPv4: "10.0.0.10"},
30: {ID: 30, Name: "exit-a", ServerIP: "10.0.0.30", ServerIPv4: "10.0.0.30"},
}
target := tunnelProbeTarget{Host: "speed.example.com", Port: 8443}
var calls []string
ping := func(nodeID int64, ip string, port int, options diagnosisExecOptions) (float64, float64, error) {
calls = append(calls, fmt.Sprintf("%d|%s|%d", nodeID, ip, port))
return 10, 0, nil
}
scores := evaluateBestExitOwner(owner, exits, nodes, "", diagnosisExecOptions{}, target, ping)
if len(scores) != 1 || !scores[0].Success {
t.Fatalf("expected successful score, got %+v", scores)
}
if !slices.Contains(calls, "30|speed.example.com|8443") {
t.Fatalf("expected exit public probe to use configured target, calls=%+v", calls)
}
for _, call := range calls {
if strings.Contains(call, defaultTunnelProbeTargetHost) {
t.Fatalf("did not expect default target call when custom target configured: %+v", calls)
}
}
}
func TestEvaluateBestExitOwnerMarksCandidateFailedWhenOwnerToExitFails(t *testing.T) {
owner := chainNodeRecord{NodeID: 10, NodeName: "entry"}
exits := []chainNodeRecord{{NodeID: 30, NodeName: "exit-a", Port: 30030}}
nodes := map[int64]*nodeRecord{
10: {ID: 10, ServerIP: "10.0.0.10", ServerIPv4: "10.0.0.10", TCPListenAddr: "[::]"},
30: {ID: 30, ServerIP: "10.0.0.30", ServerIPv4: "10.0.0.30", TCPListenAddr: "[::]"},
}
pinger := func(nodeID int64, ip string, port int, _ diagnosisExecOptions) (float64, float64, error) {
return 0, 100, errBestExitProbeForTest
}
scores := evaluateBestExitOwner(owner, exits, nodes, "", diagnosisExecOptions{}, defaultTunnelProbeTarget(), pinger)
if len(scores) != 1 || scores[0].Success {
t.Fatalf("expected failed candidate, got %+v", scores)
}
}
func TestEvaluateBestExitOwnerMarksCandidateFailedWhenTargetResolutionFails(t *testing.T) {
owner := chainNodeRecord{NodeID: 10, NodeName: "entry"}
exits := []chainNodeRecord{{NodeID: 30, NodeName: "exit-v6", Port: 30030}}
nodes := map[int64]*nodeRecord{
10: {ID: 10, Name: "entry", ServerIP: "10.0.0.10", ServerIPv4: "10.0.0.10", TCPListenAddr: "[::]"},
30: {ID: 30, Name: "exit-v6", ServerIP: "2001:db8::30", ServerIPv6: "2001:db8::30", TCPListenAddr: "[::]"},
}
pinger := func(nodeID int64, ip string, port int, _ diagnosisExecOptions) (float64, float64, error) {
t.Fatalf("ping should not be called when target resolution fails: node=%d ip=%s port=%d", nodeID, ip, port)
return 0, 100, nil
}
scores := evaluateBestExitOwner(owner, exits, nodes, "v4", diagnosisExecOptions{}, defaultTunnelProbeTarget(), pinger)
if len(scores) != 1 || scores[0].Success {
t.Fatalf("expected failed candidate, got %+v", scores)
}
}