diff --git a/crates/fakecloud-apigateway/src/service_usage_plans.rs b/crates/fakecloud-apigateway/src/service_usage_plans.rs index 05cac4133..dcd8532a4 100644 --- a/crates/fakecloud-apigateway/src/service_usage_plans.rs +++ b/crates/fakecloud-apigateway/src/service_usage_plans.rs @@ -97,7 +97,10 @@ impl ApiGatewayService { .get_mut(&id) .ok_or_else(|| not_found("UsagePlan not found"))?; apply_patch_operations(req, |op, path, value| { - if op != "replace" && op != "add" { + // `remove` must fall through too — the /apiStages/: + // detach arm below is a remove op (previously dead code because + // this guard dropped every non-replace/add op). + if op != "replace" && op != "add" && op != "remove" { return; } match path { diff --git a/crates/fakecloud-apigatewayv2/src/extras.rs b/crates/fakecloud-apigatewayv2/src/extras.rs index c30f6313f..0745ce9e0 100644 --- a/crates/fakecloud-apigatewayv2/src/extras.rs +++ b/crates/fakecloud-apigatewayv2/src/extras.rs @@ -1492,7 +1492,25 @@ impl ApiGatewayV2Service { } else { &mut state.route_responses }; - let mut value = entry.clone(); + let bucket = store.entry(api).or_default(); + if !is_create && !bucket.contains_key(&key) { + return Err(not_found("Response", &id)); + } + // Update is a partial patch: merge the incoming fields onto the + // existing record so unspecified members persist. Previously the + // record was replaced wholesale, wiping every field the caller + // didn't resend. + let mut value = if is_create { + entry.clone() + } else { + let mut existing = bucket.get(&key).cloned().unwrap_or_else(|| entry.clone()); + if let (Some(dst), Some(src)) = (existing.as_object_mut(), entry.as_object()) { + for (k, v) in src { + dst.insert(k.clone(), v.clone()); + } + } + existing + }; if is_integration { value["IntegrationResponseId"] = json!(id); // IntegrationResponseKey is required on the Smithy response shape. @@ -1513,10 +1531,6 @@ impl ApiGatewayV2Service { value["RouteResponseKey"] = json!("$default"); } } - let bucket = store.entry(api).or_default(); - if !is_create && !bucket.contains_key(&key) { - return Err(not_found("Response", &id)); - } bucket.insert(key, value.clone()); ok(value) } diff --git a/crates/fakecloud-cloudformation/src/resource_provisioner/cognito.rs b/crates/fakecloud-cloudformation/src/resource_provisioner/cognito.rs index 7391d59b9..48eca6a01 100644 --- a/crates/fakecloud-cloudformation/src/resource_provisioner/cognito.rs +++ b/crates/fakecloud-cloudformation/src/resource_provisioner/cognito.rs @@ -261,6 +261,10 @@ impl ResourceProvisioner { .unwrap_or(false), client_secrets: Vec::new(), refresh_token_rotation: None, + analytics_configuration: props + .get("AnalyticsConfiguration") + .filter(|v| !v.is_null()) + .cloned(), }; state.user_pool_clients.insert(client_id.clone(), client); diff --git a/crates/fakecloud-cloudformation/src/resource_provisioner/ecs.rs b/crates/fakecloud-cloudformation/src/resource_provisioner/ecs.rs index d287ccb17..06032e89b 100644 --- a/crates/fakecloud-cloudformation/src/resource_provisioner/ecs.rs +++ b/crates/fakecloud-cloudformation/src/resource_provisioner/ecs.rs @@ -393,6 +393,10 @@ impl ResourceProvisioner { capacity_provider_strategy, availability_zone_rebalancing, volume_configurations: Vec::new(), + service_connect_configuration: props + .get("ServiceConnectConfiguration") + .filter(|v| !v.is_null()) + .cloned(), }; state.services.insert(key.clone(), service); if let Some(c) = state.clusters.get_mut(&cluster_name) { diff --git a/crates/fakecloud-cognito/src/service/mod.rs b/crates/fakecloud-cognito/src/service/mod.rs index b01e3ed0a..ed4d2c25d 100644 --- a/crates/fakecloud-cognito/src/service/mod.rs +++ b/crates/fakecloud-cognito/src/service/mod.rs @@ -914,6 +914,9 @@ fn user_pool_client_to_json(client: &UserPoolClient) -> Value { } obj["RefreshTokenRotation"] = r; } + if let Some(ref analytics) = client.analytics_configuration { + obj["AnalyticsConfiguration"] = analytics.clone(); + } obj } diff --git a/crates/fakecloud-cognito/src/service/user_pools.rs b/crates/fakecloud-cognito/src/service/user_pools.rs index 5a16e386e..b858302fc 100644 --- a/crates/fakecloud-cognito/src/service/user_pools.rs +++ b/crates/fakecloud-cognito/src/service/user_pools.rs @@ -594,6 +594,10 @@ impl CognitoService { .unwrap_or(false), client_secrets: Vec::new(), refresh_token_rotation: parse_refresh_token_rotation(&body["RefreshTokenRotation"]), + analytics_configuration: body + .get("AnalyticsConfiguration") + .filter(|v| !v.is_null()) + .cloned(), }; let response = user_pool_client_to_json(&client); @@ -775,6 +779,9 @@ impl CognitoService { client.refresh_token_rotation = parse_refresh_token_rotation(&body["RefreshTokenRotation"]); } + if let Some(analytics) = body.get("AnalyticsConfiguration").filter(|v| !v.is_null()) { + client.analytics_configuration = Some(analytics.clone()); + } client.last_modified_date = Utc::now(); diff --git a/crates/fakecloud-cognito/src/state.rs b/crates/fakecloud-cognito/src/state.rs index 02847605e..ee632d6c1 100644 --- a/crates/fakecloud-cognito/src/state.rs +++ b/crates/fakecloud-cognito/src/state.rs @@ -609,6 +609,11 @@ pub struct UserPoolClient { /// (old token invalidated). Default "DISABLED". #[serde(default)] pub refresh_token_rotation: Option, + /// Pinpoint AnalyticsConfiguration block, stored verbatim so + /// Create/Update round-trip it through DescribeUserPoolClient. Defaults + /// to absent for pre-existing snapshots. + #[serde(default)] + pub analytics_configuration: Option, } #[derive(Clone, Debug, Serialize, Deserialize)] diff --git a/crates/fakecloud-e2e/tests/apigateway.rs b/crates/fakecloud-e2e/tests/apigateway.rs index 529f04337..a106c7220 100644 --- a/crates/fakecloud-e2e/tests/apigateway.rs +++ b/crates/fakecloud-e2e/tests/apigateway.rs @@ -170,6 +170,61 @@ async fn api_keys_and_usage_plans() { .expect("delete_api_key"); } +#[tokio::test] +async fn usage_plan_api_stage_add_then_remove() { + use aws_sdk_apigateway::types::{Op, PatchOperation}; + let server = TestServer::start().await; + let client = server.apigateway_client().await; + + let plan_id = client + .create_usage_plan() + .name("stage-plan") + .send() + .await + .expect("create_usage_plan") + .id() + .expect("plan id") + .to_string(); + + // Attach an API stage the way Terraform/CDK does: {op:add, /apiStages}. + let added = client + .update_usage_plan() + .usage_plan_id(&plan_id) + .patch_operations( + PatchOperation::builder() + .op(Op::Add) + .path("/apiStages") + .value("abc123:prod") + .build(), + ) + .send() + .await + .expect("add api stage"); + assert_eq!(added.api_stages().len(), 1); + assert_eq!(added.api_stages()[0].api_id(), Some("abc123")); + assert_eq!(added.api_stages()[0].stage(), Some("prod")); + + // Remove it via {op:remove, /apiStages/:} — the arm that was + // previously dead because the op guard rejected "remove". + let removed = client + .update_usage_plan() + .usage_plan_id(&plan_id) + .patch_operations( + PatchOperation::builder() + .op(Op::Remove) + .path("/apiStages/abc123:prod") + .build(), + ) + .send() + .await + .expect("remove api stage"); + assert!( + removed.api_stages().is_empty(), + "api stage should be removed, got {:?}", + removed.api_stages() + ); +} + #[tokio::test] async fn get_export_returns_openapi_with_real_paths() { let server = TestServer::start().await; diff --git a/crates/fakecloud-e2e/tests/apigatewayv2.rs b/crates/fakecloud-e2e/tests/apigatewayv2.rs index ae57056c5..95b2c9e66 100644 --- a/crates/fakecloud-e2e/tests/apigatewayv2.rs +++ b/crates/fakecloud-e2e/tests/apigatewayv2.rs @@ -1610,3 +1610,75 @@ async fn update_api_changes_route_selection_expression() { let got = client.get_api().api_id(&api_id).send().await.unwrap(); assert_eq!(got.route_selection_expression(), Some("$request.body.kind")); } + +#[tokio::test] +async fn update_route_response_merges_fields() { + let server = TestServer::start().await; + let client = server.apigatewayv2_client().await; + + let api_id = client + .create_api() + .name("rr-api") + .protocol_type(aws_sdk_apigatewayv2::types::ProtocolType::Websocket) + .route_selection_expression("$request.body.action") + .send() + .await + .unwrap() + .api_id() + .unwrap() + .to_string(); + + let route_id = client + .create_route() + .api_id(&api_id) + .route_key("$default") + .send() + .await + .unwrap() + .route_id() + .unwrap() + .to_string(); + + // Create a route response carrying two independent fields. + let created = client + .create_route_response() + .api_id(&api_id) + .route_id(&route_id) + .route_response_key("$default") + .model_selection_expression("$request.body.kind") + .send() + .await + .expect("create_route_response"); + let rr_id = created.route_response_id().unwrap().to_string(); + assert_eq!( + created.model_selection_expression(), + Some("$request.body.kind") + ); + + // Update ONLY the route response key. The previously-set + // modelSelectionExpression must survive the merge (before the fix, update + // wholesale-replaced the record and dropped it). + client + .update_route_response() + .api_id(&api_id) + .route_id(&route_id) + .route_response_id(&rr_id) + .route_response_key("$default") + .send() + .await + .expect("update_route_response"); + + let got = client + .get_route_response() + .api_id(&api_id) + .route_id(&route_id) + .route_response_id(&rr_id) + .send() + .await + .expect("get_route_response"); + assert_eq!( + got.model_selection_expression(), + Some("$request.body.kind"), + "modelSelectionExpression must persist across a partial update" + ); +} diff --git a/crates/fakecloud-e2e/tests/cognito.rs b/crates/fakecloud-e2e/tests/cognito.rs index 5aab8171f..6a5075d42 100644 --- a/crates/fakecloud-e2e/tests/cognito.rs +++ b/crates/fakecloud-e2e/tests/cognito.rs @@ -447,6 +447,69 @@ async fn cognito_create_describe_user_pool_client() { assert_eq!(described.user_pool_id().unwrap(), pool_id); } +#[tokio::test] +async fn cognito_user_pool_client_analytics_configuration_round_trips() { + use aws_sdk_cognitoidentityprovider::types::AnalyticsConfigurationType; + let server = TestServer::start().await; + let client = server.cognito_client().await; + + let pool_id = client + .create_user_pool() + .pool_name("analytics-pool") + .send() + .await + .expect("create user pool") + .user_pool() + .unwrap() + .id() + .unwrap() + .to_string(); + + let analytics = AnalyticsConfigurationType::builder() + .application_id("app-1234567890abcdef") + .role_arn("arn:aws:iam::123456789012:role/pinpoint-role") + .external_id("ext-42") + .user_data_shared(true) + .build(); + + let created = client + .create_user_pool_client() + .user_pool_id(&pool_id) + .client_name("analytics-client") + .analytics_configuration(analytics) + .send() + .await + .expect("create client with analytics"); + let client_id = created + .user_pool_client() + .unwrap() + .client_id() + .unwrap() + .to_string(); + let created_ac = created + .user_pool_client() + .unwrap() + .analytics_configuration() + .expect("analytics config echoed on create"); + assert_eq!(created_ac.application_id(), Some("app-1234567890abcdef")); + + // Persisted: describe reflects it, not the default. + let described = client + .describe_user_pool_client() + .user_pool_id(&pool_id) + .client_id(&client_id) + .send() + .await + .expect("describe client"); + let ac = described + .user_pool_client() + .unwrap() + .analytics_configuration() + .expect("analytics config persisted"); + assert_eq!(ac.external_id(), Some("ext-42")); + assert!(ac.user_data_shared()); +} + #[tokio::test] async fn cognito_create_client_with_secret() { let server = TestServer::start().await; diff --git a/crates/fakecloud-e2e/tests/ecs.rs b/crates/fakecloud-e2e/tests/ecs.rs index ddae178f7..72e131027 100644 --- a/crates/fakecloud-e2e/tests/ecs.rs +++ b/crates/fakecloud-e2e/tests/ecs.rs @@ -739,6 +739,99 @@ async fn bootstrap_service_fixtures(client: &aws_sdk_ecs::Client, cluster: &str, .unwrap(); } +#[tokio::test] +async fn service_connect_configuration_round_trips_on_describe() { + use aws_sdk_ecs::types::{ + ServiceConnectClientAlias, ServiceConnectConfiguration, ServiceConnectService, + }; + // desired_count 0 -> no task spawn -> no Docker needed; this exercises the + // control-plane persistence of serviceConnectConfiguration only. + let server = TestServer::start().await; + let client = server.ecs_client().await; + bootstrap_service_fixtures(&client, "sc-cluster", "sc-td").await; + + let sc = ServiceConnectConfiguration::builder() + .enabled(true) + .namespace("sc-ns") + .services( + ServiceConnectService::builder() + .port_name("app") + .client_aliases( + ServiceConnectClientAlias::builder() + .port(8080) + .dns_name("web.internal") + .build() + .unwrap(), + ) + .build() + .unwrap(), + ) + .build(); + + client + .create_service() + .cluster("sc-cluster") + .service_name("web") + .task_definition("sc-td") + .desired_count(0) + .service_connect_configuration(sc) + .send() + .await + .expect("create_service with service connect"); + + let described = client + .describe_services() + .cluster("sc-cluster") + .services("web") + .send() + .await + .expect("describe_services"); + let svc = &described.services()[0]; + let primary = svc + .deployments() + .iter() + .find(|d| d.status() == Some("PRIMARY")) + .expect("primary deployment"); + let got = primary + .service_connect_configuration() + .expect("serviceConnectConfiguration must round-trip"); + assert!(got.enabled()); + assert_eq!(got.namespace(), Some("sc-ns")); + assert_eq!(got.services()[0].port_name(), "app"); + + // Updating with an empty configuration disables Service Connect. + client + .update_service() + .cluster("sc-cluster") + .service("web") + .service_connect_configuration( + ServiceConnectConfiguration::builder() + .enabled(false) + .build(), + ) + .send() + .await + .expect("update_service disables service connect"); + let redescribed = client + .describe_services() + .cluster("sc-cluster") + .services("web") + .send() + .await + .expect("describe_services 2"); + let primary2 = redescribed.services()[0] + .deployments() + .iter() + .find(|d| d.status() == Some("PRIMARY")) + .expect("primary deployment 2"); + assert_eq!( + primary2 + .service_connect_configuration() + .map(|c| c.enabled()), + Some(false) + ); +} + #[tokio::test] async fn create_service_spawns_desired_tasks_and_describe_roundtrips() { if !require_docker_or_skip("create_service_spawns_desired_tasks_and_describe_roundtrips") { diff --git a/crates/fakecloud-e2e/tests/wafv2.rs b/crates/fakecloud-e2e/tests/wafv2.rs index eef8004fa..b22be16b3 100644 --- a/crates/fakecloud-e2e/tests/wafv2.rs +++ b/crates/fakecloud-e2e/tests/wafv2.rs @@ -723,6 +723,46 @@ async fn list_managed_rule_groups_returns_seeded_set() { .any(|g| g.name().unwrap_or("") == "AWSManagedRulesCommonRuleSet")); } +#[tokio::test] +async fn describe_managed_rule_group_returns_real_rules_and_rejects_unknown() { + let server = TestServer::start().await; + let waf = server.wafv2_client().await; + + // A known managed group returns its real capacity + rule set, not a + // fabricated "RuleA" placeholder. + let desc = waf + .describe_managed_rule_group() + .vendor_name("AWS") + .name("AWSManagedRulesCommonRuleSet") + .scope(Scope::Regional) + .send() + .await + .expect("describe known group"); + assert_eq!(desc.capacity(), Some(700)); + let rule_names: Vec<&str> = desc.rules().iter().filter_map(|r| r.name()).collect(); + assert!( + rule_names.contains(&"CrossSiteScripting_BODY"), + "expected real CRS rules, got {rule_names:?}" + ); + assert!(!rule_names.contains(&"RuleA"), "must not fabricate RuleA"); + + // An unknown group is rejected with WAFInvalidParameterException instead + // of a fabricated response. + let err = waf + .describe_managed_rule_group() + .vendor_name("AWS") + .name("AWSManagedRulesNonexistentRuleSet") + .scope(Scope::Regional) + .send() + .await + .expect_err("unknown group must error"); + let svc = err.into_service_error(); + assert!( + svc.meta().code() == Some("WAFInvalidParameterException"), + "unexpected error: {svc:?}" + ); +} + #[tokio::test] async fn get_mobile_sdk_release_url_uses_provided_platform() { let server = TestServer::start().await; diff --git a/crates/fakecloud-ecs/src/helpers.rs b/crates/fakecloud-ecs/src/helpers.rs index 33711ca3f..c0e8d03fa 100644 --- a/crates/fakecloud-ecs/src/helpers.rs +++ b/crates/fakecloud-ecs/src/helpers.rs @@ -1234,10 +1234,21 @@ pub(crate) fn service_to_json(svc: &Service) -> Value { Value::Object(deployment_cfg), ); } - map.insert( - "deployments".into(), - Value::Array(svc.deployments.iter().map(deployment_to_json).collect()), - ); + let mut deployment_values: Vec = + svc.deployments.iter().map(deployment_to_json).collect(); + // AWS carries serviceConnectConfiguration on each deployment. Echo the + // service's stored config onto the PRIMARY deployment so a Create/Update + // that supplied one round-trips on DescribeServices instead of vanishing. + if let Some(ref sc) = svc.service_connect_configuration { + for (dv, dep) in deployment_values.iter_mut().zip(svc.deployments.iter()) { + if dep.status == "PRIMARY" { + if let Some(obj) = dv.as_object_mut() { + obj.insert("serviceConnectConfiguration".into(), sc.clone()); + } + } + } + } + map.insert("deployments".into(), Value::Array(deployment_values)); map.insert( "loadBalancers".into(), Value::Array(svc.load_balancers.clone()), diff --git a/crates/fakecloud-ecs/src/runtime/mod.rs b/crates/fakecloud-ecs/src/runtime/mod.rs index 3f2a44acd..0f5f3cfcf 100644 --- a/crates/fakecloud-ecs/src/runtime/mod.rs +++ b/crates/fakecloud-ecs/src/runtime/mod.rs @@ -2953,6 +2953,7 @@ mod tests { placement_strategy: Vec::new(), network_configuration: None, volume_configurations: vec![], + service_connect_configuration: None, tags: Vec::new(), created_at: Utc::now(), created_by: None, @@ -3080,6 +3081,7 @@ mod tests { placement_strategy: Vec::new(), network_configuration: None, volume_configurations: vec![], + service_connect_configuration: None, tags: Vec::new(), created_at: Utc::now(), created_by: None, diff --git a/crates/fakecloud-ecs/src/service_services_resource.rs b/crates/fakecloud-ecs/src/service_services_resource.rs index 1761c73d0..1fff5ed1b 100644 --- a/crates/fakecloud-ecs/src/service_services_resource.rs +++ b/crates/fakecloud-ecs/src/service_services_resource.rs @@ -236,6 +236,10 @@ impl EcsService { capacity_provider_strategy: capacity_provider_strategy.clone(), availability_zone_rebalancing: availability_zone_rebalancing.clone(), volume_configurations: volume_configurations.clone(), + service_connect_configuration: body + .get("serviceConnectConfiguration") + .filter(|v| !v.is_null()) + .cloned(), }; state.services.insert(key.clone(), service.clone()); state.record_service_revision(&service); @@ -432,6 +436,12 @@ impl EcsService { if let Some(v) = body.get("networkConfiguration") { svc.network_configuration = Some(v.clone()); } + if let Some(v) = body.get("serviceConnectConfiguration") { + // An empty configuration ({}) disables Service Connect; a + // populated one replaces it. Both round-trip on Describe. + svc.service_connect_configuration = + if v.is_null() { None } else { Some(v.clone()) }; + } if let Some(s) = opt_str(&body, "platformVersion") { svc.platform_version = Some(s.to_string()); } diff --git a/crates/fakecloud-ecs/src/service_tests.rs b/crates/fakecloud-ecs/src/service_tests.rs index 550ff89a7..bc469773e 100644 --- a/crates/fakecloud-ecs/src/service_tests.rs +++ b/crates/fakecloud-ecs/src/service_tests.rs @@ -82,6 +82,7 @@ fn resolve_service_key_handles_short_and_long() { capacity_provider_strategy: vec![], availability_zone_rebalancing: None, volume_configurations: vec![], + service_connect_configuration: None, }, ); // Long-form: cluster/service. @@ -329,6 +330,7 @@ mod scheduler_reconcile { capacity_provider_strategy: vec![], availability_zone_rebalancing: None, volume_configurations: vec![], + service_connect_configuration: None, } } diff --git a/crates/fakecloud-ecs/src/state.rs b/crates/fakecloud-ecs/src/state.rs index 6ec0d4d01..ae98d72a9 100644 --- a/crates/fakecloud-ecs/src/state.rs +++ b/crates/fakecloud-ecs/src/state.rs @@ -640,6 +640,11 @@ pub struct Service { /// launched under this service. Stored as raw JSON. #[serde(default)] pub volume_configurations: Vec, + /// Service Connect configuration (namespace + service mappings) supplied on + /// Create/UpdateService. Preserved as raw JSON so every optional field + /// round-trips on DescribeServices instead of silently dropping. + #[serde(default)] + pub service_connect_configuration: Option, } #[derive(Clone, Debug, Serialize, Deserialize)] diff --git a/crates/fakecloud-wafv2/src/service/mod.rs b/crates/fakecloud-wafv2/src/service/mod.rs index 1d66d27c9..8675408bb 100644 --- a/crates/fakecloud-wafv2/src/service/mod.rs +++ b/crates/fakecloud-wafv2/src/service/mod.rs @@ -694,38 +694,137 @@ fn count_statement_leaves(stmt: &Value) -> u32 { total.max(1) } -fn managed_products() -> Vec { - vec![ - json!({ - "VendorName": "AWS", - "ManagedRuleSetName": "AWSManagedRulesCommonRuleSet", - "ProductId": "prod-aws-common", - "ProductLink": "https://docs.aws.amazon.com/waf/latest/developerguide/aws-managed-rule-groups-list.html", - "ProductTitle": "Core rule set", - "ProductDescription": "OWASP Top 10 baseline rules", - "SnsTopicArn": "arn:aws:sns:us-east-1::aws-managed-common-notifications", - "IsVersioningSupported": true, - "IsAdvancedManagedRuleSet": false, - }), - json!({ - "VendorName": "AWS", - "ManagedRuleSetName": "AWSManagedRulesSQLiRuleSet", - "ProductId": "prod-aws-sqli", - "ProductLink": "https://docs.aws.amazon.com/waf/latest/developerguide/aws-managed-rule-groups-list.html", - "ProductTitle": "SQL injection rule set", - "ProductDescription": "Rules that block SQL injection patterns", - "SnsTopicArn": "arn:aws:sns:us-east-1::aws-managed-sqli-notifications", - "IsVersioningSupported": true, - "IsAdvancedManagedRuleSet": false, - }), +/// A well-known AWS managed rule group. This is the single source of truth +/// behind ListAvailableManagedRuleGroups, DescribeAllManagedProducts, and +/// DescribeManagedRuleGroup so the three ops never disagree about which groups +/// exist, their capacity, or the rules they contain. +pub(super) struct ManagedGroupDef { + pub vendor: &'static str, + pub name: &'static str, + pub product_id: &'static str, + pub product_title: &'static str, + pub description: &'static str, + pub capacity: i64, + pub rules: &'static [&'static str], +} + +/// The AWS baseline managed rule groups fakecloud recognizes. Capacities and +/// rule names mirror the published AWS WAF managed-rule-group reference so a +/// DescribeManagedRuleGroup round-trips real data rather than a fabricated +/// placeholder. +pub(super) fn managed_rule_group_catalog() -> &'static [ManagedGroupDef] { + &[ + ManagedGroupDef { + vendor: "AWS", + name: "AWSManagedRulesCommonRuleSet", + product_id: "prod-aws-common", + product_title: "Core rule set", + description: "OWASP Top 10 baseline rules", + capacity: 700, + rules: &[ + "NoUserAgent_HEADER", + "UserAgent_BadBots_HEADER", + "SizeRestrictions_QUERYSTRING", + "SizeRestrictions_Cookie_HEADER", + "SizeRestrictions_BODY", + "SizeRestrictions_URIPATH", + "EC2MetaDataSSRF_BODY", + "EC2MetaDataSSRF_COOKIE", + "EC2MetaDataSSRF_URIPATH", + "EC2MetaDataSSRF_QUERYARGUMENTS", + "GenericLFI_QUERYARGUMENTS", + "GenericLFI_URIPATH", + "GenericLFI_BODY", + "RestrictedExtensions_URIPATH", + "RestrictedExtensions_QUERYARGUMENTS", + "GenericRFI_QUERYARGUMENTS", + "GenericRFI_BODY", + "GenericRFI_URIPATH", + "CrossSiteScripting_COOKIE", + "CrossSiteScripting_QUERYARGUMENTS", + "CrossSiteScripting_BODY", + "CrossSiteScripting_URIPATH", + ], + }, + ManagedGroupDef { + vendor: "AWS", + name: "AWSManagedRulesKnownBadInputsRuleSet", + product_id: "prod-aws-known-bad-inputs", + product_title: "Known bad inputs rule set", + description: "Block request patterns associated with known exploits", + capacity: 200, + rules: &[ + "JavaDeserializationRCE_HEADER", + "JavaDeserializationRCE_BODY", + "JavaDeserializationRCE_URIPATH", + "JavaDeserializationRCE_QUERYSTRING", + "Host_localhost_HEADER", + "PROPFIND_METHOD", + "ExploitablePaths_URIPATH", + "Log4JRCE_HEADER", + "Log4JRCE_QUERYSTRING", + "Log4JRCE_BODY", + "Log4JRCE_URIPATH", + ], + }, + ManagedGroupDef { + vendor: "AWS", + name: "AWSManagedRulesSQLiRuleSet", + product_id: "prod-aws-sqli", + product_title: "SQL injection rule set", + description: "Rules that block SQL injection patterns", + capacity: 200, + rules: &[ + "SQLi_QUERYARGUMENTS", + "SQLiExtendedPatterns_QUERYARGUMENTS", + "SQLi_BODY", + "SQLiExtendedPatterns_BODY", + "SQLi_COOKIE", + "SQLi_URIPATH", + ], + }, ] } -fn managed_rule_summaries(_vendor: &str, _name: &str) -> Vec { - vec![json!({ - "Name": "RuleA", - "Action": {"Block": {}}, - })] +/// Look up a managed rule group by (vendor, name). Returns `None` for an +/// unknown group so callers can surface WAFInvalidParameterException the way +/// AWS does instead of fabricating a response. +pub(super) fn managed_group_def(vendor: &str, name: &str) -> Option<&'static ManagedGroupDef> { + managed_rule_group_catalog() + .iter() + .find(|d| d.vendor == vendor && d.name == name) +} + +fn managed_products() -> Vec { + managed_rule_group_catalog() + .iter() + .map(|d| { + json!({ + "VendorName": d.vendor, + "ManagedRuleSetName": d.name, + "ProductId": d.product_id, + "ProductLink": "https://docs.aws.amazon.com/waf/latest/developerguide/aws-managed-rule-groups-list.html", + "ProductTitle": d.product_title, + "ProductDescription": d.description, + "SnsTopicArn": format!("arn:aws:sns:us-east-1::{}-notifications", d.product_id), + "IsVersioningSupported": true, + "IsAdvancedManagedRuleSet": false, + }) + }) + .collect() +} + +/// Real rule summaries for a known managed group. Managed groups block by +/// default, so each rule reports a Block action. +pub(super) fn managed_rule_summaries(vendor: &str, name: &str) -> Vec { + managed_group_def(vendor, name) + .map(|d| { + d.rules + .iter() + .map(|r| json!({ "Name": r, "Action": {"Block": {}} })) + .collect() + }) + .unwrap_or_default() } // ─── JSON shaping ────────────────────────────────────────────────── diff --git a/crates/fakecloud-wafv2/src/service/rule_groups.rs b/crates/fakecloud-wafv2/src/service/rule_groups.rs index 587be5e23..3e0ea6fbd 100644 --- a/crates/fakecloud-wafv2/src/service/rule_groups.rs +++ b/crates/fakecloud-wafv2/src/service/rule_groups.rs @@ -304,13 +304,25 @@ impl Wafv2Service { let _scope = require_scope(&body)?; let version = opt_str_len(&body, "VersionName", 1, 64)?.unwrap_or_else(|| "Version_1.0".to_string()); + // AWS rejects an unknown (vendor, name) with WAFInvalidParameterException + // rather than returning a fabricated rule group. + let def = managed_group_def(&vendor, &name).ok_or_else(|| { + invalid_param(format!( + "The managed rule group {vendor}/{name} does not exist." + )) + })?; + let available_labels: Vec = def + .rules + .iter() + .map(|r| json!({ "Name": format!("awswaf:managed:{vendor}:{name}:{r}") })) + .collect(); Ok(AwsResponse::ok_json(json!({ "VersionName": version, "SnsTopicArn": Arn::new("sns", "us-east-1", "", &format!("{vendor}-{name}-notifications")).to_string(), - "Capacity": 50, + "Capacity": def.capacity, "Rules": managed_rule_summaries(&vendor, &name), "LabelNamespace": format!("awswaf:managed:{vendor}:{name}:"), - "AvailableLabels": [], + "AvailableLabels": available_labels, "ConsumedLabels": [], }))) } @@ -386,27 +398,19 @@ impl Wafv2Service { let _scope = require_scope(&body)?; validate_opt_limit(&body)?; validate_opt_next_marker(&body)?; - Ok(AwsResponse::ok_json(json!({ - "ManagedRuleGroups": [ - { - "VendorName": "AWS", - "Name": "AWSManagedRulesCommonRuleSet", - "VersioningSupported": true, - "Description": "OWASP Top 10 baseline rules", - }, - { - "VendorName": "AWS", - "Name": "AWSManagedRulesKnownBadInputsRuleSet", - "VersioningSupported": true, - "Description": "Block request patterns associated with known exploits", - }, - { - "VendorName": "AWS", - "Name": "AWSManagedRulesSQLiRuleSet", + let groups: Vec = managed_rule_group_catalog() + .iter() + .map(|d| { + json!({ + "VendorName": d.vendor, + "Name": d.name, "VersioningSupported": true, - "Description": "SQL injection patterns", - }, - ], + "Description": d.description, + }) + }) + .collect(); + Ok(AwsResponse::ok_json(json!({ + "ManagedRuleGroups": groups, }))) }