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:
2026-06-04 13:44:35 -04:00
parent 58d2cb2848
commit 6c5f6f1fba
81 changed files with 9893 additions and 463 deletions

View 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();
}