Skip to main content

sentinelpass_protocol/
message.rs

1//! IPC message types — the daemon request/response vocabulary.
2
3use crate::service::{ServiceOutcome, VaultOp};
4use serde::{Deserialize, Serialize};
5
6/// Secret field that a local tool may request.
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
8#[serde(rename_all = "snake_case")]
9pub enum ExternalSecretField {
10    Username,
11    Password,
12    Title,
13}
14
15impl ExternalSecretField {
16    pub fn as_str(self) -> &'static str {
17        match self {
18            Self::Username => "username",
19            Self::Password => "password",
20            Self::Title => "title",
21        }
22    }
23}
24
25/// IPC message types
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub enum IpcMessage {
28    /// Browser-surface credential delivery (WBS-711). `page_url` is the
29    /// full URL of the requesting page as reported by the browser (the
30    /// native host forwards the validated sender URL, never a content-
31    /// script-claimed value). The daemon parses it with the WHATWG parser
32    /// and DEFAULT-DENIES delivery for plain-HTTP and unverifiable
33    /// origins; the scheme-validated host is the lookup identity.
34    /// `serde(default)` keeps pre-711 peers deserializing (they are then
35    /// denied as origin-unverified — fail-closed).
36    GetCredential {
37        domain: String,
38        #[serde(default, skip_serializing_if = "Option::is_none")]
39        page_url: Option<String>,
40        /// WBS-712/715 disambiguation: exact (case-insensitive) username
41        /// filter applied AFTER the domain match, so a multi-credential
42        /// site can request one account (popup "Pass" per row).
43        #[serde(default, skip_serializing_if = "Option::is_none")]
44        username: Option<String>,
45    },
46    GetExternalSecret {
47        client_id: String,
48        domain: String,
49        field: ExternalSecretField,
50        purpose: Option<String>,
51    },
52    GetExternalSecretResponse {
53        value: Option<String>,
54        authorized: bool,
55        error: Option<String>,
56        /// Some(true) = vault locked (distinct from not-found).
57        #[serde(default, skip_serializing_if = "Option::is_none")]
58        locked: Option<bool>,
59    },
60    GetCredentialResponse {
61        username: Option<String>,
62        password: Option<String>,
63        title: Option<String>,
64        #[serde(default, skip_serializing_if = "Option::is_none")]
65        locked: Option<bool>,
66        /// WBS-711: set when delivery was refused by the autofill origin
67        /// gate (`origin-unverified` / `insecure-http`) — distinct from a
68        /// plain no-match, which stays all-None.
69        #[serde(default, skip_serializing_if = "Option::is_none")]
70        denied_reason: Option<String>,
71    },
72    ListDomainCredentials {
73        base_domain: String,
74        #[serde(default, skip_serializing_if = "Option::is_none")]
75        page_url: Option<String>,
76    },
77    ListDomainCredentialsResponse {
78        credentials: Vec<CredentialSummary>,
79        #[serde(default, skip_serializing_if = "Option::is_none")]
80        locked: Option<bool>,
81        #[serde(default, skip_serializing_if = "Option::is_none")]
82        denied_reason: Option<String>,
83    },
84    GetTotpCode {
85        domain: String,
86        #[serde(default, skip_serializing_if = "Option::is_none")]
87        page_url: Option<String>,
88        /// WBS-715 review fix F4: exact-username disambiguator so the TOTP
89        /// belongs to the SAME account whose password was filled.
90        #[serde(default, skip_serializing_if = "Option::is_none")]
91        username: Option<String>,
92    },
93    GetTotpCodeResponse {
94        code: Option<String>,
95        seconds_remaining: Option<u32>,
96        #[serde(default, skip_serializing_if = "Option::is_none")]
97        locked: Option<bool>,
98        #[serde(default, skip_serializing_if = "Option::is_none")]
99        denied_reason: Option<String>,
100    },
101    SaveCredential {
102        domain: String,
103        username: String,
104        password: String,
105        url: Option<String>,
106        /// Extension-computed save provenance ('inline_prompt_button',
107        /// 'notification_button', 'password_change', ...), logged by the
108        /// daemon. serde default keeps pre-714 hosts wire-compatible.
109        #[serde(default, skip_serializing_if = "Option::is_none")]
110        save_trigger: Option<String>,
111    },
112    SaveCredentialResponse {
113        success: bool,
114        error: Option<String>,
115        #[serde(default, skip_serializing_if = "Option::is_none")]
116        locked: Option<bool>,
117    },
118    /// External tool writes (upserts) one secret value for one scope.
119    /// Requires a token-enforced grant with allow_write.
120    SaveSecret {
121        client_id: String,
122        domain: String,
123        value: String,
124        purpose: Option<String>,
125    },
126    SaveSecretResponse {
127        success: bool,
128        #[serde(default, skip_serializing_if = "Option::is_none")]
129        locked: Option<bool>,
130        error: Option<String>,
131    },
132    /// Defined for protocol completeness; the daemon currently rejects
133    /// deletion because third-party-created entries have no ownership
134    /// tracking yet (schema v5 shipped registry ownership groundwork; the
135    /// delete decision itself stays deliberately rejected per ADR-001 D1).
136    DeleteSecret {
137        client_id: String,
138        domain: String,
139    },
140    DeleteSecretResponse {
141        deleted: bool,
142        #[serde(default, skip_serializing_if = "Option::is_none")]
143        locked: Option<bool>,
144        error: Option<String>,
145    },
146    UnlockVault {
147        master_password: String,
148    },
149    // --- WBS-712 per-site autofill permissions ------------------------------
150    /// Grant (or re-confirm) plain-HTTP autofill for one host. The host is
151    /// normalized daemon-side; the grant is EXACT-host (no suffix matching).
152    /// Browser-surface gated: only the native host may manage grants.
153    GrantSitePermission {
154        host: String,
155        allow_insecure: bool,
156    },
157    GrantSitePermissionResponse {
158        success: bool,
159        error: Option<String>,
160    },
161    /// Revoke any grant for `host` (deletes the entry immediately).
162    RevokeSitePermission {
163        host: String,
164    },
165    RevokeSitePermissionResponse {
166        success: bool,
167        removed: bool,
168        error: Option<String>,
169    },
170    /// List all per-site grants (popup settings view).
171    ListSitePermissions,
172    ListSitePermissionsResponse {
173        permissions: Vec<SitePermissionSummary>,
174        #[serde(default, skip_serializing_if = "Option::is_none")]
175        locked: Option<bool>,
176    },
177    UnlockVaultBiometric {
178        prompt_reason: Option<String>,
179    },
180    UnlockVaultResponse {
181        success: bool,
182        error: Option<String>,
183    },
184    CheckVault,
185    VaultStatusResponse {
186        unlocked: bool,
187        /// Master-password rotation generation of the vault (ADR-002).
188        /// serde default keeps pre-epoch clients deserializing; 0 means
189        /// the responder could not read metadata.
190        #[serde(default)]
191        key_epoch: i64,
192    },
193    LockVault,
194    Shutdown,
195
196    // --- Sync messages ---
197    /// Trigger a sync cycle now (push + pull).
198    SyncNow,
199    /// Response to SyncNow.
200    SyncNowResponse {
201        success: bool,
202        pushed: u64,
203        pulled: u64,
204        error: Option<String>,
205    },
206    /// Get sync status.
207    SyncStatus,
208    /// Sync status response.
209    SyncStatusResponse {
210        enabled: bool,
211        device_id: Option<String>,
212        device_name: Option<String>,
213        relay_url: Option<String>,
214        last_sync_at: Option<i64>,
215        pending_changes: u64,
216    },
217
218    // --- application-service surface (WBS-408, ADR-007) ----------------------
219    /// One vault application-service call — the single shape UI, CLI, and
220    /// the native host use for vault operations (see `crate::service`).
221    ServiceCall {
222        op: VaultOp,
223    },
224    /// Outcome of a [`IpcMessage::ServiceCall`].
225    ServiceResult {
226        outcome: ServiceOutcome,
227    },
228}
229
230/// Summary of a credential for listing (excludes password for bulk operations)
231#[derive(Debug, Clone, Serialize, Deserialize)]
232pub struct CredentialSummary {
233    pub username: String,
234    pub title: Option<String>,
235    pub domain: String,
236}
237
238/// One per-site autofill permission grant, as listed to the popup (WBS-712).
239#[derive(Debug, Clone, Serialize, Deserialize)]
240pub struct SitePermissionSummary {
241    /// Normalized bare host.
242    pub host: String,
243    /// The only grant kind today: explicit plain-HTTP autofill consent.
244    pub allow_insecure: bool,
245    /// Unix seconds — when the grant was first made.
246    pub granted_at: i64,
247}
248
249#[cfg(test)]
250mod tests {
251    use super::*;
252
253    #[test]
254    fn test_ipc_envelope_round_trip_is_in_envelope_tests() {
255        // Envelope tests live in envelope.rs; this placeholder documents that.
256    }
257
258    #[test]
259    fn test_credential_summary_serialization() {
260        let summary = CredentialSummary {
261            username: "user@example.com".to_string(),
262            title: Some("Example Account".to_string()),
263            domain: "example.com".to_string(),
264        };
265
266        let serialized = serde_json::to_string(&summary).unwrap();
267        let deserialized: CredentialSummary = serde_json::from_str(&serialized).unwrap();
268
269        assert_eq!(deserialized.username, summary.username);
270        assert_eq!(deserialized.title, summary.title);
271        assert_eq!(deserialized.domain, summary.domain);
272    }
273
274    #[test]
275    fn test_credential_summary_without_title() {
276        let summary = CredentialSummary {
277            username: "user@example.com".to_string(),
278            title: None,
279            domain: "example.com".to_string(),
280        };
281
282        let serialized = serde_json::to_string(&summary).unwrap();
283        let deserialized: CredentialSummary = serde_json::from_str(&serialized).unwrap();
284
285        assert_eq!(deserialized.username, summary.username);
286        assert_eq!(deserialized.title, None);
287        assert_eq!(deserialized.domain, summary.domain);
288    }
289
290    #[test]
291    fn test_message_types_serialize_correctly() {
292        let messages = vec![
293            IpcMessage::GetCredential {
294                domain: "example.com".to_string(),
295                page_url: Some("https://example.com/login".to_string()),
296                username: None,
297            },
298            IpcMessage::GetExternalSecret {
299                client_id: "victor".to_string(),
300                domain: "anthropic".to_string(),
301                field: ExternalSecretField::Password,
302                purpose: Some("victor-auth".to_string()),
303            },
304            IpcMessage::CheckVault,
305            IpcMessage::LockVault,
306            IpcMessage::Shutdown,
307            IpcMessage::ListDomainCredentials {
308                base_domain: "example.com".to_string(),
309                page_url: None,
310            },
311        ];
312
313        for msg in messages {
314            let serialized = serde_json::to_string(&msg).unwrap();
315            let deserialized: IpcMessage = serde_json::from_str(&serialized).unwrap();
316
317            // Verify round-trip
318            match (&msg, &deserialized) {
319                (
320                    IpcMessage::GetCredential {
321                        domain: d1,
322                        page_url: u1,
323                        username: n1,
324                    },
325                    IpcMessage::GetCredential {
326                        domain: d2,
327                        page_url: u2,
328                        username: n2,
329                    },
330                ) => {
331                    assert_eq!(d1, d2);
332                    assert_eq!(u1, u2);
333                    assert_eq!(n1, n2);
334                }
335                (
336                    IpcMessage::GetExternalSecret {
337                        client_id: c1,
338                        domain: d1,
339                        field: f1,
340                        purpose: p1,
341                    },
342                    IpcMessage::GetExternalSecret {
343                        client_id: c2,
344                        domain: d2,
345                        field: f2,
346                        purpose: p2,
347                    },
348                ) => {
349                    assert_eq!(c1, c2);
350                    assert_eq!(d1, d2);
351                    assert_eq!(f1, f2);
352                    assert_eq!(p1, p2);
353                }
354                (
355                    IpcMessage::ListDomainCredentials {
356                        base_domain: b1,
357                        page_url: p1,
358                    },
359                    IpcMessage::ListDomainCredentials {
360                        base_domain: b2,
361                        page_url: p2,
362                    },
363                ) => {
364                    assert_eq!(b1, b2);
365                    assert_eq!(p1, p2);
366                }
367                (IpcMessage::CheckVault, IpcMessage::CheckVault) => {}
368                (IpcMessage::LockVault, IpcMessage::LockVault) => {}
369                (IpcMessage::Shutdown, IpcMessage::Shutdown) => {}
370                _ => panic!("Message type mismatch during round-trip"),
371            }
372        }
373    }
374
375    #[test]
376    fn test_get_credential_response_serialization() {
377        let response = IpcMessage::GetCredentialResponse {
378            username: Some("user@example.com".to_string()),
379            password: Some("password123".to_string()),
380            title: Some("Example".to_string()),
381            locked: None,
382            denied_reason: None,
383        };
384
385        let serialized = serde_json::to_string(&response).unwrap();
386        let deserialized: IpcMessage = serde_json::from_str(&serialized).unwrap();
387
388        match deserialized {
389            IpcMessage::GetCredentialResponse {
390                username,
391                password,
392                title,
393                locked: None,
394                denied_reason: None,
395            } => {
396                assert_eq!(username, Some("user@example.com".to_string()));
397                assert_eq!(password, Some("password123".to_string()));
398                assert_eq!(title, Some("Example".to_string()));
399            }
400            _ => panic!("Wrong response type"),
401        }
402    }
403
404    /// WBS-711: a pre-711 response payload (no `denied_reason` key) must
405    /// deserialize with the field defaulting to None, and a denial carries
406    /// its reason on the wire.
407    #[test]
408    fn test_autofill_denied_reason_wire_compat() {
409        let legacy = r#"{
410            "GetCredentialResponse": {
411                "username": null,
412                "password": null,
413                "title": null
414            }
415        }"#;
416        let deserialized: IpcMessage = serde_json::from_str(legacy).unwrap();
417        match deserialized {
418            IpcMessage::GetCredentialResponse { denied_reason, .. } => {
419                assert_eq!(denied_reason, None);
420            }
421            _ => panic!("Wrong response type"),
422        }
423
424        let denied = IpcMessage::GetCredentialResponse {
425            username: None,
426            password: None,
427            title: None,
428            locked: None,
429            denied_reason: Some("insecure-http".to_string()),
430        };
431        let serialized = serde_json::to_string(&denied).unwrap();
432        assert!(serialized.contains("insecure-http"));
433
434        let pre711_request = r#"{"GetCredential": {"domain": "example.com"}}"#;
435        let deserialized: IpcMessage = serde_json::from_str(pre711_request).unwrap();
436        match deserialized {
437            IpcMessage::GetCredential {
438                domain,
439                page_url,
440                username,
441            } => {
442                assert_eq!(domain, "example.com");
443                assert_eq!(page_url, None);
444                assert_eq!(username, None);
445            }
446            _ => panic!("Wrong request type"),
447        }
448    }
449
450    #[test]
451    fn test_get_external_secret_response_serialization() {
452        let response = IpcMessage::GetExternalSecretResponse {
453            value: Some("secret-value".to_string()),
454            authorized: true,
455            error: None,
456            locked: None,
457        };
458
459        let serialized = serde_json::to_string(&response).unwrap();
460        let deserialized: IpcMessage = serde_json::from_str(&serialized).unwrap();
461
462        match deserialized {
463            IpcMessage::GetExternalSecretResponse {
464                value,
465                authorized,
466                error,
467                locked: None,
468            } => {
469                assert_eq!(value, Some("secret-value".to_string()));
470                assert!(authorized);
471                assert_eq!(error, None);
472            }
473            _ => panic!("Wrong response type"),
474        }
475    }
476
477    #[test]
478    fn test_list_domain_credentials_response_serialization() {
479        let credentials = vec![
480            CredentialSummary {
481                username: "user1@example.com".to_string(),
482                title: Some("Account 1".to_string()),
483                domain: "example.com".to_string(),
484            },
485            CredentialSummary {
486                username: "user2@example.com".to_string(),
487                title: Some("Account 2".to_string()),
488                domain: "example.com".to_string(),
489            },
490        ];
491
492        let response = IpcMessage::ListDomainCredentialsResponse {
493            credentials: credentials.clone(),
494            locked: None,
495            denied_reason: None,
496        };
497
498        let serialized = serde_json::to_string(&response).unwrap();
499        let deserialized: IpcMessage = serde_json::from_str(&serialized).unwrap();
500
501        match deserialized {
502            IpcMessage::ListDomainCredentialsResponse {
503                credentials: decoded,
504                locked: None,
505                denied_reason: None,
506            } => {
507                assert_eq!(decoded.len(), 2);
508                assert_eq!(decoded[0].username, "user1@example.com");
509                assert_eq!(decoded[1].username, "user2@example.com");
510            }
511            _ => panic!("Wrong response type"),
512        }
513    }
514
515    #[test]
516    fn test_save_credential_response_serialization() {
517        let response = IpcMessage::SaveCredentialResponse {
518            success: true,
519            error: None,
520            locked: None,
521        };
522
523        let serialized = serde_json::to_string(&response).unwrap();
524        let deserialized: IpcMessage = serde_json::from_str(&serialized).unwrap();
525
526        match deserialized {
527            IpcMessage::SaveCredentialResponse {
528                success,
529                error,
530                locked: None,
531            } => {
532                assert!(success);
533                assert!(error.is_none());
534            }
535            _ => panic!("Wrong response type"),
536        }
537    }
538
539    #[test]
540    fn test_save_credential_error_response_serialization() {
541        let response = IpcMessage::SaveCredentialResponse {
542            success: false,
543            error: Some("Vault is locked".to_string()),
544            locked: None,
545        };
546
547        let serialized = serde_json::to_string(&response).unwrap();
548        let deserialized: IpcMessage = serde_json::from_str(&serialized).unwrap();
549
550        match deserialized {
551            IpcMessage::SaveCredentialResponse {
552                success,
553                error,
554                locked,
555            } => {
556                assert!(!success);
557                assert_eq!(error, Some("Vault is locked".to_string()));
558                assert_eq!(locked, None);
559            }
560            _ => panic!("Wrong response type"),
561        }
562    }
563
564    #[test]
565    fn test_unlock_vault_response_serialization() {
566        let response = IpcMessage::UnlockVaultResponse {
567            success: true,
568            error: None,
569        };
570
571        let serialized = serde_json::to_string(&response).unwrap();
572        let deserialized: IpcMessage = serde_json::from_str(&serialized).unwrap();
573
574        match deserialized {
575            IpcMessage::UnlockVaultResponse { success, error } => {
576                assert!(success);
577                assert!(error.is_none());
578            }
579            _ => panic!("Wrong response type"),
580        }
581    }
582
583    #[test]
584    fn test_vault_status_response_serialization() {
585        let response = IpcMessage::VaultStatusResponse {
586            unlocked: true,
587            key_epoch: 1,
588        };
589
590        let serialized = serde_json::to_string(&response).unwrap();
591        let deserialized: IpcMessage = serde_json::from_str(&serialized).unwrap();
592
593        match deserialized {
594            IpcMessage::VaultStatusResponse {
595                unlocked,
596                key_epoch,
597            } => {
598                assert!(unlocked);
599                assert_eq!(key_epoch, 1);
600            }
601            _ => panic!("Wrong response type"),
602        }
603    }
604
605    #[test]
606    fn test_totp_response_serialization() {
607        let response = IpcMessage::GetTotpCodeResponse {
608            code: Some("123456".to_string()),
609            seconds_remaining: Some(30),
610            locked: None,
611            denied_reason: None,
612        };
613
614        let serialized = serde_json::to_string(&response).unwrap();
615        let deserialized: IpcMessage = serde_json::from_str(&serialized).unwrap();
616
617        match deserialized {
618            IpcMessage::GetTotpCodeResponse {
619                code,
620                seconds_remaining,
621                locked: None,
622                denied_reason: None,
623            } => {
624                assert_eq!(code, Some("123456".to_string()));
625                assert_eq!(seconds_remaining, Some(30));
626            }
627            _ => panic!("Wrong response type"),
628        }
629    }
630
631    #[test]
632    fn test_save_credential_message_serialization() {
633        let msg = IpcMessage::SaveCredential {
634            domain: "example.com".to_string(),
635            username: "user@example.com".to_string(),
636            password: "secure_password".to_string(),
637            url: Some("https://example.com".to_string()),
638            save_trigger: Some("password_change".to_string()),
639        };
640
641        let serialized = serde_json::to_string(&msg).unwrap();
642        let deserialized: IpcMessage = serde_json::from_str(&serialized).unwrap();
643
644        match deserialized {
645            IpcMessage::SaveCredential {
646                domain,
647                username,
648                password,
649                url,
650                save_trigger,
651            } => {
652                assert_eq!(domain, "example.com");
653                assert_eq!(username, "user@example.com");
654                assert_eq!(password, "secure_password");
655                assert_eq!(url, Some("https://example.com".to_string()));
656                assert_eq!(save_trigger.as_deref(), Some("password_change"));
657            }
658            _ => panic!("Wrong message type"),
659        }
660    }
661
662    #[test]
663    fn test_unlock_vault_message_serialization() {
664        let msg = IpcMessage::UnlockVault {
665            master_password: "test_password".to_string(),
666        };
667
668        let serialized = serde_json::to_string(&msg).unwrap();
669        let deserialized: IpcMessage = serde_json::from_str(&serialized).unwrap();
670
671        match deserialized {
672            IpcMessage::UnlockVault { master_password } => {
673                assert_eq!(master_password, "test_password");
674            }
675            _ => panic!("Wrong message type"),
676        }
677    }
678
679    #[test]
680    fn test_unlock_vault_biometric_message_serialization() {
681        let msg = IpcMessage::UnlockVaultBiometric {
682            prompt_reason: Some("Authenticate to unlock".to_string()),
683        };
684
685        let serialized = serde_json::to_string(&msg).unwrap();
686        let deserialized: IpcMessage = serde_json::from_str(&serialized).unwrap();
687
688        match deserialized {
689            IpcMessage::UnlockVaultBiometric { prompt_reason } => {
690                assert_eq!(prompt_reason, Some("Authenticate to unlock".to_string()));
691            }
692            _ => panic!("Wrong response type"),
693        }
694    }
695
696    #[test]
697    fn test_empty_credential_list_serialization() {
698        let response = IpcMessage::ListDomainCredentialsResponse {
699            credentials: vec![],
700            locked: None,
701            denied_reason: None,
702        };
703
704        let serialized = serde_json::to_string(&response).unwrap();
705        let deserialized: IpcMessage = serde_json::from_str(&serialized).unwrap();
706
707        match deserialized {
708            IpcMessage::ListDomainCredentialsResponse {
709                credentials,
710                locked: None,
711                denied_reason: None,
712            } => {
713                assert!(credentials.is_empty());
714            }
715            _ => panic!("Wrong response type"),
716        }
717    }
718
719    #[test]
720    fn test_get_totp_code_message_serialization() {
721        let msg = IpcMessage::GetTotpCode {
722            domain: "example.com".to_string(),
723            page_url: Some("https://example.com/login".to_string()),
724            username: None,
725        };
726
727        let serialized = serde_json::to_string(&msg).unwrap();
728        let deserialized: IpcMessage = serde_json::from_str(&serialized).unwrap();
729
730        match deserialized {
731            IpcMessage::GetTotpCode { domain, .. } => {
732                assert_eq!(domain, "example.com");
733            }
734            _ => panic!("Wrong message type"),
735        }
736    }
737
738    #[test]
739    fn test_sync_status_message_serialization() {
740        let msg = IpcMessage::SyncStatus;
741
742        let serialized = serde_json::to_string(&msg).unwrap();
743        let deserialized: IpcMessage = serde_json::from_str(&serialized).unwrap();
744
745        match deserialized {
746            IpcMessage::SyncStatus => {}
747            _ => panic!("Wrong message type"),
748        }
749    }
750
751    #[test]
752    fn test_sync_status_response_serialization() {
753        let response = IpcMessage::SyncStatusResponse {
754            enabled: true,
755            device_id: Some("device-123".to_string()),
756            device_name: Some("Test Device".to_string()),
757            relay_url: Some("https://relay.example.com".to_string()),
758            last_sync_at: Some(1700000000),
759            pending_changes: 5,
760        };
761
762        let serialized = serde_json::to_string(&response).unwrap();
763        let deserialized: IpcMessage = serde_json::from_str(&serialized).unwrap();
764
765        match deserialized {
766            IpcMessage::SyncStatusResponse {
767                enabled,
768                device_id,
769                device_name,
770                relay_url,
771                last_sync_at,
772                pending_changes,
773            } => {
774                assert!(enabled);
775                assert_eq!(device_id, Some("device-123".to_string()));
776                assert_eq!(device_name, Some("Test Device".to_string()));
777                assert_eq!(relay_url, Some("https://relay.example.com".to_string()));
778                assert_eq!(last_sync_at, Some(1700000000));
779                assert_eq!(pending_changes, 5);
780            }
781            _ => panic!("Wrong response type"),
782        }
783    }
784}