Skip to main content

fakecloud_cloudformation/
extras.rs

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