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 val slotA = Regex("\"a\"\\s*:\\s*(\\{.*\\})\\s*\\}\\s*$").find(wrapper)!!.groupValues[1]9394 95 val browserWrapper = kotlinx.serialization.json.buildJsonObject {96 put("a", kotlinx.serialization.json.JsonPrimitive(slotA))97 put("b", kotlinx.serialization.json.JsonNull)98 }.toString()99 val browserBlob = java.util.Base64.getUrlEncoder().withoutPadding()100 .encodeToString(browserWrapper.toByteArray(Charsets.UTF_8))101102 val dst = VaultRepository(Storage())103 assertEquals(UnlockResult.Success, dst.restoreFromCodex(browserBlob, "correct horse battery"))104 assertTrue(dst.getEntries().any { it.title == "Portable" })105 }106107 @Test fun fresh_vault_yields_twelve_recovery_words() = runBlocking {108 val r = repo()109 val key = r.syncMasterKey()110 assertTrue("syncMasterKey null right after createVault", key != null)111 val words = com.peachpasswords.vault.rootKeyToWords(key!!)112 println("WORDS=${words.size} ${words.take(3)}")113 assertEquals(12, words.size)114 }115116 117118119120121 @Test fun batched_import_of_a_real_sized_vault_is_not_quadratic() = runBlocking {122 val n = 806123 val drafts = (1..n).map { login("Entry $it") }124125 val batched = repo()126 val tBatch = System.currentTimeMillis().let { start ->127 assertEquals(n, batched.addEntries(drafts))128 System.currentTimeMillis() - start129 }130 assertEquals(n, batched.getEntries().size)131132 val looped = repo()133 val tLoop = System.currentTimeMillis().let { start ->134 drafts.forEach { looped.addEntry(it.copy(id = "")) }135 System.currentTimeMillis() - start136 }137 println("IMPORT n=$n batched=${tBatch}ms looped=${tLoop}ms")138 assertTrue("batched ($tBatch ms) should beat looped ($tLoop ms)", tBatch < tLoop)139 }140141 @Test fun adopted_vault_preserves_source_root_key_vault_id() = runBlocking {142 val rootKeyHex = "66".repeat(32)143 val rootKey = hexToRootKey(rootKeyHex)144 val expectedVaultId = try {145 com.peachpasswords.sync.computeVaultId(rootKey)146 } finally {147 VaultCrypto.zero(rootKey)148 }149 val r = VaultRepository(Storage())150151 val result = r.createVaultFromRootKey("receiver-local-passphrase-456", rootKeyHex)152153 assertEquals(AdoptVaultResult.Success(expectedVaultId), result)154 assertTrue(r.isUnlocked.value)155 assertEquals(expectedVaultId, r.syncVaultId())156 }157158 @Test fun adopted_vault_supports_writes_without_rotating_vault_id() = runBlocking {159 val rootKeyHex = "33".repeat(32)160 val r = VaultRepository(Storage())161 val adopted = r.createVaultFromRootKey("receiver-local-passphrase-456", rootKeyHex)162 val vaultId = (adopted as AdoptVaultResult.Success).vaultId163164 r.addEntry(login("Example", password = "secret"))165166 assertTrue(r.getEntries().any { it.title == "Example" })167 assertEquals(vaultId, r.syncVaultId())168 }169170 @Test fun adopted_vault_rejects_invalid_root_key_input() = runBlocking {171 val r = VaultRepository(Storage())172173 listOf("a".repeat(63), "a".repeat(65), "A".repeat(64), "g".repeat(64), "").forEach { rootKeyHex ->174 val result = r.createVaultFromRootKey("receiver-local-passphrase-456", rootKeyHex)175 assertTrue(result is AdoptVaultResult.Error)176 assertTrue((result as AdoptVaultResult.Error).reason.contains("ADOPT_INVALID_ROOT_KEY"))177 }178 assertFalse(r.hasVault())179 assertFalse(r.isUnlocked.value)180 }181182 @Test fun adopted_vault_rejects_empty_receiver_password() = runBlocking {183 val r = VaultRepository(Storage())184185 assertEquals(186 AdoptVaultResult.Error("ADOPT_INVALID_PASSWORD"),187 r.createVaultFromRootKey("", "44".repeat(32)),188 )189 assertFalse(r.hasVault())190 }191192 @Test fun create_then_destroy_then_create_fresh() = runBlocking {193 val storage = Storage()194 val r = VaultRepository(storage)195 r.createVault("first-pass-here")196 r.addEntry(login("Gone"))197 r.destroyVault()198 assertFalse(r.hasVault())199 assertEquals(CreateVaultResult.Success, r.createVault("second-pass-here"))200 assertEquals(0, r.getEntries().size)201 }202203 204205 @Test fun update_nonexistent_entry_throws() = runBlocking {206 val r = repo()207 try {208 r.updateEntry(login("ghost").copy(id = "missing"))209 error("expected throw")210 } catch (e: IllegalStateException) {211 assertEquals("entry_not_found", e.message)212 }213 }214215 @Test fun delete_nonexistent_entry_throws() = runBlocking {216 val r = repo()217 try {218 r.deleteEntry("missing")219 error("expected throw")220 } catch (_: IllegalStateException) { }221 }222223 @Test fun get_entry_returns_null_for_deleted() = runBlocking {224 val r = repo()225 val e = r.addEntry(login("D"))226 r.deleteEntry(e.id)227 assertNull(r.getEntry(e.id))228 }229230 @Test fun hundred_entries_all_present() = runBlocking {231 val r = repo()232 repeat(100) { r.addEntry(login("E$it")) }233 assertEquals(100, r.getEntries().size)234 }235236 @Test fun all_fields_survive_lock_unlock() = runBlocking {237 val storage = Storage()238 val r = VaultRepository(storage)239 r.createVault("correct horse battery")240 val full = VaultEntry(241 id = "", type = "login", title = "Full",242 username = "june", email = "[email protected]", password = "s3cret",243 url = "https://example.com", additionalUrls = listOf("https://alt.example.com"),244 totp = "JBSWY3DPEHPK3PXP", notes = "a note", breach = 2,245 tags = listOf("work", "email"), createdAt = 0, modifiedAt = 0,246 )247 val saved = r.addEntry(full)248 r.lock(); r.unlock("correct horse battery")249 val back = r.getEntry(saved.id)!!250 assertEquals("june", back.username)251 assertEquals("[email protected]", back.email)252 assertEquals("https://example.com", back.url)253 assertEquals(listOf("https://alt.example.com"), back.additionalUrls)254 assertEquals("JBSWY3DPEHPK3PXP", back.totp)255 assertEquals("a note", back.notes)256 assertEquals(2, back.breach)257 assertEquals(listOf("work", "email"), back.tags)258 }259260 @Test fun unicode_and_emoji_fields_preserved() = runBlocking {261 val storage = Storage()262 val r = VaultRepository(storage)263 r.createVault("correct horse battery")264 val e = r.addEntry(265 login("日本語のタイトル", password = "🔑🦆-pässwörd-Ω").copy(notes = "メモ 🎴"),266 )267 r.lock(); r.unlock("correct horse battery")268 val back = r.getEntry(e.id)!!269 assertEquals("日本語のタイトル", back.title)270 assertEquals("🔑🦆-pässwörd-Ω", back.password)271 assertEquals("メモ 🎴", back.notes)272 }273274 @Test fun card_entry_derives_last4() = runBlocking {275 val r = repo()276 val card = VaultEntry(277 id = "", type = "card", title = "Visa", cardNumber = "4242424242424242",278 cardholderName = "J Doe", createdAt = 0, modifiedAt = 0,279 )280 val saved = r.addEntry(card)281 assertEquals("4242", saved.last4)282 }283284 @Test fun non_card_entry_keeps_null_last4() = runBlocking {285 val r = repo()286 val saved = r.addEntry(login("L"))287 assertNull(saved.last4)288 }289290 @Test fun restore_only_works_on_deleted_entry() = runBlocking {291 val r = repo()292 val e = r.addEntry(login("A"))293 try {294 r.restoreEntry(e.id) 295 error("expected throw")296 } catch (_: IllegalStateException) { }297 }298299 @Test fun purge_deleted_entries_returns_zero_when_trash_empty() = runBlocking {300 val r = repo()301 r.addEntry(login("Live"))302 assertEquals(0, r.purgeDeletedEntries())303 }304305 306307 @Test fun distinct_field_values_sorted_by_frequency() = runBlocking {308 val r = repo()309 r.addEntry(login("a").copy(username = "popular"))310 r.addEntry(login("b").copy(username = "popular"))311 r.addEntry(login("c").copy(username = "rare"))312 assertEquals(listOf("popular", "rare"), r.getDistinctFieldValues("username"))313 }314315 @Test fun distinct_field_values_dedup_case_insensitive_keeps_first_casing() = runBlocking {316 val r = repo()317 r.addEntry(login("a").copy(username = "June"))318 r.addEntry(login("b").copy(username = "june"))319 assertEquals(listOf("June"), r.getDistinctFieldValues("username"))320 }321322 @Test fun distinct_field_values_email() = runBlocking {323 val r = repo()324 r.addEntry(login("a").copy(email = "[email protected]"))325 assertEquals(listOf("[email protected]"), r.getDistinctFieldValues("email"))326 }327328 @Test fun distinct_field_values_unknown_field_is_empty() = runBlocking {329 val r = repo()330 r.addEntry(login("a").copy(username = "x"))331 assertTrue(r.getDistinctFieldValues("nope").isEmpty())332 }333334 @Test fun distinct_field_values_excludes_deleted() = runBlocking {335 val r = repo()336 val gone = r.addEntry(login("a").copy(username = "ghost"))337 r.deleteEntry(gone.id)338 assertTrue(r.getDistinctFieldValues("username").isEmpty())339 }340341 342343 @Test fun concurrent_adds_do_not_corrupt() = runBlocking {344 val r = repo()345 val results = (0 until 50).map { i -> async { r.addEntry(login("C$i")) } }.awaitAll()346 assertEquals(50, r.getEntries().size)347 assertEquals(50, results.map { it.id }.toSet().size) 348 }349350 @Test fun concurrent_add_and_delete_are_consistent() = runBlocking {351 val r = repo()352 val seed = (0 until 20).map { r.addEntry(login("S$it")) }353 val jobs = buildList {354 seed.take(10).forEach { e -> add(async { r.deleteEntry(e.id) }) }355 repeat(10) { i -> add(async { r.addEntry(login("N$i")) }) }356 }357 jobs.awaitAll()358 359 assertEquals(20, r.getEntries().size)360 assertEquals(10, r.getTombstones().size)361 }362363 364365 @Test fun local_mutations_bump_version_sync_apply_does_not() = runBlocking {366 val r = repo()367 val v0 = r.getVaultVersion()368 r.addEntry(login("Local"))369 val v1 = r.getVaultVersion()370 assertTrue(v1 > v0)371 val remote = VaultEntry(id = "rem", title = "R", type = "login", password = "x",372 createdAt = 1, modifiedAt = System.currentTimeMillis() + 10_000)373 r.applyMergedRemoteEntries(listOf(remote), emptyList())374 assertEquals(v1, r.getVaultVersion())375 }376377 @Test fun sync_key_and_vault_id_available_only_when_unlocked() = runBlocking {378 val r = repo()379 assertNotNull(r.syncMasterKey())380 assertNotNull(r.syncVaultId())381 r.lock()382 assertNull(r.syncMasterKey())383 assertNull(r.syncVaultId())384 }385}