Skip to content

Commit 234f137

Browse files
committed
fix(apigw,apigwv2,cognito,wafv2,ecs): persist dropped params + validate managed rule groups (bug-hunt)
Stubs cluster A from the 2026-07-15 bug hunt (1.26-1.30) — five write-then-read asymmetries where a Create/Update accepted a parameter the corresponding Get/Describe never reflected, plus one op fabricating data for any input. - apigateway (1.26): UpdateUsagePlan `remove` op guard was dead code, so detaching an API stage via {op:remove, /apiStages/<id>:<stage>} silently no-op'd. Guard now admits replace/add/remove. - apigatewayv2 (1.27): UpdateRouteResponse / UpdateIntegrationResponse wholesale-replaced the stored record, dropping any field not in the request. Now merges incoming fields onto the existing record so partial updates preserve untouched fields. - cognito (1.28): CreateUserPoolClient / UpdateUserPoolClient accepted AnalyticsConfiguration but dropped it; DescribeUserPoolClient showed the default. Now persisted and echoed. - wafv2 (1.29): DescribeManagedRuleGroup returned a hardcoded Capacity=50 and a fabricated "RuleA" for ANY vendor/name. Now validated against a real managed rule group catalog (Common/KnownBadInputs/SQLi) with real capacities + rule names; unknown groups return WAFInvalidParameterException. The catalog is the single source of truth behind ListAvailableManagedRuleGroups and DescribeAllManagedProducts too. - ecs (1.30): CreateService / UpdateService dropped serviceConnectConfiguration; DescribeServices never surfaced it. Now persisted and echoed on the PRIMARY deployment (AWS's shape). E2E tests added for each fix.
1 parent 920a22a commit 234f137

17 files changed

Lines changed: 549 additions & 61 deletions

File tree

crates/fakecloud-apigateway/src/service_usage_plans.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,10 @@ impl ApiGatewayService {
9797
.get_mut(&id)
9898
.ok_or_else(|| not_found("UsagePlan not found"))?;
9999
apply_patch_operations(req, |op, path, value| {
100-
if op != "replace" && op != "add" {
100+
// `remove` must fall through too — the /apiStages/<id>:<stage>
101+
// detach arm below is a remove op (previously dead code because
102+
// this guard dropped every non-replace/add op).
103+
if op != "replace" && op != "add" && op != "remove" {
101104
return;
102105
}
103106
match path {

crates/fakecloud-apigatewayv2/src/extras.rs

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1492,7 +1492,25 @@ impl ApiGatewayV2Service {
14921492
} else {
14931493
&mut state.route_responses
14941494
};
1495-
let mut value = entry.clone();
1495+
let bucket = store.entry(api).or_default();
1496+
if !is_create && !bucket.contains_key(&key) {
1497+
return Err(not_found("Response", &id));
1498+
}
1499+
// Update is a partial patch: merge the incoming fields onto the
1500+
// existing record so unspecified members persist. Previously the
1501+
// record was replaced wholesale, wiping every field the caller
1502+
// didn't resend.
1503+
let mut value = if is_create {
1504+
entry.clone()
1505+
} else {
1506+
let mut existing = bucket.get(&key).cloned().unwrap_or_else(|| entry.clone());
1507+
if let (Some(dst), Some(src)) = (existing.as_object_mut(), entry.as_object()) {
1508+
for (k, v) in src {
1509+
dst.insert(k.clone(), v.clone());
1510+
}
1511+
}
1512+
existing
1513+
};
14961514
if is_integration {
14971515
value["IntegrationResponseId"] = json!(id);
14981516
// IntegrationResponseKey is required on the Smithy response shape.
@@ -1513,10 +1531,6 @@ impl ApiGatewayV2Service {
15131531
value["RouteResponseKey"] = json!("$default");
15141532
}
15151533
}
1516-
let bucket = store.entry(api).or_default();
1517-
if !is_create && !bucket.contains_key(&key) {
1518-
return Err(not_found("Response", &id));
1519-
}
15201534
bucket.insert(key, value.clone());
15211535
ok(value)
15221536
}

crates/fakecloud-cognito/src/service/mod.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -914,6 +914,9 @@ fn user_pool_client_to_json(client: &UserPoolClient) -> Value {
914914
}
915915
obj["RefreshTokenRotation"] = r;
916916
}
917+
if let Some(ref analytics) = client.analytics_configuration {
918+
obj["AnalyticsConfiguration"] = analytics.clone();
919+
}
917920

918921
obj
919922
}

crates/fakecloud-cognito/src/service/user_pools.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -594,6 +594,10 @@ impl CognitoService {
594594
.unwrap_or(false),
595595
client_secrets: Vec::new(),
596596
refresh_token_rotation: parse_refresh_token_rotation(&body["RefreshTokenRotation"]),
597+
analytics_configuration: body
598+
.get("AnalyticsConfiguration")
599+
.filter(|v| !v.is_null())
600+
.cloned(),
597601
};
598602

599603
let response = user_pool_client_to_json(&client);
@@ -775,6 +779,9 @@ impl CognitoService {
775779
client.refresh_token_rotation =
776780
parse_refresh_token_rotation(&body["RefreshTokenRotation"]);
777781
}
782+
if let Some(analytics) = body.get("AnalyticsConfiguration").filter(|v| !v.is_null()) {
783+
client.analytics_configuration = Some(analytics.clone());
784+
}
778785

779786
client.last_modified_date = Utc::now();
780787

crates/fakecloud-cognito/src/state.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -609,6 +609,11 @@ pub struct UserPoolClient {
609609
/// (old token invalidated). Default "DISABLED".
610610
#[serde(default)]
611611
pub refresh_token_rotation: Option<RefreshTokenRotationConfig>,
612+
/// Pinpoint AnalyticsConfiguration block, stored verbatim so
613+
/// Create/Update round-trip it through DescribeUserPoolClient. Defaults
614+
/// to absent for pre-existing snapshots.
615+
#[serde(default)]
616+
pub analytics_configuration: Option<serde_json::Value>,
612617
}
613618

614619
#[derive(Clone, Debug, Serialize, Deserialize)]

crates/fakecloud-e2e/tests/apigateway.rs

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,61 @@ async fn api_keys_and_usage_plans() {
170170
.expect("delete_api_key");
171171
}
172172

173+
#[tokio::test]
174+
async fn usage_plan_api_stage_add_then_remove() {
175+
use aws_sdk_apigateway::types::{Op, PatchOperation};
176+
let server = TestServer::start().await;
177+
let client = server.apigateway_client().await;
178+
179+
let plan_id = client
180+
.create_usage_plan()
181+
.name("stage-plan")
182+
.send()
183+
.await
184+
.expect("create_usage_plan")
185+
.id()
186+
.expect("plan id")
187+
.to_string();
188+
189+
// Attach an API stage the way Terraform/CDK does: {op:add, /apiStages}.
190+
let added = client
191+
.update_usage_plan()
192+
.usage_plan_id(&plan_id)
193+
.patch_operations(
194+
PatchOperation::builder()
195+
.op(Op::Add)
196+
.path("/apiStages")
197+
.value("abc123:prod")
198+
.build(),
199+
)
200+
.send()
201+
.await
202+
.expect("add api stage");
203+
assert_eq!(added.api_stages().len(), 1);
204+
assert_eq!(added.api_stages()[0].api_id(), Some("abc123"));
205+
assert_eq!(added.api_stages()[0].stage(), Some("prod"));
206+
207+
// Remove it via {op:remove, /apiStages/<apiId>:<stage>} — the arm that was
208+
// previously dead because the op guard rejected "remove".
209+
let removed = client
210+
.update_usage_plan()
211+
.usage_plan_id(&plan_id)
212+
.patch_operations(
213+
PatchOperation::builder()
214+
.op(Op::Remove)
215+
.path("/apiStages/abc123:prod")
216+
.build(),
217+
)
218+
.send()
219+
.await
220+
.expect("remove api stage");
221+
assert!(
222+
removed.api_stages().is_empty(),
223+
"api stage should be removed, got {:?}",
224+
removed.api_stages()
225+
);
226+
}
227+
173228
#[tokio::test]
174229
async fn get_export_returns_openapi_with_real_paths() {
175230
let server = TestServer::start().await;

crates/fakecloud-e2e/tests/apigatewayv2.rs

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1610,3 +1610,75 @@ async fn update_api_changes_route_selection_expression() {
16101610
let got = client.get_api().api_id(&api_id).send().await.unwrap();
16111611
assert_eq!(got.route_selection_expression(), Some("$request.body.kind"));
16121612
}
1613+
1614+
#[tokio::test]
1615+
async fn update_route_response_merges_fields() {
1616+
let server = TestServer::start().await;
1617+
let client = server.apigatewayv2_client().await;
1618+
1619+
let api_id = client
1620+
.create_api()
1621+
.name("rr-api")
1622+
.protocol_type(aws_sdk_apigatewayv2::types::ProtocolType::Websocket)
1623+
.route_selection_expression("$request.body.action")
1624+
.send()
1625+
.await
1626+
.unwrap()
1627+
.api_id()
1628+
.unwrap()
1629+
.to_string();
1630+
1631+
let route_id = client
1632+
.create_route()
1633+
.api_id(&api_id)
1634+
.route_key("$default")
1635+
.send()
1636+
.await
1637+
.unwrap()
1638+
.route_id()
1639+
.unwrap()
1640+
.to_string();
1641+
1642+
// Create a route response carrying two independent fields.
1643+
let created = client
1644+
.create_route_response()
1645+
.api_id(&api_id)
1646+
.route_id(&route_id)
1647+
.route_response_key("$default")
1648+
.model_selection_expression("$request.body.kind")
1649+
.send()
1650+
.await
1651+
.expect("create_route_response");
1652+
let rr_id = created.route_response_id().unwrap().to_string();
1653+
assert_eq!(
1654+
created.model_selection_expression(),
1655+
Some("$request.body.kind")
1656+
);
1657+
1658+
// Update ONLY the route response key. The previously-set
1659+
// modelSelectionExpression must survive the merge (before the fix, update
1660+
// wholesale-replaced the record and dropped it).
1661+
client
1662+
.update_route_response()
1663+
.api_id(&api_id)
1664+
.route_id(&route_id)
1665+
.route_response_id(&rr_id)
1666+
.route_response_key("$default")
1667+
.send()
1668+
.await
1669+
.expect("update_route_response");
1670+
1671+
let got = client
1672+
.get_route_response()
1673+
.api_id(&api_id)
1674+
.route_id(&route_id)
1675+
.route_response_id(&rr_id)
1676+
.send()
1677+
.await
1678+
.expect("get_route_response");
1679+
assert_eq!(
1680+
got.model_selection_expression(),
1681+
Some("$request.body.kind"),
1682+
"modelSelectionExpression must persist across a partial update"
1683+
);
1684+
}

crates/fakecloud-e2e/tests/cognito.rs

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -447,6 +447,69 @@ async fn cognito_create_describe_user_pool_client() {
447447
assert_eq!(described.user_pool_id().unwrap(), pool_id);
448448
}
449449

450+
#[tokio::test]
451+
async fn cognito_user_pool_client_analytics_configuration_round_trips() {
452+
use aws_sdk_cognitoidentityprovider::types::AnalyticsConfigurationType;
453+
let server = TestServer::start().await;
454+
let client = server.cognito_client().await;
455+
456+
let pool_id = client
457+
.create_user_pool()
458+
.pool_name("analytics-pool")
459+
.send()
460+
.await
461+
.expect("create user pool")
462+
.user_pool()
463+
.unwrap()
464+
.id()
465+
.unwrap()
466+
.to_string();
467+
468+
let analytics = AnalyticsConfigurationType::builder()
469+
.application_id("app-1234567890abcdef")
470+
.role_arn("arn:aws:iam::123456789012:role/pinpoint-role")
471+
.external_id("ext-42")
472+
.user_data_shared(true)
473+
.build();
474+
475+
let created = client
476+
.create_user_pool_client()
477+
.user_pool_id(&pool_id)
478+
.client_name("analytics-client")
479+
.analytics_configuration(analytics)
480+
.send()
481+
.await
482+
.expect("create client with analytics");
483+
let client_id = created
484+
.user_pool_client()
485+
.unwrap()
486+
.client_id()
487+
.unwrap()
488+
.to_string();
489+
let created_ac = created
490+
.user_pool_client()
491+
.unwrap()
492+
.analytics_configuration()
493+
.expect("analytics config echoed on create");
494+
assert_eq!(created_ac.application_id(), Some("app-1234567890abcdef"));
495+
496+
// Persisted: describe reflects it, not the default.
497+
let described = client
498+
.describe_user_pool_client()
499+
.user_pool_id(&pool_id)
500+
.client_id(&client_id)
501+
.send()
502+
.await
503+
.expect("describe client");
504+
let ac = described
505+
.user_pool_client()
506+
.unwrap()
507+
.analytics_configuration()
508+
.expect("analytics config persisted");
509+
assert_eq!(ac.external_id(), Some("ext-42"));
510+
assert!(ac.user_data_shared());
511+
}
512+
450513
#[tokio::test]
451514
async fn cognito_create_client_with_secret() {
452515
let server = TestServer::start().await;

0 commit comments

Comments
 (0)