241 lines
9.3 KiB
Rust
241 lines
9.3 KiB
Rust
//! The self-scan path against a mock nntmux, over its Newznab API.
|
|
//!
|
|
//! Boots the real `UsenetPlugin` in the SDK router with `nntmux_url` +
|
|
//! `nntmux_api_key` in its `config.json` (the config-page shape) and a wiremock
|
|
//! nntmux answering `/api/v1/api?t=…` and `/getnzb`, then drives `/query`,
|
|
//! `/blob` and `/health` exactly as the gateway does.
|
|
|
|
use meta_feeder_sdk::hash::compute_nzb_posting_cid;
|
|
use meta_feeder_sdk::serve::{configure_plugins, router};
|
|
use meta_feeder_sdk::{GatewayQuery, QueryRequest, QueryResponse};
|
|
use usenet_feeder::usenet::UsenetPlugin;
|
|
use wiremock::matchers::{method, path, query_param};
|
|
use wiremock::{Mock, MockServer, ResponseTemplate};
|
|
|
|
const KEY: &str = "metamesh-key";
|
|
|
|
const CAPS: &str = r#"<?xml version="1.0" encoding="UTF-8"?><caps><limits max="100" default="100"/></caps>"#;
|
|
|
|
fn item(guid: &str, title: &str, cat: u32) -> String {
|
|
format!(
|
|
r#"<item><title>{title}</title><guid isPermaLink="true">http://localhost/details/{guid}</guid>
|
|
<newznab:attr name="category" value="{}"/><newznab:attr name="category" value="{cat}"/>
|
|
<newznab:attr name="size" value="734003200"/>
|
|
<newznab:attr name="usenetdate" value="Fri, 02 Oct 2026 08:30:00 +0000"/></item>"#,
|
|
cat / 1000 * 1000
|
|
)
|
|
}
|
|
|
|
fn rss(items: &[String]) -> String {
|
|
format!(
|
|
r#"<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:newznab="http://www.newznab.com/DTD/2010/feeds/attributes/"><channel><title>NNTmux</title>{}</channel></rss>"#,
|
|
items.join("")
|
|
)
|
|
}
|
|
|
|
const NZB: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
|
|
<nzb xmlns="http://www.newzbin.com/DTD/2003/nzb">
|
|
<file poster="[email protected]" date="1" subject="Ghost [1/1] "a.mkv" yEnc">
|
|
<groups><group>alt.binaries.teevee</group></groups>
|
|
<segments>
|
|
<segment bytes="100" number="1">[email protected]</segment>
|
|
<segment bytes="100" number="2">[email protected]</segment>
|
|
</segments>
|
|
</file>
|
|
</nzb>"#;
|
|
|
|
fn posting() -> String {
|
|
compute_nzb_posting_cid(&["[email protected]".to_string(), "[email protected]".to_string()])
|
|
}
|
|
|
|
async fn boot(nntmux: &MockServer) -> (String, tempfile::TempDir) {
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|
let cache = dir.path().join("gateway").join("usenet");
|
|
std::fs::create_dir_all(&cache).expect("mkdir");
|
|
std::fs::write(
|
|
cache.join("config.json"),
|
|
serde_json::json!({ "nntmux_url": nntmux.uri(), "nntmux_api_key": KEY }).to_string(),
|
|
)
|
|
.expect("write config");
|
|
|
|
let configured =
|
|
configure_plugins(vec![Box::new(UsenetPlugin::new())], dir.path()).expect("configure");
|
|
let app = router(configured, "test".to_string(), dir.path());
|
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.expect("bind");
|
|
let addr = listener.local_addr().expect("addr");
|
|
tokio::spawn(async move {
|
|
axum::serve(listener, app).await.expect("serve");
|
|
});
|
|
(format!("http://{addr}"), dir)
|
|
}
|
|
|
|
async fn caps_ok(server: &MockServer) {
|
|
Mock::given(method("GET"))
|
|
.and(path("/api/v1/api"))
|
|
.and(query_param("t", "caps"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_string(CAPS))
|
|
.mount(server)
|
|
.await;
|
|
}
|
|
|
|
async fn search_answers(server: &MockServer, body: String) {
|
|
Mock::given(method("GET"))
|
|
.and(path("/api/v1/api"))
|
|
.and(query_param("t", "search"))
|
|
.and(query_param("apikey", KEY))
|
|
.respond_with(ResponseTemplate::new(200).set_body_string(body))
|
|
.mount(server)
|
|
.await;
|
|
}
|
|
|
|
async fn query(base: &str, text: &str) -> reqwest::Response {
|
|
reqwest::Client::new()
|
|
.post(format!("{base}/query"))
|
|
.json(&QueryRequest {
|
|
upstream_id: "usenet".into(),
|
|
query: GatewayQuery::from_free_text(text),
|
|
max_results: 10,
|
|
})
|
|
.send()
|
|
.await
|
|
.expect("POST /query")
|
|
}
|
|
|
|
async fn records(base: &str, text: &str) -> Vec<meta_feeder_sdk::DiscoveryRecord> {
|
|
let r = query(base, text).await;
|
|
assert!(r.status().is_success(), "query status {}", r.status());
|
|
r.json::<QueryResponse>().await.expect("query json").records
|
|
}
|
|
|
|
/// Search hits carry their portable cid, minted from an `.nzb` downloaded ONCE:
|
|
/// a repeat search and the core's `/blob` fetch are served from the cache.
|
|
#[tokio::test]
|
|
async fn hits_carry_the_posting_cid_and_each_nzb_is_downloaded_once() {
|
|
let server = MockServer::start().await;
|
|
caps_ok(&server).await;
|
|
search_answers(
|
|
&server,
|
|
rss(&[
|
|
item("abc111", "Celebrity.Ghost.Stories.S05E08.720p.HDTV.x264-DHD", 5040),
|
|
item("abc222", "Ghost.Not.Collated.Yet", 2040),
|
|
]),
|
|
)
|
|
.await;
|
|
Mock::given(method("GET"))
|
|
.and(path("/getnzb"))
|
|
.and(query_param("id", "abc111.nzb"))
|
|
.and(query_param("r", KEY))
|
|
.respond_with(ResponseTemplate::new(200).set_body_string(NZB))
|
|
.expect(1)
|
|
.mount(&server)
|
|
.await;
|
|
Mock::given(method("GET"))
|
|
.and(path("/getnzb"))
|
|
.and(query_param("id", "abc222.nzb"))
|
|
.respond_with(ResponseTemplate::new(404))
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let (base, _dir) = boot(&server).await;
|
|
let recs = records(&base, "ghost").await;
|
|
assert_eq!(recs.len(), 1, "a release without a usable .nzb is dropped, got {recs:?}");
|
|
let r = &recs[0];
|
|
assert_eq!(r.record_id, "abc111");
|
|
assert_eq!(r.fields.get(&format!("cids/{}", posting())).map(String::as_str), Some("true"));
|
|
assert_eq!(r.fields["manifest_url"], "/blob/usenet/abc111.nzb");
|
|
assert_eq!(r.fields["contentKind"], "episode");
|
|
assert_eq!(r.fields["fileType"], "video");
|
|
assert_eq!(r.fields["sizeByte"], "734003200");
|
|
assert_eq!(r.fields["publishedAt"], "1790929800");
|
|
|
|
// Repeat search: no second download (the `.expect(1)` above is verified on drop).
|
|
assert_eq!(records(&base, "ghost").await.len(), 1);
|
|
|
|
// The core seeds the manifest from /blob — served from the cache.
|
|
let blob = reqwest::get(format!("{base}/blob/usenet/abc111.nzb")).await.expect("blob");
|
|
assert!(blob.status().is_success());
|
|
assert_eq!(blob.text().await.unwrap(), NZB);
|
|
}
|
|
|
|
/// nntmux's daily download cap (`<error code="501">`, answered as HTTP 200):
|
|
/// stop asking for the rest of this query instead of burning a request per hit.
|
|
#[tokio::test]
|
|
async fn a_spent_download_cap_stops_the_query_early() {
|
|
let server = MockServer::start().await;
|
|
caps_ok(&server).await;
|
|
search_answers(&server, rss(&[item("aaa1", "One", 2040), item("aaa2", "Two", 2040)])).await;
|
|
Mock::given(method("GET"))
|
|
.and(path("/getnzb"))
|
|
.respond_with(
|
|
ResponseTemplate::new(200)
|
|
.set_body_string(r#"<error code="501" description="Download limit reached"/>"#),
|
|
)
|
|
.expect(1)
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let (base, _dir) = boot(&server).await;
|
|
assert!(records(&base, "one").await.is_empty());
|
|
}
|
|
|
|
/// A refused key (`<error code="100">`) fails the query and shows on /health.
|
|
#[tokio::test]
|
|
async fn a_refused_key_degrades_health() {
|
|
let server = MockServer::start().await;
|
|
caps_ok(&server).await;
|
|
search_answers(
|
|
&server,
|
|
r#"<error code="100" description="Incorrect user credentials (wrong API key)"/>"#.into(),
|
|
)
|
|
.await;
|
|
|
|
let (base, _dir) = boot(&server).await;
|
|
assert!(!query(&base, "ghost").await.status().is_success());
|
|
|
|
let health: serde_json::Value = reqwest::get(format!("{base}/health")).await.unwrap().json().await.unwrap();
|
|
let usenet = health["plugins"].as_array().unwrap().iter().find(|p| p["id"] == "usenet").unwrap();
|
|
assert_eq!(usenet["health"]["state"], "degraded", "{usenet}");
|
|
assert!(usenet["health"]["reason"].as_str().unwrap().contains("API key"), "{usenet}");
|
|
}
|
|
|
|
/// A blank query never reaches nntmux (the gateway's routing gate owns structure).
|
|
#[tokio::test]
|
|
async fn a_blank_query_is_answered_locally() {
|
|
let server = MockServer::start().await;
|
|
caps_ok(&server).await;
|
|
Mock::given(method("GET"))
|
|
.and(path("/api/v1/api"))
|
|
.and(query_param("t", "search"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_string(rss(&[])))
|
|
.expect(0)
|
|
.mount(&server)
|
|
.await;
|
|
|
|
let (base, _dir) = boot(&server).await;
|
|
assert!(records(&base, " ").await.is_empty());
|
|
}
|
|
|
|
/// A configured, reachable nntmux reports healthy.
|
|
#[tokio::test]
|
|
async fn a_reachable_nntmux_is_healthy() {
|
|
let server = MockServer::start().await;
|
|
caps_ok(&server).await;
|
|
let (base, _dir) = boot(&server).await;
|
|
// Let the boot-time t=caps probe land.
|
|
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
|
|
let health: serde_json::Value = reqwest::get(format!("{base}/health")).await.unwrap().json().await.unwrap();
|
|
let usenet = health["plugins"].as_array().unwrap().iter().find(|p| p["id"] == "usenet").unwrap();
|
|
assert_eq!(usenet["health"]["state"], "ok", "{usenet}");
|
|
}
|
|
|
|
/// A path-traversal id never reaches the cache or nntmux.
|
|
#[tokio::test]
|
|
async fn a_hostile_blob_id_is_refused() {
|
|
let server = MockServer::start().await;
|
|
caps_ok(&server).await;
|
|
Mock::given(method("GET")).and(path("/getnzb")).respond_with(ResponseTemplate::new(200)).expect(0).mount(&server).await;
|
|
let (base, _dir) = boot(&server).await;
|
|
let r = reqwest::get(format!("{base}/blob/usenet/..%2F..%2Fconfig.json")).await.unwrap();
|
|
assert!(!r.status().is_success());
|
|
}
|