aboutsummaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorJeff Halter <868228+jhalter@users.noreply.github.com>2026-06-12 07:51:24 -0700
committerJeff Halter <868228+jhalter@users.noreply.github.com>2026-06-12 07:51:24 -0700
commitd2791fcbadcf332dc05e5bebc7350eca50617263 (patch)
treed997ce9eabe77fd7d635f1276873affad1f181b2 /internal
parent30db5839a93936f8b69d0d5d005bbe03197722c1 (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.
Diffstat (limited to 'internal')
-rw-r--r--internal/mobius/errors.go100
-rw-r--r--internal/mobius/handlers_accounts.go393
-rw-r--r--internal/mobius/handlers_accounts_test.go1346
-rw-r--r--internal/mobius/handlers_chat.go399
-rw-r--r--internal/mobius/handlers_chat_test.go1497
-rw-r--r--internal/mobius/handlers_files.go444
-rw-r--r--internal/mobius/handlers_files_test.go1115
-rw-r--r--internal/mobius/handlers_news.go378
-rw-r--r--internal/mobius/handlers_news_test.go1167
-rw-r--r--internal/mobius/handlers_session.go349
-rw-r--r--internal/mobius/handlers_session_test.go934
-rw-r--r--internal/mobius/handlers_transfers.go286
-rw-r--r--internal/mobius/handlers_transfers_test.go557
-rw-r--r--internal/mobius/helpers_test.go101
-rw-r--r--internal/mobius/transaction_handlers.go2297
-rw-r--r--internal/mobius/transaction_handlers_test.go6641
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))
-}