Files
im_server/server/im_http_test.go
T

431 lines
13 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/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
}