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:
@@ -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&code=7K3MQX9D2HPA&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("&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| {
|
||||
|
||||
Reference in New Issue
Block a user