//! Every route of SPEC §7 is registered and implemented (none answers 501), behind the protocol rule (SPEC §14.3.1) //! and the auth extractor (SPEC §8.2, §8.4). use axum::Router; use axum::body::Body; use axum::http::{Method, Request, StatusCode}; use axum::routing::post; use chrono::Duration; use http_body_util::BodyExt; use kvitt_server::api::ApiJson; use kvitt_server::time::{Clock, parse_ts}; use serde_json::{Value, json}; use tower::ServiceExt; use crate::support::*; const ID: &str = "0192a003-0000-7000-8000-000000000001"; /// (method, path, admin only) of every authenticated route, E-7 to E-33. fn authenticated_routes() -> Vec<(Method, String, bool)> { let id = |p: &str| p.replace("{id}", ID); vec![ (Method::GET, "/me".into(), false), (Method::PATCH, "/me".into(), false), (Method::PUT, "/me/picture".into(), false), (Method::DELETE, "/me/picture".into(), false), (Method::GET, id("/users/{id}/picture"), false), (Method::POST, "/invites".into(), true), (Method::GET, "/invites".into(), true), (Method::DELETE, id("/invites/{id}"), true), (Method::GET, "/devices".into(), false), (Method::DELETE, id("/devices/{id}"), false), (Method::PUT, "/devices/current/push-token".into(), false), (Method::POST, "/accounts".into(), false), (Method::PATCH, id("/accounts/{id}"), false), (Method::POST, id("/accounts/{id}/archive"), false), (Method::POST, id("/accounts/{id}/unarchive"), false), (Method::DELETE, id("/accounts/{id}"), false), (Method::POST, "/categories".into(), false), (Method::PATCH, id("/categories/{id}"), false), (Method::POST, id("/categories/{id}/archive"), false), (Method::POST, id("/categories/{id}/unarchive"), false), (Method::DELETE, id("/categories/{id}"), false), (Method::POST, "/recurring-templates".into(), false), (Method::PATCH, id("/recurring-templates/{id}"), false), (Method::DELETE, id("/recurring-templates/{id}"), false), (Method::GET, "/sync?since=0".into(), false), (Method::POST, "/sync".into(), false), (Method::POST, "/settlements/proposal".into(), false), ] } /// E-3 to E-6 (E-1 and E-2 are implemented). fn public_stub_routes() -> Vec<(Method, &'static str)> { vec![ (Method::POST, "/invites/accept"), (Method::GET, "/i/7K3MQX9D2HPA"), (Method::GET, "/apk/latest"), (Method::GET, "/apk/files/kvitt-1.apk"), ] } fn assert_error(reply: &Reply, status: StatusCode, code: &str) { assert_eq!(reply.status, status, "{:?}", reply.json); assert_eq!(reply.code(), code); assert!(reply.json["message"].is_string()); assert!(reply.json.as_object().unwrap().contains_key("field")); assert_eq!( reply.headers["content-type"], "application/json; charset=utf-8" ); } #[tokio::test] async fn every_route_is_registered_and_implemented() { let app = TestApp::household().await; let routes = authenticated_routes(); assert_eq!( routes.len() + public_stub_routes().len() + 2, 33, "SPEC §7 has 33 operations" ); // M3 implemented E-3 to E-30 and E-33 (tests/core_api), M4 the sync routes (tests/sync_api): nothing is 501. for (method, path, _) in routes { let reply = app .send(authed(method.clone(), &path, &admin_token())) .await; assert_ne!(reply.status, StatusCode::NOT_IMPLEMENTED, "{method} {path}"); assert_eq!( reply.headers["x-kvitt-server-version"], env!("CARGO_PKG_VERSION") ); } for (method, path) in public_stub_routes() { let reply = app.send(request(method.clone(), path, None, None)).await; assert_ne!(reply.status, StatusCode::NOT_IMPLEMENTED, "{method} {path}"); } } #[tokio::test] async fn health_reports_ok_with_versions() { let app = TestApp::empty().await; let reply = app.send(request(Method::GET, "/health", None, None)).await; assert_eq!(reply.status, StatusCode::OK); assert_eq!( reply.json, json!({"status": "ok", "server_version": env!("CARGO_PKG_VERSION"), "schema_version": 2, "db_ok": true}) ); assert_eq!( reply.headers["x-kvitt-server-version"], env!("CARGO_PKG_VERSION") ); assert_eq!( reply.headers["content-type"], "application/json; charset=utf-8" ); } #[tokio::test] async fn health_is_503_degraded_when_the_database_is_gone() { let app = TestApp::empty().await; app.state.db.close().await; let reply = app.send(request(Method::GET, "/health", None, None)).await; assert_eq!(reply.status, StatusCode::SERVICE_UNAVAILABLE); assert_eq!(reply.json["status"], "degraded"); assert_eq!(reply.json["db_ok"], false); assert_eq!(reply.json["schema_version"], 2); } #[tokio::test] async fn setup_status_follows_the_admin_user() { let empty = TestApp::empty().await; let reply = empty .send(request(Method::GET, "/setup/status", None, None)) .await; assert_eq!(reply.status, StatusCode::OK); assert_eq!(reply.json, json!({"initialized": false})); let household = TestApp::household().await; let reply = household .send(request(Method::GET, "/setup/status", None, None)) .await; assert_eq!(reply.json, json!({"initialized": true})); } #[tokio::test] async fn authenticated_routes_need_protocol_1_before_anything_else() { let app = TestApp::household().await; for (method, path, _) in authenticated_routes() { let missing = app .send(request(method.clone(), &path, Some(&admin_token()), None)) .await; assert_error( &missing, StatusCode::UPGRADE_REQUIRED, "protocol_unsupported", ); let wrong = app .send(request( method.clone(), &path, Some(&admin_token()), Some("2"), )) .await; assert_error(&wrong, StatusCode::UPGRADE_REQUIRED, "protocol_unsupported"); let no_token = app.send(request(method.clone(), &path, None, None)).await; assert_error( &no_token, StatusCode::UPGRADE_REQUIRED, "protocol_unsupported", ); } // Unauthenticated endpoints do not enforce it. let reply = app .send(request(Method::GET, "/health", None, Some("2"))) .await; assert_eq!(reply.status, StatusCode::OK); } #[tokio::test] async fn bearer_token_rules() { let app = TestApp::household().await; let missing = app.send(request(Method::GET, "/me", None, Some("1"))).await; assert_error(&missing, StatusCode::UNAUTHORIZED, "unauthorized"); let malformed = app.send(authed(Method::GET, "/me", "kvt_short")).await; assert_error(&malformed, StatusCode::UNAUTHORIZED, "unauthorized"); let unknown = app.send(authed(Method::GET, "/me", &token('U'))).await; assert_error(&unknown, StatusCode::UNAUTHORIZED, "unauthorized"); let revoked = app.send(authed(Method::GET, "/me", &revoked_token())).await; assert_error(&revoked, StatusCode::UNAUTHORIZED, "device_revoked"); let basic = Request::builder() .uri("/me") .header("x-kvitt-protocol", "1") .header("authorization", format!("Basic {}", admin_token())) .body(Body::empty()) .unwrap(); assert_error( &app.send(basic).await, StatusCode::UNAUTHORIZED, "unauthorized", ); } #[tokio::test] async fn admin_only_routes_are_forbidden_for_the_member() { let app = TestApp::household().await; for (method, path, admin_only) in authenticated_routes() { let reply = app .send(authed(method.clone(), &path, &member_token())) .await; if admin_only { assert_error(&reply, StatusCode::FORBIDDEN, "forbidden"); } else { assert_ne!(reply.status, StatusCode::FORBIDDEN, "{method} {path}"); } } } #[tokio::test] async fn path_ids_that_are_not_uuids_are_404() { let app = TestApp::household().await; for path in [ "/accounts/not-a-uuid", "/accounts/0192A003-0000-7000-8000-000000000001", "/categories/123/archive", "/users/x/picture", "/invites/nope", ] { let method = if path.starts_with("/accounts/") || path.starts_with("/invites/") { Method::DELETE } else if path.ends_with("archive") { Method::POST } else { Method::GET }; let reply = app.send(authed(method, path, &admin_token())).await; assert_error(&reply, StatusCode::NOT_FOUND, "not_found"); } } #[tokio::test] async fn unknown_paths_are_404_in_the_error_shape() { let app = TestApp::household().await; let reply = app.send(request(Method::GET, "/nope", None, None)).await; assert_error(&reply, StatusCode::NOT_FOUND, "not_found"); assert_eq!( reply.headers["x-kvitt-server-version"], env!("CARGO_PKG_VERSION") ); } async fn last_seen(app: &TestApp) -> (String, Option) { sqlx::query_as("SELECT last_seen_at, app_version_code FROM devices WHERE id = ?") .bind(ADMIN_DEVICE_ID) .fetch_one(&app.state.db) .await .unwrap() } fn with_version(path: &str, version: &str) -> Request { let mut r = authed(Method::GET, path, &admin_token()); r.headers_mut() .insert("x-kvitt-app-version", version.parse().unwrap()); r } #[tokio::test] async fn last_seen_and_app_version_are_written_at_most_hourly() { let app = TestApp::household().await; app.send(with_version("/me", "41")).await; assert_eq!(last_seen(&app).await, (NOW.to_owned(), Some(41))); let start = app.clock.now(); app.clock.set(start + Duration::minutes(59)); app.send(with_version("/me", "42")).await; assert_eq!(last_seen(&app).await, (NOW.to_owned(), Some(41))); app.clock.set(start + Duration::minutes(60)); app.send(with_version("/me", "42")).await; assert_eq!( last_seen(&app).await, ("2026-10-17T13:00:00.000Z".to_owned(), Some(42)) ); assert!(parse_ts(&last_seen(&app).await.0).is_some()); } #[tokio::test] async fn json_bodies_map_rejections_to_error_codes() { async fn echo(ApiJson(v): ApiJson) -> String { v.to_string() } let app = Router::new() .route("/echo", post(echo)) .layer(axum::extract::DefaultBodyLimit::max(16)); let send = |body: &'static str, content_type: Option<&'static str>| { let app = app.clone(); async move { let mut builder = Request::builder().method(Method::POST).uri("/echo"); if let Some(ct) = content_type { builder = builder.header("content-type", ct); } let response = app .oneshot(builder.body(Body::from(body)).unwrap()) .await .unwrap(); let status = response.status(); let bytes = response.into_body().collect().await.unwrap().to_bytes(); ( status, serde_json::from_slice::(&bytes).unwrap_or(Value::Null), ) } }; let (status, json) = send("{\"a\":1}", Some("application/json; charset=utf-8")).await; assert_eq!((status, json), (StatusCode::OK, json!({"a": 1}))); let (status, json) = send("{\"a\":", Some("application/json")).await; assert_eq!( (status, json["code"].clone()), (StatusCode::BAD_REQUEST, json!("invalid_request")) ); let (status, json) = send("{\"a\":\"0123456789abcdef\"}", Some("application/json")).await; assert_eq!( (status, json["code"].clone()), (StatusCode::PAYLOAD_TOO_LARGE, json!("payload_too_large")) ); let (status, json) = send("{}", None).await; assert_eq!( (status, json["code"].clone()), (StatusCode::BAD_REQUEST, json!("invalid_request")) ); }