@@ -12,29 +12,45 @@
|
||||
//! own `record_matches` against the query that produced them;
|
||||
//! - the upstream sees OpenSubtitles' canonical query form (sorted keys) and the
|
||||
//! API key header;
|
||||
//! - a query this feeder cannot answer never reaches the upstream.
|
||||
//! - a query this feeder cannot answer never reaches the upstream;
|
||||
//! - redeeming a `provider-file` cid through `/compute` downloads the file on
|
||||
//! the API key alone, or as an account (re-logging in once on a stale token),
|
||||
//! returns the download-only facts as record fields, and honours the quota.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use base64::Engine as _;
|
||||
use meta_feeder_sdk::hash::compute_ipfs_cid;
|
||||
use meta_feeder_sdk::query_eval::record_matches;
|
||||
use meta_feeder_sdk::{
|
||||
configure_plugins, router, ComputeRequest, GatewayQuery, ManifestResponse, QueryRequest,
|
||||
QueryResponse,
|
||||
configure_plugins, router, ComputeRequest, ComputeResponse, GatewayQuery, HashKindDto,
|
||||
ManifestResponse, QueryRequest, QueryResponse, RedeemsResponse,
|
||||
};
|
||||
use opensubtitles_feeder::locator::decode_provider_file_cid;
|
||||
use opensubtitles_feeder::locator::{decode_provider_file_cid, file_cid, provider_file_cid};
|
||||
use opensubtitles_feeder::plugin::OpenSubtitlesPlugin;
|
||||
use wiremock::matchers::{header, method, path, query_param};
|
||||
use wiremock::matchers::{body_partial_json, header, method, path, query_param};
|
||||
use wiremock::{Match, Mock, MockServer, Request, ResponseTemplate};
|
||||
|
||||
const KEY: &str = "test-key";
|
||||
const USER: &str = "viewer";
|
||||
const PASS: &str = "hunter2";
|
||||
const FILE_ID: u64 = 7061834;
|
||||
const SRT: &str = "1\n00:00:01,000 --> 00:00:02,000\nWake up, Neo.\n";
|
||||
|
||||
const KEYWORD: &str = include_str!("fixtures/search_keyword_matrix.json");
|
||||
const MOVIE_FR: &str = include_str!("fixtures/search_imdb_133093_fr.json");
|
||||
const EPISODE: &str = include_str!("fixtures/search_episode_1399_s01e01.json");
|
||||
|
||||
async fn spawn_feeder(api_key: &str, upstream: &MockServer) -> (String, tempfile::TempDir) {
|
||||
spawn_plugin(OpenSubtitlesPlugin::with_api_base(api_key, upstream.uri())).await
|
||||
}
|
||||
|
||||
async fn spawn_feeder_with_login(upstream: &MockServer) -> (String, tempfile::TempDir) {
|
||||
spawn_plugin(OpenSubtitlesPlugin::with_api_base(KEY, upstream.uri()).with_login(USER, PASS)).await
|
||||
}
|
||||
|
||||
async fn spawn_plugin(plugin: OpenSubtitlesPlugin) -> (String, tempfile::TempDir) {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let plugin = OpenSubtitlesPlugin::with_api_base(api_key, upstream.uri());
|
||||
let plugins = configure_plugins(vec![Box::new(plugin)], dir.path()).expect("configure plugins");
|
||||
let app = router(plugins, "opensubtitles-feeder-test".to_string(), dir.path());
|
||||
|
||||
@@ -129,6 +145,14 @@ async fn manifest_advertises_subtitle_only() {
|
||||
assert_eq!(m.plugins[0].id, "opensubtitles");
|
||||
assert_eq!(m.plugins[0].served_file_types, vec!["subtitle".to_string()]);
|
||||
assert!(m.plugins[0].served_content_kinds.is_empty());
|
||||
assert_eq!(m.plugins[0].package.as_deref(), Some("meta-feeder-opensubtitles"));
|
||||
// An API key alone downloads (anonymous quota), so the source is claimed.
|
||||
assert_eq!(
|
||||
serde_json::to_value(&m.plugins[0].redeems).unwrap(),
|
||||
serde_json::json!([{
|
||||
"codec": "provider-file", "field": "file", "hosts": [], "sources": ["opensubtitles"]
|
||||
}])
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -293,19 +317,239 @@ async fn missing_api_key_degrades_and_answers_nothing() {
|
||||
.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn compute_is_refused() {
|
||||
let upstream = MockServer::start().await;
|
||||
let (base, _dir) = spawn_feeder(KEY, &upstream).await;
|
||||
// --- redeeming a provider-file locator ---------------------------------------
|
||||
|
||||
let resp = reqwest::Client::new()
|
||||
async fn post_compute(base: &str, record_id: &str) -> reqwest::Response {
|
||||
reqwest::Client::new()
|
||||
.post(format!("{base}/compute"))
|
||||
.json(&ComputeRequest {
|
||||
upstream_id: "opensubtitles".into(),
|
||||
record_id: "opensubtitles:file:12658754".into(),
|
||||
record_id: record_id.into(),
|
||||
})
|
||||
.send()
|
||||
.await
|
||||
.expect("POST /compute")
|
||||
}
|
||||
|
||||
async fn mount_login(upstream: &MockServer, token: &str, times: Option<u64>) {
|
||||
let mock = Mock::given(method("POST"))
|
||||
.and(path("/login"))
|
||||
.and(header("Api-Key", KEY))
|
||||
.and(body_partial_json(serde_json::json!({ "username": USER, "password": PASS })))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "token": token })));
|
||||
match times {
|
||||
Some(n) => mock.up_to_n_times(n).expect(n).mount(upstream).await,
|
||||
None => mock.mount(upstream).await,
|
||||
}
|
||||
}
|
||||
|
||||
fn download_ok(upstream: &MockServer, remaining: u64) -> ResponseTemplate {
|
||||
ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"link": format!("{}/files/{FILE_ID}.srt", upstream.uri()),
|
||||
"file_name": "The.Matrix.1999.en.srt",
|
||||
"requests": 1,
|
||||
"remaining": remaining,
|
||||
"reset_time_utc": "2099-01-01T00:00:00.000Z"
|
||||
}))
|
||||
}
|
||||
|
||||
async fn mount_file(upstream: &MockServer, body: &str) {
|
||||
Mock::given(method("GET"))
|
||||
.and(path(format!("/files/{FILE_ID}.srt")))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_string(body))
|
||||
.mount(upstream)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_provider_file_cid_redeems_to_the_subtitle_bytes() {
|
||||
let upstream = MockServer::start().await;
|
||||
mount_login(&upstream, "t1", None).await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/download"))
|
||||
.and(header("Authorization", "Bearer t1"))
|
||||
.and(body_partial_json(serde_json::json!({ "file_id": FILE_ID })))
|
||||
.respond_with(download_ok(&upstream, 99))
|
||||
.expect(2)
|
||||
.mount(&upstream)
|
||||
.await;
|
||||
mount_file(&upstream, SRT).await;
|
||||
let (base, _dir) = spawn_feeder_with_login(&upstream).await;
|
||||
|
||||
let resp = post_compute(&base, &file_cid(FILE_ID).unwrap()).await;
|
||||
assert_eq!(resp.status(), 200, "{:?}", resp.text().await);
|
||||
let body: ComputeResponse = resp.json().await.unwrap();
|
||||
assert_eq!(body.outcomes.len(), 1);
|
||||
let o = &body.outcomes[0];
|
||||
assert_eq!(o.hash_kind, HashKindDto::Sha2_256);
|
||||
assert_eq!(o.file_extension.as_deref(), Some("srt"));
|
||||
let rec = o.record.as_ref().expect("download-only facts for the locator record");
|
||||
assert_eq!(rec.fields.get("extension").map(String::as_str), Some("srt"));
|
||||
assert_eq!(rec.fields.get("fileName").map(String::as_str), Some("The.Matrix.1999.en.srt"));
|
||||
assert_eq!(rec.fields.get("sizeByte"), Some(&SRT.len().to_string()));
|
||||
assert!(
|
||||
!rec.fields.keys().any(|k| k.starts_with("cids/")),
|
||||
"a redeem never re-addresses the locator record"
|
||||
);
|
||||
let bytes = base64::engine::general_purpose::STANDARD
|
||||
.decode(o.bytes_b64.as_deref().unwrap())
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 422);
|
||||
assert_eq!(bytes, SRT.as_bytes());
|
||||
assert_eq!(o.hash, compute_ipfs_cid(&bytes));
|
||||
|
||||
// The feeder's own record id form redeems the same file.
|
||||
assert_eq!(post_compute(&base, &format!("opensubtitles:file:{FILE_ID}")).await.status(), 200);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_stale_token_is_refreshed_once() {
|
||||
let upstream = MockServer::start().await;
|
||||
// First login hands out t1, the second t2.
|
||||
mount_login(&upstream, "t1", Some(1)).await;
|
||||
mount_login(&upstream, "t2", Some(1)).await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/download"))
|
||||
.and(header("Authorization", "Bearer t1"))
|
||||
.respond_with(ResponseTemplate::new(401))
|
||||
.expect(1)
|
||||
.mount(&upstream)
|
||||
.await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/download"))
|
||||
.and(header("Authorization", "Bearer t2"))
|
||||
.respond_with(download_ok(&upstream, 99))
|
||||
.expect(1)
|
||||
.mount(&upstream)
|
||||
.await;
|
||||
mount_file(&upstream, SRT).await;
|
||||
let (base, _dir) = spawn_feeder_with_login(&upstream).await;
|
||||
|
||||
assert_eq!(post_compute(&base, &file_cid(FILE_ID).unwrap()).await.status(), 200);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_spent_quota_is_rate_limited_and_stops_calling_download() {
|
||||
let upstream = MockServer::start().await;
|
||||
mount_login(&upstream, "t1", None).await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/download"))
|
||||
.respond_with(ResponseTemplate::new(406).set_body_json(serde_json::json!({
|
||||
"message": "You have downloaded your allowed 20 subtitles for 24h.",
|
||||
"reset_time": "23 hours and 59 minutes",
|
||||
"reset_time_utc": "2099-01-01T00:00:00.000Z"
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&upstream)
|
||||
.await;
|
||||
let (base, _dir) = spawn_feeder_with_login(&upstream).await;
|
||||
|
||||
let cid = file_cid(FILE_ID).unwrap();
|
||||
assert_eq!(post_compute(&base, &cid).await.status(), 429);
|
||||
// Blocked locally until the reset: the upstream is not asked again.
|
||||
assert_eq!(post_compute(&base, &cid).await.status(), 429);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_last_allowed_download_blocks_the_next() {
|
||||
let upstream = MockServer::start().await;
|
||||
mount_login(&upstream, "t1", None).await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/download"))
|
||||
.respond_with(download_ok(&upstream, 0))
|
||||
.expect(1)
|
||||
.mount(&upstream)
|
||||
.await;
|
||||
mount_file(&upstream, SRT).await;
|
||||
let (base, _dir) = spawn_feeder_with_login(&upstream).await;
|
||||
|
||||
let cid = file_cid(FILE_ID).unwrap();
|
||||
assert_eq!(post_compute(&base, &cid).await.status(), 200);
|
||||
assert_eq!(post_compute(&base, &cid).await.status(), 429);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn not_our_locator_is_not_found_and_spends_nothing() {
|
||||
let upstream = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.respond_with(ResponseTemplate::new(200))
|
||||
.expect(0)
|
||||
.mount(&upstream)
|
||||
.await;
|
||||
let (base, _dir) = spawn_feeder_with_login(&upstream).await;
|
||||
|
||||
let other = provider_file_cid("otherprovider", "file:1").unwrap();
|
||||
assert_eq!(post_compute(&base, &other).await.status(), 404);
|
||||
// A card locator shares the framing but not the codec.
|
||||
assert_eq!(post_compute(&base, "bagdsaaanar2g2zdcor3duojvgq3ts").await.status(), 404);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_empty_file_is_refused() {
|
||||
let upstream = MockServer::start().await;
|
||||
mount_login(&upstream, "t1", None).await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/download"))
|
||||
.respond_with(download_ok(&upstream, 99))
|
||||
.mount(&upstream)
|
||||
.await;
|
||||
mount_file(&upstream, "oops").await;
|
||||
let (base, _dir) = spawn_feeder_with_login(&upstream).await;
|
||||
|
||||
assert_eq!(post_compute(&base, &file_cid(FILE_ID).unwrap()).await.status(), 422);
|
||||
}
|
||||
|
||||
/// Anonymous mode sends the API key and no Bearer token.
|
||||
struct NoAuthorization;
|
||||
|
||||
impl Match for NoAuthorization {
|
||||
fn matches(&self, request: &Request) -> bool {
|
||||
!request.headers.contains_key("authorization")
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_api_key_alone_downloads_without_logging_in() {
|
||||
let upstream = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/login"))
|
||||
.respond_with(ResponseTemplate::new(200))
|
||||
.expect(0)
|
||||
.mount(&upstream)
|
||||
.await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/download"))
|
||||
.and(header("Api-Key", KEY))
|
||||
.and(NoAuthorization)
|
||||
.and(body_partial_json(serde_json::json!({ "file_id": FILE_ID })))
|
||||
.respond_with(download_ok(&upstream, 4))
|
||||
.expect(1)
|
||||
.mount(&upstream)
|
||||
.await;
|
||||
mount_file(&upstream, SRT).await;
|
||||
let (base, _dir) = spawn_feeder(KEY, &upstream).await;
|
||||
|
||||
let resp = post_compute(&base, &file_cid(FILE_ID).unwrap()).await;
|
||||
assert_eq!(resp.status(), 200, "{:?}", resp.text().await);
|
||||
let body: ComputeResponse = resp.json().await.unwrap();
|
||||
assert_eq!(body.outcomes.len(), 1);
|
||||
assert_eq!(body.outcomes[0].hash, compute_ipfs_cid(SRT.as_bytes()));
|
||||
|
||||
let r: RedeemsResponse = reqwest::get(format!("{base}/redeems")).await.unwrap().json().await.unwrap();
|
||||
assert_eq!(r.redeems["opensubtitles"].len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn with_a_login_provider_file_is_claimed() {
|
||||
let upstream = MockServer::start().await;
|
||||
let (base, _dir) = spawn_feeder_with_login(&upstream).await;
|
||||
|
||||
let r: RedeemsResponse = reqwest::get(format!("{base}/redeems")).await.unwrap().json().await.unwrap();
|
||||
assert_eq!(
|
||||
serde_json::to_value(&r.redeems["opensubtitles"]).unwrap(),
|
||||
serde_json::json!([{
|
||||
"codec": "provider-file", "field": "file", "hosts": [], "sources": ["opensubtitles"]
|
||||
}])
|
||||
);
|
||||
let m: ManifestResponse = reqwest::get(format!("{base}/manifest")).await.unwrap().json().await.unwrap();
|
||||
assert_eq!(m.plugins[0].redeems, r.redeems["opensubtitles"]);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user