VaultRepositoryTest.kt

26.4 KB · raw

1package com.peachpasswords.vault23import kotlinx.coroutines.runBlocking4import org.junit.Assert.assertEquals5import org.junit.Assert.assertFalse6import org.junit.Assert.assertNotNull7import org.junit.Assert.assertNull8import org.junit.Assert.assertTrue9import org.junit.Test10import com.peachpasswords.sync.LockedSyncSpoolHooks1112/** In-memory [VaultStorage] so the repository can be exercised without Android. */13private class FakeStorage : VaultStorage {14    var slotA: StoredVault? = null15    var slotB: String? = null16    private var ver: Long = 017    private var settings = VaultSettings()18    override fun loadSlotA() = slotA19    override fun hasVault() = slotA != null20    override fun writeSlotA(slot: StoredVault): Boolean { slotA = slot; return true }21    override fun loadSlotB() = slotB22    override fun writeSlotB(blobBase64: String): Boolean { slotB = blobBase64; return true }23    var decoy: StoredVault? = null24    override fun loadDecoySlot() = decoy25    override fun writeDecoySlot(slot: StoredVault): Boolean { decoy = slot; return true }26    override fun hasDecoySlot() = decoy != null27    override fun clearDecoySlot() { decoy = null }28    override fun version() = ver29    override fun stampLocalMutation(): Long { ver = maxOf(System.currentTimeMillis(), ver + 1); return ver }30    override fun loadSettings() = settings31    override fun writeSettings(s: VaultSettings): Boolean { settings = s; return true }32    var history = VaultHistory()33    override fun loadVaultHistory() = history34    override fun writeVaultHistory(history: VaultHistory): Boolean { this.history = history; return true }35    override fun clearVaultHistory() { history = VaultHistory() }36    override fun clearVault(): Boolean { slotA = null; slotB = null; ver = 0; settings = VaultSettings(); history = VaultHistory(); return true }37}3839class VaultRepositoryTest {40    private fun login(title: String, password: String = "pw") =41        VaultEntry(id = "", title = title, type = "login", password = password, createdAt = 0, modifiedAt = 0)4243    private fun note(title: String) =44        VaultEntry(id = "", title = title, type = "note", createdAt = 0, modifiedAt = 0)4546    @Test fun local_entry_commit_requests_sync_but_remote_merge_does_not() = runBlocking {47        val repo = VaultRepository(FakeStorage())48        repo.createVault("pw")49        var requests = 050        VaultMutationHooks.onCommitted = { requests += 1 }51        try {52            repo.addEntry(login("Local"))53            assertEquals(1, requests)5455            repo.applyMergedRemoteEntries(56                listOf(57                    login("Remote").copy(58                        id = "remote-1",59                        createdAt = 1,60                        modifiedAt = System.currentTimeMillis() + 10_000,61                    ),62                ),63                emptyList(),64            )65            assertEquals(1, requests)66        } finally {67            VaultMutationHooks.onCommitted = {}68        }69    }7071    @Test fun full_lock_offers_committed_state_to_opaque_sync_spool_before_zeroing() = runBlocking {72        val repo = VaultRepository(FakeStorage())73        repo.createVault("pw")74        repo.addEntry(login("GitHub", "secret"))75        var observedRoot = false76        var observedTitles = emptyList<String>()77        LockedSyncSpoolHooks.refresh = { current ->78            val root = current.syncMasterKey()79            observedRoot = root != null80            root?.let(VaultCrypto::zero)81            observedTitles = current.getEntries().map { it.title }82        }83        try {84            repo.lock()85            assertTrue(observedRoot)86            assertEquals(listOf("GitHub"), observedTitles)87            assertFalse(repo.isUnlocked.value)88            assertNull(repo.syncMasterKey())89            assertTrue(repo.getEntries().isEmpty())90        } finally {91            LockedSyncSpoolHooks.refresh = {}92        }93    }9495    @Test fun create_persists_and_unlocks() = runBlocking {96        val storage = FakeStorage()97        val repo = VaultRepository(storage)98        assertFalse(repo.hasVault())99        assertEquals(CreateVaultResult.Success, repo.createVault("correct horse"))100        assertTrue(repo.hasVault())101        assertTrue(repo.isUnlocked.value)102        // Both slots written, version stamped.103        assertTrue(storage.slotA != null && storage.slotB != null)104        assertTrue(repo.getVaultVersion() > 0)105    }106107    @Test fun entries_persist_across_lock_unlock() = runBlocking {108        val storage = FakeStorage()109        val repo = VaultRepository(storage)110        repo.createVault("pw")111        val added = repo.addEntry(login("GitHub"))112        assertTrue(added.id.isNotBlank())113        assertEquals(1, repo.getEntries().size)114        repo.lock()115        assertFalse(repo.isUnlocked.value)116        assertEquals(UnlockResult.Success, repo.unlock("pw"))117        assertEquals(1, repo.getEntries().size)118        assertEquals("GitHub", repo.getEntries().first().title)119    }120121    @Test fun wrong_password_is_rejected() = runBlocking {122        val storage = FakeStorage()123        val repo = VaultRepository(storage)124        repo.createVault("right")125        repo.lock()126        assertEquals(UnlockResult.WrongPassword, repo.unlock("wrong"))127        assertFalse(repo.isUnlocked.value)128    }129130    @Test fun delete_writes_tombstone_then_purge_removes() = runBlocking {131        val repo = VaultRepository(FakeStorage())132        repo.createVault("pw")133        val e = repo.addEntry(login("Bank"))134        repo.deleteEntry(e.id)135        assertEquals(0, repo.getEntries().size)136        assertEquals(1, repo.getTombstones().size)137        assertEquals(e.id, repo.getTombstones().first().id)138        repo.restoreEntry(e.id)139        assertEquals(1, repo.getEntries().size)140        repo.deleteEntry(e.id)141        repo.purgeEntry(e.id)142        assertEquals(0, repo.getTombstones().size)143    }144145    @Test fun duplicate_count_matches_browser_keys() = runBlocking {146        val repo = VaultRepository(FakeStorage())147        repo.createVault("pw")148        repo.addEntry(login("GitHub").copy(url = "https://github.com", username = "june"))149        repo.addEntry(login("github").copy(url = "https://github.com", username = "june"))150        repo.addEntry(login("GitHub").copy(url = "https://github.com", username = "other"))151        repo.addEntry(note("Ideas"))152        repo.addEntry(note("ideas"))153154        assertEquals(2, repo.countDuplicateEntries())155    }156157    @Test fun deduplicate_moves_older_duplicates_to_trash() = runBlocking {158        val repo = VaultRepository(FakeStorage())159        repo.createVault("pw")160        val older = repo.addEntry(login("Bank").copy(url = "https://bank.test", username = "j"))161        Thread.sleep(2)162        val newer = repo.addEntry(login("bank").copy(url = "https://bank.test", username = "j"))163164        assertEquals(1, repo.deduplicateEntries())165166        assertEquals(listOf(newer.id), repo.getEntries().map { it.id })167        assertEquals(listOf(older.id), repo.getDeletedEntries().map { it.id })168    }169170    @Test fun vault_history_snapshots_before_local_writes_and_caps() = runBlocking {171        val repo = VaultRepository(FakeStorage())172        repo.createVault("pw")173174        repeat(7) { i ->175            repo.addEntry(login("Entry $i"))176            Thread.sleep(1)177        }178179        val history = repo.getVaultHistory()180        assertEquals(5, history.size)181        assertEquals("local_save", history.first().reason)182        assertEquals(10, history.first().entryCount)183    }184185    @Test fun restoring_vault_snapshot_replaces_slot_and_locks() = runBlocking {186        val repo = VaultRepository(FakeStorage())187        repo.createVault("pw")188        val first = repo.addEntry(login("First"))189        repo.addEntry(login("Second"))190        val snapshot = repo.getVaultHistory().first()191192        assertTrue(repo.restoreVaultSnapshot(snapshot.id))193        assertFalse(repo.isUnlocked.value)194        assertEquals(UnlockResult.Success, repo.unlock("pw"))195196        assertEquals(listOf(first.id), repo.getEntries().map { it.id })197        assertEquals("restore", repo.getVaultHistory().first().reason)198    }199200    @Test fun update_refreshes_password_changed_only_on_password_change() = runBlocking {201        val repo = VaultRepository(FakeStorage())202        repo.createVault("pw")203        val e = repo.addEntry(login("Mail", password = "old"))204        val firstChanged = e.passwordChangedAt!!205        Thread.sleep(2)206        val metaOnly = repo.updateEntry(e.copy(title = "Mail2", password = "old"))207        assertEquals(firstChanged, metaOnly.passwordChangedAt)208        Thread.sleep(2)209        val pwChange = repo.updateEntry(metaOnly.copy(password = "new"))210        assertTrue(pwChange.passwordChangedAt!! > firstChanged)211    }212213    @Test fun password_history_captures_old_password_newest_first() = runBlocking {214        val repo = VaultRepository(FakeStorage())215        repo.createVault("pw")216        val e = repo.addEntry(login("H", password = "first"))217        assertNull(e.passwordHistory)218        val v2 = repo.updateEntry(e.copy(password = "second"))219        val v3 = repo.updateEntry(v2.copy(password = "third"))220        assertEquals(listOf("second", "first"), v3.passwordHistory!!.map { it.password })221        assertEquals("third", v3.password)222    }223224    @Test fun password_history_ignores_metadata_only_update() = runBlocking {225        val repo = VaultRepository(FakeStorage())226        repo.createVault("pw")227        val e = repo.addEntry(login("M", password = "keepme"))228        val r = repo.updateEntry(e.copy(title = "Renamed"))229        assertNull(r.passwordHistory)230    }231232    @Test fun password_history_item_carries_prior_age() = runBlocking {233        val repo = VaultRepository(FakeStorage())234        repo.createVault("pw")235        val e = repo.addEntry(login("S", password = "v1"))236        val firstChanged = e.passwordChangedAt!!237        Thread.sleep(2)238        val r = repo.updateEntry(e.copy(password = "v2"))239        assertEquals(firstChanged, r.passwordHistory!!.first().changedAt)240    }241242    @Test fun password_history_skips_identical_resave() = runBlocking {243        val repo = VaultRepository(FakeStorage())244        repo.createVault("pw")245        val e = repo.addEntry(login("N", password = "same"))246        val r = repo.updateEntry(e.copy(password = "same"))247        assertNull(r.passwordHistory)248    }249250    @Test fun password_history_restore_re_records_swapped_out_password() = runBlocking {251        val repo = VaultRepository(FakeStorage())252        repo.createVault("pw")253        val e = repo.addEntry(login("R", password = "old"))254        val v2 = repo.updateEntry(e.copy(password = "new"))255        val restored = repo.updateEntry(v2.copy(password = "old"))256        assertEquals("old", restored.password)257        assertEquals(listOf("new", "old"), restored.passwordHistory!!.map { it.password })258    }259260    @Test fun password_history_persists_across_lock_unlock() = runBlocking {261        val storage = FakeStorage()262        val repo = VaultRepository(storage)263        repo.createVault("pw")264        val e = repo.addEntry(login("P", password = "p1"))265        repo.updateEntry(e.copy(password = "p2"))266        repo.lock()267        assertEquals(UnlockResult.Success, repo.unlock("pw"))268        val reloaded = repo.getEntries().first { it.id == e.id }269        assertEquals(listOf("p1"), reloaded.passwordHistory!!.map { it.password })270    }271272    @Test fun password_history_caps_at_fifty() = runBlocking {273        val repo = VaultRepository(FakeStorage())274        repo.createVault("pw")275        var e = repo.addEntry(login("C", password = "pw-0"))276        for (i in 1..55) e = repo.updateEntry(e.copy(password = "pw-$i"))277        val hist = e.passwordHistory!!278        assertEquals(50, hist.size)279        // pw-55 is current; newest retired is pw-54, oldest kept is pw-5.280        assertEquals("pw-54", hist.first().password)281        assertEquals("pw-5", hist.last().password)282    }283284    @Test fun apply_merged_remote_does_not_bump_version() = runBlocking {285        val repo = VaultRepository(FakeStorage())286        repo.createVault("pw")287        repo.addEntry(login("Local"))288        val versionBefore = repo.getVaultVersion()289        val remote = VaultEntry(290            id = "remote-1", title = "Remote", type = "login", password = "x",291            createdAt = 1, modifiedAt = System.currentTimeMillis() + 10_000,292        )293        val applied = repo.applyMergedRemoteEntries(listOf(remote), emptyList())294        assertEquals(1, applied)295        assertEquals(2, repo.getEntries().size)296        // The whole point: a sync apply must NOT advance the local version.297        assertEquals(versionBefore, repo.getVaultVersion())298    }299300    /**301     * The entry-id namespace. A Peach id is a UUID, but a restored or imported302     * file brings ids from whoever wrote it and no reader bounds them, so an id303     * that is not a well-formed Peach id is replaced at the import seam rather304     * than trusted. Minting costs at most one duplicate after a cross-device305     * restore; trusting one used to be able to wedge sync on both platforms.306     */307    @Test fun import_keeps_a_well_formed_foreign_id() = runBlocking {308        val repo = VaultRepository(FakeStorage())309        repo.createVault("pw")310        val fromAnotherPeach = "b1f0c8e2-4a6d-4f1a-9c3e-7d5b2a8e1f40"311        repo.addEntries(312            listOf(313                login("Bitwarden").copy(id = fromAnotherPeach),314                login("OnePassword").copy(id = "sj4kd9f2q7m1x8zb3nv6ct0h"),315            ),316        )317        assertEquals(318            setOf(fromAnotherPeach, "sj4kd9f2q7m1x8zb3nv6ct0h"),319            repo.getEntries().mapTo(mutableSetOf()) { it.id },320        )321    }322323    @Test fun import_mints_a_fresh_id_when_the_incoming_one_is_not_a_peach_id() = runBlocking {324        val repo = VaultRepository(FakeStorage())325        repo.createVault("pw")326        repo.addEntries(327            listOf(328                login("Too long").copy(id = "x".repeat(200)),329                login("Not an id").copy(id = "a b\"c\n{\"injected\":true}"),330            ),331        )332        val minted = repo.getEntries().map { it.id }333        assertEquals("both entries survive with fresh ids", 2, minted.toSet().size)334        for (id in minted) {335            assertEquals("a minted id is a UUID", 36, id.length)336            assertNotNull(java.util.UUID.fromString(id))337        }338    }339340    /** The seam is the import path only — reminting here would fork identity. */341    @Test fun sync_still_adopts_a_peer_id_verbatim() = runBlocking {342        val repo = VaultRepository(FakeStorage())343        repo.createVault("pw")344        val peerId = "peer-" + "x".repeat(120)345        repo.applyMergedRemoteEntries(346            listOf(347                VaultEntry(348                    id = peerId, title = "Remote", type = "login", password = "x",349                    createdAt = 1, modifiedAt = System.currentTimeMillis(),350                ),351            ),352            emptyList(),353        )354        assertEquals(peerId, repo.getEntries().single().id)355    }356357    @Test fun remote_tombstone_supersedes_older_local_entry() = runBlocking {358        val repo = VaultRepository(FakeStorage())359        repo.createVault("pw")360        val local = repo.addEntry(login("Shared"))361        val tomb = Tombstone(local.id, local.modifiedAt + 5_000)362        val applied = repo.applyMergedRemoteEntries(emptyList(), listOf(tomb))363        assertEquals(1, applied)364        assertEquals(0, repo.getEntries().size)365    }366367    @Test fun change_password_reencrypts_under_new_password() = runBlocking {368        val storage = FakeStorage()369        val repo = VaultRepository(storage)370        repo.createVault("old-pass")371        repo.addEntry(login("Acct"))372        assertTrue(repo.changePassword("old-pass", "new-pass"))373        repo.lock()374        assertEquals(UnlockResult.WrongPassword, repo.unlock("old-pass"))375        assertEquals(UnlockResult.Success, repo.unlock("new-pass"))376        assertEquals(1, repo.getEntries().size)377    }378379    // ---- A1: trash purge + retention auto-purge ---------------------------380381    @Test fun purge_deleted_entries_empties_trash() = runBlocking {382        val repo = VaultRepository(FakeStorage())383        repo.createVault("pw")384        val a = repo.addEntry(login("A"))385        val b = repo.addEntry(login("B"))386        repo.addEntry(login("C"))387        repo.deleteEntry(a.id)388        repo.deleteEntry(b.id)389        assertEquals(2, repo.getDeletedEntries().size)390        assertEquals(2, repo.purgeDeletedEntries())391        assertEquals(0, repo.getDeletedEntries().size)392        assertEquals(1, repo.getEntries().size)393    }394395    @Test fun auto_purge_removes_trash_older_than_retention() = runBlocking {396        val storage = FakeStorage()397        val repo = VaultRepository(storage)398        repo.createVault("pw")399        repo.updateSettings(repo.settings.value.copy(trashRetentionDays = 30))400        // Insert an ancient soft-deleted entry via the sync-merge path (deletedAt=1000).401        val ancient = VaultEntry(id = "old", title = "Old", type = "login", password = "x",402            createdAt = 1000, modifiedAt = 1000, deletedAt = 1000)403        repo.applyMergedRemoteEntries(listOf(ancient), emptyList())404        assertEquals(1, repo.getDeletedEntries().size)405        repo.lock()406        assertEquals(UnlockResult.Success, repo.unlock("pw"))407        assertEquals(0, repo.getDeletedEntries().size)408    }409410    @Test fun auto_purge_keeps_trash_when_retention_never() = runBlocking {411        val storage = FakeStorage()412        val repo = VaultRepository(storage)413        repo.createVault("pw") // default trashRetentionDays == null → "Never"414        val ancient = VaultEntry(id = "old", title = "Old", type = "login", password = "x",415            createdAt = 1000, modifiedAt = 1000, deletedAt = 1000)416        repo.applyMergedRemoteEntries(listOf(ancient), emptyList())417        repo.lock()418        assertEquals(UnlockResult.Success, repo.unlock("pw"))419        assertEquals(1, repo.getDeletedEntries().size)420    }421422    // ---- A2: tags ---------------------------------------------------------423424    @Test fun tags_distinct_sorted_lowercased() = runBlocking {425        val repo = VaultRepository(FakeStorage())426        repo.createVault("pw")427        repo.addEntry(login("A").copy(tags = listOf("work", "email")))428        repo.addEntry(login("B").copy(tags = listOf("Work", "social")))429        assertEquals(listOf("email", "social", "work"), repo.getDistinctTags())430    }431432    @Test fun get_active_entries_filters_by_tag() = runBlocking {433        val repo = VaultRepository(FakeStorage())434        repo.createVault("pw")435        repo.addEntry(login("A").copy(tags = listOf("work")))436        repo.addEntry(login("B").copy(tags = listOf("home")))437        val gone = repo.addEntry(login("C").copy(tags = listOf("work")))438        repo.deleteEntry(gone.id)439        val work = repo.getActiveEntries("work")440        assertEquals(1, work.size)441        assertEquals("A", work.first().title)442        assertEquals(2, repo.getActiveEntries(null).size)443    }444445    @Test fun tags_round_trip_across_lock_unlock() = runBlocking {446        val storage = FakeStorage()447        val repo = VaultRepository(storage)448        repo.createVault("pw")449        val e = repo.addEntry(login("T").copy(tags = listOf("alpha", "beta")))450        repo.lock()451        repo.unlock("pw")452        assertEquals(listOf("alpha", "beta"), repo.getEntries().first { it.id == e.id }.tags)453    }454455    // ---- A5: recovery-word metadata ---------------------------------------456457    @Test fun recovery_words_set_verify_and_persist() = runBlocking {458        val storage = FakeStorage()459        val repo = VaultRepository(storage)460        repo.createVault("pw")461        assertFalse(repo.isRecoveryWordsSet())462        val words = rootKeyToWords(ByteArray(32) { it.toByte() })463        assertTrue(repo.setRecoveryWords(words))464        assertTrue(repo.isRecoveryWordsSet())465        assertTrue(repo.verifyRecoveryWords(words))466        assertFalse(repo.verifyRecoveryWords(words.reversed()))467        repo.lock()468        assertEquals(UnlockResult.Success, repo.unlock("pw"))469        assertTrue(repo.isRecoveryWordsSet())470        assertTrue(repo.verifyRecoveryWords(words))471    }472473    @Test fun join_vault_takes_the_source_identity_keeps_entries_and_adopts_its_words() = runBlocking {474        // The source: a second, independent vault whose root key we will join.475        val sourceRepo = VaultRepository(FakeStorage())476        sourceRepo.createVault("source-pw")477        val sourceRootHex = sourceRepo.syncMasterKey()!!.let { key ->478            key.joinToString("") { "%02x".format(it) }.also { VaultCrypto.zero(key) }479        }480        val sourceVaultId = sourceRepo.syncVaultId()481        val sourceWords = sourceRepo.syncMasterKey()!!.let { key ->482            rootKeyToWords(key).also { VaultCrypto.zero(key) }483        }484485        // The joiner: our own vault, with an entry that must survive.486        val storage = FakeStorage()487        val repo = VaultRepository(storage)488        repo.createVault("pw")489        val mine = repo.addEntry(login("Bank"))490        val myWordsBefore = repo.syncMasterKey()!!.let { key ->491            rootKeyToWords(key).also { VaultCrypto.zero(key) }492        }493        assertTrue(repo.setRecoveryWords(myWordsBefore))494        assertTrue(repo.syncVaultId() != sourceVaultId)495496        assertEquals(sourceWords, repo.previewRecoveryWords(sourceRootHex))497498        val result = repo.joinVault(sourceRootHex)499        assertTrue(result is AdoptVaultResult.Success)500501        // Same vault now: this is what lets sync run at all (vault_id_mismatch).502        assertEquals(sourceVaultId, repo.syncVaultId())503        // Entries are sealed under the password key, so joining keeps them.504        assertEquals("Bank", repo.getEntries().first { it.id == mine.id }.title)505        // Words converge on the source's, and the old ones are dead.506        assertTrue(repo.verifyRecoveryWords(sourceWords))507        assertFalse(repo.verifyRecoveryWords(myWordsBefore))508        assertFalse(repo.isRecoveryWordsSet())509510        // Survives a lock/unlock under the joiner's own unchanged password.511        repo.lock()512        assertEquals(UnlockResult.Success, repo.unlock("pw"))513        assertEquals("Bank", repo.getEntries().first { it.id == mine.id }.title)514        assertEquals(sourceVaultId, repo.syncVaultId())515    }516517    @Test fun join_vault_refuses_a_malformed_root_key_and_changes_nothing() = runBlocking {518        val repo = VaultRepository(FakeStorage())519        repo.createVault("pw")520        val added = repo.addEntry(login("Bank"))521        val idBefore = repo.syncVaultId()522523        assertTrue(repo.joinVault("not-hex") is AdoptVaultResult.Error)524        assertTrue(repo.joinVault("ab".repeat(31)) is AdoptVaultResult.Error)525526        assertEquals(idBefore, repo.syncVaultId())527        assertEquals("Bank", repo.getEntries().first { it.id == added.id }.title)528    }529530    @Test fun rotate_sync_identity_changes_root_keeps_entries_and_requires_new_recovery_confirmation() = runBlocking {531        val storage = FakeStorage()532        val repo = VaultRepository(storage)533        repo.createVault("pw")534        val added = repo.addEntry(login("Bank"))535        val wordsBefore = repo.syncMasterKey()!!.let { key ->536            rootKeyToWords(key).also { VaultCrypto.zero(key) }537        }538        assertTrue(repo.setRecoveryWords(wordsBefore))539        val vaultIdBefore = repo.syncVaultId()540        val versionBefore = repo.getVaultVersion()541542        assertTrue(repo.rotateSyncIdentity())543544        val wordsAfter = repo.syncMasterKey()!!.let { key ->545            rootKeyToWords(key).also { VaultCrypto.zero(key) }546        }547        assertTrue(wordsAfter != wordsBefore)548        assertTrue(repo.syncVaultId() != vaultIdBefore)549        assertFalse(repo.isRecoveryWordsSet())550        assertFalse(repo.verifyRecoveryWords(wordsBefore))551        assertTrue(repo.verifyRecoveryWords(wordsAfter))552        assertTrue(storage.slotA?.recoveryEscrow != null)553        assertTrue(storage.slotA?.rootKeyEscrow != null)554        assertTrue(repo.getVaultVersion() > versionBefore)555        assertEquals("Bank", repo.getEntries().first { it.id == added.id }.title)556557        repo.lock()558        assertEquals(UnlockResult.Success, repo.unlock("pw"))559        assertEquals("Bank", repo.getEntries().first { it.id == added.id }.title)560        assertFalse(repo.isRecoveryWordsSet())561        assertTrue(repo.verifyRecoveryWords(wordsAfter))562    }563564    @Test fun destroy_clears_everything() = runBlocking {565        val storage = FakeStorage()566        val repo = VaultRepository(storage)567        repo.createVault("pw")568        repo.addEntry(login("X"))569        repo.destroyVault()570        assertFalse(repo.isUnlocked.value)571        assertNull(storage.slotA)572        assertFalse(repo.hasVault())573    }574575    // M-07: the transient-background suppression must expire so a stale flag576    // can never suppress a later real lock. Drives the clock via the visible-577    // for-testing seam in VaultRepository.578    @Test fun transient_background_suppression_expires() = runBlocking {579        val storage = FakeStorage()580        val repo = VaultRepository(storage)581        var fakeNow = 0L582        repo.nowMs = { fakeNow }583584        // Never set — must always report not-suppressed.585        assertFalse(repo.consumeTransientBackgroundSuppression())586587        // Set, still within the window — must report suppressed and clear.588        repo.expectTransientBackground()589        assertTrue(repo.consumeTransientBackgroundSuppression())590        assertFalse(repo.consumeTransientBackgroundSuppression())591592        // Set, then advance the clock past the 30s timeout — must report593        // not-suppressed (would-have-been-stale flag cannot suppress a real594        // background event) and clear.595        fakeNow = 0L596        repo.expectTransientBackground()597        fakeNow += 30_000L598        assertFalse(repo.consumeTransientBackgroundSuppression())599        assertFalse(repo.consumeTransientBackgroundSuppression())600    }601602    /**603     * Appearance lives in plain prefs rather than the sealed vault, so it must be604     * readable while locked: otherwise the lock screen renders at the defaults and605     * snaps to the user's real choice the instant they authenticate.606     */607    @Test fun persisted_settings_are_read_before_unlock() = runBlocking {608        val storage = FakeStorage()609        VaultRepository(storage).createVault("pw")610        storage.writeSettings(VaultSettings(uiScale = "large"))611612        val restarted = VaultRepository(storage)613614        assertFalse(restarted.isUnlocked.value)615        assertEquals("large", restarted.settings.value.uiScale)616    }617618    @Test fun persisted_settings_survive_locking() = runBlocking {619        val storage = FakeStorage()620        val repo = VaultRepository(storage)621        repo.createVault("pw")622        repo.updateSettings(repo.settings.value.copy(uiScale = "large"))623624        repo.lock()625626        assertEquals("large", repo.settings.value.uiScale)627    }628}