package main import ( "bytes" "crypto/hmac" "crypto/md5" "encoding/base64" "encoding/binary" "encoding/json" "net/http" "net/http/httptest" "strings" "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" "golang.org/x/crypto/bcrypt" ) type testVerificationStore struct { active *types.VerificationCode get *types.VerificationCode err error created *types.VerificationCode failed bool verified bool consumed bool tokenHash string } type testRelationsStore struct { context *types.RelationshipContext created *types.FriendRequest blocked *types.UserBlock deleted bool canSend *bool reason string friends []types.FriendListItem friendRequests []types.FriendRequestListItem searchResults []types.UserSearchItem blocks []types.BlockListItem } type testFavoritesStore struct { items []types.FavoriteItem created *types.FavoriteItem deleted []string } func (s *testFavoritesStore) Create(_ types.TenantID, item *types.FavoriteItem) (*types.FavoriteItem, error) { s.created = item s.items = append(s.items, *item) return item, nil } func (s *testFavoritesStore) List(_ types.TenantID, _ types.Uid, _, _, _ string, _ int) ([]types.FavoriteItem, string, error) { return s.items, "", nil } func (s *testFavoritesStore) Get(_ types.TenantID, _ types.Uid, favoriteID string) (*types.FavoriteItem, error) { for i := range s.items { if s.items[i].FavoriteID == favoriteID { return &s.items[i], nil } } return nil, types.ErrNotFound } func (s *testFavoritesStore) Delete(_ types.TenantID, _ types.Uid, favoriteID string) error { s.deleted = append(s.deleted, favoriteID) return nil } func (s *testRelationsStore) CreateFriendRequest(_ types.TenantID, req *types.FriendRequest) (*types.FriendRequest, error) { s.created = req return req, nil } func (s *testRelationsStore) ListFriendRequests(_ types.TenantID, _ types.Uid, _, _, _ string, _ int) ([]types.FriendRequestListItem, string, error) { return s.friendRequests, "", nil } func (s *testRelationsStore) ResolveFriendRequest(_ types.TenantID, requestID string, actor types.Uid, status string, handledAt time.Time) (*types.FriendRequest, error) { return &types.FriendRequest{ TenantID: 7, RequestID: requestID, ApplicantID: 1, TargetID: actor, Status: status, HandledAt: &handledAt, CreatedAt: handledAt, UpdatedAt: handledAt, ExpiresAt: handledAt.Add(time.Hour), }, nil } func (s *testRelationsStore) ListFriends(_ types.TenantID, _ types.Uid, _, _, _ string, _ int) ([]types.FriendListItem, string, error) { return s.friends, "", nil } func (s *testRelationsStore) GetRelationshipContext(_ types.TenantID, userID, peerID types.Uid, topic string) (*types.RelationshipContext, error) { if s.context != nil { return s.context, nil } return &types.RelationshipContext{ TenantID: 7, UserID: userID, PeerID: peerID, Topic: topic, Relationship: types.RelationshipFriend, CanSend: true, }, nil } func (s *testRelationsStore) CanSendP2P(_ types.TenantID, _, _ types.Uid) (bool, string, error) { if s.canSend != nil { return *s.canSend, s.reason, nil } return true, types.RelationshipFriend, nil } func (s *testRelationsStore) BlockUser(tenantID types.TenantID, blockerID, blockedID types.Uid, now time.Time) (*types.UserBlock, error) { s.blocked = &types.UserBlock{TenantID: tenantID, BlockerID: blockerID, BlockedID: blockedID, CreatedAt: now, UpdatedAt: now} return s.blocked, nil } func (s *testRelationsStore) UnblockUser(_ types.TenantID, _, _ types.Uid) error { s.blocked = nil return nil } func (s *testRelationsStore) DeleteFriend(_ types.TenantID, _, _ types.Uid, _ time.Time) error { s.deleted = true return nil } func (s *testRelationsStore) UpdateFriendRemark(tenantID types.TenantID, userID, peerID types.Uid, remark string, now time.Time) (*types.Friendship, error) { return &types.Friendship{TenantID: tenantID, UserID: userID, PeerID: peerID, Status: types.RelationshipFriend, Remark: remark, Topic: userID.P2PName(peerID), CreatedAt: now, UpdatedAt: now}, nil } func (s *testRelationsStore) SearchUsers(_ types.TenantID, _ types.Uid, _, _, _ string, _ int) ([]types.UserSearchItem, string, error) { return s.searchResults, "", nil } func (s *testRelationsStore) ListBlocks(_ types.TenantID, _ types.Uid, _ string, _ int) ([]types.BlockListItem, string, error) { return s.blocks, "", nil } func (s *testVerificationStore) Create(_ types.TenantID, vc *types.VerificationCode) error { s.created = vc return s.err } func (s *testVerificationStore) Get(_ types.TenantID, _ string) (*types.VerificationCode, error) { if s.err != nil { return nil, s.err } if s.get == nil { return nil, types.ErrNotFound } return s.get, nil } func (s *testVerificationStore) GetActive(_ types.TenantID, _, _ string, _ time.Time) (*types.VerificationCode, error) { if s.err != nil { return nil, s.err } if s.active == nil { return nil, types.ErrNotFound } return s.active, nil } func (s *testVerificationStore) Fail(_ types.TenantID, _ string, _ types.VerificationState) error { s.failed = true return nil } func (s *testVerificationStore) MarkVerified(_ types.TenantID, _, tokenHash string, _ time.Time) error { s.verified = true s.tokenHash = tokenHash return nil } func (s *testVerificationStore) Consume(_ types.TenantID, _, tokenHash string, _ time.Time) error { s.consumed = true s.tokenHash = tokenHash return nil } func makeTestAPIKey(t *testing.T) string { t.Helper() globals.apiKeySalt = []byte("0123456789abcdef") data := make([]byte, apikeyLength) data[0] = 1 binary.LittleEndian.PutUint32(data[apikeyVersion:], uint32(0)) binary.LittleEndian.PutUint16(data[apikeyVersion+apikeyAppID:], uint16(1)) data[apikeyVersion+apikeyAppID+apikeySequence] = uint8(0) hasher := hmac.New(md5.New, globals.apiKeySalt) hasher.Write(data[:apikeyVersion+apikeyAppID+apikeySequence+apikeyWho]) copy(data[apikeyVersion+apikeyAppID+apikeySequence+apikeyWho:], hasher.Sum(nil)) return base64.URLEncoding.EncodeToString(data) } func decodeIMHTTPResponse(t *testing.T, rec *httptest.ResponseRecorder) imHTTPResponse { t.Helper() var resp imHTTPResponse if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { t.Fatalf("failed to decode response: %v; body=%s", err, rec.Body.String()) } return resp } func expectIMBearer(t *testing.T, ctrl *gomock.Controller, token string, tenantID types.TenantID, uid types.Uid) *mock_store.MockPersistentStorageInterface { t.Helper() oldStore := store.Store mockStore := mock_store.NewMockPersistentStorageInterface(ctrl) tokenAuth := mock_auth.NewMockAuthHandler(ctrl) mockStore.EXPECT().GetLogicalAuthHandler("token").Return(tokenAuth) tokenAuth.EXPECT().IsInitialized().Return(true) tokenAuth.EXPECT().Authenticate(auth.AuthContext{}, []byte(token)). Return(&auth.Rec{TenantID: tenantID, Uid: uid, AuthLevel: auth.LevelAuth}, nil, nil) store.Store = mockStore t.Cleanup(func() { store.Store = oldStore }) return mockStore } func TestServeIMTenantResolveSuccessFromHeader(t *testing.T) { oldTenants := store.Tenants store.Tenants = testTenantStore{tenant: &types.Tenant{ ID: 7, Code: "acme", Name: "Acme Corp", TenantDesc: ptrString("demo tenant"), State: types.TenantStateActive, }} defer func() { store.Tenants = oldTenants }() globals.servingAt = "https://im.example.com/" req := httptest.NewRequest(http.MethodPost, "/v1/im/tenants/resolve", nil) req.Header.Set("X-Tinode-APIKey", makeTestAPIKey(t)) req.Header.Set("X-Tenant-Code", " acme ") req.Header.Set("X-Request-ID", "req-1") rec := httptest.NewRecorder() serveIMTenantResolve(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) } resp := decodeIMHTTPResponse(t, rec) if resp.Code != 0 || resp.RequestID != "req-1" { t.Fatalf("unexpected response envelope: %#v", resp) } data := resp.Data.(map[string]any) tenant := data["tenant"].(map[string]any) if tenant["code"] != "acme" || tenant["name"] != "Acme Corp" { t.Fatalf("unexpected tenant payload: %#v", tenant) } if _, ok := tenant["id"]; ok { t.Fatalf("tenant payload must not expose internal id: %#v", tenant) } endpoints := data["endpoints"].(map[string]any) if endpoints["websocket"] != "https://im.example.com/v0/channels" { t.Fatalf("unexpected websocket endpoint: %#v", endpoints) } } func TestServeIMTenantResolveSuccessFromBody(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 }() globals.servingAt = "http://127.0.0.1:6060/" req := httptest.NewRequest(http.MethodPost, "/v1/im/tenants/resolve", bytes.NewBufferString(`{"tenant_code":"acme","platform":"android","app_version":"1.0.0"}`)) req.Header.Set("X-Tinode-APIKey", makeTestAPIKey(t)) rec := httptest.NewRecorder() serveIMTenantResolve(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) } resp := decodeIMHTTPResponse(t, rec) if resp.Code != 0 || resp.Reason != "" { t.Fatalf("unexpected response envelope: %#v", resp) } } func TestServeIMTenantResolveRequiresPost(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/v1/im/tenants/resolve", nil) rec := httptest.NewRecorder() serveIMTenantResolve(rec, req) if rec.Code != http.StatusMethodNotAllowed { t.Fatalf("status: got %d, want %d", rec.Code, http.StatusMethodNotAllowed) } resp := decodeIMHTTPResponse(t, rec) if resp.Reason != imReasonMethodNotAllowed { t.Fatalf("reason: got %q, want %q", resp.Reason, imReasonMethodNotAllowed) } } func TestServeIMTenantResolveRequiresAPIKey(t *testing.T) { req := httptest.NewRequest(http.MethodPost, "/v1/im/tenants/resolve", bytes.NewBufferString(`{"tenant_code":"acme"}`)) rec := httptest.NewRecorder() serveIMTenantResolve(rec, req) if rec.Code != http.StatusForbidden { t.Fatalf("status: got %d, want %d", rec.Code, http.StatusForbidden) } resp := decodeIMHTTPResponse(t, rec) if resp.Reason != imReasonAPIKeyRequired { t.Fatalf("reason: got %q, want %q", resp.Reason, imReasonAPIKeyRequired) } } func TestServeIMTenantResolveRejectsMalformedTenantCode(t *testing.T) { req := httptest.NewRequest(http.MethodPost, "/v1/im/tenants/resolve", bytes.NewBufferString(`{"tenant_code":"`+strings.Repeat("a", 65)+`"}`)) req.Header.Set("X-Tinode-APIKey", makeTestAPIKey(t)) rec := httptest.NewRecorder() serveIMTenantResolve(rec, req) if rec.Code != http.StatusBadRequest { t.Fatalf("status: got %d, want %d", rec.Code, http.StatusBadRequest) } resp := decodeIMHTTPResponse(t, rec) if resp.Reason != imReasonMalformedRequest { t.Fatalf("reason: got %q, want %q", resp.Reason, imReasonMalformedRequest) } } func TestServeIMTenantResolveHidesUnavailableTenantState(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 }() req := httptest.NewRequest(http.MethodPost, "/v1/im/tenants/resolve", bytes.NewBufferString(`{"tenant_code":"acme"}`)) req.Header.Set("X-Tinode-APIKey", makeTestAPIKey(t)) rec := httptest.NewRecorder() serveIMTenantResolve(rec, req) if rec.Code != http.StatusForbidden { t.Fatalf("status: got %d, want %d", rec.Code, http.StatusForbidden) } resp := decodeIMHTTPResponse(t, rec) if resp.Reason != imReasonTenantUnavailable { t.Fatalf("reason: got %q, want %q", resp.Reason, imReasonTenantUnavailable) } } func TestServeIMTenantResolveHidesMissingTenant(t *testing.T) { oldTenants := store.Tenants store.Tenants = testTenantStore{err: types.ErrNotFound} defer func() { store.Tenants = oldTenants }() req := httptest.NewRequest(http.MethodPost, "/v1/im/tenants/resolve", bytes.NewBufferString(`{"tenant_code":"acme"}`)) req.Header.Set("X-Tinode-APIKey", makeTestAPIKey(t)) rec := httptest.NewRecorder() serveIMTenantResolve(rec, req) if rec.Code != http.StatusNotFound { t.Fatalf("status: got %d, want %d", rec.Code, http.StatusNotFound) } resp := decodeIMHTTPResponse(t, rec) if resp.Reason != imReasonTenantUnavailable { t.Fatalf("reason: got %q, want %q", resp.Reason, imReasonTenantUnavailable) } } func TestServeIMVerificationCodesCreatesChallenge(t *testing.T) { oldTenants := store.Tenants oldVerifications := store.Verifications store.Tenants = testTenantStore{tenant: &types.Tenant{ ID: 7, Code: "acme", Name: "Acme Corp", State: types.TenantStateActive, }} verifications := &testVerificationStore{} store.Verifications = verifications defer func() { store.Tenants = oldTenants store.Verifications = oldVerifications }() req := httptest.NewRequest(http.MethodPost, "/v1/im/auth/verification-codes", bytes.NewBufferString(`{"scene":"register","phone":"+8613800138000"}`)) req.Header.Set("X-Tinode-APIKey", makeTestAPIKey(t)) req.Header.Set("X-Tenant-Code", "acme") rec := httptest.NewRecorder() serveIMVerificationCodes(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) } if verifications.created == nil { t.Fatal("expected challenge to be stored") } if verifications.created.TenantID != 7 || verifications.created.Scene != "register" { t.Fatalf("unexpected stored challenge: %#v", verifications.created) } if verifications.created.CodeHash == "" || strings.Contains(rec.Body.String(), verifications.created.CodeHash) { t.Fatalf("code hash missing or leaked: %#v body=%s", verifications.created, rec.Body.String()) } resp := decodeIMHTTPResponse(t, rec) data := resp.Data.(map[string]any) if data["challenge_id"] == "" || data["expires_in_seconds"].(float64) == 0 { t.Fatalf("unexpected response data: %#v", data) } } func TestServeIMVerificationCodesRateLimited(t *testing.T) { oldTenants := store.Tenants oldVerifications := store.Verifications store.Tenants = testTenantStore{tenant: &types.Tenant{ ID: 7, Code: "acme", Name: "Acme Corp", State: types.TenantStateActive, }} store.Verifications = &testVerificationStore{active: &types.VerificationCode{ TenantID: 7, ChallengeID: "challenge-1", State: types.VerificationStatePending, ResendAfter: time.Now().UTC().Add(time.Minute), }} defer func() { store.Tenants = oldTenants store.Verifications = oldVerifications }() req := httptest.NewRequest(http.MethodPost, "/v1/im/auth/verification-codes", bytes.NewBufferString(`{"scene":"register","phone":"+8613800138000"}`)) req.Header.Set("X-Tinode-APIKey", makeTestAPIKey(t)) req.Header.Set("X-Tenant-Code", "acme") rec := httptest.NewRecorder() serveIMVerificationCodes(rec, req) if rec.Code != http.StatusTooManyRequests { t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusTooManyRequests, rec.Body.String()) } resp := decodeIMHTTPResponse(t, rec) if resp.Reason != imReasonVerificationRate { t.Fatalf("reason: got %q, want %q", resp.Reason, imReasonVerificationRate) } } func TestServeIMVerificationCodeVerifySuccess(t *testing.T) { oldTenants := store.Tenants oldVerifications := store.Verifications store.Tenants = testTenantStore{tenant: &types.Tenant{ ID: 7, Code: "acme", Name: "Acme Corp", State: types.TenantStateActive, }} vc := &types.VerificationCode{ TenantID: 7, ChallengeID: "challenge-1", Scene: "register", PhoneHash: "phone-hash", CodeHash: hashIMSecret(7, "register", "challenge-1", "phone-hash", "123456"), State: types.VerificationStatePending, MaxAttempts: 5, ExpiresAt: time.Now().UTC().Add(time.Minute), } verifications := &testVerificationStore{get: vc} store.Verifications = verifications defer func() { store.Tenants = oldTenants store.Verifications = oldVerifications }() req := httptest.NewRequest(http.MethodPost, "/v1/im/auth/verification-codes/challenge-1/verify", bytes.NewBufferString(`{"code":"123456"}`)) req.Header.Set("X-Tinode-APIKey", makeTestAPIKey(t)) req.Header.Set("X-Tenant-Code", "acme") rec := httptest.NewRecorder() serveIMVerificationCodeVerify(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) } if !verifications.verified || verifications.tokenHash == "" { t.Fatalf("expected challenge to be marked verified: %#v", verifications) } resp := decodeIMHTTPResponse(t, rec) data := resp.Data.(map[string]any) if data["verification_token"] == "" { t.Fatalf("missing verification token: %#v", data) } } func TestServeIMVerificationCodeVerifyInvalidCode(t *testing.T) { oldTenants := store.Tenants oldVerifications := store.Verifications store.Tenants = testTenantStore{tenant: &types.Tenant{ ID: 7, Code: "acme", Name: "Acme Corp", State: types.TenantStateActive, }} store.Verifications = &testVerificationStore{get: &types.VerificationCode{ TenantID: 7, ChallengeID: "challenge-1", Scene: "register", PhoneHash: "phone-hash", CodeHash: hashIMSecret(7, "register", "challenge-1", "phone-hash", "123456"), State: types.VerificationStatePending, MaxAttempts: 5, ExpiresAt: time.Now().UTC().Add(time.Minute), }} defer func() { store.Tenants = oldTenants store.Verifications = oldVerifications }() req := httptest.NewRequest(http.MethodPost, "/v1/im/auth/verification-codes/challenge-1/verify", bytes.NewBufferString(`{"code":"654321"}`)) req.Header.Set("X-Tinode-APIKey", makeTestAPIKey(t)) req.Header.Set("X-Tenant-Code", "acme") rec := httptest.NewRecorder() serveIMVerificationCodeVerify(rec, req) if rec.Code != http.StatusUnauthorized { t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusUnauthorized, rec.Body.String()) } resp := decodeIMHTTPResponse(t, rec) if resp.Reason != imReasonCodeInvalid { t.Fatalf("reason: got %q, want %q", resp.Reason, imReasonCodeInvalid) } } func TestServeIMRealNameReturnsUnverified(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() expectIMBearer(t, ctrl, "token-1", 7, 42) oldSecurity := store.Security store.Security = &testSecurityStore{} defer func() { store.Security = oldSecurity }() req := httptest.NewRequest(http.MethodGet, "/v1/im/security/realname", nil) req.Header.Set("Authorization", "Bearer token-1") rec := httptest.NewRecorder() serveIMRealName(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) } data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) if data["status"] != "unverified" || data["can_submit"] != true { t.Fatalf("unexpected realname payload: %#v", data) } } func TestServeIMRealNameSubmissionCreatesPending(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() expectIMBearer(t, ctrl, "token-1", 7, 42) oldSecurity := store.Security security := &testSecurityStore{} store.Security = security defer func() { store.Security = oldSecurity }() req := httptest.NewRequest(http.MethodPost, "/v1/im/security/realname/submissions", bytes.NewBufferString(`{"name":"张三","id_type":"cn_id","id_number":"110101199003071234"}`)) req.Header.Set("Authorization", "Bearer token-1") req.Header.Set("Idempotency-Key", "idem-1") rec := httptest.NewRecorder() serveIMRealNameSubmissions(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) } if security.realName == nil || security.realName.Status != "pending" || security.realName.NameCipher == "张三" { t.Fatalf("unexpected stored realname: %#v", security.realName) } data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) if data["status"] != "pending" || data["submission_id"] == "" { t.Fatalf("unexpected response: %#v", data) } } func TestServeIMIndependentPasswordSetAndVerify(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() oldSecurity := store.Security security := &testSecurityStore{actionToken: &types.ActionToken{Action: "set_independent_password", Target: "youth_mode"}} store.Security = security defer func() { store.Security = oldSecurity }() expectIMBearer(t, ctrl, "token-set", 7, 42) req := httptest.NewRequest(http.MethodPost, "/v1/im/settings/independent-password", bytes.NewBufferString(`{"password":"2794","purpose":"youth_mode","action_token":"action.1"}`)) req.Header.Set("Authorization", "Bearer token-set") rec := httptest.NewRecorder() serveIMIndependentPassword(rec, req) if rec.Code != http.StatusOK { t.Fatalf("set status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) } if security.independentPassword == nil || bcrypt.CompareHashAndPassword(security.independentPassword.PasswordHash, []byte("2794")) != nil { t.Fatalf("password was not stored as a valid hash: %#v", security.independentPassword) } expectIMBearer(t, ctrl, "token-verify", 7, 42) req = httptest.NewRequest(http.MethodPost, "/v1/im/settings/independent-password/verify", bytes.NewBufferString(`{"password":"2794","purpose":"youth_mode","action":"close_youth_mode"}`)) req.Header.Set("Authorization", "Bearer token-verify") rec = httptest.NewRecorder() serveIMIndependentPasswordVerify(rec, req) if rec.Code != http.StatusOK { t.Fatalf("verify status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) } data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) if data["action"] != "close_youth_mode" || data["action_token"] == "" { t.Fatalf("unexpected verify response: %#v", data) } } func TestServeIMSecurityChallengeVerifyIssuesActionToken(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() expectIMBearer(t, ctrl, "token-1", 7, 42) oldVerifications := store.Verifications oldSecurity := store.Security security := &testSecurityStore{} store.Security = security store.Verifications = &testVerificationStore{get: &types.VerificationCode{ TenantID: 7, ChallengeID: "challenge-1", Scene: "sensitive_action", Action: "set_independent_password", PhoneHash: "phone-hash", TargetUser: 42, CodeHash: hashIMSecret(7, "sensitive_action", "challenge-1", "phone-hash", "123456"), State: types.VerificationStatePending, MaxAttempts: 5, ExpiresAt: time.Now().UTC().Add(time.Minute), }} defer func() { store.Verifications = oldVerifications store.Security = oldSecurity }() req := httptest.NewRequest(http.MethodPost, "/v1/im/security/challenges/challenge-1/verify", bytes.NewBufferString(`{"code":"123456"}`)) req.Header.Set("Authorization", "Bearer token-1") rec := httptest.NewRecorder() serveIMSecurityChallengeVerify(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) } if security.actionToken == nil || security.actionToken.Action != "set_independent_password" || security.actionToken.Target != "youth_mode" { t.Fatalf("unexpected action token: %#v", security.actionToken) } data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) if data["action"] != "set_independent_password" || data["action_token"] == "" { t.Fatalf("unexpected response: %#v", data) } } func TestServeIMBootstrapIncludesHomeCapabilities(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() expectIMBearer(t, ctrl, "token-1", 7, 42) oldSecurity := store.Security store.Security = &testSecurityStore{} defer func() { store.Security = oldSecurity }() req := httptest.NewRequest(http.MethodGet, "/v1/im/bootstrap", nil) req.Header.Set("Authorization", "Bearer token-1") rec := httptest.NewRecorder() serveIMBootstrap(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) } data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) if data["features"] == nil || data["permissions"] == nil || data["system_topic"] == nil || data["message_policy"] == nil { t.Fatalf("bootstrap missing home capabilities: %#v", data) } } func TestServeIMConversationSearch(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() expectIMBearer(t, ctrl, "token-1", 7, 42) oldUsers := store.Users users := mock_store.NewMockUsersPersistenceInterface(ctrl) store.Users = users defer func() { store.Users = oldUsers }() now := time.Now().UTC().Round(time.Millisecond) alice := types.Subscription{ ObjHeader: types.ObjHeader{UpdatedAt: now}, Topic: "p2pAlice", ReadSeqId: 2, RecvSeqId: 3, } alice.SetPublic(map[string]any{"fn": "Alice"}) alice.SetSeqId(5) alice.SetTouchedAt(now) bob := types.Subscription{ ObjHeader: types.ObjHeader{UpdatedAt: now}, Topic: "grpBob", ReadSeqId: 9, } bob.SetPublic(map[string]any{"fn": "Bob Group"}) bob.SetSeqId(9) bob.SetTouchedAt(now) users.EXPECT().GetTopics(types.TenantID(7), types.Uid(42), gomock.Any()).Return([]types.Subscription{alice, bob}, nil) req := httptest.NewRequest(http.MethodGet, "/v1/im/search/conversations?q=alice", nil) req.Header.Set("Authorization", "Bearer token-1") rec := httptest.NewRecorder() serveIMConversationSearch(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) } data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) items := data["items"].([]any) if len(items) != 1 { t.Fatalf("items length: got %d, want 1; data=%#v", len(items), data) } item := items[0].(map[string]any) if item["topic"] != "p2pAlice" || item["unread"].(float64) != 3 { t.Fatalf("unexpected conversation item: %#v", item) } } func TestServeIMSystemNotificationsListAndRead(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() oldSecurity := store.Security security := &testSecurityStore{notifications: []types.SystemNotification{{ TenantID: 7, UserID: 42, EventID: "evt-1", EventType: "security.new_device_login", Title: "新设备登录", Summary: "iPhone 登录", Action: types.KVMap{"type": "security_center"}, CreatedAt: time.Now().UTC().Round(time.Millisecond), }}} store.Security = security defer func() { store.Security = oldSecurity }() expectIMBearer(t, ctrl, "token-list", 7, 42) req := httptest.NewRequest(http.MethodGet, "/v1/im/system-notifications", nil) req.Header.Set("Authorization", "Bearer token-list") rec := httptest.NewRecorder() serveIMSystemNotifications(rec, req) if rec.Code != http.StatusOK { t.Fatalf("list status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) } data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) if len(data["items"].([]any)) != 1 { t.Fatalf("unexpected list response: %#v", data) } expectIMBearer(t, ctrl, "token-read", 7, 42) req = httptest.NewRequest(http.MethodPost, "/v1/im/system-notifications/evt-1/read", bytes.NewBufferString(`{}`)) req.Header.Set("Authorization", "Bearer token-read") req.Header.Set("Idempotency-Key", "read-1") rec = httptest.NewRecorder() serveIMSystemNotificationRead(rec, req) if rec.Code != http.StatusOK { t.Fatalf("read status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) } if security.readNotificationID != "evt-1" || security.notifications[0].ReadAt == nil { t.Fatalf("notification was not marked read: %#v", security) } } func TestServeIMMessageReceipts(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() expectIMBearer(t, ctrl, "token-1", 7, 42) oldSubs := store.Subs oldTopics := store.Topics subs := mock_store.NewMockSubsPersistenceInterface(ctrl) topics := mock_store.NewMockTopicsPersistenceInterface(ctrl) store.Subs = subs store.Topics = topics defer func() { store.Subs = oldSubs store.Topics = oldTopics }() mySub := &types.Subscription{ TenantID: 7, User: "42", Topic: "grpReceipt", ModeWant: types.ModeCFull, ModeGiven: types.ModeCFull, } subs.EXPECT().Get(types.TenantID(7), "grpReceipt", types.Uid(42), false).Return(mySub, nil) readMember := types.Subscription{User: "101", ReadSeqId: 8, RecvSeqId: 8} readMember.SetPublic(map[string]any{"fn": "Alice"}) deliveredMember := types.Subscription{User: "102", ReadSeqId: 4, RecvSeqId: 8} deliveredMember.SetPublic(map[string]any{"fn": "Bob"}) topics.EXPECT().GetUsers(types.TenantID(7), "grpReceipt", gomock.Any()).Return([]types.Subscription{readMember, deliveredMember}, nil) req := httptest.NewRequest(http.MethodGet, "/v1/im/topics/grpReceipt/messages/8/receipts", nil) req.Header.Set("Authorization", "Bearer token-1") rec := httptest.NewRecorder() serveIMMessageReceipts(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) } data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) if data["topic"] != "grpReceipt" || data["seq"].(float64) != 8 { t.Fatalf("unexpected receipt envelope: %#v", data) } if len(data["read"].([]any)) != 1 || len(data["delivered"].([]any)) != 2 { t.Fatalf("unexpected receipt items: %#v", data) } } func TestServeIMGroupMembers(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() expectIMBearer(t, ctrl, "token-1", 7, 42) oldSubs := store.Subs oldTopics := store.Topics subs := mock_store.NewMockSubsPersistenceInterface(ctrl) topics := mock_store.NewMockTopicsPersistenceInterface(ctrl) store.Subs = subs store.Topics = topics defer func() { store.Subs = oldSubs store.Topics = oldTopics }() subs.EXPECT().Get(types.TenantID(7), "grpMention", types.Uid(42), false).Return(&types.Subscription{ TenantID: 7, User: types.Uid(42).String(), Topic: "grpMention", ModeWant: types.ModeCFull, ModeGiven: types.ModeCFull, }, nil) alice := types.Subscription{User: types.Uid(101).UserId(), ModeWant: types.ModeCPublic, ModeGiven: types.ModeCPublic} alice.SetPublic(map[string]any{"display_name": "Alice"}) bob := types.Subscription{User: types.Uid(102).UserId(), ModeWant: types.ModeJoin, ModeGiven: types.ModeJoin} bob.SetPublic(map[string]any{"display_name": "Bob"}) topics.EXPECT().GetUsers(types.TenantID(7), "grpMention", gomock.Any()).Return([]types.Subscription{alice, bob}, nil) req := httptest.NewRequest(http.MethodGet, "/v1/im/groups/grpMention/members?query=ali&mentionable=true", nil) req.Header.Set("Authorization", "Bearer token-1") rec := httptest.NewRecorder() serveIMGroupResource(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) } data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) items := data["items"].([]any) if len(items) != 1 || items[0].(map[string]any)["display_name"] != "Alice" || items[0].(map[string]any)["mentionable"] != true { t.Fatalf("unexpected members response: %#v", data) } if data["permissions"].(map[string]any)["can_mention_all"] != true { t.Fatalf("expected can_mention_all permission: %#v", data) } } func TestServeIMGroupMemberProfile(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() expectIMBearer(t, ctrl, "token-1", 7, 42) oldSubs := store.Subs oldUsers := store.Users oldRelations := store.Relations subs := mock_store.NewMockSubsPersistenceInterface(ctrl) users := mock_store.NewMockUsersPersistenceInterface(ctrl) store.Subs = subs store.Users = users peer := types.Uid(101) store.Relations = &testRelationsStore{context: &types.RelationshipContext{ TenantID: 7, UserID: 42, PeerID: peer, Topic: types.Uid(42).P2PName(peer), Relationship: types.RelationshipNone, CanSend: false, }} defer func() { store.Subs = oldSubs store.Users = oldUsers store.Relations = oldRelations }() subs.EXPECT().Get(types.TenantID(7), "grpMention", types.Uid(42), false).Return(&types.Subscription{ TenantID: 7, User: types.Uid(42).String(), Topic: "grpMention", ModeWant: types.ModeCFull, ModeGiven: types.ModeCFull, }, nil) member := &types.Subscription{TenantID: 7, User: peer.UserId(), Topic: "grpMention", ModeWant: types.ModeJoin, ModeGiven: types.ModeJoin} member.SetPublic(map[string]any{"display_name": "Group Alice"}) subs.EXPECT().Get(types.TenantID(7), "grpMention", peer, false).Return(member, nil) users.EXPECT().Get(types.TenantID(7), peer).Return(&types.User{ ObjHeader: types.ObjHeader{Id: peer.String()}, TenantID: 7, Public: map[string]any{"display_name": "Alice", "signature": "hi"}, }, nil) req := httptest.NewRequest(http.MethodGet, "/v1/im/groups/grpMention/members/"+peer.UserId()+"/profile", nil) req.Header.Set("Authorization", "Bearer token-1") rec := httptest.NewRecorder() serveIMGroupResource(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) } data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) if data["relationship"] != types.RelationshipNone || data["signature"] != "hi" || data["role"] != "member" { t.Fatalf("unexpected group member profile response: %#v", data) } } func TestServeIMGroupList(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() expectIMBearer(t, ctrl, "token-1", 7, 42) oldUsers := store.Users users := mock_store.NewMockUsersPersistenceInterface(ctrl) store.Users = users defer func() { store.Users = oldUsers }() sub := types.Subscription{ TenantID: 7, User: types.Uid(42).String(), Topic: "grpAlpha", ModeWant: types.ModeCFull, ModeGiven: types.ModeCFull, ObjHeader: types.ObjHeader{UpdatedAt: time.Date(2026, 8, 12, 9, 0, 0, 0, time.UTC)}, } sub.SetPublic(map[string]any{"name": "Alpha", "avatar_file_id": "file-1"}) sub.SetSubCnt(3) users.EXPECT().GetSubs(types.TenantID(7), types.Uid(42)).Return([]types.Subscription{sub}, nil) req := httptest.NewRequest(http.MethodGet, "/v1/im/groups?category=owned&query=alp", nil) req.Header.Set("Authorization", "Bearer token-1") rec := httptest.NewRecorder() serveIMGroups(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) } data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) items := data["items"].([]any) if len(items) != 1 || items[0].(map[string]any)["name"] != "Alpha" || items[0].(map[string]any)["my_role"] != "owner" { t.Fatalf("unexpected group list response: %#v", data) } } func TestServeIMGroupCreate(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() oldStore := store.Store mockStore := mock_store.NewMockPersistentStorageInterface(ctrl) tokenAuth := mock_auth.NewMockAuthHandler(ctrl) mockStore.EXPECT().GetLogicalAuthHandler("token").Return(tokenAuth) tokenAuth.EXPECT().IsInitialized().Return(true) tokenAuth.EXPECT().Authenticate(auth.AuthContext{}, []byte("token-1")). Return(&auth.Rec{TenantID: 7, Uid: 42, AuthLevel: auth.LevelAuth}, nil, nil) mockStore.EXPECT().GetUidString().Return("newgroup") store.Store = mockStore oldTopics := store.Topics oldSubs := store.Subs topics := mock_store.NewMockTopicsPersistenceInterface(ctrl) subs := mock_store.NewMockSubsPersistenceInterface(ctrl) store.Topics = topics store.Subs = subs defer func() { store.Store = oldStore store.Topics = oldTopics store.Subs = oldSubs }() topics.EXPECT().Create(types.TenantID(7), gomock.Any(), types.Uid(42), gomock.Any()).DoAndReturn( func(_ types.TenantID, topic *types.Topic, _ types.Uid, _ any) error { if topic.Id != "grpnewgroup" { t.Fatalf("unexpected topic id: %s", topic.Id) } return nil }) subs.EXPECT().Create(types.TenantID(7), gomock.Any()).Return(nil) topics.EXPECT().UpdateSubCnt(types.TenantID(7), "grpnewgroup").Return(nil) topics.EXPECT().Get(types.TenantID(7), "grpnewgroup").Return(&types.Topic{ ObjHeader: types.ObjHeader{Id: "grpnewgroup"}, TenantID: 7, Owner: types.Uid(42).String(), SubCnt: 2, Public: map[string]any{"name": "New Group", "avatar_file_id": "file-1", "version": 1}, }, nil) subs.EXPECT().Get(types.TenantID(7), "grpnewgroup", types.Uid(42), false).Return(&types.Subscription{ TenantID: 7, User: types.Uid(42).String(), Topic: "grpnewgroup", ModeWant: types.ModeCFull, ModeGiven: types.ModeCFull, }, nil) req := httptest.NewRequest(http.MethodPost, "/v1/im/groups", bytes.NewBufferString(`{"name":"New Group","member_user_ids":["usrAgAAAAAAAAA"],"avatar_file_id":"file-1"}`)) req.Header.Set("Authorization", "Bearer token-1") rec := httptest.NewRecorder() serveIMGroups(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) } data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) if data["topic"] != "grpnewgroup" || data["name"] != "New Group" { t.Fatalf("unexpected group create response: %#v", data) } } func TestServeIMGroupDetailPatchSettingsProfileAndLeave(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() oldStore := store.Store mockStore := mock_store.NewMockPersistentStorageInterface(ctrl) tokenAuth := mock_auth.NewMockAuthHandler(ctrl) mockStore.EXPECT().GetLogicalAuthHandler("token").Return(tokenAuth).Times(3) tokenAuth.EXPECT().IsInitialized().Return(true).Times(3) tokenAuth.EXPECT().Authenticate(auth.AuthContext{}, []byte("token-1")). Return(&auth.Rec{TenantID: 7, Uid: 42, AuthLevel: auth.LevelAuth}, nil, nil).Times(3) store.Store = mockStore oldTopics := store.Topics oldSubs := store.Subs topics := mock_store.NewMockTopicsPersistenceInterface(ctrl) subs := mock_store.NewMockSubsPersistenceInterface(ctrl) store.Topics = topics store.Subs = subs defer func() { store.Store = oldStore store.Topics = oldTopics store.Subs = oldSubs }() selfSub := &types.Subscription{ TenantID: 7, User: types.Uid(42).String(), Topic: "grpAlpha", ModeWant: types.ModeCFull, ModeGiven: types.ModeCFull, Private: map[string]any{"version": 1, "profile_version": 1}, } group := &types.Topic{ ObjHeader: types.ObjHeader{Id: "grpAlpha"}, TenantID: 7, Owner: types.Uid(42).String(), SubCnt: 3, Public: map[string]any{"name": "Alpha", "version": 1}, } subs.EXPECT().Get(types.TenantID(7), "grpAlpha", types.Uid(42), false).Return(selfSub, nil).Times(3) topics.EXPECT().Get(types.TenantID(7), "grpAlpha").Return(group, nil) topics.EXPECT().Update(types.TenantID(7), "grpAlpha", gomock.Any()).Return(nil) topics.EXPECT().Get(types.TenantID(7), "grpAlpha").Return(&types.Topic{ ObjHeader: types.ObjHeader{Id: "grpAlpha"}, TenantID: 7, Owner: types.Uid(42).String(), SubCnt: 3, Public: map[string]any{"name": "Beta", "description": "hello", "version": 2}, }, nil) subs.EXPECT().Update(types.TenantID(7), "grpAlpha", types.Uid(42), gomock.Any()).Return(nil).Times(2) req := httptest.NewRequest(http.MethodPatch, "/v1/im/groups/grpAlpha", bytes.NewBufferString(`{"name":"Beta","description":"hello","version":1}`)) req.Header.Set("Authorization", "Bearer token-1") rec := httptest.NewRecorder() serveIMGroupResource(rec, req) if rec.Code != http.StatusOK { t.Fatalf("patch status: got %d body=%s", rec.Code, rec.Body.String()) } if data := decodeIMHTTPResponse(t, rec).Data.(map[string]any); data["name"] != "Beta" || data["version"].(float64) != 2 { t.Fatalf("unexpected patch response: %#v", data) } settingsReq := httptest.NewRequest(http.MethodPatch, "/v1/im/groups/grpAlpha/member-settings", bytes.NewBufferString(`{"pinned":true,"mute_notifications":true}`)) settingsReq.Header.Set("Authorization", "Bearer token-1") settingsRec := httptest.NewRecorder() serveIMGroupResource(settingsRec, settingsReq) if settingsRec.Code != http.StatusOK { t.Fatalf("settings status: got %d body=%s", settingsRec.Code, settingsRec.Body.String()) } if data := decodeIMHTTPResponse(t, settingsRec).Data.(map[string]any); data["pinned"] != true || data["mute_notifications"] != true { t.Fatalf("unexpected settings response: %#v", data) } profileReq := httptest.NewRequest(http.MethodPut, "/v1/im/groups/grpAlpha/members/me/profile", bytes.NewBufferString(`{"nickname":"群昵称","version":1}`)) profileReq.Header.Set("Authorization", "Bearer token-1") profileRec := httptest.NewRecorder() serveIMGroupResource(profileRec, profileReq) if profileRec.Code != http.StatusOK { t.Fatalf("profile status: got %d body=%s", profileRec.Code, profileRec.Body.String()) } if data := decodeIMHTTPResponse(t, profileRec).Data.(map[string]any); data["nickname"] != "群昵称" || data["version"].(float64) != 2 { t.Fatalf("unexpected profile response: %#v", data) } } func TestServeIMGroupLeave(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() expectIMBearer(t, ctrl, "token-1", 7, 42) oldTopics := store.Topics oldSubs := store.Subs topics := mock_store.NewMockTopicsPersistenceInterface(ctrl) subs := mock_store.NewMockSubsPersistenceInterface(ctrl) store.Topics = topics store.Subs = subs defer func() { store.Topics = oldTopics store.Subs = oldSubs }() subs.EXPECT().Get(types.TenantID(7), "grpAlpha", types.Uid(42), false).Return(&types.Subscription{ TenantID: 7, User: types.Uid(42).String(), Topic: "grpAlpha", ModeWant: types.ModeCPublic, ModeGiven: types.ModeCPublic, }, nil) subs.EXPECT().Delete(types.TenantID(7), "grpAlpha", types.Uid(42)).Return(nil) topics.EXPECT().UpdateSubCnt(types.TenantID(7), "grpAlpha").Return(nil) req := httptest.NewRequest(http.MethodPost, "/v1/im/groups/grpAlpha/leave", nil) req.Header.Set("Authorization", "Bearer token-1") rec := httptest.NewRecorder() serveIMGroupResource(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) } data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) if data["status"] != "left" || data["topic"] != "grpAlpha" { t.Fatalf("unexpected leave response: %#v", data) } } func TestServeIMGroupQRCodesAndResolve(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() oldStore := store.Store mockStore := mock_store.NewMockPersistentStorageInterface(ctrl) tokenAuth := mock_auth.NewMockAuthHandler(ctrl) mockStore.EXPECT().GetLogicalAuthHandler("token").Return(tokenAuth).Times(2) tokenAuth.EXPECT().IsInitialized().Return(true).Times(2) tokenAuth.EXPECT().Authenticate(auth.AuthContext{}, []byte("token-1")). Return(&auth.Rec{TenantID: 7, Uid: 42, AuthLevel: auth.LevelAuth}, nil, nil) tokenAuth.EXPECT().Authenticate(auth.AuthContext{}, []byte("token-2")). Return(&auth.Rec{TenantID: 7, Uid: 43, AuthLevel: auth.LevelAuth}, nil, nil) store.Store = mockStore oldTopics := store.Topics oldSubs := store.Subs topics := mock_store.NewMockTopicsPersistenceInterface(ctrl) subs := mock_store.NewMockSubsPersistenceInterface(ctrl) store.Topics = topics store.Subs = subs defer func() { store.Store = oldStore store.Topics = oldTopics store.Subs = oldSubs }() creatorSub := &types.Subscription{ TenantID: 7, User: types.Uid(42).String(), Topic: "grpAlpha", ModeWant: types.ModeCPublic, ModeGiven: types.ModeCPublic, } group := &types.Topic{ ObjHeader: types.ObjHeader{Id: "grpAlpha"}, TenantID: 7, Owner: types.Uid(42).String(), SubCnt: 3, Public: map[string]any{"name": "Alpha", "version": 1}, } subs.EXPECT().Get(types.TenantID(7), "grpAlpha", types.Uid(42), false).Return(creatorSub, nil) topics.EXPECT().Get(types.TenantID(7), "grpAlpha").Return(group, nil) topics.EXPECT().Get(types.TenantID(7), "grpAlpha").Return(group, nil) subs.EXPECT().Get(types.TenantID(7), "grpAlpha", types.Uid(43), false).Return(nil, nil) req := httptest.NewRequest(http.MethodPost, "/v1/im/groups/grpAlpha/qrcodes", bytes.NewBufferString(`{"expires_in_seconds":60}`)) req.Header.Set("Authorization", "Bearer token-1") rec := httptest.NewRecorder() serveIMGroupResource(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) } created := decodeIMHTTPResponse(t, rec).Data.(map[string]any) token := created["qr_token"].(string) if token == "" { t.Fatalf("unexpected group qr response: %#v", created) } resolveReq := httptest.NewRequest(http.MethodPost, "/v1/im/qrcodes/resolve", bytes.NewBufferString(`{"token":"`+token+`","purpose":"join_group"}`)) resolveReq.Header.Set("Authorization", "Bearer token-2") resolveRec := httptest.NewRecorder() serveIMQRCodesResolve(resolveRec, resolveReq) if resolveRec.Code != http.StatusOK { t.Fatalf("status: got %d, want %d; body=%s", resolveRec.Code, http.StatusOK, resolveRec.Body.String()) } resolved := decodeIMHTTPResponse(t, resolveRec).Data.(map[string]any) groupPayload := resolved["group"].(map[string]any) if resolved["status"] != "can_request" || groupPayload["name"] != "Alpha" { t.Fatalf("unexpected group qr resolve response: %#v", resolved) } } func TestServeIMGroupAnnouncementGovernanceJoinRequest(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() oldStore := store.Store mockStore := mock_store.NewMockPersistentStorageInterface(ctrl) tokenAuth := mock_auth.NewMockAuthHandler(ctrl) mockStore.EXPECT().GetLogicalAuthHandler("token").Return(tokenAuth).Times(3) tokenAuth.EXPECT().IsInitialized().Return(true).Times(3) tokenAuth.EXPECT().Authenticate(auth.AuthContext{}, []byte("token-1")). Return(&auth.Rec{TenantID: 7, Uid: 42, AuthLevel: auth.LevelAuth}, nil, nil).Times(3) mockStore.EXPECT().GetUidString().Return("ann-1") mockStore.EXPECT().GetUidString().Return("join-1") store.Store = mockStore oldTopics := store.Topics oldSubs := store.Subs topics := mock_store.NewMockTopicsPersistenceInterface(ctrl) subs := mock_store.NewMockSubsPersistenceInterface(ctrl) store.Topics = topics store.Subs = subs defer func() { store.Store = oldStore store.Topics = oldTopics store.Subs = oldSubs }() selfSub := &types.Subscription{TenantID: 7, User: types.Uid(42).String(), Topic: "grpAlpha", ModeWant: types.ModeCFull, ModeGiven: types.ModeCFull} group := &types.Topic{ObjHeader: types.ObjHeader{Id: "grpAlpha"}, TenantID: 7, Owner: types.Uid(42).String(), Aux: types.KVMap{}} subs.EXPECT().Get(types.TenantID(7), "grpAlpha", types.Uid(42), false).Return(selfSub, nil).Times(3) topics.EXPECT().Get(types.TenantID(7), "grpAlpha").Return(group, nil).Times(3) topics.EXPECT().Update(types.TenantID(7), "grpAlpha", gomock.Any()).Return(nil).Times(3) annReq := httptest.NewRequest(http.MethodPut, "/v1/im/groups/grpAlpha/announcements/current", bytes.NewBufferString(`{"content":"公告","version":1}`)) annReq.Header.Set("Authorization", "Bearer token-1") annRec := httptest.NewRecorder() serveIMGroupResource(annRec, annReq) if annRec.Code != http.StatusOK { t.Fatalf("announcement status: got %d body=%s", annRec.Code, annRec.Body.String()) } if data := decodeIMHTTPResponse(t, annRec).Data.(map[string]any); data["content"] != "公告" { t.Fatalf("unexpected announcement response: %#v", data) } govReq := httptest.NewRequest(http.MethodPatch, "/v1/im/groups/grpAlpha/governance", bytes.NewBufferString(`{"join_approval":true,"version":1}`)) govReq.Header.Set("Authorization", "Bearer token-1") govRec := httptest.NewRecorder() serveIMGroupResource(govRec, govReq) if govRec.Code != http.StatusOK { t.Fatalf("governance status: got %d body=%s", govRec.Code, govRec.Body.String()) } if data := decodeIMHTTPResponse(t, govRec).Data.(map[string]any); data["join_approval"] != true { t.Fatalf("unexpected governance response: %#v", data) } joinReq := httptest.NewRequest(http.MethodPost, "/v1/im/groups/grpAlpha/join-requests", bytes.NewBufferString(`{"message":"申请"}`)) joinReq.Header.Set("Authorization", "Bearer token-1") joinRec := httptest.NewRecorder() serveIMGroupResource(joinRec, joinReq) if joinRec.Code != http.StatusOK { t.Fatalf("join request status: got %d body=%s", joinRec.Code, joinRec.Body.String()) } if data := decodeIMHTTPResponse(t, joinRec).Data.(map[string]any); data["status"] != "pending" { t.Fatalf("unexpected join request response: %#v", data) } } func TestServeIMGroupAdminsMutesAndOwnerTransfer(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() oldStore := store.Store mockStore := mock_store.NewMockPersistentStorageInterface(ctrl) tokenAuth := mock_auth.NewMockAuthHandler(ctrl) mockStore.EXPECT().GetLogicalAuthHandler("token").Return(tokenAuth).Times(3) tokenAuth.EXPECT().IsInitialized().Return(true).Times(3) tokenAuth.EXPECT().Authenticate(auth.AuthContext{}, []byte("token-1")). Return(&auth.Rec{TenantID: 7, Uid: 42, AuthLevel: auth.LevelAuth}, nil, nil).Times(3) store.Store = mockStore oldTopics := store.Topics oldSubs := store.Subs topics := mock_store.NewMockTopicsPersistenceInterface(ctrl) subs := mock_store.NewMockSubsPersistenceInterface(ctrl) store.Topics = topics store.Subs = subs defer func() { store.Store = oldStore store.Topics = oldTopics store.Subs = oldSubs }() owner := types.Uid(42) target := types.Uid(43) ownerSub := &types.Subscription{TenantID: 7, User: owner.String(), Topic: "grpAlpha", ModeWant: types.ModeCFull, ModeGiven: types.ModeCFull} group := &types.Topic{ObjHeader: types.ObjHeader{Id: "grpAlpha"}, TenantID: 7, Owner: owner.String(), Aux: types.KVMap{}} member := types.Subscription{TenantID: 7, User: target.UserId(), Topic: "grpAlpha", ModeWant: types.ModeCPublic, ModeGiven: types.ModeCPublic} members := []types.Subscription{*ownerSub, member} subs.EXPECT().Get(types.TenantID(7), "grpAlpha", owner, false).Return(ownerSub, nil).Times(3) topics.EXPECT().GetUsers(types.TenantID(7), "grpAlpha", gomock.Any()).Return(members, nil).Times(2) subs.EXPECT().Update(types.TenantID(7), "grpAlpha", target, gomock.Any()).Return(nil).Times(2) topics.EXPECT().Get(types.TenantID(7), "grpAlpha").Return(group, nil) topics.EXPECT().Update(types.TenantID(7), "grpAlpha", gomock.Any()).Return(nil) subs.EXPECT().Get(types.TenantID(7), "grpAlpha", target, false).Return(&member, nil) subs.EXPECT().Update(types.TenantID(7), "grpAlpha", owner, gomock.Any()).Return(nil) topics.EXPECT().OwnerChange(types.TenantID(7), "grpAlpha", target).Return(nil) adminReq := httptest.NewRequest(http.MethodPost, "/v1/im/groups/grpAlpha/admins/batch", bytes.NewBufferString(`{"user_ids":["`+target.UserId()+`"],"version":1}`)) adminReq.Header.Set("Authorization", "Bearer token-1") adminRec := httptest.NewRecorder() serveIMGroupResource(adminRec, adminReq) if adminRec.Code != http.StatusOK { t.Fatalf("admin status: got %d body=%s", adminRec.Code, adminRec.Body.String()) } muteReq := httptest.NewRequest(http.MethodPost, "/v1/im/groups/grpAlpha/mutes", bytes.NewBufferString(`{"members":[{"user_id":"`+target.UserId()+`"}],"reason":"test"}`)) muteReq.Header.Set("Authorization", "Bearer token-1") muteRec := httptest.NewRecorder() serveIMGroupResource(muteRec, muteReq) if muteRec.Code != http.StatusOK { t.Fatalf("mute status: got %d body=%s", muteRec.Code, muteRec.Body.String()) } transferReq := httptest.NewRequest(http.MethodPost, "/v1/im/groups/grpAlpha/owner-transfer", bytes.NewBufferString(`{"target_user_id":"`+target.UserId()+`","version":1}`)) transferReq.Header.Set("Authorization", "Bearer token-1") transferRec := httptest.NewRecorder() serveIMGroupResource(transferRec, transferReq) if transferRec.Code != http.StatusOK { t.Fatalf("transfer status: got %d body=%s", transferRec.Code, transferRec.Body.String()) } if data := decodeIMHTTPResponse(t, transferRec).Data.(map[string]any); data["owner"] != target.UserId() { t.Fatalf("unexpected transfer response: %#v", data) } } func TestServeIMFavoritesListDetailBatchDelete(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() oldStore := store.Store mockStore := mock_store.NewMockPersistentStorageInterface(ctrl) tokenAuth := mock_auth.NewMockAuthHandler(ctrl) mockStore.EXPECT().GetLogicalAuthHandler("token").Return(tokenAuth).Times(3) tokenAuth.EXPECT().IsInitialized().Return(true).Times(3) tokenAuth.EXPECT().Authenticate(auth.AuthContext{}, []byte("token-1")). Return(&auth.Rec{TenantID: 7, Uid: 42, AuthLevel: auth.LevelAuth}, nil, nil).Times(3) store.Store = mockStore oldFavorites := store.Favorites now := time.Date(2026, 8, 13, 9, 0, 0, 0, time.UTC) favorites := &testFavoritesStore{items: []types.FavoriteItem{{ TenantID: 7, FavoriteID: "fav_1", UserID: 42, ItemType: "text", SourceTopic: "grpAlpha", SourceSeq: 3, Snapshot: map[string]any{"text": "hello"}, Availability: "available", CollectedAt: now, }}} store.Favorites = favorites defer func() { store.Store = oldStore store.Favorites = oldFavorites }() req := httptest.NewRequest(http.MethodGet, "/v1/im/favorites?type=text", nil) req.Header.Set("Authorization", "Bearer token-1") rec := httptest.NewRecorder() serveIMFavorites(rec, req) if rec.Code != http.StatusOK { t.Fatalf("list status: got %d body=%s", rec.Code, rec.Body.String()) } data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) items := data["items"].([]any) if len(items) != 1 || items[0].(map[string]any)["favorite_id"] != "fav_1" { t.Fatalf("unexpected favorites list: %#v", data) } detailReq := httptest.NewRequest(http.MethodGet, "/v1/im/favorites/fav_1", nil) detailReq.Header.Set("Authorization", "Bearer token-1") detailRec := httptest.NewRecorder() serveIMFavoriteResource(detailRec, detailReq) if detailRec.Code != http.StatusOK { t.Fatalf("detail status: got %d body=%s", detailRec.Code, detailRec.Body.String()) } deleteReq := httptest.NewRequest(http.MethodPost, "/v1/im/favorites/batch-delete", bytes.NewBufferString(`{"favorite_ids":["fav_1"]}`)) deleteReq.Header.Set("Authorization", "Bearer token-1") deleteRec := httptest.NewRecorder() serveIMFavoriteResource(deleteRec, deleteReq) if deleteRec.Code != http.StatusOK || len(favorites.deleted) != 1 { t.Fatalf("delete status: got %d body=%s deleted=%#v", deleteRec.Code, deleteRec.Body.String(), favorites.deleted) } } func TestServeIMFavoriteForwardSavesMessages(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() oldStore := store.Store mockStore := mock_store.NewMockPersistentStorageInterface(ctrl) tokenAuth := mock_auth.NewMockAuthHandler(ctrl) mockStore.EXPECT().GetLogicalAuthHandler("token").Return(tokenAuth) tokenAuth.EXPECT().IsInitialized().Return(true) tokenAuth.EXPECT().Authenticate(auth.AuthContext{}, []byte("token-1")). Return(&auth.Rec{TenantID: 7, Uid: 42, AuthLevel: auth.LevelAuth}, nil, nil) store.Store = mockStore oldFavorites := store.Favorites oldSubs := store.Subs oldMessages := store.Messages now := time.Date(2026, 8, 13, 9, 0, 0, 0, time.UTC) store.Favorites = &testFavoritesStore{items: []types.FavoriteItem{{ TenantID: 7, FavoriteID: "fav_1", UserID: 42, ItemType: "text", SourceTopic: "grpAlpha", SourceSeq: 3, Snapshot: map[string]any{"text": "hello"}, Availability: "available", CollectedAt: now, }}} subs := mock_store.NewMockSubsPersistenceInterface(ctrl) messages := mock_store.NewMockMessagesPersistenceInterface(ctrl) store.Subs = subs store.Messages = messages defer func() { store.Store = oldStore store.Favorites = oldFavorites store.Subs = oldSubs store.Messages = oldMessages }() subs.EXPECT().Get(types.TenantID(7), "grpBeta", types.Uid(42), false).Return(&types.Subscription{ TenantID: 7, User: types.Uid(42).String(), Topic: "grpBeta", ModeWant: types.ModeCPublic, ModeGiven: types.ModeCPublic, }, nil) messages.EXPECT().Save(types.TenantID(7), gomock.Any(), gomock.Nil(), true).DoAndReturn( func(_ types.TenantID, msg *types.Message, _ []string, _ bool) (*types.MessageSaveResult, error) { if msg.Topic != "grpBeta" || msg.From != types.Uid(42).String() { t.Fatalf("unexpected forwarded message: %#v", msg) } if msg.Head["x-im-forwarded"] != true { t.Fatalf("forward marker not set: %#v", msg.Head) } content := msg.Content.(map[string]any) if content["favorite_id"] != "fav_1" || content["comment"] != "FYI" { t.Fatalf("unexpected forward content: %#v", content) } return &types.MessageSaveResult{SeqId: 11, MarkedReadBySender: true}, nil }) req := httptest.NewRequest(http.MethodPost, "/v1/im/favorites/forward", bytes.NewBufferString(`{"favorite_ids":["fav_1"],"target_topics":["grpBeta"],"comment":"FYI"}`)) req.Header.Set("Authorization", "Bearer token-1") rec := httptest.NewRecorder() serveIMFavoriteResource(rec, req) if rec.Code != http.StatusOK { t.Fatalf("forward status: got %d body=%s", rec.Code, rec.Body.String()) } items := decodeIMHTTPResponse(t, rec).Data.(map[string]any)["items"].([]any) if got := items[0].(map[string]any); got["status"] != "sent" || got["seq"].(float64) != 11 { t.Fatalf("unexpected forward response: %#v", got) } } func TestServeIMSecurityURLCheck(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() oldStore := store.Store mockStore := mock_store.NewMockPersistentStorageInterface(ctrl) tokenAuth := mock_auth.NewMockAuthHandler(ctrl) mockStore.EXPECT().GetLogicalAuthHandler("token").Return(tokenAuth).Times(2) tokenAuth.EXPECT().IsInitialized().Return(true).Times(2) tokenAuth.EXPECT().Authenticate(auth.AuthContext{}, []byte("token-1")). Return(&auth.Rec{TenantID: 7, Uid: 42, AuthLevel: auth.LevelAuth}, nil, nil).Times(2) store.Store = mockStore defer func() { store.Store = oldStore }() allowReq := httptest.NewRequest(http.MethodPost, "/v1/im/security/urls/check", bytes.NewBufferString(`{"url":"https://example.com/a","source":"message"}`)) allowReq.Header.Set("Authorization", "Bearer token-1") allowRec := httptest.NewRecorder() serveIMSecurityURLCheck(allowRec, allowReq) if allowRec.Code != http.StatusOK { t.Fatalf("allow status: got %d body=%s", allowRec.Code, allowRec.Body.String()) } if data := decodeIMHTTPResponse(t, allowRec).Data.(map[string]any); data["decision"] != "allow" { t.Fatalf("unexpected allow response: %#v", data) } blockReq := httptest.NewRequest(http.MethodPost, "/v1/im/security/urls/check", bytes.NewBufferString(`{"url":"http://127.0.0.1/admin"}`)) blockReq.Header.Set("Authorization", "Bearer token-1") blockRec := httptest.NewRecorder() serveIMSecurityURLCheck(blockRec, blockReq) if blockRec.Code != http.StatusOK { t.Fatalf("block status: got %d body=%s", blockRec.Code, blockRec.Body.String()) } if data := decodeIMHTTPResponse(t, blockRec).Data.(map[string]any); data["decision"] != "block" || data["risk_challenge_id"] == "" { t.Fatalf("unexpected block response: %#v", data) } } func TestServeIMMessageSearchContextAndMedia(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() oldStore := store.Store mockStore := mock_store.NewMockPersistentStorageInterface(ctrl) tokenAuth := mock_auth.NewMockAuthHandler(ctrl) mockStore.EXPECT().GetLogicalAuthHandler("token").Return(tokenAuth).Times(3) tokenAuth.EXPECT().IsInitialized().Return(true).Times(3) tokenAuth.EXPECT().Authenticate(auth.AuthContext{}, []byte("token-1")). Return(&auth.Rec{TenantID: 7, Uid: 42, AuthLevel: auth.LevelAuth}, nil, nil).Times(3) store.Store = mockStore oldSubs := store.Subs oldMessages := store.Messages subs := mock_store.NewMockSubsPersistenceInterface(ctrl) messages := mock_store.NewMockMessagesPersistenceInterface(ctrl) store.Subs = subs store.Messages = messages defer func() { store.Store = oldStore store.Subs = oldSubs store.Messages = oldMessages }() subs.EXPECT().Get(types.TenantID(7), "grpAlpha", types.Uid(42), false).Return(&types.Subscription{TenantID: 7, User: types.Uid(42).String(), Topic: "grpAlpha", ModeWant: types.ModeCPublic, ModeGiven: types.ModeCPublic}, nil).Times(3) textMsg := types.Message{TenantID: 7, SeqId: 3, Topic: "grpAlpha", From: types.Uid(42).String(), Head: types.KVMap{"type": "text"}, Content: map[string]any{"text": "hello world"}, ObjHeader: types.ObjHeader{CreatedAt: time.Date(2026, 8, 13, 9, 0, 0, 0, time.UTC)}} fileMsg := types.Message{TenantID: 7, SeqId: 4, Topic: "grpAlpha", From: types.Uid(42).String(), Head: types.KVMap{"type": "image"}, Content: map[string]any{"file_id": "file-1", "mime": "image/png"}, ObjHeader: types.ObjHeader{CreatedAt: time.Date(2026, 8, 13, 9, 1, 0, 0, time.UTC)}} messages.EXPECT().Search(types.TenantID(7), "grpAlpha", types.Uid(42), "hello", "text", "", 50).Return([]types.Message{textMsg}, "", nil) messages.EXPECT().GetAll(types.TenantID(7), "grpAlpha", types.Uid(42), gomock.Any()).Return([]types.Message{textMsg}, nil) messages.EXPECT().Search(types.TenantID(7), "grpAlpha", types.Uid(42), "", "image", "", 50).Return([]types.Message{fileMsg}, "", nil) searchReq := httptest.NewRequest(http.MethodGet, "/v1/im/topics/grpAlpha/messages/search?q=hello&type=text", nil) searchReq.Header.Set("Authorization", "Bearer token-1") searchRec := httptest.NewRecorder() serveIMTopicMessageAction(searchRec, searchReq) if searchRec.Code != http.StatusOK { t.Fatalf("search status: got %d body=%s", searchRec.Code, searchRec.Body.String()) } contextReq := httptest.NewRequest(http.MethodGet, "/v1/im/topics/grpAlpha/messages/3/context", nil) contextReq.Header.Set("Authorization", "Bearer token-1") contextRec := httptest.NewRecorder() serveIMTopicMessageAction(contextRec, contextReq) if contextRec.Code != http.StatusOK { t.Fatalf("context status: got %d body=%s", contextRec.Code, contextRec.Body.String()) } mediaReq := httptest.NewRequest(http.MethodGet, "/v1/im/topics/grpAlpha/media?type=image", nil) mediaReq.Header.Set("Authorization", "Bearer token-1") mediaRec := httptest.NewRecorder() serveIMTopicMessageAction(mediaRec, mediaReq) if mediaRec.Code != http.StatusOK { t.Fatalf("media status: got %d body=%s", mediaRec.Code, mediaRec.Body.String()) } } func TestServeIMMeQRCodesAndResolve(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() oldStore := store.Store mockStore := mock_store.NewMockPersistentStorageInterface(ctrl) tokenAuth := mock_auth.NewMockAuthHandler(ctrl) mockStore.EXPECT().GetLogicalAuthHandler("token").Return(tokenAuth).Times(2) tokenAuth.EXPECT().IsInitialized().Return(true).Times(2) tokenAuth.EXPECT().Authenticate(auth.AuthContext{}, []byte("token-1")). Return(&auth.Rec{TenantID: 7, Uid: 1, AuthLevel: auth.LevelAuth}, nil, nil) tokenAuth.EXPECT().Authenticate(auth.AuthContext{}, []byte("token-2")). Return(&auth.Rec{TenantID: 7, Uid: 2, AuthLevel: auth.LevelAuth}, nil, nil) store.Store = mockStore oldUsers := store.Users oldRelations := store.Relations users := mock_store.NewMockUsersPersistenceInterface(ctrl) store.Users = users owner := types.Uid(1) viewer := types.Uid(2) users.EXPECT().Get(types.TenantID(7), owner).Return(&types.User{ ObjHeader: types.ObjHeader{Id: owner.String()}, TenantID: 7, Public: map[string]any{"display_name": "Owner"}, }, nil) users.EXPECT().Get(types.TenantID(7), owner).Return(&types.User{ ObjHeader: types.ObjHeader{Id: owner.String()}, TenantID: 7, Public: map[string]any{"display_name": "Owner"}, }, nil) store.Relations = &testRelationsStore{context: &types.RelationshipContext{ TenantID: 7, UserID: viewer, PeerID: owner, Topic: viewer.P2PName(owner), Relationship: types.RelationshipNone, CanSend: false, }} defer func() { store.Store = oldStore store.Users = oldUsers store.Relations = oldRelations }() req := httptest.NewRequest(http.MethodPost, "/v1/im/me/qrcodes", bytes.NewBufferString(`{"purpose":"add_friend","expires_in_seconds":60}`)) req.Header.Set("Authorization", "Bearer token-1") rec := httptest.NewRecorder() serveIMMeQRCodes(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) } created := decodeIMHTTPResponse(t, rec).Data.(map[string]any) token := created["qr_token"].(string) if token == "" || created["qr_payload"] == "" { t.Fatalf("unexpected qr create response: %#v", created) } resolveReq := httptest.NewRequest(http.MethodPost, "/v1/im/qrcodes/resolve", bytes.NewBufferString(`{"token":"`+token+`","purpose":"add_friend"}`)) resolveReq.Header.Set("Authorization", "Bearer token-2") resolveRec := httptest.NewRecorder() serveIMQRCodesResolve(resolveRec, resolveReq) if resolveRec.Code != http.StatusOK { t.Fatalf("status: got %d, want %d; body=%s", resolveRec.Code, http.StatusOK, resolveRec.Body.String()) } resolved := decodeIMHTTPResponse(t, resolveRec).Data.(map[string]any) user := resolved["user"].(map[string]any) if resolved["purpose"] != "add_friend" || user["action"] != "add" || user["display_name"] != "Owner" { t.Fatalf("unexpected qr resolve response: %#v", resolved) } } func TestServeIMMessageRecall(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() expectIMBearer(t, ctrl, "token-1", 7, 42) oldSubs := store.Subs oldMessages := store.Messages subs := mock_store.NewMockSubsPersistenceInterface(ctrl) messages := mock_store.NewMockMessagesPersistenceInterface(ctrl) store.Subs = subs store.Messages = messages defer func() { store.Subs = oldSubs store.Messages = oldMessages }() subs.EXPECT().Get(types.TenantID(7), "grpRecall", types.Uid(42), false).Return(&types.Subscription{ TenantID: 7, User: "42", Topic: "grpRecall", ModeWant: types.ModeCFull, ModeGiven: types.ModeCFull, }, nil) messages.EXPECT().GetOne(types.TenantID(7), "grpRecall", 9).Return(&types.Message{ TenantID: 7, SeqId: 9, Topic: "grpRecall", From: types.Uid(42).String(), Version: 1, ObjHeader: types.ObjHeader{CreatedAt: time.Now().Add(-time.Second)}, }, nil) messages.EXPECT().Recall(types.TenantID(7), "grpRecall", 9, types.Uid(42), 1, gomock.Any()).Return(&types.Message{ TenantID: 7, SeqId: 9, Topic: "grpRecall", From: "42", Version: 2, RecalledAt: ptrTime(time.Now().UTC()), RecalledBy: "42", ObjHeader: types.ObjHeader{UpdatedAt: time.Now().UTC()}, }, nil) req := httptest.NewRequest(http.MethodPost, "/v1/im/topics/grpRecall/messages/9/recall", bytes.NewBufferString(`{"version":1}`)) req.Header.Set("Authorization", "Bearer token-1") rec := httptest.NewRecorder() serveIMMessageRecall(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) } data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) if data["status"] != "recalled" || data["version"].(float64) != 2 { t.Fatalf("unexpected recall response: %#v", data) } } func TestServeIMMessageRevision(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() expectIMBearer(t, ctrl, "token-1", 7, 42) oldSubs := store.Subs oldMessages := store.Messages subs := mock_store.NewMockSubsPersistenceInterface(ctrl) messages := mock_store.NewMockMessagesPersistenceInterface(ctrl) store.Subs = subs store.Messages = messages defer func() { store.Subs = oldSubs store.Messages = oldMessages }() subs.EXPECT().Get(types.TenantID(7), "grpEdit", types.Uid(42), false).Return(&types.Subscription{ TenantID: 7, User: "42", Topic: "grpEdit", ModeWant: types.ModeCFull, ModeGiven: types.ModeCFull, }, nil) messages.EXPECT().GetOne(types.TenantID(7), "grpEdit", 3).Return(&types.Message{ TenantID: 7, SeqId: 3, Topic: "grpEdit", From: types.Uid(42).String(), Version: 1, Head: types.KVMap{"type": "text"}, Content: "old", ObjHeader: types.ObjHeader{CreatedAt: time.Now().Add(-time.Second)}, }, nil) messages.EXPECT().Edit(types.TenantID(7), "grpEdit", 3, types.Uid(42), 1, gomock.Any(), "new", "", gomock.Any()).Return(&types.Message{ TenantID: 7, SeqId: 3, Topic: "grpEdit", From: "42", Version: 2, Head: types.KVMap{"type": "text"}, Content: "new", ObjHeader: types.ObjHeader{UpdatedAt: time.Now().UTC()}, }, nil) req := httptest.NewRequest(http.MethodPost, "/v1/im/topics/grpEdit/messages/3/revisions", bytes.NewBufferString(`{"version":1,"content":"new"}`)) req.Header.Set("Authorization", "Bearer token-1") rec := httptest.NewRecorder() serveIMMessageRevision(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) } data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) if data["status"] != "edited" || data["version"].(float64) != 2 || data["content"] != "new" { t.Fatalf("unexpected revision response: %#v", data) } } func TestServeIMConversationContext(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() expectIMBearer(t, ctrl, "token-1", 7, 1) oldRelations := store.Relations peer := types.Uid(2) topic := types.Uid(1).P2PName(peer) store.Relations = &testRelationsStore{context: &types.RelationshipContext{ TenantID: 7, UserID: 1, PeerID: peer, Topic: topic, Relationship: types.RelationshipFriend, CanSend: true, }} defer func() { store.Relations = oldRelations }() req := httptest.NewRequest(http.MethodGet, "/v1/im/conversations/"+topic+"/context", nil) req.Header.Set("Authorization", "Bearer token-1") rec := httptest.NewRecorder() serveIMConversationContext(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) } data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) if data["relationship"] != types.RelationshipFriend || data["can_send"] != true { t.Fatalf("unexpected context response: %#v", data) } } func TestServeIMFriendRequestCreate(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() mockStore := expectIMBearer(t, ctrl, "token-1", 7, 1) mockStore.EXPECT().GetUidString().Return("request-1") oldRelations := store.Relations relations := &testRelationsStore{} store.Relations = relations defer func() { store.Relations = oldRelations }() req := httptest.NewRequest(http.MethodPost, "/v1/im/friend-requests", bytes.NewBufferString(`{"target_user_id":"`+types.Uid(2).UserId()+`","message":"你好","source":"search"}`)) req.Header.Set("Authorization", "Bearer token-1") req.Header.Set("Idempotency-Key", "fr-1") rec := httptest.NewRecorder() serveIMFriendRequests(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) } data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) if data["status"] != types.FriendRequestPending || relations.created == nil || relations.created.IdempotencyKey != "fr-1" { t.Fatalf("unexpected friend request response: %#v created=%#v", data, relations.created) } } func TestServeIMFriendRequestList(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() expectIMBearer(t, ctrl, "token-1", 7, 1) oldRelations := store.Relations now := time.Now().UTC().Round(time.Millisecond) relations := &testRelationsStore{friendRequests: []types.FriendRequestListItem{{ Request: types.FriendRequest{ TenantID: 7, RequestID: "frq_1", ApplicantID: 2, TargetID: 1, Message: "hi", Source: "search", Status: types.FriendRequestPending, CreatedAt: now, UpdatedAt: now, ExpiresAt: now.Add(time.Hour), }, Peer: &types.User{ObjHeader: types.ObjHeader{Id: types.Uid(2).String()}, TenantID: 7, Public: map[string]any{"display_name": "Alice"}}, Direction: "inbound", }}} store.Relations = relations defer func() { store.Relations = oldRelations }() req := httptest.NewRequest(http.MethodGet, "/v1/im/friend-requests?direction=inbound&status=pending", nil) req.Header.Set("Authorization", "Bearer token-1") rec := httptest.NewRecorder() serveIMFriendRequests(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) } data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) items := data["items"].([]any) if len(items) != 1 || items[0].(map[string]any)["direction"] != "inbound" { t.Fatalf("unexpected friend request list: %#v", data) } } func TestServeIMFriendsList(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() expectIMBearer(t, ctrl, "token-1", 7, 1) oldRelations := store.Relations now := time.Now().UTC().Round(time.Millisecond) peer := types.Uid(2) relations := &testRelationsStore{friends: []types.FriendListItem{{ Friendship: types.Friendship{ TenantID: 7, UserID: 1, PeerID: peer, Status: types.RelationshipFriend, Remark: "小明", Topic: types.Uid(1).P2PName(peer), CreatedAt: now, UpdatedAt: now, }, Peer: &types.User{ObjHeader: types.ObjHeader{Id: peer.String()}, TenantID: 7, Public: map[string]any{"display_name": "Ming"}}, }}} store.Relations = relations defer func() { store.Relations = oldRelations }() req := httptest.NewRequest(http.MethodGet, "/v1/im/friends?query=ming", nil) req.Header.Set("Authorization", "Bearer token-1") rec := httptest.NewRecorder() serveIMFriends(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) } data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) items := data["items"].([]any) if len(items) != 1 || items[0].(map[string]any)["display_name"] != "小明" { t.Fatalf("unexpected friends list: %#v", data) } } func TestServeIMFriendRemark(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() expectIMBearer(t, ctrl, "token-1", 7, 1) oldRelations := store.Relations peer := types.Uid(2) store.Relations = &testRelationsStore{} defer func() { store.Relations = oldRelations }() req := httptest.NewRequest(http.MethodPatch, "/v1/im/friends/"+peer.UserId(), bytes.NewBufferString(`{"remark":"小明"}`)) req.Header.Set("Authorization", "Bearer token-1") rec := httptest.NewRecorder() serveIMFriendResource(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) } data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) if data["relationship"] != types.RelationshipFriend || data["topic"] == "" { t.Fatalf("unexpected friend remark response: %#v", data) } } func TestServeIMUserSearch(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() expectIMBearer(t, ctrl, "token-1", 7, 1) oldRelations := store.Relations peer := types.Uid(2) store.Relations = &testRelationsStore{searchResults: []types.UserSearchItem{{ User: &types.User{ObjHeader: types.ObjHeader{Id: peer.String()}, TenantID: 7, Public: map[string]any{"display_name": "Alice", "avatar_file_id": "file-1"}}, Relationship: types.RelationshipNone, CanAdd: true, Action: "add", }}} defer func() { store.Relations = oldRelations }() req := httptest.NewRequest(http.MethodGet, "/v1/im/users/search?q=alice&type=account", nil) req.Header.Set("Authorization", "Bearer token-1") rec := httptest.NewRecorder() serveIMUserSearch(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) } data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) items := data["items"].([]any) if len(items) != 1 || items[0].(map[string]any)["relationship"] != types.RelationshipNone || items[0].(map[string]any)["can_add"] != true { t.Fatalf("unexpected search response: %#v", data) } } func TestServeIMUserProfile(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() expectIMBearer(t, ctrl, "token-1", 7, 1) oldUsers := store.Users oldRelations := store.Relations users := mock_store.NewMockUsersPersistenceInterface(ctrl) peer := types.Uid(2) users.EXPECT().Get(types.TenantID(7), peer).Return(&types.User{ ObjHeader: types.ObjHeader{Id: peer.String()}, TenantID: 7, Public: map[string]any{"display_name": "Alice", "avatar_file_id": "file-1", "signature": "hello"}, }, nil) store.Users = users store.Relations = &testRelationsStore{context: &types.RelationshipContext{ TenantID: 7, UserID: 1, PeerID: peer, Topic: types.Uid(1).P2PName(peer), Relationship: types.RelationshipFriend, CanSend: true, }} defer func() { store.Users = oldUsers store.Relations = oldRelations }() req := httptest.NewRequest(http.MethodGet, "/v1/im/users/"+peer.UserId()+"/profile", nil) req.Header.Set("Authorization", "Bearer token-1") rec := httptest.NewRecorder() serveIMUserResource(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) } data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) if data["relationship"] != types.RelationshipFriend || data["signature"] != "hello" || data["topic"] == "" { t.Fatalf("unexpected profile response: %#v", data) } } func TestServeIMBlocks(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() expectIMBearer(t, ctrl, "token-1", 7, 1) oldRelations := store.Relations now := time.Date(2026, 8, 12, 9, 0, 0, 0, time.UTC) peer := types.Uid(2) store.Relations = &testRelationsStore{blocks: []types.BlockListItem{{ Block: types.UserBlock{ TenantID: 7, BlockerID: 1, BlockedID: peer, CreatedAt: now, UpdatedAt: now, }, User: &types.User{ObjHeader: types.ObjHeader{Id: peer.String()}, TenantID: 7, Public: map[string]any{"display_name": "Blocked User"}}, }}} defer func() { store.Relations = oldRelations }() req := httptest.NewRequest(http.MethodGet, "/v1/im/blocks", nil) req.Header.Set("Authorization", "Bearer token-1") rec := httptest.NewRecorder() serveIMBlocks(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) } data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) items := data["items"].([]any) if len(items) != 1 || items[0].(map[string]any)["user_id"] != peer.UserId() { t.Fatalf("unexpected blocks response: %#v", data) } } func TestServeIMBlocksBatchDelete(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish() expectIMBearer(t, ctrl, "token-1", 7, 1) oldRelations := store.Relations store.Relations = &testRelationsStore{} defer func() { store.Relations = oldRelations }() peer := types.Uid(2) req := httptest.NewRequest(http.MethodPost, "/v1/im/blocks/batch-delete", bytes.NewBufferString(`{"user_ids":["`+peer.UserId()+`"]}`)) req.Header.Set("Authorization", "Bearer token-1") rec := httptest.NewRecorder() serveIMBlockUser(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) } data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) items := data["items"].([]any) if len(items) != 1 || items[0].(map[string]any)["ok"] != true { t.Fatalf("unexpected batch delete response: %#v", data) } } func ptrTime(t time.Time) *time.Time { return &t } func ptrString(s string) *string { return &s }