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 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 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
233impl CloudFrontService {
236 pub(crate) fn list_domain_conflicts(
237 &self,
238 req: &AwsRequest,
239 ) -> Result<AwsResponse, AwsServiceError> {
240 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 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 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 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 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 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
457enum 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
475fn 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
486fn 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 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 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 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 assert!(
748 get_tenant_xml(&svc, &tid).await.contains("d.example.com"),
749 "domain should be unchanged after a rejected request"
750 );
751 }
752}