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 adopted_vault_refuses_to_overwrite_existing_local_vault() = runBlocking {193 val r = repo()194 r.addEntry(login("Must survive", password = "local-only"))195 val originalVaultId = r.syncVaultId()196197 assertEquals(198 AdoptVaultResult.Error("ADOPT_VAULT_ALREADY_EXISTS"),199 r.createVaultFromRootKey(200 "receiver-local-passphrase-456",201 "55".repeat(32),202 ),203 )204205 assertEquals(originalVaultId, r.syncVaultId())206 assertTrue(r.getEntries().any { it.title == "Must survive" })207 }208209 @Test fun create_then_destroy_then_create_fresh() = runBlocking {210 val storage = Storage()211 val r = VaultRepository(storage)212 r.createVault("first-pass-here")213 r.addEntry(login("Gone"))214 r.destroyVault()215 assertFalse(r.hasVault())216 assertEquals(CreateVaultResult.Success, r.createVault("second-pass-here"))217 assertEquals(0, r.getEntries().size)218 }219220 221222 @Test fun update_nonexistent_entry_throws() = runBlocking {223 val r = repo()224 try {225 r.updateEntry(login("ghost").copy(id = "missing"))226 error("expected throw")227 } catch (e: IllegalStateException) {228 assertEquals("entry_not_found", e.message)229 }230 }231232 @Test fun delete_nonexistent_entry_throws() = runBlocking {233 val r = repo()234 try {235 r.deleteEntry("missing")236 error("expected throw")237 } catch (_: IllegalStateException) { }238 }239240 @Test fun get_entry_returns_null_for_deleted() = runBlocking {241 val r = repo()242 val e = r.addEntry(login("D"))243 r.deleteEntry(e.id)244 assertNull(r.getEntry(e.id))245 }246247 @Test fun hundred_entries_all_present() = runBlocking {248 val r = repo()249 repeat(100) { r.addEntry(login("E$it")) }250 assertEquals(100, r.getEntries().size)251 }252253 @Test fun all_fields_survive_lock_unlock() = runBlocking {254 val storage = Storage()255 val r = VaultRepository(storage)256 r.createVault("correct horse battery")257 val full = VaultEntry(258 id = "", type = "login", title = "Full",259 username = "june", email = "[email protected]", password = "s3cret",260 url = "https://example.com", additionalUrls = listOf("https://alt.example.com"),261 totp = "JBSWY3DPEHPK3PXP", notes = "a note", breach = 2,262 tags = listOf("work", "email"), createdAt = 0, modifiedAt = 0,263 )264 val saved = r.addEntry(full)265 r.lock(); r.unlock("correct horse battery")266 val back = r.getEntry(saved.id)!!267 assertEquals("june", back.username)268 assertEquals("[email protected]", back.email)269 assertEquals("https://example.com", back.url)270 assertEquals(listOf("https://alt.example.com"), back.additionalUrls)271 assertEquals("JBSWY3DPEHPK3PXP", back.totp)272 assertEquals("a note", back.notes)273 assertEquals(2, back.breach)274 assertEquals(listOf("work", "email"), back.tags)275 }276277 @Test fun unicode_and_emoji_fields_preserved() = runBlocking {278 val storage = Storage()279 val r = VaultRepository(storage)280 r.createVault("correct horse battery")281 val e = r.addEntry(282 login("日本語のタイトル", password = "🔑🦆-pässwörd-Ω").copy(notes = "メモ 🎴"),283 )284 r.lock(); r.unlock("correct horse battery")285 val back = r.getEntry(e.id)!!286 assertEquals("日本語のタイトル", back.title)287 assertEquals("🔑🦆-pässwörd-Ω", back.password)288 assertEquals("メモ 🎴", back.notes)289 }290291 @Test fun card_entry_derives_last4() = runBlocking {292 val r = repo()293 val card = VaultEntry(294 id = "", type = "card", title = "Visa", cardNumber = "4242424242424242",295 cardholderName = "J Doe", createdAt = 0, modifiedAt = 0,296 )297 val saved = r.addEntry(card)298 assertEquals("4242", saved.last4)299 }300301 @Test fun non_card_entry_keeps_null_last4() = runBlocking {302 val r = repo()303 val saved = r.addEntry(login("L"))304 assertNull(saved.last4)305 }306307 @Test fun restore_only_works_on_deleted_entry() = runBlocking {308 val r = repo()309 val e = r.addEntry(login("A"))310 try {311 r.restoreEntry(e.id) 312 error("expected throw")313 } catch (_: IllegalStateException) { }314 }315316 @Test fun purge_deleted_entries_returns_zero_when_trash_empty() = runBlocking {317 val r = repo()318 r.addEntry(login("Live"))319 assertEquals(0, r.purgeDeletedEntries())320 }321322 323324 @Test fun distinct_field_values_sorted_by_frequency() = runBlocking {325 val r = repo()326 r.addEntry(login("a").copy(username = "popular"))327 r.addEntry(login("b").copy(username = "popular"))328 r.addEntry(login("c").copy(username = "rare"))329 assertEquals(listOf("popular", "rare"), r.getDistinctFieldValues("username"))330 }331332 @Test fun distinct_field_values_dedup_case_insensitive_keeps_first_casing() = runBlocking {333 val r = repo()334 r.addEntry(login("a").copy(username = "June"))335 r.addEntry(login("b").copy(username = "june"))336 assertEquals(listOf("June"), r.getDistinctFieldValues("username"))337 }338339 @Test fun distinct_field_values_email() = runBlocking {340 val r = repo()341 r.addEntry(login("a").copy(email = "[email protected]"))342 assertEquals(listOf("[email protected]"), r.getDistinctFieldValues("email"))343 }344345 @Test fun distinct_field_values_unknown_field_is_empty() = runBlocking {346 val r = repo()347 r.addEntry(login("a").copy(username = "x"))348 assertTrue(r.getDistinctFieldValues("nope").isEmpty())349 }350351 @Test fun distinct_field_values_excludes_deleted() = runBlocking {352 val r = repo()353 val gone = r.addEntry(login("a").copy(username = "ghost"))354 r.deleteEntry(gone.id)355 assertTrue(r.getDistinctFieldValues("username").isEmpty())356 }357358 359360 @Test fun concurrent_adds_do_not_corrupt() = runBlocking {361 val r = repo()362 val results = (0 until 50).map { i -> async { r.addEntry(login("C$i")) } }.awaitAll()363 assertEquals(50, r.getEntries().size)364 assertEquals(50, results.map { it.id }.toSet().size) 365 }366367 @Test fun concurrent_add_and_delete_are_consistent() = runBlocking {368 val r = repo()369 val seed = (0 until 20).map { r.addEntry(login("S$it")) }370 val jobs = buildList {371 seed.take(10).forEach { e -> add(async { r.deleteEntry(e.id) }) }372 repeat(10) { i -> add(async { r.addEntry(login("N$i")) }) }373 }374 jobs.awaitAll()375 376 assertEquals(20, r.getEntries().size)377 assertEquals(10, r.getTombstones().size)378 }379380 381382 @Test fun local_mutations_bump_version_sync_apply_does_not() = runBlocking {383 val r = repo()384 val v0 = r.getVaultVersion()385 r.addEntry(login("Local"))386 val v1 = r.getVaultVersion()387 assertTrue(v1 > v0)388 val remote = VaultEntry(id = "rem", title = "R", type = "login", password = "x",389 createdAt = 1, modifiedAt = System.currentTimeMillis() + 10_000)390 r.applyMergedRemoteEntries(listOf(remote), emptyList())391 assertEquals(v1, r.getVaultVersion())392 }393394 @Test fun sync_key_and_vault_id_available_only_when_unlocked() = runBlocking {395 val r = repo()396 assertNotNull(r.syncMasterKey())397 assertNotNull(r.syncVaultId())398 r.lock()399 assertNull(r.syncMasterKey())400 assertNull(r.syncVaultId())401 }402}