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 81828384858687 @Test fun codex_blob_restores_from_browser_shaped_wrapper() = runBlocking {88 val src = repo()89 src.addEntry(login("Portable"))90 val androidBlob = src.codexVaultBlobBase64()!!91 val wrapper = String(java.util.Base64.getUrlDecoder().decode(androidBlob), Charsets.UTF_8)92 93 94 95 val startMarker = "\"a\"\\s*:\\s*\\{".toRegex().find(wrapper)!!.range.last + 196 var depth = 197 var end = startMarker98 for (i in startMarker until wrapper.length) {99 when (wrapper[i]) {100 '{' -> depth++101 '}' -> { depth--; if (depth == 0) { end = i; break } }102 }103 }104 val slotA = wrapper.substring(startMarker - 1, end + 1)105106 107 val browserWrapper = kotlinx.serialization.json.buildJsonObject {108 put("a", kotlinx.serialization.json.JsonPrimitive(slotA))109 put("b", kotlinx.serialization.json.JsonNull)110 }.toString()111 val browserBlob = java.util.Base64.getUrlEncoder().withoutPadding()112 .encodeToString(browserWrapper.toByteArray(Charsets.UTF_8))113114 val dst = VaultRepository(Storage())115 assertEquals(UnlockResult.Success, dst.restoreFromCodex(browserBlob, "correct horse battery"))116 assertTrue(dst.getEntries().any { it.title == "Portable" })117 }118119 @Test fun fresh_vault_yields_twelve_recovery_words() = runBlocking {120 val r = repo()121 val key = r.syncMasterKey()122 assertTrue("syncMasterKey null right after createVault", key != null)123 val words = com.peachpasswords.vault.rootKeyToWords(key!!)124 println("WORDS=${words.size} ${words.take(3)}")125 assertEquals(12, words.size)126 }127128 129130131132133 @Test fun batched_import_of_a_real_sized_vault_is_not_quadratic() = runBlocking {134 val n = 806135 val drafts = (1..n).map { login("Entry $it") }136137 val batched = repo()138 val tBatch = System.currentTimeMillis().let { start ->139 assertEquals(n, batched.addEntries(drafts))140 System.currentTimeMillis() - start141 }142 assertEquals(n, batched.getEntries().size)143144 val looped = repo()145 val tLoop = System.currentTimeMillis().let { start ->146 drafts.forEach { looped.addEntry(it.copy(id = "")) }147 System.currentTimeMillis() - start148 }149 println("IMPORT n=$n batched=${tBatch}ms looped=${tLoop}ms")150 assertTrue("batched ($tBatch ms) should beat looped ($tLoop ms)", tBatch < tLoop)151 }152153 @Test fun adopted_vault_preserves_source_root_key_vault_id() = runBlocking {154 val rootKeyHex = "66".repeat(32)155 val rootKey = hexToRootKey(rootKeyHex)156 val expectedVaultId = try {157 com.peachpasswords.sync.computeVaultId(rootKey)158 } finally {159 VaultCrypto.zero(rootKey)160 }161 val r = VaultRepository(Storage())162163 val result = r.createVaultFromRootKey("receiver-local-passphrase-456", rootKeyHex)164165 assertEquals(AdoptVaultResult.Success(expectedVaultId), result)166 assertTrue(r.isUnlocked.value)167 assertEquals(expectedVaultId, r.syncVaultId())168 }169170 @Test fun adopted_vault_supports_writes_without_rotating_vault_id() = runBlocking {171 val rootKeyHex = "33".repeat(32)172 val r = VaultRepository(Storage())173 val adopted = r.createVaultFromRootKey("receiver-local-passphrase-456", rootKeyHex)174 val vaultId = (adopted as AdoptVaultResult.Success).vaultId175176 r.addEntry(login("Example", password = "secret"))177178 assertTrue(r.getEntries().any { it.title == "Example" })179 assertEquals(vaultId, r.syncVaultId())180 }181182 @Test fun adopted_vault_rejects_invalid_root_key_input() = runBlocking {183 val r = VaultRepository(Storage())184185 listOf("a".repeat(63), "a".repeat(65), "A".repeat(64), "g".repeat(64), "").forEach { rootKeyHex ->186 val result = r.createVaultFromRootKey("receiver-local-passphrase-456", rootKeyHex)187 assertTrue(result is AdoptVaultResult.Error)188 assertTrue((result as AdoptVaultResult.Error).reason.contains("ADOPT_INVALID_ROOT_KEY"))189 }190 assertFalse(r.hasVault())191 assertFalse(r.isUnlocked.value)192 }193194 @Test fun adopted_vault_rejects_empty_receiver_password() = runBlocking {195 val r = VaultRepository(Storage())196197 assertEquals(198 AdoptVaultResult.Error("ADOPT_INVALID_PASSWORD"),199 r.createVaultFromRootKey("", "44".repeat(32)),200 )201 assertFalse(r.hasVault())202 }203204 @Test fun adopted_vault_refuses_to_overwrite_existing_local_vault() = runBlocking {205 val r = repo()206 r.addEntry(login("Must survive", password = "local-only"))207 val originalVaultId = r.syncVaultId()208209 assertEquals(210 AdoptVaultResult.Error("ADOPT_VAULT_ALREADY_EXISTS"),211 r.createVaultFromRootKey(212 "receiver-local-passphrase-456",213 "55".repeat(32),214 ),215 )216217 assertEquals(originalVaultId, r.syncVaultId())218 assertTrue(r.getEntries().any { it.title == "Must survive" })219 }220221 @Test fun create_then_destroy_then_create_fresh() = runBlocking {222 val storage = Storage()223 val r = VaultRepository(storage)224 r.createVault("first-pass-here")225 r.addEntry(login("Gone"))226 r.destroyVault()227 assertFalse(r.hasVault())228 assertEquals(CreateVaultResult.Success, r.createVault("second-pass-here"))229 assertEquals(0, r.getEntries().size)230 }231232 233234 @Test fun update_nonexistent_entry_throws() = runBlocking {235 val r = repo()236 try {237 r.updateEntry(login("ghost").copy(id = "missing"))238 error("expected throw")239 } catch (e: IllegalStateException) {240 assertEquals("entry_not_found", e.message)241 }242 }243244 @Test fun delete_nonexistent_entry_throws() = runBlocking {245 val r = repo()246 try {247 r.deleteEntry("missing")248 error("expected throw")249 } catch (_: IllegalStateException) { }250 }251252 @Test fun get_entry_returns_null_for_deleted() = runBlocking {253 val r = repo()254 val e = r.addEntry(login("D"))255 r.deleteEntry(e.id)256 assertNull(r.getEntry(e.id))257 }258259 @Test fun hundred_entries_all_present() = runBlocking {260 val r = repo()261 repeat(100) { r.addEntry(login("E$it")) }262 assertEquals(100, r.getEntries().size)263 }264265 @Test fun all_fields_survive_lock_unlock() = runBlocking {266 val storage = Storage()267 val r = VaultRepository(storage)268 r.createVault("correct horse battery")269 val full = VaultEntry(270 id = "", type = "login", title = "Full",271 username = "june", email = "[email protected]", password = "s3cret",272 url = "https://example.com", additionalUrls = listOf("https://alt.example.com"),273 totp = "JBSWY3DPEHPK3PXP", notes = "a note", breach = 2,274 tags = listOf("work", "email"), createdAt = 0, modifiedAt = 0,275 )276 val saved = r.addEntry(full)277 r.lock(); r.unlock("correct horse battery")278 val back = r.getEntry(saved.id)!!279 assertEquals("june", back.username)280 assertEquals("[email protected]", back.email)281 assertEquals("https://example.com", back.url)282 assertEquals(listOf("https://alt.example.com"), back.additionalUrls)283 assertEquals("JBSWY3DPEHPK3PXP", back.totp)284 assertEquals("a note", back.notes)285 assertEquals(2, back.breach)286 assertEquals(listOf("work", "email"), back.tags)287 }288289 @Test fun unicode_and_emoji_fields_preserved() = runBlocking {290 val storage = Storage()291 val r = VaultRepository(storage)292 r.createVault("correct horse battery")293 val e = r.addEntry(294 login("日本語のタイトル", password = "🔑🦆-pässwörd-Ω").copy(notes = "メモ 🎴"),295 )296 r.lock(); r.unlock("correct horse battery")297 val back = r.getEntry(e.id)!!298 assertEquals("日本語のタイトル", back.title)299 assertEquals("🔑🦆-pässwörd-Ω", back.password)300 assertEquals("メモ 🎴", back.notes)301 }302303 @Test fun card_entry_derives_last4() = runBlocking {304 val r = repo()305 val card = VaultEntry(306 id = "", type = "card", title = "Visa", cardNumber = "4242424242424242",307 cardholderName = "J Doe", createdAt = 0, modifiedAt = 0,308 )309 val saved = r.addEntry(card)310 assertEquals("4242", saved.last4)311 }312313 @Test fun non_card_entry_keeps_null_last4() = runBlocking {314 val r = repo()315 val saved = r.addEntry(login("L"))316 assertNull(saved.last4)317 }318319 @Test fun restore_only_works_on_deleted_entry() = runBlocking {320 val r = repo()321 val e = r.addEntry(login("A"))322 try {323 r.restoreEntry(e.id) 324 error("expected throw")325 } catch (_: IllegalStateException) { }326 }327328 @Test fun purge_deleted_entries_returns_zero_when_trash_empty() = runBlocking {329 val r = repo()330 r.addEntry(login("Live"))331 assertEquals(0, r.purgeDeletedEntries())332 }333334 335336 @Test fun distinct_field_values_sorted_by_frequency() = runBlocking {337 val r = repo()338 r.addEntry(login("a").copy(username = "popular"))339 r.addEntry(login("b").copy(username = "popular"))340 r.addEntry(login("c").copy(username = "rare"))341 assertEquals(listOf("popular", "rare"), r.getDistinctFieldValues("username"))342 }343344 @Test fun distinct_field_values_dedup_case_insensitive_keeps_first_casing() = runBlocking {345 val r = repo()346 r.addEntry(login("a").copy(username = "June"))347 r.addEntry(login("b").copy(username = "june"))348 assertEquals(listOf("June"), r.getDistinctFieldValues("username"))349 }350351 @Test fun distinct_field_values_email() = runBlocking {352 val r = repo()353 r.addEntry(login("a").copy(email = "[email protected]"))354 assertEquals(listOf("[email protected]"), r.getDistinctFieldValues("email"))355 }356357 @Test fun distinct_field_values_unknown_field_is_empty() = runBlocking {358 val r = repo()359 r.addEntry(login("a").copy(username = "x"))360 assertTrue(r.getDistinctFieldValues("nope").isEmpty())361 }362363 @Test fun distinct_field_values_excludes_deleted() = runBlocking {364 val r = repo()365 val gone = r.addEntry(login("a").copy(username = "ghost"))366 r.deleteEntry(gone.id)367 assertTrue(r.getDistinctFieldValues("username").isEmpty())368 }369370 371372 @Test fun concurrent_adds_do_not_corrupt() = runBlocking {373 val r = repo()374 val results = (0 until 50).map { i -> async { r.addEntry(login("C$i")) } }.awaitAll()375 assertEquals(50, r.getEntries().size)376 assertEquals(50, results.map { it.id }.toSet().size) 377 }378379 @Test fun concurrent_add_and_delete_are_consistent() = runBlocking {380 val r = repo()381 val seed = (0 until 20).map { r.addEntry(login("S$it")) }382 val jobs = buildList {383 seed.take(10).forEach { e -> add(async { r.deleteEntry(e.id) }) }384 repeat(10) { i -> add(async { r.addEntry(login("N$i")) }) }385 }386 jobs.awaitAll()387 388 assertEquals(20, r.getEntries().size)389 assertEquals(10, r.getTombstones().size)390 }391392 393394 @Test fun local_mutations_bump_version_sync_apply_does_not() = runBlocking {395 val r = repo()396 val v0 = r.getVaultVersion()397 r.addEntry(login("Local"))398 val v1 = r.getVaultVersion()399 assertTrue(v1 > v0)400 val remote = VaultEntry(id = "rem", title = "R", type = "login", password = "x",401 createdAt = 1, modifiedAt = System.currentTimeMillis() + 10_000)402 r.applyMergedRemoteEntries(listOf(remote), emptyList())403 assertEquals(v1, r.getVaultVersion())404 }405406 @Test fun sync_key_and_vault_id_available_only_when_unlocked() = runBlocking {407 val r = repo()408 assertNotNull(r.syncMasterKey())409 assertNotNull(r.syncVaultId())410 r.lock()411 assertNull(r.syncMasterKey())412 assertNull(r.syncVaultId())413 }414}