Add end-to-end encryption for chat messages (X25519 / NaCl box)
Messages between matched users are now encrypted client-side with each user's own key pair before they ever reach the server; the backend only ever stores/relays opaque ciphertext + nonce and cannot read message content, including for the last-message preview on the matches list. - User gains a PublicKey field, registered/rotated via PUT /profile/public-key - Message gains CipherText/Nonce; legacy plaintext Text is kept as-is for messages sent before this change - MatchView exposes the partner's public key and the last message's ciphertext/nonce so the client can decrypt previews itself - Removed the now-dead plaintext SanitizeMessageText utility — the server can no longer sanitize message content since it's opaque ciphertext Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -22,30 +22,45 @@ type SwipeResponse struct {
|
||||
Match *MatchView `json:"match,omitempty"`
|
||||
}
|
||||
|
||||
// MatchView خلاصهی یک مچ برای نمایش در لیست مچها/گفتگوها
|
||||
// MatchView خلاصهی یک مچ برای نمایش در لیست مچها/گفتگوها. برای پیشنمایش
|
||||
// آخرین پیام، سرور رمزگشایی نمیکنه (نمیتونه) — CipherText/Nonce خودِ آخرین
|
||||
// پیام رو برمیگردونه تا کلاینت خودش رمزگشایی کنه؛ LastMessage فقط برای
|
||||
// پیامهای قدیمیِ قبل از E2EE پر میشه.
|
||||
type MatchView struct {
|
||||
ID uint `json:"id"`
|
||||
UserID uint `json:"user_id"`
|
||||
FirstName string `json:"first_name"`
|
||||
Photos []string `json:"photos"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
LastMessage *string `json:"last_message,omitempty"`
|
||||
LastAt *string `json:"last_at,omitempty"`
|
||||
ID uint `json:"id"`
|
||||
UserID uint `json:"user_id"`
|
||||
FirstName string `json:"first_name"`
|
||||
Photos []string `json:"photos"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
LastMessage *string `json:"last_message,omitempty"`
|
||||
LastMessageCipherText *string `json:"last_message_cipher_text,omitempty"`
|
||||
LastMessageNonce *string `json:"last_message_nonce,omitempty"`
|
||||
LastAt *string `json:"last_at,omitempty"`
|
||||
// PartnerPublicKey کلید عمومی X25519 طرف مقابل (base64) برای رمزنگاری
|
||||
// سرتاسری؛ اگه طرف مقابل هنوز کلیدی ثبت نکرده باشه خالیه.
|
||||
PartnerPublicKey *string `json:"partner_public_key,omitempty"`
|
||||
}
|
||||
|
||||
// MessageView یک پیام در یک گفتگو
|
||||
// MessageView یک پیام در یک گفتگو. یا Text (پیام قدیمیِ قبل از E2EE) پر
|
||||
// میشه، یا CipherText+Nonce (پیام رمزنگاریشده) — سرور خودش نمیدونه محتوای
|
||||
// رمزنگاریشده چیه، فقط بین دو طرف مچ ردوبدلش میکنه.
|
||||
type MessageView struct {
|
||||
ID uint `json:"id"`
|
||||
MatchID uint `json:"match_id"`
|
||||
SenderID uint `json:"sender_id"`
|
||||
Text string `json:"text"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
IsMine bool `json:"is_mine"`
|
||||
ID uint `json:"id"`
|
||||
MatchID uint `json:"match_id"`
|
||||
SenderID uint `json:"sender_id"`
|
||||
Text string `json:"text,omitempty"`
|
||||
CipherText *string `json:"cipher_text,omitempty"`
|
||||
Nonce *string `json:"nonce,omitempty"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
IsMine bool `json:"is_mine"`
|
||||
}
|
||||
|
||||
// SendMessageRequest بدنهی درخواست ارسال پیام
|
||||
// SendMessageRequest بدنهی درخواست ارسال پیام رمزنگاریشده. متن خام پیام
|
||||
// هیچوقت به سرور نمیرسه؛ کلاینت قبل از ارسال با کلید عمومی طرف مقابل
|
||||
// رمزنگاریش میکنه.
|
||||
type SendMessageRequest struct {
|
||||
Text string `json:"text" binding:"required,min=1,max=2000"`
|
||||
CipherText string `json:"cipher_text" binding:"required,min=1,max=12000"`
|
||||
Nonce string `json:"nonce" binding:"required,len=32"`
|
||||
}
|
||||
|
||||
// CandidateAnswer پاسخ یک سوال پروفایل، برای نمایش عمومی
|
||||
|
||||
@@ -23,3 +23,10 @@ type UpdateNotificationSettingsRequest struct {
|
||||
NotifyMessages bool `json:"notify_messages"`
|
||||
NotifyLikes bool `json:"notify_likes"`
|
||||
}
|
||||
|
||||
// UpdatePublicKeyRequest ثبت/بهروزرسانی کلید عمومی X25519 کاربر برای
|
||||
// رمزنگاری سرتاسری پیامها. کلید باید دقیقاً ۳۲ بایت خام باشه که بهصورت
|
||||
// base64 استاندارد (۴۴ کاراکتر، شامل padding) کدگذاری شده.
|
||||
type UpdatePublicKeyRequest struct {
|
||||
PublicKey string `json:"public_key" binding:"required,len=44"`
|
||||
}
|
||||
|
||||
@@ -53,7 +53,9 @@ func (h *ChatHandler) ListMessages(c *gin.Context) {
|
||||
utils.Success(c, http.StatusOK, "", messages)
|
||||
}
|
||||
|
||||
// SendMessage POST /matches/:id/messages body: {text}
|
||||
// SendMessage POST /matches/:id/messages body: {cipher_text, nonce}
|
||||
// متن خام پیام هیچوقت اینجا نمیرسه؛ کلاینت قبل از ارسال با کلید عمومی
|
||||
// طرف مقابل (NaCl box / X25519) رمزنگاریش کرده.
|
||||
func (h *ChatHandler) SendMessage(c *gin.Context) {
|
||||
user, ok := currentUser(c)
|
||||
if !ok {
|
||||
@@ -66,10 +68,10 @@ func (h *ChatHandler) SendMessage(c *gin.Context) {
|
||||
}
|
||||
var req dto.SendMessageRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
utils.Fail(c, http.StatusBadRequest, "متن پیام نمیتونه خالی باشه", err.Error())
|
||||
utils.Fail(c, http.StatusBadRequest, "پیام رمزنگاریشده نامعتبر است", err.Error())
|
||||
return
|
||||
}
|
||||
msg, err := h.chatService.SendMessage(user.ID, uint(matchID), req.Text)
|
||||
msg, err := h.chatService.SendMessage(user.ID, uint(matchID), req.CipherText, req.Nonce)
|
||||
if err != nil {
|
||||
h.handleError(c, err)
|
||||
return
|
||||
@@ -81,9 +83,7 @@ func (h *ChatHandler) handleError(c *gin.Context, err error) {
|
||||
switch {
|
||||
case errors.Is(err, service.ErrNotMatchMember):
|
||||
utils.Fail(c, http.StatusForbidden, err.Error(), nil)
|
||||
case errors.Is(err, utils.ErrInvalidMessageEncoding),
|
||||
errors.Is(err, utils.ErrMessageEmpty),
|
||||
errors.Is(err, utils.ErrMessageTooLong):
|
||||
case errors.Is(err, service.ErrInvalidMessageEncoding):
|
||||
utils.Fail(c, http.StatusBadRequest, err.Error(), nil)
|
||||
default:
|
||||
utils.Fail(c, http.StatusNotFound, "گفتگو یافت نشد", nil)
|
||||
|
||||
@@ -201,3 +201,27 @@ func (h *ProfileHandler) DeleteAccount(c *gin.Context) {
|
||||
}
|
||||
utils.Success(c, http.StatusOK, "حساب کاربری غیرفعال شد", nil)
|
||||
}
|
||||
|
||||
// UpdatePublicKey PUT /profile/public-key — ثبت/جایگزینی کلید عمومی X25519
|
||||
// برای رمزنگاری سرتاسری پیامها. کلاینت این کلید رو خودش تولید میکنه؛ کلید
|
||||
// خصوصی متناظرش هیچوقت به این endpoint (یا هیچ endpoint دیگهای) فرستاده نمیشه.
|
||||
func (h *ProfileHandler) UpdatePublicKey(c *gin.Context) {
|
||||
user, ok := currentUser(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req dto.UpdatePublicKeyRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
utils.Fail(c, http.StatusBadRequest, "کلید عمومی نامعتبر است", err.Error())
|
||||
return
|
||||
}
|
||||
if err := h.profileService.UpdatePublicKey(user, req.PublicKey); err != nil {
|
||||
if errors.Is(err, service.ErrInvalidPublicKey) {
|
||||
utils.Fail(c, http.StatusBadRequest, err.Error(), nil)
|
||||
return
|
||||
}
|
||||
utils.Fail(c, http.StatusInternalServerError, "خطا در ذخیرهی کلید عمومی", nil)
|
||||
return
|
||||
}
|
||||
utils.Success(c, http.StatusOK, "کلید عمومی ذخیره شد", nil)
|
||||
}
|
||||
|
||||
@@ -55,14 +55,20 @@ func (Match) TableName() string {
|
||||
return "matches"
|
||||
}
|
||||
|
||||
// Message یک پیام متنی داخل یک مچ
|
||||
// Message یک پیام داخل یک مچ. از رمزنگاری سرتاسری (E2EE) استفاده میشه:
|
||||
// سرور فقط CipherText+Nonce (خروجی رمزنگاریشدهی سمت کلاینت با NaCl box،
|
||||
// X25519-XSalsa20-Poly1305) رو ذخیره میکنه و هیچوقت متن خام پیام رو نمیبینه.
|
||||
// Text قدیمی (قبل از اضافه شدن E2EE) برای نمایش پیامهای تاریخی نگه داشته میشه؛
|
||||
// پیامهای جدید Text خالی دارن و بهجاش CipherText/Nonce پر میشه.
|
||||
type Message struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
MatchID uint `gorm:"not null;index" json:"match_id"`
|
||||
SenderID uint `gorm:"not null" json:"sender_id"`
|
||||
Text string `gorm:"size:2000;not null" json:"text"`
|
||||
ReadAt *time.Time `json:"read_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
MatchID uint `gorm:"not null;index" json:"match_id"`
|
||||
SenderID uint `gorm:"not null" json:"sender_id"`
|
||||
Text string `gorm:"size:2000;not null" json:"text"`
|
||||
CipherText *string `gorm:"size:12000" json:"cipher_text,omitempty"`
|
||||
Nonce *string `gorm:"size:64" json:"nonce,omitempty"`
|
||||
ReadAt *time.Time `json:"read_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
func (Message) TableName() string {
|
||||
|
||||
@@ -108,6 +108,12 @@ type User struct {
|
||||
Latitude *float64 `json:"-"`
|
||||
Longitude *float64 `json:"-"`
|
||||
|
||||
// -------- کلید عمومی رمزنگاری سرتاسری پیامها (E2EE) --------
|
||||
// کلید خصوصی هیچوقت به سرور نمیرسه و فقط روی دستگاه کاربر (localStorage)
|
||||
// ذخیره میشه؛ سرور فقط این کلید عمومی رو نگه میداره تا طرف مقابل بتونه
|
||||
// باهاش پیام رمزنگاری کنه. کلید X25519 هست، base64-encoded (۳۲ بایت خام).
|
||||
PublicKey *string `gorm:"size:64" json:"public_key,omitempty"`
|
||||
|
||||
// -------- وضعیت آنبوردینگ --------
|
||||
CurrentStep OnboardingStep `gorm:"default:1;not null" json:"current_step"`
|
||||
IsOnboarded bool `gorm:"default:false;not null" json:"is_onboarded"`
|
||||
|
||||
@@ -63,6 +63,7 @@ func Setup(
|
||||
profile.POST("/checkin", h.Profile.CheckIn)
|
||||
profile.PATCH("/notifications", h.Profile.UpdateNotificationSettings)
|
||||
profile.DELETE("/me", h.Profile.DeleteAccount)
|
||||
profile.PUT("/public-key", h.Profile.UpdatePublicKey)
|
||||
}
|
||||
|
||||
// -------- آنبوردینگ (نیاز به ورود) --------
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"dating-app/internal/dto"
|
||||
"dating-app/internal/models"
|
||||
"dating-app/internal/repository"
|
||||
"dating-app/internal/utils"
|
||||
)
|
||||
|
||||
var ErrNotMatchMember = errors.New("این گفتگو مال تو نیست")
|
||||
var (
|
||||
ErrNotMatchMember = errors.New("این گفتگو مال تو نیست")
|
||||
ErrInvalidMessageEncoding = errors.New("پیام رمزنگاریشده نامعتبر است (کدگذاری اشتباه)")
|
||||
)
|
||||
|
||||
type ChatService struct {
|
||||
matchRepo *repository.MatchRepository
|
||||
@@ -85,17 +88,23 @@ func (s *ChatService) ListMatches(userID uint) ([]dto.MatchView, error) {
|
||||
}
|
||||
|
||||
view := dto.MatchView{
|
||||
ID: m.ID,
|
||||
UserID: other.ID,
|
||||
FirstName: other.FirstName,
|
||||
Photos: orEmptyStrings(photosByUser[otherID]),
|
||||
CreatedAt: m.CreatedAt.Format(time.RFC3339),
|
||||
ID: m.ID,
|
||||
UserID: other.ID,
|
||||
FirstName: other.FirstName,
|
||||
Photos: orEmptyStrings(photosByUser[otherID]),
|
||||
CreatedAt: m.CreatedAt.Format(time.RFC3339),
|
||||
PartnerPublicKey: other.PublicKey,
|
||||
}
|
||||
if last, ok := lastMessages[m.ID]; ok {
|
||||
text := last.Text
|
||||
at := last.CreatedAt.Format(time.RFC3339)
|
||||
view.LastMessage = &text
|
||||
view.LastAt = &at
|
||||
if last.CipherText != nil {
|
||||
view.LastMessageCipherText = last.CipherText
|
||||
view.LastMessageNonce = last.Nonce
|
||||
} else {
|
||||
text := last.Text
|
||||
view.LastMessage = &text
|
||||
}
|
||||
}
|
||||
views = append(views, view)
|
||||
}
|
||||
@@ -125,39 +134,46 @@ func (s *ChatService) ListMessages(userID, matchID uint) ([]dto.MessageView, err
|
||||
views := make([]dto.MessageView, 0, len(messages))
|
||||
for _, m := range messages {
|
||||
views = append(views, dto.MessageView{
|
||||
ID: m.ID,
|
||||
MatchID: m.MatchID,
|
||||
SenderID: m.SenderID,
|
||||
Text: m.Text,
|
||||
CreatedAt: m.CreatedAt.Format(time.RFC3339),
|
||||
IsMine: m.SenderID == userID,
|
||||
ID: m.ID,
|
||||
MatchID: m.MatchID,
|
||||
SenderID: m.SenderID,
|
||||
Text: m.Text,
|
||||
CipherText: m.CipherText,
|
||||
Nonce: m.Nonce,
|
||||
CreatedAt: m.CreatedAt.Format(time.RFC3339),
|
||||
IsMine: m.SenderID == userID,
|
||||
})
|
||||
}
|
||||
return views, nil
|
||||
}
|
||||
|
||||
// SendMessage یک پیام جدید توی یک مچ ثبت میکنه؛ فقط اگه کاربر عضو اون مچ باشه.
|
||||
func (s *ChatService) SendMessage(userID, matchID uint, text string) (*dto.MessageView, error) {
|
||||
// SendMessage یک پیام رمزنگاریشده توی یک مچ ثبت میکنه؛ فقط اگه کاربر عضو
|
||||
// اون مچ باشه. سرور محتوای پیام رو نمیبینه و نمیتونه — CipherText/Nonce
|
||||
// قبلاً روی دستگاه فرستنده با کلید عمومی طرف مقابل رمزنگاری شدن؛ اینجا فقط
|
||||
// اندازهی base64 اونا (از طریق binding توی SendMessageRequest) چک میشه.
|
||||
func (s *ChatService) SendMessage(userID, matchID uint, cipherText, nonce string) (*dto.MessageView, error) {
|
||||
if _, err := s.getAuthorizedMatch(userID, matchID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
clean, err := utils.SanitizeMessageText(text)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
if _, err := base64.StdEncoding.DecodeString(cipherText); err != nil {
|
||||
return nil, ErrInvalidMessageEncoding
|
||||
}
|
||||
if _, err := base64.StdEncoding.DecodeString(nonce); err != nil {
|
||||
return nil, ErrInvalidMessageEncoding
|
||||
}
|
||||
|
||||
msg := &models.Message{MatchID: matchID, SenderID: userID, Text: clean}
|
||||
msg := &models.Message{MatchID: matchID, SenderID: userID, CipherText: &cipherText, Nonce: &nonce}
|
||||
if err := s.messageRepo.Create(msg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &dto.MessageView{
|
||||
ID: msg.ID,
|
||||
MatchID: msg.MatchID,
|
||||
SenderID: msg.SenderID,
|
||||
Text: msg.Text,
|
||||
CreatedAt: msg.CreatedAt.Format(time.RFC3339),
|
||||
IsMine: true,
|
||||
ID: msg.ID,
|
||||
MatchID: msg.MatchID,
|
||||
SenderID: msg.SenderID,
|
||||
CipherText: msg.CipherText,
|
||||
Nonce: msg.Nonce,
|
||||
CreatedAt: msg.CreatedAt.Format(time.RFC3339),
|
||||
IsMine: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -13,10 +14,11 @@ import (
|
||||
const MaxPhotosPerUser = 6
|
||||
|
||||
var (
|
||||
ErrTooManyPhotos = errors.New("حداکثر تعداد عکس مجاز رو داری")
|
||||
ErrPhotoNotOwned = errors.New("این عکس متعلق به تو نیست")
|
||||
ErrInvalidImage = errors.New("فرمت فایل باید jpg، png یا webp باشه")
|
||||
ErrImageTooLarge = errors.New("حجم عکس نباید بیشتر از ۵ مگابایت باشه")
|
||||
ErrTooManyPhotos = errors.New("حداکثر تعداد عکس مجاز رو داری")
|
||||
ErrPhotoNotOwned = errors.New("این عکس متعلق به تو نیست")
|
||||
ErrInvalidImage = errors.New("فرمت فایل باید jpg، png یا webp باشه")
|
||||
ErrImageTooLarge = errors.New("حجم عکس نباید بیشتر از ۵ مگابایت باشه")
|
||||
ErrInvalidPublicKey = errors.New("کلید عمومی نامعتبر است")
|
||||
)
|
||||
|
||||
const MaxPhotoSizeBytes = 5 * 1024 * 1024
|
||||
@@ -163,3 +165,18 @@ func (s *ProfileService) UpdateNotificationSettings(user *models.User, req dto.U
|
||||
func (s *ProfileService) DeleteAccount(user *models.User) error {
|
||||
return s.userRepo.UpdateFields(user, map[string]interface{}{"is_active": false})
|
||||
}
|
||||
|
||||
// UpdatePublicKey کلید عمومی X25519 کاربر رو برای رمزنگاری سرتاسری پیامها
|
||||
// ثبت/جایگزین میکنه. کلید خصوصی متناظرش هیچوقت به سرور نمیرسه؛ اینجا فقط
|
||||
// طول کلید خام (باید دقیقاً ۳۲ بایت باشه) بعد از decode چک میشه، نه محتواش.
|
||||
func (s *ProfileService) UpdatePublicKey(user *models.User, publicKeyB64 string) error {
|
||||
raw, err := base64.StdEncoding.DecodeString(publicKeyB64)
|
||||
if err != nil || len(raw) != 32 {
|
||||
return ErrInvalidPublicKey
|
||||
}
|
||||
if err := s.userRepo.UpdateFields(user, map[string]interface{}{"public_key": publicKeyB64}); err != nil {
|
||||
return err
|
||||
}
|
||||
user.PublicKey = &publicKeyB64
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidMessageEncoding = errors.New("متن پیام معتبر نیست (کدگذاری اشتباه)")
|
||||
ErrMessageEmpty = errors.New("متن پیام نمیتواند خالی باشد")
|
||||
ErrMessageTooLong = errors.New("متن پیام بیش از حد مجاز طولانی است")
|
||||
)
|
||||
|
||||
const MaxMessageLength = 2000
|
||||
|
||||
// SanitizeMessageText متن یک پیام چت رو قبل از ذخیرهسازی پاکسازی/اعتبارسنجی میکنه:
|
||||
// - رد کردن رشتههایی که UTF-8 معتبر نیستن (بایت خراب/دستکاریشده)
|
||||
// - حذف کاراکترهای کنترلی (بهجز \n و \t) که میتونن نمایش یا لاگها رو خراب کنن
|
||||
// - trim فاصلههای اضافه و محدود کردن طول
|
||||
func SanitizeMessageText(raw string) (string, error) {
|
||||
if !utf8.ValidString(raw) {
|
||||
return "", ErrInvalidMessageEncoding
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.Grow(len(raw))
|
||||
for _, r := range raw {
|
||||
if r == '\n' || r == '\t' {
|
||||
b.WriteRune(r)
|
||||
continue
|
||||
}
|
||||
if r < 0x20 || r == 0x7F {
|
||||
continue
|
||||
}
|
||||
b.WriteRune(r)
|
||||
}
|
||||
|
||||
text := strings.TrimSpace(b.String())
|
||||
if text == "" {
|
||||
return "", ErrMessageEmpty
|
||||
}
|
||||
if utf8.RuneCountInString(text) > MaxMessageLength {
|
||||
return "", ErrMessageTooLong
|
||||
}
|
||||
return text, nil
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSanitizeMessageText_TrimsWhitespace(t *testing.T) {
|
||||
got, err := SanitizeMessageText(" سلام دوست من ")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != "سلام دوست من" {
|
||||
t.Fatalf("expected trimmed text, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeMessageText_EmptyAfterTrim(t *testing.T) {
|
||||
_, err := SanitizeMessageText(" ")
|
||||
if !errors.Is(err, ErrMessageEmpty) {
|
||||
t.Fatalf("expected ErrMessageEmpty, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeMessageText_StripsControlChars(t *testing.T) {
|
||||
got, err := SanitizeMessageText("سلام\x00\x07دنیا")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != "سلامدنیا" {
|
||||
t.Fatalf("expected control chars stripped, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeMessageText_KeepsNewlinesAndTabs(t *testing.T) {
|
||||
got, err := SanitizeMessageText("خط اول\nخط دوم\tبا تب")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != "خط اول\nخط دوم\tبا تب" {
|
||||
t.Fatalf("expected newline/tab preserved, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeMessageText_InvalidUTF8(t *testing.T) {
|
||||
invalid := string([]byte{0xff, 0xfe, 0xfd})
|
||||
_, err := SanitizeMessageText(invalid)
|
||||
if !errors.Is(err, ErrInvalidMessageEncoding) {
|
||||
t.Fatalf("expected ErrInvalidMessageEncoding, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeMessageText_TooLong(t *testing.T) {
|
||||
long := strings.Repeat("a", MaxMessageLength+1)
|
||||
_, err := SanitizeMessageText(long)
|
||||
if !errors.Is(err, ErrMessageTooLong) {
|
||||
t.Fatalf("expected ErrMessageTooLong, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeMessageText_ExactlyMaxLength(t *testing.T) {
|
||||
exact := strings.Repeat("a", MaxMessageLength)
|
||||
got, err := SanitizeMessageText(exact)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error at exact max length: %v", err)
|
||||
}
|
||||
if len(got) != MaxMessageLength {
|
||||
t.Fatalf("expected length %d, got %d", MaxMessageLength, len(got))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user