1package com.peachpasswords.vault23import kotlinx.coroutines.runBlocking4import org.junit.Assert.assertEquals5import org.junit.Assert.assertFalse6import org.junit.Assert.assertNotNull7import org.junit.Assert.assertNull8import org.junit.Assert.assertTrue9import org.junit.Assert.fail10import org.junit.Test111213141516171819202122class LockRaceAtomicityTest {2324 private class InMemStorage : VaultStorage {25 var slotA: StoredVault? = null26 var slotB: String? = null27 private var ver: Long = 028 private var settings = VaultSettings()29 override fun loadSlotA() = slotA30 override fun hasVault() = slotA != null31 override fun writeSlotA(slot: StoredVault): Boolean { slotA = slot; return true }32 override fun loadSlotB() = slotB33 override fun writeSlotB(blobBase64: String): Boolean { slotB = blobBase64; return true }34 override fun loadDecoySlot(): StoredVault? = null35 override fun writeDecoySlot(slot: StoredVault): Boolean = true36 override fun hasDecoySlot(): Boolean = false37 override fun clearDecoySlot() {}38 override fun version() = ver39 override fun stampLocalMutation(): Long { ver = ver + 1; return ver }40 override fun loadSettings() = settings41 override fun writeSettings(s: VaultSettings): Boolean { settings = s; return true }42 override fun clearVault(): Boolean { slotA = null; slotB = null; ver = 0; settings = VaultSettings(); return true }43 }4445 private fun unlockedRepo(): VaultRepository = runBlocking {46 val repo = VaultRepository(InMemStorage())47 repo.createVault("pass")48 repo.addEntry(49 VaultEntry(50 id = "", title = "Existing", type = "login", password = "p",51 createdAt = 0, modifiedAt = 0,52 ),53 )54 repo55 }5657 @Test58 fun lock_during_concurrent_saves_never_breaks_invariant() {59 val repo = unlockedRepo()6061 62 63 64 65 66 67 repeat(20) {68 val writer = Thread {69 runBlocking {70 repo.addEntry(71 VaultEntry(72 id = "", title = "t$it", type = "login", password = "p",73 createdAt = 0, modifiedAt = 0,74 ),75 )76 }77 }78 writer.start()79 repo.lock()80 runBlocking { repo.unlock("pass") }81 writer.join(5000)8283 84 85 val unlocked = repo.isUnlocked.value86 val key = repo.currentVaultKey()87 val entries = repo.entries.value88 89 90 assertEquals(91 "derivedKey must match isUnlocked (cycle $it)",92 unlocked, key != null,93 )94 95 96 97 98 99 100 if (unlocked) {101 102 103 104 assertTrue("entries must be readable when unlocked", entries.size >= 0)105 } else {106 107 108 109 110 assertTrue(entries.isEmpty())111 }112 }113 }114115 @Test116 fun requireUnlocked_via_addEntry_throws_after_lock() {117 val repo = unlockedRepo()118 repo.lock()119 try {120 runBlocking {121 repo.addEntry(122 VaultEntry(123 id = "", title = "x", type = "login", password = "p",124 createdAt = 0, modifiedAt = 0,125 ),126 )127 }128 fail("expected post-lock addEntry to throw vault_locked")129 } catch (e: IllegalStateException) {130 assertEquals("vault_locked", e.message)131 }132 assertFalse(repo.isUnlocked.value)133 assertNull(repo.currentVaultKey())134 assertEquals(emptyList<VaultEntry>(), repo.getEntries())135 }136137 @Test138 fun lock_from_lifecycle_thread_observes_consistent_state() {139 140 141 142 val repo = unlockedRepo()143 val writerStarted = java.util.concurrent.CountDownLatch(1)144 val writerDone = java.util.concurrent.CountDownLatch(1)145 val writer = Thread {146 writerStarted.countDown()147 runBlocking {148 repo.addEntry(149 VaultEntry(150 id = "", title = "Concurrent", type = "login", password = "p",151 createdAt = 0, modifiedAt = 0,152 ),153 )154 }155 writerDone.countDown()156 }157 writer.start()158 assertTrue(writerStarted.await(5, java.util.concurrent.TimeUnit.SECONDS))159 160 repo.lock()161 162 writer.join(5000)163164 165 166 167 val unlocked = repo.isUnlocked.value168 val entries = repo.getEntries()169 val key = repo.currentVaultKey()170 if (unlocked) {171 assertNotNull("unlocked session must have a derivedKey", key)172 } else {173 assertNull("locked session must have no derivedKey", key)174 assertEquals("locked session must have no entries", emptyList<VaultEntry>(), entries)175 }176 }177}