package com.peachpasswords.sync import org.junit.Assert.assertEquals import org.junit.Assert.assertNull import org.junit.Test class PairedDeviceRevocationTest { private val browser = PairedDevice( deviceId = "browser-1", deviceName = "Chrome", deviceType = "extension", longTermPublicKeyHex = "ab".repeat(32), pairedAt = 1L, ) @Test fun `new revocation records the key and removes the paired row`() { val mutation = planDeviceRevocation( devices = listOf(browser), revocations = emptyList(), deviceId = browser.deviceId, revokedByDeviceId = "phone-1", nowMs = 42L, ) assertEquals(emptyList(), mutation.devices) assertEquals(1, mutation.revocations.size) assertEquals(browser.longTermPublicKeyHex, mutation.added?.longTermPublicKeyHex) assertEquals(42L, mutation.added?.revokedAt) } @Test fun `full ledger admits another issuer by evicting the largest issuer oldest record`() { val existing = List(500) { index -> DeviceRevocation("terminal-$index", index.toString(16).padStart(64, '0'), index.toLong(), "issuer-a") } val merged = mergeRevocationRecords(existing, listOf( DeviceRevocation("peer-extra", "ff".repeat(32), 501L, "issuer-b"), )) assertEquals(500, merged.records.size) assertEquals(listOf("peer-extra"), merged.admitted.map { it.deviceId }) assertEquals(false, merged.records.any { it.deviceId == "terminal-0" }) assertEquals(true, merged.records.any { it.deviceId == "peer-extra" }) } @Test fun `same issuer local flood uses bounded capacity`() { var records = emptyList() repeat(501) { index -> records = if (records.size < 500) records + DeviceRevocation("local-$index", index.toString(16).padStart(64, '0'), index.toLong(), "issuer-a") else records.drop(1) + DeviceRevocation("local-$index", index.toString(16).padStart(64, '0'), index.toLong(), "issuer-a") } assertEquals(500, records.size) assertEquals("local-1", records.first().deviceId) assertEquals("local-500", records.last().deviceId) } @Test fun `different issuer local capacity preserves genuine issuer record`() { val existing = List(500) { index -> DeviceRevocation("terminal-$index", index.toString(16).padStart(64, '0'), index.toLong(), if (index == 0) "issuer-a" else "issuer-b") } val removable = existing.indexOfFirst { it.revokedByDeviceId != "issuer-a" } val records = existing.toMutableList().also { it.removeAt(removable); it += DeviceRevocation("local-new", "ff".repeat(32), 501L, "issuer-a") } assertEquals(500, records.size) assertEquals("terminal-0", records.first().deviceId) assertEquals("local-new", records.last().deviceId) } @Test fun `peer flood evicts only its own oldest records once it is largest issuer`() { val existing = listOf( DeviceRevocation("genuine", "aa".repeat(32), 1L, "honest-issuer"), ) val flood = List(600) { index -> DeviceRevocation( "junk-$index", index.toString(16).padStart(64, '0'), index.toLong() + 2, "flooding-issuer", ) } val merged = mergeRevocationRecords(existing, flood, setOf("honest-issuer")) assertEquals(500, merged.records.size) assertEquals(true, merged.records.any { it.deviceId == "genuine" }) assertEquals(false, merged.records.any { it.deviceId == "junk-0" }) assertEquals(true, merged.records.any { it.deviceId == "junk-599" }) // Admitted is exactly what survived the cap: a dropped record must not // be reported as applied (PB-26-130). assertEquals( merged.records.count { it.deviceId.startsWith("junk-") }, merged.admitted.size, ) } @Test fun `an all-protected ledger refuses the overflow instead of dropping trust`() { val existing = List(500) { index -> DeviceRevocation("terminal-$index", index.toString(16).padStart(64, '0'), index.toLong(), "honest-issuer") } val incoming = listOf(DeviceRevocation("incoming", "ff".repeat(32), 999L, "honest-issuer")) val merged = mergeRevocationRecords(existing, incoming, setOf("honest-issuer")) assertEquals(RevocationMergeOutcome.OVERFLOW_PROTECTED_LEDGER, merged.outcome) assertEquals("refusing to evict trusted state keeps the ledger as it was", 500, merged.records.size) assertEquals(true, merged.records.any { it.deviceId == "terminal-0" }) assertEquals(false, merged.records.any { it.deviceId == "incoming" }) assertEquals(0, merged.admitted.size) } @Test fun `existing revocation still removes a stale overlapping paired row`() { val existing = DeviceRevocation( deviceId = browser.deviceId, longTermPublicKeyHex = browser.longTermPublicKeyHex!!, revokedAt = 1L, revokedByDeviceId = "phone-1", ) val mutation = planDeviceRevocation( devices = listOf(browser), revocations = listOf(existing), deviceId = browser.deviceId, revokedByDeviceId = "phone-1", nowMs = 99L, ) assertEquals(emptyList(), mutation.devices) assertEquals(listOf(existing), mutation.revocations) assertNull(mutation.added) } @Test fun `fresh re-pair rotates a stable profile id already in the revocation ledger`() { val revoked = DeviceRevocation( deviceId = browser.deviceId, longTermPublicKeyHex = browser.longTermPublicKeyHex!!, revokedAt = 1L, revokedByDeviceId = "chrome-1", ) assertEquals(true, hasAdoptionIdentityConflict(browser, emptyList(), listOf(revoked))) } @Test fun `fresh re-pair rotates a stable profile key revoked under an older id`() { val revoked = DeviceRevocation( deviceId = "old-firefox-id", longTermPublicKeyHex = browser.longTermPublicKeyHex!!, revokedAt = 1L, revokedByDeviceId = "chrome-1", ) assertEquals(true, hasAdoptionIdentityConflict(browser, emptyList(), listOf(revoked))) } }