package com.peachpasswords.vault import kotlinx.coroutines.runBlocking import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test /** * M-01: import's REPLACE mode must be one all-or-nothing write. The old path * soft-deleted every current entry in its own durable write and then batch-added, * so a failed add left the user's real entries tombstoned in Trash with nothing * imported to show for it. [VaultRepository.replaceEntries] stages the swap in * memory and commits it once, so a write failure restores the active set. */ class ReplaceEntriesAtomicityTest { private class FaultStorage : VaultStorage { var slotA: StoredVault? = null var slotB: String? = null var failWriteSlotA = false private var ver: Long = 0 private var settings = VaultSettings() override fun loadSlotA() = slotA override fun hasVault() = slotA != null override fun writeSlotA(slot: StoredVault): Boolean { if (failWriteSlotA) return false slotA = slot return true } override fun loadSlotB() = slotB override fun writeSlotB(blobBase64: String): Boolean { slotB = blobBase64; return true } var decoy: StoredVault? = null override fun loadDecoySlot() = decoy override fun writeDecoySlot(slot: StoredVault): Boolean { decoy = slot; return true } override fun hasDecoySlot() = decoy != null override fun clearDecoySlot() { decoy = null } override fun version() = ver override fun stampLocalMutation(): Long { ver = maxOf(System.currentTimeMillis(), ver + 1); return ver } override fun loadSettings() = settings override fun writeSettings(s: VaultSettings): Boolean { settings = s; return true } override fun clearVault(): Boolean { slotA = null; slotB = null; ver = 0; settings = VaultSettings(); return true } } private fun login(title: String) = VaultEntry(id = "", title = title, type = "login", password = "pw", createdAt = 0, modifiedAt = 0) @Test fun replace_swaps_active_set_in_one_write() = runBlocking { val repo = VaultRepository(FaultStorage()) repo.createVault("pw") repo.addEntry(login("Old A")) repo.addEntry(login("Old B")) assertEquals(2, repo.replaceEntries(listOf(login("New A"), login("New B")))) assertEquals(listOf("New A", "New B"), repo.getEntries().map { it.title }.sorted()) // The old set is tombstoned, not hard-dropped — paired devices still // need to learn about the deletion. assertEquals(listOf("Old A", "Old B"), repo.getDeletedEntries().map { it.title }.sorted()) } @Test fun failed_replace_restores_prior_active_entries() = runBlocking { val storage = FaultStorage() val repo = VaultRepository(storage) repo.createVault("pw") repo.addEntry(login("Keep me")) storage.failWriteSlotA = true val threw = runCatching { repo.replaceEntries(listOf(login("Imported"))) }.isFailure assertTrue(threw) // Neither tombstoned nor replaced: the pre-import view is intact. assertEquals(listOf("Keep me"), repo.getEntries().map { it.title }) assertTrue(repo.getDeletedEntries().isEmpty()) } }