1package com.peachpasswords.sync23import com.peachpasswords.vault.Tombstone4import com.peachpasswords.vault.VaultEntry5import org.junit.Assert.assertEquals6import org.junit.Assert.assertNull7import org.junit.Test89class SyncAlertTest {10 @Test fun remote_newer_password_change_creates_browser_style_alert() {11 val local = VaultEntry(12 id = "login-1",13 title = "GitHub",14 password = "old",15 createdAt = 1L,16 modifiedAt = 10L,17 )18 val remote = local.copy(password = "new", modifiedAt = 20L)1920 val alerts = remoteEntrySyncAlerts(21 localEntries = listOf(local),22 remoteEntries = listOf(remote),23 deviceName = "MacBook",24 nowMs = 30L,25 )2627 assertEquals(1, alerts.size)28 assertEquals(SyncAlertType.REMOTE_PASSWORD_CHANGE, alerts.single().type)29 assertEquals("GitHub", alerts.single().entryTitle)30 assertEquals("MacBook changed the password for GitHub", syncAlertMessage(alerts.single()))31 }3233 @Test fun stale_or_same_password_entries_do_not_alert() {34 val local = VaultEntry(35 id = "login-1",36 title = "Mail",37 password = "same",38 createdAt = 1L,39 modifiedAt = 10L,40 )4142 val alerts = remoteEntrySyncAlerts(43 localEntries = listOf(local),44 remoteEntries = listOf(45 local.copy(password = "different", modifiedAt = 9L),46 local.copy(password = "same", modifiedAt = 20L),47 ),48 deviceName = "paired device",49 nowMs = 30L,50 )5152 assertEquals(emptyList<SyncAlert>(), alerts)53 }5455 @Test fun winning_remote_delete_creates_delete_alert() {56 val local = VaultEntry(57 id = "login-1",58 title = "Bank",59 password = "pw",60 createdAt = 1L,61 modifiedAt = 10L,62 )6364 val alerts = remoteTombstoneSyncAlerts(65 localEntries = listOf(local),66 remoteTombstones = listOf(Tombstone("login-1", deletedAt = 10L)),67 deviceName = "Browser",68 nowMs = 40L,69 )7071 assertEquals(1, alerts.size)72 assertEquals(SyncAlertType.REMOTE_DELETE, alerts.single().type)73 assertEquals("Browser deleted Bank", syncAlertMessage(alerts.single()))74 }7576 @Test fun device_key_rotation_detects_existing_peer_key_changes() {77 val existing = PairedDevice(78 deviceId = "browser",79 deviceName = "Browser",80 deviceType = "extension",81 longTermPublicKeyHex = "aa".repeat(32),82 signingPublicKeyHex = "bb".repeat(32),83 pairedAt = 1L,84 )85 val incoming = ManifestDeviceEntry(86 deviceId = "browser",87 deviceName = "Browser",88 deviceType = "extension",89 longTermPublicKeyHex = "aa".repeat(32),90 signingPublicKeyHex = "cc".repeat(32),91 pairedAt = 1L,92 )9394 val alert = deviceKeyRotationAlert(existing, incoming, nowMs = 99L)9596 assertEquals(SyncAlertType.DEVICE_KEY_ROTATED, alert?.type)97 assertEquals("Browser", alert?.deviceName)98 assertNull(deviceKeyRotationAlert(existing, incoming.copy(signingPublicKeyHex = "bb".repeat(32)), nowMs = 99L))99 }100101 @Test fun `S5 rotation alert names the device from local state not the manifest`() {102 103 104 val existing = PairedDevice(105 deviceId = "browser",106 deviceName = "Spare Tablet",107 deviceType = "extension",108 longTermPublicKeyHex = "aa".repeat(32),109 signingPublicKeyHex = "bb".repeat(32),110 pairedAt = 1L,111 )112 val incoming = ManifestDeviceEntry(113 deviceId = "browser",114 deviceName = "June's Primary Phone",115 deviceType = "extension",116 longTermPublicKeyHex = "aa".repeat(32),117 signingPublicKeyHex = "cc".repeat(32),118 pairedAt = 1L,119 )120121 val alert = deviceKeyRotationAlert(existing, incoming, nowMs = 99L)122123 assertEquals("Spare Tablet", alert?.deviceName)124 }125126 @Test fun sync_alert_queue_is_bounded_to_latest_two_hundred() {127 val existing = (0 until 199).map { SyncAlert(SyncAlertType.REMOTE_DELETE, timestamp = it.toLong()) }128 val incoming = listOf(129 SyncAlert(SyncAlertType.REMOTE_DELETE, timestamp = 199L),130 SyncAlert(SyncAlertType.REMOTE_DELETE, timestamp = 200L),131 )132133 val bounded = boundedSyncAlerts(existing, incoming)134135 assertEquals(200, bounded.size)136 assertEquals(1L, bounded.first().timestamp)137 assertEquals(200L, bounded.last().timestamp)138 }139}