johnandersen777.bsky.social/knotless
git that lives in your Atmosphere account
Forked from chadtmiller.com/knotless
0
0
//! Push notifications: devices registered through the XRPC methods, told by
//! a round of what their account hears of, through a stand-in for Apple's
//! push service.
use super::*;
use axum::extract::Path as UrlPath;
use axum::http::HeaderMap;
use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use knotless_serve::index::Notified;
use knotless_serve::web::push::{Apns, follow, message, round};
use p256::ecdsa::signature::Verifier;
use p256::ecdsa::{Signature, SigningKey};
use p256::pkcs8::EncodePrivateKey;
use std::sync::Mutex;
/// What the stand-in was sent.
#[derive(Clone)]
struct Told {
token: String,
topic: String,
kind: String,
bearer: String,
message: Value,
}
/// A stand-in for Apple, which answers by the token's first digit: `b` is
/// gone, `c` is a token it never gave, and `d` fails once.
fn apple(rt: &tokio::runtime::Runtime) -> (String, Arc<Mutex<Vec<Told>>>) {
let told = Arc::new(Mutex::new(Vec::<Told>::new()));
let kept = told.clone();
let answer = move |UrlPath(token): UrlPath<String>,
headers: HeaderMap,
axum::Json(message): axum::Json<Value>| {
let kept = kept.clone();
async move {
let header = |name: &str| headers[name].to_str().unwrap().to_string();
let mut told = kept.lock().unwrap();
let again = told.iter().any(|t: &Told| t.token == token);
told.push(Told {
token: token.clone(),
topic: header("apns-topic"),
kind: header("apns-push-type"),
bearer: header("authorization"),
message,
});
match token.chars().next() {
Some('b') => (axum::http::StatusCode::GONE, String::new()),
Some('c') => (
axum::http::StatusCode::BAD_REQUEST,
r#"{"reason":"BadDeviceToken"}"#.to_string(),
),
Some('d') if !again => (axum::http::StatusCode::INTERNAL_SERVER_ERROR, "no".into()),
_ => (axum::http::StatusCode::OK, String::new()),
}
}
};
let router = axum::Router::new().route("/3/device/{token}", axum::routing::post(answer));
let listener = rt
.block_on(tokio::net::TcpListener::bind("127.0.0.1:0"))
.unwrap();
let base = format!("http://{}", listener.local_addr().unwrap());
rt.spawn(async move { axum::serve(listener, router).await.unwrap() });
(base, told)
}
fn until(what: &str, mut done: impl FnMut() -> bool) {
for _ in 0..600 {
if done() {
return;
}
std::thread::sleep(Duration::from_millis(50));
}
panic!("never: {what}");
}
#[test]
fn a_device_hears_what_its_account_does() {
let server = gateway_with(None, true);
let rt = tokio::runtime::Runtime::new().unwrap();
let owner = person(account());
let fan = person(account());
let url = owner.url("rung");
let work = owner.work("w", 1);
owner.git(&work, &["push", "-q", &url, "main"]);
let xrpc = |nsid: &str| format!("{}/xrpc/dev.knotless.{nsid}", server.base);
let write = |of: &Sandbox, nsid: &str, input: Value| {
let token = token(of, &server.did, &format!("dev.knotless.{nsid}"));
let body = serde_json::to_vec(&input).unwrap();
post(&xrpc(nsid), Some(&token), body, true)
};
let register = |of: &Sandbox, token: &str, environment: &str| {
let input = json!({"token": token, "environment": environment});
write(of, "registerPush", input)
};
// Only a token and an environment it knows, as an account, from here.
assert_eq!(register(&owner, "../../up", "production").0, 400);
assert_eq!(register(&owner, "ab12", "moon").0, 400);
let input = json!({"token": "ab12", "environment": "production"});
let away = Some("https://elsewhere.example");
let path = xrpc("registerPush");
assert_eq!(
browse("POST", &path, None, away, Some(input.clone())).0,
403
);
assert_eq!(browse("POST", &path, None, None, Some(input)).0, 401);
let device = |digit: &str| digit.repeat(64);
assert_eq!(
register(&owner, &device("a"), "production"),
(200, "{}".into())
);
for digit in ["b", "c", "d"] {
assert_eq!(register(&owner, &device(digit), "production").0, 200);
}
// One no one answers for, on the service that is down.
assert_eq!(register(&owner, &device("e"), "sandbox").0, 200);
// One the account gives up hears nothing.
assert_eq!(register(&owner, &device("f"), "production").0, 200);
let input = json!({"token": device("f")});
assert_eq!(write(&owner, "unregisterPush", input).1, "{}");
let (base, told) = apple(&rt);
let dir = tempfile::tempdir().unwrap();
let signing = SigningKey::from_bytes((&[7u8; 32]).into()).unwrap();
let pem = signing.to_pkcs8_pem(Default::default()).unwrap();
let key = dir.path().join("apns.p8");
std::fs::write(&key, pem.as_bytes()).unwrap();
let mut apns = Apns::open(&key, Some("KEY"), Some("TEAM")).unwrap();
apns.production = base;
apns.sandbox = "http://127.0.0.1:9".into();
let index = server.app.index.clone().unwrap();
let tokens = || {
let all = index.push_devices().unwrap();
let mut tokens: Vec<_> = all.into_iter().map(|d| d.token).collect();
tokens.sort();
tokens
};
let unread = || {
let url = xrpc("getUnreadCount");
let token = token(&owner, &server.did, "dev.knotless.getUnreadCount");
let client = reqwest::blocking::Client::new();
let res = client.get(url).bearer_auth(token).send().unwrap();
res.json::<Value>().unwrap()["count"].as_u64().unwrap()
};
let to = |digit: &str| {
let told = told.lock().unwrap();
let of = told.iter().filter(|t| t.token == device(digit));
of.cloned().collect::<Vec<Told>>()
};
// Nothing yet, nothing told.
rt.block_on(round(&server.app, &index, &apns));
assert!(told.lock().unwrap().is_empty());
// A follow reaches each device; those Apple no longer knows are let go,
// and the one it failed stays for the next round.
let handle = owner.account.handle.clone();
assert_eq!(fan.cli(&["account", "follow", &handle], b"").0, 0);
until("the follow is indexed", || unread() == 1);
rt.block_on(round(&server.app, &index, &apns));
let first = to("a");
assert_eq!(first.len(), 1);
let first = &first[0];
assert_eq!(first.topic, "dev.knotless.app");
assert_eq!(first.kind, "alert");
let alert = &first.message["aps"]["alert"];
let them = &fan.account.handle;
assert_eq!(alert["title"], them.as_str());
assert_eq!(alert["body"], format!("{them} followed you"));
assert_eq!(first.message["aps"]["badge"], 1);
assert_eq!(first.message["author"], fan.account.did);
// Signed as Apple asks, with the key it gave.
let bearer = first.bearer.strip_prefix("Bearer ").unwrap();
let (signed, signature) = bearer.rsplit_once('.').unwrap();
let signature = URL_SAFE_NO_PAD.decode(signature).unwrap();
let signature = Signature::from_slice(&signature).unwrap();
signing
.verifying_key()
.verify(signed.as_bytes(), &signature)
.unwrap();
let (head, claims) = signed.split_once('.').unwrap();
let part = |p: &str| {
let bytes = URL_SAFE_NO_PAD.decode(p).unwrap();
serde_json::from_slice::<Value>(&bytes).unwrap()
};
assert_eq!(part(head), json!({"alg": "ES256", "kid": "KEY"}));
assert_eq!(part(claims)["iss"], "TEAM");
assert!(to("f").is_empty());
assert_eq!(tokens(), [device("a"), device("d"), device("e")]);
// Told once: only the one that failed hears of it again.
rt.block_on(round(&server.app, &index, &apns));
assert_eq!(to("a").len(), 1);
assert_eq!(to("d").len(), 2);
rt.block_on(round(&server.app, &index, &apns));
assert_eq!(to("d").len(), 2);
// An issue says where it is, and what it says.
let issue = ["issue", "create", &url, "Slow", "-m", "It is.\nVery."];
let (code, uri) = fan.cli(&issue, b"");
assert_eq!(code, 0, "{uri}");
until("the issue is indexed", || unread() == 2);
rt.block_on(round(&server.app, &index, &apns));
let second = to("a");
assert_eq!(second.len(), 2);
let second = &second[1].message;
let alert = &second["aps"]["alert"];
assert_eq!(alert["title"], format!("{handle}/rung"));
assert_eq!(alert["subtitle"], "Slow");
assert_eq!(alert["body"], format!("{them} opened an issue\nIt is."));
assert_eq!(second["aps"]["badge"], 2);
assert_eq!(second["aps"]["thread-id"], uri.trim());
assert_eq!(second["subject"], uri.trim());
let repo = json!({"did": owner.account.did, "handle": handle, "name": "rung"});
assert_eq!(second["repo"], repo);
// Many at once are told as many.
for n in 0..4 {
let (code, said) = fan.cli(&["issue", "create", &url, &format!("Slow {n}")], b"");
assert_eq!(code, 0, "{said}");
}
until("the issues are indexed", || unread() == 6);
rt.block_on(round(&server.app, &index, &apns));
let third = to("a");
assert_eq!(third.len(), 3);
let third = &third[2].message;
assert_eq!(third["aps"]["alert"]["body"], "4 new notifications");
assert_eq!(third["aps"]["badge"], 6);
assert_eq!(third.get("subject"), None);
// One about a private repository says nothing of it.
let private = Notified {
uri: "at://did:a/dev.knotless.issue/1".into(),
author: "did:a".into(),
reason: "issue".into(),
subject: Some("at://did:a/dev.knotless.issue/1".into()),
repo: Some(("did:a".into(), "secret".into())),
title: Some("A secret".into()),
at: "2026-01-01T00:00:00.000Z".into(),
verdict: None,
mentioned: false,
line_comments: None,
role: None,
checks: Vec::new(),
text: Some("Secrets".into()),
reviewed: false,
state: None,
private: true,
};
let said = rt.block_on(message(&server.app, &private, 1)).to_string();
assert!(
said.contains("New activity in a private repository"),
"{said}"
);
assert!(!said.to_lowercase().contains("secret"), "{said}");
// Round after round, for as long as it runs.
let rounds = follow(
server.app.clone(),
index.clone(),
apns,
Duration::from_millis(1),
);
let ran = rt.block_on(async { tokio::time::timeout(Duration::from_millis(200), rounds).await });
assert!(ran.is_err());
}