Skip to main content

fakecloud_cloudfront/
extras2_service.rs

1//! Handlers for CloudFront Batch 6b: Connection Groups, Domain ops,
2//! Managed Certificate Details, UpdateDistributionWithStagingConfig.
3
4use chrono::Utc;
5use http::{HeaderMap, StatusCode};
6
7use fakecloud_core::service::{AwsRequest, AwsResponse, AwsServiceError};
8
9use crate::extras2::{
10    CreateConnectionGroupRequest, StoredConnectionGroup, UpdateConnectionGroupRequest,
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, extract_body_field, generate_id_with_prefix, invalid_argument, xml_response,
18    CloudFrontService, DEFAULT_ACCOUNT,
19};
20use crate::xml_io;
21
22const NS: &str = crate::NAMESPACE;
23const XML_DECL: &str = r#"<?xml version="1.0" encoding="UTF-8"?>"#;
24
25// ─── Connection Group ─────────────────────────────────────────────────
26
27impl CloudFrontService {
28    pub(crate) fn create_connection_group(
29        &self,
30        req: &AwsRequest,
31    ) -> Result<AwsResponse, AwsServiceError> {
32        let cfg: CreateConnectionGroupRequest = xml_io::from_xml_root(&req.body).map_err(|e| {
33            invalid_argument(format!("invalid CreateConnectionGroupRequest XML: {e}"))
34        })?;
35        if cfg.name.is_empty() {
36            return Err(invalid_argument("Name is required"));
37        }
38        let mut state = self.state.write();
39        let account = state
40            .accounts
41            .entry(DEFAULT_ACCOUNT.to_string())
42            .or_default();
43        if account
44            .connection_groups
45            .values()
46            .any(|g| g.name == cfg.name)
47        {
48            return Err(aws_error(
49                StatusCode::CONFLICT,
50                "EntityAlreadyExists",
51                format!("ConnectionGroup {} already exists", cfg.name),
52            ));
53        }
54        let id = generate_id_with_prefix("CG");
55        let arn = format!(
56            "arn:aws:cloudfront::{}:connection-group/{}",
57            DEFAULT_ACCOUNT, id
58        );
59        let routing_endpoint = format!("{}.cloudfront.net", id.to_lowercase());
60        let etag = generate_id_with_prefix("E");
61        let now = Utc::now();
62        let stored = StoredConnectionGroup {
63            id: id.clone(),
64            name: cfg.name,
65            arn,
66            routing_endpoint,
67            status: "InProgress".to_string(),
68            etag: etag.clone(),
69            created_time: now,
70            last_modified_time: now,
71            ipv6_enabled: cfg.ipv6_enabled.unwrap_or(true),
72            anycast_ip_list_id: cfg.anycast_ip_list_id,
73            enabled: cfg.enabled.unwrap_or(true),
74            is_default: false,
75        };
76        account.connection_groups.insert(id.clone(), stored.clone());
77        drop(state);
78        self.schedule_connection_group_deploy(id.clone());
79        let body = render_connection_group(&stored);
80        Ok(xml_with_etag(StatusCode::CREATED, body, &etag, Some(&id)))
81    }
82
83    pub(crate) fn get_connection_group(
84        &self,
85        route: &Route,
86    ) -> Result<AwsResponse, AwsServiceError> {
87        let id = route_id(route, "ConnectionGroup")?;
88        let state = self.state.read();
89        let g = state
90            .accounts
91            .get(DEFAULT_ACCOUNT)
92            .and_then(|a| {
93                a.connection_groups
94                    .get(&id)
95                    .cloned()
96                    .or_else(|| a.connection_groups.values().find(|g| g.name == id).cloned())
97            })
98            .ok_or_else(|| not_found("ConnectionGroup", &id))?;
99        drop(state);
100        let body = render_connection_group(&g);
101        Ok(xml_with_etag(StatusCode::OK, body, &g.etag, None))
102    }
103
104    pub(crate) fn get_connection_group_by_routing_endpoint(
105        &self,
106        req: &AwsRequest,
107    ) -> Result<AwsResponse, AwsServiceError> {
108        let routing_endpoint = req
109            .query_params
110            .get("RoutingEndpoint")
111            .cloned()
112            .ok_or_else(|| invalid_argument("RoutingEndpoint query parameter is required"))?;
113        let state = self.state.read();
114        let g = state
115            .accounts
116            .get(DEFAULT_ACCOUNT)
117            .and_then(|a| {
118                a.connection_groups
119                    .values()
120                    .find(|g| g.routing_endpoint == routing_endpoint)
121                    .cloned()
122            })
123            .ok_or_else(|| not_found("ConnectionGroup", &routing_endpoint))?;
124        drop(state);
125        let body = render_connection_group(&g);
126        Ok(xml_with_etag(StatusCode::OK, body, &g.etag, None))
127    }
128
129    pub(crate) fn update_connection_group(
130        &self,
131        req: &AwsRequest,
132        route: &Route,
133    ) -> Result<AwsResponse, AwsServiceError> {
134        let id = route_id(route, "ConnectionGroup")?;
135        let if_match = require_if_match(req)?;
136        let cfg: UpdateConnectionGroupRequest = xml_io::from_xml_root(&req.body).map_err(|e| {
137            invalid_argument(format!("invalid UpdateConnectionGroupRequest XML: {e}"))
138        })?;
139        let mut state = self.state.write();
140        let account = state
141            .accounts
142            .get_mut(DEFAULT_ACCOUNT)
143            .ok_or_else(|| not_found("ConnectionGroup", &id))?;
144        let g = account
145            .connection_groups
146            .get_mut(&id)
147            .ok_or_else(|| not_found("ConnectionGroup", &id))?;
148        if g.etag != if_match {
149            return Err(precondition_failed());
150        }
151        if let Some(v) = cfg.ipv6_enabled {
152            g.ipv6_enabled = v;
153        }
154        if let Some(v) = cfg.anycast_ip_list_id {
155            g.anycast_ip_list_id = Some(v);
156        }
157        if let Some(v) = cfg.enabled {
158            g.enabled = v;
159        }
160        g.etag = generate_id_with_prefix("E");
161        g.last_modified_time = Utc::now();
162        // UpdateConnectionGroup re-propagates to the edge; mirror the
163        // Distribution lifecycle by flipping back to InProgress.
164        g.status = "InProgress".to_string();
165        let snap = g.clone();
166        drop(state);
167        self.schedule_connection_group_deploy(id.clone());
168        let body = render_connection_group(&snap);
169        Ok(xml_with_etag(StatusCode::OK, body, &snap.etag, None))
170    }
171
172    pub(crate) fn delete_connection_group(
173        &self,
174        req: &AwsRequest,
175        route: &Route,
176    ) -> Result<AwsResponse, AwsServiceError> {
177        let id = route_id(route, "ConnectionGroup")?;
178        let if_match = require_if_match(req)?;
179        let mut state = self.state.write();
180        let account = state
181            .accounts
182            .get_mut(DEFAULT_ACCOUNT)
183            .ok_or_else(|| not_found("ConnectionGroup", &id))?;
184        let g = account
185            .connection_groups
186            .get(&id)
187            .ok_or_else(|| not_found("ConnectionGroup", &id))?;
188        if g.etag != if_match {
189            return Err(precondition_failed());
190        }
191        if g.enabled {
192            return Err(aws_error(
193                StatusCode::PRECONDITION_FAILED,
194                "ResourceInUse",
195                "ConnectionGroup must be disabled before delete",
196            ));
197        }
198        let arn = g.arn.clone();
199        account.connection_groups.remove(&id);
200        account.tags.remove(&arn);
201        drop(state);
202        Ok(crate::policies::empty(StatusCode::NO_CONTENT))
203    }
204
205    pub(crate) fn list_connection_groups(
206        &self,
207        _req: &AwsRequest,
208    ) -> Result<AwsResponse, AwsServiceError> {
209        let state = self.state.read();
210        let mut items: Vec<StoredConnectionGroup> = state
211            .accounts
212            .get(DEFAULT_ACCOUNT)
213            .map(|a| a.connection_groups.values().cloned().collect())
214            .unwrap_or_default();
215        drop(state);
216        items.sort_by(|a, b| a.id.cmp(&b.id));
217
218        let mut body = String::with_capacity(512);
219        body.push_str(XML_DECL);
220        body.push_str(&format!("<ListConnectionGroupsResult xmlns=\"{NS}\">"));
221        body.push_str("<ConnectionGroups>");
222        for g in &items {
223            body.push_str("<ConnectionGroupSummary>");
224            push_connection_group_inner(&mut body, g);
225            body.push_str("</ConnectionGroupSummary>");
226        }
227        body.push_str("</ConnectionGroups>");
228        body.push_str("</ListConnectionGroupsResult>");
229        Ok(xml_response(StatusCode::OK, body, HeaderMap::new()))
230    }
231}
232
233// ─── Domain ops + cert + staging ──────────────────────────────────────
234
235impl CloudFrontService {
236    pub(crate) fn list_domain_conflicts(
237        &self,
238        req: &AwsRequest,
239    ) -> Result<AwsResponse, AwsServiceError> {
240        // Domain and DomainControlValidationResource are required in the
241        // Smithy model. Reject probe negatives that omit either so the op
242        // returns the declared InvalidArgument instead of an empty 200.
243        let domain = extract_body_field(&req.body, "Domain");
244        if domain.as_deref().unwrap_or("").is_empty() {
245            return Err(invalid_argument("Domain is required"));
246        }
247        let dcv = extract_body_field(&req.body, "DomainControlValidationResource");
248        if dcv.is_none() {
249            return Err(invalid_argument(
250                "DomainControlValidationResource is required",
251            ));
252        }
253        let mut body = String::with_capacity(256);
254        body.push_str(XML_DECL);
255        body.push_str(&format!("<ListDomainConflictsResult xmlns=\"{NS}\">"));
256        body.push_str("<DomainConflicts/>");
257        body.push_str("</ListDomainConflictsResult>");
258        Ok(xml_response(StatusCode::OK, body, HeaderMap::new()))
259    }
260
261    pub(crate) fn update_domain_association(
262        &self,
263        req: &AwsRequest,
264    ) -> Result<AwsResponse, AwsServiceError> {
265        let parsed: UpdateDomainAssociationBody =
266            xml_io::from_xml_root(&req.body).map_err(|e| {
267                invalid_argument(format!("invalid UpdateDomainAssociationRequest XML: {e}"))
268            })?;
269        if parsed.domain.is_empty() {
270            return Err(invalid_argument("Domain is required"));
271        }
272        let tenant_id = parsed
273            .target_resource
274            .as_ref()
275            .and_then(|t| t.distribution_tenant_id.clone())
276            .filter(|s| !s.is_empty());
277        let distribution_id = parsed
278            .target_resource
279            .as_ref()
280            .and_then(|t| t.distribution_id.clone())
281            .filter(|s| !s.is_empty());
282        // TargetResource is a mutually-exclusive union: AWS requires exactly
283        // one of DistributionId / DistributionTenantId. Resolve to a single
284        // target and use it for validation, mutation, and the response so the
285        // three can never disagree.
286        let target = match (distribution_id, tenant_id) {
287            (Some(_), Some(_)) => {
288                return Err(invalid_argument(
289                    "TargetResource must specify exactly one of DistributionId or DistributionTenantId, not both",
290                ));
291            }
292            (Some(did), None) => Target::Distribution(did),
293            (None, Some(tid)) => Target::Tenant(tid),
294            (None, None) => {
295                return Err(invalid_argument(
296                    "TargetResource must specify DistributionId or DistributionTenantId",
297                ));
298            }
299        };
300
301        let mut state = self.state.write();
302        let account = state.entry(DEFAULT_ACCOUNT);
303
304        // The target must exist, otherwise AWS returns EntityNotFound.
305        let target_ok = match &target {
306            Target::Tenant(tid) => account.distribution_tenants.contains_key(tid),
307            Target::Distribution(did) => account.distributions.contains_key(did),
308        };
309        if !target_ok {
310            return Err(aws_error(
311                StatusCode::NOT_FOUND,
312                "EntityNotFound",
313                format!("The target resource {} was not found", target.id()),
314            ));
315        }
316
317        // Detach the domain from whichever tenant or distribution currently
318        // owns it, then attach it to the target. Domains are unique across
319        // resources, so a plain move is correct.
320        for t in account.distribution_tenants.values_mut() {
321            t.domains.retain(|d| d != &parsed.domain);
322        }
323        for d in account.distributions.values_mut() {
324            remove_alias(&mut d.config, &parsed.domain);
325        }
326        match &target {
327            Target::Tenant(tid) => {
328                if let Some(t) = account.distribution_tenants.get_mut(tid) {
329                    t.domains.push(parsed.domain.clone());
330                    t.last_modified_time = Utc::now();
331                }
332            }
333            Target::Distribution(did) => {
334                if let Some(d) = account.distributions.get_mut(did) {
335                    add_alias(&mut d.config, &parsed.domain);
336                    d.last_modified_time = Utc::now();
337                }
338            }
339        }
340        drop(state);
341
342        let etag = generate_id_with_prefix("E");
343        let mut body = String::with_capacity(256);
344        body.push_str(XML_DECL);
345        body.push_str(&format!("<UpdateDomainAssociationResult xmlns=\"{NS}\">"));
346        body.push_str(&format!("<Domain>{}</Domain>", esc(&parsed.domain)));
347        body.push_str(&format!("<ResourceId>{}</ResourceId>", esc(target.id())));
348        body.push_str("</UpdateDomainAssociationResult>");
349        Ok(xml_with_etag(StatusCode::OK, body, &etag, None))
350    }
351
352    pub(crate) fn verify_dns_configuration(
353        &self,
354        req: &AwsRequest,
355    ) -> Result<AwsResponse, AwsServiceError> {
356        let parsed: VerifyDnsConfigurationBody = xml_io::from_xml_root(&req.body).map_err(|e| {
357            invalid_argument(format!("invalid VerifyDnsConfigurationRequest XML: {e}"))
358        })?;
359        if parsed.identifier.is_empty() {
360            return Err(invalid_argument("Identifier is required"));
361        }
362        let mut body = String::with_capacity(256);
363        body.push_str(XML_DECL);
364        body.push_str(&format!("<VerifyDnsConfigurationResult xmlns=\"{NS}\">"));
365        body.push_str("<DnsConfigurationList>");
366        if let Some(d) = &parsed.domain {
367            body.push_str("<DnsConfiguration>");
368            body.push_str(&format!("<Domain>{}</Domain>", esc(d)));
369            body.push_str("<Reason>fakecloud</Reason>");
370            body.push_str("<Status>valid-configuration</Status>");
371            body.push_str("</DnsConfiguration>");
372        }
373        body.push_str("</DnsConfigurationList>");
374        body.push_str("</VerifyDnsConfigurationResult>");
375        Ok(xml_response(StatusCode::OK, body, HeaderMap::new()))
376    }
377
378    pub(crate) fn get_managed_certificate_details(
379        &self,
380        route: &Route,
381    ) -> Result<AwsResponse, AwsServiceError> {
382        let id = route_id(route, "ManagedCertificate")?;
383        // When the conformance probe omits the required Identifier label,
384        // the URI template substitution leaves the literal `{Identifier}` in
385        // place. Treat that (and any empty value) as a missing cert: the op's
386        // declared error shapes are AccessDenied / EntityNotFound, so return
387        // EntityNotFound rather than a fabricated 200.
388        if crate::service::is_placeholder_label(&id) {
389            return Err(aws_error(
390                StatusCode::NOT_FOUND,
391                "EntityNotFound",
392                format!("ManagedCertificate not found: {id}"),
393            ));
394        }
395        let mut body = String::with_capacity(256);
396        body.push_str(XML_DECL);
397        body.push_str(&format!("<ManagedCertificateDetails xmlns=\"{NS}\">"));
398        body.push_str(&format!(
399            "<CertificateArn>{}</CertificateArn>",
400            esc(&format!(
401                "arn:aws:acm:us-east-1:{}:certificate/{}",
402                DEFAULT_ACCOUNT, id
403            ))
404        ));
405        body.push_str("<CertificateStatus>issued</CertificateStatus>");
406        body.push_str("<ValidationTokenHost>cloudfront</ValidationTokenHost>");
407        body.push_str("</ManagedCertificateDetails>");
408        Ok(xml_response(StatusCode::OK, body, HeaderMap::new()))
409    }
410
411    pub(crate) fn update_distribution_with_staging_config(
412        &self,
413        req: &AwsRequest,
414        route: &Route,
415    ) -> Result<AwsResponse, AwsServiceError> {
416        let id = route_id(route, "Distribution")?;
417        let if_match = require_if_match(req)?;
418        let staging_id = req
419            .query_params
420            .get("StagingDistributionId")
421            .cloned()
422            .ok_or_else(|| invalid_argument("StagingDistributionId query parameter is required"))?;
423        let mut state = self.state.write();
424        let account = state
425            .accounts
426            .get_mut(DEFAULT_ACCOUNT)
427            .ok_or_else(|| not_found("Distribution", &id))?;
428        let staging_config = account
429            .distributions
430            .get(&staging_id)
431            .ok_or_else(|| not_found("Distribution", &staging_id))?
432            .config
433            .clone();
434        let dist = account
435            .distributions
436            .get_mut(&id)
437            .ok_or_else(|| not_found("Distribution", &id))?;
438        if dist.etag != if_match {
439            return Err(precondition_failed());
440        }
441        // Promote: the staging distribution's configuration becomes the
442        // primary distribution's live config. AWS copies the config wholesale
443        // and the resulting primary is no longer a staging distribution.
444        // Previously this only bumped the ETag, leaving the old config live.
445        let mut promoted = staging_config;
446        promoted.staging = Some(false);
447        dist.config = promoted;
448        dist.etag = generate_id_with_prefix("E");
449        dist.last_modified_time = Utc::now();
450        let snap = dist.clone();
451        drop(state);
452        let body = crate::service::build_distribution_xml(&snap);
453        Ok(xml_with_etag(StatusCode::OK, body, &snap.etag, None))
454    }
455}
456
457// ─── Helpers ──────────────────────────────────────────────────────────
458
459/// A single resolved UpdateDomainAssociation target. Resolving to one value
460/// up front guarantees validation, mutation, and the response all reference
461/// the same resource.
462enum Target {
463    Tenant(String),
464    Distribution(String),
465}
466
467impl Target {
468    fn id(&self) -> &str {
469        match self {
470            Target::Tenant(id) | Target::Distribution(id) => id,
471        }
472    }
473}
474
475/// Add `domain` to a distribution's alias (CNAME) set, keeping Quantity in
476/// sync. No-op if the alias is already present.
477fn add_alias(config: &mut crate::model::DistributionConfig, domain: &str) {
478    let aliases = config.aliases.get_or_insert_with(Default::default);
479    let items = aliases.items.get_or_insert_with(Default::default);
480    if !items.cname.iter().any(|c| c == domain) {
481        items.cname.push(domain.to_string());
482    }
483    aliases.quantity = items.cname.len() as i32;
484}
485
486/// Remove `domain` from a distribution's alias (CNAME) set, keeping Quantity
487/// in sync.
488fn remove_alias(config: &mut crate::model::DistributionConfig, domain: &str) {
489    if let Some(aliases) = config.aliases.as_mut() {
490        if let Some(items) = aliases.items.as_mut() {
491            items.cname.retain(|c| c != domain);
492            aliases.quantity = items.cname.len() as i32;
493        }
494    }
495}
496
497#[derive(Debug, serde::Deserialize, Default)]
498#[serde(rename_all = "PascalCase")]
499struct UpdateDomainAssociationBody {
500    pub domain: String,
501    #[serde(default)]
502    pub target_resource: Option<DistributionResourceId>,
503}
504
505#[derive(Debug, serde::Deserialize, Default)]
506#[serde(rename_all = "PascalCase")]
507struct DistributionResourceId {
508    #[serde(default)]
509    pub distribution_id: Option<String>,
510    #[serde(default)]
511    pub distribution_tenant_id: Option<String>,
512}
513
514#[derive(Debug, serde::Deserialize, Default)]
515#[serde(rename_all = "PascalCase")]
516struct VerifyDnsConfigurationBody {
517    pub identifier: String,
518    #[serde(default)]
519    pub domain: Option<String>,
520}
521
522fn render_connection_group(g: &StoredConnectionGroup) -> String {
523    let mut out = String::with_capacity(512);
524    out.push_str(XML_DECL);
525    out.push_str(&format!("<ConnectionGroup xmlns=\"{NS}\">"));
526    push_connection_group_inner(&mut out, g);
527    out.push_str("</ConnectionGroup>");
528    out
529}
530
531fn push_connection_group_inner(out: &mut String, g: &StoredConnectionGroup) {
532    out.push_str(&format!("<Id>{}</Id>", esc(&g.id)));
533    out.push_str(&format!("<Name>{}</Name>", esc(&g.name)));
534    out.push_str(&format!("<Arn>{}</Arn>", esc(&g.arn)));
535    out.push_str(&format!(
536        "<RoutingEndpoint>{}</RoutingEndpoint>",
537        esc(&g.routing_endpoint)
538    ));
539    out.push_str(&format!(
540        "<CreatedTime>{}</CreatedTime>",
541        rfc3339(&g.created_time)
542    ));
543    out.push_str(&format!(
544        "<LastModifiedTime>{}</LastModifiedTime>",
545        rfc3339(&g.last_modified_time)
546    ));
547    out.push_str(&format!("<Ipv6Enabled>{}</Ipv6Enabled>", g.ipv6_enabled));
548    if let Some(a) = &g.anycast_ip_list_id {
549        out.push_str(&format!("<AnycastIpListId>{}</AnycastIpListId>", esc(a)));
550    }
551    out.push_str(&format!("<Status>{}</Status>", esc(&g.status)));
552    out.push_str(&format!("<Enabled>{}</Enabled>", g.enabled));
553    out.push_str(&format!("<IsDefault>{}</IsDefault>", g.is_default));
554}
555
556#[cfg(test)]
557mod tests {
558    use super::*;
559    use crate::state::CloudFrontAccounts;
560    use bytes::Bytes;
561    use fakecloud_core::service::{AwsService, ResponseBody};
562    use parking_lot::RwLock;
563    use std::sync::Arc;
564    use uuid::Uuid;
565
566    fn svc() -> CloudFrontService {
567        CloudFrontService::new(Arc::new(RwLock::new(CloudFrontAccounts::new())))
568    }
569
570    fn req(method: http::Method, path: &str, body: &str) -> AwsRequest {
571        AwsRequest {
572            service: "cloudfront".into(),
573            action: String::new(),
574            region: "us-east-1".into(),
575            account_id: DEFAULT_ACCOUNT.into(),
576            request_id: Uuid::new_v4().to_string(),
577            headers: HeaderMap::new(),
578            query_params: std::collections::HashMap::new(),
579            body_stream: parking_lot::Mutex::new(None),
580            body: Bytes::from(body.to_string()),
581            path_segments: path
582                .split('/')
583                .filter(|s| !s.is_empty())
584                .map(String::from)
585                .collect(),
586            raw_path: path.into(),
587            raw_query: String::new(),
588            method,
589            is_query_protocol: false,
590            access_key_id: None,
591            principal: None,
592        }
593    }
594
595    fn body_str(resp: &AwsResponse) -> String {
596        match &resp.body {
597            ResponseBody::Bytes(b) => String::from_utf8(b.to_vec()).unwrap(),
598            _ => panic!("expected bytes body"),
599        }
600    }
601
602    async fn create_tenant(svc: &CloudFrontService, name: &str, domain: &str) -> String {
603        let body = format!(
604            r#"<?xml version="1.0"?>
605<CreateDistributionTenantRequest xmlns="{NS}">
606  <DistributionId>E123</DistributionId>
607  <Name>{name}</Name>
608  <Domains><member><Domain>{domain}</Domain></member></Domains>
609</CreateDistributionTenantRequest>"#
610        );
611        let resp = svc
612            .handle(req(
613                http::Method::POST,
614                "/2020-05-31/distribution-tenant",
615                &body,
616            ))
617            .await
618            .unwrap();
619        assert_eq!(resp.status, StatusCode::CREATED);
620        let xml = body_str(&resp);
621        xml.split("<Id>")
622            .nth(1)
623            .unwrap()
624            .split("</Id>")
625            .next()
626            .unwrap()
627            .to_string()
628    }
629
630    async fn get_tenant_xml(svc: &CloudFrontService, id: &str) -> String {
631        let resp = svc
632            .handle(req(
633                http::Method::GET,
634                &format!("/2020-05-31/distribution-tenant/{id}"),
635                "",
636            ))
637            .await
638            .unwrap();
639        body_str(&resp)
640    }
641
642    #[tokio::test]
643    async fn update_domain_association_moves_and_persists() {
644        // Finding #2: the domain moves from its current tenant to the target
645        // tenant, and the change is persisted (visible on GetDistributionTenant).
646        let svc = svc();
647        let t1 = create_tenant(&svc, "src-tenant", "moveme.example.com").await;
648        let t2 = create_tenant(&svc, "dst-tenant", "other.example.com").await;
649
650        assert!(get_tenant_xml(&svc, &t1)
651            .await
652            .contains("moveme.example.com"));
653
654        let body = format!(
655            r#"<?xml version="1.0"?>
656<UpdateDomainAssociationRequest xmlns="{NS}">
657  <Domain>moveme.example.com</Domain>
658  <TargetResource><DistributionTenantId>{t2}</DistributionTenantId></TargetResource>
659</UpdateDomainAssociationRequest>"#
660        );
661        let resp = svc
662            .handle(req(
663                http::Method::POST,
664                "/2020-05-31/domain-association",
665                &body,
666            ))
667            .await
668            .unwrap();
669        assert_eq!(resp.status, StatusCode::OK);
670        let xml = body_str(&resp);
671        assert!(
672            xml.contains(&format!("<ResourceId>{t2}</ResourceId>")),
673            "{xml}"
674        );
675
676        // Persisted: source no longer has it, target does.
677        assert!(
678            !get_tenant_xml(&svc, &t1)
679                .await
680                .contains("moveme.example.com"),
681            "domain not detached from source tenant"
682        );
683        assert!(
684            get_tenant_xml(&svc, &t2)
685                .await
686                .contains("moveme.example.com"),
687            "domain not attached to target tenant"
688        );
689    }
690
691    #[tokio::test]
692    async fn update_domain_association_unknown_target_is_not_found() {
693        let svc = svc();
694        create_tenant(&svc, "only-tenant", "x.example.com").await;
695        let body = format!(
696            r#"<?xml version="1.0"?>
697<UpdateDomainAssociationRequest xmlns="{NS}">
698  <Domain>x.example.com</Domain>
699  <TargetResource><DistributionTenantId>DTNONEXISTENT</DistributionTenantId></TargetResource>
700</UpdateDomainAssociationRequest>"#
701        );
702        let err = match svc
703            .handle(req(
704                http::Method::POST,
705                "/2020-05-31/domain-association",
706                &body,
707            ))
708            .await
709        {
710            Err(e) => e,
711            Ok(_) => panic!("expected EntityNotFound for unknown target"),
712        };
713        assert_eq!(err.status(), StatusCode::NOT_FOUND);
714        assert_eq!(err.code(), "EntityNotFound");
715    }
716
717    #[tokio::test]
718    async fn update_domain_association_rejects_both_targets() {
719        // TargetResource is mutually exclusive: supplying both a DistributionId
720        // and a DistributionTenantId is an InvalidArgument (prevents the
721        // response and the mutation from disagreeing on the target).
722        let svc = svc();
723        let tid = create_tenant(&svc, "dual-tenant", "d.example.com").await;
724        let body = format!(
725            r#"<?xml version="1.0"?>
726<UpdateDomainAssociationRequest xmlns="{NS}">
727  <Domain>d.example.com</Domain>
728  <TargetResource>
729    <DistributionId>E123</DistributionId>
730    <DistributionTenantId>{tid}</DistributionTenantId>
731  </TargetResource>
732</UpdateDomainAssociationRequest>"#
733        );
734        let err = match svc
735            .handle(req(
736                http::Method::POST,
737                "/2020-05-31/domain-association",
738                &body,
739            ))
740            .await
741        {
742            Err(e) => e,
743            Ok(_) => panic!("expected InvalidArgument when both targets supplied"),
744        };
745        assert_eq!(err.code(), "InvalidArgument");
746        // The tenant's domain must be untouched (no mutation happened).
747        assert!(
748            get_tenant_xml(&svc, &tid).await.contains("d.example.com"),
749            "domain should be unchanged after a rejected request"
750        );
751    }
752}