mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-04-20 00:29:11 +01:00
Re-organize gradle modules.
This commit is contained in:
committed by
jeffrey-signal
parent
f4863efb2e
commit
e162eb27c7
@@ -0,0 +1,292 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.signalservice.api
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException
|
||||
import org.whispersystems.signalservice.api.push.exceptions.PushNetworkException
|
||||
|
||||
class NetworkResultTest {
|
||||
@Test
|
||||
fun `generic success`() {
|
||||
val result = NetworkResult.fromFetch {}
|
||||
|
||||
assertTrue(result is NetworkResult.Success)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `generic non-successful status code`() {
|
||||
val exception = NonSuccessfulResponseCodeException(404, "not found", "body")
|
||||
|
||||
val result = NetworkResult.fromFetch {
|
||||
throw exception
|
||||
}
|
||||
|
||||
check(result is NetworkResult.StatusCodeError)
|
||||
assertEquals(exception, result.exception)
|
||||
assertEquals(404, result.code)
|
||||
assertEquals("body", result.stringBody)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `generic network error`() {
|
||||
val exception = PushNetworkException("general exception")
|
||||
|
||||
val result = NetworkResult.fromFetch {
|
||||
throw exception
|
||||
}
|
||||
|
||||
assertTrue(result is NetworkResult.NetworkError)
|
||||
assertEquals(exception, (result as NetworkResult.NetworkError).exception)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `generic application error`() {
|
||||
val throwable = RuntimeException("test")
|
||||
|
||||
val result = NetworkResult.fromFetch {
|
||||
throw throwable
|
||||
}
|
||||
|
||||
assertTrue(result is NetworkResult.ApplicationError)
|
||||
assertEquals(throwable, (result as NetworkResult.ApplicationError).throwable)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `then - generic`() {
|
||||
val result = NetworkResult
|
||||
.fromFetch { NetworkResult.Success(1) }
|
||||
.then { NetworkResult.Success(2) }
|
||||
|
||||
assertTrue(result is NetworkResult.Success)
|
||||
assertEquals(2, (result as NetworkResult.Success).result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `then - doesn't run on error`() {
|
||||
val throwable = RuntimeException("test")
|
||||
var run = false
|
||||
|
||||
val result = NetworkResult
|
||||
.fromFetch { throw throwable }
|
||||
.then {
|
||||
run = true
|
||||
NetworkResult.Success(1)
|
||||
}
|
||||
|
||||
assertTrue(result is NetworkResult.ApplicationError)
|
||||
assertFalse(run)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `map - generic`() {
|
||||
val result = NetworkResult
|
||||
.fromFetch { NetworkResult.Success(1) }
|
||||
.map { 2 }
|
||||
|
||||
assertTrue(result is NetworkResult.Success)
|
||||
assertEquals(2, (result as NetworkResult.Success).result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `map - doesn't run on error`() {
|
||||
val throwable = RuntimeException("test")
|
||||
var run = false
|
||||
|
||||
val result = NetworkResult
|
||||
.fromFetch { throw throwable }
|
||||
.map {
|
||||
run = true
|
||||
1
|
||||
}
|
||||
|
||||
assertTrue(result is NetworkResult.ApplicationError)
|
||||
assertFalse(run)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `runIfSuccessful - doesn't run on error`() {
|
||||
val throwable = RuntimeException("test")
|
||||
var run = false
|
||||
|
||||
val result = NetworkResult
|
||||
.fromFetch { throw throwable }
|
||||
.runIfSuccessful { run = true }
|
||||
|
||||
assertTrue(result is NetworkResult.ApplicationError)
|
||||
assertFalse(run)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `runIfSuccessful - runs on success`() {
|
||||
var run = false
|
||||
|
||||
NetworkResult
|
||||
.fromFetch { NetworkResult.Success(1) }
|
||||
.runIfSuccessful { run = true }
|
||||
|
||||
assertTrue(run)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `runIfSuccessful - runs before error`() {
|
||||
val throwable = RuntimeException("test")
|
||||
var run = false
|
||||
|
||||
val result = NetworkResult
|
||||
.fromFetch { NetworkResult.Success(Unit) }
|
||||
.runIfSuccessful { run = true }
|
||||
.then { NetworkResult.ApplicationError<Unit>(throwable) }
|
||||
|
||||
assertTrue(result is NetworkResult.ApplicationError)
|
||||
assertTrue(run)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `runOnStatusCodeError - simple call`() {
|
||||
var handled = false
|
||||
|
||||
NetworkResult
|
||||
.fromFetch { throw NonSuccessfulResponseCodeException(404, "not found", "body") }
|
||||
.runOnStatusCodeError { handled = true }
|
||||
|
||||
assertTrue(handled)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `runOnStatusCodeError - ensure only called once`() {
|
||||
var handleCount = 0
|
||||
|
||||
NetworkResult
|
||||
.fromFetch { throw NonSuccessfulResponseCodeException(404, "not found", "body") }
|
||||
.runOnStatusCodeError { handleCount++ }
|
||||
.map { 1 }
|
||||
.then { NetworkResult.Success(2) }
|
||||
.map { 3 }
|
||||
|
||||
assertEquals(1, handleCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `runOnStatusCodeError - called when placed before a failing then`() {
|
||||
var handled = false
|
||||
|
||||
val result = NetworkResult
|
||||
.fromFetch { }
|
||||
.runOnStatusCodeError { handled = true }
|
||||
.then { NetworkResult.fromFetch { throw NonSuccessfulResponseCodeException(404, "not found", "body") } }
|
||||
|
||||
assertTrue(handled)
|
||||
assertTrue(result is NetworkResult.StatusCodeError)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `runOnStatusCodeError - called when placed two spots before a failing then`() {
|
||||
var handled = false
|
||||
|
||||
val result = NetworkResult
|
||||
.fromFetch { }
|
||||
.runOnStatusCodeError { handled = true }
|
||||
.then { NetworkResult.Success(Unit) }
|
||||
.then { NetworkResult.fromFetch { throw NonSuccessfulResponseCodeException(404, "not found", "body") } }
|
||||
|
||||
assertTrue(handled)
|
||||
assertTrue(result is NetworkResult.StatusCodeError)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `runOnStatusCodeError - should not be called for successful results`() {
|
||||
var handled = false
|
||||
|
||||
NetworkResult
|
||||
.fromFetch {}
|
||||
.runOnStatusCodeError { handled = true }
|
||||
|
||||
NetworkResult
|
||||
.fromFetch { throw RuntimeException("application error") }
|
||||
.runOnStatusCodeError { handled = true }
|
||||
|
||||
NetworkResult
|
||||
.fromFetch { throw PushNetworkException("network error") }
|
||||
.runOnStatusCodeError { handled = true }
|
||||
|
||||
assertFalse(handled)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `runOnApplicationError - simple call`() {
|
||||
var handled = false
|
||||
|
||||
NetworkResult
|
||||
.fromFetch { throw RuntimeException() }
|
||||
.runOnApplicationError { handled = true }
|
||||
|
||||
assertTrue(handled)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `runOnApplicationError - ensure only called once`() {
|
||||
var handleCount = 0
|
||||
|
||||
NetworkResult
|
||||
.fromFetch { throw RuntimeException() }
|
||||
.runOnApplicationError { handleCount++ }
|
||||
.map { 1 }
|
||||
.then { NetworkResult.Success(2) }
|
||||
.map { 3 }
|
||||
|
||||
assertEquals(1, handleCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `runOnApplicationError - called when placed before a failing then`() {
|
||||
var handled = false
|
||||
|
||||
val result = NetworkResult
|
||||
.fromFetch { }
|
||||
.runOnApplicationError { handled = true }
|
||||
.then { NetworkResult.fromFetch { throw RuntimeException() } }
|
||||
|
||||
assertTrue(handled)
|
||||
assertTrue(result is NetworkResult.ApplicationError)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `runOnApplicationError - called when placed two spots before a failing then`() {
|
||||
var handled = false
|
||||
|
||||
val result = NetworkResult
|
||||
.fromFetch { }
|
||||
.runOnApplicationError { handled = true }
|
||||
.then { NetworkResult.Success(Unit) }
|
||||
.then { NetworkResult.fromFetch { throw RuntimeException() } }
|
||||
|
||||
assertTrue(handled)
|
||||
assertTrue(result is NetworkResult.ApplicationError)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `runOnApplicationError - should not be called for successful results`() {
|
||||
var handled = false
|
||||
|
||||
NetworkResult
|
||||
.fromFetch {}
|
||||
.runOnApplicationError { handled = true }
|
||||
|
||||
NetworkResult
|
||||
.fromFetch { throw NonSuccessfulResponseCodeException(404, "not found", "body") }
|
||||
.runOnApplicationError { handled = true }
|
||||
|
||||
NetworkResult
|
||||
.fromFetch { throw PushNetworkException("network error") }
|
||||
.runOnApplicationError { handled = true }
|
||||
|
||||
assertFalse(handled)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.signalservice.api.crypto
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
import org.signal.core.util.copyTo
|
||||
import org.whispersystems.signalservice.internal.util.Util
|
||||
import java.io.ByteArrayOutputStream
|
||||
|
||||
class AttachmentCipherStreamUtilTest {
|
||||
|
||||
@Test
|
||||
fun `getCiphertextLength should return the correct length`() {
|
||||
for (length in 0..1024) {
|
||||
val plaintext = ByteArray(length).also { it.fill(0x42) }
|
||||
val key = Util.getSecretBytes(64)
|
||||
val iv = Util.getSecretBytes(16)
|
||||
|
||||
val outputStream = ByteArrayOutputStream()
|
||||
val cipherStream = AttachmentCipherOutputStream(key, iv, outputStream)
|
||||
plaintext.inputStream().copyTo(cipherStream)
|
||||
|
||||
val expected = AttachmentCipherStreamUtil.getCiphertextLength(length.toLong())
|
||||
val actual = outputStream.size().toLong()
|
||||
|
||||
assertEquals(expected, actual)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,954 @@
|
||||
package org.whispersystems.signalservice.api.crypto
|
||||
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.isEqualTo
|
||||
import assertk.assertions.isTrue
|
||||
import assertk.fail
|
||||
import org.conscrypt.Conscrypt
|
||||
import org.junit.Assert
|
||||
import org.junit.Ignore
|
||||
import org.junit.Test
|
||||
import org.signal.core.util.StreamUtil
|
||||
import org.signal.core.util.readFully
|
||||
import org.signal.libsignal.protocol.InvalidMessageException
|
||||
import org.signal.libsignal.protocol.incrementalmac.ChunkSizeChoice
|
||||
import org.signal.libsignal.protocol.incrementalmac.InvalidMacException
|
||||
import org.signal.libsignal.protocol.kdf.HKDF
|
||||
import org.whispersystems.signalservice.api.crypto.AttachmentCipherInputStream.IntegrityCheck
|
||||
import org.whispersystems.signalservice.api.crypto.AttachmentCipherTestHelper.createMediaKeyMaterial
|
||||
import org.whispersystems.signalservice.internal.crypto.PaddingInputStream
|
||||
import org.whispersystems.signalservice.internal.push.http.AttachmentCipherOutputStreamFactory
|
||||
import org.whispersystems.signalservice.internal.util.Util
|
||||
import org.whispersystems.signalservice.testutil.LibSignalLibraryUtil
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
import java.lang.AssertionError
|
||||
import java.security.DigestInputStream
|
||||
import java.security.MessageDigest
|
||||
import java.security.Security
|
||||
import java.util.Random
|
||||
|
||||
class AttachmentCipherTest {
|
||||
@Test
|
||||
fun attachment_encryptDecrypt_nonIncremental_encryptedDigest() {
|
||||
attachment_encryptDecrypt(incremental = false, fileSize = MEBIBYTE, integrityCheckMode = IntegrityCheckMode.ENCRYPTED_DIGEST)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun attachment_encryptDecrypt_nonIncremental_plaintextHash() {
|
||||
attachment_encryptDecrypt(incremental = false, fileSize = MEBIBYTE, integrityCheckMode = IntegrityCheckMode.PLAINTEXT_HASH)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun attachment_encryptDecrypt_nonIncremental_bothIntegrityChecks() {
|
||||
attachment_encryptDecrypt(incremental = false, fileSize = MEBIBYTE, integrityCheckMode = IntegrityCheckMode.BOTH)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun attachment_encryptDecrypt_incremental_encryptedDigest() {
|
||||
attachment_encryptDecrypt(incremental = true, fileSize = MEBIBYTE, integrityCheckMode = IntegrityCheckMode.ENCRYPTED_DIGEST)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun attachment_encryptDecrypt_incremental_plaintextHash() {
|
||||
attachment_encryptDecrypt(incremental = true, fileSize = MEBIBYTE, integrityCheckMode = IntegrityCheckMode.PLAINTEXT_HASH)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun attachment_encryptDecrypt_incremental_bothIntegrityChecks() {
|
||||
attachment_encryptDecrypt(incremental = true, fileSize = MEBIBYTE, integrityCheckMode = IntegrityCheckMode.BOTH)
|
||||
}
|
||||
|
||||
@Ignore("Useful when making changes, otherwise a bit slow.")
|
||||
@Test
|
||||
fun attachment_encryptDecrypt_nonIncremental_manyFileSizes() {
|
||||
for (i in 0..99) {
|
||||
attachment_encryptDecrypt(incremental = false, fileSize = MEBIBYTE + Random().nextInt(1, 64 * 1024), integrityCheckMode = IntegrityCheckMode.BOTH)
|
||||
}
|
||||
}
|
||||
|
||||
@Ignore("Useful when making changes, otherwise a bit slow.")
|
||||
@Test
|
||||
fun attachment_encryptDecrypt_incremental_manyFileSizes() {
|
||||
// Designed to stress the various boundary conditions of reading the final mac
|
||||
for (i in 0..99) {
|
||||
attachment_encryptDecrypt(incremental = true, fileSize = MEBIBYTE + Random().nextInt(1, 64 * 1024), IntegrityCheckMode.BOTH)
|
||||
}
|
||||
}
|
||||
|
||||
private fun attachment_encryptDecrypt(incremental: Boolean, fileSize: Int, integrityCheckMode: IntegrityCheckMode) {
|
||||
val key = Util.getSecretBytes(64)
|
||||
val plaintextInput = Util.getSecretBytes(fileSize)
|
||||
val plaintextHash = MessageDigest.getInstance("SHA-256").digest(plaintextInput)
|
||||
|
||||
val encryptResult = encryptData(plaintextInput, key, incremental)
|
||||
val cipherFile = writeToFile(encryptResult.ciphertext)
|
||||
|
||||
val integrityCheck = when (integrityCheckMode) {
|
||||
IntegrityCheckMode.ENCRYPTED_DIGEST -> IntegrityCheck.forEncryptedDigest(encryptResult.digest)
|
||||
IntegrityCheckMode.PLAINTEXT_HASH -> IntegrityCheck.forPlaintextHash(plaintextHash)
|
||||
IntegrityCheckMode.BOTH -> IntegrityCheck(
|
||||
encryptedDigest = encryptResult.digest,
|
||||
plaintextHash = plaintextHash
|
||||
)
|
||||
}
|
||||
val inputStream = AttachmentCipherInputStream.createForAttachment(cipherFile, plaintextInput.size.toLong(), key, integrityCheck, encryptResult.incrementalDigest, encryptResult.chunkSizeChoice)
|
||||
val plaintextOutput = inputStream.readFully()
|
||||
|
||||
assertThat(plaintextOutput).isEqualTo(plaintextInput)
|
||||
|
||||
cipherFile.delete()
|
||||
}
|
||||
|
||||
@Ignore("Useful when making changes, otherwise a bit slow.")
|
||||
@Test
|
||||
fun attachment_encryptDecrypt_skipAll_manyFileSizes() {
|
||||
for (i in 0..99) {
|
||||
attachment_encryptDecrypt_skipAll(incremental = false, fileSize = MEBIBYTE + Random().nextInt(1, 64 * 1024))
|
||||
}
|
||||
}
|
||||
|
||||
private fun attachment_encryptDecrypt_skipAll(incremental: Boolean, fileSize: Int) {
|
||||
val key = Util.getSecretBytes(64)
|
||||
val plaintextInput = Util.getSecretBytes(fileSize)
|
||||
val plaintextHash = MessageDigest.getInstance("SHA-256").digest(plaintextInput)
|
||||
|
||||
val encryptResult = encryptData(plaintextInput, key, incremental)
|
||||
val cipherFile = writeToFile(encryptResult.ciphertext)
|
||||
|
||||
val integrityCheck = IntegrityCheck(
|
||||
encryptedDigest = encryptResult.digest,
|
||||
plaintextHash = plaintextHash
|
||||
)
|
||||
val inputStream = AttachmentCipherInputStream.createForAttachment(cipherFile, plaintextInput.size.toLong(), key, integrityCheck, encryptResult.incrementalDigest, encryptResult.chunkSizeChoice)
|
||||
while (inputStream.skip(cipherFile.length()) > 0) {
|
||||
// Empty body, just skipping
|
||||
}
|
||||
cipherFile.delete()
|
||||
}
|
||||
|
||||
private fun attachment_encryptDecrypt_plaintextHash(incremental: Boolean, fileSize: Int) {
|
||||
val key = Util.getSecretBytes(64)
|
||||
val plaintextInput = Util.getSecretBytes(fileSize)
|
||||
val plaintextHash = MessageDigest.getInstance("SHA-256").digest(plaintextInput)
|
||||
|
||||
val encryptResult = encryptData(plaintextInput, key, incremental)
|
||||
val cipherFile = writeToFile(encryptResult.ciphertext)
|
||||
|
||||
val integrityCheck = IntegrityCheck.forPlaintextHash(plaintextHash)
|
||||
val inputStream = AttachmentCipherInputStream.createForAttachment(cipherFile, plaintextInput.size.toLong(), key, integrityCheck, encryptResult.incrementalDigest, encryptResult.chunkSizeChoice)
|
||||
val plaintextOutput = inputStream.readFully()
|
||||
|
||||
assertThat(plaintextOutput).isEqualTo(plaintextInput)
|
||||
|
||||
cipherFile.delete()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun attachment_encryptDecryptEmpty_nonIncremental() {
|
||||
attachment_encryptDecryptEmpty(incremental = false)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun attachment_encryptDecryptEmpty_incremental() {
|
||||
attachment_encryptDecryptEmpty(incremental = true)
|
||||
}
|
||||
|
||||
private fun attachment_encryptDecryptEmpty(incremental: Boolean) {
|
||||
val key = Util.getSecretBytes(64)
|
||||
val plaintextInput = "".toByteArray()
|
||||
|
||||
val encryptResult = encryptData(plaintextInput, key, incremental)
|
||||
val cipherFile = writeToFile(encryptResult.ciphertext)
|
||||
|
||||
val integrityCheck = IntegrityCheck.forEncryptedDigest(encryptResult.digest)
|
||||
val inputStream = AttachmentCipherInputStream.createForAttachment(cipherFile, plaintextInput.size.toLong(), key, integrityCheck, encryptResult.incrementalDigest, encryptResult.chunkSizeChoice)
|
||||
val plaintextOutput = inputStream.readFully()
|
||||
|
||||
Assert.assertArrayEquals(plaintextInput, plaintextOutput)
|
||||
|
||||
cipherFile.delete()
|
||||
}
|
||||
|
||||
@Test(expected = InvalidMessageException::class)
|
||||
fun attachment_decryptFailOnBadKey_nonIncremental() {
|
||||
attachment_decryptFailOnBadKey(incremental = false)
|
||||
}
|
||||
|
||||
@Test(expected = InvalidMessageException::class)
|
||||
fun attachment_decryptFailOnBadKey_incremental() {
|
||||
attachment_decryptFailOnBadKey(incremental = true)
|
||||
}
|
||||
|
||||
private fun attachment_decryptFailOnBadKey(incremental: Boolean) {
|
||||
var cipherFile: File? = null
|
||||
|
||||
try {
|
||||
val key = Util.getSecretBytes(64)
|
||||
val plaintextInput = Util.getSecretBytes(MEBIBYTE)
|
||||
|
||||
val encryptResult = encryptData(plaintextInput, key, incremental)
|
||||
cipherFile = writeToFile(encryptResult.ciphertext)
|
||||
|
||||
val badKey = ByteArray(64)
|
||||
val integrityCheck = IntegrityCheck.forEncryptedDigest(encryptResult.digest)
|
||||
AttachmentCipherInputStream.createForAttachment(cipherFile, plaintextInput.size.toLong(), badKey, integrityCheck, null, 0)
|
||||
} finally {
|
||||
cipherFile?.delete()
|
||||
}
|
||||
}
|
||||
|
||||
@Test(expected = InvalidMessageException::class)
|
||||
fun attachment_decryptFailOnBadMac_nonIncremental() {
|
||||
attachment_decryptFailOnBadMac(incremental = false)
|
||||
}
|
||||
|
||||
@Test(expected = InvalidMessageException::class)
|
||||
fun attachment_decryptFailOnBadMac_incremental() {
|
||||
attachment_decryptFailOnBadMac(incremental = true)
|
||||
}
|
||||
|
||||
private fun attachment_decryptFailOnBadMac(incremental: Boolean) {
|
||||
var cipherFile: File? = null
|
||||
|
||||
try {
|
||||
val key = Util.getSecretBytes(64)
|
||||
val plaintextInput = Util.getSecretBytes(MEBIBYTE)
|
||||
|
||||
val encryptResult = encryptData(plaintextInput, key, incremental)
|
||||
val badMacCiphertext = encryptResult.ciphertext.copyOf(encryptResult.ciphertext.size)
|
||||
|
||||
badMacCiphertext[badMacCiphertext.size - 1] = (badMacCiphertext[badMacCiphertext.size - 1] + 1).toByte()
|
||||
|
||||
cipherFile = writeToFile(badMacCiphertext)
|
||||
|
||||
val integrityCheck = IntegrityCheck.forEncryptedDigest(encryptResult.digest)
|
||||
val stream: InputStream = AttachmentCipherInputStream.createForAttachment(cipherFile, plaintextInput.size.toLong(), key, integrityCheck, encryptResult.incrementalDigest, encryptResult.chunkSizeChoice)
|
||||
|
||||
// In incremental mode, we'll only check the digest after reading the whole thing
|
||||
if (incremental) {
|
||||
StreamUtil.readFully(stream)
|
||||
}
|
||||
} finally {
|
||||
cipherFile?.delete()
|
||||
}
|
||||
}
|
||||
|
||||
@Test(expected = InvalidMessageException::class)
|
||||
fun attachment_decryptFailOnBadEncryptedDigest_nonIncremental() {
|
||||
attachment_decryptFailOnBadEncryptedDigest(incremental = false)
|
||||
}
|
||||
|
||||
@Test(expected = InvalidMessageException::class)
|
||||
fun attachment_decryptFailOnBadEncryptedDigest_incremental() {
|
||||
attachment_decryptFailOnBadEncryptedDigest(incremental = true)
|
||||
}
|
||||
|
||||
@Test(expected = InvalidMessageException::class)
|
||||
fun attachment_decryptFailOnBadPlaintextHash_nonIncremental() {
|
||||
attachment_decryptFailOnBadPlaintextHash(incremental = false)
|
||||
}
|
||||
|
||||
@Test(expected = InvalidMessageException::class)
|
||||
fun attachment_decryptFailOnBadPlaintextHash_incremental() {
|
||||
attachment_decryptFailOnBadPlaintextHash(incremental = true)
|
||||
}
|
||||
|
||||
private fun attachment_decryptFailOnBadEncryptedDigest(incremental: Boolean) {
|
||||
var cipherFile: File? = null
|
||||
|
||||
try {
|
||||
val key = Util.getSecretBytes(64)
|
||||
val plaintextInput = Util.getSecretBytes(MEBIBYTE)
|
||||
|
||||
val encryptResult = encryptData(plaintextInput, key, incremental)
|
||||
val badDigest = ByteArray(32)
|
||||
|
||||
cipherFile = writeToFile(encryptResult.ciphertext)
|
||||
|
||||
val integrityCheck = IntegrityCheck.forEncryptedDigest(badDigest)
|
||||
val stream: InputStream = AttachmentCipherInputStream.createForAttachment(cipherFile, plaintextInput.size.toLong(), key, integrityCheck, encryptResult.incrementalDigest, encryptResult.chunkSizeChoice)
|
||||
|
||||
// In incremental mode, we'll only check the digest after reading the whole thing
|
||||
if (incremental) {
|
||||
StreamUtil.readFully(stream)
|
||||
}
|
||||
} finally {
|
||||
cipherFile?.delete()
|
||||
}
|
||||
}
|
||||
|
||||
private fun attachment_decryptFailOnBadPlaintextHash(incremental: Boolean) {
|
||||
var cipherFile: File? = null
|
||||
|
||||
try {
|
||||
val key = Util.getSecretBytes(64)
|
||||
val plaintextInput = Util.getSecretBytes(MEBIBYTE)
|
||||
val badPlaintextHash = MessageDigest.getInstance("SHA-256").digest(plaintextInput).apply {
|
||||
this[0] = (this[0] + 1).toByte()
|
||||
}
|
||||
|
||||
val encryptResult = encryptData(plaintextInput, key, incremental)
|
||||
|
||||
cipherFile = writeToFile(encryptResult.ciphertext)
|
||||
|
||||
val integrityCheck = IntegrityCheck.forPlaintextHash(badPlaintextHash)
|
||||
val stream: InputStream = AttachmentCipherInputStream.createForAttachment(cipherFile, plaintextInput.size.toLong(), key, integrityCheck, encryptResult.incrementalDigest, encryptResult.chunkSizeChoice)
|
||||
|
||||
StreamUtil.readFully(stream)
|
||||
} finally {
|
||||
cipherFile?.delete()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun attachment_decryptFailOnBadIncrementalDigest() {
|
||||
var cipherFile: File? = null
|
||||
var hitCorrectException = false
|
||||
|
||||
try {
|
||||
val key = Util.getSecretBytes(64)
|
||||
val plaintextInput = Util.getSecretBytes(MEBIBYTE)
|
||||
|
||||
val encryptResult = encryptData(plaintextInput, key, true)
|
||||
val badDigest = Util.getSecretBytes(encryptResult.incrementalDigest.size)
|
||||
|
||||
cipherFile = writeToFile(encryptResult.ciphertext)
|
||||
|
||||
val integrityCheck = IntegrityCheck.forEncryptedDigest(encryptResult.digest)
|
||||
val decryptedStream: InputStream = AttachmentCipherInputStream.createForAttachment(cipherFile, plaintextInput.size.toLong(), key, integrityCheck, badDigest, encryptResult.chunkSizeChoice)
|
||||
val plaintextOutput = readInputStreamFully(decryptedStream)
|
||||
|
||||
fail(AssertionError("Expected to fail before hitting this line"))
|
||||
} catch (e: InvalidMacException) {
|
||||
hitCorrectException = true
|
||||
} catch (e: InvalidMessageException) {
|
||||
hitCorrectException = false
|
||||
} finally {
|
||||
cipherFile?.delete()
|
||||
}
|
||||
|
||||
assertThat(hitCorrectException).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun archiveInnerAndOuterLayer_encryptDecryptEmpty() {
|
||||
val innerKey = Util.getSecretBytes(64)
|
||||
val plaintextInput = "".toByteArray()
|
||||
|
||||
val innerEncryptResult = encryptData(plaintextInput, innerKey, withIncremental = false)
|
||||
val outerKey = Util.getSecretBytes(64)
|
||||
|
||||
val outerEncryptResult = encryptData(innerEncryptResult.ciphertext, outerKey, false)
|
||||
val cipherFile = writeToFile(outerEncryptResult.ciphertext)
|
||||
|
||||
val keyMaterial = createMediaKeyMaterial(outerKey)
|
||||
val decryptedStream = AttachmentCipherInputStream.createForArchivedMedia(
|
||||
archivedMediaKeyMaterial = keyMaterial,
|
||||
file = cipherFile,
|
||||
originalCipherTextLength = innerEncryptResult.ciphertext.size.toLong(),
|
||||
plaintextLength = plaintextInput.size.toLong(),
|
||||
combinedKeyMaterial = innerKey,
|
||||
plaintextHash = innerEncryptResult.plaintextHash,
|
||||
incrementalDigest = innerEncryptResult.incrementalDigest,
|
||||
incrementalMacChunkSize = innerEncryptResult.chunkSizeChoice
|
||||
)
|
||||
val plaintextOutput = decryptedStream.readFully()
|
||||
|
||||
assertThat(plaintextOutput).isEqualTo(plaintextInput)
|
||||
|
||||
cipherFile.delete()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun archiveInnerAndOuterLayer_decryptFailOnBadKey_nonIncremental() {
|
||||
archiveInnerAndOuterLayer_decryptFailOnBadKey(incremental = false)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun archiveInnerAndOuterLayer_decryptFailOnBadKey_incremental() {
|
||||
archiveInnerAndOuterLayer_decryptFailOnBadKey(incremental = true)
|
||||
}
|
||||
|
||||
private fun archiveInnerAndOuterLayer_decryptFailOnBadKey(incremental: Boolean) {
|
||||
var cipherFile: File? = null
|
||||
var hitCorrectException = false
|
||||
|
||||
try {
|
||||
val innerKey = Util.getSecretBytes(64)
|
||||
val badInnerKey = Util.getSecretBytes(64)
|
||||
val plaintextInput = "Gwen Stacy".toByteArray()
|
||||
|
||||
val innerEncryptResult = encryptData(plaintextInput, innerKey, incremental)
|
||||
val outerKey = Util.getSecretBytes(64)
|
||||
|
||||
val outerEncryptResult = encryptData(innerEncryptResult.ciphertext, outerKey, false)
|
||||
val cipherFile = writeToFile(outerEncryptResult.ciphertext)
|
||||
|
||||
val keyMaterial = createMediaKeyMaterial(badInnerKey)
|
||||
|
||||
AttachmentCipherInputStream.createForArchivedMedia(
|
||||
archivedMediaKeyMaterial = keyMaterial,
|
||||
file = cipherFile,
|
||||
originalCipherTextLength = innerEncryptResult.ciphertext.size.toLong(),
|
||||
plaintextLength = plaintextInput.size.toLong(),
|
||||
combinedKeyMaterial = innerKey,
|
||||
plaintextHash = innerEncryptResult.digest,
|
||||
incrementalDigest = innerEncryptResult.incrementalDigest,
|
||||
incrementalMacChunkSize = innerEncryptResult.chunkSizeChoice
|
||||
)
|
||||
} catch (e: InvalidMessageException) {
|
||||
hitCorrectException = true
|
||||
} finally {
|
||||
cipherFile?.delete()
|
||||
}
|
||||
|
||||
assertThat(hitCorrectException).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun archive_encryptDecrypt_nonIncremental() {
|
||||
archive_encryptDecrypt(incremental = false, fileSize = MEBIBYTE)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun archive_encryptDecrypt_incremental() {
|
||||
archive_encryptDecrypt(incremental = true, fileSize = MEBIBYTE)
|
||||
}
|
||||
|
||||
@Ignore("Useful when making changes, otherwise a bit slow.")
|
||||
@Test
|
||||
fun archive_encryptDecrypt_nonIncremental_manyFileSizes() {
|
||||
for (i in 0..99) {
|
||||
archive_encryptDecrypt(incremental = false, fileSize = MEBIBYTE + Random().nextInt(1, 64 * 1024))
|
||||
}
|
||||
}
|
||||
|
||||
@Ignore("Useful when making changes, otherwise a bit slow.")
|
||||
@Test
|
||||
fun archive_encryptDecrypt_incremental_manyFileSizes() {
|
||||
// Designed to stress the various boundary conditions of reading the final mac
|
||||
for (i in 0..99) {
|
||||
archive_encryptDecrypt(incremental = true, fileSize = MEBIBYTE + Random().nextInt(1, 64 * 1024))
|
||||
}
|
||||
}
|
||||
|
||||
private fun archive_encryptDecrypt(incremental: Boolean, fileSize: Int) {
|
||||
val innerKey = Util.getSecretBytes(64)
|
||||
val plaintextInput = Util.getSecretBytes(fileSize)
|
||||
val innerEncryptResult = encryptData(plaintextInput, innerKey, incremental)
|
||||
|
||||
val outerKey = Util.getSecretBytes(64)
|
||||
val outerEncryptResult = encryptData(innerEncryptResult.ciphertext, outerKey, withIncremental = false, padded = false) // Server doesn't pad
|
||||
val cipherFile = writeToFile(outerEncryptResult.ciphertext)
|
||||
|
||||
val keyMaterial = createMediaKeyMaterial(outerKey)
|
||||
val decryptedStream = AttachmentCipherInputStream.createForArchivedMedia(
|
||||
archivedMediaKeyMaterial = keyMaterial,
|
||||
file = cipherFile,
|
||||
originalCipherTextLength = innerEncryptResult.ciphertext.size.toLong(),
|
||||
plaintextLength = plaintextInput.size.toLong(),
|
||||
combinedKeyMaterial = innerKey,
|
||||
plaintextHash = innerEncryptResult.plaintextHash,
|
||||
incrementalDigest = innerEncryptResult.incrementalDigest,
|
||||
incrementalMacChunkSize = innerEncryptResult.chunkSizeChoice
|
||||
)
|
||||
val plaintextOutput = decryptedStream.readFully()
|
||||
|
||||
assertThat(plaintextOutput).isEqualTo(plaintextInput)
|
||||
|
||||
cipherFile.delete()
|
||||
}
|
||||
|
||||
@Ignore("Useful when making changes, otherwise a bit slow.")
|
||||
@Test
|
||||
fun archiveThumbnail_encryptDecrypt_manyFileSizes() {
|
||||
for (i in 0..99) {
|
||||
archiveThumbnail_encryptDecrypt(fileSize = MEBIBYTE + Random().nextInt(1, 64 * 1024))
|
||||
}
|
||||
}
|
||||
|
||||
private fun archiveThumbnail_encryptDecrypt(fileSize: Int) {
|
||||
val innerKey = Util.getSecretBytes(64)
|
||||
val plaintextInput = Util.getSecretBytes(fileSize)
|
||||
val innerEncryptResult = encryptData(plaintextInput, innerKey, withIncremental = false)
|
||||
|
||||
val outerKey = Util.getSecretBytes(64)
|
||||
val outerEncryptResult = encryptData(innerEncryptResult.ciphertext, outerKey, withIncremental = false, padded = false) // Server doesn't pad
|
||||
val cipherFile = writeToFile(outerEncryptResult.ciphertext)
|
||||
|
||||
val keyMaterial = createMediaKeyMaterial(outerKey)
|
||||
val decryptedStream = AttachmentCipherInputStream.createForArchivedThumbnail(
|
||||
archivedMediaKeyMaterial = keyMaterial,
|
||||
file = cipherFile,
|
||||
innerCombinedKeyMaterial = innerKey
|
||||
)
|
||||
val plaintextOutput = decryptedStream.readFully()
|
||||
|
||||
// We knowingly keep padding on thumbnails, so for the test to work, we strip the padding off, but check to make sure the sizes match up beforehand
|
||||
assertThat(plaintextOutput.size).isEqualTo(PaddingInputStream.getPaddedSize(plaintextInput.size.toLong()).toInt())
|
||||
assertThat(plaintextOutput.copyOfRange(fromIndex = 0, toIndex = plaintextInput.size)).isEqualTo(plaintextInput)
|
||||
|
||||
cipherFile.delete()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun archiveEncryptDecrypt_decryptFailOnInnerBadMac() {
|
||||
var cipherFile: File? = null
|
||||
var hitCorrectException = false
|
||||
|
||||
try {
|
||||
val innerKey = Util.getSecretBytes(64)
|
||||
val plaintextInput = Util.getSecretBytes(MEBIBYTE)
|
||||
|
||||
val innerEncryptResult = encryptData(plaintextInput, innerKey, withIncremental = true, padded = false) // Server doesn't pad
|
||||
val badMacInnerCipherText = innerEncryptResult.ciphertext.copyOf().also {
|
||||
it[it.size - 1] = (it[it.size - 1] + 1).toByte()
|
||||
}
|
||||
|
||||
val outerKey = Util.getSecretBytes(64)
|
||||
val outerEncryptResult = encryptData(badMacInnerCipherText, outerKey, false)
|
||||
|
||||
cipherFile = writeToFile(outerEncryptResult.ciphertext)
|
||||
|
||||
val keyMaterial = createMediaKeyMaterial(innerKey)
|
||||
|
||||
AttachmentCipherInputStream.createForArchivedMedia(
|
||||
archivedMediaKeyMaterial = keyMaterial,
|
||||
file = cipherFile,
|
||||
originalCipherTextLength = innerEncryptResult.ciphertext.size.toLong(),
|
||||
plaintextLength = plaintextInput.size.toLong(),
|
||||
combinedKeyMaterial = innerKey,
|
||||
plaintextHash = innerEncryptResult.digest,
|
||||
incrementalDigest = innerEncryptResult.incrementalDigest,
|
||||
incrementalMacChunkSize = innerEncryptResult.chunkSizeChoice
|
||||
)
|
||||
|
||||
Assert.fail()
|
||||
} catch (e: InvalidMessageException) {
|
||||
hitCorrectException = true
|
||||
} finally {
|
||||
cipherFile?.delete()
|
||||
}
|
||||
|
||||
Assert.assertTrue(hitCorrectException)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun archiveEncryptDecrypt_decryptFailOnOuterMac() {
|
||||
var cipherFile: File? = null
|
||||
var hitCorrectException = false
|
||||
|
||||
try {
|
||||
val innerKey = Util.getSecretBytes(64)
|
||||
val plaintextInput = Util.getSecretBytes(MEBIBYTE)
|
||||
|
||||
val innerEncryptResult = encryptData(plaintextInput, innerKey, withIncremental = true, padded = false) // Server doesn't pad
|
||||
val outerKey = Util.getSecretBytes(64)
|
||||
|
||||
val outerEncryptResult = encryptData(innerEncryptResult.ciphertext, outerKey, false)
|
||||
val badMacOuterCiphertext = outerEncryptResult.ciphertext.copyOf().also {
|
||||
it[it.size - 1] = (it[it.size - 1] + 1).toByte()
|
||||
}
|
||||
|
||||
cipherFile = writeToFile(badMacOuterCiphertext)
|
||||
|
||||
val keyMaterial = createMediaKeyMaterial(innerKey)
|
||||
|
||||
AttachmentCipherInputStream.createForArchivedMedia(
|
||||
archivedMediaKeyMaterial = keyMaterial,
|
||||
file = cipherFile,
|
||||
originalCipherTextLength = innerEncryptResult.ciphertext.size.toLong(),
|
||||
plaintextLength = plaintextInput.size.toLong(),
|
||||
combinedKeyMaterial = innerKey,
|
||||
plaintextHash = innerEncryptResult.digest,
|
||||
incrementalDigest = innerEncryptResult.incrementalDigest,
|
||||
incrementalMacChunkSize = innerEncryptResult.chunkSizeChoice
|
||||
)
|
||||
|
||||
Assert.fail()
|
||||
} catch (e: InvalidMessageException) {
|
||||
hitCorrectException = true
|
||||
} finally {
|
||||
cipherFile?.delete()
|
||||
}
|
||||
|
||||
Assert.assertTrue(hitCorrectException)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun archiveThumbnailEncryptDecrypt_decryptFailOnInnerBadMac() {
|
||||
var cipherFile: File? = null
|
||||
var hitCorrectException = false
|
||||
|
||||
try {
|
||||
val innerKey = Util.getSecretBytes(64)
|
||||
val plaintextInput = Util.getSecretBytes(MEBIBYTE)
|
||||
|
||||
val innerEncryptResult = encryptData(plaintextInput, innerKey, withIncremental = true, padded = false) // Server doesn't pad
|
||||
val badMacInnerCipherText = innerEncryptResult.ciphertext.copyOf().also {
|
||||
it[it.size - 1] = (it[it.size - 1] + 1).toByte()
|
||||
}
|
||||
|
||||
val outerKey = Util.getSecretBytes(64)
|
||||
val outerEncryptResult = encryptData(badMacInnerCipherText, outerKey, false)
|
||||
|
||||
cipherFile = writeToFile(outerEncryptResult.ciphertext)
|
||||
|
||||
val keyMaterial = createMediaKeyMaterial(innerKey)
|
||||
|
||||
AttachmentCipherInputStream.createForArchivedThumbnail(
|
||||
archivedMediaKeyMaterial = keyMaterial,
|
||||
file = cipherFile,
|
||||
innerCombinedKeyMaterial = innerKey
|
||||
).readFully()
|
||||
|
||||
Assert.fail()
|
||||
} catch (e: InvalidMessageException) {
|
||||
hitCorrectException = true
|
||||
} finally {
|
||||
cipherFile?.delete()
|
||||
}
|
||||
|
||||
Assert.assertTrue(hitCorrectException)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun archiveThumbnailEncryptDecrypt_decryptFailOnOuterMac() {
|
||||
var cipherFile: File? = null
|
||||
var hitCorrectException = false
|
||||
|
||||
try {
|
||||
val innerKey = Util.getSecretBytes(64)
|
||||
val plaintextInput = Util.getSecretBytes(MEBIBYTE)
|
||||
|
||||
val innerEncryptResult = encryptData(plaintextInput, innerKey, withIncremental = true, padded = false) // Server doesn't pad
|
||||
val outerKey = Util.getSecretBytes(64)
|
||||
|
||||
val outerEncryptResult = encryptData(innerEncryptResult.ciphertext, outerKey, false)
|
||||
val badMacOuterCiphertext = outerEncryptResult.ciphertext.copyOf().also {
|
||||
it[it.size - 1] = (it[it.size - 1] + 1).toByte()
|
||||
}
|
||||
|
||||
cipherFile = writeToFile(badMacOuterCiphertext)
|
||||
|
||||
val keyMaterial = createMediaKeyMaterial(innerKey)
|
||||
|
||||
AttachmentCipherInputStream.createForArchivedThumbnail(
|
||||
archivedMediaKeyMaterial = keyMaterial,
|
||||
file = cipherFile,
|
||||
innerCombinedKeyMaterial = innerKey
|
||||
)
|
||||
|
||||
Assert.fail()
|
||||
} catch (e: InvalidMessageException) {
|
||||
hitCorrectException = true
|
||||
} finally {
|
||||
cipherFile?.delete()
|
||||
}
|
||||
|
||||
Assert.assertTrue(hitCorrectException)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sticker_encryptDecrypt() {
|
||||
LibSignalLibraryUtil.assumeLibSignalSupportedOnOS()
|
||||
|
||||
val packKey = Util.getSecretBytes(32)
|
||||
val plaintextInput = Util.getSecretBytes(MEBIBYTE)
|
||||
val encryptResult = encryptData(plaintextInput, expandPackKey(packKey), withIncremental = false, padded = false)
|
||||
val inputStream = AttachmentCipherInputStream.createForStickerData(encryptResult.ciphertext, packKey)
|
||||
val plaintextOutput = readInputStreamFully(inputStream)
|
||||
|
||||
Assert.assertArrayEquals(plaintextInput, plaintextOutput)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sticker_encryptDecryptEmpty() {
|
||||
LibSignalLibraryUtil.assumeLibSignalSupportedOnOS()
|
||||
|
||||
val packKey = Util.getSecretBytes(32)
|
||||
val plaintextInput = "".toByteArray()
|
||||
val encryptResult = encryptData(plaintextInput, expandPackKey(packKey), withIncremental = false, padded = false)
|
||||
val inputStream = AttachmentCipherInputStream.createForStickerData(encryptResult.ciphertext, packKey)
|
||||
val plaintextOutput = readInputStreamFully(inputStream)
|
||||
|
||||
Assert.assertArrayEquals(plaintextInput, plaintextOutput)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sticker_decryptFailOnBadKey() {
|
||||
LibSignalLibraryUtil.assumeLibSignalSupportedOnOS()
|
||||
|
||||
var hitCorrectException = false
|
||||
|
||||
try {
|
||||
val packKey = Util.getSecretBytes(32)
|
||||
val plaintextInput = Util.getSecretBytes(MEBIBYTE)
|
||||
val encryptResult = encryptData(plaintextInput, expandPackKey(packKey), true)
|
||||
val badPackKey = ByteArray(32)
|
||||
|
||||
AttachmentCipherInputStream.createForStickerData(encryptResult.ciphertext, badPackKey)
|
||||
} catch (e: InvalidMessageException) {
|
||||
hitCorrectException = true
|
||||
}
|
||||
|
||||
Assert.assertTrue(hitCorrectException)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sticker_decryptFailOnBadMac() {
|
||||
LibSignalLibraryUtil.assumeLibSignalSupportedOnOS()
|
||||
|
||||
var hitCorrectException = false
|
||||
|
||||
try {
|
||||
val packKey = Util.getSecretBytes(32)
|
||||
val plaintextInput = Util.getSecretBytes(MEBIBYTE)
|
||||
val encryptResult = encryptData(plaintextInput, expandPackKey(packKey), true)
|
||||
val badMacCiphertext = encryptResult.ciphertext.copyOf(encryptResult.ciphertext.size)
|
||||
|
||||
badMacCiphertext[badMacCiphertext.size - 1] = (badMacCiphertext[badMacCiphertext.size - 1] + 1).toByte()
|
||||
|
||||
AttachmentCipherInputStream.createForStickerData(badMacCiphertext, packKey)
|
||||
} catch (e: InvalidMessageException) {
|
||||
hitCorrectException = true
|
||||
}
|
||||
|
||||
Assert.assertTrue(hitCorrectException)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun outputStream_writeAfterFlush() {
|
||||
val key = Util.getSecretBytes(64)
|
||||
val iv = Util.getSecretBytes(16)
|
||||
val plaintextInput1 = Util.getSecretBytes(MEBIBYTE)
|
||||
val plaintextInput2 = Util.getSecretBytes(MEBIBYTE)
|
||||
|
||||
val destinationOutputStream = ByteArrayOutputStream()
|
||||
val encryptingOutputStream = AttachmentCipherOutputStreamFactory(key, iv).createFor(destinationOutputStream)
|
||||
|
||||
encryptingOutputStream.write(plaintextInput1)
|
||||
encryptingOutputStream.flush()
|
||||
|
||||
encryptingOutputStream.write(plaintextInput2)
|
||||
encryptingOutputStream.close()
|
||||
|
||||
val encryptedData = destinationOutputStream.toByteArray()
|
||||
val digest = encryptingOutputStream.transmittedDigest
|
||||
val combinedData = plaintextInput1 + plaintextInput2
|
||||
|
||||
val cipherFile = writeToFile(encryptedData)
|
||||
val integrityCheck = IntegrityCheck.forEncryptedDigest(digest)
|
||||
val decryptedStream = AttachmentCipherInputStream.createForAttachment(cipherFile, combinedData.size.toLong(), key, integrityCheck, null, 0)
|
||||
val plaintextOutput = readInputStreamFully(decryptedStream)
|
||||
|
||||
assertThat(plaintextOutput).isEqualTo(combinedData)
|
||||
cipherFile.delete()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun outputStream_flushMultipleTimes() {
|
||||
val key = Util.getSecretBytes(64)
|
||||
val iv = Util.getSecretBytes(16)
|
||||
val plaintextInput1 = Util.getSecretBytes(MEBIBYTE)
|
||||
val plaintextInput2 = Util.getSecretBytes(MEBIBYTE)
|
||||
|
||||
val destinationOutputStream = ByteArrayOutputStream()
|
||||
val encryptingOutputStream = AttachmentCipherOutputStreamFactory(key, iv).createFor(destinationOutputStream)
|
||||
|
||||
encryptingOutputStream.write(plaintextInput1)
|
||||
|
||||
encryptingOutputStream.flush()
|
||||
encryptingOutputStream.flush()
|
||||
encryptingOutputStream.flush()
|
||||
|
||||
encryptingOutputStream.write(plaintextInput2)
|
||||
encryptingOutputStream.close()
|
||||
|
||||
val encryptedData = destinationOutputStream.toByteArray()
|
||||
val digest = encryptingOutputStream.transmittedDigest
|
||||
val combinedData = plaintextInput1 + plaintextInput2
|
||||
|
||||
val cipherFile = writeToFile(encryptedData)
|
||||
val integrityCheck = IntegrityCheck.forEncryptedDigest(digest)
|
||||
val decryptedStream = AttachmentCipherInputStream.createForAttachment(cipherFile, combinedData.size.toLong(), key, integrityCheck, null, 0)
|
||||
val plaintextOutput = readInputStreamFully(decryptedStream)
|
||||
|
||||
assertThat(plaintextOutput).isEqualTo(combinedData)
|
||||
cipherFile.delete()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun outputStream_singleByteWrite() {
|
||||
val key = Util.getSecretBytes(64)
|
||||
val iv = Util.getSecretBytes(16)
|
||||
val plaintextInput = Util.getSecretBytes(MEBIBYTE)
|
||||
val destinationOutputStream = ByteArrayOutputStream()
|
||||
val encryptingOutputStream = AttachmentCipherOutputStreamFactory(key, iv).createFor(destinationOutputStream)
|
||||
|
||||
for (b in plaintextInput) {
|
||||
encryptingOutputStream.write(b.toInt())
|
||||
}
|
||||
|
||||
encryptingOutputStream.close()
|
||||
|
||||
val encryptedData = destinationOutputStream.toByteArray()
|
||||
val digest = encryptingOutputStream.transmittedDigest
|
||||
|
||||
val cipherFile = writeToFile(encryptedData)
|
||||
val integrityCheck = IntegrityCheck.forEncryptedDigest(digest)
|
||||
val decryptedStream = AttachmentCipherInputStream.createForAttachment(cipherFile, plaintextInput.size.toLong(), key, integrityCheck, null, 0)
|
||||
val plaintextOutput = readInputStreamFully(decryptedStream)
|
||||
|
||||
assertThat(plaintextOutput).isEqualTo(plaintextInput)
|
||||
cipherFile.delete()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun outputStream_mixedSingleByteAndArrayWrites() {
|
||||
val key = Util.getSecretBytes(64)
|
||||
val iv = Util.getSecretBytes(16)
|
||||
val plaintextInput1 = Util.getSecretBytes(512)
|
||||
val plaintextInput2 = Util.getSecretBytes(512)
|
||||
val destinationOutputStream = ByteArrayOutputStream()
|
||||
val encryptingOutputStream = AttachmentCipherOutputStreamFactory(key, iv).createFor(destinationOutputStream)
|
||||
|
||||
// Write first part as array
|
||||
encryptingOutputStream.write(plaintextInput1)
|
||||
|
||||
// Write second part one byte at a time
|
||||
for (b in plaintextInput2) {
|
||||
encryptingOutputStream.write(b.toInt())
|
||||
}
|
||||
|
||||
encryptingOutputStream.close()
|
||||
|
||||
val expectedData = plaintextInput1 + plaintextInput2
|
||||
val encryptedData = destinationOutputStream.toByteArray()
|
||||
val digest = encryptingOutputStream.transmittedDigest
|
||||
|
||||
val cipherFile = writeToFile(encryptedData)
|
||||
val integrityCheck = IntegrityCheck.forEncryptedDigest(digest)
|
||||
val decryptedStream = AttachmentCipherInputStream.createForAttachment(cipherFile, expectedData.size.toLong(), key, integrityCheck, null, 0)
|
||||
val plaintextOutput = readInputStreamFully(decryptedStream)
|
||||
|
||||
assertThat(plaintextOutput).isEqualTo(expectedData)
|
||||
cipherFile.delete()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun outputStream_singleByteWriteWithFlushes() {
|
||||
val key = Util.getSecretBytes(64)
|
||||
val iv = Util.getSecretBytes(16)
|
||||
val plaintextInput = Util.getSecretBytes(256)
|
||||
val destinationOutputStream = ByteArrayOutputStream()
|
||||
val encryptingOutputStream = AttachmentCipherOutputStreamFactory(key, iv).createFor(destinationOutputStream)
|
||||
|
||||
// Write bytes one at a time with occasional flushes
|
||||
for (i in plaintextInput.indices) {
|
||||
encryptingOutputStream.write(plaintextInput[i].toInt())
|
||||
if (i % 64 == 0) {
|
||||
encryptingOutputStream.flush()
|
||||
}
|
||||
}
|
||||
|
||||
encryptingOutputStream.close()
|
||||
|
||||
val encryptedData = destinationOutputStream.toByteArray()
|
||||
val digest = encryptingOutputStream.transmittedDigest
|
||||
|
||||
val cipherFile = writeToFile(encryptedData)
|
||||
val integrityCheck = IntegrityCheck.forEncryptedDigest(digest)
|
||||
val decryptedStream = AttachmentCipherInputStream.createForAttachment(cipherFile, plaintextInput.size.toLong(), key, integrityCheck, null, 0)
|
||||
val plaintextOutput = readInputStreamFully(decryptedStream)
|
||||
|
||||
assertThat(plaintextOutput).isEqualTo(plaintextInput)
|
||||
cipherFile.delete()
|
||||
}
|
||||
|
||||
private class EncryptResult(
|
||||
val ciphertext: ByteArray,
|
||||
val digest: ByteArray,
|
||||
val incrementalDigest: ByteArray,
|
||||
val chunkSizeChoice: Int,
|
||||
val plaintextHash: ByteArray
|
||||
)
|
||||
|
||||
companion object {
|
||||
init {
|
||||
// https://github.com/google/conscrypt/issues/1034
|
||||
if (System.getProperty("os.arch") != "aarch64") {
|
||||
Security.insertProviderAt(Conscrypt.newProvider(), 1)
|
||||
}
|
||||
}
|
||||
|
||||
private const val MEBIBYTE = 1024 * 1024
|
||||
|
||||
private fun encryptData(data: ByteArray, keyMaterial: ByteArray, withIncremental: Boolean, padded: Boolean = true): EncryptResult {
|
||||
val digestingStream = DigestInputStream(ByteArrayInputStream(data), MessageDigest.getInstance("SHA-256"))
|
||||
|
||||
val actualData = if (padded) {
|
||||
PaddingInputStream(digestingStream, data.size.toLong()).readFully()
|
||||
} else {
|
||||
digestingStream.readFully()
|
||||
}
|
||||
|
||||
val plaintextHash = digestingStream.messageDigest.digest()
|
||||
|
||||
val outputStream = ByteArrayOutputStream()
|
||||
val incrementalDigestOut = ByteArrayOutputStream()
|
||||
val iv = Util.getSecretBytes(16)
|
||||
val factory = AttachmentCipherOutputStreamFactory(keyMaterial, iv)
|
||||
|
||||
val encryptStream: DigestingOutputStream
|
||||
val sizeChoice = ChunkSizeChoice.inferChunkSize(actualData.size)
|
||||
encryptStream = if (withIncremental) {
|
||||
factory.createIncrementalFor(outputStream, actualData.size.toLong(), sizeChoice, incrementalDigestOut)
|
||||
} else {
|
||||
factory.createFor(outputStream)
|
||||
}
|
||||
|
||||
encryptStream.write(actualData)
|
||||
encryptStream.flush()
|
||||
encryptStream.close()
|
||||
incrementalDigestOut.close()
|
||||
|
||||
return EncryptResult(
|
||||
ciphertext = outputStream.toByteArray(),
|
||||
digest = encryptStream.transmittedDigest,
|
||||
incrementalDigest = incrementalDigestOut.toByteArray(),
|
||||
chunkSizeChoice = sizeChoice.sizeInBytes,
|
||||
plaintextHash = plaintextHash
|
||||
)
|
||||
}
|
||||
|
||||
private fun writeToFile(data: ByteArray): File {
|
||||
val file = File.createTempFile("temp", ".data")
|
||||
val outputStream: OutputStream = FileOutputStream(file)
|
||||
|
||||
outputStream.write(data)
|
||||
outputStream.close()
|
||||
|
||||
return file
|
||||
}
|
||||
|
||||
private fun readInputStreamFully(inputStream: InputStream): ByteArray {
|
||||
return Util.readFullyAsBytes(inputStream)
|
||||
}
|
||||
|
||||
private fun expandPackKey(shortKey: ByteArray): ByteArray {
|
||||
return HKDF.deriveSecrets(shortKey, "Sticker Pack".toByteArray(), 64)
|
||||
}
|
||||
}
|
||||
|
||||
enum class IntegrityCheckMode {
|
||||
ENCRYPTED_DIGEST,
|
||||
PLAINTEXT_HASH,
|
||||
BOTH
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.signalservice.api.crypto
|
||||
|
||||
import org.signal.core.models.backup.MediaId
|
||||
import org.signal.core.models.backup.MediaRootBackupKey.MediaKeyMaterial
|
||||
import org.whispersystems.signalservice.internal.util.Util
|
||||
|
||||
object AttachmentCipherTestHelper {
|
||||
|
||||
/**
|
||||
* Needed to workaround this bug:
|
||||
* https://youtrack.jetbrains.com/issue/KT-60205/Java-class-has-private-access-in-class-constructor-with-inlinevalue-parameter
|
||||
*/
|
||||
@JvmStatic
|
||||
fun createMediaKeyMaterial(combinedKey: ByteArray): MediaKeyMaterial {
|
||||
val parts = Util.split(combinedKey, 32, 32)
|
||||
|
||||
return MediaKeyMaterial(
|
||||
id = MediaId(Util.getSecretBytes(15)),
|
||||
macKey = parts[1],
|
||||
aesKey = parts[0]
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
package org.whispersystems.signalservice.api.crypto
|
||||
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.isEqualTo
|
||||
import assertk.assertions.isTrue
|
||||
import assertk.fail
|
||||
import org.junit.Test
|
||||
import org.signal.core.util.readFully
|
||||
import org.signal.libsignal.protocol.InvalidMessageException
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.security.MessageDigest
|
||||
|
||||
class DigestValidatingInputStreamTest {
|
||||
|
||||
@Test
|
||||
fun `success - read byte by byte`() {
|
||||
val data = "Hello, World!".toByteArray()
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
val expectedHash = digest.digest(data)
|
||||
|
||||
val inputStream = ByteArrayInputStream(data)
|
||||
val digestEnforcingStream = DigestValidatingInputStream(
|
||||
inputStream = inputStream,
|
||||
digest = MessageDigest.getInstance("SHA-256"),
|
||||
expectedHash = expectedHash
|
||||
)
|
||||
|
||||
val result = ByteArray(data.size)
|
||||
var i = 0
|
||||
var byteRead: Byte = 0
|
||||
while (digestEnforcingStream.read().also { byteRead = it.toByte() } != -1) {
|
||||
result[i] = byteRead
|
||||
i++
|
||||
}
|
||||
|
||||
assertThat(result).isEqualTo(data)
|
||||
assertThat(digestEnforcingStream.validationAttempted).isTrue()
|
||||
|
||||
digestEnforcingStream.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `success - read byte array`() {
|
||||
val data = "Hello, World! This is a longer message to test buffer reading.".toByteArray()
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
val expectedHash = digest.digest(data)
|
||||
|
||||
val inputStream = ByteArrayInputStream(data)
|
||||
val digestEnforcingStream = DigestValidatingInputStream(
|
||||
inputStream = inputStream,
|
||||
digest = MessageDigest.getInstance("SHA-256"),
|
||||
expectedHash = expectedHash
|
||||
)
|
||||
|
||||
val result = digestEnforcingStream.readFully()
|
||||
|
||||
assertThat(result.size).isEqualTo(data.size)
|
||||
assertThat(result).isEqualTo(data)
|
||||
assertThat(digestEnforcingStream.validationAttempted).isTrue()
|
||||
|
||||
digestEnforcingStream.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `success - read byte array with offset and length`() {
|
||||
val data = "This is test data for offset and length reading.".toByteArray()
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
val expectedHash = digest.digest(data)
|
||||
|
||||
val inputStream = ByteArrayInputStream(data)
|
||||
val digestEnforcingStream = DigestValidatingInputStream(
|
||||
inputStream = inputStream,
|
||||
digest = MessageDigest.getInstance("SHA-256"),
|
||||
expectedHash = expectedHash
|
||||
)
|
||||
|
||||
val buffer = ByteArray(1024)
|
||||
var totalBytesRead = 0
|
||||
var bytesRead: Int
|
||||
|
||||
while (digestEnforcingStream.read(buffer, totalBytesRead, 10).also { bytesRead = it } > 0) {
|
||||
totalBytesRead += bytesRead
|
||||
}
|
||||
|
||||
val result = buffer.copyOf(totalBytesRead)
|
||||
assertThat(result).isEqualTo(data)
|
||||
assertThat(digestEnforcingStream.validationAttempted).isTrue()
|
||||
|
||||
digestEnforcingStream.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `success - empty data`() {
|
||||
val data = ByteArray(0)
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
val expectedHash = digest.digest(data)
|
||||
|
||||
val inputStream = ByteArrayInputStream(data)
|
||||
val digestEnforcingStream = DigestValidatingInputStream(
|
||||
inputStream = inputStream,
|
||||
digest = MessageDigest.getInstance("SHA-256"),
|
||||
expectedHash = expectedHash
|
||||
)
|
||||
|
||||
// Should immediately return -1 and validate
|
||||
val endByte = digestEnforcingStream.read()
|
||||
assertThat(endByte).isEqualTo(-1)
|
||||
assertThat(digestEnforcingStream.validationAttempted).isTrue()
|
||||
|
||||
digestEnforcingStream.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `success - alternative digest, md5`() {
|
||||
val data = "Testing MD5 hash validation".toByteArray()
|
||||
val digest = MessageDigest.getInstance("MD5")
|
||||
val expectedHash = digest.digest(data)
|
||||
|
||||
val inputStream = ByteArrayInputStream(data)
|
||||
val digestEnforcingStream = DigestValidatingInputStream(
|
||||
inputStream = inputStream,
|
||||
digest = MessageDigest.getInstance("MD5"),
|
||||
expectedHash = expectedHash
|
||||
)
|
||||
|
||||
val result = digestEnforcingStream.readFully()
|
||||
|
||||
assertThat(result).isEqualTo(data)
|
||||
assertThat(digestEnforcingStream.validationAttempted).isTrue()
|
||||
|
||||
digestEnforcingStream.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `success - multiple reads after close`() {
|
||||
val data = "Test multiple validation calls".toByteArray()
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
val expectedHash = digest.digest(data)
|
||||
|
||||
val inputStream = ByteArrayInputStream(data)
|
||||
val digestEnforcingStream = DigestValidatingInputStream(
|
||||
inputStream = inputStream,
|
||||
digest = MessageDigest.getInstance("SHA-256"),
|
||||
expectedHash = expectedHash
|
||||
)
|
||||
|
||||
val result = digestEnforcingStream.readFully()
|
||||
|
||||
// Multiple calls to read() after EOF should not cause issues
|
||||
assertThat(digestEnforcingStream.read()).isEqualTo(-1)
|
||||
assertThat(digestEnforcingStream.read()).isEqualTo(-1)
|
||||
assertThat(digestEnforcingStream.read()).isEqualTo(-1)
|
||||
|
||||
assertThat(result).isEqualTo(data)
|
||||
assertThat(digestEnforcingStream.validationAttempted).isTrue()
|
||||
|
||||
digestEnforcingStream.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `failure - read byte by byte`() {
|
||||
val data = "Hello, World!".toByteArray()
|
||||
val wrongHash = ByteArray(32) // All zeros - wrong hash
|
||||
|
||||
val inputStream = ByteArrayInputStream(data)
|
||||
val digestEnforcingStream = DigestValidatingInputStream(
|
||||
inputStream = inputStream,
|
||||
digest = MessageDigest.getInstance("SHA-256"),
|
||||
expectedHash = wrongHash
|
||||
)
|
||||
|
||||
try {
|
||||
while (digestEnforcingStream.read() != -1) {
|
||||
// Reading byte by byte
|
||||
}
|
||||
|
||||
fail("Expected InvalidCiphertextException to be thrown")
|
||||
} catch (e: InvalidMessageException) {
|
||||
// Expected exception
|
||||
} finally {
|
||||
digestEnforcingStream.close()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `failure - read byte array`() {
|
||||
val data = "Hello, World! This is a test message.".toByteArray()
|
||||
val wrongHash = ByteArray(32) // All zeros - wrong hash
|
||||
|
||||
val inputStream = ByteArrayInputStream(data)
|
||||
val digestEnforcingStream = DigestValidatingInputStream(
|
||||
inputStream = inputStream,
|
||||
digest = MessageDigest.getInstance("SHA-256"),
|
||||
expectedHash = wrongHash
|
||||
)
|
||||
|
||||
try {
|
||||
digestEnforcingStream.readFully()
|
||||
|
||||
fail("Expected InvalidCiphertextException to be thrown")
|
||||
} catch (e: InvalidMessageException) {
|
||||
// Expected exception
|
||||
} finally {
|
||||
digestEnforcingStream.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
package org.whispersystems.signalservice.api.crypto
|
||||
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.isEqualTo
|
||||
import assertk.assertions.isTrue
|
||||
import assertk.fail
|
||||
import org.junit.Test
|
||||
import org.signal.core.util.kibiBytes
|
||||
import org.signal.core.util.mebiBytes
|
||||
import org.signal.core.util.readFully
|
||||
import org.signal.libsignal.protocol.InvalidMessageException
|
||||
import org.whispersystems.signalservice.internal.util.Util
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.ByteArrayOutputStream
|
||||
import javax.crypto.Mac
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
import kotlin.random.Random
|
||||
|
||||
class MacValidatingInputStreamTest {
|
||||
|
||||
@Test
|
||||
fun `success - simple byte array read`() {
|
||||
val data = "Hello, World!".toByteArray()
|
||||
val key = Util.getSecretBytes(32)
|
||||
val dataWithMac = createDataWithMac(data, key)
|
||||
|
||||
val inputStream = ByteArrayInputStream(dataWithMac)
|
||||
val mac = createMac(key)
|
||||
val macValidatingStream = MacValidatingInputStream(inputStream, mac)
|
||||
|
||||
val result = macValidatingStream.readFully()
|
||||
|
||||
assertThat(result).isEqualTo(dataWithMac)
|
||||
macValidatingStream.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `success - byte by byte`() {
|
||||
val data = "Hello, World!".toByteArray()
|
||||
val key = Util.getSecretBytes(32)
|
||||
val dataWithMac = createDataWithMac(data, key)
|
||||
|
||||
val inputStream = ByteArrayInputStream(dataWithMac)
|
||||
val mac = createMac(key)
|
||||
val macValidatingStream = MacValidatingInputStream(inputStream, mac)
|
||||
|
||||
val out = ByteArrayOutputStream()
|
||||
var read = -1
|
||||
while (macValidatingStream.read().also { read = it } != -1) {
|
||||
out.write(read)
|
||||
}
|
||||
val result = out.toByteArray()
|
||||
|
||||
assertThat(result).isEqualTo(dataWithMac)
|
||||
macValidatingStream.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `success - many different sizes`() {
|
||||
for (i in 1..100) {
|
||||
val data = Util.getSecretBytes(Random.nextLong(from = 256.kibiBytes.bytes, until = 2.mebiBytes.bytes).toInt())
|
||||
val key = Util.getSecretBytes(32)
|
||||
val dataWithMac = createDataWithMac(data, key)
|
||||
|
||||
val inputStream = ByteArrayInputStream(dataWithMac)
|
||||
val mac = createMac(key)
|
||||
val macValidatingStream = MacValidatingInputStream(inputStream, mac)
|
||||
|
||||
val result = macValidatingStream.readFully()
|
||||
|
||||
assertThat(result).isEqualTo(dataWithMac)
|
||||
assertThat(macValidatingStream.validationAttempted).isTrue()
|
||||
macValidatingStream.close()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `success - empty data`() {
|
||||
val data = ByteArray(0)
|
||||
val key = Util.getSecretBytes(32)
|
||||
val dataWithMac = createDataWithMac(data, key)
|
||||
|
||||
val inputStream = ByteArrayInputStream(dataWithMac)
|
||||
val mac = createMac(key)
|
||||
val macValidatingStream = MacValidatingInputStream(inputStream, mac)
|
||||
|
||||
val result = macValidatingStream.readFully()
|
||||
|
||||
assertThat(result).isEqualTo(dataWithMac)
|
||||
assertThat(macValidatingStream.validationAttempted).isTrue()
|
||||
macValidatingStream.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `success - data exactly MAC length`() {
|
||||
val key = Util.getSecretBytes(32)
|
||||
val mac = createMac(key)
|
||||
val macLength = mac.macLength
|
||||
val data = ByteArray(macLength) { (it % 256).toByte() } // Data same size as MAC
|
||||
val dataWithMac = createDataWithMac(data, key)
|
||||
|
||||
val inputStream = ByteArrayInputStream(dataWithMac)
|
||||
val mac2 = createMac(key)
|
||||
val macValidatingStream = MacValidatingInputStream(inputStream, mac2)
|
||||
|
||||
val result = macValidatingStream.readFully()
|
||||
|
||||
assertThat(result).isEqualTo(dataWithMac)
|
||||
assertThat(macValidatingStream.validationAttempted).isTrue()
|
||||
macValidatingStream.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `success - multiple reads after end of stream`() {
|
||||
val data = "Test multiple reads after EOF".toByteArray()
|
||||
val key = Util.getSecretBytes(32)
|
||||
val dataWithMac = createDataWithMac(data, key)
|
||||
|
||||
val inputStream = ByteArrayInputStream(dataWithMac)
|
||||
val mac = createMac(key)
|
||||
val macValidatingStream = MacValidatingInputStream(inputStream, mac)
|
||||
|
||||
val result = macValidatingStream.readFully()
|
||||
|
||||
// Multiple calls to read() after EOF should return -1
|
||||
assertThat(macValidatingStream.read()).isEqualTo(-1)
|
||||
assertThat(macValidatingStream.read()).isEqualTo(-1)
|
||||
assertThat(macValidatingStream.read()).isEqualTo(-1)
|
||||
|
||||
assertThat(result).isEqualTo(dataWithMac)
|
||||
assertThat(macValidatingStream.validationAttempted).isTrue()
|
||||
macValidatingStream.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `failure - invalid MAC`() {
|
||||
val data = "Hello, World!".toByteArray()
|
||||
val key = Util.getSecretBytes(32)
|
||||
val wrongKey = ByteArray(32) { 24 }
|
||||
val dataWithMac = createDataWithMac(data, key)
|
||||
|
||||
val inputStream = ByteArrayInputStream(dataWithMac)
|
||||
val mac = createMac(wrongKey) // Wrong key
|
||||
val macValidatingStream = MacValidatingInputStream(inputStream, mac)
|
||||
|
||||
try {
|
||||
macValidatingStream.readFully()
|
||||
fail("Expected InvalidMessageException to be thrown")
|
||||
} catch (e: InvalidMessageException) {
|
||||
assertThat(e.message).isEqualTo("MAC validation failed!")
|
||||
} finally {
|
||||
macValidatingStream.close()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `failure - insufficient data for MAC`() {
|
||||
val key = Util.getSecretBytes(32)
|
||||
val mac = createMac(key)
|
||||
val macLength = mac.macLength
|
||||
val insufficientData = ByteArray(macLength - 1) { 5 } // Less than MAC length
|
||||
|
||||
val inputStream = ByteArrayInputStream(insufficientData)
|
||||
val mac2 = createMac(key)
|
||||
val macValidatingStream = MacValidatingInputStream(inputStream, mac2)
|
||||
|
||||
try {
|
||||
macValidatingStream.readFully()
|
||||
fail("Expected InvalidMessageException to be thrown")
|
||||
} catch (e: InvalidMessageException) {
|
||||
assertThat(e.message).isEqualTo("Stream ended before MAC could be read. Expected $macLength bytes, got ${insufficientData.size}")
|
||||
} finally {
|
||||
macValidatingStream.close()
|
||||
}
|
||||
}
|
||||
|
||||
private fun createMac(key: ByteArray): Mac {
|
||||
val mac = Mac.getInstance("HmacSHA256")
|
||||
mac.init(SecretKeySpec(key, "HmacSHA256"))
|
||||
return mac
|
||||
}
|
||||
|
||||
private fun createDataWithMac(data: ByteArray, key: ByteArray): ByteArray {
|
||||
val mac = createMac(key)
|
||||
val macBytes = mac.doFinal(data)
|
||||
return data + macBytes
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package org.whispersystems.signalservice.api.crypto;
|
||||
|
||||
|
||||
import org.conscrypt.Conscrypt;
|
||||
import org.junit.Test;
|
||||
import org.signal.libsignal.zkgroup.InvalidInputException;
|
||||
import org.signal.libsignal.zkgroup.profiles.ProfileKey;
|
||||
import org.whispersystems.signalservice.internal.util.Util;
|
||||
import org.signal.core.util.Base64;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.security.Security;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.fail;
|
||||
import static org.whispersystems.signalservice.testutil.LibSignalLibraryUtil.assumeLibSignalSupportedOnOS;
|
||||
|
||||
public class ProfileCipherTest {
|
||||
|
||||
private class TestByteArrayInputStream extends ByteArrayInputStream {
|
||||
TestByteArrayInputStream(byte[] buffer) {
|
||||
super(buffer);
|
||||
}
|
||||
|
||||
int getPos() {
|
||||
return this.pos;
|
||||
}
|
||||
}
|
||||
|
||||
static {
|
||||
// https://github.com/google/conscrypt/issues/1034
|
||||
if (!System.getProperty("os.arch").equals("aarch64")) {
|
||||
Security.insertProviderAt(Conscrypt.newProvider(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testEncryptDecrypt() throws InvalidCiphertextException, InvalidInputException {
|
||||
ProfileKey key = new ProfileKey(Util.getSecretBytes(32));
|
||||
ProfileCipher cipher = new ProfileCipher(key);
|
||||
byte[] name = cipher.encrypt("Clement\0Duval".getBytes(), 53);
|
||||
String plaintext = cipher.decryptString(name);
|
||||
assertEquals(plaintext, "Clement\0Duval");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testEmpty() throws Exception {
|
||||
ProfileKey key = new ProfileKey(Util.getSecretBytes(32));
|
||||
ProfileCipher cipher = new ProfileCipher(key);
|
||||
byte[] name = cipher.encrypt("".getBytes(), 26);
|
||||
String plaintext = cipher.decryptString(name);
|
||||
|
||||
assertEquals(plaintext.length(), 0);
|
||||
}
|
||||
|
||||
private byte[] readStream(byte[] input, ProfileKey key, int bufferSize) throws Exception {
|
||||
TestByteArrayInputStream bais = new TestByteArrayInputStream(input);
|
||||
assertEquals(0, bais.getPos());
|
||||
|
||||
ProfileCipherInputStream in = new ProfileCipherInputStream(bais, key);
|
||||
assertEquals(12 + 16, bais.getPos()); // initial read of nonce + tag-sized buffer
|
||||
|
||||
ByteArrayOutputStream result = new ByteArrayOutputStream();
|
||||
byte[] buffer = new byte[bufferSize];
|
||||
|
||||
int pos = bais.getPos();
|
||||
int read;
|
||||
while ((read = in.read(buffer)) != -1) {
|
||||
assertEquals(pos + read, bais.getPos());
|
||||
pos += read;
|
||||
result.write(buffer, 0, read);
|
||||
}
|
||||
|
||||
assertEquals(pos, input.length);
|
||||
return result.toByteArray();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testStreams() throws Exception {
|
||||
assumeLibSignalSupportedOnOS();
|
||||
|
||||
ProfileKey key = new ProfileKey(Util.getSecretBytes(32));
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
ProfileCipherOutputStream out = new ProfileCipherOutputStream(baos, key);
|
||||
|
||||
out.write("This is an avatar".getBytes());
|
||||
out.flush();
|
||||
out.close();
|
||||
|
||||
byte[] encrypted = baos.toByteArray();
|
||||
|
||||
assertEquals(new String(readStream(encrypted, key, 2048)), "This is an avatar");
|
||||
assertEquals(new String(readStream(encrypted, key, 16 /* == block size */)), "This is an avatar");
|
||||
assertEquals(new String(readStream(encrypted, key, 5)), "This is an avatar");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testStreamBadAuthentication() throws Exception {
|
||||
assumeLibSignalSupportedOnOS();
|
||||
|
||||
ProfileKey key = new ProfileKey(Util.getSecretBytes(32));
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
ProfileCipherOutputStream out = new ProfileCipherOutputStream(baos, key);
|
||||
|
||||
out.write("This is an avatar".getBytes());
|
||||
out.flush();
|
||||
out.close();
|
||||
|
||||
byte[] encrypted = baos.toByteArray();
|
||||
encrypted[encrypted.length - 1] ^= 1;
|
||||
try {
|
||||
readStream(encrypted, key, 2048);
|
||||
fail("failed to verify authenticate tag");
|
||||
} catch (IOException e) {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testEncryptLengthBucket1() throws InvalidInputException {
|
||||
ProfileKey key = new ProfileKey(Util.getSecretBytes(32));
|
||||
ProfileCipher cipher = new ProfileCipher(key);
|
||||
byte[] name = cipher.encrypt("Peter\0Parker".getBytes(), 53);
|
||||
|
||||
String encoded = Base64.encodeWithPadding(name);
|
||||
|
||||
assertEquals(108, encoded.length());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testEncryptLengthBucket2() throws InvalidInputException {
|
||||
ProfileKey key = new ProfileKey(Util.getSecretBytes(32));
|
||||
ProfileCipher cipher = new ProfileCipher(key);
|
||||
byte[] name = cipher.encrypt("Peter\0Parker".getBytes(), 257);
|
||||
|
||||
String encoded = Base64.encodeWithPadding(name);
|
||||
|
||||
assertEquals(380, encoded.length());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testTargetNameLength() {
|
||||
assertEquals(53, ProfileCipher.getTargetNameLength("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"));
|
||||
assertEquals(53, ProfileCipher.getTargetNameLength("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1"));
|
||||
assertEquals(257, ProfileCipher.getTargetNameLength("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ12"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package org.whispersystems.signalservice.api.crypto;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
|
||||
import static org.junit.Assert.assertArrayEquals;
|
||||
import static org.junit.Assert.assertEquals;
|
||||
|
||||
public class SkippingOutputStreamTest {
|
||||
|
||||
private final ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
|
||||
|
||||
@Test
|
||||
public void givenZeroToSkip_whenIWriteInt_thenIGetIntInOutput() throws Exception {
|
||||
// GIVEN
|
||||
SkippingOutputStream testSubject = new SkippingOutputStream(0, outputStream);
|
||||
|
||||
// WHEN
|
||||
testSubject.write(0);
|
||||
|
||||
// THEN
|
||||
assertEquals(1, outputStream.toByteArray().length);
|
||||
assertEquals(0, outputStream.toByteArray()[0]);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void givenOneToSkip_whenIWriteIntTwice_thenIGetSecondIntInOutput() throws Exception {
|
||||
// GIVEN
|
||||
SkippingOutputStream testSubject = new SkippingOutputStream(1, outputStream);
|
||||
|
||||
// WHEN
|
||||
testSubject.write(0);
|
||||
testSubject.write(1);
|
||||
|
||||
// THEN
|
||||
assertEquals(1, outputStream.toByteArray().length);
|
||||
assertEquals(1, outputStream.toByteArray()[0]);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void givenZeroToSkip_whenIWriteArray_thenIGetArrayInOutput() throws Exception {
|
||||
// GIVEN
|
||||
byte[] expected = new byte[]{1, 2, 3, 4, 5};
|
||||
SkippingOutputStream testSubject = new SkippingOutputStream(0, outputStream);
|
||||
|
||||
// WHEN
|
||||
testSubject.write(expected);
|
||||
|
||||
// THEN
|
||||
assertEquals(expected.length, outputStream.toByteArray().length);
|
||||
assertArrayEquals(expected, outputStream.toByteArray());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void givenNonZeroToSkip_whenIWriteArray_thenIGetEndOfArrayInOutput() throws Exception {
|
||||
// GIVEN
|
||||
byte[] expected = new byte[]{1, 2, 3, 4, 5};
|
||||
SkippingOutputStream testSubject = new SkippingOutputStream(3, outputStream);
|
||||
|
||||
// WHEN
|
||||
testSubject.write(expected);
|
||||
|
||||
// THEN
|
||||
assertEquals(2, outputStream.toByteArray().length);
|
||||
assertArrayEquals(new byte[]{4, 5}, outputStream.toByteArray());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void givenSkipGreaterThanByteArray_whenIWriteArray_thenIGetNoOutput() throws Exception {
|
||||
// GIVEN
|
||||
byte[] array = new byte[]{1, 2, 3, 4, 5};
|
||||
SkippingOutputStream testSubject = new SkippingOutputStream(10, outputStream);
|
||||
|
||||
// WHEN
|
||||
testSubject.write(array);
|
||||
|
||||
// THEN
|
||||
assertEquals(0, outputStream.toByteArray().length);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void givenZeroToSkip_whenIWriteArrayRange_thenIGetArrayRangeInOutput() throws Exception {
|
||||
// GIVEN
|
||||
byte[] expected = new byte[]{1, 2, 3, 4, 5};
|
||||
SkippingOutputStream testSubject = new SkippingOutputStream(0, outputStream);
|
||||
|
||||
// WHEN
|
||||
testSubject.write(expected, 1, 3);
|
||||
|
||||
// THEN
|
||||
assertEquals(3, outputStream.toByteArray().length);
|
||||
assertArrayEquals(new byte[]{2, 3, 4}, outputStream.toByteArray());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void givenNonZeroToSkip_whenIWriteArrayRange_thenIGetEndOfArrayRangeInOutput() throws Exception {
|
||||
// GIVEN
|
||||
byte[] expected = new byte[]{1, 2, 3, 4, 5};
|
||||
SkippingOutputStream testSubject = new SkippingOutputStream(1, outputStream);
|
||||
|
||||
// WHEN
|
||||
testSubject.write(expected, 3, 2);
|
||||
|
||||
// THEN
|
||||
assertEquals(1, outputStream.toByteArray().length);
|
||||
assertArrayEquals(new byte[]{5}, outputStream.toByteArray());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void givenSkipGreaterThanByteArrayRange_whenIWriteArrayRange_thenIGetNoOutput() throws Exception {
|
||||
// GIVEN
|
||||
byte[] array = new byte[]{1, 2, 3, 4, 5};
|
||||
SkippingOutputStream testSubject = new SkippingOutputStream(10, outputStream);
|
||||
|
||||
// WHEN
|
||||
testSubject.write(array, 3, 2);
|
||||
|
||||
// THEN
|
||||
assertEquals(0, outputStream.toByteArray().length);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void givenSkipGreaterThanByteArrayRange_whenIWriteArrayRangeTwice_thenIGetExpectedOutput() throws Exception {
|
||||
// GIVEN
|
||||
byte[] array = new byte[]{1, 2, 3, 4, 5};
|
||||
SkippingOutputStream testSubject = new SkippingOutputStream(3, outputStream);
|
||||
|
||||
// WHEN
|
||||
testSubject.write(array, 3, 2);
|
||||
testSubject.write(array, 3, 2);
|
||||
|
||||
// THEN
|
||||
assertEquals(1, outputStream.toByteArray().length);
|
||||
assertArrayEquals(new byte[]{5}, outputStream.toByteArray());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package org.whispersystems.signalservice.api.crypto;
|
||||
|
||||
import org.conscrypt.OpenSSLProvider;
|
||||
import org.junit.Test;
|
||||
import org.signal.libsignal.zkgroup.InvalidInputException;
|
||||
import org.signal.libsignal.zkgroup.profiles.ProfileKey;
|
||||
|
||||
import java.security.Security;
|
||||
import java.util.Arrays;
|
||||
|
||||
import static org.junit.Assert.assertArrayEquals;
|
||||
|
||||
public class UnidentifiedAccessTest {
|
||||
|
||||
static {
|
||||
// https://github.com/google/conscrypt/issues/1034
|
||||
if (!System.getProperty("os.arch").equals("aarch64")) {
|
||||
Security.insertProviderAt(new OpenSSLProvider(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
private final byte[] EXPECTED_RESULT = {(byte)0x5a, (byte)0x72, (byte)0x3a, (byte)0xce, (byte)0xe5, (byte)0x2c, (byte)0x5e, (byte)0xa0, (byte)0x2b, (byte)0x92, (byte)0xa3, (byte)0xa3, (byte)0x60, (byte)0xc0, (byte)0x95, (byte)0x95};
|
||||
|
||||
@Test
|
||||
public void testKeyDerivation() throws InvalidInputException {
|
||||
byte[] key = new byte[32];
|
||||
Arrays.fill(key, (byte)0x02);
|
||||
|
||||
byte[] result = UnidentifiedAccess.deriveAccessKeyFrom(new ProfileKey(key));
|
||||
assertArrayEquals(EXPECTED_RESULT, result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package org.whispersystems.signalservice.api.groupsv2
|
||||
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.containsExactly
|
||||
import assertk.assertions.isEqualTo
|
||||
import assertk.assertions.isNull
|
||||
import okio.ByteString
|
||||
import org.junit.Test
|
||||
import org.signal.core.models.ServiceId
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedGroupChange
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedPendingMember
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedPendingMemberRemoval
|
||||
import org.whispersystems.signalservice.internal.util.Util
|
||||
import java.util.UUID
|
||||
|
||||
class DecryptedGroupUtilTest {
|
||||
@Test
|
||||
fun can_extract_editor_uuid_from_decrypted_group_change() {
|
||||
val aci = randomACI()
|
||||
val editor = aci.toByteString()
|
||||
val groupChange = DecryptedGroupChange.Builder()
|
||||
.editorServiceIdBytes(editor)
|
||||
.build()
|
||||
|
||||
val parsed = DecryptedGroupUtil.editorServiceId(groupChange).get()
|
||||
|
||||
assertThat(parsed).isEqualTo(aci)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun can_extract_uuid_from_decrypted_pending_member() {
|
||||
val aci = randomACI()
|
||||
val decryptedMember = DecryptedPendingMember.Builder()
|
||||
.serviceIdBytes(aci.toByteString())
|
||||
.build()
|
||||
|
||||
val parsed = ServiceId.parseOrNull(decryptedMember.serviceIdBytes)
|
||||
|
||||
assertThat(parsed).isEqualTo(aci)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun can_extract_uuid_from_bad_decrypted_pending_member() {
|
||||
val decryptedMember = DecryptedPendingMember.Builder()
|
||||
.serviceIdBytes(ByteString.of(*Util.getSecretBytes(18)))
|
||||
.build()
|
||||
|
||||
val parsed = ServiceId.parseOrNull(decryptedMember.serviceIdBytes)
|
||||
|
||||
assertThat(parsed).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun can_extract_uuids_for_all_pending_including_bad_entries() {
|
||||
val aci1 = randomACI()
|
||||
val aci2 = randomACI()
|
||||
val decryptedMember1 = DecryptedPendingMember.Builder()
|
||||
.serviceIdBytes(aci1.toByteString())
|
||||
.build()
|
||||
val decryptedMember2 = DecryptedPendingMember.Builder()
|
||||
.serviceIdBytes(aci2.toByteString())
|
||||
.build()
|
||||
val decryptedMember3 = DecryptedPendingMember.Builder()
|
||||
.serviceIdBytes(ByteString.of(*Util.getSecretBytes(18)))
|
||||
.build()
|
||||
|
||||
val groupChange = DecryptedGroupChange.Builder()
|
||||
.newPendingMembers(listOf(decryptedMember1, decryptedMember2, decryptedMember3))
|
||||
.build()
|
||||
|
||||
val pendingUuids = DecryptedGroupUtil.pendingToServiceIdList(groupChange.newPendingMembers)
|
||||
|
||||
assertThat(pendingUuids).containsExactly(aci1, aci2, ServiceId.ACI.UNKNOWN)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun can_extract_uuids_for_all_deleted_pending_excluding_bad_entries() {
|
||||
val aci1 = randomACI()
|
||||
val aci2 = randomACI()
|
||||
val decryptedMember1 = DecryptedPendingMemberRemoval.Builder()
|
||||
.serviceIdBytes(aci1.toByteString())
|
||||
.build()
|
||||
val decryptedMember2 = DecryptedPendingMemberRemoval.Builder()
|
||||
.serviceIdBytes(aci2.toByteString())
|
||||
.build()
|
||||
val decryptedMember3 = DecryptedPendingMemberRemoval.Builder()
|
||||
.serviceIdBytes(ByteString.of(*Util.getSecretBytes(18)))
|
||||
.build()
|
||||
|
||||
val groupChange = DecryptedGroupChange.Builder()
|
||||
.deletePendingMembers(listOf(decryptedMember1, decryptedMember2, decryptedMember3))
|
||||
.build()
|
||||
|
||||
val removedUuids = DecryptedGroupUtil.removedPendingMembersServiceIdList(groupChange)
|
||||
|
||||
assertThat(removedUuids).containsExactly(aci1, aci2)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun can_extract_uuids_for_all_deleted_members_excluding_bad_entries() {
|
||||
val aci1 = randomACI()
|
||||
val aci2 = randomACI()
|
||||
val groupChange = DecryptedGroupChange.Builder()
|
||||
.deleteMembers(listOf(aci1.toByteString(), aci2.toByteString(), ByteString.of(*Util.getSecretBytes(18))))
|
||||
.build()
|
||||
|
||||
val removedServiceIds = DecryptedGroupUtil.removedMembersServiceIdList(groupChange)
|
||||
|
||||
assertThat(removedServiceIds).containsExactly(aci1, aci2)
|
||||
}
|
||||
|
||||
private fun randomACI() = ServiceId.ACI.from(UUID.randomUUID())
|
||||
}
|
||||
@@ -0,0 +1,959 @@
|
||||
package org.whispersystems.signalservice.api.groupsv2;
|
||||
|
||||
import org.junit.Test;
|
||||
import org.signal.libsignal.zkgroup.profiles.ProfileKey;
|
||||
import org.signal.storageservice.protos.groups.AccessControl;
|
||||
import org.signal.storageservice.protos.groups.Member;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedApproveMember;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedBannedMember;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedGroup;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedGroupChange;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedMember;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedModifyMemberRole;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedPendingMember;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedPendingMemberRemoval;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedRequestingMember;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedString;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedTimer;
|
||||
import org.signal.storageservice.protos.groups.local.EnabledState;
|
||||
import org.signal.core.util.UuidUtil;
|
||||
import org.whispersystems.signalservice.internal.util.Util;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import okio.ByteString;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.whispersystems.signalservice.api.groupsv2.ProtoTestUtils.admin;
|
||||
import static org.whispersystems.signalservice.api.groupsv2.ProtoTestUtils.asAdmin;
|
||||
import static org.whispersystems.signalservice.api.groupsv2.ProtoTestUtils.asMember;
|
||||
import static org.whispersystems.signalservice.api.groupsv2.ProtoTestUtils.bannedMember;
|
||||
import static org.whispersystems.signalservice.api.groupsv2.ProtoTestUtils.member;
|
||||
import static org.whispersystems.signalservice.api.groupsv2.ProtoTestUtils.newProfileKey;
|
||||
import static org.whispersystems.signalservice.api.groupsv2.ProtoTestUtils.pendingMember;
|
||||
import static org.whispersystems.signalservice.api.groupsv2.ProtoTestUtils.pendingPniAciMember;
|
||||
import static org.whispersystems.signalservice.api.groupsv2.ProtoTestUtils.randomProfileKey;
|
||||
import static org.whispersystems.signalservice.api.groupsv2.ProtoTestUtils.requestingMember;
|
||||
import static org.whispersystems.signalservice.api.groupsv2.ProtoTestUtils.withProfileKey;
|
||||
import static org.whispersystems.signalservice.api.groupsv2.ProtobufTestUtils.getMaxDeclaredFieldNumber;
|
||||
|
||||
public final class DecryptedGroupUtil_apply_Test {
|
||||
|
||||
/**
|
||||
* Reflects over the generated protobuf class and ensures that no new fields have been added since we wrote this.
|
||||
* <p>
|
||||
* If we didn't, newly added fields would not be applied by {@link DecryptedGroupUtil#apply}.
|
||||
*/
|
||||
@Test
|
||||
public void ensure_DecryptedGroupUtil_knows_about_all_fields_of_DecryptedGroupChange() {
|
||||
int maxFieldFound = getMaxDeclaredFieldNumber(DecryptedGroupChange.class);
|
||||
|
||||
assertEquals("DecryptedGroupUtil and its tests need updating to account for new fields on " + DecryptedGroupChange.class.getName(),
|
||||
24, maxFieldFound);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void apply_revision() throws NotAbleToApplyGroupV2ChangeException {
|
||||
DecryptedGroup newGroup = DecryptedGroupUtil.apply(new DecryptedGroup.Builder()
|
||||
.revision(9)
|
||||
.build(),
|
||||
new DecryptedGroupChange.Builder()
|
||||
.revision(10)
|
||||
.build());
|
||||
|
||||
assertEquals(10, newGroup.revision);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void apply_new_member() throws NotAbleToApplyGroupV2ChangeException {
|
||||
DecryptedMember member1 = member(UUID.randomUUID());
|
||||
DecryptedMember member2 = member(UUID.randomUUID());
|
||||
|
||||
DecryptedGroup newGroup = DecryptedGroupUtil.apply(new DecryptedGroup.Builder()
|
||||
.revision(10)
|
||||
.members(List.of(member1))
|
||||
.build(),
|
||||
new DecryptedGroupChange.Builder()
|
||||
.revision(11)
|
||||
.newMembers(List.of(member2))
|
||||
.build());
|
||||
|
||||
assertEquals(new DecryptedGroup.Builder()
|
||||
.revision(11)
|
||||
.members(List.of(member1, member2))
|
||||
.build(),
|
||||
newGroup);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void apply_new_member_already_in_the_group() throws NotAbleToApplyGroupV2ChangeException {
|
||||
DecryptedMember member1 = member(UUID.randomUUID());
|
||||
DecryptedMember member2 = member(UUID.randomUUID());
|
||||
|
||||
DecryptedGroup newGroup = DecryptedGroupUtil.apply(new DecryptedGroup.Builder()
|
||||
.revision(10)
|
||||
.members(List.of(member1, member2))
|
||||
.build(),
|
||||
new DecryptedGroupChange.Builder()
|
||||
.revision(11)
|
||||
.newMembers(List.of(member2))
|
||||
.build());
|
||||
|
||||
assertEquals(new DecryptedGroup.Builder()
|
||||
.revision(11)
|
||||
.members(List.of(member1, member2))
|
||||
.build(),
|
||||
newGroup);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void apply_new_member_already_in_the_group_by_uuid() throws NotAbleToApplyGroupV2ChangeException {
|
||||
DecryptedMember member1 = member(UUID.randomUUID());
|
||||
UUID member2Uuid = UUID.randomUUID();
|
||||
DecryptedMember member2a = member(member2Uuid, newProfileKey());
|
||||
DecryptedMember member2b = member(member2Uuid, newProfileKey());
|
||||
|
||||
DecryptedGroup newGroup = DecryptedGroupUtil.apply(new DecryptedGroup.Builder()
|
||||
.revision(10)
|
||||
.members(List.of(member1, member2a))
|
||||
.build(),
|
||||
new DecryptedGroupChange.Builder()
|
||||
.revision(11)
|
||||
.newMembers(List.of(member2b))
|
||||
.build());
|
||||
|
||||
assertEquals(new DecryptedGroup.Builder()
|
||||
.revision(11)
|
||||
.members(List.of(member1, member2b))
|
||||
.build(),
|
||||
newGroup);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void apply_remove_member() throws NotAbleToApplyGroupV2ChangeException {
|
||||
DecryptedMember member1 = member(UUID.randomUUID());
|
||||
DecryptedMember member2 = member(UUID.randomUUID());
|
||||
|
||||
DecryptedGroup newGroup = DecryptedGroupUtil.apply(new DecryptedGroup.Builder()
|
||||
.revision(13)
|
||||
.members(List.of(member1, member2))
|
||||
.build(),
|
||||
new DecryptedGroupChange.Builder()
|
||||
.revision(14)
|
||||
.deleteMembers(List.of(member1.aciBytes))
|
||||
.build());
|
||||
|
||||
assertEquals(new DecryptedGroup.Builder()
|
||||
.revision(14)
|
||||
.members(List.of(member2))
|
||||
.build(),
|
||||
newGroup);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void apply_remove_members() throws NotAbleToApplyGroupV2ChangeException {
|
||||
DecryptedMember member1 = member(UUID.randomUUID());
|
||||
DecryptedMember member2 = member(UUID.randomUUID());
|
||||
|
||||
DecryptedGroup newGroup = DecryptedGroupUtil.apply(new DecryptedGroup.Builder()
|
||||
.revision(13)
|
||||
.members(List.of(member1, member2))
|
||||
.build(),
|
||||
new DecryptedGroupChange.Builder()
|
||||
.revision(14)
|
||||
.deleteMembers(List.of(member1.aciBytes, member2.aciBytes))
|
||||
.build());
|
||||
|
||||
assertEquals(new DecryptedGroup.Builder()
|
||||
.revision(14)
|
||||
.build(),
|
||||
newGroup);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void apply_remove_members_not_found() throws NotAbleToApplyGroupV2ChangeException {
|
||||
DecryptedMember member1 = member(UUID.randomUUID());
|
||||
DecryptedMember member2 = member(UUID.randomUUID());
|
||||
|
||||
DecryptedGroup newGroup = DecryptedGroupUtil.apply(new DecryptedGroup.Builder()
|
||||
.revision(13)
|
||||
.members(List.of(member1))
|
||||
.build(),
|
||||
new DecryptedGroupChange.Builder()
|
||||
.revision(14)
|
||||
.deleteMembers(List.of(member2.aciBytes))
|
||||
.build());
|
||||
|
||||
assertEquals(new DecryptedGroup.Builder()
|
||||
.members(List.of(member1))
|
||||
.revision(14)
|
||||
.build(),
|
||||
newGroup);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void apply_modify_member_role() throws NotAbleToApplyGroupV2ChangeException {
|
||||
DecryptedMember member1 = member(UUID.randomUUID());
|
||||
DecryptedMember member2 = admin(UUID.randomUUID());
|
||||
|
||||
DecryptedGroup newGroup = DecryptedGroupUtil.apply(new DecryptedGroup.Builder()
|
||||
.revision(13)
|
||||
.members(List.of(member1, member2))
|
||||
.build(),
|
||||
new DecryptedGroupChange.Builder()
|
||||
.revision(14)
|
||||
.modifyMemberRoles(List.of(new DecryptedModifyMemberRole.Builder().aciBytes(member1.aciBytes).role(Member.Role.ADMINISTRATOR).build(),
|
||||
new DecryptedModifyMemberRole.Builder().aciBytes(member2.aciBytes).role(Member.Role.DEFAULT).build()))
|
||||
.build());
|
||||
|
||||
assertEquals(new DecryptedGroup.Builder()
|
||||
.revision(14)
|
||||
.members(List.of(asAdmin(member1), asMember(member2)))
|
||||
.build(),
|
||||
newGroup);
|
||||
}
|
||||
|
||||
@Test(expected = NotAbleToApplyGroupV2ChangeException.class)
|
||||
public void not_able_to_apply_modify_member_role_for_non_member() throws NotAbleToApplyGroupV2ChangeException {
|
||||
DecryptedMember member1 = member(UUID.randomUUID());
|
||||
DecryptedMember member2 = member(UUID.randomUUID());
|
||||
|
||||
DecryptedGroupUtil.apply(new DecryptedGroup.Builder()
|
||||
.revision(13)
|
||||
.members(List.of(member1))
|
||||
.build(),
|
||||
new DecryptedGroupChange.Builder()
|
||||
.revision(14)
|
||||
.modifyMemberRoles(List.of(new DecryptedModifyMemberRole.Builder()
|
||||
.role(Member.Role.ADMINISTRATOR)
|
||||
.aciBytes(member2.aciBytes)
|
||||
.build()))
|
||||
.build());
|
||||
}
|
||||
|
||||
@Test(expected = NotAbleToApplyGroupV2ChangeException.class)
|
||||
public void not_able_to_apply_modify_member_role_for_no_role() throws NotAbleToApplyGroupV2ChangeException {
|
||||
DecryptedMember member1 = member(UUID.randomUUID());
|
||||
|
||||
DecryptedGroupUtil.apply(new DecryptedGroup.Builder()
|
||||
.revision(13)
|
||||
.members(List.of(member1))
|
||||
.build(),
|
||||
new DecryptedGroupChange.Builder()
|
||||
.revision(14)
|
||||
.modifyMemberRoles(List.of(new DecryptedModifyMemberRole.Builder()
|
||||
.aciBytes(member1.aciBytes)
|
||||
.build()))
|
||||
.build());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void apply_modify_member_profile_keys() throws NotAbleToApplyGroupV2ChangeException {
|
||||
ProfileKey profileKey1 = randomProfileKey();
|
||||
ProfileKey profileKey2a = randomProfileKey();
|
||||
ProfileKey profileKey2b = randomProfileKey();
|
||||
DecryptedMember member1 = member(UUID.randomUUID(), profileKey1);
|
||||
DecryptedMember member2a = member(UUID.randomUUID(), profileKey2a);
|
||||
DecryptedMember member2b = withProfileKey(member2a, profileKey2b);
|
||||
|
||||
DecryptedGroup newGroup = DecryptedGroupUtil.apply(new DecryptedGroup.Builder()
|
||||
.revision(13)
|
||||
.members(List.of(member1, member2a))
|
||||
.build(),
|
||||
new DecryptedGroupChange.Builder()
|
||||
.revision(14)
|
||||
.modifiedProfileKeys(List.of(member2b))
|
||||
.build());
|
||||
|
||||
assertEquals(new DecryptedGroup.Builder()
|
||||
.revision(14)
|
||||
.members(List.of(member1, member2b))
|
||||
.build(),
|
||||
newGroup);
|
||||
}
|
||||
|
||||
@Test(expected = NotAbleToApplyGroupV2ChangeException.class)
|
||||
public void cant_apply_modify_member_profile_keys_if_member_not_in_group() throws NotAbleToApplyGroupV2ChangeException {
|
||||
ProfileKey profileKey1 = randomProfileKey();
|
||||
ProfileKey profileKey2a = randomProfileKey();
|
||||
ProfileKey profileKey2b = randomProfileKey();
|
||||
DecryptedMember member1 = member(UUID.randomUUID(), profileKey1);
|
||||
DecryptedMember member2a = member(UUID.randomUUID(), profileKey2a);
|
||||
DecryptedMember member2b = member(UUID.randomUUID(), profileKey2b);
|
||||
|
||||
DecryptedGroupUtil.apply(new DecryptedGroup.Builder()
|
||||
.revision(13)
|
||||
.members(List.of(member1, member2a))
|
||||
.build(),
|
||||
new DecryptedGroupChange.Builder()
|
||||
.revision(14)
|
||||
.modifiedProfileKeys(List.of(member2b))
|
||||
.build());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void apply_modify_admin_profile_keys() throws NotAbleToApplyGroupV2ChangeException {
|
||||
UUID adminUuid = UUID.randomUUID();
|
||||
ProfileKey profileKey1 = randomProfileKey();
|
||||
ProfileKey profileKey2a = randomProfileKey();
|
||||
ProfileKey profileKey2b = randomProfileKey();
|
||||
DecryptedMember member1 = member(UUID.randomUUID(), profileKey1);
|
||||
DecryptedMember admin2a = admin(adminUuid, profileKey2a);
|
||||
|
||||
DecryptedGroup newGroup = DecryptedGroupUtil.apply(new DecryptedGroup.Builder()
|
||||
.revision(13)
|
||||
.members(List.of(member1, admin2a))
|
||||
.build(),
|
||||
new DecryptedGroupChange.Builder()
|
||||
.revision(14)
|
||||
.modifiedProfileKeys(List.of(new DecryptedMember.Builder()
|
||||
.aciBytes(UuidUtil.toByteString(adminUuid))
|
||||
.build()
|
||||
.newBuilder()
|
||||
.profileKey(ByteString.of(profileKey2b.serialize()))
|
||||
.build()))
|
||||
.build());
|
||||
|
||||
assertEquals(new DecryptedGroup.Builder()
|
||||
.revision(14)
|
||||
.members(List.of(member1, admin(adminUuid, profileKey2b)))
|
||||
.build(),
|
||||
newGroup);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void apply_new_pending_member() throws NotAbleToApplyGroupV2ChangeException {
|
||||
DecryptedMember member1 = member(UUID.randomUUID());
|
||||
DecryptedPendingMember pending = pendingMember(UUID.randomUUID());
|
||||
|
||||
DecryptedGroup newGroup = DecryptedGroupUtil.apply(new DecryptedGroup.Builder()
|
||||
.revision(10)
|
||||
.members(List.of(member1))
|
||||
.build(),
|
||||
new DecryptedGroupChange.Builder()
|
||||
.revision(11)
|
||||
.newPendingMembers(List.of(pending))
|
||||
.build());
|
||||
|
||||
assertEquals(new DecryptedGroup.Builder()
|
||||
.revision(11)
|
||||
.members(List.of(member1))
|
||||
.pendingMembers(List.of(pending))
|
||||
.build(),
|
||||
newGroup);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void apply_new_pending_member_already_pending() throws NotAbleToApplyGroupV2ChangeException {
|
||||
DecryptedMember member1 = member(UUID.randomUUID());
|
||||
DecryptedPendingMember pending = pendingMember(UUID.randomUUID());
|
||||
|
||||
DecryptedGroup newGroup = DecryptedGroupUtil.apply(new DecryptedGroup.Builder()
|
||||
.revision(10)
|
||||
.members(List.of(member1))
|
||||
.pendingMembers(List.of(pending))
|
||||
.build(),
|
||||
new DecryptedGroupChange.Builder()
|
||||
.revision(11)
|
||||
.newPendingMembers(List.of(pending))
|
||||
.build());
|
||||
|
||||
assertEquals(new DecryptedGroup.Builder()
|
||||
.revision(11)
|
||||
.members(List.of(member1))
|
||||
.pendingMembers(List.of(pending))
|
||||
.build(),
|
||||
newGroup);
|
||||
}
|
||||
|
||||
@Test(expected = NotAbleToApplyGroupV2ChangeException.class)
|
||||
public void apply_new_pending_member_already_in_group() throws NotAbleToApplyGroupV2ChangeException {
|
||||
DecryptedMember member1 = member(UUID.randomUUID());
|
||||
UUID uuid2 = UUID.randomUUID();
|
||||
DecryptedMember member2 = member(uuid2);
|
||||
DecryptedPendingMember pending2 = pendingMember(uuid2);
|
||||
|
||||
DecryptedGroupUtil.apply(new DecryptedGroup.Builder()
|
||||
.revision(10)
|
||||
.members(List.of(member1, member2))
|
||||
.build(),
|
||||
new DecryptedGroupChange.Builder()
|
||||
.revision(11)
|
||||
.newPendingMembers(List.of(pending2))
|
||||
.build());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void remove_pending_member() throws NotAbleToApplyGroupV2ChangeException {
|
||||
DecryptedMember member1 = member(UUID.randomUUID());
|
||||
UUID pendingUuid = UUID.randomUUID();
|
||||
DecryptedPendingMember pending = pendingMember(pendingUuid);
|
||||
|
||||
DecryptedGroup newGroup = DecryptedGroupUtil.apply(new DecryptedGroup.Builder()
|
||||
.revision(10)
|
||||
.members(List.of(member1))
|
||||
.pendingMembers(List.of(pending))
|
||||
.build(),
|
||||
new DecryptedGroupChange.Builder()
|
||||
.revision(11)
|
||||
.deletePendingMembers(List.of(new DecryptedPendingMemberRemoval.Builder()
|
||||
.serviceIdCipherText(ProtoTestUtils.encrypt(pendingUuid))
|
||||
.build()))
|
||||
.build());
|
||||
|
||||
assertEquals(new DecryptedGroup.Builder()
|
||||
.revision(11)
|
||||
.members(List.of(member1))
|
||||
.build(),
|
||||
newGroup);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void cannot_remove_pending_member_if_not_in_group() throws NotAbleToApplyGroupV2ChangeException {
|
||||
DecryptedMember member1 = member(UUID.randomUUID());
|
||||
UUID pendingUuid = UUID.randomUUID();
|
||||
|
||||
DecryptedGroup newGroup = DecryptedGroupUtil.apply(new DecryptedGroup.Builder()
|
||||
.revision(10)
|
||||
.members(List.of(member1))
|
||||
.build(),
|
||||
new DecryptedGroupChange.Builder()
|
||||
.revision(11)
|
||||
.deletePendingMembers(List.of(new DecryptedPendingMemberRemoval.Builder()
|
||||
.serviceIdCipherText(ProtoTestUtils.encrypt(pendingUuid))
|
||||
.build()))
|
||||
.build());
|
||||
|
||||
assertEquals(new DecryptedGroup.Builder()
|
||||
.revision(11)
|
||||
.members(List.of(member1))
|
||||
.build(),
|
||||
newGroup);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void promote_pending_member() throws NotAbleToApplyGroupV2ChangeException {
|
||||
ProfileKey profileKey2 = randomProfileKey();
|
||||
DecryptedMember member1 = member(UUID.randomUUID());
|
||||
UUID pending2Uuid = UUID.randomUUID();
|
||||
DecryptedPendingMember pending2 = pendingMember(pending2Uuid);
|
||||
DecryptedMember member2 = member(pending2Uuid, profileKey2);
|
||||
|
||||
DecryptedGroup newGroup = DecryptedGroupUtil.apply(new DecryptedGroup.Builder()
|
||||
.revision(10)
|
||||
.members(List.of(member1))
|
||||
.pendingMembers(List.of(pending2))
|
||||
.build(),
|
||||
new DecryptedGroupChange.Builder()
|
||||
.revision(11)
|
||||
.promotePendingMembers(List.of(member2))
|
||||
.build());
|
||||
|
||||
assertEquals(new DecryptedGroup.Builder()
|
||||
.revision(11)
|
||||
.members(List.of(member1, member2))
|
||||
.build(),
|
||||
newGroup);
|
||||
}
|
||||
|
||||
@Test(expected = NotAbleToApplyGroupV2ChangeException.class)
|
||||
public void cannot_promote_pending_member_if_not_in_group() throws NotAbleToApplyGroupV2ChangeException {
|
||||
ProfileKey profileKey2 = randomProfileKey();
|
||||
DecryptedMember member1 = member(UUID.randomUUID());
|
||||
UUID pending2Uuid = UUID.randomUUID();
|
||||
DecryptedMember member2 = withProfileKey(admin(pending2Uuid), profileKey2);
|
||||
|
||||
DecryptedGroupUtil.apply(new DecryptedGroup.Builder()
|
||||
.revision(10)
|
||||
.members(List.of(member1))
|
||||
.build(),
|
||||
new DecryptedGroupChange.Builder()
|
||||
.revision(11)
|
||||
.promotePendingMembers(List.of(member2))
|
||||
.build());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void skip_promote_pending_member_by_direct_add() throws NotAbleToApplyGroupV2ChangeException {
|
||||
ProfileKey profileKey2 = randomProfileKey();
|
||||
ProfileKey profileKey3 = randomProfileKey();
|
||||
DecryptedMember member1 = member(UUID.randomUUID());
|
||||
UUID pending2Uuid = UUID.randomUUID();
|
||||
UUID pending3Uuid = UUID.randomUUID();
|
||||
UUID pending4Uuid = UUID.randomUUID();
|
||||
DecryptedPendingMember pending2 = pendingMember(pending2Uuid);
|
||||
DecryptedPendingMember pending3 = pendingMember(pending3Uuid);
|
||||
DecryptedPendingMember pending4 = pendingMember(pending4Uuid);
|
||||
DecryptedMember member2 = member(pending2Uuid, profileKey2);
|
||||
DecryptedMember member3 = member(pending3Uuid, profileKey3);
|
||||
|
||||
DecryptedGroup newGroup = DecryptedGroupUtil.apply(new DecryptedGroup.Builder()
|
||||
.revision(10)
|
||||
.members(List.of(member1))
|
||||
.pendingMembers(List.of(pending2, pending3, pending4))
|
||||
.build(),
|
||||
new DecryptedGroupChange.Builder()
|
||||
.revision(11)
|
||||
.newMembers(List.of(member2, member3))
|
||||
.build());
|
||||
|
||||
assertEquals(new DecryptedGroup.Builder()
|
||||
.revision(11)
|
||||
.members(List.of(member1, member2, member3))
|
||||
.pendingMembers(List.of(pending4))
|
||||
.build(),
|
||||
newGroup);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void skip_promote_requesting_member_by_direct_add() throws NotAbleToApplyGroupV2ChangeException {
|
||||
ProfileKey profileKey2 = randomProfileKey();
|
||||
ProfileKey profileKey3 = randomProfileKey();
|
||||
DecryptedMember member1 = member(UUID.randomUUID());
|
||||
UUID requesting2Uuid = UUID.randomUUID();
|
||||
UUID requesting3Uuid = UUID.randomUUID();
|
||||
UUID requesting4Uuid = UUID.randomUUID();
|
||||
DecryptedRequestingMember requesting2 = requestingMember(requesting2Uuid);
|
||||
DecryptedRequestingMember requesting3 = requestingMember(requesting3Uuid);
|
||||
DecryptedRequestingMember requesting4 = requestingMember(requesting4Uuid);
|
||||
DecryptedMember member2 = member(requesting2Uuid, profileKey2);
|
||||
DecryptedMember member3 = member(requesting3Uuid, profileKey3);
|
||||
|
||||
DecryptedGroup newGroup = DecryptedGroupUtil.apply(new DecryptedGroup.Builder()
|
||||
.revision(10)
|
||||
.members(List.of(member1))
|
||||
.requestingMembers(List.of(requesting2, requesting3, requesting4))
|
||||
.build(),
|
||||
new DecryptedGroupChange.Builder()
|
||||
.revision(11)
|
||||
.newMembers(List.of(member2, member3))
|
||||
.build());
|
||||
|
||||
assertEquals(new DecryptedGroup.Builder()
|
||||
.revision(11)
|
||||
.members(List.of(member1, member2, member3))
|
||||
.requestingMembers(List.of(requesting4))
|
||||
.build(),
|
||||
newGroup);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void title() throws NotAbleToApplyGroupV2ChangeException {
|
||||
DecryptedGroup newGroup = DecryptedGroupUtil.apply(new DecryptedGroup.Builder()
|
||||
.revision(10)
|
||||
.title("Old title")
|
||||
.build(),
|
||||
new DecryptedGroupChange.Builder()
|
||||
.revision(11)
|
||||
.newTitle(new DecryptedString.Builder().value_("New title").build())
|
||||
.build());
|
||||
|
||||
assertEquals(new DecryptedGroup.Builder()
|
||||
.revision(11)
|
||||
.title("New title")
|
||||
.build(),
|
||||
newGroup);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void description() throws NotAbleToApplyGroupV2ChangeException {
|
||||
DecryptedGroup newGroup = DecryptedGroupUtil.apply(new DecryptedGroup.Builder()
|
||||
.revision(10)
|
||||
.description("Old description")
|
||||
.build(),
|
||||
new DecryptedGroupChange.Builder()
|
||||
.revision(11)
|
||||
.newDescription(new DecryptedString.Builder().value_("New Description").build())
|
||||
.build());
|
||||
|
||||
assertEquals(new DecryptedGroup.Builder()
|
||||
.revision(11)
|
||||
.description("New Description")
|
||||
.build(),
|
||||
newGroup);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void isAnnouncementGroup() throws NotAbleToApplyGroupV2ChangeException {
|
||||
DecryptedGroup newGroup = DecryptedGroupUtil.apply(new DecryptedGroup.Builder()
|
||||
.revision(10)
|
||||
.isAnnouncementGroup(EnabledState.DISABLED)
|
||||
.build(),
|
||||
new DecryptedGroupChange.Builder()
|
||||
.revision(11)
|
||||
.newIsAnnouncementGroup(EnabledState.ENABLED)
|
||||
.build());
|
||||
|
||||
assertEquals(new DecryptedGroup.Builder()
|
||||
.revision(11)
|
||||
.isAnnouncementGroup(EnabledState.ENABLED)
|
||||
.build(),
|
||||
newGroup);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void avatar() throws NotAbleToApplyGroupV2ChangeException {
|
||||
DecryptedGroup newGroup = DecryptedGroupUtil.apply(new DecryptedGroup.Builder()
|
||||
.revision(10)
|
||||
.avatar("https://cnd/oldavatar")
|
||||
.build(),
|
||||
new DecryptedGroupChange.Builder()
|
||||
.revision(11)
|
||||
.newAvatar(new DecryptedString.Builder().value_("https://cnd/newavatar").build())
|
||||
.build());
|
||||
|
||||
assertEquals(new DecryptedGroup.Builder()
|
||||
.revision(11)
|
||||
.avatar("https://cnd/newavatar")
|
||||
.build(),
|
||||
newGroup);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void timer() throws NotAbleToApplyGroupV2ChangeException {
|
||||
DecryptedGroup newGroup = DecryptedGroupUtil.apply(new DecryptedGroup.Builder()
|
||||
.revision(10)
|
||||
.disappearingMessagesTimer(new DecryptedTimer.Builder().duration(100).build())
|
||||
.build(),
|
||||
new DecryptedGroupChange.Builder()
|
||||
.revision(11)
|
||||
.newTimer(new DecryptedTimer.Builder().duration(2000).build())
|
||||
.build());
|
||||
|
||||
assertEquals(new DecryptedGroup.Builder()
|
||||
.revision(11)
|
||||
.disappearingMessagesTimer(new DecryptedTimer.Builder().duration(2000).build())
|
||||
.build(),
|
||||
newGroup);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void attribute_access() throws NotAbleToApplyGroupV2ChangeException {
|
||||
DecryptedGroup newGroup = DecryptedGroupUtil.apply(new DecryptedGroup.Builder()
|
||||
.revision(10)
|
||||
.accessControl(new AccessControl.Builder()
|
||||
.attributes(AccessControl.AccessRequired.ADMINISTRATOR)
|
||||
.members(AccessControl.AccessRequired.MEMBER)
|
||||
.build())
|
||||
.build(),
|
||||
new DecryptedGroupChange.Builder()
|
||||
.revision(11)
|
||||
.newAttributeAccess(AccessControl.AccessRequired.MEMBER)
|
||||
.build());
|
||||
|
||||
assertEquals(new DecryptedGroup.Builder()
|
||||
.revision(11)
|
||||
.accessControl(new AccessControl.Builder()
|
||||
.attributes(AccessControl.AccessRequired.MEMBER)
|
||||
.members(AccessControl.AccessRequired.MEMBER)
|
||||
.build())
|
||||
.build(),
|
||||
newGroup);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void membership_access() throws NotAbleToApplyGroupV2ChangeException {
|
||||
DecryptedGroup newGroup = DecryptedGroupUtil.apply(new DecryptedGroup.Builder()
|
||||
.revision(10)
|
||||
.accessControl(new AccessControl.Builder()
|
||||
.attributes(AccessControl.AccessRequired.ADMINISTRATOR)
|
||||
.members(AccessControl.AccessRequired.MEMBER)
|
||||
.build())
|
||||
.build(),
|
||||
new DecryptedGroupChange.Builder()
|
||||
.revision(11)
|
||||
.newMemberAccess(AccessControl.AccessRequired.ADMINISTRATOR)
|
||||
.build());
|
||||
|
||||
assertEquals(new DecryptedGroup.Builder()
|
||||
.revision(11)
|
||||
.accessControl(new AccessControl.Builder()
|
||||
.attributes(AccessControl.AccessRequired.ADMINISTRATOR)
|
||||
.members(AccessControl.AccessRequired.ADMINISTRATOR)
|
||||
.build())
|
||||
.build(),
|
||||
newGroup);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void change_both_access_levels() throws NotAbleToApplyGroupV2ChangeException {
|
||||
DecryptedGroup newGroup = DecryptedGroupUtil.apply(new DecryptedGroup.Builder()
|
||||
.revision(10)
|
||||
.accessControl(new AccessControl.Builder()
|
||||
.attributes(AccessControl.AccessRequired.ADMINISTRATOR)
|
||||
.members(AccessControl.AccessRequired.MEMBER)
|
||||
.build())
|
||||
.build(),
|
||||
new DecryptedGroupChange.Builder()
|
||||
.revision(11)
|
||||
.newAttributeAccess(AccessControl.AccessRequired.MEMBER)
|
||||
.newMemberAccess(AccessControl.AccessRequired.ADMINISTRATOR)
|
||||
.build());
|
||||
|
||||
assertEquals(new DecryptedGroup.Builder()
|
||||
.revision(11)
|
||||
.accessControl(new AccessControl.Builder()
|
||||
.attributes(AccessControl.AccessRequired.MEMBER)
|
||||
.members(AccessControl.AccessRequired.ADMINISTRATOR)
|
||||
.build())
|
||||
.build(),
|
||||
newGroup);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void invite_link_access() throws NotAbleToApplyGroupV2ChangeException {
|
||||
DecryptedGroup newGroup = DecryptedGroupUtil.apply(new DecryptedGroup.Builder()
|
||||
.revision(10)
|
||||
.accessControl(new AccessControl.Builder()
|
||||
.attributes(AccessControl.AccessRequired.MEMBER)
|
||||
.members(AccessControl.AccessRequired.MEMBER)
|
||||
.addFromInviteLink(AccessControl.AccessRequired.UNSATISFIABLE)
|
||||
.build())
|
||||
.build(),
|
||||
new DecryptedGroupChange.Builder()
|
||||
.revision(11)
|
||||
.newInviteLinkAccess(AccessControl.AccessRequired.ADMINISTRATOR)
|
||||
.build());
|
||||
|
||||
assertEquals(new DecryptedGroup.Builder()
|
||||
.revision(11)
|
||||
.accessControl(new AccessControl.Builder()
|
||||
.attributes(AccessControl.AccessRequired.MEMBER)
|
||||
.members(AccessControl.AccessRequired.MEMBER)
|
||||
.addFromInviteLink(AccessControl.AccessRequired.ADMINISTRATOR)
|
||||
.build())
|
||||
.build(),
|
||||
newGroup);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void apply_new_requesting_member() throws NotAbleToApplyGroupV2ChangeException {
|
||||
DecryptedRequestingMember member1 = requestingMember(UUID.randomUUID());
|
||||
DecryptedRequestingMember member2 = requestingMember(UUID.randomUUID());
|
||||
|
||||
DecryptedGroup newGroup = DecryptedGroupUtil.apply(new DecryptedGroup.Builder()
|
||||
.revision(10)
|
||||
.requestingMembers(List.of(member1))
|
||||
.build(),
|
||||
new DecryptedGroupChange.Builder()
|
||||
.revision(11)
|
||||
.newRequestingMembers(List.of(member2))
|
||||
.build());
|
||||
|
||||
assertEquals(new DecryptedGroup.Builder()
|
||||
.revision(11)
|
||||
.requestingMembers(List.of(member1, member2))
|
||||
.build(),
|
||||
newGroup);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void apply_remove_requesting_member() throws NotAbleToApplyGroupV2ChangeException {
|
||||
DecryptedRequestingMember member1 = requestingMember(UUID.randomUUID());
|
||||
DecryptedRequestingMember member2 = requestingMember(UUID.randomUUID());
|
||||
|
||||
DecryptedGroup newGroup = DecryptedGroupUtil.apply(new DecryptedGroup.Builder()
|
||||
.revision(13)
|
||||
.requestingMembers(List.of(member1, member2))
|
||||
.build(),
|
||||
new DecryptedGroupChange.Builder()
|
||||
.revision(14)
|
||||
.deleteRequestingMembers(List.of(member1.aciBytes))
|
||||
.build());
|
||||
|
||||
assertEquals(new DecryptedGroup.Builder()
|
||||
.revision(14)
|
||||
.requestingMembers(List.of(member2))
|
||||
.build(),
|
||||
newGroup);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void promote_requesting_member() throws NotAbleToApplyGroupV2ChangeException {
|
||||
UUID uuid1 = UUID.randomUUID();
|
||||
UUID uuid2 = UUID.randomUUID();
|
||||
UUID uuid3 = UUID.randomUUID();
|
||||
ProfileKey profileKey1 = newProfileKey();
|
||||
ProfileKey profileKey2 = newProfileKey();
|
||||
ProfileKey profileKey3 = newProfileKey();
|
||||
DecryptedRequestingMember member1 = requestingMember(uuid1, profileKey1);
|
||||
DecryptedRequestingMember member2 = requestingMember(uuid2, profileKey2);
|
||||
DecryptedRequestingMember member3 = requestingMember(uuid3, profileKey3);
|
||||
|
||||
DecryptedGroup newGroup = DecryptedGroupUtil.apply(new DecryptedGroup.Builder()
|
||||
.revision(13)
|
||||
.requestingMembers(List.of(member1, member2, member3))
|
||||
.build(),
|
||||
new DecryptedGroupChange.Builder()
|
||||
.revision(14)
|
||||
.promoteRequestingMembers(List.of(new DecryptedApproveMember.Builder()
|
||||
.role(Member.Role.DEFAULT)
|
||||
.aciBytes(member1.aciBytes)
|
||||
.build(),
|
||||
new DecryptedApproveMember.Builder()
|
||||
.role(Member.Role.ADMINISTRATOR)
|
||||
.aciBytes(member2.aciBytes)
|
||||
.build()))
|
||||
.build());
|
||||
|
||||
assertEquals(new DecryptedGroup.Builder()
|
||||
.revision(14)
|
||||
.members(List.of(member(uuid1, profileKey1), admin(uuid2, profileKey2)))
|
||||
.requestingMembers(List.of(member3))
|
||||
.build(),
|
||||
newGroup);
|
||||
}
|
||||
|
||||
@Test(expected = NotAbleToApplyGroupV2ChangeException.class)
|
||||
public void cannot_apply_promote_requesting_member_without_a_role() throws NotAbleToApplyGroupV2ChangeException {
|
||||
UUID uuid = UUID.randomUUID();
|
||||
DecryptedRequestingMember member = requestingMember(uuid);
|
||||
|
||||
DecryptedGroupUtil.apply(new DecryptedGroup.Builder()
|
||||
.revision(13)
|
||||
.requestingMembers(List.of(member))
|
||||
.build(),
|
||||
new DecryptedGroupChange.Builder()
|
||||
.revision(14)
|
||||
.promoteRequestingMembers(List.of(new DecryptedApproveMember.Builder().aciBytes(member.aciBytes).build()))
|
||||
.build());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void invite_link_password() throws NotAbleToApplyGroupV2ChangeException {
|
||||
ByteString password1 = ByteString.of(Util.getSecretBytes(16));
|
||||
ByteString password2 = ByteString.of(Util.getSecretBytes(16));
|
||||
|
||||
DecryptedGroup newGroup = DecryptedGroupUtil.apply(new DecryptedGroup.Builder()
|
||||
.revision(10)
|
||||
.inviteLinkPassword(password1)
|
||||
.build(),
|
||||
new DecryptedGroupChange.Builder()
|
||||
.revision(11)
|
||||
.newInviteLinkPassword(password2)
|
||||
.build());
|
||||
|
||||
assertEquals(new DecryptedGroup.Builder()
|
||||
.revision(11)
|
||||
.inviteLinkPassword(password2)
|
||||
.build(),
|
||||
newGroup);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void invite_link_password_not_changed() throws NotAbleToApplyGroupV2ChangeException {
|
||||
ByteString password = ByteString.of(Util.getSecretBytes(16));
|
||||
|
||||
DecryptedGroup newGroup = DecryptedGroupUtil.apply(new DecryptedGroup.Builder()
|
||||
.revision(10)
|
||||
.inviteLinkPassword(password)
|
||||
.build(),
|
||||
new DecryptedGroupChange.Builder()
|
||||
.revision(11)
|
||||
.build());
|
||||
|
||||
assertEquals(new DecryptedGroup.Builder()
|
||||
.revision(11)
|
||||
.inviteLinkPassword(password)
|
||||
.build(),
|
||||
newGroup);
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
public void apply_new_banned_member() throws NotAbleToApplyGroupV2ChangeException {
|
||||
DecryptedMember member1 = member(UUID.randomUUID());
|
||||
DecryptedBannedMember banned = bannedMember(UUID.randomUUID());
|
||||
|
||||
DecryptedGroup newGroup = DecryptedGroupUtil.apply(new DecryptedGroup.Builder()
|
||||
.revision(10)
|
||||
.members(List.of(member1))
|
||||
.build(),
|
||||
new DecryptedGroupChange.Builder()
|
||||
.revision(11)
|
||||
.newBannedMembers(List.of(banned))
|
||||
.build());
|
||||
|
||||
assertEquals(new DecryptedGroup.Builder()
|
||||
.revision(11)
|
||||
.members(List.of(member1))
|
||||
.bannedMembers(List.of(banned))
|
||||
.build(),
|
||||
newGroup);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void apply_new_banned_member_already_banned() throws NotAbleToApplyGroupV2ChangeException {
|
||||
DecryptedMember member1 = member(UUID.randomUUID());
|
||||
DecryptedBannedMember banned = bannedMember(UUID.randomUUID());
|
||||
|
||||
DecryptedGroup newGroup = DecryptedGroupUtil.apply(new DecryptedGroup.Builder()
|
||||
.revision(10)
|
||||
.members(List.of(member1))
|
||||
.bannedMembers(List.of(banned))
|
||||
.build(),
|
||||
new DecryptedGroupChange.Builder()
|
||||
.revision(11)
|
||||
.newBannedMembers(List.of(banned))
|
||||
.build());
|
||||
|
||||
assertEquals(new DecryptedGroup.Builder()
|
||||
.revision(11)
|
||||
.members(List.of(member1))
|
||||
.bannedMembers(List.of(banned))
|
||||
.build(),
|
||||
newGroup);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void remove_banned_member() throws NotAbleToApplyGroupV2ChangeException {
|
||||
DecryptedMember member1 = member(UUID.randomUUID());
|
||||
UUID bannedUuid = UUID.randomUUID();
|
||||
DecryptedBannedMember banned = bannedMember(bannedUuid);
|
||||
|
||||
DecryptedGroup newGroup = DecryptedGroupUtil.apply(new DecryptedGroup.Builder()
|
||||
.revision(10)
|
||||
.members(List.of(member1))
|
||||
.bannedMembers(List.of(banned))
|
||||
.build(),
|
||||
new DecryptedGroupChange.Builder()
|
||||
.revision(11)
|
||||
.deleteBannedMembers(List.of(new DecryptedBannedMember.Builder()
|
||||
.serviceIdBytes(UuidUtil.toByteString(bannedUuid))
|
||||
.build()))
|
||||
.build());
|
||||
|
||||
assertEquals(new DecryptedGroup.Builder()
|
||||
.revision(11)
|
||||
.members(List.of(member1))
|
||||
.build(),
|
||||
newGroup);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void promote_pending_member_pni_aci() throws NotAbleToApplyGroupV2ChangeException {
|
||||
ProfileKey profileKey2 = randomProfileKey();
|
||||
DecryptedMember member1 = member(UUID.randomUUID());
|
||||
UUID pending2Aci = UUID.randomUUID();
|
||||
UUID pending2Pni = UUID.randomUUID();
|
||||
DecryptedPendingMember pending2 = pendingMember(pending2Pni);
|
||||
DecryptedMember member2 = pendingPniAciMember(pending2Aci, pending2Pni, profileKey2);
|
||||
|
||||
DecryptedGroup newGroup = DecryptedGroupUtil.apply(new DecryptedGroup.Builder()
|
||||
.revision(10)
|
||||
.members(List.of(member1))
|
||||
.pendingMembers(List.of(pending2))
|
||||
.build(),
|
||||
new DecryptedGroupChange.Builder()
|
||||
.revision(11)
|
||||
.promotePendingPniAciMembers(List.of(member2))
|
||||
.build());
|
||||
|
||||
assertEquals(new DecryptedGroup.Builder()
|
||||
.revision(11)
|
||||
.members(List.of(member1, member2))
|
||||
.build(),
|
||||
newGroup);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
package org.whispersystems.signalservice.api.groupsv2;
|
||||
|
||||
import org.junit.Test;
|
||||
import org.signal.storageservice.protos.groups.AccessControl;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedApproveMember;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedBannedMember;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedGroupChange;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedRequestingMember;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedString;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedTimer;
|
||||
import org.signal.storageservice.protos.groups.local.EnabledState;
|
||||
import org.signal.core.util.UuidUtil;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import okio.ByteString;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.whispersystems.signalservice.api.groupsv2.ProtoTestUtils.member;
|
||||
import static org.whispersystems.signalservice.api.groupsv2.ProtoTestUtils.pendingMember;
|
||||
import static org.whispersystems.signalservice.api.groupsv2.ProtoTestUtils.pendingMemberRemoval;
|
||||
import static org.whispersystems.signalservice.api.groupsv2.ProtoTestUtils.pendingPniAciMember;
|
||||
import static org.whispersystems.signalservice.api.groupsv2.ProtoTestUtils.promoteAdmin;
|
||||
import static org.whispersystems.signalservice.api.groupsv2.ProtoTestUtils.randomProfileKey;
|
||||
import static org.whispersystems.signalservice.api.groupsv2.ProtobufTestUtils.getMaxDeclaredFieldNumber;
|
||||
|
||||
public final class DecryptedGroupUtil_empty_Test {
|
||||
|
||||
/**
|
||||
* Reflects over the generated protobuf class and ensures that no new fields have been added since we wrote this.
|
||||
* <p>
|
||||
* If we didn't, newly added fields would easily affect {@link DecryptedGroupUtil}'s ability to detect non-empty change states.
|
||||
*/
|
||||
@Test
|
||||
public void ensure_DecryptedGroupUtil_knows_about_all_fields_of_DecryptedGroupChange() {
|
||||
int maxFieldFound = getMaxDeclaredFieldNumber(DecryptedGroupChange.class);
|
||||
|
||||
assertEquals("DecryptedGroupUtil and its tests need updating to account for new fields on " + DecryptedGroupChange.class.getName(),
|
||||
24, maxFieldFound);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void empty_change_set() {
|
||||
assertTrue(DecryptedGroupUtil.changeIsEmpty(new DecryptedGroupChange.Builder().build()));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void not_empty_with_add_member_field_3() {
|
||||
DecryptedGroupChange change = new DecryptedGroupChange.Builder()
|
||||
.newMembers(List.of(member(UUID.randomUUID())))
|
||||
.build();
|
||||
|
||||
assertFalse(DecryptedGroupUtil.changeIsEmpty(change));
|
||||
assertFalse(DecryptedGroupUtil.changeIsEmptyExceptForProfileKeyChanges(change));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void not_empty_with_delete_member_field_4() {
|
||||
DecryptedGroupChange change = new DecryptedGroupChange.Builder()
|
||||
.deleteMembers(List.of(UuidUtil.toByteString(UUID.randomUUID())))
|
||||
.build();
|
||||
|
||||
assertFalse(DecryptedGroupUtil.changeIsEmpty(change));
|
||||
assertFalse(DecryptedGroupUtil.changeIsEmptyExceptForProfileKeyChanges(change));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void not_empty_with_modify_member_roles_field_5() {
|
||||
DecryptedGroupChange change = new DecryptedGroupChange.Builder()
|
||||
.modifyMemberRoles(List.of(promoteAdmin(UUID.randomUUID())))
|
||||
.build();
|
||||
|
||||
assertFalse(DecryptedGroupUtil.changeIsEmpty(change));
|
||||
assertFalse(DecryptedGroupUtil.changeIsEmptyExceptForProfileKeyChanges(change));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void not_empty_with_modify_profile_keys_field_6() {
|
||||
DecryptedGroupChange change = new DecryptedGroupChange.Builder()
|
||||
.modifiedProfileKeys(List.of(member(UUID.randomUUID(), randomProfileKey())))
|
||||
.build();
|
||||
|
||||
assertFalse(DecryptedGroupUtil.changeIsEmpty(change));
|
||||
assertTrue(DecryptedGroupUtil.changeIsEmptyExceptForProfileKeyChanges(change));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void not_empty_with_add_pending_members_field_7() {
|
||||
DecryptedGroupChange change = new DecryptedGroupChange.Builder()
|
||||
.newPendingMembers(List.of(pendingMember(UUID.randomUUID())))
|
||||
.build();
|
||||
|
||||
assertFalse(DecryptedGroupUtil.changeIsEmpty(change));
|
||||
assertFalse(DecryptedGroupUtil.changeIsEmptyExceptForProfileKeyChanges(change));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void not_empty_with_delete_pending_members_field_8() {
|
||||
DecryptedGroupChange change = new DecryptedGroupChange.Builder()
|
||||
.deletePendingMembers(List.of(pendingMemberRemoval(UUID.randomUUID())))
|
||||
.build();
|
||||
|
||||
assertFalse(DecryptedGroupUtil.changeIsEmpty(change));
|
||||
assertFalse(DecryptedGroupUtil.changeIsEmptyExceptForProfileKeyChanges(change));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void not_empty_with_promote_delete_pending_members_field_9() {
|
||||
DecryptedGroupChange change = new DecryptedGroupChange.Builder()
|
||||
.promotePendingMembers(List.of(member(UUID.randomUUID())))
|
||||
.build();
|
||||
|
||||
assertFalse(DecryptedGroupUtil.changeIsEmpty(change));
|
||||
assertFalse(DecryptedGroupUtil.changeIsEmptyExceptForProfileKeyChanges(change));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void not_empty_with_modify_title_field_10() {
|
||||
DecryptedGroupChange change = new DecryptedGroupChange.Builder()
|
||||
.newTitle(new DecryptedString.Builder().value_("New title").build())
|
||||
.build();
|
||||
|
||||
assertFalse(DecryptedGroupUtil.changeIsEmpty(change));
|
||||
assertFalse(DecryptedGroupUtil.changeIsEmptyExceptForProfileKeyChanges(change));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void not_empty_with_modify_avatar_field_11() {
|
||||
DecryptedGroupChange change = new DecryptedGroupChange.Builder()
|
||||
.newAvatar(new DecryptedString.Builder().value_("New Avatar").build())
|
||||
.build();
|
||||
|
||||
assertFalse(DecryptedGroupUtil.changeIsEmpty(change));
|
||||
assertFalse(DecryptedGroupUtil.changeIsEmptyExceptForProfileKeyChanges(change));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void not_empty_with_modify_disappearing_message_timer_field_12() {
|
||||
DecryptedGroupChange change = new DecryptedGroupChange.Builder()
|
||||
.newTimer(new DecryptedTimer.Builder().duration(60).build())
|
||||
.build();
|
||||
|
||||
assertFalse(DecryptedGroupUtil.changeIsEmpty(change));
|
||||
assertFalse(DecryptedGroupUtil.changeIsEmptyExceptForProfileKeyChanges(change));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void not_empty_with_modify_attributes_field_13() {
|
||||
DecryptedGroupChange change = new DecryptedGroupChange.Builder()
|
||||
.newAttributeAccess(AccessControl.AccessRequired.ADMINISTRATOR)
|
||||
.build();
|
||||
|
||||
assertFalse(DecryptedGroupUtil.changeIsEmpty(change));
|
||||
assertFalse(DecryptedGroupUtil.changeIsEmptyExceptForProfileKeyChanges(change));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void not_empty_with_modify_member_access_field_14() {
|
||||
DecryptedGroupChange change = new DecryptedGroupChange.Builder()
|
||||
.newMemberAccess(AccessControl.AccessRequired.MEMBER)
|
||||
.build();
|
||||
|
||||
assertFalse(DecryptedGroupUtil.changeIsEmpty(change));
|
||||
assertFalse(DecryptedGroupUtil.changeIsEmptyExceptForProfileKeyChanges(change));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void not_empty_with_modify_add_from_invite_link_access_field_15() {
|
||||
DecryptedGroupChange change = new DecryptedGroupChange.Builder()
|
||||
.newInviteLinkAccess(AccessControl.AccessRequired.ADMINISTRATOR)
|
||||
.build();
|
||||
|
||||
assertFalse(DecryptedGroupUtil.changeIsEmpty(change));
|
||||
assertFalse(DecryptedGroupUtil.changeIsEmptyExceptForProfileKeyChanges(change));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void not_empty_with_an_add_requesting_member_field_16() {
|
||||
DecryptedGroupChange change = new DecryptedGroupChange.Builder()
|
||||
.newRequestingMembers(List.of(new DecryptedRequestingMember()))
|
||||
.build();
|
||||
|
||||
assertFalse(DecryptedGroupUtil.changeIsEmpty(change));
|
||||
assertFalse(DecryptedGroupUtil.changeIsEmptyExceptForProfileKeyChanges(change));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void not_empty_with_a_delete_requesting_member_field_17() {
|
||||
DecryptedGroupChange change = new DecryptedGroupChange.Builder()
|
||||
.deleteRequestingMembers(List.of(ByteString.of(new byte[16])))
|
||||
.build();
|
||||
|
||||
assertFalse(DecryptedGroupUtil.changeIsEmpty(change));
|
||||
assertFalse(DecryptedGroupUtil.changeIsEmptyExceptForProfileKeyChanges(change));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void not_empty_with_a_promote_requesting_member_field_18() {
|
||||
DecryptedGroupChange change = new DecryptedGroupChange.Builder()
|
||||
.promoteRequestingMembers(List.of(new DecryptedApproveMember()))
|
||||
.build();
|
||||
|
||||
assertFalse(DecryptedGroupUtil.changeIsEmpty(change));
|
||||
assertFalse(DecryptedGroupUtil.changeIsEmptyExceptForProfileKeyChanges(change));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void not_empty_with_a_new_invite_link_password_19() {
|
||||
DecryptedGroupChange change = new DecryptedGroupChange.Builder()
|
||||
.newInviteLinkPassword(ByteString.of(new byte[16]))
|
||||
.build();
|
||||
|
||||
assertFalse(DecryptedGroupUtil.changeIsEmpty(change));
|
||||
assertFalse(DecryptedGroupUtil.changeIsEmptyExceptForProfileKeyChanges(change));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void not_empty_with_modify_description_field_20() {
|
||||
DecryptedGroupChange change = new DecryptedGroupChange.Builder()
|
||||
.newDescription(new DecryptedString.Builder().value_("New description").build())
|
||||
.build();
|
||||
|
||||
assertFalse(DecryptedGroupUtil.changeIsEmpty(change));
|
||||
assertFalse(DecryptedGroupUtil.changeIsEmptyExceptForProfileKeyChanges(change));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void not_empty_with_modify_announcement_field_21() {
|
||||
DecryptedGroupChange change = new DecryptedGroupChange.Builder()
|
||||
.newIsAnnouncementGroup(EnabledState.ENABLED)
|
||||
.build();
|
||||
|
||||
assertFalse(DecryptedGroupUtil.changeIsEmpty(change));
|
||||
assertFalse(DecryptedGroupUtil.changeIsEmptyExceptForProfileKeyChanges(change));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void not_empty_with_add_banned_member_field_22() {
|
||||
DecryptedGroupChange change = new DecryptedGroupChange.Builder()
|
||||
.newBannedMembers(List.of(new DecryptedBannedMember()))
|
||||
.build();
|
||||
|
||||
assertFalse(DecryptedGroupUtil.changeIsEmpty(change));
|
||||
assertFalse(DecryptedGroupUtil.changeIsEmptyExceptForProfileKeyChanges(change));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void not_empty_with_delete_banned_member_field_23() {
|
||||
DecryptedGroupChange change = new DecryptedGroupChange.Builder()
|
||||
.deleteBannedMembers(List.of(new DecryptedBannedMember()))
|
||||
.build();
|
||||
|
||||
assertFalse(DecryptedGroupUtil.changeIsEmpty(change));
|
||||
assertFalse(DecryptedGroupUtil.changeIsEmptyExceptForProfileKeyChanges(change));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void not_empty_with_promote_pending_pni_aci_members_field_24() {
|
||||
DecryptedGroupChange change = new DecryptedGroupChange.Builder()
|
||||
.promotePendingPniAciMembers(List.of(pendingPniAciMember(UUID.randomUUID(), UUID.randomUUID(), randomProfileKey())))
|
||||
.build();
|
||||
|
||||
assertFalse(DecryptedGroupUtil.changeIsEmpty(change));
|
||||
assertFalse(DecryptedGroupUtil.changeIsEmptyExceptForProfileKeyChanges(change));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,411 @@
|
||||
package org.whispersystems.signalservice.api.groupsv2;
|
||||
|
||||
import org.junit.Test;
|
||||
import org.signal.libsignal.zkgroup.profiles.ProfileKey;
|
||||
import org.signal.storageservice.protos.groups.AccessControl;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedGroup;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedGroupChange;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedString;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedTimer;
|
||||
import org.signal.storageservice.protos.groups.local.EnabledState;
|
||||
import org.signal.core.util.UuidUtil;
|
||||
import org.whispersystems.signalservice.internal.util.Util;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import okio.ByteString;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.whispersystems.signalservice.api.groupsv2.ProtoTestUtils.admin;
|
||||
import static org.whispersystems.signalservice.api.groupsv2.ProtoTestUtils.approveAdmin;
|
||||
import static org.whispersystems.signalservice.api.groupsv2.ProtoTestUtils.approveMember;
|
||||
import static org.whispersystems.signalservice.api.groupsv2.ProtoTestUtils.bannedMember;
|
||||
import static org.whispersystems.signalservice.api.groupsv2.ProtoTestUtils.demoteAdmin;
|
||||
import static org.whispersystems.signalservice.api.groupsv2.ProtoTestUtils.member;
|
||||
import static org.whispersystems.signalservice.api.groupsv2.ProtoTestUtils.newProfileKey;
|
||||
import static org.whispersystems.signalservice.api.groupsv2.ProtoTestUtils.pendingMember;
|
||||
import static org.whispersystems.signalservice.api.groupsv2.ProtoTestUtils.pendingMemberRemoval;
|
||||
import static org.whispersystems.signalservice.api.groupsv2.ProtoTestUtils.promoteAdmin;
|
||||
import static org.whispersystems.signalservice.api.groupsv2.ProtoTestUtils.randomProfileKey;
|
||||
import static org.whispersystems.signalservice.api.groupsv2.ProtoTestUtils.requestingMember;
|
||||
import static org.whispersystems.signalservice.api.groupsv2.ProtoTestUtils.withProfileKey;
|
||||
import static org.whispersystems.signalservice.api.groupsv2.ProtobufTestUtils.getMaxDeclaredFieldNumber;
|
||||
|
||||
public final class GroupChangeReconstructTest {
|
||||
|
||||
/**
|
||||
* Reflects over the generated protobuf class and ensures that no new fields have been added since we wrote this.
|
||||
* <p>
|
||||
* If we didn't, newly added fields would not be detected by {@link GroupChangeReconstruct#reconstructGroupChange}.
|
||||
*/
|
||||
@Test
|
||||
public void ensure_GroupChangeReconstruct_knows_about_all_fields_of_DecryptedGroup() {
|
||||
int maxFieldFound = getMaxDeclaredFieldNumber(DecryptedGroup.class, ProtobufTestUtils.IGNORED_DECRYPTED_GROUP_TAGS);
|
||||
|
||||
assertEquals("GroupChangeReconstruct and its tests need updating to account for new fields on " + DecryptedGroup.class.getName(),
|
||||
13, maxFieldFound);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void empty_to_empty() {
|
||||
DecryptedGroup from = new DecryptedGroup.Builder().build();
|
||||
DecryptedGroup to = new DecryptedGroup.Builder().build();
|
||||
|
||||
DecryptedGroupChange decryptedGroupChange = GroupChangeReconstruct.reconstructGroupChange(from, to);
|
||||
|
||||
assertEquals(new DecryptedGroupChange.Builder().build(), decryptedGroupChange);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void revision_set_to_the_target() {
|
||||
DecryptedGroup from = new DecryptedGroup.Builder().revision(10).build();
|
||||
DecryptedGroup to = new DecryptedGroup.Builder().revision(20).build();
|
||||
|
||||
DecryptedGroupChange decryptedGroupChange = GroupChangeReconstruct.reconstructGroupChange(from, to);
|
||||
|
||||
assertEquals(20, decryptedGroupChange.revision);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void title_change() {
|
||||
DecryptedGroup from = new DecryptedGroup.Builder().title("A").build();
|
||||
DecryptedGroup to = new DecryptedGroup.Builder().title("B").build();
|
||||
|
||||
DecryptedGroupChange decryptedGroupChange = GroupChangeReconstruct.reconstructGroupChange(from, to);
|
||||
|
||||
assertEquals(new DecryptedGroupChange.Builder().newTitle(new DecryptedString.Builder().value_("B").build()).build(), decryptedGroupChange);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void description_change() {
|
||||
DecryptedGroup from = new DecryptedGroup.Builder().description("A").build();
|
||||
DecryptedGroup to = new DecryptedGroup.Builder().description("B").build();
|
||||
|
||||
DecryptedGroupChange decryptedGroupChange = GroupChangeReconstruct.reconstructGroupChange(from, to);
|
||||
|
||||
assertEquals(new DecryptedGroupChange.Builder().newDescription(new DecryptedString.Builder().value_("B").build()).build(), decryptedGroupChange);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void announcement_group_change() {
|
||||
DecryptedGroup from = new DecryptedGroup.Builder().isAnnouncementGroup(EnabledState.DISABLED).build();
|
||||
DecryptedGroup to = new DecryptedGroup.Builder().isAnnouncementGroup(EnabledState.ENABLED).build();
|
||||
|
||||
DecryptedGroupChange decryptedGroupChange = GroupChangeReconstruct.reconstructGroupChange(from, to);
|
||||
|
||||
assertEquals(new DecryptedGroupChange.Builder().newIsAnnouncementGroup(EnabledState.ENABLED).build(), decryptedGroupChange);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void avatar_change() {
|
||||
DecryptedGroup from = new DecryptedGroup.Builder().avatar("A").build();
|
||||
DecryptedGroup to = new DecryptedGroup.Builder().avatar("B").build();
|
||||
|
||||
DecryptedGroupChange decryptedGroupChange = GroupChangeReconstruct.reconstructGroupChange(from, to);
|
||||
|
||||
assertEquals(new DecryptedGroupChange.Builder().newAvatar(new DecryptedString.Builder().value_("B").build()).build(), decryptedGroupChange);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void timer_change() {
|
||||
DecryptedGroup from = new DecryptedGroup.Builder().disappearingMessagesTimer(new DecryptedTimer.Builder().duration(100).build()).build();
|
||||
DecryptedGroup to = new DecryptedGroup.Builder().disappearingMessagesTimer(new DecryptedTimer.Builder().duration(200).build()).build();
|
||||
|
||||
DecryptedGroupChange decryptedGroupChange = GroupChangeReconstruct.reconstructGroupChange(from, to);
|
||||
|
||||
assertEquals(new DecryptedGroupChange.Builder().newTimer(new DecryptedTimer.Builder().duration(200).build()).build(), decryptedGroupChange);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void access_control_change_attributes() {
|
||||
DecryptedGroup from = new DecryptedGroup.Builder().accessControl(new AccessControl.Builder().attributes(AccessControl.AccessRequired.MEMBER).build()).build();
|
||||
DecryptedGroup to = new DecryptedGroup.Builder().accessControl(new AccessControl.Builder().attributes(AccessControl.AccessRequired.ADMINISTRATOR).build()).build();
|
||||
|
||||
DecryptedGroupChange decryptedGroupChange = GroupChangeReconstruct.reconstructGroupChange(from, to);
|
||||
|
||||
assertEquals(new DecryptedGroupChange.Builder().newAttributeAccess(AccessControl.AccessRequired.ADMINISTRATOR).build(), decryptedGroupChange);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void access_control_change_membership() {
|
||||
DecryptedGroup from = new DecryptedGroup.Builder().accessControl(new AccessControl.Builder().members(AccessControl.AccessRequired.ADMINISTRATOR).build()).build();
|
||||
DecryptedGroup to = new DecryptedGroup.Builder().accessControl(new AccessControl.Builder().members(AccessControl.AccessRequired.MEMBER).build()).build();
|
||||
|
||||
DecryptedGroupChange decryptedGroupChange = GroupChangeReconstruct.reconstructGroupChange(from, to);
|
||||
|
||||
assertEquals(new DecryptedGroupChange.Builder().newMemberAccess(AccessControl.AccessRequired.MEMBER).build(), decryptedGroupChange);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void access_control_change_membership_and_attributes() {
|
||||
DecryptedGroup from = new DecryptedGroup.Builder().accessControl(new AccessControl.Builder().members(AccessControl.AccessRequired.MEMBER)
|
||||
.attributes(AccessControl.AccessRequired.ADMINISTRATOR).build()).build();
|
||||
DecryptedGroup to = new DecryptedGroup.Builder().accessControl(new AccessControl.Builder().members(AccessControl.AccessRequired.ADMINISTRATOR)
|
||||
.attributes(AccessControl.AccessRequired.MEMBER).build()).build();
|
||||
|
||||
DecryptedGroupChange decryptedGroupChange = GroupChangeReconstruct.reconstructGroupChange(from, to);
|
||||
|
||||
assertEquals(new DecryptedGroupChange.Builder().newMemberAccess(AccessControl.AccessRequired.ADMINISTRATOR)
|
||||
.newAttributeAccess(AccessControl.AccessRequired.MEMBER).build(), decryptedGroupChange);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void new_member() {
|
||||
UUID uuidNew = UUID.randomUUID();
|
||||
DecryptedGroup from = new DecryptedGroup.Builder().build();
|
||||
DecryptedGroup to = new DecryptedGroup.Builder().members(List.of(member(uuidNew))).build();
|
||||
|
||||
DecryptedGroupChange decryptedGroupChange = GroupChangeReconstruct.reconstructGroupChange(from, to);
|
||||
|
||||
assertEquals(new DecryptedGroupChange.Builder().newMembers(List.of(member(uuidNew))).build(), decryptedGroupChange);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void removed_member() {
|
||||
UUID uuidOld = UUID.randomUUID();
|
||||
DecryptedGroup from = new DecryptedGroup.Builder().members(List.of(member(uuidOld))).build();
|
||||
DecryptedGroup to = new DecryptedGroup.Builder().build();
|
||||
|
||||
DecryptedGroupChange decryptedGroupChange = GroupChangeReconstruct.reconstructGroupChange(from, to);
|
||||
|
||||
assertEquals(new DecryptedGroupChange.Builder().deleteMembers(List.of(UuidUtil.toByteString(uuidOld))).build(), decryptedGroupChange);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void new_member_and_existing_member() {
|
||||
UUID uuidOld = UUID.randomUUID();
|
||||
UUID uuidNew = UUID.randomUUID();
|
||||
DecryptedGroup from = new DecryptedGroup.Builder().members(List.of(member(uuidOld))).build();
|
||||
DecryptedGroup to = new DecryptedGroup.Builder().members(List.of(member(uuidOld), member(uuidNew))).build();
|
||||
|
||||
DecryptedGroupChange decryptedGroupChange = GroupChangeReconstruct.reconstructGroupChange(from, to);
|
||||
|
||||
assertEquals(new DecryptedGroupChange.Builder().newMembers(List.of(member(uuidNew))).build(), decryptedGroupChange);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void removed_member_and_remaining_member() {
|
||||
UUID uuidOld = UUID.randomUUID();
|
||||
UUID uuidRemaining = UUID.randomUUID();
|
||||
DecryptedGroup from = new DecryptedGroup.Builder().members(List.of(member(uuidOld), member(uuidRemaining))).build();
|
||||
DecryptedGroup to = new DecryptedGroup.Builder().members(List.of(member(uuidRemaining))).build();
|
||||
|
||||
DecryptedGroupChange decryptedGroupChange = GroupChangeReconstruct.reconstructGroupChange(from, to);
|
||||
|
||||
assertEquals(new DecryptedGroupChange.Builder().deleteMembers(List.of(UuidUtil.toByteString(uuidOld))).build(), decryptedGroupChange);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void new_member_by_invite() {
|
||||
UUID uuidNew = UUID.randomUUID();
|
||||
DecryptedGroup from = new DecryptedGroup.Builder().pendingMembers(List.of(pendingMember(uuidNew))).build();
|
||||
DecryptedGroup to = new DecryptedGroup.Builder().members(List.of(member(uuidNew))).build();
|
||||
|
||||
DecryptedGroupChange decryptedGroupChange = GroupChangeReconstruct.reconstructGroupChange(from, to);
|
||||
|
||||
assertEquals(new DecryptedGroupChange.Builder().promotePendingMembers(List.of(member(uuidNew))).build(), decryptedGroupChange);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void uninvited_member_by_invite() {
|
||||
UUID uuidNew = UUID.randomUUID();
|
||||
DecryptedGroup from = new DecryptedGroup.Builder().pendingMembers(List.of(pendingMember(uuidNew))).build();
|
||||
DecryptedGroup to = new DecryptedGroup.Builder().build();
|
||||
|
||||
DecryptedGroupChange decryptedGroupChange = GroupChangeReconstruct.reconstructGroupChange(from, to);
|
||||
|
||||
assertEquals(new DecryptedGroupChange.Builder().deletePendingMembers(List.of(pendingMemberRemoval(uuidNew))).build(), decryptedGroupChange);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void new_invite() {
|
||||
UUID uuidNew = UUID.randomUUID();
|
||||
DecryptedGroup from = new DecryptedGroup.Builder().build();
|
||||
DecryptedGroup to = new DecryptedGroup.Builder().pendingMembers(List.of(pendingMember(uuidNew))).build();
|
||||
|
||||
DecryptedGroupChange decryptedGroupChange = GroupChangeReconstruct.reconstructGroupChange(from, to);
|
||||
|
||||
assertEquals(new DecryptedGroupChange.Builder().newPendingMembers(List.of(pendingMember(uuidNew))).build(), decryptedGroupChange);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void to_admin() {
|
||||
UUID uuid = UUID.randomUUID();
|
||||
ProfileKey profileKey = randomProfileKey();
|
||||
DecryptedGroup from = new DecryptedGroup.Builder().members(List.of(withProfileKey(member(uuid), profileKey))).build();
|
||||
DecryptedGroup to = new DecryptedGroup.Builder().members(List.of(withProfileKey(admin(uuid), profileKey))).build();
|
||||
|
||||
DecryptedGroupChange decryptedGroupChange = GroupChangeReconstruct.reconstructGroupChange(from, to);
|
||||
|
||||
assertEquals(new DecryptedGroupChange.Builder().modifyMemberRoles(List.of(promoteAdmin(uuid))).build(), decryptedGroupChange);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void to_member() {
|
||||
UUID uuid = UUID.randomUUID();
|
||||
ProfileKey profileKey = randomProfileKey();
|
||||
DecryptedGroup from = new DecryptedGroup.Builder().members(List.of(withProfileKey(admin(uuid), profileKey))).build();
|
||||
DecryptedGroup to = new DecryptedGroup.Builder().members(List.of(withProfileKey(member(uuid), profileKey))).build();
|
||||
|
||||
DecryptedGroupChange decryptedGroupChange = GroupChangeReconstruct.reconstructGroupChange(from, to);
|
||||
|
||||
assertEquals(new DecryptedGroupChange.Builder().modifyMemberRoles(List.of(demoteAdmin(uuid))).build(), decryptedGroupChange);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void profile_key_change_member() {
|
||||
UUID uuid = UUID.randomUUID();
|
||||
ProfileKey profileKey1 = randomProfileKey();
|
||||
ProfileKey profileKey2 = randomProfileKey();
|
||||
DecryptedGroup from = new DecryptedGroup.Builder().members(List.of(withProfileKey(admin(uuid), profileKey1))).build();
|
||||
DecryptedGroup to = new DecryptedGroup.Builder().members(List.of(withProfileKey(admin(uuid), profileKey2))).build();
|
||||
|
||||
DecryptedGroupChange decryptedGroupChange = GroupChangeReconstruct.reconstructGroupChange(from, to);
|
||||
|
||||
assertEquals(new DecryptedGroupChange.Builder().modifiedProfileKeys(List.of(withProfileKey(admin(uuid), profileKey2))).build(), decryptedGroupChange);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void new_invite_access() {
|
||||
DecryptedGroup from = new DecryptedGroup.Builder()
|
||||
.accessControl(new AccessControl.Builder()
|
||||
.addFromInviteLink(AccessControl.AccessRequired.ADMINISTRATOR)
|
||||
.build())
|
||||
.build();
|
||||
DecryptedGroup to = new DecryptedGroup.Builder()
|
||||
.accessControl(new AccessControl.Builder()
|
||||
.addFromInviteLink(AccessControl.AccessRequired.UNSATISFIABLE)
|
||||
.build())
|
||||
.build();
|
||||
|
||||
DecryptedGroupChange decryptedGroupChange = GroupChangeReconstruct.reconstructGroupChange(from, to);
|
||||
|
||||
assertEquals(new DecryptedGroupChange.Builder()
|
||||
.newInviteLinkAccess(AccessControl.AccessRequired.UNSATISFIABLE)
|
||||
.build(),
|
||||
decryptedGroupChange);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void new_requesting_members() {
|
||||
UUID member1 = UUID.randomUUID();
|
||||
ProfileKey profileKey1 = newProfileKey();
|
||||
DecryptedGroup from = new DecryptedGroup.Builder()
|
||||
.build();
|
||||
DecryptedGroup to = new DecryptedGroup.Builder()
|
||||
.requestingMembers(List.of(requestingMember(member1, profileKey1)))
|
||||
.build();
|
||||
|
||||
DecryptedGroupChange decryptedGroupChange = GroupChangeReconstruct.reconstructGroupChange(from, to);
|
||||
|
||||
assertEquals(new DecryptedGroupChange.Builder()
|
||||
.newRequestingMembers(List.of(requestingMember(member1, profileKey1)))
|
||||
.build(),
|
||||
decryptedGroupChange);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void new_requesting_members_ignores_existing_by_uuid() {
|
||||
UUID member1 = UUID.randomUUID();
|
||||
UUID member2 = UUID.randomUUID();
|
||||
ProfileKey profileKey2 = newProfileKey();
|
||||
|
||||
DecryptedGroup from = new DecryptedGroup.Builder()
|
||||
.requestingMembers(List.of(requestingMember(member1, newProfileKey())))
|
||||
.build();
|
||||
|
||||
DecryptedGroup to = new DecryptedGroup.Builder()
|
||||
.requestingMembers(List.of(requestingMember(member1, newProfileKey()), requestingMember(member2, profileKey2)))
|
||||
.build();
|
||||
|
||||
DecryptedGroupChange decryptedGroupChange = GroupChangeReconstruct.reconstructGroupChange(from, to);
|
||||
|
||||
assertEquals(new DecryptedGroupChange.Builder()
|
||||
.newRequestingMembers(List.of(requestingMember(member2, profileKey2)))
|
||||
.build(),
|
||||
decryptedGroupChange);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void removed_requesting_members() {
|
||||
UUID member1 = UUID.randomUUID();
|
||||
DecryptedGroup from = new DecryptedGroup.Builder()
|
||||
.requestingMembers(List.of(requestingMember(member1, newProfileKey())))
|
||||
.build();
|
||||
DecryptedGroup to = new DecryptedGroup.Builder()
|
||||
.build();
|
||||
|
||||
DecryptedGroupChange decryptedGroupChange = GroupChangeReconstruct.reconstructGroupChange(from, to);
|
||||
|
||||
assertEquals(new DecryptedGroupChange.Builder()
|
||||
.deleteRequestingMembers(List.of(UuidUtil.toByteString(member1)))
|
||||
.build(),
|
||||
decryptedGroupChange);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void promote_requesting_members() {
|
||||
UUID member1 = UUID.randomUUID();
|
||||
ProfileKey profileKey1 = newProfileKey();
|
||||
UUID member2 = UUID.randomUUID();
|
||||
ProfileKey profileKey2 = newProfileKey();
|
||||
DecryptedGroup from = new DecryptedGroup.Builder()
|
||||
.requestingMembers(List.of(requestingMember(member1, profileKey1)))
|
||||
.requestingMembers(List.of(requestingMember(member2, profileKey2)))
|
||||
.build();
|
||||
DecryptedGroup to = new DecryptedGroup.Builder()
|
||||
.members(List.of(member(member1, profileKey1)))
|
||||
.members(List.of(admin(member2, profileKey2)))
|
||||
.build();
|
||||
|
||||
DecryptedGroupChange decryptedGroupChange = GroupChangeReconstruct.reconstructGroupChange(from, to);
|
||||
|
||||
assertEquals(new DecryptedGroupChange.Builder()
|
||||
.promoteRequestingMembers(List.of(approveMember(member1)))
|
||||
.promoteRequestingMembers(List.of(approveAdmin(member2)))
|
||||
.build(),
|
||||
decryptedGroupChange);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void new_invite_link_password() {
|
||||
ByteString password1 = ByteString.of(Util.getSecretBytes(16));
|
||||
ByteString password2 = ByteString.of(Util.getSecretBytes(16));
|
||||
DecryptedGroup from = new DecryptedGroup.Builder()
|
||||
.inviteLinkPassword(password1)
|
||||
.build();
|
||||
DecryptedGroup to = new DecryptedGroup.Builder()
|
||||
.inviteLinkPassword(password2)
|
||||
.build();
|
||||
|
||||
DecryptedGroupChange decryptedGroupChange = GroupChangeReconstruct.reconstructGroupChange(from, to);
|
||||
|
||||
assertEquals(new DecryptedGroupChange.Builder()
|
||||
.newInviteLinkPassword(password2)
|
||||
.build(),
|
||||
decryptedGroupChange);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void new_banned_member() {
|
||||
UUID uuidNew = UUID.randomUUID();
|
||||
DecryptedGroup from = new DecryptedGroup.Builder().build();
|
||||
DecryptedGroup to = new DecryptedGroup.Builder().bannedMembers(List.of(bannedMember(uuidNew))).build();
|
||||
|
||||
DecryptedGroupChange decryptedGroupChange = GroupChangeReconstruct.reconstructGroupChange(from, to);
|
||||
|
||||
assertEquals(new DecryptedGroupChange.Builder().newBannedMembers(List.of(bannedMember(uuidNew))).build(), decryptedGroupChange);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void removed_banned_member() {
|
||||
UUID uuidOld = UUID.randomUUID();
|
||||
DecryptedGroup from = new DecryptedGroup.Builder().bannedMembers(List.of(bannedMember(uuidOld))).build();
|
||||
DecryptedGroup to = new DecryptedGroup.Builder().build();
|
||||
|
||||
DecryptedGroupChange decryptedGroupChange = GroupChangeReconstruct.reconstructGroupChange(from, to);
|
||||
|
||||
assertEquals(new DecryptedGroupChange.Builder().deleteBannedMembers(List.of(bannedMember(uuidOld))).build(), decryptedGroupChange);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
package org.whispersystems.signalservice.api.groupsv2;
|
||||
|
||||
import org.junit.Test;
|
||||
import org.signal.storageservice.protos.groups.GroupChange;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.whispersystems.signalservice.api.groupsv2.ProtobufTestUtils.getMaxDeclaredFieldNumber;
|
||||
|
||||
public final class GroupChangeUtil_changeIsEmpty_Test {
|
||||
|
||||
/**
|
||||
* Reflects over the generated protobuf class and ensures that no new fields have been added since we wrote this.
|
||||
* <p>
|
||||
* If we didn't, newly added fields would easily affect {@link GroupChangeUtil}'s ability to detect empty change states and resolve conflicts.
|
||||
*/
|
||||
@Test
|
||||
public void ensure_GroupChangeUtil_knows_about_all_fields_of_GroupChange_Actions() {
|
||||
int maxFieldFound = getMaxDeclaredFieldNumber(GroupChange.Actions.class);
|
||||
|
||||
assertEquals("GroupChangeUtil and its tests need updating to account for new fields on " + GroupChange.Actions.class.getName(),
|
||||
25, maxFieldFound);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void empty_change_set() {
|
||||
assertTrue(GroupChangeUtil.changeIsEmpty(new GroupChange.Actions.Builder().build()));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void not_empty_with_add_member_field_3() {
|
||||
GroupChange.Actions actions = new GroupChange.Actions.Builder()
|
||||
.addMembers(List.of(new GroupChange.Actions.AddMemberAction()))
|
||||
.build();
|
||||
|
||||
assertFalse(GroupChangeUtil.changeIsEmpty(actions));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void not_empty_with_delete_member_field_4() {
|
||||
GroupChange.Actions actions = new GroupChange.Actions.Builder()
|
||||
.deleteMembers(List.of(new GroupChange.Actions.DeleteMemberAction()))
|
||||
.build();
|
||||
|
||||
assertFalse(GroupChangeUtil.changeIsEmpty(actions));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void not_empty_with_modify_member_roles_field_5() {
|
||||
GroupChange.Actions actions = new GroupChange.Actions.Builder()
|
||||
.modifyMemberRoles(List.of(new GroupChange.Actions.ModifyMemberRoleAction()))
|
||||
.build();
|
||||
|
||||
assertFalse(GroupChangeUtil.changeIsEmpty(actions));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void not_empty_with_modify_profile_keys_field_6() {
|
||||
GroupChange.Actions actions = new GroupChange.Actions.Builder()
|
||||
.modifyMemberProfileKeys(List.of(new GroupChange.Actions.ModifyMemberProfileKeyAction()))
|
||||
.build();
|
||||
|
||||
assertFalse(GroupChangeUtil.changeIsEmpty(actions));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void not_empty_with_add_pending_members_field_7() {
|
||||
GroupChange.Actions actions = new GroupChange.Actions.Builder()
|
||||
.addPendingMembers(List.of(new GroupChange.Actions.AddPendingMemberAction()))
|
||||
.build();
|
||||
|
||||
assertFalse(GroupChangeUtil.changeIsEmpty(actions));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void not_empty_with_delete_pending_members_field_8() {
|
||||
GroupChange.Actions actions = new GroupChange.Actions.Builder()
|
||||
.deletePendingMembers(List.of(new GroupChange.Actions.DeletePendingMemberAction()))
|
||||
.build();
|
||||
|
||||
assertFalse(GroupChangeUtil.changeIsEmpty(actions));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void not_empty_with_promote_delete_pending_members_field_9() {
|
||||
GroupChange.Actions actions = new GroupChange.Actions.Builder()
|
||||
.promotePendingMembers(List.of(new GroupChange.Actions.PromotePendingMemberAction()))
|
||||
.build();
|
||||
|
||||
assertFalse(GroupChangeUtil.changeIsEmpty(actions));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void not_empty_with_modify_title_field_10() {
|
||||
GroupChange.Actions actions = new GroupChange.Actions.Builder()
|
||||
.modifyTitle(new GroupChange.Actions.ModifyTitleAction())
|
||||
.build();
|
||||
|
||||
assertFalse(GroupChangeUtil.changeIsEmpty(actions));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void not_empty_with_modify_avatar_field_11() {
|
||||
GroupChange.Actions actions = new GroupChange.Actions.Builder()
|
||||
.modifyAvatar(new GroupChange.Actions.ModifyAvatarAction())
|
||||
.build();
|
||||
|
||||
assertFalse(GroupChangeUtil.changeIsEmpty(actions));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void not_empty_with_modify_disappearing_message_timer_field_12() {
|
||||
GroupChange.Actions actions = new GroupChange.Actions.Builder()
|
||||
.modifyDisappearingMessagesTimer(new GroupChange.Actions.ModifyDisappearingMessagesTimerAction())
|
||||
.build();
|
||||
|
||||
assertFalse(GroupChangeUtil.changeIsEmpty(actions));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void not_empty_with_modify_attributes_field_13() {
|
||||
GroupChange.Actions actions = new GroupChange.Actions.Builder()
|
||||
.modifyAttributesAccess(new GroupChange.Actions.ModifyAttributesAccessControlAction())
|
||||
.build();
|
||||
|
||||
assertFalse(GroupChangeUtil.changeIsEmpty(actions));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void not_empty_with_modify_member_access_field_14() {
|
||||
GroupChange.Actions actions = new GroupChange.Actions.Builder()
|
||||
.modifyMemberAccess(new GroupChange.Actions.ModifyMembersAccessControlAction())
|
||||
.build();
|
||||
|
||||
assertFalse(GroupChangeUtil.changeIsEmpty(actions));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void not_empty_with_modify_add_from_invite_link_field_15() {
|
||||
GroupChange.Actions actions = new GroupChange.Actions.Builder()
|
||||
.modifyAddFromInviteLinkAccess(new GroupChange.Actions.ModifyAddFromInviteLinkAccessControlAction())
|
||||
.build();
|
||||
|
||||
assertFalse(GroupChangeUtil.changeIsEmpty(actions));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void not_empty_with_add_requesting_members_field_16() {
|
||||
GroupChange.Actions actions = new GroupChange.Actions.Builder()
|
||||
.addRequestingMembers(List.of(new GroupChange.Actions.AddRequestingMemberAction()))
|
||||
.build();
|
||||
|
||||
assertFalse(GroupChangeUtil.changeIsEmpty(actions));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void not_empty_with_delete_requesting_members_field_17() {
|
||||
GroupChange.Actions actions = new GroupChange.Actions.Builder()
|
||||
.deleteRequestingMembers(List.of(new GroupChange.Actions.DeleteRequestingMemberAction()))
|
||||
.build();
|
||||
|
||||
assertFalse(GroupChangeUtil.changeIsEmpty(actions));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void not_empty_with_promote_requesting_members_field_18() {
|
||||
GroupChange.Actions actions = new GroupChange.Actions.Builder()
|
||||
.promoteRequestingMembers(List.of(new GroupChange.Actions.PromoteRequestingMemberAction()))
|
||||
.build();
|
||||
|
||||
assertFalse(GroupChangeUtil.changeIsEmpty(actions));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void not_empty_with_promote_requesting_members_field_19() {
|
||||
GroupChange.Actions actions = new GroupChange.Actions.Builder()
|
||||
.modifyInviteLinkPassword(new GroupChange.Actions.ModifyInviteLinkPasswordAction())
|
||||
.build();
|
||||
|
||||
assertFalse(GroupChangeUtil.changeIsEmpty(actions));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void not_empty_with_modify_description_field_20() {
|
||||
GroupChange.Actions actions = new GroupChange.Actions.Builder()
|
||||
.modifyDescription(new GroupChange.Actions.ModifyDescriptionAction())
|
||||
.build();
|
||||
|
||||
assertFalse(GroupChangeUtil.changeIsEmpty(actions));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void not_empty_with_modify_description_field_21() {
|
||||
GroupChange.Actions actions = new GroupChange.Actions.Builder()
|
||||
.modifyAnnouncementsOnly(new GroupChange.Actions.ModifyAnnouncementsOnlyAction())
|
||||
.build();
|
||||
|
||||
assertFalse(GroupChangeUtil.changeIsEmpty(actions));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void not_empty_with_add_banned_member_field_22() {
|
||||
GroupChange.Actions actions = new GroupChange.Actions.Builder()
|
||||
.addBannedMembers(List.of(new GroupChange.Actions.AddBannedMemberAction()))
|
||||
.build();
|
||||
|
||||
assertFalse(GroupChangeUtil.changeIsEmpty(actions));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void not_empty_with_delete_banned_member_field_23() {
|
||||
GroupChange.Actions actions = new GroupChange.Actions.Builder()
|
||||
.deleteBannedMembers(List.of(new GroupChange.Actions.DeleteBannedMemberAction()))
|
||||
.build();
|
||||
|
||||
assertFalse(GroupChangeUtil.changeIsEmpty(actions));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void not_empty_with_promote_pending_pni_aci_members_field_24() {
|
||||
GroupChange.Actions actions = new GroupChange.Actions.Builder()
|
||||
.promotePendingPniAciMembers(List.of(new GroupChange.Actions.PromotePendingPniAciMemberProfileKeyAction()))
|
||||
.build();
|
||||
|
||||
assertFalse(GroupChangeUtil.changeIsEmpty(actions));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,857 @@
|
||||
package org.whispersystems.signalservice.api.groupsv2;
|
||||
|
||||
import org.junit.Test;
|
||||
import org.signal.libsignal.zkgroup.profiles.ProfileKey;
|
||||
import org.signal.storageservice.protos.groups.AccessControl;
|
||||
import org.signal.storageservice.protos.groups.BannedMember;
|
||||
import org.signal.storageservice.protos.groups.GroupChange;
|
||||
import org.signal.storageservice.protos.groups.Member;
|
||||
import org.signal.storageservice.protos.groups.PendingMember;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedGroup;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedGroupChange;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedMember;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedString;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedTimer;
|
||||
import org.signal.storageservice.protos.groups.local.EnabledState;
|
||||
import org.signal.core.util.UuidUtil;
|
||||
import org.whispersystems.signalservice.internal.util.Util;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import okio.ByteString;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.whispersystems.signalservice.api.groupsv2.ProtoTestUtils.admin;
|
||||
import static org.whispersystems.signalservice.api.groupsv2.ProtoTestUtils.approveMember;
|
||||
import static org.whispersystems.signalservice.api.groupsv2.ProtoTestUtils.bannedMember;
|
||||
import static org.whispersystems.signalservice.api.groupsv2.ProtoTestUtils.demoteAdmin;
|
||||
import static org.whispersystems.signalservice.api.groupsv2.ProtoTestUtils.encrypt;
|
||||
import static org.whispersystems.signalservice.api.groupsv2.ProtoTestUtils.encryptedMember;
|
||||
import static org.whispersystems.signalservice.api.groupsv2.ProtoTestUtils.encryptedRequestingMember;
|
||||
import static org.whispersystems.signalservice.api.groupsv2.ProtoTestUtils.member;
|
||||
import static org.whispersystems.signalservice.api.groupsv2.ProtoTestUtils.pendingMember;
|
||||
import static org.whispersystems.signalservice.api.groupsv2.ProtoTestUtils.pendingMemberRemoval;
|
||||
import static org.whispersystems.signalservice.api.groupsv2.ProtoTestUtils.pendingPniAciMember;
|
||||
import static org.whispersystems.signalservice.api.groupsv2.ProtoTestUtils.presentation;
|
||||
import static org.whispersystems.signalservice.api.groupsv2.ProtoTestUtils.promoteAdmin;
|
||||
import static org.whispersystems.signalservice.api.groupsv2.ProtoTestUtils.randomProfileKey;
|
||||
import static org.whispersystems.signalservice.api.groupsv2.ProtoTestUtils.requestingMember;
|
||||
import static org.whispersystems.signalservice.api.groupsv2.ProtobufTestUtils.getMaxDeclaredFieldNumber;
|
||||
|
||||
public final class GroupChangeUtil_resolveConflict_Test {
|
||||
|
||||
/**
|
||||
* Reflects over the generated protobuf class and ensures that no new fields have been added since we wrote this.
|
||||
* <p>
|
||||
* If we didn't, newly added fields would not be resolved by {@link GroupChangeUtil#resolveConflict(DecryptedGroup, DecryptedGroupChange, GroupChange.Actions)}.
|
||||
*/
|
||||
@Test
|
||||
public void ensure_resolveConflict_knows_about_all_fields_of_DecryptedGroupChange() {
|
||||
int maxFieldFound = getMaxDeclaredFieldNumber(DecryptedGroupChange.class);
|
||||
|
||||
assertEquals("GroupChangeUtil#resolveConflict and its tests need updating to account for new fields on " + DecryptedGroupChange.class.getName(),
|
||||
24, maxFieldFound);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reflects over the generated protobuf class and ensures that no new fields have been added since we wrote this.
|
||||
* <p>
|
||||
* If we didn't, newly added fields would not be resolved by {@link GroupChangeUtil#resolveConflict(DecryptedGroup, DecryptedGroupChange, GroupChange.Actions)}.
|
||||
*/
|
||||
@Test
|
||||
public void ensure_resolveConflict_knows_about_all_fields_of_GroupChange() {
|
||||
int maxFieldFound = getMaxDeclaredFieldNumber(DecryptedGroupChange.class);
|
||||
|
||||
assertEquals("GroupChangeUtil#resolveConflict and its tests need updating to account for new fields on " + GroupChange.class.getName(),
|
||||
24, maxFieldFound);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reflects over the generated protobuf class and ensures that no new fields have been added since we wrote this.
|
||||
* <p>
|
||||
* If we didn't, newly added fields would not be resolved by {@link GroupChangeUtil#resolveConflict(DecryptedGroup, DecryptedGroupChange, GroupChange.Actions)}.
|
||||
*/
|
||||
@Test
|
||||
public void ensure_resolveConflict_knows_about_all_fields_of_DecryptedGroup() {
|
||||
int maxFieldFound = getMaxDeclaredFieldNumber(DecryptedGroup.class, ProtobufTestUtils.IGNORED_DECRYPTED_GROUP_TAGS);
|
||||
|
||||
assertEquals("GroupChangeUtil#resolveConflict and its tests need updating to account for new fields on " + DecryptedGroup.class.getName(),
|
||||
13, maxFieldFound);
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
public void empty_actions() {
|
||||
GroupChange.Actions resolvedActions = GroupChangeUtil.resolveConflict(new DecryptedGroup.Builder().build(),
|
||||
new DecryptedGroupChange.Builder().build(),
|
||||
new GroupChange.Actions.Builder().build())
|
||||
.build();
|
||||
|
||||
assertTrue(GroupChangeUtil.changeIsEmpty(resolvedActions));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void field_3__changes_to_add_existing_members_are_excluded() {
|
||||
UUID member1 = UUID.randomUUID();
|
||||
UUID member2 = UUID.randomUUID();
|
||||
UUID member3 = UUID.randomUUID();
|
||||
ProfileKey profileKey2 = randomProfileKey();
|
||||
DecryptedGroup groupState = new DecryptedGroup.Builder().members(List.of(member(member1), member(member3))).build();
|
||||
DecryptedGroupChange decryptedChange = new DecryptedGroupChange.Builder().newMembers(List.of(member(member1), member(member2), member(member3))).build();
|
||||
|
||||
GroupChange.Actions change = new GroupChange.Actions.Builder()
|
||||
.addMembers(List.of(new GroupChange.Actions.AddMemberAction.Builder().added(encryptedMember(member1, randomProfileKey())).build(),
|
||||
new GroupChange.Actions.AddMemberAction.Builder().added(encryptedMember(member2, profileKey2)).build(),
|
||||
new GroupChange.Actions.AddMemberAction.Builder().added(encryptedMember(member3, randomProfileKey())).build()))
|
||||
.build();
|
||||
|
||||
GroupChange.Actions resolvedActions = GroupChangeUtil.resolveConflict(groupState, decryptedChange, change).build();
|
||||
|
||||
GroupChange.Actions expected = new GroupChange.Actions.Builder()
|
||||
.addMembers(List.of(new GroupChange.Actions.AddMemberAction.Builder().added(encryptedMember(member2, profileKey2)).build()))
|
||||
.build();
|
||||
assertEquals(expected, resolvedActions);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void field_4__changes_to_remove_missing_members_are_excluded() {
|
||||
UUID member1 = UUID.randomUUID();
|
||||
UUID member2 = UUID.randomUUID();
|
||||
UUID member3 = UUID.randomUUID();
|
||||
|
||||
DecryptedGroup groupState = new DecryptedGroup.Builder()
|
||||
.members(List.of(member(member2)))
|
||||
.build();
|
||||
|
||||
DecryptedGroupChange decryptedChange = new DecryptedGroupChange.Builder()
|
||||
.deleteMembers(List.of(UuidUtil.toByteString(member1), UuidUtil.toByteString(member2), UuidUtil.toByteString(member3)))
|
||||
.build();
|
||||
|
||||
GroupChange.Actions change = new GroupChange.Actions.Builder()
|
||||
.deleteMembers(List.of(new GroupChange.Actions.DeleteMemberAction.Builder().deletedUserId(encrypt(member1)).build(),
|
||||
new GroupChange.Actions.DeleteMemberAction.Builder().deletedUserId(encrypt(member2)).build(),
|
||||
new GroupChange.Actions.DeleteMemberAction.Builder().deletedUserId(encrypt(member3)).build()))
|
||||
.build();
|
||||
|
||||
GroupChange.Actions resolvedActions = GroupChangeUtil.resolveConflict(groupState, decryptedChange, change).build();
|
||||
|
||||
GroupChange.Actions expected = new GroupChange.Actions.Builder()
|
||||
.deleteMembers(List.of(new GroupChange.Actions.DeleteMemberAction.Builder().deletedUserId(encrypt(member2)).build()))
|
||||
.build();
|
||||
assertEquals(expected, resolvedActions);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void field_5__role_change_is_preserved() {
|
||||
UUID member1 = UUID.randomUUID();
|
||||
UUID member2 = UUID.randomUUID();
|
||||
UUID member3 = UUID.randomUUID();
|
||||
|
||||
DecryptedGroup groupState = new DecryptedGroup.Builder()
|
||||
.members(List.of(admin(member1), member(member2), member(member3)))
|
||||
.build();
|
||||
|
||||
DecryptedGroupChange decryptedChange = new DecryptedGroupChange.Builder()
|
||||
.modifyMemberRoles(List.of(demoteAdmin(member1), promoteAdmin(member2)))
|
||||
.build();
|
||||
|
||||
GroupChange.Actions change = new GroupChange.Actions.Builder()
|
||||
.modifyMemberRoles(List.of(new GroupChange.Actions.ModifyMemberRoleAction.Builder().userId(encrypt(member1)).role(Member.Role.DEFAULT).build(),
|
||||
new GroupChange.Actions.ModifyMemberRoleAction.Builder().userId(encrypt(member2)).role(Member.Role.ADMINISTRATOR).build()))
|
||||
.build();
|
||||
|
||||
GroupChange.Actions resolvedActions = GroupChangeUtil.resolveConflict(groupState, decryptedChange, change).build();
|
||||
|
||||
assertEquals(change, resolvedActions);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void field_5__unnecessary_role_changes_removed() {
|
||||
UUID member1 = UUID.randomUUID();
|
||||
UUID member2 = UUID.randomUUID();
|
||||
UUID member3 = UUID.randomUUID();
|
||||
UUID memberNotInGroup = UUID.randomUUID();
|
||||
|
||||
DecryptedGroup groupState = new DecryptedGroup.Builder()
|
||||
.members(List.of(admin(member1), member(member2), member(member3)))
|
||||
.build();
|
||||
|
||||
DecryptedGroupChange decryptedChange = new DecryptedGroupChange.Builder()
|
||||
.modifyMemberRoles(List.of(promoteAdmin(member1), promoteAdmin(member2), demoteAdmin(member3), promoteAdmin(memberNotInGroup)))
|
||||
.build();
|
||||
|
||||
GroupChange.Actions change = new GroupChange.Actions.Builder()
|
||||
.modifyMemberRoles(List.of(new GroupChange.Actions.ModifyMemberRoleAction.Builder().userId(encrypt(member1)).role(Member.Role.ADMINISTRATOR).build(),
|
||||
new GroupChange.Actions.ModifyMemberRoleAction.Builder().userId(encrypt(member2)).role(Member.Role.ADMINISTRATOR).build(),
|
||||
new GroupChange.Actions.ModifyMemberRoleAction.Builder().userId(encrypt(member3)).role(Member.Role.DEFAULT).build(),
|
||||
new GroupChange.Actions.ModifyMemberRoleAction.Builder().userId(encrypt(memberNotInGroup)).role(Member.Role.ADMINISTRATOR).build()))
|
||||
.build();
|
||||
|
||||
GroupChange.Actions resolvedActions = GroupChangeUtil.resolveConflict(groupState, decryptedChange, change).build();
|
||||
|
||||
GroupChange.Actions expected = new GroupChange.Actions.Builder()
|
||||
.modifyMemberRoles(List.of(new GroupChange.Actions.ModifyMemberRoleAction.Builder().userId(encrypt(member2)).role(Member.Role.ADMINISTRATOR).build()))
|
||||
.build();
|
||||
assertEquals(expected, resolvedActions);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void field_6__profile_key_changes() {
|
||||
UUID member1 = UUID.randomUUID();
|
||||
UUID member2 = UUID.randomUUID();
|
||||
UUID member3 = UUID.randomUUID();
|
||||
UUID memberNotInGroup = UUID.randomUUID();
|
||||
ProfileKey profileKey1 = randomProfileKey();
|
||||
ProfileKey profileKey2 = randomProfileKey();
|
||||
ProfileKey profileKey3 = randomProfileKey();
|
||||
ProfileKey profileKey4 = randomProfileKey();
|
||||
ProfileKey profileKey2b = randomProfileKey();
|
||||
|
||||
DecryptedGroup groupState = new DecryptedGroup.Builder()
|
||||
.members(List.of(member(member1, profileKey1), member(member2, profileKey2), member(member3, profileKey3)))
|
||||
.build();
|
||||
|
||||
DecryptedGroupChange decryptedChange = new DecryptedGroupChange.Builder()
|
||||
.modifiedProfileKeys(List.of(member(member1, profileKey1), member(member2, profileKey2b), member(member3, profileKey3), member(memberNotInGroup, profileKey4)))
|
||||
.build();
|
||||
|
||||
GroupChange.Actions change = new GroupChange.Actions.Builder()
|
||||
.modifyMemberProfileKeys(List.of(new GroupChange.Actions.ModifyMemberProfileKeyAction.Builder().presentation(presentation(member1, profileKey1)).build(),
|
||||
new GroupChange.Actions.ModifyMemberProfileKeyAction.Builder().presentation(presentation(member2, profileKey2b)).build(),
|
||||
new GroupChange.Actions.ModifyMemberProfileKeyAction.Builder().presentation(presentation(member3, profileKey3)).build(),
|
||||
new GroupChange.Actions.ModifyMemberProfileKeyAction.Builder().presentation(presentation(memberNotInGroup, profileKey4)).build()))
|
||||
.build();
|
||||
|
||||
GroupChange.Actions resolvedActions = GroupChangeUtil.resolveConflict(groupState, decryptedChange, change).build();
|
||||
|
||||
GroupChange.Actions expected = new GroupChange.Actions.Builder()
|
||||
.modifyMemberProfileKeys(List.of(new GroupChange.Actions.ModifyMemberProfileKeyAction.Builder().presentation(presentation(member2, profileKey2b)).build()))
|
||||
.build();
|
||||
|
||||
assertEquals(expected, resolvedActions);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void field_7__add_pending_members() {
|
||||
UUID member1 = UUID.randomUUID();
|
||||
UUID member2 = UUID.randomUUID();
|
||||
UUID member3 = UUID.randomUUID();
|
||||
ProfileKey profileKey2 = randomProfileKey();
|
||||
|
||||
DecryptedGroup groupState = new DecryptedGroup.Builder()
|
||||
.members(List.of(member(member1)))
|
||||
.pendingMembers(List.of(pendingMember(member3)))
|
||||
.build();
|
||||
|
||||
DecryptedGroupChange decryptedChange = new DecryptedGroupChange.Builder()
|
||||
.newPendingMembers(List.of(pendingMember(member1), pendingMember(member2), pendingMember(member3)))
|
||||
.build();
|
||||
|
||||
GroupChange.Actions change = new GroupChange.Actions.Builder()
|
||||
.addPendingMembers(List.of(new GroupChange.Actions.AddPendingMemberAction.Builder().added(new PendingMember.Builder().member(encryptedMember(member1, randomProfileKey())).build()).build(),
|
||||
new GroupChange.Actions.AddPendingMemberAction.Builder().added(new PendingMember.Builder().member(encryptedMember(member2, profileKey2)).build()).build(),
|
||||
new GroupChange.Actions.AddPendingMemberAction.Builder().added(new PendingMember.Builder().member(encryptedMember(member3, randomProfileKey())).build()).build()))
|
||||
.build();
|
||||
|
||||
GroupChange.Actions resolvedActions = GroupChangeUtil.resolveConflict(groupState, decryptedChange, change).build();
|
||||
|
||||
GroupChange.Actions expected = new GroupChange.Actions.Builder()
|
||||
.addPendingMembers(List.of(new GroupChange.Actions.AddPendingMemberAction.Builder().added(new PendingMember.Builder().member(encryptedMember(member2, profileKey2)).build()).build()))
|
||||
.build();
|
||||
|
||||
assertEquals(expected, resolvedActions);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void field_8__delete_pending_members() {
|
||||
UUID member1 = UUID.randomUUID();
|
||||
UUID member2 = UUID.randomUUID();
|
||||
UUID member3 = UUID.randomUUID();
|
||||
|
||||
DecryptedGroup groupState = new DecryptedGroup.Builder()
|
||||
.members(List.of(member(member1)))
|
||||
.pendingMembers(List.of(pendingMember(member2)))
|
||||
.build();
|
||||
|
||||
DecryptedGroupChange decryptedChange = new DecryptedGroupChange.Builder()
|
||||
.deletePendingMembers(List.of(pendingMemberRemoval(member1), pendingMemberRemoval(member2), pendingMemberRemoval(member3)))
|
||||
.build();
|
||||
|
||||
GroupChange.Actions change = new GroupChange.Actions.Builder()
|
||||
.deletePendingMembers(List.of(new GroupChange.Actions.DeletePendingMemberAction.Builder().deletedUserId(encrypt(member1)).build(),
|
||||
new GroupChange.Actions.DeletePendingMemberAction.Builder().deletedUserId(encrypt(member2)).build(),
|
||||
new GroupChange.Actions.DeletePendingMemberAction.Builder().deletedUserId(encrypt(member3)).build()))
|
||||
.build();
|
||||
|
||||
GroupChange.Actions resolvedActions = GroupChangeUtil.resolveConflict(groupState, decryptedChange, change).build();
|
||||
|
||||
GroupChange.Actions expected = new GroupChange.Actions.Builder()
|
||||
.deletePendingMembers(List.of(new GroupChange.Actions.DeletePendingMemberAction.Builder().deletedUserId(encrypt(member2)).build()))
|
||||
.build();
|
||||
|
||||
assertEquals(expected, resolvedActions);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void field_9__promote_pending_members() {
|
||||
UUID member1 = UUID.randomUUID();
|
||||
UUID member2 = UUID.randomUUID();
|
||||
UUID member3 = UUID.randomUUID();
|
||||
ProfileKey profileKey2 = randomProfileKey();
|
||||
|
||||
DecryptedGroup groupState = new DecryptedGroup.Builder()
|
||||
.members(List.of(member(member1)))
|
||||
.pendingMembers(List.of(pendingMember(member2)))
|
||||
.build();
|
||||
|
||||
DecryptedGroupChange decryptedChange = new DecryptedGroupChange.Builder()
|
||||
.promotePendingMembers(List.of(member(member1), member(member2), member(member3)))
|
||||
.build();
|
||||
|
||||
GroupChange.Actions change = new GroupChange.Actions.Builder()
|
||||
.promotePendingMembers(List.of(new GroupChange.Actions.PromotePendingMemberAction.Builder().presentation(presentation(member1, randomProfileKey())).build(),
|
||||
new GroupChange.Actions.PromotePendingMemberAction.Builder().presentation(presentation(member2, profileKey2)).build(),
|
||||
new GroupChange.Actions.PromotePendingMemberAction.Builder().presentation(presentation(member3, randomProfileKey())).build()))
|
||||
.build();
|
||||
|
||||
GroupChange.Actions resolvedActions = GroupChangeUtil.resolveConflict(groupState, decryptedChange, change).build();
|
||||
|
||||
|
||||
GroupChange.Actions expected = new GroupChange.Actions.Builder()
|
||||
.promotePendingMembers(List.of(new GroupChange.Actions.PromotePendingMemberAction.Builder().presentation(presentation(member2, profileKey2)).build()))
|
||||
.build();
|
||||
|
||||
assertEquals(expected, resolvedActions);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void field_3_to_9__add_of_pending_member_converted_to_a_promote() {
|
||||
UUID member1 = UUID.randomUUID();
|
||||
ProfileKey profileKey1 = randomProfileKey();
|
||||
|
||||
DecryptedGroup groupState = new DecryptedGroup.Builder()
|
||||
.pendingMembers(List.of(pendingMember(member1)))
|
||||
.build();
|
||||
|
||||
DecryptedGroupChange decryptedChange = new DecryptedGroupChange.Builder()
|
||||
.newMembers(List.of(member(member1)))
|
||||
.build();
|
||||
|
||||
GroupChange.Actions change = new GroupChange.Actions.Builder()
|
||||
.addMembers(List.of(new GroupChange.Actions.AddMemberAction.Builder().added(encryptedMember(member1, profileKey1)).build()))
|
||||
.build();
|
||||
|
||||
GroupChange.Actions resolvedActions = GroupChangeUtil.resolveConflict(groupState, decryptedChange, change).build();
|
||||
|
||||
GroupChange.Actions expected = new GroupChange.Actions.Builder()
|
||||
.promotePendingMembers(List.of(new GroupChange.Actions.PromotePendingMemberAction.Builder().presentation(presentation(member1, profileKey1)).build()))
|
||||
.build();
|
||||
|
||||
assertEquals(expected, resolvedActions);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void field_10__title_change_is_preserved() {
|
||||
DecryptedGroup groupState = new DecryptedGroup.Builder()
|
||||
.title("Existing title")
|
||||
.build();
|
||||
|
||||
DecryptedGroupChange decryptedChange = new DecryptedGroupChange.Builder()
|
||||
.newTitle(new DecryptedString.Builder().value_("New title").build())
|
||||
.build();
|
||||
|
||||
GroupChange.Actions change = new GroupChange.Actions.Builder()
|
||||
.modifyTitle(new GroupChange.Actions.ModifyTitleAction.Builder().title(ByteString.of("New title encrypted".getBytes())).build())
|
||||
.build();
|
||||
|
||||
GroupChange.Actions resolvedActions = GroupChangeUtil.resolveConflict(groupState, decryptedChange, change).build();
|
||||
|
||||
assertEquals(change, resolvedActions);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void field_10__no_title_change_is_removed() {
|
||||
DecryptedGroup groupState = new DecryptedGroup.Builder()
|
||||
.title("Existing title")
|
||||
.build();
|
||||
|
||||
DecryptedGroupChange decryptedChange = new DecryptedGroupChange.Builder()
|
||||
.newTitle(new DecryptedString.Builder().value_("Existing title").build())
|
||||
.build();
|
||||
|
||||
GroupChange.Actions change = new GroupChange.Actions.Builder()
|
||||
.modifyTitle(new GroupChange.Actions.ModifyTitleAction.Builder().title(ByteString.of("Existing title encrypted".getBytes())).build())
|
||||
.build();
|
||||
|
||||
GroupChange.Actions resolvedActions = GroupChangeUtil.resolveConflict(groupState, decryptedChange, change).build();
|
||||
|
||||
assertTrue(GroupChangeUtil.changeIsEmpty(resolvedActions));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void field_11__avatar_change_is_preserved() {
|
||||
DecryptedGroup groupState = new DecryptedGroup.Builder()
|
||||
.avatar("Existing avatar")
|
||||
.build();
|
||||
|
||||
DecryptedGroupChange decryptedChange = new DecryptedGroupChange.Builder()
|
||||
.newAvatar(new DecryptedString.Builder().value_("New avatar").build())
|
||||
.build();
|
||||
|
||||
GroupChange.Actions change = new GroupChange.Actions.Builder()
|
||||
.modifyAvatar(new GroupChange.Actions.ModifyAvatarAction.Builder().avatar("New avatar possibly encrypted").build())
|
||||
.build();
|
||||
|
||||
GroupChange.Actions resolvedActions = GroupChangeUtil.resolveConflict(groupState, decryptedChange, change).build();
|
||||
|
||||
assertEquals(change, resolvedActions);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void field_11__no_avatar_change_is_removed() {
|
||||
DecryptedGroup groupState = new DecryptedGroup.Builder()
|
||||
.avatar("Existing avatar")
|
||||
.build();
|
||||
|
||||
DecryptedGroupChange decryptedChange = new DecryptedGroupChange.Builder()
|
||||
.newAvatar(new DecryptedString.Builder().value_("Existing avatar").build())
|
||||
.build();
|
||||
|
||||
GroupChange.Actions change = new GroupChange.Actions.Builder()
|
||||
.modifyAvatar(new GroupChange.Actions.ModifyAvatarAction.Builder().avatar("Existing avatar possibly encrypted").build())
|
||||
.build();
|
||||
|
||||
GroupChange.Actions resolvedActions = GroupChangeUtil.resolveConflict(groupState, decryptedChange, change).build();
|
||||
|
||||
assertTrue(GroupChangeUtil.changeIsEmpty(resolvedActions));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void field_12__timer_change_is_preserved() {
|
||||
DecryptedGroup groupState = new DecryptedGroup.Builder()
|
||||
.disappearingMessagesTimer(new DecryptedTimer.Builder().duration(123).build())
|
||||
.build();
|
||||
|
||||
DecryptedGroupChange decryptedChange = new DecryptedGroupChange.Builder()
|
||||
.newTimer(new DecryptedTimer.Builder().duration(456).build())
|
||||
.build();
|
||||
|
||||
GroupChange.Actions change = new GroupChange.Actions.Builder()
|
||||
.modifyDisappearingMessagesTimer(new GroupChange.Actions.ModifyDisappearingMessagesTimerAction.Builder().timer(ByteString.EMPTY).build())
|
||||
.build();
|
||||
|
||||
GroupChange.Actions resolvedActions = GroupChangeUtil.resolveConflict(groupState, decryptedChange, change).build();
|
||||
|
||||
assertEquals(change, resolvedActions);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void field_12__no_timer_change_is_removed() {
|
||||
DecryptedGroup groupState = new DecryptedGroup.Builder()
|
||||
.disappearingMessagesTimer(new DecryptedTimer.Builder().duration(123).build())
|
||||
.build();
|
||||
|
||||
DecryptedGroupChange decryptedChange = new DecryptedGroupChange.Builder()
|
||||
.newTimer(new DecryptedTimer.Builder().duration(123).build())
|
||||
.build();
|
||||
|
||||
GroupChange.Actions change = new GroupChange.Actions.Builder()
|
||||
.modifyDisappearingMessagesTimer(new GroupChange.Actions.ModifyDisappearingMessagesTimerAction.Builder().timer(ByteString.EMPTY).build())
|
||||
.build();
|
||||
|
||||
GroupChange.Actions resolvedActions = GroupChangeUtil.resolveConflict(groupState, decryptedChange, change).build();
|
||||
|
||||
assertTrue(GroupChangeUtil.changeIsEmpty(resolvedActions));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void field_13__attribute_access_change_is_preserved() {
|
||||
DecryptedGroup groupState = new DecryptedGroup.Builder()
|
||||
.accessControl(new AccessControl.Builder().attributes(AccessControl.AccessRequired.ADMINISTRATOR).build())
|
||||
.build();
|
||||
DecryptedGroupChange decryptedChange = new DecryptedGroupChange.Builder()
|
||||
.newAttributeAccess(AccessControl.AccessRequired.MEMBER)
|
||||
.build();
|
||||
GroupChange.Actions change = new GroupChange.Actions.Builder()
|
||||
.modifyAttributesAccess(new GroupChange.Actions.ModifyAttributesAccessControlAction.Builder().attributesAccess(AccessControl.AccessRequired.MEMBER).build())
|
||||
.build();
|
||||
|
||||
GroupChange.Actions resolvedActions = GroupChangeUtil.resolveConflict(groupState, decryptedChange, change).build();
|
||||
|
||||
assertEquals(change, resolvedActions);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void field_13__no_attribute_access_change_is_removed() {
|
||||
DecryptedGroup groupState = new DecryptedGroup.Builder()
|
||||
.accessControl(new AccessControl.Builder().attributes(AccessControl.AccessRequired.ADMINISTRATOR).build())
|
||||
.build();
|
||||
DecryptedGroupChange decryptedChange = new DecryptedGroupChange.Builder()
|
||||
.newAttributeAccess(AccessControl.AccessRequired.ADMINISTRATOR)
|
||||
.build();
|
||||
GroupChange.Actions change = new GroupChange.Actions.Builder()
|
||||
.modifyAttributesAccess(new GroupChange.Actions.ModifyAttributesAccessControlAction.Builder().attributesAccess(AccessControl.AccessRequired.ADMINISTRATOR).build())
|
||||
.build();
|
||||
|
||||
GroupChange.Actions resolvedActions = GroupChangeUtil.resolveConflict(groupState, decryptedChange, change).build();
|
||||
|
||||
assertTrue(GroupChangeUtil.changeIsEmpty(resolvedActions));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void field_14__membership_access_change_is_preserved() {
|
||||
DecryptedGroup groupState = new DecryptedGroup.Builder()
|
||||
.accessControl(new AccessControl.Builder().members(AccessControl.AccessRequired.ADMINISTRATOR).build())
|
||||
.build();
|
||||
DecryptedGroupChange decryptedChange = new DecryptedGroupChange.Builder()
|
||||
.newMemberAccess(AccessControl.AccessRequired.MEMBER)
|
||||
.build();
|
||||
GroupChange.Actions change = new GroupChange.Actions.Builder()
|
||||
.modifyMemberAccess(new GroupChange.Actions.ModifyMembersAccessControlAction.Builder().membersAccess(AccessControl.AccessRequired.MEMBER).build())
|
||||
.build();
|
||||
|
||||
GroupChange.Actions resolvedActions = GroupChangeUtil.resolveConflict(groupState, decryptedChange, change).build();
|
||||
|
||||
assertEquals(change, resolvedActions);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void field_14__no_membership_access_change_is_removed() {
|
||||
DecryptedGroup groupState = new DecryptedGroup.Builder()
|
||||
.accessControl(new AccessControl.Builder().members(AccessControl.AccessRequired.ADMINISTRATOR).build())
|
||||
.build();
|
||||
DecryptedGroupChange decryptedChange = new DecryptedGroupChange.Builder()
|
||||
.newMemberAccess(AccessControl.AccessRequired.ADMINISTRATOR)
|
||||
.build();
|
||||
GroupChange.Actions change = new GroupChange.Actions.Builder()
|
||||
.modifyMemberAccess(new GroupChange.Actions.ModifyMembersAccessControlAction.Builder().membersAccess(AccessControl.AccessRequired.ADMINISTRATOR).build())
|
||||
.build();
|
||||
|
||||
GroupChange.Actions resolvedActions = GroupChangeUtil.resolveConflict(groupState, decryptedChange, change).build();
|
||||
|
||||
assertTrue(GroupChangeUtil.changeIsEmpty(resolvedActions));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void field_15__no_membership_access_change_is_removed() {
|
||||
DecryptedGroup groupState = new DecryptedGroup.Builder()
|
||||
.accessControl(new AccessControl.Builder().addFromInviteLink(AccessControl.AccessRequired.ADMINISTRATOR).build())
|
||||
.build();
|
||||
DecryptedGroupChange decryptedChange = new DecryptedGroupChange.Builder()
|
||||
.newInviteLinkAccess(AccessControl.AccessRequired.ADMINISTRATOR)
|
||||
.build();
|
||||
GroupChange.Actions change = new GroupChange.Actions.Builder()
|
||||
.modifyAddFromInviteLinkAccess(new GroupChange.Actions.ModifyAddFromInviteLinkAccessControlAction.Builder().addFromInviteLinkAccess(AccessControl.AccessRequired.ADMINISTRATOR).build())
|
||||
.build();
|
||||
|
||||
GroupChange.Actions resolvedActions = GroupChangeUtil.resolveConflict(groupState, decryptedChange, change).build();
|
||||
|
||||
assertTrue(GroupChangeUtil.changeIsEmpty(resolvedActions));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void field_16__changes_to_add_requesting_members_when_full_members_are_removed() {
|
||||
UUID member1 = UUID.randomUUID();
|
||||
UUID member2 = UUID.randomUUID();
|
||||
UUID member3 = UUID.randomUUID();
|
||||
ProfileKey profileKey2 = randomProfileKey();
|
||||
|
||||
DecryptedGroup groupState = new DecryptedGroup.Builder()
|
||||
.members(List.of(member(member1), member(member3)))
|
||||
.build();
|
||||
|
||||
DecryptedGroupChange decryptedChange = new DecryptedGroupChange.Builder()
|
||||
.newRequestingMembers(List.of(requestingMember(member1), requestingMember(member2), requestingMember(member3)))
|
||||
.build();
|
||||
|
||||
GroupChange.Actions change = new GroupChange.Actions.Builder()
|
||||
.addRequestingMembers(List.of(new GroupChange.Actions.AddRequestingMemberAction.Builder().added(encryptedRequestingMember(member1, randomProfileKey())).build(),
|
||||
new GroupChange.Actions.AddRequestingMemberAction.Builder().added(encryptedRequestingMember(member2, profileKey2)).build(),
|
||||
new GroupChange.Actions.AddRequestingMemberAction.Builder().added(encryptedRequestingMember(member3, randomProfileKey())).build()))
|
||||
.build();
|
||||
|
||||
GroupChange.Actions resolvedActions = GroupChangeUtil.resolveConflict(groupState, decryptedChange, change).build();
|
||||
|
||||
GroupChange.Actions expected = new GroupChange.Actions.Builder()
|
||||
.addRequestingMembers(List.of(new GroupChange.Actions.AddRequestingMemberAction.Builder().added(encryptedRequestingMember(member2, profileKey2)).build()))
|
||||
.build();
|
||||
|
||||
assertEquals(expected, resolvedActions);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void field_16__changes_to_add_requesting_members_when_pending_are_promoted() {
|
||||
UUID member1 = UUID.randomUUID();
|
||||
UUID member2 = UUID.randomUUID();
|
||||
UUID member3 = UUID.randomUUID();
|
||||
ProfileKey profileKey1 = randomProfileKey();
|
||||
ProfileKey profileKey2 = randomProfileKey();
|
||||
ProfileKey profileKey3 = randomProfileKey();
|
||||
|
||||
DecryptedGroup groupState = new DecryptedGroup.Builder()
|
||||
.pendingMembers(List.of(pendingMember(member1), pendingMember(member3)))
|
||||
.build();
|
||||
|
||||
DecryptedGroupChange decryptedChange = new DecryptedGroupChange.Builder()
|
||||
.newRequestingMembers(List.of(requestingMember(member1, profileKey1), requestingMember(member2, profileKey2), requestingMember(member3, profileKey3)))
|
||||
.build();
|
||||
|
||||
GroupChange.Actions change = new GroupChange.Actions.Builder()
|
||||
.addRequestingMembers(List.of(new GroupChange.Actions.AddRequestingMemberAction.Builder().added(encryptedRequestingMember(member1, profileKey1)).build(),
|
||||
new GroupChange.Actions.AddRequestingMemberAction.Builder().added(encryptedRequestingMember(member2, profileKey2)).build(),
|
||||
new GroupChange.Actions.AddRequestingMemberAction.Builder().added(encryptedRequestingMember(member3, profileKey3)).build()))
|
||||
.build();
|
||||
|
||||
GroupChange.Actions resolvedActions = GroupChangeUtil.resolveConflict(groupState, decryptedChange, change).build();
|
||||
|
||||
GroupChange.Actions expected = new GroupChange.Actions.Builder()
|
||||
.promotePendingMembers(List.of(new GroupChange.Actions.PromotePendingMemberAction.Builder().presentation(presentation(member3, profileKey3)).build(),
|
||||
new GroupChange.Actions.PromotePendingMemberAction.Builder().presentation(presentation(member1, profileKey1)).build()))
|
||||
.addRequestingMembers(List.of(new GroupChange.Actions.AddRequestingMemberAction.Builder().added(encryptedRequestingMember(member2, profileKey2)).build()))
|
||||
.build();
|
||||
|
||||
assertEquals(expected, resolvedActions);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void field_17__changes_to_remove_missing_requesting_members_are_excluded() {
|
||||
UUID member1 = UUID.randomUUID();
|
||||
UUID member2 = UUID.randomUUID();
|
||||
UUID member3 = UUID.randomUUID();
|
||||
|
||||
DecryptedGroup groupState = new DecryptedGroup.Builder()
|
||||
.requestingMembers(List.of(requestingMember(member2)))
|
||||
.build();
|
||||
|
||||
DecryptedGroupChange decryptedChange = new DecryptedGroupChange.Builder()
|
||||
.deleteRequestingMembers(List.of(UuidUtil.toByteString(member1), UuidUtil.toByteString(member2), UuidUtil.toByteString(member3)))
|
||||
.build();
|
||||
|
||||
GroupChange.Actions change = new GroupChange.Actions.Builder()
|
||||
.deleteRequestingMembers(List.of(new GroupChange.Actions.DeleteRequestingMemberAction.Builder().deletedUserId(encrypt(member1)).build(),
|
||||
new GroupChange.Actions.DeleteRequestingMemberAction.Builder().deletedUserId(encrypt(member2)).build(),
|
||||
new GroupChange.Actions.DeleteRequestingMemberAction.Builder().deletedUserId(encrypt(member3)).build()))
|
||||
.build();
|
||||
|
||||
GroupChange.Actions resolvedActions = GroupChangeUtil.resolveConflict(groupState, decryptedChange, change).build();
|
||||
|
||||
GroupChange.Actions expected = new GroupChange.Actions.Builder()
|
||||
.deleteRequestingMembers(List.of(new GroupChange.Actions.DeleteRequestingMemberAction.Builder().deletedUserId(encrypt(member2)).build()))
|
||||
.build();
|
||||
|
||||
assertEquals(expected, resolvedActions);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void field_18__promote_requesting_members() {
|
||||
UUID member1 = UUID.randomUUID();
|
||||
UUID member2 = UUID.randomUUID();
|
||||
UUID member3 = UUID.randomUUID();
|
||||
|
||||
DecryptedGroup groupState = new DecryptedGroup.Builder()
|
||||
.members(List.of(member(member1)))
|
||||
.requestingMembers(List.of(requestingMember(member2)))
|
||||
.build();
|
||||
|
||||
DecryptedGroupChange decryptedChange = new DecryptedGroupChange.Builder()
|
||||
.promoteRequestingMembers(List.of(approveMember(member1), approveMember(member2), approveMember(member3)))
|
||||
.build();
|
||||
|
||||
GroupChange.Actions change = new GroupChange.Actions.Builder()
|
||||
.promoteRequestingMembers(List.of(new GroupChange.Actions.PromoteRequestingMemberAction.Builder().role(Member.Role.DEFAULT).userId(UuidUtil.toByteString(member1)).build(),
|
||||
new GroupChange.Actions.PromoteRequestingMemberAction.Builder().role(Member.Role.DEFAULT).userId(UuidUtil.toByteString(member2)).build(),
|
||||
new GroupChange.Actions.PromoteRequestingMemberAction.Builder().role(Member.Role.DEFAULT).userId(UuidUtil.toByteString(member3)).build()))
|
||||
.build();
|
||||
|
||||
GroupChange.Actions resolvedActions = GroupChangeUtil.resolveConflict(groupState, decryptedChange, change).build();
|
||||
|
||||
GroupChange.Actions expected = new GroupChange.Actions.Builder()
|
||||
.promoteRequestingMembers(List.of(new GroupChange.Actions.PromoteRequestingMemberAction.Builder().role(Member.Role.DEFAULT).userId(UuidUtil.toByteString(member2)).build()))
|
||||
.build();
|
||||
|
||||
assertEquals(expected, resolvedActions);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void field_19__password_change_is_kept() {
|
||||
ByteString password1 = ByteString.of(Util.getSecretBytes(16));
|
||||
ByteString password2 = ByteString.of(Util.getSecretBytes(16));
|
||||
DecryptedGroup groupState = new DecryptedGroup.Builder()
|
||||
.inviteLinkPassword(password1)
|
||||
.build();
|
||||
DecryptedGroupChange decryptedChange = new DecryptedGroupChange.Builder()
|
||||
.newInviteLinkPassword(password2)
|
||||
.build();
|
||||
GroupChange.Actions change = new GroupChange.Actions.Builder()
|
||||
.modifyInviteLinkPassword(new GroupChange.Actions.ModifyInviteLinkPasswordAction.Builder().inviteLinkPassword(password2).build())
|
||||
.build();
|
||||
|
||||
GroupChange.Actions resolvedActions = GroupChangeUtil.resolveConflict(groupState, decryptedChange, change).build();
|
||||
|
||||
GroupChange.Actions expected = new GroupChange.Actions.Builder()
|
||||
.modifyInviteLinkPassword(new GroupChange.Actions.ModifyInviteLinkPasswordAction.Builder().inviteLinkPassword(password2).build())
|
||||
.build();
|
||||
assertEquals(expected, resolvedActions);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void field_20__description_change_is_preserved() {
|
||||
DecryptedGroup groupState = new DecryptedGroup.Builder()
|
||||
.description("Existing title")
|
||||
.build();
|
||||
|
||||
DecryptedGroupChange decryptedChange = new DecryptedGroupChange.Builder()
|
||||
.newDescription(new DecryptedString.Builder().value_("New title").build())
|
||||
.build();
|
||||
|
||||
GroupChange.Actions change = new GroupChange.Actions.Builder()
|
||||
.modifyDescription(new GroupChange.Actions.ModifyDescriptionAction.Builder().description(ByteString.of("New title encrypted".getBytes())).build())
|
||||
.build();
|
||||
|
||||
GroupChange.Actions resolvedActions = GroupChangeUtil.resolveConflict(groupState, decryptedChange, change).build();
|
||||
|
||||
assertEquals(change, resolvedActions);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void field_20__no_description_change_is_removed() {
|
||||
DecryptedGroup groupState = new DecryptedGroup.Builder()
|
||||
.description("Existing title")
|
||||
.build();
|
||||
DecryptedGroupChange decryptedChange = new DecryptedGroupChange.Builder()
|
||||
.newDescription(new DecryptedString.Builder().value_("Existing title").build())
|
||||
.build();
|
||||
GroupChange.Actions change = new GroupChange.Actions.Builder()
|
||||
.modifyDescription(new GroupChange.Actions.ModifyDescriptionAction.Builder().description(ByteString.of("Existing title encrypted".getBytes())).build())
|
||||
.build();
|
||||
|
||||
GroupChange.Actions resolvedActions = GroupChangeUtil.resolveConflict(groupState, decryptedChange, change).build();
|
||||
|
||||
assertTrue(GroupChangeUtil.changeIsEmpty(resolvedActions));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void field_21__announcement_change_is_preserved() {
|
||||
DecryptedGroup groupState = new DecryptedGroup.Builder()
|
||||
.isAnnouncementGroup(EnabledState.DISABLED)
|
||||
.build();
|
||||
DecryptedGroupChange decryptedChange = new DecryptedGroupChange.Builder()
|
||||
.newIsAnnouncementGroup(EnabledState.ENABLED)
|
||||
.build();
|
||||
GroupChange.Actions change = new GroupChange.Actions.Builder()
|
||||
.modifyAnnouncementsOnly(new GroupChange.Actions.ModifyAnnouncementsOnlyAction.Builder().announcementsOnly(true).build())
|
||||
.build();
|
||||
|
||||
GroupChange.Actions resolvedActions = GroupChangeUtil.resolveConflict(groupState, decryptedChange, change).build();
|
||||
|
||||
assertEquals(change, resolvedActions);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void field_21__announcement_change_is_removed() {
|
||||
DecryptedGroup groupState = new DecryptedGroup.Builder()
|
||||
.isAnnouncementGroup(EnabledState.ENABLED)
|
||||
.build();
|
||||
DecryptedGroupChange decryptedChange = new DecryptedGroupChange.Builder()
|
||||
.newIsAnnouncementGroup(EnabledState.ENABLED)
|
||||
.build();
|
||||
GroupChange.Actions change = new GroupChange.Actions.Builder()
|
||||
.modifyAnnouncementsOnly(new GroupChange.Actions.ModifyAnnouncementsOnlyAction.Builder().announcementsOnly(true).build())
|
||||
.build();
|
||||
|
||||
GroupChange.Actions resolvedActions = GroupChangeUtil.resolveConflict(groupState, decryptedChange, change).build();
|
||||
|
||||
assertTrue(GroupChangeUtil.changeIsEmpty(resolvedActions));
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
public void field_22__add_banned_members() {
|
||||
UUID member1 = UUID.randomUUID();
|
||||
UUID member2 = UUID.randomUUID();
|
||||
UUID member3 = UUID.randomUUID();
|
||||
|
||||
DecryptedGroup groupState = new DecryptedGroup.Builder()
|
||||
.members(List.of(member(member1)))
|
||||
.bannedMembers(List.of(bannedMember(member3)))
|
||||
.build();
|
||||
|
||||
DecryptedGroupChange decryptedChange = new DecryptedGroupChange.Builder()
|
||||
.newBannedMembers(List.of(bannedMember(member1), bannedMember(member2), bannedMember(member3)))
|
||||
.build();
|
||||
|
||||
GroupChange.Actions change = new GroupChange.Actions.Builder()
|
||||
.addBannedMembers(List.of(new GroupChange.Actions.AddBannedMemberAction.Builder().added(new BannedMember.Builder().userId(encrypt(member1)).build()).build(),
|
||||
new GroupChange.Actions.AddBannedMemberAction.Builder().added(new BannedMember.Builder().userId(encrypt(member2)).build()).build(),
|
||||
new GroupChange.Actions.AddBannedMemberAction.Builder().added(new BannedMember.Builder().userId(encrypt(member3)).build()).build()))
|
||||
.build();
|
||||
|
||||
GroupChange.Actions resolvedActions = GroupChangeUtil.resolveConflict(groupState, decryptedChange, change).build();
|
||||
|
||||
GroupChange.Actions expected = new GroupChange.Actions.Builder()
|
||||
.addBannedMembers(List.of(new GroupChange.Actions.AddBannedMemberAction.Builder().added(new BannedMember.Builder().userId(encrypt(member1)).build()).build(),
|
||||
new GroupChange.Actions.AddBannedMemberAction.Builder().added(new BannedMember.Builder().userId(encrypt(member2)).build()).build()))
|
||||
.build();
|
||||
|
||||
assertEquals(expected, resolvedActions);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void field_23__delete_banned_members() {
|
||||
UUID member1 = UUID.randomUUID();
|
||||
UUID member2 = UUID.randomUUID();
|
||||
UUID member3 = UUID.randomUUID();
|
||||
|
||||
DecryptedGroup groupState = new DecryptedGroup.Builder()
|
||||
.members(List.of(member(member1)))
|
||||
.bannedMembers(List.of(bannedMember(member2)))
|
||||
.build();
|
||||
|
||||
DecryptedGroupChange decryptedChange = new DecryptedGroupChange.Builder()
|
||||
.deleteBannedMembers(List.of(bannedMember(member1), bannedMember(member2), bannedMember(member3)))
|
||||
.build();
|
||||
|
||||
GroupChange.Actions change = new GroupChange.Actions.Builder()
|
||||
.deleteBannedMembers(List.of(new GroupChange.Actions.DeleteBannedMemberAction.Builder().deletedUserId(encrypt(member1)).build(),
|
||||
new GroupChange.Actions.DeleteBannedMemberAction.Builder().deletedUserId(encrypt(member2)).build(),
|
||||
new GroupChange.Actions.DeleteBannedMemberAction.Builder().deletedUserId(encrypt(member3)).build()))
|
||||
.build();
|
||||
|
||||
GroupChange.Actions resolvedActions = GroupChangeUtil.resolveConflict(groupState, decryptedChange, change).build();
|
||||
|
||||
GroupChange.Actions expected = new GroupChange.Actions.Builder()
|
||||
.deleteBannedMembers(List.of(new GroupChange.Actions.DeleteBannedMemberAction.Builder().deletedUserId(encrypt(member2)).build()))
|
||||
.build();
|
||||
|
||||
assertEquals(expected, resolvedActions);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void field_24__promote_pending_members() {
|
||||
DecryptedMember member1 = pendingPniAciMember(UUID.randomUUID(), UUID.randomUUID(), randomProfileKey());
|
||||
DecryptedMember member2 = pendingPniAciMember(UUID.randomUUID(), UUID.randomUUID(), randomProfileKey());
|
||||
|
||||
DecryptedGroup groupState = new DecryptedGroup.Builder()
|
||||
.members(List.of(member(UuidUtil.fromByteString(member1.aciBytes))))
|
||||
.build();
|
||||
|
||||
DecryptedGroupChange decryptedChange = new DecryptedGroupChange.Builder()
|
||||
.promotePendingPniAciMembers(List.of(pendingPniAciMember(member1.aciBytes, member1.pniBytes, member1.profileKey),
|
||||
pendingPniAciMember(member2.aciBytes, member2.pniBytes, member2.profileKey)))
|
||||
.build();
|
||||
|
||||
GroupChange.Actions change = new GroupChange.Actions.Builder()
|
||||
.promotePendingPniAciMembers(List.of(new GroupChange.Actions.PromotePendingPniAciMemberProfileKeyAction.Builder().presentation(presentation(member1.pniBytes, member1.profileKey)).build(),
|
||||
new GroupChange.Actions.PromotePendingPniAciMemberProfileKeyAction.Builder().presentation(presentation(member2.pniBytes, member2.profileKey)).build()))
|
||||
.build();
|
||||
|
||||
GroupChange.Actions resolvedActions = GroupChangeUtil.resolveConflict(groupState, decryptedChange, change).build();
|
||||
|
||||
GroupChange.Actions expected = new GroupChange.Actions.Builder()
|
||||
.promotePendingPniAciMembers(List.of(new GroupChange.Actions.PromotePendingPniAciMemberProfileKeyAction.Builder().presentation(presentation(member2.pniBytes, member2.profileKey)).build()))
|
||||
.build();
|
||||
assertEquals(expected, resolvedActions);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,676 @@
|
||||
package org.whispersystems.signalservice.api.groupsv2;
|
||||
|
||||
import org.junit.Test;
|
||||
import org.signal.libsignal.zkgroup.profiles.ProfileKey;
|
||||
import org.signal.storageservice.protos.groups.AccessControl;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedGroup;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedGroupChange;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedMember;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedString;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedTimer;
|
||||
import org.signal.storageservice.protos.groups.local.EnabledState;
|
||||
import org.signal.core.util.UuidUtil;
|
||||
import org.whispersystems.signalservice.internal.util.Util;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import okio.ByteString;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.whispersystems.signalservice.api.groupsv2.ProtoTestUtils.admin;
|
||||
import static org.whispersystems.signalservice.api.groupsv2.ProtoTestUtils.approveMember;
|
||||
import static org.whispersystems.signalservice.api.groupsv2.ProtoTestUtils.bannedMember;
|
||||
import static org.whispersystems.signalservice.api.groupsv2.ProtoTestUtils.demoteAdmin;
|
||||
import static org.whispersystems.signalservice.api.groupsv2.ProtoTestUtils.member;
|
||||
import static org.whispersystems.signalservice.api.groupsv2.ProtoTestUtils.pendingMember;
|
||||
import static org.whispersystems.signalservice.api.groupsv2.ProtoTestUtils.pendingMemberRemoval;
|
||||
import static org.whispersystems.signalservice.api.groupsv2.ProtoTestUtils.pendingPniAciMember;
|
||||
import static org.whispersystems.signalservice.api.groupsv2.ProtoTestUtils.promoteAdmin;
|
||||
import static org.whispersystems.signalservice.api.groupsv2.ProtoTestUtils.randomProfileKey;
|
||||
import static org.whispersystems.signalservice.api.groupsv2.ProtoTestUtils.requestingMember;
|
||||
import static org.whispersystems.signalservice.api.groupsv2.ProtobufTestUtils.getMaxDeclaredFieldNumber;
|
||||
|
||||
public final class GroupChangeUtil_resolveConflict_decryptedOnly_Test {
|
||||
|
||||
/**
|
||||
* Reflects over the generated protobuf class and ensures that no new fields have been added since we wrote this.
|
||||
* <p>
|
||||
* If we didn't, newly added fields would not be resolved by {@link GroupChangeUtil#resolveConflict(DecryptedGroup, DecryptedGroupChange)}.
|
||||
*/
|
||||
@Test
|
||||
public void ensure_resolveConflict_knows_about_all_fields_of_DecryptedGroupChange() {
|
||||
int maxFieldFound = getMaxDeclaredFieldNumber(DecryptedGroupChange.class);
|
||||
|
||||
assertEquals("GroupChangeUtil#resolveConflict and its tests need updating to account for new fields on " + DecryptedGroupChange.class.getName(),
|
||||
24, maxFieldFound);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reflects over the generated protobuf class and ensures that no new fields have been added since we wrote this.
|
||||
* <p>
|
||||
* If we didn't, newly added fields would not be resolved by {@link GroupChangeUtil#resolveConflict(DecryptedGroup, DecryptedGroupChange)}.
|
||||
*/
|
||||
@Test
|
||||
public void ensure_resolveConflict_knows_about_all_fields_of_DecryptedGroup() {
|
||||
int maxFieldFound = getMaxDeclaredFieldNumber(DecryptedGroup.class, ProtobufTestUtils.IGNORED_DECRYPTED_GROUP_TAGS);
|
||||
|
||||
assertEquals("GroupChangeUtil#resolveConflict and its tests need updating to account for new fields on " + DecryptedGroup.class.getName(),
|
||||
13, maxFieldFound);
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
public void field_3__changes_to_add_existing_members_are_excluded() {
|
||||
UUID member1 = UUID.randomUUID();
|
||||
UUID member2 = UUID.randomUUID();
|
||||
UUID member3 = UUID.randomUUID();
|
||||
|
||||
DecryptedGroup groupState = new DecryptedGroup.Builder()
|
||||
.members(List.of(member(member1), member(member3)))
|
||||
.build();
|
||||
|
||||
DecryptedGroupChange decryptedChange = new DecryptedGroupChange.Builder()
|
||||
.newMembers(List.of(member(member1), member(member2), member(member3)))
|
||||
.build();
|
||||
|
||||
DecryptedGroupChange resolvedChanges = GroupChangeUtil.resolveConflict(groupState, decryptedChange).build();
|
||||
|
||||
DecryptedGroupChange expected = new DecryptedGroupChange.Builder()
|
||||
.newMembers(List.of(member(member2)))
|
||||
.build();
|
||||
|
||||
assertEquals(expected, resolvedChanges);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void field_4__changes_to_remove_missing_members_are_excluded() {
|
||||
UUID member1 = UUID.randomUUID();
|
||||
UUID member2 = UUID.randomUUID();
|
||||
UUID member3 = UUID.randomUUID();
|
||||
|
||||
DecryptedGroup groupState = new DecryptedGroup.Builder()
|
||||
.members(List.of(member(member2)))
|
||||
.build();
|
||||
|
||||
DecryptedGroupChange decryptedChange = new DecryptedGroupChange.Builder()
|
||||
.deleteMembers(List.of(UuidUtil.toByteString(member1), UuidUtil.toByteString(member2), UuidUtil.toByteString(member3)))
|
||||
.build();
|
||||
|
||||
DecryptedGroupChange resolvedChanges = GroupChangeUtil.resolveConflict(groupState, decryptedChange).build();
|
||||
|
||||
DecryptedGroupChange expected = new DecryptedGroupChange.Builder()
|
||||
.deleteMembers(List.of(UuidUtil.toByteString(member2)))
|
||||
.build();
|
||||
|
||||
assertEquals(expected, resolvedChanges);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void field_5__role_change_is_preserved() {
|
||||
UUID member1 = UUID.randomUUID();
|
||||
UUID member2 = UUID.randomUUID();
|
||||
UUID member3 = UUID.randomUUID();
|
||||
|
||||
DecryptedGroup groupState = new DecryptedGroup.Builder()
|
||||
.members(List.of(admin(member1), member(member2), member(member3)))
|
||||
.build();
|
||||
|
||||
DecryptedGroupChange decryptedChange = new DecryptedGroupChange.Builder()
|
||||
.modifyMemberRoles(List.of(demoteAdmin(member1), promoteAdmin(member2)))
|
||||
.build();
|
||||
|
||||
DecryptedGroupChange resolvedChanges = GroupChangeUtil.resolveConflict(groupState, decryptedChange).build();
|
||||
|
||||
assertEquals(decryptedChange, resolvedChanges);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void field_5__unnecessary_role_changes_removed() {
|
||||
UUID member1 = UUID.randomUUID();
|
||||
UUID member2 = UUID.randomUUID();
|
||||
UUID member3 = UUID.randomUUID();
|
||||
UUID memberNotInGroup = UUID.randomUUID();
|
||||
|
||||
DecryptedGroup groupState = new DecryptedGroup.Builder()
|
||||
.members(List.of(admin(member1), member(member2), member(member3)))
|
||||
.build();
|
||||
|
||||
DecryptedGroupChange decryptedChange = new DecryptedGroupChange.Builder()
|
||||
.modifyMemberRoles(List.of(promoteAdmin(member1), promoteAdmin(member2), demoteAdmin(member3), promoteAdmin(memberNotInGroup)))
|
||||
.build();
|
||||
|
||||
DecryptedGroupChange resolvedChanges = GroupChangeUtil.resolveConflict(groupState, decryptedChange).build();
|
||||
|
||||
DecryptedGroupChange expected = new DecryptedGroupChange.Builder()
|
||||
.modifyMemberRoles(List.of(promoteAdmin(member2)))
|
||||
.build();
|
||||
|
||||
assertEquals(expected, resolvedChanges);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void field_6__profile_key_changes() {
|
||||
UUID member1 = UUID.randomUUID();
|
||||
UUID member2 = UUID.randomUUID();
|
||||
UUID member3 = UUID.randomUUID();
|
||||
UUID memberNotInGroup = UUID.randomUUID();
|
||||
ProfileKey profileKey1 = randomProfileKey();
|
||||
ProfileKey profileKey2 = randomProfileKey();
|
||||
ProfileKey profileKey3 = randomProfileKey();
|
||||
ProfileKey profileKey4 = randomProfileKey();
|
||||
ProfileKey profileKey2b = randomProfileKey();
|
||||
|
||||
DecryptedGroup groupState = new DecryptedGroup.Builder()
|
||||
.members(List.of(member(member1, profileKey1), member(member2, profileKey2), member(member3, profileKey3)))
|
||||
.build();
|
||||
|
||||
DecryptedGroupChange decryptedChange = new DecryptedGroupChange.Builder()
|
||||
.modifiedProfileKeys(List.of(member(member1, profileKey1), member(member2, profileKey2b), member(member3, profileKey3), member(memberNotInGroup, profileKey4)))
|
||||
.build();
|
||||
|
||||
DecryptedGroupChange resolvedChanges = GroupChangeUtil.resolveConflict(groupState, decryptedChange).build();
|
||||
|
||||
DecryptedGroupChange expected = new DecryptedGroupChange.Builder()
|
||||
.modifiedProfileKeys(List.of(member(member2, profileKey2b)))
|
||||
.build();
|
||||
|
||||
assertEquals(expected, resolvedChanges);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void field_7__add_pending_members() {
|
||||
UUID member1 = UUID.randomUUID();
|
||||
UUID member2 = UUID.randomUUID();
|
||||
UUID member3 = UUID.randomUUID();
|
||||
|
||||
DecryptedGroup groupState = new DecryptedGroup.Builder()
|
||||
.members(List.of(member(member1)))
|
||||
.pendingMembers(List.of(pendingMember(member3)))
|
||||
.build();
|
||||
|
||||
DecryptedGroupChange decryptedChange = new DecryptedGroupChange.Builder()
|
||||
.newPendingMembers(List.of(pendingMember(member1), pendingMember(member2), pendingMember(member3)))
|
||||
.build();
|
||||
|
||||
DecryptedGroupChange resolvedChanges = GroupChangeUtil.resolveConflict(groupState, decryptedChange).build();
|
||||
|
||||
DecryptedGroupChange expected = new DecryptedGroupChange.Builder()
|
||||
.newPendingMembers(List.of(pendingMember(member2)))
|
||||
.build();
|
||||
|
||||
assertEquals(expected, resolvedChanges);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void field_8__delete_pending_members() {
|
||||
UUID member1 = UUID.randomUUID();
|
||||
UUID member2 = UUID.randomUUID();
|
||||
UUID member3 = UUID.randomUUID();
|
||||
|
||||
DecryptedGroup groupState = new DecryptedGroup.Builder()
|
||||
.members(List.of(member(member1)))
|
||||
.pendingMembers(List.of(pendingMember(member2)))
|
||||
.build();
|
||||
|
||||
DecryptedGroupChange decryptedChange = new DecryptedGroupChange.Builder()
|
||||
.deletePendingMembers(List.of(pendingMemberRemoval(member1), pendingMemberRemoval(member2), pendingMemberRemoval(member3)))
|
||||
.build();
|
||||
|
||||
DecryptedGroupChange resolvedChanges = GroupChangeUtil.resolveConflict(groupState, decryptedChange).build();
|
||||
|
||||
DecryptedGroupChange expected = new DecryptedGroupChange.Builder()
|
||||
.deletePendingMembers(List.of(pendingMemberRemoval(member2)))
|
||||
.build();
|
||||
|
||||
assertEquals(expected, resolvedChanges);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void field_9__promote_pending_members() {
|
||||
UUID member1 = UUID.randomUUID();
|
||||
UUID member2 = UUID.randomUUID();
|
||||
UUID member3 = UUID.randomUUID();
|
||||
ProfileKey profileKey2 = randomProfileKey();
|
||||
|
||||
DecryptedGroup groupState = new DecryptedGroup.Builder()
|
||||
.members(List.of(member(member1)))
|
||||
.pendingMembers(List.of(pendingMember(member2)))
|
||||
.build();
|
||||
|
||||
DecryptedGroupChange decryptedChange = new DecryptedGroupChange.Builder()
|
||||
.promotePendingMembers(List.of(member(member1), member(member2, profileKey2), member(member3)))
|
||||
.build();
|
||||
|
||||
DecryptedGroupChange resolvedChanges = GroupChangeUtil.resolveConflict(groupState, decryptedChange).build();
|
||||
|
||||
DecryptedGroupChange expected = new DecryptedGroupChange.Builder()
|
||||
.promotePendingMembers(List.of(member(member2, profileKey2)))
|
||||
.build();
|
||||
|
||||
assertEquals(expected, resolvedChanges);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void field_3_to_9__add_of_pending_member_converted_to_a_promote() {
|
||||
UUID member1 = UUID.randomUUID();
|
||||
|
||||
DecryptedGroup groupState = new DecryptedGroup.Builder()
|
||||
.pendingMembers(List.of(pendingMember(member1)))
|
||||
.build();
|
||||
|
||||
DecryptedGroupChange decryptedChange = new DecryptedGroupChange.Builder()
|
||||
.newMembers(List.of(member(member1)))
|
||||
.build();
|
||||
|
||||
DecryptedGroupChange resolvedChanges = GroupChangeUtil.resolveConflict(groupState, decryptedChange).build();
|
||||
|
||||
DecryptedGroupChange expected = new DecryptedGroupChange.Builder()
|
||||
.promotePendingMembers(List.of(member(member1)))
|
||||
.build();
|
||||
|
||||
assertEquals(expected, resolvedChanges);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void field_10__title_change_is_preserved() {
|
||||
DecryptedGroup groupState = new DecryptedGroup.Builder()
|
||||
.title("Existing title")
|
||||
.build();
|
||||
|
||||
DecryptedGroupChange decryptedChange = new DecryptedGroupChange.Builder()
|
||||
.newTitle(new DecryptedString.Builder().value_("New title").build())
|
||||
.build();
|
||||
|
||||
DecryptedGroupChange resolvedChanges = GroupChangeUtil.resolveConflict(groupState, decryptedChange).build();
|
||||
|
||||
assertEquals(decryptedChange, resolvedChanges);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void field_10__no_title_change_is_removed() {
|
||||
DecryptedGroup groupState = new DecryptedGroup.Builder()
|
||||
.title("Existing title")
|
||||
.build();
|
||||
DecryptedGroupChange decryptedChange = new DecryptedGroupChange.Builder()
|
||||
.newTitle(new DecryptedString.Builder().value_("Existing title").build())
|
||||
.build();
|
||||
|
||||
DecryptedGroupChange resolvedChanges = GroupChangeUtil.resolveConflict(groupState, decryptedChange).build();
|
||||
|
||||
assertTrue(DecryptedGroupUtil.changeIsEmpty(resolvedChanges));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void field_11__avatar_change_is_preserved() {
|
||||
DecryptedGroup groupState = new DecryptedGroup.Builder()
|
||||
.avatar("Existing avatar")
|
||||
.build();
|
||||
DecryptedGroupChange decryptedChange = new DecryptedGroupChange.Builder()
|
||||
.newAvatar(new DecryptedString.Builder().value_("New avatar").build())
|
||||
.build();
|
||||
|
||||
DecryptedGroupChange resolvedChanges = GroupChangeUtil.resolveConflict(groupState, decryptedChange).build();
|
||||
|
||||
assertEquals(decryptedChange, resolvedChanges);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void field_11__no_avatar_change_is_removed() {
|
||||
DecryptedGroup groupState = new DecryptedGroup.Builder()
|
||||
.avatar("Existing avatar")
|
||||
.build();
|
||||
DecryptedGroupChange decryptedChange = new DecryptedGroupChange.Builder()
|
||||
.newAvatar(new DecryptedString.Builder().value_("Existing avatar").build())
|
||||
.build();
|
||||
|
||||
DecryptedGroupChange resolvedChanges = GroupChangeUtil.resolveConflict(groupState, decryptedChange).build();
|
||||
|
||||
assertTrue(DecryptedGroupUtil.changeIsEmpty(resolvedChanges));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void field_12__timer_change_is_preserved() {
|
||||
DecryptedGroup groupState = new DecryptedGroup.Builder()
|
||||
.disappearingMessagesTimer(new DecryptedTimer.Builder().duration(123).build())
|
||||
.build();
|
||||
DecryptedGroupChange decryptedChange = new DecryptedGroupChange.Builder()
|
||||
.newTimer(new DecryptedTimer.Builder().duration(456).build())
|
||||
.build();
|
||||
|
||||
DecryptedGroupChange resolvedChanges = GroupChangeUtil.resolveConflict(groupState, decryptedChange).build();
|
||||
|
||||
assertEquals(decryptedChange, resolvedChanges);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void field_12__no_timer_change_is_removed() {
|
||||
DecryptedGroup groupState = new DecryptedGroup.Builder()
|
||||
.disappearingMessagesTimer(new DecryptedTimer.Builder().duration(123).build())
|
||||
.build();
|
||||
DecryptedGroupChange decryptedChange = new DecryptedGroupChange.Builder()
|
||||
.newTimer(new DecryptedTimer.Builder().duration(123).build())
|
||||
.build();
|
||||
|
||||
DecryptedGroupChange resolvedChanges = GroupChangeUtil.resolveConflict(groupState, decryptedChange).build();
|
||||
|
||||
assertTrue(DecryptedGroupUtil.changeIsEmpty(resolvedChanges));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void field_13__attribute_access_change_is_preserved() {
|
||||
DecryptedGroup groupState = new DecryptedGroup.Builder()
|
||||
.accessControl(new AccessControl.Builder().attributes(AccessControl.AccessRequired.ADMINISTRATOR).build())
|
||||
.build();
|
||||
DecryptedGroupChange decryptedChange = new DecryptedGroupChange.Builder()
|
||||
.newAttributeAccess(AccessControl.AccessRequired.MEMBER)
|
||||
.build();
|
||||
|
||||
DecryptedGroupChange resolvedChanges = GroupChangeUtil.resolveConflict(groupState, decryptedChange).build();
|
||||
|
||||
assertEquals(decryptedChange, resolvedChanges);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void field_13__no_attribute_access_change_is_removed() {
|
||||
DecryptedGroup groupState = new DecryptedGroup.Builder()
|
||||
.accessControl(new AccessControl.Builder().attributes(AccessControl.AccessRequired.ADMINISTRATOR).build())
|
||||
.build();
|
||||
DecryptedGroupChange decryptedChange = new DecryptedGroupChange.Builder()
|
||||
.newAttributeAccess(AccessControl.AccessRequired.ADMINISTRATOR)
|
||||
.build();
|
||||
|
||||
DecryptedGroupChange resolvedChanges = GroupChangeUtil.resolveConflict(groupState, decryptedChange).build();
|
||||
|
||||
assertTrue(DecryptedGroupUtil.changeIsEmpty(resolvedChanges));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void field_14__membership_access_change_is_preserved() {
|
||||
DecryptedGroup groupState = new DecryptedGroup.Builder()
|
||||
.accessControl(new AccessControl.Builder().members(AccessControl.AccessRequired.ADMINISTRATOR).build())
|
||||
.build();
|
||||
DecryptedGroupChange decryptedChange = new DecryptedGroupChange.Builder()
|
||||
.newMemberAccess(AccessControl.AccessRequired.MEMBER)
|
||||
.build();
|
||||
|
||||
DecryptedGroupChange resolvedChanges = GroupChangeUtil.resolveConflict(groupState, decryptedChange).build();
|
||||
|
||||
assertEquals(decryptedChange, resolvedChanges);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void field_14__no_membership_access_change_is_removed() {
|
||||
DecryptedGroup groupState = new DecryptedGroup.Builder()
|
||||
.accessControl(new AccessControl.Builder().members(AccessControl.AccessRequired.ADMINISTRATOR).build())
|
||||
.build();
|
||||
DecryptedGroupChange decryptedChange = new DecryptedGroupChange.Builder()
|
||||
.newMemberAccess(AccessControl.AccessRequired.ADMINISTRATOR)
|
||||
.build();
|
||||
|
||||
DecryptedGroupChange resolvedChanges = GroupChangeUtil.resolveConflict(groupState, decryptedChange).build();
|
||||
|
||||
assertTrue(DecryptedGroupUtil.changeIsEmpty(resolvedChanges));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void field_15__no_membership_access_change_is_removed() {
|
||||
DecryptedGroup groupState = new DecryptedGroup.Builder()
|
||||
.accessControl(new AccessControl.Builder().addFromInviteLink(AccessControl.AccessRequired.ADMINISTRATOR).build())
|
||||
.build();
|
||||
DecryptedGroupChange decryptedChange = new DecryptedGroupChange.Builder()
|
||||
.newInviteLinkAccess(AccessControl.AccessRequired.ADMINISTRATOR)
|
||||
.build();
|
||||
|
||||
DecryptedGroupChange resolvedChanges = GroupChangeUtil.resolveConflict(groupState, decryptedChange).build();
|
||||
|
||||
assertTrue(DecryptedGroupUtil.changeIsEmpty(resolvedChanges));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void field_16__changes_to_add_requesting_members_when_full_members_are_removed() {
|
||||
UUID member1 = UUID.randomUUID();
|
||||
UUID member2 = UUID.randomUUID();
|
||||
UUID member3 = UUID.randomUUID();
|
||||
ProfileKey profileKey2 = randomProfileKey();
|
||||
|
||||
DecryptedGroup groupState = new DecryptedGroup.Builder()
|
||||
.members(List.of(member(member1), member(member3)))
|
||||
.build();
|
||||
|
||||
DecryptedGroupChange decryptedChange = new DecryptedGroupChange.Builder()
|
||||
.newRequestingMembers(List.of(requestingMember(member1), requestingMember(member2, profileKey2), requestingMember(member3)))
|
||||
.build();
|
||||
|
||||
DecryptedGroupChange resolvedChanges = GroupChangeUtil.resolveConflict(groupState, decryptedChange).build();
|
||||
|
||||
DecryptedGroupChange expected = new DecryptedGroupChange.Builder()
|
||||
.newRequestingMembers(List.of(requestingMember(member2, profileKey2)))
|
||||
.build();
|
||||
|
||||
assertEquals(expected, resolvedChanges);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void field_16__changes_to_add_requesting_members_when_pending_are_promoted() {
|
||||
UUID member1 = UUID.randomUUID();
|
||||
UUID member2 = UUID.randomUUID();
|
||||
UUID member3 = UUID.randomUUID();
|
||||
ProfileKey profileKey1 = randomProfileKey();
|
||||
ProfileKey profileKey2 = randomProfileKey();
|
||||
ProfileKey profileKey3 = randomProfileKey();
|
||||
|
||||
DecryptedGroup groupState = new DecryptedGroup.Builder()
|
||||
.pendingMembers(List.of(pendingMember(member1), pendingMember(member3)))
|
||||
.build();
|
||||
|
||||
DecryptedGroupChange decryptedChange = new DecryptedGroupChange.Builder()
|
||||
.newRequestingMembers(List.of(requestingMember(member1, profileKey1), requestingMember(member2, profileKey2), requestingMember(member3, profileKey3)))
|
||||
.build();
|
||||
|
||||
DecryptedGroupChange resolvedChanges = GroupChangeUtil.resolveConflict(groupState, decryptedChange).build();
|
||||
|
||||
DecryptedGroupChange expected = new DecryptedGroupChange.Builder()
|
||||
.promotePendingMembers(List.of(member(member3, profileKey3), member(member1, profileKey1)))
|
||||
.newRequestingMembers(List.of(requestingMember(member2, profileKey2)))
|
||||
.build();
|
||||
|
||||
assertEquals(expected, resolvedChanges);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void field_17__changes_to_remove_missing_requesting_members_are_excluded() {
|
||||
UUID member1 = UUID.randomUUID();
|
||||
UUID member2 = UUID.randomUUID();
|
||||
UUID member3 = UUID.randomUUID();
|
||||
|
||||
DecryptedGroup groupState = new DecryptedGroup.Builder()
|
||||
.requestingMembers(List.of(requestingMember(member2)))
|
||||
.build();
|
||||
|
||||
DecryptedGroupChange decryptedChange = new DecryptedGroupChange.Builder()
|
||||
.deleteRequestingMembers(List.of(UuidUtil.toByteString(member1), UuidUtil.toByteString(member2), UuidUtil.toByteString(member3)))
|
||||
.build();
|
||||
|
||||
DecryptedGroupChange resolvedChanges = GroupChangeUtil.resolveConflict(groupState, decryptedChange).build();
|
||||
|
||||
DecryptedGroupChange expected = new DecryptedGroupChange.Builder()
|
||||
.deleteRequestingMembers(List.of(UuidUtil.toByteString(member2)))
|
||||
.build();
|
||||
|
||||
assertEquals(expected, resolvedChanges);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void field_18__promote_requesting_members() {
|
||||
UUID member1 = UUID.randomUUID();
|
||||
UUID member2 = UUID.randomUUID();
|
||||
UUID member3 = UUID.randomUUID();
|
||||
|
||||
DecryptedGroup groupState = new DecryptedGroup.Builder()
|
||||
.members(List.of(member(member1)))
|
||||
.requestingMembers(List.of(requestingMember(member2)))
|
||||
.build();
|
||||
|
||||
DecryptedGroupChange decryptedChange = new DecryptedGroupChange.Builder()
|
||||
.promoteRequestingMembers(List.of(approveMember(member1), approveMember(member2), approveMember(member3)))
|
||||
.build();
|
||||
|
||||
DecryptedGroupChange resolvedChanges = GroupChangeUtil.resolveConflict(groupState, decryptedChange).build();
|
||||
|
||||
DecryptedGroupChange expected = new DecryptedGroupChange.Builder()
|
||||
.promoteRequestingMembers(List.of(approveMember(member2)))
|
||||
.build();
|
||||
|
||||
assertEquals(expected, resolvedChanges);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void field_19__password_change_is_kept() {
|
||||
ByteString password1 = ByteString.of(Util.getSecretBytes(16));
|
||||
ByteString password2 = ByteString.of(Util.getSecretBytes(16));
|
||||
|
||||
DecryptedGroup groupState = new DecryptedGroup.Builder()
|
||||
.inviteLinkPassword(password1)
|
||||
.build();
|
||||
|
||||
DecryptedGroupChange decryptedChange = new DecryptedGroupChange.Builder()
|
||||
.newInviteLinkPassword(password2)
|
||||
.build();
|
||||
|
||||
DecryptedGroupChange resolvedChanges = GroupChangeUtil.resolveConflict(groupState, decryptedChange).build();
|
||||
|
||||
assertEquals(decryptedChange, resolvedChanges);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void field_20__description_change_is_preserved() {
|
||||
DecryptedGroup groupState = new DecryptedGroup.Builder()
|
||||
.description("Existing description")
|
||||
.build();
|
||||
|
||||
DecryptedGroupChange decryptedChange = new DecryptedGroupChange.Builder()
|
||||
.newDescription(new DecryptedString.Builder().value_("New description").build())
|
||||
.build();
|
||||
|
||||
DecryptedGroupChange resolvedChanges = GroupChangeUtil.resolveConflict(groupState, decryptedChange).build();
|
||||
|
||||
assertEquals(decryptedChange, resolvedChanges);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void field_20__no_description_change_is_removed() {
|
||||
DecryptedGroup groupState = new DecryptedGroup.Builder()
|
||||
.description("Existing description")
|
||||
.build();
|
||||
|
||||
DecryptedGroupChange decryptedChange = new DecryptedGroupChange.Builder()
|
||||
.newDescription(new DecryptedString.Builder().value_("Existing description").build())
|
||||
.build();
|
||||
|
||||
DecryptedGroupChange resolvedChanges = GroupChangeUtil.resolveConflict(groupState, decryptedChange).build();
|
||||
|
||||
assertTrue(DecryptedGroupUtil.changeIsEmpty(resolvedChanges));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void field_21__announcement_change_is_preserved() {
|
||||
DecryptedGroup groupState = new DecryptedGroup.Builder()
|
||||
.isAnnouncementGroup(EnabledState.DISABLED)
|
||||
.build();
|
||||
|
||||
DecryptedGroupChange decryptedChange = new DecryptedGroupChange.Builder()
|
||||
.newIsAnnouncementGroup(EnabledState.ENABLED)
|
||||
.build();
|
||||
|
||||
DecryptedGroupChange resolvedChanges = GroupChangeUtil.resolveConflict(groupState, decryptedChange).build();
|
||||
|
||||
assertEquals(decryptedChange, resolvedChanges);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void field_21__no_announcement_change_is_removed() {
|
||||
DecryptedGroup groupState = new DecryptedGroup.Builder()
|
||||
.isAnnouncementGroup(EnabledState.ENABLED)
|
||||
.build();
|
||||
|
||||
DecryptedGroupChange decryptedChange = new DecryptedGroupChange.Builder()
|
||||
.newIsAnnouncementGroup(EnabledState.ENABLED)
|
||||
.build();
|
||||
|
||||
DecryptedGroupChange resolvedChanges = GroupChangeUtil.resolveConflict(groupState, decryptedChange).build();
|
||||
|
||||
assertTrue(DecryptedGroupUtil.changeIsEmpty(resolvedChanges));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void field_22__add_banned_members() {
|
||||
UUID member1 = UUID.randomUUID();
|
||||
UUID member2 = UUID.randomUUID();
|
||||
UUID member3 = UUID.randomUUID();
|
||||
|
||||
DecryptedGroup groupState = new DecryptedGroup.Builder()
|
||||
.members(List.of(member(member1)))
|
||||
.bannedMembers(List.of(bannedMember(member3)))
|
||||
.build();
|
||||
|
||||
DecryptedGroupChange decryptedChange = new DecryptedGroupChange.Builder()
|
||||
.newBannedMembers(List.of(bannedMember(member1), bannedMember(member2), bannedMember(member3)))
|
||||
.build();
|
||||
|
||||
DecryptedGroupChange resolvedChanges = GroupChangeUtil.resolveConflict(groupState, decryptedChange).build();
|
||||
|
||||
DecryptedGroupChange expected = new DecryptedGroupChange.Builder()
|
||||
.newBannedMembers(List.of(bannedMember(member1), bannedMember(member2)))
|
||||
.build();
|
||||
|
||||
assertEquals(expected, resolvedChanges);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void field_23__delete_banned_members() {
|
||||
UUID member1 = UUID.randomUUID();
|
||||
UUID member2 = UUID.randomUUID();
|
||||
UUID member3 = UUID.randomUUID();
|
||||
|
||||
DecryptedGroup groupState = new DecryptedGroup.Builder()
|
||||
.members(List.of(member(member1)))
|
||||
.bannedMembers(List.of(bannedMember(member2)))
|
||||
.build();
|
||||
|
||||
DecryptedGroupChange decryptedChange = new DecryptedGroupChange.Builder()
|
||||
.deleteBannedMembers(List.of(bannedMember(member1), bannedMember(member2), bannedMember(member3)))
|
||||
.build();
|
||||
|
||||
DecryptedGroupChange resolvedChanges = GroupChangeUtil.resolveConflict(groupState, decryptedChange).build();
|
||||
|
||||
DecryptedGroupChange expected = new DecryptedGroupChange.Builder()
|
||||
.deleteBannedMembers(List.of(bannedMember(member2)))
|
||||
.build();
|
||||
|
||||
assertEquals(expected, resolvedChanges);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void field_24__promote_pending_members() {
|
||||
DecryptedMember member1 = pendingPniAciMember(UUID.randomUUID(), UUID.randomUUID(), randomProfileKey());
|
||||
DecryptedMember member2 = pendingPniAciMember(UUID.randomUUID(), UUID.randomUUID(), randomProfileKey());
|
||||
|
||||
DecryptedGroup groupState = new DecryptedGroup.Builder()
|
||||
.members(List.of(member(UuidUtil.fromByteString(member1.aciBytes))))
|
||||
.build();
|
||||
|
||||
DecryptedGroupChange decryptedChange = new DecryptedGroupChange.Builder()
|
||||
.promotePendingPniAciMembers(List.of(pendingPniAciMember(member1.aciBytes, member1.pniBytes, member1.profileKey), pendingPniAciMember(member2.aciBytes, member2.pniBytes, member2.profileKey)))
|
||||
.build();
|
||||
|
||||
DecryptedGroupChange resolvedChanges = GroupChangeUtil.resolveConflict(groupState, decryptedChange).build();
|
||||
|
||||
DecryptedGroupChange expected = new DecryptedGroupChange.Builder()
|
||||
.promotePendingPniAciMembers(List.of(pendingPniAciMember(member2.aciBytes, member2.pniBytes, member2.profileKey)))
|
||||
.build();
|
||||
|
||||
assertEquals(expected, resolvedChanges);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package org.whispersystems.signalservice.api.groupsv2
|
||||
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.containsExactly
|
||||
import assertk.assertions.isEqualTo
|
||||
import assertk.assertions.single
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.signal.core.models.ServiceId
|
||||
import org.signal.libsignal.zkgroup.groups.GroupMasterKey
|
||||
import org.signal.libsignal.zkgroup.groups.GroupSecretParams
|
||||
import org.whispersystems.signalservice.api.groupsv2.GroupsV2Operations.GroupOperations
|
||||
import org.whispersystems.signalservice.internal.util.Util
|
||||
import org.whispersystems.signalservice.testutil.LibSignalLibraryUtil
|
||||
import java.util.UUID
|
||||
|
||||
@Suppress("ClassName")
|
||||
class GroupsV2Operations_ban_Test {
|
||||
private lateinit var groupOperations: GroupOperations
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
LibSignalLibraryUtil.assumeLibSignalSupportedOnOS()
|
||||
|
||||
val server = TestZkGroupServer()
|
||||
val groupSecretParams = GroupSecretParams.deriveFromMasterKey(GroupMasterKey(Util.getSecretBytes(32)))
|
||||
val clientZkOperations = ClientZkOperations(server.serverPublicParams)
|
||||
|
||||
groupOperations = GroupsV2Operations(clientZkOperations, 10).forGroup(groupSecretParams)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun addBanToEmptyList() {
|
||||
val ban = randomACI()
|
||||
|
||||
val banUuidsChange = groupOperations.createBanServiceIdsChange(
|
||||
/* banServiceIds = */
|
||||
setOf(ban),
|
||||
/* rejectJoinRequest = */
|
||||
false,
|
||||
/* bannedMembersList = */
|
||||
emptyList()
|
||||
)
|
||||
|
||||
assertThat(banUuidsChange.addBannedMembers)
|
||||
.single()
|
||||
.transform { it.added?.userId }
|
||||
.isEqualTo(groupOperations.encryptServiceId(ban))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun addBanToPartialFullList() {
|
||||
val toBan = randomACI()
|
||||
val alreadyBanned = (0 until 5).map { ProtoTestUtils.bannedMember(UUID.randomUUID()) }
|
||||
|
||||
val banUuidsChange = groupOperations.createBanServiceIdsChange(
|
||||
/* banServiceIds = */
|
||||
setOf(toBan),
|
||||
/* rejectJoinRequest = */
|
||||
false,
|
||||
/* bannedMembersList = */
|
||||
alreadyBanned
|
||||
)
|
||||
|
||||
assertThat(banUuidsChange.addBannedMembers)
|
||||
.single()
|
||||
.transform { it.added?.userId }
|
||||
.isEqualTo(groupOperations.encryptServiceId(toBan))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun addBanToFullList() {
|
||||
val toBan = ServiceId.ACI.from(UUID.randomUUID())
|
||||
|
||||
val alreadyBanned = (0 until 10).map { i ->
|
||||
ProtoTestUtils.bannedMember(UUID.randomUUID())
|
||||
.newBuilder()
|
||||
.timestamp(100L + i)
|
||||
.build()
|
||||
}.shuffled()
|
||||
|
||||
val banUuidsChange = groupOperations.createBanServiceIdsChange(
|
||||
/* banServiceIds = */
|
||||
setOf(toBan),
|
||||
/* rejectJoinRequest = */
|
||||
false,
|
||||
/* bannedMembersList = */
|
||||
alreadyBanned
|
||||
)
|
||||
|
||||
val oldest = alreadyBanned.minBy { it.timestamp }
|
||||
assertThat(banUuidsChange.deleteBannedMembers)
|
||||
.single()
|
||||
.transform { it.deletedUserId }
|
||||
.isEqualTo(groupOperations.encryptServiceId(ServiceId.parseOrThrow(oldest.serviceIdBytes)))
|
||||
|
||||
assertThat(banUuidsChange.addBannedMembers)
|
||||
.single()
|
||||
.transform { it.added?.userId }
|
||||
.isEqualTo(groupOperations.encryptServiceId(toBan))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun addMultipleBanToFullList() {
|
||||
val toBan = (0 until 2).map { ServiceId.ACI.from(UUID.randomUUID()) }
|
||||
|
||||
val alreadyBanned = (0 until 10).map { i ->
|
||||
ProtoTestUtils.bannedMember(UUID.randomUUID())
|
||||
.newBuilder()
|
||||
.timestamp(100L + i)
|
||||
.build()
|
||||
}.shuffled()
|
||||
|
||||
val banUuidsChange = groupOperations.createBanServiceIdsChange(
|
||||
/* banServiceIds = */
|
||||
toBan.toMutableSet(),
|
||||
/* rejectJoinRequest = */
|
||||
false,
|
||||
/* bannedMembersList = */
|
||||
alreadyBanned
|
||||
)
|
||||
|
||||
val oldestTwo = alreadyBanned
|
||||
.sortedBy { it.timestamp }
|
||||
.subList(0, 2)
|
||||
.map { groupOperations.encryptServiceId(ServiceId.parseOrThrow(it.serviceIdBytes)) }
|
||||
.toTypedArray()
|
||||
assertThat(banUuidsChange.deleteBannedMembers)
|
||||
.transform { members ->
|
||||
members.map { member ->
|
||||
member.deletedUserId
|
||||
}
|
||||
}
|
||||
.containsExactly(*oldestTwo)
|
||||
|
||||
val newBans = (0..1).map { i ->
|
||||
groupOperations.encryptServiceId(toBan[i])
|
||||
}.toTypedArray()
|
||||
assertThat(banUuidsChange.addBannedMembers)
|
||||
.transform { members ->
|
||||
members.map { member ->
|
||||
member.added?.userId
|
||||
}
|
||||
}
|
||||
.containsExactly(*newBans)
|
||||
}
|
||||
|
||||
private fun randomACI(): ServiceId.ACI = ServiceId.ACI.from(UUID.randomUUID())
|
||||
}
|
||||
@@ -0,0 +1,527 @@
|
||||
package org.whispersystems.signalservice.api.groupsv2;
|
||||
|
||||
import org.junit.Before;
|
||||
import org.junit.Test;
|
||||
import org.signal.libsignal.zkgroup.InvalidInputException;
|
||||
import org.signal.libsignal.zkgroup.VerificationFailedException;
|
||||
import org.signal.libsignal.zkgroup.groups.ClientZkGroupCipher;
|
||||
import org.signal.libsignal.zkgroup.groups.GroupMasterKey;
|
||||
import org.signal.libsignal.zkgroup.groups.GroupSecretParams;
|
||||
import org.signal.libsignal.zkgroup.groups.UuidCiphertext;
|
||||
import org.signal.libsignal.zkgroup.profiles.ClientZkProfileOperations;
|
||||
import org.signal.libsignal.zkgroup.profiles.ExpiringProfileKeyCredential;
|
||||
import org.signal.libsignal.zkgroup.profiles.ExpiringProfileKeyCredentialResponse;
|
||||
import org.signal.libsignal.zkgroup.profiles.ProfileKey;
|
||||
import org.signal.libsignal.zkgroup.profiles.ProfileKeyCommitment;
|
||||
import org.signal.libsignal.zkgroup.profiles.ProfileKeyCredentialPresentation;
|
||||
import org.signal.libsignal.zkgroup.profiles.ProfileKeyCredentialRequest;
|
||||
import org.signal.libsignal.zkgroup.profiles.ProfileKeyCredentialRequestContext;
|
||||
import org.signal.storageservice.protos.groups.AccessControl;
|
||||
import org.signal.storageservice.protos.groups.GroupChange;
|
||||
import org.signal.storageservice.protos.groups.Member;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedApproveMember;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedBannedMember;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedGroupChange;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedMember;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedModifyMemberRole;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedPendingMember;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedPendingMemberRemoval;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedRequestingMember;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedString;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedTimer;
|
||||
import org.signal.storageservice.protos.groups.local.EnabledState;
|
||||
import org.signal.core.models.ServiceId.ACI;
|
||||
import org.signal.core.models.ServiceId.PNI;
|
||||
import org.signal.core.util.UuidUtil;
|
||||
import org.whispersystems.signalservice.internal.util.Util;
|
||||
import org.whispersystems.signalservice.testutil.LibSignalLibraryUtil;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import okio.ByteString;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.whispersystems.signalservice.api.groupsv2.ProtobufTestUtils.getMaxDeclaredFieldNumber;
|
||||
|
||||
public final class GroupsV2Operations_decrypt_change_Test {
|
||||
|
||||
private GroupSecretParams groupSecretParams;
|
||||
private GroupsV2Operations.GroupOperations groupOperations;
|
||||
private ClientZkOperations clientZkOperations;
|
||||
private TestZkGroupServer server;
|
||||
|
||||
@Before
|
||||
public void setup() throws InvalidInputException {
|
||||
LibSignalLibraryUtil.assumeLibSignalSupportedOnOS();
|
||||
|
||||
server = new TestZkGroupServer();
|
||||
groupSecretParams = GroupSecretParams.deriveFromMasterKey(new GroupMasterKey(Util.getSecretBytes(32)));
|
||||
clientZkOperations = new ClientZkOperations(server.getServerPublicParams());
|
||||
groupOperations = new GroupsV2Operations(clientZkOperations, 1000).forGroup(groupSecretParams);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void ensure_GroupV2Operations_decryptChange_knows_about_all_fields_of_DecryptedGroupChange() {
|
||||
int maxFieldFound = getMaxDeclaredFieldNumber(DecryptedGroupChange.class);
|
||||
|
||||
assertEquals("GroupV2Operations#decryptChange and its tests need updating to account for new fields on " + DecryptedGroupChange.class.getName(),
|
||||
24,
|
||||
maxFieldFound);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void cannot_decrypt_change_with_epoch_higher_than_known() throws IOException, VerificationFailedException, InvalidGroupStateException {
|
||||
GroupChange change = new GroupChange.Builder()
|
||||
.changeEpoch(GroupsV2Operations.HIGHEST_KNOWN_EPOCH + 1)
|
||||
.build();
|
||||
|
||||
Optional<DecryptedGroupChange> decryptedGroupChangeOptional = groupOperations.decryptChange(change, DecryptChangeVerificationMode.alreadyTrusted());
|
||||
|
||||
assertFalse(decryptedGroupChangeOptional.isPresent());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void can_pass_revision_through_encrypt_and_decrypt_methods() {
|
||||
assertDecryption(new GroupChange.Actions.Builder()
|
||||
.revision(1),
|
||||
new DecryptedGroupChange.Builder()
|
||||
.revision(1));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void can_decrypt_member_additions_field3() {
|
||||
ACI self = ACI.from(UUID.randomUUID());
|
||||
ACI newMember = ACI.from(UUID.randomUUID());
|
||||
ProfileKey profileKey = newProfileKey();
|
||||
GroupCandidate groupCandidate = groupCandidate(newMember, profileKey);
|
||||
|
||||
assertDecryption(groupOperations.createModifyGroupMembershipChange(Collections.singleton(groupCandidate), Collections.emptySet(), self)
|
||||
.revision(10),
|
||||
new DecryptedGroupChange.Builder()
|
||||
.revision(10)
|
||||
.newMembers(List.of(new DecryptedMember.Builder()
|
||||
.role(Member.Role.DEFAULT)
|
||||
.profileKey(ByteString.of(profileKey.serialize()))
|
||||
.joinedAtRevision(10)
|
||||
.aciBytes(newMember.toByteString())
|
||||
.build())));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void can_decrypt_member_direct_join_field3() {
|
||||
ACI newMember = ACI.from(UUID.randomUUID());
|
||||
ProfileKey profileKey = newProfileKey();
|
||||
GroupCandidate groupCandidate = groupCandidate(newMember, profileKey);
|
||||
|
||||
assertDecryption(groupOperations.createGroupJoinDirect(groupCandidate.getExpiringProfileKeyCredential().get())
|
||||
.revision(10),
|
||||
new DecryptedGroupChange.Builder()
|
||||
.revision(10)
|
||||
.newMembers(List.of(new DecryptedMember.Builder()
|
||||
.role(Member.Role.DEFAULT)
|
||||
.profileKey(ByteString.of(profileKey.serialize()))
|
||||
.joinedAtRevision(10)
|
||||
.aciBytes(newMember.toByteString())
|
||||
.build())));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void can_decrypt_member_additions_direct_to_admin_field3() {
|
||||
ACI self = ACI.from(UUID.randomUUID());
|
||||
ACI newMember = ACI.from(UUID.randomUUID());
|
||||
ProfileKey profileKey = newProfileKey();
|
||||
GroupCandidate groupCandidate = groupCandidate(newMember, profileKey);
|
||||
|
||||
assertDecryption(groupOperations.createModifyGroupMembershipChange(Collections.singleton(groupCandidate), Collections.emptySet(), self)
|
||||
.revision(10),
|
||||
new DecryptedGroupChange.Builder()
|
||||
.revision(10)
|
||||
.newMembers(List.of(new DecryptedMember.Builder()
|
||||
.role(Member.Role.DEFAULT)
|
||||
.profileKey(ByteString.of(profileKey.serialize()))
|
||||
.joinedAtRevision(10)
|
||||
.aciBytes(newMember.toByteString())
|
||||
.build())));
|
||||
}
|
||||
|
||||
@Test(expected = InvalidGroupStateException.class)
|
||||
public void cannot_decrypt_member_additions_with_bad_cipher_text_field3() throws IOException, VerificationFailedException, InvalidGroupStateException {
|
||||
byte[] randomPresentation = Util.getSecretBytes(5);
|
||||
GroupChange.Actions.Builder actions = new GroupChange.Actions.Builder();
|
||||
|
||||
actions.addMembers(List.of(new GroupChange.Actions.AddMemberAction.Builder().added(new Member.Builder().role(Member.Role.DEFAULT)
|
||||
.presentation(ByteString.of(randomPresentation)).build()).build()));
|
||||
|
||||
groupOperations.decryptChange(new GroupChange.Builder().actions(actions.build().encodeByteString()).build(), DecryptChangeVerificationMode.alreadyTrusted());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void can_decrypt_member_removals_field4() {
|
||||
ACI oldMember = ACI.from(UUID.randomUUID());
|
||||
|
||||
assertDecryption(groupOperations.createRemoveMembersChange(Collections.singleton(oldMember), false, Collections.emptyList())
|
||||
.revision(10),
|
||||
new DecryptedGroupChange.Builder()
|
||||
.revision(10)
|
||||
.deleteMembers(List.of(oldMember.toByteString())));
|
||||
}
|
||||
|
||||
@Test(expected = InvalidGroupStateException.class)
|
||||
public void cannot_decrypt_member_removals_with_bad_cipher_text_field4() throws IOException, VerificationFailedException, InvalidGroupStateException {
|
||||
byte[] randomPresentation = Util.getSecretBytes(5);
|
||||
GroupChange.Actions.Builder actions = new GroupChange.Actions.Builder();
|
||||
|
||||
actions.deleteMembers(List.of(new GroupChange.Actions.DeleteMemberAction.Builder().deletedUserId(ByteString.of(randomPresentation)).build()));
|
||||
|
||||
groupOperations.decryptChange(new GroupChange.Builder().actions(actions.build().encodeByteString()).build(), DecryptChangeVerificationMode.alreadyTrusted());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void can_decrypt_modify_member_action_role_to_admin_field5() {
|
||||
ACI member = ACI.from(UUID.randomUUID());
|
||||
|
||||
assertDecryption(groupOperations.createChangeMemberRole(member, Member.Role.ADMINISTRATOR),
|
||||
new DecryptedGroupChange.Builder()
|
||||
.modifyMemberRoles(List.of(new DecryptedModifyMemberRole.Builder()
|
||||
.aciBytes(member.toByteString())
|
||||
.role(Member.Role.ADMINISTRATOR)
|
||||
.build())));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void can_decrypt_modify_member_action_role_to_member_field5() {
|
||||
ACI member = ACI.from(UUID.randomUUID());
|
||||
|
||||
assertDecryption(groupOperations.createChangeMemberRole(member, Member.Role.DEFAULT),
|
||||
new DecryptedGroupChange.Builder()
|
||||
.modifyMemberRoles(List.of(new DecryptedModifyMemberRole.Builder()
|
||||
.aciBytes(member.toByteString())
|
||||
.role(Member.Role.DEFAULT).build())));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void can_decrypt_modify_member_profile_key_action_field6() {
|
||||
ACI self = ACI.from(UUID.randomUUID());
|
||||
ProfileKey profileKey = newProfileKey();
|
||||
GroupCandidate groupCandidate = groupCandidate(self, profileKey);
|
||||
|
||||
assertDecryption(groupOperations.createUpdateProfileKeyCredentialChange(groupCandidate.getExpiringProfileKeyCredential().get())
|
||||
.revision(10),
|
||||
new DecryptedGroupChange.Builder()
|
||||
.revision(10)
|
||||
.modifiedProfileKeys(List.of(new DecryptedMember.Builder()
|
||||
.role(Member.Role.UNKNOWN)
|
||||
.joinedAtRevision(-1)
|
||||
.profileKey(ByteString.of(profileKey.serialize()))
|
||||
.aciBytes(self.toByteString())
|
||||
.build())));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void can_decrypt_member_invitations_field7() {
|
||||
ACI self = ACI.from(UUID.randomUUID());
|
||||
ACI newMember = ACI.from(UUID.randomUUID());
|
||||
GroupCandidate groupCandidate = new GroupCandidate(newMember, Optional.empty());
|
||||
|
||||
assertDecryption(groupOperations.createModifyGroupMembershipChange(Collections.singleton(groupCandidate), Collections.emptySet(), self)
|
||||
.revision(13),
|
||||
new DecryptedGroupChange.Builder()
|
||||
.revision(13)
|
||||
.newPendingMembers(List.of(new DecryptedPendingMember.Builder()
|
||||
.addedByAci(self.toByteString())
|
||||
.serviceIdCipherText(groupOperations.encryptServiceId(newMember))
|
||||
.role(Member.Role.DEFAULT)
|
||||
.serviceIdBytes(newMember.toByteString())
|
||||
.build())));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void can_decrypt_pending_member_removals_field8() throws InvalidInputException {
|
||||
ACI oldMember = ACI.from(UUID.randomUUID());
|
||||
UuidCiphertext uuidCiphertext = new UuidCiphertext(groupOperations.encryptServiceId(oldMember).toByteArray());
|
||||
|
||||
assertDecryption(groupOperations.createRemoveInvitationChange(Collections.singleton(uuidCiphertext)),
|
||||
new DecryptedGroupChange.Builder()
|
||||
.deletePendingMembers(List.of(new DecryptedPendingMemberRemoval.Builder()
|
||||
.serviceIdBytes(oldMember.toByteString())
|
||||
.serviceIdCipherText(ByteString.of(uuidCiphertext.serialize()))
|
||||
.build())));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void can_decrypt_pending_member_removals_with_bad_cipher_text_field8() {
|
||||
byte[] uuidCiphertext = Util.getSecretBytes(60);
|
||||
|
||||
assertDecryption(new GroupChange.Actions.Builder()
|
||||
.deletePendingMembers(List.of(new GroupChange.Actions.DeletePendingMemberAction.Builder()
|
||||
.deletedUserId(ByteString.of(uuidCiphertext)).build())),
|
||||
new DecryptedGroupChange.Builder()
|
||||
.deletePendingMembers(List.of(new DecryptedPendingMemberRemoval.Builder()
|
||||
.serviceIdBytes(UuidUtil.toByteString(UuidUtil.UNKNOWN_UUID))
|
||||
.serviceIdCipherText(ByteString.of(uuidCiphertext))
|
||||
.build())));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void can_decrypt_promote_pending_member_field9() {
|
||||
ACI newMember = ACI.from(UUID.randomUUID());
|
||||
ProfileKey profileKey = newProfileKey();
|
||||
GroupCandidate groupCandidate = groupCandidate(newMember, profileKey);
|
||||
|
||||
assertDecryption(groupOperations.createAcceptInviteChange(groupCandidate.getExpiringProfileKeyCredential().get()),
|
||||
new DecryptedGroupChange.Builder()
|
||||
.promotePendingMembers(List.of(new DecryptedMember.Builder()
|
||||
.aciBytes(newMember.toByteString())
|
||||
.role(Member.Role.DEFAULT)
|
||||
.profileKey(ByteString.of(profileKey.serialize()))
|
||||
.joinedAtRevision(-1)
|
||||
.build())));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void can_decrypt_title_field_10() {
|
||||
assertDecryption(groupOperations.createModifyGroupTitle("New title"),
|
||||
new DecryptedGroupChange.Builder()
|
||||
.newTitle(new DecryptedString.Builder().value_("New title").build()));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void can_decrypt_avatar_key_field_11() {
|
||||
assertDecryption(new GroupChange.Actions.Builder()
|
||||
.modifyAvatar(new GroupChange.Actions.ModifyAvatarAction.Builder().avatar("New avatar").build()),
|
||||
new DecryptedGroupChange.Builder()
|
||||
.newAvatar(new DecryptedString.Builder().value_("New avatar").build()));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void can_decrypt_timer_value_field_12() {
|
||||
assertDecryption(groupOperations.createModifyGroupTimerChange(100),
|
||||
new DecryptedGroupChange.Builder()
|
||||
.newTimer(new DecryptedTimer.Builder().duration(100).build()));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void can_pass_through_new_attribute_access_rights_field_13() {
|
||||
assertDecryption(groupOperations.createChangeAttributesRights(AccessControl.AccessRequired.MEMBER),
|
||||
new DecryptedGroupChange.Builder()
|
||||
.newAttributeAccess(AccessControl.AccessRequired.MEMBER));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void can_pass_through_new_membership_rights_field_14() {
|
||||
assertDecryption(groupOperations.createChangeMembershipRights(AccessControl.AccessRequired.ADMINISTRATOR),
|
||||
new DecryptedGroupChange.Builder()
|
||||
.newMemberAccess(AccessControl.AccessRequired.ADMINISTRATOR));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void can_pass_through_new_add_by_invite_link_rights_field_15() {
|
||||
assertDecryption(groupOperations.createChangeJoinByLinkRights(AccessControl.AccessRequired.ADMINISTRATOR),
|
||||
new DecryptedGroupChange.Builder()
|
||||
.newInviteLinkAccess(AccessControl.AccessRequired.ADMINISTRATOR));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void can_pass_through_new_add_by_invite_link_rights_field_15_unsatisfiable() {
|
||||
assertDecryption(groupOperations.createChangeJoinByLinkRights(AccessControl.AccessRequired.UNSATISFIABLE),
|
||||
new DecryptedGroupChange.Builder()
|
||||
.newInviteLinkAccess(AccessControl.AccessRequired.UNSATISFIABLE));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void can_decrypt_member_requests_field16() {
|
||||
ACI newRequestingMember = ACI.from(UUID.randomUUID());
|
||||
ProfileKey profileKey = newProfileKey();
|
||||
GroupCandidate groupCandidate = groupCandidate(newRequestingMember, profileKey);
|
||||
|
||||
assertDecryption(groupOperations.createGroupJoinRequest(groupCandidate.getExpiringProfileKeyCredential().get())
|
||||
.revision(10),
|
||||
new DecryptedGroupChange.Builder()
|
||||
.revision(10)
|
||||
.newRequestingMembers(List.of(new DecryptedRequestingMember.Builder()
|
||||
.aciBytes(newRequestingMember.toByteString())
|
||||
.profileKey(ByteString.of(profileKey.serialize()))
|
||||
.build())));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void can_decrypt_member_requests_refusals_field17() {
|
||||
ACI newRequestingMember = ACI.from(UUID.randomUUID());
|
||||
|
||||
assertDecryption(groupOperations.createRefuseGroupJoinRequest(Collections.singleton(newRequestingMember), true, Collections.emptyList())
|
||||
.revision(10),
|
||||
new DecryptedGroupChange.Builder()
|
||||
.revision(10)
|
||||
.deleteRequestingMembers(List.of(newRequestingMember.toByteString()))
|
||||
.newBannedMembers(List.of(new DecryptedBannedMember.Builder().serviceIdBytes(newRequestingMember.toByteString()).build())));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void can_decrypt_promote_requesting_members_field18() {
|
||||
UUID newRequestingMember = UUID.randomUUID();
|
||||
|
||||
assertDecryption(groupOperations.createApproveGroupJoinRequest(Collections.singleton(newRequestingMember))
|
||||
.revision(15),
|
||||
new DecryptedGroupChange.Builder()
|
||||
.revision(15)
|
||||
.promoteRequestingMembers(List.of(new DecryptedApproveMember.Builder()
|
||||
.role(Member.Role.DEFAULT)
|
||||
.aciBytes(UuidUtil.toByteString(newRequestingMember))
|
||||
.build())));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void can_pass_through_new_invite_link_password_field19() {
|
||||
byte[] newPassword = Util.getSecretBytes(16);
|
||||
|
||||
assertDecryption(new GroupChange.Actions.Builder()
|
||||
.modifyInviteLinkPassword(new GroupChange.Actions.ModifyInviteLinkPasswordAction.Builder()
|
||||
.inviteLinkPassword(ByteString.of(newPassword))
|
||||
.build()),
|
||||
new DecryptedGroupChange.Builder()
|
||||
.newInviteLinkPassword(ByteString.of(newPassword)));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void can_pass_through_new_description_field20() {
|
||||
assertDecryption(groupOperations.createModifyGroupDescription("New Description"),
|
||||
new DecryptedGroupChange.Builder()
|
||||
.newDescription(new DecryptedString.Builder().value_("New Description").build()));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void can_pass_through_new_announcment_only_field21() {
|
||||
assertDecryption(new GroupChange.Actions.Builder()
|
||||
.modifyAnnouncementsOnly(new GroupChange.Actions.ModifyAnnouncementsOnlyAction.Builder()
|
||||
.announcementsOnly(true)
|
||||
.build()),
|
||||
new DecryptedGroupChange.Builder()
|
||||
.newIsAnnouncementGroup(EnabledState.ENABLED));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void can_decrypt_member_bans_field22() {
|
||||
ACI ban = ACI.from(UUID.randomUUID());
|
||||
|
||||
assertDecryption(groupOperations.createBanServiceIdsChange(Collections.singleton(ban), false, Collections.emptyList())
|
||||
.revision(13),
|
||||
new DecryptedGroupChange.Builder()
|
||||
.revision(13)
|
||||
.newBannedMembers(List.of(new DecryptedBannedMember.Builder()
|
||||
.serviceIdBytes(ban.toByteString())
|
||||
.build())));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void can_decrypt_banned_member_removals_field23() {
|
||||
ACI ban = ACI.from(UUID.randomUUID());
|
||||
|
||||
assertDecryption(groupOperations.createUnbanServiceIdsChange(Collections.singleton(ban))
|
||||
.revision(13),
|
||||
new DecryptedGroupChange.Builder()
|
||||
.revision(13)
|
||||
.deleteBannedMembers(List.of(new DecryptedBannedMember.Builder()
|
||||
.serviceIdBytes(ban.toByteString()).build())));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void can_decrypt_promote_pending_pni_aci_member_field24() {
|
||||
ACI memberAci = ACI.from(UUID.randomUUID());
|
||||
PNI memberPni = PNI.from(UUID.randomUUID());
|
||||
ProfileKey profileKey = newProfileKey();
|
||||
|
||||
GroupChange.Actions.Builder builder = new GroupChange.Actions.Builder()
|
||||
.sourceServiceId(groupOperations.encryptServiceId(memberPni))
|
||||
.revision(5)
|
||||
.promotePendingPniAciMembers(List.of(new GroupChange.Actions.PromotePendingPniAciMemberProfileKeyAction.Builder()
|
||||
.userId(groupOperations.encryptServiceId(memberAci))
|
||||
.pni(groupOperations.encryptServiceId(memberPni))
|
||||
.profileKey(encryptProfileKey(memberAci, profileKey))
|
||||
.build()));
|
||||
|
||||
assertDecryptionWithEditorSet(builder,
|
||||
new DecryptedGroupChange.Builder()
|
||||
.editorServiceIdBytes(memberAci.toByteString())
|
||||
.revision(5)
|
||||
.promotePendingPniAciMembers(List.of(new DecryptedMember.Builder()
|
||||
.aciBytes(memberAci.toByteString())
|
||||
.pniBytes(memberPni.toByteString())
|
||||
.role(Member.Role.DEFAULT)
|
||||
.profileKey(ByteString.of(profileKey.serialize()))
|
||||
.joinedAtRevision(5)
|
||||
.build())));
|
||||
}
|
||||
|
||||
private static ProfileKey newProfileKey() {
|
||||
try {
|
||||
return new ProfileKey(Util.getSecretBytes(32));
|
||||
} catch (InvalidInputException e) {
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
}
|
||||
|
||||
private ByteString encryptProfileKey(ACI aci, ProfileKey profileKey) {
|
||||
return ByteString.of(new ClientZkGroupCipher(groupSecretParams).encryptProfileKey(profileKey, aci.getLibSignalAci()).serialize());
|
||||
}
|
||||
|
||||
static GroupCandidate groupCandidate(UUID uuid) {
|
||||
return new GroupCandidate(ACI.from(uuid), Optional.empty());
|
||||
}
|
||||
|
||||
GroupCandidate groupCandidate(ACI aci, ProfileKey profileKey) {
|
||||
try {
|
||||
ClientZkProfileOperations profileOperations = clientZkOperations.getProfileOperations();
|
||||
ProfileKeyCommitment commitment = profileKey.getCommitment(aci.getLibSignalAci());
|
||||
ProfileKeyCredentialRequestContext requestContext = profileOperations.createProfileKeyCredentialRequestContext(aci.getLibSignalAci(), profileKey);
|
||||
ProfileKeyCredentialRequest request = requestContext.getRequest();
|
||||
ExpiringProfileKeyCredentialResponse expiringProfileKeyCredentialResponse = server.getExpiringProfileKeyCredentialResponse(request, aci, commitment, Instant.now().plus(7, ChronoUnit.DAYS).truncatedTo(ChronoUnit.DAYS));
|
||||
ExpiringProfileKeyCredential profileKeyCredential = profileOperations.receiveExpiringProfileKeyCredential(requestContext, expiringProfileKeyCredentialResponse);
|
||||
GroupCandidate groupCandidate = new GroupCandidate(aci, Optional.of(profileKeyCredential));
|
||||
|
||||
ProfileKeyCredentialPresentation presentation = profileOperations.createProfileKeyCredentialPresentation(groupSecretParams, profileKeyCredential);
|
||||
server.assertProfileKeyCredentialPresentation(groupSecretParams.getPublicParams(), presentation, Instant.now());
|
||||
|
||||
return groupCandidate;
|
||||
} catch (VerificationFailedException e) {
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
}
|
||||
|
||||
void assertDecryption(GroupChange.Actions.Builder inputChange,
|
||||
DecryptedGroupChange.Builder expectedDecrypted)
|
||||
{
|
||||
ACI editor = ACI.from(UUID.randomUUID());
|
||||
assertDecryptionWithEditorSet(inputChange.sourceServiceId(groupOperations.encryptServiceId(editor)), expectedDecrypted.editorServiceIdBytes(editor.toByteString()));
|
||||
}
|
||||
|
||||
void assertDecryptionWithEditorSet(GroupChange.Actions.Builder inputChange,
|
||||
DecryptedGroupChange.Builder expectedDecrypted)
|
||||
{
|
||||
GroupChange.Actions actions = inputChange.build();
|
||||
|
||||
GroupChange change = new GroupChange.Builder()
|
||||
.actions(actions.encodeByteString())
|
||||
.build();
|
||||
|
||||
DecryptedGroupChange decryptedGroupChange = decrypt(change);
|
||||
|
||||
assertEquals(expectedDecrypted.build(),
|
||||
decryptedGroupChange);
|
||||
}
|
||||
|
||||
private DecryptedGroupChange decrypt(GroupChange build) {
|
||||
try {
|
||||
return groupOperations.decryptChange(build, DecryptChangeVerificationMode.alreadyTrusted()).get();
|
||||
} catch (IOException | VerificationFailedException | InvalidGroupStateException e) {
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package org.whispersystems.signalservice.api.groupsv2;
|
||||
|
||||
import org.junit.Before;
|
||||
import org.junit.Test;
|
||||
import org.signal.libsignal.zkgroup.InvalidInputException;
|
||||
import org.signal.libsignal.zkgroup.groups.GroupMasterKey;
|
||||
import org.signal.libsignal.zkgroup.groups.GroupSecretParams;
|
||||
import org.signal.storageservice.protos.groups.AccessControl;
|
||||
import org.signal.storageservice.protos.groups.GroupJoinInfo;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedGroupJoinInfo;
|
||||
import org.whispersystems.signalservice.internal.util.Util;
|
||||
import org.whispersystems.signalservice.testutil.LibSignalLibraryUtil;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.whispersystems.signalservice.api.groupsv2.ProtobufTestUtils.getMaxDeclaredFieldNumber;
|
||||
|
||||
public final class GroupsV2Operations_decrypt_groupJoinInfo_Test {
|
||||
|
||||
private GroupsV2Operations.GroupOperations groupOperations;
|
||||
|
||||
@Before
|
||||
public void setup() throws InvalidInputException {
|
||||
LibSignalLibraryUtil.assumeLibSignalSupportedOnOS();
|
||||
|
||||
TestZkGroupServer server = new TestZkGroupServer();
|
||||
ClientZkOperations clientZkOperations = new ClientZkOperations(server.getServerPublicParams());
|
||||
GroupSecretParams groupSecretParams = GroupSecretParams.deriveFromMasterKey(new GroupMasterKey(Util.getSecretBytes(32)));
|
||||
|
||||
groupOperations = new GroupsV2Operations(clientZkOperations, 1000).forGroup(groupSecretParams);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reflects over the generated protobuf class and ensures that no new fields have been added since we wrote this.
|
||||
* <p>
|
||||
* If we didn't, newly added fields would not be decrypted by {@link GroupsV2Operations.GroupOperations#decryptGroupJoinInfo}.
|
||||
*/
|
||||
@Test
|
||||
public void ensure_GroupOperations_knows_about_all_fields_of_Group() {
|
||||
int maxFieldFound = getMaxDeclaredFieldNumber(GroupJoinInfo.class);
|
||||
|
||||
assertEquals("GroupOperations and its tests need updating to account for new fields on " + GroupJoinInfo.class.getName(),
|
||||
8, maxFieldFound);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void decrypt_title_field_2() {
|
||||
GroupJoinInfo groupJoinInfo = new GroupJoinInfo.Builder()
|
||||
.title(groupOperations.encryptTitle("Title!"))
|
||||
.build();
|
||||
|
||||
DecryptedGroupJoinInfo decryptedGroupJoinInfo = groupOperations.decryptGroupJoinInfo(groupJoinInfo);
|
||||
|
||||
assertEquals("Title!", decryptedGroupJoinInfo.title);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void avatar_field_passed_through_3() {
|
||||
GroupJoinInfo groupJoinInfo = new GroupJoinInfo.Builder()
|
||||
.avatar("AvatarCdnKey")
|
||||
.build();
|
||||
|
||||
DecryptedGroupJoinInfo decryptedGroupJoinInfo = groupOperations.decryptGroupJoinInfo(groupJoinInfo);
|
||||
|
||||
assertEquals("AvatarCdnKey", decryptedGroupJoinInfo.avatar);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void member_count_passed_through_4() {
|
||||
GroupJoinInfo groupJoinInfo = new GroupJoinInfo.Builder()
|
||||
.memberCount(97)
|
||||
.build();
|
||||
|
||||
DecryptedGroupJoinInfo decryptedGroupJoinInfo = groupOperations.decryptGroupJoinInfo(groupJoinInfo);
|
||||
|
||||
assertEquals(97, decryptedGroupJoinInfo.memberCount);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void add_from_invite_link_access_control_passed_though_5_administrator() {
|
||||
GroupJoinInfo groupJoinInfo = new GroupJoinInfo.Builder()
|
||||
.addFromInviteLink(AccessControl.AccessRequired.ADMINISTRATOR)
|
||||
.build();
|
||||
|
||||
DecryptedGroupJoinInfo decryptedGroupJoinInfo = groupOperations.decryptGroupJoinInfo(groupJoinInfo);
|
||||
|
||||
assertEquals(AccessControl.AccessRequired.ADMINISTRATOR, decryptedGroupJoinInfo.addFromInviteLink);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void add_from_invite_link_access_control_passed_though_5_any() {
|
||||
GroupJoinInfo groupJoinInfo = new GroupJoinInfo.Builder()
|
||||
.addFromInviteLink(AccessControl.AccessRequired.ANY)
|
||||
.build();
|
||||
|
||||
DecryptedGroupJoinInfo decryptedGroupJoinInfo = groupOperations.decryptGroupJoinInfo(groupJoinInfo);
|
||||
|
||||
assertEquals(AccessControl.AccessRequired.ANY, decryptedGroupJoinInfo.addFromInviteLink);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void revision_passed_though_6() {
|
||||
GroupJoinInfo groupJoinInfo = new GroupJoinInfo.Builder()
|
||||
.revision(11)
|
||||
.build();
|
||||
|
||||
DecryptedGroupJoinInfo decryptedGroupJoinInfo = groupOperations.decryptGroupJoinInfo(groupJoinInfo);
|
||||
|
||||
assertEquals(11, decryptedGroupJoinInfo.revision);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void pending_approval_passed_though_7_true() {
|
||||
GroupJoinInfo groupJoinInfo = new GroupJoinInfo.Builder()
|
||||
.pendingAdminApproval(true)
|
||||
.build();
|
||||
|
||||
DecryptedGroupJoinInfo decryptedGroupJoinInfo = groupOperations.decryptGroupJoinInfo(groupJoinInfo);
|
||||
|
||||
assertTrue(decryptedGroupJoinInfo.pendingAdminApproval);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void pending_approval_passed_though_7_false() {
|
||||
GroupJoinInfo groupJoinInfo = new GroupJoinInfo.Builder()
|
||||
.pendingAdminApproval(false)
|
||||
.build();
|
||||
|
||||
DecryptedGroupJoinInfo decryptedGroupJoinInfo = groupOperations.decryptGroupJoinInfo(groupJoinInfo);
|
||||
|
||||
assertFalse(decryptedGroupJoinInfo.pendingAdminApproval);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void decrypt_description_field_8() {
|
||||
GroupJoinInfo groupJoinInfo = new GroupJoinInfo.Builder()
|
||||
.description(groupOperations.encryptDescription("Description!"))
|
||||
.build();
|
||||
|
||||
DecryptedGroupJoinInfo decryptedGroupJoinInfo = groupOperations.decryptGroupJoinInfo(groupJoinInfo);
|
||||
|
||||
assertEquals("Description!", decryptedGroupJoinInfo.description);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
package org.whispersystems.signalservice.api.groupsv2;
|
||||
|
||||
import org.junit.Before;
|
||||
import org.junit.Test;
|
||||
import org.signal.libsignal.zkgroup.InvalidInputException;
|
||||
import org.signal.libsignal.zkgroup.VerificationFailedException;
|
||||
import org.signal.libsignal.zkgroup.groups.ClientZkGroupCipher;
|
||||
import org.signal.libsignal.zkgroup.groups.GroupMasterKey;
|
||||
import org.signal.libsignal.zkgroup.groups.GroupSecretParams;
|
||||
import org.signal.libsignal.zkgroup.profiles.ProfileKey;
|
||||
import org.signal.storageservice.protos.groups.AccessControl;
|
||||
import org.signal.storageservice.protos.groups.BannedMember;
|
||||
import org.signal.storageservice.protos.groups.Group;
|
||||
import org.signal.storageservice.protos.groups.Member;
|
||||
import org.signal.storageservice.protos.groups.PendingMember;
|
||||
import org.signal.storageservice.protos.groups.RequestingMember;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedBannedMember;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedGroup;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedMember;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedPendingMember;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedRequestingMember;
|
||||
import org.signal.storageservice.protos.groups.local.EnabledState;
|
||||
import org.signal.core.models.ServiceId.ACI;
|
||||
import org.whispersystems.signalservice.internal.util.Util;
|
||||
import org.whispersystems.signalservice.testutil.LibSignalLibraryUtil;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
import okio.ByteString;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.whispersystems.signalservice.api.groupsv2.ProtobufTestUtils.getMaxDeclaredFieldNumber;
|
||||
|
||||
public final class GroupsV2Operations_decrypt_group_Test {
|
||||
|
||||
private GroupSecretParams groupSecretParams;
|
||||
private GroupsV2Operations.GroupOperations groupOperations;
|
||||
|
||||
@Before
|
||||
public void setup() throws InvalidInputException {
|
||||
LibSignalLibraryUtil.assumeLibSignalSupportedOnOS();
|
||||
|
||||
TestZkGroupServer server = new TestZkGroupServer();
|
||||
ClientZkOperations clientZkOperations = new ClientZkOperations(server.getServerPublicParams());
|
||||
|
||||
groupSecretParams = GroupSecretParams.deriveFromMasterKey(new GroupMasterKey(Util.getSecretBytes(32)));
|
||||
groupOperations = new GroupsV2Operations(clientZkOperations, 1000).forGroup(groupSecretParams);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reflects over the generated protobuf class and ensures that no new fields have been added since we wrote this.
|
||||
* <p>
|
||||
* If we didn't, newly added fields would not be decrypted by {@link GroupsV2Operations.GroupOperations#decryptGroup}.
|
||||
*/
|
||||
@Test
|
||||
public void ensure_GroupOperations_knows_about_all_fields_of_Group() {
|
||||
int maxFieldFound = getMaxDeclaredFieldNumber(Group.class);
|
||||
|
||||
assertEquals("GroupOperations and its tests need updating to account for new fields on " + Group.class.getName(),
|
||||
13, maxFieldFound);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void decrypt_title_field_2() throws VerificationFailedException, InvalidGroupStateException {
|
||||
Group group = new Group.Builder()
|
||||
.title(groupOperations.encryptTitle("Title!"))
|
||||
.build();
|
||||
|
||||
DecryptedGroup decryptedGroup = groupOperations.decryptGroup(group);
|
||||
|
||||
assertEquals("Title!", decryptedGroup.title);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void avatar_field_passed_through_3() throws VerificationFailedException, InvalidGroupStateException {
|
||||
Group group = new Group.Builder()
|
||||
.avatar("AvatarCdnKey")
|
||||
.build();
|
||||
|
||||
DecryptedGroup decryptedGroup = groupOperations.decryptGroup(group);
|
||||
|
||||
assertEquals("AvatarCdnKey", decryptedGroup.avatar);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void decrypt_message_timer_field_4() throws VerificationFailedException, InvalidGroupStateException {
|
||||
Group group = new Group.Builder()
|
||||
.disappearingMessagesTimer(groupOperations.encryptTimer(123))
|
||||
.build();
|
||||
|
||||
DecryptedGroup decryptedGroup = groupOperations.decryptGroup(group);
|
||||
|
||||
assertEquals(123, decryptedGroup.disappearingMessagesTimer.duration);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void pass_through_access_control_field_5() throws VerificationFailedException, InvalidGroupStateException {
|
||||
AccessControl accessControl = new AccessControl.Builder()
|
||||
.members(AccessControl.AccessRequired.ADMINISTRATOR)
|
||||
.attributes(AccessControl.AccessRequired.MEMBER)
|
||||
.addFromInviteLink(AccessControl.AccessRequired.UNSATISFIABLE)
|
||||
.build();
|
||||
Group group = new Group.Builder()
|
||||
.accessControl(accessControl)
|
||||
.build();
|
||||
|
||||
DecryptedGroup decryptedGroup = groupOperations.decryptGroup(group);
|
||||
|
||||
assertEquals(accessControl, decryptedGroup.accessControl);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void set_revision_field_6() throws VerificationFailedException, InvalidGroupStateException {
|
||||
Group group = new Group.Builder()
|
||||
.revision(99)
|
||||
.build();
|
||||
|
||||
DecryptedGroup decryptedGroup = groupOperations.decryptGroup(group);
|
||||
|
||||
assertEquals(99, decryptedGroup.revision);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void decrypt_full_members_field_7() throws VerificationFailedException, InvalidGroupStateException {
|
||||
ACI admin1 = ACI.from(UUID.randomUUID());
|
||||
ACI member1 = ACI.from(UUID.randomUUID());
|
||||
ProfileKey adminProfileKey = newProfileKey();
|
||||
ProfileKey memberProfileKey = newProfileKey();
|
||||
|
||||
Group group = new Group.Builder()
|
||||
.members(List.of(new Member.Builder()
|
||||
.role(Member.Role.ADMINISTRATOR)
|
||||
.userId(groupOperations.encryptServiceId(admin1))
|
||||
.joinedAtRevision(4)
|
||||
.profileKey(encryptProfileKey(admin1, adminProfileKey))
|
||||
.build(),
|
||||
new Member.Builder()
|
||||
.role(Member.Role.DEFAULT)
|
||||
.userId(groupOperations.encryptServiceId(member1))
|
||||
.joinedAtRevision(7)
|
||||
.profileKey(encryptProfileKey(member1, memberProfileKey))
|
||||
.build()))
|
||||
.build();
|
||||
|
||||
DecryptedGroup decryptedGroup = groupOperations.decryptGroup(group);
|
||||
|
||||
assertEquals(new DecryptedGroup.Builder()
|
||||
.members(List.of(new DecryptedMember.Builder()
|
||||
.joinedAtRevision(4)
|
||||
.aciBytes(admin1.toByteString())
|
||||
.role(Member.Role.ADMINISTRATOR)
|
||||
.profileKey(ByteString.of(adminProfileKey.serialize()))
|
||||
.build(),
|
||||
new DecryptedMember.Builder()
|
||||
.joinedAtRevision(7)
|
||||
.role(Member.Role.DEFAULT)
|
||||
.aciBytes(member1.toByteString())
|
||||
.profileKey(ByteString.of(memberProfileKey.serialize()))
|
||||
.build()))
|
||||
.build().members,
|
||||
decryptedGroup.members);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void decrypt_pending_members_field_8() throws VerificationFailedException, InvalidGroupStateException {
|
||||
ACI admin1 = ACI.from(UUID.randomUUID());
|
||||
ACI member1 = ACI.from(UUID.randomUUID());
|
||||
ACI member2 = ACI.from(UUID.randomUUID());
|
||||
ACI inviter1 = ACI.from(UUID.randomUUID());
|
||||
ACI inviter2 = ACI.from(UUID.randomUUID());
|
||||
|
||||
Group group = new Group.Builder()
|
||||
.pendingMembers(List.of(new PendingMember.Builder()
|
||||
.addedByUserId(groupOperations.encryptServiceId(inviter1))
|
||||
.timestamp(100)
|
||||
.member(new Member.Builder()
|
||||
.role(Member.Role.ADMINISTRATOR)
|
||||
.userId(groupOperations.encryptServiceId(admin1))
|
||||
.build())
|
||||
.build(),
|
||||
new PendingMember.Builder()
|
||||
.addedByUserId(groupOperations.encryptServiceId(inviter1))
|
||||
.timestamp(200)
|
||||
.member(new Member.Builder()
|
||||
.role(Member.Role.DEFAULT)
|
||||
.userId(groupOperations.encryptServiceId(member1))
|
||||
.build())
|
||||
.build(),
|
||||
new PendingMember.Builder()
|
||||
.addedByUserId(groupOperations.encryptServiceId(inviter2))
|
||||
.timestamp(1500)
|
||||
.member(new Member.Builder()
|
||||
.userId(groupOperations.encryptServiceId(member2)).build())
|
||||
.build()))
|
||||
.build();
|
||||
|
||||
DecryptedGroup decryptedGroup = groupOperations.decryptGroup(group);
|
||||
|
||||
assertEquals(new DecryptedGroup.Builder()
|
||||
.pendingMembers(List.of(new DecryptedPendingMember.Builder()
|
||||
.serviceIdBytes(admin1.toByteString())
|
||||
.serviceIdCipherText(groupOperations.encryptServiceId(admin1))
|
||||
.timestamp(100)
|
||||
.addedByAci(inviter1.toByteString())
|
||||
.role(Member.Role.ADMINISTRATOR)
|
||||
.build(),
|
||||
new DecryptedPendingMember.Builder()
|
||||
.serviceIdBytes(member1.toByteString())
|
||||
.serviceIdCipherText(groupOperations.encryptServiceId(member1))
|
||||
.timestamp(200)
|
||||
.addedByAci(inviter1.toByteString())
|
||||
.role(Member.Role.DEFAULT)
|
||||
.build(),
|
||||
new DecryptedPendingMember.Builder()
|
||||
.serviceIdBytes(member2.toByteString())
|
||||
.serviceIdCipherText(groupOperations.encryptServiceId(member2))
|
||||
.timestamp(1500)
|
||||
.addedByAci(inviter2.toByteString())
|
||||
.role(Member.Role.DEFAULT)
|
||||
.build()))
|
||||
.build()
|
||||
.pendingMembers,
|
||||
decryptedGroup.pendingMembers);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void decrypt_requesting_members_field_9() throws VerificationFailedException, InvalidGroupStateException {
|
||||
ACI admin1 = ACI.from(UUID.randomUUID());
|
||||
ACI member1 = ACI.from(UUID.randomUUID());
|
||||
ProfileKey adminProfileKey = newProfileKey();
|
||||
ProfileKey memberProfileKey = newProfileKey();
|
||||
|
||||
Group group = new Group.Builder()
|
||||
.requestingMembers(List.of(new RequestingMember.Builder()
|
||||
.userId(groupOperations.encryptServiceId(admin1))
|
||||
.profileKey(encryptProfileKey(admin1, adminProfileKey))
|
||||
.timestamp(5000)
|
||||
.build(),
|
||||
new RequestingMember.Builder()
|
||||
.userId(groupOperations.encryptServiceId(member1))
|
||||
.profileKey(encryptProfileKey(member1, memberProfileKey))
|
||||
.timestamp(15000)
|
||||
.build()))
|
||||
.build();
|
||||
|
||||
DecryptedGroup decryptedGroup = groupOperations.decryptGroup(group);
|
||||
|
||||
assertEquals(new DecryptedGroup.Builder()
|
||||
.requestingMembers(List.of(new DecryptedRequestingMember.Builder()
|
||||
.aciBytes(admin1.toByteString())
|
||||
.profileKey(ByteString.of(adminProfileKey.serialize()))
|
||||
.timestamp(5000)
|
||||
.build(),
|
||||
new DecryptedRequestingMember.Builder()
|
||||
.aciBytes(member1.toByteString())
|
||||
.profileKey(ByteString.of(memberProfileKey.serialize()))
|
||||
.timestamp(15000)
|
||||
.build()))
|
||||
.build()
|
||||
.requestingMembers,
|
||||
decryptedGroup.requestingMembers);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void pass_through_group_link_password_field_10() throws VerificationFailedException, InvalidGroupStateException {
|
||||
ByteString password = ByteString.of(Util.getSecretBytes(16));
|
||||
Group group = new Group.Builder()
|
||||
.inviteLinkPassword(password)
|
||||
.build();
|
||||
|
||||
DecryptedGroup decryptedGroup = groupOperations.decryptGroup(group);
|
||||
|
||||
assertEquals(password, decryptedGroup.inviteLinkPassword);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void decrypt_description_field_11() throws VerificationFailedException, InvalidGroupStateException {
|
||||
Group group = new Group.Builder()
|
||||
.description(groupOperations.encryptDescription("Description!"))
|
||||
.build();
|
||||
|
||||
DecryptedGroup decryptedGroup = groupOperations.decryptGroup(group);
|
||||
|
||||
assertEquals("Description!", decryptedGroup.description);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void decrypt_announcements_field_12() throws VerificationFailedException, InvalidGroupStateException {
|
||||
Group group = new Group.Builder()
|
||||
.announcementsOnly(true)
|
||||
.build();
|
||||
|
||||
DecryptedGroup decryptedGroup = groupOperations.decryptGroup(group);
|
||||
|
||||
assertEquals(EnabledState.ENABLED, decryptedGroup.isAnnouncementGroup);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void decrypt_banned_members_field_13() throws VerificationFailedException, InvalidGroupStateException {
|
||||
ACI member1 = ACI.from(UUID.randomUUID());
|
||||
|
||||
Group group = new Group.Builder()
|
||||
.bannedMembers(List.of(new BannedMember.Builder().userId(groupOperations.encryptServiceId(member1)).build()))
|
||||
.build();
|
||||
|
||||
DecryptedGroup decryptedGroup = groupOperations.decryptGroup(group);
|
||||
|
||||
assertEquals(1, decryptedGroup.bannedMembers.size());
|
||||
assertEquals(new DecryptedBannedMember.Builder().serviceIdBytes(member1.toByteString()).build(), decryptedGroup.bannedMembers.get(0));
|
||||
}
|
||||
|
||||
private ByteString encryptProfileKey(ACI aci, ProfileKey profileKey) {
|
||||
return ByteString.of(new ClientZkGroupCipher(groupSecretParams).encryptProfileKey(profileKey, aci.getLibSignalAci()).serialize());
|
||||
}
|
||||
|
||||
private static ProfileKey newProfileKey() {
|
||||
try {
|
||||
return new ProfileKey(Util.getSecretBytes(32));
|
||||
} catch (InvalidInputException e) {
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
package org.whispersystems.signalservice.api.groupsv2;
|
||||
|
||||
import org.signal.libsignal.zkgroup.InvalidInputException;
|
||||
import org.signal.libsignal.zkgroup.profiles.ProfileKey;
|
||||
import org.signal.storageservice.protos.groups.Member;
|
||||
import org.signal.storageservice.protos.groups.RequestingMember;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedApproveMember;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedBannedMember;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedMember;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedModifyMemberRole;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedPendingMember;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedPendingMemberRemoval;
|
||||
import org.signal.storageservice.protos.groups.local.DecryptedRequestingMember;
|
||||
import org.signal.core.util.UuidUtil;
|
||||
import org.whispersystems.signalservice.internal.util.Util;
|
||||
|
||||
import java.security.SecureRandom;
|
||||
import java.util.Arrays;
|
||||
import java.util.UUID;
|
||||
|
||||
import okio.Buffer;
|
||||
import okio.ByteString;
|
||||
|
||||
final class ProtoTestUtils {
|
||||
|
||||
static ProfileKey randomProfileKey() {
|
||||
byte[] contents = new byte[32];
|
||||
new SecureRandom().nextBytes(contents);
|
||||
try {
|
||||
return new ProfileKey(contents);
|
||||
} catch (InvalidInputException e) {
|
||||
throw new AssertionError();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emulates encryption by creating a unique {@link ByteString} that won't equal a byte string created from the {@link UUID}.
|
||||
*/
|
||||
static ByteString encrypt(UUID uuid) {
|
||||
byte[] uuidBytes = UuidUtil.toByteArray(uuid);
|
||||
return ByteString.of(Arrays.copyOf(uuidBytes, uuidBytes.length + 1));
|
||||
}
|
||||
|
||||
/**
|
||||
* Emulates a presentation by concatenating the uuid and profile key which makes it suitable for
|
||||
* equality assertions in these tests.
|
||||
*/
|
||||
static ByteString presentation(UUID uuid, ProfileKey profileKey) {
|
||||
byte[] uuidBytes = UuidUtil.toByteArray(uuid);
|
||||
byte[] profileKeyBytes = profileKey.serialize();
|
||||
byte[] concat = new byte[uuidBytes.length + profileKeyBytes.length];
|
||||
|
||||
System.arraycopy(uuidBytes, 0, concat, 0, uuidBytes.length);
|
||||
System.arraycopy(profileKeyBytes, 0, concat, uuidBytes.length, profileKeyBytes.length);
|
||||
|
||||
return ByteString.of(concat);
|
||||
}
|
||||
|
||||
/**
|
||||
* Emulates a presentation by concatenating the uuid and profile key which makes it suitable for
|
||||
* equality assertions in these tests.
|
||||
*/
|
||||
static ByteString presentation(ByteString uuid, ByteString profileKey) {
|
||||
try (Buffer buffer = new Buffer()) {
|
||||
buffer.write(uuid);
|
||||
buffer.write(profileKey);
|
||||
return buffer.readByteString();
|
||||
}
|
||||
}
|
||||
|
||||
static DecryptedModifyMemberRole promoteAdmin(UUID member) {
|
||||
return new DecryptedModifyMemberRole.Builder()
|
||||
.aciBytes(UuidUtil.toByteString(member))
|
||||
.role(Member.Role.ADMINISTRATOR)
|
||||
.build();
|
||||
}
|
||||
|
||||
static DecryptedModifyMemberRole demoteAdmin(UUID member) {
|
||||
return new DecryptedModifyMemberRole.Builder()
|
||||
.aciBytes(UuidUtil.toByteString(member))
|
||||
.role(Member.Role.DEFAULT)
|
||||
.build();
|
||||
}
|
||||
|
||||
static Member encryptedMember(UUID uuid, ProfileKey profileKey) {
|
||||
return new Member.Builder()
|
||||
.presentation(presentation(uuid, profileKey))
|
||||
.build();
|
||||
}
|
||||
|
||||
static RequestingMember encryptedRequestingMember(UUID uuid, ProfileKey profileKey) {
|
||||
return new RequestingMember.Builder()
|
||||
.presentation(presentation(uuid, profileKey))
|
||||
.build();
|
||||
}
|
||||
|
||||
static DecryptedMember member(UUID uuid) {
|
||||
return new DecryptedMember.Builder()
|
||||
.aciBytes(UuidUtil.toByteString(uuid))
|
||||
.role(Member.Role.DEFAULT)
|
||||
.build();
|
||||
}
|
||||
|
||||
static DecryptedMember member(UUID uuid, ByteString profileKey, int joinedAtRevision) {
|
||||
return new DecryptedMember.Builder()
|
||||
.aciBytes(UuidUtil.toByteString(uuid))
|
||||
.role(Member.Role.DEFAULT)
|
||||
.joinedAtRevision(joinedAtRevision)
|
||||
.profileKey(profileKey)
|
||||
.build();
|
||||
}
|
||||
|
||||
static DecryptedPendingMemberRemoval pendingMemberRemoval(UUID uuid) {
|
||||
return new DecryptedPendingMemberRemoval.Builder()
|
||||
.serviceIdBytes(UuidUtil.toByteString(uuid))
|
||||
.serviceIdCipherText(encrypt(uuid))
|
||||
.build();
|
||||
}
|
||||
|
||||
static DecryptedPendingMember pendingMember(UUID uuid) {
|
||||
return new DecryptedPendingMember.Builder()
|
||||
.serviceIdBytes(UuidUtil.toByteString(uuid))
|
||||
.serviceIdCipherText(encrypt(uuid))
|
||||
.role(Member.Role.DEFAULT)
|
||||
.build();
|
||||
}
|
||||
|
||||
static DecryptedRequestingMember requestingMember(UUID uuid) {
|
||||
return requestingMember(uuid, newProfileKey());
|
||||
}
|
||||
|
||||
static DecryptedRequestingMember requestingMember(UUID uuid, ProfileKey profileKey) {
|
||||
return new DecryptedRequestingMember.Builder()
|
||||
.aciBytes(UuidUtil.toByteString(uuid))
|
||||
.profileKey(ByteString.of(profileKey.serialize()))
|
||||
.build();
|
||||
}
|
||||
|
||||
static DecryptedBannedMember bannedMember(UUID uuid) {
|
||||
return new DecryptedBannedMember.Builder()
|
||||
.serviceIdBytes(UuidUtil.toByteString(uuid))
|
||||
.build();
|
||||
}
|
||||
|
||||
static DecryptedApproveMember approveMember(UUID uuid) {
|
||||
return approve(uuid, Member.Role.DEFAULT);
|
||||
}
|
||||
|
||||
static DecryptedApproveMember approveAdmin(UUID uuid) {
|
||||
return approve(uuid, Member.Role.ADMINISTRATOR);
|
||||
}
|
||||
|
||||
private static DecryptedApproveMember approve(UUID uuid, Member.Role role) {
|
||||
return new DecryptedApproveMember.Builder()
|
||||
.aciBytes(UuidUtil.toByteString(uuid))
|
||||
.role(role)
|
||||
.build();
|
||||
}
|
||||
|
||||
static DecryptedMember member(UUID uuid, ProfileKey profileKey) {
|
||||
return withProfileKey(member(uuid), profileKey);
|
||||
}
|
||||
|
||||
static DecryptedMember pendingPniAciMember(UUID uuid, UUID pni, ProfileKey profileKey) {
|
||||
return new DecryptedMember.Builder()
|
||||
.aciBytes(UuidUtil.toByteString(uuid))
|
||||
.pniBytes(UuidUtil.toByteString(pni))
|
||||
.profileKey(ByteString.of(profileKey.serialize()))
|
||||
.build();
|
||||
}
|
||||
|
||||
static DecryptedMember pendingPniAciMember(ByteString uuid, ByteString pni, ByteString profileKey) {
|
||||
return new DecryptedMember.Builder()
|
||||
.aciBytes(uuid)
|
||||
.pniBytes(pni)
|
||||
.profileKey(profileKey)
|
||||
.build();
|
||||
}
|
||||
|
||||
static DecryptedMember admin(UUID uuid, ProfileKey profileKey) {
|
||||
return withProfileKey(admin(uuid), profileKey);
|
||||
}
|
||||
|
||||
static DecryptedMember admin(UUID uuid) {
|
||||
return new DecryptedMember.Builder()
|
||||
.aciBytes(UuidUtil.toByteString(uuid))
|
||||
.role(Member.Role.ADMINISTRATOR)
|
||||
.build();
|
||||
}
|
||||
|
||||
static DecryptedMember withProfileKey(DecryptedMember member, ProfileKey profileKey) {
|
||||
return member.newBuilder()
|
||||
.profileKey(ByteString.of(profileKey.serialize()))
|
||||
.build();
|
||||
}
|
||||
|
||||
static DecryptedMember asAdmin(DecryptedMember member) {
|
||||
return new DecryptedMember.Builder()
|
||||
.aciBytes(member.aciBytes)
|
||||
.role(Member.Role.ADMINISTRATOR)
|
||||
.build();
|
||||
}
|
||||
|
||||
static DecryptedMember asMember(DecryptedMember member) {
|
||||
return new DecryptedMember.Builder()
|
||||
.aciBytes(member.aciBytes)
|
||||
.role(Member.Role.DEFAULT)
|
||||
.build();
|
||||
}
|
||||
|
||||
public static ProfileKey newProfileKey() {
|
||||
try {
|
||||
return new ProfileKey(Util.getSecretBytes(32));
|
||||
} catch (InvalidInputException e) {
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package org.whispersystems.signalservice.api.groupsv2;
|
||||
|
||||
import com.squareup.wire.Message;
|
||||
import com.squareup.wire.WireField;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
final class ProtobufTestUtils {
|
||||
|
||||
/** Tags that should be ignored and not count as part of 'needs support' in the various group decryption tests. */
|
||||
static final Set<Integer> IGNORED_DECRYPTED_GROUP_TAGS = Collections.singleton(64);
|
||||
|
||||
/**
|
||||
* Finds the largest declared field number in the supplied protobuf class.
|
||||
*/
|
||||
static int getMaxDeclaredFieldNumber(Class<? extends Message<?, ?>> protobufClass) {
|
||||
return getMaxDeclaredFieldNumber(protobufClass, Collections.emptySet());
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the largest declared field number in the supplied protobuf class.
|
||||
*/
|
||||
static int getMaxDeclaredFieldNumber(Class<? extends Message<?, ?>> protobufClass, Set<Integer> excludeTags) {
|
||||
return Stream.of(protobufClass.getFields())
|
||||
.map(f -> f.getAnnotationsByType(WireField.class))
|
||||
.filter(a -> a.length == 1)
|
||||
.map(a -> a[0].tag())
|
||||
.filter(t -> !excludeTags.contains(t))
|
||||
.max(Integer::compareTo)
|
||||
.orElse(0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package org.whispersystems.signalservice.api.groupsv2;
|
||||
|
||||
import org.signal.libsignal.zkgroup.ServerPublicParams;
|
||||
import org.signal.libsignal.zkgroup.ServerSecretParams;
|
||||
import org.signal.libsignal.zkgroup.VerificationFailedException;
|
||||
import org.signal.libsignal.zkgroup.groups.GroupPublicParams;
|
||||
import org.signal.libsignal.zkgroup.profiles.ExpiringProfileKeyCredentialResponse;
|
||||
import org.signal.libsignal.zkgroup.profiles.ProfileKeyCommitment;
|
||||
import org.signal.libsignal.zkgroup.profiles.ProfileKeyCredentialPresentation;
|
||||
import org.signal.libsignal.zkgroup.profiles.ProfileKeyCredentialRequest;
|
||||
import org.signal.libsignal.zkgroup.profiles.ServerZkProfileOperations;
|
||||
import org.signal.core.models.ServiceId.ACI;
|
||||
import org.whispersystems.signalservice.testutil.LibSignalLibraryUtil;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
/**
|
||||
* Provides Zk group operations that the server would provide.
|
||||
*/
|
||||
final class TestZkGroupServer {
|
||||
|
||||
private final ServerPublicParams serverPublicParams;
|
||||
private final ServerZkProfileOperations serverZkProfileOperations;
|
||||
|
||||
TestZkGroupServer() {
|
||||
LibSignalLibraryUtil.assumeLibSignalSupportedOnOS();
|
||||
|
||||
ServerSecretParams serverSecretParams = ServerSecretParams.generate();
|
||||
|
||||
serverPublicParams = serverSecretParams.getPublicParams();
|
||||
serverZkProfileOperations = new ServerZkProfileOperations(serverSecretParams);
|
||||
}
|
||||
|
||||
public ServerPublicParams getServerPublicParams() {
|
||||
return serverPublicParams;
|
||||
}
|
||||
|
||||
public ExpiringProfileKeyCredentialResponse getExpiringProfileKeyCredentialResponse(ProfileKeyCredentialRequest request, ACI aci, ProfileKeyCommitment commitment, Instant expiration) throws VerificationFailedException {
|
||||
return serverZkProfileOperations.issueExpiringProfileKeyCredential(request, aci.getLibSignalAci(), commitment, expiration);
|
||||
}
|
||||
|
||||
public void assertProfileKeyCredentialPresentation(GroupPublicParams publicParams, ProfileKeyCredentialPresentation profileKeyCredentialPresentation, Instant now) {
|
||||
try {
|
||||
serverZkProfileOperations.verifyProfileKeyCredentialPresentation(publicParams, profileKeyCredentialPresentation, now);
|
||||
} catch (VerificationFailedException e) {
|
||||
throw new AssertionError(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package org.whispersystems.signalservice.api.kbs;
|
||||
|
||||
import org.junit.Test;
|
||||
import org.signal.core.models.MasterKey;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertNotEquals;
|
||||
|
||||
public final class MasterKeyTest {
|
||||
|
||||
@Test(expected = IllegalStateException.class)
|
||||
public void wrong_length_too_short() {
|
||||
new MasterKey(new byte[31]);
|
||||
}
|
||||
|
||||
@Test(expected = IllegalStateException.class)
|
||||
public void wrong_length_too_long() {
|
||||
new MasterKey(new byte[33]);
|
||||
}
|
||||
|
||||
@Test(expected = NullPointerException.class)
|
||||
public void invalid_input_null() {
|
||||
//noinspection ConstantConditions
|
||||
new MasterKey(null);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void equality() {
|
||||
byte[] masterKeyBytes1 = new byte[32];
|
||||
byte[] masterKeyBytes2 = new byte[32];
|
||||
MasterKey masterKey1 = new MasterKey(masterKeyBytes1);
|
||||
MasterKey masterKey2 = new MasterKey(masterKeyBytes2);
|
||||
|
||||
assertEquals(masterKey1, masterKey2);
|
||||
assertEquals(masterKey1.hashCode(), masterKey2.hashCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void in_equality() {
|
||||
byte[] masterKeyBytes1 = new byte[32];
|
||||
byte[] masterKeyBytes2 = new byte[32];
|
||||
|
||||
masterKeyBytes1[0] = 1;
|
||||
|
||||
MasterKey masterKey1 = new MasterKey(masterKeyBytes1);
|
||||
MasterKey masterKey2 = new MasterKey(masterKeyBytes2);
|
||||
|
||||
assertNotEquals(masterKey1, masterKey2);
|
||||
assertNotEquals(masterKey1.hashCode(), masterKey2.hashCode());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.signalservice.api.messages
|
||||
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import org.junit.Test
|
||||
import org.signal.core.models.ServiceId
|
||||
import org.whispersystems.signalservice.internal.push.Content
|
||||
import org.whispersystems.signalservice.internal.push.DataMessage
|
||||
import org.whispersystems.signalservice.internal.push.Envelope
|
||||
|
||||
class EnvelopeContentValidatorTest {
|
||||
|
||||
companion object {
|
||||
private val SELF_ACI = ServiceId.ACI.parseOrThrow("0a5ebe7e-9de7-41a5-a25f-6ace4f8e11d1")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `validate - ensure mismatched timestamps are marked invalid`() {
|
||||
val envelope = Envelope(
|
||||
timestamp = 1234
|
||||
)
|
||||
|
||||
val content = Content(
|
||||
dataMessage = DataMessage(
|
||||
timestamp = 12345
|
||||
)
|
||||
)
|
||||
|
||||
val result = EnvelopeContentValidator.validate(envelope, content, SELF_ACI)
|
||||
assert(result is EnvelopeContentValidator.Result.Invalid)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `validate - ensure polls without a question are marked invalid`() {
|
||||
val content = Content(
|
||||
dataMessage = DataMessage(
|
||||
pollCreate = DataMessage.PollCreate(
|
||||
options = listOf("option1", "option2"),
|
||||
allowMultiple = true
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val result = EnvelopeContentValidator.validate(Envelope(), content, SELF_ACI)
|
||||
assert(result is EnvelopeContentValidator.Result.Invalid)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `validate - ensure polls with a question exceeding 100 characters are marked invalid`() {
|
||||
val content = Content(
|
||||
dataMessage = DataMessage(
|
||||
pollCreate = DataMessage.PollCreate(
|
||||
question = "abcdefghijklmnopqrstuvwxyabcdefghijklmnopqrstuvwxyabcdefghijklmnopqrstuvwxyabcdefghijklmnopqrstuvwxyz",
|
||||
options = listOf("option1", "option2"),
|
||||
allowMultiple = true
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val result = EnvelopeContentValidator.validate(Envelope(), content, SELF_ACI)
|
||||
assert(result is EnvelopeContentValidator.Result.Invalid)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `validate - ensure polls without at least two options are marked invalid`() {
|
||||
val content = Content(
|
||||
dataMessage = DataMessage(
|
||||
pollCreate = DataMessage.PollCreate(
|
||||
question = "how are you?",
|
||||
options = listOf("option1"),
|
||||
allowMultiple = true
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val result = EnvelopeContentValidator.validate(Envelope(), content, SELF_ACI)
|
||||
assert(result is EnvelopeContentValidator.Result.Invalid)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `validate - ensure poll options that exceed 100 characters are marked invalid `() {
|
||||
val content = Content(
|
||||
dataMessage = DataMessage(
|
||||
pollCreate = DataMessage.PollCreate(
|
||||
question = "how are you",
|
||||
options = listOf("abcdefghijklmnopqrstuvwxyabcdefghijklmnopqrstuvwxyabcdefghijklmnopqrstuvwxyabcdefghijklmnopqrstuvwxyz", "option2"),
|
||||
allowMultiple = true
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val result = EnvelopeContentValidator.validate(Envelope(), content, SELF_ACI)
|
||||
assert(result is EnvelopeContentValidator.Result.Invalid)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `validate - ensure polls without an explicit allow multiple votes option are marked invalid `() {
|
||||
val content = Content(
|
||||
dataMessage = DataMessage(
|
||||
pollCreate = DataMessage.PollCreate(
|
||||
question = "how are you",
|
||||
options = listOf("option1", "option2")
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val result = EnvelopeContentValidator.validate(Envelope(), content, SELF_ACI)
|
||||
assert(result is EnvelopeContentValidator.Result.Invalid)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `validate - ensure poll terminate without timestamps are marked invalid `() {
|
||||
val content = Content(
|
||||
dataMessage = DataMessage(
|
||||
pollTerminate = DataMessage.PollTerminate()
|
||||
)
|
||||
)
|
||||
|
||||
val result = EnvelopeContentValidator.validate(Envelope(), content, SELF_ACI)
|
||||
assert(result is EnvelopeContentValidator.Result.Invalid)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `validate - ensure poll votes without a valid aci are marked invalid`() {
|
||||
val content = Content(
|
||||
dataMessage = DataMessage(
|
||||
pollVote = DataMessage.PollVote(
|
||||
targetAuthorAciBinary = "bad".toByteArray().toByteString()
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val result = EnvelopeContentValidator.validate(Envelope(), content, SELF_ACI)
|
||||
assert(result is EnvelopeContentValidator.Result.Invalid)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package org.whispersystems.signalservice.api.messages.multidevice;
|
||||
|
||||
import org.junit.Test;
|
||||
import org.signal.libsignal.zkgroup.InvalidInputException;
|
||||
import org.signal.core.models.ServiceId.ACI;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
|
||||
public class DeviceContactsInputStreamTest {
|
||||
|
||||
@Test
|
||||
public void read() throws IOException, InvalidInputException {
|
||||
ByteArrayOutputStream byteArrayOut = new ByteArrayOutputStream();
|
||||
DeviceContactsOutputStream output = new DeviceContactsOutputStream(byteArrayOut, true, true);
|
||||
Optional<ACI> aciFirst = Optional.of(ACI.from(UUID.randomUUID()));
|
||||
Optional<String> e164First = Optional.of("+1404555555");
|
||||
Optional<ACI> aciSecond = Optional.of(ACI.from(UUID.randomUUID()));
|
||||
Optional<String> e164Second = Optional.of("+1444555555");
|
||||
|
||||
DeviceContact first = new DeviceContact(
|
||||
aciFirst,
|
||||
e164First,
|
||||
Optional.of("Teal'c"),
|
||||
Optional.empty(),
|
||||
Optional.of(0),
|
||||
Optional.of(0),
|
||||
Optional.of(0)
|
||||
);
|
||||
|
||||
DeviceContact second = new DeviceContact(
|
||||
aciSecond,
|
||||
e164Second,
|
||||
Optional.of("Bra'tac"),
|
||||
Optional.empty(),
|
||||
Optional.of(0),
|
||||
Optional.of(0),
|
||||
Optional.of(0)
|
||||
);
|
||||
|
||||
output.write(first);
|
||||
output.write(second);
|
||||
|
||||
output.close();
|
||||
|
||||
ByteArrayInputStream byteArrayIn = new ByteArrayInputStream(byteArrayOut.toByteArray());
|
||||
|
||||
DeviceContactsInputStream input = new DeviceContactsInputStream(byteArrayIn);
|
||||
DeviceContact readFirst = input.read();
|
||||
DeviceContact readSecond = input.read();
|
||||
|
||||
assertEquals(first.getAci(), readFirst.getAci());
|
||||
assertEquals(first.getE164(), readFirst.getE164());
|
||||
assertEquals(first.getName(), readFirst.getName());
|
||||
|
||||
assertEquals(second.getAci(), readSecond.getAci());
|
||||
assertEquals(second.getE164(), readSecond.getE164());
|
||||
assertEquals(second.getName(), readSecond.getName());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package org.whispersystems.signalservice.api.payments;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.Currency;
|
||||
import java.util.Locale;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
|
||||
public class FiatFormatterTest {
|
||||
|
||||
private static final Currency javaCurrency = Currency.getInstance("USD");
|
||||
|
||||
@Test
|
||||
public void givenAFiatCurrency_whenIFormatWithDefaultOptions_thenIExpectADefaultFormattedString() {
|
||||
// GIVEN
|
||||
Formatter formatter = Formatter.forFiat(javaCurrency, FormatterOptions.defaults(Locale.US));
|
||||
|
||||
// WHEN
|
||||
String result = formatter.format(BigDecimal.valueOf(100));
|
||||
|
||||
// THEN
|
||||
assertEquals("$100.00", result);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void givenANegative_whenIFormatWithAlwaysPositive_thenIExpectPositive() {
|
||||
// GIVEN
|
||||
FormatterOptions options = FormatterOptions.builder(Locale.US).alwaysPositive().build();
|
||||
Formatter formatter = Formatter.forFiat(javaCurrency, options);
|
||||
|
||||
// WHEN
|
||||
String result = formatter.format(BigDecimal.valueOf(-100L));
|
||||
|
||||
// THEN
|
||||
assertEquals("$100.00", result);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void givenALargeFiatCurrency_whenIFormatWithDefaultOptions_thenIExpectGrouping() {
|
||||
// GIVEN
|
||||
Formatter formatter = Formatter.forFiat(javaCurrency, FormatterOptions.defaults(Locale.US));
|
||||
|
||||
// WHEN
|
||||
String result = formatter.format(BigDecimal.valueOf(1000L));
|
||||
|
||||
// THEN
|
||||
assertEquals("$1,000.00", result);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void givenAFiatCurrency_whenIFormatWithoutUnit_thenIExpectAStringWithoutUnit() {
|
||||
// GIVEN
|
||||
Formatter formatter = Formatter.forFiat(javaCurrency, FormatterOptions.builder(Locale.US).withoutUnit().build());
|
||||
|
||||
// WHEN
|
||||
String result = formatter.format(BigDecimal.ONE);
|
||||
|
||||
// THEN
|
||||
assertEquals("1.00", result);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package org.whispersystems.signalservice.api.payments;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.Locale;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
|
||||
public class MobileCoinFormatterTest {
|
||||
|
||||
private static final Currency currency = Money.MobileCoin.CURRENCY;
|
||||
|
||||
@Test
|
||||
public void givenAMoneyCurrency_whenIFormatWithDefaultOptions_thenIExpectADefaultFormattedString() {
|
||||
// GIVEN
|
||||
Formatter formatter = Formatter.forMoney(currency, FormatterOptions.defaults(Locale.US));
|
||||
|
||||
// WHEN
|
||||
String result = formatter.format(BigDecimal.ONE);
|
||||
|
||||
// THEN
|
||||
assertEquals("1 MOB", result);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void givenALargeMoneyCurrency_whenIFormatWithDefaultOptions_thenIExpectGrouping() {
|
||||
// GIVEN
|
||||
Formatter formatter = Formatter.forMoney(currency, FormatterOptions.defaults(Locale.US));
|
||||
|
||||
// WHEN
|
||||
String result = formatter.format(BigDecimal.valueOf(-1000L));
|
||||
|
||||
// THEN
|
||||
assertEquals("-1,000 MOB", result);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void givenANegative_whenIFormatWithAlwaysPositive_thenIExpectPositive() {
|
||||
// GIVEN
|
||||
FormatterOptions options = FormatterOptions.builder(Locale.US).alwaysPositive().build();
|
||||
Formatter formatter = Formatter.forMoney(currency, options);
|
||||
|
||||
// WHEN
|
||||
String result = formatter.format(BigDecimal.valueOf(-100L));
|
||||
|
||||
// THEN
|
||||
assertEquals("100 MOB", result);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void givenAnAmount_whenIFormatWithoutSpaceBeforeUnit_thenIExpectNoSpaceBeforeUnit() {
|
||||
// GIVEN
|
||||
FormatterOptions options = FormatterOptions.builder(Locale.US).withoutSpaceBeforeUnit().build();
|
||||
Formatter formatter = Formatter.forMoney(currency, options);
|
||||
|
||||
// WHEN
|
||||
String result = formatter.format(BigDecimal.valueOf(100L));
|
||||
|
||||
// THEN
|
||||
assertEquals("100MOB", result);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void givenAnAmount_whenIFormatWithoutUnit_thenIExpectNoSpaceBeforeUnit() {
|
||||
// GIVEN
|
||||
FormatterOptions options = FormatterOptions.builder(Locale.US).withoutUnit().build();
|
||||
Formatter formatter = Formatter.forMoney(currency, options);
|
||||
|
||||
// WHEN
|
||||
String result = formatter.format(BigDecimal.valueOf(100L));
|
||||
|
||||
// THEN
|
||||
assertEquals("100", result);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void givenAnAmount_whenIFormatWithAlwaysPrefixSign_thenIExpectSignOnPositiveValues() {
|
||||
// GIVEN
|
||||
FormatterOptions options = FormatterOptions.builder(Locale.US).alwaysPrefixWithSign().build();
|
||||
Formatter formatter = Formatter.forMoney(currency, options);
|
||||
|
||||
// WHEN
|
||||
String result = formatter.format(BigDecimal.valueOf(100L));
|
||||
|
||||
// THEN
|
||||
assertEquals("+100 MOB", result);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void givenAMoneyCurrency_whenIToStringWithDefaultOptions_thenIExpectADefaultFormattedString() {
|
||||
// GIVEN
|
||||
FormatterOptions options = FormatterOptions.defaults(Locale.US);
|
||||
|
||||
// WHEN
|
||||
String result = Money.mobileCoin(BigDecimal.ONE).toString(options);
|
||||
|
||||
// THEN
|
||||
assertEquals("1 MOB", result);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void givenAnAmount_whenIToStringWithAlwaysPrefixSign_thenIExpectSignOnPositiveValues() {
|
||||
// GIVEN
|
||||
FormatterOptions options = FormatterOptions.builder(Locale.US).alwaysPrefixWithSign().build();
|
||||
|
||||
// WHEN
|
||||
String result = Money.mobileCoin(BigDecimal.ONE).toString(options);
|
||||
|
||||
// THEN
|
||||
assertEquals("+1 MOB", result);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void givenAnAmount_whenIToStringWithMaximumFractionalDigitsOf4_thenIExpectRoundingAndTruncating() {
|
||||
// GIVEN
|
||||
FormatterOptions options = FormatterOptions.builder(Locale.US).withMaximumFractionDigits(4).build();
|
||||
|
||||
// WHEN
|
||||
String result = Money.mobileCoin(BigDecimal.valueOf(1.1234567)).toString(options);
|
||||
|
||||
// THEN
|
||||
assertEquals("1.1235 MOB", result);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void givenAnAmount_whenIToStringWithMaximumFractionalDigitsOf5_thenIExpectToSee5Places() {
|
||||
// GIVEN
|
||||
FormatterOptions options = FormatterOptions.builder(Locale.US).withMaximumFractionDigits(5).build();
|
||||
|
||||
// WHEN
|
||||
String result = Money.mobileCoin(BigDecimal.valueOf(1.1234507)).toString(options);
|
||||
|
||||
// THEN
|
||||
assertEquals("1.12345 MOB", result);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void givenAnAmount_whenIToStringWithMaximumFractionalDigitsOf5ButFewerActual_thenIExpectToSeeFewerPlaces() {
|
||||
// GIVEN
|
||||
FormatterOptions options = FormatterOptions.builder(Locale.US).withMaximumFractionDigits(5).build();
|
||||
|
||||
// WHEN
|
||||
String result = Money.mobileCoin(BigDecimal.valueOf(1.120003)).toString(options);
|
||||
|
||||
// THEN
|
||||
assertEquals("1.12 MOB", result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
package org.whispersystems.signalservice.api.payments;
|
||||
|
||||
import org.junit.Test;
|
||||
import org.whispersystems.signalservice.api.util.Uint64RangeException;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.BigInteger;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertNotEquals;
|
||||
import static org.junit.Assert.assertSame;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public final class MoneyTest_MobileCoin {
|
||||
|
||||
@Test
|
||||
public void create_zero() {
|
||||
Money mobileCoin = Money.mobileCoin(BigDecimal.ZERO);
|
||||
|
||||
assertFalse(mobileCoin.isPositive());
|
||||
assertFalse(mobileCoin.isNegative());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void create_positive() {
|
||||
Money mobileCoin = Money.mobileCoin(BigDecimal.ONE);
|
||||
|
||||
assertTrue(mobileCoin.isPositive());
|
||||
assertFalse(mobileCoin.isNegative());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void create_negative() {
|
||||
Money mobileCoin = Money.mobileCoin(BigDecimal.ONE.negate());
|
||||
|
||||
assertFalse(mobileCoin.isPositive());
|
||||
assertTrue(mobileCoin.isNegative());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void toString_format() {
|
||||
Money.MobileCoin mobileCoin = Money.mobileCoin(BigDecimal.valueOf(-1000.32456));
|
||||
|
||||
assertEquals("MOB:-1000324560000000", mobileCoin.toString());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void toAmountString_format() {
|
||||
Money.MobileCoin mobileCoin = Money.mobileCoin(BigDecimal.valueOf(-1000.32456));
|
||||
|
||||
assertEquals("-1000.32456", mobileCoin.getAmountDecimalString());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void currency() {
|
||||
Money mobileCoin = Money.mobileCoin(BigDecimal.valueOf(-1000.32456));
|
||||
|
||||
assertEquals("MOB", mobileCoin.getCurrency().getCurrencyCode());
|
||||
assertEquals(12, mobileCoin.getCurrency().getDecimalPrecision());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void equality() {
|
||||
Money mobileCoin1 = Money.mobileCoin(BigDecimal.ONE);
|
||||
Money mobileCoin10 = Money.mobileCoin(BigDecimal.ONE);
|
||||
|
||||
assertEquals(mobileCoin1, mobileCoin10);
|
||||
assertEquals(mobileCoin1.hashCode(), mobileCoin10.hashCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void inequality() {
|
||||
Money mobileCoin1 = Money.mobileCoin(BigDecimal.ONE);
|
||||
Money mobileCoin10 = Money.mobileCoin(BigDecimal.TEN);
|
||||
|
||||
assertNotEquals(mobileCoin1, mobileCoin10);
|
||||
assertNotEquals(mobileCoin1.hashCode(), mobileCoin10.hashCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void negate() {
|
||||
Money money1 = Money.mobileCoin(BigDecimal.ONE);
|
||||
Money moneyNegative1 = Money.mobileCoin(BigDecimal.ONE.negate());
|
||||
Money negated = money1.negate();
|
||||
|
||||
assertEquals(moneyNegative1, negated);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void abs() {
|
||||
Money money0 = Money.mobileCoin(BigDecimal.ZERO);
|
||||
Money money1 = Money.mobileCoin(BigDecimal.ONE);
|
||||
Money moneyNegative1 = Money.mobileCoin(BigDecimal.ONE.negate());
|
||||
Money absOfZero = money0.abs();
|
||||
Money absOfPositive = money1.abs();
|
||||
Money absOfNegative = moneyNegative1.abs();
|
||||
|
||||
assertSame(money0, absOfZero);
|
||||
assertSame(money1, absOfPositive);
|
||||
assertEquals(money1, absOfNegative);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void require_cast() {
|
||||
Money money = Money.mobileCoin(BigDecimal.ONE.negate());
|
||||
Money.MobileCoin mobileCoin = money.requireMobileCoin();
|
||||
|
||||
assertSame(money, mobileCoin);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void serialize_negative() {
|
||||
Money.MobileCoin mobileCoin = Money.mobileCoin(BigDecimal.valueOf(-1000.32456));
|
||||
|
||||
assertEquals("MOB:-1000324560000000", mobileCoin.serialize());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void parse_negative() throws Money.ParseException {
|
||||
Money original = Money.mobileCoin(BigDecimal.valueOf(-1000.32456));
|
||||
String serialized = original.serialize();
|
||||
|
||||
Money deserialized = Money.parse(serialized);
|
||||
|
||||
assertEquals(original, deserialized);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void parseOrThrow() {
|
||||
Money original = Money.mobileCoin(BigDecimal.valueOf(-123.6323));
|
||||
String serialized = original.serialize();
|
||||
|
||||
Money deserialized = Money.parseOrThrow(serialized);
|
||||
|
||||
assertEquals(original, deserialized);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void parse_zero() {
|
||||
Money value = Money.parseOrThrow("MOB:0000000000000000");
|
||||
|
||||
assertSame(Money.MobileCoin.ZERO, value);
|
||||
}
|
||||
|
||||
@Test(expected = Money.ParseException.class)
|
||||
public void parse_fail_empty() throws Money.ParseException {
|
||||
Money.parse("");
|
||||
}
|
||||
|
||||
@Test(expected = Money.ParseException.class)
|
||||
public void parse_fail_null() throws Money.ParseException {
|
||||
Money.parse(null);
|
||||
}
|
||||
|
||||
@Test(expected = Money.ParseException.class)
|
||||
public void parse_fail_unknown_currency() throws Money.ParseException {
|
||||
Money.parse("XYZ:123");
|
||||
}
|
||||
|
||||
@Test(expected = Money.ParseException.class)
|
||||
public void parse_fail_no_value() throws Money.ParseException {
|
||||
Money.parse("MOB");
|
||||
}
|
||||
|
||||
@Test(expected = Money.ParseException.class)
|
||||
public void parse_fail_too_many_parts() throws Money.ParseException {
|
||||
Money.parse("MOB:1:2");
|
||||
}
|
||||
|
||||
@Test(expected = AssertionError.class)
|
||||
public void parseOrThrowOrThrow_fail_empty() {
|
||||
Money.parseOrThrow("");
|
||||
}
|
||||
|
||||
@Test(expected = AssertionError.class)
|
||||
public void parseOrThrowOrThrow_fail_null() {
|
||||
Money.parseOrThrow(null);
|
||||
}
|
||||
|
||||
@Test(expected = AssertionError.class)
|
||||
public void parseOrThrowOrThrow_fail_unknown_currency() {
|
||||
Money.parseOrThrow("XYZ:123");
|
||||
}
|
||||
|
||||
@Test(expected = AssertionError.class)
|
||||
public void parseOrThrowOrThrow_fail_no_value() {
|
||||
Money.parseOrThrow("MOB");
|
||||
}
|
||||
|
||||
@Test(expected = AssertionError.class)
|
||||
public void parseOrThrowOrThrow_fail_too_many_parts() {
|
||||
Money.parseOrThrow("MOB:1:2");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void from_big_integer_picoMobileCoin() {
|
||||
Money.MobileCoin mobileCoin1 = Money.mobileCoin(new BigDecimal("352324.325232123456"));
|
||||
Money.MobileCoin mobileCoin2 = Money.picoMobileCoin(BigInteger.valueOf(352324325232123456L));
|
||||
|
||||
assertEquals(mobileCoin1, mobileCoin2);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void from_big_integer_picoMobileCoin_zero() {
|
||||
Money.MobileCoin mobileCoin = Money.picoMobileCoin(BigInteger.ZERO);
|
||||
|
||||
assertSame(Money.MobileCoin.ZERO, mobileCoin);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void from_very_large_big_integer_picoMobileCoin() {
|
||||
Money.MobileCoin mobileCoin1 = Money.mobileCoin(new BigDecimal("352324.325232123456"));
|
||||
Money.MobileCoin mobileCoin2 = Money.picoMobileCoin(BigInteger.valueOf(352324325232123456L));
|
||||
|
||||
assertEquals(mobileCoin1, mobileCoin2);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void to_picoMob_bigInteger() {
|
||||
Money.MobileCoin mobileCoin1 = Money.mobileCoin(BigDecimal.valueOf(21324.325232));
|
||||
|
||||
BigInteger bigInteger = mobileCoin1.toPicoMobBigInteger();
|
||||
|
||||
assertEquals(BigInteger.valueOf(21324325232000000L), bigInteger);
|
||||
}
|
||||
|
||||
@Test(expected = ArithmeticException.class)
|
||||
public void precision_loss_on_creation() {
|
||||
Money.mobileCoin(new BigDecimal("10376293.0000000000001"));
|
||||
}
|
||||
|
||||
@Test(expected = ArithmeticException.class)
|
||||
public void precision_loss_on_creation_negative() {
|
||||
Money.mobileCoin(new BigDecimal("-10376293.0000000000001"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void from_picoMob() {
|
||||
Money.MobileCoin mobileCoin1 = Money.picoMobileCoin(1234567890987654321L);
|
||||
|
||||
assertEquals("1234567.890987654321", mobileCoin1.getAmountDecimalString());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void to_picoMob() throws Uint64RangeException {
|
||||
Money.MobileCoin mobileCoin1 = Money.picoMobileCoin(1234567890987654321L);
|
||||
|
||||
assertEquals(1234567890987654321L, mobileCoin1.toPicoMobUint64());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void from_large_picoMob() {
|
||||
Money.MobileCoin mobileCoin1 = Money.picoMobileCoin(0x9000000000000000L);
|
||||
|
||||
assertEquals("10376293.541461622784", mobileCoin1.getAmountDecimalString());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void from_large_negative() {
|
||||
Money.MobileCoin mobileCoin1 = Money.mobileCoin(new BigDecimal("-1234567.890987654321"));
|
||||
|
||||
assertEquals("-1234567.890987654321", mobileCoin1.getAmountDecimalString());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void to_large_picoMob() throws Uint64RangeException {
|
||||
Money.MobileCoin mobileCoin1 = Money.picoMobileCoin(0x9000000000000000L);
|
||||
|
||||
assertEquals(0x9000000000000000L, mobileCoin1.toPicoMobUint64());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void from_maximum_picoMob() {
|
||||
Money.MobileCoin mobileCoin = Money.picoMobileCoin(0xffffffffffffffffL);
|
||||
|
||||
assertEquals("18446744073709551615", mobileCoin.serializeAmountString());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void from_maximum_picoMob_and_back() throws Uint64RangeException {
|
||||
Money.MobileCoin mobileCoin = Money.picoMobileCoin(0xffffffffffffffffL);
|
||||
|
||||
assertEquals(0xffffffffffffffffL, mobileCoin.toPicoMobUint64());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void large_mobile_coin_value_exceeding_64_bits() {
|
||||
Money.MobileCoin mobileCoin = Money.mobileCoin(new BigDecimal("18446744.073709551616"));
|
||||
|
||||
assertEquals("18446744073709551616", mobileCoin.serializeAmountString());
|
||||
}
|
||||
|
||||
@Test(expected = Uint64RangeException.class)
|
||||
public void large_mobile_coin_value_exceeding_64_bits_toPicoMobUint64_failure() throws Uint64RangeException {
|
||||
Money.MobileCoin mobileCoin = Money.mobileCoin(new BigDecimal("18446744.073709551616"));
|
||||
|
||||
mobileCoin.toPicoMobUint64();
|
||||
}
|
||||
|
||||
@Test(expected = Uint64RangeException.class)
|
||||
public void negative_to_pico_mob_uint64() throws Uint64RangeException {
|
||||
Money.MobileCoin mobileCoin1 = Money.mobileCoin(new BigDecimal("-1"));
|
||||
|
||||
mobileCoin1.toPicoMobUint64();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void greater_than() {
|
||||
assertTrue(mobileCoin2(2).greaterThan(mobileCoin2(1)));
|
||||
assertTrue(mobileCoin2(-1).greaterThan(mobileCoin2(-2)));
|
||||
assertFalse(mobileCoin2(2).greaterThan(mobileCoin2(2)));
|
||||
assertFalse(mobileCoin2(1).greaterThan(mobileCoin2(2)));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void less_than() {
|
||||
assertTrue(mobileCoin2(1).lessThan(mobileCoin2(2)));
|
||||
assertTrue(mobileCoin2(-2).lessThan(mobileCoin2(-1)));
|
||||
assertFalse(mobileCoin2(2).lessThan(mobileCoin2(2)));
|
||||
assertFalse(mobileCoin2(2).lessThan(mobileCoin2(1)));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void zero_constant() {
|
||||
Money mobileCoin1 = Money.mobileCoin(BigDecimal.ZERO);
|
||||
Money mobileCoin2 = Money.MobileCoin.ZERO;
|
||||
|
||||
assertEquals(mobileCoin1, mobileCoin2);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void to_zero() {
|
||||
Money mobileCoin = Money.mobileCoin(BigDecimal.ONE);
|
||||
|
||||
assertSame(Money.MobileCoin.ZERO, mobileCoin.toZero());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void max_long_value() {
|
||||
assertEquals("MOB:18446744073709551615", Money.MobileCoin.MAX_VALUE.serialize());
|
||||
}
|
||||
|
||||
private static Money.MobileCoin mobileCoin2(double value) {
|
||||
return Money.mobileCoin(BigDecimal.valueOf(value));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package org.whispersystems.signalservice.api.payments;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
|
||||
public final class MoneyTest_MobileCoin_add {
|
||||
|
||||
@Test
|
||||
public void add_0() {
|
||||
Money mobileCoin1 = Money.mobileCoin(BigDecimal.ZERO);
|
||||
Money mobileCoin2 = Money.mobileCoin(BigDecimal.ZERO);
|
||||
|
||||
Money sum = mobileCoin1.add(mobileCoin2);
|
||||
|
||||
assertEquals(Money.mobileCoin(BigDecimal.ZERO), sum);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void add_1_rhs() {
|
||||
Money mobileCoin1 = Money.mobileCoin(BigDecimal.ZERO);
|
||||
Money mobileCoin2 = Money.mobileCoin(BigDecimal.ONE);
|
||||
|
||||
Money sum = mobileCoin1.add(mobileCoin2);
|
||||
|
||||
assertEquals(Money.mobileCoin(BigDecimal.ONE), sum);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void add_1_lhs() {
|
||||
Money mobileCoin1 = Money.mobileCoin(BigDecimal.ONE);
|
||||
Money mobileCoin2 = Money.mobileCoin(BigDecimal.ZERO);
|
||||
|
||||
Money sum = mobileCoin1.add(mobileCoin2);
|
||||
|
||||
assertEquals(Money.mobileCoin(BigDecimal.ONE), sum);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void add_2() {
|
||||
Money mobileCoin1 = Money.mobileCoin(BigDecimal.ONE);
|
||||
Money mobileCoin2 = Money.mobileCoin(BigDecimal.ONE);
|
||||
|
||||
Money sum = mobileCoin1.add(mobileCoin2);
|
||||
|
||||
assertEquals(Money.mobileCoin(BigDecimal.valueOf(2)), sum);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void add_fraction() {
|
||||
Money mobileCoin1 = Money.mobileCoin(BigDecimal.ONE);
|
||||
Money mobileCoin2 = Money.mobileCoin(BigDecimal.valueOf(2.2));
|
||||
|
||||
Money sum = mobileCoin1.add(mobileCoin2);
|
||||
|
||||
assertEquals(Money.mobileCoin(BigDecimal.valueOf(3.2)), sum);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void add_negative_fraction() {
|
||||
Money mobileCoin1 = Money.mobileCoin(BigDecimal.valueOf(-5.2));
|
||||
Money mobileCoin2 = Money.mobileCoin(BigDecimal.ONE);
|
||||
|
||||
Money sum = mobileCoin1.add(mobileCoin2);
|
||||
|
||||
assertEquals(Money.mobileCoin(BigDecimal.valueOf(-4.2)), sum);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package org.whispersystems.signalservice.api.payments;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static java.util.Arrays.asList;
|
||||
|
||||
public final class MoneyTest_MobileCoin_comparators {
|
||||
|
||||
@Test
|
||||
public void sort_ascending() {
|
||||
Money.MobileCoin mobileCoin1 = Money.mobileCoin(BigDecimal.ONE);
|
||||
Money.MobileCoin mobileCoin2 = Money.mobileCoin(BigDecimal.valueOf(2));
|
||||
List<Money.MobileCoin> list = asList(mobileCoin2, mobileCoin1);
|
||||
list.sort(Money.MobileCoin.ASCENDING);
|
||||
|
||||
assertEquals(asList(mobileCoin1, mobileCoin2), list);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void sort_descending() {
|
||||
Money.MobileCoin mobileCoin1 = Money.mobileCoin(BigDecimal.ONE);
|
||||
Money.MobileCoin mobileCoin2 = Money.mobileCoin(BigDecimal.valueOf(2));
|
||||
List<Money.MobileCoin> list = asList(mobileCoin1, mobileCoin2);
|
||||
list.sort(Money.MobileCoin.DESCENDING);
|
||||
|
||||
assertEquals(asList(mobileCoin2, mobileCoin1), list);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package org.whispersystems.signalservice.api.payments;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
|
||||
public final class MoneyTest_MobileCoin_subtract {
|
||||
|
||||
@Test
|
||||
public void subtract_0() {
|
||||
Money mobileCoin1 = Money.mobileCoin(BigDecimal.ZERO);
|
||||
Money mobileCoin2 = Money.mobileCoin(BigDecimal.ZERO);
|
||||
|
||||
Money sum = mobileCoin1.subtract(mobileCoin2);
|
||||
|
||||
assertEquals(Money.mobileCoin(BigDecimal.ZERO), sum);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void subtract_1_rhs() {
|
||||
Money mobileCoin1 = Money.mobileCoin(BigDecimal.ZERO);
|
||||
Money mobileCoin2 = Money.mobileCoin(BigDecimal.ONE);
|
||||
|
||||
Money sum = mobileCoin1.subtract(mobileCoin2);
|
||||
|
||||
assertEquals(Money.mobileCoin(BigDecimal.ONE.negate()), sum);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void subtract_1_lhs() {
|
||||
Money mobileCoin1 = Money.mobileCoin(BigDecimal.ONE);
|
||||
Money mobileCoin2 = Money.mobileCoin(BigDecimal.ZERO);
|
||||
|
||||
Money sum = mobileCoin1.subtract(mobileCoin2);
|
||||
|
||||
assertEquals(Money.mobileCoin(BigDecimal.ONE), sum);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void subtract_2() {
|
||||
Money mobileCoin1 = Money.mobileCoin(BigDecimal.ONE);
|
||||
Money mobileCoin2 = Money.mobileCoin(BigDecimal.ONE);
|
||||
|
||||
Money sum = mobileCoin1.subtract(mobileCoin2);
|
||||
|
||||
assertEquals(Money.mobileCoin(BigDecimal.ZERO), sum);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void subtract_fraction() {
|
||||
Money mobileCoin1 = Money.mobileCoin(BigDecimal.valueOf(2.2));
|
||||
Money mobileCoin2 = Money.mobileCoin(BigDecimal.ONE);
|
||||
|
||||
Money sum = mobileCoin1.subtract(mobileCoin2);
|
||||
|
||||
assertEquals(Money.mobileCoin(BigDecimal.valueOf(1.2)), sum);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void subtract_negative_fraction() {
|
||||
Money mobileCoin1 = Money.mobileCoin(BigDecimal.ONE);
|
||||
Money mobileCoin2 = Money.mobileCoin(BigDecimal.valueOf(-5.2));
|
||||
|
||||
Money sum = mobileCoin1.subtract(mobileCoin2);
|
||||
|
||||
assertEquals(Money.mobileCoin(BigDecimal.valueOf(6.2)), sum);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package org.whispersystems.signalservice.api.payments;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertSame;
|
||||
import static java.util.Arrays.asList;
|
||||
import static java.util.Collections.emptyList;
|
||||
import static java.util.Collections.singletonList;
|
||||
|
||||
public final class MoneyTest_MobileCoin_sum {
|
||||
|
||||
@Test
|
||||
public void sum_empty_list() {
|
||||
Money sum = Money.MobileCoin.sum(emptyList());
|
||||
|
||||
assertSame(Money.MobileCoin.ZERO, sum);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void sum_1() {
|
||||
Money.MobileCoin mobileCoin1 = Money.mobileCoin(BigDecimal.ONE);
|
||||
|
||||
Money sum = Money.MobileCoin.sum(singletonList(mobileCoin1));
|
||||
|
||||
assertSame(mobileCoin1, sum);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void sum_2() {
|
||||
Money.MobileCoin mobileCoin1 = Money.mobileCoin(BigDecimal.ONE);
|
||||
Money.MobileCoin mobileCoin2 = Money.mobileCoin(BigDecimal.valueOf(2));
|
||||
|
||||
Money sum = Money.MobileCoin.sum(asList(mobileCoin1, mobileCoin2));
|
||||
|
||||
assertEquals(Money.mobileCoin(BigDecimal.valueOf(3)), sum);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void sum_negatives() {
|
||||
Money.MobileCoin mobileCoin1 = Money.mobileCoin(BigDecimal.ONE);
|
||||
Money.MobileCoin mobileCoin2 = Money.mobileCoin(BigDecimal.valueOf(-2));
|
||||
|
||||
Money sum = Money.MobileCoin.sum(asList(mobileCoin1, mobileCoin2));
|
||||
|
||||
assertEquals(Money.mobileCoin(BigDecimal.valueOf(-1)), sum);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.signalservice.api.push
|
||||
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.hasSize
|
||||
import org.junit.Test
|
||||
import org.whispersystems.signalservice.internal.push.PushTransportDetails
|
||||
|
||||
class PushTransportDetailsTest {
|
||||
private val transportV3 = PushTransportDetails()
|
||||
|
||||
@Test
|
||||
fun testV3Padding() {
|
||||
(0 until 79).forEach { i ->
|
||||
val message = ByteArray(i)
|
||||
assertThat(transportV3.getPaddedMessageBody(message)).hasSize(79)
|
||||
}
|
||||
|
||||
(79 until 159).forEach { i ->
|
||||
val message = ByteArray(i)
|
||||
assertThat(transportV3.getPaddedMessageBody(message)).hasSize(159)
|
||||
}
|
||||
|
||||
(159 until 239).forEach { i ->
|
||||
val message = ByteArray(i)
|
||||
assertThat(transportV3.getPaddedMessageBody(message)).hasSize(239)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.signalservice.api.push
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
import org.signal.core.models.ServiceId
|
||||
import org.signal.core.models.ServiceId.ACI
|
||||
import org.signal.core.models.ServiceId.PNI
|
||||
import org.signal.core.util.UuidUtil
|
||||
import java.util.UUID
|
||||
import org.signal.libsignal.protocol.ServiceId.Aci as LibSignalAci
|
||||
import org.signal.libsignal.protocol.ServiceId.Pni as LibSignalPni
|
||||
|
||||
class ServiceIdTests {
|
||||
|
||||
@Test
|
||||
fun `ServiceId parseOrNull String`() {
|
||||
val uuidString = UUID.randomUUID().toString()
|
||||
|
||||
assertNull(ServiceId.parseOrNull(null as String?))
|
||||
assertNull(ServiceId.parseOrNull(""))
|
||||
assertNull(ServiceId.parseOrNull("asdf"))
|
||||
|
||||
assertEquals(ACI.from(UUID.fromString(uuidString)), ServiceId.parseOrNull(uuidString))
|
||||
assertEquals(PNI.from(UUID.fromString(uuidString)), ServiceId.parseOrNull("PNI:$uuidString"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ServiceId parseOrNull ByteArray`() {
|
||||
val uuid = UUID.randomUUID()
|
||||
val uuidString = uuid.toString()
|
||||
val uuidBytes = UuidUtil.toByteArray(uuid)
|
||||
|
||||
assertNull(ServiceId.parseOrNull(null as ByteArray?))
|
||||
assertNull(ServiceId.parseOrNull(ByteArray(0)))
|
||||
assertNull(ServiceId.parseOrNull(byteArrayOf(1, 2, 3)))
|
||||
|
||||
assertEquals(ACI.from(UUID.fromString(uuidString)), ServiceId.parseOrNull(uuidBytes))
|
||||
assertEquals(PNI.from(UUID.fromString(uuidString)), ServiceId.parseOrNull(LibSignalPni(uuid).toServiceIdBinary()))
|
||||
assertEquals(ACI.from(UUID.fromString(uuidString)), ServiceId.parseOrNull(LibSignalAci(uuid).toServiceIdFixedWidthBinary()))
|
||||
assertEquals(PNI.from(UUID.fromString(uuidString)), ServiceId.parseOrNull(LibSignalPni(uuid).toServiceIdFixedWidthBinary()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ACI parseOrNull String`() {
|
||||
val uuid = UUID.randomUUID()
|
||||
val uuidString = uuid.toString()
|
||||
|
||||
assertNull(ACI.parseOrNull(null as String?))
|
||||
assertNull(ACI.parseOrNull(""))
|
||||
assertNull(ACI.parseOrNull("asdf"))
|
||||
assertNull(ACI.parseOrNull(LibSignalPni(uuid).toServiceIdString()))
|
||||
|
||||
assertEquals(ACI.from(UUID.fromString(uuidString)), ACI.parseOrNull(uuidString))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ACI parseOrNull ByteArray`() {
|
||||
val uuid = UUID.randomUUID()
|
||||
val uuidString = uuid.toString()
|
||||
val uuidBytes = UuidUtil.toByteArray(uuid)
|
||||
|
||||
assertNull(ACI.parseOrNull(null as ByteArray?))
|
||||
assertNull(ACI.parseOrNull(ByteArray(0)))
|
||||
assertNull(ACI.parseOrNull(byteArrayOf(1, 2, 3)))
|
||||
assertNull(ACI.parseOrNull(LibSignalPni(uuid).toServiceIdBinary()))
|
||||
|
||||
assertEquals(ACI.from(UUID.fromString(uuidString)), ACI.parseOrNull(uuidBytes))
|
||||
assertEquals(ACI.from(UUID.fromString(uuidString)), ACI.parseOrNull(LibSignalAci(uuid).toServiceIdBinary()))
|
||||
assertEquals(ACI.from(UUID.fromString(uuidString)), ACI.parseOrNull(LibSignalAci(uuid).toServiceIdFixedWidthBinary()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `PNI parseOrNull String`() {
|
||||
val uuidString = UUID.randomUUID().toString()
|
||||
|
||||
assertNull(PNI.parseOrNull(null as String?))
|
||||
assertNull(PNI.parseOrNull(""))
|
||||
assertNull(PNI.parseOrNull("asdf"))
|
||||
|
||||
assertEquals(PNI.from(UUID.fromString(uuidString)), PNI.parseOrNull(uuidString))
|
||||
assertEquals(PNI.from(UUID.fromString(uuidString)), PNI.parseOrNull("PNI:$uuidString"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `PNI parseOrNull ByteArray`() {
|
||||
val uuid = UUID.randomUUID()
|
||||
val uuidString = uuid.toString()
|
||||
val uuidBytes = UuidUtil.toByteArray(uuid)
|
||||
|
||||
assertNull(PNI.parseOrNull(null as ByteArray?))
|
||||
assertNull(PNI.parseOrNull(ByteArray(0)))
|
||||
assertNull(PNI.parseOrNull(byteArrayOf(1, 2, 3)))
|
||||
assertNull(PNI.parseOrNull(LibSignalAci(uuid).toServiceIdFixedWidthBinary()))
|
||||
|
||||
assertEquals(PNI.from(UUID.fromString(uuidString)), PNI.parseOrNull(uuidBytes))
|
||||
assertEquals(PNI.from(UUID.fromString(uuidString)), PNI.parseOrNull(LibSignalPni(uuid).toServiceIdBinary()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `PNI parsePrefixedOrNull`() {
|
||||
val uuidString = UUID.randomUUID().toString()
|
||||
|
||||
assertNull(PNI.parsePrefixedOrNull(null))
|
||||
assertNull(PNI.parsePrefixedOrNull(""))
|
||||
assertNull(PNI.parsePrefixedOrNull("asdf"))
|
||||
assertNull(PNI.parsePrefixedOrNull(uuidString))
|
||||
|
||||
assertEquals(PNI.from(UUID.fromString(uuidString)), PNI.parsePrefixedOrNull("PNI:$uuidString"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package org.whispersystems.signalservice.api.services
|
||||
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.verify
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.whispersystems.signalservice.api.NetworkResult
|
||||
import org.whispersystems.signalservice.api.donations.DonationsApi
|
||||
import org.whispersystems.signalservice.api.push.exceptions.NonSuccessfulResponseCodeException
|
||||
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription
|
||||
import org.whispersystems.signalservice.api.subscriptions.SubscriberId
|
||||
|
||||
class DonationsServiceTest {
|
||||
private val donationsApi: DonationsApi = mockk<DonationsApi>()
|
||||
private val testSubject = DonationsService(donationsApi)
|
||||
private val activeSubscription = ActiveSubscription.EMPTY
|
||||
|
||||
@Test
|
||||
fun givenASubscriberId_whenIGetASuccessfulResponse_thenItIsMappedWithTheCorrectStatusCodeAndNonEmptyObject() {
|
||||
// GIVEN
|
||||
val subscriberId = SubscriberId.generate()
|
||||
every { donationsApi.getSubscription(subscriberId) } returns NetworkResult.Success(activeSubscription)
|
||||
|
||||
// WHEN
|
||||
val response = testSubject.getSubscription(subscriberId)
|
||||
|
||||
// THEN
|
||||
verify { donationsApi.getSubscription(subscriberId) }
|
||||
assertEquals(200, response.status)
|
||||
assertTrue(response.result.isPresent)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenASubscriberId_whenIGetAnUnsuccessfulResponse_thenItIsMappedWithTheCorrectStatusCodeAndEmptyObject() {
|
||||
// GIVEN
|
||||
val subscriberId = SubscriberId.generate()
|
||||
every { donationsApi.getSubscription(subscriberId) } returns NetworkResult.StatusCodeError(NonSuccessfulResponseCodeException(403))
|
||||
|
||||
// WHEN
|
||||
val response = testSubject.getSubscription(subscriberId)
|
||||
|
||||
// THEN
|
||||
verify { donationsApi.getSubscription(subscriberId) }
|
||||
assertEquals(403, response.status)
|
||||
assertFalse(response.result.isPresent)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package org.whispersystems.signalservice.api.storage;
|
||||
|
||||
import org.junit.Test;
|
||||
import org.signal.core.models.ServiceId.ACI;
|
||||
import org.whispersystems.signalservice.internal.storage.protos.ContactRecord;
|
||||
|
||||
import okio.ByteString;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertNotEquals;
|
||||
|
||||
public class SignalContactRecordTest {
|
||||
|
||||
private static final ACI ACI_A = ACI.parseOrThrow("ebef429e-695e-4f51-bcc4-526a60ac68c7");
|
||||
private static final String E164_A = "+16108675309";
|
||||
|
||||
@Test
|
||||
public void contacts_with_same_identity_key_contents_are_equal() {
|
||||
byte[] identityKey = new byte[32];
|
||||
byte[] identityKeyCopy = identityKey.clone();
|
||||
|
||||
ContactRecord contactA = contactBuilder(ACI_A, E164_A, "a").identityKey(ByteString.of(identityKey)).build();
|
||||
ContactRecord contactB = contactBuilder(ACI_A, E164_A, "a").identityKey(ByteString.of(identityKeyCopy)).build();
|
||||
|
||||
SignalContactRecord signalContactA = new SignalContactRecord(StorageId.forContact(byteArray(1)), contactA);
|
||||
SignalContactRecord signalContactB = new SignalContactRecord(StorageId.forContact(byteArray(1)), contactB);
|
||||
|
||||
assertEquals(signalContactA, signalContactB);
|
||||
assertEquals(signalContactA.hashCode(), signalContactB.hashCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void contacts_with_different_identity_key_contents_are_not_equal() {
|
||||
byte[] identityKey = new byte[32];
|
||||
byte[] identityKeyCopy = identityKey.clone();
|
||||
identityKeyCopy[0] = 1;
|
||||
|
||||
ContactRecord contactA = contactBuilder(ACI_A, E164_A, "a").identityKey(ByteString.of(identityKey)).build();
|
||||
ContactRecord contactB = contactBuilder(ACI_A, E164_A, "a").identityKey(ByteString.of(identityKeyCopy)).build();
|
||||
|
||||
SignalContactRecord signalContactA = new SignalContactRecord(StorageId.forContact(byteArray(1)), contactA);
|
||||
SignalContactRecord signalContactB = new SignalContactRecord(StorageId.forContact(byteArray(1)), contactB);
|
||||
|
||||
assertNotEquals(signalContactA, signalContactB);
|
||||
assertNotEquals(signalContactA.hashCode(), signalContactB.hashCode());
|
||||
}
|
||||
|
||||
private static byte[] byteArray(int a) {
|
||||
byte[] bytes = new byte[4];
|
||||
bytes[3] = (byte) a;
|
||||
bytes[2] = (byte)(a >> 8);
|
||||
bytes[1] = (byte)(a >> 16);
|
||||
bytes[0] = (byte)(a >> 24);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
private static ContactRecord.Builder contactBuilder(ACI serviceId, String e164, String givenName) {
|
||||
return new ContactRecord.Builder()
|
||||
.e164(e164)
|
||||
.givenName(givenName);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package org.whispersystems.signalservice.api.storage;
|
||||
|
||||
import org.junit.Test;
|
||||
import org.signal.core.models.storageservice.StorageItemKey;
|
||||
import org.signal.libsignal.protocol.InvalidKeyException;
|
||||
import org.whispersystems.signalservice.internal.util.Util;
|
||||
|
||||
import static org.junit.Assert.assertArrayEquals;
|
||||
|
||||
public class SignalStorageCipherTest {
|
||||
|
||||
@Test
|
||||
public void symmetry() throws InvalidKeyException {
|
||||
StorageItemKey key = new StorageItemKey(Util.getSecretBytes(32));
|
||||
byte[] data = Util.getSecretBytes(1337);
|
||||
|
||||
byte[] ciphertext = SignalStorageCipher.encrypt(key, data);
|
||||
byte[] plaintext = SignalStorageCipher.decrypt(key, ciphertext);
|
||||
|
||||
assertArrayEquals(data, plaintext);
|
||||
}
|
||||
|
||||
@Test(expected = InvalidKeyException.class)
|
||||
public void badKeyOnDecrypt() throws InvalidKeyException {
|
||||
StorageItemKey key = new StorageItemKey(Util.getSecretBytes(32));
|
||||
byte[] data = Util.getSecretBytes(1337);
|
||||
|
||||
byte[] badKey = key.serialize().clone();
|
||||
badKey[0] += 1;
|
||||
|
||||
byte[] ciphertext = SignalStorageCipher.encrypt(key, data);
|
||||
byte[] plaintext = SignalStorageCipher.decrypt(new StorageItemKey(badKey), ciphertext);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package org.whispersystems.signalservice.api.subscriptions;
|
||||
|
||||
import org.junit.Test;
|
||||
import org.whispersystems.signalservice.internal.util.JsonUtil;
|
||||
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class ActiveSubscriptionTest {
|
||||
@Test
|
||||
public void givenActiveSubscription_whenIIsPaymentFailure_thenIExpectFalse() throws Exception {
|
||||
String input = "{\"subscription\":{\"level\":2000,\"billingCycleAnchor\":1636124746.000000000,\"endOfCurrentPeriod\":1675609546.000000000,\"active\":true,\"cancelAtPeriodEnd\":false,\"currency\":\"USD\",\"amount\":2000,\"status\":\"active\"},\"chargeFailure\":null}";
|
||||
ActiveSubscription activeSubscription = JsonUtil.fromJson(input, ActiveSubscription.class);
|
||||
|
||||
assertTrue(activeSubscription.isActive());
|
||||
assertFalse(activeSubscription.isFailedPayment());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void givenNoActiveSubscription_whenIIsInProgress_thenIExpectFalse() throws Exception {
|
||||
ActiveSubscription activeSubscription = new ActiveSubscription(null, null);
|
||||
|
||||
assertFalse(activeSubscription.isInProgress());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
package org.whispersystems.signalservice.api.util
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertThrows
|
||||
import org.junit.Test
|
||||
import org.signal.core.models.ServiceId
|
||||
import org.whispersystems.signalservice.api.push.SignalServiceAddress
|
||||
import java.util.UUID
|
||||
|
||||
class CredentialsProviderTest {
|
||||
private fun makeProvider(aci: UUID?, deviceId: Int = SignalServiceAddress.DEFAULT_DEVICE_ID): CredentialsProvider {
|
||||
return object : CredentialsProvider {
|
||||
override fun getAci(): ServiceId.ACI? {
|
||||
if (aci == null) {
|
||||
return null
|
||||
}
|
||||
return ServiceId.ACI.from(aci)
|
||||
}
|
||||
|
||||
override fun getPni(): ServiceId.PNI {
|
||||
TODO("Not used")
|
||||
}
|
||||
|
||||
override fun getE164(): String {
|
||||
TODO("Not used")
|
||||
}
|
||||
|
||||
override fun getDeviceId(): Int {
|
||||
return deviceId
|
||||
}
|
||||
|
||||
override fun getPassword(): String {
|
||||
TODO("Not used")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun usernameWithDefaultDeviceId() {
|
||||
val uuid = UUID.randomUUID()
|
||||
assertEquals(uuid.toString(), makeProvider(uuid).username)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun usernameWithDeviceId() {
|
||||
val uuid = UUID.randomUUID()
|
||||
assertEquals("$uuid.42", makeProvider(uuid, 42).username)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun usernameWithNullAci() {
|
||||
assertThrows(NullPointerException::class.java) { makeProvider(aci = null).username }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package org.whispersystems.signalservice.api.util;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertNotEquals;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public final class OptionalUtilTest {
|
||||
|
||||
@Test
|
||||
public void absent_are_equal() {
|
||||
assertTrue(OptionalUtil.byteArrayEquals(Optional.empty(), Optional.empty()));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void first_non_absent_not_equal() {
|
||||
assertFalse(OptionalUtil.byteArrayEquals(Optional.of(new byte[1]), Optional.empty()));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void second_non_absent_not_equal() {
|
||||
assertFalse(OptionalUtil.byteArrayEquals(Optional.empty(), Optional.of(new byte[1])));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void equal_contents() {
|
||||
byte[] contentsA = new byte[]{1, 2, 3};
|
||||
byte[] contentsB = contentsA.clone();
|
||||
Optional<byte[]> a = Optional.of(contentsA);
|
||||
Optional<byte[]> b = Optional.of(contentsB);
|
||||
assertTrue(OptionalUtil.byteArrayEquals(a, b));
|
||||
assertEquals(OptionalUtil.byteArrayHashCode(a), OptionalUtil.byteArrayHashCode(b));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void in_equal_contents() {
|
||||
byte[] contentsA = new byte[]{1, 2, 3};
|
||||
byte[] contentsB = new byte[]{4, 5, 6};
|
||||
Optional<byte[]> a = Optional.of(contentsA);
|
||||
Optional<byte[]> b = Optional.of(contentsB);
|
||||
assertFalse(OptionalUtil.byteArrayEquals(a, b));
|
||||
assertNotEquals(OptionalUtil.byteArrayHashCode(a), OptionalUtil.byteArrayHashCode(b));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void hash_code_absent() {
|
||||
assertEquals(0, OptionalUtil.byteArrayHashCode(Optional.empty()));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void or_singleAbsent() {
|
||||
assertFalse(OptionalUtil.or(Optional.empty()).isPresent());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void or_multipleAbsent() {
|
||||
assertFalse(OptionalUtil.or(Optional.empty(), Optional.empty()).isPresent());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void or_firstHasValue() {
|
||||
assertEquals(5, OptionalUtil.or(Optional.of(5), Optional.empty()).get().longValue());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void or_secondHasValue() {
|
||||
assertEquals(5, OptionalUtil.or(Optional.empty(), Optional.of(5)).get().longValue());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package org.whispersystems.signalservice.api.util;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import java.math.BigInteger;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.whispersystems.signalservice.api.util.Uint64Util.bigIntegerToUInt64;
|
||||
import static org.whispersystems.signalservice.api.util.Uint64Util.uint64ToBigInteger;
|
||||
|
||||
public final class Uint64UtilTest {
|
||||
|
||||
@Test
|
||||
public void long_zero_to_bigInteger() {
|
||||
BigInteger bigInteger = uint64ToBigInteger(0);
|
||||
|
||||
assertEquals("0", bigInteger.toString());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void long_to_bigInteger() {
|
||||
BigInteger bigInteger = uint64ToBigInteger(12345L);
|
||||
|
||||
assertEquals("12345", bigInteger.toString());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void bigInteger_zero_to_long() throws Uint64RangeException {
|
||||
long uint64 = bigIntegerToUInt64(BigInteger.ZERO);
|
||||
|
||||
assertEquals(0, uint64);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void first_uint64_value_to_bigInteger() {
|
||||
BigInteger bigInteger = uint64ToBigInteger(0x8000000000000000L);
|
||||
|
||||
assertEquals("9223372036854775808", bigInteger.toString());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void bigInteger_to_first_uint64_value() throws Uint64RangeException {
|
||||
long uint64 = bigIntegerToUInt64(new BigInteger("9223372036854775808"));
|
||||
|
||||
assertEquals(0x8000000000000000L, uint64);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void large_uint64_value_to_bigInteger() {
|
||||
BigInteger bigInteger = uint64ToBigInteger(0xa523f21e412c14d2L);
|
||||
|
||||
assertEquals("11899620852199331026", bigInteger.toString());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void bigInteger_to_large_uint64_value() throws Uint64RangeException {
|
||||
long uint64 = bigIntegerToUInt64(new BigInteger("11899620852199331026"));
|
||||
|
||||
assertEquals(0xa523f21e412c14d2L, uint64);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void largest_uint64_value_to_bigInteger() {
|
||||
BigInteger bigInteger = uint64ToBigInteger(0xffffffffffffffffL);
|
||||
|
||||
assertEquals("18446744073709551615", bigInteger.toString());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void bigInteger_to_largest_uint64_value() throws Uint64RangeException {
|
||||
long uint64 = bigIntegerToUInt64(new BigInteger("18446744073709551615"));
|
||||
|
||||
assertEquals(0xffffffffffffffffL, uint64);
|
||||
}
|
||||
|
||||
@Test(expected = Uint64RangeException.class)
|
||||
public void too_big_by_one() throws Uint64RangeException {
|
||||
bigIntegerToUInt64(new BigInteger("18446744073709551616"));
|
||||
}
|
||||
|
||||
@Test(expected = Uint64RangeException.class)
|
||||
public void too_small_by_one() throws Uint64RangeException {
|
||||
bigIntegerToUInt64(new BigInteger("-1"));
|
||||
}
|
||||
|
||||
@Test(expected = Uint64RangeException.class)
|
||||
public void too_big_by_a_lot() throws Uint64RangeException {
|
||||
bigIntegerToUInt64(new BigInteger("1844674407370955161623"));
|
||||
}
|
||||
|
||||
@Test(expected = Uint64RangeException.class)
|
||||
public void too_small_by_a_lot() throws Uint64RangeException {
|
||||
bigIntegerToUInt64(new BigInteger("-1844674407370955161623"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
package org.whispersystems.signalservice.internal.crypto
|
||||
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.isEqualTo
|
||||
import org.junit.Test
|
||||
import org.signal.core.util.StreamUtil
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.ByteArrayOutputStream
|
||||
|
||||
class PaddingInputStreamTest {
|
||||
/**
|
||||
* Small stress test to confirm padding input only returns the source stream data
|
||||
* followed strictly by zeros.
|
||||
*/
|
||||
@Test
|
||||
fun stressTest() {
|
||||
(0..2048).forEach { length ->
|
||||
val source = ByteArray(length).apply { fill(42) }
|
||||
val sourceInput = ByteArrayInputStream(source)
|
||||
val paddingInput = PaddingInputStream(sourceInput, length.toLong())
|
||||
|
||||
val paddedData = ByteArrayOutputStream().let {
|
||||
StreamUtil.copy(paddingInput, it)
|
||||
it.toByteArray()
|
||||
}
|
||||
|
||||
paddedData.forEachIndexed { index, byte ->
|
||||
if (index < length) {
|
||||
assertThat(byte).isEqualTo(source[index])
|
||||
} else {
|
||||
assertThat(byte).isEqualTo(0x00)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.signalservice.internal.crypto
|
||||
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.isEqualTo
|
||||
import assertk.assertions.isInstanceOf
|
||||
import okio.ByteString
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import org.junit.Test
|
||||
import org.signal.core.util.UuidUtil
|
||||
import org.signal.core.util.toByteArray
|
||||
import org.signal.libsignal.protocol.IdentityKey
|
||||
import org.signal.libsignal.protocol.IdentityKeyPair
|
||||
import org.signal.libsignal.protocol.ecc.ECPrivateKey
|
||||
import org.signal.libsignal.zkgroup.profiles.ProfileKey
|
||||
import org.whispersystems.signalservice.internal.push.ProvisionEnvelope
|
||||
import org.whispersystems.signalservice.internal.push.ProvisionMessage
|
||||
import org.whispersystems.signalservice.internal.push.ProvisioningVersion
|
||||
import java.util.UUID
|
||||
import kotlin.random.Random
|
||||
|
||||
class SecondaryProvisioningCipherTest {
|
||||
@Test
|
||||
fun decrypt() {
|
||||
val provisioningCipher = SecondaryProvisioningCipher.generate(IdentityKeyPair.generate())
|
||||
|
||||
val primaryIdentityKeyPair = IdentityKeyPair.generate()
|
||||
val primaryProfileKey = generateProfileKey()
|
||||
val primaryProvisioningCipher = PrimaryProvisioningCipher(provisioningCipher.secondaryDevicePublicKey.publicKey)
|
||||
val aci = UUID.randomUUID()
|
||||
|
||||
val message = ProvisionMessage(
|
||||
aciIdentityKeyPublic = ByteString.of(*primaryIdentityKeyPair.publicKey.serialize()),
|
||||
aciIdentityKeyPrivate = ByteString.of(*primaryIdentityKeyPair.privateKey.serialize()),
|
||||
provisioningCode = "code",
|
||||
provisioningVersion = ProvisioningVersion.CURRENT.value,
|
||||
number = "+14045555555",
|
||||
aci = aci.toString(),
|
||||
profileKey = ByteString.of(*primaryProfileKey.serialize()),
|
||||
readReceipts = true,
|
||||
aciBinary = aci.toByteArray().toByteString()
|
||||
)
|
||||
|
||||
val provisionMessage = ProvisionEnvelope.ADAPTER.decode(primaryProvisioningCipher.encrypt(message))
|
||||
|
||||
val result = provisioningCipher.decrypt(provisionMessage)
|
||||
assertThat(result).isInstanceOf<SecondaryProvisioningCipher.ProvisioningDecryptResult.Success<ProvisionMessage>>()
|
||||
|
||||
val success = result as SecondaryProvisioningCipher.ProvisioningDecryptResult.Success<ProvisionMessage>
|
||||
|
||||
assertThat(message.aci).isEqualTo(UuidUtil.parseOrThrow(success.message.aci!!).toString())
|
||||
assertThat(message.number).isEqualTo(success.message.number)
|
||||
assertThat(primaryIdentityKeyPair.serialize()).isEqualTo(IdentityKeyPair(IdentityKey(success.message.aciIdentityKeyPublic!!.toByteArray()), ECPrivateKey(success.message.aciIdentityKeyPrivate!!.toByteArray())).serialize())
|
||||
assertThat(primaryProfileKey.serialize()).isEqualTo(ProfileKey(success.message.profileKey!!.toByteArray()).serialize())
|
||||
assertThat(message.readReceipts).isEqualTo(success.message.readReceipts == true)
|
||||
assertThat(message.userAgent).isEqualTo(success.message.userAgent)
|
||||
assertThat(message.provisioningCode).isEqualTo(success.message.provisioningCode!!)
|
||||
assertThat(message.provisioningVersion).isEqualTo(success.message.provisioningVersion!!)
|
||||
assertThat(message.aciBinary).isEqualTo(UuidUtil.parseOrThrow(success.message.aciBinary!!).toByteArray().toByteString())
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun generateProfileKey(): ProfileKey {
|
||||
return ProfileKey(Random.nextBytes(32))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package org.whispersystems.signalservice.internal.push
|
||||
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.isEqualTo
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.junit.runners.Parameterized
|
||||
|
||||
@RunWith(Parameterized::class)
|
||||
class ContentRange_parse_Test(
|
||||
private val input: String?,
|
||||
private val expectedRangeStart: Int,
|
||||
private val expectedRangeEnd: Int,
|
||||
private val expectedSize: Int
|
||||
) {
|
||||
@Test
|
||||
fun rangeStart() {
|
||||
assertThat(ContentRange.parse(input).get().rangeStart).isEqualTo(expectedRangeStart)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rangeEnd() {
|
||||
assertThat(ContentRange.parse(input).get().rangeEnd).isEqualTo(expectedRangeEnd)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun totalSize() {
|
||||
assertThat(ContentRange.parse(input).get().totalSize).isEqualTo(expectedSize)
|
||||
}
|
||||
|
||||
companion object {
|
||||
@JvmStatic
|
||||
@Parameterized.Parameters(name = "Content-Range: \"{0}\"")
|
||||
fun data(): Collection<Array<Any>> {
|
||||
return listOf(
|
||||
arrayOf("versions 1-2/3", 1, 2, 3),
|
||||
arrayOf("versions 23-45/67", 23, 45, 67)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package org.whispersystems.signalservice.internal.push
|
||||
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.isFalse
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.junit.runners.Parameterized
|
||||
|
||||
@RunWith(Parameterized::class)
|
||||
class ContentRange_parse_withInvalidStrings_Test(private val input: String?) {
|
||||
@Test
|
||||
fun parse_should_be_absent() {
|
||||
assertThat(ContentRange.parse(input).isPresent).isFalse()
|
||||
}
|
||||
|
||||
companion object {
|
||||
@JvmStatic
|
||||
@Parameterized.Parameters(name = "Content-Range: \"{0}\"")
|
||||
fun data(): List<Array<String?>> {
|
||||
return listOf(
|
||||
arrayOf(null),
|
||||
arrayOf(""),
|
||||
arrayOf("23-45/67"),
|
||||
arrayOf("ersions 23-45/67"),
|
||||
arrayOf("versions 23-45"),
|
||||
arrayOf("versions a-b/c")
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package org.whispersystems.signalservice.internal.push
|
||||
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.containsExactly
|
||||
import assertk.assertions.hasSize
|
||||
import assertk.assertions.isEqualTo
|
||||
import org.junit.Test
|
||||
import org.whispersystems.signalservice.internal.util.JsonUtil
|
||||
|
||||
class GroupMismatchedDevicesTest {
|
||||
@Test
|
||||
fun testSimpleParse() {
|
||||
val json = """
|
||||
[
|
||||
{
|
||||
"uuid": "12345678-1234-1234-1234-123456789012",
|
||||
"devices": {
|
||||
"missingDevices": [1, 2],
|
||||
"extraDevices": [3]
|
||||
}
|
||||
},
|
||||
{
|
||||
"uuid": "22345678-1234-1234-1234-123456789012",
|
||||
"devices": {
|
||||
"missingDevices": [],
|
||||
"extraDevices": [2]
|
||||
}
|
||||
}
|
||||
]
|
||||
""".trimIndent()
|
||||
val parsed = JsonUtil.fromJson(json, Array<GroupMismatchedDevices>::class.java)
|
||||
|
||||
assertThat(parsed).hasSize(2)
|
||||
val (first, second) = parsed
|
||||
|
||||
assertThat(first.uuid).isEqualTo("12345678-1234-1234-1234-123456789012")
|
||||
assertThat(first.devices.missingDevices).containsExactly(1, 2)
|
||||
assertThat(first.devices.extraDevices).containsExactly(3)
|
||||
|
||||
assertThat(second.uuid).isEqualTo("22345678-1234-1234-1234-123456789012")
|
||||
assertThat(second.devices.extraDevices).containsExactly(2)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.signalservice.internal.push
|
||||
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.containsExactly
|
||||
import assertk.assertions.hasSize
|
||||
import assertk.assertions.isEqualTo
|
||||
import org.junit.Test
|
||||
import org.whispersystems.signalservice.internal.util.JsonUtil
|
||||
|
||||
class GroupStaleDevicesTest {
|
||||
@Test
|
||||
fun testSimpleParse() {
|
||||
val json = """
|
||||
[
|
||||
{
|
||||
"uuid": "12345678-1234-1234-1234-123456789012",
|
||||
"devices": {
|
||||
"staleDevices": [3]
|
||||
}
|
||||
},
|
||||
{
|
||||
"uuid": "22345678-1234-1234-1234-123456789012",
|
||||
"devices": {
|
||||
"staleDevices": [2]
|
||||
}
|
||||
}
|
||||
]
|
||||
""".trimIndent()
|
||||
|
||||
val parsed: Array<GroupStaleDevices> = JsonUtil.fromJson(json, Array<GroupStaleDevices>::class.java)
|
||||
|
||||
assertThat(parsed).hasSize(2)
|
||||
val (first, second) = parsed
|
||||
|
||||
assertThat(first.uuid).isEqualTo("12345678-1234-1234-1234-123456789012")
|
||||
assertThat(first.devices.staleDevices).containsExactly(3)
|
||||
|
||||
assertThat(second.uuid).isEqualTo("22345678-1234-1234-1234-123456789012")
|
||||
assertThat(second.devices.staleDevices).containsExactly(2)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.signalservice.internal.push.exceptions
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
import org.whispersystems.signalservice.api.subscriptions.ActiveSubscription
|
||||
import org.whispersystems.signalservice.internal.util.JsonUtil
|
||||
|
||||
class InAppPaymentProcessorErrorTest {
|
||||
|
||||
companion object {
|
||||
private val TEST_PROCESSOR = ActiveSubscription.Processor.STRIPE
|
||||
private const val TEST_CODE = "account_closed"
|
||||
private const val TEST_MESSAGE = "test_message"
|
||||
private const val TEST_OUTCOME_NETWORK_STATUS = "test_outcomeNetworkStatus"
|
||||
private const val TEST_OUTCOME_REASON = "test_outcomeReason"
|
||||
private const val TEST_OUTCOME_TYPE = "test_outcomeType"
|
||||
private val TEST_JSON = """
|
||||
{
|
||||
"processor": "${TEST_PROCESSOR.code}",
|
||||
"chargeFailure": {
|
||||
"code": "$TEST_CODE",
|
||||
"message": "$TEST_MESSAGE",
|
||||
"outcomeNetworkStatus": "$TEST_OUTCOME_NETWORK_STATUS",
|
||||
"outcomeReason": "$TEST_OUTCOME_REASON",
|
||||
"outcomeType": "$TEST_OUTCOME_TYPE"
|
||||
}
|
||||
|
||||
}
|
||||
""".trimIndent()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenTestJson_whenIFromJson_thenIExpectProperlyParsedError() {
|
||||
val result = JsonUtil.fromJson(TEST_JSON, InAppPaymentProcessorError::class.java)
|
||||
|
||||
assertEquals(TEST_PROCESSOR, result.processor)
|
||||
assertEquals(TEST_CODE, result.chargeFailure.code)
|
||||
assertEquals(TEST_OUTCOME_TYPE, result.chargeFailure.outcomeType)
|
||||
assertEquals(TEST_OUTCOME_NETWORK_STATUS, result.chargeFailure.outcomeNetworkStatus)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
/*
|
||||
* Copyright 2023 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.whispersystems.signalservice.internal.push.http
|
||||
|
||||
import okio.Buffer
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Test
|
||||
import org.whispersystems.signalservice.api.crypto.AttachmentCipherStreamUtil
|
||||
import org.whispersystems.signalservice.api.messages.AttachmentTransferProgress
|
||||
import org.whispersystems.signalservice.api.messages.SignalServiceAttachment
|
||||
import org.whispersystems.signalservice.internal.util.Util
|
||||
import java.io.ByteArrayInputStream
|
||||
|
||||
class DigestingRequestBodyTest {
|
||||
private val attachmentKey = Util.getSecretBytes(64)
|
||||
private val attachmentIV = Util.getSecretBytes(16)
|
||||
private val input = Util.getSecretBytes(CONTENT_LENGTH)
|
||||
|
||||
private val outputStreamFactory = AttachmentCipherOutputStreamFactory(attachmentKey, attachmentIV)
|
||||
|
||||
@Test
|
||||
fun givenSameKeyAndIV_whenIWriteToBuffer_thenIExpectSameDigests() {
|
||||
val fromStart = getBody(0)
|
||||
val fromMiddle = getBody(CONTENT_LENGTH / 2L)
|
||||
|
||||
Buffer().use { buffer ->
|
||||
fromStart.writeTo(buffer)
|
||||
}
|
||||
|
||||
Buffer().use { buffer ->
|
||||
fromMiddle.writeTo(buffer)
|
||||
}
|
||||
|
||||
val fullResult = fromStart.attachmentDigest
|
||||
assertNotNull(fullResult)
|
||||
|
||||
val partialResult = fromMiddle.attachmentDigest
|
||||
assertNotNull(partialResult)
|
||||
|
||||
assertArrayEquals(fullResult?.digest, partialResult?.digest)
|
||||
assertArrayEquals(fullResult?.incrementalDigest, partialResult?.incrementalDigest)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun givenSameKeyAndIV_whenIWriteToBuffer_thenIExpectSameContents() {
|
||||
val fromStart = getBody(0)
|
||||
val fromMiddle = getBody(CONTENT_LENGTH / 2L)
|
||||
|
||||
val cipher1: ByteArray
|
||||
|
||||
Buffer().use { buffer ->
|
||||
fromStart.writeTo(buffer)
|
||||
cipher1 = buffer.readByteArray()
|
||||
}
|
||||
|
||||
val cipher2: ByteArray
|
||||
|
||||
Buffer().use { buffer ->
|
||||
fromMiddle.writeTo(buffer)
|
||||
cipher2 = buffer.readByteArray()
|
||||
}
|
||||
|
||||
assertEquals(cipher1.size, TOTAL_LENGTH)
|
||||
assertEquals(cipher2.size, TOTAL_LENGTH - (CONTENT_LENGTH / 2))
|
||||
|
||||
cipher2.indices.forEach { i ->
|
||||
assertEquals(cipher2[i], cipher1[i + (CONTENT_LENGTH / 2)])
|
||||
}
|
||||
}
|
||||
|
||||
private fun getBody(contentStart: Long): DigestingRequestBody {
|
||||
return DigestingRequestBody(
|
||||
inputStream = ByteArrayInputStream(input),
|
||||
outputStreamFactory = outputStreamFactory,
|
||||
contentType = "application/octet",
|
||||
contentLength = CONTENT_LENGTH.toLong(),
|
||||
incremental = false,
|
||||
progressListener = object : SignalServiceAttachment.ProgressListener {
|
||||
override fun onAttachmentProgress(progress: AttachmentTransferProgress) {
|
||||
// no-op
|
||||
}
|
||||
|
||||
override fun shouldCancel() = false
|
||||
},
|
||||
cancelationSignal = { false },
|
||||
contentStart = contentStart
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val CONTENT_LENGTH = 70_000
|
||||
private val TOTAL_LENGTH = AttachmentCipherStreamUtil.getCiphertextLength(CONTENT_LENGTH.toLong()).toInt()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,602 @@
|
||||
package org.whispersystems.signalservice.internal.websocket
|
||||
|
||||
import io.mockk.clearAllMocks
|
||||
import io.mockk.clearMocks
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.verify
|
||||
import io.reactivex.rxjava3.observers.TestObserver
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertThrows
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Ignore
|
||||
import org.junit.Test
|
||||
import org.signal.libsignal.internal.CompletableFuture
|
||||
import org.signal.libsignal.net.ChatConnection
|
||||
import org.signal.libsignal.net.ChatConnectionListener
|
||||
import org.signal.libsignal.net.ChatServiceException
|
||||
import org.signal.libsignal.net.Network
|
||||
import org.signal.libsignal.net.UnauthenticatedChatConnection
|
||||
import org.whispersystems.signalservice.api.websocket.HealthMonitor
|
||||
import org.whispersystems.signalservice.api.websocket.WebSocketConnectionState
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.ExecutorService
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.TimeoutException
|
||||
|
||||
class LibSignalChatConnectionTest {
|
||||
|
||||
private val executor: ExecutorService = Executors.newSingleThreadExecutor()
|
||||
private val healthMonitor = mockk<HealthMonitor>()
|
||||
private val network = mockk<Network>()
|
||||
private val connection = LibSignalChatConnection("test", network, null, false, healthMonitor)
|
||||
private val chatConnection = mockk<UnauthenticatedChatConnection>()
|
||||
private var chatListener: ChatConnectionListener? = null
|
||||
|
||||
// Used by default-success mocks for ChatConnection behavior.
|
||||
private var connectLatch: CountDownLatch? = null
|
||||
private var disconnectLatch: CountDownLatch? = null
|
||||
private var sendLatch: CountDownLatch? = null
|
||||
|
||||
private fun setupConnectedConnection() {
|
||||
connectLatch = CountDownLatch(1)
|
||||
connection.connect()
|
||||
connectLatch!!.await(100, TimeUnit.MILLISECONDS)
|
||||
}
|
||||
|
||||
@Before
|
||||
fun before() {
|
||||
clearAllMocks()
|
||||
every { healthMonitor.onMessageError(any(), any()) }
|
||||
every { healthMonitor.onKeepAliveResponse(any(), any()) }
|
||||
|
||||
// NB: We provide default success behavior mocks here to cut down on boilerplate later, but it is
|
||||
// expected that some tests will override some of these to test failures.
|
||||
//
|
||||
// We provide a null credentials provider when creating `connection`, so LibSignalChatConnection
|
||||
// should always call connectUnauthChat()
|
||||
// TODO: Maybe also test Auth? The old one didn't.
|
||||
every { network.connectUnauthChat(any()) } answers {
|
||||
chatListener = firstArg()
|
||||
delay {
|
||||
it.complete(chatConnection)
|
||||
connectLatch?.countDown()
|
||||
}
|
||||
}
|
||||
|
||||
every { chatConnection.disconnect() } answers {
|
||||
delay {
|
||||
it.complete(null)
|
||||
disconnectLatch?.countDown()
|
||||
|
||||
// The disconnectReason is null when the disconnect is due to the local client requesting the disconnect.
|
||||
// This is a regression test because we previously forgot to update the Kotlin type definitions to
|
||||
// match this when the behavior changed in libsignal-client, causing NullPointerExceptions
|
||||
// missed connection interrupted events.
|
||||
chatListener!!.onConnectionInterrupted(chatConnection, null)
|
||||
}
|
||||
}
|
||||
|
||||
every { chatConnection.send(any()) } answers {
|
||||
delay {
|
||||
it.complete(RESPONSE_SUCCESS)
|
||||
sendLatch?.countDown()
|
||||
}
|
||||
}
|
||||
|
||||
every { chatConnection.start() } returns Unit
|
||||
}
|
||||
|
||||
// Test that the LibSignalChatConnection transitions through DISCONNECTED -> CONNECTING -> CONNECTED
|
||||
// if the underlying ChatConnection future completes successfully.
|
||||
@Test
|
||||
fun orderOfStatesOnSuccessfulConnect() {
|
||||
connectLatch = CountDownLatch(1)
|
||||
|
||||
val observer = TestObserver<WebSocketConnectionState>()
|
||||
connection.state.subscribe(observer)
|
||||
|
||||
connection.connect()
|
||||
|
||||
connectLatch!!.await(100, TimeUnit.MILLISECONDS)
|
||||
|
||||
observer.assertNotComplete()
|
||||
observer.assertValues(
|
||||
WebSocketConnectionState.DISCONNECTED,
|
||||
WebSocketConnectionState.CONNECTING,
|
||||
WebSocketConnectionState.CONNECTED
|
||||
)
|
||||
observer.assertNoConsecutiveDuplicates()
|
||||
}
|
||||
|
||||
// Test that the LibSignalChatConnection transitions to FAILED if the
|
||||
// underlying ChatConnection future completes exceptionally.
|
||||
@Test
|
||||
fun orderOfStatesOnConnectionFailure() {
|
||||
val connectionException = RuntimeException("connect failed")
|
||||
val latch = CountDownLatch(1)
|
||||
|
||||
every { network.connectUnauthChat(any()) } answers {
|
||||
chatListener = firstArg()
|
||||
delay {
|
||||
it.completeExceptionally(connectionException)
|
||||
latch.countDown()
|
||||
}
|
||||
}
|
||||
|
||||
val observer = TestObserver<WebSocketConnectionState>()
|
||||
connection.state.subscribe(observer)
|
||||
|
||||
connection.connect()
|
||||
|
||||
latch.await(100, TimeUnit.MILLISECONDS)
|
||||
|
||||
observer.assertNotComplete()
|
||||
observer.assertValues(
|
||||
WebSocketConnectionState.DISCONNECTED,
|
||||
WebSocketConnectionState.CONNECTING,
|
||||
WebSocketConnectionState.FAILED
|
||||
)
|
||||
observer.assertNoConsecutiveDuplicates()
|
||||
}
|
||||
|
||||
// Test connect followed by disconnect, checking the state transitions.
|
||||
@Ignore("Flaky")
|
||||
@Test
|
||||
fun orderOfStatesOnConnectAndDisconnect() {
|
||||
connectLatch = CountDownLatch(1)
|
||||
disconnectLatch = CountDownLatch(1)
|
||||
|
||||
val observer = TestObserver<WebSocketConnectionState>()
|
||||
|
||||
connection.state.subscribe(observer)
|
||||
|
||||
connection.connect()
|
||||
connectLatch!!.await(100, TimeUnit.MILLISECONDS)
|
||||
|
||||
connection.disconnect()
|
||||
disconnectLatch!!.await(100, TimeUnit.MILLISECONDS)
|
||||
|
||||
// onConnectionInterrupted acts like the onClosed callback for the connection here, driving the
|
||||
// transition from DISCONNECTING -> DISCONNECTED.
|
||||
chatListener!!.onConnectionInterrupted(chatConnection, null)
|
||||
|
||||
observer.assertNotComplete()
|
||||
observer.assertValues(
|
||||
WebSocketConnectionState.DISCONNECTED,
|
||||
WebSocketConnectionState.CONNECTING,
|
||||
WebSocketConnectionState.CONNECTED,
|
||||
WebSocketConnectionState.DISCONNECTING,
|
||||
WebSocketConnectionState.DISCONNECTED
|
||||
)
|
||||
observer.assertNoConsecutiveDuplicates()
|
||||
}
|
||||
|
||||
// Test that a disconnect failure transitions from CONNECTED -> DISCONNECTING -> DISCONNECTED anyway,
|
||||
// since we don't have a specific "DISCONNECT_FAILED" state.
|
||||
@Test
|
||||
fun orderOfStatesOnDisconnectFailure() {
|
||||
val disconnectException = RuntimeException("disconnect failed")
|
||||
val disconnectLatch = CountDownLatch(1)
|
||||
|
||||
every { chatConnection.disconnect() } answers {
|
||||
delay {
|
||||
it.completeExceptionally(disconnectException)
|
||||
disconnectLatch.countDown()
|
||||
}
|
||||
}
|
||||
|
||||
setupConnectedConnection()
|
||||
|
||||
val observer = TestObserver<WebSocketConnectionState>()
|
||||
connection.state.subscribe(observer)
|
||||
|
||||
connection.disconnect()
|
||||
|
||||
disconnectLatch.await(100, TimeUnit.MILLISECONDS)
|
||||
|
||||
observer.assertNotComplete()
|
||||
observer.assertValues(
|
||||
// The subscriber is created after we've already connected, so the first state it sees is CONNECTED:
|
||||
WebSocketConnectionState.CONNECTED,
|
||||
WebSocketConnectionState.DISCONNECTING,
|
||||
WebSocketConnectionState.DISCONNECTED
|
||||
)
|
||||
observer.assertNoConsecutiveDuplicates()
|
||||
}
|
||||
|
||||
// Test a successful keepAlive, i.e. we get a 200 OK in response to the keepAlive request,
|
||||
// which triggers healthMonitor.onKeepAliveResponse(...) and not onMessageError.
|
||||
@Test
|
||||
fun keepAliveSuccess() {
|
||||
setupConnectedConnection()
|
||||
|
||||
sendLatch = CountDownLatch(1)
|
||||
|
||||
connection.sendKeepAlive()
|
||||
sendLatch!!.await(100, TimeUnit.MILLISECONDS)
|
||||
|
||||
verify(exactly = 1) {
|
||||
healthMonitor.onKeepAliveResponse(any(), false)
|
||||
}
|
||||
verify(exactly = 0) {
|
||||
healthMonitor.onMessageError(any(), any())
|
||||
}
|
||||
}
|
||||
|
||||
// Test keepAlive failures: we get 4xx or 5xx, which triggers healthMonitor.onMessageError(...) but not onKeepAliveResponse.
|
||||
@Test
|
||||
fun keepAliveFailure() {
|
||||
for (response in listOf(RESPONSE_ERROR, RESPONSE_SERVER_ERROR)) {
|
||||
clearMocks(healthMonitor)
|
||||
|
||||
every { chatConnection.send(any()) } answers {
|
||||
delay {
|
||||
it.complete(response)
|
||||
sendLatch?.countDown()
|
||||
}
|
||||
}
|
||||
|
||||
setupConnectedConnection()
|
||||
|
||||
sendLatch = CountDownLatch(1)
|
||||
|
||||
connection.sendKeepAlive()
|
||||
sendLatch!!.await(100, TimeUnit.MILLISECONDS)
|
||||
|
||||
verify(exactly = 1) {
|
||||
healthMonitor.onMessageError(response.status, false)
|
||||
}
|
||||
verify(exactly = 0) {
|
||||
healthMonitor.onKeepAliveResponse(any(), any())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Test keepAlive that fails at the transport layer (send() throws),
|
||||
// which transitions from CONNECTED -> DISCONNECTED.
|
||||
@Test
|
||||
fun keepAliveConnectionFailure() {
|
||||
val connectionFailure = RuntimeException("Sending keep-alive failed")
|
||||
|
||||
val keepAliveFailureLatch = CountDownLatch(1)
|
||||
|
||||
every { chatConnection.send(any()) } answers {
|
||||
delay {
|
||||
it.completeExceptionally(connectionFailure)
|
||||
keepAliveFailureLatch.countDown()
|
||||
}
|
||||
}
|
||||
|
||||
setupConnectedConnection()
|
||||
|
||||
val observer = TestObserver<WebSocketConnectionState>()
|
||||
connection.state.subscribe(observer)
|
||||
|
||||
connection.sendKeepAlive()
|
||||
|
||||
keepAliveFailureLatch.await(100, TimeUnit.MILLISECONDS)
|
||||
|
||||
observer.assertNotComplete()
|
||||
observer.assertValues(
|
||||
// We start in the connected state
|
||||
WebSocketConnectionState.CONNECTED,
|
||||
// Disconnects as a result of keep-alive failure
|
||||
WebSocketConnectionState.DISCONNECTED
|
||||
)
|
||||
observer.assertNoConsecutiveDuplicates()
|
||||
verify(exactly = 0) {
|
||||
healthMonitor.onKeepAliveResponse(any(), any())
|
||||
healthMonitor.onMessageError(any(), any())
|
||||
}
|
||||
}
|
||||
|
||||
// Test that an incoming "connection interrupted" event from ChatConnection sets our state to DISCONNECTED.
|
||||
@Test
|
||||
fun connectionInterruptedTest() {
|
||||
val disconnectReason = ChatServiceException("simulated interrupt")
|
||||
|
||||
setupConnectedConnection()
|
||||
|
||||
val observer = TestObserver<WebSocketConnectionState>()
|
||||
connection.state.subscribe(observer)
|
||||
|
||||
chatListener!!.onConnectionInterrupted(chatConnection, disconnectReason)
|
||||
|
||||
observer.assertNotComplete()
|
||||
observer.assertValues(
|
||||
// We start in the connected state
|
||||
WebSocketConnectionState.CONNECTED,
|
||||
// Disconnects as a result of the connection interrupted event
|
||||
WebSocketConnectionState.DISCONNECTED
|
||||
)
|
||||
observer.assertNoConsecutiveDuplicates()
|
||||
verify(exactly = 0) {
|
||||
healthMonitor.onKeepAliveResponse(any(), any())
|
||||
healthMonitor.onMessageError(any(), any())
|
||||
}
|
||||
}
|
||||
|
||||
// If readRequest() does not throw when the underlying connection disconnects, this
|
||||
// causes the app to get stuck in a "fetching new messages" state.
|
||||
@Test
|
||||
fun regressionTestReadRequestThrowsOnDisconnect() {
|
||||
setupConnectedConnection()
|
||||
|
||||
executor.submit {
|
||||
Thread.sleep(100)
|
||||
chatConnection.disconnect()
|
||||
}
|
||||
|
||||
assertThrows(IOException::class.java) {
|
||||
connection.readRequest(1000)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun readRequestDoesTimeOut() {
|
||||
setupConnectedConnection()
|
||||
|
||||
val observer = TestObserver<WebSocketConnectionState>()
|
||||
connection.state.subscribe(observer)
|
||||
|
||||
assertThrows(TimeoutException::class.java) {
|
||||
connection.readRequest(10)
|
||||
}
|
||||
}
|
||||
|
||||
// Test reading incoming requests from the queue.
|
||||
// We'll simulate onIncomingMessage() from the ChatConnectionListener, then read them from the LibSignalChatConnection.
|
||||
@Test
|
||||
fun incomingRequests() {
|
||||
setupConnectedConnection()
|
||||
|
||||
val observer = TestObserver<WebSocketConnectionState>()
|
||||
connection.state.subscribe(observer)
|
||||
|
||||
// We'll now simulate incoming messages
|
||||
val envelopeA = "msgA".toByteArray()
|
||||
val envelopeB = "msgB".toByteArray()
|
||||
val envelopeC = "msgC".toByteArray()
|
||||
|
||||
val asyncMessageReadLatch = CountDownLatch(1)
|
||||
|
||||
// Helper to check that the WebSocketRequestMessage for an envelope is as expected
|
||||
fun assertRequestWithEnvelope(request: WebSocketRequestMessage, envelope: ByteArray) {
|
||||
assertEquals("PUT", request.verb)
|
||||
assertEquals("/api/v1/message", request.path)
|
||||
assertEquals(envelope.toByteString(), request.body!!)
|
||||
connection.sendResponse(
|
||||
WebSocketResponseMessage(
|
||||
request.id,
|
||||
200,
|
||||
"OK"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// Helper to check that a queue-empty request is as expected
|
||||
fun assertQueueEmptyRequest(request: WebSocketRequestMessage) {
|
||||
assertEquals("PUT", request.verb)
|
||||
assertEquals("/api/v1/queue/empty", request.path)
|
||||
connection.sendResponse(
|
||||
WebSocketResponseMessage(
|
||||
request.id,
|
||||
200,
|
||||
"OK"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// Read request asynchronously to simulate concurrency
|
||||
executor.submit {
|
||||
val request = connection.readRequest(200)
|
||||
assertRequestWithEnvelope(request, envelopeA)
|
||||
asyncMessageReadLatch.countDown()
|
||||
}
|
||||
|
||||
chatListener!!.onIncomingMessage(chatConnection, envelopeA, 0, null)
|
||||
asyncMessageReadLatch.await(100, TimeUnit.MILLISECONDS)
|
||||
|
||||
chatListener!!.onIncomingMessage(chatConnection, envelopeB, 0, null)
|
||||
assertRequestWithEnvelope(connection.readRequestIfAvailable().get(), envelopeB)
|
||||
|
||||
chatListener!!.onQueueEmpty(chatConnection)
|
||||
assertQueueEmptyRequest(connection.readRequestIfAvailable().get())
|
||||
|
||||
chatListener!!.onIncomingMessage(chatConnection, envelopeC, 0, null)
|
||||
assertRequestWithEnvelope(connection.readRequestIfAvailable().get(), envelopeC)
|
||||
|
||||
assertTrue(connection.readRequestIfAvailable().isEmpty)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun regressionTestDisconnectWhileConnecting() {
|
||||
every { network.connectUnauthChat(any()) } answers {
|
||||
chatListener = firstArg()
|
||||
delay {
|
||||
// We do not complete the future, so we stay in the CONNECTING state forever.
|
||||
}
|
||||
}
|
||||
|
||||
connection.connect()
|
||||
connection.disconnect()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun regressionTestSendWhileConnecting() {
|
||||
var connectionCompletionFuture: CompletableFuture<UnauthenticatedChatConnection>? = null
|
||||
every { network.connectUnauthChat(any()) } answers {
|
||||
chatListener = firstArg()
|
||||
connectionCompletionFuture = CompletableFuture<UnauthenticatedChatConnection>()
|
||||
connectionCompletionFuture!!
|
||||
}
|
||||
sendLatch = CountDownLatch(1)
|
||||
|
||||
connection.connect()
|
||||
|
||||
val sendSingle = connection.sendRequest(WebSocketRequestMessage("GET", "/fake-path"))
|
||||
val sendObserver = sendSingle.test()
|
||||
|
||||
assertEquals(1, sendLatch!!.count)
|
||||
sendObserver.assertNotComplete()
|
||||
|
||||
connectionCompletionFuture!!.complete(chatConnection)
|
||||
|
||||
sendLatch!!.await(100, TimeUnit.MILLISECONDS)
|
||||
sendObserver.awaitDone(100, TimeUnit.MILLISECONDS)
|
||||
sendObserver.assertValues(RESPONSE_SUCCESS.toWebsocketResponse(true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSendFailsWhenConnectionFails() {
|
||||
var connectionCompletionFuture: CompletableFuture<UnauthenticatedChatConnection>? = null
|
||||
every { network.connectUnauthChat(any()) } answers {
|
||||
chatListener = firstArg()
|
||||
connectionCompletionFuture = CompletableFuture<UnauthenticatedChatConnection>()
|
||||
connectionCompletionFuture!!
|
||||
}
|
||||
sendLatch = CountDownLatch(1)
|
||||
|
||||
connection.connect()
|
||||
val sendSingle = connection.sendRequest(WebSocketRequestMessage("GET", "/fake-path"))
|
||||
val sendObserver = sendSingle.test()
|
||||
|
||||
assertEquals(1, sendLatch!!.count)
|
||||
sendObserver.assertNotComplete()
|
||||
|
||||
connectionCompletionFuture!!.completeExceptionally(ChatServiceException(""))
|
||||
|
||||
sendObserver.awaitDone(100, TimeUnit.MILLISECONDS)
|
||||
assertEquals(1, sendLatch!!.count)
|
||||
sendObserver.assertFailure(IOException().javaClass)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun regressionTestSendAfterConnectionFutureCompletesButBeforeStateUpdates() {
|
||||
// We used to have a race condition where if sendRequest was called after
|
||||
// the chatConnectionFuture completed but before the completion handler that
|
||||
// that updates LibSignalChatConnection's state ran, we would end up with a
|
||||
// StackOverflowError exception.
|
||||
// We ended up fixing that bug by refactoring that part of the code completely.
|
||||
// This tests that scenario to ensure that we don't regress by introducing
|
||||
// some other kind of bug in that tricky situation.
|
||||
var connectionFuture: CompletableFuture<UnauthenticatedChatConnection>? = null
|
||||
val futureCompletedLatch = CountDownLatch(1)
|
||||
val requestCompletedLatch = CountDownLatch(1)
|
||||
|
||||
every { network.connectUnauthChat(any()) } answers {
|
||||
chatListener = firstArg()
|
||||
connectionFuture = CompletableFuture<UnauthenticatedChatConnection>()
|
||||
|
||||
// Add a completion handler that blocks to prevent state transition
|
||||
connectionFuture!!.whenComplete { _, _ ->
|
||||
// When we reach this point, we know connectionFuture.complete
|
||||
// must have been called, and subsequent calls will return false.
|
||||
futureCompletedLatch.countDown()
|
||||
// Block to keep state as CONNECTING
|
||||
requestCompletedLatch.await()
|
||||
}
|
||||
|
||||
connectionFuture!!
|
||||
}
|
||||
|
||||
connection.connect()
|
||||
|
||||
executor.submit {
|
||||
// This will block until all the completion handlers complete, which
|
||||
// means it will block until requestCompletedLatch is counted down.
|
||||
connectionFuture!!.complete(chatConnection)
|
||||
}
|
||||
|
||||
assertTrue("connectionFuture was never completed", futureCompletedLatch.await(100, TimeUnit.MILLISECONDS))
|
||||
|
||||
// Now calls to connectionFuture.whenComplete will synchronously
|
||||
// execute the completionHandler given to them, but the state of
|
||||
// LibSignalChatConnection will still be CONNECTING.
|
||||
// Previously, this caused a bug where the completion handler would see
|
||||
// the state was still CONNECTING, and call connectionFuture.whenComplete
|
||||
// again, thus setting off an infinite recursive loop, ending in a
|
||||
// StackOverflowError.
|
||||
connection.sendRequest(WebSocketRequestMessage("GET", "/test"))
|
||||
|
||||
// The test passed! Unblock the executor thread.
|
||||
requestCompletedLatch.countDown()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testQueueLargeNumberOfRequestsWhileConnecting() {
|
||||
// Test queuing up 100,000 requests while the connection is still CONNECTING,
|
||||
// then complete the connection to make sure they all send successfully.
|
||||
var connectionCompletionFuture: CompletableFuture<UnauthenticatedChatConnection>? = null
|
||||
val sendRequestCount = 100_000
|
||||
val allSentLatch = CountDownLatch(sendRequestCount)
|
||||
|
||||
every { network.connectUnauthChat(any()) } answers {
|
||||
chatListener = firstArg()
|
||||
connectionCompletionFuture = CompletableFuture<UnauthenticatedChatConnection>()
|
||||
connectionCompletionFuture!!
|
||||
}
|
||||
|
||||
every { chatConnection.send(any()) } answers {
|
||||
delay {
|
||||
it.complete(RESPONSE_SUCCESS)
|
||||
allSentLatch.countDown()
|
||||
}
|
||||
}
|
||||
|
||||
connection.connect()
|
||||
|
||||
val sendObservers = mutableListOf<TestObserver<WebsocketResponse>>()
|
||||
for (i in 0 until sendRequestCount) {
|
||||
val sendSingle = connection.sendRequest(WebSocketRequestMessage("GET", "/test-path-$i"))
|
||||
val observer = sendSingle.test()
|
||||
sendObservers.add(observer)
|
||||
}
|
||||
|
||||
sendObservers.forEach { observer ->
|
||||
observer.assertNotComplete()
|
||||
}
|
||||
|
||||
connectionCompletionFuture!!.complete(chatConnection)
|
||||
|
||||
assertTrue("All $sendRequestCount were not sent", allSentLatch.await(1, TimeUnit.SECONDS))
|
||||
|
||||
sendObservers.forEach { observer ->
|
||||
observer.awaitDone(100, TimeUnit.MILLISECONDS)
|
||||
observer.assertValues(RESPONSE_SUCCESS.toWebsocketResponse(true))
|
||||
observer.assertComplete()
|
||||
}
|
||||
}
|
||||
|
||||
private fun <T> delay(action: ((CompletableFuture<T>) -> Unit)): CompletableFuture<T> {
|
||||
val future = CompletableFuture<T>()
|
||||
executor.submit {
|
||||
action(future)
|
||||
}
|
||||
return future
|
||||
}
|
||||
|
||||
private fun TestObserver<WebSocketConnectionState>.assertNoConsecutiveDuplicates() {
|
||||
val states = this.values()
|
||||
for (i in 1 until states.size) {
|
||||
assertNotEquals(
|
||||
"Found duplicate consecutive states states[${i - 1}] = states[$i] = ${states[i]}",
|
||||
states[i - 1],
|
||||
states[i]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
// For verifying success / error scenarios in keepAlive tests, etc.
|
||||
private val RESPONSE_SUCCESS = ChatConnection.Response(200, "", emptyMap(), byteArrayOf())
|
||||
private val RESPONSE_ERROR = ChatConnection.Response(400, "", emptyMap(), byteArrayOf())
|
||||
private val RESPONSE_SERVER_ERROR = ChatConnection.Response(500, "", emptyMap(), byteArrayOf())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package org.whispersystems.signalservice.testutil;
|
||||
|
||||
import org.signal.libsignal.internal.Native;
|
||||
|
||||
import static org.junit.Assert.assertNotNull;
|
||||
import static org.junit.Assert.fail;
|
||||
import static org.junit.Assume.assumeNoException;
|
||||
|
||||
public final class LibSignalLibraryUtil {
|
||||
|
||||
/**
|
||||
* Attempts to initialize the LibSignal Native class, which will load the native binaries.
|
||||
* <p>
|
||||
* If that fails to link, then on Unix, it will fail as we rely on that for CI.
|
||||
* <p>
|
||||
* If that fails to link, and it's not Unix, it will skip the test via assumption violation.
|
||||
* <p>
|
||||
* If using inside a PowerMocked test, the assumption violation can be fatal, use:
|
||||
* {@code @PowerMockRunnerDelegate(JUnit4.class)}
|
||||
*/
|
||||
public static void assumeLibSignalSupportedOnOS() {
|
||||
try {
|
||||
Class.forName(Native.class.getName());
|
||||
} catch (ClassNotFoundException e) {
|
||||
fail();
|
||||
} catch (NoClassDefFoundError | UnsatisfiedLinkError e) {
|
||||
String osName = System.getProperty("os.name");
|
||||
|
||||
if (isUnix(osName)) {
|
||||
fail("Not able to link native LibSignal on a key OS: " + osName);
|
||||
} else {
|
||||
assumeNoException("Not able to link native LibSignal on this operating system: " + osName, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isUnix(String osName) {
|
||||
assertNotNull(osName);
|
||||
osName = osName.toLowerCase();
|
||||
return osName.contains("nix") || osName.contains("nux") || osName.contains("aix");
|
||||
}
|
||||
}
|
||||
BIN
lib/libsignal-service/src/test/resources/ias.cert
Normal file
BIN
lib/libsignal-service/src/test/resources/ias.cert
Normal file
Binary file not shown.
BIN
lib/libsignal-service/src/test/resources/ias.jks
Normal file
BIN
lib/libsignal-service/src/test/resources/ias.jks
Normal file
Binary file not shown.
BIN
lib/libsignal-service/src/test/resources/ias.store
Normal file
BIN
lib/libsignal-service/src/test/resources/ias.store
Normal file
Binary file not shown.
Reference in New Issue
Block a user