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