1package com.peachpasswords.sync23import org.junit.Assert.assertEquals4import org.junit.Assert.assertFalse5import org.junit.Assert.assertNotNull6import org.junit.Assert.assertNull7import org.junit.Assert.assertTrue8import org.junit.Assert.fail9import org.junit.Test1011121314151617class SyncGenerationTest {1819 private val checkpointNames = listOf(20 "before-confirmed",21 "before-archive",22 "after-archive",23 "before-clear",24 "after-clear",25 "before-identity",26 "after-identity",27 "before-generation-write",28 "after-generation-write",29 )3031 3233 @Test34 fun `fresh install derives V2_ACTIVE at generation 2 and persists it`() {35 val port = FakeSyncGeneration()3637 val record = resolveGeneration(port, 1_000L)3839 assertEquals(V2_ACTIVE, record.state)40 assertEquals(2, record.generation)41 assertEquals(record, port.record)42 }4344 @Test45 fun `existing trust state derives LEGACY_ACTIVE at generation 1`() {46 val port = FakeSyncGeneration(pairedDevices = true)4748 val record = resolveGeneration(port, 1_000L)4950 assertEquals(LEGACY_ACTIVE, record.state)51 assertEquals(1, record.generation)52 assertEquals(record, port.record)53 }5455 @Test56 fun `an unreadable record resolves to UNKNOWN rather than to a generation`() {57 val port = FakeSyncGeneration()58 port.writeBlob("{ not json")5960 assertEquals(UNKNOWN_STATE, resolveGeneration(port, 1_000L).state)61 }6263 @Test64 fun `a record this build does not understand refuses sync instead of misreading it`() {65 66 67 val port = FakeSyncGeneration()68 port.writeBlob("""{"schemaVersion":99,"active":{"schemaVersion":99,"generation":7,"state":"V2_ACTIVE","createdAt":1}}""")6970 assertEquals(UNKNOWN_STATE, resolveGeneration(port, 1_000L).state)71 assertEquals(SyncDecision.UNKNOWN, assertSyncAllowed(port, 1_000L))72 }7374 @Test75 fun `an unknown state string refuses sync instead of guessing`() {76 val port = FakeSyncGeneration()77 port.writeBlob("""{"schemaVersion":1,"active":{"schemaVersion":1,"generation":1,"state":"WOBBLING","createdAt":1}}""")7879 assertEquals(SyncDecision.UNKNOWN, assertSyncAllowed(port, 1_000L))80 }8182 8384 @Test85 fun `create-on-miss is refused for every class of trust state`() {86 val classes = mapOf(87 "paired device" to FakeSyncGeneration(pairedDevices = true),88 "revocation" to FakeSyncGeneration(revocations = true),89 "known-key history" to FakeSyncGeneration(issuerSigningKeys = true),90 "pending holder" to FakeSyncGeneration(pendingVouchedDevices = true),91 )9293 for ((label, port) in classes) {94 assertEquals(95 "minting must be refused over a $label",96 IdentityMintDecision.REFUSE_RESET_REQUIRED,97 mintDecision(port, 1_000L),98 )99 assertEquals("$label must leave the device in RESET_REQUIRED", RESET_REQUIRED, port.record?.state)100 assertEquals("$label must not mint an identity", 0, port.identityMints)101 assertEquals("$label must block sync", SyncDecision.RESET_REQUIRED, assertSyncAllowed(port, 1_000L))102 }103 }104105 @Test106 fun `create-on-miss mints only on a genuinely fresh installation`() {107 val port = FakeSyncGeneration()108109 assertEquals(IdentityMintDecision.MINT, mintDecision(port, 1_000L))110 assertNull("a fresh install writes no reset state", port.record)111 }112113 114115 @Test116 fun `reset archives, clears, mints, then flips the pointer — in that order`() {117 val port = FakeSyncGeneration(pairedDevices = true, revocations = true)118119 assertEquals(ResetResult.V2_ANCHOR, port.reset())120121 assertEquals(listOf("archive:1", "clear", "identity"), port.calls)122 val record = port.record!!123 assertEquals(V2_ANCHOR, record.state)124 assertEquals(2, record.generation)125 assertEquals("anchor-device", record.anchorDeviceId)126 assertEquals("peach_sync_archive_v1", record.legacyArchiveRef)127 assertTrue(port.cleared)128 }129130 @Test131 fun `reset is idempotent from RESET_CONFIRMED and keeps the same archive`() {132 val port = FakeSyncGeneration(pairedDevices = true)133 port.reset()134135 val firstArchive = port.record!!.legacyArchiveRef136 137 138 assertEquals(ResetResult.RESET_NOT_COMPLETED, port.reset())139 assertEquals(firstArchive, port.record!!.legacyArchiveRef)140 assertEquals(1, port.identityMints)141 }142143 @Test144 fun `a failing step stays in RESET_CONFIRMED and reports reset not completed`() {145 val failures = listOf<(FakeSyncGeneration) -> Unit>(146 { it.failArchive = true },147 { it.failClear = true },148 { it.failIdentity = true },149 )150151 for (configure in failures) {152 val port = FakeSyncGeneration(pairedDevices = true)153 configure(port)154155 assertEquals(ResetResult.RESET_NOT_COMPLETED, port.reset())156 assertEquals(RESET_CONFIRMED, port.record?.state)157 assertEquals(SyncDecision.RESET_REQUIRED, assertSyncAllowed(port, 1_000L))158 }159 }160161 @Test162 fun `a record that cannot be persisted is not trusted as a generation`() {163 val port = FakeSyncGeneration(pairedDevices = true)164 port.failWrites = true165166 val record = resolveGeneration(port, 1_000L)167168 assertEquals("a derived record that did not persist must not be trusted", UNKNOWN_STATE, record.state)169 assertEquals(SyncDecision.UNKNOWN, assertSyncAllowed(port, 1_000L))170 }171172 173174 @Test175 fun `a crash at any step leaves the archive durable and the retry reaches V2_ANCHOR`() {176 for (name in checkpointNames) {177 val port = FakeSyncGeneration(pairedDevices = true)178 port.crashAt = name179180 try {181 port.reset()182 fail("expected a simulated crash at $name")183 } catch (expected: FakeSyncGeneration.SimulatedCrash) {184 assertEquals(name, expected.message!!.removePrefix("crash at "))185 }186187 188 189 val state = port.record?.state190 assertTrue(191 "crash at $name left $state",192 state == LEGACY_ACTIVE || state == RESET_CONFIRMED || state == V2_ANCHOR,193 )194 assertFalse(195 "crash at $name cleared live trust without an archive",196 port.cleared && port.archiveCount == 0,197 )198199 200 port.crashAt = null201 port.reset()202 assertEquals("retry after a crash at $name", V2_ANCHOR, port.record?.state)203 assertEquals(2, port.record?.generation)204 assertNotNull(port.archiveRef)205 }206 }207208 @Test209 fun `a crash before the pointer flip blocks sync and a crash after it allows it`() {210 val before = FakeSyncGeneration(pairedDevices = true)211 before.crashAt = "before-generation-write"212 runCatching { before.reset() }213 assertEquals(RESET_CONFIRMED, before.record?.state)214 assertEquals(SyncDecision.RESET_REQUIRED, assertSyncAllowed(before, 1_000L))215216 val after = FakeSyncGeneration(pairedDevices = true)217 after.crashAt = "after-generation-write"218 runCatching { after.reset() }219 assertEquals(V2_ANCHOR, after.record?.state)220 assertEquals(SyncDecision.ALLOWED, assertSyncAllowed(after, 1_000L))221 assertNotNull("the archive must be durable before the flip", after.archiveRef)222 assertEquals(1, after.identityMints)223 }224225 @Test226 fun `a crash after the archive but before the clear loses nothing`() {227 val port = FakeSyncGeneration(pairedDevices = true, revocations = true)228 port.crashAt = "before-clear"229 runCatching { port.reset() }230231 assertEquals("the archive is already durable", 1, port.archiveCount)232 assertFalse("the live stores are untouched", port.cleared)233 assertTrue("the trust state is still there to archive again", port.hasLegacyTrustState())234 }235236 237238 @Test239 fun `a device that has already reset is allowed to sync and pair again`() {240 val port = FakeSyncGeneration(pairedDevices = true)241 port.reset()242243 assertEquals(SyncDecision.ALLOWED, assertSyncAllowed(port, 1_000L))244 assertTrue(port.isPostReset(1_000L))245 }246247 @Test248 fun `a legacy device may still sync before it resets`() {249 val port = FakeSyncGeneration(pairedDevices = true)250251 assertEquals(SyncDecision.ALLOWED, assertSyncAllowed(port, 1_000L))252 assertFalse("legacy sync is not post-reset", port.isPostReset(1_000L))253 }254255 @Test256 fun `enterResetRequired blocks sync and never walks a reset device backwards`() {257 val legacy = FakeSyncGeneration(pairedDevices = true)258 assertEquals(RESET_REQUIRED, enterResetRequired(legacy, 1_000L).state)259 assertEquals(SyncDecision.RESET_REQUIRED, assertSyncAllowed(legacy, 1_000L))260261 val reset = FakeSyncGeneration(pairedDevices = true)262 reset.reset()263 assertEquals("a reset device is not put back into the legacy generation", V2_ANCHOR, enterResetRequired(reset, 1_000L).state)264 }265266 267268 @Test269 fun `an anchored device is REPAIRING until a direct peer exists, then V2_ACTIVE`() {270 val port = FakeSyncGeneration(pairedDevices = true)271 port.reset()272273 onDirectV2PairingEstablished(port, hasDirectDevice = false, nowMs = 2_000L)274 assertEquals(REPAIRING, port.record?.state)275 assertTrue("still not re-paired", port.isPostReset(2_000L))276277 onDirectV2PairingEstablished(port, hasDirectDevice = true, nowMs = 3_000L)278 assertEquals(V2_ACTIVE, port.record?.state)279 }280281 @Test282 fun `pairing progress never disturbs a device that never reset`() {283 val port = FakeSyncGeneration(pairedDevices = true)284285 onDirectV2PairingEstablished(port, hasDirectDevice = true, nowMs = 2_000L)286287 assertEquals(LEGACY_ACTIVE, port.record?.state)288 }289290 291292 @Test293 fun `the reset retires the legacy generation without touching vault or recovery material`() {294 val port = FakeSyncGeneration(pairedDevices = true, revocations = true, issuerSigningKeys = true)295296 assertEquals(ResetResult.V2_ANCHOR, port.reset())297298 299 300 301 302 assertEquals(listOf("archive:1", "clear", "identity"), port.calls)303 assertEquals("the archive names the retired generation", "peach_sync_archive_v1", port.record?.legacyArchiveRef)304 assertEquals("the live generation is the new one", 2, port.record?.generation)305 }306307 308309 @Test310 fun `the three reset outcomes stay distinct`() {311 assertEquals(setOf("reset_required", "reset_not_completed", "peer_protocol_outdated", "sync_generation_unknown"),312 setOf(RESET_REQUIRED_OUTCOME, RESET_NOT_COMPLETED, PEER_PROTOCOL_OUTDATED, SYNC_GENERATION_UNKNOWN))313 assertEquals(RESET_REQUIRED_OUTCOME, SyncOutcomeReason.RESET_REQUIRED.wire())314 assertEquals(SYNC_GENERATION_UNKNOWN, SyncOutcomeReason.SYNC_GENERATION_UNKNOWN.wire())315 }316}