diff --git a/auth/auth_test.go b/auth/auth_test.go new file mode 100644 index 00000000..a13ceced --- /dev/null +++ b/auth/auth_test.go @@ -0,0 +1,1005 @@ +package auth + +import ( + "context" + "errors" + "io" + "strings" + "testing" + "time" + + "github.com/go-gost/core/auth" + "github.com/go-gost/x/internal/loader" + xlogger "github.com/go-gost/x/logger" +) + +// --- mock types --- + +type mockLoader struct { + loadData 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.loadData), nil +} + +func (m *mockLoader) Close() error { + m.closed = true + return nil +} + +type mockMapper struct { + mapData map[string]string + mapErr error + closed bool +} + +func (m *mockMapper) Load(ctx context.Context) (io.Reader, error) { + return nil, nil +} + +func (m *mockMapper) Map(ctx context.Context) (map[string]string, error) { + if m.mapErr != nil { + return nil, m.mapErr + } + return m.mapData, nil +} + +func (m *mockMapper) Close() error { + m.closed = true + return nil +} + +var _ loader.Loader = (*mockLoader)(nil) +var _ loader.Loader = (*mockMapper)(nil) +var _ loader.Mapper = (*mockMapper)(nil) + +// --- option tests --- + +func TestAuthsOption(t *testing.T) { + opts := &options{} + auths := map[string]string{"user": "pass"} + AuthsOption(auths)(opts) + if len(opts.auths) != 1 || opts.auths["user"] != "pass" { + t.Fatalf("expected auths to be set, got %v", opts.auths) + } +} + +func TestReloadPeriodOption(t *testing.T) { + opts := &options{} + ReloadPeriodOption(5 * time.Second)(opts) + if opts.period != 5*time.Second { + t.Fatalf("expected period 5s, got %v", opts.period) + } +} + +func TestFileLoaderOption(t *testing.T) { + opts := &options{} + ml := &mockLoader{} + FileLoaderOption(ml)(opts) + if opts.fileLoader != ml { + t.Fatal("expected fileLoader to be set") + } +} + +func TestRedisLoaderOption(t *testing.T) { + opts := &options{} + ml := &mockLoader{} + RedisLoaderOption(ml)(opts) + if opts.redisLoader != ml { + t.Fatal("expected redisLoader to be set") + } +} + +func TestHTTPLoaderOption(t *testing.T) { + opts := &options{} + ml := &mockLoader{} + HTTPLoaderOption(ml)(opts) + if opts.httpLoader != ml { + t.Fatal("expected httpLoader to be set") + } +} + +func TestLoggerOption(t *testing.T) { + opts := &options{} + l := xlogger.Nop() + LoggerOption(l)(opts) + if opts.logger != l { + t.Fatal("expected logger to be set") + } +} + +// --- NewAuthenticator tests --- + +func TestNewAuthenticator_Defaults(t *testing.T) { + p := NewAuthenticator() + if p == nil { + t.Fatal("expected authenticator to be created") + } + defer p.(*authenticator).Close() + + ap := p.(*authenticator) + if ap.logger == nil { + t.Fatal("expected non-nil logger (Nop fallback)") + } + if ap.cancelFunc == nil { + t.Fatal("expected cancelFunc to be set") + } + if len(ap.kvs) != 0 { + t.Fatal("expected empty kvs") + } +} + +func TestNewAuthenticator_WithAuths(t *testing.T) { + p := NewAuthenticator(AuthsOption(map[string]string{"u": "p"})) + defer p.(*authenticator).Close() + + // Initial reload is async — wait for it. + time.Sleep(50 * time.Millisecond) + + ap := p.(*authenticator) + ap.mu.RLock() + if ap.kvs["u"] != "p" { + t.Fatal("expected kvs to contain u=p") + } + ap.mu.RUnlock() +} + +func TestNewAuthenticator_WithLogger(t *testing.T) { + l := xlogger.Nop() + p := NewAuthenticator(LoggerOption(l)) + defer p.(*authenticator).Close() + + ap := p.(*authenticator) + if ap.logger != l { + t.Fatal("expected custom logger") + } +} + +// --- Authenticate tests --- + +func TestAuthenticate_NilReceiver(t *testing.T) { + var p *authenticator + id, ok := p.Authenticate(context.Background(), "u", "p") + if !ok || id != "" { + t.Fatalf("nil receiver should return '', true, got %q, %v", id, ok) + } +} + +func TestAuthenticate_EmptyKVs(t *testing.T) { + p := NewAuthenticator() + defer p.(*authenticator).Close() + + id, ok := p.Authenticate(context.Background(), "u", "p") + if ok || id != "" { + t.Fatalf("empty kvs should return '', false, got %q, %v", id, ok) + } +} + +func TestAuthenticate_UserNotFound(t *testing.T) { + p := NewAuthenticator(AuthsOption(map[string]string{"admin": "secret"})) + defer p.(*authenticator).Close() + + // Wait for initial reload to populate kvs + time.Sleep(50 * time.Millisecond) + + // When user is not in kvs, Authenticate returns (user, false) + id, ok := p.Authenticate(context.Background(), "nobody", "") + if ok || id != "nobody" { + t.Fatalf("unknown user should return 'nobody', false, got %q, %v", id, ok) + } +} + +func TestAuthenticate_UserFoundNoPassword(t *testing.T) { + p := NewAuthenticator(AuthsOption(map[string]string{"open": ""})) + defer p.(*authenticator).Close() + + time.Sleep(50 * time.Millisecond) + + id, ok := p.Authenticate(context.Background(), "open", "") + if !ok || id != "open" { + t.Fatalf("expected open, true, got %q, %v", id, ok) + } + id2, ok2 := p.Authenticate(context.Background(), "open", "anything") + if !ok2 || id2 != "open" { + t.Fatalf("any password should pass for empty-password user, got %q, %v", id2, ok2) + } +} + +func TestAuthenticate_PasswordMatch(t *testing.T) { + p := NewAuthenticator(AuthsOption(map[string]string{"admin": "secret"})) + defer p.(*authenticator).Close() + + time.Sleep(50 * time.Millisecond) + + id, ok := p.Authenticate(context.Background(), "admin", "secret") + if !ok || id != "admin" { + t.Fatalf("expected admin, true, got %q, %v", id, ok) + } +} + +func TestAuthenticate_PasswordMismatch(t *testing.T) { + p := NewAuthenticator(AuthsOption(map[string]string{"admin": "secret"})) + defer p.(*authenticator).Close() + + time.Sleep(50 * time.Millisecond) + + id, ok := p.Authenticate(context.Background(), "admin", "wrong") + if ok { + t.Fatalf("wrong password should return false, got %q, %v", id, ok) + } +} + +// --- parseAuths tests --- + +func TestParseAuths_NilReader(t *testing.T) { + p := &authenticator{} + result, err := p.parseAuths(nil) + if err != nil || result != nil { + t.Fatalf("nil reader should return nil, nil, got %v, %v", result, err) + } +} + +func TestParseAuths_EmptyReader(t *testing.T) { + p := &authenticator{} + result, err := p.parseAuths(strings.NewReader("")) + if err != nil || len(result) != 0 { + t.Fatalf("empty reader should return empty map, got %v, %v", result, err) + } +} + +func TestParseAuths_Comments(t *testing.T) { + p := &authenticator{} + result, err := p.parseAuths(strings.NewReader("# this is a comment\n# another comment")) + if err != nil || len(result) != 0 { + t.Fatalf("comments should be skipped, got %v, %v", result, err) + } +} + +func TestParseAuths_SingleKey(t *testing.T) { + p := &authenticator{} + result, err := p.parseAuths(strings.NewReader("testuser")) + if err != nil { + t.Fatal(err) + } + if result["testuser"] != "" { + t.Fatalf("single key should have empty password, got %q", result["testuser"]) + } +} + +func TestParseAuths_KeyValue(t *testing.T) { + p := &authenticator{} + result, err := p.parseAuths(strings.NewReader("testuser testpass")) + if err != nil { + t.Fatal(err) + } + if result["testuser"] != "testpass" { + t.Fatalf("expected testpass, got %q", result["testuser"]) + } +} + +func TestParseAuths_TabConversion(t *testing.T) { + p := &authenticator{} + result, err := p.parseAuths(strings.NewReader("testuser\ttestpass")) + if err != nil { + t.Fatal(err) + } + if result["testuser"] != "testpass" { + t.Fatalf("tab should be converted to space, got %q", result["testuser"]) + } +} + +func TestParseAuths_MultipleLines(t *testing.T) { + p := &authenticator{} + data := "user1 pass1\nuser2\nuser3 pass3\n# comment\n\n user4 pass4 " + result, err := p.parseAuths(strings.NewReader(data)) + if err != nil { + t.Fatal(err) + } + if len(result) != 4 { + t.Fatalf("expected 4 entries, got %d", len(result)) + } + if result["user1"] != "pass1" { + t.Fatalf("user1: expected pass1, got %q", result["user1"]) + } + if result["user2"] != "" { + t.Fatalf("user2: expected empty, got %q", result["user2"]) + } + if result["user3"] != "pass3" { + t.Fatalf("user3: expected pass3, got %q", result["user3"]) + } + if result["user4"] != "pass4" { + t.Fatalf("user4: expected pass4, got %q", result["user4"]) + } +} + +func TestParseAuths_EmptyKeyInPair(t *testing.T) { + p := &authenticator{} + result, err := p.parseAuths(strings.NewReader(" extra spaces here")) + if err != nil { + t.Fatal(err) + } + if result["extra"] != "spaces here" { + t.Fatalf("expected 'spaces here', got %q", result["extra"]) + } +} + +func TestParseAuths_WhitespaceOnlyLines(t *testing.T) { + // TrimSpace removes leading/trailing whitespace before SplitN, + // so whitespace-only lines become empty and produce no entries. + p := &authenticator{} + result, err := p.parseAuths(strings.NewReader(" \n\t ")) + if err != nil || len(result) != 0 { + t.Fatalf("whitespace-only lines should produce empty map, got %v, %v", result, err) + } +} + +// --- load tests --- + +func TestLoad_NoLoaders(t *testing.T) { + p := &authenticator{logger: xlogger.Nop()} + m, err := p.load(context.Background()) + if err != nil || len(m) != 0 { + t.Fatalf("expected empty map, nil err, got %v, %v", m, err) + } +} + +func TestLoad_FileLoaderMapper_Success(t *testing.T) { + p := &authenticator{ + options: options{ + fileLoader: &mockMapper{mapData: map[string]string{"a": "b"}}, + }, + logger: xlogger.Nop(), + } + m, err := p.load(context.Background()) + if err != nil || m["a"] != "b" { + t.Fatalf("expected a=b, got %v, %v", m, err) + } +} + +func TestLoad_FileLoaderMapper_Error(t *testing.T) { + p := &authenticator{ + options: options{ + fileLoader: &mockMapper{mapErr: errors.New("map error")}, + }, + logger: xlogger.Nop(), + } + _, err := p.load(context.Background()) + if err == nil { + t.Fatal("expected error from mapper") + } +} + +func TestLoad_FileLoaderNonMapper_Success(t *testing.T) { + p := &authenticator{ + options: options{ + fileLoader: &mockLoader{loadData: "u1 p1"}, + }, + logger: xlogger.Nop(), + } + m, err := p.load(context.Background()) + if err != nil || m["u1"] != "p1" { + t.Fatalf("expected u1=p1, got %v, %v", m, err) + } +} + +func TestLoad_FileLoaderNonMapper_LoadError(t *testing.T) { + p := &authenticator{ + options: options{ + fileLoader: &mockLoader{loadErr: errors.New("load error")}, + }, + logger: xlogger.Nop(), + } + _, err := p.load(context.Background()) + if err == nil { + t.Fatal("expected error from loader") + } +} + +func TestLoad_FileLoaderNonMapper_EmptyData(t *testing.T) { + // mockLoader with empty loadData returns empty string reader; + // parseAuths succeeds with empty result (not a parse error). + p := &authenticator{ + options: options{ + fileLoader: &mockLoader{}, + }, + logger: xlogger.Nop(), + } + m, err := p.load(context.Background()) + if err != nil || len(m) != 0 { + t.Fatalf("expected empty map, got %v, %v", m, err) + } +} + +func TestLoad_RedisLoaderMapper_Success(t *testing.T) { + p := &authenticator{ + options: options{ + redisLoader: &mockMapper{mapData: map[string]string{"r": "s"}}, + }, + logger: xlogger.Nop(), + } + m, err := p.load(context.Background()) + if err != nil || m["r"] != "s" { + t.Fatalf("expected r=s, got %v, %v", m, err) + } +} + +func TestLoad_RedisLoaderMapper_Error(t *testing.T) { + p := &authenticator{ + options: options{ + redisLoader: &mockMapper{mapErr: errors.New("redis error")}, + }, + logger: xlogger.Nop(), + } + _, err := p.load(context.Background()) + if err == nil { + t.Fatal("expected error from redis mapper") + } +} + +func TestLoad_RedisLoaderNonMapper_Success(t *testing.T) { + p := &authenticator{ + options: options{ + redisLoader: &mockLoader{loadData: "r1 rp1"}, + }, + logger: xlogger.Nop(), + } + m, err := p.load(context.Background()) + if err != nil || m["r1"] != "rp1" { + t.Fatalf("expected r1=rp1, got %v, %v", m, err) + } +} + +func TestLoad_RedisLoaderNonMapper_LoadError(t *testing.T) { + p := &authenticator{ + options: options{ + redisLoader: &mockLoader{loadErr: errors.New("redis load err")}, + }, + logger: xlogger.Nop(), + } + _, err := p.load(context.Background()) + if err == nil { + t.Fatal("expected error") + } +} + +func TestLoad_RedisLoaderNonMapper_ParseError(t *testing.T) { + p := &authenticator{ + options: options{ + redisLoader: &mockLoader{loadData: "# comment"}, + }, + logger: xlogger.Nop(), + } + m, err := p.load(context.Background()) + // parseAuths succeeds (comments are skipped), just empty + if err != nil || len(m) != 0 { + t.Fatalf("expected empty map, got %v, %v", m, err) + } +} + +func TestLoad_HTTPLoader_Success(t *testing.T) { + p := &authenticator{ + options: options{ + httpLoader: &mockLoader{loadData: "h1 hp1"}, + }, + logger: xlogger.Nop(), + } + m, err := p.load(context.Background()) + if err != nil || m["h1"] != "hp1" { + t.Fatalf("expected h1=hp1, got %v, %v", m, err) + } +} + +func TestLoad_HTTPLoader_LoadError(t *testing.T) { + p := &authenticator{ + options: options{ + httpLoader: &mockLoader{loadErr: errors.New("http error")}, + }, + logger: xlogger.Nop(), + } + _, err := p.load(context.Background()) + if err == nil { + t.Fatal("expected error from http loader") + } +} + +func TestLoad_HTTPLoader_ParseError(t *testing.T) { + p := &authenticator{ + options: options{ + httpLoader: &mockLoader{loadData: "# comment"}, + }, + logger: xlogger.Nop(), + } + m, err := p.load(context.Background()) + if err != nil || len(m) != 0 { + t.Fatalf("expected empty map, got %v, %v", m, err) + } +} + +func TestLoad_MultipleLoaders_Merge(t *testing.T) { + p := &authenticator{ + options: options{ + fileLoader: &mockLoader{loadData: "f1 fp1"}, + redisLoader: &mockLoader{loadData: "r1 rp1"}, + }, + logger: xlogger.Nop(), + } + m, err := p.load(context.Background()) + if err != nil { + t.Fatal(err) + } + if m["f1"] != "fp1" || m["r1"] != "rp1" { + t.Fatalf("expected merged results, got %v", m) + } +} + +func TestLoad_FirstSuccessSecondError(t *testing.T) { + // fileLoader succeeds, redisLoader fails — error from redisLoader is returned + // but fileLoader results are preserved + p := &authenticator{ + options: options{ + fileLoader: &mockLoader{loadData: "f1 fp1"}, + redisLoader: &mockLoader{loadErr: errors.New("redis fail")}, + }, + logger: xlogger.Nop(), + } + m, err := p.load(context.Background()) + if err == nil { + t.Fatal("expected error from redis loader") + } + if m["f1"] != "fp1" { + t.Fatalf("file loader results should be preserved, got %v", m) + } +} + +func TestLoad_RedisMapper_ErrorConditionalLoadErr(t *testing.T) { + // redisLoader mapper error when loadErr is already set (from fileLoader error) + p := &authenticator{ + options: options{ + fileLoader: &mockLoader{loadErr: errors.New("file fail")}, + redisLoader: &mockMapper{mapErr: errors.New("redis fail")}, + }, + logger: xlogger.Nop(), + } + _, err := p.load(context.Background()) + // loadErr was set by fileLoader, redisLoader error doesn't overwrite it + if err == nil || err.Error() != "file fail" { + t.Fatalf("expected 'file fail', got %v", err) + } +} + +func TestLoad_HTTPLoader_ErrorConditionalLoadErr(t *testing.T) { + // httpLoader error when loadErr already set + p := &authenticator{ + options: options{ + fileLoader: &mockLoader{loadErr: errors.New("file fail")}, + httpLoader: &mockLoader{loadErr: errors.New("http fail")}, + }, + logger: xlogger.Nop(), + } + _, err := p.load(context.Background()) + if err == nil || err.Error() != "file fail" { + t.Fatalf("expected 'file fail' (first error preserved), got %v", err) + } +} + +func TestLoad_FileMapper_ErrorConditionalLoadErr(t *testing.T) { + // fileLoader mapper error, then redisLoader non-mapper error + // fileLoader mapper sets loadErr, redisLoader non-mapper doesn't overwrite (conditional) + p := &authenticator{ + options: options{ + fileLoader: &mockMapper{mapErr: errors.New("file map fail")}, + redisLoader: &mockLoader{loadErr: errors.New("redis fail")}, + }, + logger: xlogger.Nop(), + } + _, err := p.load(context.Background()) + if err == nil || err.Error() != "file map fail" { + t.Fatalf("expected 'file map fail', got %v", err) + } +} + +// --- reload tests --- + +func TestReload_Success(t *testing.T) { + p := &authenticator{ + options: options{ + auths: map[string]string{"static": "val"}, + }, + kvs: make(map[string]string), + logger: xlogger.Nop(), + } + err := p.reload(context.Background()) + if err != nil || len(p.kvs) != 1 || p.kvs["static"] != "val" { + t.Fatalf("expected static=val, got %v, err=%v", p.kvs, err) + } +} + +func TestReload_LoadError(t *testing.T) { + p := &authenticator{ + options: options{ + fileLoader: &mockLoader{loadErr: errors.New("fail")}, + }, + kvs: map[string]string{"old": "data"}, + logger: xlogger.Nop(), + } + err := p.reload(context.Background()) + if err == nil { + t.Fatal("expected error from reload") + } + // On error, kvs should not be updated (old data preserved) + if p.kvs["old"] != "data" { + t.Fatal("old kvs should be preserved on reload error") + } +} + +func TestReload_ClearsOldData(t *testing.T) { + p := &authenticator{ + options: options{ + auths: map[string]string{"new": "val"}, + }, + kvs: map[string]string{"old": "data"}, + logger: xlogger.Nop(), + } + err := p.reload(context.Background()) + if err != nil { + t.Fatal(err) + } + if _, exists := p.kvs["old"]; exists { + t.Fatal("old data should be cleared on successful reload") + } + if p.kvs["new"] != "val" { + t.Fatal("new data should be present") + } +} + +// --- periodReload tests --- + +func TestPeriodReload_NoPeriod(t *testing.T) { + p := &authenticator{ + options: options{ + auths: map[string]string{"k": "v"}, + }, + kvs: make(map[string]string), + logger: xlogger.Nop(), + } + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + + err := p.periodReload(ctx) + if err != nil { + t.Fatalf("expected nil error for zero period, got %v", err) + } + // With period <= 0, the initial reload happened and then returned + if p.kvs["k"] != "v" { + t.Fatal("expected k=v after initial reload") + } +} + +func TestPeriodReload_ShortPeriod_Clamped(t *testing.T) { + p := &authenticator{ + options: options{ + period: 500 * time.Millisecond, // < 1s, gets clamped to 1s + }, + kvs: make(map[string]string), + logger: xlogger.Nop(), + } + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + + // Initial reload happens, then ticker starts but ctx cancels before first tick + err := p.periodReload(ctx) + if err == nil { + t.Fatal("expected context error") + } +} + +func TestPeriodReload_ContextCancellation(t *testing.T) { + p := &authenticator{ + options: options{ + period: 10 * time.Second, // long period so tick doesn't fire + }, + kvs: make(map[string]string), + logger: xlogger.Nop(), + } + ctx, cancel := context.WithCancel(context.Background()) + + done := make(chan error, 1) + go func() { + done <- p.periodReload(ctx) + }() + + // Give it time to complete the initial reload and enter the loop + time.Sleep(50 * time.Millisecond) + cancel() + + err := <-done + if err == nil { + t.Fatal("expected context error") + } +} + +func TestPeriodReload_ReloadError_Initial(t *testing.T) { + p := &authenticator{ + options: options{ + fileLoader: &mockLoader{loadErr: errors.New("fail")}, + period: 0, // no period, just initial reload + }, + kvs: make(map[string]string), + logger: xlogger.Nop(), + } + err := p.periodReload(context.Background()) + // period is 0, so after initial reload it returns nil + if err != nil { + t.Fatalf("period 0 should return nil, got %v", err) + } +} + +// --- Close tests --- + +func TestClose_NoLoaders(t *testing.T) { + p := NewAuthenticator() + err := p.(*authenticator).Close() + if err != nil { + t.Fatal(err) + } +} + +func TestClose_WithLoaders(t *testing.T) { + fl := &mockLoader{} + rl := &mockLoader{} + hl := &mockLoader{} + p := NewAuthenticator( + FileLoaderOption(fl), + RedisLoaderOption(rl), + HTTPLoaderOption(hl), + ) + err := p.(*authenticator).Close() + if err != nil { + t.Fatal(err) + } + if !fl.closed || !rl.closed || !hl.closed { + t.Fatal("all loaders should be closed") + } +} + +func TestClose_CallsCancelFunc(t *testing.T) { + p := &authenticator{ + kvs: make(map[string]string), + logger: xlogger.Nop(), + } + ctx, cancel := context.WithCancel(context.Background()) + p.cancelFunc = cancel + + err := p.Close() + if err != nil { + t.Fatal(err) + } + // Verify context was cancelled + select { + case <-ctx.Done(): + // expected + default: + t.Fatal("context should be cancelled after Close") + } +} + +// --- AuthenticatorGroup tests --- + +func TestAuthenticatorGroup_Empty(t *testing.T) { + g := AuthenticatorGroup() + id, ok := g.Authenticate(context.Background(), "u", "p") + if ok || id != "" { + t.Fatalf("empty group should return '', false, got %q, %v", id, ok) + } +} + +func TestAuthenticatorGroup_SingleSuccess(t *testing.T) { + g := AuthenticatorGroup(&staticAuther{id: "user1", ok: true}) + id, ok := g.Authenticate(context.Background(), "u", "p") + if !ok || id != "user1" { + t.Fatalf("expected user1, true, got %q, %v", id, ok) + } +} + +func TestAuthenticatorGroup_SingleFail(t *testing.T) { + g := AuthenticatorGroup(&staticAuther{id: "", ok: false}) + id, ok := g.Authenticate(context.Background(), "u", "p") + if ok || id != "" { + t.Fatalf("expected '', false, got %q, %v", id, ok) + } +} + +func TestAuthenticatorGroup_Fallthrough(t *testing.T) { + g := AuthenticatorGroup( + &staticAuther{id: "", ok: false}, + &staticAuther{id: "second", ok: true}, + ) + id, ok := g.Authenticate(context.Background(), "u", "p") + if !ok || id != "second" { + t.Fatalf("expected second, true, got %q, %v", id, ok) + } +} + +func TestAuthenticatorGroup_NilEntries(t *testing.T) { + g := AuthenticatorGroup( + nil, + &staticAuther{id: "third", ok: true}, + ) + id, ok := g.Authenticate(context.Background(), "u", "p") + if !ok || id != "third" { + t.Fatalf("nil entries should be skipped, expected third, true, got %q, %v", id, ok) + } +} + +func TestAuthenticatorGroup_AllFail(t *testing.T) { + g := AuthenticatorGroup( + &staticAuther{id: "", ok: false}, + &staticAuther{id: "", ok: false}, + ) + id, ok := g.Authenticate(context.Background(), "u", "p") + if ok || id != "" { + t.Fatalf("all fail should return '', false, got %q, %v", id, ok) + } +} + +func TestAuthenticatorGroup_Creation(t *testing.T) { + a1 := &staticAuther{id: "a", ok: true} + a2 := &staticAuther{id: "b", ok: false} + g := AuthenticatorGroup(a1, a2).(*authenticatorGroup) + if len(g.authers) != 2 { + t.Fatalf("expected 2 authers, got %d", len(g.authers)) + } +} + +// --- scanner error tests --- + +type errorReader struct{} + +func (e *errorReader) Read(p []byte) (int, error) { + return 0, errors.New("read error") +} + +func TestParseAuths_ScannerError(t *testing.T) { + p := &authenticator{} + _, err := p.parseAuths(&errorReader{}) + if err == nil { + t.Fatal("expected scanner error") + } +} + +// --- load parse error branch tests --- + +func TestLoad_FileLoaderNonMapper_ParseErrorBranch(t *testing.T) { + // File loader non-mapper: Load succeeds but reader triggers scanner error in parseAuths. + // This hits the "file loader parse" Warnf branch. + // parse errors are NOT propagated as loadErr — only load errors are. + p := &authenticator{ + options: options{ + fileLoader: &errorLoader{reader: &errorReader{}}, + }, + logger: xlogger.Nop(), + } + _, err := p.load(context.Background()) + if err != nil { + t.Fatalf("parse error should not propagate, got %v", err) + } +} + +func TestLoad_RedisLoaderNonMapper_ParseErrorBranch(t *testing.T) { + // Redis loader non-mapper — parseAuths returns scanner error. + p := &authenticator{ + options: options{ + redisLoader: &errorLoader{reader: &errorReader{}}, + }, + logger: xlogger.Nop(), + } + _, err := p.load(context.Background()) + if err != nil { + t.Fatalf("parse error should not propagate, got %v", err) + } +} + +func TestLoad_HTTPLoader_ParseErrorBranch(t *testing.T) { + // HTTP loader — parseAuths returns scanner error. + p := &authenticator{ + options: options{ + httpLoader: &errorLoader{reader: &errorReader{}}, + }, + logger: xlogger.Nop(), + } + _, err := p.load(context.Background()) + if err != nil { + t.Fatalf("parse error should not propagate, got %v", err) + } +} + +type errorLoader struct { + reader io.Reader +} + +func (l *errorLoader) Load(ctx context.Context) (io.Reader, error) { + return l.reader, nil +} + +func (l *errorLoader) Close() error { return nil } + +var _ loader.Loader = (*errorLoader)(nil) + +// --- periodReload ticker fire test --- + +func TestPeriodReload_TickerFires(t *testing.T) { + // Set up with a loader that succeeds on initial reload, so we enter the tick loop. + // Use period=1s so ticker fires quickly. + p := &authenticator{ + options: options{ + period: 1 * time.Second, + fileLoader: &mockLoader{loadData: "k v"}, + }, + kvs: make(map[string]string), + logger: xlogger.Nop(), + } + + ctx, cancel := context.WithTimeout(context.Background(), 1100*time.Millisecond) + defer cancel() + + done := make(chan error, 1) + go func() { + done <- p.periodReload(ctx) + }() + + err := <-done + if err == nil { + t.Fatal("expected context deadline exceeded") + } + + // kvs should be populated by the initial reload + p.mu.RLock() + v := p.kvs["k"] + p.mu.RUnlock() + if v != "v" { + t.Fatalf("expected k=v after initial reload, got %q", v) + } +} + +func TestPeriodReload_TickerReloadError(t *testing.T) { + // Loader that fails — covers the Warnf inside the ticker.C case. + // Initial reload fails (Warnf line 116), ticker fires, reload fails again (Warnf line 133). + p := &authenticator{ + options: options{ + period: 1 * time.Second, + fileLoader: &mockLoader{loadErr: errors.New("persistent fail")}, + }, + kvs: make(map[string]string), + logger: xlogger.Nop(), + } + + ctx, cancel := context.WithTimeout(context.Background(), 1100*time.Millisecond) + defer cancel() + + done := make(chan error, 1) + go func() { + done <- p.periodReload(ctx) + }() + + err := <-done + if err == nil { + t.Fatal("expected context deadline exceeded") + } +} + +// --- helper type for group tests --- + +type staticAuther struct { + id string + ok bool +} + +func (s *staticAuther) Authenticate(ctx context.Context, user, password string, opts ...auth.Option) (string, bool) { + return s.id, s.ok +} diff --git a/auth/plugin/grpc_test.go b/auth/plugin/grpc_test.go new file mode 100644 index 00000000..0d3e4ae2 --- /dev/null +++ b/auth/plugin/grpc_test.go @@ -0,0 +1,252 @@ +package auth + +import ( + "context" + "io" + "net" + "os" + "testing" + + "github.com/go-gost/core/auth" + corelogger "github.com/go-gost/core/logger" + "github.com/go-gost/plugin/auth/proto" + xctx "github.com/go-gost/x/ctx" + "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()) +} + +func newTestGRPCConn(t *testing.T, srv proto.AuthenticatorServer) (*grpc.ClientConn, func()) { + t.Helper() + + lis, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + + gsrv := grpc.NewServer() + proto.RegisterAuthenticatorServer(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_NilClient(t *testing.T) { + p := &grpcPlugin{ + conn: nil, + client: nil, + log: xlogger.Nop(), + } + id, ok := p.Authenticate(context.Background(), "u", "p") + assert.False(t, ok) + assert.Empty(t, id) + assert.NoError(t, p.Close()) +} + +func TestGRPCPlugin_Authenticate_Success(t *testing.T) { + conn, cleanup := newTestGRPCConn(t, &testAuthServer{ok: true, id: "user1"}) + defer cleanup() + + p := &grpcPlugin{ + conn: conn, + client: proto.NewAuthenticatorClient(conn), + log: xlogger.Nop(), + } + id, ok := p.Authenticate(context.Background(), "u", "p") + assert.True(t, ok) + assert.Equal(t, "user1", id) +} + +func TestGRPCPlugin_Authenticate_Fail(t *testing.T) { + conn, cleanup := newTestGRPCConn(t, &testAuthServer{ok: false, id: ""}) + defer cleanup() + + p := &grpcPlugin{ + conn: conn, + client: proto.NewAuthenticatorClient(conn), + log: xlogger.Nop(), + } + id, ok := p.Authenticate(context.Background(), "u", "p") + assert.False(t, ok) + assert.Empty(t, id) +} + +func TestGRPCPlugin_Authenticate_WithService(t *testing.T) { + server := &testAuthServer{ok: true, id: "srv"} + conn, cleanup := newTestGRPCConn(t, server) + defer cleanup() + + p := &grpcPlugin{ + conn: conn, + client: proto.NewAuthenticatorClient(conn), + log: xlogger.Nop(), + } + id, ok := p.Authenticate(context.Background(), "u", "p", auth.WithService("myservice")) + assert.True(t, ok) + assert.Equal(t, "srv", id) + assert.Equal(t, "myservice", server.lastService) +} + +func TestGRPCPlugin_Authenticate_WithClientAddr(t *testing.T) { + server := &testAuthServer{ok: true, id: "addr"} + conn, cleanup := newTestGRPCConn(t, server) + defer cleanup() + + p := &grpcPlugin{ + conn: conn, + client: proto.NewAuthenticatorClient(conn), + log: xlogger.Nop(), + } + + ctx := context.Background() + ctx = xctx.ContextWithSrcAddr(ctx, &net.TCPAddr{IP: net.ParseIP("10.0.0.1"), Port: 1234}) + id, ok := p.Authenticate(ctx, "u", "p") + assert.True(t, ok) + assert.Equal(t, "addr", id) + assert.Equal(t, "10.0.0.1:1234", server.lastClient) +} + +func TestGRPCPlugin_Authenticate_WithoutClientAddr(t *testing.T) { + server := &testAuthServer{ok: true, id: "noclient"} + conn, cleanup := newTestGRPCConn(t, server) + defer cleanup() + + p := &grpcPlugin{ + conn: conn, + client: proto.NewAuthenticatorClient(conn), + log: xlogger.Nop(), + } + + id, ok := p.Authenticate(context.Background(), "u", "p") + assert.True(t, ok) + assert.Equal(t, "noclient", id) + assert.Empty(t, server.lastClient) +} + +func TestGRPCPlugin_Authenticate_ServerError(t *testing.T) { + conn, cleanup := newTestGRPCConn(t, &errorAuthServer{}) + defer cleanup() + + p := &grpcPlugin{ + conn: conn, + client: proto.NewAuthenticatorClient(conn), + log: xlogger.Nop(), + } + id, ok := p.Authenticate(context.Background(), "u", "p") + assert.False(t, ok) + assert.Empty(t, id) +} + +func TestNewGRPCPlugin_RealConn(t *testing.T) { + lis, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + + gsrv := grpc.NewServer() + proto.RegisterAuthenticatorServer(gsrv, &testAuthServer{ok: true, id: "real"}) + go gsrv.Serve(lis) + defer gsrv.Stop() + + p := NewGRPCPlugin("test", lis.Addr().String(), plugin.TimeoutOption(500)) + require.NotNil(t, p) + + id, ok := p.Authenticate(context.Background(), "u", "p") + assert.True(t, ok) + assert.Equal(t, "real", id) + assert.NoError(t, p.(io.Closer).Close()) +} + +func TestNewGRPCPlugin_InvalidAddr(t *testing.T) { + // Null byte causes grpc.NewClient target parsing to fail synchronously. + // This covers the log.Error(err) path. + // NOTE: depends on grpc-go's net/url control-character validation. + // If a future grpc version changes target parsing, this test may silently + // stop covering the error branch. + p := NewGRPCPlugin("test", "\x00") + require.NotNil(t, p) + + // conn is nil, so client is nil, so Authenticate returns false + id, ok := p.Authenticate(context.Background(), "u", "p") + assert.False(t, ok) + assert.Empty(t, id) +} + +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, &testAuthServer{ok: true}) + defer cleanup() + + p := &grpcPlugin{ + conn: conn, + client: nil, + log: xlogger.Nop(), + } + assert.NoError(t, p.Close()) +} + +// --- test helpers --- + +type testAuthServer struct { + proto.UnimplementedAuthenticatorServer + ok bool + id string + lastService string + lastClient string +} + +func (s *testAuthServer) Authenticate(ctx context.Context, req *proto.AuthenticateRequest) (*proto.AuthenticateReply, error) { + s.lastService = req.Service + s.lastClient = req.Client + return &proto.AuthenticateReply{Ok: s.ok, Id: s.id}, nil +} + +type errorAuthServer struct { + proto.UnimplementedAuthenticatorServer +} + +func (s *errorAuthServer) Authenticate(ctx context.Context, req *proto.AuthenticateRequest) (*proto.AuthenticateReply, 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/auth/plugin/http_test.go b/auth/plugin/http_test.go new file mode 100644 index 00000000..d4176a44 --- /dev/null +++ b/auth/plugin/http_test.go @@ -0,0 +1,206 @@ +package auth + +import ( + "context" + "net" + "net/http" + "net/http/httptest" + "testing" + + "github.com/go-gost/core/auth" + xctx "github.com/go-gost/x/ctx" + "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_Authenticate_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, "id": "user1"}`)) + })) + defer srv.Close() + + p := NewHTTPPlugin("test", srv.URL) + id, ok := p.Authenticate(context.Background(), "u", "p") + assert.True(t, ok) + assert.Equal(t, "user1", id) +} + +func TestHTTPPlugin_Authenticate_Fail(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"ok": false, "id": ""}`)) + })) + defer srv.Close() + + p := NewHTTPPlugin("test", srv.URL) + id, ok := p.Authenticate(context.Background(), "u", "p") + assert.False(t, ok) + assert.Empty(t, id) +} + +func TestHTTPPlugin_Authenticate_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) + id, ok := p.Authenticate(context.Background(), "u", "p") + assert.False(t, ok) + assert.Empty(t, id) +} + +func TestHTTPPlugin_Authenticate_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) + id, ok := p.Authenticate(context.Background(), "u", "p") + assert.False(t, ok) + assert.Empty(t, id) +} + +func TestHTTPPlugin_Authenticate_ConnectionRefused(t *testing.T) { + p := NewHTTPPlugin("test", "http://127.0.0.1:0", plugin.TimeoutOption(0)) + id, ok := p.Authenticate(context.Background(), "u", "p") + assert.False(t, ok) + assert.Empty(t, id) +} + +func TestHTTPPlugin_Authenticate_NilClient(t *testing.T) { + hp := &httpPlugin{ + url: "http://localhost", + client: nil, + } + id, ok := hp.Authenticate(context.Background(), "u", "p") + assert.False(t, ok) + assert.Empty(t, id) +} + +func TestHTTPPlugin_Authenticate_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, "id": "svc"}`)) + })) + defer srv.Close() + + p := NewHTTPPlugin("test", srv.URL) + id, ok := p.Authenticate(context.Background(), "u", "p", auth.WithService("myservice")) + assert.True(t, ok) + assert.Equal(t, "svc", id) +} + +func TestHTTPPlugin_Authenticate_WithHeader(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, "id": "hdr"}`)) + })) + defer srv.Close() + + p := NewHTTPPlugin("test", srv.URL, plugin.HeaderOption(http.Header{ + "X-Custom": []string{"test-value"}, + })) + id, ok := p.Authenticate(context.Background(), "u", "p") + assert.True(t, ok) + assert.Equal(t, "hdr", id) + assert.Equal(t, "test-value", receivedHeader) +} + +func TestHTTPPlugin_Authenticate_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, "id": "nohdr"}`)) + })) + defer srv.Close() + + hp := &httpPlugin{ + url: srv.URL, + client: plugin.NewHTTPClient(&plugin.Options{}), + header: nil, + } + id, ok := hp.Authenticate(context.Background(), "u", "p") + assert.True(t, ok) + assert.Equal(t, "nohdr", id) +} + +func TestHTTPPlugin_Authenticate_WithClientAddr(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"ok": true, "id": "addr"}`)) + })) + defer srv.Close() + + hp := &httpPlugin{ + url: srv.URL, + client: plugin.NewHTTPClient(&plugin.Options{}), + } + ctx := xctx.ContextWithSrcAddr(context.Background(), &net.TCPAddr{IP: net.ParseIP("10.0.0.1"), Port: 5678}) + id, ok := hp.Authenticate(ctx, "u", "p") + assert.True(t, ok) + assert.Equal(t, "addr", id) +} + +func TestHTTPPlugin_Authenticate_WithoutClientAddr(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"ok": true, "id": "noaddr"}`)) + })) + defer srv.Close() + + hp := &httpPlugin{ + url: srv.URL, + client: plugin.NewHTTPClient(&plugin.Options{}), + } + id, ok := hp.Authenticate(context.Background(), "u", "p") + assert.True(t, ok) + assert.Equal(t, "noaddr", id) +} + +func TestHTTPPlugin_Authenticate_BadURL(t *testing.T) { + hp := &httpPlugin{ + url: "http://invalid hostname", + client: plugin.NewHTTPClient(&plugin.Options{}), + } + id, ok := hp.Authenticate(context.Background(), "u", "p") + assert.False(t, ok) + assert.Empty(t, id) +} + +func TestHTTPPlugin_Authenticate_WithAllOptions(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "Bearer mytoken", r.Header.Get("Authorization")) + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"ok": true, "id": "all"}`)) + })) + defer srv.Close() + + hp := &httpPlugin{ + url: srv.URL, + client: plugin.NewHTTPClient(&plugin.Options{}), + header: http.Header{"Authorization": []string{"Bearer mytoken"}}, + } + id, ok := hp.Authenticate(context.Background(), "u", "p") + assert.True(t, ok) + assert.Equal(t, "all", id) +}