Initial implementation: core vertical slice
CI / fmt, clippy, test (push) Failing after 2m46s
CI / static musl binary (push) Has been skipped
CI / advisories and licences (push) Successful in 4m22s

Implements the core of SPEC.md — the manifest exchange, less audio-tier
matching (§3) and federation (§9a), both of which the spec sequences as
later work.

- §2 Jmanifest format and series bundles
- §3 cut matching: exact / runtime / loose tiers
- §4 API, less POST /manifests/search
- §5 rate limiting; §5a trust model, anonymous bearer tokens
- §6 upload validation, all four stages
- §7 relational storage, no JSON blob on the write path
- §8 Rust + Axum + SQLite, single serialized writer, in-process job queue
- §9a content addressing, computed on upload

Reconciled against the system spec:

- anneal_sec removed, withdrawn upstream by AR-012/AR-013. Presence follows
  track extent, so a track survives its own gaps and there is nothing to
  anneal. Its successor extinction_sec and the new gallery_scope are accepted
  and stored; scope enters the §7 ranking. A manifest still carrying
  anneal_sec is a hard 400, not silently ignored — it came from a pipeline
  whose window semantics differ from what this server assumes.
- Audio signature: media under 120 s now emits no signature at all, matching
  scene-actor-extraction IR-007. The earlier §3 draft allowed a shortened
  window under 150 s, which was the weaker rule — a caller-varying length is
  the property SR-004 forbids.
- UR IDs regularised to UR-nnn; docs/requirements.md registers 32
  requirements, each tracing to an SR-nnn or PR-nnn.

189 tests: unit, end-to-end through the real router, and an injection suite
covering SQL, JSON, header and Unicode payloads. Writing that suite found two
real gaps, both fixed here: compatibility homoglyphs passed the §5a character
class, and a one-frame audio signature was accepted on a feature-length item.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-30 18:14:02 +02:00
co-authored by Claude Opus 5
commit a848750a65
38 changed files with 13014 additions and 0 deletions
+953
View File
@@ -0,0 +1,953 @@
//! 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<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": 1,
"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": [[191.6, 209.2], [438.2, 465.6]] },
{ "name": "Michael Palin", "tmdb_id": "11007", "scenes": [[300.0, 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 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;
for version in [0, 2, 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": 1,
"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": 1,
"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": [[10.0, 20.0]] } ]
})
};
// Invalid: a scene window beyond the runtime tolerance (§6).
let bad = json!({
"jmanifest_version": 1,
"identity": { "type": "episode", "series_tmdb_id": "1396", "season": 1, "episode": 3 },
"cut": { "runtime_sec": 2820.0 },
"actors": [ { "tmdb_id": "17419", "scenes": [[10.0, 99999.0]] } ]
});
let bundle = json!({
"jmanifest_version": 1,
"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": 1, "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": 1,
"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": 1,
"series": { "series_tmdb_id": "1396" },
"episodes": [ {
"jmanifest_version": 1,
"identity": { "type": "episode", "series_tmdb_id": "9999", "season": 1, "episode": 1 },
"cut": { "runtime_sec": 2820.0 },
"actors": [ { "tmdb_id": "17419", "scenes": [[1.0, 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": 1,
"identity": { "type": "episode", "series_tmdb_id": "1396",
"season": 1, "episode": i },
"cut": { "runtime_sec": 2820.0 },
"actors": [ { "tmdb_id": "17419", "scenes": [[1.0, 2.0]] } ]
})
})
.collect();
let bundle = json!({
"jmanifest_version": 1,
"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": 1,
"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": 1,
"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);
}
+634
View File
@@ -0,0 +1,634 @@
//! Injection resistance — SQL, JSON and header.
//!
//! These are regression tests for properties the design already provides, kept
//! separate from `api.rs` because their purpose is different: `api.rs` asserts the
//! spec's behaviour, this asserts that hostile input cannot escape its layer.
//!
//! Two distinct defences are at work, and it is worth being precise about which
//! applies where, because they fail differently:
//!
//! 1. **Parameterised queries** (§7, §8). Every value reaches SQLite through
//! `params![]`; the only `format!`-built SQL interpolates compile-time
//! constants (a column list and a status literal). So a value carrying SQL
//! syntax is bound as *data* and simply matches nothing.
//! 2. **Closed-vocabulary validation** (§5a, §6 stage 2). Identifiers are
//! regex-constrained and free text is restricted to a closed character class,
//! so most injection strings are rejected before they reach the database.
//!
//! Defence 1 is what actually prevents injection; defence 2 means an attacker
//! usually cannot even reach it. Testing both matters: if validation were ever
//! loosened, these tests should still pass on the strength of parameterisation
//! alone.
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;
/// Payloads spanning the usual SQL-injection shapes: boolean tautology, statement
/// termination, stacked statements, UNION exfiltration, comment truncation, and
/// string-concatenation exfiltration.
const SQL_PAYLOADS: &[&str] = &[
"1' OR '1'='1",
"1'; DROP TABLE manifests;--",
"1 UNION SELECT token_hash FROM contributors",
"' OR 1=1--",
"1'||(SELECT token_hash FROM contributors)||'",
"1)) OR 1=1 --",
"'; UPDATE manifests SET status='listed' WHERE 1=1;--",
"1/**/UNION/**/SELECT/**/1",
"x' AND (SELECT COUNT(*) FROM sqlite_master)>0 --",
"\"; DELETE FROM scenes; --",
];
struct TestServer {
router: axum::Router,
db: Db,
_dir: TempDir,
}
struct TempDir(std::path::PathBuf);
impl TempDir {
fn new(tag: &str) -> Self {
let mut p = std::env::temp_dir();
p.push(format!("jray-inj-{}-{}", tag, unique()));
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 unique() -> 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(),
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: db.clone(),
config: config.clone(),
limiter: Arc::new(RateLimiter::new()),
tmdb: Arc::new(TmdbClient::new(config.tmdb_base_url.clone(), None)),
};
Self { router: app::router(state), db, _dir: dir }
}
async fn send(&self, req: Request<Body>) -> (StatusCode, Value) {
let resp = self.router.clone().oneshot(req).await.expect("router call");
let status = resp.status();
let bytes = resp.into_body().collect().await.expect("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)
}
async fn get(&self, uri: &str) -> (StatusCode, Value) {
self.send(Request::builder().uri(uri).body(Body::empty()).unwrap()).await
}
async fn post(&self, uri: &str, token: Option<&str>, body: &Value) -> (StatusCode, Value) {
let mut b =
Request::builder().method("POST").uri(uri).header("content-type", "application/json");
if let Some(t) = token {
b = b.header("authorization", format!("Bearer {t}"));
}
self.send(b.body(Body::from(body.to_string())).unwrap()).await
}
async fn token(&self) -> String {
let (_, body) = self.post("/api/v1/tokens", None, &json!({})).await;
body["token"].as_str().expect("token").to_string()
}
/// Confirms the schema is intact and the expected row counts hold.
///
/// A successful injection would most likely drop a table or delete rows, so
/// this is the assertion that actually matters after each payload.
async fn assert_schema_intact(&self) {
let tables: Vec<String> = self
.db
.read(|conn| {
let mut stmt = conn
.prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")?;
let rows = stmt
.query_map([], |r| r.get::<_, String>(0))?
.collect::<rusqlite::Result<Vec<_>>>()?;
Ok(rows)
})
.await
.expect("listing tables");
for expected in [
"contributors",
"jobs",
"manifest_actors",
"manifests",
"people",
"reports",
"scenes",
"titles",
"tmdb_cache",
] {
assert!(
tables.iter().any(|t| t == expected),
"table {expected} is missing — an injection may have dropped it. tables: {tables:?}"
);
}
}
}
fn urlencode(s: &str) -> String {
let mut out = String::new();
for b in s.bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(b as char)
}
_ => out.push_str(&format!("%{b:02X}")),
}
}
out
}
// ---------------------------------------------------------------------------
// SQL injection — query parameters
// ---------------------------------------------------------------------------
#[tokio::test]
async fn sql_payloads_in_query_parameters_are_inert() {
let s = TestServer::new("query");
for payload in SQL_PAYLOADS {
let enc = urlencode(payload);
for uri in [
format!("/api/v1/manifests/exists?tmdb_id={enc}"),
format!("/api/v1/manifests/exists?imdb_id={enc}"),
format!("/api/v1/manifests/movie?tmdb_id={enc}"),
format!("/api/v1/manifests/movie?imdb_id={enc}"),
format!("/api/v1/manifests/episode?series_tmdb_id={enc}&season=1&episode=1"),
format!("/api/v1/manifests/series/{enc}"),
format!("/api/v1/manifests/exists?tmdb_id=1&video_hash={enc}"),
] {
let (status, body) = s.get(&uri).await;
// The payload is bound as data, so it matches nothing. What must never
// happen is a 5xx, which would mean SQLite saw it as syntax.
assert!(
status.is_success()
|| status == StatusCode::NOT_FOUND
|| status == StatusCode::BAD_REQUEST,
"payload {payload:?} on {uri} produced {status} — expected data-not-found, \
not a server error. body: {body}"
);
}
}
s.assert_schema_intact().await;
}
#[tokio::test]
async fn sql_payloads_in_path_parameters_are_inert() {
let s = TestServer::new("path");
for payload in SQL_PAYLOADS {
let enc = urlencode(payload);
for uri in [
format!("/api/v1/manifests/{enc}"),
format!("/api/v1/manifests/{enc}/status"),
format!("/api/v1/manifests/series/{enc}"),
] {
let (status, body) = s.get(&uri).await;
assert!(
!status.is_server_error(),
"payload {payload:?} on {uri} produced {status}: {body}"
);
}
}
s.assert_schema_intact().await;
}
// ---------------------------------------------------------------------------
// SQL injection — JSON body fields
// ---------------------------------------------------------------------------
#[tokio::test]
async fn sql_payloads_in_identifier_fields_are_rejected() {
// §6 stage 2 regex-constrains every identifier, so these never even reach the
// query layer. The response must be a clean 400 naming the field.
let s = TestServer::new("body-ids");
let token = s.token().await;
for payload in SQL_PAYLOADS {
let manifest = json!({
"jmanifest_version": 1,
"identity": { "type": "movie", "tmdb_id": payload },
"cut": { "runtime_sec": 100.0 },
"actors": [ { "tmdb_id": "884", "scenes": [[1.0, 2.0]] } ]
});
let (status, body) = s.post("/api/v1/manifests", Some(&token), &manifest).await;
assert_eq!(
status,
StatusCode::BAD_REQUEST,
"payload {payload:?} should be rejected by validation: {body}"
);
}
s.assert_schema_intact().await;
}
#[tokio::test]
async fn sql_payloads_in_free_text_fields_are_rejected() {
// The two free-text fields (§5a) are the only place arbitrary strings could
// arrive. The closed character class excludes quotes, semicolons and digits,
// which is what makes SQL syntax unrepresentable there.
let s = TestServer::new("body-text");
let token = s.token().await;
for payload in SQL_PAYLOADS {
for manifest in [
json!({
"jmanifest_version": 1,
"identity": { "type": "movie", "tmdb_id": "504172", "title": payload },
"cut": { "runtime_sec": 100.0 },
"actors": [ { "tmdb_id": "884", "scenes": [[1.0, 2.0]] } ]
}),
json!({
"jmanifest_version": 1,
"identity": { "type": "movie", "tmdb_id": "504172" },
"cut": { "runtime_sec": 100.0 },
"actors": [ { "name": payload, "tmdb_id": "884", "scenes": [[1.0, 2.0]] } ]
}),
] {
let (status, body) = s.post("/api/v1/manifests", Some(&token), &manifest).await;
assert_eq!(
status,
StatusCode::BAD_REQUEST,
"free-text payload {payload:?} should be rejected: {body}"
);
}
}
s.assert_schema_intact().await;
}
#[tokio::test]
async fn sql_payloads_in_a_report_note_cannot_escape() {
// `note` is the one field that accepts relatively free text (control
// characters stripped, length capped) because only the operator reads it. It
// reaches the database, so it is the strongest test of parameterisation:
// validation is *not* filtering SQL syntax here.
let s = TestServer::new("report-note");
let token = s.token().await;
let (_, body) = s
.post(
"/api/v1/manifests",
Some(&token),
&json!({
"jmanifest_version": 1,
"identity": { "type": "movie", "tmdb_id": "504172" },
"cut": { "runtime_sec": 100.0 },
"actors": [ { "tmdb_id": "884", "scenes": [[1.0, 2.0]] } ]
}),
)
.await;
let id = body["manifest_id"].as_str().expect("manifest id").to_string();
for payload in SQL_PAYLOADS {
let (status, body) = s
.post(
&format!("/api/v1/manifests/{id}/report"),
None,
&json!({ "reason": "spam", "note": payload }),
)
.await;
assert!(
status.is_success() || status == StatusCode::TOO_MANY_REQUESTS,
"note payload {payload:?} produced {status}: {body}"
);
if status.is_success() {
s.assert_schema_intact().await;
}
}
// The notes were stored verbatim as *data* — proving they were bound, not
// executed. Verified by reading them back out.
let stored: i64 =
s.db.read(|conn| Ok(conn.query_row("SELECT COUNT(*) FROM reports", [], |r| r.get(0))?))
.await
.expect("counting reports");
assert!(stored > 0, "reports should have been stored as inert data");
}
#[tokio::test]
async fn sql_payloads_in_a_bearer_token_are_inert() {
// The token is hashed before it reaches any query, but a payload arriving via
// a header must still not produce a 5xx.
let s = TestServer::new("token-inj");
for payload in SQL_PAYLOADS {
let req = Request::builder()
.method("POST")
.uri("/api/v1/manifests")
.header("content-type", "application/json")
.header("authorization", format!("Bearer {payload}"))
.body(Body::from(
json!({
"jmanifest_version": 1,
"identity": { "type": "movie", "tmdb_id": "504172" },
"cut": { "runtime_sec": 100.0 },
"actors": [ { "tmdb_id": "884", "scenes": [[1.0, 2.0]] } ]
})
.to_string(),
))
.unwrap();
let (status, body) = s.send(req).await;
assert_eq!(
status,
StatusCode::UNAUTHORIZED,
"token payload {payload:?} produced {status}: {body}"
);
}
s.assert_schema_intact().await;
}
#[tokio::test]
async fn sql_payloads_in_the_batch_exists_body_are_inert() {
let s = TestServer::new("batch-inj");
let items: Vec<Value> = SQL_PAYLOADS.iter().map(|p| json!({ "tmdb_id": p })).collect();
let (status, body) = s.post("/api/v1/manifests/exists", None, &json!({ "items": items })).await;
assert_eq!(status, StatusCode::OK, "{body}");
// Each malformed item degrades to "absent" rather than erroring the batch.
for result in body["results"].as_array().expect("results") {
assert_eq!(result["exists"], false);
}
s.assert_schema_intact().await;
}
// ---------------------------------------------------------------------------
// JSON injection / parser abuse
// ---------------------------------------------------------------------------
#[tokio::test]
async fn json_structure_abuse_is_rejected_cleanly() {
// A parser handed hostile structure must fail with 400/413, never 5xx and
// never a hang (§6 stage 1: "a parser handed an unbounded body is a
// denial-of-service primitive").
let s = TestServer::new("json-abuse");
let token = s.token().await;
let cases: Vec<(&str, String)> = vec![
("deep nesting", format!("{}{}", "[".repeat(20_000), "]".repeat(20_000))),
("unterminated", "{\"identity\": {\"type\": \"movie\"".to_string()),
("duplicate keys", r#"{"jmanifest_version":1,"jmanifest_version":2}"#.to_string()),
("null bytes", "{\"jmanifest_version\":\u{0}1}".to_string()),
("huge number", format!("{{\"jmanifest_version\":{}}}", "9".repeat(5000))),
("nan literal", r#"{"jmanifest_version":1,"cut":{"runtime_sec":NaN}}"#.to_string()),
("bare array", "[1,2,3]".to_string()),
("bare string", "\"just a string\"".to_string()),
("empty body", String::new()),
(
"prototype-style key",
r#"{"__proto__":{"admin":true},"jmanifest_version":1}"#.to_string(),
),
];
for (label, body) in cases {
let req = Request::builder()
.method("POST")
.uri("/api/v1/manifests")
.header("content-type", "application/json")
.header("authorization", format!("Bearer {token}"))
.body(Body::from(body))
.unwrap();
let (status, resp) = s.send(req).await;
assert!(
status == StatusCode::BAD_REQUEST || status == StatusCode::PAYLOAD_TOO_LARGE,
"{label} produced {status}, expected a clean rejection: {resp}"
);
}
s.assert_schema_intact().await;
}
#[tokio::test]
async fn non_finite_scene_times_are_rejected() {
// §6 explicitly rejects NaN/Infinity. They cannot arrive as JSON literals, but
// they can arrive as overflowing decimals, which parse to f64 infinity.
let s = TestServer::new("nonfinite");
let token = s.token().await;
// Sent as raw JSON text rather than via `json!`, because rustc refuses an
// out-of-range float literal — and the point is to make the *server's* parser
// handle it, which is the real attack path.
let raw = r#"{"jmanifest_version":1,
"identity":{"type":"movie","tmdb_id":"504172"},
"cut":{"runtime_sec":100.0},
"actors":[{"tmdb_id":"884","scenes":[[1.0,1e400]]}]}"#;
let req = Request::builder()
.method("POST")
.uri("/api/v1/manifests")
.header("content-type", "application/json")
.header("authorization", format!("Bearer {token}"))
.body(Body::from(raw))
.unwrap();
let (status, body) = s.send(req).await;
assert_eq!(status, StatusCode::BAD_REQUEST, "{body}");
// Likewise an overflowing runtime.
let raw = r#"{"jmanifest_version":1,
"identity":{"type":"movie","tmdb_id":"504172"},
"cut":{"runtime_sec":1e400},
"actors":[{"tmdb_id":"884","scenes":[[1.0,2.0]]}]}"#;
let req = Request::builder()
.method("POST")
.uri("/api/v1/manifests")
.header("content-type", "application/json")
.header("authorization", format!("Bearer {token}"))
.body(Body::from(raw))
.unwrap();
let (status, body) = s.send(req).await;
assert_eq!(status, StatusCode::BAD_REQUEST, "{body}");
}
// ---------------------------------------------------------------------------
// Header injection
// ---------------------------------------------------------------------------
#[tokio::test]
async fn crlf_in_a_header_value_cannot_split_the_response() {
// A CRLF-carrying header value must not appear in the response as new headers.
// `http` rejects such values at construction, so this asserts the invariant
// holds at the boundary rather than relying on our own escaping.
let bad = "1.2.3.4\r\nX-Injected: yes";
assert!(
axum::http::HeaderValue::from_str(bad).is_err(),
"the http crate must refuse CRLF in header values"
);
// And a percent-encoded variant reaching a handler stays inert data.
let s = TestServer::new("crlf");
let (status, _) = s.get("/api/v1/manifests/exists?tmdb_id=1%0D%0AX-Injected:%20yes").await;
assert!(!status.is_server_error());
s.assert_schema_intact().await;
}
#[tokio::test]
async fn oversized_headers_do_not_take_the_server_down() {
let s = TestServer::new("big-header");
let big = "a".repeat(100_000);
let req = Request::builder()
.uri("/api/v1/manifests/exists?tmdb_id=1")
.header("x-filler", big)
.body(Body::empty())
.unwrap();
let (status, _) = s.send(req).await;
assert!(!status.is_server_error(), "got {status}");
}
// ---------------------------------------------------------------------------
// Path traversal
// ---------------------------------------------------------------------------
#[tokio::test]
async fn path_traversal_attempts_reach_no_filesystem() {
// The server serves no files at all, so traversal has nowhere to go. Asserted
// anyway, because the manifest id is a path segment.
let s = TestServer::new("traversal");
for probe in [
"..%2F..%2F..%2Fetc%2Fpasswd",
"....%2F%2F....%2F%2Fetc%2Fpasswd",
"%2e%2e%2f%2e%2e%2fetc%2fshadow",
"..%5C..%5Cwindows%5Csystem32",
"%00/etc/passwd",
] {
let (status, body) = s.get(&format!("/api/v1/manifests/{probe}")).await;
assert!(
status == StatusCode::NOT_FOUND || status == StatusCode::BAD_REQUEST,
"probe {probe} produced {status}: {body}"
);
// Nothing that looks like file content should ever come back.
let text = body.to_string();
assert!(!text.contains("root:"), "probe {probe} returned passwd-like content");
}
}
// ---------------------------------------------------------------------------
// Unicode and encoding tricks against the §5a character class
// ---------------------------------------------------------------------------
#[tokio::test]
async fn unicode_tricks_cannot_smuggle_text_past_the_character_class() {
// §5a's class is checked *after* NFC normalisation, so decomposed and
// compatibility forms must not provide a way in. Fullwidth digits are the
// sharpest case: NFKC would fold them to ASCII digits, but NFC does not, and
// they are `Nd` (not a letter), so the class rejects them either way.
let s = TestServer::new("unicode");
let token = s.token().await;
for payload in [
"Actor 123", // fullwidth letters and digits
"Steve\u{FEFF}Buscemi", // zero-width no-break space
"Ste\u{0301}ve\u{202E}", // combining acute plus bidi override
"𝐒𝐭𝐞𝐯𝐞", // mathematical bold (compatibility form)
"Steve\u{2028}Buscemi", // line separator
"\u{1F600} Actor", // emoji
"Actor\u{00A0}Name\u{0000}", // nbsp plus NUL
] {
let (status, body) = s
.post(
"/api/v1/manifests",
Some(&token),
&json!({
"jmanifest_version": 1,
"identity": { "type": "movie", "tmdb_id": "504172" },
"cut": { "runtime_sec": 100.0 },
"actors": [ { "name": payload, "tmdb_id": "884", "scenes": [[1.0, 2.0]] } ]
}),
)
.await;
assert_eq!(
status,
StatusCode::BAD_REQUEST,
"unicode payload {payload:?} should be rejected: {body}"
);
}
}
#[tokio::test]
async fn the_audio_signature_field_cannot_carry_arbitrary_bytes() {
// §3: a variable-length blob would be a payload channel — "precisely what §5a
// closes". Length is fixed and every byte is structurally constrained.
let s = TestServer::new("audio-sig");
let token = s.token().await;
for sig in [
"v1:aGVsbG8gd29ybGQ=", // too short to be a signature
&format!("v1:{}", "/".repeat(4000)), // high bit set throughout
&format!("v1:{}", "A".repeat(100_000)), // oversized
"not-base64-at-all", // missing version prefix
&"A".repeat(1720), // unprefixed
] {
let (status, body) = s
.post(
"/api/v1/manifests",
Some(&token),
&json!({
"jmanifest_version": 1,
"identity": { "type": "movie", "tmdb_id": "504172" },
"cut": { "runtime_sec": 6420.5, "audio_signature": sig },
"actors": [ { "tmdb_id": "884", "scenes": [[1.0, 2.0]] } ]
}),
)
.await;
assert_eq!(
status,
StatusCode::BAD_REQUEST,
"signature {:?} should be rejected: {body}",
&sig.chars().take(40).collect::<String>()
);
}
}