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 @Test fun adopted_vault_preserves_source_root_key_vault_id() = runBlocking {117 val rootKeyHex = "66".repeat(32)118 val rootKey = hexToRootKey(rootKeyHex)119 val expectedVaultId = try {120 com.peachpasswords.sync.computeVaultId(rootKey)121 } finally {122 VaultCrypto.zero(rootKey)123 }124 val r = VaultRepository(Storage())125126 val result = r.createVaultFromRootKey("receiver-local-passphrase-456", rootKeyHex)127128 assertEquals(AdoptVaultResult.Success(expectedVaultId), result)129 assertTrue(r.isUnlocked.value)130 assertEquals(expectedVaultId, r.syncVaultId())131 }132133 @Test fun adopted_vault_supports_writes_without_rotating_vault_id() = runBlocking {134 val rootKeyHex = "33".repeat(32)135 val r = VaultRepository(Storage())136 val adopted = r.createVaultFromRootKey("receiver-local-passphrase-456", rootKeyHex)137 val vaultId = (adopted as AdoptVaultResult.Success).vaultId138139 r.addEntry(login("Example", password = "secret"))140141 assertTrue(r.getEntries().any { it.title == "Example" })142 assertEquals(vaultId, r.syncVaultId())143 }144145 @Test fun adopted_vault_rejects_invalid_root_key_input() = runBlocking {146 val r = VaultRepository(Storage())147148 listOf("a".repeat(63), "a".repeat(65), "A".repeat(64), "g".repeat(64), "").forEach { rootKeyHex ->149 val result = r.createVaultFromRootKey("receiver-local-passphrase-456", rootKeyHex)150 assertTrue(result is AdoptVaultResult.Error)151 assertTrue((result as AdoptVaultResult.Error).reason.contains("ADOPT_INVALID_ROOT_KEY"))152 }153 assertFalse(r.hasVault())154 assertFalse(r.isUnlocked.value)155 }156157 @Test fun adopted_vault_rejects_empty_receiver_password() = runBlocking {158 val r = VaultRepository(Storage())159160 assertEquals(161 AdoptVaultResult.Error("ADOPT_INVALID_PASSWORD"),162 r.createVaultFromRootKey("", "44".repeat(32)),163 )164 assertFalse(r.hasVault())165 }166167 @Test fun create_then_destroy_then_create_fresh() = runBlocking {168 val storage = Storage()169 val r = VaultRepository(storage)170 r.createVault("first-pass-here")171 r.addEntry(login("Gone"))172 r.destroyVault()173 assertFalse(r.hasVault())174 assertEquals(CreateVaultResult.Success, r.createVault("second-pass-here"))175 assertEquals(0, r.getEntries().size)176 }177178 179180 @Test fun update_nonexistent_entry_throws() = runBlocking {181 val r = repo()182 try {183 r.updateEntry(login("ghost").copy(id = "missing"))184 error("expected throw")185 } catch (e: IllegalStateException) {186 assertEquals("entry_not_found", e.message)187 }188 }189190 @Test fun delete_nonexistent_entry_throws() = runBlocking {191 val r = repo()192 try {193 r.deleteEntry("missing")194 error("expected throw")195 } catch (_: IllegalStateException) { }196 }197198 @Test fun get_entry_returns_null_for_deleted() = runBlocking {199 val r = repo()200 val e = r.addEntry(login("D"))201 r.deleteEntry(e.id)202 assertNull(r.getEntry(e.id))203 }204205 @Test fun hundred_entries_all_present() = runBlocking {206 val r = repo()207 repeat(100) { r.addEntry(login("E$it")) }208 assertEquals(100, r.getEntries().size)209 }210211 @Test fun all_fields_survive_lock_unlock() = runBlocking {212 val storage = Storage()213 val r = VaultRepository(storage)214 r.createVault("correct horse battery")215 val full = VaultEntry(216 id = "", type = "login", title = "Full",217 username = "june", email = "[email protected]", password = "s3cret",218 url = "https://example.com", additionalUrls = listOf("https://alt.example.com"),219 totp = "JBSWY3DPEHPK3PXP", notes = "a note", breach = 2,220 tags = listOf("work", "email"), createdAt = 0, modifiedAt = 0,221 )222 val saved = r.addEntry(full)223 r.lock(); r.unlock("correct horse battery")224 val back = r.getEntry(saved.id)!!225 assertEquals("june", back.username)226 assertEquals("[email protected]", back.email)227 assertEquals("https://example.com", back.url)228 assertEquals(listOf("https://alt.example.com"), back.additionalUrls)229 assertEquals("JBSWY3DPEHPK3PXP", back.totp)230 assertEquals("a note", back.notes)231 assertEquals(2, back.breach)232 assertEquals(listOf("work", "email"), back.tags)233 }234235 @Test fun unicode_and_emoji_fields_preserved() = runBlocking {236 val storage = Storage()237 val r = VaultRepository(storage)238 r.createVault("correct horse battery")239 val e = r.addEntry(240 login("日本語のタイトル", password = "🔑🦆-pässwörd-Ω").copy(notes = "メモ 🎴"),241 )242 r.lock(); r.unlock("correct horse battery")243 val back = r.getEntry(e.id)!!244 assertEquals("日本語のタイトル", back.title)245 assertEquals("🔑🦆-pässwörd-Ω", back.password)246 assertEquals("メモ 🎴", back.notes)247 }248249 @Test fun card_entry_derives_last4() = runBlocking {250 val r = repo()251 val card = VaultEntry(252 id = "", type = "card", title = "Visa", cardNumber = "4242424242424242",253 cardholderName = "J Doe", createdAt = 0, modifiedAt = 0,254 )255 val saved = r.addEntry(card)256 assertEquals("4242", saved.last4)257 }258259 @Test fun non_card_entry_keeps_null_last4() = runBlocking {260 val r = repo()261 val saved = r.addEntry(login("L"))262 assertNull(saved.last4)263 }264265 @Test fun restore_only_works_on_deleted_entry() = runBlocking {266 val r = repo()267 val e = r.addEntry(login("A"))268 try {269 r.restoreEntry(e.id) 270 error("expected throw")271 } catch (_: IllegalStateException) { }272 }273274 @Test fun purge_deleted_entries_returns_zero_when_trash_empty() = runBlocking {275 val r = repo()276 r.addEntry(login("Live"))277 assertEquals(0, r.purgeDeletedEntries())278 }279280 281282 @Test fun distinct_field_values_sorted_by_frequency() = runBlocking {283 val r = repo()284 r.addEntry(login("a").copy(username = "popular"))285 r.addEntry(login("b").copy(username = "popular"))286 r.addEntry(login("c").copy(username = "rare"))287 assertEquals(listOf("popular", "rare"), r.getDistinctFieldValues("username"))288 }289290 @Test fun distinct_field_values_dedup_case_insensitive_keeps_first_casing() = runBlocking {291 val r = repo()292 r.addEntry(login("a").copy(username = "June"))293 r.addEntry(login("b").copy(username = "june"))294 assertEquals(listOf("June"), r.getDistinctFieldValues("username"))295 }296297 @Test fun distinct_field_values_email() = runBlocking {298 val r = repo()299 r.addEntry(login("a").copy(email = "[email protected]"))300 assertEquals(listOf("[email protected]"), r.getDistinctFieldValues("email"))301 }302303 @Test fun distinct_field_values_unknown_field_is_empty() = runBlocking {304 val r = repo()305 r.addEntry(login("a").copy(username = "x"))306 assertTrue(r.getDistinctFieldValues("nope").isEmpty())307 }308309 @Test fun distinct_field_values_excludes_deleted() = runBlocking {310 val r = repo()311 val gone = r.addEntry(login("a").copy(username = "ghost"))312 r.deleteEntry(gone.id)313 assertTrue(r.getDistinctFieldValues("username").isEmpty())314 }315316 317318 @Test fun concurrent_adds_do_not_corrupt() = runBlocking {319 val r = repo()320 val results = (0 until 50).map { i -> async { r.addEntry(login("C$i")) } }.awaitAll()321 assertEquals(50, r.getEntries().size)322 assertEquals(50, results.map { it.id }.toSet().size) 323 }324325 @Test fun concurrent_add_and_delete_are_consistent() = runBlocking {326 val r = repo()327 val seed = (0 until 20).map { r.addEntry(login("S$it")) }328 val jobs = buildList {329 seed.take(10).forEach { e -> add(async { r.deleteEntry(e.id) }) }330 repeat(10) { i -> add(async { r.addEntry(login("N$i")) }) }331 }332 jobs.awaitAll()333 334 assertEquals(20, r.getEntries().size)335 assertEquals(10, r.getTombstones().size)336 }337338 339340 @Test fun local_mutations_bump_version_sync_apply_does_not() = runBlocking {341 val r = repo()342 val v0 = r.getVaultVersion()343 r.addEntry(login("Local"))344 val v1 = r.getVaultVersion()345 assertTrue(v1 > v0)346 val remote = VaultEntry(id = "rem", title = "R", type = "login", password = "x",347 createdAt = 1, modifiedAt = System.currentTimeMillis() + 10_000)348 r.applyMergedRemoteEntries(listOf(remote), emptyList())349 assertEquals(v1, r.getVaultVersion())350 }351352 @Test fun sync_key_and_vault_id_available_only_when_unlocked() = runBlocking {353 val r = repo()354 assertNotNull(r.syncMasterKey())355 assertNotNull(r.syncVaultId())356 r.lock()357 assertNull(r.syncMasterKey())358 assertNull(r.syncVaultId())359 }360}