Skip to main content

fakecloud_cloudformation/
resource_provisioner.rs

1use chrono::Utc;
2use std::collections::HashMap;
3use uuid::Uuid;
4
5use fakecloud_dynamodb::state::{
6    AttributeDefinition, DynamoTable, KeySchemaElement, ProvisionedThroughput, SharedDynamoDbState,
7};
8use fakecloud_eventbridge::state::{EventRule, SharedEventBridgeState};
9use fakecloud_iam::state::{IamPolicy, IamRole, PolicyVersion, SharedIamState};
10use fakecloud_logs::state::SharedLogsState;
11use fakecloud_s3::state::{S3Bucket, SharedS3State};
12use fakecloud_sns::state::{SharedSnsState, SnsSubscription, SnsTopic};
13use fakecloud_sqs::state::{SharedSqsState, SqsQueue};
14use fakecloud_ssm::state::{SharedSsmState, SsmParameter};
15
16use crate::state::StackResource;
17use crate::template::ResourceDefinition;
18
19/// Holds references to all service states so CloudFormation can provision resources.
20pub struct ResourceProvisioner {
21    pub sqs_state: SharedSqsState,
22    pub sns_state: SharedSnsState,
23    pub ssm_state: SharedSsmState,
24    pub iam_state: SharedIamState,
25    pub s3_state: SharedS3State,
26    pub eventbridge_state: SharedEventBridgeState,
27    pub dynamodb_state: SharedDynamoDbState,
28    pub logs_state: SharedLogsState,
29    pub account_id: String,
30    pub region: String,
31}
32
33impl ResourceProvisioner {
34    /// Create a resource and return the StackResource with physical ID.
35    pub fn create_resource(&self, resource: &ResourceDefinition) -> Result<StackResource, String> {
36        let result = match resource.resource_type.as_str() {
37            "AWS::SQS::Queue" => self.create_sqs_queue(resource),
38            "AWS::SNS::Topic" => self.create_sns_topic(resource),
39            "AWS::SNS::Subscription" => self.create_sns_subscription(resource),
40            "AWS::SSM::Parameter" => self.create_ssm_parameter(resource),
41            "AWS::IAM::Role" => self.create_iam_role(resource),
42            "AWS::IAM::Policy" => self.create_iam_policy(resource),
43            "AWS::S3::Bucket" => self.create_s3_bucket(resource),
44            "AWS::Events::Rule" => self.create_eventbridge_rule(resource),
45            "AWS::DynamoDB::Table" => self.create_dynamodb_table(resource),
46            "AWS::Logs::LogGroup" => self.create_log_group(resource),
47            other => Err(format!("Unsupported resource type: {other}")),
48        };
49
50        result.map(|physical_id| StackResource {
51            logical_id: resource.logical_id.clone(),
52            physical_id,
53            resource_type: resource.resource_type.clone(),
54            status: "CREATE_COMPLETE".to_string(),
55        })
56    }
57
58    /// Delete a previously created resource.
59    pub fn delete_resource(&self, resource: &StackResource) -> Result<(), String> {
60        match resource.resource_type.as_str() {
61            "AWS::SQS::Queue" => self.delete_sqs_queue(&resource.physical_id),
62            "AWS::SNS::Topic" => self.delete_sns_topic(&resource.physical_id),
63            "AWS::SNS::Subscription" => self.delete_sns_subscription(&resource.physical_id),
64            "AWS::SSM::Parameter" => self.delete_ssm_parameter(&resource.physical_id),
65            "AWS::IAM::Role" => self.delete_iam_role(&resource.physical_id),
66            "AWS::IAM::Policy" => self.delete_iam_policy(&resource.physical_id),
67            "AWS::S3::Bucket" => self.delete_s3_bucket(&resource.physical_id),
68            "AWS::Events::Rule" => self.delete_eventbridge_rule(&resource.physical_id),
69            "AWS::DynamoDB::Table" => self.delete_dynamodb_table(&resource.physical_id),
70            "AWS::Logs::LogGroup" => self.delete_log_group(&resource.physical_id),
71            other => Err(format!("Unsupported resource type: {other}")),
72        }
73    }
74
75    // --- SQS ---
76
77    fn create_sqs_queue(&self, resource: &ResourceDefinition) -> Result<String, String> {
78        let props = &resource.properties;
79        let queue_name = props
80            .get("QueueName")
81            .and_then(|v| v.as_str())
82            .unwrap_or(&resource.logical_id);
83
84        let mut state = self.sqs_state.write();
85        let queue_url = format!("http://localhost:4566/{}/{}", state.account_id, queue_name);
86        let arn = format!(
87            "arn:aws:sqs:{}:{}:{}",
88            state.region, state.account_id, queue_name
89        );
90
91        let is_fifo = queue_name.ends_with(".fifo");
92        let mut attributes = HashMap::new();
93        if let Some(obj) = props.as_object() {
94            for (k, v) in obj {
95                if k != "QueueName" {
96                    if let Some(s) = v.as_str() {
97                        attributes.insert(k.clone(), s.to_string());
98                    } else if let Some(n) = v.as_i64() {
99                        attributes.insert(k.clone(), n.to_string());
100                    }
101                }
102            }
103        }
104
105        let queue = SqsQueue {
106            queue_name: queue_name.to_string(),
107            queue_url: queue_url.clone(),
108            arn,
109            created_at: Utc::now(),
110            messages: std::collections::VecDeque::new(),
111            inflight: Vec::new(),
112            attributes,
113            is_fifo,
114            dedup_cache: HashMap::new(),
115            redrive_policy: None,
116            tags: HashMap::new(),
117            next_sequence_number: 0,
118            permission_labels: Vec::new(),
119            receipt_handle_map: HashMap::new(),
120        };
121
122        state
123            .name_to_url
124            .insert(queue_name.to_string(), queue_url.clone());
125        state.queues.insert(queue_url.clone(), queue);
126
127        Ok(queue_url)
128    }
129
130    fn delete_sqs_queue(&self, physical_id: &str) -> Result<(), String> {
131        let mut state = self.sqs_state.write();
132        if let Some(queue) = state.queues.remove(physical_id) {
133            state.name_to_url.remove(&queue.queue_name);
134        }
135        Ok(())
136    }
137
138    // --- SNS ---
139
140    fn create_sns_topic(&self, resource: &ResourceDefinition) -> Result<String, String> {
141        let props = &resource.properties;
142        let topic_name = props
143            .get("TopicName")
144            .and_then(|v| v.as_str())
145            .unwrap_or(&resource.logical_id);
146
147        let mut state = self.sns_state.write();
148        let topic_arn = format!(
149            "arn:aws:sns:{}:{}:{}",
150            state.region, state.account_id, topic_name
151        );
152
153        let topic = SnsTopic {
154            topic_arn: topic_arn.clone(),
155            name: topic_name.to_string(),
156            attributes: HashMap::new(),
157            tags: Vec::new(),
158            is_fifo: topic_name.ends_with(".fifo"),
159            created_at: Utc::now(),
160        };
161
162        state.topics.insert(topic_arn.clone(), topic);
163        Ok(topic_arn)
164    }
165
166    fn delete_sns_topic(&self, physical_id: &str) -> Result<(), String> {
167        let mut state = self.sns_state.write();
168        state.topics.remove(physical_id);
169        // Also remove subscriptions for this topic
170        state
171            .subscriptions
172            .retain(|_, sub| sub.topic_arn != physical_id);
173        Ok(())
174    }
175
176    // --- SNS Subscription ---
177
178    fn create_sns_subscription(&self, resource: &ResourceDefinition) -> Result<String, String> {
179        let props = &resource.properties;
180        let topic_arn = props
181            .get("TopicArn")
182            .and_then(|v| v.as_str())
183            .ok_or("SNS Subscription requires TopicArn")?;
184        let protocol = props
185            .get("Protocol")
186            .and_then(|v| v.as_str())
187            .ok_or("SNS Subscription requires Protocol")?;
188        let endpoint = props
189            .get("Endpoint")
190            .and_then(|v| v.as_str())
191            .ok_or("SNS Subscription requires Endpoint")?;
192
193        let mut state = self.sns_state.write();
194
195        // Validate that the topic exists
196        if !state.topics.contains_key(topic_arn) {
197            return Err(format!("Topic ARN does not exist: {topic_arn}"));
198        }
199
200        let sub_arn = format!("{}:{}", topic_arn, Uuid::new_v4());
201
202        let subscription = SnsSubscription {
203            subscription_arn: sub_arn.clone(),
204            topic_arn: topic_arn.to_string(),
205            protocol: protocol.to_string(),
206            endpoint: endpoint.to_string(),
207            owner: state.account_id.clone(),
208            attributes: HashMap::new(),
209            confirmed: true,
210        };
211
212        state.subscriptions.insert(sub_arn.clone(), subscription);
213        Ok(sub_arn)
214    }
215
216    fn delete_sns_subscription(&self, physical_id: &str) -> Result<(), String> {
217        let mut state = self.sns_state.write();
218        state.subscriptions.remove(physical_id);
219        Ok(())
220    }
221
222    // --- SSM ---
223
224    fn create_ssm_parameter(&self, resource: &ResourceDefinition) -> Result<String, String> {
225        let props = &resource.properties;
226        let name = props
227            .get("Name")
228            .and_then(|v| v.as_str())
229            .ok_or("SSM Parameter requires Name")?;
230        let value = props
231            .get("Value")
232            .and_then(|v| v.as_str())
233            .ok_or("SSM Parameter requires Value")?;
234        let param_type = props
235            .get("Type")
236            .and_then(|v| v.as_str())
237            .unwrap_or("String");
238
239        let mut state = self.ssm_state.write();
240        let arn = format!(
241            "arn:aws:ssm:{}:{}:parameter{}",
242            state.region,
243            state.account_id,
244            if name.starts_with('/') {
245                name.to_string()
246            } else {
247                format!("/{name}")
248            }
249        );
250
251        let parameter = SsmParameter {
252            name: name.to_string(),
253            value: value.to_string(),
254            param_type: param_type.to_string(),
255            version: 1,
256            arn: arn.clone(),
257            last_modified: Utc::now(),
258            history: Vec::new(),
259            tags: HashMap::new(),
260            labels: HashMap::new(),
261            description: props
262                .get("Description")
263                .and_then(|v| v.as_str())
264                .map(|s| s.to_string()),
265            allowed_pattern: None,
266            key_id: None,
267            data_type: "text".to_string(),
268            tier: "Standard".to_string(),
269            policies: None,
270        };
271
272        state.parameters.insert(name.to_string(), parameter);
273        Ok(name.to_string())
274    }
275
276    fn delete_ssm_parameter(&self, physical_id: &str) -> Result<(), String> {
277        let mut state = self.ssm_state.write();
278        state.parameters.remove(physical_id);
279        Ok(())
280    }
281
282    // --- IAM Role ---
283
284    fn create_iam_role(&self, resource: &ResourceDefinition) -> Result<String, String> {
285        let props = &resource.properties;
286        let role_name = props
287            .get("RoleName")
288            .and_then(|v| v.as_str())
289            .unwrap_or(&resource.logical_id);
290
291        let assume_role_policy = props
292            .get("AssumeRolePolicyDocument")
293            .map(|v| {
294                if v.is_string() {
295                    v.as_str().unwrap().to_string()
296                } else {
297                    serde_json::to_string(v).unwrap_or_default()
298                }
299            })
300            .unwrap_or_default();
301
302        let path = props.get("Path").and_then(|v| v.as_str()).unwrap_or("/");
303
304        let mut state = self.iam_state.write();
305        let role_id = format!(
306            "FKIA{}",
307            &Uuid::new_v4().to_string().replace('-', "").to_uppercase()[..16]
308        );
309        let arn = format!(
310            "arn:aws:iam::{}:role{}{}",
311            state.account_id,
312            if path == "/" { "/" } else { path },
313            role_name
314        );
315
316        let role = IamRole {
317            role_name: role_name.to_string(),
318            role_id,
319            arn: arn.clone(),
320            path: path.to_string(),
321            assume_role_policy_document: assume_role_policy,
322            created_at: Utc::now(),
323            description: props
324                .get("Description")
325                .and_then(|v| v.as_str())
326                .map(|s| s.to_string()),
327            max_session_duration: 3600,
328            tags: Vec::new(),
329            permissions_boundary: None,
330        };
331
332        state.roles.insert(role_name.to_string(), role);
333        Ok(arn)
334    }
335
336    fn delete_iam_role(&self, physical_id: &str) -> Result<(), String> {
337        let mut state = self.iam_state.write();
338        // physical_id is the ARN; find the role name
339        let role_name = state
340            .roles
341            .iter()
342            .find(|(_, r)| r.arn == physical_id)
343            .map(|(name, _)| name.clone());
344        if let Some(name) = role_name {
345            state.roles.remove(&name);
346            state.role_policies.remove(&name);
347            state.role_inline_policies.remove(&name);
348        }
349        Ok(())
350    }
351
352    // --- IAM Policy ---
353
354    fn create_iam_policy(&self, resource: &ResourceDefinition) -> Result<String, String> {
355        let props = &resource.properties;
356        let policy_name = props
357            .get("PolicyName")
358            .and_then(|v| v.as_str())
359            .unwrap_or(&resource.logical_id);
360
361        let policy_document = props
362            .get("PolicyDocument")
363            .map(|v| {
364                if v.is_string() {
365                    v.as_str().unwrap().to_string()
366                } else {
367                    serde_json::to_string(v).unwrap_or_default()
368                }
369            })
370            .unwrap_or_default();
371
372        let path = props.get("Path").and_then(|v| v.as_str()).unwrap_or("/");
373
374        let mut state = self.iam_state.write();
375        let policy_id = format!(
376            "FSIA{}",
377            &Uuid::new_v4().to_string().replace('-', "").to_uppercase()[..16]
378        );
379        let arn = format!(
380            "arn:aws:iam::{}:policy{}{}",
381            state.account_id,
382            if path == "/" { "/" } else { path },
383            policy_name
384        );
385
386        let now = Utc::now();
387        let policy = IamPolicy {
388            policy_name: policy_name.to_string(),
389            policy_id,
390            arn: arn.clone(),
391            path: path.to_string(),
392            description: props
393                .get("Description")
394                .and_then(|v| v.as_str())
395                .unwrap_or("")
396                .to_string(),
397            created_at: now,
398            tags: Vec::new(),
399            default_version_id: "v1".to_string(),
400            versions: vec![PolicyVersion {
401                version_id: "v1".to_string(),
402                document: policy_document,
403                is_default: true,
404                created_at: now,
405            }],
406            next_version_num: 2,
407            attachment_count: 0,
408        };
409
410        state.policies.insert(arn.clone(), policy);
411        Ok(arn)
412    }
413
414    fn delete_iam_policy(&self, physical_id: &str) -> Result<(), String> {
415        let mut state = self.iam_state.write();
416        state.policies.remove(physical_id);
417        Ok(())
418    }
419
420    // --- S3 ---
421
422    fn create_s3_bucket(&self, resource: &ResourceDefinition) -> Result<String, String> {
423        let props = &resource.properties;
424        let bucket_name = props
425            .get("BucketName")
426            .and_then(|v| v.as_str())
427            .unwrap_or(&resource.logical_id);
428
429        let mut state = self.s3_state.write();
430        let bucket = S3Bucket::new(bucket_name, &state.region, &state.account_id);
431        state.buckets.insert(bucket_name.to_string(), bucket);
432        Ok(bucket_name.to_string())
433    }
434
435    fn delete_s3_bucket(&self, physical_id: &str) -> Result<(), String> {
436        let mut state = self.s3_state.write();
437        state.buckets.remove(physical_id);
438        Ok(())
439    }
440
441    // --- EventBridge ---
442
443    fn create_eventbridge_rule(&self, resource: &ResourceDefinition) -> Result<String, String> {
444        let props = &resource.properties;
445        let rule_name = props
446            .get("Name")
447            .and_then(|v| v.as_str())
448            .unwrap_or(&resource.logical_id);
449        let event_bus_name = props
450            .get("EventBusName")
451            .and_then(|v| v.as_str())
452            .unwrap_or("default");
453
454        let mut state = self.eventbridge_state.write();
455
456        // Validate that the event bus exists
457        if !state.buses.contains_key(event_bus_name) {
458            return Err(format!("Event bus does not exist: {event_bus_name}"));
459        }
460
461        let arn = if event_bus_name == "default" {
462            format!(
463                "arn:aws:events:{}:{}:rule/{}",
464                state.region, state.account_id, rule_name
465            )
466        } else {
467            format!(
468                "arn:aws:events:{}:{}:rule/{}/{}",
469                state.region, state.account_id, event_bus_name, rule_name
470            )
471        };
472
473        let rule = EventRule {
474            name: rule_name.to_string(),
475            arn: arn.clone(),
476            event_bus_name: event_bus_name.to_string(),
477            event_pattern: props.get("EventPattern").map(|v| {
478                if v.is_string() {
479                    v.as_str().unwrap().to_string()
480                } else {
481                    serde_json::to_string(v).unwrap_or_default()
482                }
483            }),
484            schedule_expression: props
485                .get("ScheduleExpression")
486                .and_then(|v| v.as_str())
487                .map(|s| s.to_string()),
488            state: props
489                .get("State")
490                .and_then(|v| v.as_str())
491                .unwrap_or("ENABLED")
492                .to_string(),
493            description: props
494                .get("Description")
495                .and_then(|v| v.as_str())
496                .map(|s| s.to_string()),
497            role_arn: props
498                .get("RoleArn")
499                .and_then(|v| v.as_str())
500                .map(|s| s.to_string()),
501            managed_by: None,
502            created_by: None,
503            targets: Vec::new(),
504            tags: HashMap::new(),
505            last_fired: None,
506        };
507
508        state
509            .rules
510            .insert((event_bus_name.to_string(), rule_name.to_string()), rule);
511        Ok(arn)
512    }
513
514    fn delete_eventbridge_rule(&self, physical_id: &str) -> Result<(), String> {
515        let mut state = self.eventbridge_state.write();
516        // physical_id is the ARN; find the rule key
517        let key = state
518            .rules
519            .iter()
520            .find(|(_, r)| r.arn == physical_id)
521            .map(|(k, _)| k.clone());
522        if let Some(k) = key {
523            state.rules.remove(&k);
524        }
525        Ok(())
526    }
527
528    // --- DynamoDB ---
529
530    fn create_dynamodb_table(&self, resource: &ResourceDefinition) -> Result<String, String> {
531        let props = &resource.properties;
532        let table_name = props
533            .get("TableName")
534            .and_then(|v| v.as_str())
535            .unwrap_or(&resource.logical_id);
536
537        let mut key_schema = Vec::new();
538        if let Some(ks) = props.get("KeySchema").and_then(|v| v.as_array()) {
539            for item in ks {
540                let attr_name = item
541                    .get("AttributeName")
542                    .and_then(|v| v.as_str())
543                    .unwrap_or("")
544                    .to_string();
545                let key_type = item
546                    .get("KeyType")
547                    .and_then(|v| v.as_str())
548                    .unwrap_or("HASH")
549                    .to_string();
550                key_schema.push(KeySchemaElement {
551                    attribute_name: attr_name,
552                    key_type,
553                });
554            }
555        }
556
557        let mut attribute_definitions = Vec::new();
558        if let Some(defs) = props.get("AttributeDefinitions").and_then(|v| v.as_array()) {
559            for item in defs {
560                let attr_name = item
561                    .get("AttributeName")
562                    .and_then(|v| v.as_str())
563                    .unwrap_or("")
564                    .to_string();
565                let attr_type = item
566                    .get("AttributeType")
567                    .and_then(|v| v.as_str())
568                    .unwrap_or("S")
569                    .to_string();
570                attribute_definitions.push(AttributeDefinition {
571                    attribute_name: attr_name,
572                    attribute_type: attr_type,
573                });
574            }
575        }
576
577        let billing_mode = props
578            .get("BillingMode")
579            .and_then(|v| v.as_str())
580            .unwrap_or("PAY_PER_REQUEST")
581            .to_string();
582
583        let provisioned_throughput = if billing_mode == "PROVISIONED" {
584            if let Some(pt) = props.get("ProvisionedThroughput") {
585                ProvisionedThroughput {
586                    read_capacity_units: pt
587                        .get("ReadCapacityUnits")
588                        .and_then(|v| v.as_i64())
589                        .unwrap_or(5),
590                    write_capacity_units: pt
591                        .get("WriteCapacityUnits")
592                        .and_then(|v| v.as_i64())
593                        .unwrap_or(5),
594                }
595            } else {
596                ProvisionedThroughput {
597                    read_capacity_units: 5,
598                    write_capacity_units: 5,
599                }
600            }
601        } else {
602            ProvisionedThroughput {
603                read_capacity_units: 0,
604                write_capacity_units: 0,
605            }
606        };
607
608        let mut state = self.dynamodb_state.write();
609        let arn = format!(
610            "arn:aws:dynamodb:{}:{}:table/{}",
611            state.region, state.account_id, table_name
612        );
613
614        let table = DynamoTable {
615            name: table_name.to_string(),
616            arn: arn.clone(),
617            key_schema,
618            attribute_definitions,
619            provisioned_throughput,
620            items: Vec::new(),
621            gsi: Vec::new(),
622            lsi: Vec::new(),
623            tags: HashMap::new(),
624            created_at: Utc::now(),
625            status: "ACTIVE".to_string(),
626            item_count: 0,
627            size_bytes: 0,
628            billing_mode,
629            ttl_attribute: None,
630            ttl_enabled: false,
631            resource_policy: None,
632            pitr_enabled: false,
633            kinesis_destinations: Vec::new(),
634            contributor_insights_status: "DISABLED".to_string(),
635        };
636
637        state.tables.insert(table_name.to_string(), table);
638        Ok(arn)
639    }
640
641    fn delete_dynamodb_table(&self, physical_id: &str) -> Result<(), String> {
642        let mut state = self.dynamodb_state.write();
643        // physical_id is the ARN; find the table name
644        let table_name = state
645            .tables
646            .iter()
647            .find(|(_, t)| t.arn == physical_id)
648            .map(|(name, _)| name.clone());
649        if let Some(name) = table_name {
650            state.tables.remove(&name);
651        }
652        Ok(())
653    }
654
655    // --- CloudWatch Logs ---
656
657    fn create_log_group(&self, resource: &ResourceDefinition) -> Result<String, String> {
658        let props = &resource.properties;
659        let log_group_name = props
660            .get("LogGroupName")
661            .and_then(|v| v.as_str())
662            .unwrap_or(&resource.logical_id);
663
664        let retention_in_days = props
665            .get("RetentionInDays")
666            .and_then(|v| v.as_i64())
667            .map(|v| v as i32);
668
669        let mut state = self.logs_state.write();
670        let arn = format!(
671            "arn:aws:logs:{}:{}:log-group:{}:*",
672            state.region, state.account_id, log_group_name
673        );
674
675        let log_group = fakecloud_logs::state::LogGroup {
676            name: log_group_name.to_string(),
677            arn: arn.clone(),
678            creation_time: Utc::now().timestamp_millis(),
679            retention_in_days,
680            kms_key_id: None,
681            stored_bytes: 0,
682            log_streams: HashMap::new(),
683            tags: HashMap::new(),
684            subscription_filters: Vec::new(),
685            data_protection_policy: None,
686            index_policies: Vec::new(),
687            transformer: None,
688            deletion_protection: false,
689        };
690
691        state
692            .log_groups
693            .insert(log_group_name.to_string(), log_group);
694        Ok(arn)
695    }
696
697    fn delete_log_group(&self, physical_id: &str) -> Result<(), String> {
698        let mut state = self.logs_state.write();
699        // physical_id is the ARN; find the log group name
700        let name = state
701            .log_groups
702            .iter()
703            .find(|(_, g)| g.arn == physical_id)
704            .map(|(name, _)| name.clone());
705        if let Some(name) = name {
706            state.log_groups.remove(&name);
707        }
708        Ok(())
709    }
710}
711
712#[cfg(test)]
713mod tests {
714    use super::*;
715    use parking_lot::RwLock;
716    use std::sync::Arc;
717
718    fn make_provisioner() -> ResourceProvisioner {
719        ResourceProvisioner {
720            sqs_state: Arc::new(RwLock::new(fakecloud_sqs::state::SqsState::new(
721                "123456789012",
722                "us-east-1",
723                "http://localhost:4566",
724            ))),
725            sns_state: Arc::new(RwLock::new(fakecloud_sns::state::SnsState::new(
726                "123456789012",
727                "us-east-1",
728            ))),
729            ssm_state: Arc::new(RwLock::new(fakecloud_ssm::state::SsmState::new(
730                "123456789012",
731                "us-east-1",
732            ))),
733            iam_state: Arc::new(RwLock::new(fakecloud_iam::state::IamState::new(
734                "123456789012",
735            ))),
736            s3_state: Arc::new(RwLock::new(fakecloud_s3::state::S3State::new(
737                "123456789012",
738                "us-east-1",
739            ))),
740            eventbridge_state: Arc::new(RwLock::new(
741                fakecloud_eventbridge::state::EventBridgeState::new("123456789012", "us-east-1"),
742            )),
743            dynamodb_state: Arc::new(RwLock::new(fakecloud_dynamodb::state::DynamoDbState::new(
744                "123456789012",
745                "us-east-1",
746            ))),
747            logs_state: Arc::new(RwLock::new(fakecloud_logs::state::LogsState::new(
748                "123456789012",
749                "us-east-1",
750            ))),
751            account_id: "123456789012".to_string(),
752            region: "us-east-1".to_string(),
753        }
754    }
755
756    fn make_resource(
757        resource_type: &str,
758        logical_id: &str,
759        props: serde_json::Value,
760    ) -> ResourceDefinition {
761        ResourceDefinition {
762            logical_id: logical_id.to_string(),
763            resource_type: resource_type.to_string(),
764            properties: props,
765        }
766    }
767
768    #[test]
769    fn sns_subscription_rejects_nonexistent_topic() {
770        let prov = make_provisioner();
771        let resource = make_resource(
772            "AWS::SNS::Subscription",
773            "MySub",
774            serde_json::json!({
775                "TopicArn": "arn:aws:sns:us-east-1:123456789012:NonExistent",
776                "Protocol": "sqs",
777                "Endpoint": "arn:aws:sqs:us-east-1:123456789012:my-queue"
778            }),
779        );
780        let result = prov.create_resource(&resource);
781        assert!(result.is_err());
782        assert!(result.unwrap_err().contains("does not exist"));
783    }
784
785    #[test]
786    fn sns_subscription_succeeds_when_topic_exists() {
787        let prov = make_provisioner();
788        // First create the topic
789        let topic = make_resource(
790            "AWS::SNS::Topic",
791            "MyTopic",
792            serde_json::json!({ "TopicName": "my-topic" }),
793        );
794        let topic_result = prov.create_resource(&topic);
795        assert!(topic_result.is_ok());
796        let topic_arn = topic_result.unwrap().physical_id;
797
798        // Now create subscription referencing that topic
799        let sub = make_resource(
800            "AWS::SNS::Subscription",
801            "MySub",
802            serde_json::json!({
803                "TopicArn": topic_arn,
804                "Protocol": "sqs",
805                "Endpoint": "arn:aws:sqs:us-east-1:123456789012:my-queue"
806            }),
807        );
808        let result = prov.create_resource(&sub);
809        assert!(result.is_ok());
810    }
811
812    #[test]
813    fn eventbridge_rule_arn_default_bus_omits_bus_name() {
814        let prov = make_provisioner();
815        let resource = make_resource(
816            "AWS::Events::Rule",
817            "MyRule",
818            serde_json::json!({
819                "Name": "my-rule",
820                "ScheduleExpression": "rate(1 hour)"
821            }),
822        );
823        let result = prov.create_resource(&resource).unwrap();
824        // For default bus, ARN should be rule/<name> without /default/
825        assert_eq!(
826            result.physical_id,
827            "arn:aws:events:us-east-1:123456789012:rule/my-rule"
828        );
829        assert!(!result.physical_id.contains("rule/default/"));
830    }
831
832    #[test]
833    fn eventbridge_rule_arn_custom_bus_includes_bus_name() {
834        let prov = make_provisioner();
835        // Create a custom bus first
836        {
837            let mut state = prov.eventbridge_state.write();
838            state.buses.insert(
839                "custom-bus".to_string(),
840                fakecloud_eventbridge::state::EventBus {
841                    name: "custom-bus".to_string(),
842                    arn: "arn:aws:events:us-east-1:123456789012:event-bus/custom-bus".to_string(),
843                    policy: None,
844                    creation_time: Utc::now(),
845                    last_modified_time: Utc::now(),
846                    description: None,
847                    kms_key_identifier: None,
848                    dead_letter_config: None,
849                    tags: HashMap::new(),
850                },
851            );
852        }
853        let resource = make_resource(
854            "AWS::Events::Rule",
855            "MyRule",
856            serde_json::json!({
857                "Name": "my-rule",
858                "EventBusName": "custom-bus",
859                "ScheduleExpression": "rate(1 hour)"
860            }),
861        );
862        let result = prov.create_resource(&resource).unwrap();
863        assert_eq!(
864            result.physical_id,
865            "arn:aws:events:us-east-1:123456789012:rule/custom-bus/my-rule"
866        );
867    }
868
869    #[test]
870    fn eventbridge_rule_rejects_nonexistent_bus() {
871        let prov = make_provisioner();
872        let resource = make_resource(
873            "AWS::Events::Rule",
874            "MyRule",
875            serde_json::json!({
876                "Name": "my-rule",
877                "EventBusName": "nonexistent-bus",
878                "ScheduleExpression": "rate(1 hour)"
879            }),
880        );
881        let result = prov.create_resource(&resource);
882        assert!(result.is_err());
883        assert!(result.unwrap_err().contains("does not exist"));
884    }
885}