diff options
Diffstat (limited to 'hotline/client_conn_test.go')
| -rw-r--r-- | hotline/client_conn_test.go | 505 |
1 files changed, 505 insertions, 0 deletions
diff --git a/hotline/client_conn_test.go b/hotline/client_conn_test.go index e5acf82..94e38e9 100644 --- a/hotline/client_conn_test.go +++ b/hotline/client_conn_test.go @@ -1 +1,506 @@ package hotline + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/crypto/bcrypt" +) + +type mockAccountMgr struct { + accounts map[string]*Account +} + +func (m *mockAccountMgr) Create(account Account) error { return nil } +func (m *mockAccountMgr) Update(account Account, newLogin string) error { return nil } +func (m *mockAccountMgr) Get(login string) *Account { return m.accounts[login] } +func (m *mockAccountMgr) List() []Account { return nil } +func (m *mockAccountMgr) Delete(login string) error { return nil } + +func TestClientConn_IP(t *testing.T) { + tests := []struct { + name string + remoteAddr string + wantIP string + }{ + { + name: "extracts IP from host:port", + remoteAddr: "192.168.1.1:12345", + wantIP: "192.168.1.1", + }, + { + name: "extracts IPv6 address", + remoteAddr: "[::1]:12345", + wantIP: "::1", + }, + { + name: "returns empty string for missing port", + remoteAddr: "192.168.1.1", + wantIP: "", + }, + { + name: "returns empty string for empty RemoteAddr", + remoteAddr: "", + wantIP: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cc := &ClientConn{RemoteAddr: tt.remoteAddr} + assert.Equal(t, tt.wantIP, cc.IP()) + }) + } +} + +func TestClientConn_FileRoot(t *testing.T) { + tests := []struct { + name string + accountFileRoot string + serverFileRoot string + want string + }{ + { + name: "returns account FileRoot when set", + accountFileRoot: "/home/user/files", + serverFileRoot: "/srv/files", + want: "/home/user/files", + }, + { + name: "falls back to server Config FileRoot", + accountFileRoot: "", + serverFileRoot: "/srv/files", + want: "/srv/files", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cc := &ClientConn{ + Account: &Account{FileRoot: tt.accountFileRoot}, + Server: &Server{Config: Config{FileRoot: tt.serverFileRoot}}, + } + assert.Equal(t, tt.want, cc.FileRoot()) + }) + } +} + +func TestClientConn_Authenticate(t *testing.T) { + password := []byte("secret123") + hash, err := bcrypt.GenerateFromPassword(password, bcrypt.MinCost) + require.NoError(t, err) + + mgr := &mockAccountMgr{ + accounts: map[string]*Account{ + "admin": {Login: "admin", Password: string(hash)}, + }, + } + + tests := []struct { + name string + login string + password []byte + want bool + }{ + { + name: "valid credentials return true", + login: "admin", + password: password, + want: true, + }, + { + name: "wrong password returns false", + login: "admin", + password: []byte("wrongpassword"), + want: false, + }, + { + name: "nonexistent login returns false", + login: "nobody", + password: password, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cc := &ClientConn{ + Server: &Server{AccountManager: mgr}, + } + assert.Equal(t, tt.want, cc.Authenticate(tt.login, tt.password)) + }) + } +} + +func TestClientConn_Authorize(t *testing.T) { + tests := []struct { + name string + account *Account + access int + want bool + }{ + { + name: "returns false when Account is nil", + account: nil, + access: AccessDeleteFile, + want: false, + }, + { + name: "returns true when access bit is set", + account: func() *Account { + a := &Account{} + a.Access.Set(AccessUploadFile) + return a + }(), + access: AccessUploadFile, + want: true, + }, + { + name: "returns false when access bit is not set", + account: &Account{}, + access: AccessDownloadFile, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cc := &ClientConn{Account: tt.account} + assert.Equal(t, tt.want, cc.Authorize(tt.access)) + }) + } +} + +func TestClientConn_NewReply(t *testing.T) { + tests := []struct { + name string + clientID ClientID + tranID [4]byte + fields []Field + }{ + { + name: "reply with no fields", + clientID: ClientID{0x00, 0x01}, + tranID: [4]byte{0x00, 0x00, 0x00, 0x05}, + fields: nil, + }, + { + name: "reply with fields", + clientID: ClientID{0x00, 0x02}, + tranID: [4]byte{0x00, 0x00, 0x00, 0x0A}, + fields: []Field{NewField(FieldError, []byte("test"))}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cc := &ClientConn{ID: tt.clientID} + tran := &Transaction{ID: tt.tranID} + + reply := cc.NewReply(tran, tt.fields...) + + assert.Equal(t, byte(1), reply.IsReply) + assert.Equal(t, tt.tranID, reply.ID) + assert.Equal(t, tt.clientID, reply.ClientID) + assert.Equal(t, tt.fields, reply.Fields) + }) + } +} + +func TestClientConn_NewErrReply(t *testing.T) { + tests := []struct { + name string + clientID ClientID + tranID [4]byte + errMsg string + }{ + { + name: "returns error transaction", + clientID: ClientID{0x00, 0x01}, + tranID: [4]byte{0x00, 0x00, 0x00, 0x01}, + errMsg: "access denied", + }, + { + name: "returns error with empty message", + clientID: ClientID{0x00, 0x03}, + tranID: [4]byte{0x00, 0x00, 0x00, 0x02}, + errMsg: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cc := &ClientConn{ID: tt.clientID} + tran := &Transaction{ID: tt.tranID} + + result := cc.NewErrReply(tran, tt.errMsg) + + require.Len(t, result, 1) + errReply := result[0] + + assert.Equal(t, byte(1), errReply.IsReply) + assert.Equal(t, tt.tranID, errReply.ID) + assert.Equal(t, tt.clientID, errReply.ClientID) + assert.Equal(t, [4]byte{0, 0, 0, 1}, errReply.ErrorCode) + + require.Len(t, errReply.Fields, 1) + assert.Equal(t, FieldError, errReply.Fields[0].Type) + assert.Equal(t, []byte(tt.errMsg), errReply.Fields[0].Data) + }) + } +} + +func TestClientConn_NotifyOthers(t *testing.T) { + tests := []struct { + name string + selfID ClientID + otherConns []*ClientConn + wantCount int + wantClients []ClientID + }{ + { + name: "sends to other clients, excludes self", + selfID: ClientID{0x00, 0x01}, + otherConns: []*ClientConn{ + {ID: ClientID{0x00, 0x01}}, + {ID: ClientID{0x00, 0x02}}, + {ID: ClientID{0x00, 0x03}}, + }, + wantCount: 2, + wantClients: []ClientID{{0x00, 0x02}, {0x00, 0x03}}, + }, + { + name: "returns nil when only self is connected", + selfID: ClientID{0x00, 0x01}, + otherConns: []*ClientConn{ + {ID: ClientID{0x00, 0x01}}, + }, + wantCount: 0, + wantClients: nil, + }, + { + name: "returns nil when no clients are connected", + selfID: ClientID{0x00, 0x01}, + otherConns: []*ClientConn{}, + wantCount: 0, + wantClients: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockMgr := &MockClientMgr{} + mockMgr.On("List").Return(tt.otherConns) + + cc := &ClientConn{ + ID: tt.selfID, + Server: &Server{ClientMgr: mockMgr}, + } + + tran := NewTransaction(TranChatSend, ClientID{}, NewField(FieldError, []byte("test"))) + result := cc.NotifyOthers(tran) + + assert.Len(t, result, tt.wantCount) + + if tt.wantClients != nil { + var gotIDs []ClientID + for _, r := range result { + gotIDs = append(gotIDs, r.ClientID) + } + assert.Equal(t, tt.wantClients, gotIDs) + } + + mockMgr.AssertExpectations(t) + }) + } +} + +func TestClientConn_String(t *testing.T) { + cc := &ClientConn{ + UserName: []byte("TestUser"), + RemoteAddr: "192.168.1.10:54321", + Account: &Account{Name: "Test Account", Login: "testlogin"}, + ClientFileTransferMgr: NewClientFileTransferMgr(), + } + + result := cc.String() + + // The method replaces \n with \r + assert.Contains(t, result, "TestUser") + assert.Contains(t, result, "Test Account") + assert.Contains(t, result, "testlogin") + assert.Contains(t, result, "192.168.1.10:54321") + assert.Contains(t, result, "None.") + assert.NotContains(t, result, "\n", "all newlines should be replaced with carriage returns") + assert.Contains(t, result, "\r") +} + +func TestClientConn_Disconnect(t *testing.T) { + t.Run("removes client and closes connection", func(t *testing.T) { + mockMgr := &MockClientMgr{} + mockMgr.On("Delete", ClientID{0, 1}).Return() + mockMgr.On("List").Return([]*ClientConn{}) + + outbox := make(chan Transaction, 10) + cc := &ClientConn{ + ID: ClientID{0, 1}, + Connection: &nopCloserRWC{Buffer: &bytes.Buffer{}}, + Server: &Server{ + ClientMgr: mockMgr, + outbox: outbox, + Logger: NewTestLogger(), + }, + } + + cc.Disconnect() + + mockMgr.AssertCalled(t, "Delete", ClientID{0, 1}) + assert.Empty(t, outbox) // No other clients to notify + }) + + t.Run("notifies other clients", func(t *testing.T) { + mockMgr := &MockClientMgr{} + mockMgr.On("Delete", ClientID{0, 1}).Return() + mockMgr.On("List").Return([]*ClientConn{ + {ID: ClientID{0, 1}}, + {ID: ClientID{0, 2}}, + {ID: ClientID{0, 3}}, + }) + + outbox := make(chan Transaction, 10) + cc := &ClientConn{ + ID: ClientID{0, 1}, + Connection: &nopCloserRWC{Buffer: &bytes.Buffer{}}, + Server: &Server{ + ClientMgr: mockMgr, + outbox: outbox, + Logger: NewTestLogger(), + }, + } + + cc.Disconnect() + + assert.Len(t, outbox, 2) + mockMgr.AssertExpectations(t) + }) +} + +func TestClientConn_handleTransaction(t *testing.T) { + t.Run("dispatches to registered handler", func(t *testing.T) { + outbox := make(chan Transaction, 10) + mockMgr := &MockClientMgr{} + + cc := &ClientConn{ + ID: ClientID{0, 1}, + Account: &Account{}, + Logger: NewTestLogger(), + Server: &Server{ + outbox: outbox, + ClientMgr: mockMgr, + handlers: map[TranType]HandlerFunc{ + TranChatSend: func(cc *ClientConn, t *Transaction) []Transaction { + return []Transaction{cc.NewReply(t)} + }, + }, + }, + } + + cc.handleTransaction(NewTransaction(TranChatSend, ClientID{0, 1})) + + assert.Len(t, outbox, 1) + assert.Equal(t, 0, cc.IdleTime) + }) + + t.Run("keepalive does not reset idle time", func(t *testing.T) { + outbox := make(chan Transaction, 10) + + cc := &ClientConn{ + ID: ClientID{0, 1}, + Account: &Account{}, + IdleTime: 100, + Logger: NewTestLogger(), + Server: &Server{ + outbox: outbox, + handlers: map[TranType]HandlerFunc{ + TranKeepAlive: func(cc *ClientConn, t *Transaction) []Transaction { + return []Transaction{cc.NewReply(t)} + }, + }, + }, + } + + cc.handleTransaction(NewTransaction(TranKeepAlive, ClientID{0, 1})) + + assert.Equal(t, 100, cc.IdleTime) + }) + + t.Run("non-keepalive clears away flag", func(t *testing.T) { + outbox := make(chan Transaction, 10) + mockMgr := &MockClientMgr{} + mockMgr.On("List").Return([]*ClientConn{ + {ID: ClientID{0, 1}}, + }) + + cc := &ClientConn{ + ID: ClientID{0, 1}, + Account: &Account{}, + UserName: []byte("test"), + Icon: []byte{0, 0}, + IdleTime: 50, + Logger: NewTestLogger(), + Server: &Server{ + outbox: outbox, + ClientMgr: mockMgr, + handlers: map[TranType]HandlerFunc{ + TranChatSend: func(cc *ClientConn, t *Transaction) []Transaction { + return nil + }, + }, + }, + } + cc.Flags.Set(UserFlagAway, 1) + + cc.handleTransaction(NewTransaction(TranChatSend, ClientID{0, 1})) + + assert.Equal(t, 0, cc.IdleTime) + assert.False(t, cc.Flags.IsSet(UserFlagAway)) + // SendAll should have sent TranNotifyChangeUser + assert.Greater(t, len(outbox), 0) + }) +} + +func TestClientConn_SendAll(t *testing.T) { + mockMgr := &MockClientMgr{} + mockMgr.On("List").Return([]*ClientConn{ + {ID: ClientID{0, 1}}, + {ID: ClientID{0, 2}}, + {ID: ClientID{0, 3}}, + }) + + outbox := make(chan Transaction, 10) + cc := &ClientConn{ + ID: ClientID{0, 1}, + Server: &Server{ + ClientMgr: mockMgr, + outbox: outbox, + }, + } + + cc.SendAll(TranChatMsg, NewField(FieldData, []byte("hello"))) + + assert.Len(t, outbox, 3) + + clientIDs := make(map[ClientID]bool) + for range 3 { + tran := <-outbox + clientIDs[tran.ClientID] = true + assert.Equal(t, TranChatMsg, tran.Type) + } + assert.True(t, clientIDs[ClientID{0, 1}]) + assert.True(t, clientIDs[ClientID{0, 2}]) + assert.True(t, clientIDs[ClientID{0, 3}]) + + mockMgr.AssertExpectations(t) +} |