diff --git a/server/Cargo.lock b/server/Cargo.lock index 6a75ce1..bd073f8 100644 --- a/server/Cargo.lock +++ b/server/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "aho-corasick" version = "1.1.5" @@ -155,6 +161,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + [[package]] name = "bitflags" version = "2.13.2" @@ -188,12 +200,24 @@ version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + [[package]] name = "byteorder" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + [[package]] name = "bytes" version = "1.12.1" @@ -345,6 +369,15 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" +[[package]] +name = "crc32fast" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +dependencies = [ + "cfg-if", +] + [[package]] name = "crossbeam-queue" version = "0.3.14" @@ -472,12 +505,32 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + [[package]] name = "find-msvc-tools" version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide 0.9.1", + "zlib-rs", +] + [[package]] name = "flume" version = "0.12.0" @@ -869,6 +922,32 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "image-webp", + "moxcms", + "num-traits", + "png", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", +] + [[package]] name = "indexmap" version = "2.14.2" @@ -925,11 +1004,15 @@ name = "kvitt-server" version = "0.1.0" dependencies = [ "axum", + "base64 0.23.1", "chrono", "chrono-tz", "clap", + "getrandom", "http-body-util", + "image", "kvitt-domain", + "percent-encoding", "serde", "serde_json", "serde_norway", @@ -937,6 +1020,7 @@ dependencies = [ "sqlx", "thiserror", "tokio", + "tokio-util", "tower", "tracing", "tracing-subscriber", @@ -1025,6 +1109,26 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "1.2.4" @@ -1036,6 +1140,16 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -1131,6 +1245,19 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide 0.8.9", +] + [[package]] name = "potential_utf" version = "0.1.6" @@ -1149,6 +1276,18 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + [[package]] name = "quote" version = "1.0.47" @@ -1362,6 +1501,12 @@ dependencies = [ "libc", ] +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + [[package]] name = "siphasher" version = "1.0.4" @@ -1421,7 +1566,7 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05b44e85bf579a8eeb4ceaa77a3a523baf2bf0e9bac7e40f405d537b5d2d5ccb" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "cfg-if", "crc", @@ -1519,7 +1664,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "87a2bdd6e83f6b3ea525ca9fee568030508b58355a43d0b2c1674d5f79dcd65e" dependencies = [ "atoi", - "base64", + "base64 0.22.1", "bitflags", "byteorder", "crc", @@ -1716,6 +1861,19 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-util" +version = "0.7.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e464cf451ba96ebfc6f9b6542f17ee8b8956e33f1e40d9690624e59d7a7f8a4b" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + [[package]] name = "tower" version = "0.5.3" @@ -2144,8 +2302,29 @@ dependencies = [ "syn 3.0.9", ] +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + [[package]] name = "zmij" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zune-core" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] diff --git a/server/README.md b/server/README.md index 8ea4efe..9c58b2d 100644 --- a/server/README.md +++ b/server/README.md @@ -51,15 +51,15 @@ database is then `$KVITT_DATA_DIR/kvitt.sqlite`. Every connection uses WAL, `for | `serve` | Works: migrations, `tasks::start`, HTTP server; 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` | Prints `not implemented in M2a` (M3). | 2 | -| `pair-device --user ` | Prints `not implemented in M2a` (M3). | 2 | +| `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 | ## 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; every other handler returns 501 `not_implemented` until M3 (E-3 to E-30, E-33) and M4 (E-31, E-32) -fill in the body in their own `src/api/*.rs` file. 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 return 501 `not_implemented` until M4 fills in +`src/api/sync.rs`. 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. @@ -81,7 +81,10 @@ the wire contract makes `tests/openapi_contract.rs` fail against `contracts/open ## Seams for M3, M4 and M9 (SPEC §6.3, D-015) * **Rate limit (M3):** `src/api/rate_limit.rs` returns the layer that `router.rs` attaches on the line marked - `// M3: the rate limiter …`. In M2a it is the identity layer. + `// M3: the rate limiter …` (SPEC §8.6, AC-2.12; per-IP limits need `ConnectInfo` from `serve`). +* **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. * **Audit (`src/audit/mod.rs`, M4 implements, M3 and M9 call):** diff --git a/server/crates/kvitt-domain/src/split.rs b/server/crates/kvitt-domain/src/split.rs index 1151945..9ff758d 100644 --- a/server/crates/kvitt-domain/src/split.rs +++ b/server/crates/kvitt-domain/src/split.rs @@ -28,7 +28,7 @@ pub enum SplitError { PayerUnknown, BpOutOfRange, // `rename_all = "snake_case"` alone would give `bp_sum_not10000`; the token of SPEC §14.5.3 is `bp_sum_not_10000`. - // TODO(spec): §14.5.1 shows only `rename_all`, which does not produce this token. + // The explicit rename is the rule (D-006). #[serde(rename = "bp_sum_not_10000")] BpSumNot10000, ExactNegative, diff --git a/server/crates/kvitt-server/Cargo.toml b/server/crates/kvitt-server/Cargo.toml index 07596bd..2c499a4 100644 --- a/server/crates/kvitt-server/Cargo.toml +++ b/server/crates/kvitt-server/Cargo.toml @@ -18,16 +18,21 @@ path = "src/main.rs" [dependencies] axum = "0.8.9" +base64 = "0.23.1" chrono = { version = "0.4.45", default-features = false, features = ["clock", "std"] } 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"] } kvitt-domain = { version = "0.1.0", path = "../kvitt-domain" } +percent-encoding = "2.3.2" serde = { version = "1.0.229", features = ["derive"] } serde_json = "1.0.154" sha2 = "0.11.1" sqlx = { version = "0.9.0", default-features = false, features = ["runtime-tokio", "sqlite", "migrate", "macros"] } thiserror = "2.0.21" tokio = { version = "1.53.3", features = ["rt-multi-thread", "macros", "net", "signal", "time", "sync", "io-util"] } +tokio-util = { version = "0.7.20", features = ["io"] } tower = { version = "0.5.3", features = ["util"] } tracing = "0.1.44" tracing-subscriber = { version = "0.3.23", features = ["json", "env-filter"] } diff --git a/server/crates/kvitt-server/src/api/accounts.rs b/server/crates/kvitt-server/src/api/accounts.rs index 17b48f0..db34c72 100644 --- a/server/crates/kvitt-server/src/api/accounts.rs +++ b/server/crates/kvitt-server/src/api/accounts.rs @@ -1,13 +1,83 @@ //! Accounts: E-18 to E-22 (FR-6). //! -//! Registered and annotated by M2a; M3 owns this file and implements the handlers (until then 501 -//! `not_implemented`). +//! Names are unique among non-deleted accounts, compared case-insensitively after trimming through `name_key` +//! (Unicode lowercase, AC-6.2). A ratio change affects only expenses created afterwards (AC-6.3): expenses store +//! their own shares. Delete is a soft delete, refused while anything references the account (AC-6.5). -use crate::api::{IdPath, not_implemented}; +use axum::extract::State; +use axum::http::StatusCode; +use sqlx::SqliteConnection; +use uuid::Uuid; + +use crate::AppState; +use crate::api::{ApiJson, IdPath, JsonBody, rows, validate}; use crate::auth::AuthDevice; -use crate::error::AppError; +use crate::db; +use crate::error::{AppError, ErrorCode}; use crate::openapi::responses::{BadRequest, Conflict, NotFound, Unprocessable}; -use crate::wire::{Account, CreateAccountRequest, PatchAccountRequest, UuidSchema}; +use crate::wire::{Account, CreateAccountRequest, PatchAccountRequest, Share, User, UuidSchema}; + +/// `name_key`: the trimmed name, Unicode-lowercased (SPEC §14.1). +pub fn name_key(trimmed_name: &str) -> String { + trimmed_name.to_lowercase() +} + +fn validate_shares(shares: &[Share], users: &[User]) -> Result, AppError> { + let entries: Vec<(Uuid, i64)> = shares.iter().map(|s| (s.user_id, s.share_bp)).collect(); + rows::bp_shares("default_shares", &entries, users)?; + Ok(entries) +} + +/// `name_taken` when another non-deleted account has the same `name_key`. +async fn check_name_free( + conn: &mut SqliteConnection, + key: &str, + except: Option, +) -> Result<(), AppError> { + let taken: bool = sqlx::query_scalar( + "SELECT EXISTS (SELECT 1 FROM accounts WHERE name_key = ? AND deleted_at IS NULL AND id IS NOT ?)", + ) + .bind(key) + .bind(except.map(|id| id.to_string())) + .fetch_one(&mut *conn) + .await?; + if taken { + Err( + AppError::new(ErrorCode::NameTaken, "an account with this name exists") + .with_field("name"), + ) + } else { + Ok(()) + } +} + +async fn replace_shares( + conn: &mut SqliteConnection, + account_id: Uuid, + shares: &[(Uuid, i64)], +) -> Result<(), AppError> { + sqlx::query("DELETE FROM account_default_shares WHERE account_id = ?") + .bind(account_id.to_string()) + .execute(&mut *conn) + .await?; + for (user_id, share_bp) in shares { + sqlx::query( + "INSERT INTO account_default_shares (account_id, user_id, share_bp) VALUES (?, ?, ?)", + ) + .bind(account_id.to_string()) + .bind(user_id.to_string()) + .bind(share_bp) + .execute(&mut *conn) + .await?; + } + Ok(()) +} + +async fn reload(conn: &mut SqliteConnection, id: Uuid) -> Result { + rows::account(conn, id) + .await? + .ok_or_else(|| AppError::internal("account vanished inside its transaction")) +} #[utoipa::path( post, @@ -22,8 +92,38 @@ use crate::wire::{Account, CreateAccountRequest, PatchAccountRequest, UuidSchema (status = 422, response = Unprocessable), ) )] -pub async fn create_account(_device: AuthDevice) -> AppError { - not_implemented() +pub async fn create_account( + device: AuthDevice, + State(state): State, + ApiJson(body): ApiJson, +) -> Result<(StatusCode, JsonBody), AppError> { + let mut tx = db::begin_immediate(&state.db).await?; + let users = rows::complete_household(&mut tx).await?; + let name = validate::text("name", &body.name, 1, validate::ACCOUNT_NAME_MAX)?; + let shares = validate_shares(&body.default_shares, &users)?; + let key = name_key(&name); + check_name_free(&mut tx, &key, None).await?; + + let id = Uuid::now_v7(); + let now = state.clock.now_ts(); + let version = db::next_server_version(&mut tx).await?; + sqlx::query( + "INSERT INTO accounts (id, name, name_key, created_by_user_id, created_at, updated_at, archived_at, \ + deleted_at, server_version) VALUES (?, ?, ?, ?, ?, ?, NULL, NULL, ?)", + ) + .bind(id.to_string()) + .bind(&name) + .bind(&key) + .bind(device.user_id.to_string()) + .bind(&now) + .bind(&now) + .bind(version) + .execute(&mut *tx) + .await?; + replace_shares(&mut tx, id, &shares).await?; + let account = reload(&mut tx, id).await?; + tx.commit().await?; + Ok((StatusCode::CREATED, JsonBody(account))) } #[utoipa::path( @@ -43,8 +143,83 @@ pub async fn create_account(_device: AuthDevice) -> AppError { (status = 422, response = Unprocessable), ) )] -pub async fn patch_account(_device: AuthDevice, IdPath(_account_id): IdPath) -> AppError { - not_implemented() +pub async fn patch_account( + _device: AuthDevice, + State(state): State, + IdPath(account_id): IdPath, + ApiJson(body): ApiJson, +) -> Result, AppError> { + if body.name.is_none() && body.default_shares.is_none() { + return Err(AppError::invalid_request("PATCH needs at least one field")); + } + let mut tx = db::begin_immediate(&state.db).await?; + rows::live_account(&mut tx, account_id).await?; + let name = body + .name + .as_deref() + .map(|n| validate::text("name", n, 1, validate::ACCOUNT_NAME_MAX)) + .transpose()?; + let shares = match &body.default_shares { + Some(shares) => { + let users = rows::household(&mut tx).await?; + Some(validate_shares(shares, &users)?) + } + None => None, + }; + if let Some(name) = &name { + check_name_free(&mut tx, &name_key(name), Some(account_id)).await?; + } + + let now = state.clock.now_ts(); + let version = db::next_server_version(&mut tx).await?; + if let Some(name) = &name { + sqlx::query("UPDATE accounts SET name = ?, name_key = ? WHERE id = ?") + .bind(name) + .bind(name_key(name)) + .bind(account_id.to_string()) + .execute(&mut *tx) + .await?; + } + if let Some(shares) = &shares { + replace_shares(&mut tx, account_id, shares).await?; + } + sqlx::query("UPDATE accounts SET updated_at = ?, server_version = ? WHERE id = ?") + .bind(&now) + .bind(version) + .bind(account_id.to_string()) + .execute(&mut *tx) + .await?; + let account = reload(&mut tx, account_id).await?; + tx.commit().await?; + Ok(JsonBody(account)) +} + +/// E-20 / E-21: sets or clears `archived_at`; a no-op (same row, same `server_version`) when already in that state. +async fn set_archived( + state: &AppState, + account_id: Uuid, + archived: bool, +) -> Result, AppError> { + let mut tx = db::begin_immediate(&state.db).await?; + let account = rows::live_account(&mut tx, account_id).await?; + if account.archived_at.is_some() == archived { + tx.commit().await?; + return Ok(JsonBody(account)); + } + let now = state.clock.now_ts(); + let version = db::next_server_version(&mut tx).await?; + sqlx::query( + "UPDATE accounts SET archived_at = ?, updated_at = ?, server_version = ? WHERE id = ?", + ) + .bind(archived.then_some(&now)) + .bind(&now) + .bind(version) + .bind(account_id.to_string()) + .execute(&mut *tx) + .await?; + let account = reload(&mut tx, account_id).await?; + tx.commit().await?; + Ok(JsonBody(account)) } #[utoipa::path( @@ -61,8 +236,12 @@ pub async fn patch_account(_device: AuthDevice, IdPath(_account_id): IdPath) -> (status = 404, response = NotFound), ) )] -pub async fn archive_account(_device: AuthDevice, IdPath(_account_id): IdPath) -> AppError { - not_implemented() +pub async fn archive_account( + _device: AuthDevice, + State(state): State, + IdPath(account_id): IdPath, +) -> Result, AppError> { + set_archived(&state, account_id, true).await } #[utoipa::path( @@ -79,8 +258,12 @@ pub async fn archive_account(_device: AuthDevice, IdPath(_account_id): IdPath) - (status = 404, response = NotFound), ) )] -pub async fn unarchive_account(_device: AuthDevice, IdPath(_account_id): IdPath) -> AppError { - not_implemented() +pub async fn unarchive_account( + _device: AuthDevice, + State(state): State, + IdPath(account_id): IdPath, +) -> Result, AppError> { + set_archived(&state, account_id, false).await } #[utoipa::path( @@ -98,6 +281,40 @@ pub async fn unarchive_account(_device: AuthDevice, IdPath(_account_id): IdPath) (status = 409, response = Conflict), ) )] -pub async fn delete_account(_device: AuthDevice, IdPath(_account_id): IdPath) -> AppError { - not_implemented() +pub async fn delete_account( + _device: AuthDevice, + State(state): State, + IdPath(account_id): IdPath, +) -> Result, AppError> { + let mut tx = db::begin_immediate(&state.db).await?; + rows::live_account(&mut tx, account_id).await?; + let referenced: bool = sqlx::query_scalar( + "SELECT EXISTS (SELECT 1 FROM expenses WHERE account_id = ?1 AND deleted_at IS NULL) \ + OR EXISTS (SELECT 1 FROM settlement_allocations a JOIN settlements s ON s.id = a.settlement_id \ + WHERE a.account_id = ?1 AND s.deleted_at IS NULL) \ + OR EXISTS (SELECT 1 FROM recurring_templates WHERE account_id = ?1 AND deleted_at IS NULL)", + ) + .bind(account_id.to_string()) + .fetch_one(&mut *tx) + .await?; + if referenced { + return Err(AppError::new( + ErrorCode::AccountNotEmpty, + "the account is referenced by an expense, a settlement or a recurring template", + )); + } + let now = state.clock.now_ts(); + let version = db::next_server_version(&mut tx).await?; + sqlx::query( + "UPDATE accounts SET deleted_at = ?, updated_at = ?, server_version = ? WHERE id = ?", + ) + .bind(&now) + .bind(&now) + .bind(version) + .bind(account_id.to_string()) + .execute(&mut *tx) + .await?; + let account = reload(&mut tx, account_id).await?; + tx.commit().await?; + Ok(JsonBody(account)) } diff --git a/server/crates/kvitt-server/src/api/apk.rs b/server/crates/kvitt-server/src/api/apk.rs index a7e672e..64acd47 100644 --- a/server/crates/kvitt-server/src/api/apk.rs +++ b/server/crates/kvitt-server/src/api/apk.rs @@ -1,13 +1,44 @@ -//! E-5 latest APK metadata and E-6 APK download (SPEC §10.6). +//! E-5 latest APK metadata and E-6 APK download (SPEC §10.6, FR-16). //! -//! Registered and annotated by M2a; M3 owns this file and implements the handlers (until then 501 -//! `not_implemented`). +//! `apk/latest.json` is read on every request (no cache). Files are served only for names matching +//! `^kvitt-[0-9]+\.apk$` that exist in `apk/`, streamed with `Content-Length`. -use crate::api::not_implemented; +use axum::body::Body; +use axum::extract::{Path, State}; +use axum::http::{HeaderValue, header}; +use axum::response::{IntoResponse, Response}; +use tokio_util::io::ReaderStream; + +use crate::AppState; +use crate::api::JsonBody; use crate::error::AppError; use crate::openapi::responses::NotFound; use crate::wire::{ApkBytes, ApkLatest, IntSchema}; +/// The media type of an APK download (SPEC §10.6). +pub const APK_CONTENT_TYPE: &str = "application/vnd.android.package-archive"; + +/// Whether `name` matches `^kvitt-[0-9]+\.apk$`. +pub fn is_apk_file_name(name: &str) -> bool { + name.strip_prefix("kvitt-") + .and_then(|rest| rest.strip_suffix(".apk")) + .is_some_and(|digits| !digits.is_empty() && digits.bytes().all(|b| b.is_ascii_digit())) +} + +/// Reads `apk/latest.json`; `None` when nothing is published. +pub async fn read_latest(state: &AppState) -> Result, AppError> { + let path = state.config.apk_dir().join("latest.json"); + match tokio::fs::read(&path).await { + Ok(bytes) => serde_json::from_slice(&bytes) + .map(Some) + .map_err(|e| AppError::internal(format!("apk/latest.json is malformed: {e}"))), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(AppError::internal(format!( + "cannot read apk/latest.json: {e}" + ))), + } +} + #[utoipa::path( get, path = "/apk/latest", @@ -19,8 +50,13 @@ use crate::wire::{ApkBytes, ApkLatest, IntSchema}; (status = 404, response = NotFound), ) )] -pub async fn get_apk_latest() -> AppError { - not_implemented() +pub async fn get_apk_latest( + State(state): State, +) -> Result, AppError> { + read_latest(&state) + .await? + .map(JsonBody) + .ok_or_else(AppError::not_found) } #[utoipa::path( @@ -37,6 +73,44 @@ pub async fn get_apk_latest() -> AppError { (status = 404, response = NotFound), ) )] -pub async fn get_apk_file() -> AppError { - not_implemented() +pub async fn get_apk_file( + State(state): State, + Path(file_name): Path, +) -> Result { + if !is_apk_file_name(&file_name) { + return Err(AppError::not_found()); + } + let path = state.config.apk_dir().join(&file_name); + let file = match tokio::fs::File::open(&path).await { + Ok(file) => file, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Err(AppError::not_found()), + Err(e) => return Err(AppError::internal(format!("cannot open {file_name}: {e}"))), + }; + let metadata = file.metadata().await.map_err(AppError::internal)?; + if !metadata.is_file() { + return Err(AppError::not_found()); + } + let mut response = Body::from_stream(ReaderStream::new(file)).into_response(); + let headers = response.headers_mut(); + headers.insert( + header::CONTENT_TYPE, + HeaderValue::from_static(APK_CONTENT_TYPE), + ); + headers.insert(header::CONTENT_LENGTH, HeaderValue::from(metadata.len())); + Ok(response) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn file_name_pattern() { + assert!(is_apk_file_name("kvitt-412.apk")); + assert!(!is_apk_file_name("kvitt-.apk")); + assert!(!is_apk_file_name("kvitt-41a.apk")); + assert!(!is_apk_file_name("../kvitt-1.apk")); + assert!(!is_apk_file_name("kvitt-1.apk.bak")); + assert!(!is_apk_file_name("latest.json")); + } } diff --git a/server/crates/kvitt-server/src/api/categories.rs b/server/crates/kvitt-server/src/api/categories.rs index bbbdad8..66963cd 100644 --- a/server/crates/kvitt-server/src/api/categories.rs +++ b/server/crates/kvitt-server/src/api/categories.rs @@ -1,14 +1,35 @@ //! Categories: E-23 to E-27 (FR-7). //! -//! Registered and annotated by M2a; M3 owns this file and implements the handlers (until then 501 -//! `not_implemented`). +//! Icon and colour are tokens of SPEC §9.7. New categories get `sort_order` = current maximum + 10 (AC-7.3). Default +//! categories (with a `default_key`) keep a `null` name until renamed; `name: null` resets it (AC-7.4). Names have no +//! uniqueness rule (AC-7.7). Delete is a soft delete, refused for defaults and while in use (AC-7.6). -use crate::api::{IdPath, not_implemented}; +use axum::extract::State; +use axum::http::StatusCode; +use sqlx::SqliteConnection; +use uuid::Uuid; + +use crate::AppState; +use crate::api::{ApiJson, IdPath, JsonBody, rows, validate}; use crate::auth::AuthDevice; -use crate::error::AppError; +use crate::db; +use crate::error::{AppError, ErrorCode}; use crate::openapi::responses::{BadRequest, Conflict, NotFound, Unprocessable}; use crate::wire::{Category, CreateCategoryRequest, PatchCategoryRequest, UuidSchema}; +/// The step between `sort_order` values of new categories (AC-7.3). +pub const SORT_ORDER_STEP: i64 = 10; + +fn category_is_default(message: &str) -> AppError { + AppError::new(ErrorCode::CategoryIsDefault, message) +} + +async fn reload(conn: &mut SqliteConnection, id: Uuid) -> Result { + rows::category(conn, id) + .await? + .ok_or_else(|| AppError::internal("category vanished inside its transaction")) +} + #[utoipa::path( post, path = "/categories", @@ -22,8 +43,40 @@ use crate::wire::{Category, CreateCategoryRequest, PatchCategoryRequest, UuidSch (status = 422, response = Unprocessable), ) )] -pub async fn create_category(_device: AuthDevice) -> AppError { - not_implemented() +pub async fn create_category( + _device: AuthDevice, + State(state): State, + ApiJson(body): ApiJson, +) -> Result<(StatusCode, JsonBody), AppError> { + let name = validate::text("name", &body.name, 1, validate::CATEGORY_NAME_MAX)?; + let icon = validate::icon(&body.icon)?; + let color = validate::color(&body.color)?; + let mut tx = db::begin_immediate(&state.db).await?; + let max: Option = + sqlx::query_scalar("SELECT MAX(sort_order) FROM categories WHERE deleted_at IS NULL") + .fetch_one(&mut *tx) + .await?; + let sort_order = max.unwrap_or(0).saturating_add(SORT_ORDER_STEP); + let id = Uuid::now_v7(); + let now = state.clock.now_ts(); + let version = db::next_server_version(&mut tx).await?; + sqlx::query( + "INSERT INTO categories (id, default_key, name, icon, color, sort_order, created_at, updated_at, \ + archived_at, deleted_at, server_version) VALUES (?, NULL, ?, ?, ?, ?, ?, ?, NULL, NULL, ?)", + ) + .bind(id.to_string()) + .bind(&name) + .bind(&icon) + .bind(&color) + .bind(sort_order) + .bind(&now) + .bind(&now) + .bind(version) + .execute(&mut *tx) + .await?; + let category = reload(&mut tx, id).await?; + tx.commit().await?; + Ok((StatusCode::CREATED, JsonBody(category))) } #[utoipa::path( @@ -44,8 +97,96 @@ pub async fn create_category(_device: AuthDevice) -> AppError { (status = 422, response = Unprocessable), ) )] -pub async fn patch_category(_device: AuthDevice, IdPath(_category_id): IdPath) -> AppError { - not_implemented() +pub async fn patch_category( + _device: AuthDevice, + State(state): State, + IdPath(category_id): IdPath, + ApiJson(body): ApiJson, +) -> Result, AppError> { + if body.name.is_none() + && body.icon.is_none() + && body.color.is_none() + && body.sort_order.is_none() + { + return Err(AppError::invalid_request("PATCH needs at least one field")); + } + let mut tx = db::begin_immediate(&state.db).await?; + let mut category = rows::live_category(&mut tx, category_id).await?; + match &body.name { + None => {} + Some(None) => { + if category.default_key.is_none() { + return Err( + category_is_default("name null resets only a default category's name") + .with_field("name"), + ); + } + category.name = None; + } + Some(Some(name)) => { + category.name = Some(validate::text( + "name", + name, + 1, + validate::CATEGORY_NAME_MAX, + )?); + } + } + if let Some(icon) = &body.icon { + category.icon = validate::icon(icon)?; + } + if let Some(color) = &body.color { + category.color = validate::color(color)?; + } + if let Some(sort_order) = body.sort_order { + category.sort_order = sort_order; + } + let now = state.clock.now_ts(); + let version = db::next_server_version(&mut tx).await?; + sqlx::query( + "UPDATE categories SET name = ?, icon = ?, color = ?, sort_order = ?, updated_at = ?, server_version = ? \ + WHERE id = ?", + ) + .bind(&category.name) + .bind(&category.icon) + .bind(&category.color) + .bind(category.sort_order) + .bind(&now) + .bind(version) + .bind(category_id.to_string()) + .execute(&mut *tx) + .await?; + let category = reload(&mut tx, category_id).await?; + tx.commit().await?; + Ok(JsonBody(category)) +} + +/// E-25 / E-26: sets or clears `archived_at`; a no-op when already in that state. +async fn set_archived( + state: &AppState, + category_id: Uuid, + archived: bool, +) -> Result, AppError> { + let mut tx = db::begin_immediate(&state.db).await?; + let category = rows::live_category(&mut tx, category_id).await?; + if category.archived_at.is_some() == archived { + tx.commit().await?; + return Ok(JsonBody(category)); + } + let now = state.clock.now_ts(); + let version = db::next_server_version(&mut tx).await?; + sqlx::query( + "UPDATE categories SET archived_at = ?, updated_at = ?, server_version = ? WHERE id = ?", + ) + .bind(archived.then_some(&now)) + .bind(&now) + .bind(version) + .bind(category_id.to_string()) + .execute(&mut *tx) + .await?; + let category = reload(&mut tx, category_id).await?; + tx.commit().await?; + Ok(JsonBody(category)) } #[utoipa::path( @@ -62,8 +203,12 @@ pub async fn patch_category(_device: AuthDevice, IdPath(_category_id): IdPath) - (status = 404, response = NotFound), ) )] -pub async fn archive_category(_device: AuthDevice, IdPath(_category_id): IdPath) -> AppError { - not_implemented() +pub async fn archive_category( + _device: AuthDevice, + State(state): State, + IdPath(category_id): IdPath, +) -> Result, AppError> { + set_archived(&state, category_id, true).await } #[utoipa::path( @@ -80,8 +225,12 @@ pub async fn archive_category(_device: AuthDevice, IdPath(_category_id): IdPath) (status = 404, response = NotFound), ) )] -pub async fn unarchive_category(_device: AuthDevice, IdPath(_category_id): IdPath) -> AppError { - not_implemented() +pub async fn unarchive_category( + _device: AuthDevice, + State(state): State, + IdPath(category_id): IdPath, +) -> Result, AppError> { + set_archived(&state, category_id, false).await } #[utoipa::path( @@ -98,6 +247,43 @@ pub async fn unarchive_category(_device: AuthDevice, IdPath(_category_id): IdPat (status = 409, response = Conflict), ) )] -pub async fn delete_category(_device: AuthDevice, IdPath(_category_id): IdPath) -> AppError { - not_implemented() +pub async fn delete_category( + _device: AuthDevice, + State(state): State, + IdPath(category_id): IdPath, +) -> Result, AppError> { + let mut tx = db::begin_immediate(&state.db).await?; + let category = rows::live_category(&mut tx, category_id).await?; + if category.default_key.is_some() { + return Err(category_is_default( + "default categories cannot be deleted; archive them instead", + )); + } + let in_use: bool = sqlx::query_scalar( + "SELECT EXISTS (SELECT 1 FROM expenses WHERE category_id = ?1 AND deleted_at IS NULL) \ + OR EXISTS (SELECT 1 FROM recurring_templates WHERE category_id = ?1 AND deleted_at IS NULL)", + ) + .bind(category_id.to_string()) + .fetch_one(&mut *tx) + .await?; + if in_use { + return Err(AppError::new( + ErrorCode::CategoryInUse, + "the category is used by an expense or a recurring template", + )); + } + let now = state.clock.now_ts(); + let version = db::next_server_version(&mut tx).await?; + sqlx::query( + "UPDATE categories SET deleted_at = ?, updated_at = ?, server_version = ? WHERE id = ?", + ) + .bind(&now) + .bind(&now) + .bind(version) + .bind(category_id.to_string()) + .execute(&mut *tx) + .await?; + let category = reload(&mut tx, category_id).await?; + tx.commit().await?; + Ok(JsonBody(category)) } diff --git a/server/crates/kvitt-server/src/api/devices.rs b/server/crates/kvitt-server/src/api/devices.rs index 0db3486..e1b4025 100644 --- a/server/crates/kvitt-server/src/api/devices.rs +++ b/server/crates/kvitt-server/src/api/devices.rs @@ -1,13 +1,18 @@ //! Devices: E-15 list, E-16 revoke, E-17 push token (FR-3, SPEC §8.4). //! -//! Registered and annotated by M2a; M3 owns this file and implements the handlers (until then 501 -//! `not_implemented`). +//! Devices are never deleted; revocation sets `revoked_at` (AC-3.5) and clears the FCM token so a revoked device +//! never receives pushes again (AC-3.4). -use crate::api::{IdPath, not_implemented}; +use axum::extract::State; +use axum::http::StatusCode; + +use crate::AppState; +use crate::api::{ApiJson, IdPath, JsonBody, rows}; use crate::auth::AuthDevice; -use crate::error::AppError; +use crate::db; +use crate::error::{AppError, ErrorCode}; use crate::openapi::responses::{BadRequest, Conflict, Forbidden, NotFound}; -use crate::wire::{DeviceList, PushTokenRequest, UuidSchema}; +use crate::wire::{DeviceList, PushTokenRequest, Role, UuidSchema}; #[utoipa::path( get, @@ -18,8 +23,14 @@ use crate::wire::{DeviceList, PushTokenRequest, UuidSchema}; (status = 200, description = "Ordered by `created_at`, revoked devices included (with `revoked_at`).", body = DeviceList, content_type = "application/json; charset=utf-8"), ) )] -pub async fn list_devices(_device: AuthDevice) -> AppError { - not_implemented() +pub async fn list_devices( + device: AuthDevice, + State(state): State, +) -> Result, AppError> { + let mut conn = state.db.acquire().await?; + let only_user = (device.role != Role::Admin).then_some(device.user_id); + let devices = rows::devices(&mut conn, only_user, device.device_id).await?; + Ok(JsonBody(DeviceList { devices })) } #[utoipa::path( @@ -38,8 +49,36 @@ pub async fn list_devices(_device: AuthDevice) -> AppError { (status = 409, response = Conflict), ) )] -pub async fn revoke_device(_device: AuthDevice, IdPath(_device_id): IdPath) -> AppError { - not_implemented() +pub async fn revoke_device( + device: AuthDevice, + State(state): State, + IdPath(device_id): IdPath, +) -> Result { + let mut tx = db::begin_immediate(&state.db).await?; + let target = rows::device(&mut tx, device_id, device.device_id) + .await? + .ok_or_else(AppError::not_found)?; + if device.role != Role::Admin && target.user_id != device.user_id { + return Err(AppError::forbidden()); + } + if target.is_current { + return Err(AppError::new( + ErrorCode::CannotRevokeCurrentDevice, + "the device in use cannot revoke itself", + )); + } + if target.revoked_at.is_none() { + sqlx::query( + "UPDATE devices SET revoked_at = ?, revoked_by_user_id = ?, fcm_token = NULL WHERE id = ?", + ) + .bind(state.clock.now_ts()) + .bind(device.user_id.to_string()) + .bind(device_id.to_string()) + .execute(&mut *tx) + .await?; + } + tx.commit().await?; + Ok(StatusCode::NO_CONTENT) } #[utoipa::path( @@ -53,6 +92,26 @@ pub async fn revoke_device(_device: AuthDevice, IdPath(_device_id): IdPath) -> A (status = 400, response = BadRequest), ) )] -pub async fn put_push_token(_device: AuthDevice) -> AppError { - not_implemented() +pub async fn put_push_token( + device: AuthDevice, + State(state): State, + ApiJson(body): ApiJson, +) -> Result { + let token = match body.fcm_token.as_deref().map(str::trim) { + Some("") => { + return Err( + AppError::invalid_request("fcm_token must be a non-empty string or null") + .with_field("fcm_token"), + ); + } + other => other.map(str::to_owned), + }; + let mut tx = db::begin_immediate(&state.db).await?; + sqlx::query("UPDATE devices SET fcm_token = ? WHERE id = ?") + .bind(token) + .bind(device.device_id.to_string()) + .execute(&mut *tx) + .await?; + tx.commit().await?; + Ok(StatusCode::NO_CONTENT) } diff --git a/server/crates/kvitt-server/src/api/invites.rs b/server/crates/kvitt-server/src/api/invites.rs index 6c3ca04..77d4edf 100644 --- a/server/crates/kvitt-server/src/api/invites.rs +++ b/server/crates/kvitt-server/src/api/invites.rs @@ -1,16 +1,212 @@ -//! Invites: E-3 accept (all three kinds), E-12 create, E-13 list, E-14 cancel (SPEC §8.3, FR-2). +//! Invites: E-3 accept (all three kinds), E-12 create, E-13 list, E-14 cancel (SPEC §8.3, FR-2), plus the code and +//! token generation shared with the `bootstrap` and `pair-device` subcommands. //! -//! Registered and annotated by M2a; M3 owns this file and implements the handlers (until then 501 -//! `not_implemented`). +//! Codes are 12 Crockford base32 characters from the OS CSPRNG, stored only as the SHA-256 hex of the normalised +//! code, single-use, valid for 72 hours, cancellable. Every failure of a code is `invite_invalid` without saying why +//! (AC-2.3). Tokens are `kvt_` + base64url(32 random bytes), stored only as their SHA-256 hex (SPEC §8.2). -use crate::api::{IdPath, not_implemented}; -use crate::auth::AdminDevice; -use crate::error::AppError; +use axum::extract::State; +use axum::http::{HeaderMap, StatusCode}; +use base64::Engine as _; +use chrono::{DateTime, Duration, Utc}; +use sqlx::SqliteConnection; +use uuid::Uuid; + +use crate::AppState; +use crate::api::{ApiJson, IdPath, JsonBody, rows, validate}; +use crate::auth::{APP_VERSION_HEADER, AdminDevice, TOKEN_PREFIX, sha256_hex}; +use crate::db; +use crate::error::{AppError, ErrorCode}; use crate::openapi::responses::{BadRequest, Conflict, Forbidden, NotFound, Unprocessable}; +use crate::time::{format_ts, parse_ts}; use crate::wire::{ - AcceptInviteRequest, AcceptInviteResponse, CreateInviteRequest, Invite, InviteList, UuidSchema, + AcceptInviteRequest, AcceptInviteResponse, CreateInviteKind, CreateInviteRequest, Invite, + InviteKind, InviteList, Role, UuidSchema, }; +/// The Crockford base32 alphabet of invite codes (SPEC §8.3). +pub const CODE_ALPHABET: &[u8; 32] = b"0123456789ABCDEFGHJKMNPQRSTVWXYZ"; +/// Characters in an invite code. +pub const CODE_LEN: usize = 12; +/// How long an invite is valid (AC-2.3). +pub const INVITE_LIFETIME: Duration = Duration::hours(72); +/// Most users a household has (SPEC §14.1). +pub const MAX_USERS: usize = 2; + +/// Fills `buf` from the OS CSPRNG. +fn random_bytes(buf: &mut [u8]) -> Result<(), AppError> { + getrandom::fill(buf).map_err(|e| AppError::internal(format!("OS random source failed: {e}"))) +} + +/// A new invite code: 12 characters of the alphabet (60 random bits), without hyphens. +pub fn generate_code() -> Result { + let mut bytes = [0u8; 8]; + random_bytes(&mut bytes)?; + let bits = u64::from_le_bytes(bytes); + Ok((0..CODE_LEN) + .map(|i| char::from(CODE_ALPHABET[usize::try_from((bits >> (5 * i)) & 31).unwrap_or(0)])) + .collect()) +} + +/// Normalises a typed or linked code (SPEC §8.3): uppercase, `-` and spaces removed, `O` → `0`, `I`/`L` → `1`. +/// `None` unless the result is exactly 12 alphabet characters. +pub fn normalise_code(input: &str) -> Option { + let code: String = input + .chars() + .filter(|c| *c != '-' && *c != ' ') + .flat_map(char::to_uppercase) + .map(|c| match c { + 'O' => '0', + 'I' | 'L' => '1', + other => other, + }) + .collect(); + (code.len() == CODE_LEN && code.bytes().all(|b| CODE_ALPHABET.contains(&b))).then_some(code) +} + +/// `XXXX-XXXX-XXXX` (SPEC §8.3 display form). +pub fn display_code(code: &str) -> String { + let chars: Vec = code.chars().collect(); + chars + .chunks(4) + .map(|c| c.iter().collect::()) + .collect::>() + .join("-") +} + +/// A new device token: `kvt_` + base64url without padding of 32 CSPRNG bytes (47 characters, SPEC §8.2). +pub fn generate_token() -> Result { + let mut bytes = [0u8; 32]; + random_bytes(&mut bytes)?; + Ok(format!( + "{TOKEN_PREFIX}{}", + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes) + )) +} + +/// `/i/`. +pub fn invite_link(public_url: &str, code: &str) -> String { + format!("{public_url}/i/{code}") +} + +/// A freshly created invite with its plain code (shown once). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NewInvite { + pub id: Uuid, + pub kind: InviteKind, + pub target_user_id: Option, + pub code: String, + pub created_at: String, + pub expires_at: String, +} + +impl NewInvite { + /// The wire form with `code` and `link` (E-12 response). + pub fn wire(&self, public_url: &str) -> Invite { + Invite { + id: self.id, + kind: self.kind, + target_user_id: self.target_user_id, + code: Some(self.code.clone()), + link: Some(invite_link(public_url, &self.code)), + created_at: self.created_at.clone(), + expires_at: self.expires_at.clone(), + } + } +} + +/// Cancels every pending invite of `kind` (not accepted, not cancelled, not expired). +pub async fn cancel_pending( + conn: &mut SqliteConnection, + kind: InviteKind, + now: DateTime, +) -> Result<(), AppError> { + sqlx::query( + "UPDATE invites SET cancelled_at = ?1 WHERE kind = ?2 AND accepted_at IS NULL AND cancelled_at IS NULL \ + AND expires_at > ?1", + ) + .bind(format_ts(now)) + .bind(rows::token_of(&kind)?) + .execute(&mut *conn) + .await?; + Ok(()) +} + +/// Inserts a new invite inside the caller's write transaction. +pub async fn insert_invite( + conn: &mut SqliteConnection, + kind: InviteKind, + target_user_id: Option, + created_by_user_id: Option, + now: DateTime, +) -> Result { + let code = generate_code()?; + let invite = NewInvite { + id: Uuid::now_v7(), + kind, + target_user_id, + created_at: format_ts(now), + expires_at: format_ts(now + INVITE_LIFETIME), + code, + }; + sqlx::query( + "INSERT INTO invites (id, kind, code_hash, target_user_id, created_by_user_id, created_at, expires_at) \ + VALUES (?, ?, ?, ?, ?, ?, ?)", + ) + .bind(invite.id.to_string()) + .bind(rows::token_of(&kind)?) + .bind(sha256_hex(&invite.code)) + .bind(target_user_id.map(|id| id.to_string())) + .bind(created_by_user_id.map(|id| id.to_string())) + .bind(&invite.created_at) + .bind(&invite.expires_at) + .execute(&mut *conn) + .await?; + Ok(invite) +} + +fn invite_invalid() -> AppError { + AppError::new( + ErrorCode::InviteInvalid, + "the invite code is unknown, expired, used or cancelled", + ) +} + +fn household_full() -> AppError { + AppError::new( + ErrorCode::HouseholdFull, + "the household already has two users", + ) +} + +async fn user_count(conn: &mut SqliteConnection) -> Result { + let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM users") + .fetch_one(&mut *conn) + .await?; + usize::try_from(n).map_err(AppError::internal) +} + +fn app_version(headers: &HeaderMap) -> Option { + headers + .get(APP_VERSION_HEADER)? + .to_str() + .ok()? + .trim() + .parse::() + .ok() + .filter(|v| *v >= 0) +} + +#[derive(sqlx::FromRow)] +struct InviteRow { + id: String, + kind: String, + target_user_id: Option, + expires_at: String, + accepted_at: Option, + cancelled_at: Option, +} + #[utoipa::path( post, path = "/invites/accept", @@ -25,8 +221,122 @@ use crate::wire::{ (status = 422, response = Unprocessable), ) )] -pub async fn accept_invite() -> AppError { - not_implemented() +pub async fn accept_invite( + State(state): State, + headers: HeaderMap, + ApiJson(body): ApiJson, +) -> Result, AppError> { + let code = normalise_code(&body.code).ok_or_else(invite_invalid)?; + let now = state.clock.now(); + let mut tx = db::begin_immediate(&state.db).await?; + let row = sqlx::query_as::<_, InviteRow>( + "SELECT id, kind, target_user_id, expires_at, accepted_at, cancelled_at FROM invites WHERE code_hash = ?", + ) + .bind(sha256_hex(&code)) + .fetch_optional(&mut *tx) + .await? + .ok_or_else(invite_invalid)?; + let expired = parse_ts(&row.expires_at).is_none_or(|t| t <= now); + if row.accepted_at.is_some() || row.cancelled_at.is_some() || expired { + return Err(invite_invalid()); + } + let kind = match row.kind.as_str() { + "admin_setup" => InviteKind::AdminSetup, + "partner" => InviteKind::Partner, + "device" => InviteKind::Device, + other => return Err(AppError::internal(format!("unknown invite kind {other:?}"))), + }; + + let device_name = validate::text( + "device_name", + &body.device_name, + 1, + validate::DEVICE_NAME_MAX, + )?; + let now_ts = format_ts(now); + let user_id = match kind { + InviteKind::Device => { + let target = row + .target_user_id + .as_deref() + .ok_or_else(|| AppError::internal("device invite without target user"))?; + rows::uuid(target)? + } + InviteKind::AdminSetup | InviteKind::Partner => { + let display_name = body.display_name.as_deref().ok_or_else(|| { + AppError::invalid_request("display_name is required for this invite") + .with_field("display_name") + })?; + let display_name = + validate::text("display_name", display_name, 1, validate::DISPLAY_NAME_MAX)?; + let users = user_count(&mut tx).await?; + let role = if kind == InviteKind::AdminSetup { + if users > 0 { + // A household exists already: this setup code can no longer be used. + return Err(invite_invalid()); + } + Role::Admin + } else { + if users >= MAX_USERS { + return Err(household_full()); + } + Role::Member + }; + let user_id = Uuid::now_v7(); + let version = db::next_server_version(&mut tx).await?; + sqlx::query( + "INSERT INTO users (id, role, display_name, picture_version, created_at, updated_at, server_version) \ + VALUES (?, ?, ?, NULL, ?, ?, ?)", + ) + .bind(user_id.to_string()) + .bind(role.as_str()) + .bind(&display_name) + .bind(&now_ts) + .bind(&now_ts) + .bind(version) + .execute(&mut *tx) + .await?; + user_id + } + }; + + let token = generate_token()?; + let device_id = Uuid::now_v7(); + sqlx::query( + "INSERT INTO devices (id, user_id, name, token_hash, fcm_token, app_version_code, created_at, last_seen_at) \ + VALUES (?, ?, ?, ?, NULL, ?, ?, ?)", + ) + .bind(device_id.to_string()) + .bind(user_id.to_string()) + .bind(&device_name) + .bind(sha256_hex(&token)) + .bind(app_version(&headers)) + .bind(&now_ts) + .bind(&now_ts) + .execute(&mut *tx) + .await?; + sqlx::query("UPDATE invites SET accepted_at = ?, accepted_device_id = ? WHERE id = ?") + .bind(&now_ts) + .bind(device_id.to_string()) + .bind(&row.id) + .execute(&mut *tx) + .await?; + + let user = rows::user(&mut tx, user_id) + .await? + .ok_or_else(|| AppError::internal("invite target user is missing"))?; + let device = rows::device(&mut tx, device_id, device_id) + .await? + .ok_or_else(|| AppError::internal("new device vanished"))?; + let household = rows::household(&mut tx).await?; + tx.commit().await?; + tracing::info!(device_id = %device_id, invite_kind = row.kind, "device paired"); + Ok(JsonBody(AcceptInviteResponse { + token, + user, + device, + household, + })) } #[utoipa::path( @@ -44,8 +354,50 @@ pub async fn accept_invite() -> AppError { (status = 409, response = Conflict), ) )] -pub async fn create_invite(_admin: AdminDevice) -> AppError { - not_implemented() +pub async fn create_invite( + AdminDevice(admin): AdminDevice, + State(state): State, + ApiJson(body): ApiJson, +) -> Result<(StatusCode, JsonBody), AppError> { + let now = state.clock.now(); + let mut tx = db::begin_immediate(&state.db).await?; + let invite = match body.kind { + CreateInviteKind::Partner => { + if body.target_user_id.is_some() { + return Err(AppError::invalid_request( + "target_user_id must be null for a partner invite", + ) + .with_field("target_user_id")); + } + if user_count(&mut tx).await? >= MAX_USERS { + return Err(household_full()); + } + cancel_pending(&mut tx, InviteKind::Partner, now).await?; + insert_invite(&mut tx, InviteKind::Partner, None, Some(admin.user_id), now).await? + } + CreateInviteKind::Device => { + let target = body.target_user_id.ok_or_else(|| { + AppError::invalid_request("target_user_id is required for a device invite") + .with_field("target_user_id") + })?; + rows::user(&mut tx, target) + .await? + .ok_or_else(AppError::not_found)?; + insert_invite( + &mut tx, + InviteKind::Device, + Some(target), + Some(admin.user_id), + now, + ) + .await? + } + }; + tx.commit().await?; + Ok(( + StatusCode::CREATED, + JsonBody(invite.wire(&state.config.public_url)), + )) } #[utoipa::path( @@ -58,8 +410,33 @@ pub async fn create_invite(_admin: AdminDevice) -> AppError { (status = 403, response = Forbidden), ) )] -pub async fn list_invites(_admin: AdminDevice) -> AppError { - not_implemented() +pub async fn list_invites( + _admin: AdminDevice, + State(state): State, +) -> Result, AppError> { + let rows: Vec<(String, String, Option, String, String)> = sqlx::query_as( + "SELECT id, kind, target_user_id, created_at, expires_at FROM invites \ + WHERE accepted_at IS NULL AND cancelled_at IS NULL AND expires_at > ? ORDER BY created_at, id", + ) + .bind(state.clock.now_ts()) + .fetch_all(&state.db) + .await?; + let invites = rows + .into_iter() + .map(|(id, kind, target, created_at, expires_at)| { + Ok(Invite { + id: rows::uuid(&id)?, + kind: serde_json::from_value(serde_json::Value::String(kind)) + .map_err(AppError::internal)?, + target_user_id: target.as_deref().map(rows::uuid).transpose()?, + code: None, + link: None, + created_at, + expires_at, + }) + }) + .collect::>()?; + Ok(JsonBody(InviteList { invites })) } #[utoipa::path( @@ -77,6 +454,61 @@ pub async fn list_invites(_admin: AdminDevice) -> AppError { (status = 404, response = NotFound), ) )] -pub async fn cancel_invite(_admin: AdminDevice, IdPath(_invite_id): IdPath) -> AppError { - not_implemented() +pub async fn cancel_invite( + _admin: AdminDevice, + State(state): State, + IdPath(invite_id): IdPath, +) -> Result { + let mut tx = db::begin_immediate(&state.db).await?; + let exists: bool = sqlx::query_scalar("SELECT EXISTS (SELECT 1 FROM invites WHERE id = ?)") + .bind(invite_id.to_string()) + .fetch_one(&mut *tx) + .await?; + if !exists { + return Err(AppError::not_found()); + } + sqlx::query( + "UPDATE invites SET cancelled_at = ? WHERE id = ? AND accepted_at IS NULL AND cancelled_at IS NULL", + ) + .bind(state.clock.now_ts()) + .bind(invite_id.to_string()) + .execute(&mut *tx) + .await?; + tx.commit().await?; + Ok(StatusCode::NO_CONTENT) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn codes_have_the_alphabet_and_length() { + for _ in 0..100 { + let code = generate_code().unwrap(); + assert_eq!(normalise_code(&code).as_deref(), Some(code.as_str())); + } + } + + #[test] + fn normalisation() { + assert_eq!( + normalise_code("7k3m-qx9d 2hpa").as_deref(), + Some("7K3MQX9D2HPA") + ); + assert_eq!( + normalise_code("OOOO-IIII-LLLL").as_deref(), + Some("000011111111") + ); + assert_eq!(normalise_code("7K3MQX9D2HP"), None); + assert_eq!(normalise_code("7K3MQX9D2HPU"), None); + assert_eq!(display_code("7K3MQX9D2HPA"), "7K3M-QX9D-2HPA"); + } + + #[test] + fn tokens_have_the_shape_of_section_8_2() { + let token = generate_token().unwrap(); + assert!(crate::auth::is_token_shaped(&token), "{}", token.len()); + assert_ne!(token, generate_token().unwrap()); + } } diff --git a/server/crates/kvitt-server/src/api/landing.rs b/server/crates/kvitt-server/src/api/landing.rs index 7995c3c..1938ce5 100644 --- a/server/crates/kvitt-server/src/api/landing.rs +++ b/server/crates/kvitt-server/src/api/landing.rs @@ -1,10 +1,181 @@ -//! E-4 invite landing page (AC-2.5). +//! E-4 invite landing page (AC-2.5), server-rendered HTML with the copy of SPEC §13.3 "Invite landing page". //! -//! Registered and annotated by M2a; M3 owns this file and implements the handlers (until then 501 -//! `not_implemented`). +//! Swedish when `Accept-Language` prefers `sv`, English otherwise. The page shows the app name, "Download Kvitt" +//! linking to the latest APK, "Open in Kvitt" linking to `kvitt://invite?server=…&code=…`, the code in groups of four +//! and one instruction sentence. It never checks the code and holds no personal data. Any code of 1 to 32 +//! characters gets 200; anything else a 404 page. -use crate::api::not_implemented; -use crate::error::AppError; +use axum::extract::{Path, State}; +use axum::http::{HeaderMap, HeaderValue, StatusCode, header}; +use axum::response::{IntoResponse, Response}; +use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode}; + +use crate::AppState; +use crate::api::apk; + +/// Longest code the page accepts (E-4). +pub const MAX_CODE_CHARS: usize = 32; + +/// The page language. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Lang { + En, + Sv, +} + +/// The copy of SPEC §13.3 "Invite landing page". +struct Copy { + html_lang: &'static str, + app_name: &'static str, + download: &'static str, + open: &'static str, + instruction: &'static str, +} + +const EN: Copy = Copy { + html_lang: "en", + app_name: "Kvitt", + download: "Download Kvitt", + open: "Open in Kvitt", + instruction: "Install Kvitt, then open this link again on your phone.", +}; + +const SV: Copy = Copy { + html_lang: "sv", + app_name: "Kvitt", + download: "Ladda ner Kvitt", + open: "Öppna i Kvitt", + instruction: "Installera Kvitt och öppna sedan den här länken igen på din telefon.", +}; + +/// Swedish if the highest-weighted language range of `Accept-Language` is `sv` (or `sv-*`); English otherwise. +/// Among equal weights the first listed wins; `*` and ranges with `q=0` never select Swedish. +pub fn preferred_lang(accept_language: Option<&str>) -> Lang { + let Some(header) = accept_language else { + return Lang::En; + }; + let mut best: Option<(f32, &str)> = None; + for part in header.split(',') { + let mut pieces = part.split(';'); + let tag = pieces.next().unwrap_or("").trim(); + if tag.is_empty() { + continue; + } + let q = pieces + .find_map(|p| p.trim().strip_prefix("q=")) + .and_then(|q| q.trim().parse::().ok()) + .unwrap_or(1.0); + if q <= 0.0 { + continue; + } + if best.is_none_or(|(best_q, _)| q > best_q) { + best = Some((q, tag)); + } + } + match best { + Some((_, tag)) => { + let primary = tag.split('-').next().unwrap_or(""); + if primary.eq_ignore_ascii_case("sv") { + Lang::Sv + } else { + Lang::En + } + } + None => Lang::En, + } +} + +fn escape(text: &str) -> String { + let mut out = String::with_capacity(text.len()); + for c in text.chars() { + match c { + '&' => out.push_str("&"), + '<' => out.push_str("<"), + '>' => out.push_str(">"), + '"' => out.push_str("""), + '\'' => out.push_str("'"), + other => out.push(other), + } + } + out +} + +/// The code as the page shows and links it: uppercase, without `-` and spaces. +fn clean_code(code: &str) -> String { + code.chars() + .filter(|c| *c != '-' && *c != ' ') + .flat_map(char::to_uppercase) + .collect() +} + +fn grouped(code: &str) -> String { + let chars: Vec = code.chars().collect(); + chars + .chunks(4) + .map(|c| c.iter().collect::()) + .collect::>() + .join("-") +} + +const STYLE: &str = "body{font-family:system-ui,sans-serif;max-width:28rem;margin:3rem auto;padding:0 1rem;\ +text-align:center;color:#1b1b1b;background:#fff}h1{font-size:2rem}.code{font-family:monospace;font-size:1.5rem;\ +letter-spacing:.1em;margin:1.5rem 0}a.button{display:block;margin:.75rem 0;padding:.9rem;border-radius:.75rem;\ +text-decoration:none;font-weight:600;background:#2e7d32;color:#fff}a.secondary{background:#e8f5e9;color:#1b5e20}\ +@media (prefers-color-scheme:dark){body{background:#121212;color:#eee}a.secondary{background:#1e3320;color:#c8e6c9}}"; + +/// The landing page HTML (AC-2.5). `download_url` is the latest APK, when one is published. +pub fn render(lang: Lang, code: &str, public_url: &str, download_url: Option<&str>) -> String { + let copy = match lang { + Lang::En => EN, + Lang::Sv => SV, + }; + let code = clean_code(code); + let open_url = format!( + "kvitt://invite?server={}&code={}", + utf8_percent_encode(public_url, NON_ALPHANUMERIC), + utf8_percent_encode(&code, NON_ALPHANUMERIC) + ); + let download = download_url + .map(|url| { + format!( + "{}\n", + escape(url), + copy.download + ) + }) + .unwrap_or_default(); + format!( + "\n\n\n\n\ +\n\n\ +{app}\n\n\n\n

{app}

\n

{instruction}

\n\ +

{code}

\n{download}{open_label}\n\n\n", + lang = copy.html_lang, + app = copy.app_name, + instruction = copy.instruction, + code = escape(&grouped(&code)), + open = escape(&open_url), + open_label = copy.open, + ) +} + +const NOT_FOUND_HTML: &str = "\n\n\n\nKvitt\n\n\ +\n

Kvitt

\n

404

\n\n\n"; + +fn html(status: StatusCode, body: String) -> Response { + let mut response = (status, body).into_response(); + let headers = response.headers_mut(); + headers.insert( + header::CONTENT_TYPE, + HeaderValue::from_static("text/html; charset=utf-8"), + ); + // The URL carries an invite code: keep it out of caches and referrers. + headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store")); + headers.insert( + header::REFERRER_POLICY, + HeaderValue::from_static("no-referrer"), + ); + response +} #[utoipa::path( get, @@ -21,6 +192,54 @@ use crate::error::AppError; (status = 404, description = "`code` not 1 to 32 characters: an HTML 404 page.", body = String, content_type = "text/html; charset=utf-8"), ) )] -pub async fn get_invite_landing() -> AppError { - not_implemented() +pub async fn get_invite_landing( + State(state): State, + headers: HeaderMap, + Path(code): Path, +) -> Response { + let len = code.chars().count(); + if !(1..=MAX_CODE_CHARS).contains(&len) { + return html(StatusCode::NOT_FOUND, NOT_FOUND_HTML.to_owned()); + } + let lang = preferred_lang( + headers + .get(header::ACCEPT_LANGUAGE) + .and_then(|v| v.to_str().ok()), + ); + let download = match apk::read_latest(&state).await { + Ok(latest) => { + latest.map(|l| format!("{}/apk/files/{}", state.config.public_url, l.file_name)) + } + Err(e) => { + tracing::warn!(error_code = e.code.as_str(), "landing page: no APK link"); + None + } + }; + html( + StatusCode::OK, + render(lang, &code, &state.config.public_url, download.as_deref()), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn language_preference() { + assert_eq!(preferred_lang(None), Lang::En); + assert_eq!(preferred_lang(Some("sv-SE,sv;q=0.9,en;q=0.8")), Lang::Sv); + assert_eq!(preferred_lang(Some("en-GB,sv;q=0.9")), Lang::En); + assert_eq!(preferred_lang(Some("de;q=0.5, sv;q=0.7")), Lang::Sv); + assert_eq!(preferred_lang(Some("sv;q=0, en")), Lang::En); + assert_eq!(preferred_lang(Some("*")), Lang::En); + assert_eq!(preferred_lang(Some("SV")), Lang::Sv); + } + + #[test] + fn html_is_escaped() { + let page = render(Lang::En, "", "https://x", None); + assert!(page.contains("<B>")); + assert!(!page.contains("")); + } } diff --git a/server/crates/kvitt-server/src/api/me.rs b/server/crates/kvitt-server/src/api/me.rs index 134cfb2..5028f96 100644 --- a/server/crates/kvitt-server/src/api/me.rs +++ b/server/crates/kvitt-server/src/api/me.rs @@ -1,10 +1,11 @@ //! E-7 `GET /me` and E-8 `PATCH /me` (FR-4). -//! -//! Registered and annotated by M2a; M3 owns this file and implements the handlers (until then 501 -//! `not_implemented`). -use crate::api::not_implemented; +use axum::extract::State; + +use crate::AppState; +use crate::api::{ApiJson, JsonBody, rows, validate}; use crate::auth::AuthDevice; +use crate::db; use crate::error::AppError; use crate::openapi::responses::{BadRequest, Unprocessable}; use crate::wire::{MeResponse, PatchMeRequest, User}; @@ -18,8 +19,24 @@ use crate::wire::{MeResponse, PatchMeRequest, User}; (status = 200, description = "The caller's user and device.", body = MeResponse, content_type = "application/json; charset=utf-8"), ) )] -pub async fn get_me(_device: AuthDevice) -> AppError { - not_implemented() +pub async fn get_me( + device: AuthDevice, + State(state): State, +) -> Result, AppError> { + let mut conn = state.db.acquire().await?; + let user = rows::user(&mut conn, device.user_id) + .await? + .ok_or_else(|| AppError::internal("the caller's user is missing"))?; + let me = rows::device(&mut conn, device.device_id, device.device_id) + .await? + .ok_or_else(|| AppError::internal("the caller's device is missing"))?; + let household = rows::household(&mut conn).await?; + Ok(JsonBody(MeResponse { + user, + device: me, + household, + public_url: state.config.public_url.clone(), + })) } #[utoipa::path( @@ -34,6 +51,30 @@ pub async fn get_me(_device: AuthDevice) -> AppError { (status = 422, response = Unprocessable), ) )] -pub async fn patch_me(_device: AuthDevice) -> AppError { - not_implemented() +pub async fn patch_me( + device: AuthDevice, + State(state): State, + ApiJson(body): ApiJson, +) -> Result, AppError> { + let Some(display_name) = body.display_name else { + return Err(AppError::invalid_request("PATCH needs at least one field")); + }; + let display_name = + validate::text("display_name", &display_name, 1, validate::DISPLAY_NAME_MAX)?; + let mut tx = db::begin_immediate(&state.db).await?; + let version = db::next_server_version(&mut tx).await?; + sqlx::query( + "UPDATE users SET display_name = ?, updated_at = ?, server_version = ? WHERE id = ?", + ) + .bind(&display_name) + .bind(state.clock.now_ts()) + .bind(version) + .bind(device.user_id.to_string()) + .execute(&mut *tx) + .await?; + let user = rows::user(&mut tx, device.user_id) + .await? + .ok_or_else(|| AppError::internal("the caller's user is missing"))?; + tx.commit().await?; + Ok(JsonBody(user)) } diff --git a/server/crates/kvitt-server/src/api/mod.rs b/server/crates/kvitt-server/src/api/mod.rs index eb8a7ca..a341433 100644 --- a/server/crates/kvitt-server/src/api/mod.rs +++ b/server/crates/kvitt-server/src/api/mod.rs @@ -15,8 +15,10 @@ pub mod pictures; pub mod proposal; pub mod rate_limit; pub mod recurring; +pub mod rows; pub mod setup; pub mod sync; +pub mod validate; use axum::extract::rejection::{JsonRejection, PathRejection}; use axum::extract::{FromRequest, FromRequestParts, Request}; diff --git a/server/crates/kvitt-server/src/api/pictures.rs b/server/crates/kvitt-server/src/api/pictures.rs index 6372fd7..5177246 100644 --- a/server/crates/kvitt-server/src/api/pictures.rs +++ b/server/crates/kvitt-server/src/api/pictures.rs @@ -1,16 +1,160 @@ -//! Pictures: E-9 upload, E-10 remove, E-11 download (AC-4.4). +//! Pictures: E-9 upload, E-10 remove, E-11 download (FR-4, AC-4.4). //! -//! Registered and annotated by M2a; M3 owns this file and implements the handlers (until then 501 -//! `not_implemented`). +//! An upload is JPEG, PNG or WebP (by `Content-Type`), at most 5 MiB (the body limit in `router.rs`) and 8000 × 8000 +//! pixels. The server applies the EXIF orientation, centre-crops to a square, flattens any transparency onto white, +//! and re-encodes as JPEG quality 85 at 512 × 512 and 128 × 128 (no metadata is written). Files are +//! `pictures//-512.jpg` and `-128.jpg`; older versions are deleted after a successful +//! upload (SPEC §10.4). +//! +//! `picture_version` is the previous value + 1, starting at 1. A removal (E-10) deletes the JPEGs but leaves an empty +//! marker `-removed` in the user's directory, so the next upload continues the count instead of +//! restarting at 1: phone caches keyed by `(user_id, picture_version)` never show a removed picture again. -use crate::api::{IdPath, not_implemented}; +use std::io::Cursor; +use std::path::{Path, PathBuf}; + +use axum::body::Bytes; +use axum::extract::rejection::BytesRejection; +use axum::extract::{RawQuery, State}; +use axum::http::{HeaderMap, HeaderValue, StatusCode, header}; +use axum::response::{IntoResponse, Response}; +use image::codecs::jpeg::JpegEncoder; +use image::imageops::FilterType; +use image::metadata::Orientation; +use image::{DynamicImage, ImageDecoder, ImageFormat, ImageReader, Rgb, RgbImage}; +use uuid::Uuid; + +use crate::AppState; +use crate::api::{IdPath, JsonBody, rows}; use crate::auth::AuthDevice; -use crate::error::AppError; +use crate::db; +use crate::error::{AppError, ErrorCode}; use crate::openapi::responses::{ BadRequest, NotFound, PayloadTooLarge, Unprocessable, UnsupportedMediaType, }; +use crate::router::PICTURE_BODY_LIMIT_BYTES; use crate::wire::{IntSchema, JpegBytes, PictureSizeSchema, PngBytes, User, UuidSchema, WebpBytes}; +/// Largest accepted width and height (AC-4.4). +pub const MAX_DIMENSION: u32 = 8000; +/// JPEG quality of the stored pictures (AC-4.4). +pub const JPEG_QUALITY: u8 = 85; +/// The stored sizes (AC-4.4); E-11 serves 512 by default. +pub const SIZES: [u32; 2] = [512, 128]; +/// `Cache-Control` of E-11. +pub const CACHE_CONTROL: &str = "private, max-age=31536000, immutable"; + +fn image_invalid(detail: impl std::fmt::Display) -> AppError { + AppError::new( + ErrorCode::ImageInvalid, + format!("the picture cannot be used: {detail}"), + ) +} + +/// The image format named by a `Content-Type` value; `None` for anything but JPEG, PNG and WebP. +pub fn format_of(content_type: Option<&str>) -> Option { + let media = content_type?.split(';').next()?.trim().to_ascii_lowercase(); + match media.as_str() { + "image/jpeg" => Some(ImageFormat::Jpeg), + "image/png" => Some(ImageFormat::Png), + "image/webp" => Some(ImageFormat::WebP), + _ => None, + } +} + +/// Decodes, orients, crops and re-encodes an upload. Returns the JPEG bytes for each of [`SIZES`], in that order. +pub fn process(bytes: &[u8], format: ImageFormat) -> Result>, AppError> { + let reader = ImageReader::with_format(Cursor::new(bytes), format); + let mut decoder = reader.into_decoder().map_err(image_invalid)?; + let (width, height) = decoder.dimensions(); + if width == 0 || height == 0 || width > MAX_DIMENSION || height > MAX_DIMENSION { + return Err(image_invalid(format!("{width} × {height} pixels"))); + } + let orientation = decoder.orientation().unwrap_or(Orientation::NoTransforms); + let mut image = DynamicImage::from_decoder(decoder).map_err(image_invalid)?; + image.apply_orientation(orientation); + + let (w, h) = (image.width(), image.height()); + let side = w.min(h); + let square = image.crop_imm((w - side) / 2, (h - side) / 2, side, side); + let square = if square.color().has_alpha() { + let rgba = square.to_rgba8(); + let flat = RgbImage::from_fn(side, side, |x, y| { + let p = rgba.get_pixel(x, y).0; + let a = u32::from(p[3]); + let blend = + |c: u8| u8::try_from((u32::from(c) * a + 255 * (255 - a)) / 255).unwrap_or(255); + Rgb([blend(p[0]), blend(p[1]), blend(p[2])]) + }); + DynamicImage::ImageRgb8(flat) + } else { + DynamicImage::ImageRgb8(square.to_rgb8()) + }; + + SIZES + .iter() + .map(|&size| { + let resized = square + .resize_exact(size, size, FilterType::Lanczos3) + .to_rgb8(); + let mut out = Vec::new(); + JpegEncoder::new_with_quality(&mut out, JPEG_QUALITY) + .encode_image(&resized) + .map_err(AppError::internal)?; + Ok(out) + }) + .collect() +} + +fn user_dir(state: &AppState, user_id: Uuid) -> PathBuf { + state.config.pictures_dir().join(user_id.to_string()) +} + +fn file_name(version: i64, size: u32) -> String { + format!("{version}-{size}.jpg") +} + +/// Writes `bytes` to `path` through a temporary file and a rename. +async fn write_atomically(path: &Path, bytes: &[u8]) -> Result<(), AppError> { + let tmp = path.with_extension("jpg.tmp"); + tokio::fs::write(&tmp, bytes) + .await + .map_err(AppError::internal)?; + tokio::fs::rename(&tmp, path) + .await + .map_err(AppError::internal) +} + +/// The highest version named by a file in `dir` (`-512.jpg`, `-removed`, …), 0 when there is none. +async fn highest_version_on_disk(dir: &Path) -> i64 { + let Ok(mut entries) = tokio::fs::read_dir(dir).await else { + return 0; + }; + let mut highest = 0; + while let Ok(Some(entry)) = entries.next_entry().await { + let name = entry.file_name().to_string_lossy().into_owned(); + if let Some(v) = name.split('-').next().and_then(|v| v.parse::().ok()) { + highest = highest.max(v); + } + } + highest +} + +/// Deletes every file in `dir` except `keep`, ignoring a missing directory. +async fn delete_other_files(dir: &Path, keep: &[String]) { + let Ok(mut entries) = tokio::fs::read_dir(dir).await else { + return; + }; + while let Ok(Some(entry)) = entries.next_entry().await { + let name = entry.file_name().to_string_lossy().into_owned(); + if !keep.contains(&name) + && let Err(e) = tokio::fs::remove_file(entry.path()).await + { + tracing::warn!(detail = %e, "cannot delete an old picture file"); + } + } +} + #[utoipa::path( put, path = "/me/picture", @@ -25,8 +169,69 @@ use crate::wire::{IntSchema, JpegBytes, PictureSizeSchema, PngBytes, User, UuidS (status = 422, response = Unprocessable), ) )] -pub async fn put_me_picture(_device: AuthDevice) -> AppError { - not_implemented() +pub async fn put_me_picture( + device: AuthDevice, + State(state): State, + headers: HeaderMap, + body: Result, +) -> Result, AppError> { + let format = format_of( + headers + .get(header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()), + ) + .ok_or_else(|| { + AppError::new( + ErrorCode::UnsupportedMediaType, + "the picture must be image/jpeg, image/png or image/webp", + ) + })?; + let bytes = body.map_err(|rejection| { + if rejection.status() == StatusCode::PAYLOAD_TOO_LARGE { + AppError::payload_too_large(PICTURE_BODY_LIMIT_BYTES) + } else { + AppError::invalid_request(rejection.body_text()) + } + })?; + let encoded = tokio::task::spawn_blocking(move || process(&bytes, format)) + .await + .map_err(AppError::internal)??; + + let dir = user_dir(&state, device.user_id); + tokio::fs::create_dir_all(&dir) + .await + .map_err(AppError::internal)?; + let mut tx = db::begin_immediate(&state.db).await?; + let user = rows::user(&mut tx, device.user_id) + .await? + .ok_or_else(|| AppError::internal("the caller's user is missing"))?; + let server_version = db::next_server_version(&mut tx).await?; + let picture_version = match user.picture_version { + Some(v) => v + 1, + None => highest_version_on_disk(&dir).await + 1, + }; + for (size, bytes) in SIZES.iter().zip(&encoded) { + write_atomically(&dir.join(file_name(picture_version, *size)), bytes).await?; + } + sqlx::query( + "UPDATE users SET picture_version = ?, updated_at = ?, server_version = ? WHERE id = ?", + ) + .bind(picture_version) + .bind(state.clock.now_ts()) + .bind(server_version) + .bind(device.user_id.to_string()) + .execute(&mut *tx) + .await?; + let user = rows::user(&mut tx, device.user_id) + .await? + .ok_or_else(|| AppError::internal("the caller's user is missing"))?; + tx.commit().await?; + let keep: Vec = SIZES + .iter() + .map(|&s| file_name(picture_version, s)) + .collect(); + delete_other_files(&dir, &keep).await; + Ok(JsonBody(user)) } #[utoipa::path( @@ -39,8 +244,58 @@ pub async fn put_me_picture(_device: AuthDevice) -> AppError { (status = 200, description = "The user with `picture_version` null.", body = User, content_type = "application/json; charset=utf-8"), ) )] -pub async fn delete_me_picture(_device: AuthDevice) -> AppError { - not_implemented() +pub async fn delete_me_picture( + device: AuthDevice, + State(state): State, +) -> Result, AppError> { + let mut tx = db::begin_immediate(&state.db).await?; + let user = rows::user(&mut tx, device.user_id) + .await? + .ok_or_else(|| AppError::internal("the caller's user is missing"))?; + let Some(removed) = user.picture_version else { + tx.commit().await?; + return Ok(JsonBody(user)); + }; + let version = db::next_server_version(&mut tx).await?; + sqlx::query( + "UPDATE users SET picture_version = NULL, updated_at = ?, server_version = ? WHERE id = ?", + ) + .bind(state.clock.now_ts()) + .bind(version) + .bind(device.user_id.to_string()) + .execute(&mut *tx) + .await?; + let user = rows::user(&mut tx, device.user_id) + .await? + .ok_or_else(|| AppError::internal("the caller's user is missing"))?; + tx.commit().await?; + let dir = user_dir(&state, device.user_id); + let marker = format!("{removed}-removed"); + if let Err(e) = tokio::fs::write(dir.join(&marker), b"").await { + tracing::warn!(detail = %e, "cannot write the picture removal marker"); + } + delete_other_files(&dir, &[marker]).await; + Ok(JsonBody(user)) +} + +/// The `size` query parameter of E-11: 512 when absent, 128 or 512, anything else `invalid_request`. +fn requested_size(query: Option<&str>) -> Result { + let mut size = 512; + for pair in query.unwrap_or("").split('&').filter(|p| !p.is_empty()) { + let (key, value) = pair.split_once('=').unwrap_or((pair, "")); + if key == "size" { + size = match value { + "128" => 128, + "512" => 512, + _ => { + return Err( + AppError::invalid_request("size must be 128 or 512").with_field("size") + ); + } + }; + } + } + Ok(size) } #[utoipa::path( @@ -59,6 +314,35 @@ pub async fn delete_me_picture(_device: AuthDevice) -> AppError { (status = 404, response = NotFound), ) )] -pub async fn get_user_picture(_device: AuthDevice, IdPath(_user_id): IdPath) -> AppError { - not_implemented() +pub async fn get_user_picture( + _device: AuthDevice, + State(state): State, + IdPath(user_id): IdPath, + RawQuery(query): RawQuery, +) -> Result { + let size = requested_size(query.as_deref())?; + let mut conn = state.db.acquire().await?; + let version = rows::user(&mut conn, user_id) + .await? + .and_then(|u| u.picture_version) + .ok_or_else(AppError::not_found)?; + drop(conn); + let path = user_dir(&state, user_id).join(file_name(version, size)); + let bytes = match tokio::fs::read(&path).await { + Ok(bytes) => bytes, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Err(AppError::not_found()), + Err(e) => return Err(AppError::internal(e)), + }; + let mut response = bytes.into_response(); + let headers = response.headers_mut(); + headers.insert(header::CONTENT_TYPE, HeaderValue::from_static("image/jpeg")); + headers.insert( + header::ETAG, + HeaderValue::from_str(&format!("\"{version}\"")).map_err(AppError::internal)?, + ); + headers.insert( + header::CACHE_CONTROL, + HeaderValue::from_static(CACHE_CONTROL), + ); + Ok(response) } diff --git a/server/crates/kvitt-server/src/api/proposal.rs b/server/crates/kvitt-server/src/api/proposal.rs index 354cd8f..53afc24 100644 --- a/server/crates/kvitt-server/src/api/proposal.rs +++ b/server/crates/kvitt-server/src/api/proposal.rs @@ -1,13 +1,18 @@ -//! E-33 settlement allocation proposal (SPEC §9.4). -//! -//! Registered and annotated by M2a; M3 owns this file and implements the handlers (until then 501 -//! `not_implemented`). +//! E-33 settlement allocation proposal (SPEC §9.4, AC-11.8): `kvitt_domain::propose_allocation` over the server's +//! non-deleted accounts, expenses and settlements, the payer's nets computed with `kvitt_domain::compute_balances` +//! and the settlement `exclude_settlement_id` left out. -use crate::api::not_implemented; +use std::collections::BTreeMap; + +use axum::extract::State; +use kvitt_domain::{AccountPosition, AllocationError, BalanceExpense, BalanceSettlement}; + +use crate::AppState; +use crate::api::{ApiJson, JsonBody, rows, validate}; use crate::auth::AuthDevice; -use crate::error::AppError; +use crate::error::{AppError, ErrorCode}; use crate::openapi::responses::{BadRequest, Conflict, Unprocessable}; -use crate::wire::{ProposalRequest, ProposalResponse}; +use crate::wire::{Allocation, ProposalRequest, ProposalResponse}; #[utoipa::path( post, @@ -23,6 +28,108 @@ use crate::wire::{ProposalRequest, ProposalResponse}; (status = 422, response = Unprocessable), ) )] -pub async fn propose_settlement(_device: AuthDevice) -> AppError { - not_implemented() +pub async fn propose_settlement( + _device: AuthDevice, + State(state): State, + ApiJson(body): ApiJson, +) -> Result, AppError> { + let mut conn = state.db.acquire().await?; + let users = rows::household(&mut conn).await?; + if !users.iter().any(|u| u.id == body.payer_user_id) { + return Err(AppError::validation("payer_user_id", "user_unknown")); + } + validate::amount("amount_ore", body.amount_ore)?; + let payer = body.payer_user_id.to_string(); + + let accounts: Vec<(String, String, Option)> = sqlx::query_as( + "SELECT id, created_at, archived_at FROM accounts WHERE deleted_at IS NULL ORDER BY created_at, id", + ) + .fetch_all(&mut *conn) + .await?; + let expense_rows: Vec<(String, String, i64, String, String, i64)> = sqlx::query_as( + "SELECT e.id, e.account_id, e.amount_ore, e.payer_user_id, s.user_id, s.share_ore \ + FROM expenses e JOIN expense_splits s ON s.expense_id = e.id WHERE e.deleted_at IS NULL ORDER BY e.id", + ) + .fetch_all(&mut *conn) + .await?; + let mut expenses: BTreeMap = BTreeMap::new(); + for (id, account_id, amount_ore, payer_user_id, user_id, share_ore) in expense_rows { + expenses + .entry(id) + .or_insert_with(|| BalanceExpense { + account_id, + amount_ore, + payer_user_id, + shares: BTreeMap::new(), + }) + .shares + .insert(user_id, share_ore); + } + let settlement_rows: Vec<(String, String, String, String, i64)> = sqlx::query_as( + "SELECT s.id, s.from_user_id, s.to_user_id, a.account_id, a.amount_ore \ + FROM settlements s JOIN settlement_allocations a ON a.settlement_id = s.id \ + WHERE s.deleted_at IS NULL AND s.id IS NOT ? ORDER BY s.id", + ) + .bind(body.exclude_settlement_id.map(|id| id.to_string())) + .fetch_all(&mut *conn) + .await?; + let mut settlements: BTreeMap = BTreeMap::new(); + for (id, from_user_id, to_user_id, account_id, amount_ore) in settlement_rows { + settlements + .entry(id) + .or_insert_with(|| BalanceSettlement { + from_user_id, + to_user_id, + allocations: BTreeMap::new(), + }) + .allocations + .insert(account_id, amount_ore); + } + + let account_ids: Vec = accounts.iter().map(|(id, _, _)| id.clone()).collect(); + let balances = kvitt_domain::compute_balances( + &rows::user_ids(&users), + &account_ids, + &expenses.into_values().collect::>(), + &settlements.into_values().collect::>(), + ); + let positions: Vec = accounts + .into_iter() + .map(|(id, created_at, archived_at)| AccountPosition { + payer_net_ore: balances + .per_account + .get(&id) + .and_then(|nets| nets.get(&payer)) + .copied() + .unwrap_or(0), + account_id: id, + created_at, + archived: archived_at.is_some(), + }) + .collect(); + let proposal = + kvitt_domain::propose_allocation(body.amount_ore, &positions).map_err(|e| match e { + AllocationError::NoAccounts => { + AppError::new(ErrorCode::NoAccounts, "there are no accounts") + } + AllocationError::AmountOutOfRange => AppError::validation("amount_ore", e.token()), + })?; + Ok(JsonBody(ProposalResponse { + allocations: proposal + .allocations + .into_iter() + .map(|a| { + Ok(Allocation { + account_id: rows::uuid(&a.account_id)?, + amount_ore: a.amount_ore, + }) + }) + .collect::>()?, + excess_ore: proposal.excess_ore, + over_settled_account_ids: proposal + .over_settled_account_ids + .iter() + .map(|id| rows::uuid(id)) + .collect::>()?, + })) } diff --git a/server/crates/kvitt-server/src/api/rate_limit.rs b/server/crates/kvitt-server/src/api/rate_limit.rs index a9823ce..052e283 100644 --- a/server/crates/kvitt-server/src/api/rate_limit.rs +++ b/server/crates/kvitt-server/src/api/rate_limit.rs @@ -1,8 +1,444 @@ -//! Rate limiting (SPEC §8.6). M3 owns this file; in M2a the layer lets every request through. +//! Rate limiting (SPEC §8.6, AC-2.12, §8.5), as a tower layer that `router.rs` attaches to every route. +//! +//! * Unauthenticated endpoints (E-1 to E-6 and unknown paths): at most 60 requests per minute per client IP. +//! * Authenticated endpoints: at most 600 requests per minute per device, keyed by the hash of the bearer token +//! (the token identifies the device; the limiter never touches the database). A request to an authenticated +//! endpoint without a well-formed bearer token counts against its IP like an unauthenticated one. A response 401 +//! also counts against the IP, and once the IP is over its limit, tokens that have never been accepted are refused +//! too, so random tokens do not bypass the IP limit. +//! * Invite accepts: after 10 failed accepts (any 4xx other than 429) from one IP within 60 minutes, every accept +//! from that IP is `rate_limited` until the 60 minutes since the first of those failures have passed. +//! +//! Refusals are 429 `rate_limited` with `Retry-After` in whole seconds. Windows are fixed: they start at the first +//! request (or failure) counted and last one minute (one hour for accept failures). Everything is in memory and resets +//! on restart; there is no table. +//! +//! The client IP is the TCP peer (axum `ConnectInfo`), or the last `X-Forwarded-For` entry when the peer +//! is an address of `KVITT_TRUSTED_PROXY` (Caddy, SPEC §8.5). The host name is resolved lazily and re-resolved at most +//! once per [`PROXY_RESOLVE_INTERVAL`]; a name that does not resolve means no peer is trusted (D-022). Without +//! `ConnectInfo` (a server not started with `into_make_service_with_connect_info`) all clients share one IP bucket. -use tower::layer::util::Identity; +use std::collections::HashMap; +use std::convert::Infallible; +use std::net::{IpAddr, Ipv4Addr, SocketAddr}; +use std::sync::{Arc, Mutex, MutexGuard}; +use std::task::{Context, Poll}; +use std::time::Instant; -/// The rate-limit layer `router.rs` attaches to every route. -pub fn layer() -> Identity { - Identity::new() +use axum::extract::{ConnectInfo, MatchedPath, Request}; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use chrono::{DateTime, Duration, Utc}; +use tower::{Layer, Service}; + +use crate::AppState; +use crate::audit::BoxFuture; +use crate::auth::{is_token_shaped, sha256_hex}; +use crate::error::AppError; +use crate::time::Clock; + +/// Unauthenticated requests per window per client IP (SPEC §8.6). +pub const UNAUTHENTICATED_LIMIT: u32 = 60; +/// Authenticated requests per window per device (SPEC §8.6). +pub const AUTHENTICATED_LIMIT: u32 = 600; +/// The request window (SPEC §8.6: per minute). +pub const REQUEST_WINDOW: Duration = Duration::minutes(1); +/// Failed invite accepts tolerated per IP within [`ACCEPT_FAILURE_WINDOW`] (AC-2.12). +pub const ACCEPT_FAILURE_LIMIT: u32 = 10; +/// The invite-accept failure window (AC-2.12). +pub const ACCEPT_FAILURE_WINDOW: Duration = Duration::minutes(60); +/// How long a resolution of `KVITT_TRUSTED_PROXY` is reused. +pub const PROXY_RESOLVE_INTERVAL: std::time::Duration = std::time::Duration::from_secs(60); +/// How often expired windows are pruned from memory. +const PRUNE_INTERVAL: Duration = Duration::minutes(10); + +/// Route templates of the unauthenticated endpoints (E-1 to E-6). +const PUBLIC_ROUTES: [&str; 6] = [ + "/health", + "/setup/status", + "/invites/accept", + "/i/{code}", + "/apk/latest", + "/apk/files/{file_name}", +]; +const ACCEPT_ROUTE: &str = "/invites/accept"; + +/// The layer for `router.rs`. One limiter per router: build the router once and reuse it (as `serve` does). +pub fn layer(state: &AppState) -> RateLimitLayer { + RateLimitLayer { + limiter: Arc::new(Limiter::new( + state.clock.clone(), + state.config.trusted_proxy.clone(), + )), + } +} + +/// A fixed counting window. +#[derive(Debug, Clone, Copy)] +struct Window { + start: DateTime, + count: u32, +} + +impl Window { + fn expired(&self, now: DateTime, len: Duration) -> bool { + now >= self.start + len + } + + fn retry_after(&self, now: DateTime, len: Duration) -> u64 { + let remaining = (self.start + len - now).num_milliseconds().max(0); + u64::try_from((remaining + 999) / 1000).unwrap_or(1).max(1) + } +} + +/// Counts one request in `slot` unless the window is full; `Err(retry_after_secs)` when it is. +fn hit( + slot: &mut Option, + now: DateTime, + len: Duration, + limit: u32, +) -> Result<(), u64> { + let window = match slot { + Some(w) if !w.expired(now, len) => w, + _ => slot.insert(Window { + start: now, + count: 0, + }), + }; + if window.count >= limit { + return Err(window.retry_after(now, len)); + } + window.count += 1; + Ok(()) +} + +/// Whether `slot` is full (without counting). +fn full(slot: Option<&Window>, now: DateTime, len: Duration, limit: u32) -> Option { + slot.filter(|w| !w.expired(now, len) && w.count >= limit) + .map(|w| w.retry_after(now, len)) +} + +#[derive(Debug, Default)] +struct TokenState { + window: Option, + /// The token has had a response other than 401: it belongs to a device. + accepted: bool, +} + +#[derive(Debug, Default)] +struct Buckets { + ip: HashMap, + tokens: HashMap, + accept_failures: HashMap, + last_prune: Option>, +} + +impl Buckets { + fn prune(&mut self, now: DateTime) { + if self.last_prune.is_some_and(|t| now - t < PRUNE_INTERVAL) { + return; + } + self.last_prune = Some(now); + self.ip.retain(|_, w| !w.expired(now, REQUEST_WINDOW)); + self.tokens + .retain(|_, t| t.accepted || t.window.is_some_and(|w| !w.expired(now, REQUEST_WINDOW))); + self.accept_failures + .retain(|_, w| !w.expired(now, ACCEPT_FAILURE_WINDOW)); + } +} + +/// How a request is counted. +enum Key { + Ip(IpAddr), + Token { ip: IpAddr, hash: String }, +} + +/// The shared limiter state. +pub struct Limiter { + clock: Arc, + trusted_proxy: String, + proxy: Mutex)>>, + buckets: Mutex, +} + +fn lock(mutex: &Mutex) -> MutexGuard<'_, T> { + match mutex.lock() { + Ok(guard) => guard, + Err(poisoned) => poisoned.into_inner(), + } +} + +impl Limiter { + pub fn new(clock: Arc, trusted_proxy: String) -> Self { + Self { + clock, + trusted_proxy, + proxy: Mutex::new(None), + buckets: Mutex::new(Buckets::default()), + } + } + + /// The addresses of `KVITT_TRUSTED_PROXY`, resolved at most once per [`PROXY_RESOLVE_INTERVAL`]. + async fn proxy_addrs(&self) -> Vec { + if let Some((at, addrs)) = lock(&self.proxy).as_ref() + && at.elapsed() < PROXY_RESOLVE_INTERVAL + { + return addrs.clone(); + } + let name = self.trusted_proxy.trim(); + let addrs: Vec = if name.is_empty() { + Vec::new() + } else if let Ok(ip) = name.parse::() { + vec![ip] + } else { + match tokio::net::lookup_host((name, 0)).await { + Ok(found) => found.map(|a| a.ip()).collect(), + Err(_) => Vec::new(), + } + }; + *lock(&self.proxy) = Some((Instant::now(), addrs.clone())); + addrs + } + + /// The TCP peer and the last `X-Forwarded-For` entry of `request`. + fn peer_and_forwarded(request: &Request) -> (Option, Option) { + let peer = request + .extensions() + .get::>() + .map(|c| c.0.ip()); + let forwarded = request + .headers() + .get("x-forwarded-for") + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.rsplit(',').next()) + .and_then(|v| v.trim().parse::().ok()); + (peer, forwarded) + } + + /// The client IP (SPEC §8.5): the forwarded address when the peer is the trusted proxy, else the peer. + async fn client_ip(&self, peer: Option, forwarded: Option) -> IpAddr { + let Some(peer) = peer else { + return IpAddr::V4(Ipv4Addr::UNSPECIFIED); + }; + match forwarded { + Some(client) if self.proxy_addrs().await.contains(&peer) => client, + _ => peer, + } + } + + fn key(request: &Request, route: &str, ip: IpAddr) -> Key { + if PUBLIC_ROUTES.contains(&route) || route.is_empty() { + return Key::Ip(ip); + } + let token = request + .headers() + .get(axum::http::header::AUTHORIZATION) + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.split_once(' ')) + .filter(|(scheme, _)| scheme.eq_ignore_ascii_case("bearer")) + .map(|(_, token)| token.trim()) + .filter(|token| is_token_shaped(token)); + match token { + Some(token) => Key::Token { + ip, + hash: sha256_hex(token), + }, + None => Key::Ip(ip), + } + } + + /// Counts the request before it runs; `Err(retry_after_secs)` refuses it. + fn admit(&self, key: &Key, accept: bool, now: DateTime) -> Result<(), u64> { + let mut buckets = lock(&self.buckets); + buckets.prune(now); + match key { + Key::Ip(ip) => { + if accept + && let Some(retry) = full( + buckets.accept_failures.get(ip), + now, + ACCEPT_FAILURE_WINDOW, + ACCEPT_FAILURE_LIMIT, + ) + { + return Err(retry); + } + let mut slot = buckets.ip.get(ip).copied(); + let result = hit(&mut slot, now, REQUEST_WINDOW, UNAUTHENTICATED_LIMIT); + if let Some(w) = slot { + buckets.ip.insert(*ip, w); + } + result + } + Key::Token { ip, hash } => { + let accepted = buckets.tokens.get(hash).is_some_and(|t| t.accepted); + if !accepted + && let Some(retry) = full( + buckets.ip.get(ip), + now, + REQUEST_WINDOW, + UNAUTHENTICATED_LIMIT, + ) + { + return Err(retry); + } + let state = buckets.tokens.entry(hash.clone()).or_default(); + hit(&mut state.window, now, REQUEST_WINDOW, AUTHENTICATED_LIMIT) + } + } + } + + /// Records what the response says about the request (401s, accept failures, accepted tokens). + fn record(&self, key: &Key, accept: bool, status: StatusCode, now: DateTime) { + let mut buckets = lock(&self.buckets); + match key { + Key::Ip(ip) => { + if accept && status.is_client_error() && status != StatusCode::TOO_MANY_REQUESTS { + let mut slot = buckets.accept_failures.get(ip).copied(); + // The window is refused once full (admit), so this count never exceeds the limit. + let _ = hit(&mut slot, now, ACCEPT_FAILURE_WINDOW, ACCEPT_FAILURE_LIMIT); + if let Some(w) = slot { + buckets.accept_failures.insert(*ip, w); + } + } + } + Key::Token { ip, hash } => { + if status == StatusCode::UNAUTHORIZED { + let mut slot = buckets.ip.get(ip).copied(); + let _ = hit(&mut slot, now, REQUEST_WINDOW, UNAUTHENTICATED_LIMIT); + if let Some(w) = slot { + buckets.ip.insert(*ip, w); + } + } else if status != StatusCode::TOO_MANY_REQUESTS { + buckets.tokens.entry(hash.clone()).or_default().accepted = true; + } + } + } + } + + async fn handle(self: Arc, request: Request, mut inner: S) -> Response + where + S: Service, + { + let route = request + .extensions() + .get::() + .map(|p| p.as_str().to_owned()) + .unwrap_or_default(); + let accept = route == ACCEPT_ROUTE; + let (peer, forwarded) = Self::peer_and_forwarded(&request); + let ip = self.client_ip(peer, forwarded).await; + let key = Self::key(&request, &route, ip); + if let Err(retry_after) = self.admit(&key, accept, self.clock.now()) { + return AppError::rate_limited(retry_after).into_response(); + } + let response = match inner.call(request).await { + Ok(response) => response, + Err(never) => match never {}, + }; + self.record(&key, accept, response.status(), self.clock.now()); + response + } +} + +/// The tower layer of [`layer`]. +#[derive(Clone)] +pub struct RateLimitLayer { + limiter: Arc, +} + +impl Layer for RateLimitLayer { + type Service = RateLimit; + + fn layer(&self, inner: S) -> Self::Service { + RateLimit { + inner, + limiter: self.limiter.clone(), + } + } +} + +/// The rate-limiting service wrapping one route. +#[derive(Clone)] +pub struct RateLimit { + inner: S, + limiter: Arc, +} + +impl Service for RateLimit +where + S: Service + Clone + Send + 'static, + S::Future: Send + 'static, +{ + type Response = Response; + type Error = Infallible; + type Future = BoxFuture<'static, Result>; + + fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll> { + self.inner.poll_ready(cx) + } + + fn call(&mut self, request: Request) -> Self::Future { + // The ready clone goes into the future; a fresh clone stays for the next call (tower's usual pattern). + let clone = self.inner.clone(); + let inner = std::mem::replace(&mut self.inner, clone); + let limiter = self.limiter.clone(); + Box::pin(async move { Ok(limiter.handle(request, inner).await) }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::time::{FixedClock, parse_ts}; + + #[test] + fn fixed_window_counts_and_reports_retry_after() { + let now = parse_ts("2026-10-17T12:00:00.000Z").unwrap(); + let mut slot = None; + for _ in 0..3 { + assert!(hit(&mut slot, now, REQUEST_WINDOW, 3).is_ok()); + } + assert_eq!( + hit(&mut slot, now + Duration::seconds(20), REQUEST_WINDOW, 3), + Err(40) + ); + assert!(hit(&mut slot, now + Duration::seconds(60), REQUEST_WINDOW, 3).is_ok()); + } + + #[test] + fn retry_after_rounds_up_and_is_at_least_one() { + let now = parse_ts("2026-10-17T12:00:00.000Z").unwrap(); + let w = Window { + start: now, + count: 1, + }; + assert_eq!( + w.retry_after(now + Duration::milliseconds(59_500), REQUEST_WINDOW), + 1 + ); + assert_eq!( + w.retry_after(now + Duration::milliseconds(100), REQUEST_WINDOW), + 60 + ); + } + + #[test] + fn pruning_drops_expired_windows() { + let now = parse_ts("2026-10-17T12:00:00.000Z").unwrap(); + let limiter = Limiter::new(Arc::new(FixedClock::new(now)), String::new()); + let ip = IpAddr::V4(Ipv4Addr::LOCALHOST); + assert!(limiter.admit(&Key::Ip(ip), false, now).is_ok()); + assert_eq!(lock(&limiter.buckets).ip.len(), 1); + assert!( + limiter + .admit( + &Key::Ip(IpAddr::V4(Ipv4Addr::BROADCAST)), + false, + now + Duration::minutes(11) + ) + .is_ok() + ); + assert_eq!(lock(&limiter.buckets).ip.len(), 1); + } } diff --git a/server/crates/kvitt-server/src/api/recurring.rs b/server/crates/kvitt-server/src/api/recurring.rs index af83101..c5ab0eb 100644 --- a/server/crates/kvitt-server/src/api/recurring.rs +++ b/server/crates/kvitt-server/src/api/recurring.rs @@ -1,16 +1,482 @@ -//! Recurring templates: E-28 to E-30 (FR-9, SPEC §9.5). +//! Recurring templates: E-28 to E-30 (FR-9) and the generation of due periods (SPEC §9.5, D-015). //! -//! Registered and annotated by M2a; M3 owns this file and implements the handlers (until then 501 -//! `not_implemented`). +//! [`generate_due_periods`] is the one function that creates the expenses of a template's due periods. E-28 and E-29 +//! call it before responding; M4's scheduler calls it for every template (or [`generate_all_due`]). -use crate::api::{IdPath, not_implemented}; +use std::collections::BTreeMap; + +use axum::extract::State; +use axum::http::StatusCode; +use chrono::{DateTime, NaiveDate, NaiveTime, TimeZone, Utc}; +use kvitt_domain::{Period, SplitSpec}; +use sqlx::SqliteConnection; +use uuid::Uuid; + +use crate::AppState; +use crate::api::{ApiJson, IdPath, JsonBody, rows, validate}; +use crate::audit::AuditEvent; use crate::auth::AuthDevice; +use crate::db; use crate::error::AppError; use crate::openapi::responses::{BadRequest, Conflict, NotFound, Unprocessable}; +use crate::push::{PushMessage, PushType}; +use crate::time::{STOCKHOLM, format_ts}; use crate::wire::{ - CreateRecurringTemplateRequest, PatchRecurringTemplateRequest, RecurringTemplate, UuidSchema, + AuditAction, AuditSource, CreateRecurringTemplateRequest, EntityType, + PatchRecurringTemplateRequest, RecurringTemplate, SplitMode, TemplateSplit, User, UuidSchema, }; +/// Local time of day at which a period's expense is created (SPEC §9.5, AC-9.2). +pub const GENERATION_TIME: NaiveTime = match NaiveTime::from_hms_opt(6, 0, 0) { + Some(t) => t, + None => NaiveTime::MIN, +}; + +// --------------------------------------------------------------------------------------------------------------- +// Generation (SPEC §9.5) +// --------------------------------------------------------------------------------------------------------------- + +/// The occurrence instant of `period` for `day_of_month`: the clamped date at 06:00 Europe/Stockholm. +pub fn occurrence_instant(period: Period, day_of_month: u8) -> Result, AppError> { + let date_text = period + .clamped_date(day_of_month) + .map_err(AppError::internal)?; + let date = NaiveDate::parse_from_str(&date_text, "%Y-%m-%d").map_err(AppError::internal)?; + let local = STOCKHOLM + .from_local_datetime(&date.and_time(GENERATION_TIME)) + .earliest() + .ok_or_else(|| AppError::internal("06:00 does not exist in Europe/Stockholm"))?; + Ok(local.with_timezone(&Utc)) +} + +/// One expense created by [`generate_due_periods`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GeneratedExpense { + pub expense_id: Uuid, + pub period: String, +} + +/// The expense splits of a generated expense: `(user_id, share_ore, share_bp)`. +async fn resolve_splits( + conn: &mut SqliteConnection, + template: &RecurringTemplate, + users: &[User], +) -> Result)>, AppError> { + let ids = rows::user_ids(users); + let payer = template.payer_user_id.to_string(); + let bp: BTreeMap = match template.split_mode { + // Mode default resolves the account's current default ratio at generation time. + SplitMode::Default => { + let account = rows::account(conn, template.account_id) + .await? + .ok_or_else(|| AppError::internal("template account is missing"))?; + account + .default_shares + .iter() + .map(|s| { + Ok(( + s.user_id.to_string(), + u32::try_from(s.share_bp).map_err(AppError::internal)?, + )) + }) + .collect::>()? + } + SplitMode::Ratio | SplitMode::Full => template + .splits + .iter() + .map(|s| { + let bp = s + .share_bp + .ok_or_else(|| AppError::internal("template split without share_bp"))?; + Ok(( + s.user_id.to_string(), + u32::try_from(bp).map_err(AppError::internal)?, + )) + }) + .collect::>()?, + SplitMode::Exact => { + let ore: BTreeMap = template + .splits + .iter() + .map(|s| { + let ore = s + .share_ore + .ok_or_else(|| AppError::internal("template split without share_ore"))?; + Ok((s.user_id.to_string(), ore)) + }) + .collect::>()?; + let shares = kvitt_domain::compute_split( + template.amount_ore, + &payer, + &ids, + &SplitSpec::Exact { ore }, + ) + .map_err(|e| { + AppError::internal(format!("stored exact template split is invalid: {e}")) + })?; + return shares + .into_iter() + .map(|(user, ore)| Ok((rows::uuid(&user)?, ore, None))) + .collect(); + } + }; + // Ratio rule for default, ratio and full (full is ratio with the bearer at 10000 bp, SPEC §14.5.2). + let shares = kvitt_domain::compute_split( + template.amount_ore, + &payer, + &ids, + &SplitSpec::Ratio { bp: bp.clone() }, + ) + .map_err(|e| AppError::internal(format!("stored template split is invalid: {e}")))?; + shares + .into_iter() + .map(|(user, ore)| { + let share_bp = bp.get(&user).map(|v| i64::from(*v)); + Ok((rows::uuid(&user)?, ore, share_bp)) + }) + .collect() +} + +/// Generates every due period of one template (SPEC §9.5), oldest first, each in its own `BEGIN IMMEDIATE` +/// transaction: the expense (server UUIDv7, `expense_date` the clamped date, fields copied from the template, +/// created and updated by the template's creator, `updated_by_device_id` null), its splits (mode `default` resolves +/// the account's current default ratio; other modes use the template's splits, `ratio`/`full` recomputed by the ratio +/// rule), an audit `create` with `source` `recurring` through [`crate::audit::AuditWriter`], and the template's +/// `last_generated_period` (new `server_version`). After each commit it enqueues `recurring_expense_created`. +/// +/// A period whose expense already exists (the unique index on `(recurring_template_id, recurring_period)`, a +/// deleted generated expense included) is skipped silently and only `last_generated_period` advances. Deleted or +/// paused templates generate nothing. "Now" is `state.clock`. +pub async fn generate_due_periods( + state: &AppState, + template_id: Uuid, +) -> Result, AppError> { + let now = state.clock.now(); + let mut generated = Vec::new(); + loop { + let mut tx = db::begin_immediate(&state.db).await?; + let Some(template) = rows::template(&mut tx, template_id).await? else { + break; + }; + if template.deleted_at.is_some() || template.paused { + break; + } + let Some(period) = kvitt_domain::next_candidate_period( + &template.start_period, + template.end_period.as_deref(), + template.last_generated_period.as_deref(), + ) + .map_err(AppError::internal)? + else { + break; + }; + let day = u8::try_from(template.day_of_month).map_err(AppError::internal)?; + if occurrence_instant(period, day)? > now { + break; + } + let period_text = period.to_string(); + let exists: bool = sqlx::query_scalar( + "SELECT EXISTS (SELECT 1 FROM expenses WHERE recurring_template_id = ? AND recurring_period = ?)", + ) + .bind(template_id.to_string()) + .bind(&period_text) + .fetch_one(&mut *tx) + .await?; + let now_ts = format_ts(now); + let mut created = None; + if !exists { + let users = rows::complete_household(&mut tx).await?; + let splits = resolve_splits(&mut tx, &template, &users).await?; + let expense_id = Uuid::now_v7(); + let version = db::next_server_version(&mut tx).await?; + 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(expense_id.to_string()) + .bind(template.account_id.to_string()) + .bind(template.category_id.to_string()) + .bind(template.amount_ore) + .bind(&template.description) + .bind(&template.notes) + .bind(period.clamped_date(day).map_err(AppError::internal)?) + .bind(template.payer_user_id.to_string()) + .bind(rows::token_of(&template.split_mode)?) + .bind(template_id.to_string()) + .bind(&period_text) + .bind(template.created_by_user_id.to_string()) + .bind(template.created_by_user_id.to_string()) + .bind(&now_ts) + .bind(&now_ts) + .bind(&now_ts) + .bind(version) + .execute(&mut *tx) + .await?; + for (user_id, share_ore, share_bp) in &splits { + sqlx::query( + "INSERT INTO expense_splits (expense_id, user_id, share_ore, share_bp) VALUES (?, ?, ?, ?)", + ) + .bind(expense_id.to_string()) + .bind(user_id.to_string()) + .bind(share_ore) + .bind(share_bp) + .execute(&mut *tx) + .await?; + } + let expense = rows::expense(&mut tx, expense_id) + .await? + .ok_or_else(|| AppError::internal("generated expense vanished"))?; + let after = serde_json::to_value(&expense).map_err(AppError::internal)?; + state + .audit + .write( + &mut tx, + AuditEvent { + entity_type: EntityType::Expense, + entity_id: expense_id, + action: AuditAction::Create, + source: AuditSource::Recurring, + actor_user_id: template.created_by_user_id, + actor_device_id: None, + client_at: now_ts.clone(), + server_at: now_ts.clone(), + before: None, + after, + }, + ) + .await?; + created = Some(expense_id); + } + let version = db::next_server_version(&mut tx).await?; + sqlx::query( + "UPDATE recurring_templates SET last_generated_period = ?, updated_at = ?, server_version = ? WHERE id = ?", + ) + .bind(&period_text) + .bind(&now_ts) + .bind(version) + .bind(template_id.to_string()) + .execute(&mut *tx) + .await?; + tx.commit().await?; + if let Some(expense_id) = created { + state.push.enqueue(PushMessage { + push_type: PushType::RecurringExpenseCreated, + entity_id: Some(expense_id), + actor_user_id: template.created_by_user_id, + count: 1, + }); + generated.push(GeneratedExpense { + expense_id, + period: period_text, + }); + } + } + Ok(generated) +} + +/// [`generate_due_periods`] for every non-deleted, non-paused template, in id order. Returns the number of +/// expenses created. +pub async fn generate_all_due(state: &AppState) -> Result { + let ids: Vec = sqlx::query_scalar( + "SELECT id FROM recurring_templates WHERE deleted_at IS NULL AND paused = 0 ORDER BY id", + ) + .fetch_all(&state.db) + .await?; + let mut created = 0; + for id in ids { + created += generate_due_periods(state, rows::uuid(&id)?).await?.len(); + } + Ok(created) +} + +/// The latest period whose occurrence instant is before `now` (the resume rule of SPEC §9.5). +pub fn latest_past_period(day_of_month: u8, now: DateTime) -> Result { + let current = validate::current_period(now)?; + if occurrence_instant(current, day_of_month)? < now { + Ok(current) + } else { + validate::months_before(current, 1).ok_or_else(|| AppError::internal("no month before")) + } +} + +// --------------------------------------------------------------------------------------------------------------- +// Validation and storage of template fields +// --------------------------------------------------------------------------------------------------------------- + +/// A stored template split: `(user_id, share_bp, share_ore)`. +type StoredSplit = (Uuid, Option, Option); + +/// Every client-writable field of a template, after merging a PATCH. +struct Fields { + account_id: Uuid, + category_id: Uuid, + amount_ore: i64, + description: String, + notes: String, + payer_user_id: Uuid, + split_mode: SplitMode, + splits: Vec, + day_of_month: i64, + start_period: String, + end_period: Option, + paused: bool, +} + +/// The validated template splits as stored: `(user_id, share_bp, share_ore)` (SPEC §9.1, §14.1). +fn validate_splits(fields: &Fields, users: &[User]) -> Result, AppError> { + let fail = |message: &str| AppError::validation("splits", message); + match fields.split_mode { + SplitMode::Default => { + if fields.splits.is_empty() { + Ok(Vec::new()) + } else { + Err(fail("splits must be empty for mode default")) + } + } + SplitMode::Ratio | SplitMode::Full => { + let mut entries = Vec::new(); + for split in &fields.splits { + match (split.share_bp, split.share_ore) { + (Some(bp), None) => entries.push((split.user_id, bp)), + _ => return Err(fail("modes ratio and full take share_bp only")), + } + } + rows::bp_shares("splits", &entries, users)?; + if fields.split_mode == SplitMode::Full { + let mut values: Vec = entries.iter().map(|(_, bp)| *bp).collect(); + values.sort_unstable(); + if values != [0, 10_000] { + return Err(fail( + "mode full needs one user at 10000 bp and the other at 0", + )); + } + } + Ok(entries + .into_iter() + .map(|(u, bp)| (u, Some(bp), None)) + .collect()) + } + SplitMode::Exact => { + let mut ore = BTreeMap::new(); + for split in &fields.splits { + match (split.share_bp, split.share_ore) { + (None, Some(value)) => { + ore.insert(split.user_id.to_string(), value); + } + _ => return Err(fail("mode exact takes share_ore only")), + } + } + if ore.len() != fields.splits.len() { + return Err(fail(kvitt_domain::SplitError::ShareUsersMismatch.token())); + } + kvitt_domain::compute_split( + fields.amount_ore, + &fields.payer_user_id.to_string(), + &rows::user_ids(users), + &SplitSpec::Exact { ore }, + ) + .map_err(|e| fail(e.token()))?; + Ok(fields + .splits + .iter() + .map(|s| (s.user_id, None, s.share_ore)) + .collect()) + } + } +} + +/// Validates `fields` (SPEC §9.6, §9.1, E-28). `start_changed` applies the 12-month backfill limit, which binds only +/// a `start_period` the client sends. +async fn validate_fields( + conn: &mut SqliteConnection, + fields: &mut Fields, + start_changed: bool, + now: DateTime, +) -> Result, AppError> { + let users = rows::household(conn).await?; + let account = rows::account(conn, fields.account_id).await?; + if account.is_none_or(|a| a.deleted_at.is_some()) { + return Err(AppError::validation( + "account_id", + "account_id refers to no account", + )); + } + let category = rows::category(conn, fields.category_id).await?; + if category.is_none_or(|c| c.deleted_at.is_some()) { + return Err(AppError::validation( + "category_id", + "category_id refers to no category", + )); + } + validate::amount("amount_ore", fields.amount_ore)?; + fields.description = validate::text( + "description", + &fields.description, + 1, + validate::DESCRIPTION_MAX, + )?; + fields.notes = validate::notes("notes", &fields.notes)?; + if !users.iter().any(|u| u.id == fields.payer_user_id) { + return Err(AppError::validation( + "payer_user_id", + kvitt_domain::SplitError::PayerUnknown.token(), + )); + } + let splits = validate_splits(fields, &users)?; + validate::day_of_month(fields.day_of_month)?; + let start = if start_changed { + validate::start_period(&fields.start_period, now)? + } else { + validate::period("start_period", &fields.start_period)? + }; + if let Some(end) = &fields.end_period + && validate::period("end_period", end)? < start + { + return Err(AppError::validation( + "end_period", + "end_period must not be before start_period", + )); + } + Ok(splits) +} + +async fn replace_splits( + conn: &mut SqliteConnection, + template_id: Uuid, + splits: &[StoredSplit], +) -> Result<(), AppError> { + sqlx::query("DELETE FROM recurring_template_splits WHERE template_id = ?") + .bind(template_id.to_string()) + .execute(&mut *conn) + .await?; + for (user_id, share_bp, share_ore) in splits { + sqlx::query( + "INSERT INTO recurring_template_splits (template_id, user_id, share_bp, share_ore) VALUES (?, ?, ?, ?)", + ) + .bind(template_id.to_string()) + .bind(user_id.to_string()) + .bind(share_bp) + .bind(share_ore) + .execute(&mut *conn) + .await?; + } + Ok(()) +} + +/// Generates what is due, then reads the template back in wire form. +async fn generate_and_reload( + state: &AppState, + template_id: Uuid, +) -> Result { + generate_due_periods(state, template_id).await?; + let mut conn = state.db.acquire().await?; + rows::template(&mut conn, template_id) + .await? + .ok_or_else(|| AppError::internal("template vanished")) +} + #[utoipa::path( post, path = "/recurring-templates", @@ -25,8 +491,61 @@ use crate::wire::{ (status = 422, response = Unprocessable), ) )] -pub async fn create_recurring_template(_device: AuthDevice) -> AppError { - not_implemented() +pub async fn create_recurring_template( + device: AuthDevice, + State(state): State, + ApiJson(body): ApiJson, +) -> Result<(StatusCode, JsonBody), AppError> { + let now = state.clock.now(); + let mut tx = db::begin_immediate(&state.db).await?; + rows::complete_household(&mut tx).await?; + let mut fields = Fields { + account_id: body.account_id, + category_id: body.category_id, + amount_ore: body.amount_ore, + description: body.description, + notes: body.notes, + payer_user_id: body.payer_user_id, + split_mode: body.split_mode, + splits: body.splits, + day_of_month: body.day_of_month, + start_period: body.start_period, + end_period: body.end_period, + paused: body.paused, + }; + let splits = validate_fields(&mut tx, &mut fields, true, now).await?; + let id = Uuid::now_v7(); + let now_ts = format_ts(now); + let version = db::next_server_version(&mut tx).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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?, ?, ?, NULL, ?)", + ) + .bind(id.to_string()) + .bind(fields.account_id.to_string()) + .bind(fields.category_id.to_string()) + .bind(fields.amount_ore) + .bind(&fields.description) + .bind(&fields.notes) + .bind(fields.payer_user_id.to_string()) + .bind(rows::token_of(&fields.split_mode)?) + .bind(fields.day_of_month) + .bind(&fields.start_period) + .bind(&fields.end_period) + .bind(i64::from(fields.paused)) + .bind(device.user_id.to_string()) + .bind(device.user_id.to_string()) + .bind(&now_ts) + .bind(&now_ts) + .bind(version) + .execute(&mut *tx) + .await?; + replace_splits(&mut tx, id, &splits).await?; + tx.commit().await?; + let template = generate_and_reload(&state, id).await?; + Ok((StatusCode::CREATED, JsonBody(template))) } #[utoipa::path( @@ -47,10 +566,82 @@ pub async fn create_recurring_template(_device: AuthDevice) -> AppError { ) )] pub async fn patch_recurring_template( - _device: AuthDevice, - IdPath(_template_id): IdPath, -) -> AppError { - not_implemented() + device: AuthDevice, + State(state): State, + IdPath(template_id): IdPath, + ApiJson(body): ApiJson, +) -> Result, AppError> { + if body == PatchRecurringTemplateRequest::default() { + return Err(AppError::invalid_request("PATCH needs at least one field")); + } + if body.split_mode.is_some() != body.splits.is_some() { + return Err(AppError::invalid_request( + "split_mode and splits must be sent together", + )); + } + let now = state.clock.now(); + let mut tx = db::begin_immediate(&state.db).await?; + let current = rows::live_template(&mut tx, template_id).await?; + let start_changed = body.start_period.is_some(); + let mut fields = Fields { + account_id: body.account_id.unwrap_or(current.account_id), + category_id: body.category_id.unwrap_or(current.category_id), + amount_ore: body.amount_ore.unwrap_or(current.amount_ore), + description: body.description.unwrap_or(current.description), + notes: body.notes.unwrap_or(current.notes), + payer_user_id: body.payer_user_id.unwrap_or(current.payer_user_id), + split_mode: body.split_mode.unwrap_or(current.split_mode), + splits: body.splits.unwrap_or(current.splits), + day_of_month: body.day_of_month.unwrap_or(current.day_of_month), + start_period: body.start_period.unwrap_or(current.start_period), + end_period: body.end_period.unwrap_or(current.end_period), + paused: body.paused.unwrap_or(current.paused), + }; + let splits = validate_fields(&mut tx, &mut fields, start_changed, now).await?; + + // Resume does not backfill (SPEC §9.5): last_generated_period jumps to the latest period whose occurrence is + // already past, when that is later than the current value (and not before start_period). + let mut last_generated = current.last_generated_period.clone(); + if current.paused && !fields.paused { + let day = validate::day_of_month(fields.day_of_month)?; + let latest = latest_past_period(day, now)?; + let start = validate::period("start_period", &fields.start_period)?; + let current_last = last_generated + .as_deref() + .map(|p| validate::period("last_generated_period", p)) + .transpose()?; + if latest >= start && current_last.is_none_or(|last| latest > last) { + last_generated = Some(latest.to_string()); + } + } + + let version = db::next_server_version(&mut tx).await?; + sqlx::query( + "UPDATE recurring_templates SET account_id = ?, category_id = ?, amount_ore = ?, description = ?, notes = ?, \ + payer_user_id = ?, split_mode = ?, day_of_month = ?, start_period = ?, end_period = ?, paused = ?, \ + last_generated_period = ?, updated_by_user_id = ?, updated_at = ?, server_version = ? WHERE id = ?", + ) + .bind(fields.account_id.to_string()) + .bind(fields.category_id.to_string()) + .bind(fields.amount_ore) + .bind(&fields.description) + .bind(&fields.notes) + .bind(fields.payer_user_id.to_string()) + .bind(rows::token_of(&fields.split_mode)?) + .bind(fields.day_of_month) + .bind(&fields.start_period) + .bind(&fields.end_period) + .bind(i64::from(fields.paused)) + .bind(&last_generated) + .bind(device.user_id.to_string()) + .bind(format_ts(now)) + .bind(version) + .bind(template_id.to_string()) + .execute(&mut *tx) + .await?; + replace_splits(&mut tx, template_id, &splits).await?; + tx.commit().await?; + Ok(JsonBody(generate_and_reload(&state, template_id).await?)) } #[utoipa::path( @@ -67,8 +658,28 @@ pub async fn patch_recurring_template( ) )] pub async fn delete_recurring_template( - _device: AuthDevice, - IdPath(_template_id): IdPath, -) -> AppError { - not_implemented() + device: AuthDevice, + State(state): State, + IdPath(template_id): IdPath, +) -> Result, AppError> { + let mut tx = db::begin_immediate(&state.db).await?; + rows::live_template(&mut tx, template_id).await?; + let now = state.clock.now_ts(); + let version = db::next_server_version(&mut tx).await?; + sqlx::query( + "UPDATE recurring_templates SET deleted_at = ?, updated_at = ?, updated_by_user_id = ?, server_version = ? \ + WHERE id = ?", + ) + .bind(&now) + .bind(&now) + .bind(device.user_id.to_string()) + .bind(version) + .bind(template_id.to_string()) + .execute(&mut *tx) + .await?; + let template = rows::template(&mut tx, template_id) + .await? + .ok_or_else(|| AppError::internal("template vanished"))?; + tx.commit().await?; + Ok(JsonBody(template)) } diff --git a/server/crates/kvitt-server/src/api/rows.rs b/server/crates/kvitt-server/src/api/rows.rs new file mode 100644 index 0000000..9f20713 --- /dev/null +++ b/server/crates/kvitt-server/src/api/rows.rs @@ -0,0 +1,546 @@ +//! Reading stored rows into their wire form (SPEC §14.3.3), shared by the M3 handlers and the recurring generation. +//! +//! Every function takes a connection, so it works on the pool (via `acquire`) and inside a write transaction. + +use std::collections::BTreeMap; + +use kvitt_domain::{RecurrenceSchedule, SplitSpec}; +use serde::de::DeserializeOwned; +use sqlx::{FromRow, SqliteConnection}; +use uuid::Uuid; + +use crate::error::{AppError, ErrorCode}; +use crate::wire::{ + Account, Category, Device, Expense, RecurringTemplate, Role, Share, Split, SplitMode, + TemplateSplit, User, +}; + +/// Parses a stored UUID. +pub fn uuid(text: &str) -> Result { + Uuid::parse_str(text).map_err(|e| AppError::internal(format!("stored id is not a uuid: {e}"))) +} + +fn opt_uuid(text: Option) -> Result, AppError> { + text.as_deref().map(uuid).transpose() +} + +/// Parses a stored snake_case enum token through its serde form. +fn token(text: &str) -> Result { + serde_json::from_value(serde_json::Value::String(text.to_owned())) + .map_err(|e| AppError::internal(format!("stored token {text:?}: {e}"))) +} + +/// The snake_case token of a serde enum (`SplitMode`, …). +pub fn token_of(value: &T) -> Result { + match serde_json::to_value(value).map_err(AppError::internal)? { + serde_json::Value::String(s) => Ok(s), + other => Err(AppError::internal(format!("not a token: {other}"))), + } +} + +// --------------------------------------------------------------------------------------------------------------- +// Users +// --------------------------------------------------------------------------------------------------------------- + +#[derive(FromRow)] +struct UserRow { + id: String, + role: String, + display_name: String, + picture_version: Option, + created_at: String, + updated_at: String, + server_version: i64, +} + +impl UserRow { + fn wire(self) -> Result { + Ok(User { + id: uuid(&self.id)?, + role: Role::from_db(&self.role) + .ok_or_else(|| AppError::internal(format!("unknown role {:?}", self.role)))?, + display_name: self.display_name, + picture_version: self.picture_version, + created_at: self.created_at, + updated_at: self.updated_at, + server_version: self.server_version, + }) + } +} + +macro_rules! user_columns { + () => { + "id, role, display_name, picture_version, created_at, updated_at, server_version" + }; +} + +/// One user, if it exists. +pub async fn user(conn: &mut SqliteConnection, id: Uuid) -> Result, AppError> { + sqlx::query_as::<_, UserRow>(concat!( + "SELECT ", + user_columns!(), + " FROM users WHERE id = ?" + )) + .bind(id.to_string()) + .fetch_optional(&mut *conn) + .await? + .map(UserRow::wire) + .transpose() +} + +/// The household's users, oldest first. +pub async fn household(conn: &mut SqliteConnection) -> Result, AppError> { + sqlx::query_as::<_, UserRow>(concat!( + "SELECT ", + user_columns!(), + " FROM users ORDER BY created_at, id" + )) + .fetch_all(&mut *conn) + .await? + .into_iter() + .map(UserRow::wire) + .collect() +} + +/// The two household users; `household_incomplete` while fewer than two exist (AC-5.2). +pub async fn complete_household(conn: &mut SqliteConnection) -> Result, AppError> { + let users = household(conn).await?; + if users.len() < 2 { + return Err(AppError::new( + ErrorCode::HouseholdIncomplete, + "the partner has not joined yet", + )); + } + Ok(users) +} + +/// The household's user ids as strings (domain inputs). +pub fn user_ids(users: &[User]) -> Vec { + users.iter().map(|u| u.id.to_string()).collect() +} + +// --------------------------------------------------------------------------------------------------------------- +// Devices +// --------------------------------------------------------------------------------------------------------------- + +#[derive(FromRow)] +struct DeviceRow { + id: String, + user_id: String, + name: String, + app_version_code: Option, + created_at: String, + last_seen_at: String, + revoked_at: Option, +} + +impl DeviceRow { + fn wire(self, current: Uuid) -> Result { + let id = uuid(&self.id)?; + Ok(Device { + id, + user_id: uuid(&self.user_id)?, + name: self.name, + app_version_code: self.app_version_code, + created_at: self.created_at, + last_seen_at: self.last_seen_at, + revoked_at: self.revoked_at, + is_current: id == current, + }) + } +} + +macro_rules! device_columns { + () => { + "id, user_id, name, app_version_code, created_at, last_seen_at, revoked_at" + }; +} + +/// One device; `is_current` compares with `current`. +pub async fn device( + conn: &mut SqliteConnection, + id: Uuid, + current: Uuid, +) -> Result, AppError> { + sqlx::query_as::<_, DeviceRow>(concat!( + "SELECT ", + device_columns!(), + " FROM devices WHERE id = ?" + )) + .bind(id.to_string()) + .fetch_optional(&mut *conn) + .await? + .map(|r| r.wire(current)) + .transpose() +} + +/// Devices ordered by `created_at` (then id), all of them or one user's. +pub async fn devices( + conn: &mut SqliteConnection, + user_id: Option, + current: Uuid, +) -> Result, AppError> { + let rows = match user_id { + Some(user_id) => { + sqlx::query_as::<_, DeviceRow>(concat!( + "SELECT ", + device_columns!(), + " FROM devices WHERE user_id = ? ORDER BY created_at, id" + )) + .bind(user_id.to_string()) + .fetch_all(&mut *conn) + .await? + } + None => { + sqlx::query_as::<_, DeviceRow>(concat!( + "SELECT ", + device_columns!(), + " FROM devices ORDER BY created_at, id" + )) + .fetch_all(&mut *conn) + .await? + } + }; + rows.into_iter().map(|r| r.wire(current)).collect() +} + +// --------------------------------------------------------------------------------------------------------------- +// Accounts +// --------------------------------------------------------------------------------------------------------------- + +#[derive(FromRow)] +struct AccountRow { + id: String, + name: String, + created_by_user_id: String, + created_at: String, + updated_at: String, + archived_at: Option, + deleted_at: Option, + server_version: i64, +} + +/// One account (tombstones included), with its default shares. +pub async fn account(conn: &mut SqliteConnection, id: Uuid) -> Result, AppError> { + let Some(row) = sqlx::query_as::<_, AccountRow>( + "SELECT id, name, created_by_user_id, created_at, updated_at, archived_at, deleted_at, server_version \ + FROM accounts WHERE id = ?", + ) + .bind(id.to_string()) + .fetch_optional(&mut *conn) + .await? + else { + return Ok(None); + }; + let shares: Vec<(String, i64)> = sqlx::query_as( + "SELECT user_id, share_bp FROM account_default_shares WHERE account_id = ? ORDER BY user_id", + ) + .bind(id.to_string()) + .fetch_all(&mut *conn) + .await?; + let mut account = Account { + id: uuid(&row.id)?, + name: row.name, + default_shares: shares + .into_iter() + .map(|(user_id, share_bp)| { + Ok(Share { + user_id: uuid(&user_id)?, + share_bp, + }) + }) + .collect::>()?, + created_by_user_id: uuid(&row.created_by_user_id)?, + created_at: row.created_at, + updated_at: row.updated_at, + archived_at: row.archived_at, + deleted_at: row.deleted_at, + server_version: row.server_version, + }; + account.sort(); + Ok(Some(account)) +} + +/// A non-deleted account, or `not_found` (SPEC §14.3.2: unknown or deleted id in the path). +pub async fn live_account(conn: &mut SqliteConnection, id: Uuid) -> Result { + account(conn, id) + .await? + .filter(|a| a.deleted_at.is_none()) + .ok_or_else(AppError::not_found) +} + +// --------------------------------------------------------------------------------------------------------------- +// Categories +// --------------------------------------------------------------------------------------------------------------- + +#[derive(FromRow)] +struct CategoryRow { + id: String, + default_key: Option, + name: Option, + icon: String, + color: String, + sort_order: i64, + created_at: String, + updated_at: String, + archived_at: Option, + deleted_at: Option, + server_version: i64, +} + +/// One category (tombstones included). +pub async fn category(conn: &mut SqliteConnection, id: Uuid) -> Result, AppError> { + let Some(row) = sqlx::query_as::<_, CategoryRow>( + "SELECT id, default_key, name, icon, color, sort_order, created_at, updated_at, archived_at, deleted_at, \ + server_version FROM categories WHERE id = ?", + ) + .bind(id.to_string()) + .fetch_optional(&mut *conn) + .await? + else { + return Ok(None); + }; + Ok(Some(Category { + id: uuid(&row.id)?, + default_key: row.default_key.as_deref().map(token).transpose()?, + name: row.name, + icon: row.icon, + color: row.color, + sort_order: row.sort_order, + created_at: row.created_at, + updated_at: row.updated_at, + archived_at: row.archived_at, + deleted_at: row.deleted_at, + server_version: row.server_version, + })) +} + +/// A non-deleted category, or `not_found`. +pub async fn live_category(conn: &mut SqliteConnection, id: Uuid) -> Result { + category(conn, id) + .await? + .filter(|c| c.deleted_at.is_none()) + .ok_or_else(AppError::not_found) +} + +// --------------------------------------------------------------------------------------------------------------- +// Recurring templates +// --------------------------------------------------------------------------------------------------------------- + +#[derive(FromRow)] +struct TemplateRow { + id: String, + account_id: String, + category_id: String, + amount_ore: i64, + description: String, + notes: String, + payer_user_id: String, + split_mode: String, + day_of_month: i64, + start_period: String, + end_period: Option, + paused: i64, + last_generated_period: Option, + created_by_user_id: String, + updated_by_user_id: String, + created_at: String, + updated_at: String, + deleted_at: Option, + server_version: i64, +} + +/// The `next_occurrence_date` of SPEC §9.5, via `kvitt_domain::next_occurrence_date`. +pub fn next_occurrence(template: &RecurringTemplate) -> Result, AppError> { + let day_of_month = u8::try_from(template.day_of_month).map_err(AppError::internal)?; + kvitt_domain::next_occurrence_date(&RecurrenceSchedule { + day_of_month, + start_period: template.start_period.clone(), + end_period: template.end_period.clone(), + last_generated_period: template.last_generated_period.clone(), + paused: template.paused, + deleted: template.deleted_at.is_some(), + }) + .map_err(AppError::internal) +} + +/// One template (tombstones included), with its splits and `next_occurrence_date`. +pub async fn template( + conn: &mut SqliteConnection, + id: Uuid, +) -> Result, AppError> { + let Some(row) = sqlx::query_as::<_, TemplateRow>( + "SELECT 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 \ + FROM recurring_templates WHERE id = ?", + ) + .bind(id.to_string()) + .fetch_optional(&mut *conn) + .await? + else { + return Ok(None); + }; + let splits: Vec<(String, Option, Option)> = sqlx::query_as( + "SELECT user_id, share_bp, share_ore FROM recurring_template_splits WHERE template_id = ? \ + ORDER BY user_id", + ) + .bind(id.to_string()) + .fetch_all(&mut *conn) + .await?; + let mut template = RecurringTemplate { + id: uuid(&row.id)?, + account_id: uuid(&row.account_id)?, + category_id: uuid(&row.category_id)?, + amount_ore: row.amount_ore, + description: row.description, + notes: row.notes, + payer_user_id: uuid(&row.payer_user_id)?, + split_mode: token::(&row.split_mode)?, + splits: splits + .into_iter() + .map(|(user_id, share_bp, share_ore)| { + Ok(TemplateSplit { + user_id: uuid(&user_id)?, + share_bp, + share_ore, + }) + }) + .collect::>()?, + day_of_month: row.day_of_month, + start_period: row.start_period, + end_period: row.end_period, + paused: row.paused != 0, + last_generated_period: row.last_generated_period, + next_occurrence_date: None, + 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, + }; + template.sort(); + template.next_occurrence_date = next_occurrence(&template)?; + Ok(Some(template)) +} + +/// A non-deleted template, or `not_found`. +pub async fn live_template( + conn: &mut SqliteConnection, + id: Uuid, +) -> Result { + template(conn, id) + .await? + .filter(|t| t.deleted_at.is_none()) + .ok_or_else(AppError::not_found) +} + +// --------------------------------------------------------------------------------------------------------------- +// Expenses +// --------------------------------------------------------------------------------------------------------------- + +#[derive(FromRow)] +struct ExpenseRow { + id: String, + account_id: String, + category_id: String, + amount_ore: i64, + description: String, + notes: String, + expense_date: String, + payer_user_id: String, + split_mode: String, + recurring_template_id: Option, + recurring_period: Option, + created_by_user_id: String, + updated_by_user_id: String, + created_at: String, + updated_at: String, + deleted_at: Option, + server_version: i64, +} + +/// One expense (tombstones included), with its splits. +pub async fn expense(conn: &mut SqliteConnection, id: Uuid) -> Result, AppError> { + let Some(row) = sqlx::query_as::<_, ExpenseRow>( + "SELECT id, account_id, category_id, amount_ore, description, notes, expense_date, payer_user_id, \ + split_mode, recurring_template_id, recurring_period, created_by_user_id, updated_by_user_id, \ + created_at, updated_at, deleted_at, server_version FROM expenses WHERE id = ?", + ) + .bind(id.to_string()) + .fetch_optional(&mut *conn) + .await? + else { + return Ok(None); + }; + let splits: Vec<(String, i64, Option)> = sqlx::query_as( + "SELECT user_id, share_ore, share_bp FROM expense_splits WHERE expense_id = ? ORDER BY user_id", + ) + .bind(id.to_string()) + .fetch_all(&mut *conn) + .await?; + let mut expense = Expense { + id: uuid(&row.id)?, + account_id: uuid(&row.account_id)?, + category_id: uuid(&row.category_id)?, + amount_ore: row.amount_ore, + description: row.description, + notes: row.notes, + expense_date: row.expense_date, + payer_user_id: uuid(&row.payer_user_id)?, + split_mode: token::(&row.split_mode)?, + splits: splits + .into_iter() + .map(|(user_id, share_ore, share_bp)| { + Ok(Split { + user_id: uuid(&user_id)?, + share_ore, + share_bp, + }) + }) + .collect::>()?, + recurring_template_id: opt_uuid(row.recurring_template_id)?, + recurring_period: row.recurring_period, + 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, + }; + expense.sort(); + Ok(Some(expense)) +} + +// --------------------------------------------------------------------------------------------------------------- +// Splits +// --------------------------------------------------------------------------------------------------------------- + +/// Validates a pair of basis-point shares against the household (SPEC §9.1 validity): exactly one entry per +/// household user, each 0 to 10000, summing to 10000. Returns the shares keyed by user id. The error is +/// `validation_failed` on `field` with the domain token of SPEC §14.5.3 as the message. +pub fn bp_shares( + field: &str, + entries: &[(Uuid, i64)], + users: &[User], +) -> Result, AppError> { + let fail = |token: &str| AppError::validation(field, token); + if entries.iter().any(|(_, bp)| !(0..=10_000).contains(bp)) { + return Err(fail(kvitt_domain::SplitError::BpOutOfRange.token())); + } + let mut bp = BTreeMap::new(); + for (user_id, share_bp) in entries { + let value = u32::try_from(*share_bp).map_err(AppError::internal)?; + bp.insert(user_id.to_string(), value); + } + if entries.len() != bp.len() { + return Err(fail(kvitt_domain::SplitError::ShareUsersMismatch.token())); + } + let ids = user_ids(users); + let payer = ids.first().cloned().unwrap_or_default(); + // The amount and payer do not matter for validity; the domain checks range, sum and user set in enum order. + kvitt_domain::compute_split(1, &payer, &ids, &SplitSpec::Ratio { bp: bp.clone() }) + .map_err(|e| fail(e.token()))?; + Ok(bp) +} diff --git a/server/crates/kvitt-server/src/api/validate.rs b/server/crates/kvitt-server/src/api/validate.rs new file mode 100644 index 0000000..8194777 --- /dev/null +++ b/server/crates/kvitt-server/src/api/validate.rs @@ -0,0 +1,202 @@ +//! Input validation shared by the M3 handlers (SPEC §8.6, §9.6, §9.7). +//! +//! Strings are trimmed of leading and trailing whitespace and their length is counted in Unicode code points after +//! trimming; the trimmed value is what the server stores. + +use chrono::{DateTime, Datelike, Utc}; +use kvitt_domain::Period; + +use crate::error::AppError; +use crate::time::STOCKHOLM; + +/// Category icon tokens (SPEC §9.7, exhaustive for v1). +pub const ICONS: [&str; 30] = [ + "shopping_cart", + "restaurant", + "local_cafe", + "home", + "bolt", + "water_drop", + "wifi", + "directions_car", + "train", + "local_gas_station", + "flight", + "hotel", + "luggage", + "movie", + "music_note", + "sports_esports", + "fitness_center", + "medical_services", + "medication", + "shopping_bag", + "checkroom", + "pets", + "child_care", + "school", + "card_giftcard", + "celebration", + "build", + "chair", + "subscriptions", + "category", +]; + +/// Colour tokens (SPEC §9.7, exhaustive for v1). +pub const COLORS: [&str; 12] = [ + "green", "orange", "brown", "yellow", "blue", "teal", "purple", "red", "pink", "grey", + "indigo", "lime", +]; + +/// Account name: 1 to 60 code points (SPEC §9.6). +pub const ACCOUNT_NAME_MAX: usize = 60; +/// Category name: 1 to 40 code points. +pub const CATEGORY_NAME_MAX: usize = 40; +/// User display name: 1 to 40 code points. +pub const DISPLAY_NAME_MAX: usize = 40; +/// Device name: 1 to 60 code points. +pub const DEVICE_NAME_MAX: usize = 60; +/// Expense and template description: 1 to 100 code points. +pub const DESCRIPTION_MAX: usize = 100; +/// Expense and template notes: 0 to 1000 code points. +pub const NOTES_MAX: usize = 1000; +/// How far back a template's `start_period` may lie, in months (SPEC §9.6, AC-9.8). +pub const START_PERIOD_BACKFILL_MONTHS: u32 = 12; + +/// Trims `value` and checks its length in code points is within `min..=max`. Returns the trimmed value. +pub fn text(field: &str, value: &str, min: usize, max: usize) -> Result { + let trimmed = value.trim(); + let len = trimmed.chars().count(); + if len < min || len > max { + return Err(AppError::validation( + field, + format!("{field} must be {min} to {max} characters after trimming"), + )); + } + Ok(trimmed.to_owned()) +} + +/// Notes: trimmed, 0 to `NOTES_MAX` code points, no control characters other than newline (SPEC §8.6). +pub fn notes(field: &str, value: &str) -> Result { + let trimmed = text(field, value, 0, NOTES_MAX)?; + if trimmed.chars().any(|c| c.is_control() && c != '\n') { + return Err(AppError::validation( + field, + format!("{field} may not contain control characters other than newline"), + )); + } + Ok(trimmed) +} + +/// An icon token of SPEC §9.7. +pub fn icon(value: &str) -> Result { + if ICONS.contains(&value) { + Ok(value.to_owned()) + } else { + Err(AppError::validation( + "icon", + "icon is not a token of SPEC §9.7", + )) + } +} + +/// A colour token of SPEC §9.7. +pub fn color(value: &str) -> Result { + if COLORS.contains(&value) { + Ok(value.to_owned()) + } else { + Err(AppError::validation( + "color", + "color is not a token of SPEC §9.7", + )) + } +} + +/// `amount_ore` within 1 to 1 000 000 000 (SPEC §9.6). +pub fn amount(field: &str, amount_ore: i64) -> Result { + if kvitt_domain::amount_in_range(amount_ore) { + Ok(amount_ore) + } else { + Err(AppError::validation(field, "amount_out_of_range")) + } +} + +/// A period `YYYY-MM`. +pub fn period(field: &str, value: &str) -> Result { + value + .parse::() + .map_err(|_| AppError::validation(field, format!("{field} must be YYYY-MM"))) +} + +/// The current month in Europe/Stockholm. +pub fn current_period(now: DateTime) -> Result { + let local = now.with_timezone(&STOCKHOLM); + let year = u16::try_from(local.year()).map_err(AppError::internal)?; + let month = u8::try_from(local.month()).map_err(AppError::internal)?; + Period::new(year, month).ok_or_else(|| AppError::internal("current month out of range")) +} + +/// `period` minus `months` months, or `None` before year 1. +pub fn months_before(period: Period, months: u32) -> Option { + let index = i64::from(period.year()) * 12 + i64::from(period.month()) - 1 - i64::from(months); + let year = u16::try_from(index.div_euclid(12)).ok()?; + let month = u8::try_from(index.rem_euclid(12) + 1).ok()?; + Period::new(year, month) +} + +/// A template `start_period`: `YYYY-MM`, not earlier than 12 months before the current month (SPEC §9.6). +pub fn start_period(value: &str, now: DateTime) -> Result { + let p = period("start_period", value)?; + let earliest = months_before(current_period(now)?, START_PERIOD_BACKFILL_MONTHS); + if earliest.is_some_and(|earliest| p < earliest) { + return Err(AppError::validation( + "start_period", + "start_period may be at most 12 months before the current month", + )); + } + Ok(p) +} + +/// `day_of_month` within 1 to 31. +pub fn day_of_month(value: i64) -> Result { + u8::try_from(value) + .ok() + .filter(|d| (1..=31).contains(d)) + .ok_or_else(|| AppError::validation("day_of_month", "day_of_month must be 1 to 31")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn text_counts_code_points_after_trimming() { + assert_eq!(text("name", " Åsa ", 1, 3).unwrap(), "Åsa"); + assert!(text("name", " ", 1, 3).is_err()); + assert!(text("name", "Åsaa", 1, 3).is_err()); + } + + #[test] + fn notes_allow_newline_only() { + assert_eq!(notes("notes", "a\nb").unwrap(), "a\nb"); + assert!(notes("notes", "a\tb").is_err()); + assert!(notes("notes", "a\u{7}b").is_err()); + } + + #[test] + fn months_before_crosses_years() { + let p: Period = "2026-10".parse().unwrap(); + assert_eq!(months_before(p, 12).unwrap().to_string(), "2025-10"); + assert_eq!(months_before(p, 10).unwrap().to_string(), "2025-12"); + assert_eq!(months_before(p, 9).unwrap().to_string(), "2026-01"); + } + + #[test] + fn tokens() { + assert!(icon("pets").is_ok()); + assert!(icon("dog").is_err()); + assert!(color("lime").is_ok()); + assert!(color("black").is_err()); + } +} diff --git a/server/crates/kvitt-server/src/cli/bootstrap.rs b/server/crates/kvitt-server/src/cli/bootstrap.rs index 595f3b8..08e2c56 100644 --- a/server/crates/kvitt-server/src/cli/bootstrap.rs +++ b/server/crates/kvitt-server/src/cli/bootstrap.rs @@ -1,11 +1,74 @@ -//! `kvitt-server bootstrap` (FR-1, AC-1.1 to AC-1.3). M3 owns this file; until then it prints -//! `not implemented in M2a` and exits 2. +//! `kvitt-server bootstrap` (FR-1, AC-1.1 to AC-1.3). +//! +//! No users: cancels any pending `admin_setup` invite, creates a new one and prints its code, landing link and +//! expiry (exit 0). Users exist: `already initialized` on stderr, exit 2, nothing created. The code is printed to +//! stdout only, never logged (SPEC §8.7). use std::process::ExitCode; +use crate::api::invites::{self, NewInvite}; use crate::config::Config; +use crate::db::{self, Db}; +use crate::error::AppError; +use crate::wire::InviteKind; +use chrono::{DateTime, Utc}; -pub async fn run(config: &Config) -> ExitCode { - let _ = config; - super::not_implemented("bootstrap") +/// Exit code when the household already exists (SPEC §10.4). +pub const EXIT_ALREADY_INITIALIZED: u8 = 2; + +/// What `bootstrap` did. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Outcome { + Created(NewInvite), + AlreadyInitialized, +} + +/// Creates the `admin_setup` invite unless users exist. +pub async fn bootstrap(db: &Db, now: DateTime) -> Result { + let mut tx = db::begin_immediate(db).await?; + let users: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM users") + .fetch_one(&mut *tx) + .await?; + if users > 0 { + return Ok(Outcome::AlreadyInitialized); + } + invites::cancel_pending(&mut tx, InviteKind::AdminSetup, now).await?; + let invite = invites::insert_invite(&mut tx, InviteKind::AdminSetup, None, None, now).await?; + tx.commit().await?; + Ok(Outcome::Created(invite)) +} + +/// The text printed for a new invite (AC-1.1, AC-1.6): code in groups of four, landing link, expiry. +pub fn render(invite: &NewInvite, public_url: &str) -> String { + format!( + "Code: {}\nLink: {}\nExpires: {}\n", + invites::display_code(&invite.code), + invites::invite_link(public_url, &invite.code), + invite.expires_at + ) +} + +/// Opens the database and runs [`bootstrap`] with the system clock. +pub async fn run(config: &Config) -> ExitCode { + let result = async { + let pool = super::open_database(config).await?; + let outcome = bootstrap(&pool, Utc::now()).await; + pool.close().await; + outcome + } + .await; + match result { + Ok(Outcome::Created(invite)) => { + print!("{}", render(&invite, &config.public_url)); + ExitCode::SUCCESS + } + Ok(Outcome::AlreadyInitialized) => { + eprintln!("already initialized"); + ExitCode::from(EXIT_ALREADY_INITIALIZED) + } + Err(error) => { + eprintln!("bootstrap: {error}"); + ExitCode::FAILURE + } + } } diff --git a/server/crates/kvitt-server/src/cli/pair_device.rs b/server/crates/kvitt-server/src/cli/pair_device.rs index aa25c1f..23e1d0e 100644 --- a/server/crates/kvitt-server/src/cli/pair_device.rs +++ b/server/crates/kvitt-server/src/cli/pair_device.rs @@ -1,11 +1,64 @@ -//! `kvitt-server pair-device --user ` (AC-1.6). M3 owns this file; until then it prints -//! `not implemented in M2a` and exits 2. +//! `kvitt-server pair-device --user ` (AC-1.6): a `device` invite for an existing user, printed like +//! `bootstrap`. The recovery path when every phone of a user is lost (SPEC §9.10). Exit 3 for an unknown user. use std::process::ExitCode; -use crate::config::Config; +use chrono::{DateTime, Utc}; +use uuid::Uuid; -pub async fn run(config: &Config, user_id: &str) -> ExitCode { - let _ = (config, user_id); - super::not_implemented("pair-device") +use crate::api::invites::{self, NewInvite}; +use crate::config::Config; +use crate::db::{self, Db}; +use crate::error::AppError; +use crate::wire::InviteKind; + +/// Exit code for an unknown user (SPEC §10.4). +pub const EXIT_UNKNOWN_USER: u8 = 3; + +/// Creates a device invite for `user_id`; `None` when no such user exists (or `user_id` is not a UUID). +pub async fn pair_device( + db: &Db, + user_id: &str, + now: DateTime, +) -> Result, AppError> { + let Ok(user_id) = Uuid::parse_str(user_id.trim()) else { + return Ok(None); + }; + let mut tx = db::begin_immediate(db).await?; + let exists: bool = sqlx::query_scalar("SELECT EXISTS (SELECT 1 FROM users WHERE id = ?)") + .bind(user_id.to_string()) + .fetch_one(&mut *tx) + .await?; + if !exists { + return Ok(None); + } + let invite = + invites::insert_invite(&mut tx, InviteKind::Device, Some(user_id), None, now).await?; + tx.commit().await?; + Ok(Some(invite)) +} + +/// Opens the database and runs [`pair_device`] with the system clock. +pub async fn run(config: &Config, user_id: &str) -> ExitCode { + let result = async { + let pool = super::open_database(config).await?; + let outcome = pair_device(&pool, user_id, Utc::now()).await; + pool.close().await; + outcome + } + .await; + match result { + Ok(Some(invite)) => { + print!("{}", super::bootstrap::render(&invite, &config.public_url)); + ExitCode::SUCCESS + } + Ok(None) => { + eprintln!("unknown user"); + ExitCode::from(EXIT_UNKNOWN_USER) + } + Err(error) => { + eprintln!("pair-device: {error}"); + ExitCode::FAILURE + } + } } diff --git a/server/crates/kvitt-server/src/router.rs b/server/crates/kvitt-server/src/router.rs index 25b0b2e..fedecea 100644 --- a/server/crates/kvitt-server/src/router.rs +++ b/server/crates/kvitt-server/src/router.rs @@ -104,7 +104,7 @@ pub fn router(state: AppState) -> Router { .merge(authenticated) .fallback(fallback) .layer(DefaultBodyLimit::max(BODY_LIMIT_BYTES)) - .layer(rate_limit::layer()) // M3: the rate limiter of SPEC §8.6 attaches here (src/api/rate_limit.rs). + .layer(rate_limit::layer(&state)) // M3: the rate limiter of SPEC §8.6 attaches here (src/api/rate_limit.rs). .layer(middleware::from_fn(log_request)) .layer(middleware::map_response(add_server_version)) .with_state(state) diff --git a/server/crates/kvitt-server/tests/core_api/accounts.rs b/server/crates/kvitt-server/tests/core_api/accounts.rs new file mode 100644 index 0000000..97290c6 --- /dev/null +++ b/server/crates/kvitt-server/tests/core_api/accounts.rs @@ -0,0 +1,396 @@ +//! E-18 to E-22: accounts (FR-6, AC-5.2). + +use axum::http::Method; +use serde_json::{Value, json}; + +use crate::common::*; + +fn shares(admin_bp: i64, member_bp: i64) -> Value { + json!([ + {"user_id": ADMIN_ID, "share_bp": admin_bp}, + {"user_id": MEMBER_ID, "share_bp": member_bp}, + ]) +} + +async fn create(app: &TestApp, name: &str) -> Reply { + app.call( + Method::POST, + "/accounts", + &admin_token(), + Some(json!({"name": name, "default_shares": shares(5000, 5000)})), + ) + .await +} + +const ACCOUNT_VOLATILE: &[&str] = &[ + "id", + "created_at", + "updated_at", + "archived_at", + "deleted_at", + "server_version", +]; + +#[tokio::test] +async fn e18_creates_an_account_like_the_example() { + let app = TestApp::household().await; + let reply = app + .call( + Method::POST, + "/accounts", + &admin_token(), + Some(example("E-18-create-account/request.json")), + ) + .await; + assert_eq!(reply.status, 201, "{}", reply.text()); + assert_like_example( + &reply.json, + "E-18-create-account/response.json", + ROW_VOLATILE, + ); + assert_eq!(reply.json["created_at"], NOW); + // Shares are sorted by user id whatever the request order. + let reply = app + .call( + Method::POST, + "/accounts", + &member_token(), + Some(json!({"name": "Resor", "default_shares": [ + {"user_id": MEMBER_ID, "share_bp": 3000}, {"user_id": ADMIN_ID, "share_bp": 7000}]})), + ) + .await; + assert_eq!(reply.status, 201); + assert_eq!(reply.json["default_shares"], shares(7000, 3000)); + assert_eq!(reply.json["created_by_user_id"], MEMBER_ID); +} + +#[tokio::test] +async fn e18_household_incomplete_before_the_partner_joins() { + let app = TestApp::admin_only().await; + let reply = app + .call( + Method::POST, + "/accounts", + &admin_token(), + Some(example("E-18-create-account/request.json")), + ) + .await; + assert_error(&reply, 409, "household_incomplete"); +} + +#[tokio::test] +async fn e18_invalid_request_validation_failed_and_name_taken() { + let app = TestApp::household().await; + for body in [ + json!({"name": "x"}), + json!({"default_shares": shares(5000, 5000)}), + json!({"name": 3, "default_shares": shares(5000, 5000)}), + json!({"name": "x", "default_shares": [{"user_id": ADMIN_ID}]}), + ] { + let reply = app + .call(Method::POST, "/accounts", &admin_token(), Some(body)) + .await; + assert_error(&reply, 400, "invalid_request"); + } + let bad_shares = [ + (shares(6000, 5000), "bp_sum_not_10000"), + (shares(10001, -1), "bp_out_of_range"), + ( + json!([{"user_id": ADMIN_ID, "share_bp": 10000}]), + "share_users_mismatch", + ), + ( + json!([{"user_id": ADMIN_ID, "share_bp": 5000}, {"user_id": UNKNOWN_ID, "share_bp": 5000}]), + "share_users_mismatch", + ), + ( + json!([{"user_id": ADMIN_ID, "share_bp": 5000}, {"user_id": ADMIN_ID, "share_bp": 5000}]), + "share_users_mismatch", + ), + ]; + for (default_shares, token) in bad_shares { + let reply = app + .call( + Method::POST, + "/accounts", + &admin_token(), + Some(json!({"name": "x", "default_shares": default_shares})), + ) + .await; + assert_field_error(&reply, 422, "validation_failed", "default_shares"); + assert_eq!(reply.json["message"], token); + } + for name in ["", " ", &"a".repeat(61)] { + let reply = create(&app, name).await; + assert_field_error(&reply, 422, "validation_failed", "name"); + } + assert_eq!(create(&app, &"a".repeat(60)).await.status, 201); + assert_eq!(create(&app, "Vardag").await.status, 201); + // Case-insensitive after trimming, Unicode lowercase (AC-6.2). + for name in ["vardag", " VARDAG ", "VarDag"] { + let reply = create(&app, name).await; + assert_field_error(&reply, 409, "name_taken", "name"); + } + assert_eq!(create(&app, "Ö-konto").await.status, 201); + assert_error(&create(&app, "ö-KONTO").await, 409, "name_taken"); +} + +#[tokio::test] +async fn e19_renames_and_changes_the_ratio_like_the_example() { + let app = TestApp::household().await; + app.insert_account(ACCOUNT_1, "Vardag", "2026-10-17T07:10:00.000Z", 5000) + .await; + let path = format!("/accounts/{ACCOUNT_1}"); + let reply = app + .call( + Method::PATCH, + &path, + &member_token(), + Some(example("E-19-patch-account/request.json")), + ) + .await; + assert_eq!(reply.status, 200, "{}", reply.text()); + assert_like_example( + &reply.json, + "E-19-patch-account/response.json", + &["updated_at", "server_version"], + ); + assert_eq!(reply.json["updated_at"], NOW); + // Only one field; renaming to its own name in another case is allowed. + let reply = app + .call( + Method::PATCH, + &path, + &member_token(), + Some(json!({"name": "VARDAG & MAT"})), + ) + .await; + assert_eq!(reply.status, 200, "{}", reply.text()); + assert_eq!(reply.json["default_shares"], shares(6000, 4000)); +} + +#[tokio::test] +async fn e19_errors() { + let app = TestApp::household().await; + app.insert_account(ACCOUNT_1, "Vardag", "2026-10-17T07:10:00.000Z", 5000) + .await; + app.insert_account(ACCOUNT_2, "Resor", "2026-10-17T07:11:00.000Z", 5000) + .await; + let path = format!("/accounts/{ACCOUNT_1}"); + for body in [ + json!({}), + json!({"name": 1}), + json!({"default_shares": "x"}), + ] { + let reply = app + .call(Method::PATCH, &path, &admin_token(), Some(body)) + .await; + assert_error(&reply, 400, "invalid_request"); + } + let reply = app + .call( + Method::PATCH, + &path, + &admin_token(), + Some(json!({"name": " "})), + ) + .await; + assert_field_error(&reply, 422, "validation_failed", "name"); + let reply = app + .call( + Method::PATCH, + &path, + &admin_token(), + Some(json!({"default_shares": shares(5000, 4999)})), + ) + .await; + assert_field_error(&reply, 422, "validation_failed", "default_shares"); + let reply = app + .call( + Method::PATCH, + &path, + &admin_token(), + Some(json!({"name": "resor"})), + ) + .await; + assert_error(&reply, 409, "name_taken"); + let reply = app + .call( + Method::PATCH, + &format!("/accounts/{UNKNOWN_ID}"), + &admin_token(), + Some(json!({"name": "x"})), + ) + .await; + assert_error(&reply, 404, "not_found"); + + // A deleted account: not_found, and its name is free again (§9.10). + let deleted = app + .call( + Method::DELETE, + &format!("/accounts/{ACCOUNT_2}"), + &admin_token(), + None, + ) + .await; + assert_eq!(deleted.status, 200); + let reply = app + .call( + Method::PATCH, + &format!("/accounts/{ACCOUNT_2}"), + &admin_token(), + Some(json!({"name": "x"})), + ) + .await; + assert_error(&reply, 404, "not_found"); + let reply = app + .call( + Method::PATCH, + &path, + &admin_token(), + Some(json!({"name": "Resor"})), + ) + .await; + assert_eq!(reply.status, 200); +} + +#[tokio::test] +async fn e20_e21_archive_and_unarchive_are_idempotent() { + let app = TestApp::household().await; + app.insert_account(ACCOUNT_2, "Resor", "2026-10-17T07:10:00.000Z", 5000) + .await; + let archive = format!("/accounts/{ACCOUNT_2}/archive"); + let unarchive = format!("/accounts/{ACCOUNT_2}/unarchive"); + let first = app.call(Method::POST, &archive, &admin_token(), None).await; + assert_eq!(first.status, 200); + assert_like_example( + &first.json, + "E-20-archive-account/response.json", + ACCOUNT_VOLATILE, + ); + assert_eq!(first.json["archived_at"], NOW); + let second = app.call(Method::POST, &archive, &admin_token(), None).await; + assert_eq!( + second.json, first.json, + "no new version when already archived" + ); + + let reply = app + .call(Method::POST, &unarchive, &member_token(), None) + .await; + assert_eq!(reply.status, 200); + assert_like_example( + &reply.json, + "E-21-unarchive-account/response.json", + ACCOUNT_VOLATILE, + ); + assert!(reply.json["server_version"].as_i64() > first.json["server_version"].as_i64()); + let again = app + .call(Method::POST, &unarchive, &member_token(), None) + .await; + assert_eq!(again.json, reply.json); + + for path in [ + format!("/accounts/{UNKNOWN_ID}/archive"), + format!("/accounts/{UNKNOWN_ID}/unarchive"), + ] { + assert_error( + &app.call(Method::POST, &path, &admin_token(), None).await, + 404, + "not_found", + ); + } +} + +#[tokio::test] +async fn e22_soft_deletes_an_empty_account() { + let app = TestApp::household().await; + app.insert_account(ACCOUNT_2, "Resor", "2026-10-17T07:10:00.000Z", 5000) + .await; + // A deleted expense and a deleted settlement do not block (AC-6.5). + app.insert_expense(ACCOUNT_2, GROCERIES, 100, ADMIN_ID, (50, 50), true) + .await; + app.insert_settlement(MEMBER_ID, &[(ACCOUNT_2, 50)], true) + .await; + let path = format!("/accounts/{ACCOUNT_2}"); + let reply = app.call(Method::DELETE, &path, &admin_token(), None).await; + assert_eq!(reply.status, 200, "{}", reply.text()); + assert_like_example( + &reply.json, + "E-22-delete-account/response.json", + ACCOUNT_VOLATILE, + ); + assert_eq!(reply.json["deleted_at"], NOW); + // Tombstone stays in the table. + assert_eq!(app.scalar_i64("SELECT COUNT(*) FROM accounts").await, 1); + let again = app.call(Method::DELETE, &path, &admin_token(), None).await; + assert_error(&again, 404, "not_found"); + let unknown = app + .call( + Method::DELETE, + &format!("/accounts/{UNKNOWN_ID}"), + &admin_token(), + None, + ) + .await; + assert_error(&unknown, 404, "not_found"); +} + +#[tokio::test] +async fn e22_account_not_empty_for_expense_settlement_or_template() { + let app = TestApp::household().await; + for (i, id) in [ACCOUNT_1, ACCOUNT_2, "0192a003-0000-7000-8000-000000000003"] + .iter() + .enumerate() + { + app.insert_account(id, &format!("A{i}"), "2026-10-17T07:10:00.000Z", 5000) + .await; + } + app.insert_expense(ACCOUNT_1, GROCERIES, 100, ADMIN_ID, (50, 50), false) + .await; + app.insert_settlement(MEMBER_ID, &[(ACCOUNT_2, 50)], false) + .await; + let third = "0192a003-0000-7000-8000-000000000003"; + let template = app + .call( + Method::POST, + "/recurring-templates", + &admin_token(), + Some(json!({ + "account_id": third, "category_id": HOME, "amount_ore": 100, "description": "Hyra", + "notes": "", "payer_user_id": ADMIN_ID, "split_mode": "default", "splits": [], + "day_of_month": 25, "start_period": "2026-11", "end_period": null, "paused": false + })), + ) + .await; + assert_eq!(template.status, 201, "{}", template.text()); + for id in [ACCOUNT_1, ACCOUNT_2, third] { + let reply = app + .call( + Method::DELETE, + &format!("/accounts/{id}"), + &admin_token(), + None, + ) + .await; + assert_error(&reply, 409, "account_not_empty"); + } + // Once the template is deleted, the account can go. + let tid = template.json["id"].as_str().unwrap(); + app.call( + Method::DELETE, + &format!("/recurring-templates/{tid}"), + &admin_token(), + None, + ) + .await; + let reply = app + .call( + Method::DELETE, + &format!("/accounts/{third}"), + &admin_token(), + None, + ) + .await; + assert_eq!(reply.status, 200); +} diff --git a/server/crates/kvitt-server/tests/core_api/apk.rs b/server/crates/kvitt-server/tests/core_api/apk.rs new file mode 100644 index 0000000..22b8d8f --- /dev/null +++ b/server/crates/kvitt-server/tests/core_api/apk.rs @@ -0,0 +1,60 @@ +//! E-5 latest APK metadata and E-6 APK download (SPEC §10.6). + +use axum::http::Method; + +use crate::common::*; + +fn publish(app: &TestApp, bytes: &[u8]) { + let dir = app.data_dir().join("apk"); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("kvitt-412.apk"), bytes).unwrap(); + std::fs::write( + dir.join("latest.json"), + example("E-5-apk-latest/response.json").to_string(), + ) + .unwrap(); +} + +#[tokio::test] +async fn e5_not_found_until_published_then_read_on_every_request() { + let app = TestApp::empty().await; + let reply = app.public(Method::GET, "/apk/latest", None).await; + assert_error(&reply, 404, "not_found"); + publish(&app, b"apk"); + let reply = app.public(Method::GET, "/apk/latest", None).await; + assert_eq!(reply.status, 200); + assert_eq!(reply.json, example("E-5-apk-latest/response.json")); + // No cache: a new latest.json shows at once. + let mut changed = example("E-5-apk-latest/response.json"); + changed["version_code"] = 413.into(); + std::fs::write(app.data_dir().join("apk/latest.json"), changed.to_string()).unwrap(); + let reply = app.public(Method::GET, "/apk/latest", None).await; + assert_eq!(reply.json["version_code"], 413); +} + +#[tokio::test] +async fn e6_serves_existing_files_matching_the_pattern_only() { + let app = TestApp::empty().await; + let bytes: Vec = (0..70_000u32).map(|i| (i % 251) as u8).collect(); + publish(&app, &bytes); + let reply = app + .public(Method::GET, "/apk/files/kvitt-412.apk", None) + .await; + assert_eq!(reply.status, 200); + assert_eq!( + reply.headers["content-type"], + "application/vnd.android.package-archive" + ); + assert_eq!(reply.headers["content-length"], "70000"); + assert_eq!(reply.bytes, bytes); + for path in [ + "/apk/files/kvitt-413.apk", + "/apk/files/latest.json", + "/apk/files/kvitt-412.apk.tmp", + "/apk/files/..%2Fkvitt.sqlite", + "/apk/files/kvitt-x.apk", + ] { + let reply = app.public(Method::GET, path, None).await; + assert_error(&reply, 404, "not_found"); + } +} diff --git a/server/crates/kvitt-server/tests/core_api/categories.rs b/server/crates/kvitt-server/tests/core_api/categories.rs new file mode 100644 index 0000000..50e52ff --- /dev/null +++ b/server/crates/kvitt-server/tests/core_api/categories.rs @@ -0,0 +1,342 @@ +//! E-23 to E-27: categories (FR-7, SPEC §9.7). + +use axum::http::Method; +use serde_json::json; + +use crate::common::*; + +const CATEGORY_VOLATILE: &[&str] = &["id", "created_at", "updated_at", "server_version"]; + +async fn create_husdjur(app: &TestApp) -> String { + let reply = app + .call( + Method::POST, + "/categories", + &admin_token(), + Some(example("E-23-create-category/request.json")), + ) + .await; + assert_eq!(reply.status, 201, "{}", reply.text()); + reply.json["id"].as_str().unwrap().to_owned() +} + +#[tokio::test] +async fn e23_creates_with_sort_order_max_plus_10() { + let app = TestApp::household().await; + let reply = app + .call( + Method::POST, + "/categories", + &member_token(), + Some(example("E-23-create-category/request.json")), + ) + .await; + assert_eq!(reply.status, 201); + assert_like_example( + &reply.json, + "E-23-create-category/response.json", + CATEGORY_VOLATILE, + ); + // No uniqueness rule (AC-7.7); the next one gets 120. The server needs no household (only accounts, templates, + // expenses and settlements do, AC-5.2). + let second = app + .call( + Method::POST, + "/categories", + &admin_token(), + Some(json!({"name": "Husdjur", "icon": "pets", "color": "lime"})), + ) + .await; + assert_eq!(second.json["sort_order"], 120); + let solo = TestApp::admin_only().await; + let reply = solo + .call( + Method::POST, + "/categories", + &admin_token(), + Some(json!({"name": "x", "icon": "pets", "color": "lime"})), + ) + .await; + assert_eq!(reply.status, 201); +} + +#[tokio::test] +async fn e23_invalid_request_and_validation_failed() { + let app = TestApp::household().await; + for body in [ + json!({"name": "x", "icon": "pets"}), + json!({"name": null, "icon": "pets", "color": "lime"}), + json!({"name": "x", "icon": 1, "color": "lime"}), + ] { + let reply = app + .call(Method::POST, "/categories", &admin_token(), Some(body)) + .await; + assert_error(&reply, 400, "invalid_request"); + } + for (body, field) in [ + (json!({"name": "x", "icon": "dog", "color": "lime"}), "icon"), + ( + json!({"name": "x", "icon": "pets", "color": "black"}), + "color", + ), + ( + json!({"name": " ", "icon": "pets", "color": "lime"}), + "name", + ), + ( + json!({"name": "a".repeat(41), "icon": "pets", "color": "lime"}), + "name", + ), + ] { + let reply = app + .call(Method::POST, "/categories", &admin_token(), Some(body)) + .await; + assert_field_error(&reply, 422, "validation_failed", field); + } +} + +#[tokio::test] +async fn e24_renames_a_default_and_reorders_like_the_example_then_resets_the_name() { + let app = TestApp::household().await; + let path = format!("/categories/{GROCERIES}"); + let reply = app + .call( + Method::PATCH, + &path, + &admin_token(), + Some(example("E-24-patch-category/request.json")), + ) + .await; + assert_eq!(reply.status, 200, "{}", reply.text()); + assert_like_example( + &reply.json, + "E-24-patch-category/response.json", + &["updated_at", "server_version"], + ); + let reply = app + .call( + Method::PATCH, + &path, + &admin_token(), + Some(json!({"name": null, "icon": "home", "color": "indigo"})), + ) + .await; + assert_eq!(reply.status, 200); + assert_eq!(reply.json["name"], json!(null)); + assert_eq!(reply.json["default_key"], "groceries"); + assert_eq!(reply.json["icon"], "home"); + assert_eq!(reply.json["sort_order"], 15); +} + +#[tokio::test] +async fn e24_errors() { + let app = TestApp::household().await; + let id = create_husdjur(&app).await; + let path = format!("/categories/{id}"); + for body in [json!({}), json!({"sort_order": "x"}), json!({"icon": null})] { + let reply = app + .call(Method::PATCH, &path, &admin_token(), Some(body)) + .await; + assert_error(&reply, 400, "invalid_request"); + } + for (body, field) in [ + (json!({"icon": "dog"}), "icon"), + (json!({"color": "black"}), "color"), + (json!({"name": ""}), "name"), + ] { + let reply = app + .call(Method::PATCH, &path, &admin_token(), Some(body)) + .await; + assert_field_error(&reply, 422, "validation_failed", field); + } + let reply = app + .call( + Method::PATCH, + &path, + &admin_token(), + Some(json!({"name": null})), + ) + .await; + assert_field_error(&reply, 409, "category_is_default", "name"); + let reply = app + .call( + Method::PATCH, + &format!("/categories/{UNKNOWN_ID}"), + &admin_token(), + Some(json!({"sort_order": 1})), + ) + .await; + assert_error(&reply, 404, "not_found"); + app.call(Method::DELETE, &path, &admin_token(), None).await; + let reply = app + .call( + Method::PATCH, + &path, + &admin_token(), + Some(json!({"sort_order": 1})), + ) + .await; + assert_error(&reply, 404, "not_found"); +} + +#[tokio::test] +async fn e25_e26_archive_and_unarchive() { + let app = TestApp::household().await; + let id = create_husdjur(&app).await; + let reply = app + .call( + Method::POST, + &format!("/categories/{id}/archive"), + &admin_token(), + None, + ) + .await; + assert_eq!(reply.status, 200); + assert_like_example( + &reply.json, + "E-25-archive-category/response.json", + &[ + "id", + "created_at", + "updated_at", + "archived_at", + "server_version", + ], + ); + let reply = app + .call( + Method::POST, + &format!("/categories/{id}/unarchive"), + &admin_token(), + None, + ) + .await; + assert_eq!(reply.status, 200); + assert_like_example( + &reply.json, + "E-26-unarchive-category/response.json", + CATEGORY_VOLATILE, + ); + // Default categories can be archived (AC-7.6). + let reply = app + .call( + Method::POST, + &format!("/categories/{GROCERIES}/archive"), + &admin_token(), + None, + ) + .await; + assert_eq!(reply.status, 200); + for action in ["archive", "unarchive"] { + let reply = app + .call( + Method::POST, + &format!("/categories/{UNKNOWN_ID}/{action}"), + &admin_token(), + None, + ) + .await; + assert_error(&reply, 404, "not_found"); + } +} + +#[tokio::test] +async fn e27_deletes_unused_custom_categories_only() { + let app = TestApp::household().await; + let id = create_husdjur(&app).await; + let reply = app + .call( + Method::DELETE, + &format!("/categories/{id}"), + &admin_token(), + None, + ) + .await; + assert_eq!(reply.status, 200); + assert_like_example( + &reply.json, + "E-27-delete-category/response.json", + &[ + "id", + "created_at", + "updated_at", + "deleted_at", + "server_version", + ], + ); + assert_eq!(reply.json["deleted_at"], NOW); + let again = app + .call( + Method::DELETE, + &format!("/categories/{id}"), + &admin_token(), + None, + ) + .await; + assert_error(&again, 404, "not_found"); + let unknown = app + .call( + Method::DELETE, + &format!("/categories/{UNKNOWN_ID}"), + &admin_token(), + None, + ) + .await; + assert_error(&unknown, 404, "not_found"); + let default = app + .call( + Method::DELETE, + &format!("/categories/{GROCERIES}"), + &admin_token(), + None, + ) + .await; + assert_error(&default, 409, "category_is_default"); +} + +#[tokio::test] +async fn e27_category_in_use_by_expense_or_template() { + let app = TestApp::household().await; + app.insert_account(ACCOUNT_1, "Vardag", "2026-10-17T07:10:00.000Z", 5000) + .await; + let used_by_expense = create_husdjur(&app).await; + let used_by_template = create_husdjur(&app).await; + let free_again = create_husdjur(&app).await; + app.insert_expense(ACCOUNT_1, &used_by_expense, 100, ADMIN_ID, (50, 50), false) + .await; + app.insert_expense(ACCOUNT_1, &free_again, 100, ADMIN_ID, (50, 50), true) + .await; + let template = app + .call( + Method::POST, + "/recurring-templates", + &admin_token(), + Some(json!({ + "account_id": ACCOUNT_1, "category_id": used_by_template, "amount_ore": 100, + "description": "Foder", "notes": "", "payer_user_id": ADMIN_ID, "split_mode": "default", + "splits": [], "day_of_month": 1, "start_period": "2026-12", "end_period": null, "paused": true + })), + ) + .await; + assert_eq!(template.status, 201, "{}", template.text()); + for id in [&used_by_expense, &used_by_template] { + let reply = app + .call( + Method::DELETE, + &format!("/categories/{id}"), + &admin_token(), + None, + ) + .await; + assert_error(&reply, 409, "category_in_use"); + } + let reply = app + .call( + Method::DELETE, + &format!("/categories/{free_again}"), + &admin_token(), + None, + ) + .await; + assert_eq!(reply.status, 200, "a deleted expense does not block"); +} diff --git a/server/crates/kvitt-server/tests/core_api/cli.rs b/server/crates/kvitt-server/tests/core_api/cli.rs new file mode 100644 index 0000000..b6d22e1 --- /dev/null +++ b/server/crates/kvitt-server/tests/core_api/cli.rs @@ -0,0 +1,99 @@ +//! `kvitt-server bootstrap` and `pair-device` (FR-1, AC-1.1 to AC-1.6, SPEC §10.4), run as the real binary. + +use std::process::{Command, Output}; + +use axum::http::Method; +use serde_json::json; + +use crate::common::*; + +fn run(dir: &std::path::Path, args: &[&str]) -> Output { + Command::new(env!("CARGO_BIN_EXE_kvitt-server")) + .args(args) + .env("KVITT_DATA_DIR", dir) + .env("KVITT_PUBLIC_URL", "https://kvitt.example:8443") + .env("RUST_LOG", "warn") + .output() + .unwrap() +} + +/// The 12-character code printed in `Link:`. +fn printed_code(output: &Output) -> String { + let stdout = String::from_utf8(output.stdout.clone()).unwrap(); + let link = stdout + .lines() + .find_map(|l| l.strip_prefix("Link:")) + .unwrap() + .trim(); + let code = link.strip_prefix("https://kvitt.example:8443/i/").unwrap(); + assert_eq!(code.len(), 12); + let display = stdout + .lines() + .find_map(|l| l.strip_prefix("Code:")) + .unwrap() + .trim(); + assert_eq!(display.replace('-', ""), code); + assert_eq!(display.len(), 14); + assert!(stdout.contains("Expires: ")); + code.to_owned() +} + +#[tokio::test] +async fn bootstrap_prints_an_admin_setup_invite_cancels_the_previous_one_then_refuses() { + let dir = TempDir::new(); + let first = run(&dir.0, &["bootstrap"]); + assert_eq!(first.status.code(), Some(0), "{first:?}"); + let first_code = printed_code(&first); + let second = run(&dir.0, &["bootstrap"]); + assert_eq!(second.status.code(), Some(0)); + let second_code = printed_code(&second); + assert_ne!(first_code, second_code); + + let app = TestApp::at(dir).await; + let accept = |code: String| { + app.public( + Method::POST, + "/invites/accept", + Some(json!({"code": code, "device_name": "Galaxy S26", "display_name": "Jacob"})), + ) + }; + // AC-1.3: the first code was cancelled. + assert_error(&accept(first_code).await, 400, "invite_invalid"); + // AC-1.4. + let reply = accept(second_code).await; + assert_eq!(reply.status, 200, "{}", reply.text()); + assert_eq!(reply.json["user"]["role"], "admin"); + + // AC-1.2: users exist. + let third = run(app.data_dir(), &["bootstrap"]); + assert_eq!(third.status.code(), Some(2)); + assert_eq!( + String::from_utf8_lossy(&third.stderr).trim(), + "already initialized" + ); + assert!(third.stdout.is_empty()); + assert_eq!(app.scalar_i64("SELECT COUNT(*) FROM invites").await, 2); + + // AC-1.6: pair-device for the admin; unknown users exit 3. + let admin_id = reply.json["user"]["id"].as_str().unwrap().to_owned(); + let paired = run(app.data_dir(), &["pair-device", "--user", &admin_id]); + assert_eq!(paired.status.code(), Some(0), "{paired:?}"); + let code = printed_code(&paired); + let reply = app + .public( + Method::POST, + "/invites/accept", + Some(json!({"code": code, "device_name": "New phone"})), + ) + .await; + assert_eq!(reply.status, 200, "{}", reply.text()); + assert_eq!(reply.json["user"]["id"], json!(admin_id)); + for user in [UNKNOWN_ID, "not-a-uuid"] { + let unknown = run(app.data_dir(), &["pair-device", "--user", user]); + assert_eq!(unknown.status.code(), Some(3)); + assert_eq!( + String::from_utf8_lossy(&unknown.stderr).trim(), + "unknown user" + ); + } +} diff --git a/server/crates/kvitt-server/tests/core_api/common.rs b/server/crates/kvitt-server/tests/core_api/common.rs new file mode 100644 index 0000000..f5f9d81 --- /dev/null +++ b/server/crates/kvitt-server/tests/core_api/common.rs @@ -0,0 +1,555 @@ +//! The M3 test harness: a migrated temporary database, a fixed clock, recording fakes of `AuditWriter` and +//! `PushQueue`, a seeded household with the ids of `contracts/examples/`, one router per app (so the rate limiter +//! keeps its state across requests) and helpers to compare responses with the contract examples. + +use std::net::SocketAddr; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; + +use axum::Router; +use axum::body::Body; +use axum::extract::ConnectInfo; +use axum::http::{HeaderMap, Method, Request, StatusCode}; +use http_body_util::BodyExt; +use kvitt_server::audit::{AuditEvent, AuditWriter, BoxFuture}; +use kvitt_server::config::Config; +use kvitt_server::push::{PushMessage, PushQueue}; +use kvitt_server::time::{FixedClock, parse_ts}; +use kvitt_server::{AppState, auth, db, router}; +use serde_json::{Value, json}; +use sqlx::SqliteConnection; +use tower::ServiceExt; +use uuid::Uuid; + +pub const ADMIN_ID: &str = "0192a000-0000-7000-8000-000000000001"; +pub const MEMBER_ID: &str = "0192a000-0000-7000-8000-000000000002"; +pub const ADMIN_DEVICE_ID: &str = "0192a001-0000-7000-8000-000000000001"; +pub const MEMBER_DEVICE_ID: &str = "0192a001-0000-7000-8000-000000000002"; +pub const ADMIN_OLD_DEVICE_ID: &str = "0192a001-0000-7000-8000-000000000003"; +pub const ACCOUNT_1: &str = "0192a003-0000-7000-8000-000000000001"; +pub const ACCOUNT_2: &str = "0192a003-0000-7000-8000-000000000002"; +pub const GROCERIES: &str = "01920000-0000-7000-8000-000000000001"; +pub const HOME: &str = "01920000-0000-7000-8000-000000000003"; +pub const UNKNOWN_ID: &str = "0192ffff-0000-7000-8000-000000000001"; +/// Saturday 17 October 2026, 14:00 in Stockholm (CEST). +pub const NOW: &str = "2026-10-17T12:00:00.000Z"; +/// The default client address of test requests. +pub const CLIENT: &str = "192.0.2.10:50000"; + +pub fn token(tag: char) -> String { + format!("kvt_{}", tag.to_string().repeat(43)) +} +pub fn admin_token() -> String { + token('A') +} +pub fn member_token() -> String { + token('M') +} +pub fn old_device_token() -> String { + token('R') +} + +/// Records every audit event (M4 replaces the real writer; M3 only calls it). +#[derive(Default)] +pub struct RecordingAudit(pub Mutex>); + +impl AuditWriter for RecordingAudit { + fn write<'a>( + &'a self, + _conn: &'a mut SqliteConnection, + event: AuditEvent, + ) -> BoxFuture<'a, Result> { + self.0.lock().unwrap().push(event); + Box::pin(async { Ok(Uuid::now_v7()) }) + } +} + +/// Records every enqueued push. +#[derive(Default)] +pub struct RecordingPush(pub Mutex>); + +impl PushQueue for RecordingPush { + fn enqueue(&self, message: PushMessage) { + self.0.lock().unwrap().push(message); + } +} + +/// A data directory removed on drop. +pub struct TempDir(pub PathBuf); + +impl TempDir { + pub fn new() -> Self { + let dir = std::env::temp_dir().join(format!("kvitt-core-{}", Uuid::now_v7())); + std::fs::create_dir_all(&dir).unwrap(); + Self(dir) + } +} + +impl Drop for TempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +pub struct TestApp { + pub dir: TempDir, + pub state: AppState, + pub clock: FixedClock, + pub audit: Arc, + pub push: Arc, + pub router: Router, +} + +pub struct Reply { + pub status: StatusCode, + pub headers: HeaderMap, + pub bytes: Vec, + pub json: Value, +} + +impl Reply { + pub fn code(&self) -> &str { + self.json["code"].as_str().unwrap_or("") + } + + pub fn text(&self) -> String { + String::from_utf8_lossy(&self.bytes).into_owned() + } +} + +/// Asserts the error shape of SPEC §14.3.2 with the given status and code. +#[track_caller] +pub fn assert_error(reply: &Reply, status: u16, code: &str) { + assert_eq!(reply.status.as_u16(), status, "{}", reply.text()); + assert_eq!(reply.code(), code, "{}", reply.text()); + assert!(reply.json["message"].is_string()); + assert!(reply.json.as_object().unwrap().contains_key("field")); +} + +/// Like [`assert_error`] and also checks `field`. +#[track_caller] +pub fn assert_field_error(reply: &Reply, status: u16, code: &str, field: &str) { + assert_error(reply, status, code); + assert_eq!(reply.json["field"], json!(field), "{}", reply.text()); +} + +impl TestApp { + /// A migrated, empty database (no users) at a new temporary directory. + pub async fn empty() -> Self { + Self::at(TempDir::new()).await + } + + /// A migrated database in `dir` (which may already hold one). + pub async fn at(dir: TempDir) -> Self { + let path = dir.0.to_string_lossy().into_owned(); + let config = Config::from_lookup(|k| match k { + "KVITT_DATA_DIR" => Some(path.clone()), + // No proxy is trusted unless a test says so. + "KVITT_TRUSTED_PROXY" => Some("127.0.0.9".to_owned()), + _ => None, + }) + .unwrap(); + let pool = db::connect(&config.database_path()).await.unwrap(); + db::migrate(&pool).await.unwrap(); + let clock = FixedClock::new(parse_ts(NOW).unwrap()); + let audit = Arc::new(RecordingAudit::default()); + let push = Arc::new(RecordingPush::default()); + let mut state = AppState::new(config, pool).with_clock(Arc::new(clock.clone())); + state.audit = audit.clone(); + state.push = push.clone(); + let router = router::router(state.clone()); + Self { + dir, + state, + clock, + audit, + push, + router, + } + } + + /// Only the admin (Jacob) with one device: the household is incomplete. + pub async fn admin_only() -> Self { + let app = Self::empty().await; + app.insert_user(ADMIN_ID, "admin", "Jacob", 11).await; + app.insert_device( + ADMIN_DEVICE_ID, + ADMIN_ID, + "Galaxy S26", + &admin_token(), + None, + ) + .await; + app + } + + /// Admin Jacob and member Anna, one device each, plus a revoked old device of the admin. + pub async fn household() -> Self { + let app = Self::admin_only().await; + app.insert_user(MEMBER_ID, "member", "Anna", 12).await; + app.insert_device( + MEMBER_DEVICE_ID, + MEMBER_ID, + "Galaxy S26+", + &member_token(), + None, + ) + .await; + app.insert_device( + ADMIN_OLD_DEVICE_ID, + ADMIN_ID, + "Old phone", + &old_device_token(), + Some("2026-10-10T00:00:00.000Z"), + ) + .await; + app + } + + pub async fn insert_user(&self, id: &str, role: &str, name: &str, version: i64) { + let created = if role == "admin" { + "2026-10-01T00:00:00.000Z" + } else { + "2026-10-02T00:00:00.000Z" + }; + sqlx::query( + "INSERT INTO users (id, role, display_name, picture_version, created_at, updated_at, server_version) \ + VALUES (?, ?, ?, NULL, ?, ?, ?)", + ) + .bind(id) + .bind(role) + .bind(name) + .bind(created) + .bind(created) + .bind(version) + .execute(&self.state.db) + .await + .unwrap(); + sqlx::query("UPDATE sync_sequence SET value = MAX(value, ?)") + .bind(version) + .execute(&self.state.db) + .await + .unwrap(); + } + + pub async fn insert_device( + &self, + id: &str, + user: &str, + name: &str, + tok: &str, + revoked: Option<&str>, + ) { + let created = match id { + ADMIN_DEVICE_ID => "2026-10-01T00:00:00.000Z", + ADMIN_OLD_DEVICE_ID => "2026-10-01T01:00:00.000Z", + _ => "2026-10-02T00:00:00.000Z", + }; + sqlx::query( + "INSERT INTO devices (id, user_id, name, token_hash, created_at, last_seen_at, revoked_at) \ + VALUES (?, ?, ?, ?, ?, ?, ?)", + ) + .bind(id) + .bind(user) + .bind(name) + .bind(auth::sha256_hex(tok)) + .bind(created) + .bind(created) + .bind(revoked) + .execute(&self.state.db) + .await + .unwrap(); + } + + /// An account inserted directly (fixed id, so responses can be compared with the examples). + pub async fn insert_account(&self, id: &str, name: &str, created_at: &str, bp_admin: i64) { + let version = self.next_version().await; + sqlx::query( + "INSERT INTO accounts (id, name, name_key, created_by_user_id, created_at, updated_at, server_version) \ + VALUES (?, ?, ?, ?, ?, ?, ?)", + ) + .bind(id) + .bind(name) + .bind(name.to_lowercase()) + .bind(ADMIN_ID) + .bind(created_at) + .bind(created_at) + .bind(version) + .execute(&self.state.db) + .await + .unwrap(); + for (user, bp) in [(ADMIN_ID, bp_admin), (MEMBER_ID, 10_000 - bp_admin)] { + sqlx::query( + "INSERT INTO account_default_shares (account_id, user_id, share_bp) VALUES (?, ?, ?)", + ) + .bind(id) + .bind(user) + .bind(bp) + .execute(&self.state.db) + .await + .unwrap(); + } + } + + /// An expense inserted directly (sync is M4's): `shares` are (admin öre, member öre). + pub async fn insert_expense( + &self, + account: &str, + category: &str, + amount: i64, + payer: &str, + shares: (i64, i64), + deleted: bool, + ) -> String { + let id = Uuid::now_v7().to_string(); + let version = self.next_version().await; + sqlx::query( + "INSERT INTO expenses (id, account_id, category_id, amount_ore, description, notes, expense_date, \ + payer_user_id, split_mode, created_by_user_id, updated_by_user_id, created_at, updated_at, deleted_at, \ + server_received_at, server_version) \ + VALUES (?, ?, ?, ?, 'x', '', '2026-10-16', ?, 'exact', ?, ?, ?, ?, ?, ?, ?)", + ) + .bind(&id) + .bind(account) + .bind(category) + .bind(amount) + .bind(payer) + .bind(payer) + .bind(payer) + .bind(NOW) + .bind(NOW) + .bind(deleted.then_some(NOW)) + .bind(NOW) + .bind(version) + .execute(&self.state.db) + .await + .unwrap(); + for (user, ore) in [(ADMIN_ID, shares.0), (MEMBER_ID, shares.1)] { + sqlx::query( + "INSERT INTO expense_splits (expense_id, user_id, share_ore, share_bp) VALUES (?, ?, ?, NULL)", + ) + .bind(&id) + .bind(user) + .bind(ore) + .execute(&self.state.db) + .await + .unwrap(); + } + id + } + + /// A settlement inserted directly, from `from` to the other user, with allocations. + pub async fn insert_settlement( + &self, + from: &str, + allocations: &[(&str, i64)], + deleted: bool, + ) -> String { + let id = Uuid::now_v7().to_string(); + let to = if from == ADMIN_ID { + MEMBER_ID + } else { + ADMIN_ID + }; + let amount: i64 = allocations.iter().map(|(_, a)| a).sum(); + let version = self.next_version().await; + sqlx::query( + "INSERT INTO settlements (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_received_at, \ + server_version) VALUES (?, ?, ?, ?, '2026-10-16', '', ?, ?, ?, ?, ?, ?, ?)", + ) + .bind(&id) + .bind(from) + .bind(to) + .bind(amount) + .bind(from) + .bind(from) + .bind(NOW) + .bind(NOW) + .bind(deleted.then_some(NOW)) + .bind(NOW) + .bind(version) + .execute(&self.state.db) + .await + .unwrap(); + for (account, a) in allocations { + sqlx::query( + "INSERT INTO settlement_allocations (settlement_id, account_id, amount_ore) VALUES (?, ?, ?)", + ) + .bind(&id) + .bind(account) + .bind(a) + .execute(&self.state.db) + .await + .unwrap(); + } + id + } + + pub async fn next_version(&self) -> i64 { + sqlx::query_scalar( + "UPDATE sync_sequence SET value = value + 1 WHERE id = 1 RETURNING value", + ) + .fetch_one(&self.state.db) + .await + .unwrap() + } + + pub async fn scalar_i64(&self, sql: &'static str) -> i64 { + sqlx::query_scalar(sql) + .fetch_one(&self.state.db) + .await + .unwrap() + } + + pub fn set_now(&self, ts: &str) { + self.clock.set(parse_ts(ts).unwrap()); + } + + pub fn data_dir(&self) -> &Path { + &self.dir.0 + } + + pub async fn send(&self, request: Request) -> Reply { + let mut request = request; + if request + .extensions() + .get::>() + .is_none() + { + request + .extensions_mut() + .insert(ConnectInfo(CLIENT.parse::().unwrap())); + } + let response = self.router.clone().oneshot(request).await.unwrap(); + let status = response.status(); + let headers = response.headers().clone(); + let bytes = response + .into_body() + .collect() + .await + .unwrap() + .to_bytes() + .to_vec(); + let json = serde_json::from_slice(&bytes).unwrap_or(Value::Null); + Reply { + status, + headers, + bytes, + json, + } + } + + /// An authenticated JSON request (`body` `None` sends no body). The future borrows only the app. + pub fn call<'a>( + &'a self, + method: Method, + path: &str, + tok: &str, + body: Option, + ) -> impl std::future::Future + use<'a> { + self.send(req(method, path, Some(tok), body)) + } + + /// An unauthenticated JSON request. + pub fn public<'a>( + &'a self, + method: Method, + path: &str, + body: Option, + ) -> impl std::future::Future + use<'a> { + self.send(req(method, path, None, body)) + } +} + +/// A request with the app's usual headers (`X-Kvitt-Protocol: 1`, JSON content type when there is a body). +pub fn req(method: Method, path: &str, tok: Option<&str>, body: Option) -> Request { + let mut builder = Request::builder() + .method(method) + .uri(path) + .header("x-kvitt-protocol", "1"); + if let Some(tok) = tok { + builder = builder.header("authorization", format!("Bearer {tok}")); + } + match body { + Some(body) => builder + .header("content-type", "application/json; charset=utf-8") + .body(Body::from(body.to_string())) + .unwrap(), + None => builder.body(Body::empty()).unwrap(), + } +} + +/// A raw request with a body and content type. +pub fn raw( + method: Method, + path: &str, + tok: &str, + content_type: Option<&str>, + body: Vec, +) -> Request { + let mut builder = Request::builder() + .method(method) + .uri(path) + .header("x-kvitt-protocol", "1") + .header("authorization", format!("Bearer {tok}")); + if let Some(ct) = content_type { + builder = builder.header("content-type", ct); + } + builder.body(Body::from(body)).unwrap() +} + +/// `contracts/examples/` parsed. +pub fn example(name: &str) -> Value { + let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../../contracts/examples") + .join(name); + serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap() +} + +/// Compares `actual` with an example by JSON value. Keys in `volatile` (ids, timestamps, versions, codes, which the +/// examples cannot fix) are compared by JSON type only; everything else, the key sets included, must be equal. +#[track_caller] +pub fn assert_like_example(actual: &Value, name: &str, volatile: &[&str]) { + fn walk(a: &Value, e: &Value, path: &str, volatile: &[&str]) -> Result<(), String> { + match (a, e) { + (Value::Object(a), Value::Object(e)) => { + let ak: Vec<_> = a.keys().collect(); + let ek: Vec<_> = e.keys().collect(); + if ak != ek { + return Err(format!("{path}: keys {ak:?} != {ek:?}")); + } + for (k, ev) in e { + let av = &a[k]; + let p = format!("{path}.{k}"); + if volatile.contains(&k.as_str()) { + let same_kind = std::mem::discriminant(av) == std::mem::discriminant(ev); + if !same_kind { + return Err(format!("{p}: type of {av} differs from {ev}")); + } + } else { + walk(av, ev, &p, volatile)?; + } + } + Ok(()) + } + (Value::Array(a), Value::Array(e)) => { + if a.len() != e.len() { + return Err(format!("{path}: length {} != {}", a.len(), e.len())); + } + for (i, (av, ev)) in a.iter().zip(e).enumerate() { + walk(av, ev, &format!("{path}[{i}]"), volatile)?; + } + Ok(()) + } + _ if a == e => Ok(()), + _ => Err(format!("{path}: {a} != {e}")), + } + } + if let Err(diff) = walk(actual, &example(name), "$", volatile) { + panic!("{name}: {diff}\nactual: {actual:#}"); + } +} + +/// The timestamp and version keys every row example has. +pub const ROW_VOLATILE: &[&str] = &["id", "created_at", "updated_at", "server_version"]; diff --git a/server/crates/kvitt-server/tests/core_api/devices.rs b/server/crates/kvitt-server/tests/core_api/devices.rs new file mode 100644 index 0000000..1e30a21 --- /dev/null +++ b/server/crates/kvitt-server/tests/core_api/devices.rs @@ -0,0 +1,177 @@ +//! E-15 list, E-16 revoke, E-17 push token (FR-3, SPEC §8.4). + +use axum::http::Method; +use serde_json::json; + +use crate::common::*; + +#[tokio::test] +async fn e15_admin_sees_all_devices_member_own_ordered_by_created_at() { + let app = TestApp::household().await; + sqlx::query("UPDATE devices SET app_version_code = 41 WHERE revoked_at IS NULL") + .execute(&app.state.db) + .await + .unwrap(); + let reply = app + .call(Method::GET, "/devices", &admin_token(), None) + .await; + assert_eq!(reply.status, 200); + assert_like_example( + &reply.json, + "E-15-list-devices/response.json", + &["created_at", "last_seen_at", "revoked_at"], + ); + let ids: Vec<_> = reply.json["devices"] + .as_array() + .unwrap() + .iter() + .map(|d| d["id"].as_str().unwrap().to_owned()) + .collect(); + assert_eq!( + ids, + [ADMIN_DEVICE_ID, ADMIN_OLD_DEVICE_ID, MEMBER_DEVICE_ID] + ); + assert!(reply.json["devices"][1]["revoked_at"].is_string()); + + let member = app + .call(Method::GET, "/devices", &member_token(), None) + .await; + let devices = member.json["devices"].as_array().unwrap(); + assert_eq!(devices.len(), 1); + assert_eq!(devices[0]["id"], MEMBER_DEVICE_ID); + assert_eq!(devices[0]["is_current"], true); +} + +#[tokio::test] +async fn e16_admin_revokes_another_device_which_then_gets_device_revoked() { + let app = TestApp::household().await; + app.call( + Method::PUT, + "/devices/current/push-token", + &member_token(), + Some(json!({"fcm_token": "fcm-1"})), + ) + .await; + let path = format!("/devices/{MEMBER_DEVICE_ID}"); + let reply = app.call(Method::DELETE, &path, &admin_token(), None).await; + assert_eq!(reply.status, 204); + // Already revoked: 204 again, revoked_at unchanged. + app.set_now("2026-10-17T13:00:00.000Z"); + let reply = app.call(Method::DELETE, &path, &admin_token(), None).await; + assert_eq!(reply.status, 204); + let row: (Option, Option, Option) = sqlx::query_as( + "SELECT revoked_at, revoked_by_user_id, fcm_token FROM devices WHERE id = ?", + ) + .bind(MEMBER_DEVICE_ID) + .fetch_one(&app.state.db) + .await + .unwrap(); + assert_eq!(row, (Some(NOW.to_owned()), Some(ADMIN_ID.to_owned()), None)); + // AC-3.3: the revoked device's next request. + let reply = app.call(Method::GET, "/me", &member_token(), None).await; + assert_error(&reply, 401, "device_revoked"); + // AC-3.5: never deleted. + assert_eq!(app.scalar_i64("SELECT COUNT(*) FROM devices").await, 3); +} + +#[tokio::test] +async fn e16_forbidden_not_found_and_cannot_revoke_current_device() { + let app = TestApp::household().await; + // A member may revoke only their own devices. + let reply = app + .call( + Method::DELETE, + &format!("/devices/{ADMIN_OLD_DEVICE_ID}"), + &member_token(), + None, + ) + .await; + assert_error(&reply, 403, "forbidden"); + let reply = app + .call( + Method::DELETE, + &format!("/devices/{UNKNOWN_ID}"), + &admin_token(), + None, + ) + .await; + assert_error(&reply, 404, "not_found"); + for (tok, id) in [ + (admin_token(), ADMIN_DEVICE_ID), + (member_token(), MEMBER_DEVICE_ID), + ] { + let reply = app + .call(Method::DELETE, &format!("/devices/{id}"), &tok, None) + .await; + assert_error(&reply, 409, "cannot_revoke_current_device"); + } +} + +#[tokio::test] +async fn e16_member_revokes_own_other_device() { + let app = TestApp::household().await; + let second = "0192a001-0000-7000-8000-000000000004"; + app.insert_device(second, MEMBER_ID, "Tablet", &token('T'), None) + .await; + let reply = app + .call( + Method::DELETE, + &format!("/devices/{second}"), + &member_token(), + None, + ) + .await; + assert_eq!(reply.status, 204); +} + +#[tokio::test] +async fn e17_sets_and_clears_the_fcm_token() { + let app = TestApp::household().await; + let fcm = || async { + sqlx::query_scalar::<_, Option>("SELECT fcm_token FROM devices WHERE id = ?") + .bind(ADMIN_DEVICE_ID) + .fetch_one(&app.state.db) + .await + .unwrap() + }; + let reply = app + .call( + Method::PUT, + "/devices/current/push-token", + &admin_token(), + Some(example("E-17-push-token/request.json")), + ) + .await; + assert_eq!(reply.status, 204); + assert!(fcm().await.is_some()); + let reply = app + .call( + Method::PUT, + "/devices/current/push-token", + &admin_token(), + Some(json!({"fcm_token": null})), + ) + .await; + assert_eq!(reply.status, 204); + assert_eq!(fcm().await, None); +} + +#[tokio::test] +async fn e17_invalid_request() { + let app = TestApp::household().await; + for body in [ + json!({}), + json!({"fcm_token": 3}), + json!({"fcm_token": " "}), + ] { + let reply = app + .call( + Method::PUT, + "/devices/current/push-token", + &admin_token(), + Some(body), + ) + .await; + assert_error(&reply, 400, "invalid_request"); + } +} diff --git a/server/crates/kvitt-server/tests/core_api/generation.rs b/server/crates/kvitt-server/tests/core_api/generation.rs new file mode 100644 index 0000000..6c90b2e --- /dev/null +++ b/server/crates/kvitt-server/tests/core_api/generation.rs @@ -0,0 +1,523 @@ +//! The recurring generation of SPEC §9.5 through `api::recurring::generate_due_periods` (D-015), with recording fakes +//! of `AuditWriter` and `PushQueue`. + +use axum::http::Method; +use kvitt_server::api::recurring::{generate_all_due, generate_due_periods}; +use kvitt_server::push::PushType; +use kvitt_server::wire::{AuditAction, AuditSource, EntityType}; +use serde_json::{Value, json}; +use uuid::Uuid; + +use crate::common::*; +use crate::recurring::{create_template, household_with_account, template_body}; + +fn bp(admin: i64, member: i64) -> Value { + json!([{"user_id": ADMIN_ID, "share_bp": admin, "share_ore": null}, + {"user_id": MEMBER_ID, "share_bp": member, "share_ore": null}]) +} + +async fn new_template(app: &TestApp, changes: Value) -> (Uuid, Value) { + let reply = create_template(app, template_body(changes)).await; + assert_eq!(reply.status, 201, "{}", reply.text()); + ( + reply.json["id"].as_str().unwrap().parse().unwrap(), + reply.json, + ) +} + +async fn template(app: &TestApp, id: Uuid) -> Value { + // A no-op read through PATCH is not possible; read the wire form through the library. + let mut conn = app.state.db.acquire().await.unwrap(); + let t = kvitt_server::api::rows::template(&mut conn, id) + .await + .unwrap() + .unwrap(); + serde_json::to_value(t).unwrap() +} + +/// (expense_date, recurring_period, deleted) of a template's expenses, by period. +async fn generated(app: &TestApp, id: Uuid) -> Vec<(String, String, bool)> { + let rows: Vec<(String, String, Option)> = sqlx::query_as( + "SELECT expense_date, recurring_period, deleted_at FROM expenses WHERE recurring_template_id = ? \ + ORDER BY recurring_period", + ) + .bind(id.to_string()) + .fetch_all(&app.state.db) + .await + .unwrap(); + rows.into_iter() + .map(|(d, p, del)| (d, p, del.is_some())) + .collect() +} + +async fn expense_of(app: &TestApp, id: Uuid, period: &str) -> Value { + let expense_id: String = sqlx::query_scalar( + "SELECT id FROM expenses WHERE recurring_template_id = ? AND recurring_period = ?", + ) + .bind(id.to_string()) + .bind(period) + .fetch_one(&app.state.db) + .await + .unwrap(); + let mut conn = app.state.db.acquire().await.unwrap(); + let e = kvitt_server::api::rows::expense(&mut conn, expense_id.parse().unwrap()) + .await + .unwrap() + .unwrap(); + serde_json::to_value(e).unwrap() +} + +fn dates(rows: &[(String, String, bool)]) -> Vec<&str> { + rows.iter().map(|(d, _, _)| d.as_str()).collect() +} + +#[tokio::test] +async fn catch_up_generates_every_due_period_on_create_with_audit_and_push() { + let app = household_with_account().await; + let (id, t) = new_template( + &app, + json!({"day_of_month": 5, "start_period": "2026-07", "amount_ore": 1001, "notes": "Avi"}), + ) + .await; + let rows = generated(&app, id).await; + assert_eq!( + dates(&rows), + ["2026-07-05", "2026-08-05", "2026-09-05", "2026-10-05"] + ); + assert_eq!(t["last_generated_period"], "2026-10"); + assert_eq!(t["next_occurrence_date"], "2026-11-05"); + + let e = expense_of(&app, id, "2026-08").await; + assert_eq!(e["amount_ore"], 1001); + assert_eq!(e["description"], "Hyra"); + assert_eq!(e["notes"], "Avi"); + assert_eq!(e["category_id"], HOME); + assert_eq!(e["split_mode"], "ratio"); + // Ratio rule: the non-payer (member, 4000 bp) is rounded down, the payer absorbs the remainder. + assert_eq!( + e["splits"], + json!([{"user_id": ADMIN_ID, "share_ore": 601, "share_bp": 6000}, + {"user_id": MEMBER_ID, "share_ore": 400, "share_bp": 4000}]) + ); + assert_eq!(e["recurring_template_id"], json!(id.to_string())); + assert_eq!(e["created_by_user_id"], ADMIN_ID); + assert_eq!(e["updated_by_user_id"], ADMIN_ID); + assert_eq!(e["created_at"], NOW); + assert_eq!(e["updated_at"], NOW); + let device: Option = sqlx::query_scalar( + "SELECT updated_by_device_id FROM expenses WHERE recurring_template_id = ? LIMIT 1", + ) + .bind(id.to_string()) + .fetch_one(&app.state.db) + .await + .unwrap(); + assert_eq!(device, None); + + let audit = app.audit.0.lock().unwrap(); + assert_eq!(audit.len(), 4); + for event in audit.iter() { + assert_eq!(event.entity_type, EntityType::Expense); + assert_eq!(event.action, AuditAction::Create); + assert_eq!(event.source, AuditSource::Recurring); + assert_eq!(event.actor_user_id.to_string(), ADMIN_ID); + assert_eq!(event.actor_device_id, None); + assert_eq!(event.before, None); + assert_eq!(event.client_at, NOW); + assert_eq!(event.server_at, NOW); + assert_eq!(event.after["id"], json!(event.entity_id.to_string())); + } + let push = app.push.0.lock().unwrap(); + assert_eq!(push.len(), 4); + for (message, event) in push.iter().zip(audit.iter()) { + assert_eq!(message.push_type, PushType::RecurringExpenseCreated); + assert_eq!(message.entity_id, Some(event.entity_id)); + assert_eq!(message.actor_user_id.to_string(), ADMIN_ID); + assert_eq!(message.count, 1); + } +} + +#[tokio::test] +async fn generated_by_the_template_creator_and_bumps_the_template_version() { + let app = household_with_account().await; + let reply = app + .call( + Method::POST, + "/recurring-templates", + &member_token(), + Some(template_body( + json!({"day_of_month": 1, "start_period": "2026-10"}), + )), + ) + .await; + assert_eq!(reply.status, 201); + let id: Uuid = reply.json["id"].as_str().unwrap().parse().unwrap(); + let e = expense_of(&app, id, "2026-10").await; + assert_eq!(e["created_by_user_id"], MEMBER_ID); + assert!(reply.json["server_version"].as_i64() > e["server_version"].as_i64()); + assert_eq!( + app.push.0.lock().unwrap()[0].actor_user_id.to_string(), + MEMBER_ID + ); +} + +#[tokio::test] +async fn occurrence_is_at_0600_europe_stockholm_in_summer_and_winter() { + let app = household_with_account().await; + let (id, t) = new_template(&app, json!({"day_of_month": 18, "start_period": "2026-10"})).await; + assert_eq!(t["next_occurrence_date"], "2026-10-18"); + app.set_now("2026-10-18T03:59:59.999Z"); // 05:59:59 CEST + assert!( + generate_due_periods(&app.state, id) + .await + .unwrap() + .is_empty() + ); + app.set_now("2026-10-18T04:00:00.000Z"); // 06:00 CEST + let created = generate_due_periods(&app.state, id).await.unwrap(); + assert_eq!(created.len(), 1); + assert_eq!(created[0].period, "2026-10"); + app.set_now("2026-11-18T04:59:59.999Z"); // 05:59:59 CET + assert!( + generate_due_periods(&app.state, id) + .await + .unwrap() + .is_empty() + ); + app.set_now("2026-11-18T05:00:00.000Z"); // 06:00 CET + assert_eq!(generate_due_periods(&app.state, id).await.unwrap().len(), 1); +} + +#[tokio::test] +async fn days_29_30_31_clamp_to_the_last_day_including_february_2027() { + let app = household_with_account().await; + let (d31, _) = new_template(&app, json!({"day_of_month": 31, "start_period": "2026-11"})).await; + let (d30, _) = new_template(&app, json!({"day_of_month": 30, "start_period": "2027-02"})).await; + let (d29, t29) = + new_template(&app, json!({"day_of_month": 29, "start_period": "2027-02"})).await; + assert_eq!(t29["next_occurrence_date"], "2027-02-28"); + app.set_now("2027-05-01T12:00:00.000Z"); + assert_eq!(generate_all_due(&app.state).await.unwrap(), 6 + 3 + 3); + assert_eq!( + dates(&generated(&app, d31).await), + [ + "2026-11-30", + "2026-12-31", + "2027-01-31", + "2027-02-28", + "2027-03-31", + "2027-04-30" + ] + ); + assert_eq!( + dates(&generated(&app, d30).await), + ["2027-02-28", "2027-03-30", "2027-04-30"] + ); + assert_eq!( + dates(&generated(&app, d29).await), + ["2027-02-28", "2027-03-29", "2027-04-29"] + ); + assert_eq!( + template(&app, d31).await["next_occurrence_date"], + "2027-05-31" + ); +} + +#[tokio::test] +async fn mode_default_resolves_the_account_ratio_at_generation_time() { + let app = household_with_account().await; + let (id, _) = new_template( + &app, + json!({"split_mode": "default", "splits": [], "amount_ore": 1000, "day_of_month": 1, + "start_period": "2026-11"}), + ) + .await; + let patch = app + .call( + Method::PATCH, + &format!("/accounts/{ACCOUNT_1}"), + &admin_token(), + Some( + json!({"default_shares": [{"user_id": ADMIN_ID, "share_bp": 7000}, + {"user_id": MEMBER_ID, "share_bp": 3000}]}), + ), + ) + .await; + assert_eq!(patch.status, 200); + app.set_now("2026-11-01T05:00:00.000Z"); + generate_due_periods(&app.state, id).await.unwrap(); + let e = expense_of(&app, id, "2026-11").await; + assert_eq!(e["split_mode"], "default"); + assert_eq!( + e["splits"], + json!([{"user_id": ADMIN_ID, "share_ore": 700, "share_bp": 7000}, + {"user_id": MEMBER_ID, "share_ore": 300, "share_bp": 3000}]) + ); +} + +#[tokio::test] +async fn modes_ratio_full_and_exact_use_the_template_splits() { + let app = household_with_account().await; + let (full, _) = new_template( + &app, + json!({"split_mode": "full", "splits": bp(0, 10000), "amount_ore": 999, "day_of_month": 1, + "start_period": "2026-10"}), + ) + .await; + let e = expense_of(&app, full, "2026-10").await; + assert_eq!( + e["splits"], + json!([{"user_id": ADMIN_ID, "share_ore": 0, "share_bp": 0}, + {"user_id": MEMBER_ID, "share_ore": 999, "share_bp": 10000}]) + ); + let (exact, _) = new_template( + &app, + json!({"split_mode": "exact", "amount_ore": 1000, "day_of_month": 1, "start_period": "2026-10", + "payer_user_id": MEMBER_ID, + "splits": [{"user_id": ADMIN_ID, "share_bp": null, "share_ore": 300}, + {"user_id": MEMBER_ID, "share_bp": null, "share_ore": 700}]}), + ) + .await; + let e = expense_of(&app, exact, "2026-10").await; + assert_eq!(e["payer_user_id"], MEMBER_ID); + assert_eq!( + e["splits"], + json!([{"user_id": ADMIN_ID, "share_ore": 300, "share_bp": null}, + {"user_id": MEMBER_ID, "share_ore": 700, "share_bp": null}]) + ); + let (ratio, _) = new_template( + &app, + json!({"splits": bp(3333, 6667), "amount_ore": 999, "day_of_month": 1, "start_period": "2026-10"}), + ) + .await; + // §9.1 vector: 999, payer A at 3333 → 333 / 666. + let e = expense_of(&app, ratio, "2026-10").await; + assert_eq!(e["splits"][0]["share_ore"], 333); + assert_eq!(e["splits"][1]["share_ore"], 666); +} + +#[tokio::test] +async fn an_existing_period_is_skipped_silently_by_the_unique_index() { + let app = household_with_account().await; + let (id, _) = new_template(&app, json!({"day_of_month": 1, "start_period": "2026-11"})).await; + // Another writer already created 2026-11 (as a racing scheduler run would). + let other = app + .insert_expense(ACCOUNT_1, HOME, 1250000, ADMIN_ID, (750000, 500000), false) + .await; + sqlx::query( + "UPDATE expenses SET recurring_template_id = ?, recurring_period = '2026-11' WHERE id = ?", + ) + .bind(id.to_string()) + .bind(&other) + .execute(&app.state.db) + .await + .unwrap(); + app.set_now("2026-12-01T12:00:00.000Z"); + let created = generate_due_periods(&app.state, id).await.unwrap(); + assert_eq!( + created + .iter() + .map(|g| g.period.as_str()) + .collect::>(), + ["2026-12"] + ); + assert_eq!(generated(&app, id).await.len(), 2); + assert_eq!(app.audit.0.lock().unwrap().len(), 1); + assert_eq!(app.push.0.lock().unwrap().len(), 1); + // Running again creates nothing (idempotent). + assert!( + generate_due_periods(&app.state, id) + .await + .unwrap() + .is_empty() + ); + assert_eq!(template(&app, id).await["last_generated_period"], "2026-12"); +} + +#[tokio::test] +async fn a_deleted_generated_expense_is_not_recreated() { + let app = household_with_account().await; + let (id, _) = new_template(&app, json!({"day_of_month": 1, "start_period": "2026-10"})).await; + sqlx::query("UPDATE expenses SET deleted_at = ? WHERE recurring_template_id = ?") + .bind(NOW) + .bind(id.to_string()) + .execute(&app.state.db) + .await + .unwrap(); + assert!( + generate_due_periods(&app.state, id) + .await + .unwrap() + .is_empty() + ); + // Even if the template's marker were rewound, the tombstone holds the period (unique index, AC-9.5). + sqlx::query("UPDATE recurring_templates SET last_generated_period = NULL WHERE id = ?") + .bind(id.to_string()) + .execute(&app.state.db) + .await + .unwrap(); + assert!( + generate_due_periods(&app.state, id) + .await + .unwrap() + .is_empty() + ); + assert_eq!( + generated(&app, id).await, + [("2026-10-01".to_owned(), "2026-10".to_owned(), true)] + ); +} + +#[tokio::test] +async fn paused_generates_nothing_and_resume_does_not_backfill() { + let app = household_with_account().await; + let (early, _) = + new_template(&app, json!({"day_of_month": 5, "start_period": "2026-10"})).await; + let (late, _) = + new_template(&app, json!({"day_of_month": 20, "start_period": "2026-10"})).await; + for id in [early, late] { + let reply = app + .call( + Method::PATCH, + &format!("/recurring-templates/{id}"), + &admin_token(), + Some(json!({"paused": true})), + ) + .await; + assert_eq!(reply.json["next_occurrence_date"], json!(null)); + } + app.set_now("2027-01-10T12:00:00.000Z"); + assert_eq!(generate_all_due(&app.state).await.unwrap(), 0); + assert!( + generate_due_periods(&app.state, late) + .await + .unwrap() + .is_empty() + ); + + let resume = |id: Uuid| { + app.call( + Method::PATCH, + &format!("/recurring-templates/{id}"), + &admin_token(), + Some(json!({"paused": false})), + ) + }; + let reply = resume(early).await; + assert_eq!(reply.status, 200); + assert_eq!(reply.json["last_generated_period"], "2027-01"); + assert_eq!(reply.json["next_occurrence_date"], "2027-02-05"); + assert_eq!(dates(&generated(&app, early).await), ["2026-10-05"]); + + // Day 20 has not come yet in January: the latest past occurrence is December. + let reply = resume(late).await; + assert_eq!(reply.json["last_generated_period"], "2026-12"); + assert_eq!(reply.json["next_occurrence_date"], "2027-01-20"); + assert!(generated(&app, late).await.is_empty()); + app.set_now("2027-01-20T05:00:00.000Z"); + assert_eq!(generate_all_due(&app.state).await.unwrap(), 1); + assert_eq!(dates(&generated(&app, late).await), ["2027-01-20"]); +} + +#[tokio::test] +async fn resume_before_start_period_keeps_the_start() { + let app = household_with_account().await; + let (id, _) = new_template( + &app, + json!({"day_of_month": 5, "start_period": "2027-03", "paused": true}), + ) + .await; + let reply = app + .call( + Method::PATCH, + &format!("/recurring-templates/{id}"), + &admin_token(), + Some(json!({"paused": false})), + ) + .await; + assert_eq!(reply.json["last_generated_period"], json!(null)); + assert_eq!(reply.json["next_occurrence_date"], "2027-03-05"); +} + +#[tokio::test] +async fn archived_account_continues_and_edits_affect_only_later_expenses() { + let app = household_with_account().await; + let (id, _) = new_template(&app, json!({"day_of_month": 1, "start_period": "2026-10"})).await; + app.call( + Method::POST, + &format!("/accounts/{ACCOUNT_1}/archive"), + &admin_token(), + None, + ) + .await; + let reply = app + .call( + Method::PATCH, + &format!("/recurring-templates/{id}"), + &admin_token(), + // Moving start_period back never regenerates past periods. + Some( + json!({"amount_ore": 2000, "split_mode": "full", "splits": bp(10000, 0), + "start_period": "2026-08"}), + ), + ) + .await; + assert_eq!(reply.status, 200, "{}", reply.text()); + assert_eq!(reply.json["next_occurrence_date"], "2026-11-01"); + app.set_now("2026-11-01T06:00:00.000Z"); + generate_due_periods(&app.state, id).await.unwrap(); + assert_eq!( + dates(&generated(&app, id).await), + ["2026-10-01", "2026-11-01"] + ); + assert_eq!(expense_of(&app, id, "2026-10").await["amount_ore"], 1250000); + let november = expense_of(&app, id, "2026-11").await; + assert_eq!(november["amount_ore"], 2000); + assert_eq!(november["account_id"], ACCOUNT_1); +} + +#[tokio::test] +async fn end_period_stops_generation_and_next_occurrence_becomes_null() { + let app = household_with_account().await; + let (id, t) = new_template( + &app, + json!({"day_of_month": 1, "start_period": "2026-09", "end_period": "2026-11"}), + ) + .await; + assert_eq!(t["next_occurrence_date"], "2026-11-01"); + app.set_now("2027-02-01T12:00:00.000Z"); + generate_due_periods(&app.state, id).await.unwrap(); + assert_eq!( + dates(&generated(&app, id).await), + ["2026-09-01", "2026-10-01", "2026-11-01"] + ); + let t = template(&app, id).await; + assert_eq!(t["last_generated_period"], "2026-11"); + assert_eq!(t["next_occurrence_date"], json!(null)); +} + +#[tokio::test] +async fn deleted_templates_generate_nothing() { + let app = household_with_account().await; + let (id, _) = new_template(&app, json!({"day_of_month": 1, "start_period": "2026-11"})).await; + app.call( + Method::DELETE, + &format!("/recurring-templates/{id}"), + &admin_token(), + None, + ) + .await; + app.set_now("2027-01-01T12:00:00.000Z"); + assert!( + generate_due_periods(&app.state, id) + .await + .unwrap() + .is_empty() + ); + assert_eq!(generate_all_due(&app.state).await.unwrap(), 0); + assert!( + generate_due_periods(&app.state, Uuid::now_v7()) + .await + .unwrap() + .is_empty() + ); +} diff --git a/server/crates/kvitt-server/tests/core_api/invites.rs b/server/crates/kvitt-server/tests/core_api/invites.rs new file mode 100644 index 0000000..012ce63 --- /dev/null +++ b/server/crates/kvitt-server/tests/core_api/invites.rs @@ -0,0 +1,366 @@ +//! E-2, E-3 accept (all three kinds), E-12 create, E-13 list, E-14 cancel (FR-1, FR-2, SPEC §8.3). + +use axum::http::Method; +use chrono::Duration; +use kvitt_server::api::invites::{display_code, insert_invite}; +use kvitt_server::time::{Clock, parse_ts}; +use kvitt_server::wire::InviteKind; +use serde_json::{Value, json}; + +use crate::common::*; + +async fn new_invite(app: &TestApp, kind: InviteKind, target: Option<&str>) -> String { + let mut conn = app.state.db.acquire().await.unwrap(); + let created_by = (kind == InviteKind::Partner).then(|| ADMIN_ID.parse().unwrap()); + insert_invite( + &mut conn, + kind, + target.map(|t| t.parse().unwrap()), + created_by, + app.clock.now(), + ) + .await + .unwrap() + .code +} + +fn accept_body(code: &str, display_name: Option<&str>) -> Value { + let mut body = json!({ "code": code, "device_name": "Galaxy S26" }); + if let Some(name) = display_name { + body["display_name"] = json!(name); + } + body +} + +async fn accept(app: &TestApp, body: Value) -> Reply { + let mut request = req(Method::POST, "/invites/accept", None, Some(body)); + request + .headers_mut() + .insert("x-kvitt-app-version", "41".parse().unwrap()); + app.send(request).await +} + +#[tokio::test] +async fn e3_admin_setup_creates_the_admin_and_e2_turns_initialized() { + let app = TestApp::empty().await; + let status = app.public(Method::GET, "/setup/status", None).await; + assert_eq!(status.json, json!({"initialized": false})); + + let code = new_invite(&app, InviteKind::AdminSetup, None).await; + // Codes are normalised (§8.3): lowercase, hyphens and spaces are accepted. + let typed = display_code(&code).to_lowercase().replace('-', " - "); + let reply = accept(&app, accept_body(&typed, Some(" Jacob "))).await; + assert_eq!(reply.status, 200, "{}", reply.text()); + assert_like_example( + &reply.json, + "E-3-invites-accept/response.json", + &[ + "id", + "user_id", + "token", + "created_at", + "updated_at", + "last_seen_at", + "server_version", + ], + ); + let token = reply.json["token"].as_str().unwrap().to_owned(); + assert!(kvitt_server::auth::is_token_shaped(&token)); + assert_eq!(reply.json["user"]["role"], "admin"); + assert_eq!(reply.json["user"]["display_name"], "Jacob"); + assert_eq!(reply.json["device"]["app_version_code"], 41); + + // The token works; only its hash is stored (§8.2). + let me = app.call(Method::GET, "/me", &token, None).await; + assert_eq!(me.status, 200); + let stored: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM devices WHERE token_hash = ?") + .bind(kvitt_server::auth::sha256_hex(&token)) + .fetch_one(&app.state.db) + .await + .unwrap(); + assert_eq!(stored, 1); + let status = app.public(Method::GET, "/setup/status", None).await; + assert_eq!(status.json, json!({"initialized": true})); + + // Single use (AC-2.3). + let again = accept(&app, accept_body(&code, Some("Jacob"))).await; + assert_error(&again, 400, "invite_invalid"); +} + +#[tokio::test] +async fn e3_partner_creates_the_member_and_device_pairs_an_existing_user() { + let app = TestApp::admin_only().await; + let code = new_invite(&app, InviteKind::Partner, None).await; + let reply = accept(&app, accept_body(&code, Some("Anna"))).await; + assert_eq!(reply.status, 200, "{}", reply.text()); + assert_eq!(reply.json["user"]["role"], "member"); + assert_eq!(reply.json["household"].as_array().unwrap().len(), 2); + let member_id = reply.json["user"]["id"].as_str().unwrap().to_owned(); + + let code = new_invite(&app, InviteKind::Device, Some(&member_id)).await; + // display_name is ignored for device invites. + let reply = accept(&app, accept_body(&code, None)).await; + assert_eq!(reply.status, 200, "{}", reply.text()); + assert_eq!(reply.json["user"]["id"], json!(member_id)); + assert_eq!(reply.json["user"]["display_name"], "Anna"); + assert_eq!(reply.json["device"]["is_current"], true); + assert_eq!(app.scalar_i64("SELECT COUNT(*) FROM users").await, 2); +} + +#[tokio::test] +async fn e3_invite_invalid_for_unknown_expired_used_cancelled_and_malformed_codes() { + let app = TestApp::admin_only().await; + // Unknown and malformed. + for code in ["7K3MQX9D2HPA", "short", "7K3MQX9D2HPU", ""] { + let reply = accept(&app, accept_body(code, Some("Anna"))).await; + assert_error(&reply, 400, "invite_invalid"); + } + // Expired: 72 hours after creation. + let code = new_invite(&app, InviteKind::Partner, None).await; + app.clock.set(parse_ts(NOW).unwrap() + Duration::hours(72)); + let reply = accept(&app, accept_body(&code, Some("Anna"))).await; + assert_error(&reply, 400, "invite_invalid"); + assert_eq!( + reply.json["message"], + "the invite code is unknown, expired, used or cancelled" + ); +} + +#[tokio::test] +async fn e3_invite_invalid_for_a_cancelled_code_and_one_hour_before_expiry_it_works() { + let app = TestApp::admin_only().await; + let cancelled = new_invite(&app, InviteKind::Partner, None).await; + // A new partner invite cancels the pending one (AC-2.1). + let created = app + .call( + Method::POST, + "/invites", + &admin_token(), + Some(json!({"kind": "partner"})), + ) + .await; + assert_eq!(created.status, 201); + let reply = accept(&app, accept_body(&cancelled, Some("Anna"))).await; + assert_error(&reply, 400, "invite_invalid"); + + app.clock.set(parse_ts(NOW).unwrap() + Duration::hours(71)); + let code = created.json["code"].as_str().unwrap(); + let reply = accept(&app, accept_body(code, Some("Anna"))).await; + assert_eq!(reply.status, 200, "{}", reply.text()); +} + +#[tokio::test] +async fn e3_invalid_request_and_validation_failed() { + let app = TestApp::admin_only().await; + let code = new_invite(&app, InviteKind::Partner, None).await; + // display_name is required for partner (and admin_setup) invites. + let reply = accept(&app, accept_body(&code, None)).await; + assert_field_error(&reply, 400, "invalid_request", "display_name"); + // Missing device_name, wrong type. + let reply = accept(&app, json!({"code": code})).await; + assert_error(&reply, 400, "invalid_request"); + let reply = accept(&app, json!({"code": 12, "device_name": "x"})).await; + assert_error(&reply, 400, "invalid_request"); + // Limits of §9.6. + let mut body = accept_body(&code, Some("Anna")); + body["device_name"] = json!(" "); + assert_field_error( + &accept(&app, body).await, + 422, + "validation_failed", + "device_name", + ); + let body = accept_body(&code, Some(&"a".repeat(41))); + assert_field_error( + &accept(&app, body).await, + 422, + "validation_failed", + "display_name", + ); + // Nothing was consumed by the failures. + let reply = accept(&app, accept_body(&code, Some("Anna"))).await; + assert_eq!(reply.status, 200); +} + +#[tokio::test] +async fn e3_household_full_when_two_users_exist() { + let app = TestApp::admin_only().await; + let code = new_invite(&app, InviteKind::Partner, None).await; + app.insert_user(MEMBER_ID, "member", "Anna", 12).await; + let reply = accept(&app, accept_body(&code, Some("Bertil"))).await; + assert_error(&reply, 409, "household_full"); +} + +#[tokio::test] +async fn e12_partner_invite_matches_the_example() { + let app = TestApp::admin_only().await; + let reply = app + .call( + Method::POST, + "/invites", + &admin_token(), + Some(example("E-12-create-invite/request.json")), + ) + .await; + assert_eq!(reply.status, 201, "{}", reply.text()); + assert_like_example( + &reply.json, + "E-12-create-invite/response.json", + &["id", "code", "link", "created_at", "expires_at"], + ); + let code = reply.json["code"].as_str().unwrap(); + assert_eq!(code.len(), 12); + assert_eq!( + reply.json["link"], + json!(format!("https://icehack.asuscomm.com:8443/i/{code}")) + ); + assert_eq!(reply.json["created_at"], NOW); + assert_eq!(reply.json["expires_at"], "2026-10-20T12:00:00.000Z"); + // Only the hash is stored. + let hashes: Vec = sqlx::query_scalar("SELECT code_hash FROM invites") + .fetch_all(&app.state.db) + .await + .unwrap(); + assert_eq!(hashes, vec![kvitt_server::auth::sha256_hex(code)]); +} + +#[tokio::test] +async fn e12_device_invite_for_either_user_and_its_errors() { + let app = TestApp::household().await; + let reply = app + .call( + Method::POST, + "/invites", + &admin_token(), + Some(json!({"kind": "device", "target_user_id": MEMBER_ID})), + ) + .await; + assert_eq!(reply.status, 201); + assert_eq!(reply.json["kind"], "device"); + assert_eq!(reply.json["target_user_id"], MEMBER_ID); + + let cases = [ + (json!({"kind": "device"}), 400, "invalid_request"), + ( + json!({"kind": "device", "target_user_id": null}), + 400, + "invalid_request", + ), + ( + json!({"kind": "partner", "target_user_id": MEMBER_ID}), + 400, + "invalid_request", + ), + (json!({"kind": "admin_setup"}), 400, "invalid_request"), + (json!({}), 400, "invalid_request"), + ( + json!({"kind": "device", "target_user_id": UNKNOWN_ID}), + 404, + "not_found", + ), + (json!({"kind": "partner"}), 409, "household_full"), + ]; + for (body, status, code) in cases { + let reply = app + .call(Method::POST, "/invites", &admin_token(), Some(body.clone())) + .await; + assert_error(&reply, status, code); + } + let reply = app + .call( + Method::POST, + "/invites", + &member_token(), + Some(json!({"kind": "device", "target_user_id": MEMBER_ID})), + ) + .await; + assert_error(&reply, 403, "forbidden"); +} + +#[tokio::test] +async fn e13_lists_pending_invites_only() { + let app = TestApp::household().await; + let create = |kind: &'static str| { + let body = if kind == "device" { + json!({"kind": "device", "target_user_id": MEMBER_ID}) + } else { + json!({"kind": kind}) + }; + app.call(Method::POST, "/invites", &admin_token(), Some(body)) + }; + // Expired: created 73 hours ago. + app.clock.set(parse_ts(NOW).unwrap() - Duration::hours(73)); + create("device").await; + // Cancelled and accepted. + app.clock.set(parse_ts(NOW).unwrap() - Duration::hours(4)); + let cancelled = create("device").await; + let used = create("device").await; + app.set_now("2026-10-17T09:00:00.000Z"); + let pending = create("device").await; + app.set_now(NOW); + let id = cancelled.json["id"].as_str().unwrap(); + app.call( + Method::DELETE, + &format!("/invites/{id}"), + &admin_token(), + None, + ) + .await; + let code = used.json["code"].as_str().unwrap(); + let accepted = app + .public( + Method::POST, + "/invites/accept", + Some(json!({"code": code, "device_name": "Tablet"})), + ) + .await; + assert_eq!(accepted.status, 200); + + let reply = app + .call(Method::GET, "/invites", &admin_token(), None) + .await; + assert_eq!(reply.status, 200); + assert_like_example(&reply.json, "E-13-list-invites/response.json", &["id"]); + assert_eq!(reply.json["invites"][0]["id"], pending.json["id"]); + + let reply = app + .call(Method::GET, "/invites", &member_token(), None) + .await; + assert_error(&reply, 403, "forbidden"); +} + +#[tokio::test] +async fn e14_cancel_is_idempotent_and_its_errors() { + let app = TestApp::admin_only().await; + let created = app + .call( + Method::POST, + "/invites", + &admin_token(), + Some(json!({"kind": "partner"})), + ) + .await; + let path = format!("/invites/{}", created.json["id"].as_str().unwrap()); + for _ in 0..2 { + let reply = app.call(Method::DELETE, &path, &admin_token(), None).await; + assert_eq!(reply.status, 204); + assert!(reply.bytes.is_empty()); + } + let listed = app + .call(Method::GET, "/invites", &admin_token(), None) + .await; + assert_eq!(listed.json, json!({"invites": []})); + let reply = app + .call( + Method::DELETE, + &format!("/invites/{UNKNOWN_ID}"), + &admin_token(), + None, + ) + .await; + assert_error(&reply, 404, "not_found"); + + let app = TestApp::household().await; + let reply = app.call(Method::DELETE, &path, &member_token(), None).await; + assert_error(&reply, 403, "forbidden"); +} diff --git a/server/crates/kvitt-server/tests/core_api/landing.rs b/server/crates/kvitt-server/tests/core_api/landing.rs new file mode 100644 index 0000000..073e816 --- /dev/null +++ b/server/crates/kvitt-server/tests/core_api/landing.rs @@ -0,0 +1,84 @@ +//! E-4 invite landing page (AC-2.5, copy of SPEC §13.3). + +use axum::body::Body; +use axum::http::{Method, Request}; + +use crate::common::*; + +async fn landing(app: &TestApp, path: &str, accept_language: Option<&str>) -> Reply { + let mut builder = Request::builder().method(Method::GET).uri(path); + if let Some(lang) = accept_language { + builder = builder.header("accept-language", lang); + } + app.send(builder.body(Body::empty()).unwrap()).await +} + +#[tokio::test] +async fn e4_english_by_default_with_both_links_and_the_grouped_code() { + let app = TestApp::empty().await; + std::fs::create_dir_all(app.data_dir().join("apk")).unwrap(); + std::fs::write( + app.data_dir().join("apk/latest.json"), + example("E-5-apk-latest/response.json").to_string(), + ) + .unwrap(); + let reply = landing(&app, "/i/7K3MQX9D2HPA", Some("en-GB,en;q=0.9")).await; + assert_eq!(reply.status, 200); + assert_eq!(reply.headers["content-type"], "text/html; charset=utf-8"); + assert_eq!(reply.headers["referrer-policy"], "no-referrer"); + let html = reply.text(); + assert!(html.contains("")); + assert!(html.contains("

Kvitt

")); + assert!(html.contains("Install Kvitt, then open this link again on your phone.")); + assert!(html.contains(">Download Kvitt")); + assert!(html.contains(">Open in Kvitt")); + assert!(html.contains("7K3M-QX9D-2HPA")); + assert!(html.contains( + "href=\"kvitt://invite?server=https%3A%2F%2Ficehack%2Easuscomm%2Ecom%3A8443&code=7K3MQX9D2HPA\"" + )); + assert!(html.contains("href=\"https://icehack.asuscomm.com:8443/apk/files/kvitt-412.apk\"")); +} + +#[tokio::test] +async fn e4_swedish_when_accept_language_prefers_sv() { + let app = TestApp::empty().await; + for header in ["sv-SE,sv;q=0.9,en;q=0.8", "en;q=0.5,sv"] { + let html = landing(&app, "/i/7K3MQX9D2HPA", Some(header)).await.text(); + assert!(html.contains(""), "{header}"); + assert!( + html.contains("Installera Kvitt och öppna sedan den här länken igen på din telefon.") + ); + assert!(html.contains(">Öppna i Kvitt")); + // No APK published yet: no download button. + assert!(!html.contains("Ladda ner Kvitt")); + } + let html = landing(&app, "/i/7K3MQX9D2HPA", None).await.text(); + assert!(html.contains("")); + // The Swedish download label once an APK exists. + std::fs::create_dir_all(app.data_dir().join("apk")).unwrap(); + std::fs::write( + app.data_dir().join("apk/latest.json"), + example("E-5-apk-latest/response.json").to_string(), + ) + .unwrap(); + let html = landing(&app, "/i/7K3MQX9D2HPA", Some("sv")).await.text(); + assert!(html.contains(">Ladda ner Kvitt")); +} + +#[tokio::test] +async fn e4_does_not_check_validity_and_404s_outside_1_to_32_characters() { + let app = TestApp::admin_only().await; + // Unknown and odd codes get the same page; nothing personal is on it. + for code in ["X", "not-a-real-code", &"A".repeat(32)] { + let reply = landing(&app, &format!("/i/{code}"), None).await; + assert_eq!(reply.status, 200, "{code}"); + assert!(!reply.text().contains("Jacob")); + } + let reply = landing(&app, &format!("/i/{}", "A".repeat(33)), None).await; + assert_eq!(reply.status, 404); + assert_eq!(reply.headers["content-type"], "text/html; charset=utf-8"); + // HTML in the code is escaped. + let reply = landing(&app, "/i/%3Cscript%3E", None).await; + assert_eq!(reply.status, 200); + assert!(!reply.text().contains("