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