VaultRepositoryExtraTest.kt

13.2 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 adopted_vault_preserves_source_root_key_vault_id() = runBlocking {81        val rootKeyHex = "66".repeat(32)82        val rootKey = hexToRootKey(rootKeyHex)83        val expectedVaultId = try {84            com.peachpasswords.sync.computeVaultId(rootKey)85        } finally {86            VaultCrypto.zero(rootKey)87        }88        val r = VaultRepository(Storage())8990        val result = r.createVaultFromRootKey("receiver-local-passphrase-456", rootKeyHex)9192        assertEquals(AdoptVaultResult.Success(expectedVaultId), result)93        assertTrue(r.isUnlocked.value)94        assertEquals(expectedVaultId, r.syncVaultId())95    }9697    @Test fun adopted_vault_supports_writes_without_rotating_vault_id() = runBlocking {98        val rootKeyHex = "33".repeat(32)99        val r = VaultRepository(Storage())100        val adopted = r.createVaultFromRootKey("receiver-local-passphrase-456", rootKeyHex)101        val vaultId = (adopted as AdoptVaultResult.Success).vaultId102103        r.addEntry(login("Example", password = "secret"))104105        assertTrue(r.getEntries().any { it.title == "Example" })106        assertEquals(vaultId, r.syncVaultId())107    }108109    @Test fun adopted_vault_rejects_invalid_root_key_input() = runBlocking {110        val r = VaultRepository(Storage())111112        listOf("a".repeat(63), "a".repeat(65), "A".repeat(64), "g".repeat(64), "").forEach { rootKeyHex ->113            val result = r.createVaultFromRootKey("receiver-local-passphrase-456", rootKeyHex)114            assertTrue(result is AdoptVaultResult.Error)115            assertTrue((result as AdoptVaultResult.Error).reason.contains("ADOPT_INVALID_ROOT_KEY"))116        }117        assertFalse(r.hasVault())118        assertFalse(r.isUnlocked.value)119    }120121    @Test fun adopted_vault_rejects_empty_receiver_password() = runBlocking {122        val r = VaultRepository(Storage())123124        assertEquals(125            AdoptVaultResult.Error("ADOPT_INVALID_PASSWORD"),126            r.createVaultFromRootKey("", "44".repeat(32)),127        )128        assertFalse(r.hasVault())129    }130131    @Test fun create_then_destroy_then_create_fresh() = runBlocking {132        val storage = Storage()133        val r = VaultRepository(storage)134        r.createVault("first-pass-here")135        r.addEntry(login("Gone"))136        r.destroyVault()137        assertFalse(r.hasVault())138        assertEquals(CreateVaultResult.Success, r.createVault("second-pass-here"))139        assertEquals(0, r.getEntries().size)140    }141142    // ── Entry CRUD ──────────────────────────────────────────────────────────143144    @Test fun update_nonexistent_entry_throws() = runBlocking {145        val r = repo()146        try {147            r.updateEntry(login("ghost").copy(id = "missing"))148            error("expected throw")149        } catch (e: IllegalStateException) {150            assertEquals("entry_not_found", e.message)151        }152    }153154    @Test fun delete_nonexistent_entry_throws() = runBlocking {155        val r = repo()156        try {157            r.deleteEntry("missing")158            error("expected throw")159        } catch (_: IllegalStateException) { /* expected */ }160    }161162    @Test fun get_entry_returns_null_for_deleted() = runBlocking {163        val r = repo()164        val e = r.addEntry(login("D"))165        r.deleteEntry(e.id)166        assertNull(r.getEntry(e.id))167    }168169    @Test fun hundred_entries_all_present() = runBlocking {170        val r = repo()171        repeat(100) { r.addEntry(login("E$it")) }172        assertEquals(100, r.getEntries().size)173    }174175    @Test fun all_fields_survive_lock_unlock() = runBlocking {176        val storage = Storage()177        val r = VaultRepository(storage)178        r.createVault("correct horse battery")179        val full = VaultEntry(180            id = "", type = "login", title = "Full",181            username = "june", email = "[email protected]", password = "s3cret",182            url = "https://example.com", additionalUrls = listOf("https://alt.example.com"),183            totp = "JBSWY3DPEHPK3PXP", notes = "a note", breach = 2,184            tags = listOf("work", "email"), createdAt = 0, modifiedAt = 0,185        )186        val saved = r.addEntry(full)187        r.lock(); r.unlock("correct horse battery")188        val back = r.getEntry(saved.id)!!189        assertEquals("june", back.username)190        assertEquals("[email protected]", back.email)191        assertEquals("https://example.com", back.url)192        assertEquals(listOf("https://alt.example.com"), back.additionalUrls)193        assertEquals("JBSWY3DPEHPK3PXP", back.totp)194        assertEquals("a note", back.notes)195        assertEquals(2, back.breach)196        assertEquals(listOf("work", "email"), back.tags)197    }198199    @Test fun unicode_and_emoji_fields_preserved() = runBlocking {200        val storage = Storage()201        val r = VaultRepository(storage)202        r.createVault("correct horse battery")203        val e = r.addEntry(204            login("日本語のタイトル", password = "🔑🦆-pässwörd-Ω").copy(notes = "メモ 🎴"),205        )206        r.lock(); r.unlock("correct horse battery")207        val back = r.getEntry(e.id)!!208        assertEquals("日本語のタイトル", back.title)209        assertEquals("🔑🦆-pässwörd-Ω", back.password)210        assertEquals("メモ 🎴", back.notes)211    }212213    @Test fun card_entry_derives_last4() = runBlocking {214        val r = repo()215        val card = VaultEntry(216            id = "", type = "card", title = "Visa", cardNumber = "4242424242424242",217            cardholderName = "J Doe", createdAt = 0, modifiedAt = 0,218        )219        val saved = r.addEntry(card)220        assertEquals("4242", saved.last4)221    }222223    @Test fun non_card_entry_keeps_null_last4() = runBlocking {224        val r = repo()225        val saved = r.addEntry(login("L"))226        assertNull(saved.last4)227    }228229    @Test fun restore_only_works_on_deleted_entry() = runBlocking {230        val r = repo()231        val e = r.addEntry(login("A"))232        try {233            r.restoreEntry(e.id) // not deleted234            error("expected throw")235        } catch (_: IllegalStateException) { /* expected */ }236    }237238    @Test fun purge_deleted_entries_returns_zero_when_trash_empty() = runBlocking {239        val r = repo()240        r.addEntry(login("Live"))241        assertEquals(0, r.purgeDeletedEntries())242    }243244    // ── Search / distinct field values ────────────────────────────────────────245246    @Test fun distinct_field_values_sorted_by_frequency() = runBlocking {247        val r = repo()248        r.addEntry(login("a").copy(username = "popular"))249        r.addEntry(login("b").copy(username = "popular"))250        r.addEntry(login("c").copy(username = "rare"))251        assertEquals(listOf("popular", "rare"), r.getDistinctFieldValues("username"))252    }253254    @Test fun distinct_field_values_dedup_case_insensitive_keeps_first_casing() = runBlocking {255        val r = repo()256        r.addEntry(login("a").copy(username = "June"))257        r.addEntry(login("b").copy(username = "june"))258        assertEquals(listOf("June"), r.getDistinctFieldValues("username"))259    }260261    @Test fun distinct_field_values_email() = runBlocking {262        val r = repo()263        r.addEntry(login("a").copy(email = "[email protected]"))264        assertEquals(listOf("[email protected]"), r.getDistinctFieldValues("email"))265    }266267    @Test fun distinct_field_values_unknown_field_is_empty() = runBlocking {268        val r = repo()269        r.addEntry(login("a").copy(username = "x"))270        assertTrue(r.getDistinctFieldValues("nope").isEmpty())271    }272273    @Test fun distinct_field_values_excludes_deleted() = runBlocking {274        val r = repo()275        val gone = r.addEntry(login("a").copy(username = "ghost"))276        r.deleteEntry(gone.id)277        assertTrue(r.getDistinctFieldValues("username").isEmpty())278    }279280    // ── Concurrency ───────────────────────────────────────────────────────────281282    @Test fun concurrent_adds_do_not_corrupt() = runBlocking {283        val r = repo()284        val results = (0 until 50).map { i -> async { r.addEntry(login("C$i")) } }.awaitAll()285        assertEquals(50, r.getEntries().size)286        assertEquals(50, results.map { it.id }.toSet().size) // all ids unique287    }288289    @Test fun concurrent_add_and_delete_are_consistent() = runBlocking {290        val r = repo()291        val seed = (0 until 20).map { r.addEntry(login("S$it")) }292        val jobs = buildList {293            seed.take(10).forEach { e -> add(async { r.deleteEntry(e.id) }) }294            repeat(10) { i -> add(async { r.addEntry(login("N$i")) }) }295        }296        jobs.awaitAll()297        // 20 seeded - 10 deleted + 10 added = 20 active, 10 tombstones.298        assertEquals(20, r.getEntries().size)299        assertEquals(10, r.getTombstones().size)300    }301302    // ── Version counter ───────────────────────────────────────────────────────303304    @Test fun local_mutations_bump_version_sync_apply_does_not() = runBlocking {305        val r = repo()306        val v0 = r.getVaultVersion()307        r.addEntry(login("Local"))308        val v1 = r.getVaultVersion()309        assertTrue(v1 > v0)310        val remote = VaultEntry(id = "rem", title = "R", type = "login", password = "x",311            createdAt = 1, modifiedAt = System.currentTimeMillis() + 10_000)312        r.applyMergedRemoteEntries(listOf(remote), emptyList())313        assertEquals(v1, r.getVaultVersion())314    }315316    @Test fun sync_key_and_vault_id_available_only_when_unlocked() = runBlocking {317        val r = repo()318        assertNotNull(r.syncMasterKey())319        assertNotNull(r.syncVaultId())320        r.lock()321        assertNull(r.syncMasterKey())322        assertNull(r.syncVaultId())323    }324}