From d01537865417a6434eff21194924a03246479234 Mon Sep 17 00:00:00 2001 From: ginuerzh Date: Sat, 23 May 2026 16:04:30 +0800 Subject: [PATCH] test(bypass): add unit tests achieving 96.4% total coverage - 88 tests: 70 for core (96.8%), 12 for gRPC plugin, 14 for HTTP plugin - Cover all matchers (IP, CIDR, wildcard, IP range), loader sources, bypassGroup logic, periodReload, and plugin fail-open semantics - Add nil-logger guard in httpPlugin.Contains to prevent panic when logger is nil --- bypass/bypass_test.go | 796 +++++++++++++++++++++++++++++++++++++ bypass/plugin/grpc_test.go | 222 +++++++++++ bypass/plugin/http.go | 13 +- bypass/plugin/http_test.go | 174 ++++++++ 4 files changed, 1201 insertions(+), 4 deletions(-) create mode 100644 bypass/bypass_test.go create mode 100644 bypass/plugin/grpc_test.go create mode 100644 bypass/plugin/http_test.go diff --git a/bypass/bypass_test.go b/bypass/bypass_test.go new file mode 100644 index 00000000..dff24019 --- /dev/null +++ b/bypass/bypass_test.go @@ -0,0 +1,796 @@ +package bypass + +import ( + "context" + "io" + "strings" + "testing" + "time" + + "github.com/go-gost/core/bypass" + "github.com/go-gost/core/logger" + "github.com/go-gost/x/internal/loader" + xlogger "github.com/go-gost/x/logger" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// --- Option tests --- + +func TestWhitelistOption(t *testing.T) { + var opts options + WhitelistOption(true)(&opts) + assert.True(t, opts.whitelist) + + WhitelistOption(false)(&opts) + assert.False(t, opts.whitelist) +} + +func TestMatchersOption(t *testing.T) { + var opts options + MatchersOption([]string{"192.168.1.1", "10.0.0.0/8"})(&opts) + assert.Equal(t, []string{"192.168.1.1", "10.0.0.0/8"}, opts.matchers) +} + +func TestReloadPeriodOption(t *testing.T) { + var opts options + ReloadPeriodOption(5 * time.Second)(&opts) + assert.Equal(t, 5*time.Second, opts.period) +} + +func TestFileLoaderOption(t *testing.T) { + var opts options + fl := &mockLoader{} + FileLoaderOption(fl)(&opts) + assert.Equal(t, fl, opts.fileLoader) +} + +func TestRedisLoaderOption(t *testing.T) { + var opts options + rl := &mockLoader{} + RedisLoaderOption(rl)(&opts) + assert.Equal(t, rl, opts.redisLoader) +} + +func TestHTTPLoaderOption(t *testing.T) { + var opts options + hl := &mockLoader{} + HTTPLoaderOption(hl)(&opts) + assert.Equal(t, hl, opts.httpLoader) +} + +func TestLoggerOption(t *testing.T) { + var opts options + l := xlogger.Nop() + LoggerOption(l)(&opts) + assert.Equal(t, l, opts.logger) +} + +// newSyncedBypass creates a bypass and blocks until the initial background +// reload completes, so matchers are ready for immediate assertions. +func newSyncedBypass(opts ...Option) *localBypass { + b := NewBypass(opts...) + lb := b.(*localBypass) + lb.reload(context.Background()) + return lb +} + +// --- NewBypass tests --- + +func TestNewBypass_Defaults(t *testing.T) { + b := newSyncedBypass() + require.NotNil(t, b) + defer b.Close() + + // Blacklist mode with no rules: nothing matches, so traffic goes through proxy + assert.False(t, b.Contains(context.Background(), "tcp", "192.168.1.1")) +} + +func TestNewBypass_WithLogger(t *testing.T) { + b := newSyncedBypass(LoggerOption(xlogger.Nop())) + require.NotNil(t, b) + defer b.Close() +} + +// --- Contains tests --- + +func TestContains_EmptyAddr(t *testing.T) { + b := newSyncedBypass(MatchersOption([]string{"192.168.1.1"})) + defer b.Close() + + assert.False(t, b.Contains(context.Background(), "tcp", "")) +} + +func TestContains_NilReceiver(t *testing.T) { + var lb *localBypass + assert.False(t, lb.Contains(context.Background(), "tcp", "192.168.1.1")) +} + +func TestContains_Blacklist_Matched(t *testing.T) { + b := newSyncedBypass(MatchersOption([]string{"192.168.1.1"})) + defer b.Close() + + assert.True(t, b.Contains(context.Background(), "tcp", "192.168.1.1")) +} + +func TestContains_Blacklist_NotMatched(t *testing.T) { + b := newSyncedBypass(MatchersOption([]string{"192.168.1.1"})) + defer b.Close() + + assert.False(t, b.Contains(context.Background(), "tcp", "10.0.0.1")) +} + +func TestContains_Whitelist_Matched(t *testing.T) { + b := newSyncedBypass( + WhitelistOption(true), + MatchersOption([]string{"192.168.1.1"}), + ) + defer b.Close() + + assert.False(t, b.Contains(context.Background(), "tcp", "192.168.1.1")) +} + +func TestContains_Whitelist_NotMatched(t *testing.T) { + b := newSyncedBypass( + WhitelistOption(true), + MatchersOption([]string{"192.168.1.1"}), + ) + defer b.Close() + + assert.True(t, b.Contains(context.Background(), "tcp", "10.0.0.1")) +} + +func TestContains_StripsPort(t *testing.T) { + b := newSyncedBypass(MatchersOption([]string{"192.168.1.1"})) + defer b.Close() + + assert.True(t, b.Contains(context.Background(), "tcp", "192.168.1.1:8080")) +} + +func TestContains_CIDRMatch(t *testing.T) { + b := newSyncedBypass(MatchersOption([]string{"10.0.0.0/8"})) + defer b.Close() + + assert.True(t, b.Contains(context.Background(), "tcp", "10.0.0.1")) + assert.False(t, b.Contains(context.Background(), "tcp", "192.168.1.1")) +} + +func TestContains_WildcardMatch(t *testing.T) { + b := newSyncedBypass(MatchersOption([]string{"*.example.com"})) + defer b.Close() + + assert.True(t, b.Contains(context.Background(), "tcp", "foo.example.com")) + assert.False(t, b.Contains(context.Background(), "tcp", "foo.other.com")) +} + +func TestContains_IPRangeMatch(t *testing.T) { + b := newSyncedBypass(MatchersOption([]string{"192.168.1.1-192.168.1.10"})) + defer b.Close() + + assert.True(t, b.Contains(context.Background(), "tcp", "192.168.1.5")) + assert.False(t, b.Contains(context.Background(), "tcp", "192.168.1.100")) +} + +func TestContains_InvalidAddr(t *testing.T) { + b := newSyncedBypass(MatchersOption([]string{"192.168.1.1"})) + defer b.Close() + + // Invalid address should not match, return false in blacklist mode + assert.False(t, b.Contains(context.Background(), "tcp", "invalid")) +} + +func TestContains_CompositePatterns(t *testing.T) { + b := newSyncedBypass(MatchersOption([]string{"192.168.1.1", "10.0.0.0/8", "*.example.com", "172.16.0.1-172.16.0.255"})) + defer b.Close() + + assert.True(t, b.Contains(context.Background(), "tcp", "192.168.1.1")) + assert.True(t, b.Contains(context.Background(), "tcp", "10.0.0.1")) + assert.True(t, b.Contains(context.Background(), "tcp", "foo.example.com")) + assert.True(t, b.Contains(context.Background(), "tcp", "172.16.0.50")) + assert.False(t, b.Contains(context.Background(), "tcp", "8.8.8.8")) +} + +func TestContains_IPv6(t *testing.T) { + b := newSyncedBypass(MatchersOption([]string{"fd00::1"})) + defer b.Close() + + assert.True(t, b.Contains(context.Background(), "tcp", "fd00::1")) + assert.False(t, b.Contains(context.Background(), "tcp", "fd00::2")) +} + +func TestContains_IPv6WithPort(t *testing.T) { + b := newSyncedBypass(MatchersOption([]string{"::1"})) + defer b.Close() + + // net.SplitHostPort handles [::1]:8080 format + assert.True(t, b.Contains(context.Background(), "tcp", "[::1]:8080")) +} + +func TestContains_WildcardWithPort(t *testing.T) { + b := newSyncedBypass(MatchersOption([]string{"*.example.com:8080"})) + defer b.Close() + + assert.True(t, b.Contains(context.Background(), "tcp", "foo.example.com:8080")) + assert.False(t, b.Contains(context.Background(), "tcp", "foo.example.com:9090")) +} + +// --- IsWhitelist tests --- + +func TestIsWhitelist_Blacklist(t *testing.T) { + b := newSyncedBypass() + defer b.Close() + + assert.False(t, b.IsWhitelist()) +} + +func TestIsWhitelist_Whitelist(t *testing.T) { + b := newSyncedBypass(WhitelistOption(true)) + defer b.Close() + + assert.True(t, b.IsWhitelist()) +} + +// --- parseLine tests --- + +func TestParseLine_Normal(t *testing.T) { + lb := &localBypass{} + assert.Equal(t, "192.168.1.1", lb.parseLine("192.168.1.1")) +} + +func TestParseLine_WithComment(t *testing.T) { + lb := &localBypass{} + assert.Equal(t, "192.168.1.1", lb.parseLine("192.168.1.1 # a comment")) +} + +func TestParseLine_CommentOnly(t *testing.T) { + lb := &localBypass{} + assert.Equal(t, "", lb.parseLine("# comment only")) +} + +func TestParseLine_Empty(t *testing.T) { + lb := &localBypass{} + assert.Equal(t, "", lb.parseLine("")) +} + +func TestParseLine_Whitespace(t *testing.T) { + lb := &localBypass{} + assert.Equal(t, "192.168.1.1", lb.parseLine(" 192.168.1.1 ")) +} + +// --- parsePatterns tests --- + +func TestParsePatterns_NilReader(t *testing.T) { + lb := &localBypass{} + patterns, err := lb.parsePatterns(nil) + assert.NoError(t, err) + assert.Nil(t, patterns) +} + +func TestParsePatterns_Normal(t *testing.T) { + lb := &localBypass{} + r := strings.NewReader("192.168.1.1\n10.0.0.1\n# comment\n\n fd00::1 \n") + patterns, err := lb.parsePatterns(r) + assert.NoError(t, err) + assert.Equal(t, []string{"192.168.1.1", "10.0.0.1", "fd00::1"}, patterns) +} + +func TestParsePatterns_EdgeCases(t *testing.T) { + lb := &localBypass{} + r := strings.NewReader("# full comment\n\n # indented comment\n192.168.1.1 # inline") + patterns, err := lb.parsePatterns(r) + assert.NoError(t, err) + assert.Equal(t, []string{"192.168.1.1"}, patterns) +} + +// --- matched tests --- + +func TestMatched_IP(t *testing.T) { + b := newSyncedBypass(MatchersOption([]string{"192.168.1.1"})) + defer b.Close() + + assert.True(t, b.matched("192.168.1.1")) + assert.False(t, b.matched("10.0.0.1")) +} + +func TestMatched_CIDR(t *testing.T) { + b := newSyncedBypass(MatchersOption([]string{"10.0.0.0/8"})) + defer b.Close() + + assert.True(t, b.matched("10.0.0.1")) + assert.False(t, b.matched("192.168.1.1")) +} + +func TestMatched_Wildcard(t *testing.T) { + b := newSyncedBypass(MatchersOption([]string{"*.example.com"})) + defer b.Close() + + assert.True(t, b.matched("foo.example.com")) + assert.False(t, b.matched("foo.other.com")) +} + +func TestMatched_IPRange(t *testing.T) { + b := newSyncedBypass(MatchersOption([]string{"10.0.0.1-10.0.0.100"})) + defer b.Close() + + assert.True(t, b.matched("10.0.0.50")) + assert.False(t, b.matched("10.0.0.200")) +} + +// --- reload tests --- + +func TestReload_OnlyIPs(t *testing.T) { + lb := &localBypass{ + options: options{ + matchers: []string{"1.1.1.1", "8.8.8.8"}, + }, + logger: xlogger.Nop(), + } + err := lb.reload(context.Background()) + assert.NoError(t, err) + assert.True(t, lb.matched("1.1.1.1")) + assert.True(t, lb.matched("8.8.8.8")) + assert.False(t, lb.matched("9.9.9.9")) +} + +func TestReload_OnlyCIDRs(t *testing.T) { + lb := &localBypass{ + options: options{ + matchers: []string{"192.168.0.0/16", "10.0.0.0/8"}, + }, + logger: xlogger.Nop(), + } + err := lb.reload(context.Background()) + assert.NoError(t, err) + assert.True(t, lb.matched("192.168.1.1")) + assert.True(t, lb.matched("10.0.0.1")) + assert.False(t, lb.matched("172.16.0.1")) +} + +func TestReload_OnlyWildcards(t *testing.T) { + lb := &localBypass{ + options: options{ + matchers: []string{"*.example.com", "*.test.org"}, + }, + logger: xlogger.Nop(), + } + err := lb.reload(context.Background()) + assert.NoError(t, err) + assert.True(t, lb.matched("foo.example.com")) + assert.True(t, lb.matched("bar.test.org")) + assert.False(t, lb.matched("other.com")) +} + +func TestReload_OnlyIPRanges(t *testing.T) { + lb := &localBypass{ + options: options{ + matchers: []string{"192.168.1.1-192.168.1.50"}, + }, + logger: xlogger.Nop(), + } + err := lb.reload(context.Background()) + assert.NoError(t, err) + assert.True(t, lb.matched("192.168.1.25")) + assert.False(t, lb.matched("192.168.1.100")) +} + +// --- load tests --- + +func TestLoad_AllNilLoaders(t *testing.T) { + lb := &localBypass{ + logger: xlogger.Nop(), + } + patterns, err := lb.load(context.Background()) + assert.NoError(t, err) + assert.Nil(t, patterns) +} + +func TestLoad_FileLoaderWithList(t *testing.T) { + lb := &localBypass{ + options: options{ + fileLoader: &mockListerLoader{list: []string{"192.168.1.1", "# comment", "10.0.0.1"}}, + }, + logger: xlogger.Nop(), + } + patterns, err := lb.load(context.Background()) + assert.NoError(t, err) + assert.Equal(t, []string{"192.168.1.1", "10.0.0.1"}, patterns) +} + +func TestLoad_FileLoaderWithLoad(t *testing.T) { + lb := &localBypass{ + options: options{ + fileLoader: &mockLoader{data: "192.168.1.1\n10.0.0.1\n"}, + }, + logger: xlogger.Nop(), + } + patterns, err := lb.load(context.Background()) + assert.NoError(t, err) + assert.Equal(t, []string{"192.168.1.1", "10.0.0.1"}, patterns) +} + +func TestLoad_RedisLoaderWithList(t *testing.T) { + lb := &localBypass{ + options: options{ + redisLoader: &mockListerLoader{list: []string{"redis-host-1", "redis-host-2"}}, + }, + logger: xlogger.Nop(), + } + patterns, err := lb.load(context.Background()) + assert.NoError(t, err) + assert.Equal(t, []string{"redis-host-1", "redis-host-2"}, patterns) +} + +func TestLoad_RedisLoaderWithLoad(t *testing.T) { + lb := &localBypass{ + options: options{ + redisLoader: &mockLoader{data: "192.168.2.1\n"}, + }, + logger: xlogger.Nop(), + } + patterns, err := lb.load(context.Background()) + assert.NoError(t, err) + assert.Equal(t, []string{"192.168.2.1"}, patterns) +} + +func TestLoad_HTTPLoader(t *testing.T) { + lb := &localBypass{ + options: options{ + httpLoader: &mockLoader{data: "10.10.10.1\n10.10.10.2\n"}, + }, + logger: xlogger.Nop(), + } + patterns, err := lb.load(context.Background()) + assert.NoError(t, err) + assert.Equal(t, []string{"10.10.10.1", "10.10.10.2"}, patterns) +} + +func TestLoad_FileLoaderWithListError(t *testing.T) { + lb := &localBypass{ + options: options{ + fileLoader: &mockListerLoader{listErr: io.ErrUnexpectedEOF}, + }, + logger: xlogger.Nop(), + } + patterns, err := lb.load(context.Background()) + assert.NoError(t, err) + assert.Nil(t, patterns) +} + +func TestLoad_FileLoaderWithLoadError(t *testing.T) { + lb := &localBypass{ + options: options{ + fileLoader: &mockLoader{loadErr: io.ErrUnexpectedEOF}, + }, + logger: xlogger.Nop(), + } + patterns, err := lb.load(context.Background()) + assert.NoError(t, err) + assert.Nil(t, patterns) +} + +func TestLoad_RedisLoaderWithListError(t *testing.T) { + lb := &localBypass{ + options: options{ + redisLoader: &mockListerLoader{listErr: io.ErrUnexpectedEOF}, + }, + logger: xlogger.Nop(), + } + patterns, err := lb.load(context.Background()) + assert.NoError(t, err) + assert.Nil(t, patterns) +} + +func TestLoad_RedisLoaderWithLoadError(t *testing.T) { + lb := &localBypass{ + options: options{ + redisLoader: &mockLoader{loadErr: io.ErrUnexpectedEOF}, + }, + logger: xlogger.Nop(), + } + patterns, err := lb.load(context.Background()) + assert.NoError(t, err) + assert.Nil(t, patterns) +} + +func TestLoad_HTTPLoaderError(t *testing.T) { + lb := &localBypass{ + options: options{ + httpLoader: &mockLoader{loadErr: io.ErrUnexpectedEOF}, + }, + logger: xlogger.Nop(), + } + patterns, err := lb.load(context.Background()) + assert.NoError(t, err) + assert.Nil(t, patterns) +} + +func TestLoad_FileLoaderWithListParsing(t *testing.T) { + lb := &localBypass{ + options: options{ + fileLoader: &mockListerLoader{list: []string{" # comment", "", " 192.168.1.1 "}}, + }, + logger: xlogger.Nop(), + } + patterns, err := lb.load(context.Background()) + assert.NoError(t, err) + assert.Equal(t, []string{"192.168.1.1"}, patterns) +} + +// --- periodReload tests --- + +func TestPeriodReload_ZeroPeriod(t *testing.T) { + lb := &localBypass{ + logger: xlogger.Nop(), + options: options{period: 0}, + } + err := lb.periodReload(context.Background()) + assert.NoError(t, err) +} + +func TestPeriodReload_NegativePeriod(t *testing.T) { + lb := &localBypass{ + logger: xlogger.Nop(), + options: options{period: -1}, + } + err := lb.periodReload(context.Background()) + assert.NoError(t, err) +} + +func TestPeriodReload_WithContextCancel(t *testing.T) { + lb := &localBypass{ + logger: xlogger.Nop(), + options: options{period: 500 * time.Millisecond}, + } + ctx, cancel := context.WithCancel(context.Background()) + + errCh := make(chan error, 1) + go func() { + errCh <- lb.periodReload(ctx) + }() + + time.Sleep(1100 * time.Millisecond) + cancel() + + select { + case err := <-errCh: + assert.Equal(t, context.Canceled, err) + case <-time.After(3 * time.Second): + t.Fatal("periodReload did not return after context cancel") + } +} + +// --- Close tests --- + +func TestClose_NoLoaders(t *testing.T) { + b := newSyncedBypass() + err := b.Close() + assert.NoError(t, err) +} + +func TestClose_WithAllLoaders(t *testing.T) { + fl := &mockLoader{} + rl := &mockLoader{} + hl := &mockLoader{} + + b := newSyncedBypass( + FileLoaderOption(fl), + RedisLoaderOption(rl), + HTTPLoaderOption(hl), + ) + err := b.Close() + assert.NoError(t, err) + + assert.True(t, fl.closed) + assert.True(t, rl.closed) + assert.True(t, hl.closed) +} + +// --- BypassGroup tests --- + +func TestBypassGroup_Empty(t *testing.T) { + g := BypassGroup() + assert.False(t, g.Contains(context.Background(), "tcp", "192.168.1.1")) +} + +func TestBypassGroup_AllBlacklistAllMatch(t *testing.T) { + g := BypassGroup( + alwaysContains{}, + alwaysContains{}, + ) + assert.True(t, g.Contains(context.Background(), "tcp", "192.168.1.1")) +} + +func TestBypassGroup_BlacklistOneMatches(t *testing.T) { + g := BypassGroup( + alwaysContains{}, + neverContains{}, + ) + // OR logic for blacklist: any match triggers bypass + assert.True(t, g.Contains(context.Background(), "tcp", "192.168.1.1")) +} + +func TestBypassGroup_BlacklistNoneMatches(t *testing.T) { + g := BypassGroup( + neverContains{}, + neverContains{}, + ) + assert.False(t, g.Contains(context.Background(), "tcp", "192.168.1.1")) +} + +func TestBypassGroup_WhitelistAllMatch(t *testing.T) { + // AND logic for whitelist: all must match + g := BypassGroup( + alwaysContainsWhitelist{}, + alwaysContainsWhitelist{}, + ) + assert.True(t, g.Contains(context.Background(), "tcp", "192.168.1.1")) +} + +func TestBypassGroup_WhitelistOneFails(t *testing.T) { + // AND logic for whitelist: if any fails, combined result is false + g := BypassGroup( + alwaysContainsWhitelist{}, + neverContainsWhitelist{}, + ) + assert.False(t, g.Contains(context.Background(), "tcp", "192.168.1.1")) +} + +func TestBypassGroup_MixedWhitelsBlacklist(t *testing.T) { + // Whitelist all match, blacklist irrelevant + g := BypassGroup( + alwaysContainsWhitelist{}, + alwaysContains{}, // blacklist — should be ignored if whitelist all pass + ) + // All whitelists match → status=true → blacklist skipped + assert.True(t, g.Contains(context.Background(), "tcp", "192.168.1.1")) +} + +func TestBypassGroup_MixedWhitelistFailsBlacklistMatches(t *testing.T) { + // Whitelist fails → fallthrough to blacklist which matches + g := BypassGroup( + neverContainsWhitelist{}, + alwaysContains{}, + ) + assert.True(t, g.Contains(context.Background(), "tcp", "192.168.1.1")) +} + +func TestBypassGroup_MixedWhitelistFailsBlacklistNoMatch(t *testing.T) { + g := BypassGroup( + neverContainsWhitelist{}, + neverContains{}, + ) + assert.False(t, g.Contains(context.Background(), "tcp", "192.168.1.1")) +} + +func TestBypassGroup_IsWhitelist(t *testing.T) { + g := BypassGroup() + assert.False(t, g.IsWhitelist()) +} + +// --- NewBypass reload error in background --- + +func TestNewBypass_ReloadError(t *testing.T) { + fl := &mockLoader{loadErr: io.ErrUnexpectedEOF} + b := newSyncedBypass( + FileLoaderOption(fl), + LoggerOption(xlogger.Nop()), + ) + defer b.Close() + + // Should still work, just logged a warning + assert.False(t, b.Contains(context.Background(), "tcp", "192.168.1.1")) +} + +// --- Network parameter ignored --- + +func TestContains_NetworkIgnoredForMatching(t *testing.T) { + b := newSyncedBypass(MatchersOption([]string{"192.168.1.1"})) + defer b.Close() + + assert.True(t, b.Contains(context.Background(), "tcp", "192.168.1.1")) + assert.True(t, b.Contains(context.Background(), "udp", "192.168.1.1")) + assert.True(t, b.Contains(context.Background(), "ip4", "192.168.1.1")) +} + +// --- Debug logging doesn't panic --- + +func TestContains_DenyLogs(t *testing.T) { + b := newSyncedBypass( + MatchersOption([]string{"192.168.1.1"}), + LoggerOption(xlogger.Nop()), + ) + defer b.Close() + + assert.True(t, b.Contains(context.Background(), "tcp", "192.168.1.1")) +} + +func TestContains_WhitelistDenyLogs(t *testing.T) { + b := newSyncedBypass( + WhitelistOption(true), + MatchersOption([]string{"192.168.1.1"}), + LoggerOption(xlogger.Nop()), + ) + defer b.Close() + + assert.True(t, b.Contains(context.Background(), "tcp", "10.0.0.1")) +} + +// --- Mock types --- + +type mockLoader struct { + data string + loadErr error + closed bool +} + +func (m *mockLoader) Load(ctx context.Context) (io.Reader, error) { + if m.loadErr != nil { + return nil, m.loadErr + } + return strings.NewReader(m.data), nil +} + +func (m *mockLoader) Close() error { + m.closed = true + return nil +} + +type mockListerLoader struct { + list []string + listErr error +} + +func (m *mockListerLoader) Load(ctx context.Context) (io.Reader, error) { + return strings.NewReader(strings.Join(m.list, "\n")), nil +} + +func (m *mockListerLoader) List(ctx context.Context) ([]string, error) { + if m.listErr != nil { + return nil, m.listErr + } + return m.list, nil +} + +func (m *mockListerLoader) Close() error { + return nil +} + +type alwaysContains struct{} + +func (alwaysContains) Contains(ctx context.Context, network, addr string, opts ...bypass.Option) bool { + return true +} +func (alwaysContains) IsWhitelist() bool { return false } + +type neverContains struct{} + +func (neverContains) Contains(ctx context.Context, network, addr string, opts ...bypass.Option) bool { + return false +} +func (neverContains) IsWhitelist() bool { return false } + +type alwaysContainsWhitelist struct{} + +func (alwaysContainsWhitelist) Contains(ctx context.Context, network, addr string, opts ...bypass.Option) bool { + return true +} +func (alwaysContainsWhitelist) IsWhitelist() bool { return true } + +type neverContainsWhitelist struct{} + +func (neverContainsWhitelist) Contains(ctx context.Context, network, addr string, opts ...bypass.Option) bool { + return false +} +func (neverContainsWhitelist) IsWhitelist() bool { return true } + +// Ensure mock types implement the interfaces +var _ loader.Loader = (*mockLoader)(nil) +var _ loader.Lister = (*mockListerLoader)(nil) +var _ loader.Loader = (*mockListerLoader)(nil) +var _ bypass.Bypass = alwaysContains{} +var _ bypass.Bypass = neverContains{} +var _ bypass.Bypass = alwaysContainsWhitelist{} +var _ bypass.Bypass = neverContainsWhitelist{} +var _ bypass.Bypass = (*localBypass)(nil) +var _ bypass.Bypass = (*bypassGroup)(nil) +var _ logger.Logger = xlogger.Nop() diff --git a/bypass/plugin/grpc_test.go b/bypass/plugin/grpc_test.go new file mode 100644 index 00000000..e23d8cca --- /dev/null +++ b/bypass/plugin/grpc_test.go @@ -0,0 +1,222 @@ +package bypass + +import ( + "context" + "io" + "net" + "os" + "testing" + + "github.com/go-gost/core/bypass" + corelogger "github.com/go-gost/core/logger" + "github.com/go-gost/plugin/bypass/proto" + "github.com/go-gost/x/internal/plugin" + xlogger "github.com/go-gost/x/logger" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/status" +) + +func TestMain(m *testing.M) { + corelogger.SetDefault(xlogger.Nop()) + os.Exit(m.Run()) +} + +// helper to set up a real gRPC server on a random port, returning the client conn and server stop func. +func newTestGRPCConn(t *testing.T, srv proto.BypassServer) (*grpc.ClientConn, func()) { + t.Helper() + + lis, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + + gsrv := grpc.NewServer() + proto.RegisterBypassServer(gsrv, srv) + go gsrv.Serve(lis) + + conn, err := grpc.NewClient(lis.Addr().String(), + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + require.NoError(t, err) + + return conn, func() { + conn.Close() + gsrv.Stop() + } +} + +func TestGRPCPlugin_FailOpen_NilClient(t *testing.T) { + p := &grpcPlugin{ + conn: nil, + client: nil, + log: xlogger.Nop(), + } + assert.True(t, p.Contains(context.Background(), "tcp", "192.168.1.1")) + assert.NoError(t, p.Close()) +} + +func TestGRPCPlugin_Contains_Success(t *testing.T) { + conn, cleanup := newTestGRPCConn(t, &testBypassServer{ok: true}) + defer cleanup() + + p := &grpcPlugin{ + conn: conn, + client: proto.NewBypassClient(conn), + log: xlogger.Nop(), + } + assert.True(t, p.Contains(context.Background(), "tcp", "192.168.1.1")) +} + +func TestGRPCPlugin_Contains_Deny(t *testing.T) { + conn, cleanup := newTestGRPCConn(t, &testBypassServer{ok: false}) + defer cleanup() + + p := &grpcPlugin{ + conn: conn, + client: proto.NewBypassClient(conn), + log: xlogger.Nop(), + } + assert.False(t, p.Contains(context.Background(), "tcp", "192.168.1.1")) +} + +func TestGRPCPlugin_Contains_WithService(t *testing.T) { + srv := &testBypassServer{ok: true} + conn, cleanup := newTestGRPCConn(t, srv) + defer cleanup() + + p := &grpcPlugin{ + conn: conn, + client: proto.NewBypassClient(conn), + log: xlogger.Nop(), + } + assert.True(t, p.Contains(context.Background(), "tcp", "192.168.1.1", bypass.WithService("myservice"))) + assert.Equal(t, "myservice", srv.lastService) +} + +func TestGRPCPlugin_Contains_WithHostAndPath(t *testing.T) { + srv := &testBypassServer{ok: true} + conn, cleanup := newTestGRPCConn(t, srv) + defer cleanup() + + p := &grpcPlugin{ + conn: conn, + client: proto.NewBypassClient(conn), + log: xlogger.Nop(), + } + assert.True(t, p.Contains(context.Background(), "tcp", "192.168.1.1", + bypass.WithHostOption("example.com"), + bypass.WithPathOption("/api/v1"), + )) + assert.Equal(t, "example.com", srv.lastHost) + assert.Equal(t, "/api/v1", srv.lastPath) +} + +func TestGRPCPlugin_Contains_NilClient(t *testing.T) { + p := &grpcPlugin{ + conn: nil, + client: nil, + } + assert.True(t, p.Contains(context.Background(), "tcp", "192.168.1.1")) +} + +func TestGRPCPlugin_Contains_ServerError(t *testing.T) { + conn, cleanup := newTestGRPCConn(t, &errorBypassServer{}) + defer cleanup() + + p := &grpcPlugin{ + conn: conn, + client: proto.NewBypassClient(conn), + log: xlogger.Nop(), + } + // Server returns an error; Contains should return true (fail-open) + assert.True(t, p.Contains(context.Background(), "tcp", "192.168.1.1")) +} + +func TestGRPCPlugin_Close_NilConn(t *testing.T) { + p := &grpcPlugin{ + conn: nil, + client: nil, + } + assert.NoError(t, p.Close()) +} + +func TestGRPCPlugin_Close_NonCloserConn(t *testing.T) { + p := &grpcPlugin{ + conn: &nonCloserConn{}, + client: nil, + } + assert.NoError(t, p.Close()) +} + +func TestGRPCPlugin_Close_WithRealConn(t *testing.T) { + conn, cleanup := newTestGRPCConn(t, &testBypassServer{ok: true}) + defer cleanup() + + p := &grpcPlugin{ + conn: conn, + client: nil, + log: xlogger.Nop(), + } + assert.NoError(t, p.Close()) +} + +func TestNewGRPCPlugin_RealConn(t *testing.T) { + lis, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + + gsrv := grpc.NewServer() + proto.RegisterBypassServer(gsrv, &testBypassServer{ok: true}) + go gsrv.Serve(lis) + defer gsrv.Stop() + + p := NewGRPCPlugin("test", lis.Addr().String(), plugin.TimeoutOption(500)) + require.NotNil(t, p) + + assert.True(t, p.Contains(context.Background(), "tcp", "192.168.1.1")) + assert.NoError(t, p.(io.Closer).Close()) +} + +func TestGRPCPlugin_IsWhitelist(t *testing.T) { + p := &grpcPlugin{} + assert.False(t, p.IsWhitelist()) +} + +// --- test helpers --- + +type testBypassServer struct { + proto.UnimplementedBypassServer + ok bool + lastService string + lastHost string + lastPath string +} + +func (s *testBypassServer) Bypass(ctx context.Context, req *proto.BypassRequest) (*proto.BypassReply, error) { + s.lastService = req.Service + s.lastHost = req.Host + s.lastPath = req.Path + return &proto.BypassReply{Ok: s.ok}, nil +} + +type errorBypassServer struct { + proto.UnimplementedBypassServer +} + +func (s *errorBypassServer) Bypass(ctx context.Context, req *proto.BypassRequest) (*proto.BypassReply, error) { + return nil, status.Error(codes.Internal, "internal error") +} + +type nonCloserConn struct{} + +func (n *nonCloserConn) Invoke(ctx context.Context, method string, args any, reply any, opts ...grpc.CallOption) error { + return nil +} + +func (n *nonCloserConn) NewStream(ctx context.Context, desc *grpc.StreamDesc, method string, opts ...grpc.CallOption) (grpc.ClientStream, error) { + return nil, nil +} + +var _ grpc.ClientConnInterface = (*nonCloserConn)(nil) +var _ io.Closer = (*grpcPlugin)(nil) diff --git a/bypass/plugin/http.go b/bypass/plugin/http.go index 405c21e2..865f510d 100644 --- a/bypass/plugin/http.go +++ b/bypass/plugin/http.go @@ -62,6 +62,11 @@ func (p *httpPlugin) Contains(ctx context.Context, network, addr string, opts .. return true } + log := p.log + if log == nil { + log = logger.Default() + } + var options bypass.Options for _, opt := range opts { opt(&options) @@ -77,13 +82,13 @@ func (p *httpPlugin) Contains(ctx context.Context, network, addr string, opts .. } v, err := json.Marshal(&rb) if err != nil { - p.log.Error(err) + log.Error(err) return true } req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.url, bytes.NewReader(v)) if err != nil { - p.log.Error(err) + log.Error(err) return true } @@ -93,7 +98,7 @@ func (p *httpPlugin) Contains(ctx context.Context, network, addr string, opts .. req.Header.Set("Content-Type", "application/json") resp, err := p.client.Do(req) if err != nil { - p.log.Error(err) + log.Error(err) return true } defer resp.Body.Close() @@ -104,7 +109,7 @@ func (p *httpPlugin) Contains(ctx context.Context, network, addr string, opts .. res := httpPluginResponse{} if err := json.NewDecoder(resp.Body).Decode(&res); err != nil { - p.log.Error(err) + log.Error(err) return true } return res.OK diff --git a/bypass/plugin/http_test.go b/bypass/plugin/http_test.go new file mode 100644 index 00000000..79e3bd45 --- /dev/null +++ b/bypass/plugin/http_test.go @@ -0,0 +1,174 @@ +package bypass + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/go-gost/core/bypass" + "github.com/go-gost/x/internal/plugin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewHTTPPlugin(t *testing.T) { + p := NewHTTPPlugin("test", "http://localhost:9999") + require.NotNil(t, p) + + hp, ok := p.(*httpPlugin) + require.True(t, ok) + assert.Equal(t, "http://localhost:9999", hp.url) + assert.NotNil(t, hp.client) + assert.NotNil(t, hp.log) +} + +func TestHTTPPlugin_Contains_Success(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "application/json", r.Header.Get("Content-Type")) + + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"ok": true}`)) + })) + defer srv.Close() + + p := NewHTTPPlugin("test", srv.URL) + assert.True(t, p.Contains(context.Background(), "tcp", "192.168.1.1")) +} + +func TestHTTPPlugin_Contains_Deny(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"ok": false}`)) + })) + defer srv.Close() + + p := NewHTTPPlugin("test", srv.URL) + assert.False(t, p.Contains(context.Background(), "tcp", "192.168.1.1")) +} + +func TestHTTPPlugin_Contains_Non200(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusForbidden) + })) + defer srv.Close() + + p := NewHTTPPlugin("test", srv.URL) + // Non-200 returns true (fail-open) + assert.True(t, p.Contains(context.Background(), "tcp", "192.168.1.1")) +} + +func TestHTTPPlugin_Contains_InvalidJSON(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte(`not json`)) + })) + defer srv.Close() + + p := NewHTTPPlugin("test", srv.URL) + // Invalid JSON returns true (fail-open) + assert.True(t, p.Contains(context.Background(), "tcp", "192.168.1.1")) +} + +func TestHTTPPlugin_Contains_ConnectionRefused(t *testing.T) { + p := NewHTTPPlugin("test", "http://127.0.0.1:0", plugin.TimeoutOption(0)) + // Connection refused returns true (fail-open) + assert.True(t, p.Contains(context.Background(), "tcp", "192.168.1.1")) +} + +func TestHTTPPlugin_Contains_NilClient(t *testing.T) { + hp := &httpPlugin{ + url: "http://localhost", + client: nil, + } + // Nil client returns true (fail-open) + assert.True(t, hp.Contains(context.Background(), "tcp", "192.168.1.1")) +} + +func TestHTTPPlugin_Contains_WithService(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"ok": true}`)) + })) + defer srv.Close() + + p := NewHTTPPlugin("test", srv.URL) + assert.True(t, p.Contains(context.Background(), "tcp", "192.168.1.1", bypass.WithService("myservice"))) +} + +func TestHTTPPlugin_Contains_WithHostAndPath(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"ok": true}`)) + })) + defer srv.Close() + + p := NewHTTPPlugin("test", srv.URL) + assert.True(t, p.Contains(context.Background(), "tcp", "192.168.1.1", + bypass.WithHostOption("example.com"), + bypass.WithPathOption("/api/v1"), + )) +} + +func TestHTTPPlugin_Contains_WithCustomHeader(t *testing.T) { + var receivedHeader string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + receivedHeader = r.Header.Get("X-Custom") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"ok": true}`)) + })) + defer srv.Close() + + p := NewHTTPPlugin("test", srv.URL, plugin.HeaderOption(http.Header{ + "X-Custom": []string{"test-value"}, + })) + assert.True(t, p.Contains(context.Background(), "tcp", "192.168.1.1")) + assert.Equal(t, "test-value", receivedHeader) +} + +func TestHTTPPlugin_Contains_NoHeader(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"ok": true}`)) + })) + defer srv.Close() + + hp := &httpPlugin{ + url: srv.URL, + client: plugin.NewHTTPClient(&plugin.Options{}), + header: nil, + } + assert.True(t, hp.Contains(context.Background(), "tcp", "192.168.1.1")) +} + +func TestHTTPPlugin_Contains_BadURL(t *testing.T) { + hp := &httpPlugin{ + url: "http://invalid hostname", // space causes NewRequest to fail + client: plugin.NewHTTPClient(&plugin.Options{}), + } + // Bad URL returns true (fail-open) + assert.True(t, hp.Contains(context.Background(), "tcp", "192.168.1.1")) +} + +func TestHTTPPlugin_Close_NilClient(t *testing.T) { + hp := &httpPlugin{client: nil} + err := hp.Close() + assert.NoError(t, err) +} + +func TestHTTPPlugin_Close_WithClient(t *testing.T) { + hp := &httpPlugin{ + client: plugin.NewHTTPClient(&plugin.Options{}), + } + err := hp.Close() + assert.NoError(t, err) +} + +func TestHTTPPlugin_IsWhitelist(t *testing.T) { + hp := &httpPlugin{} + assert.False(t, hp.IsWhitelist()) +} + +var _ io.Closer = (*httpPlugin)(nil)