VaultRepositoryTest.kt

20.1 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 loadSlotB() = slotB20    override fun writeSlotB(blobBase64: String): Boolean { slotB = blobBase64; return true }21    var decoy: StoredVault? = null22    override fun loadDecoySlot() = decoy23    override fun writeDecoySlot(slot: StoredVault): Boolean { decoy = slot; return true }24    override fun hasDecoySlot() = decoy != null25    override fun clearDecoySlot() { decoy = null }26    override fun version() = ver27    override fun stampLocalMutation(): Long { ver = maxOf(System.currentTimeMillis(), ver + 1); return ver }28    override fun loadSettings() = settings29    override fun writeSettings(s: VaultSettings): Boolean { settings = s; return true }30    var history = VaultHistory()31    override fun loadVaultHistory() = history32    override fun writeVaultHistory(history: VaultHistory): Boolean { this.history = history; return true }33    override fun clearVaultHistory() { history = VaultHistory() }34    override fun clearVault() { slotA = null; slotB = null; ver = 0; settings = VaultSettings(); history = VaultHistory() }35}3637class VaultRepositoryTest {38    private fun login(title: String, password: String = "pw") =39        VaultEntry(id = "", title = title, type = "login", password = password, createdAt = 0, modifiedAt = 0)4041    private fun note(title: String) =42        VaultEntry(id = "", title = title, type = "note", createdAt = 0, modifiedAt = 0)4344    @Test fun create_persists_and_unlocks() = runBlocking {45        val storage = FakeStorage()46        val repo = VaultRepository(storage)47        assertFalse(repo.hasVault())48        assertEquals(CreateVaultResult.Success, repo.createVault("correct horse"))49        assertTrue(repo.hasVault())50        assertTrue(repo.isUnlocked.value)51        // Both slots written, version stamped.52        assertTrue(storage.slotA != null && storage.slotB != null)53        assertTrue(repo.getVaultVersion() > 0)54    }5556    @Test fun entries_persist_across_lock_unlock() = runBlocking {57        val storage = FakeStorage()58        val repo = VaultRepository(storage)59        repo.createVault("pw")60        val added = repo.addEntry(login("GitHub"))61        assertTrue(added.id.isNotBlank())62        assertEquals(1, repo.getEntries().size)63        repo.lock()64        assertFalse(repo.isUnlocked.value)65        assertEquals(UnlockResult.Success, repo.unlock("pw"))66        assertEquals(1, repo.getEntries().size)67        assertEquals("GitHub", repo.getEntries().first().title)68    }6970    @Test fun wrong_password_is_rejected() = runBlocking {71        val storage = FakeStorage()72        val repo = VaultRepository(storage)73        repo.createVault("right")74        repo.lock()75        assertEquals(UnlockResult.WrongPassword, repo.unlock("wrong"))76        assertFalse(repo.isUnlocked.value)77    }7879    @Test fun delete_writes_tombstone_then_purge_removes() = runBlocking {80        val repo = VaultRepository(FakeStorage())81        repo.createVault("pw")82        val e = repo.addEntry(login("Bank"))83        repo.deleteEntry(e.id)84        assertEquals(0, repo.getEntries().size)85        assertEquals(1, repo.getTombstones().size)86        assertEquals(e.id, repo.getTombstones().first().id)87        repo.restoreEntry(e.id)88        assertEquals(1, repo.getEntries().size)89        repo.deleteEntry(e.id)90        repo.purgeEntry(e.id)91        assertEquals(0, repo.getTombstones().size)92    }9394    @Test fun duplicate_count_matches_browser_keys() = runBlocking {95        val repo = VaultRepository(FakeStorage())96        repo.createVault("pw")97        repo.addEntry(login("GitHub").copy(url = "https://github.com", username = "june"))98        repo.addEntry(login("github").copy(url = "https://github.com", username = "june"))99        repo.addEntry(login("GitHub").copy(url = "https://github.com", username = "other"))100        repo.addEntry(note("Ideas"))101        repo.addEntry(note("ideas"))102103        assertEquals(2, repo.countDuplicateEntries())104    }105106    @Test fun deduplicate_moves_older_duplicates_to_trash() = runBlocking {107        val repo = VaultRepository(FakeStorage())108        repo.createVault("pw")109        val older = repo.addEntry(login("Bank").copy(url = "https://bank.test", username = "j"))110        Thread.sleep(2)111        val newer = repo.addEntry(login("bank").copy(url = "https://bank.test", username = "j"))112113        assertEquals(1, repo.deduplicateEntries())114115        assertEquals(listOf(newer.id), repo.getEntries().map { it.id })116        assertEquals(listOf(older.id), repo.getDeletedEntries().map { it.id })117    }118119    @Test fun vault_history_snapshots_before_local_writes_and_caps() = runBlocking {120        val repo = VaultRepository(FakeStorage())121        repo.createVault("pw")122123        repeat(7) { i ->124            repo.addEntry(login("Entry $i"))125            Thread.sleep(1)126        }127128        val history = repo.getVaultHistory()129        assertEquals(5, history.size)130        assertEquals("local_save", history.first().reason)131        assertEquals(10, history.first().entryCount)132    }133134    @Test fun restoring_vault_snapshot_replaces_slot_and_locks() = runBlocking {135        val repo = VaultRepository(FakeStorage())136        repo.createVault("pw")137        val first = repo.addEntry(login("First"))138        repo.addEntry(login("Second"))139        val snapshot = repo.getVaultHistory().first()140141        assertTrue(repo.restoreVaultSnapshot(snapshot.id))142        assertFalse(repo.isUnlocked.value)143        assertEquals(UnlockResult.Success, repo.unlock("pw"))144145        assertEquals(listOf(first.id), repo.getEntries().map { it.id })146        assertEquals("restore", repo.getVaultHistory().first().reason)147    }148149    @Test fun update_refreshes_password_changed_only_on_password_change() = runBlocking {150        val repo = VaultRepository(FakeStorage())151        repo.createVault("pw")152        val e = repo.addEntry(login("Mail", password = "old"))153        val firstChanged = e.passwordChangedAt!!154        Thread.sleep(2)155        val metaOnly = repo.updateEntry(e.copy(title = "Mail2", password = "old"))156        assertEquals(firstChanged, metaOnly.passwordChangedAt)157        Thread.sleep(2)158        val pwChange = repo.updateEntry(metaOnly.copy(password = "new"))159        assertTrue(pwChange.passwordChangedAt!! > firstChanged)160    }161162    @Test fun password_history_captures_old_password_newest_first() = runBlocking {163        val repo = VaultRepository(FakeStorage())164        repo.createVault("pw")165        val e = repo.addEntry(login("H", password = "first"))166        assertNull(e.passwordHistory)167        val v2 = repo.updateEntry(e.copy(password = "second"))168        val v3 = repo.updateEntry(v2.copy(password = "third"))169        assertEquals(listOf("second", "first"), v3.passwordHistory!!.map { it.password })170        assertEquals("third", v3.password)171    }172173    @Test fun password_history_ignores_metadata_only_update() = runBlocking {174        val repo = VaultRepository(FakeStorage())175        repo.createVault("pw")176        val e = repo.addEntry(login("M", password = "keepme"))177        val r = repo.updateEntry(e.copy(title = "Renamed"))178        assertNull(r.passwordHistory)179    }180181    @Test fun password_history_item_carries_prior_age() = runBlocking {182        val repo = VaultRepository(FakeStorage())183        repo.createVault("pw")184        val e = repo.addEntry(login("S", password = "v1"))185        val firstChanged = e.passwordChangedAt!!186        Thread.sleep(2)187        val r = repo.updateEntry(e.copy(password = "v2"))188        assertEquals(firstChanged, r.passwordHistory!!.first().changedAt)189    }190191    @Test fun password_history_skips_identical_resave() = runBlocking {192        val repo = VaultRepository(FakeStorage())193        repo.createVault("pw")194        val e = repo.addEntry(login("N", password = "same"))195        val r = repo.updateEntry(e.copy(password = "same"))196        assertNull(r.passwordHistory)197    }198199    @Test fun password_history_restore_re_records_swapped_out_password() = runBlocking {200        val repo = VaultRepository(FakeStorage())201        repo.createVault("pw")202        val e = repo.addEntry(login("R", password = "old"))203        val v2 = repo.updateEntry(e.copy(password = "new"))204        val restored = repo.updateEntry(v2.copy(password = "old"))205        assertEquals("old", restored.password)206        assertEquals(listOf("new", "old"), restored.passwordHistory!!.map { it.password })207    }208209    @Test fun password_history_persists_across_lock_unlock() = runBlocking {210        val storage = FakeStorage()211        val repo = VaultRepository(storage)212        repo.createVault("pw")213        val e = repo.addEntry(login("P", password = "p1"))214        repo.updateEntry(e.copy(password = "p2"))215        repo.lock()216        assertEquals(UnlockResult.Success, repo.unlock("pw"))217        val reloaded = repo.getEntries().first { it.id == e.id }218        assertEquals(listOf("p1"), reloaded.passwordHistory!!.map { it.password })219    }220221    @Test fun password_history_caps_at_fifty() = runBlocking {222        val repo = VaultRepository(FakeStorage())223        repo.createVault("pw")224        var e = repo.addEntry(login("C", password = "pw-0"))225        for (i in 1..55) e = repo.updateEntry(e.copy(password = "pw-$i"))226        val hist = e.passwordHistory!!227        assertEquals(50, hist.size)228        // pw-55 is current; newest retired is pw-54, oldest kept is pw-5.229        assertEquals("pw-54", hist.first().password)230        assertEquals("pw-5", hist.last().password)231    }232233    @Test fun apply_merged_remote_does_not_bump_version() = runBlocking {234        val repo = VaultRepository(FakeStorage())235        repo.createVault("pw")236        repo.addEntry(login("Local"))237        val versionBefore = repo.getVaultVersion()238        val remote = VaultEntry(239            id = "remote-1", title = "Remote", type = "login", password = "x",240            createdAt = 1, modifiedAt = System.currentTimeMillis() + 10_000,241        )242        val applied = repo.applyMergedRemoteEntries(listOf(remote), emptyList())243        assertEquals(1, applied)244        assertEquals(2, repo.getEntries().size)245        // The whole point: a sync apply must NOT advance the local version.246        assertEquals(versionBefore, repo.getVaultVersion())247    }248249    @Test fun remote_tombstone_supersedes_older_local_entry() = runBlocking {250        val repo = VaultRepository(FakeStorage())251        repo.createVault("pw")252        val local = repo.addEntry(login("Shared"))253        val tomb = Tombstone(local.id, local.modifiedAt + 5_000)254        val applied = repo.applyMergedRemoteEntries(emptyList(), listOf(tomb))255        assertEquals(1, applied)256        assertEquals(0, repo.getEntries().size)257    }258259    @Test fun change_password_reencrypts_under_new_password() = runBlocking {260        val storage = FakeStorage()261        val repo = VaultRepository(storage)262        repo.createVault("old-pass")263        repo.addEntry(login("Acct"))264        assertTrue(repo.changePassword("old-pass", "new-pass"))265        repo.lock()266        assertEquals(UnlockResult.WrongPassword, repo.unlock("old-pass"))267        assertEquals(UnlockResult.Success, repo.unlock("new-pass"))268        assertEquals(1, repo.getEntries().size)269    }270271    // ---- A1: trash purge + retention auto-purge ---------------------------272273    @Test fun purge_deleted_entries_empties_trash() = runBlocking {274        val repo = VaultRepository(FakeStorage())275        repo.createVault("pw")276        val a = repo.addEntry(login("A"))277        val b = repo.addEntry(login("B"))278        repo.addEntry(login("C"))279        repo.deleteEntry(a.id)280        repo.deleteEntry(b.id)281        assertEquals(2, repo.getDeletedEntries().size)282        assertEquals(2, repo.purgeDeletedEntries())283        assertEquals(0, repo.getDeletedEntries().size)284        assertEquals(1, repo.getEntries().size)285    }286287    @Test fun auto_purge_removes_trash_older_than_retention() = runBlocking {288        val storage = FakeStorage()289        val repo = VaultRepository(storage)290        repo.createVault("pw")291        repo.updateSettings(repo.settings.value.copy(trashRetentionDays = 30))292        // Insert an ancient soft-deleted entry via the sync-merge path (deletedAt=1000).293        val ancient = VaultEntry(id = "old", title = "Old", type = "login", password = "x",294            createdAt = 1000, modifiedAt = 1000, deletedAt = 1000)295        repo.applyMergedRemoteEntries(listOf(ancient), emptyList())296        assertEquals(1, repo.getDeletedEntries().size)297        repo.lock()298        assertEquals(UnlockResult.Success, repo.unlock("pw"))299        assertEquals(0, repo.getDeletedEntries().size)300    }301302    @Test fun auto_purge_keeps_trash_when_retention_never() = runBlocking {303        val storage = FakeStorage()304        val repo = VaultRepository(storage)305        repo.createVault("pw") // default trashRetentionDays == null → "Never"306        val ancient = VaultEntry(id = "old", title = "Old", type = "login", password = "x",307            createdAt = 1000, modifiedAt = 1000, deletedAt = 1000)308        repo.applyMergedRemoteEntries(listOf(ancient), emptyList())309        repo.lock()310        assertEquals(UnlockResult.Success, repo.unlock("pw"))311        assertEquals(1, repo.getDeletedEntries().size)312    }313314    // ---- A2: tags ---------------------------------------------------------315316    @Test fun tags_distinct_sorted_lowercased() = runBlocking {317        val repo = VaultRepository(FakeStorage())318        repo.createVault("pw")319        repo.addEntry(login("A").copy(tags = listOf("work", "email")))320        repo.addEntry(login("B").copy(tags = listOf("Work", "social")))321        assertEquals(listOf("email", "social", "work"), repo.getDistinctTags())322    }323324    @Test fun get_active_entries_filters_by_tag() = runBlocking {325        val repo = VaultRepository(FakeStorage())326        repo.createVault("pw")327        repo.addEntry(login("A").copy(tags = listOf("work")))328        repo.addEntry(login("B").copy(tags = listOf("home")))329        val gone = repo.addEntry(login("C").copy(tags = listOf("work")))330        repo.deleteEntry(gone.id)331        val work = repo.getActiveEntries("work")332        assertEquals(1, work.size)333        assertEquals("A", work.first().title)334        assertEquals(2, repo.getActiveEntries(null).size)335    }336337    @Test fun tags_round_trip_across_lock_unlock() = runBlocking {338        val storage = FakeStorage()339        val repo = VaultRepository(storage)340        repo.createVault("pw")341        val e = repo.addEntry(login("T").copy(tags = listOf("alpha", "beta")))342        repo.lock()343        repo.unlock("pw")344        assertEquals(listOf("alpha", "beta"), repo.getEntries().first { it.id == e.id }.tags)345    }346347    // ---- A5: recovery-word metadata ---------------------------------------348349    @Test fun recovery_words_set_verify_and_persist() = runBlocking {350        val storage = FakeStorage()351        val repo = VaultRepository(storage)352        repo.createVault("pw")353        assertFalse(repo.isRecoveryWordsSet())354        val words = rootKeyToWords(ByteArray(32) { it.toByte() })355        assertTrue(repo.setRecoveryWords(words))356        assertTrue(repo.isRecoveryWordsSet())357        assertTrue(repo.verifyRecoveryWords(words))358        assertFalse(repo.verifyRecoveryWords(words.reversed()))359        repo.lock()360        assertEquals(UnlockResult.Success, repo.unlock("pw"))361        assertTrue(repo.isRecoveryWordsSet())362        assertTrue(repo.verifyRecoveryWords(words))363    }364365    @Test fun join_vault_takes_the_source_identity_keeps_entries_and_adopts_its_words() = runBlocking {366        // The source: a second, independent vault whose root key we will join.367        val sourceRepo = VaultRepository(FakeStorage())368        sourceRepo.createVault("source-pw")369        val sourceRootHex = sourceRepo.syncMasterKey()!!.let { key ->370            key.joinToString("") { "%02x".format(it) }.also { VaultCrypto.zero(key) }371        }372        val sourceVaultId = sourceRepo.syncVaultId()373        val sourceWords = sourceRepo.syncMasterKey()!!.let { key ->374            rootKeyToWords(key).also { VaultCrypto.zero(key) }375        }376377        // The joiner: our own vault, with an entry that must survive.378        val storage = FakeStorage()379        val repo = VaultRepository(storage)380        repo.createVault("pw")381        val mine = repo.addEntry(login("Bank"))382        val myWordsBefore = repo.syncMasterKey()!!.let { key ->383            rootKeyToWords(key).also { VaultCrypto.zero(key) }384        }385        assertTrue(repo.setRecoveryWords(myWordsBefore))386        assertTrue(repo.syncVaultId() != sourceVaultId)387388        assertEquals(sourceWords, repo.previewRecoveryWords(sourceRootHex))389390        val result = repo.joinVault(sourceRootHex)391        assertTrue(result is AdoptVaultResult.Success)392393        // Same vault now: this is what lets sync run at all (vault_id_mismatch).394        assertEquals(sourceVaultId, repo.syncVaultId())395        // Entries are sealed under the password key, so joining keeps them.396        assertEquals("Bank", repo.getEntries().first { it.id == mine.id }.title)397        // Words converge on the source's, and the old ones are dead.398        assertTrue(repo.verifyRecoveryWords(sourceWords))399        assertFalse(repo.verifyRecoveryWords(myWordsBefore))400        assertFalse(repo.isRecoveryWordsSet())401402        // Survives a lock/unlock under the joiner's own unchanged password.403        repo.lock()404        assertEquals(UnlockResult.Success, repo.unlock("pw"))405        assertEquals("Bank", repo.getEntries().first { it.id == mine.id }.title)406        assertEquals(sourceVaultId, repo.syncVaultId())407    }408409    @Test fun join_vault_refuses_a_malformed_root_key_and_changes_nothing() = runBlocking {410        val repo = VaultRepository(FakeStorage())411        repo.createVault("pw")412        val added = repo.addEntry(login("Bank"))413        val idBefore = repo.syncVaultId()414415        assertTrue(repo.joinVault("not-hex") is AdoptVaultResult.Error)416        assertTrue(repo.joinVault("ab".repeat(31)) is AdoptVaultResult.Error)417418        assertEquals(idBefore, repo.syncVaultId())419        assertEquals("Bank", repo.getEntries().first { it.id == added.id }.title)420    }421422    @Test fun rotate_sync_identity_changes_root_keeps_entries_and_requires_new_recovery_confirmation() = runBlocking {423        val storage = FakeStorage()424        val repo = VaultRepository(storage)425        repo.createVault("pw")426        val added = repo.addEntry(login("Bank"))427        val wordsBefore = repo.syncMasterKey()!!.let { key ->428            rootKeyToWords(key).also { VaultCrypto.zero(key) }429        }430        assertTrue(repo.setRecoveryWords(wordsBefore))431        val vaultIdBefore = repo.syncVaultId()432        val versionBefore = repo.getVaultVersion()433434        assertTrue(repo.rotateSyncIdentity())435436        val wordsAfter = repo.syncMasterKey()!!.let { key ->437            rootKeyToWords(key).also { VaultCrypto.zero(key) }438        }439        assertTrue(wordsAfter != wordsBefore)440        assertTrue(repo.syncVaultId() != vaultIdBefore)441        assertFalse(repo.isRecoveryWordsSet())442        assertFalse(repo.verifyRecoveryWords(wordsBefore))443        assertTrue(repo.verifyRecoveryWords(wordsAfter))444        assertTrue(storage.slotA?.recoveryEscrow != null)445        assertTrue(storage.slotA?.rootKeyEscrow != null)446        assertTrue(repo.getVaultVersion() > versionBefore)447        assertEquals("Bank", repo.getEntries().first { it.id == added.id }.title)448449        repo.lock()450        assertEquals(UnlockResult.Success, repo.unlock("pw"))451        assertEquals("Bank", repo.getEntries().first { it.id == added.id }.title)452        assertFalse(repo.isRecoveryWordsSet())453        assertTrue(repo.verifyRecoveryWords(wordsAfter))454    }455456    @Test fun destroy_clears_everything() = runBlocking {457        val storage = FakeStorage()458        val repo = VaultRepository(storage)459        repo.createVault("pw")460        repo.addEntry(login("X"))461        repo.destroyVault()462        assertFalse(repo.isUnlocked.value)463        assertNull(storage.slotA)464        assertFalse(repo.hasVault())465    }466}