RecoveryEscrowIntegrationTest.kt

4.8 KB · raw

1package com.peachpasswords.vault23import kotlinx.coroutines.runBlocking4import org.junit.Assert.assertEquals5import org.junit.Assert.assertFalse6import org.junit.Assert.assertNull7import org.junit.Assert.assertTrue8import org.junit.Test910/**11 * Integration coverage for the full create → confirm → seal → unlock recovery12 * chain (brief Fix 1). The UI confirmation step ([RecoveryWordsViewModel.confirmAndComplete])13 * does exactly one thing the repository can be tested against: it derives the14 * vault's recovery words from the sync root key and calls [VaultRepository.setRecoveryWords].15 * These tests drive that same call so the whole escrow-sealing path is exercised16 * without a Compose harness.17 */18class RecoveryEscrowIntegrationTest {1920    private class FakeStorage : VaultStorage {21        var slotA: StoredVault? = null22        var slotB: String? = null23        private var ver: Long = 024        private var settings = VaultSettings()25        override fun loadSlotA() = slotA26        override fun hasVault() = slotA != null27        override fun writeSlotA(slot: StoredVault): Boolean { slotA = slot; return true }28        override fun writeSlotB(blobBase64: String): Boolean { slotB = blobBase64; return true }29        var decoy: StoredVault? = null30        override fun loadDecoySlot() = decoy31        override fun writeDecoySlot(slot: StoredVault): Boolean { decoy = slot; return true }32        override fun hasDecoySlot() = decoy != null33        override fun clearDecoySlot() { decoy = null }34        override fun version() = ver35        override fun stampLocalMutation(): Long { ver = maxOf(System.currentTimeMillis(), ver + 1); return ver }36        override fun loadSettings() = settings37        override fun writeSettings(s: VaultSettings): Boolean { settings = s; return true }38        override fun clearVault(): Boolean { slotA = null; slotB = null; ver = 0; settings = VaultSettings(); return true }39    }4041    /** The 12 recovery words this vault would actually display, as the confirm screen derives them. */42    private fun recoveryWordsOf(repo: VaultRepository): List<String> {43        val key = repo.syncMasterKey()!!44        return try { rootKeyToWords(key) } finally { VaultCrypto.zero(key) }45    }4647    private fun login(title: String, password: String = "pw") =48        VaultEntry(id = "", title = title, type = "login", password = password, createdAt = 0, modifiedAt = 0)4950    /** create vault → confirm words → escrow is sealed at rest. */51    @Test fun vault_creation_seals_recovery_escrow() = runBlocking {52        val storage = FakeStorage()53        val repo = VaultRepository(storage)54        repo.createVault("correct horse battery")55        // Pre-confirmation: nothing sealed yet.56        assertFalse(repo.hasRecoveryEscrow())57        // The confirmation step seals both escrows.58        assertTrue(repo.setRecoveryWords(recoveryWordsOf(repo)))59        assertTrue(repo.hasRecoveryEscrow())60    }6162    /** Full round-trip: create → confirm → lock → unlock with recovery words → success. */63    @Test fun recovery_words_unlock_after_creation() = runBlocking {64        val storage = FakeStorage()65        val repo = VaultRepository(storage)66        repo.createVault("correct horse battery")67        repo.addEntry(login("GitHub"))68        val words = recoveryWordsOf(repo)69        repo.setRecoveryWords(words)70        repo.lock()7172        assertTrue(repo.unlockWithRecoveryWords(words))73        assertTrue(repo.getEntries().any { it.title == "GitHub" })74    }7576    /** A vault whose confirmation step never ran exposes no escrow → "recovery not available". */77    @Test fun recovery_not_available_before_confirmation() = runBlocking {78        val storage = FakeStorage()79        val repo = VaultRepository(storage)80        repo.createVault("correct horse battery")81        val words = recoveryWordsOf(repo) // derived, but never confirmed82        repo.lock()83        assertFalse(repo.hasRecoveryEscrow())84        assertFalse(repo.unlockWithRecoveryWords(words))85    }8687    /** The original recovery words keep working after the master password changes. */88    @Test fun recovery_survives_password_change() = runBlocking {89        val storage = FakeStorage()90        val repo = VaultRepository(storage)91        repo.createVault("first-master-pass")92        repo.addEntry(login("Bank"))93        val words = recoveryWordsOf(repo)94        repo.setRecoveryWords(words)9596        assertTrue(repo.changePassword("first-master-pass", "second-master-pass"))97        repo.lock()9899        // Recover with the ORIGINAL words, then set a third password and unlock with it.100        assertTrue(repo.unlockWithRecoveryWords(words))101        assertTrue(repo.resetPasswordWithRecoveryWords(words, "third-master-pass"))102        repo.lock()103        assertEquals(UnlockResult.Success, repo.unlock("third-master-pass"))104        assertTrue(repo.getEntries().any { it.title == "Bank" })105    }106}