max / synckit
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
1 file changed,
+121 insertions,
-0 deletions
| @@ -448,3 +448,124 @@ | |||
| 448 | 448 | "Data must survive push encryption + pull decryption" | |
| 449 | 449 | ); | |
| 450 | 450 | } | |
| 451 | + | ||
| 452 | + | // ── Pagination is a transport detail, not a content one ── | |
| 453 | + | ||
| 454 | + | /// Build `n` encrypted changes with distinguishable payloads, starting at | |
| 455 | + | /// `first_seq`. Returns the wire JSON and the plaintexts they should decrypt to. | |
| 456 | + | fn encrypted_changes( | |
| 457 | + | key: &[u8; 32], | |
| 458 | + | device_id: DeviceId, | |
| 459 | + | first_seq: i64, | |
| 460 | + | n: i64, | |
| 461 | + | ) -> (Vec<serde_json::Value>, Vec<serde_json::Value>) { | |
| 462 | + | let mut wire = Vec::new(); | |
| 463 | + | let mut plain = Vec::new(); | |
| 464 | + | for i in 0..n { | |
| 465 | + | let seq = first_seq + i; | |
| 466 | + | let payload = json!({"title": format!("task {seq}"), "n": seq}); | |
| 467 | + | let encrypted = synckit_client::crypto::encrypt_json(&payload, key).unwrap(); | |
| 468 | + | wire.push(json!({ | |
| 469 | + | "seq": seq, | |
| 470 | + | "device_id": device_id, | |
| 471 | + | "table": "tasks", | |
| 472 | + | "op": "INSERT", | |
| 473 | + | "row_id": format!("row-{seq}"), | |
| 474 | + | "timestamp": "2025-06-01T12:00:00Z", | |
| 475 | + | "data": encrypted, | |
| 476 | + | })); | |
| 477 | + | plain.push(payload); | |
| 478 | + | } | |
| 479 | + | (wire, plain) | |
| 480 | + | } | |
| 481 | + | ||
| 482 | + | /// **Metamorphic relation:** the same six changes delivered as one page and as | |
| 483 | + | /// three pages must decrypt to the same sequence. Pagination is a property of | |
| 484 | + | /// the transport, so anything it changes about the content is a bug. | |
| 485 | + | /// | |
| 486 | + | /// The existing `pull_with_has_more_pagination` asserts the cursor plumbing | |
| 487 | + | /// against empty `changes` arrays, so it cannot see a page boundary that drops, | |
| 488 | + | /// duplicates or reorders a row. This relates two runs instead of judging one, | |
| 489 | + | /// which needs no expected-value table (Chen et al. 1998). | |
| 490 | + | #[tokio::test] | |
| 491 | + | async fn a_paginated_pull_yields_what_an_unpaginated_pull_yields() { | |
| 492 | + | let key = synckit_client::crypto::generate_master_key(); | |
| 493 | + | let device_id = DeviceId::new(Uuid::new_v4()); | |
| 494 | + | const TOTAL: i64 = 6; | |
| 495 | + | ||
| 496 | + | // Run A: one page. | |
| 497 | + | let unpaginated = { | |
| 498 | + | let server = MockServer::start().await; | |
| 499 | + | let client = authed_client(&server); | |
| 500 | + | client.set_master_key_raw(key); | |
| 501 | + | let (wire, _) = encrypted_changes(&key, device_id, 1, TOTAL); | |
| 502 | + | Mock::given(method("POST")) | |
| 503 | + | .and(path("/api/v1/sync/pull")) | |
| 504 | + | .respond_with(ResponseTemplate::new(200).set_body_json(json!({ | |
| 505 | + | "changes": wire, | |
| 506 | + | "cursor": TOTAL, | |
| 507 | + | "has_more": false, | |
| 508 | + | }))) | |
| 509 | + | .mount(&server) | |
| 510 | + | .await; | |
| 511 | + | ||
| 512 | + | let (changes, cursor, has_more) = client.pull(device_id, 0).await.unwrap(); | |
| 513 | + | assert_eq!(cursor, TOTAL); | |
| 514 | + | assert!(!has_more); | |
| 515 | + | changes | |
| 516 | + | }; | |
| 517 | + | ||
| 518 | + | // Run B: the same changes, three pages of two, drained the way a caller | |
| 519 | + | // drains them. | |
| 520 | + | let paginated = { | |
| 521 | + | let server = MockServer::start().await; | |
| 522 | + | let client = authed_client(&server); | |
| 523 | + | client.set_master_key_raw(key); | |
| 524 | + | for page in 0..3i64 { | |
| 525 | + | let first = page * 2 + 1; | |
| 526 | + | let (wire, _) = encrypted_changes(&key, device_id, first, 2); | |
| 527 | + | let cursor = first + 1; | |
| 528 | + | Mock::given(method("POST")) | |
| 529 | + | .and(path("/api/v1/sync/pull")) | |
| 530 | + | .respond_with(ResponseTemplate::new(200).set_body_json(json!({ | |
| 531 | + | "changes": wire, | |
| 532 | + | "cursor": cursor, | |
| 533 | + | "has_more": page < 2, | |
| 534 | + | }))) | |
| 535 | + | .up_to_n_times(1) | |
| 536 | + | .mount(&server) | |
| 537 | + | .await; | |
| 538 | + | } | |
| 539 | + | ||
| 540 | + | let mut collected = Vec::new(); | |
| 541 | + | let mut cursor = 0i64; | |
| 542 | + | loop { | |
| 543 | + | let (changes, next, has_more) = client.pull(device_id, cursor).await.unwrap(); | |
| 544 | + | collected.extend(changes); | |
| 545 | + | cursor = next; | |
| 546 | + | if !has_more { | |
| 547 | + | break; | |
| 548 | + | } | |
| 549 | + | } | |
| 550 | + | assert_eq!(cursor, TOTAL, "the drain should end on the same cursor"); | |
| 551 | + | collected | |
| 552 | + | }; | |
| 553 | + | ||
| 554 | + | assert_eq!( | |
| 555 | + | unpaginated.len(), | |
| 556 | + | TOTAL as usize, | |
| 557 | + | "the single-page run delivered nothing, so the comparison below is vacuous" | |
| 558 | + | ); | |
| 559 | + | assert_eq!( | |
| 560 | + | unpaginated.len(), | |
| 561 | + | paginated.len(), | |
| 562 | + | "pagination changed how many changes arrived: {} vs {}", | |
| 563 | + | unpaginated.len(), | |
| 564 | + | paginated.len() | |
| 565 | + | ); | |
| 566 | + | for (a, b) in unpaginated.iter().zip(paginated.iter()) { | |
| 567 | + | assert_eq!(a.row_id, b.row_id, "pagination reordered or dropped a row"); | |
| 568 | + | assert_eq!(a.data, b.data, "pagination changed a decrypted payload"); | |
| 569 | + | assert_eq!(a.table, b.table); | |
| 570 | + | } | |
| 571 | + | } |