im 服务端

This commit is contained in:
richjohnrambo
2026-08-15 01:33:23 +08:00
parent 7c360167b3
commit 5af396c5f5
10 changed files with 669 additions and 60 deletions
+8
View File
@@ -51,6 +51,14 @@ func (s testSecurityStore) UpdateIndependentPasswordAttempts(types.TenantID, typ
return s.err
}
func (s testSecurityStore) GetYouthMode(types.TenantID, types.Uid) (*types.YouthModeSettings, error) {
return nil, types.ErrNotFound
}
func (s testSecurityStore) SetYouthMode(types.TenantID, types.Uid, bool, int, time.Time) (*types.YouthModeSettings, error) {
return nil, s.err
}
func (s testSecurityStore) CreateSystemNotification(types.TenantID, *types.SystemNotification) error {
return s.err
}
+1 -1
View File
@@ -1452,7 +1452,7 @@ func createIMRelationTables(exec sqlExecutor) error {
PRIMARY KEY(id),
UNIQUE INDEX uk_im_favorites_favorite(favorite_id),
UNIQUE INDEX uk_im_favorites_source(user_id, source_topic, source_seq),
UNIQUE INDEX uk_im_favorites_idempotency(user_id, idempotency_key),
UNIQUE INDEX uk_im_favorites_idempotency(user_id, ((NULLIF(idempotency_key, '')))),
INDEX idx_im_favorites_inbox(user_id, id DESC),
INDEX idx_im_favorites_type(user_id, item_type, id DESC)
)`,
+32 -1
View File
@@ -4420,7 +4420,38 @@ func (a *adapter) TenantFavoriteCreate(id t.TenantID, fav *t.FavoriteItem) (*t.F
if err != nil {
return nil, err
}
return a.TenantFavoriteGet(id, fav.UserID, fav.FavoriteID)
item, err := a.TenantFavoriteGet(id, fav.UserID, fav.FavoriteID)
if err == nil {
return item, nil
}
if err != t.ErrNotFound {
return nil, err
}
return a.tenantFavoriteFindExisting(ctx, id, fav)
}
func (a *adapter) tenantFavoriteFindExisting(ctx context.Context, id t.TenantID, fav *t.FavoriteItem) (*t.FavoriteItem, error) {
where := []string{"favorite_id=?", "(source_topic=? AND source_seq=?)"}
args := []any{fav.FavoriteID, fav.SourceTopic, fav.SourceSeq}
if fav.IdempotencyKey != "" {
where = append(where, "idempotency_key=?")
args = append(args, fav.IdempotencyKey)
}
args = append([]any{decodeUID(fav.UserID)}, args...)
rows, err := a.db.QueryxContext(ctx, "SELECT * FROM im_favorites WHERE user_id=? AND ("+strings.Join(where, " OR ")+") ORDER BY id DESC LIMIT 1", args...)
if err != nil {
return nil, err
}
defer rows.Close()
if !rows.Next() {
return nil, t.ErrNotFound
}
item, err := scanFavorite(rows)
if err != nil {
return nil, err
}
item.TenantID = id
return &item, nil
}
func (a *adapter) TenantFavoriteList(id t.TenantID, uid t.Uid, itemType, queryText, cursor string, limit int) ([]t.FavoriteItem, string, error) {
+29 -20
View File
@@ -645,11 +645,7 @@ func largeFileRunGarbageCollection(period time.Duration, blockSize int) chan<- b
for {
select {
case <-gcTicker:
for _, tenantID := range store.KnownTenantIDs() {
if err := store.Files.DeleteUnused(tenantID, time.Now().Add(-time.Hour), blockSize); err != nil {
logs.Warn.Println("media gc:", tenantID, err)
}
}
largeFileGarbageCollectTenants(store.KnownTenantIDs(), time.Now().Add(-time.Hour), blockSize)
case <-stop:
return
}
@@ -659,42 +655,55 @@ func largeFileRunGarbageCollection(period time.Duration, blockSize int) chan<- b
return stop
}
func largeFileGarbageCollectTenants(tenantIDs []types.TenantID, olderThan time.Time, blockSize int) {
for _, tenantID := range tenantIDs {
if err := store.Files.DeleteUnused(tenantID, olderThan, blockSize); err != nil {
logs.Warn.Println("media gc:", tenantID, err)
}
}
}
// Authenticate non-websocket HTTP request
func authFileRequest(authMethod, secret, sid, remoteAddr string) (types.TenantID, types.Uid, []byte, error) {
var tenantID types.TenantID
var uid types.Uid
func authFileRequest(authMethod, secret, sid, remoteAddr string) (RequestPrincipal, []byte, error) {
var principal RequestPrincipal
if authMethod != "" {
decodedSecret := make([]byte, base64.StdEncoding.DecodedLen(len(secret)))
n, err := base64.StdEncoding.Decode(decodedSecret, []byte(secret))
if err != nil {
logs.Info.Println("media: invalid auth secret", authMethod, "'"+secret+"'")
return tenantID, uid, nil, types.ErrMalformed
return principal, nil, types.ErrMalformed
}
if authhdl := store.Store.GetLogicalAuthHandler(authMethod); authhdl != nil {
rec, challenge, err := authhdl.Authenticate(auth.AuthContext{RemoteAddr: remoteAddr}, decodedSecret[:n])
if err != nil {
return tenantID, uid, nil, err
return principal, nil, err
}
if challenge != nil {
return tenantID, uid, challenge, nil
return principal, challenge, nil
}
if rec == nil || rec.TenantID.IsZero() || rec.Uid.IsZero() {
return tenantID, uid, nil, types.ErrFailed
return principal, nil, types.ErrFailed
}
tenantID = rec.TenantID
uid = rec.Uid
principal.TenantID = rec.TenantID
principal.UID = rec.Uid
} else {
logs.Info.Println("media: unknown auth method", authMethod)
return tenantID, uid, nil, types.ErrMalformed
return principal, nil, types.ErrMalformed
}
} else {
// Find the session, make sure it's appropriately authenticated.
sess := globals.sessionStore.Get(sid)
if sess != nil {
tenantID = sess.tenantID
uid = sess.uid
if globals.sessionStore != nil {
sess := globals.sessionStore.Get(sid)
if sess == nil {
return principal, nil, nil
}
if sess.tenantID.IsZero() || sess.uid.IsZero() {
return principal, nil, types.ErrFailed
}
principal.TenantID = sess.tenantID
principal.UID = sess.uid
}
}
return tenantID, uid, nil, nil
return principal, nil, nil
}
+19 -5
View File
@@ -22,12 +22,12 @@ func TestAuthFileRequestFromSessionRequiresTenant(t *testing.T) {
sess, _ := globals.sessionStore.NewSession(httptest.NewRecorder(), "file-test-sid")
sess.uid = types.Uid(42)
if _, _, _, err := authFileRequest("", "", sess.sid, ""); err != types.ErrFailed {
if _, _, err := authFileRequest("", "", sess.sid, ""); err != types.ErrFailed {
t.Fatalf("session without tenant: got %v, want %v", err, types.ErrFailed)
}
sess.tenantID = types.TenantID(7)
principal, challenge, _, err := authFileRequest("", "", sess.sid, "")
principal, challenge, err := authFileRequest("", "", sess.sid, "")
if err != nil {
t.Fatal(err)
}
@@ -49,12 +49,11 @@ func TestAuthFileRequestFromTokenReturnsPrincipal(t *testing.T) {
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{RemoteAddr: "203.0.113.10"}, []byte("token-1")).
Return(&auth.Rec{TenantID: 7, Uid: 42, AuthLevel: auth.LevelAuth}, nil, nil)
store.Store = mockStore
principal, challenge, _, err := authFileRequest("token", base64.StdEncoding.EncodeToString([]byte("token-1")), "", "203.0.113.10")
principal, challenge, err := authFileRequest("token", base64.StdEncoding.EncodeToString([]byte("token-1")), "", "203.0.113.10")
if err != nil {
t.Fatal(err)
}
@@ -67,7 +66,7 @@ func TestAuthFileRequestFromTokenReturnsPrincipal(t *testing.T) {
}
func TestAuthFileRequestAllowsUnauthenticatedNewAccountPath(t *testing.T) {
principal, challenge, _, err := authFileRequest("", "", "", "")
principal, challenge, err := authFileRequest("", "", "", "")
if err != nil {
t.Fatal(err)
}
@@ -123,3 +122,18 @@ func TestContentDispositionAttachmentSanitizesName(t *testing.T) {
t.Fatalf("unexpected content disposition: %s", got)
}
}
func TestLargeFileGarbageCollectTenantsScopesDeletes(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
oldFiles := store.Files
files := mock_store.NewMockFilePersistenceInterface(ctrl)
store.Files = files
defer func() { store.Files = oldFiles }()
olderThan := time.Date(2026, 8, 13, 9, 0, 0, 0, time.UTC)
files.EXPECT().DeleteUnused(types.TenantID(7), olderThan, 25).Return(nil)
files.EXPECT().DeleteUnused(types.TenantID(8), olderThan, 25).Return(nil)
largeFileGarbageCollectTenants([]types.TenantID{7, 8}, olderThan, 25)
}
+13 -1
View File
@@ -1062,6 +1062,16 @@ func serveIMBootstrap(wrt http.ResponseWriter, req *http.Request) {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
return
}
var modules []types.WorkbenchModule
workbenchVersion := 1
if store.Workbench != nil {
var err error
modules, workbenchVersion, err = store.Workbench.Get(principal.TenantID, principal.Uid)
if err != nil && !errors.Is(err, types.ErrNotFound) && !errors.Is(err, types.ErrUnsupported) {
writeIMRelationError(wrt, req, err)
return
}
}
writeIMSuccess(wrt, req, map[string]any{
"features": map[string]any{
"create_group": true,
@@ -1076,6 +1086,8 @@ func serveIMBootstrap(wrt http.ResponseWriter, req *http.Request) {
"user_search": true,
"scan_qr": true,
},
"modules": workbenchModulePayloads(modules),
"workbench_version": workbenchVersion,
"system_topic": map[string]any{
"topic": "sys",
"type": "system",
@@ -5968,7 +5980,7 @@ func authenticateTopicReader(wrt http.ResponseWriter, req *http.Request, topic s
}
sub, err := store.Subs.Get(principal.TenantID, topic, principal.Uid, false)
if err != nil {
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
writeIMRelationError(wrt, req, err)
return nil, "", false
}
if sub == nil || !(sub.ModeGiven & sub.ModeWant).IsReader() {
+362 -28
View File
@@ -44,6 +44,9 @@ type testRelationsStore struct {
friendRequests []types.FriendRequestListItem
searchResults []types.UserSearchItem
blocks []types.BlockListItem
deleteTenant types.TenantID
deleteUser types.Uid
deletePeer types.Uid
}
type testFavoritesStore struct {
@@ -53,13 +56,22 @@ type testFavoritesStore struct {
}
type testCallsStore struct {
items []types.CallRecord
topic string
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
modules []types.WorkbenchModule
version int
tenantID types.TenantID
uid types.Uid
calledOnce bool
}
type testOrganizationStore struct {
@@ -109,7 +121,8 @@ type testAppReleaseStore struct {
}
type testDevicePushTokenStore struct {
token *types.DevicePushToken
token *types.DevicePushToken
tenantID types.TenantID
}
type testSafetyStore struct {
@@ -117,9 +130,18 @@ type testSafetyStore struct {
reasons []types.AbuseReason
abuseReport *types.AbuseReport
feedback *types.FeedbackSubmission
tenantID types.TenantID
uid types.Uid
idempotency string
decision string
actionTokenID string
targetType string
}
func (s *testWorkbenchStore) Get(_ types.TenantID, _ types.Uid) ([]types.WorkbenchModule, int, error) {
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
}
@@ -369,23 +391,29 @@ func (s *testAppReleaseStore) Latest(_ types.TenantID, platform, channel string)
return s.release, nil
}
func (s *testDevicePushTokenStore) Upsert(_ types.TenantID, token *types.DevicePushToken) (*types.DevicePushToken, error) {
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(_ types.TenantID, challenge *types.RiskChallenge) (*types.RiskChallenge, error) {
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(_ types.TenantID, uid types.Uid, challengeID, decision, actionTokenID string, now time.Time) (*types.RiskChallenge, error) {
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)}
}
@@ -399,23 +427,29 @@ func (s *testSafetyStore) ConfirmRiskChallenge(_ types.TenantID, uid types.Uid,
return s.riskChallenge, nil
}
func (s *testSafetyStore) AbuseReasons(_ types.TenantID, targetType string) ([]types.AbuseReason, error) {
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(_ types.TenantID, report *types.AbuseReport, _ string) (*types.AbuseReport, error) {
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(types.TenantID, types.Uid) (*types.FeedbackConfig, error) {
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"},
@@ -427,12 +461,14 @@ func (s *testSafetyStore) FeedbackConfig(types.TenantID, types.Uid) (*types.Feed
}, nil
}
func (s *testSafetyStore) CreateFeedback(_ types.TenantID, feedback *types.FeedbackSubmission, _ string) (*types.FeedbackSubmission, error) {
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
}
@@ -448,12 +484,26 @@ func (s *testCallsStore) Finish(_ types.TenantID, _ string, _ int, _ string, _ i
return nil
}
func (s *testCallsStore) List(_ types.TenantID, _ types.Uid, topic, _ string, _ int) ([]types.CallRecord, string, error) {
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
return s.items, "", nil
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
@@ -535,8 +585,11 @@ func (s *testRelationsStore) UnblockUser(_ types.TenantID, _, _ types.Uid) error
return nil
}
func (s *testRelationsStore) DeleteFriend(_ types.TenantID, _, _ types.Uid, _ time.Time) error {
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
}
@@ -1125,8 +1178,18 @@ func TestServeIMBootstrapIncludesHomeCapabilities(t *testing.T) {
defer ctrl.Finish()
expectIMBearer(t, ctrl, "token-1", 7, 42)
oldSecurity := store.Security
store.Security = &testSecurityStore{}
defer func() { store.Security = oldSecurity }()
oldWorkbench := store.Workbench
security := &testSecurityStore{realName: &types.RealNameVerification{Status: "verified"}}
workbench := &testWorkbenchStore{version: 9, modules: []types.WorkbenchModule{
{Code: "organization", Name: "组织架构", Route: "/organization", Enabled: true, Permission: "enabled"},
{Code: "external", Name: "外链", Route: "https://example.com", Enabled: true, Permission: "enabled"},
}}
store.Security = security
store.Workbench = workbench
defer func() {
store.Security = oldSecurity
store.Workbench = oldWorkbench
}()
req := httptest.NewRequest(http.MethodGet, "/v1/im/bootstrap", nil)
req.Header.Set("Authorization", "Bearer token-1")
@@ -1141,6 +1204,23 @@ func TestServeIMBootstrapIncludesHomeCapabilities(t *testing.T) {
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 !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) != 1 || modules[0].(map[string]any)["code"] != "organization" {
t.Fatalf("unexpected bootstrap modules: %#v", modules)
}
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) {
@@ -1706,7 +1786,7 @@ func TestServeIMDevicePushToken(t *testing.T) {
if rec.Code != http.StatusOK {
t.Fatalf("status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
if tokenStore.token == nil || tokenStore.token.DeviceID != "device-1" || tokenStore.token.Token != "push-token-1" {
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)
@@ -1763,10 +1843,76 @@ func TestServeIMConversationSearch(t *testing.T) {
}
}
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,
@@ -1776,12 +1922,21 @@ func TestServeIMSystemNotificationsListAndRead(t *testing.T) {
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", nil)
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()
@@ -1791,9 +1946,19 @@ func TestServeIMSystemNotificationsListAndRead(t *testing.T) {
t.Fatalf("list status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
if len(data["items"].([]any)) != 1 {
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(`{}`))
@@ -1806,7 +1971,7 @@ func TestServeIMSystemNotificationsListAndRead(t *testing.T) {
if rec.Code != http.StatusOK {
t.Fatalf("read status: got %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
if security.readNotificationID != "evt-1" || security.notifications[0].ReadAt == nil {
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)
}
}
@@ -2446,6 +2611,58 @@ func TestServeIMFavoritesListDetailBatchDelete(t *testing.T) {
}
}
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()
@@ -2517,6 +2734,51 @@ func TestServeIMFavoriteForwardSavesMessages(t *testing.T) {
}
}
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()
@@ -2587,6 +2849,9 @@ func TestServeIMRiskChallengeConfirm(t *testing.T) {
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) {
@@ -2612,6 +2877,9 @@ func TestServeIMAbuseReportsAndReasons(t *testing.T) {
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"]}`))
@@ -2622,7 +2890,7 @@ func TestServeIMAbuseReportsAndReasons(t *testing.T) {
if rec.Code != http.StatusOK {
t.Fatalf("report status: got %d body=%s", rec.Code, rec.Body.String())
}
if safety.abuseReport == nil || safety.abuseReport.TargetUserID != 43 || safety.abuseReport.ReasonCode != "fraud" {
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)
}
}
@@ -2643,6 +2911,9 @@ func TestServeIMFeedback(t *testing.T) {
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}`))
@@ -2653,7 +2924,7 @@ func TestServeIMFeedback(t *testing.T) {
if rec.Code != http.StatusOK {
t.Fatalf("submit status: got %d body=%s", rec.Code, rec.Body.String())
}
if safety.feedback == nil || safety.feedback.Category != "bug" || !safety.feedback.IncludeDiagnostics {
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)
}
}
@@ -2752,15 +3023,15 @@ func TestServeIMTopicCalls(t *testing.T) {
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", 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.topic != "p2pTopic" {
t.Fatalf("calls topic: got %q", calls.topic)
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 {
@@ -2777,6 +3048,34 @@ func TestServeIMTopicCalls(t *testing.T) {
}
}
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()
@@ -3128,6 +3427,41 @@ func TestServeIMFriendRemark(t *testing.T) {
}
}
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()
+21 -4
View File
@@ -219,6 +219,15 @@ type testSecurityStore struct {
lockedUntil *time.Time
notifications []types.SystemNotification
readNotificationID string
realNameTenant types.TenantID
realNameUser types.Uid
listTenant types.TenantID
listUser types.Uid
listCursor string
listLimit int
nextCursor string
readTenant types.TenantID
readUser types.Uid
}
func (s *testSecurityStore) GetAuthVersion(types.TenantID, types.Uid) (int, error) {
@@ -238,7 +247,9 @@ func (s *testSecurityStore) BumpAuthVersion(_ types.TenantID, uid types.Uid, _ t
return s.version, s.err
}
func (s *testSecurityStore) GetRealName(types.TenantID, types.Uid) (*types.RealNameVerification, error) {
func (s *testSecurityStore) GetRealName(tenantID types.TenantID, uid types.Uid) (*types.RealNameVerification, error) {
s.realNameTenant = tenantID
s.realNameUser = uid
if s.err != nil {
return nil, s.err
}
@@ -340,11 +351,17 @@ func (s *testSecurityStore) CreateSystemNotification(_ types.TenantID, notificat
return s.err
}
func (s *testSecurityStore) ListSystemNotifications(types.TenantID, types.Uid, string, int) ([]types.SystemNotification, string, error) {
return s.notifications, "", s.err
func (s *testSecurityStore) ListSystemNotifications(tenantID types.TenantID, uid types.Uid, cursor string, limit int) ([]types.SystemNotification, string, error) {
s.listTenant = tenantID
s.listUser = uid
s.listCursor = cursor
s.listLimit = limit
return s.notifications, s.nextCursor, s.err
}
func (s *testSecurityStore) MarkSystemNotificationRead(_ types.TenantID, _ types.Uid, eventID string, readAt time.Time) error {
func (s *testSecurityStore) MarkSystemNotificationRead(tenantID types.TenantID, uid types.Uid, eventID string, readAt time.Time) error {
s.readTenant = tenantID
s.readUser = uid
s.readNotificationID = eventID
for i := range s.notifications {
if s.notifications[i].EventID == eventID {