Tests for push passes, downloads, OPML round trips and directory refresh

Each against a local stand-in server: Apple's push service answering per token, a feed host
serving a login page and a torrent, a directory refresh of feeds that cannot be read. push.rs
takes the APNs address as a parameter so the test can point it at the stand-in. coverage.sh asks
for 8 GB free, not 9: its container is capped at 6.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqkrVARreS9wxzH5o2rF7N
This commit is contained in:
2026-11-11 23:04:10 +00:00
parent 75d099474c
commit a55f08d4ff
5 changed files with 209 additions and 8 deletions

View File

@@ -217,4 +217,49 @@ mod tests {
dir.pages_list.lock().await.as_mut().unwrap().0 -= PAGES_LIST_FOR;
assert!(!Arc::ptr_eq(&first, &dir.pages_list().await.unwrap()));
}
#[tokio::test]
async fn a_refresh_lists_a_feed_and_not_one_that_could_not_be_read() {
use axum::routing::get;
let rss = r#"<?xml version="1.0"?><rss version="2.0"><channel><title>Good Show</title>
<item><title>One</title><guid>g1</guid><enclosure url="https://x/1.mp3" type="audio/mpeg" length="1"/></item>
</channel></rss>"#;
let opml =
r#"<?xml version="1.0"?><opml version="2.0"><head/><body><outline xmlUrl="https://x/f"/></body></opml>"#;
let app = axum::Router::new()
.route("/good.xml", get(move || async move { rss }))
.route("/list.opml", get(move || async move { opml }))
.route("/garbled.xml", get(|| async { "<rss><channel" }))
.route("/gone.xml", get(|| async { axum::http::StatusCode::NOT_FOUND }));
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let at = listener.local_addr().unwrap();
tokio::spawn(async move { axum::serve(listener, app).await });
let allowed = ipx_feed::guard::Allowed::parse(&["127.0.0.1".into()]).unwrap();
let dir = Directory::new(Store::scratch().await, vec![], allowed).unwrap();
let paths = ["/good.xml", "/list.opml", "/garbled.xml", "/gone.xml"];
let entries: Vec<crate::SnapshotEntry> = paths
.iter()
.map(|p| crate::SnapshotEntry { url: format!("http://{at}{p}"), subscribers: 1, category: None })
.collect();
assert_eq!(dir.store.snapshot("tower", &entries).await.unwrap().accepted, 4);
let due = dir.store.due(REFRESH_EVERY, 10).await.unwrap();
assert_eq!(due.len(), 4);
for d in &due {
dir.refresh(d).await.unwrap();
}
assert!(dir.store.due(REFRESH_EVERY, 10).await.unwrap().is_empty(), "each was checked, failed or not");
let page = |path: &str| {
let d = due.iter().find(|d| d.url.ends_with(path)).unwrap();
dir.store.feed(&d.id)
};
let good = page("/good.xml").await.unwrap().unwrap();
assert_eq!(good.listing.title.as_deref(), Some("Good Show"));
assert!(good.listing.podcast && good.failing.is_none() && good.items.len() == 1);
for bad in ["/list.opml", "/garbled.xml", "/gone.xml"] {
// Never read, so never listed: a page of nothing is no use to anyone browsing.
assert!(page(bad).await.unwrap().is_none(), "{bad} is not listed");
}
}
}

View File

@@ -731,4 +731,38 @@ mod tests {
assert_eq!(managed, ["listed"], "a group still in config is left alone");
assert_eq!(ctx.db.feed_summary("promoted").await.unwrap().entries, 1, "its entries survive");
}
#[tokio::test]
async fn an_exported_opml_imports_as_the_admins_subscriptions() {
let file = std::env::temp_dir().join(format!("ipx-opml-test-{}.opml", std::process::id()));
let from = test_ctx(config::Config::default()).await;
list(&from).await.unwrap();
let mut cfg = from.edit_cfg().await;
for (id, url) in [("one", "http://x/one.xml"), ("two", "http://x/two.xml")] {
cfg.feeds
.insert(id.into(), crate::test_util::add_one_cfg(&mut config::Config::default(), url, None, vec![]));
}
from.store_cfg(cfg).await.unwrap();
list(&from).await.unwrap();
export(&from, &file).await.unwrap();
let xml = std::fs::read_to_string(&file).unwrap();
assert!(xml.contains("http://x/one.xml") && xml.contains("http://x/two.xml"), "{xml}");
let to = test_ctx(config::Config::default()).await;
let none = import(&to, &file).await.unwrap_err();
assert!(format!("{none:#}").contains("no admin account"), "the CLI subscribes the admin: {none:#}");
let admin = to.db.create_user("admin", None, true).await.unwrap();
import(&to, &file).await.unwrap();
let urls: std::collections::BTreeSet<String> = to.cfg().feeds.values().map(|f| f.url.clone()).collect();
assert_eq!(urls, ["http://x/one.xml".to_owned(), "http://x/two.xml".to_owned()].into());
for id in to.cfg().feeds.keys() {
assert_eq!(to.db.subscribers_of(id).await.unwrap(), [(admin, false)], "{id} is the admin's");
}
import(&to, &file).await.unwrap();
assert_eq!(to.cfg().feeds.len(), 2, "importing again adds nothing");
std::fs::write(&file, "not opml").unwrap();
assert!(format!("{:#}", import(&to, &file).await.unwrap_err()).contains("is not OPML"));
let _ = std::fs::remove_file(&file);
}
}

View File

@@ -457,4 +457,51 @@ mod tests {
assert!(!blocked(&[" ".to_string()], &["anything"]), "a blank word blocks nothing");
assert!(!blocked(&[], &["anything"]));
}
#[tokio::test]
async fn a_download_is_sniffed_for_what_it_is_and_placed_without_clobbering() {
use axum::routing::get;
let app = axum::Router::new()
.route("/ep.mp3", get(|| async { "ID3 an episode" }))
.route("/wall.mp3", get(|| async { "\n <!DOCTYPE html><html>Sign in</html>" }))
.route("/show.torrent", get(|| async { "d8:announce35:udp://tracker.example:80/announcee" }))
.route("/gone.mp3", get(|| async { axum::http::StatusCode::NOT_FOUND }));
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let at = listener.local_addr().unwrap();
tokio::spawn(async move { axum::serve(listener, app).await });
let d = std::env::temp_dir().join(format!("ipx-download-test-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
let mut cfg = Config::default();
cfg.general.download_dir = d.clone();
let feed = crate::test_util::feed();
let client = reqwest::Client::new();
let (c, g, f) = (&client, &cfg, &feed);
let get = |path: &str| {
let url = format!("http://{at}{path}");
async move { download(c, g, f, &url, |_, _| {}).await }
};
let mut seen = vec![];
let ep =
download(&client, &cfg, &feed, &format!("http://{at}/ep.mp3"), |done, _| seen.push(done)).await.unwrap();
assert!(matches!(ep.kind, Kind::File));
assert_eq!((ep.name.as_str(), ep.bytes), ("ep.mp3", 14));
assert_eq!(seen.last(), Some(&14), "progress reaches the whole file");
let first = place(&ep, &d.join("Show")).await.unwrap();
assert_eq!(first, d.join("Show/ep.mp3"));
assert_eq!(std::fs::read(&first).unwrap(), b"ID3 an episode");
// The same name again, from another enclosure: beside the first, not over it.
let again = get("/ep.mp3").await.unwrap();
assert_eq!(place(&again, &d.join("Show")).await.unwrap(), d.join("Show/ep-2.mp3"));
assert!(first.exists());
let wall = get("/wall.mp3").await.err().unwrap();
assert!(format!("{wall:#}").contains("HTML page"), "{wall:#}");
assert!(!d.join(".ipx-incomplete/wall.mp3").exists(), "a login page is not kept");
assert!(matches!(get("/show.torrent").await.unwrap().kind, Kind::Torrent));
assert!(format!("{:#}", get("/gone.mp3").await.err().unwrap()).contains("404"));
let _ = std::fs::remove_dir_all(&d);
}
}

View File

@@ -31,7 +31,7 @@ pub async fn run(ctx: Arc<Ctx>, mut stop: tokio::sync::watch::Receiver<bool>) {
let mut signer = Signer::default();
loop {
if ctx.cfg().push.is_set() {
let pass = pass(&ctx, &mut signer).instrument(tracing::info_span!("push"));
let pass = pass(&ctx, &mut signer, None).instrument(tracing::info_span!("push"));
tokio::select! {
_ = stop.changed() => return,
done = pass => if let Err(e) = done {
@@ -46,8 +46,8 @@ pub async fn run(ctx: Arc<Ctx>, mut stop: tokio::sync::watch::Receiver<bool>) {
}
}
/// Sends each phone its person's count, if it changed.
async fn pass(ctx: &Ctx, signer: &mut Signer) -> Result<()> {
/// Sends each phone its person's count, if it changed. `apns` stands in for Apple's address in tests.
async fn pass(ctx: &Ctx, signer: &mut Signer, apns: Option<&str>) -> Result<()> {
let devices = ctx.db.push_devices().instrument(crate::db::step("push_devices")).await?;
let mut counts: HashMap<i64, i64> = HashMap::new();
for d in devices {
@@ -66,7 +66,7 @@ async fn pass(ctx: &Ctx, signer: &mut Signer) -> Result<()> {
if d.badge == Some(n) {
continue;
}
match send(ctx, signer, &d, n).await? {
match send(ctx, signer, &d, n, apns).await? {
Sent::Yes => ctx.db.set_push_badge(&d.token, n).await?,
Sent::Gone => {
tracing::info!(user = d.user_id, "push: a phone's app is gone, so its token is forgotten");
@@ -87,12 +87,12 @@ enum Sent {
}
#[tracing::instrument(name = "push.send", skip_all, fields(user = d.user_id, badge = n, status))]
async fn send(ctx: &Ctx, signer: &mut Signer, d: &push_devices::Model, n: i64) -> Result<Sent> {
async fn send(ctx: &Ctx, signer: &mut Signer, d: &push_devices::Model, n: i64, apns: Option<&str>) -> Result<Sent> {
let cfg = ctx.cfg();
let host = if d.sandbox { "api.sandbox.push.apple.com" } else { "api.push.apple.com" };
let host = if d.sandbox { "https://api.sandbox.push.apple.com" } else { "https://api.push.apple.com" };
let r = ctx
.client
.post(format!("https://{host}/3/device/{}", d.token))
.post(format!("{}/3/device/{}", apns.unwrap_or(host), d.token))
.bearer_auth(signer.token(&cfg.push)?)
.header("apns-topic", &cfg.push.topic)
// A badge alone is still an alert to Apple: background pushes cannot set one.
@@ -245,4 +245,78 @@ MnDzi5bfjGbabd5hc7sDHiTremhTWJzPs1TdGgtqd9RDURqCHr8jfP+B
assert!(!valid_token(&format!("{}/../x", "a".repeat(64))));
assert!(!valid_token(&"g".repeat(64)));
}
/// A stand-in for Apple's push service: each device token's first two characters say how it
/// answers. Every request is recorded as (token, badge, signed).
async fn apns() -> (String, Arc<std::sync::Mutex<Vec<(String, i64, bool)>>>) {
use axum::http::{HeaderMap, StatusCode};
let seen: Arc<std::sync::Mutex<Vec<(String, i64, bool)>>> = Default::default();
let log = seen.clone();
let app = axum::Router::new().route(
"/3/device/{token}",
axum::routing::post(
move |axum::extract::Path(token): axum::extract::Path<String>,
headers: HeaderMap,
axum::Json(body): axum::Json<serde_json::Value>| {
let signed = headers.get("authorization").is_some_and(|v| v.as_bytes().starts_with(b"Bearer e"))
&& headers.get("apns-topic").is_some_and(|v| v == "net.sdf1.ipx");
log.lock().unwrap().push((token.clone(), body["aps"]["badge"].as_i64().unwrap(), signed));
let (status, reason) = match &token[..2] {
"aa" => (StatusCode::OK, ""),
"bb" => (StatusCode::GONE, "Unregistered"),
"cc" => (StatusCode::BAD_REQUEST, "BadDeviceToken"),
"dd" => (StatusCode::FORBIDDEN, "ExpiredProviderToken"),
_ => (StatusCode::INTERNAL_SERVER_ERROR, "InternalServerError"),
};
async move { (status, axum::Json(serde_json::json!({ "reason": reason }))) }
},
),
);
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let at = listener.local_addr().unwrap();
tokio::spawn(async move { axum::serve(listener, app).await });
(format!("http://{at}"), seen)
}
#[tokio::test]
async fn a_pass_sends_changed_counts_and_forgets_phones_apple_says_are_gone() {
let key = std::env::temp_dir().join(format!("ipx-push-test-{}.p8", std::process::id()));
std::fs::write(&key, KEY).unwrap();
let cfg = crate::config::Config {
push: crate::config::Push {
key_file: key.clone(),
key_id: "ABC123DEFG".into(),
team_id: "8743L8D729".into(),
..Default::default()
},
..Default::default()
};
let ctx = crate::test_util::test_ctx(cfg).await;
let me = ctx.db.create_user("me", None, false).await.unwrap();
for t in ["aa", "bb", "cc", "dd", "ee"] {
ctx.db.add_push_device(me, &t.repeat(32), false).await.unwrap();
}
let (at, seen) = apns().await;
let mut signer = Signer::default();
pass(&ctx, &mut signer, Some(&at)).await.unwrap();
let first = seen.lock().unwrap().clone();
assert_eq!(first.len(), 5, "every phone gets its first count");
assert!(first.iter().all(|(_, badge, signed)| *badge == 0 && *signed), "{first:?}");
let left: std::collections::BTreeMap<String, Option<i64>> =
ctx.db.push_devices().await.unwrap().into_iter().map(|d| (d.token[..2].to_owned(), d.badge)).collect();
assert_eq!(
left,
[("aa".into(), Some(0)), ("dd".into(), None), ("ee".into(), None)].into(),
"a 410 or a bad token forgets the phone; a refusal keeps the old badge to try again"
);
seen.lock().unwrap().clear();
pass(&ctx, &mut signer, Some(&at)).await.unwrap();
let mut again: Vec<String> = seen.lock().unwrap().iter().map(|(t, ..)| t[..2].to_owned()).collect();
again.sort();
assert_eq!(again, ["dd", "ee"], "a count Apple took is not sent again until it changes");
let _ = std::fs::remove_file(key);
}
}