M12e: contract pass (pair invite kind, admin name in invite links, sync failed flag, rejection actor, device version name, template id, update check result, untouched accounts, D-027 re-stamp, PushEvent in :core:model, snackbar hosts)

Server: E-3 invite_kind; E-12 link ?from=<admin name>, E-4 passes it into kvitt://invite;
X-Kvitt-App-Version-Name stored in devices.app_version_name (migration 0003, schema 3);
OpenAPI and examples follow. Phone: :core:model, :core:fakes, :data, features and :app
updated together; goldens re-recorded for S-20 (version name) and new S-1/S-22 states.

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-18 15:00:50 +02:00
parent 6159e18198
commit 8c607153c0
96 changed files with 1090 additions and 298 deletions
+4 -1
View File
@@ -706,7 +706,10 @@ async fn a_schema_1_database_is_refused_until_migrated() {
let refused = kvitt_import::open(&path).await;
match refused {
Err(ImportError::Io(message)) => {
assert!(message.contains("expected 2"), "{message}");
assert!(
message.contains(&format!("expected {}", db::SCHEMA_VERSION)),
"{message}"
);
assert!(message.contains("kvitt-server migrate"), "{message}");
}
Err(other) => panic!("expected a database error, got {other:?}"),
@@ -0,0 +1,9 @@
-- Kvitt server schema v3: the app's versionName per device (devices.app_version_name), sent in the
-- X-Kvitt-App-Version-Name header and written like app_version_code (SPEC §14.3.1, §8.2). Do not edit; add a new
-- migration and bump the schema version (SPEC §14.8). Applied on top of 0002_categories_v2.sql.
--
-- Existing devices keep every column and get NULL until their next request that carries the header.
ALTER TABLE devices ADD COLUMN app_version_name TEXT
CHECK (app_version_name IS NULL OR length(app_version_name) BETWEEN 1 AND 32);
UPDATE meta SET value = '3' WHERE key = 'schema_version';
+27 -11
View File
@@ -9,12 +9,13 @@ use axum::extract::State;
use axum::http::{HeaderMap, StatusCode};
use base64::Engine as _;
use chrono::{DateTime, Duration, Utc};
use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode};
use sqlx::SqliteConnection;
use uuid::Uuid;
use crate::AppState;
use crate::api::{ApiJson, IdPath, JsonBody, rows, validate};
use crate::auth::{APP_VERSION_HEADER, AdminDevice, TOKEN_PREFIX, sha256_hex};
use crate::auth::{APP_VERSION_HEADER, AdminDevice, TOKEN_PREFIX, app_version_name, sha256_hex};
use crate::db;
use crate::error::{AppError, ErrorCode};
use crate::openapi::responses::{BadRequest, Conflict, Forbidden, NotFound, Unprocessable};
@@ -84,9 +85,18 @@ pub fn generate_token() -> Result<String, AppError> {
))
}
/// `<public_url>/i/<code>`.
pub fn invite_link(public_url: &str, code: &str) -> String {
format!("{public_url}/i/{code}")
/// Query parameter of the landing link and of `kvitt://invite` that names the inviting admin (AC-2.6, S-1 help line).
pub const FROM_PARAM: &str = "from";
/// `<public_url>/i/<code>`, with `?from=<percent-encoded admin display name>` when `from` is given (E-12).
pub fn invite_link(public_url: &str, code: &str, from: Option<&str>) -> String {
match from {
Some(name) => format!(
"{public_url}/i/{code}?{FROM_PARAM}={}",
utf8_percent_encode(name, NON_ALPHANUMERIC)
),
None => format!("{public_url}/i/{code}"),
}
}
/// A freshly created invite with its plain code (shown once).
@@ -101,14 +111,14 @@ pub struct NewInvite {
}
impl NewInvite {
/// The wire form with `code` and `link` (E-12 response).
pub fn wire(&self, public_url: &str) -> Invite {
/// The wire form with `code` and `link` (E-12 response); `from` is the inviting admin's display name.
pub fn wire(&self, public_url: &str, from: Option<&str>) -> Invite {
Invite {
id: self.id,
kind: self.kind,
target_user_id: self.target_user_id,
code: Some(self.code.clone()),
link: Some(invite_link(public_url, &self.code)),
link: Some(invite_link(public_url, &self.code, from)),
created_at: self.created_at.clone(),
expires_at: self.expires_at.clone(),
}
@@ -303,14 +313,15 @@ pub async fn accept_invite(
let token = generate_token()?;
let device_id = Uuid::now_v7();
sqlx::query(
"INSERT INTO devices (id, user_id, name, token_hash, fcm_token, app_version_code, created_at, last_seen_at) \
VALUES (?, ?, ?, ?, NULL, ?, ?, ?)",
"INSERT INTO devices (id, user_id, name, token_hash, fcm_token, app_version_code, app_version_name, \
created_at, last_seen_at) VALUES (?, ?, ?, ?, NULL, ?, ?, ?, ?)",
)
.bind(device_id.to_string())
.bind(user_id.to_string())
.bind(&device_name)
.bind(sha256_hex(&token))
.bind(app_version(&headers))
.bind(app_version_name(&headers))
.bind(&now_ts)
.bind(&now_ts)
.execute(&mut *tx)
@@ -333,6 +344,7 @@ pub async fn accept_invite(
tracing::info!(device_id = %device_id, invite_kind = row.kind, "device paired");
Ok(JsonBody(AcceptInviteResponse {
token,
invite_kind: kind,
user,
device,
household,
@@ -347,7 +359,7 @@ pub async fn accept_invite(
description = "Creating a `partner` invite cancels any pending one (AC-2.1).",
request_body(content = CreateInviteRequest, content_type = "application/json; charset=utf-8"),
responses(
(status = 201, description = "The invite with `code` (12 characters, no hyphens) and `link` (`<public_url>/i/<code>`).", body = Invite, content_type = "application/json; charset=utf-8"),
(status = 201, description = "The invite with `code` (12 characters, no hyphens) and `link` (`<public_url>/i/<code>?from=<admin display name, percent-encoded>`).", body = Invite, content_type = "application/json; charset=utf-8"),
(status = 400, response = BadRequest),
(status = 403, response = Forbidden),
(status = 404, response = NotFound),
@@ -393,10 +405,14 @@ pub async fn create_invite(
.await?
}
};
// The link names the admin so S-1 can say whom the code came from (AC-2.6).
let from = rows::user(&mut tx, admin.user_id)
.await?
.map(|user| user.display_name);
tx.commit().await?;
Ok((
StatusCode::CREATED,
JsonBody(invite.wire(&state.config.public_url)),
JsonBody(invite.wire(&state.config.public_url, from.as_deref())),
))
}
+72 -10
View File
@@ -2,16 +2,18 @@
//!
//! Swedish when `Accept-Language` prefers `sv`, English otherwise. The page shows the app name, "Download Kvitt"
//! linking to the latest APK, "Open in Kvitt" linking to `kvitt://invite?server=…&code=…`, the code in groups of four
//! and one instruction sentence. It never checks the code and holds no personal data. Any code of 1 to 32
//! characters gets 200; anything else a 404 page.
//! and one instruction sentence. It never checks the code and holds no personal data of its own: the optional
//! `from` query parameter (the inviting admin's display name, set by E-12's link) is only passed on into the
//! `kvitt://invite` link, never shown. Any code of 1 to 32 characters gets 200; anything else a 404 page.
use axum::extract::{Path, State};
use axum::extract::{Path, RawQuery, State};
use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
use axum::response::{IntoResponse, Response};
use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode};
use percent_encoding::{NON_ALPHANUMERIC, percent_decode_str, utf8_percent_encode};
use crate::AppState;
use crate::api::apk;
use crate::api::invites::FROM_PARAM;
use crate::api::{apk, validate};
/// Longest code the page accepts (E-4).
pub const MAX_CODE_CHARS: usize = 32;
@@ -123,18 +125,48 @@ letter-spacing:.1em;margin:1.5rem 0}a.button{display:block;margin:.75rem 0;paddi
text-decoration:none;font-weight:600;background:#2e7d32;color:#fff}a.secondary{background:#e8f5e9;color:#1b5e20}\
@media (prefers-color-scheme:dark){body{background:#121212;color:#eee}a.secondary{background:#1e3320;color:#c8e6c9}}";
/// The landing page HTML (AC-2.5). `download_url` is the latest APK, when one is published.
pub fn render(lang: Lang, code: &str, public_url: &str, download_url: Option<&str>) -> String {
/// The `from` value of a raw query string: the first `from=` pair, `+` as space, percent-decoded as UTF-8, trimmed.
/// `None` unless it is a display name of §9.6 (1 to 40 code points, no control characters); a bad value is dropped,
/// never an error, so the page still opens.
pub fn from_param(raw_query: Option<&str>) -> Option<String> {
let value = raw_query?
.split('&')
.find_map(|pair| pair.strip_prefix(FROM_PARAM)?.strip_prefix('='))?;
let decoded = percent_decode_str(&value.replace('+', " "))
.decode_utf8()
.ok()?
.trim()
.to_owned();
let length = decoded.chars().count();
((1..=validate::DISPLAY_NAME_MAX).contains(&length) && !decoded.chars().any(char::is_control))
.then_some(decoded)
}
/// The landing page HTML (AC-2.5). `download_url` is the latest APK, when one is published; `from` the inviting
/// admin's name, passed on into the `kvitt://invite` link only.
pub fn render(
lang: Lang,
code: &str,
public_url: &str,
download_url: Option<&str>,
from: Option<&str>,
) -> String {
let copy = match lang {
Lang::En => EN,
Lang::Sv => SV,
};
let code = clean_code(code);
let open_url = format!(
let mut open_url = format!(
"kvitt://invite?server={}&code={}",
utf8_percent_encode(public_url, NON_ALPHANUMERIC),
utf8_percent_encode(&code, NON_ALPHANUMERIC)
);
if let Some(name) = from {
open_url.push_str(&format!(
"&{FROM_PARAM}={}",
utf8_percent_encode(name, NON_ALPHANUMERIC)
));
}
let download = download_url
.map(|url| {
format!(
@@ -186,6 +218,7 @@ fn html(status: StatusCode, body: String) -> Response {
security(()),
params(
("code" = String, Path),
("from" = Option<String>, Query, description = "The inviting admin's display name (E-12 link), passed on into the `kvitt://invite` link; an invalid value is ignored."),
),
responses(
(status = 200, description = "Landing page.", body = String, content_type = "text/html; charset=utf-8"),
@@ -196,6 +229,7 @@ pub async fn get_invite_landing(
State(state): State<AppState>,
headers: HeaderMap,
Path(code): Path<String>,
RawQuery(query): RawQuery,
) -> Response {
let len = code.chars().count();
if !(1..=MAX_CODE_CHARS).contains(&len) {
@@ -217,7 +251,13 @@ pub async fn get_invite_landing(
};
html(
StatusCode::OK,
render(lang, &code, &state.config.public_url, download.as_deref()),
render(
lang,
&code,
&state.config.public_url,
download.as_deref(),
from_param(query.as_deref()).as_deref(),
),
)
}
@@ -238,8 +278,30 @@ mod tests {
#[test]
fn html_is_escaped() {
let page = render(Lang::En, "<b>", "https://x", None);
let page = render(Lang::En, "<b>", "https://x", None, Some("<i>Å"));
assert!(page.contains("&lt;B&gt;"));
assert!(!page.contains("<b>"));
assert!(page.contains("&amp;from=%3Ci%3E%C3%85\""), "{page}");
assert!(!page.contains("<i>"));
}
#[test]
fn from_parameter() {
assert_eq!(from_param(None), None);
assert_eq!(from_param(Some("from=Jacob")).as_deref(), Some("Jacob"));
assert_eq!(
from_param(Some("x=1&from=%C3%85sa+Berg")).as_deref(),
Some("Åsa Berg")
);
assert_eq!(from_param(Some("from=")), None);
assert_eq!(from_param(Some("from=%20%20")), None);
assert_eq!(from_param(Some("from=%FF")), None, "not UTF-8");
assert_eq!(from_param(Some("from=a%0Ab")), None, "control character");
assert_eq!(from_param(Some(&format!("from={}", "a".repeat(41)))), None);
assert_eq!(
from_param(Some(&format!("from={}", "å".repeat(40)))).map(|n| n.chars().count()),
Some(40)
);
assert_eq!(from_param(Some("fromage=1")), None);
}
}
+3 -1
View File
@@ -129,6 +129,7 @@ struct DeviceRow {
user_id: String,
name: String,
app_version_code: Option<i64>,
app_version_name: Option<String>,
created_at: String,
last_seen_at: String,
revoked_at: Option<String>,
@@ -142,6 +143,7 @@ impl DeviceRow {
user_id: uuid(&self.user_id)?,
name: self.name,
app_version_code: self.app_version_code,
app_version_name: self.app_version_name,
created_at: self.created_at,
last_seen_at: self.last_seen_at,
revoked_at: self.revoked_at,
@@ -152,7 +154,7 @@ impl DeviceRow {
macro_rules! device_columns {
() => {
"id, user_id, name, app_version_code, created_at, last_seen_at, revoked_at"
"id, user_id, name, app_version_code, app_version_name, created_at, last_seen_at, revoked_at"
};
}
+18 -4
View File
@@ -4,7 +4,7 @@
//! 426 `protocol_unsupported`. It runs before the token check, so an outdated app always learns it must update.
//! * [`AuthDevice`] is the extractor for authenticated handlers: `Authorization: Bearer <token>`, SHA-256 hex lookup
//! of a device; missing, malformed or unknown is 401 `unauthorized`, a revoked device 401 `device_revoked`.
//! `last_seen_at` and `app_version_code` are refreshed at most once per hour per device.
//! `last_seen_at`, `app_version_code` and `app_version_name` are refreshed at most once per hour per device.
//! * [`AdminDevice`] additionally requires the `admin` role (403 `forbidden`, SPEC §8.4).
use std::fmt::Write as _;
@@ -30,12 +30,16 @@ pub const PROTOCOL_HEADER: &str = "x-kvitt-protocol";
pub const PROTOCOL_VERSION: &str = "1";
/// Header carrying the app's `versionCode`.
pub const APP_VERSION_HEADER: &str = "x-kvitt-app-version";
/// Header carrying the app's `versionName` (schema 3, shown on S-20).
pub const APP_VERSION_NAME_HEADER: &str = "x-kvitt-app-version-name";
/// Longest `versionName` stored (the `devices.app_version_name` CHECK of migration 0003).
pub const APP_VERSION_NAME_MAX: usize = 32;
/// Prefix of every device token (SPEC §8.2).
pub const TOKEN_PREFIX: &str = "kvt_";
/// Length of a token: `kvt_` plus 43 base64url characters (32 bytes, no padding).
pub const TOKEN_LEN: usize = 47;
/// How often `last_seen_at` / `app_version_code` are written at most (SPEC §8.2).
/// How often `last_seen_at` / `app_version_code` / `app_version_name` are written at most (SPEC §8.2).
const TOUCH_INTERVAL: Duration = Duration::hours(1);
/// Lowercase hex SHA-256 of `text` (token and invite code hashes, SPEC §8.2, §8.3).
@@ -103,6 +107,15 @@ fn app_version_code(headers: &HeaderMap) -> Option<i64> {
.filter(|v| *v >= 0)
}
/// The `X-Kvitt-App-Version-Name` value: trimmed, 1 to [`APP_VERSION_NAME_MAX`] characters; anything else is
/// ignored (the stored value is kept), as an unparsable `X-Kvitt-App-Version` is.
pub fn app_version_name(headers: &HeaderMap) -> Option<String> {
let value = headers.get(APP_VERSION_NAME_HEADER)?.to_str().ok()?.trim();
(1..=APP_VERSION_NAME_MAX)
.contains(&value.chars().count())
.then(|| value.to_owned())
}
impl AuthDevice {
/// Authenticates the request headers against the database.
pub async fn authenticate(headers: &HeaderMap, state: &AppState) -> Result<Self, AppError> {
@@ -141,11 +154,12 @@ impl AuthDevice {
if due {
let mut tx = crate::db::begin_immediate(&state.db).await?;
sqlx::query(
"UPDATE devices SET last_seen_at = ?, app_version_code = COALESCE(?, app_version_code) \
WHERE id = ?",
"UPDATE devices SET last_seen_at = ?, app_version_code = COALESCE(?, app_version_code), \
app_version_name = COALESCE(?, app_version_name) WHERE id = ?",
)
.bind(format_ts(now))
.bind(app_version_code(headers))
.bind(app_version_name(headers))
.bind(device_id.to_string())
.execute(&mut *tx)
.await?;
@@ -43,7 +43,7 @@ pub fn render(invite: &NewInvite, public_url: &str) -> String {
format!(
"Code: {}\nLink: {}\nExpires: {}\n",
invites::display_code(&invite.code),
invites::invite_link(public_url, &invite.code),
invites::invite_link(public_url, &invite.code, None),
invite.expires_at
)
}
+3 -2
View File
@@ -7,10 +7,11 @@ pub use pool::{Db, begin_immediate, connect};
pub use sequence::next_server_version;
/// The schema version the embedded migrations produce (`meta.schema_version`, SPEC §14.1).
pub const SCHEMA_VERSION: i64 = 2;
pub const SCHEMA_VERSION: i64 = 3;
/// The embedded migrations of `migrations/` (`0001_init.sql` is byte-identical to `contracts/schema/server-v1.sql`,
/// `0002_categories_v2.sql` to `contracts/schema/migrations/0002_categories_v2.sql`, SPEC §14.1.1).
/// `0002_categories_v2.sql` and `0003_device_version_name.sql` to their copies in `contracts/schema/migrations/`,
/// SPEC §14.1.1).
pub static MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!("./migrations");
/// Runs every pending migration.
+12 -3
View File
@@ -6,8 +6,8 @@
//! hold for every operation are applied once here, by [`CommonRules`], so they cannot drift per handler:
//!
//! * SPEC §14.3.1 headers: every operation takes `X-Kvitt-Protocol` (required on authenticated operations, optional
//! on the unauthenticated ones), `X-Kvitt-App-Version` and `Accept-Language`; every response carries
//! `X-Kvitt-Server-Version`.
//! on the unauthenticated ones), `X-Kvitt-App-Version`, `X-Kvitt-App-Version-Name` and `Accept-Language`; every
//! response carries `X-Kvitt-Server-Version`.
//! * SPEC §14.3.2 always-possible errors: `401`, `426`, `429`, `500`, `503` on authenticated operations and `429`,
//! `500`, `503` on unauthenticated ones, unless the operation documents its own.
//! * Security: the document-level requirement is `device` (bearer, SPEC §8.2); unauthenticated operations declare
@@ -258,6 +258,15 @@ impl Modify for CommonRules {
plain_schema(Type::Integer),
),
);
components.parameters.insert(
"AppVersionName".to_owned(),
header_param(
"X-Kvitt-App-Version-Name",
false,
"The app's `versionName`, sent by the app on every call; stored per device like `X-Kvitt-App-Version` (1 to 32 characters, anything else ignored).",
plain_schema(Type::String),
),
);
components.parameters.insert(
"AcceptLanguage".to_owned(),
header_param(
@@ -339,7 +348,7 @@ fn apply_operation_rules(operation: &mut Operation) {
} else {
"Protocol"
};
for name in [protocol, "AppVersion", "AcceptLanguage"] {
for name in [protocol, "AppVersion", "AppVersionName", "AcceptLanguage"] {
parameters.push(RefOr::Ref(Ref::new(format!(
"#/components/parameters/{name}"
))));
+2 -2
View File
@@ -5,7 +5,7 @@
use std::time::Instant;
use axum::Router;
use axum::extract::{DefaultBodyLimit, MatchedPath, Path, Request, State};
use axum::extract::{DefaultBodyLimit, MatchedPath, Path, RawQuery, Request, State};
use axum::http::{HeaderMap, HeaderValue};
use axum::middleware::{self, Next};
use axum::response::{IntoResponse, Response};
@@ -114,7 +114,7 @@ pub fn router(state: AppState) -> Router {
/// `GET /i/`: E-4 with an empty code, which the landing handler answers with its HTML 404 page.
async fn empty_invite_code(state: State<AppState>, headers: HeaderMap) -> Response {
landing::get_invite_landing(state, headers, Path(String::new())).await
landing::get_invite_landing(state, headers, Path(String::new()), RawQuery(None)).await
}
/// Unknown paths: 404 `not_found` in the error shape.
@@ -16,6 +16,9 @@ pub struct Device {
pub name: String,
#[schema(schema_with = super::nullable_int, required = true)]
pub app_version_code: Option<i64>,
/// The app's `versionName` from `X-Kvitt-App-Version-Name` (schema 3); null until a request carried it.
#[schema(required = true)]
pub app_version_name: Option<String>,
#[schema(value_type = TimestampSchema)]
pub created_at: String,
#[schema(value_type = TimestampSchema)]
@@ -51,10 +51,12 @@ pub struct AcceptInviteRequest {
pub display_name: Option<String>,
}
/// E-3 response. The token is shown only here.
/// E-3 response. The token is shown only here. `invite_kind` is the kind of the accepted invite, so the app shows
/// the profile step (S-2) only after a `partner` pairing (AC-2.10).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
pub struct AcceptInviteResponse {
pub token: String,
pub invite_kind: InviteKind,
pub user: User,
pub device: Device,
pub household: Vec<User>,
@@ -8,7 +8,9 @@ use crate::common::*;
#[tokio::test]
async fn e15_admin_sees_all_devices_member_own_ordered_by_created_at() {
let app = TestApp::household().await;
sqlx::query("UPDATE devices SET app_version_code = 41 WHERE revoked_at IS NULL")
sqlx::query(
"UPDATE devices SET app_version_code = 41, app_version_name = '1.1' WHERE revoked_at IS NULL",
)
.execute(&app.state.db)
.await
.unwrap();
@@ -37,6 +37,9 @@ async fn accept(app: &TestApp, body: Value) -> Reply {
request
.headers_mut()
.insert("x-kvitt-app-version", "41".parse().unwrap());
request
.headers_mut()
.insert("x-kvitt-app-version-name", " 1.1 ".parse().unwrap());
app.send(request).await
}
@@ -69,6 +72,8 @@ async fn e3_admin_setup_creates_the_admin_and_e2_turns_initialized() {
assert_eq!(reply.json["user"]["role"], "admin");
assert_eq!(reply.json["user"]["display_name"], "Jacob");
assert_eq!(reply.json["device"]["app_version_code"], 41);
assert_eq!(reply.json["device"]["app_version_name"], "1.1");
assert_eq!(reply.json["invite_kind"], "admin_setup");
// The token works; only its hash is stored (§8.2).
let me = app.call(Method::GET, "/me", &token, None).await;
@@ -94,6 +99,7 @@ async fn e3_partner_creates_the_member_and_device_pairs_an_existing_user() {
let reply = accept(&app, accept_body(&code, Some("Anna"))).await;
assert_eq!(reply.status, 200, "{}", reply.text());
assert_eq!(reply.json["user"]["role"], "member");
assert_eq!(reply.json["invite_kind"], "partner");
assert_eq!(reply.json["household"].as_array().unwrap().len(), 2);
let member_id = reply.json["user"]["id"].as_str().unwrap().to_owned();
@@ -103,6 +109,7 @@ async fn e3_partner_creates_the_member_and_device_pairs_an_existing_user() {
assert_eq!(reply.status, 200, "{}", reply.text());
assert_eq!(reply.json["user"]["id"], json!(member_id));
assert_eq!(reply.json["user"]["display_name"], "Anna");
assert_eq!(reply.json["invite_kind"], "device");
assert_eq!(reply.json["device"]["is_current"], true);
assert_eq!(app.scalar_i64("SELECT COUNT(*) FROM users").await, 2);
}
@@ -212,7 +219,9 @@ async fn e12_partner_invite_matches_the_example() {
assert_eq!(code.len(), 12);
assert_eq!(
reply.json["link"],
json!(format!("https://icehack.asuscomm.com:8443/i/{code}"))
json!(format!(
"https://icehack.asuscomm.com:8443/i/{code}?from=Jacob"
))
);
assert_eq!(reply.json["created_at"], NOW);
assert_eq!(reply.json["expires_at"], "2026-10-20T12:00:00.000Z");
@@ -82,3 +82,22 @@ async fn e4_does_not_check_validity_and_404s_outside_1_to_32_characters() {
assert_eq!(reply.status, 200);
assert!(!reply.text().contains("<SCRIPT>"));
}
#[tokio::test]
async fn e4_passes_the_admin_name_of_an_e12_link_into_the_app_link_only() {
let app = TestApp::admin_only().await;
let reply = landing(&app, "/i/7K3MQX9D2HPA?from=%C3%85sa%20Berg", None).await;
assert_eq!(reply.status, 200);
let html = reply.text();
assert!(html.contains(
"href=\"kvitt://invite?server=https%3A%2F%2Ficehack%2Easuscomm%2Ecom%3A8443&amp;code=7K3MQX9D2HPA&amp;from=%C3%85sa%20Berg\""
), "{html}");
// The name is not shown on the page itself.
assert!(!html.contains("Åsa"));
// A bad value is dropped, the page still opens.
for query in ["from=", "from=%FF", "from=a%0Ab"] {
let reply = landing(&app, &format!("/i/7K3MQX9D2HPA?{query}"), None).await;
assert_eq!(reply.status, 200, "{query}");
assert!(!reply.text().contains("&amp;from="), "{query}");
}
}
@@ -17,6 +17,9 @@ async fn e7_returns_user_device_household_and_public_url() {
request
.headers_mut()
.insert("x-kvitt-app-version", "41".parse().unwrap());
request
.headers_mut()
.insert("x-kvitt-app-version-name", "1.1".parse().unwrap());
let reply = app.send(request).await;
assert_eq!(reply.status, 200);
// The harness seeds the users after the fourteen category seeds, so they have `server_version` 15 and
@@ -1,7 +1,7 @@
//! The migrations are the contract texts byte for byte (`0001_init.sql` = `server-v1.sql`, SPEC §14.1;
//! `0002_categories_v2.sql` = `contracts/schema/migrations/0002_categories_v2.sql`, §14.1.1), connections carry the
//! §8.9 pragmas, a fresh database holds the fourteen defaults of AC-7.2, and a v1 database in use upgrades to schema
//! 2 without losing or changing a row.
//! `0002_categories_v2.sql` and `0003_device_version_name.sql` = their copies in `contracts/schema/migrations/`,
//! §14.1.1), connections carry the §8.9 pragmas, a fresh database holds the fourteen defaults of AC-7.2, a v1
//! database in use upgrades to schema 2 and a v2 database to schema 3 without losing or changing a row.
use std::path::PathBuf;
@@ -66,6 +66,19 @@ fn migration_0002_is_byte_identical_to_contract_migration() {
);
}
#[test]
fn migration_0003_is_byte_identical_to_contract_migration() {
let migration = std::fs::read(manifest("migrations/0003_device_version_name.sql")).unwrap();
let contract = std::fs::read(manifest(
"../../../contracts/schema/migrations/0003_device_version_name.sql",
))
.unwrap();
assert!(
migration == contract,
"migrations/0003_device_version_name.sql differs from contracts/schema/migrations/0003_device_version_name.sql"
);
}
#[test]
fn the_embedded_migrator_holds_exactly_those_migrations() {
let v1 = std::fs::read_to_string(manifest("../../../contracts/schema/server-v1.sql")).unwrap();
@@ -73,15 +86,21 @@ fn the_embedded_migrator_holds_exactly_those_migrations() {
"../../../contracts/schema/migrations/0002_categories_v2.sql",
))
.unwrap();
let v3 = std::fs::read_to_string(manifest(
"../../../contracts/schema/migrations/0003_device_version_name.sql",
))
.unwrap();
let migrations: Vec<_> = db::MIGRATOR.iter().collect();
assert_eq!(migrations.len(), 2);
assert_eq!(migrations.len(), 3);
assert_eq!(migrations[0].version, 1);
assert_eq!(migrations[0].sql.as_ref(), v1.as_str());
assert_eq!(migrations[1].version, 2);
assert_eq!(migrations[1].sql.as_ref(), v2.as_str());
assert_eq!(migrations[2].version, 3);
assert_eq!(migrations[2].sql.as_ref(), v3.as_str());
// sqlx wraps each migration in one transaction unless it opts out (SPEC §14.1.1 relies on it).
assert!(migrations.iter().all(|m| !m.no_tx));
assert_eq!(db::SCHEMA_VERSION, 2);
assert_eq!(db::SCHEMA_VERSION, 3);
}
/// Every row of `table` in a stable order, each rendered with SQLite's `quote()` per column (type-exact).
@@ -300,7 +319,7 @@ async fn a_v1_database_in_use_upgrades_to_schema_2_keeping_every_row() {
}
}
db::migrate(pool).await.unwrap();
db::MIGRATOR.run_to(2, pool).await.unwrap();
// The connection that ran the migration had foreign keys on (SPEC §8.9), and nothing dangles afterwards.
let mut conn = pool.acquire().await.unwrap();
@@ -374,7 +393,9 @@ async fn a_v1_database_in_use_upgrades_to_schema_2_keeping_every_row() {
15
);
// A phone at the old cursor pulls exactly the four new categories.
// A phone at the old cursor pulls exactly the four new categories (the server runs at the current schema, so
// the later migrations, which write no synced row, are applied first).
db::migrate(pool).await.unwrap();
let reply = app
.send(authed(
Method::GET,
@@ -421,6 +442,83 @@ async fn a_v1_database_in_use_upgrades_to_schema_2_keeping_every_row() {
);
}
#[tokio::test]
async fn a_v2_database_in_use_upgrades_to_schema_3_keeping_every_row() {
let app = TestApp::at_v2().await;
let pool = &app.state.db;
assert_eq!(
sqlx::query_scalar::<_, String>("SELECT value FROM meta WHERE key = 'schema_version'")
.fetch_one(pool)
.await
.unwrap(),
"2"
);
app.seed_household().await;
sqlx::query("UPDATE devices SET app_version_code = 41, fcm_token = 'fcm-1' WHERE id = ?")
.bind(crate::support::ADMIN_DEVICE_ID)
.execute(pool)
.await
.unwrap();
let old_sequence = scalar_i64(pool, "SELECT value FROM sync_sequence WHERE id = 1").await;
let tables = user_tables(pool).await;
let mut before = Vec::new();
for table in &tables {
if table != "meta" {
before.push((table.clone(), dump(pool, table, "").await));
}
}
db::migrate(pool).await.unwrap();
assert_eq!(foreign_key_violations(pool).await, 0);
assert_eq!(
user_tables(pool).await,
tables,
"no table added or left behind"
);
assert_eq!(
sqlx::query_scalar::<_, String>("SELECT value FROM meta WHERE key = 'schema_version'")
.fetch_one(pool)
.await
.unwrap(),
"3"
);
assert_eq!(
scalar_i64(pool, "SELECT value FROM sync_sequence WHERE id = 1").await,
old_sequence,
"0003 writes no synced row"
);
// Every row is unchanged; devices gain only the trailing NULL column.
for (table, rows) in &before {
let after = dump(pool, table, "").await;
if table == "devices" {
let expected: Vec<String> = rows.iter().map(|r| format!("{r}|NULL")).collect();
assert_eq!(after, expected, "devices changed");
} else {
assert_eq!(&after, rows, "{table} changed");
}
}
// The new column takes a name of 1 to 32 characters or NULL.
let set = |value: Option<&'static str>| {
sqlx::query("UPDATE devices SET app_version_name = ? WHERE id = ?")
.bind(value)
.bind(crate::support::ADMIN_DEVICE_ID)
.execute(pool)
};
assert!(set(Some("1.1")).await.is_ok());
assert!(set(None).await.is_ok());
assert!(set(Some("")).await.is_err(), "empty rejected");
assert!(
set(Some("123456789012345678901234567890123"))
.await
.is_err(),
"33 characters rejected"
);
// A second run is a no-op.
db::migrate(pool).await.unwrap();
}
#[tokio::test]
async fn migrated_database_has_schema_version_seed_rows_and_pragmas() {
let app = TestApp::empty().await;
@@ -104,7 +104,7 @@ async fn health_reports_ok_with_versions() {
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})
json!({"status": "ok", "server_version": env!("CARGO_PKG_VERSION"), "schema_version": 3, "db_ok": true})
);
assert_eq!(
reply.headers["x-kvitt-server-version"],
@@ -124,7 +124,7 @@ async fn health_is_503_degraded_when_the_database_is_gone() {
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);
assert_eq!(reply.json["schema_version"], 3);
}
#[tokio::test]
@@ -285,6 +285,44 @@ async fn last_seen_and_app_version_are_written_at_most_hourly() {
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 {
@@ -71,6 +71,11 @@ impl TestApp {
Self::migrated_to(Some(1)).await
}
/// A database migrated to schema 2 only (before 0003).
pub async fn at_v2() -> Self {
Self::migrated_to(Some(2)).await
}
async fn migrated_to(target: Option<i64>) -> Self {
let dir = TempDir::new();
let config = Config::from_lookup(|k| {