6c405e142d
crate::lock replaces three poison-tolerant lock helpers; the rate limiter reuses auth::bearer_token; the landing page reuses invites::display_code. Removed: AppError::not_implemented (D-004 placeholder, only its own test used it) and the unused Mutation::mutation_id/entity_type. tasks::start never awaited or failed: now a plain fn returning AppState. Milestone-ownership comments (M2a/M3/M4) rewritten to say what the code does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
226 lines
7.3 KiB
Rust
226 lines
7.3 KiB
Rust
//! Input validation shared by the REST handlers, the sync push checks and the importer (SPEC §8.6, §9.6, §9.7).
|
|
//!
|
|
//! Strings are trimmed of leading and trailing whitespace and their length is counted in Unicode code points after
|
|
//! trimming; the trimmed value is what the server stores.
|
|
|
|
use chrono::{DateTime, Datelike, Utc};
|
|
use kvitt_domain::Period;
|
|
|
|
use crate::error::AppError;
|
|
use crate::time::STOCKHOLM;
|
|
|
|
/// Category icon tokens (SPEC §9.7, exhaustive; `key`, `shield` and `account_balance` added by D-054).
|
|
pub const ICONS: [&str; 33] = [
|
|
"shopping_cart",
|
|
"restaurant",
|
|
"local_cafe",
|
|
"home",
|
|
"bolt",
|
|
"water_drop",
|
|
"wifi",
|
|
"directions_car",
|
|
"train",
|
|
"local_gas_station",
|
|
"flight",
|
|
"hotel",
|
|
"luggage",
|
|
"movie",
|
|
"music_note",
|
|
"sports_esports",
|
|
"fitness_center",
|
|
"medical_services",
|
|
"medication",
|
|
"shopping_bag",
|
|
"checkroom",
|
|
"pets",
|
|
"child_care",
|
|
"school",
|
|
"card_giftcard",
|
|
"celebration",
|
|
"build",
|
|
"chair",
|
|
"subscriptions",
|
|
"category",
|
|
"key",
|
|
"shield",
|
|
"account_balance",
|
|
];
|
|
|
|
/// Colour tokens (SPEC §9.7, exhaustive; `slate` and `amber` added by D-054).
|
|
pub const COLORS: [&str; 14] = [
|
|
"green", "orange", "brown", "yellow", "blue", "teal", "purple", "red", "pink", "grey",
|
|
"indigo", "lime", "slate", "amber",
|
|
];
|
|
|
|
/// Account name: 1 to 60 code points (SPEC §9.6).
|
|
pub const ACCOUNT_NAME_MAX: usize = 60;
|
|
/// Category name: 1 to 40 code points.
|
|
pub const CATEGORY_NAME_MAX: usize = 40;
|
|
/// User display name: 1 to 40 code points.
|
|
pub const DISPLAY_NAME_MAX: usize = 40;
|
|
/// Device name: 1 to 60 code points.
|
|
pub const DEVICE_NAME_MAX: usize = 60;
|
|
/// Expense and template description: 1 to 100 code points.
|
|
pub const DESCRIPTION_MAX: usize = 100;
|
|
/// Expense and template notes: 0 to 1000 code points.
|
|
pub const NOTES_MAX: usize = 1000;
|
|
/// How far back a template's `start_period` may lie, in months (SPEC §9.6, AC-9.8).
|
|
pub const START_PERIOD_BACKFILL_MONTHS: u32 = 12;
|
|
|
|
/// Trims `value` and checks its length in code points is within `min..=max`. Returns the trimmed value.
|
|
pub fn text(field: &str, value: &str, min: usize, max: usize) -> Result<String, AppError> {
|
|
let trimmed = value.trim();
|
|
let len = trimmed.chars().count();
|
|
if len < min || len > max {
|
|
return Err(AppError::validation(
|
|
field,
|
|
format!("{field} must be {min} to {max} characters after trimming"),
|
|
));
|
|
}
|
|
Ok(trimmed.to_owned())
|
|
}
|
|
|
|
/// Notes: trimmed, 0 to `NOTES_MAX` code points, no control characters other than newline (SPEC §8.6).
|
|
pub fn notes(field: &str, value: &str) -> Result<String, AppError> {
|
|
let trimmed = text(field, value, 0, NOTES_MAX)?;
|
|
if trimmed.chars().any(|c| c.is_control() && c != '\n') {
|
|
return Err(AppError::validation(
|
|
field,
|
|
format!("{field} may not contain control characters other than newline"),
|
|
));
|
|
}
|
|
Ok(trimmed)
|
|
}
|
|
|
|
/// An icon token of SPEC §9.7.
|
|
pub fn icon(value: &str) -> Result<String, AppError> {
|
|
if ICONS.contains(&value) {
|
|
Ok(value.to_owned())
|
|
} else {
|
|
Err(AppError::validation(
|
|
"icon",
|
|
"icon is not a token of SPEC §9.7",
|
|
))
|
|
}
|
|
}
|
|
|
|
/// A colour token of SPEC §9.7.
|
|
pub fn color(value: &str) -> Result<String, AppError> {
|
|
if COLORS.contains(&value) {
|
|
Ok(value.to_owned())
|
|
} else {
|
|
Err(AppError::validation(
|
|
"color",
|
|
"color is not a token of SPEC §9.7",
|
|
))
|
|
}
|
|
}
|
|
|
|
/// `amount_ore` within 1 to 1 000 000 000 (SPEC §9.6).
|
|
pub fn amount(field: &str, amount_ore: i64) -> Result<i64, AppError> {
|
|
if kvitt_domain::amount_in_range(amount_ore) {
|
|
Ok(amount_ore)
|
|
} else {
|
|
Err(AppError::validation(field, "amount_out_of_range"))
|
|
}
|
|
}
|
|
|
|
/// A period `YYYY-MM`.
|
|
pub fn period(field: &str, value: &str) -> Result<Period, AppError> {
|
|
value
|
|
.parse::<Period>()
|
|
.map_err(|_| AppError::validation(field, format!("{field} must be YYYY-MM")))
|
|
}
|
|
|
|
/// The current month in Europe/Stockholm.
|
|
pub fn current_period(now: DateTime<Utc>) -> Result<Period, AppError> {
|
|
let local = now.with_timezone(&STOCKHOLM);
|
|
let year = u16::try_from(local.year()).map_err(AppError::internal)?;
|
|
let month = u8::try_from(local.month()).map_err(AppError::internal)?;
|
|
Period::new(year, month).ok_or_else(|| AppError::internal("current month out of range"))
|
|
}
|
|
|
|
/// `period` minus `months` months, or `None` before year 1.
|
|
pub fn months_before(period: Period, months: u32) -> Option<Period> {
|
|
let index = i64::from(period.year()) * 12 + i64::from(period.month()) - 1 - i64::from(months);
|
|
let year = u16::try_from(index.div_euclid(12)).ok()?;
|
|
let month = u8::try_from(index.rem_euclid(12) + 1).ok()?;
|
|
Period::new(year, month)
|
|
}
|
|
|
|
/// A template `start_period`: `YYYY-MM`, not earlier than 12 months before the current month (SPEC §9.6).
|
|
pub fn start_period(value: &str, now: DateTime<Utc>) -> Result<Period, AppError> {
|
|
let p = period("start_period", value)?;
|
|
let earliest = months_before(current_period(now)?, START_PERIOD_BACKFILL_MONTHS);
|
|
if earliest.is_some_and(|earliest| p < earliest) {
|
|
return Err(AppError::validation(
|
|
"start_period",
|
|
"start_period may be at most 12 months before the current month",
|
|
));
|
|
}
|
|
Ok(p)
|
|
}
|
|
|
|
/// `day_of_month` within 1 to 31.
|
|
pub fn day_of_month(value: i64) -> Result<u8, AppError> {
|
|
u8::try_from(value)
|
|
.ok()
|
|
.filter(|d| (1..=31).contains(d))
|
|
.ok_or_else(|| AppError::validation("day_of_month", "day_of_month must be 1 to 31"))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn text_counts_code_points_after_trimming() {
|
|
assert_eq!(text("name", " Åsa ", 1, 3).unwrap(), "Åsa");
|
|
assert!(text("name", " ", 1, 3).is_err());
|
|
assert!(text("name", "Åsaa", 1, 3).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn notes_allow_newline_only() {
|
|
assert_eq!(notes("notes", "a\nb").unwrap(), "a\nb");
|
|
assert!(notes("notes", "a\tb").is_err());
|
|
assert!(notes("notes", "a\u{7}b").is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn months_before_crosses_years() {
|
|
let p: Period = "2026-10".parse().unwrap();
|
|
assert_eq!(months_before(p, 12).unwrap().to_string(), "2025-10");
|
|
assert_eq!(months_before(p, 10).unwrap().to_string(), "2025-12");
|
|
assert_eq!(months_before(p, 9).unwrap().to_string(), "2026-01");
|
|
}
|
|
|
|
#[test]
|
|
fn tokens() {
|
|
assert!(icon("pets").is_ok());
|
|
assert!(icon("dog").is_err());
|
|
assert!(color("lime").is_ok());
|
|
assert!(color("black").is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn tokens_added_by_schema_2() {
|
|
for token in ["key", "shield", "account_balance"] {
|
|
assert!(icon(token).is_ok(), "{token}");
|
|
}
|
|
for token in ["slate", "amber"] {
|
|
assert!(color(token).is_ok(), "{token}");
|
|
}
|
|
assert_eq!(ICONS.len(), 33);
|
|
assert_eq!(COLORS.len(), 14);
|
|
let mut icons = ICONS.to_vec();
|
|
icons.sort_unstable();
|
|
icons.dedup();
|
|
assert_eq!(icons.len(), ICONS.len(), "icon tokens are distinct");
|
|
let mut colors = COLORS.to_vec();
|
|
colors.sort_unstable();
|
|
colors.dedup();
|
|
assert_eq!(colors.len(), COLORS.len(), "colour tokens are distinct");
|
|
}
|
|
}
|