Skip to main content

max / synckit

Relate a paginated pull to an unpaginated one `pull_with_has_more_pagination` asserts the cursor plumbing against empty `changes` arrays, so it cannot see a page boundary that drops, duplicates or reorders a row. Pagination is a property of the transport, so anything it changes about the content is a bug, and relating two runs states that without needing an expected-value table. Six encrypted changes delivered as one page and as three pages of two must decrypt to the same sequence. Guarded against passing vacuously: the single-page run has to deliver all six before the comparison means anything. Checked by shortening the middle page, which fails on the arrival count. Phase 2 of wiki `testing-posture`.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-05 15:14 UTC
Signed with PGP, not checked
Commit: 84cd1736fec3c119683bdca4422d7656501084fe
Parent: 1063445
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 + }