ReplaceEntriesAtomicityTest.kt

3.3 KB · raw

1package com.peachpasswords.vault23import kotlinx.coroutines.runBlocking4import org.junit.Assert.assertEquals5import org.junit.Assert.assertTrue6import org.junit.Test78/**9 * M-01: import's REPLACE mode must be one all-or-nothing write. The old path10 * soft-deleted every current entry in its own durable write and then batch-added,11 * so a failed add left the user's real entries tombstoned in Trash with nothing12 * imported to show for it. [VaultRepository.replaceEntries] stages the swap in13 * memory and commits it once, so a write failure restores the active set.14 */15class ReplaceEntriesAtomicityTest {1617    private class FaultStorage : VaultStorage {18        var slotA: StoredVault? = null19        var slotB: String? = null20        var failWriteSlotA = false21        private var ver: Long = 022        private var settings = VaultSettings()23        override fun loadSlotA() = slotA24        override fun hasVault() = slotA != null25        override fun writeSlotA(slot: StoredVault): Boolean {26            if (failWriteSlotA) return false27            slotA = slot28            return true29        }30        override fun loadSlotB() = slotB31        override fun writeSlotB(blobBase64: String): Boolean { slotB = blobBase64; return true }32        var decoy: StoredVault? = null33        override fun loadDecoySlot() = decoy34        override fun writeDecoySlot(slot: StoredVault): Boolean { decoy = slot; return true }35        override fun hasDecoySlot() = decoy != null36        override fun clearDecoySlot() { decoy = null }37        override fun version() = ver38        override fun stampLocalMutation(): Long { ver = maxOf(System.currentTimeMillis(), ver + 1); return ver }39        override fun loadSettings() = settings40        override fun writeSettings(s: VaultSettings): Boolean { settings = s; return true }41        override fun clearVault(): Boolean { slotA = null; slotB = null; ver = 0; settings = VaultSettings(); return true }42    }4344    private fun login(title: String) =45        VaultEntry(id = "", title = title, type = "login", password = "pw", createdAt = 0, modifiedAt = 0)4647    @Test fun replace_swaps_active_set_in_one_write() = runBlocking {48        val repo = VaultRepository(FaultStorage())49        repo.createVault("pw")50        repo.addEntry(login("Old A"))51        repo.addEntry(login("Old B"))5253        assertEquals(2, repo.replaceEntries(listOf(login("New A"), login("New B"))))5455        assertEquals(listOf("New A", "New B"), repo.getEntries().map { it.title }.sorted())56        // The old set is tombstoned, not hard-dropped — paired devices still57        // need to learn about the deletion.58        assertEquals(listOf("Old A", "Old B"), repo.getDeletedEntries().map { it.title }.sorted())59    }6061    @Test fun failed_replace_restores_prior_active_entries() = runBlocking {62        val storage = FaultStorage()63        val repo = VaultRepository(storage)64        repo.createVault("pw")65        repo.addEntry(login("Keep me"))6667        storage.failWriteSlotA = true68        val threw = runCatching { repo.replaceEntries(listOf(login("Imported"))) }.isFailure69        assertTrue(threw)7071        // Neither tombstoned nor replaced: the pre-import view is intact.72        assertEquals(listOf("Keep me"), repo.getEntries().map { it.title })73        assertTrue(repo.getDeletedEntries().isEmpty())74    }75}