diff options
| author | Jeff Halter <868228+jhalter@users.noreply.github.com> | 2026-06-12 07:51:24 -0700 |
|---|---|---|
| committer | Jeff Halter <868228+jhalter@users.noreply.github.com> | 2026-06-12 07:51:24 -0700 |
| commit | d2791fcbadcf332dc05e5bebc7350eca50617263 (patch) | |
| tree | d997ce9eabe77fd7d635f1276873affad1f181b2 | |
| parent | 30db5839a93936f8b69d0d5d005bbe03197722c1 (diff) | |
Split transaction handlers and tests into domain files
Break up the 2,349-line transaction_handlers.go and its 6,716-line test
file into per-domain files (chat, files, transfers, accounts, news,
session), moving code verbatim with no signature or behavior changes.
Error message constants move to errors.go and shared test fixtures to
helpers_test.go; transaction_handlers.go retains only RegisterHandlers.
| -rw-r--r-- | internal/mobius/errors.go | 100 | ||||
| -rw-r--r-- | internal/mobius/handlers_accounts.go | 393 | ||||
| -rw-r--r-- | internal/mobius/handlers_accounts_test.go | 1346 | ||||
| -rw-r--r-- | internal/mobius/handlers_chat.go | 399 | ||||
| -rw-r--r-- | internal/mobius/handlers_chat_test.go | 1497 | ||||
| -rw-r--r-- | internal/mobius/handlers_files.go | 444 | ||||
| -rw-r--r-- | internal/mobius/handlers_files_test.go | 1115 | ||||
| -rw-r--r-- | internal/mobius/handlers_news.go | 378 | ||||
| -rw-r--r-- | internal/mobius/handlers_news_test.go | 1167 | ||||
| -rw-r--r-- | internal/mobius/handlers_session.go | 349 | ||||
| -rw-r--r-- | internal/mobius/handlers_session_test.go | 934 | ||||
| -rw-r--r-- | internal/mobius/handlers_transfers.go | 286 | ||||
| -rw-r--r-- | internal/mobius/handlers_transfers_test.go | 557 | ||||
| -rw-r--r-- | internal/mobius/helpers_test.go | 101 | ||||
| -rw-r--r-- | internal/mobius/transaction_handlers.go | 2297 | ||||
| -rw-r--r-- | internal/mobius/transaction_handlers_test.go | 6641 |
16 files changed, 9066 insertions, 8938 deletions
diff --git a/internal/mobius/errors.go b/internal/mobius/errors.go new file mode 100644 index 0000000..d7b8552 --- /dev/null +++ b/internal/mobius/errors.go @@ -0,0 +1,100 @@ +package mobius + +// Public error message constants for reuse by other packages +const ( + // Authorization error messages + ErrMsgNotAllowedParticipateChat = "You are not allowed to participate in chat." + ErrMsgNotAllowedSendPrivateMsg = "You are not allowed to send private messages." + ErrMsgNotAllowedReadNews = "You are not allowed to read news." + ErrMsgNotAllowedPostNews = "You are not allowed to post news." + ErrMsgNotAllowedCreateAccounts = "You are not allowed to create new accounts." + ErrMsgNotAllowedViewAccounts = "You are not allowed to view accounts." + ErrMsgNotAllowedModifyAccounts = "You are not allowed to modify accounts." + ErrMsgNotAllowedDeleteAccounts = "You are not allowed to delete accounts." + ErrMsgNotAllowedRequestPrivateChat = "You are not allowed to request private chat." + ErrMsgNotAllowedCreateNewsCategories = "You are not allowed to create news categories." + ErrMsgNotAllowedDeleteNewsArticles = "You are not allowed to delete news articles." + ErrMsgNotAllowedSetCommentsFiles = "You are not allowed to set comments for files." + ErrMsgNotAllowedSetCommentsFolders = "You are not allowed to set comments for folders." + ErrMsgNotAllowedRenameFiles = "You are not allowed to rename files." + ErrMsgNotAllowedRenameFolders = "You are not allowed to rename folders." + ErrMsgNotAllowedDeleteFiles = "You are not allowed to delete files." + ErrMsgNotAllowedDeleteFolders = "You are not allowed to delete folders." + ErrMsgNotAllowedMoveFiles = "You are not allowed to move files." + ErrMsgNotAllowedMoveFolders = "You are not allowed to move folders." + ErrMsgNotAllowedCreateFolders = "You are not allowed to create folders." + ErrMsgNotAllowedSendBroadcast = "You are not allowed to send broadcast messages." + ErrMsgNotAllowedGetClientInfo = "You are not allowed to get client info." + ErrMsgNotAllowedDisconnectUsers = "You are not allowed to disconnect users." + ErrMsgNotAllowedCreateNewsfolders = "You are not allowed to create news folders." + ErrMsgNotAllowedDeleteNewsCategories = "You are not allowed to delete news categories." + ErrMsgNotAllowedDeleteNewsFolders = "You are not allowed to delete news folders." + ErrMsgNotAllowedPostNewsArticles = "You are not allowed to post news articles." + ErrMsgNotAllowedDownloadFiles = "You are not allowed to download files." + ErrMsgNotAllowedDownloadFolders = "You are not allowed to download folders." + ErrMsgNotAllowedUploadFiles = "You are not allowed to upload files." + ErrMsgNotAllowedUploadFolders = "You are not allowed to upload folders." + ErrMsgNotAllowedViewDropBoxes = "You are not allowed to view drop boxes." + ErrMsgNotAllowedMakeAliases = "You are not allowed to make aliases." + + // Account error messages + ErrMsgAccountDeleted = "You are logged in with an account which was deleted." + ErrMsgAccountExists = "Cannot create account because there is already an account with that login." + ErrMsgAccountMoreAccess = "Cannot create account with more access than yourself." + ErrMsgAccountNotExist = "Account does not exist." + + // Account error templates (for dynamic content) + ErrMsgAccountExistsTemplate = "Cannot create account %s because there is already an account with that login." + + // File operation error templates + ErrMsgCannotRenameFileNotFound = "Cannot rename file %s because it does not exist or cannot be found." + ErrMsgCannotRenameFolderNotFound = "Cannot rename folder %s because it does not exist or cannot be found." + ErrMsgCannotDeleteFileNotFound = "Cannot delete file %s because it does not exist or cannot be found." + + // File operation error templates (for dynamic content) + ErrMsgFolderCreateConflictTemplate = "Cannot create folder \"%s\" because there is already a file or folder with that Name." + ErrMsgFolderCreateErrorTemplate = "Cannot create folder \"%s\" because an error occurred." + + // Upload restriction templates (these need dynamic content) + ErrMsgUploadRestrictedTemplate = "Cannot accept upload of the %s \"%v\" because you are only allowed to upload to the \"Uploads\" folder." + ErrMsgFileUploadConflictTemplate = "Cannot accept upload because there is already a file named \"%v\". Try choosing a different Name." + + // Chat/messaging templates (these need dynamic content) + ErrMsgDoesNotAcceptTemplate = "%s does not accept %s." + + // Ban messages + ErrMsgTemporaryBan = "You are temporarily banned on this server" + ErrMsgPermanentBan = "You are permanently banned on this server" + + // General error messages + ErrMsgFileNotFound = "File not found." + ErrMsgGetFileInfo = "Error getting file information." + ErrMsgSetFileInfo = "Error setting file information." + ErrMsgRenameFile = "Error renaming file." + ErrMsgRenameFolder = "Error renaming folder." + ErrMsgDeleteFile = "Error deleting file." + ErrMsgMoveFile = "Error moving file." + ErrMsgCreateFolder = "Error creating folder." + ErrMsgDownloadFolder = "Error downloading folder." + ErrMsgUploadFile = "Error uploading file." + ErrMsgUploadFolder = "Error uploading folder." + ErrMsgFileResumeData = "Invalid file resume data." + ErrMsgAccountNotFound = "Account not found." + ErrMsgUserNotFound = "User not found." + ErrMsgInvalidUserID = "Invalid user ID." + ErrMsgInvalidChatID = "Invalid chat ID." + ErrMsgCreateAlias = "Error creating alias" + ErrMsgUpdateAccount = "Error updating account." + ErrMsgDeleteAccount = "Error deleting account." + ErrMsgGetUserList = "Error getting user list." + ErrMsgJoinChat = "Error joining chat." + ErrMsgReadNewsCategories = "Error reading news categories." + ErrMsgReadNewsArticles = "Error reading news articles." + ErrMsgCreateNewsCategory = "Error creating news category." + ErrMsgCreateNewsFolder = "Error creating news folder." + ErrMsgDeleteNewsArticle = "Error deleting news article." + ErrMsgDeleteNewsItem = "Error deleting news item." + ErrMsgPostNewsArticle = "Error posting news article." + ErrMsgPostNews = "Error posting news." + ErrMsgReadMessageBoard = "Error reading message board." +) diff --git a/internal/mobius/handlers_accounts.go b/internal/mobius/handlers_accounts.go new file mode 100644 index 0000000..871e632 --- /dev/null +++ b/internal/mobius/handlers_accounts.go @@ -0,0 +1,393 @@ +package mobius + +import ( + "bufio" + "bytes" + "encoding/binary" + "fmt" + "io" + "time" + + "github.com/jhalter/mobius/hotline" +) + +// HandleSetUser sets the information for a specific user in the server's list of allowed users. +// +// Fields used in the request: +// - 105 User login Required +// - 106 User password Optional +// - 102 User name Required +// - 110 User access Required - User access privileges bitmap +// +// Fields used in the reply: None +func HandleSetUser(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) { + if !cc.Authorize(hotline.AccessModifyUser) { + return cc.NewErrReply(t, ErrMsgNotAllowedModifyAccounts) + } + + login := t.GetField(hotline.FieldUserLogin).DecodeObfuscatedString() + userName := string(t.GetField(hotline.FieldUserName).Data) + + newAccessLvl := t.GetField(hotline.FieldUserAccess).Data + + account := cc.Server.AccountManager.Get(login) + if account == nil { + return cc.NewErrReply(t, ErrMsgAccountNotFound) + } + account.Name = userName + copy(account.Access[:], newAccessLvl) + + // If the password field is cleared in the Hotline edit user UI, the SetUser transaction does + // not include FieldUserPassword + if t.GetField(hotline.FieldUserPassword).Data == nil { + account.Password = hotline.HashAndSalt([]byte("")) + } + + if !bytes.Equal([]byte{0}, t.GetField(hotline.FieldUserPassword).Data) { + account.Password = hotline.HashAndSalt(t.GetField(hotline.FieldUserPassword).Data) + } + + err := cc.Server.AccountManager.Update(*account, account.Login) + if err != nil { + cc.Logger.Error("Error updating account", "err", err) + return cc.NewErrReply(t, ErrMsgUpdateAccount) + } + + // Notify connected clients logged in as the user of the new access level + for _, c := range cc.Server.ClientMgr.List() { + if c.Account.Login == login { + newT := hotline.NewTransaction(hotline.TranUserAccess, c.ID, hotline.NewField(hotline.FieldUserAccess, newAccessLvl)) + res = append(res, newT) + + if c.Authorize(hotline.AccessDisconUser) { + c.Flags.Set(hotline.UserFlagAdmin, 1) + } else { + c.Flags.Set(hotline.UserFlagAdmin, 0) + } + + c.Account.Access = account.Access + + cc.SendAll( + hotline.TranNotifyChangeUser, + hotline.NewField(hotline.FieldUserID, c.ID[:]), + hotline.NewField(hotline.FieldUserFlags, c.Flags[:]), + hotline.NewField(hotline.FieldUserName, c.UserName), + hotline.NewField(hotline.FieldUserIconID, c.Icon), + ) + } + } + + return append(res, cc.NewReply(t)) +} + +// HandleGetUser requests the information for a specific user from the server's list of allowed users. +// +// Fields used in the request: +// - 105 User login Required +// +// Fields used in the reply: +// - 102 User name Account display name +// - 105 User login Account login (encoded, each character negated) +// - 106 User password Account password +// - 110 User access User access privileges bitmap +func HandleGetUser(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) { + if !cc.Authorize(hotline.AccessOpenUser) { + return cc.NewErrReply(t, ErrMsgNotAllowedViewAccounts) + } + + account := cc.Server.AccountManager.Get(string(t.GetField(hotline.FieldUserLogin).Data)) + if account == nil { + return cc.NewErrReply(t, ErrMsgAccountNotExist) + } + + return append(res, cc.NewReply(t, + hotline.NewField(hotline.FieldUserName, []byte(account.Name)), + hotline.NewField(hotline.FieldUserLogin, hotline.EncodeString(t.GetField(hotline.FieldUserLogin).Data)), + hotline.NewField(hotline.FieldUserPassword, []byte(account.Password)), + hotline.NewField(hotline.FieldUserAccess, account.Access[:]), + )) +} + +// HandleListUsers returns a list of all user accounts on the server. +// This is a server-specific transaction not in the original Hotline protocol. +// +// Fields used in the request: None +// +// Fields used in the reply: +// - 101 Data Repeated - Serialized account data for each user +func HandleListUsers(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) { + if !cc.Authorize(hotline.AccessOpenUser) { + return cc.NewErrReply(t, ErrMsgNotAllowedViewAccounts) + } + + var userFields []hotline.Field + for _, acc := range cc.Server.AccountManager.List() { + b, err := io.ReadAll(&acc) + if err != nil { + cc.Logger.Error("Error reading account", "account", acc.Login, "err", err) + continue + } + + userFields = append(userFields, hotline.NewField(hotline.FieldData, b)) + } + + return append(res, cc.NewReply(t, userFields...)) +} + +// HandleUpdateUser is used by the v1.5+ multi-user editor to perform account editing for multiple users at a time. +// An update can be a mix of these actions: +// * Create user +// * Delete user +// * Modify user (including renaming the account login) +// +// The Transaction sent by the client includes one data field per user that was modified. This data field in turn +// contains another data field encoded in its payload with a varying number of sub fields depending on which action is +// performed. This seems to be the only place in the Hotline protocol where a data field contains another data field. +// HandleUpdateUser processes batch user account operations from the v1.5+ multi-user editor. +// This handler supports creating, deleting, and modifying multiple user accounts in a single transaction. +// +// Fields used in the request: +// * 101 Data Repeated - Each contains encoded sub-fields for one user operation +// +// Sub-fields for user operations: +// * 101 Data Optional - Original login name (for rename operations) +// * 105 User Login Required - Login name (new name for renames) +// * 102 User Name Optional - Display name (for create/modify) +// * 106 User Password Optional - Password (for create/modify) +// * 110 User Access Optional - Access permissions (for create/modify) +// +// Fields used in the reply: +// None +func HandleUpdateUser(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) { + for _, field := range t.Fields { + var subFields []hotline.Field + + // The first two bytes are the sub-field count; reject a malformed block + // that is too short to contain it rather than slicing out of range. + if len(field.Data) < 2 { + cc.Logger.Error("update user: malformed sub-field block", "len", len(field.Data)) + return cc.NewErrReply(t, ErrMsgUpdateAccount) + } + + // Create a new scanner for parsing incoming bytes into transaction tokens + scanner := bufio.NewScanner(bytes.NewReader(field.Data[2:])) + scanner.Split(hotline.FieldScanner) + + for i := 0; i < int(binary.BigEndian.Uint16(field.Data[0:2])); i++ { + scanner.Scan() + + var field hotline.Field + if _, err := field.Write(scanner.Bytes()); err != nil { + cc.Logger.Error("update user: parse sub-field", "err", err) + return cc.NewErrReply(t, ErrMsgUpdateAccount) + } + subFields = append(subFields, field) + } + + // If there's only one subfield, that indicates this is a delete operation for the login in FieldData + if len(subFields) == 1 { + if !cc.Authorize(hotline.AccessDeleteUser) { + return cc.NewErrReply(t, ErrMsgNotAllowedDeleteAccounts) + } + + login := string(hotline.EncodeString(hotline.GetField(hotline.FieldData, &subFields).Data)) + + cc.Logger.Info("DeleteUser", "login", login) + + if err := cc.Server.AccountManager.Delete(login); err != nil { + cc.Logger.Error("Error deleting account", "err", err) + return cc.NewErrReply(t, ErrMsgDeleteAccount) + } + + for _, client := range cc.Server.ClientMgr.List() { + if client.Account.Login == login { + // "You are logged in with an account which was deleted." + + res = append(res, + hotline.NewTransaction(hotline.TranServerMsg, [2]byte{}, + hotline.NewField(hotline.FieldData, []byte(ErrMsgAccountDeleted)), + hotline.NewField(hotline.FieldChatOptions, []byte{0}), + ), + ) + + go func(c *hotline.ClientConn) { + time.Sleep(3 * time.Second) + c.Disconnect() + }(client) + } + } + + continue + } + + // login of the account to update + var accountToUpdate, loginToRename string + + // If FieldData is included, this is a rename operation where FieldData contains the login of the existing + // account and FieldUserLogin contains the new login. + if hotline.GetField(hotline.FieldData, &subFields) != nil { + loginToRename = string(hotline.EncodeString(hotline.GetField(hotline.FieldData, &subFields).Data)) + } + userLogin := string(hotline.EncodeString(hotline.GetField(hotline.FieldUserLogin, &subFields).Data)) + if loginToRename != "" { + accountToUpdate = loginToRename + } else { + accountToUpdate = userLogin + } + + // Check if accountToUpdate has an existing account. If so, we know we are updating an existing user. + if acc := cc.Server.AccountManager.Get(accountToUpdate); acc != nil { + if loginToRename != "" { + cc.Logger.Info("RenameUser", "prevLogin", accountToUpdate, "newLogin", userLogin) + } else { + cc.Logger.Info("UpdateUser", "login", accountToUpdate) + } + + // Account exists, so this is an update action. + if !cc.Authorize(hotline.AccessModifyUser) { + return cc.NewErrReply(t, ErrMsgNotAllowedModifyAccounts) + } + + // This part is a bit tricky. There are three possibilities: + // 1) The transaction is intended to update the password. + // In this case, FieldUserPassword is sent with the new password. + // 2) The transaction is intended to remove the password. + // In this case, FieldUserPassword is not sent. + // 3) The transaction updates the users access bits, but not the password. + // In this case, FieldUserPassword is sent with zero as the only byte. + if hotline.GetField(hotline.FieldUserPassword, &subFields) != nil { + newPass := hotline.GetField(hotline.FieldUserPassword, &subFields).Data + if !bytes.Equal([]byte{0}, newPass) { + acc.Password = hotline.HashAndSalt(newPass) + } + } else { + acc.Password = hotline.HashAndSalt([]byte("")) + } + + if hotline.GetField(hotline.FieldUserAccess, &subFields) != nil { + copy(acc.Access[:], hotline.GetField(hotline.FieldUserAccess, &subFields).Data) + } + + acc.Name = string(hotline.GetField(hotline.FieldUserName, &subFields).Data) + + err := cc.Server.AccountManager.Update(*acc, string(hotline.EncodeString(hotline.GetField(hotline.FieldUserLogin, &subFields).Data))) + + if err != nil { + cc.Logger.Error("update user: update account", "err", err) + return cc.NewErrReply(t, ErrMsgUpdateAccount) + } + } else { + if !cc.Authorize(hotline.AccessCreateUser) { + return cc.NewErrReply(t, ErrMsgNotAllowedCreateAccounts) + } + + cc.Logger.Info("CreateUser", "login", userLogin) + + var newAccess hotline.AccessBitmap + copy(newAccess[:], hotline.GetField(hotline.FieldUserAccess, &subFields).Data) + + // Prevent account from creating new account with greater permission + for i := 0; i < 64; i++ { + if newAccess.IsSet(i) { + if !cc.Authorize(i) { + return cc.NewErrReply(t, "Cannot create account with more access than yourself.") + } + } + } + + account := hotline.NewAccount( + userLogin, + string(hotline.GetField(hotline.FieldUserName, &subFields).Data), + string(hotline.GetField(hotline.FieldUserPassword, &subFields).Data), + newAccess, + ) + + err := cc.Server.AccountManager.Create(*account) + if err != nil { + return cc.NewErrReply(t, ErrMsgAccountExists) + } + } + } + + return append(res, cc.NewReply(t)) +} + +// HandleNewUser adds a new user to the server's list of allowed users. +// +// Fields used in the request: +// - 105 User login Required +// - 106 User password Required +// - 102 User name Required +// - 110 User access Required - User access privileges bitmap +// +// Fields used in the reply: None +func HandleNewUser(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) { + if !cc.Authorize(hotline.AccessCreateUser) { + return cc.NewErrReply(t, ErrMsgNotAllowedCreateAccounts) + } + + login := t.GetField(hotline.FieldUserLogin).DecodeObfuscatedString() + + // If the account already exists, reply with an error. + if account := cc.Server.AccountManager.Get(login); account != nil { + return cc.NewErrReply(t, fmt.Sprintf(ErrMsgAccountExistsTemplate, login)) + } + + var newAccess hotline.AccessBitmap + copy(newAccess[:], t.GetField(hotline.FieldUserAccess).Data) + + // Prevent account from creating new account with greater permission + for i := 0; i < 64; i++ { + if newAccess.IsSet(i) { + if !cc.Authorize(i) { + return cc.NewErrReply(t, ErrMsgAccountMoreAccess) + } + } + } + + account := hotline.NewAccount(login, string(t.GetField(hotline.FieldUserName).Data), string(t.GetField(hotline.FieldUserPassword).Data), newAccess) + + err := cc.Server.AccountManager.Create(*account) + if err != nil { + return cc.NewErrReply(t, ErrMsgAccountExists) + } + + return append(res, cc.NewReply(t)) +} + +// HandleDeleteUser deletes the specified user from the server's list of allowed users. +// +// Fields used in the request: +// - 105 User login Required +// +// Fields used in the reply: None +func HandleDeleteUser(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) { + if !cc.Authorize(hotline.AccessDeleteUser) { + return cc.NewErrReply(t, ErrMsgNotAllowedDeleteAccounts) + } + + login := t.GetField(hotline.FieldUserLogin).DecodeObfuscatedString() + + if err := cc.Server.AccountManager.Delete(login); err != nil { + cc.Logger.Error("Error deleting account", "err", err) + return cc.NewErrReply(t, ErrMsgDeleteAccount) + } + + for _, client := range cc.Server.ClientMgr.List() { + if client.Account.Login == login { + res = append(res, + hotline.NewTransaction(hotline.TranServerMsg, client.ID, + hotline.NewField(hotline.FieldData, []byte(ErrMsgAccountDeleted)), + hotline.NewField(hotline.FieldChatOptions, []byte{2}), + ), + ) + + go func(c *hotline.ClientConn) { + time.Sleep(2 * time.Second) + c.Disconnect() + }(client) + } + } + + return append(res, cc.NewReply(t)) +} diff --git a/internal/mobius/handlers_accounts_test.go b/internal/mobius/handlers_accounts_test.go new file mode 100644 index 0000000..a7fe827 --- /dev/null +++ b/internal/mobius/handlers_accounts_test.go @@ -0,0 +1,1346 @@ +package mobius + +import ( + "errors" + "fmt" + "testing" + + "github.com/jhalter/mobius/hotline" + "github.com/stretchr/testify/mock" + "golang.org/x/text/encoding/charmap" +) + +func TestHandleGetUser(t *testing.T) { + type args struct { + cc *hotline.ClientConn + t hotline.Transaction + } + tests := []struct { + name string + args args + wantRes []hotline.Transaction + }{ + { + name: "when account is valid", + args: args{ + cc: &hotline.ClientConn{ + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + bits.Set(hotline.AccessOpenUser) + return bits + }(), + }, + Server: &hotline.Server{ + TextDecoder: charmap.Macintosh.NewDecoder(), + TextEncoder: charmap.Macintosh.NewEncoder(), + AccountManager: func() *MockAccountManager { + m := MockAccountManager{} + m.On("Get", "guest").Return(&hotline.Account{ + Login: "guest", + Name: "Guest", + Password: "password", + Access: hotline.AccessBitmap{}, + }) + return &m + }(), + }, + }, + t: hotline.NewTransaction( + hotline.TranGetUser, [2]byte{0, 1}, + hotline.NewField(hotline.FieldUserLogin, []byte("guest")), + ), + }, + wantRes: []hotline.Transaction{ + { + IsReply: 0x01, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldUserName, []byte("Guest")), + hotline.NewField(hotline.FieldUserLogin, hotline.EncodeString([]byte("guest"))), + hotline.NewField(hotline.FieldUserPassword, []byte("password")), + hotline.NewField(hotline.FieldUserAccess, []byte{0, 0, 0, 0, 0, 0, 0, 0}), + }, + }, + }, + }, + { + name: "when user does not have required permission", + args: args{ + cc: &hotline.ClientConn{ + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + return bits + }(), + }, + Server: &hotline.Server{ + TextDecoder: charmap.Macintosh.NewDecoder(), + TextEncoder: charmap.Macintosh.NewEncoder(), + //Accounts: map[string]*Account{}, + }, + }, + t: hotline.NewTransaction( + hotline.TranGetUser, [2]byte{0, 1}, + hotline.NewField(hotline.FieldUserLogin, []byte("nonExistentUser")), + ), + }, + wantRes: []hotline.Transaction{ + { + IsReply: 0x01, + ErrorCode: [4]byte{0, 0, 0, 1}, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldError, []byte("You are not allowed to view accounts.")), + }, + }, + }, + }, + { + name: "when account does not exist", + args: args{ + cc: &hotline.ClientConn{ + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + bits.Set(hotline.AccessOpenUser) + return bits + }(), + }, + Server: &hotline.Server{ + TextDecoder: charmap.Macintosh.NewDecoder(), + TextEncoder: charmap.Macintosh.NewEncoder(), + AccountManager: func() *MockAccountManager { + m := MockAccountManager{} + m.On("Get", "nonExistentUser").Return((*hotline.Account)(nil)) + return &m + }(), + }, + }, + t: hotline.NewTransaction( + hotline.TranGetUser, [2]byte{0, 1}, + hotline.NewField(hotline.FieldUserLogin, []byte("nonExistentUser")), + ), + }, + wantRes: []hotline.Transaction{ + { + Flags: 0x00, + IsReply: 0x01, + Type: [2]byte{0, 0}, + ErrorCode: [4]byte{0, 0, 0, 1}, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldError, []byte("Account does not exist.")), + }, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotRes := HandleGetUser(tt.args.cc, &tt.args.t) + TranAssertEqual(t, tt.wantRes, gotRes) + }) + } +} + +func TestHandleDeleteUser(t *testing.T) { + type args struct { + cc *hotline.ClientConn + t hotline.Transaction + } + tests := []struct { + name string + args args + wantRes []hotline.Transaction + }{ + { + name: "when user exists", + args: args{ + cc: &hotline.ClientConn{ + 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(), + 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{}) // TODO + 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), + }, + }, + }, + { + name: "when user does not have required permission", + args: args{ + cc: &hotline.ClientConn{ + Account: &hotline.Account{ + Access: hotline.AccessBitmap{}, + }, + Server: &hotline.Server{ + TextDecoder: charmap.Macintosh.NewDecoder(), + TextEncoder: charmap.Macintosh.NewEncoder(), + //Accounts: map[string]*Account{}, + }, + }, + t: hotline.NewTransaction( + hotline.TranDeleteUser, [2]byte{0, 1}, + hotline.NewField(hotline.FieldUserLogin, hotline.EncodeString([]byte("testuser"))), + ), + }, + wantRes: []hotline.Transaction{ + { + IsReply: 0x01, + ErrorCode: [4]byte{0, 0, 0, 1}, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldError, []byte("You are not allowed to delete accounts.")), + }, + }, + }, + }, + { + 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) { + gotRes := HandleDeleteUser(tt.args.cc, &tt.args.t) + TranAssertEqual(t, tt.wantRes, gotRes) + }) + } +} + +func TestHandleNewUser(t *testing.T) { + type args struct { + cc *hotline.ClientConn + t hotline.Transaction + } + tests := []struct { + name string + args args + wantRes []hotline.Transaction + }{ + { + name: "when user does not have required permission", + args: args{ + cc: &hotline.ClientConn{ + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + return bits + }(), + }, + Server: &hotline.Server{ + TextDecoder: charmap.Macintosh.NewDecoder(), + TextEncoder: charmap.Macintosh.NewEncoder(), + //Accounts: map[string]*Account{}, + }, + }, + t: hotline.NewTransaction( + hotline.TranNewUser, [2]byte{0, 1}, + ), + }, + wantRes: []hotline.Transaction{ + { + IsReply: 0x01, + ErrorCode: [4]byte{0, 0, 0, 1}, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldError, []byte("You are not allowed to create new accounts.")), + }, + }, + }, + }, + { + name: "when user attempts to create account with greater access", + 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)(nil)) + return &m + }(), + }, + }, + t: hotline.NewTransaction( + hotline.TranNewUser, [2]byte{0, 1}, + hotline.NewField(hotline.FieldUserLogin, hotline.EncodeString([]byte("userB"))), + hotline.NewField( + hotline.FieldUserAccess, + func() []byte { + var bits hotline.AccessBitmap + bits.Set(hotline.AccessDisconUser) + return bits[:] + }(), + ), + ), + }, + 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 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) { + gotRes := HandleNewUser(tt.args.cc, &tt.args.t) + TranAssertEqual(t, tt.wantRes, gotRes) + }) + } +} + +func TestHandleListUsers(t *testing.T) { + type args struct { + cc *hotline.ClientConn + t hotline.Transaction + } + tests := []struct { + name string + args args + wantRes []hotline.Transaction + }{ + { + name: "when user does not have required permission", + args: args{ + cc: &hotline.ClientConn{ + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + return bits + }(), + }, + Server: &hotline.Server{ + TextDecoder: charmap.Macintosh.NewDecoder(), + TextEncoder: charmap.Macintosh.NewEncoder(), + //Accounts: map[string]*Account{}, + }, + }, + t: hotline.NewTransaction( + hotline.TranNewUser, [2]byte{0, 1}, + ), + }, + wantRes: []hotline.Transaction{ + { + IsReply: 0x01, + ErrorCode: [4]byte{0, 0, 0, 1}, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldError, []byte("You are not allowed to view accounts.")), + }, + }, + }, + }, + { + name: "when user has required permission", + args: args{ + cc: &hotline.ClientConn{ + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + bits.Set(hotline.AccessOpenUser) + return bits + }(), + }, + Server: &hotline.Server{ + TextDecoder: charmap.Macintosh.NewDecoder(), + TextEncoder: charmap.Macintosh.NewEncoder(), + AccountManager: func() *MockAccountManager { + m := MockAccountManager{} + m.On("List").Return([]hotline.Account{ + { + Name: "guest", + Login: "guest", + Password: "zz", + Access: hotline.AccessBitmap{255, 255, 255, 255, 255, 255, 255, 255}, + }, + }) + return &m + }(), + }, + }, + t: hotline.NewTransaction( + hotline.TranGetClientInfoText, [2]byte{0, 1}, + hotline.NewField(hotline.FieldUserID, []byte{0, 1}), + ), + }, + wantRes: []hotline.Transaction{ + { + IsReply: 0x01, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldData, []byte{ + 0x00, 0x04, 0x00, 0x66, 0x00, 0x05, 0x67, 0x75, 0x65, 0x73, 0x74, 0x00, 0x69, 0x00, 0x05, 0x98, + 0x8a, 0x9a, 0x8c, 0x8b, 0x00, 0x6e, 0x00, 0x08, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0x00, 0x6a, 0x00, 0x01, 0x78, + }), + }, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotRes := HandleListUsers(tt.args.cc, &tt.args.t) + + TranAssertEqual(t, tt.wantRes, gotRes) + }) + } +} + +func TestHandleUpdateUser(t *testing.T) { + type args struct { + cc *hotline.ClientConn + t hotline.Transaction + } + tests := []struct { + name string + args args + wantRes []hotline.Transaction + }{ + { + name: "when action is create user without required permission", + args: args{ + cc: &hotline.ClientConn{ + Logger: NewTestLogger(), + Server: &hotline.Server{ + TextDecoder: charmap.Macintosh.NewDecoder(), + TextEncoder: charmap.Macintosh.NewEncoder(), + AccountManager: func() *MockAccountManager { + m := MockAccountManager{} + m.On("Get", "bbb").Return((*hotline.Account)(nil)) + return &m + }(), + Logger: NewTestLogger(), + }, + Account: &hotline.Account{ + Access: hotline.AccessBitmap{}, + }, + }, + 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("You are not allowed to create new accounts.")), + }, + }, + }, + }, + { + name: "when action is modify user without required 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("Get", "bbb").Return(&hotline.Account{}) + return &m + }(), + }, + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + 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("You are not allowed to modify accounts.")), + }, + }, + }, + }, + { + name: "when action is delete user without required permission", + args: args{ + cc: &hotline.ClientConn{ + Logger: NewTestLogger(), + Server: &hotline.Server{TextDecoder: charmap.Macintosh.NewDecoder(), TextEncoder: charmap.Macintosh.NewEncoder()}, + Account: &hotline.Account{ + Access: hotline.AccessBitmap{}, + }, + }, + t: hotline.NewTransaction( + hotline.TranUpdateUser, + [2]byte{0, 0}, + hotline.NewField(hotline.FieldData, []byte{ + 0x00, 0x01, + 0x00, 0x65, + 0x00, 0x03, + 0x88, 0x9e, 0x8b, + }), + ), + }, + wantRes: []hotline.Transaction{ + { + IsReply: 0x01, + ErrorCode: [4]byte{0, 0, 0, 1}, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldError, []byte("You are not allowed to delete accounts.")), + }, + }, + }, + }, + { + 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: []hotline.Transaction{ + { + IsReply: 0x01, + ErrorCode: [4]byte{0, 0, 0, 1}, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldError, []byte(ErrMsgDeleteAccount)), + }, + }, + }, + }, + { + 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) { + gotRes := HandleUpdateUser(tt.args.cc, &tt.args.t) + TranAssertEqual(t, tt.wantRes, gotRes) + }) + } +} + +func TestHandleSetUser(t *testing.T) { + type args struct { + cc *hotline.ClientConn + t hotline.Transaction + } + tests := []struct { + name string + args args + wantRes []hotline.Transaction + }{ + { + name: "when user does not have required permission", + args: args{ + cc: &hotline.ClientConn{ + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + return bits + }(), + }, + Server: &hotline.Server{TextDecoder: charmap.Macintosh.NewDecoder(), TextEncoder: charmap.Macintosh.NewEncoder()}, + }, + t: hotline.NewTransaction( + hotline.TranSetUser, [2]byte{0, 1}, + ), + }, + wantRes: []hotline.Transaction{ + { + Flags: 0x00, + IsReply: 0x01, + Type: [2]byte{0, 0}, + ErrorCode: [4]byte{0, 0, 0, 1}, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldError, []byte(ErrMsgNotAllowedModifyAccounts)), + }, + }, + }, + }, + { + name: "when account is not found", + args: args{ + cc: &hotline.ClientConn{ + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + bits.Set(hotline.AccessModifyUser) + return bits + }(), + }, + Logger: NewTestLogger(), + ID: [2]byte{0, 1}, + Server: &hotline.Server{ + TextDecoder: charmap.Macintosh.NewDecoder(), + TextEncoder: charmap.Macintosh.NewEncoder(), + AccountManager: func() *MockAccountManager { + m := MockAccountManager{} + m.On("Get", "testuser").Return((*hotline.Account)(nil)) + return &m + }(), + }, + }, + t: hotline.NewTransaction( + hotline.TranSetUser, [2]byte{0, 1}, + hotline.NewField(hotline.FieldUserLogin, hotline.EncodeString([]byte("testuser"))), + hotline.NewField(hotline.FieldUserName, []byte("Test User")), + hotline.NewField(hotline.FieldUserAccess, make([]byte, 8)), + ), + }, + wantRes: []hotline.Transaction{ + { + ClientID: [2]byte{0, 1}, + IsReply: 0x01, + ErrorCode: [4]byte{0, 0, 0, 1}, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldError, []byte(ErrMsgAccountNotFound)), + }, + }, + }, + }, + { + name: "when Update returns an error", + args: args{ + cc: &hotline.ClientConn{ + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + bits.Set(hotline.AccessModifyUser) + return bits + }(), + }, + Logger: NewTestLogger(), + ID: [2]byte{0, 1}, + Server: &hotline.Server{ + TextDecoder: charmap.Macintosh.NewDecoder(), + TextEncoder: charmap.Macintosh.NewEncoder(), + AccountManager: func() *MockAccountManager { + m := MockAccountManager{} + m.On("Get", "testuser").Return(&hotline.Account{ + Login: "testuser", + Name: "Old Name", + }) + m.On("Update", mock.Anything, "testuser").Return(errors.New("disk full")) + return &m + }(), + }, + }, + t: hotline.NewTransaction( + hotline.TranSetUser, [2]byte{0, 1}, + hotline.NewField(hotline.FieldUserLogin, hotline.EncodeString([]byte("testuser"))), + hotline.NewField(hotline.FieldUserName, []byte("New Name")), + hotline.NewField(hotline.FieldUserAccess, make([]byte, 8)), + ), + }, + wantRes: []hotline.Transaction{ + { + ClientID: [2]byte{0, 1}, + IsReply: 0x01, + ErrorCode: [4]byte{0, 0, 0, 1}, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldError, []byte(ErrMsgUpdateAccount)), + }, + }, + }, + }, + { + name: "when update is successful with password change", + args: args{ + cc: &hotline.ClientConn{ + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + bits.Set(hotline.AccessModifyUser) + return bits + }(), + }, + Logger: NewTestLogger(), + ID: [2]byte{0, 1}, + Server: &hotline.Server{ + TextDecoder: charmap.Macintosh.NewDecoder(), + TextEncoder: charmap.Macintosh.NewEncoder(), + AccountManager: func() *MockAccountManager { + m := MockAccountManager{} + m.On("Get", "testuser").Return(&hotline.Account{ + Login: "testuser", + Name: "Old Name", + }) + m.On("Update", mock.Anything, "testuser").Return(nil) + return &m + }(), + ClientMgr: func() *hotline.MockClientMgr { + m := hotline.MockClientMgr{} + m.On("List").Return([]*hotline.ClientConn{}) + return &m + }(), + }, + }, + t: hotline.NewTransaction( + hotline.TranSetUser, [2]byte{0, 1}, + hotline.NewField(hotline.FieldUserLogin, hotline.EncodeString([]byte("testuser"))), + hotline.NewField(hotline.FieldUserName, []byte("New Name")), + hotline.NewField(hotline.FieldUserPassword, []byte("newpass")), + hotline.NewField(hotline.FieldUserAccess, make([]byte, 8)), + ), + }, + wantRes: []hotline.Transaction{ + { + ClientID: [2]byte{0, 1}, + IsReply: 0x01, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotRes := HandleSetUser(tt.args.cc, &tt.args.t) + + TranAssertEqual(t, tt.wantRes, gotRes) + }) + } +} diff --git a/internal/mobius/handlers_chat.go b/internal/mobius/handlers_chat.go new file mode 100644 index 0000000..bda4e58 --- /dev/null +++ b/internal/mobius/handlers_chat.go @@ -0,0 +1,399 @@ +package mobius + +import ( + "bytes" + "encoding/binary" + "fmt" + "io" + "math/big" + + "github.com/jhalter/mobius/hotline" +) + +// HandleChatSend sends a chat message to the chat. +// +// Access: Send Chat (10) +// +// Fields used in the request: +// - 109 Chat options Optional - Normal (0) or alternate (1) chat message +// - 114 Chat ID Optional +// - 101 Data Chat message string +// +// Reply is not expected. +func HandleChatSend(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) { + if !cc.Authorize(hotline.AccessSendChat) { + return cc.NewErrReply(t, ErrMsgNotAllowedParticipateChat) + } + + // Truncate long usernames + // %13.13s: This means a string that is right-aligned in a field of 13 characters. + // If the string is longer than 13 characters, it will be truncated to 13 characters. + formattedMsg := fmt.Sprintf("\r%13.13s: %s", cc.UserName, t.GetField(hotline.FieldData).Data) + + // By holding the option key, Hotline chat allows users to send /me formatted messages like: + // *** Halcyon does stuff + // This is indicated by the presence of the optional field FieldChatOptions set to a value of 1. + // Most clients do not send this option for normal chat messages. + if t.GetField(hotline.FieldChatOptions).Data != nil && bytes.Equal(t.GetField(hotline.FieldChatOptions).Data, []byte{0, 1}) { + formattedMsg = fmt.Sprintf("\r*** %s %s", cc.UserName, t.GetField(hotline.FieldData).Data) + } + + // Truncate the message to the limit. This does not handle the edge case of a string ending on multibyte character. + formattedMsg = formattedMsg[:min(len(formattedMsg), hotline.LimitChatMsg)] + + // The ChatID field is used to identify messages as belonging to a private chat. + // All clients *except* Frogblast omit this field for public chat, but Frogblast sends a value of 00 00 00 00. + chatID := t.GetField(hotline.FieldChatID).Data + if chatID != nil && !bytes.Equal([]byte{0, 0, 0, 0}, chatID) { + privChatID, ok := hotline.ChatIDFromBytes(chatID) + if !ok { + return cc.NewErrReply(t, ErrMsgInvalidChatID) + } + + // send the message to all connected clients of the private chat + for _, c := range cc.Server.ChatMgr.Members(privChatID) { + res = append(res, hotline.NewTransaction( + hotline.TranChatMsg, + c.ID, + hotline.NewField(hotline.FieldChatID, chatID), + hotline.NewField(hotline.FieldData, []byte(formattedMsg)), + )) + } + return res + } + + //cc.Server.mux.Lock() + for _, c := range cc.Server.ClientMgr.List() { + if c == nil || cc.Account == nil { + continue + } + // Skip clients that do not have the read chat permission. + if c.Authorize(hotline.AccessReadChat) { + res = append(res, hotline.NewTransaction(hotline.TranChatMsg, c.ID, hotline.NewField(hotline.FieldData, []byte(formattedMsg)))) + } + } + //cc.Server.mux.Unlock() + + return res +} + +// HandleSendInstantMsg sends an instant message to a user on the current server. +// +// Fields used in the request: +// - 103 User ID Target user +// - 113 Options myOpt_UserMessage (1), myOpt_RefuseMessage (2), myOpt_RefuseChat (3), myOpt_AutomaticResponse (4) +// - 101 Data Optional +// - 214 Quoting message Optional +// +// Fields used in the reply: None +func HandleSendInstantMsg(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) { + if !cc.Authorize(hotline.AccessSendPrivMsg) { + return cc.NewErrReply(t, ErrMsgNotAllowedSendPrivateMsg) + } + + msg := t.GetField(hotline.FieldData) + userID := t.GetField(hotline.FieldUserID) + + targetID, ok := hotline.ClientIDFromBytes(userID.Data) + if !ok { + return cc.NewErrReply(t, ErrMsgInvalidUserID) + } + + reply := hotline.NewTransaction( + hotline.TranServerMsg, + targetID, + hotline.NewField(hotline.FieldData, msg.Data), + hotline.NewField(hotline.FieldUserName, cc.UserName), + hotline.NewField(hotline.FieldUserID, cc.ID[:]), + hotline.NewField(hotline.FieldOptions, []byte{0, 1}), + ) + + // Later versions of Hotline include the original message in the FieldQuotingMsg field so + // the receiving client can display both the received message and what it is in reply to + if t.GetField(hotline.FieldQuotingMsg).Data != nil { + reply.Fields = append(reply.Fields, hotline.NewField(hotline.FieldQuotingMsg, t.GetField(hotline.FieldQuotingMsg).Data)) + } + + otherClient := cc.Server.ClientMgr.Get(targetID) + if otherClient == nil { + // Target user is no longer connected. The protocol defines no reply for + // this transaction, so there is nothing to send back. + return res + } + + // Check if target user has "Refuse private messages" flag + if otherClient.Flags.IsSet(hotline.UserFlagRefusePM) { + res = append(res, + hotline.NewTransaction( + hotline.TranServerMsg, + cc.ID, + hotline.NewField(hotline.FieldData, []byte(fmt.Sprintf(ErrMsgDoesNotAcceptTemplate, string(otherClient.UserName), "private messages"))), + hotline.NewField(hotline.FieldUserName, otherClient.UserName), + hotline.NewField(hotline.FieldUserID, otherClient.ID[:]), + hotline.NewField(hotline.FieldOptions, []byte{0, 2}), + ), + ) + } else { + res = append(res, reply) + } + + // Respond with auto reply if other client has it enabled + if len(otherClient.AutoReply) > 0 { + res = append(res, + hotline.NewTransaction( + hotline.TranServerMsg, + cc.ID, + hotline.NewField(hotline.FieldData, otherClient.AutoReply), + hotline.NewField(hotline.FieldUserName, otherClient.UserName), + hotline.NewField(hotline.FieldUserID, otherClient.ID[:]), + hotline.NewField(hotline.FieldOptions, []byte{0, 1}), + ), + ) + } + + return append(res, cc.NewReply(t)) +} + +// HandleInviteNewChat invites users to a new chat. +// +// Fields used in the request: +// - 103 User ID Optional - User IDs to invite +// +// Fields used in the reply: +// - 103 User ID Inviting user's ID +// - 104 User icon ID Inviting user's icon +// - 112 User flags Inviting user's flags +// - 102 User name Inviting user's name +// - 114 Chat ID New chat room identifier +func HandleInviteNewChat(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) { + if !cc.Authorize(hotline.AccessOpenChat) { + return cc.NewErrReply(t, ErrMsgNotAllowedRequestPrivateChat) + } + + // Client to Invite + targetID, ok := hotline.ClientIDFromBytes(t.GetField(hotline.FieldUserID).Data) + if !ok { + return cc.NewErrReply(t, ErrMsgInvalidUserID) + } + + // Check if target user has "Refuse private chat" flag + targetClient := cc.Server.ClientMgr.Get(targetID) + if targetClient == nil { + return cc.NewErrReply(t, ErrMsgUserNotFound) + } + + // Create a new chat with self as initial member. + newChatID := cc.Server.ChatMgr.New(cc) + + flagBitmap := big.NewInt(int64(binary.BigEndian.Uint16(targetClient.Flags[:]))) + if flagBitmap.Bit(hotline.UserFlagRefusePChat) == 1 { + res = append(res, + hotline.NewTransaction( + hotline.TranServerMsg, + cc.ID, + hotline.NewField(hotline.FieldData, []byte(fmt.Sprintf(ErrMsgDoesNotAcceptTemplate, string(targetClient.UserName), "private chats"))), + hotline.NewField(hotline.FieldUserName, targetClient.UserName), + hotline.NewField(hotline.FieldUserID, targetClient.ID[:]), + hotline.NewField(hotline.FieldOptions, []byte{0, 2}), + ), + ) + } else { + res = append(res, + hotline.NewTransaction( + hotline.TranInviteToChat, + targetID, + hotline.NewField(hotline.FieldChatID, newChatID[:]), + hotline.NewField(hotline.FieldUserName, cc.UserName), + hotline.NewField(hotline.FieldUserID, cc.ID[:]), + ), + ) + } + + return append( + res, + cc.NewReply(t, + hotline.NewField(hotline.FieldChatID, newChatID[:]), + hotline.NewField(hotline.FieldUserName, cc.UserName), + hotline.NewField(hotline.FieldUserID, cc.ID[:]), + hotline.NewField(hotline.FieldUserIconID, cc.Icon), + hotline.NewField(hotline.FieldUserFlags, cc.Flags[:]), + ), + ) +} + +// HandleInviteToChat invites a user to an existing chat. +// +// Fields used in the request: +// - 103 User ID Required - User to invite +// - 114 Chat ID Required +// +// Reply is not expected. +func HandleInviteToChat(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) { + if !cc.Authorize(hotline.AccessOpenChat) { + return cc.NewErrReply(t, ErrMsgNotAllowedRequestPrivateChat) + } + + // Client to Invite + targetID, ok := hotline.ClientIDFromBytes(t.GetField(hotline.FieldUserID).Data) + if !ok { + return cc.NewErrReply(t, ErrMsgInvalidUserID) + } + chatID := t.GetField(hotline.FieldChatID).Data + + return []hotline.Transaction{ + hotline.NewTransaction( + hotline.TranInviteToChat, + targetID, + hotline.NewField(hotline.FieldChatID, chatID), + hotline.NewField(hotline.FieldUserName, cc.UserName), + hotline.NewField(hotline.FieldUserID, cc.ID[:]), + ), + cc.NewReply( + t, + hotline.NewField(hotline.FieldChatID, chatID), + hotline.NewField(hotline.FieldUserName, cc.UserName), + hotline.NewField(hotline.FieldUserID, cc.ID[:]), + hotline.NewField(hotline.FieldUserIconID, cc.Icon), + hotline.NewField(hotline.FieldUserFlags, cc.Flags[:]), + ), + } +} + +// HandleRejectChatInvite rejects an invitation to join a chat. +// +// Fields used in the request: +// - 114 Chat ID Required +// +// Reply is not expected. +func HandleRejectChatInvite(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) { + chatID, ok := hotline.ChatIDFromBytes(t.GetField(hotline.FieldChatID).Data) + if !ok { + cc.Logger.Error("reject chat invite: invalid chat ID") + return res + } + + for _, c := range cc.Server.ChatMgr.Members(chatID) { + res = append(res, + hotline.NewTransaction( + hotline.TranChatMsg, + c.ID, + hotline.NewField(hotline.FieldChatID, chatID[:]), + hotline.NewField(hotline.FieldData, append(cc.UserName, []byte(" declined invitation to chat")...)), + ), + ) + } + + return res +} + +// HandleJoinChat joins a chat. +// +// Fields used in the request: +// - 114 Chat ID Required +// +// Fields used in the reply: +// - 115 Chat subject Current chat room subject +// - 300 User name with info Repeated - User information for each chat member +func HandleJoinChat(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) { + chatID, ok := hotline.ChatIDFromBytes(t.GetField(hotline.FieldChatID).Data) + if !ok { + return cc.NewErrReply(t, ErrMsgInvalidChatID) + } + + // Send TranNotifyChatChangeUser to current members of the chat to inform of new user + for _, c := range cc.Server.ChatMgr.Members(chatID) { + res = append(res, + hotline.NewTransaction( + hotline.TranNotifyChatChangeUser, + c.ID, + hotline.NewField(hotline.FieldChatID, chatID[:]), + hotline.NewField(hotline.FieldUserName, cc.UserName), + hotline.NewField(hotline.FieldUserID, cc.ID[:]), + hotline.NewField(hotline.FieldUserIconID, cc.Icon), + hotline.NewField(hotline.FieldUserFlags, cc.Flags[:]), + ), + ) + } + + cc.Server.ChatMgr.Join(chatID, cc) + + subject := cc.Server.ChatMgr.GetSubject(chatID) + + replyFields := []hotline.Field{hotline.NewField(hotline.FieldChatSubject, []byte(subject))} + for _, c := range cc.Server.ChatMgr.Members(chatID) { + b, err := io.ReadAll(&hotline.User{ + ID: c.ID, + Icon: c.Icon, + Flags: c.Flags[:], + Name: string(c.UserName), + }) + if err != nil { + cc.Logger.Error("join chat: read member info", "err", err) + return cc.NewErrReply(t, ErrMsgJoinChat) + } + replyFields = append(replyFields, hotline.NewField(hotline.FieldUsernameWithInfo, b)) + } + + return append(res, cc.NewReply(t, replyFields...)) +} + +// HandleLeaveChat leaves a chat. +// +// Fields used in the request: +// - 114 Chat ID Required +// +// Reply is not expected. +func HandleLeaveChat(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) { + chatID, ok := hotline.ChatIDFromBytes(t.GetField(hotline.FieldChatID).Data) + if !ok { + cc.Logger.Error("leave chat: invalid chat ID") + return res + } + + cc.Server.ChatMgr.Leave(chatID, cc.ID) + + // Notify members of the private chat that the user has left + for _, c := range cc.Server.ChatMgr.Members(chatID) { + res = append(res, + hotline.NewTransaction( + hotline.TranNotifyChatDeleteUser, + c.ID, + hotline.NewField(hotline.FieldChatID, chatID[:]), + hotline.NewField(hotline.FieldUserID, cc.ID[:]), + ), + ) + } + + return res +} + +// HandleSetChatSubject sets the chat subject for a chat. +// +// Fields used in the request: +// - 114 Chat ID Required +// - 115 Chat subject Required - Chat subject string +// +// Reply is not expected. +func HandleSetChatSubject(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) { + chatID, ok := hotline.ChatIDFromBytes(t.GetField(hotline.FieldChatID).Data) + if !ok { + cc.Logger.Error("set chat subject: invalid chat ID") + return res + } + + cc.Server.ChatMgr.SetSubject(chatID, string(t.GetField(hotline.FieldChatSubject).Data)) + + // Notify chat members of new subject. + for _, c := range cc.Server.ChatMgr.Members(chatID) { + res = append(res, + hotline.NewTransaction( + hotline.TranNotifyChatSubject, + c.ID, + hotline.NewField(hotline.FieldChatID, chatID[:]), + hotline.NewField(hotline.FieldChatSubject, t.GetField(hotline.FieldChatSubject).Data), + ), + ) + } + + return res +} diff --git a/internal/mobius/handlers_chat_test.go b/internal/mobius/handlers_chat_test.go new file mode 100644 index 0000000..af16159 --- /dev/null +++ b/internal/mobius/handlers_chat_test.go @@ -0,0 +1,1497 @@ +package mobius + +import ( + "testing" + + "github.com/jhalter/mobius/hotline" + "github.com/stretchr/testify/mock" + "golang.org/x/text/encoding/charmap" +) + +func TestHandleSetChatSubject(t *testing.T) { + type args struct { + cc *hotline.ClientConn + t hotline.Transaction + } + tests := []struct { + name string + args args + want []hotline.Transaction + }{ + { + name: "sends chat subject to private chat members", + args: args{ + cc: &hotline.ClientConn{ + UserName: []byte{0x00, 0x01}, + Server: &hotline.Server{ + TextDecoder: charmap.Macintosh.NewDecoder(), + TextEncoder: charmap.Macintosh.NewEncoder(), + ChatMgr: func() *hotline.MockChatManager { + m := hotline.MockChatManager{} + m.On("Members", hotline.ChatID{0x0, 0x0, 0x0, 0x1}).Return([]*hotline.ClientConn{ + { + Account: &hotline.Account{ + Access: hotline.AccessBitmap{255, 255, 255, 255, 255, 255, 255, 255}, + }, + ID: [2]byte{0, 1}, + }, + { + Account: &hotline.Account{ + Access: hotline.AccessBitmap{255, 255, 255, 255, 255, 255, 255, 255}, + }, + ID: [2]byte{0, 2}, + }, + }) + m.On("SetSubject", hotline.ChatID{0x0, 0x0, 0x0, 0x1}, "Test Subject") + return &m + }(), + //PrivateChats: map[[4]byte]*PrivateChat{ + // [4]byte{0, 0, 0, 1}: { + // Subject: "unset", + // ClientConn: map[[2]byte]*ClientConn{ + // [2]byte{0, 1}: { + // Account: &hotline.Account{ + // Access: AccessBitmap{255, 255, 255, 255, 255, 255, 255, 255}, + // }, + // ID: [2]byte{0, 1}, + // }, + // [2]byte{0, 2}: { + // Account: &hotline.Account{ + // Access: AccessBitmap{255, 255, 255, 255, 255, 255, 255, 255}, + // }, + // ID: [2]byte{0, 2}, + // }, + // }, + // }, + //}, + ClientMgr: func() *hotline.MockClientMgr { + m := hotline.MockClientMgr{} + m.On("List").Return([]*hotline.ClientConn{ + { + Account: &hotline.Account{ + Access: hotline.AccessBitmap{255, 255, 255, 255, 255, 255, 255, 255}, + }, + ID: [2]byte{0, 1}, + }, + { + Account: &hotline.Account{ + Access: hotline.AccessBitmap{255, 255, 255, 255, 255, 255, 255, 255}, + }, + ID: [2]byte{0, 2}, + }, + }, + ) + return &m + }(), + }, + }, + t: hotline.Transaction{ + Type: [2]byte{0, 0x6a}, + ID: [4]byte{0, 0, 0, 1}, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldChatID, []byte{0, 0, 0, 1}), + hotline.NewField(hotline.FieldChatSubject, []byte("Test Subject")), + }, + }, + }, + want: []hotline.Transaction{ + { + ClientID: [2]byte{0, 1}, + Type: [2]byte{0, 0x77}, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldChatID, []byte{0, 0, 0, 1}), + hotline.NewField(hotline.FieldChatSubject, []byte("Test Subject")), + }, + }, + { + ClientID: [2]byte{0, 2}, + Type: [2]byte{0, 0x77}, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldChatID, []byte{0, 0, 0, 1}), + hotline.NewField(hotline.FieldChatSubject, []byte("Test Subject")), + }, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := HandleSetChatSubject(tt.args.cc, &tt.args.t) + if !TranAssertEqual(t, tt.want, got) { + t.Errorf("HandleSetChatSubject() got = %v, want %v", got, tt.want) + } + }) + } +} + +func TestHandleLeaveChat(t *testing.T) { + type args struct { + cc *hotline.ClientConn + t hotline.Transaction + } + tests := []struct { + name string + args args + want []hotline.Transaction + }{ + { + name: "when client 2 leaves chat", + args: args{ + cc: &hotline.ClientConn{ + ID: [2]byte{0, 2}, + Server: &hotline.Server{ + TextDecoder: charmap.Macintosh.NewDecoder(), + TextEncoder: charmap.Macintosh.NewEncoder(), + ChatMgr: func() *hotline.MockChatManager { + m := hotline.MockChatManager{} + m.On("Members", hotline.ChatID{0x0, 0x0, 0x0, 0x1}).Return([]*hotline.ClientConn{ + { + Account: &hotline.Account{ + Access: hotline.AccessBitmap{255, 255, 255, 255, 255, 255, 255, 255}, + }, + ID: [2]byte{0, 1}, + }, + }) + m.On("Leave", hotline.ChatID{0x0, 0x0, 0x0, 0x1}, [2]uint8{0x0, 0x2}) + m.On("GetSubject").Return("unset") + return &m + }(), + ClientMgr: func() *hotline.MockClientMgr { + m := hotline.MockClientMgr{} + m.On("Get").Return([]*hotline.ClientConn{ + { + Account: &hotline.Account{ + Access: hotline.AccessBitmap{255, 255, 255, 255, 255, 255, 255, 255}, + }, + ID: [2]byte{0, 1}, + }, + { + Account: &hotline.Account{ + Access: hotline.AccessBitmap{255, 255, 255, 255, 255, 255, 255, 255}, + }, + ID: [2]byte{0, 2}, + }, + }, + ) + return &m + }(), + }, + }, + t: hotline.NewTransaction(hotline.TranDeleteUser, [2]byte{}, hotline.NewField(hotline.FieldChatID, []byte{0, 0, 0, 1})), + }, + want: []hotline.Transaction{ + { + ClientID: [2]byte{0, 1}, + Type: [2]byte{0, 0x76}, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldChatID, []byte{0, 0, 0, 1}), + hotline.NewField(hotline.FieldUserID, []byte{0, 2}), + }, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := HandleLeaveChat(tt.args.cc, &tt.args.t) + if !TranAssertEqual(t, tt.want, got) { + t.Errorf("HandleLeaveChat() got = %v, want %v", got, tt.want) + } + }) + } +} + +func TestHandleChatSend(t *testing.T) { + type args struct { + cc *hotline.ClientConn + t hotline.Transaction + } + tests := []struct { + name string + args args + want []hotline.Transaction + }{ + { + name: "sends chat msg transaction to all clients", + args: args{ + cc: &hotline.ClientConn{ + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + bits.Set(hotline.AccessSendChat) + return bits + }(), + }, + UserName: []byte{0x00, 0x01}, + Server: &hotline.Server{ + TextDecoder: charmap.Macintosh.NewDecoder(), + TextEncoder: charmap.Macintosh.NewEncoder(), + ClientMgr: func() *hotline.MockClientMgr { + m := hotline.MockClientMgr{} + m.On("List").Return([]*hotline.ClientConn{ + { + Account: &hotline.Account{ + Access: hotline.AccessBitmap{255, 255, 255, 255, 255, 255, 255, 255}, + }, + ID: [2]byte{0, 1}, + }, + { + Account: &hotline.Account{ + Access: hotline.AccessBitmap{255, 255, 255, 255, 255, 255, 255, 255}, + }, + ID: [2]byte{0, 2}, + }, + }, + ) + return &m + }(), + }, + }, + t: hotline.Transaction{ + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldData, []byte("hai")), + }, + }, + }, + want: []hotline.Transaction{ + { + ClientID: [2]byte{0, 1}, + Flags: 0x00, + IsReply: 0x00, + Type: [2]byte{0, 0x6a}, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldData, []byte{0x0d, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x00, 0x01, 0x3a, 0x20, 0x20, 0x68, 0x61, 0x69}), + }, + }, + { + ClientID: [2]byte{0, 2}, + Flags: 0x00, + IsReply: 0x00, + Type: [2]byte{0, 0x6a}, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldData, []byte{0x0d, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x00, 0x01, 0x3a, 0x20, 0x20, 0x68, 0x61, 0x69}), + }, + }, + }, + }, + { + name: "treats Chat Type 00 00 00 00 as a public chat message", + args: args{ + cc: &hotline.ClientConn{ + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + bits.Set(hotline.AccessSendChat) + return bits + }(), + }, + UserName: []byte{0x00, 0x01}, + Server: &hotline.Server{ + TextDecoder: charmap.Macintosh.NewDecoder(), + TextEncoder: charmap.Macintosh.NewEncoder(), + ClientMgr: func() *hotline.MockClientMgr { + m := hotline.MockClientMgr{} + m.On("List").Return([]*hotline.ClientConn{ + { + Account: &hotline.Account{ + Access: hotline.AccessBitmap{255, 255, 255, 255, 255, 255, 255, 255}, + }, + ID: [2]byte{0, 1}, + }, + { + Account: &hotline.Account{ + Access: hotline.AccessBitmap{255, 255, 255, 255, 255, 255, 255, 255}, + }, + ID: [2]byte{0, 2}, + }, + }, + ) + return &m + }(), + }, + }, + t: hotline.Transaction{ + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldData, []byte("hai")), + hotline.NewField(hotline.FieldChatID, []byte{0, 0, 0, 0}), + }, + }, + }, + want: []hotline.Transaction{ + { + ClientID: [2]byte{0, 1}, + Type: [2]byte{0, 0x6a}, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldData, []byte{0x0d, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x00, 0x01, 0x3a, 0x20, 0x20, 0x68, 0x61, 0x69}), + }, + }, + { + ClientID: [2]byte{0, 2}, + Type: [2]byte{0, 0x6a}, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldData, []byte{0x0d, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x00, 0x01, 0x3a, 0x20, 0x20, 0x68, 0x61, 0x69}), + }, + }, + }, + }, + { + name: "when user does not have required permission", + args: args{ + cc: &hotline.ClientConn{ + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + return bits + }(), + }, + Server: &hotline.Server{ + TextDecoder: charmap.Macintosh.NewDecoder(), + TextEncoder: charmap.Macintosh.NewEncoder(), + //Accounts: map[string]*Account{}, + }, + }, + t: hotline.NewTransaction( + hotline.TranChatSend, [2]byte{0, 1}, + hotline.NewField(hotline.FieldData, []byte("hai")), + ), + }, + want: []hotline.Transaction{ + { + IsReply: 0x01, + ErrorCode: [4]byte{0, 0, 0, 1}, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldError, []byte("You are not allowed to participate in chat.")), + }, + }, + }, + }, + { + name: "sends chat msg as emote if FieldChatOptions is set to 1", + args: args{ + cc: &hotline.ClientConn{ + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + bits.Set(hotline.AccessSendChat) + return bits + }(), + }, + UserName: []byte("Testy McTest"), + Server: &hotline.Server{ + TextDecoder: charmap.Macintosh.NewDecoder(), + TextEncoder: charmap.Macintosh.NewEncoder(), + ClientMgr: func() *hotline.MockClientMgr { + m := hotline.MockClientMgr{} + m.On("List").Return([]*hotline.ClientConn{ + { + Account: &hotline.Account{ + Access: hotline.AccessBitmap{255, 255, 255, 255, 255, 255, 255, 255}, + }, + ID: [2]byte{0, 1}, + }, + { + Account: &hotline.Account{ + Access: hotline.AccessBitmap{255, 255, 255, 255, 255, 255, 255, 255}, + }, + ID: [2]byte{0, 2}, + }, + }, + ) + return &m + }(), + }, + }, + t: hotline.Transaction{ + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldData, []byte("performed action")), + hotline.NewField(hotline.FieldChatOptions, []byte{0x00, 0x01}), + }, + }, + }, + want: []hotline.Transaction{ + { + ClientID: [2]byte{0, 1}, + Flags: 0x00, + IsReply: 0x00, + Type: [2]byte{0, 0x6a}, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldData, []byte("\r*** Testy McTest performed action")), + }, + }, + { + ClientID: [2]byte{0, 2}, + Flags: 0x00, + IsReply: 0x00, + Type: [2]byte{0, 0x6a}, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldData, []byte("\r*** Testy McTest performed action")), + }, + }, + }, + }, + { + name: "does not send chat msg as emote if FieldChatOptions is set to 0", + args: args{ + cc: &hotline.ClientConn{ + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + bits.Set(hotline.AccessSendChat) + return bits + }(), + }, + UserName: []byte("Testy McTest"), + Server: &hotline.Server{ + TextDecoder: charmap.Macintosh.NewDecoder(), + TextEncoder: charmap.Macintosh.NewEncoder(), + ClientMgr: func() *hotline.MockClientMgr { + m := hotline.MockClientMgr{} + m.On("List").Return([]*hotline.ClientConn{ + { + Account: &hotline.Account{ + Access: hotline.AccessBitmap{255, 255, 255, 255, 255, 255, 255, 255}, + }, + ID: [2]byte{0, 1}, + }, + { + Account: &hotline.Account{ + Access: hotline.AccessBitmap{255, 255, 255, 255, 255, 255, 255, 255}, + }, + ID: [2]byte{0, 2}, + }, + }, + ) + return &m + }(), + }, + }, + t: hotline.Transaction{ + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldData, []byte("hello")), + hotline.NewField(hotline.FieldChatOptions, []byte{0x00, 0x00}), + }, + }, + }, + want: []hotline.Transaction{ + { + ClientID: [2]byte{0, 1}, + Type: [2]byte{0, 0x6a}, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldData, []byte("\r Testy McTest: hello")), + }, + }, + { + ClientID: [2]byte{0, 2}, + Type: [2]byte{0, 0x6a}, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldData, []byte("\r Testy McTest: hello")), + }, + }, + }, + }, + { + name: "only sends chat msg to clients with AccessReadChat permission", + args: args{ + cc: &hotline.ClientConn{ + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + bits.Set(hotline.AccessSendChat) + return bits + }(), + }, + UserName: []byte{0x00, 0x01}, + Server: &hotline.Server{ + TextDecoder: charmap.Macintosh.NewDecoder(), + TextEncoder: charmap.Macintosh.NewEncoder(), + ClientMgr: func() *hotline.MockClientMgr { + m := hotline.MockClientMgr{} + m.On("List").Return([]*hotline.ClientConn{ + { + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + bits.Set(hotline.AccessReadChat) + return bits + }(), + }, + ID: [2]byte{0, 1}, + }, + { + Account: &hotline.Account{}, + ID: [2]byte{0, 2}, + }, + }, + ) + return &m + }(), + }, + }, + t: hotline.Transaction{ + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldData, []byte("hai")), + }, + }, + }, + want: []hotline.Transaction{ + { + ClientID: [2]byte{0, 1}, + Type: [2]byte{0, 0x6a}, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldData, []byte{0x0d, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x00, 0x01, 0x3a, 0x20, 0x20, 0x68, 0x61, 0x69}), + }, + }, + }, + }, + { + name: "only sends private chat msg to members of private chat", + args: args{ + cc: &hotline.ClientConn{ + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + bits.Set(hotline.AccessSendChat) + return bits + }(), + }, + UserName: []byte{0x00, 0x01}, + Server: &hotline.Server{ + TextDecoder: charmap.Macintosh.NewDecoder(), + TextEncoder: charmap.Macintosh.NewEncoder(), + ChatMgr: func() *hotline.MockChatManager { + m := hotline.MockChatManager{} + m.On("Members", hotline.ChatID{0x0, 0x0, 0x0, 0x1}).Return([]*hotline.ClientConn{ + { + ID: [2]byte{0, 1}, + }, + { + ID: [2]byte{0, 2}, + }, + }) + m.On("GetSubject").Return("unset") + return &m + }(), + ClientMgr: func() *hotline.MockClientMgr { + m := hotline.MockClientMgr{} + m.On("List").Return([]*hotline.ClientConn{ + { + Account: &hotline.Account{ + Access: hotline.AccessBitmap{255, 255, 255, 255, 255, 255, 255, 255}, + }, + ID: [2]byte{0, 1}, + }, + { + Account: &hotline.Account{ + Access: hotline.AccessBitmap{0, 0, 0, 0, 0, 0, 0, 0}, + }, + ID: [2]byte{0, 2}, + }, + { + Account: &hotline.Account{ + Access: hotline.AccessBitmap{0, 0, 0, 0, 0, 0, 0, 0}, + }, + ID: [2]byte{0, 3}, + }, + }, + ) + return &m + }(), + }, + }, + t: hotline.Transaction{ + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldData, []byte("hai")), + hotline.NewField(hotline.FieldChatID, []byte{0, 0, 0, 1}), + }, + }, + }, + want: []hotline.Transaction{ + { + ClientID: [2]byte{0, 1}, + Type: [2]byte{0, 0x6a}, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldChatID, []byte{0, 0, 0, 1}), + hotline.NewField(hotline.FieldData, []byte{0x0d, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x00, 0x01, 0x3a, 0x20, 0x20, 0x68, 0x61, 0x69}), + }, + }, + { + ClientID: [2]byte{0, 2}, + Type: [2]byte{0, 0x6a}, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldChatID, []byte{0, 0, 0, 1}), + hotline.NewField(hotline.FieldData, []byte{0x0d, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x00, 0x01, 0x3a, 0x20, 0x20, 0x68, 0x61, 0x69}), + }, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := HandleChatSend(tt.args.cc, &tt.args.t) + TranAssertEqual(t, tt.want, got) + }) + } +} + +func TestHandleSendInstantMsg(t *testing.T) { + type args struct { + cc *hotline.ClientConn + t hotline.Transaction + } + tests := []struct { + name string + args args + wantRes []hotline.Transaction + }{ + { + name: "without required permission", + args: args{ + cc: &hotline.ClientConn{ + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + return bits + }(), + }, + }, + t: hotline.NewTransaction( + hotline.TranDelNewsArt, + [2]byte{0, 0}, + ), + }, + wantRes: []hotline.Transaction{ + { + IsReply: 0x01, + ErrorCode: [4]byte{0, 0, 0, 1}, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldError, []byte("You are not allowed to send private messages.")), + }, + }, + }, + }, + { + name: "when client 1 sends a message to client 2", + args: args{ + cc: &hotline.ClientConn{ + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + bits.Set(hotline.AccessSendPrivMsg) + return bits + }(), + }, + ID: [2]byte{0, 1}, + UserName: []byte("User1"), + Server: &hotline.Server{ + TextDecoder: charmap.Macintosh.NewDecoder(), + TextEncoder: charmap.Macintosh.NewEncoder(), + ClientMgr: func() *hotline.MockClientMgr { + m := hotline.MockClientMgr{} + m.On("Get", hotline.ClientID{0x0, 0x2}).Return(&hotline.ClientConn{ + AutoReply: []byte(nil), + Flags: [2]byte{0, 0}, + }, + ) + return &m + }(), + }, + }, + t: hotline.NewTransaction( + hotline.TranSendInstantMsg, + [2]byte{0, 1}, + hotline.NewField(hotline.FieldData, []byte("hai")), + hotline.NewField(hotline.FieldUserID, []byte{0, 2}), + ), + }, + wantRes: []hotline.Transaction{ + hotline.NewTransaction( + hotline.TranServerMsg, + [2]byte{0, 2}, + hotline.NewField(hotline.FieldData, []byte("hai")), + hotline.NewField(hotline.FieldUserName, []byte("User1")), + hotline.NewField(hotline.FieldUserID, []byte{0, 1}), + hotline.NewField(hotline.FieldOptions, []byte{0, 1}), + ), + { + ClientID: [2]byte{0, 1}, + IsReply: 0x01, + Fields: []hotline.Field(nil), + }, + }, + }, + { + name: "when client 2 has autoreply enabled", + args: args{ + cc: &hotline.ClientConn{ + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + bits.Set(hotline.AccessSendPrivMsg) + return bits + }(), + }, + ID: [2]byte{0, 1}, + UserName: []byte("User1"), + Server: &hotline.Server{ + TextDecoder: charmap.Macintosh.NewDecoder(), + TextEncoder: charmap.Macintosh.NewEncoder(), + ClientMgr: func() *hotline.MockClientMgr { + m := hotline.MockClientMgr{} + m.On("Get", hotline.ClientID{0x0, 0x2}).Return(&hotline.ClientConn{ + Flags: [2]byte{0, 0}, + ID: [2]byte{0, 2}, + UserName: []byte("User2"), + AutoReply: []byte("autohai"), + }) + return &m + }(), + }, + }, + t: hotline.NewTransaction( + hotline.TranSendInstantMsg, + [2]byte{0, 1}, + hotline.NewField(hotline.FieldData, []byte("hai")), + hotline.NewField(hotline.FieldUserID, []byte{0, 2}), + ), + }, + wantRes: []hotline.Transaction{ + hotline.NewTransaction( + hotline.TranServerMsg, + [2]byte{0, 2}, + hotline.NewField(hotline.FieldData, []byte("hai")), + hotline.NewField(hotline.FieldUserName, []byte("User1")), + hotline.NewField(hotline.FieldUserID, []byte{0, 1}), + hotline.NewField(hotline.FieldOptions, []byte{0, 1}), + ), + hotline.NewTransaction( + hotline.TranServerMsg, + [2]byte{0, 1}, + hotline.NewField(hotline.FieldData, []byte("autohai")), + hotline.NewField(hotline.FieldUserName, []byte("User2")), + hotline.NewField(hotline.FieldUserID, []byte{0, 2}), + hotline.NewField(hotline.FieldOptions, []byte{0, 1}), + ), + { + ClientID: [2]byte{0, 1}, + IsReply: 0x01, + Fields: []hotline.Field(nil), + }, + }, + }, + { + name: "when client 2 has refuse private messages enabled", + args: args{ + cc: &hotline.ClientConn{ + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + bits.Set(hotline.AccessSendPrivMsg) + return bits + }(), + }, + ID: [2]byte{0, 1}, + UserName: []byte("User1"), + Server: &hotline.Server{ + TextDecoder: charmap.Macintosh.NewDecoder(), + TextEncoder: charmap.Macintosh.NewEncoder(), + ClientMgr: func() *hotline.MockClientMgr { + m := hotline.MockClientMgr{} + m.On("Get", hotline.ClientID{0x0, 0x2}).Return(&hotline.ClientConn{ + Flags: [2]byte{255, 255}, + ID: [2]byte{0, 2}, + UserName: []byte("User2"), + }, + ) + return &m + }(), + }, + }, + t: hotline.NewTransaction( + hotline.TranSendInstantMsg, + [2]byte{0, 1}, + hotline.NewField(hotline.FieldData, []byte("hai")), + hotline.NewField(hotline.FieldUserID, []byte{0, 2}), + ), + }, + wantRes: []hotline.Transaction{ + hotline.NewTransaction( + hotline.TranServerMsg, + [2]byte{0, 1}, + hotline.NewField(hotline.FieldData, []byte("User2 does not accept private messages.")), + hotline.NewField(hotline.FieldUserName, []byte("User2")), + hotline.NewField(hotline.FieldUserID, []byte{0, 2}), + hotline.NewField(hotline.FieldOptions, []byte{0, 2}), + ), + { + ClientID: [2]byte{0, 1}, + IsReply: 0x01, + Fields: []hotline.Field(nil), + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotRes := HandleSendInstantMsg(tt.args.cc, &tt.args.t) + TranAssertEqual(t, tt.wantRes, gotRes) + }) + } +} + +func TestHandleInviteNewChat(t *testing.T) { + type args struct { + cc *hotline.ClientConn + t hotline.Transaction + } + tests := []struct { + name string + args args + wantRes []hotline.Transaction + }{ + { + name: "when user does not have required permission", + args: args{ + cc: &hotline.ClientConn{ + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + return bits + }(), + }, + }, + t: hotline.NewTransaction(hotline.TranInviteNewChat, [2]byte{0, 1}), + }, + wantRes: []hotline.Transaction{ + { + IsReply: 0x01, + ErrorCode: [4]byte{0, 0, 0, 1}, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldError, []byte("You are not allowed to request private chat.")), + }, + }, + }, + }, + { + name: "when userA invites userB to new private chat", + args: args{ + cc: &hotline.ClientConn{ + ID: [2]byte{0, 1}, + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + bits.Set(hotline.AccessOpenChat) + return bits + }(), + }, + UserName: []byte("UserA"), + Icon: []byte{0, 1}, + Flags: [2]byte{0, 0}, + Server: &hotline.Server{ + TextDecoder: charmap.Macintosh.NewDecoder(), + TextEncoder: charmap.Macintosh.NewEncoder(), + ClientMgr: func() *hotline.MockClientMgr { + m := hotline.MockClientMgr{} + m.On("Get", hotline.ClientID{0x0, 0x2}).Return(&hotline.ClientConn{ + ID: [2]byte{0, 2}, + UserName: []byte("UserB"), + }) + return &m + }(), + ChatMgr: func() *hotline.MockChatManager { + m := hotline.MockChatManager{} + m.On("New", mock.AnythingOfType("*hotline.ClientConn")).Return(hotline.ChatID{0x52, 0xfd, 0xfc, 0x07}) + return &m + }(), + }, + }, + t: hotline.NewTransaction( + hotline.TranInviteNewChat, [2]byte{0, 1}, + hotline.NewField(hotline.FieldUserID, []byte{0, 2}), + ), + }, + wantRes: []hotline.Transaction{ + { + ClientID: [2]byte{0, 2}, + Type: [2]byte{0, 0x71}, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldChatID, []byte{0x52, 0xfd, 0xfc, 0x07}), + hotline.NewField(hotline.FieldUserName, []byte("UserA")), + hotline.NewField(hotline.FieldUserID, []byte{0, 1}), + }, + }, + { + ClientID: [2]byte{0, 1}, + IsReply: 0x01, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldChatID, []byte{0x52, 0xfd, 0xfc, 0x07}), + hotline.NewField(hotline.FieldUserName, []byte("UserA")), + hotline.NewField(hotline.FieldUserID, []byte{0, 1}), + hotline.NewField(hotline.FieldUserIconID, []byte{0, 1}), + hotline.NewField(hotline.FieldUserFlags, []byte{0, 0}), + }, + }, + }, + }, + { + name: "when userA invites userB to new private chat, but UserB has refuse private chat enabled", + args: args{ + cc: &hotline.ClientConn{ + ID: [2]byte{0, 1}, + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + bits.Set(hotline.AccessOpenChat) + return bits + }(), + }, + UserName: []byte("UserA"), + Icon: []byte{0, 1}, + Flags: [2]byte{0, 0}, + Server: &hotline.Server{ + TextDecoder: charmap.Macintosh.NewDecoder(), + TextEncoder: charmap.Macintosh.NewEncoder(), + ClientMgr: func() *hotline.MockClientMgr { + m := hotline.MockClientMgr{} + m.On("Get", hotline.ClientID{0, 2}).Return(&hotline.ClientConn{ + ID: [2]byte{0, 2}, + Icon: []byte{0, 1}, + UserName: []byte("UserB"), + Flags: [2]byte{255, 255}, + }) + return &m + }(), + ChatMgr: func() *hotline.MockChatManager { + m := hotline.MockChatManager{} + m.On("New", mock.AnythingOfType("*hotline.ClientConn")).Return(hotline.ChatID{0x52, 0xfd, 0xfc, 0x07}) + return &m + }(), + }, + }, + t: hotline.NewTransaction( + hotline.TranInviteNewChat, [2]byte{0, 1}, + hotline.NewField(hotline.FieldUserID, []byte{0, 2}), + ), + }, + wantRes: []hotline.Transaction{ + { + ClientID: [2]byte{0, 1}, + Type: [2]byte{0, 0x68}, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldData, []byte("UserB does not accept private chats.")), + hotline.NewField(hotline.FieldUserName, []byte("UserB")), + hotline.NewField(hotline.FieldUserID, []byte{0, 2}), + hotline.NewField(hotline.FieldOptions, []byte{0, 2}), + }, + }, + { + ClientID: [2]byte{0, 1}, + IsReply: 0x01, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldChatID, []byte{0x52, 0xfd, 0xfc, 0x07}), + hotline.NewField(hotline.FieldUserName, []byte("UserA")), + hotline.NewField(hotline.FieldUserID, []byte{0, 1}), + hotline.NewField(hotline.FieldUserIconID, []byte{0, 1}), + hotline.NewField(hotline.FieldUserFlags, []byte{0, 0}), + }, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + + gotRes := HandleInviteNewChat(tt.args.cc, &tt.args.t) + + TranAssertEqual(t, tt.wantRes, gotRes) + }) + } +} + +func TestHandleJoinChat(t *testing.T) { + type args struct { + cc *hotline.ClientConn + t hotline.Transaction + } + tests := []struct { + name string + args args + want []hotline.Transaction + }{ + { + name: "joins private chat and notifies existing members", + args: args{ + cc: &hotline.ClientConn{ + UserName: []byte("NewUser"), + ID: [2]byte{0, 3}, + Icon: []byte{0, 5}, + Flags: [2]byte{0, 1}, + Account: &hotline.Account{ + Access: hotline.AccessBitmap{255, 255, 255, 255, 255, 255, 255, 255}, + }, + Server: &hotline.Server{ + TextDecoder: charmap.Macintosh.NewDecoder(), + TextEncoder: charmap.Macintosh.NewEncoder(), + ChatMgr: func() *hotline.MockChatManager { + m := hotline.MockChatManager{} + // Mock existing members before join + m.On("Members", hotline.ChatID{0, 0, 0, 1}).Return([]*hotline.ClientConn{ + { + UserName: []byte("User1"), + ID: [2]byte{0, 1}, + Icon: []byte{0, 2}, + Flags: [2]byte{0, 0}, + }, + { + UserName: []byte("User2"), + ID: [2]byte{0, 2}, + Icon: []byte{0, 3}, + Flags: [2]byte{0, 0}, + }, + }) + // Mock join operation + m.On("Join", hotline.ChatID{0, 0, 0, 1}, mock.AnythingOfType("*hotline.ClientConn")).Return() + // Mock members after join (including new user) + m.On("Members", hotline.ChatID{0, 0, 0, 1}).Return([]*hotline.ClientConn{ + { + UserName: []byte("User1"), + ID: [2]byte{0, 1}, + Icon: []byte{0, 2}, + Flags: [2]byte{0, 0}, + }, + { + UserName: []byte("User2"), + ID: [2]byte{0, 2}, + Icon: []byte{0, 3}, + Flags: [2]byte{0, 0}, + }, + { + UserName: []byte("NewUser"), + ID: [2]byte{0, 3}, + Icon: []byte{0, 5}, + Flags: [2]byte{0, 1}, + }, + }) + // Mock chat subject + m.On("GetSubject", hotline.ChatID{0, 0, 0, 1}).Return("Test Chat Room") + return &m + }(), + }, + }, + t: hotline.Transaction{ + Type: [2]byte{0, 0x74}, // TranJoinChat + ID: [4]byte{0, 0, 0, 1}, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldChatID, []byte{0, 0, 0, 1}), + }, + }, + }, + want: []hotline.Transaction{ + // Notification to existing member 1 + { + ClientID: [2]byte{0, 1}, + Type: [2]byte{0, 0x75}, // TranNotifyChatChangeUser + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldUserName, []byte("NewUser")), + hotline.NewField(hotline.FieldUserID, []byte{0, 3}), + hotline.NewField(hotline.FieldUserIconID, []byte{0, 5}), + hotline.NewField(hotline.FieldUserFlags, []byte{0, 1}), + }, + }, + // Notification to existing member 2 + { + ClientID: [2]byte{0, 2}, + Type: [2]byte{0, 0x75}, // TranNotifyChatChangeUser + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldUserName, []byte("NewUser")), + hotline.NewField(hotline.FieldUserID, []byte{0, 3}), + hotline.NewField(hotline.FieldUserIconID, []byte{0, 5}), + hotline.NewField(hotline.FieldUserFlags, []byte{0, 1}), + }, + }, + // Reply to joining user + { + ClientID: [2]byte{0, 3}, + IsReply: 0x01, + Type: [2]byte{0, 0}, // Reply type is [0, 0] + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldChatSubject, []byte("Test Chat Room")), + // User1 info + hotline.NewField(hotline.FieldUsernameWithInfo, []byte{ + 0x00, 0x01, // User ID + 0x00, 0x02, // Icon ID + 0x00, 0x00, // Flags + 0x00, 0x05, // Username length + 0x55, 0x73, 0x65, 0x72, 0x31, // "User1" + }), + // User2 info + hotline.NewField(hotline.FieldUsernameWithInfo, []byte{ + 0x00, 0x02, // User ID + 0x00, 0x03, // Icon ID + 0x00, 0x00, // Flags + 0x00, 0x05, // Username length + 0x55, 0x73, 0x65, 0x72, 0x32, // "User2" + }), + }, + }, + }, + }, + { + name: "joins empty private chat", + args: args{ + cc: &hotline.ClientConn{ + UserName: []byte("OnlyUser"), + ID: [2]byte{0, 1}, + Icon: []byte{0, 1}, + Flags: [2]byte{0, 0}, + Account: &hotline.Account{ + Access: hotline.AccessBitmap{255, 255, 255, 255, 255, 255, 255, 255}, + }, + Server: &hotline.Server{ + TextDecoder: charmap.Macintosh.NewDecoder(), + TextEncoder: charmap.Macintosh.NewEncoder(), + ChatMgr: func() *hotline.MockChatManager { + m := hotline.MockChatManager{} + // Mock empty chat before join + m.On("Members", hotline.ChatID{0, 0, 0, 2}).Return([]*hotline.ClientConn{}) + // Mock join operation + m.On("Join", hotline.ChatID{0, 0, 0, 2}, mock.AnythingOfType("*hotline.ClientConn")).Return() + // Mock members after join + m.On("Members", hotline.ChatID{0, 0, 0, 2}).Return([]*hotline.ClientConn{ + { + UserName: []byte("OnlyUser"), + ID: [2]byte{0, 1}, + Icon: []byte{0, 1}, + Flags: [2]byte{0, 0}, + }, + }) + // Mock chat subject + m.On("GetSubject", hotline.ChatID{0, 0, 0, 2}).Return("Empty Chat") + return &m + }(), + }, + }, + t: hotline.Transaction{ + Type: [2]byte{0, 0x74}, // TranJoinChat + ID: [4]byte{0, 0, 0, 2}, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldChatID, []byte{0, 0, 0, 2}), + }, + }, + }, + want: []hotline.Transaction{ + // Only reply to joining user (no existing members to notify) + { + ClientID: [2]byte{0, 1}, + IsReply: 0x01, + Type: [2]byte{0, 0}, // Reply type is [0, 0] + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldChatSubject, []byte("Empty Chat")), + }, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := HandleJoinChat(tt.args.cc, &tt.args.t) + if !TranAssertEqual(t, tt.want, got) { + t.Errorf("HandleJoinChat() got = %v, want %v", got, tt.want) + } + }) + } +} + +func TestHandleRejectChatInvite(t *testing.T) { + type args struct { + cc *hotline.ClientConn + t hotline.Transaction + } + tests := []struct { + name string + args args + want []hotline.Transaction + }{ + { + name: "rejects invite and notifies chat members", + args: args{ + cc: &hotline.ClientConn{ + UserName: []byte("RejectUser"), + ID: [2]byte{0, 3}, + Server: &hotline.Server{ + TextDecoder: charmap.Macintosh.NewDecoder(), + TextEncoder: charmap.Macintosh.NewEncoder(), + ChatMgr: func() *hotline.MockChatManager { + m := hotline.MockChatManager{} + // Mock current members of the chat + m.On("Members", hotline.ChatID{0, 0, 0, 1}).Return([]*hotline.ClientConn{ + { + UserName: []byte("Member1"), + ID: [2]byte{0, 1}, + }, + { + UserName: []byte("Member2"), + ID: [2]byte{0, 2}, + }, + }) + return &m + }(), + }, + }, + t: hotline.Transaction{ + Type: [2]byte{0, 0x72}, // TranRejectChatInvite + ID: [4]byte{0, 0, 0, 1}, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldChatID, []byte{0, 0, 0, 1}), + }, + }, + }, + want: []hotline.Transaction{ + // Notification to member 1 + { + ClientID: [2]byte{0, 1}, + Type: [2]byte{0, 0x6A}, // TranChatMsg + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldData, []byte("RejectUser declined invitation to chat")), + }, + }, + // Notification to member 2 + { + ClientID: [2]byte{0, 2}, + Type: [2]byte{0, 0x6A}, // TranChatMsg + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldData, []byte("RejectUser declined invitation to chat")), + }, + }, + }, + }, + { + name: "rejects invite to empty chat", + args: args{ + cc: &hotline.ClientConn{ + UserName: []byte("LoneRejecter"), + ID: [2]byte{0, 1}, + Server: &hotline.Server{ + TextDecoder: charmap.Macintosh.NewDecoder(), + TextEncoder: charmap.Macintosh.NewEncoder(), + ChatMgr: func() *hotline.MockChatManager { + m := hotline.MockChatManager{} + // Mock empty chat (no members) + m.On("Members", hotline.ChatID{0, 0, 0, 2}).Return([]*hotline.ClientConn{}) + return &m + }(), + }, + }, + t: hotline.Transaction{ + Type: [2]byte{0, 0x72}, // TranRejectChatInvite + ID: [4]byte{0, 0, 0, 2}, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldChatID, []byte{0, 0, 0, 2}), + }, + }, + }, + want: []hotline.Transaction{ + // No notifications (no members to notify) + }, + }, + { + name: "rejects invite with single member", + args: args{ + cc: &hotline.ClientConn{ + UserName: []byte("Shy"), + ID: [2]byte{0, 2}, + Server: &hotline.Server{ + TextDecoder: charmap.Macintosh.NewDecoder(), + TextEncoder: charmap.Macintosh.NewEncoder(), + ChatMgr: func() *hotline.MockChatManager { + m := hotline.MockChatManager{} + // Mock chat with single member + m.On("Members", hotline.ChatID{0, 0, 0, 3}).Return([]*hotline.ClientConn{ + { + UserName: []byte("OnlyMember"), + ID: [2]byte{0, 1}, + }, + }) + return &m + }(), + }, + }, + t: hotline.Transaction{ + Type: [2]byte{0, 0x72}, // TranRejectChatInvite + ID: [4]byte{0, 0, 0, 3}, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldChatID, []byte{0, 0, 0, 3}), + }, + }, + }, + want: []hotline.Transaction{ + // Notification to the single member + { + ClientID: [2]byte{0, 1}, + Type: [2]byte{0, 0x6A}, // TranChatMsg + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldData, []byte("Shy declined invitation to chat")), + }, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := HandleRejectChatInvite(tt.args.cc, &tt.args.t) + if !TranAssertEqual(t, tt.want, got) { + t.Errorf("HandleRejectChatInvite() got = %v, want %v", got, tt.want) + } + }) + } +} + +func TestHandleInviteToChat(t *testing.T) { + type args struct { + cc *hotline.ClientConn + t hotline.Transaction + } + tests := []struct { + name string + args args + want []hotline.Transaction + }{ + { + name: "invites user to chat successfully", + args: args{ + cc: &hotline.ClientConn{ + UserName: []byte("Inviter"), + ID: [2]byte{0, 1}, + Icon: []byte{0, 2}, + Flags: [2]byte{0, 3}, + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + access := hotline.AccessBitmap{} + access.Set(hotline.AccessOpenChat) + return access + }(), + }, + }, + t: hotline.Transaction{ + Type: [2]byte{0, 0x71}, // TranInviteToChat + ID: [4]byte{0, 0, 0, 1}, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldUserID, []byte{0, 5}), + hotline.NewField(hotline.FieldChatID, []byte{0, 0, 0, 10}), + }, + }, + }, + want: []hotline.Transaction{ + // Invite sent to target user + { + ClientID: [2]byte{0, 5}, + Type: [2]byte{0, 0x71}, // TranInviteToChat + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldUserName, []byte("Inviter")), + hotline.NewField(hotline.FieldUserID, []byte{0, 1}), + }, + }, + // Reply to inviting user + { + ClientID: [2]byte{0, 1}, + IsReply: 0x01, + Type: [2]byte{0, 0}, // Reply type is [0, 0] + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldUserName, []byte("Inviter")), + hotline.NewField(hotline.FieldUserID, []byte{0, 1}), + hotline.NewField(hotline.FieldUserIconID, []byte{0, 2}), + hotline.NewField(hotline.FieldUserFlags, []byte{0, 3}), + }, + }, + }, + }, + { + name: "returns error when user lacks permission", + args: args{ + cc: &hotline.ClientConn{ + UserName: []byte("NoPermUser"), + ID: [2]byte{0, 2}, + Account: &hotline.Account{ + Access: hotline.AccessBitmap{}, // No permissions + }, + }, + t: hotline.Transaction{ + Type: [2]byte{0, 0x71}, // TranInviteToChat + ID: [4]byte{0, 0, 0, 2}, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldUserID, []byte{0, 3}), + hotline.NewField(hotline.FieldChatID, []byte{0, 0, 0, 5}), + }, + }, + }, + want: []hotline.Transaction{ + // Error reply to requesting user + { + ClientID: [2]byte{0, 2}, + IsReply: 0x01, + Type: [2]byte{0, 0}, // Reply type is [0, 0] + ErrorCode: [4]byte{0, 0, 0, 1}, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldError, []byte("You are not allowed to request private chat.")), + }, + }, + }, + }, + { + name: "invites to different chat room", + args: args{ + cc: &hotline.ClientConn{ + UserName: []byte("Host"), + ID: [2]byte{0, 10}, + Icon: []byte{0, 15}, + Flags: [2]byte{0, 20}, + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + access := hotline.AccessBitmap{} + access.Set(hotline.AccessOpenChat) + return access + }(), + }, + }, + t: hotline.Transaction{ + Type: [2]byte{0, 0x71}, // TranInviteToChat + ID: [4]byte{0, 0, 0, 3}, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldUserID, []byte{0, 99}), + hotline.NewField(hotline.FieldChatID, []byte{0, 0, 0, 25}), + }, + }, + }, + want: []hotline.Transaction{ + // Invite sent to target user + { + ClientID: [2]byte{0, 99}, + Type: [2]byte{0, 0x71}, // TranInviteToChat + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldUserName, []byte("Host")), + hotline.NewField(hotline.FieldUserID, []byte{0, 10}), + }, + }, + // Reply to inviting user + { + ClientID: [2]byte{0, 10}, + IsReply: 0x01, + Type: [2]byte{0, 0}, // Reply type is [0, 0] + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldUserName, []byte("Host")), + hotline.NewField(hotline.FieldUserID, []byte{0, 10}), + hotline.NewField(hotline.FieldUserIconID, []byte{0, 15}), + hotline.NewField(hotline.FieldUserFlags, []byte{0, 20}), + }, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := HandleInviteToChat(tt.args.cc, &tt.args.t) + if !TranAssertEqual(t, tt.want, got) { + t.Errorf("HandleInviteToChat() got = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/internal/mobius/handlers_files.go b/internal/mobius/handlers_files.go new file mode 100644 index 0000000..4052206 --- /dev/null +++ b/internal/mobius/handlers_files.go @@ -0,0 +1,444 @@ +package mobius + +import ( + "fmt" + "io" + "os" + "path" + + "github.com/jhalter/mobius/hotline" +) + +var fileTypeFLDR = [4]byte{0x66, 0x6c, 0x64, 0x72} + +// HandleGetFileInfo requests file information from the server. +// +// Fields used in the request: +// - 201 File name Required +// - 202 File path Optional +// +// Fields used in the reply: +// - 201 File name File name +// - 205 File type string Friendly file type description +// - 206 File creator string Friendly creator description +// - 210 File comment Comment string +// - 213 File type File type signature +// - 208 File create date Creation date +// - 209 File modify date Modification date +// - 207 File size File size +func HandleGetFileInfo(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) { + fileName := t.GetField(hotline.FieldFileName).Data + filePath := t.GetField(hotline.FieldFilePath).Data + + fullFilePath, err := hotline.ReadPath(cc.FileRoot(), filePath, fileName, cc.TextDecoder()) + if err != nil { + cc.Logger.Error("get file info: read path", "err", err) + return cc.NewErrReply(t, ErrMsgFileNotFound) + } + + fw, err := hotline.NewFile(cc.Server.FS, fullFilePath, 0) + if err != nil { + cc.Logger.Error("get file info: open file", "err", err) + return cc.NewErrReply(t, ErrMsgFileNotFound) + } + + encodedName, err := cc.TextEncoder().String(fw.Name) + if err != nil { + cc.Logger.Error("get file info: encode name", "err", err) + return cc.NewErrReply(t, ErrMsgGetFileInfo) + } + + fields := []hotline.Field{ + hotline.NewField(hotline.FieldFileName, []byte(encodedName)), + hotline.NewField(hotline.FieldFileTypeString, fw.Ffo.FlatFileInformationFork.FriendlyType()), + hotline.NewField(hotline.FieldFileCreatorString, fw.Ffo.FlatFileInformationFork.FriendlyCreator()), + hotline.NewField(hotline.FieldFileType, fw.Ffo.FlatFileInformationFork.TypeSignature[:]), + hotline.NewField(hotline.FieldFileCreateDate, fw.Ffo.FlatFileInformationFork.CreateDate[:]), + hotline.NewField(hotline.FieldFileModifyDate, fw.Ffo.FlatFileInformationFork.ModifyDate[:]), + } + + // Include the optional FileComment field if there is a comment. + if len(fw.Ffo.FlatFileInformationFork.Comment) != 0 { + fields = append(fields, hotline.NewField(hotline.FieldFileComment, fw.Ffo.FlatFileInformationFork.Comment)) + } + + // Include the FileSize field for files. + if fw.Ffo.FlatFileInformationFork.TypeSignature != fileTypeFLDR { + fields = append(fields, hotline.NewField(hotline.FieldFileSize, fw.TotalSize())) + } + + return append(res, cc.NewReply(t, fields...)) +} + +// HandleSetFileInfo sets information for the specified file on the server. +// +// Access: Set File Comment (28) or Set Folder Comment (29) +// +// Fields used in the request: +// - 201 File name Required +// - 202 File path Optional +// - 211 File new name Optional +// - 210 File comment Optional +// +// Fields used in the reply: None +func HandleSetFileInfo(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) { + fileName := t.GetField(hotline.FieldFileName).Data + filePath := t.GetField(hotline.FieldFilePath).Data + + fullFilePath, err := hotline.ReadPath(cc.FileRoot(), filePath, fileName, cc.TextDecoder()) + if err != nil { + cc.Logger.Error("set file info: read path", "err", err) + return cc.NewErrReply(t, ErrMsgFileNotFound) + } + + fi, err := cc.Server.FS.Stat(fullFilePath) + if err != nil { + cc.Logger.Error("set file info: stat file", "err", err) + return cc.NewErrReply(t, ErrMsgFileNotFound) + } + + hlFile, err := hotline.NewFile(cc.Server.FS, fullFilePath, 0) + if err != nil { + cc.Logger.Error("set file info: open file", "err", err) + return cc.NewErrReply(t, ErrMsgFileNotFound) + } + if t.GetField(hotline.FieldFileComment).Data != nil { + switch mode := fi.Mode(); { + case mode.IsDir(): + if !cc.Authorize(hotline.AccessSetFolderComment) { + return cc.NewErrReply(t, ErrMsgNotAllowedSetCommentsFolders) + } + case mode.IsRegular(): + if !cc.Authorize(hotline.AccessSetFileComment) { + return cc.NewErrReply(t, ErrMsgNotAllowedSetCommentsFiles) + } + } + + if err := hlFile.Ffo.FlatFileInformationFork.SetComment(t.GetField(hotline.FieldFileComment).Data); err != nil { + cc.Logger.Error("set file info: set comment", "err", err) + return cc.NewErrReply(t, ErrMsgSetFileInfo) + } + w, err := hlFile.InfoForkWriter() + if err != nil { + cc.Logger.Error("set file info: open info fork writer", "err", err) + return cc.NewErrReply(t, ErrMsgSetFileInfo) + } + _, err = io.Copy(w, &hlFile.Ffo.FlatFileInformationFork) + if err != nil { + cc.Logger.Error("set file info: write info fork", "err", err) + return cc.NewErrReply(t, ErrMsgSetFileInfo) + } + } + + fullNewFilePath, err := hotline.ReadPath(cc.FileRoot(), filePath, t.GetField(hotline.FieldFileNewName).Data, cc.TextDecoder()) + if err != nil { + cc.Logger.Error("set file info: read new name path", "err", err) + return cc.NewErrReply(t, ErrMsgRenameFile) + } + + fileNewName := t.GetField(hotline.FieldFileNewName).Data + + if fileNewName != nil { + switch mode := fi.Mode(); { + case mode.IsDir(): + if !cc.Authorize(hotline.AccessRenameFolder) { + return cc.NewErrReply(t, ErrMsgNotAllowedRenameFolders) + } + err = os.Rename(fullFilePath, fullNewFilePath) + if os.IsNotExist(err) { + return cc.NewErrReply(t, fmt.Sprintf(ErrMsgCannotRenameFolderNotFound, string(fileName))) + } + if err != nil { + cc.Logger.Error("set file info: rename folder", "err", err) + return cc.NewErrReply(t, ErrMsgRenameFolder) + } + case mode.IsRegular(): + if !cc.Authorize(hotline.AccessRenameFile) { + return cc.NewErrReply(t, ErrMsgNotAllowedRenameFiles) + } + fileDir, err := hotline.ReadPath(cc.FileRoot(), filePath, []byte{}, cc.TextDecoder()) + if err != nil { + cc.Logger.Error("set file info: read file dir", "err", err) + return cc.NewErrReply(t, ErrMsgRenameFile) + } + hlFile.Name, err = cc.TextDecoder().String(string(fileNewName)) + if err != nil { + cc.Logger.Error("set file info: decode new name", "err", err) + return cc.NewErrReply(t, ErrMsgRenameFile) + } + + err = hlFile.Move(fileDir) + if os.IsNotExist(err) { + return cc.NewErrReply(t, fmt.Sprintf(ErrMsgCannotRenameFileNotFound, string(fileName))) + } + if err != nil { + cc.Logger.Error("set file info: rename file", "err", err) + return cc.NewErrReply(t, ErrMsgRenameFile) + } + } + } + + res = append(res, cc.NewReply(t)) + return res +} + +// HandleDeleteFile deletes the specified file from the server. +// +// Access: Delete File (0) or Delete Folder (6) +// +// Fields used in the request: +// - 201 File name Required +// - 202 File path Required +// +// Fields used in the reply: None +func HandleDeleteFile(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) { + fileName := t.GetField(hotline.FieldFileName).Data + filePath := t.GetField(hotline.FieldFilePath).Data + + fullFilePath, err := hotline.ReadPath(cc.FileRoot(), filePath, fileName, cc.TextDecoder()) + if err != nil { + cc.Logger.Error("delete file: read path", "err", err) + return cc.NewErrReply(t, ErrMsgFileNotFound) + } + + hlFile, err := hotline.NewFile(cc.Server.FS, fullFilePath, 0) + if err != nil { + cc.Logger.Error("delete file: open file", "err", err) + return cc.NewErrReply(t, ErrMsgFileNotFound) + } + + fi, err := hlFile.DataFile() + if err != nil { + return cc.NewErrReply(t, fmt.Sprintf(ErrMsgCannotDeleteFileNotFound, string(fileName))) + } + + switch mode := fi.Mode(); { + case mode.IsDir(): + if !cc.Authorize(hotline.AccessDeleteFolder) { + return cc.NewErrReply(t, ErrMsgNotAllowedDeleteFolders) + } + case mode.IsRegular(): + if !cc.Authorize(hotline.AccessDeleteFile) { + return cc.NewErrReply(t, ErrMsgNotAllowedDeleteFiles) + } + } + + if err := hlFile.Delete(); err != nil { + cc.Logger.Error("delete file: delete", "err", err) + return cc.NewErrReply(t, ErrMsgDeleteFile) + } + + res = append(res, cc.NewReply(t)) + return res +} + +// HandleMoveFile moves a file from one folder to another on the same server. +// +// Fields used in the request: +// - 201 File name Required +// - 202 File path Required +// - 212 File new path Required - Destination path +// +// Fields used in the reply: None +func HandleMoveFile(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) { + fileName := string(t.GetField(hotline.FieldFileName).Data) + + filePath, err := hotline.ReadPath(cc.FileRoot(), t.GetField(hotline.FieldFilePath).Data, t.GetField(hotline.FieldFileName).Data, cc.TextDecoder()) + if err != nil { + cc.Logger.Error("move file: read source path", "err", err) + return cc.NewErrReply(t, ErrMsgFileNotFound) + } + + fileNewPath, err := hotline.ReadPath(cc.FileRoot(), t.GetField(hotline.FieldFileNewPath).Data, nil, cc.TextDecoder()) + if err != nil { + cc.Logger.Error("move file: read destination path", "err", err) + return cc.NewErrReply(t, ErrMsgFileNotFound) + } + + cc.Logger.Info("Move file", "src", filePath+"/"+fileName, "dst", fileNewPath+"/"+fileName) + + hlFile, err := hotline.NewFile(cc.Server.FS, filePath, 0) + if err != nil { + cc.Logger.Error("move file: open file", "err", err) + return cc.NewErrReply(t, ErrMsgFileNotFound) + } + + fi, err := hlFile.DataFile() + if err != nil { + return cc.NewErrReply(t, fmt.Sprintf(ErrMsgCannotDeleteFileNotFound, fileName)) + } + switch mode := fi.Mode(); { + case mode.IsDir(): + if !cc.Authorize(hotline.AccessMoveFolder) { + return cc.NewErrReply(t, ErrMsgNotAllowedMoveFolders) + } + case mode.IsRegular(): + if !cc.Authorize(hotline.AccessMoveFile) { + return cc.NewErrReply(t, ErrMsgNotAllowedMoveFiles) + } + } + if err := hlFile.Move(fileNewPath); err != nil { + cc.Logger.Error("move file: move", "err", err) + return cc.NewErrReply(t, ErrMsgMoveFile) + } + // TODO: handle other possible errors; e.g. file delete fails due to permission issue + + res = append(res, cc.NewReply(t)) + return res +} + +// HandleNewFolder creates a new folder on the server. +// +// Access: Create Folder (5) +// +// Fields used in the request: +// - 201 File name Required +// - 202 File path Optional +// +// Fields used in the reply: None +func HandleNewFolder(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) { + if !cc.Authorize(hotline.AccessCreateFolder) { + return cc.NewErrReply(t, ErrMsgNotAllowedCreateFolders) + } + folderName := string(t.GetField(hotline.FieldFileName).Data) + + folderName = path.Join("/", folderName) + + var subPath string + + // FieldFilePath is only present for nested paths + if t.GetField(hotline.FieldFilePath).Data != nil { + var newFp hotline.FilePath + _, err := newFp.Write(t.GetField(hotline.FieldFilePath).Data) + if err != nil { + cc.Logger.Error("new folder: parse file path", "err", err) + return cc.NewErrReply(t, ErrMsgCreateFolder) + } + + for _, pathItem := range newFp.Items { + subPath = path.Join("/", subPath, string(pathItem.Name)) + } + } + + // Decode only client-provided path components from Mac Roman to UTF-8. + // The FileRoot is already a UTF-8 filesystem path and must not be decoded. + subPath, err := cc.TextDecoder().String(subPath) + if err != nil { + cc.Logger.Error("new folder: decode sub path", "err", err) + return cc.NewErrReply(t, ErrMsgCreateFolder) + } + folderName, err = cc.TextDecoder().String(folderName) + if err != nil { + cc.Logger.Error("new folder: decode folder name", "err", err) + return cc.NewErrReply(t, ErrMsgCreateFolder) + } + + newFolderPath := path.Join(cc.FileRoot(), subPath, folderName) + + // TODO: check path and folder Name lengths + + if _, err := cc.Server.FS.Stat(newFolderPath); !os.IsNotExist(err) { + msg := fmt.Sprintf(ErrMsgFolderCreateConflictTemplate, folderName) + return cc.NewErrReply(t, msg) + } + + if err := cc.Server.FS.Mkdir(newFolderPath, 0777); err != nil { + msg := fmt.Sprintf(ErrMsgFolderCreateErrorTemplate, folderName) + return cc.NewErrReply(t, msg) + } + + return append(res, cc.NewReply(t)) +} + +// HandleGetFileNameList gets the list of file names from the specified folder. +// +// Fields used in the request: +// - 202 File path Optional - If not specified, root folder assumed +// +// Fields used in the reply: +// - 200 File name with info Repeated - File information for each item +func HandleGetFileNameList(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) { + fullPath, err := hotline.ReadPath( + cc.FileRoot(), + t.GetField(hotline.FieldFilePath).Data, + nil, + cc.TextDecoder(), + ) + if err != nil { + cc.Logger.Error("error reading file path", "err", err) + return cc.NewErrReply(t, "Cannot get file list.") + } + + var fp hotline.FilePath + if t.GetField(hotline.FieldFilePath).Data != nil { + if _, err = fp.Write(t.GetField(hotline.FieldFilePath).Data); err != nil { + cc.Logger.Error("error parsing file path", "err", err) + return cc.NewErrReply(t, "Cannot get file list.") + } + } + + // Handle special case for drop box folders + if fp.IsDropbox() && !cc.Authorize(hotline.AccessViewDropBoxes) { + return cc.NewErrReply(t, ErrMsgNotAllowedViewDropBoxes) + } + + fileNames, err := hotline.GetFileNameList(fullPath, cc.Server.Config.IgnoreFiles, cc.TextEncoder(), cc.Logger) + if err != nil { + cc.Logger.Error("error getting file name list", "err", err) + return cc.NewErrReply(t, "Cannot get file list.") + } + + res = append(res, cc.NewReply(t, fileNames...)) + + return res +} + +// ================================= +// Hotline private chat flow +// ================================= +// 1. ClientA sends TranInviteNewChat to server with user Type to invite +// 2. Server creates new ChatID +// 3. Server sends TranInviteToChat to invitee +// 4. Server replies to ClientA with new Chat Type +// +// A dialog box pops up in the invitee client with options to accept or decline the invitation. +// If Accepted is clicked: +// 1. ClientB sends TranJoinChat with FieldChatID + +// HandleMakeAlias makes a file alias using the specified path. +// +// Access: Make Alias (31) +// +// Fields used in the request: +// - 201 File name Required +// - 202 File path Required +// - 212 File new path Required - Destination path +// +// Fields used in the reply: None +func HandleMakeAlias(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) { + if !cc.Authorize(hotline.AccessMakeAlias) { + return cc.NewErrReply(t, ErrMsgNotAllowedMakeAliases) + } + fileName := t.GetField(hotline.FieldFileName).Data + filePath := t.GetField(hotline.FieldFilePath).Data + fileNewPath := t.GetField(hotline.FieldFileNewPath).Data + + fullFilePath, err := hotline.ReadPath(cc.FileRoot(), filePath, fileName, cc.TextDecoder()) + if err != nil { + cc.Logger.Error("make alias: read source path", "err", err) + return cc.NewErrReply(t, ErrMsgFileNotFound) + } + + fullNewFilePath, err := hotline.ReadPath(cc.FileRoot(), fileNewPath, fileName, cc.TextDecoder()) + if err != nil { + cc.Logger.Error("make alias: read destination path", "err", err) + return cc.NewErrReply(t, ErrMsgFileNotFound) + } + + if err := cc.Server.FS.Symlink(fullFilePath, fullNewFilePath); err != nil { + return cc.NewErrReply(t, ErrMsgCreateAlias) + } + + res = append(res, cc.NewReply(t)) + return res +} diff --git a/internal/mobius/handlers_files_test.go b/internal/mobius/handlers_files_test.go new file mode 100644 index 0000000..a2073a9 --- /dev/null +++ b/internal/mobius/handlers_files_test.go @@ -0,0 +1,1115 @@ +package mobius + +import ( + "errors" + "io" + "io/fs" + "log/slog" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/jhalter/mobius/hotline" + "golang.org/x/text/encoding/charmap" +) + +func TestHandleGetFileInfo(t *testing.T) { + type args struct { + cc *hotline.ClientConn + t hotline.Transaction + } + tests := []struct { + name string + args args + wantRes []hotline.Transaction + }{ + { + name: "returns expected fields when a valid file is requested", + args: args{ + cc: &hotline.ClientConn{ + ID: [2]byte{0, 1}, + Account: &hotline.Account{}, + Server: &hotline.Server{ + TextDecoder: charmap.Macintosh.NewDecoder(), + TextEncoder: charmap.Macintosh.NewEncoder(), + FS: &hotline.OSFileStore{}, + Config: hotline.Config{ + FileRoot: func() string { + path, _ := os.Getwd() + return filepath.Join(path, "/test/config/Files") + }(), + }, + }, + }, + t: hotline.NewTransaction( + hotline.TranGetFileInfo, [2]byte{}, + hotline.NewField(hotline.FieldFileName, []byte("testfile.txt")), + hotline.NewField(hotline.FieldFilePath, []byte{0x00, 0x00}), + ), + }, + wantRes: []hotline.Transaction{ + { + ClientID: [2]byte{0, 1}, + IsReply: 0x01, + Type: [2]byte{0, 0}, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldFileName, []byte("testfile.txt")), + hotline.NewField(hotline.FieldFileTypeString, []byte("Text File")), + hotline.NewField(hotline.FieldFileCreatorString, []byte("ttxt")), + hotline.NewField(hotline.FieldFileType, []byte("TEXT")), + hotline.NewField(hotline.FieldFileCreateDate, make([]byte, 8)), + hotline.NewField(hotline.FieldFileModifyDate, make([]byte, 8)), + hotline.NewField(hotline.FieldFileSize, []byte{0x0, 0x0, 0x0, 0x17}), + }, + }, + }, + }, + { + name: "returns an error reply when the file path cannot be parsed", + args: args{ + cc: &hotline.ClientConn{ + ID: [2]byte{0, 1}, + Account: &hotline.Account{}, + Logger: NewTestLogger(), + Server: &hotline.Server{ + TextDecoder: charmap.Macintosh.NewDecoder(), + TextEncoder: charmap.Macintosh.NewEncoder(), + FS: &hotline.OSFileStore{}, + Config: hotline.Config{ + FileRoot: func() string { + path, _ := os.Getwd() + return filepath.Join(path, "/test/config/Files") + }(), + }, + }, + }, + t: hotline.NewTransaction( + hotline.TranGetFileInfo, [2]byte{}, + hotline.NewField(hotline.FieldFileName, []byte("testfile.txt")), + // Malformed path: claims 1 item but supplies fewer than the + // 3 bytes a path item requires, so ReadPath fails to parse it. + hotline.NewField(hotline.FieldFilePath, []byte{0x00, 0x01, 0x00, 0x00}), + ), + }, + wantRes: []hotline.Transaction{ + { + ClientID: [2]byte{0, 1}, + IsReply: 0x01, + ErrorCode: [4]byte{0, 0, 0, 1}, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldError, []byte(ErrMsgFileNotFound)), + }, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotRes := HandleGetFileInfo(tt.args.cc, &tt.args.t) + + // Clear the file timestamp fields to work around problems running the tests in multiple timezones + // TODO: revisit how to test this by mocking the stat calls + if len(gotRes) > 0 && gotRes[0].ErrorCode == [4]byte{} { + gotRes[0].Fields[4].Data = make([]byte, 8) + gotRes[0].Fields[5].Data = make([]byte, 8) + } + + if !TranAssertEqual(t, tt.wantRes, gotRes) { + t.Errorf("HandleGetFileInfo() gotRes = %v, want %v", gotRes, tt.wantRes) + } + }) + } +} + +func TestHandleNewFolder(t *testing.T) { + type args struct { + cc *hotline.ClientConn + t hotline.Transaction + } + tests := []struct { + name string + args args + wantRes []hotline.Transaction + }{ + { + name: "without required permission", + args: args{ + cc: &hotline.ClientConn{ + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + return bits + }(), + }, + }, + t: hotline.NewTransaction( + hotline.TranNewFolder, + [2]byte{0, 0}, + ), + }, + wantRes: []hotline.Transaction{ + { + IsReply: 0x01, + ErrorCode: [4]byte{0, 0, 0, 1}, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldError, []byte("You are not allowed to create folders.")), + }, + }, + }, + }, + { + name: "when path is nested", + args: args{ + cc: &hotline.ClientConn{ + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + bits.Set(hotline.AccessCreateFolder) + return bits + }(), + }, + ID: [2]byte{0, 1}, + Server: &hotline.Server{ + TextDecoder: charmap.Macintosh.NewDecoder(), + TextEncoder: charmap.Macintosh.NewEncoder(), + Config: hotline.Config{ + FileRoot: "/Files/", + }, + FS: func() *hotline.MockFileStore { + mfs := &hotline.MockFileStore{} + mfs.On("Mkdir", "/Files/aaa/testFolder", fs.FileMode(0777)).Return(nil) + mfs.On("Stat", "/Files/aaa/testFolder").Return(nil, os.ErrNotExist) + return mfs + }(), + }, + }, + t: hotline.NewTransaction( + hotline.TranNewFolder, [2]byte{0, 1}, + hotline.NewField(hotline.FieldFileName, []byte("testFolder")), + hotline.NewField(hotline.FieldFilePath, []byte{ + 0x00, 0x01, + 0x00, 0x00, + 0x03, + 0x61, 0x61, 0x61, + }), + ), + }, + wantRes: []hotline.Transaction{ + { + ClientID: [2]byte{0, 1}, + IsReply: 0x01, + }, + }, + }, + { + name: "when path is not nested", + args: args{ + cc: &hotline.ClientConn{ + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + bits.Set(hotline.AccessCreateFolder) + return bits + }(), + }, + ID: [2]byte{0, 1}, + Server: &hotline.Server{ + TextDecoder: charmap.Macintosh.NewDecoder(), + TextEncoder: charmap.Macintosh.NewEncoder(), + Config: hotline.Config{ + FileRoot: "/Files", + }, + FS: func() *hotline.MockFileStore { + mfs := &hotline.MockFileStore{} + mfs.On("Mkdir", "/Files/testFolder", fs.FileMode(0777)).Return(nil) + mfs.On("Stat", "/Files/testFolder").Return(nil, os.ErrNotExist) + return mfs + }(), + }, + }, + t: hotline.NewTransaction( + hotline.TranNewFolder, [2]byte{0, 1}, + hotline.NewField(hotline.FieldFileName, []byte("testFolder")), + ), + }, + wantRes: []hotline.Transaction{ + { + ClientID: [2]byte{0, 1}, + IsReply: 0x01, + }, + }, + }, + { + name: "when Write returns an err", + args: args{ + cc: &hotline.ClientConn{ + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + bits.Set(hotline.AccessCreateFolder) + return bits + }(), + }, + ID: [2]byte{0, 1}, + Logger: NewTestLogger(), + Server: &hotline.Server{ + TextDecoder: charmap.Macintosh.NewDecoder(), + TextEncoder: charmap.Macintosh.NewEncoder(), + Config: hotline.Config{ + FileRoot: "/Files/", + }, + FS: func() *hotline.MockFileStore { + mfs := &hotline.MockFileStore{} + mfs.On("Mkdir", "/Files/aaa/testFolder", fs.FileMode(0777)).Return(nil) + mfs.On("Stat", "/Files/aaa/testFolder").Return(nil, os.ErrNotExist) + return mfs + }(), + }, + }, + t: hotline.NewTransaction( + hotline.TranNewFolder, [2]byte{0, 1}, + hotline.NewField(hotline.FieldFileName, []byte("testFolder")), + hotline.NewField(hotline.FieldFilePath, []byte{ + 0x00, + }), + ), + }, + wantRes: []hotline.Transaction{ + { + ClientID: [2]byte{0, 1}, + IsReply: 0x01, + ErrorCode: [4]byte{0, 0, 0, 1}, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldError, []byte(ErrMsgCreateFolder)), + }, + }, + }, + }, + { + name: "FieldFileName does not allow directory traversal", + args: args{ + cc: &hotline.ClientConn{ + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + bits.Set(hotline.AccessCreateFolder) + return bits + }(), + }, + ID: [2]byte{0, 1}, + Server: &hotline.Server{ + TextDecoder: charmap.Macintosh.NewDecoder(), + TextEncoder: charmap.Macintosh.NewEncoder(), + Config: hotline.Config{ + FileRoot: "/Files/", + }, + FS: func() *hotline.MockFileStore { + mfs := &hotline.MockFileStore{} + mfs.On("Mkdir", "/Files/testFolder", fs.FileMode(0777)).Return(nil) + mfs.On("Stat", "/Files/testFolder").Return(nil, os.ErrNotExist) + return mfs + }(), + }, + }, + t: hotline.NewTransaction( + hotline.TranNewFolder, [2]byte{0, 1}, + hotline.NewField(hotline.FieldFileName, []byte("../../testFolder")), + ), + }, + wantRes: []hotline.Transaction{ + { + ClientID: [2]byte{0, 1}, + IsReply: 0x01, + }, + }, + }, + { + name: "FieldFilePath does not allow directory traversal", + args: args{ + cc: &hotline.ClientConn{ + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + bits.Set(hotline.AccessCreateFolder) + return bits + }(), + }, + ID: [2]byte{0, 1}, + Server: &hotline.Server{ + TextDecoder: charmap.Macintosh.NewDecoder(), + TextEncoder: charmap.Macintosh.NewEncoder(), + Config: hotline.Config{ + FileRoot: "/Files/", + }, + FS: func() *hotline.MockFileStore { + mfs := &hotline.MockFileStore{} + mfs.On("Mkdir", "/Files/foo/testFolder", fs.FileMode(0777)).Return(nil) + mfs.On("Stat", "/Files/foo/testFolder").Return(nil, os.ErrNotExist) + return mfs + }(), + }, + }, + t: hotline.NewTransaction( + hotline.TranNewFolder, [2]byte{0, 1}, + hotline.NewField(hotline.FieldFileName, []byte("testFolder")), + hotline.NewField(hotline.FieldFilePath, []byte{ + 0x00, 0x02, + 0x00, 0x00, + 0x03, + 0x2e, 0x2e, 0x2f, + 0x00, 0x00, + 0x03, + 0x66, 0x6f, 0x6f, + }), + ), + }, + wantRes: []hotline.Transaction{ + { + ClientID: [2]byte{0, 1}, + IsReply: 0x01, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotRes := HandleNewFolder(tt.args.cc, &tt.args.t) + + if !TranAssertEqual(t, tt.wantRes, gotRes) { + t.Errorf("HandleNewFolder() gotRes = %v, want %v", gotRes, tt.wantRes) + } + }) + } +} + +func TestHandleMakeAlias(t *testing.T) { + type args struct { + cc *hotline.ClientConn + t hotline.Transaction + } + tests := []struct { + name string + args args + wantRes []hotline.Transaction + }{ + { + name: "with valid input and required permissions", + args: args{ + cc: &hotline.ClientConn{ + Logger: NewTestLogger(), + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + bits.Set(hotline.AccessMakeAlias) + return bits + }(), + }, + Server: &hotline.Server{ + TextDecoder: charmap.Macintosh.NewDecoder(), + TextEncoder: charmap.Macintosh.NewEncoder(), + Config: hotline.Config{ + FileRoot: func() string { + path, _ := os.Getwd() + return path + "/test/config/Files" + }(), + }, + Logger: NewTestLogger(), + FS: func() *hotline.MockFileStore { + mfs := &hotline.MockFileStore{} + path, _ := os.Getwd() + mfs.On( + "Symlink", + path+"/test/config/Files/foo/testFile", + path+"/test/config/Files/bar/testFile", + ).Return(nil) + return mfs + }(), + }, + }, + t: hotline.NewTransaction( + hotline.TranMakeFileAlias, [2]byte{0, 1}, + hotline.NewField(hotline.FieldFileName, []byte("testFile")), + hotline.NewField(hotline.FieldFilePath, hotline.EncodeFilePath(strings.Join([]string{"foo"}, "/"))), + hotline.NewField(hotline.FieldFileNewPath, hotline.EncodeFilePath(strings.Join([]string{"bar"}, "/"))), + ), + }, + wantRes: []hotline.Transaction{ + { + IsReply: 0x01, + Fields: []hotline.Field(nil), + }, + }, + }, + { + name: "when symlink returns an error", + args: args{ + cc: &hotline.ClientConn{ + Logger: NewTestLogger(), + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + bits.Set(hotline.AccessMakeAlias) + return bits + }(), + }, + Server: &hotline.Server{ + TextDecoder: charmap.Macintosh.NewDecoder(), + TextEncoder: charmap.Macintosh.NewEncoder(), + Config: hotline.Config{ + FileRoot: func() string { + path, _ := os.Getwd() + return path + "/test/config/Files" + }(), + }, + Logger: NewTestLogger(), + FS: func() *hotline.MockFileStore { + mfs := &hotline.MockFileStore{} + path, _ := os.Getwd() + mfs.On( + "Symlink", + path+"/test/config/Files/foo/testFile", + path+"/test/config/Files/bar/testFile", + ).Return(errors.New("ohno")) + return mfs + }(), + }, + }, + t: hotline.NewTransaction( + hotline.TranMakeFileAlias, [2]byte{0, 1}, + hotline.NewField(hotline.FieldFileName, []byte("testFile")), + hotline.NewField(hotline.FieldFilePath, hotline.EncodeFilePath(strings.Join([]string{"foo"}, "/"))), + hotline.NewField(hotline.FieldFileNewPath, hotline.EncodeFilePath(strings.Join([]string{"bar"}, "/"))), + ), + }, + wantRes: []hotline.Transaction{ + { + IsReply: 0x01, + ErrorCode: [4]byte{0, 0, 0, 1}, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldError, []byte("Error creating alias")), + }, + }, + }, + }, + { + name: "when user does not have required permission", + args: args{ + cc: &hotline.ClientConn{ + Logger: NewTestLogger(), + Account: &hotline.Account{ + Access: hotline.AccessBitmap{}, + }, + Server: &hotline.Server{ + TextDecoder: charmap.Macintosh.NewDecoder(), + TextEncoder: charmap.Macintosh.NewEncoder(), + Config: hotline.Config{ + FileRoot: func() string { + path, _ := os.Getwd() + return path + "/test/config/Files" + }(), + }, + }, + }, + t: hotline.NewTransaction( + hotline.TranMakeFileAlias, [2]byte{0, 1}, + hotline.NewField(hotline.FieldFileName, []byte("testFile")), + hotline.NewField(hotline.FieldFilePath, []byte{ + 0x00, 0x01, + 0x00, 0x00, + 0x03, + 0x2e, 0x2e, 0x2e, + }), + hotline.NewField(hotline.FieldFileNewPath, []byte{ + 0x00, 0x01, + 0x00, 0x00, + 0x03, + 0x2e, 0x2e, 0x2e, + }), + ), + }, + wantRes: []hotline.Transaction{ + { + IsReply: 0x01, + ErrorCode: [4]byte{0, 0, 0, 1}, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldError, []byte("You are not allowed to make aliases.")), + }, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotRes := HandleMakeAlias(tt.args.cc, &tt.args.t) + TranAssertEqual(t, tt.wantRes, gotRes) + }) + } +} + +func TestHandleDeleteFile(t *testing.T) { + type args struct { + cc *hotline.ClientConn + t hotline.Transaction + } + tests := []struct { + name string + args args + wantRes []hotline.Transaction + }{ + { + name: "when user does not have required permission to delete a folder", + args: args{ + cc: &hotline.ClientConn{ + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + return bits + }(), + }, + Server: &hotline.Server{ + TextDecoder: charmap.Macintosh.NewDecoder(), + TextEncoder: charmap.Macintosh.NewEncoder(), + Config: hotline.Config{ + FileRoot: func() string { + return "/fakeRoot/Files" + }(), + }, + FS: func() *hotline.MockFileStore { + mfi := &hotline.MockFileInfo{} + mfi.On("Mode").Return(fs.FileMode(0)) + mfi.On("Size").Return(int64(100)) + mfi.On("ModTime").Return(time.Parse(time.Layout, time.Layout)) + mfi.On("IsDir").Return(false) + mfi.On("Name").Return("testfile") + + mfs := &hotline.MockFileStore{} + mfs.On("Stat", "/fakeRoot/Files/aaa/testfile").Return(mfi, nil) + mfs.On("Stat", "/fakeRoot/Files/aaa/.info_testfile").Return(nil, errors.New("err")) + mfs.On("Stat", "/fakeRoot/Files/aaa/.rsrc_testfile").Return(nil, errors.New("err")) + + return mfs + }(), + //Accounts: map[string]*Account{}, + }, + }, + t: hotline.NewTransaction( + hotline.TranDeleteFile, [2]byte{0, 1}, + hotline.NewField(hotline.FieldFileName, []byte("testfile")), + hotline.NewField(hotline.FieldFilePath, []byte{ + 0x00, 0x01, + 0x00, 0x00, + 0x03, + 0x61, 0x61, 0x61, + }), + ), + }, + wantRes: []hotline.Transaction{ + { + IsReply: 0x01, + ErrorCode: [4]byte{0, 0, 0, 1}, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldError, []byte("You are not allowed to delete files.")), + }, + }, + }, + }, + { + name: "deletes all associated metadata files", + args: args{ + cc: &hotline.ClientConn{ + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + bits.Set(hotline.AccessDeleteFile) + return bits + }(), + }, + Server: &hotline.Server{ + TextDecoder: charmap.Macintosh.NewDecoder(), + TextEncoder: charmap.Macintosh.NewEncoder(), + Config: hotline.Config{ + FileRoot: func() string { + return "/fakeRoot/Files" + }(), + }, + FS: func() *hotline.MockFileStore { + mfi := &hotline.MockFileInfo{} + mfi.On("Mode").Return(fs.FileMode(0)) + mfi.On("Size").Return(int64(100)) + mfi.On("ModTime").Return(time.Parse(time.Layout, time.Layout)) + mfi.On("IsDir").Return(false) + mfi.On("Name").Return("testfile") + + mfs := &hotline.MockFileStore{} + mfs.On("Stat", "/fakeRoot/Files/aaa/testfile").Return(mfi, nil) + mfs.On("Stat", "/fakeRoot/Files/aaa/.info_testfile").Return(nil, errors.New("err")) + mfs.On("Stat", "/fakeRoot/Files/aaa/.rsrc_testfile").Return(nil, errors.New("err")) + + mfs.On("RemoveAll", "/fakeRoot/Files/aaa/testfile").Return(nil) + mfs.On("Remove", "/fakeRoot/Files/aaa/testfile.incomplete").Return(nil) + mfs.On("Remove", "/fakeRoot/Files/aaa/.rsrc_testfile").Return(nil) + mfs.On("Remove", "/fakeRoot/Files/aaa/.info_testfile").Return(nil) + + return mfs + }(), + //Accounts: map[string]*Account{}, + }, + }, + t: hotline.NewTransaction( + hotline.TranDeleteFile, [2]byte{0, 1}, + hotline.NewField(hotline.FieldFileName, []byte("testfile")), + hotline.NewField(hotline.FieldFilePath, []byte{ + 0x00, 0x01, + 0x00, 0x00, + 0x03, + 0x61, 0x61, 0x61, + }), + ), + }, + wantRes: []hotline.Transaction{ + { + IsReply: 0x01, + Fields: []hotline.Field(nil), + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotRes := HandleDeleteFile(tt.args.cc, &tt.args.t) + TranAssertEqual(t, tt.wantRes, gotRes) + + tt.args.cc.Server.FS.(*hotline.MockFileStore).AssertExpectations(t) + }) + } +} + +func TestHandleGetFileNameList(t *testing.T) { + type args struct { + cc *hotline.ClientConn + t hotline.Transaction + } + tests := []struct { + name string + args args + wantRes []hotline.Transaction + }{ + { + name: "when FieldFilePath is a drop box, but user does not have AccessViewDropBoxes ", + args: args{ + cc: &hotline.ClientConn{ + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + return bits + }(), + }, + Server: &hotline.Server{ + TextDecoder: charmap.Macintosh.NewDecoder(), + TextEncoder: charmap.Macintosh.NewEncoder(), + + Config: hotline.Config{ + FileRoot: func() string { + path, _ := os.Getwd() + return filepath.Join(path, "/test/config/Files/getFileNameListTestDir") + }(), + }, + }, + }, + t: hotline.NewTransaction( + hotline.TranGetFileNameList, [2]byte{0, 1}, + hotline.NewField(hotline.FieldFilePath, []byte{ + 0x00, 0x01, + 0x00, 0x00, + 0x08, + 0x64, 0x72, 0x6f, 0x70, 0x20, 0x62, 0x6f, 0x78, // "drop box" + }), + ), + }, + wantRes: []hotline.Transaction{ + { + IsReply: 0x01, + ErrorCode: [4]byte{0, 0, 0, 1}, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldError, []byte("You are not allowed to view drop boxes.")), + }, + }, + }, + }, + { + name: "with file root", + args: args{ + cc: &hotline.ClientConn{ + Account: &hotline.Account{ + FileRoot: func() string { + path, _ := os.Getwd() + return filepath.Join(path, "/test/config/Files/getFileNameListTestDir") + }(), + }, + Server: &hotline.Server{TextDecoder: charmap.Macintosh.NewDecoder(), TextEncoder: charmap.Macintosh.NewEncoder()}, + }, + t: hotline.NewTransaction( + hotline.TranGetFileNameList, [2]byte{0, 1}, + hotline.NewField(hotline.FieldFilePath, []byte{ + 0x00, 0x00, + 0x00, 0x00, + }), + ), + }, + wantRes: []hotline.Transaction{ + { + IsReply: 0x01, + Fields: []hotline.Field{ + hotline.NewField( + hotline.FieldFileNameWithInfo, + func() []byte { + fnwi := hotline.FileNameWithInfo{ + FileNameWithInfoHeader: hotline.FileNameWithInfoHeader{ + Type: [4]byte{0x54, 0x45, 0x58, 0x54}, + Creator: [4]byte{0x54, 0x54, 0x58, 0x54}, + FileSize: [4]byte{0, 0, 0x04, 0}, + RSVD: [4]byte{}, + NameScript: [2]byte{}, + NameSize: [2]byte{0, 0x0b}, + }, + Name: []byte("testfile-1k"), + } + b, _ := io.ReadAll(&fnwi) + return b + }(), + ), + }, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotRes := HandleGetFileNameList(tt.args.cc, &tt.args.t) + TranAssertEqual(t, tt.wantRes, gotRes) + }) + } +} + +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) + }) + } +} diff --git a/internal/mobius/handlers_news.go b/internal/mobius/handlers_news.go new file mode 100644 index 0000000..5946fa5 --- /dev/null +++ b/internal/mobius/handlers_news.go @@ -0,0 +1,378 @@ +package mobius + +import ( + "bytes" + "fmt" + "io" + "strings" + "time" + + "github.com/jhalter/mobius/hotline" +) + +// HandleTranOldPostNews posts to the flat news (message board). +// +// Access: News Post Article (21) +// +// Fields used in the request: +// - 101 Data Required - News post content +// +// Fields used in the reply: None +func HandleTranOldPostNews(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) { + if !cc.Authorize(hotline.AccessNewsPostArt) { + return cc.NewErrReply(t, ErrMsgNotAllowedPostNews) + } + + newsDateTemplate := hotline.NewsDateFormat + if cc.Server.Config.NewsDateFormat != "" { + newsDateTemplate = cc.Server.Config.NewsDateFormat + } + + newsTemplate := hotline.NewsTemplate + if cc.Server.Config.NewsDelimiter != "" { + newsTemplate = cc.Server.Config.NewsDelimiter + } + + newsPost := fmt.Sprintf(newsTemplate+"\r", cc.UserName, time.Now().Format(newsDateTemplate), t.GetField(hotline.FieldData).Data) + newsPost = strings.ReplaceAll(newsPost, "\n", "\r") + + _, err := cc.Server.MessageBoard.Write([]byte(newsPost)) + if err != nil { + cc.Logger.Error("error writing news post", "err", err) + return cc.NewErrReply(t, ErrMsgPostNews) + } + + // Notify all clients of updated news + cc.SendAll( + hotline.TranNewMsg, + hotline.NewField(hotline.FieldData, []byte(newsPost)), + ) + + return append(res, cc.NewReply(t)) +} + +// HandleGetNewsCatNameList gets the list of category names at the specified news path. +// +// Fields used in the request: +// - 325 News path Optional +// +// Fields used in the reply: +// - 323 News category list data Repeated - Category information +func HandleGetNewsCatNameList(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) { + if !cc.Authorize(hotline.AccessNewsReadArt) { + return cc.NewErrReply(t, ErrMsgNotAllowedReadNews) + } + + pathStrs, err := t.GetField(hotline.FieldNewsPath).DecodeNewsPath() + if err != nil { + cc.Logger.Error("get news path", "err", err) + return cc.NewErrReply(t, ErrMsgReadNewsCategories) + } + + var fields []hotline.Field + for _, cat := range cc.Server.ThreadedNewsMgr.GetCategories(pathStrs) { + b, err := io.ReadAll(&cat) + if err != nil { + cc.Logger.Error("get news categories", "err", err) + return cc.NewErrReply(t, ErrMsgReadNewsCategories) + } + + fields = append(fields, hotline.NewField(hotline.FieldNewsCatListData15, b)) + } + + return append(res, cc.NewReply(t, fields...)) +} + +// HandleNewNewsCat creates a new news category on the server. +// +// Access: News Create Category (34) +// +// Fields used in the request: +// - 322 News category name Required +// - 325 News path Required +// +// Fields used in the reply: None +func HandleNewNewsCat(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) { + if !cc.Authorize(hotline.AccessNewsCreateCat) { + return cc.NewErrReply(t, ErrMsgNotAllowedCreateNewsCategories) + } + + name := string(t.GetField(hotline.FieldNewsCatName).Data) + pathStrs, err := t.GetField(hotline.FieldNewsPath).DecodeNewsPath() + if err != nil { + cc.Logger.Error("new news category: decode news path", "err", err) + return cc.NewErrReply(t, ErrMsgCreateNewsCategory) + } + + err = cc.Server.ThreadedNewsMgr.CreateGrouping(pathStrs, name, hotline.NewsCategory) + if err != nil { + cc.Logger.Error("error creating news category", "err", err) + return cc.NewErrReply(t, ErrMsgCreateNewsCategory) + } + + return []hotline.Transaction{cc.NewReply(t)} +} + +// HandleNewNewsFldr creates a new news folder on the server. +// +// Access: News Create Folder (36) +// +// Fields used in the request: +// - 201 File name Required +// - 325 News path Required +// +// Fields used in the reply: None +func HandleNewNewsFldr(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) { + if !cc.Authorize(hotline.AccessNewsCreateFldr) { + return cc.NewErrReply(t, ErrMsgNotAllowedCreateNewsfolders) + } + + name := string(t.GetField(hotline.FieldFileName).Data) + pathStrs, err := t.GetField(hotline.FieldNewsPath).DecodeNewsPath() + if err != nil { + cc.Logger.Error("new news folder: decode news path", "err", err) + return cc.NewErrReply(t, ErrMsgCreateNewsFolder) + } + + err = cc.Server.ThreadedNewsMgr.CreateGrouping(pathStrs, name, hotline.NewsBundle) + if err != nil { + cc.Logger.Error("error creating news bundle", "err", err) + return cc.NewErrReply(t, ErrMsgCreateNewsFolder) + } + + return append(res, cc.NewReply(t)) +} + +// HandleGetNewsArtNameList gets the list of article names at the specified news path. +// +// Fields used in the request: +// - 325 News path Optional +// +// Fields used in the reply: +// - 321 News article list data Optional +func HandleGetNewsArtNameList(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) { + if !cc.Authorize(hotline.AccessNewsReadArt) { + return cc.NewErrReply(t, ErrMsgNotAllowedReadNews) + } + + pathStrs, err := t.GetField(hotline.FieldNewsPath).DecodeNewsPath() + if err != nil { + cc.Logger.Error("get news article list: decode news path", "err", err) + return cc.NewErrReply(t, ErrMsgReadNewsArticles) + } + + nald, err := cc.Server.ThreadedNewsMgr.ListArticles(pathStrs) + if err != nil { + cc.Logger.Error("get news article list: list articles", "err", err) + return cc.NewErrReply(t, ErrMsgReadNewsArticles) + } + + b, err := io.ReadAll(&nald) + if err != nil { + cc.Logger.Error("get news article list: read article list data", "err", err) + return cc.NewErrReply(t, ErrMsgReadNewsArticles) + } + + return append(res, cc.NewReply(t, hotline.NewField(hotline.FieldNewsArtListData, b))) +} + +// HandleGetNewsArtData requests information about a specific news article. +// +// Access: News Read Article (20) +// +// Fields used in the request: +// - 325 News path Required +// - 326 News article ID Required +// - 327 News article data flavor Required +// +// Fields used in the reply: +// - 328 News article title Article title +// - 329 News article poster Author +// - 330 News article date Publication date +// - 331 Previous article ID ID of previous article +// - 332 Next article ID ID of next article +// - 335 Parent article ID ID of parent article +// - 336 First child article ID ID of first reply +// - 327 News article data flavor Should be "text/plain" +// - 333 News article data Optional - Article content +func HandleGetNewsArtData(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) { + if !cc.Authorize(hotline.AccessNewsReadArt) { + return cc.NewErrReply(t, ErrMsgNotAllowedReadNews) + } + + newsPath, err := t.GetField(hotline.FieldNewsPath).DecodeNewsPath() + if err != nil { + cc.Logger.Error("get news article: decode news path", "err", err) + return cc.NewErrReply(t, ErrMsgReadNewsArticles) + } + + convertedID, err := t.GetField(hotline.FieldNewsArtID).DecodeInt() + if err != nil { + cc.Logger.Error("get news article: decode article ID", "err", err) + return cc.NewErrReply(t, ErrMsgReadNewsArticles) + } + + art := cc.Server.ThreadedNewsMgr.GetArticle(newsPath, uint32(convertedID)) + if art == nil { + return append(res, cc.NewReply(t)) + } + + res = append(res, cc.NewReply(t, + hotline.NewField(hotline.FieldNewsArtTitle, []byte(art.Title)), + hotline.NewField(hotline.FieldNewsArtPoster, []byte(art.Poster)), + hotline.NewField(hotline.FieldNewsArtDate, art.Date[:]), + hotline.NewField(hotline.FieldNewsArtPrevArt, art.PrevArt[:]), + hotline.NewField(hotline.FieldNewsArtNextArt, art.NextArt[:]), + hotline.NewField(hotline.FieldNewsArtParentArt, art.ParentArt[:]), + hotline.NewField(hotline.FieldNewsArt1stChildArt, art.FirstChildArt[:]), + hotline.NewField(hotline.FieldNewsArtDataFlav, []byte("text/plain")), + hotline.NewField(hotline.FieldNewsArtData, []byte(art.Data)), + )) + return res +} + +// HandleDelNewsItem deletes an existing news item from the server. +// +// Access: News Delete Folder (37) or News Delete Category (35) +// +// Fields used in the request: +// - 325 News path Required +// +// Fields used in the reply: None +func HandleDelNewsItem(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) { + pathStrs, err := t.GetField(hotline.FieldNewsPath).DecodeNewsPath() + if err != nil || len(pathStrs) == 0 { + cc.Logger.Error("delete news item: invalid news path", "err", err) + return cc.NewErrReply(t, ErrMsgDeleteNewsItem) + } + + item := cc.Server.ThreadedNewsMgr.NewsItem(pathStrs) + + if item.Type == [2]byte{0, 3} { + if !cc.Authorize(hotline.AccessNewsDeleteCat) { + return cc.NewErrReply(t, ErrMsgNotAllowedDeleteNewsCategories) + } + } else { + if !cc.Authorize(hotline.AccessNewsDeleteFldr) { + return cc.NewErrReply(t, ErrMsgNotAllowedDeleteNewsFolders) + } + } + + err = cc.Server.ThreadedNewsMgr.DeleteNewsItem(pathStrs) + if err != nil { + cc.Logger.Error("delete news item", "err", err) + return cc.NewErrReply(t, ErrMsgDeleteNewsItem) + } + + return append(res, cc.NewReply(t)) +} + +// HandleDelNewsArt deletes a specific news article. +// +// Access: News Delete Article (33) +// +// Fields used in the request: +// - 325 News path Required +// - 326 News article ID Required +// - 337 News article – recursive delete Optional - Delete child articles (1) or not (0) +// +// Fields used in the reply: None +func HandleDelNewsArt(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) { + if !cc.Authorize(hotline.AccessNewsDeleteArt) { + return cc.NewErrReply(t, ErrMsgNotAllowedDeleteNewsArticles) + + } + + pathStrs, err := t.GetField(hotline.FieldNewsPath).DecodeNewsPath() + if err != nil { + cc.Logger.Error("delete news article: decode news path", "err", err) + return cc.NewErrReply(t, ErrMsgDeleteNewsArticle) + } + + articleID, err := t.GetField(hotline.FieldNewsArtID).DecodeInt() + if err != nil { + cc.Logger.Error("delete news article: decode article ID", "err", err) + return cc.NewErrReply(t, ErrMsgDeleteNewsArticle) + } + + deleteRecursive := bytes.Equal([]byte{0, 1}, t.GetField(hotline.FieldNewsArtRecurseDel).Data) + + err = cc.Server.ThreadedNewsMgr.DeleteArticle(pathStrs, uint32(articleID), deleteRecursive) + if err != nil { + cc.Logger.Error("error deleting news article", "err", err) + return cc.NewErrReply(t, ErrMsgDeleteNewsArticle) + } + + return []hotline.Transaction{cc.NewReply(t)} +} + +// HandlePostNewsArt posts a new news article on the server. +// +// Access: News Post Article (21) +// +// Fields used in the request: +// - 325 News path Required +// - 326 News article ID ID of the parent article +// - 328 News article title Required +// - 334 News article flags Optional +// - 327 News article data flavor Currently "text/plain" +// - 333 News article data Required +// +// Fields used in the reply: None +func HandlePostNewsArt(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) { + if !cc.Authorize(hotline.AccessNewsPostArt) { + return cc.NewErrReply(t, ErrMsgNotAllowedPostNewsArticles) + } + + pathStrs, err := t.GetField(hotline.FieldNewsPath).DecodeNewsPath() + if err != nil || len(pathStrs) == 0 { + cc.Logger.Error("post news article: invalid news path", "err", err) + return cc.NewErrReply(t, ErrMsgPostNewsArticle) + } + + parentArticleID, err := t.GetField(hotline.FieldNewsArtID).DecodeInt() + if err != nil { + cc.Logger.Error("post news article: decode parent article ID", "err", err) + return cc.NewErrReply(t, ErrMsgPostNewsArticle) + } + + err = cc.Server.ThreadedNewsMgr.PostArticle( + pathStrs, + uint32(parentArticleID), + hotline.NewsArtData{ + Title: string(t.GetField(hotline.FieldNewsArtTitle).Data), + Poster: string(cc.UserName), + Date: hotline.NewTime(time.Now()), + DataFlav: hotline.NewsFlavor, + Data: string(t.GetField(hotline.FieldNewsArtData).Data), + }, + ) + if err != nil { + cc.Logger.Error("error posting news article", "err", err) + return cc.NewErrReply(t, ErrMsgPostNewsArticle) + } + + return append(res, cc.NewReply(t)) +} + +// HandleGetMsgs returns the flat news data (message board content). +// +// Fields used in the request: None +// +// Fields used in the reply: +// - 101 Data Message text +func HandleGetMsgs(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) { + if !cc.Authorize(hotline.AccessNewsReadArt) { + return cc.NewErrReply(t, ErrMsgNotAllowedReadNews) + } + + _, _ = cc.Server.MessageBoard.Seek(0, 0) + + newsData, err := io.ReadAll(cc.Server.MessageBoard) + if err != nil { + cc.Logger.Error("Error reading messageboard", "err", err) + return cc.NewErrReply(t, ErrMsgReadMessageBoard) + } + + return append(res, cc.NewReply(t, hotline.NewField(hotline.FieldData, newsData))) +} diff --git a/internal/mobius/handlers_news_test.go b/internal/mobius/handlers_news_test.go new file mode 100644 index 0000000..3e5125e --- /dev/null +++ b/internal/mobius/handlers_news_test.go @@ -0,0 +1,1167 @@ +package mobius + +import ( + "errors" + "io" + "log/slog" + "os" + "testing" + + "github.com/jhalter/mobius/hotline" + "github.com/stretchr/testify/mock" + "golang.org/x/text/encoding/charmap" +) + +func TestHandleGetMsgs(t *testing.T) { + type args struct { + cc *hotline.ClientConn + t hotline.Transaction + } + tests := []struct { + name string + args args + wantRes []hotline.Transaction + }{ + { + name: "returns news data", + 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(), + MessageBoard: func() *mockReadWriteSeeker { + m := mockReadWriteSeeker{} + m.On("Seek", int64(0), 0).Return(int64(0), nil) + m.On("Read", mock.AnythingOfType("[]uint8")).Run(func(args mock.Arguments) { + arg := args.Get(0).([]uint8) + copy(arg, "TEST") + }).Return(4, io.EOF) + return &m + }(), + }, + }, + t: hotline.NewTransaction( + hotline.TranGetMsgs, [2]byte{0, 1}, + ), + }, + wantRes: []hotline.Transaction{ + { + IsReply: 0x01, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldData, []byte("TEST")), + }, + }, + }, + }, + { + name: "when ReadAll returns an error", + args: args{ + cc: &hotline.ClientConn{ + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + bits.Set(hotline.AccessNewsReadArt) + return bits + }(), + }, + Logger: NewTestLogger(), + Server: &hotline.Server{ + TextDecoder: charmap.Macintosh.NewDecoder(), + TextEncoder: charmap.Macintosh.NewEncoder(), + MessageBoard: func() *mockReadWriteSeeker { + m := mockReadWriteSeeker{} + m.On("Seek", int64(0), 0).Return(int64(0), nil) + m.On("Read", mock.AnythingOfType("[]uint8")).Return(0, errors.New("read error")) + return &m + }(), + }, + }, + t: hotline.NewTransaction( + hotline.TranGetMsgs, [2]byte{0, 1}, + ), + }, + wantRes: []hotline.Transaction{ + { + IsReply: 0x01, + ErrorCode: [4]byte{0, 0, 0, 1}, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldError, []byte("Error reading message board.")), + }, + }, + }, + }, + { + name: "when user does not have required permission", + args: args{ + cc: &hotline.ClientConn{ + Account: &hotline.Account{ + Access: hotline.AccessBitmap{}, + }, + Server: &hotline.Server{ + TextDecoder: charmap.Macintosh.NewDecoder(), + TextEncoder: charmap.Macintosh.NewEncoder(), + //Accounts: map[string]*Account{}, + }, + }, + t: hotline.NewTransaction( + hotline.TranGetMsgs, [2]byte{0, 1}, + ), + }, + wantRes: []hotline.Transaction{ + { + IsReply: 0x01, + ErrorCode: [4]byte{0, 0, 0, 1}, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldError, []byte("You are not allowed to read news.")), + }, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotRes := HandleGetMsgs(tt.args.cc, &tt.args.t) + TranAssertEqual(t, tt.wantRes, gotRes) + }) + } +} + +func TestHandleDelNewsArt(t *testing.T) { + type args struct { + cc *hotline.ClientConn + t hotline.Transaction + } + tests := []struct { + name string + args args + wantRes []hotline.Transaction + }{ + { + name: "without required permission", + args: args{ + cc: &hotline.ClientConn{ + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + return bits + }(), + }, + }, + t: hotline.NewTransaction( + hotline.TranDelNewsArt, + [2]byte{0, 0}, + ), + }, + wantRes: []hotline.Transaction{ + { + IsReply: 0x01, + ErrorCode: [4]byte{0, 0, 0, 1}, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldError, []byte("You are not allowed to delete news articles.")), + }, + }, + }, + }, + { + name: "when DeleteArticle returns an error", + args: args{ + cc: &hotline.ClientConn{ + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + bits.Set(hotline.AccessNewsDeleteArt) + return bits + }(), + }, + Logger: NewTestLogger(), + ID: [2]byte{0, 1}, + Server: &hotline.Server{ + TextDecoder: charmap.Macintosh.NewDecoder(), + TextEncoder: charmap.Macintosh.NewEncoder(), + ThreadedNewsMgr: func() *hotline.MockThreadNewsMgr { + m := hotline.MockThreadNewsMgr{} + m.On("DeleteArticle", []string{"test"}, uint32(1), false).Return(errors.New("write error")) + return &m + }(), + }, + }, + t: hotline.NewTransaction( + hotline.TranDelNewsArt, [2]byte{0, 1}, + hotline.NewField(hotline.FieldNewsPath, + []byte{ + 0, 1, + 0, 0, + 4, + 0x74, 0x65, 0x73, 0x74, + }, + ), + hotline.NewField(hotline.FieldNewsArtID, []byte{0, 0, 0, 1}), + ), + }, + wantRes: []hotline.Transaction{ + { + ClientID: [2]byte{0, 1}, + IsReply: 0x01, + ErrorCode: [4]byte{0, 0, 0, 1}, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldError, []byte("Error deleting news article.")), + }, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotRes := HandleDelNewsArt(tt.args.cc, &tt.args.t) + TranAssertEqual(t, tt.wantRes, gotRes) + }) + } +} + +// When the news path is empty, the handler should return an error reply rather +// than silently discarding the request. +func TestHandleDelNewsItem_emptyPathReturnsError(t *testing.T) { + cc := &hotline.ClientConn{ + ID: [2]byte{0, 1}, + Logger: NewTestLogger(), + Server: &hotline.Server{ + TextDecoder: charmap.Macintosh.NewDecoder(), + TextEncoder: charmap.Macintosh.NewEncoder(), + }, + } + tr := hotline.NewTransaction(hotline.TranDelNewsItem, [2]byte{}) + + gotRes := HandleDelNewsItem(cc, &tr) + + 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(ErrMsgDeleteNewsItem)), + }, + }, + } + TranAssertEqual(t, wantRes, gotRes) +} + +func TestHandleDelNewsItem(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 delete a news category", + args: args{ + cc: &hotline.ClientConn{ + Account: &hotline.Account{ + Access: hotline.AccessBitmap{}, + }, + ID: [2]byte{0, 1}, + Server: &hotline.Server{ + TextDecoder: charmap.Macintosh.NewDecoder(), + TextEncoder: charmap.Macintosh.NewEncoder(), + ThreadedNewsMgr: func() *hotline.MockThreadNewsMgr { + m := hotline.MockThreadNewsMgr{} + m.On("NewsItem", []string{"test"}).Return(hotline.NewsCategoryListData15{ + Type: hotline.NewsCategory, + }) + return &m + }(), + }, + }, + t: hotline.NewTransaction( + hotline.TranDelNewsItem, [2]byte{}, + hotline.NewField(hotline.FieldNewsPath, + []byte{ + 0, 1, + 0, 0, + 4, + 0x74, 0x65, 0x73, 0x74, + }, + ), + ), + }, + wantRes: []hotline.Transaction{ + { + ClientID: [2]byte{0, 1}, + IsReply: 0x01, + ErrorCode: [4]byte{0, 0, 0, 1}, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldError, []byte("You are not allowed to delete news categories.")), + }, + }, + }, + }, + { + name: "when user does not have permission to delete a news folder", + args: args{ + cc: &hotline.ClientConn{ + Account: &hotline.Account{ + Access: hotline.AccessBitmap{}, + }, + ID: [2]byte{0, 1}, + Server: &hotline.Server{ + TextDecoder: charmap.Macintosh.NewDecoder(), + TextEncoder: charmap.Macintosh.NewEncoder(), + ThreadedNewsMgr: func() *hotline.MockThreadNewsMgr { + m := hotline.MockThreadNewsMgr{} + m.On("NewsItem", []string{"test"}).Return(hotline.NewsCategoryListData15{ + Type: hotline.NewsBundle, + }) + return &m + }(), + }, + }, + t: hotline.NewTransaction( + hotline.TranDelNewsItem, [2]byte{}, + hotline.NewField(hotline.FieldNewsPath, + []byte{ + 0, 1, + 0, 0, + 4, + 0x74, 0x65, 0x73, 0x74, + }, + ), + ), + }, + wantRes: []hotline.Transaction{ + { + ClientID: [2]byte{0, 1}, + IsReply: 0x01, + ErrorCode: [4]byte{0, 0, 0, 1}, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldError, []byte("You are not allowed to delete news folders.")), + }, + }, + }, + }, + { + name: "when user deletes a news folder", + args: args{ + cc: &hotline.ClientConn{ + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + bits.Set(hotline.AccessNewsDeleteFldr) + return bits + }(), + }, + ID: [2]byte{0, 1}, + Server: &hotline.Server{ + TextDecoder: charmap.Macintosh.NewDecoder(), + TextEncoder: charmap.Macintosh.NewEncoder(), + ThreadedNewsMgr: func() *hotline.MockThreadNewsMgr { + m := hotline.MockThreadNewsMgr{} + m.On("NewsItem", []string{"test"}).Return(hotline.NewsCategoryListData15{Type: hotline.NewsBundle}) + m.On("DeleteNewsItem", []string{"test"}).Return(nil) + return &m + }(), + }, + }, + t: hotline.NewTransaction( + hotline.TranDelNewsItem, [2]byte{}, + hotline.NewField(hotline.FieldNewsPath, + []byte{ + 0, 1, + 0, 0, + 4, + 0x74, 0x65, 0x73, 0x74, + }, + ), + ), + }, + wantRes: []hotline.Transaction{ + { + ClientID: [2]byte{0, 1}, + IsReply: 0x01, + Fields: []hotline.Field{}, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotRes := HandleDelNewsItem(tt.args.cc, &tt.args.t) + + TranAssertEqual(t, tt.wantRes, gotRes) + }) + } +} + +func TestHandleTranOldPostNews(t *testing.T) { + type args struct { + cc *hotline.ClientConn + t hotline.Transaction + } + tests := []struct { + name string + args args + wantRes []hotline.Transaction + }{ + { + name: "when user does not have required permission", + args: args{ + cc: &hotline.ClientConn{ + Account: &hotline.Account{ + Access: hotline.AccessBitmap{}, + }, + }, + t: hotline.NewTransaction( + hotline.TranOldPostNews, [2]byte{0, 1}, + hotline.NewField(hotline.FieldData, []byte("hai")), + ), + }, + wantRes: []hotline.Transaction{ + { + IsReply: 0x01, + ErrorCode: [4]byte{0, 0, 0, 1}, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldError, []byte("You are not allowed to post news.")), + }, + }, + }, + }, + { + name: "when user posts news update", + args: args{ + cc: &hotline.ClientConn{ + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + bits.Set(hotline.AccessNewsPostArt) + return bits + }(), + }, + Server: &hotline.Server{ + TextDecoder: charmap.Macintosh.NewDecoder(), + TextEncoder: charmap.Macintosh.NewEncoder(), + Config: hotline.Config{ + NewsDateFormat: "", + }, + ClientMgr: func() *hotline.MockClientMgr { + m := hotline.MockClientMgr{} + m.On("List").Return([]*hotline.ClientConn{}) + return &m + }(), + MessageBoard: func() *mockReadWriteSeeker { + m := mockReadWriteSeeker{} + m.On("Seek", int64(0), 0).Return(int64(0), nil) + m.On("Read", mock.AnythingOfType("[]uint8")).Run(func(args mock.Arguments) { + arg := args.Get(0).([]uint8) + copy(arg, "TEST") + }).Return(4, io.EOF) + m.On("Write", mock.AnythingOfType("[]uint8")).Return(3, nil) + return &m + }(), + }, + }, + t: hotline.NewTransaction( + hotline.TranOldPostNews, [2]byte{0, 1}, + hotline.NewField(hotline.FieldData, []byte("hai")), + ), + }, + wantRes: []hotline.Transaction{ + { + IsReply: 0x01, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotRes := HandleTranOldPostNews(tt.args.cc, &tt.args.t) + + TranAssertEqual(t, tt.wantRes, gotRes) + }) + } +} + +func TestHandleGetNewsArtData(t *testing.T) { + type args struct { + cc *hotline.ClientConn + t hotline.Transaction + } + tests := []struct { + name string + args args + wantRes []hotline.Transaction + }{ + { + name: "when user does not have required permission", + args: args{ + cc: &hotline.ClientConn{Account: &hotline.Account{}}, + t: hotline.NewTransaction( + hotline.TranGetNewsArtData, [2]byte{0, 1}, + ), + }, + wantRes: []hotline.Transaction{ + { + IsReply: 0x01, + ErrorCode: [4]byte{0, 0, 0, 1}, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldError, []byte("You are not allowed to read news.")), + }, + }, + }, + }, + { + name: "when user has required permission", + 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("GetArticle", []string{"Example Category"}, uint32(1)).Return(&hotline.NewsArtData{ + Title: "title", + Poster: "poster", + Date: [8]byte{}, + PrevArt: [4]byte{0, 0, 0, 1}, + NextArt: [4]byte{0, 0, 0, 2}, + ParentArt: [4]byte{0, 0, 0, 3}, + FirstChildArt: [4]byte{0, 0, 0, 4}, + DataFlav: []byte("text/plain"), + Data: "article data", + }) + return &m + }(), + }, + }, + t: hotline.NewTransaction( + hotline.TranGetNewsArtData, [2]byte{0, 1}, + hotline.NewField(hotline.FieldNewsPath, []byte{ + // Example Category + 0x00, 0x01, 0x00, 0x00, 0x10, 0x45, 0x78, 0x61, 0x6d, 0x70, 0x6c, 0x65, 0x20, 0x43, 0x61, 0x74, 0x65, 0x67, 0x6f, 0x72, 0x79, + }), + hotline.NewField(hotline.FieldNewsArtID, []byte{0, 1}), + ), + }, + wantRes: []hotline.Transaction{ + { + IsReply: 1, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldNewsArtTitle, []byte("title")), + hotline.NewField(hotline.FieldNewsArtPoster, []byte("poster")), + hotline.NewField(hotline.FieldNewsArtDate, []byte{0, 0, 0, 0, 0, 0, 0, 0}), + hotline.NewField(hotline.FieldNewsArtPrevArt, []byte{0, 0, 0, 1}), + hotline.NewField(hotline.FieldNewsArtNextArt, []byte{0, 0, 0, 2}), + hotline.NewField(hotline.FieldNewsArtParentArt, []byte{0, 0, 0, 3}), + hotline.NewField(hotline.FieldNewsArt1stChildArt, []byte{0, 0, 0, 4}), + hotline.NewField(hotline.FieldNewsArtDataFlav, []byte("text/plain")), + hotline.NewField(hotline.FieldNewsArtData, []byte("article data")), + }, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotRes := HandleGetNewsArtData(tt.args.cc, &tt.args.t) + TranAssertEqual(t, tt.wantRes, gotRes) + }) + } +} + +func TestHandleGetNewsArtNameList(t *testing.T) { + type args struct { + cc *hotline.ClientConn + t hotline.Transaction + } + tests := []struct { + name string + args args + wantRes []hotline.Transaction + }{ + { + name: "when user does not have required permission", + args: args{ + cc: &hotline.ClientConn{ + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + return bits + }(), + }, + Server: &hotline.Server{ + TextDecoder: charmap.Macintosh.NewDecoder(), + TextEncoder: charmap.Macintosh.NewEncoder(), + //Accounts: map[string]*Account{}, + }, + }, + t: hotline.NewTransaction( + hotline.TranGetNewsArtNameList, [2]byte{0, 1}, + ), + }, + wantRes: []hotline.Transaction{ + { + Flags: 0x00, + IsReply: 0x01, + Type: [2]byte{0, 0}, + ErrorCode: [4]byte{0, 0, 0, 1}, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldError, []byte("You are not allowed to read news.")), + }, + }, + }, + }, + { + 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) { + gotRes := HandleGetNewsArtNameList(tt.args.cc, &tt.args.t) + + TranAssertEqual(t, tt.wantRes, gotRes) + }) + } +} + +func TestHandleNewNewsFldr(t *testing.T) { + type args struct { + cc *hotline.ClientConn + t hotline.Transaction + } + tests := []struct { + name string + args args + wantRes []hotline.Transaction + }{ + { + name: "when user does not have required permission", + args: args{ + cc: &hotline.ClientConn{ + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + return bits + }(), + }, + Server: &hotline.Server{ + TextDecoder: charmap.Macintosh.NewDecoder(), + TextEncoder: charmap.Macintosh.NewEncoder(), + //Accounts: map[string]*Account{}, + }, + }, + t: hotline.NewTransaction( + hotline.TranGetNewsArtNameList, [2]byte{0, 1}, + ), + }, + wantRes: []hotline.Transaction{ + { + Flags: 0x00, + IsReply: 0x01, + Type: [2]byte{0, 0}, + ErrorCode: [4]byte{0, 0, 0, 1}, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldError, []byte("You are not allowed to create news folders.")), + }, + }, + }, + }, + { + name: "with a valid request", + args: args{ + cc: &hotline.ClientConn{ + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + bits.Set(hotline.AccessNewsCreateFldr) + return bits + }(), + }, + Logger: NewTestLogger(), + ID: [2]byte{0, 1}, + Server: &hotline.Server{ + TextDecoder: charmap.Macintosh.NewDecoder(), + TextEncoder: charmap.Macintosh.NewEncoder(), + ThreadedNewsMgr: func() *hotline.MockThreadNewsMgr { + m := hotline.MockThreadNewsMgr{} + m.On("CreateGrouping", []string{"test"}, "testFolder", hotline.NewsBundle).Return(nil) + return &m + }(), + }, + }, + t: hotline.NewTransaction( + hotline.TranGetNewsArtNameList, [2]byte{0, 1}, + hotline.NewField(hotline.FieldFileName, []byte("testFolder")), + hotline.NewField(hotline.FieldNewsPath, + []byte{ + 0, 1, + 0, 0, + 4, + 0x74, 0x65, 0x73, 0x74, + }, + ), + ), + }, + wantRes: []hotline.Transaction{ + { + ClientID: [2]byte{0, 1}, + IsReply: 0x01, + Fields: []hotline.Field{}, + }, + }, + }, + { + name: "when CreateGrouping returns an error", + args: args{ + cc: &hotline.ClientConn{ + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + bits.Set(hotline.AccessNewsCreateFldr) + return bits + }(), + }, + Logger: NewTestLogger(), + ID: [2]byte{0, 1}, + Server: &hotline.Server{ + TextDecoder: charmap.Macintosh.NewDecoder(), + TextEncoder: charmap.Macintosh.NewEncoder(), + ThreadedNewsMgr: func() *hotline.MockThreadNewsMgr { + m := hotline.MockThreadNewsMgr{} + m.On("CreateGrouping", []string{"test"}, "testFolder", hotline.NewsBundle).Return(errors.New("write error")) + return &m + }(), + }, + }, + t: hotline.NewTransaction( + hotline.TranGetNewsArtNameList, [2]byte{0, 1}, + hotline.NewField(hotline.FieldFileName, []byte("testFolder")), + hotline.NewField(hotline.FieldNewsPath, + []byte{ + 0, 1, + 0, 0, + 4, + 0x74, 0x65, 0x73, 0x74, + }, + ), + ), + }, + wantRes: []hotline.Transaction{ + { + ClientID: [2]byte{0, 1}, + IsReply: 0x01, + ErrorCode: [4]byte{0, 0, 0, 1}, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldError, []byte("Error creating news folder.")), + }, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotRes := HandleNewNewsFldr(tt.args.cc, &tt.args.t) + + TranAssertEqual(t, tt.wantRes, gotRes) + }) + } +} + +func TestHandlePostNewsArt(t *testing.T) { + type args struct { + cc *hotline.ClientConn + t hotline.Transaction + } + tests := []struct { + name string + args args + wantRes []hotline.Transaction + }{ + { + name: "without required permission", + args: args{ + cc: &hotline.ClientConn{ + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + return bits + }(), + }, + }, + t: hotline.NewTransaction( + hotline.TranPostNewsArt, + [2]byte{0, 0}, + ), + }, + wantRes: []hotline.Transaction{ + { + IsReply: 0x01, + ErrorCode: [4]byte{0, 0, 0, 1}, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldError, []byte("You are not allowed to post news articles.")), + }, + }, + }, + }, + { + name: "with required permission", + args: args{ + cc: &hotline.ClientConn{ + Server: &hotline.Server{ + TextDecoder: charmap.Macintosh.NewDecoder(), + TextEncoder: charmap.Macintosh.NewEncoder(), + ThreadedNewsMgr: func() *hotline.MockThreadNewsMgr { + m := hotline.MockThreadNewsMgr{} + m.On("PostArticle", []string{"www"}, uint32(0), mock.AnythingOfType("hotline.NewsArtData")).Return(nil) + return &m + }(), + }, + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + bits.Set(hotline.AccessNewsPostArt) + return bits + }(), + }, + }, + t: hotline.NewTransaction( + hotline.TranPostNewsArt, + [2]byte{0, 0}, + hotline.NewField(hotline.FieldNewsPath, []byte{0x00, 0x01, 0x00, 0x00, 0x03, 0x77, 0x77, 0x77}), + hotline.NewField(hotline.FieldNewsArtID, []byte{0x00, 0x00, 0x00, 0x00}), + ), + }, + wantRes: []hotline.Transaction{ + { + IsReply: 0x01, + ErrorCode: [4]byte{0, 0, 0, 0}, + Fields: []hotline.Field{}, + }, + }, + }, + { + name: "when PostArticle returns an error", + args: args{ + cc: &hotline.ClientConn{ + Server: &hotline.Server{ + TextDecoder: charmap.Macintosh.NewDecoder(), + TextEncoder: charmap.Macintosh.NewEncoder(), + ThreadedNewsMgr: func() *hotline.MockThreadNewsMgr { + m := hotline.MockThreadNewsMgr{} + m.On("PostArticle", []string{"www"}, uint32(0), mock.AnythingOfType("hotline.NewsArtData")).Return(errors.New("write error")) + return &m + }(), + }, + Logger: NewTestLogger(), + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + bits.Set(hotline.AccessNewsPostArt) + return bits + }(), + }, + }, + t: hotline.NewTransaction( + hotline.TranPostNewsArt, + [2]byte{0, 0}, + hotline.NewField(hotline.FieldNewsPath, []byte{0x00, 0x01, 0x00, 0x00, 0x03, 0x77, 0x77, 0x77}), + hotline.NewField(hotline.FieldNewsArtID, []byte{0x00, 0x00, 0x00, 0x00}), + ), + }, + wantRes: []hotline.Transaction{ + { + IsReply: 0x01, + ErrorCode: [4]byte{0, 0, 0, 1}, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldError, []byte("Error posting news article.")), + }, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + TranAssertEqual(t, tt.wantRes, HandlePostNewsArt(tt.args.cc, &tt.args.t)) + }) + } +} + +func TestHandleNewNewsCat(t *testing.T) { + type args struct { + cc *hotline.ClientConn + t hotline.Transaction + } + tests := []struct { + name string + args args + wantRes []hotline.Transaction + }{ + { + name: "when user does not have required permission", + args: args{ + cc: &hotline.ClientConn{ + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + return bits + }(), + }, + Server: &hotline.Server{TextDecoder: charmap.Macintosh.NewDecoder(), TextEncoder: charmap.Macintosh.NewEncoder()}, + }, + t: hotline.NewTransaction( + hotline.TranNewNewsCat, [2]byte{0, 1}, + ), + }, + wantRes: []hotline.Transaction{ + { + Flags: 0x00, + IsReply: 0x01, + Type: [2]byte{0, 0}, + ErrorCode: [4]byte{0, 0, 0, 1}, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldError, []byte(ErrMsgNotAllowedCreateNewsCategories)), + }, + }, + }, + }, + { + name: "when CreateGrouping returns an error", + args: args{ + cc: &hotline.ClientConn{ + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + bits.Set(hotline.AccessNewsCreateCat) + return bits + }(), + }, + Logger: NewTestLogger(), + ID: [2]byte{0, 1}, + Server: &hotline.Server{ + TextDecoder: charmap.Macintosh.NewDecoder(), + TextEncoder: charmap.Macintosh.NewEncoder(), + ThreadedNewsMgr: func() *hotline.MockThreadNewsMgr { + m := hotline.MockThreadNewsMgr{} + m.On("CreateGrouping", []string{"test"}, "TestCat", hotline.NewsCategory).Return(errors.New("write error")) + return &m + }(), + }, + }, + t: hotline.NewTransaction( + hotline.TranNewNewsCat, [2]byte{0, 1}, + hotline.NewField(hotline.FieldNewsCatName, []byte("TestCat")), + hotline.NewField(hotline.FieldNewsPath, + []byte{ + 0, 1, + 0, 0, + 4, + 0x74, 0x65, 0x73, 0x74, + }, + ), + ), + }, + wantRes: []hotline.Transaction{ + { + ClientID: [2]byte{0, 1}, + IsReply: 0x01, + ErrorCode: [4]byte{0, 0, 0, 1}, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldError, []byte(ErrMsgCreateNewsCategory)), + }, + }, + }, + }, + { + name: "with a valid request", + args: args{ + cc: &hotline.ClientConn{ + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + bits.Set(hotline.AccessNewsCreateCat) + return bits + }(), + }, + Logger: NewTestLogger(), + ID: [2]byte{0, 1}, + Server: &hotline.Server{ + TextDecoder: charmap.Macintosh.NewDecoder(), + TextEncoder: charmap.Macintosh.NewEncoder(), + ThreadedNewsMgr: func() *hotline.MockThreadNewsMgr { + m := hotline.MockThreadNewsMgr{} + m.On("CreateGrouping", []string{"test"}, "TestCat", hotline.NewsCategory).Return(nil) + return &m + }(), + }, + }, + t: hotline.NewTransaction( + hotline.TranNewNewsCat, [2]byte{0, 1}, + hotline.NewField(hotline.FieldNewsCatName, []byte("TestCat")), + hotline.NewField(hotline.FieldNewsPath, + []byte{ + 0, 1, + 0, 0, + 4, + 0x74, 0x65, 0x73, 0x74, + }, + ), + ), + }, + wantRes: []hotline.Transaction{ + { + ClientID: [2]byte{0, 1}, + IsReply: 0x01, + Fields: []hotline.Field{}, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotRes := HandleNewNewsCat(tt.args.cc, &tt.args.t) + + TranAssertEqual(t, tt.wantRes, gotRes) + }) + } +} + +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) + }) + } +} diff --git a/internal/mobius/handlers_session.go b/internal/mobius/handlers_session.go new file mode 100644 index 0000000..ad6773b --- /dev/null +++ b/internal/mobius/handlers_session.go @@ -0,0 +1,349 @@ +package mobius + +import ( + "context" + "encoding/binary" + "io" + "math/big" + "time" + + "github.com/jhalter/mobius/hotline" +) + +// HandleUserBroadcast broadcasts a message to all users on the server. +// +// Access: Broadcast (32) +// +// Fields used in the request: +// - 101 Data Required +// +// Fields used in the reply: None +func HandleUserBroadcast(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) { + if !cc.Authorize(hotline.AccessBroadcast) { + return cc.NewErrReply(t, ErrMsgNotAllowedSendBroadcast) + } + + cc.SendAll( + hotline.TranServerMsg, + hotline.NewField(hotline.FieldData, t.GetField(hotline.FieldData).Data), + hotline.NewField(hotline.FieldChatOptions, []byte{0}), + ) + + return append(res, cc.NewReply(t)) +} + +// HandleGetClientInfoText requests user information for a specific user. +// +// Access: Get Client Info (24) +// +// Fields used in the request: +// - 103 User ID Required +// +// Fields used in the reply: +// - 102 User name User's display name +// - 101 Data User info text string +func HandleGetClientInfoText(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) { + if !cc.Authorize(hotline.AccessGetClientInfo) { + return cc.NewErrReply(t, ErrMsgNotAllowedGetClientInfo) + } + + clientID, ok := hotline.ClientIDFromBytes(t.GetField(hotline.FieldUserID).Data) + if !ok { + return cc.NewErrReply(t, ErrMsgInvalidUserID) + } + + clientConn := cc.Server.ClientMgr.Get(clientID) + if clientConn == nil { + return cc.NewErrReply(t, ErrMsgUserNotFound) + } + + return append(res, cc.NewReply(t, + hotline.NewField(hotline.FieldData, []byte(clientConn.String())), + hotline.NewField(hotline.FieldUserName, clientConn.UserName), + )) +} + +// HandleGetUserNameList requests the list of all users connected to the current server. +// +// Fields used in the request: None +// +// Fields used in the reply: +// - 300 User name with info Repeated - User information for each connected client +func HandleGetUserNameList(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) { + var fields []hotline.Field + for _, c := range cc.Server.ClientMgr.List() { + b, err := io.ReadAll(&hotline.User{ + ID: c.ID, + Icon: c.Icon, + Flags: c.Flags[:], + Name: string(c.UserName), + }) + if err != nil { + cc.Logger.Error("get user name list: read user info", "err", err) + return cc.NewErrReply(t, ErrMsgGetUserList) + } + + fields = append(fields, hotline.NewField(hotline.FieldUsernameWithInfo, b)) + } + + return []hotline.Transaction{cc.NewReply(t, fields...)} +} + +// HandleTranAgreed notifies the server that the user accepted the server agreement. +// +// Fields used in the request: +// - 102 User name Display name +// - 104 User icon ID User icon identifier +// - 113 Options Bitmap: Automatic response (4), Refuse private chat (2), Refuse private message (1) +// - 215 Automatic response Optional - Auto-response string if options field indicates this feature +// +// Fields used in the reply: None +func HandleTranAgreed(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) { + if t.GetField(hotline.FieldUserName).Data != nil { + if cc.Authorize(hotline.AccessAnyName) { + cc.UserName = t.GetField(hotline.FieldUserName).Data + } else { + cc.UserName = []byte(cc.Account.Name) + } + } + + login := cc.Account.Login + ip := cc.IP() + + if cc.Server.Redis != nil { + // Remove old entry (login::ip) + cc.Server.Redis.SRem(context.Background(), hotline.RedisKeyOnline, login+"::"+ip) + // Add new entry with login, nickname, ip + cc.Server.Redis.SAdd(context.Background(), hotline.RedisKeyOnline, login+":"+string(cc.UserName)+":"+ip) + } + + // Ban check for nickname + if cc.Server.BanList != nil && cc.Server.BanList.IsNicknameBanned(string(cc.UserName)) { + if cc.Server.Redis != nil { + // Remove all possible online entries for this login and IP + cc.Server.Redis.SRem(context.Background(), hotline.RedisKeyOnline, login+"::"+ip) + cc.Server.Redis.SRem(context.Background(), hotline.RedisKeyOnline, login+":"+string(cc.UserName)+":"+ip) + } + if err := cc.Server.BanList.Add(ip, nil); err != nil { + cc.Logger.Error("Failed to ban IP for banned nickname", "ip", ip, "err", err) + } + cc.Disconnect() + // Connection is being torn down; no reply is sent to the client. + return res + } + + cc.Icon = t.GetField(hotline.FieldUserIconID).Data + + cc.Logger = cc.Logger.With("Name", string(cc.UserName)) + cc.Logger.Info("Login successful") + + options := t.GetField(hotline.FieldOptions).Data + optBitmap := big.NewInt(int64(binary.BigEndian.Uint16(options))) + + // Check refuse private PM option + + cc.FlagsMU.Lock() + defer cc.FlagsMU.Unlock() + cc.Flags.Set(hotline.UserFlagRefusePM, optBitmap.Bit(hotline.UserOptRefusePM)) + + // Check refuse private chat option + cc.Flags.Set(hotline.UserFlagRefusePChat, optBitmap.Bit(hotline.UserOptRefuseChat)) + + // Check auto response + if optBitmap.Bit(hotline.UserOptAutoResponse) == 1 { + cc.AutoReply = t.GetField(hotline.FieldAutomaticResponse).Data + } + + trans := cc.NotifyOthers( + hotline.NewTransaction( + hotline.TranNotifyChangeUser, [2]byte{0, 0}, + hotline.NewField(hotline.FieldUserName, cc.UserName), + hotline.NewField(hotline.FieldUserID, cc.ID[:]), + hotline.NewField(hotline.FieldUserIconID, cc.Icon), + hotline.NewField(hotline.FieldUserFlags, cc.Flags[:]), + ), + ) + res = append(res, trans...) + + if cc.Server.Config.BannerFile != "" { + bannerType := hotline.FileTypeFromFilename(cc.Server.Config.BannerFile).TypeCode + res = append(res, hotline.NewTransaction(hotline.TranServerBanner, cc.ID, hotline.NewField(hotline.FieldBannerType, []byte(bannerType)))) + } + + res = append(res, cc.NewReply(t)) + + return res +} + +// HandleDisconnectUser disconnects a user from the current server. +// +// Access: Disconnect User (22) +// +// Fields used in the request: +// - 103 User ID Required +// - 113 Options Optional - Ban options +// - 101 Data Optional +// +// Fields used in the reply: None +func HandleDisconnectUser(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) { + if !cc.Authorize(hotline.AccessDisconUser) { + return cc.NewErrReply(t, ErrMsgNotAllowedDisconnectUsers) + } + + clientID, ok := hotline.ClientIDFromBytes(t.GetField(hotline.FieldUserID).Data) + if !ok { + return cc.NewErrReply(t, ErrMsgInvalidUserID) + } + clientConn := cc.Server.ClientMgr.Get(clientID) + if clientConn == nil { + return cc.NewErrReply(t, ErrMsgUserNotFound) + } + + if clientConn.Authorize(hotline.AccessCannotBeDiscon) { + return cc.NewErrReply(t, clientConn.Account.Login+" is not allowed to be disconnected.") + } + + // If FieldOptions is set, then the client IP is banned in addition to disconnected. + // 00 01 = temporary ban + // 00 02 = permanent ban + if options := t.GetField(hotline.FieldOptions).Data; len(options) > 1 { + switch options[1] { + case 1: + // send message: "You are temporarily banned on this server" + cc.Logger.Info("Disconnect & temporarily ban user", "username", string(clientConn.UserName)) + + res = append(res, hotline.NewTransaction( + hotline.TranServerMsg, + clientConn.ID, + hotline.NewField(hotline.FieldData, []byte(ErrMsgTemporaryBan)), + hotline.NewField(hotline.FieldChatOptions, []byte{0, 0}), + )) + + banUntil := time.Now().Add(hotline.BanDuration) + ip := clientConn.IP() + + err := cc.Server.BanList.Add(ip, &banUntil) + if err != nil { + cc.Logger.Error("Error saving ban", "err", err) + // TODO + } + case 2: + // send message: "You are permanently banned on this server" + cc.Logger.Info("Disconnect & ban user", "username", string(clientConn.UserName)) + + res = append(res, hotline.NewTransaction( + hotline.TranServerMsg, + clientConn.ID, + hotline.NewField(hotline.FieldData, []byte(ErrMsgPermanentBan)), + hotline.NewField(hotline.FieldChatOptions, []byte{0, 0}), + )) + + ip := clientConn.IP() + + err := cc.Server.BanList.Add(ip, nil) + if err != nil { + cc.Logger.Error("Error saving ban", "err", err) + } + } + } + + go func() { + time.Sleep(1 * time.Second) + clientConn.Disconnect() + }() + + return append(res, cc.NewReply(t)) +} + +// HandleSetClientUserInfo sets user preferences on the server. +// +// Fields used in the request: +// - 102 User name Optional +// - 104 User icon ID Optional +// - 113 Options Bitmap: Automatic response (4), Refuse private chat (2), Refuse private message (1) +// - 215 Automatic response Optional - Auto-response string if options field indicates this feature +// +// Reply is not expected. +func HandleSetClientUserInfo(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) { + if len(t.GetField(hotline.FieldUserIconID).Data) == 4 { + cc.Icon = t.GetField(hotline.FieldUserIconID).Data[2:] + } else { + cc.Icon = t.GetField(hotline.FieldUserIconID).Data + } + if cc.Authorize(hotline.AccessAnyName) { + oldNickname := string(cc.UserName) + newNickname := string(t.GetField(hotline.FieldUserName).Data) + cc.UserName = t.GetField(hotline.FieldUserName).Data + + login := cc.Account.Login + ip := cc.IP() + + if cc.Server.Redis != nil { + // Remove old entry (login:oldnickname:ip) and (login::ip) + cc.Server.Redis.SRem(context.Background(), hotline.RedisKeyOnline, login+"::"+ip) + if oldNickname != "" { + cc.Server.Redis.SRem(context.Background(), hotline.RedisKeyOnline, login+":"+oldNickname+":"+ip) + } + // Add new entry + cc.Server.Redis.SAdd(context.Background(), hotline.RedisKeyOnline, login+":"+newNickname+":"+ip) + } + + // Ban check for nickname + if cc.Server.BanList != nil && cc.Server.BanList.IsNicknameBanned(newNickname) { + if cc.Server.Redis != nil { + // Remove all possible online entries for this login and IP + cc.Server.Redis.SRem(context.Background(), hotline.RedisKeyOnline, login+"::"+ip) + cc.Server.Redis.SRem(context.Background(), hotline.RedisKeyOnline, login+":"+newNickname+":"+ip) + if oldNickname != "" { + cc.Server.Redis.SRem(context.Background(), hotline.RedisKeyOnline, login+":"+oldNickname+":"+ip) + } + } + if err := cc.Server.BanList.Add(ip, nil); err != nil { + cc.Logger.Error("Failed to ban IP for banned nickname", "ip", ip, "err", err) + } + cc.Disconnect() + // Connection is being torn down; no reply is sent to the client. + return res + } + } + + // the options field is only passed by the client versions > 1.2.3. + options := t.GetField(hotline.FieldOptions).Data + if options != nil { + optBitmap := big.NewInt(int64(binary.BigEndian.Uint16(options))) + + cc.Flags.Set(hotline.UserFlagRefusePM, optBitmap.Bit(hotline.UserOptRefusePM)) + cc.Flags.Set(hotline.UserFlagRefusePChat, optBitmap.Bit(hotline.UserOptRefuseChat)) + + // Check auto response + if optBitmap.Bit(hotline.UserOptAutoResponse) == 1 { + cc.AutoReply = t.GetField(hotline.FieldAutomaticResponse).Data + } else { + cc.AutoReply = []byte{} + } + } + + for _, c := range cc.Server.ClientMgr.List() { + res = append(res, hotline.NewTransaction( + hotline.TranNotifyChangeUser, + c.ID, + hotline.NewField(hotline.FieldUserID, cc.ID[:]), + hotline.NewField(hotline.FieldUserIconID, cc.Icon), + hotline.NewField(hotline.FieldUserFlags, cc.Flags[:]), + hotline.NewField(hotline.FieldUserName, cc.UserName), + )) + } + + return res +} + +// HandleKeepAlive responds to client keepalive messages to maintain the connection. +// +// Fields used in the request: None +// +// Fields used in the reply: None +func HandleKeepAlive(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) { + res = append(res, cc.NewReply(t)) + + return res +} diff --git a/internal/mobius/handlers_session_test.go b/internal/mobius/handlers_session_test.go new file mode 100644 index 0000000..407b132 --- /dev/null +++ b/internal/mobius/handlers_session_test.go @@ -0,0 +1,934 @@ +package mobius + +import ( + "strings" + "testing" + "time" + + "github.com/jhalter/mobius/hotline" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "golang.org/x/text/encoding/charmap" +) + +func TestHandleGetUserNameList(t *testing.T) { + type args struct { + cc *hotline.ClientConn + t hotline.Transaction + } + tests := []struct { + name string + args args + want []hotline.Transaction + }{ + { + name: "replies with userlist transaction", + args: args{ + cc: &hotline.ClientConn{ + ID: [2]byte{0, 1}, + Server: &hotline.Server{ + TextDecoder: charmap.Macintosh.NewDecoder(), + TextEncoder: charmap.Macintosh.NewEncoder(), + ClientMgr: func() *hotline.MockClientMgr { + m := hotline.MockClientMgr{} + m.On("List").Return([]*hotline.ClientConn{ + { + ID: [2]byte{0, 1}, + Icon: []byte{0, 2}, + Flags: [2]byte{0, 3}, + UserName: []byte{0, 4}, + }, + { + ID: [2]byte{0, 2}, + Icon: []byte{0, 2}, + Flags: [2]byte{0, 3}, + UserName: []byte{0, 4}, + }, + }, + ) + return &m + }(), + }, + }, + t: hotline.Transaction{}, + }, + want: []hotline.Transaction{ + { + ClientID: [2]byte{0, 1}, + IsReply: 0x01, + Fields: []hotline.Field{ + hotline.NewField( + hotline.FieldUsernameWithInfo, + []byte{00, 01, 00, 02, 00, 03, 00, 02, 00, 04}, + ), + hotline.NewField( + hotline.FieldUsernameWithInfo, + []byte{00, 02, 00, 02, 00, 03, 00, 02, 00, 04}, + ), + }, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := HandleGetUserNameList(tt.args.cc, &tt.args.t) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestHandleDisconnectUser(t *testing.T) { + type args struct { + cc *hotline.ClientConn + t hotline.Transaction + } + tests := []struct { + name string + args args + wantRes []hotline.Transaction + }{ + { + name: "without required permission", + args: args{ + cc: &hotline.ClientConn{ + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + return bits + }(), + }, + }, + t: hotline.NewTransaction( + hotline.TranDelNewsArt, + [2]byte{0, 0}, + ), + }, + wantRes: []hotline.Transaction{ + { + IsReply: 0x01, + ErrorCode: [4]byte{0, 0, 0, 1}, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldError, []byte("You are not allowed to disconnect users.")), + }, + }, + }, + }, + { + name: "when target user has 'cannot be disconnected' priv", + args: args{ + cc: &hotline.ClientConn{ + Server: &hotline.Server{ + TextDecoder: charmap.Macintosh.NewDecoder(), + TextEncoder: charmap.Macintosh.NewEncoder(), + ClientMgr: func() *hotline.MockClientMgr { + m := hotline.MockClientMgr{} + m.On("Get", hotline.ClientID{0x0, 0x1}).Return(&hotline.ClientConn{ + Account: &hotline.Account{ + Login: "unnamed", + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + bits.Set(hotline.AccessCannotBeDiscon) + return bits + }(), + }, + }, + ) + return &m + }(), + }, + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + bits.Set(hotline.AccessDisconUser) + return bits + }(), + }, + }, + t: hotline.NewTransaction( + hotline.TranDelNewsArt, + [2]byte{0, 0}, + hotline.NewField(hotline.FieldUserID, []byte{0, 1}), + ), + }, + wantRes: []hotline.Transaction{ + { + IsReply: 0x01, + ErrorCode: [4]byte{0, 0, 0, 1}, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldError, []byte("unnamed is not allowed to be disconnected.")), + }, + }, + }, + }, + { + name: "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) { + gotRes := HandleDisconnectUser(tt.args.cc, &tt.args.t) + TranAssertEqual(t, tt.wantRes, gotRes) + }) + } +} + +func TestHandleGetClientInfoText(t *testing.T) { + type args struct { + cc *hotline.ClientConn + t hotline.Transaction + } + tests := []struct { + name string + args args + wantRes []hotline.Transaction + }{ + { + name: "when user does not have required permission", + args: args{ + cc: &hotline.ClientConn{ + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + return bits + }(), + }, + Server: &hotline.Server{ + TextDecoder: charmap.Macintosh.NewDecoder(), + TextEncoder: charmap.Macintosh.NewEncoder(), + //Accounts: map[string]*Account{}, + }, + }, + t: hotline.NewTransaction( + hotline.TranGetClientInfoText, [2]byte{0, 1}, + hotline.NewField(hotline.FieldUserID, []byte{0, 1}), + ), + }, + wantRes: []hotline.Transaction{ + { + IsReply: 0x01, + ErrorCode: [4]byte{0, 0, 0, 1}, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldError, []byte("You are not allowed to get client info.")), + }, + }, + }, + }, + { + name: "with a valid user", + args: args{ + cc: &hotline.ClientConn{ + UserName: []byte("Testy McTest"), + RemoteAddr: "1.2.3.4:12345", + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + bits.Set(hotline.AccessGetClientInfo) + return bits + }(), + Name: "test", + Login: "test", + }, + 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{ + UserName: []byte("Testy McTest"), + RemoteAddr: "1.2.3.4:12345", + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + bits.Set(hotline.AccessGetClientInfo) + return bits + }(), + Name: "test", + Login: "test", + }, + }, + ) + return &m + }(), + }, + ClientFileTransferMgr: hotline.ClientFileTransferMgr{}, + }, + t: hotline.NewTransaction( + hotline.TranGetClientInfoText, [2]byte{0, 1}, + hotline.NewField(hotline.FieldUserID, []byte{0, 1}), + ), + }, + wantRes: []hotline.Transaction{ + { + IsReply: 0x01, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldData, []byte( + strings.ReplaceAll(`Nickname: Testy McTest +Name: test +Account: test +Address: 1.2.3.4:12345 + +-------- File Downloads --------- + +None. + +------- Folder Downloads -------- + +None. + +--------- File Uploads ---------- + +None. + +-------- Folder Uploads --------- + +None. + +------- Waiting Downloads ------- + +None. + +`, "\n", "\r")), + ), + hotline.NewField(hotline.FieldUserName, []byte("Testy McTest")), + }, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotRes := HandleGetClientInfoText(tt.args.cc, &tt.args.t) + TranAssertEqual(t, tt.wantRes, gotRes) + }) + } +} + +func TestHandleTranAgreed(t *testing.T) { + type args struct { + cc *hotline.ClientConn + t hotline.Transaction + } + tests := []struct { + name string + args args + wantRes []hotline.Transaction + }{ + { + name: "normal request flow", + args: args{ + cc: &hotline.ClientConn{ + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + bits.Set(hotline.AccessDisconUser) + bits.Set(hotline.AccessAnyName) + return bits + }()}, + Icon: []byte{0, 1}, + Flags: [2]byte{0, 1}, + Version: []byte{0, 1}, + ID: [2]byte{0, 1}, + Logger: NewTestLogger(), + Server: &hotline.Server{ + TextDecoder: charmap.Macintosh.NewDecoder(), + TextEncoder: charmap.Macintosh.NewEncoder(), + Config: hotline.Config{ + BannerFile: "Banner.jpg", + }, + ClientMgr: func() *hotline.MockClientMgr { + m := hotline.MockClientMgr{} + m.On("List").Return([]*hotline.ClientConn{ + //{ + // ID: [2]byte{0, 2}, + // UserName: []byte("UserB"), + //}, + }, + ) + return &m + }(), + }, + }, + t: hotline.NewTransaction( + hotline.TranAgreed, [2]byte{}, + hotline.NewField(hotline.FieldUserName, []byte("username")), + hotline.NewField(hotline.FieldUserIconID, []byte{0, 1}), + hotline.NewField(hotline.FieldOptions, []byte{0, 0}), + ), + }, + wantRes: []hotline.Transaction{ + { + ClientID: [2]byte{0, 1}, + Type: [2]byte{0, 0x7a}, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldBannerType, []byte("JPEG")), + }, + }, + { + ClientID: [2]byte{0, 1}, + IsReply: 0x01, + Fields: []hotline.Field{}, + }, + }, + }, + { + name: "with gif banner", + args: args{ + cc: &hotline.ClientConn{ + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + bits.Set(hotline.AccessDisconUser) + bits.Set(hotline.AccessAnyName) + return bits + }()}, + Icon: []byte{0, 1}, + Flags: [2]byte{0, 1}, + Version: []byte{0, 1}, + ID: [2]byte{0, 1}, + Logger: NewTestLogger(), + Server: &hotline.Server{ + TextDecoder: charmap.Macintosh.NewDecoder(), + TextEncoder: charmap.Macintosh.NewEncoder(), + Config: hotline.Config{ + BannerFile: "Banner.gif", + }, + ClientMgr: func() *hotline.MockClientMgr { + m := hotline.MockClientMgr{} + m.On("List").Return([]*hotline.ClientConn{}) + return &m + }(), + }, + }, + t: hotline.NewTransaction( + hotline.TranAgreed, [2]byte{}, + hotline.NewField(hotline.FieldUserName, []byte("username")), + hotline.NewField(hotline.FieldUserIconID, []byte{0, 1}), + hotline.NewField(hotline.FieldOptions, []byte{0, 0}), + ), + }, + wantRes: []hotline.Transaction{ + { + ClientID: [2]byte{0, 1}, + Type: [2]byte{0, 0x7a}, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldBannerType, []byte("GIFf")), + }, + }, + { + ClientID: [2]byte{0, 1}, + IsReply: 0x01, + Fields: []hotline.Field{}, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotRes := HandleTranAgreed(tt.args.cc, &tt.args.t) + TranAssertEqual(t, tt.wantRes, gotRes) + }) + } +} + +func TestHandleSetClientUserInfo(t *testing.T) { + type args struct { + cc *hotline.ClientConn + t hotline.Transaction + } + tests := []struct { + name string + args args + wantRes []hotline.Transaction + }{ + { + name: "when client does not have AccessAnyName", + args: args{ + cc: &hotline.ClientConn{ + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + return bits + }(), + }, + ID: [2]byte{0, 1}, + UserName: []byte("Guest"), + Flags: [2]byte{0, 1}, + Server: &hotline.Server{ + TextDecoder: charmap.Macintosh.NewDecoder(), + TextEncoder: charmap.Macintosh.NewEncoder(), + ClientMgr: func() *hotline.MockClientMgr { + m := hotline.MockClientMgr{} + m.On("List").Return([]*hotline.ClientConn{ + { + ID: [2]byte{0, 1}, + }, + }) + return &m + }(), + }, + }, + t: hotline.NewTransaction( + hotline.TranSetClientUserInfo, [2]byte{}, + hotline.NewField(hotline.FieldUserIconID, []byte{0, 1}), + hotline.NewField(hotline.FieldUserName, []byte("NOPE")), + ), + }, + wantRes: []hotline.Transaction{ + { + ClientID: [2]byte{0, 1}, + Type: [2]byte{0x01, 0x2d}, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldUserID, []byte{0, 1}), + hotline.NewField(hotline.FieldUserIconID, []byte{0, 1}), + hotline.NewField(hotline.FieldUserFlags, []byte{0, 1}), + hotline.NewField(hotline.FieldUserName, []byte("Guest"))}, + }, + }, + }, + { + name: "when client has AccessAnyName and changes username", + args: args{ + cc: &hotline.ClientConn{ + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + bits.Set(hotline.AccessAnyName) + return bits + }(), + }, + ID: [2]byte{0, 1}, + UserName: []byte("Guest"), + Flags: [2]byte{0, 1}, + Server: &hotline.Server{ + TextDecoder: charmap.Macintosh.NewDecoder(), + TextEncoder: charmap.Macintosh.NewEncoder(), + ClientMgr: func() *hotline.MockClientMgr { + m := hotline.MockClientMgr{} + m.On("List").Return([]*hotline.ClientConn{ + { + ID: [2]byte{0, 1}, + }, + }) + return &m + }(), + }, + }, + t: hotline.NewTransaction( + hotline.TranSetClientUserInfo, [2]byte{}, + hotline.NewField(hotline.FieldUserName, []byte("NewName")), + ), + }, + wantRes: []hotline.Transaction{ + { + ClientID: [2]byte{0, 1}, + Type: [2]byte{0x01, 0x2d}, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldUserID, []byte{0, 1}), + hotline.NewField(hotline.FieldUserIconID, []byte{}), + hotline.NewField(hotline.FieldUserFlags, []byte{0, 1}), + hotline.NewField(hotline.FieldUserName, []byte("NewName"))}, + }, + }, + }, + { + name: "when client updates icon with 4-byte data", + args: args{ + cc: &hotline.ClientConn{ + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + return bits + }(), + }, + ID: [2]byte{0, 1}, + UserName: []byte("Guest"), + Flags: [2]byte{0, 1}, + Server: &hotline.Server{ + TextDecoder: charmap.Macintosh.NewDecoder(), + TextEncoder: charmap.Macintosh.NewEncoder(), + ClientMgr: func() *hotline.MockClientMgr { + m := hotline.MockClientMgr{} + m.On("List").Return([]*hotline.ClientConn{ + { + ID: [2]byte{0, 1}, + }, + }) + return &m + }(), + }, + }, + t: hotline.NewTransaction( + hotline.TranSetClientUserInfo, [2]byte{}, + hotline.NewField(hotline.FieldUserIconID, []byte{0, 1, 0, 5}), + ), + }, + wantRes: []hotline.Transaction{ + { + ClientID: [2]byte{0, 1}, + Type: [2]byte{0x01, 0x2d}, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldUserID, []byte{0, 1}), + hotline.NewField(hotline.FieldUserIconID, []byte{0, 5}), + hotline.NewField(hotline.FieldUserFlags, []byte{0, 1}), + hotline.NewField(hotline.FieldUserName, []byte("Guest"))}, + }, + }, + }, + { + name: "when client updates icon with 2-byte data", + args: args{ + cc: &hotline.ClientConn{ + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + return bits + }(), + }, + ID: [2]byte{0, 1}, + UserName: []byte("Guest"), + Flags: [2]byte{0, 1}, + Server: &hotline.Server{ + TextDecoder: charmap.Macintosh.NewDecoder(), + TextEncoder: charmap.Macintosh.NewEncoder(), + ClientMgr: func() *hotline.MockClientMgr { + m := hotline.MockClientMgr{} + m.On("List").Return([]*hotline.ClientConn{ + { + ID: [2]byte{0, 1}, + }, + }) + return &m + }(), + }, + }, + t: hotline.NewTransaction( + hotline.TranSetClientUserInfo, [2]byte{}, + hotline.NewField(hotline.FieldUserIconID, []byte{0, 3}), + ), + }, + wantRes: []hotline.Transaction{ + { + ClientID: [2]byte{0, 1}, + Type: [2]byte{0x01, 0x2d}, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldUserID, []byte{0, 1}), + hotline.NewField(hotline.FieldUserIconID, []byte{0, 3}), + hotline.NewField(hotline.FieldUserFlags, []byte{0, 1}), + hotline.NewField(hotline.FieldUserName, []byte("Guest"))}, + }, + }, + }, + { + name: "when client sets user options with auto-reply", + args: args{ + cc: &hotline.ClientConn{ + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + return bits + }(), + }, + ID: [2]byte{0, 1}, + UserName: []byte("Guest"), + Flags: [2]byte{0, 1}, + Version: []byte{0x01, 0x03}, + Server: &hotline.Server{ + TextDecoder: charmap.Macintosh.NewDecoder(), + TextEncoder: charmap.Macintosh.NewEncoder(), + ClientMgr: func() *hotline.MockClientMgr { + m := hotline.MockClientMgr{} + m.On("List").Return([]*hotline.ClientConn{ + { + ID: [2]byte{0, 1}, + }, + }) + return &m + }(), + }, + }, + t: hotline.NewTransaction( + hotline.TranSetClientUserInfo, [2]byte{}, + hotline.NewField(hotline.FieldOptions, []byte{0, 4}), + hotline.NewField(hotline.FieldAutomaticResponse, []byte("Away message")), + ), + }, + wantRes: []hotline.Transaction{ + { + ClientID: [2]byte{0, 1}, + Type: [2]byte{0x01, 0x2d}, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldUserID, []byte{0, 1}), + hotline.NewField(hotline.FieldUserIconID, []byte{}), + hotline.NewField(hotline.FieldUserFlags, []byte{0, 1}), + hotline.NewField(hotline.FieldUserName, []byte("Guest"))}, + }, + }, + }, + { + name: "when client sets refuse private messages flag", + args: args{ + cc: &hotline.ClientConn{ + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + return bits + }(), + }, + ID: [2]byte{0, 1}, + UserName: []byte("Guest"), + Flags: [2]byte{0, 1}, + Version: []byte{0x01, 0x03}, + Server: &hotline.Server{ + TextDecoder: charmap.Macintosh.NewDecoder(), + TextEncoder: charmap.Macintosh.NewEncoder(), + ClientMgr: func() *hotline.MockClientMgr { + m := hotline.MockClientMgr{} + m.On("List").Return([]*hotline.ClientConn{ + { + ID: [2]byte{0, 1}, + }, + }) + return &m + }(), + }, + }, + t: hotline.NewTransaction( + hotline.TranSetClientUserInfo, [2]byte{}, + hotline.NewField(hotline.FieldOptions, []byte{0, 2}), + ), + }, + wantRes: []hotline.Transaction{ + { + ClientID: [2]byte{0, 1}, + Type: [2]byte{0x01, 0x2d}, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldUserID, []byte{0, 1}), + hotline.NewField(hotline.FieldUserIconID, []byte{}), + hotline.NewField(hotline.FieldUserFlags, []byte{0, 9}), + hotline.NewField(hotline.FieldUserName, []byte("Guest"))}, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotRes := HandleSetClientUserInfo(tt.args.cc, &tt.args.t) + TranAssertEqual(t, tt.wantRes, gotRes) + }) + } +} + +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) + }) + } +} diff --git a/internal/mobius/handlers_transfers.go b/internal/mobius/handlers_transfers.go new file mode 100644 index 0000000..dfb2389 --- /dev/null +++ b/internal/mobius/handlers_transfers.go @@ -0,0 +1,286 @@ +package mobius + +import ( + "encoding/binary" + "fmt" + + "github.com/jhalter/mobius/hotline" +) + +// HandleDownloadFile downloads a file from the specified path on the server. +// +// Access: Download File (2) +// +// Fields used in the request: +// - 201 File name Required +// - 202 File path Optional +// - 203 File resume data Optional +// - 204 File transfer options Optional - Set to 2 for TEXT, JPEG, GIFF, BMP or PICT files +// +// Fields used in the reply: +// - 108 Transfer size Size of data to be downloaded +// - 207 File size Actual file size +// - 107 Reference number Used later for transfer +// - 116 Waiting count Queue position +func HandleDownloadFile(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) { + if !cc.Authorize(hotline.AccessDownloadFile) { + return cc.NewErrReply(t, ErrMsgNotAllowedDownloadFiles) + } + + fileName := t.GetField(hotline.FieldFileName).Data + filePath := t.GetField(hotline.FieldFilePath).Data + resumeData := t.GetField(hotline.FieldFileResumeData).Data + + var dataOffset int64 + var frd hotline.FileResumeData + if resumeData != nil { + if err := frd.UnmarshalBinary(t.GetField(hotline.FieldFileResumeData).Data); err != nil { + cc.Logger.Error("download file: unmarshal resume data", "err", err) + return cc.NewErrReply(t, ErrMsgFileResumeData) + } + // TODO: handle rsrc fork offset + if len(frd.ForkInfoList) > 0 { + dataOffset = int64(binary.BigEndian.Uint32(frd.ForkInfoList[0].DataSize[:])) + } + } + + fullFilePath, err := hotline.ReadPath(cc.FileRoot(), filePath, fileName, cc.TextDecoder()) + if err != nil { + cc.Logger.Error("download file: read path", "err", err) + return cc.NewErrReply(t, ErrMsgFileNotFound) + } + + hlFile, err := hotline.NewFile(cc.Server.FS, fullFilePath, dataOffset) + if err != nil { + cc.Logger.Error("download file: open file", "err", err) + return cc.NewErrReply(t, ErrMsgFileNotFound) + } + + xferSize := hlFile.Ffo.TransferSize(0) + + ft := cc.NewFileTransfer( + hotline.FileDownload, + cc.FileRoot(), + fileName, + filePath, + xferSize, + ) + + if resumeData != nil { + var frd hotline.FileResumeData + if err := frd.UnmarshalBinary(t.GetField(hotline.FieldFileResumeData).Data); err != nil { + cc.Logger.Error("download file: unmarshal resume data", "err", err) + return cc.NewErrReply(t, ErrMsgFileResumeData) + } + ft.FileResumeData = &frd + } + + // Optional field for when a client requests file preview + // Used only for TEXT, JPEG, GIFF, BMP or PICT files + // The value will always be 2 + if t.GetField(hotline.FieldFileTransferOptions).Data != nil { + ft.Options = t.GetField(hotline.FieldFileTransferOptions).Data + xferSize = hlFile.Ffo.FlatFileDataForkHeader.DataSize[:] + } + + res = append(res, cc.NewReply(t, + hotline.NewField(hotline.FieldRefNum, ft.RefNum[:]), + hotline.NewField(hotline.FieldWaitingCount, []byte{0x00, 0x00}), // TODO: Implement waiting count + hotline.NewField(hotline.FieldTransferSize, xferSize), + hotline.NewField(hotline.FieldFileSize, hlFile.Ffo.FlatFileDataForkHeader.DataSize[:]), + )) + + return res +} + +// HandleDownloadFolder downloads all files from the specified folder and its subfolders. +// +// Access: Download File (2) +// +// Fields used in the request: +// - 201 File name Required +// - 202 File path Optional +// +// Fields used in the reply: +// - 220 Folder item count Number of items in the folder +// - 107 Reference number Used later for transfer +// - 108 Transfer size Size of data to be downloaded +// - 116 Waiting count Queue position +func HandleDownloadFolder(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) { + if !cc.Authorize(hotline.AccessDownloadFolder) { + return cc.NewErrReply(t, ErrMsgNotAllowedDownloadFolders) + } + + fullFilePath, err := hotline.ReadPath(cc.FileRoot(), t.GetField(hotline.FieldFilePath).Data, t.GetField(hotline.FieldFileName).Data, cc.TextDecoder()) + if err != nil { + cc.Logger.Error("download folder: read path", "err", err) + return cc.NewErrReply(t, ErrMsgFileNotFound) + } + + transferSize, err := hotline.CalcTotalSize(fullFilePath) + if err != nil { + cc.Logger.Error("download folder: calc total size", "err", err) + return cc.NewErrReply(t, ErrMsgDownloadFolder) + } + itemCount, err := hotline.CalcItemCount(fullFilePath) + if err != nil { + cc.Logger.Error("download folder: calc item count", "err", err) + return cc.NewErrReply(t, ErrMsgDownloadFolder) + } + + fileTransfer := cc.NewFileTransfer(hotline.FolderDownload, cc.FileRoot(), t.GetField(hotline.FieldFileName).Data, t.GetField(hotline.FieldFilePath).Data, transferSize) + + var fp hotline.FilePath + _, err = fp.Write(t.GetField(hotline.FieldFilePath).Data) + if err != nil { + cc.Logger.Error("download folder: parse file path", "err", err) + return cc.NewErrReply(t, ErrMsgDownloadFolder) + } + + res = append(res, cc.NewReply(t, + hotline.NewField(hotline.FieldRefNum, fileTransfer.RefNum[:]), + hotline.NewField(hotline.FieldTransferSize, transferSize), + hotline.NewField(hotline.FieldFolderItemCount, itemCount), + hotline.NewField(hotline.FieldWaitingCount, []byte{0x00, 0x00}), // TODO: Implement waiting count + )) + return res +} + +// HandleUploadFolder uploads all files from a local folder and its subfolders to the server. +// +// Access: Upload File (1) +// +// Fields used in the request: +// - 201 File name Required +// - 202 File path Optional +// - 108 Transfer size Total size of all items in the folder +// - 220 Folder item count Number of items in the folder +// - 204 File transfer options Optional - Currently set to 1 +// +// Fields used in the reply: +// - 107 Reference number Used later for transfer +func HandleUploadFolder(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) { + if !cc.Authorize(hotline.AccessUploadFolder) { + return cc.NewErrReply(t, ErrMsgNotAllowedUploadFolders) + } + + var fp hotline.FilePath + if t.GetField(hotline.FieldFilePath).Data != nil { + if _, err := fp.Write(t.GetField(hotline.FieldFilePath).Data); err != nil { + cc.Logger.Error("upload folder: parse file path", "err", err) + return cc.NewErrReply(t, ErrMsgUploadFolder) + } + } + + // Handle special cases for Upload and Drop Box folders + if !cc.Authorize(hotline.AccessUploadAnywhere) { + if !fp.IsUploadDir() && !fp.IsDropbox() { + return cc.NewErrReply(t, fmt.Sprintf(ErrMsgUploadRestrictedTemplate, "folder", string(t.GetField(hotline.FieldFileName).Data))) + } + } + + fileTransfer := cc.NewFileTransfer(hotline.FolderUpload, + cc.FileRoot(), + t.GetField(hotline.FieldFileName).Data, + t.GetField(hotline.FieldFilePath).Data, + t.GetField(hotline.FieldTransferSize).Data, + ) + + fileTransfer.FolderItemCount = t.GetField(hotline.FieldFolderItemCount).Data + + return append(res, cc.NewReply(t, hotline.NewField(hotline.FieldRefNum, fileTransfer.RefNum[:]))) +} + +// HandleUploadFile uploads a file to the specified path on the server. +// +// Access: Upload File (1) +// +// Fields used in the request: +// - 201 File name Required +// - 202 File path Optional +// - 204 File transfer options Optional - Used to resume download, value 2 +// - 108 File transfer size Optional - Used if download is not resumed +// +// Fields used in the reply: +// - 203 File resume data Optional - Used only to resume download +// - 107 Reference number Transfer reference +func HandleUploadFile(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) { + if !cc.Authorize(hotline.AccessUploadFile) { + return cc.NewErrReply(t, ErrMsgNotAllowedUploadFiles) + } + + fileName := t.GetField(hotline.FieldFileName).Data + filePath := t.GetField(hotline.FieldFilePath).Data + transferOptions := t.GetField(hotline.FieldFileTransferOptions).Data + transferSize := t.GetField(hotline.FieldTransferSize).Data // not sent for resume + + var fp hotline.FilePath + if filePath != nil { + if _, err := fp.Write(filePath); err != nil { + cc.Logger.Error("upload file: parse file path", "err", err) + return cc.NewErrReply(t, ErrMsgUploadFile) + } + } + + // Handle special cases for Upload and Drop Box folders + if !cc.Authorize(hotline.AccessUploadAnywhere) { + if !fp.IsUploadDir() && !fp.IsDropbox() { + return cc.NewErrReply(t, fmt.Sprintf(ErrMsgUploadRestrictedTemplate, "file", string(fileName))) + } + } + fullFilePath, err := hotline.ReadPath(cc.FileRoot(), filePath, fileName, cc.TextDecoder()) + if err != nil { + cc.Logger.Error("upload file: read path", "err", err) + return cc.NewErrReply(t, ErrMsgUploadFile) + } + + if _, err := cc.Server.FS.Stat(fullFilePath); err == nil { + return cc.NewErrReply(t, fmt.Sprintf(ErrMsgFileUploadConflictTemplate, string(fileName))) + } + + ft := cc.NewFileTransfer(hotline.FileUpload, cc.FileRoot(), fileName, filePath, transferSize) + + replyT := cc.NewReply(t, hotline.NewField(hotline.FieldRefNum, ft.RefNum[:])) + + // client has requested to resume a partially transferred file + if transferOptions != nil { + // If there is no partial file to resume from, fall back to a normal upload + // reply (with the reference number already set) rather than discarding it. + if fileInfo, err := cc.Server.FS.Stat(fullFilePath + hotline.IncompleteFileSuffix); err != nil { + cc.Logger.Info("upload file: no partial file to resume, starting fresh upload", "err", err) + } else { + offset := make([]byte, 4) + binary.BigEndian.PutUint32(offset, uint32(fileInfo.Size())) + + fileResumeData := hotline.NewFileResumeData([]hotline.ForkInfoList{ + *hotline.NewForkInfoList(offset), + }) + + b, _ := fileResumeData.BinaryMarshal() + + ft.TransferSize = offset + + replyT.Fields = append(replyT.Fields, hotline.NewField(hotline.FieldFileResumeData, b)) + } + } + + res = append(res, replyT) + return res +} + +// HandleDownloadBanner requests a new banner from the server. +// +// Fields used in the request: None +// +// Fields used in the reply: +// - 107 Reference number Used later for transfer +// - 108 Transfer size Size of data to be downloaded +func HandleDownloadBanner(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) { + ft := cc.NewFileTransfer(hotline.BannerDownload, "", []byte{}, []byte{}, make([]byte, 4)) + binary.BigEndian.PutUint32(ft.TransferSize, uint32(len(cc.Server.Banner))) + + return append(res, cc.NewReply(t, + hotline.NewField(hotline.FieldRefNum, ft.RefNum[:]), + hotline.NewField(hotline.FieldTransferSize, ft.TransferSize), + )) +} diff --git a/internal/mobius/handlers_transfers_test.go b/internal/mobius/handlers_transfers_test.go new file mode 100644 index 0000000..90eea2d --- /dev/null +++ b/internal/mobius/handlers_transfers_test.go @@ -0,0 +1,557 @@ +package mobius + +import ( + "encoding/binary" + "os" + "path/filepath" + "testing" + + "github.com/jhalter/mobius/hotline" + "github.com/stretchr/testify/assert" + "golang.org/x/text/encoding/charmap" +) + +func TestHandleUploadFile(t *testing.T) { + type args struct { + cc *hotline.ClientConn + t hotline.Transaction + } + tests := []struct { + name string + args args + wantRes []hotline.Transaction + }{ + { + name: "when request is valid and user has Upload Anywhere permission", + args: args{ + cc: &hotline.ClientConn{ + Server: &hotline.Server{ + TextDecoder: charmap.Macintosh.NewDecoder(), + TextEncoder: charmap.Macintosh.NewEncoder(), + FS: &hotline.OSFileStore{}, + FileTransferMgr: hotline.NewMemFileTransferMgr(), + Config: hotline.Config{ + FileRoot: func() string { path, _ := os.Getwd(); return path + "/test/config/Files" }(), + }}, + ClientFileTransferMgr: hotline.NewClientFileTransferMgr(), + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + bits.Set(hotline.AccessUploadFile) + bits.Set(hotline.AccessUploadAnywhere) + return bits + }(), + }, + }, + t: hotline.NewTransaction( + hotline.TranUploadFile, [2]byte{0, 1}, + hotline.NewField(hotline.FieldFileName, []byte("testFile")), + hotline.NewField(hotline.FieldFilePath, []byte{ + 0x00, 0x01, + 0x00, 0x00, + 0x03, + 0x2e, 0x2e, 0x2f, + }), + ), + }, + wantRes: []hotline.Transaction{ + { + IsReply: 0x01, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldRefNum, []byte{0x52, 0xfd, 0xfc, 0x07}), // rand.Seed(1) + }, + }, + }, + }, + { + name: "when user does not have required access", + args: args{ + cc: &hotline.ClientConn{ + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + return bits + }(), + }, + }, + t: hotline.NewTransaction( + hotline.TranUploadFile, [2]byte{0, 1}, + hotline.NewField(hotline.FieldFileName, []byte("testFile")), + hotline.NewField(hotline.FieldFilePath, []byte{ + 0x00, 0x01, + 0x00, 0x00, + 0x03, + 0x2e, 0x2e, 0x2f, + }), + ), + }, + wantRes: []hotline.Transaction{ + { + IsReply: 0x01, + ErrorCode: [4]byte{0, 0, 0, 1}, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldError, []byte("You are not allowed to upload files.")), // rand.Seed(1) + }, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotRes := HandleUploadFile(tt.args.cc, &tt.args.t) + TranAssertEqual(t, tt.wantRes, gotRes) + }) + } +} + +// When a client requests to resume an upload but no partial (.incomplete) file +// exists, the handler should fall back to a normal upload reply (reference number, +// no resume-data field) rather than discarding the reply and returning nothing. +func TestHandleUploadFile_resumeFallbackWhenNoPartialFile(t *testing.T) { + cc := &hotline.ClientConn{ + Logger: NewTestLogger(), + Server: &hotline.Server{ + TextDecoder: charmap.Macintosh.NewDecoder(), + TextEncoder: charmap.Macintosh.NewEncoder(), + FS: &hotline.OSFileStore{}, + FileTransferMgr: hotline.NewMemFileTransferMgr(), + Config: hotline.Config{ + FileRoot: func() string { path, _ := os.Getwd(); return path + "/test/config/Files" }(), + }, + }, + ClientFileTransferMgr: hotline.NewClientFileTransferMgr(), + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + bits.Set(hotline.AccessUploadFile) + bits.Set(hotline.AccessUploadAnywhere) + return bits + }(), + }, + } + tr := hotline.NewTransaction( + hotline.TranUploadFile, [2]byte{0, 1}, + hotline.NewField(hotline.FieldFileName, []byte("doesNotExistYet")), + hotline.NewField(hotline.FieldFilePath, []byte{0x00, 0x01, 0x00, 0x00, 0x03, 0x2e, 0x2e, 0x2f}), + hotline.NewField(hotline.FieldFileTransferOptions, []byte{0x00, 0x02}), // request resume + ) + + gotRes := HandleUploadFile(cc, &tr) + + if len(gotRes) != 1 { + t.Fatalf("expected 1 reply transaction, got %d", len(gotRes)) + } + if gotRes[0].ErrorCode != [4]byte{} { + t.Errorf("expected a non-error reply, got error code %v", gotRes[0].ErrorCode) + } + if gotRes[0].GetField(hotline.FieldRefNum).Data == nil { + t.Errorf("expected a reference number field in the reply") + } + if gotRes[0].GetField(hotline.FieldFileResumeData).Data != nil { + t.Errorf("expected no resume data field when there is no partial file to resume") + } +} + +func TestHandleDownloadFile(t *testing.T) { + type args struct { + cc *hotline.ClientConn + t hotline.Transaction + } + tests := []struct { + name string + args args + wantRes []hotline.Transaction + }{ + { + name: "when user does not have required permission", + args: args{ + cc: &hotline.ClientConn{ + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + return bits + }(), + }, + Server: &hotline.Server{TextDecoder: charmap.Macintosh.NewDecoder(), TextEncoder: charmap.Macintosh.NewEncoder()}, + }, + t: hotline.NewTransaction(hotline.TranDownloadFile, [2]byte{0, 1}), + }, + wantRes: []hotline.Transaction{ + { + IsReply: 0x01, + ErrorCode: [4]byte{0, 0, 0, 1}, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldError, []byte("You are not allowed to download files.")), + }, + }, + }, + }, + { + name: "with a valid file", + args: args{ + cc: &hotline.ClientConn{ + ClientFileTransferMgr: hotline.NewClientFileTransferMgr(), + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + bits.Set(hotline.AccessDownloadFile) + return bits + }(), + }, + Server: &hotline.Server{ + TextDecoder: charmap.Macintosh.NewDecoder(), + TextEncoder: charmap.Macintosh.NewEncoder(), + FS: &hotline.OSFileStore{}, + FileTransferMgr: hotline.NewMemFileTransferMgr(), + Config: hotline.Config{ + FileRoot: func() string { path, _ := os.Getwd(); return path + "/test/config/Files" }(), + }, + }, + }, + t: hotline.NewTransaction( + hotline.TranDownloadFile, + [2]byte{0, 1}, + hotline.NewField(hotline.FieldFileName, []byte("testfile.txt")), + hotline.NewField(hotline.FieldFilePath, []byte{0x0, 0x00}), + ), + }, + wantRes: []hotline.Transaction{ + { + IsReply: 0x01, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldRefNum, []byte{0x52, 0xfd, 0xfc, 0x07}), + hotline.NewField(hotline.FieldWaitingCount, []byte{0x00, 0x00}), + hotline.NewField(hotline.FieldTransferSize, []byte{0x00, 0x00, 0x00, 0xa5}), + hotline.NewField(hotline.FieldFileSize, []byte{0x00, 0x00, 0x00, 0x17}), + }, + }, + }, + }, + { + name: "when client requests to resume 1k test file at offset 256", + args: args{ + cc: &hotline.ClientConn{ + ClientFileTransferMgr: hotline.NewClientFileTransferMgr(), + Account: &hotline.Account{ + Access: func() hotline.AccessBitmap { + var bits hotline.AccessBitmap + bits.Set(hotline.AccessDownloadFile) + return bits + }(), + }, + Server: &hotline.Server{ + TextDecoder: charmap.Macintosh.NewDecoder(), + TextEncoder: charmap.Macintosh.NewEncoder(), + FS: &hotline.OSFileStore{}, + + // FS: func() *hotline.MockFileStore { + // path, _ := os.Getwd() + // testFile, err := os.Open(path + "/test/config/Files/testfile-1k") + // if err != nil { + // panic(err) + // } + // + // mfi := &hotline.MockFileInfo{} + // mfi.On("Mode").Return(fs.FileMode(0)) + // mfs := &MockFileStore{} + // mfs.On("Stat", "/fakeRoot/Files/testfile.txt").Return(mfi, nil) + // mfs.On("Open", "/fakeRoot/Files/testfile.txt").Return(testFile, nil) + // mfs.On("Stat", "/fakeRoot/Files/.info_testfile.txt").Return(nil, errors.New("no")) + // mfs.On("Stat", "/fakeRoot/Files/.rsrc_testfile.txt").Return(nil, errors.New("no")) + // + // return mfs + // }(), + FileTransferMgr: hotline.NewMemFileTransferMgr(), + Config: hotline.Config{ + FileRoot: func() string { path, _ := os.Getwd(); return path + "/test/config/Files" }(), + }, + //Accounts: map[string]*Account{}, + }, + }, + t: hotline.NewTransaction( + hotline.TranDownloadFile, + [2]byte{0, 1}, + hotline.NewField(hotline.FieldFileName, []byte("testfile-1k")), + hotline.NewField(hotline.FieldFilePath, []byte{0x00, 0x00}), + hotline.NewField( + hotline.FieldFileResumeData, + func() []byte { + frd := hotline.FileResumeData{ + ForkCount: [2]byte{0, 2}, + ForkInfoList: []hotline.ForkInfoList{ + { + Fork: hotline.ForkTypeDATA, + DataSize: [4]byte{0, 0, 0x01, 0x00}, // request offset 256 + }, + { + Fork: hotline.ForkTypeMACR, + DataSize: [4]byte{0, 0, 0, 0}, + }, + }, + } + b, _ := frd.BinaryMarshal() + return b + }(), + ), + ), + }, + wantRes: []hotline.Transaction{ + { + IsReply: 0x01, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldRefNum, []byte{0x52, 0xfd, 0xfc, 0x07}), + hotline.NewField(hotline.FieldWaitingCount, []byte{0x00, 0x00}), + hotline.NewField(hotline.FieldTransferSize, []byte{0x00, 0x00, 0x03, 0x8d}), + hotline.NewField(hotline.FieldFileSize, []byte{0x00, 0x00, 0x03, 0x00}), + }, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotRes := HandleDownloadFile(tt.args.cc, &tt.args.t) + TranAssertEqual(t, tt.wantRes, gotRes) + }) + } +} + +func TestHandleDownloadBanner(t *testing.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}) + + 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 TestHandleUploadFolder(t *testing.T) { + type args struct { + cc *hotline.ClientConn + t hotline.Transaction + } + tests := []struct { + name string + args args + wantRes []hotline.Transaction + }{ + { + name: "when user does not have required access", + args: args{ + cc: &hotline.ClientConn{ + Account: &hotline.Account{ + Access: hotline.AccessBitmap{}, + }, + }, + t: hotline.NewTransaction( + hotline.TranUploadFldr, [2]byte{0, 1}, + hotline.NewField(hotline.FieldFileName, []byte("testFile")), + hotline.NewField(hotline.FieldFilePath, []byte{ + 0x00, 0x01, + 0x00, 0x00, + 0x03, + 0x2e, 0x2e, 0x2f, + }), + ), + }, + wantRes: []hotline.Transaction{ + { + IsReply: 0x01, + ErrorCode: [4]byte{0, 0, 0, 1}, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldError, []byte("You are not allowed to upload folders.")), + }, + }, + }, + }, + { + 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) { + TranAssertEqual(t, tt.wantRes, HandleUploadFolder(tt.args.cc, &tt.args.t)) + }) + } +} + +func TestHandleDownloadFolder(t *testing.T) { + type args struct { + cc *hotline.ClientConn + t hotline.Transaction + } + tests := []struct { + name string + args args + wantRes []hotline.Transaction + }{ + { + name: "when user does not have required access", + args: args{ + cc: &hotline.ClientConn{ + Account: &hotline.Account{ + Access: hotline.AccessBitmap{}, + }, + }, + t: hotline.NewTransaction( + hotline.TranDownloadFldr, [2]byte{0, 1}, + hotline.NewField(hotline.FieldFileName, []byte("testFile")), + hotline.NewField(hotline.FieldFilePath, []byte{ + 0x00, 0x01, + 0x00, 0x00, + 0x03, + 0x2e, 0x2e, 0x2f, + }), + ), + }, + wantRes: []hotline.Transaction{ + { + IsReply: 0x01, + ErrorCode: [4]byte{0, 0, 0, 1}, + Fields: []hotline.Field{ + hotline.NewField(hotline.FieldError, []byte("You are not allowed to download folders.")), + }, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + TranAssertEqual(t, tt.wantRes, HandleDownloadFolder(tt.args.cc, &tt.args.t)) + }) + } +} + +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)) +} diff --git a/internal/mobius/helpers_test.go b/internal/mobius/helpers_test.go new file mode 100644 index 0000000..7d198be --- /dev/null +++ b/internal/mobius/helpers_test.go @@ -0,0 +1,101 @@ +package mobius + +import ( + "cmp" + "encoding/binary" + "io" + "log/slog" + "os" + "slices" + "testing" + + "github.com/jhalter/mobius/hotline" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +type mockReadWriteSeeker struct { + mock.Mock +} + +func (m *mockReadWriteSeeker) Read(p []byte) (int, error) { + args := m.Called(p) + + return args.Int(0), args.Error(1) +} + +func (m *mockReadWriteSeeker) Write(p []byte) (int, error) { + args := m.Called(p) + + return args.Int(0), args.Error(1) +} + +func (m *mockReadWriteSeeker) Seek(offset int64, whence int) (int64, error) { + args := m.Called(offset, whence) + + 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)) +} + +var tranSortFunc = func(a, b hotline.Transaction) int { + return cmp.Compare( + binary.BigEndian.Uint16(a.ClientID[:]), + binary.BigEndian.Uint16(b.ClientID[:]), + ) +} + +// TranAssertEqual compares equality of transactions slices after stripping out the random transaction Type +func TranAssertEqual(t *testing.T, tran1, tran2 []hotline.Transaction) bool { + var newT1 []hotline.Transaction + var newT2 []hotline.Transaction + + for _, trans := range tran1 { + trans.ID = [4]byte{0, 0, 0, 0} + var fs []hotline.Field + for _, field := range trans.Fields { + if field.Type == hotline.FieldRefNum { // FieldRefNum + continue + } + if field.Type == hotline.FieldChatID { // FieldChatID + continue + } + + fs = append(fs, field) + } + trans.Fields = fs + newT1 = append(newT1, trans) + } + + for _, trans := range tran2 { + trans.ID = [4]byte{0, 0, 0, 0} + var fs []hotline.Field + for _, field := range trans.Fields { + if field.Type == hotline.FieldRefNum { // FieldRefNum + continue + } + if field.Type == hotline.FieldChatID { // FieldChatID + continue + } + + fs = append(fs, field) + } + trans.Fields = fs + newT2 = append(newT2, trans) + } + + slices.SortFunc(newT1, tranSortFunc) + slices.SortFunc(newT2, tranSortFunc) + + return assert.Equal(t, newT1, newT2) +} diff --git a/internal/mobius/transaction_handlers.go b/internal/mobius/transaction_handlers.go index 96b4f69..f740cca 100644 --- a/internal/mobius/transaction_handlers.go +++ b/internal/mobius/transaction_handlers.go @@ -1,120 +1,9 @@ package mobius import ( - "bufio" - "bytes" - "context" - "encoding/binary" - "fmt" - "io" - "math/big" - "os" - "path" - "strings" - "time" - "github.com/jhalter/mobius/hotline" ) -// Public error message constants for reuse by other packages -const ( - // Authorization error messages - ErrMsgNotAllowedParticipateChat = "You are not allowed to participate in chat." - ErrMsgNotAllowedSendPrivateMsg = "You are not allowed to send private messages." - ErrMsgNotAllowedReadNews = "You are not allowed to read news." - ErrMsgNotAllowedPostNews = "You are not allowed to post news." - ErrMsgNotAllowedCreateAccounts = "You are not allowed to create new accounts." - ErrMsgNotAllowedViewAccounts = "You are not allowed to view accounts." - ErrMsgNotAllowedModifyAccounts = "You are not allowed to modify accounts." - ErrMsgNotAllowedDeleteAccounts = "You are not allowed to delete accounts." - ErrMsgNotAllowedRequestPrivateChat = "You are not allowed to request private chat." - ErrMsgNotAllowedCreateNewsCategories = "You are not allowed to create news categories." - ErrMsgNotAllowedDeleteNewsArticles = "You are not allowed to delete news articles." - ErrMsgNotAllowedSetCommentsFiles = "You are not allowed to set comments for files." - ErrMsgNotAllowedSetCommentsFolders = "You are not allowed to set comments for folders." - ErrMsgNotAllowedRenameFiles = "You are not allowed to rename files." - ErrMsgNotAllowedRenameFolders = "You are not allowed to rename folders." - ErrMsgNotAllowedDeleteFiles = "You are not allowed to delete files." - ErrMsgNotAllowedDeleteFolders = "You are not allowed to delete folders." - ErrMsgNotAllowedMoveFiles = "You are not allowed to move files." - ErrMsgNotAllowedMoveFolders = "You are not allowed to move folders." - ErrMsgNotAllowedCreateFolders = "You are not allowed to create folders." - ErrMsgNotAllowedSendBroadcast = "You are not allowed to send broadcast messages." - ErrMsgNotAllowedGetClientInfo = "You are not allowed to get client info." - ErrMsgNotAllowedDisconnectUsers = "You are not allowed to disconnect users." - ErrMsgNotAllowedCreateNewsfolders = "You are not allowed to create news folders." - ErrMsgNotAllowedDeleteNewsCategories = "You are not allowed to delete news categories." - ErrMsgNotAllowedDeleteNewsFolders = "You are not allowed to delete news folders." - ErrMsgNotAllowedPostNewsArticles = "You are not allowed to post news articles." - ErrMsgNotAllowedDownloadFiles = "You are not allowed to download files." - ErrMsgNotAllowedDownloadFolders = "You are not allowed to download folders." - ErrMsgNotAllowedUploadFiles = "You are not allowed to upload files." - ErrMsgNotAllowedUploadFolders = "You are not allowed to upload folders." - ErrMsgNotAllowedViewDropBoxes = "You are not allowed to view drop boxes." - ErrMsgNotAllowedMakeAliases = "You are not allowed to make aliases." - - // Account error messages - ErrMsgAccountDeleted = "You are logged in with an account which was deleted." - ErrMsgAccountExists = "Cannot create account because there is already an account with that login." - ErrMsgAccountMoreAccess = "Cannot create account with more access than yourself." - ErrMsgAccountNotExist = "Account does not exist." - - // Account error templates (for dynamic content) - ErrMsgAccountExistsTemplate = "Cannot create account %s because there is already an account with that login." - - // File operation error templates - ErrMsgCannotRenameFileNotFound = "Cannot rename file %s because it does not exist or cannot be found." - ErrMsgCannotRenameFolderNotFound = "Cannot rename folder %s because it does not exist or cannot be found." - ErrMsgCannotDeleteFileNotFound = "Cannot delete file %s because it does not exist or cannot be found." - - // File operation error templates (for dynamic content) - ErrMsgFolderCreateConflictTemplate = "Cannot create folder \"%s\" because there is already a file or folder with that Name." - ErrMsgFolderCreateErrorTemplate = "Cannot create folder \"%s\" because an error occurred." - - // Upload restriction templates (these need dynamic content) - ErrMsgUploadRestrictedTemplate = "Cannot accept upload of the %s \"%v\" because you are only allowed to upload to the \"Uploads\" folder." - ErrMsgFileUploadConflictTemplate = "Cannot accept upload because there is already a file named \"%v\". Try choosing a different Name." - - // Chat/messaging templates (these need dynamic content) - ErrMsgDoesNotAcceptTemplate = "%s does not accept %s." - - // Ban messages - ErrMsgTemporaryBan = "You are temporarily banned on this server" - ErrMsgPermanentBan = "You are permanently banned on this server" - - // General error messages - ErrMsgFileNotFound = "File not found." - ErrMsgGetFileInfo = "Error getting file information." - ErrMsgSetFileInfo = "Error setting file information." - ErrMsgRenameFile = "Error renaming file." - ErrMsgRenameFolder = "Error renaming folder." - ErrMsgDeleteFile = "Error deleting file." - ErrMsgMoveFile = "Error moving file." - ErrMsgCreateFolder = "Error creating folder." - ErrMsgDownloadFolder = "Error downloading folder." - ErrMsgUploadFile = "Error uploading file." - ErrMsgUploadFolder = "Error uploading folder." - ErrMsgFileResumeData = "Invalid file resume data." - ErrMsgAccountNotFound = "Account not found." - ErrMsgUserNotFound = "User not found." - ErrMsgInvalidUserID = "Invalid user ID." - ErrMsgInvalidChatID = "Invalid chat ID." - ErrMsgCreateAlias = "Error creating alias" - ErrMsgUpdateAccount = "Error updating account." - ErrMsgDeleteAccount = "Error deleting account." - ErrMsgGetUserList = "Error getting user list." - ErrMsgJoinChat = "Error joining chat." - ErrMsgReadNewsCategories = "Error reading news categories." - ErrMsgReadNewsArticles = "Error reading news articles." - ErrMsgCreateNewsCategory = "Error creating news category." - ErrMsgCreateNewsFolder = "Error creating news folder." - ErrMsgDeleteNewsArticle = "Error deleting news article." - ErrMsgDeleteNewsItem = "Error deleting news item." - ErrMsgPostNewsArticle = "Error posting news article." - ErrMsgPostNews = "Error posting news." - ErrMsgReadMessageBoard = "Error reading message board." -) - // Assign functions to handle specific Hotline transaction types func RegisterHandlers(srv *hotline.Server) { srv.HandleFunc(hotline.TranAgreed, HandleTranAgreed) @@ -161,2189 +50,3 @@ func RegisterHandlers(srv *hotline.Server) { srv.HandleFunc(hotline.TranUserBroadcast, HandleUserBroadcast) srv.HandleFunc(hotline.TranDownloadBanner, HandleDownloadBanner) } - -// HandleChatSend sends a chat message to the chat. -// -// Access: Send Chat (10) -// -// Fields used in the request: -// - 109 Chat options Optional - Normal (0) or alternate (1) chat message -// - 114 Chat ID Optional -// - 101 Data Chat message string -// -// Reply is not expected. -func HandleChatSend(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) { - if !cc.Authorize(hotline.AccessSendChat) { - return cc.NewErrReply(t, ErrMsgNotAllowedParticipateChat) - } - - // Truncate long usernames - // %13.13s: This means a string that is right-aligned in a field of 13 characters. - // If the string is longer than 13 characters, it will be truncated to 13 characters. - formattedMsg := fmt.Sprintf("\r%13.13s: %s", cc.UserName, t.GetField(hotline.FieldData).Data) - - // By holding the option key, Hotline chat allows users to send /me formatted messages like: - // *** Halcyon does stuff - // This is indicated by the presence of the optional field FieldChatOptions set to a value of 1. - // Most clients do not send this option for normal chat messages. - if t.GetField(hotline.FieldChatOptions).Data != nil && bytes.Equal(t.GetField(hotline.FieldChatOptions).Data, []byte{0, 1}) { - formattedMsg = fmt.Sprintf("\r*** %s %s", cc.UserName, t.GetField(hotline.FieldData).Data) - } - - // Truncate the message to the limit. This does not handle the edge case of a string ending on multibyte character. - formattedMsg = formattedMsg[:min(len(formattedMsg), hotline.LimitChatMsg)] - - // The ChatID field is used to identify messages as belonging to a private chat. - // All clients *except* Frogblast omit this field for public chat, but Frogblast sends a value of 00 00 00 00. - chatID := t.GetField(hotline.FieldChatID).Data - if chatID != nil && !bytes.Equal([]byte{0, 0, 0, 0}, chatID) { - privChatID, ok := hotline.ChatIDFromBytes(chatID) - if !ok { - return cc.NewErrReply(t, ErrMsgInvalidChatID) - } - - // send the message to all connected clients of the private chat - for _, c := range cc.Server.ChatMgr.Members(privChatID) { - res = append(res, hotline.NewTransaction( - hotline.TranChatMsg, - c.ID, - hotline.NewField(hotline.FieldChatID, chatID), - hotline.NewField(hotline.FieldData, []byte(formattedMsg)), - )) - } - return res - } - - //cc.Server.mux.Lock() - for _, c := range cc.Server.ClientMgr.List() { - if c == nil || cc.Account == nil { - continue - } - // Skip clients that do not have the read chat permission. - if c.Authorize(hotline.AccessReadChat) { - res = append(res, hotline.NewTransaction(hotline.TranChatMsg, c.ID, hotline.NewField(hotline.FieldData, []byte(formattedMsg)))) - } - } - //cc.Server.mux.Unlock() - - return res -} - -// HandleSendInstantMsg sends an instant message to a user on the current server. -// -// Fields used in the request: -// - 103 User ID Target user -// - 113 Options myOpt_UserMessage (1), myOpt_RefuseMessage (2), myOpt_RefuseChat (3), myOpt_AutomaticResponse (4) -// - 101 Data Optional -// - 214 Quoting message Optional -// -// Fields used in the reply: None -func HandleSendInstantMsg(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) { - if !cc.Authorize(hotline.AccessSendPrivMsg) { - return cc.NewErrReply(t, ErrMsgNotAllowedSendPrivateMsg) - } - - msg := t.GetField(hotline.FieldData) - userID := t.GetField(hotline.FieldUserID) - - targetID, ok := hotline.ClientIDFromBytes(userID.Data) - if !ok { - return cc.NewErrReply(t, ErrMsgInvalidUserID) - } - - reply := hotline.NewTransaction( - hotline.TranServerMsg, - targetID, - hotline.NewField(hotline.FieldData, msg.Data), - hotline.NewField(hotline.FieldUserName, cc.UserName), - hotline.NewField(hotline.FieldUserID, cc.ID[:]), - hotline.NewField(hotline.FieldOptions, []byte{0, 1}), - ) - - // Later versions of Hotline include the original message in the FieldQuotingMsg field so - // the receiving client can display both the received message and what it is in reply to - if t.GetField(hotline.FieldQuotingMsg).Data != nil { - reply.Fields = append(reply.Fields, hotline.NewField(hotline.FieldQuotingMsg, t.GetField(hotline.FieldQuotingMsg).Data)) - } - - otherClient := cc.Server.ClientMgr.Get(targetID) - if otherClient == nil { - // Target user is no longer connected. The protocol defines no reply for - // this transaction, so there is nothing to send back. - return res - } - - // Check if target user has "Refuse private messages" flag - if otherClient.Flags.IsSet(hotline.UserFlagRefusePM) { - res = append(res, - hotline.NewTransaction( - hotline.TranServerMsg, - cc.ID, - hotline.NewField(hotline.FieldData, []byte(fmt.Sprintf(ErrMsgDoesNotAcceptTemplate, string(otherClient.UserName), "private messages"))), - hotline.NewField(hotline.FieldUserName, otherClient.UserName), - hotline.NewField(hotline.FieldUserID, otherClient.ID[:]), - hotline.NewField(hotline.FieldOptions, []byte{0, 2}), - ), - ) - } else { - res = append(res, reply) - } - - // Respond with auto reply if other client has it enabled - if len(otherClient.AutoReply) > 0 { - res = append(res, - hotline.NewTransaction( - hotline.TranServerMsg, - cc.ID, - hotline.NewField(hotline.FieldData, otherClient.AutoReply), - hotline.NewField(hotline.FieldUserName, otherClient.UserName), - hotline.NewField(hotline.FieldUserID, otherClient.ID[:]), - hotline.NewField(hotline.FieldOptions, []byte{0, 1}), - ), - ) - } - - return append(res, cc.NewReply(t)) -} - -var fileTypeFLDR = [4]byte{0x66, 0x6c, 0x64, 0x72} - -// HandleGetFileInfo requests file information from the server. -// -// Fields used in the request: -// - 201 File name Required -// - 202 File path Optional -// -// Fields used in the reply: -// - 201 File name File name -// - 205 File type string Friendly file type description -// - 206 File creator string Friendly creator description -// - 210 File comment Comment string -// - 213 File type File type signature -// - 208 File create date Creation date -// - 209 File modify date Modification date -// - 207 File size File size -func HandleGetFileInfo(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) { - fileName := t.GetField(hotline.FieldFileName).Data - filePath := t.GetField(hotline.FieldFilePath).Data - - fullFilePath, err := hotline.ReadPath(cc.FileRoot(), filePath, fileName, cc.TextDecoder()) - if err != nil { - cc.Logger.Error("get file info: read path", "err", err) - return cc.NewErrReply(t, ErrMsgFileNotFound) - } - - fw, err := hotline.NewFile(cc.Server.FS, fullFilePath, 0) - if err != nil { - cc.Logger.Error("get file info: open file", "err", err) - return cc.NewErrReply(t, ErrMsgFileNotFound) - } - - encodedName, err := cc.TextEncoder().String(fw.Name) - if err != nil { - cc.Logger.Error("get file info: encode name", "err", err) - return cc.NewErrReply(t, ErrMsgGetFileInfo) - } - - fields := []hotline.Field{ - hotline.NewField(hotline.FieldFileName, []byte(encodedName)), - hotline.NewField(hotline.FieldFileTypeString, fw.Ffo.FlatFileInformationFork.FriendlyType()), - hotline.NewField(hotline.FieldFileCreatorString, fw.Ffo.FlatFileInformationFork.FriendlyCreator()), - hotline.NewField(hotline.FieldFileType, fw.Ffo.FlatFileInformationFork.TypeSignature[:]), - hotline.NewField(hotline.FieldFileCreateDate, fw.Ffo.FlatFileInformationFork.CreateDate[:]), - hotline.NewField(hotline.FieldFileModifyDate, fw.Ffo.FlatFileInformationFork.ModifyDate[:]), - } - - // Include the optional FileComment field if there is a comment. - if len(fw.Ffo.FlatFileInformationFork.Comment) != 0 { - fields = append(fields, hotline.NewField(hotline.FieldFileComment, fw.Ffo.FlatFileInformationFork.Comment)) - } - - // Include the FileSize field for files. - if fw.Ffo.FlatFileInformationFork.TypeSignature != fileTypeFLDR { - fields = append(fields, hotline.NewField(hotline.FieldFileSize, fw.TotalSize())) - } - - return append(res, cc.NewReply(t, fields...)) -} - -// HandleSetFileInfo sets information for the specified file on the server. -// -// Access: Set File Comment (28) or Set Folder Comment (29) -// -// Fields used in the request: -// - 201 File name Required -// - 202 File path Optional -// - 211 File new name Optional -// - 210 File comment Optional -// -// Fields used in the reply: None -func HandleSetFileInfo(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) { - fileName := t.GetField(hotline.FieldFileName).Data - filePath := t.GetField(hotline.FieldFilePath).Data - - fullFilePath, err := hotline.ReadPath(cc.FileRoot(), filePath, fileName, cc.TextDecoder()) - if err != nil { - cc.Logger.Error("set file info: read path", "err", err) - return cc.NewErrReply(t, ErrMsgFileNotFound) - } - - fi, err := cc.Server.FS.Stat(fullFilePath) - if err != nil { - cc.Logger.Error("set file info: stat file", "err", err) - return cc.NewErrReply(t, ErrMsgFileNotFound) - } - - hlFile, err := hotline.NewFile(cc.Server.FS, fullFilePath, 0) - if err != nil { - cc.Logger.Error("set file info: open file", "err", err) - return cc.NewErrReply(t, ErrMsgFileNotFound) - } - if t.GetField(hotline.FieldFileComment).Data != nil { - switch mode := fi.Mode(); { - case mode.IsDir(): - if !cc.Authorize(hotline.AccessSetFolderComment) { - return cc.NewErrReply(t, ErrMsgNotAllowedSetCommentsFolders) - } - case mode.IsRegular(): - if !cc.Authorize(hotline.AccessSetFileComment) { - return cc.NewErrReply(t, ErrMsgNotAllowedSetCommentsFiles) - } - } - - if err := hlFile.Ffo.FlatFileInformationFork.SetComment(t.GetField(hotline.FieldFileComment).Data); err != nil { - cc.Logger.Error("set file info: set comment", "err", err) - return cc.NewErrReply(t, ErrMsgSetFileInfo) - } - w, err := hlFile.InfoForkWriter() - if err != nil { - cc.Logger.Error("set file info: open info fork writer", "err", err) - return cc.NewErrReply(t, ErrMsgSetFileInfo) - } - _, err = io.Copy(w, &hlFile.Ffo.FlatFileInformationFork) - if err != nil { - cc.Logger.Error("set file info: write info fork", "err", err) - return cc.NewErrReply(t, ErrMsgSetFileInfo) - } - } - - fullNewFilePath, err := hotline.ReadPath(cc.FileRoot(), filePath, t.GetField(hotline.FieldFileNewName).Data, cc.TextDecoder()) - if err != nil { - cc.Logger.Error("set file info: read new name path", "err", err) - return cc.NewErrReply(t, ErrMsgRenameFile) - } - - fileNewName := t.GetField(hotline.FieldFileNewName).Data - - if fileNewName != nil { - switch mode := fi.Mode(); { - case mode.IsDir(): - if !cc.Authorize(hotline.AccessRenameFolder) { - return cc.NewErrReply(t, ErrMsgNotAllowedRenameFolders) - } - err = os.Rename(fullFilePath, fullNewFilePath) - if os.IsNotExist(err) { - return cc.NewErrReply(t, fmt.Sprintf(ErrMsgCannotRenameFolderNotFound, string(fileName))) - } - if err != nil { - cc.Logger.Error("set file info: rename folder", "err", err) - return cc.NewErrReply(t, ErrMsgRenameFolder) - } - case mode.IsRegular(): - if !cc.Authorize(hotline.AccessRenameFile) { - return cc.NewErrReply(t, ErrMsgNotAllowedRenameFiles) - } - fileDir, err := hotline.ReadPath(cc.FileRoot(), filePath, []byte{}, cc.TextDecoder()) - if err != nil { - cc.Logger.Error("set file info: read file dir", "err", err) - return cc.NewErrReply(t, ErrMsgRenameFile) - } - hlFile.Name, err = cc.TextDecoder().String(string(fileNewName)) - if err != nil { - cc.Logger.Error("set file info: decode new name", "err", err) - return cc.NewErrReply(t, ErrMsgRenameFile) - } - - err = hlFile.Move(fileDir) - if os.IsNotExist(err) { - return cc.NewErrReply(t, fmt.Sprintf(ErrMsgCannotRenameFileNotFound, string(fileName))) - } - if err != nil { - cc.Logger.Error("set file info: rename file", "err", err) - return cc.NewErrReply(t, ErrMsgRenameFile) - } - } - } - - res = append(res, cc.NewReply(t)) - return res -} - -// HandleDeleteFile deletes the specified file from the server. -// -// Access: Delete File (0) or Delete Folder (6) -// -// Fields used in the request: -// - 201 File name Required -// - 202 File path Required -// -// Fields used in the reply: None -func HandleDeleteFile(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) { - fileName := t.GetField(hotline.FieldFileName).Data - filePath := t.GetField(hotline.FieldFilePath).Data - - fullFilePath, err := hotline.ReadPath(cc.FileRoot(), filePath, fileName, cc.TextDecoder()) - if err != nil { - cc.Logger.Error("delete file: read path", "err", err) - return cc.NewErrReply(t, ErrMsgFileNotFound) - } - - hlFile, err := hotline.NewFile(cc.Server.FS, fullFilePath, 0) - if err != nil { - cc.Logger.Error("delete file: open file", "err", err) - return cc.NewErrReply(t, ErrMsgFileNotFound) - } - - fi, err := hlFile.DataFile() - if err != nil { - return cc.NewErrReply(t, fmt.Sprintf(ErrMsgCannotDeleteFileNotFound, string(fileName))) - } - - switch mode := fi.Mode(); { - case mode.IsDir(): - if !cc.Authorize(hotline.AccessDeleteFolder) { - return cc.NewErrReply(t, ErrMsgNotAllowedDeleteFolders) - } - case mode.IsRegular(): - if !cc.Authorize(hotline.AccessDeleteFile) { - return cc.NewErrReply(t, ErrMsgNotAllowedDeleteFiles) - } - } - - if err := hlFile.Delete(); err != nil { - cc.Logger.Error("delete file: delete", "err", err) - return cc.NewErrReply(t, ErrMsgDeleteFile) - } - - res = append(res, cc.NewReply(t)) - return res -} - -// HandleMoveFile moves a file from one folder to another on the same server. -// -// Fields used in the request: -// - 201 File name Required -// - 202 File path Required -// - 212 File new path Required - Destination path -// -// Fields used in the reply: None -func HandleMoveFile(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) { - fileName := string(t.GetField(hotline.FieldFileName).Data) - - filePath, err := hotline.ReadPath(cc.FileRoot(), t.GetField(hotline.FieldFilePath).Data, t.GetField(hotline.FieldFileName).Data, cc.TextDecoder()) - if err != nil { - cc.Logger.Error("move file: read source path", "err", err) - return cc.NewErrReply(t, ErrMsgFileNotFound) - } - - fileNewPath, err := hotline.ReadPath(cc.FileRoot(), t.GetField(hotline.FieldFileNewPath).Data, nil, cc.TextDecoder()) - if err != nil { - cc.Logger.Error("move file: read destination path", "err", err) - return cc.NewErrReply(t, ErrMsgFileNotFound) - } - - cc.Logger.Info("Move file", "src", filePath+"/"+fileName, "dst", fileNewPath+"/"+fileName) - - hlFile, err := hotline.NewFile(cc.Server.FS, filePath, 0) - if err != nil { - cc.Logger.Error("move file: open file", "err", err) - return cc.NewErrReply(t, ErrMsgFileNotFound) - } - - fi, err := hlFile.DataFile() - if err != nil { - return cc.NewErrReply(t, fmt.Sprintf(ErrMsgCannotDeleteFileNotFound, fileName)) - } - switch mode := fi.Mode(); { - case mode.IsDir(): - if !cc.Authorize(hotline.AccessMoveFolder) { - return cc.NewErrReply(t, ErrMsgNotAllowedMoveFolders) - } - case mode.IsRegular(): - if !cc.Authorize(hotline.AccessMoveFile) { - return cc.NewErrReply(t, ErrMsgNotAllowedMoveFiles) - } - } - if err := hlFile.Move(fileNewPath); err != nil { - cc.Logger.Error("move file: move", "err", err) - return cc.NewErrReply(t, ErrMsgMoveFile) - } - // TODO: handle other possible errors; e.g. file delete fails due to permission issue - - res = append(res, cc.NewReply(t)) - return res -} - -// HandleNewFolder creates a new folder on the server. -// -// Access: Create Folder (5) -// -// Fields used in the request: -// - 201 File name Required -// - 202 File path Optional -// -// Fields used in the reply: None -func HandleNewFolder(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) { - if !cc.Authorize(hotline.AccessCreateFolder) { - return cc.NewErrReply(t, ErrMsgNotAllowedCreateFolders) - } - folderName := string(t.GetField(hotline.FieldFileName).Data) - - folderName = path.Join("/", folderName) - - var subPath string - - // FieldFilePath is only present for nested paths - if t.GetField(hotline.FieldFilePath).Data != nil { - var newFp hotline.FilePath - _, err := newFp.Write(t.GetField(hotline.FieldFilePath).Data) - if err != nil { - cc.Logger.Error("new folder: parse file path", "err", err) - return cc.NewErrReply(t, ErrMsgCreateFolder) - } - - for _, pathItem := range newFp.Items { - subPath = path.Join("/", subPath, string(pathItem.Name)) - } - } - - // Decode only client-provided path components from Mac Roman to UTF-8. - // The FileRoot is already a UTF-8 filesystem path and must not be decoded. - subPath, err := cc.TextDecoder().String(subPath) - if err != nil { - cc.Logger.Error("new folder: decode sub path", "err", err) - return cc.NewErrReply(t, ErrMsgCreateFolder) - } - folderName, err = cc.TextDecoder().String(folderName) - if err != nil { - cc.Logger.Error("new folder: decode folder name", "err", err) - return cc.NewErrReply(t, ErrMsgCreateFolder) - } - - newFolderPath := path.Join(cc.FileRoot(), subPath, folderName) - - // TODO: check path and folder Name lengths - - if _, err := cc.Server.FS.Stat(newFolderPath); !os.IsNotExist(err) { - msg := fmt.Sprintf(ErrMsgFolderCreateConflictTemplate, folderName) - return cc.NewErrReply(t, msg) - } - - if err := cc.Server.FS.Mkdir(newFolderPath, 0777); err != nil { - msg := fmt.Sprintf(ErrMsgFolderCreateErrorTemplate, folderName) - return cc.NewErrReply(t, msg) - } - - return append(res, cc.NewReply(t)) -} - -// HandleSetUser sets the information for a specific user in the server's list of allowed users. -// -// Fields used in the request: -// - 105 User login Required -// - 106 User password Optional -// - 102 User name Required -// - 110 User access Required - User access privileges bitmap -// -// Fields used in the reply: None -func HandleSetUser(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) { - if !cc.Authorize(hotline.AccessModifyUser) { - return cc.NewErrReply(t, ErrMsgNotAllowedModifyAccounts) - } - - login := t.GetField(hotline.FieldUserLogin).DecodeObfuscatedString() - userName := string(t.GetField(hotline.FieldUserName).Data) - - newAccessLvl := t.GetField(hotline.FieldUserAccess).Data - - account := cc.Server.AccountManager.Get(login) - if account == nil { - return cc.NewErrReply(t, ErrMsgAccountNotFound) - } - account.Name = userName - copy(account.Access[:], newAccessLvl) - - // If the password field is cleared in the Hotline edit user UI, the SetUser transaction does - // not include FieldUserPassword - if t.GetField(hotline.FieldUserPassword).Data == nil { - account.Password = hotline.HashAndSalt([]byte("")) - } - - if !bytes.Equal([]byte{0}, t.GetField(hotline.FieldUserPassword).Data) { - account.Password = hotline.HashAndSalt(t.GetField(hotline.FieldUserPassword).Data) - } - - err := cc.Server.AccountManager.Update(*account, account.Login) - if err != nil { - cc.Logger.Error("Error updating account", "err", err) - return cc.NewErrReply(t, ErrMsgUpdateAccount) - } - - // Notify connected clients logged in as the user of the new access level - for _, c := range cc.Server.ClientMgr.List() { - if c.Account.Login == login { - newT := hotline.NewTransaction(hotline.TranUserAccess, c.ID, hotline.NewField(hotline.FieldUserAccess, newAccessLvl)) - res = append(res, newT) - - if c.Authorize(hotline.AccessDisconUser) { - c.Flags.Set(hotline.UserFlagAdmin, 1) - } else { - c.Flags.Set(hotline.UserFlagAdmin, 0) - } - - c.Account.Access = account.Access - - cc.SendAll( - hotline.TranNotifyChangeUser, - hotline.NewField(hotline.FieldUserID, c.ID[:]), - hotline.NewField(hotline.FieldUserFlags, c.Flags[:]), - hotline.NewField(hotline.FieldUserName, c.UserName), - hotline.NewField(hotline.FieldUserIconID, c.Icon), - ) - } - } - - return append(res, cc.NewReply(t)) -} - -// HandleGetUser requests the information for a specific user from the server's list of allowed users. -// -// Fields used in the request: -// - 105 User login Required -// -// Fields used in the reply: -// - 102 User name Account display name -// - 105 User login Account login (encoded, each character negated) -// - 106 User password Account password -// - 110 User access User access privileges bitmap -func HandleGetUser(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) { - if !cc.Authorize(hotline.AccessOpenUser) { - return cc.NewErrReply(t, ErrMsgNotAllowedViewAccounts) - } - - account := cc.Server.AccountManager.Get(string(t.GetField(hotline.FieldUserLogin).Data)) - if account == nil { - return cc.NewErrReply(t, ErrMsgAccountNotExist) - } - - return append(res, cc.NewReply(t, - hotline.NewField(hotline.FieldUserName, []byte(account.Name)), - hotline.NewField(hotline.FieldUserLogin, hotline.EncodeString(t.GetField(hotline.FieldUserLogin).Data)), - hotline.NewField(hotline.FieldUserPassword, []byte(account.Password)), - hotline.NewField(hotline.FieldUserAccess, account.Access[:]), - )) -} - -// HandleListUsers returns a list of all user accounts on the server. -// This is a server-specific transaction not in the original Hotline protocol. -// -// Fields used in the request: None -// -// Fields used in the reply: -// - 101 Data Repeated - Serialized account data for each user -func HandleListUsers(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) { - if !cc.Authorize(hotline.AccessOpenUser) { - return cc.NewErrReply(t, ErrMsgNotAllowedViewAccounts) - } - - var userFields []hotline.Field - for _, acc := range cc.Server.AccountManager.List() { - b, err := io.ReadAll(&acc) - if err != nil { - cc.Logger.Error("Error reading account", "account", acc.Login, "err", err) - continue - } - - userFields = append(userFields, hotline.NewField(hotline.FieldData, b)) - } - - return append(res, cc.NewReply(t, userFields...)) -} - -// HandleUpdateUser is used by the v1.5+ multi-user editor to perform account editing for multiple users at a time. -// An update can be a mix of these actions: -// * Create user -// * Delete user -// * Modify user (including renaming the account login) -// -// The Transaction sent by the client includes one data field per user that was modified. This data field in turn -// contains another data field encoded in its payload with a varying number of sub fields depending on which action is -// performed. This seems to be the only place in the Hotline protocol where a data field contains another data field. -// HandleUpdateUser processes batch user account operations from the v1.5+ multi-user editor. -// This handler supports creating, deleting, and modifying multiple user accounts in a single transaction. -// -// Fields used in the request: -// * 101 Data Repeated - Each contains encoded sub-fields for one user operation -// -// Sub-fields for user operations: -// * 101 Data Optional - Original login name (for rename operations) -// * 105 User Login Required - Login name (new name for renames) -// * 102 User Name Optional - Display name (for create/modify) -// * 106 User Password Optional - Password (for create/modify) -// * 110 User Access Optional - Access permissions (for create/modify) -// -// Fields used in the reply: -// None -func HandleUpdateUser(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) { - for _, field := range t.Fields { - var subFields []hotline.Field - - // The first two bytes are the sub-field count; reject a malformed block - // that is too short to contain it rather than slicing out of range. - if len(field.Data) < 2 { - cc.Logger.Error("update user: malformed sub-field block", "len", len(field.Data)) - return cc.NewErrReply(t, ErrMsgUpdateAccount) - } - - // Create a new scanner for parsing incoming bytes into transaction tokens - scanner := bufio.NewScanner(bytes.NewReader(field.Data[2:])) - scanner.Split(hotline.FieldScanner) - - for i := 0; i < int(binary.BigEndian.Uint16(field.Data[0:2])); i++ { - scanner.Scan() - - var field hotline.Field - if _, err := field.Write(scanner.Bytes()); err != nil { - cc.Logger.Error("update user: parse sub-field", "err", err) - return cc.NewErrReply(t, ErrMsgUpdateAccount) - } - subFields = append(subFields, field) - } - - // If there's only one subfield, that indicates this is a delete operation for the login in FieldData - if len(subFields) == 1 { - if !cc.Authorize(hotline.AccessDeleteUser) { - return cc.NewErrReply(t, ErrMsgNotAllowedDeleteAccounts) - } - - login := string(hotline.EncodeString(hotline.GetField(hotline.FieldData, &subFields).Data)) - - cc.Logger.Info("DeleteUser", "login", login) - - if err := cc.Server.AccountManager.Delete(login); err != nil { - cc.Logger.Error("Error deleting account", "err", err) - return cc.NewErrReply(t, ErrMsgDeleteAccount) - } - - for _, client := range cc.Server.ClientMgr.List() { - if client.Account.Login == login { - // "You are logged in with an account which was deleted." - - res = append(res, - hotline.NewTransaction(hotline.TranServerMsg, [2]byte{}, - hotline.NewField(hotline.FieldData, []byte(ErrMsgAccountDeleted)), - hotline.NewField(hotline.FieldChatOptions, []byte{0}), - ), - ) - - go func(c *hotline.ClientConn) { - time.Sleep(3 * time.Second) - c.Disconnect() - }(client) - } - } - - continue - } - - // login of the account to update - var accountToUpdate, loginToRename string - - // If FieldData is included, this is a rename operation where FieldData contains the login of the existing - // account and FieldUserLogin contains the new login. - if hotline.GetField(hotline.FieldData, &subFields) != nil { - loginToRename = string(hotline.EncodeString(hotline.GetField(hotline.FieldData, &subFields).Data)) - } - userLogin := string(hotline.EncodeString(hotline.GetField(hotline.FieldUserLogin, &subFields).Data)) - if loginToRename != "" { - accountToUpdate = loginToRename - } else { - accountToUpdate = userLogin - } - - // Check if accountToUpdate has an existing account. If so, we know we are updating an existing user. - if acc := cc.Server.AccountManager.Get(accountToUpdate); acc != nil { - if loginToRename != "" { - cc.Logger.Info("RenameUser", "prevLogin", accountToUpdate, "newLogin", userLogin) - } else { - cc.Logger.Info("UpdateUser", "login", accountToUpdate) - } - - // Account exists, so this is an update action. - if !cc.Authorize(hotline.AccessModifyUser) { - return cc.NewErrReply(t, ErrMsgNotAllowedModifyAccounts) - } - - // This part is a bit tricky. There are three possibilities: - // 1) The transaction is intended to update the password. - // In this case, FieldUserPassword is sent with the new password. - // 2) The transaction is intended to remove the password. - // In this case, FieldUserPassword is not sent. - // 3) The transaction updates the users access bits, but not the password. - // In this case, FieldUserPassword is sent with zero as the only byte. - if hotline.GetField(hotline.FieldUserPassword, &subFields) != nil { - newPass := hotline.GetField(hotline.FieldUserPassword, &subFields).Data - if !bytes.Equal([]byte{0}, newPass) { - acc.Password = hotline.HashAndSalt(newPass) - } - } else { - acc.Password = hotline.HashAndSalt([]byte("")) - } - - if hotline.GetField(hotline.FieldUserAccess, &subFields) != nil { - copy(acc.Access[:], hotline.GetField(hotline.FieldUserAccess, &subFields).Data) - } - - acc.Name = string(hotline.GetField(hotline.FieldUserName, &subFields).Data) - - err := cc.Server.AccountManager.Update(*acc, string(hotline.EncodeString(hotline.GetField(hotline.FieldUserLogin, &subFields).Data))) - - if err != nil { - cc.Logger.Error("update user: update account", "err", err) - return cc.NewErrReply(t, ErrMsgUpdateAccount) - } - } else { - if !cc.Authorize(hotline.AccessCreateUser) { - return cc.NewErrReply(t, ErrMsgNotAllowedCreateAccounts) - } - - cc.Logger.Info("CreateUser", "login", userLogin) - - var newAccess hotline.AccessBitmap - copy(newAccess[:], hotline.GetField(hotline.FieldUserAccess, &subFields).Data) - - // Prevent account from creating new account with greater permission - for i := 0; i < 64; i++ { - if newAccess.IsSet(i) { - if !cc.Authorize(i) { - return cc.NewErrReply(t, "Cannot create account with more access than yourself.") - } - } - } - - account := hotline.NewAccount( - userLogin, - string(hotline.GetField(hotline.FieldUserName, &subFields).Data), - string(hotline.GetField(hotline.FieldUserPassword, &subFields).Data), - newAccess, - ) - - err := cc.Server.AccountManager.Create(*account) - if err != nil { - return cc.NewErrReply(t, ErrMsgAccountExists) - } - } - } - - return append(res, cc.NewReply(t)) -} - -// HandleNewUser adds a new user to the server's list of allowed users. -// -// Fields used in the request: -// - 105 User login Required -// - 106 User password Required -// - 102 User name Required -// - 110 User access Required - User access privileges bitmap -// -// Fields used in the reply: None -func HandleNewUser(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) { - if !cc.Authorize(hotline.AccessCreateUser) { - return cc.NewErrReply(t, ErrMsgNotAllowedCreateAccounts) - } - - login := t.GetField(hotline.FieldUserLogin).DecodeObfuscatedString() - - // If the account already exists, reply with an error. - if account := cc.Server.AccountManager.Get(login); account != nil { - return cc.NewErrReply(t, fmt.Sprintf(ErrMsgAccountExistsTemplate, login)) - } - - var newAccess hotline.AccessBitmap - copy(newAccess[:], t.GetField(hotline.FieldUserAccess).Data) - - // Prevent account from creating new account with greater permission - for i := 0; i < 64; i++ { - if newAccess.IsSet(i) { - if !cc.Authorize(i) { - return cc.NewErrReply(t, ErrMsgAccountMoreAccess) - } - } - } - - account := hotline.NewAccount(login, string(t.GetField(hotline.FieldUserName).Data), string(t.GetField(hotline.FieldUserPassword).Data), newAccess) - - err := cc.Server.AccountManager.Create(*account) - if err != nil { - return cc.NewErrReply(t, ErrMsgAccountExists) - } - - return append(res, cc.NewReply(t)) -} - -// HandleDeleteUser deletes the specified user from the server's list of allowed users. -// -// Fields used in the request: -// - 105 User login Required -// -// Fields used in the reply: None -func HandleDeleteUser(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) { - if !cc.Authorize(hotline.AccessDeleteUser) { - return cc.NewErrReply(t, ErrMsgNotAllowedDeleteAccounts) - } - - login := t.GetField(hotline.FieldUserLogin).DecodeObfuscatedString() - - if err := cc.Server.AccountManager.Delete(login); err != nil { - cc.Logger.Error("Error deleting account", "err", err) - return cc.NewErrReply(t, ErrMsgDeleteAccount) - } - - for _, client := range cc.Server.ClientMgr.List() { - if client.Account.Login == login { - res = append(res, - hotline.NewTransaction(hotline.TranServerMsg, client.ID, - hotline.NewField(hotline.FieldData, []byte(ErrMsgAccountDeleted)), - hotline.NewField(hotline.FieldChatOptions, []byte{2}), - ), - ) - - go func(c *hotline.ClientConn) { - time.Sleep(2 * time.Second) - c.Disconnect() - }(client) - } - } - - return append(res, cc.NewReply(t)) -} - -// HandleUserBroadcast broadcasts a message to all users on the server. -// -// Access: Broadcast (32) -// -// Fields used in the request: -// - 101 Data Required -// -// Fields used in the reply: None -func HandleUserBroadcast(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) { - if !cc.Authorize(hotline.AccessBroadcast) { - return cc.NewErrReply(t, ErrMsgNotAllowedSendBroadcast) - } - - cc.SendAll( - hotline.TranServerMsg, - hotline.NewField(hotline.FieldData, t.GetField(hotline.FieldData).Data), - hotline.NewField(hotline.FieldChatOptions, []byte{0}), - ) - - return append(res, cc.NewReply(t)) -} - -// HandleGetClientInfoText requests user information for a specific user. -// -// Access: Get Client Info (24) -// -// Fields used in the request: -// - 103 User ID Required -// -// Fields used in the reply: -// - 102 User name User's display name -// - 101 Data User info text string -func HandleGetClientInfoText(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) { - if !cc.Authorize(hotline.AccessGetClientInfo) { - return cc.NewErrReply(t, ErrMsgNotAllowedGetClientInfo) - } - - clientID, ok := hotline.ClientIDFromBytes(t.GetField(hotline.FieldUserID).Data) - if !ok { - return cc.NewErrReply(t, ErrMsgInvalidUserID) - } - - clientConn := cc.Server.ClientMgr.Get(clientID) - if clientConn == nil { - return cc.NewErrReply(t, ErrMsgUserNotFound) - } - - return append(res, cc.NewReply(t, - hotline.NewField(hotline.FieldData, []byte(clientConn.String())), - hotline.NewField(hotline.FieldUserName, clientConn.UserName), - )) -} - -// HandleGetUserNameList requests the list of all users connected to the current server. -// -// Fields used in the request: None -// -// Fields used in the reply: -// - 300 User name with info Repeated - User information for each connected client -func HandleGetUserNameList(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) { - var fields []hotline.Field - for _, c := range cc.Server.ClientMgr.List() { - b, err := io.ReadAll(&hotline.User{ - ID: c.ID, - Icon: c.Icon, - Flags: c.Flags[:], - Name: string(c.UserName), - }) - if err != nil { - cc.Logger.Error("get user name list: read user info", "err", err) - return cc.NewErrReply(t, ErrMsgGetUserList) - } - - fields = append(fields, hotline.NewField(hotline.FieldUsernameWithInfo, b)) - } - - return []hotline.Transaction{cc.NewReply(t, fields...)} -} - -// HandleTranAgreed notifies the server that the user accepted the server agreement. -// -// Fields used in the request: -// - 102 User name Display name -// - 104 User icon ID User icon identifier -// - 113 Options Bitmap: Automatic response (4), Refuse private chat (2), Refuse private message (1) -// - 215 Automatic response Optional - Auto-response string if options field indicates this feature -// -// Fields used in the reply: None -func HandleTranAgreed(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) { - if t.GetField(hotline.FieldUserName).Data != nil { - if cc.Authorize(hotline.AccessAnyName) { - cc.UserName = t.GetField(hotline.FieldUserName).Data - } else { - cc.UserName = []byte(cc.Account.Name) - } - } - - login := cc.Account.Login - ip := cc.IP() - - if cc.Server.Redis != nil { - // Remove old entry (login::ip) - cc.Server.Redis.SRem(context.Background(), hotline.RedisKeyOnline, login+"::"+ip) - // Add new entry with login, nickname, ip - cc.Server.Redis.SAdd(context.Background(), hotline.RedisKeyOnline, login+":"+string(cc.UserName)+":"+ip) - } - - // Ban check for nickname - if cc.Server.BanList != nil && cc.Server.BanList.IsNicknameBanned(string(cc.UserName)) { - if cc.Server.Redis != nil { - // Remove all possible online entries for this login and IP - cc.Server.Redis.SRem(context.Background(), hotline.RedisKeyOnline, login+"::"+ip) - cc.Server.Redis.SRem(context.Background(), hotline.RedisKeyOnline, login+":"+string(cc.UserName)+":"+ip) - } - if err := cc.Server.BanList.Add(ip, nil); err != nil { - cc.Logger.Error("Failed to ban IP for banned nickname", "ip", ip, "err", err) - } - cc.Disconnect() - // Connection is being torn down; no reply is sent to the client. - return res - } - - cc.Icon = t.GetField(hotline.FieldUserIconID).Data - - cc.Logger = cc.Logger.With("Name", string(cc.UserName)) - cc.Logger.Info("Login successful") - - options := t.GetField(hotline.FieldOptions).Data - optBitmap := big.NewInt(int64(binary.BigEndian.Uint16(options))) - - // Check refuse private PM option - - cc.FlagsMU.Lock() - defer cc.FlagsMU.Unlock() - cc.Flags.Set(hotline.UserFlagRefusePM, optBitmap.Bit(hotline.UserOptRefusePM)) - - // Check refuse private chat option - cc.Flags.Set(hotline.UserFlagRefusePChat, optBitmap.Bit(hotline.UserOptRefuseChat)) - - // Check auto response - if optBitmap.Bit(hotline.UserOptAutoResponse) == 1 { - cc.AutoReply = t.GetField(hotline.FieldAutomaticResponse).Data - } - - trans := cc.NotifyOthers( - hotline.NewTransaction( - hotline.TranNotifyChangeUser, [2]byte{0, 0}, - hotline.NewField(hotline.FieldUserName, cc.UserName), - hotline.NewField(hotline.FieldUserID, cc.ID[:]), - hotline.NewField(hotline.FieldUserIconID, cc.Icon), - hotline.NewField(hotline.FieldUserFlags, cc.Flags[:]), - ), - ) - res = append(res, trans...) - - if cc.Server.Config.BannerFile != "" { - bannerType := hotline.FileTypeFromFilename(cc.Server.Config.BannerFile).TypeCode - res = append(res, hotline.NewTransaction(hotline.TranServerBanner, cc.ID, hotline.NewField(hotline.FieldBannerType, []byte(bannerType)))) - } - - res = append(res, cc.NewReply(t)) - - return res -} - -// HandleTranOldPostNews posts to the flat news (message board). -// -// Access: News Post Article (21) -// -// Fields used in the request: -// - 101 Data Required - News post content -// -// Fields used in the reply: None -func HandleTranOldPostNews(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) { - if !cc.Authorize(hotline.AccessNewsPostArt) { - return cc.NewErrReply(t, ErrMsgNotAllowedPostNews) - } - - newsDateTemplate := hotline.NewsDateFormat - if cc.Server.Config.NewsDateFormat != "" { - newsDateTemplate = cc.Server.Config.NewsDateFormat - } - - newsTemplate := hotline.NewsTemplate - if cc.Server.Config.NewsDelimiter != "" { - newsTemplate = cc.Server.Config.NewsDelimiter - } - - newsPost := fmt.Sprintf(newsTemplate+"\r", cc.UserName, time.Now().Format(newsDateTemplate), t.GetField(hotline.FieldData).Data) - newsPost = strings.ReplaceAll(newsPost, "\n", "\r") - - _, err := cc.Server.MessageBoard.Write([]byte(newsPost)) - if err != nil { - cc.Logger.Error("error writing news post", "err", err) - return cc.NewErrReply(t, ErrMsgPostNews) - } - - // Notify all clients of updated news - cc.SendAll( - hotline.TranNewMsg, - hotline.NewField(hotline.FieldData, []byte(newsPost)), - ) - - return append(res, cc.NewReply(t)) -} - -// HandleDisconnectUser disconnects a user from the current server. -// -// Access: Disconnect User (22) -// -// Fields used in the request: -// - 103 User ID Required -// - 113 Options Optional - Ban options -// - 101 Data Optional -// -// Fields used in the reply: None -func HandleDisconnectUser(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) { - if !cc.Authorize(hotline.AccessDisconUser) { - return cc.NewErrReply(t, ErrMsgNotAllowedDisconnectUsers) - } - - clientID, ok := hotline.ClientIDFromBytes(t.GetField(hotline.FieldUserID).Data) - if !ok { - return cc.NewErrReply(t, ErrMsgInvalidUserID) - } - clientConn := cc.Server.ClientMgr.Get(clientID) - if clientConn == nil { - return cc.NewErrReply(t, ErrMsgUserNotFound) - } - - if clientConn.Authorize(hotline.AccessCannotBeDiscon) { - return cc.NewErrReply(t, clientConn.Account.Login+" is not allowed to be disconnected.") - } - - // If FieldOptions is set, then the client IP is banned in addition to disconnected. - // 00 01 = temporary ban - // 00 02 = permanent ban - if options := t.GetField(hotline.FieldOptions).Data; len(options) > 1 { - switch options[1] { - case 1: - // send message: "You are temporarily banned on this server" - cc.Logger.Info("Disconnect & temporarily ban user", "username", string(clientConn.UserName)) - - res = append(res, hotline.NewTransaction( - hotline.TranServerMsg, - clientConn.ID, - hotline.NewField(hotline.FieldData, []byte(ErrMsgTemporaryBan)), - hotline.NewField(hotline.FieldChatOptions, []byte{0, 0}), - )) - - banUntil := time.Now().Add(hotline.BanDuration) - ip := clientConn.IP() - - err := cc.Server.BanList.Add(ip, &banUntil) - if err != nil { - cc.Logger.Error("Error saving ban", "err", err) - // TODO - } - case 2: - // send message: "You are permanently banned on this server" - cc.Logger.Info("Disconnect & ban user", "username", string(clientConn.UserName)) - - res = append(res, hotline.NewTransaction( - hotline.TranServerMsg, - clientConn.ID, - hotline.NewField(hotline.FieldData, []byte(ErrMsgPermanentBan)), - hotline.NewField(hotline.FieldChatOptions, []byte{0, 0}), - )) - - ip := clientConn.IP() - - err := cc.Server.BanList.Add(ip, nil) - if err != nil { - cc.Logger.Error("Error saving ban", "err", err) - } - } - } - - go func() { - time.Sleep(1 * time.Second) - clientConn.Disconnect() - }() - - return append(res, cc.NewReply(t)) -} - -// HandleGetNewsCatNameList gets the list of category names at the specified news path. -// -// Fields used in the request: -// - 325 News path Optional -// -// Fields used in the reply: -// - 323 News category list data Repeated - Category information -func HandleGetNewsCatNameList(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) { - if !cc.Authorize(hotline.AccessNewsReadArt) { - return cc.NewErrReply(t, ErrMsgNotAllowedReadNews) - } - - pathStrs, err := t.GetField(hotline.FieldNewsPath).DecodeNewsPath() - if err != nil { - cc.Logger.Error("get news path", "err", err) - return cc.NewErrReply(t, ErrMsgReadNewsCategories) - } - - var fields []hotline.Field - for _, cat := range cc.Server.ThreadedNewsMgr.GetCategories(pathStrs) { - b, err := io.ReadAll(&cat) - if err != nil { - cc.Logger.Error("get news categories", "err", err) - return cc.NewErrReply(t, ErrMsgReadNewsCategories) - } - - fields = append(fields, hotline.NewField(hotline.FieldNewsCatListData15, b)) - } - - return append(res, cc.NewReply(t, fields...)) -} - -// HandleNewNewsCat creates a new news category on the server. -// -// Access: News Create Category (34) -// -// Fields used in the request: -// - 322 News category name Required -// - 325 News path Required -// -// Fields used in the reply: None -func HandleNewNewsCat(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) { - if !cc.Authorize(hotline.AccessNewsCreateCat) { - return cc.NewErrReply(t, ErrMsgNotAllowedCreateNewsCategories) - } - - name := string(t.GetField(hotline.FieldNewsCatName).Data) - pathStrs, err := t.GetField(hotline.FieldNewsPath).DecodeNewsPath() - if err != nil { - cc.Logger.Error("new news category: decode news path", "err", err) - return cc.NewErrReply(t, ErrMsgCreateNewsCategory) - } - - err = cc.Server.ThreadedNewsMgr.CreateGrouping(pathStrs, name, hotline.NewsCategory) - if err != nil { - cc.Logger.Error("error creating news category", "err", err) - return cc.NewErrReply(t, ErrMsgCreateNewsCategory) - } - - return []hotline.Transaction{cc.NewReply(t)} -} - -// HandleNewNewsFldr creates a new news folder on the server. -// -// Access: News Create Folder (36) -// -// Fields used in the request: -// - 201 File name Required -// - 325 News path Required -// -// Fields used in the reply: None -func HandleNewNewsFldr(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) { - if !cc.Authorize(hotline.AccessNewsCreateFldr) { - return cc.NewErrReply(t, ErrMsgNotAllowedCreateNewsfolders) - } - - name := string(t.GetField(hotline.FieldFileName).Data) - pathStrs, err := t.GetField(hotline.FieldNewsPath).DecodeNewsPath() - if err != nil { - cc.Logger.Error("new news folder: decode news path", "err", err) - return cc.NewErrReply(t, ErrMsgCreateNewsFolder) - } - - err = cc.Server.ThreadedNewsMgr.CreateGrouping(pathStrs, name, hotline.NewsBundle) - if err != nil { - cc.Logger.Error("error creating news bundle", "err", err) - return cc.NewErrReply(t, ErrMsgCreateNewsFolder) - } - - return append(res, cc.NewReply(t)) -} - -// HandleGetNewsArtNameList gets the list of article names at the specified news path. -// -// Fields used in the request: -// - 325 News path Optional -// -// Fields used in the reply: -// - 321 News article list data Optional -func HandleGetNewsArtNameList(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) { - if !cc.Authorize(hotline.AccessNewsReadArt) { - return cc.NewErrReply(t, ErrMsgNotAllowedReadNews) - } - - pathStrs, err := t.GetField(hotline.FieldNewsPath).DecodeNewsPath() - if err != nil { - cc.Logger.Error("get news article list: decode news path", "err", err) - return cc.NewErrReply(t, ErrMsgReadNewsArticles) - } - - nald, err := cc.Server.ThreadedNewsMgr.ListArticles(pathStrs) - if err != nil { - cc.Logger.Error("get news article list: list articles", "err", err) - return cc.NewErrReply(t, ErrMsgReadNewsArticles) - } - - b, err := io.ReadAll(&nald) - if err != nil { - cc.Logger.Error("get news article list: read article list data", "err", err) - return cc.NewErrReply(t, ErrMsgReadNewsArticles) - } - - return append(res, cc.NewReply(t, hotline.NewField(hotline.FieldNewsArtListData, b))) -} - -// HandleGetNewsArtData requests information about a specific news article. -// -// Access: News Read Article (20) -// -// Fields used in the request: -// - 325 News path Required -// - 326 News article ID Required -// - 327 News article data flavor Required -// -// Fields used in the reply: -// - 328 News article title Article title -// - 329 News article poster Author -// - 330 News article date Publication date -// - 331 Previous article ID ID of previous article -// - 332 Next article ID ID of next article -// - 335 Parent article ID ID of parent article -// - 336 First child article ID ID of first reply -// - 327 News article data flavor Should be "text/plain" -// - 333 News article data Optional - Article content -func HandleGetNewsArtData(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) { - if !cc.Authorize(hotline.AccessNewsReadArt) { - return cc.NewErrReply(t, ErrMsgNotAllowedReadNews) - } - - newsPath, err := t.GetField(hotline.FieldNewsPath).DecodeNewsPath() - if err != nil { - cc.Logger.Error("get news article: decode news path", "err", err) - return cc.NewErrReply(t, ErrMsgReadNewsArticles) - } - - convertedID, err := t.GetField(hotline.FieldNewsArtID).DecodeInt() - if err != nil { - cc.Logger.Error("get news article: decode article ID", "err", err) - return cc.NewErrReply(t, ErrMsgReadNewsArticles) - } - - art := cc.Server.ThreadedNewsMgr.GetArticle(newsPath, uint32(convertedID)) - if art == nil { - return append(res, cc.NewReply(t)) - } - - res = append(res, cc.NewReply(t, - hotline.NewField(hotline.FieldNewsArtTitle, []byte(art.Title)), - hotline.NewField(hotline.FieldNewsArtPoster, []byte(art.Poster)), - hotline.NewField(hotline.FieldNewsArtDate, art.Date[:]), - hotline.NewField(hotline.FieldNewsArtPrevArt, art.PrevArt[:]), - hotline.NewField(hotline.FieldNewsArtNextArt, art.NextArt[:]), - hotline.NewField(hotline.FieldNewsArtParentArt, art.ParentArt[:]), - hotline.NewField(hotline.FieldNewsArt1stChildArt, art.FirstChildArt[:]), - hotline.NewField(hotline.FieldNewsArtDataFlav, []byte("text/plain")), - hotline.NewField(hotline.FieldNewsArtData, []byte(art.Data)), - )) - return res -} - -// HandleDelNewsItem deletes an existing news item from the server. -// -// Access: News Delete Folder (37) or News Delete Category (35) -// -// Fields used in the request: -// - 325 News path Required -// -// Fields used in the reply: None -func HandleDelNewsItem(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) { - pathStrs, err := t.GetField(hotline.FieldNewsPath).DecodeNewsPath() - if err != nil || len(pathStrs) == 0 { - cc.Logger.Error("delete news item: invalid news path", "err", err) - return cc.NewErrReply(t, ErrMsgDeleteNewsItem) - } - - item := cc.Server.ThreadedNewsMgr.NewsItem(pathStrs) - - if item.Type == [2]byte{0, 3} { - if !cc.Authorize(hotline.AccessNewsDeleteCat) { - return cc.NewErrReply(t, ErrMsgNotAllowedDeleteNewsCategories) - } - } else { - if !cc.Authorize(hotline.AccessNewsDeleteFldr) { - return cc.NewErrReply(t, ErrMsgNotAllowedDeleteNewsFolders) - } - } - - err = cc.Server.ThreadedNewsMgr.DeleteNewsItem(pathStrs) - if err != nil { - cc.Logger.Error("delete news item", "err", err) - return cc.NewErrReply(t, ErrMsgDeleteNewsItem) - } - - return append(res, cc.NewReply(t)) -} - -// HandleDelNewsArt deletes a specific news article. -// -// Access: News Delete Article (33) -// -// Fields used in the request: -// - 325 News path Required -// - 326 News article ID Required -// - 337 News article – recursive delete Optional - Delete child articles (1) or not (0) -// -// Fields used in the reply: None -func HandleDelNewsArt(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) { - if !cc.Authorize(hotline.AccessNewsDeleteArt) { - return cc.NewErrReply(t, ErrMsgNotAllowedDeleteNewsArticles) - - } - - pathStrs, err := t.GetField(hotline.FieldNewsPath).DecodeNewsPath() - if err != nil { - cc.Logger.Error("delete news article: decode news path", "err", err) - return cc.NewErrReply(t, ErrMsgDeleteNewsArticle) - } - - articleID, err := t.GetField(hotline.FieldNewsArtID).DecodeInt() - if err != nil { - cc.Logger.Error("delete news article: decode article ID", "err", err) - return cc.NewErrReply(t, ErrMsgDeleteNewsArticle) - } - - deleteRecursive := bytes.Equal([]byte{0, 1}, t.GetField(hotline.FieldNewsArtRecurseDel).Data) - - err = cc.Server.ThreadedNewsMgr.DeleteArticle(pathStrs, uint32(articleID), deleteRecursive) - if err != nil { - cc.Logger.Error("error deleting news article", "err", err) - return cc.NewErrReply(t, ErrMsgDeleteNewsArticle) - } - - return []hotline.Transaction{cc.NewReply(t)} -} - -// HandlePostNewsArt posts a new news article on the server. -// -// Access: News Post Article (21) -// -// Fields used in the request: -// - 325 News path Required -// - 326 News article ID ID of the parent article -// - 328 News article title Required -// - 334 News article flags Optional -// - 327 News article data flavor Currently "text/plain" -// - 333 News article data Required -// -// Fields used in the reply: None -func HandlePostNewsArt(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) { - if !cc.Authorize(hotline.AccessNewsPostArt) { - return cc.NewErrReply(t, ErrMsgNotAllowedPostNewsArticles) - } - - pathStrs, err := t.GetField(hotline.FieldNewsPath).DecodeNewsPath() - if err != nil || len(pathStrs) == 0 { - cc.Logger.Error("post news article: invalid news path", "err", err) - return cc.NewErrReply(t, ErrMsgPostNewsArticle) - } - - parentArticleID, err := t.GetField(hotline.FieldNewsArtID).DecodeInt() - if err != nil { - cc.Logger.Error("post news article: decode parent article ID", "err", err) - return cc.NewErrReply(t, ErrMsgPostNewsArticle) - } - - err = cc.Server.ThreadedNewsMgr.PostArticle( - pathStrs, - uint32(parentArticleID), - hotline.NewsArtData{ - Title: string(t.GetField(hotline.FieldNewsArtTitle).Data), - Poster: string(cc.UserName), - Date: hotline.NewTime(time.Now()), - DataFlav: hotline.NewsFlavor, - Data: string(t.GetField(hotline.FieldNewsArtData).Data), - }, - ) - if err != nil { - cc.Logger.Error("error posting news article", "err", err) - return cc.NewErrReply(t, ErrMsgPostNewsArticle) - } - - return append(res, cc.NewReply(t)) -} - -// HandleGetMsgs returns the flat news data (message board content). -// -// Fields used in the request: None -// -// Fields used in the reply: -// - 101 Data Message text -func HandleGetMsgs(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) { - if !cc.Authorize(hotline.AccessNewsReadArt) { - return cc.NewErrReply(t, ErrMsgNotAllowedReadNews) - } - - _, _ = cc.Server.MessageBoard.Seek(0, 0) - - newsData, err := io.ReadAll(cc.Server.MessageBoard) - if err != nil { - cc.Logger.Error("Error reading messageboard", "err", err) - return cc.NewErrReply(t, ErrMsgReadMessageBoard) - } - - return append(res, cc.NewReply(t, hotline.NewField(hotline.FieldData, newsData))) -} - -// HandleDownloadFile downloads a file from the specified path on the server. -// -// Access: Download File (2) -// -// Fields used in the request: -// - 201 File name Required -// - 202 File path Optional -// - 203 File resume data Optional -// - 204 File transfer options Optional - Set to 2 for TEXT, JPEG, GIFF, BMP or PICT files -// -// Fields used in the reply: -// - 108 Transfer size Size of data to be downloaded -// - 207 File size Actual file size -// - 107 Reference number Used later for transfer -// - 116 Waiting count Queue position -func HandleDownloadFile(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) { - if !cc.Authorize(hotline.AccessDownloadFile) { - return cc.NewErrReply(t, ErrMsgNotAllowedDownloadFiles) - } - - fileName := t.GetField(hotline.FieldFileName).Data - filePath := t.GetField(hotline.FieldFilePath).Data - resumeData := t.GetField(hotline.FieldFileResumeData).Data - - var dataOffset int64 - var frd hotline.FileResumeData - if resumeData != nil { - if err := frd.UnmarshalBinary(t.GetField(hotline.FieldFileResumeData).Data); err != nil { - cc.Logger.Error("download file: unmarshal resume data", "err", err) - return cc.NewErrReply(t, ErrMsgFileResumeData) - } - // TODO: handle rsrc fork offset - if len(frd.ForkInfoList) > 0 { - dataOffset = int64(binary.BigEndian.Uint32(frd.ForkInfoList[0].DataSize[:])) - } - } - - fullFilePath, err := hotline.ReadPath(cc.FileRoot(), filePath, fileName, cc.TextDecoder()) - if err != nil { - cc.Logger.Error("download file: read path", "err", err) - return cc.NewErrReply(t, ErrMsgFileNotFound) - } - - hlFile, err := hotline.NewFile(cc.Server.FS, fullFilePath, dataOffset) - if err != nil { - cc.Logger.Error("download file: open file", "err", err) - return cc.NewErrReply(t, ErrMsgFileNotFound) - } - - xferSize := hlFile.Ffo.TransferSize(0) - - ft := cc.NewFileTransfer( - hotline.FileDownload, - cc.FileRoot(), - fileName, - filePath, - xferSize, - ) - - if resumeData != nil { - var frd hotline.FileResumeData - if err := frd.UnmarshalBinary(t.GetField(hotline.FieldFileResumeData).Data); err != nil { - cc.Logger.Error("download file: unmarshal resume data", "err", err) - return cc.NewErrReply(t, ErrMsgFileResumeData) - } - ft.FileResumeData = &frd - } - - // Optional field for when a client requests file preview - // Used only for TEXT, JPEG, GIFF, BMP or PICT files - // The value will always be 2 - if t.GetField(hotline.FieldFileTransferOptions).Data != nil { - ft.Options = t.GetField(hotline.FieldFileTransferOptions).Data - xferSize = hlFile.Ffo.FlatFileDataForkHeader.DataSize[:] - } - - res = append(res, cc.NewReply(t, - hotline.NewField(hotline.FieldRefNum, ft.RefNum[:]), - hotline.NewField(hotline.FieldWaitingCount, []byte{0x00, 0x00}), // TODO: Implement waiting count - hotline.NewField(hotline.FieldTransferSize, xferSize), - hotline.NewField(hotline.FieldFileSize, hlFile.Ffo.FlatFileDataForkHeader.DataSize[:]), - )) - - return res -} - -// HandleDownloadFolder downloads all files from the specified folder and its subfolders. -// -// Access: Download File (2) -// -// Fields used in the request: -// - 201 File name Required -// - 202 File path Optional -// -// Fields used in the reply: -// - 220 Folder item count Number of items in the folder -// - 107 Reference number Used later for transfer -// - 108 Transfer size Size of data to be downloaded -// - 116 Waiting count Queue position -func HandleDownloadFolder(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) { - if !cc.Authorize(hotline.AccessDownloadFolder) { - return cc.NewErrReply(t, ErrMsgNotAllowedDownloadFolders) - } - - fullFilePath, err := hotline.ReadPath(cc.FileRoot(), t.GetField(hotline.FieldFilePath).Data, t.GetField(hotline.FieldFileName).Data, cc.TextDecoder()) - if err != nil { - cc.Logger.Error("download folder: read path", "err", err) - return cc.NewErrReply(t, ErrMsgFileNotFound) - } - - transferSize, err := hotline.CalcTotalSize(fullFilePath) - if err != nil { - cc.Logger.Error("download folder: calc total size", "err", err) - return cc.NewErrReply(t, ErrMsgDownloadFolder) - } - itemCount, err := hotline.CalcItemCount(fullFilePath) - if err != nil { - cc.Logger.Error("download folder: calc item count", "err", err) - return cc.NewErrReply(t, ErrMsgDownloadFolder) - } - - fileTransfer := cc.NewFileTransfer(hotline.FolderDownload, cc.FileRoot(), t.GetField(hotline.FieldFileName).Data, t.GetField(hotline.FieldFilePath).Data, transferSize) - - var fp hotline.FilePath - _, err = fp.Write(t.GetField(hotline.FieldFilePath).Data) - if err != nil { - cc.Logger.Error("download folder: parse file path", "err", err) - return cc.NewErrReply(t, ErrMsgDownloadFolder) - } - - res = append(res, cc.NewReply(t, - hotline.NewField(hotline.FieldRefNum, fileTransfer.RefNum[:]), - hotline.NewField(hotline.FieldTransferSize, transferSize), - hotline.NewField(hotline.FieldFolderItemCount, itemCount), - hotline.NewField(hotline.FieldWaitingCount, []byte{0x00, 0x00}), // TODO: Implement waiting count - )) - return res -} - -// HandleUploadFolder uploads all files from a local folder and its subfolders to the server. -// -// Access: Upload File (1) -// -// Fields used in the request: -// - 201 File name Required -// - 202 File path Optional -// - 108 Transfer size Total size of all items in the folder -// - 220 Folder item count Number of items in the folder -// - 204 File transfer options Optional - Currently set to 1 -// -// Fields used in the reply: -// - 107 Reference number Used later for transfer -func HandleUploadFolder(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) { - if !cc.Authorize(hotline.AccessUploadFolder) { - return cc.NewErrReply(t, ErrMsgNotAllowedUploadFolders) - } - - var fp hotline.FilePath - if t.GetField(hotline.FieldFilePath).Data != nil { - if _, err := fp.Write(t.GetField(hotline.FieldFilePath).Data); err != nil { - cc.Logger.Error("upload folder: parse file path", "err", err) - return cc.NewErrReply(t, ErrMsgUploadFolder) - } - } - - // Handle special cases for Upload and Drop Box folders - if !cc.Authorize(hotline.AccessUploadAnywhere) { - if !fp.IsUploadDir() && !fp.IsDropbox() { - return cc.NewErrReply(t, fmt.Sprintf(ErrMsgUploadRestrictedTemplate, "folder", string(t.GetField(hotline.FieldFileName).Data))) - } - } - - fileTransfer := cc.NewFileTransfer(hotline.FolderUpload, - cc.FileRoot(), - t.GetField(hotline.FieldFileName).Data, - t.GetField(hotline.FieldFilePath).Data, - t.GetField(hotline.FieldTransferSize).Data, - ) - - fileTransfer.FolderItemCount = t.GetField(hotline.FieldFolderItemCount).Data - - return append(res, cc.NewReply(t, hotline.NewField(hotline.FieldRefNum, fileTransfer.RefNum[:]))) -} - -// HandleUploadFile uploads a file to the specified path on the server. -// -// Access: Upload File (1) -// -// Fields used in the request: -// - 201 File name Required -// - 202 File path Optional -// - 204 File transfer options Optional - Used to resume download, value 2 -// - 108 File transfer size Optional - Used if download is not resumed -// -// Fields used in the reply: -// - 203 File resume data Optional - Used only to resume download -// - 107 Reference number Transfer reference -func HandleUploadFile(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) { - if !cc.Authorize(hotline.AccessUploadFile) { - return cc.NewErrReply(t, ErrMsgNotAllowedUploadFiles) - } - - fileName := t.GetField(hotline.FieldFileName).Data - filePath := t.GetField(hotline.FieldFilePath).Data - transferOptions := t.GetField(hotline.FieldFileTransferOptions).Data - transferSize := t.GetField(hotline.FieldTransferSize).Data // not sent for resume - - var fp hotline.FilePath - if filePath != nil { - if _, err := fp.Write(filePath); err != nil { - cc.Logger.Error("upload file: parse file path", "err", err) - return cc.NewErrReply(t, ErrMsgUploadFile) - } - } - - // Handle special cases for Upload and Drop Box folders - if !cc.Authorize(hotline.AccessUploadAnywhere) { - if !fp.IsUploadDir() && !fp.IsDropbox() { - return cc.NewErrReply(t, fmt.Sprintf(ErrMsgUploadRestrictedTemplate, "file", string(fileName))) - } - } - fullFilePath, err := hotline.ReadPath(cc.FileRoot(), filePath, fileName, cc.TextDecoder()) - if err != nil { - cc.Logger.Error("upload file: read path", "err", err) - return cc.NewErrReply(t, ErrMsgUploadFile) - } - - if _, err := cc.Server.FS.Stat(fullFilePath); err == nil { - return cc.NewErrReply(t, fmt.Sprintf(ErrMsgFileUploadConflictTemplate, string(fileName))) - } - - ft := cc.NewFileTransfer(hotline.FileUpload, cc.FileRoot(), fileName, filePath, transferSize) - - replyT := cc.NewReply(t, hotline.NewField(hotline.FieldRefNum, ft.RefNum[:])) - - // client has requested to resume a partially transferred file - if transferOptions != nil { - // If there is no partial file to resume from, fall back to a normal upload - // reply (with the reference number already set) rather than discarding it. - if fileInfo, err := cc.Server.FS.Stat(fullFilePath + hotline.IncompleteFileSuffix); err != nil { - cc.Logger.Info("upload file: no partial file to resume, starting fresh upload", "err", err) - } else { - offset := make([]byte, 4) - binary.BigEndian.PutUint32(offset, uint32(fileInfo.Size())) - - fileResumeData := hotline.NewFileResumeData([]hotline.ForkInfoList{ - *hotline.NewForkInfoList(offset), - }) - - b, _ := fileResumeData.BinaryMarshal() - - ft.TransferSize = offset - - replyT.Fields = append(replyT.Fields, hotline.NewField(hotline.FieldFileResumeData, b)) - } - } - - res = append(res, replyT) - return res -} - -// HandleSetClientUserInfo sets user preferences on the server. -// -// Fields used in the request: -// - 102 User name Optional -// - 104 User icon ID Optional -// - 113 Options Bitmap: Automatic response (4), Refuse private chat (2), Refuse private message (1) -// - 215 Automatic response Optional - Auto-response string if options field indicates this feature -// -// Reply is not expected. -func HandleSetClientUserInfo(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) { - if len(t.GetField(hotline.FieldUserIconID).Data) == 4 { - cc.Icon = t.GetField(hotline.FieldUserIconID).Data[2:] - } else { - cc.Icon = t.GetField(hotline.FieldUserIconID).Data - } - if cc.Authorize(hotline.AccessAnyName) { - oldNickname := string(cc.UserName) - newNickname := string(t.GetField(hotline.FieldUserName).Data) - cc.UserName = t.GetField(hotline.FieldUserName).Data - - login := cc.Account.Login - ip := cc.IP() - - if cc.Server.Redis != nil { - // Remove old entry (login:oldnickname:ip) and (login::ip) - cc.Server.Redis.SRem(context.Background(), hotline.RedisKeyOnline, login+"::"+ip) - if oldNickname != "" { - cc.Server.Redis.SRem(context.Background(), hotline.RedisKeyOnline, login+":"+oldNickname+":"+ip) - } - // Add new entry - cc.Server.Redis.SAdd(context.Background(), hotline.RedisKeyOnline, login+":"+newNickname+":"+ip) - } - - // Ban check for nickname - if cc.Server.BanList != nil && cc.Server.BanList.IsNicknameBanned(newNickname) { - if cc.Server.Redis != nil { - // Remove all possible online entries for this login and IP - cc.Server.Redis.SRem(context.Background(), hotline.RedisKeyOnline, login+"::"+ip) - cc.Server.Redis.SRem(context.Background(), hotline.RedisKeyOnline, login+":"+newNickname+":"+ip) - if oldNickname != "" { - cc.Server.Redis.SRem(context.Background(), hotline.RedisKeyOnline, login+":"+oldNickname+":"+ip) - } - } - if err := cc.Server.BanList.Add(ip, nil); err != nil { - cc.Logger.Error("Failed to ban IP for banned nickname", "ip", ip, "err", err) - } - cc.Disconnect() - // Connection is being torn down; no reply is sent to the client. - return res - } - } - - // the options field is only passed by the client versions > 1.2.3. - options := t.GetField(hotline.FieldOptions).Data - if options != nil { - optBitmap := big.NewInt(int64(binary.BigEndian.Uint16(options))) - - cc.Flags.Set(hotline.UserFlagRefusePM, optBitmap.Bit(hotline.UserOptRefusePM)) - cc.Flags.Set(hotline.UserFlagRefusePChat, optBitmap.Bit(hotline.UserOptRefuseChat)) - - // Check auto response - if optBitmap.Bit(hotline.UserOptAutoResponse) == 1 { - cc.AutoReply = t.GetField(hotline.FieldAutomaticResponse).Data - } else { - cc.AutoReply = []byte{} - } - } - - for _, c := range cc.Server.ClientMgr.List() { - res = append(res, hotline.NewTransaction( - hotline.TranNotifyChangeUser, - c.ID, - hotline.NewField(hotline.FieldUserID, cc.ID[:]), - hotline.NewField(hotline.FieldUserIconID, cc.Icon), - hotline.NewField(hotline.FieldUserFlags, cc.Flags[:]), - hotline.NewField(hotline.FieldUserName, cc.UserName), - )) - } - - return res -} - -// HandleKeepAlive responds to client keepalive messages to maintain the connection. -// -// Fields used in the request: None -// -// Fields used in the reply: None -func HandleKeepAlive(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) { - res = append(res, cc.NewReply(t)) - - return res -} - -// HandleGetFileNameList gets the list of file names from the specified folder. -// -// Fields used in the request: -// - 202 File path Optional - If not specified, root folder assumed -// -// Fields used in the reply: -// - 200 File name with info Repeated - File information for each item -func HandleGetFileNameList(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) { - fullPath, err := hotline.ReadPath( - cc.FileRoot(), - t.GetField(hotline.FieldFilePath).Data, - nil, - cc.TextDecoder(), - ) - if err != nil { - cc.Logger.Error("error reading file path", "err", err) - return cc.NewErrReply(t, "Cannot get file list.") - } - - var fp hotline.FilePath - if t.GetField(hotline.FieldFilePath).Data != nil { - if _, err = fp.Write(t.GetField(hotline.FieldFilePath).Data); err != nil { - cc.Logger.Error("error parsing file path", "err", err) - return cc.NewErrReply(t, "Cannot get file list.") - } - } - - // Handle special case for drop box folders - if fp.IsDropbox() && !cc.Authorize(hotline.AccessViewDropBoxes) { - return cc.NewErrReply(t, ErrMsgNotAllowedViewDropBoxes) - } - - fileNames, err := hotline.GetFileNameList(fullPath, cc.Server.Config.IgnoreFiles, cc.TextEncoder(), cc.Logger) - if err != nil { - cc.Logger.Error("error getting file name list", "err", err) - return cc.NewErrReply(t, "Cannot get file list.") - } - - res = append(res, cc.NewReply(t, fileNames...)) - - return res -} - -// ================================= -// Hotline private chat flow -// ================================= -// 1. ClientA sends TranInviteNewChat to server with user Type to invite -// 2. Server creates new ChatID -// 3. Server sends TranInviteToChat to invitee -// 4. Server replies to ClientA with new Chat Type -// -// A dialog box pops up in the invitee client with options to accept or decline the invitation. -// If Accepted is clicked: -// 1. ClientB sends TranJoinChat with FieldChatID - -// HandleInviteNewChat invites users to a new chat. -// -// Fields used in the request: -// - 103 User ID Optional - User IDs to invite -// -// Fields used in the reply: -// - 103 User ID Inviting user's ID -// - 104 User icon ID Inviting user's icon -// - 112 User flags Inviting user's flags -// - 102 User name Inviting user's name -// - 114 Chat ID New chat room identifier -func HandleInviteNewChat(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) { - if !cc.Authorize(hotline.AccessOpenChat) { - return cc.NewErrReply(t, ErrMsgNotAllowedRequestPrivateChat) - } - - // Client to Invite - targetID, ok := hotline.ClientIDFromBytes(t.GetField(hotline.FieldUserID).Data) - if !ok { - return cc.NewErrReply(t, ErrMsgInvalidUserID) - } - - // Check if target user has "Refuse private chat" flag - targetClient := cc.Server.ClientMgr.Get(targetID) - if targetClient == nil { - return cc.NewErrReply(t, ErrMsgUserNotFound) - } - - // Create a new chat with self as initial member. - newChatID := cc.Server.ChatMgr.New(cc) - - flagBitmap := big.NewInt(int64(binary.BigEndian.Uint16(targetClient.Flags[:]))) - if flagBitmap.Bit(hotline.UserFlagRefusePChat) == 1 { - res = append(res, - hotline.NewTransaction( - hotline.TranServerMsg, - cc.ID, - hotline.NewField(hotline.FieldData, []byte(fmt.Sprintf(ErrMsgDoesNotAcceptTemplate, string(targetClient.UserName), "private chats"))), - hotline.NewField(hotline.FieldUserName, targetClient.UserName), - hotline.NewField(hotline.FieldUserID, targetClient.ID[:]), - hotline.NewField(hotline.FieldOptions, []byte{0, 2}), - ), - ) - } else { - res = append(res, - hotline.NewTransaction( - hotline.TranInviteToChat, - targetID, - hotline.NewField(hotline.FieldChatID, newChatID[:]), - hotline.NewField(hotline.FieldUserName, cc.UserName), - hotline.NewField(hotline.FieldUserID, cc.ID[:]), - ), - ) - } - - return append( - res, - cc.NewReply(t, - hotline.NewField(hotline.FieldChatID, newChatID[:]), - hotline.NewField(hotline.FieldUserName, cc.UserName), - hotline.NewField(hotline.FieldUserID, cc.ID[:]), - hotline.NewField(hotline.FieldUserIconID, cc.Icon), - hotline.NewField(hotline.FieldUserFlags, cc.Flags[:]), - ), - ) -} - -// HandleInviteToChat invites a user to an existing chat. -// -// Fields used in the request: -// - 103 User ID Required - User to invite -// - 114 Chat ID Required -// -// Reply is not expected. -func HandleInviteToChat(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) { - if !cc.Authorize(hotline.AccessOpenChat) { - return cc.NewErrReply(t, ErrMsgNotAllowedRequestPrivateChat) - } - - // Client to Invite - targetID, ok := hotline.ClientIDFromBytes(t.GetField(hotline.FieldUserID).Data) - if !ok { - return cc.NewErrReply(t, ErrMsgInvalidUserID) - } - chatID := t.GetField(hotline.FieldChatID).Data - - return []hotline.Transaction{ - hotline.NewTransaction( - hotline.TranInviteToChat, - targetID, - hotline.NewField(hotline.FieldChatID, chatID), - hotline.NewField(hotline.FieldUserName, cc.UserName), - hotline.NewField(hotline.FieldUserID, cc.ID[:]), - ), - cc.NewReply( - t, - hotline.NewField(hotline.FieldChatID, chatID), - hotline.NewField(hotline.FieldUserName, cc.UserName), - hotline.NewField(hotline.FieldUserID, cc.ID[:]), - hotline.NewField(hotline.FieldUserIconID, cc.Icon), - hotline.NewField(hotline.FieldUserFlags, cc.Flags[:]), - ), - } -} - -// HandleRejectChatInvite rejects an invitation to join a chat. -// -// Fields used in the request: -// - 114 Chat ID Required -// -// Reply is not expected. -func HandleRejectChatInvite(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) { - chatID, ok := hotline.ChatIDFromBytes(t.GetField(hotline.FieldChatID).Data) - if !ok { - cc.Logger.Error("reject chat invite: invalid chat ID") - return res - } - - for _, c := range cc.Server.ChatMgr.Members(chatID) { - res = append(res, - hotline.NewTransaction( - hotline.TranChatMsg, - c.ID, - hotline.NewField(hotline.FieldChatID, chatID[:]), - hotline.NewField(hotline.FieldData, append(cc.UserName, []byte(" declined invitation to chat")...)), - ), - ) - } - - return res -} - -// HandleJoinChat joins a chat. -// -// Fields used in the request: -// - 114 Chat ID Required -// -// Fields used in the reply: -// - 115 Chat subject Current chat room subject -// - 300 User name with info Repeated - User information for each chat member -func HandleJoinChat(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) { - chatID, ok := hotline.ChatIDFromBytes(t.GetField(hotline.FieldChatID).Data) - if !ok { - return cc.NewErrReply(t, ErrMsgInvalidChatID) - } - - // Send TranNotifyChatChangeUser to current members of the chat to inform of new user - for _, c := range cc.Server.ChatMgr.Members(chatID) { - res = append(res, - hotline.NewTransaction( - hotline.TranNotifyChatChangeUser, - c.ID, - hotline.NewField(hotline.FieldChatID, chatID[:]), - hotline.NewField(hotline.FieldUserName, cc.UserName), - hotline.NewField(hotline.FieldUserID, cc.ID[:]), - hotline.NewField(hotline.FieldUserIconID, cc.Icon), - hotline.NewField(hotline.FieldUserFlags, cc.Flags[:]), - ), - ) - } - - cc.Server.ChatMgr.Join(chatID, cc) - - subject := cc.Server.ChatMgr.GetSubject(chatID) - - replyFields := []hotline.Field{hotline.NewField(hotline.FieldChatSubject, []byte(subject))} - for _, c := range cc.Server.ChatMgr.Members(chatID) { - b, err := io.ReadAll(&hotline.User{ - ID: c.ID, - Icon: c.Icon, - Flags: c.Flags[:], - Name: string(c.UserName), - }) - if err != nil { - cc.Logger.Error("join chat: read member info", "err", err) - return cc.NewErrReply(t, ErrMsgJoinChat) - } - replyFields = append(replyFields, hotline.NewField(hotline.FieldUsernameWithInfo, b)) - } - - return append(res, cc.NewReply(t, replyFields...)) -} - -// HandleLeaveChat leaves a chat. -// -// Fields used in the request: -// - 114 Chat ID Required -// -// Reply is not expected. -func HandleLeaveChat(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) { - chatID, ok := hotline.ChatIDFromBytes(t.GetField(hotline.FieldChatID).Data) - if !ok { - cc.Logger.Error("leave chat: invalid chat ID") - return res - } - - cc.Server.ChatMgr.Leave(chatID, cc.ID) - - // Notify members of the private chat that the user has left - for _, c := range cc.Server.ChatMgr.Members(chatID) { - res = append(res, - hotline.NewTransaction( - hotline.TranNotifyChatDeleteUser, - c.ID, - hotline.NewField(hotline.FieldChatID, chatID[:]), - hotline.NewField(hotline.FieldUserID, cc.ID[:]), - ), - ) - } - - return res -} - -// HandleSetChatSubject sets the chat subject for a chat. -// -// Fields used in the request: -// - 114 Chat ID Required -// - 115 Chat subject Required - Chat subject string -// -// Reply is not expected. -func HandleSetChatSubject(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) { - chatID, ok := hotline.ChatIDFromBytes(t.GetField(hotline.FieldChatID).Data) - if !ok { - cc.Logger.Error("set chat subject: invalid chat ID") - return res - } - - cc.Server.ChatMgr.SetSubject(chatID, string(t.GetField(hotline.FieldChatSubject).Data)) - - // Notify chat members of new subject. - for _, c := range cc.Server.ChatMgr.Members(chatID) { - res = append(res, - hotline.NewTransaction( - hotline.TranNotifyChatSubject, - c.ID, - hotline.NewField(hotline.FieldChatID, chatID[:]), - hotline.NewField(hotline.FieldChatSubject, t.GetField(hotline.FieldChatSubject).Data), - ), - ) - } - - return res -} - -// HandleMakeAlias makes a file alias using the specified path. -// -// Access: Make Alias (31) -// -// Fields used in the request: -// - 201 File name Required -// - 202 File path Required -// - 212 File new path Required - Destination path -// -// Fields used in the reply: None -func HandleMakeAlias(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) { - if !cc.Authorize(hotline.AccessMakeAlias) { - return cc.NewErrReply(t, ErrMsgNotAllowedMakeAliases) - } - fileName := t.GetField(hotline.FieldFileName).Data - filePath := t.GetField(hotline.FieldFilePath).Data - fileNewPath := t.GetField(hotline.FieldFileNewPath).Data - - fullFilePath, err := hotline.ReadPath(cc.FileRoot(), filePath, fileName, cc.TextDecoder()) - if err != nil { - cc.Logger.Error("make alias: read source path", "err", err) - return cc.NewErrReply(t, ErrMsgFileNotFound) - } - - fullNewFilePath, err := hotline.ReadPath(cc.FileRoot(), fileNewPath, fileName, cc.TextDecoder()) - if err != nil { - cc.Logger.Error("make alias: read destination path", "err", err) - return cc.NewErrReply(t, ErrMsgFileNotFound) - } - - if err := cc.Server.FS.Symlink(fullFilePath, fullNewFilePath); err != nil { - return cc.NewErrReply(t, ErrMsgCreateAlias) - } - - res = append(res, cc.NewReply(t)) - return res -} - -// HandleDownloadBanner requests a new banner from the server. -// -// Fields used in the request: None -// -// Fields used in the reply: -// - 107 Reference number Used later for transfer -// - 108 Transfer size Size of data to be downloaded -func HandleDownloadBanner(cc *hotline.ClientConn, t *hotline.Transaction) (res []hotline.Transaction) { - ft := cc.NewFileTransfer(hotline.BannerDownload, "", []byte{}, []byte{}, make([]byte, 4)) - binary.BigEndian.PutUint32(ft.TransferSize, uint32(len(cc.Server.Banner))) - - return append(res, cc.NewReply(t, - hotline.NewField(hotline.FieldRefNum, ft.RefNum[:]), - hotline.NewField(hotline.FieldTransferSize, ft.TransferSize), - )) -} diff --git a/internal/mobius/transaction_handlers_test.go b/internal/mobius/transaction_handlers_test.go index 3d9b95a..58bb01f 100644 --- a/internal/mobius/transaction_handlers_test.go +++ b/internal/mobius/transaction_handlers_test.go @@ -1,3007 +1,12 @@ package mobius import ( - "cmp" - "encoding/binary" - "errors" - "fmt" - "io" - "io/fs" - "log/slog" - "os" - "path/filepath" - "slices" - "strings" "testing" - "time" "github.com/jhalter/mobius/hotline" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/mock" - "golang.org/x/text/encoding/charmap" ) -type mockReadWriteSeeker struct { - mock.Mock -} - -func (m *mockReadWriteSeeker) Read(p []byte) (int, error) { - args := m.Called(p) - - return args.Int(0), args.Error(1) -} - -func (m *mockReadWriteSeeker) Write(p []byte) (int, error) { - args := m.Called(p) - - return args.Int(0), args.Error(1) -} - -func (m *mockReadWriteSeeker) Seek(offset int64, whence int) (int64, error) { - args := m.Called(offset, whence) - - 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)) -} - -var tranSortFunc = func(a, b hotline.Transaction) int { - return cmp.Compare( - binary.BigEndian.Uint16(a.ClientID[:]), - binary.BigEndian.Uint16(b.ClientID[:]), - ) -} - -// TranAssertEqual compares equality of transactions slices after stripping out the random transaction Type -func TranAssertEqual(t *testing.T, tran1, tran2 []hotline.Transaction) bool { - var newT1 []hotline.Transaction - var newT2 []hotline.Transaction - - for _, trans := range tran1 { - trans.ID = [4]byte{0, 0, 0, 0} - var fs []hotline.Field - for _, field := range trans.Fields { - if field.Type == hotline.FieldRefNum { // FieldRefNum - continue - } - if field.Type == hotline.FieldChatID { // FieldChatID - continue - } - - fs = append(fs, field) - } - trans.Fields = fs - newT1 = append(newT1, trans) - } - - for _, trans := range tran2 { - trans.ID = [4]byte{0, 0, 0, 0} - var fs []hotline.Field - for _, field := range trans.Fields { - if field.Type == hotline.FieldRefNum { // FieldRefNum - continue - } - if field.Type == hotline.FieldChatID { // FieldChatID - continue - } - - fs = append(fs, field) - } - trans.Fields = fs - newT2 = append(newT2, trans) - } - - slices.SortFunc(newT1, tranSortFunc) - slices.SortFunc(newT2, tranSortFunc) - - return assert.Equal(t, newT1, newT2) -} - -func TestHandleSetChatSubject(t *testing.T) { - type args struct { - cc *hotline.ClientConn - t hotline.Transaction - } - tests := []struct { - name string - args args - want []hotline.Transaction - }{ - { - name: "sends chat subject to private chat members", - args: args{ - cc: &hotline.ClientConn{ - UserName: []byte{0x00, 0x01}, - Server: &hotline.Server{ - TextDecoder: charmap.Macintosh.NewDecoder(), - TextEncoder: charmap.Macintosh.NewEncoder(), - ChatMgr: func() *hotline.MockChatManager { - m := hotline.MockChatManager{} - m.On("Members", hotline.ChatID{0x0, 0x0, 0x0, 0x1}).Return([]*hotline.ClientConn{ - { - Account: &hotline.Account{ - Access: hotline.AccessBitmap{255, 255, 255, 255, 255, 255, 255, 255}, - }, - ID: [2]byte{0, 1}, - }, - { - Account: &hotline.Account{ - Access: hotline.AccessBitmap{255, 255, 255, 255, 255, 255, 255, 255}, - }, - ID: [2]byte{0, 2}, - }, - }) - m.On("SetSubject", hotline.ChatID{0x0, 0x0, 0x0, 0x1}, "Test Subject") - return &m - }(), - //PrivateChats: map[[4]byte]*PrivateChat{ - // [4]byte{0, 0, 0, 1}: { - // Subject: "unset", - // ClientConn: map[[2]byte]*ClientConn{ - // [2]byte{0, 1}: { - // Account: &hotline.Account{ - // Access: AccessBitmap{255, 255, 255, 255, 255, 255, 255, 255}, - // }, - // ID: [2]byte{0, 1}, - // }, - // [2]byte{0, 2}: { - // Account: &hotline.Account{ - // Access: AccessBitmap{255, 255, 255, 255, 255, 255, 255, 255}, - // }, - // ID: [2]byte{0, 2}, - // }, - // }, - // }, - //}, - ClientMgr: func() *hotline.MockClientMgr { - m := hotline.MockClientMgr{} - m.On("List").Return([]*hotline.ClientConn{ - { - Account: &hotline.Account{ - Access: hotline.AccessBitmap{255, 255, 255, 255, 255, 255, 255, 255}, - }, - ID: [2]byte{0, 1}, - }, - { - Account: &hotline.Account{ - Access: hotline.AccessBitmap{255, 255, 255, 255, 255, 255, 255, 255}, - }, - ID: [2]byte{0, 2}, - }, - }, - ) - return &m - }(), - }, - }, - t: hotline.Transaction{ - Type: [2]byte{0, 0x6a}, - ID: [4]byte{0, 0, 0, 1}, - Fields: []hotline.Field{ - hotline.NewField(hotline.FieldChatID, []byte{0, 0, 0, 1}), - hotline.NewField(hotline.FieldChatSubject, []byte("Test Subject")), - }, - }, - }, - want: []hotline.Transaction{ - { - ClientID: [2]byte{0, 1}, - Type: [2]byte{0, 0x77}, - Fields: []hotline.Field{ - hotline.NewField(hotline.FieldChatID, []byte{0, 0, 0, 1}), - hotline.NewField(hotline.FieldChatSubject, []byte("Test Subject")), - }, - }, - { - ClientID: [2]byte{0, 2}, - Type: [2]byte{0, 0x77}, - Fields: []hotline.Field{ - hotline.NewField(hotline.FieldChatID, []byte{0, 0, 0, 1}), - hotline.NewField(hotline.FieldChatSubject, []byte("Test Subject")), - }, - }, - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := HandleSetChatSubject(tt.args.cc, &tt.args.t) - if !TranAssertEqual(t, tt.want, got) { - t.Errorf("HandleSetChatSubject() got = %v, want %v", got, tt.want) - } - }) - } -} - -func TestHandleLeaveChat(t *testing.T) { - type args struct { - cc *hotline.ClientConn - t hotline.Transaction - } - tests := []struct { - name string - args args - want []hotline.Transaction - }{ - { - name: "when client 2 leaves chat", - args: args{ - cc: &hotline.ClientConn{ - ID: [2]byte{0, 2}, - Server: &hotline.Server{ - TextDecoder: charmap.Macintosh.NewDecoder(), - TextEncoder: charmap.Macintosh.NewEncoder(), - ChatMgr: func() *hotline.MockChatManager { - m := hotline.MockChatManager{} - m.On("Members", hotline.ChatID{0x0, 0x0, 0x0, 0x1}).Return([]*hotline.ClientConn{ - { - Account: &hotline.Account{ - Access: hotline.AccessBitmap{255, 255, 255, 255, 255, 255, 255, 255}, - }, - ID: [2]byte{0, 1}, - }, - }) - m.On("Leave", hotline.ChatID{0x0, 0x0, 0x0, 0x1}, [2]uint8{0x0, 0x2}) - m.On("GetSubject").Return("unset") - return &m - }(), - ClientMgr: func() *hotline.MockClientMgr { - m := hotline.MockClientMgr{} - m.On("Get").Return([]*hotline.ClientConn{ - { - Account: &hotline.Account{ - Access: hotline.AccessBitmap{255, 255, 255, 255, 255, 255, 255, 255}, - }, - ID: [2]byte{0, 1}, - }, - { - Account: &hotline.Account{ - Access: hotline.AccessBitmap{255, 255, 255, 255, 255, 255, 255, 255}, - }, - ID: [2]byte{0, 2}, - }, - }, - ) - return &m - }(), - }, - }, - t: hotline.NewTransaction(hotline.TranDeleteUser, [2]byte{}, hotline.NewField(hotline.FieldChatID, []byte{0, 0, 0, 1})), - }, - want: []hotline.Transaction{ - { - ClientID: [2]byte{0, 1}, - Type: [2]byte{0, 0x76}, - Fields: []hotline.Field{ - hotline.NewField(hotline.FieldChatID, []byte{0, 0, 0, 1}), - hotline.NewField(hotline.FieldUserID, []byte{0, 2}), - }, - }, - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := HandleLeaveChat(tt.args.cc, &tt.args.t) - if !TranAssertEqual(t, tt.want, got) { - t.Errorf("HandleLeaveChat() got = %v, want %v", got, tt.want) - } - }) - } -} - -func TestHandleGetUserNameList(t *testing.T) { - type args struct { - cc *hotline.ClientConn - t hotline.Transaction - } - tests := []struct { - name string - args args - want []hotline.Transaction - }{ - { - name: "replies with userlist transaction", - args: args{ - cc: &hotline.ClientConn{ - ID: [2]byte{0, 1}, - Server: &hotline.Server{ - TextDecoder: charmap.Macintosh.NewDecoder(), - TextEncoder: charmap.Macintosh.NewEncoder(), - ClientMgr: func() *hotline.MockClientMgr { - m := hotline.MockClientMgr{} - m.On("List").Return([]*hotline.ClientConn{ - { - ID: [2]byte{0, 1}, - Icon: []byte{0, 2}, - Flags: [2]byte{0, 3}, - UserName: []byte{0, 4}, - }, - { - ID: [2]byte{0, 2}, - Icon: []byte{0, 2}, - Flags: [2]byte{0, 3}, - UserName: []byte{0, 4}, - }, - }, - ) - return &m - }(), - }, - }, - t: hotline.Transaction{}, - }, - want: []hotline.Transaction{ - { - ClientID: [2]byte{0, 1}, - IsReply: 0x01, - Fields: []hotline.Field{ - hotline.NewField( - hotline.FieldUsernameWithInfo, - []byte{00, 01, 00, 02, 00, 03, 00, 02, 00, 04}, - ), - hotline.NewField( - hotline.FieldUsernameWithInfo, - []byte{00, 02, 00, 02, 00, 03, 00, 02, 00, 04}, - ), - }, - }, - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := HandleGetUserNameList(tt.args.cc, &tt.args.t) - assert.Equal(t, tt.want, got) - }) - } -} - -func TestHandleChatSend(t *testing.T) { - type args struct { - cc *hotline.ClientConn - t hotline.Transaction - } - tests := []struct { - name string - args args - want []hotline.Transaction - }{ - { - name: "sends chat msg transaction to all clients", - args: args{ - cc: &hotline.ClientConn{ - Account: &hotline.Account{ - Access: func() hotline.AccessBitmap { - var bits hotline.AccessBitmap - bits.Set(hotline.AccessSendChat) - return bits - }(), - }, - UserName: []byte{0x00, 0x01}, - Server: &hotline.Server{ - TextDecoder: charmap.Macintosh.NewDecoder(), - TextEncoder: charmap.Macintosh.NewEncoder(), - ClientMgr: func() *hotline.MockClientMgr { - m := hotline.MockClientMgr{} - m.On("List").Return([]*hotline.ClientConn{ - { - Account: &hotline.Account{ - Access: hotline.AccessBitmap{255, 255, 255, 255, 255, 255, 255, 255}, - }, - ID: [2]byte{0, 1}, - }, - { - Account: &hotline.Account{ - Access: hotline.AccessBitmap{255, 255, 255, 255, 255, 255, 255, 255}, - }, - ID: [2]byte{0, 2}, - }, - }, - ) - return &m - }(), - }, - }, - t: hotline.Transaction{ - Fields: []hotline.Field{ - hotline.NewField(hotline.FieldData, []byte("hai")), - }, - }, - }, - want: []hotline.Transaction{ - { - ClientID: [2]byte{0, 1}, - Flags: 0x00, - IsReply: 0x00, - Type: [2]byte{0, 0x6a}, - Fields: []hotline.Field{ - hotline.NewField(hotline.FieldData, []byte{0x0d, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x00, 0x01, 0x3a, 0x20, 0x20, 0x68, 0x61, 0x69}), - }, - }, - { - ClientID: [2]byte{0, 2}, - Flags: 0x00, - IsReply: 0x00, - Type: [2]byte{0, 0x6a}, - Fields: []hotline.Field{ - hotline.NewField(hotline.FieldData, []byte{0x0d, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x00, 0x01, 0x3a, 0x20, 0x20, 0x68, 0x61, 0x69}), - }, - }, - }, - }, - { - name: "treats Chat Type 00 00 00 00 as a public chat message", - args: args{ - cc: &hotline.ClientConn{ - Account: &hotline.Account{ - Access: func() hotline.AccessBitmap { - var bits hotline.AccessBitmap - bits.Set(hotline.AccessSendChat) - return bits - }(), - }, - UserName: []byte{0x00, 0x01}, - Server: &hotline.Server{ - TextDecoder: charmap.Macintosh.NewDecoder(), - TextEncoder: charmap.Macintosh.NewEncoder(), - ClientMgr: func() *hotline.MockClientMgr { - m := hotline.MockClientMgr{} - m.On("List").Return([]*hotline.ClientConn{ - { - Account: &hotline.Account{ - Access: hotline.AccessBitmap{255, 255, 255, 255, 255, 255, 255, 255}, - }, - ID: [2]byte{0, 1}, - }, - { - Account: &hotline.Account{ - Access: hotline.AccessBitmap{255, 255, 255, 255, 255, 255, 255, 255}, - }, - ID: [2]byte{0, 2}, - }, - }, - ) - return &m - }(), - }, - }, - t: hotline.Transaction{ - Fields: []hotline.Field{ - hotline.NewField(hotline.FieldData, []byte("hai")), - hotline.NewField(hotline.FieldChatID, []byte{0, 0, 0, 0}), - }, - }, - }, - want: []hotline.Transaction{ - { - ClientID: [2]byte{0, 1}, - Type: [2]byte{0, 0x6a}, - Fields: []hotline.Field{ - hotline.NewField(hotline.FieldData, []byte{0x0d, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x00, 0x01, 0x3a, 0x20, 0x20, 0x68, 0x61, 0x69}), - }, - }, - { - ClientID: [2]byte{0, 2}, - Type: [2]byte{0, 0x6a}, - Fields: []hotline.Field{ - hotline.NewField(hotline.FieldData, []byte{0x0d, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x00, 0x01, 0x3a, 0x20, 0x20, 0x68, 0x61, 0x69}), - }, - }, - }, - }, - { - name: "when user does not have required permission", - args: args{ - cc: &hotline.ClientConn{ - Account: &hotline.Account{ - Access: func() hotline.AccessBitmap { - var bits hotline.AccessBitmap - return bits - }(), - }, - Server: &hotline.Server{ - TextDecoder: charmap.Macintosh.NewDecoder(), - TextEncoder: charmap.Macintosh.NewEncoder(), - //Accounts: map[string]*Account{}, - }, - }, - t: hotline.NewTransaction( - hotline.TranChatSend, [2]byte{0, 1}, - hotline.NewField(hotline.FieldData, []byte("hai")), - ), - }, - want: []hotline.Transaction{ - { - IsReply: 0x01, - ErrorCode: [4]byte{0, 0, 0, 1}, - Fields: []hotline.Field{ - hotline.NewField(hotline.FieldError, []byte("You are not allowed to participate in chat.")), - }, - }, - }, - }, - { - name: "sends chat msg as emote if FieldChatOptions is set to 1", - args: args{ - cc: &hotline.ClientConn{ - Account: &hotline.Account{ - Access: func() hotline.AccessBitmap { - var bits hotline.AccessBitmap - bits.Set(hotline.AccessSendChat) - return bits - }(), - }, - UserName: []byte("Testy McTest"), - Server: &hotline.Server{ - TextDecoder: charmap.Macintosh.NewDecoder(), - TextEncoder: charmap.Macintosh.NewEncoder(), - ClientMgr: func() *hotline.MockClientMgr { - m := hotline.MockClientMgr{} - m.On("List").Return([]*hotline.ClientConn{ - { - Account: &hotline.Account{ - Access: hotline.AccessBitmap{255, 255, 255, 255, 255, 255, 255, 255}, - }, - ID: [2]byte{0, 1}, - }, - { - Account: &hotline.Account{ - Access: hotline.AccessBitmap{255, 255, 255, 255, 255, 255, 255, 255}, - }, - ID: [2]byte{0, 2}, - }, - }, - ) - return &m - }(), - }, - }, - t: hotline.Transaction{ - Fields: []hotline.Field{ - hotline.NewField(hotline.FieldData, []byte("performed action")), - hotline.NewField(hotline.FieldChatOptions, []byte{0x00, 0x01}), - }, - }, - }, - want: []hotline.Transaction{ - { - ClientID: [2]byte{0, 1}, - Flags: 0x00, - IsReply: 0x00, - Type: [2]byte{0, 0x6a}, - Fields: []hotline.Field{ - hotline.NewField(hotline.FieldData, []byte("\r*** Testy McTest performed action")), - }, - }, - { - ClientID: [2]byte{0, 2}, - Flags: 0x00, - IsReply: 0x00, - Type: [2]byte{0, 0x6a}, - Fields: []hotline.Field{ - hotline.NewField(hotline.FieldData, []byte("\r*** Testy McTest performed action")), - }, - }, - }, - }, - { - name: "does not send chat msg as emote if FieldChatOptions is set to 0", - args: args{ - cc: &hotline.ClientConn{ - Account: &hotline.Account{ - Access: func() hotline.AccessBitmap { - var bits hotline.AccessBitmap - bits.Set(hotline.AccessSendChat) - return bits - }(), - }, - UserName: []byte("Testy McTest"), - Server: &hotline.Server{ - TextDecoder: charmap.Macintosh.NewDecoder(), - TextEncoder: charmap.Macintosh.NewEncoder(), - ClientMgr: func() *hotline.MockClientMgr { - m := hotline.MockClientMgr{} - m.On("List").Return([]*hotline.ClientConn{ - { - Account: &hotline.Account{ - Access: hotline.AccessBitmap{255, 255, 255, 255, 255, 255, 255, 255}, - }, - ID: [2]byte{0, 1}, - }, - { - Account: &hotline.Account{ - Access: hotline.AccessBitmap{255, 255, 255, 255, 255, 255, 255, 255}, - }, - ID: [2]byte{0, 2}, - }, - }, - ) - return &m - }(), - }, - }, - t: hotline.Transaction{ - Fields: []hotline.Field{ - hotline.NewField(hotline.FieldData, []byte("hello")), - hotline.NewField(hotline.FieldChatOptions, []byte{0x00, 0x00}), - }, - }, - }, - want: []hotline.Transaction{ - { - ClientID: [2]byte{0, 1}, - Type: [2]byte{0, 0x6a}, - Fields: []hotline.Field{ - hotline.NewField(hotline.FieldData, []byte("\r Testy McTest: hello")), - }, - }, - { - ClientID: [2]byte{0, 2}, - Type: [2]byte{0, 0x6a}, - Fields: []hotline.Field{ - hotline.NewField(hotline.FieldData, []byte("\r Testy McTest: hello")), - }, - }, - }, - }, - { - name: "only sends chat msg to clients with AccessReadChat permission", - args: args{ - cc: &hotline.ClientConn{ - Account: &hotline.Account{ - Access: func() hotline.AccessBitmap { - var bits hotline.AccessBitmap - bits.Set(hotline.AccessSendChat) - return bits - }(), - }, - UserName: []byte{0x00, 0x01}, - Server: &hotline.Server{ - TextDecoder: charmap.Macintosh.NewDecoder(), - TextEncoder: charmap.Macintosh.NewEncoder(), - ClientMgr: func() *hotline.MockClientMgr { - m := hotline.MockClientMgr{} - m.On("List").Return([]*hotline.ClientConn{ - { - Account: &hotline.Account{ - Access: func() hotline.AccessBitmap { - var bits hotline.AccessBitmap - bits.Set(hotline.AccessReadChat) - return bits - }(), - }, - ID: [2]byte{0, 1}, - }, - { - Account: &hotline.Account{}, - ID: [2]byte{0, 2}, - }, - }, - ) - return &m - }(), - }, - }, - t: hotline.Transaction{ - Fields: []hotline.Field{ - hotline.NewField(hotline.FieldData, []byte("hai")), - }, - }, - }, - want: []hotline.Transaction{ - { - ClientID: [2]byte{0, 1}, - Type: [2]byte{0, 0x6a}, - Fields: []hotline.Field{ - hotline.NewField(hotline.FieldData, []byte{0x0d, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x00, 0x01, 0x3a, 0x20, 0x20, 0x68, 0x61, 0x69}), - }, - }, - }, - }, - { - name: "only sends private chat msg to members of private chat", - args: args{ - cc: &hotline.ClientConn{ - Account: &hotline.Account{ - Access: func() hotline.AccessBitmap { - var bits hotline.AccessBitmap - bits.Set(hotline.AccessSendChat) - return bits - }(), - }, - UserName: []byte{0x00, 0x01}, - Server: &hotline.Server{ - TextDecoder: charmap.Macintosh.NewDecoder(), - TextEncoder: charmap.Macintosh.NewEncoder(), - ChatMgr: func() *hotline.MockChatManager { - m := hotline.MockChatManager{} - m.On("Members", hotline.ChatID{0x0, 0x0, 0x0, 0x1}).Return([]*hotline.ClientConn{ - { - ID: [2]byte{0, 1}, - }, - { - ID: [2]byte{0, 2}, - }, - }) - m.On("GetSubject").Return("unset") - return &m - }(), - ClientMgr: func() *hotline.MockClientMgr { - m := hotline.MockClientMgr{} - m.On("List").Return([]*hotline.ClientConn{ - { - Account: &hotline.Account{ - Access: hotline.AccessBitmap{255, 255, 255, 255, 255, 255, 255, 255}, - }, - ID: [2]byte{0, 1}, - }, - { - Account: &hotline.Account{ - Access: hotline.AccessBitmap{0, 0, 0, 0, 0, 0, 0, 0}, - }, - ID: [2]byte{0, 2}, - }, - { - Account: &hotline.Account{ - Access: hotline.AccessBitmap{0, 0, 0, 0, 0, 0, 0, 0}, - }, - ID: [2]byte{0, 3}, - }, - }, - ) - return &m - }(), - }, - }, - t: hotline.Transaction{ - Fields: []hotline.Field{ - hotline.NewField(hotline.FieldData, []byte("hai")), - hotline.NewField(hotline.FieldChatID, []byte{0, 0, 0, 1}), - }, - }, - }, - want: []hotline.Transaction{ - { - ClientID: [2]byte{0, 1}, - Type: [2]byte{0, 0x6a}, - Fields: []hotline.Field{ - hotline.NewField(hotline.FieldChatID, []byte{0, 0, 0, 1}), - hotline.NewField(hotline.FieldData, []byte{0x0d, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x00, 0x01, 0x3a, 0x20, 0x20, 0x68, 0x61, 0x69}), - }, - }, - { - ClientID: [2]byte{0, 2}, - Type: [2]byte{0, 0x6a}, - Fields: []hotline.Field{ - hotline.NewField(hotline.FieldChatID, []byte{0, 0, 0, 1}), - hotline.NewField(hotline.FieldData, []byte{0x0d, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x00, 0x01, 0x3a, 0x20, 0x20, 0x68, 0x61, 0x69}), - }, - }, - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := HandleChatSend(tt.args.cc, &tt.args.t) - TranAssertEqual(t, tt.want, got) - }) - } -} - -func TestHandleGetFileInfo(t *testing.T) { - type args struct { - cc *hotline.ClientConn - t hotline.Transaction - } - tests := []struct { - name string - args args - wantRes []hotline.Transaction - }{ - { - name: "returns expected fields when a valid file is requested", - args: args{ - cc: &hotline.ClientConn{ - ID: [2]byte{0, 1}, - Account: &hotline.Account{}, - Server: &hotline.Server{ - TextDecoder: charmap.Macintosh.NewDecoder(), - TextEncoder: charmap.Macintosh.NewEncoder(), - FS: &hotline.OSFileStore{}, - Config: hotline.Config{ - FileRoot: func() string { - path, _ := os.Getwd() - return filepath.Join(path, "/test/config/Files") - }(), - }, - }, - }, - t: hotline.NewTransaction( - hotline.TranGetFileInfo, [2]byte{}, - hotline.NewField(hotline.FieldFileName, []byte("testfile.txt")), - hotline.NewField(hotline.FieldFilePath, []byte{0x00, 0x00}), - ), - }, - wantRes: []hotline.Transaction{ - { - ClientID: [2]byte{0, 1}, - IsReply: 0x01, - Type: [2]byte{0, 0}, - Fields: []hotline.Field{ - hotline.NewField(hotline.FieldFileName, []byte("testfile.txt")), - hotline.NewField(hotline.FieldFileTypeString, []byte("Text File")), - hotline.NewField(hotline.FieldFileCreatorString, []byte("ttxt")), - hotline.NewField(hotline.FieldFileType, []byte("TEXT")), - hotline.NewField(hotline.FieldFileCreateDate, make([]byte, 8)), - hotline.NewField(hotline.FieldFileModifyDate, make([]byte, 8)), - hotline.NewField(hotline.FieldFileSize, []byte{0x0, 0x0, 0x0, 0x17}), - }, - }, - }, - }, - { - name: "returns an error reply when the file path cannot be parsed", - args: args{ - cc: &hotline.ClientConn{ - ID: [2]byte{0, 1}, - Account: &hotline.Account{}, - Logger: NewTestLogger(), - Server: &hotline.Server{ - TextDecoder: charmap.Macintosh.NewDecoder(), - TextEncoder: charmap.Macintosh.NewEncoder(), - FS: &hotline.OSFileStore{}, - Config: hotline.Config{ - FileRoot: func() string { - path, _ := os.Getwd() - return filepath.Join(path, "/test/config/Files") - }(), - }, - }, - }, - t: hotline.NewTransaction( - hotline.TranGetFileInfo, [2]byte{}, - hotline.NewField(hotline.FieldFileName, []byte("testfile.txt")), - // Malformed path: claims 1 item but supplies fewer than the - // 3 bytes a path item requires, so ReadPath fails to parse it. - hotline.NewField(hotline.FieldFilePath, []byte{0x00, 0x01, 0x00, 0x00}), - ), - }, - wantRes: []hotline.Transaction{ - { - ClientID: [2]byte{0, 1}, - IsReply: 0x01, - ErrorCode: [4]byte{0, 0, 0, 1}, - Fields: []hotline.Field{ - hotline.NewField(hotline.FieldError, []byte(ErrMsgFileNotFound)), - }, - }, - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - gotRes := HandleGetFileInfo(tt.args.cc, &tt.args.t) - - // Clear the file timestamp fields to work around problems running the tests in multiple timezones - // TODO: revisit how to test this by mocking the stat calls - if len(gotRes) > 0 && gotRes[0].ErrorCode == [4]byte{} { - gotRes[0].Fields[4].Data = make([]byte, 8) - gotRes[0].Fields[5].Data = make([]byte, 8) - } - - if !TranAssertEqual(t, tt.wantRes, gotRes) { - t.Errorf("HandleGetFileInfo() gotRes = %v, want %v", gotRes, tt.wantRes) - } - }) - } -} - -func TestHandleNewFolder(t *testing.T) { - type args struct { - cc *hotline.ClientConn - t hotline.Transaction - } - tests := []struct { - name string - args args - wantRes []hotline.Transaction - }{ - { - name: "without required permission", - args: args{ - cc: &hotline.ClientConn{ - Account: &hotline.Account{ - Access: func() hotline.AccessBitmap { - var bits hotline.AccessBitmap - return bits - }(), - }, - }, - t: hotline.NewTransaction( - hotline.TranNewFolder, - [2]byte{0, 0}, - ), - }, - wantRes: []hotline.Transaction{ - { - IsReply: 0x01, - ErrorCode: [4]byte{0, 0, 0, 1}, - Fields: []hotline.Field{ - hotline.NewField(hotline.FieldError, []byte("You are not allowed to create folders.")), - }, - }, - }, - }, - { - name: "when path is nested", - args: args{ - cc: &hotline.ClientConn{ - Account: &hotline.Account{ - Access: func() hotline.AccessBitmap { - var bits hotline.AccessBitmap - bits.Set(hotline.AccessCreateFolder) - return bits - }(), - }, - ID: [2]byte{0, 1}, - Server: &hotline.Server{ - TextDecoder: charmap.Macintosh.NewDecoder(), - TextEncoder: charmap.Macintosh.NewEncoder(), - Config: hotline.Config{ - FileRoot: "/Files/", - }, - FS: func() *hotline.MockFileStore { - mfs := &hotline.MockFileStore{} - mfs.On("Mkdir", "/Files/aaa/testFolder", fs.FileMode(0777)).Return(nil) - mfs.On("Stat", "/Files/aaa/testFolder").Return(nil, os.ErrNotExist) - return mfs - }(), - }, - }, - t: hotline.NewTransaction( - hotline.TranNewFolder, [2]byte{0, 1}, - hotline.NewField(hotline.FieldFileName, []byte("testFolder")), - hotline.NewField(hotline.FieldFilePath, []byte{ - 0x00, 0x01, - 0x00, 0x00, - 0x03, - 0x61, 0x61, 0x61, - }), - ), - }, - wantRes: []hotline.Transaction{ - { - ClientID: [2]byte{0, 1}, - IsReply: 0x01, - }, - }, - }, - { - name: "when path is not nested", - args: args{ - cc: &hotline.ClientConn{ - Account: &hotline.Account{ - Access: func() hotline.AccessBitmap { - var bits hotline.AccessBitmap - bits.Set(hotline.AccessCreateFolder) - return bits - }(), - }, - ID: [2]byte{0, 1}, - Server: &hotline.Server{ - TextDecoder: charmap.Macintosh.NewDecoder(), - TextEncoder: charmap.Macintosh.NewEncoder(), - Config: hotline.Config{ - FileRoot: "/Files", - }, - FS: func() *hotline.MockFileStore { - mfs := &hotline.MockFileStore{} - mfs.On("Mkdir", "/Files/testFolder", fs.FileMode(0777)).Return(nil) - mfs.On("Stat", "/Files/testFolder").Return(nil, os.ErrNotExist) - return mfs - }(), - }, - }, - t: hotline.NewTransaction( - hotline.TranNewFolder, [2]byte{0, 1}, - hotline.NewField(hotline.FieldFileName, []byte("testFolder")), - ), - }, - wantRes: []hotline.Transaction{ - { - ClientID: [2]byte{0, 1}, - IsReply: 0x01, - }, - }, - }, - { - name: "when Write returns an err", - args: args{ - cc: &hotline.ClientConn{ - Account: &hotline.Account{ - Access: func() hotline.AccessBitmap { - var bits hotline.AccessBitmap - bits.Set(hotline.AccessCreateFolder) - return bits - }(), - }, - ID: [2]byte{0, 1}, - Logger: NewTestLogger(), - Server: &hotline.Server{ - TextDecoder: charmap.Macintosh.NewDecoder(), - TextEncoder: charmap.Macintosh.NewEncoder(), - Config: hotline.Config{ - FileRoot: "/Files/", - }, - FS: func() *hotline.MockFileStore { - mfs := &hotline.MockFileStore{} - mfs.On("Mkdir", "/Files/aaa/testFolder", fs.FileMode(0777)).Return(nil) - mfs.On("Stat", "/Files/aaa/testFolder").Return(nil, os.ErrNotExist) - return mfs - }(), - }, - }, - t: hotline.NewTransaction( - hotline.TranNewFolder, [2]byte{0, 1}, - hotline.NewField(hotline.FieldFileName, []byte("testFolder")), - hotline.NewField(hotline.FieldFilePath, []byte{ - 0x00, - }), - ), - }, - wantRes: []hotline.Transaction{ - { - ClientID: [2]byte{0, 1}, - IsReply: 0x01, - ErrorCode: [4]byte{0, 0, 0, 1}, - Fields: []hotline.Field{ - hotline.NewField(hotline.FieldError, []byte(ErrMsgCreateFolder)), - }, - }, - }, - }, - { - name: "FieldFileName does not allow directory traversal", - args: args{ - cc: &hotline.ClientConn{ - Account: &hotline.Account{ - Access: func() hotline.AccessBitmap { - var bits hotline.AccessBitmap - bits.Set(hotline.AccessCreateFolder) - return bits - }(), - }, - ID: [2]byte{0, 1}, - Server: &hotline.Server{ - TextDecoder: charmap.Macintosh.NewDecoder(), - TextEncoder: charmap.Macintosh.NewEncoder(), - Config: hotline.Config{ - FileRoot: "/Files/", - }, - FS: func() *hotline.MockFileStore { - mfs := &hotline.MockFileStore{} - mfs.On("Mkdir", "/Files/testFolder", fs.FileMode(0777)).Return(nil) - mfs.On("Stat", "/Files/testFolder").Return(nil, os.ErrNotExist) - return mfs - }(), - }, - }, - t: hotline.NewTransaction( - hotline.TranNewFolder, [2]byte{0, 1}, - hotline.NewField(hotline.FieldFileName, []byte("../../testFolder")), - ), - }, - wantRes: []hotline.Transaction{ - { - ClientID: [2]byte{0, 1}, - IsReply: 0x01, - }, - }, - }, - { - name: "FieldFilePath does not allow directory traversal", - args: args{ - cc: &hotline.ClientConn{ - Account: &hotline.Account{ - Access: func() hotline.AccessBitmap { - var bits hotline.AccessBitmap - bits.Set(hotline.AccessCreateFolder) - return bits - }(), - }, - ID: [2]byte{0, 1}, - Server: &hotline.Server{ - TextDecoder: charmap.Macintosh.NewDecoder(), - TextEncoder: charmap.Macintosh.NewEncoder(), - Config: hotline.Config{ - FileRoot: "/Files/", - }, - FS: func() *hotline.MockFileStore { - mfs := &hotline.MockFileStore{} - mfs.On("Mkdir", "/Files/foo/testFolder", fs.FileMode(0777)).Return(nil) - mfs.On("Stat", "/Files/foo/testFolder").Return(nil, os.ErrNotExist) - return mfs - }(), - }, - }, - t: hotline.NewTransaction( - hotline.TranNewFolder, [2]byte{0, 1}, - hotline.NewField(hotline.FieldFileName, []byte("testFolder")), - hotline.NewField(hotline.FieldFilePath, []byte{ - 0x00, 0x02, - 0x00, 0x00, - 0x03, - 0x2e, 0x2e, 0x2f, - 0x00, 0x00, - 0x03, - 0x66, 0x6f, 0x6f, - }), - ), - }, - wantRes: []hotline.Transaction{ - { - ClientID: [2]byte{0, 1}, - IsReply: 0x01, - }, - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - gotRes := HandleNewFolder(tt.args.cc, &tt.args.t) - - if !TranAssertEqual(t, tt.wantRes, gotRes) { - t.Errorf("HandleNewFolder() gotRes = %v, want %v", gotRes, tt.wantRes) - } - }) - } -} - -func TestHandleUploadFile(t *testing.T) { - type args struct { - cc *hotline.ClientConn - t hotline.Transaction - } - tests := []struct { - name string - args args - wantRes []hotline.Transaction - }{ - { - name: "when request is valid and user has Upload Anywhere permission", - args: args{ - cc: &hotline.ClientConn{ - Server: &hotline.Server{ - TextDecoder: charmap.Macintosh.NewDecoder(), - TextEncoder: charmap.Macintosh.NewEncoder(), - FS: &hotline.OSFileStore{}, - FileTransferMgr: hotline.NewMemFileTransferMgr(), - Config: hotline.Config{ - FileRoot: func() string { path, _ := os.Getwd(); return path + "/test/config/Files" }(), - }}, - ClientFileTransferMgr: hotline.NewClientFileTransferMgr(), - Account: &hotline.Account{ - Access: func() hotline.AccessBitmap { - var bits hotline.AccessBitmap - bits.Set(hotline.AccessUploadFile) - bits.Set(hotline.AccessUploadAnywhere) - return bits - }(), - }, - }, - t: hotline.NewTransaction( - hotline.TranUploadFile, [2]byte{0, 1}, - hotline.NewField(hotline.FieldFileName, []byte("testFile")), - hotline.NewField(hotline.FieldFilePath, []byte{ - 0x00, 0x01, - 0x00, 0x00, - 0x03, - 0x2e, 0x2e, 0x2f, - }), - ), - }, - wantRes: []hotline.Transaction{ - { - IsReply: 0x01, - Fields: []hotline.Field{ - hotline.NewField(hotline.FieldRefNum, []byte{0x52, 0xfd, 0xfc, 0x07}), // rand.Seed(1) - }, - }, - }, - }, - { - name: "when user does not have required access", - args: args{ - cc: &hotline.ClientConn{ - Account: &hotline.Account{ - Access: func() hotline.AccessBitmap { - var bits hotline.AccessBitmap - return bits - }(), - }, - }, - t: hotline.NewTransaction( - hotline.TranUploadFile, [2]byte{0, 1}, - hotline.NewField(hotline.FieldFileName, []byte("testFile")), - hotline.NewField(hotline.FieldFilePath, []byte{ - 0x00, 0x01, - 0x00, 0x00, - 0x03, - 0x2e, 0x2e, 0x2f, - }), - ), - }, - wantRes: []hotline.Transaction{ - { - IsReply: 0x01, - ErrorCode: [4]byte{0, 0, 0, 1}, - Fields: []hotline.Field{ - hotline.NewField(hotline.FieldError, []byte("You are not allowed to upload files.")), // rand.Seed(1) - }, - }, - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - gotRes := HandleUploadFile(tt.args.cc, &tt.args.t) - TranAssertEqual(t, tt.wantRes, gotRes) - }) - } -} - -// When a client requests to resume an upload but no partial (.incomplete) file -// exists, the handler should fall back to a normal upload reply (reference number, -// no resume-data field) rather than discarding the reply and returning nothing. -func TestHandleUploadFile_resumeFallbackWhenNoPartialFile(t *testing.T) { - cc := &hotline.ClientConn{ - Logger: NewTestLogger(), - Server: &hotline.Server{ - TextDecoder: charmap.Macintosh.NewDecoder(), - TextEncoder: charmap.Macintosh.NewEncoder(), - FS: &hotline.OSFileStore{}, - FileTransferMgr: hotline.NewMemFileTransferMgr(), - Config: hotline.Config{ - FileRoot: func() string { path, _ := os.Getwd(); return path + "/test/config/Files" }(), - }, - }, - ClientFileTransferMgr: hotline.NewClientFileTransferMgr(), - Account: &hotline.Account{ - Access: func() hotline.AccessBitmap { - var bits hotline.AccessBitmap - bits.Set(hotline.AccessUploadFile) - bits.Set(hotline.AccessUploadAnywhere) - return bits - }(), - }, - } - tr := hotline.NewTransaction( - hotline.TranUploadFile, [2]byte{0, 1}, - hotline.NewField(hotline.FieldFileName, []byte("doesNotExistYet")), - hotline.NewField(hotline.FieldFilePath, []byte{0x00, 0x01, 0x00, 0x00, 0x03, 0x2e, 0x2e, 0x2f}), - hotline.NewField(hotline.FieldFileTransferOptions, []byte{0x00, 0x02}), // request resume - ) - - gotRes := HandleUploadFile(cc, &tr) - - if len(gotRes) != 1 { - t.Fatalf("expected 1 reply transaction, got %d", len(gotRes)) - } - if gotRes[0].ErrorCode != [4]byte{} { - t.Errorf("expected a non-error reply, got error code %v", gotRes[0].ErrorCode) - } - if gotRes[0].GetField(hotline.FieldRefNum).Data == nil { - t.Errorf("expected a reference number field in the reply") - } - if gotRes[0].GetField(hotline.FieldFileResumeData).Data != nil { - t.Errorf("expected no resume data field when there is no partial file to resume") - } -} - -func TestHandleMakeAlias(t *testing.T) { - type args struct { - cc *hotline.ClientConn - t hotline.Transaction - } - tests := []struct { - name string - args args - wantRes []hotline.Transaction - }{ - { - name: "with valid input and required permissions", - args: args{ - cc: &hotline.ClientConn{ - Logger: NewTestLogger(), - Account: &hotline.Account{ - Access: func() hotline.AccessBitmap { - var bits hotline.AccessBitmap - bits.Set(hotline.AccessMakeAlias) - return bits - }(), - }, - Server: &hotline.Server{ - TextDecoder: charmap.Macintosh.NewDecoder(), - TextEncoder: charmap.Macintosh.NewEncoder(), - Config: hotline.Config{ - FileRoot: func() string { - path, _ := os.Getwd() - return path + "/test/config/Files" - }(), - }, - Logger: NewTestLogger(), - FS: func() *hotline.MockFileStore { - mfs := &hotline.MockFileStore{} - path, _ := os.Getwd() - mfs.On( - "Symlink", - path+"/test/config/Files/foo/testFile", - path+"/test/config/Files/bar/testFile", - ).Return(nil) - return mfs - }(), - }, - }, - t: hotline.NewTransaction( - hotline.TranMakeFileAlias, [2]byte{0, 1}, - hotline.NewField(hotline.FieldFileName, []byte("testFile")), - hotline.NewField(hotline.FieldFilePath, hotline.EncodeFilePath(strings.Join([]string{"foo"}, "/"))), - hotline.NewField(hotline.FieldFileNewPath, hotline.EncodeFilePath(strings.Join([]string{"bar"}, "/"))), - ), - }, - wantRes: []hotline.Transaction{ - { - IsReply: 0x01, - Fields: []hotline.Field(nil), - }, - }, - }, - { - name: "when symlink returns an error", - args: args{ - cc: &hotline.ClientConn{ - Logger: NewTestLogger(), - Account: &hotline.Account{ - Access: func() hotline.AccessBitmap { - var bits hotline.AccessBitmap - bits.Set(hotline.AccessMakeAlias) - return bits - }(), - }, - Server: &hotline.Server{ - TextDecoder: charmap.Macintosh.NewDecoder(), - TextEncoder: charmap.Macintosh.NewEncoder(), - Config: hotline.Config{ - FileRoot: func() string { - path, _ := os.Getwd() - return path + "/test/config/Files" - }(), - }, - Logger: NewTestLogger(), - FS: func() *hotline.MockFileStore { - mfs := &hotline.MockFileStore{} - path, _ := os.Getwd() - mfs.On( - "Symlink", - path+"/test/config/Files/foo/testFile", - path+"/test/config/Files/bar/testFile", - ).Return(errors.New("ohno")) - return mfs - }(), - }, - }, - t: hotline.NewTransaction( - hotline.TranMakeFileAlias, [2]byte{0, 1}, - hotline.NewField(hotline.FieldFileName, []byte("testFile")), - hotline.NewField(hotline.FieldFilePath, hotline.EncodeFilePath(strings.Join([]string{"foo"}, "/"))), - hotline.NewField(hotline.FieldFileNewPath, hotline.EncodeFilePath(strings.Join([]string{"bar"}, "/"))), - ), - }, - wantRes: []hotline.Transaction{ - { - IsReply: 0x01, - ErrorCode: [4]byte{0, 0, 0, 1}, - Fields: []hotline.Field{ - hotline.NewField(hotline.FieldError, []byte("Error creating alias")), - }, - }, - }, - }, - { - name: "when user does not have required permission", - args: args{ - cc: &hotline.ClientConn{ - Logger: NewTestLogger(), - Account: &hotline.Account{ - Access: hotline.AccessBitmap{}, - }, - Server: &hotline.Server{ - TextDecoder: charmap.Macintosh.NewDecoder(), - TextEncoder: charmap.Macintosh.NewEncoder(), - Config: hotline.Config{ - FileRoot: func() string { - path, _ := os.Getwd() - return path + "/test/config/Files" - }(), - }, - }, - }, - t: hotline.NewTransaction( - hotline.TranMakeFileAlias, [2]byte{0, 1}, - hotline.NewField(hotline.FieldFileName, []byte("testFile")), - hotline.NewField(hotline.FieldFilePath, []byte{ - 0x00, 0x01, - 0x00, 0x00, - 0x03, - 0x2e, 0x2e, 0x2e, - }), - hotline.NewField(hotline.FieldFileNewPath, []byte{ - 0x00, 0x01, - 0x00, 0x00, - 0x03, - 0x2e, 0x2e, 0x2e, - }), - ), - }, - wantRes: []hotline.Transaction{ - { - IsReply: 0x01, - ErrorCode: [4]byte{0, 0, 0, 1}, - Fields: []hotline.Field{ - hotline.NewField(hotline.FieldError, []byte("You are not allowed to make aliases.")), - }, - }, - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - gotRes := HandleMakeAlias(tt.args.cc, &tt.args.t) - TranAssertEqual(t, tt.wantRes, gotRes) - }) - } -} - -func TestHandleGetUser(t *testing.T) { - type args struct { - cc *hotline.ClientConn - t hotline.Transaction - } - tests := []struct { - name string - args args - wantRes []hotline.Transaction - }{ - { - name: "when account is valid", - args: args{ - cc: &hotline.ClientConn{ - Account: &hotline.Account{ - Access: func() hotline.AccessBitmap { - var bits hotline.AccessBitmap - bits.Set(hotline.AccessOpenUser) - return bits - }(), - }, - Server: &hotline.Server{ - TextDecoder: charmap.Macintosh.NewDecoder(), - TextEncoder: charmap.Macintosh.NewEncoder(), - AccountManager: func() *MockAccountManager { - m := MockAccountManager{} - m.On("Get", "guest").Return(&hotline.Account{ - Login: "guest", - Name: "Guest", - Password: "password", - Access: hotline.AccessBitmap{}, - }) - return &m - }(), - }, - }, - t: hotline.NewTransaction( - hotline.TranGetUser, [2]byte{0, 1}, - hotline.NewField(hotline.FieldUserLogin, []byte("guest")), - ), - }, - wantRes: []hotline.Transaction{ - { - IsReply: 0x01, - Fields: []hotline.Field{ - hotline.NewField(hotline.FieldUserName, []byte("Guest")), - hotline.NewField(hotline.FieldUserLogin, hotline.EncodeString([]byte("guest"))), - hotline.NewField(hotline.FieldUserPassword, []byte("password")), - hotline.NewField(hotline.FieldUserAccess, []byte{0, 0, 0, 0, 0, 0, 0, 0}), - }, - }, - }, - }, - { - name: "when user does not have required permission", - args: args{ - cc: &hotline.ClientConn{ - Account: &hotline.Account{ - Access: func() hotline.AccessBitmap { - var bits hotline.AccessBitmap - return bits - }(), - }, - Server: &hotline.Server{ - TextDecoder: charmap.Macintosh.NewDecoder(), - TextEncoder: charmap.Macintosh.NewEncoder(), - //Accounts: map[string]*Account{}, - }, - }, - t: hotline.NewTransaction( - hotline.TranGetUser, [2]byte{0, 1}, - hotline.NewField(hotline.FieldUserLogin, []byte("nonExistentUser")), - ), - }, - wantRes: []hotline.Transaction{ - { - IsReply: 0x01, - ErrorCode: [4]byte{0, 0, 0, 1}, - Fields: []hotline.Field{ - hotline.NewField(hotline.FieldError, []byte("You are not allowed to view accounts.")), - }, - }, - }, - }, - { - name: "when account does not exist", - args: args{ - cc: &hotline.ClientConn{ - Account: &hotline.Account{ - Access: func() hotline.AccessBitmap { - var bits hotline.AccessBitmap - bits.Set(hotline.AccessOpenUser) - return bits - }(), - }, - Server: &hotline.Server{ - TextDecoder: charmap.Macintosh.NewDecoder(), - TextEncoder: charmap.Macintosh.NewEncoder(), - AccountManager: func() *MockAccountManager { - m := MockAccountManager{} - m.On("Get", "nonExistentUser").Return((*hotline.Account)(nil)) - return &m - }(), - }, - }, - t: hotline.NewTransaction( - hotline.TranGetUser, [2]byte{0, 1}, - hotline.NewField(hotline.FieldUserLogin, []byte("nonExistentUser")), - ), - }, - wantRes: []hotline.Transaction{ - { - Flags: 0x00, - IsReply: 0x01, - Type: [2]byte{0, 0}, - ErrorCode: [4]byte{0, 0, 0, 1}, - Fields: []hotline.Field{ - hotline.NewField(hotline.FieldError, []byte("Account does not exist.")), - }, - }, - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - gotRes := HandleGetUser(tt.args.cc, &tt.args.t) - TranAssertEqual(t, tt.wantRes, gotRes) - }) - } -} - -func TestHandleDeleteUser(t *testing.T) { - type args struct { - cc *hotline.ClientConn - t hotline.Transaction - } - tests := []struct { - name string - args args - wantRes []hotline.Transaction - }{ - { - name: "when user exists", - args: args{ - cc: &hotline.ClientConn{ - 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(), - 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{}) // TODO - 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), - }, - }, - }, - { - name: "when user does not have required permission", - args: args{ - cc: &hotline.ClientConn{ - Account: &hotline.Account{ - Access: hotline.AccessBitmap{}, - }, - Server: &hotline.Server{ - TextDecoder: charmap.Macintosh.NewDecoder(), - TextEncoder: charmap.Macintosh.NewEncoder(), - //Accounts: map[string]*Account{}, - }, - }, - t: hotline.NewTransaction( - hotline.TranDeleteUser, [2]byte{0, 1}, - hotline.NewField(hotline.FieldUserLogin, hotline.EncodeString([]byte("testuser"))), - ), - }, - wantRes: []hotline.Transaction{ - { - IsReply: 0x01, - ErrorCode: [4]byte{0, 0, 0, 1}, - Fields: []hotline.Field{ - hotline.NewField(hotline.FieldError, []byte("You are not allowed to delete accounts.")), - }, - }, - }, - }, - { - 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) { - gotRes := HandleDeleteUser(tt.args.cc, &tt.args.t) - TranAssertEqual(t, tt.wantRes, gotRes) - }) - } -} - -func TestHandleGetMsgs(t *testing.T) { - type args struct { - cc *hotline.ClientConn - t hotline.Transaction - } - tests := []struct { - name string - args args - wantRes []hotline.Transaction - }{ - { - name: "returns news data", - 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(), - MessageBoard: func() *mockReadWriteSeeker { - m := mockReadWriteSeeker{} - m.On("Seek", int64(0), 0).Return(int64(0), nil) - m.On("Read", mock.AnythingOfType("[]uint8")).Run(func(args mock.Arguments) { - arg := args.Get(0).([]uint8) - copy(arg, "TEST") - }).Return(4, io.EOF) - return &m - }(), - }, - }, - t: hotline.NewTransaction( - hotline.TranGetMsgs, [2]byte{0, 1}, - ), - }, - wantRes: []hotline.Transaction{ - { - IsReply: 0x01, - Fields: []hotline.Field{ - hotline.NewField(hotline.FieldData, []byte("TEST")), - }, - }, - }, - }, - { - name: "when ReadAll returns an error", - args: args{ - cc: &hotline.ClientConn{ - Account: &hotline.Account{ - Access: func() hotline.AccessBitmap { - var bits hotline.AccessBitmap - bits.Set(hotline.AccessNewsReadArt) - return bits - }(), - }, - Logger: NewTestLogger(), - Server: &hotline.Server{ - TextDecoder: charmap.Macintosh.NewDecoder(), - TextEncoder: charmap.Macintosh.NewEncoder(), - MessageBoard: func() *mockReadWriteSeeker { - m := mockReadWriteSeeker{} - m.On("Seek", int64(0), 0).Return(int64(0), nil) - m.On("Read", mock.AnythingOfType("[]uint8")).Return(0, errors.New("read error")) - return &m - }(), - }, - }, - t: hotline.NewTransaction( - hotline.TranGetMsgs, [2]byte{0, 1}, - ), - }, - wantRes: []hotline.Transaction{ - { - IsReply: 0x01, - ErrorCode: [4]byte{0, 0, 0, 1}, - Fields: []hotline.Field{ - hotline.NewField(hotline.FieldError, []byte("Error reading message board.")), - }, - }, - }, - }, - { - name: "when user does not have required permission", - args: args{ - cc: &hotline.ClientConn{ - Account: &hotline.Account{ - Access: hotline.AccessBitmap{}, - }, - Server: &hotline.Server{ - TextDecoder: charmap.Macintosh.NewDecoder(), - TextEncoder: charmap.Macintosh.NewEncoder(), - //Accounts: map[string]*Account{}, - }, - }, - t: hotline.NewTransaction( - hotline.TranGetMsgs, [2]byte{0, 1}, - ), - }, - wantRes: []hotline.Transaction{ - { - IsReply: 0x01, - ErrorCode: [4]byte{0, 0, 0, 1}, - Fields: []hotline.Field{ - hotline.NewField(hotline.FieldError, []byte("You are not allowed to read news.")), - }, - }, - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - gotRes := HandleGetMsgs(tt.args.cc, &tt.args.t) - TranAssertEqual(t, tt.wantRes, gotRes) - }) - } -} - -func TestHandleNewUser(t *testing.T) { - type args struct { - cc *hotline.ClientConn - t hotline.Transaction - } - tests := []struct { - name string - args args - wantRes []hotline.Transaction - }{ - { - name: "when user does not have required permission", - args: args{ - cc: &hotline.ClientConn{ - Account: &hotline.Account{ - Access: func() hotline.AccessBitmap { - var bits hotline.AccessBitmap - return bits - }(), - }, - Server: &hotline.Server{ - TextDecoder: charmap.Macintosh.NewDecoder(), - TextEncoder: charmap.Macintosh.NewEncoder(), - //Accounts: map[string]*Account{}, - }, - }, - t: hotline.NewTransaction( - hotline.TranNewUser, [2]byte{0, 1}, - ), - }, - wantRes: []hotline.Transaction{ - { - IsReply: 0x01, - ErrorCode: [4]byte{0, 0, 0, 1}, - Fields: []hotline.Field{ - hotline.NewField(hotline.FieldError, []byte("You are not allowed to create new accounts.")), - }, - }, - }, - }, - { - name: "when user attempts to create account with greater access", - 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)(nil)) - return &m - }(), - }, - }, - t: hotline.NewTransaction( - hotline.TranNewUser, [2]byte{0, 1}, - hotline.NewField(hotline.FieldUserLogin, hotline.EncodeString([]byte("userB"))), - hotline.NewField( - hotline.FieldUserAccess, - func() []byte { - var bits hotline.AccessBitmap - bits.Set(hotline.AccessDisconUser) - return bits[:] - }(), - ), - ), - }, - 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 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) { - gotRes := HandleNewUser(tt.args.cc, &tt.args.t) - TranAssertEqual(t, tt.wantRes, gotRes) - }) - } -} - -func TestHandleListUsers(t *testing.T) { - type args struct { - cc *hotline.ClientConn - t hotline.Transaction - } - tests := []struct { - name string - args args - wantRes []hotline.Transaction - }{ - { - name: "when user does not have required permission", - args: args{ - cc: &hotline.ClientConn{ - Account: &hotline.Account{ - Access: func() hotline.AccessBitmap { - var bits hotline.AccessBitmap - return bits - }(), - }, - Server: &hotline.Server{ - TextDecoder: charmap.Macintosh.NewDecoder(), - TextEncoder: charmap.Macintosh.NewEncoder(), - //Accounts: map[string]*Account{}, - }, - }, - t: hotline.NewTransaction( - hotline.TranNewUser, [2]byte{0, 1}, - ), - }, - wantRes: []hotline.Transaction{ - { - IsReply: 0x01, - ErrorCode: [4]byte{0, 0, 0, 1}, - Fields: []hotline.Field{ - hotline.NewField(hotline.FieldError, []byte("You are not allowed to view accounts.")), - }, - }, - }, - }, - { - name: "when user has required permission", - args: args{ - cc: &hotline.ClientConn{ - Account: &hotline.Account{ - Access: func() hotline.AccessBitmap { - var bits hotline.AccessBitmap - bits.Set(hotline.AccessOpenUser) - return bits - }(), - }, - Server: &hotline.Server{ - TextDecoder: charmap.Macintosh.NewDecoder(), - TextEncoder: charmap.Macintosh.NewEncoder(), - AccountManager: func() *MockAccountManager { - m := MockAccountManager{} - m.On("List").Return([]hotline.Account{ - { - Name: "guest", - Login: "guest", - Password: "zz", - Access: hotline.AccessBitmap{255, 255, 255, 255, 255, 255, 255, 255}, - }, - }) - return &m - }(), - }, - }, - t: hotline.NewTransaction( - hotline.TranGetClientInfoText, [2]byte{0, 1}, - hotline.NewField(hotline.FieldUserID, []byte{0, 1}), - ), - }, - wantRes: []hotline.Transaction{ - { - IsReply: 0x01, - Fields: []hotline.Field{ - hotline.NewField(hotline.FieldData, []byte{ - 0x00, 0x04, 0x00, 0x66, 0x00, 0x05, 0x67, 0x75, 0x65, 0x73, 0x74, 0x00, 0x69, 0x00, 0x05, 0x98, - 0x8a, 0x9a, 0x8c, 0x8b, 0x00, 0x6e, 0x00, 0x08, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, - 0x00, 0x6a, 0x00, 0x01, 0x78, - }), - }, - }, - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - gotRes := HandleListUsers(tt.args.cc, &tt.args.t) - - TranAssertEqual(t, tt.wantRes, gotRes) - }) - } -} - -func TestHandleDownloadFile(t *testing.T) { - type args struct { - cc *hotline.ClientConn - t hotline.Transaction - } - tests := []struct { - name string - args args - wantRes []hotline.Transaction - }{ - { - name: "when user does not have required permission", - args: args{ - cc: &hotline.ClientConn{ - Account: &hotline.Account{ - Access: func() hotline.AccessBitmap { - var bits hotline.AccessBitmap - return bits - }(), - }, - Server: &hotline.Server{TextDecoder: charmap.Macintosh.NewDecoder(), TextEncoder: charmap.Macintosh.NewEncoder()}, - }, - t: hotline.NewTransaction(hotline.TranDownloadFile, [2]byte{0, 1}), - }, - wantRes: []hotline.Transaction{ - { - IsReply: 0x01, - ErrorCode: [4]byte{0, 0, 0, 1}, - Fields: []hotline.Field{ - hotline.NewField(hotline.FieldError, []byte("You are not allowed to download files.")), - }, - }, - }, - }, - { - name: "with a valid file", - args: args{ - cc: &hotline.ClientConn{ - ClientFileTransferMgr: hotline.NewClientFileTransferMgr(), - Account: &hotline.Account{ - Access: func() hotline.AccessBitmap { - var bits hotline.AccessBitmap - bits.Set(hotline.AccessDownloadFile) - return bits - }(), - }, - Server: &hotline.Server{ - TextDecoder: charmap.Macintosh.NewDecoder(), - TextEncoder: charmap.Macintosh.NewEncoder(), - FS: &hotline.OSFileStore{}, - FileTransferMgr: hotline.NewMemFileTransferMgr(), - Config: hotline.Config{ - FileRoot: func() string { path, _ := os.Getwd(); return path + "/test/config/Files" }(), - }, - }, - }, - t: hotline.NewTransaction( - hotline.TranDownloadFile, - [2]byte{0, 1}, - hotline.NewField(hotline.FieldFileName, []byte("testfile.txt")), - hotline.NewField(hotline.FieldFilePath, []byte{0x0, 0x00}), - ), - }, - wantRes: []hotline.Transaction{ - { - IsReply: 0x01, - Fields: []hotline.Field{ - hotline.NewField(hotline.FieldRefNum, []byte{0x52, 0xfd, 0xfc, 0x07}), - hotline.NewField(hotline.FieldWaitingCount, []byte{0x00, 0x00}), - hotline.NewField(hotline.FieldTransferSize, []byte{0x00, 0x00, 0x00, 0xa5}), - hotline.NewField(hotline.FieldFileSize, []byte{0x00, 0x00, 0x00, 0x17}), - }, - }, - }, - }, - { - name: "when client requests to resume 1k test file at offset 256", - args: args{ - cc: &hotline.ClientConn{ - ClientFileTransferMgr: hotline.NewClientFileTransferMgr(), - Account: &hotline.Account{ - Access: func() hotline.AccessBitmap { - var bits hotline.AccessBitmap - bits.Set(hotline.AccessDownloadFile) - return bits - }(), - }, - Server: &hotline.Server{ - TextDecoder: charmap.Macintosh.NewDecoder(), - TextEncoder: charmap.Macintosh.NewEncoder(), - FS: &hotline.OSFileStore{}, - - // FS: func() *hotline.MockFileStore { - // path, _ := os.Getwd() - // testFile, err := os.Open(path + "/test/config/Files/testfile-1k") - // if err != nil { - // panic(err) - // } - // - // mfi := &hotline.MockFileInfo{} - // mfi.On("Mode").Return(fs.FileMode(0)) - // mfs := &MockFileStore{} - // mfs.On("Stat", "/fakeRoot/Files/testfile.txt").Return(mfi, nil) - // mfs.On("Open", "/fakeRoot/Files/testfile.txt").Return(testFile, nil) - // mfs.On("Stat", "/fakeRoot/Files/.info_testfile.txt").Return(nil, errors.New("no")) - // mfs.On("Stat", "/fakeRoot/Files/.rsrc_testfile.txt").Return(nil, errors.New("no")) - // - // return mfs - // }(), - FileTransferMgr: hotline.NewMemFileTransferMgr(), - Config: hotline.Config{ - FileRoot: func() string { path, _ := os.Getwd(); return path + "/test/config/Files" }(), - }, - //Accounts: map[string]*Account{}, - }, - }, - t: hotline.NewTransaction( - hotline.TranDownloadFile, - [2]byte{0, 1}, - hotline.NewField(hotline.FieldFileName, []byte("testfile-1k")), - hotline.NewField(hotline.FieldFilePath, []byte{0x00, 0x00}), - hotline.NewField( - hotline.FieldFileResumeData, - func() []byte { - frd := hotline.FileResumeData{ - ForkCount: [2]byte{0, 2}, - ForkInfoList: []hotline.ForkInfoList{ - { - Fork: hotline.ForkTypeDATA, - DataSize: [4]byte{0, 0, 0x01, 0x00}, // request offset 256 - }, - { - Fork: hotline.ForkTypeMACR, - DataSize: [4]byte{0, 0, 0, 0}, - }, - }, - } - b, _ := frd.BinaryMarshal() - return b - }(), - ), - ), - }, - wantRes: []hotline.Transaction{ - { - IsReply: 0x01, - Fields: []hotline.Field{ - hotline.NewField(hotline.FieldRefNum, []byte{0x52, 0xfd, 0xfc, 0x07}), - hotline.NewField(hotline.FieldWaitingCount, []byte{0x00, 0x00}), - hotline.NewField(hotline.FieldTransferSize, []byte{0x00, 0x00, 0x03, 0x8d}), - hotline.NewField(hotline.FieldFileSize, []byte{0x00, 0x00, 0x03, 0x00}), - }, - }, - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - gotRes := HandleDownloadFile(tt.args.cc, &tt.args.t) - TranAssertEqual(t, tt.wantRes, gotRes) - }) - } -} - -func TestHandleUpdateUser(t *testing.T) { - type args struct { - cc *hotline.ClientConn - t hotline.Transaction - } - tests := []struct { - name string - args args - wantRes []hotline.Transaction - }{ - { - name: "when action is create user without required permission", - args: args{ - cc: &hotline.ClientConn{ - Logger: NewTestLogger(), - Server: &hotline.Server{ - TextDecoder: charmap.Macintosh.NewDecoder(), - TextEncoder: charmap.Macintosh.NewEncoder(), - AccountManager: func() *MockAccountManager { - m := MockAccountManager{} - m.On("Get", "bbb").Return((*hotline.Account)(nil)) - return &m - }(), - Logger: NewTestLogger(), - }, - Account: &hotline.Account{ - Access: hotline.AccessBitmap{}, - }, - }, - 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("You are not allowed to create new accounts.")), - }, - }, - }, - }, - { - name: "when action is modify user without required 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("Get", "bbb").Return(&hotline.Account{}) - return &m - }(), - }, - Account: &hotline.Account{ - Access: func() hotline.AccessBitmap { - var bits hotline.AccessBitmap - 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("You are not allowed to modify accounts.")), - }, - }, - }, - }, - { - name: "when action is delete user without required permission", - args: args{ - cc: &hotline.ClientConn{ - Logger: NewTestLogger(), - Server: &hotline.Server{TextDecoder: charmap.Macintosh.NewDecoder(), TextEncoder: charmap.Macintosh.NewEncoder()}, - Account: &hotline.Account{ - Access: hotline.AccessBitmap{}, - }, - }, - t: hotline.NewTransaction( - hotline.TranUpdateUser, - [2]byte{0, 0}, - hotline.NewField(hotline.FieldData, []byte{ - 0x00, 0x01, - 0x00, 0x65, - 0x00, 0x03, - 0x88, 0x9e, 0x8b, - }), - ), - }, - wantRes: []hotline.Transaction{ - { - IsReply: 0x01, - ErrorCode: [4]byte{0, 0, 0, 1}, - Fields: []hotline.Field{ - hotline.NewField(hotline.FieldError, []byte("You are not allowed to delete accounts.")), - }, - }, - }, - }, - { - 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: []hotline.Transaction{ - { - IsReply: 0x01, - ErrorCode: [4]byte{0, 0, 0, 1}, - Fields: []hotline.Field{ - hotline.NewField(hotline.FieldError, []byte(ErrMsgDeleteAccount)), - }, - }, - }, - }, - { - 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) { - gotRes := HandleUpdateUser(tt.args.cc, &tt.args.t) - TranAssertEqual(t, tt.wantRes, gotRes) - }) - } -} - -func TestHandleDelNewsArt(t *testing.T) { - type args struct { - cc *hotline.ClientConn - t hotline.Transaction - } - tests := []struct { - name string - args args - wantRes []hotline.Transaction - }{ - { - name: "without required permission", - args: args{ - cc: &hotline.ClientConn{ - Account: &hotline.Account{ - Access: func() hotline.AccessBitmap { - var bits hotline.AccessBitmap - return bits - }(), - }, - }, - t: hotline.NewTransaction( - hotline.TranDelNewsArt, - [2]byte{0, 0}, - ), - }, - wantRes: []hotline.Transaction{ - { - IsReply: 0x01, - ErrorCode: [4]byte{0, 0, 0, 1}, - Fields: []hotline.Field{ - hotline.NewField(hotline.FieldError, []byte("You are not allowed to delete news articles.")), - }, - }, - }, - }, - { - name: "when DeleteArticle returns an error", - args: args{ - cc: &hotline.ClientConn{ - Account: &hotline.Account{ - Access: func() hotline.AccessBitmap { - var bits hotline.AccessBitmap - bits.Set(hotline.AccessNewsDeleteArt) - return bits - }(), - }, - Logger: NewTestLogger(), - ID: [2]byte{0, 1}, - Server: &hotline.Server{ - TextDecoder: charmap.Macintosh.NewDecoder(), - TextEncoder: charmap.Macintosh.NewEncoder(), - ThreadedNewsMgr: func() *hotline.MockThreadNewsMgr { - m := hotline.MockThreadNewsMgr{} - m.On("DeleteArticle", []string{"test"}, uint32(1), false).Return(errors.New("write error")) - return &m - }(), - }, - }, - t: hotline.NewTransaction( - hotline.TranDelNewsArt, [2]byte{0, 1}, - hotline.NewField(hotline.FieldNewsPath, - []byte{ - 0, 1, - 0, 0, - 4, - 0x74, 0x65, 0x73, 0x74, - }, - ), - hotline.NewField(hotline.FieldNewsArtID, []byte{0, 0, 0, 1}), - ), - }, - wantRes: []hotline.Transaction{ - { - ClientID: [2]byte{0, 1}, - IsReply: 0x01, - ErrorCode: [4]byte{0, 0, 0, 1}, - Fields: []hotline.Field{ - hotline.NewField(hotline.FieldError, []byte("Error deleting news article.")), - }, - }, - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - gotRes := HandleDelNewsArt(tt.args.cc, &tt.args.t) - TranAssertEqual(t, tt.wantRes, gotRes) - }) - } -} - // Malformed client-supplied ID fields (wrong length) must produce an error reply // rather than panicking the handler goroutine. func TestHandlers_malformedIDFieldsReturnErrorReply(t *testing.T) { @@ -3057,3608 +62,6 @@ func TestHandlers_malformedIDFieldsReturnErrorReply(t *testing.T) { } } -func TestHandleDisconnectUser(t *testing.T) { - type args struct { - cc *hotline.ClientConn - t hotline.Transaction - } - tests := []struct { - name string - args args - wantRes []hotline.Transaction - }{ - { - name: "without required permission", - args: args{ - cc: &hotline.ClientConn{ - Account: &hotline.Account{ - Access: func() hotline.AccessBitmap { - var bits hotline.AccessBitmap - return bits - }(), - }, - }, - t: hotline.NewTransaction( - hotline.TranDelNewsArt, - [2]byte{0, 0}, - ), - }, - wantRes: []hotline.Transaction{ - { - IsReply: 0x01, - ErrorCode: [4]byte{0, 0, 0, 1}, - Fields: []hotline.Field{ - hotline.NewField(hotline.FieldError, []byte("You are not allowed to disconnect users.")), - }, - }, - }, - }, - { - name: "when target user has 'cannot be disconnected' priv", - args: args{ - cc: &hotline.ClientConn{ - Server: &hotline.Server{ - TextDecoder: charmap.Macintosh.NewDecoder(), - TextEncoder: charmap.Macintosh.NewEncoder(), - ClientMgr: func() *hotline.MockClientMgr { - m := hotline.MockClientMgr{} - m.On("Get", hotline.ClientID{0x0, 0x1}).Return(&hotline.ClientConn{ - Account: &hotline.Account{ - Login: "unnamed", - Access: func() hotline.AccessBitmap { - var bits hotline.AccessBitmap - bits.Set(hotline.AccessCannotBeDiscon) - return bits - }(), - }, - }, - ) - return &m - }(), - }, - Account: &hotline.Account{ - Access: func() hotline.AccessBitmap { - var bits hotline.AccessBitmap - bits.Set(hotline.AccessDisconUser) - return bits - }(), - }, - }, - t: hotline.NewTransaction( - hotline.TranDelNewsArt, - [2]byte{0, 0}, - hotline.NewField(hotline.FieldUserID, []byte{0, 1}), - ), - }, - wantRes: []hotline.Transaction{ - { - IsReply: 0x01, - ErrorCode: [4]byte{0, 0, 0, 1}, - Fields: []hotline.Field{ - hotline.NewField(hotline.FieldError, []byte("unnamed is not allowed to be disconnected.")), - }, - }, - }, - }, - { - name: "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) { - gotRes := HandleDisconnectUser(tt.args.cc, &tt.args.t) - TranAssertEqual(t, tt.wantRes, gotRes) - }) - } -} - -func TestHandleSendInstantMsg(t *testing.T) { - type args struct { - cc *hotline.ClientConn - t hotline.Transaction - } - tests := []struct { - name string - args args - wantRes []hotline.Transaction - }{ - { - name: "without required permission", - args: args{ - cc: &hotline.ClientConn{ - Account: &hotline.Account{ - Access: func() hotline.AccessBitmap { - var bits hotline.AccessBitmap - return bits - }(), - }, - }, - t: hotline.NewTransaction( - hotline.TranDelNewsArt, - [2]byte{0, 0}, - ), - }, - wantRes: []hotline.Transaction{ - { - IsReply: 0x01, - ErrorCode: [4]byte{0, 0, 0, 1}, - Fields: []hotline.Field{ - hotline.NewField(hotline.FieldError, []byte("You are not allowed to send private messages.")), - }, - }, - }, - }, - { - name: "when client 1 sends a message to client 2", - args: args{ - cc: &hotline.ClientConn{ - Account: &hotline.Account{ - Access: func() hotline.AccessBitmap { - var bits hotline.AccessBitmap - bits.Set(hotline.AccessSendPrivMsg) - return bits - }(), - }, - ID: [2]byte{0, 1}, - UserName: []byte("User1"), - Server: &hotline.Server{ - TextDecoder: charmap.Macintosh.NewDecoder(), - TextEncoder: charmap.Macintosh.NewEncoder(), - ClientMgr: func() *hotline.MockClientMgr { - m := hotline.MockClientMgr{} - m.On("Get", hotline.ClientID{0x0, 0x2}).Return(&hotline.ClientConn{ - AutoReply: []byte(nil), - Flags: [2]byte{0, 0}, - }, - ) - return &m - }(), - }, - }, - t: hotline.NewTransaction( - hotline.TranSendInstantMsg, - [2]byte{0, 1}, - hotline.NewField(hotline.FieldData, []byte("hai")), - hotline.NewField(hotline.FieldUserID, []byte{0, 2}), - ), - }, - wantRes: []hotline.Transaction{ - hotline.NewTransaction( - hotline.TranServerMsg, - [2]byte{0, 2}, - hotline.NewField(hotline.FieldData, []byte("hai")), - hotline.NewField(hotline.FieldUserName, []byte("User1")), - hotline.NewField(hotline.FieldUserID, []byte{0, 1}), - hotline.NewField(hotline.FieldOptions, []byte{0, 1}), - ), - { - ClientID: [2]byte{0, 1}, - IsReply: 0x01, - Fields: []hotline.Field(nil), - }, - }, - }, - { - name: "when client 2 has autoreply enabled", - args: args{ - cc: &hotline.ClientConn{ - Account: &hotline.Account{ - Access: func() hotline.AccessBitmap { - var bits hotline.AccessBitmap - bits.Set(hotline.AccessSendPrivMsg) - return bits - }(), - }, - ID: [2]byte{0, 1}, - UserName: []byte("User1"), - Server: &hotline.Server{ - TextDecoder: charmap.Macintosh.NewDecoder(), - TextEncoder: charmap.Macintosh.NewEncoder(), - ClientMgr: func() *hotline.MockClientMgr { - m := hotline.MockClientMgr{} - m.On("Get", hotline.ClientID{0x0, 0x2}).Return(&hotline.ClientConn{ - Flags: [2]byte{0, 0}, - ID: [2]byte{0, 2}, - UserName: []byte("User2"), - AutoReply: []byte("autohai"), - }) - return &m - }(), - }, - }, - t: hotline.NewTransaction( - hotline.TranSendInstantMsg, - [2]byte{0, 1}, - hotline.NewField(hotline.FieldData, []byte("hai")), - hotline.NewField(hotline.FieldUserID, []byte{0, 2}), - ), - }, - wantRes: []hotline.Transaction{ - hotline.NewTransaction( - hotline.TranServerMsg, - [2]byte{0, 2}, - hotline.NewField(hotline.FieldData, []byte("hai")), - hotline.NewField(hotline.FieldUserName, []byte("User1")), - hotline.NewField(hotline.FieldUserID, []byte{0, 1}), - hotline.NewField(hotline.FieldOptions, []byte{0, 1}), - ), - hotline.NewTransaction( - hotline.TranServerMsg, - [2]byte{0, 1}, - hotline.NewField(hotline.FieldData, []byte("autohai")), - hotline.NewField(hotline.FieldUserName, []byte("User2")), - hotline.NewField(hotline.FieldUserID, []byte{0, 2}), - hotline.NewField(hotline.FieldOptions, []byte{0, 1}), - ), - { - ClientID: [2]byte{0, 1}, - IsReply: 0x01, - Fields: []hotline.Field(nil), - }, - }, - }, - { - name: "when client 2 has refuse private messages enabled", - args: args{ - cc: &hotline.ClientConn{ - Account: &hotline.Account{ - Access: func() hotline.AccessBitmap { - var bits hotline.AccessBitmap - bits.Set(hotline.AccessSendPrivMsg) - return bits - }(), - }, - ID: [2]byte{0, 1}, - UserName: []byte("User1"), - Server: &hotline.Server{ - TextDecoder: charmap.Macintosh.NewDecoder(), - TextEncoder: charmap.Macintosh.NewEncoder(), - ClientMgr: func() *hotline.MockClientMgr { - m := hotline.MockClientMgr{} - m.On("Get", hotline.ClientID{0x0, 0x2}).Return(&hotline.ClientConn{ - Flags: [2]byte{255, 255}, - ID: [2]byte{0, 2}, - UserName: []byte("User2"), - }, - ) - return &m - }(), - }, - }, - t: hotline.NewTransaction( - hotline.TranSendInstantMsg, - [2]byte{0, 1}, - hotline.NewField(hotline.FieldData, []byte("hai")), - hotline.NewField(hotline.FieldUserID, []byte{0, 2}), - ), - }, - wantRes: []hotline.Transaction{ - hotline.NewTransaction( - hotline.TranServerMsg, - [2]byte{0, 1}, - hotline.NewField(hotline.FieldData, []byte("User2 does not accept private messages.")), - hotline.NewField(hotline.FieldUserName, []byte("User2")), - hotline.NewField(hotline.FieldUserID, []byte{0, 2}), - hotline.NewField(hotline.FieldOptions, []byte{0, 2}), - ), - { - ClientID: [2]byte{0, 1}, - IsReply: 0x01, - Fields: []hotline.Field(nil), - }, - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - gotRes := HandleSendInstantMsg(tt.args.cc, &tt.args.t) - TranAssertEqual(t, tt.wantRes, gotRes) - }) - } -} - -func TestHandleDeleteFile(t *testing.T) { - type args struct { - cc *hotline.ClientConn - t hotline.Transaction - } - tests := []struct { - name string - args args - wantRes []hotline.Transaction - }{ - { - name: "when user does not have required permission to delete a folder", - args: args{ - cc: &hotline.ClientConn{ - Account: &hotline.Account{ - Access: func() hotline.AccessBitmap { - var bits hotline.AccessBitmap - return bits - }(), - }, - Server: &hotline.Server{ - TextDecoder: charmap.Macintosh.NewDecoder(), - TextEncoder: charmap.Macintosh.NewEncoder(), - Config: hotline.Config{ - FileRoot: func() string { - return "/fakeRoot/Files" - }(), - }, - FS: func() *hotline.MockFileStore { - mfi := &hotline.MockFileInfo{} - mfi.On("Mode").Return(fs.FileMode(0)) - mfi.On("Size").Return(int64(100)) - mfi.On("ModTime").Return(time.Parse(time.Layout, time.Layout)) - mfi.On("IsDir").Return(false) - mfi.On("Name").Return("testfile") - - mfs := &hotline.MockFileStore{} - mfs.On("Stat", "/fakeRoot/Files/aaa/testfile").Return(mfi, nil) - mfs.On("Stat", "/fakeRoot/Files/aaa/.info_testfile").Return(nil, errors.New("err")) - mfs.On("Stat", "/fakeRoot/Files/aaa/.rsrc_testfile").Return(nil, errors.New("err")) - - return mfs - }(), - //Accounts: map[string]*Account{}, - }, - }, - t: hotline.NewTransaction( - hotline.TranDeleteFile, [2]byte{0, 1}, - hotline.NewField(hotline.FieldFileName, []byte("testfile")), - hotline.NewField(hotline.FieldFilePath, []byte{ - 0x00, 0x01, - 0x00, 0x00, - 0x03, - 0x61, 0x61, 0x61, - }), - ), - }, - wantRes: []hotline.Transaction{ - { - IsReply: 0x01, - ErrorCode: [4]byte{0, 0, 0, 1}, - Fields: []hotline.Field{ - hotline.NewField(hotline.FieldError, []byte("You are not allowed to delete files.")), - }, - }, - }, - }, - { - name: "deletes all associated metadata files", - args: args{ - cc: &hotline.ClientConn{ - Account: &hotline.Account{ - Access: func() hotline.AccessBitmap { - var bits hotline.AccessBitmap - bits.Set(hotline.AccessDeleteFile) - return bits - }(), - }, - Server: &hotline.Server{ - TextDecoder: charmap.Macintosh.NewDecoder(), - TextEncoder: charmap.Macintosh.NewEncoder(), - Config: hotline.Config{ - FileRoot: func() string { - return "/fakeRoot/Files" - }(), - }, - FS: func() *hotline.MockFileStore { - mfi := &hotline.MockFileInfo{} - mfi.On("Mode").Return(fs.FileMode(0)) - mfi.On("Size").Return(int64(100)) - mfi.On("ModTime").Return(time.Parse(time.Layout, time.Layout)) - mfi.On("IsDir").Return(false) - mfi.On("Name").Return("testfile") - - mfs := &hotline.MockFileStore{} - mfs.On("Stat", "/fakeRoot/Files/aaa/testfile").Return(mfi, nil) - mfs.On("Stat", "/fakeRoot/Files/aaa/.info_testfile").Return(nil, errors.New("err")) - mfs.On("Stat", "/fakeRoot/Files/aaa/.rsrc_testfile").Return(nil, errors.New("err")) - - mfs.On("RemoveAll", "/fakeRoot/Files/aaa/testfile").Return(nil) - mfs.On("Remove", "/fakeRoot/Files/aaa/testfile.incomplete").Return(nil) - mfs.On("Remove", "/fakeRoot/Files/aaa/.rsrc_testfile").Return(nil) - mfs.On("Remove", "/fakeRoot/Files/aaa/.info_testfile").Return(nil) - - return mfs - }(), - //Accounts: map[string]*Account{}, - }, - }, - t: hotline.NewTransaction( - hotline.TranDeleteFile, [2]byte{0, 1}, - hotline.NewField(hotline.FieldFileName, []byte("testfile")), - hotline.NewField(hotline.FieldFilePath, []byte{ - 0x00, 0x01, - 0x00, 0x00, - 0x03, - 0x61, 0x61, 0x61, - }), - ), - }, - wantRes: []hotline.Transaction{ - { - IsReply: 0x01, - Fields: []hotline.Field(nil), - }, - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - gotRes := HandleDeleteFile(tt.args.cc, &tt.args.t) - TranAssertEqual(t, tt.wantRes, gotRes) - - tt.args.cc.Server.FS.(*hotline.MockFileStore).AssertExpectations(t) - }) - } -} - -func TestHandleGetFileNameList(t *testing.T) { - type args struct { - cc *hotline.ClientConn - t hotline.Transaction - } - tests := []struct { - name string - args args - wantRes []hotline.Transaction - }{ - { - name: "when FieldFilePath is a drop box, but user does not have AccessViewDropBoxes ", - args: args{ - cc: &hotline.ClientConn{ - Account: &hotline.Account{ - Access: func() hotline.AccessBitmap { - var bits hotline.AccessBitmap - return bits - }(), - }, - Server: &hotline.Server{ - TextDecoder: charmap.Macintosh.NewDecoder(), - TextEncoder: charmap.Macintosh.NewEncoder(), - - Config: hotline.Config{ - FileRoot: func() string { - path, _ := os.Getwd() - return filepath.Join(path, "/test/config/Files/getFileNameListTestDir") - }(), - }, - }, - }, - t: hotline.NewTransaction( - hotline.TranGetFileNameList, [2]byte{0, 1}, - hotline.NewField(hotline.FieldFilePath, []byte{ - 0x00, 0x01, - 0x00, 0x00, - 0x08, - 0x64, 0x72, 0x6f, 0x70, 0x20, 0x62, 0x6f, 0x78, // "drop box" - }), - ), - }, - wantRes: []hotline.Transaction{ - { - IsReply: 0x01, - ErrorCode: [4]byte{0, 0, 0, 1}, - Fields: []hotline.Field{ - hotline.NewField(hotline.FieldError, []byte("You are not allowed to view drop boxes.")), - }, - }, - }, - }, - { - name: "with file root", - args: args{ - cc: &hotline.ClientConn{ - Account: &hotline.Account{ - FileRoot: func() string { - path, _ := os.Getwd() - return filepath.Join(path, "/test/config/Files/getFileNameListTestDir") - }(), - }, - Server: &hotline.Server{TextDecoder: charmap.Macintosh.NewDecoder(), TextEncoder: charmap.Macintosh.NewEncoder()}, - }, - t: hotline.NewTransaction( - hotline.TranGetFileNameList, [2]byte{0, 1}, - hotline.NewField(hotline.FieldFilePath, []byte{ - 0x00, 0x00, - 0x00, 0x00, - }), - ), - }, - wantRes: []hotline.Transaction{ - { - IsReply: 0x01, - Fields: []hotline.Field{ - hotline.NewField( - hotline.FieldFileNameWithInfo, - func() []byte { - fnwi := hotline.FileNameWithInfo{ - FileNameWithInfoHeader: hotline.FileNameWithInfoHeader{ - Type: [4]byte{0x54, 0x45, 0x58, 0x54}, - Creator: [4]byte{0x54, 0x54, 0x58, 0x54}, - FileSize: [4]byte{0, 0, 0x04, 0}, - RSVD: [4]byte{}, - NameScript: [2]byte{}, - NameSize: [2]byte{0, 0x0b}, - }, - Name: []byte("testfile-1k"), - } - b, _ := io.ReadAll(&fnwi) - return b - }(), - ), - }, - }, - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - gotRes := HandleGetFileNameList(tt.args.cc, &tt.args.t) - TranAssertEqual(t, tt.wantRes, gotRes) - }) - } -} - -func TestHandleGetClientInfoText(t *testing.T) { - type args struct { - cc *hotline.ClientConn - t hotline.Transaction - } - tests := []struct { - name string - args args - wantRes []hotline.Transaction - }{ - { - name: "when user does not have required permission", - args: args{ - cc: &hotline.ClientConn{ - Account: &hotline.Account{ - Access: func() hotline.AccessBitmap { - var bits hotline.AccessBitmap - return bits - }(), - }, - Server: &hotline.Server{ - TextDecoder: charmap.Macintosh.NewDecoder(), - TextEncoder: charmap.Macintosh.NewEncoder(), - //Accounts: map[string]*Account{}, - }, - }, - t: hotline.NewTransaction( - hotline.TranGetClientInfoText, [2]byte{0, 1}, - hotline.NewField(hotline.FieldUserID, []byte{0, 1}), - ), - }, - wantRes: []hotline.Transaction{ - { - IsReply: 0x01, - ErrorCode: [4]byte{0, 0, 0, 1}, - Fields: []hotline.Field{ - hotline.NewField(hotline.FieldError, []byte("You are not allowed to get client info.")), - }, - }, - }, - }, - { - name: "with a valid user", - args: args{ - cc: &hotline.ClientConn{ - UserName: []byte("Testy McTest"), - RemoteAddr: "1.2.3.4:12345", - Account: &hotline.Account{ - Access: func() hotline.AccessBitmap { - var bits hotline.AccessBitmap - bits.Set(hotline.AccessGetClientInfo) - return bits - }(), - Name: "test", - Login: "test", - }, - 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{ - UserName: []byte("Testy McTest"), - RemoteAddr: "1.2.3.4:12345", - Account: &hotline.Account{ - Access: func() hotline.AccessBitmap { - var bits hotline.AccessBitmap - bits.Set(hotline.AccessGetClientInfo) - return bits - }(), - Name: "test", - Login: "test", - }, - }, - ) - return &m - }(), - }, - ClientFileTransferMgr: hotline.ClientFileTransferMgr{}, - }, - t: hotline.NewTransaction( - hotline.TranGetClientInfoText, [2]byte{0, 1}, - hotline.NewField(hotline.FieldUserID, []byte{0, 1}), - ), - }, - wantRes: []hotline.Transaction{ - { - IsReply: 0x01, - Fields: []hotline.Field{ - hotline.NewField(hotline.FieldData, []byte( - strings.ReplaceAll(`Nickname: Testy McTest -Name: test -Account: test -Address: 1.2.3.4:12345 - --------- File Downloads --------- - -None. - -------- Folder Downloads -------- - -None. - ---------- File Uploads ---------- - -None. - --------- Folder Uploads --------- - -None. - -------- Waiting Downloads ------- - -None. - -`, "\n", "\r")), - ), - hotline.NewField(hotline.FieldUserName, []byte("Testy McTest")), - }, - }, - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - gotRes := HandleGetClientInfoText(tt.args.cc, &tt.args.t) - TranAssertEqual(t, tt.wantRes, gotRes) - }) - } -} - -func TestHandleTranAgreed(t *testing.T) { - type args struct { - cc *hotline.ClientConn - t hotline.Transaction - } - tests := []struct { - name string - args args - wantRes []hotline.Transaction - }{ - { - name: "normal request flow", - args: args{ - cc: &hotline.ClientConn{ - Account: &hotline.Account{ - Access: func() hotline.AccessBitmap { - var bits hotline.AccessBitmap - bits.Set(hotline.AccessDisconUser) - bits.Set(hotline.AccessAnyName) - return bits - }()}, - Icon: []byte{0, 1}, - Flags: [2]byte{0, 1}, - Version: []byte{0, 1}, - ID: [2]byte{0, 1}, - Logger: NewTestLogger(), - Server: &hotline.Server{ - TextDecoder: charmap.Macintosh.NewDecoder(), - TextEncoder: charmap.Macintosh.NewEncoder(), - Config: hotline.Config{ - BannerFile: "Banner.jpg", - }, - ClientMgr: func() *hotline.MockClientMgr { - m := hotline.MockClientMgr{} - m.On("List").Return([]*hotline.ClientConn{ - //{ - // ID: [2]byte{0, 2}, - // UserName: []byte("UserB"), - //}, - }, - ) - return &m - }(), - }, - }, - t: hotline.NewTransaction( - hotline.TranAgreed, [2]byte{}, - hotline.NewField(hotline.FieldUserName, []byte("username")), - hotline.NewField(hotline.FieldUserIconID, []byte{0, 1}), - hotline.NewField(hotline.FieldOptions, []byte{0, 0}), - ), - }, - wantRes: []hotline.Transaction{ - { - ClientID: [2]byte{0, 1}, - Type: [2]byte{0, 0x7a}, - Fields: []hotline.Field{ - hotline.NewField(hotline.FieldBannerType, []byte("JPEG")), - }, - }, - { - ClientID: [2]byte{0, 1}, - IsReply: 0x01, - Fields: []hotline.Field{}, - }, - }, - }, - { - name: "with gif banner", - args: args{ - cc: &hotline.ClientConn{ - Account: &hotline.Account{ - Access: func() hotline.AccessBitmap { - var bits hotline.AccessBitmap - bits.Set(hotline.AccessDisconUser) - bits.Set(hotline.AccessAnyName) - return bits - }()}, - Icon: []byte{0, 1}, - Flags: [2]byte{0, 1}, - Version: []byte{0, 1}, - ID: [2]byte{0, 1}, - Logger: NewTestLogger(), - Server: &hotline.Server{ - TextDecoder: charmap.Macintosh.NewDecoder(), - TextEncoder: charmap.Macintosh.NewEncoder(), - Config: hotline.Config{ - BannerFile: "Banner.gif", - }, - ClientMgr: func() *hotline.MockClientMgr { - m := hotline.MockClientMgr{} - m.On("List").Return([]*hotline.ClientConn{}) - return &m - }(), - }, - }, - t: hotline.NewTransaction( - hotline.TranAgreed, [2]byte{}, - hotline.NewField(hotline.FieldUserName, []byte("username")), - hotline.NewField(hotline.FieldUserIconID, []byte{0, 1}), - hotline.NewField(hotline.FieldOptions, []byte{0, 0}), - ), - }, - wantRes: []hotline.Transaction{ - { - ClientID: [2]byte{0, 1}, - Type: [2]byte{0, 0x7a}, - Fields: []hotline.Field{ - hotline.NewField(hotline.FieldBannerType, []byte("GIFf")), - }, - }, - { - ClientID: [2]byte{0, 1}, - IsReply: 0x01, - Fields: []hotline.Field{}, - }, - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - gotRes := HandleTranAgreed(tt.args.cc, &tt.args.t) - TranAssertEqual(t, tt.wantRes, gotRes) - }) - } -} - -func TestHandleSetClientUserInfo(t *testing.T) { - type args struct { - cc *hotline.ClientConn - t hotline.Transaction - } - tests := []struct { - name string - args args - wantRes []hotline.Transaction - }{ - { - name: "when client does not have AccessAnyName", - args: args{ - cc: &hotline.ClientConn{ - Account: &hotline.Account{ - Access: func() hotline.AccessBitmap { - var bits hotline.AccessBitmap - return bits - }(), - }, - ID: [2]byte{0, 1}, - UserName: []byte("Guest"), - Flags: [2]byte{0, 1}, - Server: &hotline.Server{ - TextDecoder: charmap.Macintosh.NewDecoder(), - TextEncoder: charmap.Macintosh.NewEncoder(), - ClientMgr: func() *hotline.MockClientMgr { - m := hotline.MockClientMgr{} - m.On("List").Return([]*hotline.ClientConn{ - { - ID: [2]byte{0, 1}, - }, - }) - return &m - }(), - }, - }, - t: hotline.NewTransaction( - hotline.TranSetClientUserInfo, [2]byte{}, - hotline.NewField(hotline.FieldUserIconID, []byte{0, 1}), - hotline.NewField(hotline.FieldUserName, []byte("NOPE")), - ), - }, - wantRes: []hotline.Transaction{ - { - ClientID: [2]byte{0, 1}, - Type: [2]byte{0x01, 0x2d}, - Fields: []hotline.Field{ - hotline.NewField(hotline.FieldUserID, []byte{0, 1}), - hotline.NewField(hotline.FieldUserIconID, []byte{0, 1}), - hotline.NewField(hotline.FieldUserFlags, []byte{0, 1}), - hotline.NewField(hotline.FieldUserName, []byte("Guest"))}, - }, - }, - }, - { - name: "when client has AccessAnyName and changes username", - args: args{ - cc: &hotline.ClientConn{ - Account: &hotline.Account{ - Access: func() hotline.AccessBitmap { - var bits hotline.AccessBitmap - bits.Set(hotline.AccessAnyName) - return bits - }(), - }, - ID: [2]byte{0, 1}, - UserName: []byte("Guest"), - Flags: [2]byte{0, 1}, - Server: &hotline.Server{ - TextDecoder: charmap.Macintosh.NewDecoder(), - TextEncoder: charmap.Macintosh.NewEncoder(), - ClientMgr: func() *hotline.MockClientMgr { - m := hotline.MockClientMgr{} - m.On("List").Return([]*hotline.ClientConn{ - { - ID: [2]byte{0, 1}, - }, - }) - return &m - }(), - }, - }, - t: hotline.NewTransaction( - hotline.TranSetClientUserInfo, [2]byte{}, - hotline.NewField(hotline.FieldUserName, []byte("NewName")), - ), - }, - wantRes: []hotline.Transaction{ - { - ClientID: [2]byte{0, 1}, - Type: [2]byte{0x01, 0x2d}, - Fields: []hotline.Field{ - hotline.NewField(hotline.FieldUserID, []byte{0, 1}), - hotline.NewField(hotline.FieldUserIconID, []byte{}), - hotline.NewField(hotline.FieldUserFlags, []byte{0, 1}), - hotline.NewField(hotline.FieldUserName, []byte("NewName"))}, - }, - }, - }, - { - name: "when client updates icon with 4-byte data", - args: args{ - cc: &hotline.ClientConn{ - Account: &hotline.Account{ - Access: func() hotline.AccessBitmap { - var bits hotline.AccessBitmap - return bits - }(), - }, - ID: [2]byte{0, 1}, - UserName: []byte("Guest"), - Flags: [2]byte{0, 1}, - Server: &hotline.Server{ - TextDecoder: charmap.Macintosh.NewDecoder(), - TextEncoder: charmap.Macintosh.NewEncoder(), - ClientMgr: func() *hotline.MockClientMgr { - m := hotline.MockClientMgr{} - m.On("List").Return([]*hotline.ClientConn{ - { - ID: [2]byte{0, 1}, - }, - }) - return &m - }(), - }, - }, - t: hotline.NewTransaction( - hotline.TranSetClientUserInfo, [2]byte{}, - hotline.NewField(hotline.FieldUserIconID, []byte{0, 1, 0, 5}), - ), - }, - wantRes: []hotline.Transaction{ - { - ClientID: [2]byte{0, 1}, - Type: [2]byte{0x01, 0x2d}, - Fields: []hotline.Field{ - hotline.NewField(hotline.FieldUserID, []byte{0, 1}), - hotline.NewField(hotline.FieldUserIconID, []byte{0, 5}), - hotline.NewField(hotline.FieldUserFlags, []byte{0, 1}), - hotline.NewField(hotline.FieldUserName, []byte("Guest"))}, - }, - }, - }, - { - name: "when client updates icon with 2-byte data", - args: args{ - cc: &hotline.ClientConn{ - Account: &hotline.Account{ - Access: func() hotline.AccessBitmap { - var bits hotline.AccessBitmap - return bits - }(), - }, - ID: [2]byte{0, 1}, - UserName: []byte("Guest"), - Flags: [2]byte{0, 1}, - Server: &hotline.Server{ - TextDecoder: charmap.Macintosh.NewDecoder(), - TextEncoder: charmap.Macintosh.NewEncoder(), - ClientMgr: func() *hotline.MockClientMgr { - m := hotline.MockClientMgr{} - m.On("List").Return([]*hotline.ClientConn{ - { - ID: [2]byte{0, 1}, - }, - }) - return &m - }(), - }, - }, - t: hotline.NewTransaction( - hotline.TranSetClientUserInfo, [2]byte{}, - hotline.NewField(hotline.FieldUserIconID, []byte{0, 3}), - ), - }, - wantRes: []hotline.Transaction{ - { - ClientID: [2]byte{0, 1}, - Type: [2]byte{0x01, 0x2d}, - Fields: []hotline.Field{ - hotline.NewField(hotline.FieldUserID, []byte{0, 1}), - hotline.NewField(hotline.FieldUserIconID, []byte{0, 3}), - hotline.NewField(hotline.FieldUserFlags, []byte{0, 1}), - hotline.NewField(hotline.FieldUserName, []byte("Guest"))}, - }, - }, - }, - { - name: "when client sets user options with auto-reply", - args: args{ - cc: &hotline.ClientConn{ - Account: &hotline.Account{ - Access: func() hotline.AccessBitmap { - var bits hotline.AccessBitmap - return bits - }(), - }, - ID: [2]byte{0, 1}, - UserName: []byte("Guest"), - Flags: [2]byte{0, 1}, - Version: []byte{0x01, 0x03}, - Server: &hotline.Server{ - TextDecoder: charmap.Macintosh.NewDecoder(), - TextEncoder: charmap.Macintosh.NewEncoder(), - ClientMgr: func() *hotline.MockClientMgr { - m := hotline.MockClientMgr{} - m.On("List").Return([]*hotline.ClientConn{ - { - ID: [2]byte{0, 1}, - }, - }) - return &m - }(), - }, - }, - t: hotline.NewTransaction( - hotline.TranSetClientUserInfo, [2]byte{}, - hotline.NewField(hotline.FieldOptions, []byte{0, 4}), - hotline.NewField(hotline.FieldAutomaticResponse, []byte("Away message")), - ), - }, - wantRes: []hotline.Transaction{ - { - ClientID: [2]byte{0, 1}, - Type: [2]byte{0x01, 0x2d}, - Fields: []hotline.Field{ - hotline.NewField(hotline.FieldUserID, []byte{0, 1}), - hotline.NewField(hotline.FieldUserIconID, []byte{}), - hotline.NewField(hotline.FieldUserFlags, []byte{0, 1}), - hotline.NewField(hotline.FieldUserName, []byte("Guest"))}, - }, - }, - }, - { - name: "when client sets refuse private messages flag", - args: args{ - cc: &hotline.ClientConn{ - Account: &hotline.Account{ - Access: func() hotline.AccessBitmap { - var bits hotline.AccessBitmap - return bits - }(), - }, - ID: [2]byte{0, 1}, - UserName: []byte("Guest"), - Flags: [2]byte{0, 1}, - Version: []byte{0x01, 0x03}, - Server: &hotline.Server{ - TextDecoder: charmap.Macintosh.NewDecoder(), - TextEncoder: charmap.Macintosh.NewEncoder(), - ClientMgr: func() *hotline.MockClientMgr { - m := hotline.MockClientMgr{} - m.On("List").Return([]*hotline.ClientConn{ - { - ID: [2]byte{0, 1}, - }, - }) - return &m - }(), - }, - }, - t: hotline.NewTransaction( - hotline.TranSetClientUserInfo, [2]byte{}, - hotline.NewField(hotline.FieldOptions, []byte{0, 2}), - ), - }, - wantRes: []hotline.Transaction{ - { - ClientID: [2]byte{0, 1}, - Type: [2]byte{0x01, 0x2d}, - Fields: []hotline.Field{ - hotline.NewField(hotline.FieldUserID, []byte{0, 1}), - hotline.NewField(hotline.FieldUserIconID, []byte{}), - hotline.NewField(hotline.FieldUserFlags, []byte{0, 9}), - hotline.NewField(hotline.FieldUserName, []byte("Guest"))}, - }, - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - gotRes := HandleSetClientUserInfo(tt.args.cc, &tt.args.t) - TranAssertEqual(t, tt.wantRes, gotRes) - }) - } -} - -// When the news path is empty, the handler should return an error reply rather -// than silently discarding the request. -func TestHandleDelNewsItem_emptyPathReturnsError(t *testing.T) { - cc := &hotline.ClientConn{ - ID: [2]byte{0, 1}, - Logger: NewTestLogger(), - Server: &hotline.Server{ - TextDecoder: charmap.Macintosh.NewDecoder(), - TextEncoder: charmap.Macintosh.NewEncoder(), - }, - } - tr := hotline.NewTransaction(hotline.TranDelNewsItem, [2]byte{}) - - gotRes := HandleDelNewsItem(cc, &tr) - - 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(ErrMsgDeleteNewsItem)), - }, - }, - } - TranAssertEqual(t, wantRes, gotRes) -} - -func TestHandleDelNewsItem(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 delete a news category", - args: args{ - cc: &hotline.ClientConn{ - Account: &hotline.Account{ - Access: hotline.AccessBitmap{}, - }, - ID: [2]byte{0, 1}, - Server: &hotline.Server{ - TextDecoder: charmap.Macintosh.NewDecoder(), - TextEncoder: charmap.Macintosh.NewEncoder(), - ThreadedNewsMgr: func() *hotline.MockThreadNewsMgr { - m := hotline.MockThreadNewsMgr{} - m.On("NewsItem", []string{"test"}).Return(hotline.NewsCategoryListData15{ - Type: hotline.NewsCategory, - }) - return &m - }(), - }, - }, - t: hotline.NewTransaction( - hotline.TranDelNewsItem, [2]byte{}, - hotline.NewField(hotline.FieldNewsPath, - []byte{ - 0, 1, - 0, 0, - 4, - 0x74, 0x65, 0x73, 0x74, - }, - ), - ), - }, - wantRes: []hotline.Transaction{ - { - ClientID: [2]byte{0, 1}, - IsReply: 0x01, - ErrorCode: [4]byte{0, 0, 0, 1}, - Fields: []hotline.Field{ - hotline.NewField(hotline.FieldError, []byte("You are not allowed to delete news categories.")), - }, - }, - }, - }, - { - name: "when user does not have permission to delete a news folder", - args: args{ - cc: &hotline.ClientConn{ - Account: &hotline.Account{ - Access: hotline.AccessBitmap{}, - }, - ID: [2]byte{0, 1}, - Server: &hotline.Server{ - TextDecoder: charmap.Macintosh.NewDecoder(), - TextEncoder: charmap.Macintosh.NewEncoder(), - ThreadedNewsMgr: func() *hotline.MockThreadNewsMgr { - m := hotline.MockThreadNewsMgr{} - m.On("NewsItem", []string{"test"}).Return(hotline.NewsCategoryListData15{ - Type: hotline.NewsBundle, - }) - return &m - }(), - }, - }, - t: hotline.NewTransaction( - hotline.TranDelNewsItem, [2]byte{}, - hotline.NewField(hotline.FieldNewsPath, - []byte{ - 0, 1, - 0, 0, - 4, - 0x74, 0x65, 0x73, 0x74, - }, - ), - ), - }, - wantRes: []hotline.Transaction{ - { - ClientID: [2]byte{0, 1}, - IsReply: 0x01, - ErrorCode: [4]byte{0, 0, 0, 1}, - Fields: []hotline.Field{ - hotline.NewField(hotline.FieldError, []byte("You are not allowed to delete news folders.")), - }, - }, - }, - }, - { - name: "when user deletes a news folder", - args: args{ - cc: &hotline.ClientConn{ - Account: &hotline.Account{ - Access: func() hotline.AccessBitmap { - var bits hotline.AccessBitmap - bits.Set(hotline.AccessNewsDeleteFldr) - return bits - }(), - }, - ID: [2]byte{0, 1}, - Server: &hotline.Server{ - TextDecoder: charmap.Macintosh.NewDecoder(), - TextEncoder: charmap.Macintosh.NewEncoder(), - ThreadedNewsMgr: func() *hotline.MockThreadNewsMgr { - m := hotline.MockThreadNewsMgr{} - m.On("NewsItem", []string{"test"}).Return(hotline.NewsCategoryListData15{Type: hotline.NewsBundle}) - m.On("DeleteNewsItem", []string{"test"}).Return(nil) - return &m - }(), - }, - }, - t: hotline.NewTransaction( - hotline.TranDelNewsItem, [2]byte{}, - hotline.NewField(hotline.FieldNewsPath, - []byte{ - 0, 1, - 0, 0, - 4, - 0x74, 0x65, 0x73, 0x74, - }, - ), - ), - }, - wantRes: []hotline.Transaction{ - { - ClientID: [2]byte{0, 1}, - IsReply: 0x01, - Fields: []hotline.Field{}, - }, - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - gotRes := HandleDelNewsItem(tt.args.cc, &tt.args.t) - - TranAssertEqual(t, tt.wantRes, gotRes) - }) - } -} - -func TestHandleTranOldPostNews(t *testing.T) { - type args struct { - cc *hotline.ClientConn - t hotline.Transaction - } - tests := []struct { - name string - args args - wantRes []hotline.Transaction - }{ - { - name: "when user does not have required permission", - args: args{ - cc: &hotline.ClientConn{ - Account: &hotline.Account{ - Access: hotline.AccessBitmap{}, - }, - }, - t: hotline.NewTransaction( - hotline.TranOldPostNews, [2]byte{0, 1}, - hotline.NewField(hotline.FieldData, []byte("hai")), - ), - }, - wantRes: []hotline.Transaction{ - { - IsReply: 0x01, - ErrorCode: [4]byte{0, 0, 0, 1}, - Fields: []hotline.Field{ - hotline.NewField(hotline.FieldError, []byte("You are not allowed to post news.")), - }, - }, - }, - }, - { - name: "when user posts news update", - args: args{ - cc: &hotline.ClientConn{ - Account: &hotline.Account{ - Access: func() hotline.AccessBitmap { - var bits hotline.AccessBitmap - bits.Set(hotline.AccessNewsPostArt) - return bits - }(), - }, - Server: &hotline.Server{ - TextDecoder: charmap.Macintosh.NewDecoder(), - TextEncoder: charmap.Macintosh.NewEncoder(), - Config: hotline.Config{ - NewsDateFormat: "", - }, - ClientMgr: func() *hotline.MockClientMgr { - m := hotline.MockClientMgr{} - m.On("List").Return([]*hotline.ClientConn{}) - return &m - }(), - MessageBoard: func() *mockReadWriteSeeker { - m := mockReadWriteSeeker{} - m.On("Seek", int64(0), 0).Return(int64(0), nil) - m.On("Read", mock.AnythingOfType("[]uint8")).Run(func(args mock.Arguments) { - arg := args.Get(0).([]uint8) - copy(arg, "TEST") - }).Return(4, io.EOF) - m.On("Write", mock.AnythingOfType("[]uint8")).Return(3, nil) - return &m - }(), - }, - }, - t: hotline.NewTransaction( - hotline.TranOldPostNews, [2]byte{0, 1}, - hotline.NewField(hotline.FieldData, []byte("hai")), - ), - }, - wantRes: []hotline.Transaction{ - { - IsReply: 0x01, - }, - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - gotRes := HandleTranOldPostNews(tt.args.cc, &tt.args.t) - - TranAssertEqual(t, tt.wantRes, gotRes) - }) - } -} - -func TestHandleInviteNewChat(t *testing.T) { - type args struct { - cc *hotline.ClientConn - t hotline.Transaction - } - tests := []struct { - name string - args args - wantRes []hotline.Transaction - }{ - { - name: "when user does not have required permission", - args: args{ - cc: &hotline.ClientConn{ - Account: &hotline.Account{ - Access: func() hotline.AccessBitmap { - var bits hotline.AccessBitmap - return bits - }(), - }, - }, - t: hotline.NewTransaction(hotline.TranInviteNewChat, [2]byte{0, 1}), - }, - wantRes: []hotline.Transaction{ - { - IsReply: 0x01, - ErrorCode: [4]byte{0, 0, 0, 1}, - Fields: []hotline.Field{ - hotline.NewField(hotline.FieldError, []byte("You are not allowed to request private chat.")), - }, - }, - }, - }, - { - name: "when userA invites userB to new private chat", - args: args{ - cc: &hotline.ClientConn{ - ID: [2]byte{0, 1}, - Account: &hotline.Account{ - Access: func() hotline.AccessBitmap { - var bits hotline.AccessBitmap - bits.Set(hotline.AccessOpenChat) - return bits - }(), - }, - UserName: []byte("UserA"), - Icon: []byte{0, 1}, - Flags: [2]byte{0, 0}, - Server: &hotline.Server{ - TextDecoder: charmap.Macintosh.NewDecoder(), - TextEncoder: charmap.Macintosh.NewEncoder(), - ClientMgr: func() *hotline.MockClientMgr { - m := hotline.MockClientMgr{} - m.On("Get", hotline.ClientID{0x0, 0x2}).Return(&hotline.ClientConn{ - ID: [2]byte{0, 2}, - UserName: []byte("UserB"), - }) - return &m - }(), - ChatMgr: func() *hotline.MockChatManager { - m := hotline.MockChatManager{} - m.On("New", mock.AnythingOfType("*hotline.ClientConn")).Return(hotline.ChatID{0x52, 0xfd, 0xfc, 0x07}) - return &m - }(), - }, - }, - t: hotline.NewTransaction( - hotline.TranInviteNewChat, [2]byte{0, 1}, - hotline.NewField(hotline.FieldUserID, []byte{0, 2}), - ), - }, - wantRes: []hotline.Transaction{ - { - ClientID: [2]byte{0, 2}, - Type: [2]byte{0, 0x71}, - Fields: []hotline.Field{ - hotline.NewField(hotline.FieldChatID, []byte{0x52, 0xfd, 0xfc, 0x07}), - hotline.NewField(hotline.FieldUserName, []byte("UserA")), - hotline.NewField(hotline.FieldUserID, []byte{0, 1}), - }, - }, - { - ClientID: [2]byte{0, 1}, - IsReply: 0x01, - Fields: []hotline.Field{ - hotline.NewField(hotline.FieldChatID, []byte{0x52, 0xfd, 0xfc, 0x07}), - hotline.NewField(hotline.FieldUserName, []byte("UserA")), - hotline.NewField(hotline.FieldUserID, []byte{0, 1}), - hotline.NewField(hotline.FieldUserIconID, []byte{0, 1}), - hotline.NewField(hotline.FieldUserFlags, []byte{0, 0}), - }, - }, - }, - }, - { - name: "when userA invites userB to new private chat, but UserB has refuse private chat enabled", - args: args{ - cc: &hotline.ClientConn{ - ID: [2]byte{0, 1}, - Account: &hotline.Account{ - Access: func() hotline.AccessBitmap { - var bits hotline.AccessBitmap - bits.Set(hotline.AccessOpenChat) - return bits - }(), - }, - UserName: []byte("UserA"), - Icon: []byte{0, 1}, - Flags: [2]byte{0, 0}, - Server: &hotline.Server{ - TextDecoder: charmap.Macintosh.NewDecoder(), - TextEncoder: charmap.Macintosh.NewEncoder(), - ClientMgr: func() *hotline.MockClientMgr { - m := hotline.MockClientMgr{} - m.On("Get", hotline.ClientID{0, 2}).Return(&hotline.ClientConn{ - ID: [2]byte{0, 2}, - Icon: []byte{0, 1}, - UserName: []byte("UserB"), - Flags: [2]byte{255, 255}, - }) - return &m - }(), - ChatMgr: func() *hotline.MockChatManager { - m := hotline.MockChatManager{} - m.On("New", mock.AnythingOfType("*hotline.ClientConn")).Return(hotline.ChatID{0x52, 0xfd, 0xfc, 0x07}) - return &m - }(), - }, - }, - t: hotline.NewTransaction( - hotline.TranInviteNewChat, [2]byte{0, 1}, - hotline.NewField(hotline.FieldUserID, []byte{0, 2}), - ), - }, - wantRes: []hotline.Transaction{ - { - ClientID: [2]byte{0, 1}, - Type: [2]byte{0, 0x68}, - Fields: []hotline.Field{ - hotline.NewField(hotline.FieldData, []byte("UserB does not accept private chats.")), - hotline.NewField(hotline.FieldUserName, []byte("UserB")), - hotline.NewField(hotline.FieldUserID, []byte{0, 2}), - hotline.NewField(hotline.FieldOptions, []byte{0, 2}), - }, - }, - { - ClientID: [2]byte{0, 1}, - IsReply: 0x01, - Fields: []hotline.Field{ - hotline.NewField(hotline.FieldChatID, []byte{0x52, 0xfd, 0xfc, 0x07}), - hotline.NewField(hotline.FieldUserName, []byte("UserA")), - hotline.NewField(hotline.FieldUserID, []byte{0, 1}), - hotline.NewField(hotline.FieldUserIconID, []byte{0, 1}), - hotline.NewField(hotline.FieldUserFlags, []byte{0, 0}), - }, - }, - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - - gotRes := HandleInviteNewChat(tt.args.cc, &tt.args.t) - - TranAssertEqual(t, tt.wantRes, gotRes) - }) - } -} - -func TestHandleGetNewsArtData(t *testing.T) { - type args struct { - cc *hotline.ClientConn - t hotline.Transaction - } - tests := []struct { - name string - args args - wantRes []hotline.Transaction - }{ - { - name: "when user does not have required permission", - args: args{ - cc: &hotline.ClientConn{Account: &hotline.Account{}}, - t: hotline.NewTransaction( - hotline.TranGetNewsArtData, [2]byte{0, 1}, - ), - }, - wantRes: []hotline.Transaction{ - { - IsReply: 0x01, - ErrorCode: [4]byte{0, 0, 0, 1}, - Fields: []hotline.Field{ - hotline.NewField(hotline.FieldError, []byte("You are not allowed to read news.")), - }, - }, - }, - }, - { - name: "when user has required permission", - 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("GetArticle", []string{"Example Category"}, uint32(1)).Return(&hotline.NewsArtData{ - Title: "title", - Poster: "poster", - Date: [8]byte{}, - PrevArt: [4]byte{0, 0, 0, 1}, - NextArt: [4]byte{0, 0, 0, 2}, - ParentArt: [4]byte{0, 0, 0, 3}, - FirstChildArt: [4]byte{0, 0, 0, 4}, - DataFlav: []byte("text/plain"), - Data: "article data", - }) - return &m - }(), - }, - }, - t: hotline.NewTransaction( - hotline.TranGetNewsArtData, [2]byte{0, 1}, - hotline.NewField(hotline.FieldNewsPath, []byte{ - // Example Category - 0x00, 0x01, 0x00, 0x00, 0x10, 0x45, 0x78, 0x61, 0x6d, 0x70, 0x6c, 0x65, 0x20, 0x43, 0x61, 0x74, 0x65, 0x67, 0x6f, 0x72, 0x79, - }), - hotline.NewField(hotline.FieldNewsArtID, []byte{0, 1}), - ), - }, - wantRes: []hotline.Transaction{ - { - IsReply: 1, - Fields: []hotline.Field{ - hotline.NewField(hotline.FieldNewsArtTitle, []byte("title")), - hotline.NewField(hotline.FieldNewsArtPoster, []byte("poster")), - hotline.NewField(hotline.FieldNewsArtDate, []byte{0, 0, 0, 0, 0, 0, 0, 0}), - hotline.NewField(hotline.FieldNewsArtPrevArt, []byte{0, 0, 0, 1}), - hotline.NewField(hotline.FieldNewsArtNextArt, []byte{0, 0, 0, 2}), - hotline.NewField(hotline.FieldNewsArtParentArt, []byte{0, 0, 0, 3}), - hotline.NewField(hotline.FieldNewsArt1stChildArt, []byte{0, 0, 0, 4}), - hotline.NewField(hotline.FieldNewsArtDataFlav, []byte("text/plain")), - hotline.NewField(hotline.FieldNewsArtData, []byte("article data")), - }, - }, - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - gotRes := HandleGetNewsArtData(tt.args.cc, &tt.args.t) - TranAssertEqual(t, tt.wantRes, gotRes) - }) - } -} - -func TestHandleGetNewsArtNameList(t *testing.T) { - type args struct { - cc *hotline.ClientConn - t hotline.Transaction - } - tests := []struct { - name string - args args - wantRes []hotline.Transaction - }{ - { - name: "when user does not have required permission", - args: args{ - cc: &hotline.ClientConn{ - Account: &hotline.Account{ - Access: func() hotline.AccessBitmap { - var bits hotline.AccessBitmap - return bits - }(), - }, - Server: &hotline.Server{ - TextDecoder: charmap.Macintosh.NewDecoder(), - TextEncoder: charmap.Macintosh.NewEncoder(), - //Accounts: map[string]*Account{}, - }, - }, - t: hotline.NewTransaction( - hotline.TranGetNewsArtNameList, [2]byte{0, 1}, - ), - }, - wantRes: []hotline.Transaction{ - { - Flags: 0x00, - IsReply: 0x01, - Type: [2]byte{0, 0}, - ErrorCode: [4]byte{0, 0, 0, 1}, - Fields: []hotline.Field{ - hotline.NewField(hotline.FieldError, []byte("You are not allowed to read news.")), - }, - }, - }, - }, - { - 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) { - gotRes := HandleGetNewsArtNameList(tt.args.cc, &tt.args.t) - - TranAssertEqual(t, tt.wantRes, gotRes) - }) - } -} - -func TestHandleNewNewsFldr(t *testing.T) { - type args struct { - cc *hotline.ClientConn - t hotline.Transaction - } - tests := []struct { - name string - args args - wantRes []hotline.Transaction - }{ - { - name: "when user does not have required permission", - args: args{ - cc: &hotline.ClientConn{ - Account: &hotline.Account{ - Access: func() hotline.AccessBitmap { - var bits hotline.AccessBitmap - return bits - }(), - }, - Server: &hotline.Server{ - TextDecoder: charmap.Macintosh.NewDecoder(), - TextEncoder: charmap.Macintosh.NewEncoder(), - //Accounts: map[string]*Account{}, - }, - }, - t: hotline.NewTransaction( - hotline.TranGetNewsArtNameList, [2]byte{0, 1}, - ), - }, - wantRes: []hotline.Transaction{ - { - Flags: 0x00, - IsReply: 0x01, - Type: [2]byte{0, 0}, - ErrorCode: [4]byte{0, 0, 0, 1}, - Fields: []hotline.Field{ - hotline.NewField(hotline.FieldError, []byte("You are not allowed to create news folders.")), - }, - }, - }, - }, - { - name: "with a valid request", - args: args{ - cc: &hotline.ClientConn{ - Account: &hotline.Account{ - Access: func() hotline.AccessBitmap { - var bits hotline.AccessBitmap - bits.Set(hotline.AccessNewsCreateFldr) - return bits - }(), - }, - Logger: NewTestLogger(), - ID: [2]byte{0, 1}, - Server: &hotline.Server{ - TextDecoder: charmap.Macintosh.NewDecoder(), - TextEncoder: charmap.Macintosh.NewEncoder(), - ThreadedNewsMgr: func() *hotline.MockThreadNewsMgr { - m := hotline.MockThreadNewsMgr{} - m.On("CreateGrouping", []string{"test"}, "testFolder", hotline.NewsBundle).Return(nil) - return &m - }(), - }, - }, - t: hotline.NewTransaction( - hotline.TranGetNewsArtNameList, [2]byte{0, 1}, - hotline.NewField(hotline.FieldFileName, []byte("testFolder")), - hotline.NewField(hotline.FieldNewsPath, - []byte{ - 0, 1, - 0, 0, - 4, - 0x74, 0x65, 0x73, 0x74, - }, - ), - ), - }, - wantRes: []hotline.Transaction{ - { - ClientID: [2]byte{0, 1}, - IsReply: 0x01, - Fields: []hotline.Field{}, - }, - }, - }, - { - name: "when CreateGrouping returns an error", - args: args{ - cc: &hotline.ClientConn{ - Account: &hotline.Account{ - Access: func() hotline.AccessBitmap { - var bits hotline.AccessBitmap - bits.Set(hotline.AccessNewsCreateFldr) - return bits - }(), - }, - Logger: NewTestLogger(), - ID: [2]byte{0, 1}, - Server: &hotline.Server{ - TextDecoder: charmap.Macintosh.NewDecoder(), - TextEncoder: charmap.Macintosh.NewEncoder(), - ThreadedNewsMgr: func() *hotline.MockThreadNewsMgr { - m := hotline.MockThreadNewsMgr{} - m.On("CreateGrouping", []string{"test"}, "testFolder", hotline.NewsBundle).Return(errors.New("write error")) - return &m - }(), - }, - }, - t: hotline.NewTransaction( - hotline.TranGetNewsArtNameList, [2]byte{0, 1}, - hotline.NewField(hotline.FieldFileName, []byte("testFolder")), - hotline.NewField(hotline.FieldNewsPath, - []byte{ - 0, 1, - 0, 0, - 4, - 0x74, 0x65, 0x73, 0x74, - }, - ), - ), - }, - wantRes: []hotline.Transaction{ - { - ClientID: [2]byte{0, 1}, - IsReply: 0x01, - ErrorCode: [4]byte{0, 0, 0, 1}, - Fields: []hotline.Field{ - hotline.NewField(hotline.FieldError, []byte("Error creating news folder.")), - }, - }, - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - gotRes := HandleNewNewsFldr(tt.args.cc, &tt.args.t) - - TranAssertEqual(t, tt.wantRes, gotRes) - }) - } -} - -func TestHandleDownloadBanner(t *testing.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}) - - 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) { - type args struct { - cc *hotline.ClientConn - t hotline.Transaction - } - tests := []struct { - name string - args args - wantRes []hotline.Transaction - }{ - { - name: "without required permission", - args: args{ - cc: &hotline.ClientConn{ - Account: &hotline.Account{ - Access: func() hotline.AccessBitmap { - var bits hotline.AccessBitmap - return bits - }(), - }, - }, - t: hotline.NewTransaction( - hotline.TranPostNewsArt, - [2]byte{0, 0}, - ), - }, - wantRes: []hotline.Transaction{ - { - IsReply: 0x01, - ErrorCode: [4]byte{0, 0, 0, 1}, - Fields: []hotline.Field{ - hotline.NewField(hotline.FieldError, []byte("You are not allowed to post news articles.")), - }, - }, - }, - }, - { - name: "with required permission", - args: args{ - cc: &hotline.ClientConn{ - Server: &hotline.Server{ - TextDecoder: charmap.Macintosh.NewDecoder(), - TextEncoder: charmap.Macintosh.NewEncoder(), - ThreadedNewsMgr: func() *hotline.MockThreadNewsMgr { - m := hotline.MockThreadNewsMgr{} - m.On("PostArticle", []string{"www"}, uint32(0), mock.AnythingOfType("hotline.NewsArtData")).Return(nil) - return &m - }(), - }, - Account: &hotline.Account{ - Access: func() hotline.AccessBitmap { - var bits hotline.AccessBitmap - bits.Set(hotline.AccessNewsPostArt) - return bits - }(), - }, - }, - t: hotline.NewTransaction( - hotline.TranPostNewsArt, - [2]byte{0, 0}, - hotline.NewField(hotline.FieldNewsPath, []byte{0x00, 0x01, 0x00, 0x00, 0x03, 0x77, 0x77, 0x77}), - hotline.NewField(hotline.FieldNewsArtID, []byte{0x00, 0x00, 0x00, 0x00}), - ), - }, - wantRes: []hotline.Transaction{ - { - IsReply: 0x01, - ErrorCode: [4]byte{0, 0, 0, 0}, - Fields: []hotline.Field{}, - }, - }, - }, - { - name: "when PostArticle returns an error", - args: args{ - cc: &hotline.ClientConn{ - Server: &hotline.Server{ - TextDecoder: charmap.Macintosh.NewDecoder(), - TextEncoder: charmap.Macintosh.NewEncoder(), - ThreadedNewsMgr: func() *hotline.MockThreadNewsMgr { - m := hotline.MockThreadNewsMgr{} - m.On("PostArticle", []string{"www"}, uint32(0), mock.AnythingOfType("hotline.NewsArtData")).Return(errors.New("write error")) - return &m - }(), - }, - Logger: NewTestLogger(), - Account: &hotline.Account{ - Access: func() hotline.AccessBitmap { - var bits hotline.AccessBitmap - bits.Set(hotline.AccessNewsPostArt) - return bits - }(), - }, - }, - t: hotline.NewTransaction( - hotline.TranPostNewsArt, - [2]byte{0, 0}, - hotline.NewField(hotline.FieldNewsPath, []byte{0x00, 0x01, 0x00, 0x00, 0x03, 0x77, 0x77, 0x77}), - hotline.NewField(hotline.FieldNewsArtID, []byte{0x00, 0x00, 0x00, 0x00}), - ), - }, - wantRes: []hotline.Transaction{ - { - IsReply: 0x01, - ErrorCode: [4]byte{0, 0, 0, 1}, - Fields: []hotline.Field{ - hotline.NewField(hotline.FieldError, []byte("Error posting news article.")), - }, - }, - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - TranAssertEqual(t, tt.wantRes, HandlePostNewsArt(tt.args.cc, &tt.args.t)) - }) - } -} - -func TestHandleUploadFolder(t *testing.T) { - type args struct { - cc *hotline.ClientConn - t hotline.Transaction - } - tests := []struct { - name string - args args - wantRes []hotline.Transaction - }{ - { - name: "when user does not have required access", - args: args{ - cc: &hotline.ClientConn{ - Account: &hotline.Account{ - Access: hotline.AccessBitmap{}, - }, - }, - t: hotline.NewTransaction( - hotline.TranUploadFldr, [2]byte{0, 1}, - hotline.NewField(hotline.FieldFileName, []byte("testFile")), - hotline.NewField(hotline.FieldFilePath, []byte{ - 0x00, 0x01, - 0x00, 0x00, - 0x03, - 0x2e, 0x2e, 0x2f, - }), - ), - }, - wantRes: []hotline.Transaction{ - { - IsReply: 0x01, - ErrorCode: [4]byte{0, 0, 0, 1}, - Fields: []hotline.Field{ - hotline.NewField(hotline.FieldError, []byte("You are not allowed to upload folders.")), - }, - }, - }, - }, - { - 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) { - TranAssertEqual(t, tt.wantRes, HandleUploadFolder(tt.args.cc, &tt.args.t)) - }) - } -} - -func TestHandleDownloadFolder(t *testing.T) { - type args struct { - cc *hotline.ClientConn - t hotline.Transaction - } - tests := []struct { - name string - args args - wantRes []hotline.Transaction - }{ - { - name: "when user does not have required access", - args: args{ - cc: &hotline.ClientConn{ - Account: &hotline.Account{ - Access: hotline.AccessBitmap{}, - }, - }, - t: hotline.NewTransaction( - hotline.TranDownloadFldr, [2]byte{0, 1}, - hotline.NewField(hotline.FieldFileName, []byte("testFile")), - hotline.NewField(hotline.FieldFilePath, []byte{ - 0x00, 0x01, - 0x00, 0x00, - 0x03, - 0x2e, 0x2e, 0x2f, - }), - ), - }, - wantRes: []hotline.Transaction{ - { - IsReply: 0x01, - ErrorCode: [4]byte{0, 0, 0, 1}, - Fields: []hotline.Field{ - hotline.NewField(hotline.FieldError, []byte("You are not allowed to download folders.")), - }, - }, - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - TranAssertEqual(t, tt.wantRes, HandleDownloadFolder(tt.args.cc, &tt.args.t)) - }) - } -} - -func TestHandleJoinChat(t *testing.T) { - type args struct { - cc *hotline.ClientConn - t hotline.Transaction - } - tests := []struct { - name string - args args - want []hotline.Transaction - }{ - { - name: "joins private chat and notifies existing members", - args: args{ - cc: &hotline.ClientConn{ - UserName: []byte("NewUser"), - ID: [2]byte{0, 3}, - Icon: []byte{0, 5}, - Flags: [2]byte{0, 1}, - Account: &hotline.Account{ - Access: hotline.AccessBitmap{255, 255, 255, 255, 255, 255, 255, 255}, - }, - Server: &hotline.Server{ - TextDecoder: charmap.Macintosh.NewDecoder(), - TextEncoder: charmap.Macintosh.NewEncoder(), - ChatMgr: func() *hotline.MockChatManager { - m := hotline.MockChatManager{} - // Mock existing members before join - m.On("Members", hotline.ChatID{0, 0, 0, 1}).Return([]*hotline.ClientConn{ - { - UserName: []byte("User1"), - ID: [2]byte{0, 1}, - Icon: []byte{0, 2}, - Flags: [2]byte{0, 0}, - }, - { - UserName: []byte("User2"), - ID: [2]byte{0, 2}, - Icon: []byte{0, 3}, - Flags: [2]byte{0, 0}, - }, - }) - // Mock join operation - m.On("Join", hotline.ChatID{0, 0, 0, 1}, mock.AnythingOfType("*hotline.ClientConn")).Return() - // Mock members after join (including new user) - m.On("Members", hotline.ChatID{0, 0, 0, 1}).Return([]*hotline.ClientConn{ - { - UserName: []byte("User1"), - ID: [2]byte{0, 1}, - Icon: []byte{0, 2}, - Flags: [2]byte{0, 0}, - }, - { - UserName: []byte("User2"), - ID: [2]byte{0, 2}, - Icon: []byte{0, 3}, - Flags: [2]byte{0, 0}, - }, - { - UserName: []byte("NewUser"), - ID: [2]byte{0, 3}, - Icon: []byte{0, 5}, - Flags: [2]byte{0, 1}, - }, - }) - // Mock chat subject - m.On("GetSubject", hotline.ChatID{0, 0, 0, 1}).Return("Test Chat Room") - return &m - }(), - }, - }, - t: hotline.Transaction{ - Type: [2]byte{0, 0x74}, // TranJoinChat - ID: [4]byte{0, 0, 0, 1}, - Fields: []hotline.Field{ - hotline.NewField(hotline.FieldChatID, []byte{0, 0, 0, 1}), - }, - }, - }, - want: []hotline.Transaction{ - // Notification to existing member 1 - { - ClientID: [2]byte{0, 1}, - Type: [2]byte{0, 0x75}, // TranNotifyChatChangeUser - Fields: []hotline.Field{ - hotline.NewField(hotline.FieldUserName, []byte("NewUser")), - hotline.NewField(hotline.FieldUserID, []byte{0, 3}), - hotline.NewField(hotline.FieldUserIconID, []byte{0, 5}), - hotline.NewField(hotline.FieldUserFlags, []byte{0, 1}), - }, - }, - // Notification to existing member 2 - { - ClientID: [2]byte{0, 2}, - Type: [2]byte{0, 0x75}, // TranNotifyChatChangeUser - Fields: []hotline.Field{ - hotline.NewField(hotline.FieldUserName, []byte("NewUser")), - hotline.NewField(hotline.FieldUserID, []byte{0, 3}), - hotline.NewField(hotline.FieldUserIconID, []byte{0, 5}), - hotline.NewField(hotline.FieldUserFlags, []byte{0, 1}), - }, - }, - // Reply to joining user - { - ClientID: [2]byte{0, 3}, - IsReply: 0x01, - Type: [2]byte{0, 0}, // Reply type is [0, 0] - Fields: []hotline.Field{ - hotline.NewField(hotline.FieldChatSubject, []byte("Test Chat Room")), - // User1 info - hotline.NewField(hotline.FieldUsernameWithInfo, []byte{ - 0x00, 0x01, // User ID - 0x00, 0x02, // Icon ID - 0x00, 0x00, // Flags - 0x00, 0x05, // Username length - 0x55, 0x73, 0x65, 0x72, 0x31, // "User1" - }), - // User2 info - hotline.NewField(hotline.FieldUsernameWithInfo, []byte{ - 0x00, 0x02, // User ID - 0x00, 0x03, // Icon ID - 0x00, 0x00, // Flags - 0x00, 0x05, // Username length - 0x55, 0x73, 0x65, 0x72, 0x32, // "User2" - }), - }, - }, - }, - }, - { - name: "joins empty private chat", - args: args{ - cc: &hotline.ClientConn{ - UserName: []byte("OnlyUser"), - ID: [2]byte{0, 1}, - Icon: []byte{0, 1}, - Flags: [2]byte{0, 0}, - Account: &hotline.Account{ - Access: hotline.AccessBitmap{255, 255, 255, 255, 255, 255, 255, 255}, - }, - Server: &hotline.Server{ - TextDecoder: charmap.Macintosh.NewDecoder(), - TextEncoder: charmap.Macintosh.NewEncoder(), - ChatMgr: func() *hotline.MockChatManager { - m := hotline.MockChatManager{} - // Mock empty chat before join - m.On("Members", hotline.ChatID{0, 0, 0, 2}).Return([]*hotline.ClientConn{}) - // Mock join operation - m.On("Join", hotline.ChatID{0, 0, 0, 2}, mock.AnythingOfType("*hotline.ClientConn")).Return() - // Mock members after join - m.On("Members", hotline.ChatID{0, 0, 0, 2}).Return([]*hotline.ClientConn{ - { - UserName: []byte("OnlyUser"), - ID: [2]byte{0, 1}, - Icon: []byte{0, 1}, - Flags: [2]byte{0, 0}, - }, - }) - // Mock chat subject - m.On("GetSubject", hotline.ChatID{0, 0, 0, 2}).Return("Empty Chat") - return &m - }(), - }, - }, - t: hotline.Transaction{ - Type: [2]byte{0, 0x74}, // TranJoinChat - ID: [4]byte{0, 0, 0, 2}, - Fields: []hotline.Field{ - hotline.NewField(hotline.FieldChatID, []byte{0, 0, 0, 2}), - }, - }, - }, - want: []hotline.Transaction{ - // Only reply to joining user (no existing members to notify) - { - ClientID: [2]byte{0, 1}, - IsReply: 0x01, - Type: [2]byte{0, 0}, // Reply type is [0, 0] - Fields: []hotline.Field{ - hotline.NewField(hotline.FieldChatSubject, []byte("Empty Chat")), - }, - }, - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := HandleJoinChat(tt.args.cc, &tt.args.t) - if !TranAssertEqual(t, tt.want, got) { - t.Errorf("HandleJoinChat() got = %v, want %v", got, tt.want) - } - }) - } -} - -func TestHandleRejectChatInvite(t *testing.T) { - type args struct { - cc *hotline.ClientConn - t hotline.Transaction - } - tests := []struct { - name string - args args - want []hotline.Transaction - }{ - { - name: "rejects invite and notifies chat members", - args: args{ - cc: &hotline.ClientConn{ - UserName: []byte("RejectUser"), - ID: [2]byte{0, 3}, - Server: &hotline.Server{ - TextDecoder: charmap.Macintosh.NewDecoder(), - TextEncoder: charmap.Macintosh.NewEncoder(), - ChatMgr: func() *hotline.MockChatManager { - m := hotline.MockChatManager{} - // Mock current members of the chat - m.On("Members", hotline.ChatID{0, 0, 0, 1}).Return([]*hotline.ClientConn{ - { - UserName: []byte("Member1"), - ID: [2]byte{0, 1}, - }, - { - UserName: []byte("Member2"), - ID: [2]byte{0, 2}, - }, - }) - return &m - }(), - }, - }, - t: hotline.Transaction{ - Type: [2]byte{0, 0x72}, // TranRejectChatInvite - ID: [4]byte{0, 0, 0, 1}, - Fields: []hotline.Field{ - hotline.NewField(hotline.FieldChatID, []byte{0, 0, 0, 1}), - }, - }, - }, - want: []hotline.Transaction{ - // Notification to member 1 - { - ClientID: [2]byte{0, 1}, - Type: [2]byte{0, 0x6A}, // TranChatMsg - Fields: []hotline.Field{ - hotline.NewField(hotline.FieldData, []byte("RejectUser declined invitation to chat")), - }, - }, - // Notification to member 2 - { - ClientID: [2]byte{0, 2}, - Type: [2]byte{0, 0x6A}, // TranChatMsg - Fields: []hotline.Field{ - hotline.NewField(hotline.FieldData, []byte("RejectUser declined invitation to chat")), - }, - }, - }, - }, - { - name: "rejects invite to empty chat", - args: args{ - cc: &hotline.ClientConn{ - UserName: []byte("LoneRejecter"), - ID: [2]byte{0, 1}, - Server: &hotline.Server{ - TextDecoder: charmap.Macintosh.NewDecoder(), - TextEncoder: charmap.Macintosh.NewEncoder(), - ChatMgr: func() *hotline.MockChatManager { - m := hotline.MockChatManager{} - // Mock empty chat (no members) - m.On("Members", hotline.ChatID{0, 0, 0, 2}).Return([]*hotline.ClientConn{}) - return &m - }(), - }, - }, - t: hotline.Transaction{ - Type: [2]byte{0, 0x72}, // TranRejectChatInvite - ID: [4]byte{0, 0, 0, 2}, - Fields: []hotline.Field{ - hotline.NewField(hotline.FieldChatID, []byte{0, 0, 0, 2}), - }, - }, - }, - want: []hotline.Transaction{ - // No notifications (no members to notify) - }, - }, - { - name: "rejects invite with single member", - args: args{ - cc: &hotline.ClientConn{ - UserName: []byte("Shy"), - ID: [2]byte{0, 2}, - Server: &hotline.Server{ - TextDecoder: charmap.Macintosh.NewDecoder(), - TextEncoder: charmap.Macintosh.NewEncoder(), - ChatMgr: func() *hotline.MockChatManager { - m := hotline.MockChatManager{} - // Mock chat with single member - m.On("Members", hotline.ChatID{0, 0, 0, 3}).Return([]*hotline.ClientConn{ - { - UserName: []byte("OnlyMember"), - ID: [2]byte{0, 1}, - }, - }) - return &m - }(), - }, - }, - t: hotline.Transaction{ - Type: [2]byte{0, 0x72}, // TranRejectChatInvite - ID: [4]byte{0, 0, 0, 3}, - Fields: []hotline.Field{ - hotline.NewField(hotline.FieldChatID, []byte{0, 0, 0, 3}), - }, - }, - }, - want: []hotline.Transaction{ - // Notification to the single member - { - ClientID: [2]byte{0, 1}, - Type: [2]byte{0, 0x6A}, // TranChatMsg - Fields: []hotline.Field{ - hotline.NewField(hotline.FieldData, []byte("Shy declined invitation to chat")), - }, - }, - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := HandleRejectChatInvite(tt.args.cc, &tt.args.t) - if !TranAssertEqual(t, tt.want, got) { - t.Errorf("HandleRejectChatInvite() got = %v, want %v", got, tt.want) - } - }) - } -} - -func TestHandleInviteToChat(t *testing.T) { - type args struct { - cc *hotline.ClientConn - t hotline.Transaction - } - tests := []struct { - name string - args args - want []hotline.Transaction - }{ - { - name: "invites user to chat successfully", - args: args{ - cc: &hotline.ClientConn{ - UserName: []byte("Inviter"), - ID: [2]byte{0, 1}, - Icon: []byte{0, 2}, - Flags: [2]byte{0, 3}, - Account: &hotline.Account{ - Access: func() hotline.AccessBitmap { - access := hotline.AccessBitmap{} - access.Set(hotline.AccessOpenChat) - return access - }(), - }, - }, - t: hotline.Transaction{ - Type: [2]byte{0, 0x71}, // TranInviteToChat - ID: [4]byte{0, 0, 0, 1}, - Fields: []hotline.Field{ - hotline.NewField(hotline.FieldUserID, []byte{0, 5}), - hotline.NewField(hotline.FieldChatID, []byte{0, 0, 0, 10}), - }, - }, - }, - want: []hotline.Transaction{ - // Invite sent to target user - { - ClientID: [2]byte{0, 5}, - Type: [2]byte{0, 0x71}, // TranInviteToChat - Fields: []hotline.Field{ - hotline.NewField(hotline.FieldUserName, []byte("Inviter")), - hotline.NewField(hotline.FieldUserID, []byte{0, 1}), - }, - }, - // Reply to inviting user - { - ClientID: [2]byte{0, 1}, - IsReply: 0x01, - Type: [2]byte{0, 0}, // Reply type is [0, 0] - Fields: []hotline.Field{ - hotline.NewField(hotline.FieldUserName, []byte("Inviter")), - hotline.NewField(hotline.FieldUserID, []byte{0, 1}), - hotline.NewField(hotline.FieldUserIconID, []byte{0, 2}), - hotline.NewField(hotline.FieldUserFlags, []byte{0, 3}), - }, - }, - }, - }, - { - name: "returns error when user lacks permission", - args: args{ - cc: &hotline.ClientConn{ - UserName: []byte("NoPermUser"), - ID: [2]byte{0, 2}, - Account: &hotline.Account{ - Access: hotline.AccessBitmap{}, // No permissions - }, - }, - t: hotline.Transaction{ - Type: [2]byte{0, 0x71}, // TranInviteToChat - ID: [4]byte{0, 0, 0, 2}, - Fields: []hotline.Field{ - hotline.NewField(hotline.FieldUserID, []byte{0, 3}), - hotline.NewField(hotline.FieldChatID, []byte{0, 0, 0, 5}), - }, - }, - }, - want: []hotline.Transaction{ - // Error reply to requesting user - { - ClientID: [2]byte{0, 2}, - IsReply: 0x01, - Type: [2]byte{0, 0}, // Reply type is [0, 0] - ErrorCode: [4]byte{0, 0, 0, 1}, - Fields: []hotline.Field{ - hotline.NewField(hotline.FieldError, []byte("You are not allowed to request private chat.")), - }, - }, - }, - }, - { - name: "invites to different chat room", - args: args{ - cc: &hotline.ClientConn{ - UserName: []byte("Host"), - ID: [2]byte{0, 10}, - Icon: []byte{0, 15}, - Flags: [2]byte{0, 20}, - Account: &hotline.Account{ - Access: func() hotline.AccessBitmap { - access := hotline.AccessBitmap{} - access.Set(hotline.AccessOpenChat) - return access - }(), - }, - }, - t: hotline.Transaction{ - Type: [2]byte{0, 0x71}, // TranInviteToChat - ID: [4]byte{0, 0, 0, 3}, - Fields: []hotline.Field{ - hotline.NewField(hotline.FieldUserID, []byte{0, 99}), - hotline.NewField(hotline.FieldChatID, []byte{0, 0, 0, 25}), - }, - }, - }, - want: []hotline.Transaction{ - // Invite sent to target user - { - ClientID: [2]byte{0, 99}, - Type: [2]byte{0, 0x71}, // TranInviteToChat - Fields: []hotline.Field{ - hotline.NewField(hotline.FieldUserName, []byte("Host")), - hotline.NewField(hotline.FieldUserID, []byte{0, 10}), - }, - }, - // Reply to inviting user - { - ClientID: [2]byte{0, 10}, - IsReply: 0x01, - Type: [2]byte{0, 0}, // Reply type is [0, 0] - Fields: []hotline.Field{ - hotline.NewField(hotline.FieldUserName, []byte("Host")), - hotline.NewField(hotline.FieldUserID, []byte{0, 10}), - hotline.NewField(hotline.FieldUserIconID, []byte{0, 15}), - hotline.NewField(hotline.FieldUserFlags, []byte{0, 20}), - }, - }, - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := HandleInviteToChat(tt.args.cc, &tt.args.t) - if !TranAssertEqual(t, tt.want, got) { - t.Errorf("HandleInviteToChat() got = %v, want %v", got, tt.want) - } - }) - } -} - -func TestHandleSetUser(t *testing.T) { - type args struct { - cc *hotline.ClientConn - t hotline.Transaction - } - tests := []struct { - name string - args args - wantRes []hotline.Transaction - }{ - { - name: "when user does not have required permission", - args: args{ - cc: &hotline.ClientConn{ - Account: &hotline.Account{ - Access: func() hotline.AccessBitmap { - var bits hotline.AccessBitmap - return bits - }(), - }, - Server: &hotline.Server{TextDecoder: charmap.Macintosh.NewDecoder(), TextEncoder: charmap.Macintosh.NewEncoder()}, - }, - t: hotline.NewTransaction( - hotline.TranSetUser, [2]byte{0, 1}, - ), - }, - wantRes: []hotline.Transaction{ - { - Flags: 0x00, - IsReply: 0x01, - Type: [2]byte{0, 0}, - ErrorCode: [4]byte{0, 0, 0, 1}, - Fields: []hotline.Field{ - hotline.NewField(hotline.FieldError, []byte(ErrMsgNotAllowedModifyAccounts)), - }, - }, - }, - }, - { - name: "when account is not found", - args: args{ - cc: &hotline.ClientConn{ - Account: &hotline.Account{ - Access: func() hotline.AccessBitmap { - var bits hotline.AccessBitmap - bits.Set(hotline.AccessModifyUser) - return bits - }(), - }, - Logger: NewTestLogger(), - ID: [2]byte{0, 1}, - Server: &hotline.Server{ - TextDecoder: charmap.Macintosh.NewDecoder(), - TextEncoder: charmap.Macintosh.NewEncoder(), - AccountManager: func() *MockAccountManager { - m := MockAccountManager{} - m.On("Get", "testuser").Return((*hotline.Account)(nil)) - return &m - }(), - }, - }, - t: hotline.NewTransaction( - hotline.TranSetUser, [2]byte{0, 1}, - hotline.NewField(hotline.FieldUserLogin, hotline.EncodeString([]byte("testuser"))), - hotline.NewField(hotline.FieldUserName, []byte("Test User")), - hotline.NewField(hotline.FieldUserAccess, make([]byte, 8)), - ), - }, - wantRes: []hotline.Transaction{ - { - ClientID: [2]byte{0, 1}, - IsReply: 0x01, - ErrorCode: [4]byte{0, 0, 0, 1}, - Fields: []hotline.Field{ - hotline.NewField(hotline.FieldError, []byte(ErrMsgAccountNotFound)), - }, - }, - }, - }, - { - name: "when Update returns an error", - args: args{ - cc: &hotline.ClientConn{ - Account: &hotline.Account{ - Access: func() hotline.AccessBitmap { - var bits hotline.AccessBitmap - bits.Set(hotline.AccessModifyUser) - return bits - }(), - }, - Logger: NewTestLogger(), - ID: [2]byte{0, 1}, - Server: &hotline.Server{ - TextDecoder: charmap.Macintosh.NewDecoder(), - TextEncoder: charmap.Macintosh.NewEncoder(), - AccountManager: func() *MockAccountManager { - m := MockAccountManager{} - m.On("Get", "testuser").Return(&hotline.Account{ - Login: "testuser", - Name: "Old Name", - }) - m.On("Update", mock.Anything, "testuser").Return(errors.New("disk full")) - return &m - }(), - }, - }, - t: hotline.NewTransaction( - hotline.TranSetUser, [2]byte{0, 1}, - hotline.NewField(hotline.FieldUserLogin, hotline.EncodeString([]byte("testuser"))), - hotline.NewField(hotline.FieldUserName, []byte("New Name")), - hotline.NewField(hotline.FieldUserAccess, make([]byte, 8)), - ), - }, - wantRes: []hotline.Transaction{ - { - ClientID: [2]byte{0, 1}, - IsReply: 0x01, - ErrorCode: [4]byte{0, 0, 0, 1}, - Fields: []hotline.Field{ - hotline.NewField(hotline.FieldError, []byte(ErrMsgUpdateAccount)), - }, - }, - }, - }, - { - name: "when update is successful with password change", - args: args{ - cc: &hotline.ClientConn{ - Account: &hotline.Account{ - Access: func() hotline.AccessBitmap { - var bits hotline.AccessBitmap - bits.Set(hotline.AccessModifyUser) - return bits - }(), - }, - Logger: NewTestLogger(), - ID: [2]byte{0, 1}, - Server: &hotline.Server{ - TextDecoder: charmap.Macintosh.NewDecoder(), - TextEncoder: charmap.Macintosh.NewEncoder(), - AccountManager: func() *MockAccountManager { - m := MockAccountManager{} - m.On("Get", "testuser").Return(&hotline.Account{ - Login: "testuser", - Name: "Old Name", - }) - m.On("Update", mock.Anything, "testuser").Return(nil) - return &m - }(), - ClientMgr: func() *hotline.MockClientMgr { - m := hotline.MockClientMgr{} - m.On("List").Return([]*hotline.ClientConn{}) - return &m - }(), - }, - }, - t: hotline.NewTransaction( - hotline.TranSetUser, [2]byte{0, 1}, - hotline.NewField(hotline.FieldUserLogin, hotline.EncodeString([]byte("testuser"))), - hotline.NewField(hotline.FieldUserName, []byte("New Name")), - hotline.NewField(hotline.FieldUserPassword, []byte("newpass")), - hotline.NewField(hotline.FieldUserAccess, make([]byte, 8)), - ), - }, - wantRes: []hotline.Transaction{ - { - ClientID: [2]byte{0, 1}, - IsReply: 0x01, - }, - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - gotRes := HandleSetUser(tt.args.cc, &tt.args.t) - - TranAssertEqual(t, tt.wantRes, gotRes) - }) - } -} - -func TestHandleNewNewsCat(t *testing.T) { - type args struct { - cc *hotline.ClientConn - t hotline.Transaction - } - tests := []struct { - name string - args args - wantRes []hotline.Transaction - }{ - { - name: "when user does not have required permission", - args: args{ - cc: &hotline.ClientConn{ - Account: &hotline.Account{ - Access: func() hotline.AccessBitmap { - var bits hotline.AccessBitmap - return bits - }(), - }, - Server: &hotline.Server{TextDecoder: charmap.Macintosh.NewDecoder(), TextEncoder: charmap.Macintosh.NewEncoder()}, - }, - t: hotline.NewTransaction( - hotline.TranNewNewsCat, [2]byte{0, 1}, - ), - }, - wantRes: []hotline.Transaction{ - { - Flags: 0x00, - IsReply: 0x01, - Type: [2]byte{0, 0}, - ErrorCode: [4]byte{0, 0, 0, 1}, - Fields: []hotline.Field{ - hotline.NewField(hotline.FieldError, []byte(ErrMsgNotAllowedCreateNewsCategories)), - }, - }, - }, - }, - { - name: "when CreateGrouping returns an error", - args: args{ - cc: &hotline.ClientConn{ - Account: &hotline.Account{ - Access: func() hotline.AccessBitmap { - var bits hotline.AccessBitmap - bits.Set(hotline.AccessNewsCreateCat) - return bits - }(), - }, - Logger: NewTestLogger(), - ID: [2]byte{0, 1}, - Server: &hotline.Server{ - TextDecoder: charmap.Macintosh.NewDecoder(), - TextEncoder: charmap.Macintosh.NewEncoder(), - ThreadedNewsMgr: func() *hotline.MockThreadNewsMgr { - m := hotline.MockThreadNewsMgr{} - m.On("CreateGrouping", []string{"test"}, "TestCat", hotline.NewsCategory).Return(errors.New("write error")) - return &m - }(), - }, - }, - t: hotline.NewTransaction( - hotline.TranNewNewsCat, [2]byte{0, 1}, - hotline.NewField(hotline.FieldNewsCatName, []byte("TestCat")), - hotline.NewField(hotline.FieldNewsPath, - []byte{ - 0, 1, - 0, 0, - 4, - 0x74, 0x65, 0x73, 0x74, - }, - ), - ), - }, - wantRes: []hotline.Transaction{ - { - ClientID: [2]byte{0, 1}, - IsReply: 0x01, - ErrorCode: [4]byte{0, 0, 0, 1}, - Fields: []hotline.Field{ - hotline.NewField(hotline.FieldError, []byte(ErrMsgCreateNewsCategory)), - }, - }, - }, - }, - { - name: "with a valid request", - args: args{ - cc: &hotline.ClientConn{ - Account: &hotline.Account{ - Access: func() hotline.AccessBitmap { - var bits hotline.AccessBitmap - bits.Set(hotline.AccessNewsCreateCat) - return bits - }(), - }, - Logger: NewTestLogger(), - ID: [2]byte{0, 1}, - Server: &hotline.Server{ - TextDecoder: charmap.Macintosh.NewDecoder(), - TextEncoder: charmap.Macintosh.NewEncoder(), - ThreadedNewsMgr: func() *hotline.MockThreadNewsMgr { - m := hotline.MockThreadNewsMgr{} - m.On("CreateGrouping", []string{"test"}, "TestCat", hotline.NewsCategory).Return(nil) - return &m - }(), - }, - }, - t: hotline.NewTransaction( - hotline.TranNewNewsCat, [2]byte{0, 1}, - hotline.NewField(hotline.FieldNewsCatName, []byte("TestCat")), - hotline.NewField(hotline.FieldNewsPath, - []byte{ - 0, 1, - 0, 0, - 4, - 0x74, 0x65, 0x73, 0x74, - }, - ), - ), - }, - wantRes: []hotline.Transaction{ - { - ClientID: [2]byte{0, 1}, - IsReply: 0x01, - Fields: []hotline.Field{}, - }, - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - gotRes := HandleNewNewsCat(tt.args.cc, &tt.args.t) - - TranAssertEqual(t, tt.wantRes, gotRes) - }) - } -} - -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) @@ -6670,47 +73,3 @@ func TestRegisterHandlers(t *testing.T) { // 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)) -} |