Files
JRay-public-server/tests/api.rs
T
dtourolleandClaude Opus 5 545c7d92a2
CI / fmt, clippy, test (push) Failing after 1m20s
CI / static musl binary (push) Has been skipped
CI / advisories and licences (push) Successful in 25s
Federation: replicate content, re-derive judgement (UR-008)
Implements §9a. The replication surface is four reads and no writes: a change
feed, fetch by content_id, a batch have, and a human-facing peer directory —
plus a capabilities endpoint carrying the accepted envelope versions, which
lets a client discover a schema mismatch in one request instead of a 400 per
manifest across a library sweep.

Pull, never push: a pulling server chooses what it ingests and when. Push would
let any peer inject work into the validation queue — the same abuse surface as
anonymous upload, at higher volume.

Nothing inherits a peer's judgement. A pulled manifest runs the full §6 stage 1
and 2 validation and this server's own cast check, and the fetched body must
hash to the content_id that was asked for — the check that stops an
intermediary or a misbehaving peer substituting content under a trusted id.
A peer's retraction flags for review rather than delisting, because
auto-delisting would hand every peer a remote delete primitive; only the opt-in
per-peer abuse channel delists, because a takedown propagating at the speed of
manual review is the wrong failure mode for that one case.

A test caught a real bug in the first cut: the feed cursor was a ULID, and
ULIDs are only monotonic *between* milliseconds — two generated in the same
millisecond carry independent random components and can sort opposite to write
order. A peer resuming from `seq > cursor` would then silently skip an entry:
replication losing manifests with no error anywhere. The cursor is now an
AUTOINCREMENT integer, and the test asserts strict monotonicity rather than
merely sortedness.

Peer administration is deliberately not an API. §9a requires that a peering
exist only because an operator typed a URL, so nothing a remote server returns
can establish or widen one; there_is_no_endpoint_that_creates_a_peering asserts
that absence rather than trusting it.

212 tests. Coverage 25/32 (78%).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

TRACES: UR-008 | PR-006
2026-07-31 09:28:32 +02:00

993 lines
39 KiB
Rust

//! 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<Body>) -> (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",
"<script>alert(1)</script>",
"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<Value> = (0..400)
.map(|i| {
let scenes: Vec<Value> = (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<Value> = (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<Value> =
(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<Value> = (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<Value> = (1..=60)
.map(|ep| {
let scenes: Vec<Value> = (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::<Vec<_>>()
})
})
.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);
}