package com.peachpasswords.vault import kotlinx.coroutines.runBlocking import org.junit.Assert.assertEquals import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull import org.junit.Test class PasskeyConsolidationTest { private class Storage : 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 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 } override fun clearVault() { slotA = null; slotB = null; ver = 0; settings = VaultSettings() } } private fun repo() = VaultRepository(Storage()) @Test fun passkey_only_entry_merges_into_matching_login_on_unlock() = runBlocking { val vault = repo() vault.createVault("test-password-123") vault.addEntry(VaultEntry( id = "", type = "login", title = "Example", username = "june", password = "hunter2", url = "https://example.com/login", createdAt = 0, modifiedAt = 0, )) vault.addEntry(VaultEntry( id = "", type = "login", title = "example.com passkey", url = "https://example.com", rpId = "example.com", credentialId = "Y3JlZA", privateKeyJwk = """{"kty":"EC","crv":"P-256","d":"dGVzdA"}""", algorithm = -7, signCount = 5, createdAt = 0, modifiedAt = 0, )) assertEquals(2, vault.getEntries().size) vault.lock() vault.unlock("test-password-123") // getEntries() filters soft-deleted; the orphan is in trash val active = vault.getEntries() assertEquals("orphan merged and trashed — one active login", 1, active.size) val merged = active[0] assertEquals("Example", merged.title) assertEquals("hunter2", merged.password) assertEquals("example.com", merged.rpId) assertEquals("Y3JlZA", merged.credentialId) assertEquals(-7, merged.algorithm) assertEquals(5, merged.signCount) } @Test fun no_consolidation_when_login_already_has_passkey() = runBlocking { val vault = repo() vault.createVault("test-password-123") vault.addEntry(VaultEntry( id = "", type = "login", title = "Example", username = "june", password = "hunter2", url = "https://example.com", rpId = "example.com", credentialId = "ZXhpc3Rpbmc", privateKeyJwk = """{"kty":"EC","crv":"P-256","d":"b2xk"}""", algorithm = -7, createdAt = 0, modifiedAt = 0, )) vault.addEntry(VaultEntry( id = "", type = "login", title = "orphan", url = "https://example.com", rpId = "example.com", credentialId = "b3JwaGFu", privateKeyJwk = """{"kty":"EC","crv":"P-256","d":"bmV3"}""", algorithm = -7, createdAt = 0, modifiedAt = 0, )) vault.lock() vault.unlock("test-password-123") assertEquals("both stay — target already has a passkey", 2, vault.getEntries().size) } @Test fun no_consolidation_for_different_domains() = runBlocking { val vault = repo() vault.createVault("test-password-123") vault.addEntry(VaultEntry( id = "", type = "login", title = "Other", username = "june", password = "hunter2", url = "https://other.com", createdAt = 0, modifiedAt = 0, )) vault.addEntry(VaultEntry( id = "", type = "login", title = "example passkey", rpId = "example.com", credentialId = "Y3JlZA", privateKeyJwk = """{"kty":"EC","crv":"P-256","d":"dGVzdA"}""", algorithm = -7, createdAt = 0, modifiedAt = 0, )) vault.lock() vault.unlock("test-password-123") assertEquals("no domain match — both stay", 2, vault.getEntries().size) } }