Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ object RestoreRepository {

if (!FullBackupImporter.validatePassphrase(context, backupFileUri, passphrase)) {
Log.i(TAG, "Restore failed due to invalid passphrase.")
return@withContext BackupImportResult.FAILURE_UNKNOWN
return@withContext BackupImportResult.FAILURE_PASSPHRASE_VALIDATION
}

Log.i(TAG, "Passphrase validated.")
Expand Down Expand Up @@ -101,6 +101,7 @@ object RestoreRepository {
SUCCESS,
FAILURE_VERSION_DOWNGRADE,
FAILURE_FOREIGN_KEY,
FAILURE_PASSPHRASE_VALIDATION,
FAILURE_UNKNOWN
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -237,11 +237,16 @@ class RestoreLocalBackupFragment : LoggingFragment(R.layout.fragment_restore_loc
Toast.makeText(requireContext(), R.string.RegistrationActivity_backup_failure_foreign_key, Toast.LENGTH_LONG).show()
}

RestoreRepository.BackupImportResult.FAILURE_UNKNOWN -> {
RestoreRepository.BackupImportResult.FAILURE_PASSPHRASE_VALIDATION -> {
Log.i(TAG, "Notifying user of restore failure due to incorrect passphrase.")
Toast.makeText(requireContext(), R.string.RegistrationActivity_incorrect_backup_passphrase, Toast.LENGTH_LONG).show()
}

RestoreRepository.BackupImportResult.FAILURE_UNKNOWN -> {
Log.i(TAG, "Notifying user of restore failure.")
Toast.makeText(requireContext(), R.string.RestoreLocalBackupActivity__backup_restore_failed, Toast.LENGTH_LONG).show()
}

RestoreRepository.BackupImportResult.SUCCESS -> {
Log.w(TAG, "Successful backup import should not be handled in this function.", IllegalStateException())
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,274 @@
/*
* Copyright 2026 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/

package org.thoughtcrime.securesms.restore

import android.app.Application
import android.content.Context
import android.net.Uri
import androidx.test.core.app.ApplicationProvider
import assertk.assertThat
import assertk.assertions.hasSize
import assertk.assertions.isEqualTo
import assertk.assertions.isFalse
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkObject
import io.mockk.mockkStatic
import io.mockk.unmockkAll
import io.mockk.verify
import kotlinx.coroutines.runBlocking
import net.zetetic.database.sqlcipher.SQLiteDatabase
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.signal.core.util.SqlUtil
import org.signal.core.util.crypto.AttachmentSecret
import org.signal.core.util.crypto.AttachmentSecretProvider
import org.signal.core.util.logging.Log
import org.thoughtcrime.securesms.AppInitialization
import org.thoughtcrime.securesms.backup.BackupPassphrase
import org.thoughtcrime.securesms.backup.FullBackupImporter
import org.thoughtcrime.securesms.database.SignalDatabase
import org.thoughtcrime.securesms.dependencies.AppDependencies
import org.thoughtcrime.securesms.jobmanager.impl.DataRestoreConstraint
import org.thoughtcrime.securesms.jobs.E164FormattingJob
import org.thoughtcrime.securesms.keyvalue.SettingsValues
import org.thoughtcrime.securesms.notifications.NotificationChannels
import org.thoughtcrime.securesms.service.LocalBackupListener
import org.thoughtcrime.securesms.testutil.LogRecorder
import org.thoughtcrime.securesms.testutil.MockAppDependenciesRule
import org.thoughtcrime.securesms.testutil.MockSignalStoreRule
import org.thoughtcrime.securesms.util.BackupUtil
import java.io.IOException

@RunWith(RobolectricTestRunner::class)
@Config(application = Application::class, manifest = Config.NONE)
class RestoreRepositoryTest {
@get:Rule
val signalStore = MockSignalStoreRule(relaxed = setOf(SettingsValues::class))

@get:Rule
val appDependencies = MockAppDependenciesRule()

private lateinit var context: Context
private lateinit var backupUri: Uri
private lateinit var logRecorder: LogRecorder
private lateinit var database: SQLiteDatabase
private lateinit var attachmentSecret: AttachmentSecret
private lateinit var attachmentSecretProvider: AttachmentSecretProvider
private lateinit var notificationChannels: NotificationChannels

@Before
fun setUp() {
context = ApplicationProvider.getApplicationContext()
backupUri = Uri.parse("content://org.thoughtcrime.securesms/backups/signal-backup")
logRecorder = LogRecorder()
Log.initialize(logRecorder)

database = mockk(relaxed = true)
attachmentSecret = mockk(relaxed = true)
attachmentSecretProvider = mockk()
notificationChannels = mockk(relaxed = true)

mockkObject(SignalDatabase.Companion)
mockkStatic(
FullBackupImporter::class,
BackupPassphrase::class,
AttachmentSecretProvider::class,
NotificationChannels::class,
BackupUtil::class,
LocalBackupListener::class,
AppInitialization::class
)

every { SignalDatabase.backupDatabase } returns database
every { SignalDatabase.runPostBackupRestoreTasks(any()) } answers { }
every { BackupPassphrase.set(any(), any()) } answers { }
every { AttachmentSecretProvider.getInstance(any(), any()) } returns attachmentSecretProvider
every { attachmentSecretProvider.getOrCreateAttachmentSecret() } returns attachmentSecret
every { NotificationChannels.getInstance() } returns notificationChannels
every { BackupUtil.canUserAccessBackupDirectory(any()) } returns true
every { LocalBackupListener.setNextBackupTimeToIntervalFromNow(any()) } returns 0L
every { LocalBackupListener.schedule(any()) } answers { }
every { AppInitialization.onPostBackupRestore(any()) } answers { }
every { signalStore.registration.localRegistrationMetadata } returns null
every { FullBackupImporter.validatePassphrase(any(), any(), any()) } returns true
stubImportFile(throwable = null)

DataRestoreConstraint.isRestoringData = false
}

@After
fun tearDown() {
DataRestoreConstraint.isRestoringData = false
unmockkAll()
}

@Test
fun `validation returning false is passphrase failure and does not import`() {
every { FullBackupImporter.validatePassphrase(any(), any(), any()) } returns false

val result = restore()

assertThat(result).isEqualTo(RestoreRepository.BackupImportResult.FAILURE_PASSPHRASE_VALIDATION)
verifyImportNotCalled()
verifyPostRestoreWorkSkipped()
}

@Test
fun `validation IOException is unknown failure and cleans up`() {
val ioException = IOException("unreadable content uri")
every { FullBackupImporter.validatePassphrase(any(), any(), any()) } throws ioException

val result = restore()

assertThat(result).isEqualTo(RestoreRepository.BackupImportResult.FAILURE_UNKNOWN)
verifyImportNotCalled()
verifyPostRestoreWorkSkipped()
assertUnknownFailureLogged(ioException)
}

@Test
fun `import IOException is unknown failure and skips post restore work`() {
val ioException = IOException("backup read failed after restore started")
stubImportFile(ioException)

val result = restore()

assertThat(result).isEqualTo(RestoreRepository.BackupImportResult.FAILURE_UNKNOWN)
verify(exactly = 1) {
FullBackupImporter.importFile(context, attachmentSecret, database, backupUri, PASSPHRASE, false)
}
verifyPostRestoreWorkSkipped()
assertUnknownFailureLogged(ioException)
}

@Test
fun `import DatabaseDowngradeException keeps the downgrade result`() {
stubImportFile(databaseDowngradeException())

val result = restore()

assertThat(result).isEqualTo(RestoreRepository.BackupImportResult.FAILURE_VERSION_DOWNGRADE)
verifyPostRestoreWorkSkipped()
}

@Test
fun `import ForeignKeyViolationException keeps the foreign key result`() {
stubImportFile(foreignKeyViolationException())

val result = restore()

assertThat(result).isEqualTo(RestoreRepository.BackupImportResult.FAILURE_FOREIGN_KEY)
verifyPostRestoreWorkSkipped()
}

@Test
fun `valid backup imports and runs post restore work`() {
val result = restore()

assertThat(result).isEqualTo(RestoreRepository.BackupImportResult.SUCCESS)
verify(exactly = 1) {
FullBackupImporter.importFile(context, attachmentSecret, database, backupUri, PASSPHRASE, false)
}
verify(exactly = 1) { SignalDatabase.runPostBackupRestoreTasks(database) }
verify(exactly = 1) { notificationChannels.restoreContactNotificationChannels() }
verify(exactly = 1) { AppDependencies.jobManager.add(ofType<E164FormattingJob>()) }
verify(exactly = 1) { BackupUtil.canUserAccessBackupDirectory(context) }
verify(exactly = 1) { LocalBackupListener.setNextBackupTimeToIntervalFromNow(context) }
verify(exactly = 1) { signalStore.settings.isBackupEnabled = true }
verify(exactly = 1) { LocalBackupListener.schedule(context) }
verify(exactly = 1) { AppInitialization.onPostBackupRestore(context) }
}

private fun restore(): RestoreRepository.BackupImportResult {
DataRestoreConstraint.isRestoringData = true

val result = runBlocking {
RestoreRepository.restoreBackupAsynchronously(context, backupUri, PASSPHRASE)
}

assertThat(DataRestoreConstraint.isRestoringData).isFalse()
return result
}

private fun stubImportFile(throwable: Throwable?) {
every {
FullBackupImporter.importFile(
any<Context>(),
any<AttachmentSecret>(),
any<SQLiteDatabase>(),
any<Uri>(),
any<String>(),
any<Boolean>()
)
} answers {
if (throwable != null) {
throw throwable
}
}
}

private fun verifyImportNotCalled() {
verify(exactly = 0) {
FullBackupImporter.importFile(
any<Context>(),
any<AttachmentSecret>(),
any<SQLiteDatabase>(),
any<Uri>(),
any<String>(),
any<Boolean>()
)
}
}

private fun verifyPostRestoreWorkSkipped() {
verify(exactly = 0) { SignalDatabase.runPostBackupRestoreTasks(any()) }
verify(exactly = 0) { notificationChannels.restoreContactNotificationChannels() }
verify(exactly = 0) { AppDependencies.jobManager.add(ofType<E164FormattingJob>()) }
verify(exactly = 0) { LocalBackupListener.setNextBackupTimeToIntervalFromNow(any()) }
verify(exactly = 0) { LocalBackupListener.schedule(any()) }
verify(exactly = 0) { AppInitialization.onPostBackupRestore(any()) }
verify(exactly = 0) { signalStore.settings.isBackupEnabled = true }
}

private fun assertUnknownFailureLogged(throwable: Throwable) {
val warnings = logRecorder.warnings.filter { entry ->
entry.tag == "RestoreRepository" && entry.message == "Restore failed due to unknown error!" && entry.throwable == throwable
}
assertThat(warnings).hasSize(1)
}

private fun databaseDowngradeException(): FullBackupImporter.DatabaseDowngradeException {
val constructor = FullBackupImporter.DatabaseDowngradeException::class.java.getDeclaredConstructor(
Int::class.javaPrimitiveType!!,
Int::class.javaPrimitiveType!!
)
constructor.isAccessible = true
return constructor.newInstance(1, 2) as FullBackupImporter.DatabaseDowngradeException
}

private fun foreignKeyViolationException(): FullBackupImporter.ForeignKeyViolationException {
return FullBackupImporter.ForeignKeyViolationException(
listOf(
SqlUtil.ForeignKeyViolation(
table = "message",
violatingRowId = 1L,
dependsOnTable = "thread",
column = "thread_id"
)
)
)
}

companion object {
private const val PASSPHRASE = "12345 67890"
}
}