package com.peachpasswords.sync import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Assert.fail import org.junit.Test /** * The §11 secure-reset contract: the durable generation record, the six states, * create-on-miss refusal, archival by generation, crash recovery at every step, * and the sync gate. Runs entirely against [FakeSyncGeneration] — no Keystore, * no Context, no Hilt. */ class SyncGenerationTest { private val checkpointNames = listOf( "before-confirmed", "before-archive", "after-archive", "before-clear", "after-clear", "before-identity", "after-identity", "before-generation-write", "after-generation-write", ) // --- derived state ------------------------------------------------------------------- @Test fun `fresh install derives V2_ACTIVE at generation 2 and persists it`() { val port = FakeSyncGeneration() val record = resolveGeneration(port, 1_000L) assertEquals(V2_ACTIVE, record.state) assertEquals(2, record.generation) assertEquals(record, port.record) } @Test fun `existing trust state derives LEGACY_ACTIVE at generation 1`() { val port = FakeSyncGeneration(pairedDevices = true) val record = resolveGeneration(port, 1_000L) assertEquals(LEGACY_ACTIVE, record.state) assertEquals(1, record.generation) assertEquals(record, port.record) } @Test fun `an unreadable record resolves to UNKNOWN rather than to a generation`() { val port = FakeSyncGeneration() port.writeBlob("{ not json") assertEquals(UNKNOWN_STATE, resolveGeneration(port, 1_000L).state) } @Test fun `a record this build does not understand refuses sync instead of misreading it`() { // The rollback case: a build that does not know the generation must not // treat v2 state as legacy trust. val port = FakeSyncGeneration() port.writeBlob("""{"schemaVersion":99,"active":{"schemaVersion":99,"generation":7,"state":"V2_ACTIVE","createdAt":1}}""") assertEquals(UNKNOWN_STATE, resolveGeneration(port, 1_000L).state) assertEquals(SyncDecision.UNKNOWN, assertSyncAllowed(port, 1_000L)) } @Test fun `an unknown state string refuses sync instead of guessing`() { val port = FakeSyncGeneration() port.writeBlob("""{"schemaVersion":1,"active":{"schemaVersion":1,"generation":1,"state":"WOBBLING","createdAt":1}}""") assertEquals(SyncDecision.UNKNOWN, assertSyncAllowed(port, 1_000L)) } // --- create-on-miss refusal (§11.5) -------------------------------------------------- @Test fun `create-on-miss is refused for every class of trust state`() { val classes = mapOf( "paired device" to FakeSyncGeneration(pairedDevices = true), "revocation" to FakeSyncGeneration(revocations = true), "known-key history" to FakeSyncGeneration(issuerSigningKeys = true), "pending holder" to FakeSyncGeneration(pendingVouchedDevices = true), ) for ((label, port) in classes) { assertEquals( "minting must be refused over a $label", IdentityMintDecision.REFUSE_RESET_REQUIRED, mintDecision(port, 1_000L), ) assertEquals("$label must leave the device in RESET_REQUIRED", RESET_REQUIRED, port.record?.state) assertEquals("$label must not mint an identity", 0, port.identityMints) assertEquals("$label must block sync", SyncDecision.RESET_REQUIRED, assertSyncAllowed(port, 1_000L)) } } @Test fun `create-on-miss mints only on a genuinely fresh installation`() { val port = FakeSyncGeneration() assertEquals(IdentityMintDecision.MINT, mintDecision(port, 1_000L)) assertNull("a fresh install writes no reset state", port.record) } // --- the reset itself ---------------------------------------------------------------- @Test fun `reset archives, clears, mints, then flips the pointer — in that order`() { val port = FakeSyncGeneration(pairedDevices = true, revocations = true) assertEquals(ResetResult.V2_ANCHOR, port.reset()) assertEquals(listOf("archive:1", "clear", "identity"), port.calls) val record = port.record!! assertEquals(V2_ANCHOR, record.state) assertEquals(2, record.generation) assertEquals("anchor-device", record.anchorDeviceId) assertEquals("peach_sync_archive_v1", record.legacyArchiveRef) assertTrue(port.cleared) } @Test fun `reset is idempotent from RESET_CONFIRMED and keeps the same archive`() { val port = FakeSyncGeneration(pairedDevices = true) port.reset() val firstArchive = port.record!!.legacyArchiveRef // A second attempt from a committed V2_ANCHOR must not retire the v2 // generation or mint a second identity. assertEquals(ResetResult.RESET_NOT_COMPLETED, port.reset()) assertEquals(firstArchive, port.record!!.legacyArchiveRef) assertEquals(1, port.identityMints) } @Test fun `a failing step stays in RESET_CONFIRMED and reports reset not completed`() { val failures = listOf<(FakeSyncGeneration) -> Unit>( { it.failArchive = true }, { it.failClear = true }, { it.failIdentity = true }, ) for (configure in failures) { val port = FakeSyncGeneration(pairedDevices = true) configure(port) assertEquals(ResetResult.RESET_NOT_COMPLETED, port.reset()) assertEquals(RESET_CONFIRMED, port.record?.state) assertEquals(SyncDecision.RESET_REQUIRED, assertSyncAllowed(port, 1_000L)) } } @Test fun `a record that cannot be persisted is not trusted as a generation`() { val port = FakeSyncGeneration(pairedDevices = true) port.failWrites = true val record = resolveGeneration(port, 1_000L) assertEquals("a derived record that did not persist must not be trusted", UNKNOWN_STATE, record.state) assertEquals(SyncDecision.UNKNOWN, assertSyncAllowed(port, 1_000L)) } // --- crash injection (§11.8) --------------------------------------------------------- @Test fun `a crash at any step leaves the archive durable and the retry reaches V2_ANCHOR`() { for (name in checkpointNames) { val port = FakeSyncGeneration(pairedDevices = true) port.crashAt = name try { port.reset() fail("expected a simulated crash at $name") } catch (expected: FakeSyncGeneration.SimulatedCrash) { assertEquals(name, expected.message!!.removePrefix("crash at ")) } // Never a wrong outcome: either nothing was retired yet, or the // reset is mid-flight, or it committed. Never a lost archive. val state = port.record?.state assertTrue( "crash at $name left $state", state == LEGACY_ACTIVE || state == RESET_CONFIRMED || state == V2_ANCHOR, ) assertFalse( "crash at $name cleared live trust without an archive", port.cleared && port.archiveCount == 0, ) // A retry must finish the job. port.crashAt = null port.reset() assertEquals("retry after a crash at $name", V2_ANCHOR, port.record?.state) assertEquals(2, port.record?.generation) assertNotNull(port.archiveRef) } } @Test fun `a crash before the pointer flip blocks sync and a crash after it allows it`() { val before = FakeSyncGeneration(pairedDevices = true) before.crashAt = "before-generation-write" runCatching { before.reset() } assertEquals(RESET_CONFIRMED, before.record?.state) assertEquals(SyncDecision.RESET_REQUIRED, assertSyncAllowed(before, 1_000L)) val after = FakeSyncGeneration(pairedDevices = true) after.crashAt = "after-generation-write" runCatching { after.reset() } assertEquals(V2_ANCHOR, after.record?.state) assertEquals(SyncDecision.ALLOWED, assertSyncAllowed(after, 1_000L)) assertNotNull("the archive must be durable before the flip", after.archiveRef) assertEquals(1, after.identityMints) } @Test fun `a crash after the archive but before the clear loses nothing`() { val port = FakeSyncGeneration(pairedDevices = true, revocations = true) port.crashAt = "before-clear" runCatching { port.reset() } assertEquals("the archive is already durable", 1, port.archiveCount) assertFalse("the live stores are untouched", port.cleared) assertTrue("the trust state is still there to archive again", port.hasLegacyTrustState()) } // --- the sync gate (§7) -------------------------------------------------------------- @Test fun `a device that has already reset is allowed to sync and pair again`() { val port = FakeSyncGeneration(pairedDevices = true) port.reset() assertEquals(SyncDecision.ALLOWED, assertSyncAllowed(port, 1_000L)) assertTrue(port.isPostReset(1_000L)) } @Test fun `a legacy device may still sync before it resets`() { val port = FakeSyncGeneration(pairedDevices = true) assertEquals(SyncDecision.ALLOWED, assertSyncAllowed(port, 1_000L)) assertFalse("legacy sync is not post-reset", port.isPostReset(1_000L)) } @Test fun `enterResetRequired blocks sync and never walks a reset device backwards`() { val legacy = FakeSyncGeneration(pairedDevices = true) assertEquals(RESET_REQUIRED, enterResetRequired(legacy, 1_000L).state) assertEquals(SyncDecision.RESET_REQUIRED, assertSyncAllowed(legacy, 1_000L)) val reset = FakeSyncGeneration(pairedDevices = true) reset.reset() assertEquals("a reset device is not put back into the legacy generation", V2_ANCHOR, enterResetRequired(reset, 1_000L).state) } // --- repair progression (§11.3) ------------------------------------------------------ @Test fun `an anchored device is REPAIRING until a direct peer exists, then V2_ACTIVE`() { val port = FakeSyncGeneration(pairedDevices = true) port.reset() onDirectV2PairingEstablished(port, hasDirectDevice = false, nowMs = 2_000L) assertEquals(REPAIRING, port.record?.state) assertTrue("still not re-paired", port.isPostReset(2_000L)) onDirectV2PairingEstablished(port, hasDirectDevice = true, nowMs = 3_000L) assertEquals(V2_ACTIVE, port.record?.state) } @Test fun `pairing progress never disturbs a device that never reset`() { val port = FakeSyncGeneration(pairedDevices = true) onDirectV2PairingEstablished(port, hasDirectDevice = true, nowMs = 2_000L) assertEquals(LEGACY_ACTIVE, port.record?.state) } // --- §15.4: the reset retires the generation, it does not rotate the root ------------- @Test fun `the reset retires the legacy generation without touching vault or recovery material`() { val port = FakeSyncGeneration(pairedDevices = true, revocations = true, issuerSigningKeys = true) assertEquals(ResetResult.V2_ANCHOR, port.reset()) // The port is the whole surface the reset can reach. It carries no root // key, no recovery material, and no vault-id parameter, so no rotation // is possible by construction; assert the ordering that makes the // archive safe instead. assertEquals(listOf("archive:1", "clear", "identity"), port.calls) assertEquals("the archive names the retired generation", "peach_sync_archive_v1", port.record?.legacyArchiveRef) assertEquals("the live generation is the new one", 2, port.record?.generation) } // --- message vocabulary -------------------------------------------------------------- @Test fun `the three reset outcomes stay distinct`() { assertEquals(setOf("reset_required", "reset_not_completed", "peer_protocol_outdated", "sync_generation_unknown"), setOf(RESET_REQUIRED_OUTCOME, RESET_NOT_COMPLETED, PEER_PROTOCOL_OUTDATED, SYNC_GENERATION_UNKNOWN)) assertEquals(RESET_REQUIRED_OUTCOME, SyncOutcomeReason.RESET_REQUIRED.wire()) assertEquals(SYNC_GENERATION_UNKNOWN, SyncOutcomeReason.SYNC_GENERATION_UNKNOWN.wire()) } }