feat: implement RSS subscriptions, downloader routing and local media checks
This commit is contained in:
Vendored
+28
@@ -0,0 +1,28 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0">
|
||||
<channel>
|
||||
<title>Local test releases</title>
|
||||
<link>https://example.invalid/</link>
|
||||
<description>Synthetic fixtures; no real media</description>
|
||||
<item>
|
||||
<guid>release-3</guid>
|
||||
<title>[Group] Example - 03 [1080p]</title>
|
||||
<enclosure url="magnet:?xt=urn:btih:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" type="application/x-bittorrent" length="1"/>
|
||||
</item>
|
||||
<item>
|
||||
<guid>release-3-other</guid>
|
||||
<title>[Group] Example - 03v2 [1080p]</title>
|
||||
<enclosure url="magnet:?xt=urn:btih:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" type="application/x-bittorrent" length="1"/>
|
||||
</item>
|
||||
<item>
|
||||
<guid>batch</guid>
|
||||
<title>[Group] Example [01-12] [1080p]</title>
|
||||
<enclosure url="magnet:?xt=urn:btih:cccccccccccccccccccccccccccccccccccccccc" type="application/x-bittorrent" length="1"/>
|
||||
</item>
|
||||
<item>
|
||||
<guid>low-resolution</guid>
|
||||
<title>[Group] Example - 04 [720p]</title>
|
||||
<enclosure url="magnet:?xt=urn:btih:dddddddddddddddddddddddddddddddddddddddd" type="application/x-bittorrent" length="1"/>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
@@ -0,0 +1,154 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
use actix_web::{test, web, App};
|
||||
use rust_project::{api, config::Config, service::Service, storage::Store};
|
||||
use serde_json::{json, Value};
|
||||
use std::{sync::Arc, time::Duration};
|
||||
|
||||
#[actix_web::test]
|
||||
async fn directory_api_rejects_traversal_and_does_not_follow_links_outside_root() {
|
||||
let directory = std::env::temp_dir().join(format!("media-browse-{}", uuid::Uuid::new_v4()));
|
||||
let root = directory.join("root");
|
||||
std::fs::create_dir_all(root.join("Series")).unwrap();
|
||||
std::fs::create_dir(directory.join("outside")).unwrap();
|
||||
std::fs::write(root.join("Series/ep01.mkv"), b"abc").unwrap();
|
||||
std::fs::write(directory.join("outside/secret"), b"outside").unwrap();
|
||||
#[cfg(unix)]
|
||||
std::os::unix::fs::symlink(directory.join("outside"), root.join("escape")).unwrap();
|
||||
let config = Config {
|
||||
database_url: "sqlite::memory:".into(),
|
||||
port: 8080,
|
||||
poll_interval: Duration::from_secs(300),
|
||||
qbit: None,
|
||||
mikan_base_url: "https://mikanani.me".into(),
|
||||
website: Default::default(),
|
||||
};
|
||||
let service = Arc::new(
|
||||
Service::new(Store::open("sqlite::memory:").await.unwrap(), &config)
|
||||
.await
|
||||
.unwrap(),
|
||||
);
|
||||
let app = test::init_service(
|
||||
App::new()
|
||||
.app_data(web::Data::new(service))
|
||||
.configure(api::configure),
|
||||
)
|
||||
.await;
|
||||
for body in [
|
||||
json!({"name":"bad", "kind":"local", "root_path":"relative"}),
|
||||
json!({"name":"bad", "kind":"smb", "root_path":"/share"}),
|
||||
] {
|
||||
let response = test::call_service(
|
||||
&app,
|
||||
test::TestRequest::post()
|
||||
.uri("/api/destinations")
|
||||
.set_json(body)
|
||||
.to_request(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(response.status(), 400);
|
||||
}
|
||||
let response = test::call_service(
|
||||
&app,
|
||||
test::TestRequest::post()
|
||||
.uri("/api/destinations")
|
||||
.set_json(json!({"name":"Local", "kind":"local", "root_path":root.to_str().unwrap()}))
|
||||
.to_request(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(response.status(), 201);
|
||||
let destination: Value = test::read_body_json(response).await;
|
||||
let id = destination["id"].as_str().unwrap();
|
||||
let response = test::call_service(
|
||||
&app,
|
||||
test::TestRequest::put()
|
||||
.uri("/api/destinations/active")
|
||||
.set_json(json!({"destination_id":id}))
|
||||
.to_request(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(response.status(), 400); // No downloader mapping: never silently use a fallback.
|
||||
let downloader: Value = test::call_and_read_body_json(&app, test::TestRequest::post().uri("/api/downloaders")
|
||||
.set_json(json!({"name":"Local qBit", "kind":"qbittorrent", "connection":{
|
||||
"url":"http://127.0.0.1:1", "username":"", "password":"", "save_path":"/downloads"
|
||||
}})).to_request()).await;
|
||||
let response = test::call_service(
|
||||
&app,
|
||||
test::TestRequest::put()
|
||||
.uri("/api/downloaders/active")
|
||||
.set_json(json!({"downloader_id":downloader["id"]}))
|
||||
.to_request(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(response.status(), 204);
|
||||
let response = test::call_service(
|
||||
&app,
|
||||
test::TestRequest::post()
|
||||
.uri(&format!("/api/destinations/{id}/mappings"))
|
||||
.set_json(json!({"downloader_id":downloader["id"],"download_root":"/downloads/local"}))
|
||||
.to_request(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(response.status(), 201);
|
||||
let mapping: Value = test::read_body_json(response).await;
|
||||
assert_eq!(mapping["version"], 1);
|
||||
let response = test::call_service(
|
||||
&app,
|
||||
test::TestRequest::put()
|
||||
.uri("/api/destinations/active")
|
||||
.set_json(json!({"destination_id":id}))
|
||||
.to_request(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(response.status(), 204);
|
||||
let status: Value = test::call_and_read_body_json(
|
||||
&app,
|
||||
test::TestRequest::get().uri("/api/status").to_request(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status["active_destination_id"], id);
|
||||
let base = format!("/api/destinations/{id}/entries");
|
||||
let listing: Value = test::call_and_read_body_json(
|
||||
&app,
|
||||
test::TestRequest::get()
|
||||
.uri(&format!("{base}?path=Series"))
|
||||
.to_request(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(listing["status"], "available");
|
||||
assert_eq!(listing["entries"][0]["name"], "ep01.mkv");
|
||||
assert_eq!(listing["entries"][0]["size"], 3);
|
||||
for path in [
|
||||
"../outside",
|
||||
"%2Fetc",
|
||||
"C%3A%2FWindows",
|
||||
"Series%5C..%5Coutside",
|
||||
"Series/../outside",
|
||||
"Series//bad",
|
||||
] {
|
||||
let response = test::call_service(
|
||||
&app,
|
||||
test::TestRequest::get()
|
||||
.uri(&format!("{base}?path={path}"))
|
||||
.to_request(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(response.status(), 400, "{path}");
|
||||
}
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let listing: Value = test::call_and_read_body_json(
|
||||
&app,
|
||||
test::TestRequest::get()
|
||||
.uri(&format!("{base}?path=escape"))
|
||||
.to_request(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(listing["status"], "unavailable");
|
||||
assert_eq!(listing["entries"], json!([]));
|
||||
}
|
||||
std::fs::rename(&root, directory.join("offline")).unwrap();
|
||||
let listing: Value =
|
||||
test::call_and_read_body_json(&app, test::TestRequest::get().uri(&base).to_request()).await;
|
||||
assert_eq!(listing["status"], "unavailable");
|
||||
std::fs::remove_dir_all(directory).unwrap();
|
||||
}
|
||||
+319
@@ -0,0 +1,319 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
use actix_web::{dev::ServerHandle, test, web, App, HttpRequest, HttpResponse, HttpServer};
|
||||
use rust_project::{api, config::Config, service::Service, storage::Store};
|
||||
use serde_json::Value;
|
||||
use std::{net::TcpListener, sync::Arc, time::Duration};
|
||||
|
||||
const SEARCH: &str = r#"<form action="/Home/Search"><input name="searchstr"></form>
|
||||
<div class="central-container"><ul class="list-inline an-ul">
|
||||
<li><a href="/Home/Bangumi/101"><div class="an-text" title="测试番剧">测试番剧</div></a></li>
|
||||
<li><a href="/Home/Bangumi/202"><div class="an-text" title="测试番剧 第二季">测试番剧 第二季</div></a></li>
|
||||
</ul></div>"#;
|
||||
|
||||
// Independently authored fixtures; publish-group ID deliberately differs from RSS group ID.
|
||||
const DETAIL: &str = r#"<p class="bangumi-title">测试番剧
|
||||
<a class="mikan-rss" href="/RSS/Bangumi?bangumiId=101"></a></p>
|
||||
<p class="bangumi-info">放送日期:星期五</p>
|
||||
<p class="bangumi-info">Bangumi番组计划链接:<a href="https://bgm.tv/subject/400602">详情</a></p>
|
||||
<div class="subgroup-text" id="12"><a href="/Home/PublishGroup/99">字幕组 & A</a>
|
||||
<a class="mikan-rss" href="/RSS/Bangumi?bangumiId=101&subgroupid=12"></a></div>"#;
|
||||
|
||||
async fn mock() -> (String, ServerHandle) {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let url = format!("http://{}", listener.local_addr().unwrap());
|
||||
let server = HttpServer::new(|| {
|
||||
App::new().default_service(web::to(|req: HttpRequest| async move {
|
||||
if req.path() == "/Home/Bangumi/202" {
|
||||
return HttpResponse::Ok().content_type("text/html").body(format!(
|
||||
"{}<div class=\"bangumi-poster\" style=\"background-image: url('/images/Bangumi/cover.png');\"></div>",
|
||||
DETAIL.replace("101", "202")));
|
||||
}
|
||||
if req.path() == "/images/Bangumi/cover.png" {
|
||||
return HttpResponse::Ok().content_type("image/png").body(b"\x89PNG\r\n\x1a\nfixture".as_slice());
|
||||
}
|
||||
if req.path() == "/Home/Bangumi/101" {
|
||||
return HttpResponse::Ok().content_type("text/html").body(DETAIL);
|
||||
}
|
||||
if req.path() == "/RSS/Bangumi" {
|
||||
assert_eq!(req.query_string(), "bangumiId=101&subgroupid=12");
|
||||
return HttpResponse::Ok()
|
||||
.content_type("application/rss+xml")
|
||||
.body(include_str!("fixtures/releases.xml"));
|
||||
}
|
||||
assert_eq!(req.path(), "/Home/Search");
|
||||
let query = web::Query::<std::collections::HashMap<String, String>>::from_query(
|
||||
req.query_string(),
|
||||
)
|
||||
.unwrap();
|
||||
match query.get("searchstr").map(String::as_str) {
|
||||
Some("empty") => return HttpResponse::Ok().content_type("text/html")
|
||||
.body(r#"<form action="/Home/Search"><input name="searchstr"></form><div class="central-container"><ul class="an-ul"></ul></div>"#),
|
||||
Some("challenge") => return HttpResponse::Ok().content_type("text/html").body("<h1>Verify your browser</h1>"),
|
||||
Some("changed") => return HttpResponse::Ok().content_type("text/html")
|
||||
.body(r#"<form action="/Home/Search"><input name="searchstr"></form><ul class="an-ul"><a href="/Home/Bangumi/101"><span class="new-title">番剧</span></a></ul>"#),
|
||||
_ => {}
|
||||
}
|
||||
assert_eq!(query.get("searchstr").unwrap(), "测试 & S2");
|
||||
HttpResponse::Ok()
|
||||
.content_type("text/html; charset=utf-8")
|
||||
.body(SEARCH)
|
||||
}))
|
||||
})
|
||||
.workers(1)
|
||||
.listen(listener)
|
||||
.unwrap()
|
||||
.run();
|
||||
let handle = server.handle();
|
||||
actix_web::rt::spawn(server);
|
||||
(url, handle)
|
||||
}
|
||||
|
||||
#[actix_web::test]
|
||||
async fn covers_use_an_application_url_and_details_remain_available_in_bounded_cache() {
|
||||
let (base, server) = mock().await;
|
||||
let service = Service::new(
|
||||
Store::open("sqlite::memory:").await.unwrap(),
|
||||
&config(&base),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let app = test::init_service(
|
||||
App::new()
|
||||
.app_data(web::Data::new(Arc::new(service)))
|
||||
.configure(api::configure),
|
||||
)
|
||||
.await;
|
||||
let detail: Value = test::call_and_read_body_json(
|
||||
&app,
|
||||
test::TestRequest::get()
|
||||
.uri("/api/sources/mikan/series/202")
|
||||
.to_request(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(detail["cover_url"], "/api/sources/mikan/series/202/cover");
|
||||
let response = test::call_service(
|
||||
&app,
|
||||
test::TestRequest::get()
|
||||
.uri(detail["cover_url"].as_str().unwrap())
|
||||
.to_request(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(response.status(), 200);
|
||||
assert_eq!(response.headers().get("Content-Type").unwrap(), "image/png");
|
||||
assert_eq!(
|
||||
test::read_body(response).await.as_ref(),
|
||||
b"\x89PNG\r\n\x1a\nfixture"
|
||||
);
|
||||
server.stop(false).await;
|
||||
let cached: Value = test::call_and_read_body_json(
|
||||
&app,
|
||||
test::TestRequest::get()
|
||||
.uri("/api/sources/mikan/series/202")
|
||||
.to_request(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(cached, detail);
|
||||
}
|
||||
|
||||
#[actix_web::test]
|
||||
async fn empty_results_are_distinct_from_challenges_or_changed_markup() {
|
||||
let (base, server) = mock().await;
|
||||
let service = Service::new(
|
||||
Store::open("sqlite::memory:").await.unwrap(),
|
||||
&config(&base),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let app = test::init_service(
|
||||
App::new()
|
||||
.app_data(web::Data::new(Arc::new(service)))
|
||||
.configure(api::configure),
|
||||
)
|
||||
.await;
|
||||
for (query, status) in [
|
||||
("empty", 200),
|
||||
("challenge", 502),
|
||||
("changed", 502),
|
||||
("%20", 400),
|
||||
] {
|
||||
let response = test::call_service(
|
||||
&app,
|
||||
test::TestRequest::get()
|
||||
.uri(&format!("/api/sources/mikan/search?q={query}"))
|
||||
.to_request(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(response.status(), status, "query {query}");
|
||||
if status == 200 {
|
||||
let value: Value = test::read_body_json(response).await;
|
||||
assert_eq!(value["series"], serde_json::json!([]));
|
||||
}
|
||||
}
|
||||
server.stop(false).await;
|
||||
}
|
||||
|
||||
#[actix_web::test]
|
||||
async fn group_candidate_can_be_previewed_and_saved_disabled_without_downloading() {
|
||||
let (base, server) = mock().await;
|
||||
let service = Service::new(
|
||||
Store::open("sqlite::memory:").await.unwrap(),
|
||||
&config(&base),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let app = test::init_service(
|
||||
App::new()
|
||||
.app_data(web::Data::new(Arc::new(service)))
|
||||
.configure(api::configure),
|
||||
)
|
||||
.await;
|
||||
let response = test::call_service(
|
||||
&app,
|
||||
test::TestRequest::get()
|
||||
.uri("/api/sources/mikan/series/101")
|
||||
.to_request(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(response.status(), 200);
|
||||
let detail: Value = test::read_body_json(response).await;
|
||||
assert_eq!(detail["title"], "测试番剧");
|
||||
assert_eq!(detail["bangumi_id"], "400602");
|
||||
assert_eq!(detail["groups"][0]["group_id"], "12");
|
||||
assert_eq!(detail["groups"][0]["name"], "字幕组 & A");
|
||||
assert!(detail["cover_url"].is_null());
|
||||
let response = test::call_service(
|
||||
&app,
|
||||
test::TestRequest::post()
|
||||
.uri("/api/sources/mikan/resolve")
|
||||
.set_json(serde_json::json!({"url": detail["groups"][0]["rss_url"]}))
|
||||
.to_request(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(response.status(), 200);
|
||||
let candidate: Value = test::read_body_json(response).await;
|
||||
assert_eq!(candidate["scope"], "group");
|
||||
assert_eq!(candidate["series_id"], "101");
|
||||
assert_eq!(candidate["group_id"], "12");
|
||||
assert_eq!(candidate["subscription"]["enabled"], false);
|
||||
let preview: Value = test::call_and_read_body_json(
|
||||
&app,
|
||||
test::TestRequest::post()
|
||||
.uri("/api/preview")
|
||||
.set_json(&candidate["subscription"])
|
||||
.to_request(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(preview[0]["episode"], 3);
|
||||
let saved = test::call_service(
|
||||
&app,
|
||||
test::TestRequest::post()
|
||||
.uri("/api/subscriptions")
|
||||
.set_json(&candidate["subscription"])
|
||||
.to_request(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(saved.status(), 201);
|
||||
let jobs: Value =
|
||||
test::call_and_read_body_json(&app, test::TestRequest::get().uri("/api/jobs").to_request())
|
||||
.await;
|
||||
assert_eq!(jobs, serde_json::json!([]));
|
||||
server.stop(true).await;
|
||||
}
|
||||
|
||||
fn config(base: &str) -> Config {
|
||||
Config {
|
||||
database_url: "sqlite::memory:".into(),
|
||||
port: 8080,
|
||||
poll_interval: Duration::from_secs(300),
|
||||
qbit: None,
|
||||
mikan_base_url: base.into(),
|
||||
website: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
#[actix_web::test]
|
||||
async fn resolver_marks_keyword_scope_and_rejects_foreign_or_ambiguous_urls() {
|
||||
let (base, server) = mock().await;
|
||||
let service = Service::new(
|
||||
Store::open("sqlite::memory:").await.unwrap(),
|
||||
&config(&base),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let app = test::init_service(
|
||||
App::new()
|
||||
.app_data(web::Data::new(Arc::new(service)))
|
||||
.configure(api::configure),
|
||||
)
|
||||
.await;
|
||||
let response = test::call_service(
|
||||
&app,
|
||||
test::TestRequest::post()
|
||||
.uri("/api/sources/mikan/resolve")
|
||||
.set_json(serde_json::json!({"url": format!("{base}/RSS/Search?searchstr=test")}))
|
||||
.to_request(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(response.status(), 200);
|
||||
let candidate: Value = test::read_body_json(response).await;
|
||||
assert_eq!(candidate["scope"], "keyword");
|
||||
assert!(candidate["series_id"].is_null());
|
||||
assert!(candidate["subscription"].is_null());
|
||||
assert!(!candidate["warnings"].as_array().unwrap().is_empty());
|
||||
for url in [
|
||||
"https://other.invalid/Home/Bangumi/101".into(),
|
||||
format!("{base}/RSS/Bangumi?bangumiId=101&bangumiId=202"),
|
||||
format!("{base}/RSS/Bangumi?bangumiId=101&subgroupid=999"),
|
||||
format!("{base}/Home/Bangumi/not-an-id"),
|
||||
] {
|
||||
let response = test::call_service(
|
||||
&app,
|
||||
test::TestRequest::post()
|
||||
.uri("/api/sources/mikan/resolve")
|
||||
.set_json(serde_json::json!({"url": url}))
|
||||
.to_request(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(response.status(), 400);
|
||||
}
|
||||
server.stop(false).await;
|
||||
}
|
||||
|
||||
#[actix_web::test]
|
||||
async fn search_discovers_distinct_series_and_encoded_keyword_rss_without_creating_jobs() {
|
||||
let (base, server) = mock().await;
|
||||
let service = Service::new(
|
||||
Store::open("sqlite::memory:").await.unwrap(),
|
||||
&config(&base),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let app = test::init_service(
|
||||
App::new()
|
||||
.app_data(web::Data::new(Arc::new(service)))
|
||||
.configure(api::configure),
|
||||
)
|
||||
.await;
|
||||
let response = test::call_service(
|
||||
&app,
|
||||
test::TestRequest::get()
|
||||
.uri("/api/sources/mikan/search?q=%E6%B5%8B%E8%AF%95%20%26%20S2")
|
||||
.to_request(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(response.status(), 200);
|
||||
let body: Value = test::read_body_json(response).await;
|
||||
assert_eq!(body["series"][0]["series_id"], "101");
|
||||
assert_eq!(body["series"][1]["title"], "测试番剧 第二季");
|
||||
let rss = reqwest::Url::parse(body["keyword_rss_url"].as_str().unwrap()).unwrap();
|
||||
assert_eq!(rss.path(), "/RSS/Search");
|
||||
assert_eq!(
|
||||
rss.query_pairs().collect::<Vec<_>>(),
|
||||
vec![("searchstr".into(), "测试 & S2".into())]
|
||||
);
|
||||
let jobs: Value =
|
||||
test::call_and_read_body_json(&app, test::TestRequest::get().uri("/api/jobs").to_request())
|
||||
.await;
|
||||
assert_eq!(jobs, serde_json::json!([]));
|
||||
server.stop(true).await;
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-only
|
||||
use actix_web::{dev::ServerHandle, test, web, App, HttpRequest, HttpResponse, HttpServer};
|
||||
use rust_project::{
|
||||
api,
|
||||
config::{Config, QbitConfig},
|
||||
service::Service,
|
||||
storage::Store,
|
||||
};
|
||||
use serde_json::{json, Value};
|
||||
use std::{net::TcpListener, sync::Arc, time::Duration};
|
||||
|
||||
async fn server(proxy: bool) -> (String, ServerHandle) {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let base = format!("http://{}", listener.local_addr().unwrap());
|
||||
let server = HttpServer::new(move || App::new().default_service(web::to(move |req: HttpRequest| async move {
|
||||
if proxy {
|
||||
assert_eq!(req.headers().get("Proxy-Authorization").and_then(|h| h.to_str().ok()), Some("Basic dXNlcjpwYXNz"), "request {}", req.uri());
|
||||
if req.uri().host() == Some("127.0.0.1") { return HttpResponse::BadGateway().finish(); }
|
||||
match req.path() {
|
||||
"/to-direct" => {
|
||||
let query = web::Query::<std::collections::HashMap<String, String>>::from_query(req.query_string()).unwrap();
|
||||
HttpResponse::Found().insert_header(("Location", query.get("target").unwrap().as_str())).finish()
|
||||
},
|
||||
"/Home/Search" => HttpResponse::Found().insert_header(("Location", "http://assets.invalid/results")).finish(),
|
||||
"/results" => {
|
||||
assert_eq!(req.uri().host(), Some("assets.invalid"));
|
||||
HttpResponse::Ok().content_type("text/html").body(r#"<form action="/Home/Search"><input name="searchstr"></form><ul class="an-ul"><a href="/Home/Bangumi/101"><div class="an-text">经代理搜索</div></a></ul>"#)
|
||||
},
|
||||
"/feed" => HttpResponse::Ok().body(include_str!("fixtures/releases.xml")),
|
||||
_ => HttpResponse::BadGateway().finish(),
|
||||
}
|
||||
} else {
|
||||
assert!(req.headers().get("Proxy-Authorization").is_none());
|
||||
match req.path() {
|
||||
"/api/v2/auth/login" => HttpResponse::Ok().insert_header(("Set-Cookie", "SID=test; Path=/")).body("Ok."),
|
||||
"/api/v2/torrents/info" => HttpResponse::Ok().json(json!([])),
|
||||
"/feed" => HttpResponse::Ok().body(include_str!("fixtures/releases.xml")),
|
||||
_ => HttpResponse::NotFound().finish(),
|
||||
}
|
||||
}
|
||||
}))).workers(1).listen(listener).unwrap().run();
|
||||
let handle = server.handle();
|
||||
actix_web::rt::spawn(server);
|
||||
(base, handle)
|
||||
}
|
||||
|
||||
#[actix_web::test]
|
||||
async fn direct_exceptions_are_explicit_and_proxy_failure_never_falls_back() {
|
||||
let (proxy, proxy_server) = server(true).await;
|
||||
let (direct, direct_server) = server(false).await;
|
||||
let proxy = proxy.replacen("http://", "http://user:pass@", 1);
|
||||
for (direct_hosts, status) in [("127.0.0.1", 200), ("", 502)] {
|
||||
let config = Config {
|
||||
database_url: "sqlite::memory:".into(),
|
||||
port: 8080,
|
||||
poll_interval: Duration::from_secs(300),
|
||||
qbit: None,
|
||||
mikan_base_url: "http://mikan.invalid".into(),
|
||||
website: rust_project::config::WebsiteConfig {
|
||||
proxy_url: Some(proxy.clone()),
|
||||
direct_hosts: direct_hosts.into(),
|
||||
},
|
||||
};
|
||||
let service = Service::new(Store::open("sqlite::memory:").await.unwrap(), &config)
|
||||
.await
|
||||
.unwrap();
|
||||
let app = test::init_service(
|
||||
App::new()
|
||||
.app_data(web::Data::new(Arc::new(service)))
|
||||
.configure(api::configure),
|
||||
)
|
||||
.await;
|
||||
let mut redirect = reqwest::Url::parse("http://unresolvable.invalid/to-direct").unwrap();
|
||||
redirect
|
||||
.query_pairs_mut()
|
||||
.append_pair("target", &format!("{direct}/feed"));
|
||||
let response = test::call_service(
|
||||
&app,
|
||||
test::TestRequest::post()
|
||||
.uri("/api/preview")
|
||||
.set_json(json!({"title":"Test", "rss_url": redirect.as_str()}))
|
||||
.to_request(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(response.status(), status);
|
||||
if status == 200 {
|
||||
let preview: Value = test::read_body_json(response).await;
|
||||
assert_eq!(preview[0]["episode"], 3);
|
||||
} else {
|
||||
let body = String::from_utf8(test::read_body(response).await.to_vec()).unwrap();
|
||||
assert!(!body.contains("user:pass"));
|
||||
}
|
||||
}
|
||||
proxy_server.stop(false).await;
|
||||
direct_server.stop(false).await;
|
||||
}
|
||||
|
||||
#[actix_web::test]
|
||||
async fn website_proxy_handles_auth_redirects_and_rss_while_downloader_stays_direct() {
|
||||
let (proxy, proxy_server) = server(true).await;
|
||||
let (direct, direct_server) = server(false).await;
|
||||
let mut proxy_url = reqwest::Url::parse(&proxy).unwrap();
|
||||
proxy_url.set_username("user").unwrap();
|
||||
proxy_url.set_password(Some("pass")).unwrap();
|
||||
let config = Config {
|
||||
database_url: "sqlite::memory:".into(),
|
||||
port: 8080,
|
||||
poll_interval: Duration::from_secs(300),
|
||||
mikan_base_url: "http://mikan.invalid".into(),
|
||||
website: rust_project::config::WebsiteConfig {
|
||||
proxy_url: Some(proxy_url.into()),
|
||||
direct_hosts: String::new(),
|
||||
},
|
||||
qbit: Some(QbitConfig {
|
||||
url: direct.clone(),
|
||||
username: "test".into(),
|
||||
password: "test".into(),
|
||||
save_path: "/downloads".into(),
|
||||
}),
|
||||
};
|
||||
let service = Service::new(Store::open("sqlite::memory:").await.unwrap(), &config)
|
||||
.await
|
||||
.unwrap();
|
||||
let app = test::init_service(
|
||||
App::new()
|
||||
.app_data(web::Data::new(Arc::new(service)))
|
||||
.configure(api::configure),
|
||||
)
|
||||
.await;
|
||||
let search: Value = test::call_and_read_body_json(
|
||||
&app,
|
||||
test::TestRequest::get()
|
||||
.uri("/api/sources/mikan/search?q=test")
|
||||
.to_request(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(search["series"][0]["title"], "经代理搜索");
|
||||
let preview: Value = test::call_and_read_body_json(
|
||||
&app,
|
||||
test::TestRequest::post()
|
||||
.uri("/api/preview")
|
||||
.set_json(json!({"title":"Test", "rss_url":"http://unresolvable.invalid/feed"}))
|
||||
.to_request(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(preview[0]["episode"], 3);
|
||||
let cycle: Value =
|
||||
test::call_and_read_body_json(&app, test::TestRequest::post().uri("/api/run").to_request())
|
||||
.await;
|
||||
assert_eq!(cycle["errors"], json!([]));
|
||||
assert_eq!(cycle["downloader_configured"], true);
|
||||
proxy_server.stop(false).await;
|
||||
direct_server.stop(false).await;
|
||||
}
|
||||
+1100
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user