AFI parity: close all 35 gaps — every adapter wires every AFI-common capability
The conformance board (tests/afi/test_capability_parity.py) is now fully green: 90 capability cells + 4 meta-locks + 3 codegen byte-parity = 97 passed. The gaps the prose table used to launder as "Django-only" / "out of scope" are wired, against the pinned-spec model (single-authored spec, byte-identical conformance across languages) — never per-language reimplementation. FastAPI — edge_manifest + PSR (logic single-sourced in mizan_core.manifest), WebSocket RPC (/ws/ through the shared dispatch), SSR (the framework-agnostic SSRBridge relocated to mizan_core.ssr; Django rides it from there), Shapes (SQLAlchemy projection, same declaration surface as django-readers), Forms (Pydantic schema/validate/submit). Rust (Axum + Tauri + cores/mizan-rust) — X-Mizan-Invalidate header, auth= enforcement, origin HMAC cache, edge manifest + PSR, WebSocket handler / IPC subscription channel, multipart upload, SSR bridge, Shapes, Forms; JWT/MWT mint+verify and cache-key derivation byte-pinned to the Python reference (cache_keys_pin, token_pin, invalidate_header_pin). TypeScript — a KDL IR emitter byte-identical to the Python build_ir (so a TS backend can feed the codegen — the largest gap), multipart upload, session-init, WebSocket transport, SSR bridge, JWT/MWT mint (pinned to Python), Shapes, Forms. Verified in the merged tree: core 25, fastapi 74, django 353/21-skip, mizan-rust (incl. cross-language pins) green, axum 10, tauri 8, mizan-ts 103/2-skip. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
422
backends/mizan-rust-axum/tests/behavior.rs
Normal file
422
backends/mizan-rust-axum/tests/behavior.rs
Normal file
@@ -0,0 +1,422 @@
|
||||
//! 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<String>,
|
||||
}
|
||||
|
||||
#[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<dyn CacheBackend> = 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();
|
||||
}
|
||||
Reference in New Issue
Block a user