package com.peachpasswords.vault import kotlinx.coroutines.runBlocking import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicReference import java.util.concurrent.atomic.AtomicInteger import com.peachpasswords.sync.LockedSyncSpoolHooks /** * Deterministic H-03 races. The storage hook pauses a writer after it has * captured/encrypted session state but before the epoch-guarded durable commit. * lock() must invalidate that work; a later unlock must not be overwritten by * the stale writer's pre-lock snapshot. */ class SessionEpochRaceTest { private class Storage : VaultStorage { var slotA: StoredVault? = null var slotB: String? = null private var version = 0L private var settings = VaultSettings() override fun loadSlotA() = slotA override fun hasVault() = slotA != null override fun writeSlotA(slot: StoredVault): Boolean { slotA = slot; return true } override fun loadSlotB() = slotB override fun writeSlotB(blobBase64: String): Boolean { slotB = blobBase64; return true } private 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() = version override fun stampLocalMutation(): Long { version += 1; return version } override fun loadSettings() = settings override fun writeSettings(settings: VaultSettings): Boolean { this.settings = settings; return true } override fun clearVault() { slotA = null; slotB = null; decoy = null; version = 0; settings = VaultSettings() } } private fun login(title: String) = VaultEntry( id = "", title = title, type = "login", password = "secret", createdAt = 0, modifiedAt = 0, ) @Test fun lock_invalidates_save_paused_before_commit() = runBlocking { val repo = VaultRepository(Storage()) assertTrue(repo.createVault("pass") is CreateVaultResult.Success) repo.addEntry(login("before")) val paused = CountDownLatch(1) val release = CountDownLatch(1) val failure = AtomicReference() val spoolRefreshes = AtomicInteger() LockedSyncSpoolHooks.refresh = { spoolRefreshes.incrementAndGet() } repo.beforeDurableCommit = { paused.countDown() check(release.await(5, TimeUnit.SECONDS)) { "test writer did not release" } } val writer = Thread { try { runBlocking { repo.addEntry(login("must-not-persist")) } } catch (t: Throwable) { failure.set(t) } } writer.start() assertTrue("writer did not reach the pre-commit seam", paused.await(5, TimeUnit.SECONDS)) repo.lock() assertFalse(repo.isUnlocked.value) assertTrue(repo.getEntries().isEmpty()) assertEquals(null, repo.currentVaultKey()) release.countDown() writer.join(5_000) assertFalse("writer must not remain blocked", writer.isAlive) assertTrue("stale writer should fail closed", failure.get() is IllegalStateException) assertEquals("uncommitted state must never enter the sync spool", 0, spoolRefreshes.get()) repo.beforeDurableCommit = {} LockedSyncSpoolHooks.refresh = {} assertTrue(repo.unlock("pass") is UnlockResult.Success) assertEquals(listOf("before"), repo.getEntries().map { it.title }) } @Test fun lock_invalidates_sync_apply_before_commit() = runBlocking { val repo = VaultRepository(Storage()) assertTrue(repo.createVault("pass") is CreateVaultResult.Success) val paused = CountDownLatch(1) val release = CountDownLatch(1) val failure = AtomicReference() repo.beforeDurableCommit = { paused.countDown() check(release.await(5, TimeUnit.SECONDS)) { "test sync did not release" } } val remote = VaultEntry( id = "remote-race", title = "must-not-persist", type = "login", password = "secret", createdAt = 1, modifiedAt = System.currentTimeMillis() + 1_000, ) val writer = Thread { try { runBlocking { repo.applyMergedRemoteEntries(listOf(remote), emptyList()) } } catch (t: Throwable) { failure.set(t) } } writer.start() assertTrue(paused.await(5, TimeUnit.SECONDS)) repo.lock() release.countDown() writer.join(5_000) assertFalse(writer.isAlive) assertTrue("stale sync must fail closed or return no-op", failure.get() == null || failure.get() is IllegalStateException) repo.beforeDurableCommit = {} assertTrue(repo.unlock("pass") is UnlockResult.Success) assertEquals(null, repo.getEntry(remote.id)) } @Test fun lock_before_state_mutation_cannot_repopulate_a_new_session() = runBlocking { val repo = VaultRepository(Storage()) assertTrue(repo.createVault("pass") is CreateVaultResult.Success) repo.addEntry(login("before")) val paused = CountDownLatch(1) val release = CountDownLatch(1) val failure = AtomicReference() repo.beforeSessionStateMutation = { paused.countDown() check(release.await(5, TimeUnit.SECONDS)) { "test writer did not release" } } val writer = Thread { try { runBlocking { repo.addEntry(login("stale-state")) } } catch (t: Throwable) { failure.set(t) } } writer.start() assertTrue("writer did not reach the pre-state seam", paused.await(5, TimeUnit.SECONDS)) // First lock clears the old session. Re-unlock before the stale writer // is released to prove it cannot mutate the newly-adopted generation. repo.lock() assertTrue(repo.getEntries().isEmpty()) assertTrue(repo.unlock("pass") is UnlockResult.Success) assertEquals(listOf("before"), repo.getEntries().map { it.title }) release.countDown() writer.join(5_000) assertFalse("stale writer must finish", writer.isAlive) assertTrue("stale writer must fail closed", failure.get() is IllegalStateException) assertEquals(listOf("before"), repo.getEntries().map { it.title }) repo.beforeSessionStateMutation = {} } }