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.Test91011private class FakeStorage : VaultStorage {12 var slotA: StoredVault? = null13 var slotB: String? = null14 private var ver: Long = 015 private var settings = VaultSettings()16 override fun loadSlotA() = slotA17 override fun hasVault() = slotA != null18 override fun writeSlotA(slot: StoredVault): Boolean { slotA = slot; return true }19 override fun loadSlotB() = slotB20 override fun writeSlotB(blobBase64: String): Boolean { slotB = blobBase64; return true }21 var decoy: StoredVault? = null22 override fun loadDecoySlot() = decoy23 override fun writeDecoySlot(slot: StoredVault): Boolean { decoy = slot; return true }24 override fun hasDecoySlot() = decoy != null25 override fun clearDecoySlot() { decoy = null }26 override fun version() = ver27 override fun stampLocalMutation(): Long { ver = maxOf(System.currentTimeMillis(), ver + 1); return ver }28 override fun loadSettings() = settings29 override fun writeSettings(s: VaultSettings): Boolean { settings = s; return true }30 var history = VaultHistory()31 override fun loadVaultHistory() = history32 override fun writeVaultHistory(history: VaultHistory): Boolean { this.history = history; return true }33 override fun clearVaultHistory() { history = VaultHistory() }34 override fun clearVault() { slotA = null; slotB = null; ver = 0; settings = VaultSettings(); history = VaultHistory() }35}3637class VaultRepositoryTest {38 private fun login(title: String, password: String = "pw") =39 VaultEntry(id = "", title = title, type = "login", password = password, createdAt = 0, modifiedAt = 0)4041 private fun note(title: String) =42 VaultEntry(id = "", title = title, type = "note", createdAt = 0, modifiedAt = 0)4344 @Test fun create_persists_and_unlocks() = runBlocking {45 val storage = FakeStorage()46 val repo = VaultRepository(storage)47 assertFalse(repo.hasVault())48 assertEquals(CreateVaultResult.Success, repo.createVault("correct horse"))49 assertTrue(repo.hasVault())50 assertTrue(repo.isUnlocked.value)51 52 assertTrue(storage.slotA != null && storage.slotB != null)53 assertTrue(repo.getVaultVersion() > 0)54 }5556 @Test fun entries_persist_across_lock_unlock() = runBlocking {57 val storage = FakeStorage()58 val repo = VaultRepository(storage)59 repo.createVault("pw")60 val added = repo.addEntry(login("GitHub"))61 assertTrue(added.id.isNotBlank())62 assertEquals(1, repo.getEntries().size)63 repo.lock()64 assertFalse(repo.isUnlocked.value)65 assertEquals(UnlockResult.Success, repo.unlock("pw"))66 assertEquals(1, repo.getEntries().size)67 assertEquals("GitHub", repo.getEntries().first().title)68 }6970 @Test fun wrong_password_is_rejected() = runBlocking {71 val storage = FakeStorage()72 val repo = VaultRepository(storage)73 repo.createVault("right")74 repo.lock()75 assertEquals(UnlockResult.WrongPassword, repo.unlock("wrong"))76 assertFalse(repo.isUnlocked.value)77 }7879 @Test fun delete_writes_tombstone_then_purge_removes() = runBlocking {80 val repo = VaultRepository(FakeStorage())81 repo.createVault("pw")82 val e = repo.addEntry(login("Bank"))83 repo.deleteEntry(e.id)84 assertEquals(0, repo.getEntries().size)85 assertEquals(1, repo.getTombstones().size)86 assertEquals(e.id, repo.getTombstones().first().id)87 repo.restoreEntry(e.id)88 assertEquals(1, repo.getEntries().size)89 repo.deleteEntry(e.id)90 repo.purgeEntry(e.id)91 assertEquals(0, repo.getTombstones().size)92 }9394 @Test fun duplicate_count_matches_browser_keys() = runBlocking {95 val repo = VaultRepository(FakeStorage())96 repo.createVault("pw")97 repo.addEntry(login("GitHub").copy(url = "https://github.com", username = "june"))98 repo.addEntry(login("github").copy(url = "https://github.com", username = "june"))99 repo.addEntry(login("GitHub").copy(url = "https://github.com", username = "other"))100 repo.addEntry(note("Ideas"))101 repo.addEntry(note("ideas"))102103 assertEquals(2, repo.countDuplicateEntries())104 }105106 @Test fun deduplicate_moves_older_duplicates_to_trash() = runBlocking {107 val repo = VaultRepository(FakeStorage())108 repo.createVault("pw")109 val older = repo.addEntry(login("Bank").copy(url = "https://bank.test", username = "j"))110 Thread.sleep(2)111 val newer = repo.addEntry(login("bank").copy(url = "https://bank.test", username = "j"))112113 assertEquals(1, repo.deduplicateEntries())114115 assertEquals(listOf(newer.id), repo.getEntries().map { it.id })116 assertEquals(listOf(older.id), repo.getDeletedEntries().map { it.id })117 }118119 @Test fun vault_history_snapshots_before_local_writes_and_caps() = runBlocking {120 val repo = VaultRepository(FakeStorage())121 repo.createVault("pw")122123 repeat(7) { i ->124 repo.addEntry(login("Entry $i"))125 Thread.sleep(1)126 }127128 val history = repo.getVaultHistory()129 assertEquals(5, history.size)130 assertEquals("local_save", history.first().reason)131 assertEquals(10, history.first().entryCount)132 }133134 @Test fun restoring_vault_snapshot_replaces_slot_and_locks() = runBlocking {135 val repo = VaultRepository(FakeStorage())136 repo.createVault("pw")137 val first = repo.addEntry(login("First"))138 repo.addEntry(login("Second"))139 val snapshot = repo.getVaultHistory().first()140141 assertTrue(repo.restoreVaultSnapshot(snapshot.id))142 assertFalse(repo.isUnlocked.value)143 assertEquals(UnlockResult.Success, repo.unlock("pw"))144145 assertEquals(listOf(first.id), repo.getEntries().map { it.id })146 assertEquals("restore", repo.getVaultHistory().first().reason)147 }148149 @Test fun update_refreshes_password_changed_only_on_password_change() = runBlocking {150 val repo = VaultRepository(FakeStorage())151 repo.createVault("pw")152 val e = repo.addEntry(login("Mail", password = "old"))153 val firstChanged = e.passwordChangedAt!!154 Thread.sleep(2)155 val metaOnly = repo.updateEntry(e.copy(title = "Mail2", password = "old"))156 assertEquals(firstChanged, metaOnly.passwordChangedAt)157 Thread.sleep(2)158 val pwChange = repo.updateEntry(metaOnly.copy(password = "new"))159 assertTrue(pwChange.passwordChangedAt!! > firstChanged)160 }161162 @Test fun password_history_captures_old_password_newest_first() = runBlocking {163 val repo = VaultRepository(FakeStorage())164 repo.createVault("pw")165 val e = repo.addEntry(login("H", password = "first"))166 assertNull(e.passwordHistory)167 val v2 = repo.updateEntry(e.copy(password = "second"))168 val v3 = repo.updateEntry(v2.copy(password = "third"))169 assertEquals(listOf("second", "first"), v3.passwordHistory!!.map { it.password })170 assertEquals("third", v3.password)171 }172173 @Test fun password_history_ignores_metadata_only_update() = runBlocking {174 val repo = VaultRepository(FakeStorage())175 repo.createVault("pw")176 val e = repo.addEntry(login("M", password = "keepme"))177 val r = repo.updateEntry(e.copy(title = "Renamed"))178 assertNull(r.passwordHistory)179 }180181 @Test fun password_history_item_carries_prior_age() = runBlocking {182 val repo = VaultRepository(FakeStorage())183 repo.createVault("pw")184 val e = repo.addEntry(login("S", password = "v1"))185 val firstChanged = e.passwordChangedAt!!186 Thread.sleep(2)187 val r = repo.updateEntry(e.copy(password = "v2"))188 assertEquals(firstChanged, r.passwordHistory!!.first().changedAt)189 }190191 @Test fun password_history_skips_identical_resave() = runBlocking {192 val repo = VaultRepository(FakeStorage())193 repo.createVault("pw")194 val e = repo.addEntry(login("N", password = "same"))195 val r = repo.updateEntry(e.copy(password = "same"))196 assertNull(r.passwordHistory)197 }198199 @Test fun password_history_restore_re_records_swapped_out_password() = runBlocking {200 val repo = VaultRepository(FakeStorage())201 repo.createVault("pw")202 val e = repo.addEntry(login("R", password = "old"))203 val v2 = repo.updateEntry(e.copy(password = "new"))204 val restored = repo.updateEntry(v2.copy(password = "old"))205 assertEquals("old", restored.password)206 assertEquals(listOf("new", "old"), restored.passwordHistory!!.map { it.password })207 }208209 @Test fun password_history_persists_across_lock_unlock() = runBlocking {210 val storage = FakeStorage()211 val repo = VaultRepository(storage)212 repo.createVault("pw")213 val e = repo.addEntry(login("P", password = "p1"))214 repo.updateEntry(e.copy(password = "p2"))215 repo.lock()216 assertEquals(UnlockResult.Success, repo.unlock("pw"))217 val reloaded = repo.getEntries().first { it.id == e.id }218 assertEquals(listOf("p1"), reloaded.passwordHistory!!.map { it.password })219 }220221 @Test fun password_history_caps_at_fifty() = runBlocking {222 val repo = VaultRepository(FakeStorage())223 repo.createVault("pw")224 var e = repo.addEntry(login("C", password = "pw-0"))225 for (i in 1..55) e = repo.updateEntry(e.copy(password = "pw-$i"))226 val hist = e.passwordHistory!!227 assertEquals(50, hist.size)228 229 assertEquals("pw-54", hist.first().password)230 assertEquals("pw-5", hist.last().password)231 }232233 @Test fun apply_merged_remote_does_not_bump_version() = runBlocking {234 val repo = VaultRepository(FakeStorage())235 repo.createVault("pw")236 repo.addEntry(login("Local"))237 val versionBefore = repo.getVaultVersion()238 val remote = VaultEntry(239 id = "remote-1", title = "Remote", type = "login", password = "x",240 createdAt = 1, modifiedAt = System.currentTimeMillis() + 10_000,241 )242 val applied = repo.applyMergedRemoteEntries(listOf(remote), emptyList())243 assertEquals(1, applied)244 assertEquals(2, repo.getEntries().size)245 246 assertEquals(versionBefore, repo.getVaultVersion())247 }248249 @Test fun remote_tombstone_supersedes_older_local_entry() = runBlocking {250 val repo = VaultRepository(FakeStorage())251 repo.createVault("pw")252 val local = repo.addEntry(login("Shared"))253 val tomb = Tombstone(local.id, local.modifiedAt + 5_000)254 val applied = repo.applyMergedRemoteEntries(emptyList(), listOf(tomb))255 assertEquals(1, applied)256 assertEquals(0, repo.getEntries().size)257 }258259 @Test fun change_password_reencrypts_under_new_password() = runBlocking {260 val storage = FakeStorage()261 val repo = VaultRepository(storage)262 repo.createVault("old-pass")263 repo.addEntry(login("Acct"))264 assertTrue(repo.changePassword("old-pass", "new-pass"))265 repo.lock()266 assertEquals(UnlockResult.WrongPassword, repo.unlock("old-pass"))267 assertEquals(UnlockResult.Success, repo.unlock("new-pass"))268 assertEquals(1, repo.getEntries().size)269 }270271 272273 @Test fun purge_deleted_entries_empties_trash() = runBlocking {274 val repo = VaultRepository(FakeStorage())275 repo.createVault("pw")276 val a = repo.addEntry(login("A"))277 val b = repo.addEntry(login("B"))278 repo.addEntry(login("C"))279 repo.deleteEntry(a.id)280 repo.deleteEntry(b.id)281 assertEquals(2, repo.getDeletedEntries().size)282 assertEquals(2, repo.purgeDeletedEntries())283 assertEquals(0, repo.getDeletedEntries().size)284 assertEquals(1, repo.getEntries().size)285 }286287 @Test fun auto_purge_removes_trash_older_than_retention() = runBlocking {288 val storage = FakeStorage()289 val repo = VaultRepository(storage)290 repo.createVault("pw")291 repo.updateSettings(repo.settings.value.copy(trashRetentionDays = 30))292 293 val ancient = VaultEntry(id = "old", title = "Old", type = "login", password = "x",294 createdAt = 1000, modifiedAt = 1000, deletedAt = 1000)295 repo.applyMergedRemoteEntries(listOf(ancient), emptyList())296 assertEquals(1, repo.getDeletedEntries().size)297 repo.lock()298 assertEquals(UnlockResult.Success, repo.unlock("pw"))299 assertEquals(0, repo.getDeletedEntries().size)300 }301302 @Test fun auto_purge_keeps_trash_when_retention_never() = runBlocking {303 val storage = FakeStorage()304 val repo = VaultRepository(storage)305 repo.createVault("pw") 306 val ancient = VaultEntry(id = "old", title = "Old", type = "login", password = "x",307 createdAt = 1000, modifiedAt = 1000, deletedAt = 1000)308 repo.applyMergedRemoteEntries(listOf(ancient), emptyList())309 repo.lock()310 assertEquals(UnlockResult.Success, repo.unlock("pw"))311 assertEquals(1, repo.getDeletedEntries().size)312 }313314 315316 @Test fun tags_distinct_sorted_lowercased() = runBlocking {317 val repo = VaultRepository(FakeStorage())318 repo.createVault("pw")319 repo.addEntry(login("A").copy(tags = listOf("work", "email")))320 repo.addEntry(login("B").copy(tags = listOf("Work", "social")))321 assertEquals(listOf("email", "social", "work"), repo.getDistinctTags())322 }323324 @Test fun get_active_entries_filters_by_tag() = runBlocking {325 val repo = VaultRepository(FakeStorage())326 repo.createVault("pw")327 repo.addEntry(login("A").copy(tags = listOf("work")))328 repo.addEntry(login("B").copy(tags = listOf("home")))329 val gone = repo.addEntry(login("C").copy(tags = listOf("work")))330 repo.deleteEntry(gone.id)331 val work = repo.getActiveEntries("work")332 assertEquals(1, work.size)333 assertEquals("A", work.first().title)334 assertEquals(2, repo.getActiveEntries(null).size)335 }336337 @Test fun tags_round_trip_across_lock_unlock() = runBlocking {338 val storage = FakeStorage()339 val repo = VaultRepository(storage)340 repo.createVault("pw")341 val e = repo.addEntry(login("T").copy(tags = listOf("alpha", "beta")))342 repo.lock()343 repo.unlock("pw")344 assertEquals(listOf("alpha", "beta"), repo.getEntries().first { it.id == e.id }.tags)345 }346347 348349 @Test fun recovery_words_set_verify_and_persist() = runBlocking {350 val storage = FakeStorage()351 val repo = VaultRepository(storage)352 repo.createVault("pw")353 assertFalse(repo.isRecoveryWordsSet())354 val words = rootKeyToWords(ByteArray(32) { it.toByte() })355 assertTrue(repo.setRecoveryWords(words))356 assertTrue(repo.isRecoveryWordsSet())357 assertTrue(repo.verifyRecoveryWords(words))358 assertFalse(repo.verifyRecoveryWords(words.reversed()))359 repo.lock()360 assertEquals(UnlockResult.Success, repo.unlock("pw"))361 assertTrue(repo.isRecoveryWordsSet())362 assertTrue(repo.verifyRecoveryWords(words))363 }364365 @Test fun join_vault_takes_the_source_identity_keeps_entries_and_adopts_its_words() = runBlocking {366 367 val sourceRepo = VaultRepository(FakeStorage())368 sourceRepo.createVault("source-pw")369 val sourceRootHex = sourceRepo.syncMasterKey()!!.let { key ->370 key.joinToString("") { "%02x".format(it) }.also { VaultCrypto.zero(key) }371 }372 val sourceVaultId = sourceRepo.syncVaultId()373 val sourceWords = sourceRepo.syncMasterKey()!!.let { key ->374 rootKeyToWords(key).also { VaultCrypto.zero(key) }375 }376377 378 val storage = FakeStorage()379 val repo = VaultRepository(storage)380 repo.createVault("pw")381 val mine = repo.addEntry(login("Bank"))382 val myWordsBefore = repo.syncMasterKey()!!.let { key ->383 rootKeyToWords(key).also { VaultCrypto.zero(key) }384 }385 assertTrue(repo.setRecoveryWords(myWordsBefore))386 assertTrue(repo.syncVaultId() != sourceVaultId)387388 assertEquals(sourceWords, repo.previewRecoveryWords(sourceRootHex))389390 val result = repo.joinVault(sourceRootHex)391 assertTrue(result is AdoptVaultResult.Success)392393 394 assertEquals(sourceVaultId, repo.syncVaultId())395 396 assertEquals("Bank", repo.getEntries().first { it.id == mine.id }.title)397 398 assertTrue(repo.verifyRecoveryWords(sourceWords))399 assertFalse(repo.verifyRecoveryWords(myWordsBefore))400 assertFalse(repo.isRecoveryWordsSet())401402 403 repo.lock()404 assertEquals(UnlockResult.Success, repo.unlock("pw"))405 assertEquals("Bank", repo.getEntries().first { it.id == mine.id }.title)406 assertEquals(sourceVaultId, repo.syncVaultId())407 }408409 @Test fun join_vault_refuses_a_malformed_root_key_and_changes_nothing() = runBlocking {410 val repo = VaultRepository(FakeStorage())411 repo.createVault("pw")412 val added = repo.addEntry(login("Bank"))413 val idBefore = repo.syncVaultId()414415 assertTrue(repo.joinVault("not-hex") is AdoptVaultResult.Error)416 assertTrue(repo.joinVault("ab".repeat(31)) is AdoptVaultResult.Error)417418 assertEquals(idBefore, repo.syncVaultId())419 assertEquals("Bank", repo.getEntries().first { it.id == added.id }.title)420 }421422 @Test fun rotate_sync_identity_changes_root_keeps_entries_and_requires_new_recovery_confirmation() = runBlocking {423 val storage = FakeStorage()424 val repo = VaultRepository(storage)425 repo.createVault("pw")426 val added = repo.addEntry(login("Bank"))427 val wordsBefore = repo.syncMasterKey()!!.let { key ->428 rootKeyToWords(key).also { VaultCrypto.zero(key) }429 }430 assertTrue(repo.setRecoveryWords(wordsBefore))431 val vaultIdBefore = repo.syncVaultId()432 val versionBefore = repo.getVaultVersion()433434 assertTrue(repo.rotateSyncIdentity())435436 val wordsAfter = repo.syncMasterKey()!!.let { key ->437 rootKeyToWords(key).also { VaultCrypto.zero(key) }438 }439 assertTrue(wordsAfter != wordsBefore)440 assertTrue(repo.syncVaultId() != vaultIdBefore)441 assertFalse(repo.isRecoveryWordsSet())442 assertFalse(repo.verifyRecoveryWords(wordsBefore))443 assertTrue(repo.verifyRecoveryWords(wordsAfter))444 assertTrue(storage.slotA?.recoveryEscrow != null)445 assertTrue(storage.slotA?.rootKeyEscrow != null)446 assertTrue(repo.getVaultVersion() > versionBefore)447 assertEquals("Bank", repo.getEntries().first { it.id == added.id }.title)448449 repo.lock()450 assertEquals(UnlockResult.Success, repo.unlock("pw"))451 assertEquals("Bank", repo.getEntries().first { it.id == added.id }.title)452 assertFalse(repo.isRecoveryWordsSet())453 assertTrue(repo.verifyRecoveryWords(wordsAfter))454 }455456 @Test fun destroy_clears_everything() = runBlocking {457 val storage = FakeStorage()458 val repo = VaultRepository(storage)459 repo.createVault("pw")460 repo.addEntry(login("X"))461 repo.destroyVault()462 assertFalse(repo.isUnlocked.value)463 assertNull(storage.slotA)464 assertFalse(repo.hasVault())465 }466467 468 469 470 @Test fun transient_background_suppression_expires() = runBlocking {471 val storage = FakeStorage()472 val repo = VaultRepository(storage)473 var fakeNow = 0L474 repo.nowMs = { fakeNow }475476 477 assertFalse(repo.consumeTransientBackgroundSuppression())478479 480 repo.expectTransientBackground()481 assertTrue(repo.consumeTransientBackgroundSuppression())482 assertFalse(repo.consumeTransientBackgroundSuppression())483484 485 486 487 fakeNow = 0L488 repo.expectTransientBackground()489 fakeNow += 30_000L490 assertFalse(repo.consumeTransientBackgroundSuppression())491 assertFalse(repo.consumeTransientBackgroundSuppression())492 }493}