Explorar o código

increase test coverage on auth service

Mike %!s(int64=2) %!d(string=hai) anos
pai
achega
0edb3ff020

+ 4 - 1
.mockery.yaml

@@ -51,4 +51,7 @@ packages:
           filename: "message_relayer_mock_test.go"
       ChatMessageRelayer:
         config:
-          filename: "chat_message_relayer_mock_test.go"
+          filename: "chat_message_relayer_mock_test.go"
+      ChatRegistry:
+        config:
+          filename: "chat_registry_test.go"

+ 46 - 114
handler/auth.go

@@ -4,8 +4,6 @@ import (
 	"bytes"
 	"context"
 	"errors"
-	"fmt"
-	"io"
 
 	"github.com/google/uuid"
 	"github.com/mkaminski/goaim/oscar"
@@ -13,9 +11,10 @@ import (
 	"github.com/mkaminski/goaim/state"
 )
 
-func NewAuthService(cfg server.Config, sessionManager SessionManager, messageRelayer MessageRelayer, feedbagManager FeedbagManager, userManager UserManager, cchatRegistry *state.ChatRegistry) *AuthService {
+// NewAuthService creates a new instance of AuthService.
+func NewAuthService(cfg server.Config, sessionManager SessionManager, messageRelayer MessageRelayer, feedbagManager FeedbagManager, userManager UserManager, chatRegistry ChatRegistry) *AuthService {
 	return &AuthService{
-		chatRegistry:   cchatRegistry,
+		chatRegistry:   chatRegistry,
 		config:         cfg,
 		feedbagManager: feedbagManager,
 		messageRelayer: messageRelayer,
@@ -24,8 +23,9 @@ func NewAuthService(cfg server.Config, sessionManager SessionManager, messageRel
 	}
 }
 
+// AuthService provides user BUCP login and session management services.
 type AuthService struct {
-	chatRegistry   *state.ChatRegistry
+	chatRegistry   ChatRegistry
 	config         server.Config
 	feedbagManager FeedbagManager
 	messageRelayer MessageRelayer
@@ -33,18 +33,24 @@ type AuthService struct {
 	userManager    UserManager
 }
 
-func (s AuthService) RetrieveChatSession(ctx context.Context, chatID string, sessID string) (*state.Session, error) {
+// RetrieveChatSession returns a chat room session. Return nil if the session
+// does not exist.
+func (s AuthService) RetrieveChatSession(chatID string, sessionID string) (*state.Session, error) {
 	_, chatSessMgr, err := s.chatRegistry.Retrieve(chatID)
 	if err != nil {
 		return nil, err
 	}
-	chatSess, found := chatSessMgr.(SessionManager).Retrieve(sessID)
-	if !found {
-		return nil, fmt.Errorf("unable to find user for session. chat id: %s, sess id: %s", chatID, sessID)
-	}
-	return chatSess, nil
+	return chatSessMgr.(SessionManager).Retrieve(sessionID), nil
+}
+
+// RetrieveBOSSession returns a user's session. Return nil if the session does
+// not exist.
+func (s AuthService) RetrieveBOSSession(sessionID string) (*state.Session, error) {
+	return s.sessionManager.Retrieve(sessionID), nil
 }
 
+// Signout removes user from the BOS server and notifies adjacent users (those
+// who have this user's screen name on their buddy list) of their departure.
 func (s AuthService) Signout(ctx context.Context, sess *state.Session) error {
 	if err := broadcastDeparture(ctx, sess, s.messageRelayer, s.feedbagManager); err != nil {
 		return err
@@ -53,98 +59,30 @@ func (s AuthService) Signout(ctx context.Context, sess *state.Session) error {
 	return nil
 }
 
-func (s AuthService) SignoutChat(ctx context.Context, sess *state.Session, chatID string) {
+// SignoutChat removes user from chat room and notifies remaining participants
+// of their departure. If user is the last to leave, the chat room is deleted.
+func (s AuthService) SignoutChat(ctx context.Context, sess *state.Session, chatID string) error {
 	chatRoom, chatSessMgr, err := s.chatRegistry.Retrieve(chatID)
 	if err != nil {
-		fmt.Println("error getting chat room to remove")
-		return
+		return err
 	}
 	alertUserLeft(ctx, sess, chatSessMgr.(ChatMessageRelayer))
 	chatSessMgr.(SessionManager).Remove(sess)
 	if chatSessMgr.(SessionManager).Empty() {
 		s.chatRegistry.RemoveRoom(chatRoom.Cookie)
 	}
+	return nil
 }
 
-func (s AuthService) VerifyLogin(rwc io.ReadWriteCloser) (*state.Session, uint32, error) {
-	seq := uint32(100)
-
-	flap, err := s.SendAndReceiveSignonFrame(rwc, &seq)
-	if err != nil {
-		return nil, 0, err
-	}
-
-	var ok bool
-	ID, ok := flap.GetSlice(oscar.OServiceTLVTagsLoginCookie)
-	if !ok {
-		return nil, 0, errors.New("unable to get session id from payload")
-	}
-
-	sess, ok := s.sessionManager.Retrieve(string(ID))
-	if !ok {
-		return nil, 0, fmt.Errorf("unable to find session by id %s", ID)
-	}
-
-	return sess, seq, nil
-}
-
-func (s AuthService) SendAndReceiveSignonFrame(rw io.ReadWriter, sequence *uint32) (oscar.FLAPSignonFrame, error) {
-	flapFrameOut := oscar.FLAPFrame{
-		StartMarker:   42,
-		FrameType:     oscar.FLAPFrameSignon,
-		Sequence:      uint16(*sequence),
-		PayloadLength: 4, // size of FLAPSignonFrame
-	}
-	if err := oscar.Marshal(flapFrameOut, rw); err != nil {
-		return oscar.FLAPSignonFrame{}, err
-	}
-	flapSignonFrameOut := oscar.FLAPSignonFrame{
-		FLAPVersion: 1,
-	}
-	if err := oscar.Marshal(flapSignonFrameOut, rw); err != nil {
-		return oscar.FLAPSignonFrame{}, err
-	}
-
-	// receive
-	flapFrameIn := oscar.FLAPFrame{}
-	if err := oscar.Unmarshal(&flapFrameIn, rw); err != nil {
-		return oscar.FLAPSignonFrame{}, err
-	}
-	b := make([]byte, flapFrameIn.PayloadLength)
-	if _, err := rw.Read(b); err != nil {
-		return oscar.FLAPSignonFrame{}, err
-	}
-	flapSignonFrameIn := oscar.FLAPSignonFrame{}
-	if err := oscar.Unmarshal(&flapSignonFrameIn, bytes.NewBuffer(b)); err != nil {
-		return oscar.FLAPSignonFrame{}, err
-	}
-
-	*sequence++
-
-	return flapSignonFrameIn, nil
-}
-
-func (s AuthService) VerifyChatLogin(rw io.ReadWriter) (*server.ChatCookie, uint32, error) {
-	seq := uint32(100)
-
-	flap, err := s.SendAndReceiveSignonFrame(rw, &seq)
-	if err != nil {
-		return nil, 0, err
-	}
-
-	var ok bool
-	buf, ok := flap.GetSlice(oscar.OServiceTLVTagsLoginCookie)
-	if !ok {
-		return nil, 0, errors.New("unable to get session id from payload")
-	}
-
-	cookie := server.ChatCookie{}
-	err = oscar.Unmarshal(&cookie, bytes.NewBuffer(buf))
-
-	return &cookie, seq, err
-}
-
-func (s AuthService) ReceiveAndSendAuthChallenge(bodyIn oscar.SNAC_0x17_0x06_BUCPChallengeRequest, newUUID func() uuid.UUID) (oscar.SNACMessage, error) {
+// BUCPChallengeRequestHandler satisfies the client request for a random auth
+// key. It returns SNAC oscar.BUCPChallengeResponse. If the screen name in
+// TLV oscar.TLVScreenName in bodyIn is recognized as a valid user, the
+// response contains the account's auth key, which salt's the user's MD5
+// password hash. If the account is invalid, an error code is set in TLV
+// oscar.TLVErrorSubcode. If login credentials are invalid and app config
+// DisableAuth is true, a stub auth key is generated and a successful challenge
+// response is returned.
+func (s AuthService) BUCPChallengeRequestHandler(bodyIn oscar.SNAC_0x17_0x06_BUCPChallengeRequest, newUUIDFn func() uuid.UUID) (oscar.SNACMessage, error) {
 	screenName, exists := bodyIn.GetString(oscar.TLVScreenName)
 	if !exists {
 		return oscar.SNACMessage{}, errors.New("screen name doesn't exist in tlv")
@@ -161,7 +99,7 @@ func (s AuthService) ReceiveAndSendAuthChallenge(bodyIn oscar.SNAC_0x17_0x06_BUC
 		authKey = u.AuthKey
 	case s.config.DisableAuth:
 		// can't find user, generate stub auth key
-		authKey = newUUID().String()
+		authKey = newUUIDFn().String()
 	default:
 		// can't find user, return login error
 		snacFrameOut := oscar.SNACFrame{
@@ -187,8 +125,17 @@ func (s AuthService) ReceiveAndSendAuthChallenge(bodyIn oscar.SNAC_0x17_0x06_BUC
 	}, nil
 }
 
-func (s AuthService) ReceiveAndSendBUCPLoginRequest(bodyIn oscar.SNAC_0x17_0x02_BUCPLoginRequest, newUUID func() uuid.UUID) (oscar.SNACMessage, error) {
-
+// BUCPLoginRequestHandler verifies user credentials. Upon successful login, a
+// session is created.
+// If login credentials are invalid and app config DisableAuth is true, a stub
+// user is created and login continues as normal. DisableAuth allows you to
+// skip the account creation procedure, which simplifies the login flow during
+// development.
+// If login is successful, the SNAC TLV list contains the BOS server address
+// (oscar.TLVReconnectHere) and an authorization cookie
+// (oscar.TLVAuthorizationCookie). Else, an error code is set
+// (oscar.TLVErrorSubcode).
+func (s AuthService) BUCPLoginRequestHandler(bodyIn oscar.SNAC_0x17_0x02_BUCPLoginRequest, newUUIDFn func() uuid.UUID, newUserFn func(screenName string) (state.User, error)) (oscar.SNACMessage, error) {
 	screenName, found := bodyIn.GetString(oscar.TLVScreenName)
 	if !found {
 		return oscar.SNACMessage{}, errors.New("screen name doesn't exist in tlv")
@@ -209,11 +156,11 @@ func (s AuthService) ReceiveAndSendBUCPLoginRequest(bodyIn oscar.SNAC_0x17_0x02_
 		loginOK = true
 	case s.config.DisableAuth:
 		// login failed but let them in anyway
-		newUser, err := newStubUser(screenName)
+		user, err := newUserFn(screenName)
 		if err != nil {
 			return oscar.SNACMessage{}, err
 		}
-		if err := s.userManager.UpsertUser(newUser); err != nil {
+		if err := s.userManager.UpsertUser(user); err != nil {
 			return oscar.SNACMessage{}, err
 		}
 		loginOK = true
@@ -223,7 +170,7 @@ func (s AuthService) ReceiveAndSendBUCPLoginRequest(bodyIn oscar.SNAC_0x17_0x02_
 	snacPayloadOut.AddTLV(oscar.NewTLV(oscar.TLVScreenName, screenName))
 
 	if loginOK {
-		sess := s.sessionManager.NewSessionWithSN(newUUID().String(), screenName)
+		sess := s.sessionManager.NewSessionWithSN(newUUIDFn().String(), screenName)
 		snacPayloadOut.AddTLVList([]oscar.TLV{
 			oscar.NewTLV(oscar.TLVReconnectHere, server.Address(s.config.OSCARHost, s.config.BOSPort)),
 			oscar.NewTLV(oscar.TLVAuthorizationCookie, sess.ID()),
@@ -242,18 +189,3 @@ func (s AuthService) ReceiveAndSendBUCPLoginRequest(bodyIn oscar.SNAC_0x17_0x02_
 		Body: snacPayloadOut,
 	}, nil
 }
-
-func newStubUser(screenName string) (state.User, error) {
-	u := state.User{ScreenName: screenName}
-
-	uid, err := uuid.NewRandom()
-	if err != nil {
-		return u, err
-	}
-	u.AuthKey = uid.String()
-
-	if err := u.HashPassword("welcome1"); err != nil {
-		return u, err
-	}
-	return u, u.HashPassword("welcome1")
-}

+ 662 - 73
handler/auth_test.go

@@ -1,6 +1,7 @@
 package handler
 
 import (
+	"io"
 	"testing"
 
 	"github.com/google/uuid"
@@ -11,38 +12,62 @@ import (
 	"github.com/stretchr/testify/mock"
 )
 
-func TestReceiveAndSendBUCPLoginRequest(t *testing.T) {
-	userGoodPwd := state.User{
-		ScreenName: "sn_user_a",
-		AuthKey:    "auth_key_user",
+func TestAuthService_BUCPLoginRequestHandler(t *testing.T) {
+	sessUUID := uuid.UUID{1, 2, 3}
+	user := state.User{
+		ScreenName: "screen_name",
+		AuthKey:    "auth_key",
 	}
-	assert.NoError(t, userGoodPwd.HashPassword("good_pwd"))
-	userBadPwd := userGoodPwd
-	assert.NoError(t, userBadPwd.HashPassword("bad_pwd"))
+	assert.NoError(t, user.HashPassword("the_password"))
+	userSession := newTestSession(user.ScreenName, sessOptID(sessUUID.String()))
 
 	cases := []struct {
-		name        string
-		cfg         server.Config
-		userInDB    state.User
-		sessionUUID uuid.UUID
-		inputSNAC   oscar.SNAC_0x17_0x02_BUCPLoginRequest
-		// expectOutput is the SNAC payload sent from the server to the
-		// recipient client
+		// name is the unit test name
+		name string
+		// cfg is the app configuration
+		cfg server.Config
+		// inputSNAC is the SNAC sent from the client to the server
+		inputSNAC oscar.SNAC_0x17_0x02_BUCPLoginRequest
+		// mockParams is the list of params sent to mocks that satisfy this
+		// method's dependencies
+		mockParams mockParams
+		// newUserFn is the function that registers a new user account
+		newUserFn func(screenName string) (state.User, error)
+		// expectOutput is the SNAC sent from the server to client
 		expectOutput oscar.SNACMessage
+		// wantErr is the error we expect from the method
+		wantErr error
 	}{
 		{
-			name: "login with valid password, expect OK login response",
+			name: "user provides valid credentials and logs in successfully",
 			cfg: server.Config{
 				OSCARHost: "127.0.0.1",
 				BOSPort:   1234,
 			},
-			userInDB:    userGoodPwd,
-			sessionUUID: uuid.UUID{1, 2, 3},
 			inputSNAC: oscar.SNAC_0x17_0x02_BUCPLoginRequest{
 				TLVRestBlock: oscar.TLVRestBlock{
 					TLVList: oscar.TLVList{
-						oscar.NewTLV(oscar.TLVPasswordHash, userGoodPwd.PassHash),
-						oscar.NewTLV(oscar.TLVScreenName, userGoodPwd.ScreenName),
+						oscar.NewTLV(oscar.TLVPasswordHash, user.PassHash),
+						oscar.NewTLV(oscar.TLVScreenName, user.ScreenName),
+					},
+				},
+			},
+			mockParams: mockParams{
+				userManagerParams: userManagerParams{
+					getUserParams: getUserParams{
+						{
+							screenName: user.ScreenName,
+							result:     &user,
+						},
+					},
+				},
+				sessionManagerParams: sessionManagerParams{
+					newSessionWithSNParams: newSessionWithSNParams{
+						{
+							sessID:     userSession.ID(),
+							screenName: user.ScreenName,
+							result:     userSession,
+						},
 					},
 				},
 			},
@@ -54,31 +79,56 @@ func TestReceiveAndSendBUCPLoginRequest(t *testing.T) {
 				Body: oscar.SNAC_0x17_0x03_BUCPLoginResponse{
 					TLVRestBlock: oscar.TLVRestBlock{
 						TLVList: oscar.TLVList{
-							oscar.NewTLV(oscar.TLVScreenName, userGoodPwd.ScreenName),
+							oscar.NewTLV(oscar.TLVScreenName, user.ScreenName),
 							oscar.NewTLV(oscar.TLVReconnectHere, "127.0.0.1:1234"),
-							oscar.NewTLV(oscar.TLVAuthorizationCookie, uuid.UUID{1, 2, 3}.String()),
+							oscar.NewTLV(oscar.TLVAuthorizationCookie, sessUUID.String()),
 						},
 					},
 				},
 			},
 		},
 		{
-			name: "login with bad password, expect OK login response (Cfg.DisableAuth=true)",
+			name: "user logs in with non-existent screen name--account is created and logged in successfully",
 			cfg: server.Config{
 				OSCARHost:   "127.0.0.1",
 				BOSPort:     1234,
 				DisableAuth: true,
 			},
-			userInDB:    userGoodPwd,
-			sessionUUID: uuid.UUID{1, 2, 3},
 			inputSNAC: oscar.SNAC_0x17_0x02_BUCPLoginRequest{
 				TLVRestBlock: oscar.TLVRestBlock{
 					TLVList: oscar.TLVList{
-						oscar.NewTLV(oscar.TLVPasswordHash, userBadPwd.PassHash),
-						oscar.NewTLV(oscar.TLVScreenName, userBadPwd.ScreenName),
+						oscar.NewTLV(oscar.TLVPasswordHash, user.PassHash),
+						oscar.NewTLV(oscar.TLVScreenName, user.ScreenName),
+					},
+				},
+			},
+			mockParams: mockParams{
+				userManagerParams: userManagerParams{
+					getUserParams: getUserParams{
+						{
+							screenName: user.ScreenName,
+							result:     nil,
+						},
+					},
+					upsertUserParams: upsertUserParams{
+						{
+							user: user,
+						},
+					},
+				},
+				sessionManagerParams: sessionManagerParams{
+					newSessionWithSNParams: newSessionWithSNParams{
+						{
+							sessID:     userSession.ID(),
+							screenName: user.ScreenName,
+							result:     userSession,
+						},
 					},
 				},
 			},
+			newUserFn: func(screenName string) (state.User, error) {
+				return user, nil
+			},
 			expectOutput: oscar.SNACMessage{
 				Frame: oscar.SNACFrame{
 					FoodGroup: oscar.BUCP,
@@ -87,27 +137,155 @@ func TestReceiveAndSendBUCPLoginRequest(t *testing.T) {
 				Body: oscar.SNAC_0x17_0x03_BUCPLoginResponse{
 					TLVRestBlock: oscar.TLVRestBlock{
 						TLVList: oscar.TLVList{
-							oscar.NewTLV(oscar.TLVScreenName, userBadPwd.ScreenName),
+							oscar.NewTLV(oscar.TLVScreenName, user.ScreenName),
 							oscar.NewTLV(oscar.TLVReconnectHere, "127.0.0.1:1234"),
-							oscar.NewTLV(oscar.TLVAuthorizationCookie, uuid.UUID{1, 2, 3}.String()),
+							oscar.NewTLV(oscar.TLVAuthorizationCookie, sessUUID.String()),
+						},
+					},
+				},
+			},
+		},
+		{
+			name: "user logs in with invalid password--account is created and logged in successfully",
+			cfg: server.Config{
+				OSCARHost:   "127.0.0.1",
+				BOSPort:     1234,
+				DisableAuth: true,
+			},
+			inputSNAC: oscar.SNAC_0x17_0x02_BUCPLoginRequest{
+				TLVRestBlock: oscar.TLVRestBlock{
+					TLVList: oscar.TLVList{
+						oscar.NewTLV(oscar.TLVPasswordHash, []byte("bad-password-hash")),
+						oscar.NewTLV(oscar.TLVScreenName, user.ScreenName),
+					},
+				},
+			},
+			mockParams: mockParams{
+				userManagerParams: userManagerParams{
+					getUserParams: getUserParams{
+						{
+							screenName: user.ScreenName,
+							result:     &user,
+						},
+					},
+					upsertUserParams: upsertUserParams{
+						{
+							user: user,
+						},
+					},
+				},
+				sessionManagerParams: sessionManagerParams{
+					newSessionWithSNParams: newSessionWithSNParams{
+						{
+							sessID:     userSession.ID(),
+							screenName: user.ScreenName,
+							result:     userSession,
+						},
+					},
+				},
+			},
+			newUserFn: func(screenName string) (state.User, error) {
+				return user, nil
+			},
+			expectOutput: oscar.SNACMessage{
+				Frame: oscar.SNACFrame{
+					FoodGroup: oscar.BUCP,
+					SubGroup:  oscar.BUCPLoginResponse,
+				},
+				Body: oscar.SNAC_0x17_0x03_BUCPLoginResponse{
+					TLVRestBlock: oscar.TLVRestBlock{
+						TLVList: oscar.TLVList{
+							oscar.NewTLV(oscar.TLVScreenName, user.ScreenName),
+							oscar.NewTLV(oscar.TLVReconnectHere, "127.0.0.1:1234"),
+							oscar.NewTLV(oscar.TLVAuthorizationCookie, sessUUID.String()),
+						},
+					},
+				},
+			},
+		},
+		{
+			name: "user provides invalid password--account creation fails due to user creation runtime error",
+			cfg: server.Config{
+				DisableAuth: true,
+			},
+			inputSNAC: oscar.SNAC_0x17_0x02_BUCPLoginRequest{
+				TLVRestBlock: oscar.TLVRestBlock{
+					TLVList: oscar.TLVList{
+						oscar.NewTLV(oscar.TLVPasswordHash, []byte("bad-password-hash")),
+						oscar.NewTLV(oscar.TLVScreenName, user.ScreenName),
+					},
+				},
+			},
+			mockParams: mockParams{
+				userManagerParams: userManagerParams{
+					getUserParams: getUserParams{
+						{
+							screenName: user.ScreenName,
+							result:     &user,
+						},
+					},
+				},
+			},
+			newUserFn: func(screenName string) (state.User, error) {
+				return user, io.EOF
+			},
+			wantErr: io.EOF,
+		},
+		{
+			name: "user provides invalid password--account creation fails due to user upsert runtime error",
+			cfg: server.Config{
+				DisableAuth: true,
+			},
+			inputSNAC: oscar.SNAC_0x17_0x02_BUCPLoginRequest{
+				TLVRestBlock: oscar.TLVRestBlock{
+					TLVList: oscar.TLVList{
+						oscar.NewTLV(oscar.TLVPasswordHash, []byte("bad-password-hash")),
+						oscar.NewTLV(oscar.TLVScreenName, user.ScreenName),
+					},
+				},
+			},
+			mockParams: mockParams{
+				userManagerParams: userManagerParams{
+					getUserParams: getUserParams{
+						{
+							screenName: user.ScreenName,
+							result:     &user,
+						},
+					},
+					upsertUserParams: upsertUserParams{
+						{
+							user: user,
+							err:  io.EOF,
 						},
 					},
 				},
 			},
+			newUserFn: func(screenName string) (state.User, error) {
+				return user, nil
+			},
+			wantErr: io.EOF,
 		},
 		{
-			name: "login with bad password, expect failed login response (Cfg.DisableAuth=false)",
+			name: "user provides invalid password and receives invalid login response",
 			cfg: server.Config{
 				OSCARHost: "127.0.0.1",
 				BOSPort:   1234,
 			},
-			userInDB:    userGoodPwd,
-			sessionUUID: uuid.UUID{1, 2, 3},
 			inputSNAC: oscar.SNAC_0x17_0x02_BUCPLoginRequest{
 				TLVRestBlock: oscar.TLVRestBlock{
 					TLVList: oscar.TLVList{
-						oscar.NewTLV(oscar.TLVPasswordHash, userBadPwd.PassHash),
-						oscar.NewTLV(oscar.TLVScreenName, userBadPwd.ScreenName),
+						oscar.NewTLV(oscar.TLVPasswordHash, []byte("bad_password")),
+						oscar.NewTLV(oscar.TLVScreenName, user.ScreenName),
+					},
+				},
+			},
+			mockParams: mockParams{
+				userManagerParams: userManagerParams{
+					getUserParams: getUserParams{
+						{
+							screenName: user.ScreenName,
+							result:     nil,
+						},
 					},
 				},
 			},
@@ -119,55 +297,87 @@ func TestReceiveAndSendBUCPLoginRequest(t *testing.T) {
 				Body: oscar.SNAC_0x17_0x03_BUCPLoginResponse{
 					TLVRestBlock: oscar.TLVRestBlock{
 						TLVList: oscar.TLVList{
-							oscar.NewTLV(oscar.TLVScreenName, userBadPwd.ScreenName),
+							oscar.NewTLV(oscar.TLVScreenName, user.ScreenName),
 							oscar.NewTLV(oscar.TLVErrorSubcode, uint16(0x01)),
 						},
 					},
 				},
 			},
 		},
+		{
+			name: "login fails on user manager lookup",
+			inputSNAC: oscar.SNAC_0x17_0x02_BUCPLoginRequest{
+				TLVRestBlock: oscar.TLVRestBlock{
+					TLVList: oscar.TLVList{
+						oscar.NewTLV(oscar.TLVPasswordHash, user.PassHash),
+						oscar.NewTLV(oscar.TLVScreenName, user.ScreenName),
+					},
+				},
+			},
+			mockParams: mockParams{
+				userManagerParams: userManagerParams{
+					getUserParams: getUserParams{
+						{
+							screenName: user.ScreenName,
+							err:        io.EOF,
+						},
+					},
+				},
+			},
+			wantErr: io.EOF,
+		},
 	}
 
 	for _, tc := range cases {
 		t.Run(tc.name, func(t *testing.T) {
-			sess := newTestSession(tc.userInDB.ScreenName, sessOptID(tc.sessionUUID.String()))
 			userManager := newMockUserManager(t)
-			userManager.EXPECT().
-				GetUser(tc.userInDB.ScreenName).
-				Return(&userGoodPwd, nil).
-				Maybe()
-			userManager.EXPECT().
-				UpsertUser(mock.Anything).
-				Return(nil).
-				Maybe()
+			for _, params := range tc.mockParams.getUserParams {
+				userManager.EXPECT().
+					GetUser(params.screenName).
+					Return(params.result, params.err)
+			}
+			for _, params := range tc.mockParams.upsertUserParams {
+				userManager.EXPECT().
+					UpsertUser(params.user).
+					Return(params.err)
+			}
 			sessionManager := newMockSessionManager(t)
-			sessionManager.EXPECT().
-				NewSessionWithSN(tc.sessionUUID.String(), tc.userInDB.ScreenName).
-				Return(sess).
-				Maybe()
+			for _, params := range tc.mockParams.newSessionWithSNParams {
+				sessionManager.EXPECT().
+					NewSessionWithSN(params.sessID, params.screenName).
+					Return(params.result)
+			}
 			svc := AuthService{
 				config:         tc.cfg,
 				sessionManager: sessionManager,
 				userManager:    userManager,
 			}
 			fnNewUUID := func() uuid.UUID {
-				return tc.sessionUUID
+				return sessUUID
 			}
-			outputSNAC, err := svc.ReceiveAndSendBUCPLoginRequest(tc.inputSNAC, fnNewUUID)
-			assert.NoError(t, err)
+			outputSNAC, err := svc.BUCPLoginRequestHandler(tc.inputSNAC, fnNewUUID, tc.newUserFn)
+			assert.ErrorIs(t, err, tc.wantErr)
 			assert.Equal(t, tc.expectOutput, outputSNAC)
 		})
 	}
 }
 
-func TestReceiveAndSendAuthChallenge(t *testing.T) {
+func TestAuthService_BUCPChallengeRequestHandler(t *testing.T) {
+	sessUUID := uuid.UUID{1, 2, 3}
 	cases := []struct {
-		name         string
-		cfg          server.Config
-		userInDB     *state.User
-		fnNewUUID    uuid.UUID
-		inputSNAC    oscar.SNAC_0x17_0x06_BUCPChallengeRequest
+		// name is the unit test name
+		name string
+		// cfg is the app configuration
+		cfg server.Config
+		// inputSNAC is the SNAC sent from the client to the server
+		inputSNAC oscar.SNAC_0x17_0x06_BUCPChallengeRequest
+		// mockParams is the list of params sent to mocks that satisfy this
+		// method's dependencies
+		mockParams mockParams
+		// expectOutput is the SNAC sent from the server to client
 		expectOutput oscar.SNACMessage
+		// wantErr is the error we expect from the method
+		wantErr error
 	}{
 		{
 			name: "login with valid username, expect OK login response",
@@ -175,11 +385,6 @@ func TestReceiveAndSendAuthChallenge(t *testing.T) {
 				OSCARHost: "127.0.0.1",
 				BOSPort:   1234,
 			},
-			userInDB: &state.User{
-				ScreenName: "sn_user_a",
-				AuthKey:    "auth_key_user_a",
-			},
-			fnNewUUID: uuid.UUID{1, 2, 3},
 			inputSNAC: oscar.SNAC_0x17_0x06_BUCPChallengeRequest{
 				TLVRestBlock: oscar.TLVRestBlock{
 					TLVList: oscar.TLVList{
@@ -187,6 +392,19 @@ func TestReceiveAndSendAuthChallenge(t *testing.T) {
 					},
 				},
 			},
+			mockParams: mockParams{
+				userManagerParams: userManagerParams{
+					getUserParams: getUserParams{
+						{
+							screenName: "sn_user_a",
+							result: &state.User{
+								ScreenName: "sn_user_a",
+								AuthKey:    "auth_key_user_a",
+							},
+						},
+					},
+				},
+			},
 			expectOutput: oscar.SNACMessage{
 				Frame: oscar.SNACFrame{
 					FoodGroup: oscar.BUCP,
@@ -204,8 +422,6 @@ func TestReceiveAndSendAuthChallenge(t *testing.T) {
 				BOSPort:     1234,
 				DisableAuth: true,
 			},
-			userInDB:  nil,
-			fnNewUUID: uuid.UUID{1, 2, 3},
 			inputSNAC: oscar.SNAC_0x17_0x06_BUCPChallengeRequest{
 				TLVRestBlock: oscar.TLVRestBlock{
 					TLVList: oscar.TLVList{
@@ -213,13 +429,23 @@ func TestReceiveAndSendAuthChallenge(t *testing.T) {
 					},
 				},
 			},
+			mockParams: mockParams{
+				userManagerParams: userManagerParams{
+					getUserParams: getUserParams{
+						{
+							screenName: "sn_user_b",
+							result:     nil,
+						},
+					},
+				},
+			},
 			expectOutput: oscar.SNACMessage{
 				Frame: oscar.SNACFrame{
 					FoodGroup: oscar.BUCP,
 					SubGroup:  oscar.BUCPChallengeResponse,
 				},
 				Body: oscar.SNAC_0x17_0x07_BUCPChallengeResponse{
-					AuthKey: uuid.UUID{1, 2, 3}.String(),
+					AuthKey: sessUUID.String(),
 				},
 			},
 		},
@@ -229,8 +455,6 @@ func TestReceiveAndSendAuthChallenge(t *testing.T) {
 				OSCARHost: "127.0.0.1",
 				BOSPort:   1234,
 			},
-			userInDB:  nil,
-			fnNewUUID: uuid.UUID{1, 2, 3},
 			inputSNAC: oscar.SNAC_0x17_0x06_BUCPChallengeRequest{
 				TLVRestBlock: oscar.TLVRestBlock{
 					TLVList: oscar.TLVList{
@@ -238,6 +462,16 @@ func TestReceiveAndSendAuthChallenge(t *testing.T) {
 					},
 				},
 			},
+			mockParams: mockParams{
+				userManagerParams: userManagerParams{
+					getUserParams: getUserParams{
+						{
+							screenName: "sn_user_b",
+							result:     nil,
+						},
+					},
+				},
+			},
 			expectOutput: oscar.SNACMessage{
 				Frame: oscar.SNACFrame{
 					FoodGroup: oscar.BUCP,
@@ -252,25 +486,380 @@ func TestReceiveAndSendAuthChallenge(t *testing.T) {
 				},
 			},
 		},
+		{
+			name: "login fails on user manager lookup",
+			inputSNAC: oscar.SNAC_0x17_0x06_BUCPChallengeRequest{
+				TLVRestBlock: oscar.TLVRestBlock{
+					TLVList: oscar.TLVList{
+						oscar.NewTLV(oscar.TLVScreenName, "sn_user_b"),
+					},
+				},
+			},
+			mockParams: mockParams{
+				userManagerParams: userManagerParams{
+					getUserParams: getUserParams{
+						{
+							screenName: "sn_user_b",
+							err:        io.EOF,
+						},
+					},
+				},
+			},
+			wantErr: io.EOF,
+		},
 	}
 
 	for _, tc := range cases {
 		t.Run(tc.name, func(t *testing.T) {
 			userManager := newMockUserManager(t)
-			userManager.EXPECT().
-				GetUser(string(tc.inputSNAC.TLVList[0].Val)).
-				Return(tc.userInDB, nil).
-				Maybe()
+			for _, params := range tc.mockParams.getUserParams {
+				userManager.EXPECT().
+					GetUser(params.screenName).
+					Return(params.result, params.err)
+			}
 			svc := AuthService{
 				config:      tc.cfg,
 				userManager: userManager,
 			}
 			fnNewUUID := func() uuid.UUID {
-				return tc.fnNewUUID
+				return sessUUID
 			}
-			outputSNAC, err := svc.ReceiveAndSendAuthChallenge(tc.inputSNAC, fnNewUUID)
-			assert.NoError(t, err)
+			outputSNAC, err := svc.BUCPChallengeRequestHandler(tc.inputSNAC, fnNewUUID)
+			assert.ErrorIs(t, err, tc.wantErr)
 			assert.Equal(t, tc.expectOutput, outputSNAC)
 		})
 	}
 }
+
+func TestAuthService_RetrieveChatSession_HappyPath(t *testing.T) {
+	chatID := "chat-1234"
+	sess := newTestSession("screen-name", sessOptCannedID)
+
+	sessionManager := newMockSessionManager(t)
+	sessionManager.EXPECT().
+		Retrieve(sess.ID()).
+		Return(sess)
+
+	chatRegistry := newMockChatRegistry(t)
+	chatRegistry.EXPECT().
+		Retrieve(chatID).
+		Return(state.ChatRoom{}, sessionManager, nil)
+
+	svc := NewAuthService(server.Config{}, nil, nil, nil, nil, chatRegistry)
+
+	have, err := svc.RetrieveChatSession(chatID, sess.ID())
+	assert.NoError(t, err)
+	assert.Equal(t, sess, have)
+}
+
+func TestAuthService_RetrieveChatSession_ChatNotFound(t *testing.T) {
+	chatID := "chat-1234"
+	sess := newTestSession("screen-name", sessOptCannedID)
+
+	chatRegistry := newMockChatRegistry(t)
+	chatRegistry.EXPECT().
+		Retrieve(chatID).
+		Return(state.ChatRoom{}, nil, state.ErrChatRoomNotFound)
+
+	svc := NewAuthService(server.Config{}, nil, nil, nil, nil, chatRegistry)
+
+	_, err := svc.RetrieveChatSession(chatID, sess.ID())
+	assert.ErrorIs(t, err, state.ErrChatRoomNotFound)
+}
+
+func TestAuthService_RetrieveChatSession_SessionNotFound(t *testing.T) {
+	chatID := "chat-1234"
+	sess := newTestSession("screen-name", sessOptCannedID)
+
+	sessionManager := newMockSessionManager(t)
+	sessionManager.EXPECT().
+		Retrieve(sess.ID()).
+		Return(nil)
+
+	chatRegistry := newMockChatRegistry(t)
+	chatRegistry.EXPECT().
+		Retrieve(chatID).
+		Return(state.ChatRoom{}, sessionManager, nil)
+
+	svc := NewAuthService(server.Config{}, nil, nil, nil, nil, chatRegistry)
+
+	have, err := svc.RetrieveChatSession(chatID, sess.ID())
+	assert.NoError(t, err)
+	assert.Nil(t, have)
+}
+
+func TestAuthService_RetrieveBOSSession_HappyPath(t *testing.T) {
+	sess := newTestSession("screen-name", sessOptCannedID)
+
+	sessionManager := newMockSessionManager(t)
+	sessionManager.EXPECT().
+		Retrieve(sess.ID()).
+		Return(sess)
+
+	svc := NewAuthService(server.Config{}, sessionManager, nil, nil, nil, nil)
+
+	have, err := svc.RetrieveBOSSession(sess.ID())
+	assert.NoError(t, err)
+	assert.Equal(t, sess, have)
+}
+
+func TestAuthService_RetrieveBOSSession_SessionNotFound(t *testing.T) {
+	sess := newTestSession("screen-name", sessOptCannedID)
+
+	sessionManager := newMockSessionManager(t)
+	sessionManager.EXPECT().
+		Retrieve(sess.ID()).
+		Return(nil)
+
+	svc := NewAuthService(server.Config{}, sessionManager, nil, nil, nil, nil)
+
+	have, err := svc.RetrieveBOSSession(sess.ID())
+	assert.NoError(t, err)
+	assert.Nil(t, have)
+}
+
+func TestAuthService_SignoutChat(t *testing.T) {
+	sess := newTestSession("", sessOptCannedSignonTime)
+
+	tests := []struct {
+		// name is the unit test name
+		name string
+		// userSession is the session of the user signing out
+		userSession *state.Session
+		// chatRoom is the chat room user is exiting
+		chatRoom state.ChatRoom
+		// wantErr is the error we expect from the method
+		wantErr error
+		// mockParams is the list of params sent to mocks that satisfy this
+		// method's dependencies
+		mockParams mockParams
+	}{
+		{
+			name:        "user signs out of chat room, room is empty after user leaves",
+			userSession: sess,
+			chatRoom: state.ChatRoom{
+				Cookie: "the-chat-cookie",
+			},
+			mockParams: mockParams{
+				chatMessageRelayerParams: chatMessageRelayerParams{
+					broadcastExceptParams: broadcastExceptParams{
+						{
+							except: sess,
+							message: oscar.SNACMessage{
+								Frame: oscar.SNACFrame{
+									FoodGroup: oscar.Chat,
+									SubGroup:  oscar.ChatUsersLeft,
+								},
+								Body: oscar.SNAC_0x0E_0x04_ChatUsersLeft{
+									Users: []oscar.TLVUserInfo{
+										sess.TLVUserInfo(),
+									},
+								},
+							},
+						},
+					},
+				},
+				sessionManagerParams: sessionManagerParams{
+					emptyParams: emptyParams{
+						{
+							result: true,
+						},
+					},
+					removeParams: removeParams{
+						{
+							sess: sess,
+						},
+					},
+				},
+			},
+		},
+		{
+			name:        "user signs out of chat room, room is not empty after user leaves",
+			userSession: sess,
+			chatRoom: state.ChatRoom{
+				Cookie: "the-chat-cookie",
+			},
+			mockParams: mockParams{
+				chatMessageRelayerParams: chatMessageRelayerParams{
+					broadcastExceptParams: broadcastExceptParams{
+						{
+							except: sess,
+							message: oscar.SNACMessage{
+								Frame: oscar.SNACFrame{
+									FoodGroup: oscar.Chat,
+									SubGroup:  oscar.ChatUsersLeft,
+								},
+								Body: oscar.SNAC_0x0E_0x04_ChatUsersLeft{
+									Users: []oscar.TLVUserInfo{
+										sess.TLVUserInfo(),
+									},
+								},
+							},
+						},
+					},
+				},
+				sessionManagerParams: sessionManagerParams{
+					emptyParams: emptyParams{
+						{
+							result: false,
+						},
+					},
+					removeParams: removeParams{
+						{
+							sess: sess,
+						},
+					},
+				},
+			},
+		},
+		{
+			name:        "user can't sign out because chat room doesn't exist",
+			userSession: sess,
+			chatRoom: state.ChatRoom{
+				Cookie: "the-chat-cookie",
+			},
+			wantErr: state.ErrChatRoomNotFound,
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			chatMessageRelayer := newMockChatMessageRelayer(t)
+			for _, params := range tt.mockParams.broadcastExceptParams {
+				chatMessageRelayer.EXPECT().
+					BroadcastExcept(nil, params.except, params.message)
+			}
+
+			sessionManager := newMockSessionManager(t)
+			chatRegistry := newMockChatRegistry(t)
+			for _, params := range tt.mockParams.removeParams {
+				sessionManager.EXPECT().Remove(params.sess)
+			}
+			for _, params := range tt.mockParams.emptyParams {
+				sessionManager.EXPECT().Empty().Return(params.result)
+				if params.result {
+					chatRegistry.EXPECT().RemoveRoom(tt.chatRoom.Cookie)
+				}
+			}
+			chatSessionManager := struct {
+				ChatMessageRelayer
+				SessionManager
+			}{
+				chatMessageRelayer,
+				sessionManager,
+			}
+			chatRegistry.EXPECT().
+				Retrieve(tt.chatRoom.Cookie).
+				Return(tt.chatRoom, chatSessionManager, tt.wantErr)
+
+			svc := NewAuthService(server.Config{}, nil, nil, nil, nil, chatRegistry)
+
+			err := svc.SignoutChat(nil, tt.userSession, tt.chatRoom.Cookie)
+			assert.ErrorIs(t, err, tt.wantErr)
+		})
+	}
+}
+
+func TestAuthService_Signout(t *testing.T) {
+	sess := newTestSession("user_screen_name", sessOptCannedSignonTime)
+
+	tests := []struct {
+		// name is the unit test name
+		name string
+		// userSession is the session of the user signing out
+		userSession *state.Session
+		// chatRoom is the chat room user is exiting
+		chatRoom state.ChatRoom
+		// wantErr is the error we expect from the method
+		wantErr error
+		// mockParams is the list of params sent to mocks that satisfy this
+		// method's dependencies
+		mockParams mockParams
+	}{
+		{
+			name:        "user signs out of chat room, room is empty after user leaves",
+			userSession: sess,
+			chatRoom: state.ChatRoom{
+				Cookie: "the-chat-cookie",
+			},
+			mockParams: mockParams{
+				sessionManagerParams: sessionManagerParams{
+					removeParams: removeParams{
+						{
+							sess: sess,
+						},
+					},
+				},
+				feedbagManagerParams: feedbagManagerParams{
+					interestedUsersParams: interestedUsersParams{
+						{
+							screenName: "user_screen_name",
+							users:      []string{"friend1", "friend2"},
+						},
+					},
+				},
+				messageRelayerParams: messageRelayerParams{
+					broadcastToScreenNamesParams: broadcastToScreenNamesParams{
+						{
+							screenNames: []string{"friend1", "friend2"},
+							message: oscar.SNACMessage{
+								Frame: oscar.SNACFrame{
+									FoodGroup: oscar.Buddy,
+									SubGroup:  oscar.BuddyDeparted,
+								},
+								Body: oscar.SNAC_0x03_0x0C_BuddyDeparted{
+									TLVUserInfo: oscar.TLVUserInfo{
+										ScreenName:   sess.ScreenName(),
+										WarningLevel: sess.Warning(),
+									},
+								},
+							},
+						},
+					},
+				},
+			},
+		},
+		{
+			name:        "user signs out of chat room, room is empty after user leaves",
+			userSession: sess,
+			chatRoom: state.ChatRoom{
+				Cookie: "the-chat-cookie",
+			},
+			mockParams: mockParams{
+				feedbagManagerParams: feedbagManagerParams{
+					interestedUsersParams: interestedUsersParams{
+						{
+							screenName: "user_screen_name",
+							users:      []string{"friend1", "friend2"},
+							err:        io.EOF,
+						},
+					},
+				},
+			},
+			wantErr: io.EOF,
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			messageRelayer := newMockMessageRelayer(t)
+			for _, params := range tt.mockParams.broadcastToScreenNamesParams {
+				messageRelayer.EXPECT().
+					BroadcastToScreenNames(mock.Anything, params.screenNames, params.message)
+			}
+			feedbagManager := newMockFeedbagManager(t)
+			for _, params := range tt.mockParams.interestedUsersParams {
+				feedbagManager.EXPECT().
+					InterestedUsers(params.screenName).
+					Return(params.users, params.err)
+			}
+			sessionManager := newMockSessionManager(t)
+			for _, params := range tt.mockParams.removeParams {
+				sessionManager.EXPECT().Remove(params.sess)
+			}
+
+			svc := NewAuthService(server.Config{}, sessionManager, messageRelayer, feedbagManager, nil, nil)
+
+			err := svc.Signout(nil, tt.userSession)
+			assert.ErrorIs(t, err, tt.wantErr)
+		})
+	}
+}

+ 1 - 7
handler/chat.go

@@ -114,13 +114,7 @@ func alertUserLeft(ctx context.Context, sess *state.Session, chatMessageRelayer
 		},
 		Body: oscar.SNAC_0x0E_0x04_ChatUsersLeft{
 			Users: []oscar.TLVUserInfo{
-				{
-					ScreenName:   sess.ScreenName(),
-					WarningLevel: sess.Warning(),
-					TLVBlock: oscar.TLVBlock{
-						TLVList: sess.UserInfo(),
-					},
-				},
+				sess.TLVUserInfo(),
 			},
 		},
 	})

+ 9 - 1
handler/chat_message_relayer_mock_test.go

@@ -1,4 +1,4 @@
-// Code generated by mockery v2.35.2. DO NOT EDIT.
+// Code generated by mockery v2.38.0. DO NOT EDIT.
 
 package handler
 
@@ -98,6 +98,10 @@ func (_c *mockChatMessageRelayer_BroadcastToScreenNames_Call) RunAndReturn(run f
 func (_m *mockChatMessageRelayer) Participants() []*state.Session {
 	ret := _m.Called()
 
+	if len(ret) == 0 {
+		panic("no return value specified for Participants")
+	}
+
 	var r0 []*state.Session
 	if rf, ok := ret.Get(0).(func() []*state.Session); ok {
 		r0 = rf()
@@ -141,6 +145,10 @@ func (_c *mockChatMessageRelayer_Participants_Call) RunAndReturn(run func() []*s
 func (_m *mockChatMessageRelayer) RetrieveByScreenName(screenName string) *state.Session {
 	ret := _m.Called(screenName)
 
+	if len(ret) == 0 {
+		panic("no return value specified for RetrieveByScreenName")
+	}
+
 	var r0 *state.Session
 	if rf, ok := ret.Get(0).(func(string) *state.Session); ok {
 		r0 = rf(screenName)

+ 167 - 0
handler/chat_registry_test.go

@@ -0,0 +1,167 @@
+// Code generated by mockery v2.38.0. DO NOT EDIT.
+
+package handler
+
+import (
+	state "github.com/mkaminski/goaim/state"
+	mock "github.com/stretchr/testify/mock"
+)
+
+// mockChatRegistry is an autogenerated mock type for the ChatRegistry type
+type mockChatRegistry struct {
+	mock.Mock
+}
+
+type mockChatRegistry_Expecter struct {
+	mock *mock.Mock
+}
+
+func (_m *mockChatRegistry) EXPECT() *mockChatRegistry_Expecter {
+	return &mockChatRegistry_Expecter{mock: &_m.Mock}
+}
+
+// Register provides a mock function with given fields: room, sessionManager
+func (_m *mockChatRegistry) Register(room state.ChatRoom, sessionManager interface{}) {
+	_m.Called(room, sessionManager)
+}
+
+// mockChatRegistry_Register_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Register'
+type mockChatRegistry_Register_Call struct {
+	*mock.Call
+}
+
+// Register is a helper method to define mock.On call
+//   - room state.ChatRoom
+//   - sessionManager interface{}
+func (_e *mockChatRegistry_Expecter) Register(room interface{}, sessionManager interface{}) *mockChatRegistry_Register_Call {
+	return &mockChatRegistry_Register_Call{Call: _e.mock.On("Register", room, sessionManager)}
+}
+
+func (_c *mockChatRegistry_Register_Call) Run(run func(room state.ChatRoom, sessionManager interface{})) *mockChatRegistry_Register_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(state.ChatRoom), args[1].(interface{}))
+	})
+	return _c
+}
+
+func (_c *mockChatRegistry_Register_Call) Return() *mockChatRegistry_Register_Call {
+	_c.Call.Return()
+	return _c
+}
+
+func (_c *mockChatRegistry_Register_Call) RunAndReturn(run func(state.ChatRoom, interface{})) *mockChatRegistry_Register_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// RemoveRoom provides a mock function with given fields: chatID
+func (_m *mockChatRegistry) RemoveRoom(chatID string) {
+	_m.Called(chatID)
+}
+
+// mockChatRegistry_RemoveRoom_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RemoveRoom'
+type mockChatRegistry_RemoveRoom_Call struct {
+	*mock.Call
+}
+
+// RemoveRoom is a helper method to define mock.On call
+//   - chatID string
+func (_e *mockChatRegistry_Expecter) RemoveRoom(chatID interface{}) *mockChatRegistry_RemoveRoom_Call {
+	return &mockChatRegistry_RemoveRoom_Call{Call: _e.mock.On("RemoveRoom", chatID)}
+}
+
+func (_c *mockChatRegistry_RemoveRoom_Call) Run(run func(chatID string)) *mockChatRegistry_RemoveRoom_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(string))
+	})
+	return _c
+}
+
+func (_c *mockChatRegistry_RemoveRoom_Call) Return() *mockChatRegistry_RemoveRoom_Call {
+	_c.Call.Return()
+	return _c
+}
+
+func (_c *mockChatRegistry_RemoveRoom_Call) RunAndReturn(run func(string)) *mockChatRegistry_RemoveRoom_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// Retrieve provides a mock function with given fields: chatID
+func (_m *mockChatRegistry) Retrieve(chatID string) (state.ChatRoom, interface{}, error) {
+	ret := _m.Called(chatID)
+
+	if len(ret) == 0 {
+		panic("no return value specified for Retrieve")
+	}
+
+	var r0 state.ChatRoom
+	var r1 interface{}
+	var r2 error
+	if rf, ok := ret.Get(0).(func(string) (state.ChatRoom, interface{}, error)); ok {
+		return rf(chatID)
+	}
+	if rf, ok := ret.Get(0).(func(string) state.ChatRoom); ok {
+		r0 = rf(chatID)
+	} else {
+		r0 = ret.Get(0).(state.ChatRoom)
+	}
+
+	if rf, ok := ret.Get(1).(func(string) interface{}); ok {
+		r1 = rf(chatID)
+	} else {
+		if ret.Get(1) != nil {
+			r1 = ret.Get(1).(interface{})
+		}
+	}
+
+	if rf, ok := ret.Get(2).(func(string) error); ok {
+		r2 = rf(chatID)
+	} else {
+		r2 = ret.Error(2)
+	}
+
+	return r0, r1, r2
+}
+
+// mockChatRegistry_Retrieve_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Retrieve'
+type mockChatRegistry_Retrieve_Call struct {
+	*mock.Call
+}
+
+// Retrieve is a helper method to define mock.On call
+//   - chatID string
+func (_e *mockChatRegistry_Expecter) Retrieve(chatID interface{}) *mockChatRegistry_Retrieve_Call {
+	return &mockChatRegistry_Retrieve_Call{Call: _e.mock.On("Retrieve", chatID)}
+}
+
+func (_c *mockChatRegistry_Retrieve_Call) Run(run func(chatID string)) *mockChatRegistry_Retrieve_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(string))
+	})
+	return _c
+}
+
+func (_c *mockChatRegistry_Retrieve_Call) Return(_a0 state.ChatRoom, _a1 interface{}, _a2 error) *mockChatRegistry_Retrieve_Call {
+	_c.Call.Return(_a0, _a1, _a2)
+	return _c
+}
+
+func (_c *mockChatRegistry_Retrieve_Call) RunAndReturn(run func(string) (state.ChatRoom, interface{}, error)) *mockChatRegistry_Retrieve_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
+// newMockChatRegistry creates a new instance of mockChatRegistry. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
+// The first argument is typically a *testing.T value.
+func newMockChatRegistry(t interface {
+	mock.TestingT
+	Cleanup(func())
+}) *mockChatRegistry {
+	mock := &mockChatRegistry{}
+	mock.Mock.Test(t)
+
+	t.Cleanup(func() { mock.AssertExpectations(t) })
+
+	return mock
+}

+ 29 - 1
handler/feedbag_manager_mock_test.go

@@ -1,4 +1,4 @@
-// Code generated by mockery v2.35.2. DO NOT EDIT.
+// Code generated by mockery v2.38.0. DO NOT EDIT.
 
 package handler
 
@@ -28,6 +28,10 @@ func (_m *mockFeedbagManager) EXPECT() *mockFeedbagManager_Expecter {
 func (_m *mockFeedbagManager) Blocked(screenName1 string, screenName2 string) (state.BlockedState, error) {
 	ret := _m.Called(screenName1, screenName2)
 
+	if len(ret) == 0 {
+		panic("no return value specified for Blocked")
+	}
+
 	var r0 state.BlockedState
 	var r1 error
 	if rf, ok := ret.Get(0).(func(string, string) (state.BlockedState, error)); ok {
@@ -81,6 +85,10 @@ func (_c *mockFeedbagManager_Blocked_Call) RunAndReturn(run func(string, string)
 func (_m *mockFeedbagManager) Buddies(screenName string) ([]string, error) {
 	ret := _m.Called(screenName)
 
+	if len(ret) == 0 {
+		panic("no return value specified for Buddies")
+	}
+
 	var r0 []string
 	var r1 error
 	if rf, ok := ret.Get(0).(func(string) ([]string, error)); ok {
@@ -135,6 +143,10 @@ func (_c *mockFeedbagManager_Buddies_Call) RunAndReturn(run func(string) ([]stri
 func (_m *mockFeedbagManager) Delete(screenName string, items []oscar.FeedbagItem) error {
 	ret := _m.Called(screenName, items)
 
+	if len(ret) == 0 {
+		panic("no return value specified for Delete")
+	}
+
 	var r0 error
 	if rf, ok := ret.Get(0).(func(string, []oscar.FeedbagItem) error); ok {
 		r0 = rf(screenName, items)
@@ -178,6 +190,10 @@ func (_c *mockFeedbagManager_Delete_Call) RunAndReturn(run func(string, []oscar.
 func (_m *mockFeedbagManager) InterestedUsers(screenName string) ([]string, error) {
 	ret := _m.Called(screenName)
 
+	if len(ret) == 0 {
+		panic("no return value specified for InterestedUsers")
+	}
+
 	var r0 []string
 	var r1 error
 	if rf, ok := ret.Get(0).(func(string) ([]string, error)); ok {
@@ -232,6 +248,10 @@ func (_c *mockFeedbagManager_InterestedUsers_Call) RunAndReturn(run func(string)
 func (_m *mockFeedbagManager) LastModified(screenName string) (time.Time, error) {
 	ret := _m.Called(screenName)
 
+	if len(ret) == 0 {
+		panic("no return value specified for LastModified")
+	}
+
 	var r0 time.Time
 	var r1 error
 	if rf, ok := ret.Get(0).(func(string) (time.Time, error)); ok {
@@ -284,6 +304,10 @@ func (_c *mockFeedbagManager_LastModified_Call) RunAndReturn(run func(string) (t
 func (_m *mockFeedbagManager) Retrieve(screenName string) ([]oscar.FeedbagItem, error) {
 	ret := _m.Called(screenName)
 
+	if len(ret) == 0 {
+		panic("no return value specified for Retrieve")
+	}
+
 	var r0 []oscar.FeedbagItem
 	var r1 error
 	if rf, ok := ret.Get(0).(func(string) ([]oscar.FeedbagItem, error)); ok {
@@ -338,6 +362,10 @@ func (_c *mockFeedbagManager_Retrieve_Call) RunAndReturn(run func(string) ([]osc
 func (_m *mockFeedbagManager) Upsert(screenName string, items []oscar.FeedbagItem) error {
 	ret := _m.Called(screenName, items)
 
+	if len(ret) == 0 {
+		panic("no return value specified for Upsert")
+	}
+
 	var r0 error
 	if rf, ok := ret.Get(0).(func(string, []oscar.FeedbagItem) error); ok {
 		r0 = rf(screenName, items)

+ 5 - 1
handler/message_relayer_mock_test.go

@@ -1,4 +1,4 @@
-// Code generated by mockery v2.35.2. DO NOT EDIT.
+// Code generated by mockery v2.38.0. DO NOT EDIT.
 
 package handler
 
@@ -63,6 +63,10 @@ func (_c *mockMessageRelayer_BroadcastToScreenNames_Call) RunAndReturn(run func(
 func (_m *mockMessageRelayer) RetrieveByScreenName(screenName string) *state.Session {
 	ret := _m.Called(screenName)
 
+	if len(ret) == 0 {
+		panic("no return value specified for RetrieveByScreenName")
+	}
+
 	var r0 *state.Session
 	if rf, ok := ret.Get(0).(func(string) *state.Session); ok {
 		r0 = rf(screenName)

+ 1 - 1
handler/oservice_test.go

@@ -88,7 +88,7 @@ func TestReceiveAndSendServiceRequest(t *testing.T) {
 							oscar.NewTLV(oscar.OServiceTLVTagsReconnectHere, "127.0.0.1:1234"),
 							oscar.NewTLV(oscar.OServiceTLVTagsLoginCookie, server.ChatCookie{
 								Cookie: []byte("the-chat-cookie"),
-								SessID: "user-sess-id",
+								SessID: "user-userSession-id",
 							}),
 							oscar.NewTLV(oscar.OServiceTLVTagsGroupID, oscar.Chat),
 							oscar.NewTLV(oscar.OServiceTLVTagsSSLCertName, ""),

+ 9 - 1
handler/profile_manager_mock_test.go

@@ -1,4 +1,4 @@
-// Code generated by mockery v2.35.2. DO NOT EDIT.
+// Code generated by mockery v2.38.0. DO NOT EDIT.
 
 package handler
 
@@ -21,6 +21,10 @@ func (_m *mockProfileManager) EXPECT() *mockProfileManager_Expecter {
 func (_m *mockProfileManager) RetrieveProfile(screenName string) (string, error) {
 	ret := _m.Called(screenName)
 
+	if len(ret) == 0 {
+		panic("no return value specified for RetrieveProfile")
+	}
+
 	var r0 string
 	var r1 error
 	if rf, ok := ret.Get(0).(func(string) (string, error)); ok {
@@ -73,6 +77,10 @@ func (_c *mockProfileManager_RetrieveProfile_Call) RunAndReturn(run func(string)
 func (_m *mockProfileManager) UpsertProfile(screenName string, body string) error {
 	ret := _m.Called(screenName, body)
 
+	if len(ret) == 0 {
+		panic("no return value specified for UpsertProfile")
+	}
+
 	var r0 error
 	if rf, ok := ret.Get(0).(func(string, string) error); ok {
 		r0 = rf(screenName, body)

+ 18 - 16
handler/session_manager_mock_test.go

@@ -1,4 +1,4 @@
-// Code generated by mockery v2.35.2. DO NOT EDIT.
+// Code generated by mockery v2.38.0. DO NOT EDIT.
 
 package handler
 
@@ -24,6 +24,10 @@ func (_m *mockSessionManager) EXPECT() *mockSessionManager_Expecter {
 func (_m *mockSessionManager) Empty() bool {
 	ret := _m.Called()
 
+	if len(ret) == 0 {
+		panic("no return value specified for Empty")
+	}
+
 	var r0 bool
 	if rf, ok := ret.Get(0).(func() bool); ok {
 		r0 = rf()
@@ -65,6 +69,10 @@ func (_c *mockSessionManager_Empty_Call) RunAndReturn(run func() bool) *mockSess
 func (_m *mockSessionManager) NewSessionWithSN(sessID string, screenName string) *state.Session {
 	ret := _m.Called(sessID, screenName)
 
+	if len(ret) == 0 {
+		panic("no return value specified for NewSessionWithSN")
+	}
+
 	var r0 *state.Session
 	if rf, ok := ret.Get(0).(func(string, string) *state.Session); ok {
 		r0 = rf(sessID, screenName)
@@ -140,14 +148,14 @@ func (_c *mockSessionManager_Remove_Call) RunAndReturn(run func(*state.Session))
 }
 
 // Retrieve provides a mock function with given fields: ID
-func (_m *mockSessionManager) Retrieve(ID string) (*state.Session, bool) {
+func (_m *mockSessionManager) Retrieve(ID string) *state.Session {
 	ret := _m.Called(ID)
 
-	var r0 *state.Session
-	var r1 bool
-	if rf, ok := ret.Get(0).(func(string) (*state.Session, bool)); ok {
-		return rf(ID)
+	if len(ret) == 0 {
+		panic("no return value specified for Retrieve")
 	}
+
+	var r0 *state.Session
 	if rf, ok := ret.Get(0).(func(string) *state.Session); ok {
 		r0 = rf(ID)
 	} else {
@@ -156,13 +164,7 @@ func (_m *mockSessionManager) Retrieve(ID string) (*state.Session, bool) {
 		}
 	}
 
-	if rf, ok := ret.Get(1).(func(string) bool); ok {
-		r1 = rf(ID)
-	} else {
-		r1 = ret.Get(1).(bool)
-	}
-
-	return r0, r1
+	return r0
 }
 
 // mockSessionManager_Retrieve_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Retrieve'
@@ -183,12 +185,12 @@ func (_c *mockSessionManager_Retrieve_Call) Run(run func(ID string)) *mockSessio
 	return _c
 }
 
-func (_c *mockSessionManager_Retrieve_Call) Return(_a0 *state.Session, _a1 bool) *mockSessionManager_Retrieve_Call {
-	_c.Call.Return(_a0, _a1)
+func (_c *mockSessionManager_Retrieve_Call) Return(_a0 *state.Session) *mockSessionManager_Retrieve_Call {
+	_c.Call.Return(_a0)
 	return _c
 }
 
-func (_c *mockSessionManager_Retrieve_Call) RunAndReturn(run func(string) (*state.Session, bool)) *mockSessionManager_Retrieve_Call {
+func (_c *mockSessionManager_Retrieve_Call) RunAndReturn(run func(string) *state.Session) *mockSessionManager_Retrieve_Call {
 	_c.Call.Return(run)
 	return _c
 }

+ 81 - 3
handler/test_helpers.go

@@ -10,9 +10,73 @@ import (
 // mockParams is a helper struct that centralizes mock function call parameters
 // in one place for a table test
 type mockParams struct {
+	chatMessageRelayerParams
+	chatRegistryParams
 	feedbagManagerParams
 	messageRelayerParams
 	profileManagerParams
+	sessionManagerParams
+	userManagerParams
+}
+
+type chatRegistryParams struct {
+	chatRegistryRetrieveParams
+}
+
+type chatRegistryRetrieveParams struct {
+	chatID         string
+	retChatRoom    state.ChatRoom
+	retChatSessMgr any
+}
+
+// userManagerParams is a helper struct that contains mock parameters for
+// UserManager methods
+type userManagerParams struct {
+	getUserParams
+	upsertUserParams
+}
+
+// getUserParams is the list of parameters passed at the mock
+// UserManager.GetUser call site
+type getUserParams []struct {
+	screenName string
+	result     *state.User
+	err        error
+}
+
+// upsertUserParams is the list of parameters passed at the mock
+// UserManager.UpsertUser call site
+type upsertUserParams []struct {
+	user state.User
+	err  error
+}
+
+// sessionManagerParams is a helper struct that contains mock parameters for
+// SessionManager methods
+type sessionManagerParams struct {
+	emptyParams
+	newSessionWithSNParams
+	removeParams
+}
+
+// newSessionWithSNParams is the list of parameters passed at the mock
+// SessionManager.NewSessionWithSN call site
+type newSessionWithSNParams []struct {
+	sessID     string
+	screenName string
+	result     *state.Session
+}
+
+// removeParams is the list of parameters passed at the mock
+// SessionManager.Remove call site
+type removeParams []struct {
+	sess *state.Session
+}
+
+// emptyParams is the list of parameters passed at the mock
+// SessionManager.Empty call site
+type emptyParams []struct {
+	result bool
 }
 
 // feedbagManagerParams is a helper struct that contains mock parameters for
@@ -41,6 +105,7 @@ type blockedParams []struct {
 type interestedUsersParams []struct {
 	screenName string
 	users      []string
+	err        error
 }
 
 // upsertParams is the list of parameters passed at the mock
@@ -129,6 +194,19 @@ type upsertProfileParams []struct {
 	body       any
 }
 
+// chatMessageRelayerParams is a helper struct that contains mock parameters
+// for ChatMessageRelayer methods
+type chatMessageRelayerParams struct {
+	broadcastExceptParams
+}
+
+// broadcastExceptParams is the list of parameters passed at the mock
+// ChatMessageRelayer.BroadcastExcept call site
+type broadcastExceptParams []struct {
+	except  *state.Session
+	message oscar.SNACMessage
+}
+
 // sessOptWarning sets a warning level on the session object
 func sessOptWarning(level uint16) func(session *state.Session) {
 	return func(session *state.Session) {
@@ -136,13 +214,13 @@ func sessOptWarning(level uint16) func(session *state.Session) {
 	}
 }
 
-// sessOptCannedID sets a canned session ID ("user-sess-id") on the session
+// sessOptCannedID sets a canned session ID ("user-userSession-id") on the session
 // object
 func sessOptCannedID(session *state.Session) {
-	session.SetID("user-sess-id")
+	session.SetID("user-userSession-id")
 }
 
-// sessOptCannedID sets a canned session ID ("user-sess-id") on the session
+// sessOptCannedID sets a canned session ID ("user-userSession-id") on the session
 // object
 func sessOptID(ID string) func(session *state.Session) {
 	return func(session *state.Session) {

+ 7 - 1
handler/types.go

@@ -27,7 +27,7 @@ type SessionManager interface {
 	Empty() bool
 	NewSessionWithSN(sessID string, screenName string) *state.Session
 	Remove(sess *state.Session)
-	Retrieve(ID string) (*state.Session, bool)
+	Retrieve(ID string) *state.Session
 }
 
 type ProfileManager interface {
@@ -46,3 +46,9 @@ type ChatMessageRelayer interface {
 	BroadcastExcept(ctx context.Context, except *state.Session, msg oscar.SNACMessage)
 	Participants() []*state.Session
 }
+
+type ChatRegistry interface {
+	Register(room state.ChatRoom, sessionManager any)
+	Retrieve(chatID string) (state.ChatRoom, any, error)
+	RemoveRoom(chatID string)
+}

+ 9 - 1
handler/user_manager_manager_mock_test.go

@@ -1,4 +1,4 @@
-// Code generated by mockery v2.35.2. DO NOT EDIT.
+// Code generated by mockery v2.38.0. DO NOT EDIT.
 
 package handler
 
@@ -24,6 +24,10 @@ func (_m *mockUserManager) EXPECT() *mockUserManager_Expecter {
 func (_m *mockUserManager) GetUser(screenName string) (*state.User, error) {
 	ret := _m.Called(screenName)
 
+	if len(ret) == 0 {
+		panic("no return value specified for GetUser")
+	}
+
 	var r0 *state.User
 	var r1 error
 	if rf, ok := ret.Get(0).(func(string) (*state.User, error)); ok {
@@ -78,6 +82,10 @@ func (_c *mockUserManager_GetUser_Call) RunAndReturn(run func(string) (*state.Us
 func (_m *mockUserManager) UpsertUser(u state.User) error {
 	ret := _m.Called(u)
 
+	if len(ret) == 0 {
+		panic("no return value specified for UpsertUser")
+	}
+
 	var r0 error
 	if rf, ok := ret.Get(0).(func(state.User) error); ok {
 		r0 = rf(u)

+ 65 - 10
server/auth_service.go

@@ -3,6 +3,7 @@ package server
 import (
 	"bytes"
 	"context"
+	"errors"
 	"io"
 	"net"
 	"os"
@@ -13,14 +14,12 @@ import (
 )
 
 type AuthHandler interface {
-	ReceiveAndSendAuthChallenge(bodyIn oscar.SNAC_0x17_0x06_BUCPChallengeRequest, newUUID func() uuid.UUID) (oscar.SNACMessage, error)
-	ReceiveAndSendBUCPLoginRequest(bodyIn oscar.SNAC_0x17_0x02_BUCPLoginRequest, newUUID func() uuid.UUID) (oscar.SNACMessage, error)
-	RetrieveChatSession(ctx context.Context, chatID string, sessID string) (*state.Session, error)
-	SendAndReceiveSignonFrame(rw io.ReadWriter, sequence *uint32) (oscar.FLAPSignonFrame, error)
+	BUCPChallengeRequestHandler(bodyIn oscar.SNAC_0x17_0x06_BUCPChallengeRequest, newUUID func() uuid.UUID) (oscar.SNACMessage, error)
+	BUCPLoginRequestHandler(bodyIn oscar.SNAC_0x17_0x02_BUCPLoginRequest, newUUID func() uuid.UUID, fn func(screenName string) (state.User, error)) (oscar.SNACMessage, error)
+	RetrieveChatSession(chatID string, sessionID string) (*state.Session, error)
+	RetrieveBOSSession(sessionID string) (*state.Session, error)
 	Signout(ctx context.Context, sess *state.Session) error
-	SignoutChat(ctx context.Context, sess *state.Session, chatID string)
-	VerifyChatLogin(rw io.ReadWriter) (*ChatCookie, uint32, error)
-	VerifyLogin(rwc io.ReadWriteCloser) (*state.Session, uint32, error)
+	SignoutChat(ctx context.Context, sess *state.Session, chatID string) error
 }
 
 type AuthService struct {
@@ -29,10 +28,66 @@ type AuthService struct {
 	RouteLogger
 }
 
+func SendAndReceiveSignonFrame(rw io.ReadWriter, sequence *uint32) (oscar.FLAPSignonFrame, error) {
+	flapFrameOut := oscar.FLAPFrame{
+		StartMarker:   42,
+		FrameType:     oscar.FLAPFrameSignon,
+		Sequence:      uint16(*sequence),
+		PayloadLength: 4, // size of FLAPSignonFrame
+	}
+	if err := oscar.Marshal(flapFrameOut, rw); err != nil {
+		return oscar.FLAPSignonFrame{}, err
+	}
+	flapSignonFrameOut := oscar.FLAPSignonFrame{
+		FLAPVersion: 1,
+	}
+	if err := oscar.Marshal(flapSignonFrameOut, rw); err != nil {
+		return oscar.FLAPSignonFrame{}, err
+	}
+
+	// receive
+	flapFrameIn := oscar.FLAPFrame{}
+	if err := oscar.Unmarshal(&flapFrameIn, rw); err != nil {
+		return oscar.FLAPSignonFrame{}, err
+	}
+	b := make([]byte, flapFrameIn.PayloadLength)
+	if _, err := rw.Read(b); err != nil {
+		return oscar.FLAPSignonFrame{}, err
+	}
+	flapSignonFrameIn := oscar.FLAPSignonFrame{}
+	if err := oscar.Unmarshal(&flapSignonFrameIn, bytes.NewBuffer(b)); err != nil {
+		return oscar.FLAPSignonFrame{}, err
+	}
+
+	*sequence++
+
+	return flapSignonFrameIn, nil
+}
+
+func verifyChatLogin(rw io.ReadWriter) (*ChatCookie, uint32, error) {
+	seq := uint32(100)
+
+	flap, err := SendAndReceiveSignonFrame(rw, &seq)
+	if err != nil {
+		return nil, 0, err
+	}
+
+	var ok bool
+	buf, ok := flap.GetSlice(oscar.OServiceTLVTagsLoginCookie)
+	if !ok {
+		return nil, 0, errors.New("unable to get session id from payload")
+	}
+
+	cookie := ChatCookie{}
+	err = oscar.Unmarshal(&cookie, bytes.NewBuffer(buf))
+
+	return &cookie, seq, err
+}
+
 func (rt AuthService) handleAuthConnection(rwc io.ReadWriteCloser) {
 	defer rwc.Close()
 	seq := uint32(100)
-	_, err := rt.SendAndReceiveSignonFrame(rwc, &seq)
+	_, err := SendAndReceiveSignonFrame(rwc, &seq)
 	if err != nil {
 		rt.Logger.Error(err.Error())
 		return
@@ -61,7 +116,7 @@ func (rt AuthService) handleAuthConnection(rwc io.ReadWriteCloser) {
 		return
 	}
 
-	msg, err := rt.ReceiveAndSendAuthChallenge(bodyIn, uuid.New)
+	msg, err := rt.BUCPChallengeRequestHandler(bodyIn, uuid.New)
 	if err != nil {
 		rt.Logger.Error(err.Error())
 		return
@@ -94,7 +149,7 @@ func (rt AuthService) handleAuthConnection(rwc io.ReadWriteCloser) {
 		return
 	}
 
-	msg, err = rt.ReceiveAndSendBUCPLoginRequest(bodyIn2, uuid.New)
+	msg, err = rt.BUCPLoginRequestHandler(bodyIn2, uuid.New, state.NewStubUser)
 	if err != nil {
 		rt.Logger.Error(err.Error())
 		return

+ 21 - 2
server/bos_service.go

@@ -49,9 +49,28 @@ func (rt BOSService) Start() {
 }
 
 func (rt BOSService) handleNewConnection(ctx context.Context, rwc io.ReadWriteCloser) {
-	sess, seq, err := rt.VerifyLogin(rwc)
+	seq := uint32(100)
+
+	flap, err := SendAndReceiveSignonFrame(rwc, &seq)
+	if err != nil {
+		rt.Logger.ErrorContext(ctx, "some error", "err", err.Error())
+		return
+	}
+
+	var ok bool
+	sessionID, ok := flap.GetSlice(oscar.OServiceTLVTagsLoginCookie)
+	if !ok {
+		rt.Logger.ErrorContext(ctx, "unable to get session id from payload")
+		return
+	}
+
+	sess, err := rt.RetrieveBOSSession(string(sessionID))
 	if err != nil {
-		rt.Logger.ErrorContext(ctx, "user disconnected with error", "err", err.Error())
+		rt.Logger.ErrorContext(ctx, "unable retrieve session", "err", err.Error())
+		return
+	}
+	if sess == nil {
+		rt.Logger.InfoContext(ctx, "session not found", "err", err.Error())
 		return
 	}
 

+ 5 - 1
server/buddy_mock_test.go

@@ -1,4 +1,4 @@
-// Code generated by mockery v2.35.2. DO NOT EDIT.
+// Code generated by mockery v2.38.0. DO NOT EDIT.
 
 package server
 
@@ -26,6 +26,10 @@ func (_m *mockBuddyHandler) EXPECT() *mockBuddyHandler_Expecter {
 func (_m *mockBuddyHandler) RightsQueryHandler(ctx context.Context, inFrame oscar.SNACFrame) oscar.SNACMessage {
 	ret := _m.Called(ctx, inFrame)
 
+	if len(ret) == 0 {
+		panic("no return value specified for RightsQueryHandler")
+	}
+
 	var r0 oscar.SNACMessage
 	if rf, ok := ret.Get(0).(func(context.Context, oscar.SNACFrame) oscar.SNACMessage); ok {
 		r0 = rf(ctx, inFrame)

+ 5 - 1
server/chat_mock_test.go

@@ -1,4 +1,4 @@
-// Code generated by mockery v2.35.2. DO NOT EDIT.
+// Code generated by mockery v2.38.0. DO NOT EDIT.
 
 package server
 
@@ -28,6 +28,10 @@ func (_m *mockChatHandler) EXPECT() *mockChatHandler_Expecter {
 func (_m *mockChatHandler) ChannelMsgToHostHandler(ctx context.Context, sess *state.Session, chatID string, inFrame oscar.SNACFrame, inBody oscar.SNAC_0x0E_0x05_ChatChannelMsgToHost) (*oscar.SNACMessage, error) {
 	ret := _m.Called(ctx, sess, chatID, inFrame, inBody)
 
+	if len(ret) == 0 {
+		panic("no return value specified for ChannelMsgToHostHandler")
+	}
+
 	var r0 *oscar.SNACMessage
 	var r1 error
 	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, string, oscar.SNACFrame, oscar.SNAC_0x0E_0x05_ChatChannelMsgToHost) (*oscar.SNACMessage, error)); ok {

+ 13 - 1
server/chat_nav_mock_test.go

@@ -1,4 +1,4 @@
-// Code generated by mockery v2.35.2. DO NOT EDIT.
+// Code generated by mockery v2.38.0. DO NOT EDIT.
 
 package server
 
@@ -28,6 +28,10 @@ func (_m *mockChatNavHandler) EXPECT() *mockChatNavHandler_Expecter {
 func (_m *mockChatNavHandler) CreateRoomHandler(ctx context.Context, sess *state.Session, inFrame oscar.SNACFrame, inBody oscar.SNAC_0x0E_0x02_ChatRoomInfoUpdate) (oscar.SNACMessage, error) {
 	ret := _m.Called(ctx, sess, inFrame, inBody)
 
+	if len(ret) == 0 {
+		panic("no return value specified for CreateRoomHandler")
+	}
+
 	var r0 oscar.SNACMessage
 	var r1 error
 	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, oscar.SNACFrame, oscar.SNAC_0x0E_0x02_ChatRoomInfoUpdate) (oscar.SNACMessage, error)); ok {
@@ -83,6 +87,10 @@ func (_c *mockChatNavHandler_CreateRoomHandler_Call) RunAndReturn(run func(conte
 func (_m *mockChatNavHandler) RequestChatRightsHandler(ctx context.Context, inFrame oscar.SNACFrame) oscar.SNACMessage {
 	ret := _m.Called(ctx, inFrame)
 
+	if len(ret) == 0 {
+		panic("no return value specified for RequestChatRightsHandler")
+	}
+
 	var r0 oscar.SNACMessage
 	if rf, ok := ret.Get(0).(func(context.Context, oscar.SNACFrame) oscar.SNACMessage); ok {
 		r0 = rf(ctx, inFrame)
@@ -126,6 +134,10 @@ func (_c *mockChatNavHandler_RequestChatRightsHandler_Call) RunAndReturn(run fun
 func (_m *mockChatNavHandler) RequestRoomInfoHandler(ctx context.Context, inFrame oscar.SNACFrame, inBody oscar.SNAC_0x0D_0x04_ChatNavRequestRoomInfo) (oscar.SNACMessage, error) {
 	ret := _m.Called(ctx, inFrame, inBody)
 
+	if len(ret) == 0 {
+		panic("no return value specified for RequestRoomInfoHandler")
+	}
+
 	var r0 oscar.SNACMessage
 	var r1 error
 	if rf, ok := ret.Get(0).(func(context.Context, oscar.SNACFrame, oscar.SNAC_0x0D_0x04_ChatNavRequestRoomInfo) (oscar.SNACMessage, error)); ok {

+ 10 - 4
server/chat_service.go

@@ -47,7 +47,7 @@ func (rt ChatService) Start() {
 }
 
 func (rt ChatService) handleNewConnection(ctx context.Context, rw io.ReadWriter) {
-	cookie, seq, err := rt.VerifyChatLogin(rw)
+	cookie, seq, err := verifyChatLogin(rw)
 	if err != nil {
 		rt.Logger.ErrorContext(ctx, "user disconnected with error", "err", err.Error())
 		return
@@ -55,16 +55,22 @@ func (rt ChatService) handleNewConnection(ctx context.Context, rw io.ReadWriter)
 
 	chatID := string(cookie.Cookie)
 
-	chatSess, err := rt.RetrieveChatSession(ctx, chatID, cookie.SessID)
+	chatSess, err := rt.RetrieveChatSession(chatID, cookie.SessID)
 	if err != nil {
-		rt.Logger.ErrorContext(ctx, "unable to find chat room", "err", err.Error())
+		rt.Logger.ErrorContext(ctx, "unable retrieve session", "err", err.Error())
+		return
+	}
+	if chatSess == nil {
+		rt.Logger.InfoContext(ctx, "chat room not found", "err", err.Error())
 		return
 	}
 
 	defer chatSess.Close()
 	go func() {
 		<-chatSess.Closed()
-		rt.SignoutChat(ctx, chatSess, chatID)
+		if err := rt.SignoutChat(ctx, chatSess, chatID); err != nil {
+			rt.Logger.ErrorContext(ctx, "unable to sign out user", "err", err.Error())
+		}
 	}()
 
 	ctx = context.WithValue(ctx, "screenName", chatSess.ScreenName())

+ 25 - 1
server/feedbag_mock_test.go

@@ -1,4 +1,4 @@
-// Code generated by mockery v2.35.2. DO NOT EDIT.
+// Code generated by mockery v2.38.0. DO NOT EDIT.
 
 package server
 
@@ -28,6 +28,10 @@ func (_m *mockFeedbagHandler) EXPECT() *mockFeedbagHandler_Expecter {
 func (_m *mockFeedbagHandler) DeleteItemHandler(ctx context.Context, sess *state.Session, inFrame oscar.SNACFrame, inBody oscar.SNAC_0x13_0x0A_FeedbagDeleteItem) (oscar.SNACMessage, error) {
 	ret := _m.Called(ctx, sess, inFrame, inBody)
 
+	if len(ret) == 0 {
+		panic("no return value specified for DeleteItemHandler")
+	}
+
 	var r0 oscar.SNACMessage
 	var r1 error
 	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, oscar.SNACFrame, oscar.SNAC_0x13_0x0A_FeedbagDeleteItem) (oscar.SNACMessage, error)); ok {
@@ -83,6 +87,10 @@ func (_c *mockFeedbagHandler_DeleteItemHandler_Call) RunAndReturn(run func(conte
 func (_m *mockFeedbagHandler) InsertItemHandler(ctx context.Context, sess *state.Session, inFrame oscar.SNACFrame, inBody oscar.SNAC_0x13_0x08_FeedbagInsertItem) (oscar.SNACMessage, error) {
 	ret := _m.Called(ctx, sess, inFrame, inBody)
 
+	if len(ret) == 0 {
+		panic("no return value specified for InsertItemHandler")
+	}
+
 	var r0 oscar.SNACMessage
 	var r1 error
 	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, oscar.SNACFrame, oscar.SNAC_0x13_0x08_FeedbagInsertItem) (oscar.SNACMessage, error)); ok {
@@ -138,6 +146,10 @@ func (_c *mockFeedbagHandler_InsertItemHandler_Call) RunAndReturn(run func(conte
 func (_m *mockFeedbagHandler) QueryHandler(ctx context.Context, sess *state.Session, inFrame oscar.SNACFrame) (oscar.SNACMessage, error) {
 	ret := _m.Called(ctx, sess, inFrame)
 
+	if len(ret) == 0 {
+		panic("no return value specified for QueryHandler")
+	}
+
 	var r0 oscar.SNACMessage
 	var r1 error
 	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, oscar.SNACFrame) (oscar.SNACMessage, error)); ok {
@@ -192,6 +204,10 @@ func (_c *mockFeedbagHandler_QueryHandler_Call) RunAndReturn(run func(context.Co
 func (_m *mockFeedbagHandler) QueryIfModifiedHandler(ctx context.Context, sess *state.Session, inFrame oscar.SNACFrame, inBody oscar.SNAC_0x13_0x05_FeedbagQueryIfModified) (oscar.SNACMessage, error) {
 	ret := _m.Called(ctx, sess, inFrame, inBody)
 
+	if len(ret) == 0 {
+		panic("no return value specified for QueryIfModifiedHandler")
+	}
+
 	var r0 oscar.SNACMessage
 	var r1 error
 	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, oscar.SNACFrame, oscar.SNAC_0x13_0x05_FeedbagQueryIfModified) (oscar.SNACMessage, error)); ok {
@@ -247,6 +263,10 @@ func (_c *mockFeedbagHandler_QueryIfModifiedHandler_Call) RunAndReturn(run func(
 func (_m *mockFeedbagHandler) RightsQueryHandler(ctx context.Context, inFrame oscar.SNACFrame) oscar.SNACMessage {
 	ret := _m.Called(ctx, inFrame)
 
+	if len(ret) == 0 {
+		panic("no return value specified for RightsQueryHandler")
+	}
+
 	var r0 oscar.SNACMessage
 	if rf, ok := ret.Get(0).(func(context.Context, oscar.SNACFrame) oscar.SNACMessage); ok {
 		r0 = rf(ctx, inFrame)
@@ -325,6 +345,10 @@ func (_c *mockFeedbagHandler_StartClusterHandler_Call) RunAndReturn(run func(con
 func (_m *mockFeedbagHandler) UpdateItemHandler(ctx context.Context, sess *state.Session, inFrame oscar.SNACFrame, inBody oscar.SNAC_0x13_0x09_FeedbagUpdateItem) (oscar.SNACMessage, error) {
 	ret := _m.Called(ctx, sess, inFrame, inBody)
 
+	if len(ret) == 0 {
+		panic("no return value specified for UpdateItemHandler")
+	}
+
 	var r0 oscar.SNACMessage
 	var r1 error
 	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, oscar.SNACFrame, oscar.SNAC_0x13_0x09_FeedbagUpdateItem) (oscar.SNACMessage, error)); ok {

+ 17 - 1
server/icbm_mock_test.go

@@ -1,4 +1,4 @@
-// Code generated by mockery v2.35.2. DO NOT EDIT.
+// Code generated by mockery v2.38.0. DO NOT EDIT.
 
 package server
 
@@ -28,6 +28,10 @@ func (_m *mockICBMHandler) EXPECT() *mockICBMHandler_Expecter {
 func (_m *mockICBMHandler) ChannelMsgToHostHandler(ctx context.Context, sess *state.Session, inFrame oscar.SNACFrame, inBody oscar.SNAC_0x04_0x06_ICBMChannelMsgToHost) (*oscar.SNACMessage, error) {
 	ret := _m.Called(ctx, sess, inFrame, inBody)
 
+	if len(ret) == 0 {
+		panic("no return value specified for ChannelMsgToHostHandler")
+	}
+
 	var r0 *oscar.SNACMessage
 	var r1 error
 	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, oscar.SNACFrame, oscar.SNAC_0x04_0x06_ICBMChannelMsgToHost) (*oscar.SNACMessage, error)); ok {
@@ -85,6 +89,10 @@ func (_c *mockICBMHandler_ChannelMsgToHostHandler_Call) RunAndReturn(run func(co
 func (_m *mockICBMHandler) ClientEventHandler(ctx context.Context, sess *state.Session, inFrame oscar.SNACFrame, inBody oscar.SNAC_0x04_0x14_ICBMClientEvent) error {
 	ret := _m.Called(ctx, sess, inFrame, inBody)
 
+	if len(ret) == 0 {
+		panic("no return value specified for ClientEventHandler")
+	}
+
 	var r0 error
 	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, oscar.SNACFrame, oscar.SNAC_0x04_0x14_ICBMClientEvent) error); ok {
 		r0 = rf(ctx, sess, inFrame, inBody)
@@ -130,6 +138,10 @@ func (_c *mockICBMHandler_ClientEventHandler_Call) RunAndReturn(run func(context
 func (_m *mockICBMHandler) EvilRequestHandler(ctx context.Context, sess *state.Session, inFrame oscar.SNACFrame, inBody oscar.SNAC_0x04_0x08_ICBMEvilRequest) (oscar.SNACMessage, error) {
 	ret := _m.Called(ctx, sess, inFrame, inBody)
 
+	if len(ret) == 0 {
+		panic("no return value specified for EvilRequestHandler")
+	}
+
 	var r0 oscar.SNACMessage
 	var r1 error
 	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, oscar.SNACFrame, oscar.SNAC_0x04_0x08_ICBMEvilRequest) (oscar.SNACMessage, error)); ok {
@@ -185,6 +197,10 @@ func (_c *mockICBMHandler_EvilRequestHandler_Call) RunAndReturn(run func(context
 func (_m *mockICBMHandler) ParameterQueryHandler(ctx context.Context, inFrame oscar.SNACFrame) oscar.SNACMessage {
 	ret := _m.Called(ctx, inFrame)
 
+	if len(ret) == 0 {
+		panic("no return value specified for ParameterQueryHandler")
+	}
+
 	var r0 oscar.SNACMessage
 	if rf, ok := ret.Get(0).(func(context.Context, oscar.SNACFrame) oscar.SNACMessage); ok {
 		r0 = rf(ctx, inFrame)

+ 21 - 1
server/locate_mock_test.go

@@ -1,4 +1,4 @@
-// Code generated by mockery v2.35.2. DO NOT EDIT.
+// Code generated by mockery v2.38.0. DO NOT EDIT.
 
 package server
 
@@ -28,6 +28,10 @@ func (_m *mockLocateHandler) EXPECT() *mockLocateHandler_Expecter {
 func (_m *mockLocateHandler) RightsQueryHandler(ctx context.Context, inFrame oscar.SNACFrame) oscar.SNACMessage {
 	ret := _m.Called(ctx, inFrame)
 
+	if len(ret) == 0 {
+		panic("no return value specified for RightsQueryHandler")
+	}
+
 	var r0 oscar.SNACMessage
 	if rf, ok := ret.Get(0).(func(context.Context, oscar.SNACFrame) oscar.SNACMessage); ok {
 		r0 = rf(ctx, inFrame)
@@ -71,6 +75,10 @@ func (_c *mockLocateHandler_RightsQueryHandler_Call) RunAndReturn(run func(conte
 func (_m *mockLocateHandler) SetDirInfoHandler(ctx context.Context, frame oscar.SNACFrame) oscar.SNACMessage {
 	ret := _m.Called(ctx, frame)
 
+	if len(ret) == 0 {
+		panic("no return value specified for SetDirInfoHandler")
+	}
+
 	var r0 oscar.SNACMessage
 	if rf, ok := ret.Get(0).(func(context.Context, oscar.SNACFrame) oscar.SNACMessage); ok {
 		r0 = rf(ctx, frame)
@@ -114,6 +122,10 @@ func (_c *mockLocateHandler_SetDirInfoHandler_Call) RunAndReturn(run func(contex
 func (_m *mockLocateHandler) SetInfoHandler(ctx context.Context, sess *state.Session, inBody oscar.SNAC_0x02_0x04_LocateSetInfo) error {
 	ret := _m.Called(ctx, sess, inBody)
 
+	if len(ret) == 0 {
+		panic("no return value specified for SetInfoHandler")
+	}
+
 	var r0 error
 	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, oscar.SNAC_0x02_0x04_LocateSetInfo) error); ok {
 		r0 = rf(ctx, sess, inBody)
@@ -158,6 +170,10 @@ func (_c *mockLocateHandler_SetInfoHandler_Call) RunAndReturn(run func(context.C
 func (_m *mockLocateHandler) SetKeywordInfoHandler(ctx context.Context, inFrame oscar.SNACFrame) oscar.SNACMessage {
 	ret := _m.Called(ctx, inFrame)
 
+	if len(ret) == 0 {
+		panic("no return value specified for SetKeywordInfoHandler")
+	}
+
 	var r0 oscar.SNACMessage
 	if rf, ok := ret.Get(0).(func(context.Context, oscar.SNACFrame) oscar.SNACMessage); ok {
 		r0 = rf(ctx, inFrame)
@@ -201,6 +217,10 @@ func (_c *mockLocateHandler_SetKeywordInfoHandler_Call) RunAndReturn(run func(co
 func (_m *mockLocateHandler) UserInfoQuery2Handler(ctx context.Context, sess *state.Session, inFrame oscar.SNACFrame, inBody oscar.SNAC_0x02_0x15_LocateUserInfoQuery2) (oscar.SNACMessage, error) {
 	ret := _m.Called(ctx, sess, inFrame, inBody)
 
+	if len(ret) == 0 {
+		panic("no return value specified for UserInfoQuery2Handler")
+	}
+
 	var r0 oscar.SNACMessage
 	var r1 error
 	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, oscar.SNACFrame, oscar.SNAC_0x02_0x15_LocateUserInfoQuery2) (oscar.SNACMessage, error)); ok {

+ 33 - 1
server/oservice_bos_mock_test.go

@@ -1,4 +1,4 @@
-// Code generated by mockery v2.35.2. DO NOT EDIT.
+// Code generated by mockery v2.38.0. DO NOT EDIT.
 
 package server
 
@@ -28,6 +28,10 @@ func (_m *mockOServiceBOSHandler) EXPECT() *mockOServiceBOSHandler_Expecter {
 func (_m *mockOServiceBOSHandler) ClientOnlineHandler(ctx context.Context, bodyIn oscar.SNAC_0x01_0x02_OServiceClientOnline, sess *state.Session) error {
 	ret := _m.Called(ctx, bodyIn, sess)
 
+	if len(ret) == 0 {
+		panic("no return value specified for ClientOnlineHandler")
+	}
+
 	var r0 error
 	if rf, ok := ret.Get(0).(func(context.Context, oscar.SNAC_0x01_0x02_OServiceClientOnline, *state.Session) error); ok {
 		r0 = rf(ctx, bodyIn, sess)
@@ -72,6 +76,10 @@ func (_c *mockOServiceBOSHandler_ClientOnlineHandler_Call) RunAndReturn(run func
 func (_m *mockOServiceBOSHandler) ClientVersionsHandler(ctx context.Context, frame oscar.SNACFrame, bodyIn oscar.SNAC_0x01_0x17_OServiceClientVersions) oscar.SNACMessage {
 	ret := _m.Called(ctx, frame, bodyIn)
 
+	if len(ret) == 0 {
+		panic("no return value specified for ClientVersionsHandler")
+	}
+
 	var r0 oscar.SNACMessage
 	if rf, ok := ret.Get(0).(func(context.Context, oscar.SNACFrame, oscar.SNAC_0x01_0x17_OServiceClientVersions) oscar.SNACMessage); ok {
 		r0 = rf(ctx, frame, bodyIn)
@@ -116,6 +124,10 @@ func (_c *mockOServiceBOSHandler_ClientVersionsHandler_Call) RunAndReturn(run fu
 func (_m *mockOServiceBOSHandler) IdleNotificationHandler(ctx context.Context, sess *state.Session, bodyIn oscar.SNAC_0x01_0x11_OServiceIdleNotification) error {
 	ret := _m.Called(ctx, sess, bodyIn)
 
+	if len(ret) == 0 {
+		panic("no return value specified for IdleNotificationHandler")
+	}
+
 	var r0 error
 	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, oscar.SNAC_0x01_0x11_OServiceIdleNotification) error); ok {
 		r0 = rf(ctx, sess, bodyIn)
@@ -160,6 +172,10 @@ func (_c *mockOServiceBOSHandler_IdleNotificationHandler_Call) RunAndReturn(run
 func (_m *mockOServiceBOSHandler) RateParamsQueryHandler(ctx context.Context, frame oscar.SNACFrame) oscar.SNACMessage {
 	ret := _m.Called(ctx, frame)
 
+	if len(ret) == 0 {
+		panic("no return value specified for RateParamsQueryHandler")
+	}
+
 	var r0 oscar.SNACMessage
 	if rf, ok := ret.Get(0).(func(context.Context, oscar.SNACFrame) oscar.SNACMessage); ok {
 		r0 = rf(ctx, frame)
@@ -237,6 +253,10 @@ func (_c *mockOServiceBOSHandler_RateParamsSubAddHandler_Call) RunAndReturn(run
 func (_m *mockOServiceBOSHandler) ServiceRequestHandler(ctx context.Context, sess *state.Session, frame oscar.SNACFrame, bodyIn oscar.SNAC_0x01_0x04_OServiceServiceRequest) (oscar.SNACMessage, error) {
 	ret := _m.Called(ctx, sess, frame, bodyIn)
 
+	if len(ret) == 0 {
+		panic("no return value specified for ServiceRequestHandler")
+	}
+
 	var r0 oscar.SNACMessage
 	var r1 error
 	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, oscar.SNACFrame, oscar.SNAC_0x01_0x04_OServiceServiceRequest) (oscar.SNACMessage, error)); ok {
@@ -292,6 +312,10 @@ func (_c *mockOServiceBOSHandler_ServiceRequestHandler_Call) RunAndReturn(run fu
 func (_m *mockOServiceBOSHandler) SetUserInfoFieldsHandler(ctx context.Context, sess *state.Session, frame oscar.SNACFrame, bodyIn oscar.SNAC_0x01_0x1E_OServiceSetUserInfoFields) (oscar.SNACMessage, error) {
 	ret := _m.Called(ctx, sess, frame, bodyIn)
 
+	if len(ret) == 0 {
+		panic("no return value specified for SetUserInfoFieldsHandler")
+	}
+
 	var r0 oscar.SNACMessage
 	var r1 error
 	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, oscar.SNACFrame, oscar.SNAC_0x01_0x1E_OServiceSetUserInfoFields) (oscar.SNACMessage, error)); ok {
@@ -347,6 +371,10 @@ func (_c *mockOServiceBOSHandler_SetUserInfoFieldsHandler_Call) RunAndReturn(run
 func (_m *mockOServiceBOSHandler) UserInfoQueryHandler(ctx context.Context, sess *state.Session, frame oscar.SNACFrame) oscar.SNACMessage {
 	ret := _m.Called(ctx, sess, frame)
 
+	if len(ret) == 0 {
+		panic("no return value specified for UserInfoQueryHandler")
+	}
+
 	var r0 oscar.SNACMessage
 	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, oscar.SNACFrame) oscar.SNACMessage); ok {
 		r0 = rf(ctx, sess, frame)
@@ -391,6 +419,10 @@ func (_c *mockOServiceBOSHandler_UserInfoQueryHandler_Call) RunAndReturn(run fun
 func (_m *mockOServiceBOSHandler) WriteOServiceHostOnline() oscar.SNACMessage {
 	ret := _m.Called()
 
+	if len(ret) == 0 {
+		panic("no return value specified for WriteOServiceHostOnline")
+	}
+
 	var r0 oscar.SNACMessage
 	if rf, ok := ret.Get(0).(func() oscar.SNACMessage); ok {
 		r0 = rf()

+ 29 - 1
server/oservice_chat_mock_test.go

@@ -1,4 +1,4 @@
-// Code generated by mockery v2.35.2. DO NOT EDIT.
+// Code generated by mockery v2.38.0. DO NOT EDIT.
 
 package server
 
@@ -28,6 +28,10 @@ func (_m *mockOServiceChatHandler) EXPECT() *mockOServiceChatHandler_Expecter {
 func (_m *mockOServiceChatHandler) ClientOnlineHandler(ctx context.Context, sess *state.Session, chatID string) error {
 	ret := _m.Called(ctx, sess, chatID)
 
+	if len(ret) == 0 {
+		panic("no return value specified for ClientOnlineHandler")
+	}
+
 	var r0 error
 	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, string) error); ok {
 		r0 = rf(ctx, sess, chatID)
@@ -72,6 +76,10 @@ func (_c *mockOServiceChatHandler_ClientOnlineHandler_Call) RunAndReturn(run fun
 func (_m *mockOServiceChatHandler) ClientVersionsHandler(ctx context.Context, frame oscar.SNACFrame, bodyIn oscar.SNAC_0x01_0x17_OServiceClientVersions) oscar.SNACMessage {
 	ret := _m.Called(ctx, frame, bodyIn)
 
+	if len(ret) == 0 {
+		panic("no return value specified for ClientVersionsHandler")
+	}
+
 	var r0 oscar.SNACMessage
 	if rf, ok := ret.Get(0).(func(context.Context, oscar.SNACFrame, oscar.SNAC_0x01_0x17_OServiceClientVersions) oscar.SNACMessage); ok {
 		r0 = rf(ctx, frame, bodyIn)
@@ -116,6 +124,10 @@ func (_c *mockOServiceChatHandler_ClientVersionsHandler_Call) RunAndReturn(run f
 func (_m *mockOServiceChatHandler) IdleNotificationHandler(ctx context.Context, sess *state.Session, bodyIn oscar.SNAC_0x01_0x11_OServiceIdleNotification) error {
 	ret := _m.Called(ctx, sess, bodyIn)
 
+	if len(ret) == 0 {
+		panic("no return value specified for IdleNotificationHandler")
+	}
+
 	var r0 error
 	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, oscar.SNAC_0x01_0x11_OServiceIdleNotification) error); ok {
 		r0 = rf(ctx, sess, bodyIn)
@@ -160,6 +172,10 @@ func (_c *mockOServiceChatHandler_IdleNotificationHandler_Call) RunAndReturn(run
 func (_m *mockOServiceChatHandler) RateParamsQueryHandler(ctx context.Context, frame oscar.SNACFrame) oscar.SNACMessage {
 	ret := _m.Called(ctx, frame)
 
+	if len(ret) == 0 {
+		panic("no return value specified for RateParamsQueryHandler")
+	}
+
 	var r0 oscar.SNACMessage
 	if rf, ok := ret.Get(0).(func(context.Context, oscar.SNACFrame) oscar.SNACMessage); ok {
 		r0 = rf(ctx, frame)
@@ -237,6 +253,10 @@ func (_c *mockOServiceChatHandler_RateParamsSubAddHandler_Call) RunAndReturn(run
 func (_m *mockOServiceChatHandler) SetUserInfoFieldsHandler(ctx context.Context, sess *state.Session, frame oscar.SNACFrame, bodyIn oscar.SNAC_0x01_0x1E_OServiceSetUserInfoFields) (oscar.SNACMessage, error) {
 	ret := _m.Called(ctx, sess, frame, bodyIn)
 
+	if len(ret) == 0 {
+		panic("no return value specified for SetUserInfoFieldsHandler")
+	}
+
 	var r0 oscar.SNACMessage
 	var r1 error
 	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, oscar.SNACFrame, oscar.SNAC_0x01_0x1E_OServiceSetUserInfoFields) (oscar.SNACMessage, error)); ok {
@@ -292,6 +312,10 @@ func (_c *mockOServiceChatHandler_SetUserInfoFieldsHandler_Call) RunAndReturn(ru
 func (_m *mockOServiceChatHandler) UserInfoQueryHandler(ctx context.Context, sess *state.Session, frame oscar.SNACFrame) oscar.SNACMessage {
 	ret := _m.Called(ctx, sess, frame)
 
+	if len(ret) == 0 {
+		panic("no return value specified for UserInfoQueryHandler")
+	}
+
 	var r0 oscar.SNACMessage
 	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, oscar.SNACFrame) oscar.SNACMessage); ok {
 		r0 = rf(ctx, sess, frame)
@@ -336,6 +360,10 @@ func (_c *mockOServiceChatHandler_UserInfoQueryHandler_Call) RunAndReturn(run fu
 func (_m *mockOServiceChatHandler) WriteOServiceHostOnline() oscar.SNACMessage {
 	ret := _m.Called()
 
+	if len(ret) == 0 {
+		panic("no return value specified for WriteOServiceHostOnline")
+	}
+
 	var r0 oscar.SNACMessage
 	if rf, ok := ret.Get(0).(func() oscar.SNACMessage); ok {
 		r0 = rf()

+ 21 - 1
server/oservice_mock_test.go

@@ -1,4 +1,4 @@
-// Code generated by mockery v2.35.2. DO NOT EDIT.
+// Code generated by mockery v2.38.0. DO NOT EDIT.
 
 package server
 
@@ -28,6 +28,10 @@ func (_m *mockOServiceHandler) EXPECT() *mockOServiceHandler_Expecter {
 func (_m *mockOServiceHandler) ClientVersionsHandler(ctx context.Context, frame oscar.SNACFrame, bodyIn oscar.SNAC_0x01_0x17_OServiceClientVersions) oscar.SNACMessage {
 	ret := _m.Called(ctx, frame, bodyIn)
 
+	if len(ret) == 0 {
+		panic("no return value specified for ClientVersionsHandler")
+	}
+
 	var r0 oscar.SNACMessage
 	if rf, ok := ret.Get(0).(func(context.Context, oscar.SNACFrame, oscar.SNAC_0x01_0x17_OServiceClientVersions) oscar.SNACMessage); ok {
 		r0 = rf(ctx, frame, bodyIn)
@@ -72,6 +76,10 @@ func (_c *mockOServiceHandler_ClientVersionsHandler_Call) RunAndReturn(run func(
 func (_m *mockOServiceHandler) IdleNotificationHandler(ctx context.Context, sess *state.Session, bodyIn oscar.SNAC_0x01_0x11_OServiceIdleNotification) error {
 	ret := _m.Called(ctx, sess, bodyIn)
 
+	if len(ret) == 0 {
+		panic("no return value specified for IdleNotificationHandler")
+	}
+
 	var r0 error
 	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, oscar.SNAC_0x01_0x11_OServiceIdleNotification) error); ok {
 		r0 = rf(ctx, sess, bodyIn)
@@ -116,6 +124,10 @@ func (_c *mockOServiceHandler_IdleNotificationHandler_Call) RunAndReturn(run fun
 func (_m *mockOServiceHandler) RateParamsQueryHandler(ctx context.Context, frame oscar.SNACFrame) oscar.SNACMessage {
 	ret := _m.Called(ctx, frame)
 
+	if len(ret) == 0 {
+		panic("no return value specified for RateParamsQueryHandler")
+	}
+
 	var r0 oscar.SNACMessage
 	if rf, ok := ret.Get(0).(func(context.Context, oscar.SNACFrame) oscar.SNACMessage); ok {
 		r0 = rf(ctx, frame)
@@ -193,6 +205,10 @@ func (_c *mockOServiceHandler_RateParamsSubAddHandler_Call) RunAndReturn(run fun
 func (_m *mockOServiceHandler) SetUserInfoFieldsHandler(ctx context.Context, sess *state.Session, frame oscar.SNACFrame, bodyIn oscar.SNAC_0x01_0x1E_OServiceSetUserInfoFields) (oscar.SNACMessage, error) {
 	ret := _m.Called(ctx, sess, frame, bodyIn)
 
+	if len(ret) == 0 {
+		panic("no return value specified for SetUserInfoFieldsHandler")
+	}
+
 	var r0 oscar.SNACMessage
 	var r1 error
 	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, oscar.SNACFrame, oscar.SNAC_0x01_0x1E_OServiceSetUserInfoFields) (oscar.SNACMessage, error)); ok {
@@ -248,6 +264,10 @@ func (_c *mockOServiceHandler_SetUserInfoFieldsHandler_Call) RunAndReturn(run fu
 func (_m *mockOServiceHandler) UserInfoQueryHandler(ctx context.Context, sess *state.Session, frame oscar.SNACFrame) oscar.SNACMessage {
 	ret := _m.Called(ctx, sess, frame)
 
+	if len(ret) == 0 {
+		panic("no return value specified for UserInfoQueryHandler")
+	}
+
 	var r0 oscar.SNACMessage
 	if rf, ok := ret.Get(0).(func(context.Context, *state.Session, oscar.SNACFrame) oscar.SNACMessage); ok {
 		r0 = rf(ctx, sess, frame)

+ 10 - 7
state/chat_registry.go

@@ -2,6 +2,7 @@ package state
 
 import (
 	"errors"
+	"fmt"
 	"sync"
 	"time"
 
@@ -29,25 +30,27 @@ func (c *ChatRegistry) Register(room ChatRoom, sessionManager any) {
 	c.smStore[room.Cookie] = sessionManager
 }
 
-func (c *ChatRegistry) Retrieve(chatID string) (ChatRoom, any, error) {
+var ErrChatRoomNotFound = errors.New("chat room not found")
+
+func (c *ChatRegistry) Retrieve(cookie string) (ChatRoom, any, error) {
 	c.mapMutex.RLock()
 	defer c.mapMutex.RUnlock()
-	chatRoom, found := c.chatRoomStore[chatID]
+	chatRoom, found := c.chatRoomStore[cookie]
 	if !found {
-		return ChatRoom{}, nil, errors.New("unable to find chat room")
+		return ChatRoom{}, nil, fmt.Errorf("%w cookie: %s", ErrChatRoomNotFound, cookie)
 	}
-	sessionManager, found := c.smStore[chatID]
+	sessionManager, found := c.smStore[cookie]
 	if !found {
 		panic("unable to find session manager for chat")
 	}
 	return chatRoom, sessionManager, nil
 }
 
-func (c *ChatRegistry) RemoveRoom(chatID string) {
+func (c *ChatRegistry) RemoveRoom(cookie string) {
 	c.mapMutex.Lock()
 	defer c.mapMutex.Unlock()
-	delete(c.chatRoomStore, chatID)
-	delete(c.smStore, chatID)
+	delete(c.chatRoomStore, cookie)
+	delete(c.smStore, cookie)
 }
 
 type ChatRoom struct {

+ 16 - 0
state/feedbag_store.go

@@ -8,6 +8,7 @@ import (
 	"io"
 	"time"
 
+	"github.com/google/uuid"
 	_ "github.com/mattn/go-sqlite3"
 	"github.com/mkaminski/goaim/oscar"
 )
@@ -371,3 +372,18 @@ func (f *SQLiteFeedbagStore) UpsertProfile(screenName string, body string) error
 	_, err := f.db.Exec(q, screenName, body)
 	return err
 }
+
+func NewStubUser(screenName string) (User, error) {
+	u := User{ScreenName: screenName}
+
+	uid, err := uuid.NewRandom()
+	if err != nil {
+		return u, err
+	}
+	u.AuthKey = uid.String()
+
+	if err := u.HashPassword("welcome1"); err != nil {
+		return u, err
+	}
+	return u, u.HashPassword("welcome1")
+}

+ 2 - 3
state/session_manager.go

@@ -66,11 +66,10 @@ func (s *InMemorySessionManager) BroadcastExcept(ctx context.Context, except *Se
 	}
 }
 
-func (s *InMemorySessionManager) Retrieve(ID string) (*Session, bool) {
+func (s *InMemorySessionManager) Retrieve(ID string) *Session {
 	s.mapMutex.RLock()
 	defer s.mapMutex.RUnlock()
-	sess, found := s.store[ID]
-	return sess, found
+	return s.store[ID]
 }
 
 func (s *InMemorySessionManager) RetrieveByScreenName(screenName string) *Session {