From ac45223e399e91600d6de099200630b7b4e0e851 Mon Sep 17 00:00:00 2001 From: richjohnrambo Date: Wed, 2 Sep 2026 10:01:38 +0800 Subject: [PATCH] Fix IM report and group permission regressions --- .../db/mysql/report_group_regression_test.go | 56 ++++++ server/db/mysql/tenant_adapter.go | 182 +++++++++++++++--- server/db/mysql/tenant_groups.go | 14 +- server/im_http.go | 8 +- server/im_http_test.go | 78 +++++++- 5 files changed, 299 insertions(+), 39 deletions(-) create mode 100644 server/db/mysql/report_group_regression_test.go diff --git a/server/db/mysql/report_group_regression_test.go b/server/db/mysql/report_group_regression_test.go new file mode 100644 index 0000000..5735b70 --- /dev/null +++ b/server/db/mysql/report_group_regression_test.go @@ -0,0 +1,56 @@ +//go:build mysql + +package mysql + +import ( + "os" + "strings" + "testing" + + "github.com/tinode/chat/server/store/types" +) + +func TestCanUseManagedReportsRequiresAllOrganizationVisibility(t *testing.T) { + if !canUseManagedReports(organizationVisibility{Scope: orgVisibilityAll}) { + t.Fatal("all visibility should be allowed to use managed report scope") + } + for _, scope := range []string{orgVisibilitySelf, orgVisibilityFriends, ""} { + if canUseManagedReports(organizationVisibility{Scope: scope}) { + t.Fatalf("scope %q should not be allowed to use managed report scope", scope) + } + } +} + +func TestReportMemberScopeWhereIncludesDepartmentAndVisibility(t *testing.T) { + where, args := reportMemberScopeWhere(organizationVisibility{Scope: orgVisibilityFriends, UserID: 42}, "dep_ops") + joined := strings.Join(where, " AND ") + if !strings.Contains(joined, "m.department_id=?") { + t.Fatalf("department filter missing from where: %s", joined) + } + if !strings.Contains(joined, "im_friendships") { + t.Fatalf("friend visibility filter missing from where: %s", joined) + } + if len(args) != 4 || args[0] != "dep_ops" || args[1] != int64(42) || args[2] != int64(42) || args[3] != types.RelationshipFriend { + t.Fatalf("unexpected args: %#v", args) + } +} + +func TestGroupAdminAccessModeDoesNotGrantOwner(t *testing.T) { + mode := groupAdminAccessMode() + if !mode.IsAdmin() { + t.Fatalf("admin mode should grant admin access: %s", mode.String()) + } + if mode.IsOwner() { + t.Fatalf("admin mode must not grant owner access: %s", mode.String()) + } +} + +func TestMessageSearchDoesNotClearScannedHead(t *testing.T) { + source, err := os.ReadFile("tenant_adapter.go") + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(source), "msg.Head = kvMapFromJSON(msg.Head)") { + t.Fatal("message search must not clear Head after sqlx StructScan has already decoded it") + } +} diff --git a/server/db/mysql/tenant_adapter.go b/server/db/mysql/tenant_adapter.go index 0b29c1d..2e27e93 100644 --- a/server/db/mysql/tenant_adapter.go +++ b/server/db/mysql/tenant_adapter.go @@ -2337,7 +2337,6 @@ func (a *adapter) TenantMessageSearch(id t.TenantID, topic string, uid t.Uid, qu return nil, "", err } msg := finalizeTenantMessage(id, row.Message, row.RecalledBy) - msg.Head = kvMapFromJSON(msg.Head) if len(msgs) == limit { nextCursor = strconv.FormatInt(msg.SeqId, 10) continue @@ -2398,7 +2397,6 @@ func (a *adapter) tenantGroupMessageSearch(id t.TenantID, topic string, uid t.Ui return nil, "", err } msg := normalizeGroupMessage(id, row.Message, row.RecalledBy) - msg.Head = kvMapFromJSON(msg.Head) if len(msgs) == limit { nextCursor = strconv.FormatInt(msg.SeqId, 10) continue @@ -4593,12 +4591,24 @@ func (a *adapter) TenantDailyReportList(id t.TenantID, uid t.Uid, scope, from, t if limit <= 0 || limit > 100 { limit = 50 } + ctx, cancel := a.getContext() + if cancel != nil { + defer cancel() + } where := []string{"1=1"} args := []any{} if scope == "" || scope == "mine" { where = append(where, "author_id=?") args = append(args, decodeUID(uid)) - } else if scope != "managed" { + } else if scope == "managed" { + visibility, err := a.organizationVisibility(ctx, uid) + if err != nil { + return nil, "", err + } + if !canUseManagedReports(visibility) { + return nil, "", t.ErrPermissionDenied + } + } else { return nil, "", t.ErrMalformed } if from != "" { @@ -4622,10 +4632,6 @@ func (a *adapter) TenantDailyReportList(id t.TenantID, uid t.Uid, scope, from, t args = append(args, parsed) } args = append(args, limit+1) - ctx, cancel := a.getContext() - if cancel != nil { - defer cancel() - } rows, err := a.db.QueryxContext(ctx, `SELECT * FROM im_daily_reports WHERE `+strings.Join(where, " AND ")+` ORDER BY id DESC LIMIT ?`, args...) if err != nil { return nil, "", err @@ -4769,14 +4775,14 @@ func (a *adapter) TenantDailyReportSubmit(id t.TenantID, uid t.Uid, reportID str return a.TenantDailyReportGet(id, uid, reportID) } -func (a *adapter) TenantDailyReportStats(id t.TenantID, uid t.Uid, date time.Time, _ string) (*t.DailyReportStats, error) { +func (a *adapter) TenantDailyReportStats(id t.TenantID, uid t.Uid, date time.Time, departmentID string) (*t.DailyReportStats, error) { if !a.tenantScopedDB { tenantAdp, err := a.routedTenantAdapter(id) if err != nil { return nil, err } if tenantAdp != a { - return tenantAdp.TenantDailyReportStats(id, uid, date, "") + return tenantAdp.TenantDailyReportStats(id, uid, date, departmentID) } } if err := validTenant(id); err != nil || uid.IsZero() { @@ -4786,11 +4792,22 @@ func (a *adapter) TenantDailyReportStats(id t.TenantID, uid t.Uid, date time.Tim if cancel != nil { defer cancel() } - stats := &t.DailyReportStats{} - if err := a.db.QueryRowContext(ctx, `SELECT COUNT(DISTINCT user_id) FROM im_org_members WHERE employment_state='active'`).Scan(&stats.ExpectedCount); err != nil { + visibility, err := a.organizationVisibility(ctx, uid) + if err != nil { return nil, err } - if err := a.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM im_daily_reports WHERE report_date=? AND status=?`, date.Format("2006-01-02"), t.DailyReportStatusSubmitted).Scan(&stats.SubmittedCount); err != nil { + memberWhere, memberArgs := reportMemberScopeWhere(visibility, departmentID) + stats := &t.DailyReportStats{} + if err := a.db.QueryRowContext(ctx, `SELECT COUNT(DISTINCT m.user_id) FROM im_org_members AS m WHERE `+strings.Join(memberWhere, " AND "), memberArgs...).Scan(&stats.ExpectedCount); err != nil { + return nil, err + } + submittedWhere := []string{"r.report_date=?", "r.status=?"} + submittedArgs := []any{date.Format("2006-01-02"), t.DailyReportStatusSubmitted} + if len(memberWhere) > 0 { + submittedWhere = append(submittedWhere, `EXISTS (SELECT 1 FROM im_org_members AS m WHERE m.user_id=r.author_id AND `+strings.Join(memberWhere, " AND ")+`)`) + submittedArgs = append(submittedArgs, memberArgs...) + } + if err := a.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM im_daily_reports AS r WHERE `+strings.Join(submittedWhere, " AND "), submittedArgs...).Scan(&stats.SubmittedCount); err != nil { return nil, err } stats.MissingCount = stats.ExpectedCount - stats.SubmittedCount @@ -4800,20 +4817,114 @@ func (a *adapter) TenantDailyReportStats(id t.TenantID, uid t.Uid, date time.Tim return stats, nil } -func (a *adapter) TenantDailyReportRemind(id t.TenantID, uid t.Uid, _ time.Time, userIDs []t.Uid, _ string) ([]t.Uid, error) { +func reportMemberScopeWhere(visibility organizationVisibility, departmentID string) ([]string, []any) { + memberWhere := []string{"m.employment_state='active'"} + memberArgs := []any{} + if strings.TrimSpace(departmentID) != "" { + memberWhere = append(memberWhere, "m.department_id=?") + memberArgs = append(memberArgs, strings.TrimSpace(departmentID)) + } + visibilitySQL, visibilityArgs := visibility.memberWhereSQL("m") + if visibilitySQL != "" { + memberWhere = append(memberWhere, visibilitySQL) + memberArgs = append(memberArgs, visibilityArgs...) + } + return memberWhere, memberArgs +} + +func canUseManagedReports(visibility organizationVisibility) bool { + return visibility.Scope == orgVisibilityAll +} + +func (a *adapter) TenantDailyReportRemind(id t.TenantID, uid t.Uid, date time.Time, userIDs []t.Uid, idempotencyKey string) ([]t.Uid, error) { if !a.tenantScopedDB { tenantAdp, err := a.routedTenantAdapter(id) if err != nil { return nil, err } if tenantAdp != a { - return tenantAdp.TenantDailyReportRemind(id, uid, time.Time{}, userIDs, "") + return tenantAdp.TenantDailyReportRemind(id, uid, date, userIDs, idempotencyKey) } } - if err := validTenant(id); err != nil || uid.IsZero() { + if err := validTenant(id); err != nil || uid.IsZero() || date.IsZero() || len(userIDs) == 0 || strings.TrimSpace(idempotencyKey) == "" { return nil, t.ErrMalformed } - return userIDs, nil + ctx, cancel := a.getContext() + if cancel != nil { + defer cancel() + } + visibility, err := a.organizationVisibility(ctx, uid) + if err != nil { + return nil, err + } + if !canUseManagedReports(visibility) { + return nil, t.ErrPermissionDenied + } + targetIDs := make([]int64, 0, len(userIDs)) + seen := map[int64]struct{}{} + for _, target := range userIDs { + if target.IsZero() { + return nil, t.ErrMalformed + } + decoded := decodeUID(target) + if decoded <= 0 { + return nil, t.ErrMalformed + } + if _, ok := seen[decoded]; ok { + continue + } + seen[decoded] = struct{}{} + targetIDs = append(targetIDs, decoded) + } + query, args, err := sqlx.In(`SELECT DISTINCT m.user_id FROM im_org_members AS m + WHERE m.employment_state='active' AND m.user_id IN (?) AND NOT EXISTS ( + SELECT 1 FROM im_daily_reports AS r + WHERE r.author_id=m.user_id AND r.report_date=? AND r.status=? + )`, targetIDs, date.Format("2006-01-02"), t.DailyReportStatusSubmitted) + if err != nil { + return nil, err + } + query = a.db.Rebind(query) + rows, err := a.db.QueryxContext(ctx, query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + reminded := make([]t.Uid, 0, len(targetIDs)) + for rows.Next() { + var raw int64 + if err = rows.Scan(&raw); err != nil { + return nil, err + } + target := store.EncodeUid(raw) + reminded = append(reminded, target) + } + if err = rows.Err(); err != nil { + return nil, err + } + tx, err := a.db.BeginTxx(ctx, nil) + if err != nil { + return nil, err + } + committed := false + defer func() { + if !committed { + _ = tx.Rollback() + } + }() + for _, target := range reminded { + eventID := t.StableEventID("daily_report_reminder", date.Format("2006-01-02"), target.UserId(), strings.TrimSpace(idempotencyKey)) + if err = insertSystemNotificationTx(tx, target, eventID, "daily_report.reminded", "日报提醒", date.Format("2006-01-02"), map[string]any{ + "date": date.Format("2006-01-02"), + }); err != nil { + return nil, err + } + } + if err = tx.Commit(); err != nil { + return nil, err + } + committed = true + return reminded, nil } func scanDailyReport(rows *sqlx.Rows) (*t.DailyReport, error) { @@ -4869,12 +4980,24 @@ func (a *adapter) TenantMonthlyReportList(id t.TenantID, uid t.Uid, scope, fromM if limit <= 0 || limit > 100 { limit = 50 } + ctx, cancel := a.getContext() + if cancel != nil { + defer cancel() + } where := []string{"1=1"} args := []any{} if scope == "" || scope == "mine" { where = append(where, "author_id=?") args = append(args, decodeUID(uid)) - } else if scope != "managed" { + } else if scope == "managed" { + visibility, err := a.organizationVisibility(ctx, uid) + if err != nil { + return nil, "", err + } + if !canUseManagedReports(visibility) { + return nil, "", t.ErrPermissionDenied + } + } else { return nil, "", t.ErrMalformed } if fromMonth != "" { @@ -4898,10 +5021,6 @@ func (a *adapter) TenantMonthlyReportList(id t.TenantID, uid t.Uid, scope, fromM args = append(args, parsed) } args = append(args, limit+1) - ctx, cancel := a.getContext() - if cancel != nil { - defer cancel() - } rows, err := a.db.QueryxContext(ctx, `SELECT * FROM im_monthly_reports WHERE `+strings.Join(where, " AND ")+` ORDER BY id DESC LIMIT ?`, args...) if err != nil { return nil, "", err @@ -5076,14 +5195,14 @@ func (a *adapter) TenantMonthlyReportReopen(id t.TenantID, uid t.Uid, reportID s return a.TenantMonthlyReportGet(id, uid, reportID) } -func (a *adapter) TenantMonthlyReportStats(id t.TenantID, uid t.Uid, month, _ string) (*t.MonthlyReportStats, error) { +func (a *adapter) TenantMonthlyReportStats(id t.TenantID, uid t.Uid, month, departmentID string) (*t.MonthlyReportStats, error) { if !a.tenantScopedDB { tenantAdp, err := a.routedTenantAdapter(id) if err != nil { return nil, err } if tenantAdp != a { - return tenantAdp.TenantMonthlyReportStats(id, uid, month, "") + return tenantAdp.TenantMonthlyReportStats(id, uid, month, departmentID) } } if err := validTenant(id); err != nil || uid.IsZero() { @@ -5093,11 +5212,22 @@ func (a *adapter) TenantMonthlyReportStats(id t.TenantID, uid t.Uid, month, _ st if cancel != nil { defer cancel() } - stats := &t.MonthlyReportStats{} - if err := a.db.QueryRowContext(ctx, `SELECT COUNT(DISTINCT user_id) FROM im_org_members WHERE employment_state='active'`).Scan(&stats.ExpectedCount); err != nil { + visibility, err := a.organizationVisibility(ctx, uid) + if err != nil { return nil, err } - if err := a.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM im_monthly_reports WHERE report_month=? AND status=?`, month, t.DailyReportStatusSubmitted).Scan(&stats.SubmittedCount); err != nil { + memberWhere, memberArgs := reportMemberScopeWhere(visibility, departmentID) + stats := &t.MonthlyReportStats{} + if err := a.db.QueryRowContext(ctx, `SELECT COUNT(DISTINCT m.user_id) FROM im_org_members AS m WHERE `+strings.Join(memberWhere, " AND "), memberArgs...).Scan(&stats.ExpectedCount); err != nil { + return nil, err + } + submittedWhere := []string{"r.report_month=?", "r.status=?"} + submittedArgs := []any{month, t.DailyReportStatusSubmitted} + if len(memberWhere) > 0 { + submittedWhere = append(submittedWhere, `EXISTS (SELECT 1 FROM im_org_members AS m WHERE m.user_id=r.author_id AND `+strings.Join(memberWhere, " AND ")+`)`) + submittedArgs = append(submittedArgs, memberArgs...) + } + if err := a.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM im_monthly_reports AS r WHERE `+strings.Join(submittedWhere, " AND "), submittedArgs...).Scan(&stats.SubmittedCount); err != nil { return nil, err } stats.MissingCount = stats.ExpectedCount - stats.SubmittedCount diff --git a/server/db/mysql/tenant_groups.go b/server/db/mysql/tenant_groups.go index 1e51e8d..eaa79b0 100644 --- a/server/db/mysql/tenant_groups.go +++ b/server/db/mysql/tenant_groups.go @@ -1170,8 +1170,8 @@ func (a *adapter) TenantGroupRoleUpdate(id t.TenantID, topic string, actor, targ return err } defer tx.Rollback() - res, err := tx.ExecContext(ctx, "UPDATE im_subscriptions SET modegiven=?,updatedat=? WHERE topic=? AND userid=? AND deletedat IS NULL", - modeGiven.String(), now, topic, decodeUID(target)) + res, err := tx.ExecContext(ctx, "UPDATE im_subscriptions SET modegiven=?,modewant=?,updatedat=? WHERE topic=? AND userid=? AND deletedat IS NULL", + modeGiven.String(), modeGiven.String(), now, topic, decodeUID(target)) if err != nil { return err } @@ -1194,6 +1194,10 @@ func (a *adapter) TenantGroupRoleUpdate(id t.TenantID, topic string, actor, targ return tx.Commit() } +func groupAdminAccessMode() t.AccessMode { + return t.ModeCPublic | t.ModeApprove +} + func (a *adapter) TenantGroupOwnerTransfer(id t.TenantID, topic string, previousOwner, newOwner t.Uid, version int, now time.Time) error { if tenantAdp, err := a.routeGroupTenant(id); err != nil { @@ -1214,10 +1218,10 @@ func (a *adapter) TenantGroupOwnerTransfer(id t.TenantID, topic string, previous uid t.Uid mode t.AccessMode role string - }{{previousOwner, t.ModeCAdmin | t.ModeCPublic, t.GroupMemberRoleAdmin}, {newOwner, t.ModeCFull, t.GroupMemberRoleOwner}} + }{{previousOwner, groupAdminAccessMode(), t.GroupMemberRoleAdmin}, {newOwner, t.ModeCFull, t.GroupMemberRoleOwner}} for _, update := range updates { - res, execErr := tx.ExecContext(ctx, "UPDATE im_subscriptions SET modegiven=?,updatedat=? WHERE topic=? AND userid=? AND deletedat IS NULL", - update.mode.String(), now, topic, decodeUID(update.uid)) + res, execErr := tx.ExecContext(ctx, "UPDATE im_subscriptions SET modegiven=?,modewant=?,updatedat=? WHERE topic=? AND userid=? AND deletedat IS NULL", + update.mode.String(), update.mode.String(), now, topic, decodeUID(update.uid)) if execErr != nil { return execErr } diff --git a/server/im_http.go b/server/im_http.go index e1cd8db..b870b20 100644 --- a/server/im_http.go +++ b/server/im_http.go @@ -2381,8 +2381,8 @@ func serveIMDailyReports(wrt http.ResponseWriter, req *http.Request) { "permissions": map[string]any{ "can_create": true, "makeup_allowed_days": 7, - "can_view_managed": true, - "can_remind_unsubmitted": true, + "can_view_managed": false, + "can_remind_unsubmitted": false, }, }) case http.MethodPost: @@ -3882,6 +3882,7 @@ func serveIMMessageRecall(wrt http.ResponseWriter, req *http.Request) { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil) return } + topic = normalizeConversationSettingsTopic(topic, principal.Uid) var body imMessageRecallRequest if !decodeIMJSONBody(wrt, req, &body) || body.Version <= 0 { if body.Version <= 0 { @@ -3933,6 +3934,7 @@ func serveIMMessageRevision(wrt http.ResponseWriter, req *http.Request) { writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", nil) return } + topic = normalizeConversationSettingsTopic(topic, principal.Uid) var body imMessageRevisionRequest if !decodeIMJSONBody(wrt, req, &body) { return @@ -5294,7 +5296,7 @@ func serveIMGroupAdmins(wrt http.ResponseWriter, req *http.Request, topic, tail } item["user_id"] = uid.UserId() if err := store.GroupBusiness.UpdateRole(principal.TenantID, topic, principal.Uid, uid, - types.GroupMemberRoleAdmin, types.ModeCAdmin|types.ModeCPublic, time.Now().UTC().Round(time.Millisecond)); err != nil { + types.GroupMemberRoleAdmin, types.ModeCPublic|types.ModeApprove, time.Now().UTC().Round(time.Millisecond)); err != nil { item["ok"] = false item["reason"] = relationErrorReason(err) } else { diff --git a/server/im_http_test.go b/server/im_http_test.go index a60f9fa..72e0e0d 100644 --- a/server/im_http_test.go +++ b/server/im_http_test.go @@ -333,6 +333,7 @@ type testGroupBusinessStore struct { invitations []types.GroupInvitation updatedSettings types.GroupSettingsPatch updatedMembers []types.GroupMemberPatch + updatedRoleMode types.AccessMode } func (s *testGroupBusinessStore) CreateGroup(_ types.TenantID, _ *types.Topic, _ types.Uid, _ any, @@ -517,8 +518,9 @@ func (s *testGroupBusinessStore) Leave(_ types.TenantID, _ string, _ types.Uid, } func (s *testGroupBusinessStore) UpdateRole(_ types.TenantID, topic string, _ types.Uid, target types.Uid, - role string, _ types.AccessMode, _ time.Time) error { + role string, mode types.AccessMode, _ time.Time) error { s.updatedMembers = append(s.updatedMembers, types.GroupMemberPatch{Role: &role}) + s.updatedRoleMode = mode s.member = &types.GroupMember{Topic: topic, UserID: target, Role: role, MemberStatus: types.GroupMemberStatusActive} return nil } @@ -3692,7 +3694,8 @@ func TestServeIMGroupDetailPatchSettingsProfileAndLeave(t *testing.T) { subs := mock_store.NewMockSubsPersistenceInterface(ctrl) store.Topics = topics store.Subs = subs - store.GroupBusiness = &testGroupBusinessStore{} + groupStore := &testGroupBusinessStore{} + store.GroupBusiness = groupStore defer func() { store.Store = oldStore store.Topics = oldTopics @@ -3771,7 +3774,8 @@ func TestServeIMGroupLeave(t *testing.T) { subs := mock_store.NewMockSubsPersistenceInterface(ctrl) store.Topics = topics store.Subs = subs - store.GroupBusiness = &testGroupBusinessStore{} + groupStore := &testGroupBusinessStore{} + store.GroupBusiness = groupStore defer func() { store.Topics = oldTopics store.Subs = oldSubs @@ -4020,7 +4024,8 @@ func TestServeIMGroupAnnouncementGovernanceJoinRequest(t *testing.T) { subs := mock_store.NewMockSubsPersistenceInterface(ctrl) store.Topics = topics store.Subs = subs - store.GroupBusiness = &testGroupBusinessStore{} + groupStore := &testGroupBusinessStore{} + store.GroupBusiness = groupStore defer func() { store.Store = oldStore store.Topics = oldTopics @@ -4083,7 +4088,8 @@ func TestServeIMGroupAdminsMutesAndOwnerTransfer(t *testing.T) { subs := mock_store.NewMockSubsPersistenceInterface(ctrl) store.Topics = topics store.Subs = subs - store.GroupBusiness = &testGroupBusinessStore{} + groupStore := &testGroupBusinessStore{} + store.GroupBusiness = groupStore defer func() { store.Store = oldStore store.Topics = oldTopics @@ -4106,6 +4112,9 @@ func TestServeIMGroupAdminsMutesAndOwnerTransfer(t *testing.T) { if adminRec.Code != http.StatusOK { t.Fatalf("admin status: got %d body=%s", adminRec.Code, adminRec.Body.String()) } + if !groupStore.updatedRoleMode.IsAdmin() || groupStore.updatedRoleMode.IsOwner() { + t.Fatalf("admin role mode should grant admin without owner: %s", groupStore.updatedRoleMode.String()) + } muteReq := httptest.NewRequest(http.MethodPost, "/v1/im/groups/grpAlpha/mutes", bytes.NewBufferString(`{"members":[{"user_id":"`+target.UserId()+`"}],"reason":"test"}`)) muteReq.Header.Set("Authorization", "Bearer token-1") @@ -5165,6 +5174,65 @@ func TestServeIMMessageRecall(t *testing.T) { } } +func TestServeIMMessageRecallNormalizesP2PUserTopic(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + actor := types.Uid(42) + peer := types.Uid(43) + normalizedTopic := actor.P2PName(peer) + expectIMBearer(t, ctrl, "token-1", 7, actor) + oldSubs := store.Subs + oldMessages := store.Messages + subs := mock_store.NewMockSubsPersistenceInterface(ctrl) + messages := mock_store.NewMockMessagesPersistenceInterface(ctrl) + store.Subs = subs + store.Messages = messages + defer func() { + store.Subs = oldSubs + store.Messages = oldMessages + }() + + subs.EXPECT().Get(types.TenantID(7), normalizedTopic, actor, false).Return(&types.Subscription{ + TenantID: 7, + User: actor.String(), + Topic: normalizedTopic, + ModeWant: types.ModeCPublic, + ModeGiven: types.ModeCPublic, + }, nil) + messages.EXPECT().GetOne(types.TenantID(7), normalizedTopic, int64(9)).Return(&types.Message{ + TenantID: 7, + SeqId: 9, + Topic: normalizedTopic, + From: actor.String(), + Version: 1, + ObjHeader: types.ObjHeader{CreatedAt: time.Now().Add(-time.Second)}, + }, nil) + messages.EXPECT().Recall(types.TenantID(7), normalizedTopic, int64(9), actor, 1, gomock.Any()).Return(&types.Message{ + TenantID: 7, + SeqId: 9, + Topic: normalizedTopic, + From: actor.String(), + Version: 2, + RecalledAt: ptrTime(time.Now().UTC()), + RecalledBy: actor.String(), + ObjHeader: types.ObjHeader{UpdatedAt: time.Now().UTC()}, + }, nil) + + req := httptest.NewRequest(http.MethodPost, "/v1/im/topics/"+peer.UserId()+"/messages/9/recall", bytes.NewBufferString(`{"version":1}`)) + req.Header.Set("Authorization", "Bearer token-1") + rec := httptest.NewRecorder() + + serveIMMessageRecall(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + data := decodeIMHTTPResponse(t, rec).Data.(map[string]any) + if data["topic"] != normalizedTopic { + t.Fatalf("expected normalized topic %q, got %#v", normalizedTopic, data) + } +} + func TestServeIMMessageRevision(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish()