Skip to main content

fakecloud_codeconnections/
service.rs

1//! AWS CodeConnections (`codeconnections`) awsJson1.0 dispatch + operation
2//! handlers.
3//!
4//! The full 27-operation control plane from the AWS Smithy model: connections,
5//! self-managed hosts, repository links, sync configurations, the sync-status /
6//! sync-blocker read surface, and resource tagging. State is account-
7//! partitioned and persisted. There is no backing source-provider handshake or
8//! Git-sync engine, so connections stay `PENDING`, and the sync-status /
9//! sync-blocker read paths report "not found" for resources that were never
10//! synced (the honest AWS shape), while every other operation is real,
11//! persisted CRUD.
12
13use 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
29/// Every operation name in the CodeConnections Smithy model.
30pub 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
60/// Valid `ProviderType` enum values.
61const PROVIDER_TYPES: &[&str] = &[
62    "Bitbucket",
63    "GitHub",
64    "GitHubEnterpriseServer",
65    "GitLab",
66    "GitLabSelfManaged",
67    "AzureDevOps",
68];
69
70/// Valid `SyncConfigurationType` enum values.
71const 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    /// Persist hook for the CloudFormation provisioner; `None` in memory mode.
103    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
183// ---------------------------------------------------------------------------
184// Helpers
185// ---------------------------------------------------------------------------
186
187fn 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
223/// A required identifier field that must be *present* but whose `@length`
224/// minimum is 0 (ARNs, resource names used only for lookup). The empty string
225/// is a valid present value: it simply resolves to a non-existent resource, so
226/// the handler returns the operation's declared "not found" error rather than a
227/// (undeclared) `ValidationException`.
228fn 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
235/// Enforce a string field's `@length` bounds (both min and max), so
236/// too-long / too-short negative variants are rejected with a client error.
237fn 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
282/// Parse a `Tags` list (`[{Key,Value}]`) into a map.
283fn 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
306// ---------------------------------------------------------------------------
307// Value builders
308// ---------------------------------------------------------------------------
309
310fn 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
364/// Numeric-offset pagination reading `MaxResults`/`NextToken` from the body.
365///
366/// `MaxResults` is bounded `[0, 100]` per the model's `@range` trait (min 0),
367/// so an out-of-range value is rejected. An explicit `MaxResults` (including 0)
368/// is honored literally; only an *absent* `MaxResults` falls back to the
369/// service default page size. `NextToken` is an opaque numeric offset: an
370/// unparseable token is tolerated as "start from the beginning" rather than
371/// raising an (undeclared) error, matching the model's error contract for the
372/// list operations.
373fn 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    // An explicit MaxResults (including the model-valid 0) is honored as-is; an
381    // absent one uses the default page size. No falsy-zero coalescing: 0 means
382    // "return zero items this page", not "use the default". A present-but-not-
383    // integer value (for example the JSON float 50.0) is rejected rather than
384    // silently defaulting: as_i64 returns None for a float, so validate against
385    // the model's @range on the raw JSON number instead of coalescing to the
386    // default page size.
387    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
419// ---------------------------------------------------------------------------
420// Handlers
421// ---------------------------------------------------------------------------
422
423impl 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        // The provider type is carried either directly (ProviderType) or by a
439        // referenced host. A supplied HostArn must resolve to an existing host,
440        // and the connection inherits that host's provider type when no explicit
441        // ProviderType is given.
442        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        // A connection must resolve to a provider type: either ProviderType is
456        // given directly, or a HostArn supplies it. Neither is invalid input.
457        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        // The connection must exist and resolve to a provider type; the link's
523        // ProviderType is inherited from the connection (or its host).
524        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        // Refuse a duplicate link for the same connection + repository.
546        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        // Enforce the model's @length bounds on every sync-config field that
585        // declares one, so a create cannot store a value its own delete/update
586        // path would later reject (create/delete asymmetry). ConfigFile
587        // (DeploymentFilePath) and RepositoryLinkId carry no @length.
588        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        // A repository link cannot be deleted while sync configurations still
674        // reference it.
675        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        // DeleteSyncConfiguration is idempotent: the model declares no
700        // resource-not-found error, so a delete of an absent configuration
701        // succeeds as a no-op.
702        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        // fakecloud runs no Git-sync engine, so no repository sync has ever been
766        // attempted, so there is no latest-sync record to report.
767        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        // No Git-sync engine runs, so there are no active sync blockers.
804        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        // No sync definitions exist without a running Git-sync engine.
924        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        // Repointing the link at a new connection must reference an existing
1029        // connection, and the link re-derives its provider type from that new
1030        // connection (as CreateRepositoryLink does). Resolve it up front with
1031        // immutable reads so it doesn't overlap the mutable link borrow below.
1032        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        // No Git-sync engine runs, so no sync blocker with this id can exist.
1094        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        // Enforce the model's @length bounds on the mutable sync-config fields
1109        // that declare one, symmetric with CreateSyncConfiguration. Branch and
1110        // RoleArn are optional on update, so they are only checked when present
1111        // (check_len is a no-op for absent fields).
1112        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        // Resolve any new repository link up-front (immutable borrow) so the
1127        // mutable borrow of the configuration below doesn't overlap.
1128        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
1170/// Resolve a taggable resource's tag map by ARN (connection, host, or
1171/// repository link), returning a clone.
1172fn 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
1185/// Resolve a taggable resource's tag map by ARN, returning a mutable reference.
1186fn 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    /// A connection created with an explicit `ProviderType` succeeds and is
1250    /// `PENDING`; a repository link created against it inherits that type.
1251    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    // ---- Defect 3: CreateConnection must resolve a provider type ----
1261
1262    #[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    // ---- Defect 2: a supplied HostArn must resolve ----
1279
1280    #[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    // ---- Defect 1: UpdateRepositoryLink new ConnectionArn must exist + re-derive provider ----
1322
1323    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); // GitHub
1359        let link = make_link(&s, &github);
1360        // A second connection with a different provider type.
1361        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    // ---- Defect 4: MaxResults=0 honored literally (no falsy-zero fallback) ----
1382
1383    #[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        // There is one connection, so a continuation token is offered.
1390        assert_eq!(out["NextToken"], json!("0"));
1391    }
1392
1393    // ---- L3: a non-integer MaxResults is rejected, not silently defaulted ----
1394
1395    #[test]
1396    fn list_connections_max_results_float_is_rejected() {
1397        let s = svc();
1398        connection_with_provider(&s);
1399        // 50.0 is a JSON float, not an integer: as_i64 returns None. It must be
1400        // rejected with a validation error, not fall back to the default page
1401        // size (which would return the connection instead of validating).
1402        let err = call_err(&s, "ListConnections", json!({ "MaxResults": 50.0 }));
1403        assert_eq!(err.code(), "ValidationException");
1404    }
1405
1406    // ---- L1 + L2: sync-configuration create/update enforce @length ----
1407
1408    /// A repository link backed by a provider-typed connection, ready to carry a
1409    /// sync configuration.
1410    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        // ResourceName @length max is 100; 101 chars must be rejected. Before
1431        // this fix the create stored it, but DeleteSyncConfiguration rejects a
1432        // >100-char ResourceName, so the record could never be deleted.
1433        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        // Branch @length max is 255; an over-length Branch on update must be
1464        // rejected symmetric with create.
1465        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}