1use crate::{
9 AzureHttpClient, Result,
10 ops::storage::StorageOps,
11 types::storage::{
12 ManagementPolicy, StorageAccount, StorageAccountCreateRequest,
13 StorageAccountListKeysResult, StorageAccountListResult, StorageAccountRegenerateKeyRequest,
14 StorageAccountUpdateRequest,
15 },
16};
17
18pub struct StorageClient<'a> {
20 ops: StorageOps<'a>,
21}
22
23impl<'a> StorageClient<'a> {
24 pub(crate) fn new(client: &'a AzureHttpClient) -> Self {
26 Self {
27 ops: StorageOps::new(client),
28 }
29 }
30
31 pub async fn list_storage_accounts(
33 &self,
34 subscription_id: &str,
35 ) -> Result<StorageAccountListResult> {
36 self.ops.list_storage_accounts(subscription_id).await
37 }
38
39 pub async fn list_storage_accounts_by_resource_group(
41 &self,
42 subscription_id: &str,
43 resource_group_name: &str,
44 ) -> Result<StorageAccountListResult> {
45 self.ops
46 .list_storage_accounts_by_resource_group(subscription_id, resource_group_name)
47 .await
48 }
49
50 pub async fn get_storage_account(
52 &self,
53 subscription_id: &str,
54 resource_group_name: &str,
55 account_name: &str,
56 ) -> Result<StorageAccount> {
57 self.ops
58 .get_storage_account(subscription_id, resource_group_name, account_name)
59 .await
60 }
61
62 pub async fn create_storage_account(
68 &self,
69 subscription_id: &str,
70 resource_group_name: &str,
71 account_name: &str,
72 body: &StorageAccountCreateRequest,
73 ) -> Result<StorageAccount> {
74 let result = self
75 .ops
76 .create_storage_account(subscription_id, resource_group_name, account_name, body)
77 .await;
78
79 match result {
80 Ok(account) => Ok(account),
81 Err(crate::AzureError::InvalidResponse { message, .. })
83 if message.contains("EOF while parsing") || message.contains("empty") =>
84 {
85 self.poll_until_provisioned(subscription_id, resource_group_name, account_name)
86 .await
87 }
88 Err(e) => Err(e),
89 }
90 }
91
92 async fn poll_until_provisioned(
94 &self,
95 subscription_id: &str,
96 resource_group_name: &str,
97 account_name: &str,
98 ) -> Result<StorageAccount> {
99 for attempt in 1..=60 {
100 tokio::time::sleep(std::time::Duration::from_secs(5)).await;
101 let account = self
102 .ops
103 .get_storage_account(subscription_id, resource_group_name, account_name)
104 .await?;
105 let state = account
106 .properties
107 .as_ref()
108 .and_then(|p| p.get("provisioningState"))
109 .and_then(|v| v.as_str())
110 .unwrap_or("");
111 if state == "Succeeded" {
112 return Ok(account);
113 }
114 if attempt % 3 == 0 {
115 eprintln!(" Polling create_storage_account: attempt {attempt}, state={state}");
116 }
117 }
118 Err(crate::AzureError::InvalidResponse {
119 message: format!(
120 "Storage account '{account_name}' did not reach Succeeded state after polling"
121 ),
122 body: None,
123 })
124 }
125
126 pub async fn delete_storage_account(
128 &self,
129 subscription_id: &str,
130 resource_group_name: &str,
131 account_name: &str,
132 ) -> Result<StorageAccount> {
133 self.ops
134 .delete_storage_account(subscription_id, resource_group_name, account_name)
135 .await
136 }
137
138 pub async fn list_keys(
140 &self,
141 subscription_id: &str,
142 resource_group_name: &str,
143 account_name: &str,
144 ) -> Result<StorageAccountListKeysResult> {
145 self.ops
146 .list_keys(subscription_id, resource_group_name, account_name)
147 .await
148 }
149
150 pub async fn regenerate_key(
152 &self,
153 subscription_id: &str,
154 resource_group_name: &str,
155 account_name: &str,
156 body: &StorageAccountRegenerateKeyRequest,
157 ) -> Result<StorageAccountListKeysResult> {
158 self.ops
159 .regenerate_key(subscription_id, resource_group_name, account_name, body)
160 .await
161 }
162
163 pub async fn update_storage_account(
170 &self,
171 subscription_id: &str,
172 resource_group_name: &str,
173 account_name: &str,
174 body: &StorageAccountUpdateRequest,
175 ) -> Result<StorageAccount> {
176 self.ops
177 .patch_storage_account(subscription_id, resource_group_name, account_name, body)
178 .await
179 }
180
181 pub async fn get_management_policy(
187 &self,
188 subscription_id: &str,
189 resource_group_name: &str,
190 account_name: &str,
191 ) -> Result<Option<ManagementPolicy>> {
192 match self
193 .ops
194 .get_management_policy(subscription_id, resource_group_name, account_name)
195 .await
196 {
197 Ok(policy) => Ok(Some(policy)),
198 Err(crate::AzureError::NotFound { .. }) => Ok(None),
199 Err(e) => Err(e),
200 }
201 }
202}
203
204#[cfg(test)]
205mod tests {
206 use super::*;
207 use crate::AzureHttpClient;
208 use crate::types::storage::StorageAccountUpdateProperties;
209
210 const SUB: &str = "test-subscription-id";
211 const RG: &str = "my-rg";
212
213 #[tokio::test]
214 async fn list_storage_accounts_returns_empty_list() {
215 let mut mock = crate::MockClient::new();
216 mock.expect_get(
217 "/subscriptions/test-subscription-id/providers/Microsoft.Storage/storageAccounts",
218 )
219 .returning_json(serde_json::json!({ "value": [] }));
220
221 let client = AzureHttpClient::from_mock(mock);
222 let storage = client.storage();
223 let result = storage
224 .list_storage_accounts(SUB)
225 .await
226 .expect("list failed");
227 assert_eq!(result.value.len(), 0);
228 }
229
230 #[tokio::test]
231 async fn list_storage_accounts_returns_accounts_with_fields() {
232 let mut mock = crate::MockClient::new();
233 mock.expect_get("/subscriptions/test-subscription-id/providers/Microsoft.Storage/storageAccounts")
234 .returning_json(serde_json::json!({
235 "value": [{
236 "id": "/subscriptions/sub/resourceGroups/my-rg/providers/Microsoft.Storage/storageAccounts/myacct",
237 "name": "myacct",
238 "type": "Microsoft.Storage/storageAccounts",
239 "location": "eastus",
240 "kind": "StorageV2",
241 "sku": { "name": "Standard_LRS", "tier": "Standard" }
242 }]
243 }));
244
245 let client = AzureHttpClient::from_mock(mock);
246 let storage = client.storage();
247 let result = storage
248 .list_storage_accounts(SUB)
249 .await
250 .expect("list failed");
251 assert_eq!(result.value.len(), 1);
252 let acct = &result.value[0];
253 assert_eq!(acct.get("name").and_then(|v| v.as_str()), Some("myacct"));
254 assert_eq!(
255 acct.get("location").and_then(|v| v.as_str()),
256 Some("eastus")
257 );
258 assert_eq!(acct.get("kind").and_then(|v| v.as_str()), Some("StorageV2"));
259 }
260
261 #[tokio::test]
262 async fn list_storage_accounts_by_resource_group_injects_rg() {
263 let mut mock = crate::MockClient::new();
264 mock.expect_get("/subscriptions/test-subscription-id/resourceGroups/my-rg/providers/Microsoft.Storage/storageAccounts")
265 .returning_json(serde_json::json!({ "value": [] }));
266
267 let client = AzureHttpClient::from_mock(mock);
268 let storage = client.storage();
269 let result = storage
270 .list_storage_accounts_by_resource_group(SUB, RG)
271 .await
272 .expect("list by rg failed");
273 assert_eq!(result.value.len(), 0);
274 }
275
276 #[tokio::test]
277 async fn get_storage_account_returns_account_fields() {
278 let mut mock = crate::MockClient::new();
279 mock.expect_get("/subscriptions/test-subscription-id/resourceGroups/my-rg/providers/Microsoft.Storage/storageAccounts/myacct")
280 .returning_json(serde_json::json!({
281 "id": "/subscriptions/sub/resourceGroups/my-rg/providers/Microsoft.Storage/storageAccounts/myacct",
282 "name": "myacct",
283 "type": "Microsoft.Storage/storageAccounts",
284 "location": "eastus",
285 "kind": "StorageV2",
286 "sku": { "name": "Standard_LRS", "tier": "Standard" },
287 "properties": {
288 "provisioningState": "Succeeded",
289 "primaryLocation": "eastus"
290 }
291 }));
292
293 let client = AzureHttpClient::from_mock(mock);
294 let storage = client.storage();
295 let account = storage
296 .get_storage_account(SUB, RG, "myacct")
297 .await
298 .expect("get failed");
299 assert_eq!(account.name.as_deref(), Some("myacct"));
300 assert_eq!(account.location, "eastus");
301 assert_eq!(account.kind.as_deref(), Some("StorageV2"));
302 let ps = account
303 .properties
304 .as_ref()
305 .and_then(|p| p.get("provisioningState"))
306 .and_then(|v| v.as_str());
307 assert_eq!(ps, Some("Succeeded"));
308 }
309
310 #[tokio::test]
311 async fn create_storage_account_sends_put_and_returns_account() {
312 let mut mock = crate::MockClient::new();
313 mock.expect_put("/subscriptions/test-subscription-id/resourceGroups/my-rg/providers/Microsoft.Storage/storageAccounts/newacct")
314 .returning_json(serde_json::json!({
315 "name": "newacct",
316 "location": "eastus",
317 "kind": "StorageV2",
318 "properties": { "provisioningState": "Succeeded" }
319 }));
320
321 let client = AzureHttpClient::from_mock(mock);
322 let storage = client.storage();
323 let body = StorageAccountCreateRequest {
324 location: "eastus".into(),
325 kind: "StorageV2".into(),
326 sku: serde_json::json!({ "name": "Standard_LRS" }),
327 ..Default::default()
328 };
329 let account = storage
330 .create_storage_account(SUB, RG, "newacct", &body)
331 .await
332 .expect("create failed");
333 assert_eq!(account.name.as_deref(), Some("newacct"));
334 assert_eq!(account.location, "eastus");
335 assert_eq!(account.kind.as_deref(), Some("StorageV2"));
336 }
337
338 #[tokio::test]
339 async fn delete_storage_account_sends_delete() {
340 let mut mock = crate::MockClient::new();
341 mock.expect_delete("/subscriptions/test-subscription-id/resourceGroups/my-rg/providers/Microsoft.Storage/storageAccounts/myacct")
342 .returning_json(serde_json::json!({
343 "name": "myacct",
344 "location": "eastus"
345 }));
346
347 let client = AzureHttpClient::from_mock(mock);
348 let storage = client.storage();
349 let result = storage
350 .delete_storage_account(SUB, RG, "myacct")
351 .await
352 .expect("delete failed");
353 assert_eq!(result.name.as_deref(), Some("myacct"));
354 }
355
356 #[tokio::test]
357 async fn list_keys_returns_two_keys() {
358 let mut mock = crate::MockClient::new();
359 mock.expect_post("/subscriptions/test-subscription-id/resourceGroups/my-rg/providers/Microsoft.Storage/storageAccounts/myacct/listKeys")
360 .returning_json(serde_json::json!({
361 "keys": [
362 { "keyName": "key1", "permissions": "Full", "value": "base64val1==" },
363 { "keyName": "key2", "permissions": "Full", "value": "base64val2==" }
364 ]
365 }));
366
367 let client = AzureHttpClient::from_mock(mock);
368 let storage = client.storage();
369 let result = storage
370 .list_keys(SUB, RG, "myacct")
371 .await
372 .expect("list_keys failed");
373 assert_eq!(result.keys.len(), 2);
374 assert_eq!(
375 result.keys[0].get("keyName").and_then(|v| v.as_str()),
376 Some("key1")
377 );
378 assert_eq!(
379 result.keys[1].get("keyName").and_then(|v| v.as_str()),
380 Some("key2")
381 );
382 }
383
384 #[tokio::test]
385 async fn regenerate_key_sends_post_with_key_name() {
386 let mut mock = crate::MockClient::new();
387 mock.expect_post("/subscriptions/test-subscription-id/resourceGroups/my-rg/providers/Microsoft.Storage/storageAccounts/myacct/regenerateKey")
388 .returning_json(serde_json::json!({
389 "keys": [
390 { "keyName": "key1", "permissions": "Full", "value": "newbase64val1==" },
391 { "keyName": "key2", "permissions": "Full", "value": "base64val2==" }
392 ]
393 }));
394
395 let client = AzureHttpClient::from_mock(mock);
396 let storage = client.storage();
397 let body = StorageAccountRegenerateKeyRequest {
398 key_name: "key1".into(),
399 };
400 let result = storage
401 .regenerate_key(SUB, RG, "myacct", &body)
402 .await
403 .expect("regenerate_key failed");
404 assert_eq!(result.keys.len(), 2);
405 assert_eq!(
406 result.keys[0].get("keyName").and_then(|v| v.as_str()),
407 Some("key1")
408 );
409 assert_eq!(
410 result.keys[0].get("value").and_then(|v| v.as_str()),
411 Some("newbase64val1==")
412 );
413 }
414
415 #[tokio::test]
416 async fn update_storage_account_sends_patch_and_returns_account() {
417 let mut mock = crate::MockClient::new();
418 mock.expect_patch("/subscriptions/test-subscription-id/resourceGroups/my-rg/providers/Microsoft.Storage/storageAccounts/myacct")
419 .returning_json(serde_json::json!({
420 "name": "myacct",
421 "location": "eastus",
422 "kind": "StorageV2",
423 "properties": {
424 "provisioningState": "Succeeded",
425 "supportsHttpsTrafficOnly": true,
426 "minimumTlsVersion": "TLS1_2",
427 "allowBlobPublicAccess": false
428 }
429 }));
430
431 let client = AzureHttpClient::from_mock(mock);
432 let storage = client.storage();
433 let body = StorageAccountUpdateRequest {
434 properties: Some(StorageAccountUpdateProperties {
435 allow_blob_public_access: Some(false),
436 supports_https_traffic_only: Some(true),
437 minimum_tls_version: Some("TLS1_2".into()),
438 }),
439 ..Default::default()
440 };
441 let account = storage
442 .update_storage_account(SUB, RG, "myacct", &body)
443 .await
444 .expect("update_storage_account failed");
445 assert_eq!(account.name.as_deref(), Some("myacct"));
446 assert_eq!(account.location, "eastus");
447 let props = account.properties.as_ref().expect("properties missing");
448 assert_eq!(
449 props
450 .get("supportsHttpsTrafficOnly")
451 .and_then(|v| v.as_bool()),
452 Some(true)
453 );
454 assert_eq!(
455 props.get("minimumTlsVersion").and_then(|v| v.as_str()),
456 Some("TLS1_2")
457 );
458 assert_eq!(
459 props.get("allowBlobPublicAccess").and_then(|v| v.as_bool()),
460 Some(false)
461 );
462 }
463
464 #[tokio::test]
465 async fn update_storage_account_partial_body_omits_unset_fields() {
466 let body = StorageAccountUpdateRequest {
468 properties: Some(StorageAccountUpdateProperties {
469 supports_https_traffic_only: Some(true),
470 ..Default::default()
471 }),
472 ..Default::default()
473 };
474 let serialized = serde_json::to_value(&body).expect("serialize failed");
475 let props = serialized
478 .get("properties")
479 .expect("properties key missing");
480 assert!(props.get("supportsHttpsTrafficOnly").is_some());
481 assert!(
482 props.get("allowBlobPublicAccess").is_none(),
483 "unset field must be omitted"
484 );
485 assert!(
486 props.get("minimumTlsVersion").is_none(),
487 "unset field must be omitted"
488 );
489 }
490
491 #[tokio::test]
492 async fn get_management_policy_returns_some_with_rules() {
493 let mut mock = crate::MockClient::new();
494 mock.expect_get("/subscriptions/test-subscription-id/resourceGroups/my-rg/providers/Microsoft.Storage/storageAccounts/myacct/managementPolicies/default")
495 .returning_json(serde_json::json!({
496 "id": "/subscriptions/sub/resourceGroups/my-rg/providers/Microsoft.Storage/storageAccounts/myacct/managementPolicies/default",
497 "name": "DefaultManagementPolicy",
498 "type": "Microsoft.Storage/storageAccounts/managementPolicies",
499 "properties": {
500 "lastModifiedTime": "2024-01-15T10:30:00Z",
501 "policy": {
502 "rules": [
503 { "enabled": true, "name": "move-to-cool", "type": "Lifecycle" },
504 { "enabled": false, "name": "delete-old-blobs", "type": "Lifecycle" }
505 ]
506 }
507 }
508 }));
509
510 let client = AzureHttpClient::from_mock(mock);
511 let storage = client.storage();
512 let result = storage
513 .get_management_policy(SUB, RG, "myacct")
514 .await
515 .expect("get_management_policy failed");
516 let policy = result.expect("expected Some(policy)");
517 assert_eq!(policy.name.as_deref(), Some("DefaultManagementPolicy"));
518
519 let props = policy.properties.expect("properties missing");
520 assert_eq!(
521 props.last_modified_time.as_deref(),
522 Some("2024-01-15T10:30:00Z")
523 );
524
525 let schema = props.policy.expect("policy schema missing");
526 assert_eq!(schema.rules.len(), 2);
527 assert_eq!(schema.rules[0].name, "move-to-cool");
528 assert!(schema.rules[0].enabled);
529 assert_eq!(schema.rules[1].name, "delete-old-blobs");
530 assert!(!schema.rules[1].enabled);
531 }
532
533 #[tokio::test]
534 async fn get_management_policy_returns_none_for_404() {
535 let mut mock = crate::MockClient::new();
538 mock.expect_get("/subscriptions/test-subscription-id/resourceGroups/my-rg/providers/Microsoft.Storage/storageAccounts/myacct/managementPolicies/default")
539 .returning_error(crate::AzureError::NotFound {
540 resource: "ManagementPolicy not found".into(),
541 });
542
543 let client = AzureHttpClient::from_mock(mock);
544 let storage = client.storage();
545 let result = storage.get_management_policy(SUB, RG, "myacct").await;
546 assert!(result.is_ok(), "NotFound should become Ok(None), not Err");
547 assert!(result.unwrap().is_none(), "should return None for 404");
548 }
549
550 #[tokio::test]
551 async fn get_management_policy_propagates_auth_error() {
552 let mut mock = crate::MockClient::new();
553 mock.expect_get("/subscriptions/test-subscription-id/resourceGroups/my-rg/providers/Microsoft.Storage/storageAccounts/myacct/managementPolicies/default")
554 .returning_error(crate::AzureError::Auth {
555 message: "Token expired".into(),
556 });
557
558 let client = AzureHttpClient::from_mock(mock);
559 let storage = client.storage();
560 let result = storage.get_management_policy(SUB, RG, "myacct").await;
561 assert!(result.is_err(), "Auth errors must propagate as Err");
562 assert!(matches!(
563 result.unwrap_err(),
564 crate::AzureError::Auth { .. }
565 ));
566 }
567}