bitwarden_core/client/
flags_client.rs1use std::{collections::HashMap, sync::Arc};
4
5use bitwarden_state::Setting;
6use chrono::{DateTime, Duration, Utc};
7
8use crate::{
9 Client,
10 client::{
11 flags::Flags,
12 internal::ApiConfigurations,
13 persisted_state::{FLAGS, FLAGS_FETCHED_AT},
14 },
15};
16
17const FLAGS_TTL: Duration = Duration::hours(1);
18
19#[derive(Debug, thiserror::Error)]
21pub enum FetchFlagsError {
22 #[error("failed to fetch /config: {0}")]
24 Api(#[from] bitwarden_api_api::ApiError),
25 #[error("state access error: {0}")]
27 State(#[from] bitwarden_state::SettingsError),
28}
29
30#[bitwarden_ffi::wasm_object]
32pub struct FlagsClient {
33 flags: Setting<Flags>,
34 flags_fetched_at: Setting<DateTime<Utc>>,
35 api_configurations: Arc<ApiConfigurations>,
36}
37
38impl FlagsClient {
39 pub async fn load(&self, flags: HashMap<String, bool>) {
41 let flags = Flags::load_from_map(flags);
42 if let Err(e) = self.flags.update(flags).await {
43 tracing::warn!("Failed to persist flags: {e}");
44 }
45 }
46
47 pub async fn get(&self) -> Flags {
49 match self.flags.get().await {
50 Ok(flags) => flags.unwrap_or_default(),
51 Err(e) => {
52 tracing::warn!("Failed to read flags, using defaults: {e}");
53 Flags::default()
54 }
55 }
56 }
57
58 pub async fn fetch(&self, force: bool) -> Result<(), FetchFlagsError> {
64 if !force {
65 let last: Option<DateTime<Utc>> = self.flags_fetched_at.get().await?;
66 if let Some(fetched_at) = last
67 && Utc::now().signed_duration_since(fetched_at) < FLAGS_TTL
68 {
69 return Ok(());
70 }
71 }
72
73 let config = self
74 .api_configurations
75 .api_client
76 .config_api()
77 .get_configs()
78 .await?;
79 let feature_states = config.feature_states.unwrap_or_default();
80 let bool_map = feature_states
83 .into_iter()
84 .filter_map(|(k, v)| v.as_bool().map(|b| (k, b)))
85 .collect();
86 self.load(bool_map).await;
87 self.flags_fetched_at.update(Utc::now()).await?;
88 Ok(())
89 }
90}
91
92impl Client {
93 pub fn flags(&self) -> FlagsClient {
95 let registry = &self.internal.state_registry;
96 FlagsClient {
97 flags: registry
98 .setting(FLAGS)
99 .expect("Settings repository must be registered on the state registry"),
100 flags_fetched_at: registry
101 .setting(FLAGS_FETCHED_AT)
102 .expect("Settings repository must be registered on the state registry"),
103 api_configurations: self.internal.api_configurations.clone(),
104 }
105 }
106}
107
108#[cfg(test)]
109mod tests {
110 use serde_json::json;
111 use wiremock::{
112 Mock, MockServer, ResponseTemplate,
113 matchers::{method, path},
114 };
115
116 use super::*;
117 use crate::{ClientSettings, DeviceType};
118
119 fn settings_for(server: &MockServer) -> ClientSettings {
120 ClientSettings {
121 identity_url: format!("http://{}", server.address()),
122 api_url: format!("http://{}", server.address()),
123 user_agent: "flags-tests".to_string(),
124 device_type: DeviceType::SDK,
125 device_identifier: None,
126 bitwarden_client_version: None,
127 bitwarden_package_type: None,
128 }
129 }
130
131 async fn write_fetched_at(client: &Client, at: DateTime<Utc>) {
132 client
133 .internal
134 .state_registry
135 .setting(FLAGS_FETCHED_AT)
136 .unwrap()
137 .update(at)
138 .await
139 .unwrap();
140 }
141
142 async fn read_fetched_at(client: &Client) -> Option<DateTime<Utc>> {
143 client
144 .internal
145 .state_registry
146 .setting(FLAGS_FETCHED_AT)
147 .unwrap()
148 .get()
149 .await
150 .unwrap()
151 }
152
153 #[tokio::test]
154 async fn load_round_trips_through_setting() {
155 let client = Client::new(None);
156
157 let initial = client.flags().get().await;
159 assert!(!initial.strict_cipher_decryption);
160
161 let mut map = HashMap::new();
163 map.insert("pm-34500-strict-cipher-decryption".to_string(), true);
164 client.flags().load(map).await;
165
166 let loaded = client.flags().get().await;
168 assert!(loaded.strict_cipher_decryption);
169
170 let persisted = client
172 .internal
173 .state_registry
174 .setting(FLAGS)
175 .unwrap()
176 .get()
177 .await
178 .unwrap()
179 .expect("flags should be persisted after load");
180 assert!(persisted.strict_cipher_decryption);
181 }
182
183 #[tokio::test]
184 async fn fetch_force_persists_flags_and_timestamp() {
185 let server = MockServer::start().await;
186 Mock::given(method("GET"))
187 .and(path("/config"))
188 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
189 "featureStates": { "pm-34500-strict-cipher-decryption": true }
190 })))
191 .expect(1)
192 .mount(&server)
193 .await;
194
195 let client = Client::new(Some(settings_for(&server)));
196 let before = Utc::now();
197 client.flags().fetch(true).await.unwrap();
198
199 assert!(client.flags().get().await.strict_cipher_decryption);
200 let fetched_at = read_fetched_at(&client)
201 .await
202 .expect("fetched_at must be set after a successful fetch");
203 assert!(fetched_at >= before);
204 }
205
206 #[tokio::test]
207 async fn fetch_skips_when_fresh() {
208 let server = MockServer::start().await;
209 Mock::given(method("GET"))
210 .and(path("/config"))
211 .respond_with(ResponseTemplate::new(200).set_body_json(json!({})))
212 .expect(0)
213 .mount(&server)
214 .await;
215
216 let client = Client::new(Some(settings_for(&server)));
217 write_fetched_at(&client, Utc::now() - Duration::minutes(5)).await;
218
219 client.flags().fetch(false).await.unwrap();
220 }
221
222 #[tokio::test]
223 async fn fetch_force_ignores_ttl() {
224 let server = MockServer::start().await;
225 Mock::given(method("GET"))
226 .and(path("/config"))
227 .respond_with(ResponseTemplate::new(200).set_body_json(json!({})))
228 .expect(1)
229 .mount(&server)
230 .await;
231
232 let client = Client::new(Some(settings_for(&server)));
233 write_fetched_at(&client, Utc::now() - Duration::minutes(5)).await;
234
235 client.flags().fetch(true).await.unwrap();
236 }
237
238 #[tokio::test]
239 async fn fetch_refreshes_when_stale() {
240 let server = MockServer::start().await;
241 Mock::given(method("GET"))
242 .and(path("/config"))
243 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
244 "featureStates": { "pm-34500-strict-cipher-decryption": true }
245 })))
246 .expect(1)
247 .mount(&server)
248 .await;
249
250 let client = Client::new(Some(settings_for(&server)));
251 let stale = Utc::now() - Duration::hours(2);
252 write_fetched_at(&client, stale).await;
253
254 client.flags().fetch(false).await.unwrap();
255
256 assert!(client.flags().get().await.strict_cipher_decryption);
257 let fetched_at = read_fetched_at(&client).await.unwrap();
258 assert!(fetched_at > stale);
259 }
260
261 #[tokio::test]
262 async fn fetch_network_error_is_non_fatal_and_preserves_flags() {
263 let server = MockServer::start().await;
264 Mock::given(method("GET"))
265 .and(path("/config"))
266 .respond_with(ResponseTemplate::new(500))
267 .mount(&server)
268 .await;
269
270 let client = Client::new(Some(settings_for(&server)));
271 client
272 .flags()
273 .load(HashMap::from([(
274 "pm-34500-strict-cipher-decryption".to_string(),
275 true,
276 )]))
277 .await;
278
279 assert!(client.flags().fetch(true).await.is_err());
280 assert!(
281 client.flags().get().await.strict_cipher_decryption,
282 "previously persisted flags must survive a failed fetch"
283 );
284 }
285}