package main import ( "bytes" "crypto/hmac" "crypto/md5" "encoding/base64" "encoding/binary" "encoding/json" "net/http" "net/http/httptest" "strings" "testing" "time" "github.com/tinode/chat/server/store" "github.com/tinode/chat/server/store/types" ) type testVerificationStore struct { active *types.VerificationCode get *types.VerificationCode err error created *types.VerificationCode failed bool verified bool consumed bool tokenHash string } 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 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 ptrString(s string) *string { return &s }