fix: repair group management edge cases
This commit is contained in:
+226
-31
@@ -41,7 +41,6 @@ type testVerificationStore struct {
|
||||
|
||||
type testHTTPInfrastructureStore struct {
|
||||
records map[string]*types.HTTPIdempotencyRecord
|
||||
events []types.BusinessEvent
|
||||
deadLetters []types.KafkaDeadLetter
|
||||
}
|
||||
|
||||
@@ -137,19 +136,6 @@ func (s *testHTTPInfrastructureStore) AbortIdempotency(_ types.TenantID, 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
|
||||
}
|
||||
|
||||
func (s *testHTTPInfrastructureStore) BeginKafkaConsume(_ types.TenantID, record *types.KafkaConsumeRecord, _ time.Time) (*types.KafkaConsumeRecord, bool, error) {
|
||||
copy := *record
|
||||
copy.Status = types.KafkaConsumeProcessing
|
||||
@@ -403,6 +389,16 @@ func (s *testGroupBusinessStore) UpdateSettings(_ types.TenantID, topic string,
|
||||
return settings, nil
|
||||
}
|
||||
|
||||
func (s *testGroupBusinessStore) Dissolve(_ types.TenantID, topic string, actor types.Uid, reason string, now time.Time) (*types.GroupSettings, error) {
|
||||
settings, _ := s.GetSettings(0, topic)
|
||||
settings.GroupStatus = "dissolved"
|
||||
settings.DissolvedBy = actor
|
||||
settings.DissolvedAt = &now
|
||||
settings.Version++
|
||||
s.settings = settings
|
||||
return settings, nil
|
||||
}
|
||||
|
||||
func (s *testGroupBusinessStore) UpsertMember(_ types.TenantID, member *types.GroupMember) (*types.GroupMember, error) {
|
||||
s.member = member
|
||||
return member, nil
|
||||
@@ -426,6 +422,20 @@ func (s *testGroupBusinessStore) UpdateMember(_ types.TenantID, topic string, ui
|
||||
}
|
||||
|
||||
func (s *testGroupBusinessStore) CreateInvitation(_ types.TenantID, inv *types.GroupInvitation) (*types.GroupInvitation, error) {
|
||||
if inv.JoinRequestID != "" {
|
||||
for i := len(s.invitations) - 1; i >= 0; i-- {
|
||||
existing := &s.invitations[i]
|
||||
if existing.Topic == inv.Topic && existing.TargetUserID == inv.TargetUserID &&
|
||||
existing.Status == types.GroupInvitationStatusPending && existing.JoinRequestID != "" {
|
||||
for j := len(s.joinRequests) - 1; j >= 0; j-- {
|
||||
request := &s.joinRequests[j]
|
||||
if request.RequestID == existing.JoinRequestID && request.Status == types.GroupJoinRequestStatusPending {
|
||||
return existing, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
s.invitations = append(s.invitations, *inv)
|
||||
if inv.JoinRequestID != "" {
|
||||
expiresAt := inv.ExpiresAt
|
||||
@@ -464,6 +474,13 @@ func (s *testGroupBusinessStore) AcceptInvitation(_ types.TenantID, invitationID
|
||||
}
|
||||
|
||||
func (s *testGroupBusinessStore) CreateJoinRequest(_ types.TenantID, req *types.GroupJoinRequest) (*types.GroupJoinRequest, error) {
|
||||
for i := len(s.joinRequests) - 1; i >= 0; i-- {
|
||||
existing := &s.joinRequests[i]
|
||||
if existing.Topic == req.Topic && existing.ApplicantUserID == req.ApplicantUserID &&
|
||||
existing.Status == types.GroupJoinRequestStatusPending {
|
||||
return existing, nil
|
||||
}
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
req.CreatedAt = now
|
||||
req.UpdatedAt = now
|
||||
@@ -613,14 +630,6 @@ func (s *testGroupBusinessStore) CanPost(_ types.TenantID, topic string, uid typ
|
||||
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
|
||||
@@ -2529,6 +2538,60 @@ func TestServeIMSecurityChallengeVerifyIssuesActionToken(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeIMSecurityChallengeSupportsGroupSensitiveScenes(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
expectIMBearer(t, ctrl, "token-1", 7, 42)
|
||||
oldUsers := store.Users
|
||||
oldVerifications := store.Verifications
|
||||
oldSecurity := store.Security
|
||||
oldSender := sendIMVerificationCode
|
||||
users := mock_store.NewMockUsersPersistenceInterface(ctrl)
|
||||
users.EXPECT().Get(types.TenantID(7), types.Uid(42)).Return(&types.User{
|
||||
ObjHeader: types.ObjHeader{Id: types.Uid(42).String()},
|
||||
TenantID: 7,
|
||||
Phone: "+8613800138000",
|
||||
}, nil)
|
||||
verifications := &testVerificationStore{}
|
||||
security := &testSecurityStore{}
|
||||
store.Users = users
|
||||
store.Verifications = verifications
|
||||
store.Security = security
|
||||
sendIMVerificationCode = func(_ types.TenantID, _, _, _, _ string) error { return nil }
|
||||
defer func() {
|
||||
store.Users = oldUsers
|
||||
store.Verifications = oldVerifications
|
||||
store.Security = oldSecurity
|
||||
sendIMVerificationCode = oldSender
|
||||
}()
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/im/security/challenges",
|
||||
bytes.NewBufferString(`{"scene":"group_owner_transfer","channel":"sms"}`))
|
||||
req.Header.Set("Authorization", "Bearer token-1")
|
||||
rec := httptest.NewRecorder()
|
||||
serveIMSecurityChallenges(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("create status: got %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if verifications.created == nil || verifications.created.Action != "group_owner_transfer" {
|
||||
t.Fatalf("unexpected created verification: %#v", verifications.created)
|
||||
}
|
||||
verifications.get = verifications.created
|
||||
|
||||
expectIMBearer(t, ctrl, "token-1", 7, 42)
|
||||
verifyReq := httptest.NewRequest(http.MethodPost, "/v1/im/security/challenges/"+verifications.created.ChallengeID+"/verify",
|
||||
bytes.NewBufferString(`{"code":"123456"}`))
|
||||
verifyReq.Header.Set("Authorization", "Bearer token-1")
|
||||
verifyRec := httptest.NewRecorder()
|
||||
serveIMSecurityChallengeVerify(verifyRec, verifyReq)
|
||||
if verifyRec.Code != http.StatusOK {
|
||||
t.Fatalf("verify status: got %d body=%s", verifyRec.Code, verifyRec.Body.String())
|
||||
}
|
||||
if security.actionToken == nil || security.actionToken.Action != "group_owner_transfer" || security.actionToken.Target != "group_owner_transfer" {
|
||||
t.Fatalf("unexpected group action token: %#v", security.actionToken)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeIMSecurityChallengeCreateEnforcesResendLimit(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
@@ -2955,11 +3018,9 @@ func TestServeIMMeetingsCreate(t *testing.T) {
|
||||
oldKafkaBus := kafkaBus
|
||||
oldPublisher := publishKafkaAuditEventEnvelope
|
||||
oldSecurity := store.Security
|
||||
oldInfrastructure := store.HTTPInfrastructure
|
||||
oldMeetings := store.Meetings
|
||||
meetings := &testMeetingsStore{}
|
||||
security := &testSecurityStore{}
|
||||
infrastructure := &testHTTPInfrastructureStore{}
|
||||
var auditPublished bool
|
||||
kafkaBus = &kafkaRuntime{}
|
||||
publishKafkaAuditEventEnvelope = func(_ context.Context, topicName, key string, envelope kafkaEnvelope) error {
|
||||
@@ -2968,13 +3029,11 @@ func TestServeIMMeetingsCreate(t *testing.T) {
|
||||
return nil
|
||||
}
|
||||
store.Security = security
|
||||
store.HTTPInfrastructure = infrastructure
|
||||
store.Meetings = meetings
|
||||
defer func() {
|
||||
kafkaBus = oldKafkaBus
|
||||
publishKafkaAuditEventEnvelope = oldPublisher
|
||||
store.Security = oldSecurity
|
||||
store.HTTPInfrastructure = oldInfrastructure
|
||||
store.Meetings = oldMeetings
|
||||
}()
|
||||
|
||||
@@ -2998,8 +3057,8 @@ func TestServeIMMeetingsCreate(t *testing.T) {
|
||||
if !auditPublished {
|
||||
t.Fatalf("meeting create should still publish audit event")
|
||||
}
|
||||
if len(security.notifications) != 0 || len(infrastructure.events) != 0 {
|
||||
t.Fatalf("meeting create HTTP layer must not duplicate workbench notifications/events: notifications=%#v events=%#v", security.notifications, infrastructure.events)
|
||||
if len(security.notifications) != 0 {
|
||||
t.Fatalf("meeting create HTTP layer must not duplicate workbench notifications: notifications=%#v", security.notifications)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4542,7 +4601,7 @@ func TestServeIMGroupDetailPatchSettingsProfileAndLeave(t *testing.T) {
|
||||
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 := httptest.NewRequest(http.MethodPatch, "/v1/im/groups/grpAlpha/members/me/settings", bytes.NewBufferString(`{"pinned":true,"mute_notifications":true,"version":1}`))
|
||||
settingsReq.Header.Set("Authorization", "Bearer token-1")
|
||||
settingsRec := httptest.NewRecorder()
|
||||
serveIMGroupResource(settingsRec, settingsReq)
|
||||
@@ -4649,6 +4708,66 @@ func TestServeIMGroupDetailPatchKeepsTopicProfileWhenBusinessSettingsMissing(t *
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeIMGroupDetailPatchUsesBusinessSettingsVersion(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
oldStore := store.Store
|
||||
oldGroupBusiness := store.GroupBusiness
|
||||
mockStore := mock_store.NewMockPersistentStorageInterface(ctrl)
|
||||
tokenAuth := mock_auth.NewMockAuthHandler(ctrl)
|
||||
mockStore.EXPECT().GetLogicalAuthHandler("token").Return(tokenAuth)
|
||||
tokenAuth.EXPECT().IsInitialized().Return(true)
|
||||
tokenAuth.EXPECT().Authenticate(auth.AuthContext{}, []byte("token-1")).
|
||||
Return(&auth.Rec{TenantID: 7, Uid: 42, AuthLevel: auth.LevelAuth}, nil, nil)
|
||||
store.Store = mockStore
|
||||
oldTopics := store.Topics
|
||||
oldSubs := store.Subs
|
||||
topics := mock_store.NewMockTopicsPersistenceInterface(ctrl)
|
||||
subs := mock_store.NewMockSubsPersistenceInterface(ctrl)
|
||||
store.Topics = topics
|
||||
store.Subs = subs
|
||||
groupStore := &testGroupBusinessStore{settings: &types.GroupSettings{
|
||||
Topic: "grpAlpha", OwnerUserID: types.Uid(42), Name: "Alpha",
|
||||
GroupStatus: types.GroupStatusActive, Version: 7,
|
||||
}}
|
||||
store.GroupBusiness = groupStore
|
||||
defer func() {
|
||||
store.Store = oldStore
|
||||
store.Topics = oldTopics
|
||||
store.Subs = oldSubs
|
||||
store.GroupBusiness = oldGroupBusiness
|
||||
}()
|
||||
selfSub := &types.Subscription{
|
||||
TenantID: 7, User: types.Uid(42).String(), Topic: "grpAlpha",
|
||||
ModeWant: types.ModeCFull, ModeGiven: types.ModeCFull,
|
||||
}
|
||||
group := &types.Topic{
|
||||
ObjHeader: types.ObjHeader{Id: "grpAlpha"}, TenantID: 7,
|
||||
Owner: types.Uid(42).String(), Public: map[string]any{"name": "Alpha", "version": 1},
|
||||
}
|
||||
subs.EXPECT().Get(types.TenantID(7), "grpAlpha", types.Uid(42), false).Return(selfSub, nil)
|
||||
topics.EXPECT().Get(types.TenantID(7), "grpAlpha").Return(group, nil)
|
||||
topics.EXPECT().Update(types.TenantID(7), "grpAlpha", gomock.Any()).Return(nil)
|
||||
topics.EXPECT().Get(types.TenantID(7), "grpAlpha").Return(&types.Topic{
|
||||
ObjHeader: types.ObjHeader{Id: "grpAlpha"}, TenantID: 7,
|
||||
Owner: types.Uid(42).String(), Public: map[string]any{"name": "Beta", "version": 8},
|
||||
}, nil)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPatch, "/v1/im/groups/grpAlpha", bytes.NewBufferString(`{"name":"Beta","version":7}`))
|
||||
req.Header.Set("Authorization", "Bearer token-1")
|
||||
rec := httptest.NewRecorder()
|
||||
serveIMGroupResource(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("patch status: got %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if groupStore.updatedSettings.Version != 7 {
|
||||
t.Fatalf("business settings update used version %d, want 7", groupStore.updatedSettings.Version)
|
||||
}
|
||||
if data := decodeIMHTTPResponse(t, rec).Data.(map[string]any); data["version"].(float64) != 8 {
|
||||
t.Fatalf("unexpected patch response: %#v", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeIMGroupMyProfileKeepsSubscriptionNicknameWhenBusinessMemberMissing(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
@@ -4926,6 +5045,26 @@ func TestServeIMGroupInvitationCreatesApprovalRequest(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDedupePendingGroupJoinRequestsKeepsLatestPerApplicant(t *testing.T) {
|
||||
applicant := types.Uid(43)
|
||||
other := types.Uid(44)
|
||||
items := []types.GroupJoinRequest{
|
||||
{ID: 10, RequestID: "gjr_old", Topic: "grpAlpha", ApplicantUserID: applicant, Status: types.GroupJoinRequestStatusPending},
|
||||
{ID: 11, RequestID: "gjr_other", Topic: "grpAlpha", ApplicantUserID: other, Status: types.GroupJoinRequestStatusPending},
|
||||
{ID: 12, RequestID: "gjr_new", Topic: "grpAlpha", ApplicantUserID: applicant, Status: types.GroupJoinRequestStatusPending},
|
||||
}
|
||||
|
||||
deduped := dedupePendingGroupJoinRequests(items, types.GroupJoinRequestStatusPending)
|
||||
if len(deduped) != 2 || deduped[0].RequestID != "gjr_other" || deduped[1].RequestID != "gjr_new" {
|
||||
t.Fatalf("unexpected deduped requests: %#v", deduped)
|
||||
}
|
||||
|
||||
all := dedupePendingGroupJoinRequests(items, "all")
|
||||
if len(all) != len(items) {
|
||||
t.Fatalf("non-pending list should not be deduped: %#v", all)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeIMGroupInvitationRespectsTargetPrivacyApproval(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
@@ -5415,6 +5554,7 @@ func TestServeIMGroupAdminsMutesAndOwnerTransfer(t *testing.T) {
|
||||
defer ctrl.Finish()
|
||||
oldStore := store.Store
|
||||
oldGroupBusiness := store.GroupBusiness
|
||||
oldSecurity := store.Security
|
||||
mockStore := mock_store.NewMockPersistentStorageInterface(ctrl)
|
||||
tokenAuth := mock_auth.NewMockAuthHandler(ctrl)
|
||||
mockStore.EXPECT().GetLogicalAuthHandler("token").Return(tokenAuth).Times(3)
|
||||
@@ -5430,11 +5570,14 @@ func TestServeIMGroupAdminsMutesAndOwnerTransfer(t *testing.T) {
|
||||
store.Subs = subs
|
||||
groupStore := &testGroupBusinessStore{}
|
||||
store.GroupBusiness = groupStore
|
||||
security := &testSecurityStore{}
|
||||
store.Security = security
|
||||
defer func() {
|
||||
store.Store = oldStore
|
||||
store.Topics = oldTopics
|
||||
store.Subs = oldSubs
|
||||
store.GroupBusiness = oldGroupBusiness
|
||||
store.Security = oldSecurity
|
||||
}()
|
||||
owner := types.Uid(42)
|
||||
target := types.Uid(43)
|
||||
@@ -5456,7 +5599,7 @@ func TestServeIMGroupAdminsMutesAndOwnerTransfer(t *testing.T) {
|
||||
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 := httptest.NewRequest(http.MethodPost, "/v1/im/groups/grpAlpha/mutes/batch", bytes.NewBufferString(`{"user_ids":["`+target.UserId()+`"],"reason":"test","version":1}`))
|
||||
muteReq.Header.Set("Authorization", "Bearer token-1")
|
||||
muteRec := httptest.NewRecorder()
|
||||
serveIMGroupResource(muteRec, muteReq)
|
||||
@@ -5466,6 +5609,7 @@ func TestServeIMGroupAdminsMutesAndOwnerTransfer(t *testing.T) {
|
||||
|
||||
transferReq := httptest.NewRequest(http.MethodPost, "/v1/im/groups/grpAlpha/owner-transfer", bytes.NewBufferString(`{"target_user_id":"`+target.UserId()+`","version":1}`))
|
||||
transferReq.Header.Set("Authorization", "Bearer token-1")
|
||||
transferReq.Header.Set("X-Action-Token", "action.1")
|
||||
transferRec := httptest.NewRecorder()
|
||||
serveIMGroupResource(transferRec, transferReq)
|
||||
if transferRec.Code != http.StatusOK {
|
||||
@@ -5474,6 +5618,57 @@ func TestServeIMGroupAdminsMutesAndOwnerTransfer(t *testing.T) {
|
||||
if data := decodeIMHTTPResponse(t, transferRec).Data.(map[string]any); data["owner"] != target.UserId() {
|
||||
t.Fatalf("unexpected transfer response: %#v", data)
|
||||
}
|
||||
if security.consumedAction != "group_owner_transfer" || security.consumedTarget != "group_owner_transfer" {
|
||||
t.Fatalf("owner transfer consumed wrong action token scope: action=%q target=%q", security.consumedAction, security.consumedTarget)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeIMGroupDissolveConsumesActionToken(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
oldStore := store.Store
|
||||
oldGroupBusiness := store.GroupBusiness
|
||||
oldSecurity := store.Security
|
||||
mockStore := mock_store.NewMockPersistentStorageInterface(ctrl)
|
||||
tokenAuth := mock_auth.NewMockAuthHandler(ctrl)
|
||||
mockStore.EXPECT().GetLogicalAuthHandler("token").Return(tokenAuth)
|
||||
tokenAuth.EXPECT().IsInitialized().Return(true)
|
||||
tokenAuth.EXPECT().Authenticate(auth.AuthContext{}, []byte("token-1")).
|
||||
Return(&auth.Rec{TenantID: 7, Uid: 42, AuthLevel: auth.LevelAuth}, nil, nil)
|
||||
store.Store = mockStore
|
||||
oldSubs := store.Subs
|
||||
subs := mock_store.NewMockSubsPersistenceInterface(ctrl)
|
||||
store.Subs = subs
|
||||
groupStore := &testGroupBusinessStore{}
|
||||
store.GroupBusiness = groupStore
|
||||
security := &testSecurityStore{}
|
||||
store.Security = security
|
||||
defer func() {
|
||||
store.Store = oldStore
|
||||
store.Subs = oldSubs
|
||||
store.GroupBusiness = oldGroupBusiness
|
||||
store.Security = oldSecurity
|
||||
}()
|
||||
ownerSub := &types.Subscription{
|
||||
TenantID: 7, User: types.Uid(42).String(), Topic: "grpAlpha",
|
||||
ModeWant: types.ModeCFull, ModeGiven: types.ModeCFull,
|
||||
}
|
||||
subs.EXPECT().Get(types.TenantID(7), "grpAlpha", types.Uid(42), false).Return(ownerSub, nil)
|
||||
|
||||
req := httptest.NewRequest(http.MethodDelete, "/v1/im/groups/grpAlpha", bytes.NewBufferString(`{"reason":"结束项目"}`))
|
||||
req.Header.Set("Authorization", "Bearer token-1")
|
||||
req.Header.Set("X-Action-Token", "action.1")
|
||||
rec := httptest.NewRecorder()
|
||||
serveIMGroupResource(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("dissolve status: got %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if security.consumedAction != "group_dissolve" || security.consumedTarget != "group_dissolve" {
|
||||
t.Fatalf("dissolve consumed wrong action token scope: action=%q target=%q", security.consumedAction, security.consumedTarget)
|
||||
}
|
||||
if groupStore.settings == nil || groupStore.settings.GroupStatus != "dissolved" {
|
||||
t.Fatalf("group not dissolved: %#v", groupStore.settings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeIMFavoritesListDetailBatchDelete(t *testing.T) {
|
||||
@@ -7435,7 +7630,7 @@ func TestServeIMMessageRecall(t *testing.T) {
|
||||
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 := httptest.NewRequest(http.MethodPost, "/v1/im/topics/grpRecall/messages/9/recall", bytes.NewBufferString(`{}`))
|
||||
req.Header.Set("Authorization", "Bearer token-1")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user