diff --git a/crates/directory/src/directory.rs b/crates/directory/src/directory.rs index 2a9297f..d1cc1e1 100644 --- a/crates/directory/src/directory.rs +++ b/crates/directory/src/directory.rs @@ -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#"Good Show + Oneg1 + "#; + let opml = + r#""#; + 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 { " = 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"); + } + } } diff --git a/crates/ipx/src/catalogue.rs b/crates/ipx/src/catalogue.rs index 43bdfe0..1bfe71c 100644 --- a/crates/ipx/src/catalogue.rs +++ b/crates/ipx/src/catalogue.rs @@ -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 = 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); + } } diff --git a/crates/ipx/src/download.rs b/crates/ipx/src/download.rs index ac24f0c..615e08e 100644 --- a/crates/ipx/src/download.rs +++ b/crates/ipx/src/download.rs @@ -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 Sign in" })) + .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); + } } diff --git a/crates/ipx/src/push.rs b/crates/ipx/src/push.rs index 7950b82..b9c0405 100644 --- a/crates/ipx/src/push.rs +++ b/crates/ipx/src/push.rs @@ -31,7 +31,7 @@ pub async fn run(ctx: Arc, mut stop: tokio::sync::watch::Receiver) { 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, mut stop: tokio::sync::watch::Receiver) { } } -/// 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 = 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 { +async fn send(ctx: &Ctx, signer: &mut Signer, d: &push_devices::Model, n: i64, apns: Option<&str>) -> Result { 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>>) { + use axum::http::{HeaderMap, StatusCode}; + let seen: Arc>> = 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, + headers: HeaderMap, + axum::Json(body): axum::Json| { + 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> = + 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 = 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); + } } diff --git a/scripts/coverage.sh b/scripts/coverage.sh index cd64b80..a6f20be 100644 --- a/scripts/coverage.sh +++ b/scripts/coverage.sh @@ -26,7 +26,8 @@ set -eu MEM=6g CPUS=0-5 -NEED_KB=$((9 * 1024 * 1024)) +# The container is capped at 6 GB, so 8 free leaves Tower 2 GB if the build fills its cap. +NEED_KB=$((8 * 1024 * 1024)) MAX_LOAD=12 IMAGE=192.168.1.130:5000/ipx-coverage:latest # The Docker daemon is Tower's, so its paths are Tower's: this tree is /mnt/user/vscode/ipx there,