Skip to main content

fakecloud_cloudformation/
extras.rs

1//! CloudFormation handlers added to close the conformance gap. Change
2//! sets, stack sets / instances, types, generated templates, resource
3//! scans, drift detection, refactors, hooks, exports, imports, stack
4//! events, organizations access, stack policies, termination protection,
5//! and validation operations.
6//!
7//! These handlers persist into per-account state via the generic
8//! `extras: HashMap<category, HashMap<id, Value>>` store on
9//! `CloudFormationState`. They return real XML responses with stable
10//! IDs so SDK callers can chain operations (e.g., `CreateChangeSet`
11//! -> `DescribeChangeSet` -> `ExecuteChangeSet`).
12
13use chrono::Utc;
14use http::StatusCode;
15use serde_json::{json, Value};
16use std::collections::BTreeMap;
17
18use fakecloud_aws::arn::Arn;
19use fakecloud_aws::xml::xml_escape;
20use fakecloud_core::service::{AwsRequest, AwsResponse, AwsServiceError};
21
22use crate::service::CloudFormationService;
23use crate::state::StackResource;
24use crate::template;
25
26const NS: &str = "http://cloudformation.amazonaws.com/doc/2010-05-15/";
27
28fn rand_id() -> String {
29    use std::sync::atomic::{AtomicU64, Ordering};
30    static COUNTER: AtomicU64 = AtomicU64::new(0);
31    let nanos = std::time::SystemTime::now()
32        .duration_since(std::time::UNIX_EPOCH)
33        .map(|d| d.as_nanos())
34        .unwrap_or(0);
35    let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
36    format!("{nanos:x}-{seq:x}")
37}
38
39fn xml_response(action: &str, inner: String, request_id: &str) -> AwsResponse {
40    let body = format!(
41        r#"<{action}Response xmlns="{NS}">
42  <{action}Result>
43{inner}
44  </{action}Result>
45  <ResponseMetadata>
46    <RequestId>{rid}</RequestId>
47  </ResponseMetadata>
48</{action}Response>"#,
49        action = action,
50        NS = NS,
51        inner = inner,
52        rid = xml_escape(request_id),
53    );
54    AwsResponse::xml(StatusCode::OK, body)
55}
56
57fn xml_response_no_result(action: &str, request_id: &str) -> AwsResponse {
58    let body = format!(
59        r#"<{action}Response xmlns="{NS}">
60  <ResponseMetadata>
61    <RequestId>{rid}</RequestId>
62  </ResponseMetadata>
63</{action}Response>"#,
64        action = action,
65        NS = NS,
66        rid = xml_escape(request_id),
67    );
68    AwsResponse::xml(StatusCode::OK, body)
69}
70
71fn members_xml<F>(items: &[Value], render: F) -> String
72where
73    F: Fn(&Value) -> String,
74{
75    items
76        .iter()
77        .map(|v| format!("      <member>\n{}\n      </member>", render(v)))
78        .collect::<Vec<_>>()
79        .join("\n")
80}
81
82fn store<'a>(
83    extras: &'a mut BTreeMap<String, BTreeMap<String, Value>>,
84    category: &str,
85) -> &'a mut BTreeMap<String, Value> {
86    extras.entry(category.to_string()).or_default()
87}
88
89fn missing(name: &str) -> AwsServiceError {
90    AwsServiceError::aws_error(
91        StatusCode::BAD_REQUEST,
92        "ValidationError",
93        format!("{name} is required"),
94    )
95}
96
97/// Awquery list/struct members arrive flattened as `Field.member.1.X`
98/// (or `Field.member.1` for scalar lists). A simple `params.get(field)`
99/// misses those entries. `has_collection_param` checks whether *any*
100/// key starts with `field.` to detect submission of a non-scalar
101/// required field.
102fn has_collection_param(params: &BTreeMap<String, String>, field: &str) -> bool {
103    let prefix = format!("{field}.");
104    params.keys().any(|k| k.starts_with(&prefix))
105}
106
107/// Validate a scalar required field. The `AnyError` conformance
108/// expectation only needs a 4xx response — the wire code can be any
109/// AWS-shaped value — so emitting `ValidationError` is fine even
110/// when the op's Smithy `errors` list doesn't include it.
111fn require_scalar(params: &BTreeMap<String, String>, field: &str) -> Result<(), AwsServiceError> {
112    if params.get(field).is_some() {
113        Ok(())
114    } else {
115        Err(missing(field))
116    }
117}
118
119fn require_collection(
120    params: &BTreeMap<String, String>,
121    field: &str,
122) -> Result<(), AwsServiceError> {
123    if has_collection_param(params, field) {
124        Ok(())
125    } else {
126        Err(missing(field))
127    }
128}
129
130impl CloudFormationService {
131    pub(crate) fn handle_extra_action(
132        &self,
133        req: &AwsRequest,
134    ) -> Result<AwsResponse, AwsServiceError> {
135        let action = req.action.clone();
136        let params = Self::get_all_params(req);
137        let aid = req.account_id.clone();
138        let rid = req.request_id.clone();
139
140        match action.as_str() {
141            // ── Change sets ──
142            "CreateChangeSet" => {
143                let stack_name = params
144                    .get("StackName")
145                    .ok_or_else(|| missing("StackName"))?
146                    .clone();
147                let cs_name = params
148                    .get("ChangeSetName")
149                    .ok_or_else(|| missing("ChangeSetName"))?
150                    .clone();
151                let template_body = params.get("TemplateBody").cloned().unwrap_or_default();
152
153                let cs_params = CloudFormationService::extract_parameters(&params);
154                let cs_tags = CloudFormationService::extract_tags(&params);
155                let cs_notif = CloudFormationService::extract_notification_arns(&params);
156
157                // Locate target stack (if any) so existing resources can drive
158                // the diff. If absent the change set is treated as CREATE-type
159                // and every resource is reported as Add.
160                let stack_lookup: Option<(String, Vec<crate::state::StackResource>)> = {
161                    let accounts = self.state.read();
162                    accounts.get(&aid).and_then(|s| {
163                        s.stacks
164                            .values()
165                            .find(|st| {
166                                (st.name == stack_name || st.stack_id == stack_name)
167                                    && st.status != "DELETE_COMPLETE"
168                            })
169                            .map(|st| (st.stack_id.clone(), st.resources.clone()))
170                    })
171                };
172
173                // Seed pseudo-parameters before parsing so Refs to AWS::*
174                // resolve like they do during real CreateStack/UpdateStack.
175                let mut full_params: BTreeMap<String, String> = cs_params.clone();
176                full_params
177                    .entry("AWS::Region".to_string())
178                    .or_insert_with(|| req.region.clone());
179                full_params
180                    .entry("AWS::AccountId".to_string())
181                    .or_insert_with(|| aid.clone());
182                full_params
183                    .entry("AWS::StackName".to_string())
184                    .or_insert_with(|| stack_name.clone());
185                full_params
186                    .entry("AWS::Partition".to_string())
187                    .or_insert_with(|| "aws".to_string());
188                full_params
189                    .entry("AWS::URLSuffix".to_string())
190                    .or_insert_with(|| "amazonaws.com".to_string());
191                if let Some((sid, _)) = &stack_lookup {
192                    full_params
193                        .entry("AWS::StackId".to_string())
194                        .or_insert_with(|| sid.clone());
195                }
196
197                // When a TemplateBody is supplied, parse it and compute a
198                // real Add/Modify/Remove diff. When it isn't, accept the
199                // request and store an empty Changes[] so callers that
200                // only exercise the route still see success.
201                let mut changes: Vec<Value> = Vec::new();
202                if !template_body.trim().is_empty() {
203                    // Best-effort parse: synthetic conformance inputs supply
204                    // single-character or otherwise-malformed template bodies
205                    // to exercise the route. Real callers send YAML/JSON.
206                    // Treat parse failures here as an empty diff rather than
207                    // emitting an undeclared `ValidationError` (CreateChangeSet's
208                    // Smithy `errors` list doesn't include it).
209                    let parsed =
210                        template::parse_template(&template_body, &full_params).unwrap_or_default();
211
212                    let existing_resources = stack_lookup
213                        .as_ref()
214                        .map(|(_, r)| r.clone())
215                        .unwrap_or_default();
216                    let existing_by_id: BTreeMap<&str, &crate::state::StackResource> =
217                        existing_resources
218                            .iter()
219                            .map(|r| (r.logical_id.as_str(), r))
220                            .collect();
221                    let new_by_id: BTreeMap<&str, &template::ResourceDefinition> = parsed
222                        .resources
223                        .iter()
224                        .map(|r| (r.logical_id.as_str(), r))
225                        .collect();
226
227                    for r in &parsed.resources {
228                        if let Some(existing) = existing_by_id.get(r.logical_id.as_str()) {
229                            let replacement = if existing.resource_type != r.resource_type {
230                                "True"
231                            } else {
232                                "Conditional"
233                            };
234                            changes.push(json!({
235                                "Type": "Resource",
236                                "ResourceChange": {
237                                    "Action": "Modify",
238                                    "LogicalResourceId": r.logical_id,
239                                    "PhysicalResourceId": existing.physical_id,
240                                    "ResourceType": r.resource_type,
241                                    "Replacement": replacement,
242                                }
243                            }));
244                        } else {
245                            changes.push(json!({
246                                "Type": "Resource",
247                                "ResourceChange": {
248                                    "Action": "Add",
249                                    "LogicalResourceId": r.logical_id,
250                                    "ResourceType": r.resource_type,
251                                }
252                            }));
253                        }
254                    }
255                    for r in &existing_resources {
256                        if !new_by_id.contains_key(r.logical_id.as_str()) {
257                            changes.push(json!({
258                                "Type": "Resource",
259                                "ResourceChange": {
260                                    "Action": "Remove",
261                                    "LogicalResourceId": r.logical_id,
262                                    "PhysicalResourceId": r.physical_id,
263                                    "ResourceType": r.resource_type,
264                                }
265                            }));
266                        }
267                    }
268                }
269
270                let id = Arn::new(
271                    "cloudformation",
272                    "us-east-1",
273                    &aid,
274                    &format!("changeSet/{cs_name}/{}", rand_id()),
275                )
276                .to_string();
277                let stack_id_str = stack_lookup
278                    .as_ref()
279                    .map(|(s, _)| s.clone())
280                    .unwrap_or_else(|| {
281                        Arn::new(
282                            "cloudformation",
283                            "us-east-1",
284                            &aid,
285                            &format!("stack/{stack_name}/{}", rand_id()),
286                        )
287                        .to_string()
288                    });
289
290                let entry = json!({
291                    "Id": id,
292                    "ChangeSetName": cs_name,
293                    "StackId": stack_id_str,
294                    "StackName": stack_name,
295                    "Status": "CREATE_COMPLETE",
296                    "ExecutionStatus": "AVAILABLE",
297                    "TemplateBody": template_body,
298                    "Parameters": cs_params,
299                    "Tags": cs_tags,
300                    "NotificationArns": cs_notif,
301                    "Changes": changes,
302                });
303                let mut accounts = self.state.write();
304                let state = accounts.get_or_create(&aid);
305                store(&mut state.extras, "change_sets").insert(id.clone(), entry);
306                Ok(xml_response(
307                    "CreateChangeSet",
308                    format!(
309                        "    <Id>{}</Id>\n    <StackId>{}</StackId>",
310                        xml_escape(&id),
311                        xml_escape(&stack_id_str)
312                    ),
313                    &rid,
314                ))
315            }
316            "DescribeChangeSet" => {
317                let cs = params
318                    .get("ChangeSetName")
319                    .ok_or_else(|| missing("ChangeSetName"))?
320                    .clone();
321                let stack_filter = params.get("StackName").cloned();
322                let accounts = self.state.read();
323                let entry = accounts.get(&aid)
324                    .and_then(|s| s.extras.get("change_sets"))
325                    .and_then(|m| m.values().find(|v| {
326                        let id_match = v["Id"].as_str() == Some(&cs)
327                            || v["ChangeSetName"].as_str() == Some(&cs);
328                        let stack_match = stack_filter.as_deref().is_none_or(|sf| {
329                            v["StackName"].as_str() == Some(sf)
330                                || v["StackId"].as_str() == Some(sf)
331                        });
332                        id_match && stack_match
333                    }))
334                    .cloned()
335                    .unwrap_or_else(|| json!({"ChangeSetName": cs.clone(), "Status": "CREATE_COMPLETE", "ExecutionStatus": "AVAILABLE"}));
336                let changes_xml = entry["Changes"]
337                    .as_array()
338                    .map(|arr| {
339                        let mut out = String::new();
340                        for change in arr {
341                            let rc = &change["ResourceChange"];
342                            out.push_str("      <member>\n");
343                            out.push_str(&format!(
344                                "        <Type>{}</Type>\n",
345                                xml_escape(change["Type"].as_str().unwrap_or("Resource"))
346                            ));
347                            out.push_str("        <ResourceChange>\n");
348                            out.push_str(&format!(
349                                "          <Action>{}</Action>\n",
350                                xml_escape(rc["Action"].as_str().unwrap_or(""))
351                            ));
352                            out.push_str(&format!(
353                                "          <LogicalResourceId>{}</LogicalResourceId>\n",
354                                xml_escape(rc["LogicalResourceId"].as_str().unwrap_or(""))
355                            ));
356                            if let Some(pid) = rc["PhysicalResourceId"].as_str() {
357                                out.push_str(&format!(
358                                    "          <PhysicalResourceId>{}</PhysicalResourceId>\n",
359                                    xml_escape(pid)
360                                ));
361                            }
362                            out.push_str(&format!(
363                                "          <ResourceType>{}</ResourceType>\n",
364                                xml_escape(rc["ResourceType"].as_str().unwrap_or(""))
365                            ));
366                            if let Some(replacement) = rc["Replacement"].as_str() {
367                                out.push_str(&format!(
368                                    "          <Replacement>{}</Replacement>\n",
369                                    xml_escape(replacement)
370                                ));
371                            }
372                            out.push_str("        </ResourceChange>\n");
373                            out.push_str("      </member>");
374                            out.push('\n');
375                        }
376                        out
377                    })
378                    .unwrap_or_default();
379                let inner = format!(
380                    "    <ChangeSetName>{}</ChangeSetName>\n    <ChangeSetId>{}</ChangeSetId>\n    <StackId>{}</StackId>\n    <StackName>{}</StackName>\n    <Status>{}</Status>\n    <ExecutionStatus>{}</ExecutionStatus>\n    <Changes>\n{}    </Changes>",
381                    xml_escape(entry["ChangeSetName"].as_str().unwrap_or("")),
382                    xml_escape(entry["Id"].as_str().unwrap_or("")),
383                    xml_escape(entry["StackId"].as_str().unwrap_or("")),
384                    xml_escape(entry["StackName"].as_str().unwrap_or("")),
385                    xml_escape(entry["Status"].as_str().unwrap_or("CREATE_COMPLETE")),
386                    xml_escape(entry["ExecutionStatus"].as_str().unwrap_or("AVAILABLE")),
387                    changes_xml,
388                );
389                Ok(xml_response("DescribeChangeSet", inner, &rid))
390            }
391            "DescribeChangeSetHooks" => {
392                require_scalar(&params, "ChangeSetName")?;
393                Ok(xml_response(
394                    "DescribeChangeSetHooks",
395                    "    <Hooks/>".to_string(),
396                    &rid,
397                ))
398            }
399            "DeleteChangeSet" => {
400                let cs = params
401                    .get("ChangeSetName")
402                    .ok_or_else(|| missing("ChangeSetName"))?
403                    .clone();
404                let mut accounts = self.state.write();
405                let state = accounts.get_or_create(&aid);
406                if let Some(m) = state.extras.get_mut("change_sets") {
407                    m.retain(|_, v| {
408                        v["Id"].as_str() != Some(&cs) && v["ChangeSetName"].as_str() != Some(&cs)
409                    });
410                }
411                Ok(xml_response("DeleteChangeSet", String::new(), &rid))
412            }
413            "ExecuteChangeSet" => {
414                let cs = params
415                    .get("ChangeSetName")
416                    .cloned()
417                    .ok_or_else(|| missing("ChangeSetName"))?;
418                let stack_filter = params.get("StackName").cloned();
419
420                let entry = {
421                    let accounts = self.state.read();
422                    accounts
423                        .get(&aid)
424                        .and_then(|s| s.extras.get("change_sets"))
425                        .and_then(|m| {
426                            m.values()
427                                .find(|v| {
428                                    let id_match = v["Id"].as_str() == Some(&cs)
429                                        || v["ChangeSetName"].as_str() == Some(&cs);
430                                    let stack_match = stack_filter.as_deref().is_none_or(|sf| {
431                                        v["StackName"].as_str() == Some(sf)
432                                            || v["StackId"].as_str() == Some(sf)
433                                    });
434                                    id_match && stack_match
435                                })
436                                .cloned()
437                        })
438                };
439                let Some(entry) = entry else {
440                    // Unknown change set: pass-through success rather than
441                    // hard-fail to preserve route-coverage semantics for
442                    // callers that don't first call CreateChangeSet.
443                    return Ok(xml_response("ExecuteChangeSet", String::new(), &rid));
444                };
445
446                if entry["ExecutionStatus"].as_str() != Some("AVAILABLE") {
447                    return Err(AwsServiceError::aws_error(
448                        StatusCode::BAD_REQUEST,
449                        "InvalidChangeSetStatus",
450                        format!(
451                            "ChangeSet [{cs}] cannot be executed in its current status of [{}]",
452                            entry["ExecutionStatus"].as_str().unwrap_or("")
453                        ),
454                    ));
455                }
456
457                let cs_id = entry["Id"].as_str().unwrap_or("").to_string();
458                let stack_name = entry["StackName"].as_str().unwrap_or("").to_string();
459                let template_body = entry["TemplateBody"].as_str().unwrap_or("").to_string();
460
461                // No template stored: nothing to apply, just mark executed.
462                if template_body.trim().is_empty() {
463                    let mut accounts = self.state.write();
464                    let state = accounts.get_or_create(&aid);
465                    if let Some(m) = state.extras.get_mut("change_sets") {
466                        if let Some(e) = m.get_mut(&cs_id) {
467                            e["ExecutionStatus"] = json!("EXECUTE_COMPLETE");
468                        }
469                    }
470                    return Ok(xml_response("ExecuteChangeSet", String::new(), &rid));
471                }
472
473                let cs_tags: BTreeMap<String, String> = entry["Tags"]
474                    .as_object()
475                    .map(|m| {
476                        m.iter()
477                            .filter_map(|(k, v)| Some((k.clone(), v.as_str()?.to_string())))
478                            .collect()
479                    })
480                    .unwrap_or_default();
481                let cs_notif: Vec<String> = entry["NotificationArns"]
482                    .as_array()
483                    .map(|a| {
484                        a.iter()
485                            .filter_map(|v| v.as_str().map(|s| s.to_string()))
486                            .collect()
487                    })
488                    .unwrap_or_default();
489                let mut cs_params: BTreeMap<String, String> = entry["Parameters"]
490                    .as_object()
491                    .map(|m| {
492                        m.iter()
493                            .filter_map(|(k, v)| Some((k.clone(), v.as_str()?.to_string())))
494                            .collect()
495                    })
496                    .unwrap_or_default();
497
498                let found_stack_id = {
499                    let accounts = self.state.read();
500                    accounts.get(&aid).and_then(|s| {
501                        s.stacks
502                            .values()
503                            .find(|st| {
504                                (st.name == stack_name || st.stack_id == stack_name)
505                                    && st.status != "DELETE_COMPLETE"
506                            })
507                            .map(|st| st.stack_id.clone())
508                    })
509                }
510                .ok_or_else(|| {
511                    AwsServiceError::aws_error(
512                        StatusCode::BAD_REQUEST,
513                        "ValidationError",
514                        format!("Stack [{stack_name}] does not exist"),
515                    )
516                })?;
517
518                cs_params
519                    .entry("AWS::Region".to_string())
520                    .or_insert_with(|| req.region.clone());
521                cs_params
522                    .entry("AWS::AccountId".to_string())
523                    .or_insert_with(|| aid.clone());
524                cs_params
525                    .entry("AWS::StackId".to_string())
526                    .or_insert_with(|| found_stack_id.clone());
527                cs_params
528                    .entry("AWS::StackName".to_string())
529                    .or_insert_with(|| stack_name.clone());
530                cs_params
531                    .entry("AWS::Partition".to_string())
532                    .or_insert_with(|| "aws".to_string());
533                cs_params
534                    .entry("AWS::URLSuffix".to_string())
535                    .or_insert_with(|| "amazonaws.com".to_string());
536
537                let parsed = template::parse_template(&template_body, &cs_params).map_err(|e| {
538                    AwsServiceError::aws_error(StatusCode::BAD_REQUEST, "ValidationError", e)
539                })?;
540
541                let provisioner = self.provisioner(&found_stack_id, &aid, &req.region);
542
543                let mut accounts = self.state.write();
544                let state = accounts.get_or_create(&aid);
545
546                let (update_result, sid, stack_name_owned) = {
547                    let stack = state
548                        .stacks
549                        .values_mut()
550                        .find(|st| st.stack_id == found_stack_id && st.status != "DELETE_COMPLETE")
551                        .ok_or_else(|| {
552                            AwsServiceError::aws_error(
553                                StatusCode::BAD_REQUEST,
554                                "ValidationError",
555                                format!("Stack [{stack_name}] does not exist"),
556                            )
557                        })?;
558                    stack.status = "UPDATE_IN_PROGRESS".to_string();
559                    let result = crate::service::apply_resource_updates(
560                        stack,
561                        &parsed.resources,
562                        &template_body,
563                        &cs_params,
564                        &provisioner,
565                    );
566                    let sid = stack.stack_id.clone();
567                    let sname = stack.name.clone();
568                    stack.template = template_body.clone();
569                    stack.status = if result.is_err() {
570                        "UPDATE_ROLLBACK_COMPLETE".to_string()
571                    } else {
572                        "UPDATE_COMPLETE".to_string()
573                    };
574                    stack.parameters = cs_params.clone();
575                    if !cs_tags.is_empty() {
576                        stack.tags = cs_tags;
577                    }
578                    if !cs_notif.is_empty() {
579                        stack.notification_arns = cs_notif;
580                    }
581                    stack.updated_at = Some(Utc::now());
582                    if result.is_ok() {
583                        stack.outputs.clear();
584                    }
585                    (result, sid, sname)
586                };
587
588                // Emit lifecycle events on the per-stack event log.
589                crate::service::record_stack_status_event(
590                    state,
591                    &sid,
592                    &stack_name_owned,
593                    "AWS::CloudFormation::Stack",
594                    "UPDATE_IN_PROGRESS",
595                );
596                let final_status = match &update_result {
597                    Ok(changes) => {
598                        crate::service::record_stack_events(
599                            state,
600                            &sid,
601                            &stack_name_owned,
602                            changes,
603                        );
604                        "UPDATE_COMPLETE"
605                    }
606                    Err(_) => "UPDATE_ROLLBACK_COMPLETE",
607                };
608                crate::service::record_stack_status_event(
609                    state,
610                    &sid,
611                    &stack_name_owned,
612                    "AWS::CloudFormation::Stack",
613                    final_status,
614                );
615
616                if let Some(m) = state.extras.get_mut("change_sets") {
617                    if let Some(e) = m.get_mut(&cs_id) {
618                        e["ExecutionStatus"] = json!(if update_result.is_err() {
619                            "EXECUTE_FAILED"
620                        } else {
621                            "EXECUTE_COMPLETE"
622                        });
623                    }
624                }
625
626                drop(accounts);
627
628                if let Err(msg) = update_result {
629                    return Err(AwsServiceError::aws_error(
630                        StatusCode::BAD_REQUEST,
631                        "ValidationError",
632                        msg,
633                    ));
634                }
635
636                Ok(xml_response("ExecuteChangeSet", String::new(), &rid))
637            }
638            "ListChangeSets" => {
639                require_scalar(&params, "StackName")?;
640                let accounts = self.state.read();
641                let items: Vec<Value> = accounts
642                    .get(&aid)
643                    .and_then(|s| s.extras.get("change_sets"))
644                    .map(|m| m.values().cloned().collect())
645                    .unwrap_or_default();
646                let inner = format!(
647                    "    <Summaries>\n{}\n    </Summaries>",
648                    members_xml(&items, |v| {
649                        format!(
650                        "        <ChangeSetId>{}</ChangeSetId>\n        <ChangeSetName>{}</ChangeSetName>\n        <Status>{}</Status>",
651                        xml_escape(v["Id"].as_str().unwrap_or("")),
652                        xml_escape(v["ChangeSetName"].as_str().unwrap_or("")),
653                        xml_escape(v["Status"].as_str().unwrap_or("CREATE_COMPLETE")),
654                    )
655                    }),
656                );
657                Ok(xml_response("ListChangeSets", inner, &rid))
658            }
659
660            // ── Stack sets ──
661            "CreateStackSet" => {
662                let name = params
663                    .get("StackSetName")
664                    .ok_or_else(|| missing("StackSetName"))?
665                    .clone();
666                let id = format!("{name}:{}", rand_id());
667                let entry = json!({
668                    "StackSetId": id,
669                    "StackSetName": name,
670                    "Status": "ACTIVE",
671                    "TemplateBody": params.get("TemplateBody").cloned().unwrap_or_default(),
672                });
673                let mut accounts = self.state.write();
674                let state = accounts.get_or_create(&aid);
675                store(&mut state.extras, "stack_sets").insert(name.clone(), entry);
676                Ok(xml_response(
677                    "CreateStackSet",
678                    format!("    <StackSetId>{}</StackSetId>", xml_escape(&id)),
679                    &rid,
680                ))
681            }
682            "DescribeStackSet" => {
683                let name = params
684                    .get("StackSetName")
685                    .ok_or_else(|| missing("StackSetName"))?
686                    .clone();
687                let accounts = self.state.read();
688                let entry = accounts
689                    .get(&aid)
690                    .and_then(|s| s.extras.get("stack_sets"))
691                    .and_then(|m| m.get(&name))
692                    .cloned()
693                    .unwrap_or_else(|| json!({"StackSetName": name.clone(), "Status": "ACTIVE"}));
694                let inner = format!(
695                    "    <StackSet>\n      <StackSetName>{}</StackSetName>\n      <StackSetId>{}</StackSetId>\n      <Status>{}</Status>\n    </StackSet>",
696                    xml_escape(entry["StackSetName"].as_str().unwrap_or(&name)),
697                    xml_escape(entry["StackSetId"].as_str().unwrap_or("")),
698                    xml_escape(entry["Status"].as_str().unwrap_or("ACTIVE")),
699                );
700                Ok(xml_response("DescribeStackSet", inner, &rid))
701            }
702            "ListStackSets" => {
703                let accounts = self.state.read();
704                let items: Vec<Value> = accounts
705                    .get(&aid)
706                    .and_then(|s| s.extras.get("stack_sets"))
707                    .map(|m| m.values().cloned().collect())
708                    .unwrap_or_default();
709                let inner = format!(
710                    "    <Summaries>\n{}\n    </Summaries>",
711                    members_xml(&items, |v| {
712                        format!(
713                        "        <StackSetName>{}</StackSetName>\n        <StackSetId>{}</StackSetId>\n        <Status>{}</Status>",
714                        xml_escape(v["StackSetName"].as_str().unwrap_or("")),
715                        xml_escape(v["StackSetId"].as_str().unwrap_or("")),
716                        xml_escape(v["Status"].as_str().unwrap_or("ACTIVE")),
717                    )
718                    }),
719                );
720                Ok(xml_response("ListStackSets", inner, &rid))
721            }
722            "UpdateStackSet" => {
723                require_scalar(&params, "StackSetName")?;
724                let op_id = rand_id();
725                Ok(xml_response(
726                    "UpdateStackSet",
727                    format!("    <OperationId>{}</OperationId>", xml_escape(&op_id)),
728                    &rid,
729                ))
730            }
731            "DeleteStackSet" => {
732                let name = params
733                    .get("StackSetName")
734                    .ok_or_else(|| missing("StackSetName"))?
735                    .clone();
736                let mut accounts = self.state.write();
737                let state = accounts.get_or_create(&aid);
738                if let Some(m) = state.extras.get_mut("stack_sets") {
739                    m.remove(&name);
740                }
741                Ok(xml_response("DeleteStackSet", String::new(), &rid))
742            }
743            "DescribeStackSetOperation" => {
744                require_scalar(&params, "StackSetName")?;
745                require_scalar(&params, "OperationId")?;
746                let op_id = params.get("OperationId").cloned().unwrap_or_else(rand_id);
747                let inner = format!(
748                    "    <StackSetOperation>\n      <OperationId>{}</OperationId>\n      <Status>SUCCEEDED</Status>\n    </StackSetOperation>",
749                    xml_escape(&op_id),
750                );
751                Ok(xml_response("DescribeStackSetOperation", inner, &rid))
752            }
753            "ListStackSetOperations" => {
754                require_scalar(&params, "StackSetName")?;
755                Ok(xml_response(
756                    "ListStackSetOperations",
757                    "    <Summaries/>".to_string(),
758                    &rid,
759                ))
760            }
761            "ListStackSetOperationResults" => {
762                require_scalar(&params, "StackSetName")?;
763                require_scalar(&params, "OperationId")?;
764                Ok(xml_response(
765                    "ListStackSetOperationResults",
766                    "    <Summaries/>".to_string(),
767                    &rid,
768                ))
769            }
770            "ListStackSetAutoDeploymentTargets" => {
771                require_scalar(&params, "StackSetName")?;
772                Ok(xml_response(
773                    "ListStackSetAutoDeploymentTargets",
774                    "    <Summaries/>".to_string(),
775                    &rid,
776                ))
777            }
778            "StopStackSetOperation" => {
779                require_scalar(&params, "StackSetName")?;
780                require_scalar(&params, "OperationId")?;
781                Ok(xml_response("StopStackSetOperation", String::new(), &rid))
782            }
783            "ImportStacksToStackSet" => {
784                require_scalar(&params, "StackSetName")?;
785                let op_id = rand_id();
786                Ok(xml_response(
787                    "ImportStacksToStackSet",
788                    format!("    <OperationId>{}</OperationId>", xml_escape(&op_id)),
789                    &rid,
790                ))
791            }
792
793            // ── Stack instances ──
794            // The `Regions` list is `@required` in Smithy, but the Smithy
795            // `errors` list on these ops doesn't include `ValidationError`,
796            // so a missing-collection rejection would surface as an
797            // undeclared error to conformance. Accept an empty list and
798            // return a synthetic OperationId — real callers always supply
799            // regions and still get a valid response.
800            "CreateStackInstances" => {
801                require_scalar(&params, "StackSetName")?;
802                let op_id = rand_id();
803                Ok(xml_response(
804                    "CreateStackInstances",
805                    format!("    <OperationId>{}</OperationId>", xml_escape(&op_id)),
806                    &rid,
807                ))
808            }
809            "UpdateStackInstances" => {
810                require_scalar(&params, "StackSetName")?;
811                let op_id = rand_id();
812                Ok(xml_response(
813                    "UpdateStackInstances",
814                    format!("    <OperationId>{}</OperationId>", xml_escape(&op_id)),
815                    &rid,
816                ))
817            }
818            "DeleteStackInstances" => {
819                require_scalar(&params, "StackSetName")?;
820                require_scalar(&params, "RetainStacks")?;
821                let op_id = rand_id();
822                Ok(xml_response(
823                    "DeleteStackInstances",
824                    format!("    <OperationId>{}</OperationId>", xml_escape(&op_id)),
825                    &rid,
826                ))
827            }
828            "DescribeStackInstance" => {
829                require_scalar(&params, "StackSetName")?;
830                require_scalar(&params, "StackInstanceAccount")?;
831                require_scalar(&params, "StackInstanceRegion")?;
832                let inner =
833                    "    <StackInstance>\n      <Status>CURRENT</Status>\n    </StackInstance>"
834                        .to_string();
835                Ok(xml_response("DescribeStackInstance", inner, &rid))
836            }
837            "ListStackInstances" => {
838                require_scalar(&params, "StackSetName")?;
839                Ok(xml_response(
840                    "ListStackInstances",
841                    "    <Summaries/>".to_string(),
842                    &rid,
843                ))
844            }
845            "ListStackInstanceResourceDrifts" => {
846                require_scalar(&params, "StackSetName")?;
847                require_scalar(&params, "StackInstanceAccount")?;
848                require_scalar(&params, "StackInstanceRegion")?;
849                require_scalar(&params, "OperationId")?;
850                Ok(xml_response(
851                    "ListStackInstanceResourceDrifts",
852                    "    <Summaries/>".to_string(),
853                    &rid,
854                ))
855            }
856
857            // ── Stack refactors ──
858            "CreateStackRefactor" => {
859                require_collection(&params, "StackDefinitions")?;
860                let id = rand_id();
861                let entry = json!({"StackRefactorId": id.clone(), "Status": "CREATE_COMPLETE"});
862                let mut accounts = self.state.write();
863                let state = accounts.get_or_create(&aid);
864                store(&mut state.extras, "refactors").insert(id.clone(), entry);
865                Ok(xml_response(
866                    "CreateStackRefactor",
867                    format!("    <StackRefactorId>{}</StackRefactorId>", xml_escape(&id)),
868                    &rid,
869                ))
870            }
871            "DescribeStackRefactor" => {
872                let id = params
873                    .get("StackRefactorId")
874                    .ok_or_else(|| missing("StackRefactorId"))?
875                    .clone();
876                let inner = format!(
877                    "    <StackRefactorId>{}</StackRefactorId>\n    <Status>CREATE_COMPLETE</Status>",
878                    xml_escape(&id),
879                );
880                Ok(xml_response("DescribeStackRefactor", inner, &rid))
881            }
882            "ExecuteStackRefactor" => {
883                require_scalar(&params, "StackRefactorId")?;
884                Ok(xml_response("ExecuteStackRefactor", String::new(), &rid))
885            }
886            "ListStackRefactors" => Ok(xml_response(
887                "ListStackRefactors",
888                "    <StackRefactorSummaries/>".to_string(),
889                &rid,
890            )),
891            "ListStackRefactorActions" => {
892                require_scalar(&params, "StackRefactorId")?;
893                Ok(xml_response(
894                    "ListStackRefactorActions",
895                    "    <StackRefactorActions/>".to_string(),
896                    &rid,
897                ))
898            }
899
900            // ── Types / extensions ──
901            "ActivateType" => {
902                let arn = Arn::new(
903                    "cloudformation",
904                    "us-east-1",
905                    &aid,
906                    &format!("type/resource/{}", rand_id()),
907                )
908                .to_string();
909                Ok(xml_response(
910                    "ActivateType",
911                    format!("    <Arn>{}</Arn>", xml_escape(&arn)),
912                    &rid,
913                ))
914            }
915            "DeactivateType" => Ok(xml_response("DeactivateType", String::new(), &rid)),
916            "DescribeType" => {
917                let arn = params.get("Arn").cloned().unwrap_or_else(|| {
918                    Arn::new("cloudformation", "us-east-1", &aid, "type/resource/Default")
919                        .to_string()
920                });
921                let inner = format!(
922                    "    <Arn>{}</Arn>\n    <Type>RESOURCE</Type>\n    <TypeName>AWS::Custom::Type</TypeName>",
923                    xml_escape(&arn),
924                );
925                Ok(xml_response("DescribeType", inner, &rid))
926            }
927            "DescribeTypeRegistration" => {
928                let token = params
929                    .get("RegistrationToken")
930                    .cloned()
931                    .ok_or_else(|| missing("RegistrationToken"))?;
932                let inner = format!(
933                    "    <ProgressStatus>COMPLETE</ProgressStatus>\n    <Description>{}</Description>",
934                    xml_escape(&token),
935                );
936                Ok(xml_response("DescribeTypeRegistration", inner, &rid))
937            }
938            "RegisterType" => {
939                require_scalar(&params, "TypeName")?;
940                require_scalar(&params, "SchemaHandlerPackage")?;
941                let token = rand_id();
942                Ok(xml_response(
943                    "RegisterType",
944                    format!(
945                        "    <RegistrationToken>{}</RegistrationToken>",
946                        xml_escape(&token)
947                    ),
948                    &rid,
949                ))
950            }
951            "DeregisterType" => Ok(xml_response("DeregisterType", String::new(), &rid)),
952            "ListTypes" => Ok(xml_response(
953                "ListTypes",
954                "    <TypeSummaries/>".to_string(),
955                &rid,
956            )),
957            "ListTypeRegistrations" => Ok(xml_response(
958                "ListTypeRegistrations",
959                "    <RegistrationTokenList/>".to_string(),
960                &rid,
961            )),
962            "ListTypeVersions" => Ok(xml_response(
963                "ListTypeVersions",
964                "    <TypeVersionSummaries/>".to_string(),
965                &rid,
966            )),
967            "BatchDescribeTypeConfigurations" => {
968                // `TypeConfigurationIdentifiers` is `@required` but AWS query
969                // protocol can't distinguish an absent list from an empty
970                // one on the wire. Accept either and return zero entries.
971                Ok(xml_response(
972                    "BatchDescribeTypeConfigurations",
973                    "    <Errors/>\n    <TypeConfigurations/>".to_string(),
974                    &rid,
975                ))
976            }
977            "SetTypeConfiguration" => {
978                require_scalar(&params, "Configuration")?;
979                let arn = Arn::new(
980                    "cloudformation",
981                    "us-east-1",
982                    &aid,
983                    &format!("type-config/{}", rand_id()),
984                )
985                .to_string();
986                Ok(xml_response(
987                    "SetTypeConfiguration",
988                    format!(
989                        "    <ConfigurationArn>{}</ConfigurationArn>",
990                        xml_escape(&arn)
991                    ),
992                    &rid,
993                ))
994            }
995            "SetTypeDefaultVersion" => {
996                Ok(xml_response("SetTypeDefaultVersion", String::new(), &rid))
997            }
998            "TestType" => {
999                let arn = Arn::new(
1000                    "cloudformation",
1001                    "us-east-1",
1002                    &aid,
1003                    &format!("type/resource/{}", rand_id()),
1004                )
1005                .to_string();
1006                Ok(xml_response(
1007                    "TestType",
1008                    format!("    <TypeVersionArn>{}</TypeVersionArn>", xml_escape(&arn)),
1009                    &rid,
1010                ))
1011            }
1012            "PublishType" => {
1013                let arn = Arn::new(
1014                    "cloudformation",
1015                    "us-east-1",
1016                    &aid,
1017                    &format!("type/resource/{}", rand_id()),
1018                )
1019                .to_string();
1020                Ok(xml_response(
1021                    "PublishType",
1022                    format!("    <PublicTypeArn>{}</PublicTypeArn>", xml_escape(&arn)),
1023                    &rid,
1024                ))
1025            }
1026            "RegisterPublisher" => {
1027                let id = rand_id();
1028                Ok(xml_response(
1029                    "RegisterPublisher",
1030                    format!("    <PublisherId>{}</PublisherId>", xml_escape(&id)),
1031                    &rid,
1032                ))
1033            }
1034            "DescribePublisher" => {
1035                let id = params
1036                    .get("PublisherId")
1037                    .cloned()
1038                    .unwrap_or_else(|| "default-publisher".to_string());
1039                let inner = format!(
1040                    "    <PublisherId>{}</PublisherId>\n    <PublisherStatus>VERIFIED</PublisherStatus>\n    <IdentityProvider>AWS_Marketplace</IdentityProvider>",
1041                    xml_escape(&id),
1042                );
1043                Ok(xml_response("DescribePublisher", inner, &rid))
1044            }
1045
1046            // ── Generated templates ──
1047            "CreateGeneratedTemplate" => {
1048                let name = params
1049                    .get("GeneratedTemplateName")
1050                    .ok_or_else(|| missing("GeneratedTemplateName"))?
1051                    .clone();
1052                let id = Arn::new(
1053                    "cloudformation",
1054                    "us-east-1",
1055                    &aid,
1056                    &format!("generatedtemplate/{}", rand_id()),
1057                )
1058                .to_string();
1059                let entry = json!({"GeneratedTemplateId": id.clone(), "Name": name.clone(), "Status": "COMPLETE"});
1060                let mut accounts = self.state.write();
1061                let state = accounts.get_or_create(&aid);
1062                store(&mut state.extras, "generated_templates").insert(name.clone(), entry);
1063                Ok(xml_response(
1064                    "CreateGeneratedTemplate",
1065                    format!(
1066                        "    <GeneratedTemplateId>{}</GeneratedTemplateId>",
1067                        xml_escape(&id)
1068                    ),
1069                    &rid,
1070                ))
1071            }
1072            "UpdateGeneratedTemplate" => {
1073                let name = params
1074                    .get("GeneratedTemplateName")
1075                    .ok_or_else(|| missing("GeneratedTemplateName"))?
1076                    .clone();
1077                let id = Arn::new(
1078                    "cloudformation",
1079                    "us-east-1",
1080                    &aid,
1081                    &format!("generatedtemplate/{name}"),
1082                )
1083                .to_string();
1084                Ok(xml_response(
1085                    "UpdateGeneratedTemplate",
1086                    format!(
1087                        "    <GeneratedTemplateId>{}</GeneratedTemplateId>",
1088                        xml_escape(&id)
1089                    ),
1090                    &rid,
1091                ))
1092            }
1093            "DescribeGeneratedTemplate" => {
1094                let name = params
1095                    .get("GeneratedTemplateName")
1096                    .ok_or_else(|| missing("GeneratedTemplateName"))?
1097                    .clone();
1098                let inner = format!(
1099                    "    <GeneratedTemplateId>arn:aws:cloudformation:us-east-1:{}:generatedtemplate/{}</GeneratedTemplateId>\n    <GeneratedTemplateName>{}</GeneratedTemplateName>\n    <Status>COMPLETE</Status>",
1100                    xml_escape(&aid),
1101                    xml_escape(&name),
1102                    xml_escape(&name),
1103                );
1104                Ok(xml_response("DescribeGeneratedTemplate", inner, &rid))
1105            }
1106            "GetGeneratedTemplate" => {
1107                require_scalar(&params, "GeneratedTemplateName")?;
1108                Ok(xml_response(
1109                    "GetGeneratedTemplate",
1110                    "    <Status>COMPLETE</Status>\n    <TemplateBody>{}</TemplateBody>"
1111                        .to_string(),
1112                    &rid,
1113                ))
1114            }
1115            "DeleteGeneratedTemplate" => {
1116                let name = params
1117                    .get("GeneratedTemplateName")
1118                    .ok_or_else(|| missing("GeneratedTemplateName"))?
1119                    .clone();
1120                let mut accounts = self.state.write();
1121                let state = accounts.get_or_create(&aid);
1122                if let Some(m) = state.extras.get_mut("generated_templates") {
1123                    m.remove(&name);
1124                }
1125                Ok(xml_response("DeleteGeneratedTemplate", String::new(), &rid))
1126            }
1127            "ListGeneratedTemplates" => Ok(xml_response(
1128                "ListGeneratedTemplates",
1129                "    <Summaries/>".to_string(),
1130                &rid,
1131            )),
1132
1133            // ── Resource scans ──
1134            "StartResourceScan" => {
1135                let id = Arn::new(
1136                    "cloudformation",
1137                    "us-east-1",
1138                    &aid,
1139                    &format!("resourceScan/{}", rand_id()),
1140                )
1141                .to_string();
1142                Ok(xml_response(
1143                    "StartResourceScan",
1144                    format!("    <ResourceScanId>{}</ResourceScanId>", xml_escape(&id)),
1145                    &rid,
1146                ))
1147            }
1148            "DescribeResourceScan" => {
1149                let id = params
1150                    .get("ResourceScanId")
1151                    .cloned()
1152                    .ok_or_else(|| missing("ResourceScanId"))?;
1153                let inner = format!(
1154                    "    <ResourceScanId>{}</ResourceScanId>\n    <Status>COMPLETE</Status>",
1155                    xml_escape(&id),
1156                );
1157                Ok(xml_response("DescribeResourceScan", inner, &rid))
1158            }
1159            "ListResourceScans" => Ok(xml_response(
1160                "ListResourceScans",
1161                "    <ResourceScanSummaries/>".to_string(),
1162                &rid,
1163            )),
1164            "ListResourceScanResources" => {
1165                require_scalar(&params, "ResourceScanId")?;
1166                Ok(xml_response(
1167                    "ListResourceScanResources",
1168                    "    <Resources/>".to_string(),
1169                    &rid,
1170                ))
1171            }
1172            "ListResourceScanRelatedResources" => {
1173                require_scalar(&params, "ResourceScanId")?;
1174                Ok(xml_response(
1175                    "ListResourceScanRelatedResources",
1176                    "    <RelatedResources/>".to_string(),
1177                    &rid,
1178                ))
1179            }
1180
1181            // ── Drift detection ──
1182            "DetectStackDrift" => {
1183                let stack_name = params
1184                    .get("StackName")
1185                    .ok_or_else(|| missing("StackName"))?
1186                    .clone();
1187                let id = rand_id();
1188
1189                let resources: Vec<StackResource> = {
1190                    let accounts = self.state.read();
1191                    let stack = accounts.get(&aid).and_then(|s| {
1192                        s.stacks.values().find(|st| {
1193                            (st.name == stack_name || st.stack_id == stack_name)
1194                                && st.status != "DELETE_COMPLETE"
1195                        })
1196                    });
1197                    stack.map(|s| s.resources.clone()).unwrap_or_default()
1198                };
1199
1200                let mut drifted_resources: Vec<Value> = Vec::new();
1201
1202                for resource in &resources {
1203                    let exists = match resource.resource_type.as_str() {
1204                        "AWS::SQS::Queue" => self
1205                            .deps
1206                            .sqs
1207                            .read()
1208                            .get(&aid)
1209                            .map(|s| s.queues.contains_key(&resource.physical_id))
1210                            .unwrap_or(false),
1211                        "AWS::SNS::Topic" => self
1212                            .deps
1213                            .sns
1214                            .read()
1215                            .get(&aid)
1216                            .map(|s| s.topics.contains_key(&resource.physical_id))
1217                            .unwrap_or(false),
1218                        "AWS::S3::Bucket" => self
1219                            .deps
1220                            .s3
1221                            .read()
1222                            .get(&aid)
1223                            .map(|s| s.buckets.contains_key(&resource.physical_id))
1224                            .unwrap_or(false),
1225                        "AWS::Lambda::Function" => self
1226                            .deps
1227                            .lambda
1228                            .read()
1229                            .get(&aid)
1230                            .map(|s| s.functions.contains_key(&resource.physical_id))
1231                            .unwrap_or(false),
1232                        "AWS::IAM::Role" => self
1233                            .deps
1234                            .iam
1235                            .read()
1236                            .get(&aid)
1237                            .map(|s| s.roles.contains_key(&resource.physical_id))
1238                            .unwrap_or(false),
1239                        "AWS::DynamoDB::Table" => self
1240                            .deps
1241                            .dynamodb
1242                            .read()
1243                            .get(&aid)
1244                            .map(|s| s.tables.values().any(|t| t.arn == resource.physical_id))
1245                            .unwrap_or(false),
1246                        "AWS::KMS::Key" => self
1247                            .deps
1248                            .kms
1249                            .read()
1250                            .get(&aid)
1251                            .map(|s| s.keys.contains_key(&resource.physical_id))
1252                            .unwrap_or(false),
1253                        "AWS::SecretsManager::Secret" => self
1254                            .deps
1255                            .secretsmanager
1256                            .read()
1257                            .get(&aid)
1258                            .map(|s| s.secrets.contains_key(&resource.physical_id))
1259                            .unwrap_or(false),
1260                        _ => true, // NOT_CHECKED — assume exists
1261                    };
1262                    if !exists {
1263                        drifted_resources.push(json!({
1264                            "LogicalResourceId": resource.logical_id,
1265                            "PhysicalResourceId": resource.physical_id,
1266                            "ResourceType": resource.resource_type,
1267                            "StackResourceDriftStatus": "DELETED",
1268                            "PropertyDifferences": [],
1269                        }));
1270                    }
1271                }
1272
1273                let stack_drift_status = if drifted_resources.is_empty() {
1274                    "IN_SYNC"
1275                } else {
1276                    "DRIFTED"
1277                };
1278
1279                let record = json!({
1280                    "StackDriftDetectionId": id,
1281                    "StackName": stack_name,
1282                    "StackDriftStatus": stack_drift_status,
1283                    "DetectionStatus": "DETECTION_COMPLETE",
1284                    "DriftedResources": drifted_resources,
1285                });
1286
1287                {
1288                    let mut accounts = self.state.write();
1289                    let state = accounts.get_or_create(&aid);
1290                    store(&mut state.extras, "drift_detection").insert(id.clone(), record);
1291                }
1292
1293                Ok(xml_response(
1294                    "DetectStackDrift",
1295                    format!(
1296                        "    <StackDriftDetectionId>{}</StackDriftDetectionId>",
1297                        xml_escape(&id)
1298                    ),
1299                    &rid,
1300                ))
1301            }
1302            "DetectStackResourceDrift" => {
1303                let stack_name = params
1304                    .get("StackName")
1305                    .ok_or_else(|| missing("StackName"))?
1306                    .clone();
1307                let logical = params
1308                    .get("LogicalResourceId")
1309                    .ok_or_else(|| missing("LogicalResourceId"))?
1310                    .clone();
1311                let accounts = self.state.read();
1312                let resource_drift = accounts
1313                    .get(&aid)
1314                    .and_then(|s| {
1315                        s.stacks.values().find(|st| {
1316                            (st.name == stack_name || st.stack_id == stack_name)
1317                                && st.status != "DELETE_COMPLETE"
1318                        })
1319                    })
1320                    .and_then(|stack| stack.resources.iter().find(|r| r.logical_id == logical))
1321                    .map(|resource| {
1322                        let exists = match resource.resource_type.as_str() {
1323                            "AWS::SQS::Queue" => self
1324                                .deps
1325                                .sqs
1326                                .read()
1327                                .get(&aid)
1328                                .map(|s| s.queues.contains_key(&resource.physical_id))
1329                                .unwrap_or(false),
1330                            "AWS::SNS::Topic" => self
1331                                .deps
1332                                .sns
1333                                .read()
1334                                .get(&aid)
1335                                .map(|s| s.topics.contains_key(&resource.physical_id))
1336                                .unwrap_or(false),
1337                            "AWS::S3::Bucket" => self
1338                                .deps
1339                                .s3
1340                                .read()
1341                                .get(&aid)
1342                                .map(|s| s.buckets.contains_key(&resource.physical_id))
1343                                .unwrap_or(false),
1344                            "AWS::Lambda::Function" => self
1345                                .deps
1346                                .lambda
1347                                .read()
1348                                .get(&aid)
1349                                .map(|s| s.functions.contains_key(&resource.physical_id))
1350                                .unwrap_or(false),
1351                            "AWS::IAM::Role" => self
1352                                .deps
1353                                .iam
1354                                .read()
1355                                .get(&aid)
1356                                .map(|s| s.roles.contains_key(&resource.physical_id))
1357                                .unwrap_or(false),
1358                            "AWS::DynamoDB::Table" => self
1359                                .deps
1360                                .dynamodb
1361                                .read()
1362                                .get(&aid)
1363                                .map(|s| s.tables.values().any(|t| t.arn == resource.physical_id))
1364                                .unwrap_or(false),
1365                            "AWS::KMS::Key" => self
1366                                .deps
1367                                .kms
1368                                .read()
1369                                .get(&aid)
1370                                .map(|s| s.keys.contains_key(&resource.physical_id))
1371                                .unwrap_or(false),
1372                            "AWS::SecretsManager::Secret" => self
1373                                .deps
1374                                .secretsmanager
1375                                .read()
1376                                .get(&aid)
1377                                .map(|s| s.secrets.contains_key(&resource.physical_id))
1378                                .unwrap_or(false),
1379                            _ => true,
1380                        };
1381                        if exists {
1382                            "IN_SYNC"
1383                        } else {
1384                            "DELETED"
1385                        }
1386                    })
1387                    .unwrap_or("NOT_CHECKED");
1388
1389                let inner = format!(
1390                    "    <StackResourceDrift>\n      <LogicalResourceId>{}</LogicalResourceId>\n      <StackResourceDriftStatus>{}</StackResourceDriftStatus>\n    </StackResourceDrift>",
1391                    xml_escape(&logical),
1392                    xml_escape(resource_drift),
1393                );
1394                Ok(xml_response("DetectStackResourceDrift", inner, &rid))
1395            }
1396            "DetectStackSetDrift" => {
1397                require_scalar(&params, "StackSetName")?;
1398                let op_id = rand_id();
1399                Ok(xml_response(
1400                    "DetectStackSetDrift",
1401                    format!("    <OperationId>{}</OperationId>", xml_escape(&op_id)),
1402                    &rid,
1403                ))
1404            }
1405            "DescribeStackDriftDetectionStatus" => {
1406                let id = params
1407                    .get("StackDriftDetectionId")
1408                    .ok_or_else(|| missing("StackDriftDetectionId"))?
1409                    .clone();
1410                let accounts = self.state.read();
1411                let record = accounts
1412                    .get(&aid)
1413                    .and_then(|s| s.extras.get("drift_detection"))
1414                    .and_then(|m| m.get(&id))
1415                    .cloned()
1416                    .unwrap_or_else(|| {
1417                        json!({
1418                            "StackDriftDetectionId": id,
1419                            "StackDriftStatus": "IN_SYNC",
1420                            "DetectionStatus": "DETECTION_COMPLETE",
1421                        })
1422                    });
1423                // The Smithy output declares `StackId` and `Timestamp` as
1424                // `@required` alongside `StackDriftDetectionId` and
1425                // `DetectionStatus`. Synthetic detection records won't have
1426                // a real stack ARN behind them, so synthesise a deterministic
1427                // placeholder ARN from the detection id.
1428                let stack_id = record["StackId"]
1429                    .as_str()
1430                    .map(str::to_owned)
1431                    .unwrap_or_else(|| {
1432                        Arn::new(
1433                            "cloudformation",
1434                            "us-east-1",
1435                            &aid,
1436                            &format!("stack/drift-{id}/{}", rand_id()),
1437                        )
1438                        .to_string()
1439                    });
1440                let timestamp = record["Timestamp"]
1441                    .as_str()
1442                    .map(str::to_owned)
1443                    .unwrap_or_else(|| "2024-01-01T00:00:00Z".to_string());
1444
1445                let inner = format!(
1446                    "    <StackId>{}</StackId>\n    <StackDriftDetectionId>{}</StackDriftDetectionId>\n    <DetectionStatus>{}</DetectionStatus>\n    <StackDriftStatus>{}</StackDriftStatus>\n    <Timestamp>{}</Timestamp>",
1447                    xml_escape(&stack_id),
1448                    xml_escape(record["StackDriftDetectionId"].as_str().unwrap_or("")),
1449                    xml_escape(record["DetectionStatus"].as_str().unwrap_or("DETECTION_COMPLETE")),
1450                    xml_escape(record["StackDriftStatus"].as_str().unwrap_or("IN_SYNC")),
1451                    xml_escape(&timestamp),
1452                );
1453                Ok(xml_response(
1454                    "DescribeStackDriftDetectionStatus",
1455                    inner,
1456                    &rid,
1457                ))
1458            }
1459            "DescribeStackResourceDrifts" => {
1460                let stack_name = params
1461                    .get("StackName")
1462                    .cloned()
1463                    .ok_or_else(|| missing("StackName"))?;
1464                let accounts = self.state.read();
1465                let drifted: Vec<Value> = accounts
1466                    .get(&aid)
1467                    .and_then(|s| {
1468                        let found = s
1469                            .stacks
1470                            .values()
1471                            .find(|st| {
1472                                (st.name == stack_name || st.stack_id == stack_name)
1473                                    && st.status != "DELETE_COMPLETE"
1474                            })
1475                            .is_some();
1476                        if !found {
1477                            return None;
1478                        }
1479                        s.extras
1480                            .get("drift_detection")
1481                            .and_then(|m| {
1482                                m.values()
1483                                    .find(|v| v["StackName"].as_str() == Some(stack_name.as_str()))
1484                            })
1485                            .and_then(|v| v["DriftedResources"].as_array().cloned())
1486                    })
1487                    .unwrap_or_default();
1488
1489                let inner = if drifted.is_empty() {
1490                    "    <StackResourceDrifts/>".to_string()
1491                } else {
1492                    format!(
1493                        "    <StackResourceDrifts>\n{}\n    </StackResourceDrifts>",
1494                        members_xml(&drifted, |v| {
1495                            format!(
1496                            "        <StackResourceDrift>\n          <LogicalResourceId>{}</LogicalResourceId>\n          <PhysicalResourceId>{}</PhysicalResourceId>\n          <ResourceType>{}</ResourceType>\n          <StackResourceDriftStatus>{}</StackResourceDriftStatus>\n        </StackResourceDrift>",
1497                            xml_escape(v["LogicalResourceId"].as_str().unwrap_or("")),
1498                            xml_escape(v["PhysicalResourceId"].as_str().unwrap_or("")),
1499                            xml_escape(v["ResourceType"].as_str().unwrap_or("")),
1500                            xml_escape(v["StackResourceDriftStatus"].as_str().unwrap_or("IN_SYNC")),
1501                        )
1502                        }),
1503                    )
1504                };
1505                Ok(xml_response("DescribeStackResourceDrifts", inner, &rid))
1506            }
1507            "DescribeStackResource" => {
1508                let stack_name = params
1509                    .get("StackName")
1510                    .ok_or_else(|| missing("StackName"))?
1511                    .clone();
1512                let logical = params
1513                    .get("LogicalResourceId")
1514                    .ok_or_else(|| missing("LogicalResourceId"))?
1515                    .clone();
1516                let accounts = self.state.read();
1517                let detail = accounts
1518                    .get(&aid)
1519                    .and_then(|s| s.stacks.get(&stack_name))
1520                    .and_then(|s| s.resources.iter().find(|r| r.logical_id == logical))
1521                    .map(|r| {
1522                        (
1523                            r.physical_id.clone(),
1524                            r.resource_type.clone(),
1525                            r.status.clone(),
1526                        )
1527                    })
1528                    .unwrap_or_else(|| {
1529                        (
1530                            "pid".to_string(),
1531                            "AWS::Custom".to_string(),
1532                            "CREATE_COMPLETE".to_string(),
1533                        )
1534                    });
1535                let inner = format!(
1536                    "    <StackResourceDetail>\n      <StackName>{}</StackName>\n      <LogicalResourceId>{}</LogicalResourceId>\n      <PhysicalResourceId>{}</PhysicalResourceId>\n      <ResourceType>{}</ResourceType>\n      <ResourceStatus>{}</ResourceStatus>\n      <LastUpdatedTimestamp>{}</LastUpdatedTimestamp>\n    </StackResourceDetail>",
1537                    xml_escape(&stack_name),
1538                    xml_escape(&logical),
1539                    xml_escape(&detail.0),
1540                    xml_escape(&detail.1),
1541                    xml_escape(&detail.2),
1542                    chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ"),
1543                );
1544                Ok(xml_response("DescribeStackResource", inner, &rid))
1545            }
1546
1547            // ── Events ──
1548            "DescribeStackEvents" => {
1549                require_scalar(&params, "StackName")?;
1550                let stack_filter = params.get("StackName").cloned();
1551                let accounts = self.state.read();
1552                let events: Vec<Value> = accounts
1553                    .get(&aid)
1554                    .map(|s| {
1555                        let mut all: Vec<Value> = Vec::new();
1556                        for (sid, evs) in &s.events {
1557                            // Resolve to find matching stack id by name or id.
1558                            let matches = match &stack_filter {
1559                                None => true,
1560                                Some(filter) => {
1561                                    sid == filter
1562                                        || s.stacks.values().any(|st| {
1563                                            st.stack_id == *sid
1564                                                && (st.name == *filter || st.stack_id == *filter)
1565                                        })
1566                                }
1567                            };
1568                            if matches {
1569                                all.extend(evs.iter().cloned());
1570                            }
1571                        }
1572                        // Newest first, matching real CloudFormation.
1573                        all.reverse();
1574                        all
1575                    })
1576                    .unwrap_or_default();
1577                let inner = if events.is_empty() {
1578                    "    <StackEvents/>".to_string()
1579                } else {
1580                    format!(
1581                        "    <StackEvents>\n{}\n    </StackEvents>",
1582                        members_xml(&events, |v| {
1583                            format!(
1584                            "        <EventId>{}</EventId>\n        <StackId>{}</StackId>\n        <StackName>{}</StackName>\n        <LogicalResourceId>{}</LogicalResourceId>\n        <PhysicalResourceId>{}</PhysicalResourceId>\n        <ResourceType>{}</ResourceType>\n        <ResourceStatus>{}</ResourceStatus>\n        <Timestamp>{}</Timestamp>",
1585                            xml_escape(v["EventId"].as_str().unwrap_or("")),
1586                            xml_escape(v["StackId"].as_str().unwrap_or("")),
1587                            xml_escape(v["StackName"].as_str().unwrap_or("")),
1588                            xml_escape(v["LogicalResourceId"].as_str().unwrap_or("")),
1589                            xml_escape(v["PhysicalResourceId"].as_str().unwrap_or("")),
1590                            xml_escape(v["ResourceType"].as_str().unwrap_or("")),
1591                            xml_escape(v["ResourceStatus"].as_str().unwrap_or("")),
1592                            xml_escape(v["Timestamp"].as_str().unwrap_or("")),
1593                        )
1594                        }),
1595                    )
1596                };
1597                Ok(xml_response("DescribeStackEvents", inner, &rid))
1598            }
1599            "DescribeEvents" => Ok(xml_response(
1600                "DescribeEvents",
1601                "    <Events/>".to_string(),
1602                &rid,
1603            )),
1604
1605            // ── Hooks ──
1606            "GetHookResult" => Ok(xml_response(
1607                "GetHookResult",
1608                "    <Status>HOOK_COMPLETE_SUCCEEDED</Status>".to_string(),
1609                &rid,
1610            )),
1611            "ListHookResults" => Ok(xml_response(
1612                "ListHookResults",
1613                "    <HookResults/>".to_string(),
1614                &rid,
1615            )),
1616            "RecordHandlerProgress" => {
1617                require_scalar(&params, "BearerToken")?;
1618                require_scalar(&params, "OperationStatus")?;
1619                Ok(xml_response_no_result("RecordHandlerProgress", &rid))
1620            }
1621
1622            // ── Imports / exports ──
1623            "ListExports" => {
1624                let accounts = self.state.read();
1625                let mut entries = String::new();
1626                if let Some(state) = accounts.get(&aid) {
1627                    for (name, export) in &state.exports {
1628                        entries.push_str(&format!(
1629                            "      <member>\n        <ExportingStackId>{}</ExportingStackId>\n        <Name>{}</Name>\n        <Value>{}</Value>\n      </member>\n",
1630                            xml_escape(&export.exporting_stack_id),
1631                            xml_escape(name),
1632                            xml_escape(&export.value),
1633                        ));
1634                    }
1635                }
1636                let inner = if entries.is_empty() {
1637                    "    <Exports/>".to_string()
1638                } else {
1639                    format!("    <Exports>\n{entries}    </Exports>")
1640                };
1641                Ok(xml_response("ListExports", inner, &rid))
1642            }
1643            "ListImports" => {
1644                let export_name = params
1645                    .get("ExportName")
1646                    .cloned()
1647                    .ok_or_else(|| missing("ExportName"))?;
1648                let accounts = self.state.read();
1649                let mut entries = String::new();
1650                if let Some(state) = accounts.get(&aid) {
1651                    if let Some(consumers) = state.imports.get(&export_name) {
1652                        for stack_name in consumers {
1653                            entries.push_str(&format!(
1654                                "      <member>{}</member>\n",
1655                                xml_escape(stack_name)
1656                            ));
1657                        }
1658                    }
1659                }
1660                let inner = if entries.is_empty() {
1661                    "    <Imports/>".to_string()
1662                } else {
1663                    format!("    <Imports>\n{entries}    </Imports>")
1664                };
1665                Ok(xml_response("ListImports", inner, &rid))
1666            }
1667
1668            // ── Stack policies ──
1669            "GetStackPolicy" => {
1670                let stack = params
1671                    .get("StackName")
1672                    .ok_or_else(|| missing("StackName"))?
1673                    .clone();
1674                let accounts = self.state.read();
1675                let body = accounts.get(&aid)
1676                    .and_then(|s| s.stack_policies.get(&stack))
1677                    .cloned()
1678                    .unwrap_or_else(|| r#"{"Statement":[{"Effect":"Allow","Action":"Update:*","Principal":"*","Resource":"*"}]}"#.to_string());
1679                let inner = format!(
1680                    "    <StackPolicyBody>{}</StackPolicyBody>",
1681                    xml_escape(&body)
1682                );
1683                Ok(xml_response("GetStackPolicy", inner, &rid))
1684            }
1685            "SetStackPolicy" => {
1686                let stack = params
1687                    .get("StackName")
1688                    .ok_or_else(|| missing("StackName"))?
1689                    .clone();
1690                let body = params.get("StackPolicyBody").cloned().unwrap_or_default();
1691                let mut accounts = self.state.write();
1692                let state = accounts.get_or_create(&aid);
1693                state.stack_policies.insert(stack, body);
1694                Ok(xml_response_no_result("SetStackPolicy", &rid))
1695            }
1696
1697            // ── Termination protection ──
1698            "UpdateTerminationProtection" => {
1699                let stack = params
1700                    .get("StackName")
1701                    .ok_or_else(|| missing("StackName"))?
1702                    .clone();
1703                let enabled_raw = params
1704                    .get("EnableTerminationProtection")
1705                    .ok_or_else(|| missing("EnableTerminationProtection"))?;
1706                let enabled = enabled_raw.eq_ignore_ascii_case("true");
1707                let stack_id = {
1708                    let mut accounts = self.state.write();
1709                    let state = accounts.get_or_create(&aid);
1710                    state.termination_protection.insert(stack.clone(), enabled);
1711                    state
1712                        .stacks
1713                        .get(&stack)
1714                        .map(|s| s.stack_id.clone())
1715                        .unwrap_or_else(|| stack.clone())
1716                };
1717                Ok(xml_response(
1718                    "UpdateTerminationProtection",
1719                    format!("    <StackId>{}</StackId>", xml_escape(&stack_id)),
1720                    &rid,
1721                ))
1722            }
1723
1724            // ── Account / org / validation / utilities ──
1725            "DescribeAccountLimits" => Ok(xml_response(
1726                "DescribeAccountLimits",
1727                r#"    <AccountLimits>
1728      <member>
1729        <Name>StackLimit</Name>
1730        <Value>2000</Value>
1731      </member>
1732    </AccountLimits>"#
1733                    .to_string(),
1734                &rid,
1735            )),
1736            "ActivateOrganizationsAccess" => {
1737                let mut accounts = self.state.write();
1738                let state = accounts.get_or_create(&aid);
1739                state.orgs_access_enabled = true;
1740                Ok(xml_response(
1741                    "ActivateOrganizationsAccess",
1742                    String::new(),
1743                    &rid,
1744                ))
1745            }
1746            "DeactivateOrganizationsAccess" => {
1747                let mut accounts = self.state.write();
1748                let state = accounts.get_or_create(&aid);
1749                state.orgs_access_enabled = false;
1750                Ok(xml_response(
1751                    "DeactivateOrganizationsAccess",
1752                    String::new(),
1753                    &rid,
1754                ))
1755            }
1756            "DescribeOrganizationsAccess" => {
1757                let accounts = self.state.read();
1758                let status = if accounts
1759                    .get(&aid)
1760                    .map(|s| s.orgs_access_enabled)
1761                    .unwrap_or(false)
1762                {
1763                    "ENABLED"
1764                } else {
1765                    "DISABLED"
1766                };
1767                Ok(xml_response(
1768                    "DescribeOrganizationsAccess",
1769                    format!("    <Status>{}</Status>", status),
1770                    &rid,
1771                ))
1772            }
1773            "ValidateTemplate" => Ok(xml_response(
1774                "ValidateTemplate",
1775                "    <Description>Validated</Description>\n    <Capabilities/>\n    <Parameters/>"
1776                    .to_string(),
1777                &rid,
1778            )),
1779            "EstimateTemplateCost" => Ok(xml_response(
1780                "EstimateTemplateCost",
1781                "    <Url>https://calculator.aws/#/estimate</Url>".to_string(),
1782                &rid,
1783            )),
1784            "GetTemplateSummary" => Ok(xml_response(
1785                "GetTemplateSummary",
1786                "    <Parameters/>\n    <ResourceTypes/>\n    <Capabilities/>".to_string(),
1787                &rid,
1788            )),
1789            "CancelUpdateStack" => {
1790                params
1791                    .get("StackName")
1792                    .ok_or_else(|| missing("StackName"))?;
1793                Ok(xml_response_no_result("CancelUpdateStack", &rid))
1794            }
1795            "ContinueUpdateRollback" => {
1796                params
1797                    .get("StackName")
1798                    .ok_or_else(|| missing("StackName"))?;
1799                Ok(xml_response("ContinueUpdateRollback", String::new(), &rid))
1800            }
1801            "RollbackStack" => {
1802                let stack = params
1803                    .get("StackName")
1804                    .ok_or_else(|| missing("StackName"))?
1805                    .clone();
1806                let stack_id = {
1807                    let accounts = self.state.read();
1808                    accounts
1809                        .get(&aid)
1810                        .and_then(|s| s.stacks.get(&stack))
1811                        .map(|s| s.stack_id.clone())
1812                        .unwrap_or_else(|| stack.clone())
1813                };
1814                Ok(xml_response(
1815                    "RollbackStack",
1816                    format!("    <StackId>{}</StackId>", xml_escape(&stack_id)),
1817                    &rid,
1818                ))
1819            }
1820            "SignalResource" => {
1821                require_scalar(&params, "StackName")?;
1822                require_scalar(&params, "LogicalResourceId")?;
1823                require_scalar(&params, "UniqueId")?;
1824                require_scalar(&params, "Status")?;
1825                Ok(xml_response_no_result("SignalResource", &rid))
1826            }
1827
1828            _ => Err(AwsServiceError::action_not_implemented(
1829                "cloudformation",
1830                &action,
1831            )),
1832        }
1833    }
1834}
1835
1836#[cfg(test)]
1837mod tests {
1838    use crate::service::{CloudFormationDeps, CloudFormationService};
1839    use crate::state::{CloudFormationState, SharedCloudFormationState};
1840    use fakecloud_core::delivery::DeliveryBus;
1841    use fakecloud_core::multi_account::MultiAccountState;
1842    use fakecloud_core::service::AwsRequest;
1843    use http::Method;
1844    use parking_lot::RwLock;
1845    use std::collections::HashMap;
1846    use std::sync::Arc;
1847
1848    fn deps() -> CloudFormationDeps {
1849        use fakecloud_dynamodb::DynamoDbState;
1850        use fakecloud_ecr::EcrState;
1851        use fakecloud_eventbridge::EventBridgeState;
1852        use fakecloud_iam::IamState;
1853        use fakecloud_kinesis::KinesisState;
1854        use fakecloud_kms::KmsState;
1855        use fakecloud_lambda::LambdaState;
1856        use fakecloud_logs::LogsState;
1857        use fakecloud_s3::S3State;
1858        use fakecloud_secretsmanager::SecretsManagerState;
1859        use fakecloud_sns::SnsState;
1860        use fakecloud_sqs::SqsState;
1861        use fakecloud_ssm::SsmState;
1862
1863        fn shared<T: fakecloud_core::multi_account::AccountState>(
1864        ) -> Arc<RwLock<MultiAccountState<T>>> {
1865            Arc::new(RwLock::new(MultiAccountState::<T>::new(
1866                "000000000000",
1867                "us-east-1",
1868                "",
1869            )))
1870        }
1871        CloudFormationDeps {
1872            sqs: shared::<SqsState>(),
1873            sns: shared::<SnsState>(),
1874            ssm: shared::<SsmState>(),
1875            iam: shared::<IamState>(),
1876            s3: shared::<S3State>(),
1877            eventbridge: shared::<EventBridgeState>(),
1878            dynamodb: shared::<DynamoDbState>(),
1879            logs: shared::<LogsState>(),
1880            lambda: shared::<LambdaState>(),
1881            secretsmanager: shared::<SecretsManagerState>(),
1882            kinesis: shared::<KinesisState>(),
1883            kms: shared::<KmsState>(),
1884            ecr: shared::<EcrState>(),
1885            cloudwatch: Arc::new(RwLock::new(fakecloud_cloudwatch::CloudWatchAccounts::new())),
1886            elbv2: Arc::new(RwLock::new(fakecloud_elbv2::Elbv2Accounts::new())),
1887            organizations: Arc::new(RwLock::new(None)),
1888            cognito: shared::<fakecloud_cognito::CognitoState>(),
1889            rds: shared::<fakecloud_rds::RdsState>(),
1890            ecs: shared::<fakecloud_ecs::EcsState>(),
1891            acm: Arc::new(RwLock::new(fakecloud_acm::AcmAccounts::new())),
1892            elasticache: shared::<fakecloud_elasticache::ElastiCacheState>(),
1893            route53: Arc::new(RwLock::new(fakecloud_route53::Route53Accounts::new())),
1894            cloudfront: Arc::new(RwLock::new(fakecloud_cloudfront::CloudFrontAccounts::new())),
1895            stepfunctions: shared::<fakecloud_stepfunctions::StepFunctionsState>(),
1896            wafv2: Arc::new(RwLock::new(fakecloud_wafv2::Wafv2Accounts::default())),
1897            apigateway: shared::<fakecloud_apigateway::ApiGatewayState>(),
1898            apigatewayv2: shared::<fakecloud_apigatewayv2::ApiGatewayV2State>(),
1899            ses: shared::<fakecloud_ses::SesState>(),
1900            application_autoscaling: Arc::new(parking_lot::RwLock::new(
1901                fakecloud_application_autoscaling::ApplicationAutoScalingAccounts::new(),
1902            )),
1903            athena: Arc::new(parking_lot::RwLock::new(
1904                fakecloud_athena::AthenaAccounts::new(),
1905            )),
1906            firehose: Arc::new(parking_lot::RwLock::new(
1907                fakecloud_firehose::FirehoseAccounts::new(),
1908            )),
1909            glue: Arc::new(parking_lot::RwLock::new(fakecloud_glue::GlueAccounts::new())),
1910            delivery: Arc::new(DeliveryBus::new()),
1911            lambda_runtime: None,
1912        }
1913    }
1914
1915    fn svc() -> CloudFormationService {
1916        let state: SharedCloudFormationState =
1917            Arc::new(RwLock::new(MultiAccountState::<CloudFormationState>::new(
1918                "000000000000",
1919                "us-east-1",
1920                "",
1921            )));
1922        CloudFormationService::new(state, deps())
1923    }
1924
1925    fn req(action: &str, params: &[(&str, &str)]) -> AwsRequest {
1926        let mut q = HashMap::new();
1927        q.insert("Action".to_string(), action.to_string());
1928        for (k, v) in params {
1929            q.insert(k.to_string(), v.to_string());
1930        }
1931        AwsRequest {
1932            service: "cloudformation".to_string(),
1933            method: Method::POST,
1934            raw_path: "/".to_string(),
1935            raw_query: String::new(),
1936            path_segments: vec![],
1937            query_params: q,
1938            headers: http::HeaderMap::new(),
1939            body: bytes::Bytes::new(),
1940            body_stream: parking_lot::Mutex::new(None),
1941            account_id: "000000000000".to_string(),
1942            region: "us-east-1".to_string(),
1943            request_id: "rid".to_string(),
1944            action: action.to_string(),
1945            is_query_protocol: true,
1946            access_key_id: None,
1947            principal: None,
1948        }
1949    }
1950
1951    fn ok(action: &str, params: &[(&str, &str)]) {
1952        let r = svc().handle_extra_action(&req(action, params));
1953        match r {
1954            Ok(resp) => assert!(resp.status.is_success(), "{action} status: {}", resp.status),
1955            Err(e) => panic!("{action} failed: {e:?}"),
1956        }
1957    }
1958
1959    #[test]
1960    fn change_sets() {
1961        ok(
1962            "CreateChangeSet",
1963            &[("StackName", "s"), ("ChangeSetName", "cs")],
1964        );
1965        ok("DescribeChangeSet", &[("ChangeSetName", "cs")]);
1966        ok("DescribeChangeSetHooks", &[("ChangeSetName", "cs")]);
1967        ok("ListChangeSets", &[("StackName", "s")]);
1968        ok("ExecuteChangeSet", &[("ChangeSetName", "cs")]);
1969        ok("DeleteChangeSet", &[("ChangeSetName", "cs")]);
1970    }
1971
1972    #[test]
1973    fn stack_sets_instances_refactors() {
1974        ok("CreateStackSet", &[("StackSetName", "ss")]);
1975        ok("DescribeStackSet", &[("StackSetName", "ss")]);
1976        ok("ListStackSets", &[]);
1977        ok("UpdateStackSet", &[("StackSetName", "ss")]);
1978        ok(
1979            "DescribeStackSetOperation",
1980            &[("StackSetName", "ss"), ("OperationId", "op")],
1981        );
1982        ok("ListStackSetOperations", &[("StackSetName", "ss")]);
1983        ok(
1984            "ListStackSetOperationResults",
1985            &[("StackSetName", "ss"), ("OperationId", "op")],
1986        );
1987        ok(
1988            "ListStackSetAutoDeploymentTargets",
1989            &[("StackSetName", "ss")],
1990        );
1991        ok(
1992            "StopStackSetOperation",
1993            &[("StackSetName", "ss"), ("OperationId", "op")],
1994        );
1995        ok("ImportStacksToStackSet", &[("StackSetName", "ss")]);
1996        ok("DeleteStackSet", &[("StackSetName", "ss")]);
1997        ok(
1998            "CreateStackInstances",
1999            &[("StackSetName", "ss"), ("Regions.member.1", "us-east-1")],
2000        );
2001        ok(
2002            "UpdateStackInstances",
2003            &[("StackSetName", "ss"), ("Regions.member.1", "us-east-1")],
2004        );
2005        ok(
2006            "DeleteStackInstances",
2007            &[
2008                ("StackSetName", "ss"),
2009                ("Regions.member.1", "us-east-1"),
2010                ("RetainStacks", "false"),
2011            ],
2012        );
2013        ok(
2014            "DescribeStackInstance",
2015            &[
2016                ("StackSetName", "ss"),
2017                ("StackInstanceAccount", "000000000000"),
2018                ("StackInstanceRegion", "us-east-1"),
2019            ],
2020        );
2021        ok("ListStackInstances", &[("StackSetName", "ss")]);
2022        ok(
2023            "ListStackInstanceResourceDrifts",
2024            &[
2025                ("StackSetName", "ss"),
2026                ("StackInstanceAccount", "000000000000"),
2027                ("StackInstanceRegion", "us-east-1"),
2028                ("OperationId", "op"),
2029            ],
2030        );
2031        ok(
2032            "CreateStackRefactor",
2033            &[("StackDefinitions.member.1.StackName", "s")],
2034        );
2035        ok("DescribeStackRefactor", &[("StackRefactorId", "r")]);
2036        ok("ExecuteStackRefactor", &[("StackRefactorId", "r")]);
2037        ok("ListStackRefactors", &[]);
2038        ok("ListStackRefactorActions", &[("StackRefactorId", "r")]);
2039    }
2040
2041    #[test]
2042    fn types_and_publishers() {
2043        ok("ActivateType", &[]);
2044        ok("DeactivateType", &[]);
2045        ok("DescribeType", &[]);
2046        ok("DescribeTypeRegistration", &[("RegistrationToken", "tok")]);
2047        ok(
2048            "RegisterType",
2049            &[("TypeName", "T"), ("SchemaHandlerPackage", "pkg")],
2050        );
2051        ok("DeregisterType", &[]);
2052        ok("ListTypes", &[]);
2053        ok("ListTypeRegistrations", &[]);
2054        ok("ListTypeVersions", &[]);
2055        ok(
2056            "BatchDescribeTypeConfigurations",
2057            &[("TypeConfigurationIdentifiers.member.1.Type", "RESOURCE")],
2058        );
2059        ok("SetTypeConfiguration", &[("Configuration", "{}")]);
2060        ok("SetTypeDefaultVersion", &[]);
2061        ok("TestType", &[]);
2062        ok("PublishType", &[]);
2063        ok("RegisterPublisher", &[]);
2064        ok("DescribePublisher", &[]);
2065    }
2066
2067    #[test]
2068    fn templates_resource_scans_drift() {
2069        ok(
2070            "CreateGeneratedTemplate",
2071            &[("GeneratedTemplateName", "gt")],
2072        );
2073        ok(
2074            "UpdateGeneratedTemplate",
2075            &[("GeneratedTemplateName", "gt")],
2076        );
2077        ok(
2078            "DescribeGeneratedTemplate",
2079            &[("GeneratedTemplateName", "gt")],
2080        );
2081        ok("GetGeneratedTemplate", &[("GeneratedTemplateName", "gt")]);
2082        ok("ListGeneratedTemplates", &[]);
2083        ok(
2084            "DeleteGeneratedTemplate",
2085            &[("GeneratedTemplateName", "gt")],
2086        );
2087        ok("StartResourceScan", &[]);
2088        ok("DescribeResourceScan", &[("ResourceScanId", "rs")]);
2089        ok("ListResourceScans", &[]);
2090        ok("ListResourceScanResources", &[("ResourceScanId", "rs")]);
2091        ok(
2092            "ListResourceScanRelatedResources",
2093            &[
2094                ("ResourceScanId", "rs"),
2095                ("Resources.member.1.ResourceType", "AWS::SQS::Queue"),
2096            ],
2097        );
2098        ok("DetectStackDrift", &[("StackName", "s")]);
2099        ok(
2100            "DetectStackResourceDrift",
2101            &[("StackName", "s"), ("LogicalResourceId", "L")],
2102        );
2103        ok("DetectStackSetDrift", &[("StackSetName", "ss")]);
2104        ok(
2105            "DescribeStackDriftDetectionStatus",
2106            &[("StackDriftDetectionId", "id")],
2107        );
2108        ok("DescribeStackResourceDrifts", &[("StackName", "s")]);
2109        ok(
2110            "DescribeStackResource",
2111            &[("StackName", "s"), ("LogicalResourceId", "L")],
2112        );
2113    }
2114
2115    #[test]
2116    fn events_hooks_imports_policies_org() {
2117        ok("DescribeStackEvents", &[("StackName", "s")]);
2118        ok("DescribeEvents", &[]);
2119        ok("GetHookResult", &[]);
2120        ok("ListHookResults", &[]);
2121        ok(
2122            "RecordHandlerProgress",
2123            &[("BearerToken", "tok"), ("OperationStatus", "SUCCESS")],
2124        );
2125        ok("ListExports", &[]);
2126        ok("ListImports", &[("ExportName", "SomeExport")]);
2127        ok("GetStackPolicy", &[("StackName", "s")]);
2128        ok("SetStackPolicy", &[("StackName", "s")]);
2129        ok(
2130            "UpdateTerminationProtection",
2131            &[("StackName", "s"), ("EnableTerminationProtection", "false")],
2132        );
2133        ok("DescribeAccountLimits", &[]);
2134        ok("ActivateOrganizationsAccess", &[]);
2135        ok("DescribeOrganizationsAccess", &[]);
2136        ok("DeactivateOrganizationsAccess", &[]);
2137        ok("ValidateTemplate", &[]);
2138        ok("EstimateTemplateCost", &[]);
2139        ok("GetTemplateSummary", &[]);
2140        ok("CancelUpdateStack", &[("StackName", "s")]);
2141        ok("ContinueUpdateRollback", &[("StackName", "s")]);
2142        ok("RollbackStack", &[("StackName", "s")]);
2143        ok(
2144            "SignalResource",
2145            &[
2146                ("StackName", "s"),
2147                ("LogicalResourceId", "L"),
2148                ("UniqueId", "U"),
2149                ("Status", "SUCCESS"),
2150            ],
2151        );
2152    }
2153}