// SPDX-License-Identifier: GPL-2.0-only use actix_web::{dev::ServerHandle, web, App, HttpRequest, HttpResponse, HttpServer}; use autocomic_rss::{ config::{Config, QbitConfig}, domain::{Decision, SubscriptionInput}, http, service::Service, sources::feeds, storage::Store, }; use serde_json::{json, Value}; use std::{ net::TcpListener, sync::{Arc, Mutex}, time::Duration, }; const FEED: &str = include_str!("fixtures/releases.xml"); const HASH: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; const TORRENT: &[u8] = b"d4:infod6:lengthi1e4:name1:a12:piece lengthi16384e6:pieces20:aaaaaaaaaaaaaaaaaaaaee"; #[derive(Default)] struct Remote { adds: usize, torrents: Vec, fail_list: bool, fail_after_add: bool, feed: Option, files: Option>, save_paths: Vec, } async fn login() -> HttpResponse { HttpResponse::Ok() .insert_header(("Set-Cookie", "SID=test; Path=/")) .body("Ok.") } async fn rss(state: web::Data>>) -> HttpResponse { HttpResponse::Ok().content_type("application/rss+xml").body( state .lock() .unwrap() .feed .clone() .unwrap_or_else(|| FEED.into()), ) } fn authenticated(req: &HttpRequest) -> bool { req.cookie("SID").is_some_and(|c| c.value() == "test") } async fn info(req: HttpRequest, state: web::Data>>) -> HttpResponse { if !authenticated(&req) { return HttpResponse::Forbidden().finish(); } let remote = state.lock().unwrap(); if remote.fail_list { return HttpResponse::ServiceUnavailable().finish(); } HttpResponse::Ok().json(&remote.torrents) } async fn add( req: HttpRequest, body: web::Bytes, state: web::Data>>, ) -> HttpResponse { if !authenticated(&req) { return HttpResponse::Forbidden().finish(); } let text = String::from_utf8_lossy(&body); let hash = if text.contains("name=\"torrents\"") { assert!(body.windows(TORRENT.len()).any(|w| w == TORRENT)); autocomic_rss::downloaders::torrent::torrent_hash(TORRENT).unwrap() } else { assert!(text.contains("name=\"urls\"")); regex::Regex::new("urn:btih:([a-f0-9]{40})") .unwrap() .captures(&text) .unwrap()[1] .to_owned() }; assert!(text.contains("name=\"savepath\"")); let save_path = text .split("name=\"savepath\"") .nth(1) .unwrap() .split("\r\n\r\n") .nth(1) .unwrap() .split("\r\n") .next() .unwrap() .to_owned(); let mut remote = state.lock().unwrap(); remote.adds += 1; remote.save_paths.push(save_path.clone()); remote .torrents .push(json!({"hash": hash, "progress": 0.0, "state": "metaDL", "save_path": save_path})); if remote.fail_after_add { remote.fail_after_add = false; return HttpResponse::InternalServerError().finish(); } HttpResponse::Ok().body("Ok.") } async fn mock() -> (String, Arc>, ServerHandle) { let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let url = format!("http://{}", listener.local_addr().unwrap()); let remote = Arc::new(Mutex::new(Remote::default())); let data = remote.clone(); let server = HttpServer::new(move || { App::new() .app_data(web::Data::new(data.clone())) .route("/feed", web::get().to(rss)) .route( "/release.torrent", web::get().to(|| async { HttpResponse::Ok().body(TORRENT) }), ) .route("/api/v2/auth/login", web::post().to(login)) .route("/api/v2/torrents/info", web::get().to(info)) .route( "/api/v2/torrents/files", web::get().to( |req: HttpRequest, state: web::Data>>| async move { assert!(authenticated(&req)); HttpResponse::Ok().json(state.lock().unwrap().files.clone().unwrap_or_else( || { vec![ json!({"name":"Series/ep03.mkv", "size":3, "priority":1}), json!({"name":"skipped.mkv", "size":10, "priority":0}), ] }, )) }, ), ) .route("/api/v2/torrents/add", web::post().to(add)) }) .workers(1) .listen(listener) .unwrap() .run(); let handle = server.handle(); actix_web::rt::spawn(server); (url, remote, handle) } fn input(url: &str, enabled: bool) -> SubscriptionInput { SubscriptionInput { title: "Example".into(), rss_url: format!("{url}/feed"), season: 1, episode_offset: 0, include: vec!["1080p".into()], exclude: vec![], enabled, } } fn config(url: &str) -> Config { Config { database_url: "sqlite::memory:".into(), port: 8080, poll_interval: Duration::from_secs(300), mikan_base_url: "https://mikanani.me".into(), website: Default::default(), qbit: Some(QbitConfig { url: url.into(), username: "test".into(), password: "test".into(), save_path: "/downloads".into(), }), } } #[actix_web::test] async fn parses_rss_enclosures_and_filter_reasons() { let releases = feeds::parse( FEED.as_bytes(), &"https://example.invalid/".parse().unwrap(), &input("https://example.invalid", false), ) .unwrap(); assert_eq!(releases[0].episode, Some(3)); assert_eq!(releases[0].decision, Decision::Ready); assert_eq!(releases[2].decision, Decision::NeedsReview); assert_eq!(releases[3].decision, Decision::Filtered); assert!(releases[0] .download_url .as_ref() .unwrap() .starts_with("magnet:")); } #[actix_web::test] async fn restart_and_repeated_scans_do_not_resubmit() { let (url, remote, server) = mock().await; let path = std::env::temp_dir().join(format!("rust-project-{}.db", uuid::Uuid::new_v4())); let db = format!("sqlite://{}", path.display()); let service = Service::new(Store::open(&db).await.unwrap(), &config(&url)) .await .unwrap(); let sub = service.store.create(input(&url, false)).await.unwrap(); let preview = service.preview_subscription(&sub.id).await.unwrap(); assert_eq!(preview[1].decision, Decision::AlreadyQueued); assert!(service.store.jobs().await.unwrap().is_empty()); assert!(service.scan_subscription(&sub.id).await.is_err()); service .store .update(&sub.id, input(&url, true)) .await .unwrap(); assert_eq!(service.cycle().await.unwrap().queued, 1); assert_eq!(service.cycle().await.unwrap().queued, 0); assert_eq!(remote.lock().unwrap().adds, 1); let jobs = service.store.jobs().await.unwrap(); assert_eq!(jobs[0].state, "submitted"); service.store.pool.close().await; drop(service); let recovered = Service::new(Store::open(&db).await.unwrap(), &config(&url)) .await .unwrap(); recovered.cycle().await.unwrap(); assert_eq!(remote.lock().unwrap().adds, 1); // Removing a task remotely does not silently recreate it. remote.lock().unwrap().torrents.clear(); recovered.cycle().await.unwrap(); assert_eq!(recovered.store.jobs().await.unwrap()[0].state, "missing"); assert_eq!(remote.lock().unwrap().adds, 1); recovered.store.retry(&jobs[0].id).await.unwrap(); recovered.cycle().await.unwrap(); assert_eq!(remote.lock().unwrap().adds, 2); remote.lock().unwrap().torrents[0]["progress"] = json!(1.0); recovered.cycle().await.unwrap(); assert_eq!(recovered.store.jobs().await.unwrap()[0].state, "completed"); recovered.store.pool.close().await; drop(recovered); std::fs::remove_file(path).unwrap(); server.stop(true).await; } #[actix_web::test] async fn reconciles_uncertain_submission_and_respects_disabled_subscriptions() { let (url, remote, server) = mock().await; let service = Service::new(Store::open("sqlite::memory:").await.unwrap(), &config(&url)) .await .unwrap(); let sub = service.store.create(input(&url, true)).await.unwrap(); remote.lock().unwrap().fail_list = true; assert!(!service.cycle().await.unwrap().errors.is_empty()); assert_eq!(remote.lock().unwrap().adds, 0); remote.lock().unwrap().fail_list = false; service .store .update(&sub.id, input(&url, false)) .await .unwrap(); service.cycle().await.unwrap(); assert_eq!(remote.lock().unwrap().adds, 0); service .store .update(&sub.id, input(&url, true)) .await .unwrap(); remote.lock().unwrap().fail_after_add = true; service.cycle().await.unwrap(); assert_eq!(remote.lock().unwrap().adds, 1); let job = &service.store.jobs().await.unwrap()[0]; assert_eq!(job.state, "submitting"); assert!(job.last_error.is_some()); sqlx::query("UPDATE download_jobs SET next_attempt_at = 0") .execute(&service.store.pool) .await .unwrap(); service.cycle().await.unwrap(); assert_eq!(remote.lock().unwrap().adds, 1); assert_eq!(service.store.jobs().await.unwrap()[0].state, "submitted"); service.store.delete(&sub.id).await.unwrap(); assert!(service.store.subscriptions().await.unwrap().is_empty()); assert_eq!(service.store.jobs().await.unwrap().len(), 1); server.stop(true).await; } #[actix_web::test] async fn api_validates_input_and_persists_crud() { use actix_web::{http::StatusCode, test}; let mut settings = config("http://127.0.0.1:1"); settings.qbit = None; let service = Arc::new( Service::new(Store::open("sqlite::memory:").await.unwrap(), &settings) .await .unwrap(), ); let app = test::init_service( App::new() .app_data(web::Data::new(service.clone())) .configure(http::configure), ) .await; let mut invalid = input("http://example.invalid", false); invalid.rss_url = "file:///etc/passwd".into(); let response = test::call_service( &app, test::TestRequest::post() .uri("/api/subscriptions") .set_json(invalid) .to_request(), ) .await; assert_eq!(response.status(), StatusCode::BAD_REQUEST); let response = test::call_service( &app, test::TestRequest::post() .uri("/api/subscriptions") .set_json(input("https://example.invalid", false)) .to_request(), ) .await; assert_eq!(response.status(), StatusCode::CREATED); let body: Value = test::read_body_json(response).await; let uri = format!("/api/subscriptions/{}", body["id"].as_str().unwrap()); let mut changed = input("https://example.invalid", false); changed.title = "Updated".into(); let response = test::call_service( &app, test::TestRequest::put() .uri(&uri) .set_json(changed) .to_request(), ) .await; assert_eq!(response.status(), StatusCode::OK); let response = test::call_service(&app, test::TestRequest::get().uri(&uri).to_request()).await; let body: Value = test::read_body_json(response).await; assert_eq!(body["title"], "Updated"); let response = test::call_service(&app, test::TestRequest::delete().uri(&uri).to_request()).await; assert_eq!(response.status(), StatusCode::NO_CONTENT); let response = test::call_service(&app, test::TestRequest::get().uri(&uri).to_request()).await; assert_eq!(response.status(), StatusCode::NOT_FOUND); } #[actix_web::test] async fn downloads_relative_torrent_enclosure_and_submits_original_bytes() { let (url, remote, server) = mock().await; remote.lock().unwrap().feed = Some( r#" Testhttps://example.invalidTest torrentExample - 05 [1080p] "# .into(), ); let service = Service::new(Store::open("sqlite::memory:").await.unwrap(), &config(&url)) .await .unwrap(); service.store.create(input(&url, true)).await.unwrap(); let report = service.cycle().await.unwrap(); assert!(report.errors.is_empty(), "{:?}", report.errors); let jobs = service.store.jobs().await.unwrap(); assert_eq!(jobs.len(), 1); assert_eq!(jobs[0].state, "submitted", "{:?}", jobs[0]); assert_eq!( jobs[0].info_hash.as_deref(), Some( autocomic_rss::downloaders::torrent::torrent_hash(TORRENT) .unwrap() .as_str() ) ); assert_eq!( service .store .torrent_bytes(&jobs[0].id) .await .unwrap() .unwrap(), TORRENT ); service.cycle().await.unwrap(); assert_eq!(remote.lock().unwrap().adds, 1); server.stop(true).await; } #[actix_web::test] async fn switching_keeps_queued_jobs_and_retries_with_their_original_downloader_after_restart() { use actix_web::test; let (url_a, remote_a, server_a) = mock().await; let (url_b, remote_b, server_b) = mock().await; let path = std::env::temp_dir().join(format!("downloader-switch-{}.db", uuid::Uuid::new_v4())); let db = format!("sqlite://{}", path.display()); let settings = config(&url_a); let service = Arc::new( Service::new(Store::open(&db).await.unwrap(), &settings) .await .unwrap(), ); let app = test::init_service( App::new() .app_data(web::Data::new(service.clone())) .configure(http::configure), ) .await; let instances: Value = test::call_and_read_body_json( &app, test::TestRequest::get() .uri("/api/downloaders") .to_request(), ) .await; let id_a = instances[0]["id"].as_str().unwrap().to_owned(); let response = test::call_service(&app, test::TestRequest::post().uri("/api/downloaders") .set_json(json!({"name":"Downloader B", "kind":"qbittorrent", "connection":{ "url":url_b, "username":"test", "password":"B-private-password", "save_path":"/downloads" }})).to_request()).await; assert_eq!(response.status(), 201); let body = String::from_utf8(test::read_body(response).await.to_vec()).unwrap(); assert!(!body.contains("B-private-password")); let instance: Value = serde_json::from_str(&body).unwrap(); assert_eq!(instance["password_configured"], true); assert_eq!(instance["is_active"], false); let id_b = instance["id"].as_str().unwrap().to_owned(); let sub_a: Value = test::call_and_read_body_json( &app, test::TestRequest::post() .uri("/api/subscriptions") .set_json(input(&url_a, true)) .to_request(), ) .await; let response = test::call_service( &app, test::TestRequest::post() .uri(&format!( "/api/subscriptions/{}/scan", sub_a["id"].as_str().unwrap() )) .to_request(), ) .await; assert_eq!(response.status(), 200); assert_eq!(remote_a.lock().unwrap().adds, 0); // Still queued when switching. let response = test::call_service( &app, test::TestRequest::put() .uri("/api/downloaders/active") .set_json(json!({"downloader_id":id_b})) .to_request(), ) .await; assert_eq!(response.status(), 204); let sub_b: Value = test::call_and_read_body_json( &app, test::TestRequest::post() .uri("/api/subscriptions") .set_json(input(&url_a, true)) .to_request(), ) .await; let run: Value = test::call_and_read_body_json(&app, test::TestRequest::post().uri("/api/run").to_request()) .await; assert_eq!(run["errors"], json!([])); assert_eq!(remote_a.lock().unwrap().adds, 1); assert_eq!(remote_b.lock().unwrap().adds, 1); // Same hash in different instances must stay separate. let jobs: Vec = test::call_and_read_body_json(&app, test::TestRequest::get().uri("/api/jobs").to_request()) .await; let job_a = jobs .iter() .find(|j| j["subscription_id"] == sub_a["id"]) .unwrap() .clone(); assert_eq!(job_a["downloader_id"], id_a); assert_eq!( jobs.iter() .find(|j| j["subscription_id"] == sub_b["id"]) .unwrap()["downloader_id"], id_b ); remote_a.lock().unwrap().fail_list = true; remote_b.lock().unwrap().torrents[0]["progress"] = json!(0.5); let run: Value = test::call_and_read_body_json(&app, test::TestRequest::post().uri("/api/run").to_request()) .await; assert!(run["errors"][0].as_str().unwrap().contains(&id_a)); let jobs: Vec = test::call_and_read_body_json(&app, test::TestRequest::get().uri("/api/jobs").to_request()) .await; assert_eq!( jobs.iter().find(|j| j["id"] == job_a["id"]).unwrap()["state"], "submitted" ); assert_eq!( jobs.iter() .find(|j| j["subscription_id"] == sub_b["id"]) .unwrap()["state"], "downloading" ); remote_a.lock().unwrap().fail_list = false; remote_a.lock().unwrap().torrents.clear(); test::call_service(&app, test::TestRequest::post().uri("/api/run").to_request()).await; let response = test::call_service( &app, test::TestRequest::post() .uri(&format!( "/api/jobs/{}/retry", job_a["id"].as_str().unwrap() )) .to_request(), ) .await; assert_eq!(response.status(), 204); test::call_service(&app, test::TestRequest::post().uri("/api/run").to_request()).await; assert_eq!(remote_a.lock().unwrap().adds, 2); assert_eq!(remote_b.lock().unwrap().adds, 1); service.store.pool.close().await; drop(app); drop(service); // Startup's original QBIT_URL must not overwrite the persisted global selection. let recovered = Arc::new( Service::new(Store::open(&db).await.unwrap(), &settings) .await .unwrap(), ); let app = test::init_service( App::new() .app_data(web::Data::new(recovered.clone())) .configure(http::configure), ) .await; let status: Value = test::call_and_read_body_json( &app, test::TestRequest::get().uri("/api/status").to_request(), ) .await; assert_eq!(status["active_downloader_id"], id_b); remote_a.lock().unwrap().torrents[0]["progress"] = json!(1.0); remote_b.lock().unwrap().torrents[0]["progress"] = json!(1.0); test::call_service(&app, test::TestRequest::post().uri("/api/run").to_request()).await; let jobs: Vec = test::call_and_read_body_json(&app, test::TestRequest::get().uri("/api/jobs").to_request()) .await; assert!(jobs.iter().all(|j| j["state"] == "completed")); assert_eq!(remote_a.lock().unwrap().adds, 2); assert_eq!(remote_b.lock().unwrap().adds, 1); recovered.store.pool.close().await; drop(app); drop(recovered); std::fs::remove_file(path).unwrap(); server_a.stop(false).await; server_b.stop(false).await; } #[actix_web::test] async fn upgrading_without_original_config_requires_explicit_legacy_claim() { use actix_web::test; use sqlx::sqlite::SqliteConnectOptions; let (url, remote, server) = mock().await; let directory = std::env::temp_dir().join(format!("legacy-downloader-{}", uuid::Uuid::new_v4())); std::fs::create_dir(&directory).unwrap(); let migration_dir = directory.join("migrations"); std::fs::create_dir(&migration_dir).unwrap(); std::fs::write( migration_dir.join("0001_initial.sql"), include_str!("../migrations/0001_initial.sql"), ) .unwrap(); let db_path = directory.join("old.db"); // Build an actual v1 database with its migration checksum, then upgrade through Store::open. let old = sqlx::SqlitePool::connect_with( SqliteConnectOptions::new() .filename(&db_path) .create_if_missing(true), ) .await .unwrap(); sqlx::migrate::Migrator::new(migration_dir.as_path()) .await .unwrap() .run(&old) .await .unwrap(); sqlx::query("INSERT INTO subscriptions(id, spec) VALUES ('legacy-sub', ?)") .bind(serde_json::to_string(&input(&url, true)).unwrap()) .execute(&old) .await .unwrap(); sqlx::query("INSERT INTO download_jobs(id, subscription_id, resource_key, title, season, episode, download_url, info_hash, state) VALUES ('legacy-job', 'legacy-sub', 'legacy-resource', 'Example - 03', 1, 3, ?, ?, 'submitted')") .bind(format!("magnet:?xt=urn:btih:{HASH}")).bind(HASH).execute(&old).await.unwrap(); old.close().await; let mut settings = config(&url); settings.qbit = None; let service = Arc::new( Service::new( Store::open(&format!("sqlite://{}", db_path.display())) .await .unwrap(), &settings, ) .await .unwrap(), ); let app = test::init_service( App::new() .app_data(web::Data::new(service.clone())) .configure(http::configure), ) .await; let status: Value = test::call_and_read_body_json( &app, test::TestRequest::get().uri("/api/status").to_request(), ) .await; assert_eq!(status["legacy_unassigned_jobs"], 1); let sub: Value = test::call_and_read_body_json( &app, test::TestRequest::post() .uri("/api/subscriptions") .set_json(input(&url, true)) .to_request(), ) .await; test::call_service( &app, test::TestRequest::post() .uri(&format!( "/api/subscriptions/{}/scan", sub["id"].as_str().unwrap() )) .to_request(), ) .await; let instance: Value = test::call_and_read_body_json( &app, test::TestRequest::post() .uri("/api/downloaders") .set_json( json!({"name":"Restored original", "kind":"qbittorrent", "connection":{ "url":url, "username":"test", "password":"test", "save_path":"/downloads" }}), ) .to_request(), ) .await; let id = instance["id"].as_str().unwrap(); let response = test::call_service( &app, test::TestRequest::put() .uri("/api/downloaders/active") .set_json(json!({"downloader_id":id})) .to_request(), ) .await; assert_eq!(response.status(), 204); let jobs: Vec = test::call_and_read_body_json(&app, test::TestRequest::get().uri("/api/jobs").to_request()) .await; assert!(jobs.iter().find(|j| j["id"] == "legacy-job").unwrap()["downloader_id"].is_null()); assert_eq!( jobs.iter() .find(|j| j["subscription_id"] == sub["id"]) .unwrap()["downloader_id"], id ); // Restore the original remote task: claiming must reconcile it, not add it again. remote .lock() .unwrap() .torrents .push(json!({"hash": HASH, "progress": 1.0, "state":"uploading"})); let claimed: Value = test::call_and_read_body_json( &app, test::TestRequest::post() .uri(&format!("/api/downloaders/{id}/claim-legacy-jobs")) .to_request(), ) .await; assert_eq!(claimed["claimed"], 1); test::call_service(&app, test::TestRequest::post().uri("/api/run").to_request()).await; let jobs: Vec = test::call_and_read_body_json(&app, test::TestRequest::get().uri("/api/jobs").to_request()) .await; let legacy = jobs.iter().find(|j| j["id"] == "legacy-job").unwrap(); assert_eq!(legacy["downloader_id"], id); assert_eq!(legacy["state"], "completed"); assert_eq!(legacy["legacy_unassigned"], false); assert_eq!(remote.lock().unwrap().adds, 0); let response = test::call_service( &app, test::TestRequest::put() .uri("/api/downloaders/active") .set_json(json!({"downloader_id":"does-not-exist"})) .to_request(), ) .await; assert_eq!(response.status(), 404); let status: Value = test::call_and_read_body_json( &app, test::TestRequest::get().uri("/api/status").to_request(), ) .await; assert_eq!(status["active_downloader_id"], id); assert_eq!(status["legacy_unassigned_jobs"], 0); service.store.pool.close().await; drop(app); drop(service); std::fs::remove_dir_all(directory).unwrap(); server.stop(false).await; } #[actix_web::test] async fn destination_and_mapping_changes_preserve_queued_paths_and_restart_snapshots() { use autocomic_rss::media::{DestinationInput, LocalKind, MappingInput}; let (url, remote, server) = mock().await; let path = std::env::temp_dir().join(format!("destination-snapshots-{}.db", uuid::Uuid::new_v4())); let db = format!("sqlite://{}", path.display()); let settings = config(&url); let service = Service::new(Store::open(&db).await.unwrap(), &settings) .await .unwrap(); let owner = service.store.active_downloader_id().await.unwrap().unwrap(); let a = service .store .create_destination(DestinationInput { name: "A".into(), kind: LocalKind::Local, root_path: "/media/a".into(), }) .await .unwrap(); let b = service .store .create_destination(DestinationInput { name: "B".into(), kind: LocalKind::Local, root_path: "/media/b".into(), }) .await .unwrap(); assert!(service.store.activate_destination(&a.id).await.is_err()); let v1 = service .store .create_mapping( &a.id, MappingInput { downloader_id: owner.clone(), download_root: "/downloads/one".into(), }, ) .await .unwrap(); service.store.activate_destination(&a.id).await.unwrap(); let first = service.store.create(input(&url, true)).await.unwrap(); service.scan_subscription(&first.id).await.unwrap(); let v2 = service .store .create_mapping( &a.id, MappingInput { downloader_id: owner.clone(), download_root: "/downloads/revised".into(), }, ) .await .unwrap(); assert_eq!(v2.version, 2); remote.lock().unwrap().feed = Some(FEED.replace(HASH, &"b".repeat(40))); let second = service.store.create(input(&url, true)).await.unwrap(); service.scan_subscription(&second.id).await.unwrap(); service .store .create_mapping( &b.id, MappingInput { downloader_id: owner.clone(), download_root: "/downloads/two".into(), }, ) .await .unwrap(); service.store.activate_destination(&b.id).await.unwrap(); remote.lock().unwrap().feed = Some(FEED.replace(HASH, &"c".repeat(40))); let third = service.store.create(input(&url, true)).await.unwrap(); service.scan_subscription(&third.id).await.unwrap(); let report = service.cycle().await.unwrap(); assert!(report.errors.is_empty(), "{:?}", report.errors); assert_eq!( remote.lock().unwrap().save_paths, vec!["/downloads/one", "/downloads/revised", "/downloads/two"] ); let jobs = service.store.jobs().await.unwrap(); let original = jobs .iter() .find(|job| job.subscription_id == first.id) .unwrap(); assert_eq!(original.destination_id.as_deref(), Some(a.id.as_str())); assert_eq!(original.mapping_id.as_deref(), Some(v1.id.as_str())); let revised = jobs .iter() .find(|job| job.subscription_id == second.id) .unwrap(); assert_eq!(revised.mapping_id.as_deref(), Some(v2.id.as_str())); service.store.pool.close().await; drop(service); let recovered = Service::new(Store::open(&db).await.unwrap(), &settings) .await .unwrap(); assert_eq!( recovered .store .active_destination_id() .await .unwrap() .as_deref(), Some(b.id.as_str()) ); let original = recovered.store.job(&original.id).await.unwrap(); assert_eq!( original.download_save_path.as_deref(), Some("/downloads/one") ); assert_eq!(original.destination_id.as_deref(), Some(a.id.as_str())); recovered.cycle().await.unwrap(); assert_eq!(remote.lock().unwrap().adds, 3); // Identical hash in the same instance but another destination cannot be reused or moved. remote.lock().unwrap().feed = Some(FEED.into()); let collision = recovered.store.create(input(&url, true)).await.unwrap(); recovered.cycle().await.unwrap(); let collision = recovered .store .jobs() .await .unwrap() .into_iter() .find(|j| j.subscription_id == collision.id) .unwrap(); assert_eq!(collision.state, "failed"); assert!(collision.last_error.unwrap().contains("save path")); assert_eq!(remote.lock().unwrap().adds, 3); let other = recovered .store .create_downloader(autocomic_rss::domain::DownloaderInput { name: "unmapped".into(), kind: autocomic_rss::domain::DownloaderKind::Qbittorrent, connection: config("http://127.0.0.1:1").qbit.unwrap(), }) .await .unwrap(); assert!(recovered .store .activate_downloader(&other.id) .await .is_err()); assert_eq!( recovered .store .active_downloader_id() .await .unwrap() .as_deref(), Some(owner.as_str()) ); recovered.store.pool.close().await; drop(recovered); std::fs::remove_file(path).unwrap(); server.stop(false).await; } #[actix_web::test] async fn file_checks_keep_download_completion_and_last_success_when_storage_is_unavailable() { use actix_web::test; use autocomic_rss::media::{DestinationInput, LocalKind, MappingInput}; let (url, remote, server) = mock().await; let directory = std::env::temp_dir().join(format!("local-media-{}", uuid::Uuid::new_v4())); let root = directory.join("media"); std::fs::create_dir_all(root.join("Series")).unwrap(); std::fs::write(root.join("Series/ep03.mkv"), b"abc").unwrap(); let db = format!("sqlite://{}", directory.join("app.db").display()); let settings = config(&url); let service = Arc::new( Service::new(Store::open(&db).await.unwrap(), &settings) .await .unwrap(), ); let owner = service.store.active_downloader_id().await.unwrap().unwrap(); let destination = service .store .create_destination(DestinationInput { name: "local".into(), kind: LocalKind::Local, root_path: root.to_str().unwrap().into(), }) .await .unwrap(); service .store .create_mapping( &destination.id, MappingInput { downloader_id: owner, download_root: "/downloads/media".into(), }, ) .await .unwrap(); service .store .activate_destination(&destination.id) .await .unwrap(); service.store.create(input(&url, true)).await.unwrap(); service.cycle().await.unwrap(); remote.lock().unwrap().torrents[0]["progress"] = json!(1.0); service.cycle().await.unwrap(); let job = service.store.jobs().await.unwrap().remove(0); assert_eq!(job.state, "completed"); let app = test::init_service( App::new() .app_data(web::Data::new(service.clone())) .configure(http::configure), ) .await; let report_url = format!("/api/jobs/{}/files", job.id); let check_url = format!("{report_url}/check"); let initial: Value = test::call_and_read_body_json(&app, test::TestRequest::get().uri(&report_url).to_request()) .await; assert_eq!(initial["status"], "unchecked"); let present: Value = test::call_and_read_body_json(&app, test::TestRequest::post().uri(&check_url).to_request()) .await; assert_eq!(present["status"], "present"); assert_eq!(present["files"].as_array().unwrap().len(), 1); // Skip priority-zero files. assert_eq!(present["files"][0]["observed_size"], 3); std::fs::remove_file(root.join("Series/ep03.mkv")).unwrap(); let absent: Value = test::call_and_read_body_json(&app, test::TestRequest::post().uri(&check_url).to_request()) .await; assert_eq!(absent["status"], "absent"); std::fs::write(root.join("Series/ep03.mkv"), b"x").unwrap(); let partial: Value = test::call_and_read_body_json(&app, test::TestRequest::post().uri(&check_url).to_request()) .await; assert_eq!(partial["status"], "present"); // Presence is not integrity or download completion. assert_eq!(partial["files"][0]["expected_size"], 3); assert_eq!(partial["files"][0]["observed_size"], 1); tokio::time::sleep(Duration::from_millis(1100)).await; std::fs::rename(&root, directory.join("offline")).unwrap(); let unavailable: Value = test::call_and_read_body_json(&app, test::TestRequest::post().uri(&check_url).to_request()) .await; assert_eq!(unavailable["status"], "unavailable"); assert_eq!(unavailable["last_success_at"], partial["last_success_at"]); assert!( unavailable["checked_at"].as_i64().unwrap() > partial["last_success_at"].as_i64().unwrap() ); assert_eq!(service.store.job(&job.id).await.unwrap().state, "completed"); assert_eq!(remote.lock().unwrap().adds, 1); service.store.pool.close().await; drop(app); drop(service); server.stop(false).await; let recovered = Service::new(Store::open(&db).await.unwrap(), &settings) .await .unwrap(); assert_eq!( recovered.store.file_report(&job.id).await.unwrap().status, "unavailable" ); std::fs::rename(directory.join("offline"), &root).unwrap(); let report = recovered.check_job_files(&job.id).await.unwrap(); assert_eq!(report.status, "present"); // Saved paths remain checkable with downloader offline. assert_eq!( recovered.store.job(&job.id).await.unwrap().state, "completed" ); recovered.store.pool.close().await; drop(recovered); std::fs::remove_dir_all(directory).unwrap(); } #[actix_web::test] async fn unsafe_downloader_paths_and_unreadable_files_are_unavailable_not_missing() { use autocomic_rss::media::{DestinationInput, LocalKind, MappingInput}; let (url, remote, server) = mock().await; let directory = std::env::temp_dir().join(format!("media-confinement-{}", uuid::Uuid::new_v4())); let root = directory.join("root"); std::fs::create_dir_all(root.join("restricted")).unwrap(); std::fs::create_dir_all(directory.join("outside")).unwrap(); std::fs::write(directory.join("outside/secret"), b"secret").unwrap(); std::fs::write(root.join("restricted/file.mkv"), b"abc").unwrap(); #[cfg(unix)] std::os::unix::fs::symlink(directory.join("outside"), root.join("escape")).unwrap(); let service = Service::new(Store::open("sqlite::memory:").await.unwrap(), &config(&url)) .await .unwrap(); let owner = service.store.active_downloader_id().await.unwrap().unwrap(); let destination = service .store .create_destination(DestinationInput { name: "local".into(), kind: LocalKind::Local, root_path: root.to_str().unwrap().into(), }) .await .unwrap(); service .store .create_mapping( &destination.id, MappingInput { downloader_id: owner, download_root: "/downloads/media".into(), }, ) .await .unwrap(); service .store .activate_destination(&destination.id) .await .unwrap(); // Each subscription is a distinct task pointing to the same already-complete test torrent. remote.lock().unwrap().torrents.push( json!({"hash":HASH, "state":"uploading", "progress":1.0, "save_path":"/downloads/media"}), ); for path in [ "../outside/secret", "/absolute", "C:\\outside", "folder/../outside", "", ] { remote.lock().unwrap().files = Some(vec![json!({"name":path,"size":3,"priority":1})]); let sub = service.store.create(input(&url, true)).await.unwrap(); service.cycle().await.unwrap(); let job = service .store .jobs() .await .unwrap() .into_iter() .find(|j| j.subscription_id == sub.id) .unwrap(); let report = service.check_job_files(&job.id).await.unwrap(); assert_eq!(report.status, "unavailable", "{path}"); assert!(report.files.is_empty()); // No partial or unsafe manifest is stored. assert_eq!(service.store.job(&job.id).await.unwrap().state, "completed"); } #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; for path in ["escape/secret", "restricted/file.mkv", "restricted"] { remote.lock().unwrap().files = Some(vec![json!({"name":path,"size":3,"priority":1})]); let sub = service.store.create(input(&url, true)).await.unwrap(); service.cycle().await.unwrap(); let job = service .store .jobs() .await .unwrap() .into_iter() .find(|j| j.subscription_id == sub.id) .unwrap(); let restricted = root.join("restricted"); let permissions = std::fs::metadata(&restricted).unwrap().permissions(); if path == "restricted/file.mkv" { std::fs::set_permissions(&restricted, std::fs::Permissions::from_mode(0o000)) .unwrap(); } let permission_enforced = std::fs::read_dir(&restricted).is_err(); let report = service.check_job_files(&job.id).await.unwrap(); std::fs::set_permissions(&restricted, permissions).unwrap(); // Root-owned CI processes may bypass chmod; other cases always enforce confinement. if path != "restricted/file.mkv" || permission_enforced { assert_eq!(report.status, "unavailable", "{path}"); assert_eq!(report.files[0].status, "unavailable"); } assert_eq!(service.store.job(&job.id).await.unwrap().state, "completed"); } } assert_eq!(remote.lock().unwrap().adds, 0); service.store.pool.close().await; drop(service); std::fs::remove_dir_all(directory).unwrap(); server.stop(false).await; }