Skip to main content

fakecloud_cloudfront/
tenants_service.rs

1// Handlers for CloudFront DistributionTenant ops (12 ops). Same
2// REST-XML conventions as Distribution: ETag-based concurrency,
3// IDs prefixed with `dt-`, ARNs under `arn:aws:cloudfront::`.
4
5use chrono::Utc;
6use http::header::LOCATION;
7use http::{HeaderMap, StatusCode};
8use uuid::Uuid;
9
10use fakecloud_core::service::{AwsRequest, AwsResponse, AwsServiceError};
11
12use crate::policies::{
13    not_found, precondition_failed, require_if_match, rfc3339, route_id, xml_with_etag,
14};
15use crate::router::Route;
16use crate::service::{
17    aws_error, esc, generate_id_with_prefix, invalid_argument, xml_response, CloudFrontService,
18    DEFAULT_ACCOUNT,
19};
20use crate::state::Tag;
21use crate::tenants::{
22    StoredDistributionTenant, StoredTenantInvalidation, TenantCustomizations,
23    TenantGeoRestrictionCustomization, TenantParameter, TenantWebAclCustomization,
24};
25use crate::xml_io;
26
27const NS: &str = crate::NAMESPACE;
28const XML_DECL: &str = r#"<?xml version="1.0" encoding="UTF-8"?>"#;
29
30#[derive(Debug, Default, serde::Deserialize)]
31#[serde(rename_all = "PascalCase")]
32struct CreateDistributionTenantRequest {
33    pub distribution_id: String,
34    pub name: String,
35    #[serde(default)]
36    pub domains: Option<DomainItems>,
37    #[serde(default)]
38    pub connection_group_id: Option<String>,
39    #[serde(default)]
40    pub enabled: Option<bool>,
41    #[serde(default)]
42    pub parameters: Option<ParametersReq>,
43    #[serde(default)]
44    pub customizations: Option<CustomizationsReq>,
45    // ManagedCertificateRequest is accepted (any <ManagedCertificateRequest>
46    // element is ignored by the deserializer) but not stored: it is an
47    // input-only member that AWS never echoes back.
48    #[serde(default)]
49    pub tags: Option<TagsReq>,
50}
51
52#[derive(Debug, Default, serde::Deserialize)]
53#[serde(rename_all = "PascalCase")]
54struct UpdateDistributionTenantRequest {
55    #[serde(default)]
56    pub distribution_id: Option<String>,
57    #[serde(default)]
58    pub domains: Option<DomainItems>,
59    #[serde(default)]
60    pub connection_group_id: Option<String>,
61    #[serde(default)]
62    pub enabled: Option<bool>,
63    #[serde(default)]
64    pub parameters: Option<ParametersReq>,
65    #[serde(default)]
66    pub customizations: Option<CustomizationsReq>,
67}
68
69#[derive(Debug, Default, serde::Deserialize)]
70#[serde(rename_all = "PascalCase")]
71struct ParametersReq {
72    #[serde(default, rename = "Parameter")]
73    pub parameter: Vec<ParameterReq>,
74}
75
76#[derive(Debug, Default, serde::Deserialize)]
77#[serde(rename_all = "PascalCase")]
78struct ParameterReq {
79    pub name: String,
80    #[serde(default)]
81    pub value: String,
82}
83
84#[derive(Debug, Default, serde::Deserialize)]
85#[serde(rename_all = "PascalCase")]
86struct CustomizationsReq {
87    #[serde(default)]
88    pub web_acl: Option<WebAclReq>,
89    #[serde(default)]
90    pub certificate: Option<CertificateReq>,
91    #[serde(default)]
92    pub geo_restrictions: Option<GeoRestrictionsReq>,
93}
94
95#[derive(Debug, Default, serde::Deserialize)]
96#[serde(rename_all = "PascalCase")]
97struct WebAclReq {
98    #[serde(default)]
99    pub action: String,
100    #[serde(default, rename = "Arn")]
101    pub arn: Option<String>,
102}
103
104#[derive(Debug, Default, serde::Deserialize)]
105#[serde(rename_all = "PascalCase")]
106struct CertificateReq {
107    #[serde(rename = "Arn")]
108    pub arn: String,
109}
110
111#[derive(Debug, Default, serde::Deserialize)]
112#[serde(rename_all = "PascalCase")]
113struct GeoRestrictionsReq {
114    #[serde(default)]
115    pub restriction_type: String,
116    #[serde(default)]
117    pub locations: Option<LocationsReq>,
118}
119
120#[derive(Debug, Default, serde::Deserialize)]
121#[serde(rename_all = "PascalCase")]
122struct LocationsReq {
123    #[serde(default, rename = "Location")]
124    pub location: Vec<String>,
125}
126
127#[derive(Debug, Default, serde::Deserialize)]
128#[serde(rename_all = "PascalCase")]
129struct TagsReq {
130    #[serde(default)]
131    pub items: Option<TagItemsReq>,
132}
133
134#[derive(Debug, Default, serde::Deserialize)]
135#[serde(rename_all = "PascalCase")]
136struct TagItemsReq {
137    #[serde(default, rename = "Tag")]
138    pub tag: Vec<TagReq>,
139}
140
141#[derive(Debug, Default, serde::Deserialize)]
142#[serde(rename_all = "PascalCase")]
143struct TagReq {
144    pub key: String,
145    #[serde(default)]
146    pub value: Option<String>,
147}
148
149#[derive(Debug, Default, serde::Deserialize)]
150struct DomainItems {
151    #[serde(default, rename = "member")]
152    pub members: Vec<DomainItem>,
153}
154
155#[derive(Debug, Default, serde::Deserialize)]
156#[serde(rename_all = "PascalCase")]
157struct DomainItem {
158    pub domain: String,
159}
160
161#[derive(Debug, Default, serde::Deserialize)]
162struct AssociateWebAclRequest {
163    #[serde(rename = "WebACLArn")]
164    pub web_acl_arn: String,
165}
166
167#[derive(Debug, Default, serde::Deserialize)]
168#[serde(rename_all = "PascalCase")]
169struct InvalidationBatchRequest {
170    pub paths: PathsItems,
171    pub caller_reference: String,
172}
173
174#[derive(Debug, Default, serde::Deserialize)]
175#[serde(rename_all = "PascalCase")]
176struct PathsItems {
177    #[serde(default)]
178    pub items: Option<PathItems>,
179}
180
181#[derive(Debug, Default, serde::Deserialize)]
182#[serde(rename_all = "PascalCase")]
183struct PathItems {
184    #[serde(default, rename = "Path")]
185    pub path: Vec<String>,
186}
187
188impl CloudFrontService {
189    pub(crate) fn create_distribution_tenant(
190        &self,
191        req: &AwsRequest,
192    ) -> Result<AwsResponse, AwsServiceError> {
193        let parsed: CreateDistributionTenantRequest =
194            xml_io::from_xml_root(&req.body).map_err(|e| {
195                invalid_argument(format!("invalid CreateDistributionTenantRequest XML: {e}"))
196            })?;
197        if parsed.distribution_id.is_empty() {
198            return Err(invalid_argument("DistributionId is required"));
199        }
200        if parsed.name.is_empty() {
201            return Err(invalid_argument("Name is required"));
202        }
203        let mut state = self.state.write();
204        let account = state
205            .accounts
206            .entry(DEFAULT_ACCOUNT.to_string())
207            .or_default();
208        if account
209            .distribution_tenants
210            .values()
211            .any(|t| t.name == parsed.name)
212        {
213            return Err(aws_error(
214                StatusCode::CONFLICT,
215                "EntityAlreadyExists",
216                format!("DistributionTenant {} already exists", parsed.name),
217            ));
218        }
219        let id = generate_tenant_id();
220        let arn = format!(
221            "arn:aws:cloudfront::{}:distribution-tenant/{}",
222            DEFAULT_ACCOUNT, id
223        );
224        let etag = generate_id_with_prefix("E");
225        let now = Utc::now();
226        let domains = parsed
227            .domains
228            .map(|d| d.members.into_iter().map(|i| i.domain).collect())
229            .unwrap_or_default();
230        let customizations = parsed.customizations.map(convert_customizations);
231        let web_acl_arn = customizations
232            .as_ref()
233            .and_then(|c| c.web_acl.as_ref())
234            .and_then(|w| w.arn.clone());
235        let stored = StoredDistributionTenant {
236            id: id.clone(),
237            arn: arn.clone(),
238            name: parsed.name,
239            distribution_id: parsed.distribution_id,
240            domains,
241            connection_group_id: parsed.connection_group_id,
242            web_acl_arn,
243            enabled: parsed.enabled.unwrap_or(true),
244            status: "InProgress".to_string(),
245            etag: etag.clone(),
246            created_time: now,
247            last_modified_time: now,
248            parameters: convert_parameters(parsed.parameters),
249            customizations,
250        };
251        account
252            .distribution_tenants
253            .insert(id.clone(), stored.clone());
254        if let Some(tags) = parsed.tags {
255            let converted: Vec<Tag> = tags
256                .items
257                .map(|i| {
258                    i.tag
259                        .into_iter()
260                        .map(|t| Tag {
261                            key: t.key,
262                            value: t.value,
263                        })
264                        .collect()
265                })
266                .unwrap_or_default();
267            if !converted.is_empty() {
268                account.tags.entry(arn).or_default().extend(converted);
269            }
270        }
271        drop(state);
272        self.schedule_distribution_tenant_deploy(id.clone());
273        let body = render_distribution_tenant(&stored);
274        Ok(xml_with_etag(StatusCode::CREATED, body, &etag, Some(&id)))
275    }
276
277    pub(crate) fn get_distribution_tenant(
278        &self,
279        route: &Route,
280    ) -> Result<AwsResponse, AwsServiceError> {
281        let id = route_id(route, "DistributionTenant")?;
282        let state = self.state.read();
283        let t = state
284            .accounts
285            .get(DEFAULT_ACCOUNT)
286            .and_then(|a| a.distribution_tenants.get(&id).cloned())
287            .ok_or_else(|| not_found("DistributionTenant", &id))?;
288        drop(state);
289        let body = render_distribution_tenant(&t);
290        Ok(xml_with_etag(StatusCode::OK, body, &t.etag, None))
291    }
292
293    pub(crate) fn get_distribution_tenant_by_domain(
294        &self,
295        req: &AwsRequest,
296    ) -> Result<AwsResponse, AwsServiceError> {
297        let domain = req
298            .query_params
299            .get("domain")
300            .or_else(|| req.query_params.get("Domain"))
301            .cloned()
302            .ok_or_else(|| invalid_argument("Domain query parameter is required"))?;
303        let state = self.state.read();
304        let t = state
305            .accounts
306            .get(DEFAULT_ACCOUNT)
307            .and_then(|a| {
308                a.distribution_tenants
309                    .values()
310                    .find(|t| t.domains.iter().any(|d| d == &domain))
311                    .cloned()
312            })
313            .ok_or_else(|| not_found("DistributionTenant", &domain))?;
314        drop(state);
315        let body = render_distribution_tenant(&t);
316        Ok(xml_with_etag(StatusCode::OK, body, &t.etag, None))
317    }
318
319    pub(crate) fn update_distribution_tenant(
320        &self,
321        req: &AwsRequest,
322        route: &Route,
323    ) -> Result<AwsResponse, AwsServiceError> {
324        let id = route_id(route, "DistributionTenant")?;
325        let if_match = require_if_match(req)?;
326        let parsed: UpdateDistributionTenantRequest =
327            xml_io::from_xml_root(&req.body).map_err(|e| {
328                invalid_argument(format!("invalid UpdateDistributionTenantRequest XML: {e}"))
329            })?;
330        let mut state = self.state.write();
331        let account = state
332            .accounts
333            .get_mut(DEFAULT_ACCOUNT)
334            .ok_or_else(|| not_found("DistributionTenant", &id))?;
335        let t = account
336            .distribution_tenants
337            .get_mut(&id)
338            .ok_or_else(|| not_found("DistributionTenant", &id))?;
339        if t.etag != if_match {
340            return Err(precondition_failed());
341        }
342        if let Some(d) = parsed.distribution_id {
343            t.distribution_id = d;
344        }
345        if let Some(d) = parsed.domains {
346            t.domains = d.members.into_iter().map(|i| i.domain).collect();
347        }
348        if let Some(c) = parsed.connection_group_id {
349            t.connection_group_id = Some(c);
350        }
351        if let Some(e) = parsed.enabled {
352            t.enabled = e;
353        }
354        if let Some(p) = parsed.parameters {
355            t.parameters = convert_parameters(Some(p));
356        }
357        if let Some(c) = parsed.customizations {
358            let converted = convert_customizations(c);
359            t.web_acl_arn = converted
360                .web_acl
361                .as_ref()
362                .and_then(|w| w.arn.clone())
363                .or_else(|| t.web_acl_arn.clone());
364            t.customizations = Some(converted);
365        }
366        t.etag = generate_id_with_prefix("E");
367        t.last_modified_time = Utc::now();
368        // UpdateDistributionTenant kicks off a fresh edge propagation; mirror
369        // the Distribution lifecycle by flipping back to InProgress.
370        t.status = "InProgress".to_string();
371        let snap = t.clone();
372        drop(state);
373        self.schedule_distribution_tenant_deploy(id.clone());
374        let body = render_distribution_tenant(&snap);
375        Ok(xml_with_etag(StatusCode::OK, body, &snap.etag, None))
376    }
377
378    pub(crate) fn delete_distribution_tenant(
379        &self,
380        req: &AwsRequest,
381        route: &Route,
382    ) -> Result<AwsResponse, AwsServiceError> {
383        let id = route_id(route, "DistributionTenant")?;
384        let if_match = require_if_match(req)?;
385        let mut state = self.state.write();
386        let account = state
387            .accounts
388            .get_mut(DEFAULT_ACCOUNT)
389            .ok_or_else(|| not_found("DistributionTenant", &id))?;
390        let t = account
391            .distribution_tenants
392            .get(&id)
393            .ok_or_else(|| not_found("DistributionTenant", &id))?;
394        if t.etag != if_match {
395            return Err(precondition_failed());
396        }
397        if t.enabled {
398            return Err(aws_error(
399                StatusCode::PRECONDITION_FAILED,
400                "ResourceInUse",
401                "DistributionTenant must be disabled before delete",
402            ));
403        }
404        let arn = t.arn.clone();
405        account.distribution_tenants.remove(&id);
406        account
407            .tenant_invalidations
408            .retain(|_, inv| inv.tenant_id != id);
409        account.tags.remove(&arn);
410        drop(state);
411        Ok(crate::policies::empty(StatusCode::NO_CONTENT))
412    }
413
414    pub(crate) fn list_distribution_tenants(
415        &self,
416        _req: &AwsRequest,
417    ) -> Result<AwsResponse, AwsServiceError> {
418        let state = self.state.read();
419        let mut items: Vec<StoredDistributionTenant> = state
420            .accounts
421            .get(DEFAULT_ACCOUNT)
422            .map(|a| a.distribution_tenants.values().cloned().collect())
423            .unwrap_or_default();
424        drop(state);
425        items.sort_by(|a, b| a.id.cmp(&b.id));
426        let body = render_tenant_list(&items, "ListDistributionTenantsResult");
427        Ok(xml_response(StatusCode::OK, body, HeaderMap::new()))
428    }
429
430    pub(crate) fn list_distribution_tenants_by_customization(
431        &self,
432        _req: &AwsRequest,
433    ) -> Result<AwsResponse, AwsServiceError> {
434        let state = self.state.read();
435        let mut items: Vec<StoredDistributionTenant> = state
436            .accounts
437            .get(DEFAULT_ACCOUNT)
438            .map(|a| a.distribution_tenants.values().cloned().collect())
439            .unwrap_or_default();
440        drop(state);
441        items.sort_by(|a, b| a.id.cmp(&b.id));
442        let body = render_tenant_list(&items, "ListDistributionTenantsByCustomizationResult");
443        Ok(xml_response(StatusCode::OK, body, HeaderMap::new()))
444    }
445
446    pub(crate) fn associate_distribution_tenant_web_acl(
447        &self,
448        req: &AwsRequest,
449        route: &Route,
450    ) -> Result<AwsResponse, AwsServiceError> {
451        let id = route_id(route, "DistributionTenant")?;
452        let if_match = require_if_match(req)?;
453        let parsed: AssociateWebAclRequest = xml_io::from_xml_root(&req.body).map_err(|e| {
454            invalid_argument(format!(
455                "invalid AssociateDistributionTenantWebACLRequest XML: {e}"
456            ))
457        })?;
458        let mut state = self.state.write();
459        let account = state
460            .accounts
461            .get_mut(DEFAULT_ACCOUNT)
462            .ok_or_else(|| not_found("DistributionTenant", &id))?;
463        let t = account
464            .distribution_tenants
465            .get_mut(&id)
466            .ok_or_else(|| not_found("DistributionTenant", &id))?;
467        if t.etag != if_match {
468            return Err(precondition_failed());
469        }
470        t.web_acl_arn = Some(parsed.web_acl_arn.clone());
471        // Mirror the association into Customizations.WebAcl so GetDistribution
472        // Tenant reflects the active WebACL, matching AWS.
473        let customizations = t.customizations.get_or_insert_with(Default::default);
474        let web_acl = customizations.web_acl.get_or_insert_with(Default::default);
475        if web_acl.action.is_empty() {
476            web_acl.action = "override".to_string();
477        }
478        web_acl.arn = Some(parsed.web_acl_arn);
479        t.etag = generate_id_with_prefix("E");
480        t.last_modified_time = Utc::now();
481        let snap = t.clone();
482        drop(state);
483        let body = render_associate_web_acl(&snap);
484        Ok(xml_with_etag(StatusCode::OK, body, &snap.etag, None))
485    }
486
487    pub(crate) fn disassociate_distribution_tenant_web_acl(
488        &self,
489        req: &AwsRequest,
490        route: &Route,
491    ) -> Result<AwsResponse, AwsServiceError> {
492        let id = route_id(route, "DistributionTenant")?;
493        let if_match = require_if_match(req)?;
494        let mut state = self.state.write();
495        let account = state
496            .accounts
497            .get_mut(DEFAULT_ACCOUNT)
498            .ok_or_else(|| not_found("DistributionTenant", &id))?;
499        let t = account
500            .distribution_tenants
501            .get_mut(&id)
502            .ok_or_else(|| not_found("DistributionTenant", &id))?;
503        if t.etag != if_match {
504            return Err(precondition_failed());
505        }
506        t.web_acl_arn = None;
507        if let Some(c) = t.customizations.as_mut() {
508            c.web_acl = None;
509        }
510        t.etag = generate_id_with_prefix("E");
511        t.last_modified_time = Utc::now();
512        let snap = t.clone();
513        drop(state);
514        let body = render_disassociate_web_acl(&snap);
515        Ok(xml_with_etag(StatusCode::OK, body, &snap.etag, None))
516    }
517
518    pub(crate) fn create_invalidation_for_distribution_tenant(
519        &self,
520        req: &AwsRequest,
521        route: &Route,
522    ) -> Result<AwsResponse, AwsServiceError> {
523        let tenant_id = route_id(route, "DistributionTenant")?;
524        let parsed: InvalidationBatchRequest = xml_io::from_xml_root(&req.body)
525            .map_err(|e| invalid_argument(format!("invalid InvalidationBatch XML: {e}")))?;
526        if parsed.caller_reference.is_empty() {
527            return Err(invalid_argument("CallerReference is required"));
528        }
529        let paths = parsed.paths.items.map(|i| i.path).unwrap_or_default();
530        if paths.is_empty() {
531            return Err(invalid_argument(
532                "InvalidationBatch.Paths must be non-empty",
533            ));
534        }
535        let mut state = self.state.write();
536        let account = state.entry(DEFAULT_ACCOUNT);
537        if !account.distribution_tenants.contains_key(&tenant_id) {
538            return Err(not_found("DistributionTenant", &tenant_id));
539        }
540        let id = generate_invalidation_id();
541        let stored = StoredTenantInvalidation {
542            id: id.clone(),
543            tenant_id: tenant_id.clone(),
544            status: "Completed".to_string(),
545            create_time: Utc::now(),
546            paths,
547            caller_reference: parsed.caller_reference,
548        };
549        account
550            .tenant_invalidations
551            .insert(id.clone(), stored.clone());
552        drop(state);
553        let body = render_tenant_invalidation(&stored);
554        let mut headers = HeaderMap::new();
555        if let Ok(v) = http::HeaderValue::from_str(&format!(
556            "/2020-05-31/distribution-tenant/{tenant_id}/invalidation/{}",
557            stored.id
558        )) {
559            headers.insert(LOCATION, v);
560        }
561        Ok(xml_response(StatusCode::CREATED, body, headers))
562    }
563
564    pub(crate) fn get_invalidation_for_distribution_tenant(
565        &self,
566        route: &Route,
567    ) -> Result<AwsResponse, AwsServiceError> {
568        let tenant_id = route
569            .id
570            .as_deref()
571            .ok_or_else(|| invalid_argument("missing distribution tenant id"))?;
572        let inv_id = route
573            .second_id
574            .as_deref()
575            .ok_or_else(|| invalid_argument("missing invalidation id"))?;
576        let state = self.state.read();
577        let account = state
578            .accounts
579            .get(DEFAULT_ACCOUNT)
580            .ok_or_else(|| not_found("Invalidation", inv_id))?;
581        if !account.distribution_tenants.contains_key(tenant_id) {
582            return Err(not_found("DistributionTenant", tenant_id));
583        }
584        let inv = account
585            .tenant_invalidations
586            .get(inv_id)
587            .filter(|i| i.tenant_id == tenant_id)
588            .ok_or_else(|| not_found("Invalidation", inv_id))?
589            .clone();
590        drop(state);
591        let body = render_tenant_invalidation(&inv);
592        Ok(xml_response(StatusCode::OK, body, HeaderMap::new()))
593    }
594
595    pub(crate) fn list_invalidations_for_distribution_tenant(
596        &self,
597        route: &Route,
598    ) -> Result<AwsResponse, AwsServiceError> {
599        let tenant_id = route
600            .id
601            .as_deref()
602            .ok_or_else(|| invalid_argument("missing distribution tenant id"))?;
603        let state = self.state.read();
604        let account = state
605            .accounts
606            .get(DEFAULT_ACCOUNT)
607            .ok_or_else(|| not_found("DistributionTenant", tenant_id))?;
608        if !account.distribution_tenants.contains_key(tenant_id) {
609            return Err(not_found("DistributionTenant", tenant_id));
610        }
611        let mut items: Vec<&StoredTenantInvalidation> = account
612            .tenant_invalidations
613            .values()
614            .filter(|i| i.tenant_id == tenant_id)
615            .collect();
616        items.sort_by_key(|a| a.create_time);
617        let body = render_tenant_invalidation_list(&items);
618        drop(state);
619        Ok(xml_response(StatusCode::OK, body, HeaderMap::new()))
620    }
621}
622
623fn generate_tenant_id() -> String {
624    let raw = Uuid::new_v4().simple().to_string().to_uppercase();
625    format!("DT{}", &raw[..12])
626}
627
628fn generate_invalidation_id() -> String {
629    let raw = Uuid::new_v4().simple().to_string().to_uppercase();
630    format!("I{}", &raw[..13])
631}
632
633fn render_distribution_tenant(t: &StoredDistributionTenant) -> String {
634    let mut out = String::with_capacity(512);
635    out.push_str(XML_DECL);
636    out.push_str(&format!("<DistributionTenant xmlns=\"{NS}\">"));
637    push_tenant_inner(&mut out, t);
638    out.push_str("</DistributionTenant>");
639    out
640}
641
642fn push_tenant_inner(out: &mut String, t: &StoredDistributionTenant) {
643    out.push_str(&format!("<Id>{}</Id>", esc(&t.id)));
644    out.push_str(&format!(
645        "<DistributionId>{}</DistributionId>",
646        esc(&t.distribution_id)
647    ));
648    out.push_str(&format!("<Name>{}</Name>", esc(&t.name)));
649    out.push_str(&format!("<Arn>{}</Arn>", esc(&t.arn)));
650    out.push_str(&format!("<Status>{}</Status>", esc(&t.status)));
651    out.push_str(&format!("<Enabled>{}</Enabled>", t.enabled));
652    out.push_str(&format!(
653        "<CreatedTime>{}</CreatedTime>",
654        rfc3339(&t.created_time)
655    ));
656    out.push_str(&format!(
657        "<LastModifiedTime>{}</LastModifiedTime>",
658        rfc3339(&t.last_modified_time)
659    ));
660    if let Some(c) = &t.connection_group_id {
661        out.push_str(&format!(
662            "<ConnectionGroupId>{}</ConnectionGroupId>",
663            esc(c)
664        ));
665    }
666    out.push_str("<Domains>");
667    for d in &t.domains {
668        out.push_str("<DomainResult>");
669        out.push_str(&format!("<Domain>{}</Domain>", esc(d)));
670        out.push_str("<Status>active</Status>");
671        out.push_str("</DomainResult>");
672    }
673    out.push_str("</Domains>");
674    if !t.parameters.is_empty() {
675        out.push_str("<Parameters>");
676        for p in &t.parameters {
677            out.push_str("<Parameter>");
678            out.push_str(&format!("<Name>{}</Name>", esc(&p.name)));
679            out.push_str(&format!("<Value>{}</Value>", esc(&p.value)));
680            out.push_str("</Parameter>");
681        }
682        out.push_str("</Parameters>");
683    }
684    if let Some(c) = &t.customizations {
685        out.push_str("<Customizations>");
686        if let Some(w) = &c.web_acl {
687            out.push_str("<WebAcl>");
688            out.push_str(&format!("<Action>{}</Action>", esc(&w.action)));
689            if let Some(arn) = &w.arn {
690                out.push_str(&format!("<Arn>{}</Arn>", esc(arn)));
691            }
692            out.push_str("</WebAcl>");
693        }
694        if let Some(cert) = &c.certificate {
695            out.push_str("<Certificate>");
696            out.push_str(&format!("<Arn>{}</Arn>", esc(cert)));
697            out.push_str("</Certificate>");
698        }
699        if let Some(g) = &c.geo_restrictions {
700            out.push_str("<GeoRestrictions>");
701            out.push_str(&format!(
702                "<RestrictionType>{}</RestrictionType>",
703                esc(&g.restriction_type)
704            ));
705            out.push_str("<Locations>");
706            for l in &g.locations {
707                out.push_str(&format!("<Location>{}</Location>", esc(l)));
708            }
709            out.push_str("</Locations>");
710            out.push_str("</GeoRestrictions>");
711        }
712        out.push_str("</Customizations>");
713    }
714}
715
716fn convert_parameters(req: Option<ParametersReq>) -> Vec<TenantParameter> {
717    req.map(|p| {
718        p.parameter
719            .into_iter()
720            .map(|r| TenantParameter {
721                name: r.name,
722                value: r.value,
723            })
724            .collect()
725    })
726    .unwrap_or_default()
727}
728
729fn convert_customizations(req: CustomizationsReq) -> TenantCustomizations {
730    TenantCustomizations {
731        web_acl: req.web_acl.map(|w| TenantWebAclCustomization {
732            action: w.action,
733            arn: w.arn,
734        }),
735        certificate: req.certificate.map(|c| c.arn),
736        geo_restrictions: req
737            .geo_restrictions
738            .map(|g| TenantGeoRestrictionCustomization {
739                restriction_type: g.restriction_type,
740                locations: g.locations.map(|l| l.location).unwrap_or_default(),
741            }),
742    }
743}
744
745fn render_tenant_list(items: &[StoredDistributionTenant], wrapper: &str) -> String {
746    let mut out = String::with_capacity(512);
747    out.push_str(XML_DECL);
748    out.push_str(&format!("<{wrapper} xmlns=\"{NS}\">"));
749    out.push_str("<NextMarker></NextMarker>");
750    out.push_str("<DistributionTenantList>");
751    for t in items {
752        out.push_str("<DistributionTenantSummary>");
753        push_tenant_inner(&mut out, t);
754        out.push_str("</DistributionTenantSummary>");
755    }
756    out.push_str("</DistributionTenantList>");
757    out.push_str(&format!("</{wrapper}>"));
758    out
759}
760
761fn render_associate_web_acl(t: &StoredDistributionTenant) -> String {
762    let mut out = String::with_capacity(256);
763    out.push_str(XML_DECL);
764    out.push_str(&format!(
765        "<AssociateDistributionTenantWebACLResult xmlns=\"{NS}\">"
766    ));
767    out.push_str(&format!("<Id>{}</Id>", esc(&t.id)));
768    if let Some(arn) = &t.web_acl_arn {
769        out.push_str(&format!("<WebACLArn>{}</WebACLArn>", esc(arn)));
770    }
771    out.push_str("</AssociateDistributionTenantWebACLResult>");
772    out
773}
774
775fn render_disassociate_web_acl(t: &StoredDistributionTenant) -> String {
776    let mut out = String::with_capacity(256);
777    out.push_str(XML_DECL);
778    out.push_str(&format!(
779        "<DisassociateDistributionTenantWebACLResult xmlns=\"{NS}\">"
780    ));
781    out.push_str(&format!("<Id>{}</Id>", esc(&t.id)));
782    out.push_str("</DisassociateDistributionTenantWebACLResult>");
783    out
784}
785
786fn render_tenant_invalidation(inv: &StoredTenantInvalidation) -> String {
787    let mut out = String::with_capacity(512);
788    out.push_str(XML_DECL);
789    out.push_str(&format!("<Invalidation xmlns=\"{NS}\">"));
790    out.push_str(&format!("<Id>{}</Id>", esc(&inv.id)));
791    out.push_str(&format!("<Status>{}</Status>", esc(&inv.status)));
792    out.push_str(&format!(
793        "<CreateTime>{}</CreateTime>",
794        rfc3339(&inv.create_time)
795    ));
796    out.push_str("<InvalidationBatch>");
797    out.push_str(&format!(
798        "<CallerReference>{}</CallerReference>",
799        esc(&inv.caller_reference)
800    ));
801    out.push_str("<Paths>");
802    out.push_str(&format!("<Quantity>{}</Quantity>", inv.paths.len()));
803    out.push_str("<Items>");
804    for p in &inv.paths {
805        out.push_str(&format!("<Path>{}</Path>", esc(p)));
806    }
807    out.push_str("</Items>");
808    out.push_str("</Paths>");
809    out.push_str("</InvalidationBatch>");
810    out.push_str("</Invalidation>");
811    out
812}
813
814fn render_tenant_invalidation_list(items: &[&StoredTenantInvalidation]) -> String {
815    let mut out = String::with_capacity(1024);
816    out.push_str(XML_DECL);
817    out.push_str(&format!("<InvalidationList xmlns=\"{NS}\">"));
818    out.push_str("<Marker></Marker>");
819    out.push_str("<MaxItems>100</MaxItems>");
820    out.push_str("<IsTruncated>false</IsTruncated>");
821    out.push_str(&format!("<Quantity>{}</Quantity>", items.len()));
822    if !items.is_empty() {
823        out.push_str("<Items>");
824        for inv in items {
825            out.push_str("<InvalidationSummary>");
826            out.push_str(&format!("<Id>{}</Id>", esc(&inv.id)));
827            out.push_str(&format!(
828                "<CreateTime>{}</CreateTime>",
829                rfc3339(&inv.create_time)
830            ));
831            out.push_str(&format!("<Status>{}</Status>", esc(&inv.status)));
832            out.push_str("</InvalidationSummary>");
833        }
834        out.push_str("</Items>");
835    }
836    out.push_str("</InvalidationList>");
837    out
838}
839
840#[cfg(test)]
841mod tests {
842    use super::*;
843    use crate::state::CloudFrontAccounts;
844    use bytes::Bytes;
845    use fakecloud_core::service::AwsService;
846    use http::HeaderValue;
847    use parking_lot::RwLock;
848    use std::sync::Arc;
849
850    fn svc() -> CloudFrontService {
851        CloudFrontService::new(Arc::new(RwLock::new(CloudFrontAccounts::new())))
852    }
853
854    fn req(method: http::Method, path: &str, body: &str, if_match: Option<&str>) -> AwsRequest {
855        let mut headers = HeaderMap::new();
856        if let Some(v) = if_match {
857            headers.insert(http::header::IF_MATCH, HeaderValue::from_str(v).unwrap());
858        }
859        AwsRequest {
860            service: "cloudfront".into(),
861            action: String::new(),
862            region: "us-east-1".into(),
863            account_id: DEFAULT_ACCOUNT.into(),
864            request_id: Uuid::new_v4().to_string(),
865            headers,
866            query_params: std::collections::HashMap::new(),
867            body_stream: parking_lot::Mutex::new(None),
868            body: Bytes::from(body.to_string()),
869            path_segments: path
870                .split('/')
871                .filter(|s| !s.is_empty())
872                .map(String::from)
873                .collect(),
874            raw_path: path.into(),
875            raw_query: String::new(),
876            method,
877            is_query_protocol: false,
878            access_key_id: None,
879            principal: None,
880        }
881    }
882
883    fn body_str(resp: &AwsResponse) -> String {
884        match &resp.body {
885            fakecloud_core::service::ResponseBody::Bytes(b) => {
886                String::from_utf8(b.to_vec()).unwrap()
887            }
888            _ => panic!("expected bytes body"),
889        }
890    }
891
892    async fn create_tenant_full(svc: &CloudFrontService, name: &str) -> (String, String) {
893        let body = format!(
894            r#"<?xml version="1.0"?>
895<CreateDistributionTenantRequest xmlns="{NS}">
896  <DistributionId>E123</DistributionId>
897  <Name>{name}</Name>
898  <Parameters>
899    <Parameter><Name>tenantId</Name><Value>acme</Value></Parameter>
900  </Parameters>
901  <Customizations>
902    <Certificate><Arn>arn:aws:acm::cert/xyz</Arn></Certificate>
903    <GeoRestrictions>
904      <RestrictionType>whitelist</RestrictionType>
905      <Locations><Location>US</Location><Location>CA</Location></Locations>
906    </GeoRestrictions>
907  </Customizations>
908  <ManagedCertificateRequest>
909    <PrimaryDomainName>acme.example.com</PrimaryDomainName>
910    <ValidationTokenHost>self-hosted</ValidationTokenHost>
911  </ManagedCertificateRequest>
912  <Tags>
913    <Items><Tag><Key>env</Key><Value>prod</Value></Tag></Items>
914  </Tags>
915</CreateDistributionTenantRequest>"#
916        );
917        let resp = svc
918            .handle(req(
919                http::Method::POST,
920                "/2020-05-31/distribution-tenant",
921                &body,
922                None,
923            ))
924            .await
925            .unwrap();
926        assert_eq!(resp.status, StatusCode::CREATED);
927        let xml = body_str(&resp);
928        let id = xml
929            .split("<Id>")
930            .nth(1)
931            .unwrap()
932            .split("</Id>")
933            .next()
934            .unwrap()
935            .to_string();
936        let etag = resp
937            .headers
938            .get(http::header::ETAG)
939            .unwrap()
940            .to_str()
941            .unwrap()
942            .to_string();
943        (id, etag)
944    }
945
946    #[tokio::test]
947    async fn create_tenant_persists_parameters_customizations_and_tags() {
948        // Finding #6: Parameters/Customizations/ManagedCertificateRequest/Tags
949        // survive create and are echoed on Get.
950        let svc = svc();
951        let (id, _etag) = create_tenant_full(&svc, "acme-tenant").await;
952
953        let get = svc
954            .handle(req(
955                http::Method::GET,
956                &format!("/2020-05-31/distribution-tenant/{id}"),
957                "",
958                None,
959            ))
960            .await
961            .unwrap();
962        let xml = body_str(&get);
963        assert!(
964            xml.contains("<Name>tenantId</Name>") && xml.contains("<Value>acme</Value>"),
965            "parameters dropped: {xml}"
966        );
967        assert!(
968            xml.contains("<Certificate><Arn>arn:aws:acm::cert/xyz</Arn></Certificate>"),
969            "certificate customization dropped: {xml}"
970        );
971        assert!(
972            xml.contains("<RestrictionType>whitelist</RestrictionType>")
973                && xml.contains("<Location>US</Location>"),
974            "geo restrictions dropped: {xml}"
975        );
976
977        // Tags persisted for ListTagsForResource lookup.
978        let arn = format!(
979            "arn:aws:cloudfront::{}:distribution-tenant/{}",
980            DEFAULT_ACCOUNT, id
981        );
982        let state = svc.shared_state();
983        let guard = state.read();
984        let tags = guard
985            .get(DEFAULT_ACCOUNT)
986            .and_then(|a| a.tags.get(&arn))
987            .cloned()
988            .unwrap_or_default();
989        assert!(
990            tags.iter()
991                .any(|t| t.key == "env" && t.value.as_deref() == Some("prod")),
992            "tenant tags dropped: {tags:?}"
993        );
994        drop(guard);
995        // ManagedCertificateRequest is input-only: AWS accepts it (create did
996        // not fail) but never echoes it on the output shapes, so it must not
997        // appear in the GetDistributionTenant response.
998        assert!(
999            !xml.contains("ManagedCertificateRequest"),
1000            "ManagedCertificateRequest must not be echoed: {xml}"
1001        );
1002    }
1003
1004    #[tokio::test]
1005    async fn associate_web_acl_surfaces_on_get_distribution_tenant() {
1006        // Finding #5: WebACLArn is visible on GetDistributionTenant after
1007        // AssociateDistributionTenantWebACL.
1008        let svc = svc();
1009        let (id, etag) = create_tenant_full(&svc, "waf-tenant").await;
1010        let web_acl = "arn:aws:wafv2:us-east-1:000:global/webacl/x/1";
1011        let assoc_body = format!(
1012            r#"<?xml version="1.0"?>
1013<AssociateDistributionTenantWebACLRequest xmlns="{NS}">
1014  <WebACLArn>{web_acl}</WebACLArn>
1015</AssociateDistributionTenantWebACLRequest>"#
1016        );
1017        let assoc = svc
1018            .handle(req(
1019                http::Method::PUT,
1020                &format!("/2020-05-31/distribution-tenant/{id}/associate-web-acl"),
1021                &assoc_body,
1022                Some(&etag),
1023            ))
1024            .await
1025            .unwrap();
1026        assert_eq!(assoc.status, StatusCode::OK);
1027
1028        let get = svc
1029            .handle(req(
1030                http::Method::GET,
1031                &format!("/2020-05-31/distribution-tenant/{id}"),
1032                "",
1033                None,
1034            ))
1035            .await
1036            .unwrap();
1037        let xml = body_str(&get);
1038        assert!(
1039            xml.contains(&format!("<Arn>{web_acl}</Arn>")) && xml.contains("<WebAcl>"),
1040            "WebACL not surfaced on GetDistributionTenant: {xml}"
1041        );
1042    }
1043}