cellos-server 0.5.2

HTTP control plane for CellOS — admission, projection over JetStream, WebSocket fan-out of CloudEvents. Pure event-sourced architecture.
Documentation
//! FUZZ-WAVE-1 MED-1 / MED-2: content-type conformance for 4xx.
//!
//! Adopter expectation (RFC 9457): every 4xx the API emits carries
//! `Content-Type: application/problem+json` and a body that parses as
//! a problem document (`type`, `title`, `status`, `detail` fields).
//!
//! Wave-1 fuzz report observations that motivated these tests:
//!   * built-in extractor rejections (Path/Query/Body limit) emitted
//!     `text/plain` — fails the conformance contract;
//!   * 404 on unmatched routes returned an empty body, no Content-Type;
//!   * 405 on wrong methods returned an empty body, no Content-Type;
//!   * `Allow` header had to survive any body rewrite so RFC 9110
//!     §15.5.6 stays satisfied.
//!
//! These tests exercise the router end-to-end via `tower::ServiceExt`
//! so any future change to the middleware ordering or fallback wiring
//! that drops problem+json regresses immediately.

use axum::body::Body;
use axum::http::{header, Method, Request, StatusCode};
use cellos_server::{router, AppState};
use http_body_util::BodyExt;
use serde_json::Value;
use tower::ServiceExt;

const TOKEN: &str = "med-ct-test-token";

fn app_state() -> AppState {
    AppState::new(None, TOKEN)
}

/// Drain a response into `(status, content-type, allow, parsed-json)`.
async fn drain(resp: axum::response::Response) -> (StatusCode, String, Option<String>, Value) {
    let status = resp.status();
    let ct = resp
        .headers()
        .get(header::CONTENT_TYPE)
        .and_then(|v| v.to_str().ok())
        .unwrap_or_default()
        .to_string();
    let allow = resp
        .headers()
        .get(header::ALLOW)
        .and_then(|v| v.to_str().ok())
        .map(str::to_string);
    let bytes = resp.into_body().collect().await.expect("body").to_bytes();
    let body: Value = serde_json::from_slice(&bytes).unwrap_or_else(|e| {
        panic!(
            "response body must parse as JSON (status={status}, ct={ct:?}, body={:?}): {e}",
            String::from_utf8_lossy(&bytes),
        )
    });
    (status, ct, allow, body)
}

fn assert_problem_json(ct: &str, status: StatusCode, body: &Value) {
    assert!(
        ct.starts_with("application/problem+json"),
        "Content-Type must be application/problem+json (got {ct:?}, status {status})",
    );
    for required in ["type", "title", "status", "detail"] {
        assert!(
            body.get(required).is_some(),
            "problem document must contain `{required}` field; got {body}",
        );
    }
    assert_eq!(
        body.get("status").and_then(Value::as_u64),
        Some(u64::from(status.as_u16())),
        "problem `status` field must match HTTP status; got {body}",
    );
    let type_uri = body.get("type").and_then(Value::as_str).unwrap_or_default();
    assert!(
        type_uri.starts_with("/problems/"),
        "`type` must be a /problems/ URI reference, got {type_uri:?}",
    );
}

// ---------------------------------------------------------------------
// MED-1 — axum built-in rejections must be problem+json
// ---------------------------------------------------------------------

/// MED-1 reproducer F30 (redo): a POST body bigger than the per-route
/// 64 KiB cap on `/v1/formations` used to surface as
/// `413 text/plain "length limit exceeded"`. The
/// `normalize_problem_response` middleware rewrites it to problem+json
/// under `/problems/payload-too-large`.
#[tokio::test]
async fn post_formations_oversize_body_is_problem_json_413() {
    let app = router(app_state());
    let big = vec![b'{'; 200 * 1024]; // 200 KiB — well over the 64 KiB cap

    let req = Request::builder()
        .method(Method::POST)
        .uri("/v1/formations")
        .header(header::AUTHORIZATION, format!("Bearer {TOKEN}"))
        .header(header::CONTENT_TYPE, "application/json")
        .body(Body::from(big))
        .expect("build request");

    let resp = app.oneshot(req).await.expect("router response");
    let (status, ct, _allow, body) = drain(resp).await;

    assert_eq!(status, StatusCode::PAYLOAD_TOO_LARGE);
    assert_problem_json(&ct, status, &body);
    let type_uri = body.get("type").and_then(Value::as_str).unwrap();
    assert_eq!(type_uri, "/problems/payload-too-large");
    let detail = body
        .get("detail")
        .and_then(Value::as_str)
        .unwrap_or_default();
    assert!(
        !detail.is_empty(),
        "413 detail must not be empty (got {body})",
    );
}

/// MED-1 reproducer FID01: a non-UUID path segment used to surface as
/// `400 text/plain "Invalid URL: UUID parsing failed: ..."`. The
/// PathRejection now flows through `normalize_problem_response`.
#[tokio::test]
async fn get_formation_with_bad_uuid_is_problem_json_400() {
    let app = router(app_state());

    let req = Request::builder()
        .method(Method::GET)
        .uri("/v1/formations/notauuid")
        .header(header::AUTHORIZATION, format!("Bearer {TOKEN}"))
        .body(Body::empty())
        .expect("build request");

    let resp = app.oneshot(req).await.expect("router response");
    let (status, ct, _, body) = drain(resp).await;

    assert_eq!(status, StatusCode::BAD_REQUEST);
    assert_problem_json(&ct, status, &body);
}

/// MED-1 reproducer LF01: a non-numeric `cursor=` query parameter on
/// the `/v1/cells` list endpoint used to surface as
/// `400 text/plain "Failed to deserialize query string: ..."`. The
/// QueryRejection now arrives as problem+json.
#[tokio::test]
async fn get_cells_with_bad_query_is_problem_json_400() {
    let app = router(app_state());

    let req = Request::builder()
        .method(Method::GET)
        .uri("/v1/cells?cursor=abc")
        .header(header::AUTHORIZATION, format!("Bearer {TOKEN}"))
        .body(Body::empty())
        .expect("build request");

    let resp = app.oneshot(req).await.expect("router response");
    let (status, ct, _, body) = drain(resp).await;

    assert_eq!(status, StatusCode::BAD_REQUEST);
    assert_problem_json(&ct, status, &body);
}

/// MED-1 reproducer E04: a numeric overflow in `?since=` on
/// `/v1/events` used to surface as `400 text/plain "number too large
/// to fit in target type"`. Now problem+json.
#[tokio::test]
async fn get_events_with_overflow_since_is_problem_json_400() {
    let app = router(app_state());

    let req = Request::builder()
        .method(Method::GET)
        .uri("/v1/events?since=9999999999999999999999")
        .header(header::AUTHORIZATION, format!("Bearer {TOKEN}"))
        .body(Body::empty())
        .expect("build request");

    let resp = app.oneshot(req).await.expect("router response");
    let (status, ct, _, body) = drain(resp).await;

    assert_eq!(status, StatusCode::BAD_REQUEST);
    assert_problem_json(&ct, status, &body);
}

// ---------------------------------------------------------------------
// MED-2 — fallbacks for unmatched routes and wrong methods
// ---------------------------------------------------------------------

/// MED-2 reproducer PT09: `GET /v1/` (and any other unmatched path)
/// used to return `404` with an empty body and no Content-Type. The
/// `not_found_handler` fallback now emits problem+json with a detail
/// that names the offending path.
#[tokio::test]
async fn unmatched_route_returns_problem_json_404() {
    let app = router(app_state());

    let req = Request::builder()
        .method(Method::GET)
        .uri("/v1/nonexistent")
        .header(header::AUTHORIZATION, format!("Bearer {TOKEN}"))
        .body(Body::empty())
        .expect("build request");

    let resp = app.oneshot(req).await.expect("router response");
    let (status, ct, _, body) = drain(resp).await;

    assert_eq!(status, StatusCode::NOT_FOUND);
    assert_problem_json(&ct, status, &body);

    let detail = body
        .get("detail")
        .and_then(Value::as_str)
        .unwrap_or_default();
    assert!(
        detail.contains("/v1/nonexistent") || detail.contains("nonexistent"),
        "404 detail should mention the unmatched path; got {detail:?}",
    );

    let type_uri = body.get("type").and_then(Value::as_str).unwrap();
    assert_eq!(type_uri, "/problems/not-found");
}

/// MED-2 reproducer PT04-08: `POST /v1/version` (wrong method on a
/// real route) used to return `405` with empty body — `Allow` header
/// was present but no problem document. The
/// `method_not_allowed_fallback` now emits problem+json AND preserves
/// the `Allow` header axum's method router attached.
#[tokio::test]
async fn wrong_method_returns_problem_json_405_with_allow_header() {
    let app = router(app_state());

    let req = Request::builder()
        .method(Method::POST)
        .uri("/v1/version")
        .header(header::AUTHORIZATION, format!("Bearer {TOKEN}"))
        .body(Body::empty())
        .expect("build request");

    let resp = app.oneshot(req).await.expect("router response");
    let (status, ct, allow, body) = drain(resp).await;

    assert_eq!(status, StatusCode::METHOD_NOT_ALLOWED);
    assert_problem_json(&ct, status, &body);

    let type_uri = body.get("type").and_then(Value::as_str).unwrap();
    assert_eq!(type_uri, "/problems/method-not-allowed");

    // RFC 9110 §15.5.6: a 405 response MUST advertise the valid
    // methods in `Allow`. The body-rewrite middleware must preserve
    // that header verbatim from axum's method router.
    let allow = allow.expect("405 must carry Allow header (RFC 9110 §15.5.6)");
    assert!(
        allow.to_ascii_uppercase().contains("GET"),
        "Allow on /v1/version (a GET route) must list GET; got {allow:?}",
    );
}

/// MED-2 sibling: hitting `/v1/formations` with a verb the route
/// rejects (PATCH) must also be problem+json with Allow listing the
/// valid methods (POST and GET, plus HEAD which axum adds for GET).
#[tokio::test]
async fn patch_formations_is_problem_json_405_with_allow() {
    let app = router(app_state());

    let req = Request::builder()
        .method(Method::PATCH)
        .uri("/v1/formations")
        .header(header::AUTHORIZATION, format!("Bearer {TOKEN}"))
        .body(Body::empty())
        .expect("build request");

    let resp = app.oneshot(req).await.expect("router response");
    let (status, ct, allow, body) = drain(resp).await;

    assert_eq!(status, StatusCode::METHOD_NOT_ALLOWED);
    assert_problem_json(&ct, status, &body);

    let allow = allow.expect("405 must carry Allow header");
    let upper = allow.to_ascii_uppercase();
    assert!(
        upper.contains("POST") && upper.contains("GET"),
        "Allow on /v1/formations must include POST and GET; got {allow:?}",
    );
}

// ---------------------------------------------------------------------
// Negative tests — 2xx and 5xx must not be rewritten
// ---------------------------------------------------------------------

/// The middleware must only touch 4xx. A successful `/v1/version`
/// response stays `application/json`, NOT problem+json — otherwise
/// every healthy adopter parser breaks.
#[tokio::test]
async fn success_responses_are_not_rewritten() {
    let app = router(app_state());

    let req = Request::builder()
        .method(Method::GET)
        .uri("/v1/version")
        .header(header::AUTHORIZATION, format!("Bearer {TOKEN}"))
        .body(Body::empty())
        .expect("build request");

    let resp = app.oneshot(req).await.expect("router response");
    assert_eq!(resp.status(), StatusCode::OK);
    let ct = resp
        .headers()
        .get(header::CONTENT_TYPE)
        .and_then(|v| v.to_str().ok())
        .unwrap_or_default()
        .to_string();
    assert!(
        ct.starts_with("application/json"),
        "200 responses must stay application/json, got {ct:?}",
    );
    assert!(
        !ct.starts_with("application/problem+json"),
        "200 must NOT be rewritten to problem+json; got {ct:?}",
    );
}

/// Existing AppError responses (401 from auth) were already
/// problem+json. The middleware must leave them alone — including
/// preserving the `/problems/unauthorized` type URI, not collapsing it
/// into a generic kind.
#[tokio::test]
async fn existing_problem_json_responses_pass_through_unchanged() {
    let app = router(app_state());

    // No Authorization header — auth handler returns problem+json 401.
    let req = Request::builder()
        .method(Method::GET)
        .uri("/v1/version")
        .body(Body::empty())
        .expect("build request");

    let resp = app.oneshot(req).await.expect("router response");
    let (status, ct, _, body) = drain(resp).await;

    assert_eq!(status, StatusCode::UNAUTHORIZED);
    assert_problem_json(&ct, status, &body);

    let type_uri = body.get("type").and_then(Value::as_str).unwrap();
    assert_eq!(
        type_uri, "/problems/unauthorized",
        "auth-layer problem document must keep its stable type URI",
    );
}