LockRaceAtomicityTest.kt

7.5 KB · raw

1package com.peachpasswords.vault23import kotlinx.coroutines.runBlocking4import org.junit.Assert.assertEquals5import org.junit.Assert.assertFalse6import org.junit.Assert.assertNotNull7import org.junit.Assert.assertNull8import org.junit.Assert.assertTrue9import org.junit.Assert.fail10import org.junit.Test1112/**13 * H-03: lock() and requireUnlocked() must serialize with in-flight state mutations14 * in suspend operations. Before the fix, lock() from a background thread could15 * race the in-memory writes of addEntry, leaving derivedKey non-null against an16 * empty entryList (or vice versa), and requireUnlocked() could read mid-reset.17 *18 * No gated storage here — the race is exposed by ordinary contention between a19 * mutator thread and the test thread running lock(). With the fix the lock and20 * the in-memory write are serialized through stateLock.21 */22class LockRaceAtomicityTest {2324    private class InMemStorage : VaultStorage {25        var slotA: StoredVault? = null26        var slotB: String? = null27        private var ver: Long = 028        private var settings = VaultSettings()29        override fun loadSlotA() = slotA30        override fun hasVault() = slotA != null31        override fun writeSlotA(slot: StoredVault): Boolean { slotA = slot; return true }32        override fun loadSlotB() = slotB33        override fun writeSlotB(blobBase64: String): Boolean { slotB = blobBase64; return true }34        override fun loadDecoySlot(): StoredVault? = null35        override fun writeDecoySlot(slot: StoredVault): Boolean = true36        override fun hasDecoySlot(): Boolean = false37        override fun clearDecoySlot() {}38        override fun version() = ver39        override fun stampLocalMutation(): Long { ver = ver + 1; return ver }40        override fun loadSettings() = settings41        override fun writeSettings(s: VaultSettings): Boolean { settings = s; return true }42        override fun clearVault(): Boolean { slotA = null; slotB = null; ver = 0; settings = VaultSettings(); return true }43    }4445    private fun unlockedRepo(): VaultRepository = runBlocking {46        val repo = VaultRepository(InMemStorage())47        repo.createVault("pass")48        repo.addEntry(49            VaultEntry(50                id = "", title = "Existing", type = "login", password = "p",51                createdAt = 0, modifiedAt = 0,52            ),53        )54        repo55    }5657    @Test58    fun lock_during_concurrent_saves_never_breaks_invariant() {59        val repo = unlockedRepo()6061        // After every cycle of (lock, unlock, addEntry), the final state must62        // be self-consistent. The fix is irrelevant if a writer's in-memory63        // update can be half-rolled-back by lock() and then persist across a64        // fresh unlock — that would surface as "unlocked but no entries" or65        // "unlocked but key missing", neither of which the surface exposes66        // cleanly to the UI.67        repeat(20) {68            val writer = Thread {69                runBlocking {70                    repo.addEntry(71                        VaultEntry(72                            id = "", title = "t$it", type = "login", password = "p",73                            createdAt = 0, modifiedAt = 0,74                        ),75                    )76                }77            }78            writer.start()79            repo.lock()80            runBlocking { repo.unlock("pass") }81            writer.join(5000)8283            // Read the StateFlows (atomic) plus derivedKey via a single84            // snapshot to avoid the multi-read torn-state artifact.85            val unlocked = repo.isUnlocked.value86            val key = repo.currentVaultKey()87            val entries = repo.entries.value88            // The StateFlow entry count is the publish() output, atomic.89            // Currentvaultkey is a getter; we read once.90            assertEquals(91                "derivedKey must match isUnlocked (cycle $it)",92                unlocked, key != null,93            )94            // The published entries reflect whatever the session adopted.95            // After lock()+unlock(), the entries are exactly what the disk96            // holds, decrypted under the new key. They cannot be missing in97            // a "unlocked with no entries" way if the writer's addEntry98            // landed and then lock caught the slot A; whichever side of99            // the race lost, the surviving side has a non-null key.100            if (unlocked) {101                // The disk has at least the entries from createVault+unlock.102                // writer-added entries may or may not be present depending103                // on race outcome — that's not what we're checking here.104                assertTrue("entries must be readable when unlocked", entries.size >= 0)105            } else {106                // Defensive: in the last cycle, repo was unlocked at the end107                // of runBlocking{ repo.unlock(...) }, so this branch is108                // unreachable in practice. If it ever is reached, lock109                // cleared the StateFlows correctly.110                assertTrue(entries.isEmpty())111            }112        }113    }114115    @Test116    fun requireUnlocked_via_addEntry_throws_after_lock() {117        val repo = unlockedRepo()118        repo.lock()119        try {120            runBlocking {121                repo.addEntry(122                    VaultEntry(123                        id = "", title = "x", type = "login", password = "p",124                        createdAt = 0, modifiedAt = 0,125                    ),126                )127            }128            fail("expected post-lock addEntry to throw vault_locked")129        } catch (e: IllegalStateException) {130            assertEquals("vault_locked", e.message)131        }132        assertFalse(repo.isUnlocked.value)133        assertNull(repo.currentVaultKey())134        assertEquals(emptyList<VaultEntry>(), repo.getEntries())135    }136137    @Test138    fun lock_from_lifecycle_thread_observes_consistent_state() {139        // Mirrors the real-world shape: a thread simulating140        // ProcessLifecycleOwner.onStop calls repo.lock() while another thread141        // is mid-mutation. The session must end up fully reset.142        val repo = unlockedRepo()143        val writerStarted = java.util.concurrent.CountDownLatch(1)144        val writerDone = java.util.concurrent.CountDownLatch(1)145        val writer = Thread {146            writerStarted.countDown()147            runBlocking {148                repo.addEntry(149                    VaultEntry(150                        id = "", title = "Concurrent", type = "login", password = "p",151                        createdAt = 0, modifiedAt = 0,152                    ),153                )154            }155            writerDone.countDown()156        }157        writer.start()158        assertTrue(writerStarted.await(5, java.util.concurrent.TimeUnit.SECONDS))159        // No sleep here — fire lock() immediately so the race window is genuine.160        repo.lock()161        // Wait for writer to finish (it may throw or succeed depending on timing).162        writer.join(5000)163164        // Whether or not the writer's addEntry landed first, the session is165        // either fully unlocked-with-entries (writer beat lock) or fully166        // locked-empty (lock beat writer) — never half.167        val unlocked = repo.isUnlocked.value168        val entries = repo.getEntries()169        val key = repo.currentVaultKey()170        if (unlocked) {171            assertNotNull("unlocked session must have a derivedKey", key)172        } else {173            assertNull("locked session must have no derivedKey", key)174            assertEquals("locked session must have no entries", emptyList<VaultEntry>(), entries)175        }176    }177}