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 adopted_vault_preserves_source_root_key_vault_id() = runBlocking {81 val rootKeyHex = "66".repeat(32)82 val rootKey = hexToRootKey(rootKeyHex)83 val expectedVaultId = try {84 com.peachpasswords.sync.computeVaultId(rootKey)85 } finally {86 VaultCrypto.zero(rootKey)87 }88 val r = VaultRepository(Storage())8990 val result = r.createVaultFromRootKey("receiver-local-passphrase-456", rootKeyHex)9192 assertEquals(AdoptVaultResult.Success(expectedVaultId), result)93 assertTrue(r.isUnlocked.value)94 assertEquals(expectedVaultId, r.syncVaultId())95 }9697 @Test fun adopted_vault_supports_writes_without_rotating_vault_id() = runBlocking {98 val rootKeyHex = "33".repeat(32)99 val r = VaultRepository(Storage())100 val adopted = r.createVaultFromRootKey("receiver-local-passphrase-456", rootKeyHex)101 val vaultId = (adopted as AdoptVaultResult.Success).vaultId102103 r.addEntry(login("Example", password = "secret"))104105 assertTrue(r.getEntries().any { it.title == "Example" })106 assertEquals(vaultId, r.syncVaultId())107 }108109 @Test fun adopted_vault_rejects_invalid_root_key_input() = runBlocking {110 val r = VaultRepository(Storage())111112 listOf("a".repeat(63), "a".repeat(65), "A".repeat(64), "g".repeat(64), "").forEach { rootKeyHex ->113 val result = r.createVaultFromRootKey("receiver-local-passphrase-456", rootKeyHex)114 assertTrue(result is AdoptVaultResult.Error)115 assertTrue((result as AdoptVaultResult.Error).reason.contains("ADOPT_INVALID_ROOT_KEY"))116 }117 assertFalse(r.hasVault())118 assertFalse(r.isUnlocked.value)119 }120121 @Test fun adopted_vault_rejects_empty_receiver_password() = runBlocking {122 val r = VaultRepository(Storage())123124 assertEquals(125 AdoptVaultResult.Error("ADOPT_INVALID_PASSWORD"),126 r.createVaultFromRootKey("", "44".repeat(32)),127 )128 assertFalse(r.hasVault())129 }130131 @Test fun create_then_destroy_then_create_fresh() = runBlocking {132 val storage = Storage()133 val r = VaultRepository(storage)134 r.createVault("first-pass-here")135 r.addEntry(login("Gone"))136 r.destroyVault()137 assertFalse(r.hasVault())138 assertEquals(CreateVaultResult.Success, r.createVault("second-pass-here"))139 assertEquals(0, r.getEntries().size)140 }141142 143144 @Test fun update_nonexistent_entry_throws() = runBlocking {145 val r = repo()146 try {147 r.updateEntry(login("ghost").copy(id = "missing"))148 error("expected throw")149 } catch (e: IllegalStateException) {150 assertEquals("entry_not_found", e.message)151 }152 }153154 @Test fun delete_nonexistent_entry_throws() = runBlocking {155 val r = repo()156 try {157 r.deleteEntry("missing")158 error("expected throw")159 } catch (_: IllegalStateException) { }160 }161162 @Test fun get_entry_returns_null_for_deleted() = runBlocking {163 val r = repo()164 val e = r.addEntry(login("D"))165 r.deleteEntry(e.id)166 assertNull(r.getEntry(e.id))167 }168169 @Test fun hundred_entries_all_present() = runBlocking {170 val r = repo()171 repeat(100) { r.addEntry(login("E$it")) }172 assertEquals(100, r.getEntries().size)173 }174175 @Test fun all_fields_survive_lock_unlock() = runBlocking {176 val storage = Storage()177 val r = VaultRepository(storage)178 r.createVault("correct horse battery")179 val full = VaultEntry(180 id = "", type = "login", title = "Full",181 username = "june", email = "[email protected]", password = "s3cret",182 url = "https://example.com", additionalUrls = listOf("https://alt.example.com"),183 totp = "JBSWY3DPEHPK3PXP", notes = "a note", breach = 2,184 tags = listOf("work", "email"), createdAt = 0, modifiedAt = 0,185 )186 val saved = r.addEntry(full)187 r.lock(); r.unlock("correct horse battery")188 val back = r.getEntry(saved.id)!!189 assertEquals("june", back.username)190 assertEquals("[email protected]", back.email)191 assertEquals("https://example.com", back.url)192 assertEquals(listOf("https://alt.example.com"), back.additionalUrls)193 assertEquals("JBSWY3DPEHPK3PXP", back.totp)194 assertEquals("a note", back.notes)195 assertEquals(2, back.breach)196 assertEquals(listOf("work", "email"), back.tags)197 }198199 @Test fun unicode_and_emoji_fields_preserved() = runBlocking {200 val storage = Storage()201 val r = VaultRepository(storage)202 r.createVault("correct horse battery")203 val e = r.addEntry(204 login("日本語のタイトル", password = "🔑🦆-pässwörd-Ω").copy(notes = "メモ 🎴"),205 )206 r.lock(); r.unlock("correct horse battery")207 val back = r.getEntry(e.id)!!208 assertEquals("日本語のタイトル", back.title)209 assertEquals("🔑🦆-pässwörd-Ω", back.password)210 assertEquals("メモ 🎴", back.notes)211 }212213 @Test fun card_entry_derives_last4() = runBlocking {214 val r = repo()215 val card = VaultEntry(216 id = "", type = "card", title = "Visa", cardNumber = "4242424242424242",217 cardholderName = "J Doe", createdAt = 0, modifiedAt = 0,218 )219 val saved = r.addEntry(card)220 assertEquals("4242", saved.last4)221 }222223 @Test fun non_card_entry_keeps_null_last4() = runBlocking {224 val r = repo()225 val saved = r.addEntry(login("L"))226 assertNull(saved.last4)227 }228229 @Test fun restore_only_works_on_deleted_entry() = runBlocking {230 val r = repo()231 val e = r.addEntry(login("A"))232 try {233 r.restoreEntry(e.id) 234 error("expected throw")235 } catch (_: IllegalStateException) { }236 }237238 @Test fun purge_deleted_entries_returns_zero_when_trash_empty() = runBlocking {239 val r = repo()240 r.addEntry(login("Live"))241 assertEquals(0, r.purgeDeletedEntries())242 }243244 245246 @Test fun distinct_field_values_sorted_by_frequency() = runBlocking {247 val r = repo()248 r.addEntry(login("a").copy(username = "popular"))249 r.addEntry(login("b").copy(username = "popular"))250 r.addEntry(login("c").copy(username = "rare"))251 assertEquals(listOf("popular", "rare"), r.getDistinctFieldValues("username"))252 }253254 @Test fun distinct_field_values_dedup_case_insensitive_keeps_first_casing() = runBlocking {255 val r = repo()256 r.addEntry(login("a").copy(username = "June"))257 r.addEntry(login("b").copy(username = "june"))258 assertEquals(listOf("June"), r.getDistinctFieldValues("username"))259 }260261 @Test fun distinct_field_values_email() = runBlocking {262 val r = repo()263 r.addEntry(login("a").copy(email = "[email protected]"))264 assertEquals(listOf("[email protected]"), r.getDistinctFieldValues("email"))265 }266267 @Test fun distinct_field_values_unknown_field_is_empty() = runBlocking {268 val r = repo()269 r.addEntry(login("a").copy(username = "x"))270 assertTrue(r.getDistinctFieldValues("nope").isEmpty())271 }272273 @Test fun distinct_field_values_excludes_deleted() = runBlocking {274 val r = repo()275 val gone = r.addEntry(login("a").copy(username = "ghost"))276 r.deleteEntry(gone.id)277 assertTrue(r.getDistinctFieldValues("username").isEmpty())278 }279280 281282 @Test fun concurrent_adds_do_not_corrupt() = runBlocking {283 val r = repo()284 val results = (0 until 50).map { i -> async { r.addEntry(login("C$i")) } }.awaitAll()285 assertEquals(50, r.getEntries().size)286 assertEquals(50, results.map { it.id }.toSet().size) 287 }288289 @Test fun concurrent_add_and_delete_are_consistent() = runBlocking {290 val r = repo()291 val seed = (0 until 20).map { r.addEntry(login("S$it")) }292 val jobs = buildList {293 seed.take(10).forEach { e -> add(async { r.deleteEntry(e.id) }) }294 repeat(10) { i -> add(async { r.addEntry(login("N$i")) }) }295 }296 jobs.awaitAll()297 298 assertEquals(20, r.getEntries().size)299 assertEquals(10, r.getTombstones().size)300 }301302 303304 @Test fun local_mutations_bump_version_sync_apply_does_not() = runBlocking {305 val r = repo()306 val v0 = r.getVaultVersion()307 r.addEntry(login("Local"))308 val v1 = r.getVaultVersion()309 assertTrue(v1 > v0)310 val remote = VaultEntry(id = "rem", title = "R", type = "login", password = "x",311 createdAt = 1, modifiedAt = System.currentTimeMillis() + 10_000)312 r.applyMergedRemoteEntries(listOf(remote), emptyList())313 assertEquals(v1, r.getVaultVersion())314 }315316 @Test fun sync_key_and_vault_id_available_only_when_unlocked() = runBlocking {317 val r = repo()318 assertNotNull(r.syncMasterKey())319 assertNotNull(r.syncVaultId())320 r.lock()321 assertNull(r.syncMasterKey())322 assertNull(r.syncVaultId())323 }324}