mirror of
https://github.com/rustfs/rustfs.git
synced 2026-09-06 12:09:12 +00:00
test(odm): pin ambiguous native listing and absence failures
This commit is contained in:
@@ -722,6 +722,44 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn native_listing_rejects_duplicate_fields_and_nested_entries() {
|
||||
for entry in [
|
||||
"<Blob><Name>a</Name><Name>b</Name><Properties><Content-Length>1</Content-Length></Properties></Blob>",
|
||||
"<Blob><Name /><Name>b</Name><Properties><Content-Length>1</Content-Length></Properties></Blob>",
|
||||
"<Blob><Name>a</Name><Properties><Content-Length>1</Content-Length><Content-Length>2</Content-Length></Properties></Blob>",
|
||||
"<BlobPrefix><Name>a/</Name><Name>b/</Name></BlobPrefix>",
|
||||
"<BlobPrefix><Name /><Name>b/</Name></BlobPrefix>",
|
||||
"<Blob><Name>a</Name><Properties><Content-Length>1</Content-Length></Properties><Blob><Name>b</Name><Properties><Content-Length>2</Content-Length></Properties></Blob></Blob>",
|
||||
"<Blob><Name>a</Name><Properties><Content-Length>1</Content-Length></Properties><BlobPrefix><Name>b/</Name></BlobPrefix></Blob>",
|
||||
"<BlobPrefix><Name>a/</Name><Blob><Name>b</Name><Properties><Content-Length>2</Content-Length></Properties></Blob></BlobPrefix>",
|
||||
"<BlobPrefix><Name>a/</Name><BlobPrefix><Name>b/</Name></BlobPrefix></BlobPrefix>",
|
||||
] {
|
||||
let body = format!(
|
||||
"<EnumerationResults><Blobs><Blob><Name>valid</Name><Properties><Content-Length>0</Content-Length></Properties></Blob>{entry}</Blobs><NextMarker>next</NextMarker></EnumerationResults>"
|
||||
);
|
||||
let (endpoint, recorded) = scripted_server(vec![ScriptedResponse::new(200, Vec::new(), body)]).await;
|
||||
let result = backend(&endpoint, Credential::SharedKey(vec![7_u8; 32]))
|
||||
.list(&SourceListRequest {
|
||||
delimiter: Some("/"),
|
||||
continuation_token: Some("opaque+/="),
|
||||
max_keys: 2,
|
||||
..Default::default()
|
||||
})
|
||||
.await;
|
||||
let err = result.expect_err("ambiguous entries must reject the entire page and its cursor");
|
||||
assert!(matches!(err, SourceError::Other(_)), "{entry}: {err:?}");
|
||||
assert!(!err.is_retryable(), "{entry}: {err:?}");
|
||||
assert_requests(
|
||||
&recorded,
|
||||
&[(
|
||||
"GET",
|
||||
"/legacy?restype=container&comp=list&delimiter=%2F&marker=opaque%2B%2F%3D&maxresults=2",
|
||||
)],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn native_listing_preserves_zero_size_unicode_prefixes_and_opaque_cursors() {
|
||||
let body = "<EnumerationResults><Blobs><Blob><Name>目录/空 & file</Name><Properties><Content-Length>0</Content-Length></Properties></Blob><BlobPrefix><Name>目录/子/</Name></BlobPrefix></Blobs><NextMarker>opaque+/=</NextMarker></EnumerationResults>";
|
||||
@@ -1140,6 +1178,62 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn s3_not_found_alias_never_proves_native_object_absence() {
|
||||
for selector in [None, Some("versionid"), Some("snapshot")] {
|
||||
for operation in ["head", "get", "list", "tags", "probe"] {
|
||||
if selector.is_some() && !matches!(operation, "head" | "get") {
|
||||
continue;
|
||||
}
|
||||
for (status, expected, retryable) in [
|
||||
(403, "access_denied", false),
|
||||
(404, "other", false),
|
||||
(416, "other", false),
|
||||
(500, "server_error", true),
|
||||
] {
|
||||
let (endpoint, recorded) = scripted_server(vec![ScriptedResponse::new(
|
||||
status,
|
||||
vec![(HEADER_ERROR_CODE, "NoSuchKey".to_string())],
|
||||
"untrusted-error-body".to_string(),
|
||||
)])
|
||||
.await;
|
||||
let credential = selector.map_or_else(
|
||||
|| Credential::SharedKey(vec![7_u8; 32]),
|
||||
|selector| Credential::Sas(vec![(selector.to_string(), "old-version".to_string())]),
|
||||
);
|
||||
let backend = backend(&endpoint, credential);
|
||||
let result = match operation {
|
||||
"head" => backend.head("missing").await.map(|_| ()),
|
||||
"get" => backend.get("missing", None).await.map(|_| ()),
|
||||
"list" => backend.list(&SourceListRequest::default()).await.map(|_| ()),
|
||||
"tags" => backend.tagging("missing").await.map(|_| ()),
|
||||
"probe" => backend.probe().await,
|
||||
_ => unreachable!(),
|
||||
};
|
||||
let err = result.expect_err("an S3 error alias is not Azure absence evidence");
|
||||
assert_eq!(err.class_label(), expected, "{operation} {selector:?} HTTP {status}: {err:?}");
|
||||
assert_eq!(err.is_retryable(), retryable, "{operation} {selector:?} HTTP {status}: {err:?}");
|
||||
if status == 500 {
|
||||
assert!(matches!(err, SourceError::ServerError(500)));
|
||||
}
|
||||
assert!(!err.to_string().contains("untrusted-error-body"));
|
||||
let (method, mut target) = match operation {
|
||||
"head" => ("HEAD", "/legacy/missing".to_string()),
|
||||
"get" => ("GET", "/legacy/missing".to_string()),
|
||||
"list" => ("GET", "/legacy?restype=container&comp=list".to_string()),
|
||||
"tags" => ("GET", "/legacy/missing?comp=tags".to_string()),
|
||||
"probe" => ("HEAD", "/legacy?restype=container".to_string()),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
if let Some(selector) = selector {
|
||||
target.push_str(&format!("?{selector}=old-version"));
|
||||
}
|
||||
assert_requests(&recorded, &[(method, target.as_str())]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ambiguous_head_preserves_the_container_probe_failure() {
|
||||
for (status, expected, retryable) in [
|
||||
|
||||
@@ -602,6 +602,48 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn native_listing_rejects_empty_prefix_entries() {
|
||||
for body in [
|
||||
r#"{"items":[{"name":"valid","size":"1"}],"prefixes":[""],"nextPageToken":"next"}"#,
|
||||
r#"{"prefixes":[""],"nextPageToken":"next"}"#,
|
||||
r#"{"prefixes":["目录/子/",""],"nextPageToken":"next"}"#,
|
||||
] {
|
||||
let (endpoint, recorded) = scripted_server(vec![ScriptedResponse::new(200, Vec::new(), body.to_string())]).await;
|
||||
let result = backend(&endpoint)
|
||||
.list(&SourceListRequest {
|
||||
delimiter: Some("/"),
|
||||
continuation_token: Some("opaque+/="),
|
||||
max_keys: 2,
|
||||
..Default::default()
|
||||
})
|
||||
.await;
|
||||
let err = result.expect_err("an empty prefix must reject the entire page and its cursor");
|
||||
assert!(matches!(err, SourceError::Other(_)), "{body}: {err:?}");
|
||||
assert!(!err.is_retryable());
|
||||
assert_requests(
|
||||
&recorded,
|
||||
&[("GET", "/storage/v1/b/legacy/o?delimiter=%2F&pageToken=opaque%2B%2F%3D&maxResults=2")],
|
||||
);
|
||||
}
|
||||
|
||||
let body = r#"{"prefixes":["目录/子/"],"nextPageToken":"opaque+/="}"#;
|
||||
let (endpoint, recorded) = scripted_server(vec![ScriptedResponse::new(200, Vec::new(), body.to_string())]).await;
|
||||
let page = backend(&endpoint)
|
||||
.list(&SourceListRequest {
|
||||
delimiter: Some("/"),
|
||||
max_keys: 1,
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.expect("a valid prefix-only page must remain usable");
|
||||
assert!(page.objects.is_empty());
|
||||
assert_eq!(page.common_prefixes, ["目录/子/"]);
|
||||
assert!(page.is_truncated);
|
||||
assert_eq!(page.next_continuation_token.as_deref(), Some("opaque+/="));
|
||||
assert_requests(&recorded, &[("GET", "/storage/v1/b/legacy/o?delimiter=%2F&maxResults=1")]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn native_listing_preserves_zero_size_unicode_prefixes_and_opaque_cursors() {
|
||||
let body = r#"{"items":[{"name":"目录/空 & file","size":"0"}],"prefixes":["目录/子/"],"nextPageToken":"opaque+/="}"#;
|
||||
|
||||
Reference in New Issue
Block a user