Skip to main content

ferro_storage/cdn/
mod.rs

1//! CDN cache-invalidation primitives for ferro-storage.
2//!
3//! This module provides the [`PurgeApi`] trait abstracting CDN cache invalidation, and the
4//! batteries-included [`DoSpacesCdn`] adapter for DigitalOcean Spaces CDN.
5//!
6//! # Relative paths
7//!
8//! The [`PurgeApi`] trait uses relative paths (e.g. `"index.html"`, `"assets/*"`).
9//! Implementations handle batching and rate limiting internally. Do NOT pass full CDN
10//! URLs — paths are relative to the CDN endpoint's origin.
11//!
12//! # DigitalOcean Spaces CDN adapter
13//!
14//! [`DoSpacesCdn`] encapsulates the operationally fiddly parts of the DO CDN purge API:
15//! - Batching: ≤50 files per `DELETE /v2/cdn/endpoints/{id}/cache` request.
16//! - Rate limiting: an internal sliding-window throttle enforces ≤5 requests per 10 s.
17//! - Wildcard slot accounting: a wildcard path (e.g. `"dir/*"`) counts as 1 file slot.
18//! - Missing endpoint id: `purge()` is a logged no-op returning `Ok(())` (no HTTP request).
19//!
20//! # Security
21//!
22//! The `DIGITALOCEAN_ACCESS_TOKEN` is never logged or printed. [`DoSpacesCdnConfig`]
23//! implements a hand-written `Debug` that redacts the token field.
24
25use crate::Error;
26use async_trait::async_trait;
27use std::collections::VecDeque;
28use std::time::Duration;
29use tokio::sync::Mutex;
30use tokio::time::Instant;
31
32const DO_CDN_API_BASE: &str = "https://api.digitalocean.com";
33const BATCH_SIZE: usize = 50;
34const RATE_LIMIT_WINDOW: Duration = Duration::from_secs(10);
35const RATE_LIMIT_MAX: usize = 5;
36
37/// Cache invalidation abstraction for CDN backends.
38///
39/// Paths are relative (e.g. `"index.html"`, `"assets/*"`); implementations handle
40/// batching and rate limiting internally.
41#[async_trait]
42pub trait PurgeApi: Send + Sync {
43    /// Purge cached content at the given relative paths.
44    ///
45    /// - An empty slice returns `Ok(())` with zero HTTP requests.
46    /// - Implementations handle batching, rate limiting, and wildcard slots internally.
47    async fn purge(&self, paths: &[String]) -> Result<(), Error>;
48}
49
50/// Configuration for the DigitalOcean Spaces CDN adapter.
51///
52/// Read from environment via [`DoSpacesCdnConfig::from_env()`].
53///
54/// # Token security
55///
56/// `api_token` is never logged. The `Debug` implementation prints `<redacted>` for this field.
57#[derive(Clone)]
58pub struct DoSpacesCdnConfig {
59    /// DO CDN endpoint id (`DO_SPACES_CDN_ID`). `None` → `purge()` is a logged no-op.
60    pub endpoint_id: Option<String>,
61    /// DO API token (`DIGITALOCEAN_ACCESS_TOKEN`). Never logged.
62    pub api_token: String,
63    /// API base URL override for tests only. Production uses the DO API base constant.
64    pub(crate) api_base: Option<String>,
65}
66
67impl std::fmt::Debug for DoSpacesCdnConfig {
68    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
69        f.debug_struct("DoSpacesCdnConfig")
70            .field("endpoint_id", &self.endpoint_id)
71            .field("api_token", &"<redacted>")
72            .field("api_base", &self.api_base)
73            .finish()
74    }
75}
76
77impl DoSpacesCdnConfig {
78    /// Read config from environment.
79    ///
80    /// - `DO_SPACES_CDN_ID` — optional. When absent, `purge()` is a logged no-op.
81    /// - `DIGITALOCEAN_ACCESS_TOKEN` — required when endpoint id is set; otherwise unused.
82    pub fn from_env() -> Self {
83        Self {
84            endpoint_id: std::env::var("DO_SPACES_CDN_ID").ok(),
85            api_token: std::env::var("DIGITALOCEAN_ACCESS_TOKEN").unwrap_or_default(),
86            api_base: None,
87        }
88    }
89}
90
91/// DigitalOcean Spaces CDN adapter implementing [`PurgeApi`].
92///
93/// Encapsulates the DO CDN purge API: `DELETE /v2/cdn/endpoints/{id}/cache` with
94/// `{"files": [...]}` body, ≤50-file batching, and a 5-req/10s internal throttle.
95pub struct DoSpacesCdn {
96    config: DoSpacesCdnConfig,
97    client: reqwest::Client,
98    request_times: Mutex<VecDeque<Instant>>,
99}
100
101impl DoSpacesCdn {
102    /// Construct from config. Builds a single `reqwest::Client` shared across all requests.
103    pub fn new(config: DoSpacesCdnConfig) -> Self {
104        Self {
105            config,
106            client: reqwest::Client::new(),
107            request_times: Mutex::new(VecDeque::new()),
108        }
109    }
110
111    fn api_base(&self) -> &str {
112        self.config.api_base.as_deref().unwrap_or(DO_CDN_API_BASE)
113    }
114
115    /// Sliding-window rate limiter: ensures ≤ RATE_LIMIT_MAX requests per RATE_LIMIT_WINDOW.
116    ///
117    /// Loops until a free slot is confirmed while holding the lock, so concurrent callers
118    /// cannot both observe `len < RATE_LIMIT_MAX` and both proceed. The lock is never held
119    /// across the `.await` sleep to avoid deadlocking other waiters.
120    async fn throttle(&self) {
121        loop {
122            let mut times = self.request_times.lock().await;
123            let now = Instant::now();
124            // Evict entries older than the window.
125            while times
126                .front()
127                .map(|t| now.duration_since(*t) >= RATE_LIMIT_WINDOW)
128                .unwrap_or(false)
129            {
130                times.pop_front();
131            }
132            if times.len() < RATE_LIMIT_MAX {
133                // Slot available — record this request and proceed.
134                times.push_back(Instant::now());
135                return;
136            }
137            // Window full: compute sleep duration, drop the lock, then re-check.
138            let oldest = *times.front().unwrap();
139            let sleep_for = RATE_LIMIT_WINDOW - now.duration_since(oldest);
140            drop(times);
141            tokio::time::sleep(sleep_for).await;
142            // Loop back to re-acquire and re-check under the lock.
143        }
144    }
145}
146
147#[async_trait]
148impl PurgeApi for DoSpacesCdn {
149    async fn purge(&self, paths: &[String]) -> Result<(), Error> {
150        if paths.is_empty() {
151            return Ok(());
152        }
153        let Some(id) = &self.config.endpoint_id else {
154            tracing::info!("DO_SPACES_CDN_ID not set — CDN purge is a no-op");
155            return Ok(());
156        };
157        if self.config.api_token.is_empty() {
158            return Err(Error::cdn(
159                "DIGITALOCEAN_ACCESS_TOKEN not set — cannot purge CDN cache",
160            ));
161        }
162        let url = format!("{}/v2/cdn/endpoints/{}/cache", self.api_base(), id);
163        let mut batches = 0usize;
164        for chunk in paths.chunks(BATCH_SIZE) {
165            self.throttle().await;
166            let resp = self
167                .client
168                .delete(&url)
169                .bearer_auth(&self.config.api_token)
170                .json(&serde_json::json!({ "files": chunk }))
171                .send()
172                .await
173                .map_err(|e| Error::cdn(e.to_string()))?;
174            if resp.status().as_u16() != 204 {
175                let status = resp.status().as_u16();
176                let body = resp.text().await.unwrap_or_default();
177                return Err(Error::cdn(format!("DO CDN purge status {status}: {body}")));
178            }
179            batches += 1;
180        }
181        tracing::info!("purged {} paths in {} request(s)", paths.len(), batches);
182        Ok(())
183    }
184}
185
186/// CDN provider selected for cache invalidation (`CDN_PROVIDER`).
187#[derive(Debug, Clone, PartialEq, Eq, Default)]
188pub enum CdnProvider {
189    /// No purge provider — `purge()` is an explicit logged no-op.
190    #[default]
191    None,
192    /// DigitalOcean Spaces CDN (always available — no cargo feature).
193    DigitalOcean,
194    /// Bunny CDN (requires the `cdn-bunny` feature).
195    Bunny,
196    /// Cloudflare (requires the `cdn-cloudflare` feature).
197    Cloudflare,
198}
199
200impl CdnProvider {
201    /// Parse case-insensitively; unknown value → `Error::CdnInvalidProvider`.
202    pub fn from_str_ci(s: &str) -> Result<Self, Error> {
203        match s.trim().to_ascii_lowercase().as_str() {
204            "none" => Ok(Self::None),
205            "digitalocean" => Ok(Self::DigitalOcean),
206            "bunny" => Ok(Self::Bunny),
207            "cloudflare" => Ok(Self::Cloudflare),
208            other => Err(Error::cdn_invalid_provider(other)),
209        }
210    }
211}
212
213/// Read `primary`; if unset, try each `alias` in order, emitting one
214/// `tracing::warn!` naming the deprecated var (never its value) on first hit.
215fn env_with_fallback(primary: &str, aliases: &[&str]) -> Option<String> {
216    if let Ok(val) = std::env::var(primary) {
217        return Some(val);
218    }
219    for alias in aliases {
220        if let Ok(val) = std::env::var(alias) {
221            tracing::warn!("{alias} is deprecated; use {primary} instead");
222            return Some(val);
223        }
224    }
225    None
226}
227
228/// Provider-agnostic CDN configuration. Read via [`Config::from_env`];
229/// build the active purge adapter with [`Config::build_purge_api`].
230///
231/// # Token security
232/// `purge_token` is never logged. `Debug` prints `<redacted>` for it.
233#[derive(Clone, Default)]
234pub struct Config {
235    /// CDN base URL fronting the bucket (`CDN_URL`). Drives `Disk::cdn_url()`.
236    pub url: Option<String>,
237    /// Selected provider for cache invalidation (`CDN_PROVIDER`).
238    pub provider: CdnProvider,
239    /// Provider API credential (`CDN_PURGE_TOKEN`). Never logged.
240    pub purge_token: Option<String>,
241    /// Provider-specific zone or endpoint id (`CDN_PURGE_ZONE`).
242    pub purge_zone: Option<String>,
243    /// Set when `CDN_PROVIDER` is explicitly provided but fails to parse.
244    /// `build_purge_api` converts this into a boot `Error` (D-03 / SC-5b).
245    pub provider_error: Option<String>,
246}
247
248impl std::fmt::Debug for Config {
249    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
250        f.debug_struct("cdn::Config")
251            .field("url", &self.url)
252            .field("provider", &self.provider)
253            .field("purge_token", &"<redacted>")
254            .field("purge_zone", &self.purge_zone)
255            .field("provider_error", &self.provider_error)
256            .finish()
257    }
258}
259
260impl Config {
261    /// Read CDN config from environment: the quartet primary + per-var legacy fallbacks.
262    ///
263    /// Provider resolution runs first; token/zone aliases are then scoped to the resolved
264    /// provider so that a stray credential from a different provider's legacy cluster cannot
265    /// win the fallback race.
266    ///
267    /// If `CDN_PROVIDER` is set to an unrecognized value the struct is returned with
268    /// `provider_error` set; the parse failure is surfaced as a boot `Error` by
269    /// [`Config::build_purge_api`].
270    pub fn from_env() -> Self {
271        // 1. Resolve the CDN display URL (provider-independent).
272        let url = env_with_fallback("CDN_URL", &["AWS_CDN_URL", "CF_CDN_URL", "BUNNY_CDN_URL"]);
273
274        // 2. Resolve provider first so token/zone aliases can be scoped to it.
275        let mut provider_error: Option<String> = None;
276        let provider = if let Ok(val) = std::env::var("CDN_PROVIDER") {
277            match CdnProvider::from_str_ci(&val) {
278                Ok(p) => p,
279                Err(e) => {
280                    tracing::error!("{e}; set CDN_PROVIDER to a valid value to enable purging");
281                    provider_error = Some(val);
282                    CdnProvider::None
283                }
284            }
285        } else if std::env::var("DO_SPACES_CDN_ID").is_ok() {
286            tracing::warn!("CDN_PROVIDER unset; inferred digitalocean from DO_SPACES_CDN_ID. Set CDN_PROVIDER=digitalocean to silence.");
287            CdnProvider::DigitalOcean
288        } else if std::env::var("CF_ZONE_ID").is_ok() {
289            tracing::warn!("CDN_PROVIDER unset; inferred cloudflare from CF_ZONE_ID. Set CDN_PROVIDER=cloudflare to silence.");
290            CdnProvider::Cloudflare
291        } else if std::env::var("BUNNY_CDN_URL").is_ok()
292            || std::env::var("BUNNY_ACCESS_KEY").is_ok()
293        {
294            tracing::warn!("CDN_PROVIDER unset; inferred bunny from BUNNY_CDN_URL/BUNNY_ACCESS_KEY. Set CDN_PROVIDER=bunny to silence.");
295            CdnProvider::Bunny
296        } else {
297            CdnProvider::None
298        };
299
300        // 3. Resolve token/zone using only the aliases that belong to the resolved provider,
301        //    preventing cross-provider credential contamination in mixed legacy deployments.
302        let (purge_token, purge_zone) = match &provider {
303            CdnProvider::DigitalOcean => (
304                env_with_fallback("CDN_PURGE_TOKEN", &["DIGITALOCEAN_ACCESS_TOKEN"]),
305                env_with_fallback("CDN_PURGE_ZONE", &["DO_SPACES_CDN_ID"]),
306            ),
307            CdnProvider::Cloudflare => (
308                env_with_fallback("CDN_PURGE_TOKEN", &["CF_API_TOKEN"]),
309                env_with_fallback("CDN_PURGE_ZONE", &["CF_ZONE_ID"]),
310            ),
311            CdnProvider::Bunny => (
312                env_with_fallback("CDN_PURGE_TOKEN", &["BUNNY_ACCESS_KEY"]),
313                None,
314            ),
315            CdnProvider::None => (
316                env_with_fallback("CDN_PURGE_TOKEN", &[]),
317                env_with_fallback("CDN_PURGE_ZONE", &[]),
318            ),
319        };
320
321        Self {
322            url,
323            provider,
324            purge_token,
325            purge_zone,
326            provider_error,
327        }
328    }
329
330    /// Build the active purge adapter, or `Ok(None)` for provider `None`.
331    ///
332    /// Returns `Err(CdnInvalidProvider)` when `CDN_PROVIDER` was set to an unrecognized
333    /// value during `from_env` (D-03 boot error, SC-5b).
334    /// Returns `Err(CdnFeatureRequired)` if the selected provider's cargo feature is off.
335    pub fn build_purge_api(&self) -> Result<Option<Box<dyn PurgeApi>>, Error> {
336        // Surface an invalid CDN_PROVIDER value as a boot error (D-03 / SC-5b).
337        if let Some(bad) = &self.provider_error {
338            return Err(Error::cdn_invalid_provider(bad));
339        }
340
341        match &self.provider {
342            CdnProvider::None => {
343                tracing::info!("CDN_PROVIDER=none — purge is a no-op");
344                Ok(None)
345            }
346            CdnProvider::DigitalOcean => {
347                let cfg = DoSpacesCdnConfig {
348                    endpoint_id: self.purge_zone.clone(),
349                    api_token: self.purge_token.clone().unwrap_or_default(),
350                    api_base: None,
351                };
352                Ok(Some(Box::new(DoSpacesCdn::new(cfg))))
353            }
354            CdnProvider::Bunny => {
355                #[cfg(feature = "cdn-bunny")]
356                {
357                    let cfg = BunnyCdnConfig {
358                        cdn_base_url: self.url.clone().unwrap_or_default(),
359                        access_key: self.purge_token.clone().unwrap_or_default(),
360                    };
361                    Ok(Some(Box::new(BunnyCdn::new(cfg))))
362                }
363                #[cfg(not(feature = "cdn-bunny"))]
364                {
365                    Err(Error::cdn_feature_required("bunny", "cdn-bunny"))
366                }
367            }
368            CdnProvider::Cloudflare => {
369                #[cfg(feature = "cdn-cloudflare")]
370                {
371                    let cfg = CloudflareCdnConfig {
372                        zone_id: self.purge_zone.clone().unwrap_or_default(),
373                        api_token: self.purge_token.clone().unwrap_or_default(),
374                        cdn_base_url: self.url.clone().unwrap_or_default(),
375                    };
376                    Ok(Some(Box::new(CloudflareCdn::new(cfg))))
377                }
378                #[cfg(not(feature = "cdn-cloudflare"))]
379                {
380                    Err(Error::cdn_feature_required("cloudflare", "cdn-cloudflare"))
381                }
382            }
383        }
384    }
385}
386
387#[cfg(feature = "cdn-bunny")]
388pub mod bunny;
389#[cfg(feature = "cdn-bunny")]
390pub use bunny::{BunnyCdn, BunnyCdnConfig};
391
392#[cfg(feature = "cdn-cloudflare")]
393pub mod cloudflare;
394#[cfg(feature = "cdn-cloudflare")]
395pub use cloudflare::{CloudflareCdn, CloudflareCdnConfig};
396
397#[cfg(test)]
398mod tests {
399    use super::*;
400    use wiremock::matchers::{body_json, header, method, path_regex};
401    use wiremock::{Mock, MockServer, ResponseTemplate};
402
403    /// Build a DoSpacesCdnConfig pointing at the wiremock server URI.
404    fn cfg(server_uri: &str, id: Option<&str>, token: &str) -> DoSpacesCdnConfig {
405        DoSpacesCdnConfig {
406            endpoint_id: id.map(String::from),
407            api_token: token.to_string(),
408            api_base: Some(server_uri.to_string()),
409        }
410    }
411
412    // --- Structural / no-HTTP tests ---
413
414    #[test]
415    fn debug_does_not_contain_token() {
416        let config = DoSpacesCdnConfig {
417            endpoint_id: Some("ep-123".into()),
418            api_token: "secret-token-abc".into(),
419            api_base: None,
420        };
421        let dbg = format!("{config:?}");
422        assert!(
423            !dbg.contains("secret-token-abc"),
424            "Debug output must not contain the token: {dbg}"
425        );
426        assert!(
427            dbg.contains("<redacted>"),
428            "Debug output must show <redacted>: {dbg}"
429        );
430    }
431
432    // --- wiremock-backed tests ---
433
434    /// 1 path → exactly 1 DELETE to /v2/cdn/endpoints/test-id/cache, correct auth, correct body.
435    #[tokio::test]
436    async fn do_adapter_request_shape() {
437        let server = MockServer::start().await;
438        Mock::given(method("DELETE"))
439            .and(path_regex(r"/v2/cdn/endpoints/test-id/cache"))
440            .and(header("Authorization", "Bearer test-token"))
441            .and(body_json(serde_json::json!({ "files": ["index.html"] })))
442            .respond_with(ResponseTemplate::new(204))
443            .expect(1)
444            .mount(&server)
445            .await;
446
447        let purger = DoSpacesCdn::new(cfg(&server.uri(), Some("test-id"), "test-token"));
448        purger.purge(&["index.html".to_string()]).await.unwrap();
449        // wiremock verifies .expect(1) on drop
450    }
451
452    /// 55 paths → exactly 2 DELETE requests (chunks of 50 + 5).
453    #[tokio::test]
454    async fn do_adapter_batches_over_50() {
455        let server = MockServer::start().await;
456        Mock::given(method("DELETE"))
457            .and(path_regex(r"/v2/cdn/endpoints/test-id/cache"))
458            .and(header("Authorization", "Bearer test-token"))
459            .respond_with(ResponseTemplate::new(204))
460            .expect(2)
461            .mount(&server)
462            .await;
463
464        let purger = DoSpacesCdn::new(cfg(&server.uri(), Some("test-id"), "test-token"));
465        let paths: Vec<String> = (0..55).map(|i| format!("file{i}.html")).collect();
466        purger.purge(&paths).await.unwrap();
467    }
468
469    /// 50 plain paths + 1 wildcard "dir/*" = 51 elements → 2 requests.
470    /// Wildcard counts as 1 slot, not expanded.
471    #[tokio::test]
472    async fn do_adapter_wildcard_slot() {
473        let server = MockServer::start().await;
474        Mock::given(method("DELETE"))
475            .and(path_regex(r"/v2/cdn/endpoints/test-id/cache"))
476            .and(header("Authorization", "Bearer test-token"))
477            .respond_with(ResponseTemplate::new(204))
478            .expect(2)
479            .mount(&server)
480            .await;
481
482        let purger = DoSpacesCdn::new(cfg(&server.uri(), Some("test-id"), "test-token"));
483        let mut paths: Vec<String> = (0..50).map(|i| format!("file{i}.html")).collect();
484        paths.push("dir/*".to_string()); // 51 total → 2 requests
485        purger.purge(&paths).await.unwrap();
486    }
487
488    /// Missing endpoint id → purge() returns Ok(()), zero HTTP requests.
489    #[tokio::test]
490    async fn do_adapter_noop_missing_id() {
491        let server = MockServer::start().await;
492        // Expect zero requests — wiremock will fail the test if any arrive
493        Mock::given(method("DELETE"))
494            .respond_with(ResponseTemplate::new(204))
495            .expect(0)
496            .mount(&server)
497            .await;
498
499        let purger = DoSpacesCdn::new(cfg(&server.uri(), None, "test-token"));
500        purger.purge(&["a".to_string()]).await.unwrap();
501    }
502
503    /// purge(&[]) → Ok(()), zero HTTP requests.
504    #[tokio::test]
505    async fn purge_empty_noop() {
506        let server = MockServer::start().await;
507        Mock::given(method("DELETE"))
508            .respond_with(ResponseTemplate::new(204))
509            .expect(0)
510            .mount(&server)
511            .await;
512
513        let purger = DoSpacesCdn::new(cfg(&server.uri(), Some("test-id"), "test-token"));
514        purger.purge(&[]).await.unwrap();
515    }
516
517    /// Non-204 response → Err(Error::Cdn(...)) whose message contains the status code.
518    #[tokio::test]
519    async fn do_adapter_error_on_non_204() {
520        let server = MockServer::start().await;
521        Mock::given(method("DELETE"))
522            .and(path_regex(r"/v2/cdn/endpoints/test-id/cache"))
523            .respond_with(ResponseTemplate::new(403))
524            .mount(&server)
525            .await;
526
527        let purger = DoSpacesCdn::new(cfg(&server.uri(), Some("test-id"), "test-token"));
528        let err = purger.purge(&["index.html".to_string()]).await.unwrap_err();
529        assert!(
530            err.to_string().contains("403"),
531            "Error must contain status 403: {err}"
532        );
533    }
534
535    // --- Config / CdnProvider unit tests ---
536
537    #[test]
538    fn cdn_config_debug_redacts_token() {
539        let config = Config {
540            purge_token: Some("secret-xyz".into()),
541            ..Default::default()
542        };
543        let dbg = format!("{config:?}");
544        assert!(
545            !dbg.contains("secret-xyz"),
546            "Debug output must not contain the token: {dbg}"
547        );
548        assert!(
549            dbg.contains("<redacted>"),
550            "Debug output must show <redacted>: {dbg}"
551        );
552    }
553
554    #[test]
555    fn cdn_invalid_provider() {
556        let result = CdnProvider::from_str_ci("Fastly");
557        assert!(result.is_err(), "Expected Err for unknown provider");
558        let msg = result.unwrap_err().to_string();
559        assert!(
560            msg.contains("none, digitalocean, bunny, cloudflare"),
561            "Error must list valid values: {msg}"
562        );
563    }
564
565    /// When CDN_PROVIDER is explicitly set to an invalid value, build_purge_api must return
566    /// Err listing valid values (D-03 / SC-5b boot error via the env path).
567    #[test]
568    fn cdn_invalid_provider_from_env_errors() {
569        let config = Config {
570            provider_error: Some("Fastly".into()),
571            ..Default::default()
572        };
573        let result = config.build_purge_api();
574        let err = match result {
575            Err(e) => e,
576            Ok(_) => panic!("build_purge_api must return Err when provider_error is set"),
577        };
578        let msg = err.to_string();
579        assert!(
580            msg.contains("none, digitalocean, bunny, cloudflare"),
581            "Error must list valid values: {msg}"
582        );
583    }
584
585    #[test]
586    fn cdn_provider_none_no_op() {
587        let config = Config::default();
588        assert!(
589            matches!(config.build_purge_api(), Ok(None)),
590            "provider=None must return Ok(None)"
591        );
592    }
593
594    #[test]
595    #[serial_test::serial]
596    fn cdn_config_from_env_quartet() {
597        std::env::set_var("CDN_URL", "https://q.example.com");
598        std::env::set_var("CDN_PROVIDER", "digitalocean");
599        std::env::set_var("CDN_PURGE_TOKEN", "tok");
600        std::env::set_var("CDN_PURGE_ZONE", "ep-9");
601
602        let config = Config::from_env();
603
604        std::env::remove_var("CDN_URL");
605        std::env::remove_var("CDN_PROVIDER");
606        std::env::remove_var("CDN_PURGE_TOKEN");
607        std::env::remove_var("CDN_PURGE_ZONE");
608
609        assert_eq!(config.url, Some("https://q.example.com".into()));
610        assert_eq!(config.provider, CdnProvider::DigitalOcean);
611        assert_eq!(config.purge_token, Some("tok".into()));
612        assert_eq!(config.purge_zone, Some("ep-9".into()));
613    }
614
615    #[test]
616    #[serial_test::serial]
617    fn cdn_fallback_aws_cdn_url() {
618        std::env::remove_var("CDN_URL");
619        std::env::set_var("AWS_CDN_URL", "https://legacy");
620
621        let config = Config::from_env();
622
623        std::env::remove_var("AWS_CDN_URL");
624
625        assert_eq!(config.url, Some("https://legacy".into()));
626    }
627
628    #[test]
629    #[serial_test::serial]
630    fn cdn_fallback_do_zone_and_token() {
631        std::env::remove_var("CDN_PURGE_ZONE");
632        std::env::remove_var("CDN_PURGE_TOKEN");
633        std::env::remove_var("CDN_PROVIDER");
634        std::env::set_var("DO_SPACES_CDN_ID", "ep-1");
635        std::env::set_var("DIGITALOCEAN_ACCESS_TOKEN", "t");
636
637        let config = Config::from_env();
638
639        std::env::remove_var("DO_SPACES_CDN_ID");
640        std::env::remove_var("DIGITALOCEAN_ACCESS_TOKEN");
641
642        assert_eq!(config.purge_zone, Some("ep-1".into()));
643        assert_eq!(config.purge_token, Some("t".into()));
644        assert_eq!(config.provider, CdnProvider::DigitalOcean);
645    }
646
647    #[cfg(not(feature = "cdn-bunny"))]
648    #[test]
649    fn cdn_feature_required_bunny() {
650        let config = Config {
651            provider: CdnProvider::Bunny,
652            ..Default::default()
653        };
654        let result = config.build_purge_api();
655        assert!(
656            result.is_err(),
657            "Expected Err when cdn-bunny feature is off"
658        );
659        let msg = match result {
660            Err(e) => e.to_string(),
661            Ok(_) => unreachable!(),
662        };
663        assert!(
664            msg.contains("cdn-bunny"),
665            "Error must mention the cdn-bunny feature: {msg}"
666        );
667    }
668
669    /// Empty token with id set → Err(Error::Cdn) mentioning DIGITALOCEAN_ACCESS_TOKEN.
670    /// Zero HTTP requests are made.
671    #[tokio::test]
672    async fn do_adapter_missing_token_errors() {
673        let server = MockServer::start().await;
674        Mock::given(method("DELETE"))
675            .respond_with(ResponseTemplate::new(204))
676            .expect(0)
677            .mount(&server)
678            .await;
679
680        let purger = DoSpacesCdn::new(cfg(&server.uri(), Some("test-id"), ""));
681        let err = purger.purge(&["index.html".to_string()]).await.unwrap_err();
682        assert!(
683            err.to_string().contains("DIGITALOCEAN_ACCESS_TOKEN"),
684            "Error must mention the token env var: {err}"
685        );
686    }
687
688    /// 300 paths → 6 chunks. The 6th request must wait for the rate-limit window.
689    /// Total elapsed must be >= ~9 s (RATE_LIMIT_WINDOW - 1s tolerance).
690    ///
691    /// NOTE: This test takes ~10 s due to the real rate-limit sleep. This is intentional —
692    /// it asserts that the throttle actually serializes under the 5-req/10s window without
693    /// relying on tokio::time::pause (which could give a false pass).
694    #[tokio::test]
695    async fn do_adapter_throttle_serializes() {
696        let server = MockServer::start().await;
697        Mock::given(method("DELETE"))
698            .and(path_regex(r"/v2/cdn/endpoints/test-id/cache"))
699            .and(header("Authorization", "Bearer test-token"))
700            .respond_with(ResponseTemplate::new(204))
701            .expect(6)
702            .mount(&server)
703            .await;
704
705        let purger = DoSpacesCdn::new(cfg(&server.uri(), Some("test-id"), "test-token"));
706        // 300 paths → 6 chunks of 50 — the 6th must wait for the window
707        let paths: Vec<String> = (0..300).map(|i| format!("file{i}.html")).collect();
708
709        let start = std::time::Instant::now();
710        purger.purge(&paths).await.unwrap();
711        let elapsed = start.elapsed();
712
713        assert!(
714            elapsed >= Duration::from_secs(9),
715            "Throttle should have held the 6th request for ~10s; elapsed: {elapsed:?}"
716        );
717    }
718}