1use sc_observability_binding_runtime::HostLoggingBackend;
6use sc_observability_dto::{
7 AdmissionDto, Failure, HealthRequest, LogEventDto, LogHealthDto, LogSnapshotDto, QueryRequest,
8 TryLogRequest, WireEnvelope, decode_event, decode_query, decode_timeout, is_protected_key,
9 normalize_field_key,
10};
11use serde::{Deserialize, Serialize, de::DeserializeOwned};
12use serde_json::Value;
13use std::{collections::BTreeSet, sync::Arc, time::Duration};
14
15const SCHEMA_VERSION: u32 = 1;
16const MAX_REQUEST_BYTES: usize = 65_536;
17const MAX_DEPTH: usize = 32;
18const REDACTED: &str = "[REDACTED]";
19
20#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
22pub struct AdapterPolicy {
23 pub allowed_window_labels: BTreeSet<String>,
24 pub allowed_targets: BTreeSet<String>,
25 pub max_request_bytes: u32,
26 pub max_depth: u32,
27 pub redacted_field_keys: BTreeSet<String>,
28}
29
30impl AdapterPolicy {
31 pub fn validate(&self) -> Result<(), Failure> {
32 if self.allowed_window_labels.is_empty() || self.allowed_targets.is_empty() {
33 return Err(invalid(
34 "policy",
35 "window and target allowlists must not be empty",
36 ));
37 }
38 if self.max_request_bytes == 0 || self.max_request_bytes as usize > MAX_REQUEST_BYTES {
39 return Err(invalid("policy.max_request_bytes", "must be in 1..65536"));
40 }
41 if self.max_depth == 0 || self.max_depth as usize > MAX_DEPTH {
42 return Err(invalid("policy.max_depth", "must be in 1..32"));
43 }
44 if self
45 .allowed_window_labels
46 .iter()
47 .any(|label| label.is_empty() || !tauri_runtime::window::is_label_valid(label))
48 {
49 return Err(invalid(
50 "policy.allowed_window_labels",
51 "labels must be valid Tauri window labels",
52 ));
53 }
54 if self
55 .allowed_targets
56 .iter()
57 .any(|target| sc_observability_types::TargetCategory::new(target).is_err())
58 {
59 return Err(invalid(
60 "policy.allowed_targets",
61 "targets must be valid target categories",
62 ));
63 }
64 if self.redacted_field_keys.iter().any(|key| is_protected_key(key)) {
65 return Err(invalid(
66 "policy.redacted_field_keys",
67 "protected provenance keys are host-owned",
68 ));
69 }
70 Ok(())
71 }
72}
73
74#[derive(Debug, Clone, PartialEq, Serialize)]
76#[serde(tag = "kind", rename_all = "snake_case")]
77pub enum WireResult<T> {
78 Ok { value: T },
79 Error { error: Failure },
80}
81
82fn invalid(field: &str, message: &str) -> Failure {
83 Failure::Validation {
84 diagnostic: Box::new(sc_observability_dto::boundary_diagnostic(
85 sc_observability_dto::error_codes::SC_OBSERVABILITY_BINDING_INVALID_INPUT,
86 message,
87 )),
88 field: field.to_owned(),
89 }
90}
91
92fn envelope<T>(result: Result<T, Failure>) -> WireEnvelope<T> {
93 match result {
94 Ok(value) => WireEnvelope::Ok {
95 schema_version: SCHEMA_VERSION,
96 value,
97 },
98 Err(error) => WireEnvelope::Error {
99 schema_version: SCHEMA_VERSION,
100 error,
101 },
102 }
103}
104
105fn inspect(value: &Value, depth: usize, limit: usize) -> Result<(), Failure> {
106 match value {
107 Value::Array(values) => {
108 if depth >= limit {
109 return Err(invalid("request", "maximum container depth is 32"));
110 }
111 values.iter().try_for_each(|item| inspect(item, depth + 1, limit))
112 }
113 Value::Object(values) => {
114 if depth >= limit {
115 return Err(invalid("request", "maximum container depth is 32"));
116 }
117 values.values().try_for_each(|item| inspect(item, depth + 1, limit))
118 }
119 _ => Ok(()),
120 }
121}
122
123fn strict_object(value: &Value, allowed: &[&str], field: &str) -> Result<(), Failure> {
124 let object = value.as_object().ok_or_else(|| invalid(field, "request must be an object"))?;
125 if object.keys().any(|key| !allowed.contains(&key.as_str())) {
126 return Err(invalid(field, "unknown field"));
127 }
128 Ok(())
129}
130
131fn strict_value(value: &Value, field: &str) -> Result<(), Failure> {
132 let object = value.as_object().ok_or_else(|| invalid(field, "value must be a tagged object"))?;
133 let kind = object.get("kind").and_then(Value::as_str).ok_or_else(|| invalid(field, "value kind is required"))?;
134 let allowed = match kind {
135 "null" => &["kind"][..],
136 "boolean" | "string" | "integer" | "float" | "array" | "object" => &["kind", "value"][..],
137 _ => return Err(invalid(field, "unknown value kind")),
138 };
139 strict_object(value, allowed, field)?;
140 match kind {
141 "array" => object.get("value").and_then(Value::as_array).ok_or_else(|| invalid(field, "array value is required"))?
142 .iter().enumerate().try_for_each(|(index, child)| strict_value(child, &format!("{field}.value[{index}]")))?,
143 "object" => object.get("value").and_then(Value::as_object).ok_or_else(|| invalid(field, "object value is required"))?
144 .iter().try_for_each(|(key, child)| strict_value(child, &format!("{field}.value.{key}")))?,
145 _ => {}
146 }
147 Ok(())
148}
149
150fn strict_request(value: &Value, operation: &str) -> Result<(), Failure> {
151 match operation {
152 "try_log" => {
153 strict_object(value, &["schema_version", "event"], "request")?;
154 let event = value.get("event").ok_or_else(|| invalid("event", "event is required"))?;
155 strict_object(event, &["schema_version", "level", "target", "action", "message", "trace", "request_id", "correlation_id", "outcome", "fields"], "event")?;
156 if let Some(fields) = event.get("fields") {
157 fields.as_object().ok_or_else(|| invalid("event.fields", "fields must be an object"))?
158 .iter().try_for_each(|(key, value)| strict_value(value, &format!("event.fields.{key}")))?;
159 }
160 }
161 "query" => {
162 strict_object(value, &["schema_version", "query"], "request")?;
163 let query = value.get("query").ok_or_else(|| invalid("query", "query is required"))?;
164 strict_object(query, &["schema_version", "service", "levels", "target", "action", "request_id", "correlation_id", "since", "until", "field_matches", "limit", "order"], "query")?;
165 if let Some(matches) = query.get("field_matches").and_then(Value::as_array) {
166 for (index, entry) in matches.iter().enumerate() {
167 strict_object(entry, &["field", "value"], &format!("query.field_matches[{index}]"))?;
168 if let Some(value) = entry.get("value") { strict_value(value, &format!("query.field_matches[{index}].value"))?; }
169 }
170 }
171 }
172 "health" => strict_object(value, &["schema_version"], "request")?,
173 "flush" => strict_object(value, &["schema_version", "timeout_ms"], "request")?,
174 _ => return Err(invalid("request", "unknown operation")),
175 }
176 Ok(())
177}
178
179fn parse<T: DeserializeOwned>(
180 value: Value,
181 policy: &AdapterPolicy,
182 field: &str,
183 operation: &str,
184) -> Result<T, Failure> {
185 let bytes = serde_json::to_vec(&value)
186 .map_err(|_| invalid(field, "request could not be serialized"))?;
187 if bytes.len() > policy.max_request_bytes as usize {
188 return Err(invalid(field, "request exceeds configured size limit"));
189 }
190 inspect(&value, 0, policy.max_depth as usize)?;
191 strict_request(&value, operation)?;
192 serde_json::from_value(value).map_err(|_| invalid(field, "request does not match schema v1"))
193}
194
195fn schema(value: &Value, field: &str) -> Result<(), Failure> {
196 let version = value
197 .get("schema_version")
198 .and_then(Value::as_u64)
199 .and_then(|v| u32::try_from(v).ok())
200 .ok_or_else(|| invalid(field, "schema_version must be an integer"))?;
201 if version != SCHEMA_VERSION {
202 return Err(Failure::UnsupportedVersion {
203 diagnostic: Box::new(sc_observability_dto::boundary_diagnostic(
204 sc_observability_dto::error_codes::SC_OBSERVABILITY_BINDING_UNSUPPORTED_VERSION,
205 "unsupported schema version",
206 )),
207 received: version,
208 });
209 }
210 Ok(())
211}
212
213fn authorize(policy: &AdapterPolicy, window: &str) -> Result<(), Failure> {
214 if policy.allowed_window_labels.contains(window) {
215 Ok(())
216 } else {
217 Err(Failure::PermissionDenied {
218 diagnostic: Box::new(sc_observability_dto::boundary_diagnostic(
219 sc_observability_dto::error_codes::SC_OBSERVABILITY_BINDING_PERMISSION_DENIED,
220 "invoking window is not authorized",
221 )),
222 })
223 }
224}
225
226fn redact_value(value: &mut sc_observability_dto::ValueDto, keys: &BTreeSet<String>) {
227 if let sc_observability_dto::ValueDto::Object { value: object } = value {
228 for (key, child) in object.iter_mut() {
229 if keys.contains(key)
230 || keys.iter().any(|configured| normalize_field_key(configured) == normalize_field_key(key))
231 {
232 *child = sc_observability_dto::ValueDto::String {
233 value: REDACTED.to_owned(),
234 };
235 } else {
236 redact_value(child, keys);
237 }
238 }
239 } else if let sc_observability_dto::ValueDto::Array { value: values } = value {
240 values
241 .iter_mut()
242 .for_each(|child| redact_value(child, keys));
243 }
244}
245
246fn redact(mut event: LogEventDto, keys: &BTreeSet<String>) -> LogEventDto {
247 event
248 .fields
249 .values_mut()
250 .for_each(|value| redact_value(value, keys));
251 event
252}
253
254#[derive(Clone)]
256pub struct Adapter {
257 backend: Arc<dyn HostLoggingBackend>,
258 policy: AdapterPolicy,
259}
260
261impl std::fmt::Debug for Adapter {
262 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
263 f.debug_struct("Adapter")
264 .field("policy", &self.policy)
265 .finish_non_exhaustive()
266 }
267}
268
269impl Adapter {
270 pub fn new(
271 backend: Arc<dyn HostLoggingBackend>,
272 policy: AdapterPolicy,
273 ) -> Result<Self, Failure> {
274 policy.validate()?;
275 Ok(Self { backend, policy })
276 }
277
278 pub fn try_log(&self, window: &str, value: Value) -> WireEnvelope<AdmissionDto> {
279 envelope(self.try_log_inner(window, value))
280 }
281
282 fn try_log_inner(&self, window: &str, value: Value) -> Result<AdmissionDto, Failure> {
283 authorize(&self.policy, window)?;
284 schema(&value, "request")?;
285 let event_value = value
286 .get("event")
287 .cloned()
288 .ok_or_else(|| invalid("event", "event is required"))?;
289 let _request: TryLogRequest = parse(value, &self.policy, "request", "try_log")?;
290 let event = decode_event(event_value)?;
293 if !self.policy.allowed_targets.contains(&event.target) {
294 return Err(invalid("event.target", "target is not allowed"));
295 }
296 self.backend.try_log(
297 redact(event, &self.policy.redacted_field_keys),
298 sc_observability_binding_runtime::ProducerOrigin::TauriFrontend,
299 )
300 }
301
302 pub async fn query(&self, window: &str, value: Value) -> WireEnvelope<LogSnapshotDto> {
303 envelope(self.query_inner(window, value).await)
304 }
305
306 async fn query_inner(&self, window: &str, value: Value) -> Result<LogSnapshotDto, Failure> {
307 authorize(&self.policy, window)?;
308 schema(&value, "request")?;
309 let request: QueryRequest = parse(value, &self.policy, "request", "query")?;
310 let query = decode_query(
311 serde_json::to_value(request.query)
312 .map_err(|_| invalid("query", "query could not be serialized"))?,
313 )?;
314 if let Some(target) = &query.target {
315 if !self.policy.allowed_targets.contains(target) {
316 return Err(invalid("query.target", "target is not allowed"));
317 }
318 }
319 let targets: Vec<String> = query.target.clone().map_or_else(
320 || self.policy.allowed_targets.iter().cloned().collect(),
321 |target| vec![target],
322 );
323 let mut events = Vec::new();
324 let mut truncated = false;
325 for target in targets {
326 let mut target_query = query.clone();
327 target_query.target = Some(target);
328 let operation = self.backend.start_query(target_query)?;
329 let snapshot = operation.completion(Duration::from_millis(2_000)).await?;
330 truncated |= snapshot.truncated;
331 events.extend(snapshot.events);
332 }
333 events.sort_by(|a, b| a.timestamp.cmp(&b.timestamp));
334 if matches!(query.order, sc_observability_dto::LogOrderDto::NewestFirst) {
335 events.reverse();
336 }
337 if events.len() > query.limit {
338 events.truncate(query.limit);
339 truncated = true;
340 }
341 Ok(LogSnapshotDto {
342 schema_version: SCHEMA_VERSION,
343 events,
344 truncated,
345 })
346 }
347
348 pub fn health(&self, window: &str, value: Value) -> WireEnvelope<LogHealthDto> {
349 envelope(self.health_inner(window, value))
350 }
351
352 fn health_inner(&self, window: &str, value: Value) -> Result<LogHealthDto, Failure> {
353 authorize(&self.policy, window)?;
354 schema(&value, "request")?;
355 let _: HealthRequest = parse(value, &self.policy, "request", "health")?;
356 self.backend.health()
357 }
358
359 pub async fn flush(
360 &self,
361 window: &str,
362 value: Value,
363 ) -> WireEnvelope<sc_observability_dto::CompletionDto> {
364 envelope(self.flush_inner(window, value).await)
365 }
366
367 async fn flush_inner(
368 &self,
369 window: &str,
370 value: Value,
371 ) -> Result<sc_observability_dto::CompletionDto, Failure> {
372 authorize(&self.policy, window)?;
373 schema(&value, "request")?;
374 let request: sc_observability_dto::FlushRequest = parse(value, &self.policy, "request", "flush")?;
375 let timeout = decode_timeout(Value::from(request.timeout_ms))?;
376 self.backend
377 .start_flush(Duration::from_millis(u64::from(timeout)))?
378 .completion(Duration::from_millis(u64::from(timeout)))
379 .await?;
380 Ok(sc_observability_dto::CompletionDto::Completed)
381 }
382}
383
384#[cfg(feature = "tauri")]
385struct ManagedAdapter(Adapter);
386
387#[cfg(feature = "tauri")]
389pub fn plugin<R: tauri::Runtime>(
390 backend: Arc<dyn HostLoggingBackend>,
391 policy: AdapterPolicy,
392) -> Result<tauri::plugin::TauriPlugin<R>, Failure> {
393 let adapter = Adapter::new(backend, policy)?;
394 Ok(tauri::plugin::Builder::new("sc-observability")
395 .setup(move |app, _api| {
396 app.manage(ManagedAdapter(adapter.clone()));
397 Ok(())
398 })
399 .invoke_handler(tauri::generate_handler![
400 sc_observability_try_log,
401 sc_observability_query,
402 sc_observability_health,
403 sc_observability_flush
404 ])
405 .build())
406}
407
408#[cfg(feature = "tauri")]
409use tauri::Manager;
410
411#[cfg(feature = "tauri")]
412#[tauri::command]
413fn sc_observability_try_log<R: tauri::Runtime>(
414 window: tauri::Window<R>,
415 request: Value,
416 state: tauri::State<'_, ManagedAdapter>,
417) -> WireEnvelope<AdmissionDto> {
418 state.0.try_log(window.label(), request)
419}
420
421#[cfg(feature = "tauri")]
422#[tauri::command]
423async fn sc_observability_query<R: tauri::Runtime>(
424 window: tauri::WebviewWindow<R>,
425 app: tauri::AppHandle<R>,
426 request: Value,
427) -> WireEnvelope<LogSnapshotDto> {
428 app.state::<ManagedAdapter>().0.query(window.label(), request).await
429}
430
431#[cfg(feature = "tauri")]
432#[tauri::command]
433fn sc_observability_health<R: tauri::Runtime>(
434 window: tauri::Window<R>,
435 request: Value,
436 state: tauri::State<'_, ManagedAdapter>,
437) -> WireEnvelope<LogHealthDto> {
438 state.0.health(window.label(), request)
439}
440
441#[cfg(feature = "tauri")]
442#[tauri::command]
443async fn sc_observability_flush<R: tauri::Runtime>(
444 window: tauri::WebviewWindow<R>,
445 app: tauri::AppHandle<R>,
446 request: Value,
447) -> WireEnvelope<sc_observability_dto::CompletionDto> {
448 app.state::<ManagedAdapter>().0.flush(window.label(), request).await
449}
450
451#[cfg(test)]
452mod tests {
453 use super::*;
454 use sc_observability_binding_runtime::{Operation, ProducerOrigin};
455 use sc_observability_dto::{CompletionDto, LogQueryDto};
456
457 struct IpcBackend;
458
459 impl HostLoggingBackend for IpcBackend {
460 fn try_log(&self, _: LogEventDto, _: ProducerOrigin) -> Result<AdmissionDto, Failure> {
461 Err(Failure::Internal { diagnostic: Box::new(sc_observability_dto::boundary_diagnostic(
462 sc_observability_dto::error_codes::SC_OBSERVABILITY_BINDING_INTERNAL,
463 "test backend",
464 )) })
465 }
466 fn start_query(&self, _: LogQueryDto) -> Result<Operation<LogSnapshotDto>, Failure> {
467 Err(Failure::Internal { diagnostic: Box::new(sc_observability_dto::boundary_diagnostic(
468 sc_observability_dto::error_codes::SC_OBSERVABILITY_BINDING_INTERNAL,
469 "test backend",
470 )) })
471 }
472 fn health(&self) -> Result<LogHealthDto, Failure> {
473 Err(Failure::Internal { diagnostic: Box::new(sc_observability_dto::boundary_diagnostic(
474 sc_observability_dto::error_codes::SC_OBSERVABILITY_BINDING_INTERNAL,
475 "test backend",
476 )) })
477 }
478 fn start_flush(&self, _: Duration) -> Result<Operation<CompletionDto>, Failure> {
479 Err(Failure::Internal { diagnostic: Box::new(sc_observability_dto::boundary_diagnostic(
480 sc_observability_dto::error_codes::SC_OBSERVABILITY_BINDING_INTERNAL,
481 "test backend",
482 )) })
483 }
484 }
485 #[test]
486 fn policy_rejects_bad_limits_and_provenance() {
487 let policy = AdapterPolicy {
488 allowed_window_labels: ["main".into()].into(),
489 allowed_targets: ["app".into()].into(),
490 max_request_bytes: 65_537,
491 max_depth: 32,
492 redacted_field_keys: BTreeSet::new(),
493 };
494 assert!(policy.validate().is_err());
495 let policy = AdapterPolicy {
496 max_request_bytes: 1,
497 redacted_field_keys: ["sc_observability::binding::language".into()].into(),
498 ..policy
499 };
500 assert!(policy.validate().is_err());
501 let policy = AdapterPolicy {
502 allowed_window_labels: ["bad\nlabel".into()].into(),
503 ..AdapterPolicy {
504 allowed_window_labels: ["main".into()].into(),
505 allowed_targets: ["app".into()].into(),
506 max_request_bytes: MAX_REQUEST_BYTES as u32,
507 max_depth: MAX_DEPTH as u32,
508 redacted_field_keys: BTreeSet::new(),
509 }
510 };
511 assert!(matches!(policy.validate(), Err(Failure::Validation { ref field, .. }) if field == "policy.allowed_window_labels"));
512 let policy = AdapterPolicy {
513 allowed_targets: ["bad target".into()].into(),
514 ..AdapterPolicy {
515 allowed_window_labels: ["main".into()].into(),
516 allowed_targets: ["app".into()].into(),
517 max_request_bytes: MAX_REQUEST_BYTES as u32,
518 max_depth: MAX_DEPTH as u32,
519 redacted_field_keys: BTreeSet::new(),
520 }
521 };
522 assert!(matches!(policy.validate(), Err(Failure::Validation { ref field, .. }) if field == "policy.allowed_targets"));
523 }
524
525 #[test]
526 fn boundary_rejects_unknown_nested_fields_and_wrong_versions() {
527 let request = serde_json::json!({
528 "schema_version": 1,
529 "event": {
530 "schema_version": 1,
531 "level": "info",
532 "target": "app",
533 "action": "test",
534 "message": null,
535 "trace": null,
536 "request_id": null,
537 "correlation_id": null,
538 "outcome": null,
539 "fields": {},
540 "future": true
541 }
542 });
543 assert!(strict_request(&request, "try_log").is_err());
544 assert!(schema(&serde_json::json!({"schema_version": 2}), "request").is_err());
545 }
546
547 #[test]
548 fn boundary_rejects_container_at_limit_but_allows_primitive_leaf() {
549 fn nested_objects(count: usize, leaf: Value) -> Value {
550 (0..count).fold(leaf, |value, _| serde_json::json!({"child": value}))
551 }
552
553 assert!(inspect(
554 &nested_objects(31, Value::Object(Default::default())),
555 0,
556 MAX_DEPTH
557 )
558 .is_ok());
559 assert!(inspect(&nested_objects(32, Value::Null), 0, MAX_DEPTH).is_ok());
560 assert!(inspect(
561 &nested_objects(32, Value::Object(Default::default())),
562 0,
563 MAX_DEPTH
564 )
565 .is_err());
566 }
567
568 #[test]
569 fn exact_request_limit_does_not_reject_omitted_nullable_event_fields() {
570 let policy = AdapterPolicy {
571 allowed_window_labels: BTreeSet::from(["main".to_owned()]),
572 allowed_targets: BTreeSet::from(["app".to_owned()]),
573 max_request_bytes: MAX_REQUEST_BYTES as u32,
574 max_depth: MAX_DEPTH as u32,
575 redacted_field_keys: BTreeSet::new(),
576 };
577 let adapter = Adapter::new(Arc::new(IpcBackend), policy).unwrap();
578 let mut request = serde_json::json!({
579 "schema_version": 1,
580 "event": {
581 "schema_version": 1,
582 "level": "info",
583 "target": "app",
584 "action": "test",
585 "message": ""
586 }
587 });
588 let overhead = serde_json::to_vec(&request).unwrap().len();
589 request["event"]["message"] = serde_json::json!("x".repeat(MAX_REQUEST_BYTES - overhead));
590 assert_eq!(serde_json::to_vec(&request).unwrap().len(), MAX_REQUEST_BYTES);
591 let result = adapter.try_log("main", request);
592 assert!(matches!(
593 result,
594 WireEnvelope::Error { error: Failure::Internal { .. }, .. }
595 ));
596 }
597
598 #[test]
599 fn redaction_replaces_nested_keys() {
600 let mut value = sc_observability_dto::ValueDto::Object {
601 value: [("password".to_owned(), sc_observability_dto::ValueDto::String {
602 value: "secret".to_owned(),
603 })].into_iter().collect(),
604 };
605 redact_value(&mut value, &BTreeSet::from(["password".to_owned()]));
606 assert_eq!(value, sc_observability_dto::ValueDto::Object {
607 value: [("password".to_owned(), sc_observability_dto::ValueDto::String {
608 value: REDACTED.to_owned(),
609 })].into_iter().collect(),
610 });
611 }
612
613 #[cfg(feature = "tauri")]
614 #[test]
615 fn mock_ipc_returns_wire_envelope_from_registered_command() {
616 let policy = AdapterPolicy {
617 allowed_window_labels: BTreeSet::from(["main".to_owned()]),
618 allowed_targets: BTreeSet::from(["app".to_owned()]),
619 max_request_bytes: MAX_REQUEST_BYTES as u32,
620 max_depth: MAX_DEPTH as u32,
621 redacted_field_keys: BTreeSet::new(),
622 };
623 let app = tauri::test::mock_builder()
624 .manage(ManagedAdapter(Adapter::new(Arc::new(IpcBackend), policy).unwrap()))
625 .invoke_handler(tauri::generate_handler![sc_observability_health])
626 .build(tauri::test::mock_context(tauri::test::noop_assets()))
627 .unwrap();
628 let window = tauri::WebviewWindowBuilder::new(&app, "main", Default::default()).build().unwrap();
629 let url = window.url().unwrap();
630 let response = tauri::test::get_ipc_response(
631 &window,
632 tauri::webview::InvokeRequest {
633 cmd: "sc_observability_health".into(),
634 callback: tauri::ipc::CallbackFn(0),
635 error: tauri::ipc::CallbackFn(1),
636 url,
637 body: serde_json::json!({"request": {"schema_version": 1}}).into(),
638 headers: Default::default(),
639 invoke_key: tauri::test::INVOKE_KEY.to_owned(),
640 },
641 ).unwrap();
642 let value = response.deserialize::<Value>().unwrap();
643 assert_eq!(value["schema_version"], 1);
644 assert_eq!(value["kind"], "error");
645 }
646}