Skip to main content

fakecloud_cloudformation/
service.rs

1use async_trait::async_trait;
2use chrono::Utc;
3use http::StatusCode;
4use std::collections::HashMap;
5use std::sync::Arc;
6
7use fakecloud_core::delivery::DeliveryBus;
8use fakecloud_core::service::{AwsRequest, AwsResponse, AwsService, AwsServiceError};
9use fakecloud_dynamodb::state::SharedDynamoDbState;
10use fakecloud_eventbridge::state::SharedEventBridgeState;
11use fakecloud_iam::state::SharedIamState;
12use fakecloud_logs::state::SharedLogsState;
13use fakecloud_s3::state::SharedS3State;
14use fakecloud_sns::state::SharedSnsState;
15use fakecloud_sqs::state::SharedSqsState;
16use fakecloud_ssm::state::SharedSsmState;
17
18use crate::resource_provisioner::ResourceProvisioner;
19use crate::state::{SharedCloudFormationState, Stack};
20use crate::template;
21use crate::xml_responses;
22
23pub struct CloudFormationService {
24    state: SharedCloudFormationState,
25    sqs_state: SharedSqsState,
26    sns_state: SharedSnsState,
27    ssm_state: SharedSsmState,
28    iam_state: SharedIamState,
29    s3_state: SharedS3State,
30    eventbridge_state: SharedEventBridgeState,
31    dynamodb_state: SharedDynamoDbState,
32    logs_state: SharedLogsState,
33    delivery: Arc<DeliveryBus>,
34}
35
36impl CloudFormationService {
37    #[allow(clippy::too_many_arguments)]
38    pub fn new(
39        state: SharedCloudFormationState,
40        sqs_state: SharedSqsState,
41        sns_state: SharedSnsState,
42        ssm_state: SharedSsmState,
43        iam_state: SharedIamState,
44        s3_state: SharedS3State,
45        eventbridge_state: SharedEventBridgeState,
46        dynamodb_state: SharedDynamoDbState,
47        logs_state: SharedLogsState,
48        delivery: Arc<DeliveryBus>,
49    ) -> Self {
50        Self {
51            state,
52            sqs_state,
53            sns_state,
54            ssm_state,
55            iam_state,
56            s3_state,
57            eventbridge_state,
58            dynamodb_state,
59            logs_state,
60            delivery,
61        }
62    }
63
64    fn provisioner(&self, stack_id: &str) -> ResourceProvisioner {
65        let cf_state = self.state.read();
66        ResourceProvisioner {
67            sqs_state: self.sqs_state.clone(),
68            sns_state: self.sns_state.clone(),
69            ssm_state: self.ssm_state.clone(),
70            iam_state: self.iam_state.clone(),
71            s3_state: self.s3_state.clone(),
72            eventbridge_state: self.eventbridge_state.clone(),
73            dynamodb_state: self.dynamodb_state.clone(),
74            logs_state: self.logs_state.clone(),
75            delivery: self.delivery.clone(),
76            account_id: cf_state.account_id.clone(),
77            region: cf_state.region.clone(),
78            stack_id: stack_id.to_string(),
79        }
80    }
81
82    fn get_param(req: &AwsRequest, key: &str) -> Option<String> {
83        // Check query params first (for Query protocol)
84        if let Some(v) = req.query_params.get(key) {
85            return Some(v.clone());
86        }
87        // Then check form-encoded body
88        let body_params = fakecloud_core::protocol::parse_query_body(&req.body);
89        body_params.get(key).cloned()
90    }
91
92    fn get_all_params(req: &AwsRequest) -> HashMap<String, String> {
93        let mut params = req.query_params.clone();
94        let body_params = fakecloud_core::protocol::parse_query_body(&req.body);
95        for (k, v) in body_params {
96            params.entry(k).or_insert(v);
97        }
98        params
99    }
100
101    fn extract_tags(params: &HashMap<String, String>) -> HashMap<String, String> {
102        let mut tags = HashMap::new();
103        for i in 1.. {
104            let key_param = format!("Tags.member.{i}.Key");
105            let value_param = format!("Tags.member.{i}.Value");
106            match (params.get(&key_param), params.get(&value_param)) {
107                (Some(k), Some(v)) => {
108                    tags.insert(k.clone(), v.clone());
109                }
110                _ => break,
111            }
112        }
113        tags
114    }
115
116    fn extract_parameters(params: &HashMap<String, String>) -> HashMap<String, String> {
117        let mut result = HashMap::new();
118        for i in 1.. {
119            let key_param = format!("Parameters.member.{i}.ParameterKey");
120            let value_param = format!("Parameters.member.{i}.ParameterValue");
121            match (params.get(&key_param), params.get(&value_param)) {
122                (Some(k), Some(v)) => {
123                    result.insert(k.clone(), v.clone());
124                }
125                _ => break,
126            }
127        }
128        result
129    }
130
131    fn create_stack(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
132        let params = Self::get_all_params(req);
133
134        let stack_name = params.get("StackName").ok_or_else(|| {
135            AwsServiceError::aws_error(
136                StatusCode::BAD_REQUEST,
137                "ValidationError",
138                "StackName is required",
139            )
140        })?;
141
142        let template_body = params.get("TemplateBody").ok_or_else(|| {
143            AwsServiceError::aws_error(
144                StatusCode::BAD_REQUEST,
145                "ValidationError",
146                "TemplateBody is required",
147            )
148        })?;
149
150        // Check if stack already exists and is not deleted
151        {
152            let state = self.state.read();
153            if let Some(existing) = state.stacks.get(stack_name.as_str()) {
154                if existing.status != "DELETE_COMPLETE" {
155                    return Err(AwsServiceError::aws_error(
156                        StatusCode::BAD_REQUEST,
157                        "AlreadyExistsException",
158                        format!("Stack [{stack_name}] already exists"),
159                    ));
160                }
161            }
162        }
163
164        let tags = Self::extract_tags(&params);
165        let parameters = Self::extract_parameters(&params);
166
167        // First pass: parse to get resource definitions (without physical ID resolution)
168        let parsed = template::parse_template(template_body, &parameters).map_err(|e| {
169            AwsServiceError::aws_error(StatusCode::BAD_REQUEST, "ValidationError", e)
170        })?;
171
172        let stack_id = {
173            let state = self.state.read();
174            format!(
175                "arn:aws:cloudformation:{}:{}:stack/{}/{}",
176                state.region,
177                state.account_id,
178                stack_name,
179                uuid::Uuid::new_v4()
180            )
181        };
182
183        let provisioner = self.provisioner(&stack_id);
184        let mut resources = Vec::new();
185        let mut physical_ids: HashMap<String, String> = HashMap::new();
186
187        // Create resources incrementally, re-resolving Refs with known physical IDs.
188        // Use multi-pass to handle dependency ordering (resources may reference each
189        // other via Ref, and JSON object key order is not guaranteed).
190        let mut pending: Vec<&template::ResourceDefinition> = parsed.resources.iter().collect();
191        let max_passes = pending.len() + 1;
192        for _ in 0..max_passes {
193            if pending.is_empty() {
194                break;
195            }
196            let mut still_pending = Vec::new();
197            let mut made_progress = false;
198
199            for resource_def in pending {
200                let resolved_def = template::resolve_resource_properties(
201                    resource_def,
202                    template_body,
203                    &parameters,
204                    &physical_ids,
205                )
206                .map_err(|e| {
207                    AwsServiceError::aws_error(StatusCode::BAD_REQUEST, "ValidationError", e)
208                })?;
209
210                match provisioner.create_resource(&resolved_def) {
211                    Ok(stack_resource) => {
212                        physical_ids.insert(
213                            stack_resource.logical_id.clone(),
214                            stack_resource.physical_id.clone(),
215                        );
216                        resources.push(stack_resource);
217                        made_progress = true;
218                    }
219                    Err(_) => {
220                        still_pending.push(resource_def);
221                    }
222                }
223            }
224
225            pending = still_pending;
226            if !made_progress && !pending.is_empty() {
227                // No progress made — report the first failure
228                let resource_def = pending[0];
229                let resolved_def = template::resolve_resource_properties(
230                    resource_def,
231                    template_body,
232                    &parameters,
233                    &physical_ids,
234                )
235                .unwrap_or_else(|_| resource_def.clone());
236                let err = provisioner.create_resource(&resolved_def).unwrap_err();
237                // Rollback
238                for r in &resources {
239                    let _ = provisioner.delete_resource(r);
240                }
241                return Err(AwsServiceError::aws_error(
242                    StatusCode::BAD_REQUEST,
243                    "ValidationError",
244                    format!(
245                        "Failed to create resource {}: {err}",
246                        resource_def.logical_id
247                    ),
248                ));
249            }
250        }
251
252        let stack = Stack {
253            name: stack_name.clone(),
254            stack_id: stack_id.clone(),
255            template: template_body.clone(),
256            status: "CREATE_COMPLETE".to_string(),
257            resources,
258            parameters,
259            tags,
260            created_at: Utc::now(),
261            updated_at: None,
262            description: parsed.description,
263        };
264
265        {
266            let mut state = self.state.write();
267            state.stacks.insert(stack_name.clone(), stack);
268        }
269
270        Ok(AwsResponse::xml(
271            StatusCode::OK,
272            xml_responses::create_stack_response(&stack_id, &req.request_id),
273        ))
274    }
275
276    fn delete_stack(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
277        let stack_name = Self::get_param(req, "StackName").ok_or_else(|| {
278            AwsServiceError::aws_error(
279                StatusCode::BAD_REQUEST,
280                "ValidationError",
281                "StackName is required",
282            )
283        })?;
284
285        let mut state = self.state.write();
286
287        // Find stack by name or stack ID
288        let stack = state.stacks.values_mut().find(|s| {
289            (s.name == stack_name || s.stack_id == stack_name) && s.status != "DELETE_COMPLETE"
290        });
291
292        if let Some(stack) = stack {
293            let stack_id = stack.stack_id.clone();
294            let resources: Vec<_> = stack.resources.clone();
295
296            // Build the provisioner while we still have the stack_id
297            // Drop the write lock temporarily so the provisioner can read state
298            drop(state);
299            let provisioner = self.provisioner(&stack_id);
300
301            // Delete resources in reverse order
302            for resource in resources.iter().rev() {
303                let _ = provisioner.delete_resource(resource);
304            }
305
306            // Re-acquire the write lock to update stack status
307            let mut state = self.state.write();
308            if let Some(stack) = state.stacks.values_mut().find(|s| s.stack_id == stack_id) {
309                stack.status = "DELETE_COMPLETE".to_string();
310                stack.resources.clear();
311            }
312        }
313
314        Ok(AwsResponse::xml(
315            StatusCode::OK,
316            xml_responses::delete_stack_response(&req.request_id),
317        ))
318    }
319
320    fn describe_stacks(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
321        let stack_name = Self::get_param(req, "StackName");
322
323        let state = self.state.read();
324        let stacks: Vec<Stack> = if let Some(ref name) = stack_name {
325            state
326                .stacks
327                .values()
328                .filter(|s| {
329                    (s.name == *name || s.stack_id == *name) && s.status != "DELETE_COMPLETE"
330                })
331                .cloned()
332                .collect()
333        } else {
334            state
335                .stacks
336                .values()
337                .filter(|s| s.status != "DELETE_COMPLETE")
338                .cloned()
339                .collect()
340        };
341
342        if let Some(ref name) = stack_name {
343            if stacks.is_empty() {
344                return Err(AwsServiceError::aws_error(
345                    StatusCode::BAD_REQUEST,
346                    "ValidationError",
347                    format!("Stack with id {name} does not exist"),
348                ));
349            }
350        }
351
352        Ok(AwsResponse::xml(
353            StatusCode::OK,
354            xml_responses::describe_stacks_response(&stacks, &req.request_id),
355        ))
356    }
357
358    fn list_stacks(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
359        let state = self.state.read();
360        let stacks: Vec<Stack> = state.stacks.values().cloned().collect();
361
362        Ok(AwsResponse::xml(
363            StatusCode::OK,
364            xml_responses::list_stacks_response(&stacks, &req.request_id),
365        ))
366    }
367
368    fn list_stack_resources(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
369        let stack_name = Self::get_param(req, "StackName").ok_or_else(|| {
370            AwsServiceError::aws_error(
371                StatusCode::BAD_REQUEST,
372                "ValidationError",
373                "StackName is required",
374            )
375        })?;
376
377        let state = self.state.read();
378        let stack = state
379            .stacks
380            .values()
381            .find(|s| {
382                (s.name == stack_name || s.stack_id == stack_name) && s.status != "DELETE_COMPLETE"
383            })
384            .ok_or_else(|| {
385                AwsServiceError::aws_error(
386                    StatusCode::BAD_REQUEST,
387                    "ValidationError",
388                    format!("Stack [{stack_name}] does not exist"),
389                )
390            })?;
391
392        Ok(AwsResponse::xml(
393            StatusCode::OK,
394            xml_responses::list_stack_resources_response(&stack.resources, &req.request_id),
395        ))
396    }
397
398    fn describe_stack_resources(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
399        let stack_name = Self::get_param(req, "StackName").ok_or_else(|| {
400            AwsServiceError::aws_error(
401                StatusCode::BAD_REQUEST,
402                "ValidationError",
403                "StackName is required",
404            )
405        })?;
406
407        let state = self.state.read();
408        let stack = state
409            .stacks
410            .values()
411            .find(|s| {
412                (s.name == stack_name || s.stack_id == stack_name) && s.status != "DELETE_COMPLETE"
413            })
414            .ok_or_else(|| {
415                AwsServiceError::aws_error(
416                    StatusCode::BAD_REQUEST,
417                    "ValidationError",
418                    format!("Stack [{stack_name}] does not exist"),
419                )
420            })?;
421
422        Ok(AwsResponse::xml(
423            StatusCode::OK,
424            xml_responses::describe_stack_resources_response(
425                &stack.resources,
426                &stack.name,
427                &req.request_id,
428            ),
429        ))
430    }
431
432    fn update_stack(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
433        let params = Self::get_all_params(req);
434
435        let stack_name = params.get("StackName").ok_or_else(|| {
436            AwsServiceError::aws_error(
437                StatusCode::BAD_REQUEST,
438                "ValidationError",
439                "StackName is required",
440            )
441        })?;
442
443        let template_body = params.get("TemplateBody").ok_or_else(|| {
444            AwsServiceError::aws_error(
445                StatusCode::BAD_REQUEST,
446                "ValidationError",
447                "TemplateBody is required",
448            )
449        })?;
450
451        let new_parameters = Self::extract_parameters(&params);
452        let new_tags = Self::extract_tags(&params);
453
454        let parsed = template::parse_template(template_body, &new_parameters).map_err(|e| {
455            AwsServiceError::aws_error(StatusCode::BAD_REQUEST, "ValidationError", e)
456        })?;
457
458        // Get stack_id before write lock for the provisioner
459        let found_stack_id = {
460            let state = self.state.read();
461            state
462                .stacks
463                .values()
464                .find(|s| {
465                    (s.name == *stack_name || s.stack_id == *stack_name)
466                        && s.status != "DELETE_COMPLETE"
467                })
468                .map(|s| s.stack_id.clone())
469                .unwrap_or_default()
470        };
471
472        let provisioner = self.provisioner(&found_stack_id);
473
474        let mut state = self.state.write();
475        let stack = state
476            .stacks
477            .values_mut()
478            .find(|s| {
479                (s.name == *stack_name || s.stack_id == *stack_name)
480                    && s.status != "DELETE_COMPLETE"
481            })
482            .ok_or_else(|| {
483                AwsServiceError::aws_error(
484                    StatusCode::BAD_REQUEST,
485                    "ValidationError",
486                    format!("Stack [{stack_name}] does not exist"),
487                )
488            })?;
489
490        // Determine which resources to add and remove
491        let old_logical_ids: std::collections::HashSet<String> = stack
492            .resources
493            .iter()
494            .map(|r| r.logical_id.clone())
495            .collect();
496        let new_logical_ids: std::collections::HashSet<String> = parsed
497            .resources
498            .iter()
499            .map(|r| r.logical_id.clone())
500            .collect();
501
502        // Delete resources that are no longer in the template
503        let to_remove: Vec<_> = stack
504            .resources
505            .iter()
506            .filter(|r| !new_logical_ids.contains(&r.logical_id))
507            .cloned()
508            .collect();
509        for resource in &to_remove {
510            let _ = provisioner.delete_resource(resource);
511        }
512        stack
513            .resources
514            .retain(|r| new_logical_ids.contains(&r.logical_id));
515
516        // Build physical ID map from existing resources
517        let mut physical_ids: HashMap<String, String> = stack
518            .resources
519            .iter()
520            .map(|r| (r.logical_id.clone(), r.physical_id.clone()))
521            .collect();
522
523        // Create new resources
524        let mut update_failed = false;
525        let mut update_error_msg = String::new();
526        for resource_def in &parsed.resources {
527            if !old_logical_ids.contains(&resource_def.logical_id) {
528                let resolved_def = match template::resolve_resource_properties(
529                    resource_def,
530                    template_body,
531                    &new_parameters,
532                    &physical_ids,
533                ) {
534                    Ok(d) => d,
535                    Err(e) => {
536                        update_failed = true;
537                        update_error_msg = format!(
538                            "Failed to resolve resource {}: {e}",
539                            resource_def.logical_id
540                        );
541                        continue;
542                    }
543                };
544                match provisioner.create_resource(&resolved_def) {
545                    Ok(stack_resource) => {
546                        physical_ids.insert(
547                            stack_resource.logical_id.clone(),
548                            stack_resource.physical_id.clone(),
549                        );
550                        stack.resources.push(stack_resource);
551                    }
552                    Err(e) => {
553                        tracing::warn!(
554                            "Failed to create resource {} during update: {e}",
555                            resource_def.logical_id
556                        );
557                        update_failed = true;
558                        update_error_msg =
559                            format!("Failed to create resource {}: {e}", resource_def.logical_id);
560                    }
561                }
562            }
563        }
564
565        let stack_id = stack.stack_id.clone();
566        stack.template = template_body.clone();
567        stack.status = if update_failed {
568            "UPDATE_FAILED".to_string()
569        } else {
570            "UPDATE_COMPLETE".to_string()
571        };
572        stack.parameters = new_parameters;
573        if !new_tags.is_empty() {
574            stack.tags = new_tags;
575        }
576        stack.updated_at = Some(Utc::now());
577        stack.description = parsed.description;
578
579        if update_failed {
580            return Err(AwsServiceError::aws_error(
581                StatusCode::BAD_REQUEST,
582                "ValidationError",
583                update_error_msg,
584            ));
585        }
586
587        Ok(AwsResponse::xml(
588            StatusCode::OK,
589            xml_responses::update_stack_response(&stack_id, &req.request_id),
590        ))
591    }
592
593    fn get_template(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
594        let stack_name = Self::get_param(req, "StackName").ok_or_else(|| {
595            AwsServiceError::aws_error(
596                StatusCode::BAD_REQUEST,
597                "ValidationError",
598                "StackName is required",
599            )
600        })?;
601
602        let state = self.state.read();
603        let stack = state
604            .stacks
605            .values()
606            .find(|s| {
607                (s.name == stack_name || s.stack_id == stack_name) && s.status != "DELETE_COMPLETE"
608            })
609            .ok_or_else(|| {
610                AwsServiceError::aws_error(
611                    StatusCode::BAD_REQUEST,
612                    "ValidationError",
613                    format!("Stack [{stack_name}] does not exist"),
614                )
615            })?;
616
617        Ok(AwsResponse::xml(
618            StatusCode::OK,
619            xml_responses::get_template_response(&stack.template, &req.request_id),
620        ))
621    }
622}
623
624#[async_trait]
625impl AwsService for CloudFormationService {
626    fn service_name(&self) -> &str {
627        "cloudformation"
628    }
629
630    async fn handle(&self, req: AwsRequest) -> Result<AwsResponse, AwsServiceError> {
631        match req.action.as_str() {
632            "CreateStack" => self.create_stack(&req),
633            "DeleteStack" => self.delete_stack(&req),
634            "DescribeStacks" => self.describe_stacks(&req),
635            "ListStacks" => self.list_stacks(&req),
636            "ListStackResources" => self.list_stack_resources(&req),
637            "DescribeStackResources" => self.describe_stack_resources(&req),
638            "UpdateStack" => self.update_stack(&req),
639            "GetTemplate" => self.get_template(&req),
640            _ => Err(AwsServiceError::action_not_implemented(
641                "cloudformation",
642                &req.action,
643            )),
644        }
645    }
646
647    fn supported_actions(&self) -> &[&str] {
648        &[
649            "CreateStack",
650            "DeleteStack",
651            "DescribeStacks",
652            "ListStacks",
653            "ListStackResources",
654            "DescribeStackResources",
655            "UpdateStack",
656            "GetTemplate",
657        ]
658    }
659}
660
661#[cfg(test)]
662mod tests {
663    use super::*;
664    use crate::state::CloudFormationState;
665    use http::HeaderMap;
666    use parking_lot::RwLock;
667    use std::sync::Arc;
668
669    fn make_service() -> CloudFormationService {
670        let cf_state = Arc::new(RwLock::new(CloudFormationState::new(
671            "123456789012",
672            "us-east-1",
673        )));
674        CloudFormationService::new(
675            cf_state,
676            Arc::new(RwLock::new(fakecloud_sqs::state::SqsState::new(
677                "123456789012",
678                "us-east-1",
679                "http://localhost:4566",
680            ))),
681            Arc::new(RwLock::new(fakecloud_sns::state::SnsState::new(
682                "123456789012",
683                "us-east-1",
684                "http://localhost:4566",
685            ))),
686            Arc::new(RwLock::new(fakecloud_ssm::state::SsmState::new(
687                "123456789012",
688                "us-east-1",
689            ))),
690            Arc::new(RwLock::new(fakecloud_iam::state::IamState::new(
691                "123456789012",
692            ))),
693            Arc::new(RwLock::new(fakecloud_s3::state::S3State::new(
694                "123456789012",
695                "us-east-1",
696            ))),
697            Arc::new(RwLock::new(
698                fakecloud_eventbridge::state::EventBridgeState::new("123456789012", "us-east-1"),
699            )),
700            Arc::new(RwLock::new(fakecloud_dynamodb::state::DynamoDbState::new(
701                "123456789012",
702                "us-east-1",
703            ))),
704            Arc::new(RwLock::new(fakecloud_logs::state::LogsState::new(
705                "123456789012",
706                "us-east-1",
707            ))),
708            Arc::new(DeliveryBus::new()),
709        )
710    }
711
712    fn make_request(action: &str, params: HashMap<String, String>) -> AwsRequest {
713        AwsRequest {
714            service: "cloudformation".to_string(),
715            action: action.to_string(),
716            region: "us-east-1".to_string(),
717            account_id: "123456789012".to_string(),
718            request_id: "test-request-id".to_string(),
719            headers: HeaderMap::new(),
720            query_params: params,
721            body: bytes::Bytes::new(),
722            path_segments: vec![],
723            raw_path: "/".to_string(),
724            raw_query: String::new(),
725            method: http::Method::POST,
726            is_query_protocol: true,
727            access_key_id: None,
728        }
729    }
730
731    #[test]
732    fn update_stack_sets_failed_status_on_resource_error() {
733        let svc = make_service();
734
735        // Create a stack with just a queue
736        let mut create_params = HashMap::new();
737        create_params.insert("StackName".to_string(), "test-stack".to_string());
738        create_params.insert(
739            "TemplateBody".to_string(),
740            r#"{"Resources":{"MyQueue":{"Type":"AWS::SQS::Queue","Properties":{"QueueName":"q1"}}}}"#.to_string(),
741        );
742        let req = make_request("CreateStack", create_params);
743        let result = svc.create_stack(&req);
744        assert!(result.is_ok());
745
746        // Update stack adding an SNS subscription with a non-existent topic
747        let mut update_params = HashMap::new();
748        update_params.insert("StackName".to_string(), "test-stack".to_string());
749        update_params.insert(
750            "TemplateBody".to_string(),
751            r#"{"Resources":{"MyQueue":{"Type":"AWS::SQS::Queue","Properties":{"QueueName":"q1"}},"BadSub":{"Type":"AWS::SNS::Subscription","Properties":{"TopicArn":"arn:aws:sns:us-east-1:123456789012:nope","Protocol":"sqs","Endpoint":"arn:aws:sqs:us-east-1:123456789012:q1"}}}}"#.to_string(),
752        );
753        let req = make_request("UpdateStack", update_params);
754        let result = svc.update_stack(&req);
755
756        // Should return an error
757        assert!(result.is_err());
758
759        // Stack status should be UPDATE_FAILED
760        let state = svc.state.read();
761        let stack = state.stacks.get("test-stack").unwrap();
762        assert_eq!(stack.status, "UPDATE_FAILED");
763    }
764
765    #[test]
766    fn create_stack_resolves_ref_to_physical_id() {
767        let svc = make_service();
768
769        // Template where subscription Refs the topic
770        let template = r#"{
771            "Resources": {
772                "MyTopic": {
773                    "Type": "AWS::SNS::Topic",
774                    "Properties": { "TopicName": "ref-test-topic" }
775                },
776                "MySub": {
777                    "Type": "AWS::SNS::Subscription",
778                    "Properties": {
779                        "TopicArn": { "Ref": "MyTopic" },
780                        "Protocol": "sqs",
781                        "Endpoint": "arn:aws:sqs:us-east-1:123456789012:some-queue"
782                    }
783                }
784            }
785        }"#;
786
787        let mut params = HashMap::new();
788        params.insert("StackName".to_string(), "ref-stack".to_string());
789        params.insert("TemplateBody".to_string(), template.to_string());
790        let req = make_request("CreateStack", params);
791        let result = svc.create_stack(&req);
792        assert!(result.is_ok(), "CreateStack failed: {:?}", result.err());
793
794        // Verify both resources were created
795        let state = svc.state.read();
796        let stack = state.stacks.get("ref-stack").unwrap();
797        assert_eq!(stack.resources.len(), 2);
798        assert_eq!(stack.status, "CREATE_COMPLETE");
799
800        // The subscription's physical ID should be an ARN (not just "MyTopic")
801        let sub = stack
802            .resources
803            .iter()
804            .find(|r| r.logical_id == "MySub")
805            .unwrap();
806        assert!(
807            sub.physical_id.contains("ref-test-topic"),
808            "Subscription physical ID should reference the topic ARN, got: {}",
809            sub.physical_id
810        );
811    }
812}