1package com.peachpasswords.vault23import kotlinx.coroutines.runBlocking4import org.junit.Assert.assertEquals5import org.junit.Assert.assertFalse6import org.junit.Assert.assertNull7import org.junit.Assert.assertTrue8import org.junit.Test91011private class FakeStorage : VaultStorage {12 var slotA: StoredVault? = null13 var slotB: String? = null14 private var ver: Long = 015 private var settings = VaultSettings()16 override fun loadSlotA() = slotA17 override fun hasVault() = slotA != null18 override fun writeSlotA(slot: StoredVault): Boolean { slotA = slot; return true }19 override fun writeSlotB(blobBase64: String): Boolean { slotB = blobBase64; return true }20 var decoy: StoredVault? = null21 override fun loadDecoySlot() = decoy22 override fun writeDecoySlot(slot: StoredVault): Boolean { decoy = slot; return true }23 override fun hasDecoySlot() = decoy != null24 override fun clearDecoySlot() { decoy = null }25 override fun version() = ver26 override fun stampLocalMutation(): Long { ver = maxOf(System.currentTimeMillis(), ver + 1); return ver }27 override fun loadSettings() = settings28 override fun writeSettings(s: VaultSettings): Boolean { settings = s; return true }29 override fun clearVault() { slotA = null; slotB = null; ver = 0; settings = VaultSettings() }30}3132class VaultRepositoryTest {33 private fun login(title: String, password: String = "pw") =34 VaultEntry(id = "", title = title, type = "login", password = password, createdAt = 0, modifiedAt = 0)3536 @Test fun create_persists_and_unlocks() = runBlocking {37 val storage = FakeStorage()38 val repo = VaultRepository(storage)39 assertFalse(repo.hasVault())40 assertEquals(CreateVaultResult.Success, repo.createVault("correct horse"))41 assertTrue(repo.hasVault())42 assertTrue(repo.isUnlocked.value)43 44 assertTrue(storage.slotA != null && storage.slotB != null)45 assertTrue(repo.getVaultVersion() > 0)46 }4748 @Test fun entries_persist_across_lock_unlock() = runBlocking {49 val storage = FakeStorage()50 val repo = VaultRepository(storage)51 repo.createVault("pw")52 val added = repo.addEntry(login("GitHub"))53 assertTrue(added.id.isNotBlank())54 assertEquals(1, repo.getEntries().size)55 repo.lock()56 assertFalse(repo.isUnlocked.value)57 assertEquals(UnlockResult.Success, repo.unlock("pw"))58 assertEquals(1, repo.getEntries().size)59 assertEquals("GitHub", repo.getEntries().first().title)60 }6162 @Test fun wrong_password_is_rejected() = runBlocking {63 val storage = FakeStorage()64 val repo = VaultRepository(storage)65 repo.createVault("right")66 repo.lock()67 assertEquals(UnlockResult.WrongPassword, repo.unlock("wrong"))68 assertFalse(repo.isUnlocked.value)69 }7071 @Test fun delete_writes_tombstone_then_purge_removes() = runBlocking {72 val repo = VaultRepository(FakeStorage())73 repo.createVault("pw")74 val e = repo.addEntry(login("Bank"))75 repo.deleteEntry(e.id)76 assertEquals(0, repo.getEntries().size)77 assertEquals(1, repo.getTombstones().size)78 assertEquals(e.id, repo.getTombstones().first().id)79 repo.restoreEntry(e.id)80 assertEquals(1, repo.getEntries().size)81 repo.deleteEntry(e.id)82 repo.purgeEntry(e.id)83 assertEquals(0, repo.getTombstones().size)84 }8586 @Test fun update_refreshes_password_changed_only_on_password_change() = runBlocking {87 val repo = VaultRepository(FakeStorage())88 repo.createVault("pw")89 val e = repo.addEntry(login("Mail", password = "old"))90 val firstChanged = e.passwordChangedAt!!91 Thread.sleep(2)92 val metaOnly = repo.updateEntry(e.copy(title = "Mail2", password = "old"))93 assertEquals(firstChanged, metaOnly.passwordChangedAt)94 Thread.sleep(2)95 val pwChange = repo.updateEntry(metaOnly.copy(password = "new"))96 assertTrue(pwChange.passwordChangedAt!! > firstChanged)97 }9899 @Test fun password_history_captures_old_password_newest_first() = runBlocking {100 val repo = VaultRepository(FakeStorage())101 repo.createVault("pw")102 val e = repo.addEntry(login("H", password = "first"))103 assertNull(e.passwordHistory)104 val v2 = repo.updateEntry(e.copy(password = "second"))105 val v3 = repo.updateEntry(v2.copy(password = "third"))106 assertEquals(listOf("second", "first"), v3.passwordHistory!!.map { it.password })107 assertEquals("third", v3.password)108 }109110 @Test fun password_history_ignores_metadata_only_update() = runBlocking {111 val repo = VaultRepository(FakeStorage())112 repo.createVault("pw")113 val e = repo.addEntry(login("M", password = "keepme"))114 val r = repo.updateEntry(e.copy(title = "Renamed"))115 assertNull(r.passwordHistory)116 }117118 @Test fun password_history_item_carries_prior_age() = runBlocking {119 val repo = VaultRepository(FakeStorage())120 repo.createVault("pw")121 val e = repo.addEntry(login("S", password = "v1"))122 val firstChanged = e.passwordChangedAt!!123 Thread.sleep(2)124 val r = repo.updateEntry(e.copy(password = "v2"))125 assertEquals(firstChanged, r.passwordHistory!!.first().changedAt)126 }127128 @Test fun password_history_skips_identical_resave() = runBlocking {129 val repo = VaultRepository(FakeStorage())130 repo.createVault("pw")131 val e = repo.addEntry(login("N", password = "same"))132 val r = repo.updateEntry(e.copy(password = "same"))133 assertNull(r.passwordHistory)134 }135136 @Test fun password_history_restore_re_records_swapped_out_password() = runBlocking {137 val repo = VaultRepository(FakeStorage())138 repo.createVault("pw")139 val e = repo.addEntry(login("R", password = "old"))140 val v2 = repo.updateEntry(e.copy(password = "new"))141 val restored = repo.updateEntry(v2.copy(password = "old"))142 assertEquals("old", restored.password)143 assertEquals(listOf("new", "old"), restored.passwordHistory!!.map { it.password })144 }145146 @Test fun password_history_persists_across_lock_unlock() = runBlocking {147 val storage = FakeStorage()148 val repo = VaultRepository(storage)149 repo.createVault("pw")150 val e = repo.addEntry(login("P", password = "p1"))151 repo.updateEntry(e.copy(password = "p2"))152 repo.lock()153 assertEquals(UnlockResult.Success, repo.unlock("pw"))154 val reloaded = repo.getEntries().first { it.id == e.id }155 assertEquals(listOf("p1"), reloaded.passwordHistory!!.map { it.password })156 }157158 @Test fun password_history_caps_at_fifty() = runBlocking {159 val repo = VaultRepository(FakeStorage())160 repo.createVault("pw")161 var e = repo.addEntry(login("C", password = "pw-0"))162 for (i in 1..55) e = repo.updateEntry(e.copy(password = "pw-$i"))163 val hist = e.passwordHistory!!164 assertEquals(50, hist.size)165 166 assertEquals("pw-54", hist.first().password)167 assertEquals("pw-5", hist.last().password)168 }169170 @Test fun apply_merged_remote_does_not_bump_version() = runBlocking {171 val repo = VaultRepository(FakeStorage())172 repo.createVault("pw")173 repo.addEntry(login("Local"))174 val versionBefore = repo.getVaultVersion()175 val remote = VaultEntry(176 id = "remote-1", title = "Remote", type = "login", password = "x",177 createdAt = 1, modifiedAt = System.currentTimeMillis() + 10_000,178 )179 val applied = repo.applyMergedRemoteEntries(listOf(remote), emptyList())180 assertEquals(1, applied)181 assertEquals(2, repo.getEntries().size)182 183 assertEquals(versionBefore, repo.getVaultVersion())184 }185186 @Test fun remote_tombstone_supersedes_older_local_entry() = runBlocking {187 val repo = VaultRepository(FakeStorage())188 repo.createVault("pw")189 val local = repo.addEntry(login("Shared"))190 val tomb = Tombstone(local.id, local.modifiedAt + 5_000)191 val applied = repo.applyMergedRemoteEntries(emptyList(), listOf(tomb))192 assertEquals(1, applied)193 assertEquals(0, repo.getEntries().size)194 }195196 @Test fun change_password_reencrypts_under_new_password() = runBlocking {197 val storage = FakeStorage()198 val repo = VaultRepository(storage)199 repo.createVault("old-pass")200 repo.addEntry(login("Acct"))201 assertTrue(repo.changePassword("old-pass", "new-pass"))202 repo.lock()203 assertEquals(UnlockResult.WrongPassword, repo.unlock("old-pass"))204 assertEquals(UnlockResult.Success, repo.unlock("new-pass"))205 assertEquals(1, repo.getEntries().size)206 }207208 209210 @Test fun purge_deleted_entries_empties_trash() = runBlocking {211 val repo = VaultRepository(FakeStorage())212 repo.createVault("pw")213 val a = repo.addEntry(login("A"))214 val b = repo.addEntry(login("B"))215 repo.addEntry(login("C"))216 repo.deleteEntry(a.id)217 repo.deleteEntry(b.id)218 assertEquals(2, repo.getDeletedEntries().size)219 assertEquals(2, repo.purgeDeletedEntries())220 assertEquals(0, repo.getDeletedEntries().size)221 assertEquals(1, repo.getEntries().size)222 }223224 @Test fun auto_purge_removes_trash_older_than_retention() = runBlocking {225 val storage = FakeStorage()226 val repo = VaultRepository(storage)227 repo.createVault("pw")228 repo.updateSettings(repo.settings.value.copy(trashRetentionDays = 30))229 230 val ancient = VaultEntry(id = "old", title = "Old", type = "login", password = "x",231 createdAt = 1000, modifiedAt = 1000, deletedAt = 1000)232 repo.applyMergedRemoteEntries(listOf(ancient), emptyList())233 assertEquals(1, repo.getDeletedEntries().size)234 repo.lock()235 assertEquals(UnlockResult.Success, repo.unlock("pw"))236 assertEquals(0, repo.getDeletedEntries().size)237 }238239 @Test fun auto_purge_keeps_trash_when_retention_never() = runBlocking {240 val storage = FakeStorage()241 val repo = VaultRepository(storage)242 repo.createVault("pw") 243 val ancient = VaultEntry(id = "old", title = "Old", type = "login", password = "x",244 createdAt = 1000, modifiedAt = 1000, deletedAt = 1000)245 repo.applyMergedRemoteEntries(listOf(ancient), emptyList())246 repo.lock()247 assertEquals(UnlockResult.Success, repo.unlock("pw"))248 assertEquals(1, repo.getDeletedEntries().size)249 }250251 252253 @Test fun tags_distinct_sorted_lowercased() = runBlocking {254 val repo = VaultRepository(FakeStorage())255 repo.createVault("pw")256 repo.addEntry(login("A").copy(tags = listOf("work", "email")))257 repo.addEntry(login("B").copy(tags = listOf("Work", "social")))258 assertEquals(listOf("email", "social", "work"), repo.getDistinctTags())259 }260261 @Test fun get_active_entries_filters_by_tag() = runBlocking {262 val repo = VaultRepository(FakeStorage())263 repo.createVault("pw")264 repo.addEntry(login("A").copy(tags = listOf("work")))265 repo.addEntry(login("B").copy(tags = listOf("home")))266 val gone = repo.addEntry(login("C").copy(tags = listOf("work")))267 repo.deleteEntry(gone.id)268 val work = repo.getActiveEntries("work")269 assertEquals(1, work.size)270 assertEquals("A", work.first().title)271 assertEquals(2, repo.getActiveEntries(null).size)272 }273274 @Test fun tags_round_trip_across_lock_unlock() = runBlocking {275 val storage = FakeStorage()276 val repo = VaultRepository(storage)277 repo.createVault("pw")278 val e = repo.addEntry(login("T").copy(tags = listOf("alpha", "beta")))279 repo.lock()280 repo.unlock("pw")281 assertEquals(listOf("alpha", "beta"), repo.getEntries().first { it.id == e.id }.tags)282 }283284 285286 @Test fun recovery_words_set_verify_and_persist() = runBlocking {287 val storage = FakeStorage()288 val repo = VaultRepository(storage)289 repo.createVault("pw")290 assertFalse(repo.isRecoveryWordsSet())291 val words = rootKeyToWords(ByteArray(32) { it.toByte() })292 assertTrue(repo.setRecoveryWords(words))293 assertTrue(repo.isRecoveryWordsSet())294 assertTrue(repo.verifyRecoveryWords(words))295 assertFalse(repo.verifyRecoveryWords(words.reversed()))296 repo.lock()297 assertEquals(UnlockResult.Success, repo.unlock("pw"))298 assertTrue(repo.isRecoveryWordsSet())299 assertTrue(repo.verifyRecoveryWords(words))300 }301302 @Test fun destroy_clears_everything() = runBlocking {303 val storage = FakeStorage()304 val repo = VaultRepository(storage)305 repo.createVault("pw")306 repo.addEntry(login("X"))307 repo.destroyVault()308 assertFalse(repo.isUnlocked.value)309 assertNull(storage.slotA)310 assertFalse(repo.hasVault())311 }312}