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
19pub 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 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 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 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 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 state
171 .subscriptions
172 .retain(|_, sub| sub.topic_arn != physical_id);
173 Ok(())
174 }
175
176 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 {
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}