//! 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 { 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 = 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>> = Arc::new(Mutex::new(Vec::new())); let sink = captured.clone(); let channel: Channel = 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 = 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 { pairs.iter().map(|(k, v)| (k.to_string(), v.clone())).collect() }