package com.peachpasswords.sync import com.peachpasswords.vault.Tombstone import com.peachpasswords.vault.VaultEntry import org.junit.Assert.assertEquals import org.junit.Assert.assertNull import org.junit.Test class SyncAlertTest { @Test fun remote_newer_password_change_creates_browser_style_alert() { val local = VaultEntry( id = "login-1", title = "GitHub", password = "old", createdAt = 1L, modifiedAt = 10L, ) val remote = local.copy(password = "new", modifiedAt = 20L) val alerts = remoteEntrySyncAlerts( localEntries = listOf(local), remoteEntries = listOf(remote), deviceName = "MacBook", nowMs = 30L, ) assertEquals(1, alerts.size) assertEquals(SyncAlertType.REMOTE_PASSWORD_CHANGE, alerts.single().type) assertEquals("GitHub", alerts.single().entryTitle) assertEquals("MacBook changed the password for GitHub", syncAlertMessage(alerts.single())) } @Test fun stale_or_same_password_entries_do_not_alert() { val local = VaultEntry( id = "login-1", title = "Mail", password = "same", createdAt = 1L, modifiedAt = 10L, ) val alerts = remoteEntrySyncAlerts( localEntries = listOf(local), remoteEntries = listOf( local.copy(password = "different", modifiedAt = 9L), local.copy(password = "same", modifiedAt = 20L), ), deviceName = "paired device", nowMs = 30L, ) assertEquals(emptyList(), alerts) } @Test fun winning_remote_delete_creates_delete_alert() { val local = VaultEntry( id = "login-1", title = "Bank", password = "pw", createdAt = 1L, modifiedAt = 10L, ) val alerts = remoteTombstoneSyncAlerts( localEntries = listOf(local), remoteTombstones = listOf(Tombstone("login-1", deletedAt = 10L)), deviceName = "Browser", nowMs = 40L, ) assertEquals(1, alerts.size) assertEquals(SyncAlertType.REMOTE_DELETE, alerts.single().type) assertEquals("Browser deleted Bank", syncAlertMessage(alerts.single())) } @Test fun device_key_rotation_detects_existing_peer_key_changes() { val existing = PairedDevice( deviceId = "browser", deviceName = "Browser", deviceType = "extension", longTermPublicKeyHex = "aa".repeat(32), signingPublicKeyHex = "bb".repeat(32), pairedAt = 1L, ) val incoming = ManifestDeviceEntry( deviceId = "browser", deviceName = "Browser", deviceType = "extension", longTermPublicKeyHex = "aa".repeat(32), signingPublicKeyHex = "cc".repeat(32), pairedAt = 1L, ) val alert = deviceKeyRotationAlert(existing, incoming, nowMs = 99L) assertEquals(SyncAlertType.DEVICE_KEY_ROTATED, alert?.type) assertEquals("Browser", alert?.deviceName) assertNull(deviceKeyRotationAlert(existing, incoming.copy(signingPublicKeyHex = "bb".repeat(32)), nowMs = 99L)) } @Test fun `S5 rotation alert names the device from local state not the manifest`() { // A hostile issuer relabels the rotating device, hoping the alert reads // as a rotation on something the user trusts more. val existing = PairedDevice( deviceId = "browser", deviceName = "Spare Tablet", deviceType = "extension", longTermPublicKeyHex = "aa".repeat(32), signingPublicKeyHex = "bb".repeat(32), pairedAt = 1L, ) val incoming = ManifestDeviceEntry( deviceId = "browser", deviceName = "June's Primary Phone", deviceType = "extension", longTermPublicKeyHex = "aa".repeat(32), signingPublicKeyHex = "cc".repeat(32), pairedAt = 1L, ) val alert = deviceKeyRotationAlert(existing, incoming, nowMs = 99L) assertEquals("Spare Tablet", alert?.deviceName) } @Test fun sync_alert_queue_is_bounded_to_latest_two_hundred() { val existing = (0 until 199).map { SyncAlert(SyncAlertType.REMOTE_DELETE, timestamp = it.toLong()) } val incoming = listOf( SyncAlert(SyncAlertType.REMOTE_DELETE, timestamp = 199L), SyncAlert(SyncAlertType.REMOTE_DELETE, timestamp = 200L), ) val bounded = boundedSyncAlerts(existing, incoming) assertEquals(200, bounded.size) assertEquals(1L, bounded.first().timestamp) assertEquals(200L, bounded.last().timestamp) } }