aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJeff Halter <868228+jhalter@users.noreply.github.com>2026-03-16 11:46:04 -0700
committerJeff Halter <868228+jhalter@users.noreply.github.com>2026-03-16 11:46:04 -0700
commit2f46f87177234070044b5304ca3e0db471699ad8 (patch)
tree6dc72ce596e95850a587001a85b3f45e394b0e34
parent92f4b7af1073b6cdd0786ce2090d6e446e0903ad (diff)
Improve test coverage for hotline and internal/mobius packages
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)
-rw-r--r--hotline/account_test.go91
-rw-r--r--hotline/ban.go70
-rw-r--r--hotline/client_conn_test.go505
-rw-r--r--hotline/client_manager_test.go62
-rw-r--r--hotline/client_test.go278
-rw-r--r--hotline/file_wrapper_test.go171
-rw-r--r--hotline/flattened_file_object_test.go142
-rw-r--r--hotline/server_test.go110
-rw-r--r--internal/mobius/agreement_test.go328
-rw-r--r--internal/mobius/api_test.go688
-rw-r--r--internal/mobius/threaded_news_test.go645
-rw-r--r--internal/mobius/transaction_handlers_test.go1522
12 files changed, 4208 insertions, 404 deletions
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
- }
+// 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()
- 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",
+ 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),
},
- 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",
- },
+ "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),
},
+ 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)
- })
- }
+
+ dir := t.TempDir()
+ fp := filepath.Join(dir, "ThreadedNews.yaml")
+
+ data, err := yaml.Marshal(&tn)
+ require.NoError(t, err)
+ require.NoError(t, os.WriteFile(fp, data, 0644))
+
+ result, err := NewThreadedNewsYAML(fp)
+ require.NoError(t, err)
+
+ return result
}
-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.
+func TestThreadedNewsYAML_GetCategories(t *testing.T) {
+ tn := newTestThreadedNews(t)
- // Path to the temporary ban file.
- tmpFilePath := path.Join(tmpDir, "ThreadedNews.yaml")
+ 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)
+ })
- 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),
- },
- },
- },
- 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))
- })
- }
+ 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)
+ })
}
-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",
- }
+func TestThreadedNewsYAML_NewsItem(t *testing.T) {
+ tn := newTestThreadedNews(t)
- err := n.CreateGrouping(nil, "NewBundle", hotline.NewsBundle)
- assert.Error(t, err)
+ 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)
+ })
- // 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")
+ 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_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)
+ })
- // Existing entry should still be present.
- _, exists = n.ThreadedNews.Categories["Existing"]
- assert.True(t, exists, "existing grouping should be preserved")
+ 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)
+ })
}
-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_ListArticles(t *testing.T) {
+ tn := newTestThreadedNews(t)
- err := n.DeleteNewsItem([]string{"ToDelete"})
- assert.Error(t, err)
+ 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)
+ })
- // 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("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)
+ })
}
-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_PostArticle(t *testing.T) {
+ t.Run("posts article to empty category and persists", func(t *testing.T) {
+ tn := newTestThreadedNews(t)
- // Snapshot state before.
- origNextArt3 := n.ThreadedNews.Categories["TestCat"].Articles[3].NextArt
+ // Create a new empty category first.
+ err := tn.CreateGrouping(nil, "Fresh", hotline.NewsCategory)
+ require.NoError(t, err)
- newArticle := hotline.NewsArtData{
- Title: "New Article",
- Poster: "tester",
- }
+ article := hotline.NewsArtData{
+ Title: "First Post",
+ Poster: "user1",
+ Data: "Content here",
+ }
+ err = tn.PostArticle([]string{"Fresh"}, 0, article)
+ require.NoError(t, err)
- err := n.PostArticle([]string{"TestCat"}, 0, newArticle)
- assert.Error(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)
+ })
- cat := n.ThreadedNews.Categories["TestCat"]
+ t.Run("reply sets parent FirstChildArt", func(t *testing.T) {
+ tn := newTestThreadedNews(t)
- // New article (ID 4) should not exist.
- _, exists := cat.Articles[4]
- assert.False(t, exists, "new article should be removed on write failure")
+ // 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)
- // 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")
+ // The reply should be article 2.
+ replyArt := tn.GetArticle([]string{"General"}, 2)
+ require.NotNil(t, replyArt)
- // Should still have exactly 3 articles.
- assert.Len(t, cat.Articles, 3)
+ // 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")
+ })
}
-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(t *testing.T) {
+ tn := newTestThreadedNews(t)
- newArticle := hotline.NewsArtData{
- Title: "Reply",
- Poster: "tester",
- }
+ // Confirm article exists before deletion.
+ art := tn.GetArticle([]string{"General"}, 1)
+ require.NotNil(t, art)
+
+ err := tn.DeleteArticle([]string{"General"}, 1, false)
+ require.NoError(t, err)
+
+ // Article should be gone.
+ art = tn.GetArticle([]string{"General"}, 1)
+ assert.Nil(t, art)
+
+ // Verify persistence.
+ require.NoError(t, tn.Load())
+ art = tn.GetArticle([]string{"General"}, 1)
+ assert.Nil(t, art)
+}
- err := n.PostArticle([]string{"TestCat"}, 1, newArticle)
+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")
+}
+
+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)
- cat := n.ThreadedNews.Categories["TestCat"]
+ // Verify persistence.
+ require.NoError(t, tn.Load())
+ item = tn.NewsItem([]string{"NewBundle"})
+ assert.Equal(t, "NewBundle", item.Name)
+ })
- // FirstChildArt should be restored to zero.
- assert.Equal(t, [4]byte{}, cat.Articles[1].FirstChildArt, "parent's FirstChildArt should be restored")
+ t.Run("creates a nested category inside a bundle", func(t *testing.T) {
+ tn := newTestThreadedNews(t)
- // New article should not exist.
- _, exists := cat.Articles[2]
- assert.False(t, exists, "reply article should be removed on write failure")
+ 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)
+ })
}
-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",
- }
+func TestThreadedNewsYAML_DeleteNewsItem(t *testing.T) {
+ t.Run("deletes a top-level category", func(t *testing.T) {
+ tn := newTestThreadedNews(t)
- err := n.DeleteArticle([]string{"TestCat"}, 1, false)
- assert.Error(t, err)
+ err := tn.DeleteNewsItem([]string{"General"})
+ require.NoError(t, err)
+
+ cats := tn.GetCategories(nil)
+ for _, c := range cats {
+ assert.NotEqual(t, "General", c.Name)
+ }
+
+ // Verify persistence.
+ require.NoError(t, tn.Load())
+ cats = tn.GetCategories(nil)
+ for _, c := range cats {
+ assert.NotEqual(t, "General", c.Name)
+ }
+ })
+
+ t.Run("deletes a nested subcategory", func(t *testing.T) {
+ tn := newTestThreadedNews(t)
+
+ err := tn.DeleteNewsItem([]string{"Archive", "Old News"})
+ require.NoError(t, err)
+
+ cats := tn.GetCategories([]string{"Archive"})
+ assert.Empty(t, cats)
+ })
+}
+
+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)
+ })
- cat := n.ThreadedNews.Categories["TestCat"]
+ 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))
- // 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)
+ _, 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,6 +2410,391 @@ func TestHandleUpdateUser(t *testing.T) {
},
},
},
+ {
+ 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.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{
+ {
+ Type: hotline.TranServerMsg,
+ Fields: []hotline.Field{
+ hotline.NewField(hotline.FieldData, []byte(ErrMsgAccountDeleted)),
+ hotline.NewField(hotline.FieldChatOptions, []byte{0}),
+ },
+ },
+ {
+ IsReply: 0x01,
+ },
+ },
+ },
+ {
+ 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.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(),
+ 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, 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{
+ {
+ 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,
+ },
+ },
+ },
+ {
+ 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
+
+ 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: "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.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{
+ {
+ IsReply: 0x01,
+ ErrorCode: [4]byte{0, 0, 0, 1},
+ Fields: []hotline.Field{
+ hotline.NewField(hotline.FieldError, []byte("Cannot create account with more access than yourself.")),
+ },
+ },
+ },
+ },
+ {
+ 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(),
+ 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
+ // Set all bits so privilege check passes
+ 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
+
+ 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,
+ ErrorCode: [4]byte{0, 0, 0, 1},
+ Fields: []hotline.Field{
+ hotline.NewField(hotline.FieldError, []byte(ErrMsgAccountExists)),
+ },
+ },
+ },
+ },
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@@ -2392,6 +2979,134 @@ func TestHandleDisconnectUser(t *testing.T) {
},
},
},
+ {
+ 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.AccessDisconUser)
+ return bits
+ }(),
+ },
+ },
+ 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, 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.TranDisconnectUser,
+ [2]byte{0, 0},
+ hotline.NewField(hotline.FieldUserID, []byte{0, 1}),
+ hotline.NewField(hotline.FieldOptions, []byte{0, 2}),
+ ),
+ },
+ wantRes: []hotline.Transaction{
+ {
+ ClientID: [2]byte{0, 1},
+ 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) {
@@ -3939,56 +4654,53 @@ 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,
- // },
- // ),
- // },
- // },
- // },
- //},
+ {
+ 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() *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.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) {
@@ -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) {
@@ -4989,6 +5782,53 @@ func TestHandleSetUser(t *testing.T) {
},
},
},
+ {
+ 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) {
@@ -5137,3 +5977,551 @@ func TestHandleNewNewsCat(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
+ }
+ tests := []struct {
+ name string
+ args args
+ want []hotline.Transaction
+ }{
+ {
+ 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{
+ Account: &hotline.Account{
+ Access: func() hotline.AccessBitmap {
+ var bits hotline.AccessBitmap
+ bits.Set(hotline.AccessBroadcast)
+ return bits
+ }(),
+ },
+ 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{
+ {
+ ClientID: [2]byte{0, 1},
+ IsReply: 0x01,
+ },
+ },
+ },
+ }
+ 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: "without news read access returns error",
+ args: args{
+ cc: &hotline.ClientConn{
+ Account: &hotline.Account{Access: hotline.AccessBitmap{}},
+ ID: [2]byte{0, 1},
+ },
+ t: hotline.NewTransaction(hotline.TranGetNewsCatNameList, [2]byte{0, 1}),
+ },
+ 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(ErrMsgNotAllowedReadNews)),
+ },
+ },
+ },
+ },
+ {
+ name: "with access returns news categories",
+ args: args{
+ cc: &hotline.ClientConn{
+ Account: &hotline.Account{
+ Access: func() hotline.AccessBitmap {
+ var bits hotline.AccessBitmap
+ bits.Set(hotline.AccessNewsReadArt)
+ return bits
+ }(),
+ },
+ 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{
+ {
+ ClientID: [2]byte{0, 1},
+ IsReply: 0x01,
+ Fields: []hotline.Field{
+ 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"
+ }),
+ },
+ },
+ },
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := HandleGetNewsCatNameList(tt.args.cc, &tt.args.t)
+ TranAssertEqual(t, tt.want, got)
+ })
+ }
+}
+
+func TestHandleMoveFile(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 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
+ return bits
+ }(),
+ },
+ Logger: slog.New(slog.NewTextHandler(os.Stdout, nil)),
+ 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{}
+ // 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.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{
+ {
+ 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 move files.")),
+ },
+ },
+ },
+ },
+ {
+ 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
+ return bits
+ }(),
+ },
+ Logger: slog.New(slog.NewTextHandler(os.Stdout, nil)),
+ 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.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.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{
+ {
+ 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 move folders.")),
+ },
+ },
+ },
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ gotRes := HandleMoveFile(tt.args.cc, &tt.args.t)
+ TranAssertEqual(t, tt.wantRes, gotRes)
+ })
+ }
+}
+
+func TestHandleSetFileInfo(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 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(),
+ 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.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{
+ {
+ 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 files.")),
+ },
+ },
+ },
+ },
+ {
+ 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
+ return bits
+ }(),
+ },
+ 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.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{
+ {
+ 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 rename files.")),
+ },
+ },
+ },
+ },
+ {
+ 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
+ return bits
+ }(),
+ },
+ 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.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.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,
+ 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 := 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))
+}