8554 lines
322 KiB
Go
8554 lines
322 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/hmac"
|
|
"crypto/md5"
|
|
"encoding/base64"
|
|
"encoding/binary"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"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/media"
|
|
"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 testHTTPInfrastructureStore struct {
|
|
deadLetters []types.KafkaDeadLetter
|
|
}
|
|
|
|
type testTenantEntryStore struct {
|
|
upload *types.PublicUpload
|
|
application *types.TenantApplication
|
|
feedback *types.TenantEntryFeedback
|
|
}
|
|
|
|
func (s *testTenantEntryStore) CreatePublicUpload(upload *types.PublicUpload) (*types.PublicUpload, error) {
|
|
now := time.Now().UTC().Round(time.Millisecond)
|
|
upload.CreatedAt = now
|
|
upload.UploadedAt = now
|
|
s.upload = upload
|
|
return upload, nil
|
|
}
|
|
|
|
func (s *testTenantEntryStore) CreateTenantApplication(application *types.TenantApplication) (*types.TenantApplication, error) {
|
|
now := time.Now().UTC().Round(time.Millisecond)
|
|
application.Status = types.TenantApplicationStatusReceived
|
|
application.EstimatedReviewHour = 24
|
|
application.SubmittedAt = now
|
|
s.application = application
|
|
return application, nil
|
|
}
|
|
|
|
func (s *testTenantEntryStore) CreateTenantEntryFeedback(feedback *types.TenantEntryFeedback) (*types.TenantEntryFeedback, error) {
|
|
now := time.Now().UTC().Round(time.Millisecond)
|
|
feedback.Status = types.TenantEntryFeedbackReceived
|
|
feedback.SubmittedAt = now
|
|
s.feedback = feedback
|
|
return feedback, nil
|
|
}
|
|
|
|
type testPublicMediaHandler struct {
|
|
def *media.PublicFileDef
|
|
data string
|
|
}
|
|
|
|
func (h *testPublicMediaHandler) Init(string) error { return nil }
|
|
func (h *testPublicMediaHandler) Headers(types.TenantID, string, *url.URL, http.Header, bool) (http.Header, int, error) {
|
|
return nil, 0, nil
|
|
}
|
|
func (h *testPublicMediaHandler) Upload(*types.FileDef, io.Reader) (string, int64, error) {
|
|
return "", 0, types.ErrUnsupported
|
|
}
|
|
func (h *testPublicMediaHandler) Download(types.TenantID, string) (*types.FileDef, media.ReadSeekCloser, error) {
|
|
return nil, nil, types.ErrUnsupported
|
|
}
|
|
func (h *testPublicMediaHandler) Delete([]string) error { return nil }
|
|
func (h *testPublicMediaHandler) GetIdFromUrl(string) types.Uid {
|
|
return types.ZeroUid
|
|
}
|
|
func (h *testPublicMediaHandler) UploadPublic(def *media.PublicFileDef, file io.Reader) (string, int64, error) {
|
|
data, _ := io.ReadAll(file)
|
|
h.def = def
|
|
h.data = string(data)
|
|
def.Location = "public/" + def.Purpose + "/" + def.ID
|
|
def.ETag = "etag"
|
|
return def.Location, int64(len(data)), nil
|
|
}
|
|
|
|
func (s *testHTTPInfrastructureStore) BeginKafkaConsume(_ types.TenantID, record *types.KafkaConsumeRecord, _ time.Time) (*types.KafkaConsumeRecord, bool, error) {
|
|
copy := *record
|
|
copy.Status = types.KafkaConsumeProcessing
|
|
return ©, true, nil
|
|
}
|
|
|
|
func (s *testHTTPInfrastructureStore) MarkKafkaConsumeSucceeded(_ types.TenantID, _, _ string, _ time.Time) error {
|
|
return nil
|
|
}
|
|
|
|
func (s *testHTTPInfrastructureStore) MarkKafkaConsumeFailed(_ types.TenantID, eventID, group, reason, message string, _ time.Time) (*types.KafkaConsumeRecord, error) {
|
|
return &types.KafkaConsumeRecord{EventID: eventID, ConsumerGroup: group, Status: types.KafkaConsumeFailed, RetryCount: 1, LastReason: reason, LastError: message}, nil
|
|
}
|
|
|
|
func (s *testHTTPInfrastructureStore) MarkKafkaConsumeDead(_ types.TenantID, dead *types.KafkaDeadLetter, _ time.Time) error {
|
|
if dead != nil {
|
|
s.deadLetters = append(s.deadLetters, *dead)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *testHTTPInfrastructureStore) ListKafkaDeadLetters(_ types.TenantID, _ types.KafkaDeadLetterListOptions) (*types.KafkaDeadLetterPage, error) {
|
|
return &types.KafkaDeadLetterPage{}, nil
|
|
}
|
|
|
|
func (s *testHTTPInfrastructureStore) GetKafkaDeadLetter(_ types.TenantID, _ string) (*types.KafkaDeadLetter, error) {
|
|
return nil, types.ErrNotFound
|
|
}
|
|
|
|
func (s *testHTTPInfrastructureStore) MarkKafkaDeadLetter(_ types.TenantID, _, _, _, _ string, _ time.Time) error {
|
|
return nil
|
|
}
|
|
|
|
func (s *testHTTPInfrastructureStore) MarkKafkaDeadLetterReplay(_ types.TenantID, deadID, _, _ string, _ time.Time) (*types.KafkaDeadLetter, error) {
|
|
return &types.KafkaDeadLetter{DeadID: deadID, Status: types.KafkaDeadStatusPendingReplay}, nil
|
|
}
|
|
|
|
type testRelationsStore struct {
|
|
context *types.RelationshipContext
|
|
err error
|
|
created *types.FriendRequest
|
|
blocked *types.UserBlock
|
|
deleted bool
|
|
canSend *bool
|
|
reason string
|
|
pendingCount int
|
|
countTenant types.TenantID
|
|
countUser types.Uid
|
|
friends []types.FriendListItem
|
|
friendRequests []types.FriendRequestListItem
|
|
searchResults []types.UserSearchItem
|
|
blocks []types.BlockListItem
|
|
deleteTenant types.TenantID
|
|
deleteUser types.Uid
|
|
deletePeer types.Uid
|
|
}
|
|
|
|
type testFavoritesStore struct {
|
|
items []types.FavoriteItem
|
|
created *types.FavoriteItem
|
|
deleted []string
|
|
}
|
|
|
|
type testCallsStore struct {
|
|
items []types.CallRecord
|
|
topic string
|
|
listed bool
|
|
tenant types.TenantID
|
|
user types.Uid
|
|
limit int
|
|
cursor string
|
|
next string
|
|
accepted bool
|
|
acceptTenant types.TenantID
|
|
acceptCallID string
|
|
acceptSeq int64
|
|
acceptedAt time.Time
|
|
}
|
|
|
|
type testThirdPartyConfigStore map[string]string
|
|
|
|
func (s testThirdPartyConfigStore) Get(_ string, keys ...string) (map[string]string, error) {
|
|
result := make(map[string]string, len(keys))
|
|
for _, key := range keys {
|
|
result[key] = s[key]
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
type testRealNameConfigStore struct {
|
|
policy *types.RealNamePolicy
|
|
err error
|
|
}
|
|
|
|
func (s testRealNameConfigStore) Policy(tenantID types.TenantID) (*types.RealNamePolicy, error) {
|
|
if s.err != nil {
|
|
return nil, s.err
|
|
}
|
|
if s.policy == nil {
|
|
return &types.RealNamePolicy{TenantID: tenantID, ReviewPolicy: types.RealNameReviewPolicyNone}, nil
|
|
}
|
|
policy := *s.policy
|
|
policy.TenantID = tenantID
|
|
policy.Normalize()
|
|
return &policy, nil
|
|
}
|
|
|
|
type testConversationsStore struct {
|
|
topic string
|
|
settings *types.ConversationSettings
|
|
settingsPatch types.ConversationSettingsPatch
|
|
draft *types.TopicDraft
|
|
putDraftCalled bool
|
|
}
|
|
|
|
type testUserSecurityStore struct {
|
|
unreadByPrefix map[string]int
|
|
notifications []types.SystemNotification
|
|
err error
|
|
}
|
|
|
|
type testProfilesStore struct {
|
|
profile *types.UserProfile
|
|
privacy *types.UserPrivacySettings
|
|
}
|
|
|
|
type testAppReleaseStore struct {
|
|
release *types.AppRelease
|
|
err error
|
|
}
|
|
|
|
type testDevicePushTokenStore struct {
|
|
token *types.DevicePushToken
|
|
tenantID types.TenantID
|
|
deletedTenantID types.TenantID
|
|
deletedUserID types.Uid
|
|
deletedDeviceID string
|
|
}
|
|
|
|
type testSafetyStore struct {
|
|
riskChallenge *types.RiskChallenge
|
|
riskErr error
|
|
reasons []types.AbuseReason
|
|
abuseReport *types.AbuseReport
|
|
feedback *types.FeedbackSubmission
|
|
tenantID types.TenantID
|
|
uid types.Uid
|
|
idempotency string
|
|
decision string
|
|
actionTokenID string
|
|
targetType string
|
|
}
|
|
|
|
type testGroupBusinessStore struct {
|
|
settings *types.GroupSettings
|
|
member *types.GroupMember
|
|
joinRequests []types.GroupJoinRequest
|
|
announcement *types.GroupAnnouncement
|
|
announcements map[string]*types.GroupAnnouncement
|
|
mutes []types.GroupMute
|
|
files []types.GroupFile
|
|
invitations []types.GroupInvitation
|
|
listMembers []types.GroupMember
|
|
listMembersOpts types.GroupMemberListOptions
|
|
listMembersNext string
|
|
updatedSettings types.GroupSettingsPatch
|
|
updatedMembers []types.GroupMemberPatch
|
|
updatedRoleMode types.AccessMode
|
|
leftTopic string
|
|
leftUID types.Uid
|
|
updateSettingsErr error
|
|
getSettingsErr error
|
|
updateMemberErr error
|
|
}
|
|
|
|
func (s *testGroupBusinessStore) CreateGroup(_ types.TenantID, _ *types.Topic, _ types.Uid, _ any,
|
|
settings *types.GroupSettings, _ []types.Uid, _ any, _ time.Time) error {
|
|
s.settings = settings
|
|
return nil
|
|
}
|
|
|
|
func (s *testGroupBusinessStore) CreateSettings(_ types.TenantID, settings *types.GroupSettings) (*types.GroupSettings, error) {
|
|
s.settings = settings
|
|
return settings, nil
|
|
}
|
|
|
|
func (s *testGroupBusinessStore) GetSettings(_ types.TenantID, topic string) (*types.GroupSettings, error) {
|
|
if s.getSettingsErr != nil {
|
|
return nil, s.getSettingsErr
|
|
}
|
|
if s.settings != nil {
|
|
return s.settings, nil
|
|
}
|
|
return &types.GroupSettings{Topic: topic, OwnerUserID: types.Uid(42), Name: "Alpha", GroupStatus: types.GroupStatusActive, MemberUploadFiles: true, MemberPrivateChat: true, MemberInvite: true, MaxAdminCount: groupMaxAdminCount, MemberCount: 3, Version: 1}, nil
|
|
}
|
|
|
|
func (s *testGroupBusinessStore) UpdateSettings(_ types.TenantID, topic string, patch types.GroupSettingsPatch) (*types.GroupSettings, error) {
|
|
s.updatedSettings = patch
|
|
if s.updateSettingsErr != nil {
|
|
return nil, s.updateSettingsErr
|
|
}
|
|
settings, _ := s.GetSettings(0, topic)
|
|
if patch.Name != nil {
|
|
settings.Name = *patch.Name
|
|
}
|
|
if patch.Description != nil {
|
|
settings.Description = *patch.Description
|
|
}
|
|
if patch.JoinApproval != nil {
|
|
settings.JoinApproval = *patch.JoinApproval
|
|
}
|
|
if patch.OwnerUserID != nil {
|
|
settings.OwnerUserID = *patch.OwnerUserID
|
|
}
|
|
settings.Version++
|
|
s.settings = settings
|
|
return settings, nil
|
|
}
|
|
|
|
func (s *testGroupBusinessStore) Dissolve(_ types.TenantID, topic string, actor types.Uid, reason string, now time.Time) (*types.GroupSettings, error) {
|
|
settings, _ := s.GetSettings(0, topic)
|
|
settings.GroupStatus = "dissolved"
|
|
settings.DissolvedBy = actor
|
|
settings.DissolvedAt = &now
|
|
settings.Version++
|
|
s.settings = settings
|
|
return settings, nil
|
|
}
|
|
|
|
func (s *testGroupBusinessStore) UpsertMember(_ types.TenantID, member *types.GroupMember) (*types.GroupMember, error) {
|
|
s.member = member
|
|
return member, nil
|
|
}
|
|
|
|
func (s *testGroupBusinessStore) GetMember(_ types.TenantID, topic string, uid types.Uid) (*types.GroupMember, error) {
|
|
if s.member != nil {
|
|
return s.member, nil
|
|
}
|
|
return &types.GroupMember{Topic: topic, UserID: uid, Role: types.GroupMemberRoleMember, MemberStatus: types.GroupMemberStatusActive, Version: 1}, nil
|
|
}
|
|
|
|
func (s *testGroupBusinessStore) ListMembers(_ types.TenantID, _ string, opts types.GroupMemberListOptions) ([]types.GroupMember, string, error) {
|
|
s.listMembersOpts = opts
|
|
return s.listMembers, s.listMembersNext, nil
|
|
}
|
|
|
|
func (s *testGroupBusinessStore) UpdateMember(_ types.TenantID, topic string, uid types.Uid, patch types.GroupMemberPatch) (*types.GroupMember, error) {
|
|
if s.updateMemberErr != nil {
|
|
return nil, s.updateMemberErr
|
|
}
|
|
s.updatedMembers = append(s.updatedMembers, patch)
|
|
return &types.GroupMember{Topic: topic, UserID: uid, Role: defaultStringPtr(patch.Role, types.GroupMemberRoleMember), MemberStatus: types.GroupMemberStatusActive, Version: 2}, nil
|
|
}
|
|
|
|
func (s *testGroupBusinessStore) CreateInvitation(_ types.TenantID, inv *types.GroupInvitation) (*types.GroupInvitation, error) {
|
|
if inv.JoinRequestID != "" {
|
|
for i := len(s.invitations) - 1; i >= 0; i-- {
|
|
existing := &s.invitations[i]
|
|
if existing.Topic == inv.Topic && existing.TargetUserID == inv.TargetUserID &&
|
|
existing.Status == types.GroupInvitationStatusPending && existing.JoinRequestID != "" {
|
|
for j := len(s.joinRequests) - 1; j >= 0; j-- {
|
|
request := &s.joinRequests[j]
|
|
if request.RequestID == existing.JoinRequestID && request.Status == types.GroupJoinRequestStatusPending {
|
|
return existing, nil
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
s.invitations = append(s.invitations, *inv)
|
|
if inv.JoinRequestID != "" {
|
|
expiresAt := inv.ExpiresAt
|
|
s.joinRequests = append(s.joinRequests, types.GroupJoinRequest{
|
|
RequestID: inv.JoinRequestID,
|
|
Topic: inv.Topic,
|
|
ApplicantUserID: inv.TargetUserID,
|
|
InviterUserID: inv.InviterUserID,
|
|
Source: "invitation",
|
|
Message: inv.Message,
|
|
Status: types.GroupJoinRequestStatusPending,
|
|
ExpiresAt: &expiresAt,
|
|
IdempotencyKey: inv.IdempotencyKey,
|
|
Version: 1,
|
|
CreatedAt: inv.CreatedAt,
|
|
UpdatedAt: inv.UpdatedAt,
|
|
})
|
|
}
|
|
return inv, nil
|
|
}
|
|
|
|
func (s *testGroupBusinessStore) AcceptInvitation(_ types.TenantID, invitationID string, uid types.Uid, _ any, now time.Time) (*types.GroupInvitation, error) {
|
|
for i := range s.invitations {
|
|
if s.invitations[i].InvitationID == invitationID {
|
|
s.invitations[i].TargetUserID = uid
|
|
if s.invitations[i].JoinRequestID != "" {
|
|
return &s.invitations[i], nil
|
|
}
|
|
s.invitations[i].Status = types.GroupInvitationStatusJoined
|
|
s.invitations[i].Result = "joined"
|
|
s.invitations[i].HandledAt = &now
|
|
return &s.invitations[i], nil
|
|
}
|
|
}
|
|
return &types.GroupInvitation{InvitationID: invitationID, Topic: "grp_test", TargetUserID: uid, Status: types.GroupInvitationStatusJoined, Result: "joined", ExpiresAt: now.Add(time.Hour), HandledAt: &now}, nil
|
|
}
|
|
|
|
func (s *testGroupBusinessStore) CreateJoinRequest(_ types.TenantID, req *types.GroupJoinRequest) (*types.GroupJoinRequest, error) {
|
|
for i := len(s.joinRequests) - 1; i >= 0; i-- {
|
|
existing := &s.joinRequests[i]
|
|
if existing.Topic == req.Topic && existing.ApplicantUserID == req.ApplicantUserID &&
|
|
existing.Status == types.GroupJoinRequestStatusPending {
|
|
return existing, nil
|
|
}
|
|
}
|
|
now := time.Now().UTC()
|
|
req.CreatedAt = now
|
|
req.UpdatedAt = now
|
|
s.joinRequests = append(s.joinRequests, *req)
|
|
return req, nil
|
|
}
|
|
|
|
func (s *testGroupBusinessStore) ListJoinRequests(_ types.TenantID, _ string, status, _ string, _ int) ([]types.GroupJoinRequest, string, error) {
|
|
if status == "" || status == "all" {
|
|
return s.joinRequests, "", nil
|
|
}
|
|
out := []types.GroupJoinRequest{}
|
|
for _, item := range s.joinRequests {
|
|
if item.Status == status {
|
|
out = append(out, item)
|
|
}
|
|
}
|
|
return out, "", nil
|
|
}
|
|
|
|
func (s *testGroupBusinessStore) ResolveJoinRequest(_ types.TenantID, topic, requestID string, actor types.Uid, action, _ string, _ int, _ any, now time.Time) (*types.GroupJoinRequest, error) {
|
|
status := types.GroupJoinRequestStatusRejected
|
|
if action == "accept" {
|
|
status = types.GroupJoinRequestStatusAccepted
|
|
}
|
|
return &types.GroupJoinRequest{RequestID: requestID, Topic: topic, ApplicantUserID: types.Uid(43), Status: status, HandledBy: actor, HandledAt: &now, Version: 2, CreatedAt: now, UpdatedAt: now}, nil
|
|
}
|
|
|
|
func (s *testGroupBusinessStore) LatestAnnouncement(_ types.TenantID, topic string) (*types.GroupAnnouncement, error) {
|
|
if s.announcement == nil {
|
|
return nil, types.ErrNotFound
|
|
}
|
|
return s.announcement, nil
|
|
}
|
|
|
|
func (s *testGroupBusinessStore) GetAnnouncement(_ types.TenantID, _ string, announcementID string) (*types.GroupAnnouncement, error) {
|
|
if s.announcements != nil {
|
|
if ann := s.announcements[announcementID]; ann != nil {
|
|
return ann, nil
|
|
}
|
|
}
|
|
if s.announcement != nil && s.announcement.AnnouncementID == announcementID {
|
|
return s.announcement, nil
|
|
}
|
|
return nil, types.ErrNotFound
|
|
}
|
|
|
|
func (s *testGroupBusinessStore) PutAnnouncement(_ types.TenantID, ann *types.GroupAnnouncement, _ int) (*types.GroupAnnouncement, error) {
|
|
ann.Version = 2
|
|
if ann.PublishedAt.IsZero() {
|
|
ann.PublishedAt = time.Now().UTC()
|
|
}
|
|
s.announcement = ann
|
|
return ann, nil
|
|
}
|
|
|
|
func (s *testGroupBusinessStore) ReadAnnouncement(_ types.TenantID, _, _ string, _ types.Uid, _ time.Time) error {
|
|
return nil
|
|
}
|
|
|
|
func (s *testGroupBusinessStore) ListMutes(_ types.TenantID, _ string, _ string, _ int) ([]types.GroupMute, string, error) {
|
|
return s.mutes, "", nil
|
|
}
|
|
|
|
func (s *testGroupBusinessStore) GetMute(_ types.TenantID, _ string, uid types.Uid) (*types.GroupMute, error) {
|
|
for i := range s.mutes {
|
|
if s.mutes[i].UserID == uid && s.mutes[i].Status == types.GroupMuteStatusActive {
|
|
return &s.mutes[i], nil
|
|
}
|
|
}
|
|
return nil, types.ErrNotFound
|
|
}
|
|
|
|
func (s *testGroupBusinessStore) UpsertMute(_ types.TenantID, mute *types.GroupMute) (*types.GroupMute, error) {
|
|
mute.Version = 1
|
|
s.mutes = append(s.mutes, *mute)
|
|
return mute, nil
|
|
}
|
|
|
|
func (s *testGroupBusinessStore) DeleteMute(_ types.TenantID, _ string, _ types.Uid, _ types.Uid, _ time.Time) error {
|
|
return nil
|
|
}
|
|
|
|
func (s *testGroupBusinessStore) ListFiles(_ types.TenantID, _ string, _ string, _ string, _ string, _ int) ([]types.GroupFile, string, error) {
|
|
return s.files, "", nil
|
|
}
|
|
|
|
func (s *testGroupBusinessStore) CreateFile(_ types.TenantID, file *types.GroupFile) (*types.GroupFile, error) {
|
|
if file.GroupFileID == "" {
|
|
file.GroupFileID = "gfile_test"
|
|
}
|
|
now := time.Now().UTC().Round(time.Millisecond)
|
|
file.CreatedAt = now
|
|
file.UpdatedAt = now
|
|
s.files = append(s.files, *file)
|
|
return file, nil
|
|
}
|
|
|
|
func (s *testGroupBusinessStore) DeleteFile(_ types.TenantID, topic, groupFileID string, actor types.Uid, _ bool, now time.Time) (*types.GroupFile, error) {
|
|
for i := range s.files {
|
|
if s.files[i].Topic == topic && s.files[i].GroupFileID == groupFileID {
|
|
s.files[i].Status = "deleted"
|
|
s.files[i].DeletedBy = actor
|
|
s.files[i].DeletedAt = &now
|
|
return &s.files[i], nil
|
|
}
|
|
}
|
|
return &types.GroupFile{Topic: topic, GroupFileID: groupFileID, Status: "deleted", DeletedBy: actor, DeletedAt: &now, CreatedAt: now, UpdatedAt: now}, nil
|
|
}
|
|
|
|
func (s *testGroupBusinessStore) InviteMember(_ types.TenantID, topic string, inviter, target types.Uid,
|
|
message, idempotencyKey string, _ any, now time.Time) (bool, error) {
|
|
for _, invitation := range s.invitations {
|
|
if invitation.Topic == topic && invitation.TargetUserID == target {
|
|
return false, nil
|
|
}
|
|
}
|
|
s.invitations = append(s.invitations, types.GroupInvitation{Topic: topic, InviterUserID: inviter,
|
|
TargetUserID: target, Message: message, IdempotencyKey: idempotencyKey,
|
|
Status: types.GroupInvitationStatusJoined, CreatedAt: now, UpdatedAt: now})
|
|
return true, nil
|
|
}
|
|
|
|
func (s *testGroupBusinessStore) Leave(_ types.TenantID, topic string, uid types.Uid, _ time.Time) error {
|
|
s.leftTopic = topic
|
|
s.leftUID = uid
|
|
return nil
|
|
}
|
|
|
|
func (s *testGroupBusinessStore) UpdateRole(_ types.TenantID, topic string, _ types.Uid, target types.Uid,
|
|
role string, mode types.AccessMode, _ time.Time) error {
|
|
s.updatedMembers = append(s.updatedMembers, types.GroupMemberPatch{Role: &role})
|
|
s.updatedRoleMode = mode
|
|
s.member = &types.GroupMember{Topic: topic, UserID: target, Role: role, MemberStatus: types.GroupMemberStatusActive}
|
|
return nil
|
|
}
|
|
|
|
func (s *testGroupBusinessStore) TransferOwner(_ types.TenantID, topic string, _, target types.Uid, _ int, _ time.Time) error {
|
|
settings, _ := s.GetSettings(0, topic)
|
|
settings.OwnerUserID = target
|
|
s.settings = settings
|
|
return nil
|
|
}
|
|
|
|
func (s *testGroupBusinessStore) CanPost(_ types.TenantID, topic string, uid types.Uid, admin bool, now time.Time) (bool, error) {
|
|
if admin {
|
|
return true, nil
|
|
}
|
|
if s.settings != nil && s.settings.Topic == topic && s.settings.MuteAll {
|
|
return false, nil
|
|
}
|
|
for _, mute := range s.mutes {
|
|
if mute.Topic == topic && mute.UserID == uid && mute.Status == types.GroupMuteStatusActive &&
|
|
(mute.ExpiresAt == nil || mute.ExpiresAt.After(now)) {
|
|
return false, nil
|
|
}
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
func defaultStringPtr(value *string, fallback string) string {
|
|
if value == nil {
|
|
return fallback
|
|
}
|
|
return *value
|
|
}
|
|
|
|
func (s *testUserSecurityStore) GetAuthVersion(_ types.TenantID, _ types.Uid) (int, error) {
|
|
return 0, nil
|
|
}
|
|
|
|
func (s *testUserSecurityStore) BumpAuthVersion(_ types.TenantID, _ types.Uid, _ time.Time) (int, error) {
|
|
return 1, nil
|
|
}
|
|
|
|
func (s *testUserSecurityStore) GetRealName(_ types.TenantID, _ types.Uid) (*types.RealNameVerification, error) {
|
|
return nil, types.ErrNotFound
|
|
}
|
|
|
|
func (s *testUserSecurityStore) CreateRealNameSubmission(_ types.TenantID, _ *types.RealNameVerification) error {
|
|
return nil
|
|
}
|
|
|
|
func (s *testUserSecurityStore) CreateRealNameAppeal(_ types.TenantID, appeal *types.RealNameAppeal) (*types.RealNameAppeal, error) {
|
|
return appeal, nil
|
|
}
|
|
|
|
func (s *testUserSecurityStore) CreateActionToken(_ types.TenantID, _ *types.ActionToken) error {
|
|
return nil
|
|
}
|
|
|
|
func (s *testUserSecurityStore) ConsumeActionToken(_ types.TenantID, _ types.Uid, _, _, _ string, _ time.Time) (*types.ActionToken, error) {
|
|
return nil, types.ErrNotFound
|
|
}
|
|
|
|
func (s *testUserSecurityStore) GetIndependentPassword(_ types.TenantID, _ types.Uid, _ string) (*types.IndependentPassword, error) {
|
|
return nil, types.ErrNotFound
|
|
}
|
|
|
|
func (s *testUserSecurityStore) UpsertIndependentPassword(_ types.TenantID, _ *types.IndependentPassword) error {
|
|
return nil
|
|
}
|
|
|
|
func (s *testUserSecurityStore) UpdateIndependentPasswordAttempts(_ types.TenantID, _ types.Uid, _ string, _ int, _ *time.Time, _ time.Time) error {
|
|
return nil
|
|
}
|
|
|
|
func (s *testUserSecurityStore) GetYouthMode(_ types.TenantID, _ types.Uid) (*types.YouthModeSettings, error) {
|
|
return nil, types.ErrNotFound
|
|
}
|
|
|
|
func (s *testUserSecurityStore) SetYouthMode(_ types.TenantID, uid types.Uid, enabled bool, policyVersion int, now time.Time) (*types.YouthModeSettings, error) {
|
|
return &types.YouthModeSettings{UserID: uid, Enabled: enabled, PolicyVersion: policyVersion, UpdatedAt: now}, nil
|
|
}
|
|
|
|
func (s *testUserSecurityStore) CreateSystemNotification(_ types.TenantID, notification *types.SystemNotification) error {
|
|
if s.err != nil {
|
|
return s.err
|
|
}
|
|
if notification != nil {
|
|
s.notifications = append(s.notifications, *notification)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *testUserSecurityStore) ListSystemNotifications(_ types.TenantID, _ types.Uid, _ string, _ int) ([]types.SystemNotification, string, error) {
|
|
return nil, "", nil
|
|
}
|
|
|
|
func (s *testUserSecurityStore) CountUnreadSystemNotifications(_ types.TenantID, _ types.Uid) (int, error) {
|
|
total := 0
|
|
for _, count := range s.unreadByPrefix {
|
|
total += count
|
|
}
|
|
return total, nil
|
|
}
|
|
|
|
func (s *testUserSecurityStore) CountUnreadSystemNotificationsByEventPrefix(_ types.TenantID, _ types.Uid, prefix string) (int, error) {
|
|
if s.unreadByPrefix == nil {
|
|
return 0, nil
|
|
}
|
|
return s.unreadByPrefix[prefix], nil
|
|
}
|
|
|
|
func (s *testUserSecurityStore) MarkSystemNotificationRead(_ types.TenantID, _ types.Uid, eventID string, readAt time.Time) (*types.SystemNotification, error) {
|
|
return &types.SystemNotification{EventID: eventID, EventType: "system.notice", Title: "通知", Summary: "通知", ReadAt: &readAt, CreatedAt: readAt}, nil
|
|
}
|
|
|
|
func (s *testProfilesStore) Get(_ types.TenantID, uid types.Uid) (*types.UserProfile, error) {
|
|
if s.profile == nil {
|
|
now := time.Now().UTC().Round(time.Millisecond)
|
|
s.profile = &types.UserProfile{UserID: uid, PublicAccount: uid.UserId(), Version: 1, CreatedAt: now, UpdatedAt: now}
|
|
}
|
|
return s.profile, nil
|
|
}
|
|
|
|
func (s *testProfilesStore) Update(_ types.TenantID, uid types.Uid, patch types.UserProfilePatch) (*types.UserProfile, error) {
|
|
profile, _ := s.Get(0, uid)
|
|
if patch.Nickname != nil {
|
|
profile.Nickname = *patch.Nickname
|
|
}
|
|
if patch.Signature != nil {
|
|
profile.Signature = *patch.Signature
|
|
}
|
|
if patch.Email != nil {
|
|
profile.Email = *patch.Email
|
|
}
|
|
profile.Version = patch.Version + 1
|
|
profile.UpdatedAt = time.Now().UTC().Round(time.Millisecond)
|
|
return profile, nil
|
|
}
|
|
|
|
func (s *testProfilesStore) GetPrivacy(_ types.TenantID, uid types.Uid) (*types.UserPrivacySettings, error) {
|
|
if s.privacy == nil {
|
|
now := time.Now().UTC().Round(time.Millisecond)
|
|
s.privacy = &types.UserPrivacySettings{
|
|
UserID: uid, AddMeMethods: map[string]bool{"account": true, "phone": true, "qr": true, "group": true},
|
|
ProfileVisibility: "friends", LastSeenVisibility: "friends", ReadReceipts: true, FriendRequestVerification: true, GroupInviteVerification: false, Version: 1, CreatedAt: now, UpdatedAt: now,
|
|
}
|
|
}
|
|
return s.privacy, nil
|
|
}
|
|
|
|
func (s *testProfilesStore) UpdatePrivacy(_ types.TenantID, uid types.Uid, patch types.UserPrivacyPatch) (*types.UserPrivacySettings, error) {
|
|
privacy, _ := s.GetPrivacy(0, uid)
|
|
if patch.AddMeMethods != nil {
|
|
privacy.AddMeMethods = patch.AddMeMethods
|
|
}
|
|
if patch.ProfileVisibility != nil {
|
|
privacy.ProfileVisibility = *patch.ProfileVisibility
|
|
}
|
|
if patch.LastSeenVisibility != nil {
|
|
privacy.LastSeenVisibility = *patch.LastSeenVisibility
|
|
}
|
|
if patch.ReadReceipts != nil {
|
|
privacy.ReadReceipts = *patch.ReadReceipts
|
|
}
|
|
if patch.FriendRequestVerification != nil {
|
|
privacy.FriendRequestVerification = *patch.FriendRequestVerification
|
|
}
|
|
if patch.GroupInviteVerification != nil {
|
|
privacy.GroupInviteVerification = *patch.GroupInviteVerification
|
|
}
|
|
privacy.Version = patch.Version + 1
|
|
privacy.UpdatedAt = time.Now().UTC().Round(time.Millisecond)
|
|
return privacy, nil
|
|
}
|
|
|
|
func (s *testAppReleaseStore) Latest(platform, channel string) (*types.AppRelease, error) {
|
|
if s.err != nil {
|
|
return nil, s.err
|
|
}
|
|
if s.release == nil {
|
|
now := time.Now().UTC().Round(time.Millisecond)
|
|
s.release = &types.AppRelease{
|
|
Platform: platform,
|
|
Channel: channel,
|
|
LatestVersion: "1.0.1",
|
|
MinSupportedVersion: "1.0.0",
|
|
UpdateType: "recommended",
|
|
ReleaseNotes: "修复已知问题。",
|
|
PublishedAt: now,
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
}
|
|
}
|
|
return s.release, nil
|
|
}
|
|
|
|
func (s *testDevicePushTokenStore) Upsert(tenantID types.TenantID, token *types.DevicePushToken) (*types.DevicePushToken, error) {
|
|
now := time.Now().UTC().Round(time.Millisecond)
|
|
token.CreatedAt = now
|
|
token.UpdatedAt = now
|
|
s.token = token
|
|
s.tenantID = tenantID
|
|
return token, nil
|
|
}
|
|
|
|
func (s *testDevicePushTokenStore) Delete(tenantID types.TenantID, uid types.Uid, deviceID string) error {
|
|
s.deletedTenantID = tenantID
|
|
s.deletedUserID = uid
|
|
s.deletedDeviceID = deviceID
|
|
return nil
|
|
}
|
|
|
|
func (s *testSafetyStore) CreateRiskChallenge(tenantID types.TenantID, challenge *types.RiskChallenge) (*types.RiskChallenge, error) {
|
|
now := time.Now().UTC().Round(time.Millisecond)
|
|
challenge.CreatedAt = now
|
|
challenge.UpdatedAt = now
|
|
s.riskChallenge = challenge
|
|
s.tenantID = tenantID
|
|
return challenge, nil
|
|
}
|
|
|
|
func (s *testSafetyStore) ConfirmRiskChallenge(tenantID types.TenantID, uid types.Uid, challengeID, decision, actionTokenID string, now time.Time) (*types.RiskChallenge, error) {
|
|
if s.riskErr != nil {
|
|
return nil, s.riskErr
|
|
}
|
|
s.tenantID = tenantID
|
|
s.uid = uid
|
|
s.decision = decision
|
|
s.actionTokenID = actionTokenID
|
|
if s.riskChallenge == nil {
|
|
s.riskChallenge = &types.RiskChallenge{ChallengeID: challengeID, UserID: uid, Action: "open_url", Level: "high", NoticeCode: "url_high", ExpiresAt: now.Add(time.Minute)}
|
|
}
|
|
s.riskChallenge.State = types.RiskChallengeStateCanceled
|
|
if decision == "continue" {
|
|
s.riskChallenge.State = types.RiskChallengeStateContinued
|
|
}
|
|
s.riskChallenge.Decision = decision
|
|
s.riskChallenge.ActionTokenID = actionTokenID
|
|
s.riskChallenge.UpdatedAt = now
|
|
return s.riskChallenge, nil
|
|
}
|
|
|
|
func (s *testSafetyStore) AbuseReasons(tenantID types.TenantID, targetType string) ([]types.AbuseReason, error) {
|
|
s.tenantID = tenantID
|
|
s.targetType = targetType
|
|
if len(s.reasons) == 0 {
|
|
s.reasons = []types.AbuseReason{{TargetType: targetType, ReasonCode: "fraud", Label: "诈骗欺诈", AllowsEvidence: true}}
|
|
}
|
|
return s.reasons, nil
|
|
}
|
|
|
|
func (s *testSafetyStore) CreateAbuseReport(tenantID types.TenantID, report *types.AbuseReport, idempotencyKey string) (*types.AbuseReport, error) {
|
|
now := time.Now().UTC().Round(time.Millisecond)
|
|
report.Status = types.AbuseReportStatusReceived
|
|
report.CreatedAt = now
|
|
report.UpdatedAt = now
|
|
s.abuseReport = report
|
|
s.tenantID = tenantID
|
|
s.idempotency = idempotencyKey
|
|
return report, nil
|
|
}
|
|
|
|
func (s *testSafetyStore) FeedbackConfig(tenantID types.TenantID, uid types.Uid) (*types.FeedbackConfig, error) {
|
|
s.tenantID = tenantID
|
|
s.uid = uid
|
|
now := time.Now().UTC().Round(time.Millisecond)
|
|
return &types.FeedbackConfig{
|
|
Categories: []string{"bug", "suggestion"},
|
|
MaxLength: 1000,
|
|
AttachmentPolicy: map[string]any{"max_files": float64(5), "purpose": "feedback"},
|
|
AllowContact: true,
|
|
DiagnosticsNotice: "仅在同意后上传诊断信息。",
|
|
UpdatedAt: now,
|
|
}, nil
|
|
}
|
|
|
|
func (s *testSafetyStore) CreateFeedback(tenantID types.TenantID, feedback *types.FeedbackSubmission, idempotencyKey string) (*types.FeedbackSubmission, error) {
|
|
now := time.Now().UTC().Round(time.Millisecond)
|
|
feedback.Status = types.FeedbackStatusReceived
|
|
feedback.CreatedAt = now
|
|
feedback.UpdatedAt = now
|
|
s.feedback = feedback
|
|
s.tenantID = tenantID
|
|
s.idempotency = idempotencyKey
|
|
return feedback, nil
|
|
}
|
|
|
|
func (s *testCallsStore) Create(_ types.TenantID, _ *types.CallRecord) error {
|
|
return nil
|
|
}
|
|
|
|
func (s *testCallsStore) Accept(tenantID types.TenantID, callID string, seq int64, acceptedAt time.Time) error {
|
|
s.accepted = true
|
|
s.acceptTenant = tenantID
|
|
s.acceptCallID = callID
|
|
s.acceptSeq = seq
|
|
s.acceptedAt = acceptedAt
|
|
return nil
|
|
}
|
|
|
|
func (s *testCallsStore) Finish(_ types.TenantID, _ string, _ int64, _ string, _ int64, _ time.Time) error {
|
|
return nil
|
|
}
|
|
|
|
func (s *testCallsStore) List(tenantID types.TenantID, uid types.Uid, topic, cursor string, limit int) ([]types.CallRecord, string, error) {
|
|
s.listed = true
|
|
s.tenant = tenantID
|
|
s.user = uid
|
|
s.topic = topic
|
|
s.cursor = cursor
|
|
s.limit = limit
|
|
return s.items, s.next, nil
|
|
}
|
|
|
|
func (s *testConversationsStore) EnsureP2P(_ types.TenantID, uid, peer types.Uid) (string, error) {
|
|
s.topic = uid.P2PName(peer)
|
|
return s.topic, nil
|
|
}
|
|
|
|
func (s *testConversationsStore) GetSettings(_ types.TenantID, _ types.Uid, topic string) (*types.ConversationSettings, error) {
|
|
s.topic = topic
|
|
if s.settings == nil {
|
|
now := time.Now().UTC().Round(time.Millisecond)
|
|
s.settings = &types.ConversationSettings{Topic: topic, Pinned: false, Muted: false, MessageRetention: "default", Version: 1, CreatedAt: now, UpdatedAt: now}
|
|
}
|
|
return s.settings, nil
|
|
}
|
|
|
|
func (s *testConversationsStore) UpdateSettings(_ types.TenantID, _ types.Uid, topic string, patch types.ConversationSettingsPatch) (*types.ConversationSettings, error) {
|
|
s.settingsPatch = patch
|
|
settings, _ := s.GetSettings(0, 0, topic)
|
|
if patch.Pinned != nil {
|
|
settings.Pinned = *patch.Pinned
|
|
}
|
|
if patch.Muted != nil {
|
|
settings.Muted = *patch.Muted
|
|
}
|
|
if patch.Remark != nil {
|
|
settings.Remark = *patch.Remark
|
|
}
|
|
if patch.MessageRetention != nil {
|
|
settings.MessageRetention = *patch.MessageRetention
|
|
}
|
|
settings.Version = patch.Version + 1
|
|
settings.UpdatedAt = time.Now().UTC().Round(time.Millisecond)
|
|
return settings, nil
|
|
}
|
|
|
|
func (s *testConversationsStore) GetDraft(_ types.TenantID, _ types.Uid, topic string) (*types.TopicDraft, error) {
|
|
if s.draft == nil {
|
|
return nil, types.ErrNotFound
|
|
}
|
|
s.draft.Topic = topic
|
|
return s.draft, nil
|
|
}
|
|
|
|
func (s *testConversationsStore) PutDraft(_ types.TenantID, uid types.Uid, topic, content string, entities []byte, version int) (*types.TopicDraft, error) {
|
|
s.putDraftCalled = true
|
|
now := time.Now().UTC().Round(time.Millisecond)
|
|
s.draft = &types.TopicDraft{UserID: uid, Topic: topic, Content: content, EntitiesJSON: entities, Version: version + 1, CreatedAt: now, UpdatedAt: now}
|
|
return s.draft, nil
|
|
}
|
|
|
|
func (s *testFavoritesStore) Create(_ types.TenantID, item *types.FavoriteItem) (*types.FavoriteItem, error) {
|
|
for i := range s.items {
|
|
existing := &s.items[i]
|
|
if existing.UserID == item.UserID && existing.SourceTopic == item.SourceTopic && existing.SourceSeq == item.SourceSeq {
|
|
return existing, nil
|
|
}
|
|
if item.IdempotencyKey != "" && existing.UserID == item.UserID && existing.IdempotencyKey == item.IdempotencyKey {
|
|
return existing, nil
|
|
}
|
|
}
|
|
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) CountPendingFriendRequests(tenantID types.TenantID, uid types.Uid) (int, error) {
|
|
s.countTenant = tenantID
|
|
s.countUser = uid
|
|
return s.pendingCount, 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.err != nil {
|
|
return nil, s.err
|
|
}
|
|
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(tenantID types.TenantID, userID, peerID types.Uid, _ time.Time) error {
|
|
s.deleted = true
|
|
s.deleteTenant = tenantID
|
|
s.deleteUser = userID
|
|
s.deletePeer = peerID
|
|
return nil
|
|
}
|
|
|
|
func (s *testRelationsStore) UpdateFriendRemark(tenantID types.TenantID, userID, peerID types.Uid, remark string, now time.Time) (*types.Friendship, error) {
|
|
friendship := &types.Friendship{TenantID: tenantID, UserID: userID, PeerID: peerID, Status: types.RelationshipFriend, Remark: remark, Topic: userID.P2PName(peerID), CreatedAt: now, UpdatedAt: now}
|
|
s.context = &types.RelationshipContext{
|
|
TenantID: tenantID,
|
|
UserID: userID,
|
|
PeerID: peerID,
|
|
Topic: friendship.Topic,
|
|
Relationship: types.RelationshipFriend,
|
|
CanSend: true,
|
|
Remark: remark,
|
|
Peer: &types.User{ObjHeader: types.ObjHeader{Id: peerID.String()}, TenantID: tenantID, Public: map[string]any{"display_name": "Ming"}},
|
|
}
|
|
return friendship, 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).AnyTimes()
|
|
tokenAuth.EXPECT().IsInitialized().Return(true).AnyTimes()
|
|
tokenAuth.EXPECT().Authenticate(auth.AuthContext{}, []byte(token)).
|
|
Return(&auth.Rec{TenantID: tenantID, Uid: uid, AuthLevel: auth.LevelAuth}, nil, nil).AnyTimes()
|
|
store.Store = mockStore
|
|
t.Cleanup(func() { store.Store = oldStore })
|
|
return mockStore
|
|
}
|
|
|
|
func TestResolveIMHTTPBearerAcceptsBase64Token(t *testing.T) {
|
|
ctrl := gomock.NewController(t)
|
|
defer ctrl.Finish()
|
|
expectIMBearer(t, ctrl, "token-1", 7, 42)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/v1/im/bootstrap", nil)
|
|
req.Header.Set("Authorization", "Bearer "+base64.StdEncoding.EncodeToString([]byte("token-1")))
|
|
|
|
principal, err := resolveIMHTTPBearer(req)
|
|
if err != nil {
|
|
t.Fatalf("resolve bearer: %v", err)
|
|
}
|
|
if principal.TenantID != 7 || principal.Uid != 42 {
|
|
t.Fatalf("principal mismatch: %#v", principal)
|
|
}
|
|
}
|
|
|
|
func TestIMHTTPProtocolHeaders(t *testing.T) {
|
|
apiKey := makeTestAPIKey(t)
|
|
called := 0
|
|
handler := withIMHTTPProtocol("/v1/im/", http.HandlerFunc(func(wrt http.ResponseWriter, req *http.Request) {
|
|
called++
|
|
writeIMSuccess(wrt, req, map[string]any{"ok": true})
|
|
}))
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/v1/im/bootstrap", nil)
|
|
rec := httptest.NewRecorder()
|
|
handler.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusForbidden || called != 0 {
|
|
t.Fatalf("missing API key: status=%d called=%d", rec.Code, called)
|
|
}
|
|
if rec.Header().Get("X-Request-ID") == "" {
|
|
t.Fatal("server did not generate X-Request-ID")
|
|
}
|
|
|
|
req = httptest.NewRequest(http.MethodGet, "/v1/im/bootstrap", nil)
|
|
req.Header.Set("X-MeetingIM-APIKey", apiKey)
|
|
rec = httptest.NewRecorder()
|
|
handler.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusBadRequest || decodeIMHTTPResponse(t, rec).Reason != imReasonSchemaUnsupported {
|
|
t.Fatalf("missing schema: status=%d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
|
|
req = httptest.NewRequest(http.MethodGet, "/v1/im/bootstrap", nil)
|
|
req.Header.Set("X-MeetingIM-APIKey", apiKey)
|
|
req.Header.Set("X-IM-Schema", "1")
|
|
req.Header.Set("X-Request-ID", "request-1")
|
|
rec = httptest.NewRecorder()
|
|
handler.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusOK || called != 1 || rec.Header().Get("X-Request-ID") != "request-1" {
|
|
t.Fatalf("valid protocol request: status=%d called=%d request_id=%q", rec.Code, called, rec.Header().Get("X-Request-ID"))
|
|
}
|
|
|
|
req = httptest.NewRequest(http.MethodGet, "/v1/im/bootstrap", nil)
|
|
req.Header.Set("X-MeetingIM-APIKey", apiKey)
|
|
req.Header.Set("X-IM-Schema", "1")
|
|
rec = httptest.NewRecorder()
|
|
handler.ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusOK || called != 2 {
|
|
t.Fatalf("legacy protocol request: status=%d called=%d", rec.Code, called)
|
|
}
|
|
}
|
|
|
|
func TestMeetingIMHTTPAuthHeaders(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodGet, "/v0/file/s/file1", nil)
|
|
req.Header.Set("X-MeetingIM-APIKey", "new-api-key")
|
|
req.Header.Set("X-MeetingIM-Auth", "token new-token")
|
|
|
|
if got := getAPIKey(req); got != "new-api-key" {
|
|
t.Fatalf("getAPIKey new header=%q", got)
|
|
}
|
|
method, secret := getHttpAuth(req)
|
|
if method != "token" || secret != "new-token" {
|
|
t.Fatalf("getHttpAuth new header method=%q secret=%q", method, secret)
|
|
}
|
|
}
|
|
|
|
func TestIMHTTPIdempotencyReplayAndConflict(t *testing.T) {
|
|
ctrl := gomock.NewController(t)
|
|
defer ctrl.Finish()
|
|
expectIMBearer(t, ctrl, "token-1", 7, 42)
|
|
oldBegin, oldComplete, oldAbort := beginIMHTTPIdempotency, completeIMHTTPIdempotency, abortIMHTTPIdempotency
|
|
type cachedResponse struct {
|
|
bodyHash string
|
|
replay *imHTTPIdempotencyReplay
|
|
}
|
|
records := map[string]*cachedResponse{}
|
|
recordKey := func(tenantID types.TenantID, uid types.Uid, method, route, key string) string {
|
|
return imHTTPIdempotencyID(tenantID, uid, method, route, key)
|
|
}
|
|
beginIMHTTPIdempotency = func(tenantID types.TenantID, uid types.Uid, method, route, key, bodyHash string) (*imHTTPIdempotencyReplay, bool, bool, error) {
|
|
id := recordKey(tenantID, uid, method, route, key)
|
|
if existing := records[id]; existing != nil {
|
|
if existing.bodyHash != bodyHash {
|
|
return nil, false, true, errIMHTTPIdempotencyConflict
|
|
}
|
|
if existing.replay == nil {
|
|
return nil, false, true, nil
|
|
}
|
|
copy := *existing.replay
|
|
copy.ResponseBody = append([]byte(nil), existing.replay.ResponseBody...)
|
|
return ©, false, true, nil
|
|
}
|
|
records[id] = &cachedResponse{bodyHash: bodyHash}
|
|
return nil, true, true, nil
|
|
}
|
|
completeIMHTTPIdempotency = func(tenantID types.TenantID, uid types.Uid, method, route, key, bodyHash string, status int, body []byte) error {
|
|
records[recordKey(tenantID, uid, method, route, key)] = &cachedResponse{
|
|
bodyHash: bodyHash,
|
|
replay: &imHTTPIdempotencyReplay{BodyHash: bodyHash, ResponseStatus: status, ResponseBody: append([]byte(nil), body...)},
|
|
}
|
|
return nil
|
|
}
|
|
abortIMHTTPIdempotency = func(tenantID types.TenantID, uid types.Uid, method, route, key string) error {
|
|
delete(records, recordKey(tenantID, uid, method, route, key))
|
|
return nil
|
|
}
|
|
defer func() {
|
|
beginIMHTTPIdempotency, completeIMHTTPIdempotency, abortIMHTTPIdempotency = oldBegin, oldComplete, oldAbort
|
|
}()
|
|
|
|
called := 0
|
|
handler := withIMHTTPProtocol("/v1/im/", http.HandlerFunc(func(wrt http.ResponseWriter, req *http.Request) {
|
|
called++
|
|
body, _ := io.ReadAll(req.Body)
|
|
writeIMSuccess(wrt, req, map[string]any{"body": string(body), "call": called})
|
|
}))
|
|
apiKey := makeTestAPIKey(t)
|
|
request := func(body string) *httptest.ResponseRecorder {
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/im/test", strings.NewReader(body))
|
|
req.Header.Set("Authorization", "Bearer token-1")
|
|
req.Header.Set("X-MeetingIM-APIKey", apiKey)
|
|
req.Header.Set("X-IM-Schema", "1")
|
|
req.Header.Set("Idempotency-Key", "same-key")
|
|
rec := httptest.NewRecorder()
|
|
handler.ServeHTTP(rec, req)
|
|
return rec
|
|
}
|
|
|
|
first := request(`{"value":1}`)
|
|
second := request(`{"value":1}`)
|
|
if first.Code != http.StatusOK || second.Code != http.StatusOK || called != 1 {
|
|
t.Fatalf("replay failed: first=%d second=%d called=%d", first.Code, second.Code, called)
|
|
}
|
|
if second.Header().Get("X-Idempotent-Replay") != "true" || first.Body.String() != second.Body.String() {
|
|
t.Fatalf("response was not replayed: first=%s second=%s", first.Body.String(), second.Body.String())
|
|
}
|
|
conflict := request(`{"value":2}`)
|
|
if conflict.Code != http.StatusConflict || decodeIMHTTPResponse(t, conflict).Reason != imReasonIdempotencyConflict || called != 1 {
|
|
t.Fatalf("conflict failed: status=%d called=%d body=%s", conflict.Code, called, conflict.Body.String())
|
|
}
|
|
}
|
|
|
|
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)
|
|
apiKey := makeTestAPIKey(t)
|
|
req.Header.Set("X-MeetingIM-APIKey", apiKey)
|
|
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"] != "wss://im.example.com/v0/channels?apikey="+url.QueryEscape(apiKey) {
|
|
t.Fatalf("unexpected websocket endpoint: %#v", endpoints)
|
|
}
|
|
legal := endpoints["legal"].(map[string]any)
|
|
if legal["user_terms"] != "https://im.example.com/legal/user-terms" ||
|
|
legal["privacy_policy"] != "https://im.example.com/legal/privacy-policy" {
|
|
t.Fatalf("unexpected legal endpoints: %#v", legal)
|
|
}
|
|
}
|
|
|
|
func TestIMHTTPProtocolDoesNotRequireIdempotencyForTenantResolve(t *testing.T) {
|
|
next := http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
|
writeIMSuccess(w, req, map[string]any{"ok": true})
|
|
})
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/im/tenants/resolve", nil)
|
|
req.Header.Set("X-MeetingIM-APIKey", makeTestAPIKey(t))
|
|
req.Header.Set("X-IM-Schema", "1")
|
|
req.Header.Set("Authorization", "Bearer test-token")
|
|
rec := httptest.NewRecorder()
|
|
|
|
withIMHTTPProtocol("/v1/im/", next).ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestServeIMTenantResolveUsesForwardedPublicHost(t *testing.T) {
|
|
oldTenants := store.Tenants
|
|
oldServingAt := globals.servingAt
|
|
store.Tenants = testTenantStore{tenant: &types.Tenant{
|
|
ID: 7,
|
|
Code: "acme",
|
|
Name: "Acme Corp",
|
|
State: types.TenantStateActive,
|
|
}}
|
|
defer func() {
|
|
store.Tenants = oldTenants
|
|
globals.servingAt = oldServingAt
|
|
}()
|
|
globals.servingAt = "http://:6060/"
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/im/tenants/resolve", nil)
|
|
req.Host = "internal:6060"
|
|
req.Header.Set("X-Forwarded-Proto", "https")
|
|
req.Header.Set("X-Forwarded-Host", "api.118im.com")
|
|
apiKey := makeTestAPIKey(t)
|
|
req.Header.Set("X-MeetingIM-APIKey", apiKey)
|
|
req.Header.Set("X-Tenant-Code", "acme")
|
|
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())
|
|
}
|
|
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
|
|
endpoints := data["endpoints"].(map[string]any)
|
|
if endpoints["websocket"] != "wss://api.118im.com/v0/channels?apikey="+url.QueryEscape(apiKey) ||
|
|
endpoints["api"] != "https://api.118im.com/v1/im" ||
|
|
endpoints["file"] != "https://api.118im.com/v0/file" {
|
|
t.Fatalf("unexpected endpoints: %#v", endpoints)
|
|
}
|
|
legal := endpoints["legal"].(map[string]any)
|
|
if legal["user_terms"] != "https://api.118im.com/legal/user-terms" ||
|
|
legal["privacy_policy"] != "https://api.118im.com/legal/privacy-policy" {
|
|
t.Fatalf("unexpected legal endpoints: %#v", legal)
|
|
}
|
|
}
|
|
|
|
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-MeetingIM-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 TestWriteIMRelationErrorUsesResourceNotFoundReason(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodGet, "/v1/im/test", nil)
|
|
rec := httptest.NewRecorder()
|
|
|
|
writeIMRelationError(rec, req, types.ErrNotFound)
|
|
|
|
if rec.Code != http.StatusNotFound {
|
|
t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusNotFound, rec.Body.String())
|
|
}
|
|
resp := decodeIMHTTPResponse(t, rec)
|
|
if resp.Reason != imReasonResourceNotFound {
|
|
t.Fatalf("reason: got %q, want %q", resp.Reason, imReasonResourceNotFound)
|
|
}
|
|
}
|
|
|
|
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-MeetingIM-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-MeetingIM-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-MeetingIM-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 TestServeIMTenantApplications(t *testing.T) {
|
|
oldTenantEntry := store.TenantEntry
|
|
entry := &testTenantEntryStore{}
|
|
store.TenantEntry = entry
|
|
defer func() { store.TenantEntry = oldTenantEntry }()
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/im/tenant-applications", strings.NewReader(`{
|
|
"credit_code":"91310000ma1k000000",
|
|
"enterprise_email":"admin@example.com",
|
|
"license_file_id":"file_123",
|
|
"tenant_code_hint":"acme",
|
|
"client_context":{"platform":"android"}
|
|
}`))
|
|
req.Header.Set("Idempotency-Key", "tenant-app-1")
|
|
rec := httptest.NewRecorder()
|
|
|
|
serveIMTenantApplications(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status: got %d, body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
if entry.application == nil || entry.application.CreditCode != "91310000MA1K000000" ||
|
|
entry.application.EnterpriseEmail != "admin@example.com" || entry.application.LicenseFileID != "file_123" {
|
|
t.Fatalf("unexpected application: %#v", entry.application)
|
|
}
|
|
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
|
|
if !strings.HasPrefix(data["application_id"].(string), "entapp_") || data["status"] != types.TenantApplicationStatusReceived {
|
|
t.Fatalf("unexpected application response: %#v", data)
|
|
}
|
|
}
|
|
|
|
func TestServeIMTenantEntryFeedback(t *testing.T) {
|
|
oldTenantEntry := store.TenantEntry
|
|
entry := &testTenantEntryStore{}
|
|
store.TenantEntry = entry
|
|
defer func() { store.TenantEntry = oldTenantEntry }()
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/im/tenant-entry-feedback", strings.NewReader(`{
|
|
"description":"企业码 acme 提示不可用,无法进入企业。",
|
|
"contact":"user@example.com",
|
|
"tenant_code_hint":"acme",
|
|
"screenshot_file_ids":["file_1","file_1","file_2"],
|
|
"client_context":{"platform":"ios"}
|
|
}`))
|
|
req.Header.Set("Idempotency-Key", "entry-feedback-1")
|
|
rec := httptest.NewRecorder()
|
|
|
|
serveIMTenantEntryFeedback(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status: got %d, body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
if entry.feedback == nil || entry.feedback.Description == "" || len(entry.feedback.ScreenshotFileIDs) != 2 {
|
|
t.Fatalf("unexpected feedback: %#v", entry.feedback)
|
|
}
|
|
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
|
|
if !strings.HasPrefix(data["feedback_id"].(string), "entryfb_") || data["status"] != types.TenantEntryFeedbackReceived {
|
|
t.Fatalf("unexpected feedback response: %#v", data)
|
|
}
|
|
}
|
|
|
|
func TestServeIMPublicUploads(t *testing.T) {
|
|
ctrl := gomock.NewController(t)
|
|
defer ctrl.Finish()
|
|
oldStore := store.Store
|
|
oldTenantEntry := store.TenantEntry
|
|
entry := &testTenantEntryStore{}
|
|
handler := &testPublicMediaHandler{}
|
|
mockStore := mock_store.NewMockPersistentStorageInterface(ctrl)
|
|
mockStore.EXPECT().GetMediaHandler().Return(handler).AnyTimes()
|
|
store.Store = mockStore
|
|
store.TenantEntry = entry
|
|
defer func() {
|
|
store.Store = oldStore
|
|
store.TenantEntry = oldTenantEntry
|
|
}()
|
|
|
|
var body bytes.Buffer
|
|
writer := multipart.NewWriter(&body)
|
|
if err := writer.WriteField("purpose", types.PublicUploadPurposeTenantFeedback); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
part, err := writer.CreateFormFile("file", "shot.png")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := part.Write([]byte("\x89PNG\r\n\x1a\n" + strings.Repeat("\x00", 64))); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := writer.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/im/public/uploads", &body)
|
|
req.Header.Set("Content-Type", writer.FormDataContentType())
|
|
rec := httptest.NewRecorder()
|
|
|
|
serveIMPublicUploads(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status: got %d, body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
if entry.upload == nil || entry.upload.Purpose != types.PublicUploadPurposeTenantFeedback ||
|
|
entry.upload.Location == "" || handler.def == nil {
|
|
t.Fatalf("unexpected upload: %#v handler=%#v", entry.upload, handler.def)
|
|
}
|
|
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
|
|
if !strings.HasPrefix(data["file_id"].(string), "file_") || data["purpose"] != types.PublicUploadPurposeTenantFeedback {
|
|
t.Fatalf("unexpected upload response: %#v", data)
|
|
}
|
|
}
|
|
|
|
func TestServeIMPublicUploadsAllowsGroupAvatar(t *testing.T) {
|
|
ctrl := gomock.NewController(t)
|
|
defer ctrl.Finish()
|
|
oldStore := store.Store
|
|
oldTenantEntry := store.TenantEntry
|
|
entry := &testTenantEntryStore{}
|
|
handler := &testPublicMediaHandler{}
|
|
mockStore := mock_store.NewMockPersistentStorageInterface(ctrl)
|
|
mockStore.EXPECT().GetMediaHandler().Return(handler).AnyTimes()
|
|
store.Store = mockStore
|
|
store.TenantEntry = entry
|
|
defer func() {
|
|
store.Store = oldStore
|
|
store.TenantEntry = oldTenantEntry
|
|
}()
|
|
|
|
var body bytes.Buffer
|
|
writer := multipart.NewWriter(&body)
|
|
if err := writer.WriteField("purpose", types.PublicUploadPurposeGroupAvatar); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
part, err := writer.CreateFormFile("file", "avatar.png")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := part.Write([]byte("\x89PNG\r\n\x1a\n" + strings.Repeat("\x00", 64))); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := writer.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/im/public/uploads", &body)
|
|
req.Header.Set("Content-Type", writer.FormDataContentType())
|
|
rec := httptest.NewRecorder()
|
|
|
|
serveIMPublicUploads(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status: got %d, body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
if entry.upload == nil || entry.upload.Purpose != types.PublicUploadPurposeGroupAvatar {
|
|
t.Fatalf("unexpected upload: %#v", entry.upload)
|
|
}
|
|
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
|
|
if !strings.HasPrefix(data["file_id"].(string), "file_") || data["purpose"] != types.PublicUploadPurposeGroupAvatar {
|
|
t.Fatalf("unexpected upload response: %#v", data)
|
|
}
|
|
}
|
|
|
|
func TestServeIMPublicUploadsAllowsUserAvatar(t *testing.T) {
|
|
ctrl := gomock.NewController(t)
|
|
defer ctrl.Finish()
|
|
oldStore := store.Store
|
|
oldTenantEntry := store.TenantEntry
|
|
entry := &testTenantEntryStore{}
|
|
handler := &testPublicMediaHandler{}
|
|
mockStore := mock_store.NewMockPersistentStorageInterface(ctrl)
|
|
mockStore.EXPECT().GetMediaHandler().Return(handler).AnyTimes()
|
|
store.Store = mockStore
|
|
store.TenantEntry = entry
|
|
defer func() {
|
|
store.Store = oldStore
|
|
store.TenantEntry = oldTenantEntry
|
|
}()
|
|
|
|
var body bytes.Buffer
|
|
writer := multipart.NewWriter(&body)
|
|
if err := writer.WriteField("purpose", types.PublicUploadPurposeUserAvatar); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
part, err := writer.CreateFormFile("file", "avatar.png")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := part.Write([]byte("\x89PNG\r\n\x1a\n" + strings.Repeat("\x00", 64))); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := writer.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/im/public/uploads", &body)
|
|
req.Header.Set("Content-Type", writer.FormDataContentType())
|
|
rec := httptest.NewRecorder()
|
|
|
|
serveIMPublicUploads(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status: got %d, body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
if entry.upload == nil || entry.upload.Purpose != types.PublicUploadPurposeUserAvatar {
|
|
t.Fatalf("unexpected upload: %#v", entry.upload)
|
|
}
|
|
}
|
|
|
|
func TestServeIMVerificationCodesCreatesChallenge(t *testing.T) {
|
|
oldTenants := store.Tenants
|
|
oldVerifications := store.Verifications
|
|
oldSender := sendIMVerificationCode
|
|
store.Tenants = testTenantStore{tenant: &types.Tenant{
|
|
ID: 7,
|
|
Code: "acme",
|
|
Name: "Acme Corp",
|
|
State: types.TenantStateActive,
|
|
}}
|
|
verifications := &testVerificationStore{}
|
|
store.Verifications = verifications
|
|
setRegistrationSMSConfigForTest(true)
|
|
sendIMVerificationCode = func(_ types.TenantID, recipient, _, scene, code string) error {
|
|
t.Fatalf("registration SMS sender must not be called before SMS integration: recipient=%q scene=%q code=%q", recipient, scene, code)
|
|
return nil
|
|
}
|
|
defer func() {
|
|
store.Tenants = oldTenants
|
|
store.Verifications = oldVerifications
|
|
sendIMVerificationCode = oldSender
|
|
resetRegistrationSMSConfigForTest()
|
|
}()
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/im/auth/verification-codes",
|
|
bytes.NewBufferString(`{"scene":"register","registration_type":0,"tenant_code":"acme","phone":"+8613800138000"}`))
|
|
req.Header.Set("X-MeetingIM-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())
|
|
}
|
|
if verifications.created.CodeHash != hashIMSecret(7, "register", verifications.created.ChallengeID,
|
|
verifications.created.PhoneHash, "123456") {
|
|
t.Fatalf("stored challenge does not match fixed registration code")
|
|
}
|
|
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 TestServeIMVerificationCodesSkipsDeliveryWhenRegistrationSMSDisabled(t *testing.T) {
|
|
oldTenants := store.Tenants
|
|
oldVerifications := store.Verifications
|
|
oldSender := sendIMVerificationCode
|
|
store.Tenants = testTenantStore{tenant: &types.Tenant{ID: 7, Code: "acme", State: types.TenantStateActive}}
|
|
verifications := &testVerificationStore{}
|
|
store.Verifications = verifications
|
|
setRegistrationSMSConfigForTest(false)
|
|
sendIMVerificationCode = func(_ types.TenantID, _, _, _, _ string) error {
|
|
t.Fatal("SMS sender must not be called when registration SMS is disabled")
|
|
return nil
|
|
}
|
|
defer func() {
|
|
store.Tenants = oldTenants
|
|
store.Verifications = oldVerifications
|
|
sendIMVerificationCode = oldSender
|
|
resetRegistrationSMSConfigForTest()
|
|
}()
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/im/auth/verification-codes",
|
|
bytes.NewBufferString(`{"scene":"register","registration_type":0,"tenant_code":"acme","phone":"+8613800138000"}`))
|
|
req.Header.Set("X-MeetingIM-APIKey", makeTestAPIKey(t))
|
|
req.Header.Set("X-Tenant-Code", "acme")
|
|
rec := httptest.NewRecorder()
|
|
|
|
serveIMVerificationCodes(rec, req)
|
|
|
|
if rec.Code != http.StatusOK || verifications.created == nil {
|
|
t.Fatalf("status=%d body=%s created=%#v", rec.Code, rec.Body.String(), verifications.created)
|
|
}
|
|
}
|
|
|
|
func TestServeIMVerificationCodesPersonalUsesDefaultTenant(t *testing.T) {
|
|
oldTenants := store.Tenants
|
|
oldVerifications := store.Verifications
|
|
oldSender := sendIMVerificationCode
|
|
store.Tenants = testTenantStore{tenant: &types.Tenant{ID: 1, Code: imDefaultTenantCode, State: types.TenantStateActive}}
|
|
verifications := &testVerificationStore{}
|
|
store.Verifications = verifications
|
|
sendIMVerificationCode = func(_ types.TenantID, _, _, _, _ string) error {
|
|
t.Fatal("registration SMS sender must not be called for fixed-code registration")
|
|
return nil
|
|
}
|
|
defer func() {
|
|
store.Tenants = oldTenants
|
|
store.Verifications = oldVerifications
|
|
sendIMVerificationCode = oldSender
|
|
}()
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/im/auth/verification-codes",
|
|
bytes.NewBufferString(`{"scene":"register","registration_type":1,"phone":"+8613800138000"}`))
|
|
req.Header.Set("X-MeetingIM-APIKey", makeTestAPIKey(t))
|
|
rec := httptest.NewRecorder()
|
|
|
|
serveIMVerificationCodes(rec, req)
|
|
|
|
if rec.Code != http.StatusOK || verifications.created == nil {
|
|
t.Fatalf("status=%d body=%s created=%#v", rec.Code, rec.Body.String(), verifications.created)
|
|
}
|
|
if verifications.created.TenantID != 1 {
|
|
t.Fatalf("tenant: got %d, want 1", verifications.created.TenantID)
|
|
}
|
|
}
|
|
|
|
func TestServeIMVerificationCodesRejectsEnterpriseDefaultTenant(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/im/auth/verification-codes",
|
|
bytes.NewBufferString(`{"scene":"register","registration_type":0,"tenant_code":"default","phone":"+8613800138000"}`))
|
|
req.Header.Set("X-MeetingIM-APIKey", makeTestAPIKey(t))
|
|
rec := httptest.NewRecorder()
|
|
|
|
serveIMVerificationCodes(rec, req)
|
|
|
|
if rec.Code != http.StatusNotFound {
|
|
t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusNotFound, rec.Body.String())
|
|
}
|
|
resp := decodeIMHTTPResponse(t, rec)
|
|
if resp.Reason != imReasonTenantUnavailable {
|
|
t.Fatalf("reason: got %q, want %q", resp.Reason, imReasonTenantUnavailable)
|
|
}
|
|
}
|
|
|
|
func TestServeIMPasswordResetCodeFindsPhoneWithoutTelCredential(t *testing.T) {
|
|
ctrl := gomock.NewController(t)
|
|
defer ctrl.Finish()
|
|
|
|
oldTenants := store.Tenants
|
|
oldVerifications := store.Verifications
|
|
oldUsers := store.Users
|
|
oldSender := sendIMVerificationCode
|
|
store.Tenants = testTenantStore{tenant: &types.Tenant{ID: 7, Code: "acme", State: types.TenantStateActive}}
|
|
verifications := &testVerificationStore{}
|
|
store.Verifications = verifications
|
|
users := mock_store.NewMockUsersPersistenceInterface(ctrl)
|
|
users.EXPECT().GetByPhone(types.TenantID(7), "+8613800138000").Return(types.Uid(42), nil)
|
|
store.Users = users
|
|
sendIMVerificationCode = func(_ types.TenantID, recipient, _, scene, code string) error {
|
|
if recipient != "+8613800138000" || scene != "reset_password" || len(code) != imVerificationCodeLength {
|
|
t.Fatalf("unexpected reset delivery: recipient=%q scene=%q code=%q", recipient, scene, code)
|
|
}
|
|
return nil
|
|
}
|
|
defer func() {
|
|
store.Tenants = oldTenants
|
|
store.Verifications = oldVerifications
|
|
store.Users = oldUsers
|
|
sendIMVerificationCode = oldSender
|
|
}()
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/im/auth/verification-codes",
|
|
bytes.NewBufferString(`{"scene":"reset_password","phone":"13800138000"}`))
|
|
req.Header.Set("X-MeetingIM-APIKey", makeTestAPIKey(t))
|
|
req.Header.Set("X-Tenant-Code", "acme")
|
|
rec := httptest.NewRecorder()
|
|
|
|
serveIMVerificationCodes(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
if verifications.created == nil || verifications.created.TargetUser != types.Uid(42) {
|
|
t.Fatalf("password reset challenge was not bound to phone account: %#v", verifications.created)
|
|
}
|
|
}
|
|
|
|
func TestServeIMVerificationCodesKeepsChallengeWhenDeliveryUnavailable(t *testing.T) {
|
|
oldTenants := store.Tenants
|
|
oldVerifications := store.Verifications
|
|
oldUsers := store.Users
|
|
oldSender := sendIMVerificationCode
|
|
store.Tenants = testTenantStore{tenant: &types.Tenant{ID: 7, Code: "acme", State: types.TenantStateActive}}
|
|
verifications := &testVerificationStore{}
|
|
store.Verifications = verifications
|
|
setRegistrationSMSConfigForTest(true)
|
|
ctrl := gomock.NewController(t)
|
|
defer ctrl.Finish()
|
|
users := mock_store.NewMockUsersPersistenceInterface(ctrl)
|
|
users.EXPECT().GetByPhone(types.TenantID(7), "+8613800138000").Return(types.Uid(42), nil)
|
|
store.Users = users
|
|
sendIMVerificationCode = func(_ types.TenantID, _, _, _, _ string) error { return types.ErrInternal }
|
|
defer func() {
|
|
store.Tenants = oldTenants
|
|
store.Verifications = oldVerifications
|
|
store.Users = oldUsers
|
|
sendIMVerificationCode = oldSender
|
|
resetRegistrationSMSConfigForTest()
|
|
}()
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/im/auth/verification-codes",
|
|
bytes.NewBufferString(`{"scene":"reset_password","phone":"+8613800138000"}`))
|
|
req.Header.Set("X-MeetingIM-APIKey", makeTestAPIKey(t))
|
|
req.Header.Set("X-Tenant-Code", "acme")
|
|
rec := httptest.NewRecorder()
|
|
|
|
serveIMVerificationCodes(rec, req)
|
|
|
|
if rec.Code != http.StatusOK || verifications.failed {
|
|
t.Fatalf("status=%d failed=%v body=%s", rec.Code, verifications.failed, rec.Body.String())
|
|
}
|
|
if verifications.created == nil || verifications.created.CodeHash != hashIMSecret(7, "reset_password", verifications.created.ChallengeID,
|
|
verifications.created.PhoneHash, "123456") {
|
|
t.Fatalf("challenge should remain active with fixed test code: %#v", verifications.created)
|
|
}
|
|
}
|
|
|
|
func TestServeIMVerificationCodesRequiresTenantCode(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/im/auth/verification-codes",
|
|
bytes.NewBufferString(`{"scene":"register","registration_type":0,"phone":"+8613800138000"}`))
|
|
req.Header.Set("X-MeetingIM-APIKey", makeTestAPIKey(t))
|
|
rec := httptest.NewRecorder()
|
|
|
|
serveIMVerificationCodes(rec, req)
|
|
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusBadRequest, rec.Body.String())
|
|
}
|
|
resp := decodeIMHTTPResponse(t, rec)
|
|
if resp.Reason != imReasonMalformedRequest {
|
|
t.Fatalf("reason: got %q, want %q", resp.Reason, imReasonMalformedRequest)
|
|
}
|
|
if resp.Details == nil {
|
|
t.Fatal("expected malformed request details")
|
|
}
|
|
}
|
|
|
|
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","registration_type":0,"tenant_code":"acme","phone":"+8613800138000"}`))
|
|
req.Header.Set("X-MeetingIM-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
|
|
apiKey := makeTestAPIKey(t)
|
|
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-MeetingIM-APIKey", apiKey)
|
|
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 TestServeIMVerificationCodeVerifyAcceptsRegistrationInvite(t *testing.T) {
|
|
oldTenants := store.Tenants
|
|
oldVerifications := store.Verifications
|
|
oldInvites := store.RegistrationInvites
|
|
store.Tenants = testTenantStore{tenant: &types.Tenant{ID: 7, Code: "acme", Name: "Acme Corp", State: types.TenantStateActive}}
|
|
verifications := &testVerificationStore{get: &types.VerificationCode{
|
|
TenantID: 7, ChallengeID: "challenge-1", Scene: "register", PhoneHash: "phone-hash",
|
|
State: types.VerificationStatePending, MaxAttempts: 5, ExpiresAt: time.Now().UTC().Add(time.Minute),
|
|
}}
|
|
store.Verifications = verifications
|
|
store.RegistrationInvites = &testRegistrationInviteStore{invite: &types.RegistrationInviteCode{
|
|
Code: "AbCdE", Status: types.RegistrationInviteActive, ExpiresAt: time.Now().UTC().Add(time.Hour),
|
|
}}
|
|
defer func() {
|
|
store.Tenants = oldTenants
|
|
store.Verifications = oldVerifications
|
|
store.RegistrationInvites = oldInvites
|
|
}()
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/im/auth/verification-codes/challenge-1/verify",
|
|
bytes.NewBufferString(`{"code":"AbCdE"}`))
|
|
req.Header.Set("X-MeetingIM-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())
|
|
}
|
|
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
|
|
if data["verification_mode"] != "invite_code" || data["invite_accepted"] != true {
|
|
t.Fatalf("unexpected invite response: %#v", data)
|
|
}
|
|
if verifications.verified {
|
|
t.Fatal("invite code must not mark the phone challenge as SMS verified")
|
|
}
|
|
}
|
|
|
|
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-MeetingIM-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 TestServeIMRealNameSubmissionAutoReviewFallsBackToManual(t *testing.T) {
|
|
ctrl := gomock.NewController(t)
|
|
defer ctrl.Finish()
|
|
expectIMBearer(t, ctrl, "token-1", 7, 42)
|
|
oldSecurity := store.Security
|
|
oldRealNameConfig := store.RealNameConfig
|
|
oldThirdParty := store.ThirdPartyConfig
|
|
security := &testSecurityStore{}
|
|
store.Security = security
|
|
store.RealNameConfig = testRealNameConfigStore{policy: &types.RealNamePolicy{
|
|
ReviewPolicy: types.RealNameReviewPolicyAutoManual,
|
|
}}
|
|
store.ThirdPartyConfig = testThirdPartyConfigStore{}
|
|
defer func() {
|
|
store.Security = oldSecurity
|
|
store.RealNameConfig = oldRealNameConfig
|
|
store.ThirdPartyConfig = oldThirdParty
|
|
}()
|
|
|
|
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 != "manual_review" || security.realName.ReviewReason == nil {
|
|
t.Fatalf("unexpected stored realname: %#v", security.realName)
|
|
}
|
|
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
|
|
if data["status"] != "pending" || data["review_status"] != "manual_review" {
|
|
t.Fatalf("unexpected response: %#v", data)
|
|
}
|
|
}
|
|
|
|
func TestServeIMRealNameAppealCreatesPending(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/appeals",
|
|
bytes.NewBufferString(`{"reason":"证件信息需要更正","attachments":["file1"]}`))
|
|
req.Header.Set("Authorization", "Bearer token-1")
|
|
rec := httptest.NewRecorder()
|
|
|
|
serveIMRealNameAppeals(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.realNameAppeal == nil || security.realNameAppeal.Reason != "证件信息需要更正" {
|
|
t.Fatalf("appeal was not stored: %#v", security.realNameAppeal)
|
|
}
|
|
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
|
|
if data["status"] != "pending" || data["appeal_id"] == "" {
|
|
t.Fatalf("unexpected appeal 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)
|
|
}
|
|
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
|
|
if data["password_set"] != true || data["next_action"] != "enable_youth_mode" || data["enable_action_token"] == "" {
|
|
t.Fatalf("unexpected set response: %#v", data)
|
|
}
|
|
if security.actionToken == nil || security.actionToken.Action != "enable_youth_mode" || security.actionToken.Target != "youth_mode" {
|
|
t.Fatalf("unexpected enable token after set: %#v", security.actionToken)
|
|
}
|
|
|
|
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 TestServeIMSecurityChallengeSupportsGroupSensitiveScenes(t *testing.T) {
|
|
ctrl := gomock.NewController(t)
|
|
defer ctrl.Finish()
|
|
expectIMBearer(t, ctrl, "token-1", 7, 42)
|
|
oldUsers := store.Users
|
|
oldVerifications := store.Verifications
|
|
oldSecurity := store.Security
|
|
oldSender := sendIMVerificationCode
|
|
users := mock_store.NewMockUsersPersistenceInterface(ctrl)
|
|
users.EXPECT().Get(types.TenantID(7), types.Uid(42)).Return(&types.User{
|
|
ObjHeader: types.ObjHeader{Id: types.Uid(42).String()},
|
|
TenantID: 7,
|
|
Phone: "+8613800138000",
|
|
}, nil)
|
|
verifications := &testVerificationStore{}
|
|
security := &testSecurityStore{}
|
|
store.Users = users
|
|
store.Verifications = verifications
|
|
store.Security = security
|
|
sendIMVerificationCode = func(_ types.TenantID, _, _, _, _ string) error { return nil }
|
|
defer func() {
|
|
store.Users = oldUsers
|
|
store.Verifications = oldVerifications
|
|
store.Security = oldSecurity
|
|
sendIMVerificationCode = oldSender
|
|
}()
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/im/security/challenges",
|
|
bytes.NewBufferString(`{"scene":"group_owner_transfer","channel":"sms"}`))
|
|
req.Header.Set("Authorization", "Bearer token-1")
|
|
rec := httptest.NewRecorder()
|
|
serveIMSecurityChallenges(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("create status: got %d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
if verifications.created == nil || verifications.created.Action != "group_owner_transfer" {
|
|
t.Fatalf("unexpected created verification: %#v", verifications.created)
|
|
}
|
|
verifications.get = verifications.created
|
|
|
|
expectIMBearer(t, ctrl, "token-1", 7, 42)
|
|
verifyReq := httptest.NewRequest(http.MethodPost, "/v1/im/security/challenges/"+verifications.created.ChallengeID+"/verify",
|
|
bytes.NewBufferString(`{"code":"123456"}`))
|
|
verifyReq.Header.Set("Authorization", "Bearer token-1")
|
|
verifyRec := httptest.NewRecorder()
|
|
serveIMSecurityChallengeVerify(verifyRec, verifyReq)
|
|
if verifyRec.Code != http.StatusOK {
|
|
t.Fatalf("verify status: got %d body=%s", verifyRec.Code, verifyRec.Body.String())
|
|
}
|
|
if security.actionToken == nil || security.actionToken.Action != "group_owner_transfer" || security.actionToken.Target != "group_owner_transfer" {
|
|
t.Fatalf("unexpected group action token: %#v", security.actionToken)
|
|
}
|
|
}
|
|
|
|
func TestServeIMSecurityChallengeCreateEnforcesResendLimit(t *testing.T) {
|
|
ctrl := gomock.NewController(t)
|
|
defer ctrl.Finish()
|
|
expectIMBearer(t, ctrl, "token-1", 7, 42)
|
|
oldUsers := store.Users
|
|
oldVerifications := store.Verifications
|
|
oldSender := sendIMVerificationCode
|
|
now := time.Now().UTC()
|
|
users := mock_store.NewMockUsersPersistenceInterface(ctrl)
|
|
users.EXPECT().Get(types.TenantID(7), types.Uid(42)).Return(&types.User{
|
|
ObjHeader: types.ObjHeader{Id: types.Uid(42).String()},
|
|
TenantID: 7,
|
|
Phone: "+8613800138000",
|
|
}, nil)
|
|
verifications := &testVerificationStore{active: &types.VerificationCode{
|
|
TenantID: 7,
|
|
ChallengeID: "challenge-active",
|
|
Scene: "sensitive_action",
|
|
Action: "close_youth_mode",
|
|
PhoneHash: hashIMSecret(7, "sensitive_action", "+8613800138000", "phone"),
|
|
TargetUser: 42,
|
|
State: types.VerificationStatePending,
|
|
ExpiresAt: now.Add(5 * time.Minute),
|
|
ResendAfter: now.Add(45 * time.Second),
|
|
}}
|
|
store.Users = users
|
|
store.Verifications = verifications
|
|
sendIMVerificationCode = func(_ types.TenantID, _, _, _, _ string) error { return nil }
|
|
defer func() {
|
|
store.Users = oldUsers
|
|
store.Verifications = oldVerifications
|
|
sendIMVerificationCode = oldSender
|
|
}()
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/im/security/challenges",
|
|
bytes.NewBufferString(`{"scene":"close_youth_mode","channel":"sms"}`))
|
|
req.Header.Set("Authorization", "Bearer token-1")
|
|
rec := httptest.NewRecorder()
|
|
|
|
serveIMSecurityChallenges(rec, req)
|
|
|
|
if rec.Code != http.StatusTooManyRequests {
|
|
t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusTooManyRequests, rec.Body.String())
|
|
}
|
|
if verifications.created != nil {
|
|
t.Fatalf("rate-limited request created a challenge: %#v", verifications.created)
|
|
}
|
|
}
|
|
|
|
func TestServeIMSecurityChallengeCreateRejectsUnknownScene(t *testing.T) {
|
|
ctrl := gomock.NewController(t)
|
|
defer ctrl.Finish()
|
|
expectIMBearer(t, ctrl, "token-1", 7, 42)
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/im/security/challenges",
|
|
bytes.NewBufferString(`{"scene":"made_up_action","channel":"sms"}`))
|
|
req.Header.Set("Authorization", "Bearer token-1")
|
|
rec := httptest.NewRecorder()
|
|
serveIMSecurityChallenges(rec, req)
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusBadRequest, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestServeIMBootstrapIncludesHomeCapabilities(t *testing.T) {
|
|
ctrl := gomock.NewController(t)
|
|
defer ctrl.Finish()
|
|
expectIMBearer(t, ctrl, "token-1", 7, 42)
|
|
oldSecurity := store.Security
|
|
oldRelations := store.Relations
|
|
oldThirdParty := store.ThirdPartyConfig
|
|
readAt := time.Now().UTC()
|
|
security := &testSecurityStore{
|
|
realName: &types.RealNameVerification{Status: "verified"},
|
|
notifications: []types.SystemNotification{
|
|
{EventID: "evt-security-created", EventType: "security.notice.created"},
|
|
{EventID: "evt-profile-updated", EventType: "profile.updated"},
|
|
{EventID: "evt-friend-request", EventType: "friend.request.created"},
|
|
{EventID: "evt-security-read", EventType: "security.notice.created", ReadAt: &readAt},
|
|
},
|
|
}
|
|
relations := &testRelationsStore{pendingCount: 2}
|
|
store.Security = security
|
|
store.Relations = relations
|
|
store.ThirdPartyConfig = testThirdPartyConfigStore{
|
|
"agora_enabled": "1",
|
|
"agora_auth_mode": "app_id",
|
|
"agora_app_id": "app123",
|
|
"agora_token_ttl_seconds": "3600",
|
|
"audio_call_enabled": "1",
|
|
"video_call_enabled": "1",
|
|
}
|
|
defer func() {
|
|
store.Security = oldSecurity
|
|
store.Relations = oldRelations
|
|
store.ThirdPartyConfig = oldThirdParty
|
|
}()
|
|
|
|
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)
|
|
}
|
|
features := data["features"].(map[string]any)
|
|
if features["audio_call"] != true || features["video_call"] != true {
|
|
t.Fatalf("bootstrap call features disabled unexpectedly: %#v", features)
|
|
}
|
|
if security.realNameTenant != 7 || security.realNameUser != 42 {
|
|
t.Fatalf("bootstrap realname lookup scope: tenant=%d uid=%s", security.realNameTenant, security.realNameUser)
|
|
}
|
|
if security.unreadTenant != 7 || security.unreadUser != 42 {
|
|
t.Fatalf("bootstrap system unread lookup scope: tenant=%d uid=%s", security.unreadTenant, security.unreadUser)
|
|
}
|
|
if relations.countTenant != 7 || relations.countUser != 42 {
|
|
t.Fatalf("bootstrap friend request count scope: tenant=%d uid=%s", relations.countTenant, relations.countUser)
|
|
}
|
|
badges := data["entry_badges"].(map[string]any)
|
|
if badges["new_friends_pending_count"].(float64) != 2 ||
|
|
badges["contacts_total_unread"].(float64) != 2 ||
|
|
badges["system_unread_count"].(float64) != 3 {
|
|
t.Fatalf("unexpected bootstrap badges: %#v", badges)
|
|
}
|
|
entries := data["entries"].(map[string]any)
|
|
newFriends := entries["new_friends"].(map[string]any)
|
|
if newFriends["route"] != "/contacts/new-friends" || newFriends["unread_count"].(float64) != 2 || newFriends["unread_source"] != "business" {
|
|
t.Fatalf("unexpected new friends entry: %#v", newFriends)
|
|
}
|
|
systemMessages := entries["system_messages"].(map[string]any)
|
|
if systemMessages["topic"] != "sys" || systemMessages["unread_count"].(float64) != 3 || systemMessages["unread_source"] != "business" {
|
|
t.Fatalf("unexpected system messages entry: %#v", systemMessages)
|
|
}
|
|
fileAssistant := entries["file_assistant"].(map[string]any)
|
|
if fileAssistant["topic"] != "slf" || fileAssistant["unread_source"] != "topic" {
|
|
t.Fatalf("unexpected file assistant entry: %#v", fileAssistant)
|
|
}
|
|
realname := data["security"].(map[string]any)["realname"].(map[string]any)
|
|
if realname["status"] != "verified" {
|
|
t.Fatalf("unexpected bootstrap realname status: %#v", realname)
|
|
}
|
|
}
|
|
|
|
func TestServeIMMeIncludesUsername(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)
|
|
users.EXPECT().Get(types.TenantID(7), types.Uid(42)).Return(&types.User{Username: "gcharles7", Phone: "+8613800138000"}, nil)
|
|
store.Users = users
|
|
defer func() { store.Users = oldUsers }()
|
|
oldProfiles := store.Profiles
|
|
store.Profiles = &testProfilesStore{}
|
|
defer func() { store.Profiles = oldProfiles }()
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/v1/im/me", nil)
|
|
req.Header.Set("Authorization", "Bearer token-1")
|
|
rec := httptest.NewRecorder()
|
|
serveIMMe(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
|
|
if data["user_id"] != types.Uid(42).UserId() || data["username"] != "gcharles7" {
|
|
t.Fatalf("unexpected me payload: %#v body=%s", data, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestServeIMMeProfile(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)
|
|
users.EXPECT().Get(types.TenantID(7), types.Uid(42)).Return(&types.User{Phone: "+8613800138000", Username: "gcharles7"}, nil)
|
|
store.Users = users
|
|
defer func() { store.Users = oldUsers }()
|
|
oldProfiles := store.Profiles
|
|
profiles := &testProfilesStore{}
|
|
store.Profiles = profiles
|
|
defer func() { store.Profiles = oldProfiles }()
|
|
|
|
req := httptest.NewRequest(http.MethodPatch, "/v1/im/me/profile", strings.NewReader(`{"nickname":"新昵称","signature":"签名","email":"me@example.com","version":1}`))
|
|
req.Header.Set("Authorization", "Bearer token-1")
|
|
rec := httptest.NewRecorder()
|
|
serveIMMeProfile(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
|
|
if data["nickname"] != "新昵称" || data["email"] != "me@example.com" || data["version"].(float64) != 2 ||
|
|
data["phone_mask"] != "138****8000" || data["username"] != "gcharles7" {
|
|
t.Fatalf("unexpected profile: %#v", data)
|
|
}
|
|
}
|
|
|
|
func TestServeIMMeProfileGetPhoneMask(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)
|
|
users.EXPECT().Get(types.TenantID(7), types.Uid(42)).Return(&types.User{Phone: "+8613600003333", Username: "gcharles7"}, nil)
|
|
store.Users = users
|
|
defer func() { store.Users = oldUsers }()
|
|
oldProfiles := store.Profiles
|
|
store.Profiles = &testProfilesStore{}
|
|
defer func() { store.Profiles = oldProfiles }()
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/v1/im/me/profile", nil)
|
|
req.Header.Set("Authorization", "Bearer token-1")
|
|
rec := httptest.NewRecorder()
|
|
serveIMMeProfile(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
|
|
if data["phone_mask"] != "136****3333" || data["username"] != "gcharles7" {
|
|
t.Fatalf("unexpected account fields: %#v body=%s", data, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestMaskIMProfilePhone(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
phone string
|
|
want string
|
|
}{
|
|
{name: "empty", phone: "", want: ""},
|
|
{name: "mainland e164", phone: "+8613600003333", want: "136****3333"},
|
|
{name: "mainland local", phone: "13600003333", want: "136****3333"},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
if got := maskIMProfilePhone(tc.phone); got != tc.want {
|
|
t.Fatalf("maskIMProfilePhone(%q)=%q, want %q", tc.phone, got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestServeIMMePrivacy(t *testing.T) {
|
|
ctrl := gomock.NewController(t)
|
|
defer ctrl.Finish()
|
|
expectIMBearer(t, ctrl, "token-1", 7, 42)
|
|
oldProfiles := store.Profiles
|
|
profiles := &testProfilesStore{}
|
|
store.Profiles = profiles
|
|
defer func() { store.Profiles = oldProfiles }()
|
|
|
|
req := httptest.NewRequest(http.MethodPatch, "/v1/im/me/privacy", strings.NewReader(`{"add_me_methods":{"account":true,"phone":false,"qr":true,"group":true},"read_receipts":false,"friend_request_verification":false,"group_invite_verification":true,"version":1}`))
|
|
req.Header.Set("Authorization", "Bearer token-1")
|
|
rec := httptest.NewRecorder()
|
|
serveIMMePrivacy(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
|
|
methods := data["add_me_methods"].(map[string]any)
|
|
editable := data["editable"].(map[string]any)
|
|
if methods["phone"] != false || data["read_receipts"] != false || data["friend_request_verification"] != false || data["group_invite_verification"] != true ||
|
|
editable["friend_request_verification"] != true || editable["group_invite_verification"] != true || data["version"].(float64) != 2 {
|
|
t.Fatalf("unexpected privacy: %#v", data)
|
|
}
|
|
}
|
|
|
|
func TestIMCanViewLastSeen(t *testing.T) {
|
|
oldProfiles := store.Profiles
|
|
oldRelations := store.Relations
|
|
defer func() {
|
|
store.Profiles = oldProfiles
|
|
store.Relations = oldRelations
|
|
}()
|
|
|
|
viewer, target := types.Uid(1), types.Uid(2)
|
|
profiles := &testProfilesStore{privacy: &types.UserPrivacySettings{
|
|
UserID: target, LastSeenVisibility: "everyone",
|
|
}}
|
|
store.Profiles = profiles
|
|
store.Relations = &testRelationsStore{context: &types.RelationshipContext{
|
|
TenantID: 7, UserID: viewer, PeerID: target, Relationship: types.RelationshipNone,
|
|
}}
|
|
if !imCanViewLastSeen(7, viewer, target) {
|
|
t.Fatal("everyone visibility must expose last seen")
|
|
}
|
|
|
|
profiles.privacy.LastSeenVisibility = "nobody"
|
|
if imCanViewLastSeen(7, viewer, target) {
|
|
t.Fatal("nobody visibility must hide last seen")
|
|
}
|
|
|
|
profiles.privacy.LastSeenVisibility = "friends"
|
|
if imCanViewLastSeen(7, viewer, target) {
|
|
t.Fatal("non-friend must not see friends-only last seen")
|
|
}
|
|
store.Relations = &testRelationsStore{context: &types.RelationshipContext{
|
|
TenantID: 7, UserID: viewer, PeerID: target, Relationship: types.RelationshipFriend,
|
|
}}
|
|
if !imCanViewLastSeen(7, viewer, target) {
|
|
t.Fatal("friend must see friends-only last seen")
|
|
}
|
|
}
|
|
|
|
func TestServeIMAppReleaseLatest(t *testing.T) {
|
|
oldReleases := store.AppReleases
|
|
store.AppReleases = &testAppReleaseStore{}
|
|
defer func() {
|
|
store.AppReleases = oldReleases
|
|
}()
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/v1/im/app/releases/latest?platform=ios¤t_version=1.0.0", nil)
|
|
rec := httptest.NewRecorder()
|
|
serveIMAppReleaseLatest(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["platform"] != "ios" || data["latest_version"] != "1.0.1" || data["channel"] != "stable" {
|
|
t.Fatalf("unexpected release payload: %#v", data)
|
|
}
|
|
if data["has_update"] != true || data["update_type"] != "recommended" || data["force_update"] != false {
|
|
t.Fatalf("unexpected update decision: %#v", data)
|
|
}
|
|
}
|
|
|
|
func TestServeIMAppReleaseLatestSupportsWeb(t *testing.T) {
|
|
oldReleases := store.AppReleases
|
|
store.AppReleases = &testAppReleaseStore{release: &types.AppRelease{
|
|
Platform: "web", Channel: "stable", LatestVersion: "1.0.0", MinSupportedVersion: "1.0.0",
|
|
UpdateType: "none", ReleaseNotes: "当前已是最新版本。", PublishedAt: time.Now().UTC(),
|
|
}}
|
|
defer func() {
|
|
store.AppReleases = oldReleases
|
|
}()
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/v1/im/app/releases/latest?platform=web¤t_version=1.0.0", nil)
|
|
rec := httptest.NewRecorder()
|
|
serveIMAppReleaseLatest(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["platform"] != "web" || data["has_update"] != false || data["update_type"] != "none" {
|
|
t.Fatalf("unexpected web release payload: %#v", data)
|
|
}
|
|
}
|
|
|
|
func TestServeIMAppReleaseLatestFallsBackForWebConfigError(t *testing.T) {
|
|
oldReleases := store.AppReleases
|
|
store.AppReleases = &testAppReleaseStore{err: types.ErrUnsupported}
|
|
defer func() {
|
|
store.AppReleases = oldReleases
|
|
}()
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/v1/im/app/releases/latest?platform=web¤t_version=1.0.0", nil)
|
|
rec := httptest.NewRecorder()
|
|
serveIMAppReleaseLatest(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["platform"] != "web" || data["latest_version"] != "1.0.0" || data["update_type"] != "none" || data["has_update"] != false {
|
|
t.Fatalf("unexpected fallback release payload: %#v", data)
|
|
}
|
|
}
|
|
|
|
func TestServeIMAppReleaseLatestForcesUnsupportedVersion(t *testing.T) {
|
|
oldReleases := store.AppReleases
|
|
store.AppReleases = &testAppReleaseStore{release: &types.AppRelease{
|
|
Platform: "ios", Channel: "stable", LatestVersion: "2.0.0", MinSupportedVersion: "1.5.0",
|
|
UpdateType: "recommended", PublishedAt: time.Now().UTC(),
|
|
}}
|
|
defer func() { store.AppReleases = oldReleases }()
|
|
req := httptest.NewRequest(http.MethodGet, "/v1/im/app/releases/latest?platform=ios¤t_version=1.4.9", nil)
|
|
rec := httptest.NewRecorder()
|
|
serveIMAppReleaseLatest(rec, req)
|
|
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
|
|
if data["has_update"] != true || data["update_type"] != "force" || data["force_update"] != true {
|
|
t.Fatalf("unexpected forced update decision: %#v", data)
|
|
}
|
|
}
|
|
|
|
func TestServeIMAppReleaseLatestRequiresCurrentVersion(t *testing.T) {
|
|
oldReleases := store.AppReleases
|
|
store.AppReleases = &testAppReleaseStore{}
|
|
defer func() { store.AppReleases = oldReleases }()
|
|
req := httptest.NewRequest(http.MethodGet, "/v1/im/app/releases/latest?platform=ios", nil)
|
|
rec := httptest.NewRecorder()
|
|
serveIMAppReleaseLatest(rec, req)
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusBadRequest, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestServeIMAppReleaseLatestReturnsNoUpdateForCurrentVersion(t *testing.T) {
|
|
oldReleases := store.AppReleases
|
|
store.AppReleases = &testAppReleaseStore{release: &types.AppRelease{
|
|
Platform: "ios", Channel: "stable", LatestVersion: "1.2.0", MinSupportedVersion: "1.0.0",
|
|
UpdateType: "force", PublishedAt: time.Now().UTC(),
|
|
}}
|
|
defer func() { store.AppReleases = oldReleases }()
|
|
req := httptest.NewRequest(http.MethodGet, "/v1/im/app/releases/latest?platform=ios¤t_version=1.2.0", nil)
|
|
rec := httptest.NewRecorder()
|
|
serveIMAppReleaseLatest(rec, req)
|
|
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
|
|
if data["has_update"] != false || data["update_type"] != "none" || data["force_update"] != false {
|
|
t.Fatalf("unexpected no-update decision: %#v", data)
|
|
}
|
|
}
|
|
|
|
func TestNumericAppVersionComparison(t *testing.T) {
|
|
left, err := parseNumericAppVersion("v1.10.0")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
right, err := parseNumericAppVersion("1.9.9")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if compareNumericAppVersion(left, right) <= 0 {
|
|
t.Fatal("expected 1.10.0 to be newer than 1.9.9")
|
|
}
|
|
}
|
|
|
|
func TestServeIMYouthModeEnableDisable(t *testing.T) {
|
|
ctrl := gomock.NewController(t)
|
|
defer ctrl.Finish()
|
|
oldSecurity := store.Security
|
|
security := &testSecurityStore{
|
|
actionToken: &types.ActionToken{Action: "enable_youth_mode", Target: "youth_mode"},
|
|
independentPassword: &types.IndependentPassword{PasswordHash: []byte("hash")},
|
|
}
|
|
store.Security = security
|
|
defer func() { store.Security = oldSecurity }()
|
|
|
|
expectIMBearer(t, ctrl, "token-enable", 7, 42)
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/im/settings/youth-mode/enable",
|
|
bytes.NewBufferString(`{"policy_version":2,"action_token":"action.1"}`))
|
|
req.Header.Set("Authorization", "Bearer token-enable")
|
|
rec := httptest.NewRecorder()
|
|
serveIMYouthModeEnable(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("enable status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
|
}
|
|
if security.consumedAction != "enable_youth_mode" || security.consumedTarget != "youth_mode" {
|
|
t.Fatalf("unexpected enable token consumption: action=%q target=%q", security.consumedAction, security.consumedTarget)
|
|
}
|
|
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
|
|
if data["enabled"] != true || data["policy_version"].(float64) != 2 {
|
|
t.Fatalf("unexpected youth enable payload: %#v", data)
|
|
}
|
|
items := data["restriction_items"].([]any)
|
|
if len(items) != 3 {
|
|
t.Fatalf("restriction_items: got %#v", items)
|
|
}
|
|
firstItem := items[0].(map[string]any)
|
|
if firstItem["code"] != "night_quiet" || firstItem["title"] != "深夜免打扰" || firstItem["enforced"] != true {
|
|
t.Fatalf("unexpected restriction item: %#v", firstItem)
|
|
}
|
|
secondItem := items[1].(map[string]any)
|
|
if secondItem["code"] != "content_filter" || secondItem["enforced"] != true {
|
|
t.Fatalf("unexpected content filter item: %#v", secondItem)
|
|
}
|
|
|
|
security.actionToken = &types.ActionToken{Action: "close_youth_mode", Target: "youth_mode"}
|
|
expectIMBearer(t, ctrl, "token-disable", 7, 42)
|
|
req = httptest.NewRequest(http.MethodPost, "/v1/im/settings/youth-mode/disable", nil)
|
|
req.Header.Set("Authorization", "Bearer token-disable")
|
|
req.Header.Set("X-Action-Token", "action.2")
|
|
rec = httptest.NewRecorder()
|
|
serveIMYouthModeDisable(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("disable status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
|
}
|
|
data = decodeIMHTTPResponse(t, rec).Data.(map[string]any)
|
|
if data["enabled"] != false {
|
|
t.Fatalf("unexpected youth disable payload: %#v", data)
|
|
}
|
|
}
|
|
|
|
func TestServeIMDevicePushToken(t *testing.T) {
|
|
ctrl := gomock.NewController(t)
|
|
defer ctrl.Finish()
|
|
oldTokens := store.DevicePushTokens
|
|
tokenStore := &testDevicePushTokenStore{}
|
|
store.DevicePushTokens = tokenStore
|
|
defer func() { store.DevicePushTokens = oldTokens }()
|
|
|
|
expectIMBearer(t, ctrl, "token-1", 7, 42)
|
|
req := httptest.NewRequest(http.MethodPut, "/v1/im/devices/device-1/push-token",
|
|
bytes.NewBufferString(`{"provider":"fcm","token":"push-token-1","app_version":"1.0.1","platform":"android"}`))
|
|
req.Header.Set("Authorization", "Bearer token-1")
|
|
rec := httptest.NewRecorder()
|
|
serveIMDevicePushToken(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
|
}
|
|
if tokenStore.tenantID != 7 || tokenStore.token == nil || tokenStore.token.UserID != 42 || tokenStore.token.DeviceID != "device-1" || tokenStore.token.Token != "push-token-1" {
|
|
t.Fatalf("unexpected stored push token: %#v", tokenStore.token)
|
|
}
|
|
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
|
|
if data["device_id"] != "device-1" || data["provider"] != "fcm" {
|
|
t.Fatalf("unexpected response: %#v", data)
|
|
}
|
|
}
|
|
|
|
func TestIMLogoutDeviceID(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/im/auth/logout", bytes.NewBufferString(`{"device_id":"device-1","ignored":true}`))
|
|
req.Header.Set("X-Device-ID", "header-device")
|
|
rec := httptest.NewRecorder()
|
|
deviceID, ok := imLogoutDeviceID(rec, req)
|
|
if !ok || deviceID != "device-1" || rec.Code != http.StatusOK {
|
|
t.Fatalf("unexpected body device id: ok=%v device_id=%q status=%d body=%s", ok, deviceID, rec.Code, rec.Body.String())
|
|
}
|
|
|
|
req = httptest.NewRequest(http.MethodPost, "/v1/im/auth/logout", nil)
|
|
req.Header.Set("X-IM-Device-ID", "header-device")
|
|
rec = httptest.NewRecorder()
|
|
deviceID, ok = imLogoutDeviceID(rec, req)
|
|
if !ok || deviceID != "header-device" || rec.Code != http.StatusOK {
|
|
t.Fatalf("unexpected header device id: ok=%v device_id=%q status=%d body=%s", ok, deviceID, rec.Code, rec.Body.String())
|
|
}
|
|
|
|
req = httptest.NewRequest(http.MethodPost, "/v1/im/auth/logout", bytes.NewBufferString(`{"device_id":"bad/device"}`))
|
|
rec = httptest.NewRecorder()
|
|
if deviceID, ok = imLogoutDeviceID(rec, req); ok || rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("invalid device id accepted: ok=%v device_id=%q status=%d body=%s", ok, deviceID, rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
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 TestServeIMConversationSearchNoDisclosureOnNoMatch(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)
|
|
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{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)
|
|
if len(data["items"].([]any)) != 0 {
|
|
t.Fatalf("conversation search leaked unmatched topic: %#v", data)
|
|
}
|
|
}
|
|
|
|
func TestServeIMConversationSearchExpiredTokenDoesNotQueryTopics(t *testing.T) {
|
|
ctrl := gomock.NewController(t)
|
|
defer ctrl.Finish()
|
|
oldStore := store.Store
|
|
oldUsers := store.Users
|
|
mockStore := mock_store.NewMockPersistentStorageInterface(ctrl)
|
|
tokenAuth := mock_auth.NewMockAuthHandler(ctrl)
|
|
users := mock_store.NewMockUsersPersistenceInterface(ctrl)
|
|
store.Store = mockStore
|
|
store.Users = users
|
|
defer func() {
|
|
store.Store = oldStore
|
|
store.Users = oldUsers
|
|
}()
|
|
|
|
mockStore.EXPECT().GetLogicalAuthHandler("token").Return(tokenAuth)
|
|
tokenAuth.EXPECT().IsInitialized().Return(true)
|
|
tokenAuth.EXPECT().Authenticate(auth.AuthContext{}, []byte("expired-token")).Return(nil, nil, types.ErrExpired)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/v1/im/search/conversations?q=alice", nil)
|
|
req.Header.Set("Authorization", "Bearer expired-token")
|
|
rec := httptest.NewRecorder()
|
|
|
|
serveIMConversationSearch(rec, req)
|
|
|
|
if rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusUnauthorized, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestServeIMSystemNotificationsListAndRead(t *testing.T) {
|
|
ctrl := gomock.NewController(t)
|
|
defer ctrl.Finish()
|
|
oldSecurity := store.Security
|
|
readAt := time.Date(2026, 8, 13, 9, 0, 0, 0, time.UTC)
|
|
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),
|
|
}, {
|
|
TenantID: 7,
|
|
UserID: 42,
|
|
EventID: "evt-2",
|
|
EventType: "system.notice",
|
|
Title: "系统公告",
|
|
Summary: "已读公告",
|
|
ReadAt: &readAt,
|
|
CreatedAt: readAt,
|
|
}}, nextCursor: "cursor-2"}
|
|
store.Security = security
|
|
defer func() { store.Security = oldSecurity }()
|
|
|
|
expectIMBearer(t, ctrl, "token-list", 7, 42)
|
|
req := httptest.NewRequest(http.MethodGet, "/v1/im/system-notifications?cursor=cursor-1&limit=2", 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)
|
|
items := data["items"].([]any)
|
|
if len(items) != 2 {
|
|
t.Fatalf("unexpected list response: %#v", data)
|
|
}
|
|
if _, ok := items[0].(map[string]any)["read_at"]; ok {
|
|
t.Fatalf("unread notification unexpectedly has read_at: %#v", items[0])
|
|
}
|
|
if items[1].(map[string]any)["read_at"] == nil {
|
|
t.Fatalf("read notification missing read_at: %#v", items[1])
|
|
}
|
|
if data["next_cursor"] != "cursor-2" || security.listTenant != 7 || security.listUser != 42 || security.listCursor != "cursor-1" || security.listLimit != 2 {
|
|
t.Fatalf("notification list scope/paging mismatch: data=%#v store=%#v", data, security)
|
|
}
|
|
|
|
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.readTenant != 7 || security.readUser != 42 || security.readNotificationID != "evt-1" || security.notifications[0].ReadAt == nil {
|
|
t.Fatalf("notification was not marked read: %#v", security)
|
|
}
|
|
readData := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
|
|
if readData["id"] != "evt-1" || readData["status"] != "ok" || readData["read_at"] != security.notifications[0].ReadAt.UTC().Format(time.RFC3339Nano) {
|
|
t.Fatalf("read response did not return persisted notification state: %#v", readData)
|
|
}
|
|
|
|
expectIMBearer(t, ctrl, "token-read-again", 7, 42)
|
|
req = httptest.NewRequest(http.MethodPost, "/v1/im/system-notifications/evt-2/read", bytes.NewBufferString(`{"read_at":"2026-09-01T00:00:00Z"}`))
|
|
req.Header.Set("Authorization", "Bearer token-read-again")
|
|
req.Header.Set("Idempotency-Key", "read-2")
|
|
rec = httptest.NewRecorder()
|
|
|
|
serveIMSystemNotificationRead(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("read again status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
|
}
|
|
readAgainData := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
|
|
if readAgainData["id"] != "evt-2" || readAgainData["read_at"] != readAt.UTC().Format(time.RFC3339Nano) {
|
|
t.Fatalf("read again response should keep persisted read_at: %#v", readAgainData)
|
|
}
|
|
}
|
|
|
|
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
|
|
oldProfiles := store.Profiles
|
|
subs := mock_store.NewMockSubsPersistenceInterface(ctrl)
|
|
topics := mock_store.NewMockTopicsPersistenceInterface(ctrl)
|
|
store.Subs = subs
|
|
store.Topics = topics
|
|
store.Profiles = &testProfilesStore{}
|
|
defer func() {
|
|
store.Subs = oldSubs
|
|
store.Topics = oldTopics
|
|
store.Profiles = oldProfiles
|
|
}()
|
|
|
|
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
|
|
oldGroupBusiness := store.GroupBusiness
|
|
oldRelations := store.Relations
|
|
subs := mock_store.NewMockSubsPersistenceInterface(ctrl)
|
|
store.Subs = subs
|
|
groupStore := &testGroupBusinessStore{listMembersNext: "101"}
|
|
store.GroupBusiness = groupStore
|
|
store.Relations = &testRelationsStore{context: &types.RelationshipContext{
|
|
TenantID: 7,
|
|
UserID: 42,
|
|
PeerID: types.Uid(101),
|
|
Topic: types.Uid(42).P2PName(types.Uid(101)),
|
|
Relationship: types.RelationshipFriend,
|
|
CanSend: true,
|
|
Remark: "老王",
|
|
}}
|
|
defer func() {
|
|
store.Subs = oldSubs
|
|
store.GroupBusiness = oldGroupBusiness
|
|
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)
|
|
groupStore.listMembers = []types.GroupMember{{
|
|
ID: 101,
|
|
Topic: "grpMention",
|
|
UserID: types.Uid(101),
|
|
Role: types.GroupMemberRoleMember,
|
|
ModeWant: types.ModeCPublic,
|
|
ModeGiven: types.ModeCPublic,
|
|
Nickname: "群里小王",
|
|
Public: map[string]any{"display_name": "Alice", "nickname": "Alice原名", "avatar_file_id": "alice.jpg"},
|
|
MemberStatus: types.GroupMemberStatusActive,
|
|
}}
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/v1/im/groups/grpMention/members?query=ali&mentionable=true&limit=20&cursor=99", 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"] != "老王" ||
|
|
items[0].(map[string]any)["nickname"] != "Alice原名" ||
|
|
items[0].(map[string]any)["group_nickname"] != "群里小王" ||
|
|
items[0].(map[string]any)["avatar_file_id"] != "alice.jpg" ||
|
|
items[0].(map[string]any)["mentionable"] != true {
|
|
t.Fatalf("unexpected members response: %#v", data)
|
|
}
|
|
if data["next_cursor"] != "101" {
|
|
t.Fatalf("unexpected cursor: %#v", data)
|
|
}
|
|
if data["permissions"].(map[string]any)["can_mention_all"] != true {
|
|
t.Fatalf("expected can_mention_all permission: %#v", data)
|
|
}
|
|
if groupStore.listMembersOpts.Query != "ali" || !groupStore.listMembersOpts.Mentionable ||
|
|
groupStore.listMembersOpts.Limit != 20 || groupStore.listMembersOpts.Cursor != "99" {
|
|
t.Fatalf("members options were not forwarded: %#v", groupStore.listMembersOpts)
|
|
}
|
|
}
|
|
|
|
func TestServeIMGroupMemberReturnsSingleSnapshot(t *testing.T) {
|
|
ctrl := gomock.NewController(t)
|
|
defer ctrl.Finish()
|
|
expectIMBearer(t, ctrl, "token-1", 7, 42)
|
|
oldSubs := store.Subs
|
|
oldUsers := store.Users
|
|
oldGroupBusiness := store.GroupBusiness
|
|
oldRelations := store.Relations
|
|
subs := mock_store.NewMockSubsPersistenceInterface(ctrl)
|
|
users := mock_store.NewMockUsersPersistenceInterface(ctrl)
|
|
store.Subs = subs
|
|
store.Users = users
|
|
memberID := types.Uid(101)
|
|
joinedAt := time.Date(2026, 9, 18, 0, 0, 0, 0, time.UTC)
|
|
store.GroupBusiness = &testGroupBusinessStore{member: &types.GroupMember{
|
|
ID: 101, Topic: "grpMention", UserID: memberID, Role: types.GroupMemberRoleMember,
|
|
MemberStatus: types.GroupMemberStatusActive, Nickname: "群里小王", Version: 3,
|
|
JoinedAt: joinedAt, UpdatedAt: joinedAt.Add(time.Minute),
|
|
}}
|
|
store.Relations = &testRelationsStore{context: &types.RelationshipContext{
|
|
TenantID: 7, UserID: 42, PeerID: memberID, Relationship: types.RelationshipNone,
|
|
}}
|
|
t.Cleanup(func() {
|
|
store.Subs = oldSubs
|
|
store.Users = oldUsers
|
|
store.GroupBusiness = oldGroupBusiness
|
|
store.Relations = oldRelations
|
|
})
|
|
|
|
fullSub := func(uid types.Uid) *types.Subscription {
|
|
return &types.Subscription{TenantID: 7, User: uid.String(), Topic: "grpMention", ModeWant: types.ModeCFull, ModeGiven: types.ModeCFull}
|
|
}
|
|
subs.EXPECT().Get(types.TenantID(7), "grpMention", types.Uid(42), false).Return(fullSub(42), nil)
|
|
subs.EXPECT().Get(types.TenantID(7), "grpMention", memberID, false).Return(fullSub(memberID), nil)
|
|
users.EXPECT().Get(types.TenantID(7), memberID).Return(&types.User{
|
|
ObjHeader: types.ObjHeader{Id: memberID.String()}, Public: map[string]any{"nickname": "Alice", "avatar_file_id": "alice.jpg"},
|
|
}, nil)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/v1/im/groups/grpMention/members/"+memberID.UserId(), 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["user_id"] != memberID.UserId() || data["sync_mode"] != "single" || data["version"].(float64) != 3 ||
|
|
data["group_nickname"] != "群里小王" || data["nickname"] != "Alice" {
|
|
t.Fatalf("unexpected single member response: %#v", data)
|
|
}
|
|
}
|
|
|
|
func TestServeIMGroupMemberDeleteRemovesMember(t *testing.T) {
|
|
ctrl := gomock.NewController(t)
|
|
defer ctrl.Finish()
|
|
expectIMBearer(t, ctrl, "token-1", 7, 42)
|
|
oldSubs := store.Subs
|
|
oldGroupBusiness := store.GroupBusiness
|
|
subs := mock_store.NewMockSubsPersistenceInterface(ctrl)
|
|
store.Subs = subs
|
|
target := types.Uid(101)
|
|
groupStore := &testGroupBusinessStore{member: &types.GroupMember{
|
|
ID: 101, Topic: "grpMention", UserID: target, Role: types.GroupMemberRoleMember,
|
|
MemberStatus: types.GroupMemberStatusActive, Version: 3,
|
|
}}
|
|
store.GroupBusiness = groupStore
|
|
t.Cleanup(func() {
|
|
store.Subs = oldSubs
|
|
store.GroupBusiness = oldGroupBusiness
|
|
})
|
|
|
|
ownerSub := &types.Subscription{TenantID: 7, User: types.Uid(42).String(), Topic: "grpMention", ModeWant: types.ModeCFull, ModeGiven: types.ModeCFull}
|
|
subs.EXPECT().Get(types.TenantID(7), "grpMention", types.Uid(42), false).Return(ownerSub, nil)
|
|
|
|
req := httptest.NewRequest(http.MethodDelete, "/v1/im/groups/grpMention/members/"+target.UserId(), 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())
|
|
}
|
|
if groupStore.leftTopic != "grpMention" || groupStore.leftUID != target {
|
|
t.Fatalf("member was not removed: topic=%q uid=%s", groupStore.leftTopic, groupStore.leftUID.UserId())
|
|
}
|
|
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
|
|
if data["topic"] != "grpMention" || data["user_id"] != target.UserId() || data["status"] != "left" {
|
|
t.Fatalf("unexpected delete response: %#v", data)
|
|
}
|
|
}
|
|
|
|
func TestGroupMemberPayloadUserIDFormats(t *testing.T) {
|
|
raw := types.Uid(101)
|
|
fromRaw := groupMemberPayload(types.Subscription{
|
|
User: raw.String(),
|
|
ModeWant: types.ModeJoin,
|
|
ModeGiven: types.ModeJoin,
|
|
}, true, "")
|
|
if fromRaw["user_id"] != raw.UserId() {
|
|
t.Fatalf("raw uid user_id: got %#v, want %s", fromRaw["user_id"], raw.UserId())
|
|
}
|
|
|
|
prefixed := types.Uid(102)
|
|
fromPrefixed := groupMemberPayload(types.Subscription{
|
|
User: prefixed.UserId(),
|
|
ModeWant: types.ModeJoin,
|
|
ModeGiven: types.ModeJoin,
|
|
}, true, "")
|
|
if fromPrefixed["user_id"] != prefixed.UserId() {
|
|
t.Fatalf("prefixed uid user_id: got %#v, want %s", fromPrefixed["user_id"], prefixed.UserId())
|
|
}
|
|
}
|
|
|
|
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.RelationshipFriend,
|
|
CanSend: true,
|
|
Remark: "老王",
|
|
}}
|
|
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.RelationshipFriend || data["signature"] != "hi" || data["role"] != "member" {
|
|
t.Fatalf("unexpected group member profile response: %#v", data)
|
|
}
|
|
if data["display_name"] != "老王" {
|
|
t.Fatalf("group member profile display name should use friend remark: %#v", data)
|
|
}
|
|
}
|
|
|
|
func TestServeIMGroupMemberProfileRejectsUserTopic(t *testing.T) {
|
|
ctrl := gomock.NewController(t)
|
|
defer ctrl.Finish()
|
|
expectIMBearer(t, ctrl, "token-1", 7, 42)
|
|
memberID := types.Uid(42).UserId()
|
|
req := httptest.NewRequest(http.MethodGet, "/v1/im/groups/"+memberID+"/members/"+memberID+"/profile", nil)
|
|
req.Header.Set("Authorization", "Bearer token-1")
|
|
rec := httptest.NewRecorder()
|
|
|
|
serveIMGroupResource(rec, req)
|
|
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusBadRequest, rec.Body.String())
|
|
}
|
|
body := decodeIMHTTPResponse(t, rec)
|
|
if body.Reason != imReasonMalformedRequest {
|
|
t.Fatalf("reason: got %q, want %q", body.Reason, imReasonMalformedRequest)
|
|
}
|
|
details := body.Details.(map[string]any)
|
|
if details["field"] != "topic" || details["actual"] != memberID {
|
|
t.Fatalf("unexpected details: %#v", details)
|
|
}
|
|
}
|
|
|
|
func TestServeIMGroupList(t *testing.T) {
|
|
ctrl := gomock.NewController(t)
|
|
defer ctrl.Finish()
|
|
expectIMBearer(t, ctrl, "token-1", 7, 42)
|
|
oldUsers := store.Users
|
|
oldGroupBusiness := store.GroupBusiness
|
|
users := mock_store.NewMockUsersPersistenceInterface(ctrl)
|
|
groupBusiness := &testGroupBusinessStore{settings: &types.GroupSettings{
|
|
Topic: "grpAlpha",
|
|
OwnerUserID: types.Uid(42),
|
|
Name: "Alpha",
|
|
AvatarFileID: "file-1",
|
|
MemberCount: 3,
|
|
Version: 1,
|
|
UpdatedAt: time.Date(2026, 8, 12, 10, 0, 0, 0, time.UTC),
|
|
}}
|
|
store.Users = users
|
|
store.GroupBusiness = groupBusiness
|
|
defer func() {
|
|
store.Users = oldUsers
|
|
store.GroupBusiness = oldGroupBusiness
|
|
}()
|
|
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": "grpAlpha"})
|
|
sub.SetSubCnt(0)
|
|
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)["member_count"].(float64) != 0 || 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
|
|
oldGroupBusiness := store.GroupBusiness
|
|
oldFiles := store.Files
|
|
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)
|
|
files := mock_store.NewMockFilePersistenceInterface(ctrl)
|
|
store.Topics = topics
|
|
store.Subs = subs
|
|
store.Files = files
|
|
groupBusiness := &testGroupBusinessStore{}
|
|
store.GroupBusiness = groupBusiness
|
|
defer func() {
|
|
store.Store = oldStore
|
|
store.Topics = oldTopics
|
|
store.Subs = oldSubs
|
|
store.Files = oldFiles
|
|
store.GroupBusiness = oldGroupBusiness
|
|
}()
|
|
avatarID := types.Uid(101).String()
|
|
|
|
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": avatarID + ".jpg", "description": "Project discussion", "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)
|
|
files.EXPECT().LinkAttachments(types.TenantID(7), "grpnewgroup", types.ZeroUid, []string{avatarID}).Return(nil)
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/im/groups", bytes.NewBufferString(`{"name":"New Group","description":"Project discussion","member_user_ids":["usrAgAAAAAAAAA"],"avatar_file_id":"`+avatarID+`.jpg"}`))
|
|
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" || data["description"] != "Project discussion" {
|
|
t.Fatalf("unexpected group create response: %#v", data)
|
|
}
|
|
if groupBusiness.settings == nil || groupBusiness.settings.Description != "Project discussion" {
|
|
t.Fatalf("group settings missing description: %#v", groupBusiness.settings)
|
|
}
|
|
}
|
|
|
|
func TestServeIMGroupDetailPatchSettingsProfileAndLeave(t *testing.T) {
|
|
ctrl := gomock.NewController(t)
|
|
defer ctrl.Finish()
|
|
oldStore := store.Store
|
|
oldGroupBusiness := store.GroupBusiness
|
|
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
|
|
groupStore := &testGroupBusinessStore{}
|
|
store.GroupBusiness = groupStore
|
|
defer func() {
|
|
store.Store = oldStore
|
|
store.Topics = oldTopics
|
|
store.Subs = oldSubs
|
|
store.GroupBusiness = oldGroupBusiness
|
|
}()
|
|
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/members/me/settings", bytes.NewBufferString(`{"pinned":true,"mute_notifications":true,"version":1}`))
|
|
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 TestServeIMGroupDetailPatchKeepsTopicProfileWhenBusinessSettingsMissing(t *testing.T) {
|
|
ctrl := gomock.NewController(t)
|
|
defer ctrl.Finish()
|
|
oldStore := store.Store
|
|
oldGroupBusiness := store.GroupBusiness
|
|
oldFiles := store.Files
|
|
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
|
|
oldTopics := store.Topics
|
|
oldSubs := store.Subs
|
|
topics := mock_store.NewMockTopicsPersistenceInterface(ctrl)
|
|
subs := mock_store.NewMockSubsPersistenceInterface(ctrl)
|
|
files := mock_store.NewMockFilePersistenceInterface(ctrl)
|
|
store.Topics = topics
|
|
store.Subs = subs
|
|
store.Files = files
|
|
store.GroupBusiness = &testGroupBusinessStore{updateSettingsErr: types.ErrNotFound}
|
|
defer func() {
|
|
store.Store = oldStore
|
|
store.Topics = oldTopics
|
|
store.Subs = oldSubs
|
|
store.Files = oldFiles
|
|
store.GroupBusiness = oldGroupBusiness
|
|
}()
|
|
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},
|
|
}
|
|
avatarID := types.Uid(100).String()
|
|
var savedPublic map[string]any
|
|
subs.EXPECT().Get(types.TenantID(7), "grpAlpha", types.Uid(42), false).Return(selfSub, nil)
|
|
topics.EXPECT().Get(types.TenantID(7), "grpAlpha").Return(group, nil)
|
|
files.EXPECT().LinkAttachments(types.TenantID(7), "grpAlpha", types.ZeroUid, []string{avatarID}).Return(nil)
|
|
topics.EXPECT().Update(types.TenantID(7), "grpAlpha", gomock.Any()).DoAndReturn(
|
|
func(_ types.TenantID, _ string, update map[string]any) error {
|
|
public, ok := update["Public"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("Public update missing: %#v", update)
|
|
}
|
|
savedPublic = public
|
|
return nil
|
|
},
|
|
)
|
|
topics.EXPECT().Get(types.TenantID(7), "grpAlpha").DoAndReturn(
|
|
func(_ types.TenantID, _ string) (*types.Topic, error) {
|
|
return &types.Topic{
|
|
ObjHeader: types.ObjHeader{Id: "grpAlpha"},
|
|
TenantID: 7,
|
|
Owner: types.Uid(42).String(),
|
|
SubCnt: 3,
|
|
Public: savedPublic,
|
|
}, nil
|
|
},
|
|
)
|
|
|
|
req := httptest.NewRequest(http.MethodPatch, "/v1/im/groups/grpAlpha", bytes.NewBufferString(`{"avatar_file_id":"`+avatarID+`.jpg"}`))
|
|
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["avatar_file_id"] != avatarID+".jpg" || data["version"].(float64) != 2 {
|
|
t.Fatalf("unexpected patch response: %#v", data)
|
|
}
|
|
}
|
|
|
|
func TestServeIMGroupDetailPatchUsesBusinessSettingsVersion(t *testing.T) {
|
|
ctrl := gomock.NewController(t)
|
|
defer ctrl.Finish()
|
|
oldStore := store.Store
|
|
oldGroupBusiness := store.GroupBusiness
|
|
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
|
|
oldTopics := store.Topics
|
|
oldSubs := store.Subs
|
|
topics := mock_store.NewMockTopicsPersistenceInterface(ctrl)
|
|
subs := mock_store.NewMockSubsPersistenceInterface(ctrl)
|
|
store.Topics = topics
|
|
store.Subs = subs
|
|
groupStore := &testGroupBusinessStore{settings: &types.GroupSettings{
|
|
Topic: "grpAlpha", OwnerUserID: types.Uid(42), Name: "Alpha",
|
|
GroupStatus: types.GroupStatusActive, Version: 7,
|
|
}}
|
|
store.GroupBusiness = groupStore
|
|
defer func() {
|
|
store.Store = oldStore
|
|
store.Topics = oldTopics
|
|
store.Subs = oldSubs
|
|
store.GroupBusiness = oldGroupBusiness
|
|
}()
|
|
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(), Public: map[string]any{"name": "Alpha", "version": 1},
|
|
}
|
|
subs.EXPECT().Get(types.TenantID(7), "grpAlpha", types.Uid(42), false).Return(selfSub, nil)
|
|
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(), Public: map[string]any{"name": "Beta", "version": 8},
|
|
}, nil)
|
|
|
|
req := httptest.NewRequest(http.MethodPatch, "/v1/im/groups/grpAlpha", bytes.NewBufferString(`{"name":"Beta","version":7}`))
|
|
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 groupStore.updatedSettings.Version != 7 {
|
|
t.Fatalf("business settings update used version %d, want 7", groupStore.updatedSettings.Version)
|
|
}
|
|
if data := decodeIMHTTPResponse(t, rec).Data.(map[string]any); data["version"].(float64) != 8 {
|
|
t.Fatalf("unexpected patch response: %#v", data)
|
|
}
|
|
}
|
|
|
|
func TestServeIMGroupDetailReturnsGoneForDissolvedSettingsWithoutSubscription(t *testing.T) {
|
|
ctrl := gomock.NewController(t)
|
|
defer ctrl.Finish()
|
|
oldStore := store.Store
|
|
oldGroupBusiness := store.GroupBusiness
|
|
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
|
|
store.GroupBusiness = &testGroupBusinessStore{settings: &types.GroupSettings{
|
|
Topic: "grpAlpha", OwnerUserID: types.Uid(42), Name: "Alpha",
|
|
GroupStatus: types.GroupStatusDissolved, Version: 2,
|
|
}}
|
|
defer func() {
|
|
store.Store = oldStore
|
|
store.GroupBusiness = oldGroupBusiness
|
|
}()
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/v1/im/groups/grpAlpha", nil)
|
|
req.Header.Set("Authorization", "Bearer token-1")
|
|
rec := httptest.NewRecorder()
|
|
serveIMGroupResource(rec, req)
|
|
|
|
if rec.Code != http.StatusGone {
|
|
t.Fatalf("detail status: got %d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
if reason := decodeIMHTTPResponse(t, rec).Reason; reason != imReasonGroupDissolved {
|
|
t.Fatalf("unexpected reason: %q", reason)
|
|
}
|
|
}
|
|
|
|
func TestServeIMGroupDetailReturnsGoneForDeletedTopicWithoutSubscription(t *testing.T) {
|
|
ctrl := gomock.NewController(t)
|
|
defer ctrl.Finish()
|
|
oldStore := store.Store
|
|
oldGroupBusiness := store.GroupBusiness
|
|
oldTopics := store.Topics
|
|
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)
|
|
topics := mock_store.NewMockTopicsPersistenceInterface(ctrl)
|
|
topics.EXPECT().Get(types.TenantID(7), "grpAlpha").Return(&types.Topic{
|
|
ObjHeader: types.ObjHeader{Id: "grpAlpha"},
|
|
TenantID: 7,
|
|
State: types.StateDeleted,
|
|
}, nil)
|
|
store.Store = mockStore
|
|
store.Topics = topics
|
|
store.GroupBusiness = &testGroupBusinessStore{settings: &types.GroupSettings{
|
|
Topic: "grpAlpha", OwnerUserID: types.Uid(42), Name: "Alpha",
|
|
GroupStatus: types.GroupStatusActive, Version: 1,
|
|
}}
|
|
defer func() {
|
|
store.Store = oldStore
|
|
store.Topics = oldTopics
|
|
store.GroupBusiness = oldGroupBusiness
|
|
}()
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/v1/im/groups/grpAlpha", nil)
|
|
req.Header.Set("Authorization", "Bearer token-1")
|
|
rec := httptest.NewRecorder()
|
|
serveIMGroupResource(rec, req)
|
|
|
|
if rec.Code != http.StatusGone {
|
|
t.Fatalf("detail status: got %d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
if reason := decodeIMHTTPResponse(t, rec).Reason; reason != imReasonGroupDissolved {
|
|
t.Fatalf("unexpected reason: %q", reason)
|
|
}
|
|
}
|
|
|
|
func TestServeIMGroupMyProfileKeepsSubscriptionNicknameWhenBusinessMemberMissing(t *testing.T) {
|
|
ctrl := gomock.NewController(t)
|
|
defer ctrl.Finish()
|
|
expectIMBearer(t, ctrl, "token-1", 7, 42)
|
|
oldSubs := store.Subs
|
|
oldGroupBusiness := store.GroupBusiness
|
|
subs := mock_store.NewMockSubsPersistenceInterface(ctrl)
|
|
store.Subs = subs
|
|
store.GroupBusiness = &testGroupBusinessStore{updateMemberErr: types.ErrNotFound}
|
|
defer func() {
|
|
store.Subs = oldSubs
|
|
store.GroupBusiness = oldGroupBusiness
|
|
}()
|
|
|
|
selfSub := &types.Subscription{
|
|
TenantID: 7,
|
|
User: types.Uid(42).String(),
|
|
Topic: "grpAlpha",
|
|
ModeWant: types.ModeCFull,
|
|
ModeGiven: types.ModeCFull,
|
|
Private: map[string]any{"comment": "", "profile_version": 1},
|
|
}
|
|
var savedPrivate map[string]any
|
|
subs.EXPECT().Get(types.TenantID(7), "grpAlpha", types.Uid(42), false).Return(selfSub, nil)
|
|
subs.EXPECT().Update(types.TenantID(7), "grpAlpha", types.Uid(42), gomock.Any()).DoAndReturn(
|
|
func(_ types.TenantID, _ string, _ types.Uid, update map[string]any) error {
|
|
private, ok := update["Private"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("Private update missing: %#v", update)
|
|
}
|
|
savedPrivate = private
|
|
return nil
|
|
},
|
|
)
|
|
|
|
req := httptest.NewRequest(http.MethodPut, "/v1/im/groups/grpAlpha/members/me/profile", bytes.NewBufferString(`{"nickname":"Coffee 备注","version":1}`))
|
|
req.Header.Set("Authorization", "Bearer token-1")
|
|
rec := httptest.NewRecorder()
|
|
serveIMGroupResource(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("profile status: got %d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
if savedPrivate["nickname"] != "Coffee 备注" || savedPrivate["profile_version"] != 2 {
|
|
t.Fatalf("subscription private not saved: %#v", savedPrivate)
|
|
}
|
|
if data := decodeIMHTTPResponse(t, rec).Data.(map[string]any); data["nickname"] != "Coffee 备注" || data["version"].(float64) != 2 {
|
|
t.Fatalf("unexpected profile response: %#v", data)
|
|
}
|
|
}
|
|
|
|
func TestServeIMGroupMyProfileGetReturnsSubscriptionNickname(t *testing.T) {
|
|
ctrl := gomock.NewController(t)
|
|
defer ctrl.Finish()
|
|
expectIMBearer(t, ctrl, "token-1", 7, 42)
|
|
oldSubs := store.Subs
|
|
subs := mock_store.NewMockSubsPersistenceInterface(ctrl)
|
|
store.Subs = subs
|
|
defer func() {
|
|
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.ModeCFull,
|
|
ModeGiven: types.ModeCFull,
|
|
Private: map[string]any{
|
|
"nickname": "Coffee 备注",
|
|
"profile_version": 3,
|
|
"profile_updated_at": "2026-09-03T01:00:00Z",
|
|
},
|
|
}, nil)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/v1/im/groups/grpAlpha/members/me/profile", nil)
|
|
req.Header.Set("Authorization", "Bearer token-1")
|
|
rec := httptest.NewRecorder()
|
|
serveIMGroupResource(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("profile get status: got %d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
|
|
if data["nickname"] != "Coffee 备注" || data["version"].(float64) != 3 || data["updated_at"] != "2026-09-03T01:00:00Z" {
|
|
t.Fatalf("unexpected profile get 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
|
|
oldGroupBusiness := store.GroupBusiness
|
|
topics := mock_store.NewMockTopicsPersistenceInterface(ctrl)
|
|
subs := mock_store.NewMockSubsPersistenceInterface(ctrl)
|
|
store.Topics = topics
|
|
store.Subs = subs
|
|
groupStore := &testGroupBusinessStore{}
|
|
store.GroupBusiness = groupStore
|
|
defer func() {
|
|
store.Topics = oldTopics
|
|
store.Subs = oldSubs
|
|
store.GroupBusiness = oldGroupBusiness
|
|
}()
|
|
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)
|
|
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 TestServeIMGroupInvitationAddsNonMember(t *testing.T) {
|
|
ctrl := gomock.NewController(t)
|
|
defer ctrl.Finish()
|
|
expectIMBearer(t, ctrl, "token-1", 7, 42)
|
|
oldHub := globals.hub
|
|
oldSubs := store.Subs
|
|
oldGroupBusiness := store.GroupBusiness
|
|
oldSecurity := store.Security
|
|
subs := mock_store.NewMockSubsPersistenceInterface(ctrl)
|
|
groupStore := &testGroupBusinessStore{}
|
|
securityStore := &testUserSecurityStore{}
|
|
globals.hub = &Hub{routeSrv: make(chan *ServerComMessage, 2)}
|
|
store.Subs = subs
|
|
store.GroupBusiness = groupStore
|
|
store.Security = securityStore
|
|
defer func() {
|
|
globals.hub = oldHub
|
|
store.Subs = oldSubs
|
|
store.GroupBusiness = oldGroupBusiness
|
|
store.Security = oldSecurity
|
|
}()
|
|
owner := types.Uid(42)
|
|
target := types.Uid(43)
|
|
subs.EXPECT().Get(types.TenantID(7), "grpAlpha", owner, false).Return(&types.Subscription{
|
|
TenantID: 7, User: owner.String(), Topic: "grpAlpha", ModeWant: types.ModeCFull, ModeGiven: types.ModeCFull,
|
|
}, nil)
|
|
subs.EXPECT().Get(types.TenantID(7), "grpAlpha", target, false).Return(nil, types.ErrNotFound)
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/im/groups/grpAlpha/invitations",
|
|
bytes.NewBufferString(`{"user_ids":["`+target.UserId()+`"],"message":"welcome"}`))
|
|
req.Header.Set("Authorization", "Bearer token-1")
|
|
req.Header.Set("Idempotency-Key", "invite-1")
|
|
rec := httptest.NewRecorder()
|
|
serveIMGroupResource(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("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)["status"] != types.GroupInvitationStatusJoined {
|
|
t.Fatalf("unexpected invitation response: %#v", data)
|
|
}
|
|
if len(groupStore.invitations) != 1 || groupStore.invitations[0].TargetUserID != target ||
|
|
groupStore.invitations[0].Status != types.GroupInvitationStatusJoined {
|
|
t.Fatalf("member was not joined directly: %#v", groupStore.invitations)
|
|
}
|
|
if len(securityStore.notifications) != 0 {
|
|
t.Fatalf("direct invite should not create approval notification: %#v", securityStore.notifications)
|
|
}
|
|
select {
|
|
case msg := <-globals.hub.routeSrv:
|
|
if msg.Info == nil || msg.Info.Event != "group.member.joined" || msg.Info.Topic != "grpAlpha" {
|
|
t.Fatalf("unexpected realtime event: %#v", msg)
|
|
}
|
|
var payload map[string]any
|
|
if err := json.Unmarshal(msg.Info.Payload, &payload); err != nil {
|
|
t.Fatalf("decode payload: %v", err)
|
|
}
|
|
if payload["user_id"] != target.UserId() || payload["member_sync"] != "single" || payload["source"] != "direct_invitation" {
|
|
t.Fatalf("unexpected member joined payload: %#v", payload)
|
|
}
|
|
default:
|
|
t.Fatal("missing group.member.joined realtime event")
|
|
}
|
|
}
|
|
|
|
func TestServeIMGroupInvitationCreatesApprovalRequest(t *testing.T) {
|
|
ctrl := gomock.NewController(t)
|
|
defer ctrl.Finish()
|
|
mockStore := expectIMBearer(t, ctrl, "token-1", 7, 42)
|
|
mockStore.EXPECT().GetUidString().Return("invite")
|
|
mockStore.EXPECT().GetUidString().Return("request")
|
|
oldSubs := store.Subs
|
|
oldGroupBusiness := store.GroupBusiness
|
|
oldUsers := store.Users
|
|
subs := mock_store.NewMockSubsPersistenceInterface(ctrl)
|
|
users := mock_store.NewMockUsersPersistenceInterface(ctrl)
|
|
groupStore := &testGroupBusinessStore{settings: &types.GroupSettings{
|
|
Topic: "grpAlpha", OwnerUserID: types.Uid(42), Name: "Alpha", GroupStatus: types.GroupStatusActive,
|
|
JoinApproval: true, MemberInvite: true, MaxAdminCount: groupMaxAdminCount, Version: 1,
|
|
}}
|
|
store.Subs = subs
|
|
store.Users = users
|
|
store.GroupBusiness = groupStore
|
|
defer func() {
|
|
store.Subs = oldSubs
|
|
store.GroupBusiness = oldGroupBusiness
|
|
store.Users = oldUsers
|
|
}()
|
|
owner := types.Uid(42)
|
|
target := types.Uid(43)
|
|
ownerSub := &types.Subscription{
|
|
TenantID: 7, User: owner.String(), Topic: "grpAlpha", ModeWant: types.ModeCFull, ModeGiven: types.ModeCFull,
|
|
}
|
|
subs.EXPECT().Get(types.TenantID(7), "grpAlpha", owner, false).Return(ownerSub, nil).Times(3)
|
|
subs.EXPECT().Get(types.TenantID(7), "grpAlpha", target, false).Return(nil, types.ErrNotFound)
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/im/groups/grpAlpha/invitations",
|
|
bytes.NewBufferString(`{"user_ids":["`+target.UserId()+`"],"message":"welcome"}`))
|
|
req.Header.Set("Authorization", "Bearer token-1")
|
|
req.Header.Set("Idempotency-Key", "invite-1")
|
|
rec := httptest.NewRecorder()
|
|
serveIMGroupResource(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status: got %d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
|
|
items := data["items"].([]any)
|
|
item := items[0].(map[string]any)
|
|
if item["invitation_id"] != "ginv_invite" || item["join_request_id"] != "gjr_request" || item["status"] != types.GroupInvitationStatusPendingApproval {
|
|
t.Fatalf("unexpected invitation response: %#v", data)
|
|
}
|
|
if len(groupStore.joinRequests) != 1 || groupStore.joinRequests[0].RequestID != "gjr_request" ||
|
|
groupStore.joinRequests[0].ApplicantUserID != target || groupStore.joinRequests[0].Source != "invitation" {
|
|
t.Fatalf("join request was not created: %#v", groupStore.joinRequests)
|
|
}
|
|
|
|
req = httptest.NewRequest(http.MethodGet, "/v1/im/groups/grpAlpha/summary", nil)
|
|
req.Header.Set("Authorization", "Bearer token-1")
|
|
rec = httptest.NewRecorder()
|
|
serveIMGroupResource(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("summary status: got %d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
summary := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
|
|
if summary["pending_join_request_count"] != float64(1) {
|
|
t.Fatalf("unexpected summary response: %#v", summary)
|
|
}
|
|
|
|
users.EXPECT().GetAll(types.TenantID(7), target, owner).Return([]types.User{
|
|
{ObjHeader: types.ObjHeader{Id: target.String()}, Public: map[string]any{"display_name": "申请人", "avatar_file_id": "avatar-target.jpg"}},
|
|
{ObjHeader: types.ObjHeader{Id: owner.String()}, Public: map[string]any{"display_name": "邀请人", "avatar_file_id": "avatar-owner.jpg"}},
|
|
}, nil)
|
|
req = httptest.NewRequest(http.MethodGet, "/v1/im/groups/grpAlpha/join-requests?status=pending", nil)
|
|
req.Header.Set("Authorization", "Bearer token-1")
|
|
rec = httptest.NewRecorder()
|
|
serveIMGroupResource(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("join requests status: got %d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
list := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
|
|
requests := list["items"].([]any)
|
|
if len(requests) != 1 || requests[0].(map[string]any)["request_id"] != "gjr_request" {
|
|
t.Fatalf("unexpected join requests response: %#v", list)
|
|
}
|
|
requestItem := requests[0].(map[string]any)
|
|
if requestItem["display_name"] != "申请人" || requestItem["avatar_file_id"] != "avatar-target.jpg" {
|
|
t.Fatalf("join request applicant profile was not populated: %#v", requestItem)
|
|
}
|
|
if requestItem["inviter_display_name"] != "邀请人" || requestItem["inviter_avatar_file_id"] != "avatar-owner.jpg" {
|
|
t.Fatalf("join request inviter profile was not populated: %#v", requestItem)
|
|
}
|
|
if user := requestItem["user"].(map[string]any); user["display_name"] != "申请人" || user["avatar_file_id"] != "avatar-target.jpg" {
|
|
t.Fatalf("join request user object was not populated: %#v", user)
|
|
}
|
|
}
|
|
|
|
func TestDedupePendingGroupJoinRequestsKeepsLatestPerApplicant(t *testing.T) {
|
|
applicant := types.Uid(43)
|
|
other := types.Uid(44)
|
|
items := []types.GroupJoinRequest{
|
|
{ID: 10, RequestID: "gjr_old", Topic: "grpAlpha", ApplicantUserID: applicant, Status: types.GroupJoinRequestStatusPending},
|
|
{ID: 11, RequestID: "gjr_other", Topic: "grpAlpha", ApplicantUserID: other, Status: types.GroupJoinRequestStatusPending},
|
|
{ID: 12, RequestID: "gjr_new", Topic: "grpAlpha", ApplicantUserID: applicant, Status: types.GroupJoinRequestStatusPending},
|
|
}
|
|
|
|
deduped := dedupePendingGroupJoinRequests(items, types.GroupJoinRequestStatusPending)
|
|
if len(deduped) != 2 || deduped[0].RequestID != "gjr_other" || deduped[1].RequestID != "gjr_new" {
|
|
t.Fatalf("unexpected deduped requests: %#v", deduped)
|
|
}
|
|
|
|
all := dedupePendingGroupJoinRequests(items, "all")
|
|
if len(all) != len(items) {
|
|
t.Fatalf("non-pending list should not be deduped: %#v", all)
|
|
}
|
|
}
|
|
|
|
func TestServeIMGroupInvitationRespectsTargetPrivacyApproval(t *testing.T) {
|
|
ctrl := gomock.NewController(t)
|
|
defer ctrl.Finish()
|
|
mockStore := expectIMBearer(t, ctrl, "token-1", 7, 42)
|
|
mockStore.EXPECT().GetUidString().Return("invite")
|
|
mockStore.EXPECT().GetUidString().Return("request")
|
|
oldSubs := store.Subs
|
|
oldGroupBusiness := store.GroupBusiness
|
|
oldProfiles := store.Profiles
|
|
subs := mock_store.NewMockSubsPersistenceInterface(ctrl)
|
|
groupStore := &testGroupBusinessStore{settings: &types.GroupSettings{
|
|
Topic: "grpAlpha", OwnerUserID: types.Uid(42), Name: "Alpha", GroupStatus: types.GroupStatusActive,
|
|
JoinApproval: false, MemberInvite: true, MaxAdminCount: groupMaxAdminCount, Version: 1,
|
|
}}
|
|
store.Subs = subs
|
|
store.GroupBusiness = groupStore
|
|
store.Profiles = &testProfilesStore{privacy: &types.UserPrivacySettings{
|
|
UserID: types.Uid(43),
|
|
AddMeMethods: map[string]bool{"account": true, "phone": true, "qr": true, "group": true},
|
|
ProfileVisibility: "friends",
|
|
LastSeenVisibility: "friends",
|
|
ReadReceipts: true,
|
|
GroupInviteVerification: true,
|
|
Version: 1,
|
|
CreatedAt: time.Now().UTC(),
|
|
UpdatedAt: time.Now().UTC(),
|
|
}}
|
|
defer func() {
|
|
store.Subs = oldSubs
|
|
store.GroupBusiness = oldGroupBusiness
|
|
store.Profiles = oldProfiles
|
|
}()
|
|
owner := types.Uid(42)
|
|
target := types.Uid(43)
|
|
subs.EXPECT().Get(types.TenantID(7), "grpAlpha", owner, false).Return(&types.Subscription{
|
|
TenantID: 7, User: owner.String(), Topic: "grpAlpha", ModeWant: types.ModeCFull, ModeGiven: types.ModeCFull,
|
|
}, nil)
|
|
subs.EXPECT().Get(types.TenantID(7), "grpAlpha", target, false).Return(nil, types.ErrNotFound)
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/im/groups/grpAlpha/invitations",
|
|
bytes.NewBufferString(`{"user_ids":["`+target.UserId()+`"],"message":"welcome"}`))
|
|
req.Header.Set("Authorization", "Bearer token-1")
|
|
req.Header.Set("Idempotency-Key", "invite-privacy")
|
|
rec := httptest.NewRecorder()
|
|
serveIMGroupResource(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status: got %d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
|
|
items := data["items"].([]any)
|
|
item := items[0].(map[string]any)
|
|
if item["invitation_id"] != "ginv_invite" || item["join_request_id"] != "gjr_request" || item["status"] != types.GroupInvitationStatusPendingApproval {
|
|
t.Fatalf("unexpected invitation response: %#v", data)
|
|
}
|
|
if len(groupStore.joinRequests) != 1 || groupStore.joinRequests[0].ApplicantUserID != target || groupStore.joinRequests[0].Source != "invitation" {
|
|
t.Fatalf("join request was not created from privacy setting: %#v", groupStore.joinRequests)
|
|
}
|
|
}
|
|
|
|
func TestServeIMGroupInvitationAccept(t *testing.T) {
|
|
ctrl := gomock.NewController(t)
|
|
defer ctrl.Finish()
|
|
expectIMBearer(t, ctrl, "token-1", 7, 43)
|
|
oldGroupBusiness := store.GroupBusiness
|
|
groupStore := &testGroupBusinessStore{invitations: []types.GroupInvitation{{
|
|
InvitationID: "ginv_test",
|
|
Topic: "grpAlpha",
|
|
InviterUserID: 42,
|
|
TargetUserID: 43,
|
|
Status: "pending",
|
|
ExpiresAt: time.Now().UTC().Add(time.Hour),
|
|
}}}
|
|
store.GroupBusiness = groupStore
|
|
defer func() { store.GroupBusiness = oldGroupBusiness }()
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/im/group-invitations/ginv_test/accept", nil)
|
|
req.Header.Set("Authorization", "Bearer token-1")
|
|
rec := httptest.NewRecorder()
|
|
serveIMGroupInvitationResource(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["invitation_id"] != "ginv_test" || data["status"] != types.GroupInvitationStatusJoined || data["result"] != "joined" {
|
|
t.Fatalf("unexpected invitation accept response: %#v", data)
|
|
}
|
|
}
|
|
|
|
func TestServeIMGroupFiles(t *testing.T) {
|
|
ctrl := gomock.NewController(t)
|
|
defer ctrl.Finish()
|
|
expectIMBearer(t, ctrl, "token-1", 7, 42)
|
|
oldSubs := store.Subs
|
|
oldGroupBusiness := store.GroupBusiness
|
|
oldFiles := store.Files
|
|
subs := mock_store.NewMockSubsPersistenceInterface(ctrl)
|
|
files := mock_store.NewMockFilePersistenceInterface(ctrl)
|
|
groupStore := &testGroupBusinessStore{}
|
|
store.Subs = subs
|
|
store.GroupBusiness = groupStore
|
|
store.Files = files
|
|
defer func() {
|
|
store.Subs = oldSubs
|
|
store.GroupBusiness = oldGroupBusiness
|
|
store.Files = oldFiles
|
|
}()
|
|
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.ModeCFull, ModeGiven: types.ModeCFull,
|
|
}, nil).Times(3)
|
|
|
|
fileID := types.Uid(101).String()
|
|
files.EXPECT().Get(types.TenantID(7), fileID).Return(&types.FileDef{
|
|
ObjHeader: types.ObjHeader{Id: fileID},
|
|
TenantID: 7,
|
|
User: types.Uid(42).String(),
|
|
Status: types.UploadCompleted,
|
|
MimeType: "application/pdf",
|
|
Size: 123,
|
|
}, nil)
|
|
createReq := httptest.NewRequest(http.MethodPost, "/v1/im/groups/grpAlpha/files",
|
|
strings.NewReader(fmt.Sprintf(`{"file_id":"%s.pdf","file_name":"方案.pdf","mime_type":"application/pdf","file_type":"document","file_size":123}`, fileID)))
|
|
createReq.Header.Set("Authorization", "Bearer token-1")
|
|
createReq.Header.Set("Idempotency-Key", "gfile-1")
|
|
createRec := httptest.NewRecorder()
|
|
serveIMGroupResource(createRec, createReq)
|
|
if createRec.Code != http.StatusOK {
|
|
t.Fatalf("create status: got %d body=%s", createRec.Code, createRec.Body.String())
|
|
}
|
|
created := decodeIMHTTPResponse(t, createRec).Data.(map[string]any)
|
|
if created["file_id"] != fileID || created["file_type"] != "document" {
|
|
t.Fatalf("unexpected create response: %#v", created)
|
|
}
|
|
|
|
listReq := httptest.NewRequest(http.MethodGet, "/v1/im/groups/grpAlpha/files", nil)
|
|
listReq.Header.Set("Authorization", "Bearer token-1")
|
|
listRec := httptest.NewRecorder()
|
|
serveIMGroupResource(listRec, listReq)
|
|
if listRec.Code != http.StatusOK {
|
|
t.Fatalf("list status: got %d body=%s", listRec.Code, listRec.Body.String())
|
|
}
|
|
listed := decodeIMHTTPResponse(t, listRec).Data.(map[string]any)
|
|
if len(listed["items"].([]any)) != 1 || listed["has_more"] != false {
|
|
t.Fatalf("unexpected list response: %#v", listed)
|
|
}
|
|
|
|
deleteReq := httptest.NewRequest(http.MethodDelete, "/v1/im/groups/grpAlpha/files/gfile_test", nil)
|
|
deleteReq.Header.Set("Authorization", "Bearer token-1")
|
|
deleteRec := httptest.NewRecorder()
|
|
serveIMGroupResource(deleteRec, deleteReq)
|
|
if deleteRec.Code != http.StatusOK {
|
|
t.Fatalf("delete status: got %d body=%s", deleteRec.Code, deleteRec.Body.String())
|
|
}
|
|
deleted := decodeIMHTTPResponse(t, deleteRec).Data.(map[string]any)
|
|
if deleted["status"] != "deleted" || deleted["deleted_by"] != types.Uid(42).UserId() {
|
|
t.Fatalf("unexpected delete response: %#v", deleted)
|
|
}
|
|
}
|
|
|
|
func TestServeIMGroupFileCreateValidatesInput(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
body string
|
|
setupFiles func(*mock_store.MockFilePersistenceInterface, string)
|
|
wantReason string
|
|
}{
|
|
{
|
|
name: "file name too long",
|
|
body: `{"file_id":"%s.pdf","file_name":"` + strings.Repeat("a", 256) + `"}`,
|
|
wantReason: imReasonValidationFailed,
|
|
},
|
|
{
|
|
name: "negative size",
|
|
body: `{"file_id":"%s.pdf","file_name":"方案.pdf","file_size":-1}`,
|
|
wantReason: imReasonValidationFailed,
|
|
},
|
|
{
|
|
name: "missing upload",
|
|
body: `{"file_id":"%s.pdf","file_name":"方案.pdf"}`,
|
|
setupFiles: func(files *mock_store.MockFilePersistenceInterface, fileID string) {
|
|
files.EXPECT().Get(types.TenantID(7), fileID).Return(nil, nil)
|
|
},
|
|
wantReason: imReasonFileUnavailable,
|
|
},
|
|
}
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
ctrl := gomock.NewController(t)
|
|
defer ctrl.Finish()
|
|
expectIMBearer(t, ctrl, "token-1", 7, 42)
|
|
oldSubs := store.Subs
|
|
oldGroupBusiness := store.GroupBusiness
|
|
oldFiles := store.Files
|
|
subs := mock_store.NewMockSubsPersistenceInterface(ctrl)
|
|
files := mock_store.NewMockFilePersistenceInterface(ctrl)
|
|
store.Subs = subs
|
|
store.GroupBusiness = &testGroupBusinessStore{}
|
|
store.Files = files
|
|
defer func() {
|
|
store.Subs = oldSubs
|
|
store.GroupBusiness = oldGroupBusiness
|
|
store.Files = oldFiles
|
|
}()
|
|
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.ModeCFull, ModeGiven: types.ModeCFull,
|
|
}, nil)
|
|
fileID := types.Uid(101).String()
|
|
if tc.setupFiles != nil {
|
|
tc.setupFiles(files, fileID)
|
|
}
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/im/groups/grpAlpha/files",
|
|
strings.NewReader(fmt.Sprintf(tc.body, fileID)))
|
|
req.Header.Set("Authorization", "Bearer token-1")
|
|
req.Header.Set("Idempotency-Key", "gfile-invalid")
|
|
rec := httptest.NewRecorder()
|
|
serveIMGroupResource(rec, req)
|
|
|
|
if rec.Code != http.StatusBadRequest && rec.Code != http.StatusNotFound {
|
|
t.Fatalf("status: got %d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
resp := decodeIMHTTPResponse(t, rec)
|
|
if resp.Reason != tc.wantReason {
|
|
t.Fatalf("reason: got %q, want %q; body=%s", resp.Reason, tc.wantReason, rec.Body.String())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
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 TestServeIMGroupInvitationDirectJoinWritesSystemNotice(t *testing.T) {
|
|
ctrl := gomock.NewController(t)
|
|
defer ctrl.Finish()
|
|
mockStore := expectIMBearer(t, ctrl, "token-1", 7, 42)
|
|
mockStore.EXPECT().IsOpen().Return(true).AnyTimes()
|
|
|
|
oldSubs := store.Subs
|
|
oldTopics := store.Topics
|
|
oldMessages := store.Messages
|
|
oldProfiles := store.Profiles
|
|
oldGroupBusiness := store.GroupBusiness
|
|
subs := mock_store.NewMockSubsPersistenceInterface(ctrl)
|
|
topics := mock_store.NewMockTopicsPersistenceInterface(ctrl)
|
|
messages := mock_store.NewMockMessagesPersistenceInterface(ctrl)
|
|
store.Subs = subs
|
|
store.Topics = topics
|
|
store.Messages = messages
|
|
store.Profiles = nil
|
|
store.GroupBusiness = &testGroupBusinessStore{}
|
|
t.Cleanup(func() {
|
|
store.Subs = oldSubs
|
|
store.Topics = oldTopics
|
|
store.Messages = oldMessages
|
|
store.Profiles = oldProfiles
|
|
store.GroupBusiness = oldGroupBusiness
|
|
})
|
|
|
|
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.ModeCFull, ModeGiven: types.ModeCFull,
|
|
}, nil)
|
|
subs.EXPECT().Get(types.TenantID(7), "grpAlpha", types.Uid(43), false).Return(nil, types.ErrNotFound)
|
|
topics.EXPECT().Get(types.TenantID(7), "grpAlpha").Return(&types.Topic{TenantID: 7, SeqId: 7}, nil)
|
|
messages.EXPECT().Save(types.TenantID(7), gomock.Any(), nil, false).DoAndReturn(
|
|
func(_ types.TenantID, msg *types.Message, _ []string, _ bool) (*types.MessageSaveResult, error) {
|
|
if msg.Topic != "grpAlpha" || msg.SeqId != 8 {
|
|
t.Fatalf("unexpected system notice message: %#v", msg)
|
|
}
|
|
if msg.Head["x-im-template"] != "group_member_joined" || msg.Head["x-im-type"] != "system_action" {
|
|
t.Fatalf("unexpected system notice head: %#v", msg.Head)
|
|
}
|
|
content := msg.Content.(map[string]any)
|
|
if content["text"] != "有成员通过邀请加入了群聊。" || content["template"] != "group_member_joined" {
|
|
t.Fatalf("unexpected system notice content: %#v", content)
|
|
}
|
|
extra := content["extra"].(map[string]any)
|
|
if extra["source"] != "direct_invitation" {
|
|
t.Fatalf("unexpected system notice extra: %#v", extra)
|
|
}
|
|
return &types.MessageSaveResult{SeqId: msg.SeqId}, nil
|
|
})
|
|
|
|
body := fmt.Sprintf(`{"user_ids":["%s"],"message":"欢迎"}`, types.Uid(43).UserId())
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/im/groups/grpAlpha/invitations", strings.NewReader(body))
|
|
req.Header.Set("Authorization", "Bearer token-1")
|
|
req.Header.Set("Idempotency-Key", "invite-1")
|
|
req = req.WithContext(context.WithValue(req.Context(), imHTTPProtocolValidated, true))
|
|
rec := httptest.NewRecorder()
|
|
serveIMGroupResource(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("invite 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)["status"] != types.GroupInvitationStatusJoined {
|
|
t.Fatalf("unexpected invitation response: %#v", data)
|
|
}
|
|
}
|
|
|
|
func TestServeIMGroupAnnouncementGovernanceJoinRequest(t *testing.T) {
|
|
ctrl := gomock.NewController(t)
|
|
defer ctrl.Finish()
|
|
oldStore := store.Store
|
|
oldGroupBusiness := store.GroupBusiness
|
|
oldMessages := store.Messages
|
|
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)
|
|
messages := mock_store.NewMockMessagesPersistenceInterface(ctrl)
|
|
store.Topics = topics
|
|
store.Subs = subs
|
|
store.Messages = messages
|
|
groupStore := &testGroupBusinessStore{}
|
|
store.GroupBusiness = groupStore
|
|
defer func() {
|
|
store.Store = oldStore
|
|
store.Topics = oldTopics
|
|
store.Subs = oldSubs
|
|
store.Messages = oldMessages
|
|
store.GroupBusiness = oldGroupBusiness
|
|
}()
|
|
selfSub := &types.Subscription{TenantID: 7, User: types.Uid(42).String(), Topic: "grpAlpha", ModeWant: types.ModeCFull, ModeGiven: types.ModeCFull}
|
|
subs.EXPECT().Get(types.TenantID(7), "grpAlpha", types.Uid(42), false).Return(selfSub, nil).Times(2)
|
|
subs.EXPECT().Get(types.TenantID(7), "grpAlpha", types.Uid(42), false).Return(nil, nil)
|
|
topics.EXPECT().Get(types.TenantID(7), "grpAlpha").Return(&types.Topic{TenantID: 7, SeqId: 10}, 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 != "grpAlpha" || msg.From != types.Uid(42).String() || msg.SeqId != 11 {
|
|
t.Fatalf("unexpected announcement message: %#v", msg)
|
|
}
|
|
if msg.Head["mime"] != "application/json" || msg.Head["x-im-type"] != "text" || msg.Head["x-im-client-mid"] != "group_announcement_ann_ann-1" {
|
|
t.Fatalf("unexpected announcement message head: %#v", msg.Head)
|
|
}
|
|
if msg.Content.(map[string]any)["text"] != "群公告\n公告" {
|
|
t.Fatalf("unexpected announcement message content: %#v", msg.Content)
|
|
}
|
|
return &types.MessageSaveResult{SeqId: msg.SeqId, MarkedReadBySender: true}, nil
|
|
})
|
|
|
|
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 TestServeIMGroupAnnouncementLatestIncludesEditPermission(t *testing.T) {
|
|
ctrl := gomock.NewController(t)
|
|
defer ctrl.Finish()
|
|
oldStore := store.Store
|
|
oldGroupBusiness := store.GroupBusiness
|
|
oldSubs := store.Subs
|
|
mockStore := expectIMBearer(t, ctrl, "token-1", 7, 42)
|
|
store.Store = mockStore
|
|
subs := mock_store.NewMockSubsPersistenceInterface(ctrl)
|
|
store.Subs = subs
|
|
store.GroupBusiness = &testGroupBusinessStore{}
|
|
defer func() {
|
|
store.Store = oldStore
|
|
store.Subs = oldSubs
|
|
store.GroupBusiness = oldGroupBusiness
|
|
}()
|
|
selfSub := &types.Subscription{TenantID: 7, User: types.Uid(42).String(), Topic: "grpAlpha", ModeWant: types.ModeCFull, ModeGiven: types.ModeCFull}
|
|
subs.EXPECT().Get(types.TenantID(7), "grpAlpha", types.Uid(42), false).Return(selfSub, nil)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/v1/im/groups/grpAlpha/announcements/latest", nil)
|
|
req.Header.Set("Authorization", "Bearer token-1")
|
|
rec := httptest.NewRecorder()
|
|
serveIMGroupResource(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("latest announcement status: got %d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
|
|
permissions := data["permissions"].(map[string]any)
|
|
if data["topic"] != "grpAlpha" || data["announcement_id"] != "" || permissions["can_edit"] != true {
|
|
t.Fatalf("unexpected latest announcement response: %#v", data)
|
|
}
|
|
}
|
|
|
|
func TestServeIMGroupAnnouncementGetUsesPathID(t *testing.T) {
|
|
ctrl := gomock.NewController(t)
|
|
defer ctrl.Finish()
|
|
oldStore := store.Store
|
|
oldGroupBusiness := store.GroupBusiness
|
|
oldSubs := store.Subs
|
|
mockStore := expectIMBearer(t, ctrl, "token-1", 7, 42)
|
|
store.Store = mockStore
|
|
subs := mock_store.NewMockSubsPersistenceInterface(ctrl)
|
|
store.Subs = subs
|
|
store.GroupBusiness = &testGroupBusinessStore{announcements: map[string]*types.GroupAnnouncement{
|
|
"ann_old": {AnnouncementID: "ann_old", Topic: "grpAlpha", AuthorUserID: types.Uid(42), Content: "旧公告", Status: types.GroupAnnouncementStatusSuperseded, Version: 1, PublishedAt: time.Date(2026, 8, 13, 9, 0, 0, 0, time.UTC)},
|
|
"ann_new": {AnnouncementID: "ann_new", Topic: "grpAlpha", AuthorUserID: types.Uid(42), Content: "新公告", Status: types.GroupAnnouncementStatusCurrent, Version: 2, PublishedAt: time.Date(2026, 8, 13, 10, 0, 0, 0, time.UTC)},
|
|
}}
|
|
defer func() {
|
|
store.Store = oldStore
|
|
store.Subs = oldSubs
|
|
store.GroupBusiness = oldGroupBusiness
|
|
}()
|
|
selfSub := &types.Subscription{TenantID: 7, User: types.Uid(42).String(), Topic: "grpAlpha", ModeWant: types.ModeCPublic, ModeGiven: types.ModeCPublic}
|
|
subs.EXPECT().Get(types.TenantID(7), "grpAlpha", types.Uid(42), false).Return(selfSub, nil)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/v1/im/groups/grpAlpha/announcements/ann_old", nil)
|
|
req.Header.Set("Authorization", "Bearer token-1")
|
|
rec := httptest.NewRecorder()
|
|
serveIMGroupResource(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("announcement get status: got %d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
|
|
if data["announcement_id"] != "ann_old" || data["content"] != "旧公告" {
|
|
t.Fatalf("announcement get ignored path id: %#v", data)
|
|
}
|
|
}
|
|
|
|
func TestServeIMGroupAnnouncementGetMissingReturnsNotFound(t *testing.T) {
|
|
ctrl := gomock.NewController(t)
|
|
defer ctrl.Finish()
|
|
expectIMBearer(t, ctrl, "token-1", 7, 42)
|
|
oldGroupBusiness := store.GroupBusiness
|
|
oldSubs := store.Subs
|
|
subs := mock_store.NewMockSubsPersistenceInterface(ctrl)
|
|
store.Subs = subs
|
|
store.GroupBusiness = &testGroupBusinessStore{}
|
|
defer func() {
|
|
store.Subs = oldSubs
|
|
store.GroupBusiness = oldGroupBusiness
|
|
}()
|
|
selfSub := &types.Subscription{TenantID: 7, User: types.Uid(42).String(), Topic: "grpAlpha", ModeWant: types.ModeCPublic, ModeGiven: types.ModeCPublic}
|
|
subs.EXPECT().Get(types.TenantID(7), "grpAlpha", types.Uid(42), false).Return(selfSub, nil)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/v1/im/groups/grpAlpha/announcements/ann_missing", nil)
|
|
req.Header.Set("Authorization", "Bearer token-1")
|
|
rec := httptest.NewRecorder()
|
|
serveIMGroupResource(rec, req)
|
|
if rec.Code != http.StatusNotFound {
|
|
t.Fatalf("announcement missing status: got %d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
resp := decodeIMHTTPResponse(t, rec)
|
|
if resp.Reason != imReasonResourceNotFound {
|
|
t.Fatalf("reason: got %q, want %q", resp.Reason, imReasonResourceNotFound)
|
|
}
|
|
}
|
|
|
|
func TestServeIMGroupAdminsMutesAndOwnerTransfer(t *testing.T) {
|
|
ctrl := gomock.NewController(t)
|
|
defer ctrl.Finish()
|
|
oldStore := store.Store
|
|
oldGroupBusiness := store.GroupBusiness
|
|
oldSecurity := store.Security
|
|
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
|
|
groupStore := &testGroupBusinessStore{}
|
|
store.GroupBusiness = groupStore
|
|
security := &testSecurityStore{}
|
|
store.Security = security
|
|
defer func() {
|
|
store.Store = oldStore
|
|
store.Topics = oldTopics
|
|
store.Subs = oldSubs
|
|
store.GroupBusiness = oldGroupBusiness
|
|
store.Security = oldSecurity
|
|
}()
|
|
owner := types.Uid(42)
|
|
target := types.Uid(43)
|
|
ownerSub := &types.Subscription{TenantID: 7, User: owner.String(), Topic: "grpAlpha", ModeWant: types.ModeCFull, ModeGiven: types.ModeCFull}
|
|
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().Get(types.TenantID(7), "grpAlpha", target, false).Return(&member, 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())
|
|
}
|
|
if !groupStore.updatedRoleMode.IsAdmin() || groupStore.updatedRoleMode.IsOwner() {
|
|
t.Fatalf("admin role mode should grant admin without owner: %s", groupStore.updatedRoleMode.String())
|
|
}
|
|
|
|
muteReq := httptest.NewRequest(http.MethodPost, "/v1/im/groups/grpAlpha/mutes/batch", bytes.NewBufferString(`{"user_ids":["`+target.UserId()+`"],"reason":"test","version":1}`))
|
|
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")
|
|
transferReq.Header.Set("X-Action-Token", "action.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)
|
|
}
|
|
if security.consumedAction != "group_owner_transfer" || security.consumedTarget != "group_owner_transfer" {
|
|
t.Fatalf("owner transfer consumed wrong action token scope: action=%q target=%q", security.consumedAction, security.consumedTarget)
|
|
}
|
|
}
|
|
|
|
func TestServeIMGroupMutesMemberGetsOwnMuteOnly(t *testing.T) {
|
|
ctrl := gomock.NewController(t)
|
|
defer ctrl.Finish()
|
|
oldStore := store.Store
|
|
oldSubs := store.Subs
|
|
oldGroupBusiness := store.GroupBusiness
|
|
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: 43, AuthLevel: auth.LevelAuth}, nil, nil).Times(2)
|
|
store.Store = mockStore
|
|
subs := mock_store.NewMockSubsPersistenceInterface(ctrl)
|
|
store.Subs = subs
|
|
memberSub := &types.Subscription{TenantID: 7, User: types.Uid(43).String(), Topic: "grpAlpha", ModeWant: types.ModeCPublic, ModeGiven: types.ModeCPublic}
|
|
subs.EXPECT().Get(types.TenantID(7), "grpAlpha", types.Uid(43), false).Return(memberSub, nil).Times(2)
|
|
groupStore := &testGroupBusinessStore{mutes: []types.GroupMute{
|
|
{Topic: "grpAlpha", UserID: types.Uid(42), MutedBy: types.Uid(99), MutedAt: time.Now().UTC(), Status: types.GroupMuteStatusActive, Version: 1},
|
|
{Topic: "grpAlpha", UserID: types.Uid(43), MutedBy: types.Uid(99), MutedAt: time.Now().UTC(), Reason: "test", Status: types.GroupMuteStatusActive, Version: 2},
|
|
}}
|
|
store.GroupBusiness = groupStore
|
|
defer func() {
|
|
store.Store = oldStore
|
|
store.Subs = oldSubs
|
|
store.GroupBusiness = oldGroupBusiness
|
|
}()
|
|
|
|
getReq := httptest.NewRequest(http.MethodGet, "/v1/im/groups/grpAlpha/mutes", nil)
|
|
getReq.Header.Set("Authorization", "Bearer token-1")
|
|
getRec := httptest.NewRecorder()
|
|
serveIMGroupResource(getRec, getReq)
|
|
if getRec.Code != http.StatusOK {
|
|
t.Fatalf("member mute get status: got %d body=%s", getRec.Code, getRec.Body.String())
|
|
}
|
|
data := decodeIMHTTPResponse(t, getRec).Data.(map[string]any)
|
|
items := data["items"].([]any)
|
|
if len(items) != 1 || items[0].(map[string]any)["user_id"] != types.Uid(43).UserId() {
|
|
t.Fatalf("member should only see own mute item: %#v", items)
|
|
}
|
|
|
|
postReq := httptest.NewRequest(http.MethodPost, "/v1/im/groups/grpAlpha/mutes/batch", bytes.NewBufferString(`{"user_ids":["`+types.Uid(42).UserId()+`"]}`))
|
|
postReq.Header.Set("Authorization", "Bearer token-1")
|
|
postRec := httptest.NewRecorder()
|
|
serveIMGroupResource(postRec, postReq)
|
|
if postRec.Code != http.StatusForbidden {
|
|
t.Fatalf("member mute post status: got %d body=%s", postRec.Code, postRec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestServeIMGroupDissolveConsumesActionToken(t *testing.T) {
|
|
ctrl := gomock.NewController(t)
|
|
defer ctrl.Finish()
|
|
oldStore := store.Store
|
|
oldGroupBusiness := store.GroupBusiness
|
|
oldSecurity := store.Security
|
|
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
|
|
oldSubs := store.Subs
|
|
subs := mock_store.NewMockSubsPersistenceInterface(ctrl)
|
|
store.Subs = subs
|
|
groupStore := &testGroupBusinessStore{}
|
|
store.GroupBusiness = groupStore
|
|
security := &testSecurityStore{}
|
|
store.Security = security
|
|
defer func() {
|
|
store.Store = oldStore
|
|
store.Subs = oldSubs
|
|
store.GroupBusiness = oldGroupBusiness
|
|
store.Security = oldSecurity
|
|
}()
|
|
ownerSub := &types.Subscription{
|
|
TenantID: 7, User: types.Uid(42).String(), Topic: "grpAlpha",
|
|
ModeWant: types.ModeCFull, ModeGiven: types.ModeCFull,
|
|
}
|
|
subs.EXPECT().Get(types.TenantID(7), "grpAlpha", types.Uid(42), false).Return(ownerSub, nil)
|
|
|
|
req := httptest.NewRequest(http.MethodDelete, "/v1/im/groups/grpAlpha", bytes.NewBufferString(`{"reason":"结束项目"}`))
|
|
req.Header.Set("Authorization", "Bearer token-1")
|
|
req.Header.Set("X-Action-Token", "action.1")
|
|
rec := httptest.NewRecorder()
|
|
serveIMGroupResource(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("dissolve status: got %d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
if security.consumedAction != "group_dissolve" || security.consumedTarget != "group_dissolve" {
|
|
t.Fatalf("dissolve consumed wrong action token scope: action=%q target=%q", security.consumedAction, security.consumedTarget)
|
|
}
|
|
if groupStore.settings == nil || groupStore.settings.GroupStatus != "dissolved" {
|
|
t.Fatalf("group not dissolved: %#v", groupStore.settings)
|
|
}
|
|
}
|
|
|
|
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
|
|
oldSubs := store.Subs
|
|
oldMessages := store.Messages
|
|
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
|
|
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), "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(2)
|
|
messages.EXPECT().GetOne(types.TenantID(7), "grpAlpha", int64(3)).Return(&types.Message{
|
|
TenantID: 7,
|
|
Topic: "grpAlpha",
|
|
SeqId: 3,
|
|
From: types.Uid(42).String(),
|
|
Head: types.KVMap{"type": "text"},
|
|
Content: map[string]any{"text": "hello"},
|
|
ObjHeader: types.ObjHeader{
|
|
CreatedAt: now,
|
|
},
|
|
}, nil).Times(2)
|
|
|
|
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 TestServeIMFavoritesListSupportsRefOnlyAttachment(t *testing.T) {
|
|
ctrl := gomock.NewController(t)
|
|
defer ctrl.Finish()
|
|
expectIMBearer(t, ctrl, "token-1", 7, 42)
|
|
oldFavorites := store.Favorites
|
|
oldSubs := store.Subs
|
|
oldMessages := store.Messages
|
|
now := time.Date(2026, 9, 6, 9, 0, 0, 0, time.UTC)
|
|
store.Favorites = &testFavoritesStore{items: []types.FavoriteItem{{
|
|
TenantID: 7, FavoriteID: "fav_ref", UserID: 42, ItemType: "file",
|
|
SourceTopic: "grpAlpha", SourceSeq: 8, Availability: "available", CollectedAt: now,
|
|
}}}
|
|
subs := mock_store.NewMockSubsPersistenceInterface(ctrl)
|
|
messages := mock_store.NewMockMessagesPersistenceInterface(ctrl)
|
|
store.Subs = subs
|
|
store.Messages = messages
|
|
defer func() {
|
|
store.Favorites = oldFavorites
|
|
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)
|
|
messages.EXPECT().GetOne(types.TenantID(7), "grpAlpha", int64(8)).Return(&types.Message{
|
|
TenantID: 7, Topic: "grpAlpha", SeqId: 8, From: types.Uid(42).String(),
|
|
Head: types.KVMap{"type": "file"}, Content: map[string]any{
|
|
"txt": " ",
|
|
"fmt": []any{map[string]any{"at": 0, "len": 1, "key": 0}},
|
|
"ent": []any{map[string]any{"tp": "EX", "data": map[string]any{
|
|
"mime": "application/pdf", "name": "large.pdf", "ref": "https://oss.example/large.pdf",
|
|
}}},
|
|
}, ObjHeader: types.ObjHeader{CreatedAt: now},
|
|
}, nil)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/v1/im/favorites?type=all&limit=1", nil)
|
|
req.Header.Set("Authorization", "Bearer token-1")
|
|
rec := httptest.NewRecorder()
|
|
serveIMFavorites(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 items := data["items"].([]any); len(items) != 1 || items[0].(map[string]any)["favorite_id"] != "fav_ref" {
|
|
t.Fatalf("unexpected ref-only favorite response: %#v", data)
|
|
}
|
|
}
|
|
|
|
func TestServeIMFavoritesListKeepsLegacySeedSnapshots(t *testing.T) {
|
|
ctrl := gomock.NewController(t)
|
|
defer ctrl.Finish()
|
|
expectIMBearer(t, ctrl, "token-1", 7, 42)
|
|
oldFavorites := store.Favorites
|
|
oldSubs := store.Subs
|
|
oldMessages := store.Messages
|
|
now := time.Date(2026, 9, 9, 9, 0, 0, 0, time.UTC)
|
|
store.Favorites = &testFavoritesStore{items: []types.FavoriteItem{{
|
|
TenantID: 7, FavoriteID: "fav_seed_19179719768_text", UserID: 42, ItemType: "text",
|
|
SourceTopic: "favSeed19179719768", SourceSeq: 7001, Availability: "available", CollectedAt: now,
|
|
Snapshot: map[string]any{"text": "seed favorite"},
|
|
}}}
|
|
store.Subs = mock_store.NewMockSubsPersistenceInterface(ctrl)
|
|
store.Messages = nil
|
|
defer func() {
|
|
store.Favorites = oldFavorites
|
|
store.Subs = oldSubs
|
|
store.Messages = oldMessages
|
|
}()
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/v1/im/favorites?type=all", nil)
|
|
req.Header.Set("Authorization", "Bearer token-1")
|
|
rec := httptest.NewRecorder()
|
|
serveIMFavorites(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)["favorite_id"] != "fav_seed_19179719768_text" {
|
|
t.Fatalf("unexpected legacy seed favorite response: %#v", data)
|
|
}
|
|
}
|
|
|
|
func TestNormalizeIMUserVisibleTopicSelf(t *testing.T) {
|
|
uid := types.Uid(42)
|
|
got, ok := normalizeIMUserVisibleTopic(uid, "slf")
|
|
if !ok || got != uid.SlfName() {
|
|
t.Fatalf("normalize slf: got=%q ok=%v want=%q", got, ok, uid.SlfName())
|
|
}
|
|
}
|
|
|
|
func TestMergeGroupSettingsPayloadUsesSettingsVersion(t *testing.T) {
|
|
payload := map[string]any{"version": 7}
|
|
mergeGroupSettingsPayload(payload, &types.GroupSettings{Version: 2})
|
|
if payload["version"] != 2 {
|
|
t.Fatalf("settings version was not used: %#v", payload)
|
|
}
|
|
}
|
|
|
|
func TestServeIMFavoriteBatchCreateCountsExisting(t *testing.T) {
|
|
ctrl := gomock.NewController(t)
|
|
defer ctrl.Finish()
|
|
mockStore := expectIMBearer(t, ctrl, "token-1", 7, 42)
|
|
mockStore.EXPECT().GetUidString().Return("new-fav")
|
|
|
|
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_existing",
|
|
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
|
|
t.Cleanup(func() {
|
|
store.Favorites = oldFavorites
|
|
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)
|
|
messages.EXPECT().GetOne(types.TenantID(7), "grpAlpha", int64(3)).Return(&types.Message{
|
|
TenantID: 7,
|
|
Topic: "grpAlpha",
|
|
SeqId: 3,
|
|
From: types.Uid(42).String(),
|
|
Head: types.KVMap{"type": "text"},
|
|
Content: map[string]any{"text": "hello"},
|
|
ObjHeader: types.ObjHeader{
|
|
CreatedAt: now,
|
|
},
|
|
}, nil)
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/im/favorites", bytes.NewBufferString(`{"messages":[{"topic":"grpAlpha","seq":"3"}]}`))
|
|
req.Header.Set("Authorization", "Bearer token-1")
|
|
rec := httptest.NewRecorder()
|
|
serveIMFavorites(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("create status: got %d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
|
|
if data["created_count"].(float64) != 0 || data["existing_count"].(float64) != 1 {
|
|
t.Fatalf("unexpected favorite counts: %#v", data)
|
|
}
|
|
}
|
|
|
|
func TestServeIMFavoriteBatchCreateAcceptsNumericSeq(t *testing.T) {
|
|
ctrl := gomock.NewController(t)
|
|
defer ctrl.Finish()
|
|
mockStore := expectIMBearer(t, ctrl, "token-1", 7, 42)
|
|
mockStore.EXPECT().GetUidString().Return("new-fav")
|
|
|
|
oldFavorites := store.Favorites
|
|
oldSubs := store.Subs
|
|
oldMessages := store.Messages
|
|
now := time.Date(2026, 8, 13, 9, 0, 0, 0, time.UTC)
|
|
favorites := &testFavoritesStore{}
|
|
subs := mock_store.NewMockSubsPersistenceInterface(ctrl)
|
|
messages := mock_store.NewMockMessagesPersistenceInterface(ctrl)
|
|
store.Favorites = favorites
|
|
store.Subs = subs
|
|
store.Messages = messages
|
|
t.Cleanup(func() {
|
|
store.Favorites = oldFavorites
|
|
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)
|
|
messages.EXPECT().GetOne(types.TenantID(7), "grpAlpha", int64(3)).Return(&types.Message{
|
|
TenantID: 7,
|
|
Topic: "grpAlpha",
|
|
SeqId: 3,
|
|
From: types.Uid(42).String(),
|
|
Head: types.KVMap{"type": "text"},
|
|
Content: map[string]any{"text": "hello"},
|
|
ObjHeader: types.ObjHeader{
|
|
CreatedAt: now,
|
|
},
|
|
}, nil)
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/im/favorites/batch", bytes.NewBufferString(`{"messages":[{"topic":"grpAlpha","seq":3}]}`))
|
|
req.Header.Set("Authorization", "Bearer token-1")
|
|
rec := httptest.NewRecorder()
|
|
serveIMFavoriteResource(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("create status: got %d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
|
|
if data["created_count"].(float64) != 1 || data["existing_count"].(float64) != 0 {
|
|
t.Fatalf("unexpected favorite counts: %#v", data)
|
|
}
|
|
if favorites.created == nil || favorites.created.SourceSeq != 3 {
|
|
t.Fatalf("favorite source seq was not saved: %#v", favorites.created)
|
|
}
|
|
}
|
|
|
|
func TestFavoriteItemIdempotencyKeyIncludesMessageIdentity(t *testing.T) {
|
|
first := favoriteItemIdempotencyKey("same-key", "grpAlpha", 1)
|
|
second := favoriteItemIdempotencyKey("same-key", "grpAlpha", 2)
|
|
if first == "" || second == "" || first == second {
|
|
t.Fatalf("favorite item idempotency key must be stable per source message: %q %q", first, second)
|
|
}
|
|
if favoriteItemIdempotencyKey("", "grpAlpha", 1) != "" {
|
|
t.Fatal("empty request idempotency key should stay empty")
|
|
}
|
|
}
|
|
|
|
func TestServeIMFavoriteBatchCreateRejectsUnreadableSource(t *testing.T) {
|
|
ctrl := gomock.NewController(t)
|
|
defer ctrl.Finish()
|
|
expectIMBearer(t, ctrl, "token-1", 7, 42)
|
|
|
|
oldFavorites := store.Favorites
|
|
oldSubs := store.Subs
|
|
oldMessages := store.Messages
|
|
store.Favorites = &testFavoritesStore{}
|
|
subs := mock_store.NewMockSubsPersistenceInterface(ctrl)
|
|
messages := mock_store.NewMockMessagesPersistenceInterface(ctrl)
|
|
store.Subs = subs
|
|
store.Messages = messages
|
|
t.Cleanup(func() {
|
|
store.Favorites = oldFavorites
|
|
store.Subs = oldSubs
|
|
store.Messages = oldMessages
|
|
})
|
|
|
|
subs.EXPECT().Get(types.TenantID(7), "grpSecret", types.Uid(42), false).Return(nil, types.ErrNotFound)
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/im/favorites", bytes.NewBufferString(`{"messages":[{"topic":"grpSecret","seq":"1"}]}`))
|
|
req.Header.Set("Authorization", "Bearer token-1")
|
|
rec := httptest.NewRecorder()
|
|
serveIMFavorites(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("create status: got %d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
|
|
if data["created_count"].(float64) != 0 || data["existing_count"].(float64) != 0 {
|
|
t.Fatalf("unexpected favorite counts: %#v", data)
|
|
}
|
|
failed := data["failed"].([]any)
|
|
if len(failed) != 1 || failed[0].(map[string]any)["reason"] != "NOT_FOUND" {
|
|
t.Fatalf("unexpected failed response: %#v", data)
|
|
}
|
|
}
|
|
|
|
func TestServeIMFavoriteBatchCreateNormalizesP2PUserTopic(t *testing.T) {
|
|
ctrl := gomock.NewController(t)
|
|
defer ctrl.Finish()
|
|
mockStore := expectIMBearer(t, ctrl, "token-1", 7, 42)
|
|
mockStore.EXPECT().GetUidString().Return("new-fav")
|
|
|
|
actor := types.Uid(42)
|
|
peer := types.Uid(43)
|
|
normalizedTopic := actor.P2PName(peer)
|
|
oldFavorites := store.Favorites
|
|
oldSubs := store.Subs
|
|
oldMessages := store.Messages
|
|
favorites := &testFavoritesStore{}
|
|
subs := mock_store.NewMockSubsPersistenceInterface(ctrl)
|
|
messages := mock_store.NewMockMessagesPersistenceInterface(ctrl)
|
|
store.Favorites = favorites
|
|
store.Subs = subs
|
|
store.Messages = messages
|
|
t.Cleanup(func() {
|
|
store.Favorites = oldFavorites
|
|
store.Subs = oldSubs
|
|
store.Messages = oldMessages
|
|
})
|
|
|
|
now := time.Date(2026, 8, 13, 9, 0, 0, 0, time.UTC)
|
|
subs.EXPECT().Get(types.TenantID(7), normalizedTopic, actor, false).Return(&types.Subscription{
|
|
TenantID: 7,
|
|
User: actor.String(),
|
|
Topic: normalizedTopic,
|
|
ModeWant: types.ModeCPublic,
|
|
ModeGiven: types.ModeCPublic,
|
|
}, nil)
|
|
messages.EXPECT().GetOne(types.TenantID(7), normalizedTopic, int64(3)).Return(&types.Message{
|
|
TenantID: 7,
|
|
Topic: normalizedTopic,
|
|
SeqId: 3,
|
|
From: peer.String(),
|
|
Head: types.KVMap{"type": "text"},
|
|
Content: map[string]any{"text": "hello"},
|
|
ObjHeader: types.ObjHeader{
|
|
CreatedAt: now,
|
|
},
|
|
}, nil)
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/im/favorites", bytes.NewBufferString(`{"messages":[{"topic":"`+peer.UserId()+`","seq":"3"}]}`))
|
|
req.Header.Set("Authorization", "Bearer token-1")
|
|
rec := httptest.NewRecorder()
|
|
serveIMFavorites(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("create status: got %d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
|
|
if data["created_count"].(float64) != 1 || data["existing_count"].(float64) != 0 {
|
|
t.Fatalf("unexpected favorite counts: %#v", data)
|
|
}
|
|
if favorites.created == nil || favorites.created.SourceTopic != normalizedTopic {
|
|
t.Fatalf("favorite source topic was not normalized: %#v", favorites.created)
|
|
}
|
|
snapshot := favorites.created.Snapshot.(map[string]any)
|
|
if snapshot["from"] != peer.UserId() {
|
|
t.Fatalf("favorite snapshot from must use full user_id: %#v", favorites.created.Snapshot)
|
|
}
|
|
}
|
|
|
|
func TestFavoriteSourcePayloadIncludesP2PPeer(t *testing.T) {
|
|
viewer := types.Uid(42)
|
|
peer := types.Uid(43)
|
|
topic := viewer.P2PName(peer)
|
|
source := favoriteSourcePayload(topic, 17, viewer)
|
|
if source["topic"] != topic || source["seq"] != int64(17) {
|
|
t.Fatalf("favorite source should keep stored topic and seq: %#v", source)
|
|
}
|
|
if source["peer_user_id"] != peer.UserId() || source["display_topic"] != peer.UserId() {
|
|
t.Fatalf("favorite source should expose addressable p2p peer: %#v", source)
|
|
}
|
|
}
|
|
|
|
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)
|
|
mockStore.EXPECT().GetUidString().Return("fav-mid")
|
|
mockStore.EXPECT().GetUidString().Return("comment-mid")
|
|
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{"head": map[string]any{"type": "text"}, "content": map[string]any{"text": "hello"}, "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)
|
|
gomock.InOrder(
|
|
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.Head["x-im-type"] != "text" || msg.Content.(map[string]any)["text"] != "FYI" {
|
|
t.Fatalf("unexpected forward comment message: %#v", msg)
|
|
}
|
|
return &types.MessageSaveResult{SeqId: 10, MarkedReadBySender: true}, 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)
|
|
}
|
|
if msg.Head["x-im-client-mid"] != "fwd_fav-mid" {
|
|
t.Fatalf("forward client mid not set: %#v", msg.Head)
|
|
}
|
|
content := msg.Content.(map[string]any)
|
|
if content["text"] != "hello" || content["comment"] != nil || content["snapshot"] != nil {
|
|
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 || got["comment_seq"].(float64) != 10 || got["client_mid"] != "fwd_fav-mid" {
|
|
t.Fatalf("unexpected forward response: %#v", got)
|
|
}
|
|
}
|
|
|
|
func TestServeIMFavoriteForwardMediaUsesNewClientMid(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("media-mid")
|
|
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_img",
|
|
UserID: 42,
|
|
ItemType: "image",
|
|
SourceTopic: "grpAlpha",
|
|
SourceSeq: 17,
|
|
Snapshot: map[string]any{
|
|
"head": map[string]any{"type": "image", "x-im-type": "image", "x-im-client-mid": "origin-mid"},
|
|
"content": map[string]any{"file_id": "file-img", "mime": "image/png"},
|
|
},
|
|
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.Head["x-im-type"] != "image" {
|
|
t.Fatalf("unexpected media forward message: %#v", msg)
|
|
}
|
|
if msg.Head["x-im-client-mid"] != "fwd_media-mid" {
|
|
t.Fatalf("media forward reused original client mid: %#v", msg.Head)
|
|
}
|
|
content := msg.Content.(map[string]any)
|
|
if content["file_id"] != "file-img" || content["mime"] != "image/png" {
|
|
t.Fatalf("unexpected media forward content: %#v", content)
|
|
}
|
|
return &types.MessageSaveResult{SeqId: 22, MarkedReadBySender: true}, nil
|
|
})
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/im/favorites/forward", bytes.NewBufferString(`{"favorite_ids":["fav_img"],"target_topics":["grpBeta"]}`))
|
|
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) != 22 || got["client_mid"] != "fwd_media-mid" {
|
|
t.Fatalf("unexpected media forward response: %#v", got)
|
|
}
|
|
}
|
|
|
|
func TestServeIMFavoriteForwardRejectsUnauthorizedTarget(t *testing.T) {
|
|
ctrl := gomock.NewController(t)
|
|
defer ctrl.Finish()
|
|
expectIMBearer(t, ctrl, "token-1", 7, 42)
|
|
|
|
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
|
|
t.Cleanup(func() {
|
|
store.Favorites = oldFavorites
|
|
store.Subs = oldSubs
|
|
store.Messages = oldMessages
|
|
})
|
|
|
|
subs.EXPECT().Get(types.TenantID(7), "grpDenied", types.Uid(42), false).Return(nil, types.ErrPermissionDenied)
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/im/favorites/forward", bytes.NewBufferString(`{"favorite_ids":["fav_1"],"target_topics":["grpDenied"]}`))
|
|
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"] != "failed" || got["reason"] != imReasonPermissionDenied {
|
|
t.Fatalf("unexpected forward response: %#v", got)
|
|
}
|
|
}
|
|
|
|
func TestServeIMMessageForwardSeparateSavesMessages(t *testing.T) {
|
|
ctrl := gomock.NewController(t)
|
|
defer ctrl.Finish()
|
|
mockStore := expectIMBearer(t, ctrl, "token-1", 7, 42)
|
|
mockStore.EXPECT().GetUidString().Return("mid-1")
|
|
|
|
oldSubs := store.Subs
|
|
oldMessages := store.Messages
|
|
subs := mock_store.NewMockSubsPersistenceInterface(ctrl)
|
|
messages := mock_store.NewMockMessagesPersistenceInterface(ctrl)
|
|
store.Subs = subs
|
|
store.Messages = messages
|
|
t.Cleanup(func() {
|
|
store.Subs = oldSubs
|
|
store.Messages = oldMessages
|
|
})
|
|
|
|
subs.EXPECT().Get(types.TenantID(7), "grpTarget", types.Uid(42), false).Return(&types.Subscription{
|
|
TenantID: 7,
|
|
User: types.Uid(42).String(),
|
|
Topic: "grpTarget",
|
|
ModeWant: types.ModeCPublic,
|
|
ModeGiven: types.ModeCPublic,
|
|
}, nil)
|
|
subs.EXPECT().Get(types.TenantID(7), "grpSource", types.Uid(42), false).Return(&types.Subscription{
|
|
TenantID: 7,
|
|
User: types.Uid(42).String(),
|
|
Topic: "grpSource",
|
|
ModeWant: types.ModeCPublic,
|
|
ModeGiven: types.ModeCPublic,
|
|
}, nil)
|
|
messages.EXPECT().GetOne(types.TenantID(7), "grpSource", int64(3)).Return(&types.Message{
|
|
TenantID: 7,
|
|
Topic: "grpSource",
|
|
SeqId: 3,
|
|
From: types.Uid(99).String(),
|
|
Head: types.KVMap{"x-im-type": "text"},
|
|
Content: map[string]any{"text": "hello"},
|
|
ObjHeader: types.ObjHeader{
|
|
CreatedAt: time.Date(2026, 8, 13, 9, 0, 0, 0, time.UTC),
|
|
},
|
|
}, 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 != "grpTarget" || msg.From != types.Uid(42).String() {
|
|
t.Fatalf("unexpected forwarded message: %#v", msg)
|
|
}
|
|
if msg.Head["x-im-forwarded"] != true || msg.Head["x-im-client-mid"] != "fwd_mid-1" {
|
|
t.Fatalf("forward metadata not set: %#v", msg.Head)
|
|
}
|
|
if msg.Content.(map[string]any)["text"] != "hello" {
|
|
t.Fatalf("unexpected content: %#v", msg.Content)
|
|
}
|
|
return &types.MessageSaveResult{SeqId: 21, MarkedReadBySender: true}, nil
|
|
})
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/im/messages/forward", bytes.NewBufferString(`{"mode":"separate","sources":[{"topic":"grpSource","seq":"3"}],"target_topics":["grpTarget"]}`))
|
|
req.Header.Set("Authorization", "Bearer token-1")
|
|
rec := httptest.NewRecorder()
|
|
serveIMMessageForward(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("forward 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 {
|
|
t.Fatalf("unexpected forward response: %#v", data)
|
|
}
|
|
got := items[0].(map[string]any)
|
|
if got["status"] != "sent" || got["seq"].(float64) != 21 || got["client_mid"] != "fwd_mid-1" {
|
|
t.Fatalf("unexpected forward item: %#v", got)
|
|
}
|
|
if failed := data["failed"].([]any); len(failed) != 0 {
|
|
t.Fatalf("unexpected failed items: %#v", failed)
|
|
}
|
|
}
|
|
|
|
func TestServeIMMessageForwardSeparateSendsCommentAsTextMessage(t *testing.T) {
|
|
ctrl := gomock.NewController(t)
|
|
defer ctrl.Finish()
|
|
mockStore := expectIMBearer(t, ctrl, "token-1", 7, 42)
|
|
mockStore.EXPECT().GetUidString().Return("comment-mid")
|
|
mockStore.EXPECT().GetUidString().Return("mid-1")
|
|
|
|
oldSubs := store.Subs
|
|
oldMessages := store.Messages
|
|
subs := mock_store.NewMockSubsPersistenceInterface(ctrl)
|
|
messages := mock_store.NewMockMessagesPersistenceInterface(ctrl)
|
|
store.Subs = subs
|
|
store.Messages = messages
|
|
t.Cleanup(func() {
|
|
store.Subs = oldSubs
|
|
store.Messages = oldMessages
|
|
})
|
|
|
|
subs.EXPECT().Get(types.TenantID(7), "grpTarget", types.Uid(42), false).Return(&types.Subscription{
|
|
TenantID: 7, User: types.Uid(42).String(), Topic: "grpTarget", ModeWant: types.ModeCPublic, ModeGiven: types.ModeCPublic,
|
|
}, nil)
|
|
subs.EXPECT().Get(types.TenantID(7), "grpSource", types.Uid(42), false).Return(&types.Subscription{
|
|
TenantID: 7, User: types.Uid(42).String(), Topic: "grpSource", ModeWant: types.ModeCPublic, ModeGiven: types.ModeCPublic,
|
|
}, nil)
|
|
messages.EXPECT().GetOne(types.TenantID(7), "grpSource", int64(3)).Return(&types.Message{
|
|
TenantID: 7,
|
|
Topic: "grpSource",
|
|
SeqId: 3,
|
|
From: types.Uid(99).String(),
|
|
Head: types.KVMap{"x-im-type": "image"},
|
|
Content: map[string]any{"file_id": "file-1", "mime": "image/png"},
|
|
ObjHeader: types.ObjHeader{
|
|
CreatedAt: time.Date(2026, 8, 13, 9, 0, 0, 0, time.UTC),
|
|
},
|
|
}, nil)
|
|
gomock.InOrder(
|
|
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.Head["x-im-type"] != "text" || msg.Content.(map[string]any)["text"] != "FYI" {
|
|
t.Fatalf("unexpected comment message: %#v", msg)
|
|
}
|
|
return &types.MessageSaveResult{SeqId: 20, MarkedReadBySender: true}, 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.Head["x-im-type"] != "image" {
|
|
t.Fatalf("forwarded message type changed: %#v", msg.Head)
|
|
}
|
|
content := msg.Content.(map[string]any)
|
|
if content["file_id"] != "file-1" || content["snapshot"] != nil || content["comment"] != nil {
|
|
t.Fatalf("forwarded message content should stay original: %#v", content)
|
|
}
|
|
return &types.MessageSaveResult{SeqId: 21, MarkedReadBySender: true}, nil
|
|
}),
|
|
)
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/im/messages/forward", bytes.NewBufferString(`{"mode":"separate","sources":[{"topic":"grpSource","seq":"3"}],"target_topics":["grpTarget"],"comment":"FYI"}`))
|
|
req.Header.Set("Authorization", "Bearer token-1")
|
|
rec := httptest.NewRecorder()
|
|
serveIMMessageForward(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("forward 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 {
|
|
t.Fatalf("unexpected forward response: %#v", data)
|
|
}
|
|
got := items[0].(map[string]any)
|
|
if got["status"] != "sent" || got["seq"].(float64) != 21 || got["comment_seq"].(float64) != 20 {
|
|
t.Fatalf("unexpected forward item: %#v", got)
|
|
}
|
|
}
|
|
|
|
func TestServeIMMessageForwardAcceptsNumericSeqAndUserTarget(t *testing.T) {
|
|
ctrl := gomock.NewController(t)
|
|
defer ctrl.Finish()
|
|
mockStore := expectIMBearer(t, ctrl, "token-1", 7, 42)
|
|
mockStore.EXPECT().GetUidString().Return("mid-2")
|
|
|
|
oldSubs := store.Subs
|
|
oldMessages := store.Messages
|
|
subs := mock_store.NewMockSubsPersistenceInterface(ctrl)
|
|
messages := mock_store.NewMockMessagesPersistenceInterface(ctrl)
|
|
store.Subs = subs
|
|
store.Messages = messages
|
|
t.Cleanup(func() {
|
|
store.Subs = oldSubs
|
|
store.Messages = oldMessages
|
|
})
|
|
|
|
targetUser := types.Uid(99)
|
|
targetTopic := types.Uid(42).P2PName(targetUser)
|
|
subs.EXPECT().Get(types.TenantID(7), targetTopic, types.Uid(42), false).Return(&types.Subscription{
|
|
TenantID: 7,
|
|
User: types.Uid(42).String(),
|
|
Topic: targetTopic,
|
|
ModeWant: types.ModeCPublic,
|
|
ModeGiven: types.ModeCPublic,
|
|
}, nil)
|
|
subs.EXPECT().Get(types.TenantID(7), "grpSource", types.Uid(42), false).Return(&types.Subscription{
|
|
TenantID: 7,
|
|
User: types.Uid(42).String(),
|
|
Topic: "grpSource",
|
|
ModeWant: types.ModeCPublic,
|
|
ModeGiven: types.ModeCPublic,
|
|
}, nil)
|
|
messages.EXPECT().GetOne(types.TenantID(7), "grpSource", int64(60)).Return(&types.Message{
|
|
TenantID: 7,
|
|
Topic: "grpSource",
|
|
SeqId: 60,
|
|
From: targetUser.String(),
|
|
Head: types.KVMap{"x-im-type": "text"},
|
|
Content: map[string]any{"text": "hello"},
|
|
ObjHeader: types.ObjHeader{
|
|
CreatedAt: time.Date(2026, 8, 13, 9, 0, 0, 0, time.UTC),
|
|
},
|
|
}, 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 != targetTopic {
|
|
t.Fatalf("target topic: got %q, want %q", msg.Topic, targetTopic)
|
|
}
|
|
return &types.MessageSaveResult{SeqId: 22, MarkedReadBySender: true}, nil
|
|
})
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/im/messages/forward", bytes.NewBufferString(`{"mode":"separate","sources":[{"topic":"grpSource","seq":60}],"target_topics":["`+targetUser.UserId()+`"]}`))
|
|
req.Header.Set("Authorization", "Bearer token-1")
|
|
rec := httptest.NewRecorder()
|
|
serveIMMessageForward(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("forward 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 {
|
|
t.Fatalf("unexpected forward response: %#v", data)
|
|
}
|
|
got := items[0].(map[string]any)
|
|
if got["target_topic"] != targetTopic || got["seq"].(float64) != 22 {
|
|
t.Fatalf("unexpected forward item: %#v", got)
|
|
}
|
|
}
|
|
|
|
func TestServeIMMessageForwardKafkaAssignsSeqAndBroadcasts(t *testing.T) {
|
|
ctrl := gomock.NewController(t)
|
|
defer ctrl.Finish()
|
|
mockStore := expectIMBearer(t, ctrl, "token-1", 7, 42)
|
|
mockStore.EXPECT().GetUidString().Return("mid-3")
|
|
mockStore.EXPECT().GetUid().Return(types.Uid(103))
|
|
|
|
oldKafkaBus := kafkaBus
|
|
oldPublisher := publishKafkaMessagePersist
|
|
oldHub := globals.hub
|
|
oldSubs := store.Subs
|
|
oldTopics := store.Topics
|
|
oldMessages := store.Messages
|
|
subs := mock_store.NewMockSubsPersistenceInterface(ctrl)
|
|
topics := mock_store.NewMockTopicsPersistenceInterface(ctrl)
|
|
messages := mock_store.NewMockMessagesPersistenceInterface(ctrl)
|
|
kafkaBus = &kafkaRuntime{}
|
|
globals.hub = &Hub{routeSrv: make(chan *ServerComMessage, 3)}
|
|
store.Subs = subs
|
|
store.Topics = topics
|
|
store.Messages = messages
|
|
t.Cleanup(func() {
|
|
kafkaBus = oldKafkaBus
|
|
publishKafkaMessagePersist = oldPublisher
|
|
globals.hub = oldHub
|
|
store.Subs = oldSubs
|
|
store.Topics = oldTopics
|
|
store.Messages = oldMessages
|
|
})
|
|
|
|
targetUser := types.Uid(99)
|
|
targetTopic := types.Uid(42).P2PName(targetUser)
|
|
subs.EXPECT().Get(types.TenantID(7), targetTopic, types.Uid(42), false).Return(&types.Subscription{
|
|
TenantID: 7,
|
|
User: types.Uid(42).String(),
|
|
Topic: targetTopic,
|
|
ModeWant: types.ModeCPublic,
|
|
ModeGiven: types.ModeCPublic,
|
|
}, nil)
|
|
subs.EXPECT().Get(types.TenantID(7), "grpSource", types.Uid(42), false).Return(&types.Subscription{
|
|
TenantID: 7,
|
|
User: types.Uid(42).String(),
|
|
Topic: "grpSource",
|
|
ModeWant: types.ModeCPublic,
|
|
ModeGiven: types.ModeCPublic,
|
|
}, nil)
|
|
topics.EXPECT().Get(types.TenantID(7), targetTopic).Return(&types.Topic{
|
|
TenantID: 7,
|
|
SeqId: 34,
|
|
}, nil)
|
|
messages.EXPECT().GetOne(types.TenantID(7), "grpSource", int64(14)).Return(&types.Message{
|
|
TenantID: 7,
|
|
Topic: "grpSource",
|
|
SeqId: 14,
|
|
From: targetUser.String(),
|
|
Head: types.KVMap{"x-im-type": "text"},
|
|
Content: map[string]any{"text": "hello"},
|
|
ObjHeader: types.ObjHeader{
|
|
CreatedAt: time.Date(2026, 8, 13, 9, 0, 0, 0, time.UTC),
|
|
},
|
|
}, nil)
|
|
|
|
var publishedTopic string
|
|
var publishedKey string
|
|
var publishedPayload kafkaMessagePersistPayload
|
|
publishKafkaMessagePersist = func(_ context.Context, topicName, key string, envelope kafkaEnvelope) error {
|
|
publishedTopic = topicName
|
|
publishedKey = key
|
|
if err := json.Unmarshal(envelope.Payload, &publishedPayload); err != nil {
|
|
t.Fatalf("failed to unmarshal kafka payload: %v", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/im/messages/forward", bytes.NewBufferString(`{"mode":"separate","sources":[{"topic":"grpSource","seq":14}],"target_topics":["`+targetUser.UserId()+`"]}`))
|
|
req.Header.Set("Authorization", "Bearer token-1")
|
|
rec := httptest.NewRecorder()
|
|
serveIMMessageForward(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("forward 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 {
|
|
t.Fatalf("unexpected forward response: %#v", data)
|
|
}
|
|
got := items[0].(map[string]any)
|
|
if got["target_topic"] != targetTopic || got["seq"].(float64) != 35 || got["status"] != "pending" || got["reason"] != "MESSAGE_PERSIST_PENDING" {
|
|
t.Fatalf("unexpected forward item: %#v", got)
|
|
}
|
|
if failed := data["failed"].([]any); len(failed) != 0 {
|
|
t.Fatalf("unexpected failed items: %#v", failed)
|
|
}
|
|
if publishedTopic != "message_persist" || publishedKey != targetTopic {
|
|
t.Fatalf("unexpected kafka publish topic=%q key=%q", publishedTopic, publishedKey)
|
|
}
|
|
if publishedPayload.Message.Topic != targetTopic || publishedPayload.Message.SeqId != 35 {
|
|
t.Fatalf("unexpected kafka message: %#v", publishedPayload.Message)
|
|
}
|
|
select {
|
|
case delivered := <-globals.hub.routeSrv:
|
|
t.Fatalf("pending forward should not broadcast immediately: %#v", delivered)
|
|
default:
|
|
}
|
|
}
|
|
|
|
func TestServeIMMessageForwardKafkaPublishUnknownReturnsUncertain(t *testing.T) {
|
|
ctrl := gomock.NewController(t)
|
|
defer ctrl.Finish()
|
|
mockStore := expectIMBearer(t, ctrl, "token-1", 7, 42)
|
|
mockStore.EXPECT().GetUid().Return(types.Uid(104))
|
|
|
|
oldKafkaBus := kafkaBus
|
|
oldPublisher := publishKafkaMessagePersist
|
|
oldHub := globals.hub
|
|
oldSubs := store.Subs
|
|
oldTopics := store.Topics
|
|
oldMessages := store.Messages
|
|
subs := mock_store.NewMockSubsPersistenceInterface(ctrl)
|
|
topics := mock_store.NewMockTopicsPersistenceInterface(ctrl)
|
|
messages := mock_store.NewMockMessagesPersistenceInterface(ctrl)
|
|
kafkaBus = &kafkaRuntime{}
|
|
globals.hub = &Hub{routeSrv: make(chan *ServerComMessage, 3)}
|
|
store.Subs = subs
|
|
store.Topics = topics
|
|
store.Messages = messages
|
|
t.Cleanup(func() {
|
|
kafkaBus = oldKafkaBus
|
|
publishKafkaMessagePersist = oldPublisher
|
|
globals.hub = oldHub
|
|
store.Subs = oldSubs
|
|
store.Topics = oldTopics
|
|
store.Messages = oldMessages
|
|
})
|
|
|
|
targetUser := types.Uid(99)
|
|
targetTopic := types.Uid(42).P2PName(targetUser)
|
|
subs.EXPECT().Get(types.TenantID(7), targetTopic, types.Uid(42), false).Return(&types.Subscription{
|
|
TenantID: 7,
|
|
User: types.Uid(42).String(),
|
|
Topic: targetTopic,
|
|
ModeWant: types.ModeCPublic,
|
|
ModeGiven: types.ModeCPublic,
|
|
}, nil)
|
|
subs.EXPECT().Get(types.TenantID(7), "grpSource", types.Uid(42), false).Return(&types.Subscription{
|
|
TenantID: 7,
|
|
User: types.Uid(42).String(),
|
|
Topic: "grpSource",
|
|
ModeWant: types.ModeCPublic,
|
|
ModeGiven: types.ModeCPublic,
|
|
}, nil)
|
|
topics.EXPECT().Get(types.TenantID(7), targetTopic).Return(&types.Topic{
|
|
TenantID: 7,
|
|
SeqId: 40,
|
|
}, nil)
|
|
messages.EXPECT().GetOne(types.TenantID(7), "grpSource", int64(14)).Return(&types.Message{
|
|
TenantID: 7,
|
|
Topic: "grpSource",
|
|
SeqId: 14,
|
|
From: targetUser.String(),
|
|
Head: types.KVMap{"x-im-type": "text"},
|
|
Content: map[string]any{"text": "hello"},
|
|
ObjHeader: types.ObjHeader{
|
|
CreatedAt: time.Date(2026, 8, 13, 9, 0, 0, 0, time.UTC),
|
|
},
|
|
}, nil)
|
|
|
|
publishKafkaMessagePersist = func(_ context.Context, _, _ string, _ kafkaEnvelope) error {
|
|
return context.DeadlineExceeded
|
|
}
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/im/messages/forward", bytes.NewBufferString(`{"mode":"separate","sources":[{"topic":"grpSource","seq":14}],"target_topics":["`+targetUser.UserId()+`"]}`))
|
|
req.Header.Set("Authorization", "Bearer token-1")
|
|
req.Header.Set("Idempotency-Key", "idem-forward-1")
|
|
rec := httptest.NewRecorder()
|
|
serveIMMessageForward(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("forward 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 {
|
|
t.Fatalf("unexpected forward response: %#v", data)
|
|
}
|
|
got := items[0].(map[string]any)
|
|
if got["status"] != "uncertain" || got["reason"] != "MESSAGE_PERSIST_RESULT_UNKNOWN" ||
|
|
got["safe_to_retry"] != false || got["seq"].(float64) != 41 {
|
|
t.Fatalf("unexpected uncertain item: %#v", got)
|
|
}
|
|
if got["client_mid"] != "fwd_"+stableIMEventID("forward", "idem-forward-1", "separate", targetTopic, "grpSource", int64(14)) {
|
|
t.Fatalf("unexpected stable client mid: %#v", got)
|
|
}
|
|
if failed := data["failed"].([]any); len(failed) != 0 {
|
|
t.Fatalf("unexpected failed items: %#v", failed)
|
|
}
|
|
select {
|
|
case delivered := <-globals.hub.routeSrv:
|
|
t.Fatalf("uncertain forward should not broadcast immediately: %#v", delivered)
|
|
default:
|
|
}
|
|
}
|
|
|
|
func TestServeIMMessageForwardBundleSavesForwardBundle(t *testing.T) {
|
|
ctrl := gomock.NewController(t)
|
|
defer ctrl.Finish()
|
|
mockStore := expectIMBearer(t, ctrl, "token-1", 7, 42)
|
|
gomock.InOrder(
|
|
mockStore.EXPECT().GetUidString().Return("bundle-1"),
|
|
mockStore.EXPECT().GetUidString().Return("mid-3"),
|
|
)
|
|
|
|
oldSubs := store.Subs
|
|
oldMessages := store.Messages
|
|
oldUsers := store.Users
|
|
oldTopics := store.Topics
|
|
subs := mock_store.NewMockSubsPersistenceInterface(ctrl)
|
|
messages := mock_store.NewMockMessagesPersistenceInterface(ctrl)
|
|
users := mock_store.NewMockUsersPersistenceInterface(ctrl)
|
|
topics := mock_store.NewMockTopicsPersistenceInterface(ctrl)
|
|
store.Subs = subs
|
|
store.Messages = messages
|
|
store.Users = users
|
|
store.Topics = topics
|
|
t.Cleanup(func() {
|
|
store.Subs = oldSubs
|
|
store.Messages = oldMessages
|
|
store.Users = oldUsers
|
|
store.Topics = oldTopics
|
|
})
|
|
|
|
subs.EXPECT().Get(types.TenantID(7), "grpTarget", types.Uid(42), false).Return(&types.Subscription{
|
|
TenantID: 7,
|
|
User: types.Uid(42).String(),
|
|
Topic: "grpTarget",
|
|
ModeWant: types.ModeCPublic,
|
|
ModeGiven: types.ModeCPublic,
|
|
}, nil)
|
|
subs.EXPECT().Get(types.TenantID(7), "grpSource", types.Uid(42), false).Return(&types.Subscription{
|
|
TenantID: 7,
|
|
User: types.Uid(42).String(),
|
|
Topic: "grpSource",
|
|
ModeWant: types.ModeCPublic,
|
|
ModeGiven: types.ModeCPublic,
|
|
}, nil)
|
|
messages.EXPECT().GetOne(types.TenantID(7), "grpSource", int64(60)).Return(&types.Message{
|
|
TenantID: 7,
|
|
Topic: "grpSource",
|
|
SeqId: 60,
|
|
From: types.Uid(99).String(),
|
|
Head: types.KVMap{"x-im-type": "text"},
|
|
Content: map[string]any{"text": "hello"},
|
|
ObjHeader: types.ObjHeader{
|
|
CreatedAt: time.Date(2026, 8, 13, 9, 0, 0, 0, time.UTC),
|
|
},
|
|
}, nil)
|
|
users.EXPECT().GetAll(types.TenantID(7), types.Uid(99)).Return([]types.User{{
|
|
ObjHeader: types.ObjHeader{Id: types.Uid(99).String()},
|
|
TenantID: 7,
|
|
Public: types.KVMap{"display_name": "后端-potter", "avatar_file_id": "avatar-99"},
|
|
}}, nil)
|
|
topics.EXPECT().Get(types.TenantID(7), "grpSource").Return(&types.Topic{Public: map[string]any{"fn": "研发群"}}, 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 != "grpTarget" || msg.Head["x-im-type"] != "forward_bundle" {
|
|
t.Fatalf("unexpected bundle message: %#v", msg)
|
|
}
|
|
content := msg.Content.(map[string]any)
|
|
if content["bundle_id"] != "fbundle_bundle-1" || content["item_count"] != 1 {
|
|
t.Fatalf("unexpected bundle content: %#v", content)
|
|
}
|
|
if content["title"] != "研发群的聊天记录" {
|
|
t.Fatalf("unexpected bundle title: %#v", content)
|
|
}
|
|
bundleItems := content["items"].([]map[string]any)
|
|
itemUser := bundleItems[0]["from_user"].(map[string]any)
|
|
if itemUser["user_id"] != types.Uid(99).UserId() || itemUser["display_name"] != "后端-potter" || itemUser["avatar_file_id"] != "avatar-99" {
|
|
t.Fatalf("unexpected item sender summary: %#v", itemUser)
|
|
}
|
|
previewItems := content["preview"].([]map[string]any)
|
|
previewUser := previewItems[0]["from_user"].(map[string]any)
|
|
if previewUser["user_id"] != types.Uid(99).UserId() || previewUser["display_name"] != "后端-potter" || previewUser["avatar_file_id"] != "avatar-99" {
|
|
t.Fatalf("unexpected preview sender summary: %#v", previewUser)
|
|
}
|
|
return &types.MessageSaveResult{SeqId: 23, MarkedReadBySender: true}, nil
|
|
})
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/im/messages/forward", bytes.NewBufferString(`{"mode":"bundle","sources":[{"topic":"grpSource","seq":60}],"target_topics":["grpTarget"]}`))
|
|
req.Header.Set("Authorization", "Bearer token-1")
|
|
rec := httptest.NewRecorder()
|
|
serveIMMessageForward(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("forward 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 {
|
|
t.Fatalf("unexpected forward response: %#v", data)
|
|
}
|
|
got := items[0].(map[string]any)
|
|
if got["mode"] != "bundle" || got["bundle_id"] != "fbundle_bundle-1" || got["seq"].(float64) != 23 {
|
|
t.Fatalf("unexpected forward item: %#v", got)
|
|
}
|
|
}
|
|
|
|
func TestForwardBundleTitleUsesP2PUsers(t *testing.T) {
|
|
uid1 := types.Uid(42)
|
|
uid2 := types.Uid(99)
|
|
title := forwardBundleTitle(types.TenantID(7), []string{uid1.P2PName(uid2)}, map[types.Uid]map[string]any{
|
|
uid1: {"display_name": "Alice"},
|
|
uid2: {"display_name": "Bob"},
|
|
})
|
|
if title != "Alice和Bob的聊天记录" {
|
|
t.Fatalf("unexpected p2p title: %s", title)
|
|
}
|
|
}
|
|
|
|
func TestForwardBundleTitleUsesGroupName(t *testing.T) {
|
|
ctrl := gomock.NewController(t)
|
|
defer ctrl.Finish()
|
|
oldTopics := store.Topics
|
|
topics := mock_store.NewMockTopicsPersistenceInterface(ctrl)
|
|
store.Topics = topics
|
|
t.Cleanup(func() { store.Topics = oldTopics })
|
|
|
|
topics.EXPECT().Get(types.TenantID(7), "grpSource").Return(&types.Topic{Public: map[string]any{"fn": "项目群"}}, nil)
|
|
title := forwardBundleTitle(types.TenantID(7), []string{"grpSource"}, nil)
|
|
if title != "项目群的聊天记录" {
|
|
t.Fatalf("unexpected group title: %s", title)
|
|
}
|
|
}
|
|
|
|
func TestServeIMSecurityURLCheck(t *testing.T) {
|
|
ctrl := gomock.NewController(t)
|
|
defer ctrl.Finish()
|
|
oldStore := store.Store
|
|
oldSafety := store.Safety
|
|
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
|
|
store.Safety = &testSafetyStore{}
|
|
defer func() {
|
|
store.Store = oldStore
|
|
store.Safety = oldSafety
|
|
}()
|
|
|
|
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 TestServeIMRiskChallengeConfirm(t *testing.T) {
|
|
ctrl := gomock.NewController(t)
|
|
defer ctrl.Finish()
|
|
expectIMBearer(t, ctrl, "token-1", 7, 42)
|
|
oldSafety := store.Safety
|
|
oldSecurity := store.Security
|
|
now := time.Now().UTC().Round(time.Millisecond)
|
|
store.Safety = &testSafetyStore{riskChallenge: &types.RiskChallenge{
|
|
ChallengeID: "risk-1", UserID: 42, Action: "open_url", Level: "high", NoticeCode: "url_high",
|
|
State: types.RiskChallengeStatePending, ExpiresAt: now.Add(time.Minute), CreatedAt: now, UpdatedAt: now,
|
|
}}
|
|
store.Security = &testSecurityStore{}
|
|
defer func() {
|
|
store.Safety = oldSafety
|
|
store.Security = oldSecurity
|
|
}()
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/im/security/risk-challenges/risk-1/confirm", bytes.NewBufferString(`{"decision":"continue"}`))
|
|
req.Header.Set("Authorization", "Bearer token-1")
|
|
rec := httptest.NewRecorder()
|
|
serveIMRiskChallengeConfirm(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status: got %d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
|
|
if data["state"] != types.RiskChallengeStateContinued || data["risk_action_token"] == "" {
|
|
t.Fatalf("unexpected risk confirm response: %#v", data)
|
|
}
|
|
if safety := store.Safety.(*testSafetyStore); safety.tenantID != 7 || safety.uid != 42 || safety.decision != "continue" || safety.actionTokenID == "" {
|
|
t.Fatalf("risk challenge scope mismatch: %#v", safety)
|
|
}
|
|
}
|
|
|
|
func TestServeIMRiskChallengeConfirmNotFound(t *testing.T) {
|
|
ctrl := gomock.NewController(t)
|
|
defer ctrl.Finish()
|
|
expectIMBearer(t, ctrl, "token-1", 7, 42)
|
|
oldSafety := store.Safety
|
|
store.Safety = &testSafetyStore{riskErr: types.ErrNotFound}
|
|
defer func() { store.Safety = oldSafety }()
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/im/security/risk-challenges/risk_fake/confirm", bytes.NewBufferString(`{"decision":"cancel"}`))
|
|
req.Header.Set("Authorization", "Bearer token-1")
|
|
rec := httptest.NewRecorder()
|
|
serveIMRiskChallengeConfirm(rec, req)
|
|
|
|
if rec.Code != http.StatusNotFound {
|
|
t.Fatalf("status: got %d want %d body=%s", rec.Code, http.StatusNotFound, rec.Body.String())
|
|
}
|
|
resp := decodeIMHTTPResponse(t, rec)
|
|
if resp.Reason != imReasonResourceNotFound {
|
|
t.Fatalf("reason: got %q want %q", resp.Reason, imReasonResourceNotFound)
|
|
}
|
|
}
|
|
|
|
func TestServeIMAbuseReportsAndReasons(t *testing.T) {
|
|
ctrl := gomock.NewController(t)
|
|
defer ctrl.Finish()
|
|
expectIMBearer(t, ctrl, "token-1", 7, 42)
|
|
oldSafety := store.Safety
|
|
oldUsers := store.Users
|
|
safety := &testSafetyStore{}
|
|
store.Safety = safety
|
|
users := mock_store.NewMockUsersPersistenceInterface(ctrl)
|
|
store.Users = users
|
|
users.EXPECT().Get(types.TenantID(7), types.Uid(43)).Return(&types.User{}, nil).AnyTimes()
|
|
defer func() {
|
|
store.Safety = oldSafety
|
|
store.Users = oldUsers
|
|
}()
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/v1/im/reports/abuse/reasons?target_type=user", nil)
|
|
req.Header.Set("Authorization", "Bearer token-1")
|
|
rec := httptest.NewRecorder()
|
|
serveIMAbuseReasons(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("reasons status: got %d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
if safety.tenantID != 7 || safety.targetType != "user" {
|
|
t.Fatalf("abuse reason scope mismatch: %#v", safety)
|
|
}
|
|
|
|
req = httptest.NewRequest(http.MethodGet, "/v1/im/reports/abuse/reasons?target_type=group_member", nil)
|
|
req.Header.Set("Authorization", "Bearer token-1")
|
|
rec = httptest.NewRecorder()
|
|
serveIMAbuseReasons(rec, req)
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("unsupported reasons status: got %d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
|
|
req = httptest.NewRequest(http.MethodPost, "/v1/im/reports/abuse",
|
|
bytes.NewBufferString(`{"target":{"type":"user","user_id":"usrKwAAAAAAAAA"},"reason_code":"fraud","description":"疑似诈骗","evidence_file_ids":["file1"]}`))
|
|
req.Header.Set("Authorization", "Bearer token-1")
|
|
req.Header.Set("Idempotency-Key", "abuse-1")
|
|
rec = httptest.NewRecorder()
|
|
serveIMAbuseReports(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("report status: got %d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
if safety.tenantID != 7 || safety.idempotency != "abuse-1" || safety.abuseReport == nil || safety.abuseReport.ReporterID != 42 || safety.abuseReport.TargetUserID != 43 || safety.abuseReport.ReasonCode != "fraud" {
|
|
t.Fatalf("unexpected abuse report: %#v", safety.abuseReport)
|
|
}
|
|
}
|
|
|
|
func TestServeIMFeedback(t *testing.T) {
|
|
ctrl := gomock.NewController(t)
|
|
defer ctrl.Finish()
|
|
expectIMBearer(t, ctrl, "token-1", 7, 42)
|
|
oldSafety := store.Safety
|
|
safety := &testSafetyStore{}
|
|
store.Safety = safety
|
|
defer func() { store.Safety = oldSafety }()
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/v1/im/feedback/config", nil)
|
|
req.Header.Set("Authorization", "Bearer token-1")
|
|
rec := httptest.NewRecorder()
|
|
serveIMFeedbackConfig(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("config status: got %d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
if safety.tenantID != 7 || safety.uid != 42 {
|
|
t.Fatalf("feedback config scope mismatch: %#v", safety)
|
|
}
|
|
|
|
req = httptest.NewRequest(http.MethodPost, "/v1/im/feedback",
|
|
bytes.NewBufferString(`{"category":"bug","content":"页面显示异常","contact":"me@example.com","attachment_file_ids":["file1"],"client_context":{"platform":"android"},"include_diagnostics":true}`))
|
|
req.Header.Set("Authorization", "Bearer token-1")
|
|
req.Header.Set("Idempotency-Key", "feedback-1")
|
|
rec = httptest.NewRecorder()
|
|
serveIMFeedback(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("submit status: got %d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
if safety.tenantID != 7 || safety.idempotency != "feedback-1" || safety.feedback == nil || safety.feedback.UserID != 42 || safety.feedback.Category != "bug" || !safety.feedback.IncludeDiagnostics {
|
|
t.Fatalf("unexpected feedback: %#v", safety.feedback)
|
|
}
|
|
}
|
|
|
|
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())
|
|
}
|
|
searchData := decodeIMHTTPResponse(t, searchRec).Data.(map[string]any)
|
|
searchItems := searchData["items"].([]any)
|
|
if len(searchItems) != 1 || searchItems[0].(map[string]any)["from"] != types.Uid(42).UserId() {
|
|
t.Fatalf("search from must use full user_id: %#v", searchData)
|
|
}
|
|
searchSnapshot := searchItems[0].(map[string]any)["snapshot"].(map[string]any)
|
|
if searchSnapshot["from"] != types.Uid(42).UserId() {
|
|
t.Fatalf("search snapshot from must use full user_id: %#v", searchSnapshot)
|
|
}
|
|
|
|
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())
|
|
}
|
|
contextData := decodeIMHTTPResponse(t, contextRec).Data.(map[string]any)
|
|
contextItems := contextData["items"].([]any)
|
|
if len(contextItems) != 1 || contextItems[0].(map[string]any)["from"] != types.Uid(42).UserId() {
|
|
t.Fatalf("context from must use full user_id: %#v", contextData)
|
|
}
|
|
|
|
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())
|
|
}
|
|
mediaData := decodeIMHTTPResponse(t, mediaRec).Data.(map[string]any)
|
|
mediaItems := mediaData["items"].([]any)
|
|
if len(mediaItems) != 1 || mediaItems[0].(map[string]any)["from"] != types.Uid(42).UserId() {
|
|
t.Fatalf("media from must use full user_id: %#v", mediaData)
|
|
}
|
|
}
|
|
|
|
func TestServeIMTopicReadActionsNormalizeP2PUserTopic(t *testing.T) {
|
|
ctrl := gomock.NewController(t)
|
|
defer ctrl.Finish()
|
|
actor := types.Uid(42)
|
|
peer := types.Uid(43)
|
|
normalizedTopic := actor.P2PName(peer)
|
|
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: actor, 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), normalizedTopic, actor, false).Return(&types.Subscription{
|
|
TenantID: 7,
|
|
User: actor.String(),
|
|
Topic: normalizedTopic,
|
|
ModeWant: types.ModeCPublic,
|
|
ModeGiven: types.ModeCPublic,
|
|
}, nil).Times(3)
|
|
textMsg := types.Message{TenantID: 7, SeqId: 9, Topic: normalizedTopic, From: actor.String(), Head: types.KVMap{"type": "text"}, Content: map[string]any{"text": "好的"}}
|
|
imageMsg := types.Message{TenantID: 7, SeqId: 10, Topic: normalizedTopic, From: actor.String(), Head: types.KVMap{"type": "image"}, Content: map[string]any{"file_id": "file-1", "mime": "image/png"}}
|
|
messages.EXPECT().Search(types.TenantID(7), normalizedTopic, actor, "好的", "text", "", 50).Return([]types.Message{textMsg}, "", nil)
|
|
messages.EXPECT().GetAll(types.TenantID(7), normalizedTopic, actor, gomock.Any()).Return([]types.Message{textMsg}, nil)
|
|
messages.EXPECT().Search(types.TenantID(7), normalizedTopic, actor, "", "image", "", 50).Return([]types.Message{imageMsg}, "", nil)
|
|
|
|
searchReq := httptest.NewRequest(http.MethodGet, "/v1/im/topics/"+peer.UserId()+"/messages/search?q=%E5%A5%BD%E7%9A%84&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/"+peer.UserId()+"/messages/9/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())
|
|
}
|
|
contextData := decodeIMHTTPResponse(t, contextRec).Data.(map[string]any)
|
|
if contextData["topic"] != normalizedTopic {
|
|
t.Fatalf("context topic was not normalized: %#v", contextData)
|
|
}
|
|
|
|
mediaReq := httptest.NewRequest(http.MethodGet, "/v1/im/topics/"+peer.UserId()+"/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 TestMessageSearchPayloadMatchesUseSnippetRuneOffsets(t *testing.T) {
|
|
msg := types.Message{
|
|
TenantID: 7,
|
|
Topic: "grpAlpha",
|
|
SeqId: 1,
|
|
Version: 2,
|
|
From: types.Uid(42).String(),
|
|
Head: types.KVMap{"type": "text"},
|
|
Content: map[string]any{"txt": "你们已成为好友,现在可以开始聊天了。", "fmt": []any{}},
|
|
ObjHeader: types.ObjHeader{
|
|
CreatedAt: time.Date(2026, 8, 13, 9, 0, 0, 0, time.UTC),
|
|
},
|
|
}
|
|
payload := messageSearchPayload(msg, "好友")
|
|
if payload["snippet"] != "你们已成为好友,现在可以开始聊天了。" {
|
|
t.Fatalf("unexpected snippet: %#v", payload["snippet"])
|
|
}
|
|
matches := payload["matches"].([]map[string]any)
|
|
if len(matches) != 1 || matches[0]["start"] != 5 || matches[0]["end"] != 7 {
|
|
t.Fatalf("unexpected matches: %#v", matches)
|
|
}
|
|
if payload["version"] != 2 {
|
|
t.Fatalf("unexpected version: %#v", payload["version"])
|
|
}
|
|
snapshot := payload["snapshot"].(map[string]any)
|
|
if snapshot["text"] != "你们已成为好友,现在可以开始聊天了。" || snapshot["version"] != 2 {
|
|
t.Fatalf("unexpected snapshot: %#v", snapshot)
|
|
}
|
|
}
|
|
|
|
func TestServeIMTopicCalls(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
|
|
oldSubs := store.Subs
|
|
oldCalls := store.Calls
|
|
subs := mock_store.NewMockSubsPersistenceInterface(ctrl)
|
|
calls := &testCallsStore{items: []types.CallRecord{{
|
|
ID: 9,
|
|
TenantID: 7,
|
|
CallID: "call-1",
|
|
Topic: "p2pTopic",
|
|
SeqID: 12,
|
|
CallerID: 42,
|
|
CalleeID: 43,
|
|
CallType: "video",
|
|
Status: types.CallStatusFinished,
|
|
DurationMS: 1200,
|
|
StartedAt: time.Date(2026, 8, 13, 9, 0, 0, 0, time.UTC),
|
|
CreatedAt: time.Date(2026, 8, 13, 9, 0, 0, 0, time.UTC),
|
|
UpdatedAt: time.Date(2026, 8, 13, 9, 1, 0, 0, time.UTC),
|
|
}}}
|
|
store.Subs = subs
|
|
store.Calls = calls
|
|
defer func() {
|
|
store.Store = oldStore
|
|
store.Subs = oldSubs
|
|
store.Calls = oldCalls
|
|
}()
|
|
subs.EXPECT().Get(types.TenantID(7), "p2pTopic", types.Uid(42), false).Return(&types.Subscription{
|
|
TenantID: 7, User: types.Uid(42).String(), Topic: "p2pTopic", ModeWant: types.ModeCPublic, ModeGiven: types.ModeCPublic,
|
|
}, nil)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/v1/im/topics/p2pTopic/calls?cursor=c1&limit=1", nil)
|
|
req.Header.Set("Authorization", "Bearer token-1")
|
|
rec := httptest.NewRecorder()
|
|
serveIMTopicMessageAction(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("calls status: got %d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
if !calls.listed || calls.tenant != 7 || calls.user != 42 || calls.topic != "p2pTopic" || calls.cursor != "c1" || calls.limit != 1 {
|
|
t.Fatalf("calls list scope mismatch: %#v", calls)
|
|
}
|
|
var resp imHTTPResponse
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
data := resp.Data.(map[string]any)
|
|
items := data["items"].([]any)
|
|
if len(items) != 1 {
|
|
t.Fatalf("items: got %#v", items)
|
|
}
|
|
item := items[0].(map[string]any)
|
|
if item["call_id"] != "call-1" || item["call_type"] != "video" || item["direction"] != "outgoing" {
|
|
t.Fatalf("unexpected call item: %#v", item)
|
|
}
|
|
}
|
|
|
|
func TestServeIMTopicCallsRejectsUnauthorizedWithoutListing(t *testing.T) {
|
|
ctrl := gomock.NewController(t)
|
|
defer ctrl.Finish()
|
|
expectIMBearer(t, ctrl, "token-1", 7, 42)
|
|
oldSubs := store.Subs
|
|
oldCalls := store.Calls
|
|
subs := mock_store.NewMockSubsPersistenceInterface(ctrl)
|
|
calls := &testCallsStore{}
|
|
store.Subs = subs
|
|
store.Calls = calls
|
|
defer func() {
|
|
store.Subs = oldSubs
|
|
store.Calls = oldCalls
|
|
}()
|
|
subs.EXPECT().Get(types.TenantID(7), "p2pDenied", types.Uid(42), false).Return(nil, types.ErrPermissionDenied)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/v1/im/topics/p2pDenied/calls", nil)
|
|
req.Header.Set("Authorization", "Bearer token-1")
|
|
rec := httptest.NewRecorder()
|
|
serveIMTopicMessageAction(rec, req)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("calls status: got %d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
if calls.listed {
|
|
t.Fatalf("calls were listed despite missing read permission: %#v", calls)
|
|
}
|
|
}
|
|
|
|
func TestServeIMCallRTCJoinReturnsAgoraAppIDMode(t *testing.T) {
|
|
ctrl := gomock.NewController(t)
|
|
defer ctrl.Finish()
|
|
expectIMBearer(t, ctrl, "token-caller", 7, 42)
|
|
oldSubs := store.Subs
|
|
oldCalls := store.Calls
|
|
oldThirdParty := store.ThirdPartyConfig
|
|
subs := mock_store.NewMockSubsPersistenceInterface(ctrl)
|
|
topic := types.Uid(42).P2PName(types.Uid(43))
|
|
calls := &testCallsStore{items: []types.CallRecord{{
|
|
ID: 9,
|
|
TenantID: 7,
|
|
CallID: "call-1",
|
|
Topic: topic,
|
|
SeqID: 12,
|
|
CallerID: 42,
|
|
CalleeID: 43,
|
|
CallType: "video",
|
|
Status: types.CallStatusStarted,
|
|
StartedAt: time.Date(2026, 9, 2, 9, 0, 0, 0, time.UTC),
|
|
CreatedAt: time.Date(2026, 9, 2, 9, 0, 0, 0, time.UTC),
|
|
UpdatedAt: time.Date(2026, 9, 2, 9, 0, 0, 0, time.UTC),
|
|
}}}
|
|
store.Subs = subs
|
|
store.Calls = calls
|
|
store.ThirdPartyConfig = testThirdPartyConfigStore{
|
|
"agora_enabled": "1",
|
|
"agora_auth_mode": "app_id",
|
|
"agora_app_id": "app123",
|
|
"agora_channel_prefix": "call_",
|
|
"agora_uid_mode": "string",
|
|
"agora_token_ttl_seconds": "3600",
|
|
}
|
|
defer func() {
|
|
store.Subs = oldSubs
|
|
store.Calls = oldCalls
|
|
store.ThirdPartyConfig = oldThirdParty
|
|
}()
|
|
subs.EXPECT().Get(types.TenantID(7), topic, types.Uid(42), false).Return(&types.Subscription{
|
|
TenantID: 7, User: types.Uid(42).String(), Topic: topic, ModeWant: types.ModeCPublic, ModeGiven: types.ModeCPublic,
|
|
}, nil)
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/im/calls/rtc-join", strings.NewReader(`{"topic":"`+topic+`","call_id":"call-1","seq":12,"call_type":"video","role":"caller"}`))
|
|
req.Header.Set("Authorization", "Bearer token-caller")
|
|
rec := httptest.NewRecorder()
|
|
serveIMCallRTCJoin(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("rtc join status: got %d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
if !calls.listed || calls.tenant != 7 || calls.user != 42 || calls.topic != topic || calls.limit != 100 {
|
|
t.Fatalf("calls lookup mismatch: %#v", calls)
|
|
}
|
|
resp := decodeIMHTTPResponse(t, rec)
|
|
data := resp.Data.(map[string]any)
|
|
if data["provider"] != "agora" || data["auth_mode"] != "app_id" || data["app_id"] != "app123" ||
|
|
data["channel_name"] != "call_call-1" || data["uid"] != types.Uid(42).UserId() || data["token"] != "" {
|
|
t.Fatalf("unexpected rtc join payload: %#v", data)
|
|
}
|
|
}
|
|
|
|
func TestServeIMCallRTCJoinAcceptsP2PAliasTopic(t *testing.T) {
|
|
ctrl := gomock.NewController(t)
|
|
defer ctrl.Finish()
|
|
expectIMBearer(t, ctrl, "token-caller", 7, 42)
|
|
oldSubs := store.Subs
|
|
oldCalls := store.Calls
|
|
oldThirdParty := store.ThirdPartyConfig
|
|
subs := mock_store.NewMockSubsPersistenceInterface(ctrl)
|
|
peer := types.Uid(43)
|
|
topic := types.Uid(42).P2PName(peer)
|
|
calls := &testCallsStore{items: []types.CallRecord{{
|
|
ID: 9,
|
|
TenantID: 7,
|
|
CallID: "call-1",
|
|
Topic: topic,
|
|
SeqID: 12,
|
|
CallerID: 42,
|
|
CalleeID: peer,
|
|
CallType: "audio",
|
|
Status: types.CallStatusStarted,
|
|
StartedAt: time.Date(2026, 9, 2, 9, 0, 0, 0, time.UTC),
|
|
CreatedAt: time.Date(2026, 9, 2, 9, 0, 0, 0, time.UTC),
|
|
UpdatedAt: time.Date(2026, 9, 2, 9, 0, 0, 0, time.UTC),
|
|
}}}
|
|
store.Subs = subs
|
|
store.Calls = calls
|
|
store.ThirdPartyConfig = testThirdPartyConfigStore{
|
|
"agora_enabled": "1",
|
|
"agora_auth_mode": "app_id",
|
|
"agora_app_id": "app123",
|
|
"agora_channel_prefix": "call_",
|
|
"agora_uid_mode": "string",
|
|
"agora_token_ttl_seconds": "3600",
|
|
}
|
|
defer func() {
|
|
store.Subs = oldSubs
|
|
store.Calls = oldCalls
|
|
store.ThirdPartyConfig = oldThirdParty
|
|
}()
|
|
subs.EXPECT().Get(types.TenantID(7), topic, types.Uid(42), false).Return(&types.Subscription{
|
|
TenantID: 7, User: types.Uid(42).String(), Topic: topic, ModeWant: types.ModeCPublic, ModeGiven: types.ModeCPublic,
|
|
}, nil)
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/im/calls/rtc-join", strings.NewReader(`{"topic":"`+peer.UserId()+`","call_id":"call-1","seq":12,"call_type":"audio","role":"caller"}`))
|
|
req.Header.Set("Authorization", "Bearer token-caller")
|
|
rec := httptest.NewRecorder()
|
|
serveIMCallRTCJoin(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("rtc join status: got %d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
|
|
if data["topic"] != topic || calls.topic != topic {
|
|
t.Fatalf("alias topic was not normalized: data=%#v calls=%#v", data, calls)
|
|
}
|
|
}
|
|
|
|
func TestServeIMCallRTCJoinReturnsSameAgoraChannelForCallee(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).AnyTimes()
|
|
tokenAuth.EXPECT().IsInitialized().Return(true).AnyTimes()
|
|
tokenAuth.EXPECT().Authenticate(auth.AuthContext{}, []byte("token-caller")).
|
|
Return(&auth.Rec{TenantID: 7, Uid: 42, AuthLevel: auth.LevelAuth}, nil, nil).AnyTimes()
|
|
tokenAuth.EXPECT().Authenticate(auth.AuthContext{}, []byte("token-callee")).
|
|
Return(&auth.Rec{TenantID: 7, Uid: 43, AuthLevel: auth.LevelAuth}, nil, nil).AnyTimes()
|
|
store.Store = mockStore
|
|
oldSubs := store.Subs
|
|
oldCalls := store.Calls
|
|
oldThirdParty := store.ThirdPartyConfig
|
|
subs := mock_store.NewMockSubsPersistenceInterface(ctrl)
|
|
topic := types.Uid(42).P2PName(types.Uid(43))
|
|
calls := &testCallsStore{items: []types.CallRecord{{
|
|
ID: 9,
|
|
TenantID: 7,
|
|
CallID: "call-1",
|
|
Topic: topic,
|
|
SeqID: 12,
|
|
CallerID: 42,
|
|
CalleeID: 43,
|
|
CallType: "audio",
|
|
Status: types.CallStatusStarted,
|
|
StartedAt: time.Date(2026, 9, 2, 9, 0, 0, 0, time.UTC),
|
|
CreatedAt: time.Date(2026, 9, 2, 9, 0, 0, 0, time.UTC),
|
|
UpdatedAt: time.Date(2026, 9, 2, 9, 0, 0, 0, time.UTC),
|
|
}}}
|
|
store.Subs = subs
|
|
store.Calls = calls
|
|
store.ThirdPartyConfig = testThirdPartyConfigStore{
|
|
"agora_enabled": "1",
|
|
"agora_auth_mode": "app_id",
|
|
"agora_app_id": "app123",
|
|
"agora_channel_prefix": "call_",
|
|
"agora_uid_mode": "string",
|
|
"agora_token_ttl_seconds": "3600",
|
|
}
|
|
defer func() {
|
|
store.Store = oldStore
|
|
store.Subs = oldSubs
|
|
store.Calls = oldCalls
|
|
store.ThirdPartyConfig = oldThirdParty
|
|
}()
|
|
subs.EXPECT().Get(types.TenantID(7), topic, types.Uid(42), false).Return(&types.Subscription{
|
|
TenantID: 7, User: types.Uid(42).String(), Topic: topic, ModeWant: types.ModeCPublic, ModeGiven: types.ModeCPublic,
|
|
}, nil)
|
|
subs.EXPECT().Get(types.TenantID(7), topic, types.Uid(43), false).Return(&types.Subscription{
|
|
TenantID: 7, User: types.Uid(43).String(), Topic: topic, ModeWant: types.ModeCPublic, ModeGiven: types.ModeCPublic,
|
|
}, nil)
|
|
|
|
callerReq := httptest.NewRequest(http.MethodPost, "/v1/im/calls/rtc-join", strings.NewReader(`{"topic":"`+topic+`","call_id":"call-1","seq":12,"call_type":"audio","role":"caller"}`))
|
|
callerReq.Header.Set("Authorization", "Bearer token-caller")
|
|
callerRec := httptest.NewRecorder()
|
|
serveIMCallRTCJoin(callerRec, callerReq)
|
|
if callerRec.Code != http.StatusOK {
|
|
t.Fatalf("caller rtc join status: got %d body=%s", callerRec.Code, callerRec.Body.String())
|
|
}
|
|
if calls.accepted {
|
|
t.Fatal("caller rtc join must not auto-accept the call")
|
|
}
|
|
|
|
calleeReq := httptest.NewRequest(http.MethodPost, "/v1/im/calls/rtc-join", strings.NewReader(`{"topic":"`+topic+`","call_id":"call-1","seq":12,"call_type":"audio","role":"callee"}`))
|
|
calleeReq.Header.Set("Authorization", "Bearer token-callee")
|
|
calleeRec := httptest.NewRecorder()
|
|
serveIMCallRTCJoin(calleeRec, calleeReq)
|
|
if calleeRec.Code != http.StatusOK {
|
|
t.Fatalf("callee rtc join status: got %d body=%s", calleeRec.Code, calleeRec.Body.String())
|
|
}
|
|
if !calls.accepted || calls.acceptTenant != 7 || calls.acceptCallID != "call-1" || calls.acceptSeq != 12 || calls.acceptedAt.IsZero() {
|
|
t.Fatalf("callee rtc join did not mark call accepted: %#v", calls)
|
|
}
|
|
|
|
callerData := decodeIMHTTPResponse(t, callerRec).Data.(map[string]any)
|
|
calleeData := decodeIMHTTPResponse(t, calleeRec).Data.(map[string]any)
|
|
if callerData["channel_name"] != calleeData["channel_name"] {
|
|
t.Fatalf("channel mismatch: caller=%#v callee=%#v", callerData, calleeData)
|
|
}
|
|
if callerData["uid"] == calleeData["uid"] || calleeData["uid"] != types.Uid(43).UserId() {
|
|
t.Fatalf("unexpected rtc uid: caller=%#v callee=%#v", callerData, calleeData)
|
|
}
|
|
if callerData["token"] != "" || calleeData["token"] != "" {
|
|
t.Fatalf("app_id mode token must be empty: caller=%#v callee=%#v", callerData, calleeData)
|
|
}
|
|
}
|
|
|
|
func TestServeIMCallRTCJoinReturnsAgoraTokenMode(t *testing.T) {
|
|
ctrl := gomock.NewController(t)
|
|
defer ctrl.Finish()
|
|
expectIMBearer(t, ctrl, "token-1", 7, 42)
|
|
oldSubs := store.Subs
|
|
oldCalls := store.Calls
|
|
oldThirdParty := store.ThirdPartyConfig
|
|
subs := mock_store.NewMockSubsPersistenceInterface(ctrl)
|
|
topic := types.Uid(42).P2PName(types.Uid(43))
|
|
calls := &testCallsStore{items: []types.CallRecord{{
|
|
ID: 9,
|
|
TenantID: 7,
|
|
CallID: "call-1",
|
|
Topic: topic,
|
|
SeqID: 12,
|
|
CallerID: 42,
|
|
CalleeID: 43,
|
|
CallType: "video",
|
|
Status: types.CallStatusStarted,
|
|
StartedAt: time.Date(2026, 9, 2, 9, 0, 0, 0, time.UTC),
|
|
CreatedAt: time.Date(2026, 9, 2, 9, 0, 0, 0, time.UTC),
|
|
UpdatedAt: time.Date(2026, 9, 2, 9, 0, 0, 0, time.UTC),
|
|
}}}
|
|
store.Subs = subs
|
|
store.Calls = calls
|
|
store.ThirdPartyConfig = testThirdPartyConfigStore{
|
|
"agora_enabled": "1",
|
|
"agora_auth_mode": "token",
|
|
"agora_app_id": "00000000000000000000000000000000",
|
|
"agora_app_certificate": "11111111111111111111111111111111",
|
|
"agora_channel_prefix": "call_",
|
|
"agora_uid_mode": "string",
|
|
"agora_token_ttl_seconds": "3600",
|
|
}
|
|
defer func() {
|
|
store.Subs = oldSubs
|
|
store.Calls = oldCalls
|
|
store.ThirdPartyConfig = oldThirdParty
|
|
}()
|
|
subs.EXPECT().Get(types.TenantID(7), topic, types.Uid(42), false).Return(&types.Subscription{
|
|
TenantID: 7, User: types.Uid(42).String(), Topic: topic, ModeWant: types.ModeCPublic, ModeGiven: types.ModeCPublic,
|
|
}, nil)
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/im/calls/rtc-join", strings.NewReader(`{"topic":"`+topic+`","call_id":"call-1","seq":12,"call_type":"video","role":"caller"}`))
|
|
req.Header.Set("Authorization", "Bearer token-1")
|
|
rec := httptest.NewRecorder()
|
|
serveIMCallRTCJoin(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("rtc join status: got %d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
|
|
if data["auth_mode"] != "token" || data["token"] == "" || data["token_expires_at"] == nil {
|
|
t.Fatalf("unexpected token payload: %#v", data)
|
|
}
|
|
if _, found := data["app_certificate"]; found {
|
|
t.Fatalf("app certificate must not be returned: %#v", data)
|
|
}
|
|
}
|
|
|
|
func TestServeIMCallRTCJoinRejectsDisabledAgora(t *testing.T) {
|
|
ctrl := gomock.NewController(t)
|
|
defer ctrl.Finish()
|
|
expectIMBearer(t, ctrl, "token-1", 7, 42)
|
|
oldThirdParty := store.ThirdPartyConfig
|
|
store.ThirdPartyConfig = testThirdPartyConfigStore{"agora_enabled": "0"}
|
|
defer func() { store.ThirdPartyConfig = oldThirdParty }()
|
|
|
|
topic := types.Uid(42).P2PName(types.Uid(43))
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/im/calls/rtc-join", strings.NewReader(`{"topic":"`+topic+`","call_id":"call-1","seq":12,"call_type":"video","role":"caller"}`))
|
|
req.Header.Set("Authorization", "Bearer token-1")
|
|
rec := httptest.NewRecorder()
|
|
serveIMCallRTCJoin(rec, req)
|
|
if rec.Code != http.StatusServiceUnavailable {
|
|
t.Fatalf("rtc join status: got %d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
resp := decodeIMHTTPResponse(t, rec)
|
|
if resp.Reason != imReasonAgoraDisabled {
|
|
t.Fatalf("reason: got %q", resp.Reason)
|
|
}
|
|
}
|
|
|
|
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
|
|
oldProfiles := store.Profiles
|
|
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,
|
|
}}
|
|
store.Profiles = &testProfilesStore{}
|
|
defer func() {
|
|
store.Store = oldStore
|
|
store.Users = oldUsers
|
|
store.Relations = oldRelations
|
|
store.Profiles = oldProfiles
|
|
}()
|
|
|
|
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", int64(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", int64(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(`{}`))
|
|
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 TestServeIMMessageMutations(t *testing.T) {
|
|
ctrl := gomock.NewController(t)
|
|
defer ctrl.Finish()
|
|
actor := types.Uid(42)
|
|
expectIMBearer(t, ctrl, "token-1", 7, actor)
|
|
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", actor, false).Return(&types.Subscription{
|
|
TenantID: 7,
|
|
User: actor.String(),
|
|
Topic: "grpRecall",
|
|
ModeWant: types.ModeCPublic,
|
|
ModeGiven: types.ModeCPublic,
|
|
}, nil)
|
|
mutatedAt := time.Date(2026, 9, 15, 10, 0, 0, 0, time.UTC)
|
|
messages.EXPECT().Mutations(types.TenantID(7), "grpRecall", actor, int64(1), 20).Return([]types.MessageMutation{{
|
|
SeqId: 9,
|
|
Version: 2,
|
|
Event: "message.recalled",
|
|
Operator: actor.UserId(),
|
|
MutatedAt: mutatedAt,
|
|
MutationVersion: 3,
|
|
}}, "3", false, int64(3), nil)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/v1/im/topics/grpRecall/message-mutations?since_version=1&limit=20", nil)
|
|
req.Header.Set("Authorization", "Bearer token-1")
|
|
rec := httptest.NewRecorder()
|
|
|
|
serveIMTopicMessageAction(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["mutation_version"].(float64) != 3 || data["next_cursor"] != "3" {
|
|
t.Fatalf("unexpected mutation response: %#v", data)
|
|
}
|
|
items := data["items"].([]any)
|
|
if len(items) != 1 {
|
|
t.Fatalf("unexpected mutation items: %#v", items)
|
|
}
|
|
item := items[0].(map[string]any)
|
|
if item["seq"].(float64) != 9 || item["version"].(float64) != 2 || item["event"] != "message.recalled" {
|
|
t.Fatalf("unexpected mutation item: %#v", item)
|
|
}
|
|
}
|
|
|
|
func TestServeIMMessageBatchRecallPartialSuccess(t *testing.T) {
|
|
ctrl := gomock.NewController(t)
|
|
defer ctrl.Finish()
|
|
actor := types.Uid(42)
|
|
expectIMBearer(t, ctrl, "token-1", 7, actor)
|
|
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
|
|
}()
|
|
|
|
sub := &types.Subscription{
|
|
TenantID: 7,
|
|
User: actor.String(),
|
|
Topic: "grpRecall",
|
|
ModeWant: types.ModeCFull,
|
|
ModeGiven: types.ModeCFull,
|
|
}
|
|
subs.EXPECT().Get(types.TenantID(7), "grpRecall", actor, false).Return(sub, nil).Times(2)
|
|
messages.EXPECT().GetOne(types.TenantID(7), "grpRecall", int64(9)).Return(&types.Message{
|
|
TenantID: 7,
|
|
SeqId: 9,
|
|
Topic: "grpRecall",
|
|
From: actor.String(),
|
|
Version: 1,
|
|
ObjHeader: types.ObjHeader{CreatedAt: time.Now().Add(-time.Second)},
|
|
}, nil)
|
|
messages.EXPECT().Recall(types.TenantID(7), "grpRecall", int64(9), actor, 1, gomock.Any()).Return(&types.Message{
|
|
TenantID: 7,
|
|
SeqId: 9,
|
|
Topic: "grpRecall",
|
|
From: actor.String(),
|
|
Version: 2,
|
|
RecalledAt: ptrTime(time.Now().UTC()),
|
|
RecalledBy: actor.String(),
|
|
ObjHeader: types.ObjHeader{UpdatedAt: time.Now().UTC()},
|
|
}, nil)
|
|
messages.EXPECT().GetOne(types.TenantID(7), "grpRecall", int64(10)).Return(&types.Message{
|
|
TenantID: 7,
|
|
SeqId: 10,
|
|
Topic: "grpRecall",
|
|
From: actor.String(),
|
|
Version: 2,
|
|
ObjHeader: types.ObjHeader{CreatedAt: time.Now().Add(-time.Second)},
|
|
}, nil)
|
|
messages.EXPECT().Recall(types.TenantID(7), "grpRecall", int64(10), actor, 1, gomock.Any()).Return(nil, types.ErrDuplicate)
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/im/topics/grpRecall/messages/recall",
|
|
bytes.NewBufferString(`{"items":[{"seq":9},{"seq":10,"version":1}],"reason":"mistake"}`))
|
|
req.Header.Set("Authorization", "Bearer token-1")
|
|
rec := httptest.NewRecorder()
|
|
|
|
serveIMMessageBatchRecall(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) != 2 {
|
|
t.Fatalf("items length: got %d, want 2", len(items))
|
|
}
|
|
first := items[0].(map[string]any)
|
|
if first["seq"].(float64) != 9 || first["status"] != "recalled" || first["version"].(float64) != 2 {
|
|
t.Fatalf("unexpected first item: %#v", first)
|
|
}
|
|
second := items[1].(map[string]any)
|
|
if second["seq"].(float64) != 10 || second["status"] != "failed" || second["reason"] != "VERSION_CONFLICT" {
|
|
t.Fatalf("unexpected second item: %#v", second)
|
|
}
|
|
}
|
|
|
|
func TestServeIMMessageRecallNormalizesP2PUserTopic(t *testing.T) {
|
|
ctrl := gomock.NewController(t)
|
|
defer ctrl.Finish()
|
|
actor := types.Uid(42)
|
|
peer := types.Uid(43)
|
|
normalizedTopic := actor.P2PName(peer)
|
|
expectIMBearer(t, ctrl, "token-1", 7, actor)
|
|
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), normalizedTopic, actor, false).Return(&types.Subscription{
|
|
TenantID: 7,
|
|
User: actor.String(),
|
|
Topic: normalizedTopic,
|
|
ModeWant: types.ModeCPublic,
|
|
ModeGiven: types.ModeCPublic,
|
|
}, nil)
|
|
messages.EXPECT().GetOne(types.TenantID(7), normalizedTopic, int64(9)).Return(&types.Message{
|
|
TenantID: 7,
|
|
SeqId: 9,
|
|
Topic: normalizedTopic,
|
|
From: actor.String(),
|
|
Version: 1,
|
|
ObjHeader: types.ObjHeader{CreatedAt: time.Now().Add(-time.Second)},
|
|
}, nil)
|
|
messages.EXPECT().Recall(types.TenantID(7), normalizedTopic, int64(9), actor, 1, gomock.Any()).Return(&types.Message{
|
|
TenantID: 7,
|
|
SeqId: 9,
|
|
Topic: normalizedTopic,
|
|
From: actor.String(),
|
|
Version: 2,
|
|
RecalledAt: ptrTime(time.Now().UTC()),
|
|
RecalledBy: actor.String(),
|
|
ObjHeader: types.ObjHeader{UpdatedAt: time.Now().UTC()},
|
|
}, nil)
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/im/topics/"+peer.UserId()+"/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["topic"] != normalizedTopic {
|
|
t.Fatalf("expected normalized topic %q, got %#v", normalizedTopic, data)
|
|
}
|
|
}
|
|
|
|
func TestEnqueueIMP2PMessageMutationEventsDeliversUserDirtyMarker(t *testing.T) {
|
|
oldStore := globals.sessionStore
|
|
defer func() { globals.sessionStore = oldStore }()
|
|
|
|
tenantID := types.TenantID(7)
|
|
actor := types.Uid(42)
|
|
peer := types.Uid(43)
|
|
topic := actor.P2PName(peer)
|
|
globals.sessionStore = realtimeTestSessionStore(
|
|
realtimeTestSession("actor-session", tenantID, actor, "me"),
|
|
realtimeTestSession("peer-session", tenantID, peer, "me"),
|
|
)
|
|
|
|
if err := enqueueIMP2PMessageMutationEvents(tenantID, topic, actor, 9, 2, "message.recalled"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
for _, tc := range []struct {
|
|
sid string
|
|
uid types.Uid
|
|
}{
|
|
{"actor-session", actor},
|
|
{"peer-session", peer},
|
|
} {
|
|
sess := globals.sessionStore.Get(tc.sid)
|
|
select {
|
|
case raw := <-sess.send:
|
|
msg, ok := raw.(*ServerComMessage)
|
|
if !ok || msg.Info == nil {
|
|
t.Fatalf("unexpected outbound for %s: %#v", tc.sid, raw)
|
|
}
|
|
if msg.Info.Topic != tc.uid.UserId() || msg.Info.Event != "message.mutation" || msg.Info.What != "im" {
|
|
t.Fatalf("unexpected mutation marker for %s: %#v", tc.sid, msg.Info)
|
|
}
|
|
var payload map[string]any
|
|
if err := json.Unmarshal(msg.Info.Payload, &payload); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if payload["topic"] != topic || payload["event"] != "message.recalled" ||
|
|
payload["seq"].(float64) != 9 || payload["version"].(float64) != 2 {
|
|
t.Fatalf("unexpected mutation payload for %s: %#v", tc.sid, payload)
|
|
}
|
|
default:
|
|
t.Fatalf("expected mutation marker for %s", tc.sid)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestEnqueueIMP2PMessageMutationEventsIgnoresGroupTopic(t *testing.T) {
|
|
oldStore := globals.sessionStore
|
|
defer func() { globals.sessionStore = oldStore }()
|
|
|
|
tenantID := types.TenantID(7)
|
|
actor := types.Uid(42)
|
|
globals.sessionStore = realtimeTestSessionStore(
|
|
realtimeTestSession("actor-session", tenantID, actor, "me"),
|
|
)
|
|
|
|
if err := enqueueIMP2PMessageMutationEvents(tenantID, "grpRecall", actor, 9, 2, "message.recalled"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(globals.sessionStore.Get("actor-session").send) != 0 {
|
|
t.Fatal("group topic should not emit per-user mutation markers")
|
|
}
|
|
}
|
|
|
|
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", int64(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", int64(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 TestServeIMConversationContextAcceptsUserTopic(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{}
|
|
defer func() { store.Relations = oldRelations }()
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/v1/im/conversations/"+peer.UserId()+"/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["topic"] != topic || data["peer_user_id"] != peer.UserId() {
|
|
t.Fatalf("unexpected context response: %#v", data)
|
|
}
|
|
}
|
|
|
|
func TestServeIMConversationContextAcceptsFriendListTopicFallback(t *testing.T) {
|
|
ctrl := gomock.NewController(t)
|
|
defer ctrl.Finish()
|
|
expectIMBearer(t, ctrl, "token-1", 7, 1)
|
|
oldRelations := store.Relations
|
|
peer := types.Uid(2)
|
|
legacyTopic := types.Uid(2).P2PName(types.Uid(3))
|
|
store.Relations = &testRelationsStore{friends: []types.FriendListItem{{
|
|
Friendship: types.Friendship{
|
|
TenantID: 7,
|
|
UserID: types.Uid(1),
|
|
PeerID: peer,
|
|
Status: types.RelationshipFriend,
|
|
Topic: legacyTopic,
|
|
},
|
|
}}}
|
|
defer func() { store.Relations = oldRelations }()
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/v1/im/conversations/"+legacyTopic+"/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["topic"] != legacyTopic || data["peer_user_id"] != peer.UserId() {
|
|
t.Fatalf("unexpected context response: %#v", data)
|
|
}
|
|
}
|
|
|
|
func TestNormalizeP2PConversationTopicParsesTinodeTopic(t *testing.T) {
|
|
user := types.Uid(1)
|
|
peer := types.Uid(2)
|
|
topic := user.P2PName(peer)
|
|
|
|
normalized, gotPeer := normalizeP2PConversationTopic(topic, user)
|
|
|
|
if normalized != topic || gotPeer != peer {
|
|
t.Fatalf("normalize p2p: got topic=%q peer=%s, want topic=%q peer=%s", normalized, gotPeer.UserId(), topic, peer.UserId())
|
|
}
|
|
}
|
|
|
|
func TestServeIMP2PConversation(t *testing.T) {
|
|
ctrl := gomock.NewController(t)
|
|
defer ctrl.Finish()
|
|
expectIMBearer(t, ctrl, "token-1", 7, 1)
|
|
oldConversations := store.Conversations
|
|
oldRelations := store.Relations
|
|
peer := types.Uid(2)
|
|
conversations := &testConversationsStore{}
|
|
store.Conversations = conversations
|
|
store.Relations = &testRelationsStore{}
|
|
defer func() {
|
|
store.Conversations = oldConversations
|
|
store.Relations = oldRelations
|
|
}()
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/im/conversations/p2p", strings.NewReader(`{"peer_user_id":"`+peer.UserId()+`"}`))
|
|
req.Header.Set("Authorization", "Bearer token-1")
|
|
rec := httptest.NewRecorder()
|
|
serveIMP2PConversation(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"] != types.Uid(1).P2PName(peer) || data["can_send"] != true {
|
|
t.Fatalf("unexpected p2p response: %#v topic=%q", data, conversations.topic)
|
|
}
|
|
}
|
|
|
|
func TestServeIMConversationSettings(t *testing.T) {
|
|
ctrl := gomock.NewController(t)
|
|
defer ctrl.Finish()
|
|
expectIMBearer(t, ctrl, "token-1", 7, 1)
|
|
oldConversations := store.Conversations
|
|
topic := types.Uid(1).P2PName(types.Uid(2))
|
|
conversations := &testConversationsStore{}
|
|
store.Conversations = conversations
|
|
defer func() { store.Conversations = oldConversations }()
|
|
|
|
req := httptest.NewRequest(http.MethodPatch, "/v1/im/conversations/"+topic+"/settings", strings.NewReader(`{"pinned":true,"mute_notifications":true,"remark":"同事","version":1}`))
|
|
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["pinned"] != true || data["mute_notifications"] != true || data["remark"] != "同事" || data["version"].(float64) != 2 {
|
|
t.Fatalf("unexpected settings response: %#v", data)
|
|
}
|
|
if conversations.settingsPatch.Muted == nil || *conversations.settingsPatch.Muted != true {
|
|
t.Fatalf("mute_notifications was not mapped to Muted patch: %#v", conversations.settingsPatch)
|
|
}
|
|
}
|
|
|
|
func TestServeIMConversationSettingsNormalizesUserTopic(t *testing.T) {
|
|
ctrl := gomock.NewController(t)
|
|
defer ctrl.Finish()
|
|
expectIMBearer(t, ctrl, "token-1", 7, 1)
|
|
oldConversations := store.Conversations
|
|
peer := types.Uid(2)
|
|
topic := types.Uid(1).P2PName(peer)
|
|
conversations := &testConversationsStore{}
|
|
store.Conversations = conversations
|
|
defer func() { store.Conversations = oldConversations }()
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/v1/im/conversations/"+peer.UserId()+"/settings", 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())
|
|
}
|
|
if conversations.topic != topic {
|
|
t.Fatalf("settings topic: got %q, want %q", conversations.topic, topic)
|
|
}
|
|
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
|
|
if data["topic"] != topic {
|
|
t.Fatalf("unexpected settings response: %#v", data)
|
|
}
|
|
}
|
|
|
|
func TestServeIMTopicDraftPutAndGet(t *testing.T) {
|
|
ctrl := gomock.NewController(t)
|
|
defer ctrl.Finish()
|
|
expectIMBearer(t, ctrl, "token-1", 7, 1)
|
|
oldMaxMessageSize := globals.maxMessageSize
|
|
globals.maxMessageSize = 1024
|
|
defer func() { globals.maxMessageSize = oldMaxMessageSize }()
|
|
oldConversations := store.Conversations
|
|
topic := "grpDraft"
|
|
conversations := &testConversationsStore{}
|
|
store.Conversations = conversations
|
|
defer func() { store.Conversations = oldConversations }()
|
|
|
|
req := httptest.NewRequest(http.MethodPut, "/v1/im/topics/"+topic+"/draft", strings.NewReader(`{"content":"未发送草稿","entities":[{"type":"mention"}],"version":0}`))
|
|
req.Header.Set("Authorization", "Bearer token-1")
|
|
rec := httptest.NewRecorder()
|
|
serveIMTopicMessageAction(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("put status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
|
}
|
|
if !conversations.putDraftCalled {
|
|
t.Fatal("expected draft store to be called")
|
|
}
|
|
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
|
|
if data["content"] != "未发送草稿" || data["version"].(float64) != 1 {
|
|
t.Fatalf("unexpected draft put response: %#v", data)
|
|
}
|
|
|
|
req = httptest.NewRequest(http.MethodGet, "/v1/im/topics/"+topic+"/draft", nil)
|
|
req.Header.Set("Authorization", "Bearer token-1")
|
|
rec = httptest.NewRecorder()
|
|
serveIMTopicMessageAction(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("get status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
|
|
}
|
|
data = decodeIMHTTPResponse(t, rec).Data.(map[string]any)
|
|
if data["content"] != "未发送草稿" {
|
|
t.Fatalf("unexpected draft get 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
|
|
oldProfiles := store.Profiles
|
|
relations := &testRelationsStore{context: &types.RelationshipContext{Relationship: types.RelationshipNone}}
|
|
store.Relations = relations
|
|
store.Profiles = &testProfilesStore{}
|
|
defer func() {
|
|
store.Relations = oldRelations
|
|
store.Profiles = oldProfiles
|
|
}()
|
|
|
|
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 TestServeIMFriendRequestCreateReturnsNotFoundForMissingTarget(t *testing.T) {
|
|
ctrl := gomock.NewController(t)
|
|
defer ctrl.Finish()
|
|
_ = expectIMBearer(t, ctrl, "token-1", 7, 1)
|
|
oldRelations := store.Relations
|
|
oldProfiles := store.Profiles
|
|
relations := &testRelationsStore{err: types.ErrNotFound}
|
|
store.Relations = relations
|
|
store.Profiles = &testProfilesStore{}
|
|
defer func() {
|
|
store.Relations = oldRelations
|
|
store.Profiles = oldProfiles
|
|
}()
|
|
|
|
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-missing")
|
|
rec := httptest.NewRecorder()
|
|
|
|
serveIMFriendRequests(rec, req)
|
|
|
|
if rec.Code != http.StatusNotFound {
|
|
t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusNotFound, rec.Body.String())
|
|
}
|
|
if relations.created != nil {
|
|
t.Fatalf("friend request should not be created: %#v", relations.created)
|
|
}
|
|
}
|
|
|
|
func TestServeIMFriendRequestCreateIgnoresRelationEventFailure(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
|
|
oldProfiles := store.Profiles
|
|
oldSecurity := store.Security
|
|
relations := &testRelationsStore{context: &types.RelationshipContext{Relationship: types.RelationshipNone}}
|
|
store.Relations = relations
|
|
store.Profiles = &testProfilesStore{}
|
|
store.Security = &testUserSecurityStore{err: errors.New("notification unavailable")}
|
|
defer func() {
|
|
store.Relations = oldRelations
|
|
store.Profiles = oldProfiles
|
|
store.Security = oldSecurity
|
|
}()
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/im/friend-requests",
|
|
bytes.NewBufferString(`{"target_user_id":"`+types.Uid(2).UserId()+`","message":"你好","source":"search"}`))
|
|
req = req.WithContext(context.WithValue(req.Context(), imHTTPProtocolValidated, true))
|
|
req.Header.Set("Authorization", "Bearer token-1")
|
|
req.Header.Set("Idempotency-Key", "fr-1")
|
|
req.Header.Set("X-Request-ID", "req-event-fail")
|
|
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())
|
|
}
|
|
if relations.created == nil || relations.created.RequestID == "" {
|
|
t.Fatalf("friend request was not created: %#v", relations.created)
|
|
}
|
|
}
|
|
|
|
func TestPublishIMConversationSystemNoticeDeliversToOnlineP2PSessions(t *testing.T) {
|
|
ctrl := gomock.NewController(t)
|
|
defer ctrl.Finish()
|
|
mockStore := mock_store.NewMockPersistentStorageInterface(ctrl)
|
|
mockStore.EXPECT().IsOpen().Return(true).AnyTimes()
|
|
oldStore := store.Store
|
|
oldTopics := store.Topics
|
|
oldMessages := store.Messages
|
|
oldHub := globals.hub
|
|
oldSessions := globals.sessionStore
|
|
store.Store = mockStore
|
|
topics := mock_store.NewMockTopicsPersistenceInterface(ctrl)
|
|
messages := mock_store.NewMockMessagesPersistenceInterface(ctrl)
|
|
store.Topics = topics
|
|
store.Messages = messages
|
|
globals.hub = &Hub{routeSrv: make(chan *ServerComMessage, 10)}
|
|
globals.sessionStore = NewSessionStore(time.Minute)
|
|
t.Cleanup(func() {
|
|
store.Store = oldStore
|
|
store.Topics = oldTopics
|
|
store.Messages = oldMessages
|
|
globals.hub = oldHub
|
|
globals.sessionStore = oldSessions
|
|
})
|
|
|
|
applicant := types.Uid(1)
|
|
target := types.Uid(2)
|
|
topic := applicant.P2PName(target)
|
|
topics.EXPECT().Get(types.TenantID(7), topic).Return(&types.Topic{TenantID: 7, SeqId: 0}, nil)
|
|
messages.EXPECT().Save(types.TenantID(7), gomock.Any(), nil, false).DoAndReturn(
|
|
func(_ types.TenantID, msg *types.Message, _ []string, _ bool) (*types.MessageSaveResult, error) {
|
|
if msg.Topic != topic || msg.SeqId != 1 {
|
|
t.Fatalf("unexpected saved message: %#v", msg)
|
|
}
|
|
return &types.MessageSaveResult{SeqId: msg.SeqId}, nil
|
|
})
|
|
|
|
for _, uid := range []types.Uid{applicant, target} {
|
|
session := &Session{
|
|
sid: uid.UserId() + "-session",
|
|
proto: WEBSOCK,
|
|
uid: uid,
|
|
tenantID: types.TenantID(7),
|
|
send: make(chan any, 2),
|
|
subs: make(map[string]*Subscription),
|
|
}
|
|
globals.sessionStore.sessCache[session.sid] = session
|
|
}
|
|
|
|
publishIMConversationSystemNotice(types.TenantID(7), topic,
|
|
"system_notice", "friend_added", "你们已成为好友,现在可以开始聊天了。",
|
|
[]imSystemMessageActor{
|
|
{UserID: applicant.UserId(), Click: map[string]any{"type": "user_profile", "target_id": applicant.UserId()}},
|
|
{UserID: target.UserId(), Click: map[string]any{"type": "user_profile", "target_id": target.UserId()}},
|
|
}, nil, map[string]any{"request_id": "frq_1"})
|
|
|
|
if len(globals.hub.routeSrv) != 3 {
|
|
t.Fatalf("expected routed topic data and two presence updates, got %d", len(globals.hub.routeSrv))
|
|
}
|
|
for _, uid := range []types.Uid{applicant, target} {
|
|
session := globals.sessionStore.Get(uid.UserId() + "-session")
|
|
select {
|
|
case raw := <-session.send:
|
|
msg, ok := raw.(*ServerComMessage)
|
|
if !ok || msg.Data == nil {
|
|
t.Fatalf("unexpected outbound for %s: %#v", uid.UserId(), raw)
|
|
}
|
|
wantTopic, err := types.P2PNameForUser(uid, topic)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if msg.Data.Topic != wantTopic || msg.Data.SeqId != 1 {
|
|
t.Fatalf("unexpected p2p data for %s: %#v", uid.UserId(), msg.Data)
|
|
}
|
|
if msg.Data.Head["x-im-template"] != "friend_added" || msg.Data.Head["x-im-type"] != "system_notice" {
|
|
t.Fatalf("unexpected system notice head: %#v", msg.Data.Head)
|
|
}
|
|
default:
|
|
t.Fatalf("online user %s did not receive system notice", uid.UserId())
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPublishIMConversationSystemNoticePresenceUsesP2PTopic(t *testing.T) {
|
|
ctrl := gomock.NewController(t)
|
|
defer ctrl.Finish()
|
|
oldStore := store.Store
|
|
oldTopics := store.Topics
|
|
oldMessages := store.Messages
|
|
oldHub := globals.hub
|
|
mockStore := mock_store.NewMockPersistentStorageInterface(ctrl)
|
|
topics := mock_store.NewMockTopicsPersistenceInterface(ctrl)
|
|
messages := mock_store.NewMockMessagesPersistenceInterface(ctrl)
|
|
store.Store = mockStore
|
|
store.Topics = topics
|
|
store.Messages = messages
|
|
globals.hub = &Hub{routeSrv: make(chan *ServerComMessage, 4)}
|
|
defer func() {
|
|
store.Store = oldStore
|
|
store.Topics = oldTopics
|
|
store.Messages = oldMessages
|
|
globals.hub = oldHub
|
|
}()
|
|
mockStore.EXPECT().IsOpen().Return(true)
|
|
|
|
applicant := types.Uid(1)
|
|
target := types.Uid(2)
|
|
topic := applicant.P2PName(target)
|
|
topics.EXPECT().Get(types.TenantID(7), topic).Return(&types.Topic{TenantID: 7, SeqId: 16}, nil)
|
|
messages.EXPECT().Save(types.TenantID(7), gomock.Any(), nil, false).DoAndReturn(
|
|
func(_ types.TenantID, msg *types.Message, _ []string, _ bool) (*types.MessageSaveResult, error) {
|
|
if msg.Topic != topic || msg.SeqId != 17 {
|
|
t.Fatalf("unexpected saved message: %#v", msg)
|
|
}
|
|
return &types.MessageSaveResult{SeqId: msg.SeqId}, nil
|
|
})
|
|
|
|
publishIMConversationSystemNotice(types.TenantID(7), topic,
|
|
"system_notice", "friend_added", "你们已成为好友,现在可以开始聊天了。",
|
|
[]imSystemMessageActor{
|
|
{UserID: applicant.UserId(), Click: map[string]any{"type": "user_profile", "target_id": applicant.UserId()}},
|
|
{UserID: target.UserId(), Click: map[string]any{"type": "user_profile", "target_id": target.UserId()}},
|
|
}, nil, map[string]any{"request_id": "frq_1"})
|
|
|
|
select {
|
|
case routed := <-globals.hub.routeSrv:
|
|
if routed.Data == nil || routed.Data.Topic != topic || !routed.PreserveTopicName {
|
|
t.Fatalf("unexpected system data broadcast: %#v", routed)
|
|
}
|
|
default:
|
|
t.Fatal("expected system data broadcast")
|
|
}
|
|
for _, uid := range []types.Uid{applicant, target} {
|
|
select {
|
|
case routed := <-globals.hub.routeSrv:
|
|
if routed.RcptTo != uid.UserId() || routed.Pres == nil || routed.Pres.Topic != "me" ||
|
|
routed.Pres.Src != topic || routed.Pres.SeqId != 17 || routed.Pres.SkipTopic != topic ||
|
|
!routed.PreserveTopicName {
|
|
t.Fatalf("unexpected system presence for %s: %#v", uid.UserId(), routed)
|
|
}
|
|
default:
|
|
t.Fatalf("expected system presence for %s", uid.UserId())
|
|
}
|
|
}
|
|
}
|
|
|
|
func mustP2PNameForUser(t *testing.T, uid types.Uid, topic string) string {
|
|
t.Helper()
|
|
name, err := types.P2PNameForUser(uid, topic)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return name
|
|
}
|
|
|
|
func TestServeIMFriendRequestCreateHonorsGroupPrivateChatSetting(t *testing.T) {
|
|
ctrl := gomock.NewController(t)
|
|
defer ctrl.Finish()
|
|
expectIMBearer(t, ctrl, "token-1", 7, 1)
|
|
oldRelations := store.Relations
|
|
oldProfiles := store.Profiles
|
|
oldSubs := store.Subs
|
|
oldGroups := store.GroupBusiness
|
|
store.Relations = &testRelationsStore{context: &types.RelationshipContext{Relationship: types.RelationshipNone}}
|
|
store.Profiles = &testProfilesStore{}
|
|
subs := mock_store.NewMockSubsPersistenceInterface(ctrl)
|
|
store.Subs = subs
|
|
store.GroupBusiness = &testGroupBusinessStore{settings: &types.GroupSettings{
|
|
Topic: "grpAlpha", MemberPrivateChat: false, GroupStatus: types.GroupStatusActive,
|
|
}}
|
|
defer func() {
|
|
store.Relations = oldRelations
|
|
store.Profiles = oldProfiles
|
|
store.Subs = oldSubs
|
|
store.GroupBusiness = oldGroups
|
|
}()
|
|
memberMode := types.ModeCPublic
|
|
for _, uid := range []types.Uid{1, 2} {
|
|
subs.EXPECT().Get(types.TenantID(7), "grpAlpha", uid, false).Return(&types.Subscription{
|
|
TenantID: 7, Topic: "grpAlpha", User: uid.String(), ModeWant: memberMode, ModeGiven: memberMode,
|
|
}, nil)
|
|
}
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/im/friend-requests",
|
|
bytes.NewBufferString(`{"target_user_id":"`+types.Uid(2).UserId()+`","source":"group","source_topic":"grpAlpha"}`))
|
|
req.Header.Set("Authorization", "Bearer token-1")
|
|
req.Header.Set("Idempotency-Key", "fr-group-1")
|
|
rec := httptest.NewRecorder()
|
|
serveIMFriendRequests(rec, req)
|
|
if rec.Code != http.StatusForbidden {
|
|
t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusForbidden, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
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: 1,
|
|
Status: types.RelationshipFriend,
|
|
Topic: types.Uid(1).P2PName(types.Uid(1)),
|
|
},
|
|
Peer: &types.User{ObjHeader: types.ObjHeader{Id: types.Uid(1).String()}, TenantID: 7, Public: map[string]any{"display_name": "Self"}},
|
|
}, {
|
|
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"] != "小明" || items[0].(map[string]any)["display_name_source"] != "remark" {
|
|
t.Fatalf("unexpected friends list: %#v", data)
|
|
}
|
|
}
|
|
|
|
func TestServeIMFriendsListUsesUserIDSourceOnlyForFallback(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)
|
|
namedPeer := types.Uid(2)
|
|
unnamedPeer := types.Uid(3)
|
|
store.Relations = &testRelationsStore{friends: []types.FriendListItem{{
|
|
Friendship: types.Friendship{
|
|
TenantID: 7,
|
|
UserID: 1,
|
|
PeerID: namedPeer,
|
|
Status: types.RelationshipFriend,
|
|
Topic: types.Uid(1).P2PName(namedPeer),
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
},
|
|
Peer: &types.User{ObjHeader: types.ObjHeader{Id: namedPeer.String()}, TenantID: 7, Public: map[string]any{"fn": "yang"}},
|
|
}, {
|
|
Friendship: types.Friendship{
|
|
TenantID: 7,
|
|
UserID: 1,
|
|
PeerID: unnamedPeer,
|
|
Status: types.RelationshipFriend,
|
|
Topic: types.Uid(1).P2PName(unnamedPeer),
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
},
|
|
Peer: &types.User{ObjHeader: types.ObjHeader{Id: unnamedPeer.String()}, TenantID: 7, Public: map[string]any{}},
|
|
}}}
|
|
defer func() { store.Relations = oldRelations }()
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/v1/im/friends", 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) != 2 {
|
|
t.Fatalf("unexpected friends list length: %#v", data)
|
|
}
|
|
named := items[0].(map[string]any)
|
|
if named["display_name"] != "yang" || named["display_name_source"] != "profile" {
|
|
t.Fatalf("named friend should use profile display: %#v", named)
|
|
}
|
|
unnamed := items[1].(map[string]any)
|
|
if unnamed["display_name"] != unnamed["user_id"] || unnamed["display_name_source"] != "user_id" {
|
|
t.Fatalf("unnamed friend should use user_id fallback source: %#v", unnamed)
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
if data["display_name"] != "小明" || data["remark"] != "小明" {
|
|
t.Fatalf("friend remark should drive display name: %#v", data)
|
|
}
|
|
peerData := data["peer"].(map[string]any)
|
|
if peerData["display_name"] != "小明" {
|
|
t.Fatalf("peer display name should use remark: %#v", data)
|
|
}
|
|
}
|
|
|
|
func TestServeIMFriendDeleteReturnsCannotSendContext(t *testing.T) {
|
|
ctrl := gomock.NewController(t)
|
|
defer ctrl.Finish()
|
|
expectIMBearer(t, ctrl, "token-1", 7, 1)
|
|
oldRelations := store.Relations
|
|
peer := types.Uid(2)
|
|
relations := &testRelationsStore{context: &types.RelationshipContext{
|
|
TenantID: 7,
|
|
UserID: 1,
|
|
PeerID: peer,
|
|
Topic: types.Uid(1).P2PName(peer),
|
|
Relationship: types.RelationshipNone,
|
|
CanSend: false,
|
|
}}
|
|
store.Relations = relations
|
|
defer func() { store.Relations = oldRelations }()
|
|
|
|
req := httptest.NewRequest(http.MethodDelete, "/v1/im/friends/"+peer.UserId(), nil)
|
|
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())
|
|
}
|
|
if !relations.deleted || relations.deleteTenant != 7 || relations.deleteUser != 1 || relations.deletePeer != peer {
|
|
t.Fatalf("friend delete scope mismatch: %#v", relations)
|
|
}
|
|
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
|
|
if data["relationship"] != types.RelationshipNone || data["can_send"] != false {
|
|
t.Fatalf("friend delete did not return blocked send context: %#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", "public_account": "alice001"}},
|
|
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 ||
|
|
items[0].(map[string]any)["public_account"] != "alice001" {
|
|
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
|
|
oldProfiles := store.Profiles
|
|
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,
|
|
Username: "alice7",
|
|
Public: map[string]any{"display_name": "Alice", "nickname": "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,
|
|
Remark: "老同事",
|
|
}}
|
|
store.Profiles = &testProfilesStore{}
|
|
defer func() {
|
|
store.Users = oldUsers
|
|
store.Relations = oldRelations
|
|
store.Profiles = oldProfiles
|
|
}()
|
|
|
|
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"] == "" || data["username"] != "alice7" {
|
|
t.Fatalf("unexpected profile response: %#v", data)
|
|
}
|
|
if data["display_name"] != "老同事" || data["remark"] != "老同事" {
|
|
t.Fatalf("profile display name should use friend remark: %#v", data)
|
|
}
|
|
if data["nickname"] != "Alice" {
|
|
t.Fatalf("profile should include original nickname: %#v", data)
|
|
}
|
|
}
|
|
|
|
func TestServeIMUserProfileBlockedByMeReturnsMinimalProfile(t *testing.T) {
|
|
ctrl := gomock.NewController(t)
|
|
defer ctrl.Finish()
|
|
expectIMBearer(t, ctrl, "token-1", 7, 1)
|
|
oldUsers := store.Users
|
|
oldRelations := store.Relations
|
|
oldProfiles := store.Profiles
|
|
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,
|
|
Username: "alice7",
|
|
Public: map[string]any{
|
|
"display_name": "Alice",
|
|
"nickname": "Alice",
|
|
"avatar_file_id": "file-1",
|
|
"signature": "hello",
|
|
"gender": "female",
|
|
"region": "Shanghai",
|
|
"public_account": "alice001",
|
|
},
|
|
}, nil)
|
|
store.Users = users
|
|
store.Relations = &testRelationsStore{context: &types.RelationshipContext{
|
|
TenantID: 7,
|
|
UserID: 1,
|
|
PeerID: peer,
|
|
Topic: types.Uid(1).P2PName(peer),
|
|
Relationship: types.RelationshipBlockedByMe,
|
|
Remark: "老同事",
|
|
}}
|
|
store.Profiles = &testProfilesStore{}
|
|
defer func() {
|
|
store.Users = oldUsers
|
|
store.Relations = oldRelations
|
|
store.Profiles = oldProfiles
|
|
}()
|
|
|
|
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.RelationshipBlockedByMe || data["display_name"] != "老同事" ||
|
|
data["avatar_file_id"] != "file-1" || data["topic"] == "" || data["username"] != "alice7" {
|
|
t.Fatalf("unexpected blocked profile response: %#v", data)
|
|
}
|
|
capabilities := data["capabilities"].(map[string]any)
|
|
if capabilities["can_send"] != false || capabilities["can_call"] != false || capabilities["can_add_friend"] != false {
|
|
t.Fatalf("blocked profile should disable actions: %#v", data)
|
|
}
|
|
for _, field := range []string{"signature", "gender", "region", "public_account"} {
|
|
if _, ok := data[field]; ok {
|
|
t.Fatalf("blocked profile should omit private field %q: %#v", field, data)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestServeIMUserQRCodes(t *testing.T) {
|
|
ctrl := gomock.NewController(t)
|
|
defer ctrl.Finish()
|
|
expectIMBearer(t, ctrl, "token-1", 7, 1)
|
|
oldUsers := store.Users
|
|
oldRelations := store.Relations
|
|
oldProfiles := store.Profiles
|
|
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"},
|
|
}, 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,
|
|
}}
|
|
store.Profiles = &testProfilesStore{}
|
|
defer func() {
|
|
store.Users = oldUsers
|
|
store.Relations = oldRelations
|
|
store.Profiles = oldProfiles
|
|
}()
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/im/users/"+peer.UserId()+"/qrcodes", bytes.NewBufferString(`{"expires_in_seconds":60}`))
|
|
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["qr_token"] == "" || !strings.Contains(data["qr_payload"].(string), "purpose=add_friend") {
|
|
t.Fatalf("unexpected qr response: %#v", data)
|
|
}
|
|
}
|
|
|
|
func TestServeIMUserQRCodesByUsername(t *testing.T) {
|
|
ctrl := gomock.NewController(t)
|
|
defer ctrl.Finish()
|
|
expectIMBearer(t, ctrl, "token-1", 7, 1)
|
|
oldUsers := store.Users
|
|
oldRelations := store.Relations
|
|
oldProfiles := store.Profiles
|
|
users := mock_store.NewMockUsersPersistenceInterface(ctrl)
|
|
peer := types.Uid(2)
|
|
peerUser := &types.User{
|
|
ObjHeader: types.ObjHeader{Id: peer.String()},
|
|
TenantID: 7,
|
|
Public: map[string]any{"display_name": "Alice", "public_account": "alice001"},
|
|
}
|
|
users.EXPECT().Get(types.TenantID(7), peer).Return(peerUser, 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,
|
|
},
|
|
searchResults: []types.UserSearchItem{{User: peerUser, Relationship: types.RelationshipFriend}},
|
|
}
|
|
store.Profiles = &testProfilesStore{}
|
|
defer func() {
|
|
store.Users = oldUsers
|
|
store.Relations = oldRelations
|
|
store.Profiles = oldProfiles
|
|
}()
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/im/users/alice001/qrcodes", bytes.NewBufferString(`{"expires_in_seconds":60}`))
|
|
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)
|
|
token := data["qr_token"].(string)
|
|
resolved, err := parseIMQRCodeToken(token, "add_friend", time.Now().UTC())
|
|
if err != nil {
|
|
t.Fatalf("parse token: %v", err)
|
|
}
|
|
if resolved.UserID != peer.UserId() {
|
|
t.Fatalf("token user id: got %s, want %s", resolved.UserID, peer.UserId())
|
|
}
|
|
}
|
|
|
|
func TestServeIMUserQRCodesRespectsPrivacy(t *testing.T) {
|
|
ctrl := gomock.NewController(t)
|
|
defer ctrl.Finish()
|
|
expectIMBearer(t, ctrl, "token-1", 7, 1)
|
|
oldUsers := store.Users
|
|
oldRelations := store.Relations
|
|
oldProfiles := store.Profiles
|
|
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,
|
|
}, 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,
|
|
}}
|
|
store.Profiles = &testProfilesStore{privacy: &types.UserPrivacySettings{
|
|
UserID: peer, AddMeMethods: map[string]bool{"account": true, "phone": true, "qr": false, "group": true},
|
|
ProfileVisibility: "friends", LastSeenVisibility: "friends", ReadReceipts: true,
|
|
}}
|
|
defer func() {
|
|
store.Users = oldUsers
|
|
store.Relations = oldRelations
|
|
store.Profiles = oldProfiles
|
|
}()
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/im/users/"+peer.UserId()+"/qrcodes", bytes.NewBufferString(`{}`))
|
|
req.Header.Set("Authorization", "Bearer token-1")
|
|
rec := httptest.NewRecorder()
|
|
|
|
serveIMUserResource(rec, req)
|
|
|
|
if rec.Code != http.StatusForbidden || decodeIMHTTPResponse(t, rec).Reason != imReasonPermissionDenied {
|
|
t.Fatalf("privacy status: got %d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
func TestIMMessageTypeDraftyEntities(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
msg *types.Message
|
|
want string
|
|
}{
|
|
{
|
|
name: "nil message",
|
|
msg: nil,
|
|
want: "text",
|
|
},
|
|
{
|
|
name: "drafty link entity",
|
|
msg: &types.Message{Content: map[string]any{
|
|
"txt": "check this https://example.com",
|
|
"fmt": []any{map[string]any{"at": 10, "len": 19, "key": 0}},
|
|
"ent": []any{map[string]any{"tp": "LN", "data": map[string]any{"url": "https://example.com"}}},
|
|
}},
|
|
want: "link",
|
|
},
|
|
{
|
|
name: "drafty image entity",
|
|
msg: &types.Message{Content: map[string]any{
|
|
"txt": "a photo",
|
|
"fmt": []any{map[string]any{"at": 7, "len": 1, "key": 0}},
|
|
"ent": []any{map[string]any{"tp": "IM", "data": map[string]any{"mime": "image/png", "name": "pic.png"}}},
|
|
}},
|
|
want: "image",
|
|
},
|
|
{
|
|
name: "drafty video entity",
|
|
msg: &types.Message{Content: map[string]any{
|
|
"txt": "clip",
|
|
"ent": []any{map[string]any{"tp": "VD", "data": map[string]any{"mime": "video/mp4"}}},
|
|
}},
|
|
want: "video",
|
|
},
|
|
{
|
|
name: "drafty audio entity",
|
|
msg: &types.Message{Content: map[string]any{
|
|
"txt": "",
|
|
"ent": []any{map[string]any{"tp": "AU", "data": map[string]any{"mime": "audio/mp4"}}},
|
|
}},
|
|
want: "voice",
|
|
},
|
|
{
|
|
name: "drafty generic file entity",
|
|
msg: &types.Message{Content: map[string]any{
|
|
"txt": "doc",
|
|
"ent": []any{map[string]any{"tp": "EX", "data": map[string]any{"mime": "application/pdf", "name": "a.pdf"}}},
|
|
}},
|
|
want: "file",
|
|
},
|
|
{
|
|
name: "drafty image file entity refines by mime",
|
|
msg: &types.Message{Content: map[string]any{
|
|
"ent": []any{map[string]any{"tp": "EX", "data": map[string]any{"mime": "image/jpeg"}}},
|
|
}},
|
|
want: "image",
|
|
},
|
|
{
|
|
name: "drafty attachment wins over inline link",
|
|
msg: &types.Message{Content: map[string]any{
|
|
"txt": "see https://example.com and",
|
|
"fmt": []any{map[string]any{"at": 4, "len": 18, "key": 1}, map[string]any{"at": 26, "len": 1, "key": 0}},
|
|
"ent": []any{map[string]any{"tp": "EX", "data": map[string]any{"mime": "application/zip"}}, map[string]any{"tp": "LN", "data": map[string]any{}}},
|
|
}},
|
|
want: "file",
|
|
},
|
|
{
|
|
name: "drafty plain text",
|
|
msg: &types.Message{Content: map[string]any{
|
|
"txt": "hello",
|
|
}},
|
|
want: "text",
|
|
},
|
|
{
|
|
name: "legacy flat file_id content still works",
|
|
msg: &types.Message{Content: map[string]any{"file_id": "file-1", "mime": "image/png"}},
|
|
want: "image",
|
|
},
|
|
{
|
|
name: "legacy flat url content still works",
|
|
msg: &types.Message{Content: map[string]any{"url": "https://example.com"}},
|
|
want: "link",
|
|
},
|
|
{
|
|
name: "head type takes priority",
|
|
msg: &types.Message{
|
|
Head: types.KVMap{"type": "system_notice"},
|
|
Content: map[string]any{"ent": []any{map[string]any{"tp": "IM"}}},
|
|
},
|
|
want: "system_notice",
|
|
},
|
|
{
|
|
name: "string content",
|
|
msg: &types.Message{Content: "plain"},
|
|
want: "text",
|
|
},
|
|
}
|
|
for _, tc := range cases {
|
|
if got := imMessageType(tc.msg); got != tc.want {
|
|
t.Errorf("%s: imMessageType = %q, want %q", tc.name, got, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
type testDictionaryStore struct {
|
|
items []types.DictionaryItem
|
|
err error
|
|
}
|
|
|
|
func (s *testDictionaryStore) Get(_ string) ([]types.DictionaryItem, error) {
|
|
if s.err != nil {
|
|
return nil, s.err
|
|
}
|
|
return s.items, nil
|
|
}
|
|
|
|
func TestServeIMContactUs(t *testing.T) {
|
|
ctrl := gomock.NewController(t)
|
|
defer ctrl.Finish()
|
|
expectIMBearer(t, ctrl, "token-1", 7, 42)
|
|
oldDict := store.DictionaryConfig
|
|
defer func() { store.DictionaryConfig = oldDict }()
|
|
|
|
store.DictionaryConfig = &testDictionaryStore{items: []types.DictionaryItem{
|
|
{Label: "客服邮箱", Value: "support@example.com", Sort: 10},
|
|
{Label: "客服电话", Value: "+86 400-000-0000", Sort: 20},
|
|
}}
|
|
req := httptest.NewRequest(http.MethodGet, "/v1/im/contact-us", nil)
|
|
req.Header.Set("Authorization", "Bearer token-1")
|
|
rec := httptest.NewRecorder()
|
|
serveIMContactUs(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status: got %d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
resp := decodeIMHTTPResponse(t, rec)
|
|
items, ok := resp.Data.(map[string]any)["items"].([]any)
|
|
if !ok || len(items) != 2 {
|
|
t.Fatalf("unexpected items: %#v", resp.Data)
|
|
}
|
|
if items[0].(map[string]any)["label"] != "客服邮箱" || items[1].(map[string]any)["value"] != "+86 400-000-0000" {
|
|
t.Fatalf("item mismatch: %#v", items)
|
|
}
|
|
|
|
store.DictionaryConfig = &testDictionaryStore{err: types.ErrUnsupported}
|
|
rec = httptest.NewRecorder()
|
|
serveIMContactUs(rec, req)
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("unsupported status: got %d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
resp = decodeIMHTTPResponse(t, rec)
|
|
if listed, ok := resp.Data.(map[string]any)["items"].([]any); !ok || len(listed) != 0 {
|
|
t.Fatalf("expected empty items, got %#v", resp.Data)
|
|
}
|
|
|
|
store.DictionaryConfig = &testDictionaryStore{err: types.ErrInternal}
|
|
rec = httptest.NewRecorder()
|
|
serveIMContactUs(rec, req)
|
|
if rec.Code != http.StatusInternalServerError {
|
|
t.Fatalf("error status: got %d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
|
|
req = httptest.NewRequest(http.MethodPost, "/v1/im/contact-us", nil)
|
|
req.Header.Set("Authorization", "Bearer token-1")
|
|
rec = httptest.NewRecorder()
|
|
serveIMContactUs(rec, req)
|
|
if rec.Code != http.StatusMethodNotAllowed {
|
|
t.Fatalf("method status: got %d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
}
|