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.LockedSyncSpoolHooks111213private 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 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 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 297 assertEquals(versionBefore, repo.getVaultVersion())298 }299300 301302303304305306307 @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 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 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 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") 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 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 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 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 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 502 assertEquals(sourceVaultId, repo.syncVaultId())503 504 assertEquals("Bank", repo.getEntries().first { it.id == mine.id }.title)505 506 assertTrue(repo.verifyRecoveryWords(sourceWords))507 assertFalse(repo.verifyRecoveryWords(myWordsBefore))508 assertFalse(repo.isRecoveryWordsSet())509510 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 576 577 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 585 assertFalse(repo.consumeTransientBackgroundSuppression())586587 588 repo.expectTransientBackground()589 assertTrue(repo.consumeTransientBackgroundSuppression())590 assertFalse(repo.consumeTransientBackgroundSuppression())591592 593 594 595 fakeNow = 0L596 repo.expectTransientBackground()597 fakeNow += 30_000L598 assertFalse(repo.consumeTransientBackgroundSuppression())599 assertFalse(repo.consumeTransientBackgroundSuppression())600 }601602 603604605606607 @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}