package com.peachpasswords.sync import kotlinx.coroutines.async import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withTimeoutOrNull import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test /** * Adoption frame protocol parity with the browser `vault-adopt.test.ts`. The * receiver must verify the granted root key derives the advertised vault id * BEFORE sealing a vault, and a denied consent must hand over nothing. */ class VaultAdoptTest { private fun memoryPair(): Pair { val a2b = Channel(Channel.UNLIMITED) val b2a = Channel(Channel.UNLIMITED) val a = object : PairingTransport { override suspend fun send(msg: ByteArray) { a2b.send(msg) } override suspend fun receive(): ByteArray = b2a.receive() override suspend fun close() {} } val b = object : PairingTransport { override suspend fun send(msg: ByteArray) { b2a.send(msg) } override suspend fun receive(): ByteArray = a2b.receive() override suspend fun close() {} } return a to b } private fun identity(name: String) = LocalDeviceIdentity( deviceId = "$name-id", deviceName = name, deviceType = DeviceType.EXTENSION, longTermPublicKeyHex = "ab".repeat(32), signingPublicKeyHex = "cd".repeat(32), ) private fun vaultIdFor(rootKeyHex: String): String { val bytes = hexToPubkey(rootKeyHex) return try { computeVaultId(bytes) } finally { bytes.fill(0) } } private open class RecorderReceiver( override val deviceName: String, override val deviceType: DeviceType, private val vaultId: String?, private val createThrows: Boolean = false, private val derived: LocalDeviceIdentity? = null, private val sasConfirmed: Boolean = true, private val localLicense: String? = "PPK-test", ) : AdoptionReceiverDeps { var createCalledWith: String? = null var licenseApplied: String? = null val revocationsCleared = mutableListOf() val upserted = mutableListOf() val errors = mutableListOf() var collidingSourceDeviceId: String? = null override fun generateDeviceId(): String = "receiver-ephemeral" override suspend fun confirmSas(): Boolean = sasConfirmed override suspend fun createVaultFromRootKey(rootKeyHex: String): String { createCalledWith = rootKeyHex if (createThrows) throw IllegalStateException("commit_failed") return vaultId ?: error("no vault id") } override suspend fun deriveLocalIdentity(): LocalDeviceIdentity = derived ?: error("no identity") override suspend fun hasSourceIdentityCollision(source: AdoptionFrame.Source): Boolean = source.deviceId == collidingSourceDeviceId override suspend fun clearPairedDeviceRevocation(deviceId: String) { revocationsCleared += deviceId } override suspend fun upsertPairedDevice(device: PairedDevice) { upserted += device } override suspend fun applyLicense(license: String) { licenseApplied = license } override suspend fun license(): String? = localLicense override fun onError(error: String) { errors += error } } private class RecorderSource( override val sourceIdentity: LocalDeviceIdentity, private val rootKeyHex: String, private val vaultId: String, license: String? = "PPK-test", private val consent: Boolean = true, ) : AdoptionSourceDeps { private var storedLicense: String? = license val upserted = mutableListOf() var licenseApplied: String? = null var consentAsked = false var rootRequested = false var collidingDeviceId: String? = null var rotatedSourceIdentity: LocalDeviceIdentity? = null override suspend fun rootKeyHex(): String { rootRequested = true return rootKeyHex } override suspend fun vaultId(): String = vaultId override suspend fun license(): String? = storedLicense override suspend fun applyLicense(license: String) { licenseApplied = license storedLicense = license } override suspend fun onConsent(request: AdoptionFrame.Request): Boolean { consentAsked = true return consent } override fun hasDeviceIdentityCollision(device: PairedDevice): Boolean = device.deviceId == collidingDeviceId override suspend fun rotateSourceIdentity(): LocalDeviceIdentity = rotatedSourceIdentity ?: error("no rotated source identity") override suspend fun upsertPairedDevice(device: PairedDevice) { upserted += device } } @Test fun completes_request_through_mutual_completion_acknowledgements() = runBlocking { val (receiverCh, sourceCh) = memoryPair() val rootKeyHex = "11".repeat(32) val vaultId = vaultIdFor(rootKeyHex) val receiverIdentity = identity("receiver").copy(deviceType = DeviceType.ANDROID) val receiver = RecorderReceiver( "Receiver", DeviceType.ANDROID, vaultId, derived = receiverIdentity, localLicense = null, ) val source = RecorderSource(identity("source"), rootKeyHex, vaultId, license = "PPK-test") val recvJob = async { requestAdoption(receiverCh, receiver) { 111L } } val grantJob = async { val request = receiveFrame(sourceCh) as AdoptionFrame.Request grantAdoption(sourceCh, request, source) { 222L } } recvJob.await() grantJob.await() assertEquals(rootKeyHex, receiver.createCalledWith) assertEquals("PPK-test", receiver.licenseApplied) assertEquals(listOf("source-id"), receiver.revocationsCleared) assertEquals("source-id", receiver.upserted.single().deviceId) assertEquals("receiver-id", source.upserted.single().deviceId) assertEquals("android", source.upserted.single().deviceType) } @Test fun source_preserves_existing_peer_and_accepts_rotated_identity_after_collision() = runBlocking { val (receiverCh, sourceCh) = memoryPair() val rootKeyHex = "13".repeat(32) val vaultId = vaultIdFor(rootKeyHex) val source = RecorderSource(identity("android"), rootKeyHex, vaultId).apply { collidingDeviceId = "chrome-id" } val sourcePromise = async { grantAdoption(sourceCh, AdoptionFrame.Request( sessionRequestId = "receiver-ephemeral", deviceName = "Firefox", deviceType = DeviceType.EXTENSION.wireValue, ), source) } sendFrame(receiverCh, AdoptionFrame.Confirm(true)) assertTrue(receiveFrame(receiverCh) is AdoptionFrame.Grant) sendFrame(receiverCh, AdoptionFrame.Identity( deviceId = "chrome-id", deviceName = "Firefox", deviceType = DeviceType.EXTENSION.wireValue, longTermPublicKeyHex = "11".repeat(32), )) val rejection = receiveFrame(receiverCh) assertTrue(rejection is AdoptionFrame.Reject) assertEquals("ADOPT_DEVICE_ID_COLLISION", (rejection as AdoptionFrame.Reject).reason) source.collidingDeviceId = null sendFrame(receiverCh, AdoptionFrame.Identity( deviceId = "firefox-id", deviceName = "Firefox", deviceType = DeviceType.EXTENSION.wireValue, longTermPublicKeyHex = "22".repeat(32), )) assertTrue(receiveFrame(receiverCh) is AdoptionFrame.Complete) sendFrame(receiverCh, AdoptionFrame.Applied) assertEquals("firefox-id", sourcePromise.await()?.deviceId) assertEquals(listOf("firefox-id"), source.upserted.map { it.deviceId }) } @Test fun receiver_rejects_revoked_source_identity_and_accepts_one_rotation() = runBlocking { val (receiverCh, sourceCh) = memoryPair() val rootKeyHex = "14".repeat(32) val vaultId = vaultIdFor(rootKeyHex) val receiver = RecorderReceiver( "Android", DeviceType.ANDROID, vaultId, derived = identity("receiver").copy(deviceType = DeviceType.ANDROID), ).apply { collidingSourceDeviceId = "chrome-id" } val receiverJob = async { requestAdoption(receiverCh, receiver) { 0L } } receiveFrame(sourceCh) // ADOPT_REQUEST receiveFrame(sourceCh) // ADOPT_CONFIRM sendFrame(sourceCh, AdoptionFrame.Grant( rootKeyHex = rootKeyHex, vaultId = vaultId, source = AdoptionFrame.Source( "chrome-id", "Chrome", "extension", "ab".repeat(32), ), )) val rejection = receiveFrame(sourceCh) assertEquals("ADOPT_DEVICE_ID_COLLISION", (rejection as AdoptionFrame.Reject).reason) receiver.collidingSourceDeviceId = null sendFrame(sourceCh, AdoptionFrame.Grant( rootKeyHex = rootKeyHex, vaultId = vaultId, source = AdoptionFrame.Source( "chrome-fresh-id", "Chrome", "extension", "ef".repeat(32), ), )) assertTrue(receiveFrame(sourceCh) is AdoptionFrame.Identity) sendFrame(sourceCh, AdoptionFrame.Complete) assertTrue(receiveFrame(sourceCh) is AdoptionFrame.Applied) assertEquals("chrome-fresh-id", receiverJob.await()?.deviceId) assertEquals(listOf("chrome-fresh-id"), receiver.revocationsCleared) assertEquals(listOf("chrome-fresh-id"), receiver.upserted.map { it.deviceId }) } @Test fun source_rotates_its_revoked_identity_and_resends_grant_once() = runBlocking { val (receiverCh, sourceCh) = memoryPair() val rootKeyHex = "15".repeat(32) val vaultId = vaultIdFor(rootKeyHex) val source = RecorderSource(identity("chrome"), rootKeyHex, vaultId).apply { rotatedSourceIdentity = identity("chrome-fresh") } val sourceJob = async { grantAdoption(sourceCh, AdoptionFrame.Request( sessionRequestId = "receiver-ephemeral", deviceName = "Android", deviceType = DeviceType.ANDROID.wireValue, ), source) } sendFrame(receiverCh, AdoptionFrame.Confirm(true)) val firstGrant = receiveFrame(receiverCh) as AdoptionFrame.Grant assertEquals("chrome-id", firstGrant.source.deviceId) sendFrame(receiverCh, AdoptionFrame.Reject("ADOPT_DEVICE_ID_COLLISION")) val secondGrant = receiveFrame(receiverCh) as AdoptionFrame.Grant assertEquals("chrome-fresh-id", secondGrant.source.deviceId) sendFrame(receiverCh, AdoptionFrame.Identity( deviceId = "android-id", deviceName = "Android", deviceType = DeviceType.ANDROID.wireValue, longTermPublicKeyHex = "22".repeat(32), )) assertTrue(receiveFrame(receiverCh) is AdoptionFrame.Complete) sendFrame(receiverCh, AdoptionFrame.Applied) assertEquals("android-id", sourceJob.await()?.deviceId) assertEquals(listOf("android-id"), source.upserted.map { it.deviceId }) } @Test fun rejects_without_creating_or_pairing_when_neither_peer_has_pro() = runBlocking { val (receiverCh, sourceCh) = memoryPair() val rootKeyHex = "12".repeat(32) val vaultId = vaultIdFor(rootKeyHex) val receiver = RecorderReceiver( "Free receiver", DeviceType.ANDROID, vaultId, localLicense = null, ) val source = RecorderSource( identity("free-source"), rootKeyHex, vaultId, license = null, ) val receiverJob = async { requestAdoption(receiverCh, receiver) { 0L } } val sourceJob = async { val request = receiveFrame(sourceCh) as AdoptionFrame.Request runCatching { grantAdoption(sourceCh, request, source) { 0L } } } assertNull(receiverJob.await()) val sourceOutcome = sourceJob.await() assertTrue(sourceOutcome.isFailure) assertEquals("ADOPT_PRO_REQUIRED", sourceOutcome.exceptionOrNull()?.message) assertEquals(listOf("ADOPT_PRO_REQUIRED"), receiver.errors) assertNull(receiver.createCalledWith) assertTrue(receiver.upserted.isEmpty()) assertTrue(source.upserted.isEmpty()) } @Test fun receiver_pro_license_is_transferred_to_a_free_source() = runBlocking { val (receiverCh, sourceCh) = memoryPair() val rootKeyHex = "13".repeat(32) val vaultId = vaultIdFor(rootKeyHex) val receiver = RecorderReceiver( "Pro receiver", DeviceType.ANDROID, vaultId, derived = identity("receiver").copy(deviceType = DeviceType.ANDROID), localLicense = "PPK-receiver", ) val source = RecorderSource( identity("free-source"), rootKeyHex, vaultId, license = null, ) val receiverJob = async { requestAdoption(receiverCh, receiver) { 1L } } val sourceJob = async { val request = receiveFrame(sourceCh) as AdoptionFrame.Request grantAdoption(sourceCh, request, source) { 2L } } assertEquals("free-source-id", receiverJob.await()?.deviceId) assertEquals("receiver-id", sourceJob.await()?.deviceId) assertEquals("PPK-receiver", source.licenseApplied) } /** * The constraint from `PEACH_RELAY_AND_DEVICEID_FIXES.md` Part 2: the label * on ADOPT_REQUEST is generated before any key exists, so persisting it as * an identity would key trust on a value with no continuity between * attempts. What lands in the store must come from ADOPT_IDENTITY. */ @Test fun request_label_is_never_what_gets_persisted() = runBlocking { val (receiverCh, sourceCh) = memoryPair() val rootKeyHex = "55".repeat(32) val source = RecorderSource(identity("source"), rootKeyHex, vaultIdFor(rootKeyHex)) // Deliberately unlike the identity the receiver will present. val request = AdoptionFrame.Request("ephemeral-label-not-an-identity", "Receiver", "extension") val grantJob = async { grantAdoption(sourceCh, request, source) { 0L } } sendFrame(receiverCh, AdoptionFrame.Confirm(true)) receiveFrame(receiverCh) // the grant sendFrame( receiverCh, AdoptionFrame.Identity( deviceId = "receiver-id", deviceName = "Receiver", deviceType = "extension", longTermPublicKeyHex = "aa".repeat(32), signingPublicKeyHex = "bb".repeat(32), ), ) receiveFrame(receiverCh) // ADOPT_COMPLETE sendFrame(receiverCh, AdoptionFrame.Applied) grantJob.await() val persisted = source.upserted.single() assertEquals("receiver-id", persisted.deviceId) assertEquals("aa".repeat(32), persisted.longTermPublicKeyHex) assertTrue( "the ephemeral request label must not reach the paired-device store", source.upserted.none { it.deviceId == "ephemeral-label-not-an-identity" }, ) } @Test fun request_carries_receiver_device_type() = runBlocking { val (receiverCh, sourceCh) = memoryPair() val receiver = RecorderReceiver("Receiver", DeviceType.ANDROID, vaultId = null) val recvJob = async { requestAdoption(receiverCh, receiver) { 0L } } val request = receiveFrame(sourceCh) as AdoptionFrame.Request recvJob.cancel() assertEquals("android", request.deviceType) assertEquals("receiver-ephemeral", request.sessionRequestId) assertEquals("Receiver", request.deviceName) } @Test fun denied_consent_grants_nothing() = runBlocking { val (receiverCh, sourceCh) = memoryPair() val rootKeyHex = "33".repeat(32) val receiver = RecorderReceiver("Receiver", DeviceType.ANDROID, vaultIdFor(rootKeyHex)) val source = RecorderSource(identity("source"), rootKeyHex, vaultIdFor(rootKeyHex), consent = false) val recvJob = async { requestAdoption(receiverCh, receiver) { 0L } } val request = receiveFrame(sourceCh) as AdoptionFrame.Request val granted = grantAdoption(sourceCh, request, source) { 0L } recvJob.cancel() assertNull(granted) assertTrue(source.consentAsked) assertFalse(source.rootRequested) assertTrue(source.upserted.isEmpty()) assertNull(receiver.createCalledWith) } @Test fun receiver_sas_rejection_grants_no_root_key() = runBlocking { val (receiverCh, sourceCh) = memoryPair() val rootKeyHex = "66".repeat(32) val receiver = RecorderReceiver( "Receiver", DeviceType.ANDROID, vaultIdFor(rootKeyHex), sasConfirmed = false, ) val source = RecorderSource(identity("source"), rootKeyHex, vaultIdFor(rootKeyHex)) val receiverJob = async { requestAdoption(receiverCh, receiver) { 0L } } val request = receiveFrame(sourceCh) as AdoptionFrame.Request val sourceJob = async { grantAdoption(sourceCh, request, source) { 0L } } assertNull(receiverJob.await()) assertNull(sourceJob.await()) assertFalse(source.rootRequested) assertNull(receiver.createCalledWith) assertTrue(receiver.revocationsCleared.isEmpty()) assertEquals(listOf("ADOPT_SAS_REJECTED"), receiver.errors) } @Test fun invalid_root_key_hex_aborts_as_unexpected_frame() = runBlocking { val (receiverCh, sourceCh) = memoryPair() val receiver = RecorderReceiver("Receiver", DeviceType.ANDROID, vaultId = null) val recvJob = async { requestAdoption(receiverCh, receiver) { 0L } } receiveFrame(sourceCh) sendFrame(sourceCh, AdoptionFrame.Grant( rootKeyHex = "bad", vaultId = "00".repeat(32), source = AdoptionFrame.Source("source", "Source", "extension", "ab".repeat(32)), )) recvJob.await() assertEquals(listOf("ADOPT_UNEXPECTED_FRAME"), receiver.errors) assertNull(receiver.createCalledWith) } @Test fun vault_id_mismatch_aborts_before_vault_creation() = runBlocking { val (receiverCh, sourceCh) = memoryPair() val receiver = RecorderReceiver("Receiver", DeviceType.ANDROID, vaultId = null) val recvJob = async { requestAdoption(receiverCh, receiver) { 0L } } receiveFrame(sourceCh) sendFrame(sourceCh, AdoptionFrame.Grant( rootKeyHex = "22".repeat(32), vaultId = "00".repeat(32), source = AdoptionFrame.Source("source", "Source", "extension", "ab".repeat(32)), )) recvJob.await() assertEquals(listOf("ADOPT_VAULT_ID_MISMATCH"), receiver.errors) assertNull(receiver.createCalledWith) } @Test fun source_waits_for_identity_before_persisting_receiver() = runBlocking { val (receiverCh, sourceCh) = memoryPair() val rootKeyHex = "33".repeat(32) val source = RecorderSource(identity("source"), rootKeyHex, vaultIdFor(rootKeyHex)) val request = AdoptionFrame.Request("receiver-ephemeral", "Receiver", "extension") sendFrame(receiverCh, AdoptionFrame.Confirm(true)) val result = withTimeoutOrNull(150) { // No receiver answers the ADOPT_IDENTITY read, so grant must hang, not persist. grantAdoption(sourceCh, request, source) { 0L } } assertNull(result) assertTrue(source.upserted.isEmpty()) } @Test fun receiver_does_not_persist_source_without_complete() = runBlocking { val (receiverCh, sourceCh) = memoryPair() val rootKeyHex = "77".repeat(32) val vaultId = vaultIdFor(rootKeyHex) val receiver = RecorderReceiver( "Receiver", DeviceType.ANDROID, vaultId, derived = identity("receiver").copy(deviceType = DeviceType.ANDROID), ) val receiverJob = async { requestAdoption(receiverCh, receiver) { 0L } } receiveFrame(sourceCh) // ADOPT_REQUEST receiveFrame(sourceCh) // ADOPT_CONFIRM sendFrame( sourceCh, AdoptionFrame.Grant( rootKeyHex = rootKeyHex, vaultId = vaultId, source = AdoptionFrame.Source( "source-id", "Source", "extension", "ab".repeat(32), ), ), ) receiveFrame(sourceCh) // ADOPT_IDENTITY sendFrame(sourceCh, AdoptionFrame.Confirm(true)) assertNull(receiverJob.await()) assertEquals(listOf("ADOPT_COMPLETION_MISSING"), receiver.errors) assertTrue(receiver.revocationsCleared.isEmpty()) assertTrue(receiver.upserted.isEmpty()) } @Test fun receiver_safety_policy_runs_before_vault_creation() = runBlocking { val (receiverCh, sourceCh) = memoryPair() val rootKeyHex = "78".repeat(32) val vaultId = vaultIdFor(rootKeyHex) val receiver = object : RecorderReceiver( "Receiver", DeviceType.ANDROID, vaultId, derived = identity("receiver").copy(deviceType = DeviceType.ANDROID), ) { override suspend fun validateSource(source: AdoptionFrame.Source) { throw IllegalStateException("JOIN_POPULATED_VAULT_BLOCKED") } } val receiverJob = async { runCatching { requestAdoption(receiverCh, receiver) { 0L } } } receiveFrame(sourceCh) // ADOPT_REQUEST receiveFrame(sourceCh) // ADOPT_CONFIRM sendFrame( sourceCh, AdoptionFrame.Grant( rootKeyHex = rootKeyHex, vaultId = vaultId, source = AdoptionFrame.Source( "new-source", "Source", "extension", "ab".repeat(32), ), ), ) val outcome = receiverJob.await() assertTrue(outcome.isFailure) assertEquals("JOIN_POPULATED_VAULT_BLOCKED", outcome.exceptionOrNull()?.message) assertNull(receiver.createCalledWith) } @Test fun applies_valid_license_before_vault_creation_failure() = runBlocking { val (receiverCh, sourceCh) = memoryPair() val rootKeyHex = "44".repeat(32) val vaultId = vaultIdFor(rootKeyHex) val receiver = RecorderReceiver("Receiver", DeviceType.ANDROID, vaultId, createThrows = true, localLicense = null) val recvJob = async { runCatching { requestAdoption(receiverCh, receiver) { 0L } } } receiveFrame(sourceCh) sendFrame(sourceCh, AdoptionFrame.Grant( rootKeyHex = rootKeyHex, vaultId = vaultId, license = "PPK-test", source = AdoptionFrame.Source("source", "Source", "extension", "ab".repeat(32)), )) val outcome = recvJob.await() assertTrue(outcome.isFailure) assertEquals("PPK-test", receiver.licenseApplied) assertEquals(rootKeyHex, receiver.createCalledWith) } @Test fun invalid_license_fails_before_vault_creation() = runBlocking { val (receiverCh, sourceCh) = memoryPair() val rootKeyHex = "45".repeat(32) val vaultId = vaultIdFor(rootKeyHex) val receiver = object : RecorderReceiver("Receiver", DeviceType.ANDROID, vaultId, localLicense = null) { override suspend fun applyLicense(license: String) { throw IllegalStateException("license_transfer_failed") } } val receiverJob = async { runCatching { requestAdoption(receiverCh, receiver) { 0L } } } receiveFrame(sourceCh) sendFrame(sourceCh, AdoptionFrame.Grant( rootKeyHex = rootKeyHex, vaultId = vaultId, license = "PPK-invalid", source = AdoptionFrame.Source("source", "Source", "extension", "ab".repeat(32)), )) val outcome = receiverJob.await() assertTrue(outcome.isFailure) assertEquals("license_transfer_failed", outcome.exceptionOrNull()?.message) assertNull(receiver.createCalledWith) assertTrue(receiver.upserted.isEmpty()) } @Test fun validator_rejects_short_and_uppercase_root_keys() { assertTrue(validateRootKeyHex("ab".repeat(32))) assertFalse(validateRootKeyHex("ab".repeat(31))) assertFalse(validateRootKeyHex("AB".repeat(32))) assertFalse(validateRootKeyHex("zz".repeat(32))) assertFalse(validateRootKeyHex(null)) } @Test fun grant_omits_absent_optionals_like_json_stringify() { val grant = AdoptionFrame.Grant( rootKeyHex = "11".repeat(32), vaultId = "22".repeat(32), source = AdoptionFrame.Source("source", "Source", "extension", "ab".repeat(32)), ) val json = AdoptJson.encodeToString(AdoptionFrame.serializer(), grant) assertFalse(json.contains("license")) assertFalse(json.contains("ws_port")) assertFalse(json.contains("signing_public_key_hex")) assertTrue(json.contains("\"type\":\"ADOPT_GRANT\"")) assertTrue(json.contains("\"root_key_hex\"")) } @Test fun grant_json_carries_snapshot_capable_true_when_request_advertised_it() { val grant = AdoptionFrame.Grant( rootKeyHex = "11".repeat(32), vaultId = "22".repeat(32), source = AdoptionFrame.Source("source", "Source", "extension", "ab".repeat(32)), snapshotCapable = true, ) val json = AdoptJson.encodeToString(AdoptionFrame.serializer(), grant) assertTrue( "explicit snapshot_capable: true must serialize", json.contains("\"snapshot_capable\":true"), ) } @Test fun grant_decodes_snapshot_capable_false_when_field_absent() { // Android wire shape diverges from browser here: AdoptJson uses // encodeDefaults = false, so a Grant constructed with snapshotCapable = false // (the Kotlin default) serializes WITHOUT the field. The decoder then // defaults it back to false. This is wire-compatible with both ends: // pre-C sources that never set the field decode to false (receiver skips // 15s wait), and v4.6.1 browser sources that DO set it to false... actually // browser always sends explicit true OR false; Android only sends true. // Both shapes decode correctly on the receiver side because the receiver's // gate is `if (sourceCanSendSnapshot)` — missing field, false field, and // undefined all flow to "skip wait". Documented as a deliberate divergence. val withoutField = AdoptJson.decodeFromString( AdoptionFrame.serializer(), "{\"type\":\"ADOPT_GRANT\",\"root_key_hex\":\"${"11".repeat(32)}\",\"vault_id\":\"${"22".repeat(32)}\",\"source\":{\"device_id\":\"source\",\"device_name\":\"Source\",\"device_type\":\"extension\",\"long_term_public_key_hex\":\"${"ab".repeat(32)}\"}}", ) as AdoptionFrame.Grant assertFalse( "missing field must decode to default false (receiver skips 15s wait)", withoutField.snapshotCapable, ) // And explicit-false round-trips (when a future change makes Android send // explicit false, the decoder still produces false). val withFieldFalse = AdoptJson.decodeFromString( AdoptionFrame.serializer(), "{\"type\":\"ADOPT_GRANT\",\"root_key_hex\":\"${"11".repeat(32)}\",\"vault_id\":\"${"22".repeat(32)}\",\"snapshot_capable\":false,\"source\":{\"device_id\":\"source\",\"device_name\":\"Source\",\"device_type\":\"extension\",\"long_term_public_key_hex\":\"${"ab".repeat(32)}\"}}", ) as AdoptionFrame.Grant assertFalse( "explicit false must decode to false", withFieldFalse.snapshotCapable, ) } @Test fun requestAdoption_invokes_onGrant_with_decoded_grant() = runBlocking { // Receiver captures the grant seen on the wire via the onGrant callback. // Source (RecorderSource) sends an ADOPT_GRANT that echoes the receiver's // ADOPT_REQUEST.snapshot_capable, which the receiver-side callback then // surfaces for the runRelayAdoptionReceiverSession gate. val (receiverCh, sourceCh) = memoryPair() val rootKeyHex = "55".repeat(32) val vaultId = vaultIdFor(rootKeyHex) val receiverIdentity = identity("receiver").copy(deviceType = DeviceType.ANDROID) val receiver = RecorderReceiver( "Receiver", DeviceType.ANDROID, vaultId, derived = receiverIdentity, localLicense = null, ) val source = RecorderSource(identity("source"), rootKeyHex, vaultId, license = "PPK-test") val seenGrants = mutableListOf() val recvJob = async { requestAdoption( receiverCh, receiver, onGrant = { grant -> seenGrants += grant }, ) } val grantJob = async { val request = receiveFrame(sourceCh) as AdoptionFrame.Request assertTrue( "receiver must advertise snapshot_capable in ADOPT_REQUEST", request.snapshotCapable == true, ) grantAdoption(sourceCh, request, source) { 0L } } recvJob.await() grantJob.await() assertEquals(1, seenGrants.size) assertEquals(rootKeyHex, seenGrants.single().rootKeyHex) // grantAdoption echoes request.snapshot_capable === true on the wire, so // the receiver's onGrant callback sees snapshot_capable: true and the // receiver's runRelayAdoptionReceiverSession enters the 15s inline wait. // The serializer tests above cover the explicit-false case. assertTrue( "grant must echo request.snapshot_capable === true", seenGrants.single().snapshotCapable, ) } }