1use 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
25impl 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 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
237impl CloudFrontService {
240 pub(crate) fn list_domain_conflicts(
241 &self,
242 req: &AwsRequest,
243 ) -> Result<AwsResponse, AwsServiceError> {
244 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 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 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 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 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 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
461enum 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
479fn 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
490fn 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 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 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 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 assert!(
752 get_tenant_xml(&svc, &tid).await.contains("d.example.com"),
753 "domain should be unchanged after a rejected request"
754 );
755 }
756}