M3: server core API (E-3 to E-30, E-33), landing page, pictures, rate limiting, recurring generation, bootstrap and pair-device

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-17 22:00:58 +02:00
parent 6dce4c73a9
commit 7869ec48d8
38 changed files with 7623 additions and 141 deletions
@@ -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);
}
@@ -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<u8> = (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");
}
}
@@ -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");
}
@@ -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"
);
}
}
@@ -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<Vec<AuditEvent>>);
impl AuditWriter for RecordingAudit {
fn write<'a>(
&'a self,
_conn: &'a mut SqliteConnection,
event: AuditEvent,
) -> BoxFuture<'a, Result<Uuid, kvitt_server::error::AppError>> {
self.0.lock().unwrap().push(event);
Box::pin(async { Ok(Uuid::now_v7()) })
}
}
/// Records every enqueued push.
#[derive(Default)]
pub struct RecordingPush(pub Mutex<Vec<PushMessage>>);
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<RecordingAudit>,
pub push: Arc<RecordingPush>,
pub router: Router,
}
pub struct Reply {
pub status: StatusCode,
pub headers: HeaderMap,
pub bytes: Vec<u8>,
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<Body>) -> Reply {
let mut request = request;
if request
.extensions()
.get::<ConnectInfo<SocketAddr>>()
.is_none()
{
request
.extensions_mut()
.insert(ConnectInfo(CLIENT.parse::<SocketAddr>().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<Value>,
) -> impl std::future::Future<Output = Reply> + 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<Value>,
) -> impl std::future::Future<Output = Reply> + 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<Value>) -> Request<Body> {
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<u8>,
) -> Request<Body> {
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/<name>` 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"];
@@ -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<String>, Option<String>, Option<String>) = 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<String>>("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");
}
}
@@ -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<String>)> = 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<String> = 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::<Vec<_>>(),
["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()
);
}
@@ -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<String> = 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");
}
@@ -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("<html lang=\"en\">"));
assert!(html.contains("<h1>Kvitt</h1>"));
assert!(html.contains("Install Kvitt, then open this link again on your phone."));
assert!(html.contains(">Download Kvitt</a>"));
assert!(html.contains(">Open in Kvitt</a>"));
assert!(html.contains("7K3M-QX9D-2HPA"));
assert!(html.contains(
"href=\"kvitt://invite?server=https%3A%2F%2Ficehack%2Easuscomm%2Ecom%3A8443&amp;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("<html lang=\"sv\">"), "{header}");
assert!(
html.contains("Installera Kvitt och öppna sedan den här länken igen på din telefon.")
);
assert!(html.contains(">Öppna i Kvitt</a>"));
// 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("<html lang=\"en\">"));
// 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</a>"));
}
#[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("<SCRIPT>"));
}
@@ -0,0 +1,111 @@
//! SPEC §8.7: logs carry no token, invite code, display name, description, note or amount.
use std::io::Write;
use std::sync::{Arc, Mutex};
use axum::http::Method;
use serde_json::json;
use crate::common::*;
use crate::recurring::{household_with_account, template_body};
#[derive(Clone, Default)]
struct Captured(Arc<Mutex<Vec<u8>>>);
impl Write for Captured {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
self.0.lock().unwrap().extend_from_slice(buf);
Ok(buf.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
#[tokio::test]
async fn logs_hold_no_personal_content() {
let captured = Captured::default();
let writer = captured.clone();
let subscriber = tracing_subscriber::fmt()
.json()
.with_max_level(tracing::Level::TRACE)
.with_current_span(true)
.with_writer(move || writer.clone())
.finish();
// Global, not thread-local: tracing caches callsite interest process-wide, and the other tests of this binary run
// in parallel. Their log lines are captured too, which only widens the check.
tracing::subscriber::set_global_default(subscriber).unwrap();
let app = household_with_account().await;
let invite = app
.call(
Method::POST,
"/invites",
&admin_token(),
Some(json!({"kind": "device", "target_user_id": MEMBER_ID})),
)
.await;
let code = invite.json["code"].as_str().unwrap().to_owned();
let paired = app
.public(
Method::POST,
"/invites/accept",
Some(json!({"code": code, "device_name": "Hemligtelefon"})),
)
.await;
let new_token = paired.json["token"].as_str().unwrap().to_owned();
app.public(
Method::POST,
"/invites/accept",
Some(json!({"code": code, "device_name": "x"})),
)
.await;
app.call(
Method::PATCH,
"/me",
&new_token,
Some(json!({"display_name": "Hemlignamn"})),
)
.await;
app.call(
Method::POST,
"/accounts",
&admin_token(),
Some(json!({"name": "Hemligkonto", "default_shares": [
{"user_id": ADMIN_ID, "share_bp": 5000}, {"user_id": MEMBER_ID, "share_bp": 5000}]})),
)
.await;
let reply = app
.call(
Method::POST,
"/recurring-templates",
&admin_token(),
Some(template_body(json!({
"description": "Hemligbeskrivning", "notes": "Hemliganteckning", "amount_ore": 987654,
"day_of_month": 1, "start_period": "2026-09"
}))),
)
.await;
assert_eq!(reply.status, 201);
app.call(Method::GET, &format!("/i/{code}"), &admin_token(), None)
.await;
app.public(Method::GET, &format!("/i/{code}"), None).await;
let logs = String::from_utf8(captured.0.lock().unwrap().clone()).unwrap();
assert!(logs.contains("\"route\":\"/invites/accept\""), "{logs}");
assert!(logs.contains("\"route\":\"/i/{code}\""), "{logs}");
for secret in [
new_token.as_str(),
code.as_str(),
"Hemligtelefon",
"Hemlignamn",
"Hemligkonto",
"Hemligbeskrivning",
"Hemliganteckning",
"987654",
"Jacob",
"Anna",
] {
assert!(!logs.contains(secret), "log contains {secret}:\n{logs}");
}
}
@@ -0,0 +1,21 @@
//! M3 integration tests: every endpoint of E-2 to E-30 and E-33 with each error code SPEC §14.3.4 lists for it, the
//! recurring generation cases of SPEC §9.5, rate limiting (§8.6, AC-2.12), pictures (AC-4.4), the landing page
//! (AC-2.5), APK serving (§10.6), the `bootstrap` and `pair-device` subcommands (FR-1) and the logging rule (§8.7).
//!
//! One test binary (faster to build than one per file); `common` is the harness.
mod accounts;
mod apk;
mod categories;
mod cli;
mod common;
mod devices;
mod generation;
mod invites;
mod landing;
mod logging;
mod me;
mod pictures;
mod proposal;
mod rate_limit;
mod recurring;
@@ -0,0 +1,83 @@
//! E-7 `GET /me` and E-8 `PATCH /me` (FR-4).
use axum::http::Method;
use serde_json::json;
use crate::common::*;
#[tokio::test]
async fn e7_returns_user_device_household_and_public_url() {
let app = TestApp::household().await;
sqlx::query("UPDATE users SET picture_version = 2 WHERE id = ?")
.bind(MEMBER_ID)
.execute(&app.state.db)
.await
.unwrap();
let mut request = req(Method::GET, "/me", Some(&admin_token()), None);
request
.headers_mut()
.insert("x-kvitt-app-version", "41".parse().unwrap());
let reply = app.send(request).await;
assert_eq!(reply.status, 200);
assert_like_example(
&reply.json,
"E-7-me/response.json",
&["created_at", "updated_at", "last_seen_at"],
);
assert_eq!(reply.json["device"]["id"], ADMIN_DEVICE_ID);
assert_eq!(reply.json["device"]["is_current"], true);
let member = app.call(Method::GET, "/me", &member_token(), None).await;
assert_eq!(member.json["user"]["id"], MEMBER_ID);
assert_eq!(member.json["user"]["role"], "member");
}
#[tokio::test]
async fn e8_changes_the_own_display_name_with_a_new_server_version() {
let app = TestApp::household().await;
let reply = app
.call(
Method::PATCH,
"/me",
&admin_token(),
Some(json!({"display_name": " Jacob H "})),
)
.await;
assert_eq!(reply.status, 200, "{}", reply.text());
assert_like_example(&reply.json, "E-8-patch-me/response.json", ROW_VOLATILE);
assert_eq!(reply.json["updated_at"], NOW);
assert!(reply.json["server_version"].as_i64().unwrap() > 12);
// The member's name is untouched (another user's profile cannot be edited, §8.4).
let member = app.call(Method::GET, "/me", &member_token(), None).await;
assert_eq!(member.json["user"]["display_name"], "Anna");
}
#[tokio::test]
async fn e8_invalid_request_and_validation_failed() {
let app = TestApp::household().await;
for body in [json!({}), json!({"display_name": 5}), json!({"other": "x"})] {
let reply = app
.call(Method::PATCH, "/me", &admin_token(), Some(body))
.await;
assert_error(&reply, 400, "invalid_request");
}
for name in ["", " ", &"å".repeat(41)] {
let reply = app
.call(
Method::PATCH,
"/me",
&admin_token(),
Some(json!({"display_name": name})),
)
.await;
assert_field_error(&reply, 422, "validation_failed", "display_name");
}
let ok = app
.call(
Method::PATCH,
"/me",
&admin_token(),
Some(json!({"display_name": "å".repeat(40)})),
)
.await;
assert_eq!(ok.status, 200);
}
@@ -0,0 +1,293 @@
//! E-9 upload, E-10 remove, E-11 download (AC-4.4, SPEC §10.4 file layout).
use std::io::Cursor;
use axum::http::Method;
use image::codecs::jpeg::JpegEncoder;
use image::{DynamicImage, ImageFormat, Rgb, RgbImage, Rgba, RgbaImage};
use serde_json::json;
use crate::common::*;
const RED: Rgb<u8> = Rgb([220, 20, 20]);
const BLUE: Rgb<u8> = Rgb([20, 20, 220]);
/// A landscape image, top half red and bottom half blue.
fn two_tone(width: u32, height: u32) -> RgbImage {
RgbImage::from_fn(
width,
height,
|_, y| if y < height / 2 { RED } else { BLUE },
)
}
fn encode(image: DynamicImage, format: ImageFormat) -> Vec<u8> {
let mut out = Cursor::new(Vec::new());
image.write_to(&mut out, format).unwrap();
out.into_inner()
}
fn jpeg(image: &RgbImage) -> Vec<u8> {
let mut out = Vec::new();
JpegEncoder::new_with_quality(&mut out, 95)
.encode_image(image)
.unwrap();
out
}
/// Inserts an APP1 EXIF segment with the given orientation right after the JPEG SOI marker.
fn with_exif_orientation(jpeg: &[u8], orientation: u16) -> Vec<u8> {
let mut tiff = Vec::new();
tiff.extend_from_slice(b"MM\0\x2a\0\0\0\x08"); // big endian, IFD at offset 8
tiff.extend_from_slice(&1u16.to_be_bytes()); // one entry
tiff.extend_from_slice(&0x0112u16.to_be_bytes()); // Orientation
tiff.extend_from_slice(&3u16.to_be_bytes()); // SHORT
tiff.extend_from_slice(&1u32.to_be_bytes()); // count
tiff.extend_from_slice(&orientation.to_be_bytes());
tiff.extend_from_slice(&[0, 0]);
tiff.extend_from_slice(&0u32.to_be_bytes()); // no next IFD
let mut payload = b"Exif\0\0".to_vec();
payload.extend_from_slice(&tiff);
let mut out = jpeg[..2].to_vec();
out.extend_from_slice(&[0xFF, 0xE1]);
out.extend_from_slice(&u16::try_from(payload.len() + 2).unwrap().to_be_bytes());
out.extend_from_slice(&payload);
out.extend_from_slice(&jpeg[2..]);
out
}
async fn upload(app: &TestApp, tok: &str, content_type: Option<&str>, body: Vec<u8>) -> Reply {
app.send(raw(Method::PUT, "/me/picture", tok, content_type, body))
.await
}
fn decode(bytes: &[u8]) -> RgbImage {
image::load_from_memory_with_format(bytes, ImageFormat::Jpeg)
.unwrap()
.to_rgb8()
}
fn close(a: Rgb<u8>, b: Rgb<u8>) -> bool {
a.0.iter().zip(b.0).all(|(x, y)| x.abs_diff(y) < 40)
}
fn files(app: &TestApp, user: &str) -> Vec<String> {
let mut names: Vec<String> = std::fs::read_dir(app.data_dir().join("pictures").join(user))
.unwrap()
.map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
.collect();
names.sort();
names
}
#[tokio::test]
async fn e9_crops_resizes_and_reencodes_and_e11_serves_both_sizes() {
let app = TestApp::household().await;
let png = encode(
DynamicImage::ImageRgb8(two_tone(600, 400)),
ImageFormat::Png,
);
let reply = upload(&app, &admin_token(), Some("image/png"), png).await;
assert_eq!(reply.status, 200, "{}", reply.text());
assert_like_example(
&reply.json,
"E-9-put-me-picture/response.json",
ROW_VOLATILE,
);
assert_eq!(reply.json["picture_version"], 1);
assert_eq!(files(&app, ADMIN_ID), ["1-128.jpg", "1-512.jpg"]);
let path = format!("/users/{ADMIN_ID}/picture");
let big = app.call(Method::GET, &path, &member_token(), None).await;
assert_eq!(big.status, 200);
assert_eq!(big.headers["content-type"], "image/jpeg");
assert_eq!(big.headers["etag"], "\"1\"");
assert_eq!(
big.headers["cache-control"],
"private, max-age=31536000, immutable"
);
let img = decode(&big.bytes);
assert_eq!(img.dimensions(), (512, 512));
// Centre crop of a 600 × 400 image keeps the full height: red on top, blue at the bottom.
assert!(close(*img.get_pixel(256, 10), RED));
assert!(close(*img.get_pixel(256, 500), BLUE));
let small = app
.call(
Method::GET,
&format!("{path}?size=128&v=1"),
&admin_token(),
None,
)
.await;
assert_eq!(decode(&small.bytes).dimensions(), (128, 128));
let explicit = app
.call(
Method::GET,
&format!("{path}?size=512"),
&admin_token(),
None,
)
.await;
assert_eq!(explicit.bytes, big.bytes);
// A second upload increments the version and deletes the older files.
let webp = encode(
DynamicImage::ImageRgb8(two_tone(300, 300)),
ImageFormat::WebP,
);
let reply = upload(&app, &admin_token(), Some("image/webp"), webp).await;
assert_eq!(reply.status, 200, "{}", reply.text());
assert_eq!(reply.json["picture_version"], 2);
assert_eq!(files(&app, ADMIN_ID), ["2-128.jpg", "2-512.jpg"]);
}
#[tokio::test]
async fn e9_applies_exif_orientation_and_strips_metadata() {
let app = TestApp::household().await;
// Orientation 6: display rotated 90° clockwise, so the red top edge becomes the right edge.
let body = with_exif_orientation(&jpeg(&two_tone(80, 40)), 6);
let reply = upload(&app, &member_token(), Some("image/jpeg"), body).await;
assert_eq!(reply.status, 200, "{}", reply.text());
let stored = std::fs::read(
app.data_dir()
.join(format!("pictures/{MEMBER_ID}/1-512.jpg")),
)
.unwrap();
let img = decode(&stored);
assert!(
close(*img.get_pixel(500, 256), RED),
"{:?}",
img.get_pixel(500, 256)
);
assert!(
close(*img.get_pixel(10, 256), BLUE),
"{:?}",
img.get_pixel(10, 256)
);
assert!(!stored.windows(4).any(|w| w == b"Exif"));
}
#[tokio::test]
async fn e9_flattens_transparency_onto_white() {
let app = TestApp::household().await;
let clear = RgbaImage::from_pixel(64, 64, Rgba([0, 0, 0, 0]));
let png = encode(DynamicImage::ImageRgba8(clear), ImageFormat::Png);
let reply = upload(&app, &admin_token(), Some("image/png; charset=binary"), png).await;
assert_eq!(reply.status, 200, "{}", reply.text());
let stored = std::fs::read(
app.data_dir()
.join(format!("pictures/{ADMIN_ID}/1-128.jpg")),
)
.unwrap();
assert!(close(
*decode(&stored).get_pixel(64, 64),
Rgb([255, 255, 255])
));
}
#[tokio::test]
async fn e9_unsupported_media_type_image_invalid_and_payload_too_large() {
let app = TestApp::household().await;
let png = encode(DynamicImage::ImageRgb8(two_tone(10, 10)), ImageFormat::Png);
for ct in [
None,
Some("image/gif"),
Some("application/octet-stream"),
Some("text/plain"),
] {
let reply = upload(&app, &admin_token(), ct, png.clone()).await;
assert_error(&reply, 415, "unsupported_media_type");
}
// Garbage, a PNG declared as JPEG, an empty body and more than 8000 pixels wide.
let wide = encode(
DynamicImage::ImageLuma8(image::GrayImage::new(8001, 1)),
ImageFormat::Png,
);
for (ct, body) in [
("image/png", b"not a picture".to_vec()),
("image/jpeg", png.clone()),
("image/webp", Vec::new()),
("image/png", wide),
] {
let reply = upload(&app, &admin_token(), Some(ct), body).await;
assert_error(&reply, 422, "image_invalid");
}
let tall_ok = encode(
DynamicImage::ImageLuma8(image::GrayImage::new(1, 8000)),
ImageFormat::Png,
);
assert_eq!(
upload(&app, &admin_token(), Some("image/png"), tall_ok)
.await
.status,
200
);
let too_big = vec![0u8; 5 * 1024 * 1024 + 1];
let reply = upload(&app, &admin_token(), Some("image/png"), too_big).await;
assert_error(&reply, 413, "payload_too_large");
// Nothing changed by the failures.
let me = app.call(Method::GET, "/me", &admin_token(), None).await;
assert_eq!(me.json["user"]["picture_version"], 1);
}
#[tokio::test]
async fn e10_removes_idempotently_and_the_next_upload_continues_the_count() {
let app = TestApp::household().await;
let png = encode(DynamicImage::ImageRgb8(two_tone(20, 20)), ImageFormat::Png);
upload(&app, &admin_token(), Some("image/png"), png.clone()).await;
let reply = app
.call(Method::DELETE, "/me/picture", &admin_token(), None)
.await;
assert_eq!(reply.status, 200);
assert_like_example(
&reply.json,
"E-10-delete-me-picture/response.json",
ROW_VOLATILE,
);
let again = app
.call(Method::DELETE, "/me/picture", &admin_token(), None)
.await;
assert_eq!(again.json, reply.json, "idempotent: no new version");
assert!(!files(&app, ADMIN_ID).iter().any(|f| f.ends_with(".jpg")));
let get = app
.call(
Method::GET,
&format!("/users/{ADMIN_ID}/picture"),
&admin_token(),
None,
)
.await;
assert_error(&get, 404, "not_found");
let reply = upload(&app, &admin_token(), Some("image/png"), png).await;
assert_eq!(reply.json["picture_version"], 2);
assert_eq!(files(&app, ADMIN_ID), ["2-128.jpg", "2-512.jpg"]);
// Never uploaded: removing is still fine.
let member = app
.call(Method::DELETE, "/me/picture", &member_token(), None)
.await;
assert_eq!(member.status, 200);
assert_eq!(member.json["picture_version"], json!(null));
}
#[tokio::test]
async fn e11_not_found_and_invalid_request() {
let app = TestApp::household().await;
for path in [
format!("/users/{UNKNOWN_ID}/picture"),
format!("/users/{MEMBER_ID}/picture"),
] {
let reply = app.call(Method::GET, &path, &admin_token(), None).await;
assert_error(&reply, 404, "not_found");
}
for query in ["size=256", "size=", "size=large"] {
let reply = app
.call(
Method::GET,
&format!("/users/{MEMBER_ID}/picture?{query}"),
&admin_token(),
None,
)
.await;
assert_error(&reply, 400, "invalid_request");
}
}
@@ -0,0 +1,168 @@
//! E-33 settlement allocation proposal (SPEC §9.4, AC-11.8).
use axum::http::Method;
use serde_json::json;
use crate::common::*;
/// Anna (member) owes 30000 in the older account and 20000 in the newer one, as in the E-33 example.
async fn example_state() -> TestApp {
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;
app.insert_expense(ACCOUNT_1, GROCERIES, 60000, ADMIN_ID, (30000, 30000), false)
.await;
app.insert_expense(ACCOUNT_2, GROCERIES, 40000, ADMIN_ID, (20000, 20000), false)
.await;
// Deleted rows never count.
app.insert_expense(ACCOUNT_1, GROCERIES, 99999, ADMIN_ID, (0, 99999), true)
.await;
app.insert_settlement(MEMBER_ID, &[(ACCOUNT_1, 30000)], true)
.await;
app
}
#[tokio::test]
async fn e33_matches_the_example() {
let app = example_state().await;
let reply = app
.call(
Method::POST,
"/settlements/proposal",
&member_token(),
Some(example("E-33-settlement-proposal/request.json")),
)
.await;
assert_eq!(reply.status, 200, "{}", reply.text());
assert_eq!(
reply.json,
example("E-33-settlement-proposal/response.json")
);
}
#[tokio::test]
async fn e33_honours_exclude_settlement_id_and_settlements() {
let app = example_state().await;
let paid = app
.insert_settlement(MEMBER_ID, &[(ACCOUNT_1, 30000)], false)
.await;
let body = |exclude: Option<&str>| json!({"payer_user_id": MEMBER_ID, "amount_ore": 20000, "exclude_settlement_id": exclude});
// With the settlement: only account 2 is owed.
let reply = app
.call(
Method::POST,
"/settlements/proposal",
&admin_token(),
Some(body(None)),
)
.await;
assert_eq!(
reply.json,
json!({"allocations": [{"account_id": ACCOUNT_2, "amount_ore": 20000}], "excess_ore": 0,
"over_settled_account_ids": []})
);
// Excluding it (editing that settlement): account 1 is owed again and comes first.
let reply = app
.call(
Method::POST,
"/settlements/proposal",
&admin_token(),
Some(body(Some(&paid))),
)
.await;
assert_eq!(
reply.json["allocations"],
json!([{"account_id": ACCOUNT_1, "amount_ore": 20000}])
);
// Absent means null.
let reply = app
.call(
Method::POST,
"/settlements/proposal",
&admin_token(),
Some(json!({"payer_user_id": MEMBER_ID, "amount_ore": 20000})),
)
.await;
assert_eq!(reply.status, 200);
}
#[tokio::test]
async fn e33_owed_nothing_goes_to_the_oldest_active_account() {
let app = example_state().await;
app.call(
Method::POST,
&format!("/accounts/{ACCOUNT_1}/archive"),
&admin_token(),
None,
)
.await;
// The admin owes nothing anywhere.
let reply = app
.call(
Method::POST,
"/settlements/proposal",
&admin_token(),
Some(json!({"payer_user_id": ADMIN_ID, "amount_ore": 500})),
)
.await;
assert_eq!(
reply.json,
json!({"allocations": [{"account_id": ACCOUNT_2, "amount_ore": 500}], "excess_ore": 500,
"over_settled_account_ids": [ACCOUNT_2]})
);
}
#[tokio::test]
async fn e33_errors() {
let app = TestApp::household().await;
let reply = app
.call(
Method::POST,
"/settlements/proposal",
&admin_token(),
Some(json!({"payer_user_id": MEMBER_ID, "amount_ore": 100})),
)
.await;
assert_error(&reply, 409, "no_accounts");
for body in [
json!({"amount_ore": 100}),
json!({"payer_user_id": MEMBER_ID, "amount_ore": "100"}),
json!({"payer_user_id": MEMBER_ID, "amount_ore": 100, "exclude_settlement_id": "x"}),
] {
let reply = app
.call(
Method::POST,
"/settlements/proposal",
&admin_token(),
Some(body),
)
.await;
assert_error(&reply, 400, "invalid_request");
}
for (body, field) in [
(
json!({"payer_user_id": MEMBER_ID, "amount_ore": 0}),
"amount_ore",
),
(
json!({"payer_user_id": MEMBER_ID, "amount_ore": 1_000_000_001}),
"amount_ore",
),
(
json!({"payer_user_id": UNKNOWN_ID, "amount_ore": 100}),
"payer_user_id",
),
] {
let reply = app
.call(
Method::POST,
"/settlements/proposal",
&admin_token(),
Some(body),
)
.await;
assert_field_error(&reply, 422, "validation_failed", field);
}
}
@@ -0,0 +1,174 @@
//! Rate limiting (SPEC §8.6, AC-2.12) and the client IP behind the trusted proxy (SPEC §8.5).
use std::net::SocketAddr;
use axum::body::Body;
use axum::extract::ConnectInfo;
use axum::http::{Method, Request};
use chrono::Duration;
use kvitt_server::time::{Clock, parse_ts};
use serde_json::json;
use crate::common::*;
fn from(peer: &str, forwarded: Option<&str>, path: &str, tok: Option<&str>) -> Request<Body> {
let mut request = req(Method::GET, path, tok, None);
request
.extensions_mut()
.insert(ConnectInfo(peer.parse::<SocketAddr>().unwrap()));
if let Some(f) = forwarded {
request
.headers_mut()
.insert("x-forwarded-for", f.parse().unwrap());
}
request
}
#[tokio::test]
async fn unauthenticated_60_per_minute_per_ip_with_retry_after() {
let app = TestApp::empty().await;
for _ in 0..60 {
let reply = app.send(from("192.0.2.1:1", None, "/health", None)).await;
assert_eq!(reply.status, 200);
}
app.clock
.set(parse_ts(NOW).unwrap() + Duration::seconds(15));
let limited = app
.send(from("192.0.2.1:2", None, "/setup/status", None))
.await;
assert_error(&limited, 429, "rate_limited");
assert_eq!(limited.headers["retry-after"], "45");
// Another client is not affected; the window ends after a minute.
let other = app.send(from("192.0.2.2:1", None, "/health", None)).await;
assert_eq!(other.status, 200);
app.clock
.set(parse_ts(NOW).unwrap() + Duration::seconds(60));
let again = app.send(from("192.0.2.1:1", None, "/health", None)).await;
assert_eq!(again.status, 200);
}
#[tokio::test]
async fn authenticated_600_per_minute_per_device() {
let app = TestApp::household().await;
for i in 0..600 {
let reply = app
.send(from("192.0.2.1:1", None, "/me", Some(&admin_token())))
.await;
assert_eq!(reply.status, 200, "request {i}");
}
let limited = app
.send(from("192.0.2.1:1", None, "/me", Some(&admin_token())))
.await;
assert_error(&limited, 429, "rate_limited");
assert_eq!(limited.headers["retry-after"], "60");
// The other device, from the same address, is not affected; neither are the public endpoints.
let member = app
.send(from("192.0.2.1:1", None, "/me", Some(&member_token())))
.await;
assert_eq!(member.status, 200);
let public = app.send(from("192.0.2.1:1", None, "/health", None)).await;
assert_eq!(public.status, 200);
}
#[tokio::test]
async fn unknown_tokens_count_against_the_ip_but_known_devices_keep_working() {
let app = TestApp::household().await;
assert_eq!(
app.send(from("192.0.2.1:1", None, "/me", Some(&admin_token())))
.await
.status,
200
);
for i in 0..60 {
let tok = format!("kvt_{:0>43}", i);
let reply = app.send(from("192.0.2.1:1", None, "/me", Some(&tok))).await;
assert_error(&reply, 401, "unauthorized");
}
let reply = app
.send(from("192.0.2.1:1", None, "/me", Some(&token('Z'))))
.await;
assert_error(&reply, 429, "rate_limited");
let reply = app.send(from("192.0.2.1:1", None, "/me", None)).await;
assert_error(&reply, 429, "rate_limited");
let reply = app
.send(from("192.0.2.1:1", None, "/me", Some(&admin_token())))
.await;
assert_eq!(reply.status, 200);
}
#[tokio::test]
async fn x_forwarded_for_is_trusted_only_from_the_proxy() {
let app = TestApp::empty().await;
// Behind the trusted proxy (127.0.0.9 in the harness), each forwarded client has its own bucket.
for _ in 0..60 {
app.send(from("127.0.0.9:1", Some("203.0.113.5"), "/health", None))
.await;
}
let limited = app
.send(from(
"127.0.0.9:1",
Some("10.0.0.1, 203.0.113.5"),
"/health",
None,
))
.await;
assert_error(&limited, 429, "rate_limited");
let other = app
.send(from("127.0.0.9:1", Some("203.0.113.6"), "/health", None))
.await;
assert_eq!(other.status, 200);
// From any other peer the header is ignored: the peer is the client.
for _ in 0..60 {
app.send(from("192.0.2.7:1", Some("203.0.113.99"), "/health", None))
.await;
}
let spoofed = app
.send(from("192.0.2.7:1", Some("203.0.113.100"), "/health", None))
.await;
assert_error(&spoofed, 429, "rate_limited");
}
#[tokio::test]
async fn ten_failed_invite_accepts_lock_the_ip_for_the_rest_of_the_hour() {
let app = TestApp::admin_only().await;
let created = app
.call(
Method::POST,
"/invites",
&admin_token(),
Some(json!({"kind": "partner"})),
)
.await;
let good = created.json["code"].as_str().unwrap().to_owned();
let accept = |code: &str, peer: &str| {
let mut request = req(
Method::POST,
"/invites/accept",
None,
Some(json!({"code": code, "device_name": "x", "display_name": "Anna"})),
);
request
.extensions_mut()
.insert(ConnectInfo(peer.parse::<SocketAddr>().unwrap()));
app.send(request)
};
let start = app.clock.now();
for i in 0..10 {
app.clock.set(start + Duration::minutes(i * 5));
let reply = accept("AAAAAAAAAAAA", "198.51.100.1:1").await;
assert_error(&reply, 400, "invite_invalid");
}
// The eleventh attempt is refused, even with the right code, until 60 minutes after the first failure.
app.clock.set(start + Duration::minutes(50));
let reply = accept(&good, "198.51.100.1:1").await;
assert_error(&reply, 429, "rate_limited");
assert_eq!(reply.headers["retry-after"], "600");
// Other endpoints and other addresses are not affected.
let health = app
.send(from("198.51.100.1:1", None, "/health", None))
.await;
assert_eq!(health.status, 200);
app.clock.set(start + Duration::minutes(60));
let reply = accept(&good, "198.51.100.1:1").await;
assert_eq!(reply.status, 200, "{}", reply.text());
}
@@ -0,0 +1,307 @@
//! E-28 to E-30: recurring templates (FR-9, SPEC §9.6 limits, §9.1 split rules).
use axum::http::Method;
use serde_json::{Value, json};
use crate::common::*;
/// The E-28 example request with fields replaced.
pub fn template_body(changes: Value) -> Value {
let mut body = example("E-28-create-recurring-template/request.json");
for (k, v) in changes.as_object().unwrap() {
body[k] = v.clone();
}
body
}
pub async fn household_with_account() -> TestApp {
let app = TestApp::household().await;
app.insert_account(ACCOUNT_1, "Vardag", "2026-10-17T07:10:00.000Z", 5000)
.await;
app
}
pub async fn create_template(app: &TestApp, body: Value) -> Reply {
app.call(
Method::POST,
"/recurring-templates",
&admin_token(),
Some(body),
)
.await
}
#[tokio::test]
async fn e28_creates_a_template_like_the_example() {
let app = household_with_account().await;
let reply = create_template(&app, example("E-28-create-recurring-template/request.json")).await;
assert_eq!(reply.status, 201, "{}", reply.text());
assert_like_example(
&reply.json,
"E-28-create-recurring-template/response.json",
ROW_VOLATILE,
);
// 25 October is not due yet on 17 October: nothing generated.
assert_eq!(app.scalar_i64("SELECT COUNT(*) FROM expenses").await, 0);
}
#[tokio::test]
async fn e28_household_incomplete() {
let app = TestApp::admin_only().await;
let reply = create_template(&app, template_body(json!({}))).await;
assert_error(&reply, 409, "household_incomplete");
}
#[tokio::test]
async fn e28_invalid_request() {
let app = household_with_account().await;
let mut missing_end = template_body(json!({}));
missing_end.as_object_mut().unwrap().remove("end_period");
let mut missing_notes = template_body(json!({}));
missing_notes.as_object_mut().unwrap().remove("notes");
for body in [
missing_end,
missing_notes,
template_body(json!({"amount_ore": "12"})),
template_body(json!({"split_mode": "half"})),
template_body(json!({"account_id": "not-a-uuid"})),
template_body(json!({"paused": null})),
] {
let reply = create_template(&app, body).await;
assert_error(&reply, 400, "invalid_request");
}
}
#[tokio::test]
async fn e28_validation_failed_with_field() {
let app = household_with_account().await;
app.insert_account(ACCOUNT_2, "Gammal", "2026-10-17T07:11:00.000Z", 5000)
.await;
app.call(
Method::DELETE,
&format!("/accounts/{ACCOUNT_2}"),
&admin_token(),
None,
)
.await;
let bp = |a: i64, b: i64| {
json!([{"user_id": ADMIN_ID, "share_bp": a, "share_ore": null},
{"user_id": MEMBER_ID, "share_bp": b, "share_ore": null}])
};
let ore = |a: i64, b: i64| {
json!([{"user_id": ADMIN_ID, "share_bp": null, "share_ore": a},
{"user_id": MEMBER_ID, "share_bp": null, "share_ore": b}])
};
let cases = [
(json!({"account_id": UNKNOWN_ID}), "account_id"),
(json!({"account_id": ACCOUNT_2}), "account_id"),
(json!({"category_id": UNKNOWN_ID}), "category_id"),
(json!({"amount_ore": 0}), "amount_ore"),
(json!({"amount_ore": 1_000_000_001}), "amount_ore"),
(json!({"description": " "}), "description"),
(json!({"description": "d".repeat(101)}), "description"),
(json!({"notes": "n".repeat(1001)}), "notes"),
(json!({"notes": "tab\there"}), "notes"),
(json!({"payer_user_id": UNKNOWN_ID}), "payer_user_id"),
(json!({"day_of_month": 0}), "day_of_month"),
(json!({"day_of_month": 32}), "day_of_month"),
(json!({"start_period": "2025-09"}), "start_period"),
(json!({"start_period": "2026-13"}), "start_period"),
(json!({"end_period": "2026-09"}), "end_period"),
(json!({"end_period": "x"}), "end_period"),
(json!({"splits": bp(6000, 5000)}), "splits"),
(json!({"splits": ore(6000, 4000)}), "splits"),
(json!({"split_mode": "default"}), "splits"),
(
json!({"split_mode": "full", "splits": bp(6000, 4000)}),
"splits",
),
(
json!({"split_mode": "exact", "splits": ore(1, 2)}),
"splits",
),
(
json!({"split_mode": "exact", "splits": bp(6000, 4000)}),
"splits",
),
(
json!({"splits": [{"user_id": ADMIN_ID, "share_bp": 10000, "share_ore": null}]}),
"splits",
),
];
for (changes, field) in cases {
let reply = create_template(&app, template_body(changes.clone())).await;
assert_field_error(&reply, 422, "validation_failed", field);
}
let reply = create_template(&app, template_body(json!({"splits": bp(6000, 4100)}))).await;
assert_eq!(reply.json["message"], "bp_sum_not_10000");
let reply = create_template(
&app,
template_body(json!({"split_mode": "exact", "splits": ore(1, 2)})),
)
.await;
assert_eq!(reply.json["message"], "exact_sum_mismatch");
// The limits themselves are accepted: 12 months back, end = start, newline in notes.
let ok = create_template(
&app,
template_body(json!({"start_period": "2025-10", "end_period": "2025-10", "notes": "a\nb"})),
)
.await;
assert_eq!(ok.status, 201, "{}", ok.text());
// Archived accounts and categories are accepted (AC-6.4 hides them only in the app's pickers).
app.call(
Method::POST,
&format!("/accounts/{ACCOUNT_1}/archive"),
&admin_token(),
None,
)
.await;
let ok = create_template(&app, template_body(json!({}))).await;
assert_eq!(ok.status, 201, "{}", ok.text());
}
#[tokio::test]
async fn e29_pauses_like_the_example_and_edits_fields() {
let app = household_with_account().await;
let created = create_template(&app, template_body(json!({}))).await;
let path = format!(
"/recurring-templates/{}",
created.json["id"].as_str().unwrap()
);
let reply = app
.call(
Method::PATCH,
&path,
&admin_token(),
Some(example("E-29-patch-recurring-template/request.json")),
)
.await;
assert_eq!(reply.status, 200, "{}", reply.text());
assert_like_example(
&reply.json,
"E-29-patch-recurring-template/response.json",
ROW_VOLATILE,
);
let reply = app
.call(
Method::PATCH,
&path,
&member_token(),
Some(json!({
"amount_ore": 1000, "split_mode": "exact",
"splits": [{"user_id": ADMIN_ID, "share_bp": null, "share_ore": 300},
{"user_id": MEMBER_ID, "share_bp": null, "share_ore": 700}],
"description": "Hyra ny", "end_period": "2027-06"
})),
)
.await;
assert_eq!(reply.status, 200, "{}", reply.text());
assert_eq!(reply.json["split_mode"], "exact");
assert_eq!(reply.json["splits"][1]["share_ore"], 700);
assert_eq!(reply.json["end_period"], "2027-06");
assert_eq!(reply.json["updated_by_user_id"], MEMBER_ID);
assert_eq!(reply.json["created_by_user_id"], ADMIN_ID);
let reply = app
.call(
Method::PATCH,
&path,
&admin_token(),
Some(json!({"end_period": null})),
)
.await;
assert_eq!(reply.json["end_period"], json!(null));
assert_eq!(reply.json["description"], "Hyra ny");
}
#[tokio::test]
async fn e29_errors() {
let app = household_with_account().await;
let created = create_template(&app, template_body(json!({}))).await;
let path = format!(
"/recurring-templates/{}",
created.json["id"].as_str().unwrap()
);
for body in [
json!({}),
json!({"split_mode": "default"}),
json!({"splits": []}),
json!({"amount_ore": "x"}),
] {
let reply = app
.call(Method::PATCH, &path, &admin_token(), Some(body))
.await;
assert_error(&reply, 400, "invalid_request");
}
for (body, field) in [
// An exact template whose amount changes without new splits breaks the sum.
(json!({"amount_ore": 0}), "amount_ore"),
(
json!({"split_mode": "default", "splits": [{"user_id": ADMIN_ID, "share_bp": 1, "share_ore": null}]}),
"splits",
),
(json!({"start_period": "2024-01"}), "start_period"),
(json!({"end_period": "2026-01"}), "end_period"),
(json!({"category_id": UNKNOWN_ID}), "category_id"),
(json!({"day_of_month": 40}), "day_of_month"),
] {
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,
&format!("/recurring-templates/{UNKNOWN_ID}"),
&admin_token(),
Some(json!({"paused": true})),
)
.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!({"paused": true})),
)
.await;
assert_error(&reply, 404, "not_found");
}
#[tokio::test]
async fn e30_soft_deletes_and_then_not_found() {
let app = household_with_account().await;
let created = create_template(&app, template_body(json!({}))).await;
let path = format!(
"/recurring-templates/{}",
created.json["id"].as_str().unwrap()
);
let reply = app.call(Method::DELETE, &path, &admin_token(), None).await;
assert_eq!(reply.status, 200);
assert_like_example(
&reply.json,
"E-30-delete-recurring-template/response.json",
&[
"id",
"created_at",
"updated_at",
"deleted_at",
"server_version",
],
);
assert_eq!(reply.json["deleted_at"], NOW);
assert_eq!(reply.json["next_occurrence_date"], json!(null));
let again = app.call(Method::DELETE, &path, &admin_token(), None).await;
assert_error(&again, 404, "not_found");
let unknown = app
.call(
Method::DELETE,
&format!("/recurring-templates/{UNKNOWN_ID}"),
&admin_token(),
None,
)
.await;
assert_error(&unknown, 404, "not_found");
}