Skip to main content

fakecloud_resource_groups/
service.rs

1//! Resource Groups restJson1 dispatch + operation handlers.
2
3use std::collections::{BTreeMap, BTreeSet};
4use std::sync::Arc;
5
6use async_trait::async_trait;
7use chrono::Utc;
8use http::{Method, StatusCode};
9use percent_encoding::percent_decode_str;
10use serde_json::{json, Value};
11use tokio::sync::Mutex as AsyncMutex;
12
13use fakecloud_core::service::{AwsRequest, AwsResponse, AwsService, AwsServiceError};
14use fakecloud_persistence::SnapshotStore;
15
16use crate::persistence::save_snapshot;
17use crate::state::{
18    AccountSettings, ResourceGroup, ResourceQuery, SharedResourceGroupsState, TagSyncTask,
19};
20
21/// Every operation name in the Resource Groups Smithy model.
22pub const RESOURCE_GROUPS_ACTIONS: &[&str] = &[
23    "CancelTagSyncTask",
24    "CreateGroup",
25    "DeleteGroup",
26    "GetAccountSettings",
27    "GetGroup",
28    "GetGroupConfiguration",
29    "GetGroupQuery",
30    "GetTagSyncTask",
31    "GetTags",
32    "GroupResources",
33    "ListGroupResources",
34    "ListGroupingStatuses",
35    "ListGroups",
36    "ListTagSyncTasks",
37    "PutGroupConfiguration",
38    "SearchResources",
39    "StartTagSyncTask",
40    "Tag",
41    "UngroupResources",
42    "Untag",
43    "UpdateAccountSettings",
44    "UpdateGroup",
45    "UpdateGroupQuery",
46];
47
48pub struct ResourceGroupsService {
49    state: SharedResourceGroupsState,
50    snapshot_store: Option<Arc<dyn SnapshotStore>>,
51    snapshot_lock: Arc<AsyncMutex<()>>,
52    /// Shared cross-service tag index, used to resolve TAG_FILTERS resource
53    /// queries (ListGroupResources / SearchResources) against live tagged
54    /// resources. `None` in unit tests that don't exercise query resolution.
55    tag_registry: Option<fakecloud_core::tag_index::TagProviderRegistry>,
56}
57
58/// A resolved route: the operation plus any path-derived argument.
59enum Route {
60    CreateGroup,
61    GetGroup,
62    GetGroupQuery,
63    UpdateGroup,
64    UpdateGroupQuery,
65    DeleteGroup,
66    ListGroups,
67    GroupResources,
68    UngroupResources,
69    ListGroupResources,
70    SearchResources,
71    GetGroupConfiguration,
72    PutGroupConfiguration,
73    GetTags(String),
74    Tag(String),
75    Untag(String),
76    GetAccountSettings,
77    UpdateAccountSettings,
78    ListGroupingStatuses,
79    StartTagSyncTask,
80    GetTagSyncTask,
81    ListTagSyncTasks,
82    CancelTagSyncTask,
83}
84
85impl Route {
86    fn mutates(&self) -> bool {
87        matches!(
88            self,
89            Route::CreateGroup
90                | Route::UpdateGroup
91                | Route::UpdateGroupQuery
92                | Route::DeleteGroup
93                | Route::GroupResources
94                | Route::UngroupResources
95                | Route::PutGroupConfiguration
96                | Route::Tag(_)
97                | Route::Untag(_)
98                | Route::UpdateAccountSettings
99                | Route::StartTagSyncTask
100                | Route::CancelTagSyncTask
101        )
102    }
103}
104
105impl ResourceGroupsService {
106    pub fn new(state: SharedResourceGroupsState) -> Self {
107        Self {
108            state,
109            snapshot_store: None,
110            snapshot_lock: Arc::new(AsyncMutex::new(())),
111            tag_registry: None,
112        }
113    }
114
115    pub fn with_snapshot_store(mut self, store: Arc<dyn SnapshotStore>) -> Self {
116        self.snapshot_store = Some(store);
117        self
118    }
119
120    pub fn with_tag_registry(
121        mut self,
122        registry: fakecloud_core::tag_index::TagProviderRegistry,
123    ) -> Self {
124        self.tag_registry = Some(registry);
125        self
126    }
127
128    async fn persist(&self) {
129        save_snapshot(
130            &self.state,
131            self.snapshot_store.clone(),
132            &self.snapshot_lock,
133        )
134        .await;
135    }
136
137    fn resolve_route(req: &AwsRequest) -> Option<Route> {
138        let segs: Vec<&str> = req
139            .raw_path
140            .trim_matches('/')
141            .split('/')
142            .filter(|s| !s.is_empty())
143            .collect();
144        match (&req.method, segs.as_slice()) {
145            (&Method::POST, ["groups"]) => Some(Route::CreateGroup),
146            (&Method::POST, ["groups-list"]) => Some(Route::ListGroups),
147            (&Method::POST, ["get-group"]) => Some(Route::GetGroup),
148            (&Method::POST, ["get-group-query"]) => Some(Route::GetGroupQuery),
149            (&Method::POST, ["update-group"]) => Some(Route::UpdateGroup),
150            (&Method::POST, ["update-group-query"]) => Some(Route::UpdateGroupQuery),
151            (&Method::POST, ["delete-group"]) => Some(Route::DeleteGroup),
152            (&Method::POST, ["group-resources"]) => Some(Route::GroupResources),
153            (&Method::POST, ["ungroup-resources"]) => Some(Route::UngroupResources),
154            (&Method::POST, ["list-group-resources"]) => Some(Route::ListGroupResources),
155            (&Method::POST, ["resources", "search"]) => Some(Route::SearchResources),
156            (&Method::POST, ["get-group-configuration"]) => Some(Route::GetGroupConfiguration),
157            (&Method::POST, ["put-group-configuration"]) => Some(Route::PutGroupConfiguration),
158            (&Method::POST, ["get-account-settings"]) => Some(Route::GetAccountSettings),
159            (&Method::POST, ["update-account-settings"]) => Some(Route::UpdateAccountSettings),
160            (&Method::POST, ["list-grouping-statuses"]) => Some(Route::ListGroupingStatuses),
161            (&Method::POST, ["start-tag-sync-task"]) => Some(Route::StartTagSyncTask),
162            (&Method::POST, ["get-tag-sync-task"]) => Some(Route::GetTagSyncTask),
163            (&Method::POST, ["list-tag-sync-tasks"]) => Some(Route::ListTagSyncTasks),
164            (&Method::POST, ["cancel-tag-sync-task"]) => Some(Route::CancelTagSyncTask),
165            (&Method::GET, ["resources", arn, "tags"]) => Some(Route::GetTags(decode(arn))),
166            (&Method::PUT, ["resources", arn, "tags"]) => Some(Route::Tag(decode(arn))),
167            (&Method::PATCH, ["resources", arn, "tags"]) => Some(Route::Untag(decode(arn))),
168            _ => None,
169        }
170    }
171
172    // --- group lifecycle --------------------------------------------------
173
174    fn create_group(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
175        let body = parse_json(&req.body)?;
176        let name = require_str(&body, "Name")?.to_string();
177        validate_group_name(&name)?;
178        let query = parse_resource_query(body.get("ResourceQuery"))?;
179        let configuration = body
180            .get("Configuration")
181            .and_then(|v| v.as_array())
182            .cloned()
183            .unwrap_or_default();
184        if query.is_none() && configuration.is_empty() {
185            return Err(bad_request(
186                "A group must be created with either a ResourceQuery or a Configuration.",
187            ));
188        }
189        let tags = parse_tags(body.get("Tags"));
190
191        let mut accounts = self.state.write();
192        let st = accounts.get_or_create(&req.account_id);
193        if st.groups.contains_key(&name) {
194            return Err(bad_request(&format!(
195                "A group with the name '{name}' already exists."
196            )));
197        }
198        let arn = group_arn(&req.region, &req.account_id, &name);
199        let group = ResourceGroup {
200            name: name.clone(),
201            arn,
202            description: opt_string(&body, "Description"),
203            query,
204            configuration,
205            tags: tags.clone(),
206            criticality: body
207                .get("Criticality")
208                .and_then(|v| v.as_i64())
209                .map(|n| n as i32),
210            owner: opt_string(&body, "Owner"),
211            display_name: opt_string(&body, "DisplayName"),
212            application_tag: BTreeMap::new(),
213            resources: BTreeSet::new(),
214            created_at: Utc::now(),
215        };
216        let out = json!({
217            "Group": group_json(&group),
218            "ResourceQuery": group.query.as_ref().map(resource_query_json),
219            "Tags": tags,
220            "GroupConfiguration": group_configuration_json(&group),
221        });
222        st.groups.insert(name, group);
223        Ok(AwsResponse::json_value(StatusCode::OK, out))
224    }
225
226    fn get_group(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
227        let body = parse_json(&req.body)?;
228        let key = group_key(&body)?;
229        let accounts = self.state.read();
230        let st = accounts.get(&req.account_id);
231        let group = st
232            .and_then(|s| s.resolve_name(&key).and_then(|n| s.groups.get(n)))
233            .ok_or_else(|| not_found(&key))?;
234        Ok(AwsResponse::json_value(
235            StatusCode::OK,
236            json!({ "Group": group_json(group) }),
237        ))
238    }
239
240    fn get_group_query(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
241        let body = parse_json(&req.body)?;
242        let key = group_key(&body)?;
243        let accounts = self.state.read();
244        let st = accounts.get(&req.account_id);
245        let group = st
246            .and_then(|s| s.resolve_name(&key).and_then(|n| s.groups.get(n)))
247            .ok_or_else(|| not_found(&key))?;
248        let query = group.query.as_ref().ok_or_else(|| {
249            bad_request("The group is not a resource-query group and has no query.")
250        })?;
251        Ok(AwsResponse::json_value(
252            StatusCode::OK,
253            json!({
254                "GroupQuery": {
255                    "GroupName": group.name,
256                    "ResourceQuery": resource_query_json(query),
257                }
258            }),
259        ))
260    }
261
262    fn update_group(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
263        let body = parse_json(&req.body)?;
264        let key = group_key(&body)?;
265        let mut accounts = self.state.write();
266        let st = accounts.get_or_create(&req.account_id);
267        let Some(name) = st.resolve_name(&key).map(String::from) else {
268            return Err(not_found(&key));
269        };
270        let group = st.groups.get_mut(&name).unwrap();
271        if let Some(d) = body.get("Description").and_then(|v| v.as_str()) {
272            group.description = Some(d.to_string());
273        }
274        if let Some(c) = body.get("Criticality").and_then(|v| v.as_i64()) {
275            group.criticality = Some(c as i32);
276        }
277        if let Some(o) = body.get("Owner").and_then(|v| v.as_str()) {
278            group.owner = Some(o.to_string());
279        }
280        if let Some(dn) = body.get("DisplayName").and_then(|v| v.as_str()) {
281            group.display_name = Some(dn.to_string());
282        }
283        Ok(AwsResponse::json_value(
284            StatusCode::OK,
285            json!({ "Group": group_json(group) }),
286        ))
287    }
288
289    fn update_group_query(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
290        let body = parse_json(&req.body)?;
291        let key = group_key(&body)?;
292        let query = parse_resource_query(body.get("ResourceQuery"))?
293            .ok_or_else(|| bad_request("ResourceQuery is required."))?;
294        let mut accounts = self.state.write();
295        let st = accounts.get_or_create(&req.account_id);
296        let Some(name) = st.resolve_name(&key).map(String::from) else {
297            return Err(not_found(&key));
298        };
299        let group = st.groups.get_mut(&name).unwrap();
300        group.query = Some(query.clone());
301        Ok(AwsResponse::json_value(
302            StatusCode::OK,
303            json!({
304                "GroupQuery": {
305                    "GroupName": group.name,
306                    "ResourceQuery": resource_query_json(&query),
307                }
308            }),
309        ))
310    }
311
312    fn delete_group(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
313        let body = parse_json(&req.body)?;
314        let key = group_key(&body)?;
315        let mut accounts = self.state.write();
316        let st = accounts.get_or_create(&req.account_id);
317        let Some(name) = st.resolve_name(&key).map(String::from) else {
318            return Err(not_found(&key));
319        };
320        let group = st.groups.remove(&name).unwrap();
321        Ok(AwsResponse::json_value(
322            StatusCode::OK,
323            json!({ "Group": group_json(&group) }),
324        ))
325    }
326
327    fn list_groups(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
328        // ListGroups binds MaxResults/NextToken to the query string (@httpQuery),
329        // not the body, so fold them in before validating + paginating.
330        let mut body = parse_json(&req.body)?;
331        inject_query_pagination(req, &mut body);
332        validate_max_results(&body)?;
333        validate_next_token(&body)?;
334        let accounts = self.state.read();
335        let groups: Vec<&ResourceGroup> = accounts
336            .get(&req.account_id)
337            .map(|s| s.groups.values().collect())
338            .unwrap_or_default();
339        let identifiers: Vec<Value> = groups.iter().map(|g| group_identifier_json(g)).collect();
340        let full: Vec<Value> = groups.iter().map(|g| group_json(g)).collect();
341        let (ident_page, next) = paginate(identifiers, &body);
342        let (full_page, _) = paginate(full, &body);
343        let mut out = json!({
344            "GroupIdentifiers": ident_page,
345            "Groups": full_page,
346        });
347        if let Some(nt) = next {
348            out["NextToken"] = json!(nt);
349        }
350        Ok(AwsResponse::json_value(StatusCode::OK, out))
351    }
352
353    // --- membership -------------------------------------------------------
354
355    fn group_resources(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
356        let body = parse_json(&req.body)?;
357        let key = require_str(&body, "Group")?.to_string();
358        let arns = string_list(body.get("ResourceArns"));
359        if arns.is_empty() {
360            return Err(bad_request("ResourceArns must not be empty."));
361        }
362        let mut accounts = self.state.write();
363        let st = accounts.get_or_create(&req.account_id);
364        let Some(name) = st.resolve_name(&key).map(String::from) else {
365            return Err(not_found(&key));
366        };
367        let group = st.groups.get_mut(&name).unwrap();
368        if group.query.is_some() {
369            return Err(bad_request(
370                "Resources cannot be added to a resource-query group; membership is computed from its query.",
371            ));
372        }
373        for a in &arns {
374            group.resources.insert(a.clone());
375        }
376        Ok(AwsResponse::json_value(
377            StatusCode::OK,
378            json!({ "Succeeded": arns, "Failed": [], "Pending": [] }),
379        ))
380    }
381
382    fn ungroup_resources(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
383        let body = parse_json(&req.body)?;
384        let key = require_str(&body, "Group")?.to_string();
385        let arns = string_list(body.get("ResourceArns"));
386        let mut accounts = self.state.write();
387        let st = accounts.get_or_create(&req.account_id);
388        let Some(name) = st.resolve_name(&key).map(String::from) else {
389            return Err(not_found(&key));
390        };
391        let group = st.groups.get_mut(&name).unwrap();
392        if group.query.is_some() {
393            return Err(bad_request(
394                "Resources cannot be removed from a resource-query group; membership is computed from its query.",
395            ));
396        }
397        for a in &arns {
398            group.resources.remove(a);
399        }
400        Ok(AwsResponse::json_value(
401            StatusCode::OK,
402            json!({ "Succeeded": arns, "Failed": [], "Pending": [] }),
403        ))
404    }
405
406    fn list_group_resources(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
407        let body = parse_json(&req.body)?;
408        validate_max_results(&body)?;
409        validate_next_token(&body)?;
410        let key = group_key_field(&body, "GroupName", "Group")?;
411        let accounts = self.state.read();
412        let st = accounts.get(&req.account_id);
413        let group = st
414            .and_then(|s| s.resolve_name(&key).and_then(|n| s.groups.get(n)))
415            .ok_or_else(|| not_found(&key))?;
416        // A group's members are the ARNs explicitly grouped via GroupResources
417        // PLUS everything its ResourceQuery matches in the cross-service tag
418        // index. Previously the query was never evaluated, so tag-based groups
419        // always reported zero members (bug-hunt).
420        let mut arns: BTreeSet<String> = group.resources.clone();
421        if let Some(rq) = &group.query {
422            for arn in self.evaluate_query(&req.account_id, &req.region, rq) {
423                arns.insert(arn);
424            }
425        }
426        let items: Vec<Value> = arns
427            .iter()
428            .map(|arn| {
429                json!({
430                    "Identifier": resource_identifier_json(arn),
431                    "Status": { "Name": "ACTIVE" },
432                })
433            })
434            .collect();
435        let idents: Vec<Value> = arns
436            .iter()
437            .map(|arn| resource_identifier_json(arn))
438            .collect();
439        let (items_page, next) = paginate(items, &body);
440        let (idents_page, _) = paginate(idents, &body);
441        let mut out = json!({
442            "Resources": items_page,
443            "ResourceIdentifiers": idents_page,
444            "QueryErrors": [],
445        });
446        if let Some(nt) = next {
447            out["NextToken"] = json!(nt);
448        }
449        Ok(AwsResponse::json_value(StatusCode::OK, out))
450    }
451
452    fn search_resources(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
453        let body = parse_json(&req.body)?;
454        validate_max_results(&body)?;
455        validate_next_token(&body)?;
456        let rq = parse_resource_query(body.get("ResourceQuery"))?
457            .ok_or_else(|| bad_request("ResourceQuery is required."))?;
458        // Evaluate the free-standing ResourceQuery against the cross-service tag
459        // index (previously always empty -- bug-hunt).
460        let arns = self.evaluate_query(&req.account_id, &req.region, &rq);
461        let idents: Vec<Value> = arns
462            .iter()
463            .map(|arn| resource_identifier_json(arn))
464            .collect();
465        let (idents_page, next) = paginate(idents, &body);
466        let mut out = json!({ "ResourceIdentifiers": idents_page, "QueryErrors": [] });
467        if let Some(nt) = next {
468            out["NextToken"] = json!(nt);
469        }
470        Ok(AwsResponse::json_value(StatusCode::OK, out))
471    }
472
473    /// Resolve a TAG_FILTERS_1_0 resource query to matching ARNs by evaluating
474    /// its `ResourceTypeFilters` + `TagFilters` against the cross-service tag
475    /// index. Returns empty for CLOUDFORMATION_STACK_1_0 (no CFN-membership hook
476    /// is available here) or when no tag registry is wired.
477    fn evaluate_query(&self, account: &str, region: &str, rq: &ResourceQuery) -> Vec<String> {
478        if rq.type_ != "TAG_FILTERS_1_0" {
479            return Vec::new();
480        }
481        let Some(registry) = &self.tag_registry else {
482            return Vec::new();
483        };
484        let Ok(parsed) = serde_json::from_str::<Value>(&rq.query) else {
485            return Vec::new();
486        };
487        let type_filters = string_list(parsed.get("ResourceTypeFilters"));
488        let tag_filters: Vec<(String, Vec<String>)> = parsed
489            .get("TagFilters")
490            .and_then(|v| v.as_array())
491            .map(|arr| {
492                arr.iter()
493                    .filter_map(|f| {
494                        let key = f.get("Key").and_then(Value::as_str)?.to_string();
495                        let values = string_list(f.get("Values"));
496                        Some((key, values))
497                    })
498                    .collect()
499            })
500            .unwrap_or_default();
501        registry
502            .resources(account, Some(region))
503            .into_iter()
504            .filter(|r| type_query_matches(&type_filters, &r.resource_type))
505            .filter(|r| tag_filters_match(&tag_filters, &r.tags))
506            .map(|r| r.arn)
507            .collect()
508    }
509
510    // --- configuration ----------------------------------------------------
511
512    fn get_group_configuration(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
513        let body = parse_json(&req.body)?;
514        let key = group_key_field(&body, "Group", "Group")?;
515        let accounts = self.state.read();
516        let st = accounts.get(&req.account_id);
517        let group = st
518            .and_then(|s| s.resolve_name(&key).and_then(|n| s.groups.get(n)))
519            .ok_or_else(|| not_found(&key))?;
520        Ok(AwsResponse::json_value(
521            StatusCode::OK,
522            json!({ "GroupConfiguration": group_configuration_json(group) }),
523        ))
524    }
525
526    fn put_group_configuration(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
527        let body = parse_json(&req.body)?;
528        let key = require_str(&body, "Group")?.to_string();
529        let configuration = body
530            .get("Configuration")
531            .and_then(|v| v.as_array())
532            .cloned()
533            .unwrap_or_default();
534        let mut accounts = self.state.write();
535        let st = accounts.get_or_create(&req.account_id);
536        let Some(name) = st.resolve_name(&key).map(String::from) else {
537            return Err(not_found(&key));
538        };
539        st.groups.get_mut(&name).unwrap().configuration = configuration;
540        Ok(AwsResponse::json_value(StatusCode::OK, json!({})))
541    }
542
543    // --- tagging ----------------------------------------------------------
544
545    fn get_tags(&self, req: &AwsRequest, arn: &str) -> Result<AwsResponse, AwsServiceError> {
546        let accounts = self.state.read();
547        let st = accounts.get(&req.account_id);
548        let group = st
549            .and_then(|s| s.groups.values().find(|g| g.arn == arn))
550            .ok_or_else(|| not_found(arn))?;
551        Ok(AwsResponse::json_value(
552            StatusCode::OK,
553            json!({ "Arn": arn, "Tags": group.tags }),
554        ))
555    }
556
557    fn tag(&self, req: &AwsRequest, arn: &str) -> Result<AwsResponse, AwsServiceError> {
558        let body = parse_json(&req.body)?;
559        let tags = parse_tags(body.get("Tags"));
560        let mut accounts = self.state.write();
561        let st = accounts.get_or_create(&req.account_id);
562        let group = st
563            .groups
564            .values_mut()
565            .find(|g| g.arn == arn)
566            .ok_or_else(|| not_found(arn))?;
567        for (k, v) in &tags {
568            group.tags.insert(k.clone(), v.clone());
569        }
570        Ok(AwsResponse::json_value(
571            StatusCode::OK,
572            json!({ "Arn": arn, "Tags": tags }),
573        ))
574    }
575
576    fn untag(&self, req: &AwsRequest, arn: &str) -> Result<AwsResponse, AwsServiceError> {
577        let body = parse_json(&req.body)?;
578        let keys = string_list(body.get("Keys"));
579        let mut accounts = self.state.write();
580        let st = accounts.get_or_create(&req.account_id);
581        let group = st
582            .groups
583            .values_mut()
584            .find(|g| g.arn == arn)
585            .ok_or_else(|| not_found(arn))?;
586        for k in &keys {
587            group.tags.remove(k);
588        }
589        Ok(AwsResponse::json_value(
590            StatusCode::OK,
591            json!({ "Arn": arn, "Keys": keys }),
592        ))
593    }
594
595    // --- account settings -------------------------------------------------
596
597    fn get_account_settings(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
598        let accounts = self.state.read();
599        let settings = accounts
600            .get(&req.account_id)
601            .map(|s| s.account_settings.clone())
602            .unwrap_or_default();
603        Ok(AwsResponse::json_value(
604            StatusCode::OK,
605            json!({ "AccountSettings": account_settings_json(&settings) }),
606        ))
607    }
608
609    fn update_account_settings(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
610        let body = parse_json(&req.body)?;
611        let desired = body
612            .get("GroupLifecycleEventsDesiredStatus")
613            .and_then(|v| v.as_str());
614        let mut accounts = self.state.write();
615        let st = accounts.get_or_create(&req.account_id);
616        if let Some(d) = desired {
617            if d != "ACTIVE" && d != "INACTIVE" {
618                return Err(bad_request(
619                    "GroupLifecycleEventsDesiredStatus must be ACTIVE or INACTIVE.",
620                ));
621            }
622            st.account_settings.desired_status = Some(d.to_string());
623        }
624        Ok(AwsResponse::json_value(
625            StatusCode::OK,
626            json!({ "AccountSettings": account_settings_json(&st.account_settings) }),
627        ))
628    }
629
630    fn list_grouping_statuses(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
631        let body = parse_json(&req.body)?;
632        validate_max_results(&body)?;
633        validate_next_token(&body)?;
634        let key = require_str(&body, "Group")?.to_string();
635        let accounts = self.state.read();
636        let st = accounts.get(&req.account_id);
637        let group = st
638            .and_then(|s| s.resolve_name(&key).and_then(|n| s.groups.get(n)))
639            // ListGroupingStatuses does not declare NotFoundException in its
640            // Smithy error set, so a missing group is a BadRequest here.
641            .ok_or_else(|| bad_request(&format!("The specified group '{key}' was not found.")))?;
642        // Membership changes here are synchronous, so nothing is ever pending.
643        Ok(AwsResponse::json_value(
644            StatusCode::OK,
645            json!({ "Group": group.arn, "GroupingStatuses": [] }),
646        ))
647    }
648
649    // --- tag-sync tasks ---------------------------------------------------
650
651    fn start_tag_sync_task(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
652        let body = parse_json(&req.body)?;
653        let key = require_str(&body, "Group")?.to_string();
654        let role_arn = require_str(&body, "RoleArn")?.to_string();
655        let mut accounts = self.state.write();
656        let st = accounts.get_or_create(&req.account_id);
657        let Some(name) = st.resolve_name(&key).map(String::from) else {
658            return Err(not_found(&key));
659        };
660        let group_arn = st.groups.get(&name).unwrap().arn.clone();
661        let task_arn = tag_sync_task_arn(&group_arn);
662        let task = TagSyncTask {
663            task_arn: task_arn.clone(),
664            group_arn: group_arn.clone(),
665            group_name: name.clone(),
666            tag_key: opt_string(&body, "TagKey"),
667            tag_value: opt_string(&body, "TagValue"),
668            resource_query: parse_resource_query(body.get("ResourceQuery"))?,
669            role_arn: role_arn.clone(),
670            status: "ACTIVE".to_string(),
671            created_at: Utc::now(),
672        };
673        st.tag_sync_tasks.insert(task_arn.clone(), task.clone());
674        Ok(AwsResponse::json_value(
675            StatusCode::OK,
676            json!({
677                "GroupArn": group_arn,
678                "GroupName": name,
679                "TaskArn": task_arn,
680                "TagKey": task.tag_key,
681                "TagValue": task.tag_value,
682                "ResourceQuery": task.resource_query.as_ref().map(resource_query_json),
683                "RoleArn": role_arn,
684            }),
685        ))
686    }
687
688    fn get_tag_sync_task(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
689        let body = parse_json(&req.body)?;
690        let task_arn = require_str(&body, "TaskArn")?.to_string();
691        let accounts = self.state.read();
692        let task = accounts
693            .get(&req.account_id)
694            .and_then(|s| s.tag_sync_tasks.get(&task_arn))
695            .ok_or_else(|| not_found(&task_arn))?;
696        Ok(AwsResponse::json_value(
697            StatusCode::OK,
698            tag_sync_task_json(task),
699        ))
700    }
701
702    fn list_tag_sync_tasks(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
703        let body = parse_json(&req.body)?;
704        validate_max_results(&body)?;
705        validate_next_token(&body)?;
706        let accounts = self.state.read();
707        let all: Vec<Value> = accounts
708            .get(&req.account_id)
709            .map(|s| s.tag_sync_tasks.values().map(tag_sync_task_json).collect())
710            .unwrap_or_default();
711        let (page, next) = paginate(all, &body);
712        let mut out = json!({ "TagSyncTasks": page });
713        if let Some(nt) = next {
714            out["NextToken"] = json!(nt);
715        }
716        Ok(AwsResponse::json_value(StatusCode::OK, out))
717    }
718
719    fn cancel_tag_sync_task(&self, req: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
720        let body = parse_json(&req.body)?;
721        let task_arn = require_str(&body, "TaskArn")?.to_string();
722        let mut accounts = self.state.write();
723        let st = accounts.get_or_create(&req.account_id);
724        if st.tag_sync_tasks.remove(&task_arn).is_none() {
725            // CancelTagSyncTask does not declare NotFoundException; a missing
726            // task is reported as a BadRequest.
727            return Err(bad_request(&format!(
728                "The specified tag-sync task '{task_arn}' was not found."
729            )));
730        }
731        Ok(AwsResponse::json_value(StatusCode::OK, json!({})))
732    }
733}
734
735#[async_trait]
736impl AwsService for ResourceGroupsService {
737    fn service_name(&self) -> &str {
738        "resource-groups"
739    }
740
741    fn supported_actions(&self) -> &[&str] {
742        RESOURCE_GROUPS_ACTIONS
743    }
744
745    async fn handle(&self, req: AwsRequest) -> Result<AwsResponse, AwsServiceError> {
746        let Some(route) = Self::resolve_route(&req) else {
747            return Err(AwsServiceError::aws_error(
748                StatusCode::NOT_FOUND,
749                "UnknownOperationException",
750                format!("Unknown operation: {} {}", req.method, req.raw_path),
751            ));
752        };
753        let mutates = route.mutates();
754        let result = match route {
755            Route::CreateGroup => self.create_group(&req),
756            Route::GetGroup => self.get_group(&req),
757            Route::GetGroupQuery => self.get_group_query(&req),
758            Route::UpdateGroup => self.update_group(&req),
759            Route::UpdateGroupQuery => self.update_group_query(&req),
760            Route::DeleteGroup => self.delete_group(&req),
761            Route::ListGroups => self.list_groups(&req),
762            Route::GroupResources => self.group_resources(&req),
763            Route::UngroupResources => self.ungroup_resources(&req),
764            Route::ListGroupResources => self.list_group_resources(&req),
765            Route::SearchResources => self.search_resources(&req),
766            Route::GetGroupConfiguration => self.get_group_configuration(&req),
767            Route::PutGroupConfiguration => self.put_group_configuration(&req),
768            Route::GetTags(arn) => self.get_tags(&req, &arn),
769            Route::Tag(arn) => self.tag(&req, &arn),
770            Route::Untag(arn) => self.untag(&req, &arn),
771            Route::GetAccountSettings => self.get_account_settings(&req),
772            Route::UpdateAccountSettings => self.update_account_settings(&req),
773            Route::ListGroupingStatuses => self.list_grouping_statuses(&req),
774            Route::StartTagSyncTask => self.start_tag_sync_task(&req),
775            Route::GetTagSyncTask => self.get_tag_sync_task(&req),
776            Route::ListTagSyncTasks => self.list_tag_sync_tasks(&req),
777            Route::CancelTagSyncTask => self.cancel_tag_sync_task(&req),
778        };
779        if mutates && result.is_ok() {
780            self.persist().await;
781        }
782        result
783    }
784}
785
786// ---------------------------------------------------------------------------
787// JSON builders
788// ---------------------------------------------------------------------------
789
790fn group_json(g: &ResourceGroup) -> Value {
791    let mut v = json!({ "GroupArn": g.arn, "Name": g.name });
792    if let Some(d) = &g.description {
793        v["Description"] = json!(d);
794    }
795    if let Some(c) = g.criticality {
796        v["Criticality"] = json!(c);
797    }
798    if let Some(o) = &g.owner {
799        v["Owner"] = json!(o);
800    }
801    if let Some(dn) = &g.display_name {
802        v["DisplayName"] = json!(dn);
803    }
804    if !g.application_tag.is_empty() {
805        v["ApplicationTag"] = json!(g.application_tag);
806    }
807    v
808}
809
810fn group_identifier_json(g: &ResourceGroup) -> Value {
811    let mut v = json!({ "GroupName": g.name, "GroupArn": g.arn });
812    if let Some(d) = &g.description {
813        v["Description"] = json!(d);
814    }
815    if let Some(c) = g.criticality {
816        v["Criticality"] = json!(c);
817    }
818    if let Some(o) = &g.owner {
819        v["Owner"] = json!(o);
820    }
821    if let Some(dn) = &g.display_name {
822        v["DisplayName"] = json!(dn);
823    }
824    v
825}
826
827fn resource_query_json(q: &ResourceQuery) -> Value {
828    json!({ "Type": q.type_, "Query": q.query })
829}
830
831fn group_configuration_json(g: &ResourceGroup) -> Value {
832    json!({
833        "Configuration": g.configuration,
834        "Status": "UPDATE_COMPLETE",
835    })
836}
837
838fn resource_identifier_json(arn: &str) -> Value {
839    let mut v = json!({ "ResourceArn": arn });
840    if let Some(t) = resource_type_from_arn(arn) {
841        v["ResourceType"] = json!(t);
842    }
843    v
844}
845
846fn account_settings_json(s: &AccountSettings) -> Value {
847    let desired = s
848        .desired_status
849        .clone()
850        .unwrap_or_else(|| "INACTIVE".into());
851    json!({
852        "GroupLifecycleEventsDesiredStatus": desired,
853        "GroupLifecycleEventsStatus": desired,
854    })
855}
856
857fn tag_sync_task_json(t: &TagSyncTask) -> Value {
858    json!({
859        "GroupArn": t.group_arn,
860        "GroupName": t.group_name,
861        "TaskArn": t.task_arn,
862        "TagKey": t.tag_key,
863        "TagValue": t.tag_value,
864        "ResourceQuery": t.resource_query.as_ref().map(resource_query_json),
865        "RoleArn": t.role_arn,
866        "Status": t.status,
867        "CreatedAt": t.created_at.timestamp() as f64,
868    })
869}
870
871// ---------------------------------------------------------------------------
872// Parsing + helpers
873// ---------------------------------------------------------------------------
874
875fn parse_json(body: &[u8]) -> Result<Value, AwsServiceError> {
876    if body.is_empty() {
877        return Ok(json!({}));
878    }
879    serde_json::from_slice(body).map_err(|e| bad_request(&format!("invalid request body: {e}")))
880}
881
882fn require_str<'a>(body: &'a Value, field: &str) -> Result<&'a str, AwsServiceError> {
883    body.get(field)
884        .and_then(|v| v.as_str())
885        .filter(|s| !s.is_empty())
886        .ok_or_else(|| bad_request(&format!("{field} is required.")))
887}
888
889fn opt_string(body: &Value, field: &str) -> Option<String> {
890    body.get(field).and_then(|v| v.as_str()).map(String::from)
891}
892
893/// GetGroup/GetGroupQuery/etc. accept the group as `GroupName` (legacy) or
894/// `Group` (name or ARN).
895fn group_key(body: &Value) -> Result<String, AwsServiceError> {
896    group_key_field(body, "GroupName", "Group")
897}
898
899fn group_key_field(body: &Value, a: &str, b: &str) -> Result<String, AwsServiceError> {
900    body.get(a)
901        .or_else(|| body.get(b))
902        .and_then(|v| v.as_str())
903        .filter(|s| !s.is_empty())
904        .map(String::from)
905        .ok_or_else(|| bad_request("A group name or ARN is required."))
906}
907
908fn parse_resource_query(v: Option<&Value>) -> Result<Option<ResourceQuery>, AwsServiceError> {
909    let Some(q) = v else { return Ok(None) };
910    if q.is_null() {
911        return Ok(None);
912    }
913    let type_ = q
914        .get("Type")
915        .and_then(|v| v.as_str())
916        .ok_or_else(|| bad_request("ResourceQuery.Type is required."))?;
917    if type_ != "TAG_FILTERS_1_0" && type_ != "CLOUDFORMATION_STACK_1_0" {
918        return Err(bad_request(
919            "ResourceQuery.Type must be TAG_FILTERS_1_0 or CLOUDFORMATION_STACK_1_0.",
920        ));
921    }
922    let query = q
923        .get("Query")
924        .and_then(|v| v.as_str())
925        .ok_or_else(|| bad_request("ResourceQuery.Query is required."))?;
926    // Query must itself be valid JSON.
927    serde_json::from_str::<Value>(query)
928        .map_err(|e| bad_request(&format!("ResourceQuery.Query is not valid JSON: {e}")))?;
929    Ok(Some(ResourceQuery {
930        type_: type_.to_string(),
931        query: query.to_string(),
932    }))
933}
934
935/// Split a tagging resource type into `(service, Option<subtype>)`, e.g.
936/// `"ec2:instance"` -> `("ec2", Some("instance"))`, `"s3"` -> `("s3", None)`.
937fn split_service_type(t: &str) -> (String, Option<String>) {
938    let t = t.to_lowercase();
939    match t.split_once(':') {
940        Some((s, ty)) => (s.to_string(), Some(ty.to_string())),
941        None => (t, None),
942    }
943}
944
945/// Normalize a single `ResourceTypeFilters` entry (CloudFormation-style
946/// `AWS::EC2::Instance`, or already tagging-style `ec2:instance`) into a
947/// `(service, Option<subtype>)` pair.
948fn normalize_type_filter(filter: &str) -> (String, Option<String>) {
949    if filter.contains("::") {
950        let parts: Vec<&str> = filter.split("::").collect();
951        // ["AWS", "EC2", "Instance"] -> ("ec2", Some("instance"))
952        match parts.as_slice() {
953            [_, service, ty] => (service.to_lowercase(), Some(ty.to_lowercase())),
954            [_, service] => (service.to_lowercase(), None),
955            _ => split_service_type(filter),
956        }
957    } else {
958        split_service_type(filter)
959    }
960}
961
962/// Whether a resource's tagging type satisfies the query's `ResourceTypeFilters`.
963/// Empty filters or `AWS::AllSupported` match everything. A service-only filter
964/// matches every subtype of that service; a `service:type` filter matches a
965/// bare `service` resource (the tag index stores some services without a
966/// subtype).
967fn type_query_matches(filters: &[String], resource_type: &str) -> bool {
968    if filters.is_empty() || filters.iter().any(|f| f == "AWS::AllSupported") {
969        return true;
970    }
971    let (rs, rt) = split_service_type(resource_type);
972    filters.iter().any(|f| {
973        let (fs, ft) = normalize_type_filter(f);
974        fs == rs && (ft.is_none() || rt.is_none() || ft == rt)
975    })
976}
977
978/// Whether a resource's tags satisfy every `TagFilter` (ANDed). A filter with
979/// no values matches any value for the key; otherwise the value must be listed.
980fn tag_filters_match(filters: &[(String, Vec<String>)], tags: &BTreeMap<String, String>) -> bool {
981    filters.iter().all(|(key, values)| match tags.get(key) {
982        None => false,
983        Some(v) => values.is_empty() || values.iter().any(|x| x == v),
984    })
985}
986
987fn parse_tags(v: Option<&Value>) -> BTreeMap<String, String> {
988    v.and_then(|t| t.as_object())
989        .map(|o| {
990            o.iter()
991                .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
992                .collect()
993        })
994        .unwrap_or_default()
995}
996
997fn string_list(v: Option<&Value>) -> Vec<String> {
998    v.and_then(|a| a.as_array())
999        .map(|a| {
1000            a.iter()
1001                .filter_map(|s| s.as_str().map(String::from))
1002                .collect()
1003        })
1004        .unwrap_or_default()
1005}
1006
1007fn validate_group_name(name: &str) -> Result<(), AwsServiceError> {
1008    if name.len() > 300
1009        || !name
1010            .chars()
1011            .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
1012    {
1013        return Err(bad_request(
1014            "Group name must be 1-300 chars of [A-Za-z0-9._-].",
1015        ));
1016    }
1017    Ok(())
1018}
1019
1020fn validate_max_results(body: &Value) -> Result<(), AwsServiceError> {
1021    if let Some(v) = body.get("MaxResults") {
1022        let n = v
1023            .as_i64()
1024            .ok_or_else(|| bad_request("MaxResults must be an integer."))?;
1025        if !(1..=50).contains(&n) {
1026            return Err(bad_request("MaxResults must be between 1 and 50."));
1027        }
1028    }
1029    Ok(())
1030}
1031
1032/// `NextToken` @length is 0..=8192.
1033fn validate_next_token(body: &Value) -> Result<(), AwsServiceError> {
1034    if let Some(t) = body.get("NextToken").and_then(|v| v.as_str()) {
1035        if t.len() > 8192 {
1036            return Err(bad_request("NextToken must be at most 8192 characters."));
1037        }
1038    }
1039    Ok(())
1040}
1041
1042/// Fold `@httpQuery`-bound `maxResults`/`nextToken` (used by ListGroups) into
1043/// the body view so the shared validation + pagination helpers see them.
1044fn inject_query_pagination(req: &AwsRequest, body: &mut Value) {
1045    if body.get("MaxResults").is_none() {
1046        if let Some(v) = req.query_params.get("maxResults") {
1047            body["MaxResults"] = v
1048                .parse::<i64>()
1049                .map(|n| json!(n))
1050                .unwrap_or_else(|_| json!(v));
1051        }
1052    }
1053    if body.get("NextToken").is_none() {
1054        if let Some(v) = req.query_params.get("nextToken") {
1055            body["NextToken"] = json!(v);
1056        }
1057    }
1058}
1059
1060/// Opaque zero-based-offset pagination. A never-issued NextToken yields an
1061/// empty terminal page rather than restarting page one.
1062fn paginate(items: Vec<Value>, body: &Value) -> (Vec<Value>, Option<String>) {
1063    let max = body
1064        .get("MaxResults")
1065        .and_then(|v| v.as_i64())
1066        .map(|n| n as usize)
1067        .unwrap_or(50);
1068    let start = match body.get("NextToken").and_then(|v| v.as_str()) {
1069        Some(t) => match t.parse::<usize>() {
1070            Ok(n) => n,
1071            Err(_) => return (Vec::new(), None),
1072        },
1073        None => 0,
1074    };
1075    let total = items.len();
1076    if start >= total {
1077        return (Vec::new(), None);
1078    }
1079    let end = start.saturating_add(max).min(total);
1080    let next = (end < total).then(|| end.to_string());
1081    (items[start..end].to_vec(), next)
1082}
1083
1084fn decode(seg: &str) -> String {
1085    percent_decode_str(seg).decode_utf8_lossy().into_owned()
1086}
1087
1088/// A 26-char lowercase-alphanumeric id, matching AWS's group/tag-sync-task id
1089/// shape (`[a-z0-9]{26}`).
1090fn short_id() -> String {
1091    const ALPHABET: &[u8] = b"abcdefghijklmnopqrstuvwxyz0123456789";
1092    let mut bytes = uuid::Uuid::new_v4().into_bytes().to_vec();
1093    bytes.extend_from_slice(&uuid::Uuid::new_v4().into_bytes()[..10]);
1094    bytes[..26]
1095        .iter()
1096        .map(|b| ALPHABET[(*b as usize) % ALPHABET.len()] as char)
1097        .collect()
1098}
1099
1100fn group_arn(region: &str, account: &str, name: &str) -> String {
1101    format!(
1102        "arn:aws:resource-groups:{region}:{account}:group/{name}/{}",
1103        short_id()
1104    )
1105}
1106
1107/// Tag-sync-task ARN nests under the group ARN:
1108/// `.../group/<name>/<group-id>/tag-sync-task/<task-id>`.
1109fn tag_sync_task_arn(group_arn: &str) -> String {
1110    format!("{group_arn}/tag-sync-task/{}", short_id())
1111}
1112
1113/// Best-effort `AWS::Service::Type` from an ARN. Returns `None` when the ARN
1114/// shape isn't recognized rather than fabricating a type.
1115fn resource_type_from_arn(arn: &str) -> Option<String> {
1116    let parts: Vec<&str> = arn.split(':').collect();
1117    if parts.len() < 6 || parts[0] != "arn" {
1118        return None;
1119    }
1120    let service = parts[2];
1121    if service.is_empty() {
1122        return None;
1123    }
1124    let resource_seg = parts[5];
1125    let restype = resource_seg
1126        .split(['/', ':'])
1127        .next()
1128        .filter(|s| !s.is_empty())?;
1129    Some(format!(
1130        "AWS::{}::{}",
1131        titlecase(service),
1132        titlecase(restype)
1133    ))
1134}
1135
1136fn titlecase(s: &str) -> String {
1137    let mut c = s.chars();
1138    match c.next() {
1139        Some(f) => f.to_uppercase().collect::<String>() + c.as_str(),
1140        None => String::new(),
1141    }
1142}
1143
1144fn bad_request(msg: &str) -> AwsServiceError {
1145    AwsServiceError::aws_error(StatusCode::BAD_REQUEST, "BadRequestException", msg)
1146}
1147
1148fn not_found(what: &str) -> AwsServiceError {
1149    AwsServiceError::aws_error(
1150        StatusCode::NOT_FOUND,
1151        "NotFoundException",
1152        format!("The specified group or resource '{what}' was not found."),
1153    )
1154}
1155
1156#[cfg(test)]
1157mod query_tests {
1158    use super::*;
1159    use fakecloud_core::multi_account::MultiAccountState;
1160    use fakecloud_core::tag_index::{TagProvider, TagProviderRegistry, TaggedResource};
1161    use parking_lot::RwLock;
1162    use std::collections::BTreeMap;
1163
1164    /// A fixed set of tagged resources, standing in for real service providers.
1165    struct FakeProvider(Vec<TaggedResource>);
1166    impl TagProvider for FakeProvider {
1167        fn tagged_resources(&self, _account_id: &str) -> Vec<TaggedResource> {
1168            self.0.clone()
1169        }
1170    }
1171
1172    fn tagged(arn: &str, rtype: &str, tags: &[(&str, &str)]) -> TaggedResource {
1173        let map: BTreeMap<String, String> = tags
1174            .iter()
1175            .map(|(k, v)| (k.to_string(), v.to_string()))
1176            .collect();
1177        TaggedResource::new(arn, rtype, map)
1178    }
1179
1180    fn svc_with(resources: Vec<TaggedResource>) -> ResourceGroupsService {
1181        let registry = TagProviderRegistry::new();
1182        registry.register(Arc::new(FakeProvider(resources)));
1183        let state: SharedResourceGroupsState = Arc::new(RwLock::new(MultiAccountState::new(
1184            "123456789012",
1185            "us-east-1",
1186            "",
1187        )));
1188        ResourceGroupsService::new(state).with_tag_registry(registry)
1189    }
1190
1191    fn req(path: &str, body: Value) -> AwsRequest {
1192        AwsRequest {
1193            service: "resource-groups".into(),
1194            action: String::new(),
1195            region: "us-east-1".into(),
1196            account_id: "123456789012".into(),
1197            request_id: "test".into(),
1198            headers: http::HeaderMap::new(),
1199            query_params: std::collections::HashMap::new(),
1200            body: serde_json::to_vec(&body).unwrap().into(),
1201            body_stream: parking_lot::Mutex::new(None),
1202            path_segments: vec![],
1203            raw_path: path.into(),
1204            raw_query: String::new(),
1205            method: Method::POST,
1206            is_query_protocol: false,
1207            access_key_id: None,
1208            principal: None,
1209        }
1210    }
1211
1212    async fn body_of(s: &ResourceGroupsService, path: &str, body: Value) -> Value {
1213        let resp = s.handle(req(path, body)).await.unwrap();
1214        serde_json::from_slice(resp.body.expect_bytes()).unwrap()
1215    }
1216
1217    fn tag_query(type_filters: Value, tag_filters: Value) -> Value {
1218        let q = json!({ "ResourceTypeFilters": type_filters, "TagFilters": tag_filters });
1219        json!({ "Type": "TAG_FILTERS_1_0", "Query": q.to_string() })
1220    }
1221
1222    #[tokio::test]
1223    async fn search_resources_evaluates_tag_query() {
1224        let s = svc_with(vec![
1225            tagged(
1226                "arn:aws:ec2:us-east-1:123456789012:instance/i-1",
1227                "ec2:instance",
1228                &[("stage", "test")],
1229            ),
1230            tagged(
1231                "arn:aws:ec2:us-east-1:123456789012:instance/i-2",
1232                "ec2:instance",
1233                &[("stage", "prod")],
1234            ),
1235        ]);
1236        let out = body_of(
1237            &s,
1238            "/resources/search",
1239            json!({
1240                "ResourceQuery": tag_query(
1241                    json!(["AWS::AllSupported"]),
1242                    json!([{ "Key": "stage", "Values": ["test"] }])
1243                )
1244            }),
1245        )
1246        .await;
1247        let ids = out["ResourceIdentifiers"].as_array().unwrap();
1248        assert_eq!(ids.len(), 1, "only the test-tagged instance: {out}");
1249        assert_eq!(
1250            ids[0]["ResourceArn"],
1251            "arn:aws:ec2:us-east-1:123456789012:instance/i-1"
1252        );
1253    }
1254
1255    #[tokio::test]
1256    async fn list_group_resources_evaluates_group_query_and_type_filter() {
1257        let s = svc_with(vec![
1258            tagged(
1259                "arn:aws:ec2:us-east-1:123456789012:instance/i-1",
1260                "ec2:instance",
1261                &[("team", "core")],
1262            ),
1263            tagged("arn:aws:s3:::my-bucket", "s3", &[("team", "core")]),
1264        ]);
1265        // Create a tag-query group scoped to EC2 instances tagged team=core.
1266        s.handle(req(
1267            "/groups",
1268            json!({
1269                "Name": "core-ec2",
1270                "ResourceQuery": tag_query(
1271                    json!(["AWS::EC2::Instance"]),
1272                    json!([{ "Key": "team", "Values": ["core"] }])
1273                )
1274            }),
1275        ))
1276        .await
1277        .unwrap();
1278
1279        let out = body_of(&s, "/list-group-resources", json!({ "Group": "core-ec2" })).await;
1280        let ids = out["ResourceIdentifiers"].as_array().unwrap();
1281        assert_eq!(ids.len(), 1, "only the EC2 instance matches: {out}");
1282        assert_eq!(
1283            ids[0]["ResourceArn"],
1284            "arn:aws:ec2:us-east-1:123456789012:instance/i-1"
1285        );
1286        // Members are also surfaced under Resources with a status.
1287        assert_eq!(out["Resources"][0]["Status"]["Name"], "ACTIVE");
1288    }
1289}