VaultRepositoryExtraTest.kt

17.5 KB · raw

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.Test1213/**14 * Second VaultRepository suite (brief Suite 1 expansion). Covers the lifecycle,15 * CRUD, Unicode/emoji fidelity, search frequency sort, large-vault, and16 * concurrency behaviors not already exercised by [VaultRepositoryTest].17 */18class 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(): Boolean { slotA = null; slotB = null; ver = 0; settings = VaultSettings(); return true }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    // ── Lifecycle ─────────────────────────────────────────────────────────────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        assertFalse("failed staged biometric decode leaves no live session", r.isUnlocked.value)70        assertTrue("failed staged biometric decode exposes no entries", r.getEntries().isEmpty())71        assertNull("failed staged biometric decode exposes no key", r.currentVaultKey())72    }7374    @Test fun codex_blob_restores_on_another_repo() = runBlocking {75        val src = repo()76        src.addEntry(login("Portable"))77        val blob = src.codexVaultBlobBase64()!!78        val dst = VaultRepository(Storage())79        assertEquals(UnlockResult.Success, dst.restoreFromCodex(blob, "correct horse battery"))80        assertTrue(dst.getEntries().any { it.title == "Portable" })81    }8283    /**84     * The browser writes each slot as a JSON *string* and includes "b"; Android85     * wrote slot "a" as an inline object with nothing after it. Restore used to86     * pull "a" out with a regex that assumed Android's shape, so every real87     * browser-generated Codex died with "invalid_codex_blob" after decoding88     * perfectly. Both shapes must restore.89     */90    @Test fun codex_blob_restores_from_browser_shaped_wrapper() = runBlocking {91        val src = repo()92        src.addEntry(login("Portable"))93        val androidBlob = src.codexVaultBlobBase64()!!94        val wrapper = String(java.util.Base64.getUrlDecoder().decode(androidBlob), Charsets.UTF_8)95        // H-04: codex now carries "a" and "b" as two siblings. Match the first96        // balanced `{…}` after `"a":` — earlier regex captured through the97        // wrapper's last brace and broke when the duress slot was added.98        val startMarker = "\"a\"\\s*:\\s*\\{".toRegex().find(wrapper)!!.range.last + 199        var depth = 1100        var end = startMarker101        for (i in startMarker until wrapper.length) {102            when (wrapper[i]) {103                '{' -> depth++104                '}' -> { depth--; if (depth == 0) { end = i; break } }105            }106        }107        val slotA = wrapper.substring(startMarker - 1, end + 1)108109        // Slot as a JSON string, plus a "b" key after it — the browser's shape.110        val browserWrapper = kotlinx.serialization.json.buildJsonObject {111            put("a", kotlinx.serialization.json.JsonPrimitive(slotA))112            put("b", kotlinx.serialization.json.JsonNull)113        }.toString()114        val browserBlob = java.util.Base64.getUrlEncoder().withoutPadding()115            .encodeToString(browserWrapper.toByteArray(Charsets.UTF_8))116117        val dst = VaultRepository(Storage())118        assertEquals(UnlockResult.Success, dst.restoreFromCodex(browserBlob, "correct horse battery"))119        assertTrue(dst.getEntries().any { it.title == "Portable" })120    }121122    @Test fun fresh_vault_yields_twelve_recovery_words() = runBlocking {123        val r = repo()124        val key = r.syncMasterKey()125        assertTrue("syncMasterKey null right after createVault", key != null)126        val words = com.peachpasswords.vault.rootKeyToWords(key!!)127        println("WORDS=${words.size} ${words.take(3)}")128        assertEquals(12, words.size)129    }130131    /**132     * A real vault is ~800 entries. Looping addEntry re-encrypts and rewrites133     * the whole vault per entry, which is quadratic and hung the app hard134     * enough to ANR on import. Batched insert must do one write.135     */136    @Test fun batched_import_of_a_real_sized_vault_is_not_quadratic() = runBlocking {137        val n = 806138        val drafts = (1..n).map { login("Entry $it") }139140        val batched = repo()141        val tBatch = System.currentTimeMillis().let { start ->142            assertEquals(n, batched.addEntries(drafts))143            System.currentTimeMillis() - start144        }145        assertEquals(n, batched.getEntries().size)146147        val looped = repo()148        val tLoop = System.currentTimeMillis().let { start ->149            drafts.forEach { looped.addEntry(it.copy(id = "")) }150            System.currentTimeMillis() - start151        }152        println("IMPORT n=$n batched=${tBatch}ms looped=${tLoop}ms")153        assertTrue("batched ($tBatch ms) should beat looped ($tLoop ms)", tBatch < tLoop)154    }155156    @Test fun adopted_vault_preserves_source_root_key_vault_id() = runBlocking {157        val rootKeyHex = "66".repeat(32)158        val rootKey = hexToRootKey(rootKeyHex)159        val expectedVaultId = try {160            com.peachpasswords.sync.computeVaultId(rootKey)161        } finally {162            VaultCrypto.zero(rootKey)163        }164        val r = VaultRepository(Storage())165166        val result = r.createVaultFromRootKey("receiver-local-passphrase-456", rootKeyHex)167168        assertEquals(AdoptVaultResult.Success(expectedVaultId), result)169        assertTrue(r.isUnlocked.value)170        assertEquals(expectedVaultId, r.syncVaultId())171    }172173    @Test fun adopted_vault_supports_writes_without_rotating_vault_id() = runBlocking {174        val rootKeyHex = "33".repeat(32)175        val r = VaultRepository(Storage())176        val adopted = r.createVaultFromRootKey("receiver-local-passphrase-456", rootKeyHex)177        val vaultId = (adopted as AdoptVaultResult.Success).vaultId178179        r.addEntry(login("Example", password = "secret"))180181        assertTrue(r.getEntries().any { it.title == "Example" })182        assertEquals(vaultId, r.syncVaultId())183    }184185    @Test fun adopted_vault_rejects_invalid_root_key_input() = runBlocking {186        val r = VaultRepository(Storage())187188        listOf("a".repeat(63), "a".repeat(65), "A".repeat(64), "g".repeat(64), "").forEach { rootKeyHex ->189            val result = r.createVaultFromRootKey("receiver-local-passphrase-456", rootKeyHex)190            assertTrue(result is AdoptVaultResult.Error)191            assertTrue((result as AdoptVaultResult.Error).reason.contains("ADOPT_INVALID_ROOT_KEY"))192        }193        assertFalse(r.hasVault())194        assertFalse(r.isUnlocked.value)195    }196197    @Test fun adopted_vault_rejects_empty_receiver_password() = runBlocking {198        val r = VaultRepository(Storage())199200        assertEquals(201            AdoptVaultResult.Error("ADOPT_INVALID_PASSWORD"),202            r.createVaultFromRootKey("", "44".repeat(32)),203        )204        assertFalse(r.hasVault())205    }206207    @Test fun adopted_vault_refuses_to_overwrite_existing_local_vault() = runBlocking {208        val r = repo()209        r.addEntry(login("Must survive", password = "local-only"))210        val originalVaultId = r.syncVaultId()211212        assertEquals(213            AdoptVaultResult.Error("ADOPT_VAULT_ALREADY_EXISTS"),214            r.createVaultFromRootKey(215                "receiver-local-passphrase-456",216                "55".repeat(32),217            ),218        )219220        assertEquals(originalVaultId, r.syncVaultId())221        assertTrue(r.getEntries().any { it.title == "Must survive" })222    }223224    @Test fun create_then_destroy_then_create_fresh() = runBlocking {225        val storage = Storage()226        val r = VaultRepository(storage)227        r.createVault("first-pass-here")228        r.addEntry(login("Gone"))229        r.destroyVault()230        assertFalse(r.hasVault())231        assertEquals(CreateVaultResult.Success, r.createVault("second-pass-here"))232        assertEquals(0, r.getEntries().size)233    }234235    // ── Entry CRUD ──────────────────────────────────────────────────────────236237    @Test fun update_nonexistent_entry_throws() = runBlocking {238        val r = repo()239        try {240            r.updateEntry(login("ghost").copy(id = "missing"))241            error("expected throw")242        } catch (e: IllegalStateException) {243            assertEquals("entry_not_found", e.message)244        }245    }246247    @Test fun delete_nonexistent_entry_throws() = runBlocking {248        val r = repo()249        try {250            r.deleteEntry("missing")251            error("expected throw")252        } catch (_: IllegalStateException) { /* expected */ }253    }254255    @Test fun get_entry_returns_null_for_deleted() = runBlocking {256        val r = repo()257        val e = r.addEntry(login("D"))258        r.deleteEntry(e.id)259        assertNull(r.getEntry(e.id))260    }261262    @Test fun hundred_entries_all_present() = runBlocking {263        val r = repo()264        repeat(100) { r.addEntry(login("E$it")) }265        assertEquals(100, r.getEntries().size)266    }267268    @Test fun all_fields_survive_lock_unlock() = runBlocking {269        val storage = Storage()270        val r = VaultRepository(storage)271        r.createVault("correct horse battery")272        val full = VaultEntry(273            id = "", type = "login", title = "Full",274            username = "june", email = "[email protected]", password = "s3cret",275            url = "https://example.com", additionalUrls = listOf("https://alt.example.com"),276            totp = "JBSWY3DPEHPK3PXP", notes = "a note", breach = 2,277            tags = listOf("work", "email"), createdAt = 0, modifiedAt = 0,278        )279        val saved = r.addEntry(full)280        r.lock(); r.unlock("correct horse battery")281        val back = r.getEntry(saved.id)!!282        assertEquals("june", back.username)283        assertEquals("[email protected]", back.email)284        assertEquals("https://example.com", back.url)285        assertEquals(listOf("https://alt.example.com"), back.additionalUrls)286        assertEquals("JBSWY3DPEHPK3PXP", back.totp)287        assertEquals("a note", back.notes)288        assertEquals(2, back.breach)289        assertEquals(listOf("work", "email"), back.tags)290    }291292    @Test fun unicode_and_emoji_fields_preserved() = runBlocking {293        val storage = Storage()294        val r = VaultRepository(storage)295        r.createVault("correct horse battery")296        val e = r.addEntry(297            login("日本語のタイトル", password = "🔑🦆-pässwörd-Ω").copy(notes = "メモ 🎴"),298        )299        r.lock(); r.unlock("correct horse battery")300        val back = r.getEntry(e.id)!!301        assertEquals("日本語のタイトル", back.title)302        assertEquals("🔑🦆-pässwörd-Ω", back.password)303        assertEquals("メモ 🎴", back.notes)304    }305306    @Test fun card_entry_derives_last4() = runBlocking {307        val r = repo()308        val card = VaultEntry(309            id = "", type = "card", title = "Visa", cardNumber = "4242424242424242",310            cardholderName = "J Doe", createdAt = 0, modifiedAt = 0,311        )312        val saved = r.addEntry(card)313        assertEquals("4242", saved.last4)314    }315316    @Test fun non_card_entry_keeps_null_last4() = runBlocking {317        val r = repo()318        val saved = r.addEntry(login("L"))319        assertNull(saved.last4)320    }321322    @Test fun restore_only_works_on_deleted_entry() = runBlocking {323        val r = repo()324        val e = r.addEntry(login("A"))325        try {326            r.restoreEntry(e.id) // not deleted327            error("expected throw")328        } catch (_: IllegalStateException) { /* expected */ }329    }330331    @Test fun purge_deleted_entries_returns_zero_when_trash_empty() = runBlocking {332        val r = repo()333        r.addEntry(login("Live"))334        assertEquals(0, r.purgeDeletedEntries())335    }336337    // ── Search / distinct field values ────────────────────────────────────────338339    @Test fun distinct_field_values_sorted_by_frequency() = runBlocking {340        val r = repo()341        r.addEntry(login("a").copy(username = "popular"))342        r.addEntry(login("b").copy(username = "popular"))343        r.addEntry(login("c").copy(username = "rare"))344        assertEquals(listOf("popular", "rare"), r.getDistinctFieldValues("username"))345    }346347    @Test fun distinct_field_values_dedup_case_insensitive_keeps_first_casing() = runBlocking {348        val r = repo()349        r.addEntry(login("a").copy(username = "June"))350        r.addEntry(login("b").copy(username = "june"))351        assertEquals(listOf("June"), r.getDistinctFieldValues("username"))352    }353354    @Test fun distinct_field_values_email() = runBlocking {355        val r = repo()356        r.addEntry(login("a").copy(email = "[email protected]"))357        assertEquals(listOf("[email protected]"), r.getDistinctFieldValues("email"))358    }359360    @Test fun distinct_field_values_unknown_field_is_empty() = runBlocking {361        val r = repo()362        r.addEntry(login("a").copy(username = "x"))363        assertTrue(r.getDistinctFieldValues("nope").isEmpty())364    }365366    @Test fun distinct_field_values_excludes_deleted() = runBlocking {367        val r = repo()368        val gone = r.addEntry(login("a").copy(username = "ghost"))369        r.deleteEntry(gone.id)370        assertTrue(r.getDistinctFieldValues("username").isEmpty())371    }372373    // ── Concurrency ───────────────────────────────────────────────────────────374375    @Test fun concurrent_adds_do_not_corrupt() = runBlocking {376        val r = repo()377        val results = (0 until 50).map { i -> async { r.addEntry(login("C$i")) } }.awaitAll()378        assertEquals(50, r.getEntries().size)379        assertEquals(50, results.map { it.id }.toSet().size) // all ids unique380    }381382    @Test fun concurrent_add_and_delete_are_consistent() = runBlocking {383        val r = repo()384        val seed = (0 until 20).map { r.addEntry(login("S$it")) }385        val jobs = buildList {386            seed.take(10).forEach { e -> add(async { r.deleteEntry(e.id) }) }387            repeat(10) { i -> add(async { r.addEntry(login("N$i")) }) }388        }389        jobs.awaitAll()390        // 20 seeded - 10 deleted + 10 added = 20 active, 10 tombstones.391        assertEquals(20, r.getEntries().size)392        assertEquals(10, r.getTombstones().size)393    }394395    // ── Version counter ───────────────────────────────────────────────────────396397    @Test fun local_mutations_bump_version_sync_apply_does_not() = runBlocking {398        val r = repo()399        val v0 = r.getVaultVersion()400        r.addEntry(login("Local"))401        val v1 = r.getVaultVersion()402        assertTrue(v1 > v0)403        val remote = VaultEntry(id = "rem", title = "R", type = "login", password = "x",404            createdAt = 1, modifiedAt = System.currentTimeMillis() + 10_000)405        r.applyMergedRemoteEntries(listOf(remote), emptyList())406        assertEquals(v1, r.getVaultVersion())407    }408409    @Test fun sync_key_and_vault_id_available_only_when_unlocked() = runBlocking {410        val r = repo()411        assertNotNull(r.syncMasterKey())412        assertNotNull(r.syncVaultId())413        r.lock()414        assertNull(r.syncMasterKey())415        assertNull(r.syncVaultId())416    }417}