VaultRepositoryTest.kt

14.6 KB · raw

1package com.peachpasswords.vault23import kotlinx.coroutines.runBlocking4import org.junit.Assert.assertEquals5import org.junit.Assert.assertFalse6import org.junit.Assert.assertNull7import org.junit.Assert.assertTrue8import org.junit.Test910/** In-memory [VaultStorage] so the repository can be exercised without Android. */11private class FakeStorage : VaultStorage {12    var slotA: StoredVault? = null13    var slotB: String? = null14    private var ver: Long = 015    private var settings = VaultSettings()16    override fun loadSlotA() = slotA17    override fun hasVault() = slotA != null18    override fun writeSlotA(slot: StoredVault): Boolean { slotA = slot; return true }19    override fun writeSlotB(blobBase64: String): Boolean { slotB = blobBase64; return true }20    var decoy: StoredVault? = null21    override fun loadDecoySlot() = decoy22    override fun writeDecoySlot(slot: StoredVault): Boolean { decoy = slot; return true }23    override fun hasDecoySlot() = decoy != null24    override fun clearDecoySlot() { decoy = null }25    override fun version() = ver26    override fun stampLocalMutation(): Long { ver = maxOf(System.currentTimeMillis(), ver + 1); return ver }27    override fun loadSettings() = settings28    override fun writeSettings(s: VaultSettings): Boolean { settings = s; return true }29    override fun clearVault() { slotA = null; slotB = null; ver = 0; settings = VaultSettings() }30}3132class VaultRepositoryTest {33    private fun login(title: String, password: String = "pw") =34        VaultEntry(id = "", title = title, type = "login", password = password, createdAt = 0, modifiedAt = 0)3536    private fun note(title: String) =37        VaultEntry(id = "", title = title, type = "note", createdAt = 0, modifiedAt = 0)3839    @Test fun create_persists_and_unlocks() = runBlocking {40        val storage = FakeStorage()41        val repo = VaultRepository(storage)42        assertFalse(repo.hasVault())43        assertEquals(CreateVaultResult.Success, repo.createVault("correct horse"))44        assertTrue(repo.hasVault())45        assertTrue(repo.isUnlocked.value)46        // Both slots written, version stamped.47        assertTrue(storage.slotA != null && storage.slotB != null)48        assertTrue(repo.getVaultVersion() > 0)49    }5051    @Test fun entries_persist_across_lock_unlock() = runBlocking {52        val storage = FakeStorage()53        val repo = VaultRepository(storage)54        repo.createVault("pw")55        val added = repo.addEntry(login("GitHub"))56        assertTrue(added.id.isNotBlank())57        assertEquals(1, repo.getEntries().size)58        repo.lock()59        assertFalse(repo.isUnlocked.value)60        assertEquals(UnlockResult.Success, repo.unlock("pw"))61        assertEquals(1, repo.getEntries().size)62        assertEquals("GitHub", repo.getEntries().first().title)63    }6465    @Test fun wrong_password_is_rejected() = runBlocking {66        val storage = FakeStorage()67        val repo = VaultRepository(storage)68        repo.createVault("right")69        repo.lock()70        assertEquals(UnlockResult.WrongPassword, repo.unlock("wrong"))71        assertFalse(repo.isUnlocked.value)72    }7374    @Test fun delete_writes_tombstone_then_purge_removes() = runBlocking {75        val repo = VaultRepository(FakeStorage())76        repo.createVault("pw")77        val e = repo.addEntry(login("Bank"))78        repo.deleteEntry(e.id)79        assertEquals(0, repo.getEntries().size)80        assertEquals(1, repo.getTombstones().size)81        assertEquals(e.id, repo.getTombstones().first().id)82        repo.restoreEntry(e.id)83        assertEquals(1, repo.getEntries().size)84        repo.deleteEntry(e.id)85        repo.purgeEntry(e.id)86        assertEquals(0, repo.getTombstones().size)87    }8889    @Test fun duplicate_count_matches_browser_keys() = runBlocking {90        val repo = VaultRepository(FakeStorage())91        repo.createVault("pw")92        repo.addEntry(login("GitHub").copy(url = "https://github.com", username = "june"))93        repo.addEntry(login("github").copy(url = "https://github.com", username = "june"))94        repo.addEntry(login("GitHub").copy(url = "https://github.com", username = "other"))95        repo.addEntry(note("Ideas"))96        repo.addEntry(note("ideas"))9798        assertEquals(2, repo.countDuplicateEntries())99    }100101    @Test fun deduplicate_moves_older_duplicates_to_trash() = runBlocking {102        val repo = VaultRepository(FakeStorage())103        repo.createVault("pw")104        val older = repo.addEntry(login("Bank").copy(url = "https://bank.test", username = "j"))105        Thread.sleep(2)106        val newer = repo.addEntry(login("bank").copy(url = "https://bank.test", username = "j"))107108        assertEquals(1, repo.deduplicateEntries())109110        assertEquals(listOf(newer.id), repo.getEntries().map { it.id })111        assertEquals(listOf(older.id), repo.getDeletedEntries().map { it.id })112    }113114    @Test fun update_refreshes_password_changed_only_on_password_change() = runBlocking {115        val repo = VaultRepository(FakeStorage())116        repo.createVault("pw")117        val e = repo.addEntry(login("Mail", password = "old"))118        val firstChanged = e.passwordChangedAt!!119        Thread.sleep(2)120        val metaOnly = repo.updateEntry(e.copy(title = "Mail2", password = "old"))121        assertEquals(firstChanged, metaOnly.passwordChangedAt)122        Thread.sleep(2)123        val pwChange = repo.updateEntry(metaOnly.copy(password = "new"))124        assertTrue(pwChange.passwordChangedAt!! > firstChanged)125    }126127    @Test fun password_history_captures_old_password_newest_first() = runBlocking {128        val repo = VaultRepository(FakeStorage())129        repo.createVault("pw")130        val e = repo.addEntry(login("H", password = "first"))131        assertNull(e.passwordHistory)132        val v2 = repo.updateEntry(e.copy(password = "second"))133        val v3 = repo.updateEntry(v2.copy(password = "third"))134        assertEquals(listOf("second", "first"), v3.passwordHistory!!.map { it.password })135        assertEquals("third", v3.password)136    }137138    @Test fun password_history_ignores_metadata_only_update() = runBlocking {139        val repo = VaultRepository(FakeStorage())140        repo.createVault("pw")141        val e = repo.addEntry(login("M", password = "keepme"))142        val r = repo.updateEntry(e.copy(title = "Renamed"))143        assertNull(r.passwordHistory)144    }145146    @Test fun password_history_item_carries_prior_age() = runBlocking {147        val repo = VaultRepository(FakeStorage())148        repo.createVault("pw")149        val e = repo.addEntry(login("S", password = "v1"))150        val firstChanged = e.passwordChangedAt!!151        Thread.sleep(2)152        val r = repo.updateEntry(e.copy(password = "v2"))153        assertEquals(firstChanged, r.passwordHistory!!.first().changedAt)154    }155156    @Test fun password_history_skips_identical_resave() = runBlocking {157        val repo = VaultRepository(FakeStorage())158        repo.createVault("pw")159        val e = repo.addEntry(login("N", password = "same"))160        val r = repo.updateEntry(e.copy(password = "same"))161        assertNull(r.passwordHistory)162    }163164    @Test fun password_history_restore_re_records_swapped_out_password() = runBlocking {165        val repo = VaultRepository(FakeStorage())166        repo.createVault("pw")167        val e = repo.addEntry(login("R", password = "old"))168        val v2 = repo.updateEntry(e.copy(password = "new"))169        val restored = repo.updateEntry(v2.copy(password = "old"))170        assertEquals("old", restored.password)171        assertEquals(listOf("new", "old"), restored.passwordHistory!!.map { it.password })172    }173174    @Test fun password_history_persists_across_lock_unlock() = runBlocking {175        val storage = FakeStorage()176        val repo = VaultRepository(storage)177        repo.createVault("pw")178        val e = repo.addEntry(login("P", password = "p1"))179        repo.updateEntry(e.copy(password = "p2"))180        repo.lock()181        assertEquals(UnlockResult.Success, repo.unlock("pw"))182        val reloaded = repo.getEntries().first { it.id == e.id }183        assertEquals(listOf("p1"), reloaded.passwordHistory!!.map { it.password })184    }185186    @Test fun password_history_caps_at_fifty() = runBlocking {187        val repo = VaultRepository(FakeStorage())188        repo.createVault("pw")189        var e = repo.addEntry(login("C", password = "pw-0"))190        for (i in 1..55) e = repo.updateEntry(e.copy(password = "pw-$i"))191        val hist = e.passwordHistory!!192        assertEquals(50, hist.size)193        // pw-55 is current; newest retired is pw-54, oldest kept is pw-5.194        assertEquals("pw-54", hist.first().password)195        assertEquals("pw-5", hist.last().password)196    }197198    @Test fun apply_merged_remote_does_not_bump_version() = runBlocking {199        val repo = VaultRepository(FakeStorage())200        repo.createVault("pw")201        repo.addEntry(login("Local"))202        val versionBefore = repo.getVaultVersion()203        val remote = VaultEntry(204            id = "remote-1", title = "Remote", type = "login", password = "x",205            createdAt = 1, modifiedAt = System.currentTimeMillis() + 10_000,206        )207        val applied = repo.applyMergedRemoteEntries(listOf(remote), emptyList())208        assertEquals(1, applied)209        assertEquals(2, repo.getEntries().size)210        // The whole point: a sync apply must NOT advance the local version.211        assertEquals(versionBefore, repo.getVaultVersion())212    }213214    @Test fun remote_tombstone_supersedes_older_local_entry() = runBlocking {215        val repo = VaultRepository(FakeStorage())216        repo.createVault("pw")217        val local = repo.addEntry(login("Shared"))218        val tomb = Tombstone(local.id, local.modifiedAt + 5_000)219        val applied = repo.applyMergedRemoteEntries(emptyList(), listOf(tomb))220        assertEquals(1, applied)221        assertEquals(0, repo.getEntries().size)222    }223224    @Test fun change_password_reencrypts_under_new_password() = runBlocking {225        val storage = FakeStorage()226        val repo = VaultRepository(storage)227        repo.createVault("old-pass")228        repo.addEntry(login("Acct"))229        assertTrue(repo.changePassword("old-pass", "new-pass"))230        repo.lock()231        assertEquals(UnlockResult.WrongPassword, repo.unlock("old-pass"))232        assertEquals(UnlockResult.Success, repo.unlock("new-pass"))233        assertEquals(1, repo.getEntries().size)234    }235236    // ---- A1: trash purge + retention auto-purge ---------------------------237238    @Test fun purge_deleted_entries_empties_trash() = runBlocking {239        val repo = VaultRepository(FakeStorage())240        repo.createVault("pw")241        val a = repo.addEntry(login("A"))242        val b = repo.addEntry(login("B"))243        repo.addEntry(login("C"))244        repo.deleteEntry(a.id)245        repo.deleteEntry(b.id)246        assertEquals(2, repo.getDeletedEntries().size)247        assertEquals(2, repo.purgeDeletedEntries())248        assertEquals(0, repo.getDeletedEntries().size)249        assertEquals(1, repo.getEntries().size)250    }251252    @Test fun auto_purge_removes_trash_older_than_retention() = runBlocking {253        val storage = FakeStorage()254        val repo = VaultRepository(storage)255        repo.createVault("pw")256        repo.updateSettings(repo.settings.value.copy(trashRetentionDays = 30))257        // Insert an ancient soft-deleted entry via the sync-merge path (deletedAt=1000).258        val ancient = VaultEntry(id = "old", title = "Old", type = "login", password = "x",259            createdAt = 1000, modifiedAt = 1000, deletedAt = 1000)260        repo.applyMergedRemoteEntries(listOf(ancient), emptyList())261        assertEquals(1, repo.getDeletedEntries().size)262        repo.lock()263        assertEquals(UnlockResult.Success, repo.unlock("pw"))264        assertEquals(0, repo.getDeletedEntries().size)265    }266267    @Test fun auto_purge_keeps_trash_when_retention_never() = runBlocking {268        val storage = FakeStorage()269        val repo = VaultRepository(storage)270        repo.createVault("pw") // default trashRetentionDays == null → "Never"271        val ancient = VaultEntry(id = "old", title = "Old", type = "login", password = "x",272            createdAt = 1000, modifiedAt = 1000, deletedAt = 1000)273        repo.applyMergedRemoteEntries(listOf(ancient), emptyList())274        repo.lock()275        assertEquals(UnlockResult.Success, repo.unlock("pw"))276        assertEquals(1, repo.getDeletedEntries().size)277    }278279    // ---- A2: tags ---------------------------------------------------------280281    @Test fun tags_distinct_sorted_lowercased() = runBlocking {282        val repo = VaultRepository(FakeStorage())283        repo.createVault("pw")284        repo.addEntry(login("A").copy(tags = listOf("work", "email")))285        repo.addEntry(login("B").copy(tags = listOf("Work", "social")))286        assertEquals(listOf("email", "social", "work"), repo.getDistinctTags())287    }288289    @Test fun get_active_entries_filters_by_tag() = runBlocking {290        val repo = VaultRepository(FakeStorage())291        repo.createVault("pw")292        repo.addEntry(login("A").copy(tags = listOf("work")))293        repo.addEntry(login("B").copy(tags = listOf("home")))294        val gone = repo.addEntry(login("C").copy(tags = listOf("work")))295        repo.deleteEntry(gone.id)296        val work = repo.getActiveEntries("work")297        assertEquals(1, work.size)298        assertEquals("A", work.first().title)299        assertEquals(2, repo.getActiveEntries(null).size)300    }301302    @Test fun tags_round_trip_across_lock_unlock() = runBlocking {303        val storage = FakeStorage()304        val repo = VaultRepository(storage)305        repo.createVault("pw")306        val e = repo.addEntry(login("T").copy(tags = listOf("alpha", "beta")))307        repo.lock()308        repo.unlock("pw")309        assertEquals(listOf("alpha", "beta"), repo.getEntries().first { it.id == e.id }.tags)310    }311312    // ---- A5: recovery-word metadata ---------------------------------------313314    @Test fun recovery_words_set_verify_and_persist() = runBlocking {315        val storage = FakeStorage()316        val repo = VaultRepository(storage)317        repo.createVault("pw")318        assertFalse(repo.isRecoveryWordsSet())319        val words = rootKeyToWords(ByteArray(32) { it.toByte() })320        assertTrue(repo.setRecoveryWords(words))321        assertTrue(repo.isRecoveryWordsSet())322        assertTrue(repo.verifyRecoveryWords(words))323        assertFalse(repo.verifyRecoveryWords(words.reversed()))324        repo.lock()325        assertEquals(UnlockResult.Success, repo.unlock("pw"))326        assertTrue(repo.isRecoveryWordsSet())327        assertTrue(repo.verifyRecoveryWords(words))328    }329330    @Test fun destroy_clears_everything() = runBlocking {331        val storage = FakeStorage()332        val repo = VaultRepository(storage)333        repo.createVault("pw")334        repo.addEntry(login("X"))335        repo.destroyVault()336        assertFalse(repo.isUnlocked.value)337        assertNull(storage.slotA)338        assertFalse(repo.hasVault())339    }340}