1package com.peachpasswords.vault23import kotlinx.coroutines.async4import kotlinx.coroutines.awaitAll5import kotlinx.coroutines.runBlocking6import org.junit.Assert.assertEquals7import org.junit.Assert.assertFalse8import org.junit.Assert.assertNotNull9import org.junit.Assert.assertNull10import org.junit.Assert.assertTrue11import org.junit.Test12131415161718class VaultRepositoryExtraTest {1920 private class Storage : VaultStorage {21 var slotA: StoredVault? = null22 var slotB: String? = null23 private var ver: Long = 024 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 writeSlotB(blobBase64: String): Boolean { slotB = blobBase64; return true }29 var decoy: StoredVault? = null30 override fun loadDecoySlot() = decoy31 override fun writeDecoySlot(slot: StoredVault): Boolean { decoy = slot; return true }32 override fun hasDecoySlot() = decoy != null33 override fun clearDecoySlot() { decoy = null }34 override fun version() = ver35 override fun stampLocalMutation(): Long { ver = maxOf(System.currentTimeMillis(), ver + 1); return ver }36 override fun loadSettings() = settings37 override fun writeSettings(s: VaultSettings): Boolean { settings = s; return true }38 override fun clearVault() { slotA = null; slotB = null; ver = 0; settings = VaultSettings() }39 }4041 private fun repo() = VaultRepository(Storage()).also { runBlocking { it.createVault("correct horse battery") } }42 private fun login(title: String, password: String = "pw") =43 VaultEntry(id = "", title = title, type = "login", password = password, createdAt = 0, modifiedAt = 0)4445 4647 @Test fun unlock_with_wrong_password_five_times_does_not_crash_or_corrupt() = runBlocking {48 val r = repo()49 r.addEntry(login("Acct"))50 r.lock()51 repeat(5) { assertEquals(UnlockResult.WrongPassword, r.unlock("nope$it")) }52 assertEquals(UnlockResult.Success, r.unlock("correct horse battery"))53 assertEquals(1, r.getEntries().size)54 }5556 @Test fun biometric_key_unlock_round_trips() = runBlocking {57 val r = repo()58 r.addEntry(login("Bio"))59 val key = r.currentVaultKey()!!60 r.lock()61 assertEquals(UnlockResult.Success, r.unlockWithKey(key))62 assertEquals(1, r.getEntries().size)63 }6465 @Test fun biometric_unlock_with_wrong_key_fails() = runBlocking {66 val r = repo()67 r.lock()68 assertEquals(UnlockResult.WrongPassword, r.unlockWithKey(ByteArray(32) { 9 }))69 }7071 @Test fun codex_blob_restores_on_another_repo() = runBlocking {72 val src = repo()73 src.addEntry(login("Portable"))74 val blob = src.codexVaultBlobBase64()!!75 val dst = VaultRepository(Storage())76 assertEquals(UnlockResult.Success, dst.restoreFromCodex(blob, "correct horse battery"))77 assertTrue(dst.getEntries().any { it.title == "Portable" })78 }7980 @Test fun create_then_destroy_then_create_fresh() = runBlocking {81 val storage = Storage()82 val r = VaultRepository(storage)83 r.createVault("first-pass-here")84 r.addEntry(login("Gone"))85 r.destroyVault()86 assertFalse(r.hasVault())87 assertEquals(CreateVaultResult.Success, r.createVault("second-pass-here"))88 assertEquals(0, r.getEntries().size)89 }9091 9293 @Test fun update_nonexistent_entry_throws() = runBlocking {94 val r = repo()95 try {96 r.updateEntry(login("ghost").copy(id = "missing"))97 error("expected throw")98 } catch (e: IllegalStateException) {99 assertEquals("entry_not_found", e.message)100 }101 }102103 @Test fun delete_nonexistent_entry_throws() = runBlocking {104 val r = repo()105 try {106 r.deleteEntry("missing")107 error("expected throw")108 } catch (_: IllegalStateException) { }109 }110111 @Test fun get_entry_returns_null_for_deleted() = runBlocking {112 val r = repo()113 val e = r.addEntry(login("D"))114 r.deleteEntry(e.id)115 assertNull(r.getEntry(e.id))116 }117118 @Test fun hundred_entries_all_present() = runBlocking {119 val r = repo()120 repeat(100) { r.addEntry(login("E$it")) }121 assertEquals(100, r.getEntries().size)122 }123124 @Test fun all_fields_survive_lock_unlock() = runBlocking {125 val storage = Storage()126 val r = VaultRepository(storage)127 r.createVault("correct horse battery")128 val full = VaultEntry(129 id = "", type = "login", title = "Full",130 username = "june", email = "[email protected]", password = "s3cret",131 url = "https://example.com", additionalUrls = listOf("https://alt.example.com"),132 totp = "JBSWY3DPEHPK3PXP", notes = "a note", breach = 2,133 tags = listOf("work", "email"), createdAt = 0, modifiedAt = 0,134 )135 val saved = r.addEntry(full)136 r.lock(); r.unlock("correct horse battery")137 val back = r.getEntry(saved.id)!!138 assertEquals("june", back.username)139 assertEquals("[email protected]", back.email)140 assertEquals("https://example.com", back.url)141 assertEquals(listOf("https://alt.example.com"), back.additionalUrls)142 assertEquals("JBSWY3DPEHPK3PXP", back.totp)143 assertEquals("a note", back.notes)144 assertEquals(2, back.breach)145 assertEquals(listOf("work", "email"), back.tags)146 }147148 @Test fun unicode_and_emoji_fields_preserved() = runBlocking {149 val storage = Storage()150 val r = VaultRepository(storage)151 r.createVault("correct horse battery")152 val e = r.addEntry(153 login("日本語のタイトル", password = "🔑🦆-pässwörd-Ω").copy(notes = "メモ 🎴"),154 )155 r.lock(); r.unlock("correct horse battery")156 val back = r.getEntry(e.id)!!157 assertEquals("日本語のタイトル", back.title)158 assertEquals("🔑🦆-pässwörd-Ω", back.password)159 assertEquals("メモ 🎴", back.notes)160 }161162 @Test fun card_entry_derives_last4() = runBlocking {163 val r = repo()164 val card = VaultEntry(165 id = "", type = "card", title = "Visa", cardNumber = "4242424242424242",166 cardholderName = "J Doe", createdAt = 0, modifiedAt = 0,167 )168 val saved = r.addEntry(card)169 assertEquals("4242", saved.last4)170 }171172 @Test fun non_card_entry_keeps_null_last4() = runBlocking {173 val r = repo()174 val saved = r.addEntry(login("L"))175 assertNull(saved.last4)176 }177178 @Test fun restore_only_works_on_deleted_entry() = runBlocking {179 val r = repo()180 val e = r.addEntry(login("A"))181 try {182 r.restoreEntry(e.id) 183 error("expected throw")184 } catch (_: IllegalStateException) { }185 }186187 @Test fun purge_deleted_entries_returns_zero_when_trash_empty() = runBlocking {188 val r = repo()189 r.addEntry(login("Live"))190 assertEquals(0, r.purgeDeletedEntries())191 }192193 194195 @Test fun distinct_field_values_sorted_by_frequency() = runBlocking {196 val r = repo()197 r.addEntry(login("a").copy(username = "popular"))198 r.addEntry(login("b").copy(username = "popular"))199 r.addEntry(login("c").copy(username = "rare"))200 assertEquals(listOf("popular", "rare"), r.getDistinctFieldValues("username"))201 }202203 @Test fun distinct_field_values_dedup_case_insensitive_keeps_first_casing() = runBlocking {204 val r = repo()205 r.addEntry(login("a").copy(username = "June"))206 r.addEntry(login("b").copy(username = "june"))207 assertEquals(listOf("June"), r.getDistinctFieldValues("username"))208 }209210 @Test fun distinct_field_values_email() = runBlocking {211 val r = repo()212 r.addEntry(login("a").copy(email = "[email protected]"))213 assertEquals(listOf("[email protected]"), r.getDistinctFieldValues("email"))214 }215216 @Test fun distinct_field_values_unknown_field_is_empty() = runBlocking {217 val r = repo()218 r.addEntry(login("a").copy(username = "x"))219 assertTrue(r.getDistinctFieldValues("nope").isEmpty())220 }221222 @Test fun distinct_field_values_excludes_deleted() = runBlocking {223 val r = repo()224 val gone = r.addEntry(login("a").copy(username = "ghost"))225 r.deleteEntry(gone.id)226 assertTrue(r.getDistinctFieldValues("username").isEmpty())227 }228229 230231 @Test fun concurrent_adds_do_not_corrupt() = runBlocking {232 val r = repo()233 val results = (0 until 50).map { i -> async { r.addEntry(login("C$i")) } }.awaitAll()234 assertEquals(50, r.getEntries().size)235 assertEquals(50, results.map { it.id }.toSet().size) 236 }237238 @Test fun concurrent_add_and_delete_are_consistent() = runBlocking {239 val r = repo()240 val seed = (0 until 20).map { r.addEntry(login("S$it")) }241 val jobs = buildList {242 seed.take(10).forEach { e -> add(async { r.deleteEntry(e.id) }) }243 repeat(10) { i -> add(async { r.addEntry(login("N$i")) }) }244 }245 jobs.awaitAll()246 247 assertEquals(20, r.getEntries().size)248 assertEquals(10, r.getTombstones().size)249 }250251 252253 @Test fun local_mutations_bump_version_sync_apply_does_not() = runBlocking {254 val r = repo()255 val v0 = r.getVaultVersion()256 r.addEntry(login("Local"))257 val v1 = r.getVaultVersion()258 assertTrue(v1 > v0)259 val remote = VaultEntry(id = "rem", title = "R", type = "login", password = "x",260 createdAt = 1, modifiedAt = System.currentTimeMillis() + 10_000)261 r.applyMergedRemoteEntries(listOf(remote), emptyList())262 assertEquals(v1, r.getVaultVersion())263 }264265 @Test fun sync_key_and_vault_id_available_only_when_unlocked() = runBlocking {266 val r = repo()267 assertNotNull(r.syncMasterKey())268 assertNotNull(r.syncVaultId())269 r.lock()270 assertNull(r.syncMasterKey())271 assertNull(r.syncVaultId())272 }273}