1package com.peachpasswords.vault23import kotlinx.coroutines.runBlocking4import org.junit.Assert.assertEquals5import org.junit.Assert.assertFalse6import org.junit.Assert.assertTrue7import org.junit.Test8import java.util.concurrent.CountDownLatch9import java.util.concurrent.TimeUnit10import java.util.concurrent.atomic.AtomicReference1112131415161718class SessionEpochRaceTest {1920 private class Storage : VaultStorage {21 var slotA: StoredVault? = null22 var slotB: String? = null23 private var version = 0L24 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 loadSlotB() = slotB29 override fun writeSlotB(blobBase64: String): Boolean { slotB = blobBase64; return true }30 private var decoy: StoredVault? = null31 override fun loadDecoySlot() = decoy32 override fun writeDecoySlot(slot: StoredVault): Boolean { decoy = slot; return true }33 override fun hasDecoySlot() = decoy != null34 override fun clearDecoySlot() { decoy = null }35 override fun version() = version36 override fun stampLocalMutation(): Long { version += 1; return version }37 override fun loadSettings() = settings38 override fun writeSettings(settings: VaultSettings): Boolean { this.settings = settings; return true }39 override fun clearVault() { slotA = null; slotB = null; decoy = null; version = 0; settings = VaultSettings() }40 }4142 private fun login(title: String) = VaultEntry(43 id = "",44 title = title,45 type = "login",46 password = "secret",47 createdAt = 0,48 modifiedAt = 0,49 )5051 @Test52 fun lock_invalidates_save_paused_before_commit() = runBlocking {53 val repo = VaultRepository(Storage())54 assertTrue(repo.createVault("pass") is CreateVaultResult.Success)55 repo.addEntry(login("before"))5657 val paused = CountDownLatch(1)58 val release = CountDownLatch(1)59 val failure = AtomicReference<Throwable?>()60 repo.beforeDurableCommit = {61 paused.countDown()62 check(release.await(5, TimeUnit.SECONDS)) { "test writer did not release" }63 }6465 val writer = Thread {66 try {67 runBlocking { repo.addEntry(login("must-not-persist")) }68 } catch (t: Throwable) {69 failure.set(t)70 }71 }72 writer.start()73 assertTrue("writer did not reach the pre-commit seam", paused.await(5, TimeUnit.SECONDS))7475 repo.lock()76 assertFalse(repo.isUnlocked.value)77 assertTrue(repo.getEntries().isEmpty())78 assertEquals(null, repo.currentVaultKey())79 release.countDown()80 writer.join(5_000)81 assertFalse("writer must not remain blocked", writer.isAlive)82 assertTrue("stale writer should fail closed", failure.get() is IllegalStateException)8384 repo.beforeDurableCommit = {}85 assertTrue(repo.unlock("pass") is UnlockResult.Success)86 assertEquals(listOf("before"), repo.getEntries().map { it.title })87 }8889 @Test90 fun lock_invalidates_sync_apply_before_commit() = runBlocking {91 val repo = VaultRepository(Storage())92 assertTrue(repo.createVault("pass") is CreateVaultResult.Success)9394 val paused = CountDownLatch(1)95 val release = CountDownLatch(1)96 val failure = AtomicReference<Throwable?>()97 repo.beforeDurableCommit = {98 paused.countDown()99 check(release.await(5, TimeUnit.SECONDS)) { "test sync did not release" }100 }101 val remote = VaultEntry(102 id = "remote-race",103 title = "must-not-persist",104 type = "login",105 password = "secret",106 createdAt = 1,107 modifiedAt = System.currentTimeMillis() + 1_000,108 )109 val writer = Thread {110 try {111 runBlocking { repo.applyMergedRemoteEntries(listOf(remote), emptyList()) }112 } catch (t: Throwable) {113 failure.set(t)114 }115 }116 writer.start()117 assertTrue(paused.await(5, TimeUnit.SECONDS))118119 repo.lock()120 release.countDown()121 writer.join(5_000)122 assertFalse(writer.isAlive)123 assertTrue("stale sync must fail closed or return no-op", failure.get() == null || failure.get() is IllegalStateException)124125 repo.beforeDurableCommit = {}126 assertTrue(repo.unlock("pass") is UnlockResult.Success)127 assertEquals(null, repo.getEntry(remote.id))128 }129130 @Test131 fun lock_before_state_mutation_cannot_repopulate_a_new_session() = runBlocking {132 val repo = VaultRepository(Storage())133 assertTrue(repo.createVault("pass") is CreateVaultResult.Success)134 repo.addEntry(login("before"))135136 val paused = CountDownLatch(1)137 val release = CountDownLatch(1)138 val failure = AtomicReference<Throwable?>()139 repo.beforeSessionStateMutation = {140 paused.countDown()141 check(release.await(5, TimeUnit.SECONDS)) { "test writer did not release" }142 }143144 val writer = Thread {145 try {146 runBlocking { repo.addEntry(login("stale-state")) }147 } catch (t: Throwable) {148 failure.set(t)149 }150 }151 writer.start()152 assertTrue("writer did not reach the pre-state seam", paused.await(5, TimeUnit.SECONDS))153154 155 156 repo.lock()157 assertTrue(repo.getEntries().isEmpty())158 assertTrue(repo.unlock("pass") is UnlockResult.Success)159 assertEquals(listOf("before"), repo.getEntries().map { it.title })160161 release.countDown()162 writer.join(5_000)163 assertFalse("stale writer must finish", writer.isAlive)164 assertTrue("stale writer must fail closed", failure.get() is IllegalStateException)165 assertEquals(listOf("before"), repo.getEntries().map { it.title })166167 repo.beforeSessionStateMutation = {}168 }169}