package com.peachpasswords.sync import kotlinx.coroutines.runBlocking import org.bouncycastle.crypto.params.X25519PrivateKeyParameters import org.junit.Assert.assertArrayEquals import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNull import org.junit.Assert.assertThrows import org.junit.Test class LockedSyncSpoolModelTest { @Test fun cumulative_inbox_counts_are_bounded_when_state_is_rebuilt() { val entries = (0..SyncLimits.MAX_ENTRY_COUNT).associate { index -> "entry-$index" to LockedSyncCachedEntry(1, "AA==") } val tombstones = (0..SyncLimits.MAX_TOMBSTONE_COUNT).associate { index -> "tombstone-$index" to 1L } assertThrows(IllegalArgumentException::class.java) { state(inboxEntries = entries) } assertThrows(IllegalArgumentException::class.java) { state(inboxTombstones = tombstones) } } @Test fun keystore_wrapped_identity_survives_a_cold_locked_restart() { val private = X25519PrivateKeyParameters(ByteArray(32) { (it + 1).toByte() }, 0) val restored = lockedSyncIdentity( state( longTermPublicKeyHex = pubkeyToHex(private.generatePublicKey().encoded), longTermPrivateKeyHex = pubkeyToHex(private.encoded), ), "wss://relay.example", ) requireNotNull(restored) assertArrayEquals(private.encoded, restored.first.secretKey) assertArrayEquals(private.generatePublicKey().encoded, restored.first.publicKey) assertEquals("device", restored.second.deviceId) assertEquals("wss://relay.example", restored.second.relayUrl) } @Test fun locked_identity_rejects_a_private_key_that_does_not_match_the_public_key() { assertNull( lockedSyncIdentity( state(longTermPrivateKeyHex = "c".repeat(64)), Rendezvous.DEFAULT_RELAY_URL, ), ) } @Test fun locked_adapter_refuses_every_device_manifest_operation() { val adapter = lockedAdapter() // A locked session must not be able to mutate trust in either direction. assertFalse(adapter.supportsDeviceManifest) assertThrows(VaultLockedException::class.java) { runBlocking { adapter.buildOutgoingDeviceManifest() } } assertThrows(VaultLockedException::class.java) { runBlocking { adapter.applyIncomingDeviceManifest(manifest(), ByteArray(32)) } } } @Test fun locked_adapter_still_stages_ciphertext_for_the_next_unlock() = runBlocking { val entries = mutableMapOf() val tombstones = mutableMapOf() val adapter = LockedSyncVaultAdapter( vaultId = "a".repeat(64), readSnapshot = { null }, stageEntriesInto = { staged, _ -> entries.putAll(staged) }, stageTombstonesInto = { tombstones.putAll(it) }, ) adapter.applyEntries(mapOf("entry-1" to "AA=="), mapOf("entry-1" to 5L)) adapter.applyTombstones(mapOf("entry-2" to 7L)) assertEquals(mapOf("entry-1" to "AA=="), entries) assertEquals(mapOf("entry-2" to 7L), tombstones) } private fun lockedAdapter() = LockedSyncVaultAdapter( vaultId = "a".repeat(64), readSnapshot = { null }, stageEntriesInto = { _, _ -> }, stageTombstonesInto = { _ -> }, ) private fun manifest() = SignedDeviceManifest( body = DeviceManifestBody( manifestVersion = DEVICE_MANIFEST_VERSION, issuerDeviceId = "issuer", issuedAt = 1L, devices = emptyList(), ), signatureHex = "00".repeat(64), ) private fun state( inboxEntries: Map = emptyMap(), inboxTombstones: Map = emptyMap(), longTermPublicKeyHex: String = "b".repeat(64), longTermPrivateKeyHex: String? = null, ) = LockedSyncSpool( vaultId = "a".repeat(64), deviceId = "device", deviceName = "Android", localVersion = 1, longTermPublicKeyHex = longTermPublicKeyHex, longTermPrivateKeyHex = longTermPrivateKeyHex, signingPublicKeyHex = "d".repeat(64), entries = emptyMap(), tombstones = emptyMap(), inboxEntries = inboxEntries, inboxTombstones = inboxTombstones, ) }