VaultRepositoryTest.kt

25.6 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() { slotA = null; slotB = null; ver = 0; settings = VaultSettings(); history = VaultHistory() }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 full_lock_offers_committed_state_to_opaque_sync_spool_before_zeroing() = runBlocking {47        val repo = VaultRepository(FakeStorage())48        repo.createVault("pw")49        repo.addEntry(login("GitHub", "secret"))50        var observedRoot = false51        var observedTitles = emptyList<String>()52        LockedSyncSpoolHooks.refresh = { current ->53            val root = current.syncMasterKey()54            observedRoot = root != null55            root?.let(VaultCrypto::zero)56            observedTitles = current.getEntries().map { it.title }57        }58        try {59            repo.lock()60            assertTrue(observedRoot)61            assertEquals(listOf("GitHub"), observedTitles)62            assertFalse(repo.isUnlocked.value)63            assertNull(repo.syncMasterKey())64            assertTrue(repo.getEntries().isEmpty())65        } finally {66            LockedSyncSpoolHooks.refresh = {}67        }68    }6970    @Test fun create_persists_and_unlocks() = runBlocking {71        val storage = FakeStorage()72        val repo = VaultRepository(storage)73        assertFalse(repo.hasVault())74        assertEquals(CreateVaultResult.Success, repo.createVault("correct horse"))75        assertTrue(repo.hasVault())76        assertTrue(repo.isUnlocked.value)77        // Both slots written, version stamped.78        assertTrue(storage.slotA != null && storage.slotB != null)79        assertTrue(repo.getVaultVersion() > 0)80    }8182    @Test fun entries_persist_across_lock_unlock() = runBlocking {83        val storage = FakeStorage()84        val repo = VaultRepository(storage)85        repo.createVault("pw")86        val added = repo.addEntry(login("GitHub"))87        assertTrue(added.id.isNotBlank())88        assertEquals(1, repo.getEntries().size)89        repo.lock()90        assertFalse(repo.isUnlocked.value)91        assertEquals(UnlockResult.Success, repo.unlock("pw"))92        assertEquals(1, repo.getEntries().size)93        assertEquals("GitHub", repo.getEntries().first().title)94    }9596    @Test fun wrong_password_is_rejected() = runBlocking {97        val storage = FakeStorage()98        val repo = VaultRepository(storage)99        repo.createVault("right")100        repo.lock()101        assertEquals(UnlockResult.WrongPassword, repo.unlock("wrong"))102        assertFalse(repo.isUnlocked.value)103    }104105    @Test fun delete_writes_tombstone_then_purge_removes() = runBlocking {106        val repo = VaultRepository(FakeStorage())107        repo.createVault("pw")108        val e = repo.addEntry(login("Bank"))109        repo.deleteEntry(e.id)110        assertEquals(0, repo.getEntries().size)111        assertEquals(1, repo.getTombstones().size)112        assertEquals(e.id, repo.getTombstones().first().id)113        repo.restoreEntry(e.id)114        assertEquals(1, repo.getEntries().size)115        repo.deleteEntry(e.id)116        repo.purgeEntry(e.id)117        assertEquals(0, repo.getTombstones().size)118    }119120    @Test fun duplicate_count_matches_browser_keys() = runBlocking {121        val repo = VaultRepository(FakeStorage())122        repo.createVault("pw")123        repo.addEntry(login("GitHub").copy(url = "https://github.com", username = "june"))124        repo.addEntry(login("github").copy(url = "https://github.com", username = "june"))125        repo.addEntry(login("GitHub").copy(url = "https://github.com", username = "other"))126        repo.addEntry(note("Ideas"))127        repo.addEntry(note("ideas"))128129        assertEquals(2, repo.countDuplicateEntries())130    }131132    @Test fun deduplicate_moves_older_duplicates_to_trash() = runBlocking {133        val repo = VaultRepository(FakeStorage())134        repo.createVault("pw")135        val older = repo.addEntry(login("Bank").copy(url = "https://bank.test", username = "j"))136        Thread.sleep(2)137        val newer = repo.addEntry(login("bank").copy(url = "https://bank.test", username = "j"))138139        assertEquals(1, repo.deduplicateEntries())140141        assertEquals(listOf(newer.id), repo.getEntries().map { it.id })142        assertEquals(listOf(older.id), repo.getDeletedEntries().map { it.id })143    }144145    @Test fun vault_history_snapshots_before_local_writes_and_caps() = runBlocking {146        val repo = VaultRepository(FakeStorage())147        repo.createVault("pw")148149        repeat(7) { i ->150            repo.addEntry(login("Entry $i"))151            Thread.sleep(1)152        }153154        val history = repo.getVaultHistory()155        assertEquals(5, history.size)156        assertEquals("local_save", history.first().reason)157        assertEquals(10, history.first().entryCount)158    }159160    @Test fun restoring_vault_snapshot_replaces_slot_and_locks() = runBlocking {161        val repo = VaultRepository(FakeStorage())162        repo.createVault("pw")163        val first = repo.addEntry(login("First"))164        repo.addEntry(login("Second"))165        val snapshot = repo.getVaultHistory().first()166167        assertTrue(repo.restoreVaultSnapshot(snapshot.id))168        assertFalse(repo.isUnlocked.value)169        assertEquals(UnlockResult.Success, repo.unlock("pw"))170171        assertEquals(listOf(first.id), repo.getEntries().map { it.id })172        assertEquals("restore", repo.getVaultHistory().first().reason)173    }174175    @Test fun update_refreshes_password_changed_only_on_password_change() = runBlocking {176        val repo = VaultRepository(FakeStorage())177        repo.createVault("pw")178        val e = repo.addEntry(login("Mail", password = "old"))179        val firstChanged = e.passwordChangedAt!!180        Thread.sleep(2)181        val metaOnly = repo.updateEntry(e.copy(title = "Mail2", password = "old"))182        assertEquals(firstChanged, metaOnly.passwordChangedAt)183        Thread.sleep(2)184        val pwChange = repo.updateEntry(metaOnly.copy(password = "new"))185        assertTrue(pwChange.passwordChangedAt!! > firstChanged)186    }187188    @Test fun password_history_captures_old_password_newest_first() = runBlocking {189        val repo = VaultRepository(FakeStorage())190        repo.createVault("pw")191        val e = repo.addEntry(login("H", password = "first"))192        assertNull(e.passwordHistory)193        val v2 = repo.updateEntry(e.copy(password = "second"))194        val v3 = repo.updateEntry(v2.copy(password = "third"))195        assertEquals(listOf("second", "first"), v3.passwordHistory!!.map { it.password })196        assertEquals("third", v3.password)197    }198199    @Test fun password_history_ignores_metadata_only_update() = runBlocking {200        val repo = VaultRepository(FakeStorage())201        repo.createVault("pw")202        val e = repo.addEntry(login("M", password = "keepme"))203        val r = repo.updateEntry(e.copy(title = "Renamed"))204        assertNull(r.passwordHistory)205    }206207    @Test fun password_history_item_carries_prior_age() = runBlocking {208        val repo = VaultRepository(FakeStorage())209        repo.createVault("pw")210        val e = repo.addEntry(login("S", password = "v1"))211        val firstChanged = e.passwordChangedAt!!212        Thread.sleep(2)213        val r = repo.updateEntry(e.copy(password = "v2"))214        assertEquals(firstChanged, r.passwordHistory!!.first().changedAt)215    }216217    @Test fun password_history_skips_identical_resave() = runBlocking {218        val repo = VaultRepository(FakeStorage())219        repo.createVault("pw")220        val e = repo.addEntry(login("N", password = "same"))221        val r = repo.updateEntry(e.copy(password = "same"))222        assertNull(r.passwordHistory)223    }224225    @Test fun password_history_restore_re_records_swapped_out_password() = runBlocking {226        val repo = VaultRepository(FakeStorage())227        repo.createVault("pw")228        val e = repo.addEntry(login("R", password = "old"))229        val v2 = repo.updateEntry(e.copy(password = "new"))230        val restored = repo.updateEntry(v2.copy(password = "old"))231        assertEquals("old", restored.password)232        assertEquals(listOf("new", "old"), restored.passwordHistory!!.map { it.password })233    }234235    @Test fun password_history_persists_across_lock_unlock() = runBlocking {236        val storage = FakeStorage()237        val repo = VaultRepository(storage)238        repo.createVault("pw")239        val e = repo.addEntry(login("P", password = "p1"))240        repo.updateEntry(e.copy(password = "p2"))241        repo.lock()242        assertEquals(UnlockResult.Success, repo.unlock("pw"))243        val reloaded = repo.getEntries().first { it.id == e.id }244        assertEquals(listOf("p1"), reloaded.passwordHistory!!.map { it.password })245    }246247    @Test fun password_history_caps_at_fifty() = runBlocking {248        val repo = VaultRepository(FakeStorage())249        repo.createVault("pw")250        var e = repo.addEntry(login("C", password = "pw-0"))251        for (i in 1..55) e = repo.updateEntry(e.copy(password = "pw-$i"))252        val hist = e.passwordHistory!!253        assertEquals(50, hist.size)254        // pw-55 is current; newest retired is pw-54, oldest kept is pw-5.255        assertEquals("pw-54", hist.first().password)256        assertEquals("pw-5", hist.last().password)257    }258259    @Test fun apply_merged_remote_does_not_bump_version() = runBlocking {260        val repo = VaultRepository(FakeStorage())261        repo.createVault("pw")262        repo.addEntry(login("Local"))263        val versionBefore = repo.getVaultVersion()264        val remote = VaultEntry(265            id = "remote-1", title = "Remote", type = "login", password = "x",266            createdAt = 1, modifiedAt = System.currentTimeMillis() + 10_000,267        )268        val applied = repo.applyMergedRemoteEntries(listOf(remote), emptyList())269        assertEquals(1, applied)270        assertEquals(2, repo.getEntries().size)271        // The whole point: a sync apply must NOT advance the local version.272        assertEquals(versionBefore, repo.getVaultVersion())273    }274275    /**276     * The entry-id namespace. A Peach id is a UUID, but a restored or imported277     * file brings ids from whoever wrote it and no reader bounds them, so an id278     * that is not a well-formed Peach id is replaced at the import seam rather279     * than trusted. Minting costs at most one duplicate after a cross-device280     * restore; trusting one used to be able to wedge sync on both platforms.281     */282    @Test fun import_keeps_a_well_formed_foreign_id() = runBlocking {283        val repo = VaultRepository(FakeStorage())284        repo.createVault("pw")285        val fromAnotherPeach = "b1f0c8e2-4a6d-4f1a-9c3e-7d5b2a8e1f40"286        repo.addEntries(287            listOf(288                login("Bitwarden").copy(id = fromAnotherPeach),289                login("OnePassword").copy(id = "sj4kd9f2q7m1x8zb3nv6ct0h"),290            ),291        )292        assertEquals(293            setOf(fromAnotherPeach, "sj4kd9f2q7m1x8zb3nv6ct0h"),294            repo.getEntries().mapTo(mutableSetOf()) { it.id },295        )296    }297298    @Test fun import_mints_a_fresh_id_when_the_incoming_one_is_not_a_peach_id() = runBlocking {299        val repo = VaultRepository(FakeStorage())300        repo.createVault("pw")301        repo.addEntries(302            listOf(303                login("Too long").copy(id = "x".repeat(200)),304                login("Not an id").copy(id = "a b\"c\n{\"injected\":true}"),305            ),306        )307        val minted = repo.getEntries().map { it.id }308        assertEquals("both entries survive with fresh ids", 2, minted.toSet().size)309        for (id in minted) {310            assertEquals("a minted id is a UUID", 36, id.length)311            assertNotNull(java.util.UUID.fromString(id))312        }313    }314315    /** The seam is the import path only — reminting here would fork identity. */316    @Test fun sync_still_adopts_a_peer_id_verbatim() = runBlocking {317        val repo = VaultRepository(FakeStorage())318        repo.createVault("pw")319        val peerId = "peer-" + "x".repeat(120)320        repo.applyMergedRemoteEntries(321            listOf(322                VaultEntry(323                    id = peerId, title = "Remote", type = "login", password = "x",324                    createdAt = 1, modifiedAt = System.currentTimeMillis(),325                ),326            ),327            emptyList(),328        )329        assertEquals(peerId, repo.getEntries().single().id)330    }331332    @Test fun remote_tombstone_supersedes_older_local_entry() = runBlocking {333        val repo = VaultRepository(FakeStorage())334        repo.createVault("pw")335        val local = repo.addEntry(login("Shared"))336        val tomb = Tombstone(local.id, local.modifiedAt + 5_000)337        val applied = repo.applyMergedRemoteEntries(emptyList(), listOf(tomb))338        assertEquals(1, applied)339        assertEquals(0, repo.getEntries().size)340    }341342    @Test fun change_password_reencrypts_under_new_password() = runBlocking {343        val storage = FakeStorage()344        val repo = VaultRepository(storage)345        repo.createVault("old-pass")346        repo.addEntry(login("Acct"))347        assertTrue(repo.changePassword("old-pass", "new-pass"))348        repo.lock()349        assertEquals(UnlockResult.WrongPassword, repo.unlock("old-pass"))350        assertEquals(UnlockResult.Success, repo.unlock("new-pass"))351        assertEquals(1, repo.getEntries().size)352    }353354    // ---- A1: trash purge + retention auto-purge ---------------------------355356    @Test fun purge_deleted_entries_empties_trash() = runBlocking {357        val repo = VaultRepository(FakeStorage())358        repo.createVault("pw")359        val a = repo.addEntry(login("A"))360        val b = repo.addEntry(login("B"))361        repo.addEntry(login("C"))362        repo.deleteEntry(a.id)363        repo.deleteEntry(b.id)364        assertEquals(2, repo.getDeletedEntries().size)365        assertEquals(2, repo.purgeDeletedEntries())366        assertEquals(0, repo.getDeletedEntries().size)367        assertEquals(1, repo.getEntries().size)368    }369370    @Test fun auto_purge_removes_trash_older_than_retention() = runBlocking {371        val storage = FakeStorage()372        val repo = VaultRepository(storage)373        repo.createVault("pw")374        repo.updateSettings(repo.settings.value.copy(trashRetentionDays = 30))375        // Insert an ancient soft-deleted entry via the sync-merge path (deletedAt=1000).376        val ancient = VaultEntry(id = "old", title = "Old", type = "login", password = "x",377            createdAt = 1000, modifiedAt = 1000, deletedAt = 1000)378        repo.applyMergedRemoteEntries(listOf(ancient), emptyList())379        assertEquals(1, repo.getDeletedEntries().size)380        repo.lock()381        assertEquals(UnlockResult.Success, repo.unlock("pw"))382        assertEquals(0, repo.getDeletedEntries().size)383    }384385    @Test fun auto_purge_keeps_trash_when_retention_never() = runBlocking {386        val storage = FakeStorage()387        val repo = VaultRepository(storage)388        repo.createVault("pw") // default trashRetentionDays == null → "Never"389        val ancient = VaultEntry(id = "old", title = "Old", type = "login", password = "x",390            createdAt = 1000, modifiedAt = 1000, deletedAt = 1000)391        repo.applyMergedRemoteEntries(listOf(ancient), emptyList())392        repo.lock()393        assertEquals(UnlockResult.Success, repo.unlock("pw"))394        assertEquals(1, repo.getDeletedEntries().size)395    }396397    // ---- A2: tags ---------------------------------------------------------398399    @Test fun tags_distinct_sorted_lowercased() = runBlocking {400        val repo = VaultRepository(FakeStorage())401        repo.createVault("pw")402        repo.addEntry(login("A").copy(tags = listOf("work", "email")))403        repo.addEntry(login("B").copy(tags = listOf("Work", "social")))404        assertEquals(listOf("email", "social", "work"), repo.getDistinctTags())405    }406407    @Test fun get_active_entries_filters_by_tag() = runBlocking {408        val repo = VaultRepository(FakeStorage())409        repo.createVault("pw")410        repo.addEntry(login("A").copy(tags = listOf("work")))411        repo.addEntry(login("B").copy(tags = listOf("home")))412        val gone = repo.addEntry(login("C").copy(tags = listOf("work")))413        repo.deleteEntry(gone.id)414        val work = repo.getActiveEntries("work")415        assertEquals(1, work.size)416        assertEquals("A", work.first().title)417        assertEquals(2, repo.getActiveEntries(null).size)418    }419420    @Test fun tags_round_trip_across_lock_unlock() = runBlocking {421        val storage = FakeStorage()422        val repo = VaultRepository(storage)423        repo.createVault("pw")424        val e = repo.addEntry(login("T").copy(tags = listOf("alpha", "beta")))425        repo.lock()426        repo.unlock("pw")427        assertEquals(listOf("alpha", "beta"), repo.getEntries().first { it.id == e.id }.tags)428    }429430    // ---- A5: recovery-word metadata ---------------------------------------431432    @Test fun recovery_words_set_verify_and_persist() = runBlocking {433        val storage = FakeStorage()434        val repo = VaultRepository(storage)435        repo.createVault("pw")436        assertFalse(repo.isRecoveryWordsSet())437        val words = rootKeyToWords(ByteArray(32) { it.toByte() })438        assertTrue(repo.setRecoveryWords(words))439        assertTrue(repo.isRecoveryWordsSet())440        assertTrue(repo.verifyRecoveryWords(words))441        assertFalse(repo.verifyRecoveryWords(words.reversed()))442        repo.lock()443        assertEquals(UnlockResult.Success, repo.unlock("pw"))444        assertTrue(repo.isRecoveryWordsSet())445        assertTrue(repo.verifyRecoveryWords(words))446    }447448    @Test fun join_vault_takes_the_source_identity_keeps_entries_and_adopts_its_words() = runBlocking {449        // The source: a second, independent vault whose root key we will join.450        val sourceRepo = VaultRepository(FakeStorage())451        sourceRepo.createVault("source-pw")452        val sourceRootHex = sourceRepo.syncMasterKey()!!.let { key ->453            key.joinToString("") { "%02x".format(it) }.also { VaultCrypto.zero(key) }454        }455        val sourceVaultId = sourceRepo.syncVaultId()456        val sourceWords = sourceRepo.syncMasterKey()!!.let { key ->457            rootKeyToWords(key).also { VaultCrypto.zero(key) }458        }459460        // The joiner: our own vault, with an entry that must survive.461        val storage = FakeStorage()462        val repo = VaultRepository(storage)463        repo.createVault("pw")464        val mine = repo.addEntry(login("Bank"))465        val myWordsBefore = repo.syncMasterKey()!!.let { key ->466            rootKeyToWords(key).also { VaultCrypto.zero(key) }467        }468        assertTrue(repo.setRecoveryWords(myWordsBefore))469        assertTrue(repo.syncVaultId() != sourceVaultId)470471        assertEquals(sourceWords, repo.previewRecoveryWords(sourceRootHex))472473        val result = repo.joinVault(sourceRootHex)474        assertTrue(result is AdoptVaultResult.Success)475476        // Same vault now: this is what lets sync run at all (vault_id_mismatch).477        assertEquals(sourceVaultId, repo.syncVaultId())478        // Entries are sealed under the password key, so joining keeps them.479        assertEquals("Bank", repo.getEntries().first { it.id == mine.id }.title)480        // Words converge on the source's, and the old ones are dead.481        assertTrue(repo.verifyRecoveryWords(sourceWords))482        assertFalse(repo.verifyRecoveryWords(myWordsBefore))483        assertFalse(repo.isRecoveryWordsSet())484485        // Survives a lock/unlock under the joiner's own unchanged password.486        repo.lock()487        assertEquals(UnlockResult.Success, repo.unlock("pw"))488        assertEquals("Bank", repo.getEntries().first { it.id == mine.id }.title)489        assertEquals(sourceVaultId, repo.syncVaultId())490    }491492    @Test fun join_vault_refuses_a_malformed_root_key_and_changes_nothing() = runBlocking {493        val repo = VaultRepository(FakeStorage())494        repo.createVault("pw")495        val added = repo.addEntry(login("Bank"))496        val idBefore = repo.syncVaultId()497498        assertTrue(repo.joinVault("not-hex") is AdoptVaultResult.Error)499        assertTrue(repo.joinVault("ab".repeat(31)) is AdoptVaultResult.Error)500501        assertEquals(idBefore, repo.syncVaultId())502        assertEquals("Bank", repo.getEntries().first { it.id == added.id }.title)503    }504505    @Test fun rotate_sync_identity_changes_root_keeps_entries_and_requires_new_recovery_confirmation() = runBlocking {506        val storage = FakeStorage()507        val repo = VaultRepository(storage)508        repo.createVault("pw")509        val added = repo.addEntry(login("Bank"))510        val wordsBefore = repo.syncMasterKey()!!.let { key ->511            rootKeyToWords(key).also { VaultCrypto.zero(key) }512        }513        assertTrue(repo.setRecoveryWords(wordsBefore))514        val vaultIdBefore = repo.syncVaultId()515        val versionBefore = repo.getVaultVersion()516517        assertTrue(repo.rotateSyncIdentity())518519        val wordsAfter = repo.syncMasterKey()!!.let { key ->520            rootKeyToWords(key).also { VaultCrypto.zero(key) }521        }522        assertTrue(wordsAfter != wordsBefore)523        assertTrue(repo.syncVaultId() != vaultIdBefore)524        assertFalse(repo.isRecoveryWordsSet())525        assertFalse(repo.verifyRecoveryWords(wordsBefore))526        assertTrue(repo.verifyRecoveryWords(wordsAfter))527        assertTrue(storage.slotA?.recoveryEscrow != null)528        assertTrue(storage.slotA?.rootKeyEscrow != null)529        assertTrue(repo.getVaultVersion() > versionBefore)530        assertEquals("Bank", repo.getEntries().first { it.id == added.id }.title)531532        repo.lock()533        assertEquals(UnlockResult.Success, repo.unlock("pw"))534        assertEquals("Bank", repo.getEntries().first { it.id == added.id }.title)535        assertFalse(repo.isRecoveryWordsSet())536        assertTrue(repo.verifyRecoveryWords(wordsAfter))537    }538539    @Test fun destroy_clears_everything() = runBlocking {540        val storage = FakeStorage()541        val repo = VaultRepository(storage)542        repo.createVault("pw")543        repo.addEntry(login("X"))544        repo.destroyVault()545        assertFalse(repo.isUnlocked.value)546        assertNull(storage.slotA)547        assertFalse(repo.hasVault())548    }549550    // M-07: the transient-background suppression must expire so a stale flag551    // can never suppress a later real lock. Drives the clock via the visible-552    // for-testing seam in VaultRepository.553    @Test fun transient_background_suppression_expires() = runBlocking {554        val storage = FakeStorage()555        val repo = VaultRepository(storage)556        var fakeNow = 0L557        repo.nowMs = { fakeNow }558559        // Never set — must always report not-suppressed.560        assertFalse(repo.consumeTransientBackgroundSuppression())561562        // Set, still within the window — must report suppressed and clear.563        repo.expectTransientBackground()564        assertTrue(repo.consumeTransientBackgroundSuppression())565        assertFalse(repo.consumeTransientBackgroundSuppression())566567        // Set, then advance the clock past the 30s timeout — must report568        // not-suppressed (would-have-been-stale flag cannot suppress a real569        // background event) and clear.570        fakeNow = 0L571        repo.expectTransientBackground()572        fakeNow += 30_000L573        assertFalse(repo.consumeTransientBackgroundSuppression())574        assertFalse(repo.consumeTransientBackgroundSuppression())575    }576577    /**578     * Appearance lives in plain prefs rather than the sealed vault, so it must be579     * readable while locked: otherwise the lock screen renders at the defaults and580     * snaps to the user's real choice the instant they authenticate.581     */582    @Test fun persisted_settings_are_read_before_unlock() = runBlocking {583        val storage = FakeStorage()584        VaultRepository(storage).createVault("pw")585        storage.writeSettings(VaultSettings(uiScale = "large"))586587        val restarted = VaultRepository(storage)588589        assertFalse(restarted.isUnlocked.value)590        assertEquals("large", restarted.settings.value.uiScale)591    }592593    @Test fun persisted_settings_survive_locking() = runBlocking {594        val storage = FakeStorage()595        val repo = VaultRepository(storage)596        repo.createVault("pw")597        repo.updateSettings(repo.settings.value.copy(uiScale = "large"))598599        repo.lock()600601        assertEquals("large", repo.settings.value.uiScale)602    }603}