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(): Boolean { slotA = null; slotB = null; ver = 0; settings = VaultSettings(); return true }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 assertFalse("failed staged biometric decode leaves no live session", r.isUnlocked.value)70 assertTrue("failed staged biometric decode exposes no entries", r.getEntries().isEmpty())71 assertNull("failed staged biometric decode exposes no key", r.currentVaultKey())72 }7374 @Test fun codex_blob_restores_on_another_repo() = runBlocking {75 val src = repo()76 src.addEntry(login("Portable"))77 val blob = src.codexVaultBlobBase64()!!78 val dst = VaultRepository(Storage())79 assertEquals(UnlockResult.Success, dst.restoreFromCodex(blob, "correct horse battery"))80 assertTrue(dst.getEntries().any { it.title == "Portable" })81 }8283 84858687888990 @Test fun codex_blob_restores_from_browser_shaped_wrapper() = runBlocking {91 val src = repo()92 src.addEntry(login("Portable"))93 val androidBlob = src.codexVaultBlobBase64()!!94 val wrapper = String(java.util.Base64.getUrlDecoder().decode(androidBlob), Charsets.UTF_8)95 96 97 98 val startMarker = "\"a\"\\s*:\\s*\\{".toRegex().find(wrapper)!!.range.last + 199 var depth = 1100 var end = startMarker101 for (i in startMarker until wrapper.length) {102 when (wrapper[i]) {103 '{' -> depth++104 '}' -> { depth--; if (depth == 0) { end = i; break } }105 }106 }107 val slotA = wrapper.substring(startMarker - 1, end + 1)108109 110 val browserWrapper = kotlinx.serialization.json.buildJsonObject {111 put("a", kotlinx.serialization.json.JsonPrimitive(slotA))112 put("b", kotlinx.serialization.json.JsonNull)113 }.toString()114 val browserBlob = java.util.Base64.getUrlEncoder().withoutPadding()115 .encodeToString(browserWrapper.toByteArray(Charsets.UTF_8))116117 val dst = VaultRepository(Storage())118 assertEquals(UnlockResult.Success, dst.restoreFromCodex(browserBlob, "correct horse battery"))119 assertTrue(dst.getEntries().any { it.title == "Portable" })120 }121122 @Test fun fresh_vault_yields_twelve_recovery_words() = runBlocking {123 val r = repo()124 val key = r.syncMasterKey()125 assertTrue("syncMasterKey null right after createVault", key != null)126 val words = com.peachpasswords.vault.rootKeyToWords(key!!)127 println("WORDS=${words.size} ${words.take(3)}")128 assertEquals(12, words.size)129 }130131 132133134135136 @Test fun batched_import_of_a_real_sized_vault_is_not_quadratic() = runBlocking {137 val n = 806138 val drafts = (1..n).map { login("Entry $it") }139140 val batched = repo()141 val tBatch = System.currentTimeMillis().let { start ->142 assertEquals(n, batched.addEntries(drafts))143 System.currentTimeMillis() - start144 }145 assertEquals(n, batched.getEntries().size)146147 val looped = repo()148 val tLoop = System.currentTimeMillis().let { start ->149 drafts.forEach { looped.addEntry(it.copy(id = "")) }150 System.currentTimeMillis() - start151 }152 println("IMPORT n=$n batched=${tBatch}ms looped=${tLoop}ms")153 assertTrue("batched ($tBatch ms) should beat looped ($tLoop ms)", tBatch < tLoop)154 }155156 @Test fun adopted_vault_preserves_source_root_key_vault_id() = runBlocking {157 val rootKeyHex = "66".repeat(32)158 val rootKey = hexToRootKey(rootKeyHex)159 val expectedVaultId = try {160 com.peachpasswords.sync.computeVaultId(rootKey)161 } finally {162 VaultCrypto.zero(rootKey)163 }164 val r = VaultRepository(Storage())165166 val result = r.createVaultFromRootKey("receiver-local-passphrase-456", rootKeyHex)167168 assertEquals(AdoptVaultResult.Success(expectedVaultId), result)169 assertTrue(r.isUnlocked.value)170 assertEquals(expectedVaultId, r.syncVaultId())171 }172173 @Test fun adopted_vault_supports_writes_without_rotating_vault_id() = runBlocking {174 val rootKeyHex = "33".repeat(32)175 val r = VaultRepository(Storage())176 val adopted = r.createVaultFromRootKey("receiver-local-passphrase-456", rootKeyHex)177 val vaultId = (adopted as AdoptVaultResult.Success).vaultId178179 r.addEntry(login("Example", password = "secret"))180181 assertTrue(r.getEntries().any { it.title == "Example" })182 assertEquals(vaultId, r.syncVaultId())183 }184185 @Test fun adopted_vault_rejects_invalid_root_key_input() = runBlocking {186 val r = VaultRepository(Storage())187188 listOf("a".repeat(63), "a".repeat(65), "A".repeat(64), "g".repeat(64), "").forEach { rootKeyHex ->189 val result = r.createVaultFromRootKey("receiver-local-passphrase-456", rootKeyHex)190 assertTrue(result is AdoptVaultResult.Error)191 assertTrue((result as AdoptVaultResult.Error).reason.contains("ADOPT_INVALID_ROOT_KEY"))192 }193 assertFalse(r.hasVault())194 assertFalse(r.isUnlocked.value)195 }196197 @Test fun adopted_vault_rejects_empty_receiver_password() = runBlocking {198 val r = VaultRepository(Storage())199200 assertEquals(201 AdoptVaultResult.Error("ADOPT_INVALID_PASSWORD"),202 r.createVaultFromRootKey("", "44".repeat(32)),203 )204 assertFalse(r.hasVault())205 }206207 @Test fun adopted_vault_refuses_to_overwrite_existing_local_vault() = runBlocking {208 val r = repo()209 r.addEntry(login("Must survive", password = "local-only"))210 val originalVaultId = r.syncVaultId()211212 assertEquals(213 AdoptVaultResult.Error("ADOPT_VAULT_ALREADY_EXISTS"),214 r.createVaultFromRootKey(215 "receiver-local-passphrase-456",216 "55".repeat(32),217 ),218 )219220 assertEquals(originalVaultId, r.syncVaultId())221 assertTrue(r.getEntries().any { it.title == "Must survive" })222 }223224 @Test fun create_then_destroy_then_create_fresh() = runBlocking {225 val storage = Storage()226 val r = VaultRepository(storage)227 r.createVault("first-pass-here")228 r.addEntry(login("Gone"))229 r.destroyVault()230 assertFalse(r.hasVault())231 assertEquals(CreateVaultResult.Success, r.createVault("second-pass-here"))232 assertEquals(0, r.getEntries().size)233 }234235 236237 @Test fun update_nonexistent_entry_throws() = runBlocking {238 val r = repo()239 try {240 r.updateEntry(login("ghost").copy(id = "missing"))241 error("expected throw")242 } catch (e: IllegalStateException) {243 assertEquals("entry_not_found", e.message)244 }245 }246247 @Test fun delete_nonexistent_entry_throws() = runBlocking {248 val r = repo()249 try {250 r.deleteEntry("missing")251 error("expected throw")252 } catch (_: IllegalStateException) { }253 }254255 @Test fun get_entry_returns_null_for_deleted() = runBlocking {256 val r = repo()257 val e = r.addEntry(login("D"))258 r.deleteEntry(e.id)259 assertNull(r.getEntry(e.id))260 }261262 @Test fun hundred_entries_all_present() = runBlocking {263 val r = repo()264 repeat(100) { r.addEntry(login("E$it")) }265 assertEquals(100, r.getEntries().size)266 }267268 @Test fun all_fields_survive_lock_unlock() = runBlocking {269 val storage = Storage()270 val r = VaultRepository(storage)271 r.createVault("correct horse battery")272 val full = VaultEntry(273 id = "", type = "login", title = "Full",274 username = "june", email = "[email protected]", password = "s3cret",275 url = "https://example.com", additionalUrls = listOf("https://alt.example.com"),276 totp = "JBSWY3DPEHPK3PXP", notes = "a note", breach = 2,277 tags = listOf("work", "email"), createdAt = 0, modifiedAt = 0,278 )279 val saved = r.addEntry(full)280 r.lock(); r.unlock("correct horse battery")281 val back = r.getEntry(saved.id)!!282 assertEquals("june", back.username)283 assertEquals("[email protected]", back.email)284 assertEquals("https://example.com", back.url)285 assertEquals(listOf("https://alt.example.com"), back.additionalUrls)286 assertEquals("JBSWY3DPEHPK3PXP", back.totp)287 assertEquals("a note", back.notes)288 assertEquals(2, back.breach)289 assertEquals(listOf("work", "email"), back.tags)290 }291292 @Test fun unicode_and_emoji_fields_preserved() = runBlocking {293 val storage = Storage()294 val r = VaultRepository(storage)295 r.createVault("correct horse battery")296 val e = r.addEntry(297 login("日本語のタイトル", password = "🔑🦆-pässwörd-Ω").copy(notes = "メモ 🎴"),298 )299 r.lock(); r.unlock("correct horse battery")300 val back = r.getEntry(e.id)!!301 assertEquals("日本語のタイトル", back.title)302 assertEquals("🔑🦆-pässwörd-Ω", back.password)303 assertEquals("メモ 🎴", back.notes)304 }305306 @Test fun card_entry_derives_last4() = runBlocking {307 val r = repo()308 val card = VaultEntry(309 id = "", type = "card", title = "Visa", cardNumber = "4242424242424242",310 cardholderName = "J Doe", createdAt = 0, modifiedAt = 0,311 )312 val saved = r.addEntry(card)313 assertEquals("4242", saved.last4)314 }315316 @Test fun non_card_entry_keeps_null_last4() = runBlocking {317 val r = repo()318 val saved = r.addEntry(login("L"))319 assertNull(saved.last4)320 }321322 @Test fun restore_only_works_on_deleted_entry() = runBlocking {323 val r = repo()324 val e = r.addEntry(login("A"))325 try {326 r.restoreEntry(e.id) 327 error("expected throw")328 } catch (_: IllegalStateException) { }329 }330331 @Test fun purge_deleted_entries_returns_zero_when_trash_empty() = runBlocking {332 val r = repo()333 r.addEntry(login("Live"))334 assertEquals(0, r.purgeDeletedEntries())335 }336337 338339 @Test fun distinct_field_values_sorted_by_frequency() = runBlocking {340 val r = repo()341 r.addEntry(login("a").copy(username = "popular"))342 r.addEntry(login("b").copy(username = "popular"))343 r.addEntry(login("c").copy(username = "rare"))344 assertEquals(listOf("popular", "rare"), r.getDistinctFieldValues("username"))345 }346347 @Test fun distinct_field_values_dedup_case_insensitive_keeps_first_casing() = runBlocking {348 val r = repo()349 r.addEntry(login("a").copy(username = "June"))350 r.addEntry(login("b").copy(username = "june"))351 assertEquals(listOf("June"), r.getDistinctFieldValues("username"))352 }353354 @Test fun distinct_field_values_email() = runBlocking {355 val r = repo()356 r.addEntry(login("a").copy(email = "[email protected]"))357 assertEquals(listOf("[email protected]"), r.getDistinctFieldValues("email"))358 }359360 @Test fun distinct_field_values_unknown_field_is_empty() = runBlocking {361 val r = repo()362 r.addEntry(login("a").copy(username = "x"))363 assertTrue(r.getDistinctFieldValues("nope").isEmpty())364 }365366 @Test fun distinct_field_values_excludes_deleted() = runBlocking {367 val r = repo()368 val gone = r.addEntry(login("a").copy(username = "ghost"))369 r.deleteEntry(gone.id)370 assertTrue(r.getDistinctFieldValues("username").isEmpty())371 }372373 374375 @Test fun concurrent_adds_do_not_corrupt() = runBlocking {376 val r = repo()377 val results = (0 until 50).map { i -> async { r.addEntry(login("C$i")) } }.awaitAll()378 assertEquals(50, r.getEntries().size)379 assertEquals(50, results.map { it.id }.toSet().size) 380 }381382 @Test fun concurrent_add_and_delete_are_consistent() = runBlocking {383 val r = repo()384 val seed = (0 until 20).map { r.addEntry(login("S$it")) }385 val jobs = buildList {386 seed.take(10).forEach { e -> add(async { r.deleteEntry(e.id) }) }387 repeat(10) { i -> add(async { r.addEntry(login("N$i")) }) }388 }389 jobs.awaitAll()390 391 assertEquals(20, r.getEntries().size)392 assertEquals(10, r.getTombstones().size)393 }394395 396397 @Test fun local_mutations_bump_version_sync_apply_does_not() = runBlocking {398 val r = repo()399 val v0 = r.getVaultVersion()400 r.addEntry(login("Local"))401 val v1 = r.getVaultVersion()402 assertTrue(v1 > v0)403 val remote = VaultEntry(id = "rem", title = "R", type = "login", password = "x",404 createdAt = 1, modifiedAt = System.currentTimeMillis() + 10_000)405 r.applyMergedRemoteEntries(listOf(remote), emptyList())406 assertEquals(v1, r.getVaultVersion())407 }408409 @Test fun sync_key_and_vault_id_available_only_when_unlocked() = runBlocking {410 val r = repo()411 assertNotNull(r.syncMasterKey())412 assertNotNull(r.syncVaultId())413 r.lock()414 assertNull(r.syncMasterKey())415 assertNull(r.syncVaultId())416 }417}