| aeed63c0 Andy McCall ↶ |
1 |
|
| 2 |
|
| 3 |
|
| 4 |
|
| 5 |
|
| 6 |
|
| 7 |
use axum::body::Body; |
| 8 |
use axum::http::{header, Request, StatusCode}; |
| 9 |
use axum::routing::{get, post}; |
| 10 |
use axum::{Json, Router}; |
| 11 |
use http_body_util::BodyExt; |
| 12 |
use oxygenhub::config::Config; |
| 13 |
use oxygenhub::db; |
| 14 |
use oxygenhub::Shared; |
| 15 |
use std::sync::{Arc, Mutex}; |
| 16 |
use tower::ServiceExt; |
| 17 |
|
| 18 |
type Sent = Arc<Mutex<Vec<serde_json::Value>>>; |
| 19 |
|
| 20 |
|
| 21 |
async fn fake_google() -> (String, Sent) { |
| 22 |
let sent: Sent = Arc::default(); |
| 23 |
let kept = sent.clone(); |
| 24 |
let app = Router::new() |
| 25 |
.route( |
| 26 |
"/token", |
| 27 |
get(|h: axum::http::HeaderMap| async move { |
| 28 |
assert_eq!(h.get("metadata-flavor").and_then(|v| v.to_str().ok()), Some("Google")); |
| 29 |
Json(serde_json::json!({ "access_token": "vm-token", "expires_in": 3600 })) |
| 30 |
}), |
| 31 |
) |
| 32 |
.route( |
| 33 |
"/v1/projects/demo/messages:send", |
| 34 |
post(move |h: axum::http::HeaderMap, Json(body): Json<serde_json::Value>| { |
| 35 |
let kept = kept.clone(); |
| 36 |
async move { |
| 37 |
assert_eq!(h.get("authorization").and_then(|v| v.to_str().ok()), Some("Bearer vm-token")); |
| 38 |
let gone = body["message"]["token"] == "gone"; |
| 39 |
kept.lock().unwrap().push(body); |
| 40 |
if gone { |
| 41 |
( |
| 42 |
StatusCode::NOT_FOUND, |
| 43 |
Json(serde_json::json!({ "error": { "status": "NOT_FOUND", "details": [{ "errorCode": "UNREGISTERED" }] } })), |
| 44 |
) |
| 45 |
} else { |
| 46 |
(StatusCode::OK, Json(serde_json::json!({ "name": "projects/demo/messages/1" }))) |
| 47 |
} |
| 48 |
} |
| 49 |
}), |
| 50 |
); |
| 51 |
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); |
| 52 |
let base = format!("http://{}", listener.local_addr().unwrap()); |
| 53 |
tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); |
| 54 |
(base, sent) |
| 55 |
} |
| 56 |
|
| 57 |
async fn call(app: &Shared, method: &str, path: &str, token: &str, body: Option<serde_json::Value>) -> (StatusCode, String) { |
| 58 |
let mut req = Request::builder().method(method).uri(path).header(header::AUTHORIZATION, format!("Bearer {token}")); |
| 59 |
let body = match body { |
| 60 |
Some(b) => { |
| 61 |
req = req.header(header::CONTENT_TYPE, "application/json"); |
| 62 |
Body::from(b.to_string()) |
| 63 |
} |
| 64 |
None => Body::empty(), |
| 65 |
}; |
| 66 |
let res = oxygenhub::router(app.clone()).oneshot(req.body(body).unwrap()).await.unwrap(); |
| 67 |
let status = res.status(); |
| 68 |
(status, String::from_utf8_lossy(&res.into_body().collect().await.unwrap().to_bytes()).into_owned()) |
| 69 |
} |
| 70 |
|
| 71 |
fn b64url_decode(s: &str) -> Vec<u8> { |
| 72 |
let val = |c: u8| match c { |
| 73 |
b'A'..=b'Z' => c - b'A', |
| 74 |
b'a'..=b'z' => c - b'a' + 26, |
| 75 |
b'0'..=b'9' => c - b'0' + 52, |
| 76 |
b'-' => 62, |
| 77 |
_ => 63, |
| 78 |
} as u32; |
| 79 |
let mut out = Vec::new(); |
| 80 |
for chunk in s.as_bytes().chunks(4) { |
| 81 |
let n = chunk.iter().enumerate().fold(0u32, |acc, (i, c)| acc | (val(*c) << (18 - 6 * i))); |
| 82 |
out.extend_from_slice(&n.to_be_bytes()[1..chunk.len()]); |
| 83 |
} |
| 84 |
out |
| 85 |
} |
| 86 |
|
| 87 |
async fn sent_after(sent: &Sent, n: usize) -> Vec<serde_json::Value> { |
| 88 |
for _ in 0..100 { |
| 89 |
if sent.lock().unwrap().len() >= n { |
| 90 |
break; |
| 91 |
} |
| 92 |
tokio::time::sleep(std::time::Duration::from_millis(50)).await; |
| 93 |
} |
| 94 |
sent.lock().unwrap().clone() |
| 95 |
} |
| 96 |
|
| 97 |
#[tokio::test(flavor = "multi_thread")] |
| 98 |
async fn a_notification_reaches_the_phone_sealed() { |
| 99 |
let (google, sent) = fake_google().await; |
| 100 |
let dir = tempfile::tempdir().unwrap(); |
| 101 |
let mut config = Config::load(dir.path()).unwrap(); |
| 102 |
config.push.fcm_project = "demo".into(); |
| 103 |
config.push.fcm_url = google.clone(); |
| 104 |
config.push.token_url = format!("{google}/token"); |
| 105 |
let app = oxygenhub::build(config).await.unwrap(); |
| 106 |
let bob = db::accounts::create_account(&app.db, "bob", "[email protected]", "x", "salt", false).await.unwrap(); |
| 107 |
let (phone_token, hash) = oxygenhub::auth::new_token(); |
| 108 |
db::accounts::create_token(&app.db, bob.id, "pixel", &hash, "repo:read", "", None).await.unwrap(); |
| 109 |
|
| 110 |
|
| 111 |
let secret = flow_core::alert::new_secret(); |
| 112 |
let key = hex::encode(flow_core::alert::public_key(&secret)); |
| 113 |
let bad = |
| 114 |
call(&app, "PUT", "/api/v1/user/push", &phone_token, Some(serde_json::json!({ "fcm_token": "f1", "public_key": "abc" }))).await; |
| 115 |
assert_eq!(bad.0, StatusCode::BAD_REQUEST, "{}", bad.1); |
| 116 |
let (status, body) = |
| 117 |
call(&app, "PUT", "/api/v1/user/push", &phone_token, Some(serde_json::json!({ "fcm_token": "f1", "public_key": key }))).await; |
| 118 |
assert_eq!(status, StatusCode::OK, "{body}"); |
| 119 |
assert!(body.contains("\"alerts\":true"), "{body}"); |
| 120 |
|
| 121 |
|
| 122 |
oxygenhub::notify::tell( |
| 123 |
&app, |
| 124 |
"issue.assigned", |
| 125 |
"andy/demo#1: Fix it", |
| 126 |
"/andy/demo/issue/1", |
| 127 |
"andy", |
| 128 |
None, |
| 129 |
vec![(bob.id, db::notify::Reason::Participating)], |
| 130 |
) |
| 131 |
.await; |
| 132 |
let got = sent_after(&sent, 1).await; |
| 133 |
assert_eq!(got.len(), 1, "{got:?}"); |
| 134 |
let m = &got[0]["message"]; |
| 135 |
assert_eq!(m["token"], "f1"); |
| 136 |
assert_eq!(m["android"]["priority"], "high"); |
| 137 |
assert!(m.get("notification").is_none() && !got[0].to_string().contains("Fix it"), "nothing readable on the way: {got:?}"); |
| 138 |
let opened = flow_core::alert::open(&secret, &b64url_decode(m["data"]["sealed"].as_str().unwrap())).expect("sealed to the phone"); |
| 139 |
let alert: oxygenhub::push::Alert = serde_json::from_slice(&opened).unwrap(); |
| 140 |
assert_eq!(alert.kind, "issue.assigned"); |
| 141 |
assert_eq!(alert.title, "📌 Issue Assigned: andy/demo#1: Fix it"); |
| 142 |
assert!(alert.url.ends_with("/andy/demo/issue/1") && alert.url.starts_with("http"), "{}", alert.url); |
| 143 |
|
| 144 |
|
| 145 |
let (gone_token, hash) = oxygenhub::auth::new_token(); |
| 146 |
db::accounts::create_token(&app.db, bob.id, "old phone", &hash, "repo:read", "", None).await.unwrap(); |
| 147 |
call(&app, "PUT", "/api/v1/user/push", &gone_token, Some(serde_json::json!({ "fcm_token": "gone", "public_key": key }))).await; |
| 148 |
assert_eq!(oxygenhub::push::send_now(&app, bob.id, &alert).await, Ok(1)); |
| 149 |
let left: Vec<String> = db::push::of_account(&app.db, bob.id).await.unwrap().into_iter().map(|d| d.fcm_token).collect(); |
| 150 |
assert_eq!(left, ["f1"]); |
| 151 |
|
| 152 |
|
| 153 |
assert_eq!(call(&app, "DELETE", "/api/v1/user/push", &phone_token, None).await.0, StatusCode::NO_CONTENT); |
| 154 |
assert!(db::push::of_account(&app.db, bob.id).await.unwrap().is_empty()); |
| 155 |
call(&app, "PUT", "/api/v1/user/push", &phone_token, Some(serde_json::json!({ "fcm_token": "f1", "public_key": key }))).await; |
| 156 |
assert_eq!(call(&app, "DELETE", "/api/v1/user/token", &phone_token, None).await.0, StatusCode::NO_CONTENT); |
| 157 |
assert!(db::push::of_account(&app.db, bob.id).await.unwrap().is_empty(), "gone with its token"); |
| 158 |
} |