1package com.peachpasswords.sync23import kotlinx.coroutines.runBlocking4import org.bouncycastle.crypto.params.X25519PrivateKeyParameters5import org.junit.Assert.assertArrayEquals6import org.junit.Assert.assertEquals7import org.junit.Assert.assertFalse8import org.junit.Assert.assertNull9import org.junit.Assert.assertThrows10import org.junit.Test1112class LockedSyncSpoolModelTest {13 @Test fun cumulative_inbox_counts_are_bounded_when_state_is_rebuilt() {14 val entries = (0..SyncLimits.MAX_ENTRY_COUNT).associate { index ->15 "entry-$index" to LockedSyncCachedEntry(1, "AA==")16 }17 val tombstones = (0..SyncLimits.MAX_TOMBSTONE_COUNT).associate { index ->18 "tombstone-$index" to 1L19 }2021 assertThrows(IllegalArgumentException::class.java) { state(inboxEntries = entries) }22 assertThrows(IllegalArgumentException::class.java) { state(inboxTombstones = tombstones) }23 }2425 @Test fun keystore_wrapped_identity_survives_a_cold_locked_restart() {26 val private = X25519PrivateKeyParameters(ByteArray(32) { (it + 1).toByte() }, 0)27 val restored = lockedSyncIdentity(28 state(29 longTermPublicKeyHex = pubkeyToHex(private.generatePublicKey().encoded),30 longTermPrivateKeyHex = pubkeyToHex(private.encoded),31 ),32 "wss://relay.example",33 )3435 requireNotNull(restored)36 assertArrayEquals(private.encoded, restored.first.secretKey)37 assertArrayEquals(private.generatePublicKey().encoded, restored.first.publicKey)38 assertEquals("device", restored.second.deviceId)39 assertEquals("wss://relay.example", restored.second.relayUrl)40 }4142 @Test fun locked_identity_rejects_a_private_key_that_does_not_match_the_public_key() {43 assertNull(44 lockedSyncIdentity(45 state(longTermPrivateKeyHex = "c".repeat(64)),46 Rendezvous.DEFAULT_RELAY_URL,47 ),48 )49 }5051 @Test fun locked_adapter_refuses_every_device_manifest_operation() {52 val adapter = lockedAdapter()5354 55 assertFalse(adapter.supportsDeviceManifest)56 assertThrows(VaultLockedException::class.java) {57 runBlocking { adapter.buildOutgoingDeviceManifest() }58 }59 assertThrows(VaultLockedException::class.java) {60 runBlocking { adapter.applyIncomingDeviceManifest(manifest(), ByteArray(32)) }61 }62 }6364 @Test fun locked_adapter_still_stages_ciphertext_for_the_next_unlock() = runBlocking {65 val entries = mutableMapOf<String, String>()66 val tombstones = mutableMapOf<String, Long>()67 val adapter = LockedSyncVaultAdapter(68 vaultId = "a".repeat(64),69 readSnapshot = { null },70 stageEntriesInto = { staged, _ -> entries.putAll(staged) },71 stageTombstonesInto = { tombstones.putAll(it) },72 )7374 adapter.applyEntries(mapOf("entry-1" to "AA=="), mapOf("entry-1" to 5L))75 adapter.applyTombstones(mapOf("entry-2" to 7L))7677 assertEquals(mapOf("entry-1" to "AA=="), entries)78 assertEquals(mapOf("entry-2" to 7L), tombstones)79 }8081 private fun lockedAdapter() = LockedSyncVaultAdapter(82 vaultId = "a".repeat(64),83 readSnapshot = { null },84 stageEntriesInto = { _, _ -> },85 stageTombstonesInto = { _ -> },86 )8788 private fun manifest() = SignedDeviceManifest(89 body = DeviceManifestBody(90 manifestVersion = DEVICE_MANIFEST_VERSION,91 issuerDeviceId = "issuer",92 issuedAt = 1L,93 devices = emptyList(),94 ),95 signatureHex = "00".repeat(64),96 )9798 private fun state(99 inboxEntries: Map<String, LockedSyncCachedEntry> = emptyMap(),100 inboxTombstones: Map<String, Long> = emptyMap(),101 longTermPublicKeyHex: String = "b".repeat(64),102 longTermPrivateKeyHex: String? = null,103 ) = LockedSyncSpool(104 vaultId = "a".repeat(64),105 deviceId = "device",106 deviceName = "Android",107 localVersion = 1,108 longTermPublicKeyHex = longTermPublicKeyHex,109 longTermPrivateKeyHex = longTermPrivateKeyHex,110 signingPublicKeyHex = "d".repeat(64),111 entries = emptyMap(),112 tombstones = emptyMap(),113 inboxEntries = inboxEntries,114 inboxTombstones = inboxTombstones,115 )116}