Skip to main content

max / synckit

Move seven test modules to sibling files crypto, conflict, hlc, helpers, apply, sync and types each carried a trailing inline test module holding two thirds of the file. Each becomes a tests.rs sibling behind a `#[cfg(test)] mod tests;` declaration. crypto.rs goes 2319 lines to 962, conflict.rs 2195 to 650, hlc.rs 2156 to 569. Every #[test] and every fn is preserved; the small line losses are cargo fmt rejoining expressions that fit in 100 columns once the dedent removed a level. No production line changes, and no public path moved.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session
https://claude.ai/code/session_01EEmeiSJnmyL98QzA5Dwsvz
Author: Max Johnson <me@maxj.phd> · 2026-09-03 23:24 UTC
Signed with PGP, not checked
Commit: bce29d24aaf641bf1f61d48eae59ddb0e282ff63
Parent: 7ef3087
14 files changed, +5161 insertions, -3479 deletions
@@ -647,1549 +647,4 @@
647 647 }
648 648
649 649 #[cfg(test)]
650 - mod tests {
651 - use super::*;
652 - use crate::types::{ChangeOp, Hlc};
653 - use serde_json::json;
654 -
655 - /// Properties of the resolver.
656 - ///
657 - /// The contract here is convergence, which is a statement about every pair
658 - /// of changes rather than about the pairs someone wrote down. The 43 tests
659 - /// below are examples; these state the rule. See wiki `testing-posture`,
660 - /// Phase 2.
661 - mod properties {
662 - use super::*;
663 - use proptest::prelude::*;
664 -
665 - /// Small device pool: node is the final tiebreak, so collisions are the
666 - /// interesting case and random UUIDs would never produce them.
667 - fn device_id() -> impl Strategy<Value = DeviceId> {
668 - (0u8..3).prop_map(|n| {
669 - let mut bytes = [0u8; 16];
670 - bytes[15] = n;
671 - DeviceId::new(Uuid::from_bytes(bytes))
672 - })
673 - }
674 -
675 - /// Walls clustered tightly so ties and near-ties are common, plus a
676 - /// far-future band that trips the clock-poisoning guard.
677 - fn any_hlc() -> impl Strategy<Value = Hlc> {
678 - let wall = prop_oneof![
679 - 6 => 1_700_000_000_000i64..1_700_000_000_010,
680 - 2 => 0i64..2_000_000_000_000,
681 - 2 => 4_000_000_000_000i64..8_000_000_000_000,
682 - ];
683 - (wall, 0u32..4, device_id()).prop_map(|(wall_ms, counter, node)| Hlc {
684 - wall_ms,
685 - counter,
686 - node,
687 - })
688 - }
689 -
690 - /// Pairs of clocks, weighted so exact ties are common.
691 - ///
692 - /// Two independent draws almost never collide, and the tie is exactly
693 - /// where convergence is hardest: it is the case the payload tiebreak in
694 - /// `resolve_tie` exists for. Generating the pair rather than two
695 - /// independent clocks is what gives this property teeth, verified by
696 - /// removing that tiebreak and watching the convergence test fail.
697 - fn hlc_pair() -> impl Strategy<Value = (Hlc, Hlc)> {
698 - prop_oneof![
699 - 3 => (any_hlc(), any_hlc()),
700 - 3 => any_hlc().prop_map(|h| (h, h)),
701 - 2 => (any_hlc(), 0u32..4).prop_map(|(h, counter)| (h, Hlc { counter, ..h })),
702 - ]
703 - }
704 -
705 - fn entry_with(hlc: Hlc, payload: u8) -> ChangeEntry {
706 - let mut e = make_entry("tasks", "row-1", ChangeOp::Update, Utc::now());
707 - e.hlc = hlc;
708 - e.data = Some(json!({ "v": payload }));
709 - e
710 - }
711 -
712 - fn pulled_with(hlc: Hlc, payload: u8) -> PulledChange {
713 - let mut p = make_pulled(
714 - "tasks",
715 - "row-1",
716 - ChangeOp::Update,
717 - Utc::now(),
718 - hlc.node.as_uuid(),
719 - 1,
720 - );
721 - p.entry.hlc = hlc;
722 - p.entry.data = Some(json!({ "v": payload }));
723 - p
724 - }
725 -
726 - proptest! {
727 - /// **Convergence.** Two devices hold the same pair with the roles
728 - /// reversed: what is local on A is remote on B. If the answer
729 - /// depended on which side the resolver was handed, the two devices
730 - /// would keep different rows and never reconcile. No example test
731 - /// notices unless it happens to pick that pair.
732 - ///
733 - /// Stated over the surviving payload rather than the `Resolution`
734 - /// variant: at an exact tie both sides keep local, which converges
735 - /// precisely because the two changes are then byte-identical.
736 - #[test]
737 - fn lww_picks_the_same_winner_from_either_side(
738 - (a_hlc, b_hlc) in hlc_pair(),
739 - a_payload in any::<u8>(),
740 - b_payload in any::<u8>(),
741 - ) {
742 - let now = Utc::now();
743 - let on_a = match resolve_lww_at(
744 - &entry_with(a_hlc, a_payload),
745 - &pulled_with(b_hlc, b_payload),
746 - now,
747 - ) {
748 - Resolution::KeepLocal => a_payload,
749 - Resolution::KeepRemote => b_payload,
750 - other => return Err(TestCaseError::fail(format!("unexpected {other:?}"))),
751 - };
752 - let on_b = match resolve_lww_at(
753 - &entry_with(b_hlc, b_payload),
754 - &pulled_with(a_hlc, a_payload),
755 - now,
756 - ) {
757 - Resolution::KeepLocal => b_payload,
758 - Resolution::KeepRemote => a_payload,
759 - other => return Err(TestCaseError::fail(format!("unexpected {other:?}"))),
760 - };
761 -
762 - prop_assert_eq!(
763 - on_a, on_b,
764 - "the two devices kept different payloads and will never converge: \
765 - A kept {}, B kept {} (a={:?}, b={:?})",
766 - on_a, on_b, a_hlc, b_hlc
767 - );
768 - }
769 -
770 - /// Resolution is a function of its inputs. Cheap to state, and it is
771 - /// what lets the resolver be re-run from a retry without
772 - /// re-deriving the world.
773 - #[test]
774 - fn lww_is_deterministic(
775 - (a_hlc, b_hlc) in hlc_pair(),
776 - a_payload in any::<u8>(),
777 - b_payload in any::<u8>(),
778 - ) {
779 - let now = Utc::now();
780 - let local = entry_with(a_hlc, a_payload);
781 - let first = resolve_lww_at(&local, &pulled_with(b_hlc, b_payload), now);
782 - let second = resolve_lww_at(&local, &pulled_with(b_hlc, b_payload), now);
783 - prop_assert_eq!(format!("{first:?}"), format!("{second:?}"));
784 - }
785 -
786 - /// A poisoned clock must never beat an honest one. This is the
787 - /// guard's whole purpose: an unbounded future timestamp would
788 - /// otherwise win every conflict for years.
789 - #[test]
790 - fn an_honest_clock_beats_a_poisoned_one(
791 - honest_wall in 1_700_000_000_000i64..1_700_000_100_000,
792 - poison_offset in (MAX_HLC_DRIFT_MS + 1)..10_000_000_000i64,
793 - node_a in device_id(),
794 - node_b in device_id(),
795 - ) {
796 - let now = Utc::now();
797 - let honest = Hlc { wall_ms: honest_wall, counter: 0, node: node_a };
798 - let poisoned = Hlc {
799 - wall_ms: now.timestamp_millis().saturating_add(poison_offset),
800 - counter: 0,
801 - node: node_b,
802 - };
803 - prop_assume!(!is_clock_poisoned(&honest, now));
804 -
805 - prop_assert!(
806 - matches!(
807 - resolve_lww_at(&entry_with(honest, 1), &pulled_with(poisoned, 2), now),
808 - Resolution::KeepLocal
809 - ),
810 - "a poisoned remote won against an honest local"
811 - );
812 - prop_assert!(
813 - matches!(
814 - resolve_lww_at(&entry_with(poisoned, 2), &pulled_with(honest, 1), now),
815 - Resolution::KeepRemote
816 - ),
817 - "a poisoned local won against an honest remote"
818 - );
819 - }
820 -
821 - /// **A field merge converges, dependent groups included.**
822 - ///
823 - /// The two devices see mirror images of one conflict: what is local
824 - /// on A is remote on B. They must compute the same merged object, or
825 - /// they hold different bytes forever with nothing to detect it.
826 - ///
827 - /// This is aimed at the group rule specifically. Everything else in
828 - /// the merge decides a field from values both devices have, but the
829 - /// group rule picks a *side*, and "side" is the one concept that is
830 - /// device-relative. It converges because the winner comes from
831 - /// `resolve_tie` over the two entries rather than from which one the
832 - /// caller happened to label local, and this is what would fail if
833 - /// that ever regressed to a "ties go to local" rule.
834 - #[test]
835 - fn field_merge_converges_on_mirrored_inputs(
836 - (a_hlc, b_hlc) in hlc_pair(),
837 - a_state in 0u8..3,
838 - b_state in 0u8..3,
839 - a_at in 0u8..3,
840 - b_at in 0u8..3,
841 - a_note in 0u8..3,
842 - b_note in 0u8..3,
843 - ) {
844 - const GROUPS: &[&[&str]] = &[&["state", "state_at"]];
845 - let base = json!({"state": "s0", "state_at": "t0", "note": "n0"});
846 - let a = json!({
847 - "state": format!("s{a_state}"),
848 - "state_at": format!("t{a_at}"),
849 - "note": format!("n{a_note}"),
850 - });
851 - let b = json!({
852 - "state": format!("s{b_state}"),
853 - "state_at": format!("t{b_at}"),
854 - "note": format!("n{b_note}"),
855 - });
856 -
857 - // On device A the local side is `a`; on device B it is `b`.
858 - let on_a = resolve_field_merge_with(&a, &b, &base, &a_hlc, &b_hlc, GROUPS);
859 - let on_b = resolve_field_merge_with(&b, &a, &base, &b_hlc, &a_hlc, GROUPS);
860 -
861 - prop_assert_eq!(
862 - format!("{on_a:?}"),
863 - format!("{on_b:?}"),
864 - "two devices merged the same conflict differently and will \
865 - never converge (a={:?}, b={:?})",
866 - a_hlc,
867 - b_hlc
868 - );
869 - }
870 -
871 - /// **A declared group never lands split across the two sides.**
872 - ///
873 - /// The property the declaration exists to buy. However the merge
874 - /// resolves, every member of a contested group has to come from one
875 - /// side, so the pair describes a state some device actually held. A
876 - /// merge that decided `state` and `state_at` independently fails this
877 - /// on the inputs where the two sides disagree about only one of them,
878 - /// which is exactly the GoingsOn start()-versus-complete() case.
879 - #[test]
880 - fn a_contested_group_never_lands_split(
881 - (a_hlc, b_hlc) in hlc_pair(),
882 - a_state in 0u8..3,
883 - b_state in 0u8..3,
884 - a_at in 0u8..3,
885 - b_at in 0u8..3,
886 - ) {
887 - const GROUPS: &[&[&str]] = &[&["state", "state_at"]];
888 - let base = json!({"state": "s0", "state_at": "t0"});
889 - let a = json!({"state": format!("s{a_state}"), "state_at": format!("t{a_at}")});
890 - let b = json!({"state": format!("s{b_state}"), "state_at": format!("t{b_at}")});
891 -
892 - let Resolution::Merged(merged) =
893 - resolve_field_merge_with(&a, &b, &base, &a_hlc, &b_hlc, GROUPS)
894 - else {
895 - return Err(TestCaseError::fail("an object base must merge"));
896 - };
897 -
898 - // The result's group is allowed to be A's, B's, or the base's
899 - // (untouched). What it must never be is one column from one side
900 - // and the other from a different one.
901 - let pair = (&merged["state"], &merged["state_at"]);
902 - let candidates = [
903 - (&a["state"], &a["state_at"]),
904 - (&b["state"], &b["state_at"]),
905 - (&base["state"], &base["state_at"]),
906 - ];
907 - prop_assert!(
908 - candidates.contains(&pair),
909 - "the group landed split: got {:?}, which is no device's version \
910 - of it (a={a}, b={b})",
911 - merged
912 - );
913 - }
914 - }
915 - }
916 -
917 - /// Fixed node for locally-minted test entries, distinct from any random
918 - /// `other_device`, so HLC tiebreaks are deterministic.
919 - fn local_node() -> DeviceId {
920 - DeviceId::new(Uuid::from_u128(0x1111_1111_1111_1111_1111_1111_1111_1111))
921 - }
922 -
923 - /// A second fixed device node, distinct from [`local_node`], for the
924 - /// field-merge tests that need to name the remote side's clock.
925 - fn remote_node() -> DeviceId {
926 - DeviceId::new(Uuid::from_u128(0x2222_2222_2222_2222_2222_2222_2222_2222))
927 - }
928 -
929 - /// Map a wall-clock timestamp onto an HLC at `node`, so the timestamp-ordered
930 - /// field-merge tests express the same intent against the HLC-based API. A
931 - /// strictly later `ts` yields a strictly greater HLC (higher `wall_ms`); equal
932 - /// `ts` on distinct nodes ties on the node, which is exactly the convergent
933 - /// behavior the F1 fix guarantees.
934 - fn ts_hlc(ts: DateTime<Utc>, node: DeviceId) -> Hlc {
935 - Hlc::from_legacy(ts.timestamp_millis(), node)
936 - }
937 -
938 - fn make_entry(table: &str, row_id: &str, op: ChangeOp, ts: DateTime<Utc>) -> ChangeEntry {
939 - // Derive the HLC wall component from the timestamp so the time-ordered
940 - // tests below still express the intended ordering.
941 - ChangeEntry {
942 - table: table.to_string(),
943 - op,
944 - row_id: row_id.to_string(),
945 - timestamp: ts,
946 - hlc: Hlc::from_legacy(ts.timestamp_millis(), local_node()),
947 - data: Some(json!({"value": "test"})),
948 - extra: serde_json::Map::default(),
949 - }
950 - }
951 -
952 - fn make_pulled(
953 - table: &str,
954 - row_id: &str,
955 - op: ChangeOp,
956 - ts: DateTime<Utc>,
957 - device_id: Uuid,
958 - seq: i64,
959 - ) -> PulledChange {
960 - let mut entry = make_entry(table, row_id, op, ts);
961 - entry.hlc = Hlc::from_legacy(ts.timestamp_millis(), DeviceId::new(device_id));
962 - PulledChange {
963 - storage_version: None,
964 - entry,
965 - device_id: DeviceId::new(device_id),
966 - seq,
967 - }
968 - }
969 -
970 - /// Build a pulled change with an explicit HLC, for resolution tests that need
971 - /// to control the clock independently of the wall timestamp.
972 - fn pulled_with_hlc(row_id: &str, op: ChangeOp, hlc: Hlc, device_id: Uuid) -> PulledChange {
973 - let mut p = make_pulled("tasks", row_id, op, Utc::now(), device_id, 1);
974 - p.entry.hlc = hlc;
975 - p
976 - }
977 -
978 - // ── detect_conflicts ──
979 -
980 - #[test]
981 - fn no_conflicts_when_different_rows() {
982 - let our_device = Uuid::new_v4();
983 - let other_device = Uuid::new_v4();
984 - let now = Utc::now();
985 -
986 - let remote = vec![make_pulled(
987 - "tasks",
988 - "r1",
989 - ChangeOp::Update,
990 - now,
991 - other_device,
992 - 1,
993 - )];
994 - let local = vec![make_entry("tasks", "r2", ChangeOp::Update, now)];
995 -
996 - let (clean, conflicts) = detect_conflicts(remote, &local, DeviceId::new(our_device));
997 - assert_eq!(clean.len(), 1);
998 - assert!(conflicts.is_empty());
999 - }
1000 -
1001 - #[test]
1002 - fn conflict_detected_same_row_different_device() {
1003 - let our_device = Uuid::new_v4();
1004 - let other_device = Uuid::new_v4();
1005 - let now = Utc::now();
1006 -
1007 - let remote = vec![make_pulled(
1008 - "tasks",
1009 - "r1",
1010 - ChangeOp::Update,
1011 - now,
1012 - other_device,
1013 - 1,
1014 - )];
1015 - let local = vec![make_entry("tasks", "r1", ChangeOp::Update, now)];
1016 -
1017 - let (clean, conflicts) = detect_conflicts(remote, &local, DeviceId::new(our_device));
1018 - assert!(clean.is_empty());
1019 - assert_eq!(conflicts.len(), 1);
1020 - assert_eq!(conflicts[0].remote.entry.row_id, "r1");
1021 - assert_eq!(conflicts[0].local.row_id, "r1");
1022 - }
1023 -
1024 - #[test]
1025 - fn own_echo_without_pending_edit_is_clean() {
1026 - // An echo of our own device with no contesting local pending edit is
1027 - // clean (it still passes the HLC gate at apply time).
1028 - let our_device = Uuid::new_v4();
1029 - let now = Utc::now();
1030 -
1031 - let remote = vec![make_pulled(
1032 - "tasks",
1033 - "r1",
1034 - ChangeOp::Update,
1035 - now,
1036 - our_device,
1037 - 1,
1038 - )];
1039 - let (clean, conflicts) = detect_conflicts(remote, &[], DeviceId::new(our_device));
1040 - assert_eq!(clean.len(), 1);
1041 - assert!(conflicts.is_empty());
1042 - }
1043 -
1044 - #[test]
1045 - fn echo_contesting_a_pending_edit_is_resolved_not_trusted() {
1046 - // Hardening: a pulled change labeled as our own echo that contests an
1047 - // un-pushed local edit is resolved as a conflict, not waved through as
1048 - // clean. Trusting the device_id label would let a server relabel a hostile
1049 - // row as our echo to skip conflict detection entirely.
1050 - let our_device = Uuid::new_v4();
1051 - let now = Utc::now();
1052 -
1053 - let remote = vec![make_pulled(
1054 - "tasks",
1055 - "r1",
1056 - ChangeOp::Update,
1057 - now,
1058 - our_device,
1059 - 1,
1060 - )];
1061 - let local = vec![make_entry("tasks", "r1", ChangeOp::Update, now)];
1062 -
1063 - let (clean, conflicts) = detect_conflicts(remote, &local, DeviceId::new(our_device));
1064 - assert!(clean.is_empty());
1065 - assert_eq!(
1066 - conflicts.len(),
1067 - 1,
1068 - "echo contesting a pending edit is resolved"
1069 - );
1070 - }
1071 -
1072 - #[test]
1073 - fn clean_changes_gate_drops_stale_keeps_newer() {
1074 - let our_device = Uuid::new_v4();
1075 - let other_device = Uuid::new_v4();
1076 - let now = Utc::now();
1077 - // A clean remote change for tasks/r1; its HLC wall == now_ms.
1078 - let remote = vec![make_pulled(
1079 - "tasks",
1080 - "r1",
1081 - ChangeOp::Update,
1082 - now,
1083 - other_device,
1084 - 1,
1085 - )];
1086 - let (clean, _conflicts) = detect_conflicts(remote, &[], DeviceId::new(our_device));
1087 - assert_eq!(clean.len(), 1);
1088 -
1089 - // No committed clock for the row → kept (first time we've seen it).
1090 - assert_eq!(clean.clone().gated(|_, _| None).len(), 1);
1091 - // Committed clock older than the remote → kept.
1092 - assert_eq!(
1093 - clean
1094 - .clone()
1095 - .gated(|_, _| Some(Hlc::zero(DeviceId::new(other_device))))
1096 - .len(),
1097 - 1
1098 - );
1099 - // Committed clock newer than the remote → dropped (would clobber newer local).
1100 - let newer = Hlc {
1101 - wall_ms: now.timestamp_millis() + 1,
1102 - counter: 0,
1103 - node: DeviceId::new(other_device),
1104 - };
1105 - assert!(clean.gated(move |_, _| Some(newer)).is_empty());
1106 - }
1107 -
1108 - #[test]
1109 - fn different_tables_same_row_id_no_conflict() {
1110 - let our_device = Uuid::new_v4();
1111 - let other_device = Uuid::new_v4();
1112 - let now = Utc::now();
1113 -
1114 - let remote = vec![make_pulled(
1115 - "tasks",
1116 - "r1",
1117 - ChangeOp::Update,
1118 - now,
1119 - other_device,
1120 - 1,
1121 - )];
1122 - let local = vec![make_entry("events", "r1", ChangeOp::Update, now)];
1123 -
1124 - let (clean, conflicts) = detect_conflicts(remote, &local, DeviceId::new(our_device));
1125 - assert_eq!(clean.len(), 1);
1126 - assert!(conflicts.is_empty());
1127 - }
1128 -
1129 - #[test]
1130 - fn detect_conflicts_correct_split() {
1131 - let our_device = Uuid::new_v4();
1132 - let other_device = Uuid::new_v4();
1133 - let now = Utc::now();
1134 -
1135 - let remote = vec![
1136 - make_pulled("tasks", "r1", ChangeOp::Update, now, other_device, 1),
1137 - make_pulled("tasks", "r2", ChangeOp::Insert, now, other_device, 2),
1138 - make_pulled("events", "r3", ChangeOp::Delete, now, other_device, 3),
1139 - ];
1140 - let local = vec![
1141 - make_entry("tasks", "r1", ChangeOp::Update, now),
1142 - // r2 not in local → clean
1143 - // r3 not in local → clean
1144 - ];
1145 -
1146 - let (clean, conflicts) = detect_conflicts(remote, &local, DeviceId::new(our_device));
Lines truncated
@@ -959,1361 +959,4 @@
959 959 }
960 960
961 961 #[cfg(test)]
962 - mod tests {
963 - use super::*;
964 -
965 - /// Properties of the sealing layer.
966 - ///
967 - /// Encryption is a round-trip for every input, not for the handful of
968 - /// payload shapes the examples below happen to use. See wiki
969 - /// `testing-posture`, Phase 2.
970 - mod properties {
971 - use super::*;
972 - use proptest::prelude::*;
973 -
974 - proptest! {
975 - /// `decrypt(encrypt(m, k), k) == m`, including for the empty
976 - /// message and for inputs that straddle the chunking boundary.
977 - #[test]
978 - fn encryption_round_trips(
979 - plaintext in prop::collection::vec(any::<u8>(), 0..4096),
980 - ) {
981 - let key = generate_master_key();
982 - let sealed = encrypt_bytes(&plaintext, &key).expect("encrypt");
983 - let opened = decrypt_bytes(&sealed, &key).expect("decrypt");
984 - prop_assert_eq!(opened, plaintext);
985 - }
986 -
987 - /// A wrong key must be an error rather than garbage plaintext,
988 - /// which is what makes the AEAD tag load-bearing instead of
989 - /// decorative.
990 - #[test]
991 - fn decryption_under_the_wrong_key_fails(
992 - plaintext in prop::collection::vec(any::<u8>(), 0..1024),
993 - ) {
994 - let key = generate_master_key();
995 - let other = generate_master_key();
996 - prop_assume!(key != other);
997 - let sealed = encrypt_bytes(&plaintext, &key).expect("encrypt");
998 - prop_assert!(
999 - decrypt_bytes(&sealed, &other).is_err(),
1000 - "a wrong key produced a result instead of an error"
1001 - );
1002 - }
1003 -
1004 - /// Sealing the same bytes twice under one key must not repeat the
1005 - /// ciphertext. A reused nonce is the classic AEAD break, and
1006 - /// nothing asserted the nonce actually varies.
1007 - #[test]
1008 - fn sealing_twice_does_not_repeat_ciphertext(
1009 - plaintext in prop::collection::vec(any::<u8>(), 1..512),
1010 - ) {
1011 - let key = generate_master_key();
1012 - let a = encrypt_bytes(&plaintext, &key).expect("encrypt");
1013 - let b = encrypt_bytes(&plaintext, &key).expect("encrypt");
1014 - prop_assert_ne!(a, b, "the same plaintext sealed to identical bytes twice");
1015 - }
1016 - }
1017 - }
1018 -
1019 - /// Differential relations over the chunked-blob format.
1020 - ///
1021 - /// Three implementations describe one layout: `encrypt_blob_chunked`
1022 - /// produces it, `blob_encrypted_len`/`sealed_chunk_len`/
1023 - /// `blob_chunk_count_for` predict it before a byte is sealed, and
1024 - /// `parse_blob_header` + `decrypt_blob_chunk` read it back one chunk at a
1025 - /// time. Relating them needs no expected-value table, which is what makes
1026 - /// these cheap (Chen et al. 1998; McKeeman 1998).
1027 - ///
1028 - /// Note on what is NOT asserted: the multipart and one-shot paths do not
1029 - /// produce identical ciphertext and cannot, because every chunk is sealed
1030 - /// under a fresh nonce (see `sealing_twice_does_not_repeat_ciphertext`
1031 - /// above). The relation that holds, and the one the uploader depends on, is
1032 - /// that the predicted layout equals the produced layout.
1033 - ///
1034 - /// See wiki `testing-posture`, Phase 2.
1035 - mod blob_relations {
1036 - use super::*;
1037 - use proptest::prelude::*;
1038 -
1039 - /// Lengths that land either side of a chunk boundary, using a small
1040 - /// stand-in for the 1 MiB production chunk so a case is cheap to run.
1041 - /// The boundary arithmetic is what these relations are about, and it is
1042 - /// the same arithmetic at any chunk size.
1043 - fn plaintext() -> impl Strategy<Value = Vec<u8>> {
1044 - prop_oneof![
1045 - 1 => Just(Vec::new()),
1046 - 4 => prop::collection::vec(any::<u8>(), 1..4096),
1047 - ]
1048 - }
1049 -
1050 - proptest! {
1051 - /// The uploader signs an exact `Content-Length` per part before it
1052 - /// has sealed anything, so a predicted length that disagrees with
1053 - /// the produced one is a broken upload rather than a wrong number.
1054 - #[test]
1055 - fn the_predicted_length_equals_the_produced_length(plaintext in plaintext()) {
1056 - let key = generate_master_key();
1057 - let sealed = encrypt_blob_chunked(&plaintext, &key, "h").expect("encrypt");
1058 - prop_assert_eq!(
1059 - sealed.len(),
1060 - blob_encrypted_len(plaintext.len()),
1061 - "blob_encrypted_len disagrees with encrypt_blob_chunked for {} bytes",
1062 - plaintext.len()
1063 - );
1064 - }
1065 -
1066 - /// The per-chunk lengths must add up the same way, since the
1067 - /// uploader slices parts by them. Checked against the header the
1068 - /// encoder actually wrote rather than against the predictor's own
1069 - /// idea of it.
1070 - #[test]
1071 - fn the_predicted_chunk_layout_equals_the_produced_one(plaintext in plaintext()) {
1072 - let key = generate_master_key();
1073 - let sealed = encrypt_blob_chunked(&plaintext, &key, "h").expect("encrypt");
1074 - let (header, consumed) = parse_blob_header(&sealed).expect("parse header");
1075 -
1076 - prop_assert_eq!(
1077 - header.chunk_count,
1078 - blob_chunk_count_for(plaintext.len()),
1079 - "header chunk count disagrees with the predictor"
1080 - );
1081 - let summed: usize = (0..header.chunk_count)
1082 - .map(|i| header.sealed_chunk_len(i))
1083 - .sum();
1084 - prop_assert_eq!(
1085 - consumed + summed,
1086 - sealed.len(),
1087 - "the per-chunk lengths do not tile the sealed body"
1088 - );
1089 - }
1090 -
1091 - /// The two decode paths are two implementations of one format: the
1092 - /// buffered fallback and the streaming reader the download path
1093 - /// actually uses. They must agree on every input, or a blob opens
1094 - /// one way in a test and another way in the app.
1095 - #[test]
1096 - fn streaming_and_buffered_decode_agree(plaintext in plaintext()) {
1097 - let key = generate_master_key();
1098 - let sealed = encrypt_blob_chunked(&plaintext, &key, "h").expect("encrypt");
1099 -
1100 - let buffered = decrypt_blob_chunked(&sealed, &key, "h").expect("buffered decrypt");
1101 -
1102 - let (header, consumed) = parse_blob_header(&sealed).expect("parse header");
1103 - let mut streamed = Vec::new();
1104 - let mut offset = consumed;
1105 - for i in 0..header.chunk_count {
1106 - let len = header.sealed_chunk_len(i);
1107 - let chunk = &sealed[offset..offset + len];
1108 - streamed.extend_from_slice(
1109 - &decrypt_blob_chunk(chunk, &key, "h", i, header.chunk_count)
1110 - .expect("chunk decrypt"),
1111 - );
1112 - offset += len;
1113 - }
1114 -
1115 - prop_assert_eq!(&buffered, &plaintext, "buffered decode lost the plaintext");
1116 - prop_assert_eq!(
1117 - &streamed, &plaintext,
1118 - "streaming decode disagreed with the plaintext"
1119 - );
1120 - prop_assert_eq!(offset, sealed.len(), "streaming decode left bytes unread");
1121 - }
1122 - }
1123 - }
1124 -
1125 - #[test]
1126 - fn master_key_generation_is_random() {
1127 - let k1 = generate_master_key();
1128 - let k2 = generate_master_key();
1129 - assert_ne!(k1, k2, "Two generated keys must differ");
1130 - assert_eq!(k1.len(), 32);
1131 - }
1132 -
1133 - #[test]
1134 - fn wrapping_key_derivation_is_deterministic() {
1135 - let salt = [42u8; 32];
1136 - let k1 = derive_wrapping_key("password123", &salt).unwrap();
1137 - let k2 = derive_wrapping_key("password123", &salt).unwrap();
1138 - assert_eq!(*k1, *k2, "Same inputs must produce same wrapping key");
1139 - }
1140 -
1141 - #[test]
1142 - fn different_passwords_produce_different_keys() {
1143 - let salt = [42u8; 32];
1144 - let k1 = derive_wrapping_key("password1", &salt).unwrap();
1145 - let k2 = derive_wrapping_key("password2", &salt).unwrap();
1146 - assert_ne!(*k1, *k2);
1147 - }
1148 -
1149 - #[test]
1150 - fn different_salts_produce_different_keys() {
1151 - let salt1 = [1u8; 32];
1152 - let salt2 = [2u8; 32];
1153 - let k1 = derive_wrapping_key("password", &salt1).unwrap();
1154 - let k2 = derive_wrapping_key("password", &salt2).unwrap();
1155 - assert_ne!(*k1, *k2);
1156 - }
1157 -
1158 - // ── Password normalization (NFC/NFD) ──
1159 -
1160 - #[test]
1161 - fn nfc_and_nfd_passwords_derive_same_key() {
1162 - // "e" + combining acute accent (NFD form of e-acute)
1163 - let nfd_password = "caf\u{0065}\u{0301}"; // "cafe" with decomposed accent
1164 - // Pre-composed e-acute (NFC form)
1165 - let nfc_password = "caf\u{00e9}"; // "cafe" with composed accent
1166 -
1167 - // Verify they are actually different byte sequences
1168 - assert_ne!(
1169 - nfd_password.as_bytes(),
1170 - nfc_password.as_bytes(),
1171 - "NFD and NFC should have different raw bytes"
1172 - );
1173 -
1174 - let salt = [99u8; 32];
1175 - let k1 = derive_wrapping_key(nfd_password, &salt).unwrap();
1176 - let k2 = derive_wrapping_key(nfc_password, &salt).unwrap();
1177 - assert_eq!(
1178 - *k1, *k2,
1179 - "Same password in NFC and NFD forms must derive the same key"
1180 - );
1181 - }
1182 -
1183 - #[test]
1184 - fn nfc_nfd_wrap_unwrap_roundtrip() {
1185 - let master_key = generate_master_key();
1186 - // Wrap with NFC form
1187 - let nfc_password = "caf\u{00e9}";
1188 - let envelope = wrap_master_key(&master_key, nfc_password).unwrap();
1189 -
1190 - // Unwrap with NFD form
1191 - let nfd_password = "caf\u{0065}\u{0301}";
1192 - let recovered = unwrap_master_key(&envelope, nfd_password).unwrap();
1193 - assert_eq!(master_key, recovered);
1194 - }
1195 -
1196 - #[test]
1197 - fn nfd_wrap_nfc_unwrap_roundtrip() {
1198 - let master_key = generate_master_key();
1199 - // Wrap with NFD form
1200 - let nfd_password = "caf\u{0065}\u{0301}";
1201 - let envelope = wrap_master_key(&master_key, nfd_password).unwrap();
1202 -
1203 - // Unwrap with NFC form
1204 - let nfc_password = "caf\u{00e9}";
1205 - let recovered = unwrap_master_key(&envelope, nfc_password).unwrap();
1206 - assert_eq!(master_key, recovered);
1207 - }
1208 -
1209 - #[test]
1210 - fn normalize_password_converts_to_nfc() {
1211 - let nfd = "caf\u{0065}\u{0301}";
1212 - let nfc = "caf\u{00e9}";
1213 - let normalized = normalize_password(nfd).unwrap();
1214 - assert_eq!(normalized, nfc);
1215 - }
1216 -
1217 - // ── Empty password rejection ──
1218 -
1219 - #[test]
1220 - fn empty_password_rejected_by_normalize() {
1221 - let result = normalize_password("");
1222 - assert!(result.is_err());
1223 - let msg = result.unwrap_err().to_string();
1224 - assert!(msg.contains("empty"), "Error should mention empty: {msg}");
1225 - }
1226 -
1227 - #[test]
1228 - fn empty_password_rejected_by_derive() {
1229 - let salt = [0u8; 32];
1230 - let result = derive_wrapping_key("", &salt);
1231 - assert!(result.is_err());
1232 - }
1233 -
1234 - #[test]
1235 - fn empty_password_rejected_by_wrap() {
1236 - let master_key = generate_master_key();
1237 - let result = wrap_master_key(&master_key, "");
1238 - assert!(result.is_err());
1239 - }
1240 -
1241 - #[test]
1242 - fn empty_password_rejected_by_unwrap() {
1243 - let master_key = generate_master_key();
1244 - let envelope = wrap_master_key(&master_key, "valid").unwrap();
1245 - let result = unwrap_master_key(&envelope, "");
1246 - assert!(result.is_err());
1247 - }
1248 -
1249 - // ── Password length limit ──
1250 -
1251 - #[test]
1252 - fn very_long_password_rejected() {
1253 - let long_password = "a".repeat(MAX_PASSWORD_BYTES + 1);
1254 - let result = normalize_password(&long_password);
1255 - assert!(result.is_err());
1256 - let msg = result.unwrap_err().to_string();
1257 - assert!(
1258 - msg.contains("maximum length"),
1259 - "Error should mention max length: {msg}"
1260 - );
1261 - }
1262 -
1263 - #[test]
1264 - fn password_at_max_length_accepted() {
1265 - let max_password = "a".repeat(MAX_PASSWORD_BYTES);
1266 - let result = normalize_password(&max_password);
1267 - assert!(result.is_ok());
1268 - }
1269 -
1270 - #[test]
1271 - fn password_just_under_max_length_accepted() {
1272 - let password = "a".repeat(MAX_PASSWORD_BYTES - 1);
1273 - let result = normalize_password(&password);
1274 - assert!(result.is_ok());
1275 - }
1276 -
1277 - // ── Salt reuse detection ──
1278 -
1279 - #[test]
1280 - fn two_wraps_use_different_salts() {
1281 - let master_key = generate_master_key();
1282 - let e1_json = wrap_master_key(&master_key, "pass").unwrap();
1283 - let e2_json = wrap_master_key(&master_key, "pass").unwrap();
1284 -
1285 - let e1: KeyEnvelope = serde_json::from_str(&e1_json).unwrap();
1286 - let e2: KeyEnvelope = serde_json::from_str(&e2_json).unwrap();
1287 -
1288 - assert_ne!(e1.salt, e2.salt, "Each wrap must use a unique random salt");
1289 - assert_ne!(
1290 - e1.nonce, e2.nonce,
1291 - "Each wrap must use a unique random nonce"
1292 - );
1293 - }
1294 -
1295 - // ── Key derivation determinism ──
1296 -
1297 - #[test]
1298 - fn key_derivation_deterministic_multiple_calls() {
1299 - let salt = [77u8; 32];
1300 - let password = "deterministic-test-password";
1301 -
1302 - let k1 = derive_wrapping_key(password, &salt).unwrap();
1303 - let k2 = derive_wrapping_key(password, &salt).unwrap();
1304 - let k3 = derive_wrapping_key(password, &salt).unwrap();
1305 -
1306 - assert_eq!(*k1, *k2);
1307 - assert_eq!(*k2, *k3);
1308 - }
1309 -
1310 - // ── Key rotation: re-wrap with new password, old data still readable ──
1311 -
1312 - #[test]
1313 - fn key_rotation_preserves_data_access() {
1314 - let master_key = generate_master_key();
1315 - let plaintext = b"encrypted before password change";
1316 -
1317 - // Encrypt data with the master key
1318 - let encrypted = encrypt_data(plaintext, &master_key).unwrap();
1319 -
1320 - // Wrap master key with old password
1321 - let old_envelope = wrap_master_key(&master_key, "old-pass").unwrap();
1322 -
1323 - // Simulate password change: unwrap with old, re-wrap with new
1324 - let recovered_key = unwrap_master_key(&old_envelope, "old-pass").unwrap();
1325 - assert_eq!(recovered_key, master_key);
1326 -
1327 - let new_envelope = wrap_master_key(&recovered_key, "new-pass").unwrap();
1328 -
1329 - // Verify: unwrap with new password gives same key
1330 - let key_from_new = unwrap_master_key(&new_envelope, "new-pass").unwrap();
1331 - assert_eq!(key_from_new, master_key);
1332 -
1333 - // Verify: old encrypted data can still be decrypted
1334 - let decrypted = decrypt_data(&encrypted, &key_from_new).unwrap();
1335 - assert_eq!(decrypted, plaintext);
1336 -
1337 - // Verify: old password no longer works on new envelope
1338 - let result = unwrap_master_key(&new_envelope, "old-pass");
1339 - assert!(result.is_err());
1340 - }
1341 -
1342 - // ── Encryption roundtrip with various data sizes ──
1343 -
1344 - #[test]
1345 - fn encrypt_decrypt_empty_data() {
1346 - let master_key = generate_master_key();
1347 - let encrypted = encrypt_data(b"", &master_key).unwrap();
1348 - let decrypted = decrypt_data(&encrypted, &master_key).unwrap();
1349 - assert!(decrypted.is_empty());
1350 - }
1351 -
1352 - #[test]
1353 - fn encrypt_decrypt_single_byte() {
1354 - let master_key = generate_master_key();
1355 - let encrypted = encrypt_data(&[42], &master_key).unwrap();
1356 - let decrypted = decrypt_data(&encrypted, &master_key).unwrap();
1357 - assert_eq!(decrypted, vec![42]);
1358 - }
1359 -
1360 - #[test]
1361 - fn encrypt_decrypt_large_payload() {
1362 - let master_key = generate_master_key();
1363 - // 1MB of data
1364 - let plaintext: Vec<u8> = (0..1_000_000).map(|i| (i % 256) as u8).collect();
1365 - let encrypted = encrypt_data(&plaintext, &master_key).unwrap();
1366 - let decrypted = decrypt_data(&encrypted, &master_key).unwrap();
1367 - assert_eq!(decrypted, plaintext);
1368 - }
1369 -
1370 - // ── Wrong key gives error, not garbage ──
1371 -
1372 - #[test]
1373 - fn wrong_key_gives_decryption_error_not_garbage() {
1374 - let key1 = generate_master_key();
1375 - let key2 = generate_master_key();
1376 - let plaintext = b"this should fail cleanly with wrong key";
1377 -
1378 - let encrypted = encrypt_data(plaintext, &key1).unwrap();
1379 - let result = decrypt_data(&encrypted, &key2);
1380 -
1381 - // Must be an error, not a successful decryption to garbage
1382 - assert!(result.is_err());
1383 - assert!(
1384 - matches!(result.unwrap_err(), SyncKitError::DecryptionFailed),
1385 - "Wrong key must produce DecryptionFailed, not garbage output"
1386 - );
1387 - }
1388 -
1389 - #[test]
1390 - fn wrong_key_bytes_gives_decryption_error_not_garbage() {
1391 - let key1 = generate_master_key();
1392 - let key2 = generate_master_key();
1393 - let plaintext = b"binary data check";
1394 -
1395 - let encrypted = encrypt_bytes(plaintext, &key1).unwrap();
1396 - let result = decrypt_bytes(&encrypted, &key2);
1397 -
1398 - assert!(result.is_err());
1399 - assert!(matches!(
1400 - result.unwrap_err(),
1401 - SyncKitError::DecryptionFailed
1402 - ));
1403 - }
1404 -
1405 - // ── JSON encryption edge cases ──
1406 -
1407 - #[test]
1408 - fn json_encrypt_decrypt_null() {
1409 - let master_key = generate_master_key();
1410 - let original = serde_json::Value::Null;
1411 - let encrypted = encrypt_json(&original, &master_key).unwrap();
1412 - let decrypted = decrypt_json(&encrypted, &master_key).unwrap();
1413 - assert_eq!(decrypted, original);
1414 - }
1415 -
1416 - #[test]
1417 - fn json_encrypt_decrypt_nested_object() {
1418 - let master_key = generate_master_key();
1419 - let original = serde_json::json!({
1420 - "level1": {
1421 - "level2": {
1422 - "level3": [1, 2, 3],
1423 - "flag": true
1424 - }
1425 - },
1426 - "empty_array": [],
1427 - "empty_object": {}
1428 - });
1429 -
1430 - let encrypted = encrypt_json(&original, &master_key).unwrap();
1431 - let decrypted = decrypt_json(&encrypted, &master_key).unwrap();
1432 - assert_eq!(decrypted, original);
1433 - }
1434 -
1435 - #[test]
1436 - fn json_decrypt_with_wrong_key_fails() {
1437 - let key1 = generate_master_key();
1438 - let key2 = generate_master_key();
1439 - let original = serde_json::json!({"secret": "data"});
1440 -
1441 - let encrypted = encrypt_json(&original, &key1).unwrap();
1442 - let result = decrypt_json(&encrypted, &key2);
1443 - assert!(result.is_err());
1444 - }
1445 -
1446 - #[test]
1447 - fn json_decrypt_non_string_value_fails() {
1448 - let master_key = generate_master_key();
1449 - let not_a_string = serde_json::json!(42);
1450 - let result = decrypt_json(&not_a_string, &master_key);
1451 - assert!(result.is_err());
1452 - }
1453 -
1454 - // ── Blob (bytes) edge cases ──
1455 -
1456 - #[test]
1457 - fn bytes_zero_byte_blob_roundtrip() {
1458 - let master_key = generate_master_key();
Lines truncated
@@ -835,656 +835,4 @@
835 835 }
836 836
837 837 #[cfg(test)]
838 - mod tests {
839 - use super::*;
840 - use serde_json::json;
841 -
842 - /// Properties of the clock.
843 - ///
844 - /// The HLC contracts are universally quantified, an order is an order for
845 - /// every pair, while the tests below are examples. Examples pin the cases
846 - /// someone thought of; these state the rule. Shrinking is the part that
847 - /// pays: a counterexample you can read beats a hundred passing examples.
848 - /// See wiki `testing-posture`, Phase 2.
849 - mod properties {
850 - use super::*;
851 - use proptest::prelude::*;
852 -
853 - /// A small device pool on purpose. With random UUIDs a node collision
854 - /// is vanishingly rare, and the node tiebreak is what needs exercising.
855 - fn device_id() -> impl Strategy<Value = DeviceId> {
856 - (0u8..4).prop_map(|n| {
857 - let mut bytes = [0u8; 16];
858 - bytes[15] = n;
859 - DeviceId::new(Uuid::from_bytes(bytes))
860 - })
861 - }
862 -
863 - /// Ordinary clocks plus the i64 ceiling, where `bump_counter`'s overflow
864 - /// ladder lives.
865 - fn wall_ms() -> impl Strategy<Value = i64> {
866 - prop_oneof![
867 - 5 => 0i64..2_000_000_000_000,
868 - 1 => (i64::MAX - 4)..=i64::MAX,
869 - ]
870 - }
871 -
872 - fn counter() -> impl Strategy<Value = u32> {
873 - prop_oneof![
874 - 5 => 0u32..1000,
875 - 1 => (u32::MAX - 2)..=u32::MAX,
876 - ]
877 - }
878 -
879 - fn any_hlc() -> impl Strategy<Value = Hlc> {
880 - (wall_ms(), counter(), device_id()).prop_map(|(wall_ms, counter, node)| Hlc {
881 - wall_ms,
882 - counter,
883 - node,
884 - })
885 - }
886 -
887 - /// The parts that encode causality. `Hlc`'s `Ord` also breaks ties on
888 - /// `node`, which is a convergence device rather than a clock reading,
889 - /// so a monotonicity claim is about this pair.
890 - fn reading(h: &Hlc) -> (i64, u32) {
891 - (h.wall_ms, h.counter)
892 - }
893 -
894 - /// At `{i64::MAX, u32::MAX}` no greater HLC is representable and the
895 - /// documented behaviour is to clamp rather than wrap backwards. A
896 - /// monotonicity property has to exempt that point or it asserts
897 - /// something the type cannot provide.
898 - fn at_ceiling(h: &Hlc) -> bool {
899 - h.wall_ms == i64::MAX && h.counter == u32::MAX
900 - }
901 -
902 - proptest! {
903 - /// A local event always produces a clock strictly later than the one
904 - /// it advanced from. Two local writes comparing equal would leave
905 - /// LWW unable to order a device against itself.
906 - #[test]
907 - fn tick_is_strictly_increasing(
908 - prev in any_hlc(),
909 - now_ms in wall_ms(),
910 - node in device_id(),
911 - ) {
912 - let next = Hlc::tick(prev, now_ms, node);
913 - if at_ceiling(&prev) {
914 - prop_assert_eq!(reading(&next), reading(&prev), "the ceiling clamps");
915 - } else {
916 - prop_assert!(
917 - reading(&next) > reading(&prev),
918 - "tick({:?}, {}) gave {:?}, which does not follow it",
919 - prev, now_ms, next
920 - );
921 - }
922 - }
923 -
924 - /// Receiving leaves the clock ahead of everything seen: later than
925 - /// the previous local reading and past the remote one. A subsequent
926 - /// local write then causally follows the remote change, which is the
927 - /// entire point of the receive rule.
928 - #[test]
929 - fn observe_overtakes_both_inputs(
930 - prev in any_hlc(),
931 - remote in any_hlc(),
932 - now_ms in wall_ms(),
933 - node in device_id(),
934 - ) {
935 - prop_assume!(!at_ceiling(&prev) && !at_ceiling(&remote));
936 - let next = Hlc::observe(prev, remote, now_ms, node);
937 - prop_assert!(
938 - reading(&next) > reading(&prev),
939 - "observe left the clock at or behind its previous reading: {:?} -> {:?}",
940 - prev, next
941 - );
942 - prop_assert!(
943 - reading(&next) > reading(&remote),
944 - "observe did not overtake the remote clock: remote {:?}, got {:?}",
945 - remote, next
946 - );
947 - }
948 -
949 - /// Observing the same remote twice must not move the clock
950 - /// backwards. Retries and duplicate deliveries make this a real
951 - /// sequence rather than a hypothetical one.
952 - #[test]
953 - fn observe_is_monotone_under_repetition(
954 - prev in any_hlc(),
955 - remote in any_hlc(),
956 - now_ms in wall_ms(),
957 - node in device_id(),
958 - ) {
959 - prop_assume!(!at_ceiling(&prev) && !at_ceiling(&remote));
960 - let once = Hlc::observe(prev, remote, now_ms, node);
961 - let twice = Hlc::observe(once, remote, now_ms, node);
962 - prop_assert!(
963 - reading(&twice) >= reading(&once),
964 - "a repeated observe went backwards: {:?} -> {:?}",
965 - once, twice
966 - );
967 - }
968 - }
969 - }
970 -
971 - /// Build an HLC from its three components, shortest form for the ordering
972 - /// tests below.
973 - fn hlc(wall_ms: i64, counter: u32, node: u8) -> Hlc {
974 - let mut bytes = [0u8; 16];
975 - bytes[15] = node;
976 - Hlc {
977 - wall_ms,
978 - counter,
979 - node: DeviceId::new(Uuid::from_bytes(bytes)),
980 - }
981 - }
982 -
983 - /// The nil node is the legacy floor, and the floor has to lose. A pre-HLC
984 - /// entry deserializes onto it, so if it ever won a comparison a legacy
985 - /// entry would overwrite a real edit.
986 - #[test]
987 - fn legacy_floor_loses_to_every_real_clock() {
988 - let floor = hlc_legacy_floor();
989 - assert!(floor < hlc(0, 0, 1));
990 - assert!(floor < hlc(0, 1, 0));
991 - assert!(floor < hlc(1, 0, 0));
992 - }
993 -
994 - #[test]
995 - fn change_op_serde_roundtrip() {
996 - for (variant, expected_str) in [
997 - (ChangeOp::Insert, "\"INSERT\""),
998 - (ChangeOp::Update, "\"UPDATE\""),
999 - (ChangeOp::Delete, "\"DELETE\""),
1000 - ] {
1001 - let serialized = serde_json::to_string(&variant).unwrap();
1002 - assert_eq!(serialized, expected_str);
1003 - let deserialized: ChangeOp = serde_json::from_str(&serialized).unwrap();
1004 - assert_eq!(deserialized, variant);
1005 - }
1006 - }
1007 -
1008 - #[test]
1009 - fn change_op_display_matches_serde() {
1010 - for variant in [ChangeOp::Insert, ChangeOp::Update, ChangeOp::Delete] {
1011 - let display = variant.to_string();
1012 - let serde_str = serde_json::to_string(&variant).unwrap();
1013 - // serde wraps in quotes, Display does not
1014 - assert_eq!(format!("\"{display}\""), serde_str);
1015 - }
1016 - }
1017 -
1018 - #[test]
1019 - fn change_op_from_str_opt_rejects_lowercase() {
1020 - assert_eq!(ChangeOp::from_str_opt("insert"), None);
1021 - assert_eq!(ChangeOp::from_str_opt("update"), None);
1022 - assert_eq!(ChangeOp::from_str_opt("delete"), None);
1023 - }
1024 -
1025 - #[test]
1026 - fn change_op_from_str_opt_rejects_unknown() {
1027 - assert_eq!(ChangeOp::from_str_opt("UPSERT"), None);
1028 - assert_eq!(ChangeOp::from_str_opt(""), None);
1029 - assert_eq!(ChangeOp::from_str_opt("MERGE"), None);
1030 - }
1031 -
1032 - #[test]
1033 - fn change_op_is_copy_and_eq() {
1034 - let op = ChangeOp::Insert;
1035 - let copied = op; // Copy
1036 - assert_eq!(op, copied);
1037 - }
1038 -
1039 - #[test]
1040 - fn change_op_hash_works() {
1041 - use std::collections::HashSet;
1042 - let mut set = HashSet::new();
1043 - set.insert(ChangeOp::Insert);
1044 - set.insert(ChangeOp::Update);
1045 - set.insert(ChangeOp::Delete);
1046 - set.insert(ChangeOp::Insert); // duplicate
1047 - assert_eq!(set.len(), 3);
1048 - }
1049 -
1050 - #[test]
1051 - fn change_entry_serialization_omits_none_data() {
1052 - let entry = ChangeEntry {
1053 - table: "t".into(),
1054 - op: ChangeOp::Delete,
1055 - row_id: "r".into(),
1056 - timestamp: chrono::Utc::now(),
1057 - hlc: Hlc::zero(DeviceId::nil()),
1058 - data: None,
1059 - extra: serde_json::Map::default(),
1060 - };
1061 - let json = serde_json::to_string(&entry).unwrap();
1062 - assert!(
1063 - !json.contains("\"data\""),
1064 - "None data should be omitted: {json}"
1065 - );
1066 - }
1067 -
1068 - #[test]
1069 - fn change_entry_serialization_includes_some_data() {
1070 - let entry = ChangeEntry {
1071 - table: "t".into(),
1072 - op: ChangeOp::Insert,
1073 - row_id: "r".into(),
1074 - timestamp: chrono::Utc::now(),
1075 - hlc: Hlc::zero(DeviceId::nil()),
1076 - data: Some(json!({"k": "v"})),
1077 - extra: serde_json::Map::default(),
1078 - };
1079 - let json = serde_json::to_string(&entry).unwrap();
1080 - assert!(
1081 - json.contains("\"data\""),
1082 - "Some data should be present: {json}"
1083 - );
1084 - }
1085 -
1086 - #[test]
1087 - fn change_entry_preserves_unknown_fields_across_roundtrip() {
1088 - let json = r#"{
1089 - "table": "tasks",
1090 - "op": "INSERT",
1091 - "row_id": "r1",
1092 - "timestamp": "2025-01-15T10:00:00Z",
1093 - "data": {"title": "test"},
1094 - "future_marker": "should survive",
1095 - "another_unknown": 42
1096 - }"#;
1097 - let entry: ChangeEntry = serde_json::from_str(json).unwrap();
1098 - assert_eq!(entry.table, "tasks");
1099 - assert_eq!(entry.op, ChangeOp::Insert);
1100 - assert_eq!(entry.data.as_ref().unwrap()["title"], "test");
1101 - // A newer client's fields are captured, not dropped...
1102 - assert_eq!(entry.extra["future_marker"], "should survive");
1103 - assert_eq!(entry.extra["another_unknown"], 42);
1104 - // ...and round-trip back out, so an older client re-serializing this
1105 - // entry cannot silently discard a resolution-relevant field.
1106 - let reserialized = serde_json::to_value(&entry).unwrap();
1107 - assert_eq!(reserialized["future_marker"], "should survive");
1108 - assert_eq!(reserialized["another_unknown"], 42);
1109 - }
1110 -
1111 - #[test]
1112 - fn device_deserialization_with_iso_timestamps() {
1113 - let json = r#"{
1114 - "id": "550e8400-e29b-41d4-a716-446655440000",
1115 - "app_id": "6ba7b810-9dad-11d1-80b4-00c04fd430c8",
1116 - "user_id": "550e8400-e29b-41d4-a716-446655440001",
1117 - "device_name": "MacBook Pro",
1118 - "platform": "macos",
1119 - "last_seen_at": "2025-06-15T14:30:00.123Z",
1120 - "created_at": "2025-01-01T00:00:00Z"
1121 - }"#;
1122 - let device: Device = serde_json::from_str(json).unwrap();
1123 - assert_eq!(device.device_name, "MacBook Pro");
1124 - assert_eq!(device.platform, "macos");
1125 - assert_eq!(
1126 - device.id.as_uuid(),
1127 - Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap()
1128 - );
1129 - }
1130 -
1131 - #[test]
1132 - fn sync_status_with_zero_total_changes() {
1133 - let json = r#"{"total_changes": 0, "latest_cursor": null}"#;
1134 - let status: SyncStatus = serde_json::from_str(json).unwrap();
1135 - assert_eq!(status.total_changes, 0);
1136 - assert!(status.latest_cursor.is_none());
1137 - }
1138 -
1139 - #[test]
1140 - fn blob_upload_url_response_already_exists() {
1141 - let json = r#"{"upload_url": "", "already_exists": true}"#;
1142 - let resp: BlobUploadUrlResponse = serde_json::from_str(json).unwrap();
1143 - assert!(resp.already_exists);
1144 - assert!(resp.upload_url.is_empty());
1145 - }
1146 -
1147 - #[test]
1148 - fn change_op_debug_format() {
1149 - assert_eq!(format!("{:?}", ChangeOp::Insert), "Insert");
1150 - assert_eq!(format!("{:?}", ChangeOp::Update), "Update");
1151 - assert_eq!(format!("{:?}", ChangeOp::Delete), "Delete");
1152 - }
1153 -
1154 - // ── PullFilter ──
1155 -
1156 - #[test]
1157 - fn pull_filter_serialization_with_both_fields() {
1158 - let filter = PullFilter {
1159 - tables: Some(vec!["tasks".to_string(), "events".to_string()]),
1160 - since: Some("2025-06-15T12:00:00Z".parse().unwrap()),
1161 - };
1162 - let json = serde_json::to_string(&filter).unwrap();
1163 - let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
1164 - assert_eq!(parsed["tables"].as_array().unwrap().len(), 2);
1165 - assert!(parsed["since"].is_string());
1166 - }
1167 -
1168 - #[test]
1169 - fn pull_filter_serialization_with_none_fields() {
1170 - let filter = PullFilter::default();
1171 - let json = serde_json::to_string(&filter).unwrap();
1172 - // None fields should be omitted entirely
1173 - assert!(!json.contains("tables"));
1174 - assert!(!json.contains("since"));
1175 - assert_eq!(json, "{}");
1176 - }
1177 -
1178 - #[test]
1179 - fn pull_filter_empty_tables_vec_omitted() {
1180 - let filter = PullFilter {
1181 - tables: Some(vec![]),
1182 - since: None,
1183 - };
1184 - let json = serde_json::to_string(&filter).unwrap();
1185 - assert!(
1186 - !json.contains("tables"),
1187 - "empty tables vec should be omitted: {json}"
1188 - );
1189 - assert_eq!(json, "{}");
1190 - }
1191 -
1192 - #[test]
1193 - fn filtered_pull_request_includes_filter_fields() {
1194 - let req = FilteredPullRequest {
1195 - device_id: DeviceId::new(
1196 - Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap(),
1197 - ),
1198 - cursor: 42,
1199 - tables: Some(vec!["tasks".to_string()]),
1200 - since: Some("2025-01-01T00:00:00Z".parse().unwrap()),
1201 - };
1202 - let json = serde_json::to_string(&req).unwrap();
1203 - let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
1204 - assert_eq!(parsed["cursor"], 42);
1205 - assert_eq!(parsed["tables"].as_array().unwrap().len(), 1);
1206 - assert!(parsed["since"].is_string());
1207 - }
1208 -
1209 - // ── Hybrid logical clock ──
1210 -
1211 - #[test]
1212 - fn hlc_orders_by_wall_then_counter_then_node() {
1213 - let a = DeviceId::new(Uuid::from_u128(1));
1214 - let b = DeviceId::new(Uuid::from_u128(2));
1215 - // wall dominates
1216 - assert!(
1217 - Hlc {
1218 - wall_ms: 1,
1219 - counter: 9,
1220 - node: b
1221 - } < Hlc {
1222 - wall_ms: 2,
1223 - counter: 0,
1224 - node: a
1225 - }
1226 - );
1227 - // then counter
1228 - assert!(
1229 - Hlc {
1230 - wall_ms: 5,
1231 - counter: 0,
1232 - node: b
1233 - } < Hlc {
1234 - wall_ms: 5,
1235 - counter: 1,
1236 - node: a
1237 - }
1238 - );
1239 - // then node
1240 - assert!(
1241 - Hlc {
1242 - wall_ms: 5,
1243 - counter: 1,
1244 - node: a
1245 - } < Hlc {
1246 - wall_ms: 5,
1247 - counter: 1,
1248 - node: b
1249 - }
1250 - );
1251 - }
1252 -
1253 - #[test]
1254 - fn hlc_tick_adopts_physical_time_and_resets_counter() {
1255 - let node = DeviceId::new(Uuid::from_u128(1));
1256 - let prev = Hlc {
1257 - wall_ms: 1000,
1258 - counter: 4,
1259 - node,
1260 - };
1261 - let next = Hlc::tick(prev, 2000, node);
1262 - assert_eq!(
1263 - next,
1264 - Hlc {
1265 - wall_ms: 2000,
1266 - counter: 0,
1267 - node
1268 - }
1269 - );
1270 - }
1271 -
1272 - #[test]
1273 - fn hlc_tick_bumps_counter_when_clock_has_not_advanced() {
1274 - let node = DeviceId::new(Uuid::from_u128(1));
1275 - // Physical time equal to or behind the last reading: hold wall, bump counter,
1276 - // so the new local event still sorts strictly after the previous one.
1277 - let prev = Hlc {
1278 - wall_ms: 1000,
1279 - counter: 4,
1280 - node,
1281 - };
1282 - assert_eq!(
1283 - Hlc::tick(prev, 1000, node),
1284 - Hlc {
1285 - wall_ms: 1000,
1286 - counter: 5,
1287 - node
1288 - }
1289 - );
1290 - assert_eq!(
1291 - Hlc::tick(prev, 500, node),
1292 - Hlc {
1293 - wall_ms: 1000,
1294 - counter: 5,
1295 - node
1296 - }
1297 - );
1298 - // Strictly increasing under a stuck clock.
1299 - let mut h = Hlc::zero(node);
1300 - let mut last = h;
1301 - for _ in 0..100 {
1302 - h = Hlc::tick(h, 0, node);
1303 - assert!(h > last);
1304 - last = h;
1305 - }
1306 - }
1307 -
1308 - #[test]
1309 - fn hlc_observe_stays_ahead_of_a_skewed_fast_remote() {
1310 - let me = DeviceId::new(Uuid::from_u128(1));
1311 - let them = DeviceId::new(Uuid::from_u128(2));
1312 - // Our physical clock is at 1000; a remote with a fast clock sends 9000.
1313 - let local = Hlc {
1314 - wall_ms: 1000,
1315 - counter: 0,
1316 - node: me,
1317 - };
1318 - let remote = Hlc {
1319 - wall_ms: 9000,
1320 - counter: 3,
1321 - node: them,
1322 - };
1323 - let merged = Hlc::observe(local, remote, 1000, me);
1324 - // We adopt the remote wall and a strictly-greater counter, so our next
1325 - // local write causally follows the remote change despite the skew.
1326 - assert_eq!(merged.wall_ms, 9000);
1327 - assert!(merged.counter > remote.counter);
1328 - assert_eq!(merged.node, me);
1329 - let next_local = Hlc::tick(merged, 1001, me);
1330 - assert!(
1331 - next_local > remote,
1332 - "a later local write must outrank the skewed remote"
1333 - );
1334 - }
Lines truncated
@@ -706,1420 +706,4 @@
706 706 }
707 707
708 708 #[cfg(test)]
709 - mod tests {
710 - use super::*;
711 - use crate::ids::DeviceId;
712 - use crate::types::ChangeOp;
713 - use base64::Engine;
714 - use chrono::Utc;
715 - use std::time::Duration;
716 - use uuid::Uuid;
717 -
718 - use super::super::TOKEN_EXPIRY_BUFFER_SECS;
719 -
720 - fn test_config() -> super::super::SyncKitConfig {
721 - super::super::SyncKitConfig {
722 - server_url: "https://example.com".to_string(),
723 - api_key: "test-api-key-123".to_string(),
724 - }
725 - }
726 -
727 - /// Build a fake JWT with the given `exp` claim (no real signature).
728 - fn fake_jwt(exp: i64) -> String {
729 - let header = base64::engine::general_purpose::URL_SAFE_NO_PAD
730 - .encode(r#"{"alg":"HS256","typ":"JWT"}"#);
731 - let payload_json = serde_json::json!({
732 - "sub": "550e8400-e29b-41d4-a716-446655440000",
733 - "app": "6ba7b810-9dad-11d1-80b4-00c04fd430c8",
734 - "exp": exp,
735 - "iat": exp - 3600,
736 - });
737 - let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD
738 - .encode(payload_json.to_string().as_bytes());
739 - let signature = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(b"fake-signature");
740 - format!("{header}.{payload}.{signature}")
741 - }
742 -
743 - // ── wire-version envelope dispatch ──
744 -
745 - #[test]
746 - fn split_envelope_dispatches_on_explicit_version() {
747 - let node = DeviceId::new(Uuid::from_u128(1));
748 - let hlc = Hlc {
749 - wall_ms: 5,
750 - counter: 2,
751 - node,
752 - };
753 -
754 - // v2 envelope: explicit __skver, parsed by version.
755 - let v2 = serde_json::json!({ "__skver": 2, "__skhlc": hlc, "data": {"k": "v"} });
756 - let (got, data, _) = SyncKitClient::split_hlc_envelope(v2, node, 0).unwrap();
757 - assert_eq!(got, hlc);
758 - assert_eq!(data, Some(serde_json::json!({"k": "v"})));
759 -
760 - // gen-1 envelope: __skhlc present, no version tag.
761 - let gen1 = serde_json::json!({ "__skhlc": hlc, "data": null });
762 - let (got, data, _) = SyncKitClient::split_hlc_envelope(gen1, node, 0).unwrap();
763 - assert_eq!(got, hlc);
764 - assert_eq!(data, None);
765 -
766 - // Bare legacy row: HLC synthesized from node + timestamp.
767 - let bare = serde_json::json!({ "title": "buy milk" });
768 - let (got, data, _) = SyncKitClient::split_hlc_envelope(bare.clone(), node, 1234).unwrap();
769 - assert_eq!(got, Hlc::from_legacy(1234, node));
770 - assert_eq!(data, Some(bare));
771 - }
772 -
773 - #[test]
774 - fn the_storage_stamp_rides_inside_the_sealed_envelope_and_survives_a_round_trip() {
775 - let node = DeviceId::new(Uuid::from_u128(1));
776 - let hlc = Hlc {
777 - wall_ms: 5,
778 - counter: 2,
779 - node,
780 - };
781 -
782 - let stamped =
783 - SyncKitClient::hlc_envelope(&hlc, Some(&serde_json::json!({"k": "v"})), Some(4));
784 - assert_eq!(stamped["__sksv"], serde_json::json!(4));
785 - let (got, data, version) = SyncKitClient::split_hlc_envelope(stamped, node, 0).unwrap();
786 - assert_eq!(got, hlc);
787 - assert_eq!(data, Some(serde_json::json!({"k": "v"})));
788 - assert_eq!(version, Some(4));
789 -
790 - // A Delete carries no row payload and still carries the stamp, which is
791 - // what lets the gate see a peer whose only pending change is a delete.
792 - let delete = SyncKitClient::hlc_envelope(&hlc, None, Some(4));
793 - let (_, data, version) = SyncKitClient::split_hlc_envelope(delete, node, 0).unwrap();
794 - assert_eq!(data, None);
795 - assert_eq!(version, Some(4));
796 - }
797 -
798 - #[test]
799 - fn an_undeclared_version_leaves_the_sealed_bytes_exactly_as_they_were() {
800 - let node = DeviceId::new(Uuid::from_u128(1));
801 - let hlc = Hlc {
802 - wall_ms: 5,
803 - counter: 2,
804 - node,
805 - };
806 - let plain = SyncKitClient::hlc_envelope(&hlc, None, None);
807 - assert!(plain.get("__sksv").is_none());
808 - assert_eq!(
809 - plain,
810 - serde_json::json!({ "__skver": 2, "__skhlc": hlc, "data": null }),
811 - "an app that declares no version must push byte-identical envelopes"
812 - );
813 - }
814 -
815 - /// The stamp is added *within* v2 rather than as a new `__skver`, so a reader
816 - /// that predates it addresses `__skhlc` and `data` by name and is unaffected.
817 - #[test]
818 - fn a_stamped_envelope_still_reads_as_an_ordinary_v2_envelope() {
819 - let node = DeviceId::new(Uuid::from_u128(1));
820 - let hlc = Hlc {
821 - wall_ms: 9,
822 - counter: 1,
823 - node,
824 - };
825 - let stamped = SyncKitClient::hlc_envelope(&hlc, Some(&serde_json::json!(7)), Some(12));
826 - assert_eq!(stamped["__skver"], serde_json::json!(2));
827 - assert_eq!(
828 - serde_json::from_value::<Hlc>(stamped["__skhlc"].clone()).unwrap(),
829 - hlc
830 - );
831 - assert_eq!(stamped["data"], serde_json::json!(7));
832 - }
833 -
834 - /// Optional by construction: a malformed stamp must not fail a change that is
835 - /// otherwise fine.
836 - #[test]
837 - fn a_malformed_stamp_reads_as_no_stamp() {
838 - let node = DeviceId::new(Uuid::from_u128(1));
839 - let hlc = Hlc {
840 - wall_ms: 1,
841 - counter: 0,
842 - node,
843 - };
844 - for bad in [
845 - serde_json::json!("four"),
846 - serde_json::json!(-1),
847 - serde_json::json!(null),
848 - serde_json::json!(u64::from(u32::MAX) + 1),
849 - ] {
850 - let env = serde_json::json!({
851 - "__skver": 2, "__skhlc": hlc, "data": null, "__sksv": bad
852 - });
853 - let (_, _, version) = SyncKitClient::split_hlc_envelope(env, node, 0).unwrap();
854 - assert_eq!(version, None, "bad stamp {bad} should read as absent");
855 - }
856 - }
857 -
858 - #[test]
859 - fn split_envelope_rejects_unknown_version_loudly() {
860 - // The X2 hazard: a future envelope version must error, not silently
861 - // fall back to a bare-row read (which would corrupt the clock).
862 - let node = DeviceId::new(Uuid::from_u128(1));
863 - let hlc = Hlc {
864 - wall_ms: 5,
865 - counter: 0,
866 - node,
867 - };
868 - let future = serde_json::json!({ "__skver": 3, "__skhlc": hlc, "data": null });
869 - let err = SyncKitClient::split_hlc_envelope(future, node, 0).unwrap_err();
870 - assert!(
871 - matches!(err, SyncKitError::Crypto(ref m) if m.contains("envelope version 3")),
872 - "unexpected error: {err:?}"
873 - );
874 - }
875 -
876 - // ── encrypt_change / decrypt_change ──
877 -
878 - #[test]
879 - fn delete_seals_hlc_envelope_and_roundtrips() {
880 - // A Delete carries no row payload, but its HLC must still travel, so it is
881 - // now encrypted into an envelope (data = Some), and decrypt restores the
882 - // op, a None payload, and the exact HLC.
883 - let client = SyncKitClient::new(test_config());
884 - let key = crypto::generate_master_key();
885 - *client.master_key.write() = Some(crypto::ZeroizeOnDrop(key));
886 -
887 - let device = DeviceId::new(Uuid::new_v4());
888 - let hlc = Hlc {
889 - wall_ms: 12_345,
890 - counter: 7,
891 - node: device,
892 - };
893 - let entry = ChangeEntry {
894 - table: "tasks".to_string(),
895 - op: ChangeOp::Delete,
896 - row_id: "row-1".to_string(),
897 - timestamp: Utc::now(),
898 - hlc,
899 - data: None,
900 - extra: serde_json::Map::default(),
901 - };
902 -
903 - let wire = client.encrypt_change(entry).unwrap();
904 - assert_eq!(wire.op, ChangeOp::Delete);
905 - assert!(
906 - wire.data.is_some(),
907 - "delete now seals an encrypted HLC envelope"
908 - );
909 -
910 - let pull_entry = PullChangeEntry {
911 - seq: 1,
912 - device_id: device,
913 - table: wire.table,
914 - op: wire.op,
915 - row_id: wire.row_id,
916 - timestamp: wire.timestamp,
917 - data: wire.data,
918 - key_id: None,
919 - gck_version: None,
920 - };
921 - let decrypted = client.decrypt_change(pull_entry).unwrap();
922 - assert_eq!(decrypted.op, ChangeOp::Delete);
923 - assert_eq!(decrypted.row_id, "row-1");
924 - assert!(
925 - decrypted.data.is_none(),
926 - "payload is still None after the envelope unwraps"
927 - );
928 - assert_eq!(decrypted.hlc, hlc, "HLC survives the round trip");
929 - }
930 -
931 - #[test]
932 - fn encrypt_change_fails_without_master_key() {
933 - let client = SyncKitClient::new(test_config());
934 - let entry = ChangeEntry {
935 - table: "tasks".to_string(),
936 - op: ChangeOp::Insert,
937 - row_id: "row-1".to_string(),
938 - timestamp: Utc::now(),
939 - hlc: Hlc::zero(DeviceId::nil()),
940 - data: Some(serde_json::json!({"title": "test"})),
941 - extra: serde_json::Map::default(),
942 - };
943 -
944 - let err = client.encrypt_change(entry).unwrap_err();
945 - assert!(matches!(err, SyncKitError::NoMasterKey));
946 - }
947 -
948 - #[test]
949 - fn encrypt_change_produces_encrypted_data() {
950 - let client = SyncKitClient::new(test_config());
951 - let key = crypto::generate_master_key();
952 - *client.master_key.write() = Some(crypto::ZeroizeOnDrop(key));
953 -
954 - let original_data = serde_json::json!({"title": "Buy milk", "priority": 3});
955 - let entry = ChangeEntry {
956 - table: "tasks".to_string(),
957 - op: ChangeOp::Insert,
958 - row_id: "row-1".to_string(),
959 - timestamp: Utc::now(),
960 - hlc: Hlc::zero(DeviceId::nil()),
961 - data: Some(original_data.clone()),
962 - extra: serde_json::Map::default(),
963 - };
964 -
965 - let wire = client.encrypt_change(entry).unwrap();
966 - assert!(wire.data.is_some());
967 - let encrypted = wire.data.unwrap();
968 - assert!(encrypted.is_string());
969 - assert_ne!(encrypted, original_data);
970 - }
971 -
972 - #[test]
973 - fn encrypt_decrypt_roundtrip() {
974 - let client = SyncKitClient::new(test_config());
975 - let key = crypto::generate_master_key();
976 - *client.master_key.write() = Some(crypto::ZeroizeOnDrop(key));
977 -
978 - let original_data = serde_json::json!({
979 - "title": "Buy milk",
980 - "tags": ["groceries", "urgent"],
981 - "count": 42
982 - });
983 - let ts = Utc::now();
984 - let entry = ChangeEntry {
985 - table: "tasks".to_string(),
986 - op: ChangeOp::Update,
987 - row_id: "row-abc".to_string(),
988 - timestamp: ts,
989 - hlc: Hlc::zero(DeviceId::nil()),
990 - data: Some(original_data.clone()),
991 - extra: serde_json::Map::default(),
992 - };
993 -
994 - let wire = client.encrypt_change(entry).unwrap();
995 - let pull_entry = PullChangeEntry {
996 - seq: 1,
997 - device_id: crate::ids::DeviceId::new(uuid::Uuid::new_v4()),
998 - table: wire.table,
999 - op: wire.op,
1000 - row_id: wire.row_id,
1001 - timestamp: wire.timestamp,
1002 - data: wire.data,
1003 - key_id: None,
1004 - gck_version: None,
1005 - };
1006 -
1007 - let decrypted = client.decrypt_change(pull_entry).unwrap();
1008 - assert_eq!(decrypted.table, "tasks");
1009 - assert_eq!(decrypted.op, ChangeOp::Update);
1010 - assert_eq!(decrypted.row_id, "row-abc");
1011 - assert_eq!(decrypted.data.unwrap(), original_data);
1012 - }
1013 -
1014 - #[test]
1015 - fn decrypt_change_with_no_data() {
1016 - let client = SyncKitClient::new(test_config());
1017 - let pull_entry = PullChangeEntry {
1018 - seq: 5,
1019 - device_id: crate::ids::DeviceId::new(uuid::Uuid::new_v4()),
1020 - table: "events".to_string(),
1021 - op: ChangeOp::Delete,
1022 - row_id: "evt-1".to_string(),
1023 - timestamp: Utc::now(),
1024 - data: None,
1025 - key_id: None,
1026 - gck_version: None,
1027 - };
1028 -
1029 - let decrypted = client.decrypt_change(pull_entry).unwrap();
1030 - assert_eq!(decrypted.table, "events");
1031 - assert_eq!(decrypted.op, ChangeOp::Delete);
1032 - assert!(decrypted.data.is_none());
1033 - }
1034 -
1035 - #[test]
1036 - fn decrypt_change_fails_without_master_key() {
1037 - let client = SyncKitClient::new(test_config());
1038 - let pull_entry = PullChangeEntry {
1039 - seq: 1,
1040 - device_id: crate::ids::DeviceId::new(uuid::Uuid::new_v4()),
1041 - table: "tasks".to_string(),
1042 - op: ChangeOp::Insert,
1043 - row_id: "row-1".to_string(),
1044 - timestamp: Utc::now(),
1045 - data: Some(serde_json::json!("some-encrypted-string")),
1046 - key_id: None,
1047 - gck_version: None,
1048 - };
1049 -
1050 - let err = client.decrypt_change(pull_entry).unwrap_err();
1051 - assert!(matches!(err, SyncKitError::NoMasterKey));
1052 - }
1053 -
1054 - // ── is_transient error classification ──
1055 -
1056 - #[test]
1057 - fn is_transient_server_5xx() {
1058 - let err = SyncKitError::Server {
1059 - status: 500,
1060 - message: "Internal Server Error".to_string(),
1061 - retry_after_secs: None,
1062 - };
1063 - assert!(is_transient(&err));
1064 - let err = SyncKitError::Server {
1065 - status: 502,
1066 - message: "Bad Gateway".to_string(),
1067 - retry_after_secs: None,
1068 - };
1069 - assert!(is_transient(&err));
1070 - let err = SyncKitError::Server {
1071 - status: 503,
1072 - message: "Service Unavailable".to_string(),
1073 - retry_after_secs: None,
1074 - };
1075 - assert!(is_transient(&err));
1076 - let err = SyncKitError::Server {
1077 - status: 504,
1078 - message: "Gateway Timeout".to_string(),
1079 - retry_after_secs: None,
1080 - };
1081 - assert!(is_transient(&err));
1082 - }
1083 -
1084 - #[test]
1085 - fn is_transient_rate_limited_429() {
1086 - let err = SyncKitError::Server {
1087 - status: 429,
1088 - message: "Too Many Requests".to_string(),
1089 - retry_after_secs: None,
1090 - };
1091 - assert!(is_transient(&err));
1092 - }
1093 -
1094 - #[test]
1095 - fn is_not_transient_client_4xx() {
1096 - let err = SyncKitError::Server {
1097 - status: 400,
1098 - message: "Bad Request".to_string(),
1099 - retry_after_secs: None,
1100 - };
1101 - assert!(!is_transient(&err));
1102 - let err = SyncKitError::Server {
1103 - status: 401,
1104 - message: "Unauthorized".to_string(),
1105 - retry_after_secs: None,
1106 - };
1107 - assert!(!is_transient(&err));
1108 - let err = SyncKitError::Server {
1109 - status: 403,
1110 - message: "Forbidden".to_string(),
1111 - retry_after_secs: None,
1112 - };
1113 - assert!(!is_transient(&err));
1114 - let err = SyncKitError::Server {
1115 - status: 404,
1116 - message: "Not Found".to_string(),
1117 - retry_after_secs: None,
1118 - };
1119 - assert!(!is_transient(&err));
1120 - let err = SyncKitError::Server {
1121 - status: 409,
1122 - message: "Conflict".to_string(),
1123 - retry_after_secs: None,
1124 - };
1125 - assert!(!is_transient(&err));
1126 - let err = SyncKitError::Server {
1127 - status: 422,
1128 - message: "Unprocessable Entity".to_string(),
1129 - retry_after_secs: None,
1130 - };
1131 - assert!(!is_transient(&err));
1132 - }
1133 -
1134 - #[test]
1135 - fn is_not_transient_not_authenticated() {
1136 - assert!(!is_transient(&SyncKitError::NotAuthenticated));
1137 - }
1138 -
1139 - #[test]
1140 - fn is_not_transient_no_master_key() {
1141 - assert!(!is_transient(&SyncKitError::NoMasterKey));
1142 - }
1143 -
1144 - #[test]
1145 - fn is_not_transient_decryption_failed() {
1146 - assert!(!is_transient(&SyncKitError::DecryptionFailed));
1147 - }
1148 -
1149 - #[test]
1150 - fn is_not_transient_invalid_envelope() {
1151 - assert!(!is_transient(&SyncKitError::InvalidEnvelope(
1152 - "bad version".to_string()
1153 - )));
1154 - }
1155 -
1156 - #[test]
1157 - fn is_not_transient_crypto() {
1158 - assert!(!is_transient(&SyncKitError::Crypto(
1159 - "encrypt failed".to_string()
1160 - )));
1161 - }
1162 -
1163 - #[test]
1164 - fn is_not_transient_json() {
1165 - let err: SyncKitError = serde_json::from_str::<serde_json::Value>("not json")
1166 - .unwrap_err()
1167 - .into();
1168 - assert!(!is_transient(&err));
1169 - }
1170 -
1171 - #[test]
1172 - fn is_not_transient_base64() {
1173 - let err: SyncKitError = base64::engine::general_purpose::STANDARD
1174 - .decode("!!!invalid!!!")
1175 - .unwrap_err()
1176 - .into();
1177 - assert!(!is_transient(&err));
1178 - }
1179 -
1180 - #[test]
1181 - fn is_not_transient_token_expired() {
1182 - assert!(!is_transient(&SyncKitError::TokenExpired));
1183 - }
1184 -
1185 - #[test]
1186 - fn is_not_transient_internal() {
1187 - assert!(!is_transient(&SyncKitError::Internal(
1188 - "lock poisoned".to_string()
1189 - )));
1190 - }
1191 -
1192 - // ── Retry constants ──
1193 -
1194 - #[test]
1195 - fn retry_constants_are_sensible() {
1196 - assert_eq!(MAX_RETRIES, 3);
1197 - assert_eq!(BASE_DELAY, Duration::from_secs(1));
1198 - }
1199 -
1200 - #[test]
1201 - fn backoff_delays_are_exponential() {
1202 - let delay_0 = BASE_DELAY * 2u32.pow(0);
1203 - let delay_1 = BASE_DELAY * 2u32.pow(1);
1204 - let delay_2 = BASE_DELAY * 2u32.pow(2);
1205 -
Lines truncated
@@ -740,908 +740,4 @@
740 740 }
741 741
742 742 #[cfg(test)]
743 - mod tests {
744 - use super::*;
745 - use crate::types::{ChangeEntry, ChangeOp, hlc_legacy_floor};
746 - use rusqlite::Connection;
747 - use serde_json::json;
748 -
749 - use super::super::db::configure_connection;
750 - use super::super::schema::{SyncSchema, SyncTable};
751 -
752 - fn upsert(table: &str, row_id: &str, data: Value) -> ChangeEntry {
753 - ChangeEntry {
754 - table: table.into(),
755 - op: ChangeOp::Insert,
756 - row_id: row_id.into(),
757 - timestamp: chrono::Utc::now(),
758 - hlc: hlc_legacy_floor(),
759 - data: Some(data),
760 - extra: serde_json::Map::default(),
761 - }
762 - }
763 -
764 - fn delete(table: &str, row_id: &str, data: Value) -> ChangeEntry {
765 - ChangeEntry {
766 - op: ChangeOp::Delete,
767 - ..upsert(table, row_id, data)
768 - }
769 - }
770 -
771 - fn schema() -> SyncSchema {
772 - SyncSchema::new(vec![
773 - SyncTable::full("parent", &["id", "name"]),
774 - SyncTable::full("child", &["id", "parent_id", "note"]),
775 - SyncTable::full("acct", &["id", "name"])
776 - .preserve_local(&["secret"])
777 - .insert_defaults(&[("secret", "")]),
778 - SyncTable::full("tagpair", &["a", "b"]).pk(&["a", "b"]),
779 - SyncTable::full("items", &["id", "is_read", "is_starred"])
780 - .partial_update(&["is_read", "is_starred"])
781 - .ignore_deletes(),
782 - SyncTable::full("samp", &["hash", "name", "deleted_at"])
783 - .pk(&["hash"])
784 - .hashed()
785 - .tombstone("deleted_at"),
786 - SyncTable::full("cfg", &["key", "value"])
787 - .pk(&["key"])
788 - .exclude_where("{row}.key NOT LIKE 'sync\\_%' ESCAPE '\\'"),
789 - SyncTable::full("reffer", &["id", "ext_id"]).references_unsynced(),
790 - // `kind` is NOT NULL *with a default*, the only shape in which
791 - // omitting a column and binding an explicit NULL differ observably.
792 - SyncTable::full("note", &["id", "body", "kind"]),
793 - // A preserved column that is also a whitelist column, so a payload
794 - // can carry it and the ON CONFLICT SET has to refuse it.
795 - SyncTable::full("vault", &["id", "label", "token"]).preserve_local(&["token"]),
796 - // Partial update on a composite key: two WHERE bindings, not one.
797 - SyncTable::full("pairflag", &["a", "b", "flag"])
798 - .pk(&["a", "b"])
799 - .partial_update(&["flag"]),
800 - // INTEGER PRIMARY KEY, so a text id is a datatype mismatch: a SQLite
801 - // failure that is not a constraint violation.
802 - SyncTable::full("tally", &["id", "label"]),
803 - ])
804 - }
805 -
806 - fn db() -> Connection {
807 - let conn = Connection::open_in_memory().unwrap();
808 - configure_connection(&conn).unwrap();
809 - conn.execute_batch(
810 - "
811 - CREATE TABLE parent (id TEXT PRIMARY KEY, name TEXT);
812 - CREATE TABLE child (id TEXT PRIMARY KEY, parent_id TEXT NOT NULL REFERENCES parent(id) ON DELETE CASCADE, note TEXT);
813 - CREATE TABLE acct (id TEXT PRIMARY KEY, name TEXT, secret TEXT NOT NULL);
814 - CREATE TABLE tagpair (a TEXT, b TEXT, PRIMARY KEY (a, b));
815 - CREATE TABLE items (id TEXT PRIMARY KEY, is_read INTEGER, is_starred INTEGER, title TEXT);
816 - CREATE TABLE ghost (id INTEGER PRIMARY KEY);
817 - CREATE TABLE samp (hash TEXT PRIMARY KEY, name TEXT, deleted_at INTEGER);
818 - CREATE TABLE cfg (key TEXT PRIMARY KEY, value TEXT);
819 - CREATE TABLE reffer (id TEXT PRIMARY KEY, ext_id INTEGER NOT NULL REFERENCES ghost(id));
820 - CREATE TABLE note (id TEXT PRIMARY KEY, body TEXT, kind TEXT NOT NULL DEFAULT 'plain');
821 - CREATE TABLE vault (id TEXT PRIMARY KEY, label TEXT, token TEXT);
822 - CREATE TABLE pairflag (a TEXT, b TEXT, flag INTEGER, PRIMARY KEY (a, b));
823 - CREATE TABLE tally (id INTEGER PRIMARY KEY, label TEXT);
824 - ",
825 - )
826 - .unwrap();
827 - let s = schema();
828 - conn.execute_batch(&s.migration_sql()).unwrap();
829 - conn
830 - }
831 -
832 - /// These tests exercise the applier, not the pipeline that feeds it, so they
833 - /// build the resolved batch directly rather than routing every case through
834 - /// `resolve_pull`.
835 - fn apply(conn: &mut Connection, changes: &[ChangeEntry]) -> ApplyOutcome {
836 - let changes = ResolvedChanges::for_test(changes.to_vec());
837 - apply_remote_changes(conn, &schema(), &changes, "").unwrap()
838 - }
839 -
840 - #[test]
841 - fn full_insert_then_update_via_on_conflict() {
842 - let mut conn = db();
843 - let o = apply(
844 - &mut conn,
845 - &[upsert("parent", "p1", json!({"id":"p1","name":"a"}))],
846 - );
847 - assert_eq!(o.applied, 1);
848 - assert!(o.changed_tables.contains("parent"));
849 - apply(
850 - &mut conn,
851 - &[upsert("parent", "p1", json!({"id":"p1","name":"b"}))],
852 - );
853 - let name: String = conn
854 - .query_row("SELECT name FROM parent WHERE id='p1'", [], |r| r.get(0))
855 - .unwrap();
856 - assert_eq!(name, "b");
857 - }
858 -
859 - #[test]
860 - fn on_conflict_update_does_not_cascade_to_children() {
861 - let mut conn = db();
862 - apply(
863 - &mut conn,
864 - &[
865 - upsert("parent", "p1", json!({"id":"p1","name":"a"})),
866 - upsert(
867 - "child",
868 - "c1",
869 - json!({"id":"c1","parent_id":"p1","note":"n"}),
870 - ),
871 - ],
872 - );
873 - // Re-upsert the parent; the child must survive (ON CONFLICT DO UPDATE, not REPLACE).
874 - apply(
875 - &mut conn,
876 - &[upsert("parent", "p1", json!({"id":"p1","name":"a2"}))],
877 - );
878 - let kids: i64 = conn
879 - .query_row("SELECT COUNT(*) FROM child", [], |r| r.get(0))
880 - .unwrap();
881 - assert_eq!(kids, 1);
882 - }
883 -
884 - #[test]
885 - fn preserve_local_and_insert_defaults() {
886 - let mut conn = db();
887 - // First insert: secret defaults to '' (satisfies NOT NULL); payload never carries it.
888 - apply(
889 - &mut conn,
890 - &[upsert("acct", "a1", json!({"id":"a1","name":"n1"}))],
891 - );
892 - // Locally the user sets a real secret.
893 - conn.execute("UPDATE acct SET secret='hunter2' WHERE id='a1'", [])
894 - .unwrap();
895 - // A remote update to config columns must NOT clobber the local secret.
896 - apply(
897 - &mut conn,
898 - &[upsert("acct", "a1", json!({"id":"a1","name":"n2"}))],
899 - );
900 - let (name, secret): (String, String) = conn
901 - .query_row("SELECT name, secret FROM acct WHERE id='a1'", [], |r| {
902 - Ok((r.get(0)?, r.get(1)?))
903 - })
904 - .unwrap();
905 - assert_eq!(name, "n2");
906 - assert_eq!(
907 - secret, "hunter2",
908 - "preserved secret must survive a remote update"
909 - );
910 - }
911 -
912 - #[test]
913 - fn null_tolerance_omits_not_null_but_keeps_nullable_null() {
914 - let mut conn = db();
915 - apply(
916 - &mut conn,
917 - &[upsert("parent", "p1", json!({"id":"p1","name":"start"}))],
918 - );
919 - // name is nullable → an explicit null clears it.
920 - apply(
921 - &mut conn,
922 - &[upsert("parent", "p1", json!({"id":"p1","name":null}))],
923 - );
924 - let name: Option<String> = conn
925 - .query_row("SELECT name FROM parent WHERE id='p1'", [], |r| r.get(0))
926 - .unwrap();
927 - assert_eq!(name, None);
928 - // A null for a NOT NULL column (child.parent_id) is omitted, so an insert
929 - // takes no value for it → constraint violation → deferred, not fatal.
930 - let o = apply(
931 - &mut conn,
932 - &[upsert(
933 - "child",
934 - "c1",
935 - json!({"id":"c1","parent_id":null,"note":"x"}),
936 - )],
937 - );
938 - assert_eq!(o.applied, 0);
939 - assert_eq!(o.deferred.len(), 1);
940 - assert_eq!(o.deferred[0].row_id, "c1");
941 - }
942 -
943 - #[test]
944 - fn all_pk_table_uses_insert_or_ignore() {
945 - let mut conn = db();
946 - let o = apply(
947 - &mut conn,
948 - &[upsert("tagpair", "x", json!({"a":"x","b":"y"}))],
949 - );
950 - assert_eq!(o.applied, 1);
951 - // Re-applying the same all-PK row is a no-op, not an error.
952 - let o2 = apply(
953 - &mut conn,
954 - &[upsert("tagpair", "x", json!({"a":"x","b":"y"}))],
955 - );
956 - assert_eq!(o2.applied, 1); // executed, 0 rows changed, still Ok
957 - let n: i64 = conn
958 - .query_row("SELECT COUNT(*) FROM tagpair", [], |r| r.get(0))
959 - .unwrap();
960 - assert_eq!(n, 1);
961 - }
962 -
963 - #[test]
964 - fn partial_update_touches_only_set_columns() {
965 - let mut conn = db();
966 - conn.execute(
967 - "INSERT INTO items (id, is_read, is_starred, title) VALUES ('i1', 0, 0, 'keep')",
968 - [],
969 - )
970 - .unwrap();
971 - apply(
972 - &mut conn,
973 - &[ChangeEntry {
974 - op: ChangeOp::Update,
975 - ..upsert("items", "i1", json!({"id":"i1","is_read":1,"is_starred":0}))
976 - }],
977 - );
978 - let (read, title): (i64, String) = conn
979 - .query_row("SELECT is_read, title FROM items WHERE id='i1'", [], |r| {
980 - Ok((r.get(0)?, r.get(1)?))
981 - })
982 - .unwrap();
983 - assert_eq!(read, 1);
984 - assert_eq!(
985 - title, "keep",
986 - "partial update must not touch non-set columns"
987 - );
988 - }
989 -
990 - #[test]
991 - fn hard_delete_and_ignore_delete() {
992 - let mut conn = db();
993 - apply(
994 - &mut conn,
995 - &[upsert("parent", "p1", json!({"id":"p1","name":"a"}))],
996 - );
997 - let o = apply(&mut conn, &[delete("parent", "p1", json!({"id":"p1"}))]);
998 - assert_eq!(o.applied, 1);
999 - assert_eq!(
1000 - conn.query_row("SELECT COUNT(*) FROM parent", [], |r| r.get::<_, i64>(0))
1001 - .unwrap(),
1002 - 0
1003 - );
1004 -
1005 - // items ignore deletes.
1006 - conn.execute(
1007 - "INSERT INTO items (id, is_read, is_starred) VALUES ('i1', 1, 0)",
1008 - [],
1009 - )
1010 - .unwrap();
1011 - let o2 = apply(&mut conn, &[delete("items", "i1", json!({"id":"i1"}))]);
1012 - assert_eq!(o2.applied, 0);
1013 - assert_eq!(
1014 - conn.query_row("SELECT COUNT(*) FROM items", [], |r| r.get::<_, i64>(0))
1015 - .unwrap(),
1016 - 1
1017 - );
1018 - }
1019 -
1020 - #[test]
1021 - fn tombstone_delete_sets_column_and_keeps_earliest() {
1022 - let mut conn = db();
1023 - conn.execute("INSERT INTO samp (hash, name) VALUES ('h1', 's')", [])
1024 - .unwrap();
1025 - // A hashed table's delete carries the PK in data; the opaque row_id is ignored.
1026 - apply(
1027 - &mut conn,
1028 - &[delete("samp", "OPAQUE_HASH", json!({"hash":"h1"}))],
1029 - );
1030 - let (present, del): (i64, Option<i64>) = conn
1031 - .query_row(
1032 - "SELECT COUNT(*), MAX(deleted_at) FROM samp WHERE hash='h1'",
1033 - [],
1034 - |r| Ok((r.get(0)?, r.get(1)?)),
1035 - )
1036 - .unwrap();
1037 - assert_eq!(present, 1, "tombstone keeps the row");
1038 - let first = del.unwrap();
1039 - // Re-deleting keeps the earliest instant (COALESCE).
1040 - apply(
1041 - &mut conn,
1042 - &[delete("samp", "OPAQUE_HASH", json!({"hash":"h1"}))],
1043 - );
1044 - let second: i64 = conn
1045 - .query_row("SELECT deleted_at FROM samp WHERE hash='h1'", [], |r| {
1046 - r.get(0)
1047 - })
1048 - .unwrap();
1049 - assert_eq!(first, second);
1050 - }
1051 -
1052 - #[test]
1053 - fn exclude_where_guards_import_both_ways() {
1054 - let mut conn = db();
1055 - let o = apply(
1056 - &mut conn,
1057 - &[
1058 - upsert(
1059 - "cfg",
1060 - "sync_cursor",
1061 - json!({"key":"sync_cursor","value":"9"}),
1062 - ), // excluded
1063 - upsert("cfg", "theme", json!({"key":"theme","value":"dark"})), // included
1064 - ],
1065 - );
1066 - assert_eq!(o.applied, 1);
1067 - let keys: Vec<String> = {
1068 - let mut s = conn.prepare("SELECT key FROM cfg ORDER BY key").unwrap();
1069 - s.query_map([], |r| r.get(0))
1070 - .unwrap()
1071 - .map(|r| r.unwrap())
1072 - .collect()
1073 - };
1074 - assert_eq!(keys, vec!["theme".to_string()]);
1075 - // A hostile delete of an excluded key is also dropped.
1076 - conn.execute(
1077 - "INSERT INTO cfg (key, value) VALUES ('sync_secret', 'x')",
1078 - [],
1079 - )
1080 - .unwrap();
1081 - let o2 = apply(
1082 - &mut conn,
1083 - &[delete("cfg", "sync_secret", json!({"key":"sync_secret"}))],
1084 - );
1085 - assert_eq!(o2.applied, 0);
1086 - assert_eq!(
1087 - conn.query_row(
1088 - "SELECT COUNT(*) FROM cfg WHERE key='sync_secret'",
1089 - [],
1090 - |r| r.get::<_, i64>(0)
1091 - )
1092 - .unwrap(),
1093 - 1
1094 - );
1095 - }
1096 -
1097 - #[test]
1098 - fn fk_ordering_parents_before_children_children_before_parents() {
1099 - let mut conn = db();
1100 - // Child listed before parent in the batch, but FK enforced, must still apply
1101 - // because the engine orders upserts parents-first.
1102 - let o = apply(
1103 - &mut conn,
1104 - &[
1105 - upsert(
1106 - "child",
1107 - "c1",
1108 - json!({"id":"c1","parent_id":"p1","note":"n"}),
1109 - ),
1110 - upsert("parent", "p1", json!({"id":"p1","name":"a"})),
1111 - ],
1112 - );
1113 - assert_eq!(o.applied, 2);
1114 - // Delete both; children-first ordering means the child goes before the parent.
1115 - let o2 = apply(
1116 - &mut conn,
1117 - &[
1118 - delete("parent", "p1", json!({"id":"p1"})),
1119 - delete("child", "c1", json!({"id":"c1"})),
1120 - ],
1121 - );
1122 - assert_eq!(o2.applied, 2);
1123 - }
1124 -
1125 - #[test]
1126 - fn references_unsynced_relaxes_fk() {
1127 - let mut conn = db();
1128 - // reffer.ext_id points at a ghost row that does not exist and is not synced.
1129 - // Without FK relaxation this would be a constraint violation.
1130 - let o = apply(
1131 - &mut conn,
1132 - &[upsert("reffer", "r1", json!({"id":"r1","ext_id":999}))],
1133 - );
1134 - assert_eq!(
1135 - o.applied, 1,
1136 - "references_unsynced disables FK for the apply"
1137 - );
1138 - // FK enforcement is restored afterward.
1139 - let fk: i64 = conn
1140 - .query_row("PRAGMA foreign_keys", [], |r| r.get(0))
1141 - .unwrap();
1142 - assert_eq!(fk, 1);
1143 - }
1144 -
1145 - #[test]
1146 - fn constraint_violation_is_skipped_not_fatal() {
1147 - let mut conn = db();
1148 - // First row violates FK (no parent p9); second is valid. Batch must not abort.
1149 - let o = apply(
1150 - &mut conn,
1151 - &[
1152 - upsert(
1153 - "child",
1154 - "bad",
1155 - json!({"id":"bad","parent_id":"p9","note":"x"}),
1156 - ),
1157 - upsert("parent", "p1", json!({"id":"p1","name":"ok"})),
1158 - ],
1159 - );
1160 - assert_eq!(o.applied, 1);
1161 - assert_eq!(o.deferred.len(), 1, "the poison row is held, not lost");
1162 - assert_eq!(
1163 - conn.query_row("SELECT COUNT(*) FROM parent", [], |r| r.get::<_, i64>(0))
1164 - .unwrap(),
1165 - 1
1166 - );
1167 - }
1168 -
1169 - #[test]
1170 - fn unknown_table_change_is_deferred_not_dropped() {
1171 - let mut conn = db();
1172 - let o = apply(&mut conn, &[upsert("nonexistent", "x", json!({"id":"x"}))]);
1173 - assert_eq!(o.applied, 0);
1174 - // Deferred rather than rejected: the table may exist after a client
1175 - // upgrade, and then the held entry applies.
1176 - assert_eq!(o.deferred.len(), 1);
1177 - assert_eq!(o.deferred[0].table, "nonexistent");
1178 - assert!(o.rejected.is_empty());
1179 - }
1180 -
1181 - #[test]
1182 - fn an_excluded_row_is_filtered_not_held() {
1183 - let mut conn = db();
1184 - // cfg's include predicate is "key NOT LIKE 'sync_%'", so a sync_ key is
1185 - // excluded on import. That is policy, not failure, and must never reach
1186 - // the dead-letter.
1187 - let o = apply(
1188 - &mut conn,
1189 - &[upsert(
1190 - "cfg",
1191 - "sync_token",
1192 - json!({"key":"sync_token","value":"x"}),
1193 - )],
1194 - );
1195 - assert_eq!(o.applied, 0);
1196 - assert_eq!(o.filtered, 1);
1197 - assert!(o.deferred.is_empty());
1198 - assert!(o.rejected.is_empty());
1199 - }
1200 -
1201 - #[test]
1202 - fn a_payloadless_upsert_is_rejected_not_deferred() {
1203 - let mut conn = db();
1204 - let mut change = upsert("parent", "p1", json!({"id":"p1"}));
1205 - change.data = None;
1206 - let o = apply(&mut conn, &[change]);
1207 - assert_eq!(o.applied, 0);
1208 - assert_eq!(
1209 - o.rejected.len(),
1210 - 1,
1211 - "identical bytes would fail identically"
1212 - );
1213 - assert!(o.deferred.is_empty());
1214 - }
1215 -
1216 - #[test]
1217 - fn fk_sweep_catches_what_the_batch_wide_relaxation_hides() {
1218 - let mut conn = db();
1219 - // `reffer` declares references_unsynced, so the whole apply runs with
1220 - // foreign_keys=OFF. Without the sweep, the child row below lands with a
1221 - // missing parent and nothing is reported.
1222 - let o = apply(
1223 - &mut conn,
1224 - &[
1225 - upsert("reffer", "r1", json!({"id":"r1","ext_id":404})),
1226 - upsert(
1227 - "child",
1228 - "c1",
1229 - json!({"id":"c1","parent_id":"missing","note":"x"}),
1230 - ),
1231 - ],
1232 - );
1233 -
1234 - assert_eq!(
1235 - conn.query_row("SELECT COUNT(*) FROM child", [], |r| r.get::<_, i64>(0))
1236 - .unwrap(),
1237 - 0,
1238 - "the orphan is removed, not left to resurface later"
1239 - );
Lines truncated
@@ -566,1591 +566,4 @@
566 566 }
567 567
568 568 #[cfg(test)]
569 - mod tests {
570 - use super::super::apply::apply_remote_changes;
571 - use super::super::db::configure_connection;
572 - use super::super::schema::{SyncSchema, SyncTable};
573 - use super::*;
574 - use rusqlite::Connection;
575 -
576 - fn node(n: u128) -> DeviceId {
577 - DeviceId::new(Uuid::from_u128(n))
578 - }
579 -
580 - fn schema() -> SyncSchema {
581 - SyncSchema::new(vec![SyncTable::full("note", &["id", "name"])])
582 - }
583 -
584 - fn server_order_schema() -> SyncSchema {
585 - schema().conflict_strategy(ConflictStrategy::ServerOrder)
586 - }
587 -
588 - /// A device: in-memory DB with the note table + migration, and a node id.
589 - fn device(n: u128) -> (Connection, DeviceId) {
590 - let conn = Connection::open_in_memory().unwrap();
591 - configure_connection(&conn).unwrap();
592 - conn.execute_batch("CREATE TABLE note (id TEXT PRIMARY KEY, name TEXT);")
593 - .unwrap();
594 - conn.execute_batch(&schema().migration_sql()).unwrap();
595 - (conn, node(n))
596 - }
597 -
598 - /// Make a local edit (domain write → trigger captures it), stamp it at
599 - /// `now_ms`, and return it as a pulled change from `node` (as a peer would
600 - /// receive it via the server).
601 - fn local_edit_as_pulled(
602 - conn: &Connection,
603 - node: DeviceId,
604 - id: &str,
605 - name: &str,
606 - now_ms: i64,
607 - seq: i64,
608 - ) -> PulledChange {
609 - conn.execute(
610 - "INSERT INTO note (id, name) VALUES (?1, ?2) \
611 - ON CONFLICT(id) DO UPDATE SET name = excluded.name",
612 - (id, name),
613 - )
614 - .unwrap();
615 - stamp_pending(conn, node, now_ms).unwrap();
616 - let entry = load_local_pending(conn, node)
617 - .unwrap()
618 - .into_iter()
619 - .find(|e| e.row_id == id)
620 - .unwrap();
621 - PulledChange {
622 - storage_version: None,
623 - entry,
624 - device_id: node,
625 - seq,
626 - }
627 - }
628 -
629 - /// A bare entry for the collapse tests: only table, row_id, op and HLC
630 - /// matter there, so the payload names the entry for the assertion message.
631 - fn entry(row_id: &str, op: ChangeOp, wall_ms: i64, node_n: u128, label: &str) -> ChangeEntry {
632 - ChangeEntry {
633 - table: "note".into(),
634 - op,
635 - row_id: row_id.into(),
636 - timestamp: Utc::now(),
637 - hlc: Hlc {
638 - wall_ms,
639 - counter: 0,
640 - node: node(node_n),
641 - },
642 - data: Some(serde_json::json!({ "name": label })),
643 - extra: serde_json::Map::default(),
644 - }
645 - }
646 -
647 - fn labels(entries: &[ChangeEntry]) -> Vec<String> {
648 - entries
649 - .iter()
650 - .map(|e| e.data.as_ref().unwrap()["name"].as_str().unwrap().into())
651 - .collect()
652 - }
653 -
654 - /// The collapse keeps the highest HLC per row, and it has to do so whichever
655 - /// order the entries arrive in. Both directions are asserted because the
656 - /// obvious way to get this wrong, comparing in the wrong direction, is
657 - /// invisible when only the already-sorted order is tested: it then keeps the
658 - /// last entry, which is also the newest.
659 - #[test]
660 - fn collapse_keeps_the_highest_hlc_per_row_in_either_order() {
661 - let older = entry("r1", ChangeOp::Update, 100, 1, "older");
662 - let newer = entry("r1", ChangeOp::Update, 200, 1, "newer");
663 -
664 - let ascending = collapse_max_hlc(vec![older.clone(), newer.clone()]);
665 - assert_eq!(labels(&ascending), ["newer"], "newest lost, arriving last");
666 -
667 - let descending = collapse_max_hlc(vec![newer, older]);
668 - assert_eq!(
669 - labels(&descending),
670 - ["newer"],
671 - "newest lost, arriving first"
672 - );
673 - }
674 -
675 - /// Operation-agnostic: a newer delete beats an older edit and an older
676 - /// delete loses to a newer edit. The HLC decides, never the operation.
677 - #[test]
678 - fn collapse_ignores_the_operation() {
679 - let newer_delete = collapse_max_hlc(vec![
680 - entry("r1", ChangeOp::Update, 100, 1, "edit"),
681 - entry("r1", ChangeOp::Delete, 200, 1, "delete"),
682 - ]);
683 - assert_eq!(newer_delete.len(), 1);
684 - assert_eq!(newer_delete[0].op, ChangeOp::Delete);
685 -
686 - let older_delete = collapse_max_hlc(vec![
687 - entry("r1", ChangeOp::Delete, 100, 1, "delete"),
688 - entry("r1", ChangeOp::Update, 200, 1, "edit"),
689 - ]);
690 - assert_eq!(older_delete.len(), 1);
691 - assert_eq!(older_delete[0].op, ChangeOp::Update);
692 - }
693 -
694 - /// The collapse is per row: distinct rows all survive, and first-seen order
695 - /// is preserved, which is what the doc comment promises the apply engine.
696 - #[test]
697 - fn collapse_is_per_row_and_keeps_first_seen_order() {
698 - let out = collapse_max_hlc(vec![
699 - entry("r2", ChangeOp::Update, 100, 1, "r2-old"),
700 - entry("r1", ChangeOp::Update, 100, 1, "r1-only"),
701 - entry("r2", ChangeOp::Update, 200, 1, "r2-new"),
702 - ]);
703 - assert_eq!(labels(&out), ["r2-new", "r1-only"]);
704 - }
705 -
706 - /// The case the shared order exists for. Two changes for one row at an
707 - /// exact HLC tie: the winner has to be the same on every device, and the
708 - /// only thing every device agrees on is the payload bytes. Arrival order is
709 - /// not that thing, so the two orders must agree here.
710 - #[test]
711 - fn collapse_breaks_an_exact_hlc_tie_on_payload_not_arrival_order() {
712 - let a = entry("r1", ChangeOp::Update, 100, 1, "aaa");
713 - let b = entry("r1", ChangeOp::Update, 100, 1, "bbb");
714 - assert_eq!(a.hlc, b.hlc, "the tie is the premise of this test");
715 -
716 - let forwards = collapse_max_hlc(vec![a.clone(), b.clone()]);
717 - let backwards = collapse_max_hlc(vec![b, a]);
718 - assert_eq!(
719 - labels(&forwards),
720 - labels(&backwards),
721 - "two devices disagreed at an exact tie because they saw the batch in different orders"
722 - );
723 - assert_eq!(labels(&forwards), ["bbb"], "higher payload bytes win");
724 - }
725 -
726 - fn note_name(conn: &Connection, id: &str) -> Option<String> {
727 - conn.query_row("SELECT name FROM note WHERE id = ?1", [id], |r| r.get(0))
728 - .optional()
729 - .unwrap()
730 - }
731 -
732 - fn pull_apply(
733 - conn: &mut Connection,
734 - s: &SyncSchema,
735 - node: DeviceId,
736 - pulled: Vec<PulledChange>,
737 - ) {
738 - let now = Utc::now();
739 - let resolved = resolve_pull(conn, s, node, pulled, now, "").unwrap();
740 - apply_remote_changes(conn, s, &resolved, "").unwrap();
741 - record_committed(conn, resolved.as_slice()).unwrap();
742 - }
743 -
744 - #[test]
745 - fn stamp_pending_assigns_monotonic_hlcs() {
746 - let (conn, n) = device(1);
747 - conn.execute("INSERT INTO note (id, name) VALUES ('a', '1')", [])
748 - .unwrap();
749 - conn.execute("INSERT INTO note (id, name) VALUES ('b', '2')", [])
750 - .unwrap();
751 - assert_eq!(stamp_pending(&conn, n, 1000).unwrap(), 2);
752 - // Re-stamping is a no-op (both already stamped).
753 - assert_eq!(stamp_pending(&conn, n, 2000).unwrap(), 0);
754 - let stamps: Vec<(i64, i64)> = {
755 - let mut s = conn
756 - .prepare("SELECT hlc_wall, hlc_counter FROM sync_changelog ORDER BY id")
757 - .unwrap();
758 - s.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))
759 - .unwrap()
760 - .map(|r| r.unwrap())
761 - .collect()
762 - };
763 - assert!(stamps.iter().all(|(w, _)| *w == 1000));
764 - assert_eq!(
765 - stamps[0].1 + 1,
766 - stamps[1].1,
767 - "counter increments within a wall_ms"
768 - );
769 - }
770 -
771 - #[test]
772 - fn committed_ledger_advances_only() {
773 - let (conn, _) = device(1);
774 - let older = Hlc {
775 - wall_ms: 100,
776 - counter: 0,
777 - node: node(9),
778 - };
779 - let newer = Hlc {
780 - wall_ms: 200,
781 - counter: 0,
782 - node: node(9),
783 - };
784 - set_committed(&conn, "note", "r", &newer).unwrap();
785 - set_committed(&conn, "note", "r", &older).unwrap(); // must not regress
786 - assert_eq!(
787 - committed_hlc(&conn, "note", "r").unwrap().unwrap().wall_ms,
788 - 200
789 - );
790 - }
791 -
792 - #[test]
793 - fn conflicting_edits_converge_to_higher_hlc_both_directions() {
794 - // A edits at t=100, B edits at t=200 → B wins everywhere.
795 - let (mut a, an) = device(1);
796 - let (mut b, bn) = device(2);
797 - let a_change = local_edit_as_pulled(&a, an, "r", "from-A", 100, 1);
798 - let b_change = local_edit_as_pulled(&b, bn, "r", "from-B", 200, 1);
799 -
800 - pull_apply(&mut a, &schema(), an, vec![b_change]); // A pulls B (newer) → adopts B
801 - pull_apply(&mut b, &schema(), bn, vec![a_change]); // B pulls A (older) → keeps B
802 -
803 - assert_eq!(note_name(&a, "r").as_deref(), Some("from-B"));
804 - assert_eq!(note_name(&b, "r").as_deref(), Some("from-B"));
805 - }
806 -
807 - #[test]
808 - fn gate_drops_repulled_older_change() {
809 - let (mut a, an) = device(1);
810 - let (b, bn) = device(2);
811 - // B's change is applied on A.
812 - let b_change = local_edit_as_pulled(&b, bn, "r", "v-old", 100, 1);
813 - pull_apply(&mut a, &schema(), an, vec![b_change.clone()]);
814 - // A then makes a NEWER local edit and commits it.
815 - let _a_new = local_edit_as_pulled(&a, an, "r", "v-new", 300, 2);
816 - // Mark A's edit committed (as a push would) so the gate has a committed clock.
817 - let a_pending = load_local_pending(&a, an);
818 - record_committed(&a, &a_pending.unwrap()).unwrap();
819 - // Re-pulling B's OLD change must be gated out (older than committed).
820 - let resolved = resolve_pull(&a, &schema(), an, vec![b_change], Utc::now(), "").unwrap();
821 - assert!(
822 - resolved.iter().all(|e| e.row_id != "r"),
823 - "stale re-pull must be gated"
824 - );
825 - }
826 -
827 - #[test]
828 - fn newer_delete_beats_older_edit() {
829 - let (mut a, an) = device(1);
830 - let (b, bn) = device(2);
831 - // A has an older edit locally.
832 - local_edit_as_pulled(&a, an, "r", "edit", 100, 1);
833 - // B deletes the same row, newer.
834 - b.execute("INSERT INTO note (id, name) VALUES ('r', 'x')", [])
835 - .unwrap();
836 - stamp_pending(&b, bn, 50).unwrap();
837 - b.execute("DELETE FROM note WHERE id = 'r'", []).unwrap();
838 - stamp_pending(&b, bn, 200).unwrap();
839 - let del = load_local_pending(&b, bn)
840 - .unwrap()
841 - .into_iter()
842 - .find(|e| e.op == ChangeOp::Delete)
843 - .unwrap();
844 - let pulled = PulledChange {
845 - storage_version: None,
846 - entry: del,
847 - device_id: bn,
848 - seq: 2,
849 - };
850 - pull_apply(&mut a, &schema(), an, vec![pulled]);
851 - assert_eq!(
852 - note_name(&a, "r"),
853 - None,
854 - "newer delete wins over older edit"
855 - );
856 - }
857 -
858 - #[test]
859 - fn server_order_applies_last_delivered_no_hlc() {
860 - let (mut a, an) = device(1);
861 - let s = server_order_schema();
862 - // Two pulled changes for the same row; server order = last wins, HLC ignored.
863 - let (src, sn) = device(2);
864 - let first = local_edit_as_pulled(&src, sn, "r", "first", 999, 1); // higher wall
865 - let (src2, sn2) = device(3);
866 - let second = local_edit_as_pulled(&src2, sn2, "r", "second", 1, 2); // lower wall, later seq
867 - let resolved = resolve_pull(&a, &s, an, vec![first, second], Utc::now(), "").unwrap();
868 - apply_remote_changes(&mut a, &s, &resolved, "").unwrap();
869 - assert_eq!(
870 - note_name(&a, "r").as_deref(),
871 - Some("second"),
872 - "server order: last delivered wins"
873 - );
874 - }
875 -
876 - /// The two guarantees `ResolvedChanges` documents, asserted on the same
877 - /// input so the difference between them is the only variable. Under the HLC
878 - /// strategy a batch carrying two changes for one row resolves to one entry;
879 - /// under `ServerOrder` it deliberately stays two, because last-delivered-wins
880 - /// is what an app choosing that strategy asked for.
881 - #[test]
882 - fn resolve_pull_collapses_a_row_under_hlc_and_does_not_under_server_order() {
883 - let (src, sn) = device(2);
884 - let first = local_edit_as_pulled(&src, sn, "r", "first", 100, 1);
885 - let second = local_edit_as_pulled(&src, sn, "r", "second", 200, 2);
886 -
887 - let (hlc_device, hn) = device(1);
888 - let under_hlc = resolve_pull(
889 - &hlc_device,
890 - &schema(),
891 - hn,
892 - vec![first.clone(), second.clone()],
893 - Utc::now(),
894 - "",
895 - )
896 - .unwrap();
897 - assert_eq!(
898 - under_hlc.len(),
899 - 1,
900 - "the HLC strategy promises one entry per row; the apply order would \
901 - otherwise decide the value"
902 - );
903 -
904 - let (server_device, svn) = device(3);
905 - let under_server_order = resolve_pull(
906 - &server_device,
907 - &server_order_schema(),
908 - svn,
909 - vec![first, second],
910 - Utc::now(),
911 - "",
912 - )
913 - .unwrap();
914 - assert_eq!(
915 - under_server_order.len(),
916 - 2,
917 - "ServerOrder must not collapse: last delivered wins is the strategy"
918 - );
919 - }
920 -
921 - /// A model of the pull pipeline.
922 - ///
923 - /// Aimed at [`resolve_pull`] with a real `Connection`, deliberately, and not
924 - /// at the pure conflict layer one step down. The one-entry-per-row invariant
925 - /// only exists after the collapse, so `resolve_pull` is the lowest layer
926 - /// where a max-HLC-wins specification is an honest thing to assert.
927 - /// `CleanChanges::gated_at`, one layer down, promises only committed-clock
928 - /// filtering, so three of these four properties do not hold there even
929 - /// against correct code. See wiki `testing-posture`, Phase 3.
930 - ///
931 - /// The four properties are the ones a sync engine lives or dies on, and each
932 - /// is a different way for two devices to end up holding different bytes.
933 - mod model {
934 - use super::*;
935 - use proptest::prelude::*;
936 -
937 - /// A fixed instant, so the poisoning guard is deterministic. Generated
938 - /// walls sit near it and well inside `MAX_HLC_DRIFT_MS`; poisoning is
939 - /// covered by its own example test, and letting it fire here would mean
940 - /// the properties were quietly asserting over an empty batch.
941 - const BASE_MS: i64 = 1_700_000_000_000;
942 -
943 - fn now() -> DateTime<Utc> {
944 - DateTime::from_timestamp_millis(BASE_MS).unwrap()
945 - }
946 -
947 - /// Three rows, three devices, three wall readings, and every range here
948 - /// is narrow on purpose.
949 - ///
950 - /// The interesting case is two changes for one row at an *exact* HLC
951 - /// tie with differing payloads, because that is the only case the
952 - /// payload tiebreak in `change_order` serves. A wider generator makes it
953 - /// unreachable: a first attempt drew walls from a 100ms window and
954 - /// produced roughly one tie across a whole 256-case run, few enough that
955 - /// deleting the tiebreak outright left every property passing. Ties have
956 - /// to be common for these properties to observe anything, so the clock
957 - /// is generated with almost no entropy in it and the payload carries the
958 - /// variation instead.
959 - fn batch() -> impl Strategy<Value = Vec<PulledChange>> {
960 - let one = (0u8..3, 0u8..3, 0i64..3, 0u32..2, 0u8..4).prop_map(
961 - |(row, dev, wall_off, counter, payload)| {
962 - let node = node(u128::from(dev));
963 - PulledChange {
964 - storage_version: None,
965 - entry: ChangeEntry {
966 - table: "note".into(),
967 - op: ChangeOp::Update,
968 - row_id: format!("r{row}"),
969 - timestamp: now(),
970 - hlc: Hlc {
971 - wall_ms: BASE_MS + wall_off,
972 - counter,
973 - node,
974 - },
975 - data: Some(serde_json::json!({
976 - "id": format!("r{row}"),
977 - "name": format!("v{payload}"),
978 - })),
979 - extra: serde_json::Map::default(),
980 - },
981 - device_id: node,
982 - seq: 0,
983 - }
984 - },
985 - );
986 - proptest::collection::vec(one, 0..6)
987 - }
988 -
989 - /// The whole observable state of a device: what each row holds, and what
990 - /// the committed ledger says about it. Both matter. A pipeline that
991 - /// wrote the right value but recorded the wrong committed clock would
992 - /// gate its own next pull incorrectly, and comparing only the rows would
993 - /// not see it.
994 - fn state(conn: &Connection) -> Vec<(String, Option<String>, Option<Hlc>)> {
995 - ["r0", "r1", "r2"]
996 - .iter()
997 - .map(|r| {
998 - (
999 - (*r).to_string(),
1000 - note_name(conn, r),
1001 - committed_hlc(conn, "note", r).unwrap(),
1002 - )
1003 - })
1004 - .collect()
1005 - }
1006 -
1007 - /// Run a batch through the real pipeline on a fresh device.
1008 - fn pull(conn: &mut Connection, node: DeviceId, pulled: Vec<PulledChange>) {
1009 - let s = schema();
1010 - let resolved = resolve_pull(conn, &s, node, pulled, now(), "").unwrap();
1011 - apply_remote_changes(conn, &s, &resolved, "").unwrap();
1012 - record_committed(conn, resolved.as_slice()).unwrap();
1013 - }
1014 -
1015 - /// The specification the pipeline is supposed to implement: per row, the
1016 - /// highest wall clock wins, then the highest counter, then the highest
1017 - /// device, then the highest payload bytes.
1018 - ///
1019 - /// Spelled out rather than delegated to `change_order`, and that is the
1020 - /// whole point of it. An oracle that called `change_order` would agree
1021 - /// with a broken `change_order`, which is the imitation-oracle failure
1022 - /// from wiki `testing-posture` wearing a different hat: it was the first
1023 - /// version of this function, and deleting the payload tiebreak left all
1024 - /// four properties passing. This version fails when the rule changes,
1025 - /// because it is a second statement of the rule rather than a reference
1026 - /// to the first.
1027 - fn expected_winner(pulled: &[PulledChange], row: &str) -> Option<String> {
1028 - fn rank(e: &ChangeEntry) -> (i64, u32, Uuid, Vec<u8>) {
1029 - (
1030 - e.hlc.wall_ms,
1031 - e.hlc.counter,
1032 - e.hlc.node.as_uuid(),
1033 - serde_json::to_vec(e.data.as_ref().unwrap()).unwrap(),
1034 - )
1035 - }
1036 - pulled
1037 - .iter()
1038 - .map(|p| &p.entry)
1039 - .filter(|e| e.row_id == row)
1040 - .max_by_key(|e| rank(e))
1041 - .map(|e| e.data.as_ref().unwrap()["name"].as_str().unwrap().into())
1042 - }
1043 -
1044 - proptest! {
1045 - /// **The pipeline implements max-HLC-wins.** The value each row ends
1046 - /// up holding is the one from the change that wins under
1047 - /// `change_order`, whatever else the batch contained.
1048 - #[test]
1049 - fn final_value_is_the_winner_under_change_order(pulled in batch()) {
1050 - let (mut conn, n) = device(1);
1051 - pull(&mut conn, n, pulled.clone());
1052 - for row in ["r0", "r1", "r2"] {
1053 - prop_assert_eq!(
1054 - note_name(&conn, row),
1055 - expected_winner(&pulled, row),
1056 - "row {} does not hold the winner",
1057 - row
1058 - );
1059 - }
1060 - }
1061 -
1062 - /// **Batch order does not change the final state.** The server may
1063 - /// deliver a batch in any order; two devices that see the same
1064 - /// changes in different orders must agree afterwards. This is the
1065 - /// property the whole change-ordering unification was for.
Lines truncated
@@ -893,930 +893,4 @@
893 893 }
894 894
895 895 #[cfg(test)]
896 - mod tests {
897 - use super::super::db::get_sync_state_or;
898 - use super::super::schema::SyncTable;
899 - use super::*;
900 - use std::sync::{Arc, Mutex};
901 -
902 - /// A shared in-memory "server": an append-only personal log of (origin
903 - /// device, entry), plus a group log of (group, origin device, entry).
904 - #[derive(Clone, Default)]
905 - struct FakeServer {
906 - log: Arc<Mutex<Vec<(DeviceId, ChangeEntry)>>>,
907 - group_log: Arc<Mutex<Vec<(GroupId, DeviceId, ChangeEntry)>>>,
908 - /// The storage version a pulled page appears to have been sealed under.
909 - /// Stands in for the `__sksv` the real transport reads out of the
910 - /// envelope; the fake log holds decrypted entries, so there is no
911 - /// envelope here to carry it.
912 - peer_version: Arc<Mutex<Option<u32>>>,
913 - }
914 -
915 - impl SyncTransport for FakeServer {
916 - fn group_scope_push(
917 - &self,
918 - group_id: GroupId,
919 - _gck_version: i32,
920 - device_id: DeviceId,
921 - changes: Vec<ChangeEntry>,
922 - ) -> impl Future<Output = Result<i64>> + Send {
923 - let group_log = self.group_log.clone();
924 - async move {
925 - let mut l = group_log.lock().unwrap();
926 - for c in changes {
927 - l.push((group_id, device_id, c));
928 - }
929 - Ok(l.len() as i64)
930 - }
931 - }
932 -
933 - async fn register_device(&self, _name: &str, _platform: &str) -> Result<Device> {
934 - Ok(Device {
935 - id: DeviceId::new(uuid::Uuid::from_u128(0xDE)),
936 - app_id: crate::ids::AppId::nil(),
937 - user_id: crate::ids::UserId::nil(),
938 - device_name: "fake".into(),
939 - platform: "test".into(),
940 - last_seen_at: Utc::now(),
941 - created_at: Utc::now(),
942 - })
943 - }
944 -
945 - fn push(
946 - &self,
947 - device_id: DeviceId,
948 - changes: Vec<ChangeEntry>,
949 - ) -> impl Future<Output = Result<i64>> + Send {
950 - let log = self.log.clone();
951 - async move {
952 - let mut l = log.lock().unwrap();
953 - for c in changes {
954 - l.push((device_id, c));
955 - }
956 - Ok(l.len() as i64)
957 - }
958 - }
959 -
960 - fn pull_rich(
961 - &self,
962 - _device_id: DeviceId,
963 - cursor: i64,
964 - ) -> impl Future<Output = Result<(Vec<PulledChange>, i64, bool)>> + Send {
965 - let log = self.log.clone();
966 - let peer_version = *self.peer_version.lock().unwrap();
967 - async move {
968 - let l = log.lock().unwrap();
969 - let out: Vec<PulledChange> = l
970 - .iter()
971 - .enumerate()
972 - .filter(|(i, _)| (*i as i64 + 1) > cursor)
973 - .map(|(i, (dev, entry))| PulledChange {
974 - storage_version: peer_version,
975 - entry: entry.clone(),
976 - device_id: *dev,
977 - seq: i as i64 + 1,
978 - })
979 - .collect();
980 - Ok((out, l.len() as i64, false))
981 - }
982 - }
983 - }
984 -
985 - fn schema() -> SyncSchema {
986 - SyncSchema::new(vec![SyncTable::full("note", &["id", "name"])])
987 - }
988 -
989 - /// A schema with a group-scoped table: `task` carries a local `group_id`
990 - /// provenance column (not a synced column), declared via `group_scoped`.
991 - fn group_schema() -> SyncSchema {
992 - SyncSchema::new(vec![
993 - SyncTable::full("task", &["id", "name"]).group_scoped("group_id"),
994 - ])
995 - }
996 -
997 - fn group_device(path: &std::path::Path, n: u128) -> (DbSource, DeviceId) {
998 - let db = DbSource::path(path);
999 - let conn = db.open().unwrap();
1000 - conn.execute_batch("CREATE TABLE task (id TEXT PRIMARY KEY, name TEXT, group_id TEXT);")
1001 - .unwrap();
1002 - conn.execute_batch(&group_schema().migration_sql()).unwrap();
1003 - (db, DeviceId::new(uuid::Uuid::from_u128(n)))
1004 - }
1005 -
1006 - #[tokio::test]
1007 - async fn push_scope_drains_only_its_own_scope() {
1008 - let dir = tempdir();
1009 - let (db, node) = group_device(&dir.join("g.db"), 7);
1010 - let server = FakeServer::default();
1011 - let gid = GroupId::new(uuid::Uuid::from_u128(0x6971));
1012 -
1013 - // A personal task (group_id NULL) and a group task (group_id = gid).
1014 - {
1015 - let c = db.open().unwrap();
1016 - c.execute(
1017 - "INSERT INTO task (id, name, group_id) VALUES ('p', 'personal', NULL)",
1018 - [],
1019 - )
1020 - .unwrap();
1021 - c.execute(
1022 - "INSERT INTO task (id, name, group_id) VALUES ('g', 'grouped', ?1)",
1023 - [gid.to_string()],
1024 - )
1025 - .unwrap();
1026 - }
1027 -
1028 - // Personal push drains only the personal row.
1029 - let pushed = push_scope(&db, &server, &group_schema(), node, SyncScope::Personal)
1030 - .await
1031 - .unwrap();
1032 - assert_eq!(pushed, 1);
1033 - assert_eq!(server.log.lock().unwrap().len(), 1);
1034 - assert_eq!(server.log.lock().unwrap()[0].1.row_id, "p");
1035 - assert!(server.group_log.lock().unwrap().is_empty());
1036 -
1037 - // Group push drains only the group row, to that group.
1038 - let pushed = push_scope(
1039 - &db,
1040 - &server,
1041 - &group_schema(),
1042 - node,
1043 - SyncScope::Group {
1044 - id: gid,
1045 - gck_version: 1,
1046 - },
1047 - )
1048 - .await
1049 - .unwrap();
1050 - assert_eq!(pushed, 1);
1051 - let gl = server.group_log.lock().unwrap();
1052 - assert_eq!(gl.len(), 1);
1053 - assert_eq!(gl[0].0, gid);
1054 - assert_eq!(gl[0].2.row_id, "g");
1055 - // The personal log did not grow.
1056 - assert_eq!(server.log.lock().unwrap().len(), 1);
1057 - }
1058 -
1059 - fn device(path: &std::path::Path, n: u128) -> (DbSource, DeviceId) {
1060 - let db = DbSource::path(path);
1061 - let conn = db.open().unwrap();
1062 - conn.execute_batch("CREATE TABLE note (id TEXT PRIMARY KEY, name TEXT);")
1063 - .unwrap();
1064 - conn.execute_batch(&schema().migration_sql()).unwrap();
1065 - (db, DeviceId::new(uuid::Uuid::from_u128(n)))
1066 - }
1067 -
1068 - /// The gate's end-to-end case: a peer on a different manifest is refused
1069 - /// before anything is applied, and the cursor does not move, so the page is
1070 - /// still there once both sides agree.
1071 - #[tokio::test]
1072 - async fn a_peer_on_another_storage_version_is_refused_and_nothing_lands() {
1073 - let dir = tempdir();
1074 - let (writer, writer_node) = device(&dir.join("a.db"), 1);
1075 - let (reader, reader_node) = device(&dir.join("b.db"), 2);
1076 - let server = FakeServer::default();
1077 - let gated = schema().storage_version(4);
1078 -
1079 - edit(&writer, "n1", "from the newer device");
1080 - push_scope(&writer, &server, &gated, writer_node, SyncScope::Personal)
1081 - .await
1082 - .unwrap();
1083 - // The peer is a manifest ahead.
1084 - *server.peer_version.lock().unwrap() = Some(5);
1085 -
1086 - let err = pull_scope(&reader, &server, &gated, reader_node, SyncScope::Personal)
1087 - .await
1088 - .unwrap_err();
1089 - let r = match err {
1090 - SyncKitError::StorageVersion(r) => r,
1091 - other => panic!("expected a storage-version refusal, got {other:?}"),
1092 - };
1093 - assert_eq!((r.mine, r.theirs), (4, 5));
1094 - assert_eq!(r.message(), "Update this device.");
1095 -
1096 - // No partial write, no dropped records, and the cursor is where it was.
1097 - let conn = reader.open().unwrap();
1098 - let rows: i64 = conn
1099 - .query_row("SELECT COUNT(*) FROM note", [], |r| r.get(0))
1100 - .unwrap();
1101 - assert_eq!(rows, 0, "nothing was applied");
1102 - assert_eq!(
1103 - get_scope_cursor(&conn, "").unwrap(),
1104 - 0,
1105 - "the cursor did not advance past a page that was never applied"
1106 - );
1107 -
1108 - // Once the reader catches up, the same page applies.
1109 - let matched = schema().storage_version(5);
1110 - pull_scope(&reader, &server, &matched, reader_node, SyncScope::Personal)
1111 - .await
1112 - .unwrap();
1113 - let name: String = conn
1114 - .query_row("SELECT name FROM note WHERE id = 'n1'", [], |r| r.get(0))
1115 - .unwrap();
1116 - assert_eq!(name, "from the newer device");
1117 - }
1118 -
1119 - /// An app that has not adopted the gate must be entirely unaffected.
1120 - #[tokio::test]
1121 - async fn an_undeclared_manifest_pulls_a_stamped_page_as_before() {
1122 - let dir = tempdir();
1123 - let (writer, writer_node) = device(&dir.join("a.db"), 1);
1124 - let (reader, reader_node) = device(&dir.join("b.db"), 2);
1125 - let server = FakeServer::default();
1126 -
1127 - edit(&writer, "n1", "hello");
1128 - push_scope(
1129 - &writer,
1130 - &server,
1131 - &schema(),
1132 - writer_node,
1133 - SyncScope::Personal,
1134 - )
1135 - .await
1136 - .unwrap();
1137 - *server.peer_version.lock().unwrap() = Some(9);
1138 -
1139 - pull_scope(
1140 - &reader,
1141 - &server,
1142 - &schema(),
1143 - reader_node,
1144 - SyncScope::Personal,
1145 - )
1146 - .await
1147 - .unwrap();
1148 - let conn = reader.open().unwrap();
1149 - let name: String = conn
1150 - .query_row("SELECT name FROM note WHERE id = 'n1'", [], |r| r.get(0))
1151 - .unwrap();
1152 - assert_eq!(name, "hello");
1153 - }
1154 -
1155 - fn edit(db: &DbSource, id: &str, name: &str) {
1156 - let conn = db.open().unwrap();
1157 - conn.execute(
1158 - "INSERT INTO note (id, name) VALUES (?1, ?2) ON CONFLICT(id) DO UPDATE SET name = excluded.name",
1159 - (id, name),
1160 - )
1161 - .unwrap();
1162 - }
1163 -
1164 - /// A schema whose `child` table has a real foreign key to `parent`, so a
1165 - /// child arriving first violates a constraint instead of quietly landing.
1166 - fn fk_schema() -> SyncSchema {
1167 - SyncSchema::new(vec![
1168 - SyncTable::full("parent", &["id", "name"]),
1169 - SyncTable::full("child", &["id", "parent_id"]),
1170 - ])
1171 - }
1172 -
1173 - fn fk_device(path: &std::path::Path, n: u128) -> (DbSource, DeviceId) {
1174 - let db = DbSource::path(path);
1175 - let conn = db.open().unwrap();
1176 - conn.execute_batch(
1177 - "CREATE TABLE parent (id TEXT PRIMARY KEY, name TEXT);
1178 - CREATE TABLE child (id TEXT PRIMARY KEY, parent_id TEXT NOT NULL REFERENCES parent(id));",
1179 - )
1180 - .unwrap();
1181 - conn.execute_batch(&fk_schema().migration_sql()).unwrap();
1182 - (db, DeviceId::new(uuid::Uuid::from_u128(n)))
1183 - }
1184 -
1185 - /// Put an entry on the fake server as if another device had pushed it.
1186 - fn serve(server: &FakeServer, table: &str, row_id: &str, data: serde_json::Value) {
1187 - server.log.lock().unwrap().push((
1188 - DeviceId::new(uuid::Uuid::from_u128(0xAA)),
1189 - ChangeEntry {
1190 - table: table.into(),
1191 - op: ChangeOp::Insert,
1192 - row_id: row_id.into(),
1193 - timestamp: Utc::now(),
1194 - hlc: crate::types::hlc_legacy_floor(),
1195 - data: Some(data),
1196 - extra: serde_json::Map::default(),
1197 - },
1198 - ));
1199 - }
1200 -
1201 - fn row_count(db: &DbSource, table: &str) -> i64 {
1202 - db.open()
1203 - .unwrap()
1204 - .query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |r| r.get(0))
1205 - .unwrap()
1206 - }
1207 -
1208 - #[tokio::test]
1209 - async fn a_child_that_arrives_before_its_parent_is_held_and_lands_on_the_next_pull() {
1210 - let dir = tempdir();
1211 - let (db, node) = fk_device(&dir.join("fk.db"), 11);
1212 - let server = FakeServer::default();
1213 -
1214 - // The child arrives alone. Its parent does not exist yet, so it cannot be
1215 - // written; before the hold existed this row was gone for good, because the
1216 - // cursor moved past it and the server never sends an entry twice.
1217 - serve(
1218 - &server,
1219 - "child",
1220 - "c1",
1221 - serde_json::json!({"id":"c1","parent_id":"p1"}),
1222 - );
1223 - let out = pull_scope(&db, &server, &fk_schema(), node, SyncScope::Personal)
1224 - .await
1225 - .unwrap();
1226 -
1227 - assert_eq!(out.applied, 0);
1228 - assert_eq!(out.deferred, 1);
1229 - assert_eq!(row_count(&db, "child"), 0);
1230 - {
1231 - let conn = db.open().unwrap();
1232 - assert_eq!(
1233 - get_scope_cursor(&conn, "").unwrap(),
1234 - 1,
1235 - "the cursor still advances; the hold is what makes that safe"
1236 - );
1237 - assert_eq!(deferred::counts(&conn, "").unwrap().deferred, 1);
1238 - }
1239 -
1240 - // The parent lands on the next pull, and the held child rides in with it.
1241 - serve(
1242 - &server,
1243 - "parent",
1244 - "p1",
1245 - serde_json::json!({"id":"p1","name":"p"}),
1246 - );
1247 - let out = pull_scope(&db, &server, &fk_schema(), node, SyncScope::Personal)
1248 - .await
1249 - .unwrap();
1250 -
1251 - assert_eq!(out.applied, 2, "the new parent plus the retried child");
1252 - assert_eq!(out.deferred, 0);
1253 - assert_eq!(row_count(&db, "child"), 1);
1254 - assert_eq!(
1255 - deferred::counts(&db.open().unwrap(), "").unwrap().total(),
1256 - 0,
1257 - "a held entry that lands is cleared"
1258 - );
1259 - }
1260 -
1261 - #[tokio::test]
1262 - async fn a_parent_that_never_arrives_stops_being_retried_at_the_cap() {
1263 - let dir = tempdir();
1264 - let (db, node) = fk_device(&dir.join("fk_cap.db"), 12);
1265 - let server = FakeServer::default();
1266 -
1267 - serve(
1268 - &server,
1269 - "child",
1270 - "c1",
1271 - serde_json::json!({"id":"c1","parent_id":"nope"}),
1272 - );
1273 -
1274 - // Each pull spends one attempt. The first holds it; MAX_ATTEMPTS more
1275 - // exhaust it. An empty page still runs the retry, which is the point.
1276 - for _ in 0..=deferred::MAX_ATTEMPTS {
1277 - pull_scope(&db, &server, &fk_schema(), node, SyncScope::Personal)
1278 - .await
1279 - .unwrap();
1280 - }
1281 -
1282 - let conn = db.open().unwrap();
1283 - let counts = deferred::counts(&conn, "").unwrap();
1284 - assert_eq!(counts.deferred, 0, "no longer retried");
1285 - assert_eq!(counts.rejected, 1, "but still visible, not discarded");
1286 - let listed = deferred::list(&conn, "").unwrap();
1287 - assert_eq!(listed[0].row_id, "c1");
1288 - assert_eq!(listed[0].attempts, deferred::MAX_ATTEMPTS);
1289 - }
1290 -
1291 - #[tokio::test]
1292 - async fn a_deferred_entry_is_not_recorded_as_committed() {
1293 - let dir = tempdir();
1294 - let (db, node) = fk_device(&dir.join("fk_gate.db"), 13);
1295 - let server = FakeServer::default();
1296 -
1297 - serve(
1298 - &server,
1299 - "child",
1300 - "c1",
1301 - serde_json::json!({"id":"c1","parent_id":"p1"}),
1302 - );
1303 - pull_scope(&db, &server, &fk_schema(), node, SyncScope::Personal)
1304 - .await
1305 - .unwrap();
1306 -
1307 - // Recording an unapplied row's HLC would gate its own retry out on the
1308 - // next pull, since the gate drops anything not newer than what is
1309 - // committed, and the hold would be a queue that never drains.
1310 - assert!(
1311 - super::super::hlc::committed_hlc(&db.open().unwrap(), "child", "c1")
1312 - .unwrap()
1313 - .is_none()
1314 - );
1315 - }
1316 -
1317 - fn stamp_at(db: &DbSource, node: DeviceId, now_ms: i64) {
1318 - stamp_pending(&db.open().unwrap(), node, now_ms).unwrap();
1319 - }
1320 -
1321 - fn note_name(db: &DbSource, id: &str) -> Option<String> {
1322 - db.open()
1323 - .unwrap()
1324 - .query_row("SELECT name FROM note WHERE id = ?1", [id], |r| r.get(0))
1325 - .ok()
1326 - }
1327 -
1328 - #[tokio::test]
1329 - async fn two_device_push_pull_converges_to_higher_hlc() {
1330 - let dir = tempdir();
1331 - let (da, na) = device(&dir.join("a.db"), 1);
1332 - let (db_, nb) = device(&dir.join("b.db"), 2);
1333 - let server = FakeServer::default();
1334 -
1335 - // A edits first (t=100), B edits the same row later (t=200) → B wins.
1336 - edit(&da, "r", "from-A");
1337 - stamp_at(&da, na, 100);
1338 - edit(&db_, "r", "from-B");
1339 - stamp_at(&db_, nb, 200);
1340 -
1341 - push_changes(&da, &server, &schema(), na).await.unwrap();
1342 - push_changes(&db_, &server, &schema(), nb).await.unwrap();
1343 -
1344 - pull_changes(&da, &server, &schema(), na).await.unwrap();
1345 - pull_changes(&db_, &server, &schema(), nb).await.unwrap();
1346 -
1347 - assert_eq!(note_name(&da, "r").as_deref(), Some("from-B"));
1348 - assert_eq!(note_name(&db_, "r").as_deref(), Some("from-B"));
1349 - }
1350 -
1351 - #[tokio::test]
1352 - async fn push_marks_rows_and_advances_cursor() {
1353 - let dir = tempdir();
1354 - let (da, na) = device(&dir.join("a.db"), 1);
1355 - let server = FakeServer::default();
1356 - edit(&da, "r1", "x");
1357 - edit(&da, "r2", "y");
1358 -
1359 - let pushed = push_changes(&da, &server, &schema(), na).await.unwrap();
1360 - assert_eq!(pushed, 2);
1361 - // All local rows are marked pushed.
1362 - let unpushed: i64 = da
1363 - .open()
1364 - .unwrap()
1365 - .query_row(
1366 - "SELECT COUNT(*) FROM sync_changelog WHERE pushed = 0",
1367 - [],
1368 - |r| r.get(0),
1369 - )
1370 - .unwrap();
1371 - assert_eq!(unpushed, 0);
1372 -
1373 - // A fresh device pulls both and advances its cursor to 2.
1374 - let (db_, nb) = device(&dir.join("b.db"), 2);
1375 - let out = pull_changes(&db_, &server, &schema(), nb).await.unwrap();
1376 - assert_eq!(out.applied, 2);
1377 - assert_eq!(note_name(&db_, "r1").as_deref(), Some("x"));
1378 - // Personal pull advances the personal ('') scope cursor.
1379 - let cursor = get_scope_cursor(&db_.open().unwrap(), "").unwrap();
1380 - assert_eq!(cursor, 2);
1381 - }
1382 -
1383 - /// The push half of the field-merge base.
1384 - ///
1385 - /// A pull is the obvious moment a row becomes common ground and it is only
1386 - /// half of them: once the server takes an edit, that edit is what a peer will
1387 - /// pull, so it is the version the two devices next diverge from. Re-basing
1388 - /// only on pull would leave the base stuck at whatever this device last
1389 - /// *received*, and every merge afterwards would report this device's own
1390 - /// already-shared edits as changes, handing itself fields it never contested.
1391 - #[tokio::test]
1392 - async fn an_acknowledged_push_rebases_the_row() {
Lines truncated
@@ -1,0 +1,1414 @@
1 + //! Tests for [`super`].
2 +
3 + use super::*;
4 + use crate::ids::DeviceId;
5 + use crate::types::ChangeOp;
6 + use base64::Engine;
7 + use chrono::Utc;
8 + use std::time::Duration;
9 + use uuid::Uuid;
10 +
11 + use super::super::TOKEN_EXPIRY_BUFFER_SECS;
12 +
13 + fn test_config() -> super::super::SyncKitConfig {
14 + super::super::SyncKitConfig {
15 + server_url: "https://example.com".to_string(),
16 + api_key: "test-api-key-123".to_string(),
17 + }
18 + }
19 +
20 + /// Build a fake JWT with the given `exp` claim (no real signature).
21 + fn fake_jwt(exp: i64) -> String {
22 + let header =
23 + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(r#"{"alg":"HS256","typ":"JWT"}"#);
24 + let payload_json = serde_json::json!({
25 + "sub": "550e8400-e29b-41d4-a716-446655440000",
26 + "app": "6ba7b810-9dad-11d1-80b4-00c04fd430c8",
27 + "exp": exp,
28 + "iat": exp - 3600,
29 + });
30 + let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD
31 + .encode(payload_json.to_string().as_bytes());
32 + let signature = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(b"fake-signature");
33 + format!("{header}.{payload}.{signature}")
34 + }
35 +
36 + // ── wire-version envelope dispatch ──
37 +
38 + #[test]
39 + fn split_envelope_dispatches_on_explicit_version() {
40 + let node = DeviceId::new(Uuid::from_u128(1));
41 + let hlc = Hlc {
42 + wall_ms: 5,
43 + counter: 2,
44 + node,
45 + };
46 +
47 + // v2 envelope: explicit __skver, parsed by version.
48 + let v2 = serde_json::json!({ "__skver": 2, "__skhlc": hlc, "data": {"k": "v"} });
49 + let (got, data, _) = SyncKitClient::split_hlc_envelope(v2, node, 0).unwrap();
50 + assert_eq!(got, hlc);
51 + assert_eq!(data, Some(serde_json::json!({"k": "v"})));
52 +
53 + // gen-1 envelope: __skhlc present, no version tag.
54 + let gen1 = serde_json::json!({ "__skhlc": hlc, "data": null });
55 + let (got, data, _) = SyncKitClient::split_hlc_envelope(gen1, node, 0).unwrap();
56 + assert_eq!(got, hlc);
57 + assert_eq!(data, None);
58 +
59 + // Bare legacy row: HLC synthesized from node + timestamp.
60 + let bare = serde_json::json!({ "title": "buy milk" });
61 + let (got, data, _) = SyncKitClient::split_hlc_envelope(bare.clone(), node, 1234).unwrap();
62 + assert_eq!(got, Hlc::from_legacy(1234, node));
63 + assert_eq!(data, Some(bare));
64 + }
65 +
66 + #[test]
67 + fn the_storage_stamp_rides_inside_the_sealed_envelope_and_survives_a_round_trip() {
68 + let node = DeviceId::new(Uuid::from_u128(1));
69 + let hlc = Hlc {
70 + wall_ms: 5,
71 + counter: 2,
72 + node,
73 + };
74 +
75 + let stamped = SyncKitClient::hlc_envelope(&hlc, Some(&serde_json::json!({"k": "v"})), Some(4));
76 + assert_eq!(stamped["__sksv"], serde_json::json!(4));
77 + let (got, data, version) = SyncKitClient::split_hlc_envelope(stamped, node, 0).unwrap();
78 + assert_eq!(got, hlc);
79 + assert_eq!(data, Some(serde_json::json!({"k": "v"})));
80 + assert_eq!(version, Some(4));
81 +
82 + // A Delete carries no row payload and still carries the stamp, which is
83 + // what lets the gate see a peer whose only pending change is a delete.
84 + let delete = SyncKitClient::hlc_envelope(&hlc, None, Some(4));
85 + let (_, data, version) = SyncKitClient::split_hlc_envelope(delete, node, 0).unwrap();
86 + assert_eq!(data, None);
87 + assert_eq!(version, Some(4));
88 + }
89 +
90 + #[test]
91 + fn an_undeclared_version_leaves_the_sealed_bytes_exactly_as_they_were() {
92 + let node = DeviceId::new(Uuid::from_u128(1));
93 + let hlc = Hlc {
94 + wall_ms: 5,
95 + counter: 2,
96 + node,
97 + };
98 + let plain = SyncKitClient::hlc_envelope(&hlc, None, None);
99 + assert!(plain.get("__sksv").is_none());
100 + assert_eq!(
101 + plain,
102 + serde_json::json!({ "__skver": 2, "__skhlc": hlc, "data": null }),
103 + "an app that declares no version must push byte-identical envelopes"
104 + );
105 + }
106 +
107 + /// The stamp is added *within* v2 rather than as a new `__skver`, so a reader
108 + /// that predates it addresses `__skhlc` and `data` by name and is unaffected.
109 + #[test]
110 + fn a_stamped_envelope_still_reads_as_an_ordinary_v2_envelope() {
111 + let node = DeviceId::new(Uuid::from_u128(1));
112 + let hlc = Hlc {
113 + wall_ms: 9,
114 + counter: 1,
115 + node,
116 + };
117 + let stamped = SyncKitClient::hlc_envelope(&hlc, Some(&serde_json::json!(7)), Some(12));
118 + assert_eq!(stamped["__skver"], serde_json::json!(2));
119 + assert_eq!(
120 + serde_json::from_value::<Hlc>(stamped["__skhlc"].clone()).unwrap(),
121 + hlc
122 + );
123 + assert_eq!(stamped["data"], serde_json::json!(7));
124 + }
125 +
126 + /// Optional by construction: a malformed stamp must not fail a change that is
127 + /// otherwise fine.
128 + #[test]
129 + fn a_malformed_stamp_reads_as_no_stamp() {
130 + let node = DeviceId::new(Uuid::from_u128(1));
131 + let hlc = Hlc {
132 + wall_ms: 1,
133 + counter: 0,
134 + node,
135 + };
136 + for bad in [
137 + serde_json::json!("four"),
138 + serde_json::json!(-1),
139 + serde_json::json!(null),
140 + serde_json::json!(u64::from(u32::MAX) + 1),
141 + ] {
142 + let env = serde_json::json!({
143 + "__skver": 2, "__skhlc": hlc, "data": null, "__sksv": bad
144 + });
145 + let (_, _, version) = SyncKitClient::split_hlc_envelope(env, node, 0).unwrap();
146 + assert_eq!(version, None, "bad stamp {bad} should read as absent");
147 + }
148 + }
149 +
150 + #[test]
151 + fn split_envelope_rejects_unknown_version_loudly() {
152 + // The X2 hazard: a future envelope version must error, not silently
153 + // fall back to a bare-row read (which would corrupt the clock).
154 + let node = DeviceId::new(Uuid::from_u128(1));
155 + let hlc = Hlc {
156 + wall_ms: 5,
157 + counter: 0,
158 + node,
159 + };
160 + let future = serde_json::json!({ "__skver": 3, "__skhlc": hlc, "data": null });
161 + let err = SyncKitClient::split_hlc_envelope(future, node, 0).unwrap_err();
162 + assert!(
163 + matches!(err, SyncKitError::Crypto(ref m) if m.contains("envelope version 3")),
164 + "unexpected error: {err:?}"
165 + );
166 + }
167 +
168 + // ── encrypt_change / decrypt_change ──
169 +
170 + #[test]
171 + fn delete_seals_hlc_envelope_and_roundtrips() {
172 + // A Delete carries no row payload, but its HLC must still travel, so it is
173 + // now encrypted into an envelope (data = Some), and decrypt restores the
174 + // op, a None payload, and the exact HLC.
175 + let client = SyncKitClient::new(test_config());
176 + let key = crypto::generate_master_key();
177 + *client.master_key.write() = Some(crypto::ZeroizeOnDrop(key));
178 +
179 + let device = DeviceId::new(Uuid::new_v4());
180 + let hlc = Hlc {
181 + wall_ms: 12_345,
182 + counter: 7,
183 + node: device,
184 + };
185 + let entry = ChangeEntry {
186 + table: "tasks".to_string(),
187 + op: ChangeOp::Delete,
188 + row_id: "row-1".to_string(),
189 + timestamp: Utc::now(),
190 + hlc,
191 + data: None,
192 + extra: serde_json::Map::default(),
193 + };
194 +
195 + let wire = client.encrypt_change(entry).unwrap();
196 + assert_eq!(wire.op, ChangeOp::Delete);
197 + assert!(
198 + wire.data.is_some(),
199 + "delete now seals an encrypted HLC envelope"
200 + );
201 +
202 + let pull_entry = PullChangeEntry {
203 + seq: 1,
204 + device_id: device,
205 + table: wire.table,
206 + op: wire.op,
207 + row_id: wire.row_id,
208 + timestamp: wire.timestamp,
209 + data: wire.data,
210 + key_id: None,
211 + gck_version: None,
212 + };
213 + let decrypted = client.decrypt_change(pull_entry).unwrap();
214 + assert_eq!(decrypted.op, ChangeOp::Delete);
215 + assert_eq!(decrypted.row_id, "row-1");
216 + assert!(
217 + decrypted.data.is_none(),
218 + "payload is still None after the envelope unwraps"
219 + );
220 + assert_eq!(decrypted.hlc, hlc, "HLC survives the round trip");
221 + }
222 +
223 + #[test]
224 + fn encrypt_change_fails_without_master_key() {
225 + let client = SyncKitClient::new(test_config());
226 + let entry = ChangeEntry {
227 + table: "tasks".to_string(),
228 + op: ChangeOp::Insert,
229 + row_id: "row-1".to_string(),
230 + timestamp: Utc::now(),
231 + hlc: Hlc::zero(DeviceId::nil()),
232 + data: Some(serde_json::json!({"title": "test"})),
233 + extra: serde_json::Map::default(),
234 + };
235 +
236 + let err = client.encrypt_change(entry).unwrap_err();
237 + assert!(matches!(err, SyncKitError::NoMasterKey));
238 + }
239 +
240 + #[test]
241 + fn encrypt_change_produces_encrypted_data() {
242 + let client = SyncKitClient::new(test_config());
243 + let key = crypto::generate_master_key();
244 + *client.master_key.write() = Some(crypto::ZeroizeOnDrop(key));
245 +
246 + let original_data = serde_json::json!({"title": "Buy milk", "priority": 3});
247 + let entry = ChangeEntry {
248 + table: "tasks".to_string(),
249 + op: ChangeOp::Insert,
250 + row_id: "row-1".to_string(),
251 + timestamp: Utc::now(),
252 + hlc: Hlc::zero(DeviceId::nil()),
253 + data: Some(original_data.clone()),
254 + extra: serde_json::Map::default(),
255 + };
256 +
257 + let wire = client.encrypt_change(entry).unwrap();
258 + assert!(wire.data.is_some());
259 + let encrypted = wire.data.unwrap();
260 + assert!(encrypted.is_string());
261 + assert_ne!(encrypted, original_data);
262 + }
263 +
264 + #[test]
265 + fn encrypt_decrypt_roundtrip() {
266 + let client = SyncKitClient::new(test_config());
267 + let key = crypto::generate_master_key();
268 + *client.master_key.write() = Some(crypto::ZeroizeOnDrop(key));
269 +
270 + let original_data = serde_json::json!({
271 + "title": "Buy milk",
272 + "tags": ["groceries", "urgent"],
273 + "count": 42
274 + });
275 + let ts = Utc::now();
276 + let entry = ChangeEntry {
277 + table: "tasks".to_string(),
278 + op: ChangeOp::Update,
279 + row_id: "row-abc".to_string(),
280 + timestamp: ts,
281 + hlc: Hlc::zero(DeviceId::nil()),
282 + data: Some(original_data.clone()),
283 + extra: serde_json::Map::default(),
284 + };
285 +
286 + let wire = client.encrypt_change(entry).unwrap();
287 + let pull_entry = PullChangeEntry {
288 + seq: 1,
289 + device_id: crate::ids::DeviceId::new(uuid::Uuid::new_v4()),
290 + table: wire.table,
291 + op: wire.op,
292 + row_id: wire.row_id,
293 + timestamp: wire.timestamp,
294 + data: wire.data,
295 + key_id: None,
296 + gck_version: None,
297 + };
298 +
299 + let decrypted = client.decrypt_change(pull_entry).unwrap();
300 + assert_eq!(decrypted.table, "tasks");
301 + assert_eq!(decrypted.op, ChangeOp::Update);
302 + assert_eq!(decrypted.row_id, "row-abc");
303 + assert_eq!(decrypted.data.unwrap(), original_data);
304 + }
305 +
306 + #[test]
307 + fn decrypt_change_with_no_data() {
308 + let client = SyncKitClient::new(test_config());
309 + let pull_entry = PullChangeEntry {
310 + seq: 5,
311 + device_id: crate::ids::DeviceId::new(uuid::Uuid::new_v4()),
312 + table: "events".to_string(),
313 + op: ChangeOp::Delete,
314 + row_id: "evt-1".to_string(),
315 + timestamp: Utc::now(),
316 + data: None,
317 + key_id: None,
318 + gck_version: None,
319 + };
320 +
321 + let decrypted = client.decrypt_change(pull_entry).unwrap();
322 + assert_eq!(decrypted.table, "events");
323 + assert_eq!(decrypted.op, ChangeOp::Delete);
324 + assert!(decrypted.data.is_none());
325 + }
326 +
327 + #[test]
328 + fn decrypt_change_fails_without_master_key() {
329 + let client = SyncKitClient::new(test_config());
330 + let pull_entry = PullChangeEntry {
331 + seq: 1,
332 + device_id: crate::ids::DeviceId::new(uuid::Uuid::new_v4()),
333 + table: "tasks".to_string(),
334 + op: ChangeOp::Insert,
335 + row_id: "row-1".to_string(),
336 + timestamp: Utc::now(),
337 + data: Some(serde_json::json!("some-encrypted-string")),
338 + key_id: None,
339 + gck_version: None,
340 + };
341 +
342 + let err = client.decrypt_change(pull_entry).unwrap_err();
343 + assert!(matches!(err, SyncKitError::NoMasterKey));
344 + }
345 +
346 + // ── is_transient error classification ──
347 +
348 + #[test]
349 + fn is_transient_server_5xx() {
350 + let err = SyncKitError::Server {
351 + status: 500,
352 + message: "Internal Server Error".to_string(),
353 + retry_after_secs: None,
354 + };
355 + assert!(is_transient(&err));
356 + let err = SyncKitError::Server {
357 + status: 502,
358 + message: "Bad Gateway".to_string(),
359 + retry_after_secs: None,
360 + };
361 + assert!(is_transient(&err));
362 + let err = SyncKitError::Server {
363 + status: 503,
364 + message: "Service Unavailable".to_string(),
365 + retry_after_secs: None,
366 + };
367 + assert!(is_transient(&err));
368 + let err = SyncKitError::Server {
369 + status: 504,
370 + message: "Gateway Timeout".to_string(),
371 + retry_after_secs: None,
372 + };
373 + assert!(is_transient(&err));
374 + }
375 +
376 + #[test]
377 + fn is_transient_rate_limited_429() {
378 + let err = SyncKitError::Server {
379 + status: 429,
380 + message: "Too Many Requests".to_string(),
381 + retry_after_secs: None,
382 + };
383 + assert!(is_transient(&err));
384 + }
385 +
386 + #[test]
387 + fn is_not_transient_client_4xx() {
388 + let err = SyncKitError::Server {
389 + status: 400,
390 + message: "Bad Request".to_string(),
391 + retry_after_secs: None,
392 + };
393 + assert!(!is_transient(&err));
394 + let err = SyncKitError::Server {
395 + status: 401,
396 + message: "Unauthorized".to_string(),
397 + retry_after_secs: None,
398 + };
399 + assert!(!is_transient(&err));
400 + let err = SyncKitError::Server {
401 + status: 403,
402 + message: "Forbidden".to_string(),
403 + retry_after_secs: None,
404 + };
405 + assert!(!is_transient(&err));
406 + let err = SyncKitError::Server {
407 + status: 404,
408 + message: "Not Found".to_string(),
409 + retry_after_secs: None,
410 + };
411 + assert!(!is_transient(&err));
412 + let err = SyncKitError::Server {
413 + status: 409,
414 + message: "Conflict".to_string(),
415 + retry_after_secs: None,
416 + };
417 + assert!(!is_transient(&err));
418 + let err = SyncKitError::Server {
419 + status: 422,
420 + message: "Unprocessable Entity".to_string(),
421 + retry_after_secs: None,
422 + };
423 + assert!(!is_transient(&err));
424 + }
425 +
426 + #[test]
427 + fn is_not_transient_not_authenticated() {
428 + assert!(!is_transient(&SyncKitError::NotAuthenticated));
429 + }
430 +
431 + #[test]
432 + fn is_not_transient_no_master_key() {
433 + assert!(!is_transient(&SyncKitError::NoMasterKey));
434 + }
435 +
436 + #[test]
437 + fn is_not_transient_decryption_failed() {
438 + assert!(!is_transient(&SyncKitError::DecryptionFailed));
439 + }
440 +
441 + #[test]
442 + fn is_not_transient_invalid_envelope() {
443 + assert!(!is_transient(&SyncKitError::InvalidEnvelope(
444 + "bad version".to_string()
445 + )));
446 + }
447 +
448 + #[test]
449 + fn is_not_transient_crypto() {
450 + assert!(!is_transient(&SyncKitError::Crypto(
451 + "encrypt failed".to_string()
452 + )));
453 + }
454 +
455 + #[test]
456 + fn is_not_transient_json() {
457 + let err: SyncKitError = serde_json::from_str::<serde_json::Value>("not json")
458 + .unwrap_err()
459 + .into();
460 + assert!(!is_transient(&err));
461 + }
462 +
463 + #[test]
464 + fn is_not_transient_base64() {
465 + let err: SyncKitError = base64::engine::general_purpose::STANDARD
466 + .decode("!!!invalid!!!")
467 + .unwrap_err()
468 + .into();
469 + assert!(!is_transient(&err));
470 + }
471 +
472 + #[test]
473 + fn is_not_transient_token_expired() {
474 + assert!(!is_transient(&SyncKitError::TokenExpired));
475 + }
476 +
477 + #[test]
478 + fn is_not_transient_internal() {
479 + assert!(!is_transient(&SyncKitError::Internal(
480 + "lock poisoned".to_string()
481 + )));
482 + }
483 +
484 + // ── Retry constants ──
485 +
486 + #[test]
487 + fn retry_constants_are_sensible() {
488 + assert_eq!(MAX_RETRIES, 3);
489 + assert_eq!(BASE_DELAY, Duration::from_secs(1));
490 + }
491 +
492 + #[test]
493 + fn backoff_delays_are_exponential() {
494 + let delay_0 = BASE_DELAY * 2u32.pow(0);
495 + let delay_1 = BASE_DELAY * 2u32.pow(1);
496 + let delay_2 = BASE_DELAY * 2u32.pow(2);
497 +
498 + assert_eq!(delay_0, Duration::from_secs(1));
499 + assert_eq!(delay_1, Duration::from_secs(2));
500 + assert_eq!(delay_2, Duration::from_secs(4));
Lines truncated
@@ -1,0 +1,1544 @@
1 + //! Tests for [`super`].
2 +
3 + use super::*;
4 + use crate::types::{ChangeOp, Hlc};
5 + use serde_json::json;
6 +
7 + /// Properties of the resolver.
8 + ///
9 + /// The contract here is convergence, which is a statement about every pair
10 + /// of changes rather than about the pairs someone wrote down. The 43 tests
11 + /// below are examples; these state the rule. See wiki `testing-posture`,
12 + /// Phase 2.
13 + mod properties {
14 + use super::*;
15 + use proptest::prelude::*;
16 +
17 + /// Small device pool: node is the final tiebreak, so collisions are the
18 + /// interesting case and random UUIDs would never produce them.
19 + fn device_id() -> impl Strategy<Value = DeviceId> {
20 + (0u8..3).prop_map(|n| {
21 + let mut bytes = [0u8; 16];
22 + bytes[15] = n;
23 + DeviceId::new(Uuid::from_bytes(bytes))
24 + })
25 + }
26 +
27 + /// Walls clustered tightly so ties and near-ties are common, plus a
28 + /// far-future band that trips the clock-poisoning guard.
29 + fn any_hlc() -> impl Strategy<Value = Hlc> {
30 + let wall = prop_oneof![
31 + 6 => 1_700_000_000_000i64..1_700_000_000_010,
32 + 2 => 0i64..2_000_000_000_000,
33 + 2 => 4_000_000_000_000i64..8_000_000_000_000,
34 + ];
35 + (wall, 0u32..4, device_id()).prop_map(|(wall_ms, counter, node)| Hlc {
36 + wall_ms,
37 + counter,
38 + node,
39 + })
40 + }
41 +
42 + /// Pairs of clocks, weighted so exact ties are common.
43 + ///
44 + /// Two independent draws almost never collide, and the tie is exactly
45 + /// where convergence is hardest: it is the case the payload tiebreak in
46 + /// `resolve_tie` exists for. Generating the pair rather than two
47 + /// independent clocks is what gives this property teeth, verified by
48 + /// removing that tiebreak and watching the convergence test fail.
49 + fn hlc_pair() -> impl Strategy<Value = (Hlc, Hlc)> {
50 + prop_oneof![
51 + 3 => (any_hlc(), any_hlc()),
52 + 3 => any_hlc().prop_map(|h| (h, h)),
53 + 2 => (any_hlc(), 0u32..4).prop_map(|(h, counter)| (h, Hlc { counter, ..h })),
54 + ]
55 + }
56 +
57 + fn entry_with(hlc: Hlc, payload: u8) -> ChangeEntry {
58 + let mut e = make_entry("tasks", "row-1", ChangeOp::Update, Utc::now());
59 + e.hlc = hlc;
60 + e.data = Some(json!({ "v": payload }));
61 + e
62 + }
63 +
64 + fn pulled_with(hlc: Hlc, payload: u8) -> PulledChange {
65 + let mut p = make_pulled(
66 + "tasks",
67 + "row-1",
68 + ChangeOp::Update,
69 + Utc::now(),
70 + hlc.node.as_uuid(),
71 + 1,
72 + );
73 + p.entry.hlc = hlc;
74 + p.entry.data = Some(json!({ "v": payload }));
75 + p
76 + }
77 +
78 + proptest! {
79 + /// **Convergence.** Two devices hold the same pair with the roles
80 + /// reversed: what is local on A is remote on B. If the answer
81 + /// depended on which side the resolver was handed, the two devices
82 + /// would keep different rows and never reconcile. No example test
83 + /// notices unless it happens to pick that pair.
84 + ///
85 + /// Stated over the surviving payload rather than the `Resolution`
86 + /// variant: at an exact tie both sides keep local, which converges
87 + /// precisely because the two changes are then byte-identical.
88 + #[test]
89 + fn lww_picks_the_same_winner_from_either_side(
90 + (a_hlc, b_hlc) in hlc_pair(),
91 + a_payload in any::<u8>(),
92 + b_payload in any::<u8>(),
93 + ) {
94 + let now = Utc::now();
95 + let on_a = match resolve_lww_at(
96 + &entry_with(a_hlc, a_payload),
97 + &pulled_with(b_hlc, b_payload),
98 + now,
99 + ) {
100 + Resolution::KeepLocal => a_payload,
101 + Resolution::KeepRemote => b_payload,
102 + other => return Err(TestCaseError::fail(format!("unexpected {other:?}"))),
103 + };
104 + let on_b = match resolve_lww_at(
105 + &entry_with(b_hlc, b_payload),
106 + &pulled_with(a_hlc, a_payload),
107 + now,
108 + ) {
109 + Resolution::KeepLocal => b_payload,
110 + Resolution::KeepRemote => a_payload,
111 + other => return Err(TestCaseError::fail(format!("unexpected {other:?}"))),
112 + };
113 +
114 + prop_assert_eq!(
115 + on_a, on_b,
116 + "the two devices kept different payloads and will never converge: \
117 + A kept {}, B kept {} (a={:?}, b={:?})",
118 + on_a, on_b, a_hlc, b_hlc
119 + );
120 + }
121 +
122 + /// Resolution is a function of its inputs. Cheap to state, and it is
123 + /// what lets the resolver be re-run from a retry without
124 + /// re-deriving the world.
125 + #[test]
126 + fn lww_is_deterministic(
127 + (a_hlc, b_hlc) in hlc_pair(),
128 + a_payload in any::<u8>(),
129 + b_payload in any::<u8>(),
130 + ) {
131 + let now = Utc::now();
132 + let local = entry_with(a_hlc, a_payload);
133 + let first = resolve_lww_at(&local, &pulled_with(b_hlc, b_payload), now);
134 + let second = resolve_lww_at(&local, &pulled_with(b_hlc, b_payload), now);
135 + prop_assert_eq!(format!("{first:?}"), format!("{second:?}"));
136 + }
137 +
138 + /// A poisoned clock must never beat an honest one. This is the
139 + /// guard's whole purpose: an unbounded future timestamp would
140 + /// otherwise win every conflict for years.
141 + #[test]
142 + fn an_honest_clock_beats_a_poisoned_one(
143 + honest_wall in 1_700_000_000_000i64..1_700_000_100_000,
144 + poison_offset in (MAX_HLC_DRIFT_MS + 1)..10_000_000_000i64,
145 + node_a in device_id(),
146 + node_b in device_id(),
147 + ) {
148 + let now = Utc::now();
149 + let honest = Hlc { wall_ms: honest_wall, counter: 0, node: node_a };
150 + let poisoned = Hlc {
151 + wall_ms: now.timestamp_millis().saturating_add(poison_offset),
152 + counter: 0,
153 + node: node_b,
154 + };
155 + prop_assume!(!is_clock_poisoned(&honest, now));
156 +
157 + prop_assert!(
158 + matches!(
159 + resolve_lww_at(&entry_with(honest, 1), &pulled_with(poisoned, 2), now),
160 + Resolution::KeepLocal
161 + ),
162 + "a poisoned remote won against an honest local"
163 + );
164 + prop_assert!(
165 + matches!(
166 + resolve_lww_at(&entry_with(poisoned, 2), &pulled_with(honest, 1), now),
167 + Resolution::KeepRemote
168 + ),
169 + "a poisoned local won against an honest remote"
170 + );
171 + }
172 +
173 + /// **A field merge converges, dependent groups included.**
174 + ///
175 + /// The two devices see mirror images of one conflict: what is local
176 + /// on A is remote on B. They must compute the same merged object, or
177 + /// they hold different bytes forever with nothing to detect it.
178 + ///
179 + /// This is aimed at the group rule specifically. Everything else in
180 + /// the merge decides a field from values both devices have, but the
181 + /// group rule picks a *side*, and "side" is the one concept that is
182 + /// device-relative. It converges because the winner comes from
183 + /// `resolve_tie` over the two entries rather than from which one the
184 + /// caller happened to label local, and this is what would fail if
185 + /// that ever regressed to a "ties go to local" rule.
186 + #[test]
187 + fn field_merge_converges_on_mirrored_inputs(
188 + (a_hlc, b_hlc) in hlc_pair(),
189 + a_state in 0u8..3,
190 + b_state in 0u8..3,
191 + a_at in 0u8..3,
192 + b_at in 0u8..3,
193 + a_note in 0u8..3,
194 + b_note in 0u8..3,
195 + ) {
196 + const GROUPS: &[&[&str]] = &[&["state", "state_at"]];
197 + let base = json!({"state": "s0", "state_at": "t0", "note": "n0"});
198 + let a = json!({
199 + "state": format!("s{a_state}"),
200 + "state_at": format!("t{a_at}"),
201 + "note": format!("n{a_note}"),
202 + });
203 + let b = json!({
204 + "state": format!("s{b_state}"),
205 + "state_at": format!("t{b_at}"),
206 + "note": format!("n{b_note}"),
207 + });
208 +
209 + // On device A the local side is `a`; on device B it is `b`.
210 + let on_a = resolve_field_merge_with(&a, &b, &base, &a_hlc, &b_hlc, GROUPS);
211 + let on_b = resolve_field_merge_with(&b, &a, &base, &b_hlc, &a_hlc, GROUPS);
212 +
213 + prop_assert_eq!(
214 + format!("{on_a:?}"),
215 + format!("{on_b:?}"),
216 + "two devices merged the same conflict differently and will \
217 + never converge (a={:?}, b={:?})",
218 + a_hlc,
219 + b_hlc
220 + );
221 + }
222 +
223 + /// **A declared group never lands split across the two sides.**
224 + ///
225 + /// The property the declaration exists to buy. However the merge
226 + /// resolves, every member of a contested group has to come from one
227 + /// side, so the pair describes a state some device actually held. A
228 + /// merge that decided `state` and `state_at` independently fails this
229 + /// on the inputs where the two sides disagree about only one of them,
230 + /// which is exactly the GoingsOn start()-versus-complete() case.
231 + #[test]
232 + fn a_contested_group_never_lands_split(
233 + (a_hlc, b_hlc) in hlc_pair(),
234 + a_state in 0u8..3,
235 + b_state in 0u8..3,
236 + a_at in 0u8..3,
237 + b_at in 0u8..3,
238 + ) {
239 + const GROUPS: &[&[&str]] = &[&["state", "state_at"]];
240 + let base = json!({"state": "s0", "state_at": "t0"});
241 + let a = json!({"state": format!("s{a_state}"), "state_at": format!("t{a_at}")});
242 + let b = json!({"state": format!("s{b_state}"), "state_at": format!("t{b_at}")});
243 +
244 + let Resolution::Merged(merged) =
245 + resolve_field_merge_with(&a, &b, &base, &a_hlc, &b_hlc, GROUPS)
246 + else {
247 + return Err(TestCaseError::fail("an object base must merge"));
248 + };
249 +
250 + // The result's group is allowed to be A's, B's, or the base's
251 + // (untouched). What it must never be is one column from one side
252 + // and the other from a different one.
253 + let pair = (&merged["state"], &merged["state_at"]);
254 + let candidates = [
255 + (&a["state"], &a["state_at"]),
256 + (&b["state"], &b["state_at"]),
257 + (&base["state"], &base["state_at"]),
258 + ];
259 + prop_assert!(
260 + candidates.contains(&pair),
261 + "the group landed split: got {:?}, which is no device's version \
262 + of it (a={a}, b={b})",
263 + merged
264 + );
265 + }
266 + }
267 + }
268 +
269 + /// Fixed node for locally-minted test entries, distinct from any random
270 + /// `other_device`, so HLC tiebreaks are deterministic.
271 + fn local_node() -> DeviceId {
272 + DeviceId::new(Uuid::from_u128(0x1111_1111_1111_1111_1111_1111_1111_1111))
273 + }
274 +
275 + /// A second fixed device node, distinct from [`local_node`], for the
276 + /// field-merge tests that need to name the remote side's clock.
277 + fn remote_node() -> DeviceId {
278 + DeviceId::new(Uuid::from_u128(0x2222_2222_2222_2222_2222_2222_2222_2222))
279 + }
280 +
281 + /// Map a wall-clock timestamp onto an HLC at `node`, so the timestamp-ordered
282 + /// field-merge tests express the same intent against the HLC-based API. A
283 + /// strictly later `ts` yields a strictly greater HLC (higher `wall_ms`); equal
284 + /// `ts` on distinct nodes ties on the node, which is exactly the convergent
285 + /// behavior the F1 fix guarantees.
286 + fn ts_hlc(ts: DateTime<Utc>, node: DeviceId) -> Hlc {
287 + Hlc::from_legacy(ts.timestamp_millis(), node)
288 + }
289 +
290 + fn make_entry(table: &str, row_id: &str, op: ChangeOp, ts: DateTime<Utc>) -> ChangeEntry {
291 + // Derive the HLC wall component from the timestamp so the time-ordered
292 + // tests below still express the intended ordering.
293 + ChangeEntry {
294 + table: table.to_string(),
295 + op,
296 + row_id: row_id.to_string(),
297 + timestamp: ts,
298 + hlc: Hlc::from_legacy(ts.timestamp_millis(), local_node()),
299 + data: Some(json!({"value": "test"})),
300 + extra: serde_json::Map::default(),
301 + }
302 + }
303 +
304 + fn make_pulled(
305 + table: &str,
306 + row_id: &str,
307 + op: ChangeOp,
308 + ts: DateTime<Utc>,
309 + device_id: Uuid,
310 + seq: i64,
311 + ) -> PulledChange {
312 + let mut entry = make_entry(table, row_id, op, ts);
313 + entry.hlc = Hlc::from_legacy(ts.timestamp_millis(), DeviceId::new(device_id));
314 + PulledChange {
315 + storage_version: None,
316 + entry,
317 + device_id: DeviceId::new(device_id),
318 + seq,
319 + }
320 + }
321 +
322 + /// Build a pulled change with an explicit HLC, for resolution tests that need
323 + /// to control the clock independently of the wall timestamp.
324 + fn pulled_with_hlc(row_id: &str, op: ChangeOp, hlc: Hlc, device_id: Uuid) -> PulledChange {
325 + let mut p = make_pulled("tasks", row_id, op, Utc::now(), device_id, 1);
326 + p.entry.hlc = hlc;
327 + p
328 + }
329 +
330 + // ── detect_conflicts ──
331 +
332 + #[test]
333 + fn no_conflicts_when_different_rows() {
334 + let our_device = Uuid::new_v4();
335 + let other_device = Uuid::new_v4();
336 + let now = Utc::now();
337 +
338 + let remote = vec![make_pulled(
339 + "tasks",
340 + "r1",
341 + ChangeOp::Update,
342 + now,
343 + other_device,
344 + 1,
345 + )];
346 + let local = vec![make_entry("tasks", "r2", ChangeOp::Update, now)];
347 +
348 + let (clean, conflicts) = detect_conflicts(remote, &local, DeviceId::new(our_device));
349 + assert_eq!(clean.len(), 1);
350 + assert!(conflicts.is_empty());
351 + }
352 +
353 + #[test]
354 + fn conflict_detected_same_row_different_device() {
355 + let our_device = Uuid::new_v4();
356 + let other_device = Uuid::new_v4();
357 + let now = Utc::now();
358 +
359 + let remote = vec![make_pulled(
360 + "tasks",
361 + "r1",
362 + ChangeOp::Update,
363 + now,
364 + other_device,
365 + 1,
366 + )];
367 + let local = vec![make_entry("tasks", "r1", ChangeOp::Update, now)];
368 +
369 + let (clean, conflicts) = detect_conflicts(remote, &local, DeviceId::new(our_device));
370 + assert!(clean.is_empty());
371 + assert_eq!(conflicts.len(), 1);
372 + assert_eq!(conflicts[0].remote.entry.row_id, "r1");
373 + assert_eq!(conflicts[0].local.row_id, "r1");
374 + }
375 +
376 + #[test]
377 + fn own_echo_without_pending_edit_is_clean() {
378 + // An echo of our own device with no contesting local pending edit is
379 + // clean (it still passes the HLC gate at apply time).
380 + let our_device = Uuid::new_v4();
381 + let now = Utc::now();
382 +
383 + let remote = vec![make_pulled(
384 + "tasks",
385 + "r1",
386 + ChangeOp::Update,
387 + now,
388 + our_device,
389 + 1,
390 + )];
391 + let (clean, conflicts) = detect_conflicts(remote, &[], DeviceId::new(our_device));
392 + assert_eq!(clean.len(), 1);
393 + assert!(conflicts.is_empty());
394 + }
395 +
396 + #[test]
397 + fn echo_contesting_a_pending_edit_is_resolved_not_trusted() {
398 + // Hardening: a pulled change labeled as our own echo that contests an
399 + // un-pushed local edit is resolved as a conflict, not waved through as
400 + // clean. Trusting the device_id label would let a server relabel a hostile
401 + // row as our echo to skip conflict detection entirely.
402 + let our_device = Uuid::new_v4();
403 + let now = Utc::now();
404 +
405 + let remote = vec![make_pulled(
406 + "tasks",
407 + "r1",
408 + ChangeOp::Update,
409 + now,
410 + our_device,
411 + 1,
412 + )];
413 + let local = vec![make_entry("tasks", "r1", ChangeOp::Update, now)];
414 +
415 + let (clean, conflicts) = detect_conflicts(remote, &local, DeviceId::new(our_device));
416 + assert!(clean.is_empty());
417 + assert_eq!(
418 + conflicts.len(),
419 + 1,
420 + "echo contesting a pending edit is resolved"
421 + );
422 + }
423 +
424 + #[test]
425 + fn clean_changes_gate_drops_stale_keeps_newer() {
426 + let our_device = Uuid::new_v4();
427 + let other_device = Uuid::new_v4();
428 + let now = Utc::now();
429 + // A clean remote change for tasks/r1; its HLC wall == now_ms.
430 + let remote = vec![make_pulled(
431 + "tasks",
432 + "r1",
433 + ChangeOp::Update,
434 + now,
435 + other_device,
436 + 1,
437 + )];
438 + let (clean, _conflicts) = detect_conflicts(remote, &[], DeviceId::new(our_device));
439 + assert_eq!(clean.len(), 1);
440 +
441 + // No committed clock for the row → kept (first time we've seen it).
442 + assert_eq!(clean.clone().gated(|_, _| None).len(), 1);
443 + // Committed clock older than the remote → kept.
444 + assert_eq!(
445 + clean
446 + .clone()
447 + .gated(|_, _| Some(Hlc::zero(DeviceId::new(other_device))))
448 + .len(),
449 + 1
450 + );
451 + // Committed clock newer than the remote → dropped (would clobber newer local).
452 + let newer = Hlc {
453 + wall_ms: now.timestamp_millis() + 1,
454 + counter: 0,
455 + node: DeviceId::new(other_device),
456 + };
457 + assert!(clean.gated(move |_, _| Some(newer)).is_empty());
458 + }
459 +
460 + #[test]
461 + fn different_tables_same_row_id_no_conflict() {
462 + let our_device = Uuid::new_v4();
463 + let other_device = Uuid::new_v4();
464 + let now = Utc::now();
465 +
466 + let remote = vec![make_pulled(
467 + "tasks",
468 + "r1",
469 + ChangeOp::Update,
470 + now,
471 + other_device,
472 + 1,
473 + )];
474 + let local = vec![make_entry("events", "r1", ChangeOp::Update, now)];
475 +
476 + let (clean, conflicts) = detect_conflicts(remote, &local, DeviceId::new(our_device));
477 + assert_eq!(clean.len(), 1);
478 + assert!(conflicts.is_empty());
479 + }
480 +
481 + #[test]
482 + fn detect_conflicts_correct_split() {
483 + let our_device = Uuid::new_v4();
484 + let other_device = Uuid::new_v4();
485 + let now = Utc::now();
486 +
487 + let remote = vec![
488 + make_pulled("tasks", "r1", ChangeOp::Update, now, other_device, 1),
489 + make_pulled("tasks", "r2", ChangeOp::Insert, now, other_device, 2),
490 + make_pulled("events", "r3", ChangeOp::Delete, now, other_device, 3),
491 + ];
492 + let local = vec![
493 + make_entry("tasks", "r1", ChangeOp::Update, now),
494 + // r2 not in local → clean
495 + // r3 not in local → clean
496 + ];
497 +
498 + let (clean, conflicts) = detect_conflicts(remote, &local, DeviceId::new(our_device));
499 + // The negative side of `is_empty`: every other assertion in this file is
500 + // `assert!(clean.is_empty())`, which a constant-`true` `is_empty` also
Lines truncated
@@ -1,0 +1,1355 @@
1 + //! Tests for [`super`].
2 +
3 + use super::*;
4 +
5 + /// Properties of the sealing layer.
6 + ///
7 + /// Encryption is a round-trip for every input, not for the handful of
8 + /// payload shapes the examples below happen to use. See wiki
9 + /// `testing-posture`, Phase 2.
10 + mod properties {
11 + use super::*;
12 + use proptest::prelude::*;
13 +
14 + proptest! {
15 + /// `decrypt(encrypt(m, k), k) == m`, including for the empty
16 + /// message and for inputs that straddle the chunking boundary.
17 + #[test]
18 + fn encryption_round_trips(
19 + plaintext in prop::collection::vec(any::<u8>(), 0..4096),
20 + ) {
21 + let key = generate_master_key();
22 + let sealed = encrypt_bytes(&plaintext, &key).expect("encrypt");
23 + let opened = decrypt_bytes(&sealed, &key).expect("decrypt");
24 + prop_assert_eq!(opened, plaintext);
25 + }
26 +
27 + /// A wrong key must be an error rather than garbage plaintext,
28 + /// which is what makes the AEAD tag load-bearing instead of
29 + /// decorative.
30 + #[test]
31 + fn decryption_under_the_wrong_key_fails(
32 + plaintext in prop::collection::vec(any::<u8>(), 0..1024),
33 + ) {
34 + let key = generate_master_key();
35 + let other = generate_master_key();
36 + prop_assume!(key != other);
37 + let sealed = encrypt_bytes(&plaintext, &key).expect("encrypt");
38 + prop_assert!(
39 + decrypt_bytes(&sealed, &other).is_err(),
40 + "a wrong key produced a result instead of an error"
41 + );
42 + }
43 +
44 + /// Sealing the same bytes twice under one key must not repeat the
45 + /// ciphertext. A reused nonce is the classic AEAD break, and
46 + /// nothing asserted the nonce actually varies.
47 + #[test]
48 + fn sealing_twice_does_not_repeat_ciphertext(
49 + plaintext in prop::collection::vec(any::<u8>(), 1..512),
50 + ) {
51 + let key = generate_master_key();
52 + let a = encrypt_bytes(&plaintext, &key).expect("encrypt");
53 + let b = encrypt_bytes(&plaintext, &key).expect("encrypt");
54 + prop_assert_ne!(a, b, "the same plaintext sealed to identical bytes twice");
55 + }
56 + }
57 + }
58 +
59 + /// Differential relations over the chunked-blob format.
60 + ///
61 + /// Three implementations describe one layout: `encrypt_blob_chunked`
62 + /// produces it, `blob_encrypted_len`/`sealed_chunk_len`/
63 + /// `blob_chunk_count_for` predict it before a byte is sealed, and
64 + /// `parse_blob_header` + `decrypt_blob_chunk` read it back one chunk at a
65 + /// time. Relating them needs no expected-value table, which is what makes
66 + /// these cheap (Chen et al. 1998; McKeeman 1998).
67 + ///
68 + /// Note on what is NOT asserted: the multipart and one-shot paths do not
69 + /// produce identical ciphertext and cannot, because every chunk is sealed
70 + /// under a fresh nonce (see `sealing_twice_does_not_repeat_ciphertext`
71 + /// above). The relation that holds, and the one the uploader depends on, is
72 + /// that the predicted layout equals the produced layout.
73 + ///
74 + /// See wiki `testing-posture`, Phase 2.
75 + mod blob_relations {
76 + use super::*;
77 + use proptest::prelude::*;
78 +
79 + /// Lengths that land either side of a chunk boundary, using a small
80 + /// stand-in for the 1 MiB production chunk so a case is cheap to run.
81 + /// The boundary arithmetic is what these relations are about, and it is
82 + /// the same arithmetic at any chunk size.
83 + fn plaintext() -> impl Strategy<Value = Vec<u8>> {
84 + prop_oneof![
85 + 1 => Just(Vec::new()),
86 + 4 => prop::collection::vec(any::<u8>(), 1..4096),
87 + ]
88 + }
89 +
90 + proptest! {
91 + /// The uploader signs an exact `Content-Length` per part before it
92 + /// has sealed anything, so a predicted length that disagrees with
93 + /// the produced one is a broken upload rather than a wrong number.
94 + #[test]
95 + fn the_predicted_length_equals_the_produced_length(plaintext in plaintext()) {
96 + let key = generate_master_key();
97 + let sealed = encrypt_blob_chunked(&plaintext, &key, "h").expect("encrypt");
98 + prop_assert_eq!(
99 + sealed.len(),
100 + blob_encrypted_len(plaintext.len()),
101 + "blob_encrypted_len disagrees with encrypt_blob_chunked for {} bytes",
102 + plaintext.len()
103 + );
104 + }
105 +
106 + /// The per-chunk lengths must add up the same way, since the
107 + /// uploader slices parts by them. Checked against the header the
108 + /// encoder actually wrote rather than against the predictor's own
109 + /// idea of it.
110 + #[test]
111 + fn the_predicted_chunk_layout_equals_the_produced_one(plaintext in plaintext()) {
112 + let key = generate_master_key();
113 + let sealed = encrypt_blob_chunked(&plaintext, &key, "h").expect("encrypt");
114 + let (header, consumed) = parse_blob_header(&sealed).expect("parse header");
115 +
116 + prop_assert_eq!(
117 + header.chunk_count,
118 + blob_chunk_count_for(plaintext.len()),
119 + "header chunk count disagrees with the predictor"
120 + );
121 + let summed: usize = (0..header.chunk_count)
122 + .map(|i| header.sealed_chunk_len(i))
123 + .sum();
124 + prop_assert_eq!(
125 + consumed + summed,
126 + sealed.len(),
127 + "the per-chunk lengths do not tile the sealed body"
128 + );
129 + }
130 +
131 + /// The two decode paths are two implementations of one format: the
132 + /// buffered fallback and the streaming reader the download path
133 + /// actually uses. They must agree on every input, or a blob opens
134 + /// one way in a test and another way in the app.
135 + #[test]
136 + fn streaming_and_buffered_decode_agree(plaintext in plaintext()) {
137 + let key = generate_master_key();
138 + let sealed = encrypt_blob_chunked(&plaintext, &key, "h").expect("encrypt");
139 +
140 + let buffered = decrypt_blob_chunked(&sealed, &key, "h").expect("buffered decrypt");
141 +
142 + let (header, consumed) = parse_blob_header(&sealed).expect("parse header");
143 + let mut streamed = Vec::new();
144 + let mut offset = consumed;
145 + for i in 0..header.chunk_count {
146 + let len = header.sealed_chunk_len(i);
147 + let chunk = &sealed[offset..offset + len];
148 + streamed.extend_from_slice(
149 + &decrypt_blob_chunk(chunk, &key, "h", i, header.chunk_count)
150 + .expect("chunk decrypt"),
151 + );
152 + offset += len;
153 + }
154 +
155 + prop_assert_eq!(&buffered, &plaintext, "buffered decode lost the plaintext");
156 + prop_assert_eq!(
157 + &streamed, &plaintext,
158 + "streaming decode disagreed with the plaintext"
159 + );
160 + prop_assert_eq!(offset, sealed.len(), "streaming decode left bytes unread");
161 + }
162 + }
163 + }
164 +
165 + #[test]
166 + fn master_key_generation_is_random() {
167 + let k1 = generate_master_key();
168 + let k2 = generate_master_key();
169 + assert_ne!(k1, k2, "Two generated keys must differ");
170 + assert_eq!(k1.len(), 32);
171 + }
172 +
173 + #[test]
174 + fn wrapping_key_derivation_is_deterministic() {
175 + let salt = [42u8; 32];
176 + let k1 = derive_wrapping_key("password123", &salt).unwrap();
177 + let k2 = derive_wrapping_key("password123", &salt).unwrap();
178 + assert_eq!(*k1, *k2, "Same inputs must produce same wrapping key");
179 + }
180 +
181 + #[test]
182 + fn different_passwords_produce_different_keys() {
183 + let salt = [42u8; 32];
184 + let k1 = derive_wrapping_key("password1", &salt).unwrap();
185 + let k2 = derive_wrapping_key("password2", &salt).unwrap();
186 + assert_ne!(*k1, *k2);
187 + }
188 +
189 + #[test]
190 + fn different_salts_produce_different_keys() {
191 + let salt1 = [1u8; 32];
192 + let salt2 = [2u8; 32];
193 + let k1 = derive_wrapping_key("password", &salt1).unwrap();
194 + let k2 = derive_wrapping_key("password", &salt2).unwrap();
195 + assert_ne!(*k1, *k2);
196 + }
197 +
198 + // ── Password normalization (NFC/NFD) ──
199 +
200 + #[test]
201 + fn nfc_and_nfd_passwords_derive_same_key() {
202 + // "e" + combining acute accent (NFD form of e-acute)
203 + let nfd_password = "caf\u{0065}\u{0301}"; // "cafe" with decomposed accent
204 + // Pre-composed e-acute (NFC form)
205 + let nfc_password = "caf\u{00e9}"; // "cafe" with composed accent
206 +
207 + // Verify they are actually different byte sequences
208 + assert_ne!(
209 + nfd_password.as_bytes(),
210 + nfc_password.as_bytes(),
211 + "NFD and NFC should have different raw bytes"
212 + );
213 +
214 + let salt = [99u8; 32];
215 + let k1 = derive_wrapping_key(nfd_password, &salt).unwrap();
216 + let k2 = derive_wrapping_key(nfc_password, &salt).unwrap();
217 + assert_eq!(
218 + *k1, *k2,
219 + "Same password in NFC and NFD forms must derive the same key"
220 + );
221 + }
222 +
223 + #[test]
224 + fn nfc_nfd_wrap_unwrap_roundtrip() {
225 + let master_key = generate_master_key();
226 + // Wrap with NFC form
227 + let nfc_password = "caf\u{00e9}";
228 + let envelope = wrap_master_key(&master_key, nfc_password).unwrap();
229 +
230 + // Unwrap with NFD form
231 + let nfd_password = "caf\u{0065}\u{0301}";
232 + let recovered = unwrap_master_key(&envelope, nfd_password).unwrap();
233 + assert_eq!(master_key, recovered);
234 + }
235 +
236 + #[test]
237 + fn nfd_wrap_nfc_unwrap_roundtrip() {
238 + let master_key = generate_master_key();
239 + // Wrap with NFD form
240 + let nfd_password = "caf\u{0065}\u{0301}";
241 + let envelope = wrap_master_key(&master_key, nfd_password).unwrap();
242 +
243 + // Unwrap with NFC form
244 + let nfc_password = "caf\u{00e9}";
245 + let recovered = unwrap_master_key(&envelope, nfc_password).unwrap();
246 + assert_eq!(master_key, recovered);
247 + }
248 +
249 + #[test]
250 + fn normalize_password_converts_to_nfc() {
251 + let nfd = "caf\u{0065}\u{0301}";
252 + let nfc = "caf\u{00e9}";
253 + let normalized = normalize_password(nfd).unwrap();
254 + assert_eq!(normalized, nfc);
255 + }
256 +
257 + // ── Empty password rejection ──
258 +
259 + #[test]
260 + fn empty_password_rejected_by_normalize() {
261 + let result = normalize_password("");
262 + assert!(result.is_err());
263 + let msg = result.unwrap_err().to_string();
264 + assert!(msg.contains("empty"), "Error should mention empty: {msg}");
265 + }
266 +
267 + #[test]
268 + fn empty_password_rejected_by_derive() {
269 + let salt = [0u8; 32];
270 + let result = derive_wrapping_key("", &salt);
271 + assert!(result.is_err());
272 + }
273 +
274 + #[test]
275 + fn empty_password_rejected_by_wrap() {
276 + let master_key = generate_master_key();
277 + let result = wrap_master_key(&master_key, "");
278 + assert!(result.is_err());
279 + }
280 +
281 + #[test]
282 + fn empty_password_rejected_by_unwrap() {
283 + let master_key = generate_master_key();
284 + let envelope = wrap_master_key(&master_key, "valid").unwrap();
285 + let result = unwrap_master_key(&envelope, "");
286 + assert!(result.is_err());
287 + }
288 +
289 + // ── Password length limit ──
290 +
291 + #[test]
292 + fn very_long_password_rejected() {
293 + let long_password = "a".repeat(MAX_PASSWORD_BYTES + 1);
294 + let result = normalize_password(&long_password);
295 + assert!(result.is_err());
296 + let msg = result.unwrap_err().to_string();
297 + assert!(
298 + msg.contains("maximum length"),
299 + "Error should mention max length: {msg}"
300 + );
301 + }
302 +
303 + #[test]
304 + fn password_at_max_length_accepted() {
305 + let max_password = "a".repeat(MAX_PASSWORD_BYTES);
306 + let result = normalize_password(&max_password);
307 + assert!(result.is_ok());
308 + }
309 +
310 + #[test]
311 + fn password_just_under_max_length_accepted() {
312 + let password = "a".repeat(MAX_PASSWORD_BYTES - 1);
313 + let result = normalize_password(&password);
314 + assert!(result.is_ok());
315 + }
316 +
317 + // ── Salt reuse detection ──
318 +
319 + #[test]
320 + fn two_wraps_use_different_salts() {
321 + let master_key = generate_master_key();
322 + let e1_json = wrap_master_key(&master_key, "pass").unwrap();
323 + let e2_json = wrap_master_key(&master_key, "pass").unwrap();
324 +
325 + let e1: KeyEnvelope = serde_json::from_str(&e1_json).unwrap();
326 + let e2: KeyEnvelope = serde_json::from_str(&e2_json).unwrap();
327 +
328 + assert_ne!(e1.salt, e2.salt, "Each wrap must use a unique random salt");
329 + assert_ne!(
330 + e1.nonce, e2.nonce,
331 + "Each wrap must use a unique random nonce"
332 + );
333 + }
334 +
335 + // ── Key derivation determinism ──
336 +
337 + #[test]
338 + fn key_derivation_deterministic_multiple_calls() {
339 + let salt = [77u8; 32];
340 + let password = "deterministic-test-password";
341 +
342 + let k1 = derive_wrapping_key(password, &salt).unwrap();
343 + let k2 = derive_wrapping_key(password, &salt).unwrap();
344 + let k3 = derive_wrapping_key(password, &salt).unwrap();
345 +
346 + assert_eq!(*k1, *k2);
347 + assert_eq!(*k2, *k3);
348 + }
349 +
350 + // ── Key rotation: re-wrap with new password, old data still readable ──
351 +
352 + #[test]
353 + fn key_rotation_preserves_data_access() {
354 + let master_key = generate_master_key();
355 + let plaintext = b"encrypted before password change";
356 +
357 + // Encrypt data with the master key
358 + let encrypted = encrypt_data(plaintext, &master_key).unwrap();
359 +
360 + // Wrap master key with old password
361 + let old_envelope = wrap_master_key(&master_key, "old-pass").unwrap();
362 +
363 + // Simulate password change: unwrap with old, re-wrap with new
364 + let recovered_key = unwrap_master_key(&old_envelope, "old-pass").unwrap();
365 + assert_eq!(recovered_key, master_key);
366 +
367 + let new_envelope = wrap_master_key(&recovered_key, "new-pass").unwrap();
368 +
369 + // Verify: unwrap with new password gives same key
370 + let key_from_new = unwrap_master_key(&new_envelope, "new-pass").unwrap();
371 + assert_eq!(key_from_new, master_key);
372 +
373 + // Verify: old encrypted data can still be decrypted
374 + let decrypted = decrypt_data(&encrypted, &key_from_new).unwrap();
375 + assert_eq!(decrypted, plaintext);
376 +
377 + // Verify: old password no longer works on new envelope
378 + let result = unwrap_master_key(&new_envelope, "old-pass");
379 + assert!(result.is_err());
380 + }
381 +
382 + // ── Encryption roundtrip with various data sizes ──
383 +
384 + #[test]
385 + fn encrypt_decrypt_empty_data() {
386 + let master_key = generate_master_key();
387 + let encrypted = encrypt_data(b"", &master_key).unwrap();
388 + let decrypted = decrypt_data(&encrypted, &master_key).unwrap();
389 + assert!(decrypted.is_empty());
390 + }
391 +
392 + #[test]
393 + fn encrypt_decrypt_single_byte() {
394 + let master_key = generate_master_key();
395 + let encrypted = encrypt_data(&[42], &master_key).unwrap();
396 + let decrypted = decrypt_data(&encrypted, &master_key).unwrap();
397 + assert_eq!(decrypted, vec![42]);
398 + }
399 +
400 + #[test]
401 + fn encrypt_decrypt_large_payload() {
402 + let master_key = generate_master_key();
403 + // 1MB of data
404 + let plaintext: Vec<u8> = (0..1_000_000).map(|i| (i % 256) as u8).collect();
405 + let encrypted = encrypt_data(&plaintext, &master_key).unwrap();
406 + let decrypted = decrypt_data(&encrypted, &master_key).unwrap();
407 + assert_eq!(decrypted, plaintext);
408 + }
409 +
410 + // ── Wrong key gives error, not garbage ──
411 +
412 + #[test]
413 + fn wrong_key_gives_decryption_error_not_garbage() {
414 + let key1 = generate_master_key();
415 + let key2 = generate_master_key();
416 + let plaintext = b"this should fail cleanly with wrong key";
417 +
418 + let encrypted = encrypt_data(plaintext, &key1).unwrap();
419 + let result = decrypt_data(&encrypted, &key2);
420 +
421 + // Must be an error, not a successful decryption to garbage
422 + assert!(result.is_err());
423 + assert!(
424 + matches!(result.unwrap_err(), SyncKitError::DecryptionFailed),
425 + "Wrong key must produce DecryptionFailed, not garbage output"
426 + );
427 + }
428 +
429 + #[test]
430 + fn wrong_key_bytes_gives_decryption_error_not_garbage() {
431 + let key1 = generate_master_key();
432 + let key2 = generate_master_key();
433 + let plaintext = b"binary data check";
434 +
435 + let encrypted = encrypt_bytes(plaintext, &key1).unwrap();
436 + let result = decrypt_bytes(&encrypted, &key2);
437 +
438 + assert!(result.is_err());
439 + assert!(matches!(
440 + result.unwrap_err(),
441 + SyncKitError::DecryptionFailed
442 + ));
443 + }
444 +
445 + // ── JSON encryption edge cases ──
446 +
447 + #[test]
448 + fn json_encrypt_decrypt_null() {
449 + let master_key = generate_master_key();
450 + let original = serde_json::Value::Null;
451 + let encrypted = encrypt_json(&original, &master_key).unwrap();
452 + let decrypted = decrypt_json(&encrypted, &master_key).unwrap();
453 + assert_eq!(decrypted, original);
454 + }
455 +
456 + #[test]
457 + fn json_encrypt_decrypt_nested_object() {
458 + let master_key = generate_master_key();
459 + let original = serde_json::json!({
460 + "level1": {
461 + "level2": {
462 + "level3": [1, 2, 3],
463 + "flag": true
464 + }
465 + },
466 + "empty_array": [],
467 + "empty_object": {}
468 + });
469 +
470 + let encrypted = encrypt_json(&original, &master_key).unwrap();
471 + let decrypted = decrypt_json(&encrypted, &master_key).unwrap();
472 + assert_eq!(decrypted, original);
473 + }
474 +
475 + #[test]
476 + fn json_decrypt_with_wrong_key_fails() {
477 + let key1 = generate_master_key();
478 + let key2 = generate_master_key();
479 + let original = serde_json::json!({"secret": "data"});
480 +
481 + let encrypted = encrypt_json(&original, &key1).unwrap();
482 + let result = decrypt_json(&encrypted, &key2);
483 + assert!(result.is_err());
484 + }
485 +
486 + #[test]
487 + fn json_decrypt_non_string_value_fails() {
488 + let master_key = generate_master_key();
489 + let not_a_string = serde_json::json!(42);
490 + let result = decrypt_json(&not_a_string, &master_key);
491 + assert!(result.is_err());
492 + }
493 +
494 + // ── Blob (bytes) edge cases ──
495 +
496 + #[test]
497 + fn bytes_zero_byte_blob_roundtrip() {
498 + let master_key = generate_master_key();
499 + let empty: &[u8] = &[];
500 + let encrypted = encrypt_bytes(empty, &master_key).unwrap();
Lines truncated
@@ -1,0 +1,905 @@
1 + //! Tests for [`super`].
2 +
3 + use super::*;
4 + use crate::types::{ChangeEntry, ChangeOp, hlc_legacy_floor};
5 + use rusqlite::Connection;
6 + use serde_json::json;
7 +
8 + use super::super::db::configure_connection;
9 + use super::super::schema::{SyncSchema, SyncTable};
10 +
11 + fn upsert(table: &str, row_id: &str, data: Value) -> ChangeEntry {
12 + ChangeEntry {
13 + table: table.into(),
14 + op: ChangeOp::Insert,
15 + row_id: row_id.into(),
16 + timestamp: chrono::Utc::now(),
17 + hlc: hlc_legacy_floor(),
18 + data: Some(data),
19 + extra: serde_json::Map::default(),
20 + }
21 + }
22 +
23 + fn delete(table: &str, row_id: &str, data: Value) -> ChangeEntry {
24 + ChangeEntry {
25 + op: ChangeOp::Delete,
26 + ..upsert(table, row_id, data)
27 + }
28 + }
29 +
30 + fn schema() -> SyncSchema {
31 + SyncSchema::new(vec![
32 + SyncTable::full("parent", &["id", "name"]),
33 + SyncTable::full("child", &["id", "parent_id", "note"]),
34 + SyncTable::full("acct", &["id", "name"])
35 + .preserve_local(&["secret"])
36 + .insert_defaults(&[("secret", "")]),
37 + SyncTable::full("tagpair", &["a", "b"]).pk(&["a", "b"]),
38 + SyncTable::full("items", &["id", "is_read", "is_starred"])
39 + .partial_update(&["is_read", "is_starred"])
40 + .ignore_deletes(),
41 + SyncTable::full("samp", &["hash", "name", "deleted_at"])
42 + .pk(&["hash"])
43 + .hashed()
44 + .tombstone("deleted_at"),
45 + SyncTable::full("cfg", &["key", "value"])
46 + .pk(&["key"])
47 + .exclude_where("{row}.key NOT LIKE 'sync\\_%' ESCAPE '\\'"),
48 + SyncTable::full("reffer", &["id", "ext_id"]).references_unsynced(),
49 + // `kind` is NOT NULL *with a default*, the only shape in which
50 + // omitting a column and binding an explicit NULL differ observably.
51 + SyncTable::full("note", &["id", "body", "kind"]),
52 + // A preserved column that is also a whitelist column, so a payload
53 + // can carry it and the ON CONFLICT SET has to refuse it.
54 + SyncTable::full("vault", &["id", "label", "token"]).preserve_local(&["token"]),
55 + // Partial update on a composite key: two WHERE bindings, not one.
56 + SyncTable::full("pairflag", &["a", "b", "flag"])
57 + .pk(&["a", "b"])
58 + .partial_update(&["flag"]),
59 + // INTEGER PRIMARY KEY, so a text id is a datatype mismatch: a SQLite
60 + // failure that is not a constraint violation.
61 + SyncTable::full("tally", &["id", "label"]),
62 + ])
63 + }
64 +
65 + fn db() -> Connection {
66 + let conn = Connection::open_in_memory().unwrap();
67 + configure_connection(&conn).unwrap();
68 + conn.execute_batch(
69 + "
70 + CREATE TABLE parent (id TEXT PRIMARY KEY, name TEXT);
71 + CREATE TABLE child (id TEXT PRIMARY KEY, parent_id TEXT NOT NULL REFERENCES parent(id) ON DELETE CASCADE, note TEXT);
72 + CREATE TABLE acct (id TEXT PRIMARY KEY, name TEXT, secret TEXT NOT NULL);
73 + CREATE TABLE tagpair (a TEXT, b TEXT, PRIMARY KEY (a, b));
74 + CREATE TABLE items (id TEXT PRIMARY KEY, is_read INTEGER, is_starred INTEGER, title TEXT);
75 + CREATE TABLE ghost (id INTEGER PRIMARY KEY);
76 + CREATE TABLE samp (hash TEXT PRIMARY KEY, name TEXT, deleted_at INTEGER);
77 + CREATE TABLE cfg (key TEXT PRIMARY KEY, value TEXT);
78 + CREATE TABLE reffer (id TEXT PRIMARY KEY, ext_id INTEGER NOT NULL REFERENCES ghost(id));
79 + CREATE TABLE note (id TEXT PRIMARY KEY, body TEXT, kind TEXT NOT NULL DEFAULT 'plain');
80 + CREATE TABLE vault (id TEXT PRIMARY KEY, label TEXT, token TEXT);
81 + CREATE TABLE pairflag (a TEXT, b TEXT, flag INTEGER, PRIMARY KEY (a, b));
82 + CREATE TABLE tally (id INTEGER PRIMARY KEY, label TEXT);
83 + ",
84 + )
85 + .unwrap();
86 + let s = schema();
87 + conn.execute_batch(&s.migration_sql()).unwrap();
88 + conn
89 + }
90 +
91 + /// These tests exercise the applier, not the pipeline that feeds it, so they
92 + /// build the resolved batch directly rather than routing every case through
93 + /// `resolve_pull`.
94 + fn apply(conn: &mut Connection, changes: &[ChangeEntry]) -> ApplyOutcome {
95 + let changes = ResolvedChanges::for_test(changes.to_vec());
96 + apply_remote_changes(conn, &schema(), &changes, "").unwrap()
97 + }
98 +
99 + #[test]
100 + fn full_insert_then_update_via_on_conflict() {
101 + let mut conn = db();
102 + let o = apply(
103 + &mut conn,
104 + &[upsert("parent", "p1", json!({"id":"p1","name":"a"}))],
105 + );
106 + assert_eq!(o.applied, 1);
107 + assert!(o.changed_tables.contains("parent"));
108 + apply(
109 + &mut conn,
110 + &[upsert("parent", "p1", json!({"id":"p1","name":"b"}))],
111 + );
112 + let name: String = conn
113 + .query_row("SELECT name FROM parent WHERE id='p1'", [], |r| r.get(0))
114 + .unwrap();
115 + assert_eq!(name, "b");
116 + }
117 +
118 + #[test]
119 + fn on_conflict_update_does_not_cascade_to_children() {
120 + let mut conn = db();
121 + apply(
122 + &mut conn,
123 + &[
124 + upsert("parent", "p1", json!({"id":"p1","name":"a"})),
125 + upsert(
126 + "child",
127 + "c1",
128 + json!({"id":"c1","parent_id":"p1","note":"n"}),
129 + ),
130 + ],
131 + );
132 + // Re-upsert the parent; the child must survive (ON CONFLICT DO UPDATE, not REPLACE).
133 + apply(
134 + &mut conn,
135 + &[upsert("parent", "p1", json!({"id":"p1","name":"a2"}))],
136 + );
137 + let kids: i64 = conn
138 + .query_row("SELECT COUNT(*) FROM child", [], |r| r.get(0))
139 + .unwrap();
140 + assert_eq!(kids, 1);
141 + }
142 +
143 + #[test]
144 + fn preserve_local_and_insert_defaults() {
145 + let mut conn = db();
146 + // First insert: secret defaults to '' (satisfies NOT NULL); payload never carries it.
147 + apply(
148 + &mut conn,
149 + &[upsert("acct", "a1", json!({"id":"a1","name":"n1"}))],
150 + );
151 + // Locally the user sets a real secret.
152 + conn.execute("UPDATE acct SET secret='hunter2' WHERE id='a1'", [])
153 + .unwrap();
154 + // A remote update to config columns must NOT clobber the local secret.
155 + apply(
156 + &mut conn,
157 + &[upsert("acct", "a1", json!({"id":"a1","name":"n2"}))],
158 + );
159 + let (name, secret): (String, String) = conn
160 + .query_row("SELECT name, secret FROM acct WHERE id='a1'", [], |r| {
161 + Ok((r.get(0)?, r.get(1)?))
162 + })
163 + .unwrap();
164 + assert_eq!(name, "n2");
165 + assert_eq!(
166 + secret, "hunter2",
167 + "preserved secret must survive a remote update"
168 + );
169 + }
170 +
171 + #[test]
172 + fn null_tolerance_omits_not_null_but_keeps_nullable_null() {
173 + let mut conn = db();
174 + apply(
175 + &mut conn,
176 + &[upsert("parent", "p1", json!({"id":"p1","name":"start"}))],
177 + );
178 + // name is nullable → an explicit null clears it.
179 + apply(
180 + &mut conn,
181 + &[upsert("parent", "p1", json!({"id":"p1","name":null}))],
182 + );
183 + let name: Option<String> = conn
184 + .query_row("SELECT name FROM parent WHERE id='p1'", [], |r| r.get(0))
185 + .unwrap();
186 + assert_eq!(name, None);
187 + // A null for a NOT NULL column (child.parent_id) is omitted, so an insert
188 + // takes no value for it → constraint violation → deferred, not fatal.
189 + let o = apply(
190 + &mut conn,
191 + &[upsert(
192 + "child",
193 + "c1",
194 + json!({"id":"c1","parent_id":null,"note":"x"}),
195 + )],
196 + );
197 + assert_eq!(o.applied, 0);
198 + assert_eq!(o.deferred.len(), 1);
199 + assert_eq!(o.deferred[0].row_id, "c1");
200 + }
201 +
202 + #[test]
203 + fn all_pk_table_uses_insert_or_ignore() {
204 + let mut conn = db();
205 + let o = apply(
206 + &mut conn,
207 + &[upsert("tagpair", "x", json!({"a":"x","b":"y"}))],
208 + );
209 + assert_eq!(o.applied, 1);
210 + // Re-applying the same all-PK row is a no-op, not an error.
211 + let o2 = apply(
212 + &mut conn,
213 + &[upsert("tagpair", "x", json!({"a":"x","b":"y"}))],
214 + );
215 + assert_eq!(o2.applied, 1); // executed, 0 rows changed, still Ok
216 + let n: i64 = conn
217 + .query_row("SELECT COUNT(*) FROM tagpair", [], |r| r.get(0))
218 + .unwrap();
219 + assert_eq!(n, 1);
220 + }
221 +
222 + #[test]
223 + fn partial_update_touches_only_set_columns() {
224 + let mut conn = db();
225 + conn.execute(
226 + "INSERT INTO items (id, is_read, is_starred, title) VALUES ('i1', 0, 0, 'keep')",
227 + [],
228 + )
229 + .unwrap();
230 + apply(
231 + &mut conn,
232 + &[ChangeEntry {
233 + op: ChangeOp::Update,
234 + ..upsert("items", "i1", json!({"id":"i1","is_read":1,"is_starred":0}))
235 + }],
236 + );
237 + let (read, title): (i64, String) = conn
238 + .query_row("SELECT is_read, title FROM items WHERE id='i1'", [], |r| {
239 + Ok((r.get(0)?, r.get(1)?))
240 + })
241 + .unwrap();
242 + assert_eq!(read, 1);
243 + assert_eq!(
244 + title, "keep",
245 + "partial update must not touch non-set columns"
246 + );
247 + }
248 +
249 + #[test]
250 + fn hard_delete_and_ignore_delete() {
251 + let mut conn = db();
252 + apply(
253 + &mut conn,
254 + &[upsert("parent", "p1", json!({"id":"p1","name":"a"}))],
255 + );
256 + let o = apply(&mut conn, &[delete("parent", "p1", json!({"id":"p1"}))]);
257 + assert_eq!(o.applied, 1);
258 + assert_eq!(
259 + conn.query_row("SELECT COUNT(*) FROM parent", [], |r| r.get::<_, i64>(0))
260 + .unwrap(),
261 + 0
262 + );
263 +
264 + // items ignore deletes.
265 + conn.execute(
266 + "INSERT INTO items (id, is_read, is_starred) VALUES ('i1', 1, 0)",
267 + [],
268 + )
269 + .unwrap();
270 + let o2 = apply(&mut conn, &[delete("items", "i1", json!({"id":"i1"}))]);
271 + assert_eq!(o2.applied, 0);
272 + assert_eq!(
273 + conn.query_row("SELECT COUNT(*) FROM items", [], |r| r.get::<_, i64>(0))
274 + .unwrap(),
275 + 1
276 + );
277 + }
278 +
279 + #[test]
280 + fn tombstone_delete_sets_column_and_keeps_earliest() {
281 + let mut conn = db();
282 + conn.execute("INSERT INTO samp (hash, name) VALUES ('h1', 's')", [])
283 + .unwrap();
284 + // A hashed table's delete carries the PK in data; the opaque row_id is ignored.
285 + apply(
286 + &mut conn,
287 + &[delete("samp", "OPAQUE_HASH", json!({"hash":"h1"}))],
288 + );
289 + let (present, del): (i64, Option<i64>) = conn
290 + .query_row(
291 + "SELECT COUNT(*), MAX(deleted_at) FROM samp WHERE hash='h1'",
292 + [],
293 + |r| Ok((r.get(0)?, r.get(1)?)),
294 + )
295 + .unwrap();
296 + assert_eq!(present, 1, "tombstone keeps the row");
297 + let first = del.unwrap();
298 + // Re-deleting keeps the earliest instant (COALESCE).
299 + apply(
300 + &mut conn,
301 + &[delete("samp", "OPAQUE_HASH", json!({"hash":"h1"}))],
302 + );
303 + let second: i64 = conn
304 + .query_row("SELECT deleted_at FROM samp WHERE hash='h1'", [], |r| {
305 + r.get(0)
306 + })
307 + .unwrap();
308 + assert_eq!(first, second);
309 + }
310 +
311 + #[test]
312 + fn exclude_where_guards_import_both_ways() {
313 + let mut conn = db();
314 + let o = apply(
315 + &mut conn,
316 + &[
317 + upsert(
318 + "cfg",
319 + "sync_cursor",
320 + json!({"key":"sync_cursor","value":"9"}),
321 + ), // excluded
322 + upsert("cfg", "theme", json!({"key":"theme","value":"dark"})), // included
323 + ],
324 + );
325 + assert_eq!(o.applied, 1);
326 + let keys: Vec<String> = {
327 + let mut s = conn.prepare("SELECT key FROM cfg ORDER BY key").unwrap();
328 + s.query_map([], |r| r.get(0))
329 + .unwrap()
330 + .map(|r| r.unwrap())
331 + .collect()
332 + };
333 + assert_eq!(keys, vec!["theme".to_string()]);
334 + // A hostile delete of an excluded key is also dropped.
335 + conn.execute(
336 + "INSERT INTO cfg (key, value) VALUES ('sync_secret', 'x')",
337 + [],
338 + )
339 + .unwrap();
340 + let o2 = apply(
341 + &mut conn,
342 + &[delete("cfg", "sync_secret", json!({"key":"sync_secret"}))],
343 + );
344 + assert_eq!(o2.applied, 0);
345 + assert_eq!(
346 + conn.query_row(
347 + "SELECT COUNT(*) FROM cfg WHERE key='sync_secret'",
348 + [],
349 + |r| r.get::<_, i64>(0)
350 + )
351 + .unwrap(),
352 + 1
353 + );
354 + }
355 +
356 + #[test]
357 + fn fk_ordering_parents_before_children_children_before_parents() {
358 + let mut conn = db();
359 + // Child listed before parent in the batch, but FK enforced, must still apply
360 + // because the engine orders upserts parents-first.
361 + let o = apply(
362 + &mut conn,
363 + &[
364 + upsert(
365 + "child",
366 + "c1",
367 + json!({"id":"c1","parent_id":"p1","note":"n"}),
368 + ),
369 + upsert("parent", "p1", json!({"id":"p1","name":"a"})),
370 + ],
371 + );
372 + assert_eq!(o.applied, 2);
373 + // Delete both; children-first ordering means the child goes before the parent.
374 + let o2 = apply(
375 + &mut conn,
376 + &[
377 + delete("parent", "p1", json!({"id":"p1"})),
378 + delete("child", "c1", json!({"id":"c1"})),
379 + ],
380 + );
381 + assert_eq!(o2.applied, 2);
382 + }
383 +
384 + #[test]
385 + fn references_unsynced_relaxes_fk() {
386 + let mut conn = db();
387 + // reffer.ext_id points at a ghost row that does not exist and is not synced.
388 + // Without FK relaxation this would be a constraint violation.
389 + let o = apply(
390 + &mut conn,
391 + &[upsert("reffer", "r1", json!({"id":"r1","ext_id":999}))],
392 + );
393 + assert_eq!(
394 + o.applied, 1,
395 + "references_unsynced disables FK for the apply"
396 + );
397 + // FK enforcement is restored afterward.
398 + let fk: i64 = conn
399 + .query_row("PRAGMA foreign_keys", [], |r| r.get(0))
400 + .unwrap();
401 + assert_eq!(fk, 1);
402 + }
403 +
404 + #[test]
405 + fn constraint_violation_is_skipped_not_fatal() {
406 + let mut conn = db();
407 + // First row violates FK (no parent p9); second is valid. Batch must not abort.
408 + let o = apply(
409 + &mut conn,
410 + &[
411 + upsert(
412 + "child",
413 + "bad",
414 + json!({"id":"bad","parent_id":"p9","note":"x"}),
415 + ),
416 + upsert("parent", "p1", json!({"id":"p1","name":"ok"})),
417 + ],
418 + );
419 + assert_eq!(o.applied, 1);
420 + assert_eq!(o.deferred.len(), 1, "the poison row is held, not lost");
421 + assert_eq!(
422 + conn.query_row("SELECT COUNT(*) FROM parent", [], |r| r.get::<_, i64>(0))
423 + .unwrap(),
424 + 1
425 + );
426 + }
427 +
428 + #[test]
429 + fn unknown_table_change_is_deferred_not_dropped() {
430 + let mut conn = db();
431 + let o = apply(&mut conn, &[upsert("nonexistent", "x", json!({"id":"x"}))]);
432 + assert_eq!(o.applied, 0);
433 + // Deferred rather than rejected: the table may exist after a client
434 + // upgrade, and then the held entry applies.
435 + assert_eq!(o.deferred.len(), 1);
436 + assert_eq!(o.deferred[0].table, "nonexistent");
437 + assert!(o.rejected.is_empty());
438 + }
439 +
440 + #[test]
441 + fn an_excluded_row_is_filtered_not_held() {
442 + let mut conn = db();
443 + // cfg's include predicate is "key NOT LIKE 'sync_%'", so a sync_ key is
444 + // excluded on import. That is policy, not failure, and must never reach
445 + // the dead-letter.
446 + let o = apply(
447 + &mut conn,
448 + &[upsert(
449 + "cfg",
450 + "sync_token",
451 + json!({"key":"sync_token","value":"x"}),
452 + )],
453 + );
454 + assert_eq!(o.applied, 0);
455 + assert_eq!(o.filtered, 1);
456 + assert!(o.deferred.is_empty());
457 + assert!(o.rejected.is_empty());
458 + }
459 +
460 + #[test]
461 + fn a_payloadless_upsert_is_rejected_not_deferred() {
462 + let mut conn = db();
463 + let mut change = upsert("parent", "p1", json!({"id":"p1"}));
464 + change.data = None;
465 + let o = apply(&mut conn, &[change]);
466 + assert_eq!(o.applied, 0);
467 + assert_eq!(
468 + o.rejected.len(),
469 + 1,
470 + "identical bytes would fail identically"
471 + );
472 + assert!(o.deferred.is_empty());
473 + }
474 +
475 + #[test]
476 + fn fk_sweep_catches_what_the_batch_wide_relaxation_hides() {
477 + let mut conn = db();
478 + // `reffer` declares references_unsynced, so the whole apply runs with
479 + // foreign_keys=OFF. Without the sweep, the child row below lands with a
480 + // missing parent and nothing is reported.
481 + let o = apply(
482 + &mut conn,
483 + &[
484 + upsert("reffer", "r1", json!({"id":"r1","ext_id":404})),
485 + upsert(
486 + "child",
487 + "c1",
488 + json!({"id":"c1","parent_id":"missing","note":"x"}),
489 + ),
490 + ],
491 + );
492 +
493 + assert_eq!(
494 + conn.query_row("SELECT COUNT(*) FROM child", [], |r| r.get::<_, i64>(0))
495 + .unwrap(),
496 + 0,
497 + "the orphan is removed, not left to resurface later"
498 + );
499 + assert_eq!(
500 + conn.query_row("SELECT COUNT(*) FROM reffer", [], |r| r.get::<_, i64>(0))
Lines truncated