Skip to main content

bitwarden_test/play/
http_client.rs

1//! HTTP client with automatic x-play-id header injection
2
3use reqwest::{Client, RequestBuilder, Response};
4use serde::{Serialize, de::DeserializeOwned};
5use tracing::debug;
6
7use super::{PlayConfig, PlayError, PlayResult};
8
9/// HTTP client wrapper that adds the x-play-id header to all requests
10#[derive(Debug, Clone)]
11pub(crate) struct PlayHttpClient {
12    client: Client,
13    play_id: String,
14    config: PlayConfig,
15}
16
17impl PlayHttpClient {
18    /// Create a new HTTP client with the given play_id
19    pub(crate) fn new(play_id: String, config: PlayConfig) -> Self {
20        let client = bitwarden_api_api::new_http_client();
21
22        Self {
23            client,
24            play_id,
25            config,
26        }
27    }
28
29    /// Get the play_id for this client
30    pub(crate) fn play_id(&self) -> &str {
31        &self.play_id
32    }
33
34    /// Get the configuration
35    pub(crate) fn config(&self) -> &PlayConfig {
36        &self.config
37    }
38
39    /// Add the x-play-id header to a request builder
40    fn with_play_id(&self, builder: RequestBuilder) -> RequestBuilder {
41        builder.header("x-play-id", &self.play_id)
42    }
43
44    /// POST JSON to the seeder API and parse JSON response
45    pub(crate) async fn post_seeder<T: Serialize, R: DeserializeOwned>(
46        &self,
47        path: &str,
48        body: &T,
49    ) -> PlayResult<R> {
50        let url = format!("{}{}", self.config.seeder_url, path);
51
52        debug!(
53            method = "POST",
54            url = %url,
55            play_id = %self.play_id,
56            body = ?serde_json::to_string(body).ok(),
57            "Play request"
58        );
59
60        let response = self
61            .with_play_id(self.client.post(&url))
62            .json(body)
63            .send()
64            .await?;
65
66        self.handle_json_response(response).await
67    }
68
69    /// DELETE to the seeder API
70    pub(crate) async fn delete_seeder(&self, path: &str) -> PlayResult<()> {
71        let url = format!("{}{}", self.config.seeder_url, path);
72
73        debug!(
74            method = "DELETE",
75            url = %url,
76            play_id = %self.play_id,
77            "Play request"
78        );
79
80        let response = self.with_play_id(self.client.delete(&url)).send().await?;
81
82        let status = response.status();
83        debug!(status = %status, "Play response");
84
85        if status.is_success() {
86            Ok(())
87        } else {
88            let body = response.text().await.unwrap_or_default();
89            debug!(body = %body, "Play error response body");
90            Err(PlayError::Response {
91                status: status.as_u16(),
92                body,
93            })
94        }
95    }
96
97    /// Handle a JSON response, returning an error for non-success status codes
98    async fn handle_json_response<R: DeserializeOwned>(&self, response: Response) -> PlayResult<R> {
99        let status = response.status();
100
101        if status.is_success() {
102            let body = response.text().await?;
103            debug!(status = %status, body = %body, "Play response");
104            Ok(serde_json::from_str(&body)?)
105        } else {
106            let body = response.text().await.unwrap_or_default();
107            debug!(status = %status, body = %body, "Play error response");
108            Err(PlayError::Response {
109                status: status.as_u16(),
110                body,
111            })
112        }
113    }
114}
115
116#[cfg(test)]
117mod tests {
118    use wiremock::{
119        Mock, MockServer, ResponseTemplate,
120        matchers::{header, method, path},
121    };
122
123    use super::*;
124
125    fn create_test_config(seeder_url: &str) -> PlayConfig {
126        PlayConfig::new(
127            "https://api.example.com",
128            "https://identity.example.com",
129            seeder_url,
130        )
131    }
132
133    #[test]
134    fn test_new_stores_play_id_and_config() {
135        let config = create_test_config("http://localhost:5047");
136        let client = PlayHttpClient::new("test-play-id".to_string(), config);
137
138        assert_eq!(client.play_id(), "test-play-id");
139        assert_eq!(client.config().api_url, "https://api.example.com");
140        assert_eq!(client.config().identity_url, "https://identity.example.com");
141        assert_eq!(client.config().seeder_url, "http://localhost:5047");
142    }
143
144    #[tokio::test]
145    async fn test_post_seeder() {
146        let mock_server = MockServer::start().await;
147
148        Mock::given(method("POST"))
149            .and(path("/seed/"))
150            .and(header("x-play-id", "test-play-id"))
151            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
152                "id": 42,
153                "name": "test-user"
154            })))
155            .expect(1)
156            .mount(&mock_server)
157            .await;
158
159        let config = create_test_config(&mock_server.uri());
160        let client = PlayHttpClient::new("test-play-id".to_string(), config);
161
162        #[derive(serde::Deserialize, Debug, PartialEq)]
163        struct TestResponse {
164            id: i32,
165            name: String,
166        }
167
168        let result: TestResponse = client
169            .post_seeder("/seed/", &serde_json::json!({}))
170            .await
171            .unwrap();
172
173        assert_eq!(result.id, 42);
174        assert_eq!(result.name, "test-user");
175    }
176
177    #[tokio::test]
178    async fn test_post_seeder_handles_server_error() {
179        let mock_server = MockServer::start().await;
180
181        Mock::given(method("POST"))
182            .and(path("/seed/"))
183            .respond_with(ResponseTemplate::new(500).set_body_string("Internal Server Error"))
184            .mount(&mock_server)
185            .await;
186
187        let config = create_test_config(&mock_server.uri());
188        let client = PlayHttpClient::new("test-id".to_string(), config);
189
190        let result: PlayResult<serde_json::Value> =
191            client.post_seeder("/seed/", &serde_json::json!({})).await;
192
193        match result {
194            Err(PlayError::Response { status, body }) => {
195                assert_eq!(status, 500);
196                assert_eq!(body, "Internal Server Error");
197            }
198            _ => panic!("Expected ServerError"),
199        }
200    }
201
202    #[tokio::test]
203    async fn test_delete_seeder_sends_correct_request() {
204        let mock_server = MockServer::start().await;
205
206        Mock::given(method("DELETE"))
207            .and(path("/seed/test-play-id"))
208            .and(header("x-play-id", "test-play-id"))
209            .respond_with(ResponseTemplate::new(200))
210            .expect(1)
211            .mount(&mock_server)
212            .await;
213
214        let config = create_test_config(&mock_server.uri());
215        let client = PlayHttpClient::new("test-play-id".to_string(), config);
216
217        let result = client.delete_seeder("/seed/test-play-id").await;
218
219        assert!(result.is_ok());
220    }
221
222    #[tokio::test]
223    async fn test_delete_seeder_handles_server_error() {
224        let mock_server = MockServer::start().await;
225
226        Mock::given(method("DELETE"))
227            .and(path("/seed/test-id"))
228            .respond_with(ResponseTemplate::new(404).set_body_string("Not found"))
229            .mount(&mock_server)
230            .await;
231
232        let config = create_test_config(&mock_server.uri());
233        let client = PlayHttpClient::new("test-id".to_string(), config);
234
235        let result = client.delete_seeder("/seed/test-id").await;
236
237        match result {
238            Err(PlayError::Response { status, body }) => {
239                assert_eq!(status, 404);
240                assert_eq!(body, "Not found");
241            }
242            _ => panic!("Expected ServerError"),
243        }
244    }
245}