1package com.peachpasswords.sync23import kotlinx.coroutines.channels.Channel4import kotlinx.coroutines.runBlocking5import org.junit.Assert.assertEquals6import org.junit.Assert.assertNull7import org.junit.Assert.assertTrue8import org.junit.Test9101112131415class VaultAdoptSnapshotTest {1617 private suspend fun sendSnapshotFrame(channel: PairingTransport, frame: AdoptionFrame.Snapshot) {18 val raw = AdoptJson.encodeToString(AdoptionFrame.serializer(), frame).encodeToByteArray()19 channel.send(SyncPadding.pad(raw))20 }2122 @Test23 fun `PB-26-706 snapshot frames use the shared sync padding wire format`() = runBlocking {24 val sent = mutableListOf<ByteArray>()25 val channel = object : PairingTransport {26 override suspend fun send(msg: ByteArray) { sent += msg }27 override suspend fun receive(): ByteArray = error("unused")28 override suspend fun close() {}29 }3031 sendVaultSnapshot(channel, mapOf("id-0" to "ciphertext"), emptyMap())3233 val wire = sent.single()34 assertEquals(SyncPadding.bucket(wire.size), wire.size)35 assertTrue(String(wire, Charsets.UTF_8).first() != '{')36 assertTrue(String(SyncPadding.unpad(wire), Charsets.UTF_8).startsWith("{\"type\":\"ADOPT_SNAPSHOT\""))37 }3839 private fun memoryPair(): Pair<PairingTransport, PairingTransport> {40 val a2b = Channel<ByteArray>(Channel.UNLIMITED)41 val b2a = Channel<ByteArray>(Channel.UNLIMITED)42 val a = object : PairingTransport {43 override suspend fun send(msg: ByteArray) { a2b.send(msg) }44 override suspend fun receive(): ByteArray = b2a.receive()45 override suspend fun close() {}46 }47 val b = object : PairingTransport {48 override suspend fun send(msg: ByteArray) { b2a.send(msg) }49 override suspend fun receive(): ByteArray = a2b.receive()50 override suspend fun close() {}51 }52 return a to b53 }5455 @Test56 fun `round-trips an empty vault`() = runBlocking {57 val (source, receiver) = memoryPair()58 sendVaultSnapshot(source, emptyMap(), emptyMap())59 val (entries, tombstones) = receiveVaultSnapshot(receiver)60 assertTrue(entries.isEmpty())61 assertTrue(tombstones.isEmpty())62 }6364 @Test65 fun `round-trips 50 entries and 5 tombstones in a single chunk`() = runBlocking {66 val (source, receiver) = memoryPair()67 val entries = (0 until 50).associate { "id-$it" to "cipher-$it" }68 val tombstones = (0 until 5).associate { "dead-$it" to (1000L + it) }69 sendVaultSnapshot(source, entries, tombstones)70 val (receivedEntries, receivedTombstones) = receiveVaultSnapshot(receiver)71 assertEquals(entries, receivedEntries)72 assertEquals(tombstones, receivedTombstones)73 }7475 @Test76 fun `splits a large vault into 3+ chunks and reassembles`() = runBlocking {77 val (source, receiver) = memoryPair()78 var frames = 079 val countingSource = object : PairingTransport {80 override suspend fun send(msg: ByteArray) { frames++; source.send(msg) }81 override suspend fun receive(): ByteArray = source.receive()82 override suspend fun close() {}83 }84 val big = "x".repeat(200 * 1024)85 val entries = (0 until 5).associate { "id-$it" to big }86 val tombstones = mapOf("dead-0" to 42L)87 sendVaultSnapshot(countingSource, entries, tombstones)88 val (receivedEntries, receivedTombstones) = receiveVaultSnapshot(receiver)89 assertTrue("expected 3+ chunks, got $frames", frames >= 3)90 assertEquals(entries, receivedEntries)91 assertEquals(tombstones, receivedTombstones)92 }9394 @Test95 fun `rejects out-of-order chunks`() = runBlocking {96 val (source, receiver) = memoryPair()97 sendSnapshotFrame(source, AdoptionFrame.Snapshot(98 chunkIndex = 1,99 chunkCount = 2,100 entries = mapOf("a" to "x"),101 tombstones = emptyMap(),102 ))103 val error = runCatching { receiveVaultSnapshot(receiver) }.exceptionOrNull()104 assertTrue(error is IllegalStateException)105 assertTrue(error!!.message!!.contains("OUT_OF_ORDER"))106 }107108 @Test109 fun `rejects chunk_count exceeding MAX_SNAPSHOT_CHUNKS`() = runBlocking {110 val (source, receiver) = memoryPair()111 sendSnapshotFrame(source, AdoptionFrame.Snapshot(112 chunkIndex = 0,113 chunkCount = MAX_SNAPSHOT_CHUNKS + 1,114 entries = emptyMap(),115 tombstones = emptyMap(),116 ))117 val error = runCatching { receiveVaultSnapshot(receiver) }.exceptionOrNull()118 assertTrue(error is IllegalStateException)119 assertTrue(error!!.message!!.contains("TOO_MANY_CHUNKS"))120 }121122 @Test123 fun `validates ADOPT_SNAPSHOT frames`() {124 val valid = AdoptionFrame.Snapshot(125 chunkIndex = 0, chunkCount = 1,126 entries = mapOf("a" to "x"), tombstones = emptyMap(),127 )128 assertTrue(isAdoptSnapshot(valid))129 assertTrue(!isAdoptSnapshot(null))130 assertTrue(!isAdoptSnapshot(AdoptionFrame.Complete))131 }132133 @Test134 fun `snapshot_capable serializes in ADOPT_REQUEST`() {135 val request = AdoptionFrame.Request(136 sessionRequestId = "req-1",137 deviceName = "test",138 deviceType = "extension",139 snapshotCapable = true,140 )141 val json = AdoptJson.encodeToString(AdoptionFrame.serializer(), request)142 assertTrue(json.contains("\"snapshot_capable\":true"))143 144 val noFlag = AdoptionFrame.Request(145 sessionRequestId = "req-2",146 deviceName = "test",147 deviceType = "extension",148 )149 val jsonNoFlag = AdoptJson.encodeToString(AdoptionFrame.serializer(), noFlag)150 assertTrue(!jsonNoFlag.contains("snapshot_capable"))151 }152153 @Test154 fun `rejects padded frame exceeding wire ceiling`() = runBlocking {155 156 157 158 159 val oversized = ByteArray(MAX_PADDED_SNAPSHOT_FRAME_BYTES + 1)160 val channel = object : PairingTransport {161 override suspend fun send(msg: ByteArray) {}162 override suspend fun receive(): ByteArray = oversized163 override suspend fun close() {}164 }165 val error = runCatching { receiveVaultSnapshot(channel) }.exceptionOrNull()166 assertTrue(error is IllegalStateException)167 assertTrue(error!!.message!!.contains("CHUNK_TOO_LARGE"))168 }169170 @Test171 fun `rejects chunk_count mismatch on subsequent frame`() = runBlocking {172 val (source, receiver) = memoryPair()173 sendSnapshotFrame(source, AdoptionFrame.Snapshot(174 chunkIndex = 0,175 chunkCount = 2,176 entries = mapOf("a" to "x"),177 tombstones = emptyMap(),178 ))179 sendSnapshotFrame(source, AdoptionFrame.Snapshot(180 chunkIndex = 1,181 chunkCount = 3,182 entries = mapOf("b" to "y"),183 tombstones = emptyMap(),184 ))185 val error = runCatching { receiveVaultSnapshot(receiver) }.exceptionOrNull()186 assertTrue(error is IllegalStateException)187 assertTrue(error!!.message!!.contains("CHUNK_COUNT_MISMATCH"))188 }189190 @Test191 fun `round-trips duress_slot on final chunk`() = runBlocking {192 val (source, receiver) = memoryPair()193 val entries = mapOf("id-0" to "cipher-0")194 val tombstones = mapOf("dead-0" to 42L)195 val duressJson = """{"version":1,"salt":"aa","nonce":"bb","ciphertext":"cc","tag":"dd"}"""196 sendVaultSnapshot(source, entries, tombstones, duressSlot = duressJson)197 val snapshot = receiveVaultSnapshot(receiver)198 assertEquals(entries, snapshot.entries)199 assertEquals(tombstones, snapshot.tombstones)200 assertEquals(duressJson, snapshot.duressSlot)201 }202203 @Test204 fun `duress_slot absent returns null`() = runBlocking {205 val (source, receiver) = memoryPair()206 sendVaultSnapshot(source, mapOf("id-0" to "cipher-0"), emptyMap())207 val snapshot = receiveVaultSnapshot(receiver)208 assertNull(snapshot.duressSlot)209 }210211 @Test212 fun `duress_slot rides final chunk only in multi-chunk transfer`() = runBlocking {213 val (source, receiver) = memoryPair()214 val big = "x".repeat(200 * 1024)215 val entries = (0 until 5).associate { "id-$it" to big }216 val tombstones = mapOf("dead-0" to 42L)217 val duressJson = """{"version":1,"salt":"ff","nonce":"ee","ciphertext":"dd","tag":"cc"}"""218 sendVaultSnapshot(source, entries, tombstones, duressSlot = duressJson)219 val snapshot = receiveVaultSnapshot(receiver)220 assertEquals(entries, snapshot.entries)221 assertEquals(tombstones, snapshot.tombstones)222 assertEquals(duressJson, snapshot.duressSlot)223 }224225 @Test226 fun `old frame without duress_slot deserializes`() {227 val oldJson = """{"type":"ADOPT_SNAPSHOT","chunk_index":0,"chunk_count":1,"entries":{"a":"x"},"tombstones":{}}"""228 val frame = runCatching {229 AdoptJson.decodeFromString(AdoptionFrame.serializer(), oldJson)230 }.getOrNull()231 assertTrue(isAdoptSnapshot(frame))232 assertTrue((frame as AdoptionFrame.Snapshot).duressSlot == null)233 }234235 236 237 238 @Test239 fun `frame ceiling 768KiB duress on separate frame round-trips`() = runBlocking {240 val (source, receiver) = memoryPair()241 val entries = mapOf("id-0" to "x".repeat(450 * 1024))242 val tombstones = mapOf("dead-0" to 1L)243 244 245 246 val duressJson = ("""{"version":1,"salt":"${"a".repeat(16)}",""" +247 """"nonce":"${"b".repeat(12)}",""" +248 """"ciphertext":"${"c".repeat(320 * 1024)}",""" +249 """"tag":"${"d".repeat(16)}"}""")250 sendVaultSnapshot(source, entries, tombstones, duressSlot = duressJson)251 val snapshot = receiveVaultSnapshot(receiver)252 assertEquals(entries, snapshot.entries)253 assertEquals(tombstones, snapshot.tombstones)254 assertEquals(duressJson, snapshot.duressSlot)255 }256257 258 259 260 @Test261 fun `intermediate frame sizes stay under wire ceiling when duress rides its own frame`() = runBlocking {262 val (source, _) = memoryPair()263 val observedRawSizes = mutableListOf<Int>()264 val observedWireSizes = mutableListOf<Int>()265 val observedHasDuress = mutableListOf<Boolean>()266 val observedEntriesSize = mutableListOf<Int>()267 val probe = object : PairingTransport {268 override suspend fun send(msg: ByteArray) {269 observedWireSizes.add(msg.size)270 val raw = SyncPadding.unpad(msg)271 observedRawSizes.add(raw.size)272 val parsed = runCatching {273 AdoptJson.decodeFromString(AdoptionFrame.Snapshot.serializer(), String(raw, Charsets.UTF_8))274 }.getOrNull()275 observedHasDuress.add(parsed?.duressSlot != null)276 observedEntriesSize.add(parsed?.entries?.size ?: 0)277 source.send(msg)278 }279 override suspend fun receive(): ByteArray = source.receive()280 override suspend fun close() {}281 }282 val entries = mapOf("id-0" to "x".repeat(450 * 1024))283 val duressJson = "y".repeat(320 * 1024)284 val ceiling = MAX_SNAPSHOT_CHUNK_BYTES + 256 * 1024 285286 sendVaultSnapshot(probe, entries, mapOf("dead-0" to 1L), duressSlot = duressJson)287288 assertEquals(2, observedRawSizes.size)289 assertTrue(observedWireSizes.all { it == SyncPadding.bucket(it) })290 291 assertEquals(false, observedHasDuress[0])292 assertEquals(1, observedEntriesSize[0])293 org.junit.Assert.assertTrue(294 "entries frame ${observedRawSizes[0]} should be < ceiling $ceiling",295 observedRawSizes[0] < ceiling296 )297 298 assertEquals(true, observedHasDuress[1])299 assertEquals(0, observedEntriesSize[1])300 org.junit.Assert.assertTrue(301 "duress frame ${observedRawSizes[1]} should be < ceiling $ceiling",302 observedRawSizes[1] < ceiling303 )304 305 306 org.junit.Assert.assertTrue(307 "combined raw ${observedRawSizes[0] + observedRawSizes[1]} should exceed ceiling $ceiling",308 observedRawSizes[0] + observedRawSizes[1] > ceiling309 )310 }311312 313 314 315 @Test316 fun `legacy duress on entries final chunk still decodes`() = runBlocking {317 val (source, receiver) = memoryPair()318 val duressJson = """{"version":1,"salt":"aa","nonce":"bb","ciphertext":"cc","tag":"dd"}"""319 320 321 sendSnapshotFrame(source, AdoptionFrame.Snapshot(322 chunkIndex = 0,323 chunkCount = 1,324 entries = mapOf("id-0" to "ciphertext"),325 tombstones = mapOf("dead-0" to 42L),326 duressSlot = duressJson,327 ))328 val snapshot = receiveVaultSnapshot(receiver)329 assertEquals(mapOf("id-0" to "ciphertext"), snapshot.entries)330 assertEquals(mapOf("dead-0" to 42L), snapshot.tombstones)331 assertEquals(duressJson, snapshot.duressSlot)332 }333334 335 336 337 338 @Test339 fun `duress is reserved a frame so 199 entries plus duress fits in MAX_SNAPSHOT_CHUNKS`() = runBlocking {340 val (source, receiver) = memoryPair()341 val duressJson = """{"version":1,"salt":"aa","nonce":"bb","ciphertext":"cc","tag":"dd"}"""342 val big = "x".repeat(40 * 1024)343 val entries = (0 until 199).associate { "id-$it" to big }344 sendVaultSnapshot(source, entries, mapOf("dead-0" to 1L), duressSlot = duressJson)345 val snapshot = receiveVaultSnapshot(receiver)346 assertEquals(entries, snapshot.entries)347 assertEquals(mapOf("dead-0" to 1L), snapshot.tombstones)348 assertEquals(duressJson, snapshot.duressSlot)349 }350351 352 353 354 355 356 @Test357 fun `over-budget snapshot preflights with zero frames sent`() = runBlocking {358 val (source, _) = memoryPair()359 var frames = 0360 val countingSource = object : PairingTransport {361 override suspend fun send(msg: ByteArray) { frames++; source.send(msg) }362 override suspend fun receive(): ByteArray = source.receive()363 override suspend fun close() {}364 }365 val duressJson = """{"version":1,"salt":"aa","nonce":"bb","ciphertext":"cc","tag":"dd"}"""366 367 val big = "x".repeat(60 * 1024)368 val entries = (0 until 200).associate { "id-$it" to big }369 val error = runCatching {370 sendVaultSnapshot(countingSource, entries, mapOf("dead-0" to 1L), duressSlot = duressJson)371 }.exceptionOrNull()372 assertTrue(error is IllegalStateException)373 assertTrue(error!!.message!!.contains("ADOPT_SNAPSHOT_OVER_BUDGET"))374 assertEquals(0, frames)375 }376377 378 379 380 @Test381 fun `duress larger than wire ceiling throws loudly before sending`() = runBlocking {382 val (source, _) = memoryPair()383 var frames = 0384 val countingSource = object : PairingTransport {385 override suspend fun send(msg: ByteArray) { frames++; source.send(msg) }386 override suspend fun receive(): ByteArray = source.receive()387 override suspend fun close() {}388 }389 val ceiling = 768 * 1024390 val emptyWireSize = AdoptJson.encodeToString(391 AdoptionFrame.serializer(),392 AdoptionFrame.Snapshot(1, 2, emptyMap(), emptyMap(), ""),393 ).encodeToByteArray().size394 val duressJson = "x".repeat(ceiling - emptyWireSize + 1)395 val error = runCatching {396 sendVaultSnapshot(countingSource, mapOf("id-0" to "small"), emptyMap(), duressSlot = duressJson)397 }.exceptionOrNull()398 assertTrue(error is SnapshotDuressFrameTooLargeException)399 assertEquals(0, frames)400 }401402 403 404 405406 @Test407 fun `V1 frame bytes at the aggregate limit pass, limit plus one rejects before merge`() {408 val atLimit = createAdoptSnapshotBudget()409 atLimit.bytes = MAX_ADOPT_SNAPSHOT_BYTES - 100410 applyAdoptSnapshotFrame(atLimit, mapOf("a" to "x"), emptyMap(), null, 100)411 assertEquals(MAX_ADOPT_SNAPSHOT_BYTES, atLimit.bytes)412413 val over = createAdoptSnapshotBudget()414 over.bytes = MAX_ADOPT_SNAPSHOT_BYTES - 100415 val error = runCatching {416 applyAdoptSnapshotFrame(over, mapOf("a" to "x"), emptyMap(), null, 101)417 }.exceptionOrNull()418 assertTrue(error is IllegalStateException)419 assertTrue(error!!.message!!.contains("ADOPT_SNAPSHOT_OVER_BUDGET"))420 421 assertTrue(over.entries.isEmpty())422 }423424 @Test425 fun `V2 25000 entries pass, the 25001st rejects`() {426 val entries = (0 until MAX_ADOPT_SNAPSHOT_ENTRIES).associate { "e$it" to "v" }427 val acc = createAdoptSnapshotBudget()428 applyAdoptSnapshotFrame(acc, entries, emptyMap(), null, 100)429 assertEquals(MAX_ADOPT_SNAPSHOT_ENTRIES, acc.entryCount)430 val error = runCatching {431 applyAdoptSnapshotFrame(acc, mapOf("oneMore" to "v"), emptyMap(), null, 1)432 }.exceptionOrNull()433 assertTrue(error is IllegalStateException)434 assertTrue(error!!.message!!.contains("ADOPT_SNAPSHOT_TOO_MANY_ENTRIES"))435 assertNull(acc.entries["oneMore"])436 }437438 @Test439 fun `V3 25000 tombstones pass, the 25001st rejects`() {440 val tombstones = (0 until MAX_ADOPT_SNAPSHOT_TOMBSTONES).associate { "dead-$it" to (it + 1L) }441 val acc = createAdoptSnapshotBudget()442 applyAdoptSnapshotFrame(acc, emptyMap(), tombstones, null, 100)443 assertEquals(MAX_ADOPT_SNAPSHOT_TOMBSTONES, acc.tombstoneCount)444 val error = runCatching {445 applyAdoptSnapshotFrame(acc, emptyMap(), mapOf("oneMore" to 1L), null, 1)446 }.exceptionOrNull()447 assertTrue(error is IllegalStateException)448 assertTrue(error!!.message!!.contains("ADOPT_SNAPSHOT_TOO_MANY_TOMBSTONES"))449 assertNull(acc.tombstones["oneMore"])450 }451452 @Test453 fun `V4 entry and tombstone IDs of 512 chars pass, 513 reject`() {454 val ok = "k".repeat(MAX_ADOPT_SNAPSHOT_ID_CHARS)455 val acc = createAdoptSnapshotBudget()456 applyAdoptSnapshotFrame(acc, mapOf(ok to "v"), mapOf(ok to 1L), null, 10)457 assertEquals("v", acc.entries[ok])458459 val tooLong = "k".repeat(MAX_ADOPT_SNAPSHOT_ID_CHARS + 1)460 val entryError = runCatching {461 applyAdoptSnapshotFrame(createAdoptSnapshotBudget(), mapOf(tooLong to "v"), emptyMap(), null, 10)462 }.exceptionOrNull()463 assertTrue(entryError is IllegalStateException)464 assertTrue(entryError!!.message!!.contains("ADOPT_SNAPSHOT_ID_TOO_LONG"))465 val tombstoneError = runCatching {466 applyAdoptSnapshotFrame(createAdoptSnapshotBudget(), emptyMap(), mapOf(tooLong to 1L), null, 10)467 }.exceptionOrNull()468 assertTrue(tombstoneError is IllegalStateException)469 assertTrue(tombstoneError!!.message!!.contains("ADOPT_SNAPSHOT_ID_TOO_LONG"))470 }471472 @Test473 fun `V5 envelope at the base64 overhead ceiling passes, plus one rejects`() {474 val atLimit = "c".repeat(MAX_ADOPT_SNAPSHOT_ENVELOPE_BYTES)475 val acc = createAdoptSnapshotBudget()476 applyAdoptSnapshotFrame(acc, mapOf("e1" to atLimit), emptyMap(), null, 10)477 assertEquals(atLimit, acc.entries["e1"])478479 val over = "c".repeat(MAX_ADOPT_SNAPSHOT_ENVELOPE_BYTES + 1)480 val error = runCatching {481 applyAdoptSnapshotFrame(createAdoptSnapshotBudget(), mapOf("e1" to over), emptyMap(), null, 10)482 }.exceptionOrNull()483 assertTrue(error is IllegalStateException)484 assertTrue(error!!.message!!.contains("ADOPT_SNAPSHOT_ENVELOPE_TOO_LARGE"))485 }486487 @Test488 fun `V6 duress slot at the wire frame ceiling passes, plus one rejects`() {489 val acc = createAdoptSnapshotBudget()490 applyAdoptSnapshotFrame(acc, emptyMap(), emptyMap(), "d".repeat(SNAPSHOT_FRAME_WIRE_CEILING), 10)491 assertEquals(SNAPSHOT_FRAME_WIRE_CEILING, acc.duressSlot!!.length)492493 val error = runCatching {494 applyAdoptSnapshotFrame(495 createAdoptSnapshotBudget(), emptyMap(), emptyMap(),496 "d".repeat(SNAPSHOT_FRAME_WIRE_CEILING + 1), 10,497 )498 }.exceptionOrNull()499 assertTrue(error is IllegalStateException)500 assertTrue(error!!.message!!.contains("ADOPT_SNAPSHOT_DURESS_FRAME_TOO_LARGE"))501 }502503 @Test504 fun `V7 non-integer tombstone JSON rejects before merge`() = runBlocking {505 506 507 508 509 for (hostileValue in listOf("1.5", "99999999999999999999999", "NaN")) {510 val (source, receiver) = memoryPair()511 val hostile = """{"type":"ADOPT_SNAPSHOT","chunk_index":0,"chunk_count":1,"entries":{},"tombstones":{"dead":$hostileValue}}"""512 source.send(SyncPadding.pad(hostile.encodeToByteArray()))513 val error = runCatching { receiveVaultSnapshot(receiver) }.exceptionOrNull()514 assertTrue("value $hostileValue must reject", error is IllegalStateException)515 assertTrue(error!!.message!!.contains("ADOPT_SNAPSHOT_INVALID_FRAME"))516 }517 }518519 @Test520 fun `V8 high-count low-byte snapshot round-trips 25000 tiny entries`() = runBlocking {521 val (source, receiver) = memoryPair()522 val entries = (0 until MAX_ADOPT_SNAPSHOT_ENTRIES).associate { "e$it" to "v" }523 sendVaultSnapshot(source, entries, emptyMap())524 val snapshot = receiveVaultSnapshot(receiver)525 assertEquals(MAX_ADOPT_SNAPSHOT_ENTRIES, snapshot.entries.size)526 }527528 @Test529 fun `V9 low-count high-byte stream over the aggregate rejects with no partial result`() = runBlocking {530 531 532 val (source, receiver) = memoryPair()533 val envelope = "c".repeat(690_000)534 val chunks = 16535 536 537 for (i in 0 until chunks) {538 sendSnapshotFrame(source, AdoptionFrame.Snapshot(539 chunkIndex = i,540 chunkCount = chunks,541 entries = mapOf("big-$i" to envelope),542 tombstones = emptyMap(),543 ))544 }545 val error = runCatching { receiveVaultSnapshot(receiver) }.exceptionOrNull()546 assertTrue(error is IllegalStateException)547 assertTrue(error!!.message!!.contains("ADOPT_SNAPSHOT_OVER_BUDGET"))548 }549550 @Test551 fun `V10 over-budget sender preflights with zero frames sent`() = runBlocking {552 val (source, _) = memoryPair()553 var frames = 0554 val countingSource = object : PairingTransport {555 override suspend fun send(msg: ByteArray) { frames++; source.send(msg) }556 override suspend fun receive(): ByteArray = source.receive()557 override suspend fun close() {}558 }559 val envelope = "c".repeat(690_000)560 val entries = (0 until 16).associate { "big-$it" to envelope }561 val error = runCatching {562 sendVaultSnapshot(countingSource, entries, emptyMap())563 }.exceptionOrNull()564 assertTrue(error is IllegalStateException)565 assertTrue(error!!.message!!.contains("ADOPT_SNAPSHOT_OVER_BUDGET"))566 assertEquals(0, frames)567 }568569 @Test570 fun `V11 duress bytes count against the aggregate budget`() {571 val acc = createAdoptSnapshotBudget()572 acc.bytes = MAX_ADOPT_SNAPSHOT_BYTES - 100573 574 val error = runCatching {575 applyAdoptSnapshotFrame(acc, emptyMap(), emptyMap(), "d".repeat(200), 200)576 }.exceptionOrNull()577 assertTrue(error is IllegalStateException)578 assertTrue(error!!.message!!.contains("ADOPT_SNAPSHOT_OVER_BUDGET"))579 }580}