package main import ( "net/http" "sync" "testing" "time" "github.com/golang/mock/gomock" "github.com/tinode/chat/server/auth" "github.com/tinode/chat/server/auth/mock_auth" "github.com/tinode/chat/server/store" "github.com/tinode/chat/server/store/mock_store" "github.com/tinode/chat/server/store/types" ) type testTenantStore struct { tenant *types.Tenant err error } func ptrInt(v int) *int { return &v } type testLoginDeviceStore struct { devices map[types.Uid][]types.DeviceDef updates int err error } func (s *testLoginDeviceStore) Update(tenantID types.TenantID, uid types.Uid, oldDeviceID string, dev *types.DeviceDef) error { s.updates++ if s.err != nil { return s.err } if dev != nil { dev.TenantID = tenantID s.devices[uid] = append(s.devices[uid], *dev) } return nil } func (s *testLoginDeviceStore) GetAll(_ types.TenantID, uids ...types.Uid) (map[types.Uid][]types.DeviceDef, int, error) { if s.err != nil { return nil, 0, s.err } result := make(map[types.Uid][]types.DeviceDef) count := 0 for _, uid := range uids { result[uid] = append([]types.DeviceDef(nil), s.devices[uid]...) count += len(result[uid]) } return result, count, nil } func (s *testLoginDeviceStore) Delete(_ types.TenantID, uid types.Uid, deviceID string) error { devices := s.devices[uid] for i, device := range devices { if device.DeviceId == deviceID { s.devices[uid] = append(devices[:i], devices[i+1:]...) return nil } } return types.ErrNotFound } func (s testTenantStore) GetByCode(string) (*types.Tenant, error) { return s.tenant, s.err } func (s testTenantStore) GetByID(types.TenantID) (*types.Tenant, error) { return s.tenant, s.err } func (s testTenantStore) ListActive(int) ([]types.Tenant, error) { if s.err != nil || s.tenant == nil { return nil, s.err } return []types.Tenant{*s.tenant}, nil } func test_makeSession(uid types.Uid) *Session { return &Session{ send: make(chan any, 10), uid: uid, authLvl: auth.LevelAuth, inflightReqs: newBoundedWaitGroup(1), ver: 22, tenantID: 1, tenantCode: "test", } } func TestLoginDeviceSecurityNoticeOnlyForNewDevice(t *testing.T) { uid := types.Uid(42) devices := &testLoginDeviceStore{devices: map[types.Uid][]types.DeviceDef{ uid: {{TenantID: 7, DeviceId: "known-device", Platform: "ios"}}, }} security := &testSecurityStore{} oldDevices := store.Devices oldSecurity := store.Security store.Devices = devices store.Security = security t.Cleanup(func() { store.Devices = oldDevices store.Security = oldSecurity }) known, err := isNewLoginDevice(7, uid, "known-device") if err != nil || known { t.Fatalf("known device detected as new: new=%v err=%v", known, err) } newDevice, err := isNewLoginDevice(7, uid, "new-device") if err != nil || !newDevice { t.Fatalf("new device was not detected: new=%v err=%v", newDevice, err) } createLoginDeviceSecurityNotice(7, uid, "new-device", "ios", "UnitTestUA", time.Date(2026, 9, 6, 12, 0, 0, 0, time.UTC)) if len(security.notifications) != 1 { t.Fatalf("expected one security notification, got %d", len(security.notifications)) } notification := security.notifications[0] if notification.EventType != "security.login_device" || notification.Title == "" || notification.Action["device_id"] != "new-device" { t.Fatalf("unexpected login device notification: %#v", notification) } } func TestDispatchHello(t *testing.T) { oldTenants := store.Tenants store.Tenants = testTenantStore{tenant: &types.Tenant{ ID: 7, Code: "acme", Name: "Acme Corp", State: types.TenantStateActive, }} defer func() { store.Tenants = oldTenants }() s := &Session{ send: make(chan any, 10), uid: types.Uid(1), authLvl: auth.LevelAuth, } wg := sync.WaitGroup{} r := responses{} wg.Add(1) go s.testWriteLoop(&r, &wg) msg := &ClientComMessage{ Hi: &MsgClientHi{ Id: "123", Version: "1", UserAgent: "test-ua", Lang: "en-GB", Tenant: "acme", }, } s.dispatch(msg) close(s.send) wg.Wait() if len(r.messages) != 1 { t.Errorf("responses: expected 1, received %d.", len(r.messages)) } resp := r.messages[0].(*ServerComMessage) if resp == nil { t.Fatal("Response must be ServerComMessage") } if resp.Ctrl != nil { if resp.Ctrl.Code != 201 { t.Errorf("Response code: expected 201, got %d", resp.Ctrl.Code) } if resp.Ctrl.Params == nil { t.Error("Response is expected to contain params dict.") } } else { t.Error("Response must contain a ctrl message.") } if s.lang != "en-GB" { t.Errorf("Session language expected to be 'en-GB' vs '%s'", s.lang) } if s.userAgent != "test-ua" { t.Errorf("Session UA expected to be 'test-ua' vs '%s'", s.userAgent) } if s.countryCode != "GB" { t.Errorf("Country code expected to be 'GB' vs '%s'", s.countryCode) } if s.ver == 0 { t.Errorf("s.ver expected 0 vs found %d", s.ver) } if s.tenantID != 7 || s.tenantCode != "acme" { t.Errorf("unexpected tenant binding: id=%d code=%q", s.tenantID, s.tenantCode) } tenantParams := resp.Ctrl.Params.(map[string]any)["tenant"].(map[string]any) if tenantParams["code"] != "acme" || tenantParams["name"] != "Acme Corp" { t.Errorf("unexpected public tenant params: %#v", tenantParams) } } func TestDispatchHelloDefaultsTenant(t *testing.T) { oldTenants := store.Tenants store.Tenants = testTenantStore{tenant: &types.Tenant{ ID: 1, Code: "default", Name: "Default Tenant", State: types.TenantStateActive, }} defer func() { store.Tenants = oldTenants }() s := &Session{send: make(chan any, 1)} s.dispatch(&ClientComMessage{Hi: &MsgClientHi{Id: "123", Version: "1"}}) resp := (<-s.send).(*ServerComMessage) if resp.Ctrl.Code != http.StatusCreated { t.Fatalf("response code: expected %d, got %d", http.StatusCreated, resp.Ctrl.Code) } if s.tenantID != 1 || s.tenantCode != "default" || s.ver == 0 { t.Fatalf("default hello did not bind session: tenant=%d code=%q version=%d", s.tenantID, s.tenantCode, s.ver) } tenantParams := resp.Ctrl.Params.(map[string]any)["tenant"].(map[string]any) if tenantParams["code"] != "default" || tenantParams["name"] != "Default Tenant" { t.Fatalf("unexpected tenant params: %#v", tenantParams) } } func TestDispatchHelloRejectsInactiveTenant(t *testing.T) { oldTenants := store.Tenants store.Tenants = testTenantStore{tenant: &types.Tenant{ ID: 7, Code: "acme", Name: "Acme Corp", State: types.TenantStateSuspended, }} defer func() { store.Tenants = oldTenants }() s := &Session{send: make(chan any, 1)} s.dispatch(&ClientComMessage{Hi: &MsgClientHi{Id: "123", Version: "1", Tenant: "acme"}}) resp := (<-s.send).(*ServerComMessage) if resp.Ctrl.Code != http.StatusForbidden { t.Fatalf("response code: expected %d, got %d", http.StatusForbidden, resp.Ctrl.Code) } if !s.tenantID.IsZero() || s.ver != 0 { t.Fatalf("inactive tenant must not bind session: tenant=%d version=%d", s.tenantID, s.ver) } } func TestDispatchHelloRejectsTenantChange(t *testing.T) { s := &Session{ send: make(chan any, 1), uid: 1, ver: parseVersion("1"), tenantID: 7, tenantCode: "acme", } s.dispatch(&ClientComMessage{Hi: &MsgClientHi{Id: "123", Version: "1", Tenant: "other"}}) resp := (<-s.send).(*ServerComMessage) if resp.Ctrl.Code != http.StatusConflict { t.Fatalf("response code: expected %d, got %d", http.StatusConflict, resp.Ctrl.Code) } if s.tenantID != 7 || s.tenantCode != "acme" { t.Fatalf("tenant binding changed: tenant=%d code=%q", s.tenantID, s.tenantCode) } } func verifyResponseCodes(r *responses, codes []int, t *testing.T) { if len(r.messages) != len(codes) { t.Errorf("responses: expected %d, received %d.", len(codes), len(r.messages)) } for i := range codes { resp := r.messages[i].(*ServerComMessage) if resp == nil { t.Fatalf("Response %d must be ServerComMessage", i) } if resp.Ctrl == nil { t.Fatalf("Response %d must contain a ctrl message.", i) } if resp.Ctrl.Code != codes[i] { t.Errorf("Response code: expected %d, got %d", codes[i], resp.Ctrl.Code) } } } func TestDispatchInvalidVersion(t *testing.T) { s := &Session{ send: make(chan any, 10), uid: types.Uid(1), authLvl: auth.LevelAuth, } wg := sync.WaitGroup{} r := responses{} wg.Add(1) go s.testWriteLoop(&r, &wg) msg := &ClientComMessage{ Hi: &MsgClientHi{ Id: "123", // Invalid version string. Version: "INVALID VERSION STRING", }, } s.dispatch(msg) close(s.send) wg.Wait() verifyResponseCodes(&r, []int{http.StatusBadRequest}, t) } func TestDispatchUnsupportedVersion(t *testing.T) { s := &Session{ send: make(chan any, 10), uid: types.Uid(1), authLvl: auth.LevelAuth, } wg := sync.WaitGroup{} r := responses{} wg.Add(1) go s.testWriteLoop(&r, &wg) msg := &ClientComMessage{ Hi: &MsgClientHi{ Id: "123", // Invalid version string. Version: "0.1", }, } s.dispatch(msg) close(s.send) wg.Wait() verifyResponseCodes(&r, []int{http.StatusHTTPVersionNotSupported}, t) } func TestDispatchLogin(t *testing.T) { ctrl := gomock.NewController(t) ss := mock_store.NewMockPersistentStorageInterface(ctrl) aa := mock_auth.NewMockAuthHandler(ctrl) uid := types.Uid(1) oldSecurity := store.Security store.Store = ss store.Security = &testSecurityStore{version: 1} defer func() { store.Store = nil store.Security = oldSecurity ctrl.Finish() }() secret := "<==auth-secret==>" authRec := &auth.Rec{ TenantID: 1, Uid: uid, AuthLevel: auth.LevelAuth, Tags: []string{"tag1", "tag2"}, State: types.StateOK, } ss.EXPECT().GetLogicalAuthHandler("basic").Return(aa) aa.EXPECT().Authenticate(auth.AuthContext{TenantID: 1}, []byte(secret)).Return(authRec, nil, nil) // Token generation. ss.EXPECT().GetLogicalAuthHandler("token").Return(aa) token := "<==auth-token==>" expires, _ := time.Parse(time.RFC822, "01 Jan 50 00:00 UTC") aa.EXPECT().GenSecret(authRec).Return([]byte(token), expires, nil) s := &Session{ send: make(chan any, 10), authLvl: auth.LevelAuth, ver: 16, tenantID: 1, tenantCode: "test", } wg := sync.WaitGroup{} r := responses{} wg.Add(1) go s.testWriteLoop(&r, &wg) msg := &ClientComMessage{ Login: &MsgClientLogin{ Id: "123", Scheme: "basic", Secret: []byte(secret), LoginType: ptrInt(registrationTypeEnterprise), TenantCode: "test", }, } s.dispatch(msg) close(s.send) wg.Wait() if len(r.messages) != 1 { t.Errorf("responses: expected 1, received %d.", len(r.messages)) } resp := r.messages[0].(*ServerComMessage) if resp == nil { t.Fatal("Response must be ServerComMessage") } if resp.Ctrl != nil { if resp.Ctrl.Id != "123" { t.Errorf("Response id: expected '123', found '%s'", resp.Ctrl.Id) } if resp.Ctrl.Code != 200 { t.Errorf("Response code: expected 200, got %d", resp.Ctrl.Code) } if resp.Ctrl.Params == nil { t.Error("Response is expected to contain params dict.") } p := resp.Ctrl.Params.(map[string]any) if authToken := string(p["token"].([]byte)); authToken != token { t.Errorf("Auth token: expected '%s', found '%s'.", token, authToken) } if exp := p["expires"].(time.Time); exp != expires { t.Errorf("Token expiration: expected '%s', found '%s'.", expires, exp) } } else { t.Error("Response must contain a ctrl message.") } } func TestDispatchLoginRejectsAuthenticatorTenantMismatch(t *testing.T) { ctrl := gomock.NewController(t) ss := mock_store.NewMockPersistentStorageInterface(ctrl) aa := mock_auth.NewMockAuthHandler(ctrl) store.Store = ss defer func() { store.Store = nil ctrl.Finish() }() secret := []byte("alice:password") ss.EXPECT().GetLogicalAuthHandler("basic").Return(aa) aa.EXPECT().Authenticate(auth.AuthContext{TenantID: 1}, secret).Return(&auth.Rec{ TenantID: 2, Uid: 42, AuthLevel: auth.LevelAuth, State: types.StateOK, }, nil, nil) s := &Session{send: make(chan any, 1), ver: 16, tenantID: 1, tenantCode: "acme"} s.login(&ClientComMessage{ Login: &MsgClientLogin{Id: "login-1", Scheme: "basic", Secret: secret, LoginType: ptrInt(registrationTypeEnterprise), TenantCode: "acme"}, Timestamp: time.Now(), }) resp := (<-s.send).(*ServerComMessage) if resp.Ctrl == nil || resp.Ctrl.Code != http.StatusUnauthorized { t.Fatalf("unexpected response: %+v", resp) } if !s.uid.IsZero() { t.Fatalf("tenant mismatch authenticated session as %s", s.uid.UserId()) } } func TestDispatchLoginRejectsEnterpriseDefaultTenant(t *testing.T) { s := &Session{send: make(chan any, 1), ver: 16, tenantID: 1, tenantCode: imDefaultTenantCode} s.login(&ClientComMessage{ Login: &MsgClientLogin{Id: "login-1", Scheme: "basic", Secret: []byte("alice:password"), LoginType: ptrInt(registrationTypeEnterprise), TenantCode: imDefaultTenantCode}, Timestamp: time.Now(), }) resp := (<-s.send).(*ServerComMessage) if resp.Ctrl == nil || resp.Ctrl.Code != http.StatusUnprocessableEntity { t.Fatalf("unexpected response: %+v", resp) } params, _ := resp.Ctrl.Params.(map[string]any) if params["reason"] != "TENANT_CODE_FORBIDDEN" { t.Fatalf("unexpected params: %+v", resp.Ctrl.Params) } } func TestDispatchLoginRequiresTypeForBasic(t *testing.T) { s := &Session{send: make(chan any, 1), ver: 16, tenantID: 1, tenantCode: imDefaultTenantCode} s.login(&ClientComMessage{ Login: &MsgClientLogin{Id: "login-1", Scheme: "basic", Secret: []byte("alice:password")}, Timestamp: time.Now(), }) resp := (<-s.send).(*ServerComMessage) if resp.Ctrl == nil || resp.Ctrl.Code != http.StatusUnprocessableEntity { t.Fatalf("unexpected response: %+v", resp) } params, _ := resp.Ctrl.Params.(map[string]any) if params["reason"] != "LOGIN_TYPE_REQUIRED" { t.Fatalf("unexpected params: %+v", resp.Ctrl.Params) } } func TestPublishSysRequiresRoot(t *testing.T) { s := &Session{ send: make(chan any, 1), uid: types.Uid(42), authLvl: auth.LevelAuth, tenantID: 1, tenantCode: "test", } s.publish(&ClientComMessage{ Original: "sys", RcptTo: "sys", AsUser: types.Uid(42).UserId(), Timestamp: time.Now(), Pub: &MsgClientPub{Id: "pub-1", Topic: "sys", Content: map[string]any{"type": "system"}}, }) resp := (<-s.send).(*ServerComMessage) if resp.Ctrl == nil || resp.Ctrl.Code != http.StatusForbidden { t.Fatalf("unexpected response: %+v", resp) } } func TestDispatchRejectsCrossTenantAsUser(t *testing.T) { ctrl := gomock.NewController(t) users := mock_store.NewMockUsersPersistenceInterface(ctrl) oldUsers := store.Users store.Users = users defer func() { store.Users = oldUsers ctrl.Finish() }() target := types.Uid(42) users.EXPECT().Get(types.TenantID(1), target).Return(&types.User{TenantID: 2, State: types.StateOK}, nil) s := &Session{ send: make(chan any, 1), uid: 1, authLvl: auth.LevelRoot, ver: 16, tenantID: 1, tenantCode: "acme", } s.dispatch(&ClientComMessage{Extra: &MsgClientExtra{AsUser: target.UserId()}}) resp := (<-s.send).(*ServerComMessage) if resp.Ctrl == nil || resp.Ctrl.Code != http.StatusForbidden { t.Fatalf("unexpected response: %+v", resp) } } func TestDispatchSubscribe(t *testing.T) { uid := types.Uid(1) s := test_makeSession(uid) wg := sync.WaitGroup{} r := responses{} wg.Add(1) go s.testWriteLoop(&r, &wg) hub := &Hub{ join: make(chan *ClientComMessage, 10), } globals.hub = hub defer func() { globals.hub = nil }() msg := &ClientComMessage{ Sub: &MsgClientSub{ Id: "123", Topic: "me", Get: &MsgGetQuery{ What: "sub desc tags cred", }, }, } s.dispatch(msg) close(s.send) wg.Wait() // Check we've routed the join request via the hub. if len(r.messages) != 0 { t.Errorf("responses: expected 0, received %d.", len(r.messages)) } if len(hub.join) == 1 { join := <-hub.join if join.sess != s { t.Error("Hub.join request: sess field expected to be the session under test.") } if join != msg { t.Error("Hub.join request: subscribe message expected to be the original subscribe message.") } } else { t.Errorf("Hub join messages: expected 1, received %d.", len(hub.join)) } s.inflightReqs.Done() } func TestDispatchAlreadySubscribed(t *testing.T) { uid := types.Uid(1) s := test_makeSession(uid) wg := sync.WaitGroup{} r := responses{} wg.Add(1) go s.testWriteLoop(&r, &wg) msg := &ClientComMessage{ Sub: &MsgClientSub{ Id: "123", Topic: "me", Get: &MsgGetQuery{ What: "sub desc tags cred", }, }, } // Pretend the session's already subscribed to topic 'me'. s.subs = make(map[string]*Subscription) s.subs[uid.UserId()] = &Subscription{} s.dispatch(msg) close(s.send) wg.Wait() verifyResponseCodes(&r, []int{http.StatusNotModified}, t) } func TestSubscribeP2PUserAliasReachesTopic(t *testing.T) { oldHub := globals.hub globals.hub = &Hub{join: make(chan *ClientComMessage, 1)} t.Cleanup(func() { globals.hub = oldHub }) self := types.Uid(1) peer := types.Uid(2) canonical := self.P2PName(peer) s := test_makeSession(self) s.subs = map[string]*Subscription{canonical: {}} msg := &ClientComMessage{ Sub: &MsgClientSub{Id: "alias-sub", Topic: peer.UserId()}, Id: "alias-sub", Original: peer.UserId(), AsUser: self.UserId(), TenantID: s.tenantID, Timestamp: types.TimeNow(), sess: s, } s.subscribe(msg) select { case routed := <-globals.hub.join: if routed != msg || routed.RcptTo != canonical { t.Fatalf("unexpected alias subscription route: %#v", routed) } s.inflightReqs.Done() default: t.Fatal("p2p user alias was incorrectly handled as an already-subscribed topic") } } func TestDispatchSubscribeJoinChannelFull(t *testing.T) { uid := types.Uid(1) s := test_makeSession(uid) wg := sync.WaitGroup{} r := responses{} wg.Add(1) go s.testWriteLoop(&r, &wg) hub := &Hub{ // Make it unbuffered with no readers - so emit operation fails immediately. join: make(chan *ClientComMessage), } globals.hub = hub defer func() { globals.hub = nil }() msg := &ClientComMessage{ Sub: &MsgClientSub{ Id: "123", Topic: "me", Get: &MsgGetQuery{ What: "sub desc tags cred", }, }, } s.dispatch(msg) close(s.send) wg.Wait() verifyResponseCodes(&r, []int{http.StatusServiceUnavailable}, t) } func TestDispatchLeave(t *testing.T) { uid := types.Uid(1) s := test_makeSession(uid) wg := sync.WaitGroup{} r := responses{} wg.Add(1) go s.testWriteLoop(&r, &wg) destUid := types.Uid(2) topicName := uid.P2PName(destUid) leave := make(chan *ClientComMessage, 1) s.subs = make(map[string]*Subscription) s.subs[topicName] = &Subscription{ done: leave, } msg := &ClientComMessage{ Leave: &MsgClientLeave{ Id: "123", Topic: destUid.UserId(), }, } s.dispatch(msg) close(s.send) wg.Wait() // Check we've routed the join request via the leave channel. if len(r.messages) != 0 { t.Errorf("responses: expected 0, received %d.", len(r.messages)) } if len(leave) == 1 { req := <-leave if req.sess != s { t.Error("Leave request: sess field expected to be the session under test.") } if req != msg { t.Error("Leave request: leave message expected to be the original leave message.") } // leave request handler is expected to clean up subs. s.delSub(topicName) } else { t.Errorf("Unsub messages: expected 1, received %d.", len(leave)) } if len(s.subs) != 0 { t.Errorf("Session subs: expected to be empty, actual size: %d", len(s.subs)) } s.inflightReqs.Done() } func TestDispatchLeaveUnsubMe(t *testing.T) { uid := types.Uid(1) s := test_makeSession(uid) wg := sync.WaitGroup{} r := responses{} wg.Add(1) go s.testWriteLoop(&r, &wg) s.subs = make(map[string]*Subscription) s.subs[uid.UserId()] = &Subscription{} msg := &ClientComMessage{ Leave: &MsgClientLeave{ Id: "123", // Cannot unsubscribe from 'me'. Topic: "me", Unsub: true, }, } s.dispatch(msg) close(s.send) wg.Wait() verifyResponseCodes(&r, []int{http.StatusForbidden}, t) } func TestDispatchLeaveUnknownTopic(t *testing.T) { uid := types.Uid(1) s := test_makeSession(uid) wg := sync.WaitGroup{} r := responses{} wg.Add(1) go s.testWriteLoop(&r, &wg) // Session isn't subscribed to topic 'me'. // And wants to leave it => no change. s.subs = make(map[string]*Subscription) msg := &ClientComMessage{ Leave: &MsgClientLeave{ Id: "123", Topic: "me", }, } s.dispatch(msg) close(s.send) wg.Wait() verifyResponseCodes(&r, []int{http.StatusNotModified}, t) } func TestDispatchLeaveUnsubFromUnknownTopic(t *testing.T) { uid := types.Uid(1) s := test_makeSession(uid) wg := sync.WaitGroup{} r := responses{} wg.Add(1) go s.testWriteLoop(&r, &wg) // Session isn't subscribed to topic 'me'. // And wants to leave & unsubscribe from it. s.subs = make(map[string]*Subscription) msg := &ClientComMessage{ Leave: &MsgClientLeave{ Id: "123", Topic: "me", Unsub: true, }, } s.dispatch(msg) close(s.send) wg.Wait() verifyResponseCodes(&r, []int{http.StatusConflict}, t) } func TestDispatchPublish(t *testing.T) { uid := types.Uid(1) s := test_makeSession(uid) wg := sync.WaitGroup{} r := responses{} wg.Add(1) go s.testWriteLoop(&r, &wg) destUid := types.Uid(2) topicName := uid.P2PName(destUid) brdcst := make(chan *ClientComMessage, 1) s.subs = make(map[string]*Subscription) s.subs[topicName] = &Subscription{ broadcast: brdcst, } testMessage := "test content" msg := &ClientComMessage{ Pub: &MsgClientPub{ Id: "123", Topic: destUid.UserId(), Content: testMessage, }, } s.dispatch(msg) close(s.send) wg.Wait() // Check we've routed the join request via the broadcast channel. if len(r.messages) != 0 { t.Errorf("responses: expected 0, received %d.", len(r.messages)) } if len(brdcst) == 1 { req := <-brdcst if req.sess != s { t.Error("Pub request: sess field expected to be the session under test.") } if req.Pub.Content != testMessage { t.Errorf("Pub request content: expected '%s' vs '%s'.", testMessage, req.Pub.Content) } if req.Pub.Topic != destUid.UserId() { t.Errorf("Pub request topic: expected '%s' vs '%s'.", destUid.UserId(), req.Pub.Topic) } } else { t.Errorf("Pub messages: expected 1, received %d.", len(brdcst)) } } func TestDispatchPublishBroadcastChannelFull(t *testing.T) { uid := types.Uid(1) s := test_makeSession(uid) wg := sync.WaitGroup{} r := responses{} wg.Add(1) go s.testWriteLoop(&r, &wg) destUid := types.Uid(2) topicName := uid.P2PName(destUid) // Make broadcast channel unbuffered with no reader - // emit op will fail. brdcst := make(chan *ClientComMessage) s.subs = make(map[string]*Subscription) s.subs[topicName] = &Subscription{ broadcast: brdcst, } testMessage := "test content" msg := &ClientComMessage{ Pub: &MsgClientPub{ Id: "123", Topic: destUid.UserId(), Content: testMessage, }, } s.dispatch(msg) close(s.send) wg.Wait() verifyResponseCodes(&r, []int{http.StatusServiceUnavailable}, t) } func TestDispatchPublishMissingSubcription(t *testing.T) { uid := types.Uid(1) s := test_makeSession(uid) wg := sync.WaitGroup{} r := responses{} wg.Add(1) go s.testWriteLoop(&r, &wg) destUid := types.Uid(2) // Subscription to topic missing. s.subs = make(map[string]*Subscription) testMessage := "test content" msg := &ClientComMessage{ Pub: &MsgClientPub{ Id: "123", Topic: destUid.UserId(), Content: testMessage, }, } s.dispatch(msg) close(s.send) wg.Wait() verifyResponseCodes(&r, []int{http.StatusConflict}, t) } func TestDispatchGet(t *testing.T) { uid := types.Uid(1) s := test_makeSession(uid) wg := sync.WaitGroup{} r := responses{} wg.Add(1) go s.testWriteLoop(&r, &wg) destUid := types.Uid(2) topicName := uid.P2PName(destUid) meta := make(chan *ClientComMessage, 1) s.subs = make(map[string]*Subscription) s.subs[topicName] = &Subscription{ meta: meta, } msg := &ClientComMessage{ Get: &MsgClientGet{ Id: "123", Topic: destUid.UserId(), MsgGetQuery: MsgGetQuery{ What: "desc sub del cred", }, }, } s.dispatch(msg) close(s.send) wg.Wait() // Check we've routed the join request via the meta channel. if len(r.messages) != 0 { t.Errorf("responses: expected 0, received %d.", len(r.messages)) } if len(meta) == 1 { req := <-meta if req.sess != s { t.Error("Get request: sess field expected to be the session under test.") } } else { t.Errorf("Get messages: expected 1, received %d.", len(meta)) } } func TestDispatchGetMalformedWhat(t *testing.T) { uid := types.Uid(1) s := test_makeSession(uid) wg := sync.WaitGroup{} r := responses{} wg.Add(1) go s.testWriteLoop(&r, &wg) destUid := types.Uid(2) msg := &ClientComMessage{ Get: &MsgClientGet{ Id: "123", Topic: destUid.UserId(), MsgGetQuery: MsgGetQuery{ // Empty 'what'. This will produce an error. What: "", }, }, } s.dispatch(msg) close(s.send) wg.Wait() verifyResponseCodes(&r, []int{http.StatusBadRequest}, t) } func TestDispatchGetMetaChannelFull(t *testing.T) { uid := types.Uid(1) s := test_makeSession(uid) wg := sync.WaitGroup{} r := responses{} wg.Add(1) go s.testWriteLoop(&r, &wg) destUid := types.Uid(2) topicName := uid.P2PName(destUid) // Unbuffered chan with no readers - emit will fail. meta := make(chan *ClientComMessage) s.subs = make(map[string]*Subscription) s.subs[topicName] = &Subscription{ meta: meta, } msg := &ClientComMessage{ Get: &MsgClientGet{ Id: "123", Topic: destUid.UserId(), MsgGetQuery: MsgGetQuery{ What: "desc sub", }, }, } s.dispatch(msg) close(s.send) wg.Wait() verifyResponseCodes(&r, []int{http.StatusServiceUnavailable}, t) } func TestDispatchSet(t *testing.T) { uid := types.Uid(1) s := test_makeSession(uid) wg := sync.WaitGroup{} r := responses{} wg.Add(1) go s.testWriteLoop(&r, &wg) destUid := types.Uid(2) topicName := uid.P2PName(destUid) meta := make(chan *ClientComMessage, 1) s.subs = make(map[string]*Subscription) s.subs[topicName] = &Subscription{ meta: meta, } msg := &ClientComMessage{ Set: &MsgClientSet{ Id: "123", Topic: destUid.UserId(), MsgSetQuery: MsgSetQuery{ Desc: &MsgSetDesc{}, Sub: &MsgSetSub{}, Tags: []string{"abc"}, Cred: &MsgCredClient{}, }, }, } s.dispatch(msg) close(s.send) wg.Wait() // Check we've routed the join request via the meta channel. if len(r.messages) != 0 { t.Errorf("responses: expected 0, received %d.", len(r.messages)) } if len(meta) == 1 { req := <-meta if req.sess != s { t.Error("Set request: sess field expected to be the session under test.") } expectedWhat := constMsgMetaDesc | constMsgMetaSub | constMsgMetaTags | constMsgMetaCred if msg.MetaWhat != expectedWhat { t.Errorf("Set request what: expected %d vs %d", expectedWhat, msg.MetaWhat) } } else { t.Errorf("Set messages: expected 1, received %d.", len(meta)) } } func TestDispatchSetMalformedWhat(t *testing.T) { uid := types.Uid(1) s := test_makeSession(uid) wg := sync.WaitGroup{} r := responses{} wg.Add(1) go s.testWriteLoop(&r, &wg) destUid := types.Uid(2) msg := &ClientComMessage{ Set: &MsgClientSet{ Id: "123", Topic: destUid.UserId(), MsgSetQuery: MsgSetQuery{ // No meta requests. }, }, } s.dispatch(msg) close(s.send) wg.Wait() verifyResponseCodes(&r, []int{http.StatusBadRequest}, t) } func TestDispatchSetMetaChannelFull(t *testing.T) { uid := types.Uid(1) s := test_makeSession(uid) wg := sync.WaitGroup{} r := responses{} wg.Add(1) go s.testWriteLoop(&r, &wg) destUid := types.Uid(2) topicName := uid.P2PName(destUid) // Unbuffered meta channel w/ no readers - emit will fail. meta := make(chan *ClientComMessage) s.subs = make(map[string]*Subscription) s.subs[topicName] = &Subscription{ meta: meta, } msg := &ClientComMessage{ Set: &MsgClientSet{ Id: "123", Topic: destUid.UserId(), MsgSetQuery: MsgSetQuery{ // No meta requests. Desc: &MsgSetDesc{}, Sub: &MsgSetSub{}, Tags: []string{"abc"}, Cred: &MsgCredClient{}, }, }, } s.dispatch(msg) close(s.send) wg.Wait() verifyResponseCodes(&r, []int{http.StatusServiceUnavailable}, t) } func TestDispatchDelMsg(t *testing.T) { uid := types.Uid(1) s := test_makeSession(uid) wg := sync.WaitGroup{} r := responses{} wg.Add(1) go s.testWriteLoop(&r, &wg) destUid := types.Uid(2) topicName := uid.P2PName(destUid) meta := make(chan *ClientComMessage, 1) s.subs = make(map[string]*Subscription) s.subs[topicName] = &Subscription{ meta: meta, } msg := &ClientComMessage{ Del: &MsgClientDel{ Id: "123", Topic: destUid.UserId(), What: "msg", DelSeq: []MsgRange{{LowId: 3, HiId: 4}}, Hard: true, }, } s.dispatch(msg) close(s.send) wg.Wait() // Check we've routed the join request via the meta channel. if len(r.messages) != 0 { t.Errorf("responses: expected 0, received %d.", len(r.messages)) } if len(meta) == 1 { req := <-meta if req.sess != s { t.Error("Del request: sess field expected to be the session under test.") } } else { t.Errorf("Del messages: expected 1, received %d.", len(meta)) } } func TestDispatchDelMalformedWhat(t *testing.T) { uid := types.Uid(1) s := test_makeSession(uid) wg := sync.WaitGroup{} r := responses{} wg.Add(1) go s.testWriteLoop(&r, &wg) destUid := types.Uid(2) msg := &ClientComMessage{ Del: &MsgClientDel{ Id: "123", Topic: destUid.UserId(), // Invalid 'what' - this will produce an error. What: "INVALID", }, } s.dispatch(msg) close(s.send) wg.Wait() verifyResponseCodes(&r, []int{http.StatusBadRequest}, t) } func TestDispatchDelMetaChanFull(t *testing.T) { uid := types.Uid(1) s := test_makeSession(uid) wg := sync.WaitGroup{} r := responses{} wg.Add(1) go s.testWriteLoop(&r, &wg) destUid := types.Uid(2) topicName := uid.P2PName(destUid) // Unbuffered chan - to simulate a full buffered chan. meta := make(chan *ClientComMessage) s.subs = make(map[string]*Subscription) s.subs[topicName] = &Subscription{ meta: meta, } msg := &ClientComMessage{ Del: &MsgClientDel{ Id: "123", Topic: destUid.UserId(), What: "msg", DelSeq: []MsgRange{{LowId: 3, HiId: 4}}, Hard: true, }, } s.dispatch(msg) close(s.send) wg.Wait() verifyResponseCodes(&r, []int{http.StatusServiceUnavailable}, t) } func TestDispatchDelUnsubscribedSession(t *testing.T) { uid := types.Uid(1) s := test_makeSession(uid) wg := sync.WaitGroup{} r := responses{} wg.Add(1) go s.testWriteLoop(&r, &wg) destUid := types.Uid(2) // Session isn't subscribed. s.subs = make(map[string]*Subscription) msg := &ClientComMessage{ Del: &MsgClientDel{ Id: "123", Topic: destUid.UserId(), What: "msg", DelSeq: []MsgRange{{LowId: 3, HiId: 4}}, Hard: true, }, } s.dispatch(msg) close(s.send) wg.Wait() verifyResponseCodes(&r, []int{http.StatusConflict}, t) } func TestDispatchNote(t *testing.T) { uid := types.Uid(1) s := test_makeSession(uid) wg := sync.WaitGroup{} r := responses{} wg.Add(1) go s.testWriteLoop(&r, &wg) destUid := types.Uid(2) topicName := uid.P2PName(destUid) brdcst := make(chan *ClientComMessage, 1) s.subs = make(map[string]*Subscription) s.subs[topicName] = &Subscription{ broadcast: brdcst, } msg := &ClientComMessage{ Note: &MsgClientNote{ Topic: destUid.UserId(), What: "recv", SeqId: 5, }, } s.dispatch(msg) close(s.send) wg.Wait() // Check we've routed the join request via the broadcast channel. if len(r.messages) != 0 { t.Errorf("responses: expected 0, received %d.", len(r.messages)) } if len(brdcst) == 1 { req := <-brdcst if req.sess != s { t.Error("Pub request: sess field expected to be the session under test.") } if req.Note.What != msg.Note.What { t.Errorf("Note request what: expected '%s' vs '%s'.", msg.Note.What, req.Note.What) } if req.Note.SeqId != msg.Note.SeqId { t.Errorf("Note request seqId: expected %d vs %d.", msg.Note.SeqId, req.Note.SeqId) } if req.Note.Topic != destUid.UserId() { t.Errorf("Note request topic: expected '%s' vs '%s'.", destUid.UserId(), req.Note.Topic) } } else { t.Errorf("Note messages: expected 1, received %d.", len(brdcst)) } } func TestDispatchNoteBroadcastChanFull(t *testing.T) { uid := types.Uid(1) s := test_makeSession(uid) wg := sync.WaitGroup{} r := responses{} wg.Add(1) go s.testWriteLoop(&r, &wg) destUid := types.Uid(2) topicName := uid.P2PName(destUid) // Unbuffered chan - to simulate a full buffered chan. brdcst := make(chan *ClientComMessage) s.subs = make(map[string]*Subscription) s.subs[topicName] = &Subscription{ broadcast: brdcst, } msg := &ClientComMessage{ Note: &MsgClientNote{ Topic: destUid.UserId(), What: "recv", SeqId: 5, }, } s.dispatch(msg) close(s.send) wg.Wait() verifyResponseCodes(&r, []int{http.StatusServiceUnavailable}, t) } func TestDispatchNoteOnNonSubscribedTopic(t *testing.T) { uid := types.Uid(1) s := test_makeSession(uid) wg := sync.WaitGroup{} r := responses{} wg.Add(1) go s.testWriteLoop(&r, &wg) destUid := types.Uid(2) s.subs = make(map[string]*Subscription) msg := &ClientComMessage{ Note: &MsgClientNote{ Topic: destUid.UserId(), What: "read", SeqId: 5, }, } s.dispatch(msg) close(s.send) wg.Wait() verifyResponseCodes(&r, []int{http.StatusConflict}, t) } func TestDispatchAccNew(t *testing.T) { ctrl := gomock.NewController(t) ss := mock_store.NewMockPersistentStorageInterface(ctrl) uu := mock_store.NewMockUsersPersistenceInterface(ctrl) aa := mock_auth.NewMockAuthHandler(ctrl) uid := types.Uid(1) oldSecurity := store.Security oldVerifications := store.Verifications oldInvites := store.RegistrationInvites oldValidators := globals.validators globals.validators = map[string]credValidator{"tel": {}} store.Store = ss store.Users = uu store.Security = &testSecurityStore{version: 1} store.Verifications = nil invites := &testRegistrationInviteStore{invite: &types.RegistrationInviteCode{ Code: "AbCdE", Status: types.RegistrationInviteActive, ExpiresAt: time.Now().Add(time.Hour), }} store.RegistrationInvites = invites setRegistrationSMSConfigForTest(true) defer func() { store.Store = nil store.Users = nil store.Security = oldSecurity store.Verifications = oldVerifications store.RegistrationInvites = oldInvites globals.validators = oldValidators resetRegistrationSMSConfigForTest() ctrl.Finish() }() remoteAddr := "192.168.0.1" secret := "alice:password" authRec := &auth.Rec{ TenantID: 1, Uid: uid, AuthLevel: auth.LevelAuth, State: types.StateOK, } ss.EXPECT().GetLogicalAuthHandler("basic").Return(aa) ss.EXPECT().GetValidator("tel").Return(registrationTestValidator{}) // This login is available. aa.EXPECT().IsUnique(auth.AuthContext{TenantID: 1, RemoteAddr: remoteAddr}, []byte(secret)).Return(true, nil) uu.EXPECT().Create(types.TenantID(1), gomock.Any(), gomock.Any()).DoAndReturn( func(_ types.TenantID, user *types.User, private any) (*types.User, error) { if user.Phone != "+8613800138000" { t.Fatalf("unexpected registration phone: %q", user.Phone) } user.SetUid(uid) return user, nil }) aa.EXPECT().AddRecord(auth.AuthContext{TenantID: 1, RemoteAddr: remoteAddr}, gomock.Any(), []byte(secret)).Return(authRec, nil) // Token generation. ss.EXPECT().GetLogicalAuthHandler("token").Return(aa) token := "<==auth-token==>" aa.EXPECT().GenSecret(gomock.Any()).Return([]byte(token), time.Now(), nil) s := &Session{ send: make(chan any, 10), authLvl: auth.LevelAuth, ver: 16, tenantID: 1, tenantCode: "test", remoteAddr: remoteAddr, } wg := sync.WaitGroup{} r := responses{} wg.Add(1) go s.testWriteLoop(&r, &wg) public := "public name" msg := &ClientComMessage{ Acc: &MsgClientAcc{ Id: "123", User: "newXYZ", Scheme: "basic", Username: "alice", Phone: "13800138000", RegistrationType: ptrInt(registrationTypeEnterprise), TenantCode: "test", Secret: []byte(secret), Cred: []MsgCredClient{{ Method: "tel", Value: "+8613800138000", Response: "AbCdE", Params: map[string]any{"challenge_id": "challenge-1"}, }}, Desc: &MsgSetDesc{Public: public}, }, } s.dispatch(msg) close(s.send) wg.Wait() if len(r.messages) != 1 { t.Errorf("responses: expected 1, received %d.", len(r.messages)) } if !invites.useRecorded { t.Error("registration invite use was not recorded") } resp := r.messages[0].(*ServerComMessage) if resp == nil { t.Fatal("Response must be ServerComMessage") } if resp.Ctrl != nil { if resp.Ctrl.Id != "123" { t.Errorf("Response id: expected '123', found '%s'", resp.Ctrl.Id) } if resp.Ctrl.Code != 201 { t.Errorf("Response code: expected 201, got %d", resp.Ctrl.Code) } if resp.Ctrl.Params == nil { t.Error("Response is expected to contain params dict.") } p := resp.Ctrl.Params.(map[string]any) if respUid := string(p["user"].(string)); respUid != uid.UserId() { t.Errorf("Response uid: expected '%s', found '%s'.", uid.UserId(), respUid) } if lvl := p["authlvl"].(string); lvl != auth.LevelAuth.String() { t.Errorf("Auth level: expected '%s', found '%s'.", auth.LevelAuth.String(), lvl) } if username := p["username"].(string); username != "alice" { t.Errorf("Username: expected 'alice', found '%s'.", username) } if desc := p["desc"].(*MsgTopicDesc); desc.Public.(map[string]any)["fn"] != "alice" { t.Errorf("Public fn: expected 'alice', found '%v'.", desc.Public) } } else { t.Error("Response must contain a ctrl message.") } } func TestDispatchAccNewUsernameOnlyDoesNotRequireInvite(t *testing.T) { ctrl := gomock.NewController(t) ss := mock_store.NewMockPersistentStorageInterface(ctrl) uu := mock_store.NewMockUsersPersistenceInterface(ctrl) aa := mock_auth.NewMockAuthHandler(ctrl) uid := types.Uid(2) oldSecurity := store.Security oldInvites := store.RegistrationInvites oldAuthValidators := globals.authValidators store.Store = ss store.Users = uu store.Security = &testSecurityStore{version: 1} store.RegistrationInvites = nil globals.authValidators = map[auth.Level][]string{} setRegistrationSMSConfigForTest(true) defer func() { store.Store = nil store.Users = nil store.Security = oldSecurity store.RegistrationInvites = oldInvites globals.authValidators = oldAuthValidators resetRegistrationSMSConfigForTest() ctrl.Finish() }() remoteAddr := "192.168.0.1" secret := "alice:password" authRec := &auth.Rec{ TenantID: 1, Uid: uid, AuthLevel: auth.LevelAuth, State: types.StateOK, } ss.EXPECT().GetLogicalAuthHandler("basic").Return(aa) aa.EXPECT().IsUnique(auth.AuthContext{TenantID: 1, RemoteAddr: remoteAddr}, []byte(secret)).Return(true, nil) uu.EXPECT().Create(types.TenantID(1), gomock.Any(), gomock.Any()).DoAndReturn( func(_ types.TenantID, user *types.User, private any) (*types.User, error) { if user.Username != "alice" || user.Phone != "" { t.Fatalf("unexpected registration user: username=%q phone=%q", user.Username, user.Phone) } user.SetUid(uid) return user, nil }) aa.EXPECT().AddRecord(auth.AuthContext{TenantID: 1, RemoteAddr: remoteAddr}, gomock.Any(), []byte(secret)).Return(authRec, nil) ss.EXPECT().GetLogicalAuthHandler("token").Return(aa) aa.EXPECT().GenSecret(gomock.Any()).Return([]byte("<==auth-token==>"), time.Now(), nil) s := &Session{ send: make(chan any, 10), authLvl: auth.LevelAuth, ver: 16, tenantID: 1, tenantCode: "test", remoteAddr: remoteAddr, } wg := sync.WaitGroup{} r := responses{} wg.Add(1) go s.testWriteLoop(&r, &wg) msg := &ClientComMessage{ Acc: &MsgClientAcc{ Id: "username-only", User: "new", Scheme: "basic", Username: "alice", RegistrationType: ptrInt(registrationTypeEnterprise), TenantCode: "test", Secret: []byte(secret), Desc: &MsgSetDesc{Public: map[string]any{"fn": "Alice"}}, }, } s.dispatch(msg) close(s.send) wg.Wait() if len(r.messages) != 1 { t.Fatalf("responses: expected 1, received %d", len(r.messages)) } resp := r.messages[0].(*ServerComMessage) if resp.Ctrl == nil || resp.Ctrl.Code != 201 { t.Fatalf("expected successful username registration, got %#v", resp.Ctrl) } if username := resp.Ctrl.Params.(map[string]any)["username"].(string); username != "alice" { t.Fatalf("username: got %q", username) } } func TestDispatchNoMessage(t *testing.T) { remoteAddr := "192.168.0.1" s := &Session{ send: make(chan any, 10), authLvl: auth.LevelAuth, ver: 16, tenantID: 1, tenantCode: "test", remoteAddr: remoteAddr, } wg := sync.WaitGroup{} r := responses{} wg.Add(1) go s.testWriteLoop(&r, &wg) msg := &ClientComMessage{} s.dispatch(msg) close(s.send) wg.Wait() if len(r.messages) != 1 { t.Errorf("responses: expected 1, received %d.", len(r.messages)) } resp := r.messages[0].(*ServerComMessage) if resp == nil { t.Fatal("Response must be ServerComMessage") } if resp.Ctrl == nil { t.Fatal("Response must contain a ctrl message.") } if resp.Ctrl.Code != 400 { t.Errorf("Response code: expected 400, got %d", resp.Ctrl.Code) } }