1package com.peachpasswords.sync23import kotlinx.coroutines.async4import kotlinx.coroutines.channels.Channel5import kotlinx.coroutines.runBlocking6import kotlinx.coroutines.withTimeoutOrNull7import org.junit.Assert.assertEquals8import org.junit.Assert.assertFalse9import org.junit.Assert.assertNull10import org.junit.Assert.assertTrue11import org.junit.Test12131415161718class VaultAdoptTest {1920 private fun memoryPair(): Pair<PairingTransport, PairingTransport> {21 val a2b = Channel<ByteArray>(Channel.UNLIMITED)22 val b2a = Channel<ByteArray>(Channel.UNLIMITED)23 val a = object : PairingTransport {24 override suspend fun send(msg: ByteArray) { a2b.send(msg) }25 override suspend fun receive(): ByteArray = b2a.receive()26 override suspend fun close() {}27 }28 val b = object : PairingTransport {29 override suspend fun send(msg: ByteArray) { b2a.send(msg) }30 override suspend fun receive(): ByteArray = a2b.receive()31 override suspend fun close() {}32 }33 return a to b34 }3536 private fun identity(name: String) = LocalDeviceIdentity(37 deviceId = "$name-id",38 deviceName = name,39 deviceType = DeviceType.EXTENSION,40 longTermPublicKeyHex = "ab".repeat(32),41 signingPublicKeyHex = "cd".repeat(32),42 )4344 private fun vaultIdFor(rootKeyHex: String): String {45 val bytes = hexToPubkey(rootKeyHex)46 return try {47 computeVaultId(bytes)48 } finally {49 bytes.fill(0)50 }51 }5253 private open class RecorderReceiver(54 override val deviceName: String,55 override val deviceType: DeviceType,56 private val vaultId: String?,57 private val createThrows: Boolean = false,58 private val derived: LocalDeviceIdentity? = null,59 private val sasConfirmed: Boolean = true,60 private val localLicense: String? = "PPK-test",61 ) : AdoptionReceiverDeps {62 var createCalledWith: String? = null63 var licenseApplied: String? = null64 val revocationsCleared = mutableListOf<String>()65 val upserted = mutableListOf<PairedDevice>()66 val errors = mutableListOf<String>()67 var collidingSourceDeviceId: String? = null68 override fun generateDeviceId(): String = "receiver-ephemeral"69 override suspend fun confirmSas(): Boolean = sasConfirmed70 override suspend fun createVaultFromRootKey(rootKeyHex: String): String {71 createCalledWith = rootKeyHex72 if (createThrows) throw IllegalStateException("commit_failed")73 return vaultId ?: error("no vault id")74 }75 override suspend fun deriveLocalIdentity(): LocalDeviceIdentity = derived ?: error("no identity")76 override suspend fun hasSourceIdentityCollision(source: AdoptionFrame.Source): Boolean =77 source.deviceId == collidingSourceDeviceId78 override suspend fun clearPairedDeviceRevocation(deviceId: String) {79 revocationsCleared += deviceId80 }81 override suspend fun upsertPairedDevice(device: PairedDevice) { upserted += device }82 override suspend fun applyLicense(license: String) { licenseApplied = license }83 override suspend fun license(): String? = localLicense84 override fun onError(error: String) { errors += error }85 }8687 private class RecorderSource(88 override val sourceIdentity: LocalDeviceIdentity,89 private val rootKeyHex: String,90 private val vaultId: String,91 license: String? = "PPK-test",92 private val consent: Boolean = true,93 ) : AdoptionSourceDeps {94 private var storedLicense: String? = license95 val upserted = mutableListOf<PairedDevice>()96 var licenseApplied: String? = null97 var consentAsked = false98 var rootRequested = false99 var collidingDeviceId: String? = null100 var rotatedSourceIdentity: LocalDeviceIdentity? = null101 override suspend fun rootKeyHex(): String {102 rootRequested = true103 return rootKeyHex104 }105 override suspend fun vaultId(): String = vaultId106 override suspend fun license(): String? = storedLicense107 override suspend fun applyLicense(license: String) {108 licenseApplied = license109 storedLicense = license110 }111 override suspend fun onConsent(request: AdoptionFrame.Request): Boolean {112 consentAsked = true113 return consent114 }115 override fun hasDeviceIdentityCollision(device: PairedDevice): Boolean =116 device.deviceId == collidingDeviceId117 override suspend fun rotateSourceIdentity(): LocalDeviceIdentity =118 rotatedSourceIdentity ?: error("no rotated source identity")119 override suspend fun upsertPairedDevice(device: PairedDevice) { upserted += device }120 }121122 @Test fun completes_request_through_mutual_completion_acknowledgements() = runBlocking {123 val (receiverCh, sourceCh) = memoryPair()124 val rootKeyHex = "11".repeat(32)125 val vaultId = vaultIdFor(rootKeyHex)126 val receiverIdentity = identity("receiver").copy(deviceType = DeviceType.ANDROID)127 val receiver = RecorderReceiver(128 "Receiver",129 DeviceType.ANDROID,130 vaultId,131 derived = receiverIdentity,132 localLicense = null,133 )134 val source = RecorderSource(identity("source"), rootKeyHex, vaultId, license = "PPK-test")135136 val recvJob = async { requestAdoption(receiverCh, receiver) { 111L } }137 val grantJob = async {138 val request = receiveFrame(sourceCh) as AdoptionFrame.Request139 grantAdoption(sourceCh, request, source) { 222L }140 }141 recvJob.await()142 grantJob.await()143144 assertEquals(rootKeyHex, receiver.createCalledWith)145 assertEquals("PPK-test", receiver.licenseApplied)146 assertEquals(listOf("source-id"), receiver.revocationsCleared)147 assertEquals("source-id", receiver.upserted.single().deviceId)148 assertEquals("receiver-id", source.upserted.single().deviceId)149 assertEquals("android", source.upserted.single().deviceType)150 }151152 @Test fun source_preserves_existing_peer_and_accepts_rotated_identity_after_collision() = runBlocking {153 val (receiverCh, sourceCh) = memoryPair()154 val rootKeyHex = "13".repeat(32)155 val vaultId = vaultIdFor(rootKeyHex)156 val source = RecorderSource(identity("android"), rootKeyHex, vaultId).apply {157 collidingDeviceId = "chrome-id"158 }159160 val sourcePromise = async {161 grantAdoption(sourceCh, AdoptionFrame.Request(162 sessionRequestId = "receiver-ephemeral",163 deviceName = "Firefox",164 deviceType = DeviceType.EXTENSION.wireValue,165 ), source)166 }167 sendFrame(receiverCh, AdoptionFrame.Confirm(true))168 assertTrue(receiveFrame(receiverCh) is AdoptionFrame.Grant)169 sendFrame(receiverCh, AdoptionFrame.Identity(170 deviceId = "chrome-id",171 deviceName = "Firefox",172 deviceType = DeviceType.EXTENSION.wireValue,173 longTermPublicKeyHex = "11".repeat(32),174 ))175 val rejection = receiveFrame(receiverCh)176 assertTrue(rejection is AdoptionFrame.Reject)177 assertEquals("ADOPT_DEVICE_ID_COLLISION", (rejection as AdoptionFrame.Reject).reason)178 source.collidingDeviceId = null179 sendFrame(receiverCh, AdoptionFrame.Identity(180 deviceId = "firefox-id",181 deviceName = "Firefox",182 deviceType = DeviceType.EXTENSION.wireValue,183 longTermPublicKeyHex = "22".repeat(32),184 ))185 assertTrue(receiveFrame(receiverCh) is AdoptionFrame.Complete)186 sendFrame(receiverCh, AdoptionFrame.Applied)187188 assertEquals("firefox-id", sourcePromise.await()?.deviceId)189 assertEquals(listOf("firefox-id"), source.upserted.map { it.deviceId })190 }191192 @Test fun receiver_rejects_revoked_source_identity_and_accepts_one_rotation() = runBlocking {193 val (receiverCh, sourceCh) = memoryPair()194 val rootKeyHex = "14".repeat(32)195 val vaultId = vaultIdFor(rootKeyHex)196 val receiver = RecorderReceiver(197 "Android",198 DeviceType.ANDROID,199 vaultId,200 derived = identity("receiver").copy(deviceType = DeviceType.ANDROID),201 ).apply {202 collidingSourceDeviceId = "chrome-id"203 }204205 val receiverJob = async { requestAdoption(receiverCh, receiver) { 0L } }206 receiveFrame(sourceCh) 207 receiveFrame(sourceCh) 208 sendFrame(sourceCh, AdoptionFrame.Grant(209 rootKeyHex = rootKeyHex,210 vaultId = vaultId,211 source = AdoptionFrame.Source(212 "chrome-id",213 "Chrome",214 "extension",215 "ab".repeat(32),216 ),217 ))218 val rejection = receiveFrame(sourceCh)219 assertEquals("ADOPT_DEVICE_ID_COLLISION", (rejection as AdoptionFrame.Reject).reason)220221 receiver.collidingSourceDeviceId = null222 sendFrame(sourceCh, AdoptionFrame.Grant(223 rootKeyHex = rootKeyHex,224 vaultId = vaultId,225 source = AdoptionFrame.Source(226 "chrome-fresh-id",227 "Chrome",228 "extension",229 "ef".repeat(32),230 ),231 ))232 assertTrue(receiveFrame(sourceCh) is AdoptionFrame.Identity)233 sendFrame(sourceCh, AdoptionFrame.Complete)234 assertTrue(receiveFrame(sourceCh) is AdoptionFrame.Applied)235236 assertEquals("chrome-fresh-id", receiverJob.await()?.deviceId)237 assertEquals(listOf("chrome-fresh-id"), receiver.revocationsCleared)238 assertEquals(listOf("chrome-fresh-id"), receiver.upserted.map { it.deviceId })239 }240241 @Test fun source_rotates_its_revoked_identity_and_resends_grant_once() = runBlocking {242 val (receiverCh, sourceCh) = memoryPair()243 val rootKeyHex = "15".repeat(32)244 val vaultId = vaultIdFor(rootKeyHex)245 val source = RecorderSource(identity("chrome"), rootKeyHex, vaultId).apply {246 rotatedSourceIdentity = identity("chrome-fresh")247 }248249 val sourceJob = async {250 grantAdoption(sourceCh, AdoptionFrame.Request(251 sessionRequestId = "receiver-ephemeral",252 deviceName = "Android",253 deviceType = DeviceType.ANDROID.wireValue,254 ), source)255 }256 sendFrame(receiverCh, AdoptionFrame.Confirm(true))257 val firstGrant = receiveFrame(receiverCh) as AdoptionFrame.Grant258 assertEquals("chrome-id", firstGrant.source.deviceId)259 sendFrame(receiverCh, AdoptionFrame.Reject("ADOPT_DEVICE_ID_COLLISION"))260 val secondGrant = receiveFrame(receiverCh) as AdoptionFrame.Grant261 assertEquals("chrome-fresh-id", secondGrant.source.deviceId)262 sendFrame(receiverCh, AdoptionFrame.Identity(263 deviceId = "android-id",264 deviceName = "Android",265 deviceType = DeviceType.ANDROID.wireValue,266 longTermPublicKeyHex = "22".repeat(32),267 ))268 assertTrue(receiveFrame(receiverCh) is AdoptionFrame.Complete)269 sendFrame(receiverCh, AdoptionFrame.Applied)270271 assertEquals("android-id", sourceJob.await()?.deviceId)272 assertEquals(listOf("android-id"), source.upserted.map { it.deviceId })273 }274275 @Test fun rejects_without_creating_or_pairing_when_neither_peer_has_pro() = runBlocking {276 val (receiverCh, sourceCh) = memoryPair()277 val rootKeyHex = "12".repeat(32)278 val vaultId = vaultIdFor(rootKeyHex)279 val receiver = RecorderReceiver(280 "Free receiver",281 DeviceType.ANDROID,282 vaultId,283 localLicense = null,284 )285 val source = RecorderSource(286 identity("free-source"),287 rootKeyHex,288 vaultId,289 license = null,290 )291292 val receiverJob = async { requestAdoption(receiverCh, receiver) { 0L } }293 val sourceJob = async {294 val request = receiveFrame(sourceCh) as AdoptionFrame.Request295 runCatching { grantAdoption(sourceCh, request, source) { 0L } }296 }297298 assertNull(receiverJob.await())299 val sourceOutcome = sourceJob.await()300 assertTrue(sourceOutcome.isFailure)301 assertEquals("ADOPT_PRO_REQUIRED", sourceOutcome.exceptionOrNull()?.message)302 assertEquals(listOf("ADOPT_PRO_REQUIRED"), receiver.errors)303 assertNull(receiver.createCalledWith)304 assertTrue(receiver.upserted.isEmpty())305 assertTrue(source.upserted.isEmpty())306 }307308 @Test fun receiver_pro_license_is_transferred_to_a_free_source() = runBlocking {309 val (receiverCh, sourceCh) = memoryPair()310 val rootKeyHex = "13".repeat(32)311 val vaultId = vaultIdFor(rootKeyHex)312 val receiver = RecorderReceiver(313 "Pro receiver",314 DeviceType.ANDROID,315 vaultId,316 derived = identity("receiver").copy(deviceType = DeviceType.ANDROID),317 localLicense = "PPK-receiver",318 )319 val source = RecorderSource(320 identity("free-source"),321 rootKeyHex,322 vaultId,323 license = null,324 )325326 val receiverJob = async { requestAdoption(receiverCh, receiver) { 1L } }327 val sourceJob = async {328 val request = receiveFrame(sourceCh) as AdoptionFrame.Request329 grantAdoption(sourceCh, request, source) { 2L }330 }331332 assertEquals("free-source-id", receiverJob.await()?.deviceId)333 assertEquals("receiver-id", sourceJob.await()?.deviceId)334 assertEquals("PPK-receiver", source.licenseApplied)335 }336337 338339340341342343 @Test fun request_label_is_never_what_gets_persisted() = runBlocking {344 val (receiverCh, sourceCh) = memoryPair()345 val rootKeyHex = "55".repeat(32)346 val source = RecorderSource(identity("source"), rootKeyHex, vaultIdFor(rootKeyHex))347 348 val request = AdoptionFrame.Request("ephemeral-label-not-an-identity", "Receiver", "extension")349350 val grantJob = async { grantAdoption(sourceCh, request, source) { 0L } }351 sendFrame(receiverCh, AdoptionFrame.Confirm(true))352 receiveFrame(receiverCh) 353 sendFrame(354 receiverCh,355 AdoptionFrame.Identity(356 deviceId = "receiver-id",357 deviceName = "Receiver",358 deviceType = "extension",359 longTermPublicKeyHex = "aa".repeat(32),360 signingPublicKeyHex = "bb".repeat(32),361 ),362 )363 receiveFrame(receiverCh) 364 sendFrame(receiverCh, AdoptionFrame.Applied)365 grantJob.await()366367 val persisted = source.upserted.single()368 assertEquals("receiver-id", persisted.deviceId)369 assertEquals("aa".repeat(32), persisted.longTermPublicKeyHex)370 assertTrue(371 "the ephemeral request label must not reach the paired-device store",372 source.upserted.none { it.deviceId == "ephemeral-label-not-an-identity" },373 )374 }375376 @Test fun request_carries_receiver_device_type() = runBlocking {377 val (receiverCh, sourceCh) = memoryPair()378 val receiver = RecorderReceiver("Receiver", DeviceType.ANDROID, vaultId = null)379 val recvJob = async { requestAdoption(receiverCh, receiver) { 0L } }380 val request = receiveFrame(sourceCh) as AdoptionFrame.Request381 recvJob.cancel()382383 assertEquals("android", request.deviceType)384 assertEquals("receiver-ephemeral", request.sessionRequestId)385 assertEquals("Receiver", request.deviceName)386 }387388 @Test fun denied_consent_grants_nothing() = runBlocking {389 val (receiverCh, sourceCh) = memoryPair()390 val rootKeyHex = "33".repeat(32)391 val receiver = RecorderReceiver("Receiver", DeviceType.ANDROID, vaultIdFor(rootKeyHex))392 val source = RecorderSource(identity("source"), rootKeyHex, vaultIdFor(rootKeyHex), consent = false)393394 val recvJob = async { requestAdoption(receiverCh, receiver) { 0L } }395 val request = receiveFrame(sourceCh) as AdoptionFrame.Request396 val granted = grantAdoption(sourceCh, request, source) { 0L }397 recvJob.cancel()398399 assertNull(granted)400 assertTrue(source.consentAsked)401 assertFalse(source.rootRequested)402 assertTrue(source.upserted.isEmpty())403 assertNull(receiver.createCalledWith)404 }405406 @Test fun receiver_sas_rejection_grants_no_root_key() = runBlocking {407 val (receiverCh, sourceCh) = memoryPair()408 val rootKeyHex = "66".repeat(32)409 val receiver = RecorderReceiver(410 "Receiver",411 DeviceType.ANDROID,412 vaultIdFor(rootKeyHex),413 sasConfirmed = false,414 )415 val source = RecorderSource(identity("source"), rootKeyHex, vaultIdFor(rootKeyHex))416417 val receiverJob = async { requestAdoption(receiverCh, receiver) { 0L } }418 val request = receiveFrame(sourceCh) as AdoptionFrame.Request419 val sourceJob = async { grantAdoption(sourceCh, request, source) { 0L } }420421 assertNull(receiverJob.await())422 assertNull(sourceJob.await())423 assertFalse(source.rootRequested)424 assertNull(receiver.createCalledWith)425 assertTrue(receiver.revocationsCleared.isEmpty())426 assertEquals(listOf("ADOPT_SAS_REJECTED"), receiver.errors)427 }428429 @Test fun invalid_root_key_hex_aborts_as_unexpected_frame() = runBlocking {430 val (receiverCh, sourceCh) = memoryPair()431 val receiver = RecorderReceiver("Receiver", DeviceType.ANDROID, vaultId = null)432 val recvJob = async { requestAdoption(receiverCh, receiver) { 0L } }433 receiveFrame(sourceCh)434 sendFrame(sourceCh, AdoptionFrame.Grant(435 rootKeyHex = "bad",436 vaultId = "00".repeat(32),437 source = AdoptionFrame.Source("source", "Source", "extension", "ab".repeat(32)),438 ))439 recvJob.await()440441 assertEquals(listOf("ADOPT_UNEXPECTED_FRAME"), receiver.errors)442 assertNull(receiver.createCalledWith)443 }444445 @Test fun vault_id_mismatch_aborts_before_vault_creation() = runBlocking {446 val (receiverCh, sourceCh) = memoryPair()447 val receiver = RecorderReceiver("Receiver", DeviceType.ANDROID, vaultId = null)448 val recvJob = async { requestAdoption(receiverCh, receiver) { 0L } }449 receiveFrame(sourceCh)450 sendFrame(sourceCh, AdoptionFrame.Grant(451 rootKeyHex = "22".repeat(32),452 vaultId = "00".repeat(32),453 source = AdoptionFrame.Source("source", "Source", "extension", "ab".repeat(32)),454 ))455 recvJob.await()456457 assertEquals(listOf("ADOPT_VAULT_ID_MISMATCH"), receiver.errors)458 assertNull(receiver.createCalledWith)459 }460461 @Test fun source_waits_for_identity_before_persisting_receiver() = runBlocking {462 val (receiverCh, sourceCh) = memoryPair()463 val rootKeyHex = "33".repeat(32)464 val source = RecorderSource(identity("source"), rootKeyHex, vaultIdFor(rootKeyHex))465 val request = AdoptionFrame.Request("receiver-ephemeral", "Receiver", "extension")466467 sendFrame(receiverCh, AdoptionFrame.Confirm(true))468 val result = withTimeoutOrNull(150) {469 470 grantAdoption(sourceCh, request, source) { 0L }471 }472473 assertNull(result)474 assertTrue(source.upserted.isEmpty())475 }476477 @Test fun receiver_does_not_persist_source_without_complete() = runBlocking {478 val (receiverCh, sourceCh) = memoryPair()479 val rootKeyHex = "77".repeat(32)480 val vaultId = vaultIdFor(rootKeyHex)481 val receiver = RecorderReceiver(482 "Receiver",483 DeviceType.ANDROID,484 vaultId,485 derived = identity("receiver").copy(deviceType = DeviceType.ANDROID),486 )487 val receiverJob = async { requestAdoption(receiverCh, receiver) { 0L } }488489 receiveFrame(sourceCh) 490 receiveFrame(sourceCh) 491 sendFrame(492 sourceCh,493 AdoptionFrame.Grant(494 rootKeyHex = rootKeyHex,495 vaultId = vaultId,496 source = AdoptionFrame.Source(497 "source-id",498 "Source",499 "extension",500 "ab".repeat(32),501 ),502 ),503 )504 receiveFrame(sourceCh) 505 sendFrame(sourceCh, AdoptionFrame.Confirm(true))506507 assertNull(receiverJob.await())508 assertEquals(listOf("ADOPT_COMPLETION_MISSING"), receiver.errors)509 assertTrue(receiver.revocationsCleared.isEmpty())510 assertTrue(receiver.upserted.isEmpty())511 }512513 @Test fun receiver_safety_policy_runs_before_vault_creation() = runBlocking {514 val (receiverCh, sourceCh) = memoryPair()515 val rootKeyHex = "78".repeat(32)516 val vaultId = vaultIdFor(rootKeyHex)517 val receiver = object : RecorderReceiver(518 "Receiver",519 DeviceType.ANDROID,520 vaultId,521 derived = identity("receiver").copy(deviceType = DeviceType.ANDROID),522 ) {523 override suspend fun validateSource(source: AdoptionFrame.Source) {524 throw IllegalStateException("JOIN_POPULATED_VAULT_BLOCKED")525 }526 }527 val receiverJob = async {528 runCatching { requestAdoption(receiverCh, receiver) { 0L } }529 }530531 receiveFrame(sourceCh) 532 receiveFrame(sourceCh) 533 sendFrame(534 sourceCh,535 AdoptionFrame.Grant(536 rootKeyHex = rootKeyHex,537 vaultId = vaultId,538 source = AdoptionFrame.Source(539 "new-source",540 "Source",541 "extension",542 "ab".repeat(32),543 ),544 ),545 )546547 val outcome = receiverJob.await()548 assertTrue(outcome.isFailure)549 assertEquals("JOIN_POPULATED_VAULT_BLOCKED", outcome.exceptionOrNull()?.message)550 assertNull(receiver.createCalledWith)551 }552553 @Test fun applies_valid_license_before_vault_creation_failure() = runBlocking {554 val (receiverCh, sourceCh) = memoryPair()555 val rootKeyHex = "44".repeat(32)556 val vaultId = vaultIdFor(rootKeyHex)557 val receiver = RecorderReceiver("Receiver", DeviceType.ANDROID, vaultId, createThrows = true, localLicense = null)558 val recvJob = async {559 runCatching { requestAdoption(receiverCh, receiver) { 0L } }560 }561 receiveFrame(sourceCh)562 sendFrame(sourceCh, AdoptionFrame.Grant(563 rootKeyHex = rootKeyHex,564 vaultId = vaultId,565 license = "PPK-test",566 source = AdoptionFrame.Source("source", "Source", "extension", "ab".repeat(32)),567 ))568 val outcome = recvJob.await()569570 assertTrue(outcome.isFailure)571 assertEquals("PPK-test", receiver.licenseApplied)572 assertEquals(rootKeyHex, receiver.createCalledWith)573 }574575 @Test fun invalid_license_fails_before_vault_creation() = runBlocking {576 val (receiverCh, sourceCh) = memoryPair()577 val rootKeyHex = "45".repeat(32)578 val vaultId = vaultIdFor(rootKeyHex)579 val receiver = object : RecorderReceiver("Receiver", DeviceType.ANDROID, vaultId, localLicense = null) {580 override suspend fun applyLicense(license: String) {581 throw IllegalStateException("license_transfer_failed")582 }583 }584 val receiverJob = async { runCatching { requestAdoption(receiverCh, receiver) { 0L } } }585 receiveFrame(sourceCh)586 sendFrame(sourceCh, AdoptionFrame.Grant(587 rootKeyHex = rootKeyHex,588 vaultId = vaultId,589 license = "PPK-invalid",590 source = AdoptionFrame.Source("source", "Source", "extension", "ab".repeat(32)),591 ))592593 val outcome = receiverJob.await()594 assertTrue(outcome.isFailure)595 assertEquals("license_transfer_failed", outcome.exceptionOrNull()?.message)596 assertNull(receiver.createCalledWith)597 assertTrue(receiver.upserted.isEmpty())598 }599600 @Test fun validator_rejects_short_and_uppercase_root_keys() {601 assertTrue(validateRootKeyHex("ab".repeat(32)))602 assertFalse(validateRootKeyHex("ab".repeat(31)))603 assertFalse(validateRootKeyHex("AB".repeat(32)))604 assertFalse(validateRootKeyHex("zz".repeat(32)))605 assertFalse(validateRootKeyHex(null))606 }607608 @Test fun grant_omits_absent_optionals_like_json_stringify() {609 val grant = AdoptionFrame.Grant(610 rootKeyHex = "11".repeat(32),611 vaultId = "22".repeat(32),612 source = AdoptionFrame.Source("source", "Source", "extension", "ab".repeat(32)),613 )614 val json = AdoptJson.encodeToString(AdoptionFrame.serializer(), grant)615616 assertFalse(json.contains("license"))617 assertFalse(json.contains("ws_port"))618 assertFalse(json.contains("signing_public_key_hex"))619 assertTrue(json.contains("\"type\":\"ADOPT_GRANT\""))620 assertTrue(json.contains("\"root_key_hex\""))621 }622623 @Test fun grant_json_carries_snapshot_capable_true_when_request_advertised_it() {624 val grant = AdoptionFrame.Grant(625 rootKeyHex = "11".repeat(32),626 vaultId = "22".repeat(32),627 source = AdoptionFrame.Source("source", "Source", "extension", "ab".repeat(32)),628 snapshotCapable = true,629 )630 val json = AdoptJson.encodeToString(AdoptionFrame.serializer(), grant)631 assertTrue(632 "explicit snapshot_capable: true must serialize",633 json.contains("\"snapshot_capable\":true"),634 )635 }636637 @Test fun grant_decodes_snapshot_capable_false_when_field_absent() {638 639 640 641 642 643 644 645 646 647 648 val withoutField = AdoptJson.decodeFromString(649 AdoptionFrame.serializer(),650 "{\"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)}\"}}",651 ) as AdoptionFrame.Grant652 assertFalse(653 "missing field must decode to default false (receiver skips 15s wait)",654 withoutField.snapshotCapable,655 )656 657 658 val withFieldFalse = AdoptJson.decodeFromString(659 AdoptionFrame.serializer(),660 "{\"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)}\"}}",661 ) as AdoptionFrame.Grant662 assertFalse(663 "explicit false must decode to false",664 withFieldFalse.snapshotCapable,665 )666 }667668 @Test fun requestAdoption_invokes_onGrant_with_decoded_grant() = runBlocking {669 670 671 672 673 val (receiverCh, sourceCh) = memoryPair()674 val rootKeyHex = "55".repeat(32)675 val vaultId = vaultIdFor(rootKeyHex)676 val receiverIdentity = identity("receiver").copy(deviceType = DeviceType.ANDROID)677 val receiver = RecorderReceiver(678 "Receiver",679 DeviceType.ANDROID,680 vaultId,681 derived = receiverIdentity,682 localLicense = null,683 )684 val source = RecorderSource(identity("source"), rootKeyHex, vaultId, license = "PPK-test")685 val seenGrants = mutableListOf<AdoptionFrame.Grant>()686687 val recvJob = async {688 requestAdoption(689 receiverCh,690 receiver,691 onGrant = { grant -> seenGrants += grant },692 )693 }694 val grantJob = async {695 val request = receiveFrame(sourceCh) as AdoptionFrame.Request696 assertTrue(697 "receiver must advertise snapshot_capable in ADOPT_REQUEST",698 request.snapshotCapable == true,699 )700 grantAdoption(sourceCh, request, source) { 0L }701 }702 recvJob.await()703 grantJob.await()704705 assertEquals(1, seenGrants.size)706 assertEquals(rootKeyHex, seenGrants.single().rootKeyHex)707 708 709 710 711 assertTrue(712 "grant must echo request.snapshot_capable === true",713 seenGrants.single().snapshotCapable,714 )715 }716}