VaultRepositoryExtraTest.kt

11.3 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() { 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    // ── 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    }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    @Test fun create_then_destroy_then_create_fresh() = runBlocking {81        val storage = Storage()82        val r = VaultRepository(storage)83        r.createVault("first-pass-here")84        r.addEntry(login("Gone"))85        r.destroyVault()86        assertFalse(r.hasVault())87        assertEquals(CreateVaultResult.Success, r.createVault("second-pass-here"))88        assertEquals(0, r.getEntries().size)89    }9091    // ── Entry CRUD ──────────────────────────────────────────────────────────9293    @Test fun update_nonexistent_entry_throws() = runBlocking {94        val r = repo()95        try {96            r.updateEntry(login("ghost").copy(id = "missing"))97            error("expected throw")98        } catch (e: IllegalStateException) {99            assertEquals("entry_not_found", e.message)100        }101    }102103    @Test fun delete_nonexistent_entry_throws() = runBlocking {104        val r = repo()105        try {106            r.deleteEntry("missing")107            error("expected throw")108        } catch (_: IllegalStateException) { /* expected */ }109    }110111    @Test fun get_entry_returns_null_for_deleted() = runBlocking {112        val r = repo()113        val e = r.addEntry(login("D"))114        r.deleteEntry(e.id)115        assertNull(r.getEntry(e.id))116    }117118    @Test fun hundred_entries_all_present() = runBlocking {119        val r = repo()120        repeat(100) { r.addEntry(login("E$it")) }121        assertEquals(100, r.getEntries().size)122    }123124    @Test fun all_fields_survive_lock_unlock() = runBlocking {125        val storage = Storage()126        val r = VaultRepository(storage)127        r.createVault("correct horse battery")128        val full = VaultEntry(129            id = "", type = "login", title = "Full",130            username = "june", email = "[email protected]", password = "s3cret",131            url = "https://example.com", additionalUrls = listOf("https://alt.example.com"),132            totp = "JBSWY3DPEHPK3PXP", notes = "a note", breach = 2,133            tags = listOf("work", "email"), createdAt = 0, modifiedAt = 0,134        )135        val saved = r.addEntry(full)136        r.lock(); r.unlock("correct horse battery")137        val back = r.getEntry(saved.id)!!138        assertEquals("june", back.username)139        assertEquals("[email protected]", back.email)140        assertEquals("https://example.com", back.url)141        assertEquals(listOf("https://alt.example.com"), back.additionalUrls)142        assertEquals("JBSWY3DPEHPK3PXP", back.totp)143        assertEquals("a note", back.notes)144        assertEquals(2, back.breach)145        assertEquals(listOf("work", "email"), back.tags)146    }147148    @Test fun unicode_and_emoji_fields_preserved() = runBlocking {149        val storage = Storage()150        val r = VaultRepository(storage)151        r.createVault("correct horse battery")152        val e = r.addEntry(153            login("日本語のタイトル", password = "🔑🦆-pässwörd-Ω").copy(notes = "メモ 🎴"),154        )155        r.lock(); r.unlock("correct horse battery")156        val back = r.getEntry(e.id)!!157        assertEquals("日本語のタイトル", back.title)158        assertEquals("🔑🦆-pässwörd-Ω", back.password)159        assertEquals("メモ 🎴", back.notes)160    }161162    @Test fun card_entry_derives_last4() = runBlocking {163        val r = repo()164        val card = VaultEntry(165            id = "", type = "card", title = "Visa", cardNumber = "4242424242424242",166            cardholderName = "J Doe", createdAt = 0, modifiedAt = 0,167        )168        val saved = r.addEntry(card)169        assertEquals("4242", saved.last4)170    }171172    @Test fun non_card_entry_keeps_null_last4() = runBlocking {173        val r = repo()174        val saved = r.addEntry(login("L"))175        assertNull(saved.last4)176    }177178    @Test fun restore_only_works_on_deleted_entry() = runBlocking {179        val r = repo()180        val e = r.addEntry(login("A"))181        try {182            r.restoreEntry(e.id) // not deleted183            error("expected throw")184        } catch (_: IllegalStateException) { /* expected */ }185    }186187    @Test fun purge_deleted_entries_returns_zero_when_trash_empty() = runBlocking {188        val r = repo()189        r.addEntry(login("Live"))190        assertEquals(0, r.purgeDeletedEntries())191    }192193    // ── Search / distinct field values ────────────────────────────────────────194195    @Test fun distinct_field_values_sorted_by_frequency() = runBlocking {196        val r = repo()197        r.addEntry(login("a").copy(username = "popular"))198        r.addEntry(login("b").copy(username = "popular"))199        r.addEntry(login("c").copy(username = "rare"))200        assertEquals(listOf("popular", "rare"), r.getDistinctFieldValues("username"))201    }202203    @Test fun distinct_field_values_dedup_case_insensitive_keeps_first_casing() = runBlocking {204        val r = repo()205        r.addEntry(login("a").copy(username = "June"))206        r.addEntry(login("b").copy(username = "june"))207        assertEquals(listOf("June"), r.getDistinctFieldValues("username"))208    }209210    @Test fun distinct_field_values_email() = runBlocking {211        val r = repo()212        r.addEntry(login("a").copy(email = "[email protected]"))213        assertEquals(listOf("[email protected]"), r.getDistinctFieldValues("email"))214    }215216    @Test fun distinct_field_values_unknown_field_is_empty() = runBlocking {217        val r = repo()218        r.addEntry(login("a").copy(username = "x"))219        assertTrue(r.getDistinctFieldValues("nope").isEmpty())220    }221222    @Test fun distinct_field_values_excludes_deleted() = runBlocking {223        val r = repo()224        val gone = r.addEntry(login("a").copy(username = "ghost"))225        r.deleteEntry(gone.id)226        assertTrue(r.getDistinctFieldValues("username").isEmpty())227    }228229    // ── Concurrency ───────────────────────────────────────────────────────────230231    @Test fun concurrent_adds_do_not_corrupt() = runBlocking {232        val r = repo()233        val results = (0 until 50).map { i -> async { r.addEntry(login("C$i")) } }.awaitAll()234        assertEquals(50, r.getEntries().size)235        assertEquals(50, results.map { it.id }.toSet().size) // all ids unique236    }237238    @Test fun concurrent_add_and_delete_are_consistent() = runBlocking {239        val r = repo()240        val seed = (0 until 20).map { r.addEntry(login("S$it")) }241        val jobs = buildList {242            seed.take(10).forEach { e -> add(async { r.deleteEntry(e.id) }) }243            repeat(10) { i -> add(async { r.addEntry(login("N$i")) }) }244        }245        jobs.awaitAll()246        // 20 seeded - 10 deleted + 10 added = 20 active, 10 tombstones.247        assertEquals(20, r.getEntries().size)248        assertEquals(10, r.getTombstones().size)249    }250251    // ── Version counter ───────────────────────────────────────────────────────252253    @Test fun local_mutations_bump_version_sync_apply_does_not() = runBlocking {254        val r = repo()255        val v0 = r.getVaultVersion()256        r.addEntry(login("Local"))257        val v1 = r.getVaultVersion()258        assertTrue(v1 > v0)259        val remote = VaultEntry(id = "rem", title = "R", type = "login", password = "x",260            createdAt = 1, modifiedAt = System.currentTimeMillis() + 10_000)261        r.applyMergedRemoteEntries(listOf(remote), emptyList())262        assertEquals(v1, r.getVaultVersion())263    }264265    @Test fun sync_key_and_vault_id_available_only_when_unlocked() = runBlocking {266        val r = repo()267        assertNotNull(r.syncMasterKey())268        assertNotNull(r.syncVaultId())269        r.lock()270        assertNull(r.syncMasterKey())271        assertNull(r.syncVaultId())272    }273}