VaultRepositoryTest.kt

22.3 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.Test9import com.peachpasswords.sync.LockedSyncSpoolHooks1011/** In-memory [VaultStorage] so the repository can be exercised without Android. */12private class FakeStorage : VaultStorage {13    var slotA: StoredVault? = null14    var slotB: String? = null15    private var ver: Long = 016    private var settings = VaultSettings()17    override fun loadSlotA() = slotA18    override fun hasVault() = slotA != null19    override fun writeSlotA(slot: StoredVault): Boolean { slotA = slot; return true }20    override fun loadSlotB() = slotB21    override fun writeSlotB(blobBase64: String): Boolean { slotB = blobBase64; return true }22    var decoy: StoredVault? = null23    override fun loadDecoySlot() = decoy24    override fun writeDecoySlot(slot: StoredVault): Boolean { decoy = slot; return true }25    override fun hasDecoySlot() = decoy != null26    override fun clearDecoySlot() { decoy = null }27    override fun version() = ver28    override fun stampLocalMutation(): Long { ver = maxOf(System.currentTimeMillis(), ver + 1); return ver }29    override fun loadSettings() = settings30    override fun writeSettings(s: VaultSettings): Boolean { settings = s; return true }31    var history = VaultHistory()32    override fun loadVaultHistory() = history33    override fun writeVaultHistory(history: VaultHistory): Boolean { this.history = history; return true }34    override fun clearVaultHistory() { history = VaultHistory() }35    override fun clearVault() { slotA = null; slotB = null; ver = 0; settings = VaultSettings(); history = VaultHistory() }36}3738class VaultRepositoryTest {39    private fun login(title: String, password: String = "pw") =40        VaultEntry(id = "", title = title, type = "login", password = password, createdAt = 0, modifiedAt = 0)4142    private fun note(title: String) =43        VaultEntry(id = "", title = title, type = "note", createdAt = 0, modifiedAt = 0)4445    @Test fun full_lock_offers_committed_state_to_opaque_sync_spool_before_zeroing() = runBlocking {46        val repo = VaultRepository(FakeStorage())47        repo.createVault("pw")48        repo.addEntry(login("GitHub", "secret"))49        var observedRoot = false50        var observedTitles = emptyList<String>()51        LockedSyncSpoolHooks.refresh = { current ->52            val root = current.syncMasterKey()53            observedRoot = root != null54            root?.let(VaultCrypto::zero)55            observedTitles = current.getEntries().map { it.title }56        }57        try {58            repo.lock()59            assertTrue(observedRoot)60            assertEquals(listOf("GitHub"), observedTitles)61            assertFalse(repo.isUnlocked.value)62            assertNull(repo.syncMasterKey())63            assertTrue(repo.getEntries().isEmpty())64        } finally {65            LockedSyncSpoolHooks.refresh = {}66        }67    }6869    @Test fun create_persists_and_unlocks() = runBlocking {70        val storage = FakeStorage()71        val repo = VaultRepository(storage)72        assertFalse(repo.hasVault())73        assertEquals(CreateVaultResult.Success, repo.createVault("correct horse"))74        assertTrue(repo.hasVault())75        assertTrue(repo.isUnlocked.value)76        // Both slots written, version stamped.77        assertTrue(storage.slotA != null && storage.slotB != null)78        assertTrue(repo.getVaultVersion() > 0)79    }8081    @Test fun entries_persist_across_lock_unlock() = runBlocking {82        val storage = FakeStorage()83        val repo = VaultRepository(storage)84        repo.createVault("pw")85        val added = repo.addEntry(login("GitHub"))86        assertTrue(added.id.isNotBlank())87        assertEquals(1, repo.getEntries().size)88        repo.lock()89        assertFalse(repo.isUnlocked.value)90        assertEquals(UnlockResult.Success, repo.unlock("pw"))91        assertEquals(1, repo.getEntries().size)92        assertEquals("GitHub", repo.getEntries().first().title)93    }9495    @Test fun wrong_password_is_rejected() = runBlocking {96        val storage = FakeStorage()97        val repo = VaultRepository(storage)98        repo.createVault("right")99        repo.lock()100        assertEquals(UnlockResult.WrongPassword, repo.unlock("wrong"))101        assertFalse(repo.isUnlocked.value)102    }103104    @Test fun delete_writes_tombstone_then_purge_removes() = runBlocking {105        val repo = VaultRepository(FakeStorage())106        repo.createVault("pw")107        val e = repo.addEntry(login("Bank"))108        repo.deleteEntry(e.id)109        assertEquals(0, repo.getEntries().size)110        assertEquals(1, repo.getTombstones().size)111        assertEquals(e.id, repo.getTombstones().first().id)112        repo.restoreEntry(e.id)113        assertEquals(1, repo.getEntries().size)114        repo.deleteEntry(e.id)115        repo.purgeEntry(e.id)116        assertEquals(0, repo.getTombstones().size)117    }118119    @Test fun duplicate_count_matches_browser_keys() = runBlocking {120        val repo = VaultRepository(FakeStorage())121        repo.createVault("pw")122        repo.addEntry(login("GitHub").copy(url = "https://github.com", username = "june"))123        repo.addEntry(login("github").copy(url = "https://github.com", username = "june"))124        repo.addEntry(login("GitHub").copy(url = "https://github.com", username = "other"))125        repo.addEntry(note("Ideas"))126        repo.addEntry(note("ideas"))127128        assertEquals(2, repo.countDuplicateEntries())129    }130131    @Test fun deduplicate_moves_older_duplicates_to_trash() = runBlocking {132        val repo = VaultRepository(FakeStorage())133        repo.createVault("pw")134        val older = repo.addEntry(login("Bank").copy(url = "https://bank.test", username = "j"))135        Thread.sleep(2)136        val newer = repo.addEntry(login("bank").copy(url = "https://bank.test", username = "j"))137138        assertEquals(1, repo.deduplicateEntries())139140        assertEquals(listOf(newer.id), repo.getEntries().map { it.id })141        assertEquals(listOf(older.id), repo.getDeletedEntries().map { it.id })142    }143144    @Test fun vault_history_snapshots_before_local_writes_and_caps() = runBlocking {145        val repo = VaultRepository(FakeStorage())146        repo.createVault("pw")147148        repeat(7) { i ->149            repo.addEntry(login("Entry $i"))150            Thread.sleep(1)151        }152153        val history = repo.getVaultHistory()154        assertEquals(5, history.size)155        assertEquals("local_save", history.first().reason)156        assertEquals(10, history.first().entryCount)157    }158159    @Test fun restoring_vault_snapshot_replaces_slot_and_locks() = runBlocking {160        val repo = VaultRepository(FakeStorage())161        repo.createVault("pw")162        val first = repo.addEntry(login("First"))163        repo.addEntry(login("Second"))164        val snapshot = repo.getVaultHistory().first()165166        assertTrue(repo.restoreVaultSnapshot(snapshot.id))167        assertFalse(repo.isUnlocked.value)168        assertEquals(UnlockResult.Success, repo.unlock("pw"))169170        assertEquals(listOf(first.id), repo.getEntries().map { it.id })171        assertEquals("restore", repo.getVaultHistory().first().reason)172    }173174    @Test fun update_refreshes_password_changed_only_on_password_change() = runBlocking {175        val repo = VaultRepository(FakeStorage())176        repo.createVault("pw")177        val e = repo.addEntry(login("Mail", password = "old"))178        val firstChanged = e.passwordChangedAt!!179        Thread.sleep(2)180        val metaOnly = repo.updateEntry(e.copy(title = "Mail2", password = "old"))181        assertEquals(firstChanged, metaOnly.passwordChangedAt)182        Thread.sleep(2)183        val pwChange = repo.updateEntry(metaOnly.copy(password = "new"))184        assertTrue(pwChange.passwordChangedAt!! > firstChanged)185    }186187    @Test fun password_history_captures_old_password_newest_first() = runBlocking {188        val repo = VaultRepository(FakeStorage())189        repo.createVault("pw")190        val e = repo.addEntry(login("H", password = "first"))191        assertNull(e.passwordHistory)192        val v2 = repo.updateEntry(e.copy(password = "second"))193        val v3 = repo.updateEntry(v2.copy(password = "third"))194        assertEquals(listOf("second", "first"), v3.passwordHistory!!.map { it.password })195        assertEquals("third", v3.password)196    }197198    @Test fun password_history_ignores_metadata_only_update() = runBlocking {199        val repo = VaultRepository(FakeStorage())200        repo.createVault("pw")201        val e = repo.addEntry(login("M", password = "keepme"))202        val r = repo.updateEntry(e.copy(title = "Renamed"))203        assertNull(r.passwordHistory)204    }205206    @Test fun password_history_item_carries_prior_age() = runBlocking {207        val repo = VaultRepository(FakeStorage())208        repo.createVault("pw")209        val e = repo.addEntry(login("S", password = "v1"))210        val firstChanged = e.passwordChangedAt!!211        Thread.sleep(2)212        val r = repo.updateEntry(e.copy(password = "v2"))213        assertEquals(firstChanged, r.passwordHistory!!.first().changedAt)214    }215216    @Test fun password_history_skips_identical_resave() = runBlocking {217        val repo = VaultRepository(FakeStorage())218        repo.createVault("pw")219        val e = repo.addEntry(login("N", password = "same"))220        val r = repo.updateEntry(e.copy(password = "same"))221        assertNull(r.passwordHistory)222    }223224    @Test fun password_history_restore_re_records_swapped_out_password() = runBlocking {225        val repo = VaultRepository(FakeStorage())226        repo.createVault("pw")227        val e = repo.addEntry(login("R", password = "old"))228        val v2 = repo.updateEntry(e.copy(password = "new"))229        val restored = repo.updateEntry(v2.copy(password = "old"))230        assertEquals("old", restored.password)231        assertEquals(listOf("new", "old"), restored.passwordHistory!!.map { it.password })232    }233234    @Test fun password_history_persists_across_lock_unlock() = runBlocking {235        val storage = FakeStorage()236        val repo = VaultRepository(storage)237        repo.createVault("pw")238        val e = repo.addEntry(login("P", password = "p1"))239        repo.updateEntry(e.copy(password = "p2"))240        repo.lock()241        assertEquals(UnlockResult.Success, repo.unlock("pw"))242        val reloaded = repo.getEntries().first { it.id == e.id }243        assertEquals(listOf("p1"), reloaded.passwordHistory!!.map { it.password })244    }245246    @Test fun password_history_caps_at_fifty() = runBlocking {247        val repo = VaultRepository(FakeStorage())248        repo.createVault("pw")249        var e = repo.addEntry(login("C", password = "pw-0"))250        for (i in 1..55) e = repo.updateEntry(e.copy(password = "pw-$i"))251        val hist = e.passwordHistory!!252        assertEquals(50, hist.size)253        // pw-55 is current; newest retired is pw-54, oldest kept is pw-5.254        assertEquals("pw-54", hist.first().password)255        assertEquals("pw-5", hist.last().password)256    }257258    @Test fun apply_merged_remote_does_not_bump_version() = runBlocking {259        val repo = VaultRepository(FakeStorage())260        repo.createVault("pw")261        repo.addEntry(login("Local"))262        val versionBefore = repo.getVaultVersion()263        val remote = VaultEntry(264            id = "remote-1", title = "Remote", type = "login", password = "x",265            createdAt = 1, modifiedAt = System.currentTimeMillis() + 10_000,266        )267        val applied = repo.applyMergedRemoteEntries(listOf(remote), emptyList())268        assertEquals(1, applied)269        assertEquals(2, repo.getEntries().size)270        // The whole point: a sync apply must NOT advance the local version.271        assertEquals(versionBefore, repo.getVaultVersion())272    }273274    @Test fun remote_tombstone_supersedes_older_local_entry() = runBlocking {275        val repo = VaultRepository(FakeStorage())276        repo.createVault("pw")277        val local = repo.addEntry(login("Shared"))278        val tomb = Tombstone(local.id, local.modifiedAt + 5_000)279        val applied = repo.applyMergedRemoteEntries(emptyList(), listOf(tomb))280        assertEquals(1, applied)281        assertEquals(0, repo.getEntries().size)282    }283284    @Test fun change_password_reencrypts_under_new_password() = runBlocking {285        val storage = FakeStorage()286        val repo = VaultRepository(storage)287        repo.createVault("old-pass")288        repo.addEntry(login("Acct"))289        assertTrue(repo.changePassword("old-pass", "new-pass"))290        repo.lock()291        assertEquals(UnlockResult.WrongPassword, repo.unlock("old-pass"))292        assertEquals(UnlockResult.Success, repo.unlock("new-pass"))293        assertEquals(1, repo.getEntries().size)294    }295296    // ---- A1: trash purge + retention auto-purge ---------------------------297298    @Test fun purge_deleted_entries_empties_trash() = runBlocking {299        val repo = VaultRepository(FakeStorage())300        repo.createVault("pw")301        val a = repo.addEntry(login("A"))302        val b = repo.addEntry(login("B"))303        repo.addEntry(login("C"))304        repo.deleteEntry(a.id)305        repo.deleteEntry(b.id)306        assertEquals(2, repo.getDeletedEntries().size)307        assertEquals(2, repo.purgeDeletedEntries())308        assertEquals(0, repo.getDeletedEntries().size)309        assertEquals(1, repo.getEntries().size)310    }311312    @Test fun auto_purge_removes_trash_older_than_retention() = runBlocking {313        val storage = FakeStorage()314        val repo = VaultRepository(storage)315        repo.createVault("pw")316        repo.updateSettings(repo.settings.value.copy(trashRetentionDays = 30))317        // Insert an ancient soft-deleted entry via the sync-merge path (deletedAt=1000).318        val ancient = VaultEntry(id = "old", title = "Old", type = "login", password = "x",319            createdAt = 1000, modifiedAt = 1000, deletedAt = 1000)320        repo.applyMergedRemoteEntries(listOf(ancient), emptyList())321        assertEquals(1, repo.getDeletedEntries().size)322        repo.lock()323        assertEquals(UnlockResult.Success, repo.unlock("pw"))324        assertEquals(0, repo.getDeletedEntries().size)325    }326327    @Test fun auto_purge_keeps_trash_when_retention_never() = runBlocking {328        val storage = FakeStorage()329        val repo = VaultRepository(storage)330        repo.createVault("pw") // default trashRetentionDays == null → "Never"331        val ancient = VaultEntry(id = "old", title = "Old", type = "login", password = "x",332            createdAt = 1000, modifiedAt = 1000, deletedAt = 1000)333        repo.applyMergedRemoteEntries(listOf(ancient), emptyList())334        repo.lock()335        assertEquals(UnlockResult.Success, repo.unlock("pw"))336        assertEquals(1, repo.getDeletedEntries().size)337    }338339    // ---- A2: tags ---------------------------------------------------------340341    @Test fun tags_distinct_sorted_lowercased() = runBlocking {342        val repo = VaultRepository(FakeStorage())343        repo.createVault("pw")344        repo.addEntry(login("A").copy(tags = listOf("work", "email")))345        repo.addEntry(login("B").copy(tags = listOf("Work", "social")))346        assertEquals(listOf("email", "social", "work"), repo.getDistinctTags())347    }348349    @Test fun get_active_entries_filters_by_tag() = runBlocking {350        val repo = VaultRepository(FakeStorage())351        repo.createVault("pw")352        repo.addEntry(login("A").copy(tags = listOf("work")))353        repo.addEntry(login("B").copy(tags = listOf("home")))354        val gone = repo.addEntry(login("C").copy(tags = listOf("work")))355        repo.deleteEntry(gone.id)356        val work = repo.getActiveEntries("work")357        assertEquals(1, work.size)358        assertEquals("A", work.first().title)359        assertEquals(2, repo.getActiveEntries(null).size)360    }361362    @Test fun tags_round_trip_across_lock_unlock() = runBlocking {363        val storage = FakeStorage()364        val repo = VaultRepository(storage)365        repo.createVault("pw")366        val e = repo.addEntry(login("T").copy(tags = listOf("alpha", "beta")))367        repo.lock()368        repo.unlock("pw")369        assertEquals(listOf("alpha", "beta"), repo.getEntries().first { it.id == e.id }.tags)370    }371372    // ---- A5: recovery-word metadata ---------------------------------------373374    @Test fun recovery_words_set_verify_and_persist() = runBlocking {375        val storage = FakeStorage()376        val repo = VaultRepository(storage)377        repo.createVault("pw")378        assertFalse(repo.isRecoveryWordsSet())379        val words = rootKeyToWords(ByteArray(32) { it.toByte() })380        assertTrue(repo.setRecoveryWords(words))381        assertTrue(repo.isRecoveryWordsSet())382        assertTrue(repo.verifyRecoveryWords(words))383        assertFalse(repo.verifyRecoveryWords(words.reversed()))384        repo.lock()385        assertEquals(UnlockResult.Success, repo.unlock("pw"))386        assertTrue(repo.isRecoveryWordsSet())387        assertTrue(repo.verifyRecoveryWords(words))388    }389390    @Test fun join_vault_takes_the_source_identity_keeps_entries_and_adopts_its_words() = runBlocking {391        // The source: a second, independent vault whose root key we will join.392        val sourceRepo = VaultRepository(FakeStorage())393        sourceRepo.createVault("source-pw")394        val sourceRootHex = sourceRepo.syncMasterKey()!!.let { key ->395            key.joinToString("") { "%02x".format(it) }.also { VaultCrypto.zero(key) }396        }397        val sourceVaultId = sourceRepo.syncVaultId()398        val sourceWords = sourceRepo.syncMasterKey()!!.let { key ->399            rootKeyToWords(key).also { VaultCrypto.zero(key) }400        }401402        // The joiner: our own vault, with an entry that must survive.403        val storage = FakeStorage()404        val repo = VaultRepository(storage)405        repo.createVault("pw")406        val mine = repo.addEntry(login("Bank"))407        val myWordsBefore = repo.syncMasterKey()!!.let { key ->408            rootKeyToWords(key).also { VaultCrypto.zero(key) }409        }410        assertTrue(repo.setRecoveryWords(myWordsBefore))411        assertTrue(repo.syncVaultId() != sourceVaultId)412413        assertEquals(sourceWords, repo.previewRecoveryWords(sourceRootHex))414415        val result = repo.joinVault(sourceRootHex)416        assertTrue(result is AdoptVaultResult.Success)417418        // Same vault now: this is what lets sync run at all (vault_id_mismatch).419        assertEquals(sourceVaultId, repo.syncVaultId())420        // Entries are sealed under the password key, so joining keeps them.421        assertEquals("Bank", repo.getEntries().first { it.id == mine.id }.title)422        // Words converge on the source's, and the old ones are dead.423        assertTrue(repo.verifyRecoveryWords(sourceWords))424        assertFalse(repo.verifyRecoveryWords(myWordsBefore))425        assertFalse(repo.isRecoveryWordsSet())426427        // Survives a lock/unlock under the joiner's own unchanged password.428        repo.lock()429        assertEquals(UnlockResult.Success, repo.unlock("pw"))430        assertEquals("Bank", repo.getEntries().first { it.id == mine.id }.title)431        assertEquals(sourceVaultId, repo.syncVaultId())432    }433434    @Test fun join_vault_refuses_a_malformed_root_key_and_changes_nothing() = runBlocking {435        val repo = VaultRepository(FakeStorage())436        repo.createVault("pw")437        val added = repo.addEntry(login("Bank"))438        val idBefore = repo.syncVaultId()439440        assertTrue(repo.joinVault("not-hex") is AdoptVaultResult.Error)441        assertTrue(repo.joinVault("ab".repeat(31)) is AdoptVaultResult.Error)442443        assertEquals(idBefore, repo.syncVaultId())444        assertEquals("Bank", repo.getEntries().first { it.id == added.id }.title)445    }446447    @Test fun rotate_sync_identity_changes_root_keeps_entries_and_requires_new_recovery_confirmation() = runBlocking {448        val storage = FakeStorage()449        val repo = VaultRepository(storage)450        repo.createVault("pw")451        val added = repo.addEntry(login("Bank"))452        val wordsBefore = repo.syncMasterKey()!!.let { key ->453            rootKeyToWords(key).also { VaultCrypto.zero(key) }454        }455        assertTrue(repo.setRecoveryWords(wordsBefore))456        val vaultIdBefore = repo.syncVaultId()457        val versionBefore = repo.getVaultVersion()458459        assertTrue(repo.rotateSyncIdentity())460461        val wordsAfter = repo.syncMasterKey()!!.let { key ->462            rootKeyToWords(key).also { VaultCrypto.zero(key) }463        }464        assertTrue(wordsAfter != wordsBefore)465        assertTrue(repo.syncVaultId() != vaultIdBefore)466        assertFalse(repo.isRecoveryWordsSet())467        assertFalse(repo.verifyRecoveryWords(wordsBefore))468        assertTrue(repo.verifyRecoveryWords(wordsAfter))469        assertTrue(storage.slotA?.recoveryEscrow != null)470        assertTrue(storage.slotA?.rootKeyEscrow != null)471        assertTrue(repo.getVaultVersion() > versionBefore)472        assertEquals("Bank", repo.getEntries().first { it.id == added.id }.title)473474        repo.lock()475        assertEquals(UnlockResult.Success, repo.unlock("pw"))476        assertEquals("Bank", repo.getEntries().first { it.id == added.id }.title)477        assertFalse(repo.isRecoveryWordsSet())478        assertTrue(repo.verifyRecoveryWords(wordsAfter))479    }480481    @Test fun destroy_clears_everything() = runBlocking {482        val storage = FakeStorage()483        val repo = VaultRepository(storage)484        repo.createVault("pw")485        repo.addEntry(login("X"))486        repo.destroyVault()487        assertFalse(repo.isUnlocked.value)488        assertNull(storage.slotA)489        assertFalse(repo.hasVault())490    }491492    // M-07: the transient-background suppression must expire so a stale flag493    // can never suppress a later real lock. Drives the clock via the visible-494    // for-testing seam in VaultRepository.495    @Test fun transient_background_suppression_expires() = runBlocking {496        val storage = FakeStorage()497        val repo = VaultRepository(storage)498        var fakeNow = 0L499        repo.nowMs = { fakeNow }500501        // Never set — must always report not-suppressed.502        assertFalse(repo.consumeTransientBackgroundSuppression())503504        // Set, still within the window — must report suppressed and clear.505        repo.expectTransientBackground()506        assertTrue(repo.consumeTransientBackgroundSuppression())507        assertFalse(repo.consumeTransientBackgroundSuppression())508509        // Set, then advance the clock past the 30s timeout — must report510        // not-suppressed (would-have-been-stale flag cannot suppress a real511        // background event) and clear.512        fakeNow = 0L513        repo.expectTransientBackground()514        fakeNow += 30_000L515        assertFalse(repo.consumeTransientBackgroundSuppression())516        assertFalse(repo.consumeTransientBackgroundSuppression())517    }518}