Skip to content

Commit ac8f1e6

Browse files
authored
fix(logs,sns,secretsmanager): real logGroupClass + aggregate summaries + numeric-filter type + pagination (bug-hunt) (#2307)
2 parents bf6ce9d + ac8caa3 commit ac8f1e6

4 files changed

Lines changed: 404 additions & 5 deletions

File tree

crates/fakecloud-logs/src/service/groups.rs

Lines changed: 267 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,29 @@ use super::{extract_log_group_from_arn, resolve_log_group_name};
1111

1212
use crate::state::LogGroup;
1313

14+
/// Ordered grouping key for `ListAggregateLogGroupSummaries`:
15+
/// (dataSource.Name, dataSource.Type, optional dataSource.Format).
16+
type DataSourceGroupKey = (String, String, Option<String>);
17+
18+
/// Derive a data-source name for a log group from its name.
19+
///
20+
/// fakecloud does not model per-log-group telemetry data sources, so the only
21+
/// real signal available is the log group name. AWS-managed log groups follow
22+
/// the `/aws/<service>/...` convention that identifies the originating service
23+
/// (e.g. `/aws/lambda`, `/aws/vpc`); everything else is grouped by its leading
24+
/// name segment. This is what `ListAggregateLogGroupSummaries` groups on.
25+
fn derive_log_group_data_source(name: &str) -> String {
26+
let segs: Vec<&str> = name.split('/').filter(|s| !s.is_empty()).collect();
27+
if segs.is_empty() {
28+
return "custom".to_string();
29+
}
30+
if segs[0] == "aws" && segs.len() >= 2 {
31+
format!("/aws/{}", segs[1])
32+
} else {
33+
segs[0].to_string()
34+
}
35+
}
36+
1437
impl LogsService {
1538
// ---- Log Groups ----
1639

@@ -533,10 +556,81 @@ impl LogsService {
533556
129,
534557
)?;
535558
validate_optional_string_length("nextToken", body["nextToken"].as_str(), 1, 4096)?;
536-
// Stub: return empty summaries
559+
560+
let group_by = body["groupBy"].as_str().unwrap_or("");
561+
let include_format = group_by == "DATA_SOURCE_NAME_TYPE_AND_FORMAT";
562+
let class_filter = body["logGroupClass"].as_str();
563+
let pattern = body["logGroupNamePattern"].as_str().unwrap_or("");
564+
let limit = body["limit"].as_i64().unwrap_or(50) as usize;
565+
let next_token = body["nextToken"].as_str();
566+
567+
let accounts = self.state.read();
568+
let empty = crate::state::LogsState::new(&req.account_id, &req.region);
569+
let state = accounts.get(&req.account_id).unwrap_or(&empty);
570+
571+
// Aggregate the actual stored log groups by their derived data-source
572+
// characteristics. fakecloud stores raw plaintext events with no OCSF
573+
// transformation, so every group's Type is a plain LogGroup and its
574+
// Format is Plain; the Name is derived from the log group name. Under
575+
// both groupBy modes this collapses to grouping by data-source name.
576+
let mut counts: std::collections::BTreeMap<DataSourceGroupKey, i64> =
577+
std::collections::BTreeMap::new();
578+
for g in state.log_groups.values() {
579+
let class = g.log_group_class.as_deref().unwrap_or("STANDARD");
580+
if let Some(f) = class_filter {
581+
if class != f {
582+
continue;
583+
}
584+
}
585+
if !pattern.is_empty() && !g.name.contains(pattern) {
586+
continue;
587+
}
588+
let ds_name = derive_log_group_data_source(&g.name);
589+
let key = (
590+
ds_name,
591+
"LogGroup".to_string(),
592+
include_format.then(|| "Plain".to_string()),
593+
);
594+
*counts.entry(key).or_insert(0) += 1;
595+
}
596+
597+
let all: Vec<(DataSourceGroupKey, i64)> = counts.into_iter().collect();
598+
599+
// Opaque integer offset token. An unresolvable/garbage token ends the
600+
// listing (empty page, no token) rather than restarting at offset 0,
601+
// which would loop a client that resumes while a token is present.
602+
let start = match next_token {
603+
Some(t) => t.parse::<usize>().unwrap_or(usize::MAX),
604+
None => 0,
605+
}
606+
.min(all.len());
607+
let end = (start + limit).min(all.len());
608+
609+
let summaries: Vec<Value> = all[start..end]
610+
.iter()
611+
.map(|((ds_name, ds_type, ds_format), count)| {
612+
let mut ids = vec![
613+
json!({ "key": "dataSource.Name", "value": ds_name }),
614+
json!({ "key": "dataSource.Type", "value": ds_type }),
615+
];
616+
if let Some(fmt) = ds_format {
617+
ids.push(json!({ "key": "dataSource.Format", "value": fmt }));
618+
}
619+
json!({
620+
"logGroupCount": count,
621+
"groupingIdentifiers": ids,
622+
})
623+
})
624+
.collect();
625+
626+
let mut result = json!({ "aggregateLogGroupSummaries": summaries });
627+
if end < all.len() {
628+
result["nextToken"] = json!(end.to_string());
629+
}
630+
537631
Ok(AwsResponse::json(
538632
StatusCode::OK,
539-
serde_json::to_string(&json!({ "aggregateLogGroupSummaries": [] })).unwrap(),
633+
serde_json::to_string(&result).unwrap(),
540634
))
541635
}
542636

@@ -601,7 +695,14 @@ impl LogsService {
601695
json!({
602696
"logGroupName": g.name,
603697
"logGroupArn": log_group_arn,
604-
"logGroupClass": "STANDARD",
698+
// Render the group's actual stored class, matching
699+
// DescribeLogGroups. CreateLogGroup persists
700+
// INFREQUENT_ACCESS / DELIVERY, so hardcoding STANDARD
701+
// reported the wrong class for those groups.
702+
"logGroupClass": g
703+
.log_group_class
704+
.as_deref()
705+
.unwrap_or("STANDARD"),
605706
})
606707
})
607708
.collect();
@@ -900,4 +1001,167 @@ mod tests {
9001001
let req = make_request("ListAggregateLogGroupSummaries", json!({}));
9011002
assert!(svc.list_aggregate_log_group_summaries(&req).is_err());
9021003
}
1004+
1005+
fn create_group_with_class(svc: &crate::LogsService, name: &str, class: &str) {
1006+
let req = make_request(
1007+
"CreateLogGroup",
1008+
json!({ "logGroupName": name, "logGroupClass": class }),
1009+
);
1010+
svc.create_log_group(&req).unwrap();
1011+
}
1012+
1013+
#[test]
1014+
fn list_log_groups_reports_stored_class() {
1015+
let svc = make_service();
1016+
create_group_with_class(&svc, "/infrequent", "INFREQUENT_ACCESS");
1017+
create_group(&svc, "/standard");
1018+
1019+
let req = make_request("ListLogGroups", json!({}));
1020+
let resp = svc.list_log_groups(&req).unwrap();
1021+
let body: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
1022+
let by_name: std::collections::HashMap<&str, &str> = body["logGroups"]
1023+
.as_array()
1024+
.unwrap()
1025+
.iter()
1026+
.map(|g| {
1027+
(
1028+
g["logGroupName"].as_str().unwrap(),
1029+
g["logGroupClass"].as_str().unwrap(),
1030+
)
1031+
})
1032+
.collect();
1033+
assert_eq!(by_name["/infrequent"], "INFREQUENT_ACCESS");
1034+
assert_eq!(by_name["/standard"], "STANDARD");
1035+
}
1036+
1037+
#[test]
1038+
fn list_aggregate_log_group_summaries_aggregates_created_groups() {
1039+
let svc = make_service();
1040+
create_group(&svc, "/aws/lambda/fn-a");
1041+
create_group(&svc, "/aws/lambda/fn-b");
1042+
create_group(&svc, "/aws/vpc/flowlogs");
1043+
create_group(&svc, "myapp/web");
1044+
1045+
let req = make_request(
1046+
"ListAggregateLogGroupSummaries",
1047+
json!({ "groupBy": "DATA_SOURCE_NAME_AND_TYPE" }),
1048+
);
1049+
let resp = svc.list_aggregate_log_group_summaries(&req).unwrap();
1050+
let body: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
1051+
let summaries = body["aggregateLogGroupSummaries"].as_array().unwrap();
1052+
1053+
// Three data sources: /aws/lambda (2), /aws/vpc (1), myapp (1).
1054+
assert_eq!(summaries.len(), 3);
1055+
let total: i64 = summaries
1056+
.iter()
1057+
.map(|s| s["logGroupCount"].as_i64().unwrap())
1058+
.sum();
1059+
assert_eq!(total, 4);
1060+
1061+
let find = |name: &str| -> i64 {
1062+
summaries
1063+
.iter()
1064+
.find(|s| {
1065+
s["groupingIdentifiers"]
1066+
.as_array()
1067+
.unwrap()
1068+
.iter()
1069+
.any(|id| id["key"] == "dataSource.Name" && id["value"] == name)
1070+
})
1071+
.map(|s| s["logGroupCount"].as_i64().unwrap())
1072+
.unwrap_or(0)
1073+
};
1074+
assert_eq!(find("/aws/lambda"), 2);
1075+
assert_eq!(find("/aws/vpc"), 1);
1076+
assert_eq!(find("myapp"), 1);
1077+
1078+
// NAME_AND_TYPE must not emit a Format identifier.
1079+
assert!(!summaries[0]["groupingIdentifiers"]
1080+
.as_array()
1081+
.unwrap()
1082+
.iter()
1083+
.any(|id| id["key"] == "dataSource.Format"));
1084+
}
1085+
1086+
#[test]
1087+
fn list_aggregate_log_group_summaries_format_and_class_filter() {
1088+
let svc = make_service();
1089+
create_group_with_class(&svc, "/aws/lambda/fn", "INFREQUENT_ACCESS");
1090+
create_group(&svc, "/aws/lambda/other"); // STANDARD
1091+
1092+
// Filter to INFREQUENT_ACCESS only -> one group.
1093+
let req = make_request(
1094+
"ListAggregateLogGroupSummaries",
1095+
json!({
1096+
"groupBy": "DATA_SOURCE_NAME_TYPE_AND_FORMAT",
1097+
"logGroupClass": "INFREQUENT_ACCESS",
1098+
}),
1099+
);
1100+
let resp = svc.list_aggregate_log_group_summaries(&req).unwrap();
1101+
let body: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
1102+
let summaries = body["aggregateLogGroupSummaries"].as_array().unwrap();
1103+
assert_eq!(summaries.len(), 1);
1104+
assert_eq!(summaries[0]["logGroupCount"], json!(1));
1105+
// FORMAT groupBy emits a dataSource.Format identifier.
1106+
assert!(summaries[0]["groupingIdentifiers"]
1107+
.as_array()
1108+
.unwrap()
1109+
.iter()
1110+
.any(|id| id["key"] == "dataSource.Format"));
1111+
}
1112+
1113+
#[test]
1114+
fn list_aggregate_log_group_summaries_paginates() {
1115+
let svc = make_service();
1116+
create_group(&svc, "/aws/a/x");
1117+
create_group(&svc, "/aws/b/x");
1118+
create_group(&svc, "/aws/c/x");
1119+
1120+
let req = make_request(
1121+
"ListAggregateLogGroupSummaries",
1122+
json!({ "groupBy": "DATA_SOURCE_NAME_AND_TYPE", "limit": 2 }),
1123+
);
1124+
let resp = svc.list_aggregate_log_group_summaries(&req).unwrap();
1125+
let body: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
1126+
assert_eq!(
1127+
body["aggregateLogGroupSummaries"].as_array().unwrap().len(),
1128+
2
1129+
);
1130+
let token = body["nextToken"].as_str().expect("nextToken on page 1");
1131+
1132+
let req2 = make_request(
1133+
"ListAggregateLogGroupSummaries",
1134+
json!({ "groupBy": "DATA_SOURCE_NAME_AND_TYPE", "limit": 2, "nextToken": token }),
1135+
);
1136+
let resp2 = svc.list_aggregate_log_group_summaries(&req2).unwrap();
1137+
let body2: Value = serde_json::from_slice(resp2.body.expect_bytes()).unwrap();
1138+
assert_eq!(
1139+
body2["aggregateLogGroupSummaries"]
1140+
.as_array()
1141+
.unwrap()
1142+
.len(),
1143+
1
1144+
);
1145+
assert!(body2["nextToken"].is_null());
1146+
}
1147+
1148+
#[test]
1149+
fn list_aggregate_log_group_summaries_garbage_token_ends_listing() {
1150+
let svc = make_service();
1151+
create_group(&svc, "/aws/a/x");
1152+
create_group(&svc, "/aws/b/x");
1153+
1154+
// A non-integer token must end the listing rather than restart page 1.
1155+
let req = make_request(
1156+
"ListAggregateLogGroupSummaries",
1157+
json!({ "groupBy": "DATA_SOURCE_NAME_AND_TYPE", "nextToken": "not-a-number" }),
1158+
);
1159+
let resp = svc.list_aggregate_log_group_summaries(&req).unwrap();
1160+
let body: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
1161+
assert_eq!(
1162+
body["aggregateLogGroupSummaries"].as_array().unwrap().len(),
1163+
0
1164+
);
1165+
assert!(body["nextToken"].is_null());
1166+
}
9031167
}

crates/fakecloud-secretsmanager/src/service.rs

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1043,9 +1043,14 @@ impl SecretsManagerService {
10431043
.collect();
10441044
secrets.sort_by_key(|a| a.created_at);
10451045

1046-
// Simple pagination with name-based token
1046+
// Simple pagination with name-based token. When the token's secret was
1047+
// deleted between pages the lookup fails; end the listing (empty page,
1048+
// no token) rather than restarting at offset 0 (which could loop).
10471049
let start_idx = if let Some(token) = next_token {
1048-
secrets.iter().position(|s| s.name == token).unwrap_or(0)
1050+
secrets
1051+
.iter()
1052+
.position(|s| s.name == token)
1053+
.unwrap_or(secrets.len())
10491054
} else {
10501055
0
10511056
};

crates/fakecloud-secretsmanager/src/service_tests.rs

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1828,6 +1828,79 @@ async fn batch_get_secret_value_with_filters() {
18281828
assert_eq!(b["SecretValues"].as_array().unwrap().len(), 2);
18291829
}
18301830

1831+
#[tokio::test]
1832+
async fn batch_get_secret_value_filters_paginate() {
1833+
let state = make_state();
1834+
let svc = SecretsManagerService::new(state);
1835+
1836+
// Five matching secrets; MaxResults=2 forces three pages.
1837+
for i in 0..5 {
1838+
let body = serde_json::json!({"Name": format!("batch-page-{i}"), "SecretString": "v"});
1839+
let req = make_request("CreateSecret", &body.to_string());
1840+
svc.handle(req).await.unwrap();
1841+
}
1842+
1843+
let mut seen = std::collections::BTreeSet::new();
1844+
let mut token: Option<String> = None;
1845+
let mut pages = 0;
1846+
loop {
1847+
let mut body = serde_json::json!({
1848+
"Filters": [{"Key": "name", "Values": ["batch-page"]}],
1849+
"MaxResults": 2,
1850+
});
1851+
if let Some(t) = &token {
1852+
body["NextToken"] = serde_json::json!(t);
1853+
}
1854+
let req = make_request("BatchGetSecretValue", &body.to_string());
1855+
let resp = svc.handle(req).await.unwrap();
1856+
let b: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
1857+
for v in b["SecretValues"].as_array().unwrap() {
1858+
seen.insert(v["Name"].as_str().unwrap().to_string());
1859+
}
1860+
pages += 1;
1861+
match b["NextToken"].as_str() {
1862+
Some(t) => token = Some(t.to_string()),
1863+
None => break,
1864+
}
1865+
assert!(pages < 10, "pagination did not terminate");
1866+
}
1867+
// All five reachable across pages, not just the first page of 2.
1868+
assert_eq!(seen.len(), 5);
1869+
assert_eq!(pages, 3);
1870+
}
1871+
1872+
#[tokio::test]
1873+
async fn list_secrets_stale_token_ends_listing() {
1874+
let state = make_state();
1875+
let svc = SecretsManagerService::new(state);
1876+
1877+
for i in 0..4 {
1878+
let body = serde_json::json!({"Name": format!("stale-{i}"), "SecretString": "v"});
1879+
let req = make_request("CreateSecret", &body.to_string());
1880+
svc.handle(req).await.unwrap();
1881+
}
1882+
1883+
// First page of 2 yields a NextToken (the name of the next secret).
1884+
let body = serde_json::json!({"MaxResults": 2});
1885+
let req = make_request("ListSecrets", &body.to_string());
1886+
let resp = svc.handle(req).await.unwrap();
1887+
let b: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
1888+
let token = b["NextToken"].as_str().unwrap().to_string();
1889+
1890+
// Delete the token's secret, then resume: the token no longer resolves.
1891+
// Listing must END (empty page, no token) instead of restarting at page 1.
1892+
let del = serde_json::json!({"SecretId": token, "ForceDeleteWithoutRecovery": true});
1893+
let req = make_request("DeleteSecret", &del.to_string());
1894+
svc.handle(req).await.unwrap();
1895+
1896+
let body = serde_json::json!({"MaxResults": 2, "NextToken": token});
1897+
let req = make_request("ListSecrets", &body.to_string());
1898+
let resp = svc.handle(req).await.unwrap();
1899+
let b: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
1900+
assert_eq!(b["SecretList"].as_array().unwrap().len(), 0);
1901+
assert!(b["NextToken"].is_null());
1902+
}
1903+
18311904
// ── RotateSecret validation ──
18321905

18331906
#[tokio::test]

0 commit comments

Comments
 (0)