2107 lines
78 KiB
Go
2107 lines
78 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/hmac"
|
|
"crypto/md5"
|
|
"encoding/base64"
|
|
"encoding/binary"
|
|
"encoding/json"
|
|
"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 testRelationsStore struct {
|
|
context *types.RelationshipContext
|
|
created *types.FriendRequest
|
|
blocked *types.UserBlock
|
|
deleted bool
|
|
canSend *bool
|
|
reason string
|
|
friends []types.FriendListItem
|
|
friendRequests []types.FriendRequestListItem
|
|
searchResults []types.UserSearchItem
|
|
blocks []types.BlockListItem
|
|
}
|
|
|
|
type testFavoritesStore struct {
|
|
items []types.FavoriteItem
|
|
created *types.FavoriteItem
|
|
deleted []string
|
|
}
|
|
|
|
func (s *testFavoritesStore) Create(_ types.TenantID, item *types.FavoriteItem) (*types.FavoriteItem, error) {
|
|
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) 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(_ types.TenantID, _, _ types.Uid, _ time.Time) error {
|
|
s.deleted = true
|
|
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)
|
|
tokenAuth.EXPECT().IsInitialized().Return(true)
|
|
tokenAuth.EXPECT().Authenticate(auth.AuthContext{}, []byte(token)).
|
|
Return(&auth.Rec{TenantID: tenantID, Uid: uid, AuthLevel: auth.LevelAuth}, nil, nil)
|
|
store.Store = mockStore
|
|
t.Cleanup(func() { store.Store = oldStore })
|
|
return mockStore
|
|
}
|
|
|
|
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"] != "https://im.example.com/v0/channels" {
|
|
t.Fatalf("unexpected websocket endpoint: %#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
|
|
store.Tenants = testTenantStore{tenant: &types.Tenant{
|
|
ID: 7,
|
|
Code: "acme",
|
|
Name: "Acme Corp",
|
|
State: types.TenantStateActive,
|
|
}}
|
|
verifications := &testVerificationStore{}
|
|
store.Verifications = verifications
|
|
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.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())
|
|
}
|
|
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 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
|
|
store.Security = &testSecurityStore{}
|
|
defer func() { store.Security = oldSecurity }()
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
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 TestServeIMSystemNotificationsListAndRead(t *testing.T) {
|
|
ctrl := gomock.NewController(t)
|
|
defer ctrl.Finish()
|
|
oldSecurity := store.Security
|
|
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),
|
|
}}}
|
|
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.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)
|
|
if len(data["items"].([]any)) != 1 {
|
|
t.Fatalf("unexpected list response: %#v", data)
|
|
}
|
|
|
|
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.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
|
|
subs := mock_store.NewMockSubsPersistenceInterface(ctrl)
|
|
topics := mock_store.NewMockTopicsPersistenceInterface(ctrl)
|
|
store.Subs = subs
|
|
store.Topics = topics
|
|
defer func() {
|
|
store.Subs = oldSubs
|
|
store.Topics = oldTopics
|
|
}()
|
|
|
|
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
|
|
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
|
|
defer func() {
|
|
store.Store = oldStore
|
|
store.Topics = oldTopics
|
|
store.Subs = oldSubs
|
|
}()
|
|
|
|
topics.EXPECT().Create(types.TenantID(7), gomock.Any(), types.Uid(42), gomock.Any()).DoAndReturn(
|
|
func(_ types.TenantID, topic *types.Topic, _ types.Uid, _ any) error {
|
|
if topic.Id != "grpnewgroup" {
|
|
t.Fatalf("unexpected topic id: %s", topic.Id)
|
|
}
|
|
return nil
|
|
})
|
|
subs.EXPECT().Create(types.TenantID(7), gomock.Any()).Return(nil)
|
|
topics.EXPECT().UpdateSubCnt(types.TenantID(7), "grpnewgroup").Return(nil)
|
|
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", "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","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" {
|
|
t.Fatalf("unexpected group create response: %#v", data)
|
|
}
|
|
}
|
|
|
|
func TestServeIMGroupDetailPatchSettingsProfileAndLeave(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
|
|
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
|
|
}()
|
|
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
|
|
topics := mock_store.NewMockTopicsPersistenceInterface(ctrl)
|
|
subs := mock_store.NewMockSubsPersistenceInterface(ctrl)
|
|
store.Topics = topics
|
|
store.Subs = subs
|
|
defer func() {
|
|
store.Topics = oldTopics
|
|
store.Subs = oldSubs
|
|
}()
|
|
subs.EXPECT().Get(types.TenantID(7), "grpAlpha", types.Uid(42), false).Return(&types.Subscription{
|
|
TenantID: 7,
|
|
User: types.Uid(42).String(),
|
|
Topic: "grpAlpha",
|
|
ModeWant: types.ModeCPublic,
|
|
ModeGiven: types.ModeCPublic,
|
|
}, nil)
|
|
subs.EXPECT().Delete(types.TenantID(7), "grpAlpha", types.Uid(42)).Return(nil)
|
|
topics.EXPECT().UpdateSubCnt(types.TenantID(7), "grpAlpha").Return(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 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
|
|
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
|
|
defer func() {
|
|
store.Store = oldStore
|
|
store.Topics = oldTopics
|
|
store.Subs = oldSubs
|
|
}()
|
|
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(), Aux: types.KVMap{}}
|
|
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).Times(3)
|
|
topics.EXPECT().Update(types.TenantID(7), "grpAlpha", gomock.Any()).Return(nil).Times(3)
|
|
|
|
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
|
|
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
|
|
defer func() {
|
|
store.Store = oldStore
|
|
store.Topics = oldTopics
|
|
store.Subs = oldSubs
|
|
}()
|
|
owner := types.Uid(42)
|
|
target := types.Uid(43)
|
|
ownerSub := &types.Subscription{TenantID: 7, User: owner.String(), Topic: "grpAlpha", ModeWant: types.ModeCFull, ModeGiven: types.ModeCFull}
|
|
group := &types.Topic{ObjHeader: types.ObjHeader{Id: "grpAlpha"}, TenantID: 7, Owner: owner.String(), Aux: types.KVMap{}}
|
|
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().Update(types.TenantID(7), "grpAlpha", target, gomock.Any()).Return(nil).Times(2)
|
|
topics.EXPECT().Get(types.TenantID(7), "grpAlpha").Return(group, nil)
|
|
topics.EXPECT().Update(types.TenantID(7), "grpAlpha", gomock.Any()).Return(nil)
|
|
subs.EXPECT().Get(types.TenantID(7), "grpAlpha", target, false).Return(&member, nil)
|
|
subs.EXPECT().Update(types.TenantID(7), "grpAlpha", owner, gomock.Any()).Return(nil)
|
|
topics.EXPECT().OwnerChange(types.TenantID(7), "grpAlpha", target).Return(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 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 TestServeIMSecurityURLCheck(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).Times(2)
|
|
store.Store = mockStore
|
|
defer func() { store.Store = oldStore }()
|
|
|
|
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 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 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
|
|
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,
|
|
}}
|
|
defer func() {
|
|
store.Store = oldStore
|
|
store.Users = oldUsers
|
|
store.Relations = oldRelations
|
|
}()
|
|
|
|
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
|
|
relations := &testRelationsStore{}
|
|
store.Relations = relations
|
|
defer func() { store.Relations = oldRelations }()
|
|
|
|
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 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"}},
|
|
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 {
|
|
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
|
|
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,
|
|
}}
|
|
defer func() {
|
|
store.Users = oldUsers
|
|
store.Relations = oldRelations
|
|
}()
|
|
|
|
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 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
|
|
}
|