Privacy-Period-Tracker/app/src/test/kotlin/dev/privacyllc/period/lock/LockMethodMigrationTest.kt

179 lines
6.7 KiB
Kotlin

package dev.privacyllc.period.lock
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import dev.privacyllc.period.core.datastore.UserPreferencesRepository
import dev.privacyllc.period.core.security.AppLockRepository
import dev.privacyllc.period.core.security.LockMethod
import dev.privacyllc.period.core.security.MacProvider
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
/**
* Giving an existing install the lock method it was already using.
*
* The reason this has its own test file rather than a couple of cases somewhere:
* a migration that gets this wrong does not show a wrong number on a screen. It
* locks somebody out of their own history, permanently, on an update they did
* not ask for — because the policy behind this lock is that a forgotten PIN is
* not recoverable.
*
* So the load-bearing assertion here is a negative one: whatever the old install
* looked like, this can never produce a method with no PIN behind it.
*/
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class LockMethodMigrationTest {
@get:Rule val temp = TemporaryFolder()
/** See LockSettingsViewModelTest: AndroidKeyStore does not exist off-device. */
private class InMemoryMacProvider : MacProvider {
private var key: ByteArray? = null
override fun hasKey() = key != null
override fun ensureKey() { if (key == null) key = ByteArray(32) { it.toByte() } }
override fun mac(data: ByteArray): ByteArray {
val k = key ?: error("the verifier key is absent")
return Mac.getInstance("HmacSHA256").apply { init(SecretKeySpec(k, "HmacSHA256")) }.doFinal(data)
}
override fun deleteKey() { key = null }
}
private lateinit var lock: AppLockRepository
private lateinit var preferences: UserPreferencesRepository
private lateinit var migration: LockMethodMigration
@Before fun setUp() {
val scope = CoroutineScope(Dispatchers.IO)
// Unique names: several tests re-run this to walk every legacy shape,
// and TemporaryFolder refuses to hand out the same file twice.
val id = System.nanoTime()
lock = AppLockRepository(
PreferenceDataStoreFactory.create(scope = scope) { temp.newFile("app_lock_$id.preferences_pb") },
InMemoryMacProvider(),
)
preferences = UserPreferencesRepository(
PreferenceDataStoreFactory.create(scope = scope) { temp.newFile("prefs_$id.preferences_pb") },
)
migration = LockMethodMigration(lock, preferences)
}
/** What an install from before the method key looked like. */
private fun legacyInstall(hasPin: Boolean, fingerprintFlag: Boolean) = runBlocking {
if (hasPin) lock.setPin("2468".toCharArray())
preferences.setBiometricLockEnabled(fingerprintFlag)
// The method key is what the old build never wrote.
if (hasPin) lock.clearStoredMethodForTest()
}
@Test fun `a PIN with the fingerprint shortcut on becomes either`() = runBlocking {
legacyInstall(hasPin = true, fingerprintFlag = true)
migration.run()
assertEquals(LockMethod.PIN_AND_BIOMETRIC, lock.method.first())
}
@Test fun `a PIN without the shortcut becomes a PIN`() = runBlocking {
legacyInstall(hasPin = true, fingerprintFlag = false)
migration.run()
assertEquals(LockMethod.PIN, lock.method.first())
}
@Test fun `no PIN becomes off, whatever the old flag said`() = runBlocking {
listOf(true, false).forEach { flag ->
setUp()
legacyInstall(hasPin = false, fingerprintFlag = flag)
migration.run()
assertEquals(LockMethod.NONE, lock.method.first())
}
}
@Test fun `it never produces a lock with no PIN behind it`() = runBlocking {
// All four legacy combinations. A sensor as the only way in is a
// decision the user has to make deliberately, never one an update makes
// for her.
listOf(true to true, true to false, false to true, false to false).forEach { (hasPin, flag) ->
setUp()
legacyInstall(hasPin = hasPin, fingerprintFlag = flag)
migration.run()
val method = lock.method.first()
assertTrue(
"a legacy install (pin=$hasPin, flag=$flag) migrated to $method",
method == LockMethod.NONE || method.requiresPin,
)
}
}
@Test fun `running it twice changes nothing`() = runBlocking {
legacyInstall(hasPin = true, fingerprintFlag = true)
migration.run()
migration.run()
assertEquals(LockMethod.PIN_AND_BIOMETRIC, lock.method.first())
}
@Test fun `it leaves a method the user has already chosen alone`() = runBlocking {
// She turned the fingerprint shortcut off in a newer build; the stale
// flag must not turn it back on.
runBlocking { lock.setPin("2468".toCharArray(), LockMethod.PIN) }
preferences.setBiometricLockEnabled(true)
migration.run()
assertEquals(LockMethod.PIN, lock.method.first())
}
@Test fun `the retired flag is cleared once it has been read`() = runBlocking {
legacyInstall(hasPin = true, fingerprintFlag = true)
migration.run()
assertNull(preferences.legacyBiometricLockEnabled.first())
}
@Test fun `an unreadable preferences file still leaves a PIN path`() = runBlocking {
legacyInstall(hasPin = true, fingerprintFlag = true)
// A flag that cannot be read is a flag that was not set: the worst it
// costs is a shortcut she turns back on.
val broken = UserPreferencesRepository(
PreferenceDataStoreFactory.create(scope = CoroutineScope(Dispatchers.IO)) {
temp.newFile("broken_${System.nanoTime()}.preferences_pb")
.apply { writeBytes(byteArrayOf(9, 9, 9)) }
},
)
LockMethodMigration(lock, broken).run()
assertTrue(lock.method.first().requiresPin)
}
@Test fun `a fresh install is simply off`() = runBlocking {
migration.run()
assertEquals(LockMethod.NONE, lock.method.first())
assertFalse(lock.hasPin.first())
}
}