diff --git a/signalc/src/main/kotlin/info/signalboost/signalc/Mocks.kt b/signalc/src/main/kotlin/info/signalboost/signalc/Mocks.kt index fe064249ca0cb45a700937fc6f4ddd7dfcd34b52..dc5137de2d4a0e87ad0bb337a396381078dc7e0e 100644 --- a/signalc/src/main/kotlin/info/signalboost/signalc/Mocks.kt +++ b/signalc/src/main/kotlin/info/signalboost/signalc/Mocks.kt @@ -29,7 +29,7 @@ object Mocks { coEvery { publishPreKeys(any()) } returns Unit coEvery { publishPreKeys(any(), any()) } returns Unit coEvery { refreshPreKeysIfDepleted(any()) } returns Unit - coEvery { getUnidentifiedAccessPair(any(), any()) } returns mockk() + coEvery { getUnidentifiedAccessPair(any(), any()) } returns null } val accountStore: AccountStore.() -> Unit = { coEvery { save(any<NewAccount>()) } returns Unit @@ -40,9 +40,13 @@ object Mocks { every { closeQuietly() } returns Unit } val contactStore: ContactStore.() -> Unit = { + coEvery { create(any(), any()) } returns 0 + coEvery { create(any(), any(), any(), any()) } returns 0 coEvery { createOwnContact(any()) } returns 0 - coEvery { storeProfileKey(any(), any(), any())} returns Unit + coEvery { hasContact(any(), any()) } returns true coEvery { loadProfileKey(any(), any())} returns mockk() + coEvery { storeMissingIdentifier(any(), any(), any())} returns Unit + coEvery { storeProfileKey(any(), any(), any())} returns Unit } val protocolStore: ProtocolStore.() -> Unit = { every { of(any()) } returns mockk { @@ -71,6 +75,12 @@ object Mocks { coEvery { send(any(), any(), any(), any(), any(), any()) } answers { mockkSuccessOf(secondArg()) } + coEvery { sendProfileKey(any(), any(), any()) } answers { + mockkSuccessOf(secondArg()) + } + coEvery { sendReceipt(any(), any(), any()) } answers { + mockkSuccessOf(secondArg()) + } coEvery { setExpiration(any(), any(), any()) } answers { mockkSuccessOf(secondArg()) } diff --git a/signalc/src/main/kotlin/info/signalboost/signalc/logic/SignalReceiver.kt b/signalc/src/main/kotlin/info/signalboost/signalc/logic/SignalReceiver.kt index f210d1ae169646633b58ad8a0c93319edfc6e38e..10a979ee7bfb04320c22739d058ea4cd13626cbb 100644 --- a/signalc/src/main/kotlin/info/signalboost/signalc/logic/SignalReceiver.kt +++ b/signalc/src/main/kotlin/info/signalboost/signalc/logic/SignalReceiver.kt @@ -44,6 +44,7 @@ import kotlin.time.ExperimentalTime class SignalReceiver(private val app: Application) { companion object: Any(), KLoggable { override val logger = logger() + private val metrics = Metrics.SignalReceiver private const val TMP_FILE_PREFIX = "___" private const val TMP_FILE_SUFFIX = ".tmp" private const val MAX_ATTACHMENT_SIZE = 150L * 1024 * 1024 // 150MB @@ -60,7 +61,6 @@ class SignalReceiver(private val app: Application) { // FIELDS/FACTORIES internal val messagesInFlight = AtomicInteger(0) - private val subscriptions = ConcurrentHashMap<String,Job>() private val messageReceivers = ConcurrentHashMap<String,SignalServiceMessageReceiver>() private val messagePipes = ConcurrentHashMap<String,SignalServiceMessagePipe>() @@ -166,20 +166,25 @@ class SignalReceiver(private val app: Application) { private suspend fun dispatch(account: VerifiedAccount, envelope: SignalServiceEnvelope): Job? { envelope.type.asEnum().let { logger.debug { "Got ${it.asString} from ${envelope.sourceIdentifier ?: "SEALED"} to ${account.username}" } - Metrics.SignalReceiver.numberOfMessagesReceived.labels(it.asString).inc() + metrics.numberOfMessagesReceived.labels(it.asString).inc() return when (it) { EnvelopeType.CIPHERTEXT, - EnvelopeType.UNIDENTIFIED_SENDER -> processMessage(envelope, account) - + EnvelopeType.UNIDENTIFIED_SENDER -> { + processMessage(envelope, account) + } EnvelopeType.PREKEY_BUNDLE -> { processPreKeyBundle(envelope, account) processMessage(envelope, account) } - - EnvelopeType.RECEIPT -> processReceipt(envelope, account) - - EnvelopeType.KEY_EXCHANGE, // TODO: handle this to process "reset secure session" events - EnvelopeType.UNKNOWN -> drop(envelope, account) + EnvelopeType.RECEIPT -> { + processReceipt(envelope, account) + null + } + EnvelopeType.KEY_EXCHANGE, // TODO: handle KEY_EXCHANGE to process "reset secure session" events + EnvelopeType.UNKNOWN -> { + drop(envelope, account) + null + } } } } @@ -193,21 +198,22 @@ class SignalReceiver(private val app: Application) { try { messagesInFlight.getAndIncrement() + // decrypt data message (returning early if not data message -- eg: typing notification, etc.) val contents = cipherOf(account).decrypt(envelope) - val dataMessage = contents.dataMessage.orNull()?: return@launch // drop non-data msgs (eg: typing, sync) + val dataMessage = contents.dataMessage.orNull()?: return@launch contactAddress = contents.sender.asSignalcAddress() - val body = dataMessage.body?.orNull() ?: "" // expiry timer changes contain empty message bodies - val attachments = dataMessage.attachments.orNull() ?: emptyList() + // acknowledge receipt to sender and store their profile key if present + app.signalSender.sendReceipt(account, contactAddress, dataMessage.timestamp) dataMessage.profileKey.orNull()?.let { - // dataMessage.isProfileKeyUpdate is flaky on 1st message, so we store profile key on every message app.contactStore.storeProfileKey(account.id, contactAddress.identifier, it) + } ?: run { + metrics.numberOfMessagesWithoutProfileKey.labels(envelope.isUnidentifiedSender.toString()).inc() } - launch { - app.signalSender.sendReceipt(account, contactAddress, dataMessage.timestamp) - } - + // extract contents of message and relay to client + val body = dataMessage.body?.orNull() ?: "" // expiry timer changes contain empty message bodies + val attachments = dataMessage.attachments.orNull() ?: emptyList() app.socketSender.send( SocketResponse.Cleartext.of( contactAddress, @@ -226,46 +232,38 @@ class SignalReceiver(private val app: Application) { } } - private suspend fun processPreKeyBundle(envelope: SignalServiceEnvelope, account: VerifiedAccount) = - withContext(app.coroutineScope.coroutineContext + Concurrency.Dispatcher) { - // This is our first (and only!) chance to store both the UUID and phone number that will be necessary - // to successfully decrypt subsequent messages in a session that may refer either to UUID or number. - // Store them here so we can resolve either to an int id in the protocol store! - if(!app.contactStore.hasContact(account.username, envelope.sourceIdentifier)) { - app.contactStore.create( - accountId = account.username, - phoneNumber = envelope.sourceE164.orNull(), - uuid = UUID.fromString(envelope.sourceUuid.orNull()), - ) - // We also want new contacts to be able to send us sealed sender messages as soon as possible, so - // send them our profile key (which enalbes sealed-sender message sending) now. - app.signalSender.sendProfileKey(account, envelope.asSignalcAddress()) - } - // If we are receiving a prekey bundle, this is the beginning of a new session, the initiation - // of which might have depleted our prekey reserves below the level we want to keep on hand - // to start new sessions. So: launch a background job to check our prekey reserve and replenish it if needed! - app.accountManager.refreshPreKeysIfDepleted(account) + private suspend fun processPreKeyBundle(envelope: SignalServiceEnvelope, account: VerifiedAccount) { + // Prekey buncles are received (once per device) from new contacts before we initiate a sealed sender session + // with them. As such, this is our first (and only!) chance to store both the UUID and phone number + // that will be necessary to successfully decrypt subsequent messages in a session that may refer to the same + // user by *either* UUID or phone number (and will switch between the two during thes same session). + // We store both here so we can resolve either UUID or phone number to the same contact later. + // We also want new contacts to be able to send us sealed sender messages as soon as possible, so + // we send them our profile key, which enables sealed-sender message sending. + if(!app.contactStore.hasContact(account.id, envelope.sourceIdentifier)) { + app.contactStore.create(account, envelope) + app.signalSender.sendProfileKey(account, envelope.asSignalcAddress()) } - - private suspend fun processReceipt(envelope: SignalServiceEnvelope, account: VerifiedAccount): Job? { - // An unsealed receipt is the first contact we will have if we initiated a conversation with a contact, so use the - // opportunity to store their UUID and phone number! - if(!envelope.isUnidentifiedSender) { - app.contactStore.storeMissingIdentifier( - accountId = account.username, - contactPhoneNumber = envelope.sourceE164.get(), - contactUuid = UUID.fromString(envelope.sourceUuid.get()), - ) - } - return null + // If we are receiving a prekey bundle, this is also the beginning of a new session, which consumes one of the + // recipient's one-time prekeys. Since this might have depleted the recipient's prekeys below the number needed + // to start new sessions, we launch a background job to check the prekey reserve and replenish it if needed! + app.accountManager.refreshPreKeysIfDepleted(account) } - private suspend fun drop(envelope: SignalServiceEnvelope, account: VerifiedAccount): Job? { + private suspend fun processReceipt(envelope: SignalServiceEnvelope, account: VerifiedAccount) = + // An unsealed receipt is the first contact we will have if we initiated a conversation with a contact, so + // use the opportunity to store their UUID and phone number! (Note: all envelopes of type RECEIPT are unsealed.) + app.contactStore.storeMissingIdentifier( + accountId = account.id, + contactPhoneNumber = envelope.sourceE164.get(), + contactUuid = UUID.fromString(envelope.sourceUuid.get()), + ) + + + private suspend fun drop(envelope: SignalServiceEnvelope, account: VerifiedAccount) = app.socketSender.send( SocketResponse.Dropped(envelope.asSignalcAddress(), account.address, envelope) ) - return null - } private suspend fun handleRelayError(err: Throwable, account: SignalcAddress, contact: SignalcAddress?) { when (err) { @@ -274,13 +272,7 @@ class SignalReceiver(private val app: Application) { // it to send a garbage message that will force a session reset and raise another identity exception. // That exception (unlike this one) will include the fingerprint corresponding to the new session, // and can be handled (and trusted) from the send path. - app.socketSender.send( - SocketResponse.InboundIdentityFailure.of( - account, - contact, - null, - ) - ) + app.socketSender.send(SocketResponse.InboundIdentityFailure.of(account, contact,null)) } else -> { app.socketSender.send(SocketResponse.DecryptionError(account, contact, err)) diff --git a/signalc/src/main/kotlin/info/signalboost/signalc/logic/SignalSender.kt b/signalc/src/main/kotlin/info/signalboost/signalc/logic/SignalSender.kt index 73330bd976f396732ff7a5cd6c4d26deeae8b05a..007a5b67a7da394c91b415b64bae00088365a09b 100644 --- a/signalc/src/main/kotlin/info/signalboost/signalc/logic/SignalSender.kt +++ b/signalc/src/main/kotlin/info/signalboost/signalc/logic/SignalSender.kt @@ -84,7 +84,7 @@ class SignalSender(private val app: Application) { Optional.absent(), // preview (we don't support those!) sendAttachment.width, sendAttachment.height, - TimeUtil.nowInMillis(), //uploadTimestamp + nowInMillis(), //uploadTimestamp Optional.fromNullable(sendAttachment.caption), Optional.fromNullable(sendAttachment.blurHash), null, // progressListener @@ -103,7 +103,7 @@ class SignalSender(private val app: Application) { body: String, expiration: Int, attachments: List<SocketRequest.Send.Attachment> = emptyList(), - timestamp: Long = TimeUtil.nowInMillis(), + timestamp: Long = nowInMillis(), ): SignalcSendResult = // TODO: handle `signalservice.api.push.exceptions.NotFoundException` here sendDataMessage( @@ -124,7 +124,7 @@ class SignalSender(private val app: Application) { suspend fun sendProfileKey( sender: VerifiedAccount, recipient: SignalcAddress, - timestamp: Long = TimeUtil.nowInMillis(), + timestamp: Long = nowInMillis(), ): SignalcSendResult = sendDataMessage( sender, diff --git a/signalc/src/main/kotlin/info/signalboost/signalc/metrics/Metrics.kt b/signalc/src/main/kotlin/info/signalboost/signalc/metrics/Metrics.kt index 0353ef7d902590a67eb65cc30816209654b84ab6..a9eec56d5a2c4a4329df6555d288c3e3f461148a 100644 --- a/signalc/src/main/kotlin/info/signalboost/signalc/metrics/Metrics.kt +++ b/signalc/src/main/kotlin/info/signalboost/signalc/metrics/Metrics.kt @@ -64,10 +64,20 @@ object Metrics { object SignalReceiver { val numberOfMessagesReceived: Counter = counterOf( "signal_receiver__number_of_messages_received", - "Counts number of inbound PREKEY_BUNDLE messages we receive from signal server when users try to establish new sessions." + - "If we often receive a high number of these in quick succession, consider throttling prekey replenish jobs.", + "Counts number of inbound messages received from signal server and categorizes them by type", "envelope_type", ) + + val numberOfMessagesWithoutProfileKey = counterOf( + "signal_receiver__number_of_messages_without_profile_key", + "Counts number of messages received without profile key. " + + "The 'is_sealed_sender' label allow us to track unsealed messages without profile keys " + + "which are worrisome because this is the signature of sessions initiated from a desktop client " + + "but not confirmed on a phone by tapping 'continue'. Without such confirmation, we will " + + "never be able to initiate sealed sender sessions, and thus risk getting blocked " + + "if the number of such messages gets too high.", + "is_sealed_sender" + ) } object SignalSender { diff --git a/signalc/src/main/kotlin/info/signalboost/signalc/store/ContactStore.kt b/signalc/src/main/kotlin/info/signalboost/signalc/store/ContactStore.kt index af46742d8ab8840b19278b609b93bb42d2d0eaa3..569bbcb085b6002268d7acdbfe9e9a351244e222 100644 --- a/signalc/src/main/kotlin/info/signalboost/signalc/store/ContactStore.kt +++ b/signalc/src/main/kotlin/info/signalboost/signalc/store/ContactStore.kt @@ -14,6 +14,7 @@ import org.jetbrains.exposed.sql.* import org.jetbrains.exposed.sql.transactions.experimental.newSuspendedTransaction import org.jetbrains.exposed.sql.transactions.transaction import org.signal.zkgroup.profiles.ProfileKey +import org.whispersystems.signalservice.api.messages.SignalServiceEnvelope import java.lang.IllegalStateException import java.util.UUID import kotlin.io.path.ExperimentalPathApi @@ -45,6 +46,9 @@ class ContactStore(app: Application) { } }.resultedValues!!.single()[Contacts.contactId] + suspend fun create(account: VerifiedAccount, envelope: SignalServiceEnvelope): Int = + create(account.id, envelope.sourceE164.orNull(), envelope.sourceUuid.orNull()?.let { UUID.fromString(it) } ) + suspend fun createOwnContact(account: VerifiedAccount) = with(account) { create(accountId = username, phoneNumber = username, uuid = uuid, profileKey = profileKeyBytes) } diff --git a/signalc/src/test/kotlin/info/signalboost/signalc/logic/SignalReceiverTest.kt b/signalc/src/test/kotlin/info/signalboost/signalc/logic/SignalReceiverTest.kt index 6ca55166a29b1e1b61004704408612657aad9933..6c7153c547e0b290d94ed0a65ddfa351b8bebcad 100644 --- a/signalc/src/test/kotlin/info/signalboost/signalc/logic/SignalReceiverTest.kt +++ b/signalc/src/test/kotlin/info/signalboost/signalc/logic/SignalReceiverTest.kt @@ -2,6 +2,7 @@ package info.signalboost.signalc.logic import info.signalboost.signalc.Application import info.signalboost.signalc.Config +import info.signalboost.signalc.model.SignalcAddress.Companion.asSignalcAddress import info.signalboost.signalc.testSupport.coroutines.CoroutineUtil.teardown import info.signalboost.signalc.testSupport.dataGenerators.AccountGen.genVerifiedAccount import info.signalboost.signalc.testSupport.dataGenerators.AddressGen.genDeviceId @@ -38,6 +39,7 @@ import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage import org.whispersystems.signalservice.api.messages.SignalServiceEnvelope import org.whispersystems.signalservice.internal.push.SignalServiceProtos.Envelope.Type.* import java.io.IOException +import java.util.* import java.util.concurrent.TimeoutException import kotlin.io.path.ExperimentalPathApi import kotlin.io.path.Path @@ -118,6 +120,7 @@ class SignalReceiverTest : FreeSpec({ every { anyConstructed<SignalServiceCipher>().decrypt(any()) } returns mockk { + every { sender } returns senderAddress.asSignalServiceAddress() every { dataMessage.orNull() } returns mockDataMessage.apply { every { body.orNull() } returnsMany cleartexts } @@ -541,6 +544,47 @@ class SignalReceiverTest : FreeSpec({ } } } + + "when the receiving account does has not stored a contact for the sender" - { + coEvery { + app.contactStore.hasContact(recipientAccount.id, envelope.sourceIdentifier) + } returns false + + "creates a contact for the sender" { + messageReceiver.subscribe(recipientAccount) + eventually(timeout, pollInterval) { + coVerify { + app.contactStore.create(recipientAccount, envelope) + } + } + } + + "sends the contact a profile key" { + messageReceiver.subscribe(recipientAccount) + eventually(timeout * 4, pollInterval) { + coVerify { + app.signalSender.sendProfileKey(recipientAccount, any()) + } + } + } + } + } + + "when signal sends a RECEIPT" - { + val (envelope) = signalSendsEnvelopeOf(RECEIPT_VALUE) + + "stores an missing identifiers contained in the receipt" { + messageReceiver.subscribe(recipientAccount) + eventually(timeout, pollInterval) { + coVerify { + app.contactStore.storeMissingIdentifier( + recipientAccount.id, + envelope.sourceE164.get(), + UUID.fromString(envelope.sourceUuid.get()), + ) + } + } + } } "when issued for an account that is already subscribed" - { diff --git a/signalc/src/test/kotlin/info/signalboost/signalc/logic/SignalSenderTest.kt b/signalc/src/test/kotlin/info/signalboost/signalc/logic/SignalSenderTest.kt index 865baa77aec2789ff81fa00d6ef48c0de48b989e..1deaeaaf5e481b1a628179371bb3fa8ec844d0a9 100644 --- a/signalc/src/test/kotlin/info/signalboost/signalc/logic/SignalSenderTest.kt +++ b/signalc/src/test/kotlin/info/signalboost/signalc/logic/SignalSenderTest.kt @@ -14,8 +14,10 @@ import info.signalboost.signalc.testSupport.dataGenerators.FileGen.genJpegFile import info.signalboost.signalc.testSupport.dataGenerators.SocketRequestGen.genSendAttachment import info.signalboost.signalc.testSupport.matchers.SignalMessageMatchers.signalDataMessage import info.signalboost.signalc.testSupport.matchers.SignalMessageMatchers.signalExpirationUpdate +import info.signalboost.signalc.testSupport.matchers.SignalMessageMatchers.signalReceiptMessage import info.signalboost.signalc.util.KeyUtil.genUuidStr import info.signalboost.signalc.util.TimeUtil +import info.signalboost.signalc.util.TimeUtil.nowInMillis import io.kotest.assertions.timing.eventually import io.kotest.core.spec.style.FreeSpec import io.kotest.matchers.should @@ -33,6 +35,7 @@ import org.whispersystems.signalservice.api.crypto.UnidentifiedAccessPair import org.whispersystems.signalservice.api.messages.SendMessageResult import org.whispersystems.signalservice.api.messages.SignalServiceAttachmentStream import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage +import org.whispersystems.signalservice.api.messages.SignalServiceReceiptMessage import org.whispersystems.signalservice.api.push.SignalServiceAddress import java.io.File import java.io.InputStream @@ -60,8 +63,24 @@ class SignalSenderTest : FreeSpec({ val app = Application(config).run(testScope) val verifiedAccount = genVerifiedAccount() + val contactPhoneNumber = genPhoneNumber() val timeout = Duration.milliseconds(5) + val mockSuccess = mockk<SendMessageResult.Success> { + every { isNeedsSync } returns true + every { isUnidentified } returns false + every { duration } returns 0L + } + + fun sendingSucceeds() { + every { + anyConstructed<SignalServiceMessageSender>().sendMessage(any(), any(), any()) + } returns mockk { + every { address } returns contactPhoneNumber.asSignalServiceAddress() + every { success } returns mockSuccess + } + } + beforeSpec { mockkObject(TimeUtil) mockkConstructor(SignalServiceMessageSender::class) @@ -114,14 +133,7 @@ class SignalSenderTest : FreeSpec({ } } - val mockSuccess = mockk<SendMessageResult.Success> { - every { isNeedsSync } returns true - every { isUnidentified } returns false - every { duration } returns 0L - } - "#send" - { - val recipientPhone = genPhoneNumber() val dataMessageSlot = slot<SignalServiceDataMessage>() every { anyConstructed<SignalServiceMessageSender>().sendMessage( @@ -130,14 +142,14 @@ class SignalSenderTest : FreeSpec({ capture(dataMessageSlot), ) } returns mockk { - every { address } returns recipientPhone.asSignalServiceAddress() + every { address } returns contactPhoneNumber.asSignalServiceAddress() every { success } returns mockSuccess } val now = TimeUtil.nowInMillis() suspend fun sendHello(): SignalcSendResult = app.signalSender.send( sender = verifiedAccount, - recipient = recipientPhone.asSignalcAddress(), + recipient = contactPhoneNumber.asSignalcAddress(), body = "hello!", expiration = 5000, attachments = emptyList(), @@ -148,8 +160,8 @@ class SignalSenderTest : FreeSpec({ val result = sendHello() verify { anyConstructed<SignalServiceMessageSender>().sendMessage( - SignalServiceAddress(null, recipientPhone), - any(), // TODO: we actually pass something here! + SignalServiceAddress(null, contactPhoneNumber), + any(), signalDataMessage( body = "hello!", timestamp = now, @@ -164,7 +176,7 @@ class SignalSenderTest : FreeSpec({ every { TimeUtil.nowInMillis() } returns 1000L app.signalSender.send( verifiedAccount, - recipientPhone.asSignalcAddress(), + contactPhoneNumber.asSignalcAddress(), "hello!", DEFAULT_EXPIRY_TIME, emptyList() @@ -186,7 +198,7 @@ class SignalSenderTest : FreeSpec({ } app.signalSender.send( verifiedAccount, - recipientPhone.asSignalcAddress(), + contactPhoneNumber.asSignalcAddress(), "", DEFAULT_EXPIRY_TIME, emptyList() @@ -204,7 +216,7 @@ class SignalSenderTest : FreeSpec({ "when sealed sender tokens can be derived from a recipient's profile key" - { val mockkUnidentifiedAccessPair = mockk<UnidentifiedAccessPair>() coEvery { - app.accountManager.getUnidentifiedAccessPair(verifiedAccount.identifier, recipientPhone) + app.accountManager.getUnidentifiedAccessPair(verifiedAccount.identifier, contactPhoneNumber) } returns mockkUnidentifiedAccessPair "sends a sealed sender message with access tokens derived from key" { @@ -222,7 +234,7 @@ class SignalSenderTest : FreeSpec({ "when sealed sender tokens cannot be derrived for a recipient (b/c missing profile key)" - { "when unsealed messages are allowed" - { coEvery { - app.accountManager.getUnidentifiedAccessPair(verifiedAccount.identifier, recipientPhone) + app.accountManager.getUnidentifiedAccessPair(verifiedAccount.identifier, contactPhoneNumber) } returns null "sends an unsealed-sender message (with no access tokens)" { @@ -245,10 +257,10 @@ class SignalSenderTest : FreeSpec({ ) val app2 = Application(config2).run(testScope) coEvery { - app2.accountManager.getUnidentifiedAccessPair(verifiedAccount.identifier, recipientPhone) + app2.accountManager.getUnidentifiedAccessPair(verifiedAccount.identifier, contactPhoneNumber) } returns null - val recipientAddress = recipientPhone.asSignalcAddress() + val recipientAddress = contactPhoneNumber.asSignalcAddress() afterTest { app2.stop() @@ -297,7 +309,7 @@ class SignalSenderTest : FreeSpec({ app.signalSender.send( sender = verifiedAccount, - recipient = recipientPhone.asSignalcAddress(), + recipient = contactPhoneNumber.asSignalcAddress(), body = "hello!", expiration = 5000, attachments = listOf(sendAttachment), @@ -334,6 +346,46 @@ class SignalSenderTest : FreeSpec({ } } + "#sendProfileKey" - { + sendingSucceeds() + + "sends a profile key to a contact" { + app.signalSender.sendProfileKey(verifiedAccount, contactPhoneNumber.asSignalcAddress()) + verify { + anyConstructed<SignalServiceMessageSender>().sendMessage( + contactPhoneNumber.asSignalServiceAddress(), + any(), + signalDataMessage( + isProfileKeyUpdate = true, + profileKey = verifiedAccount.profileKeyBytes, + ) + ) + } + } + } + + "#sendReceipt" - { + val timestamp = nowInMillis() + every { + anyConstructed<SignalServiceMessageSender>().sendReceipt(any(), any(), any()) + } returns Unit + + "sends a READ receipt to a contact" { + app.signalSender.sendReceipt(verifiedAccount, contactPhoneNumber.asSignalcAddress(), timestamp) + verify { + anyConstructed<SignalServiceMessageSender>().sendReceipt( + contactPhoneNumber.asSignalServiceAddress(), + Optional.absent(), + signalReceiptMessage( + type = SignalServiceReceiptMessage.Type.READ, + timestamps = listOf(timestamp), + id = timestamp, + ) + ) + } + } + } + "#setExpiration" - { val recipientPhone = genPhoneNumber() every { @@ -352,7 +404,7 @@ class SignalSenderTest : FreeSpec({ verify { anyConstructed<SignalServiceMessageSender>().sendMessage( SignalServiceAddress(null, recipientPhone), - any(), // TODO: assert that we passed correct access pair here! + Optional.absent(), signalExpirationUpdate(60) ) } diff --git a/signalc/src/test/kotlin/info/signalboost/signalc/store/ContactStoreTest.kt b/signalc/src/test/kotlin/info/signalboost/signalc/store/ContactStoreTest.kt index beb2d01bef3f00f5a98560b02014eb421a321b35..1333f268121d75f8626d57fc05e1996799e092d2 100644 --- a/signalc/src/test/kotlin/info/signalboost/signalc/store/ContactStoreTest.kt +++ b/signalc/src/test/kotlin/info/signalboost/signalc/store/ContactStoreTest.kt @@ -5,8 +5,10 @@ import com.zaxxer.hikari.HikariDataSource import info.signalboost.signalc.Application import info.signalboost.signalc.Config import info.signalboost.signalc.testSupport.coroutines.CoroutineUtil.teardown +import info.signalboost.signalc.testSupport.dataGenerators.AccountGen.genVerifiedAccount import info.signalboost.signalc.testSupport.dataGenerators.AddressGen.genPhoneNumber import info.signalboost.signalc.testSupport.dataGenerators.AddressGen.genUuid +import info.signalboost.signalc.testSupport.dataGenerators.EnvelopeGen.genEnvelope import info.signalboost.signalc.util.KeyUtil.genProfileKeyBytes import io.kotest.assertions.throwables.shouldThrow import io.kotest.core.spec.style.FreeSpec @@ -33,7 +35,8 @@ class ContactStoreTest: FreeSpec({ val config = Config.mockAllExcept(ContactStore::class, HikariDataSource::class) val app = Application(config).run(testScope) - val accountId = genPhoneNumber() + val account = genVerifiedAccount() + val accountId = account.id val contactProfileKey = genRandomBytes(32) val contactUpdatedProfileKey = genRandomBytes(32) @@ -92,6 +95,18 @@ class ContactStoreTest: FreeSpec({ } } } + + "given an account and an envelope" - { + val envelope = genEnvelope() + val id = app.contactStore.create(account, envelope) + + "creates a contact and returns its id" { + id shouldBeGreaterThan 0 + app.contactStore.count() shouldBeGreaterThan initCount + app.contactStore.hasContact(accountId, envelope.sourceE164.get()) shouldBe true + app.contactStore.hasContact(accountId, envelope.sourceUuid.get()) shouldBe true + } + } } "#hasContact" - { diff --git a/signalc/src/test/kotlin/info/signalboost/signalc/testSupport/matchers/SignalMessageMatchers.kt b/signalc/src/test/kotlin/info/signalboost/signalc/testSupport/matchers/SignalMessageMatchers.kt index 2431b04c2f51c46b2d92174ae117bf2bb94ddab8..fcdf41dd611c0f9353ef0c673c7cd2bc68bede81 100644 --- a/signalc/src/test/kotlin/info/signalboost/signalc/testSupport/matchers/SignalMessageMatchers.kt +++ b/signalc/src/test/kotlin/info/signalboost/signalc/testSupport/matchers/SignalMessageMatchers.kt @@ -4,6 +4,7 @@ import io.mockk.MockKMatcherScope import org.whispersystems.libsignal.util.guava.Optional import org.whispersystems.signalservice.api.messages.SignalServiceAttachment import org.whispersystems.signalservice.api.messages.SignalServiceDataMessage +import org.whispersystems.signalservice.api.messages.SignalServiceReceiptMessage object SignalMessageMatchers { @@ -12,14 +13,30 @@ object SignalMessageMatchers { timestamp: Long? = null, expiresInSeconds: Int? = null, attachments: Optional<List<SignalServiceAttachment>>? = null, + isProfileKeyUpdate: Boolean? = null, + profileKey: ByteArray? = null, ): SignalServiceDataMessage = match { // check for equality of each provided param. if param not provided, don't check it! body?.let{ _ -> it.body.or("") == body } ?: true && - timestamp?.let { _ -> it.timestamp == timestamp } ?: true && - expiresInSeconds?.let { _ -> it.expiresInSeconds == expiresInSeconds } ?: true && - attachments?.let { _ -> it.attachments == attachments } ?: true + timestamp?.let { _ -> it.timestamp == timestamp } ?: true && + expiresInSeconds?.let { _ -> it.expiresInSeconds == expiresInSeconds } ?: true && + attachments?.let { _ -> it.attachments == attachments } ?: true && + isProfileKeyUpdate?.let { _ -> it.isProfileKeyUpdate == isProfileKeyUpdate} ?: true && + profileKey?.let { _ -> it.profileKey.or(ByteArray(0)).contentEquals(profileKey) } ?: true } + fun MockKMatcherScope.signalReceiptMessage( + type: SignalServiceReceiptMessage.Type? = null, + timestamps: List<Long>? = null, + id: Long? = null, + ): SignalServiceReceiptMessage = match { + // check for equality of each provided param. if param not provided, don't check it! + type?.let{ _ -> it.type == type } ?: true && + timestamps?.let { _ -> it.timestamps == timestamps } ?: true && + id?.let { _ -> it.`when` == id } ?: true + } + + fun MockKMatcherScope.signalExpirationUpdate( expiresInSeconds: Int, ): SignalServiceDataMessage = match {