Files
mizan/backends/mizan-tauri/tests/behavior.rs
Ryth Azhur 6c5f6f1fba 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>
2026-06-04 13:44:35 -04:00

371 lines
13 KiB
Rust

//! Runtime behavior tests for the Tauri IPC adapter — the conformance ceiling
//! over the source-presence probes. Each IPC-applicable cell is driven through
//! the real dispatch path against a mock Tauri `AppHandle`
//! (`tauri::test::mock_app`), asserting on the response JSON / error / channel
//! frames. The IPC serialization boundary is exercised by Tauri's own
//! `get_ipc_response` machinery in integration; here we drive `dispatch` /
//! `subscribe` (the programmatic entry points the commands wrap) so the
//! protocol logic — auth, cache, upload binding, shapes, forms, subscription —
//! is asserted directly.
use mizan_core as mizan;
use mizan_core::prelude::*;
use mizan_core::{
AuthConfig, CacheBackend, CacheOrchestrator, JwtConfig, MemoryCache, RequestHandle, Upload,
};
use mizan_tauri::{dispatch, subscribe, Envelope, MizanTauriConfig, SubscriptionFrame};
use serde::{Deserialize, Serialize};
use serde_json::{json, Map, Value};
use std::sync::{Arc, Mutex};
use tauri::ipc::Channel;
use tauri::test::mock_app;
use tauri::{AppHandle, Manager};
// ─── Fixture functions (auto-registered via linkme at link time) ────────────
#[derive(Mizan, Serialize, Deserialize, Debug, Clone)]
pub struct TProfile {
pub user_id: i64,
pub name: String,
}
#[derive(Mizan, Serialize, Deserialize, Debug, Clone)]
pub struct TOk {
pub ok: bool,
}
#[derive(Mizan, Serialize, Deserialize, Debug, Clone)]
pub struct TSecret {
pub flag: String,
}
#[derive(Mizan, Serialize, Deserialize, Debug, Clone)]
pub struct TUploadEcho {
pub filename: String,
pub size: i64,
}
#[mizan::context("tprofile")]
pub struct TProfileCtx;
#[mizan::client(context = TProfileCtx)]
pub async fn t_user_profile(_req: &RequestHandle<'_>, user_id: i64) -> TProfile {
TProfile {
user_id,
name: format!("user-{user_id}"),
}
}
#[mizan::client(affects = TProfileCtx)]
pub async fn t_update_profile(_req: &RequestHandle<'_>, user_id: i64, name: String) -> TOk {
let _ = (user_id, name);
TOk { ok: true }
}
#[mizan::client(auth = "staff")]
pub async fn t_secret(_req: &RequestHandle<'_>) -> TSecret {
TSecret {
flag: "ipc-secret".into(),
}
}
#[mizan::client(websocket)]
pub async fn t_watch(_req: &RequestHandle<'_>, room: i64) -> TOk {
let _ = room;
TOk { ok: true }
}
#[mizan::client]
pub async fn t_set_avatar(_req: &RequestHandle<'_>, user_id: i64, avatar: Upload) -> TUploadEcho {
let _ = user_id;
TUploadEcho {
filename: avatar.filename.clone().unwrap_or_default(),
size: avatar.size() as i64,
}
}
#[mizan::client(form_name = "tcontact", form_role = "submit")]
pub async fn t_contact_submit(_req: &RequestHandle<'_>, name: String) -> TOk {
let _ = name;
TOk { ok: true }
}
// ─── Harness ────────────────────────────────────────────────────────────────
/// Build a mock app with the given Mizan config managed.
fn app_with(config: MizanTauriConfig) -> AppHandle<tauri::test::MockRuntime> {
let app = mock_app();
let handle = app.handle().clone();
handle.manage(config);
// Leak the app so its `AppHandle` stays valid for the test body; the
// process tears down at test end.
std::mem::forget(app);
handle
}
fn rt() -> tokio::runtime::Runtime {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap()
}
// ─── rpc_call + invalidate_body ──────────────────────────────────────────────
#[test]
fn call_returns_result_and_invalidate() {
let handle = app_with(MizanTauriConfig::default());
rt().block_on(async {
let env = Envelope::Call {
function_name: "t_update_profile".into(),
args: obj(&[("user_id", json!(7)), ("name", json!("Z"))]),
token: None,
};
let resp = dispatch(&handle, env).await.unwrap();
assert_eq!(resp["result"], json!({"ok": true}));
// IPC carries invalidation in the envelope (no header channel).
assert_eq!(
resp["invalidate"],
json!([{"context": "tprofile", "params": {"user_id": 7}}])
);
});
}
// ─── auth_enforcement ────────────────────────────────────────────────────────
#[test]
fn auth_guard_over_ipc() {
rt().block_on(async {
// No auth config → anonymous → staff-guarded fn rejected.
let handle = app_with(MizanTauriConfig::default());
let err = dispatch(
&handle,
Envelope::Call {
function_name: "t_secret".into(),
args: Map::new(),
token: None,
},
)
.await
.unwrap_err();
assert!(matches!(err, mizan::MizanError::Unauthorized(_)));
// Staff JWT on the envelope token → admitted.
let cfg = JwtConfig::new("ipc-secret");
let token = mizan::create_access_token(&cfg, "1", "sid", true, false, mizan::now_unix());
let config = MizanTauriConfig {
auth: AuthConfig {
jwt: Some(cfg),
mwt_secret: None,
mwt_audience: "mizan".into(),
},
cache: CacheOrchestrator::disabled(),
};
let handle = app_with(config);
let resp = dispatch(
&handle,
Envelope::Call {
function_name: "t_secret".into(),
args: Map::new(),
token: Some(format!("Bearer {token}")),
},
)
.await
.unwrap();
assert_eq!(resp["result"]["flag"], json!("ipc-secret"));
});
}
#[test]
fn invalid_token_rejected_over_ipc() {
rt().block_on(async {
let config = MizanTauriConfig {
auth: AuthConfig {
jwt: Some(JwtConfig::new("ipc-secret")),
mwt_secret: None,
mwt_audience: "mizan".into(),
},
cache: CacheOrchestrator::disabled(),
};
let handle = app_with(config);
let err = dispatch(
&handle,
Envelope::Fetch {
context: "tprofile".into(),
params: obj(&[("user_id", json!(1))]),
token: Some("Bearer garbage".into()),
},
)
.await
.unwrap_err();
assert!(matches!(err, mizan::MizanError::Unauthorized(_)));
});
}
// ─── origin_cache ────────────────────────────────────────────────────────────
#[test]
fn fetch_uses_origin_cache() {
rt().block_on(async {
let backend: Arc<dyn CacheBackend> = Arc::new(MemoryCache::new());
let cache = CacheOrchestrator::new(Some(backend.clone()), Some("ipc-cache-secret".into()));
let config = MizanTauriConfig {
auth: AuthConfig::new(),
cache,
};
let handle = app_with(config);
let fetch = || Envelope::Fetch {
context: "tprofile".into(),
params: obj(&[("user_id", json!(3))]),
token: None,
};
let first = dispatch(&handle, fetch()).await.unwrap();
assert_eq!(first["t_user_profile"]["user_id"], json!(3));
// The cache now holds the bundle — confirm a key exists under the
// context prefix (proves the put happened).
let key = mizan::derive_cache_key(
"ipc-cache-secret",
"tprofile",
&std::collections::BTreeMap::from([("user_id".to_string(), json!(3))]),
None,
0,
);
assert!(backend.get(&key).is_some(), "fetch populated the origin cache");
// Second fetch returns the same bundle (served from cache).
let second = dispatch(&handle, fetch()).await.unwrap();
assert_eq!(first, second);
// A scoped mutation purges the key.
let _ = dispatch(
&handle,
Envelope::Call {
function_name: "t_update_profile".into(),
args: obj(&[("user_id", json!(3)), ("name", json!("New"))]),
token: None,
},
)
.await
.unwrap();
assert!(backend.get(&key).is_none(), "mutation purged the cache key");
});
}
// ─── upload ──────────────────────────────────────────────────────────────────
#[test]
fn upload_binds_from_envelope() {
use base64::engine::general_purpose::STANDARD;
use base64::Engine;
rt().block_on(async {
let handle = app_with(MizanTauriConfig::default());
let data = b"IPC-FILE-BYTES";
let file = json!({
"filename": "a.png",
"content_type": "image/png",
"data_b64": STANDARD.encode(data),
});
let resp = dispatch(
&handle,
Envelope::Call {
function_name: "t_set_avatar".into(),
args: obj(&[("user_id", json!(9)), ("avatar", file)]),
token: None,
},
)
.await
.unwrap();
assert_eq!(resp["result"]["filename"], json!("a.png"));
assert_eq!(resp["result"]["size"], json!(data.len()));
});
}
// ─── shapes ──────────────────────────────────────────────────────────────────
#[test]
fn shape_op_projects_output() {
rt().block_on(async {
let handle = app_with(MizanTauriConfig::default());
let resp = dispatch(
&handle,
Envelope::Shape {
function_name: "t_user_profile".into(),
},
)
.await
.unwrap();
assert_eq!(resp["type"], json!("tUserProfileOutput"));
let fields = resp["fields"].as_array().unwrap();
assert!(fields.contains(&json!("user_id")));
assert!(fields.contains(&json!("name")));
});
}
// ─── forms ───────────────────────────────────────────────────────────────────
#[test]
fn form_submit_op() {
rt().block_on(async {
let handle = app_with(MizanTauriConfig::default());
let resp = dispatch(
&handle,
Envelope::Form {
form: "tcontact".into(),
role: "submit".into(),
args: json!({"name": "Ada"}),
},
)
.await
.unwrap();
assert_eq!(resp, json!({"ok": true}));
});
}
// ─── websocket-equivalent: subscription channel ──────────────────────────────
#[test]
fn subscription_pushes_frame_and_rejects_non_ws_fn() {
rt().block_on(async {
let handle = app_with(MizanTauriConfig::default());
// A websocket-declared fn pushes a frame on the channel.
let captured: Arc<Mutex<Vec<Value>>> = Arc::new(Mutex::new(Vec::new()));
let sink = captured.clone();
let channel: Channel<SubscriptionFrame> = Channel::new(move |body| {
// The channel serializes the SubscriptionFrame to JSON; read it
// back as a generic Value.
let v: Value = body.deserialize().unwrap_or(Value::Null);
sink.lock().unwrap().push(v);
Ok(())
});
subscribe(&handle, "t_watch", obj(&[("room", json!(1))]), channel)
.await
.unwrap();
let frames = captured.lock().unwrap();
assert_eq!(frames.len(), 1, "subscription pushed exactly one frame");
assert_eq!(frames[0]["result"], json!({"ok": true}));
// A non-websocket fn over the subscription transport is rejected.
let reject_channel: Channel<SubscriptionFrame> = Channel::new(|_| Ok(()));
let err = subscribe(
&handle,
"t_user_profile",
obj(&[("user_id", json!(1))]),
reject_channel,
)
.await
.unwrap_err();
assert!(err.message().contains("subscription transport"));
});
}
// ─── helpers ──────────────────────────────────────────────────────────────────
fn obj(pairs: &[(&str, Value)]) -> Map<String, Value> {
pairs.iter().map(|(k, v)| (k.to_string(), v.clone())).collect()
}