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() { 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 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 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 272 assertEquals(versionBefore, repo.getVaultVersion())273 }274275 276277278279280281282 @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 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 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 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") 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 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 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 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 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 477 assertEquals(sourceVaultId, repo.syncVaultId())478 479 assertEquals("Bank", repo.getEntries().first { it.id == mine.id }.title)480 481 assertTrue(repo.verifyRecoveryWords(sourceWords))482 assertFalse(repo.verifyRecoveryWords(myWordsBefore))483 assertFalse(repo.isRecoveryWordsSet())484485 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 551 552 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 560 assertFalse(repo.consumeTransientBackgroundSuppression())561562 563 repo.expectTransientBackground()564 assertTrue(repo.consumeTransientBackgroundSuppression())565 assertFalse(repo.consumeTransientBackgroundSuppression())566567 568 569 570 fakeNow = 0L571 repo.expectTransientBackground()572 fakeNow += 30_000L573 assertFalse(repo.consumeTransientBackgroundSuppression())574 assertFalse(repo.consumeTransientBackgroundSuppression())575 }576577 578579580581582 @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}