package store import ( "encoding/json" "strings" "testing" "time" "github.com/tinode/chat/server/store/types" ) func TestForTenantRejectsZero(t *testing.T) { if scoped, err := ForTenant(types.ZeroTenantID); err != types.ErrMalformed || scoped != nil { t.Fatalf("ForTenant(0) = (%v, %v), want (nil, malformed)", scoped, err) } } func TestValidateMessageHeadersProductContract(t *testing.T) { valid := []types.KVMap{ {"x-im-schema": float64(1), "x-im-type": "text"}, {"x-im-schema": json.Number("1"), "x-im-type": "voice"}, {"x-im-schema": "1", "x-im-type": "contact_card"}, {"x-im-schema": 1, "type": "audio", "x-im-type": "voice"}, {"x-im-schema": 1, "x-im-type": "system_notice"}, {"x-im-schema": 1, "x-im-type": "system_action"}, {"x-im-schema": 1, "x-im-type": "text", "x-im-reply": map[string]any{"seq": float64(128), "type": "text", "text": "hello", "from": "usr1"}}, {"x-im-schema": 1, "x-im-type": "text", "x-im-reply": `{"seq":128,"type":"text","text":"hello","from":"usr1"}`}, {"type": "system"}, } for _, head := range valid { if err := validateMessageHeaders(head); err != nil { t.Fatalf("validateMessageHeaders(%v) = %v, want nil", head, err) } } invalid := []types.KVMap{ {"x-im-schema": float64(2), "x-im-type": "text"}, {"x-im-schema": true, "x-im-type": "text"}, {"x-im-schema": float64(1), "x-im-type": "unknown"}, {"type": "text", "x-im-type": "image"}, {"x-im-schema": 1, "x-im-type": "text", "x-im-reply": map[string]any{"seq": 0, "type": "text", "text": "hello"}}, {"x-im-schema": 1, "x-im-type": "text", "x-im-reply": map[string]any{"seq": 1.5, "type": "text", "text": "hello"}}, {"x-im-schema": 1, "x-im-type": "text", "x-im-reply": map[string]any{"seq": 1, "type": "text"}}, {"x-im-schema": 1, "x-im-type": "text", "x-im-reply": "not-json"}, } for _, head := range invalid { if err := validateMessageHeaders(head); err != types.ErrMalformed { t.Fatalf("validateMessageHeaders(%v) = %v, want malformed", head, err) } } } func TestVerificationCodeCachePreservesInternalFields(t *testing.T) { tokenHash := "verify-token-hash" now := time.Now().UTC().Round(time.Millisecond) vc := &types.VerificationCode{ ID: 11, TenantID: 7, ChallengeID: "challenge-1", Scene: "register", Action: "signup", PhoneHash: "phone-hash", PhoneMask: "+861****000", TargetUser: types.ParseUserId("usrR25C_ZgHzyg"), CodeHash: "code-hash", VerificationTokenHash: &tokenHash, State: types.VerificationStateVerified, Attempts: 2, MaxAttempts: 5, ExpiresAt: now.Add(5 * time.Minute), ResendAfter: now.Add(time.Minute), VerifiedAt: &now, CreatedAt: now, UpdatedAt: now, } publicPayload, err := json.Marshal(vc) if err != nil { t.Fatal(err) } if strings.Contains(string(publicPayload), "code-hash") || strings.Contains(string(publicPayload), "phone-hash") { t.Fatalf("public verification JSON leaked internal fields: %s", publicPayload) } cachePayload, err := json.Marshal(verificationCodeCacheFromType(vc)) if err != nil { t.Fatal(err) } if !strings.Contains(string(cachePayload), `"code_hash":"code-hash"`) || !strings.Contains(string(cachePayload), `"phone_hash":"phone-hash"`) || !strings.Contains(string(cachePayload), `"target_user":"usrR25C_ZgHzyg"`) || !strings.Contains(string(cachePayload), `"max_attempts":5`) { t.Fatalf("cache verification JSON missing internal fields: %s", cachePayload) } var cached verificationCodeCache if err := json.Unmarshal(cachePayload, &cached); err != nil { t.Fatal(err) } got := cached.toType() if got.CodeHash != vc.CodeHash || got.PhoneHash != vc.PhoneHash || got.TargetUser != vc.TargetUser || got.State != vc.State || got.Attempts != vc.Attempts || got.MaxAttempts != vc.MaxAttempts || got.VerificationTokenHash == nil || *got.VerificationTokenHash != tokenHash { t.Fatalf("cache round trip lost fields: got %#v", got) } } func TestActionTokenCachePreservesInternalFields(t *testing.T) { now := time.Now().UTC().Round(time.Millisecond) consumedAt := now.Add(time.Minute) token := &types.ActionToken{ ID: 9, TenantID: 7, UserID: types.ParseUserId("usrR25C_ZgHzyg"), TokenID: "token-id", Action: "close_youth_mode", Target: "youth_mode", TokenHash: "token-hash", State: 1, ExpiresAt: now.Add(5 * time.Minute), ConsumedAt: &consumedAt, CreatedAt: now, UpdatedAt: consumedAt, } publicPayload, err := json.Marshal(token) if err != nil { t.Fatal(err) } if strings.Contains(string(publicPayload), "token-hash") || strings.Contains(string(publicPayload), "token-id") { t.Fatalf("public action token JSON leaked internal fields: %s", publicPayload) } cachePayload, err := json.Marshal(actionTokenCacheFromType(token)) if err != nil { t.Fatal(err) } if !strings.Contains(string(cachePayload), `"token_hash":"token-hash"`) || !strings.Contains(string(cachePayload), `"token_id":"token-id"`) || !strings.Contains(string(cachePayload), `"state":1`) { t.Fatalf("cache action token JSON missing internal fields: %s", cachePayload) } var cached actionTokenCache if err := json.Unmarshal(cachePayload, &cached); err != nil { t.Fatal(err) } got := cached.toType() if got.TokenHash != token.TokenHash || got.TokenID != token.TokenID || got.UserID != token.UserID || got.State != token.State || got.Action != token.Action { t.Fatalf("cache round trip lost fields: got %#v", got) } } func TestValidateMessageHeadersNormalizesReplyJSONString(t *testing.T) { head := types.KVMap{ "x-im-schema": 1, "x-im-type": "text", "x-im-reply": `{"seq":128,"type":"text","text":"hello","from":"usr1"}`, } if err := validateMessageHeaders(head); err != nil { t.Fatalf("validateMessageHeaders() = %v, want nil", err) } reply, ok := head["x-im-reply"].(map[string]any) if !ok { t.Fatalf("x-im-reply was not normalized to object: %#v", head["x-im-reply"]) } if reply["seq"].(float64) != 128 || reply["type"] != "text" || reply["text"] != "hello" || reply["from"] != "usr1" { t.Fatalf("unexpected normalized reply: %#v", reply) } }