package com.peachpasswords.vault import kotlinx.coroutines.runBlocking import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test import com.peachpasswords.sync.LockedSyncSpoolHooks /** In-memory [VaultStorage] so the repository can be exercised without Android. */ private class FakeStorage : VaultStorage { var slotA: StoredVault? = null var slotB: String? = null private var ver: Long = 0 private var settings = VaultSettings() override fun loadSlotA() = slotA override fun hasVault() = slotA != null override fun writeSlotA(slot: StoredVault): Boolean { slotA = slot; return true } override fun loadSlotB() = slotB override fun writeSlotB(blobBase64: String): Boolean { slotB = blobBase64; return true } var decoy: StoredVault? = null override fun loadDecoySlot() = decoy override fun writeDecoySlot(slot: StoredVault): Boolean { decoy = slot; return true } override fun hasDecoySlot() = decoy != null override fun clearDecoySlot() { decoy = null } override fun version() = ver override fun stampLocalMutation(): Long { ver = maxOf(System.currentTimeMillis(), ver + 1); return ver } override fun loadSettings() = settings override fun writeSettings(s: VaultSettings): Boolean { settings = s; return true } var history = VaultHistory() override fun loadVaultHistory() = history override fun writeVaultHistory(history: VaultHistory): Boolean { this.history = history; return true } override fun clearVaultHistory() { history = VaultHistory() } override fun clearVault(): Boolean { slotA = null; slotB = null; ver = 0; settings = VaultSettings(); history = VaultHistory(); return true } } class VaultRepositoryTest { private fun login(title: String, password: String = "pw") = VaultEntry(id = "", title = title, type = "login", password = password, createdAt = 0, modifiedAt = 0) private fun note(title: String) = VaultEntry(id = "", title = title, type = "note", createdAt = 0, modifiedAt = 0) @Test fun local_entry_commit_requests_sync_but_remote_merge_does_not() = runBlocking { val repo = VaultRepository(FakeStorage()) repo.createVault("pw") var requests = 0 VaultMutationHooks.onCommitted = { requests += 1 } try { repo.addEntry(login("Local")) assertEquals(1, requests) repo.applyMergedRemoteEntries( listOf( login("Remote").copy( id = "remote-1", createdAt = 1, modifiedAt = System.currentTimeMillis() + 10_000, ), ), emptyList(), ) assertEquals(1, requests) } finally { VaultMutationHooks.onCommitted = {} } } @Test fun full_lock_offers_committed_state_to_opaque_sync_spool_before_zeroing() = runBlocking { val repo = VaultRepository(FakeStorage()) repo.createVault("pw") repo.addEntry(login("GitHub", "secret")) var observedRoot = false var observedTitles = emptyList() LockedSyncSpoolHooks.refresh = { current -> val root = current.syncMasterKey() observedRoot = root != null root?.let(VaultCrypto::zero) observedTitles = current.getEntries().map { it.title } } try { repo.lock() assertTrue(observedRoot) assertEquals(listOf("GitHub"), observedTitles) assertFalse(repo.isUnlocked.value) assertNull(repo.syncMasterKey()) assertTrue(repo.getEntries().isEmpty()) } finally { LockedSyncSpoolHooks.refresh = {} } } @Test fun create_persists_and_unlocks() = runBlocking { val storage = FakeStorage() val repo = VaultRepository(storage) assertFalse(repo.hasVault()) assertEquals(CreateVaultResult.Success, repo.createVault("correct horse")) assertTrue(repo.hasVault()) assertTrue(repo.isUnlocked.value) // Both slots written, version stamped. assertTrue(storage.slotA != null && storage.slotB != null) assertTrue(repo.getVaultVersion() > 0) } @Test fun entries_persist_across_lock_unlock() = runBlocking { val storage = FakeStorage() val repo = VaultRepository(storage) repo.createVault("pw") val added = repo.addEntry(login("GitHub")) assertTrue(added.id.isNotBlank()) assertEquals(1, repo.getEntries().size) repo.lock() assertFalse(repo.isUnlocked.value) assertEquals(UnlockResult.Success, repo.unlock("pw")) assertEquals(1, repo.getEntries().size) assertEquals("GitHub", repo.getEntries().first().title) } @Test fun wrong_password_is_rejected() = runBlocking { val storage = FakeStorage() val repo = VaultRepository(storage) repo.createVault("right") repo.lock() assertEquals(UnlockResult.WrongPassword, repo.unlock("wrong")) assertFalse(repo.isUnlocked.value) } @Test fun delete_writes_tombstone_then_purge_removes() = runBlocking { val repo = VaultRepository(FakeStorage()) repo.createVault("pw") val e = repo.addEntry(login("Bank")) repo.deleteEntry(e.id) assertEquals(0, repo.getEntries().size) assertEquals(1, repo.getTombstones().size) assertEquals(e.id, repo.getTombstones().first().id) repo.restoreEntry(e.id) assertEquals(1, repo.getEntries().size) repo.deleteEntry(e.id) repo.purgeEntry(e.id) assertEquals(0, repo.getTombstones().size) } @Test fun duplicate_count_matches_browser_keys() = runBlocking { val repo = VaultRepository(FakeStorage()) repo.createVault("pw") repo.addEntry(login("GitHub").copy(url = "https://github.com", username = "june")) repo.addEntry(login("github").copy(url = "https://github.com", username = "june")) repo.addEntry(login("GitHub").copy(url = "https://github.com", username = "other")) repo.addEntry(note("Ideas")) repo.addEntry(note("ideas")) assertEquals(2, repo.countDuplicateEntries()) } @Test fun deduplicate_moves_older_duplicates_to_trash() = runBlocking { val repo = VaultRepository(FakeStorage()) repo.createVault("pw") val older = repo.addEntry(login("Bank").copy(url = "https://bank.test", username = "j")) Thread.sleep(2) val newer = repo.addEntry(login("bank").copy(url = "https://bank.test", username = "j")) assertEquals(1, repo.deduplicateEntries()) assertEquals(listOf(newer.id), repo.getEntries().map { it.id }) assertEquals(listOf(older.id), repo.getDeletedEntries().map { it.id }) } @Test fun vault_history_snapshots_before_local_writes_and_caps() = runBlocking { val repo = VaultRepository(FakeStorage()) repo.createVault("pw") repeat(7) { i -> repo.addEntry(login("Entry $i")) Thread.sleep(1) } val history = repo.getVaultHistory() assertEquals(5, history.size) assertEquals("local_save", history.first().reason) assertEquals(10, history.first().entryCount) } @Test fun restoring_vault_snapshot_replaces_slot_and_locks() = runBlocking { val repo = VaultRepository(FakeStorage()) repo.createVault("pw") val first = repo.addEntry(login("First")) repo.addEntry(login("Second")) val snapshot = repo.getVaultHistory().first() assertTrue(repo.restoreVaultSnapshot(snapshot.id)) assertFalse(repo.isUnlocked.value) assertEquals(UnlockResult.Success, repo.unlock("pw")) assertEquals(listOf(first.id), repo.getEntries().map { it.id }) assertEquals("restore", repo.getVaultHistory().first().reason) } @Test fun update_refreshes_password_changed_only_on_password_change() = runBlocking { val repo = VaultRepository(FakeStorage()) repo.createVault("pw") val e = repo.addEntry(login("Mail", password = "old")) val firstChanged = e.passwordChangedAt!! Thread.sleep(2) val metaOnly = repo.updateEntry(e.copy(title = "Mail2", password = "old")) assertEquals(firstChanged, metaOnly.passwordChangedAt) Thread.sleep(2) val pwChange = repo.updateEntry(metaOnly.copy(password = "new")) assertTrue(pwChange.passwordChangedAt!! > firstChanged) } @Test fun password_history_captures_old_password_newest_first() = runBlocking { val repo = VaultRepository(FakeStorage()) repo.createVault("pw") val e = repo.addEntry(login("H", password = "first")) assertNull(e.passwordHistory) val v2 = repo.updateEntry(e.copy(password = "second")) val v3 = repo.updateEntry(v2.copy(password = "third")) assertEquals(listOf("second", "first"), v3.passwordHistory!!.map { it.password }) assertEquals("third", v3.password) } @Test fun password_history_ignores_metadata_only_update() = runBlocking { val repo = VaultRepository(FakeStorage()) repo.createVault("pw") val e = repo.addEntry(login("M", password = "keepme")) val r = repo.updateEntry(e.copy(title = "Renamed")) assertNull(r.passwordHistory) } @Test fun password_history_item_carries_prior_age() = runBlocking { val repo = VaultRepository(FakeStorage()) repo.createVault("pw") val e = repo.addEntry(login("S", password = "v1")) val firstChanged = e.passwordChangedAt!! Thread.sleep(2) val r = repo.updateEntry(e.copy(password = "v2")) assertEquals(firstChanged, r.passwordHistory!!.first().changedAt) } @Test fun password_history_skips_identical_resave() = runBlocking { val repo = VaultRepository(FakeStorage()) repo.createVault("pw") val e = repo.addEntry(login("N", password = "same")) val r = repo.updateEntry(e.copy(password = "same")) assertNull(r.passwordHistory) } @Test fun password_history_restore_re_records_swapped_out_password() = runBlocking { val repo = VaultRepository(FakeStorage()) repo.createVault("pw") val e = repo.addEntry(login("R", password = "old")) val v2 = repo.updateEntry(e.copy(password = "new")) val restored = repo.updateEntry(v2.copy(password = "old")) assertEquals("old", restored.password) assertEquals(listOf("new", "old"), restored.passwordHistory!!.map { it.password }) } @Test fun password_history_persists_across_lock_unlock() = runBlocking { val storage = FakeStorage() val repo = VaultRepository(storage) repo.createVault("pw") val e = repo.addEntry(login("P", password = "p1")) repo.updateEntry(e.copy(password = "p2")) repo.lock() assertEquals(UnlockResult.Success, repo.unlock("pw")) val reloaded = repo.getEntries().first { it.id == e.id } assertEquals(listOf("p1"), reloaded.passwordHistory!!.map { it.password }) } @Test fun password_history_caps_at_fifty() = runBlocking { val repo = VaultRepository(FakeStorage()) repo.createVault("pw") var e = repo.addEntry(login("C", password = "pw-0")) for (i in 1..55) e = repo.updateEntry(e.copy(password = "pw-$i")) val hist = e.passwordHistory!! assertEquals(50, hist.size) // pw-55 is current; newest retired is pw-54, oldest kept is pw-5. assertEquals("pw-54", hist.first().password) assertEquals("pw-5", hist.last().password) } @Test fun apply_merged_remote_does_not_bump_version() = runBlocking { val repo = VaultRepository(FakeStorage()) repo.createVault("pw") repo.addEntry(login("Local")) val versionBefore = repo.getVaultVersion() val remote = VaultEntry( id = "remote-1", title = "Remote", type = "login", password = "x", createdAt = 1, modifiedAt = System.currentTimeMillis() + 10_000, ) val applied = repo.applyMergedRemoteEntries(listOf(remote), emptyList()) assertEquals(1, applied) assertEquals(2, repo.getEntries().size) // The whole point: a sync apply must NOT advance the local version. assertEquals(versionBefore, repo.getVaultVersion()) } /** * The entry-id namespace. A Peach id is a UUID, but a restored or imported * file brings ids from whoever wrote it and no reader bounds them, so an id * that is not a well-formed Peach id is replaced at the import seam rather * than trusted. Minting costs at most one duplicate after a cross-device * restore; trusting one used to be able to wedge sync on both platforms. */ @Test fun import_keeps_a_well_formed_foreign_id() = runBlocking { val repo = VaultRepository(FakeStorage()) repo.createVault("pw") val fromAnotherPeach = "b1f0c8e2-4a6d-4f1a-9c3e-7d5b2a8e1f40" repo.addEntries( listOf( login("Bitwarden").copy(id = fromAnotherPeach), login("OnePassword").copy(id = "sj4kd9f2q7m1x8zb3nv6ct0h"), ), ) assertEquals( setOf(fromAnotherPeach, "sj4kd9f2q7m1x8zb3nv6ct0h"), repo.getEntries().mapTo(mutableSetOf()) { it.id }, ) } @Test fun import_mints_a_fresh_id_when_the_incoming_one_is_not_a_peach_id() = runBlocking { val repo = VaultRepository(FakeStorage()) repo.createVault("pw") repo.addEntries( listOf( login("Too long").copy(id = "x".repeat(200)), login("Not an id").copy(id = "a b\"c\n{\"injected\":true}"), ), ) val minted = repo.getEntries().map { it.id } assertEquals("both entries survive with fresh ids", 2, minted.toSet().size) for (id in minted) { assertEquals("a minted id is a UUID", 36, id.length) assertNotNull(java.util.UUID.fromString(id)) } } /** The seam is the import path only — reminting here would fork identity. */ @Test fun sync_still_adopts_a_peer_id_verbatim() = runBlocking { val repo = VaultRepository(FakeStorage()) repo.createVault("pw") val peerId = "peer-" + "x".repeat(120) repo.applyMergedRemoteEntries( listOf( VaultEntry( id = peerId, title = "Remote", type = "login", password = "x", createdAt = 1, modifiedAt = System.currentTimeMillis(), ), ), emptyList(), ) assertEquals(peerId, repo.getEntries().single().id) } @Test fun remote_tombstone_supersedes_older_local_entry() = runBlocking { val repo = VaultRepository(FakeStorage()) repo.createVault("pw") val local = repo.addEntry(login("Shared")) val tomb = Tombstone(local.id, local.modifiedAt + 5_000) val applied = repo.applyMergedRemoteEntries(emptyList(), listOf(tomb)) assertEquals(1, applied) assertEquals(0, repo.getEntries().size) } @Test fun change_password_reencrypts_under_new_password() = runBlocking { val storage = FakeStorage() val repo = VaultRepository(storage) repo.createVault("old-pass") repo.addEntry(login("Acct")) assertTrue(repo.changePassword("old-pass", "new-pass")) repo.lock() assertEquals(UnlockResult.WrongPassword, repo.unlock("old-pass")) assertEquals(UnlockResult.Success, repo.unlock("new-pass")) assertEquals(1, repo.getEntries().size) } // ---- A1: trash purge + retention auto-purge --------------------------- @Test fun purge_deleted_entries_empties_trash() = runBlocking { val repo = VaultRepository(FakeStorage()) repo.createVault("pw") val a = repo.addEntry(login("A")) val b = repo.addEntry(login("B")) repo.addEntry(login("C")) repo.deleteEntry(a.id) repo.deleteEntry(b.id) assertEquals(2, repo.getDeletedEntries().size) assertEquals(2, repo.purgeDeletedEntries()) assertEquals(0, repo.getDeletedEntries().size) assertEquals(1, repo.getEntries().size) } @Test fun auto_purge_removes_trash_older_than_retention() = runBlocking { val storage = FakeStorage() val repo = VaultRepository(storage) repo.createVault("pw") repo.updateSettings(repo.settings.value.copy(trashRetentionDays = 30)) // Insert an ancient soft-deleted entry via the sync-merge path (deletedAt=1000). val ancient = VaultEntry(id = "old", title = "Old", type = "login", password = "x", createdAt = 1000, modifiedAt = 1000, deletedAt = 1000) repo.applyMergedRemoteEntries(listOf(ancient), emptyList()) assertEquals(1, repo.getDeletedEntries().size) repo.lock() assertEquals(UnlockResult.Success, repo.unlock("pw")) assertEquals(0, repo.getDeletedEntries().size) } @Test fun auto_purge_keeps_trash_when_retention_never() = runBlocking { val storage = FakeStorage() val repo = VaultRepository(storage) repo.createVault("pw") // default trashRetentionDays == null → "Never" val ancient = VaultEntry(id = "old", title = "Old", type = "login", password = "x", createdAt = 1000, modifiedAt = 1000, deletedAt = 1000) repo.applyMergedRemoteEntries(listOf(ancient), emptyList()) repo.lock() assertEquals(UnlockResult.Success, repo.unlock("pw")) assertEquals(1, repo.getDeletedEntries().size) } // ---- A2: tags --------------------------------------------------------- @Test fun tags_distinct_sorted_lowercased() = runBlocking { val repo = VaultRepository(FakeStorage()) repo.createVault("pw") repo.addEntry(login("A").copy(tags = listOf("work", "email"))) repo.addEntry(login("B").copy(tags = listOf("Work", "social"))) assertEquals(listOf("email", "social", "work"), repo.getDistinctTags()) } @Test fun get_active_entries_filters_by_tag() = runBlocking { val repo = VaultRepository(FakeStorage()) repo.createVault("pw") repo.addEntry(login("A").copy(tags = listOf("work"))) repo.addEntry(login("B").copy(tags = listOf("home"))) val gone = repo.addEntry(login("C").copy(tags = listOf("work"))) repo.deleteEntry(gone.id) val work = repo.getActiveEntries("work") assertEquals(1, work.size) assertEquals("A", work.first().title) assertEquals(2, repo.getActiveEntries(null).size) } @Test fun tags_round_trip_across_lock_unlock() = runBlocking { val storage = FakeStorage() val repo = VaultRepository(storage) repo.createVault("pw") val e = repo.addEntry(login("T").copy(tags = listOf("alpha", "beta"))) repo.lock() repo.unlock("pw") assertEquals(listOf("alpha", "beta"), repo.getEntries().first { it.id == e.id }.tags) } // ---- A5: recovery-word metadata --------------------------------------- @Test fun recovery_words_set_verify_and_persist() = runBlocking { val storage = FakeStorage() val repo = VaultRepository(storage) repo.createVault("pw") assertFalse(repo.isRecoveryWordsSet()) val words = rootKeyToWords(ByteArray(32) { it.toByte() }) assertTrue(repo.setRecoveryWords(words)) assertTrue(repo.isRecoveryWordsSet()) assertTrue(repo.verifyRecoveryWords(words)) assertFalse(repo.verifyRecoveryWords(words.reversed())) repo.lock() assertEquals(UnlockResult.Success, repo.unlock("pw")) assertTrue(repo.isRecoveryWordsSet()) assertTrue(repo.verifyRecoveryWords(words)) } @Test fun join_vault_takes_the_source_identity_keeps_entries_and_adopts_its_words() = runBlocking { // The source: a second, independent vault whose root key we will join. val sourceRepo = VaultRepository(FakeStorage()) sourceRepo.createVault("source-pw") val sourceRootHex = sourceRepo.syncMasterKey()!!.let { key -> key.joinToString("") { "%02x".format(it) }.also { VaultCrypto.zero(key) } } val sourceVaultId = sourceRepo.syncVaultId() val sourceWords = sourceRepo.syncMasterKey()!!.let { key -> rootKeyToWords(key).also { VaultCrypto.zero(key) } } // The joiner: our own vault, with an entry that must survive. val storage = FakeStorage() val repo = VaultRepository(storage) repo.createVault("pw") val mine = repo.addEntry(login("Bank")) val myWordsBefore = repo.syncMasterKey()!!.let { key -> rootKeyToWords(key).also { VaultCrypto.zero(key) } } assertTrue(repo.setRecoveryWords(myWordsBefore)) assertTrue(repo.syncVaultId() != sourceVaultId) assertEquals(sourceWords, repo.previewRecoveryWords(sourceRootHex)) val result = repo.joinVault(sourceRootHex) assertTrue(result is AdoptVaultResult.Success) // Same vault now: this is what lets sync run at all (vault_id_mismatch). assertEquals(sourceVaultId, repo.syncVaultId()) // Entries are sealed under the password key, so joining keeps them. assertEquals("Bank", repo.getEntries().first { it.id == mine.id }.title) // Words converge on the source's, and the old ones are dead. assertTrue(repo.verifyRecoveryWords(sourceWords)) assertFalse(repo.verifyRecoveryWords(myWordsBefore)) assertFalse(repo.isRecoveryWordsSet()) // Survives a lock/unlock under the joiner's own unchanged password. repo.lock() assertEquals(UnlockResult.Success, repo.unlock("pw")) assertEquals("Bank", repo.getEntries().first { it.id == mine.id }.title) assertEquals(sourceVaultId, repo.syncVaultId()) } @Test fun join_vault_refuses_a_malformed_root_key_and_changes_nothing() = runBlocking { val repo = VaultRepository(FakeStorage()) repo.createVault("pw") val added = repo.addEntry(login("Bank")) val idBefore = repo.syncVaultId() assertTrue(repo.joinVault("not-hex") is AdoptVaultResult.Error) assertTrue(repo.joinVault("ab".repeat(31)) is AdoptVaultResult.Error) assertEquals(idBefore, repo.syncVaultId()) assertEquals("Bank", repo.getEntries().first { it.id == added.id }.title) } @Test fun rotate_sync_identity_changes_root_keeps_entries_and_requires_new_recovery_confirmation() = runBlocking { val storage = FakeStorage() val repo = VaultRepository(storage) repo.createVault("pw") val added = repo.addEntry(login("Bank")) val wordsBefore = repo.syncMasterKey()!!.let { key -> rootKeyToWords(key).also { VaultCrypto.zero(key) } } assertTrue(repo.setRecoveryWords(wordsBefore)) val vaultIdBefore = repo.syncVaultId() val versionBefore = repo.getVaultVersion() assertTrue(repo.rotateSyncIdentity()) val wordsAfter = repo.syncMasterKey()!!.let { key -> rootKeyToWords(key).also { VaultCrypto.zero(key) } } assertTrue(wordsAfter != wordsBefore) assertTrue(repo.syncVaultId() != vaultIdBefore) assertFalse(repo.isRecoveryWordsSet()) assertFalse(repo.verifyRecoveryWords(wordsBefore)) assertTrue(repo.verifyRecoveryWords(wordsAfter)) assertTrue(storage.slotA?.recoveryEscrow != null) assertTrue(storage.slotA?.rootKeyEscrow != null) assertTrue(repo.getVaultVersion() > versionBefore) assertEquals("Bank", repo.getEntries().first { it.id == added.id }.title) repo.lock() assertEquals(UnlockResult.Success, repo.unlock("pw")) assertEquals("Bank", repo.getEntries().first { it.id == added.id }.title) assertFalse(repo.isRecoveryWordsSet()) assertTrue(repo.verifyRecoveryWords(wordsAfter)) } @Test fun destroy_clears_everything() = runBlocking { val storage = FakeStorage() val repo = VaultRepository(storage) repo.createVault("pw") repo.addEntry(login("X")) repo.destroyVault() assertFalse(repo.isUnlocked.value) assertNull(storage.slotA) assertFalse(repo.hasVault()) } // M-07: the transient-background suppression must expire so a stale flag // can never suppress a later real lock. Drives the clock via the visible- // for-testing seam in VaultRepository. @Test fun transient_background_suppression_expires() = runBlocking { val storage = FakeStorage() val repo = VaultRepository(storage) var fakeNow = 0L repo.nowMs = { fakeNow } // Never set — must always report not-suppressed. assertFalse(repo.consumeTransientBackgroundSuppression()) // Set, still within the window — must report suppressed and clear. repo.expectTransientBackground() assertTrue(repo.consumeTransientBackgroundSuppression()) assertFalse(repo.consumeTransientBackgroundSuppression()) // Set, then advance the clock past the 30s timeout — must report // not-suppressed (would-have-been-stale flag cannot suppress a real // background event) and clear. fakeNow = 0L repo.expectTransientBackground() fakeNow += 30_000L assertFalse(repo.consumeTransientBackgroundSuppression()) assertFalse(repo.consumeTransientBackgroundSuppression()) } /** * Appearance lives in plain prefs rather than the sealed vault, so it must be * readable while locked: otherwise the lock screen renders at the defaults and * snaps to the user's real choice the instant they authenticate. */ @Test fun persisted_settings_are_read_before_unlock() = runBlocking { val storage = FakeStorage() VaultRepository(storage).createVault("pw") storage.writeSettings(VaultSettings(uiScale = "large")) val restarted = VaultRepository(storage) assertFalse(restarted.isUnlocked.value) assertEquals("large", restarted.settings.value.uiScale) } @Test fun persisted_settings_survive_locking() = runBlocking { val storage = FakeStorage() val repo = VaultRepository(storage) repo.createVault("pw") repo.updateSettings(repo.settings.value.copy(uiScale = "large")) repo.lock() assertEquals("large", repo.settings.value.uiScale) } }