Skip to main content

fakecloud_support/
service.rs

1//! AWS Support awsJson1_1 dispatch + operation handlers.
2//!
3//! Requests carry `X-Amz-Target: AWSSupport_20130415.<Operation>`; dispatch
4//! keys off `req.action`. Every operation runs model-driven input validation
5//! first (required / length / range / enum / pattern), then real,
6//! account-partitioned, persisted CRUD. Dereferencing a case that does not
7//! exist returns Support's canonical `CaseIdNotFound`; an unknown attachment id
8//! returns `AttachmentIdNotFound`, and an unknown attachment set id returns
9//! `AttachmentSetIdNotFound`.
10//!
11//! Honest gap: fakecloud runs no Trusted Advisor analysis engine and attaches
12//! no live support agent. `DescribeTrustedAdvisorCheckResult` /
13//! `DescribeTrustedAdvisorCheckSummaries` return well-formed all-clear result
14//! shapes (zero flagged resources) rather than fabricating findings, and no
15//! automated agent reply is generated. Cases, communications, attachment sets,
16//! severity levels, the check catalogue, and the refresh state machine are all
17//! real.
18
19use std::sync::Arc;
20
21use async_trait::async_trait;
22use http::StatusCode;
23use serde_json::{json, Value};
24use tokio::sync::Mutex as AsyncMutex;
25
26use fakecloud_core::service::{AwsRequest, AwsResponse, AwsService, AwsServiceError};
27use fakecloud_persistence::SnapshotStore;
28
29use crate::catalog;
30use crate::shared::{
31    current_year, display_id, iso_now, new_attachment_id, new_attachment_set_id, new_case_id,
32    str_member,
33};
34use crate::state::{SharedSupportState, SupportData};
35
36/// Every operation name in the AWS Support Smithy model (16 operations).
37pub const SUPPORT_ACTIONS: &[&str] = &[
38    "AddAttachmentsToSet",
39    "AddCommunicationToCase",
40    "CreateCase",
41    "DescribeAttachment",
42    "DescribeCases",
43    "DescribeCommunications",
44    "DescribeCreateCaseOptions",
45    "DescribeServices",
46    "DescribeSeverityLevels",
47    "DescribeSupportedLanguages",
48    "DescribeTrustedAdvisorCheckRefreshStatuses",
49    "DescribeTrustedAdvisorCheckResult",
50    "DescribeTrustedAdvisorCheckSummaries",
51    "DescribeTrustedAdvisorChecks",
52    "RefreshTrustedAdvisorCheck",
53    "ResolveCase",
54];
55
56/// Read-only verbs; any other action mutates persisted state and triggers a
57/// snapshot after success. The inverse formulation guarantees no mutation is
58/// ever missed if a new op is added. `DescribeTrustedAdvisorCheckRefreshStatuses`
59/// advances the refresh state machine in memory but is intentionally treated as
60/// read-only (the transition is re-derived on the next read, so persisting it is
61/// not required).
62fn is_mutating_action(action: &str) -> bool {
63    !action.starts_with("Describe")
64}
65
66pub struct SupportService {
67    state: SharedSupportState,
68    snapshot_store: Option<Arc<dyn SnapshotStore>>,
69    snapshot_lock: Arc<AsyncMutex<()>>,
70}
71
72impl SupportService {
73    pub fn new(state: SharedSupportState) -> Self {
74        Self {
75            state,
76            snapshot_store: None,
77            snapshot_lock: Arc::new(AsyncMutex::new(())),
78        }
79    }
80
81    pub fn with_snapshot_store(mut self, store: Arc<dyn SnapshotStore>) -> Self {
82        self.snapshot_store = Some(store);
83        self
84    }
85
86    async fn save(&self) {
87        crate::persistence::save_snapshot(
88            &self.state,
89            self.snapshot_store.clone(),
90            &self.snapshot_lock,
91        )
92        .await;
93    }
94
95    /// Persist hook for the CloudFormation provisioner; `None` in memory mode.
96    pub fn snapshot_hook(&self) -> Option<fakecloud_persistence::SnapshotHook> {
97        let store = self.snapshot_store.clone()?;
98        let state = self.state.clone();
99        let lock = self.snapshot_lock.clone();
100        Some(Arc::new(move || {
101            let state = state.clone();
102            let store = store.clone();
103            let lock = lock.clone();
104            Box::pin(async move {
105                crate::persistence::save_snapshot(&state, Some(store), &lock).await;
106            })
107        }))
108    }
109
110    /// Run `f` against this account's mutable state.
111    fn with_account_mut<R>(&self, req: &AwsRequest, f: impl FnOnce(&mut SupportData) -> R) -> R {
112        let mut guard = self.state.write();
113        let acct = guard.get_or_create(&req.account_id);
114        f(acct)
115    }
116}
117
118#[async_trait]
119impl AwsService for SupportService {
120    fn service_name(&self) -> &str {
121        "support"
122    }
123
124    async fn handle(&self, req: AwsRequest) -> Result<AwsResponse, AwsServiceError> {
125        let action = req.action.clone();
126        if let Err(msg) = crate::validate::validate_input(&action, &req.json_body()) {
127            return Err(validation_error(msg));
128        }
129        let result = self.dispatch(&action, &req);
130        if is_mutating_action(&action)
131            && matches!(result.as_ref(), Ok(resp) if resp.status.is_success())
132        {
133            self.save().await;
134        }
135        result
136    }
137
138    fn supported_actions(&self) -> &[&str] {
139        SUPPORT_ACTIONS
140    }
141}
142
143impl SupportService {
144    fn dispatch(&self, action: &str, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
145        let body = req.json_body();
146        match action {
147            // Support Cases
148            "CreateCase" => self.create_case(req, &body),
149            "DescribeCases" => self.describe_cases(req, &body),
150            "DescribeCommunications" => self.describe_communications(req, &body),
151            "AddCommunicationToCase" => self.add_communication_to_case(req, &body),
152            "ResolveCase" => self.resolve_case(req, &body),
153            "AddAttachmentsToSet" => self.add_attachments_to_set(req, &body),
154            "DescribeAttachment" => self.describe_attachment(req, &body),
155            // Trusted Advisor
156            "DescribeTrustedAdvisorChecks" => self.describe_ta_checks(&body),
157            "DescribeTrustedAdvisorCheckResult" => self.describe_ta_check_result(&body),
158            "DescribeTrustedAdvisorCheckSummaries" => self.describe_ta_check_summaries(&body),
159            "DescribeTrustedAdvisorCheckRefreshStatuses" => {
160                self.describe_ta_refresh_statuses(req, &body)
161            }
162            "RefreshTrustedAdvisorCheck" => self.refresh_ta_check(req, &body),
163            // Severity levels + case-creation reference data
164            "DescribeSeverityLevels" => self.describe_severity_levels(&body),
165            "DescribeServices" => self.describe_services(&body),
166            "DescribeCreateCaseOptions" => self.describe_create_case_options(&body),
167            "DescribeSupportedLanguages" => self.describe_supported_languages(&body),
168            _ => Err(AwsServiceError::action_not_implemented(
169                self.service_name(),
170                action,
171            )),
172        }
173    }
174
175    // ---- Support Cases ----------------------------------------------------
176
177    fn create_case(&self, req: &AwsRequest, body: &Value) -> Result<AwsResponse, AwsServiceError> {
178        let account = req.account_id.clone();
179        let subject = str_member(body, "subject").unwrap_or_default().to_string();
180        let comm_body = str_member(body, "communicationBody")
181            .unwrap_or_default()
182            .to_string();
183        let service_code = str_member(body, "serviceCode")
184            .unwrap_or("general-info-and-getting-started")
185            .to_string();
186        let severity_code = str_member(body, "severityCode")
187            .unwrap_or("normal")
188            .to_string();
189        let category_code = str_member(body, "categoryCode")
190            .unwrap_or("other")
191            .to_string();
192        let language = str_member(body, "language").unwrap_or("en").to_string();
193        let cc = body
194            .get("ccEmailAddresses")
195            .cloned()
196            .unwrap_or_else(|| json!([]));
197        let attachment_set_id = str_member(body, "attachmentSetId").map(str::to_string);
198        let submitted_by = format!("arn:aws:iam::{account}:root");
199        let now = iso_now();
200
201        self.with_account_mut(req, |d| {
202            // Resolve the attachment set for the initial communication, if any.
203            let attachment_set = match &attachment_set_id {
204                Some(id) => {
205                    let set = d
206                        .attachment_sets
207                        .get(id)
208                        .ok_or_else(|| attachment_set_id_not_found(id))?;
209                    attachment_set_summary(d, set)
210                }
211                None => json!([]),
212            };
213
214            let case_id = new_case_id(&account, current_year());
215            let disp = display_id(&case_id);
216            let case = json!({
217                "caseId": case_id,
218                "displayId": disp,
219                "subject": subject,
220                "status": "opened",
221                "serviceCode": service_code,
222                "categoryCode": category_code,
223                "severityCode": severity_code,
224                "submittedBy": submitted_by,
225                "timeCreated": now,
226                "ccEmailAddresses": cc,
227                "language": language,
228            });
229            let comm = json!({
230                "caseId": case_id,
231                "body": comm_body,
232                "submittedBy": submitted_by,
233                "timeCreated": now,
234                "attachmentSet": attachment_set,
235            });
236            d.cases.insert(case_id.clone(), case);
237            d.communications.insert(case_id.clone(), vec![comm]);
238            Ok(ok(json!({ "caseId": case_id })))
239        })
240    }
241
242    fn describe_cases(
243        &self,
244        req: &AwsRequest,
245        body: &Value,
246    ) -> Result<AwsResponse, AwsServiceError> {
247        let case_id_list: Option<Vec<String>> =
248            body.get("caseIdList").and_then(Value::as_array).map(|a| {
249                a.iter()
250                    .filter_map(|v| v.as_str().map(String::from))
251                    .collect()
252            });
253        let display_id_filter = str_member(body, "displayId").map(str::to_string);
254        let after = str_member(body, "afterTime").map(str::to_string);
255        let before = str_member(body, "beforeTime").map(str::to_string);
256        let include_resolved = body
257            .get("includeResolvedCases")
258            .and_then(Value::as_bool)
259            .unwrap_or(false);
260        let include_comms = body
261            .get("includeCommunications")
262            .and_then(Value::as_bool)
263            .unwrap_or(true);
264        let max = body
265            .get("maxResults")
266            .and_then(Value::as_u64)
267            .map(|n| n as usize);
268        let start = decode_token(str_member(body, "nextToken"));
269
270        self.with_account_mut(req, |d| {
271            // Collect the candidate case ids in a deterministic order.
272            let mut selected: Vec<Value> = Vec::new();
273            if let Some(ids) = &case_id_list {
274                for id in ids {
275                    let case = d.cases.get(id).ok_or_else(|| case_id_not_found(id))?;
276                    selected.push(case.clone());
277                }
278            } else {
279                for case in d.cases.values() {
280                    selected.push(case.clone());
281                }
282            }
283
284            // Apply the non-id filters.
285            selected.retain(|c| {
286                let status = c.get("status").and_then(Value::as_str).unwrap_or("");
287                if !include_resolved && status == "resolved" {
288                    return false;
289                }
290                if let Some(disp) = &display_id_filter {
291                    if c.get("displayId").and_then(Value::as_str) != Some(disp.as_str()) {
292                        return false;
293                    }
294                }
295                let created = c.get("timeCreated").and_then(Value::as_str).unwrap_or("");
296                if let Some(a) = &after {
297                    if created < a.as_str() {
298                        return false;
299                    }
300                }
301                if let Some(b) = &before {
302                    if created > b.as_str() {
303                        return false;
304                    }
305                }
306                true
307            });
308
309            // Paginate.
310            let (page, next) = paginate(&selected, start, max);
311            let cases: Vec<Value> = page
312                .iter()
313                .map(|c| {
314                    let mut c = c.clone();
315                    let cid = c
316                        .get("caseId")
317                        .and_then(Value::as_str)
318                        .unwrap_or("")
319                        .to_string();
320                    if include_comms {
321                        let comms = d.communications.get(&cid).cloned().unwrap_or_default();
322                        c.as_object_mut().unwrap().insert(
323                            "recentCommunications".to_string(),
324                            json!({ "communications": comms }),
325                        );
326                    }
327                    c
328                })
329                .collect();
330
331            let mut out = json!({ "cases": cases });
332            if let Some(tok) = next {
333                out.as_object_mut()
334                    .unwrap()
335                    .insert("nextToken".to_string(), json!(tok));
336            }
337            Ok(ok(out))
338        })
339    }
340
341    fn describe_communications(
342        &self,
343        req: &AwsRequest,
344        body: &Value,
345    ) -> Result<AwsResponse, AwsServiceError> {
346        let case_id = str_member(body, "caseId").unwrap_or_default().to_string();
347        let after = str_member(body, "afterTime").map(str::to_string);
348        let before = str_member(body, "beforeTime").map(str::to_string);
349        let max = body
350            .get("maxResults")
351            .and_then(Value::as_u64)
352            .map(|n| n as usize);
353        let start = decode_token(str_member(body, "nextToken"));
354
355        self.with_account_mut(req, |d| {
356            if !d.cases.contains_key(&case_id) {
357                return Err(case_id_not_found(&case_id));
358            }
359            let all = d.communications.get(&case_id).cloned().unwrap_or_default();
360            let filtered: Vec<Value> = all
361                .into_iter()
362                .filter(|c| {
363                    let created = c.get("timeCreated").and_then(Value::as_str).unwrap_or("");
364                    after.as_deref().map(|a| created >= a).unwrap_or(true)
365                        && before.as_deref().map(|b| created <= b).unwrap_or(true)
366                })
367                .collect();
368            let (page, next) = paginate(&filtered, start, max);
369            let mut out = json!({ "communications": page });
370            if let Some(tok) = next {
371                out.as_object_mut()
372                    .unwrap()
373                    .insert("nextToken".to_string(), json!(tok));
374            }
375            Ok(ok(out))
376        })
377    }
378
379    fn add_communication_to_case(
380        &self,
381        req: &AwsRequest,
382        body: &Value,
383    ) -> Result<AwsResponse, AwsServiceError> {
384        let account = req.account_id.clone();
385        let case_id = str_member(body, "caseId").unwrap_or_default().to_string();
386        let comm_body = str_member(body, "communicationBody")
387            .unwrap_or_default()
388            .to_string();
389        let attachment_set_id = str_member(body, "attachmentSetId").map(str::to_string);
390        let submitted_by = format!("arn:aws:iam::{account}:root");
391        let now = iso_now();
392
393        self.with_account_mut(req, |d| {
394            if !d.cases.contains_key(&case_id) {
395                return Err(case_id_not_found(&case_id));
396            }
397            let attachment_set = match &attachment_set_id {
398                Some(id) => {
399                    let set = d
400                        .attachment_sets
401                        .get(id)
402                        .ok_or_else(|| attachment_set_id_not_found(id))?;
403                    attachment_set_summary(d, set)
404                }
405                None => json!([]),
406            };
407            let comm = json!({
408                "caseId": case_id,
409                "body": comm_body,
410                "submittedBy": submitted_by,
411                "timeCreated": now,
412                "attachmentSet": attachment_set,
413            });
414            d.communications
415                .entry(case_id.clone())
416                .or_default()
417                .push(comm);
418            Ok(ok(json!({ "result": true })))
419        })
420    }
421
422    fn resolve_case(&self, req: &AwsRequest, body: &Value) -> Result<AwsResponse, AwsServiceError> {
423        let case_id = str_member(body, "caseId").unwrap_or_default().to_string();
424        self.with_account_mut(req, |d| {
425            let case = d
426                .cases
427                .get_mut(&case_id)
428                .ok_or_else(|| case_id_not_found(&case_id))?;
429            let initial = case
430                .get("status")
431                .and_then(Value::as_str)
432                .unwrap_or("opened")
433                .to_string();
434            case.as_object_mut()
435                .unwrap()
436                .insert("status".to_string(), json!("resolved"));
437            Ok(ok(json!({
438                "initialCaseStatus": initial,
439                "finalCaseStatus": "resolved",
440            })))
441        })
442    }
443
444    fn add_attachments_to_set(
445        &self,
446        req: &AwsRequest,
447        body: &Value,
448    ) -> Result<AwsResponse, AwsServiceError> {
449        let requested_set = str_member(body, "attachmentSetId").map(str::to_string);
450        let attachments = body
451            .get("attachments")
452            .and_then(Value::as_array)
453            .cloned()
454            .unwrap_or_default();
455        // Attachment sets expire one hour after their last modification.
456        let expiry = (chrono::Utc::now() + chrono::Duration::hours(1))
457            .format("%Y-%m-%dT%H:%M:%S%.3fZ")
458            .to_string();
459
460        self.with_account_mut(req, |d| {
461            let set_id = match &requested_set {
462                Some(id) => {
463                    if !d.attachment_sets.contains_key(id) {
464                        return Err(attachment_set_id_not_found(id));
465                    }
466                    id.clone()
467                }
468                None => new_attachment_set_id(),
469            };
470
471            let mut ids: Vec<String> = d
472                .attachment_sets
473                .get(&set_id)
474                .and_then(|s| s.get("attachmentIds"))
475                .and_then(Value::as_array)
476                .map(|a| {
477                    a.iter()
478                        .filter_map(|v| v.as_str().map(String::from))
479                        .collect()
480                })
481                .unwrap_or_default();
482
483            for att in &attachments {
484                let att_id = new_attachment_id();
485                let file_name = att.get("fileName").cloned().unwrap_or(json!(""));
486                let data = att.get("data").cloned().unwrap_or(json!(""));
487                d.attachments.insert(
488                    att_id.clone(),
489                    json!({ "fileName": file_name, "data": data }),
490                );
491                ids.push(att_id);
492            }
493
494            d.attachment_sets.insert(
495                set_id.clone(),
496                json!({ "attachmentIds": ids, "expiryTime": expiry }),
497            );
498            Ok(ok(json!({
499                "attachmentSetId": set_id,
500                "expiryTime": expiry,
501            })))
502        })
503    }
504
505    fn describe_attachment(
506        &self,
507        req: &AwsRequest,
508        body: &Value,
509    ) -> Result<AwsResponse, AwsServiceError> {
510        let attachment_id = str_member(body, "attachmentId")
511            .unwrap_or_default()
512            .to_string();
513        self.with_account_mut(req, |d| {
514            let att = d
515                .attachments
516                .get(&attachment_id)
517                .ok_or_else(|| attachment_id_not_found(&attachment_id))?;
518            Ok(ok(json!({ "attachment": att })))
519        })
520    }
521
522    // ---- Trusted Advisor --------------------------------------------------
523
524    fn describe_ta_checks(&self, _body: &Value) -> Result<AwsResponse, AwsServiceError> {
525        Ok(ok(json!({ "checks": catalog::ta_checks() })))
526    }
527
528    fn describe_ta_check_result(&self, body: &Value) -> Result<AwsResponse, AwsServiceError> {
529        let check_id = str_member(body, "checkId").unwrap_or_default().to_string();
530        Ok(ok(json!({
531            "result": ta_check_result(&check_id),
532        })))
533    }
534
535    fn describe_ta_check_summaries(&self, body: &Value) -> Result<AwsResponse, AwsServiceError> {
536        let ids = string_list(body, "checkIds");
537        let summaries: Vec<Value> = ids.iter().map(|id| ta_check_summary(id)).collect();
538        Ok(ok(json!({ "summaries": summaries })))
539    }
540
541    fn describe_ta_refresh_statuses(
542        &self,
543        req: &AwsRequest,
544        body: &Value,
545    ) -> Result<AwsResponse, AwsServiceError> {
546        let ids = string_list(body, "checkIds");
547        let statuses = self.with_account_mut(req, |d| {
548            ids.iter()
549                .map(|id| {
550                    let status = d.advance_refresh(id);
551                    json!({
552                        "checkId": id,
553                        "status": status,
554                        "millisUntilNextRefreshable": refresh_interval_ms(&status),
555                    })
556                })
557                .collect::<Vec<Value>>()
558        });
559        Ok(ok(json!({ "statuses": statuses })))
560    }
561
562    fn refresh_ta_check(
563        &self,
564        req: &AwsRequest,
565        body: &Value,
566    ) -> Result<AwsResponse, AwsServiceError> {
567        let check_id = str_member(body, "checkId").unwrap_or_default().to_string();
568        self.with_account_mut(req, |d| {
569            d.ta_refresh
570                .insert(check_id.clone(), "enqueued".to_string());
571        });
572        Ok(ok(json!({
573            "status": {
574                "checkId": check_id,
575                "status": "enqueued",
576                "millisUntilNextRefreshable": refresh_interval_ms("enqueued"),
577            }
578        })))
579    }
580
581    // ---- Severity levels + reference data ---------------------------------
582
583    fn describe_severity_levels(&self, _body: &Value) -> Result<AwsResponse, AwsServiceError> {
584        Ok(ok(json!({ "severityLevels": catalog::severity_levels() })))
585    }
586
587    fn describe_services(&self, body: &Value) -> Result<AwsResponse, AwsServiceError> {
588        let filter = body
589            .get("serviceCodeList")
590            .and_then(Value::as_array)
591            .map(|a| {
592                a.iter()
593                    .filter_map(|v| v.as_str().map(String::from))
594                    .collect::<Vec<_>>()
595            });
596        Ok(ok(json!({
597            "services": catalog::services(filter.as_deref()),
598        })))
599    }
600
601    fn describe_create_case_options(&self, _body: &Value) -> Result<AwsResponse, AwsServiceError> {
602        Ok(ok(json!({
603            "languageAvailability": "AVAILABLE",
604            "communicationTypes": [
605                { "type": "web", "supportedHours": [], "datesWithoutSupport": [] }
606            ],
607        })))
608    }
609
610    fn describe_supported_languages(&self, _body: &Value) -> Result<AwsResponse, AwsServiceError> {
611        Ok(ok(json!({
612            "supportedLanguages": [
613                { "code": "en", "language": "English", "display": "English" },
614                { "code": "ja", "language": "Japanese", "display": "日本語" }
615            ],
616        })))
617    }
618}
619
620// ---- Trusted Advisor result builders -------------------------------------
621
622fn ta_check_result(check_id: &str) -> Value {
623    json!({
624        "checkId": check_id,
625        "timestamp": iso_now(),
626        "status": "ok",
627        "resourcesSummary": zero_resources_summary(),
628        "categorySpecificSummary": category_specific_summary(check_id),
629        "flaggedResources": [],
630    })
631}
632
633fn ta_check_summary(check_id: &str) -> Value {
634    json!({
635        "checkId": check_id,
636        "timestamp": iso_now(),
637        "status": "ok",
638        "hasFlaggedResources": false,
639        "resourcesSummary": zero_resources_summary(),
640        "categorySpecificSummary": category_specific_summary(check_id),
641    })
642}
643
644fn zero_resources_summary() -> Value {
645    json!({
646        "resourcesProcessed": 0,
647        "resourcesFlagged": 0,
648        "resourcesIgnored": 0,
649        "resourcesSuppressed": 0,
650    })
651}
652
653fn category_specific_summary(check_id: &str) -> Value {
654    // The cost-optimising summary is only meaningful for cost checks, but the
655    // member is always present in the live response with zeroed savings.
656    let _ = catalog::check_category(check_id);
657    json!({
658        "costOptimizing": {
659            "estimatedMonthlySavings": 0.0,
660            "estimatedPercentMonthlySavings": 0.0,
661        }
662    })
663}
664
665/// Milliseconds until the check may be refreshed again. Zero while a refresh is
666/// in flight; a full interval once it has settled.
667fn refresh_interval_ms(status: &str) -> i64 {
668    match status {
669        "success" | "none" => 3_600_000,
670        _ => 0,
671    }
672}
673
674// ---- pagination helpers ---------------------------------------------------
675
676/// Decode a `nextToken` (a plain decimal offset) into a start index.
677fn decode_token(token: Option<&str>) -> usize {
678    token.and_then(|t| t.parse::<usize>().ok()).unwrap_or(0)
679}
680
681/// Return `(page, next_token)` for `items[start..]` limited to `max`.
682fn paginate(items: &[Value], start: usize, max: Option<usize>) -> (Vec<Value>, Option<String>) {
683    let start = start.min(items.len());
684    let remaining = &items[start..];
685    match max {
686        Some(m) if m < remaining.len() => {
687            let page = remaining[..m].to_vec();
688            (page, Some((start + m).to_string()))
689        }
690        _ => (remaining.to_vec(), None),
691    }
692}
693
694/// Summarise an attachment set (stored as `{attachmentIds, expiryTime}`) into
695/// the `AttachmentSet` wire list of `{attachmentId, fileName}`.
696fn attachment_set_summary(d: &SupportData, set: &Value) -> Value {
697    let ids = set
698        .get("attachmentIds")
699        .and_then(Value::as_array)
700        .cloned()
701        .unwrap_or_default();
702    let details: Vec<Value> = ids
703        .iter()
704        .filter_map(|v| v.as_str())
705        .map(|id| {
706            let file_name = d
707                .attachments
708                .get(id)
709                .and_then(|a| a.get("fileName"))
710                .cloned()
711                .unwrap_or(json!(""));
712            json!({ "attachmentId": id, "fileName": file_name })
713        })
714        .collect();
715    json!(details)
716}
717
718fn string_list(body: &Value, name: &str) -> Vec<String> {
719    body.get(name)
720        .and_then(Value::as_array)
721        .map(|a| {
722            a.iter()
723                .filter_map(|v| v.as_str().map(String::from))
724                .collect()
725        })
726        .unwrap_or_default()
727}
728
729// ---- error / response helpers --------------------------------------------
730
731fn validation_error(msg: impl Into<String>) -> AwsServiceError {
732    AwsServiceError::aws_error(StatusCode::BAD_REQUEST, "ValidationException", msg.into())
733}
734
735fn case_id_not_found(id: &str) -> AwsServiceError {
736    AwsServiceError::aws_error(
737        StatusCode::BAD_REQUEST,
738        "CaseIdNotFound",
739        format!("Case {id} was not found."),
740    )
741}
742
743fn attachment_id_not_found(id: &str) -> AwsServiceError {
744    AwsServiceError::aws_error(
745        StatusCode::BAD_REQUEST,
746        "AttachmentIdNotFound",
747        format!("Attachment {id} was not found."),
748    )
749}
750
751fn attachment_set_id_not_found(id: &str) -> AwsServiceError {
752    AwsServiceError::aws_error(
753        StatusCode::BAD_REQUEST,
754        "AttachmentSetIdNotFound",
755        format!("Attachment set {id} was not found."),
756    )
757}
758
759fn ok(value: Value) -> AwsResponse {
760    AwsResponse::ok_json(value)
761}
762
763#[cfg(test)]
764mod tests {
765    use super::*;
766    use fakecloud_core::multi_account::MultiAccountState;
767    use parking_lot::RwLock;
768
769    fn service() -> SupportService {
770        SupportService::new(Arc::new(RwLock::new(MultiAccountState::new(
771            "000000000000",
772            "us-east-1",
773            "",
774        ))))
775    }
776
777    fn req(action: &str, body: Value) -> AwsRequest {
778        AwsRequest {
779            service: "support".to_string(),
780            action: action.to_string(),
781            region: "us-east-1".to_string(),
782            account_id: "000000000000".to_string(),
783            request_id: "test".to_string(),
784            headers: http::HeaderMap::new(),
785            query_params: std::collections::HashMap::new(),
786            body: bytes::Bytes::from(serde_json::to_vec(&body).unwrap()),
787            body_stream: parking_lot::Mutex::new(None),
788            path_segments: Vec::new(),
789            raw_path: String::new(),
790            raw_query: String::new(),
791            method: http::Method::POST,
792            is_query_protocol: false,
793            access_key_id: None,
794            principal: None,
795        }
796    }
797
798    fn body_of(resp: &AwsResponse) -> Value {
799        serde_json::from_slice(resp.body.expect_bytes()).unwrap()
800    }
801
802    /// `Result::unwrap_err` requires the `Ok` type to be `Debug`; `AwsResponse`
803    /// is not, so extract the error by matching instead.
804    fn expect_err(r: Result<AwsResponse, AwsServiceError>) -> AwsServiceError {
805        match r {
806            Ok(_) => panic!("expected an error response"),
807            Err(e) => e,
808        }
809    }
810
811    #[test]
812    fn case_lifecycle() {
813        let svc = service();
814        // Create.
815        let created = svc
816            .create_case(
817                &req("CreateCase", json!({})),
818                &json!({ "subject": "Cannot connect", "communicationBody": "help please" }),
819            )
820            .unwrap();
821        let case_id = body_of(&created)["caseId"].as_str().unwrap().to_string();
822        assert!(case_id.starts_with("case-000000000000-"));
823
824        // Describe returns the case with its seeded communication.
825        let described = svc
826            .describe_cases(&req("DescribeCases", json!({})), &json!({}))
827            .unwrap();
828        let out = body_of(&described);
829        assert_eq!(out["cases"].as_array().unwrap().len(), 1);
830        let comms = &out["cases"][0]["recentCommunications"]["communications"];
831        assert_eq!(comms.as_array().unwrap().len(), 1);
832        assert_eq!(comms[0]["body"], "help please");
833
834        // Add a communication.
835        let added = svc
836            .add_communication_to_case(
837                &req("AddCommunicationToCase", json!({})),
838                &json!({ "caseId": case_id, "communicationBody": "any update?" }),
839            )
840            .unwrap();
841        assert_eq!(body_of(&added)["result"], true);
842        let comms = svc
843            .describe_communications(
844                &req("DescribeCommunications", json!({})),
845                &json!({ "caseId": case_id }),
846            )
847            .unwrap();
848        assert_eq!(
849            body_of(&comms)["communications"].as_array().unwrap().len(),
850            2
851        );
852
853        // Resolve.
854        let resolved = svc
855            .resolve_case(
856                &req("ResolveCase", json!({})),
857                &json!({ "caseId": case_id }),
858            )
859            .unwrap();
860        let out = body_of(&resolved);
861        assert_eq!(out["initialCaseStatus"], "opened");
862        assert_eq!(out["finalCaseStatus"], "resolved");
863
864        // Resolved cases are excluded unless includeResolvedCases.
865        let default_list = svc
866            .describe_cases(&req("DescribeCases", json!({})), &json!({}))
867            .unwrap();
868        assert_eq!(body_of(&default_list)["cases"].as_array().unwrap().len(), 0);
869        let with_resolved = svc
870            .describe_cases(
871                &req("DescribeCases", json!({})),
872                &json!({ "includeResolvedCases": true }),
873            )
874            .unwrap();
875        assert_eq!(
876            body_of(&with_resolved)["cases"].as_array().unwrap().len(),
877            1
878        );
879    }
880
881    #[test]
882    fn unknown_case_is_case_id_not_found() {
883        let svc = service();
884        let err = expect_err(svc.describe_communications(
885            &req("DescribeCommunications", json!({})),
886            &json!({ "caseId": "case-nope" }),
887        ));
888        assert!(format!("{err:?}").contains("CaseIdNotFound"));
889    }
890
891    #[test]
892    fn attachment_set_lifecycle() {
893        let svc = service();
894        let created = svc
895            .add_attachments_to_set(
896                &req("AddAttachmentsToSet", json!({})),
897                &json!({ "attachments": [{ "fileName": "log.txt", "data": "aGVsbG8=" }] }),
898            )
899            .unwrap();
900        let out = body_of(&created);
901        let set_id = out["attachmentSetId"].as_str().unwrap().to_string();
902        assert!(set_id.starts_with("as-"));
903        assert!(out["expiryTime"].is_string());
904
905        // Extend the same set.
906        let extended = svc
907            .add_attachments_to_set(
908                &req("AddAttachmentsToSet", json!({})),
909                &json!({ "attachmentSetId": set_id, "attachments": [{ "fileName": "b.txt", "data": "eA==" }] }),
910            )
911            .unwrap();
912        assert_eq!(body_of(&extended)["attachmentSetId"], set_id);
913
914        // Unknown set id is rejected.
915        let err = expect_err(svc.add_attachments_to_set(
916            &req("AddAttachmentsToSet", json!({})),
917            &json!({ "attachmentSetId": "as-missing", "attachments": [] }),
918        ));
919        assert!(format!("{err:?}").contains("AttachmentSetIdNotFound"));
920    }
921
922    #[test]
923    fn describe_attachment_unknown_id() {
924        let svc = service();
925        let err = expect_err(svc.describe_attachment(
926            &req("DescribeAttachment", json!({})),
927            &json!({ "attachmentId": "attachment-x" }),
928        ));
929        assert!(format!("{err:?}").contains("AttachmentIdNotFound"));
930    }
931
932    #[test]
933    fn severity_levels_and_ta_catalogue() {
934        let svc = service();
935        let sev = body_of(&svc.describe_severity_levels(&json!({})).unwrap());
936        assert_eq!(sev["severityLevels"].as_array().unwrap().len(), 5);
937        let checks = body_of(&svc.describe_ta_checks(&json!({})).unwrap());
938        assert!(!checks["checks"].as_array().unwrap().is_empty());
939    }
940
941    #[test]
942    fn ta_refresh_state_machine() {
943        let svc = service();
944        // Enqueue.
945        let enq = body_of(
946            &svc.refresh_ta_check(
947                &req("RefreshTrustedAdvisorCheck", json!({})),
948                &json!({ "checkId": "Qch7DwouX1" }),
949            )
950            .unwrap(),
951        );
952        assert_eq!(enq["status"]["status"], "enqueued");
953        // Each describe advances: enqueued -> processing -> success.
954        let step1 = body_of(
955            &svc.describe_ta_refresh_statuses(
956                &req("DescribeTrustedAdvisorCheckRefreshStatuses", json!({})),
957                &json!({ "checkIds": ["Qch7DwouX1"] }),
958            )
959            .unwrap(),
960        );
961        assert_eq!(step1["statuses"][0]["status"], "processing");
962        let step2 = body_of(
963            &svc.describe_ta_refresh_statuses(
964                &req("DescribeTrustedAdvisorCheckRefreshStatuses", json!({})),
965                &json!({ "checkIds": ["Qch7DwouX1"] }),
966            )
967            .unwrap(),
968        );
969        assert_eq!(step2["statuses"][0]["status"], "success");
970    }
971
972    #[test]
973    fn describe_cases_paginates() {
974        let svc = service();
975        for _ in 0..3 {
976            svc.create_case(
977                &req("CreateCase", json!({})),
978                &json!({ "subject": "s", "communicationBody": "b" }),
979            )
980            .unwrap();
981        }
982        let page1 = body_of(
983            &svc.describe_cases(
984                &req("DescribeCases", json!({})),
985                &json!({ "maxResults": 2 }),
986            )
987            .unwrap(),
988        );
989        assert_eq!(page1["cases"].as_array().unwrap().len(), 2);
990        let token = page1["nextToken"].as_str().unwrap();
991        let page2 = body_of(
992            &svc.describe_cases(
993                &req("DescribeCases", json!({})),
994                &json!({ "maxResults": 2, "nextToken": token }),
995            )
996            .unwrap(),
997        );
998        assert_eq!(page2["cases"].as_array().unwrap().len(), 1);
999        assert!(page2.get("nextToken").is_none());
1000    }
1001}