package com.peachpasswords.sync import org.junit.Assert.assertEquals import org.junit.Assert.assertNotEquals import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test /** * The indicator light is the whole point of this vocabulary, so the mapping is * swept exhaustively rather than sampled: a reason added later has to be given * a place here or the sweep fails. */ class SyncOutcomeTest { private val succeeded = setOf( SyncOutcomeReason.COMPLETED_CHANGES, SyncOutcomeReason.COMPLETED_NO_CHANGES, ) @Test fun `only a real success is ever green, even with devices paired`() { for (reason in SyncOutcomeReason.entries) { val indicator = syncIndicator(SyncOutcomeSummary(reason = reason), pairedCount = 5) if (reason in succeeded) { assertEquals("$reason must be ok", SyncIndicator.OK, indicator) } else { assertNotEquals("$reason must never resolve to ok", SyncIndicator.OK, indicator) } } } /** * The set itself, not just its self-consistency with the light: moving a * reason out of it would otherwise flip red to unlit with every other test * still green. "Paired, but nothing here can accept a sync" in particular * is a problem, not a quiet state. * * Network-class outcomes (relay/handshake/timeout/connection_lost) live in * [SyncOutcomeReason.isWarning] and resolve to the amber light, not red — * the background loop keeps trying, so a transient miss is a warning, not * a problem. VAULT_LOCKED joined them in v2.9.35 for the same reason: the * loop retries once the vault unlocks, so amber, not red. */ @Test fun `the failing outcomes are exactly these`() { assertEquals( setOf( SyncOutcomeReason.NO_ELIGIBLE_HOST, SyncOutcomeReason.PRIVATE_RELAY_UNSET, // Android refused to start the runner: nothing synced, and the // person can do something about it. SyncOutcomeReason.FOREGROUND_START_BLOCKED, SyncOutcomeReason.VAULT_ID_MISMATCH, SyncOutcomeReason.PEER_PROTOCOL_OUTDATED, // §11: a device that owes a secure reset, or whose generation // cannot be read, has not synced and must be told so. SyncOutcomeReason.RESET_REQUIRED, SyncOutcomeReason.SYNC_GENERATION_UNKNOWN, SyncOutcomeReason.TRUST_DATA_CORRUPTED, SyncOutcomeReason.RUN_FAILED, ), SyncOutcomeReason.entries.filter { it.isFailure }.toSet(), ) } @Test fun `the warning outcomes are the network class plus a locked vault`() { assertEquals( setOf( SyncOutcomeReason.VAULT_LOCKED, SyncOutcomeReason.RELAY_UNREACHABLE, SyncOutcomeReason.HANDSHAKE_FAILED, SyncOutcomeReason.SYNC_TIMEOUT, SyncOutcomeReason.CONNECTION_LOST, ), SyncOutcomeReason.entries.filter { it.isWarning }.toSet(), ) } @Test fun `every failure is red and every warning is amber, and nothing else is either`() { for (reason in SyncOutcomeReason.entries) { val indicator = syncIndicator(SyncOutcomeSummary(reason = reason), pairedCount = 1) assertEquals( "$reason: isFailure and the red light must agree", reason.isFailure, indicator == SyncIndicator.ERROR, ) assertEquals( "$reason: isWarning and the amber light must agree", reason.isWarning, indicator == SyncIndicator.WARN, ) } } @Test fun `a run in flight pulses whatever the last outcome was`() { for (reason in SyncOutcomeReason.entries + null) { assertEquals( SyncIndicator.BUSY, syncIndicator(SyncOutcomeSummary(reason = reason, syncing = true), pairedCount = 0), ) } } @Test fun `a successful pair with nothing attempted is green, and nothing paired is unlit`() { assertEquals(SyncIndicator.OK, syncIndicator(SyncOutcomeSummary(), pairedCount = 1)) assertEquals(SyncIndicator.IDLE, syncIndicator(SyncOutcomeSummary(), pairedCount = 0)) // Paired but switched off is not a problem and not health — unlit. assertEquals( SyncIndicator.IDLE, syncIndicator(SyncOutcomeSummary(reason = SyncOutcomeReason.SYNC_DISABLED), pairedCount = 1), ) } @Test fun `a sync that could not run reads differently from one that found nothing`() { val ranAndFoundNothing = describeSyncOutcome( SyncOutcomeSummary(reason = SyncOutcomeReason.COMPLETED_NO_CHANGES), ) val couldNotRun = describeSyncOutcome( SyncOutcomeSummary(reason = SyncOutcomeReason.NO_ELIGIBLE_HOST, detail = "Pixel [type=android]"), ) assertEquals("Up to date — nothing to send or receive", ranAndFoundNothing) assertEquals( "Paired, but no device here can accept a sync (Pixel [type=android])", couldNotRun, ) } /** Word for word the browser's `COPY` table — the two clients sit side by side. */ @Test fun `copy matches the browser`() { fun copy(reason: SyncOutcomeReason) = describeSyncOutcome(SyncOutcomeSummary(reason = reason)) assertEquals("Not synced yet", describeSyncOutcome(SyncOutcomeSummary())) assertEquals("Synced", copy(SyncOutcomeReason.COMPLETED_CHANGES)) assertEquals("Up to date — nothing to send or receive", copy(SyncOutcomeReason.COMPLETED_NO_CHANGES)) assertEquals("No devices paired yet", copy(SyncOutcomeReason.NO_PAIRED_DEVICES)) assertEquals("Paired, but no device here can accept a sync", copy(SyncOutcomeReason.NO_ELIGIBLE_HOST)) assertEquals("Sync is switched off", copy(SyncOutcomeReason.SYNC_DISABLED)) assertEquals("Vault was locked — unlock and sync again", copy(SyncOutcomeReason.VAULT_LOCKED)) // Android-only, so there is no browser line to match — see the enum. assertEquals( "Android blocked Peach from starting sync in the background — open Peach and sync again", copy(SyncOutcomeReason.FOREGROUND_START_BLOCKED), ) assertEquals("Couldn't reach the relay", copy(SyncOutcomeReason.RELAY_UNREACHABLE)) assertEquals("Reached the relay, but the device didn't answer", copy(SyncOutcomeReason.HANDSHAKE_FAILED)) assertEquals("That device holds a different vault", copy(SyncOutcomeReason.VAULT_ID_MISMATCH)) assertEquals( "Update Peach on both devices, then re-pair them", copy(SyncOutcomeReason.PEER_PROTOCOL_OUTDATED), ) assertEquals("The device stopped responding mid-sync", copy(SyncOutcomeReason.SYNC_TIMEOUT)) assertEquals("The connection dropped mid-sync — will retry", copy(SyncOutcomeReason.CONNECTION_LOST)) assertEquals("Device trust data is damaged — re-pair your devices after updating", copy(SyncOutcomeReason.TRUST_DATA_CORRUPTED)) assertEquals("Sync failed", copy(SyncOutcomeReason.RUN_FAILED)) } @Test fun `a completed sync says what moved`() { assertEquals( "Synced — 3 sent, 1 received", describeSyncOutcome( SyncOutcomeSummary( reason = SyncOutcomeReason.COMPLETED_CHANGES, entriesSent = 3, entriesReceived = 1, ), ), ) } @Test fun `the label always carries the colour's claim in words`() { assertEquals( "Sync problem — Couldn't reach the relay", syncIndicatorLabel( SyncIndicator.ERROR, SyncOutcomeSummary(reason = SyncOutcomeReason.RELAY_UNREACHABLE), ), ) assertEquals("Not syncing — Not synced yet", syncIndicatorLabel(SyncIndicator.IDLE, SyncOutcomeSummary())) assertTrue(syncIndicatorLabel(SyncIndicator.BUSY, SyncOutcomeSummary()).startsWith("Syncing")) assertTrue(syncIndicatorLabel(SyncIndicator.OK, SyncOutcomeSummary()).startsWith("Sync healthy")) } @Test fun `opaque session errors are classified, not lumped into one failure`() { assertEquals(SyncOutcomeReason.VAULT_LOCKED, classifySyncError("VAULT_LOCKED")) assertEquals(SyncOutcomeReason.VAULT_ID_MISMATCH, classifySyncError("vault_id_mismatch")) assertEquals(SyncOutcomeReason.PEER_PROTOCOL_OUTDATED, classifySyncError("PEER_PROTOCOL_OUTDATED")) assertEquals(SyncOutcomeReason.SYNC_TIMEOUT, classifySyncError("session timed out after 30s")) assertEquals( SyncOutcomeReason.HANDSHAKE_FAILED, classifySyncError("peer identity does not match authenticated KK key"), ) assertEquals( SyncOutcomeReason.HANDSHAKE_FAILED, classifySyncError("reconnect capability does not match relay room"), ) assertEquals(SyncOutcomeReason.RELAY_UNREACHABLE, classifySyncError("relay_closed_1006")) assertEquals(SyncOutcomeReason.CONNECTION_LOST, classifySyncError("connection closed by peer")) assertEquals(SyncOutcomeReason.TRUST_DATA_CORRUPTED, classifySyncError("trust_data_corrupted:paired_devices:decode_failed")) assertEquals(SyncOutcomeReason.RUN_FAILED, classifySyncError("something nobody predicted")) } @Test fun `a session outcome becomes the user-facing one`() { assertEquals( SyncOutcomeReason.COMPLETED_NO_CHANGES, SyncOutcome(SyncOutcomeStatus.COMPLETED).toSummary().reason, ) assertEquals( SyncOutcomeReason.COMPLETED_CHANGES, SyncOutcome(SyncOutcomeStatus.COMPLETED, entriesReceived = 2).toSummary().reason, ) // A deletion that crossed the wire is a change, not "nothing to do". assertEquals( SyncOutcomeReason.COMPLETED_CHANGES, SyncOutcome(SyncOutcomeStatus.COMPLETED, tombstonesSent = 1).toSummary().reason, ) assertEquals( SyncOutcomeReason.VAULT_ID_MISMATCH, SyncOutcome(SyncOutcomeStatus.VAULT_MISMATCH).toSummary().reason, ) assertEquals( SyncOutcomeReason.SYNC_TIMEOUT, SyncOutcome(SyncOutcomeStatus.TIMEOUT, error = "session timed out after 30s").toSummary().reason, ) assertEquals( SyncOutcomeReason.RELAY_UNREACHABLE, SyncOutcome(SyncOutcomeStatus.ERRORED, error = "relay_receive_queue_full").toSummary().reason, ) assertEquals( SyncOutcomeReason.PEER_PROTOCOL_OUTDATED, SyncOutcome(SyncOutcomeStatus.ERRORED, error = "PEER_PROTOCOL_OUTDATED").toSummary().reason, ) } @Test fun `the reason for a failure survives being killed`() { val failure = SyncOutcomeSummary( reason = SyncOutcomeReason.HANDSHAKE_FAILED, detail = "peer identity does not match authenticated KK key", entriesSent = 4, entriesReceived = 2, finishedAt = 1_700_000_000_000L, ) assertEquals(failure, decodeSyncOutcome(encodeSyncOutcome(failure))) } @Test fun `a process killed mid-sync does not come back claiming to be syncing`() { val inFlight = SyncOutcomeSummary(reason = SyncOutcomeReason.COMPLETED_CHANGES, syncing = true) val restored = decodeSyncOutcome(encodeSyncOutcome(inFlight)) assertEquals(false, restored.syncing) assertEquals(SyncOutcomeReason.COMPLETED_CHANGES, restored.reason) } @Test fun `nothing stored, or something unreadable, restores as nothing attempted`() { assertNull(decodeSyncOutcome(null).reason) assertNull(decodeSyncOutcome("{not json").reason) assertNull(decodeSyncOutcome("""{"reason":"A_REASON_FROM_THE_FUTURE"}""").reason) } }