Skip to main content

hasp_backend_azure_kv/
lib.rs

1//! `azure-kv://` backend for hasp.
2//!
3//! Grammar: `azure-kv://<vault-name>/<secret-name>?version=<version>&field=<path>`
4//!   - `<vault-name>`  — Azure Key Vault name (host). Must be non-empty.
5//!   - `<secret-name>` — Path segment after the host. Must be non-empty.
6//!   - `?version`      — Optional version string. Defaults to latest (empty).
7//!   - `?field`        — Optional dotted JSON path. When set, the
8//!     stored secret value is parsed as JSON and the named scalar is
9//!     returned (see `hasp_core::extract_field`). Non-JSON payloads
10//!     fail with `InvalidUrl`.
11//!
12//! Supported operations: `get`, `put`, `list`, `delete`, `exists`.
13//!
14//! Authentication is ambient only. `azure_identity::create_credential`
15//! resolves the standard Azure credential chain (service principal env vars,
16//! managed identity, Azure CLI) transparently. No auth-bootstrap flows or
17//! token refresh logic lives in this crate.
18
19use hasp_core::{
20    Backend, BackendFailureKind, Entry, Error, ExposeSecret, ProxyConfig, SecretString,
21};
22use serde::Deserialize;
23use std::time::Duration;
24use url::Url;
25
26/// URL shape for `azure-kv://` addresses.
27///
28/// `vault_name` and `secret_name` are identifiers, not secret values.
29/// They may appear in error messages (redacted per URL discipline).
30#[derive(Debug)]
31pub struct AzureKvUrl {
32    pub vault_name: String,
33    pub secret_name: String,
34    pub version: Option<String>,
35    pub field: Option<String>,
36}
37
38impl TryFrom<&Url> for AzureKvUrl {
39    type Error = Error;
40
41    fn try_from(url: &Url) -> Result<Self, Self::Error> {
42        if url.scheme() != "azure-kv" {
43            return Err(Error::InvalidUrl("expected azure-kv:// scheme".into()));
44        }
45
46        let vault_name = url
47            .host_str()
48            .ok_or_else(|| Error::InvalidUrl("azure-kv:// requires a vault name (host)".into()))?
49            .to_owned();
50        if vault_name.is_empty() {
51            return Err(Error::InvalidUrl(
52                "azure-kv:// vault name must not be empty".into(),
53            ));
54        }
55
56        let secret_name = url.path().trim_start_matches('/').to_owned();
57
58        let mut version = None;
59        let mut field = None;
60        for (k, v) in url.query_pairs() {
61            match k.as_ref() {
62                "version" => version = Some(v.into_owned()),
63                "field" => field = Some(v.into_owned()),
64                _ => {
65                    return Err(Error::InvalidUrl(format!(
66                        "azure-kv:// unknown query parameter: {k}"
67                    )));
68                }
69            }
70        }
71
72        Ok(AzureKvUrl {
73            vault_name,
74            secret_name,
75            version,
76            field,
77        })
78    }
79}
80
81/// Azure Key Vault REST backend.
82///
83/// Construction attempts to build a Tokio runtime inside a `Result` so the
84/// async Azure credential flow can be used from the sync `Backend` trait.
85/// The runtime is `current_thread` to keep the backend lightweight. If
86/// runtime creation fails, the error is stored and replayed on first use.
87pub struct AzureKvBackend {
88    init: Result<tokio::runtime::Runtime, Error>,
89    proxy: Option<ProxyConfig>,
90}
91
92impl std::fmt::Debug for AzureKvBackend {
93    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
94        f.debug_struct("AzureKvBackend")
95            .field("init", &self.init.is_ok())
96            .field("proxy", &self.proxy.as_ref().map(|_| "[REDACTED]"))
97            .finish()
98    }
99}
100
101impl AzureKvBackend {
102    const SCHEME: &'static str = "azure-kv";
103    const API_VERSION: &'static str = "7.5";
104    const TOKEN_SCOPE: &'static str = "https://vault.azure.net/.default";
105
106    /// Create a new `AzureKvBackend`.
107    ///
108    /// Errors on construction are deferred to first use so
109    /// `Store::with_defaults()` never panics.
110    pub fn new() -> Self {
111        Self::with_proxy(None)
112    }
113
114    pub fn with_proxy(proxy: Option<ProxyConfig>) -> Self {
115        Self {
116            proxy,
117            init: tokio::runtime::Builder::new_current_thread()
118                .enable_io()
119                .enable_time()
120                .build()
121                .map_err(|e| Error::Backend {
122                    scheme: Self::SCHEME,
123                    kind: BackendFailureKind::Permanent,
124                    message: format!("failed to create tokio runtime: {e}"),
125                }),
126        }
127    }
128
129    fn runtime(&self) -> Result<&tokio::runtime::Runtime, Error> {
130        self.init.as_ref().map_err(|e| e.clone())
131    }
132
133    fn block_on<F>(&self, future: F) -> Result<F::Output, Error>
134    where
135        F: std::future::Future,
136    {
137        let rt = self.runtime()?;
138        Ok(rt.block_on(future))
139    }
140
141    /// Obtain a fresh access token via the Azure identity credential chain.
142    fn token(&self) -> Result<String, Error> {
143        self.block_on(async {
144            let credential = azure_identity::create_credential().map_err(|e| {
145                let msg = e.to_string();
146                if msg.to_lowercase().contains("credential") {
147                    Error::AuthenticationFailed(format!(
148                        "no ambient Azure credentials; set AZURE_CLIENT_ID/SECRET/TENANT_ID or log in with Azure CLI: {msg}"
149                    ))
150                } else {
151                    Error::Backend {
152                        scheme: Self::SCHEME,
153                        kind: BackendFailureKind::Permanent,
154                        message: format!("failed to discover Azure credentials: {msg}"),
155                    }
156                }
157            })?;
158
159            let access_token = credential
160                .get_token(&[Self::TOKEN_SCOPE])
161                .await
162                .map_err(|e| Error::AuthenticationFailed(format!(
163                    "failed to acquire Azure access token: {e}"
164                )))?;
165
166            let bearer = access_token.token.secret().to_string();
167            Ok(bearer)
168        })?
169    }
170
171    /// Build a `reqwest::blocking::Client` ready for Azure.
172    fn client(&self) -> reqwest::blocking::Client {
173        let mut builder = reqwest::blocking::Client::builder().timeout(Duration::from_secs(10));
174
175        if let Some(p) = &self.proxy {
176            let proxy = reqwest::Proxy::all(p.url_without_credentials())
177                .expect("reqwest proxy construction is infallible with a valid URL");
178            builder = builder.proxy(proxy);
179        }
180
181        builder
182            .build()
183            .expect("reqwest client construction is infallible with default features")
184    }
185
186    /// Build the Azure Key Vault REST URL.
187    fn build_url(&self, url: &AzureKvUrl) -> String {
188        let version_path = match &url.version {
189            Some(v) if !v.is_empty() => format!("/{v}"),
190            _ => String::new(),
191        };
192        format!(
193            "https://{}.vault.azure.net/secrets/{}{version_path}?api-version={}",
194            url.vault_name,
195            url.secret_name,
196            Self::API_VERSION,
197        )
198    }
199
200    /// Ensure the secret name extracted from the URL is non-empty.
201    fn ensure_secret_name(url: &AzureKvUrl) -> Result<(), Error> {
202        if url.secret_name.is_empty() {
203            return Err(Error::InvalidUrl(
204                "azure-kv:// secret name must not be empty".into(),
205            ));
206        }
207        Ok(())
208    }
209
210    /// Build the list URL for a given vault.
211    fn build_list_url(&self, vault_name: &str) -> String {
212        format!(
213            "https://{vault_name}.vault.azure.net/secrets?api-version={}",
214            Self::API_VERSION,
215        )
216    }
217}
218
219impl Default for AzureKvBackend {
220    fn default() -> Self {
221        Self::new()
222    }
223}
224
225impl Backend for AzureKvBackend {
226    fn scheme(&self) -> &'static str {
227        Self::SCHEME
228    }
229
230    fn validate(&self, url: &Url) -> Result<(), Error> {
231        AzureKvUrl::try_from(url).map(|_| ())
232    }
233
234    fn get(&self, url: &Url) -> Result<SecretString, Error> {
235        let kv_url = AzureKvUrl::try_from(url)?;
236        Self::ensure_secret_name(&kv_url)?;
237        let token = self.token()?;
238        let request_url = self.build_url(&kv_url);
239
240        let client = self.client();
241        let response = client
242            .get(&request_url)
243            .bearer_auth(&token)
244            .send()
245            .map_err(map_reqwest_error)?;
246
247        let status = response.status();
248        if !status.is_success() {
249            return Err(map_http_status(status, url));
250        }
251
252        let payload: SecretResponse = response.json().map_err(|e| Error::Backend {
253            scheme: Self::SCHEME,
254            kind: BackendFailureKind::Permanent,
255            message: format!("invalid JSON from Azure Key Vault: {e}"),
256        })?;
257
258        let value = payload.value.ok_or_else(|| Error::Backend {
259            scheme: Self::SCHEME,
260            kind: BackendFailureKind::Permanent,
261            message: "Azure Key Vault returned a secret without a value field".into(),
262        })?;
263
264        // Field extraction runs on the parsed JSON before wrapping in
265        // `SecretString` — the parent payload never escapes this function
266        // as a plaintext `String`.
267        let value = match &kv_url.field {
268            Some(path) => hasp_core::extract_field_from_str(&value, path)?,
269            None => value,
270        };
271        Ok(SecretString::new(value.into()))
272    }
273
274    fn put(&self, url: &Url, value: &SecretString) -> Result<(), Error> {
275        let kv_url = AzureKvUrl::try_from(url)?;
276        Self::ensure_secret_name(&kv_url)?;
277        let token = self.token()?;
278        let request_url = self.build_url(&kv_url);
279
280        let body = serde_json::json!({ "value": value.expose_secret() });
281
282        let client = self.client();
283        let response = client
284            .put(&request_url)
285            .bearer_auth(&token)
286            .json(&body)
287            .send()
288            .map_err(map_reqwest_error)?;
289
290        let status = response.status();
291        if !status.is_success() {
292            return Err(map_http_status(status, url));
293        }
294
295        Ok(())
296    }
297
298    fn list(&self, url: &Url) -> Result<Vec<Entry>, Error> {
299        let kv_url = AzureKvUrl::try_from(url)?;
300        let token = self.token()?;
301        let mut request_url = self.build_list_url(&kv_url.vault_name);
302
303        let client = self.client();
304        let mut entries = Vec::new();
305        const MAX_PAGES: usize = 500;
306
307        for _ in 0..MAX_PAGES {
308            let response = client
309                .get(&request_url)
310                .bearer_auth(&token)
311                .send()
312                .map_err(map_reqwest_error)?;
313
314            let status = response.status();
315            if !status.is_success() {
316                return Err(map_http_status(status, url));
317            }
318
319            let payload: SecretListResponse = response.json().map_err(|e| Error::Backend {
320                scheme: Self::SCHEME,
321                kind: BackendFailureKind::Permanent,
322                message: format!("invalid JSON from Azure Key Vault list: {e}"),
323            })?;
324
325            // Extract secret names from full Azure IDs. Each entry id is a URL
326            // like https://vault.vault.azure.net/secrets/name/versions/xxx.
327            // We extract the name component for the canonical hasp URL.
328            for item in payload.value.into_iter().flatten() {
329                let segments: Vec<_> = item.id.rsplit('/').collect();
330                let name = if segments.len() >= 2 {
331                    segments[1].to_owned()
332                } else {
333                    item.id.rsplit('/').next().unwrap_or(&item.id).to_owned()
334                };
335                let entry_url = Url::parse(&format!("azure-kv://{}/{name}", kv_url.vault_name))
336                    .map_err(|e| Error::Backend {
337                        scheme: Self::SCHEME,
338                        kind: BackendFailureKind::Permanent,
339                        message: format!("failed to build list entry URL: {e}"),
340                    })?;
341                entries.push(Entry {
342                    name: name.clone(),
343                    url: entry_url,
344                });
345            }
346
347            match payload.next_link {
348                Some(link) if !link.is_empty() => {
349                    request_url = link;
350                }
351                _ => break,
352            }
353        }
354
355        Ok(entries)
356    }
357
358    fn delete(&self, url: &Url) -> Result<(), Error> {
359        let kv_url = AzureKvUrl::try_from(url)?;
360        Self::ensure_secret_name(&kv_url)?;
361        let token = self.token()?;
362        let request_url = self.build_url(&kv_url);
363
364        let client = self.client();
365        let response = client
366            .delete(&request_url)
367            .bearer_auth(&token)
368            .send()
369            .map_err(map_reqwest_error)?;
370
371        match response.status() {
372            reqwest::StatusCode::ACCEPTED | reqwest::StatusCode::NO_CONTENT => Ok(()),
373            status => Err(map_http_status(status, url)),
374        }
375    }
376
377    fn exists(&self, url: &Url) -> Result<bool, Error> {
378        let kv_url = AzureKvUrl::try_from(url)?;
379        Self::ensure_secret_name(&kv_url)?;
380        let token = self.token()?;
381        let request_url = self.build_url(&kv_url);
382
383        let client = self.client();
384        let response = client
385            .get(&request_url)
386            .bearer_auth(&token)
387            .send()
388            .map_err(map_reqwest_error)?;
389
390        match response.status() {
391            reqwest::StatusCode::OK => Ok(true),
392            reqwest::StatusCode::NOT_FOUND => Ok(false),
393            status => Err(map_http_status(status, url)),
394        }
395    }
396}
397
398/// Response body from the Azure Key Vault `GetSecret` endpoint.
399///
400/// `value` is the plaintext secret string.
401#[derive(Debug, Deserialize)]
402struct SecretResponse {
403    value: Option<String>,
404}
405
406/// Response body from the Azure Key Vault `GetSecrets` (list) endpoint.
407///
408/// `value` is an array of secret identifiers. `nextLink` is the URL for
409/// the next page; it is followed transparently up to a bounded limit.
410#[derive(Debug, Deserialize)]
411struct SecretListResponse {
412    #[serde(default)]
413    value: Option<Vec<SecretListItem>>,
414    #[serde(rename = "nextLink")]
415    next_link: Option<String>,
416}
417
418/// A single secret entry in the Azure Key Vault list response.
419///
420/// `id` is the full Azure resource ID for the secret.
421#[derive(Debug, Deserialize)]
422struct SecretListItem {
423    id: String,
424}
425
426/// Map `reqwest` network errors into the locked `hasp_core::Error` taxonomy.
427fn map_reqwest_error(err: reqwest::Error) -> Error {
428    let kind = if err.is_timeout() || err.is_connect() {
429        BackendFailureKind::Transient
430    } else {
431        BackendFailureKind::Permanent
432    };
433    Error::Backend {
434        scheme: "azure-kv",
435        kind,
436        message: format!("Azure Key Vault request failed: {err}"),
437    }
438}
439
440/// Map Azure Key Vault HTTP status codes into the locked `hasp_core::Error`
441/// taxonomy.
442///
443/// Reference:
444/// <https://docs.microsoft.com/en-us/rest/api/keyvault/common-error-response>
445// TODO(#4): validate against live Azure subscription — see notes/TODO-live-error-mapping.md
446fn map_http_status(status: reqwest::StatusCode, url: &Url) -> Error {
447    match status {
448        reqwest::StatusCode::NOT_FOUND => Error::NotFound(url.to_string()),
449        reqwest::StatusCode::FORBIDDEN => {
450            Error::PermissionDenied(format!("azure-kv:// permission denied for {url}"))
451        }
452        reqwest::StatusCode::UNAUTHORIZED => {
453            Error::AuthenticationFailed(format!("azure-kv:// authentication failed for {url}"))
454        }
455        reqwest::StatusCode::TOO_MANY_REQUESTS => Error::Backend {
456            scheme: "azure-kv",
457            kind: BackendFailureKind::Throttled,
458            message: format!("Azure Key Vault throttled the request (HTTP {status})"),
459        },
460        status if status.is_server_error() => Error::Backend {
461            scheme: "azure-kv",
462            kind: BackendFailureKind::Transient,
463            message: format!("Azure Key Vault returned HTTP {status}"),
464        },
465        reqwest::StatusCode::CONFLICT => Error::PreconditionFailed(format!(
466            "azure-kv:// secret is in soft-delete recovery (HTTP {status})"
467        )),
468        status if status.as_u16() == 400 => {
469            Error::InvalidUrl(format!("azure-kv:// invalid request (HTTP {status})"))
470        }
471        _ => Error::Backend {
472            scheme: "azure-kv",
473            kind: BackendFailureKind::Permanent,
474            message: format!("Azure Key Vault returned HTTP {status}"),
475        },
476    }
477}
478
479#[cfg(test)]
480mod tests {
481    use super::*;
482
483    #[test]
484    fn parse_valid_url_simple() {
485        let url = Url::parse("azure-kv://my-vault/my-secret").unwrap();
486        let kv = AzureKvUrl::try_from(&url).unwrap();
487        assert_eq!(kv.vault_name, "my-vault");
488        assert_eq!(kv.secret_name, "my-secret");
489        assert_eq!(kv.version, None);
490    }
491
492    #[test]
493    fn parse_valid_url_with_version() {
494        let url = Url::parse("azure-kv://my-vault/my-secret?version=abc123").unwrap();
495        let kv = AzureKvUrl::try_from(&url).unwrap();
496        assert_eq!(kv.vault_name, "my-vault");
497        assert_eq!(kv.secret_name, "my-secret");
498        assert_eq!(kv.version, Some("abc123".into()));
499    }
500
501    #[test]
502    fn parse_valid_url_with_path() {
503        let url = Url::parse("azure-kv://my-vault/secrets/app/db-password").unwrap();
504        let kv = AzureKvUrl::try_from(&url).unwrap();
505        assert_eq!(kv.vault_name, "my-vault");
506        assert_eq!(kv.secret_name, "secrets/app/db-password");
507    }
508
509    #[test]
510    fn parse_missing_host_fails() {
511        let url = Url::parse("azure-kv:///my-secret").unwrap();
512        assert!(AzureKvUrl::try_from(&url).is_err());
513    }
514
515    #[test]
516    fn empty_secret_name_fails_at_api_boundary() {
517        let backend = AzureKvBackend::new();
518        let url = Url::parse("azure-kv://my-vault/").unwrap();
519        let dummy = SecretString::new("x".into());
520        assert!(
521            matches!(
522                backend.get(&url),
523                Err(Error::InvalidUrl(ref s)) if s.contains("secret name must not be empty")
524            ),
525            "empty secret name should fail at API boundary"
526        );
527        assert!(
528            matches!(
529                backend.put(&url, &dummy),
530                Err(Error::InvalidUrl(ref s)) if s.contains("secret name must not be empty")
531            ),
532            "empty secret name should fail at API boundary for put"
533        );
534        assert!(
535            matches!(
536                backend.delete(&url),
537                Err(Error::InvalidUrl(ref s)) if s.contains("secret name must not be empty")
538            ),
539            "empty secret name should fail at API boundary for delete"
540        );
541    }
542
543    #[test]
544    fn parse_unknown_query_fails() {
545        let url = Url::parse("azure-kv://my-vault/my-secret?raw=true").unwrap();
546        assert!(AzureKvUrl::try_from(&url).is_err());
547    }
548
549    #[test]
550    fn build_url_without_version() {
551        let backend = AzureKvBackend::new();
552        let kv = AzureKvUrl {
553            vault_name: "my-vault".into(),
554            secret_name: "my-secret".into(),
555            version: None,
556            field: None,
557        };
558        let url = backend.build_url(&kv);
559        assert_eq!(
560            url,
561            "https://my-vault.vault.azure.net/secrets/my-secret?api-version=7.5"
562        );
563    }
564
565    #[test]
566    fn build_url_with_version() {
567        let backend = AzureKvBackend::new();
568        let kv = AzureKvUrl {
569            vault_name: "my-vault".into(),
570            secret_name: "my-secret".into(),
571            version: Some("v1".into()),
572            field: None,
573        };
574        let url = backend.build_url(&kv);
575        assert_eq!(
576            url,
577            "https://my-vault.vault.azure.net/secrets/my-secret/v1?api-version=7.5"
578        );
579    }
580
581    #[test]
582    fn parse_valid_url_with_field() {
583        let url = Url::parse("azure-kv://my-vault/my-secret?field=.creds.password").unwrap();
584        let kv = AzureKvUrl::try_from(&url).unwrap();
585        assert_eq!(kv.field, Some(".creds.password".into()));
586        assert_eq!(kv.version, None);
587    }
588
589    #[test]
590    fn error_map_404_to_not_found() {
591        let url = Url::parse("azure-kv://my-vault/my-secret").unwrap();
592        let err = map_http_status(reqwest::StatusCode::NOT_FOUND, &url);
593        assert!(matches!(err, Error::NotFound(ref s) if s == "azure-kv://my-vault/my-secret"));
594    }
595
596    #[test]
597    fn error_map_403_to_permission_denied() {
598        let url = Url::parse("azure-kv://my-vault/my-secret").unwrap();
599        let err = map_http_status(reqwest::StatusCode::FORBIDDEN, &url);
600        assert!(matches!(err, Error::PermissionDenied(ref s) if s.contains("permission denied")));
601    }
602
603    #[test]
604    fn error_map_401_to_authentication_failed() {
605        let url = Url::parse("azure-kv://my-vault/my-secret").unwrap();
606        let err = map_http_status(reqwest::StatusCode::UNAUTHORIZED, &url);
607        assert!(
608            matches!(err, Error::AuthenticationFailed(ref s) if s.contains("authentication failed"))
609        );
610    }
611
612    #[test]
613    fn error_map_429_to_throttled() {
614        let url = Url::parse("azure-kv://my-vault/my-secret").unwrap();
615        let err = map_http_status(reqwest::StatusCode::TOO_MANY_REQUESTS, &url);
616        assert!(matches!(
617            err,
618            Error::Backend {
619                kind: BackendFailureKind::Throttled,
620                ..
621            }
622        ));
623    }
624
625    #[test]
626    fn error_map_500_to_transient() {
627        let url = Url::parse("azure-kv://my-vault/my-secret").unwrap();
628        let err = map_http_status(reqwest::StatusCode::INTERNAL_SERVER_ERROR, &url);
629        assert!(matches!(
630            err,
631            Error::Backend {
632                kind: BackendFailureKind::Transient,
633                ..
634            }
635        ));
636    }
637
638    #[test]
639    fn error_map_418_to_permanent() {
640        let url = Url::parse("azure-kv://my-vault/my-secret").unwrap();
641        let err = map_http_status(reqwest::StatusCode::IM_A_TEAPOT, &url);
642        assert!(matches!(
643            err,
644            Error::Backend {
645                kind: BackendFailureKind::Permanent,
646                ..
647            }
648        ));
649    }
650
651    #[test]
652    fn supported_operations() {
653        let backend = AzureKvBackend::new();
654        let url = Url::parse("azure-kv://my-vault/my-secret").unwrap();
655        let dummy = SecretString::new("x".into());
656
657        assert!(
658            matches!(
659                backend.put(&url, &dummy),
660                Err(Error::Backend { .. })
661                    | Err(Error::AuthenticationFailed(_))
662                    | Err(Error::NotFound(_))
663            ),
664            "put now supported (fails at network layer): {err:?}",
665            err = backend.put(&url, &dummy).unwrap_err()
666        );
667        assert!(
668            matches!(
669                backend.list(&url),
670                Err(Error::Backend { .. })
671                    | Err(Error::AuthenticationFailed(_))
672                    | Err(Error::NotFound(_))
673            ),
674            "list now supported (fails at network layer): {err:?}",
675            err = backend.list(&url).unwrap_err()
676        );
677        assert!(
678            matches!(
679                backend.delete(&url),
680                Err(Error::Backend { .. })
681                    | Err(Error::AuthenticationFailed(_))
682                    | Err(Error::NotFound(_))
683            ),
684            "delete now supported (fails at network layer): {err:?}",
685            err = backend.delete(&url).unwrap_err()
686        );
687    }
688
689    #[test]
690    fn error_map_409_to_precondition_failed() {
691        let url = Url::parse("azure-kv://my-vault/my-secret").unwrap();
692        let err = map_http_status(reqwest::StatusCode::CONFLICT, &url);
693        assert!(
694            matches!(err, Error::PreconditionFailed(ref s) if s.contains("soft-delete")),
695            "got: {err:?}"
696        );
697    }
698
699    #[test]
700    fn list_parsing_from_json() {
701        let payload: SecretListResponse = serde_json::from_str(
702            r#"{"value":[{"id":"https://my-vault.vault.azure.net/secrets/secret-a/abc123"},{"id":"https://my-vault.vault.azure.net/secrets/secret-b/def456"}]}"#
703        ).unwrap();
704
705        let items = payload.value.unwrap();
706        assert_eq!(items.len(), 2);
707        let segments: Vec<_> = items[0].id.rsplit('/').collect();
708        assert_eq!(segments[1], "secret-a");
709        let segments: Vec<_> = items[1].id.rsplit('/').collect();
710        assert_eq!(segments[1], "secret-b");
711    }
712
713    #[test]
714    fn list_parsing_with_next_link() {
715        let payload: SecretListResponse = serde_json::from_str(
716            r#"{"value":[{"id":"https://my-vault.vault.azure.net/secrets/secret-a/abc123"}],"nextLink":"https://my-vault.vault.azure.net/secrets?api-version=7.5&$skiptoken=abc"}"#
717        ).unwrap();
718
719        let items = payload.value.unwrap();
720        assert_eq!(items.len(), 1);
721        assert_eq!(
722            payload.next_link.unwrap(),
723            "https://my-vault.vault.azure.net/secrets?api-version=7.5&$skiptoken=abc"
724        );
725    }
726
727    #[test]
728    fn list_parsing_empty() {
729        let payload: SecretListResponse = serde_json::from_str(r#"{"value":[]}"#).unwrap();
730        assert_eq!(payload.value.unwrap().len(), 0);
731    }
732
733    #[test]
734    fn backend_scheme() {
735        let backend = AzureKvBackend::new();
736        assert_eq!(backend.scheme(), "azure-kv");
737    }
738
739    // Azure KV returns `value` as a string field on the SecretResponse;
740    // when `?field=` is set the get-path treats that string as a JSON
741    // payload and walks it. These tests cover the shared helper on
742    // representative Azure shapes.
743    #[test]
744    fn field_extraction_happy() {
745        let payload = r#"{"username":"app","password":"hunter2"}"#;
746        let v = hasp_core::extract_field_from_str(payload, "password").unwrap();
747        assert_eq!(v, "hunter2");
748    }
749
750    #[test]
751    fn field_extraction_missing_field_is_not_found() {
752        let payload = r#"{"username":"app"}"#;
753        let err = hasp_core::extract_field_from_str(payload, "password").unwrap_err();
754        assert!(matches!(err, Error::NotFound(_)));
755    }
756
757    #[test]
758    fn field_extraction_non_json_is_invalid_url() {
759        let payload = "plain-string-not-json";
760        let err = hasp_core::extract_field_from_str(payload, "password").unwrap_err();
761        assert!(matches!(err, Error::InvalidUrl(_)));
762    }
763}