Skip to main content

azure_lite_rs/api/
storage.rs

1//! Azure Storage API client.
2//!
3//! Thin wrapper over generated ops. All URL construction and HTTP methods
4//! are in `ops::storage::StorageOps`. This layer adds:
5//! - Ergonomic method signatures
6//! - Polling for async operations (CreateStorageAccount returns 202)
7
8use crate::{
9    AzureHttpClient, Result,
10    ops::storage::StorageOps,
11    types::storage::{
12        ManagementPolicy, StorageAccount, StorageAccountCreateRequest,
13        StorageAccountListKeysResult, StorageAccountListResult, StorageAccountRegenerateKeyRequest,
14        StorageAccountUpdateRequest,
15    },
16};
17
18/// Client for the Azure Storage API
19pub struct StorageClient<'a> {
20    ops: StorageOps<'a>,
21}
22
23impl<'a> StorageClient<'a> {
24    /// Create a new Azure Storage API client
25    pub(crate) fn new(client: &'a AzureHttpClient) -> Self {
26        Self {
27            ops: StorageOps::new(client),
28        }
29    }
30
31    /// Lists all storage accounts in a subscription
32    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    /// Lists all storage accounts in a resource group
40    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    /// Returns the properties of a storage account
51    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    /// Creates a new storage account.
63    ///
64    /// Azure Storage creates accounts asynchronously — this method polls
65    /// until provisioningState is "Succeeded" (or up to ~60s) when the
66    /// initial PUT returns 202 with an empty body.
67    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            // 202 Accepted returns an empty body — poll via GET until provisioned
82            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    /// Poll GET until provisioningState == "Succeeded" (max 60 attempts * 5s = 5 min).
93    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    /// Deletes a storage account
127    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    /// Lists the access keys for a storage account
139    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    /// Regenerates one of the access keys for a storage account
151    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    /// Updates properties of an existing storage account (partial PATCH).
164    ///
165    /// ARM PATCH semantics — only fields set in `body` are changed; all other
166    /// properties remain unchanged. Useful for toggling security settings such
167    /// as `allow_blob_public_access`, `supports_https_traffic_only`, and
168    /// `minimum_tls_version` without touching the rest of the account.
169    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    /// Fetch the blob lifecycle management policy for a storage account.
182    ///
183    /// Returns `Ok(None)` if no policy is configured (HTTP 404 — valid state).
184    /// Returns `Ok(Some(policy))` on HTTP 200 with the policy data.
185    /// Returns `Err(...)` on other errors (401, 403, 5xx, etc.).
186    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        // Verify that setting only one field doesn't include others in the serialized body
467        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        // Only supportsHttpsTrafficOnly should be present — allowBlobPublicAccess and
476        // minimumTlsVersion should be omitted (skip_serializing_if = "Option::is_none")
477        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        // Integration-proven: storage accounts without a lifecycle policy return 404,
536        // which becomes AzureError::NotFound. get_management_policy must return Ok(None).
537        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}