Skip to main content

fakecloud_cloudformation/
extras.rs

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