1use std::sync::Arc;
14
15use async_trait::async_trait;
16use http::StatusCode;
17use serde_json::{json, Map, Value};
18use tokio::sync::Mutex as AsyncMutex;
19
20use fakecloud_core::service::{AwsRequest, AwsResponse, AwsService, AwsServiceError};
21use fakecloud_persistence::SnapshotStore;
22
23use crate::persistence::save_snapshot;
24use crate::state::{
25 skey, ConnectionRecord, HostRecord, RepositoryLinkRecord, SharedCodeConnectionsState,
26 SyncConfigurationRecord, TagMap,
27};
28
29pub const CODECONNECTIONS_ACTIONS: &[&str] = &[
31 "CreateConnection",
32 "CreateHost",
33 "CreateRepositoryLink",
34 "CreateSyncConfiguration",
35 "DeleteConnection",
36 "DeleteHost",
37 "DeleteRepositoryLink",
38 "DeleteSyncConfiguration",
39 "GetConnection",
40 "GetHost",
41 "GetRepositoryLink",
42 "GetRepositorySyncStatus",
43 "GetResourceSyncStatus",
44 "GetSyncBlockerSummary",
45 "GetSyncConfiguration",
46 "ListConnections",
47 "ListHosts",
48 "ListRepositoryLinks",
49 "ListRepositorySyncDefinitions",
50 "ListSyncConfigurations",
51 "ListTagsForResource",
52 "TagResource",
53 "UntagResource",
54 "UpdateHost",
55 "UpdateRepositoryLink",
56 "UpdateSyncBlocker",
57 "UpdateSyncConfiguration",
58];
59
60const PROVIDER_TYPES: &[&str] = &[
62 "Bitbucket",
63 "GitHub",
64 "GitHubEnterpriseServer",
65 "GitLab",
66 "GitLabSelfManaged",
67 "AzureDevOps",
68];
69
70const SYNC_TYPES: &[&str] = &["CFN_STACK_SYNC"];
72
73pub struct CodeConnectionsService {
74 state: SharedCodeConnectionsState,
75 snapshot_store: Option<Arc<dyn SnapshotStore>>,
76 snapshot_lock: Arc<AsyncMutex<()>>,
77}
78
79impl CodeConnectionsService {
80 pub fn new(state: SharedCodeConnectionsState) -> Self {
81 Self {
82 state,
83 snapshot_store: None,
84 snapshot_lock: Arc::new(AsyncMutex::new(())),
85 }
86 }
87
88 pub fn with_snapshot_store(mut self, store: Arc<dyn SnapshotStore>) -> Self {
89 self.snapshot_store = Some(store);
90 self
91 }
92
93 async fn save(&self) {
94 save_snapshot(
95 &self.state,
96 self.snapshot_store.clone(),
97 &self.snapshot_lock,
98 )
99 .await;
100 }
101
102 pub fn snapshot_hook(&self) -> Option<fakecloud_persistence::SnapshotHook> {
104 let store = self.snapshot_store.clone()?;
105 let state = self.state.clone();
106 let lock = self.snapshot_lock.clone();
107 Some(Arc::new(move || {
108 let state = state.clone();
109 let store = store.clone();
110 let lock = lock.clone();
111 Box::pin(async move {
112 save_snapshot(&state, Some(store), &lock).await;
113 })
114 }))
115 }
116}
117
118#[async_trait]
119impl AwsService for CodeConnectionsService {
120 fn service_name(&self) -> &str {
121 "codeconnections"
122 }
123
124 async fn handle(&self, req: AwsRequest) -> Result<AwsResponse, AwsServiceError> {
125 let mutates = is_mutating(req.action.as_str());
126 let result = dispatch(self, &req);
127 if mutates && matches!(result.as_ref(), Ok(resp) if resp.status.is_success()) {
128 self.save().await;
129 }
130 result
131 }
132
133 fn supported_actions(&self) -> &[&str] {
134 CODECONNECTIONS_ACTIONS
135 }
136}
137
138fn is_mutating(action: &str) -> bool {
139 action.starts_with("Create")
140 || action.starts_with("Delete")
141 || action.starts_with("Update")
142 || action == "TagResource"
143 || action == "UntagResource"
144}
145
146fn dispatch(s: &CodeConnectionsService, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
147 let body = parse(req)?;
148 match req.action.as_str() {
149 "CreateConnection" => s.create_connection(req, &body),
150 "CreateHost" => s.create_host(req, &body),
151 "CreateRepositoryLink" => s.create_repository_link(req, &body),
152 "CreateSyncConfiguration" => s.create_sync_configuration(req, &body),
153 "DeleteConnection" => s.delete_connection(req, &body),
154 "DeleteHost" => s.delete_host(req, &body),
155 "DeleteRepositoryLink" => s.delete_repository_link(req, &body),
156 "DeleteSyncConfiguration" => s.delete_sync_configuration(req, &body),
157 "GetConnection" => s.get_connection(req, &body),
158 "GetHost" => s.get_host(req, &body),
159 "GetRepositoryLink" => s.get_repository_link(req, &body),
160 "GetRepositorySyncStatus" => s.get_repository_sync_status(req, &body),
161 "GetResourceSyncStatus" => s.get_resource_sync_status(req, &body),
162 "GetSyncBlockerSummary" => s.get_sync_blocker_summary(req, &body),
163 "GetSyncConfiguration" => s.get_sync_configuration(req, &body),
164 "ListConnections" => s.list_connections(req, &body),
165 "ListHosts" => s.list_hosts(req, &body),
166 "ListRepositoryLinks" => s.list_repository_links(req, &body),
167 "ListRepositorySyncDefinitions" => s.list_repository_sync_definitions(req, &body),
168 "ListSyncConfigurations" => s.list_sync_configurations(req, &body),
169 "ListTagsForResource" => s.list_tags_for_resource(req, &body),
170 "TagResource" => s.tag_resource(req, &body),
171 "UntagResource" => s.untag_resource(req, &body),
172 "UpdateHost" => s.update_host(req, &body),
173 "UpdateRepositoryLink" => s.update_repository_link(req, &body),
174 "UpdateSyncBlocker" => s.update_sync_blocker(req, &body),
175 "UpdateSyncConfiguration" => s.update_sync_configuration(req, &body),
176 _ => Err(AwsServiceError::action_not_implemented(
177 s.service_name(),
178 &req.action,
179 )),
180 }
181}
182
183fn ok(v: Value) -> Result<AwsResponse, AwsServiceError> {
188 Ok(AwsResponse::json_value(StatusCode::OK, v))
189}
190
191fn parse(req: &AwsRequest) -> Result<Value, AwsServiceError> {
192 if req.body.is_empty() {
193 return Ok(json!({}));
194 }
195 serde_json::from_slice(&req.body)
196 .map_err(|e| validation(format!("Request body is malformed: {e}")))
197}
198
199fn validation(msg: impl Into<String>) -> AwsServiceError {
200 AwsServiceError::aws_error(StatusCode::BAD_REQUEST, "ValidationException", msg)
201}
202fn invalid_input(msg: impl Into<String>) -> AwsServiceError {
203 AwsServiceError::aws_error(StatusCode::BAD_REQUEST, "InvalidInputException", msg)
204}
205fn not_found(msg: impl Into<String>) -> AwsServiceError {
206 AwsServiceError::aws_error(StatusCode::NOT_FOUND, "ResourceNotFoundException", msg)
207}
208fn already_exists(msg: impl Into<String>) -> AwsServiceError {
209 AwsServiceError::aws_error(
210 StatusCode::BAD_REQUEST,
211 "ResourceAlreadyExistsException",
212 msg,
213 )
214}
215
216fn req_str<'a>(b: &'a Value, f: &str) -> Result<&'a str, AwsServiceError> {
217 b.get(f)
218 .and_then(Value::as_str)
219 .filter(|s| !s.is_empty())
220 .ok_or_else(|| validation(format!("{f} is a required argument.")))
221}
222
223fn id_field(b: &Value, f: &str) -> Result<String, AwsServiceError> {
229 b.get(f)
230 .and_then(Value::as_str)
231 .map(str::to_string)
232 .ok_or_else(|| validation(format!("{f} is a required argument.")))
233}
234
235fn check_len(b: &Value, field: &str, min: usize, max: usize) -> Result<(), AwsServiceError> {
238 if let Some(s) = b.get(field).and_then(Value::as_str) {
239 let n = s.chars().count();
240 if n < min || n > max {
241 return Err(validation(format!(
242 "{field} must have length between {min} and {max}, inclusive."
243 )));
244 }
245 }
246 Ok(())
247}
248
249fn opt_str(b: &Value, f: &str) -> Option<String> {
250 b.get(f)
251 .and_then(Value::as_str)
252 .filter(|s| !s.is_empty())
253 .map(str::to_string)
254}
255
256fn check_enum(b: &Value, field: &str, allowed: &[&str]) -> Result<(), AwsServiceError> {
257 if let Some(s) = b.get(field).and_then(Value::as_str) {
258 if !allowed.contains(&s) {
259 return Err(validation(format!(
260 "{field} must be one of [{}].",
261 allowed.join(", ")
262 )));
263 }
264 }
265 Ok(())
266}
267
268fn gen_uuid() -> String {
269 uuid::Uuid::new_v4().to_string()
270}
271
272fn connection_arn(region: &str, account: &str, id: &str) -> String {
273 format!("arn:aws:codeconnections:{region}:{account}:connection/{id}")
274}
275fn host_arn(region: &str, account: &str, id: &str) -> String {
276 format!("arn:aws:codeconnections:{region}:{account}:host/{id}")
277}
278fn repository_link_arn(region: &str, account: &str, id: &str) -> String {
279 format!("arn:aws:codeconnections:{region}:{account}:repository-link/{id}")
280}
281
282fn parse_tags(b: &Value) -> TagMap {
284 let mut out = TagMap::new();
285 if let Some(arr) = b.get("Tags").and_then(Value::as_array) {
286 for t in arr {
287 if let (Some(k), Some(v)) = (
288 t.get("Key").and_then(Value::as_str),
289 t.get("Value").and_then(Value::as_str),
290 ) {
291 out.insert(k.to_string(), v.to_string());
292 }
293 }
294 }
295 out
296}
297
298fn tags_value(t: &TagMap) -> Value {
299 Value::Array(
300 t.iter()
301 .map(|(k, v)| json!({ "Key": k, "Value": v }))
302 .collect(),
303 )
304}
305
306fn connection_value(r: &ConnectionRecord) -> Value {
311 let mut m = Map::new();
312 m.insert("ConnectionName".into(), json!(r.connection_name));
313 m.insert("ConnectionArn".into(), json!(r.connection_arn));
314 if let Some(pt) = &r.provider_type {
315 m.insert("ProviderType".into(), json!(pt));
316 }
317 m.insert("OwnerAccountId".into(), json!(r.owner_account_id));
318 m.insert("ConnectionStatus".into(), json!(r.connection_status));
319 if let Some(h) = &r.host_arn {
320 m.insert("HostArn".into(), json!(h));
321 }
322 Value::Object(m)
323}
324
325fn repository_link_info(r: &RepositoryLinkRecord) -> Value {
326 let mut m = Map::new();
327 m.insert("ConnectionArn".into(), json!(r.connection_arn));
328 if let Some(k) = &r.encryption_key_arn {
329 m.insert("EncryptionKeyArn".into(), json!(k));
330 }
331 m.insert("OwnerId".into(), json!(r.owner_id));
332 m.insert("ProviderType".into(), json!(r.provider_type));
333 m.insert("RepositoryLinkArn".into(), json!(r.repository_link_arn));
334 m.insert("RepositoryLinkId".into(), json!(r.repository_link_id));
335 m.insert("RepositoryName".into(), json!(r.repository_name));
336 Value::Object(m)
337}
338
339fn sync_configuration_value(r: &SyncConfigurationRecord) -> Value {
340 let mut m = Map::new();
341 m.insert("Branch".into(), json!(r.branch));
342 if let Some(c) = &r.config_file {
343 m.insert("ConfigFile".into(), json!(c));
344 }
345 m.insert("OwnerId".into(), json!(r.owner_id));
346 m.insert("ProviderType".into(), json!(r.provider_type));
347 m.insert("RepositoryLinkId".into(), json!(r.repository_link_id));
348 m.insert("RepositoryName".into(), json!(r.repository_name));
349 m.insert("ResourceName".into(), json!(r.resource_name));
350 m.insert("RoleArn".into(), json!(r.role_arn));
351 m.insert("SyncType".into(), json!(r.sync_type));
352 if let Some(v) = &r.publish_deployment_status {
353 m.insert("PublishDeploymentStatus".into(), json!(v));
354 }
355 if let Some(v) = &r.trigger_resource_update_on {
356 m.insert("TriggerResourceUpdateOn".into(), json!(v));
357 }
358 if let Some(v) = &r.pull_request_comment {
359 m.insert("PullRequestComment".into(), json!(v));
360 }
361 Value::Object(m)
362}
363
364fn page(
374 items: Vec<Value>,
375 list_key: &str,
376 body: &Value,
377 token_max: usize,
378) -> Result<AwsResponse, AwsServiceError> {
379 check_len(body, "NextToken", 1, token_max)?;
380 let max = match body.get("MaxResults") {
388 None | Some(Value::Null) => 100,
389 Some(v) => {
390 let n = v.as_i64().ok_or_else(|| {
391 validation("MaxResults must be an integer between 0 and 100, inclusive.")
392 })?;
393 if !(0..=100).contains(&n) {
394 return Err(validation(
395 "MaxResults must be between 0 and 100, inclusive.",
396 ));
397 }
398 n as usize
399 }
400 };
401 let start = body
402 .get("NextToken")
403 .and_then(Value::as_str)
404 .and_then(|t| t.parse::<usize>().ok())
405 .unwrap_or(0)
406 .min(items.len());
407 let end = start.saturating_add(max).min(items.len());
408 let mut out = Map::new();
409 out.insert(
410 list_key.to_string(),
411 Value::Array(items[start..end].to_vec()),
412 );
413 if end < items.len() {
414 out.insert("NextToken".to_string(), Value::String(end.to_string()));
415 }
416 ok(Value::Object(out))
417}
418
419impl CodeConnectionsService {
424 fn create_connection(
425 &self,
426 req: &AwsRequest,
427 body: &Value,
428 ) -> Result<AwsResponse, AwsServiceError> {
429 let name = req_str(body, "ConnectionName")?.to_string();
430 check_len(body, "ConnectionName", 1, 32)?;
431 check_len(body, "HostArn", 0, 256)?;
432 check_enum(body, "ProviderType", PROVIDER_TYPES)?;
433 let host_arn = opt_str(body, "HostArn");
434 let tags = parse_tags(body);
435 let id = gen_uuid();
436 let arn = connection_arn(&req.region, &req.account_id, &id);
437
438 let mut provider_type = opt_str(body, "ProviderType");
443 if let Some(h) = &host_arn {
444 let host = self
445 .state
446 .read()
447 .get(&req.account_id)
448 .and_then(|st| st.hosts.get(h).cloned())
449 .ok_or_else(|| not_found(format!("Host {h} does not exist.")))?;
450 if provider_type.is_none() {
451 provider_type = Some(host.provider_type);
452 }
453 }
454
455 if provider_type.is_none() {
458 return Err(invalid_input(
459 "Either ProviderType or HostArn must be specified.",
460 ));
461 }
462
463 let record = ConnectionRecord {
464 connection_arn: arn.clone(),
465 connection_name: name,
466 provider_type,
467 owner_account_id: req.account_id.clone(),
468 connection_status: "PENDING".into(),
469 host_arn,
470 tags: tags.clone(),
471 };
472 self.state
473 .write()
474 .get_or_create(&req.account_id)
475 .connections
476 .insert(arn.clone(), record);
477 ok(json!({ "ConnectionArn": arn, "Tags": tags_value(&tags) }))
478 }
479
480 fn create_host(&self, req: &AwsRequest, body: &Value) -> Result<AwsResponse, AwsServiceError> {
481 let name = req_str(body, "Name")?.to_string();
482 check_len(body, "Name", 1, 64)?;
483 let provider_type = req_str(body, "ProviderType")?.to_string();
484 check_enum(body, "ProviderType", PROVIDER_TYPES)?;
485 let provider_endpoint = req_str(body, "ProviderEndpoint")?.to_string();
486 check_len(body, "ProviderEndpoint", 1, 512)?;
487 let vpc_configuration = body.get("VpcConfiguration").cloned();
488 let tags = parse_tags(body);
489 let id = gen_uuid();
490 let arn = host_arn(&req.region, &req.account_id, &id);
491
492 let record = HostRecord {
493 host_arn: arn.clone(),
494 name,
495 provider_type,
496 provider_endpoint,
497 status: "PENDING".into(),
498 vpc_configuration,
499 tags: tags.clone(),
500 };
501 self.state
502 .write()
503 .get_or_create(&req.account_id)
504 .hosts
505 .insert(arn.clone(), record);
506 ok(json!({ "HostArn": arn, "Tags": tags_value(&tags) }))
507 }
508
509 fn create_repository_link(
510 &self,
511 req: &AwsRequest,
512 body: &Value,
513 ) -> Result<AwsResponse, AwsServiceError> {
514 let connection_arn = id_field(body, "ConnectionArn")?;
515 let owner_id = req_str(body, "OwnerId")?.to_string();
516 let repository_name = req_str(body, "RepositoryName")?.to_string();
517 let encryption_key_arn = opt_str(body, "EncryptionKeyArn");
518 let tags = parse_tags(body);
519
520 let mut accounts = self.state.write();
521 let st = accounts.get_or_create(&req.account_id);
522 let connection = st
525 .connections
526 .get(&connection_arn)
527 .cloned()
528 .ok_or_else(|| invalid_input(format!("Connection {connection_arn} does not exist.")))?;
529 let provider_type = connection
530 .provider_type
531 .clone()
532 .or_else(|| {
533 connection
534 .host_arn
535 .as_ref()
536 .and_then(|h| st.hosts.get(h))
537 .map(|h| h.provider_type.clone())
538 })
539 .ok_or_else(|| {
540 invalid_input(format!(
541 "Connection {connection_arn} is not associated with a provider type."
542 ))
543 })?;
544
545 if st.repository_links.values().any(|l| {
547 l.connection_arn == connection_arn
548 && l.owner_id == owner_id
549 && l.repository_name == repository_name
550 }) {
551 return Err(already_exists(format!(
552 "A repository link for {owner_id}/{repository_name} already exists."
553 )));
554 }
555
556 let id = gen_uuid();
557 let arn = repository_link_arn(&req.region, &req.account_id, &id);
558 let record = RepositoryLinkRecord {
559 repository_link_id: id.clone(),
560 repository_link_arn: arn,
561 connection_arn,
562 owner_id,
563 repository_name,
564 provider_type,
565 encryption_key_arn,
566 tags,
567 };
568 let value = repository_link_info(&record);
569 st.repository_links.insert(id, record);
570 ok(json!({ "RepositoryLinkInfo": value }))
571 }
572
573 fn create_sync_configuration(
574 &self,
575 req: &AwsRequest,
576 body: &Value,
577 ) -> Result<AwsResponse, AwsServiceError> {
578 let branch = req_str(body, "Branch")?.to_string();
579 let config_file = req_str(body, "ConfigFile")?.to_string();
580 let repository_link_id = req_str(body, "RepositoryLinkId")?.to_string();
581 let resource_name = req_str(body, "ResourceName")?.to_string();
582 let role_arn = req_str(body, "RoleArn")?.to_string();
583 let sync_type = req_str(body, "SyncType")?.to_string();
584 check_len(body, "ResourceName", 1, 100)?;
589 check_len(body, "Branch", 1, 255)?;
590 check_len(body, "RoleArn", 1, 1024)?;
591 check_enum(body, "SyncType", SYNC_TYPES)?;
592 check_enum(body, "PublishDeploymentStatus", &["ENABLED", "DISABLED"])?;
593 check_enum(
594 body,
595 "TriggerResourceUpdateOn",
596 &["ANY_CHANGE", "FILE_CHANGE"],
597 )?;
598 check_enum(body, "PullRequestComment", &["ENABLED", "DISABLED"])?;
599
600 let mut accounts = self.state.write();
601 let st = accounts.get_or_create(&req.account_id);
602 let link = st
603 .repository_links
604 .get(&repository_link_id)
605 .cloned()
606 .ok_or_else(|| {
607 invalid_input(format!(
608 "Repository link {repository_link_id} does not exist."
609 ))
610 })?;
611
612 let key = skey(&sync_type, &resource_name);
613 if st.sync_configurations.contains_key(&key) {
614 return Err(already_exists(format!(
615 "A sync configuration for {resource_name} already exists."
616 )));
617 }
618
619 let record = SyncConfigurationRecord {
620 branch,
621 config_file: Some(config_file),
622 owner_id: link.owner_id,
623 provider_type: link.provider_type,
624 repository_link_id,
625 repository_name: link.repository_name,
626 resource_name,
627 role_arn,
628 sync_type,
629 publish_deployment_status: opt_str(body, "PublishDeploymentStatus"),
630 trigger_resource_update_on: opt_str(body, "TriggerResourceUpdateOn"),
631 pull_request_comment: opt_str(body, "PullRequestComment"),
632 };
633 let value = sync_configuration_value(&record);
634 st.sync_configurations.insert(key, record);
635 ok(json!({ "SyncConfiguration": value }))
636 }
637
638 fn delete_connection(
639 &self,
640 req: &AwsRequest,
641 body: &Value,
642 ) -> Result<AwsResponse, AwsServiceError> {
643 let arn = id_field(body, "ConnectionArn")?;
644 let mut accounts = self.state.write();
645 let st = accounts.get_or_create(&req.account_id);
646 if st.connections.remove(&arn).is_none() {
647 return Err(not_found(format!("Connection {arn} does not exist.")));
648 }
649 ok(json!({}))
650 }
651
652 fn delete_host(&self, req: &AwsRequest, body: &Value) -> Result<AwsResponse, AwsServiceError> {
653 let arn = id_field(body, "HostArn")?;
654 let mut accounts = self.state.write();
655 let st = accounts.get_or_create(&req.account_id);
656 if st.hosts.remove(&arn).is_none() {
657 return Err(not_found(format!("Host {arn} does not exist.")));
658 }
659 ok(json!({}))
660 }
661
662 fn delete_repository_link(
663 &self,
664 req: &AwsRequest,
665 body: &Value,
666 ) -> Result<AwsResponse, AwsServiceError> {
667 let id = req_str(body, "RepositoryLinkId")?.to_string();
668 let mut accounts = self.state.write();
669 let st = accounts.get_or_create(&req.account_id);
670 if !st.repository_links.contains_key(&id) {
671 return Err(not_found(format!("Repository link {id} does not exist.")));
672 }
673 if st
676 .sync_configurations
677 .values()
678 .any(|c| c.repository_link_id == id)
679 {
680 return Err(AwsServiceError::aws_error(
681 StatusCode::BAD_REQUEST,
682 "SyncConfigurationStillExistsException",
683 format!("Repository link {id} still has sync configurations."),
684 ));
685 }
686 st.repository_links.remove(&id);
687 ok(json!({}))
688 }
689
690 fn delete_sync_configuration(
691 &self,
692 req: &AwsRequest,
693 body: &Value,
694 ) -> Result<AwsResponse, AwsServiceError> {
695 let sync_type = req_str(body, "SyncType")?.to_string();
696 let resource_name = req_str(body, "ResourceName")?.to_string();
697 check_len(body, "ResourceName", 1, 100)?;
698 check_enum(body, "SyncType", SYNC_TYPES)?;
699 self.state
703 .write()
704 .get_or_create(&req.account_id)
705 .sync_configurations
706 .remove(&skey(&sync_type, &resource_name));
707 ok(json!({}))
708 }
709
710 fn get_connection(
711 &self,
712 req: &AwsRequest,
713 body: &Value,
714 ) -> Result<AwsResponse, AwsServiceError> {
715 let arn = id_field(body, "ConnectionArn")?;
716 let accounts = self.state.read();
717 let conn = accounts
718 .get(&req.account_id)
719 .and_then(|st| st.connections.get(&arn))
720 .ok_or_else(|| not_found(format!("Connection {arn} does not exist.")))?;
721 ok(json!({ "Connection": connection_value(conn) }))
722 }
723
724 fn get_host(&self, req: &AwsRequest, body: &Value) -> Result<AwsResponse, AwsServiceError> {
725 let arn = id_field(body, "HostArn")?;
726 let accounts = self.state.read();
727 let host = accounts
728 .get(&req.account_id)
729 .and_then(|st| st.hosts.get(&arn))
730 .ok_or_else(|| not_found(format!("Host {arn} does not exist.")))?;
731 let mut m = Map::new();
732 m.insert("Name".into(), json!(host.name));
733 m.insert("Status".into(), json!(host.status));
734 m.insert("ProviderType".into(), json!(host.provider_type));
735 m.insert("ProviderEndpoint".into(), json!(host.provider_endpoint));
736 if let Some(vpc) = &host.vpc_configuration {
737 m.insert("VpcConfiguration".into(), vpc.clone());
738 }
739 ok(Value::Object(m))
740 }
741
742 fn get_repository_link(
743 &self,
744 req: &AwsRequest,
745 body: &Value,
746 ) -> Result<AwsResponse, AwsServiceError> {
747 let id = req_str(body, "RepositoryLinkId")?.to_string();
748 let accounts = self.state.read();
749 let link = accounts
750 .get(&req.account_id)
751 .and_then(|st| st.repository_links.get(&id))
752 .ok_or_else(|| not_found(format!("Repository link {id} does not exist.")))?;
753 ok(json!({ "RepositoryLinkInfo": repository_link_info(link) }))
754 }
755
756 fn get_repository_sync_status(
757 &self,
758 _req: &AwsRequest,
759 body: &Value,
760 ) -> Result<AwsResponse, AwsServiceError> {
761 req_str(body, "Branch")?;
762 req_str(body, "RepositoryLinkId")?;
763 req_str(body, "SyncType")?;
764 check_enum(body, "SyncType", SYNC_TYPES)?;
765 Err(not_found(
768 "No repository sync status found for the requested repository link.",
769 ))
770 }
771
772 fn get_resource_sync_status(
773 &self,
774 _req: &AwsRequest,
775 body: &Value,
776 ) -> Result<AwsResponse, AwsServiceError> {
777 req_str(body, "ResourceName")?;
778 req_str(body, "SyncType")?;
779 check_enum(body, "SyncType", SYNC_TYPES)?;
780 Err(not_found(
781 "No resource sync status found for the requested resource.",
782 ))
783 }
784
785 fn get_sync_blocker_summary(
786 &self,
787 req: &AwsRequest,
788 body: &Value,
789 ) -> Result<AwsResponse, AwsServiceError> {
790 let sync_type = req_str(body, "SyncType")?.to_string();
791 let resource_name = req_str(body, "ResourceName")?.to_string();
792 check_enum(body, "SyncType", SYNC_TYPES)?;
793 let accounts = self.state.read();
794 let exists = accounts.get(&req.account_id).is_some_and(|st| {
795 st.sync_configurations
796 .contains_key(&skey(&sync_type, &resource_name))
797 });
798 if !exists {
799 return Err(not_found(format!(
800 "No sync configuration found for {resource_name}."
801 )));
802 }
803 ok(json!({
805 "SyncBlockerSummary": {
806 "ResourceName": resource_name,
807 "LatestBlockers": [],
808 }
809 }))
810 }
811
812 fn get_sync_configuration(
813 &self,
814 req: &AwsRequest,
815 body: &Value,
816 ) -> Result<AwsResponse, AwsServiceError> {
817 let sync_type = req_str(body, "SyncType")?.to_string();
818 let resource_name = req_str(body, "ResourceName")?.to_string();
819 check_enum(body, "SyncType", SYNC_TYPES)?;
820 let accounts = self.state.read();
821 let cfg = accounts
822 .get(&req.account_id)
823 .and_then(|st| {
824 st.sync_configurations
825 .get(&skey(&sync_type, &resource_name))
826 })
827 .ok_or_else(|| {
828 not_found(format!("No sync configuration found for {resource_name}."))
829 })?;
830 ok(json!({ "SyncConfiguration": sync_configuration_value(cfg) }))
831 }
832
833 fn list_connections(
834 &self,
835 req: &AwsRequest,
836 body: &Value,
837 ) -> Result<AwsResponse, AwsServiceError> {
838 check_enum(body, "ProviderTypeFilter", PROVIDER_TYPES)?;
839 check_len(body, "HostArnFilter", 0, 256)?;
840 let provider_filter = opt_str(body, "ProviderTypeFilter");
841 let host_filter = opt_str(body, "HostArnFilter");
842 let accounts = self.state.read();
843 let items: Vec<Value> = accounts
844 .get(&req.account_id)
845 .map(|st| {
846 st.connections
847 .values()
848 .filter(|c| {
849 provider_filter
850 .as_deref()
851 .is_none_or(|p| c.provider_type.as_deref() == Some(p))
852 })
853 .filter(|c| {
854 host_filter
855 .as_deref()
856 .is_none_or(|h| c.host_arn.as_deref() == Some(h))
857 })
858 .map(connection_value)
859 .collect()
860 })
861 .unwrap_or_default();
862 page(items, "Connections", body, 1024)
863 }
864
865 fn list_hosts(&self, req: &AwsRequest, body: &Value) -> Result<AwsResponse, AwsServiceError> {
866 let accounts = self.state.read();
867 let items: Vec<Value> = accounts
868 .get(&req.account_id)
869 .map(|st| {
870 st.hosts
871 .values()
872 .map(|h| {
873 let mut m = Map::new();
874 m.insert("Name".into(), json!(h.name));
875 m.insert("HostArn".into(), json!(h.host_arn));
876 m.insert("ProviderType".into(), json!(h.provider_type));
877 m.insert("ProviderEndpoint".into(), json!(h.provider_endpoint));
878 m.insert("Status".into(), json!(h.status));
879 if let Some(vpc) = &h.vpc_configuration {
880 m.insert("VpcConfiguration".into(), vpc.clone());
881 }
882 Value::Object(m)
883 })
884 .collect()
885 })
886 .unwrap_or_default();
887 page(items, "Hosts", body, 1024)
888 }
889
890 fn list_repository_links(
891 &self,
892 req: &AwsRequest,
893 body: &Value,
894 ) -> Result<AwsResponse, AwsServiceError> {
895 let accounts = self.state.read();
896 let items: Vec<Value> = accounts
897 .get(&req.account_id)
898 .map(|st| {
899 st.repository_links
900 .values()
901 .map(repository_link_info)
902 .collect()
903 })
904 .unwrap_or_default();
905 page(items, "RepositoryLinks", body, 2048)
906 }
907
908 fn list_repository_sync_definitions(
909 &self,
910 req: &AwsRequest,
911 body: &Value,
912 ) -> Result<AwsResponse, AwsServiceError> {
913 let id = req_str(body, "RepositoryLinkId")?.to_string();
914 req_str(body, "SyncType")?;
915 check_enum(body, "SyncType", SYNC_TYPES)?;
916 let accounts = self.state.read();
917 let exists = accounts
918 .get(&req.account_id)
919 .is_some_and(|st| st.repository_links.contains_key(&id));
920 if !exists {
921 return Err(not_found(format!("Repository link {id} does not exist.")));
922 }
923 ok(json!({ "RepositorySyncDefinitions": [] }))
925 }
926
927 fn list_sync_configurations(
928 &self,
929 req: &AwsRequest,
930 body: &Value,
931 ) -> Result<AwsResponse, AwsServiceError> {
932 let id = req_str(body, "RepositoryLinkId")?.to_string();
933 let sync_type = req_str(body, "SyncType")?.to_string();
934 check_enum(body, "SyncType", SYNC_TYPES)?;
935 let accounts = self.state.read();
936 let st = accounts
937 .get(&req.account_id)
938 .filter(|st| st.repository_links.contains_key(&id))
939 .ok_or_else(|| not_found(format!("Repository link {id} does not exist.")))?;
940 let items: Vec<Value> = st
941 .sync_configurations
942 .values()
943 .filter(|c| c.repository_link_id == id && c.sync_type == sync_type)
944 .map(sync_configuration_value)
945 .collect();
946 page(items, "SyncConfigurations", body, 2048)
947 }
948
949 fn list_tags_for_resource(
950 &self,
951 req: &AwsRequest,
952 body: &Value,
953 ) -> Result<AwsResponse, AwsServiceError> {
954 let arn = req_str(body, "ResourceArn")?.to_string();
955 let accounts = self.state.read();
956 let st = accounts
957 .get(&req.account_id)
958 .ok_or_else(|| not_found(format!("Resource {arn} does not exist.")))?;
959 let tags = resolve_tags(st, &arn)
960 .ok_or_else(|| not_found(format!("Resource {arn} does not exist.")))?;
961 ok(json!({ "Tags": tags_value(&tags) }))
962 }
963
964 fn tag_resource(&self, req: &AwsRequest, body: &Value) -> Result<AwsResponse, AwsServiceError> {
965 let arn = req_str(body, "ResourceArn")?.to_string();
966 let new_tags = parse_tags(body);
967 let mut accounts = self.state.write();
968 let st = accounts.get_or_create(&req.account_id);
969 let tags = resolve_tags_mut(st, &arn)
970 .ok_or_else(|| not_found(format!("Resource {arn} does not exist.")))?;
971 tags.extend(new_tags);
972 ok(json!({}))
973 }
974
975 fn untag_resource(
976 &self,
977 req: &AwsRequest,
978 body: &Value,
979 ) -> Result<AwsResponse, AwsServiceError> {
980 let arn = req_str(body, "ResourceArn")?.to_string();
981 let keys: Vec<String> = body
982 .get("TagKeys")
983 .and_then(Value::as_array)
984 .map(|a| {
985 a.iter()
986 .filter_map(|x| x.as_str().map(str::to_string))
987 .collect()
988 })
989 .unwrap_or_default();
990 let mut accounts = self.state.write();
991 let st = accounts.get_or_create(&req.account_id);
992 let tags = resolve_tags_mut(st, &arn)
993 .ok_or_else(|| not_found(format!("Resource {arn} does not exist.")))?;
994 for k in &keys {
995 tags.remove(k);
996 }
997 ok(json!({}))
998 }
999
1000 fn update_host(&self, req: &AwsRequest, body: &Value) -> Result<AwsResponse, AwsServiceError> {
1001 let arn = id_field(body, "HostArn")?;
1002 let mut accounts = self.state.write();
1003 let st = accounts.get_or_create(&req.account_id);
1004 let host = st
1005 .hosts
1006 .get_mut(&arn)
1007 .ok_or_else(|| not_found(format!("Host {arn} does not exist.")))?;
1008 if let Some(endpoint) = opt_str(body, "ProviderEndpoint") {
1009 host.provider_endpoint = endpoint;
1010 }
1011 if let Some(vpc) = body.get("VpcConfiguration") {
1012 host.vpc_configuration = Some(vpc.clone());
1013 }
1014 ok(json!({}))
1015 }
1016
1017 fn update_repository_link(
1018 &self,
1019 req: &AwsRequest,
1020 body: &Value,
1021 ) -> Result<AwsResponse, AwsServiceError> {
1022 let id = req_str(body, "RepositoryLinkId")?.to_string();
1023 let mut accounts = self.state.write();
1024 let st = accounts.get_or_create(&req.account_id);
1025 if !st.repository_links.contains_key(&id) {
1026 return Err(not_found(format!("Repository link {id} does not exist.")));
1027 }
1028 let new_connection = match opt_str(body, "ConnectionArn") {
1033 Some(arn) => {
1034 let connection =
1035 st.connections.get(&arn).cloned().ok_or_else(|| {
1036 invalid_input(format!("Connection {arn} does not exist."))
1037 })?;
1038 let provider_type = connection
1039 .provider_type
1040 .clone()
1041 .or_else(|| {
1042 connection
1043 .host_arn
1044 .as_ref()
1045 .and_then(|h| st.hosts.get(h))
1046 .map(|h| h.provider_type.clone())
1047 })
1048 .ok_or_else(|| {
1049 invalid_input(format!(
1050 "Connection {arn} is not associated with a provider type."
1051 ))
1052 })?;
1053 Some((arn, provider_type))
1054 }
1055 None => None,
1056 };
1057
1058 let link = st
1059 .repository_links
1060 .get_mut(&id)
1061 .expect("repository link existence checked above");
1062 if let Some((arn, provider_type)) = new_connection {
1063 link.connection_arn = arn;
1064 link.provider_type = provider_type;
1065 }
1066 if let Some(key) = opt_str(body, "EncryptionKeyArn") {
1067 link.encryption_key_arn = Some(key);
1068 }
1069 let value = repository_link_info(link);
1070 ok(json!({ "RepositoryLinkInfo": value }))
1071 }
1072
1073 fn update_sync_blocker(
1074 &self,
1075 req: &AwsRequest,
1076 body: &Value,
1077 ) -> Result<AwsResponse, AwsServiceError> {
1078 let id = req_str(body, "Id")?.to_string();
1079 let sync_type = req_str(body, "SyncType")?.to_string();
1080 let resource_name = req_str(body, "ResourceName")?.to_string();
1081 req_str(body, "ResolvedReason")?;
1082 check_enum(body, "SyncType", SYNC_TYPES)?;
1083 let accounts = self.state.read();
1084 let exists = accounts.get(&req.account_id).is_some_and(|st| {
1085 st.sync_configurations
1086 .contains_key(&skey(&sync_type, &resource_name))
1087 });
1088 if !exists {
1089 return Err(not_found(format!(
1090 "No sync configuration found for {resource_name}."
1091 )));
1092 }
1093 Err(AwsServiceError::aws_error(
1095 StatusCode::BAD_REQUEST,
1096 "SyncBlockerDoesNotExistException",
1097 format!("Sync blocker {id} does not exist."),
1098 ))
1099 }
1100
1101 fn update_sync_configuration(
1102 &self,
1103 req: &AwsRequest,
1104 body: &Value,
1105 ) -> Result<AwsResponse, AwsServiceError> {
1106 let resource_name = req_str(body, "ResourceName")?.to_string();
1107 let sync_type = req_str(body, "SyncType")?.to_string();
1108 check_len(body, "ResourceName", 1, 100)?;
1113 check_len(body, "Branch", 1, 255)?;
1114 check_len(body, "RoleArn", 1, 1024)?;
1115 check_enum(body, "SyncType", SYNC_TYPES)?;
1116 check_enum(body, "PublishDeploymentStatus", &["ENABLED", "DISABLED"])?;
1117 check_enum(
1118 body,
1119 "TriggerResourceUpdateOn",
1120 &["ANY_CHANGE", "FILE_CHANGE"],
1121 )?;
1122 check_enum(body, "PullRequestComment", &["ENABLED", "DISABLED"])?;
1123
1124 let mut accounts = self.state.write();
1125 let st = accounts.get_or_create(&req.account_id);
1126 let new_link = match opt_str(body, "RepositoryLinkId") {
1129 Some(link_id) => Some(st.repository_links.get(&link_id).cloned().ok_or_else(|| {
1130 invalid_input(format!("Repository link {link_id} does not exist."))
1131 })?),
1132 None => None,
1133 };
1134
1135 let cfg = st
1136 .sync_configurations
1137 .get_mut(&skey(&sync_type, &resource_name))
1138 .ok_or_else(|| {
1139 not_found(format!("No sync configuration found for {resource_name}."))
1140 })?;
1141 if let Some(branch) = opt_str(body, "Branch") {
1142 cfg.branch = branch;
1143 }
1144 if let Some(config_file) = opt_str(body, "ConfigFile") {
1145 cfg.config_file = Some(config_file);
1146 }
1147 if let Some(role_arn) = opt_str(body, "RoleArn") {
1148 cfg.role_arn = role_arn;
1149 }
1150 if let Some(link) = new_link {
1151 cfg.repository_link_id = link.repository_link_id;
1152 cfg.repository_name = link.repository_name;
1153 cfg.owner_id = link.owner_id;
1154 cfg.provider_type = link.provider_type;
1155 }
1156 if let Some(v) = opt_str(body, "PublishDeploymentStatus") {
1157 cfg.publish_deployment_status = Some(v);
1158 }
1159 if let Some(v) = opt_str(body, "TriggerResourceUpdateOn") {
1160 cfg.trigger_resource_update_on = Some(v);
1161 }
1162 if let Some(v) = opt_str(body, "PullRequestComment") {
1163 cfg.pull_request_comment = Some(v);
1164 }
1165 let value = sync_configuration_value(cfg);
1166 ok(json!({ "SyncConfiguration": value }))
1167 }
1168}
1169
1170fn resolve_tags(st: &crate::state::CodeConnectionsState, arn: &str) -> Option<TagMap> {
1173 if let Some(c) = st.connections.get(arn) {
1174 return Some(c.tags.clone());
1175 }
1176 if let Some(h) = st.hosts.get(arn) {
1177 return Some(h.tags.clone());
1178 }
1179 st.repository_links
1180 .values()
1181 .find(|l| l.repository_link_arn == arn)
1182 .map(|l| l.tags.clone())
1183}
1184
1185fn resolve_tags_mut<'a>(
1187 st: &'a mut crate::state::CodeConnectionsState,
1188 arn: &str,
1189) -> Option<&'a mut TagMap> {
1190 if st.connections.contains_key(arn) {
1191 return st.connections.get_mut(arn).map(|c| &mut c.tags);
1192 }
1193 if st.hosts.contains_key(arn) {
1194 return st.hosts.get_mut(arn).map(|h| &mut h.tags);
1195 }
1196 st.repository_links
1197 .values_mut()
1198 .find(|l| l.repository_link_arn == arn)
1199 .map(|l| &mut l.tags)
1200}
1201
1202#[cfg(test)]
1203mod tests {
1204 use super::*;
1205 use bytes::Bytes;
1206 use fakecloud_core::multi_account::MultiAccountState;
1207 use http::{HeaderMap, Method};
1208 use parking_lot::{Mutex, RwLock};
1209 use std::collections::HashMap;
1210
1211 fn svc() -> CodeConnectionsService {
1212 CodeConnectionsService::new(Arc::new(RwLock::new(MultiAccountState::new(
1213 "000000000000",
1214 "us-east-1",
1215 "",
1216 ))))
1217 }
1218
1219 fn req(action: &str, body: Value) -> AwsRequest {
1220 AwsRequest {
1221 service: "codeconnections".into(),
1222 action: action.into(),
1223 region: "us-east-1".into(),
1224 account_id: "000000000000".into(),
1225 request_id: "req".into(),
1226 headers: HeaderMap::new(),
1227 query_params: HashMap::new(),
1228 body: Bytes::from(serde_json::to_vec(&body).unwrap()),
1229 body_stream: Mutex::new(None),
1230 path_segments: vec![],
1231 raw_path: String::new(),
1232 raw_query: String::new(),
1233 method: Method::POST,
1234 is_query_protocol: false,
1235 access_key_id: None,
1236 principal: None,
1237 }
1238 }
1239
1240 fn call(s: &CodeConnectionsService, action: &str, body: Value) -> Value {
1241 let resp = dispatch(s, &req(action, body)).expect("op ok");
1242 serde_json::from_slice(resp.body.expect_bytes()).unwrap()
1243 }
1244
1245 fn call_err(s: &CodeConnectionsService, action: &str, body: Value) -> AwsServiceError {
1246 dispatch(s, &req(action, body)).err().expect("op err")
1247 }
1248
1249 fn connection_with_provider(s: &CodeConnectionsService) -> String {
1252 let out = call(
1253 s,
1254 "CreateConnection",
1255 json!({ "ConnectionName": "conn", "ProviderType": "GitHub" }),
1256 );
1257 out["ConnectionArn"].as_str().unwrap().to_string()
1258 }
1259
1260 #[test]
1263 fn create_connection_with_provider_type_is_pending() {
1264 let s = svc();
1265 let arn = connection_with_provider(&s);
1266 let got = call(&s, "GetConnection", json!({ "ConnectionArn": arn }));
1267 assert_eq!(got["Connection"]["ConnectionStatus"], json!("PENDING"));
1268 assert_eq!(got["Connection"]["ProviderType"], json!("GitHub"));
1269 }
1270
1271 #[test]
1272 fn create_connection_with_neither_provider_nor_host_is_rejected() {
1273 let s = svc();
1274 let err = call_err(&s, "CreateConnection", json!({ "ConnectionName": "conn" }));
1275 assert_eq!(err.code(), "InvalidInputException");
1276 }
1277
1278 #[test]
1281 fn create_connection_with_unknown_host_is_rejected() {
1282 let s = svc();
1283 let err = call_err(
1284 &s,
1285 "CreateConnection",
1286 json!({
1287 "ConnectionName": "conn",
1288 "HostArn": "arn:aws:codeconnections:us-east-1:000000000000:host/does-not-exist"
1289 }),
1290 );
1291 assert_eq!(err.code(), "ResourceNotFoundException");
1292 }
1293
1294 #[test]
1295 fn create_connection_inherits_provider_type_from_host() {
1296 let s = svc();
1297 let host = call(
1298 &s,
1299 "CreateHost",
1300 json!({
1301 "Name": "host",
1302 "ProviderType": "GitHubEnterpriseServer",
1303 "ProviderEndpoint": "https://example.com"
1304 }),
1305 );
1306 let host_arn = host["HostArn"].as_str().unwrap().to_string();
1307 let conn = call(
1308 &s,
1309 "CreateConnection",
1310 json!({ "ConnectionName": "conn", "HostArn": host_arn.clone() }),
1311 );
1312 let arn = conn["ConnectionArn"].as_str().unwrap().to_string();
1313 let got = call(&s, "GetConnection", json!({ "ConnectionArn": arn }));
1314 assert_eq!(
1315 got["Connection"]["ProviderType"],
1316 json!("GitHubEnterpriseServer")
1317 );
1318 assert_eq!(got["Connection"]["HostArn"], json!(host_arn));
1319 }
1320
1321 fn make_link(s: &CodeConnectionsService, connection_arn: &str) -> String {
1324 let out = call(
1325 s,
1326 "CreateRepositoryLink",
1327 json!({
1328 "ConnectionArn": connection_arn,
1329 "OwnerId": "owner",
1330 "RepositoryName": "repo"
1331 }),
1332 );
1333 out["RepositoryLinkInfo"]["RepositoryLinkId"]
1334 .as_str()
1335 .unwrap()
1336 .to_string()
1337 }
1338
1339 #[test]
1340 fn update_repository_link_to_missing_connection_is_rejected() {
1341 let s = svc();
1342 let conn = connection_with_provider(&s);
1343 let link = make_link(&s, &conn);
1344 let err = call_err(
1345 &s,
1346 "UpdateRepositoryLink",
1347 json!({
1348 "RepositoryLinkId": link,
1349 "ConnectionArn": "arn:aws:codeconnections:us-east-1:000000000000:connection/missing"
1350 }),
1351 );
1352 assert_eq!(err.code(), "InvalidInputException");
1353 }
1354
1355 #[test]
1356 fn update_repository_link_rederives_provider_from_new_connection() {
1357 let s = svc();
1358 let github = connection_with_provider(&s); let link = make_link(&s, &github);
1360 let bitbucket = call(
1362 &s,
1363 "CreateConnection",
1364 json!({ "ConnectionName": "bb", "ProviderType": "Bitbucket" }),
1365 )["ConnectionArn"]
1366 .as_str()
1367 .unwrap()
1368 .to_string();
1369 let out = call(
1370 &s,
1371 "UpdateRepositoryLink",
1372 json!({ "RepositoryLinkId": link, "ConnectionArn": bitbucket.clone() }),
1373 );
1374 assert_eq!(out["RepositoryLinkInfo"]["ConnectionArn"], json!(bitbucket));
1375 assert_eq!(
1376 out["RepositoryLinkInfo"]["ProviderType"],
1377 json!("Bitbucket")
1378 );
1379 }
1380
1381 #[test]
1384 fn list_connections_max_results_zero_returns_empty_page() {
1385 let s = svc();
1386 connection_with_provider(&s);
1387 let out = call(&s, "ListConnections", json!({ "MaxResults": 0 }));
1388 assert_eq!(out["Connections"], json!([]));
1389 assert_eq!(out["NextToken"], json!("0"));
1391 }
1392
1393 #[test]
1396 fn list_connections_max_results_float_is_rejected() {
1397 let s = svc();
1398 connection_with_provider(&s);
1399 let err = call_err(&s, "ListConnections", json!({ "MaxResults": 50.0 }));
1403 assert_eq!(err.code(), "ValidationException");
1404 }
1405
1406 fn link_for_sync(s: &CodeConnectionsService) -> String {
1411 let conn = connection_with_provider(s);
1412 make_link(s, &conn)
1413 }
1414
1415 fn create_sync_body(link_id: &str, resource_name: &str) -> Value {
1416 json!({
1417 "Branch": "main",
1418 "ConfigFile": "config.yaml",
1419 "RepositoryLinkId": link_id,
1420 "ResourceName": resource_name,
1421 "RoleArn": "arn:aws:iam::000000000000:role/sync-role",
1422 "SyncType": "CFN_STACK_SYNC"
1423 })
1424 }
1425
1426 #[test]
1427 fn create_sync_configuration_over_length_resource_name_is_rejected() {
1428 let s = svc();
1429 let link = link_for_sync(&s);
1430 let over = "a".repeat(101);
1434 let err = call_err(
1435 &s,
1436 "CreateSyncConfiguration",
1437 create_sync_body(&link, &over),
1438 );
1439 assert_eq!(err.code(), "ValidationException");
1440 }
1441
1442 #[test]
1443 fn create_sync_configuration_happy_path_still_succeeds() {
1444 let s = svc();
1445 let link = link_for_sync(&s);
1446 let out = call(
1447 &s,
1448 "CreateSyncConfiguration",
1449 create_sync_body(&link, "MyStack"),
1450 );
1451 assert_eq!(out["SyncConfiguration"]["ResourceName"], json!("MyStack"));
1452 }
1453
1454 #[test]
1455 fn update_sync_configuration_over_length_branch_is_rejected() {
1456 let s = svc();
1457 let link = link_for_sync(&s);
1458 call(
1459 &s,
1460 "CreateSyncConfiguration",
1461 create_sync_body(&link, "MyStack"),
1462 );
1463 let over_branch = "b".repeat(256);
1466 let err = call_err(
1467 &s,
1468 "UpdateSyncConfiguration",
1469 json!({
1470 "ResourceName": "MyStack",
1471 "SyncType": "CFN_STACK_SYNC",
1472 "Branch": over_branch
1473 }),
1474 );
1475 assert_eq!(err.code(), "ValidationException");
1476 }
1477}