From 03e38d1ca3a36193cf7964a0efa42d20e3214ccf Mon Sep 17 00:00:00 2001 From: ssmithx Date: Tue, 29 Sep 2026 20:12:16 +0000 Subject: [PATCH] test: regression tests for the paid-download fixes - mint_client: a stub mint shows swap() sends the full v2 keyset id when given a cashuB short id, and leaves complete v1/v2 ids unchanged. - fips::dial: the single-delivery decisions are now small functions (fips_answer_is_final, fips_retryable). Tests cover them and, against a silent local peer, check that a single-delivery request isn't resent after a timeout while an ordinary one still is. - content_server: an unreadable paid file returns Unavailable before the payment gate runs, and a readable one still returns 402. Also covers ensure_readable's grant/reopen behaviour. The podman grant is replaced by a refusal under cfg(test) so results don't depend on the host. Co-Authored-By: Claude Opus 5.5 --- core/archipelago/src/content_server.rs | 168 ++++++++++++++++++++- core/archipelago/src/fips/dial.rs | 116 +++++++++++++- core/archipelago/src/wallet/mint_client.rs | 132 ++++++++++++++++ 3 files changed, 404 insertions(+), 12 deletions(-) diff --git a/core/archipelago/src/content_server.rs b/core/archipelago/src/content_server.rs index db06dc57..72a981f5 100644 --- a/core/archipelago/src/content_server.rs +++ b/core/archipelago/src/content_server.rs @@ -597,14 +597,42 @@ pub async fn serve_content_preview(data_dir: &Path, id: &str) -> Result Result<()> { + ensure_readable_with(path, grant_read_access).await +} + +async fn ensure_readable_with(path: &Path, grant: F) -> Result<()> +where + F: FnOnce(PathBuf) -> Fut, + Fut: std::future::Future>, +{ match fs::File::open(path).await { Ok(_) => return Ok(()), Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => {} Err(e) => return Err(e).context("Failed to open content file"), } + grant(path.to_path_buf()).await?; + info!("Granted read access to shared content file {}", path.display()); + fs::File::open(path) + .await + .context("Content file still unreadable after chmod")?; + Ok(()) +} + +// Tests must not shell out to podman: whether it exists (and can chmod a +// file the test user owns) would decide the outcome. +#[cfg(not(test))] +use grant_read_via_podman as grant_read_access; + +#[cfg(test)] +async fn grant_read_access(_path: PathBuf) -> Result<()> { + anyhow::bail!("granting read access is disabled in tests") +} + +#[cfg_attr(test, allow(dead_code))] +async fn grant_read_via_podman(path: PathBuf) -> Result<()> { let out = tokio::process::Command::new("podman") .args(["unshare", "chmod", "a+r"]) - .arg(path) + .arg(&path) .output() .await .context("Failed to run podman unshare chmod")?; @@ -614,10 +642,6 @@ async fn ensure_readable(path: &Path) -> Result<()> { String::from_utf8_lossy(&out.stderr).trim() ); } - info!("Granted read access to shared content file {}", path.display()); - fs::File::open(path) - .await - .context("Content file still unreadable after chmod")?; Ok(()) } @@ -774,3 +798,137 @@ mod prune_missing_content_tests { assert_eq!(reloaded.items[0].id, "present-item"); } } + +#[cfg(test)] +mod unreadable_content_tests { + use super::*; + use std::os::unix::fs::PermissionsExt; + + /// Writes `bytes` to the FileBrowser area and makes it unreadable, the + /// way a 0640 upload owned by a container subuid looks to this service. + /// `None` when the test runs as root, where mode bits don't stop reads. + fn unreadable_file(data_dir: &Path, name: &str) -> Option { + let dir = data_dir.join("filebrowser").join("Music"); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join(name); + std::fs::write(&path, b"audio").unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o000)).unwrap(); + std::fs::File::open(&path).is_err().then_some(path) + } + + fn paid_item(filename: &str) -> ContentItem { + ContentItem { + id: "paid-item".to_string(), + filename: filename.to_string(), + mime_type: "audio/mpeg".to_string(), + size_bytes: 5, + description: String::new(), + access: AccessControl::Paid { + price_sats: 10, + accepted: vec!["ecash".to_string()], + }, + availability: Availability::AllPeers, + added_at: "2026-01-01T00:00:00Z".to_string(), + } + } + + /// Regression (2026-09-29): the seller redeemed the buyer's token and + /// only then failed to read the file, so the buyer paid for nothing. + /// An unreadable file must be refused before the payment gate runs, + /// which is why a token that would never verify still gets Unavailable + /// rather than PaymentRequired. + #[tokio::test] + async fn an_unreadable_paid_file_is_refused_before_any_payment_is_taken() { + let dir = tempfile::tempdir().unwrap(); + let data_dir = dir.path(); + let Some(_path) = unreadable_file(data_dir, "song.mp3") else { + return; // running as root + }; + save_catalog( + data_dir, + &ContentCatalog { + items: vec![paid_item("Music/song.mp3")], + }, + ) + .await + .unwrap(); + + let result = serve_content( + data_dir, + "paid-item", + Some("cashuBnot-a-real-token"), + None, + None, + None, + false, + ) + .await + .unwrap(); + assert!(matches!(result, ServeResult::Unavailable)); + // An unreadable file is not a missing one: keep the catalog entry. + assert_eq!(load_catalog(data_dir).await.unwrap().items.len(), 1); + } + + #[tokio::test] + async fn a_readable_paid_file_still_demands_payment() { + let dir = tempfile::tempdir().unwrap(); + let data_dir = dir.path(); + let music = data_dir.join("filebrowser").join("Music"); + std::fs::create_dir_all(&music).unwrap(); + std::fs::write(music.join("song.mp3"), b"audio").unwrap(); + save_catalog( + data_dir, + &ContentCatalog { + items: vec![paid_item("Music/song.mp3")], + }, + ) + .await + .unwrap(); + + let result = serve_content(data_dir, "paid-item", None, None, None, None, false) + .await + .unwrap(); + assert!(matches!(result, ServeResult::PaymentRequired(10))); + } + + #[tokio::test] + async fn ensure_readable_grants_access_once_then_reopens() { + let dir = tempfile::tempdir().unwrap(); + let Some(path) = unreadable_file(dir.path(), "a.mp3") else { + return; + }; + let calls = std::sync::atomic::AtomicUsize::new(0); + ensure_readable_with(&path, |p| { + calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + async move { + std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o644))?; + Ok(()) + } + }) + .await + .unwrap(); + assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn ensure_readable_leaves_a_readable_file_alone() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("ok.mp3"); + std::fs::write(&path, b"x").unwrap(); + ensure_readable_with(&path, |_| async { anyhow::bail!("must not grant") }) + .await + .unwrap(); + } + + #[tokio::test] + async fn ensure_readable_reports_a_failed_grant() { + let dir = tempfile::tempdir().unwrap(); + let Some(path) = unreadable_file(dir.path(), "b.mp3") else { + return; + }; + let err = ensure_readable_with(&path, |_| async { anyhow::bail!("no podman") }) + .await + .unwrap_err(); + assert!(err.to_string().contains("no podman")); + } +} diff --git a/core/archipelago/src/fips/dial.rs b/core/archipelago/src/fips/dial.rs index a0e4e9c5..df4a35ba 100644 --- a/core/archipelago/src/fips/dial.rs +++ b/core/archipelago/src/fips/dial.rs @@ -46,6 +46,25 @@ fn fips_should_fall_back(status: reqwest::StatusCode) -> bool { status == reqwest::StatusCode::NOT_FOUND || status.is_server_error() } +/// Is this FIPS answer the final one, or should the request go again over +/// Tor? A single-delivery request already reached the peer, so any answer +/// is final: a Tor replay would carry the same (possibly spent) payload. +fn fips_answer_is_final( + pref: crate::settings::transport::TransportPref, + single_delivery: bool, + status: reqwest::StatusCode, +) -> bool { + pref == crate::settings::transport::TransportPref::Fips + || single_delivery + || !fips_should_fall_back(status) +} + +/// May a failed FIPS attempt be sent again? Only a failed connect proves the +/// peer never saw it; a timeout can land after the request was delivered. +fn fips_retryable(single_delivery: bool, e: &reqwest::Error) -> bool { + e.is_connect() || (!single_delivery && e.is_timeout()) +} + /// DNS suffix appended to a peer's bech32 npub. pub const FIPS_DNS_SUFFIX: &str = "fips"; @@ -508,10 +527,7 @@ impl<'a> PeerRequest<'a> { if matches!(pref, TransportPref::Auto | TransportPref::Fips) { match self.try_fips_get().await? { Some(resp) => { - if pref == TransportPref::Fips - || self.single_delivery - || !fips_should_fall_back(resp.status()) - { + if fips_answer_is_final(pref, self.single_delivery, resp.status()) { telemetry::record_fips_ok(); self.spawn_record(crate::transport::TransportKind::Fips); return Ok((resp, crate::transport::TransportKind::Fips)); @@ -648,9 +664,7 @@ impl<'a> PeerRequest<'a> { rb = rb.header(*k, v); } let single = self.single_delivery; - let attempt = send_with_retry_if(rb, |e| { - e.is_connect() || (!single && e.is_timeout()) - }); + let attempt = send_with_retry_if(rb, |e| fips_retryable(single, e)); match tokio::time::timeout(budget, attempt).await { Ok(Ok(r)) => Ok(Some(r)), // Anything but a failed connect may have reached the peer. @@ -806,4 +820,92 @@ mod tests { let err = decode_response(0xAABB, &r, "x").unwrap_err(); assert!(err.to_string().contains("no AAAA")); } + + #[test] + fn a_single_delivery_answer_is_final_whatever_its_status() { + use crate::settings::transport::TransportPref; + use reqwest::StatusCode; + // Regression (2026-09-29): the seller redeemed a paid download's + // token, answered 404, and the Tor fallback replayed the spent token. + for status in [ + StatusCode::NOT_FOUND, + StatusCode::INTERNAL_SERVER_ERROR, + StatusCode::SERVICE_UNAVAILABLE, + StatusCode::OK, + ] { + assert!(fips_answer_is_final(TransportPref::Auto, true, status)); + } + // Everything else keeps the existing fallback rules. + assert!(!fips_answer_is_final( + TransportPref::Auto, + false, + StatusCode::NOT_FOUND + )); + assert!(!fips_answer_is_final( + TransportPref::Auto, + false, + StatusCode::BAD_GATEWAY + )); + assert!(fips_answer_is_final( + TransportPref::Auto, + false, + StatusCode::PAYMENT_REQUIRED + )); + assert!(fips_answer_is_final( + TransportPref::Fips, + false, + StatusCode::NOT_FOUND + )); + } + + /// A listener that accepts connections and never answers, counting them. + async fn silent_peer() -> (String, std::sync::Arc) { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let seen = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let counter = seen.clone(); + tokio::spawn(async move { + let mut held = Vec::new(); + while let Ok((stream, _)) = listener.accept().await { + counter.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + held.push(stream); // keep it open, never reply + } + }); + (format!("http://{addr}/content/x"), seen) + } + + #[tokio::test] + async fn a_single_delivery_request_is_not_resent_after_a_timeout() { + let (url, seen) = silent_peer().await; + let c = client_with_timeout(Duration::from_millis(300)); + let err = send_with_retry_if(c.get(&url), |e| fips_retryable(true, e)) + .await + .expect_err("peer never answers"); + assert!(err.is_timeout()); + assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn an_ordinary_request_is_still_retried_once_after_a_timeout() { + let (url, seen) = silent_peer().await; + let c = client_with_timeout(Duration::from_millis(300)); + let _ = send_with_retry_if(c.get(&url), |e| fips_retryable(false, e)).await; + assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 2); + } + + #[tokio::test] + async fn a_single_delivery_request_still_retries_a_refused_connect() { + // Nothing listening: the peer provably never saw the request. + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + drop(listener); + let c = client_with_timeout(Duration::from_millis(500)); + let err = send_with_retry_if(c.get(format!("http://{addr}/")), |e| { + fips_retryable(true, e) + }) + .await + .expect_err("nothing listening"); + assert!(err.is_connect()); + assert!(fips_retryable(true, &err)); + } } diff --git a/core/archipelago/src/wallet/mint_client.rs b/core/archipelago/src/wallet/mint_client.rs index b7979fc7..91b65055 100644 --- a/core/archipelago/src/wallet/mint_client.rs +++ b/core/archipelago/src/wallet/mint_client.rs @@ -869,4 +869,136 @@ mod tests { let client = MintClient::new("http://mint.example.com").unwrap(); assert_eq!(client.url(), "http://mint.example.com"); } + + /// A minimal mint on 127.0.0.1 answering `/v1/keys` and `/v1/keysets` + /// with one v2 keyset, and rejecting every `/v1/swap` as already spent. + /// Each swap request body is sent back on the returned channel. + async fn stub_mint( + full_id: &'static str, + ) -> (String, tokio::sync::mpsc::UnboundedReceiver) { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); + tokio::spawn(async move { + loop { + let Ok((mut stream, _)) = listener.accept().await else { + return; + }; + let mut buf = Vec::new(); + let mut chunk = [0u8; 4096]; + // Read headers, then as much body as Content-Length says. + let (head, body) = loop { + let n = stream.read(&mut chunk).await.unwrap_or(0); + if n == 0 { + break (String::new(), Vec::new()); + } + buf.extend_from_slice(&chunk[..n]); + let Some(pos) = buf.windows(4).position(|w| w == b"\r\n\r\n") else { + continue; + }; + let head = String::from_utf8_lossy(&buf[..pos]).to_string(); + let len = head + .lines() + .find_map(|l| { + let (k, v) = l.split_once(':')?; + k.eq_ignore_ascii_case("content-length") + .then(|| v.trim().parse::().ok())? + }) + .unwrap_or(0); + while buf.len() < pos + 4 + len { + let n = stream.read(&mut chunk).await.unwrap_or(0); + if n == 0 { + break; + } + buf.extend_from_slice(&chunk[..n]); + } + break (head, buf[pos + 4..].to_vec()); + }; + let request_line = head.lines().next().unwrap_or_default().to_string(); + let (status, reply) = if request_line.starts_with("GET /v1/keys ") { + let key = "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; + let keys: serde_json::Map = (0..16) + .map(|i| ((1u64 << i).to_string(), serde_json::json!(key))) + .collect(); + ( + "200 OK", + serde_json::json!({"keysets": [ + {"id": full_id, "unit": "sat", "active": true, "keys": keys} + ]}), + ) + } else if request_line.starts_with("GET /v1/keysets ") { + ( + "200 OK", + serde_json::json!({"keysets": [ + {"id": full_id, "unit": "sat", "active": true, "input_fee_ppk": 0} + ]}), + ) + } else if request_line.starts_with("POST /v1/swap ") { + let _ = tx.send(serde_json::from_slice(&body).unwrap_or_default()); + ( + "400 Bad Request", + serde_json::json!({"code": 11001, "detail": "Token Already Spent"}), + ) + } else { + ("404 Not Found", serde_json::json!({})) + }; + let reply = reply.to_string(); + let _ = stream + .write_all( + format!( + "HTTP/1.1 {status}\r\nContent-Type: application/json\r\n\ + Content-Length: {}\r\nConnection: close\r\n\r\n{reply}", + reply.len() + ) + .as_bytes(), + ) + .await; + } + }); + (format!("http://{addr}"), rx) + } + + fn proof_with_id(id: &str) -> Proof { + Proof { + amount: 8, + id: id.to_string(), + secret: "test-secret".to_string(), + c: "02".to_string() + &"11".repeat(32), + } + } + + /// Regression (2026-09-29): the paid-download seller called `swap` + /// directly with a cashuB token's short v2 keyset id and the mint + /// answered 422. `swap` itself must send the full id. + #[tokio::test] + async fn swap_expands_a_short_v2_keyset_id_before_calling_the_mint() { + const FULL: &str = "01fc0ec0e59cd6fa01b7a88f8cd77fce81fd1e64bca67d752e984992b7a3c3a821"; + let (url, mut swaps) = stub_mint(FULL).await; + let client = MintClient::new(&url).unwrap(); + + let Err(err) = client + .swap(&[proof_with_id("01fc0ec0e59cd6fa")], &[8]) + .await + else { + panic!("stub mint rejects every swap"); + }; + assert!(err.is::(), "unexpected error: {err:#}"); + + let body = swaps.recv().await.expect("swap reached the mint"); + assert_eq!(body["inputs"][0]["id"], FULL); + } + + #[tokio::test] + async fn swap_passes_complete_keyset_ids_through_unchanged() { + const FULL: &str = "01fc0ec0e59cd6fa01b7a88f8cd77fce81fd1e64bca67d752e984992b7a3c3a821"; + let (url, mut swaps) = stub_mint(FULL).await; + let client = MintClient::new(&url).unwrap(); + + for id in [FULL, "009a1f293253e41e"] { + let _ = client.swap(&[proof_with_id(id)], &[8]).await; + let body = swaps.recv().await.expect("swap reached the mint"); + assert_eq!(body["inputs"][0]["id"], id); + } + } }