Files
x/router/router_test.go
T
ginuerzh 4443b0b964 fix(router): close httpLoader on shutdown, log parse errors, default nil logger
- Add missing httpLoader.Close() in localRouter.Close() to prevent
  resource leak
- Log parse errors from file/redis/http loaders instead of silently
  discarding them
- Add nil reader guards before calling parseRoutes in load()
- Default nil logger to xlogger.Nop() to prevent nil panics
- Use context.Background() instead of context.TODO() for root context
- Use strings.ReplaceAll instead of deprecated strings.Replace with -1
- Add doc comments to all exported symbols
- Add 27 unit tests + 1 benchmark
2026-05-25 23:03:19 +08:00

802 lines
20 KiB
Go

package router
import (
"context"
"errors"
"fmt"
"io"
"net"
"strings"
"testing"
"time"
"github.com/go-gost/core/router"
"github.com/go-gost/x/internal/loader"
xlogger "github.com/go-gost/x/logger"
)
// ---------------------------------------------------------------------------
// Mock helpers
// ---------------------------------------------------------------------------
// closeRouter closes a router if it implements io.Closer.
func closeRouter(r router.Router) {
if c, ok := r.(interface{ Close() error }); ok {
c.Close()
}
}
// mockLoader implements loader.Loader for testing.
type mockLoader struct {
data string
err error
closed bool
}
func (m *mockLoader) Load(_ context.Context) (io.Reader, error) {
if m.err != nil {
return nil, m.err
}
return strings.NewReader(m.data), nil
}
func (m *mockLoader) Close() error {
m.closed = true
return nil
}
// mockLister implements both loader.Loader and loader.Lister.
type mockLister struct {
lines []string
loadOk bool // if true, Load also works
closed bool
}
func (m *mockLister) Load(_ context.Context) (io.Reader, error) {
return strings.NewReader(strings.Join(m.lines, "\n")), nil
}
func (m *mockLister) List(_ context.Context) ([]string, error) {
return m.lines, nil
}
func (m *mockLister) Close() error {
m.closed = true
return nil
}
// errLoader always fails to load.
type errLoader struct {
closed bool
}
func (e *errLoader) Load(_ context.Context) (io.Reader, error) {
return nil, errors.New("load failed")
}
func (e *errLoader) Close() error {
e.closed = true
return nil
}
// errLister always fails to list.
type errLister struct {
closed bool
}
func (e *errLister) Load(_ context.Context) (io.Reader, error) {
return nil, errors.New("load failed")
}
func (e *errLister) List(_ context.Context) ([]string, error) {
return nil, errors.New("list failed")
}
func (e *errLister) Close() error {
e.closed = true
return nil
}
// ---------------------------------------------------------------------------
// ParseRoute tests
// ---------------------------------------------------------------------------
func TestParseRoute_CIDR(t *testing.T) {
r := ParseRoute("10.0.0.0/8", "192.168.1.1")
if r == nil {
t.Fatal("expected non-nil route")
}
if r.Dst != "10.0.0.0/8" {
t.Errorf("Dst = %q, want %q", r.Dst, "10.0.0.0/8")
}
if r.Gateway != "192.168.1.1" {
t.Errorf("Gateway = %q, want %q", r.Gateway, "192.168.1.1")
}
if r.Net == nil {
t.Fatal("expected non-nil Net for CIDR")
}
if !r.Net.Contains(net.ParseIP("10.1.2.3")) {
t.Error("Net should contain 10.1.2.3")
}
}
func TestParseRoute_EmptyDst(t *testing.T) {
if r := ParseRoute("", "1.2.3.4"); r != nil {
t.Errorf("expected nil for empty dst, got %+v", r)
}
}
func TestParseRoute_InvalidCIDR(t *testing.T) {
// Non-CIDR string: Net will be nil but route is still returned
r := ParseRoute("not-a-cidr", "1.2.3.4")
if r == nil {
t.Fatal("expected non-nil route even for invalid CIDR")
}
if r.Net != nil {
t.Error("expected nil Net for invalid CIDR")
}
if r.Dst != "not-a-cidr" {
t.Errorf("Dst = %q, want %q", r.Dst, "not-a-cidr")
}
}
func TestParseRoute_PlainIP(t *testing.T) {
r := ParseRoute("192.168.1.0", "10.0.0.1")
if r == nil {
t.Fatal("expected non-nil route")
}
if r.Net != nil {
t.Error("expected nil Net for plain IP (not CIDR)")
}
}
// ---------------------------------------------------------------------------
// parseLine tests
// ---------------------------------------------------------------------------
func TestParseLine_Valid(t *testing.T) {
lr := &localRouter{}
r := lr.parseLine("10.0.0.0/8 192.168.1.1")
if r == nil {
t.Fatal("expected non-nil route")
}
if r.Dst != "10.0.0.0/8" {
t.Errorf("Dst = %q", r.Dst)
}
if r.Gateway != "192.168.1.1" {
t.Errorf("Gateway = %q", r.Gateway)
}
}
func TestParseLine_TabSeparated(t *testing.T) {
lr := &localRouter{}
r := lr.parseLine("10.0.0.0/8\t192.168.1.1")
if r == nil {
t.Fatal("tabs should be treated as separators")
}
if r.Dst != "10.0.0.0/8" {
t.Errorf("Dst = %q", r.Dst)
}
}
func TestParseLine_Comment(t *testing.T) {
lr := &localRouter{}
r := lr.parseLine("10.0.0.0/8 192.168.1.1 # default route")
if r == nil {
t.Fatal("expected non-nil route, comment should be stripped")
}
}
func TestParseLine_CommentOnly(t *testing.T) {
lr := &localRouter{}
if r := lr.parseLine("# just a comment"); r != nil {
t.Errorf("comment-only line should return nil, got %+v", r)
}
}
func TestParseLine_Empty(t *testing.T) {
lr := &localRouter{}
if r := lr.parseLine(""); r != nil {
t.Errorf("empty line should return nil")
}
if r := lr.parseLine(" "); r != nil {
t.Errorf("whitespace line should return nil")
}
}
func TestParseLine_TooFewFields(t *testing.T) {
lr := &localRouter{}
if r := lr.parseLine("10.0.0.0/8"); r != nil {
t.Errorf("single field should return nil")
}
}
func TestParseLine_ExtraSpaces(t *testing.T) {
lr := &localRouter{}
r := lr.parseLine(" 10.0.0.0/8 192.168.1.1 ")
if r == nil {
t.Fatal("extra whitespace should be trimmed")
}
if r.Dst != "10.0.0.0/8" {
t.Errorf("Dst = %q", r.Dst)
}
}
// ---------------------------------------------------------------------------
// parseRoutes tests
// ---------------------------------------------------------------------------
func TestParseRoutes_Valid(t *testing.T) {
lr := &localRouter{}
input := "10.0.0.0/8 192.168.1.1\n172.16.0.0/12 10.0.0.1\n"
routes, err := lr.parseRoutes(strings.NewReader(input))
if err != nil {
t.Fatal(err)
}
if len(routes) != 2 {
t.Fatalf("expected 2 routes, got %d", len(routes))
}
if routes[0].Dst != "10.0.0.0/8" {
t.Errorf("routes[0].Dst = %q", routes[0].Dst)
}
if routes[1].Dst != "172.16.0.0/12" {
t.Errorf("routes[1].Dst = %q", routes[1].Dst)
}
}
func TestParseRoutes_NilReader(t *testing.T) {
lr := &localRouter{}
routes, err := lr.parseRoutes(nil)
if err != nil {
t.Fatal(err)
}
if len(routes) != 0 {
t.Errorf("expected 0 routes for nil reader, got %d", len(routes))
}
}
func TestParseRoutes_MixedContent(t *testing.T) {
lr := &localRouter{}
input := "# header\n10.0.0.0/8 192.168.1.1\n\n# comment\n172.16.0.0/12 10.0.0.1\nbadline\n"
routes, err := lr.parseRoutes(strings.NewReader(input))
if err != nil {
t.Fatal(err)
}
if len(routes) != 2 {
t.Fatalf("expected 2 valid routes (skipping blanks/comments/single-field), got %d", len(routes))
}
}
// errReader is an io.Reader that always errors.
type errReader struct{}
func (errReader) Read(_ []byte) (int, error) { return 0, errors.New("read error") }
func TestParseRoutes_ScannerError(t *testing.T) {
lr := &localRouter{}
routes, err := lr.parseRoutes(errReader{})
if err == nil {
t.Fatal("expected scanner error from failing reader")
}
if len(routes) != 0 {
t.Errorf("expected 0 routes on error, got %d", len(routes))
}
}
// ---------------------------------------------------------------------------
// GetRoute tests
// ---------------------------------------------------------------------------
func TestNewRouter_StaticRoutes(t *testing.T) {
r := NewRouter(
RoutesOption([]*router.Route{
{Dst: "10.0.0.0/8", Gateway: "192.168.1.1", Net: mustCIDR("10.0.0.0/8")},
{Dst: "172.16.0.0/12", Gateway: "10.0.0.1", Net: mustCIDR("172.16.0.0/12")},
}),
LoggerOption(xlogger.Nop()),
)
defer closeRouter(r)
ctx := context.Background()
// Exact match
route := r.GetRoute(ctx, "10.0.0.0/8")
if route == nil {
t.Fatal("expected route for exact dst match")
}
if route.Gateway != "192.168.1.1" {
t.Errorf("Gateway = %q, want %q", route.Gateway, "192.168.1.1")
}
// CIDR containment match
route = r.GetRoute(ctx, "10.1.2.3")
if route == nil {
t.Fatal("expected route for IP within 10.0.0.0/8")
}
if route.Gateway != "192.168.1.1" {
t.Errorf("Gateway = %q, want %q", route.Gateway, "192.168.1.1")
}
// No match
route = r.GetRoute(ctx, "8.8.8.8")
if route != nil {
t.Errorf("expected nil for unmatched IP, got %+v", route)
}
}
func TestGetRoute_EmptyDst(t *testing.T) {
r := NewRouter(LoggerOption(xlogger.Nop()))
defer closeRouter(r)
if route := r.GetRoute(context.Background(), ""); route != nil {
t.Errorf("expected nil for empty dst")
}
}
func TestGetRoute_NilReceiver(t *testing.T) {
var r *localRouter
if route := r.GetRoute(context.Background(), "10.0.0.1"); route != nil {
t.Errorf("expected nil for nil receiver")
}
}
func TestGetRoute_NilNet(t *testing.T) {
r := NewRouter(
RoutesOption([]*router.Route{
{Dst: "192.168.1.0", Gateway: "10.0.0.1", Net: nil},
}),
LoggerOption(xlogger.Nop()),
)
defer closeRouter(r)
// Should match by exact Dst string
route := r.GetRoute(context.Background(), "192.168.1.0")
if route == nil {
t.Fatal("expected route for exact Dst match with nil Net")
}
if route.Gateway != "10.0.0.1" {
t.Errorf("Gateway = %q", route.Gateway)
}
// Should NOT match by CIDR since Net is nil
route = r.GetRoute(context.Background(), "192.168.1.100")
if route != nil {
t.Errorf("expected nil — Net is nil so CIDR containment can't match")
}
}
func TestGetRoute_IPv6CIDR(t *testing.T) {
r := NewRouter(
RoutesOption([]*router.Route{
{Dst: "fd00::/8", Gateway: "fe80::1", Net: mustCIDR("fd00::/8")},
}),
LoggerOption(xlogger.Nop()),
)
defer closeRouter(r)
route := r.GetRoute(context.Background(), "fd00:dead:beef::1")
if route == nil {
t.Fatal("expected route for IPv6 in fd00::/8")
}
if route.Gateway != "fe80::1" {
t.Errorf("Gateway = %q", route.Gateway)
}
}
// ---------------------------------------------------------------------------
// Loader integration tests
// ---------------------------------------------------------------------------
func TestNewRouter_FileLoader(t *testing.T) {
fl := &mockLoader{
data: "10.0.0.0/8 192.168.1.1\n172.16.0.0/12 10.0.0.1\n",
}
r := NewRouter(
FileLoaderOption(fl),
LoggerOption(xlogger.Nop()),
)
defer closeRouter(r)
route := r.GetRoute(context.Background(), "10.5.5.5")
if route == nil {
t.Fatal("expected route loaded from file loader")
}
if route.Gateway != "192.168.1.1" {
t.Errorf("Gateway = %q", route.Gateway)
}
}
func TestNewRouter_FileLoaderLister(t *testing.T) {
fl := &mockLister{
lines: []string{"10.0.0.0/8 192.168.1.1", "172.16.0.0/12 10.0.0.1"},
}
r := NewRouter(
FileLoaderOption(fl),
LoggerOption(xlogger.Nop()),
)
defer closeRouter(r)
route := r.GetRoute(context.Background(), "172.20.0.1")
if route == nil {
t.Fatal("expected route from lister-based file loader")
}
if route.Gateway != "10.0.0.1" {
t.Errorf("Gateway = %q", route.Gateway)
}
}
func TestNewRouter_RedisLoader(t *testing.T) {
rl := &mockLoader{
data: "192.168.0.0/16 10.0.0.1\n",
}
r := NewRouter(
RedisLoaderOption(rl),
LoggerOption(xlogger.Nop()),
)
defer closeRouter(r)
route := r.GetRoute(context.Background(), "192.168.99.99")
if route == nil {
t.Fatal("expected route from redis loader")
}
}
func TestNewRouter_HTTPLoader(t *testing.T) {
hl := &mockLoader{
data: "10.10.0.0/16 172.16.0.1\n",
}
r := NewRouter(
HTTPLoaderOption(hl),
LoggerOption(xlogger.Nop()),
)
defer closeRouter(r)
route := r.GetRoute(context.Background(), "10.10.5.5")
if route == nil {
t.Fatal("expected route from http loader")
}
}
func TestNewRouter_AllLoaders(t *testing.T) {
fl := &mockLoader{data: "10.0.0.0/8 gw-file\n"}
rl := &mockLoader{data: "172.16.0.0/12 gw-redis\n"}
hl := &mockLoader{data: "192.168.0.0/16 gw-http\n"}
r := NewRouter(
FileLoaderOption(fl),
RedisLoaderOption(rl),
HTTPLoaderOption(hl),
LoggerOption(xlogger.Nop()),
)
defer closeRouter(r)
tests := []struct {
dst, wantGW string
}{
{"10.1.2.3", "gw-file"},
{"172.20.0.1", "gw-redis"},
{"192.168.1.1", "gw-http"},
}
for _, tc := range tests {
route := r.GetRoute(context.Background(), tc.dst)
if route == nil {
t.Fatalf("expected route for %s", tc.dst)
}
if route.Gateway != tc.wantGW {
t.Errorf("GetRoute(%s).Gateway = %q, want %q", tc.dst, route.Gateway, tc.wantGW)
}
}
}
func TestNewRouter_StaticPlusLoader(t *testing.T) {
fl := &mockLoader{data: "10.0.0.0/8 gw-dynamic\n"}
static := []*router.Route{
{Dst: "172.16.0.0/12", Gateway: "gw-static", Net: mustCIDR("172.16.0.0/12")},
}
r := NewRouter(
RoutesOption(static),
FileLoaderOption(fl),
LoggerOption(xlogger.Nop()),
)
defer closeRouter(r)
// Dynamic route
route := r.GetRoute(context.Background(), "10.0.0.1")
if route == nil || route.Gateway != "gw-dynamic" {
t.Errorf("expected gw-dynamic for 10.0.0.1, got %+v", route)
}
// Static route
route = r.GetRoute(context.Background(), "172.20.0.1")
if route == nil || route.Gateway != "gw-static" {
t.Errorf("expected gw-static for 172.20.0.1, got %+v", route)
}
}
// ---------------------------------------------------------------------------
// Error handling tests
// ---------------------------------------------------------------------------
func TestNewRouter_LoaderError(t *testing.T) {
el := &errLoader{}
// Should not panic, just log the error
r := NewRouter(
FileLoaderOption(el),
LoggerOption(xlogger.Nop()),
)
defer closeRouter(r)
// No routes loaded
route := r.GetRoute(context.Background(), "10.0.0.1")
if route != nil {
t.Errorf("expected nil route when loader fails")
}
}
func TestNewRouter_ListerError(t *testing.T) {
el := &errLister{}
r := NewRouter(
FileLoaderOption(el),
LoggerOption(xlogger.Nop()),
)
defer closeRouter(r)
route := r.GetRoute(context.Background(), "10.0.0.1")
if route != nil {
t.Errorf("expected nil route when lister fails")
}
}
func TestNewRouter_NilLogger(t *testing.T) {
// Should not panic when no logger is provided
r := NewRouter(
RoutesOption([]*router.Route{
{Dst: "10.0.0.0/8", Gateway: "192.168.1.1", Net: mustCIDR("10.0.0.0/8")},
}),
)
defer closeRouter(r)
route := r.GetRoute(context.Background(), "10.0.0.1")
if route == nil {
t.Fatal("expected route even without logger")
}
}
// ---------------------------------------------------------------------------
// Close tests
// ---------------------------------------------------------------------------
func TestClose_ClosesAllLoaders(t *testing.T) {
fl := &mockLoader{}
rl := &mockLoader{}
hl := &mockLoader{}
r := NewRouter(
FileLoaderOption(fl),
RedisLoaderOption(rl),
HTTPLoaderOption(hl),
LoggerOption(xlogger.Nop()),
)
if err := r.(*localRouter).Close(); err != nil {
t.Fatalf("Close() error: %v", err)
}
if !fl.closed {
t.Error("fileLoader not closed")
}
if !rl.closed {
t.Error("redisLoader not closed")
}
if !hl.closed {
t.Error("httpLoader not closed")
}
}
func TestClose_NoLoaders(t *testing.T) {
r := NewRouter(LoggerOption(xlogger.Nop()))
if err := r.(*localRouter).Close(); err != nil {
t.Fatalf("Close() error: %v", err)
}
}
// ---------------------------------------------------------------------------
// Periodic reload tests
// ---------------------------------------------------------------------------
func TestPeriodReload(t *testing.T) {
fl := &mockLoader{data: "10.0.0.0/8 gw-v1\n"}
r := NewRouter(
FileLoaderOption(fl),
ReloadPeriodOption(50*time.Millisecond),
LoggerOption(xlogger.Nop()),
)
defer closeRouter(r)
// Initial load
route := r.GetRoute(context.Background(), "10.0.0.1")
if route == nil || route.Gateway != "gw-v1" {
t.Fatalf("initial: got %+v", route)
}
// Update loader data and poll for reload (period clamped to >=1s)
fl.data = "10.0.0.0/8 gw-v2\n"
deadline := time.After(3 * time.Second)
for {
route = r.GetRoute(context.Background(), "10.0.0.1")
if route != nil && route.Gateway == "gw-v2" {
break
}
select {
case <-deadline:
t.Fatalf("timed out waiting for reload, got %+v", route)
default:
time.Sleep(100 * time.Millisecond)
}
}
}
func TestPeriodReload_StopsOnCancel(t *testing.T) {
fl := &mockLoader{data: "10.0.0.0/8 gw\n"}
r := NewRouter(
FileLoaderOption(fl),
ReloadPeriodOption(50*time.Millisecond),
LoggerOption(xlogger.Nop()),
)
// Close cancels the context
r.(*localRouter).Close()
// Update data after close — should not be picked up
fl.data = "10.0.0.0/8 gw-updated\n"
time.Sleep(150 * time.Millisecond)
route := r.GetRoute(context.Background(), "10.0.0.1")
if route != nil && route.Gateway == "gw-updated" {
t.Error("route should not update after Close()")
}
}
func TestReloadPeriodOption_MinimumClamp(t *testing.T) {
// Period below 1s should be clamped to 1s internally
fl := &mockLoader{data: "10.0.0.0/8 gw\n"}
r := NewRouter(
FileLoaderOption(fl),
ReloadPeriodOption(1*time.Nanosecond),
LoggerOption(xlogger.Nop()),
)
defer closeRouter(r)
// Should still work — just won't reload faster than 1s
route := r.GetRoute(context.Background(), "10.0.0.1")
if route == nil {
t.Fatal("expected route")
}
}
// ---------------------------------------------------------------------------
// load() with nil reader from loader
// ---------------------------------------------------------------------------
func TestLoad_NilReaderOnError(t *testing.T) {
el := &errLoader{}
r := NewRouter(
FileLoaderOption(el),
LoggerOption(xlogger.Nop()),
).(*localRouter)
routes, err := r.load(context.Background())
if err != nil {
t.Fatalf("load should not return error for loader failures, got %v", err)
}
if len(routes) != 0 {
t.Errorf("expected 0 routes, got %d", len(routes))
}
}
func TestLoad_EmptyData(t *testing.T) {
fl := &mockLoader{data: ""}
r := NewRouter(
FileLoaderOption(fl),
LoggerOption(xlogger.Nop()),
).(*localRouter)
routes, err := r.load(context.Background())
if err != nil {
t.Fatal(err)
}
if len(routes) != 0 {
t.Errorf("expected 0 routes for empty data, got %d", len(routes))
}
}
// ---------------------------------------------------------------------------
// parseRoutes nil reader guard
// ---------------------------------------------------------------------------
func TestParseRoutes_NilReaderNotAsserted(t *testing.T) {
// Verify the nil guard works via the load path
var fl loader.Loader = &nilReaderLoader{}
r := NewRouter(
FileLoaderOption(fl),
LoggerOption(xlogger.Nop()),
)
defer closeRouter(r)
// Should not panic
route := r.GetRoute(context.Background(), "10.0.0.1")
if route != nil {
t.Errorf("expected nil route from nil reader loader")
}
}
// nilReaderLoader returns a nil io.Reader.
type nilReaderLoader struct{}
func (n *nilReaderLoader) Load(_ context.Context) (io.Reader, error) { return nil, nil }
func (n *nilReaderLoader) Close() error { return nil }
// ---------------------------------------------------------------------------
// Context cancellation
// ---------------------------------------------------------------------------
func TestGetRoute_CancelledContext(t *testing.T) {
r := NewRouter(
RoutesOption([]*router.Route{
{Dst: "10.0.0.0/8", Gateway: "192.168.1.1", Net: mustCIDR("10.0.0.0/8")},
}),
LoggerOption(xlogger.Nop()),
)
defer closeRouter(r)
ctx, cancel := context.WithCancel(context.Background())
cancel()
// GetRoute doesn't use context internally (no IO), so cancelled ctx is fine
route := r.GetRoute(ctx, "10.0.0.1")
if route == nil {
t.Fatal("GetRoute should still return route with cancelled context")
}
}
// ---------------------------------------------------------------------------
// Benchmark
// ---------------------------------------------------------------------------
func BenchmarkGetRoute(b *testing.B) {
routes := make([]*router.Route, 100)
for i := 0; i < 100; i++ {
cidr := mustCIDR(fmt.Sprintf("10.%d.0.0/16", i))
routes[i] = &router.Route{
Dst: fmt.Sprintf("10.%d.0.0/16", i),
Gateway: fmt.Sprintf("192.168.%d.1", i),
Net: cidr,
}
}
r := NewRouter(
RoutesOption(routes),
LoggerOption(xlogger.Nop()),
)
defer closeRouter(r)
ctx := context.Background()
b.ResetTimer()
for i := 0; i < b.N; i++ {
r.GetRoute(ctx, "10.50.5.5")
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
func mustCIDR(s string) *net.IPNet {
_, ipNet, err := net.ParseCIDR(s)
if err != nil {
panic(err)
}
return ipNet
}