M2a: server workspace, kvitt-domain, skeleton routes, OpenAPI match, Dockerfile
Cargo workspace (kvitt-domain, kvitt-server, kvitt-import), Rust 1.99.1 pinned. kvitt-domain passes every Rust vector and adds next_occurrence_date (D-015). kvitt-server: config, SQLite pool (WAL, §8.9 pragmas), migration 0001 identical to the contract schema, error type for all 25 codes, auth extractor and protocol layer, all 33 routes (E-1/E-2 implemented, the rest 501), utoipa document equal to contracts/openapi.yaml, seams for M3/M4/M9, serve/migrate/healthcheck. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,511 @@
|
||||
//! The utoipa document equals `contracts/openapi.yaml` (SPEC §14.3, §11.2 M2a).
|
||||
//!
|
||||
//! Both documents are parsed into JSON values and brought to one canonical form before comparing. The comparison
|
||||
//! fails on any added, missing or differently typed field, path, operation, parameter, request body, response,
|
||||
//! response header, security requirement or enum value. The normalisations, all of which are representational (they
|
||||
//! do not change what a client may send or will receive):
|
||||
//!
|
||||
//! 1. Key order: JSON objects are compared as maps.
|
||||
//! 2. `$ref` naming and placement: every local `$ref` is replaced by the (normalised) value it points to, so it does
|
||||
//! not matter whether something is a named component or inline, or what the component is called. Sibling keys
|
||||
//! next to a `$ref` override the target's (OpenAPI 3.1 Reference Object). Discriminator `mapping` values are
|
||||
//! references too and are resolved the same way. Components that no operation reaches are compared by name.
|
||||
//! 3. Documentation text: `description` and `example`/`examples` keywords are dropped everywhere (property *names*
|
||||
//! called `description` are kept). Operation `summary` and `operationId` are compared.
|
||||
//! 4. Nullable spellings: `type: [T, "null"]` is rewritten to `oneOf: [{type: T, …}, {type: "null"}]`; nested
|
||||
//! `oneOf`s are flattened and their members compared as a set (order of alternatives carries no meaning).
|
||||
//! 5. `const: v` is rewritten to `enum: [v]` with the JSON type of `v`; `enum` values and `required` lists are
|
||||
//! compared as sets.
|
||||
//! 6. Defaults written out or left implicit: `required: false` and `deprecated: false` are dropped; a path parameter
|
||||
//! is always required.
|
||||
//! 7. Parameters are compared as a set keyed by `(in, name)` (their order carries no meaning).
|
||||
//! 8. Security: each operation carries its effective requirement (its own `security`, or the document's); the
|
||||
//! document-level key itself is then dropped.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
const NAME_MAPS: &[&str] = &[
|
||||
"properties",
|
||||
"patternProperties",
|
||||
"dependentRequired",
|
||||
"headers",
|
||||
"content",
|
||||
"responses",
|
||||
"mapping",
|
||||
"securitySchemes",
|
||||
"$defs",
|
||||
];
|
||||
const DOC_KEYWORDS: &[&str] = &["description", "example", "examples"];
|
||||
const METHODS: &[&str] = &[
|
||||
"get", "put", "post", "delete", "patch", "options", "head", "trace",
|
||||
];
|
||||
const COMPONENT_KINDS: &[&str] = &[
|
||||
"schemas",
|
||||
"responses",
|
||||
"parameters",
|
||||
"headers",
|
||||
"requestBodies",
|
||||
];
|
||||
|
||||
fn contract() -> Value {
|
||||
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../../contracts/openapi.yaml");
|
||||
let text = std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("{}: {e}", path.display()));
|
||||
serde_norway::from_str(&text).expect("contracts/openapi.yaml is YAML")
|
||||
}
|
||||
|
||||
fn generated() -> Value {
|
||||
serde_json::to_value(kvitt_server::openapi::document()).expect("document serialises")
|
||||
}
|
||||
|
||||
/// Resolves `#/a/b/c` in `root`.
|
||||
fn pointer<'a>(root: &'a Value, reference: &str) -> &'a Value {
|
||||
let pointer = reference
|
||||
.strip_prefix('#')
|
||||
.unwrap_or_else(|| panic!("only local refs are supported: {reference}"));
|
||||
root.pointer(pointer)
|
||||
.unwrap_or_else(|| panic!("dangling $ref {reference}"))
|
||||
}
|
||||
|
||||
struct Resolver<'a> {
|
||||
root: &'a Value,
|
||||
reached: BTreeSet<String>,
|
||||
}
|
||||
|
||||
impl Resolver<'_> {
|
||||
/// Replaces every `$ref` (and discriminator mapping value) by its target, recursively.
|
||||
fn inline(&mut self, value: &Value, stack: &mut Vec<String>) -> Value {
|
||||
match value {
|
||||
Value::Object(map) => {
|
||||
if let Some(Value::String(reference)) = map.get("$ref") {
|
||||
assert!(!stack.contains(reference), "recursive $ref {reference}");
|
||||
self.reached.insert(reference.clone());
|
||||
stack.push(reference.clone());
|
||||
let target = self.inline(pointer(self.root, reference), stack);
|
||||
stack.pop();
|
||||
let mut merged = match target {
|
||||
Value::Object(t) => t,
|
||||
other => return other,
|
||||
};
|
||||
for (k, v) in map {
|
||||
if k != "$ref" {
|
||||
merged.insert(k.clone(), self.inline(v, stack));
|
||||
}
|
||||
}
|
||||
return Value::Object(merged);
|
||||
}
|
||||
let mut out = Map::new();
|
||||
for (k, v) in map {
|
||||
if k == "discriminator" {
|
||||
let mut d = v.clone();
|
||||
if let Some(Value::Object(mapping)) = v.get("mapping") {
|
||||
let resolved: Map<String, Value> = mapping
|
||||
.iter()
|
||||
.map(|(name, target)| {
|
||||
let reference =
|
||||
target.as_str().expect("mapping value is a ref string");
|
||||
(
|
||||
name.clone(),
|
||||
self.inline(&json!({ "$ref": reference }), stack),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
d["mapping"] = Value::Object(resolved);
|
||||
}
|
||||
out.insert(k.clone(), d);
|
||||
} else {
|
||||
out.insert(k.clone(), self.inline(v, stack));
|
||||
}
|
||||
}
|
||||
Value::Object(out)
|
||||
}
|
||||
Value::Array(items) => {
|
||||
Value::Array(items.iter().map(|v| self.inline(v, stack)).collect())
|
||||
}
|
||||
other => other.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn json_type(v: &Value) -> &'static str {
|
||||
match v {
|
||||
Value::Null => "null",
|
||||
Value::Bool(_) => "boolean",
|
||||
Value::Number(n) if n.is_i64() || n.is_u64() => "integer",
|
||||
Value::Number(_) => "number",
|
||||
Value::String(_) => "string",
|
||||
Value::Array(_) => "array",
|
||||
Value::Object(_) => "object",
|
||||
}
|
||||
}
|
||||
|
||||
fn sorted_set(items: Vec<Value>) -> Vec<Value> {
|
||||
let mut by_text: BTreeMap<String, Value> = BTreeMap::new();
|
||||
for item in items {
|
||||
by_text.insert(item.to_string(), item);
|
||||
}
|
||||
by_text.into_values().collect()
|
||||
}
|
||||
|
||||
/// The canonical form (normalisations 3 to 7). `names` is true when the object's keys are names, not keywords.
|
||||
fn canon(value: &Value, names: bool) -> Value {
|
||||
match value {
|
||||
Value::Object(map) if names => Value::Object(
|
||||
map.iter()
|
||||
.map(|(k, v)| (k.clone(), canon(v, false)))
|
||||
.collect(),
|
||||
),
|
||||
Value::Object(map) => {
|
||||
let mut out = Map::new();
|
||||
for (k, v) in map {
|
||||
if DOC_KEYWORDS.contains(&k.as_str()) {
|
||||
continue;
|
||||
}
|
||||
if (k == "required" || k == "deprecated") && *v == Value::Bool(false) {
|
||||
continue;
|
||||
}
|
||||
let child = if k == "parameters" {
|
||||
canon_parameters(v)
|
||||
} else {
|
||||
canon(v, NAME_MAPS.contains(&k.as_str()))
|
||||
};
|
||||
out.insert(k.clone(), child);
|
||||
}
|
||||
if let Some(constant) = out.remove("const") {
|
||||
out.entry("type")
|
||||
.or_insert_with(|| json!(json_type(&constant)));
|
||||
out.insert("enum".into(), json!([constant]));
|
||||
}
|
||||
if let Some(Value::Array(types)) = out.get("type").cloned() {
|
||||
if types.len() > 1 {
|
||||
out.remove("type");
|
||||
let mut base = out.clone();
|
||||
let mut alternatives = Vec::new();
|
||||
for t in types {
|
||||
if t == "null" {
|
||||
alternatives.push(json!({ "type": "null" }));
|
||||
} else {
|
||||
base.insert("type".into(), t);
|
||||
alternatives.push(Value::Object(base.clone()));
|
||||
}
|
||||
}
|
||||
out = Map::new();
|
||||
out.insert("oneOf".into(), json!(alternatives));
|
||||
} else if let Some(t) = types.into_iter().next() {
|
||||
out.insert("type".into(), t);
|
||||
}
|
||||
}
|
||||
for key in ["oneOf", "anyOf"] {
|
||||
if let Some(Value::Array(alternatives)) = out.remove(key) {
|
||||
let mut flat = Vec::new();
|
||||
for alternative in alternatives {
|
||||
let alternative = canon(&alternative, false);
|
||||
match alternative.get(key) {
|
||||
Some(Value::Array(inner))
|
||||
if alternative.as_object().is_some_and(|o| o.len() == 1) =>
|
||||
{
|
||||
flat.extend(inner.iter().cloned())
|
||||
}
|
||||
_ => flat.push(alternative),
|
||||
}
|
||||
}
|
||||
out.insert(key.into(), Value::Array(sorted_set(flat)));
|
||||
}
|
||||
}
|
||||
for key in ["enum", "required"] {
|
||||
if let Some(Value::Array(items)) = out.remove(key) {
|
||||
out.insert(key.into(), Value::Array(sorted_set(items)));
|
||||
}
|
||||
}
|
||||
Value::Object(out)
|
||||
}
|
||||
Value::Array(items) => Value::Array(items.iter().map(|v| canon(v, false)).collect()),
|
||||
other => other.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Normalisation 7 (and the path-parameter half of 6): parameters as a map keyed by `in name`.
|
||||
fn canon_parameters(value: &Value) -> Value {
|
||||
let Value::Array(items) = value else {
|
||||
return canon(value, true);
|
||||
};
|
||||
let mut out = Map::new();
|
||||
for item in items {
|
||||
let mut p = canon(item, false);
|
||||
let location = p["in"].as_str().unwrap_or_default().to_owned();
|
||||
let name = p["name"].as_str().unwrap_or_default().to_owned();
|
||||
if location == "path" {
|
||||
p["required"] = json!(true);
|
||||
}
|
||||
let key = format!("{location} {name}");
|
||||
assert!(!out.contains_key(&key), "duplicate parameter {key}");
|
||||
out.insert(key, p);
|
||||
}
|
||||
Value::Object(out)
|
||||
}
|
||||
|
||||
/// The whole normalised document.
|
||||
fn normalise(doc: &Value) -> Value {
|
||||
let mut resolver = Resolver {
|
||||
root: doc,
|
||||
reached: BTreeSet::new(),
|
||||
};
|
||||
let root_security = doc.get("security").cloned().unwrap_or(json!([]));
|
||||
let mut operations = Map::new();
|
||||
for (path, item) in doc["paths"].as_object().expect("paths") {
|
||||
let item = resolver.inline(item, &mut Vec::new());
|
||||
let mut methods = Map::new();
|
||||
for (method, operation) in item.as_object().expect("path item") {
|
||||
assert!(
|
||||
METHODS.contains(&method.as_str()),
|
||||
"{path}: path-level key {method} is not supported by this comparison"
|
||||
);
|
||||
let mut operation = operation.clone();
|
||||
let security = operation
|
||||
.get("security")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| root_security.clone());
|
||||
operation["security"] = security;
|
||||
operation.as_object_mut().map(|o| o.remove("tags"));
|
||||
methods.insert(method.clone(), canon(&operation, false));
|
||||
}
|
||||
operations.insert(path.clone(), Value::Object(methods));
|
||||
}
|
||||
|
||||
let mut unreachable = Map::new();
|
||||
if let Some(components) = doc.get("components") {
|
||||
for kind in COMPONENT_KINDS {
|
||||
for (name, value) in components
|
||||
.get(*kind)
|
||||
.and_then(Value::as_object)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
let reference = format!("#/components/{kind}/{name}");
|
||||
if !resolver.reached.contains(&reference) {
|
||||
let mut inner = Resolver {
|
||||
root: doc,
|
||||
reached: BTreeSet::new(),
|
||||
};
|
||||
unreachable.insert(
|
||||
reference,
|
||||
canon(&inner.inline(value, &mut Vec::new()), false),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
json!({
|
||||
"openapi": doc["openapi"],
|
||||
"info": { "title": doc["info"]["title"], "version": doc["info"]["version"] },
|
||||
"servers": canon(&doc["servers"], false),
|
||||
"securitySchemes": canon(&doc["components"]["securitySchemes"], true),
|
||||
"operations": Value::Object(operations),
|
||||
"unreachable_components": Value::Object(unreachable),
|
||||
"info_extra_keys": doc["info"].as_object().map(|o| {
|
||||
o.keys().filter(|k| !["title", "version", "description"].contains(&k.as_str())).cloned().collect::<Vec<_>>()
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
/// Paths (JSON-pointer style) where `a` and `b` differ.
|
||||
fn short(v: &Value) -> String {
|
||||
let text = v.to_string();
|
||||
if text.chars().count() > 300 {
|
||||
format!("{}…", text.chars().take(300).collect::<String>())
|
||||
} else {
|
||||
text
|
||||
}
|
||||
}
|
||||
|
||||
fn diff(a: &Value, b: &Value, at: String, out: &mut Vec<String>) {
|
||||
match (a, b) {
|
||||
(Value::Object(x), Value::Object(y)) => {
|
||||
let keys: BTreeSet<&String> = x.keys().chain(y.keys()).collect();
|
||||
for k in keys {
|
||||
let here = format!("{at}/{}", k.replace('~', "~0").replace('/', "~1"));
|
||||
match (x.get(k), y.get(k)) {
|
||||
(Some(v), Some(w)) => diff(v, w, here, out),
|
||||
(Some(v), None) => out.push(format!("{here}: only in contract: {}", short(v))),
|
||||
(None, Some(w)) => out.push(format!("{here}: only in generated: {}", short(w))),
|
||||
(None, None) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
(Value::Array(x), Value::Array(y)) if x.len() == y.len() => {
|
||||
for (i, (v, w)) in x.iter().zip(y).enumerate() {
|
||||
diff(v, w, format!("{at}/{i}"), out);
|
||||
}
|
||||
}
|
||||
_ if a == b => {}
|
||||
_ => out.push(format!(
|
||||
"{at}: contract {} != generated {}",
|
||||
short(a),
|
||||
short(b)
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn differences(contract: &Value, generated: &Value) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
diff(
|
||||
&normalise(contract),
|
||||
&normalise(generated),
|
||||
String::new(),
|
||||
&mut out,
|
||||
);
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_document_equals_contract() {
|
||||
let contract = contract();
|
||||
let generated = generated();
|
||||
let diffs = differences(&contract, &generated);
|
||||
assert!(
|
||||
diffs.is_empty(),
|
||||
"{} differences between contracts/openapi.yaml and the utoipa document:\n{}",
|
||||
diffs.len(),
|
||||
diffs
|
||||
.iter()
|
||||
.take(80)
|
||||
.cloned()
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
);
|
||||
// The contract has 33 operations (SPEC §7, E-1 to E-33); make sure none were lost on both sides.
|
||||
let count = normalise(&generated)["operations"]
|
||||
.as_object()
|
||||
.map(|paths| {
|
||||
paths
|
||||
.values()
|
||||
.filter_map(Value::as_object)
|
||||
.map(|m| m.len())
|
||||
.sum::<usize>()
|
||||
})
|
||||
.unwrap_or(0);
|
||||
assert_eq!(count, 33);
|
||||
}
|
||||
|
||||
// The comparison itself must be strict: each mutation of the contract below must be detected.
|
||||
|
||||
fn mutated(f: impl FnOnce(&mut Value)) -> Vec<String> {
|
||||
let contract = contract();
|
||||
let mut other = contract.clone();
|
||||
f(&mut other);
|
||||
differences(&contract, &other)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_an_added_property() {
|
||||
assert!(
|
||||
!mutated(|d| {
|
||||
d["components"]["schemas"]["User"]["properties"]["extra"] = json!({ "type": "string" });
|
||||
})
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_a_missing_required_field() {
|
||||
assert!(
|
||||
!mutated(|d| {
|
||||
d["components"]["schemas"]["Account"]["required"]
|
||||
.as_array_mut()
|
||||
.unwrap()
|
||||
.retain(|v| v != "deleted_at");
|
||||
})
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_a_changed_type() {
|
||||
assert!(
|
||||
!mutated(|d| {
|
||||
d["components"]["schemas"]["Share"]["properties"]["share_bp"] =
|
||||
json!({ "type": "string" });
|
||||
})
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_a_removed_enum_value() {
|
||||
assert!(
|
||||
!mutated(|d| {
|
||||
d["components"]["schemas"]["ErrorCode"]["enum"]
|
||||
.as_array_mut()
|
||||
.unwrap()
|
||||
.pop();
|
||||
})
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_a_removed_parameter() {
|
||||
assert!(
|
||||
!mutated(|d| {
|
||||
d["paths"]["/sync"]["get"]["parameters"]
|
||||
.as_array_mut()
|
||||
.unwrap()
|
||||
.remove(0);
|
||||
})
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_a_changed_security_requirement() {
|
||||
assert!(
|
||||
!mutated(|d| {
|
||||
d["paths"]["/me"]["get"]["security"] = json!([]);
|
||||
})
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_a_removed_operation_and_response() {
|
||||
assert!(
|
||||
!mutated(|d| {
|
||||
d["paths"]["/me"].as_object_mut().unwrap().remove("patch");
|
||||
})
|
||||
.is_empty()
|
||||
);
|
||||
assert!(
|
||||
!mutated(|d| {
|
||||
d["paths"]["/me"]["get"]["responses"]
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.remove("426");
|
||||
})
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_key_order_ref_naming_and_descriptions() {
|
||||
assert!(
|
||||
mutated(|d| {
|
||||
// Rename a schema component and point every reference at the new name.
|
||||
let text = serde_json::to_string(d).unwrap().replace(
|
||||
"#/components/schemas/Share\"",
|
||||
"#/components/schemas/DefaultShare\"",
|
||||
);
|
||||
*d = serde_json::from_str(&text).unwrap();
|
||||
let share = d["components"]["schemas"]
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.remove("Share")
|
||||
.unwrap();
|
||||
d["components"]["schemas"]["DefaultShare"] = share;
|
||||
d["components"]["schemas"]["User"]["description"] = json!("changed wording");
|
||||
})
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
//! Every error code of SPEC §14.3.2 serialises like `contracts/examples/errors/<code>.json`, with its HTTP status.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::IntoResponse;
|
||||
use http_body_util::BodyExt;
|
||||
use kvitt_server::error::{AppError, ErrorBody, ErrorCode};
|
||||
use serde_json::Value;
|
||||
|
||||
const STATUS: &[(&str, u16)] = &[
|
||||
("invalid_request", 400),
|
||||
("invite_invalid", 400),
|
||||
("unauthorized", 401),
|
||||
("device_revoked", 401),
|
||||
("forbidden", 403),
|
||||
("not_found", 404),
|
||||
("household_full", 409),
|
||||
("household_incomplete", 409),
|
||||
("name_taken", 409),
|
||||
("account_not_empty", 409),
|
||||
("category_in_use", 409),
|
||||
("category_is_default", 409),
|
||||
("cannot_revoke_current_device", 409),
|
||||
("no_accounts", 409),
|
||||
("cursor_ahead", 409),
|
||||
("payload_too_large", 413),
|
||||
("unsupported_media_type", 415),
|
||||
("validation_failed", 422),
|
||||
("image_invalid", 422),
|
||||
("batch_too_large", 422),
|
||||
("protocol_unsupported", 426),
|
||||
("rate_limited", 429),
|
||||
("internal", 500),
|
||||
("not_implemented", 501),
|
||||
("unavailable", 503),
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn every_code_has_the_status_of_the_spec_table() {
|
||||
assert_eq!(ErrorCode::ALL.len(), STATUS.len());
|
||||
for (code, (token, status)) in ErrorCode::ALL.iter().zip(STATUS) {
|
||||
assert_eq!(code.as_str(), *token);
|
||||
assert_eq!(code.status().as_u16(), *status, "{token}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn every_error_example_round_trips_through_app_error() {
|
||||
let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../../contracts/examples/errors");
|
||||
let mut seen = 0;
|
||||
for entry in std::fs::read_dir(&dir).unwrap() {
|
||||
let path = entry.unwrap().path();
|
||||
let example: Value =
|
||||
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
|
||||
let body: ErrorBody = serde_json::from_value(example.clone()).unwrap();
|
||||
let stem = path.file_stem().unwrap().to_string_lossy().into_owned();
|
||||
assert_eq!(
|
||||
body.code.as_str(),
|
||||
stem,
|
||||
"{}: file name is the code",
|
||||
path.display()
|
||||
);
|
||||
|
||||
let mut error = AppError::new(body.code, body.message.clone());
|
||||
if let Some(field) = &body.field {
|
||||
error = error.with_field(field);
|
||||
}
|
||||
let response = error.into_response();
|
||||
let status = response.status();
|
||||
assert_eq!(
|
||||
response.headers()["content-type"],
|
||||
"application/json; charset=utf-8"
|
||||
);
|
||||
let bytes = response.into_body().collect().await.unwrap().to_bytes();
|
||||
let json: Value = serde_json::from_slice(&bytes).unwrap();
|
||||
assert_eq!(json, example, "{stem}");
|
||||
let expected = STATUS
|
||||
.iter()
|
||||
.find(|(t, _)| *t == stem)
|
||||
.map(|(_, s)| *s)
|
||||
.unwrap();
|
||||
assert_eq!(status, StatusCode::from_u16(expected).unwrap());
|
||||
seen += 1;
|
||||
}
|
||||
assert_eq!(seen, ErrorCode::ALL.len(), "one example per error code");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rate_limited_carries_retry_after() {
|
||||
let response = AppError::rate_limited(30).into_response();
|
||||
assert_eq!(response.headers()["retry-after"], "30");
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
//! Integration tests of the M2a server skeleton: migration identity and pragmas, routes, authentication and the
|
||||
//! protocol rule, error shapes, and the wire types against `contracts/examples/`.
|
||||
|
||||
mod errors;
|
||||
mod migration;
|
||||
mod routes;
|
||||
mod support;
|
||||
mod wire_examples;
|
||||
@@ -0,0 +1,96 @@
|
||||
//! `0001_init.sql` is the contract schema byte for byte (SPEC §14.1), and connections carry the §8.9 pragmas.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use kvitt_server::db;
|
||||
|
||||
use crate::support::TestApp;
|
||||
|
||||
fn manifest(path: &str) -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(path)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migration_0001_is_byte_identical_to_contract_schema() {
|
||||
let migration = std::fs::read(manifest("migrations/0001_init.sql")).unwrap();
|
||||
let contract = std::fs::read(manifest("../../../contracts/schema/server-v1.sql")).unwrap();
|
||||
assert!(
|
||||
migration == contract,
|
||||
"migrations/0001_init.sql differs from contracts/schema/server-v1.sql"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_embedded_migrator_holds_exactly_that_migration() {
|
||||
let contract =
|
||||
std::fs::read_to_string(manifest("../../../contracts/schema/server-v1.sql")).unwrap();
|
||||
let migrations: Vec<_> = db::MIGRATOR.iter().collect();
|
||||
assert_eq!(migrations.len(), 1);
|
||||
assert_eq!(migrations[0].version, 1);
|
||||
assert_eq!(migrations[0].sql.as_ref(), contract.as_str());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn migrated_database_has_schema_version_seed_rows_and_pragmas() {
|
||||
let app = TestApp::empty().await;
|
||||
let pool = &app.state.db;
|
||||
let version: String = sqlx::query_scalar("SELECT value FROM meta WHERE key = 'schema_version'")
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(version, db::SCHEMA_VERSION.to_string());
|
||||
let epoch: String = sqlx::query_scalar("SELECT value FROM meta WHERE key = 'sync_epoch'")
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(epoch.len(), 32);
|
||||
let categories: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM categories")
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(categories, 10);
|
||||
|
||||
let mut conn = pool.acquire().await.unwrap();
|
||||
let journal: String = sqlx::query_scalar("PRAGMA journal_mode")
|
||||
.fetch_one(&mut *conn)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(journal, "wal");
|
||||
let fk: i64 = sqlx::query_scalar("PRAGMA foreign_keys")
|
||||
.fetch_one(&mut *conn)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(fk, 1);
|
||||
let sync: i64 = sqlx::query_scalar("PRAGMA synchronous")
|
||||
.fetch_one(&mut *conn)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(sync, 1, "synchronous = NORMAL");
|
||||
let busy: i64 = sqlx::query_scalar("PRAGMA busy_timeout")
|
||||
.fetch_one(&mut *conn)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(busy, 5000);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn migrating_twice_is_a_no_op() {
|
||||
let app = TestApp::empty().await;
|
||||
db::migrate(&app.state.db).await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn server_version_sequence_starts_after_the_seed_and_increments() {
|
||||
let app = TestApp::empty().await;
|
||||
let mut tx = db::begin_immediate(&app.state.db).await.unwrap();
|
||||
assert_eq!(db::next_server_version(&mut tx).await.unwrap(), 11);
|
||||
assert_eq!(db::next_server_version(&mut tx).await.unwrap(), 12);
|
||||
tx.commit().await.unwrap();
|
||||
let mut conn = app.state.db.acquire().await.unwrap();
|
||||
assert_eq!(
|
||||
db::sequence::current_server_version(&mut conn)
|
||||
.await
|
||||
.unwrap(),
|
||||
12
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
//! Every route of SPEC §7 is registered; E-1 and E-2 work; the rest answer 501 `not_implemented` 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_answers_501_until_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"
|
||||
);
|
||||
for (method, path, _) in routes {
|
||||
let reply = app
|
||||
.send(authed(method.clone(), &path, &admin_token()))
|
||||
.await;
|
||||
assert_error(&reply, StatusCode::NOT_IMPLEMENTED, "not_implemented");
|
||||
assert_eq!(
|
||||
reply.json,
|
||||
json!({"code": "not_implemented", "message": "not implemented", "field": null}),
|
||||
"{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_error(&reply, StatusCode::NOT_IMPLEMENTED, "not_implemented");
|
||||
}
|
||||
}
|
||||
|
||||
#[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": 1, "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"], 1);
|
||||
}
|
||||
|
||||
#[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_error(&reply, StatusCode::NOT_IMPLEMENTED, "not_implemented");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[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());
|
||||
}
|
||||
|
||||
#[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"))
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
//! Shared helpers of the skeleton tests: a migrated temporary database, seeded users and devices, a fixed
|
||||
//! clock and a one-shot request helper.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::http::{Method, Request, StatusCode};
|
||||
use http_body_util::BodyExt;
|
||||
use kvitt_server::config::Config;
|
||||
use kvitt_server::time::{FixedClock, parse_ts};
|
||||
use kvitt_server::{AppState, auth, db, router};
|
||||
use serde_json::Value;
|
||||
use tower::ServiceExt;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub const ADMIN_ID: &str = "0192a000-0000-7000-8000-000000000001";
|
||||
pub const MEMBER_ID: &str = "0192a000-0000-7000-8000-000000000002";
|
||||
pub const ADMIN_DEVICE_ID: &str = "0192a001-0000-7000-8000-000000000001";
|
||||
pub const MEMBER_DEVICE_ID: &str = "0192a001-0000-7000-8000-000000000002";
|
||||
pub const REVOKED_DEVICE_ID: &str = "0192a001-0000-7000-8000-000000000003";
|
||||
pub const NOW: &str = "2026-10-17T12:00:00.000Z";
|
||||
|
||||
pub fn token(tag: char) -> String {
|
||||
format!("kvt_{}", tag.to_string().repeat(43))
|
||||
}
|
||||
|
||||
pub fn admin_token() -> String {
|
||||
token('A')
|
||||
}
|
||||
pub fn member_token() -> String {
|
||||
token('M')
|
||||
}
|
||||
pub fn revoked_token() -> String {
|
||||
token('R')
|
||||
}
|
||||
|
||||
/// A data directory removed on drop.
|
||||
pub struct TempDir(pub PathBuf);
|
||||
|
||||
impl TempDir {
|
||||
pub fn new() -> Self {
|
||||
let dir = std::env::temp_dir().join(format!("kvitt-test-{}", Uuid::now_v7()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
Self(dir)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TempDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
pub struct TestApp {
|
||||
/// Kept alive so the directory is removed when the app is dropped.
|
||||
pub _dir: TempDir,
|
||||
pub state: AppState,
|
||||
pub clock: FixedClock,
|
||||
}
|
||||
|
||||
impl TestApp {
|
||||
/// A migrated, empty database (no users).
|
||||
pub async fn empty() -> Self {
|
||||
let dir = TempDir::new();
|
||||
let config = Config::from_lookup(|k| {
|
||||
(k == "KVITT_DATA_DIR").then(|| dir.0.to_string_lossy().into_owned())
|
||||
})
|
||||
.unwrap();
|
||||
let pool = db::connect(&config.database_path()).await.unwrap();
|
||||
db::migrate(&pool).await.unwrap();
|
||||
let clock = FixedClock::new(parse_ts(NOW).unwrap());
|
||||
let state = AppState::new(config, pool).with_clock(Arc::new(clock.clone()));
|
||||
Self {
|
||||
_dir: dir,
|
||||
state,
|
||||
clock,
|
||||
}
|
||||
}
|
||||
|
||||
/// Admin and member users, one device each, plus a revoked device of the member.
|
||||
pub async fn household() -> Self {
|
||||
let app = Self::empty().await;
|
||||
let pool = &app.state.db;
|
||||
for (id, role, name, version) in [
|
||||
(ADMIN_ID, "admin", "Jacob", 11),
|
||||
(MEMBER_ID, "member", "Partner", 12),
|
||||
] {
|
||||
sqlx::query(
|
||||
"INSERT INTO users (id, role, display_name, picture_version, created_at, updated_at, server_version) \
|
||||
VALUES (?, ?, ?, NULL, ?, ?, ?)",
|
||||
)
|
||||
.bind(id)
|
||||
.bind(role)
|
||||
.bind(name)
|
||||
.bind("2026-10-01T00:00:00.000Z")
|
||||
.bind("2026-10-01T00:00:00.000Z")
|
||||
.bind(version)
|
||||
.execute(pool)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
for (id, user, tok, revoked) in [
|
||||
(ADMIN_DEVICE_ID, ADMIN_ID, admin_token(), None),
|
||||
(MEMBER_DEVICE_ID, MEMBER_ID, member_token(), None),
|
||||
(
|
||||
REVOKED_DEVICE_ID,
|
||||
MEMBER_ID,
|
||||
revoked_token(),
|
||||
Some("2026-10-10T00:00:00.000Z"),
|
||||
),
|
||||
] {
|
||||
sqlx::query(
|
||||
"INSERT INTO devices (id, user_id, name, token_hash, created_at, last_seen_at, revoked_at) \
|
||||
VALUES (?, ?, 'Phone', ?, '2026-10-01T00:00:00.000Z', '2026-10-01T00:00:00.000Z', ?)",
|
||||
)
|
||||
.bind(id)
|
||||
.bind(user)
|
||||
.bind(auth::sha256_hex(&tok))
|
||||
.bind(revoked)
|
||||
.execute(pool)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
app
|
||||
}
|
||||
|
||||
pub async fn send(&self, request: Request<Body>) -> Reply {
|
||||
let response = router::router(self.state.clone())
|
||||
.oneshot(request)
|
||||
.await
|
||||
.unwrap();
|
||||
let status = response.status();
|
||||
let headers = response.headers().clone();
|
||||
let bytes = response.into_body().collect().await.unwrap().to_bytes();
|
||||
let json = serde_json::from_slice(&bytes).unwrap_or(Value::Null);
|
||||
Reply {
|
||||
status,
|
||||
headers,
|
||||
json,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Reply {
|
||||
pub status: StatusCode,
|
||||
pub headers: axum::http::HeaderMap,
|
||||
pub json: Value,
|
||||
}
|
||||
|
||||
impl Reply {
|
||||
pub fn code(&self) -> &str {
|
||||
self.json["code"].as_str().unwrap_or("")
|
||||
}
|
||||
}
|
||||
|
||||
/// A request builder with the app's usual headers.
|
||||
pub fn request(
|
||||
method: Method,
|
||||
path: &str,
|
||||
token: Option<&str>,
|
||||
protocol: Option<&str>,
|
||||
) -> Request<Body> {
|
||||
let mut builder = Request::builder().method(method).uri(path);
|
||||
if let Some(token) = token {
|
||||
builder = builder.header("authorization", format!("Bearer {token}"));
|
||||
}
|
||||
if let Some(protocol) = protocol {
|
||||
builder = builder.header("x-kvitt-protocol", protocol);
|
||||
}
|
||||
builder.body(Body::empty()).unwrap()
|
||||
}
|
||||
|
||||
pub fn authed(method: Method, path: &str, token: &str) -> Request<Body> {
|
||||
request(method, path, Some(token), Some("1"))
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
//! Every request and response example of `contracts/examples/` (SPEC §14.3.5) decodes into its wire type and
|
||||
//! re-encodes to the same JSON value.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use kvitt_server::error::ErrorBody;
|
||||
use kvitt_server::wire::*;
|
||||
use serde::Serialize;
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde_json::Value;
|
||||
|
||||
fn round_trip<T: DeserializeOwned + Serialize>(name: &str, example: &Value) {
|
||||
let decoded: T = serde_json::from_value(example.clone()).unwrap_or_else(|e| {
|
||||
panic!(
|
||||
"{name}: does not decode as {}: {e}",
|
||||
std::any::type_name::<T>()
|
||||
)
|
||||
});
|
||||
let encoded = serde_json::to_value(&decoded).unwrap();
|
||||
assert_eq!(&encoded, example, "{name}: re-encoding differs");
|
||||
}
|
||||
|
||||
fn check(name: &str, example: &Value) -> bool {
|
||||
macro_rules! map {
|
||||
($($file:literal => $t:ty,)*) => {
|
||||
match name {
|
||||
$($file => { round_trip::<$t>(name, example); true })*
|
||||
_ => false,
|
||||
}
|
||||
};
|
||||
}
|
||||
map! {
|
||||
"E-1-health/response.json" => Health,
|
||||
"E-2-setup-status/response.json" => SetupStatus,
|
||||
"E-3-invites-accept/request.json" => AcceptInviteRequest,
|
||||
"E-3-invites-accept/response.json" => AcceptInviteResponse,
|
||||
"E-5-apk-latest/response.json" => ApkLatest,
|
||||
"E-7-me/response.json" => MeResponse,
|
||||
"E-8-patch-me/request.json" => PatchMeRequest,
|
||||
"E-8-patch-me/response.json" => User,
|
||||
"E-9-put-me-picture/response.json" => User,
|
||||
"E-10-delete-me-picture/response.json" => User,
|
||||
"E-12-create-invite/request.json" => CreateInviteRequest,
|
||||
"E-12-create-invite/response.json" => Invite,
|
||||
"E-13-list-invites/response.json" => InviteList,
|
||||
"E-15-list-devices/response.json" => DeviceList,
|
||||
"E-17-push-token/request.json" => PushTokenRequest,
|
||||
"E-18-create-account/request.json" => CreateAccountRequest,
|
||||
"E-18-create-account/response.json" => Account,
|
||||
"E-19-patch-account/request.json" => PatchAccountRequest,
|
||||
"E-19-patch-account/response.json" => Account,
|
||||
"E-20-archive-account/response.json" => Account,
|
||||
"E-21-unarchive-account/response.json" => Account,
|
||||
"E-22-delete-account/response.json" => Account,
|
||||
"E-23-create-category/request.json" => CreateCategoryRequest,
|
||||
"E-23-create-category/response.json" => Category,
|
||||
"E-24-patch-category/request.json" => PatchCategoryRequest,
|
||||
"E-24-patch-category/response.json" => Category,
|
||||
"E-25-archive-category/response.json" => Category,
|
||||
"E-26-unarchive-category/response.json" => Category,
|
||||
"E-27-delete-category/response.json" => Category,
|
||||
"E-28-create-recurring-template/request.json" => CreateRecurringTemplateRequest,
|
||||
"E-28-create-recurring-template/response.json" => RecurringTemplate,
|
||||
"E-29-patch-recurring-template/request.json" => PatchRecurringTemplateRequest,
|
||||
"E-29-patch-recurring-template/response.json" => RecurringTemplate,
|
||||
"E-30-delete-recurring-template/response.json" => RecurringTemplate,
|
||||
"E-31-sync-pull/response.json" => SyncPullResponse,
|
||||
"E-32-sync-push/request.json" => SyncPushRequest,
|
||||
"E-32-sync-push/response.json" => SyncPushResponse,
|
||||
"E-33-settlement-proposal/request.json" => ProposalRequest,
|
||||
"E-33-settlement-proposal/response.json" => ProposalResponse,
|
||||
"sync/pull-empty-page.json" => SyncPullResponse,
|
||||
"sync/pull-page.json" => SyncPullResponse,
|
||||
"sync/push-request.json" => SyncPushRequest,
|
||||
"sync/push-request-delete.json" => SyncPushRequest,
|
||||
"sync/push-request-rejections.json" => SyncPushRequest,
|
||||
"sync/push-response.json" => SyncPushResponse,
|
||||
"sync/push-response-delete.json" => SyncPushResponse,
|
||||
"sync/push-response-rejections.json" => SyncPushResponse,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_example_round_trips() {
|
||||
let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../../contracts/examples");
|
||||
let mut unmapped = Vec::new();
|
||||
let mut checked = 0;
|
||||
let mut dirs: Vec<_> = std::fs::read_dir(&root)
|
||||
.unwrap()
|
||||
.map(|e| e.unwrap().path())
|
||||
.collect();
|
||||
dirs.sort();
|
||||
for dir in dirs.into_iter().filter(|p| p.is_dir()) {
|
||||
let dir_name = dir.file_name().unwrap().to_string_lossy().into_owned();
|
||||
for file in std::fs::read_dir(&dir).unwrap() {
|
||||
let path = file.unwrap().path();
|
||||
let name = format!("{dir_name}/{}", path.file_name().unwrap().to_string_lossy());
|
||||
let example: Value =
|
||||
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
|
||||
if dir_name == "errors" {
|
||||
round_trip::<ErrorBody>(&name, &example);
|
||||
checked += 1;
|
||||
} else if dir_name == "push" {
|
||||
// FCM payloads (SPEC §14.6) are built by M4's sender, not by a wire type of this crate.
|
||||
continue;
|
||||
} else if check(&name, &example) {
|
||||
checked += 1;
|
||||
} else {
|
||||
unmapped.push(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
unmapped.is_empty(),
|
||||
"examples without a wire type: {unmapped:?}"
|
||||
);
|
||||
assert!(checked > 60, "only {checked} examples checked");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absent_and_null_are_distinct_where_the_contract_says_so() {
|
||||
let patch: PatchCategoryRequest = serde_json::from_str(r#"{"name": null}"#).unwrap();
|
||||
assert_eq!(patch.name, Some(None));
|
||||
let patch: PatchCategoryRequest = serde_json::from_str(r#"{"icon": "home"}"#).unwrap();
|
||||
assert_eq!(patch.name, None);
|
||||
// E-17: the key is required, the value nullable.
|
||||
assert!(serde_json::from_str::<PushTokenRequest>("{}").is_err());
|
||||
let clear: PushTokenRequest = serde_json::from_str(r#"{"fcm_token": null}"#).unwrap();
|
||||
assert_eq!(clear.fcm_token, None);
|
||||
// E-12: target_user_id may be absent.
|
||||
let invite: CreateInviteRequest = serde_json::from_str(r#"{"kind": "partner"}"#).unwrap();
|
||||
assert_eq!(invite.target_user_id, None);
|
||||
}
|
||||
Reference in New Issue
Block a user