//! 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(), publish_peer_directory: true, contact: Some("admin@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 }, "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_")); } #[tokio::test] async fn the_token_response_carries_the_contribution_licence() { // §5b / UR-019. There are no accounts, so token issuance is the only moment // a grant can be taken — a licence the server publishes but never delivers // is one no contributor agreed to, which is precisely the gap that leaves // federated replication (UR-008) without a grant flowing through it. let s = TestServer::new("token-licence"); let (status, body, _) = s.post_json("/api/v1/tokens", &json!({})).await; assert_eq!(status, StatusCode::OK, "body: {body}"); assert_eq!(body["contribution_license"].as_str(), Some("CC0-1.0")); let terms = body["contribution_terms"].as_str().expect("terms in response"); assert!(terms.contains("CC0 1.0"), "terms must name the licence: {terms}"); // The scope limit is the operative half: it must be impossible to read the // grant as covering the film rather than the manifest. assert!(terms.contains("manifest only"), "terms must bound the grant to the manifest: {terms}"); } // --------------------------------------------------------------------------- // §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"); } /// TRACES: DR-001 | SR-004 #[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}"); } /// TRACES: UR-015 | SR-003 #[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}" ); } /// TRACES: UR-015, UR-016 | SR-003 #[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!({ "container_duration_sec": 6420.5 }); 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