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