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.AtomicReference11import java.util.concurrent.atomic.AtomicInteger12import com.peachpasswords.sync.LockedSyncSpoolHooks1314151617181920class SessionEpochRaceTest {2122 private class Storage : VaultStorage {23 var slotA: StoredVault? = null24 var slotB: String? = null25 private var version = 0L26 private var settings = VaultSettings()27 override fun loadSlotA() = slotA28 override fun hasVault() = slotA != null29 override fun writeSlotA(slot: StoredVault): Boolean { slotA = slot; return true }30 override fun loadSlotB() = slotB31 override fun writeSlotB(blobBase64: String): Boolean { slotB = blobBase64; return true }32 private 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() = version38 override fun stampLocalMutation(): Long { version += 1; return version }39 override fun loadSettings() = settings40 override fun writeSettings(settings: VaultSettings): Boolean { this.settings = settings; return true }41 override fun clearVault(): Boolean { slotA = null; slotB = null; decoy = null; version = 0; settings = VaultSettings(); return true }42 }4344 private fun login(title: String) = VaultEntry(45 id = "",46 title = title,47 type = "login",48 password = "secret",49 createdAt = 0,50 modifiedAt = 0,51 )5253 @Test54 fun lock_invalidates_save_paused_before_commit() = runBlocking {55 val repo = VaultRepository(Storage())56 assertTrue(repo.createVault("pass") is CreateVaultResult.Success)57 repo.addEntry(login("before"))5859 val paused = CountDownLatch(1)60 val release = CountDownLatch(1)61 val failure = AtomicReference<Throwable?>()62 val spoolRefreshes = AtomicInteger()63 LockedSyncSpoolHooks.refresh = { spoolRefreshes.incrementAndGet() }64 repo.beforeDurableCommit = {65 paused.countDown()66 check(release.await(5, TimeUnit.SECONDS)) { "test writer did not release" }67 }6869 val writer = Thread {70 try {71 runBlocking { repo.addEntry(login("must-not-persist")) }72 } catch (t: Throwable) {73 failure.set(t)74 }75 }76 writer.start()77 assertTrue("writer did not reach the pre-commit seam", paused.await(5, TimeUnit.SECONDS))7879 repo.lock()80 assertFalse(repo.isUnlocked.value)81 assertTrue(repo.getEntries().isEmpty())82 assertEquals(null, repo.currentVaultKey())83 release.countDown()84 writer.join(5_000)85 assertFalse("writer must not remain blocked", writer.isAlive)86 assertTrue("stale writer should fail closed", failure.get() is IllegalStateException)87 assertEquals("uncommitted state must never enter the sync spool", 0, spoolRefreshes.get())8889 repo.beforeDurableCommit = {}90 LockedSyncSpoolHooks.refresh = {}91 assertTrue(repo.unlock("pass") is UnlockResult.Success)92 assertEquals(listOf("before"), repo.getEntries().map { it.title })93 }9495 @Test96 fun lock_invalidates_sync_apply_before_commit() = runBlocking {97 val repo = VaultRepository(Storage())98 assertTrue(repo.createVault("pass") is CreateVaultResult.Success)99100 val paused = CountDownLatch(1)101 val release = CountDownLatch(1)102 val failure = AtomicReference<Throwable?>()103 repo.beforeDurableCommit = {104 paused.countDown()105 check(release.await(5, TimeUnit.SECONDS)) { "test sync did not release" }106 }107 val remote = VaultEntry(108 id = "remote-race",109 title = "must-not-persist",110 type = "login",111 password = "secret",112 createdAt = 1,113 modifiedAt = System.currentTimeMillis() + 1_000,114 )115 val writer = Thread {116 try {117 runBlocking { repo.applyMergedRemoteEntries(listOf(remote), emptyList()) }118 } catch (t: Throwable) {119 failure.set(t)120 }121 }122 writer.start()123 assertTrue(paused.await(5, TimeUnit.SECONDS))124125 repo.lock()126 release.countDown()127 writer.join(5_000)128 assertFalse(writer.isAlive)129 assertTrue("stale sync must fail closed or return no-op", failure.get() == null || failure.get() is IllegalStateException)130131 repo.beforeDurableCommit = {}132 assertTrue(repo.unlock("pass") is UnlockResult.Success)133 assertEquals(null, repo.getEntry(remote.id))134 }135136 @Test137 fun lock_before_state_mutation_cannot_repopulate_a_new_session() = runBlocking {138 val repo = VaultRepository(Storage())139 assertTrue(repo.createVault("pass") is CreateVaultResult.Success)140 repo.addEntry(login("before"))141142 val paused = CountDownLatch(1)143 val release = CountDownLatch(1)144 val failure = AtomicReference<Throwable?>()145 repo.beforeSessionStateMutation = {146 paused.countDown()147 check(release.await(5, TimeUnit.SECONDS)) { "test writer did not release" }148 }149150 val writer = Thread {151 try {152 runBlocking { repo.addEntry(login("stale-state")) }153 } catch (t: Throwable) {154 failure.set(t)155 }156 }157 writer.start()158 assertTrue("writer did not reach the pre-state seam", paused.await(5, TimeUnit.SECONDS))159160 161 162 repo.lock()163 assertTrue(repo.getEntries().isEmpty())164 assertTrue(repo.unlock("pass") is UnlockResult.Success)165 assertEquals(listOf("before"), repo.getEntries().map { it.title })166167 release.countDown()168 writer.join(5_000)169 assertFalse("stale writer must finish", writer.isAlive)170 assertTrue("stale writer must fail closed", failure.get() is IllegalStateException)171 assertEquals(listOf("before"), repo.getEntries().map { it.title })172173 repo.beforeSessionStateMutation = {}174 }175}