SessionEpochRaceTest.kt

6.8 KB · raw

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.LockedSyncSpoolHooks1314/**15 * Deterministic H-03 races. The storage hook pauses a writer after it has16 * captured/encrypted session state but before the epoch-guarded durable commit.17 * lock() must invalidate that work; a later unlock must not be overwritten by18 * the stale writer's pre-lock snapshot.19 */20class 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        // First lock clears the old session. Re-unlock before the stale writer161        // is released to prove it cannot mutate the newly-adopted generation.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}