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
+19
View File
@@ -0,0 +1,19 @@
# Build context of server/Dockerfile: the repository root (D-132, SPEC §10.3). An allowlist, so the Android project,
# the docs, the screenshot goldens, worktrees and secrets never enter the context (it stays a few MB).
# infra/backup/Dockerfile builds from its own directory and does not read this file.
*
!server
!web
!contracts
# Inside the allowed directories: build output, local data, dependencies and anything secret.
server/target
server/data
web/node_modules
web/dist
**/*.sqlite*
**/*.db
**/*.apk
**/*.pem
**/.env
**/.env.*
**/.DS_Store
+16 -9
View File
@@ -11,8 +11,9 @@
# the credentials actions/checkout stored (the job's own token), not this secret, so it needs no write:repository.
#
# RISK: the runner is a Raspberry Pi 3 with 1 GB RAM and the server image compiles about 232 Rust crates
# (aws-lc-sys's C code included). The build passes CARGO_BUILD_JOBS=1 to keep memory down; server/Dockerfile
# honours it (`ARG CARGO_BUILD_JOBS`, SPEC §10.3). The Dockerfile keeps the cargo registry and the target directory
# (aws-lc-sys's C code included), after an npm ci and Vite build of the web client (D-132; not yet measured on the
# Pi). The build passes CARGO_BUILD_JOBS=1 to keep memory down; server/Dockerfile honours it (`ARG CARGO_BUILD_JOBS`,
# SPEC §10.3). The Dockerfile keeps the cargo registry and the target directory
# in BuildKit cache mounts, which persist in the reused builder `local-http-builder`, so later builds are
# incremental. First-build duration on the Pi: unknown.
# The builder's buildkitd.toml is read only when the builder is created; after changing it, remove the builder on
@@ -34,6 +35,8 @@ on:
push:
paths:
- "server/**"
- "web/**"
- "contracts/**"
- "infra/backup/**"
- ".gitea/workflows/**"
workflow_dispatch: {}
@@ -65,13 +68,16 @@ jobs:
with:
# Tells the action to use the local 'git' binary installed above
set-safe-directory: 'true'
# Only the two build contexts: server/Dockerfile copies its own context (server/, Cargo workspace,
# migrations and the import's category table included) and infra/backup/Dockerfile copies backup.sh and
# healthcheck.sh. Cone mode also checks out the files at the root and directly in infra/. A sparse
# checkout makes the action fetch with --filter=blob:none, so only the blobs of these paths are downloaded
# (not the Android screenshot goldens).
# Only what the two builds read: server/Dockerfile builds from the repository root (D-132) and copies
# server/ (Cargo workspace, migrations, the import's category table), web/ (the web client, built by its
# Node stage) and contracts/; the root .dockerignore keeps everything else out. infra/backup/Dockerfile
# copies backup.sh and healthcheck.sh. Cone mode also checks out the files at the root (.dockerignore
# included) and directly in infra/. A sparse checkout makes the action fetch with --filter=blob:none, so
# only the blobs of these paths are downloaded (not the Android screenshot goldens).
sparse-checkout: |
server
web
contracts
infra/backup
sparse-checkout-cone-mode: true
# Full commit and tag history (trees and commits only, no blobs, because of the filter above): "Determine
@@ -105,7 +111,8 @@ jobs:
--push
- name: Build server image
run: |
# Context server/: server/Dockerfile copies the Cargo workspace from its own directory.
# Context: the repository root (D-132). server/Dockerfile builds the web client first (Node stage), then
# the Rust binaries, one after the other, so npm and rustc never share the Pi's 1 GB.
docker buildx build \
--platform linux/arm64 \
--provenance=false \
@@ -114,7 +121,7 @@ jobs:
-t "$REGISTRY/kvitt-server:$VERSION" \
-t "$REGISTRY/kvitt-server:latest" \
--metadata-file /tmp/kvitt-server.metadata.json \
./server \
. \
--push
- name: Print image digests
# The digests the Kvitt Pi pulls (cd ~/kvitt && docker compose images, or docker image inspect) must match.
@@ -0,0 +1,50 @@
{
"measure": "household",
"today": "2026-10-17",
"current_month": "2026-10",
"months": 3,
"household": {
"total_ore": 153000,
"average_ore": 51000,
"last_month_ore": 78000,
"last_month_vs_average_pct": 53,
"current_month_ore": 12000,
"points": [
{ "month": "2026-07", "amount_ore": 40000, "moving_average_ore": 40000 },
{ "month": "2026-08", "amount_ore": 35000, "moving_average_ore": 37500 },
{ "month": "2026-09", "amount_ore": 78000, "moving_average_ore": 51000 }
]
},
"categories": [
{
"category_id": "01920000-0000-7000-8000-000000000001",
"trend": {
"total_ore": 93000,
"average_ore": 31000,
"last_month_ore": 63000,
"last_month_vs_average_pct": 103,
"current_month_ore": 12000,
"points": [
{ "month": "2026-07", "amount_ore": 30000, "moving_average_ore": 30000 },
{ "month": "2026-08", "amount_ore": 0, "moving_average_ore": 15000 },
{ "month": "2026-09", "amount_ore": 63000, "moving_average_ore": 31000 }
]
}
},
{
"category_id": "01920000-0000-7000-8000-000000000003",
"trend": {
"total_ore": 60000,
"average_ore": 20000,
"last_month_ore": 15000,
"last_month_vs_average_pct": -25,
"current_month_ore": 0,
"points": [
{ "month": "2026-07", "amount_ore": 10000, "moving_average_ore": 10000 },
{ "month": "2026-08", "amount_ore": 35000, "moving_average_ore": 22500 },
{ "month": "2026-09", "amount_ore": 15000, "moving_average_ore": 20000 }
]
}
}
]
}
@@ -0,0 +1,31 @@
{
"measure": "household",
"today": "2026-10-17",
"current_month": "2026-10",
"accounts": [
{
"account_id": "0192a003-0000-7000-8000-000000000001",
"current_month_ore": 12000,
"months": [
{ "month": "2026-11", "recurring_ore": 4900, "baseline_ore": 40000, "forecast_ore": 44900 },
{ "month": "2026-12", "recurring_ore": 0, "baseline_ore": 40000, "forecast_ore": 40000 }
]
},
{
"account_id": "0192a003-0000-7000-8000-000000000002",
"current_month_ore": 0,
"months": [
{ "month": "2026-11", "recurring_ore": 15000, "baseline_ore": 1000, "forecast_ore": 16000 },
{ "month": "2026-12", "recurring_ore": 15000, "baseline_ore": 1000, "forecast_ore": 16000 }
]
},
{
"account_id": "0192a003-0000-7000-8000-000000000003",
"current_month_ore": 0,
"months": [
{ "month": "2026-11", "recurring_ore": 0, "baseline_ore": 0, "forecast_ore": 0 },
{ "month": "2026-12", "recurring_ore": 0, "baseline_ore": 0, "forecast_ore": 0 }
]
}
]
}
+217
View File
@@ -1364,6 +1364,109 @@ paths:
'503':
$ref: '#/components/responses/Unavailable'
/analytics/trends:
get:
operationId: getAnalyticsTrends
summary: E-34 Spending trends and category averages
description: |
D-133, SPEC §14.5.7. The last `months` whole calendar months before the current one (Europe/Stockholm), for
the household and per category: each month's spend, the trailing 3-month moving average (the first months
average what exists, D-128), the total, the average per month (half-up to öre, empty months count), the last
whole month against the average in whole percent (half-up; null when the average is 0), and the current month
so far. Non-deleted expenses only; settlements never count; archived and deleted categories count.
parameters:
- name: months
in: query
required: false
description: Whole months, 1 to 24 (`invalid_request` otherwise).
schema:
type: integer
default: 6
- name: measure
in: query
required: false
description: '`household` (full amounts, whoever paid) or `my_share` (the calling user''s shares), AC-14.4; other values are `invalid_request`.'
schema:
type: string
enum: [household, my_share]
default: household
- $ref: '#/components/parameters/Protocol'
- $ref: '#/components/parameters/AppVersion'
- $ref: '#/components/parameters/AppVersionName'
- $ref: '#/components/parameters/AcceptLanguage'
responses:
'200':
description: The trends.
headers:
X-Kvitt-Server-Version:
$ref: '#/components/headers/ServerVersion'
content:
application/json; charset=utf-8:
schema:
$ref: '#/components/schemas/TrendsResponse'
'400':
$ref: '#/components/responses/BadRequest'
description: '`invalid_request` (bad `months` or `measure`, with `field`).'
'401':
$ref: '#/components/responses/Unauthorized'
'426':
$ref: '#/components/responses/ProtocolUnsupported'
'429':
$ref: '#/components/responses/RateLimited'
'500':
$ref: '#/components/responses/Internal'
'503':
$ref: '#/components/responses/Unavailable'
/analytics/forecast:
get:
operationId: getAnalyticsForecast
summary: E-35 Spending forecast per account
description: |
D-133, SPEC §14.5.7. For every non-deleted account (archived included) and each of the two calendar months
after the current one (Europe/Stockholm): `recurring_ore` = the amounts of the account's non-paused,
non-deleted recurring templates due in that month (one per template and month, within `start_period` and
`end_period`); `baseline_ore` = the account's spend over the last 3 whole months (the window of D-128's moving
average), expenses generated from a recurring template excluded, divided by 3 (half-up to öre; months without
spend count, so a new account has a lower baseline); `forecast_ore = recurring_ore + baseline_ore`. `current_month_ore` is the current month so
far, generated expenses included. `my_share` uses the shares a generation would write today.
parameters:
- name: measure
in: query
required: false
description: '`household` (full amounts, whoever paid) or `my_share` (the calling user''s shares), AC-14.4; other values are `invalid_request`.'
schema:
type: string
enum: [household, my_share]
default: household
- $ref: '#/components/parameters/Protocol'
- $ref: '#/components/parameters/AppVersion'
- $ref: '#/components/parameters/AppVersionName'
- $ref: '#/components/parameters/AcceptLanguage'
responses:
'200':
description: The forecast.
headers:
X-Kvitt-Server-Version:
$ref: '#/components/headers/ServerVersion'
content:
application/json; charset=utf-8:
schema:
$ref: '#/components/schemas/ForecastResponse'
'400':
$ref: '#/components/responses/BadRequest'
description: '`invalid_request` (bad `months` or `measure`, with `field`).'
'401':
$ref: '#/components/responses/Unauthorized'
'426':
$ref: '#/components/responses/ProtocolUnsupported'
'429':
$ref: '#/components/responses/RateLimited'
'500':
$ref: '#/components/responses/Internal'
'503':
$ref: '#/components/responses/Unavailable'
components:
securitySchemes:
device:
@@ -1630,6 +1733,10 @@ components:
SplitMode:
type: string
enum: [default, ratio, exact, full]
AnalyticsMeasure:
type: string
description: AC-14.4. `household` sums full expense amounts, whoever paid; `my_share` the calling user's shares.
enum: [household, my_share]
EntityType:
type: string
enum: [expense, settlement]
@@ -2318,6 +2425,116 @@ components:
items:
$ref: '#/components/schemas/Uuid'
# E-34 and E-35 (D-133, SPEC §14.5.7)
TrendPoint:
type: object
required: [month, amount_ore, moving_average_ore]
properties:
month:
$ref: '#/components/schemas/Period'
amount_ore:
$ref: '#/components/schemas/Ore'
moving_average_ore:
$ref: '#/components/schemas/Ore'
description: Trailing over 3 months; the first points average what exists (D-128).
Trend:
type: object
required: [total_ore, average_ore, last_month_ore, last_month_vs_average_pct, current_month_ore, points]
properties:
total_ore:
$ref: '#/components/schemas/Ore'
description: Sum over the whole months.
average_ore:
$ref: '#/components/schemas/Ore'
description: '`total_ore` ÷ months, half-up to öre (empty months count).'
last_month_ore:
$ref: '#/components/schemas/Ore'
description: The last whole month (the month before the current one).
last_month_vs_average_pct:
type: [integer, 'null']
description: '`last_month_ore` against `average_ore`, whole percent of the average, half-up; null when the average is 0.'
current_month_ore:
$ref: '#/components/schemas/Ore'
description: The current month from its first day to today (Europe/Stockholm); not one of the whole months.
points:
type: array
description: One point per whole month, oldest first, empty months as zero.
items:
$ref: '#/components/schemas/TrendPoint'
CategoryTrend:
type: object
required: [category_id, trend]
properties:
category_id:
$ref: '#/components/schemas/Uuid'
trend:
$ref: '#/components/schemas/Trend'
TrendsResponse:
type: object
required: [measure, today, current_month, months, household, categories]
properties:
measure:
$ref: '#/components/schemas/AnalyticsMeasure'
today:
$ref: '#/components/schemas/Date'
description: Today in Europe/Stockholm.
current_month:
$ref: '#/components/schemas/Period'
months:
type: integer
description: The number of whole months in every series.
household:
$ref: '#/components/schemas/Trend'
categories:
type: array
description: Every category with spending in the whole months or the current month (archived and deleted
included), by `total_ore` descending, then `category_id`.
items:
$ref: '#/components/schemas/CategoryTrend'
ForecastMonth:
type: object
required: [month, recurring_ore, baseline_ore, forecast_ore]
properties:
month:
$ref: '#/components/schemas/Period'
recurring_ore:
$ref: '#/components/schemas/Ore'
baseline_ore:
$ref: '#/components/schemas/Ore'
forecast_ore:
$ref: '#/components/schemas/Ore'
description: '`recurring_ore + baseline_ore`.'
AccountForecast:
type: object
required: [account_id, current_month_ore, months]
properties:
account_id:
$ref: '#/components/schemas/Uuid'
current_month_ore:
$ref: '#/components/schemas/Ore'
description: The current month so far, recurring-generated expenses included.
months:
type: array
description: The two months after the current one, in order.
items:
$ref: '#/components/schemas/ForecastMonth'
ForecastResponse:
type: object
required: [measure, today, current_month, accounts]
properties:
measure:
$ref: '#/components/schemas/AnalyticsMeasure'
today:
$ref: '#/components/schemas/Date'
description: Today in Europe/Stockholm.
current_month:
$ref: '#/components/schemas/Period'
accounts:
type: array
description: Every non-deleted account, archived included, by `created_at`, then `account_id`.
items:
$ref: '#/components/schemas/AccountForecast'
# ---- sync (SPEC §14.4) ----
SyncPullResponse:
type: object
+3 -2
View File
@@ -3,7 +3,8 @@
# The Mac fallback for the Gitea Actions workflow .gitea/workflows/build-images.yaml (which is the normal path).
# Usage: infra/build-images.sh [tag] tag defaults to the short git SHA of HEAD; `latest` is always tagged too.
#
# Images (D-104, D-120): ${KVITT_REGISTRY}/kvitt-server (context server/) and kvitt-backup (infra/backup).
# Images (D-104, D-120): ${KVITT_REGISTRY}/kvitt-server (context: the repository root, D-132; server/Dockerfile
# builds the web client too) and kvitt-backup (infra/backup).
# KVITT_REGISTRY and the optional credentials come from infra/secrets/registry.env (git-ignored):
# KVITT_REGISTRY=gitea.lan/jacobh2
# KVITT_REGISTRY_USER=<gitea user> optional
@@ -39,7 +40,7 @@ registry_host="${registry%%/*}"
# name|context|dockerfile
images=(
"kvitt-server|server|server/Dockerfile"
"kvitt-server|.|server/Dockerfile"
"kvitt-backup|infra/backup|infra/backup/Dockerfile"
)
+2 -2
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env bash
# Builds the development images on this Mac (native linux/arm64, nothing pushed):
# kvitt-server:dev from server/Dockerfile
# kvitt-server:dev from server/Dockerfile (context: the repository root, D-132: it builds web/ too)
# kvitt-backup:dev from infra/backup/Dockerfile
# Usage: infra/dev/build.sh [server|backup|all] (default: all)
# Refuses to run while a Gradle build holds the gradle-serial lock (CLAUDE.md: never build images during a Gradle build).
@@ -20,7 +20,7 @@ backup_image="$(env_value KVITT_BACKUP_IMAGE kvitt-backup:dev)"
build_server() {
local dockerfile="$repo_root/server/Dockerfile"
local context="${KVITT_SERVER_BUILD_CONTEXT:-$repo_root/server}"
local context="${KVITT_SERVER_BUILD_CONTEXT:-$repo_root}"
echo "Building $server_image from server/Dockerfile"
docker build --platform linux/arm64 -f "$dockerfile" -t "$server_image" "$context"
}
-2
View File
@@ -1,2 +0,0 @@
target/
**/*.sqlite*
+4 -4
View File
@@ -1838,9 +1838,9 @@ checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f"
[[package]]
name = "rustls-webpki"
version = "0.103.15"
version = "0.103.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281"
dependencies = [
"aws-lc-rs",
"ring",
@@ -2868,9 +2868,9 @@ dependencies = [
[[package]]
name = "webpki-root-certs"
version = "1.0.9"
version = "1.0.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b"
checksum = "58ec80e73fd8baf57bf0d9615554c75348bda990d6d5bbeab964dd068319fecb"
dependencies = [
"rustls-pki-types",
]
+31 -6
View File
@@ -1,9 +1,28 @@
# Kvitt server image (SPEC §10.3). Build context: server/. Built on the owner's M4 Mac, never on the Pi:
# docker build --platform linux/arm64 -f server/Dockerfile -t kvitt-server:dev server/
# Kvitt server image (SPEC §10.3). Build context: the repository root (D-132; the root .dockerignore lets only
# server/, web/ and contracts/ in). From the repository root:
# docker build --platform linux/arm64 -f server/Dockerfile -t kvitt-server:dev .
# linux/amd64 builds the same way (--platform linux/amd64; emulated on the Mac, slow). The Rust version is the one
# pinned in rust-toolchain.toml; the two always change together.
# pinned in rust-toolchain.toml; the two always change together. The image also carries the web client (web/,
# a Vite single-page app built with base '/app/'), which the server serves under /app/ from KVITT_WEB_DIR (/app).
# ---- stage 1: build --------------------------------------------------------------------------------------------
# ---- stage 1: web client ---------------------------------------------------------------------------------------
# Node.js 24, the Active LTS line on 2026-10-21 (26 becomes LTS at the end of October 2026). Pinned by tag and by
# the digest of its multi-arch index. Runs on the build platform: the output is static files, the same for every
# target, so an amd64 image never emulates npm. web/ without package.json or package-lock.json fails here, by design.
FROM --platform=$BUILDPLATFORM docker.io/library/node:24.22.1-bookworm-slim@sha256:55fb5f5a527ea2c5c4a95f963e48ef90aa38c7b346054fdc8ee9957a2b55d205 AS web
ENV CI=true \
NPM_CONFIG_AUDIT=false \
NPM_CONFIG_FUND=false \
NPM_CONFIG_UPDATE_NOTIFIER=false
# The repository layout is kept (/repo/web beside /repo/contracts), so the web build may read ../contracts.
WORKDIR /repo/web
COPY web/package.json web/package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci
COPY contracts/ /repo/contracts/
COPY web/ ./
RUN npm run build && test -f dist/index.html
# ---- stage 2: server build -------------------------------------------------------------------------------------
FROM docker.io/library/rust:1.99.1-bookworm AS build
ARG TARGETARCH
# Parallel rustc jobs. Unset (the default) means cargo's own default, one per core, as on the Mac. The Gitea runner
@@ -16,7 +35,11 @@ ARG CARGO_BUILD_JOBS
ENV RUSTUP_TOOLCHAIN=1.99.1 \
CARGO_TERM_COLOR=never
WORKDIR /src
COPY . .
COPY server/ .
# The web client passes through this stage on its way to the runtime image. That also orders the builds: BuildKit
# runs independent stages in parallel, and npm next to rustc could exhaust the 1 GB of the Pi 3 runner. A web-only
# change re-runs the cargo step below, which then finds nothing to compile (the target directory is a cache mount).
COPY --from=web /repo/web/dist /out/web
# Native build for the target platform: aarch64-unknown-linux-gnu on arm64 (generic Armv8-A, no
# target-cpu=native: the Pi 3 is a Cortex-A53), x86_64-unknown-linux-gnu on amd64.
RUN --mount=type=cache,target=/usr/local/cargo/registry \
@@ -32,11 +55,13 @@ RUN --mount=type=cache,target=/usr/local/cargo/registry \
&& mkdir -p /out \
&& cp "target/$triple/release/kvitt-server" "target/$triple/release/kvitt-import" /out/
# ---- stage 2: runtime ------------------------------------------------------------------------------------------
# ---- stage 3: runtime ------------------------------------------------------------------------------------------
# glibc + libgcc, no shell, no package manager. Pinned by digest (multi-arch index of :nonroot). It carries the
# Debian CA bundle at /etc/ssl/certs/ca-certificates.crt, which rustls-platform-verifier reads to reach Google.
FROM gcr.io/distroless/cc-debian12:nonroot@sha256:9dac0a79194e45a7da0158a9c6da57b217585af0786db3845d1f0ec1a0dd182f
COPY --from=build /out/kvitt-server /out/kvitt-import /usr/local/bin/
# The web client, served under /app/ (KVITT_WEB_DIR defaults to /app); owned by root, read-only for uid 10001.
COPY --from=build /out/web /app
# Runs as uid/gid 10001 with a read-only root filesystem; /data is the only writable volume and /secrets is
# mounted read-only (SPEC §8.9). Listens on 8080 inside the compose network.
USER 10001:10001
+10 -5
View File
@@ -34,6 +34,7 @@ Configuration is environment variables only (SPEC §10.4, exhaustive):
| `KVITT_FCM_CREDENTIALS` | `/secrets/fcm-service-account.json` | FCM key; missing file = push disabled (warning at start). |
| `KVITT_FCM_PROJECT_ID` | `kvitt-1337` | FCM project. |
| `KVITT_TRUSTED_PROXY` | `none` | IP literal or host name allowed to set `X-Forwarded-For` (on the Pi `HA_HOST`, NPM's LAN address); `none` or a name that does not resolve means no proxy. |
| `KVITT_WEB_DIR` | `/app` | The built web client, served under `/app/` (D-132): `index.html` for app routes, `assets/*` cached for a year, the app's own CSP. Missing directory: every `/app` path is 404 `not_found` (as with `cargo run`, unless you point it at `web/dist`). |
| `RUST_LOG` | `info` | tracing filter; logs are JSON lines on stdout. |
The default data directory `/data` is the container volume. When running outside Docker, set `KVITT_DATA_DIR`
@@ -141,7 +142,8 @@ database was never fetched it prints a message and skips the check; it never fai
protocol and token rules, error shapes against `contracts/examples/errors/`, wire types against every example in
`contracts/examples/`.
* `crates/kvitt-server/tests/core_api/`: E-2 to E-30, E-33, generation, rate limiting, pictures, CLI.
* `crates/kvitt-server/tests/core_api/`: E-2 to E-30, E-33 to E-35 (analytics, D-133), generation, rate limiting,
pictures, CLI, the web client under `/app/` (D-132).
* `crates/kvitt-server/tests/sync_api/`: every rule of SPEC §14.4 (pull, push steps 1 to 12, rejection reasons,
§9.10 conflicts, 14-day offline scenario, the push examples), audit writer, `rotate-epoch`, end to end push → pull,
`ConnectInfo` rate-limit buckets over real TCP.
@@ -161,11 +163,14 @@ target itself); the sync, push and scheduler binaries also include `tests/common
## Docker image
```sh
docker build --platform linux/arm64 -f server/Dockerfile -t kvitt-server:dev server/
# from the repository root: the build context is the root (D-132), filtered by the root .dockerignore
docker build --platform linux/arm64 -f server/Dockerfile -t kvitt-server:dev .
```
Build on the Mac, never on the Pi (`infra/dev/build.sh` does this for the dev stack). Stage 1 is
`rust:<pinned version>-bookworm`, building both binaries in release mode with `--locked`; stage 2 is
`gcr.io/distroless/cc-debian12:nonroot` pinned by digest, binaries in `/usr/local/bin/`, user 10001, entrypoint
`kvitt-server`, command `serve`. `--platform linux/amd64` also works (emulated on the Mac). The container needs only
`node:24.22.1-bookworm-slim` (pinned by digest, on the build platform), running `npm ci && npm run build` in `web/`;
stage 2 is `rust:<pinned version>-bookworm`, building both binaries in release mode with `--locked` (it copies the
web build through, so the two never run at the same time); stage 3 is `gcr.io/distroless/cc-debian12:nonroot`
pinned by digest, binaries in `/usr/local/bin/`, the web client in `/app`, user 10001, entrypoint `kvitt-server`,
command `serve`. `--platform linux/amd64` also works (emulated on the Mac). The container needs only
`/data` writable and runs with a read-only root filesystem.
+148
View File
@@ -0,0 +1,148 @@
//! Server-side analytics rules (D-133, SPEC §14.5.7): averages, the trailing moving average, the percent difference
//! of the last whole month against the average, and whether a recurring template occurs in a month.
//!
//! The rounding and window rules are the ones S-12 uses on the phone (D-128, `AnalyticsRules` in
//! `:feature:analytics`): division rounds half away from zero, the moving average trails over
//! [`MOVING_AVERAGE_WINDOW`] months and its first points average what exists, and the percent is taken against the
//! rounded average. Pure: no clock, no time zone; the caller picks the months.
use crate::recurring::{Period, RecurrenceError, next_candidate_period};
/// The trailing moving average covers the month and the two before it (D-128); the forecast baseline averages the
/// same number of whole months (D-133).
pub const MOVING_AVERAGE_WINDOW: usize = 3;
/// `numerator / denominator` rounded half away from zero (`3 / 2` is 2, `-3 / 2` is -2); 0 when `denominator` is 0.
pub fn div_half_up(numerator: i64, denominator: i64) -> i64 {
if denominator == 0 {
return 0;
}
let quotient = numerator / denominator;
let remainder = numerator % denominator;
if remainder.unsigned_abs().saturating_mul(2) >= denominator.unsigned_abs() {
let sign = if (numerator < 0) == (denominator < 0) {
1
} else {
-1
};
quotient + sign
} else {
quotient
}
}
/// The average per month of `values` (one per month, empty months as 0), half-up to öre; 0 for no months.
pub fn average(values: &[i64]) -> i64 {
let count = i64::try_from(values.len()).unwrap_or(i64::MAX);
div_half_up(values.iter().sum(), count)
}
/// The trailing moving average of `values` over `window` points, half-up to öre: each point with the `window − 1`
/// before it; the first points average what exists (1, then 2 points). A `window` of 0 counts as 1.
pub fn moving_average(values: &[i64], window: usize) -> Vec<i64> {
let window = window.max(1);
(0..values.len())
.map(|i| average(&values[(i + 1).saturating_sub(window)..=i]))
.collect()
}
/// The last month against the average, in whole percent of the (rounded) average, half-up: positive above, negative
/// below. `None` when the rounded average is 0 (no spending, or less than one öre per month).
pub fn percent_vs_average(last: i64, average: i64) -> Option<i64> {
if average == 0 {
return None;
}
let difference = last - average;
let percent = div_half_up(difference.abs().saturating_mul(100), average.abs());
Some(if difference < 0 { -percent } else { percent })
}
/// Whether a template with this schedule creates an expense for `period`: `start_period ≤ period`, `period ≤
/// end_period` (if set) and `period > last_generated_period` (if set) (SPEC §9.5). Paused and deleted templates are
/// the caller's to leave out.
pub fn occurs_in_period(
start_period: &str,
end_period: Option<&str>,
last_generated_period: Option<&str>,
period: Period,
) -> Result<bool, RecurrenceError> {
let end: Option<Period> = end_period.map(str::parse).transpose()?;
let first = next_candidate_period(start_period, end_period, last_generated_period)?;
Ok(match first {
Some(first) => first <= period && end.is_none_or(|end| period <= end),
None => false,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn period(text: &str) -> Period {
text.parse().unwrap()
}
#[test]
fn half_up_division_matches_the_phone() {
assert_eq!(div_half_up(3, 2), 2);
assert_eq!(div_half_up(4, 3), 1);
assert_eq!(div_half_up(5, 3), 2);
assert_eq!(div_half_up(-3, 2), -2);
assert_eq!(div_half_up(-4, 3), -1);
assert_eq!(div_half_up(0, 7), 0);
assert_eq!(div_half_up(7, 0), 0);
}
#[test]
fn average_counts_empty_months() {
assert_eq!(average(&[100, 200, 0]), 100);
assert_eq!(average(&[1, 2]), 2);
assert_eq!(average(&[50_000, 0, 50_000]), 33_333);
assert_eq!(average(&[]), 0);
assert_eq!(average(&[0, 0, 0]), 0);
}
#[test]
fn moving_average_trails_three_and_shortens_at_the_start() {
assert_eq!(MOVING_AVERAGE_WINDOW, 3);
assert_eq!(
moving_average(&[300, 0, 600, 900, 300], MOVING_AVERAGE_WINDOW),
vec![300, 150, 300, 500, 600]
);
assert_eq!(moving_average(&[1, 2], 3), vec![1, 2]);
assert_eq!(moving_average(&[7], 3), vec![7]);
assert_eq!(moving_average(&[], 3), Vec::<i64>::new());
assert_eq!(moving_average(&[4, 8], 0), vec![4, 8]);
}
#[test]
fn percent_against_the_rounded_average() {
assert_eq!(percent_vs_average(150, 100), Some(50));
assert_eq!(percent_vs_average(50, 100), Some(-50));
assert_eq!(percent_vs_average(100, 100), Some(0));
// 1/3 above: 33.33 % rounds to 33; 2/3 above: 66.67 % rounds to 67; exactly half rounds away from zero.
assert_eq!(percent_vs_average(400, 300), Some(33));
assert_eq!(percent_vs_average(500, 300), Some(67));
assert_eq!(percent_vs_average(1_005, 1_000), Some(1));
assert_eq!(percent_vs_average(995, 1_000), Some(-1));
assert_eq!(percent_vs_average(0, 100), Some(-100));
assert_eq!(percent_vs_average(5, 0), None);
assert_eq!(percent_vs_average(0, 0), None);
}
#[test]
fn occurrence_in_a_period() {
let p = period("2026-11");
assert!(occurs_in_period("2026-01", None, Some("2026-10"), p).unwrap());
assert!(occurs_in_period("2026-11", None, None, p).unwrap());
assert!(!occurs_in_period("2026-12", None, None, p).unwrap());
assert!(occurs_in_period("2026-01", Some("2026-11"), None, p).unwrap());
assert!(!occurs_in_period("2026-01", Some("2026-10"), None, p).unwrap());
assert!(!occurs_in_period("2026-01", None, Some("2026-11"), p).unwrap());
assert!(occurs_in_period("2026-01", None, Some("2026-10"), period("2027-01")).unwrap());
assert_eq!(
occurs_in_period("2026-13", None, None, p),
Err(RecurrenceError::PeriodInvalid)
);
}
}
+7 -1
View File
@@ -1,4 +1,5 @@
//! Kvitt domain rules as pure functions (SPEC §9.1, §9.3, §9.4, §9.5 `next_occurrence_date`, §14.5).
//! Kvitt domain rules as pure functions (SPEC §9.1, §9.3, §9.4, §9.5 `next_occurrence_date`, §14.5, and the
//! server-side analytics rules of §14.5.7).
//!
//! No I/O, no clock, no randomness, no locale. Ids are plain strings (the test vectors use short ids like `"a"`);
//! money is `i64` öre and basis points are `u32`. The Kotlin `:domain` module implements the same functions and both
@@ -8,6 +9,7 @@
#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used))]
mod allocation;
mod analytics;
mod balance;
mod recurring;
mod settlement;
@@ -17,6 +19,10 @@ pub use allocation::{
AccountPosition, Allocation, AllocationError, AllocationProposal, over_settled_accounts,
propose_allocation,
};
pub use analytics::{
MOVING_AVERAGE_WINDOW, average, div_half_up, moving_average, occurs_in_period,
percent_vs_average,
};
pub use balance::{BalanceExpense, BalanceSettlement, Balances, compute_balances};
pub use recurring::{
Period, RecurrenceError, RecurrenceSchedule, next_candidate_period, next_occurrence_date,
@@ -60,6 +60,15 @@ impl Period {
}
}
/// The month before, or `None` before 0001-01.
pub fn previous(self) -> Option<Self> {
if self.month == 1 {
Self::new(self.year.checked_sub(1)?, 12)
} else {
Self::new(self.year, self.month - 1)
}
}
/// Number of days in this month (Gregorian leap years).
pub fn days(self) -> u8 {
match self.month {
@@ -184,6 +193,15 @@ mod tests {
next_occurrence_date(s).unwrap()
}
#[test]
fn previous_month() {
let p = |s: &str| s.parse::<Period>().unwrap();
assert_eq!(p("2026-03").previous(), Some(p("2026-02")));
assert_eq!(p("2026-01").previous(), Some(p("2025-12")));
assert_eq!(p("0001-01").previous(), None);
assert_eq!(p("2025-12").next(), Some(p("2026-01")));
}
#[test]
fn never_generated_starts_at_start_period() {
assert_eq!(
@@ -0,0 +1,526 @@
//! E-34 spending trends and E-35 forecast (D-133, SPEC §14.5.7): read-only, computed per request from the
//! non-deleted expenses. Settlements never count as spend (AC-14.6); archived accounts and categories count
//! (AC-14.9); `measure` is `household` or `my_share` (AC-14.4).
//!
//! Months are calendar months of the stored `expense_date` (a plain `YYYY-MM-DD`, no time zone), and "today" is the
//! calendar date in Europe/Stockholm. SQLite sums per group and month (`substr(expense_date, 1, 7)`), so no request
//! walks the expenses in Rust; the averages and percentages then come from `kvitt_domain` (§14.5.7).
use std::collections::{BTreeMap, HashMap};
use axum::extract::{RawQuery, State};
use chrono::{Datelike, NaiveDate};
use kvitt_domain::{MOVING_AVERAGE_WINDOW, Period};
use sqlx::SqliteConnection;
use uuid::Uuid;
use crate::AppState;
use crate::api::{JsonBody, recurring, rows};
use crate::auth::AuthDevice;
use crate::error::AppError;
use crate::openapi::responses::BadRequest;
use crate::time::stockholm_today;
use crate::wire::{
AccountForecast, AnalyticsMeasure, AnalyticsMeasureParamSchema, AnalyticsMonthsSchema,
CategoryTrend, ForecastMonth, ForecastResponse, Trend, TrendPoint, TrendsResponse,
};
/// Default of E-34's `months`.
pub const DEFAULT_MONTHS: i64 = 6;
/// Largest E-34 `months`.
pub const MAX_MONTHS: i64 = 24;
/// Months E-35 forecasts: the two after the current one.
pub const FORECAST_MONTHS: usize = 2;
/// Whole months the E-35 baseline averages over: the window of D-128's moving average, shared (D-133).
pub const BASELINE_MONTHS: i64 = MOVING_AVERAGE_WINDOW as i64;
/// The query parameters of E-34 and E-35. Unknown keys are ignored (E-35 has no `months`); the last occurrence of a
/// key wins.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct AnalyticsQuery {
measure: AnalyticsMeasure,
months: i64,
}
fn parse_query(query: Option<&str>, with_months: bool) -> Result<AnalyticsQuery, AppError> {
let mut parsed = AnalyticsQuery {
measure: AnalyticsMeasure::Household,
months: DEFAULT_MONTHS,
};
for pair in query.unwrap_or("").split('&').filter(|p| !p.is_empty()) {
let (key, value) = pair.split_once('=').unwrap_or((pair, ""));
match key {
"measure" => {
parsed.measure = match value {
"household" => AnalyticsMeasure::Household,
"my_share" => AnalyticsMeasure::MyShare,
_ => {
return Err(AppError::invalid_request(
"measure must be household or my_share",
)
.with_field("measure"));
}
};
}
"months" if with_months => {
parsed.months = value
.parse::<i64>()
.ok()
.filter(|m| (1..=MAX_MONTHS).contains(m))
.ok_or_else(|| {
AppError::invalid_request(format!(
"months must be an integer from 1 to {MAX_MONTHS}"
))
.with_field("months")
})?;
}
_ => {}
}
}
Ok(parsed)
}
/// "Today" in Europe/Stockholm and its month.
struct Calendar {
today: NaiveDate,
current: Period,
}
impl Calendar {
fn of(state: &AppState) -> Result<Self, AppError> {
let today = stockholm_today(state.clock.now());
let current = Period::new(
u16::try_from(today.year()).map_err(AppError::internal)?,
u8::try_from(today.month()).map_err(AppError::internal)?,
)
.ok_or_else(|| AppError::internal("today is outside 0001 to 9999"))?;
Ok(Self { today, current })
}
fn today_text(&self) -> String {
self.today.format("%Y-%m-%d").to_string()
}
/// The `count` whole months before the current one, oldest first.
fn whole_months(&self, count: i64) -> Result<Vec<Period>, AppError> {
let mut months = Vec::new();
let mut month = self.current;
for _ in 0..count {
month = month
.previous()
.ok_or_else(|| AppError::internal("month before 0001-01"))?;
months.push(month);
}
months.reverse();
Ok(months)
}
/// The `count` months after the current one, in order.
fn next_months(&self, count: usize) -> Result<Vec<Period>, AppError> {
let mut months = Vec::new();
let mut month = self.current;
for _ in 0..count {
month = month
.next()
.ok_or_else(|| AppError::internal("month after 9999-12"))?;
months.push(month);
}
Ok(months)
}
}
/// The first day of `month`, `YYYY-MM-01`.
fn first_day(month: Period) -> String {
format!("{month}-01")
}
/// One row of [`monthly_sums`]: the group id, the month, whether the expenses were generated from a recurring
/// template, and their sum for the measure.
type MonthlySum = (String, String, bool, i64);
/// What [`monthly_sums`] groups by.
#[derive(Debug, Clone, Copy)]
enum GroupBy {
Category,
Account,
}
/// The statement of [`monthly_sums`] for one group column, value and join (constants only).
macro_rules! monthly_sums_sql {
($group:literal, $value:literal, $join:literal) => {
concat!(
"SELECT ",
$group,
", substr(e.expense_date, 1, 7) AS month, ",
"e.recurring_template_id IS NOT NULL AS generated, SUM(",
$value,
") FROM expenses e ",
$join,
" WHERE e.deleted_at IS NULL AND e.expense_date >= ? AND e.expense_date <= ? ",
"GROUP BY ",
$group,
", month, generated"
)
};
}
/// Sums of the non-deleted expenses dated `from` to `to` (inclusive, `YYYY-MM-DD`), per group, month and generated
/// flag. `user` set: that user's shares (`my_share`, the join binds the user before the dates); `None`: the full
/// amounts (`household`).
async fn monthly_sums(
conn: &mut SqliteConnection,
group_by: GroupBy,
user: Option<Uuid>,
from: &str,
to: &str,
) -> Result<Vec<MonthlySum>, AppError> {
let sql: &'static str = match (group_by, user.is_some()) {
(GroupBy::Category, false) => monthly_sums_sql!("e.category_id", "e.amount_ore", ""),
(GroupBy::Account, false) => monthly_sums_sql!("e.account_id", "e.amount_ore", ""),
(GroupBy::Category, true) => monthly_sums_sql!(
"e.category_id",
"s.share_ore",
"JOIN expense_splits s ON s.expense_id = e.id AND s.user_id = ?"
),
(GroupBy::Account, true) => monthly_sums_sql!(
"e.account_id",
"s.share_ore",
"JOIN expense_splits s ON s.expense_id = e.id AND s.user_id = ?"
),
};
let mut query = sqlx::query_as::<_, MonthlySum>(sql);
if let Some(user) = user {
query = query.bind(user.to_string());
}
Ok(query.bind(from).bind(to).fetch_all(&mut *conn).await?)
}
/// The user whose shares `measure` adds up, if any.
fn measured_user(measure: AnalyticsMeasure, device: &AuthDevice) -> Option<Uuid> {
match measure {
AnalyticsMeasure::Household => None,
AnalyticsMeasure::MyShare => Some(device.user_id),
}
}
/// A [`Trend`] from the amounts of the whole months (oldest first) and the current month so far.
fn trend(months: &[Period], amounts: &[i64], current_month_ore: i64) -> Trend {
let average_ore = kvitt_domain::average(amounts);
let last_month_ore = amounts.last().copied().unwrap_or(0);
let moving = kvitt_domain::moving_average(amounts, MOVING_AVERAGE_WINDOW);
Trend {
total_ore: amounts.iter().sum(),
average_ore,
last_month_ore,
last_month_vs_average_pct: kvitt_domain::percent_vs_average(last_month_ore, average_ore),
current_month_ore,
points: months
.iter()
.zip(amounts)
.zip(moving)
.map(|((month, amount), moving_average_ore)| TrendPoint {
month: month.to_string(),
amount_ore: *amount,
moving_average_ore,
})
.collect(),
}
}
/// Amounts per whole month and the current month's, for one group.
#[derive(Debug, Clone, Default)]
struct GroupAmounts {
whole: Vec<i64>,
current: i64,
}
#[utoipa::path(
get,
path = "/analytics/trends",
operation_id = "getAnalyticsTrends",
summary = "E-34 Spending trends and category averages",
description = "D-133, SPEC §14.5.7. The last `months` whole calendar months before the current one (Europe/Stockholm), \
for the household and per category: each month's spend, the trailing 3-month moving average (the first months average \
what exists, D-128), the total, the average per month (half-up to öre, empty months count), the last whole month \
against the average in whole percent (half-up; null when the average is 0), and the current month so far. Non-deleted \
expenses only; settlements never count; archived and deleted categories count.",
params(
("months" = Option<AnalyticsMonthsSchema>, Query, description = "Whole months, 1 to 24 (`invalid_request` otherwise)."),
("measure" = Option<AnalyticsMeasureParamSchema>, Query, description = "`household` or `my_share` (`invalid_request` otherwise), AC-14.4."),
),
responses(
(status = 200, description = "The trends.", body = TrendsResponse, content_type = "application/json; charset=utf-8"),
(status = 400, response = BadRequest),
)
)]
pub async fn get_trends(
device: AuthDevice,
State(state): State<AppState>,
RawQuery(query): RawQuery,
) -> Result<JsonBody<TrendsResponse>, AppError> {
let query = parse_query(query.as_deref(), true)?;
let calendar = Calendar::of(&state)?;
let months = calendar.whole_months(query.months)?;
let index: HashMap<String, usize> = months
.iter()
.enumerate()
.map(|(i, m)| (m.to_string(), i))
.collect();
let current_key = calendar.current.to_string();
let from = months
.first()
.map_or_else(|| first_day(calendar.current), |m| first_day(*m));
let mut conn = state.db.acquire().await?;
let sums = monthly_sums(
&mut conn,
GroupBy::Category,
measured_user(query.measure, &device),
&from,
&calendar.today_text(),
)
.await?;
drop(conn);
let empty = || GroupAmounts {
whole: vec![0; months.len()],
current: 0,
};
let mut household = empty();
let mut categories: BTreeMap<String, GroupAmounts> = BTreeMap::new();
for (category_id, month, _generated, amount) in sums {
let category = categories.entry(category_id).or_insert_with(empty);
if let Some(&i) = index.get(&month) {
category.whole[i] += amount;
household.whole[i] += amount;
} else if month == current_key {
category.current += amount;
household.current += amount;
}
}
let mut categories: Vec<CategoryTrend> = categories
.into_iter()
.filter(|(_, a)| a.current != 0 || a.whole.iter().any(|v| *v != 0))
.map(|(id, a)| {
Ok(CategoryTrend {
category_id: rows::uuid(&id)?,
trend: trend(&months, &a.whole, a.current),
})
})
.collect::<Result<_, AppError>>()?;
categories.sort_by(|a, b| {
b.trend
.total_ore
.cmp(&a.trend.total_ore)
.then_with(|| a.category_id.cmp(&b.category_id))
});
Ok(JsonBody(TrendsResponse {
measure: query.measure,
today: calendar.today_text(),
current_month: current_key,
months: query.months,
household: trend(&months, &household.whole, household.current),
categories,
}))
}
/// Per account: the recurring amount due in each forecast month, for the measure.
async fn recurring_per_account(
conn: &mut SqliteConnection,
user: Option<Uuid>,
forecast: &[Period],
) -> Result<HashMap<Uuid, Vec<i64>>, AppError> {
let ids: Vec<String> = sqlx::query_scalar(
"SELECT id FROM recurring_templates WHERE deleted_at IS NULL AND paused = 0 ORDER BY id",
)
.fetch_all(&mut *conn)
.await?;
let users = match user {
Some(_) => rows::household(conn).await?,
None => Vec::new(),
};
let mut due: HashMap<Uuid, Vec<i64>> = HashMap::new();
for id in ids {
let Some(template) = rows::template(conn, rows::uuid(&id)?).await? else {
continue;
};
let amount = match user {
None => template.amount_ore,
// The shares a generation would write now (mode default: the account's current default ratio).
Some(user) => recurring::resolve_splits(conn, &template, &users)
.await?
.iter()
.find(|s| s.user_id == user)
.map_or(0, |s| s.share_ore),
};
let amounts = due
.entry(template.account_id)
.or_insert_with(|| vec![0; forecast.len()]);
for (slot, month) in amounts.iter_mut().zip(forecast) {
// One expense per template and month (SPEC §9.5).
if kvitt_domain::occurs_in_period(
&template.start_period,
template.end_period.as_deref(),
template.last_generated_period.as_deref(),
*month,
)
.map_err(AppError::internal)?
{
*slot += amount;
}
}
}
Ok(due)
}
#[utoipa::path(
get,
path = "/analytics/forecast",
operation_id = "getAnalyticsForecast",
summary = "E-35 Spending forecast per account",
description = "D-133, SPEC §14.5.7. For every non-deleted account (archived included) and each of the two calendar \
months after the current one (Europe/Stockholm): `recurring_ore` = the amounts of the account's non-paused, \
non-deleted recurring templates due in that month (one per template and month, within `start_period` and \
`end_period`); `baseline_ore` = the account's spend over the last 3 whole months (the window of D-128's \
moving average), expenses generated from a recurring template excluded, divided by 3 (half-up to öre; months without spend count, so a new account has a lower \
baseline); `forecast_ore = recurring_ore + baseline_ore`. `current_month_ore` is the current month so far, \
generated expenses included. `my_share` uses the shares a generation would write today.",
params(
("measure" = Option<AnalyticsMeasureParamSchema>, Query, description = "`household` or `my_share` (`invalid_request` otherwise), AC-14.4."),
),
responses(
(status = 200, description = "The forecast.", body = ForecastResponse, content_type = "application/json; charset=utf-8"),
(status = 400, response = BadRequest),
)
)]
pub async fn get_forecast(
device: AuthDevice,
State(state): State<AppState>,
RawQuery(query): RawQuery,
) -> Result<JsonBody<ForecastResponse>, AppError> {
let query = parse_query(query.as_deref(), false)?;
let user = measured_user(query.measure, &device);
let calendar = Calendar::of(&state)?;
let baseline_months = calendar.whole_months(BASELINE_MONTHS)?;
let forecast_months = calendar.next_months(FORECAST_MONTHS)?;
let current_key = calendar.current.to_string();
let baseline_keys: Vec<String> = baseline_months.iter().map(ToString::to_string).collect();
let from = baseline_months
.first()
.map_or_else(|| first_day(calendar.current), |m| first_day(*m));
let mut conn = state.db.acquire().await?;
let accounts: Vec<String> = sqlx::query_scalar(
"SELECT id FROM accounts WHERE deleted_at IS NULL ORDER BY created_at, id",
)
.fetch_all(&mut *conn)
.await?;
let sums = monthly_sums(
&mut conn,
GroupBy::Account,
user,
&from,
&calendar.today_text(),
)
.await?;
let recurring = recurring_per_account(&mut conn, user, &forecast_months).await?;
drop(conn);
// Per account: (baseline total over the whole months, current month so far).
let mut spend: HashMap<String, (i64, i64)> = HashMap::new();
for (account_id, month, generated, amount) in sums {
let entry = spend.entry(account_id).or_default();
if month == current_key {
entry.1 += amount;
} else if !generated && baseline_keys.contains(&month) {
entry.0 += amount;
}
}
let accounts = accounts
.into_iter()
.map(|id| {
let account_id = rows::uuid(&id)?;
let (baseline_total, current_month_ore) = spend.get(&id).copied().unwrap_or((0, 0));
let baseline_ore = kvitt_domain::div_half_up(baseline_total, BASELINE_MONTHS);
let due = recurring.get(&account_id);
Ok(AccountForecast {
account_id,
current_month_ore,
months: forecast_months
.iter()
.enumerate()
.map(|(i, month)| {
let recurring_ore = due.and_then(|d| d.get(i)).copied().unwrap_or(0);
ForecastMonth {
month: month.to_string(),
recurring_ore,
baseline_ore,
forecast_ore: recurring_ore + baseline_ore,
}
})
.collect(),
})
})
.collect::<Result<_, AppError>>()?;
Ok(JsonBody(ForecastResponse {
measure: query.measure,
today: calendar.today_text(),
current_month: current_key,
accounts,
}))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn query_defaults_and_values() {
let q = parse_query(None, true).unwrap();
assert_eq!(q.measure, AnalyticsMeasure::Household);
assert_eq!(q.months, 6);
let q = parse_query(Some("months=24&measure=my_share&other=1"), true).unwrap();
assert_eq!(q.measure, AnalyticsMeasure::MyShare);
assert_eq!(q.months, 24);
assert_eq!(parse_query(Some("months=1"), true).unwrap().months, 1);
// E-35 ignores `months`.
assert_eq!(parse_query(Some("months=99"), false).unwrap().months, 6);
}
#[test]
fn query_errors_name_the_parameter() {
for bad in ["months=0", "months=25", "months=x", "months=", "months=-1"] {
let e = parse_query(Some(bad), true).unwrap_err();
assert_eq!(e.field.as_deref(), Some("months"), "{bad}");
}
for bad in ["measure=mine", "measure=", "measure=Household"] {
let e = parse_query(Some(bad), true).unwrap_err();
assert_eq!(e.field.as_deref(), Some("measure"), "{bad}");
}
}
#[test]
fn trend_fields() {
let months: Vec<Period> = ["2026-07", "2026-08", "2026-09"]
.iter()
.map(|m| m.parse().unwrap())
.collect();
let t = trend(&months, &[300, 0, 600], 50);
assert_eq!(t.total_ore, 900);
assert_eq!(t.average_ore, 300);
assert_eq!(t.last_month_ore, 600);
assert_eq!(t.last_month_vs_average_pct, Some(100));
assert_eq!(t.current_month_ore, 50);
let moving: Vec<i64> = t.points.iter().map(|p| p.moving_average_ore).collect();
assert_eq!(moving, vec![300, 150, 300]);
assert_eq!(t.points[0].month, "2026-07");
assert_eq!(
trend(&months, &[0, 0, 0], 0).last_month_vs_average_pct,
None
);
}
}
@@ -3,6 +3,7 @@
//! row access and input rules the handlers, the sync protocol and the importer share.
pub mod accounts;
pub mod analytics;
pub mod apk;
pub mod categories;
pub mod devices;
@@ -18,6 +19,7 @@ pub mod rows;
pub mod setup;
pub mod sync;
pub mod validate;
pub mod web;
use axum::extract::rejection::{JsonRejection, PathRejection};
use axum::extract::{FromRequest, FromRequestParts, Request};
@@ -1,6 +1,8 @@
//! Rate limiting (SPEC §8.6, AC-2.12, §8.5), as a tower layer that `router.rs` attaches to every route.
//!
//! * Unauthenticated endpoints (E-1 to E-5 and unknown paths): at most 60 requests per minute per client IP.
//! * Unauthenticated endpoints (E-1 to E-5, the web client's static files under `/app/` and unknown paths): at most
//! 60 requests per minute per client IP. A page load of the web client is its `index.html` and a handful of hashed
//! assets, which the browser then caches for a year, so 60 per minute leaves room for reloads (D-132).
//! * APK downloads (E-6, `/apk/files/{file_name}`, D-119): at most 10 requests per minute per client IP, in a bucket
//! of their own that does not count against the 60 (security review L-3: each answer can be a 10 MB file).
//! * Authenticated endpoints: at most 600 requests per minute per device, keyed by the hash of the bearer token
@@ -62,14 +64,18 @@ pub const PROXY_RESOLVE_INTERVAL: std::time::Duration = std::time::Duration::fro
/// How often expired windows are pruned from memory.
const PRUNE_INTERVAL: Duration = Duration::minutes(10);
/// Route templates of the unauthenticated endpoints (E-1 to E-6).
const PUBLIC_ROUTES: [&str; 6] = [
/// Route templates of the unauthenticated endpoints (E-1 to E-6) and of the web client (D-132), which count against
/// the client IP even when a request carries a bearer token.
const PUBLIC_ROUTES: [&str; 9] = [
"/health",
"/setup/status",
"/invites/accept",
"/i/{code}",
"/apk/latest",
"/apk/files/{file_name}",
"/app",
"/app/",
"/app/{*path}",
];
const ACCEPT_ROUTE: &str = "/invites/accept";
/// Route template of E-6, limited by [`APK_DOWNLOAD_LIMIT`] instead of [`UNAUTHENTICATED_LIMIT`].
@@ -58,8 +58,8 @@ pub struct GeneratedExpense {
pub period: String,
}
/// The expense splits of a generated expense.
async fn resolve_splits(
/// The expense splits of a generated expense (also E-35's `my_share` forecast of a template).
pub(crate) async fn resolve_splits(
conn: &mut SqliteConnection,
template: &RecurringTemplate,
users: &[User],
+245
View File
@@ -0,0 +1,245 @@
//! The web client under `/app/` (D-132, SPEC §10.4 `KVITT_WEB_DIR`): static files of the single-page app built into
//! the image at `/app`. Not an API route: no protocol check, no token, not in `contracts/openapi.yaml`.
//!
//! * `GET /app` redirects to `/app/` (308); `/app/` and `/app/index.html` serve `index.html`.
//! * `GET /app/<path>` serves the file when `<path>` is safe (below) and names a file in the directory. A missing or
//! unsafe path falls back to `index.html` (the app routes in the browser), except under `assets/`, where a missing
//! file is 404 `not_found`: a hashed asset that is gone must fail, not load HTML.
//! * Caching: `assets/*` (content-hashed names) `public, max-age=31536000, immutable`; `index.html` `no-store` (it names
//! the current assets); other files `no-cache`.
//! * Headers: the app's own `Content-Security-Policy` ([`APP_CSP`]; the strict policy NPM sets elsewhere would block the
//! app's scripts and API calls), `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, `Referrer-Policy:
//! no-referrer`.
//! * Without the directory (an image built without the app, the development `cargo run`) every `/app` path is 404
//! `not_found`, as before the web client existed.
//!
//! Path safety: a path is at most [`MAX_PATH_LEN`] bytes and [`MAX_SEGMENTS`] segments, and each segment is 1 to 255
//! characters of `A-Z a-z 0-9 . _ -` that does not start with `.`. So no `..`, no hidden file, no absolute path, no
//! back-slash or percent trick reaches the file system: an unsafe path is never joined to the directory.
use std::path::Path as FsPath;
use axum::extract::{Path, State};
use axum::http::{HeaderValue, header};
use axum::response::{IntoResponse, Redirect, Response};
use crate::AppState;
use crate::error::AppError;
/// The prefix the app is served under; Vite builds it with `base: '/app/'`.
pub const APP_PREFIX: &str = "/app/";
/// The `Content-Security-Policy` of every response under `/app/`: exactly the policy NPM sets for `/app/` (D-132,
/// `$kvitt_csp` in `infra/npm/kvitt.conf`), so the development stack behaves as the Pi does.
pub const APP_CSP: &str = "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'";
/// `Cache-Control` of `assets/*`.
pub const CACHE_IMMUTABLE: &str = "public, max-age=31536000, immutable";
/// `Cache-Control` of `index.html`.
pub const CACHE_INDEX: &str = "no-store";
/// `Cache-Control` of the other files (favicon, manifest).
pub const CACHE_OTHER: &str = "no-cache";
/// Longest path under `/app/` that is looked up, in bytes.
pub const MAX_PATH_LEN: usize = 512;
/// Most segments of a path under `/app/` that is looked up.
pub const MAX_SEGMENTS: usize = 8;
const INDEX: &str = "index.html";
const ASSETS: &str = "assets/";
/// Whether `path` (relative, without the `/app/` prefix) may be joined to the web directory.
pub fn is_safe_path(path: &str) -> bool {
path.len() <= MAX_PATH_LEN
&& path.split('/').count() <= MAX_SEGMENTS
&& path.split('/').all(|segment| {
(1..=255).contains(&segment.len())
&& !segment.starts_with('.')
&& segment
.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'))
})
}
/// The media type of a served file, from its extension.
pub fn content_type(path: &str) -> &'static str {
let extension = path
.rsplit_once('.')
.map(|(_, e)| e.to_ascii_lowercase())
.unwrap_or_default();
match extension.as_str() {
"html" => "text/html; charset=utf-8",
"js" | "mjs" => "text/javascript; charset=utf-8",
"css" => "text/css; charset=utf-8",
"json" | "map" => "application/json; charset=utf-8",
"webmanifest" => "application/manifest+json",
"svg" => "image/svg+xml",
"png" => "image/png",
"jpg" | "jpeg" => "image/jpeg",
"webp" => "image/webp",
"ico" => "image/x-icon",
"woff2" => "font/woff2",
"woff" => "font/woff",
"txt" => "text/plain; charset=utf-8",
"wasm" => "application/wasm",
_ => "application/octet-stream",
}
}
fn cache_control(path: &str) -> &'static str {
if path.starts_with(ASSETS) {
CACHE_IMMUTABLE
} else if path == INDEX {
CACHE_INDEX
} else {
CACHE_OTHER
}
}
/// The bytes of `dir/path` when it is a regular file; `None` when it is missing or not a file.
async fn read_file(dir: &FsPath, path: &str) -> Result<Option<Vec<u8>>, AppError> {
let full = dir.join(path);
match tokio::fs::metadata(&full).await {
Ok(meta) if meta.is_file() => {}
Ok(_) => return Ok(None),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(AppError::internal(format!("cannot stat web file: {e}"))),
}
match tokio::fs::read(&full).await {
Ok(bytes) => Ok(Some(bytes)),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(AppError::internal(format!("cannot read web file: {e}"))),
}
}
async fn web_dir_exists(state: &AppState) -> bool {
tokio::fs::metadata(&state.config.web_dir)
.await
.is_ok_and(|m| m.is_dir())
}
fn file_response(path: &str, bytes: Vec<u8>) -> Response {
let mut response = bytes.into_response();
let headers = response.headers_mut();
headers.insert(
header::CONTENT_TYPE,
HeaderValue::from_static(content_type(path)),
);
headers.insert(
header::CACHE_CONTROL,
HeaderValue::from_static(cache_control(path)),
);
headers.insert(
header::CONTENT_SECURITY_POLICY,
HeaderValue::from_static(APP_CSP),
);
headers.insert(
header::X_CONTENT_TYPE_OPTIONS,
HeaderValue::from_static("nosniff"),
);
headers.insert(header::X_FRAME_OPTIONS, HeaderValue::from_static("DENY"));
headers.insert(
header::REFERRER_POLICY,
HeaderValue::from_static("no-referrer"),
);
response
}
/// Serves `path` (relative to the web directory; empty for the index) with the fallback rules of the module docs.
async fn serve(state: &AppState, path: &str) -> Result<Response, AppError> {
if !web_dir_exists(state).await {
return Err(AppError::not_found());
}
let dir = &state.config.web_dir;
if !path.is_empty()
&& is_safe_path(path)
&& let Some(bytes) = read_file(dir, path).await?
{
return Ok(file_response(path, bytes));
}
if path.starts_with(ASSETS) {
return Err(AppError::not_found());
}
match read_file(dir, INDEX).await? {
Some(bytes) => Ok(file_response(INDEX, bytes)),
None => Err(AppError::not_found()),
}
}
/// `GET /app`: 308 to `/app/` when the app is installed.
pub async fn get_app_root(State(state): State<AppState>) -> Result<Response, AppError> {
if web_dir_exists(&state).await {
Ok(Redirect::permanent(APP_PREFIX).into_response())
} else {
Err(AppError::not_found())
}
}
/// `GET /app/`: the index.
pub async fn get_app_index(State(state): State<AppState>) -> Result<Response, AppError> {
serve(&state, "").await
}
/// `GET /app/{*path}`. A path that does not percent-decode to UTF-8 is never looked up: it gets the index.
pub async fn get_app_file(
State(state): State<AppState>,
path: Result<Path<String>, axum::extract::rejection::PathRejection>,
) -> Result<Response, AppError> {
match path {
Ok(Path(path)) => serve(&state, &path).await,
Err(_) => serve(&state, "").await,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn safe_paths() {
for ok in [
"index.html",
"assets/index-B2x_9a.js",
"favicon.svg",
"expenses/0192a003-0000-7000-8000-000000000001",
"a/b/c/d/e/f/g/h",
] {
assert!(is_safe_path(ok), "{ok}");
}
for bad in [
"",
"..",
"../kvitt.sqlite",
"assets/../../etc/passwd",
".env",
"assets/.hidden",
"/etc/passwd",
"a//b",
"a/",
"a\\b",
"a%2e%2e",
"caf\u{e9}",
"a b",
"a/b/c/d/e/f/g/h/i",
"\u{0}",
] {
assert!(!is_safe_path(bad), "{bad:?}");
}
assert!(!is_safe_path(&"a".repeat(256)));
assert!(!is_safe_path(&format!("{}/x", "a/".repeat(300))));
}
#[test]
fn content_types_and_caching() {
assert_eq!(content_type("index.html"), "text/html; charset=utf-8");
assert_eq!(
content_type("assets/a-1.JS"),
"text/javascript; charset=utf-8"
);
assert_eq!(content_type("assets/a-1.css"), "text/css; charset=utf-8");
assert_eq!(content_type("favicon.svg"), "image/svg+xml");
assert_eq!(content_type("LICENSE"), "application/octet-stream");
assert_eq!(cache_control("assets/a-1.js"), CACHE_IMMUTABLE);
assert_eq!(cache_control("index.html"), CACHE_INDEX);
assert_eq!(cache_control("favicon.svg"), CACHE_OTHER);
}
}
+9
View File
@@ -19,6 +19,8 @@ pub const DEFAULT_FCM_PROJECT_ID: &str = "kvitt-1337";
pub const DEFAULT_TRUSTED_PROXY: &str = NO_TRUSTED_PROXY;
/// The `KVITT_TRUSTED_PROXY` value that means no proxy (compared ignoring case and surrounding white space).
pub const NO_TRUSTED_PROXY: &str = "none";
/// Default of `KVITT_WEB_DIR`: the web client's files in the image (D-132).
pub const DEFAULT_WEB_DIR: &str = "/app";
/// Default of `RUST_LOG`.
pub const DEFAULT_LOG_FILTER: &str = "info";
@@ -45,6 +47,9 @@ pub struct Config {
/// `KVITT_TRUSTED_PROXY`: host name whose resolved address may set `X-Forwarded-For`. Resolved lazily by the
/// rate limiter; [`NO_TRUSTED_PROXY`] (the default) and an unresolvable name mean "no proxy".
pub trusted_proxy: String,
/// `KVITT_WEB_DIR`: the built web client, served under `/app/` (`api::web`); a missing directory means no web
/// client (every `/app` path is 404).
pub web_dir: PathBuf,
}
impl Config {
@@ -79,6 +84,7 @@ impl Config {
fcm_credentials: PathBuf::from(get("KVITT_FCM_CREDENTIALS", DEFAULT_FCM_CREDENTIALS)),
fcm_project_id: get("KVITT_FCM_PROJECT_ID", DEFAULT_FCM_PROJECT_ID),
trusted_proxy: get("KVITT_TRUSTED_PROXY", DEFAULT_TRUSTED_PROXY),
web_dir: PathBuf::from(get("KVITT_WEB_DIR", DEFAULT_WEB_DIR)),
})
}
@@ -121,6 +127,7 @@ mod tests {
);
assert_eq!(config.fcm_project_id, "kvitt-1337");
assert_eq!(config.trusted_proxy, "none");
assert_eq!(config.web_dir, PathBuf::from("/app"));
assert_eq!(config.database_path(), PathBuf::from("/data/kvitt.sqlite"));
}
@@ -131,6 +138,7 @@ mod tests {
"KVITT_BIND" => Some("127.0.0.1:9000".into()),
"KVITT_PUBLIC_URL" => Some("http://192.168.1.10:8080/".into()),
"KVITT_TRUSTED_PROXY" => Some("192.168.1.20".into()),
"KVITT_WEB_DIR" => Some("/srv/kvitt-web".into()),
_ => None,
})
.unwrap();
@@ -141,6 +149,7 @@ mod tests {
assert_eq!(config.bind, "127.0.0.1:9000".parse().unwrap());
assert_eq!(config.public_url, "http://192.168.1.10:8080");
assert_eq!(config.trusted_proxy, "192.168.1.20");
assert_eq!(config.web_dir, PathBuf::from("/srv/kvitt-web"));
}
#[test]
@@ -85,6 +85,8 @@ const SERVER_VERSION_HEADER: &str = "X-Kvitt-Server-Version";
api::sync::sync_pull,
api::sync::sync_push,
api::proposal::propose_settlement,
api::analytics::get_trends,
api::analytics::get_forecast,
),
components(
schemas(
@@ -96,6 +98,8 @@ const SERVER_VERSION_HEADER: &str = "X-Kvitt-Server-Version";
wire::IntSchema,
wire::PictureSizeSchema,
wire::SyncLimitSchema,
wire::AnalyticsMeasureParamSchema,
wire::AnalyticsMonthsSchema,
),
responses(
responses::BadRequest,
+11 -5
View File
@@ -10,8 +10,8 @@ use axum::response::{IntoResponse, Response};
use axum::routing::{get, post, put};
use crate::api::{
accounts, apk, categories, devices, health, invites, landing, me, pictures, proposal,
rate_limit, recurring, setup, sync,
accounts, analytics, apk, categories, devices, health, invites, landing, me, pictures,
proposal, rate_limit, recurring, setup, sync, web,
};
use crate::error::{AppError, LoggedErrorCode};
use crate::{AppState, SERVER_VERSION, auth};
@@ -25,7 +25,7 @@ pub const SERVER_VERSION_HEADER: &str = "x-kvitt-server-version";
/// Builds the application.
pub fn router(state: AppState) -> Router {
// E-1 to E-6: no authentication, no protocol check.
// E-1 to E-6 and the web client under /app/: no authentication, no protocol check.
let public = Router::new()
.route("/health", get(health::get_health))
.route("/setup/status", get(setup::get_setup_status))
@@ -34,9 +34,13 @@ pub fn router(state: AppState) -> Router {
// `/i/` with an empty code: the landing page's HTML 404, not the JSON fallback.
.route("/i/", get(empty_invite_code))
.route("/apk/latest", get(apk::get_apk_latest))
.route("/apk/files/{file_name}", get(apk::get_apk_file));
.route("/apk/files/{file_name}", get(apk::get_apk_file))
// The web client (D-132): static files, not API routes (`api::web`).
.route("/app", get(web::get_app_root))
.route("/app/", get(web::get_app_index))
.route("/app/{*path}", get(web::get_app_file));
// E-7 to E-33: device token (the handlers' `AuthDevice` / `AdminDevice` extractors) and `X-Kvitt-Protocol: 1`.
// E-7 to E-35: device token (the handlers' `AuthDevice` / `AdminDevice` extractors) and `X-Kvitt-Protocol: 1`.
let authenticated = Router::new()
.route("/me", get(me::get_me).patch(me::patch_me))
.route(
@@ -97,6 +101,8 @@ pub fn router(state: AppState) -> Router {
)
.route("/sync", get(sync::sync_pull).post(sync::sync_push))
.route("/settlements/proposal", post(proposal::propose_settlement))
.route("/analytics/trends", get(analytics::get_trends))
.route("/analytics/forecast", get(analytics::get_forecast))
.route_layer(middleware::from_fn(auth::require_protocol));
Router::new()
@@ -0,0 +1,117 @@
//! Analytics responses (D-133, SPEC §14.3.4 E-34 and E-35, rules in §14.5.7): read-only, computed per request.
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use uuid::Uuid;
use super::{DateSchema, OreSchema, PeriodSchema, UuidSchema, int, nullable_int};
/// What a sum adds up (AC-14.4): `household` the full expense amounts, whoever paid; `my_share` the calling user's
/// shares.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum AnalyticsMeasure {
Household,
MyShare,
}
/// One whole month of a [`Trend`].
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
pub struct TrendPoint {
#[schema(value_type = PeriodSchema)]
pub month: String,
#[schema(value_type = OreSchema)]
pub amount_ore: i64,
/// Trailing over 3 months, the first points over what exists (D-128).
#[schema(value_type = OreSchema)]
pub moving_average_ore: i64,
}
/// Spending over the last whole months, for the household or one category.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
pub struct Trend {
/// Sum over the whole months.
#[schema(value_type = OreSchema)]
pub total_ore: i64,
/// `total_ore` ÷ months, half-up to öre (empty months count).
#[schema(value_type = OreSchema)]
pub average_ore: i64,
/// The last whole month (the month before the current one).
#[schema(value_type = OreSchema)]
pub last_month_ore: i64,
/// `last_month_ore` against `average_ore` in whole percent of the average, half-up; null when the average is 0.
#[schema(schema_with = nullable_int, required = true)]
pub last_month_vs_average_pct: Option<i64>,
/// The current month from its first day to today (Europe/Stockholm), not part of the whole months.
#[schema(value_type = OreSchema)]
pub current_month_ore: i64,
/// One point per whole month, oldest first, empty months as zero.
pub points: Vec<TrendPoint>,
}
/// A category's [`Trend`].
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
pub struct CategoryTrend {
#[schema(value_type = UuidSchema)]
pub category_id: Uuid,
pub trend: Trend,
}
/// E-34 response.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
pub struct TrendsResponse {
pub measure: AnalyticsMeasure,
/// Today in Europe/Stockholm.
#[schema(value_type = DateSchema)]
pub today: String,
#[schema(value_type = PeriodSchema)]
pub current_month: String,
/// The number of whole months in every series.
#[schema(schema_with = int)]
pub months: i64,
pub household: Trend,
/// Every category with spending in the whole months or the current month, archived and deleted categories
/// included; by `total_ore` descending, then `category_id`.
pub categories: Vec<CategoryTrend>,
}
/// One forecast month of an account.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
pub struct ForecastMonth {
#[schema(value_type = PeriodSchema)]
pub month: String,
/// The account's active recurring templates due in the month.
#[schema(value_type = OreSchema)]
pub recurring_ore: i64,
/// Average per month of the last 3 whole months, recurring-generated expenses excluded.
#[schema(value_type = OreSchema)]
pub baseline_ore: i64,
/// `recurring_ore + baseline_ore`.
#[schema(value_type = OreSchema)]
pub forecast_ore: i64,
}
/// One account's forecast.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
pub struct AccountForecast {
#[schema(value_type = UuidSchema)]
pub account_id: Uuid,
/// The current month from its first day to today (Europe/Stockholm), recurring-generated expenses included.
#[schema(value_type = OreSchema)]
pub current_month_ore: i64,
/// The two months after the current one, in order.
pub months: Vec<ForecastMonth>,
}
/// E-35 response.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
pub struct ForecastResponse {
pub measure: AnalyticsMeasure,
/// Today in Europe/Stockholm.
#[schema(value_type = DateSchema)]
pub today: String,
#[schema(value_type = PeriodSchema)]
pub current_month: String,
/// Every non-deleted account, archived included, by `created_at`, then `account_id`.
pub accounts: Vec<AccountForecast>,
}
@@ -6,6 +6,7 @@
//! Request bodies ignore unknown fields (serde's default).
pub mod account;
pub mod analytics;
pub mod audit;
pub mod category;
pub mod device;
@@ -17,6 +18,10 @@ pub mod sync;
pub mod user;
pub use account::{Account, CreateAccountRequest, PatchAccountRequest, Share};
pub use analytics::{
AccountForecast, AnalyticsMeasure, CategoryTrend, ForecastMonth, ForecastResponse, Trend,
TrendPoint, TrendsResponse,
};
pub use audit::{AuditAction, AuditChange, AuditEntry, AuditSource, EntityType};
pub use category::{Category, CreateCategoryRequest, DefaultCategoryKey, PatchCategoryRequest};
pub use device::{Device, DeviceList, PushTokenRequest};
@@ -191,6 +196,25 @@ primitive_schema!(
.default(Some(serde_json::json!(512)))
);
primitive_schema!(
/// The `measure` query parameter of E-34 and E-35 (AC-14.4): default `household`.
AnalyticsMeasureParamSchema,
"AnalyticsMeasureParam",
ObjectBuilder::new()
.schema_type(SchemaType::Type(Type::String))
.enum_values(Some(["household", "my_share"]))
.default(Some(serde_json::json!("household")))
);
primitive_schema!(
/// The `months` query parameter of E-34: 1 to 24, default 6.
AnalyticsMonthsSchema,
"AnalyticsMonths",
ObjectBuilder::new()
.schema_type(SchemaType::Type(Type::Integer))
.default(Some(serde_json::json!(6)))
);
primitive_schema!(
/// The `limit` query parameter of E-31: default 500.
SyncLimitSchema,
@@ -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,
+15 -1
View File
@@ -769,6 +769,7 @@ DEFAULT_KEY = E(*DEFAULT_KEYS)
KEYWORD_MATCHES = ["token", "prefix", "phrase"]
SPLIT_MODE = E("default", "ratio", "exact", "full")
ENTITY_TYPE = E("expense", "settlement")
MEASURE = E("household", "my_share")
REASON = E("stale", "deleted", "validation_failed", "reference_not_found", "clock_skew")
ERROR_CODES = ["invalid_request", "invite_invalid", "unauthorized", "device_revoked", "forbidden", "not_found",
"household_full", "household_incomplete", "name_taken", "account_not_empty", "category_in_use",
@@ -853,6 +854,16 @@ SHAPES = {
"ProposalRequest": S({"payer_user_id": "uuid", "amount_ore": "int", "exclude_settlement_id": N("uuid")},
optional=["exclude_settlement_id"]),
"ProposalResponse": S({"allocations": A("Allocation"), "excess_ore": "int", "over_settled_account_ids": A("uuid")}),
# E-34, E-35 analytics (D-133, §14.5.7)
"TrendPoint": S({"month": "period", "amount_ore": "int", "moving_average_ore": "int"}),
"Trend": S({"total_ore": "int", "average_ore": "int", "last_month_ore": "int", "last_month_vs_average_pct": N("int"),
"current_month_ore": "int", "points": A("TrendPoint", 1, 24)}),
"CategoryTrend": S({"category_id": "uuid", "trend": "Trend"}),
"TrendsResponse": S({"measure": MEASURE, "today": "date", "current_month": "period", "months": "int",
"household": "Trend", "categories": A("CategoryTrend")}),
"ForecastMonth": S({"month": "period", "recurring_ore": "int", "baseline_ore": "int", "forecast_ore": "int"}),
"AccountForecast": S({"account_id": "uuid", "current_month_ore": "int", "months": A("ForecastMonth", 2, 2)}),
"ForecastResponse": S({"measure": MEASURE, "today": "date", "current_month": "period", "accounts": A("AccountForecast")}),
# §14.4.2 and §14.4.3 sync
"SyncPullResponse": S({"epoch": "hex32", "since": "int", "next_since": "int", "has_more": "bool", "server_time": "ts",
"users": A("User"), "categories": A("Category"), "accounts": A("Account"),
@@ -1097,6 +1108,8 @@ EXAMPLES = {
"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-page.json": "SyncPullResponse",
"sync/pull-empty-page.json": "SyncPullResponse",
"sync/pull-page-categories-v2.json": "SyncPullResponse",
@@ -1458,6 +1471,7 @@ ENDPOINTS = [
("E-27", "delete", "/categories/{category_id}"), ("E-28", "post", "/recurring-templates"),
("E-29", "patch", "/recurring-templates/{template_id}"), ("E-30", "delete", "/recurring-templates/{template_id}"),
("E-31", "get", "/sync"), ("E-32", "post", "/sync"), ("E-33", "post", "/settlements/proposal"),
("E-34", "get", "/analytics/trends"), ("E-35", "get", "/analytics/forecast"),
]
UNAUTHENTICATED = {"E-1", "E-2", "E-3", "E-4", "E-5", "E-6"}
@@ -1500,7 +1514,7 @@ def check_openapi():
ops[(cur_method, cur_path)]["security"] = m.group(1).strip()
want = {(meth, p): eid for eid, meth, p in ENDPOINTS}
got = {k: v["eid"] for k, v in ops.items()}
report(got == want, "openapi operations are exactly E-1..E-33 of §7",
report(got == want, "openapi operations are exactly E-1..E-35 of §7",
"missing %s, extra %s, wrong ids %s" % (sorted(set(want) - set(got)), sorted(set(got) - set(want)),
sorted(k for k in want if k in got and got[k] != want[k])) if got != want else "%d operations" % len(got))
bad_sec = sorted(eid for (meth, p), eid in want.items() if (meth, p) in ops and