AdoptionAdapterCoverageTest.kt

7.5 KB · raw

1package com.peachpasswords.sync23import kotlinx.coroutines.runBlocking4import org.junit.Assert.assertEquals5import org.junit.Assert.assertFalse6import org.junit.Assert.assertTrue7import org.junit.Test89/**10 * §10.5: every relay adapter must forward every v2 callback, and a callback must11 * never be one that only fails once a peer asks for it.12 *13 * The v2 interfaces extend the v1 surface, so a *missing* member is a compile14 * error. What the compiler cannot see is a member that is present and returns15 * without calling through — which is exactly the defect this exists to catch:16 * the host's `onConsent(RequestV2)` once returned `true` without reaching the17 * SAS prompt, so the source-side confirmation never appeared.18 *19 * [bindSourceSessionDeps] is the whole forwarding surface for both relay source20 * adapters, so dirtying every member here covers both.21 */22class AdoptionAdapterCoverageTest {2324    private val request = AdoptionFrame.Request(25        sessionRequestId = "session-request",26        deviceName = "Phone",27        deviceType = "android",28    )2930    private val requestV2 = AdoptionFrame.RequestV2(31        adoptionVersion = ADOPTION_VERSION,32        deviceId = "receiver-id",33        deviceName = "Phone",34        deviceType = "android",35        longTermPublicKeyHex = "aa".repeat(32),36        signingPublicKeyHex = "bb".repeat(32),37    )3839    private val device = PairedDevice(40        deviceId = "receiver-id",41        deviceName = "Phone",42        deviceType = "android",43        longTermPublicKeyHex = "aa".repeat(32),44        pairedAt = 1L,45    )4647    private val presented = LocalDeviceIdentity(48        deviceId = "presented-id",49        deviceName = "This browser",50        deviceType = DeviceType.ANDROID,51        longTermPublicKeyHex = "cc".repeat(32),52        signingPublicKeyHex = "dd".repeat(32),53    )5455    /// Records which members the binder actually reached.56    private class RecordingSourceDeps : AdoptionSourceV2Deps {57        val calls = mutableSetOf<String>()5859        override val sourceIdentity = LocalDeviceIdentity(60            deviceId = "probe-id",61            deviceName = "Probe",62            deviceType = DeviceType.ANDROID,63            longTermPublicKeyHex = "ee".repeat(32),64            signingPublicKeyHex = "ff".repeat(32),65        )6667        override val pairState = AdoptionPairState("01".repeat(32), "wss://probe.test")6869        override suspend fun rootKeyHex() = "11".repeat(32).also { calls += "rootKeyHex" }70        override suspend fun vaultId() = "22".repeat(32).also { calls += "vaultId" }71        override suspend fun license() = "PPK-probe".also { calls += "license" }72        override suspend fun applyLicense(license: String) {73            calls += "applyLicense"74        }7576        override suspend fun onConsent(request: AdoptionFrame.Request) =77            true.also { calls += "onConsentV1" }7879        override suspend fun onConsent(request: AdoptionFrame.RequestV2) =80            true.also { calls += "onConsentV2" }8182        override suspend fun persistPendingHolder(request: AdoptionFrame.Request, nowMs: Long) =83            true.also { calls += "persistPendingHolder" }8485        override suspend fun completePendingHolder(request: AdoptionFrame.Request, device: PairedDevice) =86            true.also { calls += "completePendingHolder" }8788        override suspend fun persistPendingIdentity(device: PairedDevice, nowMs: Long) =89            true.also { calls += "persistPendingIdentity" }9091        override suspend fun completePendingIdentity(device: PairedDevice) =92            true.also { calls += "completePendingIdentity" }9394        override suspend fun upsertPairedDevice(device: PairedDevice) {95            calls += "upsertPairedDevice"96        }9798        override fun hasDeviceIdentityCollision(device: PairedDevice) =99            false.also { calls += "hasDeviceIdentityCollision" }100101        override suspend fun rotateSourceIdentity() =102            sourceIdentity.also { calls += "rotateSourceIdentity" }103104        override fun snapshotCapable() = false.also { calls += "snapshotCapable" }105106        override suspend fun vaultSnapshot() = null.also { calls += "vaultSnapshot" }107        override suspend fun outgoingDeviceManifest() = null.also { calls += "outgoingDeviceManifest" }108109        override suspend fun markSynced(deviceId: String) {110            calls += "markSynced"111        }112113        override suspend fun duressSlotJson() = null.also { calls += "duressSlotJson" }114    }115116    @Test117    fun `the session binder forwards every member, and covers the whole interface`() = runBlocking {118        val probe = RecordingSourceDeps()119        val sessionPairState = AdoptionPairState("33".repeat(32), "wss://session.test")120        var confirmedSas: String? = null121        var confirmedRequest: AdoptionFrame.Request? = null122123        val bound = bindSourceSessionDeps(124            sourceDeps = probe,125            sourceIdentity = presented,126            pairState = sessionPairState,127            sas = "12345678",128            snapshotCapable = true,129            confirm = { sas, request ->130                confirmedSas = sas131                confirmedRequest = request132                true133            },134        )135136        // Session facts are bound, not forwarded.137        assertEquals("the presented identity is the session's", presented, bound.sourceIdentity)138        assertEquals(sessionPairState, bound.pairState)139        assertTrue("snapshot capability is a session fact", bound.snapshotCapable())140141        bound.rootKeyHex()142        bound.vaultId()143        bound.license()144        bound.applyLicense("PPK-x")145        bound.onConsent(request)146        bound.persistPendingHolder(request, 1L)147        bound.completePendingHolder(request, device)148        bound.persistPendingIdentity(device, 1L)149        bound.completePendingIdentity(device)150        bound.upsertPairedDevice(device)151        bound.hasDeviceIdentityCollision(device)152        bound.rotateSourceIdentity()153        bound.vaultSnapshot()154        bound.outgoingDeviceManifest()155        bound.markSynced(device.deviceId)156        bound.duressSlotJson()157        assertEquals(true, bound.onConsent(requestV2))158159        // The v2 consent must reach the prompt. This is the defect that motivated160        // §10.5: a `true` here without calling through loses the user's SAS.161        assertEquals("the v2 consent must carry the session SAS", "12345678", confirmedSas)162        assertEquals(163            "the v2 consent must hand the human the requesting device's label",164            "receiver-id",165            confirmedRequest?.sessionRequestId,166        )167        // v1 consent is answered by the caller, which has already run its own SAS168        // prompt before the grant; the binder must not prompt a second time.169        assertFalse("the v1 consent is answered locally", probe.calls.contains("onConsentV1"))170171        // Exhaustive: a member added to the interface and not exercised here172        // fails this, rather than surfacing once a peer triggers it.173        val declared = AdoptionSourceV2Deps::class.java.methods174            .map { it.name }175            .filterNot { it in OBJECT_METHODS }176            .toSet()177        val covered = probe.calls178            .filterNot { it == "onConsentV1" || it == "onConsentV2" }179            .toSet() +180            setOf("onConsent", "getSourceIdentity", "getPairState", "snapshotCapable")181        assertEquals(182            "every AdoptionSourceV2Deps member must be covered by this test",183            declared,184            covered,185        )186    }187188    private companion object {189        val OBJECT_METHODS = setOf("equals", "hashCode", "toString")190    }191}