//! Phone alerts (oxygenhub-android#16): a phone registers its Cloud //! Messaging token and its key with the token it signed in with; each //! notification its account is told goes to it sealed to that key, so the //! push service sees no title; a registration Cloud Messaging no longer //! knows is forgotten, and one goes with its token. use axum::body::Body; use axum::http::{header, Request, StatusCode}; use axum::routing::{get, post}; use axum::{Json, Router}; use http_body_util::BodyExt; use oxygenhub::config::Config; use oxygenhub::db; use oxygenhub::Shared; use std::sync::{Arc, Mutex}; use tower::ServiceExt; type Sent = Arc>>; /// Google's metadata server and Cloud Messaging, as far as the hub uses them. async fn fake_google() -> (String, Sent) { let sent: Sent = Arc::default(); let kept = sent.clone(); let app = Router::new() .route( "/token", get(|h: axum::http::HeaderMap| async move { assert_eq!(h.get("metadata-flavor").and_then(|v| v.to_str().ok()), Some("Google")); Json(serde_json::json!({ "access_token": "vm-token", "expires_in": 3600 })) }), ) .route( "/v1/projects/demo/messages:send", post(move |h: axum::http::HeaderMap, Json(body): Json| { let kept = kept.clone(); async move { assert_eq!(h.get("authorization").and_then(|v| v.to_str().ok()), Some("Bearer vm-token")); let gone = body["message"]["token"] == "gone"; kept.lock().unwrap().push(body); if gone { ( StatusCode::NOT_FOUND, Json(serde_json::json!({ "error": { "status": "NOT_FOUND", "details": [{ "errorCode": "UNREGISTERED" }] } })), ) } else { (StatusCode::OK, Json(serde_json::json!({ "name": "projects/demo/messages/1" }))) } } }), ); let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let base = format!("http://{}", listener.local_addr().unwrap()); tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); (base, sent) } async fn call(app: &Shared, method: &str, path: &str, token: &str, body: Option) -> (StatusCode, String) { let mut req = Request::builder().method(method).uri(path).header(header::AUTHORIZATION, format!("Bearer {token}")); let body = match body { Some(b) => { req = req.header(header::CONTENT_TYPE, "application/json"); Body::from(b.to_string()) } None => Body::empty(), }; let res = oxygenhub::router(app.clone()).oneshot(req.body(body).unwrap()).await.unwrap(); let status = res.status(); (status, String::from_utf8_lossy(&res.into_body().collect().await.unwrap().to_bytes()).into_owned()) } fn b64url_decode(s: &str) -> Vec { let val = |c: u8| match c { b'A'..=b'Z' => c - b'A', b'a'..=b'z' => c - b'a' + 26, b'0'..=b'9' => c - b'0' + 52, b'-' => 62, _ => 63, } as u32; let mut out = Vec::new(); for chunk in s.as_bytes().chunks(4) { let n = chunk.iter().enumerate().fold(0u32, |acc, (i, c)| acc | (val(*c) << (18 - 6 * i))); out.extend_from_slice(&n.to_be_bytes()[1..chunk.len()]); } out } async fn sent_after(sent: &Sent, n: usize) -> Vec { for _ in 0..100 { if sent.lock().unwrap().len() >= n { break; } tokio::time::sleep(std::time::Duration::from_millis(50)).await; } sent.lock().unwrap().clone() } #[tokio::test(flavor = "multi_thread")] async fn a_notification_reaches_the_phone_sealed() { let (google, sent) = fake_google().await; let dir = tempfile::tempdir().unwrap(); let mut config = Config::load(dir.path()).unwrap(); config.push.fcm_project = "demo".into(); config.push.fcm_url = google.clone(); config.push.token_url = format!("{google}/token"); let app = oxygenhub::build(config).await.unwrap(); let bob = db::accounts::create_account(&app.db, "bob", "bob@example.com", "x", "salt", false).await.unwrap(); let (phone_token, hash) = oxygenhub::auth::new_token(); db::accounts::create_token(&app.db, bob.id, "pixel", &hash, "repo:read", "", None).await.unwrap(); // The phone registers its Cloud Messaging token and its key. let secret = flow_core::alert::new_secret(); let key = hex::encode(flow_core::alert::public_key(&secret)); let bad = call(&app, "PUT", "/api/v1/user/push", &phone_token, Some(serde_json::json!({ "fcm_token": "f1", "public_key": "abc" }))).await; assert_eq!(bad.0, StatusCode::BAD_REQUEST, "{}", bad.1); let (status, body) = call(&app, "PUT", "/api/v1/user/push", &phone_token, Some(serde_json::json!({ "fcm_token": "f1", "public_key": key }))).await; assert_eq!(status, StatusCode::OK, "{body}"); assert!(body.contains("\"alerts\":true"), "{body}"); // A notification for bob: his phone is sent it, sealed. oxygenhub::notify::tell( &app, "issue.assigned", "andy/demo#1: Fix it", "/andy/demo/issue/1", "andy", None, vec![(bob.id, db::notify::Reason::Participating)], ) .await; let got = sent_after(&sent, 1).await; assert_eq!(got.len(), 1, "{got:?}"); let m = &got[0]["message"]; assert_eq!(m["token"], "f1"); assert_eq!(m["android"]["priority"], "high"); assert!(m.get("notification").is_none() && !got[0].to_string().contains("Fix it"), "nothing readable on the way: {got:?}"); let opened = flow_core::alert::open(&secret, &b64url_decode(m["data"]["sealed"].as_str().unwrap())).expect("sealed to the phone"); let alert: oxygenhub::push::Alert = serde_json::from_slice(&opened).unwrap(); assert_eq!(alert.kind, "issue.assigned"); assert_eq!(alert.title, "📌 Issue Assigned: andy/demo#1: Fix it"); assert!(alert.url.ends_with("/andy/demo/issue/1") && alert.url.starts_with("http"), "{}", alert.url); // A registration Cloud Messaging no longer knows is forgotten. let (gone_token, hash) = oxygenhub::auth::new_token(); db::accounts::create_token(&app.db, bob.id, "old phone", &hash, "repo:read", "", None).await.unwrap(); call(&app, "PUT", "/api/v1/user/push", &gone_token, Some(serde_json::json!({ "fcm_token": "gone", "public_key": key }))).await; assert_eq!(oxygenhub::push::send_now(&app, bob.id, &alert).await, Ok(1)); let left: Vec = db::push::of_account(&app.db, bob.id).await.unwrap().into_iter().map(|d| d.fcm_token).collect(); assert_eq!(left, ["f1"]); // Unregistering, and signing out (the token revoked), end it. assert_eq!(call(&app, "DELETE", "/api/v1/user/push", &phone_token, None).await.0, StatusCode::NO_CONTENT); assert!(db::push::of_account(&app.db, bob.id).await.unwrap().is_empty()); call(&app, "PUT", "/api/v1/user/push", &phone_token, Some(serde_json::json!({ "fcm_token": "f1", "public_key": key }))).await; assert_eq!(call(&app, "DELETE", "/api/v1/user/token", &phone_token, None).await.0, StatusCode::NO_CONTENT); assert!(db::push::of_account(&app.db, bob.id).await.unwrap().is_empty(), "gone with its token"); }