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, message_box
79        );
80        if let Some(s) = sender {
81            url.push_str(&format!("&sender={s}"));
82        }
83
84        let response = self.get_json(&url).await?;
85        check_status_error(&response.body)?;
86
87        let parsed: GetPermissionResponse = serde_json::from_slice(&response.body)?;
88        Ok(parsed.permission)
89    }
90
91    /// List permission records for this identity key.
92    ///
93    /// GETs `/permissions/list` with snake_case `message_box` query param —
94    /// this is the unique endpoint that uses snake_case for its query key
95    /// (Pitfall 1: do NOT use `messageBox` here).
96    pub async fn list_message_box_permissions(
97        &self,
98        message_box: Option<&str>,
99        limit: Option<u32>,
100        offset: Option<u32>,
101        override_host: Option<&str>,
102    ) -> Result<Vec<MessageBoxPermission>, MessageBoxError> {
103        self.assert_initialized().await?;
104
105        let base = override_host.unwrap_or_else(|| self.host());
106        let mut url = format!("{base}/permissions/list");
107        let mut params: Vec<String> = Vec::new();
108
109        // CRITICAL: key is snake_case `message_box` — NOT `messageBox`.
110        if let Some(mb) = message_box {
111            params.push(format!("message_box={mb}"));
112        }
113        if let Some(l) = limit {
114            params.push(format!("limit={l}"));
115        }
116        if let Some(o) = offset {
117            params.push(format!("offset={o}"));
118        }
119
120        if !params.is_empty() {
121            url.push('?');
122            url.push_str(&params.join("&"));
123        }
124
125        let response = self.get_json(&url).await?;
126        check_status_error(&response.body)?;
127
128        let parsed: ListPermissionsResponse = serde_json::from_slice(&response.body)?;
129        Ok(parsed.permissions)
130    }
131
132    /// Get a delivery quote for sending a message to `recipient`'s `message_box`.
133    ///
134    /// The response body is wrapped (`{"quote": {...}}`). The delivery agent's
135    /// identity key comes from the `x-bsv-auth-identity-key` response header —
136    /// NOT from the JSON body (Pitfall 2).
137    pub async fn get_message_box_quote(
138        &self,
139        recipient: &str,
140        message_box: &str,
141        override_host: Option<&str>,
142    ) -> Result<MessageBoxQuote, MessageBoxError> {
143        self.assert_initialized().await?;
144
145        let base = override_host.unwrap_or_else(|| self.host());
146        // NOTE: query param key is camelCase `messageBox` here.
147        let url = format!(
148            "{base}/permissions/quote?recipient={}&messageBox={}",
149            recipient, message_box
150        );
151
152        let response = self.get_json(&url).await?;
153        check_status_error(&response.body)?;
154
155        // Extract delivery agent identity key from response header.
156        // This header is NOT in the JSON body — it is set by the BRC-31 server.
157        let delivery_agent_identity_key = response
158            .headers
159            .get("x-bsv-auth-identity-key")
160            .cloned()
161            .ok_or_else(|| MessageBoxError::MissingHeader("x-bsv-auth-identity-key".into()))?;
162
163        let parsed: QuoteResponse = serde_json::from_slice(&response.body)?;
164
165        Ok(MessageBoxQuote {
166            delivery_fee: parsed.quote.delivery_fee,
167            recipient_fee: parsed.quote.recipient_fee,
168            delivery_agent_identity_key,
169        })
170    }
171
172    // -----------------------------------------------------------------------
173    // Notification wrappers
174    // -----------------------------------------------------------------------
175
176    /// Allow a peer to send notifications by granting them access to the
177    /// `"notifications"` message box with the given `recipient_fee`.
178    pub async fn allow_notifications_from_peer(
179        &self,
180        sender: &str,
181        recipient_fee: i64,
182        override_host: Option<&str>,
183    ) -> Result<(), MessageBoxError> {
184        self.set_message_box_permission(
185            SetPermissionParams {
186                message_box: "notifications".to_string(),
187                sender: Some(sender.to_string()),
188                recipient_fee,
189            },
190            override_host,
191        )
192        .await
193    }
194
195    /// Block a peer from the `"notifications"` message box by setting
196    /// `recipient_fee = -1`.
197    pub async fn deny_notifications_from_peer(
198        &self,
199        sender: &str,
200        override_host: Option<&str>,
201    ) -> Result<(), MessageBoxError> {
202        self.set_message_box_permission(
203            SetPermissionParams {
204                message_box: "notifications".to_string(),
205                sender: Some(sender.to_string()),
206                recipient_fee: -1,
207            },
208            override_host,
209        )
210        .await
211    }
212
213    /// Check whether `peer` has notification access from this identity's
214    /// perspective.
215    ///
216    /// Uses `get_identity_key()` for `recipient` — the check is always
217    /// performed against the local identity.
218    pub async fn check_peer_notification_status(
219        &self,
220        peer: &str,
221        override_host: Option<&str>,
222    ) -> Result<Option<MessageBoxPermission>, MessageBoxError> {
223        let recipient = self.get_identity_key().await?;
224        self.get_message_box_permission(&recipient, "notifications", Some(peer), override_host)
225            .await
226    }
227
228    /// List all permission records in the `"notifications"` message box.
229    pub async fn list_peer_notifications(
230        &self,
231        override_host: Option<&str>,
232    ) -> Result<Vec<MessageBoxPermission>, MessageBoxError> {
233        self.list_message_box_permissions(Some("notifications"), None, None, override_host)
234            .await
235    }
236
237    /// Send a notification body to `recipient`'s `"notifications"` inbox.
238    ///
239    /// Delegates to `send_message` with `message_box = "notifications"` and
240    /// `check_permissions = true` — matching TS which passes `checkPermissions: true`
241    /// so that fee quotes are fetched and payments created if required.
242    pub async fn send_notification(
243        &self,
244        recipient: &str,
245        body: &str,
246        override_host: Option<&str>,
247    ) -> Result<String, MessageBoxError> {
248        match override_host {
249            Some(host) => {
250                self.send_message_to_host(
251                    host,
252                    recipient,
253                    "notifications",
254                    body,
255                    false,
256                    true,
257                    None,
258                    None,
259                )
260                .await
261            }
262            None => {
263                self.send_message(recipient, "notifications", body, false, true, None, None)
264                    .await
265            }
266        }
267    }
268
269    /// Get delivery quotes for multiple recipients in one logical call.
270    ///
271    /// Groups recipients by resolved host, requests quotes from each host,
272    /// then aggregates into a `MessageBoxMultiQuote` with per-recipient breakdown
273    /// and delivery agent identity keys per host.
274    pub async fn get_message_box_quote_multi(
275        &self,
276        recipients: &[&str],
277        message_box: &str,
278        override_host: Option<&str>,
279    ) -> Result<crate::types::MessageBoxMultiQuote, MessageBoxError> {
280        use crate::types::{MessageBoxMultiQuote, RecipientQuote};
281        use std::collections::HashMap;
282
283        let mut quotes_by_recipient: Vec<RecipientQuote> = Vec::new();
284        let mut blocked_recipients: Vec<String> = Vec::new();
285        let mut delivery_agent_identity_key_by_host: HashMap<String, String> = HashMap::new();
286        let mut total_delivery_fee: i64 = 0;
287        let mut total_recipient_fee: i64 = 0;
288
289        for recipient in recipients {
290            // Resolve host per recipient (or use override).
291            let host = if let Some(h) = override_host {
292                h.to_string()
293            } else {
294                self.resolve_host_for_recipient(recipient)
295                    .await
296                    .unwrap_or_else(|_| self.host().to_string())
297            };
298
299            // Build the quote URL — multiple recipient query params per host would be ideal
300            // but the TS client issues one request per recipient; we match that behavior.
301            let url = format!(
302                "{host}/permissions/quote?recipient={}&messageBox={}",
303                recipient, message_box
304            );
305
306            let response = match self.get_json(&url).await {
307                Ok(r) => r,
308                Err(e) => {
309                    // If the quote fails, mark as failed (treat as blocked for safety).
310                    blocked_recipients.push(recipient.to_string());
311                    quotes_by_recipient.push(RecipientQuote {
312                        recipient: recipient.to_string(),
313                        message_box: message_box.to_string(),
314                        delivery_fee: 0,
315                        recipient_fee: 0,
316                        status: format!("error: {e}"),
317                    });
318                    continue;
319                }
320            };
321
322            // Extract delivery agent key from header — record per host.
323            if let Some(key) = response.headers.get("x-bsv-auth-identity-key") {
324                delivery_agent_identity_key_by_host.insert(host.clone(), key.clone());
325            }
326
327            let parsed = match serde_json::from_slice::<QuoteResponse>(&response.body) {
328                Ok(p) => p,
329                Err(_) => {
330                    blocked_recipients.push(recipient.to_string());
331                    quotes_by_recipient.push(RecipientQuote {
332                        recipient: recipient.to_string(),
333                        message_box: message_box.to_string(),
334                        delivery_fee: 0,
335                        recipient_fee: 0,
336                        status: "parse_error".to_string(),
337                    });
338                    continue;
339                }
340            };
341
342            let delivery_fee = parsed.quote.delivery_fee;
343            let recipient_fee = parsed.quote.recipient_fee;
344            let status = if recipient_fee < 0 {
345                "blocked".to_string()
346            } else if recipient_fee == 0 && delivery_fee == 0 {
347                "always_allow".to_string()
348            } else {
349                "payment_required".to_string()
350            };
351
352            if recipient_fee < 0 {
353                blocked_recipients.push(recipient.to_string());
354            } else {
355                total_delivery_fee += delivery_fee;
356                total_recipient_fee += recipient_fee;
357            }
358
359            quotes_by_recipient.push(RecipientQuote {
360                recipient: recipient.to_string(),
361                message_box: message_box.to_string(),
362                delivery_fee,
363                recipient_fee,
364                status,
365            });
366        }
367
368        Ok(MessageBoxMultiQuote {
369            quotes_by_recipient,
370            totals: Some(crate::types::SendListTotals {
371                delivery_fees: total_delivery_fee,
372                recipient_fees: total_recipient_fee,
373                total_for_payable_recipients: total_delivery_fee + total_recipient_fee,
374            }),
375            blocked_recipients,
376            delivery_agent_identity_key_by_host,
377        })
378    }
379
380    /// Send a notification to multiple recipients at once.
381    ///
382    /// Delegates to `send_message_to_recipients` with `message_box = "notifications"`.
383    /// Matches the TS `sendNotification` overload that accepts `PubKeyHex[]`.
384    pub async fn send_notification_to_recipients(
385        &self,
386        recipients: &[&str],
387        body: &str,
388        override_host: Option<&str>,
389    ) -> Result<crate::types::SendListResult, MessageBoxError> {
390        use crate::types::SendListParams;
391
392        let params = SendListParams {
393            recipients: recipients.iter().map(|s| s.to_string()).collect(),
394            message_box: "notifications".to_string(),
395            body: body.to_string(),
396            skip_encryption: Some(false),
397        };
398
399        self.send_message_to_recipients(&params, override_host)
400            .await
401    }
402}
403
404// ---------------------------------------------------------------------------
405// Tests
406// ---------------------------------------------------------------------------
407
408#[cfg(test)]
409mod tests {
410    use super::*;
411    use crate::types::{MessageBoxPermission, MessageBoxQuote, SetPermissionParams};
412    use bsv::primitives::private_key::PrivateKey;
413    use bsv::wallet::error::WalletError;
414    use bsv::wallet::interfaces::*;
415    use bsv::wallet::proto_wallet::ProtoWallet;
416    use std::sync::Arc;
417
418    // -----------------------------------------------------------------------
419    // ArcWallet test helper (same pattern as other modules)
420    // -----------------------------------------------------------------------
421
422    #[derive(Clone)]
423    struct ArcWallet(Arc<ProtoWallet>);
424
425    impl ArcWallet {
426        fn new() -> Self {
427            let key = PrivateKey::from_random().expect("random key");
428            ArcWallet(Arc::new(ProtoWallet::new(key)))
429        }
430    }
431
432    #[async_trait::async_trait]
433    impl WalletInterface for ArcWallet {
434        async fn create_action(
435            &self,
436            args: CreateActionArgs,
437            orig: Option<&str>,
438        ) -> Result<CreateActionResult, WalletError> {
439            self.0.create_action(args, orig).await
440        }
441        async fn sign_action(
442            &self,
443            args: SignActionArgs,
444            orig: Option<&str>,
445        ) -> Result<SignActionResult, WalletError> {
446            self.0.sign_action(args, orig).await
447        }
448        async fn abort_action(
449            &self,
450            args: AbortActionArgs,
451            orig: Option<&str>,
452        ) -> Result<AbortActionResult, WalletError> {
453            self.0.abort_action(args, orig).await
454        }
455        async fn list_actions(
456            &self,
457            args: ListActionsArgs,
458            orig: Option<&str>,
459        ) -> Result<ListActionsResult, WalletError> {
460            self.0.list_actions(args, orig).await
461        }
462        async fn internalize_action(
463            &self,
464            args: InternalizeActionArgs,
465            orig: Option<&str>,
466        ) -> Result<InternalizeActionResult, WalletError> {
467            self.0.internalize_action(args, orig).await
468        }
469        async fn list_outputs(
470            &self,
471            args: ListOutputsArgs,
472            orig: Option<&str>,
473        ) -> Result<ListOutputsResult, WalletError> {
474            self.0.list_outputs(args, orig).await
475        }
476        async fn relinquish_output(
477            &self,
478            args: RelinquishOutputArgs,
479            orig: Option<&str>,
480        ) -> Result<RelinquishOutputResult, WalletError> {
481            self.0.relinquish_output(args, orig).await
482        }
483        async fn get_public_key(
484            &self,
485            args: GetPublicKeyArgs,
486            orig: Option<&str>,
487        ) -> Result<GetPublicKeyResult, WalletError> {
488            self.0.get_public_key(args, orig).await
489        }
490        async fn reveal_counterparty_key_linkage(
491            &self,
492            args: RevealCounterpartyKeyLinkageArgs,
493            orig: Option<&str>,
494        ) -> Result<RevealCounterpartyKeyLinkageResult, WalletError> {
495            self.0.reveal_counterparty_key_linkage(args, orig).await
496        }
497        async fn reveal_specific_key_linkage(
498            &self,
499            args: RevealSpecificKeyLinkageArgs,
500            orig: Option<&str>,
501        ) -> Result<RevealSpecificKeyLinkageResult, WalletError> {
502            self.0.reveal_specific_key_linkage(args, orig).await
503        }
504        async fn encrypt(
505            &self,
506            args: EncryptArgs,
507            orig: Option<&str>,
508        ) -> Result<EncryptResult, WalletError> {
509            self.0.encrypt(args, orig).await
510        }
511        async fn decrypt(
512            &self,
513            args: DecryptArgs,
514            orig: Option<&str>,
515        ) -> Result<DecryptResult, WalletError> {
516            self.0.decrypt(args, orig).await
517        }
518        async fn create_hmac(
519            &self,
520            args: CreateHmacArgs,
521            orig: Option<&str>,
522        ) -> Result<CreateHmacResult, WalletError> {
523            self.0.create_hmac(args, orig).await
524        }
525        async fn verify_hmac(
526            &self,
527            args: VerifyHmacArgs,
528            orig: Option<&str>,
529        ) -> Result<VerifyHmacResult, WalletError> {
530            self.0.verify_hmac(args, orig).await
531        }
532        async fn create_signature(
533            &self,
534            args: CreateSignatureArgs,
535            orig: Option<&str>,
536        ) -> Result<CreateSignatureResult, WalletError> {
537            self.0.create_signature(args, orig).await
538        }
539        async fn verify_signature(
540            &self,
541            args: VerifySignatureArgs,
542            orig: Option<&str>,
543        ) -> Result<VerifySignatureResult, WalletError> {
544            self.0.verify_signature(args, orig).await
545        }
546        async fn acquire_certificate(
547            &self,
548            args: AcquireCertificateArgs,
549            orig: Option<&str>,
550        ) -> Result<Certificate, WalletError> {
551            self.0.acquire_certificate(args, orig).await
552        }
553        async fn list_certificates(
554            &self,
555            args: ListCertificatesArgs,
556            orig: Option<&str>,
557        ) -> Result<ListCertificatesResult, WalletError> {
558            self.0.list_certificates(args, orig).await
559        }
560        async fn prove_certificate(
561            &self,
562            args: ProveCertificateArgs,
563            orig: Option<&str>,
564        ) -> Result<ProveCertificateResult, WalletError> {
565            self.0.prove_certificate(args, orig).await
566        }
567        async fn relinquish_certificate(
568            &self,
569            args: RelinquishCertificateArgs,
570            orig: Option<&str>,
571        ) -> Result<RelinquishCertificateResult, WalletError> {
572            self.0.relinquish_certificate(args, orig).await
573        }
574        async fn discover_by_identity_key(
575            &self,
576            args: DiscoverByIdentityKeyArgs,
577            orig: Option<&str>,
578        ) -> Result<DiscoverCertificatesResult, WalletError> {
579            self.0.discover_by_identity_key(args, orig).await
580        }
581        async fn discover_by_attributes(
582            &self,
583            args: DiscoverByAttributesArgs,
584            orig: Option<&str>,
585        ) -> Result<DiscoverCertificatesResult, WalletError> {
586            self.0.discover_by_attributes(args, orig).await
587        }
588        async fn is_authenticated(
589            &self,
590            orig: Option<&str>,
591        ) -> Result<AuthenticatedResult, WalletError> {
592            self.0.is_authenticated(orig).await
593        }
594        async fn wait_for_authentication(
595            &self,
596            orig: Option<&str>,
597        ) -> Result<AuthenticatedResult, WalletError> {
598            self.0.wait_for_authentication(orig).await
599        }
600        async fn get_height(&self, orig: Option<&str>) -> Result<GetHeightResult, WalletError> {
601            self.0.get_height(orig).await
602        }
603        async fn get_header_for_height(
604            &self,
605            args: GetHeaderArgs,
606            orig: Option<&str>,
607        ) -> Result<GetHeaderResult, WalletError> {
608            self.0.get_header_for_height(args, orig).await
609        }
610        async fn get_network(&self, orig: Option<&str>) -> Result<GetNetworkResult, WalletError> {
611            self.0.get_network(orig).await
612        }
613        async fn get_version(&self, orig: Option<&str>) -> Result<GetVersionResult, WalletError> {
614            self.0.get_version(orig).await
615        }
616    }
617
618    fn make_client(host: &str) -> MessageBoxClient<ArcWallet> {
619        MessageBoxClient::new(
620            host.to_string(),
621            ArcWallet::new(),
622            None,
623            bsv::services::overlay_tools::Network::Mainnet,
624        )
625    }
626
627    // -----------------------------------------------------------------------
628    // URL construction tests (no HTTP needed)
629    // -----------------------------------------------------------------------
630
631    /// `set_message_box_permission` serializes a camelCase POST body.
632    ///
633    /// Verifies the key casing: `messageBox` and `recipientFee` in the JSON.
634    #[test]
635    fn set_permission_post_body_is_camel_case() {
636        let params = SetPermissionParams {
637            message_box: "payment_inbox".to_string(),
638            sender: Some("03abc".to_string()),
639            recipient_fee: 100,
640        };
641        let json = serde_json::to_string(&params).unwrap();
642        assert!(
643            json.contains("\"messageBox\""),
644            "messageBox must be camelCase"
645        );
646        assert!(
647            json.contains("\"recipientFee\""),
648            "recipientFee must be camelCase"
649        );
650        assert!(
651            json.contains("\"sender\""),
652            "sender must be present when Some"
653        );
654        assert!(!json.contains("message_box"), "no snake_case leakage");
655        assert!(!json.contains("recipient_fee"), "no snake_case leakage");
656    }
657
658    /// `get_message_box_permission` builds URL with camelCase query params.
659    ///
660    /// The URL must contain `messageBox=` (camelCase) — not `message_box=`.
661    #[test]
662    fn get_permission_url_uses_camel_case_query_params() {
663        // Test URL construction logic directly.
664        let host = "https://example.com";
665        let recipient = "03recipient";
666        let message_box = "inbox";
667        let sender = Some("03sender");
668
669        let mut url = format!(
670            "{}/permissions/get?recipient={}&messageBox={}",
671            host, recipient, message_box
672        );
673        if let Some(s) = sender {
674            url.push_str(&format!("&sender={s}"));
675        }
676
677        assert!(
678            url.contains("messageBox=inbox"),
679            "must use camelCase messageBox"
680        );
681        assert!(!url.contains("message_box"), "must not use snake_case");
682        assert!(
683            url.contains("recipient=03recipient"),
684            "recipient param present"
685        );
686        assert!(
687            url.contains("sender=03sender"),
688            "sender param present when Some"
689        );
690    }
691
692    /// `get_message_box_permission` omits `sender` param when None.
693    #[test]
694    fn get_permission_url_omits_sender_when_none() {
695        let host = "https://example.com";
696        let recipient = "03recipient";
697        let message_box = "inbox";
698        let sender: Option<&str> = None;
699
700        let mut url = format!(
701            "{}/permissions/get?recipient={}&messageBox={}",
702            host, recipient, message_box
703        );
704        if let Some(s) = sender {
705            url.push_str(&format!("&sender={s}"));
706        }
707
708        assert!(!url.contains("sender"), "sender param absent when None");
709        assert!(url.contains("messageBox=inbox"), "messageBox present");
710    }
711
712    /// `list_message_box_permissions` uses snake_case `message_box` query param.
713    ///
714    /// This is Pitfall 1: /permissions/list uses snake_case for the query key,
715    /// unlike every other endpoint that uses camelCase.
716    #[test]
717    fn list_permissions_url_uses_snake_case_message_box_param() {
718        let host = "https://example.com";
719        let message_box = Some("notifications");
720
721        let mut url = format!("{}/permissions/list", host);
722        let mut params: Vec<String> = Vec::new();
723
724        // CRITICAL: snake_case key
725        if let Some(mb) = message_box {
726            params.push(format!("message_box={mb}"));
727        }
728        if !params.is_empty() {
729            url.push('?');
730            url.push_str(&params.join("&"));
731        }
732
733        assert!(
734            url.contains("message_box=notifications"),
735            "must use snake_case message_box key: {}",
736            url
737        );
738        assert!(
739            !url.contains("messageBox"),
740            "must NOT use camelCase messageBox in list endpoint: {}",
741            url
742        );
743    }
744
745    /// `get_message_box_quote` URL uses camelCase `messageBox` query param.
746    #[test]
747    fn quote_url_uses_camel_case_message_box_param() {
748        let host = "https://example.com";
749        let url = format!("{}/permissions/quote?recipient=03r&messageBox=inbox", host);
750        assert!(
751            url.contains("messageBox=inbox"),
752            "quote endpoint uses camelCase"
753        );
754        assert!(!url.contains("message_box"), "not snake_case");
755    }
756
757    // -----------------------------------------------------------------------
758    // Header extraction logic
759    // -----------------------------------------------------------------------
760
761    /// `get_message_box_quote` returns `MissingHeader` when header absent.
762    ///
763    /// Tests the header extraction error path without a live HTTP call.
764    #[test]
765    fn missing_header_produces_missing_header_error() {
766        // Simulate the header extraction logic from get_message_box_quote.
767        use std::collections::HashMap;
768        let headers: HashMap<String, String> = HashMap::new();
769
770        let result = headers
771            .get("x-bsv-auth-identity-key")
772            .cloned()
773            .ok_or_else(|| MessageBoxError::MissingHeader("x-bsv-auth-identity-key".into()));
774
775        assert!(result.is_err(), "must error when header absent");
776        assert!(
777            matches!(result.unwrap_err(), MessageBoxError::MissingHeader(_)),
778            "error must be MissingHeader variant"
779        );
780    }
781
782    /// `get_message_box_quote` extracts header when present.
783    #[test]
784    fn present_header_is_extracted_correctly() {
785        use std::collections::HashMap;
786        let mut headers: HashMap<String, String> = HashMap::new();
787        headers.insert(
788            "x-bsv-auth-identity-key".to_string(),
789            "03deadbeef".to_string(),
790        );
791
792        let result: Result<String, MessageBoxError> = headers
793            .get("x-bsv-auth-identity-key")
794            .cloned()
795            .ok_or_else(|| MessageBoxError::MissingHeader("x-bsv-auth-identity-key".into()));
796
797        assert!(result.is_ok());
798        assert_eq!(result.unwrap(), "03deadbeef");
799    }
800
801    // -----------------------------------------------------------------------
802    // Response parsing tests (types already tested in types.rs, but verify
803    // the wrapper response structures parse correctly)
804    // -----------------------------------------------------------------------
805
806    /// `GetPermissionResponse` parses wrapped `{"permission": {...}}` body.
807    #[test]
808    fn get_permission_response_parses_wrapped_body() {
809        let raw = r#"{"permission": {"messageBox": "inbox", "recipientFee": 0, "createdAt": "2024-01-01T00:00:00Z", "updatedAt": "2024-01-01T00:00:00Z"}}"#;
810        let parsed: GetPermissionResponse = serde_json::from_str(raw).unwrap();
811        let perm = parsed.permission.unwrap();
812        assert_eq!(perm.message_box, "inbox");
813        assert_eq!(perm.recipient_fee, 0);
814    }
815
816    /// `GetPermissionResponse` parses `{"permission": null}` as None.
817    #[test]
818    fn get_permission_response_parses_null_as_none() {
819        let raw = r#"{"permission": null}"#;
820        let parsed: GetPermissionResponse = serde_json::from_str(raw).unwrap();
821        assert!(parsed.permission.is_none());
822    }
823
824    /// `ListPermissionsResponse` parses wrapped `{"permissions": [...]}` body.
825    #[test]
826    fn list_permissions_response_parses_wrapped_body() {
827        // /permissions/list returns snake_case field names.
828        let raw = r#"{"permissions": [{"message_box": "inbox", "recipient_fee": 100, "created_at": "2024-01-01T00:00:00Z", "updated_at": "2024-01-01T00:00:00Z"}]}"#;
829        let parsed: ListPermissionsResponse = serde_json::from_str(raw).unwrap();
830        assert_eq!(parsed.permissions.len(), 1);
831        assert_eq!(parsed.permissions[0].message_box, "inbox");
832        assert_eq!(parsed.permissions[0].recipient_fee, 100);
833    }
834
835    /// `QuoteResponse` parses wrapped `{"quote": {...}}` body.
836    #[test]
837    fn quote_response_parses_wrapped_body() {
838        let raw = r#"{"quote": {"recipientFee": 50, "deliveryFee": 10}}"#;
839        let parsed: QuoteResponse = serde_json::from_str(raw).unwrap();
840        assert_eq!(parsed.quote.recipient_fee, 50);
841        assert_eq!(parsed.quote.delivery_fee, 10);
842    }
843
844    // -----------------------------------------------------------------------
845    // Notification wrapper delegation tests
846    // -----------------------------------------------------------------------
847
848    /// `allow_notifications_from_peer` constructs params with messageBox="notifications".
849    #[test]
850    fn allow_notifications_params_uses_notifications_box() {
851        // Verify the params that would be sent.
852        let params = SetPermissionParams {
853            message_box: "notifications".to_string(),
854            sender: Some("03peer".to_string()),
855            recipient_fee: 0,
856        };
857        assert_eq!(params.message_box, "notifications");
858        assert_eq!(params.sender, Some("03peer".to_string()));
859    }
860
861    /// `deny_notifications_from_peer` constructs params with recipient_fee=-1.
862    #[test]
863    fn deny_notifications_params_uses_negative_one_fee() {
864        // Verify the params that would be sent.
865        let params = SetPermissionParams {
866            message_box: "notifications".to_string(),
867            sender: Some("03peer".to_string()),
868            recipient_fee: -1,
869        };
870        assert_eq!(params.recipient_fee, -1);
871        assert_eq!(params.message_box, "notifications");
872    }
873
874    /// MessageBoxClient::deny_notifications_from_peer produces blocked status from
875    /// the permission fee value.
876    #[test]
877    fn deny_fee_produces_blocked_status() {
878        let perm = MessageBoxPermission {
879            sender: Some("03peer".to_string()),
880            message_box: "notifications".to_string(),
881            recipient_fee: -1,
882            created_at: "2024-01-01".to_string(),
883            updated_at: "2024-01-01".to_string(),
884        };
885        assert_eq!(perm.status(), "blocked");
886    }
887
888    /// MessageBoxQuote fields are populated correctly from header + JSON body.
889    #[test]
890    fn quote_constructed_from_header_and_body() {
891        let quote = MessageBoxQuote {
892            delivery_fee: 10,
893            recipient_fee: 50,
894            delivery_agent_identity_key: "03agent".to_string(),
895        };
896        assert_eq!(quote.delivery_fee, 10);
897        assert_eq!(quote.recipient_fee, 50);
898        assert_eq!(quote.delivery_agent_identity_key, "03agent");
899    }
900
901    /// Client can be constructed — compile check for permissions module.
902    #[test]
903    fn client_with_permissions_compiles() {
904        let _client = make_client("https://example.com");
905    }
906}