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