@@ -129,7 +129,10 @@ void presignedPostRejectsExceedingContentLength() {
129129 .post ("/" + BUCKET )
130130 .then ()
131131 .statusCode (400 )
132- .body (containsString ("EntityTooLarge" ));
132+ .contentType ("application/xml" )
133+ .body (hasXPath ("/Error/Code" , equalTo ("EntityTooLarge" )))
134+ .body (hasXPath ("/Error/Message" , equalTo (
135+ "Your proposed upload exceeds the maximum allowed size." )));
133136 }
134137
135138 @ Test
@@ -143,7 +146,10 @@ void presignedPostRequiresKeyField() {
143146 .post ("/" + BUCKET )
144147 .then ()
145148 .statusCode (400 )
146- .body (containsString ("InvalidArgument" ));
149+ .contentType ("application/xml" )
150+ .body (hasXPath ("/Error/Code" , equalTo ("InvalidArgument" )))
151+ .body (hasXPath ("/Error/Message" , equalTo (
152+ "Bucket POST must contain a field named 'key'." )));
147153 }
148154
149155 @ Test
@@ -157,7 +163,10 @@ void presignedPostRequiresFileField() {
157163 .post ("/" + BUCKET )
158164 .then ()
159165 .statusCode (400 )
160- .body (containsString ("InvalidArgument" ));
166+ .contentType ("application/xml" )
167+ .body (hasXPath ("/Error/Code" , equalTo ("InvalidArgument" )))
168+ .body (hasXPath ("/Error/Message" , equalTo (
169+ "Bucket POST must contain a file field." )));
161170 }
162171
163172 @ Test
@@ -227,7 +236,8 @@ void presignedPostToNonExistentBucketFails() {
227236 .post ("/nonexistent-presigned-bucket" )
228237 .then ()
229238 .statusCode (404 )
230- .body (containsString ("NoSuchBucket" ));
239+ .contentType ("application/xml" )
240+ .body (hasXPath ("/Error/Code" , equalTo ("NoSuchBucket" )));
231241 }
232242
233243 @ Test
@@ -255,6 +265,117 @@ void presignedPostWithContentLengthWithinRange() {
255265 .statusCode (204 );
256266 }
257267
268+ @ Test
269+ @ Order (91 )
270+ void presignedPostRejectsContentTypeMismatch () {
271+ String key = "uploads/ct-mismatch.png" ;
272+ String fileContent = "not a real png" ;
273+
274+ String policy = buildPolicy (BUCKET , key , "image/png" , 0 , 10485760 );
275+ String policyBase64 = Base64 .getEncoder ().encodeToString (policy .getBytes (StandardCharsets .UTF_8 ));
276+
277+ given ()
278+ .multiPart ("key" , key )
279+ .multiPart ("Content-Type" , "image/gif" )
280+ .multiPart ("policy" , policyBase64 )
281+ .multiPart ("x-amz-algorithm" , "AWS4-HMAC-SHA256" )
282+ .multiPart ("x-amz-credential" , "AKIAIOSFODNN7EXAMPLE/20260330/us-east-1/s3/aws4_request" )
283+ .multiPart ("x-amz-date" , AMZ_DATE_FORMAT .format (Instant .now ()))
284+ .multiPart ("x-amz-signature" , "dummysignature" )
285+ .multiPart ("file" , "ct-mismatch.png" , fileContent .getBytes (StandardCharsets .UTF_8 ), "image/gif" )
286+ .when ()
287+ .post ("/" + BUCKET )
288+ .then ()
289+ .statusCode (403 )
290+ .contentType ("application/xml" )
291+ .body (hasXPath ("/Error/Code" , equalTo ("AccessDenied" )))
292+ .body (hasXPath ("/Error/Message" , equalTo (
293+ "Invalid according to Policy: Policy Condition failed: "
294+ + "[\" eq\" , \" $Content-Type\" , \" image/png\" ]" )));
295+ }
296+
297+ @ Test
298+ @ Order (92 )
299+ void presignedPostRejectsKeyMismatch () {
300+ String key = "uploads/wrong-key.txt" ;
301+ String fileContent = "test content" ;
302+
303+ String policy = buildPolicy (BUCKET , "uploads/expected-key.txt" , "text/plain" , 0 , 10485760 );
304+ String policyBase64 = Base64 .getEncoder ().encodeToString (policy .getBytes (StandardCharsets .UTF_8 ));
305+
306+ given ()
307+ .multiPart ("key" , key )
308+ .multiPart ("Content-Type" , "text/plain" )
309+ .multiPart ("policy" , policyBase64 )
310+ .multiPart ("x-amz-algorithm" , "AWS4-HMAC-SHA256" )
311+ .multiPart ("x-amz-credential" , "AKIAIOSFODNN7EXAMPLE/20260330/us-east-1/s3/aws4_request" )
312+ .multiPart ("x-amz-date" , AMZ_DATE_FORMAT .format (Instant .now ()))
313+ .multiPart ("x-amz-signature" , "dummysignature" )
314+ .multiPart ("file" , "wrong-key.txt" , fileContent .getBytes (StandardCharsets .UTF_8 ), "text/plain" )
315+ .when ()
316+ .post ("/" + BUCKET )
317+ .then ()
318+ .statusCode (403 )
319+ .contentType ("application/xml" )
320+ .body (hasXPath ("/Error/Code" , equalTo ("AccessDenied" )))
321+ .body (hasXPath ("/Error/Message" , equalTo (
322+ "Invalid according to Policy: Policy Condition failed: "
323+ + "[\" eq\" , \" $key\" , \" uploads/expected-key.txt\" ]" )));
324+ }
325+
326+ @ Test
327+ @ Order (93 )
328+ void presignedPostWithStartsWithCondition () {
329+ String key = "uploads/prefix-test.txt" ;
330+ String fileContent = "starts-with test" ;
331+
332+ String policy = buildStartsWithPolicy (BUCKET , "uploads/" , "text/" , 0 , 10485760 );
333+ String policyBase64 = Base64 .getEncoder ().encodeToString (policy .getBytes (StandardCharsets .UTF_8 ));
334+
335+ given ()
336+ .multiPart ("key" , key )
337+ .multiPart ("Content-Type" , "text/plain" )
338+ .multiPart ("policy" , policyBase64 )
339+ .multiPart ("x-amz-algorithm" , "AWS4-HMAC-SHA256" )
340+ .multiPart ("x-amz-credential" , "AKIAIOSFODNN7EXAMPLE/20260330/us-east-1/s3/aws4_request" )
341+ .multiPart ("x-amz-date" , AMZ_DATE_FORMAT .format (Instant .now ()))
342+ .multiPart ("x-amz-signature" , "dummysignature" )
343+ .multiPart ("file" , "prefix-test.txt" , fileContent .getBytes (StandardCharsets .UTF_8 ), "text/plain" )
344+ .when ()
345+ .post ("/" + BUCKET )
346+ .then ()
347+ .statusCode (204 );
348+ }
349+
350+ @ Test
351+ @ Order (94 )
352+ void presignedPostRejectsStartsWithMismatch () {
353+ String key = "other/wrong-prefix.txt" ;
354+ String fileContent = "starts-with mismatch" ;
355+
356+ String policy = buildStartsWithPolicy (BUCKET , "uploads/" , "text/" , 0 , 10485760 );
357+ String policyBase64 = Base64 .getEncoder ().encodeToString (policy .getBytes (StandardCharsets .UTF_8 ));
358+
359+ given ()
360+ .multiPart ("key" , key )
361+ .multiPart ("Content-Type" , "text/plain" )
362+ .multiPart ("policy" , policyBase64 )
363+ .multiPart ("x-amz-algorithm" , "AWS4-HMAC-SHA256" )
364+ .multiPart ("x-amz-credential" , "AKIAIOSFODNN7EXAMPLE/20260330/us-east-1/s3/aws4_request" )
365+ .multiPart ("x-amz-date" , AMZ_DATE_FORMAT .format (Instant .now ()))
366+ .multiPart ("x-amz-signature" , "dummysignature" )
367+ .multiPart ("file" , "wrong-prefix.txt" , fileContent .getBytes (StandardCharsets .UTF_8 ), "text/plain" )
368+ .when ()
369+ .post ("/" + BUCKET )
370+ .then ()
371+ .statusCode (403 )
372+ .contentType ("application/xml" )
373+ .body (hasXPath ("/Error/Code" , equalTo ("AccessDenied" )))
374+ .body (hasXPath ("/Error/Message" , equalTo (
375+ "Invalid according to Policy: Policy Condition failed: "
376+ + "[\" starts-with\" , \" $key\" , \" uploads/\" ]" )));
377+ }
378+
258379 @ Test
259380 @ Order (100 )
260381 void cleanupBucket () {
@@ -264,6 +385,7 @@ void cleanupBucket() {
264385 given ().delete ("/" + BUCKET + "/uploads/no-policy.txt" );
265386 given ().delete ("/" + BUCKET + "/uploads/typed-file.json" );
266387 given ().delete ("/" + BUCKET + "/uploads/within-range.txt" );
388+ given ().delete ("/" + BUCKET + "/uploads/prefix-test.txt" );
267389
268390 given ()
269391 .when ()
@@ -288,4 +410,22 @@ private String buildPolicy(String bucket, String key, String contentType, long m
288410 }
289411 """ .formatted (expiration , bucket , key , contentType , minSize , maxSize );
290412 }
413+
414+ private String buildStartsWithPolicy (String bucket , String keyPrefix , String contentTypePrefix ,
415+ long minSize , long maxSize ) {
416+ String expiration = Instant .now ().plusSeconds (3600 )
417+ .atZone (ZoneOffset .UTC )
418+ .format (DateTimeFormatter .ISO_INSTANT );
419+ return """
420+ {
421+ "expiration": "%s",
422+ "conditions": [
423+ {"bucket": "%s"},
424+ ["starts-with", "$key", "%s"],
425+ ["starts-with", "$Content-Type", "%s"],
426+ ["content-length-range", %d, %d]
427+ ]
428+ }
429+ """ .formatted (expiration , bucket , keyPrefix , contentTypePrefix , minSize , maxSize );
430+ }
291431}
0 commit comments