Skip to main content

fakecloud_cloudformation/
service.rs

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