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

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:
2026-10-21 20:52:46 +02:00
parent 7bddc50a53
commit 50a0d6792a
32 changed files with 2247 additions and 48 deletions
@@ -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,