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 adopted_vault_preserves_source_root_key_vault_id() = runBlocking {108 val rootKeyHex = "66".repeat(32)109 val rootKey = hexToRootKey(rootKeyHex)110 val expectedVaultId = try {111 com.peachpasswords.sync.computeVaultId(rootKey)112 } finally {113 VaultCrypto.zero(rootKey)114 }115 val r = VaultRepository(Storage())116117 val result = r.createVaultFromRootKey("receiver-local-passphrase-456", rootKeyHex)118119 assertEquals(AdoptVaultResult.Success(expectedVaultId), result)120 assertTrue(r.isUnlocked.value)121 assertEquals(expectedVaultId, r.syncVaultId())122 }123124 @Test fun adopted_vault_supports_writes_without_rotating_vault_id() = runBlocking {125 val rootKeyHex = "33".repeat(32)126 val r = VaultRepository(Storage())127 val adopted = r.createVaultFromRootKey("receiver-local-passphrase-456", rootKeyHex)128 val vaultId = (adopted as AdoptVaultResult.Success).vaultId129130 r.addEntry(login("Example", password = "secret"))131132 assertTrue(r.getEntries().any { it.title == "Example" })133 assertEquals(vaultId, r.syncVaultId())134 }135136 @Test fun adopted_vault_rejects_invalid_root_key_input() = runBlocking {137 val r = VaultRepository(Storage())138139 listOf("a".repeat(63), "a".repeat(65), "A".repeat(64), "g".repeat(64), "").forEach { rootKeyHex ->140 val result = r.createVaultFromRootKey("receiver-local-passphrase-456", rootKeyHex)141 assertTrue(result is AdoptVaultResult.Error)142 assertTrue((result as AdoptVaultResult.Error).reason.contains("ADOPT_INVALID_ROOT_KEY"))143 }144 assertFalse(r.hasVault())145 assertFalse(r.isUnlocked.value)146 }147148 @Test fun adopted_vault_rejects_empty_receiver_password() = runBlocking {149 val r = VaultRepository(Storage())150151 assertEquals(152 AdoptVaultResult.Error("ADOPT_INVALID_PASSWORD"),153 r.createVaultFromRootKey("", "44".repeat(32)),154 )155 assertFalse(r.hasVault())156 }157158 @Test fun create_then_destroy_then_create_fresh() = runBlocking {159 val storage = Storage()160 val r = VaultRepository(storage)161 r.createVault("first-pass-here")162 r.addEntry(login("Gone"))163 r.destroyVault()164 assertFalse(r.hasVault())165 assertEquals(CreateVaultResult.Success, r.createVault("second-pass-here"))166 assertEquals(0, r.getEntries().size)167 }168169 170171 @Test fun update_nonexistent_entry_throws() = runBlocking {172 val r = repo()173 try {174 r.updateEntry(login("ghost").copy(id = "missing"))175 error("expected throw")176 } catch (e: IllegalStateException) {177 assertEquals("entry_not_found", e.message)178 }179 }180181 @Test fun delete_nonexistent_entry_throws() = runBlocking {182 val r = repo()183 try {184 r.deleteEntry("missing")185 error("expected throw")186 } catch (_: IllegalStateException) { }187 }188189 @Test fun get_entry_returns_null_for_deleted() = runBlocking {190 val r = repo()191 val e = r.addEntry(login("D"))192 r.deleteEntry(e.id)193 assertNull(r.getEntry(e.id))194 }195196 @Test fun hundred_entries_all_present() = runBlocking {197 val r = repo()198 repeat(100) { r.addEntry(login("E$it")) }199 assertEquals(100, r.getEntries().size)200 }201202 @Test fun all_fields_survive_lock_unlock() = runBlocking {203 val storage = Storage()204 val r = VaultRepository(storage)205 r.createVault("correct horse battery")206 val full = VaultEntry(207 id = "", type = "login", title = "Full",208 username = "june", email = "[email protected]", password = "s3cret",209 url = "https://example.com", additionalUrls = listOf("https://alt.example.com"),210 totp = "JBSWY3DPEHPK3PXP", notes = "a note", breach = 2,211 tags = listOf("work", "email"), createdAt = 0, modifiedAt = 0,212 )213 val saved = r.addEntry(full)214 r.lock(); r.unlock("correct horse battery")215 val back = r.getEntry(saved.id)!!216 assertEquals("june", back.username)217 assertEquals("[email protected]", back.email)218 assertEquals("https://example.com", back.url)219 assertEquals(listOf("https://alt.example.com"), back.additionalUrls)220 assertEquals("JBSWY3DPEHPK3PXP", back.totp)221 assertEquals("a note", back.notes)222 assertEquals(2, back.breach)223 assertEquals(listOf("work", "email"), back.tags)224 }225226 @Test fun unicode_and_emoji_fields_preserved() = runBlocking {227 val storage = Storage()228 val r = VaultRepository(storage)229 r.createVault("correct horse battery")230 val e = r.addEntry(231 login("日本語のタイトル", password = "🔑🦆-pässwörd-Ω").copy(notes = "メモ 🎴"),232 )233 r.lock(); r.unlock("correct horse battery")234 val back = r.getEntry(e.id)!!235 assertEquals("日本語のタイトル", back.title)236 assertEquals("🔑🦆-pässwörd-Ω", back.password)237 assertEquals("メモ 🎴", back.notes)238 }239240 @Test fun card_entry_derives_last4() = runBlocking {241 val r = repo()242 val card = VaultEntry(243 id = "", type = "card", title = "Visa", cardNumber = "4242424242424242",244 cardholderName = "J Doe", createdAt = 0, modifiedAt = 0,245 )246 val saved = r.addEntry(card)247 assertEquals("4242", saved.last4)248 }249250 @Test fun non_card_entry_keeps_null_last4() = runBlocking {251 val r = repo()252 val saved = r.addEntry(login("L"))253 assertNull(saved.last4)254 }255256 @Test fun restore_only_works_on_deleted_entry() = runBlocking {257 val r = repo()258 val e = r.addEntry(login("A"))259 try {260 r.restoreEntry(e.id) 261 error("expected throw")262 } catch (_: IllegalStateException) { }263 }264265 @Test fun purge_deleted_entries_returns_zero_when_trash_empty() = runBlocking {266 val r = repo()267 r.addEntry(login("Live"))268 assertEquals(0, r.purgeDeletedEntries())269 }270271 272273 @Test fun distinct_field_values_sorted_by_frequency() = runBlocking {274 val r = repo()275 r.addEntry(login("a").copy(username = "popular"))276 r.addEntry(login("b").copy(username = "popular"))277 r.addEntry(login("c").copy(username = "rare"))278 assertEquals(listOf("popular", "rare"), r.getDistinctFieldValues("username"))279 }280281 @Test fun distinct_field_values_dedup_case_insensitive_keeps_first_casing() = runBlocking {282 val r = repo()283 r.addEntry(login("a").copy(username = "June"))284 r.addEntry(login("b").copy(username = "june"))285 assertEquals(listOf("June"), r.getDistinctFieldValues("username"))286 }287288 @Test fun distinct_field_values_email() = runBlocking {289 val r = repo()290 r.addEntry(login("a").copy(email = "[email protected]"))291 assertEquals(listOf("[email protected]"), r.getDistinctFieldValues("email"))292 }293294 @Test fun distinct_field_values_unknown_field_is_empty() = runBlocking {295 val r = repo()296 r.addEntry(login("a").copy(username = "x"))297 assertTrue(r.getDistinctFieldValues("nope").isEmpty())298 }299300 @Test fun distinct_field_values_excludes_deleted() = runBlocking {301 val r = repo()302 val gone = r.addEntry(login("a").copy(username = "ghost"))303 r.deleteEntry(gone.id)304 assertTrue(r.getDistinctFieldValues("username").isEmpty())305 }306307 308309 @Test fun concurrent_adds_do_not_corrupt() = runBlocking {310 val r = repo()311 val results = (0 until 50).map { i -> async { r.addEntry(login("C$i")) } }.awaitAll()312 assertEquals(50, r.getEntries().size)313 assertEquals(50, results.map { it.id }.toSet().size) 314 }315316 @Test fun concurrent_add_and_delete_are_consistent() = runBlocking {317 val r = repo()318 val seed = (0 until 20).map { r.addEntry(login("S$it")) }319 val jobs = buildList {320 seed.take(10).forEach { e -> add(async { r.deleteEntry(e.id) }) }321 repeat(10) { i -> add(async { r.addEntry(login("N$i")) }) }322 }323 jobs.awaitAll()324 325 assertEquals(20, r.getEntries().size)326 assertEquals(10, r.getTombstones().size)327 }328329 330331 @Test fun local_mutations_bump_version_sync_apply_does_not() = runBlocking {332 val r = repo()333 val v0 = r.getVaultVersion()334 r.addEntry(login("Local"))335 val v1 = r.getVaultVersion()336 assertTrue(v1 > v0)337 val remote = VaultEntry(id = "rem", title = "R", type = "login", password = "x",338 createdAt = 1, modifiedAt = System.currentTimeMillis() + 10_000)339 r.applyMergedRemoteEntries(listOf(remote), emptyList())340 assertEquals(v1, r.getVaultVersion())341 }342343 @Test fun sync_key_and_vault_id_available_only_when_unlocked() = runBlocking {344 val r = repo()345 assertNotNull(r.syncMasterKey())346 assertNotNull(r.syncVaultId())347 r.lock()348 assertNull(r.syncMasterKey())349 assertNull(r.syncVaultId())350 }351}