Files
im_server/server/im_http_test.go
T

994 lines
33 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
}
func (s *testRelationsStore) CreateFriendRequest(_ types.TenantID, req *types.FriendRequest) (*types.FriendRequest, error) {
s.created = req
return req, 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) 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 *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 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 ptrTime(t time.Time) *time.Time {
return &t
}
func ptrString(s string) *string {
return &s
}