Skip to main content

max / makenotwork

24.2 KB · 624 lines History Blame Raw
1 //! Tests for [`super`].
2
3 use super::*;
4
5 use aws_sdk_s3::config::retry::RetryConfig;
6 use aws_smithy_http_client::test_util::{ReplayEvent, StaticReplayClient};
7 use aws_smithy_types::body::SdkBody;
8
9 fn test_client() -> S3Client {
10 // `from_conf` is local — no network until a request is sent — so this
11 // builds a usable client without reaching any endpoint.
12 let s3_config = aws_sdk_s3::Config::builder()
13 .behavior_version(BehaviorVersion::latest())
14 .http_client(https_client())
15 .region(Region::new("test"))
16 .endpoint_url("http://127.0.0.1:1")
17 .credentials_provider(Credentials::new("ak", "sk", None, None, "test"))
18 .force_path_style(true)
19 .build();
20 S3Client {
21 client: Client::from_conf(s3_config),
22 bucket: "test-bucket".to_string(),
23 }
24 }
25
26 /// An `S3Client` whose HTTP layer replays canned responses.
27 ///
28 /// WHY THIS EXISTS. Two paths in this crate cannot be reached from a live
29 /// object store, and both were mutation survivors (infra `f7f13914`,
30 /// `a536db81`). One is defensive code against a response shape S3 does not
31 /// currently produce; the other writes a bucket setting the test bucket's
32 /// credentials cannot read back. A canned response reaches the first and a
33 /// recorded request reaches the second, with no live infrastructure and no
34 /// bucket-owner permission.
35 ///
36 /// Retries are off so an exhausted replay list fails immediately: a mutant
37 /// that makes the crate send one request too many should show up as a
38 /// failed assertion rather than as seconds of SDK backoff.
39 fn replay_client(events: Vec<ReplayEvent>) -> (S3Client, StaticReplayClient) {
40 let replay = StaticReplayClient::new(events);
41 let s3_config = aws_sdk_s3::Config::builder()
42 .behavior_version(BehaviorVersion::latest())
43 .http_client(replay.clone())
44 .retry_config(RetryConfig::disabled())
45 .region(Region::new("test"))
46 .endpoint_url("http://127.0.0.1:1")
47 .credentials_provider(Credentials::new("ak", "sk", None, None, "test"))
48 .force_path_style(true)
49 .build();
50 (
51 S3Client {
52 client: Client::from_conf(s3_config),
53 bucket: "test-bucket".to_string(),
54 },
55 replay,
56 )
57 }
58
59 /// A canned 200 with an XML body, which is what every S3 read returns.
60 fn xml_ok(body: &str) -> ReplayEvent {
61 ReplayEvent::new(
62 http::Request::builder()
63 .uri("http://test-bucket.localhost/")
64 .body(SdkBody::empty())
65 .unwrap(),
66 http::Response::builder()
67 .status(200)
68 .header("content-type", "application/xml")
69 .body(SdkBody::from(body.to_string()))
70 .unwrap(),
71 )
72 }
73
74 /// A canned S3 error document, which is how S3 says "no CORS here".
75 fn xml_err(status: u16, code: &str) -> ReplayEvent {
76 let body = format!(
77 "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\
78 <Error><Code>{code}</Code><Message>canned</Message>\
79 <RequestId>r</RequestId><HostId>h</HostId></Error>"
80 );
81 ReplayEvent::new(
82 http::Request::builder()
83 .uri("http://test-bucket.localhost/")
84 .body(SdkBody::empty())
85 .unwrap(),
86 http::Response::builder()
87 .status(status)
88 .header("content-type", "application/xml")
89 .body(SdkBody::from(body))
90 .unwrap(),
91 )
92 }
93
94 /// infra `f7f13914`. The "truncated but no continuation markers" guard in
95 /// `list_multipart_uploads_for_key` survived mutation because reaching it
96 /// needs a response S3 does not produce: truncated, yet naming nothing to
97 /// continue from. Live MinIO will not build it, and >1000 pending uploads on
98 /// one key is not a fixture anyone should own. A canned body is.
99 ///
100 /// The guard is kept rather than deleted because without it that response
101 /// is an infinite loop against a real endpoint, and the crate is the last
102 /// thing standing between the orphan reaper and a spin.
103 ///
104 /// The single-request assertion is the one that matters. Removing the
105 /// `break` makes the loop ask again with the same (absent) markers, and
106 /// there is no second canned response, so it also fails the `Ok` — either
107 /// way the mutant dies.
108 #[tokio::test]
109 async fn a_truncated_listing_with_no_markers_stops_instead_of_looping() {
110 let (client, replay) = replay_client(vec![xml_ok(
111 r#"<?xml version="1.0" encoding="UTF-8"?>
112 <ListMultipartUploadsResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
113 <Bucket>test-bucket</Bucket>
114 <Prefix>staging/abc</Prefix>
115 <MaxUploads>1000</MaxUploads>
116 <IsTruncated>true</IsTruncated>
117 <Upload>
118 <Key>staging/abc</Key>
119 <UploadId>upload-one</UploadId>
120 <Initiated>2026-08-31T00:00:00.000Z</Initiated>
121 </Upload>
122 <Upload>
123 <Key>staging/abcdef</Key>
124 <UploadId>not-ours</UploadId>
125 <Initiated>2026-08-31T00:00:00.000Z</Initiated>
126 </Upload>
127 </ListMultipartUploadsResult>"#,
128 )]);
129
130 let ids = client
131 .list_multipart_uploads_for_key("staging/abc")
132 .await
133 .expect("a truncated page with nothing to continue from is a complete answer");
134
135 // The exact-key filter travels with it: `ListMultipartUploads` matches a
136 // prefix, so `staging/abcdef` is a live session the reaper must not abort.
137 assert_eq!(ids, vec!["upload-one".to_string()]);
138 assert_eq!(
139 replay.actual_requests().count(),
140 1,
141 "the guard exists to stop a second identical request"
142 );
143 }
144
145 /// The other half of the guard: a truncated page that DOES name a marker is
146 /// followed. Without this the test above is satisfied by a loop that never
147 /// iterates at all, which is a different bug wearing the same result.
148 #[tokio::test]
149 async fn a_truncated_listing_with_a_marker_asks_for_the_next_page() {
150 let (client, replay) = replay_client(vec![
151 xml_ok(
152 r#"<?xml version="1.0" encoding="UTF-8"?>
153 <ListMultipartUploadsResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
154 <Bucket>test-bucket</Bucket>
155 <IsTruncated>true</IsTruncated>
156 <NextKeyMarker>staging/abc</NextKeyMarker>
157 <NextUploadIdMarker>upload-one</NextUploadIdMarker>
158 <Upload>
159 <Key>staging/abc</Key>
160 <UploadId>upload-one</UploadId>
161 <Initiated>2026-08-31T00:00:00.000Z</Initiated>
162 </Upload>
163 </ListMultipartUploadsResult>"#,
164 ),
165 xml_ok(
166 r#"<?xml version="1.0" encoding="UTF-8"?>
167 <ListMultipartUploadsResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
168 <Bucket>test-bucket</Bucket>
169 <IsTruncated>false</IsTruncated>
170 <Upload>
171 <Key>staging/abc</Key>
172 <UploadId>upload-two</UploadId>
173 <Initiated>2026-08-31T00:00:00.000Z</Initiated>
174 </Upload>
175 </ListMultipartUploadsResult>"#,
176 ),
177 ]);
178
179 let ids = client
180 .list_multipart_uploads_for_key("staging/abc")
181 .await
182 .expect("two pages is an ordinary listing");
183
184 assert_eq!(
185 ids,
186 vec!["upload-one".to_string(), "upload-two".to_string()]
187 );
188 let second = replay
189 .actual_requests()
190 .nth(1)
191 .expect("the second page was requested")
192 .uri()
193 .to_string();
194 assert!(
195 second.contains("upload-id-marker=upload-one"),
196 "the marker from page one carries into page two: {second}"
197 );
198 }
199
200 /// A page may name only ONE of the two markers, and that is still a page to
201 /// follow. The guard reads "neither marker", so it is an `&&`; an `||` there
202 /// stops on the first page whose key marker happens to be the only one set,
203 /// silently returning a short list to the orphan reaper -- which then leaves
204 /// the parts it did not see billing forever.
205 ///
206 /// `ListMultipartUploads` returns `NextUploadIdMarker` only when the page
207 /// splits a key's uploads, so a page ending on a key boundary carries the
208 /// key marker alone. That is the ordinary case, not a corner.
209 #[tokio::test]
210 async fn a_page_naming_only_the_key_marker_is_still_followed() {
211 let (client, replay) = replay_client(vec![
212 xml_ok(
213 r#"<?xml version="1.0" encoding="UTF-8"?>
214 <ListMultipartUploadsResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
215 <Bucket>test-bucket</Bucket>
216 <IsTruncated>true</IsTruncated>
217 <NextKeyMarker>staging/abc</NextKeyMarker>
218 <Upload>
219 <Key>staging/abc</Key>
220 <UploadId>upload-one</UploadId>
221 <Initiated>2026-08-31T00:00:00.000Z</Initiated>
222 </Upload>
223 </ListMultipartUploadsResult>"#,
224 ),
225 xml_ok(
226 r#"<?xml version="1.0" encoding="UTF-8"?>
227 <ListMultipartUploadsResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
228 <Bucket>test-bucket</Bucket>
229 <IsTruncated>false</IsTruncated>
230 <Upload>
231 <Key>staging/abc</Key>
232 <UploadId>upload-two</UploadId>
233 <Initiated>2026-08-31T00:00:00.000Z</Initiated>
234 </Upload>
235 </ListMultipartUploadsResult>"#,
236 ),
237 ]);
238
239 let ids = client
240 .list_multipart_uploads_for_key("staging/abc")
241 .await
242 .expect("one marker is enough to continue");
243
244 assert_eq!(
245 ids,
246 vec!["upload-one".to_string(), "upload-two".to_string()]
247 );
248 assert_eq!(replay.actual_requests().count(), 2);
249 }
250
251 /// infra `a536db81`. `configure_cors` returns `()` and the crate exposed no
252 /// readback, so replacing its whole body with `()` was invisible to every
253 /// test that could be written against it. The request it sends is the
254 /// observable, and it is the right observable: what the object store ends up
255 /// configured with is decided entirely by that one PUT.
256 #[tokio::test]
257 async fn configuring_cors_sends_the_rule_the_browser_upload_needs() {
258 let (client, replay) = replay_client(vec![xml_ok("")]);
259
260 client.configure_cors("https://example.test/").await;
261
262 let requests: Vec<_> = replay.actual_requests().collect();
263 assert_eq!(requests.len(), 1, "configure_cors must send a PUT");
264 let uri = requests[0].uri().to_string();
265 assert!(uri.contains("cors"), "put_bucket_cors, not some other PUT");
266
267 let body = String::from_utf8(
268 requests[0]
269 .body()
270 .bytes()
271 .expect("an in-memory XML body")
272 .to_vec(),
273 )
274 .expect("the CORS document is UTF-8");
275
276 // The trailing slash is trimmed: an origin is a scheme-host-port and a
277 // browser never sends the slash, so a rule carrying one matches nothing.
278 assert!(body.contains("<AllowedOrigin>https://example.test</AllowedOrigin>"));
279 for method in ["PUT", "GET", "HEAD"] {
280 assert!(
281 body.contains(&format!("<AllowedMethod>{method}</AllowedMethod>")),
282 "{method} is missing from {body}"
283 );
284 }
285 for header in ["Content-Type", "Cache-Control", "Content-Disposition"] {
286 assert!(
287 body.contains(&format!("<AllowedHeader>{header}</AllowedHeader>")),
288 "{header} is missing from {body}"
289 );
290 }
291 // ETag is what a direct-to-S3 part upload reads back to complete the
292 // upload; without exposing it the browser cannot finish a multipart.
293 assert!(body.contains("<ExposeHeader>ETag</ExposeHeader>"));
294 assert!(body.contains("<MaxAgeSeconds>3600</MaxAgeSeconds>"));
295 }
296
297 /// The readback the wrapper was missing, over a canned response.
298 #[tokio::test]
299 async fn cors_rules_come_back_in_the_crates_own_shape() {
300 let (client, _replay) = replay_client(vec![xml_ok(
301 r#"<?xml version="1.0" encoding="UTF-8"?>
302 <CORSConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
303 <CORSRule>
304 <AllowedOrigin>https://example.test</AllowedOrigin>
305 <AllowedMethod>PUT</AllowedMethod>
306 <AllowedMethod>GET</AllowedMethod>
307 <AllowedHeader>Content-Type</AllowedHeader>
308 <ExposeHeader>ETag</ExposeHeader>
309 <MaxAgeSeconds>3600</MaxAgeSeconds>
310 </CORSRule>
311 </CORSConfiguration>"#,
312 )]);
313
314 let rules = client.bucket_cors().await.expect("a configured bucket");
315 assert_eq!(
316 rules,
317 vec![CorsRuleView {
318 allowed_origins: vec!["https://example.test".to_string()],
319 allowed_methods: vec!["PUT".to_string(), "GET".to_string()],
320 allowed_headers: vec!["Content-Type".to_string()],
321 expose_headers: vec!["ETag".to_string()],
322 max_age_seconds: Some(3600),
323 }]
324 );
325 }
326
327 /// A bucket with no CORS is not a failure. S3 answers `GetBucketCors` with
328 /// `NoSuchCORSConfiguration`, and a caller asking "what is set" is entitled
329 /// to hear "nothing" rather than an error it has to pattern-match itself.
330 #[tokio::test]
331 async fn a_bucket_with_no_cors_reads_back_as_no_rules() {
332 let (client, _replay) = replay_client(vec![xml_err(404, "NoSuchCORSConfiguration")]);
333
334 let rules = client.bucket_cors().await.expect("absence is not an error");
335 assert!(rules.is_empty());
336 }
337
338 /// Any other error still is one, or the method above would report a broken
339 /// endpoint as an unconfigured bucket.
340 #[tokio::test]
341 async fn a_cors_read_that_fails_for_another_reason_is_an_error() {
342 let (client, _replay) = replay_client(vec![xml_err(403, "AccessDenied")]);
343
344 let err = client
345 .bucket_cors()
346 .await
347 .expect_err("AccessDenied is not an empty CORS configuration");
348 assert!(err.contains("get_bucket_cors"), "{err}");
349 }
350
351 const MIB: u64 = 1024 * 1024;
352
353 #[test]
354 fn oracle_accepts_every_plan_the_crate_makes() {
355 // Spot sizes across the whole legal range, including the exact
356 // boundaries, since those are where tiling arithmetic goes wrong.
357 for total in [
358 1,
359 MULTIPART_MIN_PART_SIZE as u64 - 1,
360 MULTIPART_MIN_PART_SIZE as u64,
361 MULTIPART_MIN_PART_SIZE as u64 + 1,
362 25 * MIB,
363 MULTIPART_DEFAULT_PART_SIZE as u64 * MULTIPART_MAX_PARTS as u64,
364 MULTIPART_MAX_OBJECT_SIZE - 1,
365 MULTIPART_MAX_OBJECT_SIZE,
366 ] {
367 oracle::check_auto(total);
368 oracle::check_plan(total, MULTIPART_MIN_PART_SIZE);
369 oracle::check_plan(total, MULTIPART_DEFAULT_PART_SIZE);
370 }
371 // And the refusals, which must be refusals for a reason that holds.
372 oracle::check_auto(0);
373 oracle::check_auto(MULTIPART_MAX_OBJECT_SIZE + 1);
374 oracle::check_plan(25 * MIB, MULTIPART_MIN_PART_SIZE - 1);
375 oracle::check_plan(25 * MIB, MULTIPART_MAX_PART_SIZE as usize + 1);
376 }
377
378 #[test]
379 fn the_limits_are_the_numbers_the_s3_contract_states() {
380 // Every one of these is written as an arithmetic expression, and an
381 // expression nothing asserts is a number nothing pins: mutation found
382 // that `5 * 1024 * 1024 * 1024` could become `5 + 1024 + 1024 + 1024`
383 // and no test noticed (infra `1498fe2a`). The plans built from them
384 // would still be self-consistent, which is exactly why the planner's
385 // own tests cannot catch it. Stated here in bytes, from the S3 API
386 // contract.
387 assert_eq!(MULTIPART_MIN_PART_SIZE, 5_242_880, "5 MiB");
388 assert_eq!(MULTIPART_MAX_PARTS, 10_000);
389 assert_eq!(MULTIPART_MAX_PART_SIZE, 5_368_709_120, "5 GiB");
390 assert_eq!(MULTIPART_MAX_OBJECT_SIZE, 5_497_558_138_880, "5 TiB");
391 assert_eq!(MULTIPART_DEFAULT_PART_SIZE, 16_777_216, "16 MiB");
392 assert_eq!(MAX_PRESIGN_EXPIRY_SECS, 604_800, "7 days, SigV4's maximum");
393 }
394
395 #[test]
396 #[should_panic(expected = "disagrees with div_ceil")]
397 fn oracle_catches_a_part_count_the_client_would_reject() {
398 // Hand-built, because the crate will not produce it. SyncKit refuses a
399 // session whose part_count is not div_ceil (synckit-client
400 // `client/blob.rs`), so a plan like this is an upload that can never
401 // start.
402 //
403 // THE TILING PROPERTY HAS NO SUCH TEST, and deliberately so: it cannot
404 // be broken by hand. `part_len` and `part_range` both derive from
405 // `part_size` and `part_count`, so any plan that satisfies the
406 // div_ceil check above necessarily tiles. What establishes that the
407 // tiling assertions are observed rather than merely present is the
408 // mutation run (infra `1498fe2a`), which changes the derivation itself.
409 // That division is the point of running both.
410 let plan = MultipartPlan {
411 total_size: 25 * MIB,
412 part_size: 10 * MIB as usize,
413 part_count: 4,
414 };
415 oracle::check_geometry(&plan);
416 }
417
418 #[test]
419 fn multipart_plan_divides_with_remainder() {
420 // 25 MiB in 10 MiB parts -> 10 + 10 + 5.
421 let plan = MultipartPlan::new(25 * MIB, 10 * MIB as usize).unwrap();
422 assert_eq!(plan.part_count, 3);
423 assert_eq!(plan.part_len(1), 10 * MIB);
424 assert_eq!(plan.part_len(2), 10 * MIB);
425 assert_eq!(plan.part_len(3), 5 * MIB);
426 assert_eq!(plan.part_len(4), 0, "out-of-range part");
427 assert_eq!(plan.part_range(1), Some((0, 10 * MIB - 1)));
428 assert_eq!(plan.part_range(3), Some((20 * MIB, 25 * MIB - 1)));
429 assert_eq!(plan.part_range(4), None);
430 }
431
432 #[test]
433 fn multipart_plan_divides_evenly() {
434 // 20 MiB in 5 MiB parts -> four full parts, last is a full part.
435 let plan = MultipartPlan::new(20 * MIB, MULTIPART_MIN_PART_SIZE).unwrap();
436 assert_eq!(plan.part_count, 4);
437 assert_eq!(plan.part_len(4), 5 * MIB);
438 assert_eq!(plan.part_range(4), Some((15 * MIB, 20 * MIB - 1)));
439 }
440
441 #[test]
442 fn multipart_plan_rejects_empty_object() {
443 let err = MultipartPlan::new(0, MULTIPART_MIN_PART_SIZE).unwrap_err();
444 assert!(err.contains("non-empty"), "unexpected error: {err}");
445 }
446
447 #[test]
448 fn multipart_plan_rejects_undersized_part() {
449 let err = MultipartPlan::new(100 * MIB, MULTIPART_MIN_PART_SIZE - 1).unwrap_err();
450 assert!(err.contains("5 MiB"), "unexpected error: {err}");
451 }
452
453 #[test]
454 fn multipart_plan_rejects_oversized_part() {
455 let err = MultipartPlan::new(10 * MIB, MULTIPART_MAX_PART_SIZE as usize + 1).unwrap_err();
456 assert!(err.contains("5 GiB"), "unexpected error: {err}");
457 }
458
459 #[test]
460 fn multipart_plan_rejects_too_many_parts() {
461 // One 5 MiB part past the 10k limit at the minimum part size.
462 let total = MULTIPART_MIN_PART_SIZE as u64 * (MULTIPART_MAX_PARTS as u64 + 1);
463 let err = MultipartPlan::new(total, MULTIPART_MIN_PART_SIZE).unwrap_err();
464 assert!(err.contains("10000-part"), "unexpected error: {err}");
465 }
466
467 #[test]
468 fn multipart_plan_accepts_exactly_max_parts() {
469 let total = MULTIPART_MIN_PART_SIZE as u64 * MULTIPART_MAX_PARTS as u64;
470 let plan = MultipartPlan::new(total, MULTIPART_MIN_PART_SIZE).unwrap();
471 assert_eq!(plan.part_count, MULTIPART_MAX_PARTS);
472 }
473
474 #[test]
475 fn multipart_plan_rejects_over_object_ceiling() {
476 let err = MultipartPlan::new(
477 MULTIPART_MAX_OBJECT_SIZE + 1,
478 MULTIPART_MAX_PART_SIZE as usize,
479 )
480 .unwrap_err();
481 assert!(err.contains("5 TiB"), "unexpected error: {err}");
482 }
483
484 #[test]
485 fn multipart_plan_auto_uses_default_for_small_objects() {
486 let plan = MultipartPlan::auto(100 * MIB).unwrap();
487 assert_eq!(plan.part_size, MULTIPART_DEFAULT_PART_SIZE);
488 // 100 MiB / 16 MiB -> 7 parts (ceil).
489 assert_eq!(plan.part_count, 7);
490 }
491
492 #[test]
493 fn multipart_plan_auto_scales_part_size_to_stay_within_part_cap() {
494 // An object too big for the default part size within 10k parts must get
495 // a larger part size, and the resulting plan must be valid.
496 let big = MULTIPART_DEFAULT_PART_SIZE as u64 * (MULTIPART_MAX_PARTS as u64 + 500);
497 let plan = MultipartPlan::auto(big).unwrap();
498 assert!(plan.part_size > MULTIPART_DEFAULT_PART_SIZE);
499 assert!(plan.part_count <= MULTIPART_MAX_PARTS);
500 // Whole-MiB part size.
501 assert_eq!(plan.part_size as u64 % MIB, 0);
502 }
503
504 #[test]
505 fn multipart_plan_auto_rejects_empty() {
506 assert!(MultipartPlan::auto(0).is_err());
507 }
508
509 #[tokio::test]
510 async fn copy_object_multipart_rejects_empty_source_before_any_request() {
511 // Plan is pre-flight: an empty source fails before the destination
512 // multipart upload is created, so the unreachable endpoint is untouched.
513 let client = test_client();
514 let err = client
515 .copy_object_multipart("bkt", "src", "dst", "application/octet-stream", 0, None)
516 .await
517 .expect_err("empty source must be rejected");
518 assert!(err.contains("non-empty"), "unexpected error: {err}");
519 }
520
521 /// The `X-Amz-SignedHeaders` list from a presigned URL.
522 fn signed_headers(url: &str) -> String {
523 url.split('&')
524 .find_map(|p| p.strip_prefix("X-Amz-SignedHeaders="))
525 .map(|v| v.replace("%3B", ";"))
526 .expect("presigned URL must carry X-Amz-SignedHeaders")
527 }
528
529 #[tokio::test]
530 async fn presign_upload_signs_content_length_when_bound() {
531 // Callers rely on `max_bytes` being enforced, and it is enforced only
532 // because it lands in SignedHeaders: a client sending a different
533 // Content-Length then fails the signature. That also makes the declared
534 // size a hard contract — a caller that declares anything other than the
535 // exact body length breaks every upload — so pin it here rather than
536 // discovering it against production S3.
537 let client = test_client();
538
539 let bound = client
540 .presign_upload("k", "application/octet-stream", 900, None, Some(12_345))
541 .await
542 .unwrap();
543 let headers = signed_headers(&bound);
544 assert!(
545 headers.contains("content-length"),
546 "max_bytes must be signed, got: {headers}"
547 );
548
549 let unbound = client
550 .presign_upload("k", "application/octet-stream", 900, None, None)
551 .await
552 .unwrap();
553 assert!(
554 !signed_headers(&unbound).contains("content-length"),
555 "without max_bytes the client is free to send any length"
556 );
557 }
558
559 #[tokio::test]
560 async fn presign_upload_part_rejects_out_of_range_part_number() {
561 // Pre-flight range check: fires before any network call, so the
562 // unreachable dummy endpoint is never touched.
563 let client = test_client();
564 for bad in [0, MULTIPART_MAX_PARTS as i32 + 1] {
565 let err = client
566 .presign_upload_part("k", "uid", bad, 3600, None, None)
567 .await
568 .expect_err("out-of-range part number must be rejected");
569 assert!(err.contains("out of range"), "unexpected error: {err}");
570 }
571 }
572
573 #[tokio::test]
574 async fn presign_upload_part_signs_the_checksum_when_bound() {
575 // S3 enforces a bound checksum by rehashing the part, but only if the
576 // client sends the header — which it must, because signing it makes it
577 // mandatory. Both halves of that live in SignedHeaders.
578 let client = test_client();
579
580 let bound = client
581 .presign_upload_part("k", "uid", 1, 900, Some(64), Some("Zm9vYmFyYmF6"))
582 .await
583 .unwrap();
584 let headers = signed_headers(&bound);
585 assert!(
586 headers.contains("x-amz-checksum-sha256"),
587 "a bound checksum must be signed, got: {headers}"
588 );
589
590 let unbound = client
591 .presign_upload_part("k", "uid", 1, 900, Some(64), None)
592 .await
593 .unwrap();
594 assert!(
595 !signed_headers(&unbound).contains("checksum"),
596 "no checksum bound means no checksum header is required"
597 );
598 }
599
600 #[tokio::test]
601 async fn complete_multipart_rejects_empty_parts() {
602 let client = test_client();
603 let err = client
604 .complete_multipart_upload("k", "uid", &[])
605 .await
606 .expect_err("empty parts must be rejected");
607 assert!(err.contains("no parts"), "unexpected error: {err}");
608 }
609
610 #[tokio::test]
611 async fn upload_multipart_rejects_undersized_part_before_any_request() {
612 // The minimum-part-size guard is pre-flight: it must fire before the
613 // multipart upload is created, so there is nothing to strand and no
614 // network call (the dummy endpoint is unreachable — reaching it would
615 // hang/error instead of returning this exact message).
616 let client = test_client();
617 let path = std::path::Path::new("/nonexistent");
618 let err = client
619 .upload_multipart("k", "application/octet-stream", path, Some(1024))
620 .await
621 .expect_err("undersized part size must be rejected");
622 assert!(err.contains("at least 5 MB"), "unexpected error: {err}");
623 }
624