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:
@@ -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&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");
|
||||
}
|
||||
Reference in New Issue
Block a user