Files
im_server/server/im_http_test.go
T

4408 lines
163 KiB
Go

package main
import (
"bytes"
"crypto/hmac"
"crypto/md5"
"encoding/base64"
"encoding/binary"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/golang/mock/gomock"
"github.com/tinode/chat/server/auth"
"github.com/tinode/chat/server/auth/mock_auth"
"github.com/tinode/chat/server/store"
"github.com/tinode/chat/server/store/mock_store"
"github.com/tinode/chat/server/store/types"
"golang.org/x/crypto/bcrypt"
)
type testVerificationStore struct {
active *types.VerificationCode
get *types.VerificationCode
err error
created *types.VerificationCode
failed bool
verified bool
consumed bool
tokenHash string
}
type testHTTPInfrastructureStore struct {
records map[string]*types.HTTPIdempotencyRecord
events []types.BusinessEvent
}
func (s *testHTTPInfrastructureStore) recordKey(record *types.HTTPIdempotencyRecord) string {
return record.UserID.String() + "|" + record.Method + "|" + record.Route + "|" + record.Key
}
func (s *testHTTPInfrastructureStore) BeginIdempotency(_ types.TenantID, record *types.HTTPIdempotencyRecord) (*types.HTTPIdempotencyRecord, bool, error) {
if s.records == nil {
s.records = make(map[string]*types.HTTPIdempotencyRecord)
}
key := s.recordKey(record)
if existing := s.records[key]; existing != nil {
copy := *existing
copy.ResponseBody = append([]byte(nil), existing.ResponseBody...)
return &copy, false, nil
}
copy := *record
copy.State = types.HTTPIdempotencyProcessing
s.records[key] = &copy
return &copy, true, nil
}
func (s *testHTTPInfrastructureStore) CompleteIdempotency(_ types.TenantID, record *types.HTTPIdempotencyRecord) error {
copy := *record
copy.State = types.HTTPIdempotencyCompleted
copy.ResponseBody = append([]byte(nil), record.ResponseBody...)
s.records[s.recordKey(record)] = &copy
return nil
}
func (s *testHTTPInfrastructureStore) AbortIdempotency(_ types.TenantID, record *types.HTTPIdempotencyRecord) error {
delete(s.records, s.recordKey(record))
return nil
}
func (s *testHTTPInfrastructureStore) CreateEvent(_ types.TenantID, event *types.BusinessEvent) error {
s.events = append(s.events, *event)
return nil
}
func (s *testHTTPInfrastructureStore) ListPendingEvents(_ types.TenantID, _ int, _ time.Time) ([]types.BusinessEvent, error) {
return append([]types.BusinessEvent(nil), s.events...), nil
}
func (s *testHTTPInfrastructureStore) MarkEventDelivered(_ types.TenantID, _ string, _ time.Time) error {
return nil
}
type testRelationsStore struct {
context *types.RelationshipContext
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
}
type testWorkbenchStore struct {
modules []types.WorkbenchModule
version int
tenantID types.TenantID
uid types.Uid
calledOnce bool
}
type testOrganizationStore struct {
departments []types.OrganizationDepartment
members []types.OrganizationMember
searchItems []types.OrganizationSearchItem
parentID string
departmentID string
query string
resultTypes string
}
type testMeetingsStore struct {
meeting *types.Meeting
attendeeIDs []types.Uid
created *types.Meeting
response string
responseVersion int
}
type testDailyReportsStore struct {
report *types.DailyReport
saved bool
submitted bool
remindKeys string
}
type testMonthlyReportsStore struct {
report *types.MonthlyReport
saved bool
submitted bool
}
type testMailboxStore struct {
config *types.MailboxConfig
submission *types.MailboxSubmission
idempotencyKey string
}
type testProfilesStore struct {
profile *types.UserProfile
privacy *types.UserPrivacySettings
}
type testAppReleaseStore struct {
release *types.AppRelease
}
type testDevicePushTokenStore struct {
token *types.DevicePushToken
tenantID types.TenantID
}
type testSafetyStore struct {
riskChallenge *types.RiskChallenge
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
mutes []types.GroupMute
invitations []types.GroupInvitation
updatedSettings types.GroupSettingsPatch
updatedMembers []types.GroupMemberPatch
}
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.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
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) 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) {
return &types.GroupMember{Topic: topic, UserID: uid, Role: types.GroupMemberRoleMember, MemberStatus: types.GroupMemberStatusActive, Version: 1}, nil
}
func (s *testGroupBusinessStore) ListMembers(_ types.TenantID, _ string, _ types.GroupMemberListOptions) ([]types.GroupMember, string, error) {
return nil, "", nil
}
func (s *testGroupBusinessStore) UpdateMember(_ types.TenantID, topic string, uid types.Uid, patch types.GroupMemberPatch) (*types.GroupMember, error) {
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) {
s.invitations = append(s.invitations, *inv)
return inv, nil
}
func (s *testGroupBusinessStore) CreateJoinRequest(_ types.TenantID, req *types.GroupJoinRequest) (*types.GroupJoinRequest, error) {
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) 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) 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) 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, _ string, _ types.Uid, _ time.Time) error {
return nil
}
func (s *testGroupBusinessStore) UpdateRole(_ types.TenantID, topic string, _ types.Uid, target types.Uid,
role string, _ types.AccessMode, _ time.Time) error {
s.updatedMembers = append(s.updatedMembers, types.GroupMemberPatch{Role: &role})
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 (s *testGroupBusinessStore) ListPendingEvents(_ types.TenantID, _ int, _ time.Time) ([]types.GroupEvent, error) {
return nil, nil
}
func (s *testGroupBusinessStore) MarkEventDelivered(_ types.TenantID, _ string, _ time.Time) error {
return nil
}
func defaultStringPtr(value *string, fallback string) string {
if value == nil {
return fallback
}
return *value
}
func (s *testWorkbenchStore) Get(tenantID types.TenantID, uid types.Uid) ([]types.WorkbenchModule, int, error) {
s.tenantID = tenantID
s.uid = uid
s.calledOnce = true
if s.version == 0 {
s.version = 1
}
return s.modules, s.version, nil
}
func (s *testOrganizationStore) ListDepartments(_ types.TenantID, _ types.Uid, parentID, _ string, _ int) ([]types.OrganizationDepartment, string, error) {
s.parentID = parentID
return s.departments, "", nil
}
func (s *testOrganizationStore) ListMembers(_ types.TenantID, _ types.Uid, departmentID, _ string, _ int) ([]types.OrganizationMember, string, error) {
s.departmentID = departmentID
return s.members, "", nil
}
func (s *testOrganizationStore) Search(_ types.TenantID, _ types.Uid, queryText, resultTypes, _ string, _ int) ([]types.OrganizationSearchItem, string, error) {
s.query = queryText
s.resultTypes = resultTypes
return s.searchItems, "", nil
}
func (s *testMeetingsStore) Create(_ types.TenantID, uid types.Uid, meeting *types.Meeting, attendeeIDs []types.Uid) (*types.Meeting, error) {
s.created = meeting
s.attendeeIDs = attendeeIDs
now := time.Now().UTC().Round(time.Millisecond)
meeting.OrganizerID = uid
meeting.State = types.MeetingStateScheduled
meeting.Version = 1
meeting.CreatedAt = now
meeting.UpdatedAt = now
meeting.Participants = []types.MeetingParticipant{
{MeetingID: meeting.MeetingID, UserID: uid, Response: types.MeetingResponseAccepted, ResponseAt: &now},
}
for _, attendeeID := range attendeeIDs {
meeting.Participants = append(meeting.Participants, types.MeetingParticipant{MeetingID: meeting.MeetingID, UserID: attendeeID, Response: types.MeetingResponsePending})
}
s.meeting = meeting
return meeting, nil
}
func (s *testMeetingsStore) Get(_ types.TenantID, _ types.Uid, _ string) (*types.Meeting, error) {
return s.meeting, nil
}
func (s *testMeetingsStore) Update(_ types.TenantID, _ types.Uid, _ string, _ types.MeetingPatch, _ string) (*types.Meeting, error) {
return s.meeting, nil
}
func (s *testMeetingsStore) Cancel(_ types.TenantID, _ types.Uid, _ string, _ string) (*types.Meeting, error) {
s.meeting.State = types.MeetingStateCanceled
return s.meeting, nil
}
func (s *testMeetingsStore) Remind(_ types.TenantID, _ types.Uid, _ string, _ string) ([]types.MeetingParticipant, error) {
return s.meeting.Participants, nil
}
func (s *testMeetingsStore) Respond(_ types.TenantID, uid types.Uid, _ string, response string, version int) (*types.Meeting, error) {
s.response = response
s.responseVersion = version
s.meeting.Version++
for i := range s.meeting.Participants {
if s.meeting.Participants[i].UserID == uid {
s.meeting.Participants[i].Response = response
}
}
return s.meeting, nil
}
func (s *testDailyReportsStore) List(_ types.TenantID, _ types.Uid, _, _, _, _, _ string, _ int) ([]types.DailyReport, string, error) {
if s.report == nil {
return nil, "", nil
}
return []types.DailyReport{*s.report}, "", nil
}
func (s *testDailyReportsStore) CreateDraft(_ types.TenantID, uid types.Uid, reportID string, reportDate time.Time) (*types.DailyReport, error) {
now := time.Now().UTC().Round(time.Millisecond)
s.report = &types.DailyReport{
ReportID: reportID,
AuthorID: uid,
ReportDate: reportDate,
Status: types.DailyReportStatusDraft,
ReadState: "unread",
Version: 1,
CreatedAt: now,
UpdatedAt: now,
}
return s.report, nil
}
func (s *testDailyReportsStore) Get(_ types.TenantID, _ types.Uid, _ string) (*types.DailyReport, error) {
return s.report, nil
}
func (s *testDailyReportsStore) SaveDraft(_ types.TenantID, _ types.Uid, _ string, content string, attachments []string, version int) (*types.DailyReport, error) {
s.saved = true
s.report.Content = content
s.report.Attachments = attachments
s.report.Version = version + 1
return s.report, nil
}
func (s *testDailyReportsStore) Submit(_ types.TenantID, _ types.Uid, _ string, version int) (*types.DailyReport, error) {
s.submitted = true
now := time.Now().UTC().Round(time.Millisecond)
s.report.Status = types.DailyReportStatusSubmitted
s.report.SubmittedAt = &now
s.report.Version = version + 1
return s.report, nil
}
func (s *testDailyReportsStore) Stats(_ types.TenantID, _ types.Uid, _ time.Time, _ string) (*types.DailyReportStats, error) {
return &types.DailyReportStats{ExpectedCount: 3, SubmittedCount: 1, MissingCount: 2}, nil
}
func (s *testDailyReportsStore) Remind(_ types.TenantID, _ types.Uid, _ time.Time, userIDs []types.Uid, idempotencyKey string) ([]types.Uid, error) {
s.remindKeys = idempotencyKey
return userIDs, nil
}
func (s *testMonthlyReportsStore) List(_ types.TenantID, _ types.Uid, _, _, _, _, _ string, _ int) ([]types.MonthlyReport, string, error) {
if s.report == nil {
return nil, "", nil
}
return []types.MonthlyReport{*s.report}, "", nil
}
func (s *testMonthlyReportsStore) CreateDraft(_ types.TenantID, uid types.Uid, reportID, month string) (*types.MonthlyReport, error) {
now := time.Now().UTC().Round(time.Millisecond)
s.report = &types.MonthlyReport{
ReportID: reportID,
AuthorID: uid,
Month: month,
Status: types.DailyReportStatusDraft,
ReadState: "unread",
Version: 1,
CreatedAt: now,
UpdatedAt: now,
}
return s.report, nil
}
func (s *testMonthlyReportsStore) Get(_ types.TenantID, _ types.Uid, _ string) (*types.MonthlyReport, error) {
return s.report, nil
}
func (s *testMonthlyReportsStore) SaveDraft(_ types.TenantID, _ types.Uid, _ string, workSummary, nextPlan string, attachments []string, version int) (*types.MonthlyReport, error) {
s.saved = true
s.report.WorkSummary = workSummary
s.report.NextPlan = nextPlan
s.report.Attachments = attachments
s.report.Version = version + 1
return s.report, nil
}
func (s *testMonthlyReportsStore) Submit(_ types.TenantID, _ types.Uid, _ string, version int) (*types.MonthlyReport, error) {
s.submitted = true
now := time.Now().UTC().Round(time.Millisecond)
s.report.Status = types.DailyReportStatusSubmitted
s.report.SubmittedAt = &now
s.report.Version = version + 1
return s.report, nil
}
func (s *testMonthlyReportsStore) Stats(_ types.TenantID, _ types.Uid, _ string, _ string) (*types.MonthlyReportStats, error) {
return &types.MonthlyReportStats{ExpectedCount: 4, SubmittedCount: 2, MissingCount: 2}, nil
}
func (s *testMailboxStore) Config(_ types.TenantID, _ types.Uid) (*types.MailboxConfig, error) {
return s.config, nil
}
func (s *testMailboxStore) Submit(_ types.TenantID, uid types.Uid, submission *types.MailboxSubmission, idempotencyKey string) (*types.MailboxSubmission, error) {
s.idempotencyKey = idempotencyKey
now := time.Now().UTC().Round(time.Millisecond)
submission.SenderID = uid
submission.Status = types.MailboxSubmissionStatusReceived
submission.SubmittedAt = now
submission.CreatedAt = now
submission.UpdatedAt = now
s.submission = submission
return submission, 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, 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
}
privacy.Version = patch.Version + 1
privacy.UpdatedAt = time.Now().UTC().Round(time.Millisecond)
return privacy, nil
}
func (s *testAppReleaseStore) Latest(_ types.TenantID, platform, channel string) (*types.AppRelease, error) {
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: "optional",
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 *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) {
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(_ types.TenantID, _ string, _ int, _ time.Time) error {
return nil
}
func (s *testCallsStore) Finish(_ types.TenantID, _ string, _ int, _ 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 *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.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) {
return &types.Friendship{TenantID: tenantID, UserID: userID, PeerID: peerID, Status: types.RelationshipFriend, Remark: remark, Topic: userID.P2PName(peerID), CreatedAt: now, UpdatedAt: now}, nil
}
func (s *testRelationsStore) SearchUsers(_ types.TenantID, _ types.Uid, _, _, _ string, _ int) ([]types.UserSearchItem, string, error) {
return s.searchResults, "", nil
}
func (s *testRelationsStore) ListBlocks(_ types.TenantID, _ types.Uid, _ string, _ int) ([]types.BlockListItem, string, error) {
return s.blocks, "", nil
}
func (s *testVerificationStore) Create(_ types.TenantID, vc *types.VerificationCode) error {
s.created = vc
return s.err
}
func (s *testVerificationStore) Get(_ types.TenantID, _ string) (*types.VerificationCode, error) {
if s.err != nil {
return nil, s.err
}
if s.get == nil {
return nil, types.ErrNotFound
}
return s.get, nil
}
func (s *testVerificationStore) GetActive(_ types.TenantID, _, _ string, _ time.Time) (*types.VerificationCode, error) {
if s.err != nil {
return nil, s.err
}
if s.active == nil {
return nil, types.ErrNotFound
}
return s.active, nil
}
func (s *testVerificationStore) Fail(_ types.TenantID, _ string, _ types.VerificationState) error {
s.failed = true
return nil
}
func (s *testVerificationStore) MarkVerified(_ types.TenantID, _, tokenHash string, _ time.Time) error {
s.verified = true
s.tokenHash = tokenHash
return nil
}
func (s *testVerificationStore) Consume(_ types.TenantID, _, tokenHash string, _ time.Time) error {
s.consumed = true
s.tokenHash = tokenHash
return nil
}
func makeTestAPIKey(t *testing.T) string {
t.Helper()
globals.apiKeySalt = []byte("0123456789abcdef")
data := make([]byte, apikeyLength)
data[0] = 1
binary.LittleEndian.PutUint32(data[apikeyVersion:], uint32(0))
binary.LittleEndian.PutUint16(data[apikeyVersion+apikeyAppID:], uint16(1))
data[apikeyVersion+apikeyAppID+apikeySequence] = uint8(0)
hasher := hmac.New(md5.New, globals.apiKeySalt)
hasher.Write(data[:apikeyVersion+apikeyAppID+apikeySequence+apikeyWho])
copy(data[apikeyVersion+apikeyAppID+apikeySequence+apikeyWho:], hasher.Sum(nil))
return base64.URLEncoding.EncodeToString(data)
}
func decodeIMHTTPResponse(t *testing.T, rec *httptest.ResponseRecorder) imHTTPResponse {
t.Helper()
var resp imHTTPResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to decode response: %v; body=%s", err, rec.Body.String())
}
return resp
}
func expectIMBearer(t *testing.T, ctrl *gomock.Controller, token string, tenantID types.TenantID, uid types.Uid) *mock_store.MockPersistentStorageInterface {
t.Helper()
oldStore := store.Store
mockStore := mock_store.NewMockPersistentStorageInterface(ctrl)
tokenAuth := mock_auth.NewMockAuthHandler(ctrl)
mockStore.EXPECT().GetLogicalAuthHandler("token").Return(tokenAuth).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-Tinode-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-Tinode-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"))
}
}
func TestIMHTTPIdempotencyReplayAndConflict(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
expectIMBearer(t, ctrl, "token-1", 7, 42)
oldInfrastructure := store.HTTPInfrastructure
infrastructure := &testHTTPInfrastructureStore{}
store.HTTPInfrastructure = infrastructure
defer func() { store.HTTPInfrastructure = oldInfrastructure }()
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-Tinode-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)
req.Header.Set("X-Tinode-APIKey", makeTestAPIKey(t))
req.Header.Set("X-Tenant-Code", " acme ")
req.Header.Set("X-Request-ID", "req-1")
rec := httptest.NewRecorder()
serveIMTenantResolve(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
resp := decodeIMHTTPResponse(t, rec)
if resp.Code != 0 || resp.RequestID != "req-1" {
t.Fatalf("unexpected response envelope: %#v", resp)
}
data := resp.Data.(map[string]any)
tenant := data["tenant"].(map[string]any)
if tenant["code"] != "acme" || tenant["name"] != "Acme Corp" {
t.Fatalf("unexpected tenant payload: %#v", tenant)
}
if _, ok := tenant["id"]; ok {
t.Fatalf("tenant payload must not expose internal id: %#v", tenant)
}
endpoints := data["endpoints"].(map[string]any)
if endpoints["websocket"] != "wss://im.example.com/v0/channels" {
t.Fatalf("unexpected websocket endpoint: %#v", endpoints)
}
}
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")
req.Header.Set("X-Tinode-APIKey", makeTestAPIKey(t))
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" ||
endpoints["api"] != "https://api.118im.com/v1/im" ||
endpoints["file"] != "https://api.118im.com/v0/file" {
t.Fatalf("unexpected endpoints: %#v", endpoints)
}
}
func TestServeIMTenantResolveSuccessFromBody(t *testing.T) {
oldTenants := store.Tenants
store.Tenants = testTenantStore{tenant: &types.Tenant{
ID: 7,
Code: "acme",
Name: "Acme Corp",
State: types.TenantStateActive,
}}
defer func() { store.Tenants = oldTenants }()
globals.servingAt = "http://127.0.0.1:6060/"
req := httptest.NewRequest(http.MethodPost, "/v1/im/tenants/resolve",
bytes.NewBufferString(`{"tenant_code":"acme","platform":"android","app_version":"1.0.0"}`))
req.Header.Set("X-Tinode-APIKey", makeTestAPIKey(t))
rec := httptest.NewRecorder()
serveIMTenantResolve(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
resp := decodeIMHTTPResponse(t, rec)
if resp.Code != 0 || resp.Reason != "" {
t.Fatalf("unexpected response envelope: %#v", resp)
}
}
func TestServeIMTenantResolveRequiresPost(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/v1/im/tenants/resolve", nil)
rec := httptest.NewRecorder()
serveIMTenantResolve(rec, req)
if rec.Code != http.StatusMethodNotAllowed {
t.Fatalf("status: got %d, want %d", rec.Code, http.StatusMethodNotAllowed)
}
resp := decodeIMHTTPResponse(t, rec)
if resp.Reason != imReasonMethodNotAllowed {
t.Fatalf("reason: got %q, want %q", resp.Reason, imReasonMethodNotAllowed)
}
}
func TestServeIMTenantResolveRequiresAPIKey(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/v1/im/tenants/resolve",
bytes.NewBufferString(`{"tenant_code":"acme"}`))
rec := httptest.NewRecorder()
serveIMTenantResolve(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("status: got %d, want %d", rec.Code, http.StatusForbidden)
}
resp := decodeIMHTTPResponse(t, rec)
if resp.Reason != imReasonAPIKeyRequired {
t.Fatalf("reason: got %q, want %q", resp.Reason, imReasonAPIKeyRequired)
}
}
func TestServeIMTenantResolveRejectsMalformedTenantCode(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/v1/im/tenants/resolve",
bytes.NewBufferString(`{"tenant_code":"`+strings.Repeat("a", 65)+`"}`))
req.Header.Set("X-Tinode-APIKey", makeTestAPIKey(t))
rec := httptest.NewRecorder()
serveIMTenantResolve(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status: got %d, want %d", rec.Code, http.StatusBadRequest)
}
resp := decodeIMHTTPResponse(t, rec)
if resp.Reason != imReasonMalformedRequest {
t.Fatalf("reason: got %q, want %q", resp.Reason, imReasonMalformedRequest)
}
}
func TestServeIMTenantResolveHidesUnavailableTenantState(t *testing.T) {
oldTenants := store.Tenants
store.Tenants = testTenantStore{tenant: &types.Tenant{
ID: 7,
Code: "acme",
Name: "Acme Corp",
State: types.TenantStateSuspended,
}}
defer func() { store.Tenants = oldTenants }()
req := httptest.NewRequest(http.MethodPost, "/v1/im/tenants/resolve",
bytes.NewBufferString(`{"tenant_code":"acme"}`))
req.Header.Set("X-Tinode-APIKey", makeTestAPIKey(t))
rec := httptest.NewRecorder()
serveIMTenantResolve(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("status: got %d, want %d", rec.Code, http.StatusForbidden)
}
resp := decodeIMHTTPResponse(t, rec)
if resp.Reason != imReasonTenantUnavailable {
t.Fatalf("reason: got %q, want %q", resp.Reason, imReasonTenantUnavailable)
}
}
func TestServeIMTenantResolveHidesMissingTenant(t *testing.T) {
oldTenants := store.Tenants
store.Tenants = testTenantStore{err: types.ErrNotFound}
defer func() { store.Tenants = oldTenants }()
req := httptest.NewRequest(http.MethodPost, "/v1/im/tenants/resolve",
bytes.NewBufferString(`{"tenant_code":"acme"}`))
req.Header.Set("X-Tinode-APIKey", makeTestAPIKey(t))
rec := httptest.NewRecorder()
serveIMTenantResolve(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("status: got %d, want %d", rec.Code, http.StatusNotFound)
}
resp := decodeIMHTTPResponse(t, rec)
if resp.Reason != imReasonTenantUnavailable {
t.Fatalf("reason: got %q, want %q", resp.Reason, imReasonTenantUnavailable)
}
}
func TestServeIMVerificationCodesCreatesChallenge(t *testing.T) {
oldTenants := store.Tenants
oldVerifications := store.Verifications
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","phone":"+8613800138000"}`))
req.Header.Set("X-Tinode-APIKey", makeTestAPIKey(t))
req.Header.Set("X-Tenant-Code", "acme")
rec := httptest.NewRecorder()
serveIMVerificationCodes(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
if verifications.created == nil {
t.Fatal("expected challenge to be stored")
}
if verifications.created.TenantID != 7 || verifications.created.Scene != "register" {
t.Fatalf("unexpected stored challenge: %#v", verifications.created)
}
if verifications.created.CodeHash == "" || strings.Contains(rec.Body.String(), verifications.created.CodeHash) {
t.Fatalf("code hash missing or leaked: %#v body=%s", verifications.created, rec.Body.String())
}
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","phone":"+8613800138000"}`))
req.Header.Set("X-Tinode-APIKey", makeTestAPIKey(t))
req.Header.Set("X-Tenant-Code", "acme")
rec := httptest.NewRecorder()
serveIMVerificationCodes(rec, req)
if rec.Code != http.StatusOK || verifications.created == nil {
t.Fatalf("status=%d body=%s created=%#v", rec.Code, rec.Body.String(), verifications.created)
}
}
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-Tinode-APIKey", makeTestAPIKey(t))
req.Header.Set("X-Tenant-Code", "acme")
rec := httptest.NewRecorder()
serveIMVerificationCodes(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status=%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 TestServeIMVerificationCodesExpiresChallengeOnDeliveryFailure(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-Tinode-APIKey", makeTestAPIKey(t))
req.Header.Set("X-Tenant-Code", "acme")
rec := httptest.NewRecorder()
serveIMVerificationCodes(rec, req)
if rec.Code != http.StatusBadGateway || !verifications.failed {
t.Fatalf("status=%d failed=%v body=%s", rec.Code, verifications.failed, rec.Body.String())
}
if resp := decodeIMHTTPResponse(t, rec); resp.Reason != imReasonSMSDeliveryFailed {
t.Fatalf("reason=%q", resp.Reason)
}
}
func TestServeIMVerificationCodesRequiresTenantCode(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/v1/im/auth/verification-codes",
bytes.NewBufferString(`{"scene":"register","phone":"+8613800138000"}`))
req.Header.Set("X-Tinode-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","phone":"+8613800138000"}`))
req.Header.Set("X-Tinode-APIKey", makeTestAPIKey(t))
req.Header.Set("X-Tenant-Code", "acme")
rec := httptest.NewRecorder()
serveIMVerificationCodes(rec, req)
if rec.Code != http.StatusTooManyRequests {
t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusTooManyRequests, rec.Body.String())
}
resp := decodeIMHTTPResponse(t, rec)
if resp.Reason != imReasonVerificationRate {
t.Fatalf("reason: got %q, want %q", resp.Reason, imReasonVerificationRate)
}
}
func TestServeIMVerificationCodeVerifySuccess(t *testing.T) {
oldTenants := store.Tenants
oldVerifications := store.Verifications
store.Tenants = testTenantStore{tenant: &types.Tenant{
ID: 7,
Code: "acme",
Name: "Acme Corp",
State: types.TenantStateActive,
}}
vc := &types.VerificationCode{
TenantID: 7,
ChallengeID: "challenge-1",
Scene: "register",
PhoneHash: "phone-hash",
CodeHash: hashIMSecret(7, "register", "challenge-1", "phone-hash", "123456"),
State: types.VerificationStatePending,
MaxAttempts: 5,
ExpiresAt: time.Now().UTC().Add(time.Minute),
}
verifications := &testVerificationStore{get: vc}
store.Verifications = verifications
defer func() {
store.Tenants = oldTenants
store.Verifications = oldVerifications
}()
req := httptest.NewRequest(http.MethodPost, "/v1/im/auth/verification-codes/challenge-1/verify",
bytes.NewBufferString(`{"code":"123456"}`))
req.Header.Set("X-Tinode-APIKey", makeTestAPIKey(t))
req.Header.Set("X-Tenant-Code", "acme")
rec := httptest.NewRecorder()
serveIMVerificationCodeVerify(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
if !verifications.verified || verifications.tokenHash == "" {
t.Fatalf("expected challenge to be marked verified: %#v", verifications)
}
resp := decodeIMHTTPResponse(t, rec)
data := resp.Data.(map[string]any)
if data["verification_token"] == "" {
t.Fatalf("missing verification token: %#v", data)
}
}
func TestServeIMVerificationCodeVerifyInvalidCode(t *testing.T) {
oldTenants := store.Tenants
oldVerifications := store.Verifications
store.Tenants = testTenantStore{tenant: &types.Tenant{
ID: 7,
Code: "acme",
Name: "Acme Corp",
State: types.TenantStateActive,
}}
store.Verifications = &testVerificationStore{get: &types.VerificationCode{
TenantID: 7,
ChallengeID: "challenge-1",
Scene: "register",
PhoneHash: "phone-hash",
CodeHash: hashIMSecret(7, "register", "challenge-1", "phone-hash", "123456"),
State: types.VerificationStatePending,
MaxAttempts: 5,
ExpiresAt: time.Now().UTC().Add(time.Minute),
}}
defer func() {
store.Tenants = oldTenants
store.Verifications = oldVerifications
}()
req := httptest.NewRequest(http.MethodPost, "/v1/im/auth/verification-codes/challenge-1/verify",
bytes.NewBufferString(`{"code":"654321"}`))
req.Header.Set("X-Tinode-APIKey", makeTestAPIKey(t))
req.Header.Set("X-Tenant-Code", "acme")
rec := httptest.NewRecorder()
serveIMVerificationCodeVerify(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusUnauthorized, rec.Body.String())
}
resp := decodeIMHTTPResponse(t, rec)
if resp.Reason != imReasonCodeInvalid {
t.Fatalf("reason: got %q, want %q", resp.Reason, imReasonCodeInvalid)
}
}
func TestServeIMRealNameReturnsUnverified(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
expectIMBearer(t, ctrl, "token-1", 7, 42)
oldSecurity := store.Security
store.Security = &testSecurityStore{}
defer func() { store.Security = oldSecurity }()
req := httptest.NewRequest(http.MethodGet, "/v1/im/security/realname", nil)
req.Header.Set("Authorization", "Bearer token-1")
rec := httptest.NewRecorder()
serveIMRealName(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
if data["status"] != "unverified" || data["can_submit"] != true {
t.Fatalf("unexpected realname payload: %#v", data)
}
}
func TestServeIMRealNameSubmissionCreatesPending(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
expectIMBearer(t, ctrl, "token-1", 7, 42)
oldSecurity := store.Security
security := &testSecurityStore{}
store.Security = security
defer func() { store.Security = oldSecurity }()
req := httptest.NewRequest(http.MethodPost, "/v1/im/security/realname/submissions",
bytes.NewBufferString(`{"name":"张三","id_type":"cn_id","id_number":"110101199003071234"}`))
req.Header.Set("Authorization", "Bearer token-1")
req.Header.Set("Idempotency-Key", "idem-1")
rec := httptest.NewRecorder()
serveIMRealNameSubmissions(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
if security.realName == nil || security.realName.Status != "pending" || security.realName.NameCipher == "张三" {
t.Fatalf("unexpected stored realname: %#v", security.realName)
}
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
if data["status"] != "pending" || data["submission_id"] == "" {
t.Fatalf("unexpected response: %#v", data)
}
}
func TestServeIMIndependentPasswordSetAndVerify(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
oldSecurity := store.Security
security := &testSecurityStore{actionToken: &types.ActionToken{Action: "set_independent_password", Target: "youth_mode"}}
store.Security = security
defer func() { store.Security = oldSecurity }()
expectIMBearer(t, ctrl, "token-set", 7, 42)
req := httptest.NewRequest(http.MethodPost, "/v1/im/settings/independent-password",
bytes.NewBufferString(`{"password":"2794","purpose":"youth_mode","action_token":"action.1"}`))
req.Header.Set("Authorization", "Bearer token-set")
rec := httptest.NewRecorder()
serveIMIndependentPassword(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("set status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
if security.independentPassword == nil ||
bcrypt.CompareHashAndPassword(security.independentPassword.PasswordHash, []byte("2794")) != nil {
t.Fatalf("password was not stored as a valid hash: %#v", security.independentPassword)
}
expectIMBearer(t, ctrl, "token-verify", 7, 42)
req = httptest.NewRequest(http.MethodPost, "/v1/im/settings/independent-password/verify",
bytes.NewBufferString(`{"password":"2794","purpose":"youth_mode","action":"close_youth_mode"}`))
req.Header.Set("Authorization", "Bearer token-verify")
rec = httptest.NewRecorder()
serveIMIndependentPasswordVerify(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("verify status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
if data["action"] != "close_youth_mode" || data["action_token"] == "" {
t.Fatalf("unexpected verify response: %#v", data)
}
}
func TestServeIMSecurityChallengeVerifyIssuesActionToken(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
expectIMBearer(t, ctrl, "token-1", 7, 42)
oldVerifications := store.Verifications
oldSecurity := store.Security
security := &testSecurityStore{}
store.Security = security
store.Verifications = &testVerificationStore{get: &types.VerificationCode{
TenantID: 7,
ChallengeID: "challenge-1",
Scene: "sensitive_action",
Action: "set_independent_password",
PhoneHash: "phone-hash",
TargetUser: 42,
CodeHash: hashIMSecret(7, "sensitive_action", "challenge-1", "phone-hash", "123456"),
State: types.VerificationStatePending,
MaxAttempts: 5,
ExpiresAt: time.Now().UTC().Add(time.Minute),
}}
defer func() {
store.Verifications = oldVerifications
store.Security = oldSecurity
}()
req := httptest.NewRequest(http.MethodPost, "/v1/im/security/challenges/challenge-1/verify",
bytes.NewBufferString(`{"code":"123456"}`))
req.Header.Set("Authorization", "Bearer token-1")
rec := httptest.NewRecorder()
serveIMSecurityChallengeVerify(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
if security.actionToken == nil || security.actionToken.Action != "set_independent_password" || security.actionToken.Target != "youth_mode" {
t.Fatalf("unexpected action token: %#v", security.actionToken)
}
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
if data["action"] != "set_independent_password" || data["action_token"] == "" {
t.Fatalf("unexpected response: %#v", data)
}
}
func TestServeIMBootstrapIncludesHomeCapabilities(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
expectIMBearer(t, ctrl, "token-1", 7, 42)
oldSecurity := store.Security
oldWorkbench := store.Workbench
oldRelations := store.Relations
security := &testSecurityStore{
realName: &types.RealNameVerification{Status: "verified"},
unreadNotifications: 4,
}
relations := &testRelationsStore{pendingCount: 2}
workbench := &testWorkbenchStore{version: 9, modules: []types.WorkbenchModule{
{Code: "organization", Name: "组织架构", Route: "/organization", UnreadCount: 3, Enabled: true, Permission: "enabled"},
{Code: "meetings", Name: "会议通知", Route: "/meetings", UnreadCount: 5, Enabled: false, Permission: "enabled"},
{Code: "external", Name: "外链", Route: "https://example.com", UnreadCount: 9, Enabled: true, Permission: "enabled"},
}}
store.Security = security
store.Relations = relations
store.Workbench = workbench
defer func() {
store.Security = oldSecurity
store.Relations = oldRelations
store.Workbench = oldWorkbench
}()
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)
}
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)
}
if !workbench.calledOnce || workbench.tenantID != 7 || workbench.uid != 42 {
t.Fatalf("bootstrap workbench lookup scope: store=%#v", workbench)
}
if data["workbench_version"].(float64) != 9 {
t.Fatalf("unexpected bootstrap workbench version: %#v", data)
}
modules := data["modules"].([]any)
if len(modules) != 2 || modules[0].(map[string]any)["code"] != "organization" || modules[1].(map[string]any)["code"] != "meetings" {
t.Fatalf("unexpected bootstrap modules: %#v", modules)
}
if modules[0].(map[string]any)["unread_count"].(float64) != 3 {
t.Fatalf("unexpected bootstrap module unread: %#v", modules[0])
}
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) != 4 ||
badges["workbench_total_unread"].(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) != 4 || 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 TestServeIMWorkbench(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
expectIMBearer(t, ctrl, "token-1", 7, 42)
oldWorkbench := store.Workbench
oldTenants := store.Tenants
store.Workbench = &testWorkbenchStore{version: 3, modules: []types.WorkbenchModule{
{Code: "organization", Name: "组织架构", Route: "/organization", Enabled: true, Permission: "enabled"},
{Code: "external", Name: "外部链接", Route: "https://example.com", Enabled: true, Permission: "enabled"},
}}
store.Tenants = testTenantStore{tenant: &types.Tenant{ID: 7, Code: "acme", State: types.TenantStateActive}}
defer func() {
store.Workbench = oldWorkbench
store.Tenants = oldTenants
}()
req := httptest.NewRequest(http.MethodGet, "/v1/im/workbench", nil)
req.Header.Set("Authorization", "Bearer token-1")
rec := httptest.NewRecorder()
serveIMWorkbench(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["layout_version"].(float64) != 3 {
t.Fatalf("unexpected layout version: %#v", data)
}
items := data["modules"].([]any)
if len(items) != 1 {
t.Fatalf("expected external route to be filtered, got %#v", items)
}
item := items[0].(map[string]any)
if item["code"] != "organization" || item["route"] != "/organization" {
t.Fatalf("unexpected module: %#v", item)
}
enterprise := data["enterprise"].(map[string]any)
if enterprise["tenant_code"] != "acme" {
t.Fatalf("tenant_code: got %#v, want acme", enterprise["tenant_code"])
}
}
func TestServeIMOrganizationDepartments(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
expectIMBearer(t, ctrl, "token-1", 7, 42)
oldOrganization := store.Organization
org := &testOrganizationStore{departments: []types.OrganizationDepartment{
{DepartmentID: "dep-sales", Name: "销售部", ParentID: "root", ChildCount: 1, MemberCount: 12, Version: 2},
}}
store.Organization = org
defer func() { store.Organization = oldOrganization }()
req := httptest.NewRequest(http.MethodGet, "/v1/im/organization/departments?parent_id=root", nil)
req.Header.Set("Authorization", "Bearer token-1")
rec := httptest.NewRecorder()
serveIMOrganizationDepartments(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 || org.parentID != "root" {
t.Fatalf("unexpected departments response: %#v parent=%q", items, org.parentID)
}
item := items[0].(map[string]any)
if item["department_id"] != "dep-sales" || item["name"] != "销售部" {
t.Fatalf("unexpected department payload: %#v", item)
}
}
func TestServeIMOrganizationDepartmentMembers(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
expectIMBearer(t, ctrl, "token-1", 7, 42)
oldOrganization := store.Organization
org := &testOrganizationStore{members: []types.OrganizationMember{
{DepartmentID: "dep-sales", UserID: types.ParseUserId("usrAAAAAAAAAAA"), DisplayName: "张三", Position: "销售经理", Status: "active", Contactable: true, Version: 1},
}}
store.Organization = org
defer func() { store.Organization = oldOrganization }()
req := httptest.NewRequest(http.MethodGet, "/v1/im/organization/departments/dep-sales/members", nil)
req.Header.Set("Authorization", "Bearer token-1")
rec := httptest.NewRecorder()
serveIMOrganizationDepartmentResource(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 || org.departmentID != "dep-sales" || data["department_id"] != "dep-sales" {
t.Fatalf("unexpected members response: %#v department=%q", data, org.departmentID)
}
item := items[0].(map[string]any)
if item["display_name"] != "张三" || item["position"] != "销售经理" || item["contactable"] != true {
t.Fatalf("unexpected member payload: %#v", item)
}
}
func TestServeIMOrganizationSearch(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
expectIMBearer(t, ctrl, "token-1", 7, 42)
oldOrganization := store.Organization
member := types.OrganizationMember{DepartmentID: "dep-sales", UserID: types.ParseUserId("usrAAAAAAAAAAA"), DisplayName: "张三", Status: "active", Contactable: true, Version: 1}
org := &testOrganizationStore{searchItems: []types.OrganizationSearchItem{
{Type: "department", Department: &types.OrganizationDepartment{DepartmentID: "dep-sales", Name: "销售部", ParentID: "root", Version: 1}},
{Type: "user", Member: &member},
}}
store.Organization = org
defer func() { store.Organization = oldOrganization }()
req := httptest.NewRequest(http.MethodGet, "/v1/im/organization/search?q=%E9%94%80%E5%94%AE&types=department,user", nil)
req.Header.Set("Authorization", "Bearer token-1")
rec := httptest.NewRecorder()
serveIMOrganizationSearch(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 || org.query != "销售" || org.resultTypes != "department,user" {
t.Fatalf("unexpected search response: %#v query=%q types=%q", items, org.query, org.resultTypes)
}
if items[0].(map[string]any)["type"] != "department" || items[1].(map[string]any)["type"] != "user" {
t.Fatalf("unexpected search payload: %#v", items)
}
}
func TestServeIMMeetingsCreate(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
expectIMBearer(t, ctrl, "token-1", 7, 42)
oldMeetings := store.Meetings
meetings := &testMeetingsStore{}
store.Meetings = meetings
defer func() { store.Meetings = oldMeetings }()
body := `{"title":"周会","start_at":"2026-08-14T09:00:00Z","end_at":"2026-08-14T10:00:00Z","location":"A101","attendee_user_ids":["` + types.Uid(43).UserId() + `"],"reminder_minutes":[10]}`
req := httptest.NewRequest(http.MethodPost, "/v1/im/meetings", strings.NewReader(body))
req.Header.Set("Authorization", "Bearer token-1")
req.Header.Set("Idempotency-Key", "mtg-1")
rec := httptest.NewRecorder()
serveIMMeetings(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 meetings.created == nil || meetings.created.IdempotencyKey != "mtg-1" || meetings.created.Title != "周会" {
t.Fatalf("unexpected created meeting: %#v", meetings.created)
}
if len(meetings.attendeeIDs) != 1 || data["state"] != types.MeetingStateScheduled || data["my_response"] != types.MeetingResponseAccepted {
t.Fatalf("unexpected meeting response: %#v attendees=%#v", data, meetings.attendeeIDs)
}
}
func TestServeIMMeetingMyResponse(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
attendee := types.Uid(43)
expectIMBearer(t, ctrl, "token-1", 7, attendee)
oldMeetings := store.Meetings
now := time.Now().UTC().Round(time.Millisecond)
meetings := &testMeetingsStore{meeting: &types.Meeting{
MeetingID: "mtg_test",
OrganizerID: 42,
Title: "周会",
StartAt: now.Add(time.Hour),
EndAt: now.Add(2 * time.Hour),
State: types.MeetingStateScheduled,
Version: 3,
CreatedAt: now,
UpdatedAt: now,
Participants: []types.MeetingParticipant{
{MeetingID: "mtg_test", UserID: 42, Response: types.MeetingResponseAccepted},
{MeetingID: "mtg_test", UserID: attendee, Response: types.MeetingResponsePending},
},
}}
store.Meetings = meetings
defer func() { store.Meetings = oldMeetings }()
req := httptest.NewRequest(http.MethodPut, "/v1/im/meetings/mtg_test/my-response", strings.NewReader(`{"response":"accepted","version":3}`))
req.Header.Set("Authorization", "Bearer token-1")
rec := httptest.NewRecorder()
serveIMMeetingResource(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 meetings.response != types.MeetingResponseAccepted || meetings.responseVersion != 3 || data["my_response"] != types.MeetingResponseAccepted {
t.Fatalf("unexpected response: data=%#v store=%#v", data, meetings)
}
}
func TestServeIMDailyReportsCreateAndList(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
expectIMBearer(t, ctrl, "token-1", 7, 42)
oldDailyReports := store.DailyReports
reports := &testDailyReportsStore{}
store.DailyReports = reports
defer func() { store.DailyReports = oldDailyReports }()
req := httptest.NewRequest(http.MethodPost, "/v1/im/reports/daily", strings.NewReader(`{"report_date":"2026-08-14"}`))
req.Header.Set("Authorization", "Bearer token-1")
rec := httptest.NewRecorder()
serveIMDailyReports(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 reports.report == nil || data["report_date"] != "2026-08-14" || data["status"] != types.DailyReportStatusDraft {
t.Fatalf("unexpected daily report response: %#v report=%#v", data, reports.report)
}
}
func TestServeIMDailyReportDraftAndSubmit(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
expectIMBearer(t, ctrl, "token-1", 7, 42)
oldDailyReports := store.DailyReports
now := time.Now().UTC().Round(time.Millisecond)
reports := &testDailyReportsStore{report: &types.DailyReport{
ReportID: "dr_test",
AuthorID: 42,
ReportDate: time.Date(2026, 8, 14, 0, 0, 0, 0, time.UTC),
Status: types.DailyReportStatusDraft,
ReadState: "unread",
Version: 1,
CreatedAt: now,
UpdatedAt: now,
}}
store.DailyReports = reports
defer func() { store.DailyReports = oldDailyReports }()
req := httptest.NewRequest(http.MethodPut, "/v1/im/reports/daily/dr_test/draft", strings.NewReader(`{"content":"今日完成接口","attachments":["file1"],"version":1}`))
req.Header.Set("Authorization", "Bearer token-1")
rec := httptest.NewRecorder()
serveIMDailyReportResource(rec, req)
if rec.Code != http.StatusOK || !reports.saved {
t.Fatalf("draft status=%d saved=%v body=%s", rec.Code, reports.saved, rec.Body.String())
}
req = httptest.NewRequest(http.MethodPost, "/v1/im/reports/daily/dr_test/submit", strings.NewReader(`{"version":2}`))
req.Header.Set("Authorization", "Bearer token-1")
rec = httptest.NewRecorder()
serveIMDailyReportResource(rec, req)
if rec.Code != http.StatusOK || !reports.submitted {
t.Fatalf("submit status=%d submitted=%v body=%s", rec.Code, reports.submitted, rec.Body.String())
}
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
if data["status"] != types.DailyReportStatusSubmitted || data["submitted_at"] == nil {
t.Fatalf("unexpected submit response: %#v", data)
}
}
func TestServeIMDailyReportStatisticsAndReminders(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
expectIMBearer(t, ctrl, "token-1", 7, 42)
oldDailyReports := store.DailyReports
reports := &testDailyReportsStore{}
store.DailyReports = reports
defer func() { store.DailyReports = oldDailyReports }()
req := httptest.NewRequest(http.MethodGet, "/v1/im/reports/daily/statistics?date=2026-08-14", nil)
req.Header.Set("Authorization", "Bearer token-1")
rec := httptest.NewRecorder()
serveIMDailyReportStatistics(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("stats status=%d body=%s", rec.Code, rec.Body.String())
}
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
if data["expected_count"].(float64) != 3 || data["missing_count"].(float64) != 2 {
t.Fatalf("unexpected stats: %#v", data)
}
req = httptest.NewRequest(http.MethodPost, "/v1/im/reports/daily/reminders", strings.NewReader(`{"date":"2026-08-14","user_ids":["`+types.Uid(43).UserId()+`"]}`))
req.Header.Set("Authorization", "Bearer token-1")
req.Header.Set("Idempotency-Key", "daily-remind-1")
rec = httptest.NewRecorder()
serveIMDailyReportReminders(rec, req)
if rec.Code != http.StatusOK || reports.remindKeys != "daily-remind-1" {
t.Fatalf("remind status=%d key=%q body=%s", rec.Code, reports.remindKeys, rec.Body.String())
}
}
func TestServeIMMonthlyReportsCreate(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
expectIMBearer(t, ctrl, "token-1", 7, 42)
oldMonthlyReports := store.MonthlyReports
reports := &testMonthlyReportsStore{}
store.MonthlyReports = reports
defer func() { store.MonthlyReports = oldMonthlyReports }()
req := httptest.NewRequest(http.MethodPost, "/v1/im/reports/monthly", strings.NewReader(`{"month":"2026-08"}`))
req.Header.Set("Authorization", "Bearer token-1")
rec := httptest.NewRecorder()
serveIMMonthlyReports(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 reports.report == nil || data["month"] != "2026-08" || data["action"] != "edit" {
t.Fatalf("unexpected monthly report response: %#v report=%#v", data, reports.report)
}
}
func TestServeIMMonthlyReportDraftAndSubmit(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
expectIMBearer(t, ctrl, "token-1", 7, 42)
oldMonthlyReports := store.MonthlyReports
now := time.Now().UTC().Round(time.Millisecond)
reports := &testMonthlyReportsStore{report: &types.MonthlyReport{
ReportID: "mr_test",
AuthorID: 42,
Month: "2026-08",
Status: types.DailyReportStatusDraft,
ReadState: "unread",
Version: 1,
CreatedAt: now,
UpdatedAt: now,
}}
store.MonthlyReports = reports
defer func() { store.MonthlyReports = oldMonthlyReports }()
req := httptest.NewRequest(http.MethodPut, "/v1/im/reports/monthly/mr_test/draft", strings.NewReader(`{"work_summary":"本月总结","next_plan":"下月计划","attachments":["file1"],"version":1}`))
req.Header.Set("Authorization", "Bearer token-1")
rec := httptest.NewRecorder()
serveIMMonthlyReportResource(rec, req)
if rec.Code != http.StatusOK || !reports.saved {
t.Fatalf("draft status=%d saved=%v body=%s", rec.Code, reports.saved, rec.Body.String())
}
req = httptest.NewRequest(http.MethodPost, "/v1/im/reports/monthly/mr_test/submit", strings.NewReader(`{"version":2}`))
req.Header.Set("Authorization", "Bearer token-1")
rec = httptest.NewRecorder()
serveIMMonthlyReportResource(rec, req)
if rec.Code != http.StatusOK || !reports.submitted {
t.Fatalf("submit status=%d submitted=%v body=%s", rec.Code, reports.submitted, rec.Body.String())
}
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
if data["status"] != types.DailyReportStatusSubmitted || data["action"] != "view" {
t.Fatalf("unexpected submit response: %#v", data)
}
}
func TestServeIMMonthlyReportStatistics(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
expectIMBearer(t, ctrl, "token-1", 7, 42)
oldMonthlyReports := store.MonthlyReports
reports := &testMonthlyReportsStore{}
store.MonthlyReports = reports
defer func() { store.MonthlyReports = oldMonthlyReports }()
req := httptest.NewRequest(http.MethodGet, "/v1/im/reports/monthly/statistics?month=2026-08", nil)
req.Header.Set("Authorization", "Bearer token-1")
rec := httptest.NewRecorder()
serveIMMonthlyReportStatistics(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("stats status=%d body=%s", rec.Code, rec.Body.String())
}
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
if data["expected_count"].(float64) != 4 || data["submitted_count"].(float64) != 2 {
t.Fatalf("unexpected stats: %#v", data)
}
}
func TestServeIMMailboxConfig(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
expectIMBearer(t, ctrl, "token-1", 7, 42)
oldMailbox := store.Mailbox
now := time.Now().UTC().Round(time.Millisecond)
mailbox := &testMailboxStore{config: &types.MailboxConfig{
Enabled: true,
AllowAnonymous: true,
RequiredFields: []string{"subject", "content"},
MaxLength: 5000,
AttachmentPolicy: map[string]any{"max_files": float64(5)},
Notice: types.MailboxNotice{Title: "总经理信箱", Content: "请确认文案", UpdatedAt: now},
UpdatedAt: now,
}}
store.Mailbox = mailbox
defer func() { store.Mailbox = oldMailbox }()
req := httptest.NewRequest(http.MethodGet, "/v1/im/executive-mailbox/config", nil)
req.Header.Set("Authorization", "Bearer token-1")
rec := httptest.NewRecorder()
serveIMMailboxConfig(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["enabled"] != true || data["allow_anonymous"] != true {
t.Fatalf("unexpected mailbox config: %#v", data)
}
notice := data["notice"].(map[string]any)
if notice["version"] == "" || notice["title"] != "总经理信箱" {
t.Fatalf("unexpected notice: %#v", notice)
}
}
func TestServeIMMailboxSubmission(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
expectIMBearer(t, ctrl, "token-1", 7, 42)
oldMailbox := store.Mailbox
mailbox := &testMailboxStore{}
store.Mailbox = mailbox
defer func() { store.Mailbox = oldMailbox }()
body := `{"subject":"建议","content":"希望优化流程","anonymous":true,"attachment_file_ids":["file1"],"notice_version":"v1"}`
req := httptest.NewRequest(http.MethodPost, "/v1/im/executive-mailbox/submissions", strings.NewReader(body))
req.Header.Set("Authorization", "Bearer token-1")
req.Header.Set("Idempotency-Key", "mailbox-1")
rec := httptest.NewRecorder()
serveIMMailboxSubmissions(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 mailbox.submission == nil || mailbox.idempotencyKey != "mailbox-1" || data["status"] != types.MailboxSubmissionStatusReceived {
t.Fatalf("unexpected submission: data=%#v store=%#v", data, mailbox)
}
}
func TestServeIMMeProfile(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/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 {
t.Fatalf("unexpected profile: %#v", data)
}
}
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,"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)
if methods["phone"] != false || data["read_receipts"] != false || 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) {
oldTenants := store.Tenants
oldReleases := store.AppReleases
store.Tenants = testTenantStore{tenant: &types.Tenant{ID: 7, Code: "default", Name: "Default", State: types.TenantStateActive}}
store.AppReleases = &testAppReleaseStore{}
defer func() {
store.Tenants = oldTenants
store.AppReleases = oldReleases
}()
req := httptest.NewRequest(http.MethodGet, "/v1/im/app/releases/latest?platform=ios&current_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)
}
}
func TestServeIMYouthModeEnableDisable(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"},
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())
}
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)
}
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 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)
}
}
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
oldTopics := store.Topics
subs := mock_store.NewMockSubsPersistenceInterface(ctrl)
topics := mock_store.NewMockTopicsPersistenceInterface(ctrl)
store.Subs = subs
store.Topics = topics
defer func() {
store.Subs = oldSubs
store.Topics = oldTopics
}()
subs.EXPECT().Get(types.TenantID(7), "grpMention", types.Uid(42), false).Return(&types.Subscription{
TenantID: 7,
User: types.Uid(42).String(),
Topic: "grpMention",
ModeWant: types.ModeCFull,
ModeGiven: types.ModeCFull,
}, nil)
alice := types.Subscription{User: types.Uid(101).UserId(), ModeWant: types.ModeCPublic, ModeGiven: types.ModeCPublic}
alice.SetPublic(map[string]any{"display_name": "Alice"})
bob := types.Subscription{User: types.Uid(102).UserId(), ModeWant: types.ModeJoin, ModeGiven: types.ModeJoin}
bob.SetPublic(map[string]any{"display_name": "Bob"})
topics.EXPECT().GetUsers(types.TenantID(7), "grpMention", gomock.Any()).Return([]types.Subscription{alice, bob}, nil)
req := httptest.NewRequest(http.MethodGet, "/v1/im/groups/grpMention/members?query=ali&mentionable=true", nil)
req.Header.Set("Authorization", "Bearer token-1")
rec := httptest.NewRecorder()
serveIMGroupResource(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
items := data["items"].([]any)
if len(items) != 1 || items[0].(map[string]any)["display_name"] != "Alice" || items[0].(map[string]any)["mentionable"] != true {
t.Fatalf("unexpected members response: %#v", data)
}
if data["permissions"].(map[string]any)["can_mention_all"] != true {
t.Fatalf("expected can_mention_all permission: %#v", data)
}
}
func TestServeIMGroupMemberProfile(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
expectIMBearer(t, ctrl, "token-1", 7, 42)
oldSubs := store.Subs
oldUsers := store.Users
oldRelations := store.Relations
subs := mock_store.NewMockSubsPersistenceInterface(ctrl)
users := mock_store.NewMockUsersPersistenceInterface(ctrl)
store.Subs = subs
store.Users = users
peer := types.Uid(101)
store.Relations = &testRelationsStore{context: &types.RelationshipContext{
TenantID: 7,
UserID: 42,
PeerID: peer,
Topic: types.Uid(42).P2PName(peer),
Relationship: types.RelationshipNone,
CanSend: false,
}}
defer func() {
store.Subs = oldSubs
store.Users = oldUsers
store.Relations = oldRelations
}()
subs.EXPECT().Get(types.TenantID(7), "grpMention", types.Uid(42), false).Return(&types.Subscription{
TenantID: 7,
User: types.Uid(42).String(),
Topic: "grpMention",
ModeWant: types.ModeCFull,
ModeGiven: types.ModeCFull,
}, nil)
member := &types.Subscription{TenantID: 7, User: peer.UserId(), Topic: "grpMention", ModeWant: types.ModeJoin, ModeGiven: types.ModeJoin}
member.SetPublic(map[string]any{"display_name": "Group Alice"})
subs.EXPECT().Get(types.TenantID(7), "grpMention", peer, false).Return(member, nil)
users.EXPECT().Get(types.TenantID(7), peer).Return(&types.User{
ObjHeader: types.ObjHeader{Id: peer.String()},
TenantID: 7,
Public: map[string]any{"display_name": "Alice", "signature": "hi"},
}, nil)
req := httptest.NewRequest(http.MethodGet, "/v1/im/groups/grpMention/members/"+peer.UserId()+"/profile", nil)
req.Header.Set("Authorization", "Bearer token-1")
rec := httptest.NewRecorder()
serveIMGroupResource(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
if data["relationship"] != types.RelationshipNone || data["signature"] != "hi" || data["role"] != "member" {
t.Fatalf("unexpected group member profile response: %#v", data)
}
}
func TestServeIMGroupList(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
expectIMBearer(t, ctrl, "token-1", 7, 42)
oldUsers := store.Users
users := mock_store.NewMockUsersPersistenceInterface(ctrl)
store.Users = users
defer func() { store.Users = oldUsers }()
sub := types.Subscription{
TenantID: 7,
User: types.Uid(42).String(),
Topic: "grpAlpha",
ModeWant: types.ModeCFull,
ModeGiven: types.ModeCFull,
ObjHeader: types.ObjHeader{UpdatedAt: time.Date(2026, 8, 12, 9, 0, 0, 0, time.UTC)},
}
sub.SetPublic(map[string]any{"name": "Alpha", "avatar_file_id": "file-1"})
sub.SetSubCnt(3)
users.EXPECT().GetSubs(types.TenantID(7), types.Uid(42)).Return([]types.Subscription{sub}, nil)
req := httptest.NewRequest(http.MethodGet, "/v1/im/groups?category=owned&query=alp", nil)
req.Header.Set("Authorization", "Bearer token-1")
rec := httptest.NewRecorder()
serveIMGroups(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
items := data["items"].([]any)
if len(items) != 1 || items[0].(map[string]any)["name"] != "Alpha" || items[0].(map[string]any)["my_role"] != "owner" {
t.Fatalf("unexpected group list response: %#v", data)
}
}
func TestServeIMGroupCreate(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
oldStore := store.Store
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)
mockStore.EXPECT().GetUidString().Return("newgroup")
store.Store = mockStore
oldTopics := store.Topics
oldSubs := store.Subs
topics := mock_store.NewMockTopicsPersistenceInterface(ctrl)
subs := mock_store.NewMockSubsPersistenceInterface(ctrl)
store.Topics = topics
store.Subs = subs
groupBusiness := &testGroupBusinessStore{}
store.GroupBusiness = groupBusiness
defer func() {
store.Store = oldStore
store.Topics = oldTopics
store.Subs = oldSubs
store.GroupBusiness = oldGroupBusiness
}()
topics.EXPECT().Get(types.TenantID(7), "grpnewgroup").Return(&types.Topic{
ObjHeader: types.ObjHeader{Id: "grpnewgroup"},
TenantID: 7,
Owner: types.Uid(42).String(),
SubCnt: 2,
Public: map[string]any{"name": "New Group", "avatar_file_id": "file-1", "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)
req := httptest.NewRequest(http.MethodPost, "/v1/im/groups", bytes.NewBufferString(`{"name":"New Group","description":"Project discussion","member_user_ids":["usrAgAAAAAAAAA"],"avatar_file_id":"file-1"}`))
req.Header.Set("Authorization", "Bearer token-1")
rec := httptest.NewRecorder()
serveIMGroups(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
if data["topic"] != "grpnewgroup" || data["name"] != "New Group" || 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
store.GroupBusiness = &testGroupBusinessStore{}
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/member-settings", bytes.NewBufferString(`{"pinned":true,"mute_notifications":true}`))
settingsReq.Header.Set("Authorization", "Bearer token-1")
settingsRec := httptest.NewRecorder()
serveIMGroupResource(settingsRec, settingsReq)
if settingsRec.Code != http.StatusOK {
t.Fatalf("settings status: got %d body=%s", settingsRec.Code, settingsRec.Body.String())
}
if data := decodeIMHTTPResponse(t, settingsRec).Data.(map[string]any); data["pinned"] != true || data["mute_notifications"] != true {
t.Fatalf("unexpected settings response: %#v", data)
}
profileReq := httptest.NewRequest(http.MethodPut, "/v1/im/groups/grpAlpha/members/me/profile", bytes.NewBufferString(`{"nickname":"群昵称","version":1}`))
profileReq.Header.Set("Authorization", "Bearer token-1")
profileRec := httptest.NewRecorder()
serveIMGroupResource(profileRec, profileReq)
if profileRec.Code != http.StatusOK {
t.Fatalf("profile status: got %d body=%s", profileRec.Code, profileRec.Body.String())
}
if data := decodeIMHTTPResponse(t, profileRec).Data.(map[string]any); data["nickname"] != "群昵称" || data["version"].(float64) != 2 {
t.Fatalf("unexpected profile response: %#v", data)
}
}
func TestServeIMGroupLeave(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
expectIMBearer(t, ctrl, "token-1", 7, 42)
oldTopics := store.Topics
oldSubs := store.Subs
oldGroupBusiness := store.GroupBusiness
topics := mock_store.NewMockTopicsPersistenceInterface(ctrl)
subs := mock_store.NewMockSubsPersistenceInterface(ctrl)
store.Topics = topics
store.Subs = subs
store.GroupBusiness = &testGroupBusinessStore{}
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)
oldSubs := store.Subs
oldGroupBusiness := store.GroupBusiness
subs := mock_store.NewMockSubsPersistenceInterface(ctrl)
groupStore := &testGroupBusinessStore{}
store.Subs = subs
store.GroupBusiness = groupStore
defer func() {
store.Subs = oldSubs
store.GroupBusiness = oldGroupBusiness
}()
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)
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"] != "joined" {
t.Fatalf("unexpected invitation response: %#v", data)
}
if len(groupStore.invitations) != 1 || groupStore.invitations[0].TargetUserID != target {
t.Fatalf("invitation was not persisted: %#v", groupStore.invitations)
}
}
func TestServeIMGroupQRCodesAndResolve(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
oldStore := store.Store
mockStore := mock_store.NewMockPersistentStorageInterface(ctrl)
tokenAuth := mock_auth.NewMockAuthHandler(ctrl)
mockStore.EXPECT().GetLogicalAuthHandler("token").Return(tokenAuth).Times(2)
tokenAuth.EXPECT().IsInitialized().Return(true).Times(2)
tokenAuth.EXPECT().Authenticate(auth.AuthContext{}, []byte("token-1")).
Return(&auth.Rec{TenantID: 7, Uid: 42, AuthLevel: auth.LevelAuth}, nil, nil)
tokenAuth.EXPECT().Authenticate(auth.AuthContext{}, []byte("token-2")).
Return(&auth.Rec{TenantID: 7, Uid: 43, AuthLevel: auth.LevelAuth}, nil, nil)
store.Store = mockStore
oldTopics := store.Topics
oldSubs := store.Subs
topics := mock_store.NewMockTopicsPersistenceInterface(ctrl)
subs := mock_store.NewMockSubsPersistenceInterface(ctrl)
store.Topics = topics
store.Subs = subs
defer func() {
store.Store = oldStore
store.Topics = oldTopics
store.Subs = oldSubs
}()
creatorSub := &types.Subscription{
TenantID: 7,
User: types.Uid(42).String(),
Topic: "grpAlpha",
ModeWant: types.ModeCPublic,
ModeGiven: types.ModeCPublic,
}
group := &types.Topic{
ObjHeader: types.ObjHeader{Id: "grpAlpha"},
TenantID: 7,
Owner: types.Uid(42).String(),
SubCnt: 3,
Public: map[string]any{"name": "Alpha", "version": 1},
}
subs.EXPECT().Get(types.TenantID(7), "grpAlpha", types.Uid(42), false).Return(creatorSub, nil)
topics.EXPECT().Get(types.TenantID(7), "grpAlpha").Return(group, nil)
topics.EXPECT().Get(types.TenantID(7), "grpAlpha").Return(group, nil)
subs.EXPECT().Get(types.TenantID(7), "grpAlpha", types.Uid(43), false).Return(nil, nil)
req := httptest.NewRequest(http.MethodPost, "/v1/im/groups/grpAlpha/qrcodes", bytes.NewBufferString(`{"expires_in_seconds":60}`))
req.Header.Set("Authorization", "Bearer token-1")
rec := httptest.NewRecorder()
serveIMGroupResource(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
created := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
token := created["qr_token"].(string)
if token == "" {
t.Fatalf("unexpected group qr response: %#v", created)
}
resolveReq := httptest.NewRequest(http.MethodPost, "/v1/im/qrcodes/resolve", bytes.NewBufferString(`{"token":"`+token+`","purpose":"join_group"}`))
resolveReq.Header.Set("Authorization", "Bearer token-2")
resolveRec := httptest.NewRecorder()
serveIMQRCodesResolve(resolveRec, resolveReq)
if resolveRec.Code != http.StatusOK {
t.Fatalf("status: got %d, want %d; body=%s", resolveRec.Code, http.StatusOK, resolveRec.Body.String())
}
resolved := decodeIMHTTPResponse(t, resolveRec).Data.(map[string]any)
groupPayload := resolved["group"].(map[string]any)
if resolved["status"] != "can_request" || groupPayload["name"] != "Alpha" {
t.Fatalf("unexpected group qr resolve response: %#v", resolved)
}
}
func TestServeIMGroupAnnouncementGovernanceJoinRequest(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
oldStore := store.Store
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)
mockStore.EXPECT().GetUidString().Return("ann-1")
mockStore.EXPECT().GetUidString().Return("join-1")
store.Store = mockStore
oldTopics := store.Topics
oldSubs := store.Subs
topics := mock_store.NewMockTopicsPersistenceInterface(ctrl)
subs := mock_store.NewMockSubsPersistenceInterface(ctrl)
store.Topics = topics
store.Subs = subs
store.GroupBusiness = &testGroupBusinessStore{}
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}
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)
annReq := httptest.NewRequest(http.MethodPut, "/v1/im/groups/grpAlpha/announcements/current", bytes.NewBufferString(`{"content":"公告","version":1}`))
annReq.Header.Set("Authorization", "Bearer token-1")
annRec := httptest.NewRecorder()
serveIMGroupResource(annRec, annReq)
if annRec.Code != http.StatusOK {
t.Fatalf("announcement status: got %d body=%s", annRec.Code, annRec.Body.String())
}
if data := decodeIMHTTPResponse(t, annRec).Data.(map[string]any); data["content"] != "公告" {
t.Fatalf("unexpected announcement response: %#v", data)
}
govReq := httptest.NewRequest(http.MethodPatch, "/v1/im/groups/grpAlpha/governance", bytes.NewBufferString(`{"join_approval":true,"version":1}`))
govReq.Header.Set("Authorization", "Bearer token-1")
govRec := httptest.NewRecorder()
serveIMGroupResource(govRec, govReq)
if govRec.Code != http.StatusOK {
t.Fatalf("governance status: got %d body=%s", govRec.Code, govRec.Body.String())
}
if data := decodeIMHTTPResponse(t, govRec).Data.(map[string]any); data["join_approval"] != true {
t.Fatalf("unexpected governance response: %#v", data)
}
joinReq := httptest.NewRequest(http.MethodPost, "/v1/im/groups/grpAlpha/join-requests", bytes.NewBufferString(`{"message":"申请"}`))
joinReq.Header.Set("Authorization", "Bearer token-1")
joinRec := httptest.NewRecorder()
serveIMGroupResource(joinRec, joinReq)
if joinRec.Code != http.StatusOK {
t.Fatalf("join request status: got %d body=%s", joinRec.Code, joinRec.Body.String())
}
if data := decodeIMHTTPResponse(t, joinRec).Data.(map[string]any); data["status"] != "pending" {
t.Fatalf("unexpected join request response: %#v", data)
}
}
func TestServeIMGroupAdminsMutesAndOwnerTransfer(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
oldStore := store.Store
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
store.GroupBusiness = &testGroupBusinessStore{}
defer func() {
store.Store = oldStore
store.Topics = oldTopics
store.Subs = oldSubs
store.GroupBusiness = oldGroupBusiness
}()
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())
}
muteReq := httptest.NewRequest(http.MethodPost, "/v1/im/groups/grpAlpha/mutes", bytes.NewBufferString(`{"members":[{"user_id":"`+target.UserId()+`"}],"reason":"test"}`))
muteReq.Header.Set("Authorization", "Bearer token-1")
muteRec := httptest.NewRecorder()
serveIMGroupResource(muteRec, muteReq)
if muteRec.Code != http.StatusOK {
t.Fatalf("mute status: got %d body=%s", muteRec.Code, muteRec.Body.String())
}
transferReq := httptest.NewRequest(http.MethodPost, "/v1/im/groups/grpAlpha/owner-transfer", bytes.NewBufferString(`{"target_user_id":"`+target.UserId()+`","version":1}`))
transferReq.Header.Set("Authorization", "Bearer token-1")
transferRec := httptest.NewRecorder()
serveIMGroupResource(transferRec, transferReq)
if transferRec.Code != http.StatusOK {
t.Fatalf("transfer status: got %d body=%s", transferRec.Code, transferRec.Body.String())
}
if data := decodeIMHTTPResponse(t, transferRec).Data.(map[string]any); data["owner"] != target.UserId() {
t.Fatalf("unexpected transfer response: %#v", data)
}
}
func TestServeIMFavoritesListDetailBatchDelete(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
oldStore := store.Store
mockStore := mock_store.NewMockPersistentStorageInterface(ctrl)
tokenAuth := mock_auth.NewMockAuthHandler(ctrl)
mockStore.EXPECT().GetLogicalAuthHandler("token").Return(tokenAuth).Times(3)
tokenAuth.EXPECT().IsInitialized().Return(true).Times(3)
tokenAuth.EXPECT().Authenticate(auth.AuthContext{}, []byte("token-1")).
Return(&auth.Rec{TenantID: 7, Uid: 42, AuthLevel: auth.LevelAuth}, nil, nil).Times(3)
store.Store = mockStore
oldFavorites := store.Favorites
now := time.Date(2026, 8, 13, 9, 0, 0, 0, time.UTC)
favorites := &testFavoritesStore{items: []types.FavoriteItem{{
TenantID: 7,
FavoriteID: "fav_1",
UserID: 42,
ItemType: "text",
SourceTopic: "grpAlpha",
SourceSeq: 3,
Snapshot: map[string]any{"text": "hello"},
Availability: "available",
CollectedAt: now,
}}}
store.Favorites = favorites
defer func() {
store.Store = oldStore
store.Favorites = oldFavorites
}()
req := httptest.NewRequest(http.MethodGet, "/v1/im/favorites?type=text", nil)
req.Header.Set("Authorization", "Bearer token-1")
rec := httptest.NewRecorder()
serveIMFavorites(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("list status: got %d body=%s", rec.Code, rec.Body.String())
}
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
items := data["items"].([]any)
if len(items) != 1 || items[0].(map[string]any)["favorite_id"] != "fav_1" {
t.Fatalf("unexpected favorites list: %#v", data)
}
detailReq := httptest.NewRequest(http.MethodGet, "/v1/im/favorites/fav_1", nil)
detailReq.Header.Set("Authorization", "Bearer token-1")
detailRec := httptest.NewRecorder()
serveIMFavoriteResource(detailRec, detailReq)
if detailRec.Code != http.StatusOK {
t.Fatalf("detail status: got %d body=%s", detailRec.Code, detailRec.Body.String())
}
deleteReq := httptest.NewRequest(http.MethodPost, "/v1/im/favorites/batch-delete", bytes.NewBufferString(`{"favorite_ids":["fav_1"]}`))
deleteReq.Header.Set("Authorization", "Bearer token-1")
deleteRec := httptest.NewRecorder()
serveIMFavoriteResource(deleteRec, deleteReq)
if deleteRec.Code != http.StatusOK || len(favorites.deleted) != 1 {
t.Fatalf("delete status: got %d body=%s deleted=%#v", deleteRec.Code, deleteRec.Body.String(), favorites.deleted)
}
}
func 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
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,
}}}
messages := mock_store.NewMockMessagesPersistenceInterface(ctrl)
store.Messages = messages
t.Cleanup(func() {
store.Favorites = oldFavorites
store.Messages = oldMessages
})
messages.EXPECT().GetOne(types.TenantID(7), "grpAlpha", 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 TestServeIMFavoriteForwardSavesMessages(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
oldStore := store.Store
mockStore := mock_store.NewMockPersistentStorageInterface(ctrl)
tokenAuth := mock_auth.NewMockAuthHandler(ctrl)
mockStore.EXPECT().GetLogicalAuthHandler("token").Return(tokenAuth)
tokenAuth.EXPECT().IsInitialized().Return(true)
tokenAuth.EXPECT().Authenticate(auth.AuthContext{}, []byte("token-1")).
Return(&auth.Rec{TenantID: 7, Uid: 42, AuthLevel: auth.LevelAuth}, nil, nil)
store.Store = mockStore
oldFavorites := store.Favorites
oldSubs := store.Subs
oldMessages := store.Messages
now := time.Date(2026, 8, 13, 9, 0, 0, 0, time.UTC)
store.Favorites = &testFavoritesStore{items: []types.FavoriteItem{{
TenantID: 7,
FavoriteID: "fav_1",
UserID: 42,
ItemType: "text",
SourceTopic: "grpAlpha",
SourceSeq: 3,
Snapshot: map[string]any{"text": "hello"},
Availability: "available",
CollectedAt: now,
}}}
subs := mock_store.NewMockSubsPersistenceInterface(ctrl)
messages := mock_store.NewMockMessagesPersistenceInterface(ctrl)
store.Subs = subs
store.Messages = messages
defer func() {
store.Store = oldStore
store.Favorites = oldFavorites
store.Subs = oldSubs
store.Messages = oldMessages
}()
subs.EXPECT().Get(types.TenantID(7), "grpBeta", types.Uid(42), false).Return(&types.Subscription{
TenantID: 7,
User: types.Uid(42).String(),
Topic: "grpBeta",
ModeWant: types.ModeCPublic,
ModeGiven: types.ModeCPublic,
}, nil)
messages.EXPECT().Save(types.TenantID(7), gomock.Any(), gomock.Nil(), true).DoAndReturn(
func(_ types.TenantID, msg *types.Message, _ []string, _ bool) (*types.MessageSaveResult, error) {
if msg.Topic != "grpBeta" || msg.From != types.Uid(42).String() {
t.Fatalf("unexpected forwarded message: %#v", msg)
}
if msg.Head["x-im-forwarded"] != true {
t.Fatalf("forward marker not set: %#v", msg.Head)
}
content := msg.Content.(map[string]any)
if content["favorite_id"] != "fav_1" || content["comment"] != "FYI" {
t.Fatalf("unexpected forward content: %#v", content)
}
return &types.MessageSaveResult{SeqId: 11, MarkedReadBySender: true}, nil
})
req := httptest.NewRequest(http.MethodPost, "/v1/im/favorites/forward", bytes.NewBufferString(`{"favorite_ids":["fav_1"],"target_topics":["grpBeta"],"comment":"FYI"}`))
req.Header.Set("Authorization", "Bearer token-1")
rec := httptest.NewRecorder()
serveIMFavoriteResource(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("forward status: got %d body=%s", rec.Code, rec.Body.String())
}
items := decodeIMHTTPResponse(t, rec).Data.(map[string]any)["items"].([]any)
if got := items[0].(map[string]any); got["status"] != "sent" || got["seq"].(float64) != 11 {
t.Fatalf("unexpected forward response: %#v", got)
}
}
func 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 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 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.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())
}
contextReq := httptest.NewRequest(http.MethodGet, "/v1/im/topics/grpAlpha/messages/3/context", nil)
contextReq.Header.Set("Authorization", "Bearer token-1")
contextRec := httptest.NewRecorder()
serveIMTopicMessageAction(contextRec, contextReq)
if contextRec.Code != http.StatusOK {
t.Fatalf("context status: got %d body=%s", contextRec.Code, contextRec.Body.String())
}
mediaReq := httptest.NewRequest(http.MethodGet, "/v1/im/topics/grpAlpha/media?type=image", nil)
mediaReq.Header.Set("Authorization", "Bearer token-1")
mediaRec := httptest.NewRecorder()
serveIMTopicMessageAction(mediaRec, mediaReq)
if mediaRec.Code != http.StatusOK {
t.Fatalf("media status: got %d body=%s", mediaRec.Code, mediaRec.Body.String())
}
}
func 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 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", 9).Return(&types.Message{
TenantID: 7,
SeqId: 9,
Topic: "grpRecall",
From: types.Uid(42).String(),
Version: 1,
ObjHeader: types.ObjHeader{CreatedAt: time.Now().Add(-time.Second)},
}, nil)
messages.EXPECT().Recall(types.TenantID(7), "grpRecall", 9, types.Uid(42), 1, gomock.Any()).Return(&types.Message{
TenantID: 7,
SeqId: 9,
Topic: "grpRecall",
From: "42",
Version: 2,
RecalledAt: ptrTime(time.Now().UTC()),
RecalledBy: "42",
ObjHeader: types.ObjHeader{UpdatedAt: time.Now().UTC()},
}, nil)
req := httptest.NewRequest(http.MethodPost, "/v1/im/topics/grpRecall/messages/9/recall", bytes.NewBufferString(`{"version":1}`))
req.Header.Set("Authorization", "Bearer token-1")
rec := httptest.NewRecorder()
serveIMMessageRecall(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
if data["status"] != "recalled" || data["version"].(float64) != 2 {
t.Fatalf("unexpected recall response: %#v", data)
}
}
func TestServeIMMessageRevision(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
expectIMBearer(t, ctrl, "token-1", 7, 42)
oldSubs := store.Subs
oldMessages := store.Messages
subs := mock_store.NewMockSubsPersistenceInterface(ctrl)
messages := mock_store.NewMockMessagesPersistenceInterface(ctrl)
store.Subs = subs
store.Messages = messages
defer func() {
store.Subs = oldSubs
store.Messages = oldMessages
}()
subs.EXPECT().Get(types.TenantID(7), "grpEdit", types.Uid(42), false).Return(&types.Subscription{
TenantID: 7,
User: "42",
Topic: "grpEdit",
ModeWant: types.ModeCFull,
ModeGiven: types.ModeCFull,
}, nil)
messages.EXPECT().GetOne(types.TenantID(7), "grpEdit", 3).Return(&types.Message{
TenantID: 7,
SeqId: 3,
Topic: "grpEdit",
From: types.Uid(42).String(),
Version: 1,
Head: types.KVMap{"type": "text"},
Content: "old",
ObjHeader: types.ObjHeader{CreatedAt: time.Now().Add(-time.Second)},
}, nil)
messages.EXPECT().Edit(types.TenantID(7), "grpEdit", 3, types.Uid(42), 1, gomock.Any(), "new", "", gomock.Any()).Return(&types.Message{
TenantID: 7,
SeqId: 3,
Topic: "grpEdit",
From: "42",
Version: 2,
Head: types.KVMap{"type": "text"},
Content: "new",
ObjHeader: types.ObjHeader{UpdatedAt: time.Now().UTC()},
}, nil)
req := httptest.NewRequest(http.MethodPost, "/v1/im/topics/grpEdit/messages/3/revisions", bytes.NewBufferString(`{"version":1,"content":"new"}`))
req.Header.Set("Authorization", "Bearer token-1")
rec := httptest.NewRecorder()
serveIMMessageRevision(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
if data["status"] != "edited" || data["version"].(float64) != 2 || data["content"] != "new" {
t.Fatalf("unexpected revision response: %#v", data)
}
}
func TestServeIMConversationContext(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
expectIMBearer(t, ctrl, "token-1", 7, 1)
oldRelations := store.Relations
peer := types.Uid(2)
topic := types.Uid(1).P2PName(peer)
store.Relations = &testRelationsStore{context: &types.RelationshipContext{
TenantID: 7,
UserID: 1,
PeerID: peer,
Topic: topic,
Relationship: types.RelationshipFriend,
CanSend: true,
}}
defer func() { store.Relations = oldRelations }()
req := httptest.NewRequest(http.MethodGet, "/v1/im/conversations/"+topic+"/context", nil)
req.Header.Set("Authorization", "Bearer token-1")
rec := httptest.NewRecorder()
serveIMConversationContext(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
if data["relationship"] != types.RelationshipFriend || data["can_send"] != true {
t.Fatalf("unexpected context response: %#v", data)
}
}
func TestServeIMFriendRequestCreate(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
mockStore := expectIMBearer(t, ctrl, "token-1", 7, 1)
mockStore.EXPECT().GetUidString().Return("request-1")
oldRelations := store.Relations
oldProfiles := store.Profiles
relations := &testRelationsStore{}
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 TestServeIMFriendRequestList(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
expectIMBearer(t, ctrl, "token-1", 7, 1)
oldRelations := store.Relations
now := time.Now().UTC().Round(time.Millisecond)
relations := &testRelationsStore{friendRequests: []types.FriendRequestListItem{{
Request: types.FriendRequest{
TenantID: 7,
RequestID: "frq_1",
ApplicantID: 2,
TargetID: 1,
Message: "hi",
Source: "search",
Status: types.FriendRequestPending,
CreatedAt: now,
UpdatedAt: now,
ExpiresAt: now.Add(time.Hour),
},
Peer: &types.User{ObjHeader: types.ObjHeader{Id: types.Uid(2).String()}, TenantID: 7, Public: map[string]any{"display_name": "Alice"}},
Direction: "inbound",
}}}
store.Relations = relations
defer func() { store.Relations = oldRelations }()
req := httptest.NewRequest(http.MethodGet, "/v1/im/friend-requests?direction=inbound&status=pending", nil)
req.Header.Set("Authorization", "Bearer token-1")
rec := httptest.NewRecorder()
serveIMFriendRequests(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
items := data["items"].([]any)
if len(items) != 1 || items[0].(map[string]any)["direction"] != "inbound" {
t.Fatalf("unexpected friend request list: %#v", data)
}
}
func TestServeIMFriendsList(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
expectIMBearer(t, ctrl, "token-1", 7, 1)
oldRelations := store.Relations
now := time.Now().UTC().Round(time.Millisecond)
peer := types.Uid(2)
relations := &testRelationsStore{friends: []types.FriendListItem{{
Friendship: types.Friendship{
TenantID: 7,
UserID: 1,
PeerID: peer,
Status: types.RelationshipFriend,
Remark: "小明",
Topic: types.Uid(1).P2PName(peer),
CreatedAt: now,
UpdatedAt: now,
},
Peer: &types.User{ObjHeader: types.ObjHeader{Id: peer.String()}, TenantID: 7, Public: map[string]any{"display_name": "Ming"}},
}}}
store.Relations = relations
defer func() { store.Relations = oldRelations }()
req := httptest.NewRequest(http.MethodGet, "/v1/im/friends?query=ming", nil)
req.Header.Set("Authorization", "Bearer token-1")
rec := httptest.NewRecorder()
serveIMFriends(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
items := data["items"].([]any)
if len(items) != 1 || items[0].(map[string]any)["display_name"] != "小明" {
t.Fatalf("unexpected friends list: %#v", data)
}
}
func TestServeIMFriendRemark(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
expectIMBearer(t, ctrl, "token-1", 7, 1)
oldRelations := store.Relations
peer := types.Uid(2)
store.Relations = &testRelationsStore{}
defer func() { store.Relations = oldRelations }()
req := httptest.NewRequest(http.MethodPatch, "/v1/im/friends/"+peer.UserId(), bytes.NewBufferString(`{"remark":"小明"}`))
req.Header.Set("Authorization", "Bearer token-1")
rec := httptest.NewRecorder()
serveIMFriendResource(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
if data["relationship"] != types.RelationshipFriend || data["topic"] == "" {
t.Fatalf("unexpected friend remark response: %#v", data)
}
}
func 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,
Public: map[string]any{"display_name": "Alice", "avatar_file_id": "file-1", "signature": "hello"},
}, nil)
store.Users = users
store.Relations = &testRelationsStore{context: &types.RelationshipContext{
TenantID: 7,
UserID: 1,
PeerID: peer,
Topic: types.Uid(1).P2PName(peer),
Relationship: types.RelationshipFriend,
CanSend: true,
}}
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"] == "" {
t.Fatalf("unexpected profile response: %#v", 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
}