Skip to main content

dove_core/provision/
cloudfront.rs

1//! CloudFront in front of the gate's API Gateway. It gives the gate a stable
2//! `*.cloudfront.net` domain (and, via `dove domain add`, a custom one), a place
3//! to attach caching/WAF later, and the perpetual free tier. The origin is the
4//! HTTP API — a plain public HTTPS origin, no signing needed.
5//!
6//! Moved from the `dove` CLI's `src/cloudfront.rs` — logic unchanged; terminal
7//! output (`ui::step`) replaced with `Progress` calls so this crate does no
8//! terminal I/O of its own.
9
10use crate::progress::Progress;
11use anyhow::{anyhow, bail, Context, Result};
12use std::process::Command;
13
14// AWS-managed CloudFront policies.
15const CACHE_DISABLED: &str = "4135ea2d-6df8-44a3-9df3-4b5a84be39ad";
16const CACHING_OPTIMIZED: &str = "658327ea-f89d-4fab-a63d-7e88639e58f6";
17// "AllViewerExceptHostHeader": forward everything except Host, so CloudFront
18// sends the origin's own host — required for an API Gateway origin to route.
19const ALL_VIEWER_EXCEPT_HOST: &str = "b689b0a8-53d0-40ab-baf2-68738e2966ac";
20
21/// A CloudFront Function that collapses every `/d/*` (and `/r/*`) to one path
22/// each, so all share pages — and all request-upload pages — share a single
23/// cache entry apiece: the decryptor page is byte-identical for every share, and
24/// the upload page is byte-identical for every request (both ids are read
25/// client-side), so a flood of `/d/<random>` or `/r/<random>` becomes cache hits
26/// instead of Lambda invocations. Rewrites the *origin* path only; the browser
27/// URL is unchanged, so the page still reads the real id.
28const PAGE_REWRITE_JS: &str = r#"function handler(event) {
29    var req = event.request;
30    if (req.uri.startsWith('/d/')) { req.uri = '/d/p'; }
31    if (req.uri.startsWith('/r/')) { req.uri = '/r/p'; }
32    return req;
33}
34"#;
35
36/// What a fronted gate resolves to.
37pub struct Front {
38    pub distribution_id: String,
39    pub domain: String,
40}
41
42/// Stand up (or reuse) the CloudFront distribution over the gate's API Gateway
43/// origin. Returns the distribution id + its `*.cloudfront.net` domain.
44pub fn front_gate(
45    profile: Option<&str>,
46    account: &str,
47    origin_host: &str,
48    existing_distribution: Option<&str>,
49    progress: &dyn Progress,
50) -> Result<Front> {
51    if let Some(dist_id) = existing_distribution {
52        progress.step("cloudfront (reuse)");
53        // A re-provision must apply the request feature's CloudFront changes to
54        // an ALREADY-LIVE distribution (default behavior allows POST for the
55        // upload finalize; the `/r/*` behavior). These live only in
56        // `distribution_config` (the CREATE path), so without this a re-provision
57        // never delivers them to an existing distribution.
58        //
59        // Ensure the page-rewrite function exists (create-or-skip; deterministic
60        // ARN). We deliberately do NOT update its code here: the shared `/d/`
61        // rewrite fn is left untouched, so an existing distribution's `/r/*` may
62        // not yet collapse to a single cache entry — that `/r/` collapse is a
63        // deferred optimization, not a correctness requirement.
64        let page_fn_arn = ensure_page_function(profile, account)?;
65
66        // Surgical merge, mirroring `add_alias`: fetch the CURRENT config and
67        // modify ONLY the default behavior's methods + the `/r/*` behavior,
68        // preserving everything else. A wholesale `update-distribution` with
69        // `distribution_config` would emit `CloudFrontDefaultCertificate:true`
70        // with no aliases and STRIP the custom domain (share.example.com) + ACM cert
71        // that `add_alias` attached.
72        let out = aws(
73            profile,
74            &[
75                "cloudfront",
76                "get-distribution-config",
77                "--id",
78                dist_id,
79                "--output",
80                "json",
81            ],
82        )?;
83        if !out.status.success() {
84            bail!(
85                "get-distribution-config: {}",
86                String::from_utf8_lossy(&out.stderr).trim()
87            );
88        }
89        let v: serde_json::Value = serde_json::from_slice(&out.stdout)?;
90        let etag = v["ETag"]
91            .as_str()
92            .ok_or_else(|| anyhow!("no ETag"))?
93            .to_string();
94        let mut cfg = v["DistributionConfig"].clone();
95        merge_request_behaviors(&mut cfg, &page_fn_arn);
96
97        let tmp = std::env::temp_dir().join(format!("dove-dist-{dist_id}.json"));
98        std::fs::write(&tmp, cfg.to_string())?;
99        let cfg_arg = format!("file://{}", tmp.display());
100        let out = aws(
101            profile,
102            &[
103                "cloudfront",
104                "update-distribution",
105                "--id",
106                dist_id,
107                "--distribution-config",
108                &cfg_arg,
109                "--if-match",
110                &etag,
111                "--output",
112                "json",
113            ],
114        );
115        let _ = std::fs::remove_file(&tmp);
116        let out = out?;
117        if !out.status.success() {
118            bail!(
119                "update-distribution: {}",
120                String::from_utf8_lossy(&out.stderr).trim()
121            );
122        }
123
124        let domain = distribution_domain(profile, dist_id)?;
125        progress.done("cloudfront (reuse)");
126        return Ok(Front {
127            distribution_id: dist_id.to_string(),
128            domain,
129        });
130    }
131    // The page cache-key rewrite function must exist before the distribution
132    // references it.
133    progress.step("page cache function");
134    let fn_arn = ensure_page_function(profile, account);
135    if fn_arn.is_ok() {
136        progress.done("page cache function");
137    }
138    let fn_arn = fn_arn?;
139
140    progress.step("cloudfront distribution");
141    let front = create_distribution(profile, origin_host, &fn_arn);
142    if front.is_ok() {
143        progress.done("cloudfront distribution");
144    }
145    front
146}
147
148/// Create + publish the page-rewrite CloudFront Function, reusing it by name.
149/// Its ARN is deterministic per account.
150fn ensure_page_function(profile: Option<&str>, account: &str) -> Result<String> {
151    let arn = format!("arn:aws:cloudfront::{account}:function/dove-page-rewrite");
152    let tmp = std::env::temp_dir().join("dove-page-rewrite.js");
153    std::fs::write(&tmp, PAGE_REWRITE_JS)?;
154    let code_arg = format!("fileb://{}", tmp.display());
155    let out = aws(
156        profile,
157        &[
158            "cloudfront",
159            "create-function",
160            "--name",
161            "dove-page-rewrite",
162            "--function-config",
163            "Comment=dove page cache-key rewrite,Runtime=cloudfront-js-2.0",
164            "--function-code",
165            &code_arg,
166            "--output",
167            "json",
168        ],
169    );
170    let _ = std::fs::remove_file(&tmp);
171    let out = out?;
172    if out.status.success() {
173        let v: serde_json::Value = serde_json::from_slice(&out.stdout)?;
174        let etag = v["ETag"]
175            .as_str()
176            .ok_or_else(|| anyhow!("no ETag from create-function"))?;
177        let pub_out = aws(
178            profile,
179            &[
180                "cloudfront",
181                "publish-function",
182                "--name",
183                "dove-page-rewrite",
184                "--if-match",
185                etag,
186            ],
187        )?;
188        if !pub_out.status.success() {
189            bail!(
190                "publish-function: {}",
191                String::from_utf8_lossy(&pub_out.stderr).trim()
192            );
193        }
194    } else if !String::from_utf8_lossy(&out.stderr).contains("FunctionAlreadyExists") {
195        bail!(
196            "create-function: {}",
197            String::from_utf8_lossy(&out.stderr).trim()
198        );
199    }
200    Ok(arn)
201}
202
203fn create_distribution(
204    profile: Option<&str>,
205    origin_host: &str,
206    page_fn_arn: &str,
207) -> Result<Front> {
208    let caller_ref = format!("dove-{origin_host}");
209    let config = distribution_config(&caller_ref, origin_host, page_fn_arn);
210    let out = aws(
211        profile,
212        &[
213            "cloudfront",
214            "create-distribution",
215            "--distribution-config",
216            &config,
217            "--output",
218            "json",
219        ],
220    )?;
221    if !out.status.success() {
222        bail!(
223            "create-distribution: {}",
224            String::from_utf8_lossy(&out.stderr).trim()
225        );
226    }
227    let v: serde_json::Value = serde_json::from_slice(&out.stdout)?;
228    Ok(Front {
229        distribution_id: v["Distribution"]["Id"]
230            .as_str()
231            .ok_or_else(|| anyhow!("no distribution Id"))?
232            .to_string(),
233        domain: v["Distribution"]["DomainName"]
234            .as_str()
235            .ok_or_else(|| anyhow!("no distribution DomainName"))?
236            .to_string(),
237    })
238}
239
240/// Attach a custom domain (`domain`) with its ACM cert to an existing gate
241/// distribution: add the alias + viewer certificate, preserving everything else.
242pub fn add_alias(
243    profile: Option<&str>,
244    dist_id: &str,
245    domain: &str,
246    cert_arn: &str,
247) -> Result<String> {
248    let out = aws(
249        profile,
250        &[
251            "cloudfront",
252            "get-distribution-config",
253            "--id",
254            dist_id,
255            "--output",
256            "json",
257        ],
258    )?;
259    if !out.status.success() {
260        bail!(
261            "get-distribution-config: {}",
262            String::from_utf8_lossy(&out.stderr).trim()
263        );
264    }
265    let v: serde_json::Value = serde_json::from_slice(&out.stdout)?;
266    let etag = v["ETag"]
267        .as_str()
268        .ok_or_else(|| anyhow!("no ETag"))?
269        .to_string();
270    let mut cfg = v["DistributionConfig"].clone();
271    cfg["Aliases"] = serde_json::json!({"Quantity": 1, "Items": [domain]});
272    cfg["ViewerCertificate"] = serde_json::json!({
273        "ACMCertificateArn": cert_arn,
274        "SSLSupportMethod": "sni-only",
275        "MinimumProtocolVersion": "TLSv1.2_2021",
276        "CloudFrontDefaultCertificate": false,
277    });
278    let domain_name = cfg["DomainName"].as_str().unwrap_or_default().to_string();
279
280    let tmp = std::env::temp_dir().join(format!("dove-dist-{dist_id}.json"));
281    std::fs::write(&tmp, cfg.to_string())?;
282    let cfg_arg = format!("file://{}", tmp.display());
283    let out = aws(
284        profile,
285        &[
286            "cloudfront",
287            "update-distribution",
288            "--id",
289            dist_id,
290            "--distribution-config",
291            &cfg_arg,
292            "--if-match",
293            &etag,
294            "--output",
295            "json",
296        ],
297    );
298    let _ = std::fs::remove_file(&tmp);
299    if !out?.status.success() {
300        bail!("update-distribution failed");
301    }
302    Ok(domain_name)
303}
304
305fn distribution_domain(profile: Option<&str>, dist_id: &str) -> Result<String> {
306    let out = aws(
307        profile,
308        &[
309            "cloudfront",
310            "get-distribution",
311            "--id",
312            dist_id,
313            "--output",
314            "json",
315        ],
316    )?;
317    if !out.status.success() {
318        bail!(
319            "get-distribution: {}",
320            String::from_utf8_lossy(&out.stderr).trim()
321        );
322    }
323    let v: serde_json::Value = serde_json::from_slice(&out.stdout)?;
324    v["Distribution"]["DomainName"]
325        .as_str()
326        .map(str::to_string)
327        .ok_or_else(|| anyhow!("no DomainName"))
328}
329
330// ── pure helpers ──────────────────────────────────────────────────────────
331
332/// A cache behavior (all fields CloudFront requires) for a path pattern, with an
333/// optional viewer-request function.
334fn cache_behavior(path: &str, cache_policy: &str, fn_arn: Option<&str>) -> serde_json::Value {
335    let fns = match fn_arn {
336        Some(arn) => serde_json::json!({
337            "Quantity": 1,
338            "Items": [{"EventType": "viewer-request", "FunctionARN": arn}]
339        }),
340        None => serde_json::json!({"Quantity": 0}),
341    };
342    serde_json::json!({
343        "PathPattern": path,
344        "TargetOriginId": "gate",
345        "ViewerProtocolPolicy": "redirect-to-https",
346        "CachePolicyId": cache_policy,
347        "Compress": true,
348        "AllowedMethods": {
349            "Quantity": 2, "Items": ["GET", "HEAD"],
350            "CachedMethods": {"Quantity": 2, "Items": ["GET", "HEAD"]}
351        },
352        "FunctionAssociations": fns,
353        "SmoothStreaming": false,
354        "FieldLevelEncryptionId": "",
355        "LambdaFunctionAssociations": {"Quantity": 0},
356        "TrustedSigners": {"Enabled": false, "Quantity": 0},
357        "TrustedKeyGroups": {"Enabled": false, "Quantity": 0}
358    })
359}
360
361/// Apply the request feature's two CloudFront changes to a `DistributionConfig`
362/// value, **in place**, preserving everything else (Aliases, ViewerCertificate,
363/// Origins, Comment, and any other cache behaviors). PURE — no I/O; the reuse
364/// path in `front_gate` feeds it the LIVE fetched config so a re-provision
365/// updates an existing distribution without stripping its custom domain/cert.
366///
367/// 1. The default behavior must allow POST — the request feature's upload
368///    finalize, `POST {origin}/done/<id>`, is the only POST on the whole gate,
369///    and CloudFront 403s any method outside `AllowedMethods` before it reaches
370///    the origin. POST is never cached, though: `CachedMethods` stays GET/HEAD.
371/// 2. A `/r/*` cache behavior (the request-agnostic upload page) is ensured,
372///    carrying the page-rewrite function.
373///
374/// Idempotent: a second application adds no behavior and leaves `Quantity`
375/// stable (it matches on `PathPattern == "/r/*"`).
376fn merge_request_behaviors(config: &mut serde_json::Value, page_fn_arn: &str) {
377    config["DefaultCacheBehavior"]["AllowedMethods"] = serde_json::json!({
378        "Quantity": 7,
379        "Items": ["GET", "HEAD", "OPTIONS", "PUT", "POST", "PATCH", "DELETE"],
380        "CachedMethods": {"Quantity": 2, "Items": ["GET", "HEAD"]}
381    });
382
383    // A distribution with zero cache behaviors can come back as `{"Quantity":0}`
384    // with no `Items`; normalize so there's always an array to append to.
385    if !config["CacheBehaviors"]["Items"].is_array() {
386        config["CacheBehaviors"] = serde_json::json!({"Quantity": 0, "Items": []});
387    }
388    let items = config["CacheBehaviors"]["Items"]
389        .as_array_mut()
390        .expect("CacheBehaviors.Items normalized to an array above");
391    if !items.iter().any(|b| b["PathPattern"] == "/r/*") {
392        items.push(cache_behavior("/r/*", CACHING_OPTIMIZED, Some(page_fn_arn)));
393    }
394    let len = items.len();
395    config["CacheBehaviors"]["Quantity"] = serde_json::json!(len);
396}
397
398/// The distribution config JSON. The **default** behavior is the dynamic gate (no
399/// caching, API Gateway origin). Three cached behaviors sit in front: `/d/*` (the
400/// share-agnostic page) and `/r/*` (the request-agnostic upload page) — each
401/// collapsed to one cache entry by the rewrite function — and `/og.png`; all three
402/// are served from the edge, never invoking the Lambda. Default cert; a custom
403/// domain is added later by `domain add`.
404pub fn distribution_config(caller_ref: &str, origin_host: &str, page_fn_arn: &str) -> String {
405    serde_json::json!({
406        "CallerReference": caller_ref,
407        "Comment": "dove gate",
408        "Enabled": true,
409        "Origins": {"Quantity": 1, "Items": [{
410            "Id": "gate",
411            "DomainName": origin_host,
412            "CustomOriginConfig": {
413                "HTTPPort": 80,
414                "HTTPSPort": 443,
415                "OriginProtocolPolicy": "https-only",
416                "OriginSslProtocols": {"Quantity": 1, "Items": ["TLSv1.2"]}
417            }
418        }]},
419        "DefaultCacheBehavior": {
420            "TargetOriginId": "gate",
421            "ViewerProtocolPolicy": "redirect-to-https",
422            // The request feature's ONLY POST is the upload finalize,
423            // `POST {origin}/done/<id>` — CloudFront 403s any method not in
424            // the default behavior's AllowedMethods before it ever reaches
425            // the gate, so POST must be allowed here (never cached, though:
426            // CachedMethods stays GET/HEAD).
427            "AllowedMethods": {
428                "Quantity": 7, "Items": ["GET", "HEAD", "OPTIONS", "PUT", "POST", "PATCH", "DELETE"],
429                "CachedMethods": {"Quantity": 2, "Items": ["GET", "HEAD"]}
430            },
431            "CachePolicyId": CACHE_DISABLED,
432            "OriginRequestPolicyId": ALL_VIEWER_EXCEPT_HOST,
433            "Compress": true
434        },
435        "CacheBehaviors": {"Quantity": 3, "Items": [
436            cache_behavior("/d/*", CACHING_OPTIMIZED, Some(page_fn_arn)),
437            cache_behavior("/r/*", CACHING_OPTIMIZED, Some(page_fn_arn)),
438            cache_behavior("/og.png", CACHING_OPTIMIZED, None)
439        ]},
440        "ViewerCertificate": {"CloudFrontDefaultCertificate": true}
441    })
442    .to_string()
443}
444
445fn aws(profile: Option<&str>, args: &[&str]) -> Result<std::process::Output> {
446    let mut cmd = Command::new("aws");
447    if let Some(p) = profile {
448        cmd.args(["--profile", p]);
449    }
450    cmd.args(args)
451        .output()
452        .with_context(|| format!("running aws {}", args.join(" ")))
453}
454
455#[cfg(test)]
456mod tests {
457    use super::*;
458
459    #[test]
460    fn distribution_config_fronts_the_api_gateway_origin() {
461        let c = distribution_config(
462            "dove-x",
463            "abc.execute-api.us-east-1.amazonaws.com",
464            "arn:aws:cloudfront::1:function/dove-page-rewrite",
465        );
466        assert!(c.contains("\"DomainName\":\"abc.execute-api.us-east-1.amazonaws.com\""));
467        assert!(c.contains(CACHE_DISABLED)); // default (dynamic) behavior
468        assert!(c.contains(CACHING_OPTIMIZED)); // the cached /d/*, /r/*, and /og.png behaviors
469        assert!(c.contains("/d/*") && c.contains("/r/*") && c.contains("/og.png"));
470        assert!(c.contains("\"Quantity\":3")); // CacheBehaviors.Quantity bumped for /r/*
471
472        // The /r/* behavior carries the same page-rewrite function as /d/*.
473        // Parsed (not substring-searched): serde_json's default object
474        // serialization sorts keys, so a plain search from "PathPattern" onward
475        // can miss fields that sort before it, like "FunctionAssociations".
476        let parsed: serde_json::Value = serde_json::from_str(&c).unwrap();
477        let behaviors = parsed["CacheBehaviors"]["Items"].as_array().unwrap();
478        let r_behavior = behaviors
479            .iter()
480            .find(|b| b["PathPattern"] == "/r/*")
481            .expect("/r/* behavior present");
482        assert_eq!(
483            r_behavior["FunctionAssociations"]["Items"][0]["FunctionARN"],
484            "arn:aws:cloudfront::1:function/dove-page-rewrite"
485        );
486        assert_eq!(r_behavior["CachePolicyId"], CACHING_OPTIMIZED);
487        assert!(c.contains(ALL_VIEWER_EXCEPT_HOST));
488        assert!(!c.contains("OriginAccessControlId")); // no OAC — plain origin
489
490        // The default behavior must allow POST — the request feature's
491        // upload finalize, `POST {origin}/done/<id>`, is the only POST on
492        // the whole gate, and CloudFront 403s anything outside
493        // AllowedMethods before it reaches the origin. It must still never
494        // be cached: CachedMethods stays exactly GET/HEAD.
495        let default_methods = &parsed["DefaultCacheBehavior"]["AllowedMethods"];
496        let allowed: Vec<&str> = default_methods["Items"]
497            .as_array()
498            .unwrap()
499            .iter()
500            .map(|v| v.as_str().unwrap())
501            .collect();
502        assert!(
503            allowed.contains(&"POST"),
504            "default behavior must allow POST for /done"
505        );
506        assert!(allowed.contains(&"GET") && allowed.contains(&"HEAD"));
507        let cached: Vec<&str> = default_methods["CachedMethods"]["Items"]
508            .as_array()
509            .unwrap()
510            .iter()
511            .map(|v| v.as_str().unwrap())
512            .collect();
513        assert_eq!(cached, vec!["GET", "HEAD"]);
514    }
515
516    // ── reuse-path merge (re-provision of an existing distribution) ──────────
517    //
518    // A live, custom-domained distribution config as CloudFront returns it: a
519    // share.example.com alias + its ACM ViewerCertificate (both added by
520    // `add_alias`), a GET/HEAD-only default behavior, and the `/d/*` + `/og.png`
521    // cache behaviors. Placeholder account id only. These prove the surgical
522    // merge applies the request feature's changes without touching the custom
523    // domain/cert (the footgun) and is idempotent.
524    const TEST_FN_ARN: &str = "arn:aws:cloudfront::000000000000:function/dove-page-rewrite";
525
526    fn live_config_with_custom_domain() -> serde_json::Value {
527        serde_json::json!({
528            "CallerReference": "dove-abc.execute-api.us-east-1.amazonaws.com",
529            "Comment": "dove gate",
530            "Enabled": true,
531            "Aliases": {"Quantity": 1, "Items": ["share.example.com"]},
532            "Origins": {"Quantity": 1, "Items": [{
533                "Id": "gate",
534                "DomainName": "abc.execute-api.us-east-1.amazonaws.com"
535            }]},
536            "DefaultCacheBehavior": {
537                "TargetOriginId": "gate",
538                "ViewerProtocolPolicy": "redirect-to-https",
539                "AllowedMethods": {
540                    "Quantity": 2, "Items": ["GET", "HEAD"],
541                    "CachedMethods": {"Quantity": 2, "Items": ["GET", "HEAD"]}
542                },
543                "CachePolicyId": CACHE_DISABLED,
544                "OriginRequestPolicyId": ALL_VIEWER_EXCEPT_HOST,
545                "Compress": true
546            },
547            "CacheBehaviors": {"Quantity": 2, "Items": [
548                cache_behavior("/d/*", CACHING_OPTIMIZED, Some(TEST_FN_ARN)),
549                cache_behavior("/og.png", CACHING_OPTIMIZED, None)
550            ]},
551            "ViewerCertificate": {
552                "ACMCertificateArn": "arn:aws:acm:us-east-1:000000000000:certificate/test",
553                "SSLSupportMethod": "sni-only",
554                "CloudFrontDefaultCertificate": false
555            }
556        })
557    }
558
559    fn path_patterns(config: &serde_json::Value) -> Vec<String> {
560        config["CacheBehaviors"]["Items"]
561            .as_array()
562            .unwrap()
563            .iter()
564            .map(|b| b["PathPattern"].as_str().unwrap().to_string())
565            .collect()
566    }
567
568    // The footgun guard: the surgical merge must NOT strip the custom domain or
569    // its ACM certificate. A wholesale update-distribution would.
570    #[test]
571    fn merge_preserves_alias_and_cert() {
572        let mut cfg = live_config_with_custom_domain();
573        merge_request_behaviors(&mut cfg, TEST_FN_ARN);
574
575        assert_eq!(
576            cfg["Aliases"]["Items"].as_array().unwrap(),
577            &vec![serde_json::json!("share.example.com")]
578        );
579        assert_eq!(
580            cfg["ViewerCertificate"]["ACMCertificateArn"],
581            "arn:aws:acm:us-east-1:000000000000:certificate/test"
582        );
583        // Never flips back to the CloudFront default cert.
584        assert_eq!(
585            cfg["ViewerCertificate"]["CloudFrontDefaultCertificate"],
586            false
587        );
588        assert_eq!(cfg["ViewerCertificate"]["SSLSupportMethod"], "sni-only");
589        // Untouched surroundings.
590        assert_eq!(cfg["Comment"], "dove gate");
591        assert_eq!(cfg["Origins"]["Items"][0]["Id"], "gate");
592    }
593
594    // POST must reach the origin (for `POST /done/<id>`) but must NEVER be cached.
595    #[test]
596    fn merge_allows_post_but_caches_only_get_head() {
597        let mut cfg = live_config_with_custom_domain();
598        merge_request_behaviors(&mut cfg, TEST_FN_ARN);
599
600        let dm = &cfg["DefaultCacheBehavior"]["AllowedMethods"];
601        let allowed: Vec<&str> = dm["Items"]
602            .as_array()
603            .unwrap()
604            .iter()
605            .map(|v| v.as_str().unwrap())
606            .collect();
607        assert!(
608            allowed.contains(&"POST"),
609            "default behavior must allow POST for /done"
610        );
611        let cached: Vec<&str> = dm["CachedMethods"]["Items"]
612            .as_array()
613            .unwrap()
614            .iter()
615            .map(|v| v.as_str().unwrap())
616            .collect();
617        assert_eq!(cached, vec!["GET", "HEAD"]);
618    }
619
620    // The `/r/*` behavior is added (with the page fn) and the existing
621    // behaviors survive; Quantity tracks the real Items length.
622    #[test]
623    fn merge_adds_r_behavior() {
624        let mut cfg = live_config_with_custom_domain();
625        merge_request_behaviors(&mut cfg, TEST_FN_ARN);
626
627        let patterns = path_patterns(&cfg);
628        assert!(patterns.contains(&"/r/*".to_string()));
629        assert!(patterns.contains(&"/d/*".to_string()));
630        assert!(patterns.contains(&"/og.png".to_string()));
631
632        let items = cfg["CacheBehaviors"]["Items"].as_array().unwrap();
633        let r = items
634            .iter()
635            .find(|b| b["PathPattern"] == "/r/*")
636            .expect("/r/* behavior present");
637        assert_eq!(
638            r["FunctionAssociations"]["Items"][0]["FunctionARN"],
639            TEST_FN_ARN
640        );
641        assert_eq!(r["CachePolicyId"], CACHING_OPTIMIZED);
642        assert_eq!(
643            cfg["CacheBehaviors"]["Quantity"].as_u64().unwrap() as usize,
644            items.len()
645        );
646    }
647
648    // A second re-provision adds nothing and leaves Quantity stable.
649    #[test]
650    fn merge_is_idempotent() {
651        let mut cfg = live_config_with_custom_domain();
652        merge_request_behaviors(&mut cfg, TEST_FN_ARN);
653        let len_after_first = cfg["CacheBehaviors"]["Items"].as_array().unwrap().len();
654        let quantity_after_first = cfg["CacheBehaviors"]["Quantity"].as_u64().unwrap();
655
656        merge_request_behaviors(&mut cfg, TEST_FN_ARN);
657
658        let items = cfg["CacheBehaviors"]["Items"].as_array().unwrap();
659        let r_count = items.iter().filter(|b| b["PathPattern"] == "/r/*").count();
660        assert_eq!(r_count, 1, "exactly one /r/* behavior after two merges");
661        assert_eq!(items.len(), len_after_first);
662        assert_eq!(
663            cfg["CacheBehaviors"]["Quantity"].as_u64().unwrap(),
664            quantity_after_first
665        );
666        assert_eq!(
667            cfg["CacheBehaviors"]["Quantity"].as_u64().unwrap() as usize,
668            items.len()
669        );
670    }
671}