SnapshotRestoreAtomicityTest.kt

8.1 KB · raw

1package com.peachpasswords.vault23import kotlinx.coroutines.runBlocking4import org.junit.Assert.assertEquals5import org.junit.Assert.assertFalse6import org.junit.Assert.assertNotEquals7import org.junit.Assert.assertTrue8import org.junit.Test910/**11 * H-02: a snapshot restore must either fully commit slot A + slot B + version12 * stamp or leave every byte of at-rest state (slot A, slot B, history)13 * untouched. A partial commit strands the session on a vault that no longer14 * matches disk, and a "restore" history entry written before the commit makes15 * a failed restore look like a successful one in the history list.16 */17class SnapshotRestoreAtomicityTest {1819    private class FaultStorage : VaultStorage {20        var slotA: StoredVault? = null21        var slotB: String? = null22        var failWriteSlotA = false23        var failWriteSlotB = false24        var failStamp = false25        private var ver: Long = 026        private var settings = VaultSettings()27        var history = VaultHistory()28        override fun loadSlotA() = slotA29        override fun hasVault() = slotA != null30        override fun writeSlotA(slot: StoredVault): Boolean {31            if (failWriteSlotA) return false32            slotA = slot; return true33        }34        override fun loadSlotB() = slotB35        override fun writeSlotB(blobBase64: String): Boolean {36            if (failWriteSlotB) return false37            slotB = blobBase64; return true38        }39        var decoy: StoredVault? = null40        override fun loadDecoySlot() = decoy41        override fun writeDecoySlot(slot: StoredVault): Boolean { decoy = slot; return true }42        override fun hasDecoySlot() = decoy != null43        override fun clearDecoySlot() { decoy = null }44        override fun version() = ver45        override fun stampLocalMutation(): Long {46            if (failStamp) throw RuntimeException("stamp failed")47            ver = maxOf(System.currentTimeMillis(), ver + 1); return ver48        }49        override fun loadSettings() = settings50        override fun writeSettings(s: VaultSettings): Boolean { settings = s; return true }51        override fun loadVaultHistory() = history52        override fun writeVaultHistory(history: VaultHistory): Boolean { this.history = history; return true }53        override fun clearVaultHistory() { history = VaultHistory() }54        override fun clearVault(): Boolean { slotA = null; slotB = null; ver = 0; settings = VaultSettings(); history = VaultHistory(); return true }55    }5657    /**58     * Build a repo with two entries plus a restorable snapshot. After setup,59     * the on-disk vault holds both entries; the captured snapshot holds only60     * the first entry (takeVaultSnapshot reads slot A from disk BEFORE the new61     * blob is written, so the snapshot from addEntry("Second") captures the62     * First-only state that was on disk just before Second was persisted).63     */64    private data class Fixture(65        val repo: VaultRepository,66        val storage: FaultStorage,67        val snapshotId: String,68    )6970    private fun setup(): Fixture = runBlocking {71        val storage = FaultStorage()72        val repo = VaultRepository(storage)73        repo.createVault("pw")74        repo.addEntry(VaultEntry(id = "", title = "First", type = "login", password = "p", createdAt = 0, modifiedAt = 0))75        repo.addEntry(VaultEntry(id = "", title = "Second", type = "login", password = "p", createdAt = 0, modifiedAt = 0))76        val snapshotId = repo.getVaultHistory().first().id77        Fixture(repo, storage, snapshotId)78    }7980    private fun loginEntry(title: String) =81        VaultEntry(id = "", title = title, type = "login", password = "p", createdAt = 0, modifiedAt = 0)8283    @Test fun happy_path_swaps_slots_locks_and_appends_restore_snapshot() = runBlocking {84        val f = setup()85        val slotABefore = f.storage.slotA86        val slotBBefore = f.storage.slotB87        val historyBefore = f.storage.history.snapshots.toList()8889        assertTrue(f.repo.restoreVaultSnapshot(f.snapshotId))9091        // Disk now holds the restored pair, not the prior pair.92        assertNotEquals(slotABefore, f.storage.slotA)93        assertNotEquals(slotBBefore, f.storage.slotB)94        // Session is locked; the original "restore" entry was taken BEFORE the95        // commit (it captures the pre-restore state), so it remains in history.96        assertFalse(f.repo.isUnlocked.value)97        assertEquals(historyBefore.size + 1, f.storage.history.snapshots.size)98        assertEquals("restore", f.storage.history.snapshots.first().reason)99        // Vault still opens under the same password and shows the restored entry set.100        assertEquals(UnlockResult.Success, f.repo.unlock("pw"))101        assertEquals(1, f.repo.getEntries().size)102        assertTrue(f.repo.getEntries().any { it.title == "First" })103        assertFalse(f.repo.getEntries().any { it.title == "Second" })104    }105106    @Test fun write_slot_a_failure_keeps_slots_and_history_unchanged() = runBlocking {107        val f = setup()108        val slotABefore = f.storage.slotA109        val slotBBefore = f.storage.slotB110        val historyBefore = f.storage.history.snapshots.toList()111        f.storage.failWriteSlotA = true112113        assertFalse(f.repo.restoreVaultSnapshot(f.snapshotId))114115        assertEquals(slotABefore, f.storage.slotA)116        assertEquals(slotBBefore, f.storage.slotB)117        assertEquals(historyBefore, f.storage.history.snapshots)118        assertTrue(f.storage.history.snapshots.none { it.reason == "restore" })119        // Session is still on the pre-restore key.120        assertTrue(f.repo.isUnlocked.value)121        assertEquals(UnlockResult.Success, f.repo.unlock("pw"))122        assertTrue(f.repo.getEntries().any { it.title == "Second" })123    }124125    @Test fun write_slot_b_failure_rolls_back_slot_a_and_history() = runBlocking {126        val f = setup()127        val slotABefore = f.storage.slotA128        val slotBBefore = f.storage.slotB129        val historyBefore = f.storage.history.snapshots.toList()130        f.storage.failWriteSlotB = true131132        assertFalse(f.repo.restoreVaultSnapshot(f.snapshotId))133134        // The pre-restore pair is back on disk — without the rollback, the at-rest135        // pair would be (restored A, prior B) and an at-rest observer could pick136        // the real slot by size (MN-1).137        assertEquals(slotABefore, f.storage.slotA)138        assertEquals(slotBBefore, f.storage.slotB)139        assertEquals(historyBefore, f.storage.history.snapshots)140        assertTrue(f.storage.history.snapshots.none { it.reason == "restore" })141        // Session still on prior key.142        assertTrue(f.repo.isUnlocked.value)143        assertEquals(UnlockResult.Success, f.repo.unlock("pw"))144        assertTrue(f.repo.getEntries().any { it.title == "Second" })145    }146147    @Test fun stamp_failure_rolls_back_both_slots_and_history() = runBlocking {148        val f = setup()149        val slotABefore = f.storage.slotA150        val slotBBefore = f.storage.slotB151        val historyBefore = f.storage.history.snapshots.toList()152        f.storage.failStamp = true153154        assertFalse(f.repo.restoreVaultSnapshot(f.snapshotId))155156        // Stamp happens AFTER slot B is written; rollback must restore BOTH slots.157        assertEquals(slotABefore, f.storage.slotA)158        assertEquals(slotBBefore, f.storage.slotB)159        assertEquals(historyBefore, f.storage.history.snapshots)160        assertTrue(f.storage.history.snapshots.none { it.reason == "restore" })161        // Vault still on prior key — both entries still readable.162        assertEquals(UnlockResult.Success, f.repo.unlock("pw"))163        assertTrue(f.repo.getEntries().any { it.title == "First" })164        assertTrue(f.repo.getEntries().any { it.title == "Second" })165    }166167    @Test fun unknown_snapshot_id_is_a_no_op() = runBlocking {168        val f = setup()169        val slotABefore = f.storage.slotA170        val slotBBefore = f.storage.slotB171        val historyBefore = f.storage.history.snapshots.toList()172        assertTrue(f.repo.isUnlocked.value)173174        assertFalse(f.repo.restoreVaultSnapshot("does-not-exist"))175176        assertEquals(slotABefore, f.storage.slotA)177        assertEquals(slotBBefore, f.storage.slotB)178        assertEquals(historyBefore, f.storage.history.snapshots)179        assertTrue(f.repo.isUnlocked.value)180    }181}