Skip to main content

bsv_messagebox_client/
permissions.rs

1use bsv::wallet::interfaces::WalletInterface;
2use serde::Deserialize;
3
4use crate::client::{check_status_error, MessageBoxClient};
5use crate::error::MessageBoxError;
6use crate::types::{MessageBoxPermission, MessageBoxQuote, SetPermissionParams};
7
8// ---------------------------------------------------------------------------
9// Internal response wrapper types (not exposed publicly)
10// ---------------------------------------------------------------------------
11
12#[derive(Deserialize)]
13struct GetPermissionResponse {
14    permission: Option<MessageBoxPermission>,
15}
16
17#[derive(Deserialize)]
18struct ListPermissionsResponse {
19    permissions: Vec<MessageBoxPermission>,
20}
21
22#[derive(Deserialize)]
23struct QuoteResponse {
24    quote: QuoteBody,
25}
26
27#[derive(Deserialize)]
28struct QuoteBody {
29    #[serde(rename = "recipientFee")]
30    recipient_fee: i64,
31    #[serde(rename = "deliveryFee")]
32    delivery_fee: i64,
33}
34
35// ---------------------------------------------------------------------------
36// Permission and notification methods
37// ---------------------------------------------------------------------------
38
39impl<W: WalletInterface + Clone + 'static + Send + Sync> MessageBoxClient<W> {
40    /// Set a permission rule for a message box.
41    ///
42    /// POSTs camelCase JSON to `/permissions/set`. The server returns HTTP 200
43    /// even for logical errors — use `check_status_error` to detect them.
44    pub async fn set_message_box_permission(
45        &self,
46        params: SetPermissionParams,
47        override_host: Option<&str>,
48    ) -> Result<(), MessageBoxError> {
49        self.assert_initialized().await?;
50
51        let base = override_host.unwrap_or_else(|| self.host());
52        let body_bytes = serde_json::to_vec(&params)?;
53        let url = format!("{base}/permissions/set");
54        let response = self.post_json(&url, body_bytes).await?;
55        check_status_error(&response.body)?;
56
57        Ok(())
58    }
59
60    /// Retrieve a single permission record.
61    ///
62    /// GETs `/permissions/get` with camelCase query params (`recipient`, `messageBox`,
63    /// optional `sender`). Returns `None` when the server responds with
64    /// `{"permission": null}`.
65    pub async fn get_message_box_permission(
66        &self,
67        recipient: &str,
68        message_box: &str,
69        sender: Option<&str>,
70        override_host: Option<&str>,
71    ) -> Result<Option<MessageBoxPermission>, MessageBoxError> {
72        self.assert_initialized().await?;
73
74        let base = override_host.unwrap_or_else(|| self.host());
75        // NOTE: query param key is camelCase `messageBox` — not snake_case.
76        let mut url = format!(
77            "{base}/permissions/get?recipient={}&messageBox={}",
78            recipient,
79            message_box
80        );
81        if let Some(s) = sender {
82            url.push_str(&format!("&sender={s}"));
83        }
84
85        let response = self.get_json(&url).await?;
86        check_status_error(&response.body)?;
87
88        let parsed: GetPermissionResponse = serde_json::from_slice(&response.body)?;
89        Ok(parsed.permission)
90    }
91
92    /// List permission records for this identity key.
93    ///
94    /// GETs `/permissions/list` with snake_case `message_box` query param —
95    /// this is the unique endpoint that uses snake_case for its query key
96    /// (Pitfall 1: do NOT use `messageBox` here).
97    pub async fn list_message_box_permissions(
98        &self,
99        message_box: Option<&str>,
100        limit: Option<u32>,
101        offset: Option<u32>,
102        override_host: Option<&str>,
103    ) -> Result<Vec<MessageBoxPermission>, MessageBoxError> {
104        self.assert_initialized().await?;
105
106        let base = override_host.unwrap_or_else(|| self.host());
107        let mut url = format!("{base}/permissions/list");
108        let mut params: Vec<String> = Vec::new();
109
110        // CRITICAL: key is snake_case `message_box` — NOT `messageBox`.
111        if let Some(mb) = message_box {
112            params.push(format!("message_box={mb}"));
113        }
114        if let Some(l) = limit {
115            params.push(format!("limit={l}"));
116        }
117        if let Some(o) = offset {
118            params.push(format!("offset={o}"));
119        }
120
121        if !params.is_empty() {
122            url.push('?');
123            url.push_str(&params.join("&"));
124        }
125
126        let response = self.get_json(&url).await?;
127        check_status_error(&response.body)?;
128
129        let parsed: ListPermissionsResponse = serde_json::from_slice(&response.body)?;
130        Ok(parsed.permissions)
131    }
132
133    /// Get a delivery quote for sending a message to `recipient`'s `message_box`.
134    ///
135    /// The response body is wrapped (`{"quote": {...}}`). The delivery agent's
136    /// identity key comes from the `x-bsv-auth-identity-key` response header —
137    /// NOT from the JSON body (Pitfall 2).
138    pub async fn get_message_box_quote(
139        &self,
140        recipient: &str,
141        message_box: &str,
142        override_host: Option<&str>,
143    ) -> Result<MessageBoxQuote, MessageBoxError> {
144        self.assert_initialized().await?;
145
146        let base = override_host.unwrap_or_else(|| self.host());
147        // NOTE: query param key is camelCase `messageBox` here.
148        let url = format!(
149            "{base}/permissions/quote?recipient={}&messageBox={}",
150            recipient,
151            message_box
152        );
153
154        let response = self.get_json(&url).await?;
155        check_status_error(&response.body)?;
156
157        // Extract delivery agent identity key from response header.
158        // This header is NOT in the JSON body — it is set by the BRC-31 server.
159        let delivery_agent_identity_key = response
160            .headers
161            .get("x-bsv-auth-identity-key")
162            .cloned()
163            .ok_or_else(|| {
164                MessageBoxError::MissingHeader("x-bsv-auth-identity-key".into())
165            })?;
166
167        let parsed: QuoteResponse = serde_json::from_slice(&response.body)?;
168
169        Ok(MessageBoxQuote {
170            delivery_fee: parsed.quote.delivery_fee,
171            recipient_fee: parsed.quote.recipient_fee,
172            delivery_agent_identity_key,
173        })
174    }
175
176    // -----------------------------------------------------------------------
177    // Notification wrappers
178    // -----------------------------------------------------------------------
179
180    /// Allow a peer to send notifications by granting them access to the
181    /// `"notifications"` message box with the given `recipient_fee`.
182    pub async fn allow_notifications_from_peer(
183        &self,
184        sender: &str,
185        recipient_fee: i64,
186        override_host: Option<&str>,
187    ) -> Result<(), MessageBoxError> {
188        self.set_message_box_permission(
189            SetPermissionParams {
190                message_box: "notifications".to_string(),
191                sender: Some(sender.to_string()),
192                recipient_fee,
193            },
194            override_host,
195        )
196        .await
197    }
198
199    /// Block a peer from the `"notifications"` message box by setting
200    /// `recipient_fee = -1`.
201    pub async fn deny_notifications_from_peer(
202        &self,
203        sender: &str,
204        override_host: Option<&str>,
205    ) -> Result<(), MessageBoxError> {
206        self.set_message_box_permission(
207            SetPermissionParams {
208                message_box: "notifications".to_string(),
209                sender: Some(sender.to_string()),
210                recipient_fee: -1,
211            },
212            override_host,
213        )
214        .await
215    }
216
217    /// Check whether `peer` has notification access from this identity's
218    /// perspective.
219    ///
220    /// Uses `get_identity_key()` for `recipient` — the check is always
221    /// performed against the local identity.
222    pub async fn check_peer_notification_status(
223        &self,
224        peer: &str,
225        override_host: Option<&str>,
226    ) -> Result<Option<MessageBoxPermission>, MessageBoxError> {
227        let recipient = self.get_identity_key().await?;
228        self.get_message_box_permission(&recipient, "notifications", Some(peer), override_host)
229            .await
230    }
231
232    /// List all permission records in the `"notifications"` message box.
233    pub async fn list_peer_notifications(
234        &self,
235        override_host: Option<&str>,
236    ) -> Result<Vec<MessageBoxPermission>, MessageBoxError> {
237        self.list_message_box_permissions(Some("notifications"), None, None, override_host)
238            .await
239    }
240
241    /// Send a notification body to `recipient`'s `"notifications"` inbox.
242    ///
243    /// Delegates to `send_message` with `message_box = "notifications"` and
244    /// `check_permissions = true` — matching TS which passes `checkPermissions: true`
245    /// so that fee quotes are fetched and payments created if required.
246    pub async fn send_notification(
247        &self,
248        recipient: &str,
249        body: &str,
250        override_host: Option<&str>,
251    ) -> Result<String, MessageBoxError> {
252        match override_host {
253            Some(host) => self.send_message_to_host(host, recipient, "notifications", body, false, true, None, None).await,
254            None => self.send_message(recipient, "notifications", body, false, true, None, None).await,
255        }
256    }
257
258    /// Get delivery quotes for multiple recipients in one logical call.
259    ///
260    /// Groups recipients by resolved host, requests quotes from each host,
261    /// then aggregates into a `MessageBoxMultiQuote` with per-recipient breakdown
262    /// and delivery agent identity keys per host.
263    pub async fn get_message_box_quote_multi(
264        &self,
265        recipients: &[&str],
266        message_box: &str,
267        override_host: Option<&str>,
268    ) -> Result<crate::types::MessageBoxMultiQuote, MessageBoxError> {
269        use std::collections::HashMap;
270        use crate::types::{MessageBoxMultiQuote, RecipientQuote};
271
272        let mut quotes_by_recipient: Vec<RecipientQuote> = Vec::new();
273        let mut blocked_recipients: Vec<String> = Vec::new();
274        let mut delivery_agent_identity_key_by_host: HashMap<String, String> = HashMap::new();
275        let mut total_delivery_fee: i64 = 0;
276        let mut total_recipient_fee: i64 = 0;
277
278        for recipient in recipients {
279            // Resolve host per recipient (or use override).
280            let host = if let Some(h) = override_host {
281                h.to_string()
282            } else {
283                self.resolve_host_for_recipient(recipient).await.unwrap_or_else(|_| self.host().to_string())
284            };
285
286            // Build the quote URL — multiple recipient query params per host would be ideal
287            // but the TS client issues one request per recipient; we match that behavior.
288            let url = format!(
289                "{host}/permissions/quote?recipient={}&messageBox={}",
290                recipient,
291                message_box
292            );
293
294            let response = match self.get_json(&url).await {
295                Ok(r) => r,
296                Err(e) => {
297                    // If the quote fails, mark as failed (treat as blocked for safety).
298                    blocked_recipients.push(recipient.to_string());
299                    quotes_by_recipient.push(RecipientQuote {
300                        recipient: recipient.to_string(),
301                        message_box: message_box.to_string(),
302                        delivery_fee: 0,
303                        recipient_fee: 0,
304                        status: format!("error: {e}"),
305                    });
306                    continue;
307                }
308            };
309
310            // Extract delivery agent key from header — record per host.
311            if let Some(key) = response.headers.get("x-bsv-auth-identity-key") {
312                delivery_agent_identity_key_by_host.insert(host.clone(), key.clone());
313            }
314
315            let parsed = match serde_json::from_slice::<QuoteResponse>(&response.body) {
316                Ok(p) => p,
317                Err(_) => {
318                    blocked_recipients.push(recipient.to_string());
319                    quotes_by_recipient.push(RecipientQuote {
320                        recipient: recipient.to_string(),
321                        message_box: message_box.to_string(),
322                        delivery_fee: 0,
323                        recipient_fee: 0,
324                        status: "parse_error".to_string(),
325                    });
326                    continue;
327                }
328            };
329
330            let delivery_fee = parsed.quote.delivery_fee;
331            let recipient_fee = parsed.quote.recipient_fee;
332            let status = if recipient_fee < 0 {
333                "blocked".to_string()
334            } else if recipient_fee == 0 && delivery_fee == 0 {
335                "always_allow".to_string()
336            } else {
337                "payment_required".to_string()
338            };
339
340            if recipient_fee < 0 {
341                blocked_recipients.push(recipient.to_string());
342            } else {
343                total_delivery_fee += delivery_fee;
344                total_recipient_fee += recipient_fee;
345            }
346
347            quotes_by_recipient.push(RecipientQuote {
348                recipient: recipient.to_string(),
349                message_box: message_box.to_string(),
350                delivery_fee,
351                recipient_fee,
352                status,
353            });
354        }
355
356        Ok(MessageBoxMultiQuote {
357            quotes_by_recipient,
358            totals: Some(crate::types::SendListTotals {
359                delivery_fees: total_delivery_fee,
360                recipient_fees: total_recipient_fee,
361                total_for_payable_recipients: total_delivery_fee + total_recipient_fee,
362            }),
363            blocked_recipients,
364            delivery_agent_identity_key_by_host,
365        })
366    }
367
368    /// Send a notification to multiple recipients at once.
369    ///
370    /// Delegates to `send_message_to_recipients` with `message_box = "notifications"`.
371    /// Matches the TS `sendNotification` overload that accepts `PubKeyHex[]`.
372    pub async fn send_notification_to_recipients(
373        &self,
374        recipients: &[&str],
375        body: &str,
376        override_host: Option<&str>,
377    ) -> Result<crate::types::SendListResult, MessageBoxError> {
378        use crate::types::SendListParams;
379
380        let params = SendListParams {
381            recipients: recipients.iter().map(|s| s.to_string()).collect(),
382            message_box: "notifications".to_string(),
383            body: body.to_string(),
384            skip_encryption: Some(false),
385        };
386
387        self.send_message_to_recipients(&params, override_host).await
388    }
389}
390
391// ---------------------------------------------------------------------------
392// Tests
393// ---------------------------------------------------------------------------
394
395#[cfg(test)]
396mod tests {
397    use super::*;
398    use crate::types::{MessageBoxPermission, MessageBoxQuote, SetPermissionParams};
399    use bsv::primitives::private_key::PrivateKey;
400    use bsv::wallet::error::WalletError;
401    use bsv::wallet::interfaces::*;
402    use bsv::wallet::proto_wallet::ProtoWallet;
403    use std::sync::Arc;
404
405    // -----------------------------------------------------------------------
406    // ArcWallet test helper (same pattern as other modules)
407    // -----------------------------------------------------------------------
408
409    #[derive(Clone)]
410    struct ArcWallet(Arc<ProtoWallet>);
411
412    impl ArcWallet {
413        fn new() -> Self {
414            let key = PrivateKey::from_random().expect("random key");
415            ArcWallet(Arc::new(ProtoWallet::new(key)))
416        }
417    }
418
419    #[async_trait::async_trait]
420    impl WalletInterface for ArcWallet {
421        async fn create_action(&self, args: CreateActionArgs, orig: Option<&str>) -> Result<CreateActionResult, WalletError> { self.0.create_action(args, orig).await }
422        async fn sign_action(&self, args: SignActionArgs, orig: Option<&str>) -> Result<SignActionResult, WalletError> { self.0.sign_action(args, orig).await }
423        async fn abort_action(&self, args: AbortActionArgs, orig: Option<&str>) -> Result<AbortActionResult, WalletError> { self.0.abort_action(args, orig).await }
424        async fn list_actions(&self, args: ListActionsArgs, orig: Option<&str>) -> Result<ListActionsResult, WalletError> { self.0.list_actions(args, orig).await }
425        async fn internalize_action(&self, args: InternalizeActionArgs, orig: Option<&str>) -> Result<InternalizeActionResult, WalletError> { self.0.internalize_action(args, orig).await }
426        async fn list_outputs(&self, args: ListOutputsArgs, orig: Option<&str>) -> Result<ListOutputsResult, WalletError> { self.0.list_outputs(args, orig).await }
427        async fn relinquish_output(&self, args: RelinquishOutputArgs, orig: Option<&str>) -> Result<RelinquishOutputResult, WalletError> { self.0.relinquish_output(args, orig).await }
428        async fn get_public_key(&self, args: GetPublicKeyArgs, orig: Option<&str>) -> Result<GetPublicKeyResult, WalletError> { self.0.get_public_key(args, orig).await }
429        async fn reveal_counterparty_key_linkage(&self, args: RevealCounterpartyKeyLinkageArgs, orig: Option<&str>) -> Result<RevealCounterpartyKeyLinkageResult, WalletError> { self.0.reveal_counterparty_key_linkage(args, orig).await }
430        async fn reveal_specific_key_linkage(&self, args: RevealSpecificKeyLinkageArgs, orig: Option<&str>) -> Result<RevealSpecificKeyLinkageResult, WalletError> { self.0.reveal_specific_key_linkage(args, orig).await }
431        async fn encrypt(&self, args: EncryptArgs, orig: Option<&str>) -> Result<EncryptResult, WalletError> { self.0.encrypt(args, orig).await }
432        async fn decrypt(&self, args: DecryptArgs, orig: Option<&str>) -> Result<DecryptResult, WalletError> { self.0.decrypt(args, orig).await }
433        async fn create_hmac(&self, args: CreateHmacArgs, orig: Option<&str>) -> Result<CreateHmacResult, WalletError> { self.0.create_hmac(args, orig).await }
434        async fn verify_hmac(&self, args: VerifyHmacArgs, orig: Option<&str>) -> Result<VerifyHmacResult, WalletError> { self.0.verify_hmac(args, orig).await }
435        async fn create_signature(&self, args: CreateSignatureArgs, orig: Option<&str>) -> Result<CreateSignatureResult, WalletError> { self.0.create_signature(args, orig).await }
436        async fn verify_signature(&self, args: VerifySignatureArgs, orig: Option<&str>) -> Result<VerifySignatureResult, WalletError> { self.0.verify_signature(args, orig).await }
437        async fn acquire_certificate(&self, args: AcquireCertificateArgs, orig: Option<&str>) -> Result<Certificate, WalletError> { self.0.acquire_certificate(args, orig).await }
438        async fn list_certificates(&self, args: ListCertificatesArgs, orig: Option<&str>) -> Result<ListCertificatesResult, WalletError> { self.0.list_certificates(args, orig).await }
439        async fn prove_certificate(&self, args: ProveCertificateArgs, orig: Option<&str>) -> Result<ProveCertificateResult, WalletError> { self.0.prove_certificate(args, orig).await }
440        async fn relinquish_certificate(&self, args: RelinquishCertificateArgs, orig: Option<&str>) -> Result<RelinquishCertificateResult, WalletError> { self.0.relinquish_certificate(args, orig).await }
441        async fn discover_by_identity_key(&self, args: DiscoverByIdentityKeyArgs, orig: Option<&str>) -> Result<DiscoverCertificatesResult, WalletError> { self.0.discover_by_identity_key(args, orig).await }
442        async fn discover_by_attributes(&self, args: DiscoverByAttributesArgs, orig: Option<&str>) -> Result<DiscoverCertificatesResult, WalletError> { self.0.discover_by_attributes(args, orig).await }
443        async fn is_authenticated(&self, orig: Option<&str>) -> Result<AuthenticatedResult, WalletError> { self.0.is_authenticated(orig).await }
444        async fn wait_for_authentication(&self, orig: Option<&str>) -> Result<AuthenticatedResult, WalletError> { self.0.wait_for_authentication(orig).await }
445        async fn get_height(&self, orig: Option<&str>) -> Result<GetHeightResult, WalletError> { self.0.get_height(orig).await }
446        async fn get_header_for_height(&self, args: GetHeaderArgs, orig: Option<&str>) -> Result<GetHeaderResult, WalletError> { self.0.get_header_for_height(args, orig).await }
447        async fn get_network(&self, orig: Option<&str>) -> Result<GetNetworkResult, WalletError> { self.0.get_network(orig).await }
448        async fn get_version(&self, orig: Option<&str>) -> Result<GetVersionResult, WalletError> { self.0.get_version(orig).await }
449    }
450
451    fn make_client(host: &str) -> MessageBoxClient<ArcWallet> {
452        MessageBoxClient::new(host.to_string(), ArcWallet::new(), None, bsv::services::overlay_tools::Network::Mainnet)
453    }
454
455    // -----------------------------------------------------------------------
456    // URL construction tests (no HTTP needed)
457    // -----------------------------------------------------------------------
458
459    /// `set_message_box_permission` serializes a camelCase POST body.
460    ///
461    /// Verifies the key casing: `messageBox` and `recipientFee` in the JSON.
462    #[test]
463    fn set_permission_post_body_is_camel_case() {
464        let params = SetPermissionParams {
465            message_box: "payment_inbox".to_string(),
466            sender: Some("03abc".to_string()),
467            recipient_fee: 100,
468        };
469        let json = serde_json::to_string(&params).unwrap();
470        assert!(json.contains("\"messageBox\""), "messageBox must be camelCase");
471        assert!(json.contains("\"recipientFee\""), "recipientFee must be camelCase");
472        assert!(json.contains("\"sender\""), "sender must be present when Some");
473        assert!(!json.contains("message_box"), "no snake_case leakage");
474        assert!(!json.contains("recipient_fee"), "no snake_case leakage");
475    }
476
477    /// `get_message_box_permission` builds URL with camelCase query params.
478    ///
479    /// The URL must contain `messageBox=` (camelCase) — not `message_box=`.
480    #[test]
481    fn get_permission_url_uses_camel_case_query_params() {
482        // Test URL construction logic directly.
483        let host = "https://example.com";
484        let recipient = "03recipient";
485        let message_box = "inbox";
486        let sender = Some("03sender");
487
488        let mut url = format!(
489            "{}/permissions/get?recipient={}&messageBox={}",
490            host, recipient, message_box
491        );
492        if let Some(s) = sender {
493            url.push_str(&format!("&sender={s}"));
494        }
495
496        assert!(url.contains("messageBox=inbox"), "must use camelCase messageBox");
497        assert!(!url.contains("message_box"), "must not use snake_case");
498        assert!(url.contains("recipient=03recipient"), "recipient param present");
499        assert!(url.contains("sender=03sender"), "sender param present when Some");
500    }
501
502    /// `get_message_box_permission` omits `sender` param when None.
503    #[test]
504    fn get_permission_url_omits_sender_when_none() {
505        let host = "https://example.com";
506        let recipient = "03recipient";
507        let message_box = "inbox";
508        let sender: Option<&str> = None;
509
510        let mut url = format!(
511            "{}/permissions/get?recipient={}&messageBox={}",
512            host, recipient, message_box
513        );
514        if let Some(s) = sender {
515            url.push_str(&format!("&sender={s}"));
516        }
517
518        assert!(!url.contains("sender"), "sender param absent when None");
519        assert!(url.contains("messageBox=inbox"), "messageBox present");
520    }
521
522    /// `list_message_box_permissions` uses snake_case `message_box` query param.
523    ///
524    /// This is Pitfall 1: /permissions/list uses snake_case for the query key,
525    /// unlike every other endpoint that uses camelCase.
526    #[test]
527    fn list_permissions_url_uses_snake_case_message_box_param() {
528        let host = "https://example.com";
529        let message_box = Some("notifications");
530
531        let mut url = format!("{}/permissions/list", host);
532        let mut params: Vec<String> = Vec::new();
533
534        // CRITICAL: snake_case key
535        if let Some(mb) = message_box {
536            params.push(format!("message_box={mb}"));
537        }
538        if !params.is_empty() {
539            url.push('?');
540            url.push_str(&params.join("&"));
541        }
542
543        assert!(
544            url.contains("message_box=notifications"),
545            "must use snake_case message_box key: {}",
546            url
547        );
548        assert!(
549            !url.contains("messageBox"),
550            "must NOT use camelCase messageBox in list endpoint: {}",
551            url
552        );
553    }
554
555    /// `get_message_box_quote` URL uses camelCase `messageBox` query param.
556    #[test]
557    fn quote_url_uses_camel_case_message_box_param() {
558        let host = "https://example.com";
559        let url = format!(
560            "{}/permissions/quote?recipient=03r&messageBox=inbox",
561            host
562        );
563        assert!(url.contains("messageBox=inbox"), "quote endpoint uses camelCase");
564        assert!(!url.contains("message_box"), "not snake_case");
565    }
566
567    // -----------------------------------------------------------------------
568    // Header extraction logic
569    // -----------------------------------------------------------------------
570
571    /// `get_message_box_quote` returns `MissingHeader` when header absent.
572    ///
573    /// Tests the header extraction error path without a live HTTP call.
574    #[test]
575    fn missing_header_produces_missing_header_error() {
576        // Simulate the header extraction logic from get_message_box_quote.
577        use std::collections::HashMap;
578        let headers: HashMap<String, String> = HashMap::new();
579
580        let result = headers
581            .get("x-bsv-auth-identity-key")
582            .cloned()
583            .ok_or_else(|| MessageBoxError::MissingHeader("x-bsv-auth-identity-key".into()));
584
585        assert!(result.is_err(), "must error when header absent");
586        assert!(
587            matches!(result.unwrap_err(), MessageBoxError::MissingHeader(_)),
588            "error must be MissingHeader variant"
589        );
590    }
591
592    /// `get_message_box_quote` extracts header when present.
593    #[test]
594    fn present_header_is_extracted_correctly() {
595        use std::collections::HashMap;
596        let mut headers: HashMap<String, String> = HashMap::new();
597        headers.insert(
598            "x-bsv-auth-identity-key".to_string(),
599            "03deadbeef".to_string(),
600        );
601
602        let result: Result<String, MessageBoxError> = headers
603            .get("x-bsv-auth-identity-key")
604            .cloned()
605            .ok_or_else(|| MessageBoxError::MissingHeader("x-bsv-auth-identity-key".into()));
606
607        assert!(result.is_ok());
608        assert_eq!(result.unwrap(), "03deadbeef");
609    }
610
611    // -----------------------------------------------------------------------
612    // Response parsing tests (types already tested in types.rs, but verify
613    // the wrapper response structures parse correctly)
614    // -----------------------------------------------------------------------
615
616    /// `GetPermissionResponse` parses wrapped `{"permission": {...}}` body.
617    #[test]
618    fn get_permission_response_parses_wrapped_body() {
619        let raw = r#"{"permission": {"messageBox": "inbox", "recipientFee": 0, "createdAt": "2024-01-01T00:00:00Z", "updatedAt": "2024-01-01T00:00:00Z"}}"#;
620        let parsed: GetPermissionResponse = serde_json::from_str(raw).unwrap();
621        let perm = parsed.permission.unwrap();
622        assert_eq!(perm.message_box, "inbox");
623        assert_eq!(perm.recipient_fee, 0);
624    }
625
626    /// `GetPermissionResponse` parses `{"permission": null}` as None.
627    #[test]
628    fn get_permission_response_parses_null_as_none() {
629        let raw = r#"{"permission": null}"#;
630        let parsed: GetPermissionResponse = serde_json::from_str(raw).unwrap();
631        assert!(parsed.permission.is_none());
632    }
633
634    /// `ListPermissionsResponse` parses wrapped `{"permissions": [...]}` body.
635    #[test]
636    fn list_permissions_response_parses_wrapped_body() {
637        // /permissions/list returns snake_case field names.
638        let raw = r#"{"permissions": [{"message_box": "inbox", "recipient_fee": 100, "created_at": "2024-01-01T00:00:00Z", "updated_at": "2024-01-01T00:00:00Z"}]}"#;
639        let parsed: ListPermissionsResponse = serde_json::from_str(raw).unwrap();
640        assert_eq!(parsed.permissions.len(), 1);
641        assert_eq!(parsed.permissions[0].message_box, "inbox");
642        assert_eq!(parsed.permissions[0].recipient_fee, 100);
643    }
644
645    /// `QuoteResponse` parses wrapped `{"quote": {...}}` body.
646    #[test]
647    fn quote_response_parses_wrapped_body() {
648        let raw = r#"{"quote": {"recipientFee": 50, "deliveryFee": 10}}"#;
649        let parsed: QuoteResponse = serde_json::from_str(raw).unwrap();
650        assert_eq!(parsed.quote.recipient_fee, 50);
651        assert_eq!(parsed.quote.delivery_fee, 10);
652    }
653
654    // -----------------------------------------------------------------------
655    // Notification wrapper delegation tests
656    // -----------------------------------------------------------------------
657
658    /// `allow_notifications_from_peer` constructs params with messageBox="notifications".
659    #[test]
660    fn allow_notifications_params_uses_notifications_box() {
661        // Verify the params that would be sent.
662        let params = SetPermissionParams {
663            message_box: "notifications".to_string(),
664            sender: Some("03peer".to_string()),
665            recipient_fee: 0,
666        };
667        assert_eq!(params.message_box, "notifications");
668        assert_eq!(params.sender, Some("03peer".to_string()));
669    }
670
671    /// `deny_notifications_from_peer` constructs params with recipient_fee=-1.
672    #[test]
673    fn deny_notifications_params_uses_negative_one_fee() {
674        // Verify the params that would be sent.
675        let params = SetPermissionParams {
676            message_box: "notifications".to_string(),
677            sender: Some("03peer".to_string()),
678            recipient_fee: -1,
679        };
680        assert_eq!(params.recipient_fee, -1);
681        assert_eq!(params.message_box, "notifications");
682    }
683
684    /// MessageBoxClient::deny_notifications_from_peer produces blocked status from
685    /// the permission fee value.
686    #[test]
687    fn deny_fee_produces_blocked_status() {
688        let perm = MessageBoxPermission {
689            sender: Some("03peer".to_string()),
690            message_box: "notifications".to_string(),
691            recipient_fee: -1,
692            created_at: "2024-01-01".to_string(),
693            updated_at: "2024-01-01".to_string(),
694        };
695        assert_eq!(perm.status(), "blocked");
696    }
697
698    /// MessageBoxQuote fields are populated correctly from header + JSON body.
699    #[test]
700    fn quote_constructed_from_header_and_body() {
701        let quote = MessageBoxQuote {
702            delivery_fee: 10,
703            recipient_fee: 50,
704            delivery_agent_identity_key: "03agent".to_string(),
705        };
706        assert_eq!(quote.delivery_fee, 10);
707        assert_eq!(quote.recipient_fee, 50);
708        assert_eq!(quote.delivery_agent_identity_key, "03agent");
709    }
710
711    /// Client can be constructed — compile check for permissions module.
712    #[test]
713    fn client_with_permissions_compiles() {
714        let _client = make_client("https://example.com");
715    }
716}