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
@@ -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