OXYGENHUB
# 58ac360b3345 oxygenhub / crates / hub / tests / push.rs
7.3 KiB · Rust · last changed Phone alerts: a phone registers its Cloud Messaging token and key (PUT /api/v1/user/push), and each notification its account is told goes to it sealed to that key, sent keyless as the VM's service account; pins OxygenFlow main (refs oxygenhub-android#16) 9 days ago source blame history raw
1
//! Phone alerts (oxygenhub-android#16): a phone registers its Cloud
2
//! Messaging token and its key with the token it signed in with; each
3
//! notification its account is told goes to it sealed to that key, so the
4
//! push service sees no title; a registration Cloud Messaging no longer
5
//! knows is forgotten, and one goes with its token.
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
/// Google's metadata server and Cloud Messaging, as far as the hub uses them.
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
    // The phone registers its Cloud Messaging token and its key.
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
    // A notification for bob: his phone is sent it, sealed.
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
    // A registration Cloud Messaging no longer knows is forgotten.
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
    // Unregistering, and signing out (the token revoked), end it.
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
}