Server: web client under /app/ (D-132), analytics E-34 trends and E-35 forecast (D-133)
Build Kvitt images / build (push) Failing after 1m20s
Build Kvitt images / build (push) Failing after 1m20s
Part A (D-132): KVITT_WEB_DIR (default /app) served under /app/ by a small handler: /app redirects to /app/, app routes fall back to index.html, a missing assets/* file is 404, assets cached immutable, index.html no-store, the app's own CSP plus nosniff, DENY and no-referrer. Unsafe paths never reach the file system. /app paths count against the unauthenticated per-IP limit. Without the directory every /app path stays 404 not_found. server/Dockerfile gains a Node 24 LTS stage (pinned by digest) building web/, passed through the Rust stage so npm and rustc never run at once on the Pi 3; the build context is the repository root with an allowlist .dockerignore (server/.dockerignore removed); workflow, build-images.sh and infra/dev/build.sh follow. Part B (D-133): GET /analytics/trends (E-34) and GET /analytics/forecast (E-35), authenticated, read-only, SQL month buckets; rules (half-up average, trailing 3-month moving average, percent against the rounded average, template occurrence per month) in kvitt-domain::analytics. OpenAPI, examples, contracts-check and tests updated (35 operations). Cargo.lock: rustls-webpki 0.103.16 and webpki-root-certs 1.0.10 (RUSTSEC-2026-0382, -0383) so that cargo audit passes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
This commit is contained in:
@@ -156,6 +156,8 @@ impl TestApp {
|
||||
"KVITT_PUBLIC_URL" => Some(PUBLIC_URL.to_owned()),
|
||||
// No proxy is trusted unless a test says so.
|
||||
"KVITT_TRUSTED_PROXY" => Some("127.0.0.9".to_owned()),
|
||||
// The web client's directory; absent unless a test creates it (`/app` on the host never matters).
|
||||
"KVITT_WEB_DIR" => Some(format!("{path}/web")),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
@@ -0,0 +1,550 @@
|
||||
//! E-34 trends and E-35 forecast (D-133, SPEC §14.5.7) over a seeded database. Today is 2026-10-17 (the harness
|
||||
//! clock), so the whole months of E-34 `months=3` and of the forecast baseline are 2026-07 to 2026-09 and the
|
||||
//! forecast months are 2026-11 and 2026-12.
|
||||
|
||||
use axum::http::Method;
|
||||
use serde_json::{Value, json};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::common::*;
|
||||
|
||||
const ACCOUNT_3: &str = "0192a003-0000-7000-8000-000000000003";
|
||||
const T_RENT: &str = "0192a004-0000-7000-8000-000000000001";
|
||||
const T_STREAMING: &str = "0192a004-0000-7000-8000-000000000002";
|
||||
const T_PAUSED: &str = "0192a004-0000-7000-8000-000000000003";
|
||||
const T_DELETED: &str = "0192a004-0000-7000-8000-000000000004";
|
||||
|
||||
/// How a seeded expense differs from a plain one.
|
||||
#[derive(Clone, Copy)]
|
||||
enum Kind<'a> {
|
||||
Plain,
|
||||
/// Generated from (template id, period).
|
||||
Generated(&'a str, &'a str),
|
||||
Deleted,
|
||||
}
|
||||
|
||||
/// An expense with a date; `shares` are (admin öre, member öre).
|
||||
async fn expense(
|
||||
app: &TestApp,
|
||||
account: &str,
|
||||
category: &str,
|
||||
amount: i64,
|
||||
date: &str,
|
||||
shares: (i64, i64),
|
||||
kind: Kind<'_>,
|
||||
) {
|
||||
let generated = match kind {
|
||||
Kind::Generated(template, period) => Some((template, period)),
|
||||
Kind::Plain | Kind::Deleted => None,
|
||||
};
|
||||
let deleted = matches!(kind, Kind::Deleted);
|
||||
let id = Uuid::now_v7().to_string();
|
||||
let version = app.next_version().await;
|
||||
sqlx::query(
|
||||
"INSERT INTO expenses (id, account_id, category_id, amount_ore, description, notes, expense_date, \
|
||||
payer_user_id, split_mode, recurring_template_id, recurring_period, created_by_user_id, updated_by_user_id, \
|
||||
created_at, updated_at, deleted_at, server_received_at, server_version) \
|
||||
VALUES (?, ?, ?, ?, 'x', '', ?, ?, 'exact', ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(&id)
|
||||
.bind(account)
|
||||
.bind(category)
|
||||
.bind(amount)
|
||||
.bind(date)
|
||||
.bind(ADMIN_ID)
|
||||
.bind(generated.map(|g| g.0))
|
||||
.bind(generated.map(|g| g.1))
|
||||
.bind(ADMIN_ID)
|
||||
.bind(ADMIN_ID)
|
||||
.bind(NOW)
|
||||
.bind(NOW)
|
||||
.bind(deleted.then_some(NOW))
|
||||
.bind(NOW)
|
||||
.bind(version)
|
||||
.execute(&app.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(&app.state.db)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
/// A template paid by the admin. `ratio` is the admin's bp for mode ratio; `None` is mode default.
|
||||
struct Template<'a> {
|
||||
id: &'a str,
|
||||
account: &'a str,
|
||||
amount: i64,
|
||||
day: i64,
|
||||
start: &'a str,
|
||||
end: Option<&'a str>,
|
||||
last: Option<&'a str>,
|
||||
paused: bool,
|
||||
deleted: bool,
|
||||
ratio: Option<i64>,
|
||||
}
|
||||
|
||||
async fn template(app: &TestApp, t: Template<'_>) {
|
||||
let version = app.next_version().await;
|
||||
sqlx::query(
|
||||
"INSERT INTO recurring_templates (id, account_id, category_id, amount_ore, description, notes, payer_user_id, \
|
||||
split_mode, day_of_month, start_period, end_period, paused, last_generated_period, created_by_user_id, \
|
||||
updated_by_user_id, created_at, updated_at, deleted_at, server_version) \
|
||||
VALUES (?, ?, ?, ?, 'x', '', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.bind(t.id)
|
||||
.bind(t.account)
|
||||
.bind(HOME)
|
||||
.bind(t.amount)
|
||||
.bind(ADMIN_ID)
|
||||
.bind(if t.ratio.is_some() { "ratio" } else { "default" })
|
||||
.bind(t.day)
|
||||
.bind(t.start)
|
||||
.bind(t.end)
|
||||
.bind(i64::from(t.paused))
|
||||
.bind(t.last)
|
||||
.bind(ADMIN_ID)
|
||||
.bind(ADMIN_ID)
|
||||
.bind(NOW)
|
||||
.bind(NOW)
|
||||
.bind(t.deleted.then_some(NOW))
|
||||
.bind(version)
|
||||
.execute(&app.state.db)
|
||||
.await
|
||||
.unwrap();
|
||||
if let Some(bp) = t.ratio {
|
||||
for (user, share) in [(ADMIN_ID, bp), (MEMBER_ID, 10_000 - bp)] {
|
||||
sqlx::query(
|
||||
"INSERT INTO recurring_template_splits (template_id, user_id, share_bp, share_ore) \
|
||||
VALUES (?, ?, ?, NULL)",
|
||||
)
|
||||
.bind(t.id)
|
||||
.bind(user)
|
||||
.bind(share)
|
||||
.execute(&app.state.db)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The scenario of the E-34 and E-35 examples.
|
||||
async fn example_state() -> TestApp {
|
||||
let app = TestApp::household().await;
|
||||
app.insert_account(ACCOUNT_1, "Vardag", "2026-01-01T00:00:00.000Z", 5000)
|
||||
.await;
|
||||
app.insert_account(ACCOUNT_2, "Hyra", "2026-02-01T00:00:00.000Z", 6000)
|
||||
.await;
|
||||
app.insert_account(ACCOUNT_3, "Nytt", "2026-10-10T00:00:00.000Z", 5000)
|
||||
.await;
|
||||
// An archived account still counts (AC-14.9).
|
||||
sqlx::query("UPDATE accounts SET archived_at = ? WHERE id = ?")
|
||||
.bind(NOW)
|
||||
.bind(ACCOUNT_2)
|
||||
.execute(&app.state.db)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let a1 = |category, amount, date, shares| (ACCOUNT_1, category, amount, date, shares);
|
||||
for (account, category, amount, date, shares) in [
|
||||
a1(GROCERIES, 30_000, "2026-07-10", (15_000, 15_000)),
|
||||
a1(GROCERIES, 60_000, "2026-09-03", (30_000, 30_000)),
|
||||
a1(HOME, 10_000, "2026-07-20", (5_000, 5_000)),
|
||||
a1(HOME, 20_000, "2026-08-15", (10_000, 10_000)),
|
||||
// The current month so far.
|
||||
a1(GROCERIES, 12_000, "2026-10-05", (6_000, 6_000)),
|
||||
// After today: not "so far".
|
||||
a1(GROCERIES, 5_000, "2026-10-25", (2_500, 2_500)),
|
||||
// Before the whole months.
|
||||
a1(HOME, 99_999, "2026-06-30", (50_000, 49_999)),
|
||||
(ACCOUNT_2, GROCERIES, 3_000, "2026-09-12", (1_800, 1_200)),
|
||||
] {
|
||||
expense(&app, account, category, amount, date, shares, Kind::Plain).await;
|
||||
}
|
||||
// Deleted expenses never count.
|
||||
expense(
|
||||
&app,
|
||||
ACCOUNT_1,
|
||||
HOME,
|
||||
77_777,
|
||||
"2026-08-01",
|
||||
(0, 77_777),
|
||||
Kind::Deleted,
|
||||
)
|
||||
.await;
|
||||
|
||||
// Rent on day 31 (clamped to 30 September), generated for August and September: spend in E-34, not in the
|
||||
// forecast baseline.
|
||||
template(
|
||||
&app,
|
||||
Template {
|
||||
id: T_RENT,
|
||||
account: ACCOUNT_2,
|
||||
amount: 15_000,
|
||||
day: 31,
|
||||
start: "2026-08",
|
||||
end: None,
|
||||
last: Some("2026-09"),
|
||||
paused: false,
|
||||
deleted: false,
|
||||
ratio: None,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
for (date, period) in [("2026-08-31", "2026-08"), ("2026-09-30", "2026-09")] {
|
||||
expense(
|
||||
&app,
|
||||
ACCOUNT_2,
|
||||
HOME,
|
||||
15_000,
|
||||
date,
|
||||
(9_000, 6_000),
|
||||
Kind::Generated(T_RENT, period),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
// November only (end_period).
|
||||
template(
|
||||
&app,
|
||||
Template {
|
||||
id: T_STREAMING,
|
||||
account: ACCOUNT_1,
|
||||
amount: 4_900,
|
||||
day: 5,
|
||||
start: "2026-11",
|
||||
end: Some("2026-11"),
|
||||
last: None,
|
||||
paused: false,
|
||||
deleted: false,
|
||||
ratio: Some(7_000),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
// Paused and deleted templates are not forecast.
|
||||
for (id, paused, deleted) in [(T_PAUSED, true, false), (T_DELETED, false, true)] {
|
||||
template(
|
||||
&app,
|
||||
Template {
|
||||
id,
|
||||
account: ACCOUNT_1,
|
||||
amount: 100_000,
|
||||
day: 1,
|
||||
start: "2026-01",
|
||||
end: None,
|
||||
last: Some("2026-10"),
|
||||
paused,
|
||||
deleted,
|
||||
ratio: None,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
// Settlements are never spend (AC-14.6).
|
||||
app.insert_settlement(MEMBER_ID, &[(ACCOUNT_1, 50_000)], false)
|
||||
.await;
|
||||
app
|
||||
}
|
||||
|
||||
async fn get(app: &TestApp, path: &str, tok: &str) -> Value {
|
||||
let reply = app.call(Method::GET, path, tok, None).await;
|
||||
assert_eq!(reply.status, 200, "{path}: {}", reply.text());
|
||||
reply.json
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn e34_matches_the_example() {
|
||||
let app = example_state().await;
|
||||
let body = get(&app, "/analytics/trends?months=3", &admin_token()).await;
|
||||
assert_eq!(body, example("E-34-analytics-trends/response.json"));
|
||||
// The measure written out is the same.
|
||||
let explicit = get(
|
||||
&app,
|
||||
"/analytics/trends?measure=household&months=3",
|
||||
&member_token(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(explicit, body);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn e34_my_share_sums_the_callers_shares() {
|
||||
let app = example_state().await;
|
||||
let admin = get(
|
||||
&app,
|
||||
"/analytics/trends?months=3&measure=my_share",
|
||||
&admin_token(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(admin["measure"], "my_share");
|
||||
let amounts = |trend: &Value| -> Vec<i64> {
|
||||
trend["points"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|p| p["amount_ore"].as_i64().unwrap())
|
||||
.collect()
|
||||
};
|
||||
assert_eq!(amounts(&admin["household"]), vec![20_000, 19_000, 40_800]);
|
||||
assert_eq!(admin["household"]["current_month_ore"], 6_000);
|
||||
assert_eq!(admin["categories"][0]["category_id"], GROCERIES);
|
||||
assert_eq!(
|
||||
amounts(&admin["categories"][0]["trend"]),
|
||||
vec![15_000, 0, 31_800]
|
||||
);
|
||||
let member = get(
|
||||
&app,
|
||||
"/analytics/trends?months=3&measure=my_share",
|
||||
&member_token(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(amounts(&member["household"]), vec![20_000, 16_000, 37_200]);
|
||||
// The two shares add up to the household.
|
||||
let household = get(&app, "/analytics/trends?months=3", &member_token()).await;
|
||||
assert_eq!(
|
||||
admin["household"]["total_ore"].as_i64().unwrap()
|
||||
+ member["household"]["total_ore"].as_i64().unwrap(),
|
||||
household["household"]["total_ore"].as_i64().unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn e34_default_months_short_windows_and_zero_average() {
|
||||
let app = TestApp::household().await;
|
||||
app.insert_account(ACCOUNT_1, "Vardag", "2026-01-01T00:00:00.000Z", 5000)
|
||||
.await;
|
||||
// Nothing at all: every series is zeros, no category, no percent.
|
||||
let body = get(&app, "/analytics/trends", &admin_token()).await;
|
||||
assert_eq!(body["months"], 6);
|
||||
let points = body["household"]["points"].as_array().unwrap();
|
||||
assert_eq!(points.len(), 6);
|
||||
assert_eq!(points[0]["month"], "2026-04");
|
||||
assert_eq!(points[5]["month"], "2026-09");
|
||||
assert_eq!(body["household"]["average_ore"], 0);
|
||||
assert_eq!(body["household"]["last_month_vs_average_pct"], Value::Null);
|
||||
assert_eq!(body["categories"], json!([]));
|
||||
|
||||
// Only the current month: listed, all whole months zero, no percent.
|
||||
expense(
|
||||
&app,
|
||||
ACCOUNT_1,
|
||||
HOME,
|
||||
500,
|
||||
"2026-10-01",
|
||||
(250, 250),
|
||||
Kind::Plain,
|
||||
)
|
||||
.await;
|
||||
// 2 öre over two months is an average of 1 öre; 2 öre in the last month is +100 %.
|
||||
expense(
|
||||
&app,
|
||||
ACCOUNT_1,
|
||||
GROCERIES,
|
||||
2,
|
||||
"2026-09-30",
|
||||
(1, 1),
|
||||
Kind::Plain,
|
||||
)
|
||||
.await;
|
||||
let body = get(&app, "/analytics/trends?months=2", &admin_token()).await;
|
||||
let home = body["categories"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|c| c["category_id"] == HOME)
|
||||
.unwrap();
|
||||
assert_eq!(home["trend"]["total_ore"], 0);
|
||||
assert_eq!(home["trend"]["current_month_ore"], 500);
|
||||
assert_eq!(home["trend"]["last_month_vs_average_pct"], Value::Null);
|
||||
assert_eq!(body["categories"][0]["category_id"], GROCERIES);
|
||||
let groceries = &body["categories"][0]["trend"];
|
||||
assert_eq!(groceries["average_ore"], 1);
|
||||
assert_eq!(groceries["last_month_vs_average_pct"], 100);
|
||||
// Moving average over the first two months: 0, then (0 + 2) / 2.
|
||||
assert_eq!(
|
||||
groceries["points"],
|
||||
json!([
|
||||
{"month": "2026-08", "amount_ore": 0, "moving_average_ore": 0},
|
||||
{"month": "2026-09", "amount_ore": 2, "moving_average_ore": 1}
|
||||
])
|
||||
);
|
||||
// One whole month: the moving average is the month itself.
|
||||
let body = get(&app, "/analytics/trends?months=1", &admin_token()).await;
|
||||
assert_eq!(
|
||||
body["household"]["points"],
|
||||
json!([{"month": "2026-09", "amount_ore": 2, "moving_average_ore": 2}])
|
||||
);
|
||||
assert_eq!(body["household"]["last_month_vs_average_pct"], 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn months_follow_the_stockholm_calendar() {
|
||||
let app = TestApp::household().await;
|
||||
// 23:30 UTC on 31 October is 00:30 on 1 November in Stockholm (CET after the clocks went back on 25 October).
|
||||
app.set_now("2026-10-31T23:30:00.000Z");
|
||||
let body = get(&app, "/analytics/trends?months=1", &admin_token()).await;
|
||||
assert_eq!(body["today"], "2026-11-01");
|
||||
assert_eq!(body["current_month"], "2026-11");
|
||||
assert_eq!(body["household"]["points"][0]["month"], "2026-10");
|
||||
let body = get(&app, "/analytics/forecast", &admin_token()).await;
|
||||
assert_eq!(body["current_month"], "2026-11");
|
||||
assert_eq!(body["accounts"], json!([]));
|
||||
// December rolls over into the next year.
|
||||
app.set_now("2026-12-15T12:00:00.000Z");
|
||||
app.insert_account(ACCOUNT_1, "Vardag", "2026-01-01T00:00:00.000Z", 5000)
|
||||
.await;
|
||||
let body = get(&app, "/analytics/forecast", &admin_token()).await;
|
||||
let months: Vec<&str> = body["accounts"][0]["months"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|m| m["month"].as_str().unwrap())
|
||||
.collect();
|
||||
assert_eq!(months, vec!["2027-01", "2027-02"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn e35_matches_the_example() {
|
||||
let app = example_state().await;
|
||||
let body = get(&app, "/analytics/forecast", &member_token()).await;
|
||||
assert_eq!(body, example("E-35-analytics-forecast/response.json"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn e35_my_share_uses_the_shares_a_generation_would_write() {
|
||||
let app = example_state().await;
|
||||
let admin = get(&app, "/analytics/forecast?measure=my_share", &admin_token()).await;
|
||||
assert_eq!(admin["measure"], "my_share");
|
||||
// Account 1: streaming 4900 at 7000 bp for the admin (payer): the member's 1470 is floored, the admin pays
|
||||
// 3430; baseline (15000 + 30000 + 5000 + 10000) / 3; today's month 6000.
|
||||
assert_eq!(
|
||||
admin["accounts"][0],
|
||||
json!({"account_id": ACCOUNT_1, "current_month_ore": 6_000, "months": [
|
||||
{"month": "2026-11", "recurring_ore": 3_430, "baseline_ore": 20_000, "forecast_ore": 23_430},
|
||||
{"month": "2026-12", "recurring_ore": 0, "baseline_ore": 20_000, "forecast_ore": 20_000}
|
||||
]})
|
||||
);
|
||||
// Account 2: rent in mode default resolves the account's current ratio (admin 6000 bp): 9000 / 6000.
|
||||
assert_eq!(admin["accounts"][1]["months"][0]["recurring_ore"], 9_000);
|
||||
assert_eq!(admin["accounts"][1]["months"][0]["baseline_ore"], 600);
|
||||
let member = get(
|
||||
&app,
|
||||
"/analytics/forecast?measure=my_share",
|
||||
&member_token(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(member["accounts"][1]["months"][1]["recurring_ore"], 6_000);
|
||||
assert_eq!(member["accounts"][1]["months"][1]["baseline_ore"], 400);
|
||||
assert_eq!(member["accounts"][0]["months"][0]["recurring_ore"], 1_470);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn e35_templates_by_period_and_baseline_rounding() {
|
||||
let app = TestApp::household().await;
|
||||
app.insert_account(ACCOUNT_1, "Vardag", "2026-01-01T00:00:00.000Z", 5000)
|
||||
.await;
|
||||
// Starts in December: not in November.
|
||||
template(
|
||||
&app,
|
||||
Template {
|
||||
id: T_RENT,
|
||||
account: ACCOUNT_1,
|
||||
amount: 10_000,
|
||||
day: 29,
|
||||
start: "2026-12",
|
||||
end: None,
|
||||
last: None,
|
||||
paused: false,
|
||||
deleted: false,
|
||||
ratio: None,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
// A monthly template counts once per month, whatever its day.
|
||||
template(
|
||||
&app,
|
||||
Template {
|
||||
id: T_STREAMING,
|
||||
account: ACCOUNT_1,
|
||||
amount: 1_000,
|
||||
day: 31,
|
||||
start: "2026-01",
|
||||
end: None,
|
||||
last: Some("2026-09"),
|
||||
paused: false,
|
||||
deleted: false,
|
||||
ratio: Some(5_000),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
// 100 öre over three months: 33.33 rounds to 33; 200 öre: 66.67 rounds to 67.
|
||||
expense(
|
||||
&app,
|
||||
ACCOUNT_1,
|
||||
GROCERIES,
|
||||
100,
|
||||
"2026-08-02",
|
||||
(50, 50),
|
||||
Kind::Plain,
|
||||
)
|
||||
.await;
|
||||
let body = get(&app, "/analytics/forecast", &admin_token()).await;
|
||||
assert_eq!(
|
||||
body["accounts"][0]["months"],
|
||||
json!([
|
||||
{"month": "2026-11", "recurring_ore": 1_000, "baseline_ore": 33, "forecast_ore": 1_033},
|
||||
{"month": "2026-12", "recurring_ore": 11_000, "baseline_ore": 33, "forecast_ore": 11_033}
|
||||
])
|
||||
);
|
||||
expense(
|
||||
&app,
|
||||
ACCOUNT_1,
|
||||
GROCERIES,
|
||||
100,
|
||||
"2026-07-31",
|
||||
(50, 50),
|
||||
Kind::Plain,
|
||||
)
|
||||
.await;
|
||||
let body = get(&app, "/analytics/forecast", &admin_token()).await;
|
||||
assert_eq!(body["accounts"][0]["months"][0]["baseline_ore"], 67);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bad_parameters_and_authentication() {
|
||||
let app = TestApp::household().await;
|
||||
for (path, field) in [
|
||||
("/analytics/trends?months=0", "months"),
|
||||
("/analytics/trends?months=25", "months"),
|
||||
("/analytics/trends?months=six", "months"),
|
||||
("/analytics/trends?measure=mine", "measure"),
|
||||
("/analytics/forecast?measure=", "measure"),
|
||||
] {
|
||||
let reply = app.call(Method::GET, path, &admin_token(), None).await;
|
||||
assert_field_error(&reply, 400, "invalid_request", field);
|
||||
}
|
||||
// Unknown keys are ignored; E-35 has no `months`.
|
||||
let reply = app
|
||||
.call(
|
||||
Method::GET,
|
||||
"/analytics/forecast?months=99&x=1",
|
||||
&admin_token(),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(reply.status, 200, "{}", reply.text());
|
||||
for path in ["/analytics/trends", "/analytics/forecast"] {
|
||||
let reply = app.public(Method::GET, path, None).await;
|
||||
assert_error(&reply, 401, "unauthorized");
|
||||
let reply = app.call(Method::GET, path, &old_device_token(), None).await;
|
||||
assert_error(&reply, 401, "device_revoked");
|
||||
let reply = app.call(Method::POST, path, &admin_token(), None).await;
|
||||
assert_eq!(reply.status, 405);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
//! REST 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`, `pair-device` and `invite-partner` subcommands (FR-1, D-113) and
|
||||
//! the logging rule (§8.7).
|
||||
//! REST integration tests: every endpoint of E-2 to E-30 and E-33 to E-35 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 web client under `/app/` (D-132), the `bootstrap`, `pair-device` and
|
||||
//! `invite-partner` subcommands (FR-1, D-113) and the logging rule (§8.7).
|
||||
//!
|
||||
//! One test binary (faster to build than one per file); the harness is `tests/common/`.
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
pub mod common;
|
||||
|
||||
mod accounts;
|
||||
mod analytics;
|
||||
mod apk;
|
||||
mod categories;
|
||||
mod cli;
|
||||
@@ -22,3 +23,4 @@ mod pictures;
|
||||
mod proposal;
|
||||
mod rate_limit;
|
||||
mod recurring;
|
||||
mod web;
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
//! The web client under `/app/` (D-132, `KVITT_WEB_DIR`): static files, SPA fallback, caching, the app's CSP, path
|
||||
//! safety and the unauthenticated rate limit.
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::http::{Method, Request, header};
|
||||
|
||||
use crate::common::*;
|
||||
|
||||
const INDEX: &str = "<!doctype html><title>Kvitt</title><script type=module src=/app/assets/index-B2x9a.js></script>";
|
||||
const SCRIPT: &str = "console.log('kvitt')";
|
||||
|
||||
/// Installs a small built app in the harness's web directory (`<data dir>/web`).
|
||||
fn install(app: &TestApp) {
|
||||
let web = app.data_dir().join("web");
|
||||
std::fs::create_dir_all(web.join("assets")).unwrap();
|
||||
std::fs::write(web.join("index.html"), INDEX).unwrap();
|
||||
std::fs::write(web.join("assets/index-B2x9a.js"), SCRIPT).unwrap();
|
||||
std::fs::write(web.join("favicon.svg"), "<svg/>").unwrap();
|
||||
std::fs::create_dir_all(web.join("nested")).unwrap();
|
||||
}
|
||||
|
||||
/// A browser request: no protocol header, no token.
|
||||
fn browser(method: Method, path: &str) -> Request<Body> {
|
||||
Request::builder()
|
||||
.method(method)
|
||||
.uri(path)
|
||||
.body(Body::empty())
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn header_of(reply: &Reply, name: header::HeaderName) -> &str {
|
||||
reply.headers.get(name).map_or("", |v| v.to_str().unwrap())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn without_the_directory_every_app_path_is_not_found() {
|
||||
let app = TestApp::empty().await;
|
||||
for path in [
|
||||
"/app",
|
||||
"/app/",
|
||||
"/app/index.html",
|
||||
"/app/assets/index-B2x9a.js",
|
||||
"/app/expenses/1",
|
||||
] {
|
||||
let reply = app.send(browser(Method::GET, path)).await;
|
||||
assert_error(&reply, 404, "not_found");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn serves_the_index_assets_and_the_spa_fallback() {
|
||||
let app = TestApp::empty().await;
|
||||
install(&app);
|
||||
|
||||
let reply = app.send(browser(Method::GET, "/app")).await;
|
||||
assert_eq!(reply.status, 308);
|
||||
assert_eq!(header_of(&reply, header::LOCATION), "/app/");
|
||||
|
||||
for path in [
|
||||
"/app/",
|
||||
"/app/index.html",
|
||||
"/app/expenses/0192a003-0000-7000-8000-000000000001",
|
||||
"/app/settings",
|
||||
// Unsafe paths never reach the file system; they get the index.
|
||||
"/app/../kvitt.sqlite",
|
||||
"/app/%2e%2e/kvitt.sqlite",
|
||||
"/app/.hidden",
|
||||
"/app/nested",
|
||||
"/app/%FF",
|
||||
] {
|
||||
let reply = app.send(browser(Method::GET, path)).await;
|
||||
assert_eq!(reply.status, 200, "{path}");
|
||||
assert_eq!(reply.text(), INDEX, "{path}");
|
||||
assert_eq!(
|
||||
header_of(&reply, header::CONTENT_TYPE),
|
||||
"text/html; charset=utf-8"
|
||||
);
|
||||
assert_eq!(header_of(&reply, header::CACHE_CONTROL), "no-store");
|
||||
assert_eq!(
|
||||
header_of(&reply, header::CONTENT_SECURITY_POLICY),
|
||||
"default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' blob: data:; \
|
||||
font-src 'self'; connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'"
|
||||
);
|
||||
assert_eq!(header_of(&reply, header::X_CONTENT_TYPE_OPTIONS), "nosniff");
|
||||
assert_eq!(header_of(&reply, header::X_FRAME_OPTIONS), "DENY");
|
||||
assert_eq!(header_of(&reply, header::REFERRER_POLICY), "no-referrer");
|
||||
assert_eq!(
|
||||
header_of(&reply, "x-kvitt-server-version".parse().unwrap()),
|
||||
env!("CARGO_PKG_VERSION")
|
||||
);
|
||||
}
|
||||
|
||||
let reply = app
|
||||
.send(browser(Method::GET, "/app/assets/index-B2x9a.js"))
|
||||
.await;
|
||||
assert_eq!(reply.status, 200);
|
||||
assert_eq!(reply.text(), SCRIPT);
|
||||
assert_eq!(
|
||||
header_of(&reply, header::CONTENT_TYPE),
|
||||
"text/javascript; charset=utf-8"
|
||||
);
|
||||
assert_eq!(
|
||||
header_of(&reply, header::CACHE_CONTROL),
|
||||
"public, max-age=31536000, immutable"
|
||||
);
|
||||
assert!(!header_of(&reply, header::CONTENT_SECURITY_POLICY).is_empty());
|
||||
|
||||
let reply = app.send(browser(Method::GET, "/app/favicon.svg")).await;
|
||||
assert_eq!(header_of(&reply, header::CONTENT_TYPE), "image/svg+xml");
|
||||
assert_eq!(header_of(&reply, header::CACHE_CONTROL), "no-cache");
|
||||
|
||||
// A hashed asset that is gone fails instead of loading HTML.
|
||||
for path in [
|
||||
"/app/assets/index-old.js",
|
||||
"/app/assets/../../kvitt.sqlite",
|
||||
"/app/assets%2f..%2f..%2fkvitt.sqlite",
|
||||
] {
|
||||
let reply = app.send(browser(Method::GET, path)).await;
|
||||
assert_error(&reply, 404, "not_found");
|
||||
}
|
||||
|
||||
// HEAD is answered without a body; other methods are 405 with an empty body.
|
||||
let reply = app.send(browser(Method::HEAD, "/app/")).await;
|
||||
assert_eq!(reply.status, 200);
|
||||
assert!(reply.bytes.is_empty());
|
||||
let reply = app.send(browser(Method::POST, "/app/")).await;
|
||||
assert_eq!(reply.status, 405);
|
||||
assert!(reply.bytes.is_empty());
|
||||
|
||||
// The rest of the API keeps its rules: an unknown path outside /app is still the JSON 404.
|
||||
let reply = app.send(browser(Method::GET, "/application")).await;
|
||||
assert_error(&reply, 404, "not_found");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn without_an_index_the_fallback_is_not_found() {
|
||||
let app = TestApp::empty().await;
|
||||
std::fs::create_dir_all(app.data_dir().join("web")).unwrap();
|
||||
let reply = app.send(browser(Method::GET, "/app/")).await;
|
||||
assert_error(&reply, 404, "not_found");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn app_files_count_against_the_unauthenticated_ip_limit() {
|
||||
let app = TestApp::empty().await;
|
||||
install(&app);
|
||||
for _ in 0..30 {
|
||||
let reply = app.send(browser(Method::GET, "/app/")).await;
|
||||
assert_eq!(reply.status, 200);
|
||||
}
|
||||
// A token does not move a static request to the per-device limit.
|
||||
for _ in 0..30 {
|
||||
let request = Request::builder()
|
||||
.uri("/app/assets/index-B2x9a.js")
|
||||
.header("authorization", format!("Bearer {}", admin_token()))
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
assert_eq!(app.send(request).await.status, 200);
|
||||
}
|
||||
let reply = app.send(browser(Method::GET, "/app/")).await;
|
||||
assert_error(&reply, 429, "rate_limited");
|
||||
let reply = app.public(Method::GET, "/health", None).await;
|
||||
assert_error(&reply, 429, "rate_limited");
|
||||
}
|
||||
@@ -376,7 +376,7 @@ fn generated_document_equals_contract() {
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
);
|
||||
// The contract has 33 operations (SPEC §7, E-1 to E-33); make sure none were lost on both sides.
|
||||
// The contract has 35 operations (SPEC §7, E-1 to E-35); make sure none were lost on both sides.
|
||||
let count = normalise(&generated)["operations"]
|
||||
.as_object()
|
||||
.map(|paths| {
|
||||
@@ -387,7 +387,7 @@ fn generated_document_equals_contract() {
|
||||
.sum::<usize>()
|
||||
})
|
||||
.unwrap_or(0);
|
||||
assert_eq!(count, 33);
|
||||
assert_eq!(count, 35);
|
||||
}
|
||||
|
||||
// The comparison itself must be strict: each mutation of the contract below must be detected.
|
||||
|
||||
@@ -50,6 +50,7 @@ impl TestApp {
|
||||
let config = Config::from_lookup(|k| match k {
|
||||
"KVITT_DATA_DIR" => Some(dir.0.to_string_lossy().into_owned()),
|
||||
"KVITT_PUBLIC_URL" => Some("https://kvitt.example.invalid".to_owned()),
|
||||
"KVITT_WEB_DIR" => Some(dir.0.join("web").to_string_lossy().into_owned()),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
@@ -70,6 +70,8 @@ fn check(name: &str, example: &Value) -> bool {
|
||||
"E-32-sync-push/response.json" => SyncPushResponse,
|
||||
"E-33-settlement-proposal/request.json" => ProposalRequest,
|
||||
"E-33-settlement-proposal/response.json" => ProposalResponse,
|
||||
"E-34-analytics-trends/response.json" => TrendsResponse,
|
||||
"E-35-analytics-forecast/response.json" => ForecastResponse,
|
||||
"sync/pull-empty-page.json" => SyncPullResponse,
|
||||
"sync/pull-page.json" => SyncPullResponse,
|
||||
"sync/pull-page-categories-v2.json" => SyncPullResponse,
|
||||
|
||||
Reference in New Issue
Block a user