package com.peachpasswords.vault import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.runBlocking import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test /** * Second VaultRepository suite (brief Suite 1 expansion). Covers the lifecycle, * CRUD, Unicode/emoji fidelity, search frequency sort, large-vault, and * concurrency behaviors not already exercised by [VaultRepositoryTest]. */ class VaultRepositoryExtraTest { private class Storage : VaultStorage { var slotA: StoredVault? = null var slotB: String? = null private var ver: Long = 0 private var settings = VaultSettings() override fun loadSlotA() = slotA override fun hasVault() = slotA != null override fun writeSlotA(slot: StoredVault): Boolean { slotA = slot; return true } override fun writeSlotB(blobBase64: String): Boolean { slotB = blobBase64; return true } var decoy: StoredVault? = null override fun loadDecoySlot() = decoy override fun writeDecoySlot(slot: StoredVault): Boolean { decoy = slot; return true } override fun hasDecoySlot() = decoy != null override fun clearDecoySlot() { decoy = null } override fun version() = ver override fun stampLocalMutation(): Long { ver = maxOf(System.currentTimeMillis(), ver + 1); return ver } override fun loadSettings() = settings override fun writeSettings(s: VaultSettings): Boolean { settings = s; return true } override fun clearVault(): Boolean { slotA = null; slotB = null; ver = 0; settings = VaultSettings(); return true } } private fun repo() = VaultRepository(Storage()).also { runBlocking { it.createVault("correct horse battery") } } private fun login(title: String, password: String = "pw") = VaultEntry(id = "", title = title, type = "login", password = password, createdAt = 0, modifiedAt = 0) // ── Lifecycle ───────────────────────────────────────────────────────────── @Test fun unlock_with_wrong_password_five_times_does_not_crash_or_corrupt() = runBlocking { val r = repo() r.addEntry(login("Acct")) r.lock() repeat(5) { assertEquals(UnlockResult.WrongPassword, r.unlock("nope$it")) } assertEquals(UnlockResult.Success, r.unlock("correct horse battery")) assertEquals(1, r.getEntries().size) } @Test fun biometric_key_unlock_round_trips() = runBlocking { val r = repo() r.addEntry(login("Bio")) val key = r.currentVaultKey()!! r.lock() assertEquals(UnlockResult.Success, r.unlockWithKey(key)) assertEquals(1, r.getEntries().size) } @Test fun biometric_unlock_with_wrong_key_fails() = runBlocking { val r = repo() r.lock() assertEquals(UnlockResult.WrongPassword, r.unlockWithKey(ByteArray(32) { 9 })) assertFalse("failed staged biometric decode leaves no live session", r.isUnlocked.value) assertTrue("failed staged biometric decode exposes no entries", r.getEntries().isEmpty()) assertNull("failed staged biometric decode exposes no key", r.currentVaultKey()) } @Test fun codex_blob_restores_on_another_repo() = runBlocking { val src = repo() src.addEntry(login("Portable")) val blob = src.codexVaultBlobBase64()!! val dst = VaultRepository(Storage()) assertEquals(UnlockResult.Success, dst.restoreFromCodex(blob, "correct horse battery")) assertTrue(dst.getEntries().any { it.title == "Portable" }) } /** * The browser writes each slot as a JSON *string* and includes "b"; Android * wrote slot "a" as an inline object with nothing after it. Restore used to * pull "a" out with a regex that assumed Android's shape, so every real * browser-generated Codex died with "invalid_codex_blob" after decoding * perfectly. Both shapes must restore. */ @Test fun codex_blob_restores_from_browser_shaped_wrapper() = runBlocking { val src = repo() src.addEntry(login("Portable")) val androidBlob = src.codexVaultBlobBase64()!! val wrapper = String(java.util.Base64.getUrlDecoder().decode(androidBlob), Charsets.UTF_8) // H-04: codex now carries "a" and "b" as two siblings. Match the first // balanced `{…}` after `"a":` — earlier regex captured through the // wrapper's last brace and broke when the duress slot was added. val startMarker = "\"a\"\\s*:\\s*\\{".toRegex().find(wrapper)!!.range.last + 1 var depth = 1 var end = startMarker for (i in startMarker until wrapper.length) { when (wrapper[i]) { '{' -> depth++ '}' -> { depth--; if (depth == 0) { end = i; break } } } } val slotA = wrapper.substring(startMarker - 1, end + 1) // Slot as a JSON string, plus a "b" key after it — the browser's shape. val browserWrapper = kotlinx.serialization.json.buildJsonObject { put("a", kotlinx.serialization.json.JsonPrimitive(slotA)) put("b", kotlinx.serialization.json.JsonNull) }.toString() val browserBlob = java.util.Base64.getUrlEncoder().withoutPadding() .encodeToString(browserWrapper.toByteArray(Charsets.UTF_8)) val dst = VaultRepository(Storage()) assertEquals(UnlockResult.Success, dst.restoreFromCodex(browserBlob, "correct horse battery")) assertTrue(dst.getEntries().any { it.title == "Portable" }) } @Test fun fresh_vault_yields_twelve_recovery_words() = runBlocking { val r = repo() val key = r.syncMasterKey() assertTrue("syncMasterKey null right after createVault", key != null) val words = com.peachpasswords.vault.rootKeyToWords(key!!) println("WORDS=${words.size} ${words.take(3)}") assertEquals(12, words.size) } /** * A real vault is ~800 entries. Looping addEntry re-encrypts and rewrites * the whole vault per entry, which is quadratic and hung the app hard * enough to ANR on import. Batched insert must do one write. */ @Test fun batched_import_of_a_real_sized_vault_is_not_quadratic() = runBlocking { val n = 806 val drafts = (1..n).map { login("Entry $it") } val batched = repo() val tBatch = System.currentTimeMillis().let { start -> assertEquals(n, batched.addEntries(drafts)) System.currentTimeMillis() - start } assertEquals(n, batched.getEntries().size) val looped = repo() val tLoop = System.currentTimeMillis().let { start -> drafts.forEach { looped.addEntry(it.copy(id = "")) } System.currentTimeMillis() - start } println("IMPORT n=$n batched=${tBatch}ms looped=${tLoop}ms") assertTrue("batched ($tBatch ms) should beat looped ($tLoop ms)", tBatch < tLoop) } @Test fun adopted_vault_preserves_source_root_key_vault_id() = runBlocking { val rootKeyHex = "66".repeat(32) val rootKey = hexToRootKey(rootKeyHex) val expectedVaultId = try { com.peachpasswords.sync.computeVaultId(rootKey) } finally { VaultCrypto.zero(rootKey) } val r = VaultRepository(Storage()) val result = r.createVaultFromRootKey("receiver-local-passphrase-456", rootKeyHex) assertEquals(AdoptVaultResult.Success(expectedVaultId), result) assertTrue(r.isUnlocked.value) assertEquals(expectedVaultId, r.syncVaultId()) } @Test fun adopted_vault_supports_writes_without_rotating_vault_id() = runBlocking { val rootKeyHex = "33".repeat(32) val r = VaultRepository(Storage()) val adopted = r.createVaultFromRootKey("receiver-local-passphrase-456", rootKeyHex) val vaultId = (adopted as AdoptVaultResult.Success).vaultId r.addEntry(login("Example", password = "secret")) assertTrue(r.getEntries().any { it.title == "Example" }) assertEquals(vaultId, r.syncVaultId()) } @Test fun adopted_vault_rejects_invalid_root_key_input() = runBlocking { val r = VaultRepository(Storage()) listOf("a".repeat(63), "a".repeat(65), "A".repeat(64), "g".repeat(64), "").forEach { rootKeyHex -> val result = r.createVaultFromRootKey("receiver-local-passphrase-456", rootKeyHex) assertTrue(result is AdoptVaultResult.Error) assertTrue((result as AdoptVaultResult.Error).reason.contains("ADOPT_INVALID_ROOT_KEY")) } assertFalse(r.hasVault()) assertFalse(r.isUnlocked.value) } @Test fun adopted_vault_rejects_empty_receiver_password() = runBlocking { val r = VaultRepository(Storage()) assertEquals( AdoptVaultResult.Error("ADOPT_INVALID_PASSWORD"), r.createVaultFromRootKey("", "44".repeat(32)), ) assertFalse(r.hasVault()) } @Test fun adopted_vault_refuses_to_overwrite_existing_local_vault() = runBlocking { val r = repo() r.addEntry(login("Must survive", password = "local-only")) val originalVaultId = r.syncVaultId() assertEquals( AdoptVaultResult.Error("ADOPT_VAULT_ALREADY_EXISTS"), r.createVaultFromRootKey( "receiver-local-passphrase-456", "55".repeat(32), ), ) assertEquals(originalVaultId, r.syncVaultId()) assertTrue(r.getEntries().any { it.title == "Must survive" }) } @Test fun create_then_destroy_then_create_fresh() = runBlocking { val storage = Storage() val r = VaultRepository(storage) r.createVault("first-pass-here") r.addEntry(login("Gone")) r.destroyVault() assertFalse(r.hasVault()) assertEquals(CreateVaultResult.Success, r.createVault("second-pass-here")) assertEquals(0, r.getEntries().size) } // ── Entry CRUD ────────────────────────────────────────────────────────── @Test fun update_nonexistent_entry_throws() = runBlocking { val r = repo() try { r.updateEntry(login("ghost").copy(id = "missing")) error("expected throw") } catch (e: IllegalStateException) { assertEquals("entry_not_found", e.message) } } @Test fun delete_nonexistent_entry_throws() = runBlocking { val r = repo() try { r.deleteEntry("missing") error("expected throw") } catch (_: IllegalStateException) { /* expected */ } } @Test fun get_entry_returns_null_for_deleted() = runBlocking { val r = repo() val e = r.addEntry(login("D")) r.deleteEntry(e.id) assertNull(r.getEntry(e.id)) } @Test fun hundred_entries_all_present() = runBlocking { val r = repo() repeat(100) { r.addEntry(login("E$it")) } assertEquals(100, r.getEntries().size) } @Test fun all_fields_survive_lock_unlock() = runBlocking { val storage = Storage() val r = VaultRepository(storage) r.createVault("correct horse battery") val full = VaultEntry( id = "", type = "login", title = "Full", username = "june", email = "june@example.com", password = "s3cret", url = "https://example.com", additionalUrls = listOf("https://alt.example.com"), totp = "JBSWY3DPEHPK3PXP", notes = "a note", breach = 2, tags = listOf("work", "email"), createdAt = 0, modifiedAt = 0, ) val saved = r.addEntry(full) r.lock(); r.unlock("correct horse battery") val back = r.getEntry(saved.id)!! assertEquals("june", back.username) assertEquals("june@example.com", back.email) assertEquals("https://example.com", back.url) assertEquals(listOf("https://alt.example.com"), back.additionalUrls) assertEquals("JBSWY3DPEHPK3PXP", back.totp) assertEquals("a note", back.notes) assertEquals(2, back.breach) assertEquals(listOf("work", "email"), back.tags) } @Test fun unicode_and_emoji_fields_preserved() = runBlocking { val storage = Storage() val r = VaultRepository(storage) r.createVault("correct horse battery") val e = r.addEntry( login("日本語のタイトル", password = "🔑🦆-pässwörd-Ω").copy(notes = "メモ 🎴"), ) r.lock(); r.unlock("correct horse battery") val back = r.getEntry(e.id)!! assertEquals("日本語のタイトル", back.title) assertEquals("🔑🦆-pässwörd-Ω", back.password) assertEquals("メモ 🎴", back.notes) } @Test fun card_entry_derives_last4() = runBlocking { val r = repo() val card = VaultEntry( id = "", type = "card", title = "Visa", cardNumber = "4242424242424242", cardholderName = "J Doe", createdAt = 0, modifiedAt = 0, ) val saved = r.addEntry(card) assertEquals("4242", saved.last4) } @Test fun non_card_entry_keeps_null_last4() = runBlocking { val r = repo() val saved = r.addEntry(login("L")) assertNull(saved.last4) } @Test fun restore_only_works_on_deleted_entry() = runBlocking { val r = repo() val e = r.addEntry(login("A")) try { r.restoreEntry(e.id) // not deleted error("expected throw") } catch (_: IllegalStateException) { /* expected */ } } @Test fun purge_deleted_entries_returns_zero_when_trash_empty() = runBlocking { val r = repo() r.addEntry(login("Live")) assertEquals(0, r.purgeDeletedEntries()) } // ── Search / distinct field values ──────────────────────────────────────── @Test fun distinct_field_values_sorted_by_frequency() = runBlocking { val r = repo() r.addEntry(login("a").copy(username = "popular")) r.addEntry(login("b").copy(username = "popular")) r.addEntry(login("c").copy(username = "rare")) assertEquals(listOf("popular", "rare"), r.getDistinctFieldValues("username")) } @Test fun distinct_field_values_dedup_case_insensitive_keeps_first_casing() = runBlocking { val r = repo() r.addEntry(login("a").copy(username = "June")) r.addEntry(login("b").copy(username = "june")) assertEquals(listOf("June"), r.getDistinctFieldValues("username")) } @Test fun distinct_field_values_email() = runBlocking { val r = repo() r.addEntry(login("a").copy(email = "x@example.com")) assertEquals(listOf("x@example.com"), r.getDistinctFieldValues("email")) } @Test fun distinct_field_values_unknown_field_is_empty() = runBlocking { val r = repo() r.addEntry(login("a").copy(username = "x")) assertTrue(r.getDistinctFieldValues("nope").isEmpty()) } @Test fun distinct_field_values_excludes_deleted() = runBlocking { val r = repo() val gone = r.addEntry(login("a").copy(username = "ghost")) r.deleteEntry(gone.id) assertTrue(r.getDistinctFieldValues("username").isEmpty()) } // ── Concurrency ─────────────────────────────────────────────────────────── @Test fun concurrent_adds_do_not_corrupt() = runBlocking { val r = repo() val results = (0 until 50).map { i -> async { r.addEntry(login("C$i")) } }.awaitAll() assertEquals(50, r.getEntries().size) assertEquals(50, results.map { it.id }.toSet().size) // all ids unique } @Test fun concurrent_add_and_delete_are_consistent() = runBlocking { val r = repo() val seed = (0 until 20).map { r.addEntry(login("S$it")) } val jobs = buildList { seed.take(10).forEach { e -> add(async { r.deleteEntry(e.id) }) } repeat(10) { i -> add(async { r.addEntry(login("N$i")) }) } } jobs.awaitAll() // 20 seeded - 10 deleted + 10 added = 20 active, 10 tombstones. assertEquals(20, r.getEntries().size) assertEquals(10, r.getTombstones().size) } // ── Version counter ─────────────────────────────────────────────────────── @Test fun local_mutations_bump_version_sync_apply_does_not() = runBlocking { val r = repo() val v0 = r.getVaultVersion() r.addEntry(login("Local")) val v1 = r.getVaultVersion() assertTrue(v1 > v0) val remote = VaultEntry(id = "rem", title = "R", type = "login", password = "x", createdAt = 1, modifiedAt = System.currentTimeMillis() + 10_000) r.applyMergedRemoteEntries(listOf(remote), emptyList()) assertEquals(v1, r.getVaultVersion()) } @Test fun sync_key_and_vault_id_available_only_when_unlocked() = runBlocking { val r = repo() assertNotNull(r.syncMasterKey()) assertNotNull(r.syncVaultId()) r.lock() assertNull(r.syncMasterKey()) assertNull(r.syncVaultId()) } }