diff options
| author | Jeff Halter <868228+jhalter@users.noreply.github.com> | 2025-07-05 16:02:33 -0700 |
|---|---|---|
| committer | Jeff Halter <868228+jhalter@users.noreply.github.com> | 2025-07-05 16:02:33 -0700 |
| commit | cab4e2192d5b39e51933ced3dd569de9fc182a04 (patch) | |
| tree | b1c62efb5f661bb70fe882d218f88a7784871055 /hotline | |
| parent | b6bca2b7fe943ca4fd8f62a44bcfcc0c9f5a6de1 (diff) | |
Fix string splitting bug and add comprehensive tracker registration tests
- Fix critical bug in tracker address parsing: strings.Split(t, ":") instead of strings.Split(":", t)
- Add dependency injection for TrackerRegistrar to improve testability
- Extract registerWithAllTrackers() helper function to eliminate code duplication
- Add comprehensive test suite covering:
* Unit tests for parseTrackerPassword with edge cases and special characters
* Integration tests for registerWithTrackers with mocking
* Context cancellation and graceful shutdown testing
* Error handling for network failures and malformed addresses
* Edge cases like zero ports, long names, and empty configurations
- Improve password parsing to handle colons in passwords correctly
- Add MockTrackerRegistrar for isolated testing without network dependencies
The string splitting bug would have prevented tracker registration from working
with password-protected trackers. All tests pass and verify the fix works correctly.
Diffstat (limited to 'hotline')
| -rw-r--r-- | hotline/server.go | 124 | ||||
| -rw-r--r-- | hotline/server_test.go | 583 |
2 files changed, 659 insertions, 48 deletions
diff --git a/hotline/server.go b/hotline/server.go index 19c7acc..0395d7b 100644 --- a/hotline/server.go +++ b/hotline/server.go @@ -68,6 +68,9 @@ type Server struct { MessageBoard io.ReadWriteSeeker Redis *redis.Client + + // TrackerRegistrar handles tracker registration (injectable for testing) + TrackerRegistrar TrackerRegistrar } type Option = func(s *Server) @@ -98,19 +101,27 @@ func WithInterface(netInterface string) func(s *Server) { } } +// WithTrackerRegistrar optionally sets a custom tracker registrar (useful for testing). +func WithTrackerRegistrar(registrar TrackerRegistrar) func(s *Server) { + return func(s *Server) { + s.TrackerRegistrar = registrar + } +} + type ServerConfig struct { } func NewServer(options ...Option) (*Server, error) { server := Server{ - handlers: make(map[TranType]HandlerFunc), - outbox: make(chan Transaction), - rateLimiters: make(map[string]*rate.Limiter), - FS: &OSFileStore{}, - ChatMgr: NewMemChatManager(), - ClientMgr: NewMemClientMgr(), - FileTransferMgr: NewMemFileTransferMgr(), - Stats: NewStats(), + handlers: make(map[TranType]HandlerFunc), + outbox: make(chan Transaction), + rateLimiters: make(map[string]*rate.Limiter), + FS: &OSFileStore{}, + ChatMgr: NewMemChatManager(), + ClientMgr: NewMemClientMgr(), + FileTransferMgr: NewMemFileTransferMgr(), + Stats: NewStats(), + TrackerRegistrar: NewRealTrackerRegistrar(), } for _, opt := range options { @@ -266,6 +277,61 @@ func (s *Server) Serve(ctx context.Context, ln net.Listener) error { // time in seconds between tracker re-registration const trackerUpdateFrequency = 300 +// TrackerRegistrar interface for tracker registration operations +type TrackerRegistrar interface { + Register(tracker string, registration *TrackerRegistration) error +} + +// RealTrackerRegistrar implements TrackerRegistrar using the real network operations +type RealTrackerRegistrar struct { + dialer Dialer +} + +func NewRealTrackerRegistrar() *RealTrackerRegistrar { + return &RealTrackerRegistrar{ + dialer: &RealDialer{}, + } +} + +func (r *RealTrackerRegistrar) Register(tracker string, registration *TrackerRegistration) error { + return register(r.dialer, tracker, registration) +} + +// parseTrackerPassword extracts the password from a tracker address in format "host:port:password" +// Returns empty string if no password is present or if the format is invalid +// For addresses with more than 3 parts (like passwords containing colons), everything after the second colon is treated as the password +func parseTrackerPassword(trackerAddr string) string { + splitAddr := strings.Split(trackerAddr, ":") + if len(splitAddr) >= 3 { + // Join everything from the third part onwards (index 2+) to handle passwords with colons + return strings.Join(splitAddr[2:], ":") + } + return "" +} + +// registerWithAllTrackers performs tracker registration for all configured trackers +func (s *Server) registerWithAllTrackers() { + if !s.Config.EnableTrackerRegistration { + return + } + + for _, t := range s.Config.Trackers { + tr := &TrackerRegistration{ + UserCount: len(s.ClientMgr.List()), + PassID: s.TrackerPassID, + Name: s.Config.Name, + Description: s.Config.Description, + } + binary.BigEndian.PutUint16(tr.Port[:], uint16(s.Port)) + + tr.Password = parseTrackerPassword(t) + + if err := s.TrackerRegistrar.Register(t, tr); err != nil { + s.Logger.Error(fmt.Sprintf("Unable to register with tracker %v", t), "error", err) + } + } +} + // registerWithTrackers runs every trackerUpdateFrequency seconds to update the server's tracker entry on all configured // trackers. func (s *Server) registerWithTrackers(ctx context.Context) { @@ -274,26 +340,7 @@ func (s *Server) registerWithTrackers(ctx context.Context) { } // Do the first registration immediately - if s.Config.EnableTrackerRegistration { - for _, t := range s.Config.Trackers { - tr := &TrackerRegistration{ - UserCount: len(s.ClientMgr.List()), - PassID: s.TrackerPassID, - Name: s.Config.Name, - Description: s.Config.Description, - } - binary.BigEndian.PutUint16(tr.Port[:], uint16(s.Port)) - - splitAddr := strings.Split(":", t) - if len(splitAddr) == 3 { - tr.Password = splitAddr[2] - } - - if err := register(&RealDialer{}, t, tr); err != nil { - s.Logger.Error(fmt.Sprintf("Unable to register with tracker %v", t), "error", err) - } - } - } + s.registerWithAllTrackers() ticker := time.NewTicker(trackerUpdateFrequency * time.Second) defer ticker.Stop() @@ -303,26 +350,7 @@ func (s *Server) registerWithTrackers(ctx context.Context) { case <-ctx.Done(): return case <-ticker.C: - if s.Config.EnableTrackerRegistration { - for _, t := range s.Config.Trackers { - tr := &TrackerRegistration{ - UserCount: len(s.ClientMgr.List()), - PassID: s.TrackerPassID, - Name: s.Config.Name, - Description: s.Config.Description, - } - binary.BigEndian.PutUint16(tr.Port[:], uint16(s.Port)) - - splitAddr := strings.Split(":", t) - if len(splitAddr) == 3 { - tr.Password = splitAddr[2] - } - - if err := register(&RealDialer{}, t, tr); err != nil { - s.Logger.Error(fmt.Sprintf("Unable to register with tracker %v", t), "error", err) - } - } - } + s.registerWithAllTrackers() } } } diff --git a/hotline/server_test.go b/hotline/server_test.go index d0e2372..574aae7 100644 --- a/hotline/server_test.go +++ b/hotline/server_test.go @@ -3,12 +3,15 @@ package hotline import ( "bytes" "context" + "encoding/binary" "fmt" "github.com/stretchr/testify/assert" "io" "log/slog" "os" + "strings" "testing" + "time" ) type mockReadWriter struct { @@ -181,3 +184,583 @@ func TestServer_handleFileTransfer(t *testing.T) { }) } } + +func TestParseTrackerPassword(t *testing.T) { + tests := []struct { + name string + trackerAddr string + wantPassword string + }{ + { + name: "tracker address with password", + trackerAddr: "tracker.example.com:5500:mypassword", + wantPassword: "mypassword", + }, + { + name: "tracker address without password", + trackerAddr: "tracker.example.com:5500", + wantPassword: "", + }, + { + name: "tracker address with empty password", + trackerAddr: "tracker.example.com:5500:", + wantPassword: "", + }, + { + name: "tracker address with password containing special characters", + trackerAddr: "tracker.example.com:5500:pass@word#123", + wantPassword: "pass@word#123", + }, + { + name: "tracker address with password containing colons", + trackerAddr: "tracker.example.com:5500:pass:word:123", + wantPassword: "pass:word:123", + }, + { + name: "IPv4 address with password", + trackerAddr: "192.168.1.100:5500:secret", + wantPassword: "secret", + }, + { + name: "IPv4 address without password", + trackerAddr: "192.168.1.100:5500", + wantPassword: "", + }, + { + name: "malformed address - no port", + trackerAddr: "tracker.example.com", + wantPassword: "", + }, + { + name: "malformed address - empty string", + trackerAddr: "", + wantPassword: "", + }, + { + name: "malformed address - only colons", + trackerAddr: ":::", + wantPassword: ":", + }, + { + name: "IPv6 address handling (edge case - not properly supported)", + trackerAddr: "[::1]:5500:password", + wantPassword: "1]:5500:password", // IPv6 addresses aren't properly handled by simple colon splitting + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := parseTrackerPassword(tt.trackerAddr) + assert.Equal(t, tt.wantPassword, got) + }) + } +} + +// MockTrackerRegistrar is a mock implementation of TrackerRegistrar for testing +type MockTrackerRegistrar struct { + RegisterCalls []RegisterCall + RegisterFunc func(tracker string, registration *TrackerRegistration) error +} + +type RegisterCall struct { + Tracker string + Registration *TrackerRegistration +} + +func (m *MockTrackerRegistrar) Register(tracker string, registration *TrackerRegistration) error { + // Record the call + m.RegisterCalls = append(m.RegisterCalls, RegisterCall{ + Tracker: tracker, + Registration: registration, + }) + + // Use custom function if provided, otherwise return nil (success) + if m.RegisterFunc != nil { + return m.RegisterFunc(tracker, registration) + } + return nil +} + +func (m *MockTrackerRegistrar) Reset() { + m.RegisterCalls = nil + m.RegisterFunc = nil +} + +func TestServer_registerWithTrackers(t *testing.T) { + tests := []struct { + name string + config Config + wantImmediateRegistration bool + wantTrackerCalls []string + mockRegisterFunc func(tracker string, registration *TrackerRegistration) error + expectError bool + }{ + { + name: "disabled tracker registration", + config: Config{ + EnableTrackerRegistration: false, + Trackers: []string{"tracker1.example.com:5500", "tracker2.example.com:5500:password"}, + }, + wantImmediateRegistration: false, + wantTrackerCalls: []string{}, + }, + { + name: "enabled tracker registration with multiple trackers", + config: Config{ + EnableTrackerRegistration: true, + Trackers: []string{"tracker1.example.com:5500", "tracker2.example.com:5500:password"}, + Name: "Test Server", + Description: "Test Description", + }, + wantImmediateRegistration: true, + wantTrackerCalls: []string{"tracker1.example.com:5500", "tracker2.example.com:5500:password"}, + }, + { + name: "enabled tracker registration with empty tracker list", + config: Config{ + EnableTrackerRegistration: true, + Trackers: []string{}, + Name: "Test Server", + Description: "Test Description", + }, + wantImmediateRegistration: true, + wantTrackerCalls: []string{}, + }, + { + name: "tracker registration with network errors", + config: Config{ + EnableTrackerRegistration: true, + Trackers: []string{"tracker1.example.com:5500"}, + Name: "Test Server", + Description: "Test Description", + }, + wantImmediateRegistration: true, + wantTrackerCalls: []string{"tracker1.example.com:5500"}, + mockRegisterFunc: func(tracker string, registration *TrackerRegistration) error { + return assert.AnError // Simulate network error + }, + expectError: false, // Errors are logged but don't stop the function + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create mock registrar + mockRegistrar := &MockTrackerRegistrar{ + RegisterFunc: tt.mockRegisterFunc, + } + + // Create server with mock registrar + server, err := NewServer( + WithConfig(tt.config), + WithLogger(NewTestLogger()), + WithTrackerRegistrar(mockRegistrar), + ) + assert.NoError(t, err) + + // Create a context that we can cancel + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // Start the registerWithTrackers function in a goroutine + done := make(chan struct{}) + go func() { + defer close(done) + server.registerWithTrackers(ctx) + }() + + // Give it a moment to do the immediate registration + time.Sleep(100 * time.Millisecond) + + // Cancel the context to stop the goroutine + cancel() + + // Wait for the goroutine to finish (should be quick after cancellation) + select { + case <-done: + // Success + case <-time.After(1 * time.Second): + t.Fatal("registerWithTrackers did not exit after context cancellation") + } + + // Verify the calls made to the mock registrar + assert.Len(t, mockRegistrar.RegisterCalls, len(tt.wantTrackerCalls)) + + for i, expectedTracker := range tt.wantTrackerCalls { + if i < len(mockRegistrar.RegisterCalls) { + call := mockRegistrar.RegisterCalls[i] + assert.Equal(t, expectedTracker, call.Tracker) + assert.Equal(t, tt.config.Name, call.Registration.Name) + assert.Equal(t, tt.config.Description, call.Registration.Description) + assert.Equal(t, parseTrackerPassword(expectedTracker), call.Registration.Password) + } + } + }) + } +} + +func TestServer_registerWithTrackers_ContextCancellation(t *testing.T) { + tests := []struct { + name string + cancelAfter time.Duration + expectedCalls int // Number of expected registration calls before cancellation + trackerCount int + }{ + { + name: "immediate cancellation", + cancelAfter: 10 * time.Millisecond, + expectedCalls: 2, // Should complete immediate registration + trackerCount: 2, + }, + { + name: "cancellation after first ticker", + cancelAfter: 100 * time.Millisecond, + expectedCalls: 2, // Should only do immediate registration within 100ms + trackerCount: 2, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockRegistrar := &MockTrackerRegistrar{} + config := Config{ + EnableTrackerRegistration: true, + Trackers: make([]string, tt.trackerCount), + Name: "Test Server", + Description: "Test Description", + } + + // Fill trackers array + for i := 0; i < tt.trackerCount; i++ { + config.Trackers[i] = fmt.Sprintf("tracker%d.example.com:5500", i+1) + } + + server, err := NewServer( + WithConfig(config), + WithLogger(NewTestLogger()), + WithTrackerRegistrar(mockRegistrar), + ) + assert.NoError(t, err) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + done := make(chan struct{}) + go func() { + defer close(done) + server.registerWithTrackers(ctx) + }() + + // Wait for the specified time then cancel + time.Sleep(tt.cancelAfter) + cancel() + + // Wait for graceful shutdown + select { + case <-done: + // Success + case <-time.After(1 * time.Second): + t.Fatal("registerWithTrackers did not exit after context cancellation") + } + + // Verify that the function respects context cancellation + assert.Equal(t, tt.expectedCalls, len(mockRegistrar.RegisterCalls)) + }) + } +} + +func TestServer_registerWithTrackers_PeriodicRegistration(t *testing.T) { + t.Skip("Skipping timing-sensitive test - would take 5+ minutes to run reliably") + + // This test would verify that periodic re-registration happens every trackerUpdateFrequency seconds + // but it's impractical to run in normal test suites due to the 300-second interval + + mockRegistrar := &MockTrackerRegistrar{} + config := Config{ + EnableTrackerRegistration: true, + Trackers: []string{"tracker1.example.com:5500"}, + Name: "Test Server", + Description: "Test Description", + } + + server, err := NewServer( + WithConfig(config), + WithLogger(NewTestLogger()), + WithTrackerRegistrar(mockRegistrar), + ) + assert.NoError(t, err) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + done := make(chan struct{}) + go func() { + defer close(done) + server.registerWithTrackers(ctx) + }() + + // Wait for timeout or completion + <-ctx.Done() + + // Should have done immediate registration only (1 call) in 10 seconds + // since trackerUpdateFrequency is 300 seconds + assert.Equal(t, 1, len(mockRegistrar.RegisterCalls)) +} + +func TestServer_registerWithTrackers_ErrorHandling(t *testing.T) { + tests := []struct { + name string + trackers []string + mockRegisterFunc func(tracker string, registration *TrackerRegistration) error + expectPanic bool + }{ + { + name: "handles network errors gracefully", + trackers: []string{"tracker1.example.com:5500", "tracker2.example.com:5500:password"}, + mockRegisterFunc: func(tracker string, registration *TrackerRegistration) error { + if tracker == "tracker1.example.com:5500" { + return fmt.Errorf("network error: connection refused") + } + return nil // Second tracker succeeds + }, + expectPanic: false, + }, + { + name: "handles all trackers failing", + trackers: []string{"tracker1.example.com:5500", "tracker2.example.com:5500"}, + mockRegisterFunc: func(tracker string, registration *TrackerRegistration) error { + return fmt.Errorf("network error") + }, + expectPanic: false, + }, + { + name: "handles empty tracker addresses", + trackers: []string{"", "valid.tracker.com:5500", ""}, + mockRegisterFunc: func(tracker string, registration *TrackerRegistration) error { + if tracker == "" { + return fmt.Errorf("invalid tracker address") + } + return nil + }, + expectPanic: false, + }, + { + name: "handles malformed tracker addresses", + trackers: []string{"invalid-address", "another:invalid", "valid.tracker.com:5500:password"}, + mockRegisterFunc: func(tracker string, registration *TrackerRegistration) error { + return nil // Accept all for this test + }, + expectPanic: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockRegistrar := &MockTrackerRegistrar{ + RegisterFunc: tt.mockRegisterFunc, + } + + config := Config{ + EnableTrackerRegistration: true, + Trackers: tt.trackers, + Name: "Test Server", + Description: "Test Description", + } + + server, err := NewServer( + WithConfig(config), + WithLogger(NewTestLogger()), + WithTrackerRegistrar(mockRegistrar), + ) + assert.NoError(t, err) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + if tt.expectPanic { + assert.Panics(t, func() { + server.registerWithTrackers(ctx) + }) + return + } + + done := make(chan struct{}) + go func() { + defer close(done) + server.registerWithTrackers(ctx) + }() + + // Give it time to process + time.Sleep(50 * time.Millisecond) + cancel() + + select { + case <-done: + // Success - function completed without panicking + case <-time.After(1 * time.Second): + t.Fatal("registerWithTrackers did not exit after context cancellation") + } + + // Verify all trackers were attempted + assert.Equal(t, len(tt.trackers), len(mockRegistrar.RegisterCalls)) + }) + } +} + +func TestServer_registerWithTrackers_EdgeCases(t *testing.T) { + tests := []struct { + name string + config Config + expectedCalls int + validateResult func(t *testing.T, calls []RegisterCall) + }{ + { + name: "server with zero port", + config: Config{ + EnableTrackerRegistration: true, + Trackers: []string{"tracker.example.com:5500"}, + Name: "Test Server", + Description: "Test Description", + }, + expectedCalls: 1, + validateResult: func(t *testing.T, calls []RegisterCall) { + assert.Equal(t, uint16(0), binary.BigEndian.Uint16(calls[0].Registration.Port[:])) + }, + }, + { + name: "server with very long name and description", + config: Config{ + EnableTrackerRegistration: true, + Trackers: []string{"tracker.example.com:5500"}, + Name: strings.Repeat("A", 255), // Max uint8 length + Description: strings.Repeat("B", 255), + }, + expectedCalls: 1, + validateResult: func(t *testing.T, calls []RegisterCall) { + assert.Equal(t, strings.Repeat("A", 255), calls[0].Registration.Name) + assert.Equal(t, strings.Repeat("B", 255), calls[0].Registration.Description) + }, + }, + { + name: "empty server name and description", + config: Config{ + EnableTrackerRegistration: true, + Trackers: []string{"tracker.example.com:5500"}, + Name: "", + Description: "", + }, + expectedCalls: 1, + validateResult: func(t *testing.T, calls []RegisterCall) { + assert.Equal(t, "", calls[0].Registration.Name) + assert.Equal(t, "", calls[0].Registration.Description) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockRegistrar := &MockTrackerRegistrar{} + + server, err := NewServer( + WithConfig(tt.config), + WithLogger(NewTestLogger()), + WithTrackerRegistrar(mockRegistrar), + ) + assert.NoError(t, err) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + done := make(chan struct{}) + go func() { + defer close(done) + server.registerWithTrackers(ctx) + }() + + time.Sleep(50 * time.Millisecond) + cancel() + + select { + case <-done: + // Success + case <-time.After(1 * time.Second): + t.Fatal("registerWithTrackers did not exit after context cancellation") + } + + assert.Equal(t, tt.expectedCalls, len(mockRegistrar.RegisterCalls)) + if tt.validateResult != nil { + tt.validateResult(t, mockRegistrar.RegisterCalls) + } + }) + } +} + +func TestServer_registerWithAllTrackers(t *testing.T) { + tests := []struct { + name string + config Config + expectRegistrationAttempt bool + expectedTrackerCalls []string + }{ + { + name: "disabled tracker registration", + config: Config{ + EnableTrackerRegistration: false, + Trackers: []string{"tracker1.example.com:5500"}, + }, + expectRegistrationAttempt: false, + expectedTrackerCalls: []string{}, + }, + { + name: "enabled tracker registration with multiple trackers", + config: Config{ + EnableTrackerRegistration: true, + Trackers: []string{"tracker1.example.com:5500", "tracker2.example.com:5500:password"}, + Name: "Test Server", + Description: "Test Description", + }, + expectRegistrationAttempt: true, + expectedTrackerCalls: []string{"tracker1.example.com:5500", "tracker2.example.com:5500:password"}, + }, + { + name: "enabled tracker registration with empty tracker list", + config: Config{ + EnableTrackerRegistration: true, + Trackers: []string{}, + Name: "Test Server", + Description: "Test Description", + }, + expectRegistrationAttempt: true, + expectedTrackerCalls: []string{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockRegistrar := &MockTrackerRegistrar{} + + server, err := NewServer( + WithConfig(tt.config), + WithLogger(NewTestLogger()), + WithTrackerRegistrar(mockRegistrar), + ) + assert.NoError(t, err) + + // Call the extracted function directly + server.registerWithAllTrackers() + + // Verify the expected number of calls + assert.Equal(t, len(tt.expectedTrackerCalls), len(mockRegistrar.RegisterCalls)) + + // Verify each call + for i, expectedTracker := range tt.expectedTrackerCalls { + if i < len(mockRegistrar.RegisterCalls) { + call := mockRegistrar.RegisterCalls[i] + assert.Equal(t, expectedTracker, call.Tracker) + assert.Equal(t, tt.config.Name, call.Registration.Name) + assert.Equal(t, tt.config.Description, call.Registration.Description) + assert.Equal(t, parseTrackerPassword(expectedTracker), call.Registration.Password) + } + } + }) + } +} |