SessionEpochRaceTest.kt

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