From 15056dc7df938eb3dc5a26dca9f180f6e58c3230 Mon Sep 17 00:00:00 2001 From: Jacob Hagstedt Date: Sat, 17 Oct 2026 22:27:23 +0200 Subject: [PATCH] M4: sync pull and push (E-31, E-32), audit writer and diff, FCM sender, push batching, scheduler, pruning, rotate-epoch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - src/sync/: pull with UNION ALL paging and cursor_ahead; push processing of SPEC §14.4.4 steps 1 to 12 in one BEGIN IMMEDIATE with a savepoint per mutation, LWW with delete-wins, tombstones, clock_skew, replay from sync_mutations, sync_batch for more than 3 notifiable changes; 90-day pruning. - src/audit/: SqlAuditWriter and the §14.4.7 diff (now the default in AppState::new). - src/push/: bounded PushHub (100, drop oldest), FCM HTTP v1 client with service-account JWT (RS256, jsonwebtoken on aws-lc-rs) and token cache, sender with 1/5/25 s retries and token clearing; disabled without credentials. - src/tasks/: start installs push and spawns the 06:00 generation (and at start) and 04:00 pruning loops on the injectable clock. - cli: rotate-epoch; serve uses into_make_service_with_connect_info; stale doc comments updated; /i/ empty code gets the HTML 404. - tests: sync_api, push_fcm (local stub of Google's endpoints), scheduler_daily; skeleton routes test now asserts no route is 501. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B --- server/Cargo.lock | 832 ++++++++++++- server/README.md | 36 +- server/crates/kvitt-server/Cargo.toml | 3 + server/crates/kvitt-server/src/api/mod.rs | 9 +- server/crates/kvitt-server/src/api/sync.rs | 57 +- server/crates/kvitt-server/src/audit/diff.rs | 229 ++++ server/crates/kvitt-server/src/audit/mod.rs | 48 +- server/crates/kvitt-server/src/cli/mod.rs | 36 +- .../kvitt-server/src/cli/rotate_epoch.rs | 41 +- server/crates/kvitt-server/src/lib.rs | 8 +- server/crates/kvitt-server/src/push/fcm.rs | 309 +++++ server/crates/kvitt-server/src/push/mod.rs | 15 +- server/crates/kvitt-server/src/push/queue.rs | 75 ++ server/crates/kvitt-server/src/push/sender.rs | 176 +++ server/crates/kvitt-server/src/router.rs | 11 +- server/crates/kvitt-server/src/sync/check.rs | 231 ++++ server/crates/kvitt-server/src/sync/mod.rs | 31 + server/crates/kvitt-server/src/sync/pull.rs | 120 ++ server/crates/kvitt-server/src/sync/push.rs | 807 +++++++++++++ server/crates/kvitt-server/src/sync/rows.rs | 160 +++ server/crates/kvitt-server/src/tasks/mod.rs | 83 +- .../kvitt-server/src/tasks/scheduler.rs | 116 ++ .../crates/kvitt-server/tests/push_fcm/fcm.rs | 466 ++++++++ .../kvitt-server/tests/push_fcm/main.rs | 17 + .../kvitt-server/tests/push_fcm/stub.rs | 170 +++ .../tests/scheduler_daily/main.rs | 16 + .../tests/scheduler_daily/scheduler.rs | 300 +++++ .../kvitt-server/tests/skeleton/routes.rs | 20 +- .../kvitt-server/tests/sync_api/audit.rs | 90 ++ .../kvitt-server/tests/sync_api/conflicts.rs | 173 +++ .../crates/kvitt-server/tests/sync_api/e2e.rs | 49 + .../crates/kvitt-server/tests/sync_api/m4.rs | 225 ++++ .../kvitt-server/tests/sync_api/main.rs | 24 + .../kvitt-server/tests/sync_api/offline.rs | 72 ++ .../kvitt-server/tests/sync_api/pull.rs | 227 ++++ .../kvitt-server/tests/sync_api/push_rules.rs | 1042 +++++++++++++++++ .../kvitt-server/tests/sync_api/serve.rs | 90 ++ 37 files changed, 6312 insertions(+), 102 deletions(-) create mode 100644 server/crates/kvitt-server/src/audit/diff.rs create mode 100644 server/crates/kvitt-server/src/push/fcm.rs create mode 100644 server/crates/kvitt-server/src/push/queue.rs create mode 100644 server/crates/kvitt-server/src/push/sender.rs create mode 100644 server/crates/kvitt-server/src/sync/check.rs create mode 100644 server/crates/kvitt-server/src/sync/mod.rs create mode 100644 server/crates/kvitt-server/src/sync/pull.rs create mode 100644 server/crates/kvitt-server/src/sync/push.rs create mode 100644 server/crates/kvitt-server/src/sync/rows.rs create mode 100644 server/crates/kvitt-server/src/tasks/scheduler.rs create mode 100644 server/crates/kvitt-server/tests/push_fcm/fcm.rs create mode 100644 server/crates/kvitt-server/tests/push_fcm/main.rs create mode 100644 server/crates/kvitt-server/tests/push_fcm/stub.rs create mode 100644 server/crates/kvitt-server/tests/scheduler_daily/main.rs create mode 100644 server/crates/kvitt-server/tests/scheduler_daily/scheduler.rs create mode 100644 server/crates/kvitt-server/tests/sync_api/audit.rs create mode 100644 server/crates/kvitt-server/tests/sync_api/conflicts.rs create mode 100644 server/crates/kvitt-server/tests/sync_api/e2e.rs create mode 100644 server/crates/kvitt-server/tests/sync_api/m4.rs create mode 100644 server/crates/kvitt-server/tests/sync_api/main.rs create mode 100644 server/crates/kvitt-server/tests/sync_api/offline.rs create mode 100644 server/crates/kvitt-server/tests/sync_api/pull.rs create mode 100644 server/crates/kvitt-server/tests/sync_api/push_rules.rs create mode 100644 server/crates/kvitt-server/tests/sync_api/serve.rs diff --git a/server/Cargo.lock b/server/Cargo.lock index bd073f8..9072358 100644 --- a/server/Cargo.lock +++ b/server/Cargo.lock @@ -68,7 +68,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -79,7 +79,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -103,6 +103,30 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "aws-lc-rs" +version = "1.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faac5829c2b74c28f830747e7818ccfb684261b5f48a1118b1e2a13d36dfab13" +dependencies = [ + "aws-lc-sys", + "untrusted 0.7.1", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1622d8446a2d4b2ce0c7eefc73dd43a99779028d5ee5c2dd8073a658ba8a2bc" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + [[package]] name = "axum" version = "0.8.9" @@ -231,6 +255,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6651c9ed80effdc7db0ff72512157f901af5e3549e341e24b1dd4887d836d838" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] @@ -240,6 +266,12 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + [[package]] name = "chacha20" version = "0.10.2" @@ -248,7 +280,7 @@ checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" dependencies = [ "cfg-if", "cpufeatures 0.3.1", - "rand_core", + "rand_core 0.10.1", ] [[package]] @@ -312,6 +344,15 @@ version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + [[package]] name = "cmov" version = "0.5.4" @@ -324,12 +365,32 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "combine" +version = "4.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4356eba6976435498d05a311ee9fc486da3bc81c200d3f0d7dd9703b41913f92" +dependencies = [ + "bytes", + "memchr", +] + [[package]] name = "const-oid" version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -421,6 +482,12 @@ dependencies = [ "cmov", ] +[[package]] +name = "deranged" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e" + [[package]] name = "digest" version = "0.10.7" @@ -460,6 +527,12 @@ version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + [[package]] name = "either" version = "1.19.0" @@ -482,7 +555,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -492,7 +565,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "de48cc4d1c1d97a20fd819def54b890cadde72ed3ad0c614822a0a433361be96" dependencies = [ "cfg-if", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -557,6 +630,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + [[package]] name = "futures-channel" version = "0.3.34" @@ -638,6 +717,19 @@ dependencies = [ "version_check", ] +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + [[package]] name = "getrandom" version = "0.4.3" @@ -645,9 +737,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi", - "rand_core", + "rand_core 0.10.1", + "wasm-bindgen", ] [[package]] @@ -777,6 +871,22 @@ dependencies = [ "pin-project-lite", "smallvec", "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", ] [[package]] @@ -785,13 +895,22 @@ version = "0.1.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" dependencies = [ + "base64 0.23.1", "bytes", + "futures-channel", + "futures-util", "http", "http-body", + "httparse", "hyper", + "ipnet", + "libc", + "percent-encoding", "pin-project-lite", + "socket2", "tokio", "tower-service", + "tracing", ] [[package]] @@ -960,6 +1079,12 @@ dependencies = [ "serde_core", ] +[[package]] +name = "ipnet" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -972,6 +1097,65 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + [[package]] name = "js-sys" version = "0.3.106" @@ -983,6 +1167,24 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "jsonwebtoken" +version = "11.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e75fe14a82d81e5f5af639997db37d8b96045938a7ac6ab18cdbe1c7467e05e1" +dependencies = [ + "aws-lc-rs", + "base64 0.22.1", + "getrandom 0.2.17", + "js-sys", + "pem", + "serde", + "serde_json", + "signature", + "simple_asn1", + "zeroize", +] + [[package]] name = "kvitt-domain" version = "0.1.0" @@ -1003,16 +1205,19 @@ dependencies = [ name = "kvitt-server" version = "0.1.0" dependencies = [ + "aws-lc-rs", "axum", "base64 0.23.1", "chrono", "chrono-tz", "clap", - "getrandom", + "getrandom 0.4.3", "http-body-util", "image", + "jsonwebtoken", "kvitt-domain", "percent-encoding", + "reqwest", "serde", "serde_json", "serde_norway", @@ -1072,6 +1277,12 @@ version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" +[[package]] +name = "lru-slab" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" + [[package]] name = "matchers" version = "0.2.0" @@ -1137,7 +1348,7 @@ checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" dependencies = [ "libc", "wasi", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -1156,7 +1367,32 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", ] [[package]] @@ -1180,6 +1416,12 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + [[package]] name = "parking" version = "2.2.1" @@ -1209,6 +1451,16 @@ dependencies = [ "windows-link", ] +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64 0.22.1", + "serde_core", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -1267,6 +1519,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958" + [[package]] name = "proc-macro2" version = "1.0.107" @@ -1288,6 +1546,63 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" +[[package]] +name = "quinn" +version = "0.11.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af66907df18639dcf4db56ca65490cabc4b27a97dbadd96f2926cca73298f016" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + [[package]] name = "quote" version = "1.0.47" @@ -1310,8 +1625,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" dependencies = [ "chacha20", - "getrandom", - "rand_core", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", ] [[package]] @@ -1320,6 +1644,15 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -1346,6 +1679,147 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "reqwest" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029" +dependencies = [ + "base64 0.23.1", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted 0.9.0", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustls" +version = "0.23.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" +dependencies = [ + "aws-lc-rs", + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a073f5dc7469f984c52ad2752b63b0807745133b6de880b7b64c1ac4c48aec4" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98" +dependencies = [ + "core-foundation", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f" + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted 0.9.0", +] + [[package]] name = "rustversion" version = "1.0.23" @@ -1358,12 +1832,59 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cb23c424d8ee841bb4d813677ca237e2a610067d594fec130f87d6093495ab5" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + [[package]] name = "serde" version = "1.0.229" @@ -1501,12 +2022,49 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core 0.6.4", +] + [[package]] name = "simd-adler32" version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "simple_asn1" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror", + "time", +] + [[package]] name = "siphasher" version = "1.0.4" @@ -1535,7 +2093,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -1739,6 +2297,12 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "2.0.119" @@ -1766,6 +2330,9 @@ name = "sync_wrapper" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] [[package]] name = "synstructure" @@ -1807,6 +2374,36 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tinystr" version = "0.8.4" @@ -1836,7 +2433,7 @@ dependencies = [ "signal-hook-registry", "socket2", "tokio-macros", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -1850,6 +2447,16 @@ dependencies = [ "syn 3.0.9", ] +[[package]] +name = "tokio-rustls" +version = "0.26.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "tokio-stream" version = "0.1.19" @@ -1890,6 +2497,24 @@ dependencies = [ "tracing", ] +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + [[package]] name = "tower-layer" version = "0.3.3" @@ -1977,6 +2602,12 @@ dependencies = [ "tracing-serde", ] +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "typenum" version = "1.20.1" @@ -2016,6 +2647,18 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39abd59bf32521c7f2301b52d05a6a2c975b6003521cbd0c6dc1582f0a22104" +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "url" version = "2.5.8" @@ -2070,7 +2713,7 @@ version = "1.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cc1186384beb7dd8eedea376413fd654937285ea6c9cfbb928dc3043ea4b606" dependencies = [ - "getrandom", + "getrandom 0.4.3", "js-sys", "serde_core", "wasm-bindgen", @@ -2094,6 +2737,25 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec4cdd0dd910afe868b7ef477227d8d538b46b3075031afee8a9f2acb0a2ed0b" +dependencies = [ + "try-lock", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -2113,6 +2775,17 @@ dependencies = [ "wasm-bindgen-shared", ] +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" +dependencies = [ + "js-sys", + "tokio", + "wasm-bindgen", +] + [[package]] name = "wasm-bindgen-macro" version = "0.2.129" @@ -2145,12 +2818,50 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "web-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "whoami" version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "626c4bac6755d76ffc12cb01b2eac751db1996b9e0041de9aa02c8c211ddc82c" +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "windows-core" version = "0.62.2" @@ -2210,6 +2921,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -2219,6 +2939,70 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + [[package]] name = "writeable" version = "0.6.4" @@ -2269,6 +3053,26 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "zerotrie" version = "0.2.5" diff --git a/server/README.md b/server/README.md index 9c58b2d..4ef2e95 100644 --- a/server/README.md +++ b/server/README.md @@ -48,18 +48,18 @@ database is then `$KVITT_DATA_DIR/kvitt.sqlite`. Every connection uses WAL, `for | Command | State in M2a | Exit codes | |---|---|---| -| `serve` | Works: migrations, `tasks::start`, HTTP server; SIGTERM drains in-flight requests for up to 10 s. | 0 on clean shutdown | +| `serve` | Works: migrations, `tasks::start` (push sender, scheduler, pruning), HTTP server with the client address in `ConnectInfo` (rate limiter); SIGTERM drains in-flight requests for up to 10 s. | 0 on clean shutdown | | `migrate` | Works: migrations only. | 0 | | `healthcheck` | Works: `GET http://127.0.0.1:/health`, used by the compose healthcheck (no curl in the image). | 0 if 200, else 1 | | `bootstrap` | Works (M3): cancels a pending `admin_setup` invite, prints a new code, link and expiry; `already initialized` on stderr when users exist. | 0 ok, 2 already initialized | | `pair-device --user ` | Works (M3): prints a `device` invite for the user like `bootstrap`. | 0 ok, 3 unknown user | -| `rotate-epoch` | Prints `not implemented in M2a` (M4). | 2 | +| `rotate-epoch` | Works (M4): sets `meta.sync_epoch` to a new random value and prints it (SPEC §10.9 restore step 5). | 0 ok, 1 database error | ## HTTP API Every route of SPEC §7 is registered in `src/router.rs`. `GET /health` (E-1) and `GET /setup/status` (E-2) are -implemented by M2a, E-3 to E-30 and E-33 by M3; E-31 and E-32 return 501 `not_implemented` until M4 fills in -`src/api/sync.rs`. The rules of SPEC §14.3.1 are layers in `router.rs`: +implemented by M2a, E-3 to E-30 and E-33 by M3, E-31 and E-32 by M4 (`src/api/sync.rs`, protocol in `src/sync/`). +`GET /i/` with an empty code answers the landing page's HTML 404. The rules of SPEC §14.3.1 are layers in `router.rs`: `X-Kvitt-Protocol: 1` on authenticated routes (426 otherwise, checked before the token), `X-Kvitt-Server-Version` on every response, body limits 1 MiB / 5 MiB for `PUT /me/picture`, JSON 404 for unknown paths. @@ -85,8 +85,11 @@ the wire contract makes `tests/openapi_contract.rs` fail against `contracts/open * **Recurring generation (M3, for M4's scheduler):** `api::recurring::generate_due_periods(&AppState, template_id)` generates the due periods of one template (SPEC §9.5); `api::recurring::generate_all_due(&AppState)` runs it for every non-deleted, non-paused template. -* **Background tasks (M4):** `src/tasks/mod.rs` has `pub async fn start(state: AppState) -> Result<(), AppError>`, - called once by `serve` after migrations. In M2a it starts nothing. +* **Background tasks (M4):** `src/tasks/mod.rs` has `pub async fn start(state: AppState) -> Result`, + called once by `serve` after migrations; it installs the push queue in the returned state (the router is built from + it) and spawns the FCM sender, the recurring scheduler (at start and daily 06:00 Europe/Stockholm, through + `generate_all_due`) and the `sync_mutations` pruning (daily 04:00). The scheduler loop (`tasks/scheduler.rs`) reads + the injectable `AppState.clock` and re-checks it at least every 30 s, so clock jumps are handled. * **Audit (`src/audit/mod.rs`, M4 implements, M3 and M9 call):** ```rust @@ -99,10 +102,12 @@ the wire contract makes `tests/openapi_contract.rs` fail against `contracts/open `AuditEvent` carries entity type and id, action, source, actor user and device, `client_at`, `server_at`, and the wire object before (`None` for create) and after the change as JSON; the writer derives `changes` per SPEC §14.4.7 and inserts the row with a `server_version` from the caller's `BEGIN IMMEDIATE` transaction (`conn`). - `AppState.audit` holds `NoopAuditWriter` until M4 replaces it. + `AppState.audit` holds `SqlAuditWriter` (M4; the diff is `audit::diff::changes`). * **Push (`src/push/mod.rs`, M4 implements, M3 calls):** `PushQueue::enqueue(&self, message: PushMessage)` with `PushMessage { push_type: PushType, entity_id: Option, actor_user_id: Uuid, count: u32 }` and the eight - types of SPEC §14.6. It never blocks or fails; call it after commit. `AppState.push` holds `NoopPushQueue`. + types of SPEC §14.6. It never blocks or fails; call it after commit. `AppState::new` holds `NoopPushQueue` (push + disabled); `tasks::start` replaces it with `PushHub` (bounded queue of 100) drained by `push::Sender` (FCM HTTP v1, + OAuth2 service-account JWT, retries 1 s / 5 s / 25 s) when `KVITT_FCM_CREDENTIALS` exists. * **Recurring dates (`kvitt-domain`, for M3's generation function and M4's scheduler):** `Period` (`YYYY-MM`, `FromStr`/`Display`, `next()`, `days()`, `clamped_date(day_of_month)`), `next_candidate_period(start_period, end_period, last_generated_period) -> Result, RecurrenceError>` @@ -117,12 +122,21 @@ From `server/`: `cargo fmt --check && cargo clippy --all-targets -- -D warnings Kotlin-only analytics files are parsed and skipped). * `crates/kvitt-server/tests/openapi_contract.rs`: the generated document equals `contracts/openapi.yaml` after the normalisations documented at the top of the file, plus self-tests that the comparison catches changes. -* `crates/kvitt-server/tests/skeleton/`: migration identity and pragmas, every route registered (501), E-1, E-2, +* `crates/kvitt-server/tests/skeleton/`: migration identity and pragmas, every route registered and implemented, E-1, E-2, protocol and token rules, error shapes against `contracts/examples/errors/`, wire types against every example in `contracts/examples/`. -M3 and M4 put their integration tests in `tests/core_*.rs`, `tests/sync_*.rs`, `tests/scheduler_*.rs`, -`tests/push_*.rs`. No `unwrap`/`expect` in non-test code (a crate-level `deny` enforces it under clippy). +* `crates/kvitt-server/tests/core_api/` (M3): E-2 to E-30, E-33, generation, rate limiting, pictures, CLI. +* `crates/kvitt-server/tests/sync_api/` (M4): every rule of SPEC §14.4 (pull, push steps 1 to 12, rejection reasons, + §9.10 conflicts, 14-day offline scenario, the push examples), audit writer, `rotate-epoch`, end to end push → pull, + `ConnectInfo` rate-limit buckets over real TCP. +* `crates/kvitt-server/tests/push_fcm/` (M4): payloads against `contracts/examples/push/`, recipients, OAuth2 flow + and token cache, retries, token clearing, queue overflow, push disabled, against a local stub of Google's endpoints + (an RSA key is generated per test; none is committed). +* `crates/kvitt-server/tests/scheduler_daily/` (M4): 06:00 and 04:00 across both DST changes, run at start, clock + jumps, catch-up through `generate_all_due`, paused and deleted templates skipped, 90-day pruning. + +The M4 binaries reuse M3's harness (`tests/core_api/common.rs`) and `tests/sync_api/m4.rs` through `#[path]`. No `unwrap`/`expect` in non-test code (a crate-level `deny` enforces it under clippy). ## Docker image diff --git a/server/crates/kvitt-server/Cargo.toml b/server/crates/kvitt-server/Cargo.toml index 2c499a4..ccdd2c8 100644 --- a/server/crates/kvitt-server/Cargo.toml +++ b/server/crates/kvitt-server/Cargo.toml @@ -24,8 +24,10 @@ chrono-tz = "0.10.4" clap = { version = "4.6.7", features = ["derive", "env"] } getrandom = "0.4.3" image = { version = "0.25.10", default-features = false, features = ["jpeg", "png", "webp"] } +jsonwebtoken = { version = "11.1.0", default-features = false, features = ["aws_lc_rs", "use_pem"] } kvitt-domain = { version = "0.1.0", path = "../kvitt-domain" } percent-encoding = "2.3.2" +reqwest = { version = "0.13.5", default-features = false, features = ["rustls", "json"] } serde = { version = "1.0.229", features = ["derive"] } serde_json = "1.0.154" sha2 = "0.11.1" @@ -40,5 +42,6 @@ utoipa = { version = "6.0.0", features = ["uuid"] } uuid = { version = "1.28.0", features = ["v7", "serde"] } [dev-dependencies] +aws-lc-rs = "1.18.2" http-body-util = "0.1.5" serde_norway = "0.9.42" diff --git a/server/crates/kvitt-server/src/api/mod.rs b/server/crates/kvitt-server/src/api/mod.rs index a341433..6f4cd5e 100644 --- a/server/crates/kvitt-server/src/api/mod.rs +++ b/server/crates/kvitt-server/src/api/mod.rs @@ -1,7 +1,7 @@ //! HTTP handlers, one file per area (SPEC §14.7), and the extractors and response types they share. //! -//! M2a implements `health` (E-1) and `setup` (E-2); every other handler is registered and annotated but returns 501 -//! `not_implemented` until M3 (E-3 to E-30, E-33) and M4 (E-31, E-32) fill in the bodies. +//! Every handler is implemented: `health` (E-1) and `setup` (E-2) by M2a, E-3 to E-30 and E-33 by M3, the sync +//! handlers E-31 and E-32 (`sync.rs`, protocol in `crate::sync`) by M4. pub mod accounts; pub mod apk; @@ -111,8 +111,3 @@ impl FromRequestParts for IdPath { .ok_or_else(AppError::not_found) } } - -/// The body of every handler that is registered but not yet implemented (501 `not_implemented`). -pub fn not_implemented() -> AppError { - AppError::not_implemented() -} diff --git a/server/crates/kvitt-server/src/api/sync.rs b/server/crates/kvitt-server/src/api/sync.rs index 2df21a7..f60117c 100644 --- a/server/crates/kvitt-server/src/api/sync.rs +++ b/server/crates/kvitt-server/src/api/sync.rs @@ -1,12 +1,17 @@ -//! Sync: E-31 pull and E-32 push (SPEC §14.4). -//! -//! Registered and annotated by M2a; M4 owns this file and implements the handlers (until then 501 -//! `not_implemented`). +//! Sync: E-31 pull and E-32 push (SPEC §14.4). The protocol itself is in `crate::sync`. -use crate::api::not_implemented; +use std::collections::HashMap; + +use axum::extract::{Query, State}; +use serde::Deserialize; +use serde_json::Value; + +use crate::AppState; +use crate::api::{ApiJson, JsonBody}; use crate::auth::AuthDevice; use crate::error::AppError; use crate::openapi::responses::{BadRequest, Conflict, PayloadTooLarge, Unprocessable}; +use crate::sync; use crate::wire::{SyncLimitSchema, SyncPullResponse, SyncPushRequest, SyncPushResponse}; #[utoipa::path( @@ -25,8 +30,36 @@ use crate::wire::{SyncLimitSchema, SyncPullResponse, SyncPushRequest, SyncPushRe (status = 409, response = Conflict), ) )] -pub async fn sync_pull(_device: AuthDevice) -> AppError { - not_implemented() +pub async fn sync_pull( + _device: AuthDevice, + State(state): State, + Query(query): Query>, +) -> Result, AppError> { + let int = |key: &str| -> Result, AppError> { + query + .get(key) + .map(|v| { + v.parse::().map_err(|_| { + AppError::invalid_request(format!("{key} must be an integer")).with_field(key) + }) + }) + .transpose() + }; + let since = int("since")? + .ok_or_else(|| AppError::invalid_request("since is required").with_field("since"))?; + if since < 0 { + return Err(AppError::invalid_request("since must be 0 or more").with_field("since")); + } + let limit = sync::pull::page_size(int("limit")?)?; + Ok(JsonBody(sync::pull::pull(&state, since, limit).await?)) +} + +/// The E-32 body as the handler reads it: the mutations stay raw JSON so that a malformed mutation is rejected on +/// its own (`validation_failed`, SPEC §14.4.4 step 3) instead of failing the request. The schema in the OpenAPI +/// document is [`SyncPushRequest`]. +#[derive(Debug, Deserialize)] +pub struct RawPushRequest { + pub mutations: Vec, } #[utoipa::path( @@ -44,6 +77,12 @@ pub async fn sync_pull(_device: AuthDevice) -> AppError { (status = 422, response = Unprocessable), ) )] -pub async fn sync_push(_device: AuthDevice) -> AppError { - not_implemented() +pub async fn sync_push( + device: AuthDevice, + State(state): State, + ApiJson(body): ApiJson, +) -> Result, AppError> { + Ok(JsonBody( + sync::push::push(&state, &device, body.mutations).await?, + )) } diff --git a/server/crates/kvitt-server/src/audit/diff.rs b/server/crates/kvitt-server/src/audit/diff.rs new file mode 100644 index 0000000..d305b16 --- /dev/null +++ b/server/crates/kvitt-server/src/audit/diff.rs @@ -0,0 +1,229 @@ +//! The audit field diff of SPEC §14.4.7, computed from the wire objects (`Expense`, `Settlement` as JSON) before and +//! after a change. + +use serde_json::Value; + +use crate::wire::{AuditAction, AuditChange, EntityType}; + +/// Diff fields of an expense, in the order of the SPEC §14.4.7 table. +pub const EXPENSE_FIELDS: [&str; 9] = [ + "account_id", + "category_id", + "payer_user_id", + "amount_ore", + "description", + "notes", + "expense_date", + "split_mode", + "splits", +]; + +/// Diff fields of a settlement, in the order of the SPEC §14.4.7 table. +pub const SETTLEMENT_FIELDS: [&str; 6] = [ + "from_user_id", + "to_user_id", + "amount_ore", + "settlement_date", + "note", + "allocations", +]; + +/// The field of a `delete` change (both entity types). +pub const DELETED_AT: &str = "deleted_at"; + +/// The diff fields of `entity_type`, in table order. +pub fn fields(entity_type: EntityType) -> &'static [&'static str] { + match entity_type { + EntityType::Expense => &EXPENSE_FIELDS, + EntityType::Settlement => &SETTLEMENT_FIELDS, + } +} + +/// The value of `field` in `object` in its diff form: arrays `splits` sorted by `user_id` and `allocations` by +/// `account_id` (SPEC §14.4.7); a missing field is `null`. +pub fn value_of(object: &Value, field: &str) -> Value { + let value = object.get(field).cloned().unwrap_or(Value::Null); + let sort_key = match field { + "splits" => "user_id", + "allocations" => "account_id", + _ => return value, + }; + match value { + Value::Array(mut items) => { + items.sort_by(|a, b| { + let ka = a.get(sort_key).and_then(Value::as_str).unwrap_or(""); + let kb = b.get(sort_key).and_then(Value::as_str).unwrap_or(""); + ka.cmp(kb) + }); + Value::Array(items) + } + other => other, + } +} + +/// The `changes` of one audit entry (SPEC §14.4.7): +/// +/// * `create`: one change per diff field, `old` null, in table order (`before` is ignored); +/// * `update`: one change per diff field whose JSON value differs, in table order; +/// * `delete`: exactly the `deleted_at` change. +pub fn changes( + entity_type: EntityType, + action: AuditAction, + before: Option<&Value>, + after: &Value, +) -> Vec { + let null = Value::Null; + match action { + AuditAction::Create => fields(entity_type) + .iter() + .map(|field| AuditChange { + field: (*field).to_owned(), + old: Value::Null, + new: value_of(after, field), + }) + .collect(), + AuditAction::Update => { + let before = before.unwrap_or(&null); + fields(entity_type) + .iter() + .filter_map(|field| { + let old = value_of(before, field); + let new = value_of(after, field); + (old != new).then(|| AuditChange { + field: (*field).to_owned(), + old, + new, + }) + }) + .collect() + } + AuditAction::Delete => vec![AuditChange { + field: DELETED_AT.to_owned(), + old: before.map_or(Value::Null, |b| value_of(b, DELETED_AT)), + new: value_of(after, DELETED_AT), + }], + } +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + fn expense() -> Value { + json!({ + "id": "e", "account_id": "a1", "category_id": "c1", "amount_ore": 41200, "description": "ICA Maxi", + "notes": "", "expense_date": "2026-10-16", "payer_user_id": "u1", "split_mode": "default", + "splits": [ + {"user_id": "u2", "share_ore": 20600, "share_bp": 5000}, + {"user_id": "u1", "share_ore": 20600, "share_bp": 5000} + ], + "recurring_template_id": null, "recurring_period": null, "created_by_user_id": "u1", + "updated_by_user_id": "u1", "created_at": "t", "updated_at": "t", "deleted_at": null, "server_version": 9 + }) + } + + fn settlement() -> Value { + json!({ + "id": "s", "from_user_id": "u2", "to_user_id": "u1", "amount_ore": 50000, "settlement_date": "2026-10-16", + "note": "", "allocations": [ + {"account_id": "a2", "amount_ore": 20000}, {"account_id": "a1", "amount_ore": 30000} + ], + "created_by_user_id": "u2", "updated_by_user_id": "u2", "created_at": "t", "updated_at": "t", + "deleted_at": null, "server_version": 9 + }) + } + + #[test] + fn create_lists_every_field_in_table_order_with_old_null() { + let c = changes(EntityType::Expense, AuditAction::Create, None, &expense()); + let names: Vec<_> = c.iter().map(|c| c.field.as_str()).collect(); + assert_eq!(names, EXPENSE_FIELDS); + assert!(c.iter().all(|c| c.old.is_null())); + assert_eq!(c[3].new, json!(41200)); + // Splits sorted by user_id. + assert_eq!(c[8].new[0]["user_id"], "u1"); + let c = changes( + EntityType::Settlement, + AuditAction::Create, + None, + &settlement(), + ); + let names: Vec<_> = c.iter().map(|c| c.field.as_str()).collect(); + assert_eq!(names, SETTLEMENT_FIELDS); + assert_eq!(c[5].new[0]["account_id"], "a1"); + assert!(!names.contains(&"deleted_at")); + } + + #[test] + fn update_lists_only_differing_fields_in_table_order() { + let before = expense(); + let mut after = expense(); + after["notes"] = json!("x"); + after["amount_ore"] = json!(41300); + after["splits"][0]["share_ore"] = json!(20700); + // Not diff fields: ignored. + after["updated_at"] = json!("t2"); + after["server_version"] = json!(10); + let c = changes( + EntityType::Expense, + AuditAction::Update, + Some(&before), + &after, + ); + let names: Vec<_> = c.iter().map(|c| c.field.as_str()).collect(); + assert_eq!(names, ["amount_ore", "notes", "splits"]); + assert_eq!(c[0].old, json!(41200)); + assert_eq!(c[0].new, json!(41300)); + assert_eq!(c[1].old, json!("")); + } + + #[test] + fn update_ignores_array_order_and_detects_nothing_for_equal_rows() { + let before = settlement(); + let mut after = settlement(); + after["allocations"] = json!([ + {"account_id": "a1", "amount_ore": 30000}, {"account_id": "a2", "amount_ore": 20000} + ]); + assert!( + changes( + EntityType::Settlement, + AuditAction::Update, + Some(&before), + &after + ) + .is_empty() + ); + after["note"] = json!("Swish"); + let c = changes( + EntityType::Settlement, + AuditAction::Update, + Some(&before), + &after, + ); + assert_eq!(c.len(), 1); + assert_eq!( + (c[0].field.as_str(), &c[0].old, &c[0].new), + ("note", &json!(""), &json!("Swish")) + ); + } + + #[test] + fn delete_is_exactly_the_deleted_at_change() { + let before = expense(); + let mut after = expense(); + after["deleted_at"] = json!("2026-10-17T08:00:00.000Z"); + after["notes"] = json!("ignored"); + let c = changes( + EntityType::Expense, + AuditAction::Delete, + Some(&before), + &after, + ); + assert_eq!( + serde_json::to_value(&c).unwrap(), + json!([{"field": "deleted_at", "old": null, "new": "2026-10-17T08:00:00.000Z"}]) + ); + } +} diff --git a/server/crates/kvitt-server/src/audit/mod.rs b/server/crates/kvitt-server/src/audit/mod.rs index e948a7f..30f445c 100644 --- a/server/crates/kvitt-server/src/audit/mod.rs +++ b/server/crates/kvitt-server/src/audit/mod.rs @@ -1,5 +1,5 @@ -//! The audit log seam (SPEC §14.4.7). M2a defines the trait and a no-op; M4 owns `src/audit/**` and replaces the -//! implementation with one that computes the field diff and inserts the `audit_log` row. +//! The audit log (SPEC §14.4.7, FR-12). M2a defined the trait; M4 owns `src/audit/**` and implements it with +//! [`SqlAuditWriter`], which computes the field diff ([`diff::changes`]) and inserts the `audit_log` row. //! //! Callers (sync push in M4, recurring generation in E-28 and the scheduler, the importer in M9) call //! [`AuditWriter::write`] inside their own `BEGIN IMMEDIATE` transaction, passing the wire object before and after @@ -12,9 +12,12 @@ use std::pin::Pin; use sqlx::SqliteConnection; use uuid::Uuid; +use crate::db; use crate::error::AppError; use crate::wire::{AuditAction, AuditSource, EntityType}; +pub mod diff; + /// A boxed, sendable future (object-safe async trait methods). pub type BoxFuture<'a, T> = Pin + Send + 'a>>; @@ -48,16 +51,45 @@ pub trait AuditWriter: Send + Sync + 'static { ) -> BoxFuture<'a, Result>; } -/// Writes nothing. Used until M4 lands its writer. +/// The real writer: derives `changes` per SPEC §14.4.7 and appends one `audit_log` row (never updated or deleted, +/// AC-12.1) with a UUIDv7 id and a `server_version` taken on the caller's transaction. #[derive(Debug, Clone, Copy, Default)] -pub struct NoopAuditWriter; +pub struct SqlAuditWriter; -impl AuditWriter for NoopAuditWriter { +impl AuditWriter for SqlAuditWriter { fn write<'a>( &'a self, - _conn: &'a mut SqliteConnection, - _event: AuditEvent, + conn: &'a mut SqliteConnection, + event: AuditEvent, ) -> BoxFuture<'a, Result> { - Box::pin(async { Ok(Uuid::now_v7()) }) + Box::pin(async move { + let changes = diff::changes( + event.entity_type, + event.action, + event.before.as_ref(), + &event.after, + ); + let changes_json = serde_json::to_string(&changes).map_err(AppError::internal)?; + let id = Uuid::now_v7(); + let version = db::next_server_version(&mut *conn).await?; + sqlx::query( + "INSERT INTO audit_log (id, entity_type, entity_id, action, source, actor_user_id, actor_device_id, \ + client_at, server_at, changes, server_version) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + ) + .bind(id.to_string()) + .bind(event.entity_type.as_str()) + .bind(event.entity_id.to_string()) + .bind(event.action.as_str()) + .bind(event.source.as_str()) + .bind(event.actor_user_id.to_string()) + .bind(event.actor_device_id.map(|d| d.to_string())) + .bind(&event.client_at) + .bind(&event.server_at) + .bind(changes_json) + .bind(version) + .execute(&mut *conn) + .await?; + Ok(id) + }) } } diff --git a/server/crates/kvitt-server/src/cli/mod.rs b/server/crates/kvitt-server/src/cli/mod.rs index 83bb215..a9efb1e 100644 --- a/server/crates/kvitt-server/src/cli/mod.rs +++ b/server/crates/kvitt-server/src/cli/mod.rs @@ -1,15 +1,18 @@ -//! `kvitt-server` subcommands (SPEC §10.4, exhaustive): `serve`, `migrate` and `healthcheck` are implemented here -//! by M2a; `bootstrap` and `pair-device` belong to M3, `rotate-epoch` to M4, and until then print -//! `not implemented in M2a` and exit 2. +//! `kvitt-server` subcommands (SPEC §10.4, exhaustive), all implemented: `serve`, `migrate` and `healthcheck` here, +//! `bootstrap` and `pair-device` (M3) and `rotate-epoch` (M4) in their own files. pub mod bootstrap; pub mod healthcheck; pub mod pair_device; pub mod rotate_epoch; +use std::net::SocketAddr; use std::process::ExitCode; use std::time::Duration; +use axum::Router; +use axum::extract::connect_info::IntoMakeServiceWithConnectInfo; + use clap::{Parser, Subcommand}; use tokio::net::TcpListener; use tokio::sync::Notify; @@ -21,9 +24,6 @@ use crate::{AppState, db, router, tasks}; /// How long `serve` lets in-flight requests finish after SIGTERM (SPEC §10.4). pub const SHUTDOWN_DRAIN: Duration = Duration::from_secs(10); -/// Exit code of a subcommand that is not implemented yet. -pub const EXIT_NOT_IMPLEMENTED: u8 = 2; - #[derive(Debug, Parser)] #[command( name = "kvitt-server", @@ -75,12 +75,6 @@ pub async fn run(command: Command, config: Config) -> ExitCode { } } -/// Prints the M2a placeholder message and returns exit code 2. -pub fn not_implemented(command: &str) -> ExitCode { - eprintln!("{command}: not implemented in M2a"); - ExitCode::from(EXIT_NOT_IMPLEMENTED) -} - /// Opens (creating if needed) the data directory and the database, and runs the migrations. pub async fn open_database(config: &Config) -> Result { std::fs::create_dir_all(&config.data_dir).map_err(|e| { @@ -100,15 +94,9 @@ async fn migrate(config: Config) -> Result<(), AppError> { async fn serve(config: Config) -> Result<(), AppError> { let pool = open_database(&config).await?; - if !config.fcm_credentials.exists() { - tracing::warn!( - path = %config.fcm_credentials.display(), - "FCM credentials file missing: push is disabled" - ); - } let bind = config.bind; - let state = AppState::new(config, pool.clone()); - tasks::start(state.clone()).await?; + // `tasks::start` installs the push queue (or logs once that push is disabled) and starts the scheduler. + let state = tasks::start(AppState::new(config, pool.clone())).await?; let listener = TcpListener::bind(bind) .await @@ -120,7 +108,7 @@ async fn serve(config: Config) -> Result<(), AppError> { let shutdown = shutdown.clone(); async move { shutdown.notified().await } }; - let server = axum::serve(listener, router::router(state)).with_graceful_shutdown(graceful); + let server = axum::serve(listener, make_service(state)).with_graceful_shutdown(graceful); let mut server = tokio::spawn(async move { server.await }); tokio::select! { @@ -142,6 +130,12 @@ async fn serve(config: Config) -> Result<(), AppError> { } } +/// The router as served: with the TCP peer address in `ConnectInfo`, which the rate limiter keys +/// unauthenticated requests by (SPEC §8.6). +pub fn make_service(state: AppState) -> IntoMakeServiceWithConnectInfo { + router::router(state).into_make_service_with_connect_info::() +} + async fn finish( result: Result, tokio::task::JoinError>, pool: &db::Db, diff --git a/server/crates/kvitt-server/src/cli/rotate_epoch.rs b/server/crates/kvitt-server/src/cli/rotate_epoch.rs index ee470b8..d53d158 100644 --- a/server/crates/kvitt-server/src/cli/rotate_epoch.rs +++ b/server/crates/kvitt-server/src/cli/rotate_epoch.rs @@ -1,11 +1,44 @@ -//! `kvitt-server rotate-epoch` (SPEC §10.9). M4 owns this file; until then it prints `not implemented in M2a` and -//! exits 2. +//! `kvitt-server rotate-epoch` (SPEC §10.4, §10.9 restore step 5, §14.4.8): sets `meta.sync_epoch` to a new +//! `lower(hex(randomblob(16)))`, so phones detect the restore and re-push what they hold. Exit 0; 1 on a database +//! error. use std::process::ExitCode; use crate::config::Config; +use crate::db::{self, Db}; +use crate::error::AppError; + +/// Replaces the epoch and returns the new value. +pub async fn rotate(db: &Db) -> Result { + let mut tx = db::begin_immediate(db).await?; + let epoch: String = sqlx::query_scalar( + "UPDATE meta SET value = lower(hex(randomblob(16))) WHERE key = 'sync_epoch' RETURNING value", + ) + .fetch_optional(&mut *tx) + .await? + .ok_or_else(|| AppError::internal("meta.sync_epoch is missing"))?; + tx.commit().await?; + Ok(epoch) +} pub async fn run(config: &Config) -> ExitCode { - let _ = config; - super::not_implemented("rotate-epoch") + let result = async { + let pool = super::open_database(config).await?; + let epoch = rotate(&pool).await; + pool.close().await; + epoch + } + .await; + match result { + Ok(epoch) => { + tracing::info!("sync_epoch rotated"); + println!("sync_epoch rotated: {epoch}"); + ExitCode::SUCCESS + } + Err(e) => { + tracing::error!(error_code = e.code.as_str(), detail = %e.message, "rotate-epoch failed"); + eprintln!("rotate-epoch: {e}"); + ExitCode::FAILURE + } + } } diff --git a/server/crates/kvitt-server/src/lib.rs b/server/crates/kvitt-server/src/lib.rs index 4251757..3e3eb8e 100644 --- a/server/crates/kvitt-server/src/lib.rs +++ b/server/crates/kvitt-server/src/lib.rs @@ -16,11 +16,12 @@ pub mod error; pub mod openapi; pub mod push; pub mod router; +pub mod sync; pub mod tasks; pub mod time; pub mod wire; -use audit::{AuditWriter, NoopAuditWriter}; +use audit::{AuditWriter, SqlAuditWriter}; use config::Config; use db::Db; use push::{NoopPushQueue, PushQueue}; @@ -40,13 +41,14 @@ pub struct AppState { } impl AppState { - /// State with the system clock and the default audit writer and push queue (no-ops until M4). + /// State with the system clock, the real audit writer and no push (push is installed by `tasks::start`, which + /// needs the FCM credentials; without them push stays disabled). pub fn new(config: Config, db: Db) -> Self { Self { config: Arc::new(config), db, clock: Arc::new(SystemClock), - audit: Arc::new(NoopAuditWriter), + audit: Arc::new(SqlAuditWriter), push: Arc::new(NoopPushQueue), } } diff --git a/server/crates/kvitt-server/src/push/fcm.rs b/server/crates/kvitt-server/src/push/fcm.rs new file mode 100644 index 0000000..631d202 --- /dev/null +++ b/server/crates/kvitt-server/src/push/fcm.rs @@ -0,0 +1,309 @@ +//! The FCM HTTP v1 client (SPEC §14.6, §6.3): data-only messages, OAuth2 access tokens from a service-account JWT +//! (RS256) exchanged at Google's token endpoint and cached until 5 minutes before expiry. +//! +//! Endpoints are code constants; [`Endpoints`] exists so tests can point the client at a local stub server. + +use std::path::Path; +use std::sync::Arc; +use std::time::Duration as StdDuration; + +use chrono::{DateTime, Duration, Utc}; +use jsonwebtoken::{Algorithm, EncodingKey, Header}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use tokio::sync::Mutex; + +use super::{PushMessage, PushType}; +use crate::error::AppError; +use crate::time::Clock; + +/// Google's OAuth2 token endpoint. +pub const TOKEN_URL: &str = "https://oauth2.googleapis.com/token"; +/// The FCM API host; the send URL is `/v1/projects//messages:send`. +pub const SEND_BASE_URL: &str = "https://fcm.googleapis.com"; +/// The OAuth2 scope of the HTTP v1 API. +pub const SCOPE: &str = "https://www.googleapis.com/auth/firebase.messaging"; +/// The JWT bearer grant type. +pub const GRANT_TYPE: &str = "urn:ietf:params:oauth:grant-type:jwt-bearer"; +/// JWT lifetime: Google's maximum, one hour. +pub const JWT_LIFETIME: Duration = Duration::hours(1); +/// A cached access token is replaced this long before it expires. +pub const REFRESH_MARGIN: Duration = Duration::minutes(5); +/// Per-request timeout of the HTTP client. +pub const HTTP_TIMEOUT: StdDuration = StdDuration::from_secs(30); +/// `android.ttl` of every message (SPEC §14.6). +pub const MESSAGE_TTL: &str = "86400s"; +/// Payload version (`data.v`). +pub const PAYLOAD_VERSION: &str = "1"; + +/// Where the client sends. [`Endpoints::google`] in production. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Endpoints { + pub token_url: String, + pub send_base_url: String, +} + +impl Endpoints { + pub fn google() -> Self { + Self { + token_url: TOKEN_URL.to_owned(), + send_base_url: SEND_BASE_URL.to_owned(), + } + } +} + +/// The fields of a service-account key file the client uses. +#[derive(Clone, Deserialize)] +pub struct ServiceAccount { + pub client_email: String, + pub private_key: String, + #[serde(default)] + pub private_key_id: Option, +} + +impl std::fmt::Debug for ServiceAccount { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + // Never print the key. + f.debug_struct("ServiceAccount") + .field("client_email", &self.client_email) + .finish_non_exhaustive() + } +} + +#[derive(Serialize)] +struct Claims<'a> { + iss: &'a str, + scope: &'a str, + aud: &'a str, + iat: i64, + exp: i64, +} + +#[derive(Deserialize)] +struct TokenResponse { + access_token: String, + expires_in: i64, +} + +/// The result of one send attempt. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SendOutcome { + /// 2xx. + Sent, + /// `UNREGISTERED` or HTTP 404: the token is dead; clear it. + Unregistered, + /// 429, 5xx or a network error: try again. + Retry(String), + /// Anything else: give up on this message for this device. + Failed(String), +} + +/// The `entity_type` of a push type (SPEC §14.6 table). +pub fn entity_type_of(push_type: PushType) -> &'static str { + match push_type { + PushType::ExpenseCreated + | PushType::ExpenseUpdated + | PushType::ExpenseDeleted + | PushType::RecurringExpenseCreated => "expense", + PushType::SettlementCreated | PushType::SettlementUpdated | PushType::SettlementDeleted => { + "settlement" + } + PushType::SyncBatch => "", + } +} + +/// The HTTP v1 request body for one device (SPEC §14.6): data only, every value a string, ids only (AC-13.8). +pub fn message_body(fcm_token: &str, message: &PushMessage) -> Value { + json!({ + "message": { + "token": fcm_token, + "android": { "priority": "high", "ttl": MESSAGE_TTL }, + "data": { + "v": PAYLOAD_VERSION, + "type": message.push_type.as_str(), + "entity_type": entity_type_of(message.push_type), + "entity_id": message.entity_id.map(|id| id.to_string()).unwrap_or_default(), + "actor_user_id": message.actor_user_id.to_string(), + "count": message.count.to_string(), + } + } + }) +} + +/// Whether an FCM error body carries `errorCode` `UNREGISTERED` in a `google.firebase.fcm.v1.FcmError` detail. +pub fn is_unregistered(body: &Value) -> bool { + body.pointer("/error/details") + .and_then(Value::as_array) + .is_some_and(|details| { + details + .iter() + .any(|d| d.get("errorCode").and_then(Value::as_str) == Some("UNREGISTERED")) + }) +} + +/// Sends FCM HTTP v1 messages for one project. +pub struct FcmClient { + http: reqwest::Client, + account: ServiceAccount, + key: EncodingKey, + project_id: String, + endpoints: Endpoints, + clock: Arc, + cache: Mutex)>>, +} + +impl FcmClient { + /// A client from the service-account JSON (`key_json`). + pub fn new( + key_json: &str, + project_id: &str, + endpoints: Endpoints, + clock: Arc, + ) -> Result { + let account: ServiceAccount = serde_json::from_str(key_json).map_err(|e| { + AppError::internal(format!( + "FCM credentials are not a service-account key: {e}" + )) + })?; + let key = EncodingKey::from_rsa_pem(account.private_key.as_bytes()) + .map_err(|e| AppError::internal(format!("FCM credentials: private_key: {e}")))?; + let http = reqwest::Client::builder() + .timeout(HTTP_TIMEOUT) + .build() + .map_err(|e| AppError::internal(format!("HTTP client: {e}")))?; + Ok(Self { + http, + account, + key, + project_id: project_id.to_owned(), + endpoints, + clock, + cache: Mutex::new(None), + }) + } + + /// A client from the key file at `path`; `Ok(None)` when the file does not exist (push disabled). + pub fn from_file( + path: &Path, + project_id: &str, + endpoints: Endpoints, + clock: Arc, + ) -> Result, AppError> { + let text = match std::fs::read_to_string(path) { + Ok(text) => text, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(e) => { + return Err(AppError::internal(format!( + "cannot read FCM credentials {}: {e}", + path.display() + ))); + } + }; + Self::new(&text, project_id, endpoints, clock).map(Some) + } + + /// `/v1/projects//messages:send`. + pub fn send_url(&self) -> String { + format!( + "{}/v1/projects/{}/messages:send", + self.endpoints.send_base_url, self.project_id + ) + } + + fn assertion(&self, now: DateTime) -> Result { + let mut header = Header::new(Algorithm::RS256); + header.kid = self.account.private_key_id.clone(); + let claims = Claims { + iss: &self.account.client_email, + scope: SCOPE, + aud: TOKEN_URL, + iat: now.timestamp(), + exp: (now + JWT_LIFETIME).timestamp(), + }; + jsonwebtoken::encode(&header, &claims, &self.key) + .map_err(|e| format!("JWT signing failed: {e}")) + } + + /// A valid access token, from the cache or a new exchange. + async fn access_token(&self) -> Result { + let now = self.clock.now(); + let mut cache = self.cache.lock().await; + if let Some((token, expires)) = cache.as_ref() + && now < *expires - REFRESH_MARGIN + { + return Ok(token.clone()); + } + let assertion = self.assertion(now).map_err(SendOutcome::Failed)?; + let grant: String = + percent_encoding::utf8_percent_encode(GRANT_TYPE, percent_encoding::NON_ALPHANUMERIC) + .collect(); + let response = self + .http + .post(&self.endpoints.token_url) + .header("content-type", "application/x-www-form-urlencoded") + .body(format!("grant_type={grant}&assertion={assertion}")) + .send() + .await + .map_err(|e| SendOutcome::Retry(format!("token request: {e}")))?; + let status = response.status(); + if !status.is_success() { + let detail = format!("token endpoint answered {}", status.as_u16()); + return Err(if status.as_u16() == 429 || status.is_server_error() { + SendOutcome::Retry(detail) + } else { + SendOutcome::Failed(detail) + }); + } + let body: TokenResponse = response + .json() + .await + .map_err(|e| SendOutcome::Retry(format!("token response: {e}")))?; + let expires = now + Duration::seconds(body.expires_in); + *cache = Some((body.access_token.clone(), expires)); + Ok(body.access_token) + } + + /// Forgets the cached access token (after a 401). + async fn forget_token(&self) { + *self.cache.lock().await = None; + } + + /// One send attempt of `body` (from [`message_body`]). + pub async fn send(&self, body: &Value) -> SendOutcome { + let token = match self.access_token().await { + Ok(token) => token, + Err(outcome) => return outcome, + }; + let response = match self + .http + .post(self.send_url()) + .bearer_auth(token) + .json(body) + .send() + .await + { + Ok(response) => response, + Err(e) => return SendOutcome::Retry(format!("send: {e}")), + }; + let status = response.status(); + if status.is_success() { + return SendOutcome::Sent; + } + let code = status.as_u16(); + let error: Value = response.json().await.unwrap_or(Value::Null); + if code == 404 || is_unregistered(&error) { + return SendOutcome::Unregistered; + } + if code == 429 || status.is_server_error() { + return SendOutcome::Retry(format!("FCM answered {code}")); + } + if code == 401 { + self.forget_token().await; + } + let status_text = error + .pointer("/error/status") + .and_then(Value::as_str) + .unwrap_or(""); + SendOutcome::Failed(format!("FCM answered {code} {status_text}")) + } +} diff --git a/server/crates/kvitt-server/src/push/mod.rs b/server/crates/kvitt-server/src/push/mod.rs index 963537f..4fb3e35 100644 --- a/server/crates/kvitt-server/src/push/mod.rs +++ b/server/crates/kvitt-server/src/push/mod.rs @@ -1,11 +1,20 @@ -//! The push notification seam (SPEC §14.6). M2a defines the trait and a no-op; M4 owns `src/push/**` and replaces -//! the implementation with the bounded queue and FCM sender. +//! Push notifications (SPEC §14.6). M2a defined the trait; M4 owns `src/push/**` and implements it with the bounded +//! queue [`PushHub`] drained by the FCM [`Sender`] (started by `tasks::start`). [`NoopPushQueue`] remains for push +//! disabled (no credentials file). //! //! [`PushQueue::enqueue`] never blocks and never fails the caller: sending happens on a background task and never //! delays an HTTP response (SPEC §14.6). Call it after the write transaction has committed. use uuid::Uuid; +pub mod fcm; +pub mod queue; +pub mod sender; + +pub use fcm::{Endpoints, FcmClient}; +pub use queue::PushHub; +pub use sender::{RetryPolicy, Sender}; + /// The notification types of SPEC §14.6 (exhaustive). #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum PushType { @@ -52,7 +61,7 @@ pub trait PushQueue: Send + Sync + 'static { fn enqueue(&self, message: PushMessage); } -/// Drops every message. Used until M4 lands the sender, and when push is disabled. +/// Drops every message: push disabled (no FCM credentials). #[derive(Debug, Clone, Copy, Default)] pub struct NoopPushQueue; diff --git a/server/crates/kvitt-server/src/push/queue.rs b/server/crates/kvitt-server/src/push/queue.rs new file mode 100644 index 0000000..b8ac984 --- /dev/null +++ b/server/crates/kvitt-server/src/push/queue.rs @@ -0,0 +1,75 @@ +//! The bounded push queue (SPEC §14.6): capacity 100, the oldest message dropped with a warning when full. +//! [`PushQueue::enqueue`] never blocks; the sender task waits on [`PushHub::next`]. + +use std::collections::VecDeque; +use std::sync::{Mutex, MutexGuard}; + +use tokio::sync::Notify; + +use super::{PushMessage, PushQueue}; + +/// Queue capacity (SPEC §14.6). +pub const QUEUE_CAPACITY: usize = 100; + +/// The queue between the request handlers (and the scheduler) and the sender task. +#[derive(Debug, Default)] +pub struct PushHub { + queue: Mutex>, + notify: Notify, +} + +impl PushHub { + pub fn new() -> Self { + Self::default() + } + + fn lock(&self) -> MutexGuard<'_, VecDeque> { + match self.queue.lock() { + Ok(guard) => guard, + Err(poisoned) => poisoned.into_inner(), + } + } + + /// Messages waiting. + pub fn len(&self) -> usize { + self.lock().len() + } + + pub fn is_empty(&self) -> bool { + self.len() == 0 + } + + /// The oldest waiting message, if any. + pub fn pop(&self) -> Option { + self.lock().pop_front() + } + + /// Waits for the next message. + pub async fn next(&self) -> PushMessage { + loop { + if let Some(message) = self.pop() { + return message; + } + self.notify.notified().await; + } + } +} + +impl PushQueue for PushHub { + fn enqueue(&self, message: PushMessage) { + { + let mut queue = self.lock(); + if queue.len() >= QUEUE_CAPACITY + && let Some(dropped) = queue.pop_front() + { + tracing::warn!( + push_type = dropped.push_type.as_str(), + capacity = QUEUE_CAPACITY, + "push queue full: oldest message dropped" + ); + } + queue.push_back(message); + } + self.notify.notify_one(); + } +} diff --git a/server/crates/kvitt-server/src/push/sender.rs b/server/crates/kvitt-server/src/push/sender.rs new file mode 100644 index 0000000..7569348 --- /dev/null +++ b/server/crates/kvitt-server/src/push/sender.rs @@ -0,0 +1,176 @@ +//! The push sender task (SPEC §14.6): takes messages from the [`PushHub`], resolves the target devices, sends one +//! FCM message per device with retries, and clears dead tokens. + +use std::sync::Arc; +use std::time::Duration; + +use sqlx::Row; + +use super::PushMessage; +use super::PushType; +use super::fcm::{FcmClient, SendOutcome, message_body}; +use super::queue::PushHub; +use crate::db::{self, Db}; +use crate::error::AppError; + +/// Delays before the retries of a send that failed with 429, 5xx or a network error (SPEC §14.6: 1 s, 5 s, 25 s). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RetryPolicy { + pub delays: Vec, +} + +impl Default for RetryPolicy { + fn default() -> Self { + Self { + delays: vec![ + Duration::from_secs(1), + Duration::from_secs(5), + Duration::from_secs(25), + ], + } + } +} + +/// One target device. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Recipient { + pub device_id: String, + pub fcm_token: String, +} + +/// What happened to one message for one device. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Delivery { + Sent { attempts: usize }, + TokenCleared, + GaveUp { attempts: usize, detail: String }, +} + +/// The background sender. +pub struct Sender { + db: Db, + hub: Arc, + fcm: FcmClient, + retry: RetryPolicy, +} + +impl Sender { + pub fn new(db: Db, hub: Arc, fcm: FcmClient, retry: RetryPolicy) -> Self { + Self { + db, + hub, + fcm, + retry, + } + } + + /// Runs forever, one message at a time. + pub async fn run(self) { + loop { + let message = self.hub.next().await; + if let Err(e) = self.deliver(&message).await { + tracing::warn!( + push_type = message.push_type.as_str(), + error_code = e.code.as_str(), + detail = %e.message, + "push not delivered" + ); + } + } + } + + /// The devices `message` goes to (SPEC §14.6, AC-13.1, AC-13.2): non-revoked devices with an FCM token of the + /// other user, or of both users for `recurring_expense_created`. + pub async fn recipients(&self, message: &PushMessage) -> Result, AppError> { + let rows = if message.push_type == PushType::RecurringExpenseCreated { + sqlx::query( + "SELECT id, fcm_token FROM devices WHERE revoked_at IS NULL AND fcm_token IS NOT NULL \ + ORDER BY created_at, id", + ) + .fetch_all(&self.db) + .await? + } else { + sqlx::query( + "SELECT id, fcm_token FROM devices WHERE revoked_at IS NULL AND fcm_token IS NOT NULL \ + AND user_id <> ? ORDER BY created_at, id", + ) + .bind(message.actor_user_id.to_string()) + .fetch_all(&self.db) + .await? + }; + rows.into_iter() + .map(|row| { + Ok(Recipient { + device_id: row.try_get("id")?, + fcm_token: row.try_get("fcm_token")?, + }) + }) + .collect() + } + + /// Sends `message` to every recipient. Returns one delivery per recipient, in recipient order. + pub async fn deliver(&self, message: &PushMessage) -> Result, AppError> { + let mut deliveries = Vec::new(); + for recipient in self.recipients(message).await? { + let delivery = self.send_to(&recipient, message).await?; + match &delivery { + Delivery::Sent { attempts } => tracing::debug!( + push_type = message.push_type.as_str(), + device_id = %recipient.device_id, + attempts, + "push sent" + ), + Delivery::TokenCleared => tracing::info!( + device_id = %recipient.device_id, + "FCM token unregistered: cleared" + ), + Delivery::GaveUp { attempts, detail } => tracing::warn!( + push_type = message.push_type.as_str(), + device_id = %recipient.device_id, + attempts, + detail = %detail, + "push failed" + ), + } + deliveries.push(delivery); + } + Ok(deliveries) + } + + async fn send_to( + &self, + recipient: &Recipient, + message: &PushMessage, + ) -> Result { + let body = message_body(&recipient.fcm_token, message); + let mut attempts = 0; + let mut delays = self.retry.delays.iter(); + loop { + attempts += 1; + match self.fcm.send(&body).await { + SendOutcome::Sent => return Ok(Delivery::Sent { attempts }), + SendOutcome::Unregistered => { + self.clear_token(recipient).await?; + return Ok(Delivery::TokenCleared); + } + SendOutcome::Failed(detail) => return Ok(Delivery::GaveUp { attempts, detail }), + SendOutcome::Retry(detail) => match delays.next() { + Some(delay) => tokio::time::sleep(*delay).await, + None => return Ok(Delivery::GaveUp { attempts, detail }), + }, + } + } + } + + /// Clears the device's token, unless the app registered a new one meanwhile. + async fn clear_token(&self, recipient: &Recipient) -> Result<(), AppError> { + let mut tx = db::begin_immediate(&self.db).await?; + sqlx::query("UPDATE devices SET fcm_token = NULL WHERE id = ? AND fcm_token = ?") + .bind(&recipient.device_id) + .bind(&recipient.fcm_token) + .execute(&mut *tx) + .await?; + tx.commit().await?; + Ok(()) + } +} diff --git a/server/crates/kvitt-server/src/router.rs b/server/crates/kvitt-server/src/router.rs index fedecea..9e846e6 100644 --- a/server/crates/kvitt-server/src/router.rs +++ b/server/crates/kvitt-server/src/router.rs @@ -5,8 +5,8 @@ use std::time::Instant; use axum::Router; -use axum::extract::{DefaultBodyLimit, MatchedPath, Request}; -use axum::http::HeaderValue; +use axum::extract::{DefaultBodyLimit, MatchedPath, Path, Request, State}; +use axum::http::{HeaderMap, HeaderValue}; use axum::middleware::{self, Next}; use axum::response::{IntoResponse, Response}; use axum::routing::{get, post, put}; @@ -33,6 +33,8 @@ pub fn router(state: AppState) -> Router { .route("/setup/status", get(setup::get_setup_status)) .route("/invites/accept", post(invites::accept_invite)) .route("/i/{code}", get(landing::get_invite_landing)) + // `/i/` with an empty code: the landing page's HTML 404, not the JSON fallback. + .route("/i/", get(empty_invite_code)) .route("/apk/latest", get(apk::get_apk_latest)) .route("/apk/files/{file_name}", get(apk::get_apk_file)); @@ -110,6 +112,11 @@ pub fn router(state: AppState) -> Router { .with_state(state) } +/// `GET /i/`: E-4 with an empty code, which the landing handler answers with its HTML 404 page. +async fn empty_invite_code(state: State, headers: HeaderMap) -> Response { + landing::get_invite_landing(state, headers, Path(String::new())).await +} + /// Unknown paths: 404 `not_found` in the error shape. async fn fallback() -> AppError { AppError::not_found() diff --git a/server/crates/kvitt-server/src/sync/check.rs b/server/crates/kvitt-server/src/sync/check.rs new file mode 100644 index 0000000..3316c21 --- /dev/null +++ b/server/crates/kvitt-server/src/sync/check.rs @@ -0,0 +1,231 @@ +//! Step 8 of SPEC §14.4.4: a pushed expense or settlement against §9.6, §9.1 (server check) or §9.4 (settlement +//! validity), and its references. +//! +//! The order of the checks: field rules (§9.6), then the split or settlement rules with the domain error token as the +//! message (§14.5.3), then references (`reference_not_found`, message = the JSON key). The spec fixes no order between +//! these; a correct phone triggers none of them. + +use std::collections::{BTreeMap, BTreeSet}; + +use chrono::{DateTime, Days, NaiveDate, Utc}; +use kvitt_domain::SplitSpec; +use sqlx::SqliteConnection; + +use crate::api::{rows, validate}; +use crate::error::AppError; +use crate::time::{parse_date, stockholm_today}; +use crate::wire::{PushedExpense, PushedSettlement, RejectionReason, SplitMode, User}; + +/// The earliest `expense_date` / `settlement_date` (SPEC §9.6). +pub const EARLIEST_DATE: NaiveDate = match NaiveDate::from_ymd_opt(2000, 1, 1) { + Some(d) => d, + None => NaiveDate::MIN, +}; +/// How far after server today (Europe/Stockholm) a date may lie, in days (SPEC §9.6). +pub const MAX_DAYS_AHEAD: u64 = 365; +/// Settlement `note`: 0 to 200 code points (SPEC §9.6). +pub const SETTLEMENT_NOTE_MAX: usize = 200; +/// The message of a split whose `share_ore` differ from the recomputed ones (SPEC §9.1 server check). +pub const SHARE_MISMATCH: &str = "share_ore does not match the split rule"; + +/// A rejection of SPEC §14.4.6 with its developer message. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Rejection { + pub reason: RejectionReason, + pub message: String, +} + +impl Rejection { + pub fn new(reason: RejectionReason, message: impl Into) -> Self { + Self { + reason, + message: message.into(), + } + } + + pub fn invalid(message: impl Into) -> Self { + Self::new(RejectionReason::ValidationFailed, message) + } +} + +/// Either a rejection of the one mutation or an error that fails the whole request. +#[derive(Debug)] +pub enum Fail { + Reject(Rejection), + Internal(AppError), +} + +impl From for Fail { + fn from(e: AppError) -> Self { + Self::Internal(e) + } +} + +impl From for Fail { + fn from(r: Rejection) -> Self { + Self::Reject(r) + } +} + +/// `validation_failed` from a §9.6 check (the REST helpers of `api::validate` report through `AppError`). +fn field_rule(result: Result) -> Result { + result.map_err(|e| Rejection::invalid(e.message)) +} + +/// A date of SPEC §9.6: valid, from 2000-01-01 to server today (Europe/Stockholm) + 365 days. +pub fn date(field: &str, text: &str, now: DateTime) -> Result<(), Rejection> { + let date = parse_date(text) + .ok_or_else(|| Rejection::invalid(format!("{field} must be YYYY-MM-DD")))?; + let latest = stockholm_today(now) + .checked_add_days(Days::new(MAX_DAYS_AHEAD)) + .unwrap_or(NaiveDate::MAX); + if date < EARLIEST_DATE || date > latest { + return Err(Rejection::invalid(format!( + "{field} must be from 2000-01-01 to today + 365 days" + ))); + } + Ok(()) +} + +/// The split rules of SPEC §9.1: exactly one entry per household user, and the server check (shares recomputed with +/// `kvitt-domain`; for mode `default` the bp are only checked to sum to 10000, never against the account). +pub fn splits(expense: &PushedExpense, users: &[User]) -> Result<(), Rejection> { + let household: BTreeSet = rows::user_ids(users).into_iter().collect(); + let pushed: BTreeSet = expense + .splits + .iter() + .map(|s| s.user_id.to_string()) + .collect(); + if expense.splits.len() != 2 || pushed != household { + return Err(Rejection::invalid( + kvitt_domain::SplitError::ShareUsersMismatch.token(), + )); + } + let ids = rows::user_ids(users); + let payer = expense.payer_user_id.to_string(); + let spec = match expense.split_mode { + SplitMode::Exact => { + if expense.splits.iter().any(|s| s.share_bp.is_some()) { + return Err(Rejection::invalid("share_bp must be null for mode exact")); + } + SplitSpec::Exact { + ore: expense + .splits + .iter() + .map(|s| (s.user_id.to_string(), s.share_ore)) + .collect(), + } + } + SplitMode::Default | SplitMode::Ratio | SplitMode::Full => { + let mut bp = BTreeMap::new(); + for split in &expense.splits { + let value = split.share_bp.ok_or_else(|| { + Rejection::invalid("share_bp is required for this split_mode") + })?; + let value = u32::try_from(value) + .ok() + .filter(|v| *v <= kvitt_domain::BP_TOTAL) + .ok_or_else(|| { + Rejection::invalid(kvitt_domain::SplitError::BpOutOfRange.token()) + })?; + if expense.split_mode == SplitMode::Full + && value != 0 + && value != kvitt_domain::BP_TOTAL + { + return Err(Rejection::invalid( + "mode full needs share_bp 10000 for the bearer and 0 for the other user", + )); + } + bp.insert(split.user_id.to_string(), value); + } + SplitSpec::Ratio { bp } + } + }; + let computed = kvitt_domain::compute_split(expense.amount_ore, &payer, &ids, &spec) + .map_err(|e| Rejection::invalid(e.token()))?; + let matches = expense + .splits + .iter() + .all(|s| computed.get(&s.user_id.to_string()) == Some(&s.share_ore)); + if !matches { + return Err(Rejection::invalid(SHARE_MISMATCH)); + } + Ok(()) +} + +/// A non-deleted account (archived is fine), else `reference_not_found` with `field` as the message. +async fn live_account( + conn: &mut SqliteConnection, + id: uuid::Uuid, + field: &str, +) -> Result<(), Fail> { + match rows::account(conn, id).await? { + Some(a) if a.deleted_at.is_none() => Ok(()), + _ => Err(Rejection::new(RejectionReason::ReferenceNotFound, field).into()), + } +} + +/// Validates a pushed expense; returns it with `description` and `notes` trimmed (the stored form, SPEC §8.6). +pub async fn expense( + conn: &mut SqliteConnection, + pushed: &PushedExpense, + users: &[User], + now: DateTime, +) -> Result { + let mut checked = pushed.clone(); + checked.amount_ore = field_rule(validate::amount("amount_ore", pushed.amount_ore))?; + checked.description = field_rule(validate::text( + "description", + &pushed.description, + 1, + validate::DESCRIPTION_MAX, + ))?; + checked.notes = field_rule(validate::notes("notes", &pushed.notes))?; + date("expense_date", &pushed.expense_date, now)?; + if !users.iter().any(|u| u.id == pushed.payer_user_id) { + return Err(Rejection::invalid(kvitt_domain::SplitError::PayerUnknown.token()).into()); + } + splits(&checked, users)?; + live_account(conn, pushed.account_id, "account_id").await?; + match rows::category(conn, pushed.category_id).await? { + Some(c) if c.deleted_at.is_none() => {} + _ => { + return Err(Rejection::new(RejectionReason::ReferenceNotFound, "category_id").into()); + } + } + checked.splits.sort_by_key(|s| s.user_id); + Ok(checked) +} + +/// Validates a pushed settlement (SPEC §9.4 settlement validity, §9.6); returns it with `note` trimmed. +pub async fn settlement( + conn: &mut SqliteConnection, + pushed: &PushedSettlement, + users: &[User], + now: DateTime, +) -> Result { + let mut checked = pushed.clone(); + checked.note = field_rule(validate::text("note", &pushed.note, 0, SETTLEMENT_NOTE_MAX))?; + date("settlement_date", &pushed.settlement_date, now)?; + let allocations: Vec = pushed + .allocations + .iter() + .map(|a| kvitt_domain::Allocation { + account_id: a.account_id.to_string(), + amount_ore: a.amount_ore, + }) + .collect(); + kvitt_domain::validate_settlement( + pushed.amount_ore, + &pushed.from_user_id.to_string(), + &pushed.to_user_id.to_string(), + &rows::user_ids(users), + &allocations, + ) + .map_err(|e| Rejection::invalid(e.token()))?; + for allocation in &pushed.allocations { + live_account(conn, allocation.account_id, "allocations").await?; + } + checked.allocations.sort_by_key(|a| a.account_id); + Ok(checked) +} diff --git a/server/crates/kvitt-server/src/sync/mod.rs b/server/crates/kvitt-server/src/sync/mod.rs new file mode 100644 index 0000000..3aa22b3 --- /dev/null +++ b/server/crates/kvitt-server/src/sync/mod.rs @@ -0,0 +1,31 @@ +//! The sync protocol v1 (SPEC §14.4): pull ([`pull`]), push processing ([`push`]) with the validation of step 8 +//! ([`check`]), and the daily pruning of `sync_mutations` ([`prune_mutations`]). The HTTP handlers are in +//! `api/sync.rs`. + +pub mod check; +pub mod pull; +pub mod push; +pub mod rows; + +use chrono::{DateTime, Duration, Utc}; + +use crate::db::{self, Db}; +use crate::error::AppError; +use crate::time::format_ts; + +/// How long `sync_mutations` rows are kept (SPEC §14.4.9, D-021). +pub const MUTATION_RETENTION: Duration = Duration::days(90); + +/// Deletes the `sync_mutations` rows whose `received_at` is older than 90 days before `now` (SPEC §14.1). Returns +/// the number of rows deleted. Timestamps are in the fixed RFC 3339 form, so text order is time order. +pub async fn prune_mutations(db: &Db, now: DateTime) -> Result { + let cutoff = format_ts(now - MUTATION_RETENTION); + let mut tx = db::begin_immediate(db).await?; + let deleted = sqlx::query("DELETE FROM sync_mutations WHERE received_at < ?") + .bind(cutoff) + .execute(&mut *tx) + .await? + .rows_affected(); + tx.commit().await?; + Ok(deleted) +} diff --git a/server/crates/kvitt-server/src/sync/pull.rs b/server/crates/kvitt-server/src/sync/pull.rs new file mode 100644 index 0000000..5e7b718 --- /dev/null +++ b/server/crates/kvitt-server/src/sync/pull.rs @@ -0,0 +1,120 @@ +//! E-31 pull (SPEC §14.4.2). + +use crate::AppState; +use crate::api::rows; +use crate::db::sequence::current_server_version; +use crate::error::{AppError, ErrorCode}; +use crate::time::format_ts; +use crate::wire::SyncPullResponse; + +use super::rows as sync_rows; + +/// Rows per page when `limit` is absent (SPEC §14.4.9). +pub const DEFAULT_LIMIT: i64 = 500; +/// Largest `limit` (SPEC §9.6). +pub const MAX_LIMIT: i64 = 1000; + +/// The seven synced tables as one `(server_version, table, id)` stream, tombstones included. Child rows (splits, +/// allocations, default shares, template splits) have no version of their own: they travel with their parent. +const ROW_STREAM: &str = "\ + SELECT server_version AS v, 'users' AS t, id FROM users WHERE server_version > ?1 \ + UNION ALL SELECT server_version, 'categories', id FROM categories WHERE server_version > ?1 \ + UNION ALL SELECT server_version, 'accounts', id FROM accounts WHERE server_version > ?1 \ + UNION ALL SELECT server_version, 'recurring_templates', id FROM recurring_templates WHERE server_version > ?1 \ + UNION ALL SELECT server_version, 'expenses', id FROM expenses WHERE server_version > ?1 \ + UNION ALL SELECT server_version, 'settlements', id FROM settlements WHERE server_version > ?1 \ + UNION ALL SELECT server_version, 'audit_log', id FROM audit_log WHERE server_version > ?1 \ + ORDER BY v LIMIT ?2"; + +/// Checks `limit` (1 to 1000, SPEC §9.6) and returns it or the default. +pub fn page_size(limit: Option) -> Result { + match limit { + None => Ok(DEFAULT_LIMIT), + Some(l) if (1..=MAX_LIMIT).contains(&l) => Ok(l), + Some(_) => Err(AppError::invalid_request("limit must be 1 to 1000").with_field("limit")), + } +} + +/// One page of rows with `server_version > since`. Runs in one read transaction, so the page, the cursor check and +/// `has_more` see the same snapshot. +pub async fn pull(state: &AppState, since: i64, limit: i64) -> Result { + let mut tx = state.db.begin().await?; + let current = current_server_version(&mut tx).await?; + if since > current { + return Err(AppError::new( + ErrorCode::CursorAhead, + "since is greater than the server's current sequence value", + ) + .with_field("since")); + } + let epoch = sync_rows::epoch(&mut tx).await?; + // One row more than the page tells whether more exist. + let mut stream: Vec<(i64, String, String)> = sqlx::query_as(ROW_STREAM) + .bind(since) + .bind(limit + 1) + .fetch_all(&mut *tx) + .await?; + let has_more = i64::try_from(stream.len()).map_err(AppError::internal)? > limit; + stream.truncate(usize::try_from(limit).map_err(AppError::internal)?); + let next_since = stream.last().map_or(since, |(v, _, _)| *v); + + let mut page = SyncPullResponse { + epoch, + since, + next_since, + has_more, + server_time: format_ts(state.clock.now()), + users: Vec::new(), + categories: Vec::new(), + accounts: Vec::new(), + recurring_templates: Vec::new(), + expenses: Vec::new(), + settlements: Vec::new(), + audit_entries: Vec::new(), + }; + let missing = |table: &str| AppError::internal(format!("{table} row vanished during pull")); + // The stream is ordered by server_version, so every array is too. + for (_, table, id) in stream { + let id = rows::uuid(&id)?; + match table.as_str() { + "users" => page.users.push( + rows::user(&mut tx, id) + .await? + .ok_or_else(|| missing("users"))?, + ), + "categories" => page.categories.push( + rows::category(&mut tx, id) + .await? + .ok_or_else(|| missing("categories"))?, + ), + "accounts" => page.accounts.push( + rows::account(&mut tx, id) + .await? + .ok_or_else(|| missing("accounts"))?, + ), + "recurring_templates" => page.recurring_templates.push( + rows::template(&mut tx, id) + .await? + .ok_or_else(|| missing("recurring_templates"))?, + ), + "expenses" => page.expenses.push( + rows::expense(&mut tx, id) + .await? + .ok_or_else(|| missing("expenses"))?, + ), + "settlements" => page.settlements.push( + sync_rows::settlement(&mut tx, id) + .await? + .ok_or_else(|| missing("settlements"))?, + ), + "audit_log" => page.audit_entries.push( + sync_rows::audit_entry(&mut tx, id) + .await? + .ok_or_else(|| missing("audit_log"))?, + ), + other => return Err(AppError::internal(format!("unknown table {other}"))), + } + } + tx.commit().await?; + Ok(page) +} diff --git a/server/crates/kvitt-server/src/sync/push.rs b/server/crates/kvitt-server/src/sync/push.rs new file mode 100644 index 0000000..0ef0448 --- /dev/null +++ b/server/crates/kvitt-server/src/sync/push.rs @@ -0,0 +1,807 @@ +//! E-32 push (SPEC §14.4.3, §14.4.4): the whole request in one `BEGIN IMMEDIATE` transaction, each mutation in its +//! own savepoint (rolled back on rejection), the result of every mutation recorded in `sync_mutations` (step 12), +//! pushes enqueued after commit (more than [`SYNC_BATCH_THRESHOLD`] become one `sync_batch`, AC-13.4). + +use std::future::Future; + +use chrono::{DateTime, Duration, Utc}; +use serde::Serialize; +use serde::de::DeserializeOwned; +use serde_json::Value; +use sqlx::{Acquire, SqliteConnection}; +use uuid::Uuid; + +use crate::AppState; +use crate::api::rows; +use crate::audit::AuditEvent; +use crate::auth::AuthDevice; +use crate::db; +use crate::error::{AppError, ErrorCode}; +use crate::push::{PushMessage, PushType}; +use crate::time::{format_ts, parse_ts}; +use crate::wire::sync::{AcceptedTag, RejectedTag, SyncEntity}; +use crate::wire::{ + AcceptedResult, AuditAction, AuditSource, EntityType, Expense, MutationResult, PushedExpense, + PushedSettlement, RejectedResult, RejectionReason, Settlement, User, +}; + +use super::check::{self, Fail, Rejection}; +use super::rows as sync_rows; + +/// Mutations per `POST /sync` (SPEC §14.4.9). +pub const MAX_MUTATIONS: usize = 100; +/// How far ahead of server now a pushed `updated_at` may lie (SPEC §9.6, step 4). +pub const CLOCK_SKEW_LIMIT: Duration = Duration::minutes(10); +/// More notifiable changes than this in one request send one `sync_batch` instead (AC-13.4). +pub const SYNC_BATCH_THRESHOLD: usize = 3; + +/// The E-32 response body. Results are kept as JSON so a replay returns the stored result byte for byte in value. +#[derive(Debug, Clone, Serialize)] +pub struct PushResponse { + pub server_time: String, + pub results: Vec, +} + +/// The keys every mutation must have for a per-mutation result: `mutation_id`, `entity_type` and `entity.id`. +struct Envelope { + mutation_id: Uuid, + entity_type: EntityType, + entity_id: Uuid, + entity: Value, +} + +/// A UUID in the canonical form the server stores and returns (lowercase, hyphenated). +fn canonical_uuid(value: Option<&Value>) -> Option { + let text = value?.as_str()?; + Uuid::parse_str(text) + .ok() + .filter(|id| id.hyphenated().to_string() == text) +} + +fn envelope(index: usize, raw: Value) -> Result { + let bad = |what: &str| { + AppError::invalid_request(format!("mutations[{index}]: {what}")).with_field("mutations") + }; + let mutation_id = canonical_uuid(raw.get("mutation_id")) + .ok_or_else(|| bad("mutation_id must be a lowercase hyphenated uuid"))?; + let entity_type = match raw.get("entity_type").and_then(Value::as_str) { + Some("expense") => EntityType::Expense, + Some("settlement") => EntityType::Settlement, + _ => return Err(bad("entity_type must be expense or settlement")), + }; + let entity = raw.get("entity").cloned().unwrap_or(Value::Null); + let entity_id = canonical_uuid(entity.get("id")) + .ok_or_else(|| bad("entity.id must be a lowercase hyphenated uuid"))?; + Ok(Envelope { + mutation_id, + entity_type, + entity_id, + entity, + }) +} + +/// The id-valued keys of a pushed entity that must be canonical UUIDs (step 3). +fn id_paths( + entity_type: EntityType, +) -> ( + &'static [&'static str], + &'static str, + &'static [&'static str], +) { + match entity_type { + EntityType::Expense => ( + &["id", "account_id", "category_id", "payer_user_id"], + "splits", + &["user_id"], + ), + EntityType::Settlement => ( + &["id", "from_user_id", "to_user_id"], + "allocations", + &["account_id"], + ), + } +} + +/// Step 3 checks serde cannot express: ids in canonical form and timestamps in the fixed RFC 3339 form. +fn shape(entity_type: EntityType, entity: &Value) -> Result<(), Rejection> { + let (top, child_key, child_ids) = id_paths(entity_type); + for key in top { + if canonical_uuid(entity.get(*key)).is_none() { + return Err(Rejection::invalid(format!( + "{key} must be a lowercase hyphenated uuid" + ))); + } + } + if let Some(children) = entity.get(child_key).and_then(Value::as_array) { + for child in children { + for key in child_ids { + if canonical_uuid(child.get(*key)).is_none() { + return Err(Rejection::invalid(format!( + "{child_key}[].{key} must be a lowercase hyphenated uuid" + ))); + } + } + } + } + for key in ["created_at", "updated_at"] { + let ok = entity + .get(key) + .and_then(Value::as_str) + .is_some_and(|t| parse_ts(t).is_some()); + if !ok { + return Err(Rejection::invalid(format!( + "{key} must be a timestamp in the fixed RFC 3339 form" + ))); + } + } + match entity.get("deleted_at") { + Some(Value::Null) => {} + Some(Value::String(t)) if parse_ts(t).is_some() => {} + _ => { + return Err(Rejection::invalid( + "deleted_at must be null or a timestamp in the fixed RFC 3339 form", + )); + } + } + Ok(()) +} + +/// What differs between expenses and settlements in the steps of §14.4.4. +trait Kind { + type Pushed: DeserializeOwned + Serialize + Clone + Send + Sync; + type Wire: Serialize + Clone + Send + Sync; + const ENTITY: EntityType; + /// Step 7 on a stored row: `deleted_at`, `updated_at`, `updated_by_user_id`, `updated_by_device_id`, + /// `server_version`, by `id`. + const DELETE_SQL: &'static str; + const CREATED: PushType; + const UPDATED: PushType; + const DELETED: PushType; + + fn updated_at(p: &Self::Pushed) -> &str; + fn deleted_at(p: &Self::Pushed) -> Option<&str>; + fn stored_updated_at(w: &Self::Wire) -> &str; + fn stored_deleted_at(w: &Self::Wire) -> Option<&str>; + fn entity(w: Self::Wire) -> SyncEntity; + + fn load( + conn: &mut SqliteConnection, + id: Uuid, + ) -> impl Future, AppError>> + Send; + fn check( + conn: &mut SqliteConnection, + p: &Self::Pushed, + users: &[User], + now: DateTime, + ) -> impl Future> + Send; + /// Inserts the row and its children (a tombstone when `deleted_at` is set). + fn insert( + conn: &mut SqliteConnection, + p: &Self::Pushed, + caller: &AuthDevice, + version: i64, + now_ts: &str, + ) -> impl Future> + Send; + /// Replaces the client-writable fields (except `created_at`) and the children; server-owned fields keep their + /// stored values. + fn update( + conn: &mut SqliteConnection, + p: &Self::Pushed, + caller: &AuthDevice, + version: i64, + ) -> impl Future> + Send; +} + +struct ExpenseKind; +struct SettlementKind; + +impl Kind for ExpenseKind { + type Pushed = PushedExpense; + type Wire = Expense; + const ENTITY: EntityType = EntityType::Expense; + const DELETE_SQL: &'static str = "UPDATE expenses SET deleted_at = ?, updated_at = ?, updated_by_user_id = ?, \ + updated_by_device_id = ?, server_version = ? WHERE id = ?"; + const CREATED: PushType = PushType::ExpenseCreated; + const UPDATED: PushType = PushType::ExpenseUpdated; + const DELETED: PushType = PushType::ExpenseDeleted; + + fn updated_at(p: &PushedExpense) -> &str { + &p.updated_at + } + fn deleted_at(p: &PushedExpense) -> Option<&str> { + p.deleted_at.as_deref() + } + fn stored_updated_at(w: &Expense) -> &str { + &w.updated_at + } + fn stored_deleted_at(w: &Expense) -> Option<&str> { + w.deleted_at.as_deref() + } + fn entity(w: Expense) -> SyncEntity { + SyncEntity::Expense(w) + } + + async fn load(conn: &mut SqliteConnection, id: Uuid) -> Result, AppError> { + rows::expense(conn, id).await + } + + async fn check( + conn: &mut SqliteConnection, + p: &PushedExpense, + users: &[User], + now: DateTime, + ) -> Result { + check::expense(conn, p, users, now).await + } + + async fn insert( + conn: &mut SqliteConnection, + p: &PushedExpense, + caller: &AuthDevice, + version: i64, + now_ts: &str, + ) -> Result<(), AppError> { + sqlx::query( + "INSERT INTO expenses (id, account_id, category_id, amount_ore, description, notes, expense_date, \ + payer_user_id, split_mode, recurring_template_id, recurring_period, import_key, created_by_user_id, \ + updated_by_user_id, updated_by_device_id, created_at, updated_at, deleted_at, server_received_at, \ + server_version) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, NULL, NULL, ?, ?, ?, ?, ?, ?, ?, ?)", + ) + .bind(p.id.to_string()) + .bind(p.account_id.to_string()) + .bind(p.category_id.to_string()) + .bind(p.amount_ore) + .bind(&p.description) + .bind(&p.notes) + .bind(&p.expense_date) + .bind(p.payer_user_id.to_string()) + .bind(rows::token_of(&p.split_mode)?) + .bind(caller.user_id.to_string()) + .bind(caller.user_id.to_string()) + .bind(caller.device_id.to_string()) + .bind(&p.created_at) + .bind(&p.updated_at) + .bind(&p.deleted_at) + .bind(now_ts) + .bind(version) + .execute(&mut *conn) + .await?; + insert_splits(conn, p).await + } + + async fn update( + conn: &mut SqliteConnection, + p: &PushedExpense, + caller: &AuthDevice, + version: i64, + ) -> Result<(), AppError> { + sqlx::query( + "UPDATE expenses SET account_id = ?, category_id = ?, amount_ore = ?, description = ?, notes = ?, \ + expense_date = ?, payer_user_id = ?, split_mode = ?, updated_at = ?, updated_by_user_id = ?, \ + updated_by_device_id = ?, server_version = ? WHERE id = ?", + ) + .bind(p.account_id.to_string()) + .bind(p.category_id.to_string()) + .bind(p.amount_ore) + .bind(&p.description) + .bind(&p.notes) + .bind(&p.expense_date) + .bind(p.payer_user_id.to_string()) + .bind(rows::token_of(&p.split_mode)?) + .bind(&p.updated_at) + .bind(caller.user_id.to_string()) + .bind(caller.device_id.to_string()) + .bind(version) + .bind(p.id.to_string()) + .execute(&mut *conn) + .await?; + sqlx::query("DELETE FROM expense_splits WHERE expense_id = ?") + .bind(p.id.to_string()) + .execute(&mut *conn) + .await?; + insert_splits(conn, p).await + } +} + +async fn insert_splits(conn: &mut SqliteConnection, p: &PushedExpense) -> Result<(), AppError> { + for split in &p.splits { + sqlx::query( + "INSERT INTO expense_splits (expense_id, user_id, share_ore, share_bp) VALUES (?, ?, ?, ?)", + ) + .bind(p.id.to_string()) + .bind(split.user_id.to_string()) + .bind(split.share_ore) + .bind(split.share_bp) + .execute(&mut *conn) + .await?; + } + Ok(()) +} + +impl Kind for SettlementKind { + type Pushed = PushedSettlement; + type Wire = Settlement; + const ENTITY: EntityType = EntityType::Settlement; + const DELETE_SQL: &'static str = "UPDATE settlements SET deleted_at = ?, updated_at = ?, updated_by_user_id = ?, \ + updated_by_device_id = ?, server_version = ? WHERE id = ?"; + const CREATED: PushType = PushType::SettlementCreated; + const UPDATED: PushType = PushType::SettlementUpdated; + const DELETED: PushType = PushType::SettlementDeleted; + + fn updated_at(p: &PushedSettlement) -> &str { + &p.updated_at + } + fn deleted_at(p: &PushedSettlement) -> Option<&str> { + p.deleted_at.as_deref() + } + fn stored_updated_at(w: &Settlement) -> &str { + &w.updated_at + } + fn stored_deleted_at(w: &Settlement) -> Option<&str> { + w.deleted_at.as_deref() + } + fn entity(w: Settlement) -> SyncEntity { + SyncEntity::Settlement(w) + } + + async fn load(conn: &mut SqliteConnection, id: Uuid) -> Result, AppError> { + sync_rows::settlement(conn, id).await + } + + async fn check( + conn: &mut SqliteConnection, + p: &PushedSettlement, + users: &[User], + now: DateTime, + ) -> Result { + check::settlement(conn, p, users, now).await + } + + async fn insert( + conn: &mut SqliteConnection, + p: &PushedSettlement, + caller: &AuthDevice, + version: i64, + now_ts: &str, + ) -> Result<(), AppError> { + sqlx::query( + "INSERT INTO settlements (id, from_user_id, to_user_id, amount_ore, settlement_date, note, import_key, \ + created_by_user_id, updated_by_user_id, updated_by_device_id, created_at, updated_at, deleted_at, \ + server_received_at, server_version) VALUES (?, ?, ?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?)", + ) + .bind(p.id.to_string()) + .bind(p.from_user_id.to_string()) + .bind(p.to_user_id.to_string()) + .bind(p.amount_ore) + .bind(&p.settlement_date) + .bind(&p.note) + .bind(caller.user_id.to_string()) + .bind(caller.user_id.to_string()) + .bind(caller.device_id.to_string()) + .bind(&p.created_at) + .bind(&p.updated_at) + .bind(&p.deleted_at) + .bind(now_ts) + .bind(version) + .execute(&mut *conn) + .await?; + insert_allocations(conn, p).await + } + + async fn update( + conn: &mut SqliteConnection, + p: &PushedSettlement, + caller: &AuthDevice, + version: i64, + ) -> Result<(), AppError> { + sqlx::query( + "UPDATE settlements SET from_user_id = ?, to_user_id = ?, amount_ore = ?, settlement_date = ?, \ + note = ?, updated_at = ?, updated_by_user_id = ?, updated_by_device_id = ?, server_version = ? \ + WHERE id = ?", + ) + .bind(p.from_user_id.to_string()) + .bind(p.to_user_id.to_string()) + .bind(p.amount_ore) + .bind(&p.settlement_date) + .bind(&p.note) + .bind(&p.updated_at) + .bind(caller.user_id.to_string()) + .bind(caller.device_id.to_string()) + .bind(version) + .bind(p.id.to_string()) + .execute(&mut *conn) + .await?; + sqlx::query("DELETE FROM settlement_allocations WHERE settlement_id = ?") + .bind(p.id.to_string()) + .execute(&mut *conn) + .await?; + insert_allocations(conn, p).await + } +} + +async fn insert_allocations( + conn: &mut SqliteConnection, + p: &PushedSettlement, +) -> Result<(), AppError> { + for allocation in &p.allocations { + sqlx::query( + "INSERT INTO settlement_allocations (settlement_id, account_id, amount_ore) VALUES (?, ?, ?)", + ) + .bind(p.id.to_string()) + .bind(allocation.account_id.to_string()) + .bind(allocation.amount_ore) + .execute(&mut *conn) + .await?; + } + Ok(()) +} + +/// The outcome of one mutation: its result and the push it causes, if any. +struct Processed { + result: MutationResult, + push: Option, +} + +/// The context every mutation of one request shares. +struct Ctx<'a> { + state: &'a AppState, + caller: &'a AuthDevice, + users: &'a [User], + now: DateTime, + now_ts: String, +} + +fn accepted(env: &Envelope, entity: SyncEntity) -> MutationResult { + let server_version = match &entity { + SyncEntity::Expense(e) => e.server_version, + SyncEntity::Settlement(s) => s.server_version, + }; + MutationResult::Accepted(AcceptedResult { + mutation_id: env.mutation_id, + entity_type: env.entity_type, + entity_id: env.entity_id, + status: AcceptedTag::Accepted, + server_version, + entity, + }) +} + +fn rejected(env: &Envelope, rejection: Rejection, current: Option) -> MutationResult { + MutationResult::Rejected(RejectedResult { + mutation_id: env.mutation_id, + entity_type: env.entity_type, + entity_id: env.entity_id, + status: RejectedTag::Rejected, + reason: rejection.reason, + message: rejection.message, + current, + }) +} + +fn push_of(push_type: PushType, env: &Envelope, ctx: &Ctx<'_>) -> Option { + Some(PushMessage { + push_type, + entity_id: Some(env.entity_id), + actor_user_id: ctx.caller.user_id, + count: 1, + }) +} + +async fn reload(conn: &mut SqliteConnection, id: Uuid) -> Result { + K::load(conn, id) + .await? + .ok_or_else(|| AppError::internal("row vanished inside its transaction")) +} + +fn json(value: &T) -> Result { + serde_json::to_value(value).map_err(AppError::internal) +} + +async fn audit( + conn: &mut SqliteConnection, + ctx: &Ctx<'_>, + env: &Envelope, + action: AuditAction, + client_at: &str, + before: Option<&K::Wire>, + after: &K::Wire, +) -> Result<(), AppError> { + ctx.state + .audit + .write( + conn, + AuditEvent { + entity_type: K::ENTITY, + entity_id: env.entity_id, + action, + source: AuditSource::App, + actor_user_id: ctx.caller.user_id, + actor_device_id: Some(ctx.caller.device_id), + client_at: client_at.to_owned(), + server_at: ctx.now_ts.clone(), + before: before.map(json).transpose()?, + after: json(after)?, + }, + ) + .await?; + Ok(()) +} + +/// Steps 3 to 11 of SPEC §14.4.4 for one mutation, on the mutation's savepoint. +async fn process( + conn: &mut SqliteConnection, + ctx: &Ctx<'_>, + env: &Envelope, +) -> Result { + // Every rejection carries the stored row (or null) as `current`; load it lazily. + async fn current( + conn: &mut SqliteConnection, + id: Uuid, + ) -> Result, AppError> { + Ok(K::load(conn, id).await?.map(K::entity)) + } + let reject = |rejection: Rejection, current: Option| Processed { + result: rejected(env, rejection, current), + push: None, + }; + + // 3. Shape. + let pushed: K::Pushed = match shape(K::ENTITY, &env.entity).and_then(|()| { + serde_json::from_value(env.entity.clone()).map_err(|e| Rejection::invalid(e.to_string())) + }) { + Ok(p) => p, + Err(r) => { + let cur = current::(conn, env.entity_id).await?; + return Ok(reject(r, cur)); + } + }; + // 4. Clock. + let updated_at = parse_ts(K::updated_at(&pushed)).ok_or_else(|| { + AppError::internal("updated_at passed the shape check but does not parse") + })?; + if updated_at > ctx.now + CLOCK_SKEW_LIMIT { + let cur = current::(conn, env.entity_id).await?; + return Ok(reject( + Rejection::new( + RejectionReason::ClockSkew, + "updated_at is more than 10 minutes ahead of server time", + ), + cur, + )); + } + // 5. Load. + let stored = K::load(conn, env.entity_id).await?; + + // 6. Stored row is a tombstone. + if let Some(tombstone) = stored + .as_ref() + .filter(|s| K::stored_deleted_at(s).is_some()) + { + let entity = K::entity(tombstone.clone()); + return Ok(if K::deleted_at(&pushed).is_some() { + Processed { + result: accepted(env, entity), + push: None, + } + } else { + reject( + Rejection::new(RejectionReason::Deleted, "the row is deleted"), + Some(entity), + ) + }); + } + + // 7. Pushed entity is a delete: always wins. + if let Some(deleted_at) = K::deleted_at(&pushed).map(str::to_owned) { + if let Some(stored) = stored { + let stored_updated = parse_ts(K::stored_updated_at(&stored)) + .ok_or_else(|| AppError::internal("stored updated_at does not parse"))?; + let new_updated_at = format_ts(stored_updated.max(updated_at)); + let version = db::next_server_version(conn).await?; + sqlx::query(K::DELETE_SQL) + .bind(&deleted_at) + .bind(&new_updated_at) + .bind(ctx.caller.user_id.to_string()) + .bind(ctx.caller.device_id.to_string()) + .bind(version) + .bind(env.entity_id.to_string()) + .execute(&mut *conn) + .await?; + let after = reload::(conn, env.entity_id).await?; + audit::( + conn, + ctx, + env, + AuditAction::Delete, + &deleted_at, + Some(&stored), + &after, + ) + .await?; + return Ok(Processed { + result: accepted(env, K::entity(after)), + push: push_of(K::DELETED, env, ctx), + }); + } + // Created and deleted before it ever synced: store the tombstone, no audit, no push. It is validated like a + // create so the row satisfies every rule of the schema (SPEC §14.4.4 step 7 asks for the references). + let checked = match K::check(conn, &pushed, ctx.users, ctx.now).await { + Ok(c) => c, + Err(Fail::Reject(r)) => return Ok(reject(r, None)), + Err(Fail::Internal(e)) => return Err(e), + }; + let version = db::next_server_version(conn).await?; + K::insert(conn, &checked, ctx.caller, version, &ctx.now_ts).await?; + let after = reload::(conn, env.entity_id).await?; + return Ok(Processed { + result: accepted(env, K::entity(after)), + push: None, + }); + } + + // 8. Validate. + let checked = match K::check(conn, &pushed, ctx.users, ctx.now).await { + Ok(c) => c, + Err(Fail::Reject(r)) => return Ok(reject(r, stored.map(K::entity))), + Err(Fail::Internal(e)) => return Err(e), + }; + + let Some(stored) = stored else { + // 9. No stored row: insert. + let version = db::next_server_version(conn).await?; + K::insert(conn, &checked, ctx.caller, version, &ctx.now_ts).await?; + let after = reload::(conn, env.entity_id).await?; + audit::( + conn, + ctx, + env, + AuditAction::Create, + K::updated_at(&checked), + None, + &after, + ) + .await?; + return Ok(Processed { + result: accepted(env, K::entity(after)), + push: push_of(K::CREATED, env, ctx), + }); + }; + + // 10. Stale: last write wins on the client updated_at; ties go to the server's copy. + let stored_updated = parse_ts(K::stored_updated_at(&stored)) + .ok_or_else(|| AppError::internal("stored updated_at does not parse"))?; + if updated_at <= stored_updated { + return Ok(reject( + Rejection::new(RejectionReason::Stale, "a newer version exists"), + Some(K::entity(stored)), + )); + } + + // 11. Newer: an empty diff changes nothing. + let stored_json = json(&stored)?; + let diff = crate::audit::diff::changes( + K::ENTITY, + AuditAction::Update, + Some(&stored_json), + &json(&checked)?, + ); + if diff.is_empty() { + return Ok(Processed { + result: accepted(env, K::entity(stored)), + push: None, + }); + } + let version = db::next_server_version(conn).await?; + K::update(conn, &checked, ctx.caller, version).await?; + let after = reload::(conn, env.entity_id).await?; + audit::( + conn, + ctx, + env, + AuditAction::Update, + K::updated_at(&checked), + Some(&stored), + &after, + ) + .await?; + Ok(Processed { + result: accepted(env, K::entity(after)), + push: push_of(K::UPDATED, env, ctx), + }) +} + +/// The pushes of one request after commit: each change, or one `sync_batch` for more than three (AC-13.4). +pub fn batch_pushes(pushes: Vec, pusher: Uuid) -> Vec { + if pushes.len() > SYNC_BATCH_THRESHOLD { + vec![PushMessage { + push_type: PushType::SyncBatch, + entity_id: None, + actor_user_id: pusher, + count: u32::try_from(pushes.len()).unwrap_or(u32::MAX), + }] + } else { + pushes + } +} + +/// Processes one `POST /sync` body (`mutations` already split into raw JSON values). +pub async fn push( + state: &AppState, + caller: &AuthDevice, + mutations: Vec, +) -> Result { + if mutations.is_empty() || mutations.len() > MAX_MUTATIONS { + return Err(AppError::new( + ErrorCode::BatchTooLarge, + "a push carries 1 to 100 mutations", + ) + .with_field("mutations")); + } + let envelopes = mutations + .into_iter() + .enumerate() + .map(|(i, raw)| envelope(i, raw)) + .collect::, _>>()?; + + let mut tx = db::begin_immediate(&state.db).await?; + // 2. Household (the whole request, before any mutation). + let users = rows::complete_household(&mut tx).await?; + let now = state.clock.now(); + let ctx = Ctx { + state, + caller, + users: &users, + now, + now_ts: format_ts(now), + }; + + let mut results = Vec::with_capacity(envelopes.len()); + let mut pushes = Vec::new(); + for env in &envelopes { + // 1. Replay. + let stored: Option = + sqlx::query_scalar("SELECT result FROM sync_mutations WHERE mutation_id = ?") + .bind(env.mutation_id.to_string()) + .fetch_optional(&mut *tx) + .await?; + if let Some(stored) = stored { + let value: Value = serde_json::from_str(&stored) + .map_err(|e| AppError::internal(format!("stored sync result: {e}")))?; + results.push(value); + continue; + } + let mut savepoint = tx.begin().await?; + let processed = match env.entity_type { + EntityType::Expense => process::(&mut savepoint, &ctx, env).await?, + EntityType::Settlement => process::(&mut savepoint, &ctx, env).await?, + }; + match processed.result { + MutationResult::Accepted(_) => savepoint.commit().await?, + MutationResult::Rejected(_) => savepoint.rollback().await?, + } + let result = json(&processed.result)?; + // 12. Record the result, accepted or rejected. + sqlx::query( + "INSERT INTO sync_mutations (mutation_id, device_id, entity_type, entity_id, result, received_at) \ + VALUES (?, ?, ?, ?, ?, ?)", + ) + .bind(env.mutation_id.to_string()) + .bind(caller.device_id.to_string()) + .bind(env.entity_type.as_str()) + .bind(env.entity_id.to_string()) + .bind(result.to_string()) + .bind(&ctx.now_ts) + .execute(&mut *tx) + .await?; + results.push(result); + pushes.extend(processed.push); + } + tx.commit().await?; + for message in batch_pushes(pushes, caller.user_id) { + state.push.enqueue(message); + } + Ok(PushResponse { + server_time: ctx.now_ts, + results, + }) +} diff --git a/server/crates/kvitt-server/src/sync/rows.rs b/server/crates/kvitt-server/src/sync/rows.rs new file mode 100644 index 0000000..535589e --- /dev/null +++ b/server/crates/kvitt-server/src/sync/rows.rs @@ -0,0 +1,160 @@ +//! Loading the rows sync needs that `api::rows` does not cover: settlements, audit entries and the epoch. + +use sqlx::{FromRow, SqliteConnection}; +use uuid::Uuid; + +use crate::api::rows::uuid; +use crate::error::AppError; +use crate::wire::{ + Allocation, AuditAction, AuditChange, AuditEntry, AuditSource, EntityType, Settlement, +}; + +#[derive(FromRow)] +struct SettlementRow { + id: String, + from_user_id: String, + to_user_id: String, + amount_ore: i64, + settlement_date: String, + note: String, + created_by_user_id: String, + updated_by_user_id: String, + created_at: String, + updated_at: String, + deleted_at: Option, + server_version: i64, +} + +/// One settlement (tombstones included), with its allocations sorted by `account_id`. +pub async fn settlement( + conn: &mut SqliteConnection, + id: Uuid, +) -> Result, AppError> { + let Some(row) = sqlx::query_as::<_, SettlementRow>( + "SELECT id, from_user_id, to_user_id, amount_ore, settlement_date, note, created_by_user_id, \ + updated_by_user_id, created_at, updated_at, deleted_at, server_version FROM settlements WHERE id = ?", + ) + .bind(id.to_string()) + .fetch_optional(&mut *conn) + .await? + else { + return Ok(None); + }; + let allocations: Vec<(String, i64)> = sqlx::query_as( + "SELECT account_id, amount_ore FROM settlement_allocations WHERE settlement_id = ? ORDER BY account_id", + ) + .bind(id.to_string()) + .fetch_all(&mut *conn) + .await?; + let mut settlement = Settlement { + id: uuid(&row.id)?, + from_user_id: uuid(&row.from_user_id)?, + to_user_id: uuid(&row.to_user_id)?, + amount_ore: row.amount_ore, + settlement_date: row.settlement_date, + note: row.note, + allocations: allocations + .into_iter() + .map(|(account_id, amount_ore)| { + Ok(Allocation { + account_id: uuid(&account_id)?, + amount_ore, + }) + }) + .collect::>()?, + created_by_user_id: uuid(&row.created_by_user_id)?, + updated_by_user_id: uuid(&row.updated_by_user_id)?, + created_at: row.created_at, + updated_at: row.updated_at, + deleted_at: row.deleted_at, + server_version: row.server_version, + }; + settlement.sort(); + Ok(Some(settlement)) +} + +#[derive(FromRow)] +struct AuditRow { + id: String, + entity_type: String, + entity_id: String, + action: String, + source: String, + actor_user_id: String, + client_at: String, + server_at: String, + changes: String, + server_version: i64, +} + +fn entity_type(text: &str) -> Result { + match text { + "expense" => Ok(EntityType::Expense), + "settlement" => Ok(EntityType::Settlement), + other => Err(AppError::internal(format!( + "unknown audit entity_type {other:?}" + ))), + } +} + +fn action(text: &str) -> Result { + match text { + "create" => Ok(AuditAction::Create), + "update" => Ok(AuditAction::Update), + "delete" => Ok(AuditAction::Delete), + other => Err(AppError::internal(format!( + "unknown audit action {other:?}" + ))), + } +} + +fn source(text: &str) -> Result { + match text { + "app" => Ok(AuditSource::App), + "recurring" => Ok(AuditSource::Recurring), + "import" => Ok(AuditSource::Import), + other => Err(AppError::internal(format!( + "unknown audit source {other:?}" + ))), + } +} + +/// One audit entry in its wire form (SPEC §14.3.3; `actor_device_id` is not on the wire). +pub async fn audit_entry( + conn: &mut SqliteConnection, + id: Uuid, +) -> Result, AppError> { + let Some(row) = sqlx::query_as::<_, AuditRow>( + "SELECT id, entity_type, entity_id, action, source, actor_user_id, client_at, server_at, changes, \ + server_version FROM audit_log WHERE id = ?", + ) + .bind(id.to_string()) + .fetch_optional(&mut *conn) + .await? + else { + return Ok(None); + }; + let changes: Vec = serde_json::from_str(&row.changes) + .map_err(|e| AppError::internal(format!("stored audit changes: {e}")))?; + Ok(Some(AuditEntry { + id: uuid(&row.id)?, + entity_type: entity_type(&row.entity_type)?, + entity_id: uuid(&row.entity_id)?, + action: action(&row.action)?, + source: source(&row.source)?, + actor_user_id: uuid(&row.actor_user_id)?, + client_at: row.client_at, + server_at: row.server_at, + changes, + server_version: row.server_version, + })) +} + +/// `meta.sync_epoch`. +pub async fn epoch(conn: &mut SqliteConnection) -> Result { + let value: Option = + sqlx::query_scalar("SELECT value FROM meta WHERE key = 'sync_epoch'") + .fetch_optional(&mut *conn) + .await?; + value.ok_or_else(|| AppError::internal("meta.sync_epoch is missing")) +} diff --git a/server/crates/kvitt-server/src/tasks/mod.rs b/server/crates/kvitt-server/src/tasks/mod.rs index f5df872..c4e961e 100644 --- a/server/crates/kvitt-server/src/tasks/mod.rs +++ b/server/crates/kvitt-server/src/tasks/mod.rs @@ -1,11 +1,82 @@ -//! Background tasks started by `serve`: the recurring scheduler (SPEC §9.5), the push sender (§14.6) and -//! `sync_mutations` pruning (§14.1). M4 owns `src/tasks/**`; in M2a this starts nothing. +//! Background tasks started by `serve` (SPEC §10.4, D-015): the push sender (SPEC §14.6), the recurring scheduler at +//! start and daily 06:00 Europe/Stockholm (§9.5) and the `sync_mutations` pruning daily at 04:00 (§14.1, D-021). + +pub mod scheduler; + +use std::sync::Arc; use crate::AppState; use crate::error::AppError; +use crate::push::{Endpoints, FcmClient, NoopPushQueue, PushHub, PushQueue, RetryPolicy, Sender}; -/// Starts the background tasks (spawned on the runtime; this returns once they are started). -pub async fn start(state: AppState) -> Result<(), AppError> { - let _ = state; - Ok(()) +/// The push queue for `state`: the real queue with its sender spawned when the credentials file exists and parses, +/// otherwise push disabled with one warning (SPEC §10.4). Never fails start-up. +pub fn start_push( + state: &AppState, + endpoints: Endpoints, + retry: RetryPolicy, +) -> Arc { + let config = &state.config; + match FcmClient::from_file( + &config.fcm_credentials, + &config.fcm_project_id, + endpoints, + state.clock.clone(), + ) { + Ok(Some(client)) => { + let hub = Arc::new(PushHub::new()); + let sender = Sender::new(state.db.clone(), hub.clone(), client, retry); + tokio::spawn(sender.run()); + tracing::info!(project = %config.fcm_project_id, "push enabled"); + hub + } + Ok(None) => { + tracing::warn!( + path = %config.fcm_credentials.display(), + "FCM credentials file missing: push is disabled" + ); + Arc::new(NoopPushQueue) + } + Err(e) => { + tracing::warn!( + path = %config.fcm_credentials.display(), + detail = %e.message, + "FCM credentials unusable: push is disabled" + ); + Arc::new(NoopPushQueue) + } + } +} + +/// Starts the background tasks and returns `state` with the real push queue installed (the router must be built +/// from the returned state). The tasks are spawned on the runtime; this returns once they are started. +pub async fn start(mut state: AppState) -> Result { + state.push = start_push(&state, Endpoints::google(), RetryPolicy::default()); + + let generation = state.clone(); + tokio::spawn(async move { + let clock = generation.clock.clone(); + scheduler::run_daily( + clock, + scheduler::GENERATION_TIME, + true, + scheduler::POLL_INTERVAL, + || scheduler::generation_job(&generation), + ) + .await; + }); + + let pruning = state.clone(); + tokio::spawn(async move { + let clock = pruning.clock.clone(); + scheduler::run_daily( + clock, + scheduler::PRUNE_TIME, + false, + scheduler::POLL_INTERVAL, + || scheduler::prune_job(&pruning), + ) + .await; + }); + Ok(state) } diff --git a/server/crates/kvitt-server/src/tasks/scheduler.rs b/server/crates/kvitt-server/src/tasks/scheduler.rs new file mode 100644 index 0000000..3233893 --- /dev/null +++ b/server/crates/kvitt-server/src/tasks/scheduler.rs @@ -0,0 +1,116 @@ +//! Daily jobs at a local Europe/Stockholm time (SPEC §9.5, §14.1, D-021), DST-aware, with an injectable clock. +//! +//! The loop never trusts a single long sleep: it sleeps at most [`POLL_INTERVAL`] and re-reads the clock, so a clock +//! that jumps forward (NTP after boot on the Pi, which has no RTC) runs a missed job once at the next poll, and a +//! clock that jumps backward more than a day re-plans the next run instead of waiting for the old instant. + +use std::future::Future; +use std::sync::Arc; +use std::time::Duration as StdDuration; + +use chrono::{DateTime, Days, Duration, NaiveTime, TimeZone, Utc}; + +use crate::AppState; +use crate::api::recurring; +use crate::error::AppError; +use crate::sync; +use crate::time::{Clock, STOCKHOLM}; + +/// Local time of the recurring generation (SPEC §9.5). +pub const GENERATION_TIME: NaiveTime = recurring::GENERATION_TIME; +/// Local time of the `sync_mutations` pruning (D-021). +pub const PRUNE_TIME: NaiveTime = match NaiveTime::from_hms_opt(4, 0, 0) { + Some(t) => t, + None => NaiveTime::MIN, +}; +/// Longest sleep between two looks at the clock. +pub const POLL_INTERVAL: StdDuration = StdDuration::from_secs(30); + +/// The first instant strictly after `after` whose Europe/Stockholm wall time is `at`. On a day where `at` falls into +/// a DST gap (never the case for 04:00 or 06:00 in Stockholm) that day is skipped; in an overlap the earlier instant +/// is used. +pub fn next_run(after: DateTime, at: NaiveTime) -> Result, AppError> { + let mut date = after.with_timezone(&STOCKHOLM).date_naive(); + // Today, tomorrow, and two spare days for a gap. + for _ in 0..4 { + if let Some(local) = STOCKHOLM.from_local_datetime(&date.and_time(at)).earliest() { + let instant = local.with_timezone(&Utc); + if instant > after { + return Ok(instant); + } + } + date = date + .checked_add_days(Days::new(1)) + .ok_or_else(|| AppError::internal("date overflow planning the next run"))?; + } + Err(AppError::internal("no next run within four days")) +} + +/// Runs `job` once at start (if `run_at_start`) and then every day at local `at`, reading "now" from `clock`. +/// Never returns. `poll` is [`POLL_INTERVAL`] in production; tests pass a few milliseconds. +pub async fn run_daily( + clock: Arc, + at: NaiveTime, + run_at_start: bool, + poll: StdDuration, + mut job: F, +) where + F: FnMut() -> Fut, + Fut: Future, +{ + if run_at_start { + job().await; + } + let mut due = plan(clock.now(), at).await; + loop { + let now = clock.now(); + if now >= due { + job().await; + due = plan(clock.now().max(due), at).await; + continue; + } + if due - now > Duration::days(1) + Duration::hours(2) { + // The clock went backward: plan from the new now. + due = plan(now, at).await; + continue; + } + let wait = (due - now).to_std().unwrap_or(StdDuration::ZERO).min(poll); + tokio::time::sleep(wait).await; + } +} + +/// [`next_run`], retried after a poll interval if it fails (it cannot for valid times; logged, never panics). +async fn plan(after: DateTime, at: NaiveTime) -> DateTime { + match next_run(after, at) { + Ok(instant) => instant, + Err(e) => { + tracing::error!(detail = %e.message, "cannot plan the next daily run"); + after + Duration::days(1) + } + } +} + +/// The 06:00 job: M3's generation for every non-deleted, non-paused template (SPEC §9.5). Errors are logged; the +/// next run retries (generation is idempotent). +pub async fn generation_job(state: &AppState) { + match recurring::generate_all_due(state).await { + Ok(created) => tracing::info!(created, "recurring generation done"), + Err(e) => tracing::warn!( + error_code = e.code.as_str(), + detail = %e.message, + "recurring generation failed" + ), + } +} + +/// The 04:00 job: delete `sync_mutations` rows older than 90 days (SPEC §14.1, D-021). +pub async fn prune_job(state: &AppState) { + match sync::prune_mutations(&state.db, state.clock.now()).await { + Ok(deleted) => tracing::info!(deleted, "sync_mutations pruned"), + Err(e) => tracing::warn!( + error_code = e.code.as_str(), + detail = %e.message, + "sync_mutations pruning failed" + ), + } +} diff --git a/server/crates/kvitt-server/tests/push_fcm/fcm.rs b/server/crates/kvitt-server/tests/push_fcm/fcm.rs new file mode 100644 index 0000000..00a5a60 --- /dev/null +++ b/server/crates/kvitt-server/tests/push_fcm/fcm.rs @@ -0,0 +1,466 @@ +//! The FCM sender against the stub (SPEC §14.6). + +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use chrono::Duration as ChronoDuration; +use jsonwebtoken::{Algorithm, DecodingKey, Validation}; +use kvitt_server::push::fcm::{SCOPE, TOKEN_URL, message_body}; +use kvitt_server::push::queue::QUEUE_CAPACITY; +use kvitt_server::push::sender::Delivery; +use kvitt_server::push::{ + FcmClient, PushHub, PushMessage, PushQueue, PushType, RetryPolicy, Sender, +}; +use kvitt_server::time::{format_ts, parse_ts}; +use serde_json::{Value, json}; +use uuid::Uuid; + +use crate::common::*; +use crate::m4; +use crate::stub::*; + +const EXPENSE: &str = "01929c5e-1111-7aaa-8bbb-000000000001"; +const FAST: [u64; 3] = [10, 20, 40]; + +struct Setup { + app: TestApp, + stub: Stub, + key: TestKey, + hub: Arc, +} + +impl Setup { + async fn new() -> Self { + let app = m4::app().await; + for (device, tok) in [ + (ADMIN_DEVICE_ID, "tok-admin"), + (MEMBER_DEVICE_ID, "tok-member"), + (ADMIN_OLD_DEVICE_ID, "tok-revoked"), + ] { + set_fcm_token(&app, device, Some(tok)).await; + } + Self { + app, + stub: Stub::start().await, + key: service_account(), + hub: Arc::new(PushHub::new()), + } + } + + fn client(&self) -> FcmClient { + FcmClient::new( + &self.key.json, + "kvitt-1337", + self.stub.endpoints(), + self.app.state.clock.clone(), + ) + .unwrap() + } + + fn sender(&self) -> Sender { + Sender::new( + self.app.state.db.clone(), + self.hub.clone(), + self.client(), + RetryPolicy { + delays: FAST.iter().map(|ms| Duration::from_millis(*ms)).collect(), + }, + ) + } +} + +async fn set_fcm_token(app: &TestApp, device: &str, tok: Option<&str>) { + sqlx::query("UPDATE devices SET fcm_token = ? WHERE id = ?") + .bind(tok) + .bind(device) + .execute(&app.state.db) + .await + .unwrap(); +} + +async fn fcm_token(app: &TestApp, device: &str) -> Option { + sqlx::query_scalar("SELECT fcm_token FROM devices WHERE id = ?") + .bind(device) + .fetch_one(&app.state.db) + .await + .unwrap() +} + +fn message(push_type: PushType, actor: &str) -> PushMessage { + PushMessage { + push_type, + entity_id: (push_type != PushType::SyncBatch).then(|| EXPENSE.parse().unwrap()), + actor_user_id: actor.parse().unwrap(), + count: 1, + } +} + +#[test] +fn payload_of_every_type_equals_the_contract_example() { + let all = [ + PushType::ExpenseCreated, + PushType::ExpenseUpdated, + PushType::ExpenseDeleted, + PushType::SettlementCreated, + PushType::SettlementUpdated, + PushType::SettlementDeleted, + PushType::RecurringExpenseCreated, + PushType::SyncBatch, + ]; + for push_type in all { + let expected = example(&format!("push/{}.json", push_type.as_str())); + let data = &expected["message"]["data"]; + let entity_id = data["entity_id"].as_str().unwrap(); + let msg = PushMessage { + push_type, + entity_id: (!entity_id.is_empty()).then(|| entity_id.parse::().unwrap()), + actor_user_id: data["actor_user_id"].as_str().unwrap().parse().unwrap(), + count: data["count"].as_str().unwrap().parse().unwrap(), + }; + let body = message_body(expected["message"]["token"].as_str().unwrap(), &msg); + assert_eq!(body, expected, "{}", push_type.as_str()); + // Data only, every value a string (SPEC §14.6), ids only (AC-13.8). + assert!(body["message"].get("notification").is_none()); + assert!( + body["message"]["data"] + .as_object() + .unwrap() + .values() + .all(Value::is_string) + ); + } +} + +#[tokio::test] +async fn oauth_jwt_and_send_request_follow_the_http_v1_flow() { + let s = Setup::new().await; + let deliveries = s + .sender() + .deliver(&message(PushType::ExpenseCreated, ADMIN_ID)) + .await + .unwrap(); + assert_eq!(deliveries, [Delivery::Sent { attempts: 1 }]); + + let tokens = s.stub.tokens(); + assert_eq!(tokens.len(), 1); + let form = String::from_utf8(tokens[0].body.to_vec()).unwrap(); + let (grant, assertion) = form.split_once('&').unwrap(); + assert_eq!( + grant, + "grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant%2Dtype%3Ajwt%2Dbearer" + ); + let jwt = assertion.strip_prefix("assertion=").unwrap(); + let header = jsonwebtoken::decode_header(jwt).unwrap(); + assert_eq!(header.alg, Algorithm::RS256); + assert_eq!(header.kid.as_deref(), Some("test-key-1")); + let mut validation = Validation::new(Algorithm::RS256); + validation.set_audience(&[TOKEN_URL]); + validation.set_issuer(&["kvitt-fcm@kvitt-1337.iam.gserviceaccount.com"]); + // The scripted clock is in the future relative to the machine's: check exp by hand. + validation.validate_exp = false; + let claims: Value = jsonwebtoken::decode( + jwt, + &DecodingKey::from_rsa_der(&s.key.public_der), + &validation, + ) + .unwrap() + .claims; + assert_eq!(claims["scope"], SCOPE); + let now = parse_ts(NOW).unwrap().timestamp(); + assert_eq!(claims["iat"], now); + assert_eq!(claims["exp"], now + 3600); + + // One message to the other user's one non-revoked device. + let sends = s.stub.sends(); + assert_eq!(sends.len(), 1); + assert_eq!(sends[0].path, "/v1/projects/kvitt-1337/messages:send"); + assert_eq!( + sends[0].authorization.as_deref(), + Some("Bearer ya29.stub-1") + ); + let expected = json!({"message": {"token": "tok-member", "android": {"priority": "high", "ttl": "86400s"}, + "data": {"v": "1", "type": "expense_created", "entity_type": "expense", "entity_id": EXPENSE, + "actor_user_id": ADMIN_ID, "count": "1"}}}); + assert_eq!(sends[0].json(), expected); +} + +#[tokio::test] +async fn access_token_is_cached_until_5_minutes_before_expiry() { + let s = Setup::new().await; + let sender = s.sender(); + let msg = message(PushType::ExpenseUpdated, ADMIN_ID); + sender.deliver(&msg).await.unwrap(); + s.app.set_now(&format_ts( + parse_ts(NOW).unwrap() + ChronoDuration::minutes(54), + )); + sender.deliver(&msg).await.unwrap(); + assert_eq!(s.stub.tokens().len(), 1); + s.app.set_now(&format_ts( + parse_ts(NOW).unwrap() + ChronoDuration::minutes(55), + )); + sender.deliver(&msg).await.unwrap(); + assert_eq!(s.stub.tokens().len(), 2); + assert_eq!( + s.stub.sends()[2].authorization.as_deref(), + Some("Bearer ya29.stub-2") + ); +} + +#[tokio::test] +async fn recipients_are_the_other_users_live_devices_with_a_token_recurring_goes_to_both() { + let s = Setup::new().await; + let sender = s.sender(); + let ids = |r: Vec| { + r.into_iter().map(|r| r.device_id).collect::>() + }; + // Admin acts: the member's device. Member acts: the admin's current device (the revoked one is skipped). + assert_eq!( + ids(sender + .recipients(&message(PushType::SettlementCreated, ADMIN_ID)) + .await + .unwrap()), + [MEMBER_DEVICE_ID] + ); + assert_eq!( + ids(sender + .recipients(&message(PushType::ExpenseDeleted, MEMBER_ID)) + .await + .unwrap()), + [ADMIN_DEVICE_ID] + ); + assert_eq!( + ids(sender + .recipients(&message(PushType::SyncBatch, MEMBER_ID)) + .await + .unwrap()), + [ADMIN_DEVICE_ID] + ); + // AC-13.2: recurring to every live device of both users, the actor's included. + assert_eq!( + ids(sender + .recipients(&message(PushType::RecurringExpenseCreated, ADMIN_ID)) + .await + .unwrap()), + [ADMIN_DEVICE_ID, MEMBER_DEVICE_ID] + ); + // A device without a token is skipped. + set_fcm_token(&s.app, MEMBER_DEVICE_ID, None).await; + assert!( + sender + .recipients(&message(PushType::ExpenseCreated, ADMIN_ID)) + .await + .unwrap() + .is_empty() + ); +} + +#[test] +fn production_retry_delays_are_1_5_25_seconds() { + assert_eq!( + RetryPolicy::default().delays, + [ + Duration::from_secs(1), + Duration::from_secs(5), + Duration::from_secs(25) + ] + ); +} + +#[tokio::test] +async fn retries_429_and_5xx_with_the_policy_delays_then_succeeds_or_gives_up() { + let s = Setup::new().await; + let sender = s.sender(); + let msg = message(PushType::ExpenseCreated, ADMIN_ID); + s.stub.script_sends(&[ + (503, fcm_error(503, "UNAVAILABLE", "UNAVAILABLE")), + (500, fcm_error(500, "INTERNAL", "INTERNAL")), + (429, fcm_error(429, "RESOURCE_EXHAUSTED", "QUOTA_EXCEEDED")), + ]); + let started = Instant::now(); + assert_eq!( + sender.deliver(&msg).await.unwrap(), + [Delivery::Sent { attempts: 4 }] + ); + assert!(started.elapsed() >= Duration::from_millis(FAST.iter().sum())); + assert_eq!(s.stub.sends().len(), 4); + + s.stub.script_sends(&[ + (503, json!({})), + (503, json!({})), + (503, json!({})), + (503, json!({})), + ]); + let d = sender.deliver(&msg).await.unwrap(); + assert!( + matches!(&d[0], Delivery::GaveUp { attempts: 4, .. }), + "{d:?}" + ); + assert_eq!(s.stub.sends().len(), 8); + + // Other 4xx: no retry, token kept. + s.stub + .script_sends(&[(400, fcm_error(400, "INVALID_ARGUMENT", "INVALID_ARGUMENT"))]); + let d = sender.deliver(&msg).await.unwrap(); + assert!( + matches!(&d[0], Delivery::GaveUp { attempts: 1, .. }), + "{d:?}" + ); + assert_eq!( + fcm_token(&s.app, MEMBER_DEVICE_ID).await.as_deref(), + Some("tok-member") + ); +} + +#[tokio::test] +async fn network_errors_are_retried() { + let s = Setup::new().await; + // A port nobody listens on. + let dead = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = dead.local_addr().unwrap(); + drop(dead); + let endpoints = kvitt_server::push::Endpoints { + token_url: format!("http://{addr}/token"), + send_base_url: format!("http://{addr}"), + }; + let client = FcmClient::new( + &s.key.json, + "kvitt-1337", + endpoints, + s.app.state.clock.clone(), + ) + .unwrap(); + let sender = Sender::new( + s.app.state.db.clone(), + s.hub.clone(), + client, + RetryPolicy { + delays: FAST.iter().map(|ms| Duration::from_millis(*ms)).collect(), + }, + ); + let d = sender + .deliver(&message(PushType::ExpenseCreated, ADMIN_ID)) + .await + .unwrap(); + assert!( + matches!(&d[0], Delivery::GaveUp { attempts: 4, .. }), + "{d:?}" + ); +} + +#[tokio::test] +async fn unregistered_or_404_clears_the_devices_token() { + let s = Setup::new().await; + let sender = s.sender(); + s.stub + .script_sends(&[(404, fcm_error(404, "NOT_FOUND", "UNREGISTERED"))]); + assert_eq!( + sender + .deliver(&message(PushType::ExpenseCreated, ADMIN_ID)) + .await + .unwrap(), + [Delivery::TokenCleared] + ); + assert_eq!(fcm_token(&s.app, MEMBER_DEVICE_ID).await, None); + assert_eq!(s.stub.sends().len(), 1, "no retry"); + // UNREGISTERED under another status, and a bare 404. + s.stub.script_sends(&[ + (400, fcm_error(400, "INVALID_ARGUMENT", "UNREGISTERED")), + (404, json!({})), + ]); + set_fcm_token(&s.app, MEMBER_DEVICE_ID, Some("tok-member")).await; + sender + .deliver(&message(PushType::ExpenseCreated, ADMIN_ID)) + .await + .unwrap(); + assert_eq!(fcm_token(&s.app, MEMBER_DEVICE_ID).await, None); + sender + .deliver(&message(PushType::ExpenseCreated, MEMBER_ID)) + .await + .unwrap(); + assert_eq!(fcm_token(&s.app, ADMIN_DEVICE_ID).await, None); +} + +#[tokio::test] +async fn full_queue_drops_the_oldest() { + let hub = PushHub::new(); + for i in 0..=QUEUE_CAPACITY { + hub.enqueue(PushMessage { + push_type: PushType::SyncBatch, + entity_id: None, + actor_user_id: ADMIN_ID.parse().unwrap(), + count: u32::try_from(i).unwrap(), + }); + } + assert_eq!(QUEUE_CAPACITY, 100); + assert_eq!(hub.len(), 100); + assert_eq!(hub.pop().unwrap().count, 1, "message 0 was dropped"); +} + +#[tokio::test] +async fn missing_or_unusable_credentials_disable_push() { + let s = Setup::new().await; + let missing = s.app.data_dir().join("no-such-key.json"); + let client = FcmClient::from_file( + &missing, + "kvitt-1337", + s.stub.endpoints(), + s.app.state.clock.clone(), + ); + assert!(client.unwrap().is_none()); + // tasks::start_push with the configured path (missing in the test config): the no-op queue; nothing is sent. + let mut state = s.app.state.clone(); + let mut config = (*state.config).clone(); + config.fcm_credentials = missing; + state.config = Arc::new(config.clone()); + let queue = kvitt_server::tasks::start_push(&state, s.stub.endpoints(), RetryPolicy::default()); + queue.enqueue(message(PushType::ExpenseCreated, ADMIN_ID)); + // An unparsable file: disabled too. + let bad = s.app.data_dir().join("bad-key.json"); + std::fs::write( + &bad, + "{\"client_email\": \"x\", \"private_key\": \"not a key\"}", + ) + .unwrap(); + config.fcm_credentials = bad; + state.config = Arc::new(config); + let queue = kvitt_server::tasks::start_push(&state, s.stub.endpoints(), RetryPolicy::default()); + queue.enqueue(message(PushType::ExpenseCreated, ADMIN_ID)); + tokio::time::sleep(Duration::from_millis(100)).await; + assert!(s.stub.sends().is_empty()); + assert!(s.stub.tokens().is_empty()); +} + +#[tokio::test] +async fn sync_push_reaches_fcm_through_the_queue_and_the_sender_task() { + let mut s = Setup::new().await; + let key_file = s.app.data_dir().join("fcm.json"); + std::fs::write(&key_file, &s.key.json).unwrap(); + let mut config = (*s.app.state.config).clone(); + config.fcm_credentials = key_file; + s.app.state.config = Arc::new(config); + let queue = kvitt_server::tasks::start_push( + &s.app.state, + s.stub.endpoints(), + RetryPolicy { + delays: vec![Duration::from_millis(10)], + }, + ); + m4::with_push(&mut s.app, queue); + let id = m4::new_id(); + m4::push_one( + &s.app, + &member_token(), + m4::exp(m4::expense(&id, "2026-10-17T11:00:00.000Z")), + ) + .await; + let deadline = Instant::now() + Duration::from_secs(5); + while s.stub.sends().is_empty() && Instant::now() < deadline { + tokio::time::sleep(Duration::from_millis(10)).await; + } + let sends = s.stub.sends(); + assert_eq!(sends.len(), 1); + let body = sends[0].json(); + assert_eq!(body["message"]["token"], "tok-admin"); + assert_eq!(body["message"]["data"]["entity_id"], id); + assert_eq!(body["message"]["data"]["actor_user_id"], MEMBER_ID); +} diff --git a/server/crates/kvitt-server/tests/push_fcm/main.rs b/server/crates/kvitt-server/tests/push_fcm/main.rs new file mode 100644 index 0000000..82d80ff --- /dev/null +++ b/server/crates/kvitt-server/tests/push_fcm/main.rs @@ -0,0 +1,17 @@ +//! M4 push tests (SPEC §14.6, FR-13): payloads of every type against `contracts/examples/push/`, recipients, the +//! OAuth2 service-account flow and token cache, retries and their delays, token clearing on `UNREGISTERED`/404, +//! queue overflow, push disabled without credentials, all against a local stub of Google's two endpoints. + +#[path = "../core_api/common.rs"] +pub mod common; +#[path = "../sync_api/m4.rs"] +pub mod m4; +pub mod stub; + +mod fcm; + +impl Default for common::TempDir { + fn default() -> Self { + Self::new() + } +} diff --git a/server/crates/kvitt-server/tests/push_fcm/stub.rs b/server/crates/kvitt-server/tests/push_fcm/stub.rs new file mode 100644 index 0000000..bf66fb5 --- /dev/null +++ b/server/crates/kvitt-server/tests/push_fcm/stub.rs @@ -0,0 +1,170 @@ +//! A local stub of `oauth2.googleapis.com/token` and `fcm.googleapis.com/v1/projects/{p}/messages:send`, plus a +//! throwaway RSA service-account key generated per test (no key is committed). + +use std::collections::VecDeque; +use std::net::SocketAddr; +use std::sync::{Arc, Mutex}; + +use aws_lc_rs::encoding::AsDer; +use aws_lc_rs::rsa::{KeyPair, KeySize}; +use aws_lc_rs::signature::KeyPair as _; +use axum::Router; +use axum::body::Bytes; +use axum::extract::{Path, State}; +use axum::http::{HeaderMap, StatusCode}; +use axum::response::{IntoResponse, Response}; +use axum::routing::post; +use base64::Engine; +use kvitt_server::push::Endpoints; +use serde_json::{Value, json}; + +/// One request the stub received. +#[derive(Debug, Clone)] +pub struct Received { + pub path: String, + pub authorization: Option, + pub body: Bytes, +} + +impl Received { + pub fn json(&self) -> Value { + serde_json::from_slice(&self.body).unwrap() + } +} + +#[derive(Default)] +pub struct StubState { + pub tokens: Mutex>, + pub sends: Mutex>, + /// Scripted send responses, used in order; afterwards 200. + pub send_script: Mutex>, + /// Scripted token responses; afterwards a fresh token valid 3600 s. + pub token_script: Mutex>, +} + +pub struct Stub { + pub addr: SocketAddr, + pub state: Arc, +} + +impl Stub { + pub async fn start() -> Self { + let state = Arc::new(StubState::default()); + let app = Router::new() + .route("/token", post(token)) + .route("/v1/projects/{project}/{action}", post(send)) + .with_state(state.clone()); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + Self { addr, state } + } + + pub fn endpoints(&self) -> Endpoints { + Endpoints { + token_url: format!("http://{}/token", self.addr), + send_base_url: format!("http://{}", self.addr), + } + } + + pub fn script_sends(&self, responses: &[(u16, Value)]) { + self.state + .send_script + .lock() + .unwrap() + .extend(responses.iter().cloned()); + } + + pub fn sends(&self) -> Vec { + self.state.sends.lock().unwrap().clone() + } + + pub fn tokens(&self) -> Vec { + self.state.tokens.lock().unwrap().clone() + } +} + +fn record(headers: &HeaderMap, path: String, body: Bytes) -> Received { + Received { + path, + authorization: headers + .get("authorization") + .map(|v| v.to_str().unwrap().to_owned()), + body, + } +} + +async fn token(State(state): State>, headers: HeaderMap, body: Bytes) -> Response { + let n = { + let mut tokens = state.tokens.lock().unwrap(); + tokens.push(record(&headers, "/token".into(), body)); + tokens.len() + }; + if let Some((status, body)) = state.token_script.lock().unwrap().pop_front() { + return (StatusCode::from_u16(status).unwrap(), axum::Json(body)).into_response(); + } + axum::Json(json!({"access_token": format!("ya29.stub-{n}"), "expires_in": 3600, "token_type": "Bearer"})) + .into_response() +} + +async fn send( + State(state): State>, + Path((project, action)): Path<(String, String)>, + headers: HeaderMap, + body: Bytes, +) -> Response { + state.sends.lock().unwrap().push(record( + &headers, + format!("/v1/projects/{project}/{action}"), + body, + )); + match state.send_script.lock().unwrap().pop_front() { + Some((status, body)) => { + (StatusCode::from_u16(status).unwrap(), axum::Json(body)).into_response() + } + None => { + axum::Json(json!({"name": format!("projects/{project}/messages/1")})).into_response() + } + } +} + +/// A generated service-account key file and the public key to verify its JWTs. +pub struct TestKey { + pub json: String, + pub public_der: Vec, +} + +pub fn service_account() -> TestKey { + let pair = KeyPair::generate(KeySize::Rsa2048).unwrap(); + let der = AsDer::::as_der(&pair).unwrap(); + let b64 = base64::engine::general_purpose::STANDARD.encode(der.as_ref()); + let lines: Vec<&str> = b64 + .as_bytes() + .chunks(64) + .map(|c| std::str::from_utf8(c).unwrap()) + .collect(); + let pem = format!( + "-----BEGIN PRIVATE KEY-----\n{}\n-----END PRIVATE KEY-----\n", + lines.join("\n") + ); + let json = json!({ + "type": "service_account", + "project_id": "kvitt-1337", + "private_key_id": "test-key-1", + "private_key": pem, + "client_email": "kvitt-fcm@kvitt-1337.iam.gserviceaccount.com", + "token_uri": "https://oauth2.googleapis.com/token" + }) + .to_string(); + TestKey { + json, + public_der: pair.public_key().as_ref().to_vec(), + } +} + +/// An FCM error body with an `FcmError` detail. +pub fn fcm_error(code: u16, status: &str, error_code: &str) -> Value { + json!({"error": {"code": code, "message": "stub", "status": status, "details": [ + {"@type": "type.googleapis.com/google.firebase.fcm.v1.FcmError", "errorCode": error_code} + ]}}) +} diff --git a/server/crates/kvitt-server/tests/scheduler_daily/main.rs b/server/crates/kvitt-server/tests/scheduler_daily/main.rs new file mode 100644 index 0000000..87e0f06 --- /dev/null +++ b/server/crates/kvitt-server/tests/scheduler_daily/main.rs @@ -0,0 +1,16 @@ +//! M4 scheduler tests (SPEC §9.5 scheduler cases, §14.1 pruning, D-021): the daily run planned at local 06:00 and +//! 04:00 across both DST changes, the run at start, clock jumps with an injected clock, catch-up after downtime +//! through M3's generation function, paused and deleted templates skipped, and the 90-day pruning. + +#[path = "../core_api/common.rs"] +pub mod common; +#[path = "../sync_api/m4.rs"] +pub mod m4; + +mod scheduler; + +impl Default for common::TempDir { + fn default() -> Self { + Self::new() + } +} diff --git a/server/crates/kvitt-server/tests/scheduler_daily/scheduler.rs b/server/crates/kvitt-server/tests/scheduler_daily/scheduler.rs new file mode 100644 index 0000000..8055596 --- /dev/null +++ b/server/crates/kvitt-server/tests/scheduler_daily/scheduler.rs @@ -0,0 +1,300 @@ +use std::sync::Arc; +use std::time::Duration; + +use chrono::Duration as ChronoDuration; +use kvitt_server::tasks::scheduler::{ + GENERATION_TIME, PRUNE_TIME, generation_job, next_run, prune_job, run_daily, +}; +use kvitt_server::time::{FixedClock, format_ts, parse_ts}; +use tokio::sync::mpsc; + +use crate::common::*; +use crate::m4; + +fn ts(text: &str) -> chrono::DateTime { + parse_ts(text).unwrap() +} + +#[test] +fn next_06_00_is_local_time_across_both_dst_changes() { + // Summer time (CEST, UTC+2): 06:00 is 04:00Z. + assert_eq!( + next_run(ts("2026-10-17T03:59:59.999Z"), GENERATION_TIME).unwrap(), + ts("2026-10-17T04:00:00.000Z") + ); + // Strictly after: at 06:00 the next run is tomorrow. + assert_eq!( + next_run(ts("2026-10-17T04:00:00.000Z"), GENERATION_TIME).unwrap(), + ts("2026-10-18T04:00:00.000Z") + ); + // Autumn: summer time ends 25 Oct 2026 at 03:00 CEST; 06:00 that day is CET, 05:00Z (a 25-hour day). + assert_eq!( + next_run(ts("2026-10-24T04:00:00.000Z"), GENERATION_TIME).unwrap(), + ts("2026-10-25T05:00:00.000Z") + ); + assert_eq!( + next_run(ts("2026-10-25T05:00:00.000Z"), GENERATION_TIME).unwrap(), + ts("2026-10-26T05:00:00.000Z") + ); + // Spring: summer time starts 28 Mar 2027 at 02:00 CET; 06:00 that day is CEST, 04:00Z (a 23-hour day). + assert_eq!( + next_run(ts("2027-03-27T05:00:00.000Z"), GENERATION_TIME).unwrap(), + ts("2027-03-28T04:00:00.000Z") + ); + // Pruning at 04:00 on both DST days. + assert_eq!( + next_run(ts("2026-10-24T02:00:00.000Z"), PRUNE_TIME).unwrap(), + ts("2026-10-25T03:00:00.000Z") + ); + assert_eq!( + next_run(ts("2027-03-27T03:00:00.000Z"), PRUNE_TIME).unwrap(), + ts("2027-03-28T02:00:00.000Z") + ); +} + +/// Starts `run_daily` at 06:00 on `clock` with a 2 ms poll; every run sends the clock's time. +fn daily(clock: &FixedClock, run_at_start: bool) -> mpsc::UnboundedReceiver { + let (tx, rx) = mpsc::unbounded_channel(); + let clock2 = clock.clone(); + let shared: Arc = Arc::new(clock.clone()); + tokio::spawn(async move { + run_daily( + shared, + GENERATION_TIME, + run_at_start, + Duration::from_millis(2), + || { + let tx = tx.clone(); + let now = kvitt_server::time::Clock::now_ts(&clock2); + async move { + let _ = tx.send(now); + } + }, + ) + .await; + }); + rx +} + +async fn next(rx: &mut mpsc::UnboundedReceiver) -> Option { + tokio::time::timeout(Duration::from_millis(200), rx.recv()) + .await + .ok() + .flatten() +} + +#[tokio::test] +async fn runs_at_start_then_at_06_00_and_survives_clock_jumps() { + let clock = FixedClock::new(ts("2026-10-17T03:00:00.000Z")); // 05:00 CEST + let mut rx = daily(&clock, true); + assert_eq!( + next(&mut rx).await.as_deref(), + Some("2026-10-17T03:00:00.000Z"), + "run at start" + ); + assert_eq!(next(&mut rx).await, None, "not before 06:00"); + clock.set(ts("2026-10-17T03:59:59.999Z")); + assert_eq!(next(&mut rx).await, None); + clock.set(ts("2026-10-17T04:00:00.000Z")); + assert_eq!( + next(&mut rx).await.as_deref(), + Some("2026-10-17T04:00:00.000Z") + ); + assert_eq!(next(&mut rx).await, None, "once per day"); + // Forward jump over three days (downtime, NTP after boot): one run, catch-up is the job's business. + clock.set(ts("2026-10-20T12:00:00.000Z")); + assert_eq!( + next(&mut rx).await.as_deref(), + Some("2026-10-20T12:00:00.000Z") + ); + assert_eq!(next(&mut rx).await, None); + // Backward jump by a week: the next run is re-planned for the new 06:00, not the old one. + clock.set(ts("2026-10-13T03:00:00.000Z")); + assert_eq!(next(&mut rx).await, None); + clock.set(ts("2026-10-13T04:00:00.000Z")); + assert_eq!( + next(&mut rx).await.as_deref(), + Some("2026-10-13T04:00:00.000Z") + ); +} + +#[tokio::test] +async fn without_run_at_start_the_first_run_is_the_next_local_time() { + let clock = FixedClock::new(ts("2026-10-17T05:00:00.000Z")); + let mut rx = daily(&clock, false); + assert_eq!(next(&mut rx).await, None); + clock.set(ts("2026-10-18T04:00:00.000Z")); + assert_eq!( + next(&mut rx).await.as_deref(), + Some("2026-10-18T04:00:00.000Z") + ); +} + +async fn insert_template( + app: &TestApp, + id: &str, + day: i64, + start: &str, + last: Option<&str>, + paused: bool, + deleted: bool, +) { + let version = app.next_version().await; + sqlx::query( + "INSERT INTO recurring_templates (id, account_id, category_id, amount_ore, description, notes, payer_user_id, \ + split_mode, day_of_month, start_period, end_period, paused, last_generated_period, created_by_user_id, \ + updated_by_user_id, created_at, updated_at, deleted_at, server_version) \ + VALUES (?, ?, ?, 1250000, 'Hyra', '', ?, 'default', ?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?)", + ) + .bind(id) + .bind(ACCOUNT_1) + .bind(HOME) + .bind(ADMIN_ID) + .bind(day) + .bind(start) + .bind(i64::from(paused)) + .bind(last) + .bind(ADMIN_ID) + .bind(ADMIN_ID) + .bind("2026-06-01T00:00:00.000Z") + .bind("2026-06-01T00:00:00.000Z") + .bind(deleted.then_some("2026-06-02T00:00:00.000Z")) + .bind(version) + .execute(&app.state.db) + .await + .unwrap(); +} + +async fn periods(app: &TestApp, template: &str) -> Vec { + sqlx::query_scalar("SELECT recurring_period FROM expenses WHERE recurring_template_id = ? ORDER BY recurring_period") + .bind(template) + .fetch_all(&app.state.db) + .await + .unwrap() +} + +#[tokio::test] +async fn start_runs_generation_catching_up_after_downtime_and_skips_paused_and_deleted() { + let mut app = m4::app().await; + let active = m4::new_id(); + let paused = m4::new_id(); + let deleted = m4::new_id(); + // Last generated July; the server was down since. Now is 17 October 14:00. + insert_template(&app, &active, 15, "2026-06", Some("2026-07"), false, false).await; + insert_template(&app, &paused, 1, "2026-06", None, true, false).await; + insert_template(&app, &deleted, 1, "2026-06", None, false, true).await; + // Missing credentials in the test config: push disabled, the scheduler still runs. + let state = kvitt_server::tasks::start(app.state.clone()).await.unwrap(); + app.state = state; + let deadline = std::time::Instant::now() + Duration::from_secs(5); + while periods(&app, &active).await.len() < 3 && std::time::Instant::now() < deadline { + tokio::time::sleep(Duration::from_millis(10)).await; + } + assert_eq!( + periods(&app, &active).await, + ["2026-08", "2026-09", "2026-10"] + ); + assert!(periods(&app, &paused).await.is_empty()); + assert!(periods(&app, &deleted).await.is_empty()); + let audit: i64 = app + .scalar_i64("SELECT count(*) FROM audit_log WHERE source = 'recurring'") + .await; + assert_eq!(audit, 3); +} + +#[tokio::test] +async fn the_06_00_tick_generates_the_period_due_that_morning() { + let app = m4::app().await; + let id = m4::new_id(); + insert_template(&app, &id, 15, "2026-11", None, false, false).await; + // 15 November 2026, 05:59 CET: not yet. + app.set_now("2026-11-15T04:59:00.000Z"); + generation_job(&app.state).await; + assert!(periods(&app, &id).await.is_empty()); + let state = app.state.clone(); + let shared = state.clock.clone(); + tokio::spawn(async move { + run_daily( + shared, + GENERATION_TIME, + false, + Duration::from_millis(2), + || generation_job(&state), + ) + .await; + }); + tokio::time::sleep(Duration::from_millis(30)).await; + assert!(periods(&app, &id).await.is_empty()); + app.set_now("2026-11-15T05:00:00.000Z"); + let deadline = std::time::Instant::now() + Duration::from_secs(5); + while periods(&app, &id).await.is_empty() && std::time::Instant::now() < deadline { + tokio::time::sleep(Duration::from_millis(5)).await; + } + assert_eq!(periods(&app, &id).await, ["2026-11"]); + let date: String = sqlx::query_scalar("SELECT expense_date FROM expenses") + .fetch_one(&app.state.db) + .await + .unwrap(); + assert_eq!(date, "2026-11-15"); +} + +async fn insert_mutation(app: &TestApp, received_at: &str) -> String { + let id = m4::new_id(); + sqlx::query( + "INSERT INTO sync_mutations (mutation_id, device_id, entity_type, entity_id, result, received_at) \ + VALUES (?, ?, 'expense', ?, '{}', ?)", + ) + .bind(&id) + .bind(ADMIN_DEVICE_ID) + .bind(m4::new_id()) + .bind(received_at) + .execute(&app.state.db) + .await + .unwrap(); + id +} + +#[tokio::test] +async fn pruning_deletes_rows_older_than_90_days_and_keeps_the_rest() { + let app = m4::app().await; + let now = ts(NOW); + insert_mutation( + &app, + &format_ts(now - ChronoDuration::days(90) - ChronoDuration::milliseconds(1)), + ) + .await; + insert_mutation(&app, "2026-01-01T00:00:00.000Z").await; + let boundary = insert_mutation(&app, &format_ts(now - ChronoDuration::days(90))).await; + let recent = insert_mutation(&app, &format_ts(now - ChronoDuration::days(89))).await; + prune_job(&app.state).await; + let left: Vec = + sqlx::query_scalar("SELECT mutation_id FROM sync_mutations ORDER BY received_at") + .fetch_all(&app.state.db) + .await + .unwrap(); + assert_eq!(left, [boundary, recent]); +} + +#[tokio::test] +async fn pruning_runs_at_04_00_local() { + let app = m4::app().await; + // 03:59 CEST on 18 October. + app.set_now("2026-10-18T01:59:00.000Z"); + insert_mutation(&app, "2026-07-01T00:00:00.000Z").await; + let state = app.state.clone(); + let shared = state.clock.clone(); + tokio::spawn(async move { + run_daily(shared, PRUNE_TIME, false, Duration::from_millis(2), || { + prune_job(&state) + }) + .await; + }); + tokio::time::sleep(Duration::from_millis(30)).await; + assert_eq!(m4::count(&app, "sync_mutations").await, 1); + app.set_now("2026-10-18T02:00:00.000Z"); + let deadline = std::time::Instant::now() + Duration::from_secs(5); + while m4::count(&app, "sync_mutations").await > 0 && std::time::Instant::now() < deadline { + tokio::time::sleep(Duration::from_millis(5)).await; + } + assert_eq!(m4::count(&app, "sync_mutations").await, 0); +} diff --git a/server/crates/kvitt-server/tests/skeleton/routes.rs b/server/crates/kvitt-server/tests/skeleton/routes.rs index 7f747e5..9ec1df8 100644 --- a/server/crates/kvitt-server/tests/skeleton/routes.rs +++ b/server/crates/kvitt-server/tests/skeleton/routes.rs @@ -1,5 +1,5 @@ -//! Every route of SPEC §7 is registered; E-1 and E-2 work; the rest answer 501 `not_implemented` behind the -//! protocol rule (SPEC §14.3.1) and the auth extractor (SPEC §8.2, §8.4). +//! Every route of SPEC §7 is registered and implemented (none answers 501), behind the protocol rule (SPEC §14.3.1) +//! and the auth extractor (SPEC §8.2, §8.4). use axum::Router; use axum::body::Body; @@ -72,7 +72,7 @@ fn assert_error(reply: &Reply, status: StatusCode, code: &str) { } #[tokio::test] -async fn every_route_is_registered_and_answers_501_until_implemented() { +async fn every_route_is_registered_and_implemented() { let app = TestApp::household().await; let routes = authenticated_routes(); assert_eq!( @@ -80,20 +80,12 @@ async fn every_route_is_registered_and_answers_501_until_implemented() { 33, "SPEC §7 has 33 operations" ); - // M3 implemented E-3 to E-30 and E-33 (tests/core_api); only M4's sync routes still answer 501. - for (method, path, _) in routes - .into_iter() - .filter(|(_, p, _)| p.starts_with("/sync")) - { + // M3 implemented E-3 to E-30 and E-33 (tests/core_api), M4 the sync routes (tests/sync_api): nothing is 501. + for (method, path, _) in routes { let reply = app .send(authed(method.clone(), &path, &admin_token())) .await; - assert_error(&reply, StatusCode::NOT_IMPLEMENTED, "not_implemented"); - assert_eq!( - reply.json, - json!({"code": "not_implemented", "message": "not implemented", "field": null}), - "{method} {path}" - ); + assert_ne!(reply.status, StatusCode::NOT_IMPLEMENTED, "{method} {path}"); assert_eq!( reply.headers["x-kvitt-server-version"], env!("CARGO_PKG_VERSION") diff --git a/server/crates/kvitt-server/tests/sync_api/audit.rs b/server/crates/kvitt-server/tests/sync_api/audit.rs new file mode 100644 index 0000000..9d06f0b --- /dev/null +++ b/server/crates/kvitt-server/tests/sync_api/audit.rs @@ -0,0 +1,90 @@ +//! The real audit writer (SPEC §14.4.7, FR-12) through M3's recurring generation and through sync: source, actor, +//! times and append-only entries. + +use serde_json::json; + +use crate::common::*; +use crate::m4::*; + +#[tokio::test] +async fn generation_writes_recurring_audit_with_server_now_and_no_device() { + let app = app().await; + app.set_now("2026-10-17T12:00:00.000Z"); + let template = new_id(); + sqlx::query( + "INSERT INTO recurring_templates (id, account_id, category_id, amount_ore, description, notes, payer_user_id, \ + split_mode, day_of_month, start_period, created_by_user_id, updated_by_user_id, created_at, updated_at, \ + server_version) VALUES (?, ?, ?, 1250000, 'Hyra', '', ?, 'default', 1, '2026-10', ?, ?, ?, ?, 900000)", + ) + .bind(&template) + .bind(ACCOUNT_1) + .bind(HOME) + .bind(ADMIN_ID) + .bind(MEMBER_ID) + .bind(MEMBER_ID) + .bind(NOW) + .bind(NOW) + .execute(&app.state.db) + .await + .unwrap(); + let generated = + kvitt_server::api::recurring::generate_due_periods(&app.state, template.parse().unwrap()) + .await + .unwrap(); + assert_eq!(generated.len(), 1); + let audit = audit_of(&app, &generated[0].expense_id.to_string()).await; + assert_eq!(audit.len(), 1); + let a = &audit[0]; + assert_eq!( + (a.action.as_str(), a.source.as_str()), + ("create", "recurring") + ); + assert_eq!(a.actor_user_id, MEMBER_ID); + assert_eq!(a.actor_device_id, None); + assert_eq!((a.client_at.as_str(), a.server_at.as_str()), (NOW, NOW)); + assert_eq!( + a.changes[3], + json!({"field": "amount_ore", "old": null, "new": 1250000}) + ); + // The entry reaches the phones through pull. + let page = pull(&app, &admin_token(), 0, None).await.json; + assert!( + page["audit_entries"] + .as_array() + .unwrap() + .iter() + .any(|e| e["source"] == "recurring") + ); +} + +#[tokio::test] +async fn entries_are_append_only_create_update_delete() { + let app = app().await; + let id = new_id(); + push_one( + &app, + &admin_token(), + exp(expense(&id, "2026-10-17T10:00:00.000Z")), + ) + .await; + let first = audit_of(&app, &id).await; + let mut e = expense(&id, "2026-10-17T11:00:00.000Z"); + e["description"] = json!("Coop"); + push_one(&app, &member_token(), exp(e)).await; + let mut d = expense(&id, "2026-10-17T11:30:00.000Z"); + d["deleted_at"] = json!("2026-10-17T11:30:00.000Z"); + push_one(&app, &admin_token(), exp(d)).await; + let all = audit_of(&app, &id).await; + let actions: Vec<_> = all.iter().map(|a| a.action.as_str()).collect(); + assert_eq!(actions, ["create", "update", "delete"]); + assert_eq!(all[0], first[0], "earlier entries are never updated"); + assert_eq!( + all[1].changes, + json!([{"field": "description", "old": "ICA Maxi", "new": "Coop"}]) + ); + assert_eq!(all[1].actor_user_id, MEMBER_ID); + assert!( + all.windows(2) + .all(|w| w[0].server_version < w[1].server_version) + ); +} diff --git a/server/crates/kvitt-server/tests/sync_api/conflicts.rs b/server/crates/kvitt-server/tests/sync_api/conflicts.rs new file mode 100644 index 0000000..51f4ffa --- /dev/null +++ b/server/crates/kvitt-server/tests/sync_api/conflicts.rs @@ -0,0 +1,173 @@ +//! The §9.10 conflict cases, end to end through E-32 and E-31. + +use serde_json::json; + +use crate::common::*; +use crate::m4::*; + +const T0: &str = "2026-10-17T10:00:00.000Z"; + +fn edit(id: &str, at: &str, description: &str) -> serde_json::Value { + let mut e = expense(id, at); + e["created_at"] = json!(T0); + e["description"] = json!(description); + e +} + +fn delete(id: &str, at: &str) -> serde_json::Value { + let mut e = expense(id, at); + e["created_at"] = json!(T0); + e["deleted_at"] = json!(at); + e +} + +#[tokio::test] +async fn both_users_edit_offline_the_later_updated_at_wins_in_either_arrival_order() { + for later_arrives_first in [true, false] { + let app = crate::m4::app().await; + let id = new_id(); + push_one(&app, &admin_token(), exp(expense(&id, T0))).await; + let anna = exp(edit(&id, "2026-10-17T11:00:00.000Z", "Anna's")); + let jacob = exp(edit(&id, "2026-10-17T11:05:00.000Z", "Jacob's")); + if later_arrives_first { + accepted(&push_one(&app, &admin_token(), jacob).await); + let r = push_one(&app, &member_token(), anna).await; + rejected(&r, "stale"); + assert_eq!(r["current"]["description"], "Jacob's"); + } else { + accepted(&push_one(&app, &member_token(), anna).await); + accepted(&push_one(&app, &admin_token(), jacob).await); + } + let page = pull(&app, &member_token(), 0, None).await.json; + assert_eq!(page["expenses"][0]["description"], "Jacob's"); + // Only accepted edits are logged; the winning edit is the last one. + let audit = audit_of(&app, &id).await; + assert_eq!(audit.last().unwrap().changes[0]["new"], "Jacob's"); + assert_eq!(audit.len(), if later_arrives_first { 2 } else { 3 }); + } +} + +#[tokio::test] +async fn one_deletes_the_other_edits_delete_wins_regardless_of_order_and_timestamps() { + // Delete first, then a newer edit: rejected deleted. + let app = crate::m4::app().await; + let id = new_id(); + push_one(&app, &admin_token(), exp(expense(&id, T0))).await; + accepted( + &push_one( + &app, + &admin_token(), + exp(delete(&id, "2026-10-17T11:00:00.000Z")), + ) + .await, + ); + let r = push_one( + &app, + &member_token(), + exp(edit(&id, "2026-10-17T11:30:00.000Z", "edit")), + ) + .await; + rejected(&r, "deleted"); + // Edit first, then an older delete: applied. + let app = crate::m4::app().await; + let id = new_id(); + push_one(&app, &admin_token(), exp(expense(&id, T0))).await; + accepted( + &push_one( + &app, + &member_token(), + exp(edit(&id, "2026-10-17T11:30:00.000Z", "edit")), + ) + .await, + ); + let entity = accepted( + &push_one( + &app, + &admin_token(), + exp(delete(&id, "2026-10-17T11:00:00.000Z")), + ) + .await, + ); + assert_eq!(entity["deleted_at"], "2026-10-17T11:00:00.000Z"); + assert_eq!(entity["description"], "edit"); +} + +#[tokio::test] +async fn same_user_on_two_devices_is_lww_per_row() { + let app = crate::m4::app().await; + // A second, non-revoked device of the admin. + let second = token('S'); + app.insert_device( + "0192a001-0000-7000-8000-000000000009", + ADMIN_ID, + "Tablet", + &second, + None, + ) + .await; + let id = new_id(); + push_one(&app, &admin_token(), exp(expense(&id, T0))).await; + accepted( + &push_one( + &app, + &second, + exp(edit(&id, "2026-10-17T11:05:00.000Z", "tablet")), + ) + .await, + ); + rejected( + &push_one( + &app, + &admin_token(), + exp(edit(&id, "2026-10-17T11:00:00.000Z", "phone")), + ) + .await, + "stale", + ); +} + +#[tokio::test] +async fn an_expense_in_an_account_deleted_meanwhile_is_reference_not_found_archived_category_accepted() + { + let app = crate::m4::app().await; + tombstone(&app, "accounts", ACCOUNT_1).await; + rejected( + &push_one(&app, &admin_token(), exp(expense(&new_id(), T0))).await, + "reference_not_found", + ); + sqlx::query("UPDATE categories SET archived_at = ? WHERE id = ?") + .bind(NOW) + .bind(GROCERIES) + .execute(&app.state.db) + .await + .unwrap(); + let mut e = expense(&new_id(), T0); + e["account_id"] = json!(ACCOUNT_2); + accepted(&push_one(&app, &admin_token(), exp(e)).await); +} + +#[tokio::test] +async fn epoch_recovery_re_push_of_identical_rows_comes_back_stale_with_equal_content() { + // §14.4.8: after a restore the phone re-pushes everything; identical rows are stale with current equal to the + // pushed client-writable fields (the phone treats that as accepted), missing rows are inserted. + let app = crate::m4::app().await; + let kept = new_id(); + let entity = expense(&kept, T0); + push_one(&app, &admin_token(), exp(entity.clone())).await; + kvitt_server::cli::rotate_epoch::rotate(&app.state.db) + .await + .unwrap(); + let lost = new_id(); + let reply = push( + &app, + &admin_token(), + vec![exp(entity.clone()), exp(expense(&lost, T0))], + ) + .await; + let results = reply.json["results"].as_array().unwrap(); + rejected(&results[0], "stale"); + for (k, v) in entity.as_object().unwrap() { + assert_eq!(&results[0]["current"][k], v, "{k}"); + } + accepted(&results[1]); +} diff --git a/server/crates/kvitt-server/tests/sync_api/e2e.rs b/server/crates/kvitt-server/tests/sync_api/e2e.rs new file mode 100644 index 0000000..1488277 --- /dev/null +++ b/server/crates/kvitt-server/tests/sync_api/e2e.rs @@ -0,0 +1,49 @@ +//! End to end: device A creates through E-32, device B pulls through E-31 and sees the row and its audit entry +//! (AC-12.3); exactly one push is queued on the real queue, for the other user's devices. + +use std::sync::Arc; + +use kvitt_server::push::{PushHub, PushType}; + +use crate::common::*; +use crate::m4::*; + +#[tokio::test] +async fn create_on_device_a_pull_on_device_b_row_audit_and_one_push() { + let mut app = app().await; + let hub = Arc::new(PushHub::new()); + with_push(&mut app, hub.clone()); + let id = new_id(); + let cursor_b = pull(&app, &member_token(), 0, Some(1000)).await.json["next_since"] + .as_i64() + .unwrap(); + + let result = push_one( + &app, + &admin_token(), + exp(expense(&id, "2026-10-17T11:59:00.000Z")), + ) + .await; + let created = accepted(&result); + + let page = pull(&app, &member_token(), cursor_b, None).await.json; + assert_eq!(page["expenses"].as_array().unwrap().len(), 1); + assert_eq!(page["expenses"][0], created); + let audit = page["audit_entries"].as_array().unwrap(); + assert_eq!(audit.len(), 1); + assert_eq!(audit[0]["entity_id"], id); + assert_eq!(audit[0]["action"], "create"); + assert_eq!(audit[0]["source"], "app"); + assert_eq!(audit[0]["actor_user_id"], ADMIN_ID); + assert_eq!(audit[0]["client_at"], "2026-10-17T11:59:00.000Z"); + assert_eq!(audit[0]["changes"].as_array().unwrap().len(), 9); + assert!(audit[0]["server_version"].as_i64() > created["server_version"].as_i64()); + assert_eq!(page["next_since"], audit[0]["server_version"]); + + assert_eq!(hub.len(), 1); + let message = hub.pop().unwrap(); + assert_eq!(message.push_type, PushType::ExpenseCreated); + assert_eq!(message.entity_id.unwrap().to_string(), id); + assert_eq!(message.actor_user_id.to_string(), ADMIN_ID); + assert_eq!(message.count, 1); +} diff --git a/server/crates/kvitt-server/tests/sync_api/m4.rs b/server/crates/kvitt-server/tests/sync_api/m4.rs new file mode 100644 index 0000000..3055c1c --- /dev/null +++ b/server/crates/kvitt-server/tests/sync_api/m4.rs @@ -0,0 +1,225 @@ +//! M4 additions to M3's harness: an app with the real audit writer, entity and mutation builders, push and pull +//! helpers, and audit row access. Shared by the `sync_api`, `push_fcm` and `scheduler_daily` test binaries. + +use std::sync::Arc; + +use axum::http::Method; +use kvitt_server::audit::SqlAuditWriter; +use kvitt_server::push::PushQueue; +use kvitt_server::router; +use serde_json::{Value, json}; +use uuid::Uuid; + +use crate::common::*; + +/// The household of `TestApp::household` with accounts ACCOUNT_1 (50/50) and ACCOUNT_2 (50/50) and the real audit +/// writer; pushes go to the recording queue. +pub async fn app() -> TestApp { + let mut app = TestApp::household().await; + app.insert_account(ACCOUNT_1, "Vardag", "2026-10-01T00:00:00.000Z", 5000) + .await; + app.insert_account(ACCOUNT_2, "Resor", "2026-10-02T00:00:00.000Z", 5000) + .await; + app.state.audit = Arc::new(SqlAuditWriter); + app.router = router::router(app.state.clone()); + app +} + +/// Replaces the push queue (and rebuilds the router). +pub fn with_push(app: &mut TestApp, push: Arc) { + app.state.push = push; + app.router = router::router(app.state.clone()); +} + +pub fn new_id() -> String { + Uuid::now_v7().to_string() +} + +/// A pushed expense: 412,00 kr ICA Maxi in ACCOUNT_1, groceries, paid by the admin, mode default 50/50. +pub fn expense(id: &str, updated_at: &str) -> Value { + json!({ + "id": id, "account_id": ACCOUNT_1, "category_id": GROCERIES, "amount_ore": 41200, + "description": "ICA Maxi", "notes": "", "expense_date": "2026-10-16", "payer_user_id": ADMIN_ID, + "split_mode": "default", + "splits": [ + {"user_id": ADMIN_ID, "share_ore": 20600, "share_bp": 5000}, + {"user_id": MEMBER_ID, "share_ore": 20600, "share_bp": 5000} + ], + "created_at": updated_at, "updated_at": updated_at, "deleted_at": null + }) +} + +/// A pushed settlement: the member pays the admin 500,00 kr, 300 to ACCOUNT_1 and 200 to ACCOUNT_2. +pub fn settlement(id: &str, updated_at: &str) -> Value { + json!({ + "id": id, "from_user_id": MEMBER_ID, "to_user_id": ADMIN_ID, "amount_ore": 50000, + "settlement_date": "2026-10-16", "note": "", + "allocations": [ + {"account_id": ACCOUNT_1, "amount_ore": 30000}, {"account_id": ACCOUNT_2, "amount_ore": 20000} + ], + "created_at": updated_at, "updated_at": updated_at, "deleted_at": null + }) +} + +/// One mutation with a new mutation id. +pub fn mutation(entity_type: &str, entity: Value) -> Value { + json!({"mutation_id": new_id(), "entity_type": entity_type, "entity": entity}) +} + +pub fn exp(entity: Value) -> Value { + mutation("expense", entity) +} + +pub fn set(entity: Value) -> Value { + mutation("settlement", entity) +} + +/// `POST /sync` with these mutations. +pub async fn push(app: &TestApp, tok: &str, mutations: Vec) -> Reply { + app.call( + Method::POST, + "/sync", + tok, + Some(json!({ "mutations": mutations })), + ) + .await +} + +/// `POST /sync` with one mutation; asserts 200 and returns its result. +pub async fn push_one(app: &TestApp, tok: &str, mutation: Value) -> Value { + let reply = push(app, tok, vec![mutation]).await; + assert_eq!(reply.status, 200, "{}", reply.text()); + reply.json["results"][0].clone() +} + +/// `GET /sync`. +pub async fn pull(app: &TestApp, tok: &str, since: i64, limit: Option) -> Reply { + let path = match limit { + Some(l) => format!("/sync?since={since}&limit={l}"), + None => format!("/sync?since={since}"), + }; + app.call(Method::GET, &path, tok, None).await +} + +/// Asserts an accepted result for `entity_id` and returns its entity. +#[track_caller] +pub fn accepted(result: &Value) -> Value { + assert_eq!(result["status"], "accepted", "{result:#}"); + assert_eq!(result["server_version"], result["entity"]["server_version"]); + result["entity"].clone() +} + +/// Asserts a rejected result with `reason`. +#[track_caller] +pub fn rejected(result: &Value, reason: &str) { + assert_eq!(result["status"], "rejected", "{result:#}"); + assert_eq!(result["reason"], reason, "{result:#}"); + assert!(result["message"].is_string()); + let keys: Vec<_> = result.as_object().unwrap().keys().cloned().collect(); + assert_eq!( + keys, + [ + "current", + "entity_id", + "entity_type", + "message", + "mutation_id", + "reason", + "status" + ] + ); +} + +/// One audit row as stored. +#[derive(Debug, Clone, PartialEq)] +pub struct AuditRow { + pub action: String, + pub source: String, + pub actor_user_id: String, + pub actor_device_id: Option, + pub client_at: String, + pub server_at: String, + pub changes: Value, + pub server_version: i64, +} + +#[derive(sqlx::FromRow)] +struct RawAudit { + action: String, + source: String, + actor_user_id: String, + actor_device_id: Option, + client_at: String, + server_at: String, + changes: String, + server_version: i64, +} + +/// The audit rows of an entity, oldest first. +pub async fn audit_of(app: &TestApp, entity_id: &str) -> Vec { + let rows: Vec = sqlx::query_as( + "SELECT action, source, actor_user_id, actor_device_id, client_at, server_at, changes, server_version \ + FROM audit_log WHERE entity_id = ? ORDER BY server_version", + ) + .bind(entity_id) + .fetch_all(&app.state.db) + .await + .unwrap(); + rows.into_iter() + .map(|r| AuditRow { + action: r.action, + source: r.source, + actor_user_id: r.actor_user_id, + actor_device_id: r.actor_device_id, + client_at: r.client_at, + server_at: r.server_at, + changes: serde_json::from_str(&r.changes).unwrap(), + server_version: r.server_version, + }) + .collect() +} + +/// The pushes enqueued so far, as `(type, entity id, actor, count)`. +pub fn pushes(app: &TestApp) -> Vec<(String, Option, String, u32)> { + app.push + .0 + .lock() + .unwrap() + .iter() + .map(|m| { + ( + m.push_type.as_str().to_owned(), + m.entity_id.map(|id| id.to_string()), + m.actor_user_id.to_string(), + m.count, + ) + }) + .collect() +} + +/// `count(*)` of a table. +pub async fn count(app: &TestApp, table: &str) -> i64 { + sqlx::query_scalar::<_, i64>(sqlx::AssertSqlSafe(format!("SELECT count(*) FROM {table}"))) + .fetch_one(&app.state.db) + .await + .unwrap() +} + +/// The current `sync_sequence` value. +pub async fn sequence(app: &TestApp) -> i64 { + app.scalar_i64("SELECT value FROM sync_sequence").await +} + +/// Marks a row deleted directly (an account or category deleted through REST meanwhile). +pub async fn tombstone(app: &TestApp, table: &str, id: &str) { + let version = app.next_version().await; + sqlx::query(sqlx::AssertSqlSafe(format!( + "UPDATE {table} SET deleted_at = ?, server_version = ? WHERE id = ?" + ))) + .bind(NOW) + .bind(version) + .bind(id) + .execute(&app.state.db) + .await + .unwrap(); +} diff --git a/server/crates/kvitt-server/tests/sync_api/main.rs b/server/crates/kvitt-server/tests/sync_api/main.rs new file mode 100644 index 0000000..4d39a6e --- /dev/null +++ b/server/crates/kvitt-server/tests/sync_api/main.rs @@ -0,0 +1,24 @@ +//! M4 sync integration tests: every rule of SPEC §14.4.2 (pull) and §14.4.4 (push steps 1 to 12), each rejection +//! reason of §14.4.6, the §9.10 conflict cases, batch limits, `cursor_ahead`, the epoch, the 14-day offline scenario +//! (AC-15.6), the audit writer (§14.4.7), `rotate-epoch` (§10.4), the end-to-end path device A push → device B pull, +//! and the orchestrator-assigned `serve` fixes (ConnectInfo for the rate limiter, `/i/` HTML 404). +//! +//! Reuses M3's harness (`tests/core_api/common.rs`) with the real `SqlAuditWriter` swapped in (`m4.rs`). + +#[path = "../core_api/common.rs"] +pub mod common; +pub mod m4; + +mod audit; +mod conflicts; +mod e2e; +mod offline; +mod pull; +mod push_rules; +mod serve; + +impl Default for common::TempDir { + fn default() -> Self { + Self::new() + } +} diff --git a/server/crates/kvitt-server/tests/sync_api/offline.rs b/server/crates/kvitt-server/tests/sync_api/offline.rs new file mode 100644 index 0000000..0c85082 --- /dev/null +++ b/server/crates/kvitt-server/tests/sync_api/offline.rs @@ -0,0 +1,72 @@ +//! AC-15.6, server side: a phone offline for 14 days with 200 local changes syncs all of them on reconnect (scripted +//! clock: the changes carry the times they were made, the push happens 14 days later). + +use chrono::Duration; +use kvitt_server::time::{format_ts, parse_ts}; +use serde_json::{Value, json}; + +use crate::common::*; +use crate::m4::*; + +#[tokio::test] +async fn fourteen_days_offline_200_changes_all_sync() { + let app = app().await; + // Anna's tablet-free phone: 150 creates, 30 edits of earlier ones, 20 deletes, spread over 14 days. + let start = parse_ts(NOW).unwrap(); + let at = |i: i64| format_ts(start + Duration::minutes(i * 100)); + let mut latest: Vec = Vec::new(); + let mut changes: Vec = Vec::new(); + for i in 0..150 { + let mut e = expense(&new_id(), &at(i)); + e["description"] = json!(format!("Köp {i}")); + latest.push(e.clone()); + changes.push(e); + } + for (i, row) in latest.iter_mut().enumerate().take(30) { + row["updated_at"] = json!(at(150 + i as i64)); + row["notes"] = json!("ändrad"); + changes.push(row.clone()); + } + for (i, row) in latest.iter_mut().enumerate().skip(30).take(20) { + row["updated_at"] = json!(at(180 + i as i64 - 30)); + row["deleted_at"] = row["updated_at"].clone(); + changes.push(row.clone()); + } + assert_eq!(changes.len(), 200); + assert!( + parse_ts(changes[199]["updated_at"].as_str().unwrap()).unwrap() + < start + Duration::days(14) + ); + // Meanwhile Jacob kept syncing; the server clock is 14 days on. + app.set_now(&format_ts(start + Duration::days(14))); + + // The phone pushes its pending rows (the latest state of each, as §14.4.5 reads them) in batches of 100. + let pending: Vec = latest.iter().map(|e| exp(e.clone())).collect(); + for batch in pending.chunks(100) { + let reply = push(&app, &member_token(), batch.to_vec()).await; + assert_eq!(reply.status, 200); + for r in reply.json["results"].as_array().unwrap() { + accepted(r); + } + } + // And Jacob's phone pulls everything (cursor from before the outage stays valid). + let mut since = 0; + let mut expenses = Vec::new(); + loop { + let page = pull(&app, &admin_token(), since, Some(1000)).await.json; + expenses.extend(page["expenses"].as_array().unwrap().clone()); + since = page["next_since"].as_i64().unwrap(); + if page["has_more"] == false { + break; + } + } + assert_eq!(expenses.len(), 150); + let edited = expenses.iter().filter(|e| e["notes"] == "ändrad").count(); + let deleted = expenses + .iter() + .filter(|e| !e["deleted_at"].is_null()) + .count(); + assert_eq!((edited, deleted), (30, 20)); + // Every create of a row that was later deleted offline was never seen by the server: tombstone, no audit. + assert_eq!(count(&app, "audit_log").await, 130); +} diff --git a/server/crates/kvitt-server/tests/sync_api/pull.rs b/server/crates/kvitt-server/tests/sync_api/pull.rs new file mode 100644 index 0000000..39a1b8d --- /dev/null +++ b/server/crates/kvitt-server/tests/sync_api/pull.rs @@ -0,0 +1,227 @@ +//! E-31 pull (SPEC §14.4.2): cursor rules, paging, ordering, tombstones, children with their parent, the epoch. + +use axum::http::Method; +use serde_json::Value; + +use crate::common::*; +use crate::m4::*; + +const T1: &str = "2026-10-17T11:00:00.000Z"; +const ARRAYS: [&str; 7] = [ + "users", + "categories", + "accounts", + "recurring_templates", + "expenses", + "settlements", + "audit_entries", +]; + +fn versions(page: &Value) -> Vec { + let mut all: Vec = ARRAYS + .iter() + .flat_map(|a| { + page[*a] + .as_array() + .unwrap() + .iter() + .map(|r| r["server_version"].as_i64().unwrap()) + }) + .collect(); + all.sort(); + all +} + +#[tokio::test] +async fn since_greater_than_the_sequence_is_cursor_ahead_equal_is_an_empty_page() { + let app = app().await; + let current = sequence(&app).await; + let reply = pull(&app, &admin_token(), current + 1, None).await; + assert_error(&reply, 409, "cursor_ahead"); + let reply = pull(&app, &admin_token(), current, None).await; + assert_eq!(reply.status, 200); + assert_like_example( + &reply.json, + "sync/pull-empty-page.json", + &["epoch", "since", "next_since", "server_time"], + ); + assert_eq!(reply.json["next_since"], current); + assert_eq!(reply.json["has_more"], false); +} + +#[tokio::test] +async fn query_parameters_are_checked() { + let app = app().await; + for path in [ + "/sync", + "/sync?since=abc", + "/sync?since=-1", + "/sync?since=0&limit=0", + "/sync?since=0&limit=1001", + "/sync?since=0&limit=x", + ] { + let reply = app.call(Method::GET, path, &admin_token(), None).await; + assert_error(&reply, 400, "invalid_request"); + } + assert_eq!(pull(&app, &admin_token(), 0, Some(1000)).await.status, 200); + assert_eq!(pull(&app, &admin_token(), 0, Some(1)).await.status, 200); +} + +#[tokio::test] +async fn the_first_page_has_everything_in_version_order_with_the_epoch_and_server_time() { + let app = app().await; + let id = new_id(); + push_one(&app, &admin_token(), exp(expense(&id, T1))).await; + push_one(&app, &admin_token(), set(settlement(&new_id(), T1))).await; + let reply = pull(&app, &member_token(), 0, None).await; + assert_eq!(reply.status, 200); + let page = &reply.json; + let epoch: String = sqlx::query_scalar("SELECT value FROM meta WHERE key = 'sync_epoch'") + .fetch_one(&app.state.db) + .await + .unwrap(); + assert_eq!(page["epoch"], epoch); + assert_eq!(page["since"], 0); + assert_eq!(page["server_time"], NOW); + assert_eq!(page["has_more"], false); + assert_eq!(page["next_since"], sequence(&app).await); + let counts: Vec = ARRAYS + .iter() + .map(|a| page[*a].as_array().unwrap().len()) + .collect(); + // 2 users, 10 default categories, 2 accounts, no templates, 1 expense, 1 settlement, 2 audit entries. + assert_eq!(counts, [2, 10, 2, 0, 1, 1, 2]); + for a in ARRAYS { + let v: Vec = page[a] + .as_array() + .unwrap() + .iter() + .map(|r| r["server_version"].as_i64().unwrap()) + .collect(); + let mut sorted = v.clone(); + sorted.sort(); + assert_eq!(v, sorted, "{a} sorted by server_version"); + } + // Children come with the parent. + assert_eq!(page["expenses"][0]["splits"].as_array().unwrap().len(), 2); + assert_eq!( + page["accounts"][0]["default_shares"] + .as_array() + .unwrap() + .len(), + 2 + ); + assert_eq!(page["audit_entries"][0]["entity_id"], id); + let keys: Vec<_> = page["audit_entries"][0] + .as_object() + .unwrap() + .keys() + .cloned() + .collect(); + assert_eq!( + keys, + [ + "action", + "actor_user_id", + "changes", + "client_at", + "entity_id", + "entity_type", + "id", + "server_at", + "server_version", + "source" + ] + ); +} + +#[tokio::test] +async fn paging_walks_every_row_once_parents_with_children_count_as_one() { + let app = app().await; + for _ in 0..9 { + push_one(&app, &admin_token(), exp(expense(&new_id(), T1))).await; + } + // 2 users + 10 categories + 2 accounts + 9 expenses + 9 audit entries = 32 rows. + let mut since = 0; + let mut seen = Vec::new(); + let mut pages = 0; + loop { + let reply = pull(&app, &admin_token(), since, Some(5)).await; + let page = &reply.json; + let v = versions(page); + assert!(v.len() <= 5); + assert_eq!(page["since"], since); + assert_eq!(page["next_since"], v.last().copied().unwrap_or(since)); + assert!(v.iter().all(|x| *x > since)); + seen.extend(v); + since = page["next_since"].as_i64().unwrap(); + pages += 1; + if page["has_more"] == false { + break; + } + } + assert_eq!(pages, 7); + assert_eq!(seen.len(), 32); + let mut dedup = seen.clone(); + dedup.dedup(); + assert_eq!(dedup, seen); + // The final empty page. + let reply = pull(&app, &admin_token(), since, Some(5)).await; + assert_eq!(reply.json["next_since"], since); + assert_eq!(reply.json["has_more"], false); + // has_more is false when the page ends exactly on the last row. + let reply = pull(&app, &admin_token(), 0, Some(32)).await; + assert_eq!(reply.json["has_more"], false); + let reply = pull(&app, &admin_token(), 0, Some(31)).await; + assert_eq!(reply.json["has_more"], true); +} + +#[tokio::test] +async fn the_default_limit_is_500() { + let app = app().await; + for _ in 0..500 { + app.insert_expense(ACCOUNT_1, GROCERIES, 100, ADMIN_ID, (50, 50), false) + .await; + } + let reply = pull(&app, &admin_token(), 0, None).await; + assert_eq!(versions(&reply.json).len(), 500); + assert_eq!(reply.json["has_more"], true); +} + +#[tokio::test] +async fn tombstones_are_pulled_and_updated_rows_move_to_their_new_version() { + let app = app().await; + let id = new_id(); + push_one(&app, &admin_token(), exp(expense(&id, T1))).await; + let cursor = sequence(&app).await; + let mut del = expense(&id, "2026-10-17T11:30:00.000Z"); + del["deleted_at"] = serde_json::json!("2026-10-17T11:30:00.000Z"); + push_one(&app, &admin_token(), exp(del)).await; + let page = pull(&app, &member_token(), cursor, None).await.json; + assert_eq!(page["expenses"].as_array().unwrap().len(), 1); + assert_eq!( + page["expenses"][0]["deleted_at"], + "2026-10-17T11:30:00.000Z" + ); + assert_eq!(page["audit_entries"][0]["action"], "delete"); + // A full pull holds the row once, at its new version. + let page = pull(&app, &member_token(), 0, None).await.json; + assert_eq!(page["expenses"].as_array().unwrap().len(), 1); +} + +#[tokio::test] +async fn an_epoch_change_is_visible_in_pull() { + let app = app().await; + let before = pull(&app, &admin_token(), 0, Some(1)).await.json["epoch"].clone(); + let new = kvitt_server::cli::rotate_epoch::rotate(&app.state.db) + .await + .unwrap(); + let after = pull(&app, &admin_token(), 0, Some(1)).await.json["epoch"].clone(); + assert_ne!(before, after); + assert_eq!(after, new); + assert_eq!(new.len(), 32); + assert!( + new.chars() + .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()) + ); +} diff --git a/server/crates/kvitt-server/tests/sync_api/push_rules.rs b/server/crates/kvitt-server/tests/sync_api/push_rules.rs new file mode 100644 index 0000000..03d640e --- /dev/null +++ b/server/crates/kvitt-server/tests/sync_api/push_rules.rs @@ -0,0 +1,1042 @@ +//! E-32 push: SPEC §14.4.3 and every step of §14.4.4 in order, each rejection reason of §14.4.6, batch limits and +//! push batching (AC-13.4). + +use axum::http::Method; +use serde_json::{Value, json}; + +use crate::common::*; +use crate::m4::*; + +/// A change applied to a pushed entity. +type Mutator = Box; + +const T1: &str = "2026-10-17T11:00:00.000Z"; +const T2: &str = "2026-10-17T11:30:00.000Z"; + +// ---- whole-request rules ---------------------------------------------------------------------------------------- + +#[tokio::test] +async fn batch_of_101_is_batch_too_large_and_0_too_and_100_is_fine() { + let app = app().await; + let many = |n: usize| { + (0..n) + .map(|_| exp(expense(&new_id(), T1))) + .collect::>() + }; + let reply = push(&app, &admin_token(), many(101)).await; + assert_error(&reply, 422, "batch_too_large"); + assert_eq!(count(&app, "expenses").await, 0); + let reply = push(&app, &admin_token(), Vec::new()).await; + assert_error(&reply, 422, "batch_too_large"); + let reply = push(&app, &admin_token(), many(100)).await; + assert_eq!(reply.status, 200, "{}", reply.text()); + assert_eq!(reply.json["results"].as_array().unwrap().len(), 100); + assert_eq!(count(&app, "expenses").await, 100); +} + +#[tokio::test] +async fn body_over_1_mib_is_payload_too_large() { + let app = app().await; + let mut e = expense(&new_id(), T1); + e["notes"] = json!("x".repeat(1024 * 1024 + 1)); + let reply = push(&app, &admin_token(), vec![exp(e)]).await; + assert_error(&reply, 413, "payload_too_large"); +} + +#[tokio::test] +async fn malformed_body_or_envelope_is_invalid_request() { + let app = app().await; + let reply = app + .call( + Method::POST, + "/sync", + &admin_token(), + Some(json!({"nope": []})), + ) + .await; + assert_error(&reply, 400, "invalid_request"); + // Without a usable mutation_id, entity_type or entity.id there is no per-mutation result to give. + for bad in [ + json!({"mutation_id": "x", "entity_type": "expense", "entity": expense(&new_id(), T1)}), + json!({"mutation_id": new_id(), "entity_type": "account", "entity": expense(&new_id(), T1)}), + json!({"mutation_id": new_id(), "entity_type": "expense", "entity": {"id": 5}}), + json!({"mutation_id": new_id().to_uppercase(), "entity_type": "expense", "entity": expense(&new_id(), T1)}), + ] { + let reply = push(&app, &admin_token(), vec![exp(expense(&new_id(), T1)), bad]).await; + assert_error(&reply, 400, "invalid_request"); + } + assert_eq!(count(&app, "expenses").await, 0); +} + +#[tokio::test] +async fn step2_household_incomplete_fails_the_whole_request() { + let app = TestApp::admin_only().await; + let reply = push(&app, &admin_token(), vec![exp(expense(&new_id(), T1))]).await; + assert_error(&reply, 409, "household_incomplete"); + assert_eq!(count(&app, "sync_mutations").await, 0); +} + +#[tokio::test] +async fn results_come_in_request_order_with_exact_key_sets() { + let app = app().await; + let ids: Vec = (0..3).map(|_| new_id()).collect(); + let mut bad = expense(&ids[1], T1); + bad["amount_ore"] = json!(0); + let mutations = vec![ + exp(expense(&ids[0], T1)), + exp(bad), + set(settlement(&ids[2], T1)), + ]; + let reply = push(&app, &admin_token(), mutations.clone()).await; + assert_eq!(reply.status, 200); + assert_eq!(reply.json["server_time"], NOW); + let results = reply.json["results"].as_array().unwrap(); + for (r, m) in results.iter().zip(&mutations) { + assert_eq!(r["mutation_id"], m["mutation_id"]); + assert_eq!(r["entity_id"], m["entity"]["id"]); + assert_eq!(r["entity_type"], m["entity_type"]); + } + let keys: Vec<_> = results[0].as_object().unwrap().keys().cloned().collect(); + assert_eq!( + keys, + [ + "entity", + "entity_id", + "entity_type", + "mutation_id", + "server_version", + "status" + ] + ); + rejected(&results[1], "validation_failed"); + accepted(&results[2]); +} + +// ---- step 1: replay --------------------------------------------------------------------------------------------- + +#[tokio::test] +async fn step1_replay_returns_the_stored_result_unchanged_with_no_other_effect() { + let app = app().await; + let id = new_id(); + let m = exp(expense(&id, T1)); + let first = push_one(&app, &admin_token(), m.clone()).await; + let version = sequence(&app).await; + let audits = count(&app, "audit_log").await; + let pushed = pushes(&app).len(); + + // The same mutation id with different content (and later in time) replays the first result. + let mut changed = m.clone(); + changed["entity"]["amount_ore"] = json!(1); + changed["entity"]["updated_at"] = json!(T2); + app.set_now("2026-10-18T12:00:00.000Z"); + let again = push_one(&app, &admin_token(), changed).await; + assert_eq!(again, first); + assert_eq!(sequence(&app).await, version); + assert_eq!(count(&app, "audit_log").await, audits); + assert_eq!(pushes(&app).len(), pushed); + assert_eq!(count(&app, "sync_mutations").await, 1); + let amount: i64 = app.scalar_i64("SELECT amount_ore FROM expenses").await; + assert_eq!(amount, 41200); + + // A rejected result replays too. + let mut bad = exp(expense(&new_id(), T1)); + bad["entity"]["description"] = json!(""); + let r1 = push_one(&app, &admin_token(), bad.clone()).await; + rejected(&r1, "validation_failed"); + bad["entity"]["description"] = json!("fixed"); + let r2 = push_one(&app, &admin_token(), bad).await; + assert_eq!(r1, r2); +} + +#[tokio::test] +async fn duplicate_mutation_ids_in_one_request_replay_the_first() { + let app = app().await; + let m = exp(expense(&new_id(), T1)); + let reply = push(&app, &admin_token(), vec![m.clone(), m]).await; + let results = reply.json["results"].as_array().unwrap(); + assert_eq!(results[0], results[1]); + assert_eq!(count(&app, "expenses").await, 1); +} + +// ---- step 3: shape ---------------------------------------------------------------------------------------------- + +#[tokio::test] +async fn step3_shape_errors_are_rejected_validation_failed_per_mutation() { + let app = app().await; + let cases: Vec<(&str, Mutator)> = vec![ + ( + "missing field", + Box::new(|e| { + e.as_object_mut().unwrap().remove("description"); + }), + ), + ( + "missing deleted_at", + Box::new(|e| { + e.as_object_mut().unwrap().remove("deleted_at"); + }), + ), + ("wrong type", Box::new(|e| e["amount_ore"] = json!("41200"))), + ( + "unknown enum", + Box::new(|e| e["split_mode"] = json!("half")), + ), + ( + "not a uuid", + Box::new(|e| e["account_id"] = json!("vardag")), + ), + ( + "uppercase uuid", + Box::new(|e| e["account_id"] = json!(ACCOUNT_1.to_uppercase())), + ), + ( + "split user not a uuid", + Box::new(|e| e["splits"][0]["user_id"] = json!("jacob")), + ), + ( + "timestamp form", + Box::new(|e| e["updated_at"] = json!("2026-10-17T11:00:00Z")), + ), + ( + "deleted_at form", + Box::new(|e| e["deleted_at"] = json!("yesterday")), + ), + ]; + for (name, mutate) in cases { + let mut e = expense(&new_id(), T1); + mutate(&mut e); + let r = push_one(&app, &admin_token(), exp(e)).await; + assert_eq!(r["reason"], "validation_failed", "{name}"); + rejected(&r, "validation_failed"); + assert_eq!(r["current"], Value::Null, "{name}"); + } + let mut s = settlement(&new_id(), T1); + s["allocations"][0]["account_id"] = json!(1); + rejected( + &push_one(&app, &admin_token(), set(s)).await, + "validation_failed", + ); + assert_eq!(count(&app, "expenses").await, 0); + assert_eq!(count(&app, "sync_mutations").await, 10); +} + +#[tokio::test] +async fn server_owned_fields_are_ignored_if_sent() { + let app = app().await; + let mut e = expense(&new_id(), T1); + e["server_version"] = json!(999_999); + e["created_by_user_id"] = json!(MEMBER_ID); + e["recurring_template_id"] = json!(UNKNOWN_ID); + e["recurring_period"] = json!("2026-10"); + let entity = accepted(&push_one(&app, &admin_token(), exp(e)).await); + assert_eq!(entity["created_by_user_id"], ADMIN_ID); + assert_eq!(entity["recurring_template_id"], Value::Null); + assert_eq!(entity["recurring_period"], Value::Null); + assert_ne!(entity["server_version"], 999_999); +} + +// ---- step 4: clock ---------------------------------------------------------------------------------------------- + +#[tokio::test] +async fn step4_updated_at_more_than_10_minutes_ahead_is_clock_skew() { + let app = app().await; + let r = push_one( + &app, + &admin_token(), + exp(expense(&new_id(), "2026-10-17T12:10:00.001Z")), + ) + .await; + rejected(&r, "clock_skew"); + assert_eq!(r["current"], Value::Null); + let r = push_one( + &app, + &admin_token(), + exp(expense(&new_id(), "2026-10-17T12:10:00.000Z")), + ) + .await; + accepted(&r); + // Clock skew comes before the tombstone and validation checks: the stored row is the current. + let id = new_id(); + push_one(&app, &admin_token(), exp(expense(&id, T1))).await; + let mut e = expense(&id, "2026-10-18T12:00:00.000Z"); + e["amount_ore"] = json!(0); + let r = push_one(&app, &admin_token(), exp(e)).await; + rejected(&r, "clock_skew"); + assert_eq!(r["current"]["id"], id); +} + +// ---- steps 5, 8, 9: load, validate, insert ----------------------------------------------------------------------- + +#[tokio::test] +async fn step9_create_inserts_with_server_fields_audit_create_and_push() { + let app = app().await; + let id = new_id(); + let before = sequence(&app).await; + let mut pushed = expense(&id, T1); + pushed["description"] = json!(" ICA Maxi "); + pushed["created_at"] = json!("2026-10-17T10:59:00.000Z"); + let r = push_one(&app, &member_token(), exp(pushed)).await; + let entity = accepted(&r); + assert_eq!(entity["description"], "ICA Maxi", "stored trimmed"); + assert_eq!(entity["created_by_user_id"], MEMBER_ID); + assert_eq!(entity["updated_by_user_id"], MEMBER_ID); + assert_eq!(entity["created_at"], "2026-10-17T10:59:00.000Z"); + assert_eq!(entity["updated_at"], T1); + assert!(entity["server_version"].as_i64().unwrap() > before); + let (device, received): (Option, String) = sqlx::query_as( + "SELECT updated_by_device_id, server_received_at FROM expenses WHERE id = ?", + ) + .bind(&id) + .fetch_one(&app.state.db) + .await + .unwrap(); + assert_eq!(device.as_deref(), Some(MEMBER_DEVICE_ID)); + assert_eq!(received, NOW); + + let audit = audit_of(&app, &id).await; + assert_eq!(audit.len(), 1); + let a = &audit[0]; + assert_eq!((a.action.as_str(), a.source.as_str()), ("create", "app")); + assert_eq!(a.actor_user_id, MEMBER_ID); + assert_eq!(a.actor_device_id.as_deref(), Some(MEMBER_DEVICE_ID)); + assert_eq!((a.client_at.as_str(), a.server_at.as_str()), (T1, NOW)); + let fields: Vec<_> = a + .changes + .as_array() + .unwrap() + .iter() + .map(|c| c["field"].as_str().unwrap()) + .collect(); + assert_eq!( + fields, + [ + "account_id", + "category_id", + "payer_user_id", + "amount_ore", + "description", + "notes", + "expense_date", + "split_mode", + "splits" + ] + ); + assert!( + a.changes + .as_array() + .unwrap() + .iter() + .all(|c| c["old"].is_null()) + ); + assert_eq!(a.changes[4]["new"], "ICA Maxi"); + assert_eq!( + pushes(&app), + [( + "expense_created".to_owned(), + Some(id.clone()), + MEMBER_ID.to_owned(), + 1 + )] + ); + + let sid = new_id(); + let entity = accepted(&push_one(&app, &member_token(), set(settlement(&sid, T1))).await); + assert_eq!(entity["allocations"].as_array().unwrap().len(), 2); + let audit = audit_of(&app, &sid).await; + let fields: Vec<_> = audit[0] + .changes + .as_array() + .unwrap() + .iter() + .map(|c| c["field"].as_str().unwrap()) + .collect(); + assert_eq!( + fields, + [ + "from_user_id", + "to_user_id", + "amount_ore", + "settlement_date", + "note", + "allocations" + ] + ); + assert_eq!(pushes(&app)[1].0, "settlement_created"); +} + +/// One field rule of §9.6 / §9.1 / §9.4 broken: validation_failed. +async fn assert_invalid(app: &TestApp, entity_type: &str, entity: Value, why: &str) { + let r = push_one(app, &admin_token(), mutation(entity_type, entity)).await; + assert_eq!(r["reason"], "validation_failed", "{why}: {r:#}"); +} + +#[tokio::test] +async fn step8_expense_rules_of_9_6_and_9_1() { + let app = app().await; + let e = || expense(&new_id(), T1); + let with = |f: &dyn Fn(&mut Value)| { + let mut v = e(); + f(&mut v); + v + }; + let cases: Vec<(&str, Value)> = vec![ + ( + "amount 0", + with(&|v| { + v["amount_ore"] = json!(0); + }), + ), + ( + "amount over max", + with(&|v| { + v["amount_ore"] = json!(1_000_000_001); + v["splits"][0]["share_ore"] = json!(500_000_001); + v["splits"][1]["share_ore"] = json!(500_000_000); + }), + ), + ( + "empty description", + with(&|v| { + v["description"] = json!(" "); + }), + ), + ( + "description 101", + with(&|v| { + v["description"] = json!("é".repeat(101)); + }), + ), + ( + "notes 1001", + with(&|v| { + v["notes"] = json!("n".repeat(1001)); + }), + ), + ( + "notes control char", + with(&|v| { + v["notes"] = json!("a\tb"); + }), + ), + ( + "date before 2000", + with(&|v| { + v["expense_date"] = json!("1999-12-31"); + }), + ), + ( + "date over a year ahead", + with(&|v| { + v["expense_date"] = json!("2027-10-18"); + }), + ), + ( + "date invalid", + with(&|v| { + v["expense_date"] = json!("2026-02-30"); + }), + ), + ( + "share mismatch", + with(&|v| { + v["splits"][0]["share_ore"] = json!(20000); + v["splits"][1]["share_ore"] = json!(21200); + }), + ), + ( + "bp sum", + with(&|v| { + v["splits"][0]["share_bp"] = json!(6000); + }), + ), + ( + "bp out of range", + with(&|v| { + v["splits"][0]["share_bp"] = json!(-1); + v["splits"][1]["share_bp"] = json!(10001); + }), + ), + ( + "bp missing in ratio mode", + with(&|v| { + v["split_mode"] = json!("ratio"); + v["splits"][0]["share_bp"] = json!(null); + }), + ), + ( + "exact with bp", + with(&|v| { + v["split_mode"] = json!("exact"); + }), + ), + ( + "exact sum", + with(&|v| { + v["split_mode"] = json!("exact"); + v["splits"][0]["share_bp"] = json!(null); + v["splits"][1]["share_bp"] = json!(null); + v["splits"][1]["share_ore"] = json!(1); + }), + ), + ( + "full with 50/50", + with(&|v| { + v["split_mode"] = json!("full"); + }), + ), + ( + "unknown split user", + with(&|v| { + v["splits"][1]["user_id"] = json!(UNKNOWN_ID); + }), + ), + ( + "one split", + with(&|v| { + v["splits"].as_array_mut().unwrap().pop(); + }), + ), + ( + "three splits", + with(&|v| { + let first = v["splits"][0].clone(); + v["splits"].as_array_mut().unwrap().push(first); + }), + ), + ( + "payer not in household", + with(&|v| { + v["payer_user_id"] = json!(UNKNOWN_ID); + }), + ), + ]; + for (why, entity) in cases { + assert_invalid(&app, "expense", entity, why).await; + } + assert_eq!(count(&app, "expenses").await, 0); + assert_eq!(count(&app, "audit_log").await, 0); + assert!(pushes(&app).is_empty()); +} + +#[tokio::test] +async fn step8_valid_edges_are_accepted() { + let app = app().await; + let ok = |v: Value| async { accepted(&push_one(&app, &admin_token(), exp(v)).await) }; + let mut v = expense(&new_id(), T1); + v["expense_date"] = json!("2027-10-17"); // today (Stockholm) + 365 + v["description"] = json!("é".repeat(100)); + v["notes"] = json!("rad ett\nrad två"); + ok(v).await; + let mut v = expense(&new_id(), T1); + v["expense_date"] = json!("2000-01-01"); + ok(v).await; + // Mode default: the bp are not compared with the account's current default (here 60/40 vs the account's 50/50). + let mut v = expense(&new_id(), T1); + v["splits"] = json!([ + {"user_id": ADMIN_ID, "share_ore": 24720, "share_bp": 6000}, + {"user_id": MEMBER_ID, "share_ore": 16480, "share_bp": 4000} + ]); + ok(v).await; + // Ratio rule: the payer absorbs the remainder; exact; full; splits in any order. + let mut v = expense(&new_id(), T1); + v["amount_ore"] = json!(1001); + v["split_mode"] = json!("ratio"); + v["splits"] = json!([ + {"user_id": MEMBER_ID, "share_ore": 500, "share_bp": 5000}, + {"user_id": ADMIN_ID, "share_ore": 501, "share_bp": 5000} + ]); + let entity = ok(v).await; + assert_eq!( + entity["splits"][0]["user_id"], ADMIN_ID, + "sorted by user_id" + ); + let mut v = expense(&new_id(), T1); + v["split_mode"] = json!("exact"); + v["splits"] = json!([ + {"user_id": ADMIN_ID, "share_ore": 41000, "share_bp": null}, + {"user_id": MEMBER_ID, "share_ore": 200, "share_bp": null} + ]); + ok(v).await; + let mut v = expense(&new_id(), T1); + v["split_mode"] = json!("full"); + v["splits"] = json!([ + {"user_id": ADMIN_ID, "share_ore": 0, "share_bp": 0}, + {"user_id": MEMBER_ID, "share_ore": 41200, "share_bp": 10000} + ]); + ok(v).await; +} + +#[tokio::test] +async fn step8_settlement_rules_of_9_4() { + let app = app().await; + let s = || settlement(&new_id(), T1); + let with = |f: &dyn Fn(&mut Value)| { + let mut v = s(); + f(&mut v); + v + }; + let cases: Vec<(&str, Value)> = vec![ + ( + "same user", + with(&|v| { + v["to_user_id"] = json!(MEMBER_ID); + }), + ), + ( + "unknown user", + with(&|v| { + v["to_user_id"] = json!(UNKNOWN_ID); + }), + ), + ( + "amount 0", + with(&|v| { + v["amount_ore"] = json!(0); + }), + ), + ( + "no allocations", + with(&|v| { + v["allocations"] = json!([]); + }), + ), + ( + "allocation 0", + with(&|v| { + v["allocations"][0]["amount_ore"] = json!(0); + v["allocations"][1]["amount_ore"] = json!(50000); + }), + ), + ( + "sum mismatch", + with(&|v| { + v["allocations"][0]["amount_ore"] = json!(1); + }), + ), + ( + "duplicate account", + with(&|v| { + v["allocations"][1]["account_id"] = json!(ACCOUNT_1); + }), + ), + ( + "51 allocations", + with(&|v| { + v["amount_ore"] = json!(51); + v["allocations"] = Value::Array( + (0..51) + .map(|_| json!({"account_id": new_id(), "amount_ore": 1})) + .collect(), + ); + }), + ), + ( + "note 201", + with(&|v| { + v["note"] = json!("n".repeat(201)); + }), + ), + ( + "date", + with(&|v| { + v["settlement_date"] = json!("16/10/2026"); + }), + ), + ]; + for (why, entity) in cases { + assert_invalid(&app, "settlement", entity, why).await; + } + assert_eq!(count(&app, "settlements").await, 0); +} + +#[tokio::test] +async fn step8_deleted_references_are_reference_not_found_archived_are_fine() { + let app = app().await; + tombstone(&app, "accounts", ACCOUNT_2).await; + tombstone(&app, "categories", HOME).await; + let mut e = expense(&new_id(), T1); + e["account_id"] = json!(ACCOUNT_2); + let r = push_one(&app, &admin_token(), exp(e)).await; + rejected(&r, "reference_not_found"); + assert_eq!(r["message"], "account_id"); + let mut e = expense(&new_id(), T1); + e["account_id"] = json!(UNKNOWN_ID); + rejected( + &push_one(&app, &admin_token(), exp(e)).await, + "reference_not_found", + ); + let mut e = expense(&new_id(), T1); + e["category_id"] = json!(HOME); + let r = push_one(&app, &admin_token(), exp(e)).await; + rejected(&r, "reference_not_found"); + assert_eq!(r["message"], "category_id"); + let r = push_one(&app, &admin_token(), set(settlement(&new_id(), T1))).await; + rejected(&r, "reference_not_found"); + assert_eq!(r["message"], "allocations"); + + // §9.10: archived accounts and categories accept writes. + sqlx::query("UPDATE accounts SET archived_at = ? WHERE id = ?") + .bind(NOW) + .bind(ACCOUNT_1) + .execute(&app.state.db) + .await + .unwrap(); + sqlx::query("UPDATE categories SET archived_at = ? WHERE id = ?") + .bind(NOW) + .bind(GROCERIES) + .execute(&app.state.db) + .await + .unwrap(); + accepted(&push_one(&app, &admin_token(), exp(expense(&new_id(), T1))).await); +} + +#[tokio::test] +async fn step8_rejection_of_an_existing_row_carries_it_as_current() { + let app = app().await; + let id = new_id(); + let stored = accepted(&push_one(&app, &admin_token(), exp(expense(&id, T1))).await); + let mut e = expense(&id, T2); + e["amount_ore"] = json!(0); + let r = push_one(&app, &admin_token(), exp(e)).await; + rejected(&r, "validation_failed"); + assert_eq!(r["current"], stored); +} + +// ---- step 6: stored tombstone ----------------------------------------------------------------------------------- + +#[tokio::test] +async fn step6_tombstone_accepts_a_repeated_delete_and_rejects_anything_else_as_deleted() { + let app = app().await; + let id = new_id(); + push_one(&app, &admin_token(), exp(expense(&id, T1))).await; + let mut del = expense(&id, T2); + del["deleted_at"] = json!(T2); + let tomb = accepted(&push_one(&app, &admin_token(), exp(del.clone())).await); + let version = sequence(&app).await; + let audits = count(&app, "audit_log").await; + let pushed = pushes(&app).len(); + + // Idempotent delete: accepted, nothing changes. + let mut del2 = del.clone(); + del2["deleted_at"] = json!("2026-10-17T11:45:00.000Z"); + del2["updated_at"] = json!("2026-10-17T11:45:00.000Z"); + let r = push_one(&app, &member_token(), exp(del2)).await; + assert_eq!(accepted(&r), tomb); + assert_eq!(sequence(&app).await, version); + assert_eq!(count(&app, "audit_log").await, audits); + assert_eq!(pushes(&app).len(), pushed); + + // An edit of the tombstone, even a newer one: deleted, with the tombstone as current. + let mut edit = expense(&id, "2026-10-17T11:59:00.000Z"); + edit["amount_ore"] = json!(100); + edit["splits"][0]["share_ore"] = json!(50); + edit["splits"][1]["share_ore"] = json!(50); + let r = push_one(&app, &member_token(), exp(edit)).await; + rejected(&r, "deleted"); + assert_eq!(r["current"], tomb); + assert_eq!(sequence(&app).await, version); +} + +// ---- step 7: pushed delete ------------------------------------------------------------------------------------- + +#[tokio::test] +async fn step7_delete_of_a_stored_row_always_wins_and_keeps_other_fields() { + let app = app().await; + let id = new_id(); + let stored = accepted(&push_one(&app, &admin_token(), exp(expense(&id, T2))).await); + // An older delete with different content: still applied; other fields keep the stored values. + let mut del = expense(&id, T1); + del["deleted_at"] = json!("2026-10-17T11:10:00.000Z"); + del["amount_ore"] = json!(999); + del["description"] = json!("changed"); + let entity = accepted(&push_one(&app, &member_token(), exp(del)).await); + assert_eq!(entity["deleted_at"], "2026-10-17T11:10:00.000Z"); + assert_eq!(entity["updated_at"], T2, "the later of stored and pushed"); + assert_eq!(entity["amount_ore"], 41200); + assert_eq!(entity["description"], "ICA Maxi"); + assert_eq!(entity["updated_by_user_id"], MEMBER_ID); + assert_eq!(entity["created_by_user_id"], ADMIN_ID); + assert!( + entity["server_version"].as_i64().unwrap() > stored["server_version"].as_i64().unwrap() + ); + + let audit = audit_of(&app, &id).await; + assert_eq!(audit.len(), 2); + assert_eq!(audit[1].action, "delete"); + assert_eq!(audit[1].client_at, "2026-10-17T11:10:00.000Z"); + assert_eq!( + audit[1].changes, + json!([{"field": "deleted_at", "old": null, "new": "2026-10-17T11:10:00.000Z"}]) + ); + assert_eq!( + pushes(&app)[1], + ( + "expense_deleted".to_owned(), + Some(id), + MEMBER_ID.to_owned(), + 1 + ) + ); + // A newer pushed updated_at is taken. + let sid = new_id(); + push_one(&app, &admin_token(), set(settlement(&sid, T1))).await; + let mut del = settlement(&sid, T2); + del["deleted_at"] = json!(T2); + let entity = accepted(&push_one(&app, &admin_token(), set(del)).await); + assert_eq!(entity["updated_at"], T2); + assert_eq!(pushes(&app).last().unwrap().0, "settlement_deleted"); +} + +#[tokio::test] +async fn step7_delete_of_a_never_synced_row_stores_a_tombstone_without_audit_or_push() { + let app = app().await; + let id = new_id(); + let mut del = expense(&id, T1); + del["deleted_at"] = json!(T1); + let entity = accepted(&push_one(&app, &admin_token(), exp(del)).await); + assert_eq!(entity["deleted_at"], T1); + assert_eq!(entity["created_by_user_id"], ADMIN_ID); + assert!(audit_of(&app, &id).await.is_empty()); + assert!(pushes(&app).is_empty()); + // References are still checked. + tombstone(&app, "accounts", ACCOUNT_2).await; + let mut del = expense(&new_id(), T1); + del["deleted_at"] = json!(T1); + del["account_id"] = json!(ACCOUNT_2); + let r = push_one(&app, &admin_token(), exp(del)).await; + rejected(&r, "reference_not_found"); + assert_eq!(r["current"], Value::Null); +} + +// ---- step 10: stale --------------------------------------------------------------------------------------------- + +#[tokio::test] +async fn step10_equal_or_older_updated_at_is_stale_ties_go_to_the_server() { + let app = app().await; + let id = new_id(); + let stored = accepted(&push_one(&app, &admin_token(), exp(expense(&id, T2))).await); + for at in [T2, T1] { + let mut e = expense(&id, at); + e["description"] = json!("Other"); + let r = push_one(&app, &member_token(), exp(e)).await; + rejected(&r, "stale"); + assert_eq!(r["message"], "a newer version exists"); + assert_eq!(r["current"], stored); + } + assert_eq!(audit_of(&app, &id).await.len(), 1); +} + +// ---- step 11: update -------------------------------------------------------------------------------------------- + +#[tokio::test] +async fn step11_newer_update_writes_the_diff_and_keeps_server_owned_fields() { + let app = app().await; + // A generated expense: recurring fields must survive an edit. + let id = new_id(); + accepted(&push_one(&app, &admin_token(), exp(expense(&id, T1))).await); + sqlx::query("INSERT INTO recurring_templates (id, account_id, category_id, amount_ore, description, notes, payer_user_id, split_mode, day_of_month, start_period, created_by_user_id, updated_by_user_id, created_at, updated_at, server_version) VALUES (?, ?, ?, 41200, 'ICA', '', ?, 'default', 1, '2026-10', ?, ?, ?, ?, 900000)") + .bind(UNKNOWN_ID).bind(ACCOUNT_1).bind(GROCERIES).bind(ADMIN_ID).bind(ADMIN_ID).bind(ADMIN_ID).bind(NOW).bind(NOW) + .execute(&app.state.db).await.unwrap(); + sqlx::query( + "UPDATE expenses SET recurring_template_id = ?, recurring_period = '2026-10' WHERE id = ?", + ) + .bind(UNKNOWN_ID) + .bind(&id) + .execute(&app.state.db) + .await + .unwrap(); + let mut e = expense(&id, T2); + e["amount_ore"] = json!(41300); + e["splits"][0]["share_ore"] = json!(20650); + e["splits"][1]["share_ore"] = json!(20650); + e["notes"] = json!("pant"); + e["created_at"] = json!("2026-01-01T00:00:00.000Z"); + let entity = accepted(&push_one(&app, &member_token(), exp(e)).await); + assert_eq!(entity["amount_ore"], 41300); + assert_eq!(entity["updated_at"], T2); + assert_eq!(entity["updated_by_user_id"], MEMBER_ID); + assert_eq!(entity["created_by_user_id"], ADMIN_ID); + assert_eq!(entity["recurring_template_id"], UNKNOWN_ID); + assert_eq!(entity["recurring_period"], "2026-10"); + assert_eq!( + entity["created_at"], T1, + "created_at keeps the stored value" + ); + let audit = audit_of(&app, &id).await; + assert_eq!(audit[1].action, "update"); + assert_eq!(audit[1].client_at, T2); + assert_eq!( + audit[1].changes, + json!([ + {"field": "amount_ore", "old": 41200, "new": 41300}, + {"field": "notes", "old": "", "new": "pant"}, + {"field": "splits", + "old": [{"user_id": ADMIN_ID, "share_ore": 20600, "share_bp": 5000}, {"user_id": MEMBER_ID, "share_ore": 20600, "share_bp": 5000}], + "new": [{"user_id": ADMIN_ID, "share_ore": 20650, "share_bp": 5000}, {"user_id": MEMBER_ID, "share_ore": 20650, "share_bp": 5000}]} + ]) + ); + assert_eq!( + pushes(&app)[1], + ( + "expense_updated".to_owned(), + Some(id), + MEMBER_ID.to_owned(), + 1 + ) + ); + + // Settlement update with new allocations replaces the children. + let sid = new_id(); + push_one(&app, &admin_token(), set(settlement(&sid, T1))).await; + let mut s = settlement(&sid, T2); + s["allocations"] = json!([{"account_id": ACCOUNT_2, "amount_ore": 50000}]); + let entity = accepted(&push_one(&app, &admin_token(), set(s)).await); + assert_eq!( + entity["allocations"], + json!([{"account_id": ACCOUNT_2, "amount_ore": 50000}]) + ); + let n: i64 = app + .scalar_i64("SELECT count(*) FROM settlement_allocations") + .await; + assert_eq!(n, 1); + assert_eq!( + audit_of(&app, &sid).await[1].changes[0]["field"], + "allocations" + ); +} + +#[tokio::test] +async fn step11_empty_diff_changes_nothing_not_even_updated_at() { + let app = app().await; + let id = new_id(); + let stored = accepted(&push_one(&app, &admin_token(), exp(expense(&id, T1))).await); + let version = sequence(&app).await; + // Newer updated_at, a different created_at and splits in another order: no diff field differs. + let mut e = expense(&id, T2); + e["splits"].as_array_mut().unwrap().reverse(); + e["description"] = json!(" ICA Maxi "); + let r = push_one(&app, &member_token(), exp(e)).await; + assert_eq!(accepted(&r), stored); + assert_eq!(sequence(&app).await, version); + assert_eq!(audit_of(&app, &id).await.len(), 1); + assert_eq!(pushes(&app).len(), 1); +} + +// ---- step 12: sync_mutations ------------------------------------------------------------------------------------ + +#[tokio::test] +async fn step12_every_result_is_recorded_with_device_and_time() { + let app = app().await; + let ok = exp(expense(&new_id(), T1)); + let mut bad = exp(expense(&new_id(), T1)); + bad["entity"]["amount_ore"] = json!(-5); + let reply = push(&app, &member_token(), vec![ok.clone(), bad.clone()]).await; + let rows: Vec<(String, String, String, String, String, String)> = sqlx::query_as( + "SELECT mutation_id, device_id, entity_type, entity_id, result, received_at FROM sync_mutations ORDER BY rowid", + ) + .fetch_all(&app.state.db) + .await + .unwrap(); + assert_eq!(rows.len(), 2); + for (row, (m, result)) in rows.iter().zip( + [ok, bad] + .iter() + .zip(reply.json["results"].as_array().unwrap()), + ) { + assert_eq!(row.0, m["mutation_id"].as_str().unwrap()); + assert_eq!(row.1, MEMBER_DEVICE_ID); + assert_eq!(row.2, "expense"); + assert_eq!(row.3, m["entity"]["id"].as_str().unwrap()); + assert_eq!(serde_json::from_str::(&row.4).unwrap(), *result); + assert_eq!(row.5, NOW); + } +} + +// ---- pushes after commit, sync_batch ----------------------------------------------------------------------------- + +#[tokio::test] +async fn more_than_3_notifiable_changes_send_one_sync_batch_3_send_each() { + let app = app().await; + let three: Vec = (0..3).map(|_| exp(expense(&new_id(), T1))).collect(); + push(&app, &admin_token(), three).await; + let p = pushes(&app); + assert_eq!(p.len(), 3); + assert!(p.iter().all(|m| m.0 == "expense_created" && m.3 == 1)); + + // Four notifiable changes plus a rejection and a no-op (not notifiable): one sync_batch with count 4. + let id = new_id(); + push_one(&app, &admin_token(), exp(expense(&id, T1))).await; + let mut bad = expense(&new_id(), T1); + bad["amount_ore"] = json!(0); + let mut batch: Vec = (0..3).map(|_| exp(expense(&new_id(), T1))).collect(); + batch.push(set(settlement(&new_id(), T1))); + batch.push(exp(bad)); + batch.push(exp(expense(&id, T2))); + push(&app, &member_token(), batch).await; + let p = pushes(&app); + assert_eq!(p.len(), 5); + assert_eq!( + p[4], + ("sync_batch".to_owned(), None, MEMBER_ID.to_owned(), 4) + ); +} + +// ---- the contract examples --------------------------------------------------------------------------------------- + +/// The `contracts/examples/sync/push-*` scenario: the stored rows of the examples, then each request, compared with +/// its response by value (ids, versions and times of rows created here by type only). +#[tokio::test] +async fn push_examples_reproduce_their_responses() { + let app = app().await; + app.set_now("2026-10-17T08:00:01.000Z"); + // Fika (exact) and the settlement stored before, as in the pull-page example. + let fika = example("sync/push-request-delete.json")["mutations"][0]["entity"].clone(); + let mut stored = fika.clone(); + stored["updated_at"] = stored["created_at"].clone(); + stored["deleted_at"] = Value::Null; + accepted(&push_one(&app, &member_token(), exp(stored)).await); + let reply = app + .call( + Method::POST, + "/sync", + &admin_token(), + Some(example("sync/push-request-delete.json")), + ) + .await; + assert_like_example( + &reply.json, + "sync/push-response-delete.json", + &["server_version"], + ); + + app.set_now(NOW); + let mut s = example("sync/push-response.json")["results"][1]["current"].clone(); + for key in ["created_by_user_id", "updated_by_user_id", "server_version"] { + s.as_object_mut().unwrap().remove(key); + } + accepted(&push_one(&app, &member_token(), set(s)).await); + let reply = app + .call( + Method::POST, + "/sync", + &admin_token(), + Some(example("sync/push-request.json")), + ) + .await; + assert_like_example(&reply.json, "sync/push-response.json", &["server_version"]); + + tombstone(&app, "accounts", ACCOUNT_2).await; + let reply = app + .call( + Method::POST, + "/sync", + &admin_token(), + Some(example("sync/push-request-rejections.json")), + ) + .await; + assert_like_example( + &reply.json, + "sync/push-response-rejections.json", + &["server_version"], + ); +} diff --git a/server/crates/kvitt-server/tests/sync_api/serve.rs b/server/crates/kvitt-server/tests/sync_api/serve.rs new file mode 100644 index 0000000..bf471a5 --- /dev/null +++ b/server/crates/kvitt-server/tests/sync_api/serve.rs @@ -0,0 +1,90 @@ +//! `serve` as deployed: the router with `ConnectInfo` (so the rate limiter sees each client's address, +//! SPEC §8.6), `/i/` with an empty code as the HTML 404 page, and `rotate-epoch` as the real binary (SPEC §10.4). + +use std::net::SocketAddr; +use std::process::Command; + +use axum::body::Body; +use axum::http::{Method, Request}; +use kvitt_server::cli::make_service; +use tokio::net::TcpListener; + +use crate::common::*; + +#[tokio::test] +async fn two_client_addresses_get_separate_rate_limit_buckets_end_to_end() { + let app = TestApp::empty().await; + // One service, so one rate limiter, reached from two client addresses: 127.0.0.1 and ::1. + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let v4 = listener.local_addr().unwrap(); + let listener6 = TcpListener::bind("[::1]:0").await.unwrap(); + let v6 = listener6.local_addr().unwrap(); + let service = make_service(app.state.clone()); + let service6 = service.clone(); + tokio::spawn(async move { axum::serve(listener, service).await.unwrap() }); + tokio::spawn(async move { axum::serve(listener6, service6).await.unwrap() }); + + let client = reqwest::Client::new(); + let get = |addr: SocketAddr| { + let client = client.clone(); + async move { + client + .get(format!("http://{addr}/health")) + .send() + .await + .unwrap() + .status() + .as_u16() + } + }; + for _ in 0..60 { + assert_eq!(get(v4).await, 200); + } + assert_eq!( + get(v4).await, + 429, + "the 61st request from 127.0.0.1 in the minute" + ); + assert_eq!(get(v6).await, 200, "::1 has its own bucket"); +} + +#[tokio::test] +async fn empty_invite_code_is_the_html_404_page() { + let app = TestApp::empty().await; + let reply = app + .send( + Request::builder() + .method(Method::GET) + .uri("/i/") + .body(Body::empty()) + .unwrap(), + ) + .await; + assert_eq!(reply.status, 404); + assert_eq!(reply.headers["content-type"], "text/html; charset=utf-8"); + assert!(reply.text().contains("

404

")); +} + +#[tokio::test] +async fn rotate_epoch_sets_a_new_random_epoch_and_exits_0() { + let app = TestApp::empty().await; + let epoch = || async { + sqlx::query_scalar::<_, String>("SELECT value FROM meta WHERE key = 'sync_epoch'") + .fetch_one(&app.state.db) + .await + .unwrap() + }; + let before = epoch().await; + let output = Command::new(env!("CARGO_BIN_EXE_kvitt-server")) + .arg("rotate-epoch") + .env("KVITT_DATA_DIR", app.data_dir()) + .env("RUST_LOG", "warn") + .output() + .unwrap(); + assert_eq!(output.status.code(), Some(0), "{output:?}"); + let after = epoch().await; + assert_ne!(before, after); + assert_eq!(after.len(), 32); + assert!(after.chars().all(|c| matches!(c, '0'..='9' | 'a'..='f'))); + assert!(String::from_utf8_lossy(&output.stdout).contains(&after)); +}