Enforce member label emoji and text constraints.

This commit is contained in:
jeffrey-signal
2026-02-26 08:32:32 -05:00
committed by GitHub
parent 503bf04ec5
commit 316d0e67c5
10 changed files with 407 additions and 19 deletions

View File

@@ -0,0 +1,71 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.groups.memberlabel
import android.app.Application
import io.mockk.every
import io.mockk.mockkObject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.thoughtcrime.securesms.emoji.EmojiSource
import org.thoughtcrime.securesms.testutil.MockAppDependenciesRule
@RunWith(RobolectricTestRunner::class)
@Config(manifest = Config.NONE, application = Application::class)
class MemberLabelEmojiValidationTest {
@get:Rule
val appDependencies = MockAppDependenciesRule()
private fun withEmojiSource(block: () -> Unit) {
val source = EmojiSource.loadAssetBasedEmojis()
mockkObject(EmojiSource) {
every { EmojiSource.latest } returns source
block()
}
}
@Test
fun `sanitizeEmoji returns valid emoji unchanged`() = withEmojiSource {
assertEquals("\uD83D\uDE0D", MemberLabel.sanitizeEmoji("\uD83D\uDE0D")) // 😍
}
@Test
fun `sanitizeEmoji returns valid ZWJ sequence unchanged`() = withEmojiSource {
val familyEmoji = "\uD83D\uDC68\u200D\uD83D\uDC69\u200D\uD83D\uDC67\u200D\uD83D\uDC66" // 👨‍👩‍👧‍
assertEquals(familyEmoji, MemberLabel.sanitizeEmoji(familyEmoji))
}
@Test
fun `sanitizeEmoji returns null for plain text`() = withEmojiSource {
assertNull(MemberLabel.sanitizeEmoji("hello"))
}
@Test
fun `sanitizeEmoji returns null for multiple emojis`() = withEmojiSource {
assertNull(MemberLabel.sanitizeEmoji("\uD83D\uDE0D\uD83D\uDE0D")) // 😍😍
}
@Test
fun `sanitizeEmoji returns null for emoji plus text`() = withEmojiSource {
assertNull(MemberLabel.sanitizeEmoji("\uD83D\uDE0Dhi"))
}
@Test
fun `sanitizeEmoji returns null for null input`() = withEmojiSource {
assertNull(MemberLabel.sanitizeEmoji(null))
}
@Test
fun `sanitizeEmoji returns null for empty string`() = withEmojiSource {
assertNull(MemberLabel.sanitizeEmoji(""))
}
}

View File

@@ -0,0 +1,177 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.thoughtcrime.securesms.groups.memberlabel
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.signal.core.util.StringUtil
class MemberLabelSanitizationTest {
@Test
fun `sanitizeLabelText trims leading and trailing whitespace`() {
assertEquals("hello", MemberLabel.sanitizeLabelText(" hello "))
}
@Test
fun `sanitizeLabelText replaces newline with space`() {
assertEquals("hello world", MemberLabel.sanitizeLabelText("hello\nworld"))
}
@Test
fun `sanitizeLabelText replaces carriage return with space`() {
assertEquals("hello world", MemberLabel.sanitizeLabelText("hello\rworld"))
}
@Test
fun `sanitizeLabelText replaces carriage return newline with space`() {
assertEquals("hello world", MemberLabel.sanitizeLabelText("hello\r\nworld"))
}
@Test
fun `sanitizeLabelText replaces tab with space`() {
assertEquals("hello world", MemberLabel.sanitizeLabelText("hello\tworld"))
}
@Test
fun `sanitizeLabelText collapses multiple spaces into one`() {
assertEquals("hello world", MemberLabel.sanitizeLabelText("hello world"))
}
@Test
fun `sanitizeLabelText collapses mixed whitespace into single space`() {
assertEquals("hello world", MemberLabel.sanitizeLabelText("hello \n\r\t world"))
}
@Test
fun `sanitizeLabelText collapses all-whitespace string`() {
assertEquals("", MemberLabel.sanitizeLabelText(" \n\r\t "))
}
@Test
fun `sanitizeLabelText preserves normal text`() {
assertEquals("hello world", MemberLabel.sanitizeLabelText("hello world"))
}
@Test
fun `sanitizeLabelText trims leading and trailing unicode whitespace characters`() {
assertEquals("hello", MemberLabel.sanitizeLabelText("\u200Ehello\u200F"))
}
@Test
fun `sanitizeLabelText does not truncate short text`() {
assertEquals("hello", MemberLabel.sanitizeLabelText("hello"))
}
@Test
fun `sanitizeLabelText truncates to 24 graphemes`() {
val input = "A".repeat(30)
val result = MemberLabel.sanitizeLabelText(input)
assertEquals("A".repeat(24), result)
}
@Test
fun `sanitizeLabelText counts emoji as single grapheme`() {
val input = "\uD83C\uDF89".repeat(30) // 🎉
val result = MemberLabel.sanitizeLabelText(input)
assertEquals("\uD83C\uDF89".repeat(24), result)
}
@Test
fun `sanitizeLabelText handles mix of ascii and emoji`() {
val input = "A".repeat(20) + "\uD83C\uDF89".repeat(10)
val result = MemberLabel.sanitizeLabelText(input)
assertEquals("A".repeat(20) + "\uD83C\uDF89".repeat(4), result)
}
@Test
fun `sanitizeLabelText enforces byte limit`() {
val fourByteEmoji = "\uD83C\uDF89" // 🎉 is 4 bytes in UTF-8
val input = fourByteEmoji.repeat(24)
val result = MemberLabel.sanitizeLabelText(input)
assertTrue(result.toByteArray(Charsets.UTF_8).size <= MemberLabel.MAX_LABEL_BYTES)
}
@Test
fun `sanitizeLabelText does not exceed byte limit with large graphemes`() {
val familyEmoji = "\uD83D\uDC68\u200D\uD83D\uDC69\u200D\uD83D\uDC67\u200D\uD83D\uDC66" // 👨‍👩‍👧‍👦 is 25 bytes in UTF-8
val input = familyEmoji.repeat(10)
val result = MemberLabel.sanitizeLabelText(input)
assertTrue(result.toByteArray(Charsets.UTF_8).size <= MemberLabel.MAX_LABEL_BYTES)
}
@Test
fun `truncateLabelText truncates to 24 graphemes`() {
val input = "A".repeat(30)
assertEquals("A".repeat(24), MemberLabel.truncateLabelText(input))
}
@Test
fun `truncateLabelText preserves trailing space`() {
assertEquals("hello ", MemberLabel.truncateLabelText("hello "))
}
@Test
fun `truncateLabelText preserves leading space`() {
assertEquals(" hello", MemberLabel.truncateLabelText(" hello"))
}
@Test
fun `truncateLabelText preserves multiple spaces between words`() {
assertEquals("hello world", MemberLabel.truncateLabelText("hello world"))
}
@Test
fun `truncateLabelText enforces byte limit`() {
val fourByteEmoji = "\uD83C\uDF89" // 🎉 = 4 bytes
val input = fourByteEmoji.repeat(30)
val result = MemberLabel.truncateLabelText(input)
assertTrue(result.toByteArray(Charsets.UTF_8).size <= MemberLabel.MAX_LABEL_BYTES)
assertEquals(fourByteEmoji.repeat(24), result)
}
@Test
fun `displayText wraps non-ascii label text with BiDi isolation`() {
val arabicText = "مرحبا بالعالم"
assertEquals("\u2068$arabicText\u2069", MemberLabel(emoji = null, text = arabicText).displayText)
}
@Test
fun `displayText does not wrap ascii-only label text`() {
val ascii = "Vet Coordinator"
assertEquals(ascii, MemberLabel(emoji = null, text = ascii).displayText)
}
@Test
fun `displayText balances unmatched opening BiDi character`() {
val unbalanced = "hello\u2067world"
val result = MemberLabel(emoji = null, text = unbalanced).displayText
assertTrue(result.startsWith("\u2068"))
assertTrue(result.endsWith("\u2069"))
}
@Test
fun `trimToFit does not truncate when within limit`() {
assertEquals("hello", StringUtil.trimToFit("hello", 10))
}
@Test
fun `trimToFit truncates ascii to byte limit`() {
val input = "A".repeat(100)
val result = StringUtil.trimToFit(input, 48)
assertEquals(48, result.toByteArray(Charsets.UTF_8).size)
assertEquals("A".repeat(48), result)
}
@Test
fun `trimToFit does not split multi-byte graphemes`() {
val emoji = "\uD83C\uDF89" // 🎉 = 4 bytes
val input = emoji.repeat(15)
val result = StringUtil.trimToFit(input, MemberLabel.MAX_EMOJI_BYTES)
assertTrue(result.toByteArray(Charsets.UTF_8).size <= MemberLabel.MAX_EMOJI_BYTES)
assertEquals(emoji.repeat(12), result)
}
}

View File

@@ -11,6 +11,7 @@ import io.mockk.mockk
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
@@ -229,4 +230,88 @@ class MemberLabelViewModelTest {
memberLabelRepo.setLabel(groupId, MemberLabel(text = "", emoji = null))
}
}
@Test
fun `onLabelTextChanged counts emoji as single grapheme`() = runTest(testDispatcher) {
coEvery { memberLabelRepo.getLabel(groupId, any<RecipientId>()) } returns null
val viewModel = MemberLabelViewModel(memberLabelRepo, groupId, recipientId)
val emoji = "\uD83C\uDF89" // 🎉
viewModel.onLabelTextChanged(emoji.repeat(30))
assertEquals(emoji.repeat(24), viewModel.uiState.value.labelText)
}
@Test
fun `remainingCharacters counts emoji as single grapheme`() = runTest(testDispatcher) {
coEvery { memberLabelRepo.getLabel(groupId, any<RecipientId>()) } returns null
val viewModel = MemberLabelViewModel(memberLabelRepo, groupId, recipientId)
val emoji = "\uD83C\uDF89" // 🎉
viewModel.onLabelTextChanged(emoji.repeat(10))
assertEquals(14, viewModel.uiState.value.remainingCharacters)
}
@Test
fun `remainingCharacters counts mixed ascii and emoji correctly`() = runTest(testDispatcher) {
coEvery { memberLabelRepo.getLabel(groupId, any<RecipientId>()) } returns null
val viewModel = MemberLabelViewModel(memberLabelRepo, groupId, recipientId)
viewModel.onLabelTextChanged("Hello \uD83C\uDF89") // "Hello 🎉" = 7 graphemes
assertEquals(17, viewModel.uiState.value.remainingCharacters)
}
@Test
fun `onLabelTextChanged does not truncate text within grapheme limit`() = runTest(testDispatcher) {
coEvery { memberLabelRepo.getLabel(groupId, any<RecipientId>()) } returns null
val viewModel = MemberLabelViewModel(memberLabelRepo, groupId, recipientId)
viewModel.onLabelTextChanged("Short label")
assertEquals("Short label", viewModel.uiState.value.labelText)
}
@Test
fun `onLabelTextChanged truncates at exactly 24 graphemes with emoji`() = runTest(testDispatcher) {
coEvery { memberLabelRepo.getLabel(groupId, any<RecipientId>()) } returns null
val viewModel = MemberLabelViewModel(memberLabelRepo, groupId, recipientId)
val input = "A".repeat(23) + "\uD83C\uDF89\uD83C\uDF89" // 25 graphemes
viewModel.onLabelTextChanged(input)
val expected = "A".repeat(23) + "\uD83C\uDF89" // 24 graphemes
assertEquals(expected, viewModel.uiState.value.labelText)
}
@Test
fun `isSaveEnabled returns false when the only change is trailing whitespace`() = runTest(testDispatcher) {
coEvery { memberLabelRepo.getLabel(groupId, any<RecipientId>()) } returns MemberLabel(emoji = null, text = "Original")
val viewModel = MemberLabelViewModel(memberLabelRepo, groupId, recipientId)
viewModel.onLabelTextChanged("Original ")
assertFalse(viewModel.uiState.value.isSaveEnabled)
}
@Test
fun `isSaveEnabled returns false when the only change is leading whitespace`() = runTest(testDispatcher) {
coEvery { memberLabelRepo.getLabel(groupId, any<RecipientId>()) } returns MemberLabel(emoji = null, text = "Original")
val viewModel = MemberLabelViewModel(memberLabelRepo, groupId, recipientId)
viewModel.onLabelTextChanged(" Original")
assertFalse(viewModel.uiState.value.isSaveEnabled)
}
@Test
fun `isSaveEnabled returns true when text differs beyond whitespace`() = runTest(testDispatcher) {
coEvery { memberLabelRepo.getLabel(groupId, any<RecipientId>()) } returns MemberLabel(emoji = null, text = "Original")
val viewModel = MemberLabelViewModel(memberLabelRepo, groupId, recipientId)
viewModel.onLabelTextChanged(" Modified ")
assertTrue(viewModel.uiState.value.isSaveEnabled)
}
}