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,