Skip to content

Commit 4ea5114

Browse files
authored
fix(kinesis,ec2,sns,iam,ecs,secretsmanager,bedrock): validation + pagination correctness (bug-hunt) (#2286)
2 parents b347c33 + 2a94764 commit 4ea5114

14 files changed

Lines changed: 350 additions & 36 deletions

File tree

crates/fakecloud-bedrock/src/inference_profiles.rs

Lines changed: 11 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -123,23 +123,22 @@ pub(crate) fn list_inference_profiles(
123123
.and_then(|v| v.parse::<usize>().ok())
124124
.unwrap_or(100)
125125
.max(1);
126-
let next_token = req.query_params.get("nextToken");
126+
// The nextToken is an opaque numeric offset into the combined result list.
127+
// A prior version emitted the last item's Id but resumed by comparing it to
128+
// the ARN, and computed the offset against the APPLICATION-only list before
129+
// prepending SYSTEM_DEFINED profiles — so page 2 skipped or duplicated rows.
130+
let start = req
131+
.query_params
132+
.get("nextToken")
133+
.and_then(|t| t.parse::<usize>().ok())
134+
.unwrap_or(0);
127135

128136
let accts = state.read();
129137
let empty = crate::state::BedrockState::new(&req.account_id, &req.region);
130138
let s = accts.get(&req.account_id).unwrap_or(&empty);
131139
let mut items: Vec<&InferenceProfile> = s.inference_profiles.values().collect();
132140
items.sort_by(|a, b| a.inference_profile_arn.cmp(&b.inference_profile_arn));
133141

134-
let start = if let Some(token) = next_token {
135-
items
136-
.iter()
137-
.position(|p| p.inference_profile_arn.as_str() > token.as_str())
138-
.unwrap_or(items.len())
139-
} else {
140-
0
141-
};
142-
143142
// SYSTEM_DEFINED (AWS-managed cross-region) profiles exist out of the box;
144143
// list them alongside any user-created APPLICATION profiles. A `type`
145144
// filter, when present, narrows to one kind.
@@ -180,14 +179,8 @@ pub(crate) fn list_inference_profiles(
180179
let mut resp = json!({ "inferenceProfileSummaries": page });
181180
let end = start.saturating_add(max_results);
182181
if end < total {
183-
// Page on the last item's id so the caller can continue.
184-
if let Some(last) = resp["inferenceProfileSummaries"]
185-
.as_array()
186-
.and_then(|a| a.last())
187-
.and_then(|v| v["inferenceProfileId"].as_str())
188-
{
189-
resp["nextToken"] = json!(last);
190-
}
182+
// Resume at the next offset into the combined list.
183+
resp["nextToken"] = json!(end.to_string());
191184
}
192185

193186
Ok(AwsResponse::ok_json(resp))

crates/fakecloud-e2e/tests/bedrock.rs

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2353,3 +2353,53 @@ async fn bedrock_inference_profiles_catalogue_and_application_arn() {
23532353
.inference_profile_arn()
23542354
.contains("application-inference-profile/"));
23552355
}
2356+
2357+
#[tokio::test]
2358+
async fn bedrock_list_inference_profiles_paginates_without_skip_or_dup() {
2359+
let server = TestServer::start().await;
2360+
let client = server.bedrock_client().await;
2361+
2362+
// Full list in one shot as the source of truth.
2363+
let all = client.list_inference_profiles().send().await.unwrap();
2364+
let expected: Vec<String> = all
2365+
.inference_profile_summaries()
2366+
.iter()
2367+
.map(|s| s.inference_profile_id().to_string())
2368+
.collect();
2369+
assert!(
2370+
expected.len() >= 2,
2371+
"need at least 2 profiles to exercise pagination"
2372+
);
2373+
2374+
// Page through with maxResults=1; the cursor must neither skip nor
2375+
// duplicate an entry (regression: token was an Id but resume compared ARNs
2376+
// and offset indexed the wrong list).
2377+
let mut collected: Vec<String> = Vec::new();
2378+
let mut token: Option<String> = None;
2379+
loop {
2380+
let mut req = client.list_inference_profiles().max_results(1);
2381+
if let Some(t) = &token {
2382+
req = req.next_token(t.clone());
2383+
}
2384+
let page = req.send().await.unwrap();
2385+
for s in page.inference_profile_summaries() {
2386+
collected.push(s.inference_profile_id().to_string());
2387+
}
2388+
match page.next_token() {
2389+
Some(t) => token = Some(t.to_string()),
2390+
None => break,
2391+
}
2392+
}
2393+
2394+
let mut got = collected.clone();
2395+
got.sort();
2396+
got.dedup();
2397+
assert_eq!(
2398+
got.len(),
2399+
collected.len(),
2400+
"pagination duplicated an entry: {collected:?}"
2401+
);
2402+
let mut exp_sorted = expected.clone();
2403+
exp_sorted.sort();
2404+
assert_eq!(got, exp_sorted, "pagination skipped or added entries");
2405+
}

crates/fakecloud-e2e/tests/ec2_vpc_control_plane.rs

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -415,3 +415,43 @@ async fn replace_route_table_association_moves_main_to_new_table() {
415415
Some(new_rt_id.as_str())
416416
);
417417
}
418+
419+
#[tokio::test]
420+
async fn associate_address_rejects_unknown_allocation_id() {
421+
let server = TestServer::start().await;
422+
let c = server.ec2_client().await;
423+
424+
// A real allocation associates fine.
425+
let alloc = c
426+
.allocate_address()
427+
.domain(aws_sdk_ec2::types::DomainType::Vpc)
428+
.send()
429+
.await
430+
.expect("allocate_address");
431+
let alloc_id = alloc.allocation_id().expect("allocation_id").to_string();
432+
let ok = c
433+
.associate_address()
434+
.allocation_id(&alloc_id)
435+
.network_interface_id("eni-00000000000000000")
436+
.send()
437+
.await
438+
.expect("associate real allocation");
439+
assert!(ok.association_id().is_some());
440+
441+
// An unknown AllocationId must error, not fabricate a phantom association.
442+
let err = c
443+
.associate_address()
444+
.allocation_id("eipalloc-deadbeefdeadbeef0")
445+
.network_interface_id("eni-00000000000000000")
446+
.send()
447+
.await
448+
.expect_err("unknown allocation must be rejected");
449+
// EC2's query-protocol error envelope isn't parsed into meta().code() by
450+
// aws-sdk-ec2 (a separate, pre-existing format gap), so assert on the raw
451+
// error text which carries the code.
452+
let msg = format!("{}", aws_sdk_ec2::error::DisplayErrorContext(&err));
453+
assert!(
454+
msg.contains("InvalidAllocationID.NotFound"),
455+
"expected InvalidAllocationID.NotFound, got: {msg}"
456+
);
457+
}

crates/fakecloud-e2e/tests/secretsmanager.rs

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,54 @@ async fn secretsmanager_batch_get_secret_value() {
315315
assert!(resp.errors().is_empty());
316316
}
317317

318+
#[tokio::test]
319+
async fn secretsmanager_batch_get_secret_value_paginates_with_filters() {
320+
use aws_sdk_secretsmanager::types::{Filter, FilterNameStringType};
321+
let server = TestServer::start().await;
322+
let client = server.secretsmanager_client().await;
323+
324+
for name in ["pag-a", "pag-b", "pag-c"] {
325+
client
326+
.create_secret()
327+
.name(name)
328+
.secret_string("v")
329+
.send()
330+
.await
331+
.unwrap();
332+
}
333+
334+
let name_filter = Filter::builder()
335+
.key(FilterNameStringType::Name)
336+
.values("pag-")
337+
.build();
338+
339+
// Page 1: MaxResults=2 must return 2 and a NextToken.
340+
let page1 = client
341+
.batch_get_secret_value()
342+
.filters(name_filter.clone())
343+
.max_results(2)
344+
.send()
345+
.await
346+
.unwrap();
347+
assert_eq!(page1.secret_values().len(), 2);
348+
let token = page1
349+
.next_token()
350+
.expect("NextToken when more results remain")
351+
.to_string();
352+
353+
// Page 2: resumes past the first two, returning the last one, no token.
354+
let page2 = client
355+
.batch_get_secret_value()
356+
.filters(name_filter)
357+
.max_results(2)
358+
.next_token(token)
359+
.send()
360+
.await
361+
.unwrap();
362+
assert_eq!(page2.secret_values().len(), 1);
363+
assert!(page2.next_token().is_none());
364+
}
365+
318366
#[tokio::test]
319367
async fn secretsmanager_get_random_password() {
320368
let server = TestServer::start().await;

crates/fakecloud-e2e/tests/sns.rs

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,45 @@ async fn sns_publish_delivers_to_sqs_subscriber() {
264264
assert_eq!(envelope["TopicArn"], topic_arn);
265265
}
266266

267+
/// Subscribe with an unknown protocol must be rejected with InvalidParameter
268+
/// instead of creating a phantom subscription that can never deliver.
269+
#[tokio::test]
270+
async fn sns_subscribe_rejects_unknown_protocol() {
271+
let server = TestServer::start().await;
272+
let sns = server.sns_client().await;
273+
let topic_arn = sns
274+
.create_topic()
275+
.name("proto-topic")
276+
.send()
277+
.await
278+
.unwrap()
279+
.topic_arn()
280+
.unwrap()
281+
.to_string();
282+
283+
let err = sns
284+
.subscribe()
285+
.topic_arn(&topic_arn)
286+
.protocol("carrier-pigeon")
287+
.endpoint("nowhere")
288+
.send()
289+
.await
290+
.expect_err("unknown protocol must be rejected");
291+
assert_eq!(
292+
err.into_service_error().meta().code(),
293+
Some("InvalidParameter")
294+
);
295+
296+
// No phantom subscription was created.
297+
let subs = sns
298+
.list_subscriptions_by_topic()
299+
.topic_arn(&topic_arn)
300+
.send()
301+
.await
302+
.unwrap();
303+
assert!(subs.subscriptions().is_empty());
304+
}
305+
267306
/// An SQS subscriber whose target queue is gone must route the notification to
268307
/// the subscription's RedrivePolicy DLQ instead of silently dropping it — the
269308
/// DLQ was previously honored for HTTP subscribers only.

crates/fakecloud-ec2/src/service/eip.rs

Lines changed: 36 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -184,14 +184,43 @@ pub(crate) fn associate_address(
184184
req: &AwsRequest,
185185
) -> Result<AwsResponse, AwsServiceError> {
186186
let assoc_id = gen_id("eipassoc");
187-
if let Some(alloc) = req.query_params.get("AllocationId") {
188-
let mut accounts = svc.state.write();
189-
let state = accounts.get_or_create(&req.account_id);
190-
if let Some(e) = state.elastic_ips.get_mut(alloc) {
191-
e.association_id = Some(assoc_id.clone());
192-
e.instance_id = req.query_params.get("InstanceId").cloned();
193-
e.network_interface_id = req.query_params.get("NetworkInterfaceId").cloned();
187+
let mut accounts = svc.state.write();
188+
let state = accounts.get_or_create(&req.account_id);
189+
// Resolve the target address by AllocationId (VPC) or PublicIp (Classic).
190+
// An unknown identifier must error rather than fabricate a phantom
191+
// association that no Describe would ever reflect.
192+
let target_alloc = if let Some(alloc) = req.query_params.get("AllocationId") {
193+
if !state.elastic_ips.contains_key(alloc) {
194+
return Err(not_found("InvalidAllocationID.NotFound", alloc));
195+
}
196+
alloc.clone()
197+
} else if let Some(ip) = req.query_params.get("PublicIp") {
198+
match state
199+
.elastic_ips
200+
.values()
201+
.find(|e| &e.public_ip == ip)
202+
.map(|e| e.allocation_id.clone())
203+
{
204+
Some(id) => id,
205+
None => {
206+
return Err(AwsServiceError::aws_error(
207+
http::StatusCode::BAD_REQUEST,
208+
"InvalidAddress.NotFound",
209+
format!("Address '{ip}' not found."),
210+
));
211+
}
194212
}
213+
} else {
214+
return Err(AwsServiceError::aws_error(
215+
http::StatusCode::BAD_REQUEST,
216+
"MissingParameter",
217+
"The request must contain either AllocationId or PublicIp.".to_string(),
218+
));
219+
};
220+
if let Some(e) = state.elastic_ips.get_mut(&target_alloc) {
221+
e.association_id = Some(assoc_id.clone());
222+
e.instance_id = req.query_params.get("InstanceId").cloned();
223+
e.network_interface_id = req.query_params.get("NetworkInterfaceId").cloned();
195224
}
196225
Ok(Ec2Service::respond(
197226
"AssociateAddress",

crates/fakecloud-ecs/src/helpers.rs

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,13 +87,29 @@ pub(crate) fn opt_str<'a>(body: &'a Value, field: &str) -> Option<&'a str> {
8787
/// an empty page — restarting at the beginning would silently re-list page 1
8888
/// and risk an infinite pagination loop for the client.
8989
pub(crate) fn paginate_arns(
90+
arns: Vec<String>,
91+
next_token: &str,
92+
max_results: usize,
93+
) -> (Vec<String>, Option<String>) {
94+
paginate_arns_dir(arns, next_token, max_results, false)
95+
}
96+
97+
/// Like [`paginate_arns`] but honors a sort direction. `descending` reverses the
98+
/// canonical (ascending, deduped) order and adjusts the resume cursor
99+
/// comparison so DESC pages advance correctly instead of being silently
100+
/// re-sorted back to ASC.
101+
pub(crate) fn paginate_arns_dir(
90102
mut arns: Vec<String>,
91103
next_token: &str,
92104
max_results: usize,
105+
descending: bool,
93106
) -> (Vec<String>, Option<String>) {
94107
use base64::Engine;
95108
arns.sort();
96109
arns.dedup();
110+
if descending {
111+
arns.reverse();
112+
}
97113
let cursor = if next_token.is_empty() {
98114
None
99115
} else {
@@ -106,9 +122,17 @@ pub(crate) fn paginate_arns(
106122
0
107123
} else {
108124
match &cursor {
125+
// Resume just past the cursor. For ASC the next element is the first
126+
// greater than the cursor; for DESC it's the first less than it.
109127
Some(c) => arns
110128
.iter()
111-
.position(|a| a.as_str() > c.as_str())
129+
.position(|a| {
130+
if descending {
131+
a.as_str() < c.as_str()
132+
} else {
133+
a.as_str() > c.as_str()
134+
}
135+
})
112136
.unwrap_or(arns.len()),
113137
// Non-empty but undecodable token: a cursor we never emitted.
114138
// Treat as past the end (empty page) rather than restarting at 0.
@@ -1571,7 +1595,21 @@ pub(crate) fn task_protection_json(task: &Task) -> Value {
15711595

15721596
#[cfg(test)]
15731597
mod pagination_tests {
1574-
use super::paginate_arns;
1598+
use super::{paginate_arns, paginate_arns_dir};
1599+
1600+
#[test]
1601+
fn paginate_arns_dir_descending_pages_in_reverse() {
1602+
let all: Vec<String> = (0..5).map(|i| format!("arn:{i}")).collect();
1603+
// DESC page 1: highest first.
1604+
let (p1, next) = paginate_arns_dir(all.clone(), "", 2, true);
1605+
assert_eq!(p1, vec!["arn:4", "arn:3"]);
1606+
let token = next.expect("nextToken after DESC page 1");
1607+
// DESC page 2 continues downward, not silently re-sorted to ASC.
1608+
let (p2, next2) = paginate_arns_dir(all.clone(), &token, 2, true);
1609+
assert_eq!(p2, vec!["arn:2", "arn:1"]);
1610+
let (p3, _) = paginate_arns_dir(all, &next2.unwrap(), 2, true);
1611+
assert_eq!(p3, vec!["arn:0"]);
1612+
}
15751613

15761614
#[test]
15771615
fn paginate_arns_key_cursor_is_stable_across_delete() {

crates/fakecloud-ecs/src/service_task_definitions.rs

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -337,13 +337,7 @@ impl EcsService {
337337
}
338338
}
339339
}
340-
if sort == "DESC" {
341-
arns.sort();
342-
arns.reverse();
343-
} else {
344-
arns.sort();
345-
}
346-
let (page, next) = paginate_arns(arns, next_token, max_results);
340+
let (page, next) = paginate_arns_dir(arns, next_token, max_results, sort == "DESC");
347341
let mut out = json!({"taskDefinitionArns": page});
348342
if let Some(n) = next {
349343
out.as_object_mut()

0 commit comments

Comments
 (0)