relay/
lib.rs

1/* This Source Code Form is subject to the terms of the Mozilla Public
2 * License, v. 2.0. If a copy of the MPL was not distributed with this
3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4
5mod error;
6
7uniffi::setup_scaffolding!("relay");
8
9pub use error::{ApiResult, Error, RelayApiError, Result};
10use error_support::handle_error;
11
12use serde::{Deserialize, Serialize};
13use url::Url;
14use viaduct::{header_names, Method, Request};
15
16#[derive(uniffi::Object)]
17pub struct RelayClient {
18    server_url: String,
19    auth_token: Option<String>,
20}
21
22#[derive(Debug, Deserialize, uniffi::Record)]
23pub struct RelayAddress {
24    pub mask_type: String,
25    pub enabled: bool,
26    pub description: String,
27    pub generated_for: String,
28    pub block_list_emails: bool,
29    pub used_on: Option<String>,
30    pub id: i64,
31    pub address: String,
32    pub domain: i64,
33    pub full_address: String,
34    pub created_at: String, // Use String for timestamps for now (or chrono types later)
35    pub last_modified_at: String,
36    pub last_used_at: Option<String>,
37    pub num_forwarded: i64,
38    pub num_blocked: i64,
39    pub num_level_one_trackers_blocked: i64,
40    pub num_replied: i64,
41    pub num_spam: i64,
42}
43
44#[derive(Debug, Serialize)]
45struct CreateAddressPayload<'a> {
46    enabled: bool,
47    description: &'a str,
48    generated_for: &'a str,
49    used_on: &'a str,
50}
51
52#[derive(Deserialize)]
53struct RelayApiErrorMessage {
54    detail: String,
55}
56
57impl RelayClient {
58    fn build_url(&self, path: &str) -> Result<Url> {
59        Ok(Url::parse(&format!("{}{}", self.server_url, path))?)
60    }
61
62    fn prepare_request(&self, method: Method, url: Url) -> Result<Request> {
63        log::trace!("making {} request to: {}", method.as_str(), url);
64        let mut request = Request::new(method, url);
65        if let Some(ref token) = self.auth_token {
66            request = request.header(header_names::AUTHORIZATION, format!("Bearer {}", token))?;
67        }
68        Ok(request)
69    }
70}
71
72#[uniffi::export]
73impl RelayClient {
74    #[uniffi::constructor]
75    #[handle_error(Error)]
76    pub fn new(server_url: String, auth_token: Option<String>) -> ApiResult<Self> {
77        Ok(Self {
78            server_url,
79            auth_token,
80        })
81    }
82
83    #[handle_error(Error)]
84    pub fn fetch_addresses(&self) -> ApiResult<Vec<RelayAddress>> {
85        let url = self.build_url("/api/v1/relayaddresses/")?;
86        let request = self.prepare_request(Method::Get, url)?;
87
88        let response = request.send()?;
89        let body = response.text();
90        log::trace!("response text: {}", body);
91        if let Ok(parsed) = serde_json::from_str::<RelayApiErrorMessage>(&body) {
92            return Err(Error::RelayApi(parsed.detail));
93        }
94
95        let addresses: Vec<RelayAddress> = response.json()?;
96        Ok(addresses)
97    }
98
99    #[handle_error(Error)]
100    pub fn accept_terms(&self) -> ApiResult<()> {
101        let url = self.build_url("/api/v1/terms-accepted-user/")?;
102        let request = self.prepare_request(Method::Post, url)?;
103
104        let response = request.send()?;
105        let body = response.text();
106        log::trace!("response text: {}", body);
107        if let Ok(parsed) = serde_json::from_str::<RelayApiErrorMessage>(&body) {
108            return Err(Error::RelayApi(parsed.detail));
109        }
110        Ok(())
111    }
112
113    #[handle_error(Error)]
114    pub fn create_address(
115        &self,
116        description: &str,
117        generated_for: &str,
118        used_on: &str,
119    ) -> ApiResult<RelayAddress> {
120        let url = self.build_url("/api/v1/relayaddresses/")?;
121
122        let payload = CreateAddressPayload {
123            enabled: true,
124            description,
125            generated_for,
126            used_on,
127        };
128
129        let mut request = self.prepare_request(Method::Post, url)?;
130        request = request.json(&payload);
131
132        let response = request.send()?;
133        let body = response.text();
134        log::trace!("response text: {}", body);
135        if let Ok(parsed) = serde_json::from_str::<RelayApiErrorMessage>(&body) {
136            return Err(Error::RelayApi(parsed.detail));
137        }
138
139        let address: RelayAddress = response.json()?;
140        Ok(address)
141    }
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147    use mockito::mock;
148
149    fn base_addresses_json(extra_fields: &str) -> String {
150        format!(
151            r#"
152            [
153                {{
154                    "mask_type": "random",
155                    "enabled": true,
156                    "description": "Base Address",
157                    "generated_for": "example.com",
158                    "block_list_emails": false,
159                    "id": 1,
160                    "address": "base12345",
161                    "domain": 2,
162                    "full_address": "base12345@mozmail.com",
163                    "created_at": "2021-01-01T00:00:00Z",
164                    "last_modified_at": "2021-01-02T00:00:00Z",
165                    {extra_fields}
166                    "num_forwarded": 5,
167                    "num_blocked": 1,
168                    "num_level_one_trackers_blocked": 0,
169                    "num_replied": 2,
170                    "num_spam": 0
171                }}
172            ]
173            "#
174        )
175    }
176
177    #[test]
178    fn test_fetch_addresses() {
179        viaduct_reqwest::use_reqwest_backend();
180
181        let addresses_json = base_addresses_json(
182            r#""used_on": "example.com", "last_used_at": "2021-01-03T00:00:00Z", "#,
183        );
184        log::trace!("addresses_json: {}", addresses_json);
185
186        let _mock = mock("GET", "/api/v1/relayaddresses/")
187            .with_status(200)
188            .with_header("content-type", "application/json")
189            .with_body(addresses_json)
190            .create();
191
192        let client = RelayClient::new(mockito::server_url(), Some("mock_token".to_string()));
193
194        let addresses = client
195            .expect("success")
196            .fetch_addresses()
197            .expect("should fetch addresses");
198
199        assert_eq!(addresses.len(), 1);
200        let addr = &addresses[0];
201        assert!(addr.enabled);
202        assert_eq!(addr.full_address, "base12345@mozmail.com");
203        assert_eq!(addr.generated_for, "example.com");
204    }
205
206    #[test]
207    fn test_fetch_addresses_used_on_null() {
208        viaduct_reqwest::use_reqwest_backend();
209
210        let addresses_json =
211            base_addresses_json(r#""used_on": null,"last_used_at": "2021-01-03T00:00:00Z","#);
212
213        let _mock = mock("GET", "/api/v1/relayaddresses/")
214            .with_status(200)
215            .with_header("content-type", "application/json")
216            .with_body(addresses_json)
217            .create();
218
219        let client = RelayClient::new(mockito::server_url(), Some("mock_token".to_string()));
220        let addresses = client
221            .expect("success")
222            .fetch_addresses()
223            .expect("should fetch addresses");
224
225        assert_eq!(addresses.len(), 1);
226        assert_eq!(addresses[0].used_on, None);
227    }
228
229    #[test]
230    fn test_fetch_addresses_last_used_at_null() {
231        viaduct_reqwest::use_reqwest_backend();
232
233        let addresses_json =
234            base_addresses_json(r#""used_on": "example.com","last_used_at": null,"#);
235
236        let _mock = mock("GET", "/api/v1/relayaddresses/")
237            .with_status(200)
238            .with_header("content-type", "application/json")
239            .with_body(addresses_json)
240            .create();
241
242        let client = RelayClient::new(mockito::server_url(), Some("mock_token".to_string()));
243        let addresses = client
244            .expect("success")
245            .fetch_addresses()
246            .expect("should fetch addresses");
247
248        assert_eq!(addresses.len(), 1);
249        assert_eq!(addresses[0].last_used_at, None);
250    }
251
252    fn test_accept_terms_response(
253        status_code: usize,
254        body: Option<&str>,
255        token: Option<&str>,
256        expect_error: bool,
257    ) {
258        viaduct_reqwest::use_reqwest_backend();
259
260        let mut mock = mock("POST", "/api/v1/terms-accepted-user/").with_status(status_code);
261
262        if let Some(body_text) = body {
263            mock = mock
264                .with_header("content-type", "application/json")
265                .with_body(body_text);
266        }
267
268        let _mock = mock.create();
269        let client = RelayClient::new(mockito::server_url(), token.map(String::from));
270
271        let result = client.expect("success").accept_terms();
272
273        if expect_error {
274            assert!(result.is_err(), "Expected error but got success.");
275        } else {
276            assert!(result.is_ok(), "Expected success but got error.");
277        }
278    }
279
280    #[test]
281    fn test_accept_terms_user_created() {
282        test_accept_terms_response(201, None, Some("mock_token"), false);
283    }
284
285    #[test]
286    fn test_accept_terms_user_exists() {
287        test_accept_terms_response(202, None, Some("mock_token"), false);
288    }
289
290    #[test]
291    fn test_accept_terms_missing_authorization_header() {
292        test_accept_terms_response(
293            400,
294            Some(r#"{"detail": "Missing Bearer header."}"#),
295            None,
296            true,
297        );
298    }
299
300    #[test]
301    fn test_accept_terms_invalid_token() {
302        test_accept_terms_response(
303            403,
304            Some(r#"{"detail": "Invalid token."}"#),
305            Some("invalid_token"),
306            true,
307        );
308    }
309
310    #[test]
311    fn test_accept_terms_server_error_profile_failure() {
312        test_accept_terms_response(
313            500,
314            Some(r#"{"detail": "Did not receive a 200 response for account profile."}"#),
315            Some("valid_token_but_profile_fails"),
316            true,
317        );
318    }
319
320    #[test]
321    fn test_accept_terms_user_not_found() {
322        test_accept_terms_response(
323            404,
324            Some(r#"{"detail": "FXA user not found."}"#),
325            Some("valid_token_but_user_missing"),
326            true,
327        );
328    }
329
330    #[test]
331    fn test_create_address() {
332        viaduct_reqwest::use_reqwest_backend();
333
334        let address_json = r#"
335        {
336            "mask_type": "alias",
337            "enabled": true,
338            "description": "Created Address",
339            "generated_for": "example.com",
340            "block_list_emails": false,
341            "used_on": "example.com",
342            "id": 2,
343            "address": "new123456",
344            "domain": 2,
345            "full_address": "new123456@mozmail.com",
346            "created_at": "2021-01-04T00:00:00Z",
347            "last_modified_at": "2021-01-05T00:00:00Z",
348            "last_used_at": "2021-01-06T00:00:00Z",
349            "num_forwarded": 3,
350            "num_blocked": 0,
351            "num_level_one_trackers_blocked": 0,
352            "num_replied": 1,
353            "num_spam": 0
354        }
355    "#;
356
357        let _mock = mock("POST", "/api/v1/relayaddresses/")
358            .match_header("authorization", "Bearer mock_token")
359            .match_header("content-type", "application/json")
360            .with_status(201)
361            .with_header("content-type", "application/json")
362            .with_body(address_json)
363            .create();
364
365        let client = RelayClient::new(mockito::server_url(), Some("mock_token".to_string()));
366
367        let address = client
368            .expect("success")
369            .create_address("Created Address", "example.com", "example.com")
370            .expect("should create address successfully");
371
372        assert_eq!(address.full_address, "new123456@mozmail.com");
373        assert_eq!(address.generated_for, "example.com");
374        assert!(address.enabled);
375    }
376}