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 private fun note(title: String) =37 VaultEntry(id = "", title = title, type = "note", createdAt = 0, modifiedAt = 0)3839 @Test fun create_persists_and_unlocks() = runBlocking {40 val storage = FakeStorage()41 val repo = VaultRepository(storage)42 assertFalse(repo.hasVault())43 assertEquals(CreateVaultResult.Success, repo.createVault("correct horse"))44 assertTrue(repo.hasVault())45 assertTrue(repo.isUnlocked.value)46 47 assertTrue(storage.slotA != null && storage.slotB != null)48 assertTrue(repo.getVaultVersion() > 0)49 }5051 @Test fun entries_persist_across_lock_unlock() = runBlocking {52 val storage = FakeStorage()53 val repo = VaultRepository(storage)54 repo.createVault("pw")55 val added = repo.addEntry(login("GitHub"))56 assertTrue(added.id.isNotBlank())57 assertEquals(1, repo.getEntries().size)58 repo.lock()59 assertFalse(repo.isUnlocked.value)60 assertEquals(UnlockResult.Success, repo.unlock("pw"))61 assertEquals(1, repo.getEntries().size)62 assertEquals("GitHub", repo.getEntries().first().title)63 }6465 @Test fun wrong_password_is_rejected() = runBlocking {66 val storage = FakeStorage()67 val repo = VaultRepository(storage)68 repo.createVault("right")69 repo.lock()70 assertEquals(UnlockResult.WrongPassword, repo.unlock("wrong"))71 assertFalse(repo.isUnlocked.value)72 }7374 @Test fun delete_writes_tombstone_then_purge_removes() = runBlocking {75 val repo = VaultRepository(FakeStorage())76 repo.createVault("pw")77 val e = repo.addEntry(login("Bank"))78 repo.deleteEntry(e.id)79 assertEquals(0, repo.getEntries().size)80 assertEquals(1, repo.getTombstones().size)81 assertEquals(e.id, repo.getTombstones().first().id)82 repo.restoreEntry(e.id)83 assertEquals(1, repo.getEntries().size)84 repo.deleteEntry(e.id)85 repo.purgeEntry(e.id)86 assertEquals(0, repo.getTombstones().size)87 }8889 @Test fun duplicate_count_matches_browser_keys() = runBlocking {90 val repo = VaultRepository(FakeStorage())91 repo.createVault("pw")92 repo.addEntry(login("GitHub").copy(url = "https://github.com", username = "june"))93 repo.addEntry(login("github").copy(url = "https://github.com", username = "june"))94 repo.addEntry(login("GitHub").copy(url = "https://github.com", username = "other"))95 repo.addEntry(note("Ideas"))96 repo.addEntry(note("ideas"))9798 assertEquals(2, repo.countDuplicateEntries())99 }100101 @Test fun deduplicate_moves_older_duplicates_to_trash() = runBlocking {102 val repo = VaultRepository(FakeStorage())103 repo.createVault("pw")104 val older = repo.addEntry(login("Bank").copy(url = "https://bank.test", username = "j"))105 Thread.sleep(2)106 val newer = repo.addEntry(login("bank").copy(url = "https://bank.test", username = "j"))107108 assertEquals(1, repo.deduplicateEntries())109110 assertEquals(listOf(newer.id), repo.getEntries().map { it.id })111 assertEquals(listOf(older.id), repo.getDeletedEntries().map { it.id })112 }113114 @Test fun update_refreshes_password_changed_only_on_password_change() = runBlocking {115 val repo = VaultRepository(FakeStorage())116 repo.createVault("pw")117 val e = repo.addEntry(login("Mail", password = "old"))118 val firstChanged = e.passwordChangedAt!!119 Thread.sleep(2)120 val metaOnly = repo.updateEntry(e.copy(title = "Mail2", password = "old"))121 assertEquals(firstChanged, metaOnly.passwordChangedAt)122 Thread.sleep(2)123 val pwChange = repo.updateEntry(metaOnly.copy(password = "new"))124 assertTrue(pwChange.passwordChangedAt!! > firstChanged)125 }126127 @Test fun password_history_captures_old_password_newest_first() = runBlocking {128 val repo = VaultRepository(FakeStorage())129 repo.createVault("pw")130 val e = repo.addEntry(login("H", password = "first"))131 assertNull(e.passwordHistory)132 val v2 = repo.updateEntry(e.copy(password = "second"))133 val v3 = repo.updateEntry(v2.copy(password = "third"))134 assertEquals(listOf("second", "first"), v3.passwordHistory!!.map { it.password })135 assertEquals("third", v3.password)136 }137138 @Test fun password_history_ignores_metadata_only_update() = runBlocking {139 val repo = VaultRepository(FakeStorage())140 repo.createVault("pw")141 val e = repo.addEntry(login("M", password = "keepme"))142 val r = repo.updateEntry(e.copy(title = "Renamed"))143 assertNull(r.passwordHistory)144 }145146 @Test fun password_history_item_carries_prior_age() = runBlocking {147 val repo = VaultRepository(FakeStorage())148 repo.createVault("pw")149 val e = repo.addEntry(login("S", password = "v1"))150 val firstChanged = e.passwordChangedAt!!151 Thread.sleep(2)152 val r = repo.updateEntry(e.copy(password = "v2"))153 assertEquals(firstChanged, r.passwordHistory!!.first().changedAt)154 }155156 @Test fun password_history_skips_identical_resave() = runBlocking {157 val repo = VaultRepository(FakeStorage())158 repo.createVault("pw")159 val e = repo.addEntry(login("N", password = "same"))160 val r = repo.updateEntry(e.copy(password = "same"))161 assertNull(r.passwordHistory)162 }163164 @Test fun password_history_restore_re_records_swapped_out_password() = runBlocking {165 val repo = VaultRepository(FakeStorage())166 repo.createVault("pw")167 val e = repo.addEntry(login("R", password = "old"))168 val v2 = repo.updateEntry(e.copy(password = "new"))169 val restored = repo.updateEntry(v2.copy(password = "old"))170 assertEquals("old", restored.password)171 assertEquals(listOf("new", "old"), restored.passwordHistory!!.map { it.password })172 }173174 @Test fun password_history_persists_across_lock_unlock() = runBlocking {175 val storage = FakeStorage()176 val repo = VaultRepository(storage)177 repo.createVault("pw")178 val e = repo.addEntry(login("P", password = "p1"))179 repo.updateEntry(e.copy(password = "p2"))180 repo.lock()181 assertEquals(UnlockResult.Success, repo.unlock("pw"))182 val reloaded = repo.getEntries().first { it.id == e.id }183 assertEquals(listOf("p1"), reloaded.passwordHistory!!.map { it.password })184 }185186 @Test fun password_history_caps_at_fifty() = runBlocking {187 val repo = VaultRepository(FakeStorage())188 repo.createVault("pw")189 var e = repo.addEntry(login("C", password = "pw-0"))190 for (i in 1..55) e = repo.updateEntry(e.copy(password = "pw-$i"))191 val hist = e.passwordHistory!!192 assertEquals(50, hist.size)193 194 assertEquals("pw-54", hist.first().password)195 assertEquals("pw-5", hist.last().password)196 }197198 @Test fun apply_merged_remote_does_not_bump_version() = runBlocking {199 val repo = VaultRepository(FakeStorage())200 repo.createVault("pw")201 repo.addEntry(login("Local"))202 val versionBefore = repo.getVaultVersion()203 val remote = VaultEntry(204 id = "remote-1", title = "Remote", type = "login", password = "x",205 createdAt = 1, modifiedAt = System.currentTimeMillis() + 10_000,206 )207 val applied = repo.applyMergedRemoteEntries(listOf(remote), emptyList())208 assertEquals(1, applied)209 assertEquals(2, repo.getEntries().size)210 211 assertEquals(versionBefore, repo.getVaultVersion())212 }213214 @Test fun remote_tombstone_supersedes_older_local_entry() = runBlocking {215 val repo = VaultRepository(FakeStorage())216 repo.createVault("pw")217 val local = repo.addEntry(login("Shared"))218 val tomb = Tombstone(local.id, local.modifiedAt + 5_000)219 val applied = repo.applyMergedRemoteEntries(emptyList(), listOf(tomb))220 assertEquals(1, applied)221 assertEquals(0, repo.getEntries().size)222 }223224 @Test fun change_password_reencrypts_under_new_password() = runBlocking {225 val storage = FakeStorage()226 val repo = VaultRepository(storage)227 repo.createVault("old-pass")228 repo.addEntry(login("Acct"))229 assertTrue(repo.changePassword("old-pass", "new-pass"))230 repo.lock()231 assertEquals(UnlockResult.WrongPassword, repo.unlock("old-pass"))232 assertEquals(UnlockResult.Success, repo.unlock("new-pass"))233 assertEquals(1, repo.getEntries().size)234 }235236 237238 @Test fun purge_deleted_entries_empties_trash() = runBlocking {239 val repo = VaultRepository(FakeStorage())240 repo.createVault("pw")241 val a = repo.addEntry(login("A"))242 val b = repo.addEntry(login("B"))243 repo.addEntry(login("C"))244 repo.deleteEntry(a.id)245 repo.deleteEntry(b.id)246 assertEquals(2, repo.getDeletedEntries().size)247 assertEquals(2, repo.purgeDeletedEntries())248 assertEquals(0, repo.getDeletedEntries().size)249 assertEquals(1, repo.getEntries().size)250 }251252 @Test fun auto_purge_removes_trash_older_than_retention() = runBlocking {253 val storage = FakeStorage()254 val repo = VaultRepository(storage)255 repo.createVault("pw")256 repo.updateSettings(repo.settings.value.copy(trashRetentionDays = 30))257 258 val ancient = VaultEntry(id = "old", title = "Old", type = "login", password = "x",259 createdAt = 1000, modifiedAt = 1000, deletedAt = 1000)260 repo.applyMergedRemoteEntries(listOf(ancient), emptyList())261 assertEquals(1, repo.getDeletedEntries().size)262 repo.lock()263 assertEquals(UnlockResult.Success, repo.unlock("pw"))264 assertEquals(0, repo.getDeletedEntries().size)265 }266267 @Test fun auto_purge_keeps_trash_when_retention_never() = runBlocking {268 val storage = FakeStorage()269 val repo = VaultRepository(storage)270 repo.createVault("pw") 271 val ancient = VaultEntry(id = "old", title = "Old", type = "login", password = "x",272 createdAt = 1000, modifiedAt = 1000, deletedAt = 1000)273 repo.applyMergedRemoteEntries(listOf(ancient), emptyList())274 repo.lock()275 assertEquals(UnlockResult.Success, repo.unlock("pw"))276 assertEquals(1, repo.getDeletedEntries().size)277 }278279 280281 @Test fun tags_distinct_sorted_lowercased() = runBlocking {282 val repo = VaultRepository(FakeStorage())283 repo.createVault("pw")284 repo.addEntry(login("A").copy(tags = listOf("work", "email")))285 repo.addEntry(login("B").copy(tags = listOf("Work", "social")))286 assertEquals(listOf("email", "social", "work"), repo.getDistinctTags())287 }288289 @Test fun get_active_entries_filters_by_tag() = runBlocking {290 val repo = VaultRepository(FakeStorage())291 repo.createVault("pw")292 repo.addEntry(login("A").copy(tags = listOf("work")))293 repo.addEntry(login("B").copy(tags = listOf("home")))294 val gone = repo.addEntry(login("C").copy(tags = listOf("work")))295 repo.deleteEntry(gone.id)296 val work = repo.getActiveEntries("work")297 assertEquals(1, work.size)298 assertEquals("A", work.first().title)299 assertEquals(2, repo.getActiveEntries(null).size)300 }301302 @Test fun tags_round_trip_across_lock_unlock() = runBlocking {303 val storage = FakeStorage()304 val repo = VaultRepository(storage)305 repo.createVault("pw")306 val e = repo.addEntry(login("T").copy(tags = listOf("alpha", "beta")))307 repo.lock()308 repo.unlock("pw")309 assertEquals(listOf("alpha", "beta"), repo.getEntries().first { it.id == e.id }.tags)310 }311312 313314 @Test fun recovery_words_set_verify_and_persist() = runBlocking {315 val storage = FakeStorage()316 val repo = VaultRepository(storage)317 repo.createVault("pw")318 assertFalse(repo.isRecoveryWordsSet())319 val words = rootKeyToWords(ByteArray(32) { it.toByte() })320 assertTrue(repo.setRecoveryWords(words))321 assertTrue(repo.isRecoveryWordsSet())322 assertTrue(repo.verifyRecoveryWords(words))323 assertFalse(repo.verifyRecoveryWords(words.reversed()))324 repo.lock()325 assertEquals(UnlockResult.Success, repo.unlock("pw"))326 assertTrue(repo.isRecoveryWordsSet())327 assertTrue(repo.verifyRecoveryWords(words))328 }329330 @Test fun destroy_clears_everything() = runBlocking {331 val storage = FakeStorage()332 val repo = VaultRepository(storage)333 repo.createVault("pw")334 repo.addEntry(login("X"))335 repo.destroyVault()336 assertFalse(repo.isUnlocked.value)337 assertNull(storage.slotA)338 assertFalse(repo.hasVault())339 }340}