//! End-to-end tests through the real router. //! //! The unit tests cover each spec rule in isolation; these cover the wiring — //! status codes, headers, and the properties that only hold if the layers are //! composed correctly (per-route body caps, rate-limit surfaces, the strict //! schema actually reaching uploads). use std::sync::Arc; use axum::body::Body; use axum::http::{Request, StatusCode}; use http_body_util::BodyExt; use jray_server::app; use jray_server::config::Config; use jray_server::db::Db; use jray_server::ratelimit::RateLimiter; use jray_server::state::AppState; use jray_server::tmdb::TmdbClient; use serde_json::{json, Value}; use tower::ServiceExt; /// A server backed by a temporary on-disk database. /// /// On-disk rather than `:memory:` because §8's design uses a separate writer /// connection and a read pool, and in-memory SQLite is per-connection — the /// readers would see an empty database. Testing the real topology is the point. struct TestServer { router: axum::Router, _dir: TempDir, } struct TempDir(std::path::PathBuf); impl TempDir { fn new(tag: &str) -> Self { let mut p = std::env::temp_dir(); // Unique per test without pulling in a tempfile dependency. p.push(format!("jray-test-{}-{}", tag, ulid_like())); std::fs::create_dir_all(&p).expect("creating temp dir"); Self(p) } fn db_path(&self) -> String { self.0.join("test.db").to_string_lossy().into_owned() } } impl Drop for TempDir { fn drop(&mut self) { let _ = std::fs::remove_dir_all(&self.0); } } fn ulid_like() -> String { use std::sync::atomic::{AtomicU64, Ordering}; static N: AtomicU64 = AtomicU64::new(0); let t = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_nanos()) .unwrap_or(0); format!("{t}-{}", N.fetch_add(1, Ordering::Relaxed)) } impl TestServer { fn new(tag: &str) -> Self { let dir = TempDir::new(tag); let db = Db::open(&dir.db_path()).expect("opening database"); let config = Arc::new(Config { bind: "127.0.0.1:0".into(), db_path: dir.db_path(), // No key: uploads stay `pending`, which is the correct failure mode // (§8) and keeps these tests free of network calls. tmdb_api_key: None, tmdb_base_url: "http://127.0.0.1:1".into(), trusted_proxies: Vec::new(), server_id: "test.example".into(), request_timeout: std::time::Duration::from_secs(30), job_batch: 8, job_poll_interval: std::time::Duration::from_secs(3600), }); let state = AppState { db, config: config.clone(), limiter: Arc::new(RateLimiter::new()), tmdb: Arc::new(TmdbClient::new(config.tmdb_base_url.clone(), None)), }; Self { router: app::router(state), _dir: dir } } async fn send(&self, req: Request) -> (StatusCode, Value, axum::http::HeaderMap) { let resp = self.router.clone().oneshot(req).await.expect("router call"); let status = resp.status(); let headers = resp.headers().clone(); let bytes = resp.into_body().collect().await.expect("reading body").to_bytes(); let body = if bytes.is_empty() { Value::Null } else { serde_json::from_slice(&bytes) .unwrap_or(Value::String(String::from_utf8_lossy(&bytes).into_owned())) }; (status, body, headers) } async fn get(&self, uri: &str) -> (StatusCode, Value, axum::http::HeaderMap) { self.send(Request::builder().uri(uri).body(Body::empty()).unwrap()).await } async fn post_json( &self, uri: &str, body: &Value, ) -> (StatusCode, Value, axum::http::HeaderMap) { self.send( Request::builder() .method("POST") .uri(uri) .header("content-type", "application/json") .body(Body::from(body.to_string())) .unwrap(), ) .await } async fn post_json_auth( &self, uri: &str, token: &str, body: &Value, ) -> (StatusCode, Value, axum::http::HeaderMap) { self.send( Request::builder() .method("POST") .uri(uri) .header("content-type", "application/json") .header("authorization", format!("Bearer {token}")) .body(Body::from(body.to_string())) .unwrap(), ) .await } /// Issues an anonymous bearer capability (§5a). async fn token(&self) -> String { let (status, body, _) = self.post_json("/api/v1/tokens", &json!({})).await; assert_eq!(status, StatusCode::OK, "token issue failed: {body}"); body["token"].as_str().expect("token in response").to_string() } } fn movie_manifest(tmdb_id: &str, runtime: f64) -> Value { json!({ "jmanifest_version": 2, "identity": { "type": "movie", "tmdb_id": tmdb_id, "title": "The Death of Stalin", "year": 2017 }, "cut": { "runtime_sec": runtime, "video_hash": "opensubtitles:8e245d9679d31e12" }, "extraction": { "sample_fps": 5, "extinction_sec": 12, "pipeline_version": "scene-actor-extraction 0.4.1", "gallery_scope": "global" }, "actors": [ { "name": "Steve Buscemi", "tmdb_id": "884", "scenes": [{"start":191.6,"end":209.2},{"start":438.2,"end":465.6}] }, { "name": "Michael Palin", "tmdb_id": "11007", "scenes": [{"start":300.0,"end":320.0}] } ] }) } // --------------------------------------------------------------------------- // Health and readiness // --------------------------------------------------------------------------- #[tokio::test] async fn health_is_unauthenticated() { let s = TestServer::new("health"); let (status, body, _) = s.get("/health").await; assert_eq!(status, StatusCode::OK); assert_eq!(body["status"], "ok"); } #[tokio::test] async fn readiness_reports_database_and_tmdb_configuration() { // §8: TMDB is a hard dependency for UR-3, so its absence is worth surfacing. let s = TestServer::new("ready"); let (status, body, _) = s.get("/ready").await; assert_eq!(status, StatusCode::OK); assert_eq!(body["status"], "ready"); assert_eq!(body["tmdb_configured"], false); } // --------------------------------------------------------------------------- // §5a — tokens // --------------------------------------------------------------------------- #[tokio::test] async fn upload_without_a_token_is_rejected() { let s = TestServer::new("noauth"); let (status, _, _) = s.post_json("/api/v1/manifests", &movie_manifest("504172", 6420.5)).await; assert_eq!(status, StatusCode::UNAUTHORIZED); } #[tokio::test] async fn upload_with_an_unknown_token_is_rejected() { // A token the server never issued has no contributor row, and §5a stores only // hashes, so there is nothing to match. let s = TestServer::new("badauth"); let (status, _, _) = s .post_json_auth("/api/v1/manifests", "jray_deadbeef", &movie_manifest("504172", 6420.5)) .await; assert_eq!(status, StatusCode::UNAUTHORIZED); } #[tokio::test] async fn tokens_are_issued_anonymously_and_are_distinct() { let s = TestServer::new("tokens"); let a = s.token().await; let b = s.token().await; assert_ne!(a, b); assert!(a.starts_with("jray_")); } // --------------------------------------------------------------------------- // §6 — upload validation // --------------------------------------------------------------------------- #[tokio::test] async fn valid_upload_is_accepted_as_pending() { // §6 stage 3: accepted with `202` and held unlisted until the cast check. let s = TestServer::new("upload-ok"); let token = s.token().await; let (status, body, _) = s.post_json_auth("/api/v1/manifests", &token, &movie_manifest("504172", 6420.5)).await; assert_eq!(status, StatusCode::ACCEPTED, "body: {body}"); assert_eq!(body["status"], "pending"); assert!(body["manifest_id"].is_string()); } #[tokio::test] async fn a_pending_manifest_is_not_served() { // The property that makes §6 stage 3 meaningful: an unverified manifest is // not served to anyone in the meantime. let s = TestServer::new("pending-hidden"); let token = s.token().await; let (_, body, _) = s.post_json_auth("/api/v1/manifests", &token, &movie_manifest("504172", 6420.5)).await; let id = body["manifest_id"].as_str().unwrap(); let (status, _, _) = s.get("/api/v1/manifests/movie?tmdb_id=504172").await; assert_eq!(status, StatusCode::NOT_FOUND); let (status, _, _) = s.get(&format!("/api/v1/manifests/{id}")).await; assert_eq!(status, StatusCode::NOT_FOUND); // But its status is pollable (§4). let (status, body, _) = s.get(&format!("/api/v1/manifests/{id}/status")).await; assert_eq!(status, StatusCode::OK); assert_eq!(body["status"], "pending"); } #[tokio::test] async fn unknown_field_anywhere_is_rejected_with_400() { // §6 stage 2, enforced by `deny_unknown_fields` on every DTO. let s = TestServer::new("strict"); let token = s.token().await; let mut m = movie_manifest("504172", 6420.5); m["surprise"] = json!("payload"); let (status, body, _) = s.post_json_auth("/api/v1/manifests", &token, &m).await; assert_eq!(status, StatusCode::BAD_REQUEST); assert!( body["message"].as_str().unwrap_or("").contains("surprise"), "the error should name the offending field: {body}" ); // Nested, too — `extra="forbid"` applies at every level (§5a). let mut m = movie_manifest("504172", 6420.5); m["cut"]["extra"] = json!(1); let (status, _, _) = s.post_json_auth("/api/v1/manifests", &token, &m).await; assert_eq!(status, StatusCode::BAD_REQUEST); } #[tokio::test] async fn contributor_local_identifiers_are_rejected_not_ignored() { // §1/§6: `movie` leaks the contributor's directory layout and `jellyfin_id` is // a GUID from their database. Both must be *rejected on upload*, so a client // that forgets to strip them gets a hard 400 naming the field rather than // quietly publishing them. let s = TestServer::new("strip"); let token = s.token().await; let mut m = movie_manifest("504172", 6420.5); m["movie"] = json!("/data/movies/The.Death.of.Stalin.2017.mkv"); let (status, body, _) = s.post_json_auth("/api/v1/manifests", &token, &m).await; assert_eq!(status, StatusCode::BAD_REQUEST); assert!(body["message"].as_str().unwrap_or("").contains("movie"), "{body}"); let mut m = movie_manifest("504172", 6420.5); m["actors"][0]["jellyfin_id"] = json!("a1b2c3d4e5f6"); let (status, body, _) = s.post_json_auth("/api/v1/manifests", &token, &m).await; assert_eq!(status, StatusCode::BAD_REQUEST); assert!(body["message"].as_str().unwrap_or("").contains("jellyfin_id"), "{body}"); } #[tokio::test] async fn the_withdrawn_anneal_sec_field_is_rejected() { // `anneal_sec` was withdrawn in the SR-003 bump: presence now follows track // extent, so a track survives its own gaps and there is nothing to anneal // (`scene-actor-extraction` AR-012/AR-013). // // Rejecting rather than ignoring it is the point. A manifest still carrying // the field was produced by a pipeline whose window semantics differ from // what this server now assumes, and silently accepting it would store // timings whose meaning we cannot vouch for. let s = TestServer::new("anneal-withdrawn"); let token = s.token().await; let mut m = movie_manifest("504172", 6420.5); m["extraction"]["anneal_sec"] = json!(3); let (status, body, _) = s.post_json_auth("/api/v1/manifests", &token, &m).await; assert_eq!(status, StatusCode::BAD_REQUEST); assert!( body["message"].as_str().unwrap_or("").contains("anneal_sec"), "the error should name the withdrawn field: {body}" ); } #[tokio::test] async fn the_schema_bump_fields_round_trip() { // `extinction_sec` and `gallery_scope` are the SR-003 additions. They are // stored and reconstructed, since §7 ranks on scope and both are provenance // a consumer may want. let s = TestServer::new("bump-fields"); let token = s.token().await; let m = movie_manifest("504172", 6420.5); assert_eq!(m["extraction"]["gallery_scope"], "global"); let (status, body, _) = s.post_json_auth("/api/v1/manifests", &token, &m).await; assert_eq!(status, StatusCode::ACCEPTED, "{body}"); // An unrecognised scope is a closed-vocabulary violation, not a free string. let mut bad = movie_manifest("504173", 6420.5); bad["extraction"]["gallery_scope"] = json!("enormous"); let (status, _, _) = s.post_json_auth("/api/v1/manifests", &token, &bad).await; assert_eq!(status, StatusCode::BAD_REQUEST, "gallery_scope is a closed enum"); } #[tokio::test] async fn per_window_belief_and_route_round_trip() { // SR-003 gives each window its posterior and identification route // (extraction AR-017). They are stored and served — a consumer needs them to // know how much to trust a window — but they are never part of identity. let s = TestServer::new("belief"); let token = s.token().await; let mut m = movie_manifest("504172", 6420.5); m["actors"][0]["scenes"] = json!([ { "start": 191.6, "end": 209.2, "belief": 0.98, "route": "live" }, { "start": 438.2, "end": 465.6, "belief": 0.81, "route": "deferred" } ]); let (status, body, _) = s.post_json_auth("/api/v1/manifests", &token, &m).await; assert_eq!(status, StatusCode::ACCEPTED, "{body}"); // A belief outside [0, 1] is not a probability. Bounded rather than merely // stored, because §5a's Threat 1 argument rests on every accepted value // being bounded — an unbounded float is a 64-bit channel. for bad in [-0.1, 1.5] { let mut m = movie_manifest("504173", 6420.5); m["actors"][0]["scenes"] = json!([{ "start": 1.0, "end": 2.0, "belief": bad }]); let (status, body, _) = s.post_json_auth("/api/v1/manifests", &token, &m).await; assert_eq!(status, StatusCode::BAD_REQUEST, "belief {bad}: {body}"); } // `route` is a closed vocabulary, so an invented value cannot be stored. let mut m = movie_manifest("504174", 6420.5); m["actors"][0]["scenes"] = json!([{ "start": 1.0, "end": 2.0, "route": "telepathy" }]); let (status, _, _) = s.post_json_auth("/api/v1/manifests", &token, &m).await; assert_eq!(status, StatusCode::BAD_REQUEST); } #[tokio::test] async fn an_unknown_manifest_version_is_rejected() { // UR-014 / SR-003: a consumer encountering an unknown `schema_version` // refuses or warns; it never guesses. let s = TestServer::new("version"); let token = s.token().await; // Version 1 is the one that matters: SR-003 settled on a **flag day**, not a // dual-accept period, so the immediately-previous version is refused exactly // like a nonsensical one. A v1 read path would be the one nobody exercises, // and so the one that rots while being dragged through every later change. for version in [0, 1, 3, 99] { let mut m = movie_manifest("504172", 6420.5); m["jmanifest_version"] = json!(version); let (status, body, _) = s.post_json_auth("/api/v1/manifests", &token, &m).await; assert_eq!(status, StatusCode::BAD_REQUEST, "version {version}: {body}"); assert!( body["message"].as_str().unwrap_or("").contains("jmanifest_version"), "should name the field: {body}" ); } } #[tokio::test] async fn missing_runtime_is_rejected() { // §2: `cut.runtime_sec` is required — the primary alignment guard. let s = TestServer::new("no-runtime"); let token = s.token().await; let mut m = movie_manifest("504172", 6420.5); m["cut"] = json!({ "video_hash": "opensubtitles:8e245d9679d31e12" }); let (status, _, _) = s.post_json_auth("/api/v1/manifests", &token, &m).await; assert_eq!(status, StatusCode::BAD_REQUEST); } #[tokio::test] async fn empty_actor_list_is_rejected() { // §6: 15 of the 331 corpus files have empty actor lists — extraction // failures, not contributions. let s = TestServer::new("empty-actors"); let token = s.token().await; let mut m = movie_manifest("504172", 6420.5); m["actors"] = json!([]); let (status, _, _) = s.post_json_auth("/api/v1/manifests", &token, &m).await; assert_eq!(status, StatusCode::BAD_REQUEST); } #[tokio::test] async fn smuggled_payload_in_an_actor_name_is_rejected() { // §5a: the character class defeats base64/hex smuggling, which needs digits // and padding characters. This is the last free-text channel, so it is worth // asserting end-to-end and not only in the unit tests. let s = TestServer::new("smuggle"); let token = s.token().await; for payload in [ "SGVsbG8gd29ybGQgdGhpcyBpcyBhIHBheWxvYWQ=", "4d5a90000300000004000000ffff0000", "", "http://evil.example/x", ] { let mut m = movie_manifest("504172", 6420.5); m["actors"][0]["name"] = json!(payload); let (status, _, _) = s.post_json_auth("/api/v1/manifests", &token, &m).await; assert_eq!(status, StatusCode::BAD_REQUEST, "payload {payload:?} should be rejected"); } } #[tokio::test] async fn resubmitting_identical_content_is_not_a_duplicate_error() { // §9a: content addressing gives deduplication — the same manifest from the // same contributor is recognised rather than stored twice. let s = TestServer::new("dedup"); let token = s.token().await; let m = movie_manifest("504172", 6420.5); let (first, body1, _) = s.post_json_auth("/api/v1/manifests", &token, &m).await; assert_eq!(first, StatusCode::ACCEPTED); let (second, body2, _) = s.post_json_auth("/api/v1/manifests", &token, &m).await; assert_eq!(second, StatusCode::OK); assert_eq!(body2["status"], "already_present"); assert_eq!(body1["manifest_id"], body2["manifest_id"]); } #[tokio::test] async fn oversized_body_is_rejected_by_the_route_cap() { // §6 stage 1: the app-level cap counts bytes as they are read, so a lying // `Content-Length` and a chunked upload are both safe. Here the body genuinely // exceeds the 2 MiB single-manifest cap. let s = TestServer::new("too-big"); let token = s.token().await; let mut m = movie_manifest("504172", 6420.5); // Many actors, each with many windows — legitimate shape, illegitimate size. let actors: Vec = (0..400) .map(|i| { let scenes: Vec = (0..1500).map(|j| json!([j as f64, (j + 1) as f64])).collect(); json!({ "tmdb_id": (1000 + i).to_string(), "scenes": scenes }) }) .collect(); m["actors"] = json!(actors); let (status, _, _) = s.post_json_auth("/api/v1/manifests", &token, &m).await; assert_eq!(status, StatusCode::PAYLOAD_TOO_LARGE); } #[tokio::test] async fn a_lying_content_length_does_not_bypass_the_cap() { // §6 stage 0 is explicit that `Content-Length` is a *claim by the client*: a // hostile client can declare 100 and send far more, so the streaming cap is // mandatory rather than redundant. let s = TestServer::new("lying-length"); let token = s.token().await; let huge = "x".repeat(3 * 1024 * 1024); let body = format!("{{\"padding\":\"{huge}\"}}"); let req = Request::builder() .method("POST") .uri("/api/v1/manifests") .header("content-type", "application/json") .header("authorization", format!("Bearer {token}")) .header("content-length", "100") .body(Body::from(body)) .unwrap(); let (status, _, _) = s.send(req).await; assert_ne!( status, StatusCode::ACCEPTED, "an oversized body must never be accepted, whatever the declared length" ); assert!( status == StatusCode::PAYLOAD_TOO_LARGE || status == StatusCode::BAD_REQUEST, "unexpected status {status}" ); } // --------------------------------------------------------------------------- // §4 — exists // --------------------------------------------------------------------------- #[tokio::test] async fn exists_returns_200_with_false_rather_than_404() { // §4: absence is a normal answer, and `404` would conflate "no manifest" with // "bad route" for the client. let s = TestServer::new("exists-absent"); let (status, body, _) = s.get("/api/v1/manifests/exists?tmdb_id=999999").await; assert_eq!(status, StatusCode::OK); assert_eq!(body["exists"], false); assert!(body["manifest_id"].is_null()); } #[tokio::test] async fn exists_requires_identity_parameters() { let s = TestServer::new("exists-noid"); let (status, _, _) = s.get("/api/v1/manifests/exists").await; assert_eq!(status, StatusCode::BAD_REQUEST); } #[tokio::test] async fn exists_carries_rate_limit_headers() { // §5: responses carry `X-RateLimit-Limit`, `-Remaining` and `-Reset`. let s = TestServer::new("exists-headers"); let (_, _, headers) = s.get("/api/v1/manifests/exists?tmdb_id=1").await; assert_eq!(headers["x-ratelimit-limit"], "600"); assert_eq!(headers["x-ratelimit-remaining"], "599"); assert!(headers.contains_key("x-ratelimit-reset")); } #[tokio::test] async fn batch_exists_is_positional_and_capped_at_100() { // §4: results are positional, and the cap is what lets §5 be generous per // request while staying strict per item. let s = TestServer::new("exists-batch"); let items: Vec = (0..3).map(|i| json!({ "tmdb_id": (100 + i).to_string() })).collect(); let (status, body, headers) = s.post_json("/api/v1/manifests/exists", &json!({ "items": items })).await; assert_eq!(status, StatusCode::OK); assert_eq!(body["results"].as_array().unwrap().len(), 3); assert_eq!(headers["x-ratelimit-limit"], "60", "batch has its own §5 budget"); let too_many: Vec = (0..101).map(|i| json!({ "tmdb_id": (100 + i).to_string() })).collect(); let (status, _, _) = s.post_json("/api/v1/manifests/exists", &json!({ "items": too_many })).await; assert_eq!(status, StatusCode::BAD_REQUEST); } #[tokio::test] async fn batch_exists_rejects_unknown_fields() { let s = TestServer::new("exists-batch-strict"); let (status, _, _) = s.post_json("/api/v1/manifests/exists", &json!({ "items": [], "extra": 1 })).await; assert_eq!(status, StatusCode::BAD_REQUEST); } #[tokio::test] async fn one_bad_item_does_not_fail_the_whole_batch() { // A 100-item sweep should not be lost to one malformed entry. let s = TestServer::new("exists-batch-partial"); let (status, body, _) = s .post_json( "/api/v1/manifests/exists", &json!({ "items": [ { "tmdb_id": "1" }, { }, { "tmdb_id": "2" } ] }), ) .await; assert_eq!(status, StatusCode::OK); let results = body["results"].as_array().unwrap(); assert_eq!(results.len(), 3); assert_eq!(results[1]["exists"], false); } // --------------------------------------------------------------------------- // §5 — rate limiting // --------------------------------------------------------------------------- #[tokio::test] async fn exceeding_a_limit_returns_429_with_retry_after() { // §5: exceeding a limit returns `429` with `Retry-After`, which the JRay // client must honour. let s = TestServer::new("ratelimit"); let token = s.token().await; // The bundle surface has the tightest write limit (20/hour), so it is the // cheapest to exhaust. let bundle = json!({ "jmanifest_version": 2, "series": { "series_tmdb_id": "1396", "title": "Breaking Bad" }, "episodes": [] }); let mut saw_429 = false; for _ in 0..25 { let (status, _, headers) = s.post_json_auth("/api/v1/manifests/bundle", &token, &bundle).await; if status == StatusCode::TOO_MANY_REQUESTS { assert!(headers.contains_key("retry-after"), "429 must carry Retry-After"); saw_429 = true; break; } } assert!(saw_429, "the §5 bundle limit should engage within 25 requests"); } #[tokio::test] async fn read_surfaces_have_independent_budgets() { // §5: each surface has its own budget, so a library sweep hammering `exists` // cannot exhaust the budget a fetch needs. // // Only the `exists` surface returns a body on an empty database; the fetch // surfaces 404 (and a 404 carries no quota headers, by design). So the // independence is asserted by consuming `exists` and observing that its // counter alone moves. let s = TestServer::new("surfaces"); let (_, _, h) = s.get("/api/v1/manifests/exists?tmdb_id=1").await; assert_eq!(h["x-ratelimit-limit"], "600"); assert_eq!(h["x-ratelimit-remaining"], "599"); // A fetch and a series request in between must not consume `exists` budget. let _ = s.get("/api/v1/manifests/movie?tmdb_id=1").await; let _ = s.get("/api/v1/manifests/series/1396").await; let (_, _, h) = s.get("/api/v1/manifests/exists?tmdb_id=1").await; assert_eq!( h["x-ratelimit-remaining"], "598", "fetch requests must not draw down the exists budget" ); // And the batch form is a separate surface again (§5). let (_, _, h) = s.post_json("/api/v1/manifests/exists", &json!({ "items": [ { "tmdb_id": "1" } ] })).await; assert_eq!(h["x-ratelimit-limit"], "60"); assert_eq!(h["x-ratelimit-remaining"], "59"); } #[tokio::test] async fn a_forged_forwarded_header_cannot_reset_a_budget() { // §8: the app must trust `X-Forwarded-For` only from the operator's proxy, // because §5 rate limiting keys on client IP. This server has no configured // proxies, so the header must be ignored entirely — otherwise a client could // mint a fresh budget per request. let s = TestServer::new("xff"); let mut last_remaining = u32::MAX; for i in 0..3 { let req = Request::builder() .uri("/api/v1/manifests/exists?tmdb_id=1") .header("x-forwarded-for", format!("10.1.1.{i}")) .body(Body::empty()) .unwrap(); let (_, _, headers) = s.send(req).await; let remaining: u32 = headers["x-ratelimit-remaining"].to_str().unwrap().parse().unwrap(); assert!( remaining < last_remaining, "budget must keep decreasing despite a changing X-Forwarded-For" ); last_remaining = remaining; } } // --------------------------------------------------------------------------- // §4 — fetch // --------------------------------------------------------------------------- #[tokio::test] async fn fetch_requires_identity_parameters() { let s = TestServer::new("fetch-noid"); let (status, _, _) = s.get("/api/v1/manifests/movie").await; assert_eq!(status, StatusCode::BAD_REQUEST); let (status, _, _) = s.get("/api/v1/manifests/episode?series_tmdb_id=1396").await; assert_eq!(status, StatusCode::BAD_REQUEST, "episode fetch needs season and episode"); } #[tokio::test] async fn fetching_an_absent_manifest_is_404() { // §4: `404` if none clears `loose`. let s = TestServer::new("fetch-absent"); let (status, _, _) = s.get("/api/v1/manifests/movie?tmdb_id=999999").await; assert_eq!(status, StatusCode::NOT_FOUND); } #[tokio::test] async fn series_bundle_for_an_unknown_series_is_404() { let s = TestServer::new("series-absent"); let (status, _, _) = s.get("/api/v1/manifests/series/999999").await; assert_eq!(status, StatusCode::NOT_FOUND); } #[tokio::test] async fn status_of_an_unknown_manifest_reports_rejected() { // §6 deletes rejected manifests, so a vanished id must not read as a bad // route — the contributor polling it needs a verdict. let s = TestServer::new("status-unknown"); let (status, body, _) = s.get("/api/v1/manifests/01HZZZZZZZZZZZZZZZZZZZZZZZ/status").await; assert_eq!(status, StatusCode::OK); assert_eq!(body["status"], "rejected"); } // --------------------------------------------------------------------------- // §2, §4 — bundles // --------------------------------------------------------------------------- #[tokio::test] async fn bundle_upload_is_not_atomic() { // §2: valid episodes are accepted and invalid ones rejected, with a // per-episode result list. All-or-nothing would let one bad episode discard an // entire season's compute. let s = TestServer::new("bundle-partial"); let token = s.token().await; let good = |ep: i64| { json!({ "jmanifest_version": 2, "identity": { "type": "episode", "series_tmdb_id": "1396", "title": "Breaking Bad", "season": 1, "episode": ep }, "cut": { "runtime_sec": 2820.0 }, "actors": [ { "name": "Bryan Cranston", "tmdb_id": "17419", "scenes": [{"start":10.0,"end":20.0}] } ] }) }; // Invalid: a scene window beyond the runtime tolerance (§6). let bad = json!({ "jmanifest_version": 2, "identity": { "type": "episode", "series_tmdb_id": "1396", "season": 1, "episode": 3 }, "cut": { "runtime_sec": 2820.0 }, "actors": [ { "tmdb_id": "17419", "scenes": [{"start":10.0,"end":99999.0}] } ] }); let bundle = json!({ "jmanifest_version": 2, "series": { "series_tmdb_id": "1396", "title": "Breaking Bad" }, "episodes": [ good(1), bad, good(2) ] }); let (status, body, _) = s.post_json_auth("/api/v1/manifests/bundle", &token, &bundle).await; assert_eq!(status, StatusCode::ACCEPTED, "{body}"); let results = body["results"].as_array().unwrap(); assert_eq!(results.len(), 3); assert_eq!(results[0]["status"], "pending"); assert_eq!(results[1]["status"], "rejected"); assert!(results[1]["reason"].is_string(), "a rejected episode should say why"); assert_eq!(results[2]["status"], "pending", "a later episode must still be accepted"); } #[tokio::test] async fn bundle_envelope_errors_are_whole_request_400s() { // §4: `400` for the envelope itself, whereas individual bad episodes are // reported in the results list. let s = TestServer::new("bundle-envelope"); let token = s.token().await; let (status, _, _) = s .post_json_auth( "/api/v1/manifests/bundle", &token, &json!({ "jmanifest_version": 2, "series": {}, "episodes": [] }), ) .await; assert_eq!(status, StatusCode::BAD_REQUEST, "series needs an identifier"); let (status, _, _) = s .post_json_auth( "/api/v1/manifests/bundle", &token, &json!({ "jmanifest_version": 2, "series": { "series_tmdb_id": "1396" }, "episodes": [], "extra": 1 }), ) .await; assert_eq!(status, StatusCode::BAD_REQUEST, "unknown envelope field"); } #[tokio::test] async fn bundle_rejects_an_episode_contradicting_the_envelope() { // An episode must not be silently reattributed to the bundle's series. let s = TestServer::new("bundle-mismatch"); let token = s.token().await; let bundle = json!({ "jmanifest_version": 2, "series": { "series_tmdb_id": "1396" }, "episodes": [ { "jmanifest_version": 2, "identity": { "type": "episode", "series_tmdb_id": "9999", "season": 1, "episode": 1 }, "cut": { "runtime_sec": 2820.0 }, "actors": [ { "tmdb_id": "17419", "scenes": [{"start":1.0,"end":2.0}] } ] } ] }); let (status, body, _) = s.post_json_auth("/api/v1/manifests/bundle", &token, &bundle).await; assert_eq!(status, StatusCode::ACCEPTED); assert_eq!(body["results"][0]["status"], "rejected"); } #[tokio::test] async fn bundle_beyond_the_episode_cap_is_413() { // §2/§4: capped at 500 episodes; beyond that the client must page by season. let s = TestServer::new("bundle-cap"); let token = s.token().await; let episodes: Vec = (0..501) .map(|i| { json!({ "jmanifest_version": 2, "identity": { "type": "episode", "series_tmdb_id": "1396", "season": 1, "episode": i }, "cut": { "runtime_sec": 2820.0 }, "actors": [ { "tmdb_id": "17419", "scenes": [{"start":1.0,"end":2.0}] } ] }) }) .collect(); let bundle = json!({ "jmanifest_version": 2, "series": { "series_tmdb_id": "1396" }, "episodes": episodes }); let (status, _, _) = s.post_json_auth("/api/v1/manifests/bundle", &token, &bundle).await; assert_eq!(status, StatusCode::PAYLOAD_TOO_LARGE); } #[tokio::test] async fn bundle_route_accepts_a_body_larger_than_the_single_manifest_cap() { // §6 stage 1: per-route limits, so the bundle endpoint gets its larger cap // without widening the others. A ~3 MiB bundle exceeds the 2 MiB manifest cap // but is well within the 25 MiB bundle cap. let s = TestServer::new("bundle-bigger-cap"); let token = s.token().await; let episodes: Vec = (1..=60) .map(|ep| { let scenes: Vec = (0..600).map(|j| json!([j as f64, (j + 1) as f64])).collect(); json!({ "jmanifest_version": 2, "identity": { "type": "episode", "series_tmdb_id": "1396", "season": 1, "episode": ep }, "cut": { "runtime_sec": 2820.0 }, "actors": (0..8).map(|a| json!({ "tmdb_id": (20000 + a).to_string(), "scenes": scenes })).collect::>() }) }) .collect(); let bundle = json!({ "jmanifest_version": 2, "series": { "series_tmdb_id": "1396" }, "episodes": episodes }); let encoded = bundle.to_string(); assert!( encoded.len() > 2 * 1024 * 1024, "test body should exceed the single-manifest cap, got {} bytes", encoded.len() ); let (status, _, _) = s.post_json_auth("/api/v1/manifests/bundle", &token, &bundle).await; assert_eq!(status, StatusCode::ACCEPTED, "the bundle route has its own larger cap"); } // --------------------------------------------------------------------------- // §4 — reports // --------------------------------------------------------------------------- #[tokio::test] async fn reporting_an_unknown_manifest_is_404() { let s = TestServer::new("report-unknown"); let (status, _, _) = s .post_json( "/api/v1/manifests/01HZZZZZZZZZZZZZZZZZZZZZZZ/report", &json!({ "reason": "misaligned" }), ) .await; assert_eq!(status, StatusCode::NOT_FOUND); } #[tokio::test] async fn a_report_is_accepted_and_does_not_delist() { // §5a: delisting stays an operator action. Automatic delisting on report would // hand any client a remote delete primitive. let s = TestServer::new("report-ok"); let token = s.token().await; let (_, body, _) = s.post_json_auth("/api/v1/manifests", &token, &movie_manifest("504172", 6420.5)).await; let id = body["manifest_id"].as_str().unwrap().to_string(); let (status, body, _) = s .post_json( &format!("/api/v1/manifests/{id}/report"), &json!({ "reason": "wrong_actors", "note": "these are not the right people" }), ) .await; assert_eq!(status, StatusCode::OK, "{body}"); assert!(body["report_id"].is_string()); let (status, body, _) = s.get(&format!("/api/v1/manifests/{id}/status")).await; assert_eq!(status, StatusCode::OK); assert_eq!(body["status"], "pending", "a report must not change status by itself"); } #[tokio::test] async fn report_rejects_an_unknown_reason_and_unknown_fields() { let s = TestServer::new("report-strict"); let (status, _, _) = s .post_json("/api/v1/manifests/x/report", &json!({ "reason": "i_just_dont_like_it" })) .await; assert_eq!(status, StatusCode::BAD_REQUEST); let (status, _, _) = s .post_json("/api/v1/manifests/x/report", &json!({ "reason": "spam", "extra": true })) .await; assert_eq!(status, StatusCode::BAD_REQUEST); } #[tokio::test] async fn report_note_is_length_capped() { // §5a: free text from an anonymous caller is capped hard. let s = TestServer::new("report-note"); let token = s.token().await; let (_, body, _) = s.post_json_auth("/api/v1/manifests", &token, &movie_manifest("504172", 6420.5)).await; let id = body["manifest_id"].as_str().unwrap().to_string(); let (status, _, _) = s .post_json( &format!("/api/v1/manifests/{id}/report"), &json!({ "reason": "spam", "note": "a".repeat(5000) }), ) .await; assert_eq!(status, StatusCode::BAD_REQUEST); } // --------------------------------------------------------------------------- // Malformed input // --------------------------------------------------------------------------- #[tokio::test] async fn malformed_json_is_a_400_not_a_500() { let s = TestServer::new("bad-json"); let token = s.token().await; let req = Request::builder() .method("POST") .uri("/api/v1/manifests") .header("content-type", "application/json") .header("authorization", format!("Bearer {token}")) .body(Body::from("{ this is not json")) .unwrap(); let (status, _, _) = s.send(req).await; assert_eq!(status, StatusCode::BAD_REQUEST); } #[tokio::test] async fn deeply_nested_json_does_not_crash_the_parser() { // §6 stage 1 caps nesting depth; a parser handed unbounded input is a DoS // primitive, so the failure must be a clean rejection. let s = TestServer::new("deep-json"); let token = s.token().await; let deep = format!("{}{}", "[".repeat(5000), "]".repeat(5000)); let req = Request::builder() .method("POST") .uri("/api/v1/manifests") .header("content-type", "application/json") .header("authorization", format!("Bearer {token}")) .body(Body::from(deep)) .unwrap(); let (status, _, _) = s.send(req).await; assert!( status == StatusCode::BAD_REQUEST || status == StatusCode::PAYLOAD_TOO_LARGE, "deeply nested input should be rejected cleanly, got {status}" ); } #[tokio::test] async fn unknown_routes_are_404() { let s = TestServer::new("routes"); let (status, _, _) = s.get("/api/v1/nonexistent").await; assert_eq!(status, StatusCode::NOT_FOUND); let (status, _, _) = s.get("/api/v2/manifests/exists?tmdb_id=1").await; assert_eq!(status, StatusCode::NOT_FOUND); }