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::{Stack, 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
89/// A CloudFormation type is a hook when its registry `Type` is `HOOK`
90/// or its `TypeName` carries the `::HOOK::` / `::Hook` segment AWS uses
91/// for activated hook types (e.g. `MyOrg::MyHook::Hook`).
92fn is_hook_type(type_kind: Option<&str>, type_name: Option<&str>) -> bool {
93    if type_kind
94        .map(|k| k.eq_ignore_ascii_case("HOOK"))
95        .unwrap_or(false)
96    {
97        return true;
98    }
99    type_name
100        .map(|n| {
101            let upper = n.to_ascii_uppercase();
102            upper.ends_with("::HOOK") || upper.contains("::HOOK::")
103        })
104        .unwrap_or(false)
105}
106
107/// Register/activate a hook in per-account state so change-set
108/// execution can record real hook results against it. Idempotent on
109/// `TypeName` (bug-audit 2026-06-13, 1.8).
110fn register_hook(
111    extras: &mut BTreeMap<String, BTreeMap<String, Value>>,
112    type_name: &str,
113    failure_mode: Option<&str>,
114    configuration: Option<&str>,
115) {
116    let entry = json!({
117        "TypeName": type_name,
118        "TypeVersionId": "00000001",
119        "TypeConfigurationVersionId": "1",
120        // FAIL or WARN. Defaults to FAIL (AWS hook default), so a hook a
121        // user marks as failing actually surfaces a HOOK_COMPLETE_FAILED
122        // rather than canned success.
123        "FailureMode": failure_mode.unwrap_or("FAIL"),
124        "Configuration": configuration.unwrap_or(""),
125    });
126    store(extras, "hooks").insert(type_name.to_string(), entry);
127}
128
129/// Record one hook-result record per hook configured on a change set
130/// when it executes, keyed by a freshly minted `HookResultId`. The
131/// status derives from the hook's `FailureMode`: a `FAIL`-mode hook is
132/// recorded as `HOOK_COMPLETE_FAILED` (it would have blocked the op),
133/// any other mode as `HOOK_COMPLETE_SUCCEEDED`. Returns the IDs created
134/// (unused today but handy for callers). (bug-audit 2026-06-13, 1.8).
135struct HookTarget<'a> {
136    account_id: &'a str,
137    target_type: &'a str,
138    target_id: &'a str,
139    logical_resource_id: &'a str,
140    invocation_point: &'a str,
141    op_failed: bool,
142}
143
144fn record_hook_results(
145    extras: &mut BTreeMap<String, BTreeMap<String, Value>>,
146    hooks: &[Value],
147    target: &HookTarget<'_>,
148) {
149    let HookTarget {
150        account_id,
151        target_type,
152        target_id,
153        logical_resource_id,
154        invocation_point,
155        op_failed,
156    } = *target;
157    let now = Utc::now().timestamp_millis();
158    for hook in hooks {
159        let type_name = hook
160            .get("TypeName")
161            .and_then(Value::as_str)
162            .unwrap_or("Unknown::Hook");
163        let failure_mode = hook
164            .get("FailureMode")
165            .and_then(Value::as_str)
166            .unwrap_or("FAIL");
167        // A FAIL-mode hook that runs against a failing op records a
168        // failure; otherwise it succeeded. A WARN-mode hook never blocks,
169        // so it succeeds regardless.
170        let status = if failure_mode.eq_ignore_ascii_case("FAIL") && op_failed {
171            "HOOK_COMPLETE_FAILED"
172        } else {
173            "HOOK_COMPLETE_SUCCEEDED"
174        };
175        let result_id = rand_id();
176        let type_arn = Arn::new(
177            "cloudformation",
178            "us-east-1",
179            account_id,
180            &format!("type/hook/{}", type_name.replace("::", "-")),
181        )
182        .to_string();
183        let record = json!({
184            "HookResultId": result_id,
185            "InvocationPoint": invocation_point,
186            "FailureMode": failure_mode,
187            "TypeName": type_name,
188            "TypeVersionId": hook.get("TypeVersionId").and_then(Value::as_str).unwrap_or("00000001"),
189            "TypeConfigurationVersionId": hook.get("TypeConfigurationVersionId").and_then(Value::as_str).unwrap_or("1"),
190            "TypeArn": type_arn,
191            "Status": status,
192            "HookStatusReason": if status == "HOOK_COMPLETE_FAILED" { "Hook failed" } else { "Hook succeeded" },
193            "InvokedAt": now,
194            "TargetType": target_type,
195            "TargetId": target_id,
196            "LogicalResourceId": logical_resource_id,
197        });
198        store(extras, "hook_results").insert(result_id, record);
199    }
200}
201
202fn missing(name: &str) -> AwsServiceError {
203    AwsServiceError::aws_error(
204        StatusCode::BAD_REQUEST,
205        "ValidationError",
206        format!("{name} is required"),
207    )
208}
209
210/// Awquery list/struct members arrive flattened as `Field.member.1.X`
211/// (or `Field.member.1` for scalar lists). A simple `params.get(field)`
212/// misses those entries. `has_collection_param` checks whether *any*
213/// key starts with `field.` to detect submission of a non-scalar
214/// required field.
215fn has_collection_param(params: &BTreeMap<String, String>, field: &str) -> bool {
216    let prefix = format!("{field}.");
217    params.keys().any(|k| k.starts_with(&prefix))
218}
219
220/// Validate a scalar required field. The `AnyError` conformance
221/// expectation only needs a 4xx response — the wire code can be any
222/// AWS-shaped value — so emitting `ValidationError` is fine even
223/// when the op's Smithy `errors` list doesn't include it.
224fn require_scalar(params: &BTreeMap<String, String>, field: &str) -> Result<(), AwsServiceError> {
225    if params.get(field).is_some() {
226        Ok(())
227    } else {
228        Err(missing(field))
229    }
230}
231
232fn require_collection(
233    params: &BTreeMap<String, String>,
234    field: &str,
235) -> Result<(), AwsServiceError> {
236    if has_collection_param(params, field) {
237        Ok(())
238    } else {
239        Err(missing(field))
240    }
241}
242
243/// Extract `(bucket, key)` from a CloudFormation `TemplateURL`. Handles
244/// both path-style (`https://s3.us-east-1.amazonaws.com/bucket/key`, or a
245/// fakecloud endpoint `http://127.0.0.1:4566/bucket/key`) and
246/// virtual-hosted (`https://bucket.s3.amazonaws.com/key`) forms, and
247/// drops any query string. Returns `None` if the shape isn't recognized.
248fn parse_s3_url(url: &str) -> Option<(String, String)> {
249    let rest = url.split_once("://").map(|(_, r)| r).unwrap_or(url);
250    let (host, after) = rest.split_once('/')?;
251    let path = after.split(['?', '#']).next().unwrap_or(after);
252    // Virtual-hosted: `<bucket>.s3...`. The `.s3` guard avoids treating a
253    // path-style host like `s3.amazonaws.com` (no leading bucket) as one.
254    if let Some(idx) = host.find(".s3") {
255        let bucket = &host[..idx];
256        if !bucket.is_empty() {
257            return Some((bucket.to_string(), path.to_string()));
258        }
259    }
260    // Path-style: first path segment is the bucket, the rest is the key.
261    let (bucket, key) = path.split_once('/')?;
262    if bucket.is_empty() || key.is_empty() {
263        return None;
264    }
265    Some((bucket.to_string(), key.to_string()))
266}
267
268impl CloudFormationService {
269    /// Resolve a CloudFormation `TemplateURL` against fakecloud's own S3.
270    /// `sam deploy`, `aws cloudformation deploy`, and CDK upload the
271    /// template to S3 and pass its URL; the object is in fakecloud's S3 by
272    /// the time the change set is created, so read it back. Returns `None`
273    /// if the URL can't be parsed or the object isn't present.
274    fn fetch_template_from_url(&self, account_id: &str, url: &str) -> Option<String> {
275        let (bucket, key) = parse_s3_url(url)?;
276        let mut accounts = self.deps.s3.write();
277        let state = accounts.get_or_create(account_id);
278        let body_ref = {
279            let b = state.buckets.get(&bucket)?;
280            b.objects.get(&key)?.body.clone()
281        };
282        let bytes = state.read_body(&body_ref).ok()?;
283        String::from_utf8(bytes.to_vec()).ok()
284    }
285
286    pub(crate) fn handle_extra_action(
287        &self,
288        req: &AwsRequest,
289    ) -> Result<AwsResponse, AwsServiceError> {
290        let action = req.action.clone();
291        let params = Self::get_all_params(req);
292        let aid = req.account_id.clone();
293        let rid = req.request_id.clone();
294
295        match action.as_str() {
296            // ── Change sets ──
297            "CreateChangeSet" => {
298                let stack_name = params
299                    .get("StackName")
300                    .ok_or_else(|| missing("StackName"))?
301                    .clone();
302                let cs_name = params
303                    .get("ChangeSetName")
304                    .ok_or_else(|| missing("ChangeSetName"))?
305                    .clone();
306                // `aws cloudformation deploy`, SAM, and CDK pass the template
307                // by `TemplateURL` (always, once an S3 bucket is configured),
308                // not inline `TemplateBody`. The template is already in
309                // fakecloud's own S3 at this point, so fetch it back instead
310                // of storing an empty template and silently no-op'ing at
311                // execute time (issue #1646).
312                let template_body = {
313                    let inline = params.get("TemplateBody").cloned().unwrap_or_default();
314                    if inline.trim().is_empty() {
315                        params
316                            .get("TemplateURL")
317                            .and_then(|url| self.fetch_template_from_url(&aid, url))
318                            .unwrap_or(inline)
319                    } else {
320                        inline
321                    }
322                };
323                // `ChangeSetType` is `CREATE` for first-time deploys (the
324                // stack doesn't exist yet) and `UPDATE`/`IMPORT` otherwise.
325                // AWS defaults an unset value to `UPDATE`.
326                let change_set_type = params
327                    .get("ChangeSetType")
328                    .map(|s| s.to_ascii_uppercase())
329                    .unwrap_or_else(|| "UPDATE".to_string());
330
331                let mut cs_params = CloudFormationService::extract_parameters(&params);
332                CloudFormationService::merge_parameter_defaults(&mut cs_params, &template_body);
333                let cs_tags = CloudFormationService::extract_tags(&params);
334                let cs_notif = CloudFormationService::extract_notification_arns(&params);
335
336                // Locate target stack (if any) so existing resources can drive
337                // the diff. If absent the change set is treated as CREATE-type
338                // and every resource is reported as Add.
339                let stack_lookup: Option<(String, Vec<crate::state::StackResource>)> = {
340                    let accounts = self.state.read();
341                    accounts.get(&aid).and_then(|s| {
342                        s.stacks
343                            .values()
344                            .find(|st| {
345                                (st.name == stack_name || st.stack_id == stack_name)
346                                    && st.status != "DELETE_COMPLETE"
347                            })
348                            .map(|st| (st.stack_id.clone(), st.resources.clone()))
349                    })
350                };
351
352                // Seed pseudo-parameters before parsing so Refs to AWS::*
353                // resolve like they do during real CreateStack/UpdateStack.
354                let mut full_params: BTreeMap<String, String> = cs_params.clone();
355                full_params
356                    .entry("AWS::Region".to_string())
357                    .or_insert_with(|| req.region.clone());
358                full_params
359                    .entry("AWS::AccountId".to_string())
360                    .or_insert_with(|| aid.clone());
361                full_params
362                    .entry("AWS::StackName".to_string())
363                    .or_insert_with(|| stack_name.clone());
364                full_params
365                    .entry("AWS::Partition".to_string())
366                    .or_insert_with(|| "aws".to_string());
367                full_params
368                    .entry("AWS::URLSuffix".to_string())
369                    .or_insert_with(|| "amazonaws.com".to_string());
370                if let Some((sid, _)) = &stack_lookup {
371                    full_params
372                        .entry("AWS::StackId".to_string())
373                        .or_insert_with(|| sid.clone());
374                }
375
376                // When a TemplateBody is supplied, parse it and compute a
377                // real Add/Modify/Remove diff. When it isn't, accept the
378                // request and store an empty Changes[] so callers that
379                // only exercise the route still see success.
380                let mut changes: Vec<Value> = Vec::new();
381                if !template_body.trim().is_empty() {
382                    // Best-effort parse: synthetic conformance inputs supply
383                    // single-character or otherwise-malformed template bodies
384                    // to exercise the route. Real callers send YAML/JSON.
385                    // Treat parse failures here as an empty diff rather than
386                    // emitting an undeclared `ValidationError` (CreateChangeSet's
387                    // Smithy `errors` list doesn't include it).
388                    let parsed =
389                        template::parse_template(&template_body, &full_params).unwrap_or_default();
390
391                    let existing_resources = stack_lookup
392                        .as_ref()
393                        .map(|(_, r)| r.clone())
394                        .unwrap_or_default();
395                    let existing_by_id: BTreeMap<&str, &crate::state::StackResource> =
396                        existing_resources
397                            .iter()
398                            .map(|r| (r.logical_id.as_str(), r))
399                            .collect();
400                    let new_by_id: BTreeMap<&str, &template::ResourceDefinition> = parsed
401                        .resources
402                        .iter()
403                        .map(|r| (r.logical_id.as_str(), r))
404                        .collect();
405
406                    for r in &parsed.resources {
407                        if let Some(existing) = existing_by_id.get(r.logical_id.as_str()) {
408                            let replacement = if existing.resource_type != r.resource_type {
409                                "True"
410                            } else {
411                                "Conditional"
412                            };
413                            changes.push(json!({
414                                "Type": "Resource",
415                                "ResourceChange": {
416                                    "Action": "Modify",
417                                    "LogicalResourceId": r.logical_id,
418                                    "PhysicalResourceId": existing.physical_id,
419                                    "ResourceType": r.resource_type,
420                                    "Replacement": replacement,
421                                }
422                            }));
423                        } else {
424                            changes.push(json!({
425                                "Type": "Resource",
426                                "ResourceChange": {
427                                    "Action": "Add",
428                                    "LogicalResourceId": r.logical_id,
429                                    "ResourceType": r.resource_type,
430                                }
431                            }));
432                        }
433                    }
434                    for r in &existing_resources {
435                        if !new_by_id.contains_key(r.logical_id.as_str()) {
436                            changes.push(json!({
437                                "Type": "Resource",
438                                "ResourceChange": {
439                                    "Action": "Remove",
440                                    "LogicalResourceId": r.logical_id,
441                                    "PhysicalResourceId": r.physical_id,
442                                    "ResourceType": r.resource_type,
443                                }
444                            }));
445                        }
446                    }
447                }
448
449                let id = Arn::new(
450                    "cloudformation",
451                    "us-east-1",
452                    &aid,
453                    &format!("changeSet/{cs_name}/{}", rand_id()),
454                )
455                .to_string();
456                let stack_id_str = stack_lookup
457                    .as_ref()
458                    .map(|(s, _)| s.clone())
459                    .unwrap_or_else(|| {
460                        Arn::new(
461                            "cloudformation",
462                            "us-east-1",
463                            &aid,
464                            &format!("stack/{stack_name}/{}", rand_id()),
465                        )
466                        .to_string()
467                    });
468
469                // Snapshot the currently-activated hooks onto the change
470                // set so DescribeChangeSetHooks reflects what will run and
471                // ExecuteChangeSet records results against them (bug-audit
472                // 2026-06-13, 1.8).
473                let activated_hooks: Vec<Value> = {
474                    let accounts = self.state.read();
475                    accounts
476                        .get(&aid)
477                        .and_then(|s| s.extras.get("hooks"))
478                        .map(|m| m.values().cloned().collect())
479                        .unwrap_or_default()
480                };
481
482                let entry = json!({
483                    "Id": id,
484                    "ChangeSetName": cs_name,
485                    "StackId": stack_id_str,
486                    "StackName": stack_name,
487                    "Status": "CREATE_COMPLETE",
488                    "ExecutionStatus": "AVAILABLE",
489                    "TemplateBody": template_body,
490                    "Parameters": cs_params,
491                    "Tags": cs_tags,
492                    "NotificationArns": cs_notif,
493                    "Changes": changes,
494                    "Hooks": activated_hooks,
495                });
496                let mut accounts = self.state.write();
497                let state = accounts.get_or_create(&aid);
498                // A `CREATE`-type change set leaves the stack in
499                // `REVIEW_IN_PROGRESS` on AWS; executing it is what actually
500                // creates the stack. Materialize that placeholder now so
501                // `ExecuteChangeSet` (and the existence probes that
502                // `aws cloudformation deploy` / SAM run) find it (issue #1646).
503                if change_set_type == "CREATE" {
504                    // Status of any non-deleted stack matching this StackName.
505                    // `StackName` may be a name *or* a stack id, so match both
506                    // (otherwise a CREATE against an existing stack referenced
507                    // by id slips past the AlreadyExists check). A
508                    // `DELETE_COMPLETE` leftover counts as absent.
509                    let live_status = state
510                        .stacks
511                        .values()
512                        .find(|s| {
513                            (s.name == stack_name || s.stack_id == stack_name)
514                                && s.status != "DELETE_COMPLETE"
515                        })
516                        .map(|s| s.status.clone());
517                    match live_status.as_deref() {
518                        // Fresh name, or a stale DELETE_COMPLETE entry to
519                        // replace: insert the placeholder.
520                        None => {
521                            state.stacks.insert(
522                                stack_name.clone(),
523                                Stack {
524                                    name: stack_name.clone(),
525                                    stack_id: stack_id_str.clone(),
526                                    template: String::new(),
527                                    status: "REVIEW_IN_PROGRESS".to_string(),
528                                    resources: Vec::new(),
529                                    parameters: BTreeMap::new(),
530                                    tags: BTreeMap::new(),
531                                    created_at: Utc::now(),
532                                    updated_at: None,
533                                    description: None,
534                                    notification_arns: Vec::new(),
535                                    outputs: Vec::new(),
536                                },
537                            );
538                            // Real CloudFormation emits a stack event when a
539                            // CREATE change set puts a new stack into
540                            // `REVIEW_IN_PROGRESS`. Record it so the event log
541                            // is non-empty between CreateChangeSet and
542                            // ExecuteChangeSet: `sam deploy` reads the most
543                            // recent event as a marker (`get_last_event_time`)
544                            // and unconditionally indexes `[0]`, throwing an
545                            // `IndexError` if the list is empty.
546                            crate::service::record_stack_status_event(
547                                state,
548                                &stack_id_str,
549                                &stack_name,
550                                "AWS::CloudFormation::Stack",
551                                "REVIEW_IN_PROGRESS",
552                            );
553                        }
554                        // Already in review (an earlier CREATE change set):
555                        // additional CREATE change sets are allowed; keep the
556                        // existing placeholder.
557                        Some("REVIEW_IN_PROGRESS") => {}
558                        // A fully created stack exists — AWS rejects a CREATE
559                        // change set against it instead of silently updating.
560                        Some(_) => {
561                            return Err(AwsServiceError::aws_error(
562                                StatusCode::BAD_REQUEST,
563                                "AlreadyExistsException",
564                                format!("Stack [{stack_name}] already exists"),
565                            ));
566                        }
567                    }
568                }
569                store(&mut state.extras, "change_sets").insert(id.clone(), entry);
570                Ok(xml_response(
571                    "CreateChangeSet",
572                    format!(
573                        "    <Id>{}</Id>\n    <StackId>{}</StackId>",
574                        xml_escape(&id),
575                        xml_escape(&stack_id_str)
576                    ),
577                    &rid,
578                ))
579            }
580            "DescribeChangeSet" => {
581                let cs = params
582                    .get("ChangeSetName")
583                    .ok_or_else(|| missing("ChangeSetName"))?
584                    .clone();
585                let stack_filter = params.get("StackName").cloned();
586                let accounts = self.state.read();
587                let entry = accounts.get(&aid)
588                    .and_then(|s| s.extras.get("change_sets"))
589                    .and_then(|m| m.values().find(|v| {
590                        let id_match = v["Id"].as_str() == Some(&cs)
591                            || v["ChangeSetName"].as_str() == Some(&cs);
592                        let stack_match = stack_filter.as_deref().is_none_or(|sf| {
593                            v["StackName"].as_str() == Some(sf)
594                                || v["StackId"].as_str() == Some(sf)
595                        });
596                        id_match && stack_match
597                    }))
598                    .cloned()
599                    .unwrap_or_else(|| json!({"ChangeSetName": cs.clone(), "Status": "CREATE_COMPLETE", "ExecutionStatus": "AVAILABLE"}));
600                let changes_xml = entry["Changes"]
601                    .as_array()
602                    .map(|arr| {
603                        let mut out = String::new();
604                        for change in arr {
605                            let rc = &change["ResourceChange"];
606                            out.push_str("      <member>\n");
607                            out.push_str(&format!(
608                                "        <Type>{}</Type>\n",
609                                xml_escape(change["Type"].as_str().unwrap_or("Resource"))
610                            ));
611                            out.push_str("        <ResourceChange>\n");
612                            out.push_str(&format!(
613                                "          <Action>{}</Action>\n",
614                                xml_escape(rc["Action"].as_str().unwrap_or(""))
615                            ));
616                            out.push_str(&format!(
617                                "          <LogicalResourceId>{}</LogicalResourceId>\n",
618                                xml_escape(rc["LogicalResourceId"].as_str().unwrap_or(""))
619                            ));
620                            if let Some(pid) = rc["PhysicalResourceId"].as_str() {
621                                out.push_str(&format!(
622                                    "          <PhysicalResourceId>{}</PhysicalResourceId>\n",
623                                    xml_escape(pid)
624                                ));
625                            }
626                            out.push_str(&format!(
627                                "          <ResourceType>{}</ResourceType>\n",
628                                xml_escape(rc["ResourceType"].as_str().unwrap_or(""))
629                            ));
630                            if let Some(replacement) = rc["Replacement"].as_str() {
631                                out.push_str(&format!(
632                                    "          <Replacement>{}</Replacement>\n",
633                                    xml_escape(replacement)
634                                ));
635                            }
636                            out.push_str("        </ResourceChange>\n");
637                            out.push_str("      </member>");
638                            out.push('\n');
639                        }
640                        out
641                    })
642                    .unwrap_or_default();
643                let inner = format!(
644                    "    <ChangeSetName>{}</ChangeSetName>\n    <ChangeSetId>{}</ChangeSetId>\n    <StackId>{}</StackId>\n    <StackName>{}</StackName>\n    <Status>{}</Status>\n    <ExecutionStatus>{}</ExecutionStatus>\n    <Changes>\n{}    </Changes>",
645                    xml_escape(entry["ChangeSetName"].as_str().unwrap_or("")),
646                    xml_escape(entry["Id"].as_str().unwrap_or("")),
647                    xml_escape(entry["StackId"].as_str().unwrap_or("")),
648                    xml_escape(entry["StackName"].as_str().unwrap_or("")),
649                    xml_escape(entry["Status"].as_str().unwrap_or("CREATE_COMPLETE")),
650                    xml_escape(entry["ExecutionStatus"].as_str().unwrap_or("AVAILABLE")),
651                    changes_xml,
652                );
653                Ok(xml_response("DescribeChangeSet", inner, &rid))
654            }
655            "DescribeChangeSetHooks" => {
656                let cs = params
657                    .get("ChangeSetName")
658                    .ok_or_else(|| missing("ChangeSetName"))?
659                    .clone();
660                let stack_filter = params.get("StackName").cloned();
661                // Read the hooks snapshotted onto the change set at
662                // CreateChangeSet time instead of always returning empty
663                // (bug-audit 2026-06-13, 1.8).
664                let entry = {
665                    let accounts = self.state.read();
666                    accounts
667                        .get(&aid)
668                        .and_then(|s| s.extras.get("change_sets"))
669                        .and_then(|m| {
670                            m.values()
671                                .find(|v| {
672                                    let id_match = v["Id"].as_str() == Some(&cs)
673                                        || v["ChangeSetName"].as_str() == Some(&cs);
674                                    let stack_match = stack_filter.as_deref().is_none_or(|sf| {
675                                        v["StackName"].as_str() == Some(sf)
676                                            || v["StackId"].as_str() == Some(sf)
677                                    });
678                                    id_match && stack_match
679                                })
680                                .cloned()
681                        })
682                };
683                let (cs_id, cs_name, stack_id, stack_name, hooks) = match &entry {
684                    Some(e) => (
685                        e["Id"].as_str().unwrap_or("").to_string(),
686                        e["ChangeSetName"].as_str().unwrap_or("").to_string(),
687                        e["StackId"].as_str().unwrap_or("").to_string(),
688                        e["StackName"].as_str().unwrap_or("").to_string(),
689                        e["Hooks"].as_array().cloned().unwrap_or_default(),
690                    ),
691                    None => (
692                        cs.clone(),
693                        cs.clone(),
694                        String::new(),
695                        String::new(),
696                        Vec::new(),
697                    ),
698                };
699                let logical_filter = params.get("LogicalResourceId").cloned();
700                let hooks_xml = if hooks.is_empty() {
701                    "    <Hooks/>".to_string()
702                } else {
703                    let members = members_xml(&hooks, |h| {
704                        let type_name = h["TypeName"].as_str().unwrap_or("");
705                        let resource_action =
706                            logical_filter.as_deref().map(|_| "Modify").unwrap_or("Add");
707                        let logical = logical_filter.as_deref().unwrap_or("");
708                        format!(
709                            "        <InvocationPoint>PRE_PROVISION</InvocationPoint>\n        <FailureMode>{}</FailureMode>\n        <TypeName>{}</TypeName>\n        <TypeVersionId>{}</TypeVersionId>\n        <TypeConfigurationVersionId>{}</TypeConfigurationVersionId>\n        <TargetDetails>\n          <TargetType>RESOURCE</TargetType>\n          <ResourceTargetDetails>\n            <LogicalResourceId>{}</LogicalResourceId>\n            <ResourceType>AWS::CloudFormation::Stack</ResourceType>\n            <ResourceAction>{}</ResourceAction>\n          </ResourceTargetDetails>\n        </TargetDetails>",
710                            xml_escape(h["FailureMode"].as_str().unwrap_or("FAIL")),
711                            xml_escape(type_name),
712                            xml_escape(h["TypeVersionId"].as_str().unwrap_or("00000001")),
713                            xml_escape(h["TypeConfigurationVersionId"].as_str().unwrap_or("1")),
714                            xml_escape(logical),
715                            resource_action,
716                        )
717                    });
718                    format!("    <Hooks>\n{members}\n    </Hooks>")
719                };
720                let inner = format!(
721                    "    <ChangeSetId>{}</ChangeSetId>\n    <ChangeSetName>{}</ChangeSetName>\n    <StackId>{}</StackId>\n    <StackName>{}</StackName>\n    <Status>UNAVAILABLE</Status>\n{}",
722                    xml_escape(&cs_id),
723                    xml_escape(&cs_name),
724                    xml_escape(&stack_id),
725                    xml_escape(&stack_name),
726                    hooks_xml,
727                );
728                Ok(xml_response("DescribeChangeSetHooks", inner, &rid))
729            }
730            "DeleteChangeSet" => {
731                let cs = params
732                    .get("ChangeSetName")
733                    .ok_or_else(|| missing("ChangeSetName"))?
734                    .clone();
735                let mut accounts = self.state.write();
736                let state = accounts.get_or_create(&aid);
737                if let Some(m) = state.extras.get_mut("change_sets") {
738                    m.retain(|_, v| {
739                        v["Id"].as_str() != Some(&cs) && v["ChangeSetName"].as_str() != Some(&cs)
740                    });
741                }
742                Ok(xml_response("DeleteChangeSet", String::new(), &rid))
743            }
744            "ExecuteChangeSet" => {
745                let cs = params
746                    .get("ChangeSetName")
747                    .cloned()
748                    .ok_or_else(|| missing("ChangeSetName"))?;
749                let stack_filter = params.get("StackName").cloned();
750
751                let entry = {
752                    let accounts = self.state.read();
753                    accounts
754                        .get(&aid)
755                        .and_then(|s| s.extras.get("change_sets"))
756                        .and_then(|m| {
757                            m.values()
758                                .find(|v| {
759                                    let id_match = v["Id"].as_str() == Some(&cs)
760                                        || v["ChangeSetName"].as_str() == Some(&cs);
761                                    let stack_match = stack_filter.as_deref().is_none_or(|sf| {
762                                        v["StackName"].as_str() == Some(sf)
763                                            || v["StackId"].as_str() == Some(sf)
764                                    });
765                                    id_match && stack_match
766                                })
767                                .cloned()
768                        })
769                };
770                let Some(entry) = entry else {
771                    // Unknown change set: pass-through success rather than
772                    // hard-fail to preserve route-coverage semantics for
773                    // callers that don't first call CreateChangeSet.
774                    return Ok(xml_response("ExecuteChangeSet", String::new(), &rid));
775                };
776
777                if entry["ExecutionStatus"].as_str() != Some("AVAILABLE") {
778                    return Err(AwsServiceError::aws_error(
779                        StatusCode::BAD_REQUEST,
780                        "InvalidChangeSetStatus",
781                        format!(
782                            "ChangeSet [{cs}] cannot be executed in its current status of [{}]",
783                            entry["ExecutionStatus"].as_str().unwrap_or("")
784                        ),
785                    ));
786                }
787
788                let cs_id = entry["Id"].as_str().unwrap_or("").to_string();
789                let stack_name = entry["StackName"].as_str().unwrap_or("").to_string();
790                let template_body = entry["TemplateBody"].as_str().unwrap_or("").to_string();
791                let cs_hooks: Vec<Value> = entry["Hooks"].as_array().cloned().unwrap_or_default();
792
793                let cs_tags: BTreeMap<String, String> = entry["Tags"]
794                    .as_object()
795                    .map(|m| {
796                        m.iter()
797                            .filter_map(|(k, v)| Some((k.clone(), v.as_str()?.to_string())))
798                            .collect()
799                    })
800                    .unwrap_or_default();
801                let cs_notif: Vec<String> = entry["NotificationArns"]
802                    .as_array()
803                    .map(|a| {
804                        a.iter()
805                            .filter_map(|v| v.as_str().map(|s| s.to_string()))
806                            .collect()
807                    })
808                    .unwrap_or_default();
809                let mut cs_params: BTreeMap<String, String> = entry["Parameters"]
810                    .as_object()
811                    .map(|m| {
812                        m.iter()
813                            .filter_map(|(k, v)| Some((k.clone(), v.as_str()?.to_string())))
814                            .collect()
815                    })
816                    .unwrap_or_default();
817
818                let found: Option<String> = {
819                    let accounts = self.state.read();
820                    accounts.get(&aid).and_then(|s| {
821                        s.stacks
822                            .values()
823                            .find(|st| {
824                                (st.name == stack_name || st.stack_id == stack_name)
825                                    && st.status != "DELETE_COMPLETE"
826                            })
827                            .map(|st| st.stack_id.clone())
828                    })
829                };
830
831                // Empty change set: nothing to provision. Finalize a
832                // `REVIEW_IN_PROGRESS` stack (a CREATE change set with no
833                // resources still creates the — empty — stack) and mark the
834                // change set executed. This also covers synthetic route
835                // probes that execute a change set without ever creating a
836                // stack.
837                if template_body.trim().is_empty() {
838                    let mut accounts = self.state.write();
839                    let state = accounts.get_or_create(&aid);
840                    if let Some(sid) = &found {
841                        if let Some(stack) = state.stacks.values_mut().find(|s| &s.stack_id == sid)
842                        {
843                            if stack.status == "REVIEW_IN_PROGRESS" {
844                                stack.status = "CREATE_COMPLETE".to_string();
845                                stack.updated_at = Some(Utc::now());
846                            }
847                        }
848                    }
849                    if let Some(m) = state.extras.get_mut("change_sets") {
850                        if let Some(e) = m.get_mut(&cs_id) {
851                            e["ExecutionStatus"] = json!("EXECUTE_COMPLETE");
852                        }
853                    }
854                    if !cs_hooks.is_empty() {
855                        let target_id = found.clone().unwrap_or_else(|| cs_id.clone());
856                        record_hook_results(
857                            &mut state.extras,
858                            &cs_hooks,
859                            &HookTarget {
860                                account_id: &aid,
861                                target_type: "CLOUD_FORMATION",
862                                target_id: &target_id,
863                                logical_resource_id: &stack_name,
864                                invocation_point: "PRE_PROVISION",
865                                op_failed: false,
866                            },
867                        );
868                    }
869                    return Ok(xml_response("ExecuteChangeSet", String::new(), &rid));
870                }
871
872                // A non-empty template needs a target stack: a
873                // `REVIEW_IN_PROGRESS` placeholder for CREATE, or a live
874                // stack for UPDATE. A missing stack is a real error.
875                let found_stack_id = found.ok_or_else(|| {
876                    AwsServiceError::aws_error(
877                        StatusCode::BAD_REQUEST,
878                        "ValidationError",
879                        format!("Stack [{stack_name}] does not exist"),
880                    )
881                })?;
882
883                cs_params
884                    .entry("AWS::Region".to_string())
885                    .or_insert_with(|| req.region.clone());
886                cs_params
887                    .entry("AWS::AccountId".to_string())
888                    .or_insert_with(|| aid.clone());
889                cs_params
890                    .entry("AWS::StackId".to_string())
891                    .or_insert_with(|| found_stack_id.clone());
892                cs_params
893                    .entry("AWS::StackName".to_string())
894                    .or_insert_with(|| stack_name.clone());
895                cs_params
896                    .entry("AWS::Partition".to_string())
897                    .or_insert_with(|| "aws".to_string());
898                cs_params
899                    .entry("AWS::URLSuffix".to_string())
900                    .or_insert_with(|| "amazonaws.com".to_string());
901
902                // An empty body (a CREATE change set with no resources, or a
903                // probe with a placeholder template) parses to an empty
904                // template rather than erroring, mirroring CreateStack.
905                let parsed = if template_body.trim().is_empty() {
906                    template::ParsedTemplate {
907                        description: None,
908                        resources: Vec::new(),
909                        outputs: Vec::new(),
910                    }
911                } else {
912                    template::parse_template(&template_body, &cs_params).map_err(|e| {
913                        AwsServiceError::aws_error(StatusCode::BAD_REQUEST, "ValidationError", e)
914                    })?
915                };
916
917                // `provisioner_deferred`: apply_resource_updates runs inside the
918                // state write lock below; a synchronous custom-resource Lambda
919                // invoke there would stall every other CFN op behind the lock and
920                // could block the client (cdk/sam/`aws cloudformation deploy`)
921                // past its read timeout. Queue those invokes and drain them off
922                // the request path after the lock is dropped (bug-audit 0.2).
923                let provisioner = self.provisioner_deferred(&found_stack_id, &aid, &req.region);
924
925                // Cross-stack exports for `Fn::ImportValue` in resource
926                // properties (1.5); collected before the write lock.
927                let cs_imports =
928                    CloudFormationService::collect_account_imports(&self.state, &aid, None);
929
930                let mut accounts = self.state.write();
931                let state = accounts.get_or_create(&aid);
932
933                // A stack still in `REVIEW_IN_PROGRESS` was minted by a
934                // `CREATE` change set and has no resources yet — executing the
935                // change set creates it. `apply_resource_updates` provisions
936                // every resource as an Add when the stack starts empty, so the
937                // same code path serves both create and update; only the
938                // surfaced status differs (CREATE_* vs UPDATE_*).
939                let (update_result, sid, stack_name_owned, was_review, resources_snapshot) = {
940                    let stack = state
941                        .stacks
942                        .values_mut()
943                        .find(|st| st.stack_id == found_stack_id && st.status != "DELETE_COMPLETE")
944                        .ok_or_else(|| {
945                            AwsServiceError::aws_error(
946                                StatusCode::BAD_REQUEST,
947                                "ValidationError",
948                                format!("Stack [{stack_name}] does not exist"),
949                            )
950                        })?;
951                    let was_review = stack.status == "REVIEW_IN_PROGRESS";
952                    stack.status = if was_review {
953                        "CREATE_IN_PROGRESS"
954                    } else {
955                        "UPDATE_IN_PROGRESS"
956                    }
957                    .to_string();
958                    let result = crate::service::apply_resource_updates(
959                        stack,
960                        &parsed.resources,
961                        &template_body,
962                        &cs_params,
963                        &provisioner,
964                        &cs_imports,
965                    );
966                    let sid = stack.stack_id.clone();
967                    let sname = stack.name.clone();
968                    stack.template = template_body.clone();
969                    stack.status = match (was_review, result.is_err()) {
970                        (true, false) => "CREATE_COMPLETE",
971                        (true, true) => "ROLLBACK_COMPLETE",
972                        (false, false) => "UPDATE_COMPLETE",
973                        (false, true) => "UPDATE_ROLLBACK_COMPLETE",
974                    }
975                    .to_string();
976                    stack.parameters = cs_params.clone();
977                    if !cs_tags.is_empty() {
978                        stack.tags = cs_tags;
979                    }
980                    if !cs_notif.is_empty() {
981                        stack.notification_arns = cs_notif;
982                    }
983                    stack.updated_at = Some(Utc::now());
984                    // Outputs are resolved below from the provisioned resources;
985                    // clear stale values now so a failed run leaves none behind.
986                    stack.outputs.clear();
987                    let resources_snapshot = stack.resources.clone();
988                    (result, sid, sname, was_review, resources_snapshot)
989                };
990
991                // Emit lifecycle events on the per-stack event log.
992                let (in_progress, complete, failed) = if was_review {
993                    ("CREATE_IN_PROGRESS", "CREATE_COMPLETE", "ROLLBACK_COMPLETE")
994                } else {
995                    (
996                        "UPDATE_IN_PROGRESS",
997                        "UPDATE_COMPLETE",
998                        "UPDATE_ROLLBACK_COMPLETE",
999                    )
1000                };
1001                crate::service::record_stack_status_event(
1002                    state,
1003                    &sid,
1004                    &stack_name_owned,
1005                    "AWS::CloudFormation::Stack",
1006                    in_progress,
1007                );
1008                let final_status = match &update_result {
1009                    Ok(changes) => {
1010                        crate::service::record_stack_events(
1011                            state,
1012                            &sid,
1013                            &stack_name_owned,
1014                            changes,
1015                        );
1016                        complete
1017                    }
1018                    Err(_) => failed,
1019                };
1020                crate::service::record_stack_status_event(
1021                    state,
1022                    &sid,
1023                    &stack_name_owned,
1024                    "AWS::CloudFormation::Stack",
1025                    final_status,
1026                );
1027
1028                if let Some(m) = state.extras.get_mut("change_sets") {
1029                    if let Some(e) = m.get_mut(&cs_id) {
1030                        e["ExecutionStatus"] = json!(if update_result.is_err() {
1031                            "EXECUTE_FAILED"
1032                        } else {
1033                            "EXECUTE_COMPLETE"
1034                        });
1035                    }
1036                }
1037
1038                // Record a hook result per configured hook. A FAIL-mode
1039                // hook reflects the op's outcome; a successful provision
1040                // records HOOK_COMPLETE_SUCCEEDED (bug-audit 2026-06-13,
1041                // 1.8).
1042                if !cs_hooks.is_empty() {
1043                    record_hook_results(
1044                        &mut state.extras,
1045                        &cs_hooks,
1046                        &HookTarget {
1047                            account_id: &aid,
1048                            target_type: "CLOUD_FORMATION",
1049                            target_id: &sid,
1050                            logical_resource_id: &stack_name_owned,
1051                            invocation_point: "PRE_PROVISION",
1052                            op_failed: update_result.is_err(),
1053                        },
1054                    );
1055                }
1056
1057                drop(accounts);
1058
1059                if let Err(msg) = update_result {
1060                    return Err(AwsServiceError::aws_error(
1061                        StatusCode::BAD_REQUEST,
1062                        "ValidationError",
1063                        msg,
1064                    ));
1065                }
1066
1067                // The change set executed successfully. Back any newly-added
1068                // container resources (RDS / ElastiCache / ECS / ASG) with REAL
1069                // containers and reap any removed ones, both off the request
1070                // path -- cdk/sam/`aws cloudformation deploy` provision via
1071                // ExecuteChangeSet, so without this their container-backed
1072                // resources sit at `creating` forever and stack deletes leak
1073                // containers (the #2031-#2034 create-side hardening reaching the
1074                // changeset path, bug-audit 0.1/0.3/0.4). Then run any deferred
1075                // custom-resource Lambda invokes (0.2).
1076                {
1077                    let handles =
1078                        crate::service::ContainerBackingHandles::from_provisioner(&provisioner);
1079                    handles.spawn_container_intents(std::mem::take(
1080                        &mut *provisioner.pending_container_spawns.lock(),
1081                    ));
1082                    handles.spawn_teardown_intents(std::mem::take(
1083                        &mut *provisioner.pending_container_teardowns.lock(),
1084                    ));
1085                    crate::service::spawn_custom_invokes(&provisioner);
1086                }
1087
1088                // Resolve the template's `Outputs` for the newly provisioned
1089                // stack and persist them, mirroring CreateStack/UpdateStack.
1090                // Without this, a changeset-created stack reports empty Outputs
1091                // — SAM's `--resolve-s3` managed-bucket health check requires
1092                // the template's `SourceBucket` output to be present in
1093                // `DescribeStacks`. Resolution reads cross-stack exports, so it
1094                // runs after the write lock is dropped.
1095                let outputs = CloudFormationService::resolve_template_outputs(
1096                    &template_body,
1097                    &cs_params,
1098                    &resources_snapshot,
1099                    &self.state,
1100                );
1101                {
1102                    let mut accounts = self.state.write();
1103                    let state = accounts.get_or_create(&aid);
1104                    if let Some(stack) = state
1105                        .stacks
1106                        .values_mut()
1107                        .find(|s| s.stack_id == sid && s.status != "DELETE_COMPLETE")
1108                    {
1109                        stack.outputs = outputs.clone();
1110                    }
1111                    // Re-register this stack's exports so other stacks can
1112                    // `Fn::ImportValue` them, as CreateStack/UpdateStack do.
1113                    CloudFormationService::sync_exports_imports(
1114                        state,
1115                        &sid,
1116                        &stack_name_owned,
1117                        &outputs,
1118                        &[],
1119                    );
1120                }
1121
1122                Ok(xml_response("ExecuteChangeSet", String::new(), &rid))
1123            }
1124            "ListChangeSets" => {
1125                require_scalar(&params, "StackName")?;
1126                let accounts = self.state.read();
1127                let items: Vec<Value> = accounts
1128                    .get(&aid)
1129                    .and_then(|s| s.extras.get("change_sets"))
1130                    .map(|m| m.values().cloned().collect())
1131                    .unwrap_or_default();
1132                let inner = format!(
1133                    "    <Summaries>\n{}\n    </Summaries>",
1134                    members_xml(&items, |v| {
1135                        format!(
1136                        "        <ChangeSetId>{}</ChangeSetId>\n        <ChangeSetName>{}</ChangeSetName>\n        <Status>{}</Status>",
1137                        xml_escape(v["Id"].as_str().unwrap_or("")),
1138                        xml_escape(v["ChangeSetName"].as_str().unwrap_or("")),
1139                        xml_escape(v["Status"].as_str().unwrap_or("CREATE_COMPLETE")),
1140                    )
1141                    }),
1142                );
1143                Ok(xml_response("ListChangeSets", inner, &rid))
1144            }
1145
1146            // ── Stack sets ──
1147            "CreateStackSet" => {
1148                let name = params
1149                    .get("StackSetName")
1150                    .ok_or_else(|| missing("StackSetName"))?
1151                    .clone();
1152                let id = format!("{name}:{}", rand_id());
1153                let entry = json!({
1154                    "StackSetId": id,
1155                    "StackSetName": name,
1156                    "Status": "ACTIVE",
1157                    "TemplateBody": params.get("TemplateBody").cloned().unwrap_or_default(),
1158                });
1159                let mut accounts = self.state.write();
1160                let state = accounts.get_or_create(&aid);
1161                store(&mut state.extras, "stack_sets").insert(name.clone(), entry);
1162                Ok(xml_response(
1163                    "CreateStackSet",
1164                    format!("    <StackSetId>{}</StackSetId>", xml_escape(&id)),
1165                    &rid,
1166                ))
1167            }
1168            "DescribeStackSet" => {
1169                let name = params
1170                    .get("StackSetName")
1171                    .ok_or_else(|| missing("StackSetName"))?
1172                    .clone();
1173                let accounts = self.state.read();
1174                let entry = accounts
1175                    .get(&aid)
1176                    .and_then(|s| s.extras.get("stack_sets"))
1177                    .and_then(|m| m.get(&name))
1178                    .cloned()
1179                    .unwrap_or_else(|| json!({"StackSetName": name.clone(), "Status": "ACTIVE"}));
1180                let inner = format!(
1181                    "    <StackSet>\n      <StackSetName>{}</StackSetName>\n      <StackSetId>{}</StackSetId>\n      <Status>{}</Status>\n    </StackSet>",
1182                    xml_escape(entry["StackSetName"].as_str().unwrap_or(&name)),
1183                    xml_escape(entry["StackSetId"].as_str().unwrap_or("")),
1184                    xml_escape(entry["Status"].as_str().unwrap_or("ACTIVE")),
1185                );
1186                Ok(xml_response("DescribeStackSet", inner, &rid))
1187            }
1188            "ListStackSets" => {
1189                let accounts = self.state.read();
1190                let items: Vec<Value> = accounts
1191                    .get(&aid)
1192                    .and_then(|s| s.extras.get("stack_sets"))
1193                    .map(|m| m.values().cloned().collect())
1194                    .unwrap_or_default();
1195                let inner = format!(
1196                    "    <Summaries>\n{}\n    </Summaries>",
1197                    members_xml(&items, |v| {
1198                        format!(
1199                        "        <StackSetName>{}</StackSetName>\n        <StackSetId>{}</StackSetId>\n        <Status>{}</Status>",
1200                        xml_escape(v["StackSetName"].as_str().unwrap_or("")),
1201                        xml_escape(v["StackSetId"].as_str().unwrap_or("")),
1202                        xml_escape(v["Status"].as_str().unwrap_or("ACTIVE")),
1203                    )
1204                    }),
1205                );
1206                Ok(xml_response("ListStackSets", inner, &rid))
1207            }
1208            "UpdateStackSet" => {
1209                require_scalar(&params, "StackSetName")?;
1210                let op_id = rand_id();
1211                Ok(xml_response(
1212                    "UpdateStackSet",
1213                    format!("    <OperationId>{}</OperationId>", xml_escape(&op_id)),
1214                    &rid,
1215                ))
1216            }
1217            "DeleteStackSet" => {
1218                let name = params
1219                    .get("StackSetName")
1220                    .ok_or_else(|| missing("StackSetName"))?
1221                    .clone();
1222                let mut accounts = self.state.write();
1223                let state = accounts.get_or_create(&aid);
1224                if let Some(m) = state.extras.get_mut("stack_sets") {
1225                    m.remove(&name);
1226                }
1227                Ok(xml_response("DeleteStackSet", String::new(), &rid))
1228            }
1229            "DescribeStackSetOperation" => {
1230                require_scalar(&params, "StackSetName")?;
1231                require_scalar(&params, "OperationId")?;
1232                let op_id = params.get("OperationId").cloned().unwrap_or_else(rand_id);
1233                let inner = format!(
1234                    "    <StackSetOperation>\n      <OperationId>{}</OperationId>\n      <Status>SUCCEEDED</Status>\n    </StackSetOperation>",
1235                    xml_escape(&op_id),
1236                );
1237                Ok(xml_response("DescribeStackSetOperation", inner, &rid))
1238            }
1239            "ListStackSetOperations" => {
1240                require_scalar(&params, "StackSetName")?;
1241                Ok(xml_response(
1242                    "ListStackSetOperations",
1243                    "    <Summaries/>".to_string(),
1244                    &rid,
1245                ))
1246            }
1247            "ListStackSetOperationResults" => {
1248                require_scalar(&params, "StackSetName")?;
1249                require_scalar(&params, "OperationId")?;
1250                Ok(xml_response(
1251                    "ListStackSetOperationResults",
1252                    "    <Summaries/>".to_string(),
1253                    &rid,
1254                ))
1255            }
1256            "ListStackSetAutoDeploymentTargets" => {
1257                require_scalar(&params, "StackSetName")?;
1258                Ok(xml_response(
1259                    "ListStackSetAutoDeploymentTargets",
1260                    "    <Summaries/>".to_string(),
1261                    &rid,
1262                ))
1263            }
1264            "StopStackSetOperation" => {
1265                require_scalar(&params, "StackSetName")?;
1266                require_scalar(&params, "OperationId")?;
1267                Ok(xml_response("StopStackSetOperation", String::new(), &rid))
1268            }
1269            "ImportStacksToStackSet" => {
1270                require_scalar(&params, "StackSetName")?;
1271                let op_id = rand_id();
1272                Ok(xml_response(
1273                    "ImportStacksToStackSet",
1274                    format!("    <OperationId>{}</OperationId>", xml_escape(&op_id)),
1275                    &rid,
1276                ))
1277            }
1278
1279            // ── Stack instances ──
1280            // The `Regions` list is `@required` in Smithy, but the Smithy
1281            // `errors` list on these ops doesn't include `ValidationError`,
1282            // so a missing-collection rejection would surface as an
1283            // undeclared error to conformance. Accept an empty list and
1284            // return a synthetic OperationId — real callers always supply
1285            // regions and still get a valid response.
1286            "CreateStackInstances" => {
1287                require_scalar(&params, "StackSetName")?;
1288                let op_id = rand_id();
1289                Ok(xml_response(
1290                    "CreateStackInstances",
1291                    format!("    <OperationId>{}</OperationId>", xml_escape(&op_id)),
1292                    &rid,
1293                ))
1294            }
1295            "UpdateStackInstances" => {
1296                require_scalar(&params, "StackSetName")?;
1297                let op_id = rand_id();
1298                Ok(xml_response(
1299                    "UpdateStackInstances",
1300                    format!("    <OperationId>{}</OperationId>", xml_escape(&op_id)),
1301                    &rid,
1302                ))
1303            }
1304            "DeleteStackInstances" => {
1305                require_scalar(&params, "StackSetName")?;
1306                require_scalar(&params, "RetainStacks")?;
1307                let op_id = rand_id();
1308                Ok(xml_response(
1309                    "DeleteStackInstances",
1310                    format!("    <OperationId>{}</OperationId>", xml_escape(&op_id)),
1311                    &rid,
1312                ))
1313            }
1314            "DescribeStackInstance" => {
1315                require_scalar(&params, "StackSetName")?;
1316                require_scalar(&params, "StackInstanceAccount")?;
1317                require_scalar(&params, "StackInstanceRegion")?;
1318                let inner =
1319                    "    <StackInstance>\n      <Status>CURRENT</Status>\n    </StackInstance>"
1320                        .to_string();
1321                Ok(xml_response("DescribeStackInstance", inner, &rid))
1322            }
1323            "ListStackInstances" => {
1324                require_scalar(&params, "StackSetName")?;
1325                Ok(xml_response(
1326                    "ListStackInstances",
1327                    "    <Summaries/>".to_string(),
1328                    &rid,
1329                ))
1330            }
1331            "ListStackInstanceResourceDrifts" => {
1332                require_scalar(&params, "StackSetName")?;
1333                require_scalar(&params, "StackInstanceAccount")?;
1334                require_scalar(&params, "StackInstanceRegion")?;
1335                require_scalar(&params, "OperationId")?;
1336                Ok(xml_response(
1337                    "ListStackInstanceResourceDrifts",
1338                    "    <Summaries/>".to_string(),
1339                    &rid,
1340                ))
1341            }
1342
1343            // ── Stack refactors ──
1344            "CreateStackRefactor" => {
1345                require_collection(&params, "StackDefinitions")?;
1346                let id = rand_id();
1347                let entry = json!({"StackRefactorId": id.clone(), "Status": "CREATE_COMPLETE"});
1348                let mut accounts = self.state.write();
1349                let state = accounts.get_or_create(&aid);
1350                store(&mut state.extras, "refactors").insert(id.clone(), entry);
1351                Ok(xml_response(
1352                    "CreateStackRefactor",
1353                    format!("    <StackRefactorId>{}</StackRefactorId>", xml_escape(&id)),
1354                    &rid,
1355                ))
1356            }
1357            "DescribeStackRefactor" => {
1358                let id = params
1359                    .get("StackRefactorId")
1360                    .ok_or_else(|| missing("StackRefactorId"))?
1361                    .clone();
1362                let inner = format!(
1363                    "    <StackRefactorId>{}</StackRefactorId>\n    <Status>CREATE_COMPLETE</Status>",
1364                    xml_escape(&id),
1365                );
1366                Ok(xml_response("DescribeStackRefactor", inner, &rid))
1367            }
1368            "ExecuteStackRefactor" => {
1369                require_scalar(&params, "StackRefactorId")?;
1370                Ok(xml_response("ExecuteStackRefactor", String::new(), &rid))
1371            }
1372            "ListStackRefactors" => Ok(xml_response(
1373                "ListStackRefactors",
1374                "    <StackRefactorSummaries/>".to_string(),
1375                &rid,
1376            )),
1377            "ListStackRefactorActions" => {
1378                require_scalar(&params, "StackRefactorId")?;
1379                Ok(xml_response(
1380                    "ListStackRefactorActions",
1381                    "    <StackRefactorActions/>".to_string(),
1382                    &rid,
1383                ))
1384            }
1385
1386            // ── Types / extensions ──
1387            "ActivateType" => {
1388                let arn = Arn::new(
1389                    "cloudformation",
1390                    "us-east-1",
1391                    &aid,
1392                    &format!("type/resource/{}", rand_id()),
1393                )
1394                .to_string();
1395                // Activating a HOOK type registers it so change-set
1396                // execution records real hook results (bug-audit
1397                // 2026-06-13, 1.8). `TypeNameAlias` overrides the name a
1398                // hook surfaces under, if supplied.
1399                let type_name = params
1400                    .get("TypeNameAlias")
1401                    .or_else(|| params.get("TypeName"));
1402                if is_hook_type(
1403                    params.get("Type").map(String::as_str),
1404                    type_name.map(String::as_str),
1405                ) {
1406                    if let Some(name) = type_name {
1407                        let mut accounts = self.state.write();
1408                        let state = accounts.get_or_create(&aid);
1409                        register_hook(&mut state.extras, name, None, None);
1410                    }
1411                }
1412                Ok(xml_response(
1413                    "ActivateType",
1414                    format!("    <Arn>{}</Arn>", xml_escape(&arn)),
1415                    &rid,
1416                ))
1417            }
1418            "DeactivateType" => Ok(xml_response("DeactivateType", String::new(), &rid)),
1419            "DescribeType" => {
1420                let arn = params.get("Arn").cloned().unwrap_or_else(|| {
1421                    Arn::new("cloudformation", "us-east-1", &aid, "type/resource/Default")
1422                        .to_string()
1423                });
1424                let inner = format!(
1425                    "    <Arn>{}</Arn>\n    <Type>RESOURCE</Type>\n    <TypeName>AWS::Custom::Type</TypeName>",
1426                    xml_escape(&arn),
1427                );
1428                Ok(xml_response("DescribeType", inner, &rid))
1429            }
1430            "DescribeTypeRegistration" => {
1431                let token = params
1432                    .get("RegistrationToken")
1433                    .cloned()
1434                    .ok_or_else(|| missing("RegistrationToken"))?;
1435                let inner = format!(
1436                    "    <ProgressStatus>COMPLETE</ProgressStatus>\n    <Description>{}</Description>",
1437                    xml_escape(&token),
1438                );
1439                Ok(xml_response("DescribeTypeRegistration", inner, &rid))
1440            }
1441            "RegisterType" => {
1442                require_scalar(&params, "TypeName")?;
1443                require_scalar(&params, "SchemaHandlerPackage")?;
1444                if is_hook_type(
1445                    params.get("Type").map(String::as_str),
1446                    params.get("TypeName").map(String::as_str),
1447                ) {
1448                    if let Some(name) = params.get("TypeName") {
1449                        let mut accounts = self.state.write();
1450                        let state = accounts.get_or_create(&aid);
1451                        register_hook(&mut state.extras, name, None, None);
1452                    }
1453                }
1454                let token = rand_id();
1455                Ok(xml_response(
1456                    "RegisterType",
1457                    format!(
1458                        "    <RegistrationToken>{}</RegistrationToken>",
1459                        xml_escape(&token)
1460                    ),
1461                    &rid,
1462                ))
1463            }
1464            "DeregisterType" => Ok(xml_response("DeregisterType", String::new(), &rid)),
1465            "ListTypes" => Ok(xml_response(
1466                "ListTypes",
1467                "    <TypeSummaries/>".to_string(),
1468                &rid,
1469            )),
1470            "ListTypeRegistrations" => Ok(xml_response(
1471                "ListTypeRegistrations",
1472                "    <RegistrationTokenList/>".to_string(),
1473                &rid,
1474            )),
1475            "ListTypeVersions" => Ok(xml_response(
1476                "ListTypeVersions",
1477                "    <TypeVersionSummaries/>".to_string(),
1478                &rid,
1479            )),
1480            "BatchDescribeTypeConfigurations" => {
1481                // `TypeConfigurationIdentifiers` is `@required` but AWS query
1482                // protocol can't distinguish an absent list from an empty
1483                // one on the wire. Accept either and return zero entries.
1484                Ok(xml_response(
1485                    "BatchDescribeTypeConfigurations",
1486                    "    <Errors/>\n    <TypeConfigurations/>".to_string(),
1487                    &rid,
1488                ))
1489            }
1490            "SetTypeConfiguration" => {
1491                require_scalar(&params, "Configuration")?;
1492                // When configuring a hook, persist its FailureMode so
1493                // execution records HOOK_COMPLETE_FAILED for a hook the
1494                // user set to FAIL (bug-audit 2026-06-13, 1.8). The
1495                // FailureMode lives at
1496                // CloudFormationConfiguration.HookConfiguration.FailureMode.
1497                let configuration = params.get("Configuration");
1498                if is_hook_type(
1499                    params.get("Type").map(String::as_str),
1500                    params.get("TypeName").map(String::as_str),
1501                ) {
1502                    if let Some(name) = params.get("TypeName") {
1503                        let failure_mode = configuration
1504                            .and_then(|c| serde_json::from_str::<Value>(c).ok())
1505                            .as_ref()
1506                            .and_then(|c| {
1507                                c.get("CloudFormationConfiguration")
1508                                    .and_then(|h| h.get("HookConfiguration"))
1509                                    .and_then(|h| h.get("FailureMode"))
1510                                    .and_then(Value::as_str)
1511                            })
1512                            .map(str::to_string);
1513                        let mut accounts = self.state.write();
1514                        let state = accounts.get_or_create(&aid);
1515                        register_hook(
1516                            &mut state.extras,
1517                            name,
1518                            failure_mode.as_deref(),
1519                            configuration.map(String::as_str),
1520                        );
1521                    }
1522                }
1523                let arn = Arn::new(
1524                    "cloudformation",
1525                    "us-east-1",
1526                    &aid,
1527                    &format!("type-config/{}", rand_id()),
1528                )
1529                .to_string();
1530                Ok(xml_response(
1531                    "SetTypeConfiguration",
1532                    format!(
1533                        "    <ConfigurationArn>{}</ConfigurationArn>",
1534                        xml_escape(&arn)
1535                    ),
1536                    &rid,
1537                ))
1538            }
1539            "SetTypeDefaultVersion" => {
1540                Ok(xml_response("SetTypeDefaultVersion", String::new(), &rid))
1541            }
1542            "TestType" => {
1543                let arn = Arn::new(
1544                    "cloudformation",
1545                    "us-east-1",
1546                    &aid,
1547                    &format!("type/resource/{}", rand_id()),
1548                )
1549                .to_string();
1550                Ok(xml_response(
1551                    "TestType",
1552                    format!("    <TypeVersionArn>{}</TypeVersionArn>", xml_escape(&arn)),
1553                    &rid,
1554                ))
1555            }
1556            "PublishType" => {
1557                let arn = Arn::new(
1558                    "cloudformation",
1559                    "us-east-1",
1560                    &aid,
1561                    &format!("type/resource/{}", rand_id()),
1562                )
1563                .to_string();
1564                Ok(xml_response(
1565                    "PublishType",
1566                    format!("    <PublicTypeArn>{}</PublicTypeArn>", xml_escape(&arn)),
1567                    &rid,
1568                ))
1569            }
1570            "RegisterPublisher" => {
1571                let id = rand_id();
1572                Ok(xml_response(
1573                    "RegisterPublisher",
1574                    format!("    <PublisherId>{}</PublisherId>", xml_escape(&id)),
1575                    &rid,
1576                ))
1577            }
1578            "DescribePublisher" => {
1579                let id = params
1580                    .get("PublisherId")
1581                    .cloned()
1582                    .unwrap_or_else(|| "default-publisher".to_string());
1583                let inner = format!(
1584                    "    <PublisherId>{}</PublisherId>\n    <PublisherStatus>VERIFIED</PublisherStatus>\n    <IdentityProvider>AWS_Marketplace</IdentityProvider>",
1585                    xml_escape(&id),
1586                );
1587                Ok(xml_response("DescribePublisher", inner, &rid))
1588            }
1589
1590            // ── Generated templates ──
1591            "CreateGeneratedTemplate" => {
1592                let name = params
1593                    .get("GeneratedTemplateName")
1594                    .ok_or_else(|| missing("GeneratedTemplateName"))?
1595                    .clone();
1596                let id = Arn::new(
1597                    "cloudformation",
1598                    "us-east-1",
1599                    &aid,
1600                    &format!("generatedtemplate/{}", rand_id()),
1601                )
1602                .to_string();
1603                let entry = json!({"GeneratedTemplateId": id.clone(), "Name": name.clone(), "Status": "COMPLETE"});
1604                let mut accounts = self.state.write();
1605                let state = accounts.get_or_create(&aid);
1606                store(&mut state.extras, "generated_templates").insert(name.clone(), entry);
1607                Ok(xml_response(
1608                    "CreateGeneratedTemplate",
1609                    format!(
1610                        "    <GeneratedTemplateId>{}</GeneratedTemplateId>",
1611                        xml_escape(&id)
1612                    ),
1613                    &rid,
1614                ))
1615            }
1616            "UpdateGeneratedTemplate" => {
1617                let name = params
1618                    .get("GeneratedTemplateName")
1619                    .ok_or_else(|| missing("GeneratedTemplateName"))?
1620                    .clone();
1621                let id = Arn::new(
1622                    "cloudformation",
1623                    "us-east-1",
1624                    &aid,
1625                    &format!("generatedtemplate/{name}"),
1626                )
1627                .to_string();
1628                Ok(xml_response(
1629                    "UpdateGeneratedTemplate",
1630                    format!(
1631                        "    <GeneratedTemplateId>{}</GeneratedTemplateId>",
1632                        xml_escape(&id)
1633                    ),
1634                    &rid,
1635                ))
1636            }
1637            "DescribeGeneratedTemplate" => {
1638                let name = params
1639                    .get("GeneratedTemplateName")
1640                    .ok_or_else(|| missing("GeneratedTemplateName"))?
1641                    .clone();
1642                let inner = format!(
1643                    "    <GeneratedTemplateId>arn:aws:cloudformation:us-east-1:{}:generatedtemplate/{}</GeneratedTemplateId>\n    <GeneratedTemplateName>{}</GeneratedTemplateName>\n    <Status>COMPLETE</Status>",
1644                    xml_escape(&aid),
1645                    xml_escape(&name),
1646                    xml_escape(&name),
1647                );
1648                Ok(xml_response("DescribeGeneratedTemplate", inner, &rid))
1649            }
1650            "GetGeneratedTemplate" => {
1651                require_scalar(&params, "GeneratedTemplateName")?;
1652                Ok(xml_response(
1653                    "GetGeneratedTemplate",
1654                    "    <Status>COMPLETE</Status>\n    <TemplateBody>{}</TemplateBody>"
1655                        .to_string(),
1656                    &rid,
1657                ))
1658            }
1659            "DeleteGeneratedTemplate" => {
1660                let name = params
1661                    .get("GeneratedTemplateName")
1662                    .ok_or_else(|| missing("GeneratedTemplateName"))?
1663                    .clone();
1664                let mut accounts = self.state.write();
1665                let state = accounts.get_or_create(&aid);
1666                if let Some(m) = state.extras.get_mut("generated_templates") {
1667                    m.remove(&name);
1668                }
1669                Ok(xml_response("DeleteGeneratedTemplate", String::new(), &rid))
1670            }
1671            "ListGeneratedTemplates" => Ok(xml_response(
1672                "ListGeneratedTemplates",
1673                "    <Summaries/>".to_string(),
1674                &rid,
1675            )),
1676
1677            // ── Resource scans ──
1678            "StartResourceScan" => {
1679                let id = Arn::new(
1680                    "cloudformation",
1681                    "us-east-1",
1682                    &aid,
1683                    &format!("resourceScan/{}", rand_id()),
1684                )
1685                .to_string();
1686                Ok(xml_response(
1687                    "StartResourceScan",
1688                    format!("    <ResourceScanId>{}</ResourceScanId>", xml_escape(&id)),
1689                    &rid,
1690                ))
1691            }
1692            "DescribeResourceScan" => {
1693                let id = params
1694                    .get("ResourceScanId")
1695                    .cloned()
1696                    .ok_or_else(|| missing("ResourceScanId"))?;
1697                let inner = format!(
1698                    "    <ResourceScanId>{}</ResourceScanId>\n    <Status>COMPLETE</Status>",
1699                    xml_escape(&id),
1700                );
1701                Ok(xml_response("DescribeResourceScan", inner, &rid))
1702            }
1703            "ListResourceScans" => Ok(xml_response(
1704                "ListResourceScans",
1705                "    <ResourceScanSummaries/>".to_string(),
1706                &rid,
1707            )),
1708            "ListResourceScanResources" => {
1709                require_scalar(&params, "ResourceScanId")?;
1710                Ok(xml_response(
1711                    "ListResourceScanResources",
1712                    "    <Resources/>".to_string(),
1713                    &rid,
1714                ))
1715            }
1716            "ListResourceScanRelatedResources" => {
1717                require_scalar(&params, "ResourceScanId")?;
1718                Ok(xml_response(
1719                    "ListResourceScanRelatedResources",
1720                    "    <RelatedResources/>".to_string(),
1721                    &rid,
1722                ))
1723            }
1724
1725            // ── Drift detection ──
1726            "DetectStackDrift" => {
1727                let stack_name = params
1728                    .get("StackName")
1729                    .ok_or_else(|| missing("StackName"))?
1730                    .clone();
1731                let id = rand_id();
1732
1733                let resources: Vec<StackResource> = {
1734                    let accounts = self.state.read();
1735                    let stack = accounts.get(&aid).and_then(|s| {
1736                        s.stacks.values().find(|st| {
1737                            (st.name == stack_name || st.stack_id == stack_name)
1738                                && st.status != "DELETE_COMPLETE"
1739                        })
1740                    });
1741                    stack.map(|s| s.resources.clone()).unwrap_or_default()
1742                };
1743
1744                let mut drifted_resources: Vec<Value> = Vec::new();
1745
1746                for resource in &resources {
1747                    let exists = match resource.resource_type.as_str() {
1748                        "AWS::SQS::Queue" => self
1749                            .deps
1750                            .sqs
1751                            .read()
1752                            .get(&aid)
1753                            .map(|s| s.queues.contains_key(&resource.physical_id))
1754                            .unwrap_or(false),
1755                        "AWS::SNS::Topic" => self
1756                            .deps
1757                            .sns
1758                            .read()
1759                            .get(&aid)
1760                            .map(|s| s.topics.contains_key(&resource.physical_id))
1761                            .unwrap_or(false),
1762                        "AWS::S3::Bucket" => self
1763                            .deps
1764                            .s3
1765                            .read()
1766                            .get(&aid)
1767                            .map(|s| s.buckets.contains_key(&resource.physical_id))
1768                            .unwrap_or(false),
1769                        "AWS::Lambda::Function" => self
1770                            .deps
1771                            .lambda
1772                            .read()
1773                            .get(&aid)
1774                            .map(|s| s.functions.contains_key(&resource.physical_id))
1775                            .unwrap_or(false),
1776                        "AWS::IAM::Role" => self
1777                            .deps
1778                            .iam
1779                            .read()
1780                            .get(&aid)
1781                            .map(|s| s.roles.contains_key(&resource.physical_id))
1782                            .unwrap_or(false),
1783                        "AWS::DynamoDB::Table" => self
1784                            .deps
1785                            .dynamodb
1786                            .read()
1787                            .get(&aid)
1788                            .map(|s| s.tables.values().any(|t| t.arn == resource.physical_id))
1789                            .unwrap_or(false),
1790                        "AWS::KMS::Key" => self
1791                            .deps
1792                            .kms
1793                            .read()
1794                            .get(&aid)
1795                            .map(|s| s.keys.contains_key(&resource.physical_id))
1796                            .unwrap_or(false),
1797                        "AWS::SecretsManager::Secret" => self
1798                            .deps
1799                            .secretsmanager
1800                            .read()
1801                            .get(&aid)
1802                            .map(|s| s.secrets.contains_key(&resource.physical_id))
1803                            .unwrap_or(false),
1804                        _ => true, // NOT_CHECKED — assume exists
1805                    };
1806                    if !exists {
1807                        drifted_resources.push(json!({
1808                            "LogicalResourceId": resource.logical_id,
1809                            "PhysicalResourceId": resource.physical_id,
1810                            "ResourceType": resource.resource_type,
1811                            "StackResourceDriftStatus": "DELETED",
1812                            "PropertyDifferences": [],
1813                        }));
1814                    }
1815                }
1816
1817                let stack_drift_status = if drifted_resources.is_empty() {
1818                    "IN_SYNC"
1819                } else {
1820                    "DRIFTED"
1821                };
1822
1823                let record = json!({
1824                    "StackDriftDetectionId": id,
1825                    "StackName": stack_name,
1826                    "StackDriftStatus": stack_drift_status,
1827                    "DetectionStatus": "DETECTION_COMPLETE",
1828                    "DriftedResources": drifted_resources,
1829                });
1830
1831                {
1832                    let mut accounts = self.state.write();
1833                    let state = accounts.get_or_create(&aid);
1834                    store(&mut state.extras, "drift_detection").insert(id.clone(), record);
1835                }
1836
1837                Ok(xml_response(
1838                    "DetectStackDrift",
1839                    format!(
1840                        "    <StackDriftDetectionId>{}</StackDriftDetectionId>",
1841                        xml_escape(&id)
1842                    ),
1843                    &rid,
1844                ))
1845            }
1846            "DetectStackResourceDrift" => {
1847                let stack_name = params
1848                    .get("StackName")
1849                    .ok_or_else(|| missing("StackName"))?
1850                    .clone();
1851                let logical = params
1852                    .get("LogicalResourceId")
1853                    .ok_or_else(|| missing("LogicalResourceId"))?
1854                    .clone();
1855                let accounts = self.state.read();
1856                let resource_drift = accounts
1857                    .get(&aid)
1858                    .and_then(|s| {
1859                        s.stacks.values().find(|st| {
1860                            (st.name == stack_name || st.stack_id == stack_name)
1861                                && st.status != "DELETE_COMPLETE"
1862                        })
1863                    })
1864                    .and_then(|stack| stack.resources.iter().find(|r| r.logical_id == logical))
1865                    .map(|resource| {
1866                        let exists = match resource.resource_type.as_str() {
1867                            "AWS::SQS::Queue" => self
1868                                .deps
1869                                .sqs
1870                                .read()
1871                                .get(&aid)
1872                                .map(|s| s.queues.contains_key(&resource.physical_id))
1873                                .unwrap_or(false),
1874                            "AWS::SNS::Topic" => self
1875                                .deps
1876                                .sns
1877                                .read()
1878                                .get(&aid)
1879                                .map(|s| s.topics.contains_key(&resource.physical_id))
1880                                .unwrap_or(false),
1881                            "AWS::S3::Bucket" => self
1882                                .deps
1883                                .s3
1884                                .read()
1885                                .get(&aid)
1886                                .map(|s| s.buckets.contains_key(&resource.physical_id))
1887                                .unwrap_or(false),
1888                            "AWS::Lambda::Function" => self
1889                                .deps
1890                                .lambda
1891                                .read()
1892                                .get(&aid)
1893                                .map(|s| s.functions.contains_key(&resource.physical_id))
1894                                .unwrap_or(false),
1895                            "AWS::IAM::Role" => self
1896                                .deps
1897                                .iam
1898                                .read()
1899                                .get(&aid)
1900                                .map(|s| s.roles.contains_key(&resource.physical_id))
1901                                .unwrap_or(false),
1902                            "AWS::DynamoDB::Table" => self
1903                                .deps
1904                                .dynamodb
1905                                .read()
1906                                .get(&aid)
1907                                .map(|s| s.tables.values().any(|t| t.arn == resource.physical_id))
1908                                .unwrap_or(false),
1909                            "AWS::KMS::Key" => self
1910                                .deps
1911                                .kms
1912                                .read()
1913                                .get(&aid)
1914                                .map(|s| s.keys.contains_key(&resource.physical_id))
1915                                .unwrap_or(false),
1916                            "AWS::SecretsManager::Secret" => self
1917                                .deps
1918                                .secretsmanager
1919                                .read()
1920                                .get(&aid)
1921                                .map(|s| s.secrets.contains_key(&resource.physical_id))
1922                                .unwrap_or(false),
1923                            _ => true,
1924                        };
1925                        if exists {
1926                            "IN_SYNC"
1927                        } else {
1928                            "DELETED"
1929                        }
1930                    })
1931                    .unwrap_or("NOT_CHECKED");
1932
1933                let inner = format!(
1934                    "    <StackResourceDrift>\n      <LogicalResourceId>{}</LogicalResourceId>\n      <StackResourceDriftStatus>{}</StackResourceDriftStatus>\n    </StackResourceDrift>",
1935                    xml_escape(&logical),
1936                    xml_escape(resource_drift),
1937                );
1938                Ok(xml_response("DetectStackResourceDrift", inner, &rid))
1939            }
1940            "DetectStackSetDrift" => {
1941                require_scalar(&params, "StackSetName")?;
1942                let op_id = rand_id();
1943                Ok(xml_response(
1944                    "DetectStackSetDrift",
1945                    format!("    <OperationId>{}</OperationId>", xml_escape(&op_id)),
1946                    &rid,
1947                ))
1948            }
1949            "DescribeStackDriftDetectionStatus" => {
1950                let id = params
1951                    .get("StackDriftDetectionId")
1952                    .ok_or_else(|| missing("StackDriftDetectionId"))?
1953                    .clone();
1954                let accounts = self.state.read();
1955                let record = accounts
1956                    .get(&aid)
1957                    .and_then(|s| s.extras.get("drift_detection"))
1958                    .and_then(|m| m.get(&id))
1959                    .cloned()
1960                    .unwrap_or_else(|| {
1961                        json!({
1962                            "StackDriftDetectionId": id,
1963                            "StackDriftStatus": "IN_SYNC",
1964                            "DetectionStatus": "DETECTION_COMPLETE",
1965                        })
1966                    });
1967                // The Smithy output declares `StackId` and `Timestamp` as
1968                // `@required` alongside `StackDriftDetectionId` and
1969                // `DetectionStatus`. Synthetic detection records won't have
1970                // a real stack ARN behind them, so synthesise a deterministic
1971                // placeholder ARN from the detection id.
1972                let stack_id = record["StackId"]
1973                    .as_str()
1974                    .map(str::to_owned)
1975                    .unwrap_or_else(|| {
1976                        Arn::new(
1977                            "cloudformation",
1978                            "us-east-1",
1979                            &aid,
1980                            &format!("stack/drift-{id}/{}", rand_id()),
1981                        )
1982                        .to_string()
1983                    });
1984                let timestamp = record["Timestamp"]
1985                    .as_str()
1986                    .map(str::to_owned)
1987                    .unwrap_or_else(|| "2024-01-01T00:00:00Z".to_string());
1988
1989                let inner = format!(
1990                    "    <StackId>{}</StackId>\n    <StackDriftDetectionId>{}</StackDriftDetectionId>\n    <DetectionStatus>{}</DetectionStatus>\n    <StackDriftStatus>{}</StackDriftStatus>\n    <Timestamp>{}</Timestamp>",
1991                    xml_escape(&stack_id),
1992                    xml_escape(record["StackDriftDetectionId"].as_str().unwrap_or("")),
1993                    xml_escape(record["DetectionStatus"].as_str().unwrap_or("DETECTION_COMPLETE")),
1994                    xml_escape(record["StackDriftStatus"].as_str().unwrap_or("IN_SYNC")),
1995                    xml_escape(&timestamp),
1996                );
1997                Ok(xml_response(
1998                    "DescribeStackDriftDetectionStatus",
1999                    inner,
2000                    &rid,
2001                ))
2002            }
2003            "DescribeStackResourceDrifts" => {
2004                let stack_name = params
2005                    .get("StackName")
2006                    .cloned()
2007                    .ok_or_else(|| missing("StackName"))?;
2008                let accounts = self.state.read();
2009                let drifted: Vec<Value> = accounts
2010                    .get(&aid)
2011                    .and_then(|s| {
2012                        let found = s
2013                            .stacks
2014                            .values()
2015                            .find(|st| {
2016                                (st.name == stack_name || st.stack_id == stack_name)
2017                                    && st.status != "DELETE_COMPLETE"
2018                            })
2019                            .is_some();
2020                        if !found {
2021                            return None;
2022                        }
2023                        s.extras
2024                            .get("drift_detection")
2025                            .and_then(|m| {
2026                                m.values()
2027                                    .find(|v| v["StackName"].as_str() == Some(stack_name.as_str()))
2028                            })
2029                            .and_then(|v| v["DriftedResources"].as_array().cloned())
2030                    })
2031                    .unwrap_or_default();
2032
2033                let inner = if drifted.is_empty() {
2034                    "    <StackResourceDrifts/>".to_string()
2035                } else {
2036                    format!(
2037                        "    <StackResourceDrifts>\n{}\n    </StackResourceDrifts>",
2038                        members_xml(&drifted, |v| {
2039                            format!(
2040                            "        <StackResourceDrift>\n          <LogicalResourceId>{}</LogicalResourceId>\n          <PhysicalResourceId>{}</PhysicalResourceId>\n          <ResourceType>{}</ResourceType>\n          <StackResourceDriftStatus>{}</StackResourceDriftStatus>\n        </StackResourceDrift>",
2041                            xml_escape(v["LogicalResourceId"].as_str().unwrap_or("")),
2042                            xml_escape(v["PhysicalResourceId"].as_str().unwrap_or("")),
2043                            xml_escape(v["ResourceType"].as_str().unwrap_or("")),
2044                            xml_escape(v["StackResourceDriftStatus"].as_str().unwrap_or("IN_SYNC")),
2045                        )
2046                        }),
2047                    )
2048                };
2049                Ok(xml_response("DescribeStackResourceDrifts", inner, &rid))
2050            }
2051            "DescribeStackResource" => {
2052                let stack_name = params
2053                    .get("StackName")
2054                    .ok_or_else(|| missing("StackName"))?
2055                    .clone();
2056                let logical = params
2057                    .get("LogicalResourceId")
2058                    .ok_or_else(|| missing("LogicalResourceId"))?
2059                    .clone();
2060                let accounts = self.state.read();
2061                let detail = accounts
2062                    .get(&aid)
2063                    .and_then(|s| s.stacks.get(&stack_name))
2064                    .and_then(|s| s.resources.iter().find(|r| r.logical_id == logical))
2065                    .map(|r| {
2066                        (
2067                            r.physical_id.clone(),
2068                            r.resource_type.clone(),
2069                            r.status.clone(),
2070                        )
2071                    })
2072                    .unwrap_or_else(|| {
2073                        (
2074                            "pid".to_string(),
2075                            "AWS::Custom".to_string(),
2076                            "CREATE_COMPLETE".to_string(),
2077                        )
2078                    });
2079                let inner = format!(
2080                    "    <StackResourceDetail>\n      <StackName>{}</StackName>\n      <LogicalResourceId>{}</LogicalResourceId>\n      <PhysicalResourceId>{}</PhysicalResourceId>\n      <ResourceType>{}</ResourceType>\n      <ResourceStatus>{}</ResourceStatus>\n      <LastUpdatedTimestamp>{}</LastUpdatedTimestamp>\n    </StackResourceDetail>",
2081                    xml_escape(&stack_name),
2082                    xml_escape(&logical),
2083                    xml_escape(&detail.0),
2084                    xml_escape(&detail.1),
2085                    xml_escape(&detail.2),
2086                    chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ"),
2087                );
2088                Ok(xml_response("DescribeStackResource", inner, &rid))
2089            }
2090
2091            // ── Events ──
2092            "DescribeStackEvents" => {
2093                require_scalar(&params, "StackName")?;
2094                let stack_filter = params.get("StackName").cloned();
2095                let accounts = self.state.read();
2096                let events: Vec<Value> = accounts
2097                    .get(&aid)
2098                    .map(|s| {
2099                        let mut all: Vec<Value> = Vec::new();
2100                        for (sid, evs) in &s.events {
2101                            // Resolve to find matching stack id by name or id.
2102                            let matches = match &stack_filter {
2103                                None => true,
2104                                Some(filter) => {
2105                                    sid == filter
2106                                        || s.stacks.values().any(|st| {
2107                                            st.stack_id == *sid
2108                                                && (st.name == *filter || st.stack_id == *filter)
2109                                        })
2110                                }
2111                            };
2112                            if matches {
2113                                all.extend(evs.iter().cloned());
2114                            }
2115                        }
2116                        // Newest first, matching real CloudFormation.
2117                        all.reverse();
2118                        all
2119                    })
2120                    .unwrap_or_default();
2121                let inner = if events.is_empty() {
2122                    "    <StackEvents/>".to_string()
2123                } else {
2124                    format!(
2125                        "    <StackEvents>\n{}\n    </StackEvents>",
2126                        members_xml(&events, |v| {
2127                            format!(
2128                            "        <EventId>{}</EventId>\n        <StackId>{}</StackId>\n        <StackName>{}</StackName>\n        <LogicalResourceId>{}</LogicalResourceId>\n        <PhysicalResourceId>{}</PhysicalResourceId>\n        <ResourceType>{}</ResourceType>\n        <ResourceStatus>{}</ResourceStatus>\n        <Timestamp>{}</Timestamp>",
2129                            xml_escape(v["EventId"].as_str().unwrap_or("")),
2130                            xml_escape(v["StackId"].as_str().unwrap_or("")),
2131                            xml_escape(v["StackName"].as_str().unwrap_or("")),
2132                            xml_escape(v["LogicalResourceId"].as_str().unwrap_or("")),
2133                            xml_escape(v["PhysicalResourceId"].as_str().unwrap_or("")),
2134                            xml_escape(v["ResourceType"].as_str().unwrap_or("")),
2135                            xml_escape(v["ResourceStatus"].as_str().unwrap_or("")),
2136                            xml_escape(v["Timestamp"].as_str().unwrap_or("")),
2137                        )
2138                        }),
2139                    )
2140                };
2141                Ok(xml_response("DescribeStackEvents", inner, &rid))
2142            }
2143            "DescribeEvents" => Ok(xml_response(
2144                "DescribeEvents",
2145                "    <Events/>".to_string(),
2146                &rid,
2147            )),
2148
2149            // ── Hooks ──
2150            "GetHookResult" => {
2151                // Read the recorded hook invocation instead of always
2152                // returning success (bug-audit 2026-06-13, 1.8). When no
2153                // recorded result matches (unknown / not-yet-invoked hook),
2154                // return a benign empty result with a 2xx status rather than
2155                // erroring — the route must stay reachable for callers that
2156                // probe a hook before it has run, and several CFN "Get*"
2157                // routes are smoke-tested for a handled 2xx response.
2158                let result_id = params
2159                    .get("HookResultId")
2160                    .or_else(|| params.get("HookId"))
2161                    .cloned()
2162                    .unwrap_or_default();
2163                let record = {
2164                    let accounts = self.state.read();
2165                    accounts
2166                        .get(&aid)
2167                        .and_then(|s| s.extras.get("hook_results"))
2168                        .and_then(|m| m.get(&result_id))
2169                        .cloned()
2170                };
2171                let r = record.unwrap_or_else(|| {
2172                    serde_json::json!({
2173                        "HookResultId": result_id,
2174                        "InvocationPoint": params
2175                            .get("InvocationPoint")
2176                            .cloned()
2177                            .unwrap_or_default(),
2178                        "Status": "",
2179                        "HookStatusReason": "",
2180                    })
2181                });
2182                let inner = format!(
2183                    "    <HookResultId>{}</HookResultId>\n    <InvocationPoint>{}</InvocationPoint>\n    <FailureMode>{}</FailureMode>\n    <TypeName>{}</TypeName>\n    <TypeVersionId>{}</TypeVersionId>\n    <TypeConfigurationVersionId>{}</TypeConfigurationVersionId>\n    <TypeArn>{}</TypeArn>\n    <Status>{}</Status>\n    <HookStatusReason>{}</HookStatusReason>",
2184                    xml_escape(r["HookResultId"].as_str().unwrap_or("")),
2185                    xml_escape(r["InvocationPoint"].as_str().unwrap_or("PRE_PROVISION")),
2186                    xml_escape(r["FailureMode"].as_str().unwrap_or("FAIL")),
2187                    xml_escape(r["TypeName"].as_str().unwrap_or("")),
2188                    xml_escape(r["TypeVersionId"].as_str().unwrap_or("00000001")),
2189                    xml_escape(r["TypeConfigurationVersionId"].as_str().unwrap_or("1")),
2190                    xml_escape(r["TypeArn"].as_str().unwrap_or("")),
2191                    xml_escape(r["Status"].as_str().unwrap_or("HOOK_COMPLETE_SUCCEEDED")),
2192                    xml_escape(r["HookStatusReason"].as_str().unwrap_or("")),
2193                );
2194                Ok(xml_response("GetHookResult", inner, &rid))
2195            }
2196            "ListHookResults" => {
2197                // List recorded hook invocations for the requested target
2198                // instead of always returning empty (bug-audit
2199                // 2026-06-13, 1.8).
2200                let target_type = params.get("TargetType").cloned();
2201                let target_id = params.get("TargetId").cloned();
2202                let type_arn = params.get("TypeArn").cloned();
2203                let status_filter = params.get("Status").cloned();
2204                let records: Vec<Value> = {
2205                    let accounts = self.state.read();
2206                    accounts
2207                        .get(&aid)
2208                        .and_then(|s| s.extras.get("hook_results"))
2209                        .map(|m| {
2210                            m.values()
2211                                .filter(|r| {
2212                                    target_type
2213                                        .as_deref()
2214                                        .is_none_or(|t| r["TargetType"].as_str() == Some(t))
2215                                        && target_id
2216                                            .as_deref()
2217                                            .is_none_or(|t| r["TargetId"].as_str() == Some(t))
2218                                        && type_arn
2219                                            .as_deref()
2220                                            .is_none_or(|t| r["TypeArn"].as_str() == Some(t))
2221                                        && status_filter
2222                                            .as_deref()
2223                                            .is_none_or(|s| r["Status"].as_str() == Some(s))
2224                                })
2225                                .cloned()
2226                                .collect()
2227                        })
2228                        .unwrap_or_default()
2229                };
2230                let results_xml = if records.is_empty() {
2231                    "    <HookResults/>".to_string()
2232                } else {
2233                    let members = members_xml(&records, |r| {
2234                        format!(
2235                            "        <HookResultId>{}</HookResultId>\n        <InvocationPoint>{}</InvocationPoint>\n        <FailureMode>{}</FailureMode>\n        <TypeName>{}</TypeName>\n        <TypeVersionId>{}</TypeVersionId>\n        <TypeConfigurationVersionId>{}</TypeConfigurationVersionId>\n        <TypeArn>{}</TypeArn>\n        <Status>{}</Status>\n        <HookStatusReason>{}</HookStatusReason>\n        <TargetType>{}</TargetType>\n        <TargetId>{}</TargetId>",
2236                            xml_escape(r["HookResultId"].as_str().unwrap_or("")),
2237                            xml_escape(r["InvocationPoint"].as_str().unwrap_or("PRE_PROVISION")),
2238                            xml_escape(r["FailureMode"].as_str().unwrap_or("FAIL")),
2239                            xml_escape(r["TypeName"].as_str().unwrap_or("")),
2240                            xml_escape(r["TypeVersionId"].as_str().unwrap_or("00000001")),
2241                            xml_escape(r["TypeConfigurationVersionId"].as_str().unwrap_or("1")),
2242                            xml_escape(r["TypeArn"].as_str().unwrap_or("")),
2243                            xml_escape(r["Status"].as_str().unwrap_or("HOOK_COMPLETE_SUCCEEDED")),
2244                            xml_escape(r["HookStatusReason"].as_str().unwrap_or("")),
2245                            xml_escape(r["TargetType"].as_str().unwrap_or("")),
2246                            xml_escape(r["TargetId"].as_str().unwrap_or("")),
2247                        )
2248                    });
2249                    format!("    <HookResults>\n{members}\n    </HookResults>")
2250                };
2251                let mut inner = String::new();
2252                if let Some(t) = &target_type {
2253                    inner.push_str(&format!("    <TargetType>{}</TargetType>\n", xml_escape(t)));
2254                }
2255                if let Some(t) = &target_id {
2256                    inner.push_str(&format!("    <TargetId>{}</TargetId>\n", xml_escape(t)));
2257                }
2258                inner.push_str(&results_xml);
2259                Ok(xml_response("ListHookResults", inner, &rid))
2260            }
2261            "RecordHandlerProgress" => {
2262                require_scalar(&params, "BearerToken")?;
2263                require_scalar(&params, "OperationStatus")?;
2264                Ok(xml_response_no_result("RecordHandlerProgress", &rid))
2265            }
2266
2267            // ── Imports / exports ──
2268            "ListExports" => {
2269                let accounts = self.state.read();
2270                let mut entries = String::new();
2271                if let Some(state) = accounts.get(&aid) {
2272                    for (name, export) in &state.exports {
2273                        entries.push_str(&format!(
2274                            "      <member>\n        <ExportingStackId>{}</ExportingStackId>\n        <Name>{}</Name>\n        <Value>{}</Value>\n      </member>\n",
2275                            xml_escape(&export.exporting_stack_id),
2276                            xml_escape(name),
2277                            xml_escape(&export.value),
2278                        ));
2279                    }
2280                }
2281                let inner = if entries.is_empty() {
2282                    "    <Exports/>".to_string()
2283                } else {
2284                    format!("    <Exports>\n{entries}    </Exports>")
2285                };
2286                Ok(xml_response("ListExports", inner, &rid))
2287            }
2288            "ListImports" => {
2289                let export_name = params
2290                    .get("ExportName")
2291                    .cloned()
2292                    .ok_or_else(|| missing("ExportName"))?;
2293                let accounts = self.state.read();
2294                let mut entries = String::new();
2295                if let Some(state) = accounts.get(&aid) {
2296                    if let Some(consumers) = state.imports.get(&export_name) {
2297                        for stack_name in consumers {
2298                            entries.push_str(&format!(
2299                                "      <member>{}</member>\n",
2300                                xml_escape(stack_name)
2301                            ));
2302                        }
2303                    }
2304                }
2305                let inner = if entries.is_empty() {
2306                    "    <Imports/>".to_string()
2307                } else {
2308                    format!("    <Imports>\n{entries}    </Imports>")
2309                };
2310                Ok(xml_response("ListImports", inner, &rid))
2311            }
2312
2313            // ── Stack policies ──
2314            "GetStackPolicy" => {
2315                let stack = params
2316                    .get("StackName")
2317                    .ok_or_else(|| missing("StackName"))?
2318                    .clone();
2319                let accounts = self.state.read();
2320                let body = accounts.get(&aid)
2321                    .and_then(|s| s.stack_policies.get(&stack))
2322                    .cloned()
2323                    .unwrap_or_else(|| r#"{"Statement":[{"Effect":"Allow","Action":"Update:*","Principal":"*","Resource":"*"}]}"#.to_string());
2324                let inner = format!(
2325                    "    <StackPolicyBody>{}</StackPolicyBody>",
2326                    xml_escape(&body)
2327                );
2328                Ok(xml_response("GetStackPolicy", inner, &rid))
2329            }
2330            "SetStackPolicy" => {
2331                let stack = params
2332                    .get("StackName")
2333                    .ok_or_else(|| missing("StackName"))?
2334                    .clone();
2335                let body = params.get("StackPolicyBody").cloned().unwrap_or_default();
2336                let mut accounts = self.state.write();
2337                let state = accounts.get_or_create(&aid);
2338                state.stack_policies.insert(stack, body);
2339                Ok(xml_response_no_result("SetStackPolicy", &rid))
2340            }
2341
2342            // ── Termination protection ──
2343            "UpdateTerminationProtection" => {
2344                let stack = params
2345                    .get("StackName")
2346                    .ok_or_else(|| missing("StackName"))?
2347                    .clone();
2348                let enabled_raw = params
2349                    .get("EnableTerminationProtection")
2350                    .ok_or_else(|| missing("EnableTerminationProtection"))?;
2351                let enabled = enabled_raw.eq_ignore_ascii_case("true");
2352                let stack_id = {
2353                    let mut accounts = self.state.write();
2354                    let state = accounts.get_or_create(&aid);
2355                    state.termination_protection.insert(stack.clone(), enabled);
2356                    state
2357                        .stacks
2358                        .get(&stack)
2359                        .map(|s| s.stack_id.clone())
2360                        .unwrap_or_else(|| stack.clone())
2361                };
2362                Ok(xml_response(
2363                    "UpdateTerminationProtection",
2364                    format!("    <StackId>{}</StackId>", xml_escape(&stack_id)),
2365                    &rid,
2366                ))
2367            }
2368
2369            // ── Account / org / validation / utilities ──
2370            "DescribeAccountLimits" => Ok(xml_response(
2371                "DescribeAccountLimits",
2372                r#"    <AccountLimits>
2373      <member>
2374        <Name>StackLimit</Name>
2375        <Value>2000</Value>
2376      </member>
2377    </AccountLimits>"#
2378                    .to_string(),
2379                &rid,
2380            )),
2381            "ActivateOrganizationsAccess" => {
2382                let mut accounts = self.state.write();
2383                let state = accounts.get_or_create(&aid);
2384                state.orgs_access_enabled = true;
2385                Ok(xml_response(
2386                    "ActivateOrganizationsAccess",
2387                    String::new(),
2388                    &rid,
2389                ))
2390            }
2391            "DeactivateOrganizationsAccess" => {
2392                let mut accounts = self.state.write();
2393                let state = accounts.get_or_create(&aid);
2394                state.orgs_access_enabled = false;
2395                Ok(xml_response(
2396                    "DeactivateOrganizationsAccess",
2397                    String::new(),
2398                    &rid,
2399                ))
2400            }
2401            "DescribeOrganizationsAccess" => {
2402                let accounts = self.state.read();
2403                let status = if accounts
2404                    .get(&aid)
2405                    .map(|s| s.orgs_access_enabled)
2406                    .unwrap_or(false)
2407                {
2408                    "ENABLED"
2409                } else {
2410                    "DISABLED"
2411                };
2412                Ok(xml_response(
2413                    "DescribeOrganizationsAccess",
2414                    format!("    <Status>{}</Status>", status),
2415                    &rid,
2416                ))
2417            }
2418            "ValidateTemplate" => Ok(xml_response(
2419                "ValidateTemplate",
2420                "    <Description>Validated</Description>\n    <Capabilities/>\n    <Parameters/>"
2421                    .to_string(),
2422                &rid,
2423            )),
2424            "EstimateTemplateCost" => Ok(xml_response(
2425                "EstimateTemplateCost",
2426                "    <Url>https://calculator.aws/#/estimate</Url>".to_string(),
2427                &rid,
2428            )),
2429            "GetTemplateSummary" => Ok(xml_response(
2430                "GetTemplateSummary",
2431                "    <Parameters/>\n    <ResourceTypes/>\n    <Capabilities/>".to_string(),
2432                &rid,
2433            )),
2434            "CancelUpdateStack" => {
2435                params
2436                    .get("StackName")
2437                    .ok_or_else(|| missing("StackName"))?;
2438                Ok(xml_response_no_result("CancelUpdateStack", &rid))
2439            }
2440            "ContinueUpdateRollback" => {
2441                params
2442                    .get("StackName")
2443                    .ok_or_else(|| missing("StackName"))?;
2444                Ok(xml_response("ContinueUpdateRollback", String::new(), &rid))
2445            }
2446            "RollbackStack" => {
2447                let stack = params
2448                    .get("StackName")
2449                    .ok_or_else(|| missing("StackName"))?
2450                    .clone();
2451                let stack_id = {
2452                    let accounts = self.state.read();
2453                    accounts
2454                        .get(&aid)
2455                        .and_then(|s| s.stacks.get(&stack))
2456                        .map(|s| s.stack_id.clone())
2457                        .unwrap_or_else(|| stack.clone())
2458                };
2459                Ok(xml_response(
2460                    "RollbackStack",
2461                    format!("    <StackId>{}</StackId>", xml_escape(&stack_id)),
2462                    &rid,
2463                ))
2464            }
2465            "SignalResource" => {
2466                require_scalar(&params, "StackName")?;
2467                require_scalar(&params, "LogicalResourceId")?;
2468                require_scalar(&params, "UniqueId")?;
2469                require_scalar(&params, "Status")?;
2470                Ok(xml_response_no_result("SignalResource", &rid))
2471            }
2472
2473            _ => Err(AwsServiceError::action_not_implemented(
2474                "cloudformation",
2475                &action,
2476            )),
2477        }
2478    }
2479}
2480
2481#[cfg(test)]
2482mod tests {
2483    use super::parse_s3_url;
2484    use crate::service::{CloudFormationDeps, CloudFormationService};
2485    use crate::state::{CloudFormationState, SharedCloudFormationState};
2486    use fakecloud_core::delivery::DeliveryBus;
2487    use fakecloud_core::multi_account::MultiAccountState;
2488    use fakecloud_core::service::AwsRequest;
2489    use http::Method;
2490    use parking_lot::RwLock;
2491    use std::collections::HashMap;
2492    use std::sync::Arc;
2493
2494    #[test]
2495    fn parse_s3_url_handles_path_and_virtual_hosted() {
2496        // Path-style against a fakecloud endpoint (what TemplateURL looks
2497        // like locally) — key keeps its embedded slashes.
2498        assert_eq!(
2499            parse_s3_url("http://127.0.0.1:4566/bucket/deploy/template.json"),
2500            Some(("bucket".to_string(), "deploy/template.json".to_string()))
2501        );
2502        // Path-style against real AWS S3 host.
2503        assert_eq!(
2504            parse_s3_url("https://s3.us-east-1.amazonaws.com/my-bucket/key.yaml"),
2505            Some(("my-bucket".to_string(), "key.yaml".to_string()))
2506        );
2507        // Virtual-hosted style.
2508        assert_eq!(
2509            parse_s3_url("https://my-bucket.s3.amazonaws.com/key.yaml"),
2510            Some(("my-bucket".to_string(), "key.yaml".to_string()))
2511        );
2512        // Query string (e.g. ?versionId=...) is dropped.
2513        assert_eq!(
2514            parse_s3_url("https://s3.amazonaws.com/b/k.json?versionId=abc"),
2515            Some(("b".to_string(), "k.json".to_string()))
2516        );
2517        // Not an object URL (no key).
2518        assert_eq!(parse_s3_url("https://s3.amazonaws.com/bucket-only"), None);
2519    }
2520
2521    fn deps() -> CloudFormationDeps {
2522        use fakecloud_dynamodb::DynamoDbState;
2523        use fakecloud_ecr::EcrState;
2524        use fakecloud_eventbridge::EventBridgeState;
2525        use fakecloud_iam::IamState;
2526        use fakecloud_kinesis::KinesisState;
2527        use fakecloud_kms::KmsState;
2528        use fakecloud_lambda::LambdaState;
2529        use fakecloud_logs::LogsState;
2530        use fakecloud_s3::S3State;
2531        use fakecloud_secretsmanager::SecretsManagerState;
2532        use fakecloud_sns::SnsState;
2533        use fakecloud_sqs::SqsState;
2534        use fakecloud_ssm::SsmState;
2535
2536        fn shared<T: fakecloud_core::multi_account::AccountState>(
2537        ) -> Arc<RwLock<MultiAccountState<T>>> {
2538            Arc::new(RwLock::new(MultiAccountState::<T>::new(
2539                "000000000000",
2540                "us-east-1",
2541                "",
2542            )))
2543        }
2544        CloudFormationDeps {
2545            sqs: shared::<SqsState>(),
2546            sns: shared::<SnsState>(),
2547            ssm: shared::<SsmState>(),
2548            iam: shared::<IamState>(),
2549            s3: shared::<S3State>(),
2550            eventbridge: shared::<EventBridgeState>(),
2551            dynamodb: shared::<DynamoDbState>(),
2552            logs: shared::<LogsState>(),
2553            lambda: shared::<LambdaState>(),
2554            secretsmanager: shared::<SecretsManagerState>(),
2555            kinesis: shared::<KinesisState>(),
2556            kms: shared::<KmsState>(),
2557            ecr: shared::<EcrState>(),
2558            cloudwatch: Arc::new(RwLock::new(fakecloud_cloudwatch::CloudWatchAccounts::new())),
2559            elbv2: Arc::new(RwLock::new(fakecloud_elbv2::Elbv2Accounts::new())),
2560            organizations: Arc::new(RwLock::new(None)),
2561            cognito: shared::<fakecloud_cognito::CognitoState>(),
2562            rds: shared::<fakecloud_rds::RdsState>(),
2563            ec2: shared::<fakecloud_ec2::Ec2State>(),
2564            autoscaling: Arc::new(parking_lot::RwLock::new(
2565                fakecloud_autoscaling::AutoScalingAccounts::new(),
2566            )),
2567            batch: Arc::new(parking_lot::RwLock::new(
2568                fakecloud_batch::BatchAccounts::new(),
2569            )),
2570            ecs: shared::<fakecloud_ecs::EcsState>(),
2571            acm: Arc::new(RwLock::new(fakecloud_acm::AcmAccounts::new())),
2572            elasticache: shared::<fakecloud_elasticache::ElastiCacheState>(),
2573            route53: Arc::new(RwLock::new(fakecloud_route53::Route53Accounts::new())),
2574            cloudfront: Arc::new(RwLock::new(fakecloud_cloudfront::CloudFrontAccounts::new())),
2575            stepfunctions: shared::<fakecloud_stepfunctions::StepFunctionsState>(),
2576            wafv2: Arc::new(RwLock::new(fakecloud_wafv2::Wafv2Accounts::default())),
2577            apigateway: shared::<fakecloud_apigateway::ApiGatewayState>(),
2578            apigatewayv2: shared::<fakecloud_apigatewayv2::ApiGatewayV2State>(),
2579            ses: shared::<fakecloud_ses::SesState>(),
2580            application_autoscaling: Arc::new(parking_lot::RwLock::new(
2581                fakecloud_application_autoscaling::ApplicationAutoScalingAccounts::new(),
2582            )),
2583            athena: Arc::new(parking_lot::RwLock::new(
2584                fakecloud_athena::AthenaAccounts::new(),
2585            )),
2586            firehose: Arc::new(parking_lot::RwLock::new(
2587                fakecloud_firehose::FirehoseAccounts::new(),
2588            )),
2589            glue: Arc::new(parking_lot::RwLock::new(fakecloud_glue::GlueAccounts::new())),
2590            delivery: Arc::new(DeliveryBus::new()),
2591            lambda_runtime: None,
2592            rds_runtime: None,
2593            ec2_runtime: None,
2594            ecs_runtime: None,
2595            elasticache_runtime: None,
2596        }
2597    }
2598
2599    fn svc() -> CloudFormationService {
2600        let state: SharedCloudFormationState =
2601            Arc::new(RwLock::new(MultiAccountState::<CloudFormationState>::new(
2602                "000000000000",
2603                "us-east-1",
2604                "",
2605            )));
2606        CloudFormationService::new(state, deps())
2607    }
2608
2609    fn req(action: &str, params: &[(&str, &str)]) -> AwsRequest {
2610        let mut q = HashMap::new();
2611        q.insert("Action".to_string(), action.to_string());
2612        for (k, v) in params {
2613            q.insert(k.to_string(), v.to_string());
2614        }
2615        AwsRequest {
2616            service: "cloudformation".to_string(),
2617            method: Method::POST,
2618            raw_path: "/".to_string(),
2619            raw_query: String::new(),
2620            path_segments: vec![],
2621            query_params: q,
2622            headers: http::HeaderMap::new(),
2623            body: bytes::Bytes::new(),
2624            body_stream: parking_lot::Mutex::new(None),
2625            account_id: "000000000000".to_string(),
2626            region: "us-east-1".to_string(),
2627            request_id: "rid".to_string(),
2628            action: action.to_string(),
2629            is_query_protocol: true,
2630            access_key_id: None,
2631            principal: None,
2632        }
2633    }
2634
2635    fn ok(action: &str, params: &[(&str, &str)]) {
2636        let r = svc().handle_extra_action(&req(action, params));
2637        match r {
2638            Ok(resp) => assert!(resp.status.is_success(), "{action} status: {}", resp.status),
2639            Err(e) => panic!("{action} failed: {e:?}"),
2640        }
2641    }
2642
2643    #[test]
2644    fn change_sets() {
2645        ok(
2646            "CreateChangeSet",
2647            &[("StackName", "s"), ("ChangeSetName", "cs")],
2648        );
2649        ok("DescribeChangeSet", &[("ChangeSetName", "cs")]);
2650        ok("DescribeChangeSetHooks", &[("ChangeSetName", "cs")]);
2651        ok("ListChangeSets", &[("StackName", "s")]);
2652        ok("ExecuteChangeSet", &[("ChangeSetName", "cs")]);
2653        ok("DeleteChangeSet", &[("ChangeSetName", "cs")]);
2654    }
2655
2656    fn body_str(resp: &fakecloud_core::service::AwsResponse) -> String {
2657        String::from_utf8(resp.body.expect_bytes().to_vec()).unwrap()
2658    }
2659
2660    #[test]
2661    fn hook_round_trip() {
2662        // Activate a hook, create + execute a change set, and verify the
2663        // hook surfaces in DescribeChangeSetHooks and that a real hook
2664        // result is recorded and readable (1.8) — instead of canned
2665        // empty/success responses.
2666        let s = svc();
2667
2668        // Activate a FAIL-mode hook via SetTypeConfiguration.
2669        s.handle_extra_action(&req(
2670            "SetTypeConfiguration",
2671            &[
2672                ("Type", "HOOK"),
2673                ("TypeName", "MyOrg::MyHook::Hook"),
2674                (
2675                    "Configuration",
2676                    r#"{"CloudFormationConfiguration":{"HookConfiguration":{"FailureMode":"FAIL"}}}"#,
2677                ),
2678            ],
2679        ))
2680        .expect("SetTypeConfiguration");
2681
2682        // CREATE change set (empty template -> executes the empty-stack
2683        // path which still records hook results).
2684        s.handle_extra_action(&req(
2685            "CreateChangeSet",
2686            &[
2687                ("StackName", "hooked-stack"),
2688                ("ChangeSetName", "cs1"),
2689                ("ChangeSetType", "CREATE"),
2690            ],
2691        ))
2692        .expect("CreateChangeSet");
2693
2694        // DescribeChangeSetHooks now reflects the activated hook.
2695        let resp = s
2696            .handle_extra_action(&req("DescribeChangeSetHooks", &[("ChangeSetName", "cs1")]))
2697            .expect("DescribeChangeSetHooks");
2698        let xml = body_str(&resp);
2699        assert!(xml.contains("MyOrg::MyHook::Hook"), "hooks XML: {xml}");
2700        assert!(xml.contains("<FailureMode>FAIL</FailureMode>"));
2701
2702        // Execute records a hook result.
2703        s.handle_extra_action(&req("ExecuteChangeSet", &[("ChangeSetName", "cs1")]))
2704            .expect("ExecuteChangeSet");
2705
2706        // ListHookResults returns the recorded invocation.
2707        let resp = s
2708            .handle_extra_action(&req(
2709                "ListHookResults",
2710                &[("TargetType", "CLOUD_FORMATION")],
2711            ))
2712            .expect("ListHookResults");
2713        let xml = body_str(&resp);
2714        assert!(xml.contains("<HookResults>"), "list XML: {xml}");
2715        assert!(xml.contains("MyOrg::MyHook::Hook"));
2716        // A successful provision with a FAIL-mode hook records success.
2717        assert!(xml.contains("HOOK_COMPLETE_SUCCEEDED"));
2718
2719        // Pull the HookResultId out and read it back via GetHookResult.
2720        let id = xml
2721            .split("<HookResultId>")
2722            .nth(1)
2723            .and_then(|s| s.split("</HookResultId>").next())
2724            .expect("a HookResultId in the list")
2725            .to_string();
2726        let resp = s
2727            .handle_extra_action(&req("GetHookResult", &[("HookResultId", &id)]))
2728            .expect("GetHookResult");
2729        let xml = body_str(&resp);
2730        assert!(xml.contains("HOOK_COMPLETE_SUCCEEDED"), "get XML: {xml}");
2731        assert!(xml.contains("MyOrg::MyHook::Hook"));
2732
2733        // An unknown HookResultId returns a handled 2xx response with an
2734        // empty result (the route stays reachable for a hook with no
2735        // recorded invocation) — but it must NOT echo the recorded hook's
2736        // data, so it isn't masking a real result.
2737        let resp = s
2738            .handle_extra_action(&req("GetHookResult", &[("HookResultId", "nope")]))
2739            .expect("GetHookResult for an unknown id still returns 2xx");
2740        let xml = body_str(&resp);
2741        assert!(
2742            xml.contains("<HookResultId>nope</HookResultId>"),
2743            "unknown-id XML: {xml}"
2744        );
2745        assert!(
2746            !xml.contains("MyOrg::MyHook::Hook"),
2747            "unknown id must not echo the recorded hook: {xml}"
2748        );
2749    }
2750
2751    // A minimal CREATE-type change set template with one resource and one
2752    // output, used by the changeset bookkeeping tests below.
2753    const CS_TEMPLATE: &str = r#"{"Resources":{"Q":{"Type":"AWS::SQS::Queue","Properties":{"QueueName":"cs-q"}}},"Outputs":{"QUrl":{"Value":{"Ref":"Q"}}}}"#;
2754
2755    // Gap #2: a CREATE change set that mints a new REVIEW_IN_PROGRESS stack must
2756    // record a stack event, so `DescribeStackEvents` is non-empty before the
2757    // change set is executed (sam's `get_last_event_time` indexes `[0]`).
2758    #[test]
2759    fn create_change_set_records_review_in_progress_event() {
2760        let svc = svc();
2761        svc.handle_extra_action(&req(
2762            "CreateChangeSet",
2763            &[
2764                ("StackName", "cs-events"),
2765                ("ChangeSetName", "cs1"),
2766                ("ChangeSetType", "CREATE"),
2767                ("TemplateBody", CS_TEMPLATE),
2768            ],
2769        ))
2770        .expect("create change set");
2771
2772        // The event log must carry exactly the REVIEW_IN_PROGRESS stack event.
2773        {
2774            let accounts = svc.state.read();
2775            let acct = accounts.get("000000000000").unwrap();
2776            let total: usize = acct.events.values().map(|v| v.len()).sum();
2777            assert_eq!(total, 1, "expected one event after CreateChangeSet");
2778            let ev = acct.events.values().next().unwrap().last().unwrap();
2779            assert_eq!(ev["ResourceStatus"].as_str(), Some("REVIEW_IN_PROGRESS"));
2780            assert_eq!(
2781                ev["ResourceType"].as_str(),
2782                Some("AWS::CloudFormation::Stack")
2783            );
2784        }
2785
2786        // And DescribeStackEvents surfaces it rather than an empty list.
2787        let resp = svc
2788            .handle_extra_action(&req("DescribeStackEvents", &[("StackName", "cs-events")]))
2789            .expect("describe stack events");
2790        let body = std::str::from_utf8(resp.body.expect_bytes()).unwrap();
2791        assert!(!body.contains("<StackEvents/>"), "events list was empty");
2792        assert!(body.contains("REVIEW_IN_PROGRESS"), "body: {body}");
2793    }
2794
2795    // Gap #2b: a stack that provisions within one wall-clock second must still
2796    // produce strictly-increasing, sub-second event timestamps — sam's
2797    // deploy-wait only registers completion on an event strictly later than the
2798    // REVIEW_IN_PROGRESS marker, so equal whole-second timestamps hang it.
2799    #[test]
2800    fn changeset_stack_events_have_monotonic_subsecond_timestamps() {
2801        let svc = svc();
2802        svc.handle_extra_action(&req(
2803            "CreateChangeSet",
2804            &[
2805                ("StackName", "cs-fast"),
2806                ("ChangeSetName", "cs1"),
2807                ("ChangeSetType", "CREATE"),
2808                ("TemplateBody", CS_TEMPLATE),
2809            ],
2810        ))
2811        .expect("create change set");
2812        svc.handle_extra_action(&req(
2813            "ExecuteChangeSet",
2814            &[("StackName", "cs-fast"), ("ChangeSetName", "cs1")],
2815        ))
2816        .expect("execute change set");
2817
2818        let accounts = svc.state.read();
2819        let acct = accounts.get("000000000000").unwrap();
2820        let stack_id = acct.stacks.get("cs-fast").unwrap().stack_id.clone();
2821        let events = acct.events.get(&stack_id).expect("stack has events");
2822
2823        // The full create lifecycle: REVIEW_IN_PROGRESS marker through the
2824        // terminal CREATE_COMPLETE, several events deep, all within one second.
2825        assert!(events.len() >= 3, "expected several lifecycle events");
2826        let ts: Vec<&str> = events
2827            .iter()
2828            .map(|e| e["Timestamp"].as_str().unwrap())
2829            .collect();
2830        assert_eq!(
2831            events.first().unwrap()["ResourceStatus"].as_str(),
2832            Some("REVIEW_IN_PROGRESS")
2833        );
2834        assert_eq!(
2835            events.last().unwrap()["ResourceStatus"].as_str(),
2836            Some("CREATE_COMPLETE")
2837        );
2838        // Millisecond precision (fractional seconds) and strictly increasing.
2839        // The fixed-width rfc3339 millis format sorts lexicographically by time.
2840        for t in &ts {
2841            assert!(t.contains('.'), "timestamp lacks sub-second precision: {t}");
2842        }
2843        for w in ts.windows(2) {
2844            assert!(w[1] > w[0], "timestamps not strictly increasing: {w:?}");
2845        }
2846    }
2847
2848    // Gap #1: tags set on CreateChangeSet and outputs declared in the template
2849    // must both survive ExecuteChangeSet and appear on the created stack (sam's
2850    // managed-bucket health check requires Tags + Outputs in DescribeStacks).
2851    #[test]
2852    fn execute_change_set_persists_tags_and_outputs() {
2853        let svc = svc();
2854        svc.handle_extra_action(&req(
2855            "CreateChangeSet",
2856            &[
2857                ("StackName", "cs-stack"),
2858                ("ChangeSetName", "cs1"),
2859                ("ChangeSetType", "CREATE"),
2860                ("TemplateBody", CS_TEMPLATE),
2861                ("Tags.member.1.Key", "ManagedStackSource"),
2862                ("Tags.member.1.Value", "AwsSamCli"),
2863            ],
2864        ))
2865        .expect("create change set");
2866        svc.handle_extra_action(&req(
2867            "ExecuteChangeSet",
2868            &[("StackName", "cs-stack"), ("ChangeSetName", "cs1")],
2869        ))
2870        .expect("execute change set");
2871
2872        let accounts = svc.state.read();
2873        let stack = accounts
2874            .get("000000000000")
2875            .unwrap()
2876            .stacks
2877            .get("cs-stack")
2878            .unwrap();
2879        assert_eq!(stack.status, "CREATE_COMPLETE");
2880        assert_eq!(
2881            stack.tags.get("ManagedStackSource").map(String::as_str),
2882            Some("AwsSamCli"),
2883            "changeset Tags dropped on execute"
2884        );
2885        assert_eq!(stack.outputs.len(), 1, "changeset Outputs not resolved");
2886        assert_eq!(stack.outputs[0].key, "QUrl");
2887        assert!(!stack.outputs[0].value.is_empty());
2888    }
2889
2890    // Gap #3: a StateMachine that references a Lambda via DefinitionSubstitutions
2891    // must provision *after* the Lambda even when it sorts/declares first, so the
2892    // substitution resolves to the function name rather than leaving the logical
2893    // id baked in the ASL (which broke every invoke with Lambda.ResourceNotFound).
2894    #[test]
2895    fn changeset_provisions_lambda_before_referencing_state_machine() {
2896        let d = deps();
2897        let sfn = d.stepfunctions.clone();
2898        let state: SharedCloudFormationState =
2899            Arc::new(RwLock::new(MultiAccountState::<CloudFormationState>::new(
2900                "000000000000",
2901                "us-east-1",
2902                "",
2903            )));
2904        let svc = CloudFormationService::new(state, d);
2905
2906        // "Machine" sorts before "Worker", so without dependency ordering the
2907        // StateMachine provisions first and bakes the unresolved logical id.
2908        let template = r#"{
2909            "Resources": {
2910                "Machine": {
2911                    "Type": "AWS::StepFunctions::StateMachine",
2912                    "Properties": {
2913                        "RoleArn": "arn:aws:iam::000000000000:role/sfn",
2914                        "DefinitionString": "{\"StartAt\":\"T\",\"States\":{\"T\":{\"Type\":\"Task\",\"Resource\":\"${fn}\",\"End\":true}}}",
2915                        "DefinitionSubstitutions": {"fn": {"Ref": "Worker"}}
2916                    }
2917                },
2918                "Worker": {
2919                    "Type": "AWS::Lambda::Function",
2920                    "Properties": {
2921                        "FunctionName": "workflow_dispatcher_v2-1",
2922                        "Runtime": "python3.12",
2923                        "Handler": "index.handler",
2924                        "Role": "arn:aws:iam::000000000000:role/lambda",
2925                        "Code": {"ZipFile": "def handler(e, c): return e"}
2926                    }
2927                }
2928            }
2929        }"#;
2930
2931        svc.handle_extra_action(&req(
2932            "CreateChangeSet",
2933            &[
2934                ("StackName", "wf"),
2935                ("ChangeSetName", "cs1"),
2936                ("ChangeSetType", "CREATE"),
2937                ("TemplateBody", template),
2938            ],
2939        ))
2940        .expect("create change set");
2941        svc.handle_extra_action(&req(
2942            "ExecuteChangeSet",
2943            &[("StackName", "wf"), ("ChangeSetName", "cs1")],
2944        ))
2945        .expect("execute change set");
2946
2947        let accounts = sfn.read();
2948        let st = accounts.get("000000000000").expect("sfn account exists");
2949        let machine = st
2950            .state_machines
2951            .values()
2952            .next()
2953            .expect("state machine was provisioned");
2954        assert!(
2955            machine.definition.contains("workflow_dispatcher_v2-1"),
2956            "ASL should carry the resolved function name, got: {}",
2957            machine.definition
2958        );
2959        assert!(
2960            !machine.definition.contains("${fn}"),
2961            "substitution token left unreplaced: {}",
2962            machine.definition
2963        );
2964        assert!(
2965            !machine.definition.contains("Worker"),
2966            "logical id leaked into baked ASL: {}",
2967            machine.definition
2968        );
2969    }
2970
2971    #[test]
2972    fn stack_sets_instances_refactors() {
2973        ok("CreateStackSet", &[("StackSetName", "ss")]);
2974        ok("DescribeStackSet", &[("StackSetName", "ss")]);
2975        ok("ListStackSets", &[]);
2976        ok("UpdateStackSet", &[("StackSetName", "ss")]);
2977        ok(
2978            "DescribeStackSetOperation",
2979            &[("StackSetName", "ss"), ("OperationId", "op")],
2980        );
2981        ok("ListStackSetOperations", &[("StackSetName", "ss")]);
2982        ok(
2983            "ListStackSetOperationResults",
2984            &[("StackSetName", "ss"), ("OperationId", "op")],
2985        );
2986        ok(
2987            "ListStackSetAutoDeploymentTargets",
2988            &[("StackSetName", "ss")],
2989        );
2990        ok(
2991            "StopStackSetOperation",
2992            &[("StackSetName", "ss"), ("OperationId", "op")],
2993        );
2994        ok("ImportStacksToStackSet", &[("StackSetName", "ss")]);
2995        ok("DeleteStackSet", &[("StackSetName", "ss")]);
2996        ok(
2997            "CreateStackInstances",
2998            &[("StackSetName", "ss"), ("Regions.member.1", "us-east-1")],
2999        );
3000        ok(
3001            "UpdateStackInstances",
3002            &[("StackSetName", "ss"), ("Regions.member.1", "us-east-1")],
3003        );
3004        ok(
3005            "DeleteStackInstances",
3006            &[
3007                ("StackSetName", "ss"),
3008                ("Regions.member.1", "us-east-1"),
3009                ("RetainStacks", "false"),
3010            ],
3011        );
3012        ok(
3013            "DescribeStackInstance",
3014            &[
3015                ("StackSetName", "ss"),
3016                ("StackInstanceAccount", "000000000000"),
3017                ("StackInstanceRegion", "us-east-1"),
3018            ],
3019        );
3020        ok("ListStackInstances", &[("StackSetName", "ss")]);
3021        ok(
3022            "ListStackInstanceResourceDrifts",
3023            &[
3024                ("StackSetName", "ss"),
3025                ("StackInstanceAccount", "000000000000"),
3026                ("StackInstanceRegion", "us-east-1"),
3027                ("OperationId", "op"),
3028            ],
3029        );
3030        ok(
3031            "CreateStackRefactor",
3032            &[("StackDefinitions.member.1.StackName", "s")],
3033        );
3034        ok("DescribeStackRefactor", &[("StackRefactorId", "r")]);
3035        ok("ExecuteStackRefactor", &[("StackRefactorId", "r")]);
3036        ok("ListStackRefactors", &[]);
3037        ok("ListStackRefactorActions", &[("StackRefactorId", "r")]);
3038    }
3039
3040    #[test]
3041    fn types_and_publishers() {
3042        ok("ActivateType", &[]);
3043        ok("DeactivateType", &[]);
3044        ok("DescribeType", &[]);
3045        ok("DescribeTypeRegistration", &[("RegistrationToken", "tok")]);
3046        ok(
3047            "RegisterType",
3048            &[("TypeName", "T"), ("SchemaHandlerPackage", "pkg")],
3049        );
3050        ok("DeregisterType", &[]);
3051        ok("ListTypes", &[]);
3052        ok("ListTypeRegistrations", &[]);
3053        ok("ListTypeVersions", &[]);
3054        ok(
3055            "BatchDescribeTypeConfigurations",
3056            &[("TypeConfigurationIdentifiers.member.1.Type", "RESOURCE")],
3057        );
3058        ok("SetTypeConfiguration", &[("Configuration", "{}")]);
3059        ok("SetTypeDefaultVersion", &[]);
3060        ok("TestType", &[]);
3061        ok("PublishType", &[]);
3062        ok("RegisterPublisher", &[]);
3063        ok("DescribePublisher", &[]);
3064    }
3065
3066    #[test]
3067    fn templates_resource_scans_drift() {
3068        ok(
3069            "CreateGeneratedTemplate",
3070            &[("GeneratedTemplateName", "gt")],
3071        );
3072        ok(
3073            "UpdateGeneratedTemplate",
3074            &[("GeneratedTemplateName", "gt")],
3075        );
3076        ok(
3077            "DescribeGeneratedTemplate",
3078            &[("GeneratedTemplateName", "gt")],
3079        );
3080        ok("GetGeneratedTemplate", &[("GeneratedTemplateName", "gt")]);
3081        ok("ListGeneratedTemplates", &[]);
3082        ok(
3083            "DeleteGeneratedTemplate",
3084            &[("GeneratedTemplateName", "gt")],
3085        );
3086        ok("StartResourceScan", &[]);
3087        ok("DescribeResourceScan", &[("ResourceScanId", "rs")]);
3088        ok("ListResourceScans", &[]);
3089        ok("ListResourceScanResources", &[("ResourceScanId", "rs")]);
3090        ok(
3091            "ListResourceScanRelatedResources",
3092            &[
3093                ("ResourceScanId", "rs"),
3094                ("Resources.member.1.ResourceType", "AWS::SQS::Queue"),
3095            ],
3096        );
3097        ok("DetectStackDrift", &[("StackName", "s")]);
3098        ok(
3099            "DetectStackResourceDrift",
3100            &[("StackName", "s"), ("LogicalResourceId", "L")],
3101        );
3102        ok("DetectStackSetDrift", &[("StackSetName", "ss")]);
3103        ok(
3104            "DescribeStackDriftDetectionStatus",
3105            &[("StackDriftDetectionId", "id")],
3106        );
3107        ok("DescribeStackResourceDrifts", &[("StackName", "s")]);
3108        ok(
3109            "DescribeStackResource",
3110            &[("StackName", "s"), ("LogicalResourceId", "L")],
3111        );
3112    }
3113
3114    #[test]
3115    fn events_hooks_imports_policies_org() {
3116        ok("DescribeStackEvents", &[("StackName", "s")]);
3117        ok("DescribeEvents", &[]);
3118        // GetHookResult now reads a real recorded result; an unknown id
3119        // is HookResultNotFound (covered in `hook_round_trip`), so it's
3120        // no longer a blanket-OK route. ListHookResults with no recorded
3121        // results returns an empty list.
3122        ok("ListHookResults", &[]);
3123        ok(
3124            "RecordHandlerProgress",
3125            &[("BearerToken", "tok"), ("OperationStatus", "SUCCESS")],
3126        );
3127        ok("ListExports", &[]);
3128        ok("ListImports", &[("ExportName", "SomeExport")]);
3129        ok("GetStackPolicy", &[("StackName", "s")]);
3130        ok("SetStackPolicy", &[("StackName", "s")]);
3131        ok(
3132            "UpdateTerminationProtection",
3133            &[("StackName", "s"), ("EnableTerminationProtection", "false")],
3134        );
3135        ok("DescribeAccountLimits", &[]);
3136        ok("ActivateOrganizationsAccess", &[]);
3137        ok("DescribeOrganizationsAccess", &[]);
3138        ok("DeactivateOrganizationsAccess", &[]);
3139        ok("ValidateTemplate", &[]);
3140        ok("EstimateTemplateCost", &[]);
3141        ok("GetTemplateSummary", &[]);
3142        ok("CancelUpdateStack", &[("StackName", "s")]);
3143        ok("ContinueUpdateRollback", &[("StackName", "s")]);
3144        ok("RollbackStack", &[("StackName", "s")]);
3145        ok(
3146            "SignalResource",
3147            &[
3148                ("StackName", "s"),
3149                ("LogicalResourceId", "L"),
3150                ("UniqueId", "U"),
3151                ("Status", "SUCCESS"),
3152            ],
3153        );
3154    }
3155}