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

179 lines
6.7 KiB
Kotlin
Raw Normal View History

feat: give the lock a method, and every old install the one it had Groundwork for offering a PIN, a fingerprint, or either. This commit is the storage and the migration; the screens follow. LockMethod is an enum rather than two booleans because BIOMETRIC is the state with no fallback, and everything that has to be careful -- the migration, the settings transitions, what happens when a sensor stops working -- is careful specifically about the absence of a PIN. A boolean pair spreads that condition across two fields nothing stops disagreeing. It lives in the lock's own DataStore, not UserPreferences, and that placement is the point: resetToDefaults() there is edit { clear() }, so a fingerprint-only lock recorded beside the theme would be one "reset my settings" away from silently vanishing. resolve() decides what the stored state actually means, and each of its three rules closes a way somebody could be locked out or wrongly let in. A verifier with no recorded method reads as PIN, so the gate is shut from the first frame of an old install rather than waiting for a migration. A method needing a PIN with no verifier reads as NONE -- the same policy VerifierRecord.decode already applies, because a lock nobody can open is worse than no lock when the way out is behind it. An unrecognised name falls back to the verifier, so a newer build's value cannot brick an older one. A PIN is never checked for a method that does not use one, even with a stale record in the file. The migration can never produce BIOMETRIC. recordMigratedMethod refuses it at the API, and both its conditions -- nothing recorded, verifier agrees -- are evaluated inside the DataStore transaction, so a migration racing an erase cannot resurrect a lock the user just removed. Getting this wrong does not show a wrong number on a screen: it locks somebody out of their own history on an update they did not ask for. The migration lives in app because it reads two stores and core/security depends on nothing. 24 repository tests and 9 migration tests, including all four legacy combinations asserting the never-PIN-less invariant, proved by removing the require and watching exactly one go red. Part of #63 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-21 01:32:37 -05:00
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())
}
}