max / synckit
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
- Claude-Session
- https://claude.ai/code/session_01EEmeiSJnmyL98QzA5Dwsvz
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(¬_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(¬_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