SyncGenerationTest.kt

12.6 KB · raw

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.Test1011/**12 * The §11 secure-reset contract: the durable generation record, the six states,13 * create-on-miss refusal, archival by generation, crash recovery at every step,14 * and the sync gate. Runs entirely against [FakeSyncGeneration] — no Keystore,15 * no Context, no Hilt.16 */17class 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    // --- derived state -------------------------------------------------------------------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        // The rollback case: a build that does not know the generation must not66        // treat v2 state as legacy trust.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    // --- create-on-miss refusal (§11.5) --------------------------------------------------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    // --- the reset itself ----------------------------------------------------------------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        // A second attempt from a committed V2_ANCHOR must not retire the v2137        // generation or mint a second identity.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    // --- crash injection (§11.8) ---------------------------------------------------------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            // Never a wrong outcome: either nothing was retired yet, or the188            // reset is mid-flight, or it committed. Never a lost archive.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            // A retry must finish the job.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    // --- the sync gate (§7) --------------------------------------------------------------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    // --- repair progression (§11.3) ------------------------------------------------------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    // --- §15.4: the reset retires the generation, it does not rotate the root -------------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        // The port is the whole surface the reset can reach. It carries no root299        // key, no recovery material, and no vault-id parameter, so no rotation300        // is possible by construction; assert the ordering that makes the301        // archive safe instead.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    // --- message vocabulary --------------------------------------------------------------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}