package com.peachpasswords.vault import kotlinx.coroutines.runBlocking import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test /** In-memory [VaultStorage] for the non-committing dual-derivation path (MF-5). */ private class SlotAFakeStorage : VaultStorage { private var slotA: StoredVault? = null private var slotB: String? = null private var ver: Long = 0 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 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() = ver override fun stampLocalMutation(): Long { ver = maxOf(System.currentTimeMillis(), ver + 1); return ver } override fun loadSettings() = settings override fun writeSettings(s: VaultSettings): Boolean { settings = s; return true } override fun clearVault(): Boolean { slotA = null; slotB = null; ver = 0; settings = VaultSettings(); return true } } class SlotAUnlockTest { private fun freshRepo(): VaultRepository = VaultRepository(SlotAFakeStorage()) @Test fun try_unlock_does_not_mutate_session() = runBlocking { val repo = freshRepo() repo.createVault("correct horse battery staple") repo.addEntry(VaultEntry(id = "", title = "GitHub", type = "login", password = "pw", createdAt = 0, modifiedAt = 0)) repo.lock() assertFalse(repo.isUnlocked.value) val derived = repo.tryUnlockSlotA("correct horse battery staple") assertNotNull("right password derives a slot-A unlock", derived) // Deriving alone must NOT unlock the live session — only commit does. assertFalse("tryUnlockSlotA leaves the session locked", repo.isUnlocked.value) assertEquals(UnlockResult.Success, repo.commitSlotAUnlock(derived!!)) assertTrue("commit adopts the session", repo.isUnlocked.value) assertEquals(1, repo.getEntries().size) } @Test fun try_unlock_returns_null_for_wrong_password() = runBlocking { val repo = freshRepo() repo.createVault("the-real-password-here") repo.lock() assertNull("wrong password derives nothing", repo.tryUnlockSlotA("not-the-password")) assertFalse(repo.isUnlocked.value) } @Test fun discard_does_not_unlock() = runBlocking { val repo = freshRepo() repo.createVault("correct horse battery staple") repo.lock() val derived = repo.tryUnlockSlotA("correct horse battery staple")!! repo.discardSlotAUnlock(derived) // zero without adopting assertFalse("discarded unlock never adopts the session", repo.isUnlocked.value) } @Test fun sibling_tokens_cannot_replace_a_successfully_adopted_session() = runBlocking { val repo = freshRepo() repo.createVault("correct horse battery staple") repo.lock() val first = repo.tryUnlockSlotA("correct horse battery staple")!! val sibling = repo.tryUnlockSlotA("correct horse battery staple")!! assertEquals(UnlockResult.Success, repo.commitSlotAUnlock(first)) assertEquals( "a token from the consumed epoch must not replace the adopted session", UnlockResult.Error("stale_unlock"), repo.commitSlotAUnlock(sibling), ) assertTrue(repo.isUnlocked.value) } @Test fun an_old_token_cannot_resume_after_lock_and_new_unlock() = runBlocking { val repo = freshRepo() repo.createVault("correct horse battery staple") repo.lock() val old = repo.tryUnlockSlotA("correct horse battery staple")!! repo.lock() val current = repo.tryUnlockSlotA("correct horse battery staple")!! assertEquals(UnlockResult.Success, repo.commitSlotAUnlock(current)) assertEquals( "a paused old unlock must fail closed after lock/re-unlock", UnlockResult.Error("stale_unlock"), repo.commitSlotAUnlock(old), ) assertTrue(repo.isUnlocked.value) } @Test fun a_token_is_one_shot_even_when_the_first_commit_succeeds() = runBlocking { val repo = freshRepo() repo.createVault("correct horse battery staple") repo.lock() val token = repo.tryUnlockSlotA("correct horse battery staple")!! assertEquals(UnlockResult.Success, repo.commitSlotAUnlock(token)) assertEquals(UnlockResult.Error("unlock_token_consumed"), repo.commitSlotAUnlock(token)) assertTrue(repo.isUnlocked.value) } }