8cb42f85a9
core_api/common.rs becomes tests/common/mod.rs and sync_api/m4.rs tests/common/sync_fixtures.rs; each binary includes them through #[path] (the binaries of SPEC §14.7 stay as they are). TempDir's Default moves into the harness instead of three copies; the skeleton helpers take ids, tokens and TempDir from it. Milestone wording in test docs and the server README replaced by what the code does; README seams and test layout updated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
371 lines
13 KiB
Rust
371 lines
13 KiB
Rust
//! 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"
|
|
);
|
|
// Every route has its handler (behaviour in tests/core_api and 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": 3, "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"], 3);
|
|
}
|
|
|
|
#[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<i64>) {
|
|
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<Body> {
|
|
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());
|
|
}
|
|
|
|
async fn version_name(app: &TestApp) -> Option<String> {
|
|
sqlx::query_scalar("SELECT app_version_name FROM devices WHERE id = ?")
|
|
.bind(ADMIN_DEVICE_ID)
|
|
.fetch_one(&app.state.db)
|
|
.await
|
|
.unwrap()
|
|
}
|
|
|
|
fn with_version_name(path: &str, name: &str) -> Request<Body> {
|
|
let mut r = authed(Method::GET, path, &admin_token());
|
|
r.headers_mut()
|
|
.insert("x-kvitt-app-version-name", name.parse().unwrap());
|
|
r
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn app_version_name_is_written_with_last_seen_and_bad_values_are_ignored() {
|
|
let app = TestApp::household().await;
|
|
app.send(with_version_name("/me", " 1.1 ")).await;
|
|
assert_eq!(version_name(&app).await.as_deref(), Some("1.1"));
|
|
|
|
let start = app.clock.now();
|
|
app.clock.set(start + Duration::minutes(59));
|
|
app.send(with_version_name("/me", "1.2")).await;
|
|
assert_eq!(version_name(&app).await.as_deref(), Some("1.1"), "hourly");
|
|
|
|
// An over-long or blank value keeps the stored one; no header keeps it too.
|
|
app.clock.set(start + Duration::minutes(60));
|
|
app.send(with_version_name("/me", &"9".repeat(33))).await;
|
|
assert_eq!(version_name(&app).await.as_deref(), Some("1.1"));
|
|
app.clock.set(start + Duration::minutes(120));
|
|
app.send(authed(Method::GET, "/me", &admin_token())).await;
|
|
assert_eq!(version_name(&app).await.as_deref(), Some("1.1"));
|
|
app.clock.set(start + Duration::minutes(180));
|
|
app.send(with_version_name("/me", "1.2")).await;
|
|
assert_eq!(version_name(&app).await.as_deref(), Some("1.2"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn json_bodies_map_rejections_to_error_codes() {
|
|
async fn echo(ApiJson(v): ApiJson<Value>) -> 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::<Value>(&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"))
|
|
);
|
|
}
|