From 2f46f87177234070044b5304ca3e0db471699ad8 Mon Sep 17 00:00:00 2001 From: Jeff Halter <868228+jhalter@users.noreply.github.com> Date: Mon, 16 Mar 2026 11:46:04 -0700 Subject: Improve test coverage for hotline and internal/mobius packages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add comprehensive test cases across both packages to increase coverage: - hotline: 52.4% → ~55% (Disconnect, handleTransaction, SendAll, sendBanMessage, MemClientMgr, and other tests) - internal/mobius: 75.9% → ~80% (HandleUpdateUser, HandleDeleteUser, HandleSetUser, HandleNewUser, HandleUserBroadcast success paths) --- hotline/account_test.go | 91 + hotline/ban.go | 70 +- hotline/client_conn_test.go | 505 ++++++ hotline/client_manager_test.go | 62 + hotline/client_test.go | 278 +++ hotline/file_wrapper_test.go | 171 ++ hotline/flattened_file_object_test.go | 142 ++ hotline/server_test.go | 110 ++ internal/mobius/agreement_test.go | 328 ++++ internal/mobius/api_test.go | 688 ++++++++ internal/mobius/threaded_news_test.go | 659 ++++--- internal/mobius/transaction_handlers_test.go | 2446 ++++++++++++++++++++------ 12 files changed, 4677 insertions(+), 873 deletions(-) create mode 100644 hotline/account_test.go create mode 100644 hotline/client_manager_test.go create mode 100644 hotline/client_test.go create mode 100644 hotline/file_wrapper_test.go create mode 100644 internal/mobius/agreement_test.go create mode 100644 internal/mobius/api_test.go diff --git a/hotline/account_test.go b/hotline/account_test.go new file mode 100644 index 0000000..43fe28f --- /dev/null +++ b/hotline/account_test.go @@ -0,0 +1,91 @@ +package hotline + +import ( + "encoding/binary" + "io" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/crypto/bcrypt" +) + +func TestNewAccount(t *testing.T) { + access := AccessBitmap{0xff, 0, 0, 0, 0, 0, 0, 0} + acct := NewAccount("jdoe", "John Doe", "secret123", access) + + assert.Equal(t, "jdoe", acct.Login) + assert.Equal(t, "John Doe", acct.Name) + assert.Equal(t, access, acct.Access) + + // Password should be a bcrypt hash, not the plaintext. + assert.NotEqual(t, "secret123", acct.Password) + err := bcrypt.CompareHashAndPassword([]byte(acct.Password), []byte("secret123")) + require.NoError(t, err, "password hash should validate against original plaintext") +} + +func TestHashAndSalt(t *testing.T) { + t.Run("produces valid bcrypt hash", func(t *testing.T) { + hash := HashAndSalt([]byte("password")) + err := bcrypt.CompareHashAndPassword([]byte(hash), []byte("password")) + require.NoError(t, err) + }) + + t.Run("different calls produce different hashes", func(t *testing.T) { + h1 := HashAndSalt([]byte("same")) + h2 := HashAndSalt([]byte("same")) + assert.NotEqual(t, h1, h2, "bcrypt salts should differ between calls") + + // Both should still verify against the original input. + require.NoError(t, bcrypt.CompareHashAndPassword([]byte(h1), []byte("same"))) + require.NoError(t, bcrypt.CompareHashAndPassword([]byte(h2), []byte("same"))) + }) +} + +func TestAccount_Read(t *testing.T) { + t.Run("with password set", func(t *testing.T) { + acct := NewAccount("admin", "Admin User", "pass", AccessBitmap{}) + + data, err := io.ReadAll(acct) + require.NoError(t, err) + + // First two bytes are the field count (big-endian uint16). + require.GreaterOrEqual(t, len(data), 2) + fieldCount := binary.BigEndian.Uint16(data[:2]) + assert.Equal(t, uint16(4), fieldCount, "should have 4 fields when password is set") + + // Verify the password marker "x" appears in the serialized data. + assert.Contains(t, string(data), "x") + + // Verify the user name appears in the serialized data. + assert.Contains(t, string(data), "Admin User") + }) + + t.Run("with empty password", func(t *testing.T) { + acct := &Account{ + Login: "guest", + Name: "Guest", + Password: HashAndSalt([]byte("")), + Access: AccessBitmap{}, + } + + data, err := io.ReadAll(acct) + require.NoError(t, err) + + require.GreaterOrEqual(t, len(data), 2) + fieldCount := binary.BigEndian.Uint16(data[:2]) + assert.Equal(t, uint16(3), fieldCount, "should have 3 fields when password is empty") + }) + + t.Run("full read returns all serialized bytes", func(t *testing.T) { + acct := NewAccount("test", "Test", "pw", AccessBitmap{0x01}) + + data, err := io.ReadAll(acct) + require.NoError(t, err) + assert.Greater(t, len(data), 2, "serialized output should contain field data beyond the count header") + }) +} + +func TestNewAccount_GuestConstant(t *testing.T) { + assert.Equal(t, "guest", GuestAccount) +} diff --git a/hotline/ban.go b/hotline/ban.go index cc2ecde..2713fbe 100644 --- a/hotline/ban.go +++ b/hotline/ban.go @@ -1,6 +1,10 @@ package hotline -import "time" +import ( + "time" + + "github.com/stretchr/testify/mock" +) // BanDuration is the length of time for temporary bans. const BanDuration = 30 * time.Minute @@ -36,3 +40,67 @@ type BanMgr interface { ListBannedUsernames() ([]string, error) ListBannedNicknames() ([]string, error) } + +type MockBanMgr struct { + mock.Mock +} + +func (m *MockBanMgr) Add(ip string, until *time.Time) error { + args := m.Called(ip, until) + return args.Error(0) +} + +func (m *MockBanMgr) IsBanned(ip string) (bool, *time.Time) { + args := m.Called(ip) + return args.Bool(0), args.Get(1).(*time.Time) +} + +func (m *MockBanMgr) UnbanIP(ip string) error { + args := m.Called(ip) + return args.Error(0) +} + +func (m *MockBanMgr) BanUsername(username string) error { + args := m.Called(username) + return args.Error(0) +} + +func (m *MockBanMgr) UnbanUsername(username string) error { + args := m.Called(username) + return args.Error(0) +} + +func (m *MockBanMgr) IsUsernameBanned(username string) bool { + args := m.Called(username) + return args.Bool(0) +} + +func (m *MockBanMgr) BanNickname(nickname string) error { + args := m.Called(nickname) + return args.Error(0) +} + +func (m *MockBanMgr) UnbanNickname(nickname string) error { + args := m.Called(nickname) + return args.Error(0) +} + +func (m *MockBanMgr) IsNicknameBanned(nickname string) bool { + args := m.Called(nickname) + return args.Bool(0) +} + +func (m *MockBanMgr) ListBannedIPs() ([]string, error) { + args := m.Called() + return args.Get(0).([]string), args.Error(1) +} + +func (m *MockBanMgr) ListBannedUsernames() ([]string, error) { + args := m.Called() + return args.Get(0).([]string), args.Error(1) +} + +func (m *MockBanMgr) ListBannedNicknames() ([]string, error) { + args := m.Called() + return args.Get(0).([]string), args.Error(1) +} 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) +} diff --git a/hotline/client_manager_test.go b/hotline/client_manager_test.go new file mode 100644 index 0000000..a2a0320 --- /dev/null +++ b/hotline/client_manager_test.go @@ -0,0 +1,62 @@ +package hotline + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestMemClientMgr_Add(t *testing.T) { + mgr := NewMemClientMgr() + + c1 := &ClientConn{} + c2 := &ClientConn{} + mgr.Add(c1) + mgr.Add(c2) + + assert.NotEqual(t, c1.ID, c2.ID) + assert.Len(t, mgr.List(), 2) +} + +func TestMemClientMgr_Get(t *testing.T) { + mgr := NewMemClientMgr() + + assert.Nil(t, mgr.Get(ClientID{0xFF, 0xFF})) + + c := &ClientConn{} + mgr.Add(c) + assert.Equal(t, c, mgr.Get(c.ID)) +} + +func TestMemClientMgr_Delete(t *testing.T) { + mgr := NewMemClientMgr() + + c := &ClientConn{} + mgr.Add(c) + id := c.ID + + mgr.Delete(id) + assert.Nil(t, mgr.Get(id)) +} + +func TestMemClientMgr_List_Sorted(t *testing.T) { + mgr := NewMemClientMgr() + + // Add 3 clients - they'll get sequential IDs + c1 := &ClientConn{} + c2 := &ClientConn{} + c3 := &ClientConn{} + mgr.Add(c1) + mgr.Add(c2) + mgr.Add(c3) + + list := mgr.List() + assert.Len(t, list, 3) + + // Verify sorted by ID + for i := 1; i < len(list); i++ { + assert.True(t, list[i-1].ID[0] < list[i].ID[0] || + (list[i-1].ID[0] == list[i].ID[0] && list[i-1].ID[1] <= list[i].ID[1]), + "clients should be sorted by ID") + } +} diff --git a/hotline/client_test.go b/hotline/client_test.go new file mode 100644 index 0000000..23b6155 --- /dev/null +++ b/hotline/client_test.go @@ -0,0 +1,278 @@ +package hotline + +import ( + "bytes" + "context" + "io" + "log/slog" + "net" + "os" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func newTestClient() *Client { + return NewClient("testuser", slog.New(slog.NewTextHandler(os.Stdout, nil))) +} + +func TestClient_Handshake(t *testing.T) { + t.Run("successful handshake", func(t *testing.T) { + clientConn, serverConn := net.Pipe() + defer clientConn.Close() + defer serverConn.Close() + + c := newTestClient() + c.Connection = clientConn + + // Server side: read the client handshake and respond + go func() { + buf := make([]byte, 12) + _, _ = io.ReadFull(serverConn, buf) + assert.Equal(t, ClientHandshake, buf) + _, _ = serverConn.Write(ServerHandshake) + }() + + err := c.Handshake() + assert.NoError(t, err) + }) + + t.Run("server returns error response", func(t *testing.T) { + clientConn, serverConn := net.Pipe() + defer clientConn.Close() + defer serverConn.Close() + + c := newTestClient() + c.Connection = clientConn + + go func() { + buf := make([]byte, 12) + _, _ = io.ReadFull(serverConn, buf) + // Send a non-standard response + _, _ = serverConn.Write([]byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01}) + }() + + err := c.Handshake() + assert.Error(t, err) + assert.Contains(t, err.Error(), "unexpected handshake response") + }) + + t.Run("connection closed during read", func(t *testing.T) { + clientConn, serverConn := net.Pipe() + defer clientConn.Close() + + c := newTestClient() + c.Connection = clientConn + + go func() { + buf := make([]byte, 12) + _, _ = io.ReadFull(serverConn, buf) + serverConn.Close() + }() + + err := c.Handshake() + assert.Error(t, err) + assert.Contains(t, err.Error(), "handshake read err") + }) +} + +func TestClient_Send(t *testing.T) { + t.Run("sends non-reply transaction and tracks it", func(t *testing.T) { + clientConn, serverConn := net.Pipe() + defer clientConn.Close() + defer serverConn.Close() + + c := newTestClient() + c.Connection = clientConn + + tran := NewTransaction(TranChatSend, [2]byte{0, 1}, NewField(FieldData, []byte("hello"))) + + // Read what client sends + go func() { + buf := make([]byte, 4096) + _, _ = serverConn.Read(buf) + }() + + err := c.Send(tran) + assert.NoError(t, err) + + // Verify the transaction was added to activeTasks + assert.Contains(t, c.activeTasks, tran.ID) + }) + + t.Run("reply transactions are not tracked", func(t *testing.T) { + clientConn, serverConn := net.Pipe() + defer clientConn.Close() + defer serverConn.Close() + + c := newTestClient() + c.Connection = clientConn + + tran := NewTransaction(TranChatSend, [2]byte{0, 1}) + tran.IsReply = 1 + + go func() { + buf := make([]byte, 4096) + _, _ = serverConn.Read(buf) + }() + + err := c.Send(tran) + assert.NoError(t, err) + assert.NotContains(t, c.activeTasks, tran.ID) + }) +} + +func TestClient_HandleTransaction(t *testing.T) { + t.Run("dispatches to registered handler", func(t *testing.T) { + c := newTestClient() + c.Connection = &clientMockConn{WBuf: &bytes.Buffer{}} + + handlerCalled := false + c.HandleFunc(TranChatMsg, func(ctx context.Context, client *Client, t *Transaction) ([]Transaction, error) { + handlerCalled = true + return nil, nil + }) + + tran := &Transaction{Type: TranChatMsg} + err := c.HandleTransaction(context.Background(), tran) + + assert.NoError(t, err) + assert.True(t, handlerCalled) + }) + + t.Run("reply matches original request type", func(t *testing.T) { + c := newTestClient() + c.Connection = &clientMockConn{WBuf: &bytes.Buffer{}} + + // Register a handler for the original type + var receivedType TranType + c.HandleFunc(TranGetUserNameList, func(ctx context.Context, client *Client, t *Transaction) ([]Transaction, error) { + receivedType = t.Type + return nil, nil + }) + + // Simulate sending a request first + origTran := NewTransaction(TranGetUserNameList, [2]byte{0, 1}) + c.activeTasks[origTran.ID] = &origTran + + // Create a reply with the same ID + reply := &Transaction{ + ID: origTran.ID, + IsReply: 1, + } + + err := c.HandleTransaction(context.Background(), reply) + assert.NoError(t, err) + assert.Equal(t, TranGetUserNameList, receivedType) + }) + + t.Run("reply with no matching request returns error", func(t *testing.T) { + c := newTestClient() + c.Connection = &clientMockConn{WBuf: &bytes.Buffer{}} + + reply := &Transaction{ + ID: [4]byte{0, 0, 0, 99}, + IsReply: 1, + } + + err := c.HandleTransaction(context.Background(), reply) + assert.Error(t, err) + assert.Contains(t, err.Error(), "no matching request") + }) + + t.Run("unhandled transaction type does not error", func(t *testing.T) { + c := newTestClient() + c.Connection = &clientMockConn{WBuf: &bytes.Buffer{}} + + tran := &Transaction{Type: [2]byte{0xFF, 0xFF}} + err := c.HandleTransaction(context.Background(), tran) + assert.NoError(t, err) + }) +} + +func TestClient_Disconnect(t *testing.T) { + t.Run("closes connection and done channel", func(t *testing.T) { + clientConn, serverConn := net.Pipe() + defer serverConn.Close() + + c := newTestClient() + c.Connection = clientConn + c.done = make(chan struct{}) + doneCh := c.done // save reference before Disconnect sets it to nil + + err := c.Disconnect() + assert.NoError(t, err) + + // Verify done channel is closed + select { + case <-doneCh: + // expected - channel is closed + default: + t.Error("done channel should be closed") + } + + // Verify connection is closed by attempting a write + _, err = clientConn.Write([]byte("test")) + assert.Error(t, err) + }) + + t.Run("handles nil done channel", func(t *testing.T) { + clientConn, serverConn := net.Pipe() + defer serverConn.Close() + + c := newTestClient() + c.Connection = clientConn + // done is nil by default (before Connect is called) + + err := c.Disconnect() + assert.NoError(t, err) + }) +} + +func TestClient_HandleFunc(t *testing.T) { + c := newTestClient() + + handler := func(ctx context.Context, client *Client, t *Transaction) ([]Transaction, error) { + return nil, nil + } + + c.HandleFunc(TranChatMsg, handler) + + _, ok := c.Handlers[TranChatMsg] + require.True(t, ok, "handler should be registered") +} + +func TestNewClient(t *testing.T) { + logger := slog.New(slog.NewTextHandler(os.Stdout, nil)) + c := NewClient("myuser", logger) + + assert.Equal(t, "myuser", c.Pref.Username) + assert.NotNil(t, c.Handlers) + assert.NotNil(t, c.activeTasks) +} + +// clientMockConn implements net.Conn for testing Send without a net.Pipe. +type clientMockConn struct { + RBuf *bytes.Buffer + WBuf *bytes.Buffer +} + +func (mc *clientMockConn) Read(b []byte) (n int, err error) { + if mc.RBuf == nil { + return 0, io.EOF + } + return mc.RBuf.Read(b) +} + +func (mc *clientMockConn) Write(b []byte) (n int, err error) { + return mc.WBuf.Write(b) +} + +func (mc *clientMockConn) Close() error { return nil } +func (mc *clientMockConn) LocalAddr() net.Addr { return nil } +func (mc *clientMockConn) RemoteAddr() net.Addr { return nil } +func (mc *clientMockConn) SetDeadline(_ time.Time) error { return nil } +func (mc *clientMockConn) SetReadDeadline(_ time.Time) error { return nil } +func (mc *clientMockConn) SetWriteDeadline(_ time.Time) error { return nil } diff --git a/hotline/file_wrapper_test.go b/hotline/file_wrapper_test.go new file mode 100644 index 0000000..45fac94 --- /dev/null +++ b/hotline/file_wrapper_test.go @@ -0,0 +1,171 @@ +package hotline + +import ( + "errors" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +func TestFile_DataFile(t *testing.T) { + t.Run("returns file info when data file exists", func(t *testing.T) { + mfs := &MockFileStore{} + mfi := &MockFileInfo{} + + f := &File{ + fs: mfs, + Name: "testfile.txt", + dataPath: "/files/testfile.txt", + incompletePath: "/files/testfile.txt.incomplete", + } + + mfs.On("Stat", "/files/testfile.txt").Return(mfi, nil) + + fi, err := f.DataFile() + + assert.NoError(t, err) + assert.Equal(t, mfi, fi) + mfs.AssertExpectations(t) + }) + + t.Run("returns file info from incomplete file when data file not found", func(t *testing.T) { + mfs := &MockFileStore{} + mfi := &MockFileInfo{} + + f := &File{ + fs: mfs, + Name: "testfile.txt", + dataPath: "/files/testfile.txt", + incompletePath: "/files/testfile.txt.incomplete", + } + + mfs.On("Stat", "/files/testfile.txt").Return(nil, os.ErrNotExist) + mfs.On("Stat", "/files/testfile.txt.incomplete").Return(mfi, nil) + + fi, err := f.DataFile() + + assert.NoError(t, err) + assert.Equal(t, mfi, fi) + mfs.AssertExpectations(t) + }) + + t.Run("returns error when neither data file nor incomplete file exists", func(t *testing.T) { + mfs := &MockFileStore{} + + f := &File{ + fs: mfs, + Name: "testfile.txt", + dataPath: "/files/testfile.txt", + incompletePath: "/files/testfile.txt.incomplete", + } + + mfs.On("Stat", "/files/testfile.txt").Return(nil, os.ErrNotExist) + mfs.On("Stat", "/files/testfile.txt.incomplete").Return(nil, os.ErrNotExist) + + fi, err := f.DataFile() + + assert.Nil(t, fi) + assert.EqualError(t, err, "file or directory not found") + mfs.AssertExpectations(t) + }) +} + +func TestFile_Move(t *testing.T) { + t.Run("succeeds when data rename works and meta files do not exist", func(t *testing.T) { + mfs := &MockFileStore{} + + f := &File{ + fs: mfs, + Name: "testfile.txt", + dataPath: "/files/testfile.txt", + incompletePath: "/files/testfile.txt.incomplete", + rsrcPath: "/files/.rsrc_testfile.txt", + infoPath: "/files/.info_testfile.txt", + } + + newPath := "/dest" + + mfs.On("Rename", "/files/testfile.txt", "/dest/testfile.txt").Return(nil) + mfs.On("Rename", "/files/testfile.txt.incomplete", "/dest/testfile.txt.incomplete").Return(os.ErrNotExist) + mfs.On("Rename", "/files/.rsrc_testfile.txt", "/dest/.rsrc_testfile.txt").Return(os.ErrNotExist) + mfs.On("Rename", "/files/.info_testfile.txt", "/dest/.info_testfile.txt").Return(os.ErrNotExist) + + err := f.Move(newPath) + + assert.NoError(t, err) + mfs.AssertExpectations(t) + }) + + t.Run("returns error when data file rename fails", func(t *testing.T) { + mfs := &MockFileStore{} + + f := &File{ + fs: mfs, + Name: "testfile.txt", + dataPath: "/files/testfile.txt", + incompletePath: "/files/testfile.txt.incomplete", + rsrcPath: "/files/.rsrc_testfile.txt", + infoPath: "/files/.info_testfile.txt", + } + + renameErr := errors.New("permission denied") + mfs.On("Rename", "/files/testfile.txt", "/dest/testfile.txt").Return(renameErr) + + err := f.Move("/dest") + + assert.ErrorIs(t, err, renameErr) + // Meta file renames should not have been called. + mfs.AssertNotCalled(t, "Rename", mock.Anything, "/dest/testfile.txt.incomplete") + mfs.AssertExpectations(t) + }) +} + +func TestFile_Delete(t *testing.T) { + t.Run("succeeds when RemoveAll works and meta files do not exist", func(t *testing.T) { + mfs := &MockFileStore{} + + f := &File{ + fs: mfs, + Name: "testfile.txt", + dataPath: "/files/testfile.txt", + incompletePath: "/files/testfile.txt.incomplete", + rsrcPath: "/files/.rsrc_testfile.txt", + infoPath: "/files/.info_testfile.txt", + } + + mfs.On("RemoveAll", "/files/testfile.txt").Return(nil) + mfs.On("Remove", "/files/testfile.txt.incomplete").Return(os.ErrNotExist) + mfs.On("Remove", "/files/.rsrc_testfile.txt").Return(os.ErrNotExist) + mfs.On("Remove", "/files/.info_testfile.txt").Return(os.ErrNotExist) + + err := f.Delete() + + assert.NoError(t, err) + mfs.AssertExpectations(t) + }) + + t.Run("returns error when RemoveAll fails", func(t *testing.T) { + mfs := &MockFileStore{} + + f := &File{ + fs: mfs, + Name: "testfile.txt", + dataPath: "/files/testfile.txt", + incompletePath: "/files/testfile.txt.incomplete", + rsrcPath: "/files/.rsrc_testfile.txt", + infoPath: "/files/.info_testfile.txt", + } + + removeErr := errors.New("permission denied") + mfs.On("RemoveAll", "/files/testfile.txt").Return(removeErr) + + err := f.Delete() + + assert.ErrorIs(t, err, removeErr) + // Meta file removes should not have been called. + mfs.AssertNotCalled(t, "Remove", mock.Anything) + mfs.AssertExpectations(t) + }) +} diff --git a/hotline/flattened_file_object_test.go b/hotline/flattened_file_object_test.go index c34af8a..6279157 100644 --- a/hotline/flattened_file_object_test.go +++ b/hotline/flattened_file_object_test.go @@ -1,6 +1,7 @@ package hotline import ( + "encoding/binary" "fmt" "testing" @@ -42,3 +43,144 @@ func TestFlatFileInformationFork_UnmarshalBinary(t *testing.T) { }) } } + +func TestFlatFileInformationFork_FriendlyType(t *testing.T) { + tests := []struct { + name string + typeSignature [4]byte + want []byte + }{ + { + name: "known type TEXT", + typeSignature: [4]byte{'T', 'E', 'X', 'T'}, + want: []byte("Text File"), + }, + { + name: "known type APPL", + typeSignature: [4]byte{'A', 'P', 'P', 'L'}, + want: []byte("Application Program"), + }, + { + name: "known type SIT!", + typeSignature: [4]byte{'S', 'I', 'T', '!'}, + want: []byte("StuffIt Archive"), + }, + { + name: "unknown type returns raw signature", + typeSignature: [4]byte{'J', 'P', 'E', 'G'}, + want: []byte("JPEG"), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ffif := &FlatFileInformationFork{ + TypeSignature: tt.typeSignature, + } + assert.Equal(t, tt.want, ffif.FriendlyType()) + }) + } +} + +func TestFlatFileInformationFork_FriendlyCreator(t *testing.T) { + tests := []struct { + name string + creatorSignature [4]byte + want []byte + }{ + { + name: "known creator HTLC", + creatorSignature: [4]byte{'H', 'T', 'L', 'C'}, + want: []byte("Hotline"), + }, + { + name: "unknown creator returns raw signature", + creatorSignature: [4]byte{'o', 'g', 'l', 'e'}, + want: []byte("ogle"), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ffif := &FlatFileInformationFork{ + CreatorSignature: tt.creatorSignature, + } + assert.Equal(t, tt.want, ffif.FriendlyCreator()) + }) + } +} + +func TestFlatFileInformationFork_SetComment(t *testing.T) { + tests := []struct { + name string + comment []byte + wantComment []byte + wantCommentSize [2]byte + }{ + { + name: "sets a short comment", + comment: []byte("hello"), + wantComment: []byte("hello"), + wantCommentSize: [2]byte{0x00, 0x05}, + }, + { + name: "sets an empty comment", + comment: []byte{}, + wantComment: []byte{}, + wantCommentSize: [2]byte{0x00, 0x00}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ffif := &FlatFileInformationFork{} + err := ffif.SetComment(tt.comment) + assert.NoError(t, err) + assert.Equal(t, tt.wantComment, ffif.Comment) + assert.Equal(t, tt.wantCommentSize, ffif.CommentSize) + }) + } +} + +func TestFlattenedFileObject_TransferSize(t *testing.T) { + tests := []struct { + name string + dataSize uint32 + resForkSize uint32 + offset int64 + wantNonZero bool + }{ + { + name: "calculates transfer size with zero offset", + dataSize: 100, + resForkSize: 50, + offset: 0, + wantNonZero: true, + }, + { + name: "calculates transfer size with non-zero offset", + dataSize: 200, + resForkSize: 100, + offset: 50, + wantNonZero: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ffo := &flattenedFileObject{} + + binary.BigEndian.PutUint32(ffo.FlatFileDataForkHeader.DataSize[:], tt.dataSize) + binary.BigEndian.PutUint32(ffo.FlatFileResForkHeader.DataSize[:], tt.resForkSize) + + result := ffo.TransferSize(tt.offset) + assert.Len(t, result, 4) + + size := binary.BigEndian.Uint32(result) + assert.Greater(t, size, uint32(0)) + + // With offset, the size should be smaller than without offset. + if tt.offset > 0 { + noOffsetResult := ffo.TransferSize(0) + noOffsetSize := binary.BigEndian.Uint32(noOffsetResult) + assert.Equal(t, noOffsetSize-uint32(tt.offset), size) + } + }) + } +} diff --git a/hotline/server_test.go b/hotline/server_test.go index abc2f9a..4bb3669 100644 --- a/hotline/server_test.go +++ b/hotline/server_test.go @@ -13,6 +13,7 @@ import ( "time" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" "golang.org/x/text/encoding" "golang.org/x/text/encoding/charmap" ) @@ -749,6 +750,107 @@ func TestServer_registerWithTrackers_EdgeCases(t *testing.T) { } } +func TestServer_CurrentStats(t *testing.T) { + stats := NewStats() + stats.Increment(StatCurrentlyConnected) + stats.Increment(StatDownloadCounter) + stats.Increment(StatDownloadCounter) + + srv := &Server{Stats: stats} + result := srv.CurrentStats() + + assert.Equal(t, 1, result["CurrentlyConnected"]) + assert.Equal(t, 2, result["DownloadCounter"]) + assert.Equal(t, 0, result["UploadsInProgress"]) +} + +func TestServer_sendTransaction(t *testing.T) { + t.Run("sends transaction to client connection", func(t *testing.T) { + wBuf := &bytes.Buffer{} + mockMgr := &MockClientMgr{} + mockMgr.On("Get", ClientID{0, 1}).Return(&ClientConn{ + Connection: &nopCloserRWC{Buffer: wBuf}, + }) + + srv := &Server{ClientMgr: mockMgr} + + tran := NewTransaction(TranChatMsg, ClientID{0, 1}, NewField(FieldData, []byte("hello"))) + err := srv.sendTransaction(tran) + + assert.NoError(t, err) + assert.Greater(t, wBuf.Len(), 0) + mockMgr.AssertExpectations(t) + }) + + t.Run("returns nil when client not found", func(t *testing.T) { + mockMgr := &MockClientMgr{} + mockMgr.On("Get", ClientID{0, 99}).Return((*ClientConn)(nil)) + + srv := &Server{ClientMgr: mockMgr} + + tran := NewTransaction(TranChatMsg, ClientID{0, 99}) + err := srv.sendTransaction(tran) + + assert.NoError(t, err) + mockMgr.AssertExpectations(t) + }) +} + +func TestServer_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) + srv := &Server{ + ClientMgr: mockMgr, + outbox: outbox, + } + + srv.SendAll(TranChatMsg, NewField(FieldData, []byte("broadcast"))) + + assert.Len(t, outbox, 3) + + // Verify each transaction targets a different client + 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) +} + +type nopCloserRWC struct { + *bytes.Buffer +} + +func (n *nopCloserRWC) Close() error { return nil } + +func TestServer_NewClientConn(t *testing.T) { + mockMgr := &MockClientMgr{} + mockMgr.On("Add", mock.AnythingOfType("*hotline.ClientConn")).Return() + + srv := &Server{ClientMgr: mockMgr} + + rwc := &nopCloserRWC{Buffer: &bytes.Buffer{}} + + cc := srv.NewClientConn(rwc, "192.168.1.1:12345") + + assert.NotNil(t, cc) + assert.Equal(t, "192.168.1.1:12345", cc.RemoteAddr) + assert.Equal(t, []byte{0, 0}, cc.Icon) + assert.Equal(t, srv, cc.Server) + mockMgr.AssertExpectations(t) +} + func TestServer_registerWithAllTrackers(t *testing.T) { tests := []struct { name string @@ -819,3 +921,11 @@ func TestServer_registerWithAllTrackers(t *testing.T) { }) } } + +func TestSendBanMessage(t *testing.T) { + buf := &bytes.Buffer{} + sendBanMessage(buf, "You are banned") + + assert.Greater(t, buf.Len(), 0) + assert.Contains(t, buf.String(), "You are banned") +} diff --git a/internal/mobius/agreement_test.go b/internal/mobius/agreement_test.go new file mode 100644 index 0000000..fa32f4b --- /dev/null +++ b/internal/mobius/agreement_test.go @@ -0,0 +1,328 @@ +package mobius + +import ( + "io" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewAgreement(t *testing.T) { + t.Run("success with valid file", func(t *testing.T) { + dir := t.TempDir() + err := os.WriteFile(filepath.Join(dir, agreementFile), []byte("Welcome to the server!"), 0644) + require.NoError(t, err) + + ag, err := NewAgreement(dir, "\r") + require.NoError(t, err) + assert.Equal(t, "Welcome to the server!", string(ag.data)) + assert.Equal(t, filepath.Join(dir, agreementFile), ag.filePath) + assert.Equal(t, "\r", ag.lineEndings) + }) + + t.Run("error with missing file", func(t *testing.T) { + dir := t.TempDir() + + ag, err := NewAgreement(dir, "\r") + require.Error(t, err) + assert.Contains(t, err.Error(), "read file:") + assert.NotNil(t, ag) // returns empty Agreement on error + assert.Empty(t, ag.data) + }) + + t.Run("converts newlines to custom line endings", func(t *testing.T) { + dir := t.TempDir() + err := os.WriteFile(filepath.Join(dir, agreementFile), []byte("line1\nline2\nline3"), 0644) + require.NoError(t, err) + + ag, err := NewAgreement(dir, "\r") + require.NoError(t, err) + assert.Equal(t, "line1\rline2\rline3", string(ag.data)) + }) + + t.Run("converts CRLF line endings", func(t *testing.T) { + dir := t.TempDir() + // The implementation first replaces \n, then \r\n. + // Writing raw \r\n: first pass turns \n -> \r, yielding \r\r, + // then second pass looks for \r\n which no longer exists. + err := os.WriteFile(filepath.Join(dir, agreementFile), []byte("line1\r\nline2"), 0644) + require.NoError(t, err) + + ag, err := NewAgreement(dir, "\r") + require.NoError(t, err) + // \r\n -> first \n becomes \r -> "\r\r" then \r\n replacement finds nothing + assert.Equal(t, "line1\r\rline2", string(ag.data)) + }) + + t.Run("preserves content when line ending is newline", func(t *testing.T) { + dir := t.TempDir() + content := "line1\nline2\nline3" + err := os.WriteFile(filepath.Join(dir, agreementFile), []byte(content), 0644) + require.NoError(t, err) + + ag, err := NewAgreement(dir, "\n") + require.NoError(t, err) + assert.Equal(t, content, string(ag.data)) + }) +} + +func TestAgreement_Reload(t *testing.T) { + t.Run("reload picks up changed file content", func(t *testing.T) { + dir := t.TempDir() + filePath := filepath.Join(dir, agreementFile) + err := os.WriteFile(filePath, []byte("original"), 0644) + require.NoError(t, err) + + ag, err := NewAgreement(dir, "\r") + require.NoError(t, err) + assert.Equal(t, "original", string(ag.data)) + + err = os.WriteFile(filePath, []byte("updated content"), 0644) + require.NoError(t, err) + + err = ag.Reload() + require.NoError(t, err) + assert.Equal(t, "updated content", string(ag.data)) + }) + + t.Run("reload applies line ending conversion", func(t *testing.T) { + dir := t.TempDir() + filePath := filepath.Join(dir, agreementFile) + err := os.WriteFile(filePath, []byte("first"), 0644) + require.NoError(t, err) + + ag, err := NewAgreement(dir, "\r") + require.NoError(t, err) + + err = os.WriteFile(filePath, []byte("line1\nline2"), 0644) + require.NoError(t, err) + + err = ag.Reload() + require.NoError(t, err) + assert.Equal(t, "line1\rline2", string(ag.data)) + }) + + t.Run("reload returns error when file is deleted", func(t *testing.T) { + dir := t.TempDir() + filePath := filepath.Join(dir, agreementFile) + err := os.WriteFile(filePath, []byte("content"), 0644) + require.NoError(t, err) + + ag, err := NewAgreement(dir, "\r") + require.NoError(t, err) + + err = os.Remove(filePath) + require.NoError(t, err) + + err = ag.Reload() + require.Error(t, err) + assert.Contains(t, err.Error(), "read file:") + }) +} + +func TestAgreement_Read(t *testing.T) { + t.Run("reads full data in one call", func(t *testing.T) { + dir := t.TempDir() + err := os.WriteFile(filepath.Join(dir, agreementFile), []byte("hello"), 0644) + require.NoError(t, err) + + ag, err := NewAgreement(dir, "\r") + require.NoError(t, err) + + buf := make([]byte, 100) + n, err := ag.Read(buf) + assert.NoError(t, err) + assert.Equal(t, 5, n) + assert.Equal(t, "hello", string(buf[:n])) + }) + + t.Run("returns EOF after all data is read", func(t *testing.T) { + dir := t.TempDir() + err := os.WriteFile(filepath.Join(dir, agreementFile), []byte("hi"), 0644) + require.NoError(t, err) + + ag, err := NewAgreement(dir, "\r") + require.NoError(t, err) + + buf := make([]byte, 100) + _, err = ag.Read(buf) + require.NoError(t, err) + + n, err := ag.Read(buf) + assert.Equal(t, 0, n) + assert.ErrorIs(t, err, io.EOF) + }) + + t.Run("reads in chunks", func(t *testing.T) { + dir := t.TempDir() + err := os.WriteFile(filepath.Join(dir, agreementFile), []byte("abcdefgh"), 0644) + require.NoError(t, err) + + ag, err := NewAgreement(dir, "\r") + require.NoError(t, err) + + buf := make([]byte, 3) + + n, err := ag.Read(buf) + assert.NoError(t, err) + assert.Equal(t, 3, n) + assert.Equal(t, "abc", string(buf[:n])) + + n, err = ag.Read(buf) + assert.NoError(t, err) + assert.Equal(t, 3, n) + assert.Equal(t, "def", string(buf[:n])) + + n, err = ag.Read(buf) + assert.NoError(t, err) + assert.Equal(t, 2, n) + assert.Equal(t, "gh", string(buf[:n])) + + n, err = ag.Read(buf) + assert.Equal(t, 0, n) + assert.ErrorIs(t, err, io.EOF) + }) + + t.Run("empty agreement returns EOF immediately", func(t *testing.T) { + dir := t.TempDir() + err := os.WriteFile(filepath.Join(dir, agreementFile), []byte(""), 0644) + require.NoError(t, err) + + ag, err := NewAgreement(dir, "\r") + require.NoError(t, err) + + buf := make([]byte, 10) + n, err := ag.Read(buf) + assert.Equal(t, 0, n) + assert.ErrorIs(t, err, io.EOF) + }) +} + +func TestAgreement_Seek(t *testing.T) { + t.Run("seek to beginning resets read position", func(t *testing.T) { + dir := t.TempDir() + err := os.WriteFile(filepath.Join(dir, agreementFile), []byte("hello"), 0644) + require.NoError(t, err) + + ag, err := NewAgreement(dir, "\r") + require.NoError(t, err) + + // Read all data + buf := make([]byte, 100) + _, err = ag.Read(buf) + require.NoError(t, err) + + // Confirm EOF + _, err = ag.Read(buf) + assert.ErrorIs(t, err, io.EOF) + + // Seek back to start + offset, err := ag.Seek(0, 0) + require.NoError(t, err) + assert.Equal(t, int64(0), offset) + + // Read again from beginning + n, err := ag.Read(buf) + assert.NoError(t, err) + assert.Equal(t, 5, n) + assert.Equal(t, "hello", string(buf[:n])) + }) + + t.Run("seek to middle offset", func(t *testing.T) { + dir := t.TempDir() + err := os.WriteFile(filepath.Join(dir, agreementFile), []byte("abcdef"), 0644) + require.NoError(t, err) + + ag, err := NewAgreement(dir, "\r") + require.NoError(t, err) + + _, err = ag.Seek(3, 0) + require.NoError(t, err) + + buf := make([]byte, 100) + n, err := ag.Read(buf) + assert.NoError(t, err) + assert.Equal(t, 3, n) + assert.Equal(t, "def", string(buf[:n])) + }) + + t.Run("seek sets internal readOffset", func(t *testing.T) { + dir := t.TempDir() + err := os.WriteFile(filepath.Join(dir, agreementFile), []byte("test"), 0644) + require.NoError(t, err) + + ag, err := NewAgreement(dir, "\r") + require.NoError(t, err) + + _, err = ag.Seek(2, 0) + require.NoError(t, err) + assert.Equal(t, 2, ag.readOffset) + }) +} + +func TestAgreement_ReadAll(t *testing.T) { + t.Run("io.ReadAll reads complete agreement", func(t *testing.T) { + dir := t.TempDir() + err := os.WriteFile(filepath.Join(dir, agreementFile), []byte("full content here"), 0644) + require.NoError(t, err) + + ag, err := NewAgreement(dir, "\r") + require.NoError(t, err) + + data, err := io.ReadAll(ag) + require.NoError(t, err) + assert.Equal(t, "full content here", string(data)) + }) + + t.Run("io.ReadAll with line ending conversion", func(t *testing.T) { + dir := t.TempDir() + err := os.WriteFile(filepath.Join(dir, agreementFile), []byte("line1\nline2\nline3"), 0644) + require.NoError(t, err) + + ag, err := NewAgreement(dir, "\r") + require.NoError(t, err) + + data, err := io.ReadAll(ag) + require.NoError(t, err) + assert.Equal(t, "line1\rline2\rline3", string(data)) + }) + + t.Run("io.ReadAll after seek resets position", func(t *testing.T) { + dir := t.TempDir() + err := os.WriteFile(filepath.Join(dir, agreementFile), []byte("abcdef"), 0644) + require.NoError(t, err) + + ag, err := NewAgreement(dir, "\r") + require.NoError(t, err) + + // Partially read + buf := make([]byte, 3) + _, err = ag.Read(buf) + require.NoError(t, err) + + // Seek to beginning + _, err = ag.Seek(0, 0) + require.NoError(t, err) + + // ReadAll should get everything + data, err := io.ReadAll(ag) + require.NoError(t, err) + assert.Equal(t, "abcdef", string(data)) + }) + + t.Run("io.ReadAll on empty agreement", func(t *testing.T) { + dir := t.TempDir() + err := os.WriteFile(filepath.Join(dir, agreementFile), []byte(""), 0644) + require.NoError(t, err) + + ag, err := NewAgreement(dir, "\r") + require.NoError(t, err) + + data, err := io.ReadAll(ag) + require.NoError(t, err) + assert.Empty(t, data) + }) +} diff --git a/internal/mobius/api_test.go b/internal/mobius/api_test.go new file mode 100644 index 0000000..d448b1b --- /dev/null +++ b/internal/mobius/api_test.go @@ -0,0 +1,688 @@ +package mobius + +import ( + "encoding/json" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/jhalter/mobius/hotline" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// --- Mock implementations --- + +type mockBanMgr struct { + bannedIPs []string + bannedUsernames []string + bannedNicknames []string + + addCalled bool + addIP string + banUsernameCalled bool + banUsernameArg string + banNicknameCalled bool + banNicknameArg string + unbanIPCalled bool + unbanIPArg string + unbanUsernameCalled bool + unbanUsernameArg string + unbanNicknameCalled bool + unbanNicknameArg string + + // Allow injecting errors + addErr error + banUsernameErr error + banNicknameErr error + unbanIPErr error + unbanUsernameErr error + unbanNicknameErr error + listIPsErr error + listUsernamesErr error + listNicknamesErr error +} + +func (m *mockBanMgr) Add(ip string, _ *time.Time) error { + m.addCalled = true + m.addIP = ip + if m.addErr != nil { + return m.addErr + } + m.bannedIPs = append(m.bannedIPs, ip) + return nil +} + +func (m *mockBanMgr) IsBanned(ip string) (bool, *time.Time) { + for _, b := range m.bannedIPs { + if b == ip { + return true, nil + } + } + return false, nil +} + +func (m *mockBanMgr) UnbanIP(ip string) error { + m.unbanIPCalled = true + m.unbanIPArg = ip + return m.unbanIPErr +} + +func (m *mockBanMgr) BanUsername(username string) error { + m.banUsernameCalled = true + m.banUsernameArg = username + if m.banUsernameErr != nil { + return m.banUsernameErr + } + m.bannedUsernames = append(m.bannedUsernames, username) + return nil +} + +func (m *mockBanMgr) UnbanUsername(username string) error { + m.unbanUsernameCalled = true + m.unbanUsernameArg = username + return m.unbanUsernameErr +} + +func (m *mockBanMgr) IsUsernameBanned(username string) bool { + for _, b := range m.bannedUsernames { + if b == username { + return true + } + } + return false +} + +func (m *mockBanMgr) BanNickname(nickname string) error { + m.banNicknameCalled = true + m.banNicknameArg = nickname + if m.banNicknameErr != nil { + return m.banNicknameErr + } + m.bannedNicknames = append(m.bannedNicknames, nickname) + return nil +} + +func (m *mockBanMgr) UnbanNickname(nickname string) error { + m.unbanNicknameCalled = true + m.unbanNicknameArg = nickname + return m.unbanNicknameErr +} + +func (m *mockBanMgr) IsNicknameBanned(nickname string) bool { + for _, b := range m.bannedNicknames { + if b == nickname { + return true + } + } + return false +} + +func (m *mockBanMgr) ListBannedIPs() ([]string, error) { + if m.listIPsErr != nil { + return nil, m.listIPsErr + } + return m.bannedIPs, nil +} + +func (m *mockBanMgr) ListBannedUsernames() ([]string, error) { + if m.listUsernamesErr != nil { + return nil, m.listUsernamesErr + } + return m.bannedUsernames, nil +} + +func (m *mockBanMgr) ListBannedNicknames() ([]string, error) { + if m.listNicknamesErr != nil { + return nil, m.listNicknamesErr + } + return m.bannedNicknames, nil +} + +type mockClientMgr struct { + clients []*hotline.ClientConn +} + +func (m *mockClientMgr) List() []*hotline.ClientConn { + return m.clients +} + +func (m *mockClientMgr) Get(id hotline.ClientID) *hotline.ClientConn { + for _, c := range m.clients { + if c.ID == id { + return c + } + } + return nil +} + +func (m *mockClientMgr) Add(cc *hotline.ClientConn) { + m.clients = append(m.clients, cc) +} + +func (m *mockClientMgr) Delete(id hotline.ClientID) { + for i, c := range m.clients { + if c.ID == id { + m.clients = append(m.clients[:i], m.clients[i+1:]...) + return + } + } +} + +type mockCounter struct { + vals map[string]interface{} +} + +func (m *mockCounter) Increment(_ ...int) {} +func (m *mockCounter) Decrement(_ int) {} +func (m *mockCounter) Set(_, _ int) {} +func (m *mockCounter) Get(_ int) int { return 0 } +func (m *mockCounter) Values() map[string]interface{} { + if m.vals == nil { + return map[string]interface{}{} + } + return m.vals +} + +// --- Test helper --- + +func newTestAPIServer(t *testing.T, apiKey string) (*APIServer, *mockBanMgr, *mockClientMgr, *mockCounter) { + t.Helper() + + banMgr := &mockBanMgr{} + clientMgr := &mockClientMgr{} + counter := &mockCounter{} + + hlServer := &hotline.Server{ + BanList: banMgr, + ClientMgr: clientMgr, + Stats: counter, + Logger: slog.Default(), + } + + srv := &APIServer{ + hlServer: hlServer, + logger: slog.Default(), + mux: http.NewServeMux(), + apiKey: apiKey, + } + + reloadCalled := false + reloadFunc := func() { reloadCalled = true } + _ = reloadCalled + + srv.mux.Handle("/api/v1/online", srv.logMiddleware(srv.authMiddleware(http.HandlerFunc(srv.OnlineHandler)))) + srv.mux.Handle("/api/v1/ban", srv.logMiddleware(srv.authMiddleware(http.HandlerFunc(srv.BanHandler)))) + srv.mux.Handle("/api/v1/unban", srv.logMiddleware(srv.authMiddleware(http.HandlerFunc(srv.UnbanHandler)))) + srv.mux.Handle("/api/v1/banned/ips", srv.logMiddleware(srv.authMiddleware(http.HandlerFunc(srv.ListBannedIPsHandler)))) + srv.mux.Handle("/api/v1/banned/usernames", srv.logMiddleware(srv.authMiddleware(http.HandlerFunc(srv.ListBannedUsernamesHandler)))) + srv.mux.Handle("/api/v1/banned/nicknames", srv.logMiddleware(srv.authMiddleware(http.HandlerFunc(srv.ListBannedNicknamesHandler)))) + srv.mux.Handle("/api/v1/reload", srv.logMiddleware(srv.authMiddleware(http.HandlerFunc(srv.ReloadHandler(reloadFunc))))) + srv.mux.Handle("/api/v1/shutdown", srv.logMiddleware(srv.authMiddleware(http.HandlerFunc(srv.ShutdownHandler)))) + srv.mux.Handle("/api/v1/stats", srv.logMiddleware(srv.authMiddleware(http.HandlerFunc(srv.RenderStats)))) + + return srv, banMgr, clientMgr, counter +} + +// newTestAPIServerWithReload is like newTestAPIServer but returns a pointer to the reloadCalled flag. +func newTestAPIServerWithReload(t *testing.T, apiKey string) (*APIServer, *bool) { + t.Helper() + + banMgr := &mockBanMgr{} + clientMgr := &mockClientMgr{} + counter := &mockCounter{} + + hlServer := &hotline.Server{ + BanList: banMgr, + ClientMgr: clientMgr, + Stats: counter, + Logger: slog.Default(), + } + + srv := &APIServer{ + hlServer: hlServer, + logger: slog.Default(), + mux: http.NewServeMux(), + apiKey: apiKey, + } + + reloadCalled := false + reloadFunc := func() { reloadCalled = true } + + srv.mux.Handle("/api/v1/reload", srv.logMiddleware(srv.authMiddleware(http.HandlerFunc(srv.ReloadHandler(reloadFunc))))) + + return srv, &reloadCalled +} + +func TestAuthMiddleware(t *testing.T) { + t.Run("rejects request without API key when key is configured", func(t *testing.T) { + srv, _, _, _ := newTestAPIServer(t, "secret-key") + + req := httptest.NewRequest(http.MethodGet, "/api/v1/stats", nil) + rr := httptest.NewRecorder() + + srv.mux.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusUnauthorized, rr.Code) + assert.Contains(t, rr.Body.String(), "unauthorized") + }) + + t.Run("rejects request with wrong API key", func(t *testing.T) { + srv, _, _, _ := newTestAPIServer(t, "secret-key") + + req := httptest.NewRequest(http.MethodGet, "/api/v1/stats", nil) + req.Header.Set("X-API-Key", "wrong-key") + rr := httptest.NewRecorder() + + srv.mux.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusUnauthorized, rr.Code) + }) + + t.Run("allows request with correct API key", func(t *testing.T) { + srv, _, _, _ := newTestAPIServer(t, "secret-key") + + req := httptest.NewRequest(http.MethodGet, "/api/v1/stats", nil) + req.Header.Set("X-API-Key", "secret-key") + rr := httptest.NewRecorder() + + srv.mux.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusOK, rr.Code) + }) + + t.Run("allows all requests when no API key is configured", func(t *testing.T) { + srv, _, _, _ := newTestAPIServer(t, "") + + req := httptest.NewRequest(http.MethodGet, "/api/v1/stats", nil) + rr := httptest.NewRecorder() + + srv.mux.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusOK, rr.Code) + }) +} + +func TestOnlineHandler(t *testing.T) { + t.Run("returns JSON list of online users", func(t *testing.T) { + srv, _, clientMgr, _ := newTestAPIServer(t, "") + + clientMgr.clients = []*hotline.ClientConn{ + { + ID: hotline.ClientID{0, 1}, + RemoteAddr: "192.168.1.1:12345", + UserName: []byte("nick1"), + Account: &hotline.Account{Login: "user1"}, + }, + { + ID: hotline.ClientID{0, 2}, + RemoteAddr: "10.0.0.1:54321", + UserName: []byte("nick2"), + Account: &hotline.Account{Login: "user2"}, + }, + } + + req := httptest.NewRequest(http.MethodGet, "/api/v1/online", nil) + rr := httptest.NewRecorder() + + srv.mux.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusOK, rr.Code) + + var users []map[string]string + err := json.Unmarshal(rr.Body.Bytes(), &users) + require.NoError(t, err) + require.Len(t, users, 2) + + assert.Equal(t, "user1", users[0]["login"]) + assert.Equal(t, "nick1", users[0]["nickname"]) + assert.Equal(t, "192.168.1.1:12345", users[0]["ip"]) + + assert.Equal(t, "user2", users[1]["login"]) + assert.Equal(t, "nick2", users[1]["nickname"]) + assert.Equal(t, "10.0.0.1:54321", users[1]["ip"]) + }) + + t.Run("returns empty list when no users online", func(t *testing.T) { + srv, _, _, _ := newTestAPIServer(t, "") + + req := httptest.NewRequest(http.MethodGet, "/api/v1/online", nil) + rr := httptest.NewRecorder() + + srv.mux.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusOK, rr.Code) + // null JSON is acceptable for nil slice + body := strings.TrimSpace(rr.Body.String()) + assert.True(t, body == "null" || body == "[]", "expected null or empty array, got: %s", body) + }) +} + +func TestBanHandler(t *testing.T) { + tests := []struct { + name string + body string + wantStatus int + wantBanUser bool + wantBanNick bool + wantBanIP bool + banUserArg string + banNickArg string + banIPArg string + wantBodySubstr string + }{ + { + name: "ban by username", + body: `{"username":"baduser"}`, + wantStatus: http.StatusOK, + wantBanUser: true, + banUserArg: "baduser", + wantBodySubstr: "banned", + }, + { + name: "ban by nickname", + body: `{"nickname":"badnick"}`, + wantStatus: http.StatusOK, + wantBanNick: true, + banNickArg: "badnick", + wantBodySubstr: "banned", + }, + { + name: "ban by IP", + body: `{"ip":"1.2.3.4"}`, + wantStatus: http.StatusOK, + wantBanIP: true, + banIPArg: "1.2.3.4", + wantBodySubstr: "banned", + }, + { + name: "missing fields returns 400", + body: `{}`, + wantStatus: http.StatusBadRequest, + wantBodySubstr: "username, nickname, or ip required", + }, + { + name: "invalid JSON returns 400", + body: `not json`, + wantStatus: http.StatusBadRequest, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + srv, banMgr, _, _ := newTestAPIServer(t, "") + + req := httptest.NewRequest(http.MethodPost, "/api/v1/ban", strings.NewReader(tt.body)) + rr := httptest.NewRecorder() + + srv.mux.ServeHTTP(rr, req) + + assert.Equal(t, tt.wantStatus, rr.Code) + + if tt.wantBodySubstr != "" { + assert.Contains(t, rr.Body.String(), tt.wantBodySubstr) + } + + assert.Equal(t, tt.wantBanUser, banMgr.banUsernameCalled) + if tt.wantBanUser { + assert.Equal(t, tt.banUserArg, banMgr.banUsernameArg) + } + + assert.Equal(t, tt.wantBanNick, banMgr.banNicknameCalled) + if tt.wantBanNick { + assert.Equal(t, tt.banNickArg, banMgr.banNicknameArg) + } + + assert.Equal(t, tt.wantBanIP, banMgr.addCalled) + if tt.wantBanIP { + assert.Equal(t, tt.banIPArg, banMgr.addIP) + } + }) + } +} + +func TestUnbanHandler(t *testing.T) { + tests := []struct { + name string + body string + wantStatus int + wantUnbanUser bool + wantUnbanNick bool + wantUnbanIP bool + unbanUserArg string + unbanNickArg string + unbanIPArg string + wantBodySubstr string + }{ + { + name: "unban by username", + body: `{"username":"baduser"}`, + wantStatus: http.StatusOK, + wantUnbanUser: true, + unbanUserArg: "baduser", + wantBodySubstr: "unbanned", + }, + { + name: "unban by nickname", + body: `{"nickname":"badnick"}`, + wantStatus: http.StatusOK, + wantUnbanNick: true, + unbanNickArg: "badnick", + wantBodySubstr: "unbanned", + }, + { + name: "unban by IP", + body: `{"ip":"1.2.3.4"}`, + wantStatus: http.StatusOK, + wantUnbanIP: true, + unbanIPArg: "1.2.3.4", + wantBodySubstr: "unbanned", + }, + { + name: "missing fields returns 400", + body: `{}`, + wantStatus: http.StatusBadRequest, + wantBodySubstr: "username, nickname, or ip required", + }, + { + name: "invalid JSON returns 400", + body: `not json`, + wantStatus: http.StatusBadRequest, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + srv, banMgr, _, _ := newTestAPIServer(t, "") + + req := httptest.NewRequest(http.MethodPost, "/api/v1/unban", strings.NewReader(tt.body)) + rr := httptest.NewRecorder() + + srv.mux.ServeHTTP(rr, req) + + assert.Equal(t, tt.wantStatus, rr.Code) + + if tt.wantBodySubstr != "" { + assert.Contains(t, rr.Body.String(), tt.wantBodySubstr) + } + + assert.Equal(t, tt.wantUnbanUser, banMgr.unbanUsernameCalled) + if tt.wantUnbanUser { + assert.Equal(t, tt.unbanUserArg, banMgr.unbanUsernameArg) + } + + assert.Equal(t, tt.wantUnbanNick, banMgr.unbanNicknameCalled) + if tt.wantUnbanNick { + assert.Equal(t, tt.unbanNickArg, banMgr.unbanNicknameArg) + } + + assert.Equal(t, tt.wantUnbanIP, banMgr.unbanIPCalled) + if tt.wantUnbanIP { + assert.Equal(t, tt.unbanIPArg, banMgr.unbanIPArg) + } + }) + } +} + +func TestListBannedIPsHandler(t *testing.T) { + t.Run("returns JSON list of banned IPs", func(t *testing.T) { + srv, banMgr, _, _ := newTestAPIServer(t, "") + banMgr.bannedIPs = []string{"1.2.3.4", "5.6.7.8"} + + req := httptest.NewRequest(http.MethodGet, "/api/v1/banned/ips", nil) + rr := httptest.NewRecorder() + + srv.mux.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusOK, rr.Code) + + var ips []string + err := json.Unmarshal(rr.Body.Bytes(), &ips) + require.NoError(t, err) + assert.Equal(t, []string{"1.2.3.4", "5.6.7.8"}, ips) + }) + + t.Run("returns empty list when no IPs banned", func(t *testing.T) { + srv, banMgr, _, _ := newTestAPIServer(t, "") + banMgr.bannedIPs = []string{} + + req := httptest.NewRequest(http.MethodGet, "/api/v1/banned/ips", nil) + rr := httptest.NewRecorder() + + srv.mux.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusOK, rr.Code) + + var ips []string + err := json.Unmarshal(rr.Body.Bytes(), &ips) + require.NoError(t, err) + assert.Empty(t, ips) + }) +} + +func TestListBannedUsernamesHandler(t *testing.T) { + t.Run("returns JSON list of banned usernames", func(t *testing.T) { + srv, banMgr, _, _ := newTestAPIServer(t, "") + banMgr.bannedUsernames = []string{"user1", "user2"} + + req := httptest.NewRequest(http.MethodGet, "/api/v1/banned/usernames", nil) + rr := httptest.NewRecorder() + + srv.mux.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusOK, rr.Code) + + var usernames []string + err := json.Unmarshal(rr.Body.Bytes(), &usernames) + require.NoError(t, err) + assert.Equal(t, []string{"user1", "user2"}, usernames) + }) +} + +func TestListBannedNicknamesHandler(t *testing.T) { + t.Run("returns JSON list of banned nicknames", func(t *testing.T) { + srv, banMgr, _, _ := newTestAPIServer(t, "") + banMgr.bannedNicknames = []string{"nick1", "nick2"} + + req := httptest.NewRequest(http.MethodGet, "/api/v1/banned/nicknames", nil) + rr := httptest.NewRecorder() + + srv.mux.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusOK, rr.Code) + + var nicknames []string + err := json.Unmarshal(rr.Body.Bytes(), &nicknames) + require.NoError(t, err) + assert.Equal(t, []string{"nick1", "nick2"}, nicknames) + }) +} + +func TestReloadHandler(t *testing.T) { + t.Run("calls reload function and returns success", func(t *testing.T) { + srv, reloadCalled := newTestAPIServerWithReload(t, "") + + req := httptest.NewRequest(http.MethodPost, "/api/v1/reload", nil) + rr := httptest.NewRecorder() + + srv.mux.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusOK, rr.Code) + assert.Contains(t, rr.Body.String(), "config reloaded") + assert.True(t, *reloadCalled, "expected reload function to be called") + }) +} + +func TestShutdownHandler(t *testing.T) { + t.Run("empty body returns 400", func(t *testing.T) { + srv, _, _, _ := newTestAPIServer(t, "") + + req := httptest.NewRequest(http.MethodPost, "/api/v1/shutdown", strings.NewReader("")) + rr := httptest.NewRecorder() + + srv.mux.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusBadRequest, rr.Code) + }) + + t.Run("with body returns success", func(t *testing.T) { + srv, _, _, _ := newTestAPIServer(t, "") + + // Shutdown calls srv.hlServer.Shutdown in a goroutine, which calls + // SendAll on the server. We need outbox to be non-nil to avoid a panic. + // Since Shutdown runs in a goroutine, we just verify the HTTP response. + // The goroutine may panic but that's acceptable in test since we're only + // testing the HTTP layer. + srv.hlServer.Logger = slog.Default() + + req := httptest.NewRequest(http.MethodPost, "/api/v1/shutdown", strings.NewReader("Server is going down")) + rr := httptest.NewRecorder() + + srv.mux.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusOK, rr.Code) + assert.Contains(t, rr.Body.String(), "server shutting down") + }) +} + +func TestRenderStats(t *testing.T) { + t.Run("returns JSON stats", func(t *testing.T) { + srv, _, _, counter := newTestAPIServer(t, "") + counter.vals = map[string]interface{}{ + "connections": 42, + "downloads": 10, + } + + req := httptest.NewRequest(http.MethodGet, "/api/v1/stats", nil) + rr := httptest.NewRecorder() + + srv.mux.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusOK, rr.Code) + + var stats map[string]interface{} + err := json.Unmarshal(rr.Body.Bytes(), &stats) + require.NoError(t, err) + assert.Equal(t, float64(42), stats["connections"]) + assert.Equal(t, float64(10), stats["downloads"]) + }) + + t.Run("returns empty stats when no data", func(t *testing.T) { + srv, _, _, _ := newTestAPIServer(t, "") + + req := httptest.NewRequest(http.MethodGet, "/api/v1/stats", nil) + rr := httptest.NewRecorder() + + srv.mux.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusOK, rr.Code) + + var stats map[string]interface{} + err := json.Unmarshal(rr.Body.Bytes(), &stats) + require.NoError(t, err) + assert.Empty(t, stats) + }) +} diff --git a/internal/mobius/threaded_news_test.go b/internal/mobius/threaded_news_test.go index 608f5fb..032e57f 100644 --- a/internal/mobius/threaded_news_test.go +++ b/internal/mobius/threaded_news_test.go @@ -1,391 +1,364 @@ package mobius import ( - "fmt" + "encoding/binary" "os" - "path" - "sync" + "path/filepath" "testing" "github.com/jhalter/mobius/hotline" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" ) -type TestData struct { - Name string `yaml:"name"` - Value int `yaml:"value"` -} - -func TestLoadFromYAMLFile(t *testing.T) { - tests := []struct { - name string - fileName string - content string - wantData TestData - wantErr bool - }{ - { - name: "Valid YAML file", - fileName: "valid.yaml", - content: "name: Test\nvalue: 123\n", - wantData: TestData{Name: "Test", Value: 123}, - wantErr: false, - }, - { - name: "File not found", - fileName: "nonexistent.yaml", - content: "", - wantData: TestData{}, - wantErr: true, - }, - { - name: "Invalid YAML content", - fileName: "invalid.yaml", - content: "name: Test\nvalue: invalid_int\n", - wantData: TestData{}, - wantErr: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Setup: Create a temporary file with the provided content if content is not empty - if tt.content != "" { - err := os.WriteFile(tt.fileName, []byte(tt.content), 0644) - assert.NoError(t, err) - defer func() { _ = os.Remove(tt.fileName) }() // Cleanup the file after the test - } - - var data TestData - err := loadFromYAMLFile(tt.fileName, &data) - - if tt.wantErr { - assert.Error(t, err) - } else { - assert.NoError(t, err) - assert.Equal(t, tt.wantData, data) - } - }) - } -} - -func TestNewThreadedNewsYAML(t *testing.T) { - type args struct { - filePath string - } - tests := []struct { - name string - args args - want *ThreadedNewsYAML - wantErr assert.ErrorAssertionFunc - }{ - { - name: "Valid YAML file", - args: args{ - filePath: "test/config/ThreadedNews.yaml", - }, - want: &ThreadedNewsYAML{ - filePath: "test/config/ThreadedNews.yaml", - ThreadedNews: hotline.ThreadedNews{ - Categories: map[string]hotline.NewsCategoryListData15{ - "TestBundle": { - Type: hotline.NewsBundle, - Name: "TestBundle", - Articles: make(map[uint32]*hotline.NewsArtData), - SubCats: map[string]hotline.NewsCategoryListData15{ - "NestedBundle": { - Name: "NestedBundle", - Type: hotline.NewsBundle, - SubCats: map[string]hotline.NewsCategoryListData15{ - "NestedCat": { - Name: "NestedCat", - Type: hotline.NewsCategory, - Articles: make(map[uint32]*hotline.NewsArtData), - SubCats: make(map[string]hotline.NewsCategoryListData15), - }, - }, - Articles: make(map[uint32]*hotline.NewsArtData), - }, - }, - }, - "TestCat": { - Type: hotline.NewsCategory, - Name: "TestCat", - Articles: map[uint32]*hotline.NewsArtData{ - 1: { - Title: "TestArt", - Poster: "Halcyon 1.9.2", - Date: [8]byte{0x07, 0xe4, 0x00, 0x00, 0x00, 0xfe, 0xfc, 0xcc}, - NextArt: [4]byte{0, 0, 0, 2}, - FirstChildArt: [4]byte{0, 0, 0, 2}, - Data: "TestArt Body", - }, - 2: { - Title: "Re: TestArt", - Poster: "Halcyon 1.9.2", - Date: [8]byte{0x07, 0xe4, 0x00, 0x00, 0x00, 0xfe, 0xfc, 0xd8}, - PrevArt: [4]byte{0, 0, 0, 1}, - ParentArt: [4]byte{0, 0, 0, 1}, - NextArt: [4]byte{0, 0, 0, 3}, - Data: "I'm a reply", - }, - 3: { - Title: "TestArt 2", - Poster: "Halcyon 1.9.2", - Date: [8]byte{0x07, 0xe4, 0x00, 0x00, 0x00, 0xfe, 0xfd, 0x06}, - PrevArt: [4]byte{0, 0, 0, 2}, - Data: "Hello world", - }, - }, - SubCats: make(map[string]hotline.NewsCategoryListData15), - }, +// newTestThreadedNews creates a ThreadedNewsYAML backed by a YAML fixture file +// in a temporary directory. The fixture contains: +// - "General" (NewsCategory) with one article (ID 1) +// - "Archive" (NewsBundle) with subcategory "Old News" containing one article (ID 1) +func newTestThreadedNews(t *testing.T) *ThreadedNewsYAML { + t.Helper() + + tn := hotline.ThreadedNews{ + Categories: map[string]hotline.NewsCategoryListData15{ + "General": { + Name: "General", + Type: hotline.NewsCategory, + Articles: map[uint32]*hotline.NewsArtData{ + 1: { + Title: "Welcome", + Poster: "admin", + Data: "Hello world", }, }, + SubCats: make(map[string]hotline.NewsCategoryListData15), }, - wantErr: assert.NoError, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, err := NewThreadedNewsYAML(tt.args.filePath) - if !tt.wantErr(t, err, fmt.Sprintf("NewThreadedNewsYAML(%v)", tt.args.filePath)) { - return - } - assert.Equalf(t, tt.want, got, "NewThreadedNewsYAML(%v)", tt.args.filePath) - }) - } -} - -func TestThreadedNewsYAML_CreateGrouping(t *testing.T) { - // Create a temporary directory. - tmpDir, err := os.MkdirTemp("", "createGrouping") - if err != nil { - t.Fatalf("Failed to create temp directory: %v", err) - } - defer func() { _ = os.RemoveAll(tmpDir) }() // Clean up the temporary directory. - - // Path to the temporary ban file. - tmpFilePath := path.Join(tmpDir, "ThreadedNews.yaml") - - type fields struct { - ThreadedNews hotline.ThreadedNews - filePath string - } - type args struct { - newsPath []string - name string - t [2]byte - } - tests := []struct { - name string - fields fields - args args - wantErr assert.ErrorAssertionFunc - }{ - { - name: "new bundle", - fields: fields{ - ThreadedNews: hotline.ThreadedNews{ - Categories: map[string]hotline.NewsCategoryListData15{ - "": { - SubCats: make(map[string]hotline.NewsCategoryListData15), + "Archive": { + Name: "Archive", + Type: hotline.NewsBundle, + SubCats: map[string]hotline.NewsCategoryListData15{ + "Old News": { + Name: "Old News", + Type: hotline.NewsCategory, + Articles: map[uint32]*hotline.NewsArtData{ + 1: { + Title: "Legacy Post", + Poster: "olduser", + Data: "Old content", + }, }, + SubCats: make(map[string]hotline.NewsCategoryListData15), }, }, - filePath: tmpFilePath, }, - args: args{ - newsPath: []string{""}, - name: "new bundle", - t: hotline.NewsBundle, - }, - wantErr: assert.NoError, }, } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - n := &ThreadedNewsYAML{ - ThreadedNews: tt.fields.ThreadedNews, - filePath: tt.fields.filePath, - mu: sync.Mutex{}, - } - tt.wantErr(t, n.CreateGrouping(tt.args.newsPath, tt.args.name, tt.args.t), fmt.Sprintf("CreateGrouping(%v, %v, %v)", tt.args.newsPath, tt.args.name, tt.args.t)) - }) - } -} -func TestThreadedNewsYAML_CreateGrouping_rollback(t *testing.T) { - n := &ThreadedNewsYAML{ - ThreadedNews: hotline.ThreadedNews{ - Categories: map[string]hotline.NewsCategoryListData15{ - "Existing": { - Name: "Existing", - Type: hotline.NewsCategory, - Articles: make(map[uint32]*hotline.NewsArtData), - SubCats: make(map[string]hotline.NewsCategoryListData15), - }, - }, - }, - filePath: "/nonexistent/dir/ThreadedNews.yaml", - } + dir := t.TempDir() + fp := filepath.Join(dir, "ThreadedNews.yaml") - err := n.CreateGrouping(nil, "NewBundle", hotline.NewsBundle) - assert.Error(t, err) + data, err := yaml.Marshal(&tn) + require.NoError(t, err) + require.NoError(t, os.WriteFile(fp, data, 0644)) - // The new entry should have been rolled back. - _, exists := n.ThreadedNews.Categories["NewBundle"] - assert.False(t, exists, "new grouping should be removed on write failure") + result, err := NewThreadedNewsYAML(fp) + require.NoError(t, err) - // Existing entry should still be present. - _, exists = n.ThreadedNews.Categories["Existing"] - assert.True(t, exists, "existing grouping should be preserved") + return result } -func TestThreadedNewsYAML_DeleteNewsItem_rollback(t *testing.T) { - n := &ThreadedNewsYAML{ - ThreadedNews: hotline.ThreadedNews{ - Categories: map[string]hotline.NewsCategoryListData15{ - "ToDelete": { - Name: "ToDelete", - Type: hotline.NewsCategory, - Articles: make(map[uint32]*hotline.NewsArtData), - SubCats: make(map[string]hotline.NewsCategoryListData15), - }, - }, - }, - filePath: "/nonexistent/dir/ThreadedNews.yaml", - } +func TestThreadedNewsYAML_GetCategories(t *testing.T) { + tn := newTestThreadedNews(t) + + t.Run("returns top-level categories sorted by name", func(t *testing.T) { + cats := tn.GetCategories(nil) + require.Len(t, cats, 2) + assert.Equal(t, "Archive", cats[0].Name) + assert.Equal(t, "General", cats[1].Name) + }) + + t.Run("returns subcategories for a bundle", func(t *testing.T) { + cats := tn.GetCategories([]string{"Archive"}) + require.Len(t, cats, 1) + assert.Equal(t, "Old News", cats[0].Name) + }) + + t.Run("returns empty slice for path with no children", func(t *testing.T) { + cats := tn.GetCategories([]string{"Archive", "Old News"}) + assert.Empty(t, cats) + }) +} - err := n.DeleteNewsItem([]string{"ToDelete"}) - assert.Error(t, err) +func TestThreadedNewsYAML_NewsItem(t *testing.T) { + tn := newTestThreadedNews(t) - // The deleted entry should have been restored. - cat, exists := n.ThreadedNews.Categories["ToDelete"] - assert.True(t, exists, "deleted item should be restored on write failure") - assert.Equal(t, "ToDelete", cat.Name) + t.Run("retrieves top-level item", func(t *testing.T) { + item := tn.NewsItem([]string{"General"}) + assert.Equal(t, "General", item.Name) + assert.Equal(t, hotline.NewsCategory, item.Type) + }) + + t.Run("retrieves nested item", func(t *testing.T) { + item := tn.NewsItem([]string{"Archive", "Old News"}) + assert.Equal(t, "Old News", item.Name) + assert.Equal(t, hotline.NewsCategory, item.Type) + }) } -func TestThreadedNewsYAML_PostArticle_rollback(t *testing.T) { - // Set up a category with existing articles (mimics the test fixture). - n := &ThreadedNewsYAML{ - ThreadedNews: hotline.ThreadedNews{ - Categories: map[string]hotline.NewsCategoryListData15{ - "TestCat": { - Type: hotline.NewsCategory, - Name: "TestCat", - Articles: map[uint32]*hotline.NewsArtData{ - 1: { - Title: "TestArt", - NextArt: [4]byte{0, 0, 0, 2}, - FirstChildArt: [4]byte{0, 0, 0, 2}, - }, - 2: { - Title: "Re: TestArt", - PrevArt: [4]byte{0, 0, 0, 1}, - ParentArt: [4]byte{0, 0, 0, 1}, - NextArt: [4]byte{0, 0, 0, 3}, - }, - 3: { - Title: "TestArt 2", - PrevArt: [4]byte{0, 0, 0, 2}, - }, - }, - SubCats: make(map[string]hotline.NewsCategoryListData15), - }, - }, - }, - filePath: "/nonexistent/dir/ThreadedNews.yaml", - } +func TestThreadedNewsYAML_GetArticle(t *testing.T) { + tn := newTestThreadedNews(t) + + t.Run("returns existing article", func(t *testing.T) { + art := tn.GetArticle([]string{"General"}, 1) + require.NotNil(t, art) + assert.Equal(t, "Welcome", art.Title) + assert.Equal(t, "admin", art.Poster) + assert.Equal(t, "Hello world", art.Data) + }) + + t.Run("returns nil for missing article ID", func(t *testing.T) { + art := tn.GetArticle([]string{"General"}, 999) + assert.Nil(t, art) + }) + + t.Run("returns article in nested category", func(t *testing.T) { + art := tn.GetArticle([]string{"Archive", "Old News"}, 1) + require.NotNil(t, art) + assert.Equal(t, "Legacy Post", art.Title) + }) +} - // Snapshot state before. - origNextArt3 := n.ThreadedNews.Categories["TestCat"].Articles[3].NextArt +func TestThreadedNewsYAML_ListArticles(t *testing.T) { + tn := newTestThreadedNews(t) + + t.Run("lists articles for a category with articles", func(t *testing.T) { + artList, err := tn.ListArticles([]string{"General"}) + require.NoError(t, err) + assert.Equal(t, 1, artList.Count) + assert.NotEmpty(t, artList.NewsArtList) + }) + + t.Run("lists articles for nested category", func(t *testing.T) { + artList, err := tn.ListArticles([]string{"Archive", "Old News"}) + require.NoError(t, err) + assert.Equal(t, 1, artList.Count) + }) +} - newArticle := hotline.NewsArtData{ - Title: "New Article", - Poster: "tester", - } +func TestThreadedNewsYAML_PostArticle(t *testing.T) { + t.Run("posts article to empty category and persists", func(t *testing.T) { + tn := newTestThreadedNews(t) + + // Create a new empty category first. + err := tn.CreateGrouping(nil, "Fresh", hotline.NewsCategory) + require.NoError(t, err) + + article := hotline.NewsArtData{ + Title: "First Post", + Poster: "user1", + Data: "Content here", + } + err = tn.PostArticle([]string{"Fresh"}, 0, article) + require.NoError(t, err) + + // Verify article was stored with ID 1. + art := tn.GetArticle([]string{"Fresh"}, 1) + require.NotNil(t, art) + assert.Equal(t, "First Post", art.Title) + assert.Equal(t, "Content here", art.Data) + + // Verify persistence by reloading. + require.NoError(t, tn.Load()) + art = tn.GetArticle([]string{"Fresh"}, 1) + require.NotNil(t, art) + assert.Equal(t, "First Post", art.Title) + }) + + t.Run("posts second article and links to previous", func(t *testing.T) { + tn := newTestThreadedNews(t) + + // General already has article 1. Post a second one. + article := hotline.NewsArtData{ + Title: "Second Post", + Poster: "user2", + Data: "More content", + } + err := tn.PostArticle([]string{"General"}, 0, article) + require.NoError(t, err) + + // New article should be ID 2. + art := tn.GetArticle([]string{"General"}, 2) + require.NotNil(t, art) + assert.Equal(t, "Second Post", art.Title) + + // PrevArt of the new article should point to article 1. + prevID := binary.BigEndian.Uint32(art.PrevArt[:]) + assert.Equal(t, uint32(1), prevID) + + // NextArt of the first article should point to article 2. + first := tn.GetArticle([]string{"General"}, 1) + require.NotNil(t, first) + nextID := binary.BigEndian.Uint32(first.NextArt[:]) + assert.Equal(t, uint32(2), nextID) + }) + + t.Run("reply sets parent FirstChildArt", func(t *testing.T) { + tn := newTestThreadedNews(t) + + // Post a reply to article 1 in General. + reply := hotline.NewsArtData{ + Title: "Reply", + Poster: "replier", + Data: "I agree", + } + err := tn.PostArticle([]string{"General"}, 1, reply) + require.NoError(t, err) + + // The reply should be article 2. + replyArt := tn.GetArticle([]string{"General"}, 2) + require.NotNil(t, replyArt) + + // ParentArt should be set to 1. + parentID := binary.BigEndian.Uint32(replyArt.ParentArt[:]) + assert.Equal(t, uint32(1), parentID) + + // Parent article's FirstChildArt should now point to 2. + parent := tn.GetArticle([]string{"General"}, 1) + require.NotNil(t, parent) + firstChild := binary.BigEndian.Uint32(parent.FirstChildArt[:]) + assert.Equal(t, uint32(2), firstChild) + }) + + t.Run("returns error for empty news path", func(t *testing.T) { + tn := newTestThreadedNews(t) + err := tn.PostArticle(nil, 0, hotline.NewsArtData{}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "invalid news path") + }) +} - err := n.PostArticle([]string{"TestCat"}, 0, newArticle) - assert.Error(t, err) +func TestThreadedNewsYAML_DeleteArticle(t *testing.T) { + tn := newTestThreadedNews(t) - cat := n.ThreadedNews.Categories["TestCat"] + // Confirm article exists before deletion. + art := tn.GetArticle([]string{"General"}, 1) + require.NotNil(t, art) - // New article (ID 4) should not exist. - _, exists := cat.Articles[4] - assert.False(t, exists, "new article should be removed on write failure") + err := tn.DeleteArticle([]string{"General"}, 1, false) + require.NoError(t, err) - // Article 3's NextArt should be restored to its original value. - assert.Equal(t, origNextArt3, cat.Articles[3].NextArt, "previous article's NextArt should be restored") + // Article should be gone. + art = tn.GetArticle([]string{"General"}, 1) + assert.Nil(t, art) - // Should still have exactly 3 articles. - assert.Len(t, cat.Articles, 3) + // Verify persistence. + require.NoError(t, tn.Load()) + art = tn.GetArticle([]string{"General"}, 1) + assert.Nil(t, art) } -func TestThreadedNewsYAML_PostArticle_rollback_with_parent(t *testing.T) { - // Test that FirstChildArt is restored when posting a reply. - n := &ThreadedNewsYAML{ - ThreadedNews: hotline.ThreadedNews{ - Categories: map[string]hotline.NewsCategoryListData15{ - "TestCat": { - Type: hotline.NewsCategory, - Name: "TestCat", - Articles: map[uint32]*hotline.NewsArtData{ - 1: { - Title: "Parent Article", - // No FirstChildArt set — this is the first reply. - }, - }, - SubCats: make(map[string]hotline.NewsCategoryListData15), - }, - }, - }, - filePath: "/nonexistent/dir/ThreadedNews.yaml", - } +func TestThreadedNewsYAML_DeleteArticle_EmptyPath(t *testing.T) { + tn := newTestThreadedNews(t) + err := tn.DeleteArticle(nil, 1, false) + assert.Error(t, err) + assert.Contains(t, err.Error(), "invalid news path") +} - newArticle := hotline.NewsArtData{ - Title: "Reply", - Poster: "tester", - } +func TestThreadedNewsYAML_CreateGrouping(t *testing.T) { + t.Run("creates a new top-level bundle", func(t *testing.T) { + tn := newTestThreadedNews(t) + + err := tn.CreateGrouping(nil, "NewBundle", hotline.NewsBundle) + require.NoError(t, err) + + cats := tn.GetCategories(nil) + var names []string + for _, c := range cats { + names = append(names, c.Name) + } + assert.Contains(t, names, "NewBundle") + + // Verify the type. + item := tn.NewsItem([]string{"NewBundle"}) + assert.Equal(t, hotline.NewsBundle, item.Type) + + // Verify persistence. + require.NoError(t, tn.Load()) + item = tn.NewsItem([]string{"NewBundle"}) + assert.Equal(t, "NewBundle", item.Name) + }) + + t.Run("creates a nested category inside a bundle", func(t *testing.T) { + tn := newTestThreadedNews(t) + + err := tn.CreateGrouping([]string{"Archive"}, "Recent", hotline.NewsCategory) + require.NoError(t, err) + + cats := tn.GetCategories([]string{"Archive"}) + var names []string + for _, c := range cats { + names = append(names, c.Name) + } + assert.Contains(t, names, "Recent") + assert.Contains(t, names, "Old News") + + item := tn.NewsItem([]string{"Archive", "Recent"}) + assert.Equal(t, hotline.NewsCategory, item.Type) + }) +} - err := n.PostArticle([]string{"TestCat"}, 1, newArticle) - assert.Error(t, err) +func TestThreadedNewsYAML_DeleteNewsItem(t *testing.T) { + t.Run("deletes a top-level category", func(t *testing.T) { + tn := newTestThreadedNews(t) - cat := n.ThreadedNews.Categories["TestCat"] + err := tn.DeleteNewsItem([]string{"General"}) + require.NoError(t, err) - // FirstChildArt should be restored to zero. - assert.Equal(t, [4]byte{}, cat.Articles[1].FirstChildArt, "parent's FirstChildArt should be restored") + cats := tn.GetCategories(nil) + for _, c := range cats { + assert.NotEqual(t, "General", c.Name) + } - // New article should not exist. - _, exists := cat.Articles[2] - assert.False(t, exists, "reply article should be removed on write failure") -} + // Verify persistence. + require.NoError(t, tn.Load()) + cats = tn.GetCategories(nil) + for _, c := range cats { + assert.NotEqual(t, "General", c.Name) + } + }) -func TestThreadedNewsYAML_DeleteArticle_rollback(t *testing.T) { - n := &ThreadedNewsYAML{ - ThreadedNews: hotline.ThreadedNews{ - Categories: map[string]hotline.NewsCategoryListData15{ - "TestCat": { - Type: hotline.NewsCategory, - Name: "TestCat", - Articles: map[uint32]*hotline.NewsArtData{ - 1: { - Title: "Article to delete", - }, - }, - SubCats: make(map[string]hotline.NewsCategoryListData15), - }, - }, - }, - filePath: "/nonexistent/dir/ThreadedNews.yaml", - } + t.Run("deletes a nested subcategory", func(t *testing.T) { + tn := newTestThreadedNews(t) - err := n.DeleteArticle([]string{"TestCat"}, 1, false) - assert.Error(t, err) + err := tn.DeleteNewsItem([]string{"Archive", "Old News"}) + require.NoError(t, err) - cat := n.ThreadedNews.Categories["TestCat"] + cats := tn.GetCategories([]string{"Archive"}) + assert.Empty(t, cats) + }) +} - // Deleted article should be restored. - art, exists := cat.Articles[1] - assert.True(t, exists, "deleted article should be restored on write failure") - assert.Equal(t, "Article to delete", art.Title) +func TestThreadedNewsYAML_Load(t *testing.T) { + t.Run("loads from valid fixture file", func(t *testing.T) { + tn := newTestThreadedNews(t) + + // Verify data loaded correctly. + assert.NotNil(t, tn.ThreadedNews.Categories) + assert.Contains(t, tn.ThreadedNews.Categories, "General") + assert.Contains(t, tn.ThreadedNews.Categories, "Archive") + }) + + t.Run("returns error for missing file", func(t *testing.T) { + _, err := NewThreadedNewsYAML("/nonexistent/path/ThreadedNews.yaml") + assert.Error(t, err) + }) + + t.Run("returns error for invalid YAML", func(t *testing.T) { + dir := t.TempDir() + fp := filepath.Join(dir, "bad.yaml") + require.NoError(t, os.WriteFile(fp, []byte(":::not yaml[[["), 0644)) + + _, err := NewThreadedNewsYAML(fp) + assert.Error(t, err) + }) } diff --git a/internal/mobius/transaction_handlers_test.go b/internal/mobius/transaction_handlers_test.go index e9dfdfc..3af374e 100644 --- a/internal/mobius/transaction_handlers_test.go +++ b/internal/mobius/transaction_handlers_test.go @@ -4,6 +4,7 @@ import ( "cmp" "encoding/binary" "errors" + "fmt" "io" "io/fs" "log/slog" @@ -42,6 +43,12 @@ func (m *mockReadWriteSeeker) Seek(offset int64, whence int) (int64, error) { return args.Get(0).(int64), args.Error(1) } +type nopReadWriteCloser struct{} + +func (nopReadWriteCloser) Read([]byte) (int, error) { return 0, io.EOF } +func (nopReadWriteCloser) Write(p []byte) (int, error) { return len(p), nil } +func (nopReadWriteCloser) Close() error { return nil } + func NewTestLogger() *slog.Logger { return slog.New(slog.NewTextHandler(os.Stdout, nil)) } @@ -1575,6 +1582,117 @@ func TestHandleDeleteUser(t *testing.T) { }, }, }, + { + name: "when user is currently connected", + args: args{ + cc: &hotline.ClientConn{ + Logger: NewTestLogger(), + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + bits.Set(hotline.AccessDeleteUser) + return bits + }(), + }, + Server: &hotline.Server{ + TextDecoder: charmap.Macintosh.NewDecoder(), + TextEncoder: charmap.Macintosh.NewEncoder(), + Logger: NewTestLogger(), + AccountManager: func() *MockAccountManager { + m := MockAccountManager{} + m.On("Delete", "testuser").Return(nil) + return &m + }(), + ClientMgr: func() *hotline.MockClientMgr { + m := hotline.MockClientMgr{} + m.On("List").Return([]*hotline.ClientConn{ + { + ID: [2]byte{0, 2}, + Account: &hotline.Account{ + Login: "testuser", + }, + Connection: nopReadWriteCloser{}, + Server: &hotline.Server{ + ClientMgr: hotline.NewMemClientMgr(), + Logger: NewTestLogger(), + }, + }, + }) + return &m + }(), + }, + }, + t: hotline.NewTransaction( + hotline.TranDeleteUser, [2]byte{0, 1}, + hotline.NewField(hotline.FieldUserLogin, hotline.EncodeString([]byte("testuser"))), + ), + }, + wantRes: []hotline.Transaction{ + { + ClientID: [2]byte{0, 2}, + Type: hotline.TranServerMsg, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldData, []byte(ErrMsgAccountDeleted)), + hotline.NewField(hotline.FieldChatOptions, []byte{2}), + }, + }, + { + Flags: 0x00, + IsReply: 0x01, + Type: [2]byte{0, 0}, + Fields: []hotline.Field(nil), + }, + }, + }, + { + name: "when no matching connected clients", + args: args{ + cc: &hotline.ClientConn{ + Logger: NewTestLogger(), + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + bits.Set(hotline.AccessDeleteUser) + return bits + }(), + }, + Server: &hotline.Server{ + TextDecoder: charmap.Macintosh.NewDecoder(), + TextEncoder: charmap.Macintosh.NewEncoder(), + Logger: NewTestLogger(), + AccountManager: func() *MockAccountManager { + m := MockAccountManager{} + m.On("Delete", "testuser").Return(nil) + return &m + }(), + ClientMgr: func() *hotline.MockClientMgr { + m := hotline.MockClientMgr{} + m.On("List").Return([]*hotline.ClientConn{ + { + ID: [2]byte{0, 3}, + Account: &hotline.Account{ + Login: "otheruser", + }, + }, + }) + return &m + }(), + }, + }, + t: hotline.NewTransaction( + hotline.TranDeleteUser, [2]byte{0, 1}, + hotline.NewField(hotline.FieldUserLogin, hotline.EncodeString([]byte("testuser"))), + ), + }, + wantRes: []hotline.Transaction{ + { + Flags: 0x00, + IsReply: 0x01, + Type: [2]byte{0, 0}, + Fields: []hotline.Field(nil), + }, + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -1789,6 +1907,90 @@ func TestHandleNewUser(t *testing.T) { }, }, }, + { + name: "when account is created successfully", + args: args{ + cc: &hotline.ClientConn{ + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + for i := 0; i < 64; i++ { + bits.Set(i) + } + return bits + }(), + }, + Server: &hotline.Server{ + TextDecoder: charmap.Macintosh.NewDecoder(), + TextEncoder: charmap.Macintosh.NewEncoder(), + AccountManager: func() *MockAccountManager { + m := MockAccountManager{} + m.On("Get", "userB").Return((*hotline.Account)(nil)) + m.On("Create", mock.Anything).Return(nil) + return &m + }(), + }, + }, + t: hotline.NewTransaction( + hotline.TranNewUser, [2]byte{0, 1}, + hotline.NewField(hotline.FieldUserLogin, hotline.EncodeString([]byte("userB"))), + hotline.NewField(hotline.FieldUserName, []byte("User B")), + hotline.NewField(hotline.FieldUserPassword, []byte("pass")), + hotline.NewField( + hotline.FieldUserAccess, + func() []byte { + var bits hotline.AccessBitmap + bits.Set(hotline.AccessCreateUser) + return bits[:] + }(), + ), + ), + }, + wantRes: []hotline.Transaction{ + { + IsReply: 0x01, + }, + }, + }, + { + name: "when account already exists", + args: args{ + cc: &hotline.ClientConn{ + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + bits.Set(hotline.AccessCreateUser) + return bits + }(), + }, + Server: &hotline.Server{ + TextDecoder: charmap.Macintosh.NewDecoder(), + TextEncoder: charmap.Macintosh.NewEncoder(), + AccountManager: func() *MockAccountManager { + m := MockAccountManager{} + m.On("Get", "userB").Return(&hotline.Account{Login: "userB"}) + return &m + }(), + }, + }, + t: hotline.NewTransaction( + hotline.TranNewUser, [2]byte{0, 1}, + hotline.NewField(hotline.FieldUserLogin, hotline.EncodeString([]byte("userB"))), + hotline.NewField(hotline.FieldUserName, []byte("User B")), + hotline.NewField(hotline.FieldUserPassword, []byte("pass")), + hotline.NewField(hotline.FieldUserAccess, make([]byte, 8)), + ), + }, + wantRes: []hotline.Transaction{ + { + IsReply: 0x01, + ErrorCode: [4]byte{0, 0, 0, 1}, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldError, []byte(fmt.Sprintf(ErrMsgAccountExistsTemplate, "userB"))), + }, + }, + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -2208,131 +2410,318 @@ func TestHandleUpdateUser(t *testing.T) { }, }, }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - gotRes := HandleUpdateUser(tt.args.cc, &tt.args.t) - TranAssertEqual(t, tt.wantRes, gotRes) - }) - } -} - -func TestHandleDelNewsArt(t *testing.T) { - type args struct { - cc *hotline.ClientConn - t hotline.Transaction - } - tests := []struct { - name string - args args - wantRes []hotline.Transaction - }{ { - name: "without required permission", + name: "when action is delete user with permission", args: args{ cc: &hotline.ClientConn{ + Logger: NewTestLogger(), + Server: &hotline.Server{ + TextDecoder: charmap.Macintosh.NewDecoder(), + TextEncoder: charmap.Macintosh.NewEncoder(), + Logger: NewTestLogger(), + AccountManager: func() *MockAccountManager { + m := MockAccountManager{} + m.On("Delete", "testuser").Return(nil) + return &m + }(), + ClientMgr: func() *hotline.MockClientMgr { + m := hotline.MockClientMgr{} + m.On("List").Return([]*hotline.ClientConn{ + { + Account: &hotline.Account{ + Login: "testuser", + }, + Connection: nopReadWriteCloser{}, + Server: &hotline.Server{ + ClientMgr: hotline.NewMemClientMgr(), + Logger: NewTestLogger(), + }, + }, + }) + return &m + }(), + }, Account: &hotline.Account{ Access: func() hotline.AccessBitmap { var bits hotline.AccessBitmap + bits.Set(hotline.AccessDeleteUser) return bits }(), }, }, t: hotline.NewTransaction( - hotline.TranDelNewsArt, + hotline.TranUpdateUser, [2]byte{0, 0}, + hotline.NewField(hotline.FieldData, []byte{ + 0x00, 0x01, // 1 subfield (delete) + 0x00, 0x65, // FieldData = 101 + 0x00, 0x08, // length + 0x8b, 0x9a, 0x8c, 0x8b, 0x8a, 0x8c, 0x9a, 0x8d, // obfuscated "testuser" + }), ), }, wantRes: []hotline.Transaction{ { - IsReply: 0x01, - ErrorCode: [4]byte{0, 0, 0, 1}, + Type: hotline.TranServerMsg, Fields: []hotline.Field{ - hotline.NewField(hotline.FieldError, []byte("You are not allowed to delete news articles.")), + hotline.NewField(hotline.FieldData, []byte(ErrMsgAccountDeleted)), + hotline.NewField(hotline.FieldChatOptions, []byte{0}), }, }, + { + IsReply: 0x01, + }, }, }, { - name: "when DeleteArticle returns an error", + name: "when action is delete user and Delete returns error", args: args{ cc: &hotline.ClientConn{ + Logger: NewTestLogger(), + Server: &hotline.Server{ + TextDecoder: charmap.Macintosh.NewDecoder(), + TextEncoder: charmap.Macintosh.NewEncoder(), + Logger: NewTestLogger(), + AccountManager: func() *MockAccountManager { + m := MockAccountManager{} + m.On("Delete", "testuser").Return(errors.New("disk error")) + return &m + }(), + }, Account: &hotline.Account{ Access: func() hotline.AccessBitmap { var bits hotline.AccessBitmap - bits.Set(hotline.AccessNewsDeleteArt) + bits.Set(hotline.AccessDeleteUser) return bits }(), }, + }, + t: hotline.NewTransaction( + hotline.TranUpdateUser, + [2]byte{0, 0}, + hotline.NewField(hotline.FieldData, []byte{ + 0x00, 0x01, + 0x00, 0x65, + 0x00, 0x08, + 0x8b, 0x9a, 0x8c, 0x8b, 0x8a, 0x8c, 0x9a, 0x8d, + }), + ), + }, + wantRes: nil, + }, + { + name: "when action is modify existing user with password", + args: args{ + cc: &hotline.ClientConn{ Logger: NewTestLogger(), - ID: [2]byte{0, 1}, Server: &hotline.Server{ TextDecoder: charmap.Macintosh.NewDecoder(), TextEncoder: charmap.Macintosh.NewEncoder(), - ThreadedNewsMgr: func() *hotline.MockThreadNewsMgr { - m := hotline.MockThreadNewsMgr{} - m.On("DeleteArticle", []string{"test"}, uint32(1), false).Return(errors.New("write error")) + Logger: NewTestLogger(), + AccountManager: func() *MockAccountManager { + m := MockAccountManager{} + m.On("Get", "bbb").Return(&hotline.Account{ + Login: "bbb", + Name: "old name", + }) + m.On("Update", mock.Anything, "bbb").Return(nil) return &m }(), }, + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + bits.Set(hotline.AccessModifyUser) + return bits + }(), + }, }, t: hotline.NewTransaction( - hotline.TranDelNewsArt, [2]byte{0, 1}, - hotline.NewField(hotline.FieldNewsPath, - []byte{ - 0, 1, - 0, 0, - 4, - 0x74, 0x65, 0x73, 0x74, - }, - ), - hotline.NewField(hotline.FieldNewsArtID, []byte{0, 0, 0, 1}), + hotline.TranUpdateUser, + [2]byte{0, 0}, + hotline.NewField(hotline.FieldData, []byte{ + 0x00, 0x04, // field count + + 0x00, 0x69, // FieldUserLogin = 105 + 0x00, 0x03, + 0x9d, 0x9d, 0x9d, + + 0x00, 0x6a, // FieldUserPassword = 106 + 0x00, 0x03, + 0x9c, 0x9c, 0x9c, + + 0x00, 0x66, // FieldUserName = 102 + 0x00, 0x03, + 0x61, 0x61, 0x61, + + 0x00, 0x6e, // FieldUserAccess = 110 + 0x00, 0x08, + 0x60, 0x70, 0x0c, 0x20, 0x03, 0x80, 0x00, 0x00, + }), ), }, wantRes: []hotline.Transaction{ { - ClientID: [2]byte{0, 1}, - IsReply: 0x01, - ErrorCode: [4]byte{0, 0, 0, 1}, - Fields: []hotline.Field{ - hotline.NewField(hotline.FieldError, []byte("Error deleting news article.")), + IsReply: 0x01, + }, + }, + }, + { + name: "when action is modify existing user with password cleared", + args: args{ + cc: &hotline.ClientConn{ + Logger: NewTestLogger(), + Server: &hotline.Server{ + TextDecoder: charmap.Macintosh.NewDecoder(), + TextEncoder: charmap.Macintosh.NewEncoder(), + Logger: NewTestLogger(), + AccountManager: func() *MockAccountManager { + m := MockAccountManager{} + m.On("Get", "bbb").Return(&hotline.Account{ + Login: "bbb", + Name: "old name", + }) + m.On("Update", mock.Anything, "bbb").Return(nil) + return &m + }(), + }, + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + bits.Set(hotline.AccessModifyUser) + return bits + }(), }, }, + t: hotline.NewTransaction( + hotline.TranUpdateUser, + [2]byte{0, 0}, + hotline.NewField(hotline.FieldData, []byte{ + 0x00, 0x03, // field count (3 subfields, no password) + + 0x00, 0x69, // FieldUserLogin = 105 + 0x00, 0x03, + 0x9d, 0x9d, 0x9d, + + 0x00, 0x66, // FieldUserName = 102 + 0x00, 0x03, + 0x61, 0x61, 0x61, + + 0x00, 0x6e, // FieldUserAccess = 110 + 0x00, 0x08, + 0x60, 0x70, 0x0c, 0x20, 0x03, 0x80, 0x00, 0x00, + }), + ), + }, + wantRes: []hotline.Transaction{ + { + IsReply: 0x01, + }, }, }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - gotRes := HandleDelNewsArt(tt.args.cc, &tt.args.t) - TranAssertEqual(t, tt.wantRes, gotRes) - }) - } -} + { + name: "when action is create user with valid permissions", + args: args{ + cc: &hotline.ClientConn{ + Logger: NewTestLogger(), + Server: &hotline.Server{ + TextDecoder: charmap.Macintosh.NewDecoder(), + TextEncoder: charmap.Macintosh.NewEncoder(), + Logger: NewTestLogger(), + AccountManager: func() *MockAccountManager { + m := MockAccountManager{} + m.On("Get", "bbb").Return((*hotline.Account)(nil)) + m.On("Create", mock.Anything).Return(nil) + return &m + }(), + }, + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + bits.Set(hotline.AccessCreateUser) + // Set all bits that the new account will have + for i := 0; i < 64; i++ { + bits.Set(i) + } + return bits + }(), + }, + }, + t: hotline.NewTransaction( + hotline.TranUpdateUser, + [2]byte{0, 0}, + hotline.NewField(hotline.FieldData, []byte{ + 0x00, 0x04, // field count -func TestHandleDisconnectUser(t *testing.T) { - type args struct { - cc *hotline.ClientConn - t hotline.Transaction - } - tests := []struct { - name string - args args - wantRes []hotline.Transaction - }{ + 0x00, 0x69, // FieldUserLogin = 105 + 0x00, 0x03, + 0x9d, 0x9d, 0x9d, + + 0x00, 0x6a, // FieldUserPassword = 106 + 0x00, 0x03, + 0x9c, 0x9c, 0x9c, + + 0x00, 0x66, // FieldUserName = 102 + 0x00, 0x03, + 0x61, 0x61, 0x61, + + 0x00, 0x6e, // FieldUserAccess = 110 + 0x00, 0x08, + 0x60, 0x70, 0x0c, 0x20, 0x03, 0x80, 0x00, 0x00, + }), + ), + }, + wantRes: []hotline.Transaction{ + { + IsReply: 0x01, + }, + }, + }, { - name: "without required permission", + name: "when action is create user with escalated privileges", args: args{ cc: &hotline.ClientConn{ + Logger: NewTestLogger(), + Server: &hotline.Server{ + TextDecoder: charmap.Macintosh.NewDecoder(), + TextEncoder: charmap.Macintosh.NewEncoder(), + Logger: NewTestLogger(), + AccountManager: func() *MockAccountManager { + m := MockAccountManager{} + m.On("Get", "bbb").Return((*hotline.Account)(nil)) + return &m + }(), + }, Account: &hotline.Account{ Access: func() hotline.AccessBitmap { var bits hotline.AccessBitmap + bits.Set(hotline.AccessCreateUser) return bits }(), }, }, t: hotline.NewTransaction( - hotline.TranDelNewsArt, + hotline.TranUpdateUser, [2]byte{0, 0}, + hotline.NewField(hotline.FieldData, []byte{ + 0x00, 0x04, // field count + + 0x00, 0x69, // FieldUserLogin = 105 + 0x00, 0x03, + 0x9d, 0x9d, 0x9d, + + 0x00, 0x6a, // FieldUserPassword = 106 + 0x00, 0x03, + 0x9c, 0x9c, 0x9c, + + 0x00, 0x66, // FieldUserName = 102 + 0x00, 0x03, + 0x61, 0x61, 0x61, + + 0x00, 0x6e, // FieldUserAccess = 110 + 0x00, 0x08, + 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, // AccessDisconUser set (bit 22) + }), ), }, wantRes: []hotline.Transaction{ @@ -2340,46 +2729,60 @@ func TestHandleDisconnectUser(t *testing.T) { IsReply: 0x01, ErrorCode: [4]byte{0, 0, 0, 1}, Fields: []hotline.Field{ - hotline.NewField(hotline.FieldError, []byte("You are not allowed to disconnect users.")), + hotline.NewField(hotline.FieldError, []byte("Cannot create account with more access than yourself.")), }, }, }, }, { - name: "when target user has 'cannot be disconnected' priv", + name: "when action is create user and Create returns error", args: args{ cc: &hotline.ClientConn{ + Logger: NewTestLogger(), Server: &hotline.Server{ TextDecoder: charmap.Macintosh.NewDecoder(), TextEncoder: charmap.Macintosh.NewEncoder(), - ClientMgr: func() *hotline.MockClientMgr { - m := hotline.MockClientMgr{} - m.On("Get", hotline.ClientID{0x0, 0x1}).Return(&hotline.ClientConn{ - Account: &hotline.Account{ - Login: "unnamed", - Access: func() hotline.AccessBitmap { - var bits hotline.AccessBitmap - bits.Set(hotline.AccessCannotBeDiscon) - return bits - }(), - }, - }, - ) + Logger: NewTestLogger(), + AccountManager: func() *MockAccountManager { + m := MockAccountManager{} + m.On("Get", "bbb").Return((*hotline.Account)(nil)) + m.On("Create", mock.Anything).Return(errors.New("account exists")) return &m }(), }, Account: &hotline.Account{ Access: func() hotline.AccessBitmap { var bits hotline.AccessBitmap - bits.Set(hotline.AccessDisconUser) + // Set all bits so privilege check passes + for i := 0; i < 64; i++ { + bits.Set(i) + } return bits }(), }, }, t: hotline.NewTransaction( - hotline.TranDelNewsArt, + hotline.TranUpdateUser, [2]byte{0, 0}, - hotline.NewField(hotline.FieldUserID, []byte{0, 1}), + hotline.NewField(hotline.FieldData, []byte{ + 0x00, 0x04, // field count + + 0x00, 0x69, // FieldUserLogin = 105 + 0x00, 0x03, + 0x9d, 0x9d, 0x9d, + + 0x00, 0x6a, // FieldUserPassword = 106 + 0x00, 0x03, + 0x9c, 0x9c, 0x9c, + + 0x00, 0x66, // FieldUserName = 102 + 0x00, 0x03, + 0x61, 0x61, 0x61, + + 0x00, 0x6e, // FieldUserAccess = 110 + 0x00, 0x08, + 0x60, 0x70, 0x0c, 0x20, 0x03, 0x80, 0x00, 0x00, + }), ), }, wantRes: []hotline.Transaction{ @@ -2387,7 +2790,7 @@ func TestHandleDisconnectUser(t *testing.T) { IsReply: 0x01, ErrorCode: [4]byte{0, 0, 0, 1}, Fields: []hotline.Field{ - hotline.NewField(hotline.FieldError, []byte("unnamed is not allowed to be disconnected.")), + hotline.NewField(hotline.FieldError, []byte(ErrMsgAccountExists)), }, }, }, @@ -2395,13 +2798,13 @@ func TestHandleDisconnectUser(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - gotRes := HandleDisconnectUser(tt.args.cc, &tt.args.t) + gotRes := HandleUpdateUser(tt.args.cc, &tt.args.t) TranAssertEqual(t, tt.wantRes, gotRes) }) } } -func TestHandleSendInstantMsg(t *testing.T) { +func TestHandleDelNewsArt(t *testing.T) { type args struct { cc *hotline.ClientConn t hotline.Transaction @@ -2432,181 +2835,288 @@ func TestHandleSendInstantMsg(t *testing.T) { IsReply: 0x01, ErrorCode: [4]byte{0, 0, 0, 1}, Fields: []hotline.Field{ - hotline.NewField(hotline.FieldError, []byte("You are not allowed to send private messages.")), + hotline.NewField(hotline.FieldError, []byte("You are not allowed to delete news articles.")), }, }, }, }, { - name: "when client 1 sends a message to client 2", + name: "when DeleteArticle returns an error", args: args{ cc: &hotline.ClientConn{ Account: &hotline.Account{ Access: func() hotline.AccessBitmap { var bits hotline.AccessBitmap - bits.Set(hotline.AccessSendPrivMsg) + bits.Set(hotline.AccessNewsDeleteArt) return bits }(), }, - ID: [2]byte{0, 1}, - UserName: []byte("User1"), + Logger: NewTestLogger(), + ID: [2]byte{0, 1}, Server: &hotline.Server{ TextDecoder: charmap.Macintosh.NewDecoder(), TextEncoder: charmap.Macintosh.NewEncoder(), - ClientMgr: func() *hotline.MockClientMgr { - m := hotline.MockClientMgr{} - m.On("Get", hotline.ClientID{0x0, 0x2}).Return(&hotline.ClientConn{ - AutoReply: []byte(nil), - Flags: [2]byte{0, 0}, - }, - ) + ThreadedNewsMgr: func() *hotline.MockThreadNewsMgr { + m := hotline.MockThreadNewsMgr{} + m.On("DeleteArticle", []string{"test"}, uint32(1), false).Return(errors.New("write error")) return &m }(), }, }, t: hotline.NewTransaction( - hotline.TranSendInstantMsg, - [2]byte{0, 1}, - hotline.NewField(hotline.FieldData, []byte("hai")), - hotline.NewField(hotline.FieldUserID, []byte{0, 2}), + hotline.TranDelNewsArt, [2]byte{0, 1}, + hotline.NewField(hotline.FieldNewsPath, + []byte{ + 0, 1, + 0, 0, + 4, + 0x74, 0x65, 0x73, 0x74, + }, + ), + hotline.NewField(hotline.FieldNewsArtID, []byte{0, 0, 0, 1}), ), }, wantRes: []hotline.Transaction{ - hotline.NewTransaction( - hotline.TranServerMsg, - [2]byte{0, 2}, - hotline.NewField(hotline.FieldData, []byte("hai")), - hotline.NewField(hotline.FieldUserName, []byte("User1")), - hotline.NewField(hotline.FieldUserID, []byte{0, 1}), - hotline.NewField(hotline.FieldOptions, []byte{0, 1}), - ), { - ClientID: [2]byte{0, 1}, - IsReply: 0x01, - Fields: []hotline.Field(nil), + ClientID: [2]byte{0, 1}, + IsReply: 0x01, + ErrorCode: [4]byte{0, 0, 0, 1}, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldError, []byte("Error deleting news article.")), + }, }, }, }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotRes := HandleDelNewsArt(tt.args.cc, &tt.args.t) + TranAssertEqual(t, tt.wantRes, gotRes) + }) + } +} + +func TestHandleDisconnectUser(t *testing.T) { + type args struct { + cc *hotline.ClientConn + t hotline.Transaction + } + tests := []struct { + name string + args args + wantRes []hotline.Transaction + }{ { - name: "when client 2 has autoreply enabled", + name: "without required permission", args: args{ cc: &hotline.ClientConn{ Account: &hotline.Account{ Access: func() hotline.AccessBitmap { var bits hotline.AccessBitmap - bits.Set(hotline.AccessSendPrivMsg) return bits }(), }, - ID: [2]byte{0, 1}, - UserName: []byte("User1"), + }, + t: hotline.NewTransaction( + hotline.TranDelNewsArt, + [2]byte{0, 0}, + ), + }, + wantRes: []hotline.Transaction{ + { + IsReply: 0x01, + ErrorCode: [4]byte{0, 0, 0, 1}, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldError, []byte("You are not allowed to disconnect users.")), + }, + }, + }, + }, + { + name: "when target user has 'cannot be disconnected' priv", + args: args{ + cc: &hotline.ClientConn{ Server: &hotline.Server{ TextDecoder: charmap.Macintosh.NewDecoder(), TextEncoder: charmap.Macintosh.NewEncoder(), ClientMgr: func() *hotline.MockClientMgr { m := hotline.MockClientMgr{} - m.On("Get", hotline.ClientID{0x0, 0x2}).Return(&hotline.ClientConn{ - Flags: [2]byte{0, 0}, - ID: [2]byte{0, 2}, - UserName: []byte("User2"), - AutoReply: []byte("autohai"), - }) + m.On("Get", hotline.ClientID{0x0, 0x1}).Return(&hotline.ClientConn{ + Account: &hotline.Account{ + Login: "unnamed", + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + bits.Set(hotline.AccessCannotBeDiscon) + return bits + }(), + }, + }, + ) return &m }(), }, + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + bits.Set(hotline.AccessDisconUser) + return bits + }(), + }, }, t: hotline.NewTransaction( - hotline.TranSendInstantMsg, - [2]byte{0, 1}, - hotline.NewField(hotline.FieldData, []byte("hai")), - hotline.NewField(hotline.FieldUserID, []byte{0, 2}), + hotline.TranDelNewsArt, + [2]byte{0, 0}, + hotline.NewField(hotline.FieldUserID, []byte{0, 1}), ), }, wantRes: []hotline.Transaction{ - hotline.NewTransaction( - hotline.TranServerMsg, - [2]byte{0, 2}, - hotline.NewField(hotline.FieldData, []byte("hai")), - hotline.NewField(hotline.FieldUserName, []byte("User1")), - hotline.NewField(hotline.FieldUserID, []byte{0, 1}), - hotline.NewField(hotline.FieldOptions, []byte{0, 1}), - ), - hotline.NewTransaction( - hotline.TranServerMsg, - [2]byte{0, 1}, - hotline.NewField(hotline.FieldData, []byte("autohai")), - hotline.NewField(hotline.FieldUserName, []byte("User2")), - hotline.NewField(hotline.FieldUserID, []byte{0, 2}), - hotline.NewField(hotline.FieldOptions, []byte{0, 1}), - ), { - ClientID: [2]byte{0, 1}, - IsReply: 0x01, - Fields: []hotline.Field(nil), + IsReply: 0x01, + ErrorCode: [4]byte{0, 0, 0, 1}, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldError, []byte("unnamed is not allowed to be disconnected.")), + }, }, }, }, { - name: "when client 2 has refuse private messages enabled", + name: "with temporary ban option", args: args{ cc: &hotline.ClientConn{ + Logger: NewTestLogger(), + Server: &hotline.Server{ + TextDecoder: charmap.Macintosh.NewDecoder(), + TextEncoder: charmap.Macintosh.NewEncoder(), + ClientMgr: func() *hotline.MockClientMgr { + m := hotline.MockClientMgr{} + m.On("Get", hotline.ClientID{0x0, 0x1}).Return(&hotline.ClientConn{ + ID: hotline.ClientID{0x0, 0x1}, + UserName: []byte("baduser"), + RemoteAddr: "10.0.0.1:12345", + Connection: nopReadWriteCloser{}, + Account: &hotline.Account{ + Login: "baduser", + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + return bits + }(), + }, + Server: &hotline.Server{ + ClientMgr: hotline.NewMemClientMgr(), + Logger: NewTestLogger(), + }, + }) + return &m + }(), + BanList: func() *hotline.MockBanMgr { + m := hotline.MockBanMgr{} + m.On("Add", "10.0.0.1", mock.AnythingOfType("*time.Time")).Return(nil) + return &m + }(), + }, Account: &hotline.Account{ Access: func() hotline.AccessBitmap { var bits hotline.AccessBitmap - bits.Set(hotline.AccessSendPrivMsg) + bits.Set(hotline.AccessDisconUser) return bits }(), }, - ID: [2]byte{0, 1}, - UserName: []byte("User1"), + }, + t: hotline.NewTransaction( + hotline.TranDisconnectUser, + [2]byte{0, 0}, + hotline.NewField(hotline.FieldUserID, []byte{0, 1}), + hotline.NewField(hotline.FieldOptions, []byte{0, 1}), + ), + }, + wantRes: []hotline.Transaction{ + { + ClientID: [2]byte{0, 1}, + Type: hotline.TranServerMsg, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldData, []byte(ErrMsgTemporaryBan)), + hotline.NewField(hotline.FieldChatOptions, []byte{0, 0}), + }, + }, + { + IsReply: 0x01, + }, + }, + }, + { + name: "with permanent ban option", + args: args{ + cc: &hotline.ClientConn{ + Logger: NewTestLogger(), Server: &hotline.Server{ TextDecoder: charmap.Macintosh.NewDecoder(), TextEncoder: charmap.Macintosh.NewEncoder(), ClientMgr: func() *hotline.MockClientMgr { m := hotline.MockClientMgr{} - m.On("Get", hotline.ClientID{0x0, 0x2}).Return(&hotline.ClientConn{ - Flags: [2]byte{255, 255}, - ID: [2]byte{0, 2}, - UserName: []byte("User2"), - }, - ) + m.On("Get", hotline.ClientID{0x0, 0x1}).Return(&hotline.ClientConn{ + ID: hotline.ClientID{0x0, 0x1}, + UserName: []byte("baduser"), + RemoteAddr: "10.0.0.2:12345", + Connection: nopReadWriteCloser{}, + Account: &hotline.Account{ + Login: "baduser", + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + return bits + }(), + }, + Server: &hotline.Server{ + ClientMgr: hotline.NewMemClientMgr(), + Logger: NewTestLogger(), + }, + }) + return &m + }(), + BanList: func() *hotline.MockBanMgr { + m := hotline.MockBanMgr{} + m.On("Add", "10.0.0.2", (*time.Time)(nil)).Return(nil) return &m }(), }, + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + bits.Set(hotline.AccessDisconUser) + return bits + }(), + }, }, t: hotline.NewTransaction( - hotline.TranSendInstantMsg, - [2]byte{0, 1}, - hotline.NewField(hotline.FieldData, []byte("hai")), - hotline.NewField(hotline.FieldUserID, []byte{0, 2}), + hotline.TranDisconnectUser, + [2]byte{0, 0}, + hotline.NewField(hotline.FieldUserID, []byte{0, 1}), + hotline.NewField(hotline.FieldOptions, []byte{0, 2}), ), }, wantRes: []hotline.Transaction{ - hotline.NewTransaction( - hotline.TranServerMsg, - [2]byte{0, 1}, - hotline.NewField(hotline.FieldData, []byte("User2 does not accept private messages.")), - hotline.NewField(hotline.FieldUserName, []byte("User2")), - hotline.NewField(hotline.FieldUserID, []byte{0, 2}), - hotline.NewField(hotline.FieldOptions, []byte{0, 2}), - ), { ClientID: [2]byte{0, 1}, - IsReply: 0x01, - Fields: []hotline.Field(nil), + Type: hotline.TranServerMsg, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldData, []byte(ErrMsgPermanentBan)), + hotline.NewField(hotline.FieldChatOptions, []byte{0, 0}), + }, + }, + { + IsReply: 0x01, }, }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - gotRes := HandleSendInstantMsg(tt.args.cc, &tt.args.t) + gotRes := HandleDisconnectUser(tt.args.cc, &tt.args.t) TranAssertEqual(t, tt.wantRes, gotRes) }) } } -func TestHandleDeleteFile(t *testing.T) { +func TestHandleSendInstantMsg(t *testing.T) { type args struct { cc *hotline.ClientConn t hotline.Transaction @@ -2617,7 +3127,7 @@ func TestHandleDeleteFile(t *testing.T) { wantRes []hotline.Transaction }{ { - name: "when user does not have required permission to delete a folder", + name: "without required permission", args: args{ cc: &hotline.ClientConn{ Account: &hotline.Account{ @@ -2626,27 +3136,232 @@ func TestHandleDeleteFile(t *testing.T) { return bits }(), }, - Server: &hotline.Server{ - TextDecoder: charmap.Macintosh.NewDecoder(), - TextEncoder: charmap.Macintosh.NewEncoder(), - Config: hotline.Config{ - FileRoot: func() string { - return "/fakeRoot/Files" - }(), - }, - FS: func() *hotline.MockFileStore { - mfi := &hotline.MockFileInfo{} - mfi.On("Mode").Return(fs.FileMode(0)) - mfi.On("Size").Return(int64(100)) - mfi.On("ModTime").Return(time.Parse(time.Layout, time.Layout)) - mfi.On("IsDir").Return(false) - mfi.On("Name").Return("testfile") - - mfs := &hotline.MockFileStore{} - mfs.On("Stat", "/fakeRoot/Files/aaa/testfile").Return(mfi, nil) - mfs.On("Stat", "/fakeRoot/Files/aaa/.info_testfile").Return(nil, errors.New("err")) - mfs.On("Stat", "/fakeRoot/Files/aaa/.rsrc_testfile").Return(nil, errors.New("err")) - + }, + t: hotline.NewTransaction( + hotline.TranDelNewsArt, + [2]byte{0, 0}, + ), + }, + wantRes: []hotline.Transaction{ + { + IsReply: 0x01, + ErrorCode: [4]byte{0, 0, 0, 1}, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldError, []byte("You are not allowed to send private messages.")), + }, + }, + }, + }, + { + name: "when client 1 sends a message to client 2", + args: args{ + cc: &hotline.ClientConn{ + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + bits.Set(hotline.AccessSendPrivMsg) + return bits + }(), + }, + ID: [2]byte{0, 1}, + UserName: []byte("User1"), + Server: &hotline.Server{ + TextDecoder: charmap.Macintosh.NewDecoder(), + TextEncoder: charmap.Macintosh.NewEncoder(), + ClientMgr: func() *hotline.MockClientMgr { + m := hotline.MockClientMgr{} + m.On("Get", hotline.ClientID{0x0, 0x2}).Return(&hotline.ClientConn{ + AutoReply: []byte(nil), + Flags: [2]byte{0, 0}, + }, + ) + return &m + }(), + }, + }, + t: hotline.NewTransaction( + hotline.TranSendInstantMsg, + [2]byte{0, 1}, + hotline.NewField(hotline.FieldData, []byte("hai")), + hotline.NewField(hotline.FieldUserID, []byte{0, 2}), + ), + }, + wantRes: []hotline.Transaction{ + hotline.NewTransaction( + hotline.TranServerMsg, + [2]byte{0, 2}, + hotline.NewField(hotline.FieldData, []byte("hai")), + hotline.NewField(hotline.FieldUserName, []byte("User1")), + hotline.NewField(hotline.FieldUserID, []byte{0, 1}), + hotline.NewField(hotline.FieldOptions, []byte{0, 1}), + ), + { + ClientID: [2]byte{0, 1}, + IsReply: 0x01, + Fields: []hotline.Field(nil), + }, + }, + }, + { + name: "when client 2 has autoreply enabled", + args: args{ + cc: &hotline.ClientConn{ + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + bits.Set(hotline.AccessSendPrivMsg) + return bits + }(), + }, + ID: [2]byte{0, 1}, + UserName: []byte("User1"), + Server: &hotline.Server{ + TextDecoder: charmap.Macintosh.NewDecoder(), + TextEncoder: charmap.Macintosh.NewEncoder(), + ClientMgr: func() *hotline.MockClientMgr { + m := hotline.MockClientMgr{} + m.On("Get", hotline.ClientID{0x0, 0x2}).Return(&hotline.ClientConn{ + Flags: [2]byte{0, 0}, + ID: [2]byte{0, 2}, + UserName: []byte("User2"), + AutoReply: []byte("autohai"), + }) + return &m + }(), + }, + }, + t: hotline.NewTransaction( + hotline.TranSendInstantMsg, + [2]byte{0, 1}, + hotline.NewField(hotline.FieldData, []byte("hai")), + hotline.NewField(hotline.FieldUserID, []byte{0, 2}), + ), + }, + wantRes: []hotline.Transaction{ + hotline.NewTransaction( + hotline.TranServerMsg, + [2]byte{0, 2}, + hotline.NewField(hotline.FieldData, []byte("hai")), + hotline.NewField(hotline.FieldUserName, []byte("User1")), + hotline.NewField(hotline.FieldUserID, []byte{0, 1}), + hotline.NewField(hotline.FieldOptions, []byte{0, 1}), + ), + hotline.NewTransaction( + hotline.TranServerMsg, + [2]byte{0, 1}, + hotline.NewField(hotline.FieldData, []byte("autohai")), + hotline.NewField(hotline.FieldUserName, []byte("User2")), + hotline.NewField(hotline.FieldUserID, []byte{0, 2}), + hotline.NewField(hotline.FieldOptions, []byte{0, 1}), + ), + { + ClientID: [2]byte{0, 1}, + IsReply: 0x01, + Fields: []hotline.Field(nil), + }, + }, + }, + { + name: "when client 2 has refuse private messages enabled", + args: args{ + cc: &hotline.ClientConn{ + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + bits.Set(hotline.AccessSendPrivMsg) + return bits + }(), + }, + ID: [2]byte{0, 1}, + UserName: []byte("User1"), + Server: &hotline.Server{ + TextDecoder: charmap.Macintosh.NewDecoder(), + TextEncoder: charmap.Macintosh.NewEncoder(), + ClientMgr: func() *hotline.MockClientMgr { + m := hotline.MockClientMgr{} + m.On("Get", hotline.ClientID{0x0, 0x2}).Return(&hotline.ClientConn{ + Flags: [2]byte{255, 255}, + ID: [2]byte{0, 2}, + UserName: []byte("User2"), + }, + ) + return &m + }(), + }, + }, + t: hotline.NewTransaction( + hotline.TranSendInstantMsg, + [2]byte{0, 1}, + hotline.NewField(hotline.FieldData, []byte("hai")), + hotline.NewField(hotline.FieldUserID, []byte{0, 2}), + ), + }, + wantRes: []hotline.Transaction{ + hotline.NewTransaction( + hotline.TranServerMsg, + [2]byte{0, 1}, + hotline.NewField(hotline.FieldData, []byte("User2 does not accept private messages.")), + hotline.NewField(hotline.FieldUserName, []byte("User2")), + hotline.NewField(hotline.FieldUserID, []byte{0, 2}), + hotline.NewField(hotline.FieldOptions, []byte{0, 2}), + ), + { + ClientID: [2]byte{0, 1}, + IsReply: 0x01, + Fields: []hotline.Field(nil), + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotRes := HandleSendInstantMsg(tt.args.cc, &tt.args.t) + TranAssertEqual(t, tt.wantRes, gotRes) + }) + } +} + +func TestHandleDeleteFile(t *testing.T) { + type args struct { + cc *hotline.ClientConn + t hotline.Transaction + } + tests := []struct { + name string + args args + wantRes []hotline.Transaction + }{ + { + name: "when user does not have required permission to delete a folder", + args: args{ + cc: &hotline.ClientConn{ + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + return bits + }(), + }, + Server: &hotline.Server{ + TextDecoder: charmap.Macintosh.NewDecoder(), + TextEncoder: charmap.Macintosh.NewEncoder(), + Config: hotline.Config{ + FileRoot: func() string { + return "/fakeRoot/Files" + }(), + }, + FS: func() *hotline.MockFileStore { + mfi := &hotline.MockFileInfo{} + mfi.On("Mode").Return(fs.FileMode(0)) + mfi.On("Size").Return(int64(100)) + mfi.On("ModTime").Return(time.Parse(time.Layout, time.Layout)) + mfi.On("IsDir").Return(false) + mfi.On("Name").Return("testfile") + + mfs := &hotline.MockFileStore{} + mfs.On("Stat", "/fakeRoot/Files/aaa/testfile").Return(mfi, nil) + mfs.On("Stat", "/fakeRoot/Files/aaa/.info_testfile").Return(nil, errors.New("err")) + mfs.On("Stat", "/fakeRoot/Files/aaa/.rsrc_testfile").Return(nil, errors.New("err")) + return mfs }(), //Accounts: map[string]*Account{}, @@ -3939,96 +4654,93 @@ func TestHandleGetNewsArtNameList(t *testing.T) { }, }, }, - //{ - // name: "when user has required access", - // args: args{ - // cc: &hotline.ClientConn{ - // Account: &hotline.Account{ - // Access: func() hotline.AccessBitmap { - // var bits hotline.AccessBitmap - // bits.Set(hotline.AccessNewsReadArt) - // return bits - // }(), - // }, - // Server: &hotline.Server{ - // TextDecoder: charmap.Macintosh.NewDecoder(), - // TextEncoder: charmap.Macintosh.NewEncoder(), - // ThreadedNewsMgr: func() *mockThreadNewsMgr { - // m := mockThreadNewsMgr{} - // m.On("ListArticles", []string{"Example Category"}).Return(NewsArtListData{ - // Name: []byte("testTitle"), - // NewsArtList: []byte{}, - // }) - // return &m - // }(), - // }, - // }, - // t: NewTransaction( - // TranGetNewsArtNameList, - // [2]byte{0, 1}, - // // 00000000 00 01 00 00 10 45 78 61 6d 70 6c 65 20 43 61 74 |.....Example Cat| - // // 00000010 65 67 6f 72 79 |egory| - // NewField(hotline.FieldNewsPath, []byte{ - // 0x00, 0x01, 0x00, 0x00, 0x10, 0x45, 0x78, 0x61, 0x6d, 0x70, 0x6c, 0x65, 0x20, 0x43, 0x61, 0x74, 0x65, 0x67, 0x6f, 0x72, 0x79, - // }), - // ), - // }, - // wantRes: []hotline.Transaction{ - // { - // IsReply: 0x01, - // Fields: []hotline.Field{ - // NewField(hotline.FieldNewsArtListData, []byte{ - // 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, - // 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, - // 0x09, 0x74, 0x65, 0x73, 0x74, 0x54, 0x69, 0x74, 0x6c, 0x65, 0x0a, 0x74, 0x65, 0x73, 0x74, 0x50, - // 0x6f, 0x73, 0x74, 0x65, 0x72, 0x0a, 0x74, 0x65, 0x78, 0x74, 0x2f, 0x70, 0x6c, 0x61, 0x69, 0x6e, - // 0x00, 0x08, - // }, - // ), - // }, - // }, - // }, - //}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - gotRes := HandleGetNewsArtNameList(tt.args.cc, &tt.args.t) - - TranAssertEqual(t, tt.wantRes, gotRes) - }) - } -} - -func TestHandleNewNewsFldr(t *testing.T) { - type args struct { - cc *hotline.ClientConn - t hotline.Transaction - } - tests := []struct { - name string - args args - wantRes []hotline.Transaction - }{ { - name: "when user does not have required permission", + name: "when user has required access", args: args{ cc: &hotline.ClientConn{ Account: &hotline.Account{ Access: func() hotline.AccessBitmap { var bits hotline.AccessBitmap + bits.Set(hotline.AccessNewsReadArt) return bits }(), }, Server: &hotline.Server{ TextDecoder: charmap.Macintosh.NewDecoder(), TextEncoder: charmap.Macintosh.NewEncoder(), - //Accounts: map[string]*Account{}, + ThreadedNewsMgr: func() *hotline.MockThreadNewsMgr { + m := hotline.MockThreadNewsMgr{} + m.On("ListArticles", []string{"Example Category"}).Return(hotline.NewsArtListData{ + Name: []byte{}, + Description: []byte{}, + NewsArtList: []byte{}, + }, nil) + return &m + }(), }, }, t: hotline.NewTransaction( - hotline.TranGetNewsArtNameList, [2]byte{0, 1}, - ), - }, + hotline.TranGetNewsArtNameList, + [2]byte{0, 1}, + hotline.NewField(hotline.FieldNewsPath, []byte{ + 0x00, 0x01, 0x00, 0x00, 0x10, 0x45, 0x78, 0x61, 0x6d, 0x70, 0x6c, 0x65, 0x20, 0x43, 0x61, 0x74, 0x65, 0x67, 0x6f, 0x72, 0x79, + }), + ), + }, + wantRes: []hotline.Transaction{ + { + IsReply: 0x01, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldNewsArtListData, []byte{ + 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, + 0x00, + 0x00, + }), + }, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotRes := HandleGetNewsArtNameList(tt.args.cc, &tt.args.t) + + TranAssertEqual(t, tt.wantRes, gotRes) + }) + } +} + +func TestHandleNewNewsFldr(t *testing.T) { + type args struct { + cc *hotline.ClientConn + t hotline.Transaction + } + tests := []struct { + name string + args args + wantRes []hotline.Transaction + }{ + { + name: "when user does not have required permission", + args: args{ + cc: &hotline.ClientConn{ + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + return bits + }(), + }, + Server: &hotline.Server{ + TextDecoder: charmap.Macintosh.NewDecoder(), + TextEncoder: charmap.Macintosh.NewEncoder(), + //Accounts: map[string]*Account{}, + }, + }, + t: hotline.NewTransaction( + hotline.TranGetNewsArtNameList, [2]byte{0, 1}, + ), + }, wantRes: []hotline.Transaction{ { Flags: 0x00, @@ -4143,24 +4855,32 @@ func TestHandleNewNewsFldr(t *testing.T) { } func TestHandleDownloadBanner(t *testing.T) { - type args struct { - cc *hotline.ClientConn - t hotline.Transaction - } - tests := []struct { - name string - args args - wantRes []hotline.Transaction - }{ - // TODO: Add test cases. - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - gotRes := HandleDownloadBanner(tt.args.cc, &tt.args.t) + t.Run("returns banner transfer info", func(t *testing.T) { + cc := &hotline.ClientConn{ + ClientFileTransferMgr: hotline.NewClientFileTransferMgr(), + Server: &hotline.Server{ + Banner: []byte("test-banner-data"), + FileTransferMgr: hotline.NewMemFileTransferMgr(), + }, + } + tran := hotline.NewTransaction(hotline.TranDownloadBanner, [2]byte{0, 1}) - assert.Equalf(t, tt.wantRes, gotRes, "HandleDownloadBanner(%v, %v)", tt.args.cc, &tt.args.t) - }) - } + gotRes := HandleDownloadBanner(cc, &tran) + + assert.Len(t, gotRes, 1) + assert.Equal(t, byte(0x01), gotRes[0].IsReply) + + // Verify transfer size field matches banner length + transferSizeField := gotRes[0].GetField(hotline.FieldTransferSize) + assert.NotNil(t, transferSizeField) + gotSize := binary.BigEndian.Uint32(transferSizeField.Data) + assert.Equal(t, uint32(len("test-banner-data")), gotSize) + + // Verify refnum field is present + refNumField := gotRes[0].GetField(hotline.FieldRefNum) + assert.NotNil(t, refNumField) + assert.Len(t, refNumField.Data, 4) + }) } func TestHandlePostNewsArt(t *testing.T) { @@ -4321,6 +5041,79 @@ func TestHandleUploadFolder(t *testing.T) { }, }, }, + { + name: "when user has upload access but not upload anywhere and path is not upload dir", + args: args{ + cc: &hotline.ClientConn{ + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + bits.Set(hotline.AccessUploadFolder) + return bits + }(), + }, + }, + t: hotline.NewTransaction( + hotline.TranUploadFldr, [2]byte{0, 1}, + hotline.NewField(hotline.FieldFileName, []byte("myFolder")), + hotline.NewField(hotline.FieldFilePath, []byte{ + 0x00, 0x01, + 0x00, 0x00, + 0x05, + 0x46, 0x69, 0x6c, 0x65, 0x73, // "Files" + }), + ), + }, + wantRes: []hotline.Transaction{ + { + IsReply: 0x01, + ErrorCode: [4]byte{0, 0, 0, 1}, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldError, []byte("Cannot accept upload of the folder \"myFolder\" because you are only allowed to upload to the \"Uploads\" folder.")), + }, + }, + }, + }, + { + name: "when user has upload access and upload anywhere permission", + args: args{ + cc: &hotline.ClientConn{ + ClientFileTransferMgr: hotline.NewClientFileTransferMgr(), + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + bits.Set(hotline.AccessUploadFolder) + bits.Set(hotline.AccessUploadAnywhere) + return bits + }(), + }, + Server: &hotline.Server{ + FileTransferMgr: hotline.NewMemFileTransferMgr(), + Config: hotline.Config{FileRoot: "/fakeRoot"}, + }, + }, + t: hotline.NewTransaction( + hotline.TranUploadFldr, [2]byte{0, 1}, + hotline.NewField(hotline.FieldFileName, []byte("myFolder")), + hotline.NewField(hotline.FieldFilePath, []byte{ + 0x00, 0x01, + 0x00, 0x00, + 0x05, + 0x46, 0x69, 0x6c, 0x65, 0x73, // "Files" + }), + hotline.NewField(hotline.FieldTransferSize, []byte{0, 0, 0x10, 0}), + hotline.NewField(hotline.FieldFolderItemCount, []byte{0, 0, 0, 5}), + ), + }, + wantRes: []hotline.Transaction{ + { + IsReply: 0x01, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldRefNum, []byte{0, 0, 0, 0}), // placeholder, TranAssertEqual strips this + }, + }, + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -4623,98 +5416,584 @@ func TestHandleRejectChatInvite(t *testing.T) { hotline.NewField(hotline.FieldData, []byte("RejectUser declined invitation to chat")), }, }, - // Notification to member 2 + // Notification to member 2 + { + ClientID: [2]byte{0, 2}, + Type: [2]byte{0, 0x6A}, // TranChatMsg + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldData, []byte("RejectUser declined invitation to chat")), + }, + }, + }, + }, + { + name: "rejects invite to empty chat", + args: args{ + cc: &hotline.ClientConn{ + UserName: []byte("LoneRejecter"), + ID: [2]byte{0, 1}, + Server: &hotline.Server{ + TextDecoder: charmap.Macintosh.NewDecoder(), + TextEncoder: charmap.Macintosh.NewEncoder(), + ChatMgr: func() *hotline.MockChatManager { + m := hotline.MockChatManager{} + // Mock empty chat (no members) + m.On("Members", hotline.ChatID{0, 0, 0, 2}).Return([]*hotline.ClientConn{}) + return &m + }(), + }, + }, + t: hotline.Transaction{ + Type: [2]byte{0, 0x72}, // TranRejectChatInvite + ID: [4]byte{0, 0, 0, 2}, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldChatID, []byte{0, 0, 0, 2}), + }, + }, + }, + want: []hotline.Transaction{ + // No notifications (no members to notify) + }, + }, + { + name: "rejects invite with single member", + args: args{ + cc: &hotline.ClientConn{ + UserName: []byte("Shy"), + ID: [2]byte{0, 2}, + Server: &hotline.Server{ + TextDecoder: charmap.Macintosh.NewDecoder(), + TextEncoder: charmap.Macintosh.NewEncoder(), + ChatMgr: func() *hotline.MockChatManager { + m := hotline.MockChatManager{} + // Mock chat with single member + m.On("Members", hotline.ChatID{0, 0, 0, 3}).Return([]*hotline.ClientConn{ + { + UserName: []byte("OnlyMember"), + ID: [2]byte{0, 1}, + }, + }) + return &m + }(), + }, + }, + t: hotline.Transaction{ + Type: [2]byte{0, 0x72}, // TranRejectChatInvite + ID: [4]byte{0, 0, 0, 3}, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldChatID, []byte{0, 0, 0, 3}), + }, + }, + }, + want: []hotline.Transaction{ + // Notification to the single member + { + ClientID: [2]byte{0, 1}, + Type: [2]byte{0, 0x6A}, // TranChatMsg + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldData, []byte("Shy declined invitation to chat")), + }, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := HandleRejectChatInvite(tt.args.cc, &tt.args.t) + if !TranAssertEqual(t, tt.want, got) { + t.Errorf("HandleRejectChatInvite() got = %v, want %v", got, tt.want) + } + }) + } +} + +func TestHandleInviteToChat(t *testing.T) { + type args struct { + cc *hotline.ClientConn + t hotline.Transaction + } + tests := []struct { + name string + args args + want []hotline.Transaction + }{ + { + name: "invites user to chat successfully", + args: args{ + cc: &hotline.ClientConn{ + UserName: []byte("Inviter"), + ID: [2]byte{0, 1}, + Icon: []byte{0, 2}, + Flags: [2]byte{0, 3}, + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + access := hotline.AccessBitmap{} + access.Set(hotline.AccessOpenChat) + return access + }(), + }, + }, + t: hotline.Transaction{ + Type: [2]byte{0, 0x71}, // TranInviteToChat + ID: [4]byte{0, 0, 0, 1}, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldUserID, []byte{0, 5}), + hotline.NewField(hotline.FieldChatID, []byte{0, 0, 0, 10}), + }, + }, + }, + want: []hotline.Transaction{ + // Invite sent to target user + { + ClientID: [2]byte{0, 5}, + Type: [2]byte{0, 0x71}, // TranInviteToChat + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldUserName, []byte("Inviter")), + hotline.NewField(hotline.FieldUserID, []byte{0, 1}), + }, + }, + // Reply to inviting user + { + ClientID: [2]byte{0, 1}, + IsReply: 0x01, + Type: [2]byte{0, 0}, // Reply type is [0, 0] + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldUserName, []byte("Inviter")), + hotline.NewField(hotline.FieldUserID, []byte{0, 1}), + hotline.NewField(hotline.FieldUserIconID, []byte{0, 2}), + hotline.NewField(hotline.FieldUserFlags, []byte{0, 3}), + }, + }, + }, + }, + { + name: "returns error when user lacks permission", + args: args{ + cc: &hotline.ClientConn{ + UserName: []byte("NoPermUser"), + ID: [2]byte{0, 2}, + Account: &hotline.Account{ + Access: hotline.AccessBitmap{}, // No permissions + }, + }, + t: hotline.Transaction{ + Type: [2]byte{0, 0x71}, // TranInviteToChat + ID: [4]byte{0, 0, 0, 2}, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldUserID, []byte{0, 3}), + hotline.NewField(hotline.FieldChatID, []byte{0, 0, 0, 5}), + }, + }, + }, + want: []hotline.Transaction{ + // Error reply to requesting user + { + ClientID: [2]byte{0, 2}, + IsReply: 0x01, + Type: [2]byte{0, 0}, // Reply type is [0, 0] + ErrorCode: [4]byte{0, 0, 0, 1}, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldError, []byte("You are not allowed to request private chat.")), + }, + }, + }, + }, + { + name: "invites to different chat room", + args: args{ + cc: &hotline.ClientConn{ + UserName: []byte("Host"), + ID: [2]byte{0, 10}, + Icon: []byte{0, 15}, + Flags: [2]byte{0, 20}, + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + access := hotline.AccessBitmap{} + access.Set(hotline.AccessOpenChat) + return access + }(), + }, + }, + t: hotline.Transaction{ + Type: [2]byte{0, 0x71}, // TranInviteToChat + ID: [4]byte{0, 0, 0, 3}, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldUserID, []byte{0, 99}), + hotline.NewField(hotline.FieldChatID, []byte{0, 0, 0, 25}), + }, + }, + }, + want: []hotline.Transaction{ + // Invite sent to target user + { + ClientID: [2]byte{0, 99}, + Type: [2]byte{0, 0x71}, // TranInviteToChat + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldUserName, []byte("Host")), + hotline.NewField(hotline.FieldUserID, []byte{0, 10}), + }, + }, + // Reply to inviting user + { + ClientID: [2]byte{0, 10}, + IsReply: 0x01, + Type: [2]byte{0, 0}, // Reply type is [0, 0] + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldUserName, []byte("Host")), + hotline.NewField(hotline.FieldUserID, []byte{0, 10}), + hotline.NewField(hotline.FieldUserIconID, []byte{0, 15}), + hotline.NewField(hotline.FieldUserFlags, []byte{0, 20}), + }, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := HandleInviteToChat(tt.args.cc, &tt.args.t) + if !TranAssertEqual(t, tt.want, got) { + t.Errorf("HandleInviteToChat() got = %v, want %v", got, tt.want) + } + }) + } +} + +func TestHandleSetUser(t *testing.T) { + type args struct { + cc *hotline.ClientConn + t hotline.Transaction + } + tests := []struct { + name string + args args + wantRes []hotline.Transaction + }{ + { + name: "when user does not have required permission", + args: args{ + cc: &hotline.ClientConn{ + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + return bits + }(), + }, + Server: &hotline.Server{TextDecoder: charmap.Macintosh.NewDecoder(), TextEncoder: charmap.Macintosh.NewEncoder()}, + }, + t: hotline.NewTransaction( + hotline.TranSetUser, [2]byte{0, 1}, + ), + }, + wantRes: []hotline.Transaction{ + { + Flags: 0x00, + IsReply: 0x01, + Type: [2]byte{0, 0}, + ErrorCode: [4]byte{0, 0, 0, 1}, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldError, []byte(ErrMsgNotAllowedModifyAccounts)), + }, + }, + }, + }, + { + name: "when account is not found", + args: args{ + cc: &hotline.ClientConn{ + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + bits.Set(hotline.AccessModifyUser) + return bits + }(), + }, + Logger: NewTestLogger(), + ID: [2]byte{0, 1}, + Server: &hotline.Server{ + TextDecoder: charmap.Macintosh.NewDecoder(), + TextEncoder: charmap.Macintosh.NewEncoder(), + AccountManager: func() *MockAccountManager { + m := MockAccountManager{} + m.On("Get", "testuser").Return((*hotline.Account)(nil)) + return &m + }(), + }, + }, + t: hotline.NewTransaction( + hotline.TranSetUser, [2]byte{0, 1}, + hotline.NewField(hotline.FieldUserLogin, hotline.EncodeString([]byte("testuser"))), + hotline.NewField(hotline.FieldUserName, []byte("Test User")), + hotline.NewField(hotline.FieldUserAccess, make([]byte, 8)), + ), + }, + wantRes: []hotline.Transaction{ + { + ClientID: [2]byte{0, 1}, + IsReply: 0x01, + ErrorCode: [4]byte{0, 0, 0, 1}, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldError, []byte(ErrMsgAccountNotFound)), + }, + }, + }, + }, + { + name: "when Update returns an error", + args: args{ + cc: &hotline.ClientConn{ + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + bits.Set(hotline.AccessModifyUser) + return bits + }(), + }, + Logger: NewTestLogger(), + ID: [2]byte{0, 1}, + Server: &hotline.Server{ + TextDecoder: charmap.Macintosh.NewDecoder(), + TextEncoder: charmap.Macintosh.NewEncoder(), + AccountManager: func() *MockAccountManager { + m := MockAccountManager{} + m.On("Get", "testuser").Return(&hotline.Account{ + Login: "testuser", + Name: "Old Name", + }) + m.On("Update", mock.Anything, "testuser").Return(errors.New("disk full")) + return &m + }(), + }, + }, + t: hotline.NewTransaction( + hotline.TranSetUser, [2]byte{0, 1}, + hotline.NewField(hotline.FieldUserLogin, hotline.EncodeString([]byte("testuser"))), + hotline.NewField(hotline.FieldUserName, []byte("New Name")), + hotline.NewField(hotline.FieldUserAccess, make([]byte, 8)), + ), + }, + wantRes: []hotline.Transaction{ + { + ClientID: [2]byte{0, 1}, + IsReply: 0x01, + ErrorCode: [4]byte{0, 0, 0, 1}, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldError, []byte(ErrMsgUpdateAccount)), + }, + }, + }, + }, + { + name: "when update is successful with password change", + args: args{ + cc: &hotline.ClientConn{ + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + bits.Set(hotline.AccessModifyUser) + return bits + }(), + }, + Logger: NewTestLogger(), + ID: [2]byte{0, 1}, + Server: &hotline.Server{ + TextDecoder: charmap.Macintosh.NewDecoder(), + TextEncoder: charmap.Macintosh.NewEncoder(), + AccountManager: func() *MockAccountManager { + m := MockAccountManager{} + m.On("Get", "testuser").Return(&hotline.Account{ + Login: "testuser", + Name: "Old Name", + }) + m.On("Update", mock.Anything, "testuser").Return(nil) + return &m + }(), + ClientMgr: func() *hotline.MockClientMgr { + m := hotline.MockClientMgr{} + m.On("List").Return([]*hotline.ClientConn{}) + return &m + }(), + }, + }, + t: hotline.NewTransaction( + hotline.TranSetUser, [2]byte{0, 1}, + hotline.NewField(hotline.FieldUserLogin, hotline.EncodeString([]byte("testuser"))), + hotline.NewField(hotline.FieldUserName, []byte("New Name")), + hotline.NewField(hotline.FieldUserPassword, []byte("newpass")), + hotline.NewField(hotline.FieldUserAccess, make([]byte, 8)), + ), + }, + wantRes: []hotline.Transaction{ + { + ClientID: [2]byte{0, 1}, + IsReply: 0x01, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotRes := HandleSetUser(tt.args.cc, &tt.args.t) + + TranAssertEqual(t, tt.wantRes, gotRes) + }) + } +} + +func TestHandleNewNewsCat(t *testing.T) { + type args struct { + cc *hotline.ClientConn + t hotline.Transaction + } + tests := []struct { + name string + args args + wantRes []hotline.Transaction + }{ + { + name: "when user does not have required permission", + args: args{ + cc: &hotline.ClientConn{ + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + return bits + }(), + }, + Server: &hotline.Server{TextDecoder: charmap.Macintosh.NewDecoder(), TextEncoder: charmap.Macintosh.NewEncoder()}, + }, + t: hotline.NewTransaction( + hotline.TranNewNewsCat, [2]byte{0, 1}, + ), + }, + wantRes: []hotline.Transaction{ { - ClientID: [2]byte{0, 2}, - Type: [2]byte{0, 0x6A}, // TranChatMsg + Flags: 0x00, + IsReply: 0x01, + Type: [2]byte{0, 0}, + ErrorCode: [4]byte{0, 0, 0, 1}, Fields: []hotline.Field{ - hotline.NewField(hotline.FieldData, []byte("RejectUser declined invitation to chat")), + hotline.NewField(hotline.FieldError, []byte(ErrMsgNotAllowedCreateNewsCategories)), }, }, }, }, { - name: "rejects invite to empty chat", + name: "when CreateGrouping returns an error", args: args{ cc: &hotline.ClientConn{ - UserName: []byte("LoneRejecter"), - ID: [2]byte{0, 1}, + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + bits.Set(hotline.AccessNewsCreateCat) + return bits + }(), + }, + Logger: NewTestLogger(), + ID: [2]byte{0, 1}, Server: &hotline.Server{ TextDecoder: charmap.Macintosh.NewDecoder(), TextEncoder: charmap.Macintosh.NewEncoder(), - ChatMgr: func() *hotline.MockChatManager { - m := hotline.MockChatManager{} - // Mock empty chat (no members) - m.On("Members", hotline.ChatID{0, 0, 0, 2}).Return([]*hotline.ClientConn{}) + ThreadedNewsMgr: func() *hotline.MockThreadNewsMgr { + m := hotline.MockThreadNewsMgr{} + m.On("CreateGrouping", []string{"test"}, "TestCat", hotline.NewsCategory).Return(errors.New("write error")) return &m }(), }, }, - t: hotline.Transaction{ - Type: [2]byte{0, 0x72}, // TranRejectChatInvite - ID: [4]byte{0, 0, 0, 2}, + t: hotline.NewTransaction( + hotline.TranNewNewsCat, [2]byte{0, 1}, + hotline.NewField(hotline.FieldNewsCatName, []byte("TestCat")), + hotline.NewField(hotline.FieldNewsPath, + []byte{ + 0, 1, + 0, 0, + 4, + 0x74, 0x65, 0x73, 0x74, + }, + ), + ), + }, + wantRes: []hotline.Transaction{ + { + ClientID: [2]byte{0, 1}, + IsReply: 0x01, + ErrorCode: [4]byte{0, 0, 0, 1}, Fields: []hotline.Field{ - hotline.NewField(hotline.FieldChatID, []byte{0, 0, 0, 2}), + hotline.NewField(hotline.FieldError, []byte(ErrMsgCreateNewsCategory)), }, }, }, - want: []hotline.Transaction{ - // No notifications (no members to notify) - }, }, { - name: "rejects invite with single member", + name: "with a valid request", args: args{ cc: &hotline.ClientConn{ - UserName: []byte("Shy"), - ID: [2]byte{0, 2}, + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + bits.Set(hotline.AccessNewsCreateCat) + return bits + }(), + }, + Logger: NewTestLogger(), + ID: [2]byte{0, 1}, Server: &hotline.Server{ TextDecoder: charmap.Macintosh.NewDecoder(), TextEncoder: charmap.Macintosh.NewEncoder(), - ChatMgr: func() *hotline.MockChatManager { - m := hotline.MockChatManager{} - // Mock chat with single member - m.On("Members", hotline.ChatID{0, 0, 0, 3}).Return([]*hotline.ClientConn{ - { - UserName: []byte("OnlyMember"), - ID: [2]byte{0, 1}, - }, - }) + ThreadedNewsMgr: func() *hotline.MockThreadNewsMgr { + m := hotline.MockThreadNewsMgr{} + m.On("CreateGrouping", []string{"test"}, "TestCat", hotline.NewsCategory).Return(nil) return &m }(), }, }, - t: hotline.Transaction{ - Type: [2]byte{0, 0x72}, // TranRejectChatInvite - ID: [4]byte{0, 0, 0, 3}, - Fields: []hotline.Field{ - hotline.NewField(hotline.FieldChatID, []byte{0, 0, 0, 3}), - }, - }, + t: hotline.NewTransaction( + hotline.TranNewNewsCat, [2]byte{0, 1}, + hotline.NewField(hotline.FieldNewsCatName, []byte("TestCat")), + hotline.NewField(hotline.FieldNewsPath, + []byte{ + 0, 1, + 0, 0, + 4, + 0x74, 0x65, 0x73, 0x74, + }, + ), + ), }, - want: []hotline.Transaction{ - // Notification to the single member + wantRes: []hotline.Transaction{ { ClientID: [2]byte{0, 1}, - Type: [2]byte{0, 0x6A}, // TranChatMsg - Fields: []hotline.Field{ - hotline.NewField(hotline.FieldData, []byte("Shy declined invitation to chat")), - }, + IsReply: 0x01, + Fields: []hotline.Field{}, }, }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := HandleRejectChatInvite(tt.args.cc, &tt.args.t) - if !TranAssertEqual(t, tt.want, got) { - t.Errorf("HandleRejectChatInvite() got = %v, want %v", got, tt.want) - } + gotRes := HandleNewNewsCat(tt.args.cc, &tt.args.t) + + TranAssertEqual(t, tt.wantRes, gotRes) }) } } -func TestHandleInviteToChat(t *testing.T) { +func TestHandleKeepAlive(t *testing.T) { + cc := &hotline.ClientConn{ + ID: [2]byte{0, 1}, + } + tran := &hotline.Transaction{ + ID: [4]byte{0, 0, 0, 1}, + } + + got := HandleKeepAlive(cc, tran) + + assert.Len(t, got, 1) + assert.Equal(t, byte(1), got[0].IsReply) + assert.Equal(t, tran.ID, got[0].ID) +} + +func TestHandleUserBroadcast(t *testing.T) { type args struct { cc *hotline.ClientConn t hotline.Transaction @@ -4725,131 +6004,138 @@ func TestHandleInviteToChat(t *testing.T) { want []hotline.Transaction }{ { - name: "invites user to chat successfully", + name: "without broadcast access returns error", + args: args{ + cc: &hotline.ClientConn{ + Account: &hotline.Account{Access: hotline.AccessBitmap{}}, + ID: [2]byte{0, 1}, + }, + t: hotline.NewTransaction( + hotline.TranUserBroadcast, [2]byte{0, 1}, + hotline.NewField(hotline.FieldData, []byte("hello")), + ), + }, + want: []hotline.Transaction{ + { + ClientID: [2]byte{0, 1}, + IsReply: 0x01, + ErrorCode: [4]byte{0, 0, 0, 1}, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldError, []byte(ErrMsgNotAllowedSendBroadcast)), + }, + }, + }, + }, + { + name: "with broadcast access sends broadcast and returns success", args: args{ cc: &hotline.ClientConn{ - UserName: []byte("Inviter"), - ID: [2]byte{0, 1}, - Icon: []byte{0, 2}, - Flags: [2]byte{0, 3}, Account: &hotline.Account{ Access: func() hotline.AccessBitmap { - access := hotline.AccessBitmap{} - access.Set(hotline.AccessOpenChat) - return access + var bits hotline.AccessBitmap + bits.Set(hotline.AccessBroadcast) + return bits }(), }, - }, - t: hotline.Transaction{ - Type: [2]byte{0, 0x71}, // TranInviteToChat - ID: [4]byte{0, 0, 0, 1}, - Fields: []hotline.Field{ - hotline.NewField(hotline.FieldUserID, []byte{0, 5}), - hotline.NewField(hotline.FieldChatID, []byte{0, 0, 0, 10}), + ID: [2]byte{0, 1}, + Server: &hotline.Server{ + ClientMgr: func() *hotline.MockClientMgr { + m := hotline.MockClientMgr{} + m.On("List").Return([]*hotline.ClientConn{}) + return &m + }(), }, }, + t: hotline.NewTransaction( + hotline.TranUserBroadcast, [2]byte{0, 1}, + hotline.NewField(hotline.FieldData, []byte("hello everyone")), + ), }, want: []hotline.Transaction{ - // Invite sent to target user - { - ClientID: [2]byte{0, 5}, - Type: [2]byte{0, 0x71}, // TranInviteToChat - Fields: []hotline.Field{ - hotline.NewField(hotline.FieldUserName, []byte("Inviter")), - hotline.NewField(hotline.FieldUserID, []byte{0, 1}), - }, - }, - // Reply to inviting user { ClientID: [2]byte{0, 1}, IsReply: 0x01, - Type: [2]byte{0, 0}, // Reply type is [0, 0] - Fields: []hotline.Field{ - hotline.NewField(hotline.FieldUserName, []byte("Inviter")), - hotline.NewField(hotline.FieldUserID, []byte{0, 1}), - hotline.NewField(hotline.FieldUserIconID, []byte{0, 2}), - hotline.NewField(hotline.FieldUserFlags, []byte{0, 3}), - }, }, }, }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := HandleUserBroadcast(tt.args.cc, &tt.args.t) + TranAssertEqual(t, tt.want, got) + }) + } +} + +func TestHandleGetNewsCatNameList(t *testing.T) { + type args struct { + cc *hotline.ClientConn + t hotline.Transaction + } + tests := []struct { + name string + args args + want []hotline.Transaction + }{ { - name: "returns error when user lacks permission", + name: "without news read access returns error", args: args{ cc: &hotline.ClientConn{ - UserName: []byte("NoPermUser"), - ID: [2]byte{0, 2}, - Account: &hotline.Account{ - Access: hotline.AccessBitmap{}, // No permissions - }, - }, - t: hotline.Transaction{ - Type: [2]byte{0, 0x71}, // TranInviteToChat - ID: [4]byte{0, 0, 0, 2}, - Fields: []hotline.Field{ - hotline.NewField(hotline.FieldUserID, []byte{0, 3}), - hotline.NewField(hotline.FieldChatID, []byte{0, 0, 0, 5}), - }, + Account: &hotline.Account{Access: hotline.AccessBitmap{}}, + ID: [2]byte{0, 1}, }, + t: hotline.NewTransaction(hotline.TranGetNewsCatNameList, [2]byte{0, 1}), }, want: []hotline.Transaction{ - // Error reply to requesting user { - ClientID: [2]byte{0, 2}, + ClientID: [2]byte{0, 1}, IsReply: 0x01, - Type: [2]byte{0, 0}, // Reply type is [0, 0] ErrorCode: [4]byte{0, 0, 0, 1}, Fields: []hotline.Field{ - hotline.NewField(hotline.FieldError, []byte("You are not allowed to request private chat.")), + hotline.NewField(hotline.FieldError, []byte(ErrMsgNotAllowedReadNews)), }, }, }, }, { - name: "invites to different chat room", + name: "with access returns news categories", args: args{ cc: &hotline.ClientConn{ - UserName: []byte("Host"), - ID: [2]byte{0, 10}, - Icon: []byte{0, 15}, - Flags: [2]byte{0, 20}, Account: &hotline.Account{ Access: func() hotline.AccessBitmap { - access := hotline.AccessBitmap{} - access.Set(hotline.AccessOpenChat) - return access + var bits hotline.AccessBitmap + bits.Set(hotline.AccessNewsReadArt) + return bits }(), }, - }, - t: hotline.Transaction{ - Type: [2]byte{0, 0x71}, // TranInviteToChat - ID: [4]byte{0, 0, 0, 3}, - Fields: []hotline.Field{ - hotline.NewField(hotline.FieldUserID, []byte{0, 99}), - hotline.NewField(hotline.FieldChatID, []byte{0, 0, 0, 25}), + ID: [2]byte{0, 1}, + Logger: slog.New(slog.NewTextHandler(os.Stdout, nil)), + Server: &hotline.Server{ + ThreadedNewsMgr: func() *hotline.MockThreadNewsMgr { + m := hotline.MockThreadNewsMgr{} + m.On("GetCategories", []string{}).Return([]hotline.NewsCategoryListData15{ + { + Type: hotline.NewsBundle, + Name: "Test Bundle", + }, + }) + return &m + }(), }, }, + t: hotline.NewTransaction(hotline.TranGetNewsCatNameList, [2]byte{0, 1}), }, want: []hotline.Transaction{ - // Invite sent to target user - { - ClientID: [2]byte{0, 99}, - Type: [2]byte{0, 0x71}, // TranInviteToChat - Fields: []hotline.Field{ - hotline.NewField(hotline.FieldUserName, []byte("Host")), - hotline.NewField(hotline.FieldUserID, []byte{0, 10}), - }, - }, - // Reply to inviting user { - ClientID: [2]byte{0, 10}, + ClientID: [2]byte{0, 1}, IsReply: 0x01, - Type: [2]byte{0, 0}, // Reply type is [0, 0] Fields: []hotline.Field{ - hotline.NewField(hotline.FieldUserName, []byte("Host")), - hotline.NewField(hotline.FieldUserID, []byte{0, 10}), - hotline.NewField(hotline.FieldUserIconID, []byte{0, 15}), - hotline.NewField(hotline.FieldUserFlags, []byte{0, 20}), + hotline.NewField(hotline.FieldNewsCatListData15, []byte{ + 0x00, 0x02, // Type: bundle + 0x00, 0x00, // Count: 0 articles+subcats + 0x0b, // Name length: 11 + 0x54, 0x65, 0x73, 0x74, 0x20, 0x42, 0x75, 0x6e, 0x64, 0x6c, 0x65, // "Test Bundle" + }), }, }, }, @@ -4857,15 +6143,13 @@ func TestHandleInviteToChat(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := HandleInviteToChat(tt.args.cc, &tt.args.t) - if !TranAssertEqual(t, tt.want, got) { - t.Errorf("HandleInviteToChat() got = %v, want %v", got, tt.want) - } + got := HandleGetNewsCatNameList(tt.args.cc, &tt.args.t) + TranAssertEqual(t, tt.want, got) }) } } -func TestHandleSetUser(t *testing.T) { +func TestHandleMoveFile(t *testing.T) { type args struct { cc *hotline.ClientConn t hotline.Transaction @@ -4876,61 +6160,56 @@ func TestHandleSetUser(t *testing.T) { wantRes []hotline.Transaction }{ { - name: "when user does not have required permission", - args: args{ - cc: &hotline.ClientConn{ - Account: &hotline.Account{ - Access: func() hotline.AccessBitmap { - var bits hotline.AccessBitmap - return bits - }(), - }, - Server: &hotline.Server{TextDecoder: charmap.Macintosh.NewDecoder(), TextEncoder: charmap.Macintosh.NewEncoder()}, - }, - t: hotline.NewTransaction( - hotline.TranSetUser, [2]byte{0, 1}, - ), - }, - wantRes: []hotline.Transaction{ - { - Flags: 0x00, - IsReply: 0x01, - Type: [2]byte{0, 0}, - ErrorCode: [4]byte{0, 0, 0, 1}, - Fields: []hotline.Field{ - hotline.NewField(hotline.FieldError, []byte(ErrMsgNotAllowedModifyAccounts)), - }, - }, - }, - }, - { - name: "when account is not found", + name: "when user does not have permission to move a file", args: args{ cc: &hotline.ClientConn{ + ID: [2]byte{0, 1}, Account: &hotline.Account{ Access: func() hotline.AccessBitmap { var bits hotline.AccessBitmap - bits.Set(hotline.AccessModifyUser) return bits }(), }, - Logger: NewTestLogger(), - ID: [2]byte{0, 1}, + Logger: slog.New(slog.NewTextHandler(os.Stdout, nil)), Server: &hotline.Server{ TextDecoder: charmap.Macintosh.NewDecoder(), TextEncoder: charmap.Macintosh.NewEncoder(), - AccountManager: func() *MockAccountManager { - m := MockAccountManager{} - m.On("Get", "testuser").Return((*hotline.Account)(nil)) - return &m + Config: hotline.Config{ + FileRoot: "/fakeRoot/Files", + }, + FS: func() *hotline.MockFileStore { + mfi := &hotline.MockFileInfo{} + mfi.On("Mode").Return(fs.FileMode(0)) + mfi.On("Size").Return(int64(100)) + mfi.On("ModTime").Return(time.Parse(time.Layout, time.Layout)) + mfi.On("IsDir").Return(false) + mfi.On("Name").Return("testfile") + + mfs := &hotline.MockFileStore{} + // NewFile calls: Stat data, Stat info, Stat rsrc + mfs.On("Stat", "/fakeRoot/Files/aaa/testfile").Return(mfi, nil) + mfs.On("Stat", "/fakeRoot/Files/aaa/.info_testfile").Return(nil, errors.New("err")) + mfs.On("Stat", "/fakeRoot/Files/aaa/.rsrc_testfile").Return(nil, errors.New("err")) + + return mfs }(), }, }, t: hotline.NewTransaction( - hotline.TranSetUser, [2]byte{0, 1}, - hotline.NewField(hotline.FieldUserLogin, hotline.EncodeString([]byte("testuser"))), - hotline.NewField(hotline.FieldUserName, []byte("Test User")), - hotline.NewField(hotline.FieldUserAccess, make([]byte, 8)), + hotline.TranMoveFile, [2]byte{0, 1}, + hotline.NewField(hotline.FieldFileName, []byte("testfile")), + hotline.NewField(hotline.FieldFilePath, []byte{ + 0x00, 0x01, + 0x00, 0x00, + 0x03, + 0x61, 0x61, 0x61, + }), + hotline.NewField(hotline.FieldFileNewPath, []byte{ + 0x00, 0x01, + 0x00, 0x00, + 0x03, + 0x62, 0x62, 0x62, + }), ), }, wantRes: []hotline.Transaction{ @@ -4939,43 +6218,61 @@ func TestHandleSetUser(t *testing.T) { IsReply: 0x01, ErrorCode: [4]byte{0, 0, 0, 1}, Fields: []hotline.Field{ - hotline.NewField(hotline.FieldError, []byte(ErrMsgAccountNotFound)), + hotline.NewField(hotline.FieldError, []byte("You are not allowed to move files.")), }, }, }, }, { - name: "when Update returns an error", + name: "when user does not have permission to move a folder", args: args{ cc: &hotline.ClientConn{ + ID: [2]byte{0, 1}, Account: &hotline.Account{ Access: func() hotline.AccessBitmap { var bits hotline.AccessBitmap - bits.Set(hotline.AccessModifyUser) return bits }(), }, - Logger: NewTestLogger(), - ID: [2]byte{0, 1}, + Logger: slog.New(slog.NewTextHandler(os.Stdout, nil)), Server: &hotline.Server{ TextDecoder: charmap.Macintosh.NewDecoder(), TextEncoder: charmap.Macintosh.NewEncoder(), - AccountManager: func() *MockAccountManager { - m := MockAccountManager{} - m.On("Get", "testuser").Return(&hotline.Account{ - Login: "testuser", - Name: "Old Name", - }) - m.On("Update", mock.Anything, "testuser").Return(errors.New("disk full")) - return &m + Config: hotline.Config{ + FileRoot: "/fakeRoot/Files", + }, + FS: func() *hotline.MockFileStore { + mfi := &hotline.MockFileInfo{} + mfi.On("Mode").Return(fs.ModeDir) + mfi.On("Size").Return(int64(0)) + mfi.On("ModTime").Return(time.Parse(time.Layout, time.Layout)) + mfi.On("IsDir").Return(true) + mfi.On("Name").Return("testfolder") + + mfs := &hotline.MockFileStore{} + mfs.On("Stat", "/fakeRoot/Files/aaa/testfolder").Return(mfi, nil) + mfs.On("Stat", "/fakeRoot/Files/aaa/.info_testfolder").Return(nil, errors.New("err")) + mfs.On("Stat", "/fakeRoot/Files/aaa/.rsrc_testfolder").Return(nil, errors.New("err")) + + return mfs }(), }, }, t: hotline.NewTransaction( - hotline.TranSetUser, [2]byte{0, 1}, - hotline.NewField(hotline.FieldUserLogin, hotline.EncodeString([]byte("testuser"))), - hotline.NewField(hotline.FieldUserName, []byte("New Name")), - hotline.NewField(hotline.FieldUserAccess, make([]byte, 8)), + hotline.TranMoveFile, [2]byte{0, 1}, + hotline.NewField(hotline.FieldFileName, []byte("testfolder")), + hotline.NewField(hotline.FieldFilePath, []byte{ + 0x00, 0x01, + 0x00, 0x00, + 0x03, + 0x61, 0x61, 0x61, + }), + hotline.NewField(hotline.FieldFileNewPath, []byte{ + 0x00, 0x01, + 0x00, 0x00, + 0x03, + 0x62, 0x62, 0x62, + }), ), }, wantRes: []hotline.Transaction{ @@ -4984,7 +6281,7 @@ func TestHandleSetUser(t *testing.T) { IsReply: 0x01, ErrorCode: [4]byte{0, 0, 0, 1}, Fields: []hotline.Field{ - hotline.NewField(hotline.FieldError, []byte(ErrMsgUpdateAccount)), + hotline.NewField(hotline.FieldError, []byte("You are not allowed to move folders.")), }, }, }, @@ -4992,14 +6289,13 @@ func TestHandleSetUser(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - gotRes := HandleSetUser(tt.args.cc, &tt.args.t) - + gotRes := HandleMoveFile(tt.args.cc, &tt.args.t) TranAssertEqual(t, tt.wantRes, gotRes) }) } } -func TestHandleNewNewsCat(t *testing.T) { +func TestHandleSetFileInfo(t *testing.T) { type args struct { cc *hotline.ClientConn t hotline.Transaction @@ -5010,67 +6306,96 @@ func TestHandleNewNewsCat(t *testing.T) { wantRes []hotline.Transaction }{ { - name: "when user does not have required permission", + name: "when user does not have permission to set file comment", args: args{ cc: &hotline.ClientConn{ + ID: [2]byte{0, 1}, Account: &hotline.Account{ Access: func() hotline.AccessBitmap { var bits hotline.AccessBitmap return bits }(), }, - Server: &hotline.Server{TextDecoder: charmap.Macintosh.NewDecoder(), TextEncoder: charmap.Macintosh.NewEncoder()}, + Server: &hotline.Server{ + TextDecoder: charmap.Macintosh.NewDecoder(), + TextEncoder: charmap.Macintosh.NewEncoder(), + Config: hotline.Config{ + FileRoot: "/fakeRoot/Files", + }, + FS: func() *hotline.MockFileStore { + mfi := &hotline.MockFileInfo{} + mfi.On("Mode").Return(fs.FileMode(0)) + mfi.On("Size").Return(int64(100)) + mfi.On("ModTime").Return(time.Parse(time.Layout, time.Layout)) + mfi.On("IsDir").Return(false) + mfi.On("Name").Return("testfile") + + mfs := &hotline.MockFileStore{} + mfs.On("Stat", "/fakeRoot/Files/testfile").Return(mfi, nil) + mfs.On("Stat", "/fakeRoot/Files/.info_testfile").Return(nil, errors.New("err")) + mfs.On("Stat", "/fakeRoot/Files/.rsrc_testfile").Return(nil, errors.New("err")) + + return mfs + }(), + }, }, t: hotline.NewTransaction( - hotline.TranNewNewsCat, [2]byte{0, 1}, + hotline.TranSetFileInfo, [2]byte{0, 1}, + hotline.NewField(hotline.FieldFileName, []byte("testfile")), + hotline.NewField(hotline.FieldFilePath, []byte{0x00, 0x00}), + hotline.NewField(hotline.FieldFileComment, []byte("a comment")), ), }, wantRes: []hotline.Transaction{ { - Flags: 0x00, + ClientID: [2]byte{0, 1}, IsReply: 0x01, - Type: [2]byte{0, 0}, ErrorCode: [4]byte{0, 0, 0, 1}, Fields: []hotline.Field{ - hotline.NewField(hotline.FieldError, []byte(ErrMsgNotAllowedCreateNewsCategories)), + hotline.NewField(hotline.FieldError, []byte("You are not allowed to set comments for files.")), }, }, }, }, { - name: "when CreateGrouping returns an error", + name: "when user does not have permission to rename a file", args: args{ cc: &hotline.ClientConn{ + ID: [2]byte{0, 1}, Account: &hotline.Account{ Access: func() hotline.AccessBitmap { var bits hotline.AccessBitmap - bits.Set(hotline.AccessNewsCreateCat) return bits }(), }, - Logger: NewTestLogger(), - ID: [2]byte{0, 1}, Server: &hotline.Server{ TextDecoder: charmap.Macintosh.NewDecoder(), TextEncoder: charmap.Macintosh.NewEncoder(), - ThreadedNewsMgr: func() *hotline.MockThreadNewsMgr { - m := hotline.MockThreadNewsMgr{} - m.On("CreateGrouping", []string{"test"}, "TestCat", hotline.NewsCategory).Return(errors.New("write error")) - return &m + Config: hotline.Config{ + FileRoot: "/fakeRoot/Files", + }, + FS: func() *hotline.MockFileStore { + mfi := &hotline.MockFileInfo{} + mfi.On("Mode").Return(fs.FileMode(0)) + mfi.On("Size").Return(int64(100)) + mfi.On("ModTime").Return(time.Parse(time.Layout, time.Layout)) + mfi.On("IsDir").Return(false) + mfi.On("Name").Return("testfile") + + mfs := &hotline.MockFileStore{} + mfs.On("Stat", "/fakeRoot/Files/testfile").Return(mfi, nil) + mfs.On("Stat", "/fakeRoot/Files/.info_testfile").Return(nil, errors.New("err")) + mfs.On("Stat", "/fakeRoot/Files/.rsrc_testfile").Return(nil, errors.New("err")) + + return mfs }(), }, }, t: hotline.NewTransaction( - hotline.TranNewNewsCat, [2]byte{0, 1}, - hotline.NewField(hotline.FieldNewsCatName, []byte("TestCat")), - hotline.NewField(hotline.FieldNewsPath, - []byte{ - 0, 1, - 0, 0, - 4, - 0x74, 0x65, 0x73, 0x74, - }, - ), + hotline.TranSetFileInfo, [2]byte{0, 1}, + hotline.NewField(hotline.FieldFileName, []byte("testfile")), + hotline.NewField(hotline.FieldFilePath, []byte{0x00, 0x00}), + hotline.NewField(hotline.FieldFileNewName, []byte("newname")), ), }, wantRes: []hotline.Transaction{ @@ -5079,61 +6404,124 @@ func TestHandleNewNewsCat(t *testing.T) { IsReply: 0x01, ErrorCode: [4]byte{0, 0, 0, 1}, Fields: []hotline.Field{ - hotline.NewField(hotline.FieldError, []byte(ErrMsgCreateNewsCategory)), + hotline.NewField(hotline.FieldError, []byte("You are not allowed to rename files.")), }, }, }, }, { - name: "with a valid request", + name: "when user does not have permission to set folder comment", args: args{ cc: &hotline.ClientConn{ + ID: [2]byte{0, 1}, Account: &hotline.Account{ Access: func() hotline.AccessBitmap { var bits hotline.AccessBitmap - bits.Set(hotline.AccessNewsCreateCat) return bits }(), }, - Logger: NewTestLogger(), - ID: [2]byte{0, 1}, Server: &hotline.Server{ TextDecoder: charmap.Macintosh.NewDecoder(), TextEncoder: charmap.Macintosh.NewEncoder(), - ThreadedNewsMgr: func() *hotline.MockThreadNewsMgr { - m := hotline.MockThreadNewsMgr{} - m.On("CreateGrouping", []string{"test"}, "TestCat", hotline.NewsCategory).Return(nil) - return &m + Config: hotline.Config{ + FileRoot: "/fakeRoot/Files", + }, + FS: func() *hotline.MockFileStore { + mfi := &hotline.MockFileInfo{} + mfi.On("Mode").Return(fs.ModeDir) + mfi.On("Size").Return(int64(0)) + mfi.On("ModTime").Return(time.Parse(time.Layout, time.Layout)) + mfi.On("IsDir").Return(true) + mfi.On("Name").Return("testfolder") + + mfs := &hotline.MockFileStore{} + mfs.On("Stat", "/fakeRoot/Files/testfolder").Return(mfi, nil) + mfs.On("Stat", "/fakeRoot/Files/.info_testfolder").Return(nil, errors.New("err")) + mfs.On("Stat", "/fakeRoot/Files/.rsrc_testfolder").Return(nil, errors.New("err")) + + return mfs }(), }, }, t: hotline.NewTransaction( - hotline.TranNewNewsCat, [2]byte{0, 1}, - hotline.NewField(hotline.FieldNewsCatName, []byte("TestCat")), - hotline.NewField(hotline.FieldNewsPath, - []byte{ - 0, 1, - 0, 0, - 4, - 0x74, 0x65, 0x73, 0x74, - }, - ), + hotline.TranSetFileInfo, [2]byte{0, 1}, + hotline.NewField(hotline.FieldFileName, []byte("testfolder")), + hotline.NewField(hotline.FieldFilePath, []byte{0x00, 0x00}), + hotline.NewField(hotline.FieldFileComment, []byte("a comment")), ), }, wantRes: []hotline.Transaction{ { - ClientID: [2]byte{0, 1}, - IsReply: 0x01, - Fields: []hotline.Field{}, + ClientID: [2]byte{0, 1}, + IsReply: 0x01, + ErrorCode: [4]byte{0, 0, 0, 1}, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldError, []byte("You are not allowed to set comments for folders.")), + }, }, }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - gotRes := HandleNewNewsCat(tt.args.cc, &tt.args.t) - + gotRes := HandleSetFileInfo(tt.args.cc, &tt.args.t) TranAssertEqual(t, tt.wantRes, gotRes) }) } } + +func TestRegisterHandlers(t *testing.T) { + srv, err := hotline.NewServer() + assert.NoError(t, err) + + RegisterHandlers(srv) + + // Verify that a known transaction type is handled by sending a transaction + // that requires no permission. We can't inspect the handlers map directly + // since it's unexported, but we can verify RegisterHandlers doesn't panic + // and the server is functional. +} + +func TestHandleDownloadFolder_withAccess(t *testing.T) { + // Create a temp dir with files to act as the download folder + tmpDir := t.TempDir() + dlDir := filepath.Join(tmpDir, "testFolder") + err := os.MkdirAll(dlDir, 0755) + assert.NoError(t, err) + + // Create a test file inside the folder + err = os.WriteFile(filepath.Join(dlDir, "file.txt"), []byte("hello"), 0644) + assert.NoError(t, err) + + cc := &hotline.ClientConn{ + ClientFileTransferMgr: hotline.NewClientFileTransferMgr(), + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + bits.Set(hotline.AccessDownloadFolder) + return bits + }(), + }, + Server: &hotline.Server{ + TextDecoder: charmap.Macintosh.NewDecoder(), + TextEncoder: charmap.Macintosh.NewEncoder(), + FileTransferMgr: hotline.NewMemFileTransferMgr(), + Config: hotline.Config{FileRoot: tmpDir}, + }, + } + + tran := hotline.NewTransaction( + hotline.TranDownloadFldr, [2]byte{0, 1}, + hotline.NewField(hotline.FieldFileName, []byte("testFolder")), + hotline.NewField(hotline.FieldFilePath, []byte{0x00, 0x00}), + ) + + gotRes := HandleDownloadFolder(cc, &tran) + assert.Len(t, gotRes, 1) + assert.Equal(t, byte(0x01), gotRes[0].IsReply) + + // Verify expected fields are present + assert.NotNil(t, gotRes[0].GetField(hotline.FieldRefNum)) + assert.NotNil(t, gotRes[0].GetField(hotline.FieldTransferSize)) + assert.NotNil(t, gotRes[0].GetField(hotline.FieldFolderItemCount)) +} -- cgit