Skip to main content

fakecloud_amplify/
service.rs

1//! AWS Amplify (`amplify`) restJson1 dispatch + operation handlers.
2//!
3//! The full 37-operation AWS Amplify control plane. Requests are routed to an
4//! operation by HTTP method + `@http` URI path; path labels are captured
5//! positionally (percent-decoded, so an ARN label whose slashes arrive
6//! percent-encoded survives intact) and query parameters are read from the raw
7//! query string. State is account-partitioned and persisted; each resource is
8//! stored as its already-output-valid wire object so `Get*` echoes exactly what
9//! `Create*` / `Update*` persisted, and the async domain-verification /
10//! job-build lifecycles settle deterministically on the next read.
11
12use std::sync::Arc;
13
14use async_trait::async_trait;
15use http::{Method, StatusCode};
16use percent_encoding::percent_decode_str;
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::shared;
25use crate::state::{AmplifyData, AppRecord, SharedAmplifyState};
26
27/// Every operation name in the AWS Amplify Smithy model (37 operations).
28pub const AMPLIFY_ACTIONS: &[&str] = &[
29    "CreateApp",
30    "CreateBackendEnvironment",
31    "CreateBranch",
32    "CreateDeployment",
33    "CreateDomainAssociation",
34    "CreateWebhook",
35    "DeleteApp",
36    "DeleteBackendEnvironment",
37    "DeleteBranch",
38    "DeleteDomainAssociation",
39    "DeleteJob",
40    "DeleteWebhook",
41    "GenerateAccessLogs",
42    "GetApp",
43    "GetArtifactUrl",
44    "GetBackendEnvironment",
45    "GetBranch",
46    "GetDomainAssociation",
47    "GetJob",
48    "GetWebhook",
49    "ListApps",
50    "ListArtifacts",
51    "ListBackendEnvironments",
52    "ListBranches",
53    "ListDomainAssociations",
54    "ListJobs",
55    "ListTagsForResource",
56    "ListWebhooks",
57    "StartDeployment",
58    "StartJob",
59    "StopJob",
60    "TagResource",
61    "UntagResource",
62    "UpdateApp",
63    "UpdateBranch",
64    "UpdateDomainAssociation",
65    "UpdateWebhook",
66];
67
68/// Operations that mutate persisted state on success (so a snapshot is taken).
69const MUTATING: &[&str] = &[
70    "CreateApp",
71    "UpdateApp",
72    "DeleteApp",
73    "CreateBranch",
74    "UpdateBranch",
75    "DeleteBranch",
76    "CreateDomainAssociation",
77    "UpdateDomainAssociation",
78    "DeleteDomainAssociation",
79    "CreateWebhook",
80    "UpdateWebhook",
81    "DeleteWebhook",
82    "CreateBackendEnvironment",
83    "DeleteBackendEnvironment",
84    "StartJob",
85    "StopJob",
86    "DeleteJob",
87    "CreateDeployment",
88    "StartDeployment",
89    "TagResource",
90    "UntagResource",
91];
92
93pub struct AmplifyService {
94    state: SharedAmplifyState,
95    snapshot_store: Option<Arc<dyn SnapshotStore>>,
96    snapshot_lock: Arc<AsyncMutex<()>>,
97}
98
99impl AmplifyService {
100    pub fn new(state: SharedAmplifyState) -> Self {
101        Self {
102            state,
103            snapshot_store: None,
104            snapshot_lock: Arc::new(AsyncMutex::new(())),
105        }
106    }
107
108    pub fn with_snapshot_store(mut self, store: Arc<dyn SnapshotStore>) -> Self {
109        self.snapshot_store = Some(store);
110        self
111    }
112
113    async fn save(&self) {
114        save_snapshot(
115            &self.state,
116            self.snapshot_store.clone(),
117            &self.snapshot_lock,
118        )
119        .await;
120    }
121
122    /// Persist hook for the CloudFormation provisioner; `None` in memory mode.
123    pub fn snapshot_hook(&self) -> Option<fakecloud_persistence::SnapshotHook> {
124        let store = self.snapshot_store.clone()?;
125        let state = self.state.clone();
126        let lock = self.snapshot_lock.clone();
127        Some(Arc::new(move || {
128            let state = state.clone();
129            let store = store.clone();
130            let lock = lock.clone();
131            Box::pin(async move {
132                crate::persistence::save_snapshot(&state, Some(store), &lock).await;
133            })
134        }))
135    }
136
137    /// Settle any in-flight domain / job lifecycle transition for the account.
138    /// Returns `true` if a transition fired (so the caller persists the change).
139    fn reconcile(&self, account: &str) -> bool {
140        let mut guard = self.state.write();
141        guard.get_or_create(account).reconcile()
142    }
143
144    /// Route a request to an operation name + captured path labels by HTTP
145    /// method + `@http` URI path. Returns `None` when no route matches.
146    fn resolve_action(req: &AwsRequest) -> Option<(&'static str, Vec<String>)> {
147        let raw = req.raw_path.split('?').next().unwrap_or(&req.raw_path);
148        let trimmed = raw.strip_prefix('/').unwrap_or(raw);
149        let segs: Vec<String> = if trimmed.is_empty() {
150            Vec::new()
151        } else {
152            trimmed
153                .split('/')
154                .map(|s| percent_decode_str(s).decode_utf8_lossy().into_owned())
155                .collect()
156        };
157        let s: Vec<&str> = segs.iter().map(String::as_str).collect();
158        let m = &req.method;
159        let get = m == Method::GET;
160        let post = m == Method::POST;
161        let del = m == Method::DELETE;
162        let l1 = |a: &str| vec![a.to_string()];
163        let l2 = |a: &str, b: &str| vec![a.to_string(), b.to_string()];
164        let l3 = |a: &str, b: &str, c: &str| vec![a.to_string(), b.to_string(), c.to_string()];
165        let (action, labels): (&'static str, Vec<String>) = match s.as_slice() {
166            ["apps"] if post => ("CreateApp", vec![]),
167            ["apps"] if get => ("ListApps", vec![]),
168            ["apps", app] if post => ("UpdateApp", l1(app)),
169            ["apps", app] if get => ("GetApp", l1(app)),
170            ["apps", app] if del => ("DeleteApp", l1(app)),
171            ["apps", app, "branches"] if post => ("CreateBranch", l1(app)),
172            ["apps", app, "branches"] if get => ("ListBranches", l1(app)),
173            ["apps", app, "branches", br] if post => ("UpdateBranch", l2(app, br)),
174            ["apps", app, "branches", br] if get => ("GetBranch", l2(app, br)),
175            ["apps", app, "branches", br] if del => ("DeleteBranch", l2(app, br)),
176            ["apps", app, "branches", br, "deployments"] if post => {
177                ("CreateDeployment", l2(app, br))
178            }
179            ["apps", app, "branches", br, "deployments", "start"] if post => {
180                ("StartDeployment", l2(app, br))
181            }
182            ["apps", app, "branches", br, "jobs"] if post => ("StartJob", l2(app, br)),
183            ["apps", app, "branches", br, "jobs"] if get => ("ListJobs", l2(app, br)),
184            ["apps", app, "branches", br, "jobs", job] if get => ("GetJob", l3(app, br, job)),
185            ["apps", app, "branches", br, "jobs", job] if del => ("DeleteJob", l3(app, br, job)),
186            ["apps", app, "branches", br, "jobs", job, "stop"] if del => {
187                ("StopJob", l3(app, br, job))
188            }
189            ["apps", app, "branches", br, "jobs", job, "artifacts"] if get => {
190                ("ListArtifacts", l3(app, br, job))
191            }
192            ["apps", app, "domains"] if post => ("CreateDomainAssociation", l1(app)),
193            ["apps", app, "domains"] if get => ("ListDomainAssociations", l1(app)),
194            ["apps", app, "domains", dom] if post => ("UpdateDomainAssociation", l2(app, dom)),
195            ["apps", app, "domains", dom] if get => ("GetDomainAssociation", l2(app, dom)),
196            ["apps", app, "domains", dom] if del => ("DeleteDomainAssociation", l2(app, dom)),
197            ["apps", app, "webhooks"] if post => ("CreateWebhook", l1(app)),
198            ["apps", app, "webhooks"] if get => ("ListWebhooks", l1(app)),
199            ["apps", app, "backendenvironments"] if post => ("CreateBackendEnvironment", l1(app)),
200            ["apps", app, "backendenvironments"] if get => ("ListBackendEnvironments", l1(app)),
201            ["apps", app, "backendenvironments", env] if get => {
202                ("GetBackendEnvironment", l2(app, env))
203            }
204            ["apps", app, "backendenvironments", env] if del => {
205                ("DeleteBackendEnvironment", l2(app, env))
206            }
207            ["apps", app, "accesslogs"] if post => ("GenerateAccessLogs", l1(app)),
208            ["webhooks", wh] if post => ("UpdateWebhook", l1(wh)),
209            ["webhooks", wh] if get => ("GetWebhook", l1(wh)),
210            ["webhooks", wh] if del => ("DeleteWebhook", l1(wh)),
211            ["artifacts", art] if get => ("GetArtifactUrl", l1(art)),
212            ["tags", arn] if post => ("TagResource", l1(arn)),
213            ["tags", arn] if get => ("ListTagsForResource", l1(arn)),
214            ["tags", arn] if del => ("UntagResource", l1(arn)),
215            _ => return None,
216        };
217        Some((action, labels))
218    }
219}
220
221#[async_trait]
222impl AwsService for AmplifyService {
223    fn service_name(&self) -> &str {
224        "amplify"
225    }
226
227    async fn handle(&self, req: AwsRequest) -> Result<AwsResponse, AwsServiceError> {
228        let Some((action, labels)) = Self::resolve_action(&req) else {
229            return Err(AwsServiceError::aws_error(
230                StatusCode::NOT_FOUND,
231                "UnknownOperationException",
232                format!("Unknown operation: {} {}", req.method, req.raw_path),
233            ));
234        };
235        let (result, settled) = self.dispatch(action, &labels, &req);
236        let success = matches!(result.as_ref(), Ok(resp) if resp.status.is_success());
237        if settled || (MUTATING.contains(&action) && success) {
238            self.save().await;
239        }
240        result
241    }
242
243    fn supported_actions(&self) -> &[&str] {
244        AMPLIFY_ACTIONS
245    }
246}
247
248/// Per-request account + region context.
249struct Ctx {
250    account: String,
251    region: String,
252}
253
254impl AmplifyService {
255    fn dispatch(
256        &self,
257        action: &str,
258        labels: &[String],
259        req: &AwsRequest,
260    ) -> (Result<AwsResponse, AwsServiceError>, bool) {
261        let body = match parse_body(req) {
262            Ok(b) => b,
263            Err(e) => return (Err(e), false),
264        };
265        if let Err(e) = crate::validate::validate_input(action, &body) {
266            return (Err(e), false);
267        }
268        let ctx = Ctx {
269            account: req.account_id.clone(),
270            region: req.region.clone(),
271        };
272        let q = parse_query(&req.raw_query);
273        // Settle any in-flight lifecycle transition before the handler reads
274        // state, remembering whether it fired so the change is persisted even
275        // for a pure read.
276        let settled = self.reconcile(&ctx.account);
277        let a = |i: usize| labels.get(i).map(String::as_str).unwrap_or_default();
278        let result = match action {
279            "CreateApp" => self.create_app(&ctx, &body),
280            "GetApp" => self.get_app(&ctx, a(0)),
281            "UpdateApp" => self.update_app(&ctx, a(0), &body),
282            "DeleteApp" => self.delete_app(&ctx, a(0)),
283            "ListApps" => self.list_apps(&ctx, &q),
284            "CreateBranch" => self.create_branch(&ctx, a(0), &body),
285            "GetBranch" => self.get_branch(&ctx, a(0), a(1)),
286            "UpdateBranch" => self.update_branch(&ctx, a(0), a(1), &body),
287            "DeleteBranch" => self.delete_branch(&ctx, a(0), a(1)),
288            "ListBranches" => self.list_branches(&ctx, a(0), &q),
289            "CreateDomainAssociation" => self.create_domain(&ctx, a(0), &body),
290            "GetDomainAssociation" => self.get_domain(&ctx, a(0), a(1)),
291            "UpdateDomainAssociation" => self.update_domain(&ctx, a(0), a(1), &body),
292            "DeleteDomainAssociation" => self.delete_domain(&ctx, a(0), a(1)),
293            "ListDomainAssociations" => self.list_domains(&ctx, a(0), &q),
294            "CreateWebhook" => self.create_webhook(&ctx, a(0), &body),
295            "GetWebhook" => self.get_webhook(&ctx, a(0)),
296            "UpdateWebhook" => self.update_webhook(&ctx, a(0), &body),
297            "DeleteWebhook" => self.delete_webhook(&ctx, a(0)),
298            "ListWebhooks" => self.list_webhooks(&ctx, a(0), &q),
299            "CreateBackendEnvironment" => self.create_backend_env(&ctx, a(0), &body),
300            "GetBackendEnvironment" => self.get_backend_env(&ctx, a(0), a(1)),
301            "DeleteBackendEnvironment" => self.delete_backend_env(&ctx, a(0), a(1)),
302            "ListBackendEnvironments" => self.list_backend_envs(&ctx, a(0), &q),
303            "StartJob" => self.start_job(&ctx, a(0), a(1), &body),
304            "GetJob" => self.get_job(&ctx, a(0), a(1), a(2)),
305            "StopJob" => self.stop_job(&ctx, a(0), a(1), a(2)),
306            "DeleteJob" => self.delete_job(&ctx, a(0), a(1), a(2)),
307            "ListJobs" => self.list_jobs(&ctx, a(0), a(1), &q),
308            "CreateDeployment" => self.create_deployment(&ctx, a(0), a(1), &body),
309            "StartDeployment" => self.start_deployment(&ctx, a(0), a(1), &body),
310            "GenerateAccessLogs" => self.generate_access_logs(&ctx, a(0), &body),
311            "GetArtifactUrl" => self.get_artifact_url(&ctx, a(0)),
312            "ListArtifacts" => self.list_artifacts(&ctx, a(0), a(1), a(2), &q),
313            "TagResource" => self.tag_resource(&ctx, a(0), &body),
314            "UntagResource" => self.untag_resource(&ctx, a(0), &q),
315            "ListTagsForResource" => self.list_tags(&ctx, a(0)),
316            _ => Err(AwsServiceError::action_not_implemented("amplify", action)),
317        };
318        (result, settled)
319    }
320}
321
322// ===================== helpers =====================
323
324fn ok(v: Value) -> Result<AwsResponse, AwsServiceError> {
325    Ok(AwsResponse::json_value(StatusCode::OK, v))
326}
327
328/// Build the `certificate` object for a DomainAssociation from a request's
329/// `certificateSettings` input. Shared by create + update so both persist the
330/// requested cert type / customCertificateArn identically. Returns `None` when
331/// the request carries no `certificateSettings`.
332fn build_certificate(body: &Value, domain: &str) -> Option<Value> {
333    let cs = body.get("certificateSettings").and_then(Value::as_object)?;
334    let ctype = cs
335        .get("type")
336        .and_then(Value::as_str)
337        .unwrap_or("AMPLIFY_MANAGED");
338    let mut cert = Map::new();
339    cert.insert("type".into(), json!(ctype));
340    if let Some(arn) = cs.get("customCertificateArn") {
341        cert.insert("customCertificateArn".into(), arn.clone());
342    }
343    cert.insert(
344        "certificateVerificationDNSRecord".into(),
345        json!(shared::certificate_verification_dns_record(domain)),
346    );
347    Some(Value::Object(cert))
348}
349
350fn parse_body(req: &AwsRequest) -> Result<Value, AwsServiceError> {
351    if req.body.is_empty() {
352        return Ok(json!({}));
353    }
354    serde_json::from_slice(&req.body)
355        .map_err(|e| bad_request(&format!("Request body is malformed: {e}")))
356}
357
358fn bad_request(msg: &str) -> AwsServiceError {
359    AwsServiceError::aws_error(StatusCode::BAD_REQUEST, "BadRequestException", msg)
360}
361
362fn not_found(msg: &str) -> AwsServiceError {
363    AwsServiceError::aws_error(StatusCode::NOT_FOUND, "NotFoundException", msg)
364}
365
366fn resource_not_found(msg: &str) -> AwsServiceError {
367    AwsServiceError::aws_error(StatusCode::NOT_FOUND, "ResourceNotFoundException", msg)
368}
369
370fn parse_query(raw: &str) -> Vec<(String, String)> {
371    raw.split('&')
372        .filter(|p| !p.is_empty())
373        .map(|pair| {
374            let (k, v) = pair.split_once('=').unwrap_or((pair, ""));
375            (
376                percent_decode_str(k).decode_utf8_lossy().into_owned(),
377                percent_decode_str(v).decode_utf8_lossy().into_owned(),
378            )
379        })
380        .collect()
381}
382
383fn query_one<'a>(q: &'a [(String, String)], key: &str) -> Option<&'a str> {
384    q.iter()
385        .find(|(k, _)| k == key)
386        .map(|(_, v)| v.as_str())
387        .filter(|v| !v.is_empty())
388}
389
390fn query_all(q: &[(String, String)], key: &str) -> Vec<String> {
391    q.iter()
392        .filter(|(k, _)| k == key)
393        .map(|(_, v)| v.clone())
394        .collect()
395}
396
397/// Validate an `appId` path label against Amplify's `^d[a-z0-9]+$` (1..=20)
398/// pattern, rejecting a malformed id with `BadRequestException` before lookup.
399fn check_app_id(app_id: &str) -> Result<(), AwsServiceError> {
400    let valid = (1..=20).contains(&app_id.len())
401        && app_id.starts_with('d')
402        && app_id
403            .chars()
404            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit());
405    if valid {
406        Ok(())
407    } else {
408        Err(bad_request(&format!(
409            "App id '{app_id}' does not match required pattern ^d[a-z0-9]+$ (1-20 characters)."
410        )))
411    }
412}
413
414/// Validate a `branchName` path label (1..=255 chars).
415fn check_branch_name(name: &str) -> Result<(), AwsServiceError> {
416    if (1..=255).contains(&name.len()) {
417        Ok(())
418    } else {
419        Err(bad_request(&format!(
420            "Branch name must be 1-255 characters, got {}.",
421            name.len()
422        )))
423    }
424}
425
426/// Read an optional string body member (owned), or a default.
427fn str_or(body: &Value, key: &str, default: &str) -> String {
428    body.get(key)
429        .and_then(Value::as_str)
430        .unwrap_or(default)
431        .to_string()
432}
433
434fn bool_or(body: &Value, key: &str, default: bool) -> bool {
435    body.get(key).and_then(Value::as_bool).unwrap_or(default)
436}
437
438/// Copy each named optional member from `body` into `out` verbatim when present
439/// and non-null. Used to echo the model's optional members onto stored wire
440/// objects without inventing values.
441fn echo(out: &mut Map<String, Value>, body: &Value, keys: &[&str]) {
442    for key in keys {
443        if let Some(v) = body.get(*key) {
444            if !v.is_null() {
445                out.insert((*key).to_string(), v.clone());
446            }
447        }
448    }
449}
450
451/// Paginate a list of wire objects using the request's `maxResults` /
452/// `nextToken` query params. Validates `maxResults` against `[0, max]` and
453/// `nextToken` length, returning `BadRequestException` for out-of-range values.
454fn paginate(
455    items: Vec<Value>,
456    q: &[(String, String)],
457    max_results_cap: usize,
458) -> Result<(Vec<Value>, Option<String>), AwsServiceError> {
459    if let Some(tok) = query_one(q, "nextToken") {
460        if tok.len() > 2000 {
461            return Err(bad_request("nextToken exceeds the maximum length of 2000."));
462        }
463    }
464    let max = match query_one(q, "maxResults") {
465        Some(v) => {
466            let n: i64 = v
467                .parse()
468                .map_err(|_| bad_request("maxResults must be an integer."))?;
469            if n < 0 || n as usize > max_results_cap {
470                return Err(bad_request(&format!(
471                    "maxResults must be between 0 and {max_results_cap}."
472                )));
473            }
474            if n == 0 {
475                max_results_cap
476            } else {
477                n as usize
478            }
479        }
480        None => max_results_cap.min(100),
481    };
482    let start = query_one(q, "nextToken")
483        .and_then(|v| v.parse::<usize>().ok())
484        .unwrap_or(0);
485    let end = (start + max).min(items.len());
486    let page: Vec<Value> = items.get(start..end).unwrap_or(&[]).to_vec();
487    let next = if end < items.len() {
488        Some(end.to_string())
489    } else {
490        None
491    };
492    Ok((page, next))
493}
494
495impl AmplifyService {
496    // ------------------------------- Apps -------------------------------
497
498    fn build_app(&self, ctx: &Ctx, app_id: &str, body: &Value) -> Value {
499        let now = shared::now_epoch();
500        let mut app = Map::new();
501        app.insert("appId".into(), json!(app_id));
502        app.insert(
503            "appArn".into(),
504            json!(shared::app_arn(&ctx.region, &ctx.account, app_id)),
505        );
506        app.insert("name".into(), json!(str_or(body, "name", "")));
507        app.insert("description".into(), json!(str_or(body, "description", "")));
508        app.insert("repository".into(), json!(str_or(body, "repository", "")));
509        app.insert("platform".into(), json!(str_or(body, "platform", "WEB")));
510        app.insert("createTime".into(), json!(now));
511        app.insert("updateTime".into(), json!(now));
512        app.insert(
513            "environmentVariables".into(),
514            body.get("environmentVariables")
515                .cloned()
516                .unwrap_or_else(|| json!({})),
517        );
518        app.insert(
519            "defaultDomain".into(),
520            json!(shared::default_domain(app_id)),
521        );
522        app.insert(
523            "enableBranchAutoBuild".into(),
524            json!(bool_or(body, "enableBranchAutoBuild", true)),
525        );
526        app.insert(
527            "enableBasicAuth".into(),
528            json!(bool_or(body, "enableBasicAuth", false)),
529        );
530        // The repository clone method AWS derives from the supplied credential:
531        // a personal/OAuth token yields TOKEN, otherwise SSH deploy keys.
532        let clone_method = if body.get("accessToken").is_some() || body.get("oauthToken").is_some()
533        {
534            "TOKEN"
535        } else {
536            "SSH"
537        };
538        app.insert("repositoryCloneMethod".into(), json!(clone_method));
539        echo(
540            &mut app,
541            body,
542            &[
543                "tags",
544                "computeRoleArn",
545                "iamServiceRoleArn",
546                "enableBranchAutoDeletion",
547                "basicAuthCredentials",
548                "customRules",
549                "buildSpec",
550                "customHeaders",
551                "enableAutoBranchCreation",
552                "autoBranchCreationPatterns",
553                "autoBranchCreationConfig",
554                "cacheConfig",
555                "jobConfig",
556            ],
557        );
558        Value::Object(app)
559    }
560
561    fn create_app(&self, ctx: &Ctx, body: &Value) -> Result<AwsResponse, AwsServiceError> {
562        let app_id = shared::new_app_id();
563        let app = self.build_app(ctx, &app_id, body);
564        let mut guard = self.state.write();
565        let data = guard.get_or_create(&ctx.account);
566        data.apps.insert(
567            app_id.clone(),
568            AppRecord {
569                app: app.clone(),
570                ..Default::default()
571            },
572        );
573        ok(json!({ "app": app }))
574    }
575
576    fn get_app(&self, ctx: &Ctx, app_id: &str) -> Result<AwsResponse, AwsServiceError> {
577        check_app_id(app_id)?;
578        let guard = self.state.read();
579        match guard.get(&ctx.account).and_then(|d| d.apps.get(app_id)) {
580            Some(rec) => ok(json!({ "app": rec.app })),
581            None => Err(not_found(&format!("App {app_id} not found."))),
582        }
583    }
584
585    fn update_app(
586        &self,
587        ctx: &Ctx,
588        app_id: &str,
589        body: &Value,
590    ) -> Result<AwsResponse, AwsServiceError> {
591        check_app_id(app_id)?;
592        let mut guard = self.state.write();
593        let data = guard.get_or_create(&ctx.account);
594        let Some(rec) = data.apps.get_mut(app_id) else {
595            return Err(not_found(&format!("App {app_id} not found.")));
596        };
597        let Some(app) = rec.app.as_object_mut() else {
598            return Err(not_found(&format!("App {app_id} not found.")));
599        };
600        for key in [
601            "name",
602            "description",
603            "platform",
604            "computeRoleArn",
605            "iamServiceRoleArn",
606            "environmentVariables",
607            "basicAuthCredentials",
608            "customRules",
609            "buildSpec",
610            "customHeaders",
611            "autoBranchCreationPatterns",
612            "autoBranchCreationConfig",
613            "repository",
614            "cacheConfig",
615            "jobConfig",
616        ] {
617            if let Some(v) = body.get(key) {
618                app.insert(key.to_string(), v.clone());
619            }
620        }
621        // Boolean members renamed between input and output.
622        if let Some(v) = body.get("enableBranchAutoBuild") {
623            app.insert("enableBranchAutoBuild".into(), v.clone());
624        }
625        for key in [
626            "enableBranchAutoDeletion",
627            "enableBasicAuth",
628            "enableAutoBranchCreation",
629        ] {
630            if let Some(v) = body.get(key) {
631                app.insert(key.to_string(), v.clone());
632            }
633        }
634        app.insert("updateTime".into(), json!(shared::now_epoch()));
635        ok(json!({ "app": Value::Object(app.clone()) }))
636    }
637
638    fn delete_app(&self, ctx: &Ctx, app_id: &str) -> Result<AwsResponse, AwsServiceError> {
639        check_app_id(app_id)?;
640        let mut guard = self.state.write();
641        let data = guard.get_or_create(&ctx.account);
642        let Some(rec) = data.apps.remove(app_id) else {
643            return Err(not_found(&format!("App {app_id} not found.")));
644        };
645        // Cascade: drop webhooks that belonged to this app.
646        data.webhooks
647            .retain(|_, w| w.get("appId").and_then(Value::as_str) != Some(app_id));
648        ok(json!({ "app": rec.app }))
649    }
650
651    fn list_apps(&self, ctx: &Ctx, q: &[(String, String)]) -> Result<AwsResponse, AwsServiceError> {
652        let guard = self.state.read();
653        let apps: Vec<Value> = guard
654            .get(&ctx.account)
655            .map(|d| d.apps.values().map(|r| r.app.clone()).collect())
656            .unwrap_or_default();
657        let (page, next) = paginate(apps, q, 100)?;
658        let mut out = json!({ "apps": page });
659        if let Some(n) = next {
660            out["nextToken"] = json!(n);
661        }
662        ok(out)
663    }
664
665    // ------------------------------ Branches ----------------------------
666
667    fn build_branch(&self, ctx: &Ctx, app_id: &str, branch_name: &str, body: &Value) -> Value {
668        let now = shared::now_epoch();
669        let mut b = Map::new();
670        b.insert(
671            "branchArn".into(),
672            json!(shared::branch_arn(
673                &ctx.region,
674                &ctx.account,
675                app_id,
676                branch_name
677            )),
678        );
679        b.insert("branchName".into(), json!(branch_name));
680        b.insert("description".into(), json!(str_or(body, "description", "")));
681        b.insert("stage".into(), json!(str_or(body, "stage", "NONE")));
682        b.insert(
683            "displayName".into(),
684            json!(str_or(body, "displayName", branch_name)),
685        );
686        b.insert(
687            "enableNotification".into(),
688            json!(bool_or(body, "enableNotification", false)),
689        );
690        b.insert("createTime".into(), json!(now));
691        b.insert("updateTime".into(), json!(now));
692        b.insert(
693            "environmentVariables".into(),
694            body.get("environmentVariables")
695                .cloned()
696                .unwrap_or_else(|| json!({})),
697        );
698        b.insert(
699            "enableAutoBuild".into(),
700            json!(bool_or(body, "enableAutoBuild", true)),
701        );
702        b.insert("customDomains".into(), json!([]));
703        b.insert("framework".into(), json!(str_or(body, "framework", "")));
704        b.insert("activeJobId".into(), json!(""));
705        b.insert("totalNumberOfJobs".into(), json!("0"));
706        b.insert(
707            "enableBasicAuth".into(),
708            json!(bool_or(body, "enableBasicAuth", false)),
709        );
710        b.insert("ttl".into(), json!(str_or(body, "ttl", "5")));
711        b.insert(
712            "enablePullRequestPreview".into(),
713            json!(bool_or(body, "enablePullRequestPreview", false)),
714        );
715        b.insert(
716            "thumbnailUrl".into(),
717            json!(shared::thumbnail_url(&ctx.region, app_id, branch_name)),
718        );
719        echo(
720            &mut b,
721            body,
722            &[
723                "tags",
724                "enableSkewProtection",
725                "enablePerformanceMode",
726                "basicAuthCredentials",
727                "buildSpec",
728                "pullRequestEnvironmentName",
729                "backendEnvironmentArn",
730                "backend",
731                "computeRoleArn",
732            ],
733        );
734        Value::Object(b)
735    }
736
737    fn create_branch(
738        &self,
739        ctx: &Ctx,
740        app_id: &str,
741        body: &Value,
742    ) -> Result<AwsResponse, AwsServiceError> {
743        check_app_id(app_id)?;
744        let branch_name = body
745            .get("branchName")
746            .and_then(Value::as_str)
747            .unwrap_or_default()
748            .to_string();
749        check_branch_name(&branch_name)?;
750        let branch = self.build_branch(ctx, app_id, &branch_name, body);
751        let mut guard = self.state.write();
752        let data = guard.get_or_create(&ctx.account);
753        let Some(rec) = data.apps.get_mut(app_id) else {
754            return Err(not_found(&format!("App {app_id} not found.")));
755        };
756        if rec.branches.contains_key(&branch_name) {
757            return Err(bad_request(&format!(
758                "A branch with the name {branch_name} already exists."
759            )));
760        }
761        rec.branches.insert(branch_name, branch.clone());
762        ok(json!({ "branch": branch }))
763    }
764
765    fn get_branch(
766        &self,
767        ctx: &Ctx,
768        app_id: &str,
769        branch: &str,
770    ) -> Result<AwsResponse, AwsServiceError> {
771        check_app_id(app_id)?;
772        let guard = self.state.read();
773        match guard
774            .get(&ctx.account)
775            .and_then(|d| d.apps.get(app_id))
776            .and_then(|r| r.branches.get(branch))
777        {
778            Some(b) => ok(json!({ "branch": b })),
779            None => Err(not_found(&format!("Branch {branch} not found."))),
780        }
781    }
782
783    fn update_branch(
784        &self,
785        ctx: &Ctx,
786        app_id: &str,
787        branch: &str,
788        body: &Value,
789    ) -> Result<AwsResponse, AwsServiceError> {
790        check_app_id(app_id)?;
791        let mut guard = self.state.write();
792        let data = guard.get_or_create(&ctx.account);
793        let Some(b) = data
794            .apps
795            .get_mut(app_id)
796            .and_then(|r| r.branches.get_mut(branch))
797            .and_then(Value::as_object_mut)
798        else {
799            return Err(not_found(&format!("Branch {branch} not found.")));
800        };
801        for key in [
802            "description",
803            "framework",
804            "stage",
805            "enableNotification",
806            "enableAutoBuild",
807            "enableSkewProtection",
808            "environmentVariables",
809            "basicAuthCredentials",
810            "enableBasicAuth",
811            "enablePerformanceMode",
812            "buildSpec",
813            "ttl",
814            "displayName",
815            "enablePullRequestPreview",
816            "pullRequestEnvironmentName",
817            "backendEnvironmentArn",
818            "backend",
819            "computeRoleArn",
820        ] {
821            if let Some(v) = body.get(key) {
822                b.insert(key.to_string(), v.clone());
823            }
824        }
825        b.insert("updateTime".into(), json!(shared::now_epoch()));
826        ok(json!({ "branch": Value::Object(b.clone()) }))
827    }
828
829    fn delete_branch(
830        &self,
831        ctx: &Ctx,
832        app_id: &str,
833        branch: &str,
834    ) -> Result<AwsResponse, AwsServiceError> {
835        check_app_id(app_id)?;
836        let mut guard = self.state.write();
837        let data = guard.get_or_create(&ctx.account);
838        let Some(rec) = data.apps.get_mut(app_id) else {
839            return Err(not_found(&format!("App {app_id} not found.")));
840        };
841        match rec.branches.remove(branch) {
842            Some(b) => {
843                rec.jobs.remove(branch);
844                rec.next_job_id.remove(branch);
845                ok(json!({ "branch": b }))
846            }
847            None => Err(not_found(&format!("Branch {branch} not found."))),
848        }
849    }
850
851    fn list_branches(
852        &self,
853        ctx: &Ctx,
854        app_id: &str,
855        q: &[(String, String)],
856    ) -> Result<AwsResponse, AwsServiceError> {
857        check_app_id(app_id)?;
858        let guard = self.state.read();
859        let branches: Vec<Value> = guard
860            .get(&ctx.account)
861            .and_then(|d| d.apps.get(app_id))
862            .map(|r| r.branches.values().cloned().collect())
863            .unwrap_or_default();
864        let (page, next) = paginate(branches, q, 50)?;
865        let mut out = json!({ "branches": page });
866        if let Some(n) = next {
867            out["nextToken"] = json!(n);
868        }
869        ok(out)
870    }
871
872    // --------------------------- Domains --------------------------------
873
874    fn build_domain(&self, ctx: &Ctx, app_id: &str, domain: &str, body: &Value) -> Value {
875        let mut d = Map::new();
876        d.insert(
877            "domainAssociationArn".into(),
878            json!(shared::domain_arn(
879                &ctx.region,
880                &ctx.account,
881                app_id,
882                domain
883            )),
884        );
885        d.insert("domainName".into(), json!(domain));
886        d.insert(
887            "enableAutoSubDomain".into(),
888            json!(bool_or(body, "enableAutoSubDomain", false)),
889        );
890        d.insert("domainStatus".into(), json!("CREATING"));
891        d.insert("updateStatus".into(), json!("PENDING_VERIFICATION"));
892        d.insert("statusReason".into(), json!(""));
893        d.insert(
894            "certificateVerificationDNSRecord".into(),
895            json!(shared::certificate_verification_dns_record(domain)),
896        );
897        // Build the subdomains from the requested settings.
898        let sub_settings = body
899            .get("subDomainSettings")
900            .and_then(Value::as_array)
901            .cloned()
902            .unwrap_or_default();
903        let subdomains: Vec<Value> = sub_settings
904            .iter()
905            .map(|s| {
906                json!({
907                    "subDomainSetting": s,
908                    "verified": false,
909                    "dnsRecord": shared::subdomain_dns_record(app_id),
910                })
911            })
912            .collect();
913        d.insert("subDomains".into(), json!(subdomains));
914        // Certificate mirrors the requested certificate settings.
915        if let Some(cert) = build_certificate(body, domain) {
916            d.insert("certificate".into(), cert);
917        }
918        echo(
919            &mut d,
920            body,
921            &["autoSubDomainCreationPatterns", "autoSubDomainIAMRole"],
922        );
923        Value::Object(d)
924    }
925
926    fn create_domain(
927        &self,
928        ctx: &Ctx,
929        app_id: &str,
930        body: &Value,
931    ) -> Result<AwsResponse, AwsServiceError> {
932        check_app_id(app_id)?;
933        let domain = body
934            .get("domainName")
935            .and_then(Value::as_str)
936            .unwrap_or_default()
937            .to_string();
938        let assoc = self.build_domain(ctx, app_id, &domain, body);
939        let mut guard = self.state.write();
940        let data = guard.get_or_create(&ctx.account);
941        let Some(rec) = data.apps.get_mut(app_id) else {
942            return Err(not_found(&format!("App {app_id} not found.")));
943        };
944        if rec.domains.contains_key(&domain) {
945            return Err(bad_request(&format!(
946                "A domain association for {domain} already exists."
947            )));
948        }
949        rec.domains.insert(domain, assoc.clone());
950        ok(json!({ "domainAssociation": assoc }))
951    }
952
953    fn get_domain(
954        &self,
955        ctx: &Ctx,
956        app_id: &str,
957        domain: &str,
958    ) -> Result<AwsResponse, AwsServiceError> {
959        check_app_id(app_id)?;
960        let guard = self.state.read();
961        match guard
962            .get(&ctx.account)
963            .and_then(|d| d.apps.get(app_id))
964            .and_then(|r| r.domains.get(domain))
965        {
966            Some(a) => ok(json!({ "domainAssociation": a })),
967            None => Err(not_found(&format!(
968                "Domain association {domain} not found."
969            ))),
970        }
971    }
972
973    fn update_domain(
974        &self,
975        ctx: &Ctx,
976        app_id: &str,
977        domain: &str,
978        body: &Value,
979    ) -> Result<AwsResponse, AwsServiceError> {
980        check_app_id(app_id)?;
981        let mut guard = self.state.write();
982        let data = guard.get_or_create(&ctx.account);
983        let Some(a) = data
984            .apps
985            .get_mut(app_id)
986            .and_then(|r| r.domains.get_mut(domain))
987            .and_then(Value::as_object_mut)
988        else {
989            return Err(not_found(&format!(
990                "Domain association {domain} not found."
991            )));
992        };
993        if let Some(v) = body.get("enableAutoSubDomain") {
994            a.insert("enableAutoSubDomain".into(), v.clone());
995        }
996        for key in ["autoSubDomainCreationPatterns", "autoSubDomainIAMRole"] {
997            if let Some(v) = body.get(key) {
998                a.insert(key.to_string(), v.clone());
999            }
1000        }
1001        if let Some(settings) = body.get("subDomainSettings").and_then(Value::as_array) {
1002            let subs: Vec<Value> = settings
1003                .iter()
1004                .map(|s| {
1005                    json!({
1006                        "subDomainSetting": s,
1007                        "verified": false,
1008                        "dnsRecord": shared::subdomain_dns_record(app_id),
1009                    })
1010                })
1011                .collect();
1012            a.insert("subDomains".into(), json!(subs));
1013        }
1014        // Persist updated certificateSettings so GetDomainAssociation echoes the
1015        // new certificate (previously dropped, leaving a stale create-time cert).
1016        if let Some(cert) = build_certificate(body, domain) {
1017            a.insert("certificate".into(), cert);
1018        }
1019        // Re-enter verification so the update settles on the next read.
1020        a.insert("domainStatus".into(), json!("UPDATING"));
1021        a.insert("updateStatus".into(), json!("PENDING_VERIFICATION"));
1022        ok(json!({ "domainAssociation": Value::Object(a.clone()) }))
1023    }
1024
1025    fn delete_domain(
1026        &self,
1027        ctx: &Ctx,
1028        app_id: &str,
1029        domain: &str,
1030    ) -> Result<AwsResponse, AwsServiceError> {
1031        check_app_id(app_id)?;
1032        let mut guard = self.state.write();
1033        let data = guard.get_or_create(&ctx.account);
1034        let Some(rec) = data.apps.get_mut(app_id) else {
1035            return Err(not_found(&format!("App {app_id} not found.")));
1036        };
1037        match rec.domains.remove(domain) {
1038            Some(a) => ok(json!({ "domainAssociation": a })),
1039            None => Err(not_found(&format!(
1040                "Domain association {domain} not found."
1041            ))),
1042        }
1043    }
1044
1045    fn list_domains(
1046        &self,
1047        ctx: &Ctx,
1048        app_id: &str,
1049        q: &[(String, String)],
1050    ) -> Result<AwsResponse, AwsServiceError> {
1051        check_app_id(app_id)?;
1052        let guard = self.state.read();
1053        let domains: Vec<Value> = guard
1054            .get(&ctx.account)
1055            .and_then(|d| d.apps.get(app_id))
1056            .map(|r| r.domains.values().cloned().collect())
1057            .unwrap_or_default();
1058        let (page, next) = paginate(domains, q, 50)?;
1059        let mut out = json!({ "domainAssociations": page });
1060        if let Some(n) = next {
1061            out["nextToken"] = json!(n);
1062        }
1063        ok(out)
1064    }
1065
1066    // --------------------------- Webhooks -------------------------------
1067
1068    fn create_webhook(
1069        &self,
1070        ctx: &Ctx,
1071        app_id: &str,
1072        body: &Value,
1073    ) -> Result<AwsResponse, AwsServiceError> {
1074        check_app_id(app_id)?;
1075        let branch = body
1076            .get("branchName")
1077            .and_then(Value::as_str)
1078            .unwrap_or_default()
1079            .to_string();
1080        check_branch_name(&branch)?;
1081        let webhook_id = shared::new_webhook_id();
1082        let now = shared::now_epoch();
1083        let mut guard = self.state.write();
1084        let data = guard.get_or_create(&ctx.account);
1085        let Some(rec) = data.apps.get_mut(app_id) else {
1086            return Err(not_found(&format!("App {app_id} not found.")));
1087        };
1088        if !rec.branches.contains_key(&branch) {
1089            return Err(not_found(&format!("Branch {branch} not found.")));
1090        }
1091        let webhook = json!({
1092            "webhookArn": shared::webhook_arn(&ctx.region, &ctx.account, app_id, &webhook_id),
1093            "webhookId": webhook_id,
1094            "webhookUrl": shared::webhook_url(&ctx.region, &webhook_id),
1095            "appId": app_id,
1096            "branchName": branch,
1097            "description": str_or(body, "description", ""),
1098            "createTime": now,
1099            "updateTime": now,
1100        });
1101        data.webhooks.insert(webhook_id, webhook.clone());
1102        ok(json!({ "webhook": webhook }))
1103    }
1104
1105    fn get_webhook(&self, ctx: &Ctx, webhook_id: &str) -> Result<AwsResponse, AwsServiceError> {
1106        let guard = self.state.read();
1107        match guard
1108            .get(&ctx.account)
1109            .and_then(|d| d.webhooks.get(webhook_id))
1110        {
1111            Some(w) => ok(json!({ "webhook": w })),
1112            None => Err(not_found(&format!("Webhook {webhook_id} not found."))),
1113        }
1114    }
1115
1116    fn update_webhook(
1117        &self,
1118        ctx: &Ctx,
1119        webhook_id: &str,
1120        body: &Value,
1121    ) -> Result<AwsResponse, AwsServiceError> {
1122        let mut guard = self.state.write();
1123        let data = guard.get_or_create(&ctx.account);
1124        let Some(w) = data
1125            .webhooks
1126            .get_mut(webhook_id)
1127            .and_then(Value::as_object_mut)
1128        else {
1129            return Err(not_found(&format!("Webhook {webhook_id} not found.")));
1130        };
1131        if let Some(v) = body.get("branchName") {
1132            w.insert("branchName".into(), v.clone());
1133        }
1134        if let Some(v) = body.get("description") {
1135            w.insert("description".into(), v.clone());
1136        }
1137        w.insert("updateTime".into(), json!(shared::now_epoch()));
1138        ok(json!({ "webhook": Value::Object(w.clone()) }))
1139    }
1140
1141    fn delete_webhook(&self, ctx: &Ctx, webhook_id: &str) -> Result<AwsResponse, AwsServiceError> {
1142        let mut guard = self.state.write();
1143        let data = guard.get_or_create(&ctx.account);
1144        match data.webhooks.remove(webhook_id) {
1145            Some(w) => ok(json!({ "webhook": w })),
1146            None => Err(not_found(&format!("Webhook {webhook_id} not found."))),
1147        }
1148    }
1149
1150    fn list_webhooks(
1151        &self,
1152        ctx: &Ctx,
1153        app_id: &str,
1154        q: &[(String, String)],
1155    ) -> Result<AwsResponse, AwsServiceError> {
1156        check_app_id(app_id)?;
1157        let guard = self.state.read();
1158        let webhooks: Vec<Value> = guard
1159            .get(&ctx.account)
1160            .map(|d| {
1161                d.webhooks
1162                    .values()
1163                    .filter(|w| w.get("appId").and_then(Value::as_str) == Some(app_id))
1164                    .cloned()
1165                    .collect()
1166            })
1167            .unwrap_or_default();
1168        let (page, next) = paginate(webhooks, q, 50)?;
1169        let mut out = json!({ "webhooks": page });
1170        if let Some(n) = next {
1171            out["nextToken"] = json!(n);
1172        }
1173        ok(out)
1174    }
1175
1176    // ---------------------- Backend environments ------------------------
1177
1178    fn create_backend_env(
1179        &self,
1180        ctx: &Ctx,
1181        app_id: &str,
1182        body: &Value,
1183    ) -> Result<AwsResponse, AwsServiceError> {
1184        check_app_id(app_id)?;
1185        let name = body
1186            .get("environmentName")
1187            .and_then(Value::as_str)
1188            .unwrap_or_default()
1189            .to_string();
1190        let now = shared::now_epoch();
1191        let mut env = Map::new();
1192        env.insert(
1193            "backendEnvironmentArn".into(),
1194            json!(shared::backend_environment_arn(
1195                &ctx.region,
1196                &ctx.account,
1197                app_id,
1198                &name
1199            )),
1200        );
1201        env.insert("environmentName".into(), json!(name));
1202        env.insert("createTime".into(), json!(now));
1203        env.insert("updateTime".into(), json!(now));
1204        echo(&mut env, body, &["stackName", "deploymentArtifacts"]);
1205        let env = Value::Object(env);
1206        let mut guard = self.state.write();
1207        let data = guard.get_or_create(&ctx.account);
1208        let Some(rec) = data.apps.get_mut(app_id) else {
1209            return Err(not_found(&format!("App {app_id} not found.")));
1210        };
1211        let name = env["environmentName"]
1212            .as_str()
1213            .unwrap_or_default()
1214            .to_string();
1215        if rec.backend_environments.contains_key(&name) {
1216            return Err(bad_request(&format!(
1217                "A backend environment {name} already exists."
1218            )));
1219        }
1220        rec.backend_environments.insert(name, env.clone());
1221        ok(json!({ "backendEnvironment": env }))
1222    }
1223
1224    fn get_backend_env(
1225        &self,
1226        ctx: &Ctx,
1227        app_id: &str,
1228        name: &str,
1229    ) -> Result<AwsResponse, AwsServiceError> {
1230        check_app_id(app_id)?;
1231        let guard = self.state.read();
1232        match guard
1233            .get(&ctx.account)
1234            .and_then(|d| d.apps.get(app_id))
1235            .and_then(|r| r.backend_environments.get(name))
1236        {
1237            Some(e) => ok(json!({ "backendEnvironment": e })),
1238            None => Err(not_found(&format!("Backend environment {name} not found."))),
1239        }
1240    }
1241
1242    fn delete_backend_env(
1243        &self,
1244        ctx: &Ctx,
1245        app_id: &str,
1246        name: &str,
1247    ) -> Result<AwsResponse, AwsServiceError> {
1248        check_app_id(app_id)?;
1249        let mut guard = self.state.write();
1250        let data = guard.get_or_create(&ctx.account);
1251        let Some(rec) = data.apps.get_mut(app_id) else {
1252            return Err(not_found(&format!("App {app_id} not found.")));
1253        };
1254        match rec.backend_environments.remove(name) {
1255            Some(e) => ok(json!({ "backendEnvironment": e })),
1256            None => Err(not_found(&format!("Backend environment {name} not found."))),
1257        }
1258    }
1259
1260    fn list_backend_envs(
1261        &self,
1262        ctx: &Ctx,
1263        app_id: &str,
1264        q: &[(String, String)],
1265    ) -> Result<AwsResponse, AwsServiceError> {
1266        check_app_id(app_id)?;
1267        let filter = query_one(q, "environmentName");
1268        let guard = self.state.read();
1269        let envs: Vec<Value> = guard
1270            .get(&ctx.account)
1271            .and_then(|d| d.apps.get(app_id))
1272            .map(|r| {
1273                r.backend_environments
1274                    .values()
1275                    .filter(|e| {
1276                        filter.is_none()
1277                            || e.get("environmentName").and_then(Value::as_str) == filter
1278                    })
1279                    .cloned()
1280                    .collect()
1281            })
1282            .unwrap_or_default();
1283        let (page, next) = paginate(envs, q, 50)?;
1284        let mut out = json!({ "backendEnvironments": page });
1285        if let Some(n) = next {
1286            out["nextToken"] = json!(n);
1287        }
1288        ok(out)
1289    }
1290
1291    // ------------------------------ Jobs --------------------------------
1292
1293    /// Build a fresh `JobSummary` in the `PENDING` state.
1294    fn build_job_summary(
1295        &self,
1296        ctx: &Ctx,
1297        app_id: &str,
1298        branch: &str,
1299        job_id: &str,
1300        job_type: &str,
1301        body: &Value,
1302    ) -> Value {
1303        let now = shared::now_epoch();
1304        json!({
1305            "jobArn": shared::job_arn(&ctx.region, &ctx.account, app_id, branch, job_id),
1306            "jobId": job_id,
1307            "commitId": str_or(body, "commitId", "HEAD"),
1308            "commitMessage": str_or(body, "commitMessage", ""),
1309            "commitTime": body.get("commitTime").cloned().unwrap_or_else(|| json!(now)),
1310            "startTime": now,
1311            "status": "PENDING",
1312            "jobType": job_type,
1313        })
1314    }
1315
1316    /// Allocate the next numeric job id for a branch and bump its counter +
1317    /// the branch's `totalNumberOfJobs` / `activeJobId`.
1318    fn allocate_job(rec: &mut AppRecord, branch: &str, summary: &Value) -> String {
1319        let next = rec.next_job_id.entry(branch.to_string()).or_insert(1);
1320        let job_id = next.to_string();
1321        *next += 1;
1322        let job = json!({ "summary": summary, "steps": [] });
1323        rec.jobs
1324            .entry(branch.to_string())
1325            .or_default()
1326            .insert(job_id.clone(), job);
1327        if let Some(b) = rec.branches.get_mut(branch).and_then(Value::as_object_mut) {
1328            b.insert("activeJobId".into(), json!(job_id));
1329            let total = rec
1330                .jobs
1331                .get(branch)
1332                .map(|m| m.len())
1333                .unwrap_or(0)
1334                .to_string();
1335            b.insert("totalNumberOfJobs".into(), json!(total));
1336        }
1337        job_id
1338    }
1339
1340    fn start_job(
1341        &self,
1342        ctx: &Ctx,
1343        app_id: &str,
1344        branch: &str,
1345        body: &Value,
1346    ) -> Result<AwsResponse, AwsServiceError> {
1347        check_app_id(app_id)?;
1348        let job_type = body
1349            .get("jobType")
1350            .and_then(Value::as_str)
1351            .unwrap_or("RELEASE")
1352            .to_string();
1353        let mut guard = self.state.write();
1354        let data = guard.get_or_create(&ctx.account);
1355        let Some(rec) = data.apps.get_mut(app_id) else {
1356            return Err(not_found(&format!("App {app_id} not found.")));
1357        };
1358        if !rec.branches.contains_key(branch) {
1359            return Err(not_found(&format!("Branch {branch} not found.")));
1360        }
1361        // `StartJob` on an existing (e.g. CREATED-from-deployment) job id
1362        // restarts that job; otherwise a fresh id is allocated.
1363        let requested = body.get("jobId").and_then(Value::as_str);
1364        let (job_id, summary) = if let Some(jid) = requested.filter(|j| {
1365            rec.jobs
1366                .get(branch)
1367                .map(|m| m.contains_key(*j))
1368                .unwrap_or(false)
1369        }) {
1370            let jid = jid.to_string();
1371            let summary = self.build_job_summary(ctx, app_id, branch, &jid, &job_type, body);
1372            if let Some(job) = rec
1373                .jobs
1374                .get_mut(branch)
1375                .and_then(|m| m.get_mut(&jid))
1376                .and_then(Value::as_object_mut)
1377            {
1378                job.insert("summary".into(), summary.clone());
1379            }
1380            (jid, summary)
1381        } else {
1382            // Allocate a placeholder id to build the summary, then commit.
1383            let peek = rec
1384                .next_job_id
1385                .get(branch)
1386                .copied()
1387                .unwrap_or(1)
1388                .to_string();
1389            let summary = self.build_job_summary(ctx, app_id, branch, &peek, &job_type, body);
1390            let job_id = Self::allocate_job(rec, branch, &summary);
1391            (job_id, summary)
1392        };
1393        let _ = job_id;
1394        ok(json!({ "jobSummary": summary }))
1395    }
1396
1397    fn get_job(
1398        &self,
1399        ctx: &Ctx,
1400        app_id: &str,
1401        branch: &str,
1402        job_id: &str,
1403    ) -> Result<AwsResponse, AwsServiceError> {
1404        check_app_id(app_id)?;
1405        let guard = self.state.read();
1406        match guard
1407            .get(&ctx.account)
1408            .and_then(|d| d.apps.get(app_id))
1409            .and_then(|r| r.jobs.get(branch))
1410            .and_then(|m| m.get(job_id))
1411        {
1412            Some(job) => ok(json!({ "job": job })),
1413            None => Err(not_found(&format!("Job {job_id} not found."))),
1414        }
1415    }
1416
1417    fn stop_job(
1418        &self,
1419        ctx: &Ctx,
1420        app_id: &str,
1421        branch: &str,
1422        job_id: &str,
1423    ) -> Result<AwsResponse, AwsServiceError> {
1424        check_app_id(app_id)?;
1425        let mut guard = self.state.write();
1426        let data = guard.get_or_create(&ctx.account);
1427        let Some(job) = data
1428            .apps
1429            .get_mut(app_id)
1430            .and_then(|r| r.jobs.get_mut(branch))
1431            .and_then(|m| m.get_mut(job_id))
1432            .and_then(Value::as_object_mut)
1433        else {
1434            return Err(not_found(&format!("Job {job_id} not found.")));
1435        };
1436        let now = shared::now_epoch();
1437        if let Some(summary) = job.get_mut("summary").and_then(Value::as_object_mut) {
1438            summary.insert("status".into(), json!("CANCELLED"));
1439            summary.insert("endTime".into(), json!(now));
1440            let summary = Value::Object(summary.clone());
1441            return ok(json!({ "jobSummary": summary }));
1442        }
1443        Err(not_found(&format!("Job {job_id} not found.")))
1444    }
1445
1446    fn delete_job(
1447        &self,
1448        ctx: &Ctx,
1449        app_id: &str,
1450        branch: &str,
1451        job_id: &str,
1452    ) -> Result<AwsResponse, AwsServiceError> {
1453        check_app_id(app_id)?;
1454        let mut guard = self.state.write();
1455        let data = guard.get_or_create(&ctx.account);
1456        let Some(jobs) = data
1457            .apps
1458            .get_mut(app_id)
1459            .and_then(|r| r.jobs.get_mut(branch))
1460        else {
1461            return Err(not_found(&format!("Job {job_id} not found.")));
1462        };
1463        match jobs.remove(job_id) {
1464            Some(job) => {
1465                let summary = job.get("summary").cloned().unwrap_or_else(|| json!({}));
1466                ok(json!({ "jobSummary": summary }))
1467            }
1468            None => Err(not_found(&format!("Job {job_id} not found."))),
1469        }
1470    }
1471
1472    fn list_jobs(
1473        &self,
1474        ctx: &Ctx,
1475        app_id: &str,
1476        branch: &str,
1477        q: &[(String, String)],
1478    ) -> Result<AwsResponse, AwsServiceError> {
1479        check_app_id(app_id)?;
1480        let guard = self.state.read();
1481        let mut summaries: Vec<Value> = guard
1482            .get(&ctx.account)
1483            .and_then(|d| d.apps.get(app_id))
1484            .and_then(|r| r.jobs.get(branch))
1485            .map(|m| {
1486                m.values()
1487                    .filter_map(|j| j.get("summary").cloned())
1488                    .collect()
1489            })
1490            .unwrap_or_default();
1491        // Newest job first (Amplify orders jobs by descending id).
1492        summaries.reverse();
1493        let (page, next) = paginate(summaries, q, 50)?;
1494        let mut out = json!({ "jobSummaries": page });
1495        if let Some(n) = next {
1496            out["nextToken"] = json!(n);
1497        }
1498        ok(out)
1499    }
1500
1501    // -------------------------- Deployments -----------------------------
1502
1503    fn create_deployment(
1504        &self,
1505        ctx: &Ctx,
1506        app_id: &str,
1507        branch: &str,
1508        body: &Value,
1509    ) -> Result<AwsResponse, AwsServiceError> {
1510        // `CreateDeployment` declares no NotFoundException; it is a presign
1511        // operation that hands back S3 upload URLs. Validate the labels, then
1512        // return upload targets. When the branch exists we reserve a job id in
1513        // the `CREATED` state that a later `StartDeployment` starts.
1514        check_app_id(app_id)?;
1515        check_branch_name(branch)?;
1516        let file_map = body.get("fileMap").and_then(Value::as_object);
1517        let mut uploads = Map::new();
1518        if let Some(fm) = file_map {
1519            for name in fm.keys() {
1520                uploads.insert(
1521                    name.clone(),
1522                    json!(shared::upload_url(&ctx.region, app_id, branch, name)),
1523                );
1524            }
1525        }
1526        let zip_url = shared::upload_url(&ctx.region, app_id, branch, "deployment.zip");
1527
1528        let mut job_id: Option<String> = None;
1529        let mut guard = self.state.write();
1530        let data = guard.get_or_create(&ctx.account);
1531        if let Some(rec) = data.apps.get_mut(app_id) {
1532            if rec.branches.contains_key(branch) {
1533                let next = rec.next_job_id.entry(branch.to_string()).or_insert(1);
1534                let id = next.to_string();
1535                *next += 1;
1536                let now = shared::now_epoch();
1537                let summary = json!({
1538                    "jobArn": shared::job_arn(&ctx.region, &ctx.account, app_id, branch, &id),
1539                    "jobId": id,
1540                    "commitId": "HEAD",
1541                    "commitMessage": "",
1542                    "commitTime": now,
1543                    "startTime": now,
1544                    "status": "CREATED",
1545                    "jobType": "MANUAL",
1546                });
1547                rec.jobs
1548                    .entry(branch.to_string())
1549                    .or_default()
1550                    .insert(id.clone(), json!({ "summary": summary, "steps": [] }));
1551                job_id = Some(id);
1552            }
1553        }
1554        let mut out = json!({
1555            "fileUploadUrls": Value::Object(uploads),
1556            "zipUploadUrl": zip_url,
1557        });
1558        if let Some(id) = job_id {
1559            out["jobId"] = json!(id);
1560        }
1561        ok(out)
1562    }
1563
1564    fn start_deployment(
1565        &self,
1566        ctx: &Ctx,
1567        app_id: &str,
1568        branch: &str,
1569        body: &Value,
1570    ) -> Result<AwsResponse, AwsServiceError> {
1571        check_app_id(app_id)?;
1572        let mut guard = self.state.write();
1573        let data = guard.get_or_create(&ctx.account);
1574        let Some(rec) = data.apps.get_mut(app_id) else {
1575            return Err(not_found(&format!("App {app_id} not found.")));
1576        };
1577        if !rec.branches.contains_key(branch) {
1578            return Err(not_found(&format!("Branch {branch} not found.")));
1579        }
1580        let now = shared::now_epoch();
1581        let requested = body
1582            .get("jobId")
1583            .and_then(Value::as_str)
1584            .map(str::to_string);
1585        let (job_id, summary) = match requested.filter(|j| {
1586            rec.jobs
1587                .get(branch)
1588                .map(|m| m.contains_key(j))
1589                .unwrap_or(false)
1590        }) {
1591            Some(jid) => {
1592                let summary = json!({
1593                    "jobArn": shared::job_arn(&ctx.region, &ctx.account, app_id, branch, &jid),
1594                    "jobId": jid,
1595                    "commitId": "HEAD",
1596                    "commitMessage": "",
1597                    "commitTime": now,
1598                    "startTime": now,
1599                    "status": "PENDING",
1600                    "jobType": "MANUAL",
1601                });
1602                if let Some(job) = rec
1603                    .jobs
1604                    .get_mut(branch)
1605                    .and_then(|m| m.get_mut(&jid))
1606                    .and_then(Value::as_object_mut)
1607                {
1608                    job.insert("summary".into(), summary.clone());
1609                }
1610                (jid, summary)
1611            }
1612            None => {
1613                let peek = rec
1614                    .next_job_id
1615                    .get(branch)
1616                    .copied()
1617                    .unwrap_or(1)
1618                    .to_string();
1619                let summary = json!({
1620                    "jobArn": shared::job_arn(&ctx.region, &ctx.account, app_id, branch, &peek),
1621                    "jobId": peek,
1622                    "commitId": "HEAD",
1623                    "commitMessage": "",
1624                    "commitTime": now,
1625                    "startTime": now,
1626                    "status": "PENDING",
1627                    "jobType": "MANUAL",
1628                });
1629                let job_id = Self::allocate_job(rec, branch, &summary);
1630                (job_id, summary)
1631            }
1632        };
1633        let _ = job_id;
1634        ok(json!({ "jobSummary": summary }))
1635    }
1636
1637    // -------------------------- Artifacts -------------------------------
1638
1639    /// The deterministic artifact set for a job. A settled (`SUCCEED`) build
1640    /// exposes its build log; other states expose nothing yet.
1641    fn artifacts_for_job(app_id: &str, branch: &str, job_id: &str, job: &Value) -> Vec<Value> {
1642        let status = job
1643            .get("summary")
1644            .and_then(|s| s.get("status"))
1645            .and_then(Value::as_str)
1646            .unwrap_or("");
1647        if status != "SUCCEED" {
1648            return Vec::new();
1649        }
1650        vec![json!({
1651            "artifactFileName": "buildLogs.txt",
1652            "artifactId": shared::encode_artifact_id(app_id, branch, job_id, "buildLogs.txt"),
1653        })]
1654    }
1655
1656    fn list_artifacts(
1657        &self,
1658        ctx: &Ctx,
1659        app_id: &str,
1660        branch: &str,
1661        job_id: &str,
1662        q: &[(String, String)],
1663    ) -> Result<AwsResponse, AwsServiceError> {
1664        check_app_id(app_id)?;
1665        let guard = self.state.read();
1666        let artifacts: Vec<Value> = guard
1667            .get(&ctx.account)
1668            .and_then(|d| d.apps.get(app_id))
1669            .and_then(|r| r.jobs.get(branch))
1670            .and_then(|m| m.get(job_id))
1671            .map(|job| Self::artifacts_for_job(app_id, branch, job_id, job))
1672            .unwrap_or_default();
1673        let (page, next) = paginate(artifacts, q, 50)?;
1674        let mut out = json!({ "artifacts": page });
1675        if let Some(n) = next {
1676            out["nextToken"] = json!(n);
1677        }
1678        ok(out)
1679    }
1680
1681    fn get_artifact_url(
1682        &self,
1683        ctx: &Ctx,
1684        artifact_id: &str,
1685    ) -> Result<AwsResponse, AwsServiceError> {
1686        match shared::decode_artifact_id(artifact_id) {
1687            Some((app_id, branch, job_id, file)) => ok(json!({
1688                "artifactId": artifact_id,
1689                "artifactUrl": shared::artifact_url(&ctx.region, &app_id, &branch, &job_id, &file),
1690            })),
1691            None => Err(not_found(&format!("Artifact {artifact_id} not found."))),
1692        }
1693    }
1694
1695    // ------------------------ Access logs -------------------------------
1696
1697    fn generate_access_logs(
1698        &self,
1699        ctx: &Ctx,
1700        app_id: &str,
1701        body: &Value,
1702    ) -> Result<AwsResponse, AwsServiceError> {
1703        check_app_id(app_id)?;
1704        let domain = body
1705            .get("domainName")
1706            .and_then(Value::as_str)
1707            .ok_or_else(|| bad_request("domainName is required."))?
1708            .to_string();
1709        let guard = self.state.read();
1710        let exists = guard
1711            .get(&ctx.account)
1712            .map(|d| d.apps.contains_key(app_id))
1713            .unwrap_or(false);
1714        drop(guard);
1715        if !exists {
1716            return Err(not_found(&format!("App {app_id} not found.")));
1717        }
1718        ok(json!({
1719            "logUrl": shared::access_log_url(&ctx.region, app_id, &domain),
1720        }))
1721    }
1722
1723    // ------------------------------ Tags --------------------------------
1724
1725    /// Resolve a `ResourceArn` label to a mutable tag-carrying resource (an app
1726    /// or a branch), rejecting a malformed arn with `BadRequestException` and a
1727    /// missing resource with `ResourceNotFoundException`.
1728    fn tags_target_mut<'a>(
1729        data: &'a mut AmplifyData,
1730        arn: &str,
1731    ) -> Result<&'a mut Value, AwsServiceError> {
1732        if !arn.starts_with("arn:aws:amplify:") {
1733            return Err(bad_request(&format!(
1734                "Resource arn '{arn}' does not match required pattern ^arn:aws:amplify:."
1735            )));
1736        }
1737        let (app_id, branch) = shared::parse_resource_arn(arn)
1738            .ok_or_else(|| resource_not_found(&format!("Resource {arn} not found.")))?;
1739        let rec = data
1740            .apps
1741            .get_mut(&app_id)
1742            .ok_or_else(|| resource_not_found(&format!("Resource {arn} not found.")))?;
1743        match branch {
1744            Some(br) => rec
1745                .branches
1746                .get_mut(&br)
1747                .ok_or_else(|| resource_not_found(&format!("Resource {arn} not found."))),
1748            None => Ok(&mut rec.app),
1749        }
1750    }
1751
1752    fn tag_resource(
1753        &self,
1754        ctx: &Ctx,
1755        arn: &str,
1756        body: &Value,
1757    ) -> Result<AwsResponse, AwsServiceError> {
1758        let mut guard = self.state.write();
1759        let data = guard.get_or_create(&ctx.account);
1760        let target = Self::tags_target_mut(data, arn)?;
1761        if let Some(obj) = target.as_object_mut() {
1762            let tags = obj.entry("tags").or_insert_with(|| json!({}));
1763            if let (Some(tags), Some(new)) = (
1764                tags.as_object_mut(),
1765                body.get("tags").and_then(Value::as_object),
1766            ) {
1767                for (k, v) in new {
1768                    tags.insert(k.clone(), v.clone());
1769                }
1770            }
1771        }
1772        ok(json!({}))
1773    }
1774
1775    fn untag_resource(
1776        &self,
1777        ctx: &Ctx,
1778        arn: &str,
1779        q: &[(String, String)],
1780    ) -> Result<AwsResponse, AwsServiceError> {
1781        let keys = query_all(q, "tagKeys");
1782        if keys.is_empty() {
1783            return Err(bad_request("tagKeys is required."));
1784        }
1785        let mut guard = self.state.write();
1786        let data = guard.get_or_create(&ctx.account);
1787        let target = Self::tags_target_mut(data, arn)?;
1788        if let Some(tags) = target
1789            .as_object_mut()
1790            .and_then(|o| o.get_mut("tags"))
1791            .and_then(Value::as_object_mut)
1792        {
1793            for k in &keys {
1794                tags.remove(k);
1795            }
1796        }
1797        ok(json!({}))
1798    }
1799
1800    fn list_tags(&self, ctx: &Ctx, arn: &str) -> Result<AwsResponse, AwsServiceError> {
1801        if !arn.starts_with("arn:aws:amplify:") {
1802            return Err(bad_request(&format!(
1803                "Resource arn '{arn}' does not match required pattern ^arn:aws:amplify:."
1804            )));
1805        }
1806        let (app_id, branch) = shared::parse_resource_arn(arn)
1807            .ok_or_else(|| resource_not_found(&format!("Resource {arn} not found.")))?;
1808        let guard = self.state.read();
1809        let rec = guard
1810            .get(&ctx.account)
1811            .and_then(|d| d.apps.get(&app_id))
1812            .ok_or_else(|| resource_not_found(&format!("Resource {arn} not found.")))?;
1813        let obj = match &branch {
1814            Some(br) => rec
1815                .branches
1816                .get(br)
1817                .ok_or_else(|| resource_not_found(&format!("Resource {arn} not found.")))?,
1818            None => &rec.app,
1819        };
1820        let tags = obj.get("tags").cloned().unwrap_or_else(|| json!({}));
1821        ok(json!({ "tags": tags }))
1822    }
1823}
1824
1825#[cfg(test)]
1826mod tests {
1827    use super::*;
1828    use fakecloud_core::multi_account::MultiAccountState;
1829    use parking_lot::RwLock;
1830
1831    fn svc() -> AmplifyService {
1832        AmplifyService::new(Arc::new(RwLock::new(MultiAccountState::new(
1833            "000000000000",
1834            "us-east-1",
1835            "",
1836        ))))
1837    }
1838
1839    fn ctx() -> Ctx {
1840        Ctx {
1841            account: "000000000000".into(),
1842            region: "us-east-1".into(),
1843        }
1844    }
1845
1846    fn body_json(resp: &AwsResponse) -> Value {
1847        serde_json::from_slice(resp.body.expect_bytes()).expect("json response body")
1848    }
1849
1850    fn err_of(r: Result<AwsResponse, AwsServiceError>) -> AwsServiceError {
1851        match r {
1852            Ok(_) => panic!("expected an error"),
1853            Err(e) => e,
1854        }
1855    }
1856
1857    fn make_app(s: &AmplifyService, c: &Ctx) -> String {
1858        let created = s
1859            .create_app(c, &json!({ "name": "my-app", "platform": "WEB" }))
1860            .unwrap();
1861        let body = body_json(&created);
1862        body["app"]["appId"].as_str().unwrap().to_string()
1863    }
1864
1865    #[test]
1866    fn update_domain_persists_certificate_settings() {
1867        // UpdateDomainAssociation dropped certificateSettings; the new cert must
1868        // be echoed by GetDomainAssociation (bug-hunt).
1869        let s = svc();
1870        let c = ctx();
1871        let app_id = make_app(&s, &c);
1872        s.create_domain(
1873            &c,
1874            &app_id,
1875            &json!({
1876                "domainName": "example.com",
1877                "subDomainSettings": [{ "prefix": "www", "branchName": "main" }],
1878                "certificateSettings": { "type": "AMPLIFY_MANAGED" }
1879            }),
1880        )
1881        .unwrap();
1882
1883        // Switch to a CUSTOM certificate.
1884        let arn = "arn:aws:acm:us-east-1:000000000000:certificate/abc";
1885        s.update_domain(
1886            &c,
1887            &app_id,
1888            "example.com",
1889            &json!({
1890                "certificateSettings": { "type": "CUSTOM", "customCertificateArn": arn }
1891            }),
1892        )
1893        .unwrap();
1894
1895        let dom = body_json(&s.get_domain(&c, &app_id, "example.com").unwrap());
1896        let cert = &dom["domainAssociation"]["certificate"];
1897        assert_eq!(cert["type"], "CUSTOM");
1898        assert_eq!(cert["customCertificateArn"], arn);
1899    }
1900
1901    #[test]
1902    fn create_app_returns_full_shape() {
1903        let s = svc();
1904        let created = s.create_app(&ctx(), &json!({ "name": "hello" })).unwrap();
1905        let app = &body_json(&created)["app"];
1906        for field in [
1907            "appId",
1908            "appArn",
1909            "name",
1910            "description",
1911            "repository",
1912            "platform",
1913            "createTime",
1914            "updateTime",
1915            "environmentVariables",
1916            "defaultDomain",
1917            "enableBranchAutoBuild",
1918            "enableBasicAuth",
1919        ] {
1920            assert!(app.get(field).is_some(), "missing {field}");
1921        }
1922        assert!(app["appId"].as_str().unwrap().starts_with('d'));
1923        assert!(app["appArn"]
1924            .as_str()
1925            .unwrap()
1926            .starts_with("arn:aws:amplify:us-east-1:000000000000:apps/"));
1927    }
1928
1929    #[test]
1930    fn create_then_get_list_delete_app() {
1931        let s = svc();
1932        let c = ctx();
1933        let app_id = make_app(&s, &c);
1934        let got = body_json(&s.get_app(&c, &app_id).unwrap());
1935        assert_eq!(got["app"]["name"], json!("my-app"));
1936        let listed = body_json(&s.list_apps(&c, &[]).unwrap());
1937        assert_eq!(listed["apps"].as_array().unwrap().len(), 1);
1938        s.delete_app(&c, &app_id).unwrap();
1939        assert_eq!(
1940            err_of(s.get_app(&c, &app_id)).status(),
1941            StatusCode::NOT_FOUND
1942        );
1943    }
1944
1945    #[test]
1946    fn get_app_malformed_id_is_bad_request() {
1947        let s = svc();
1948        assert_eq!(
1949            err_of(s.get_app(&ctx(), "not-an-app-id")).status(),
1950            StatusCode::BAD_REQUEST
1951        );
1952    }
1953
1954    #[test]
1955    fn create_branch_requires_existing_app() {
1956        let s = svc();
1957        let err =
1958            err_of(s.create_branch(&ctx(), "d0000000000000", &json!({ "branchName": "main" })));
1959        assert_eq!(err.status(), StatusCode::NOT_FOUND);
1960    }
1961
1962    #[test]
1963    fn branch_round_trip_and_domain() {
1964        let s = svc();
1965        let c = ctx();
1966        let app_id = make_app(&s, &c);
1967        let branch = body_json(
1968            &s.create_branch(
1969                &c,
1970                &app_id,
1971                &json!({ "branchName": "main", "stage": "PRODUCTION" }),
1972            )
1973            .unwrap(),
1974        );
1975        assert_eq!(branch["branch"]["stage"], json!("PRODUCTION"));
1976        let got = body_json(&s.get_branch(&c, &app_id, "main").unwrap());
1977        assert_eq!(got["branch"]["branchName"], json!("main"));
1978        let listed = body_json(&s.list_branches(&c, &app_id, &[]).unwrap());
1979        assert_eq!(listed["branches"].as_array().unwrap().len(), 1);
1980
1981        // Domain association lifecycle.
1982        let dom = body_json(
1983            &s.create_domain(
1984                &c,
1985                &app_id,
1986                &json!({
1987                    "domainName": "example.com",
1988                    "subDomainSettings": [{ "prefix": "www", "branchName": "main" }],
1989                }),
1990            )
1991            .unwrap(),
1992        );
1993        assert_eq!(dom["domainAssociation"]["domainStatus"], json!("CREATING"));
1994        // Reconcile settles it to AVAILABLE.
1995        assert!(s.reconcile(&c.account));
1996        let dom = body_json(&s.get_domain(&c, &app_id, "example.com").unwrap());
1997        assert_eq!(dom["domainAssociation"]["domainStatus"], json!("AVAILABLE"));
1998    }
1999
2000    #[test]
2001    fn job_lifecycle_and_artifacts() {
2002        let s = svc();
2003        let c = ctx();
2004        let app_id = make_app(&s, &c);
2005        s.create_branch(&c, &app_id, &json!({ "branchName": "main" }))
2006            .unwrap();
2007        let started = body_json(
2008            &s.start_job(&c, &app_id, "main", &json!({ "jobType": "RELEASE" }))
2009                .unwrap(),
2010        );
2011        let job_id = started["jobSummary"]["jobId"].as_str().unwrap().to_string();
2012        assert_eq!(started["jobSummary"]["status"], json!("PENDING"));
2013        // Walk the deterministic build to completion.
2014        for _ in 0..4 {
2015            s.reconcile(&c.account);
2016        }
2017        let job = body_json(&s.get_job(&c, &app_id, "main", &job_id).unwrap());
2018        assert_eq!(job["job"]["summary"]["status"], json!("SUCCEED"));
2019        assert_eq!(job["job"]["steps"][0]["stepName"], json!("BUILD"));
2020        // Artifacts show up on a settled build.
2021        let arts = body_json(&s.list_artifacts(&c, &app_id, "main", &job_id, &[]).unwrap());
2022        let art = &arts["artifacts"][0];
2023        assert_eq!(art["artifactFileName"], json!("buildLogs.txt"));
2024        let art_id = art["artifactId"].as_str().unwrap();
2025        let url = body_json(&s.get_artifact_url(&c, art_id).unwrap());
2026        assert!(url["artifactUrl"].as_str().unwrap().starts_with("https://"));
2027    }
2028
2029    #[test]
2030    fn stop_job_cancels() {
2031        let s = svc();
2032        let c = ctx();
2033        let app_id = make_app(&s, &c);
2034        s.create_branch(&c, &app_id, &json!({ "branchName": "main" }))
2035            .unwrap();
2036        let started = body_json(
2037            &s.start_job(&c, &app_id, "main", &json!({ "jobType": "MANUAL" }))
2038                .unwrap(),
2039        );
2040        let job_id = started["jobSummary"]["jobId"].as_str().unwrap().to_string();
2041        let stopped = body_json(&s.stop_job(&c, &app_id, "main", &job_id).unwrap());
2042        assert_eq!(stopped["jobSummary"]["status"], json!("CANCELLED"));
2043    }
2044
2045    #[test]
2046    fn webhook_round_trip() {
2047        let s = svc();
2048        let c = ctx();
2049        let app_id = make_app(&s, &c);
2050        s.create_branch(&c, &app_id, &json!({ "branchName": "main" }))
2051            .unwrap();
2052        let wh = body_json(
2053            &s.create_webhook(
2054                &c,
2055                &app_id,
2056                &json!({ "branchName": "main", "description": "hook" }),
2057            )
2058            .unwrap(),
2059        );
2060        let id = wh["webhook"]["webhookId"].as_str().unwrap().to_string();
2061        let got = body_json(&s.get_webhook(&c, &id).unwrap());
2062        assert_eq!(got["webhook"]["branchName"], json!("main"));
2063        let listed = body_json(&s.list_webhooks(&c, &app_id, &[]).unwrap());
2064        assert_eq!(listed["webhooks"].as_array().unwrap().len(), 1);
2065        s.delete_webhook(&c, &id).unwrap();
2066        assert_eq!(
2067            err_of(s.get_webhook(&c, &id)).status(),
2068            StatusCode::NOT_FOUND
2069        );
2070    }
2071
2072    #[test]
2073    fn backend_environment_round_trip() {
2074        let s = svc();
2075        let c = ctx();
2076        let app_id = make_app(&s, &c);
2077        let env = body_json(
2078            &s.create_backend_env(&c, &app_id, &json!({ "environmentName": "staging" }))
2079                .unwrap(),
2080        );
2081        assert_eq!(
2082            env["backendEnvironment"]["environmentName"],
2083            json!("staging")
2084        );
2085        let got = body_json(&s.get_backend_env(&c, &app_id, "staging").unwrap());
2086        assert_eq!(
2087            got["backendEnvironment"]["environmentName"],
2088            json!("staging")
2089        );
2090        let listed = body_json(&s.list_backend_envs(&c, &app_id, &[]).unwrap());
2091        assert_eq!(listed["backendEnvironments"].as_array().unwrap().len(), 1);
2092    }
2093
2094    #[test]
2095    fn tag_untag_by_arn() {
2096        let s = svc();
2097        let c = ctx();
2098        let app_id = make_app(&s, &c);
2099        let arn = shared::app_arn(&c.region, &c.account, &app_id);
2100        s.tag_resource(&c, &arn, &json!({ "tags": { "team": "web" } }))
2101            .unwrap();
2102        let listed = body_json(&s.list_tags(&c, &arn).unwrap());
2103        assert_eq!(listed["tags"]["team"], json!("web"));
2104        s.untag_resource(&c, &arn, &[("tagKeys".into(), "team".into())])
2105            .unwrap();
2106        let listed = body_json(&s.list_tags(&c, &arn).unwrap());
2107        assert_eq!(listed["tags"].as_object().unwrap().len(), 0);
2108    }
2109
2110    #[test]
2111    fn list_tags_unknown_resource_is_not_found() {
2112        let s = svc();
2113        let arn = "arn:aws:amplify:us-east-1:000000000000:apps/d0000000000000";
2114        assert_eq!(
2115            err_of(s.list_tags(&ctx(), arn)).status(),
2116            StatusCode::NOT_FOUND
2117        );
2118    }
2119
2120    #[test]
2121    fn list_maxresults_out_of_range_is_bad_request() {
2122        let s = svc();
2123        let c = ctx();
2124        let q = vec![("maxResults".to_string(), "999".to_string())];
2125        assert_eq!(
2126            err_of(s.list_apps(&c, &q)).status(),
2127            StatusCode::BAD_REQUEST
2128        );
2129    }
2130
2131    #[test]
2132    fn create_deployment_returns_upload_urls() {
2133        let s = svc();
2134        let c = ctx();
2135        let app_id = make_app(&s, &c);
2136        s.create_branch(&c, &app_id, &json!({ "branchName": "main" }))
2137            .unwrap();
2138        let dep = body_json(
2139            &s.create_deployment(
2140                &c,
2141                &app_id,
2142                "main",
2143                &json!({ "fileMap": { "index.html": "abc" } }),
2144            )
2145            .unwrap(),
2146        );
2147        assert!(dep["zipUploadUrl"]
2148            .as_str()
2149            .unwrap()
2150            .starts_with("https://"));
2151        assert!(dep["fileUploadUrls"]["index.html"]
2152            .as_str()
2153            .unwrap()
2154            .starts_with("https://"));
2155        assert!(dep["jobId"].is_string());
2156    }
2157
2158    #[test]
2159    fn routing_covers_every_action() {
2160        // Sanity: the action table and router agree on the operation count.
2161        assert_eq!(AMPLIFY_ACTIONS.len(), 37);
2162    }
2163}