//! Runtime behavior tests for the axum adapter — the conformance ceiling that //! the source-presence probes set the floor for. Each AFI-common HTTP cell is //! driven end to end through the real router (`tower::ServiceExt::oneshot`, //! no socket) and asserted on the wire bytes/headers; the WebSocket cell runs //! against a real bound port. use axum::body::Body; use axum::http::{Request, StatusCode}; use http_body_util::BodyExt; use mizan_core as mizan; use mizan_core::prelude::*; use mizan_core::{ AuthConfig, CacheBackend, CacheOrchestrator, JwtConfig, MemoryCache, RequestHandle, Upload, }; use mizan_axum::{router, MizanState}; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use std::sync::Arc; use tower::ServiceExt; // ─── Fixture: the functions these tests dispatch ──────────────────────────── #[derive(Mizan, Serialize, Deserialize, Debug, Clone)] pub struct Profile { pub user_id: i64, pub name: String, } #[derive(Mizan, Serialize, Deserialize, Debug, Clone)] pub struct Ok { pub ok: bool, } #[derive(Mizan, Serialize, Deserialize, Debug, Clone)] pub struct Secret { pub flag: String, } #[derive(Mizan, Serialize, Deserialize, Debug, Clone)] pub struct UploadEcho { pub filename: String, pub size: i64, } #[derive(Mizan, Serialize, Deserialize, Debug, Clone)] pub struct SchemaOut { pub fields: Vec, } #[mizan::context("bprofile")] pub struct BProfileCtx; #[mizan::client(context = BProfileCtx)] pub async fn b_user_profile(_req: &RequestHandle<'_>, user_id: i64) -> Profile { Profile { user_id, name: format!("user-{user_id}"), } } #[mizan::client(affects = BProfileCtx)] pub async fn b_update_profile(_req: &RequestHandle<'_>, user_id: i64, name: String) -> Ok { let _ = (user_id, name); Ok { ok: true } } #[mizan::client(auth = "staff")] pub async fn b_secret(_req: &RequestHandle<'_>) -> Secret { Secret { flag: "top-secret".into(), } } #[mizan::client(websocket)] pub async fn b_ping(_req: &RequestHandle<'_>, n: i64) -> Ok { let _ = n; Ok { ok: true } } #[mizan::client] pub async fn b_set_avatar(_req: &RequestHandle<'_>, user_id: i64, avatar: Upload) -> UploadEcho { let _ = user_id; UploadEcho { filename: avatar.filename.clone().unwrap_or_default(), size: avatar.size() as i64, } } #[mizan::client(form_name = "contact", form_role = "submit")] pub async fn b_contact_submit(_req: &RequestHandle<'_>, name: String) -> Ok { let _ = name; Ok { ok: true } } #[mizan::client(form_name = "contact", form_role = "schema")] pub async fn b_contact_schema(_req: &RequestHandle<'_>) -> SchemaOut { SchemaOut { fields: vec!["name".into()], } } // ─── Helpers ──────────────────────────────────────────────────────────────── fn stateless_app() -> axum::Router { router(MizanState::builder().build()) } async fn body_json(resp: axum::response::Response) -> Value { let bytes = resp.into_body().collect().await.unwrap().to_bytes(); serde_json::from_slice(&bytes).unwrap() } async fn post_call(app: &axum::Router, fn_name: &str, args: Value) -> axum::response::Response { let req = Request::builder() .method("POST") .uri("/call/") .header("content-type", "application/json") .body(Body::from(json!({"fn": fn_name, "args": args}).to_string())) .unwrap(); app.clone().oneshot(req).await.unwrap() } // ─── invalidate_header + invalidate_body + rpc_call ────────────────────────── #[tokio::test] async fn call_emits_invalidate_body_and_header() { let app = stateless_app(); let resp = post_call(&app, "b_update_profile", json!({"user_id": 7, "name": "Z"})).await; assert_eq!(resp.status(), StatusCode::OK); // The header is co-equal with the body channel: scoped to user_id=7. let header = resp .headers() .get("X-Mizan-Invalidate") .expect("X-Mizan-Invalidate present") .to_str() .unwrap() .to_string(); assert_eq!(header, "bprofile;user_id=7"); assert_eq!( resp.headers().get("cache-control").unwrap(), "no-store" ); let body = body_json(resp).await; assert_eq!(body["result"], json!({"ok": true})); // Body invalidate entry is the scoped object form. assert_eq!( body["invalidate"], json!([{"context": "bprofile", "params": {"user_id": 7}}]) ); } // ─── auth_enforcement ──────────────────────────────────────────────────────── #[tokio::test] async fn auth_guard_rejects_anonymous_and_admits_staff() { // No auth config + a staff-guarded fn → anonymous is rejected 401. let app = stateless_app(); let resp = post_call(&app, "b_secret", json!({})).await; assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); // With a JWT config + a staff token, the same call is admitted. Mint at // the real clock so the token is unexpired when the handler verifies it. let cfg = JwtConfig::new("beh-secret"); let token = mizan::create_access_token(&cfg, "1", "sid", /*staff*/ true, false, mizan::now_unix()); let auth = AuthConfig { jwt: Some(cfg), mwt_secret: None, mwt_audience: "mizan".into(), }; let app = router(MizanState::builder().auth(auth).build()); let req = Request::builder() .method("POST") .uri("/call/") .header("content-type", "application/json") .header("authorization", format!("Bearer {token}")) .body(Body::from(json!({"fn": "b_secret", "args": {}}).to_string())) .unwrap(); let resp = app.oneshot(req).await.unwrap(); assert_eq!(resp.status(), StatusCode::OK); let body = body_json(resp).await; assert_eq!(body["result"], json!({"flag": "top-secret"})); } #[tokio::test] async fn auth_guard_forbids_non_staff_token() { // A valid but non-staff token → 403 on a staff-guarded fn. let cfg = JwtConfig::new("beh-secret"); let token = mizan::create_access_token(&cfg, "2", "sid", /*staff*/ false, false, mizan::now_unix()); let auth = AuthConfig { jwt: Some(cfg), mwt_secret: None, mwt_audience: "mizan".into(), }; let app = router(MizanState::builder().auth(auth).build()); let req = Request::builder() .method("POST") .uri("/call/") .header("content-type", "application/json") .header("authorization", format!("Bearer {token}")) .body(Body::from(json!({"fn": "b_secret", "args": {}}).to_string())) .unwrap(); let resp = app.oneshot(req).await.unwrap(); assert_eq!(resp.status(), StatusCode::FORBIDDEN); } #[tokio::test] async fn invalid_token_is_rejected_not_downgraded() { // A present-but-bad bearer rejects (401) even on an unguarded context — // the INVALID-sentinel contract. let auth = AuthConfig { jwt: Some(JwtConfig::new("beh-secret")), mwt_secret: None, mwt_audience: "mizan".into(), }; let app = router(MizanState::builder().auth(auth).build()); let req = Request::builder() .method("GET") .uri("/ctx/bprofile/?user_id=1") .header("authorization", "Bearer not-a-real-token") .body(Body::empty()) .unwrap(); let resp = app.oneshot(req).await.unwrap(); assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); } // ─── origin_cache ──────────────────────────────────────────────────────────── #[tokio::test] async fn context_fetch_uses_origin_cache() { let backend: Arc = Arc::new(MemoryCache::new()); let cache = CacheOrchestrator::new(Some(backend.clone()), Some("cache-secret".into())); let app = router(MizanState::builder().cache(cache).build()); // First fetch: MISS, populates the cache. let req = Request::builder() .uri("/ctx/bprofile/?user_id=3") .body(Body::empty()) .unwrap(); let resp = app.clone().oneshot(req).await.unwrap(); assert_eq!(resp.headers().get("X-Mizan-Cache").unwrap(), "MISS"); let first = body_json(resp).await; assert_eq!(first["b_user_profile"]["user_id"], json!(3)); // Second fetch: HIT, served from cache. let req = Request::builder() .uri("/ctx/bprofile/?user_id=3") .body(Body::empty()) .unwrap(); let resp = app.clone().oneshot(req).await.unwrap(); assert_eq!(resp.headers().get("X-Mizan-Cache").unwrap(), "HIT"); let second = body_json(resp).await; assert_eq!(first, second); // A mutation scoped to user_id=3 purges that key → next fetch MISSes. let _ = post_call(&app, "b_update_profile", json!({"user_id": 3, "name": "New"})).await; let req = Request::builder() .uri("/ctx/bprofile/?user_id=3") .body(Body::empty()) .unwrap(); let resp = app.oneshot(req).await.unwrap(); assert_eq!(resp.headers().get("X-Mizan-Cache").unwrap(), "MISS"); } // ─── upload ────────────────────────────────────────────────────────────────── #[tokio::test] async fn multipart_upload_binds_into_input() { let app = stateless_app(); let boundary = "----mizanbeh"; let file_bytes = b"PNGDATA-0123456789"; let body = format!( "--{b}\r\nContent-Disposition: form-data; name=\"fn\"\r\n\r\nb_set_avatar\r\n\ --{b}\r\nContent-Disposition: form-data; name=\"args\"\r\n\r\n{{\"user_id\":9}}\r\n\ --{b}\r\nContent-Disposition: form-data; name=\"avatar\"; filename=\"a.png\"\r\n\ Content-Type: image/png\r\n\r\n{data}\r\n--{b}--\r\n", b = boundary, data = String::from_utf8_lossy(file_bytes), ); let req = Request::builder() .method("POST") .uri("/call/") .header( "content-type", format!("multipart/form-data; boundary={boundary}"), ) .body(Body::from(body)) .unwrap(); let resp = app.oneshot(req).await.unwrap(); assert_eq!(resp.status(), StatusCode::OK); let body = body_json(resp).await; assert_eq!(body["result"]["filename"], json!("a.png")); assert_eq!(body["result"]["size"], json!(file_bytes.len())); } // ─── edge_manifest + psr ───────────────────────────────────────────────────── #[tokio::test] async fn manifest_and_psr_descriptor() { let app = stateless_app(); let req = Request::builder() .uri("/manifest/") .body(Body::empty()) .unwrap(); let manifest = body_json(app.clone().oneshot(req).await.unwrap()).await; // bprofile is user-scoped (user_id) → dynamic_cached. assert_eq!( manifest["contexts"]["bprofile"]["render_strategy"], json!("dynamic_cached") ); assert_eq!( manifest["mutations"]["b_update_profile"]["affects"], json!(["bprofile"]) ); // Per-context PSR descriptor. let req = Request::builder() .uri("/psr/bprofile/") .body(Body::empty()) .unwrap(); let psr = body_json(app.oneshot(req).await.unwrap()).await; assert_eq!(psr["render_strategy"], json!("dynamic_cached")); } // ─── shapes ────────────────────────────────────────────────────────────────── #[tokio::test] async fn shape_projection_endpoint() { let app = stateless_app(); let req = Request::builder() .uri("/shape/b_user_profile/") .body(Body::empty()) .unwrap(); let resp = app.oneshot(req).await.unwrap(); assert_eq!(resp.status(), StatusCode::OK); let body = body_json(resp).await; // Output type name is camelCased by the macro (`b_user_profile` → // `bUserProfile`), suffixed `Output`. assert_eq!(body["type"], json!("bUserProfileOutput")); let fields = body["fields"].as_array().unwrap(); assert!(fields.contains(&json!("user_id"))); assert!(fields.contains(&json!("name"))); } // ─── forms ─────────────────────────────────────────────────────────────────── #[tokio::test] async fn forms_schema_and_submit_routes() { let app = stateless_app(); let req = Request::builder() .method("POST") .uri("/form/contact/schema/") .header("content-type", "application/json") .body(Body::from("{}")) .unwrap(); let resp = app.clone().oneshot(req).await.unwrap(); assert_eq!(resp.status(), StatusCode::OK); let body = body_json(resp).await; assert_eq!(body["fields"], json!(["name"])); let req = Request::builder() .method("POST") .uri("/form/contact/submit/") .header("content-type", "application/json") .body(Body::from(json!({"name": "Ada"}).to_string())) .unwrap(); let resp = app.oneshot(req).await.unwrap(); assert_eq!(resp.status(), StatusCode::OK); assert_eq!(body_json(resp).await, json!({"ok": true})); } // ─── websocket ─────────────────────────────────────────────────────────────── #[tokio::test] async fn websocket_transport_dispatches_and_rejects_non_ws_fn() { use tokio_tungstenite::tungstenite::Message; // Bind a real socket — the WS upgrade needs an actual connection. let app = stateless_app(); let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); let server = tokio::spawn(async move { axum::serve(listener, app).await.unwrap(); }); let url = format!("ws://{addr}/ws/"); let (mut socket, _) = tokio_tungstenite::connect_async(&url).await.unwrap(); // A websocket-declared fn dispatches. use futures_util::{SinkExt, StreamExt}; socket .send(Message::Text( json!({"id": 1, "op": "call", "fn": "b_ping", "args": {"n": 5}}).to_string(), )) .await .unwrap(); let reply = socket.next().await.unwrap().unwrap(); let v: Value = serde_json::from_str(reply.to_text().unwrap()).unwrap(); assert_eq!(v["id"], json!(1)); assert_eq!(v["result"], json!({"ok": true})); // A non-websocket fn over WS is rejected (transport boundary enforced). socket .send(Message::Text( json!({"id": 2, "op": "call", "fn": "b_user_profile", "args": {"user_id": 1}}) .to_string(), )) .await .unwrap(); let reply = socket.next().await.unwrap().unwrap(); let v: Value = serde_json::from_str(reply.to_text().unwrap()).unwrap(); assert_eq!(v["id"], json!(2)); assert!(v["error"]["message"] .as_str() .unwrap() .contains("WebSocket transport")); server.abort(); }