Compare commits

...

1 Commits

Author SHA1 Message Date
overtrue 6c442ea5c5 test(admin): port admin-A handler tests to owned admin types
Port the pools, utils, heal and health handler tests off s3s request,
response, body and error types. The heal and health router tests now go
through test-only owned helpers on S3Router (check_unsigned_access,
call_unsigned) that keep the same check_access and call path.

No production behavior change. Four files leave the s3s footprint, so
the files baseline drops from 65 to 61.
2026-10-30 00:50:22 +08:00
6 changed files with 89 additions and 82 deletions
+1 -1
View File
@@ -12,6 +12,6 @@
# s3_error_lines lines holding s3_error!, same scope, minus those the script
# header's rule clears (rustfs_s3_types's macro); fails closed
# ecstore_files Rust files referencing s3s paths under crates/ecstore/src
files=65
files=61
s3_error_lines=289
ecstore_files=0
+10 -34
View File
@@ -2293,33 +2293,21 @@ mod tests {
#[tokio::test]
async fn test_nested_heal_routes_still_require_authentication() {
use s3s::route::S3Route;
let mut router = super::S3Router::new(false);
super::register_heal_route(&mut router).expect("register production Heal routes");
for prefix in ["/rustfs/admin", "/minio/admin"] {
for object in ["dir/object.bin", "dir%2Fobject.bin", "literal%252Fslash"] {
let mut req = s3s::S3Request {
input: s3s::Body::empty(),
method: http::Method::POST,
uri: format!("{prefix}/v3/heal/test-bucket/{object}").parse().expect("Heal URI"),
headers: http::HeaderMap::new(),
extensions: http::Extensions::new(),
credentials: None,
region: None,
service: None,
trailing_headers: None,
};
let uri: http::Uri = format!("{prefix}/v3/heal/test-bucket/{object}").parse().expect("Heal URI");
let err = router
.check_access(&mut req)
.check_unsigned_access(http::Method::POST, uri.clone())
.await
.expect_err("router must require a signature");
assert_eq!(err.code(), &S3ErrorCode::AccessDenied.into());
assert_eq!(err.code(), &S3ErrorCode::AccessDenied);
let err = router
.call(req)
.call_unsigned(http::Method::POST, uri)
.await
.expect_err("handler must independently require authentication");
assert_eq!(err.code(), &S3ErrorCode::InvalidRequest.into());
assert_eq!(err.code(), &S3ErrorCode::InvalidRequest);
assert!(err.to_string().contains("authentication required"));
}
}
@@ -2327,34 +2315,22 @@ mod tests {
#[tokio::test]
async fn replacement_recovery_diagnostics_require_authentication() {
use s3s::route::S3Route;
let mut router = super::S3Router::new(false);
super::register_heal_route(&mut router).expect("register production Heal routes");
for prefix in ["/rustfs/admin", "/minio/admin"] {
let path = format!("{prefix}/v4/heal/replacement-recovery");
assert!(router.contains_compatible_route(http::Method::GET, &path));
let mut req = s3s::S3Request {
input: s3s::Body::empty(),
method: http::Method::GET,
uri: path.parse().expect("replacement diagnostics URI"),
headers: http::HeaderMap::new(),
extensions: http::Extensions::new(),
credentials: None,
region: None,
service: None,
trailing_headers: None,
};
let uri: http::Uri = path.parse().expect("replacement diagnostics URI");
let err = router
.check_access(&mut req)
.check_unsigned_access(http::Method::GET, uri.clone())
.await
.expect_err("diagnostics router must require a signature");
assert_eq!(err.code(), &S3ErrorCode::AccessDenied.into());
assert_eq!(err.code(), &S3ErrorCode::AccessDenied);
let err = router
.call(req)
.call_unsigned(http::Method::GET, uri)
.await
.expect_err("diagnostics handler must authenticate before inspecting local records");
assert_eq!(err.code(), &S3ErrorCode::InvalidRequest.into());
assert_eq!(err.code(), &S3ErrorCode::InvalidRequest);
assert!(err.to_string().contains("authentication required"));
}
}
+15 -35
View File
@@ -91,56 +91,42 @@ impl Operation for HealthCheckHandler {
mod tests {
use super::*;
use http_body_util::BodyExt as _;
use s3s::route::S3Route as _;
use serial_test::serial;
use temp_env::with_var;
fn health_request(method: Method) -> s3s::S3Request<s3s::Body> {
s3s::S3Request {
input: s3s::Body::empty(),
method,
uri: HEALTH_PREFIX.parse().expect("health URI"),
headers: HeaderMap::new(),
extensions: http::Extensions::new(),
credentials: None,
region: None,
service: None,
trailing_headers: None,
}
}
async fn call_real_health_handler(method: Method) -> s3s::S3Response<s3s::Body> {
async fn call_real_health_handler(method: Method) -> (StatusCode, HeaderMap, Body) {
let mut router = S3Router::new(false);
router
.insert(method.clone(), HEALTH_PREFIX, AdminOperation(&HealthCheckHandler {}))
.expect("health route should register");
router
.call(health_request(method))
.call_unsigned(method, HEALTH_PREFIX.parse().expect("health URI"))
.await
.expect("health handler should return a response")
.into_parts()
}
#[tokio::test]
async fn real_health_handler_get_and_head_preserve_wire_branches() {
let get = call_real_health_handler(Method::GET).await;
assert_eq!(get.status, Some(StatusCode::OK));
let (get_status, get_headers, get_body) = call_real_health_handler(Method::GET).await;
assert_eq!(get_status, StatusCode::OK);
assert_eq!(
get.headers.get(CONTENT_TYPE).and_then(|value| value.to_str().ok()),
get_headers.get(CONTENT_TYPE).and_then(|value| value.to_str().ok()),
Some("application/json")
);
let payload: serde_json::Value =
serde_json::from_slice(&get.output.collect().await.expect("GET body should read").to_bytes())
serde_json::from_slice(&get_body.collect().await.expect("GET body should read").to_bytes())
.expect("GET health response should be JSON");
assert_eq!(payload["status"], "ok");
let head = call_real_health_handler(Method::HEAD).await;
assert_eq!(head.status, Some(StatusCode::OK));
let (head_status, head_headers, head_body) = call_real_health_handler(Method::HEAD).await;
assert_eq!(head_status, StatusCode::OK);
assert_eq!(
head.headers.get(CONTENT_TYPE).and_then(|value| value.to_str().ok()),
head_headers.get(CONTENT_TYPE).and_then(|value| value.to_str().ok()),
Some("application/json")
);
assert!(
head.output
head_body
.collect()
.await
.expect("HEAD body should read")
@@ -151,18 +137,12 @@ mod tests {
#[tokio::test]
async fn real_health_handler_rejects_unsupported_methods_with_allow_header() {
let response = call_real_health_handler(Method::POST).await;
let (status, headers, body) = call_real_health_handler(Method::POST).await;
assert_eq!(response.status, Some(StatusCode::METHOD_NOT_ALLOWED));
assert_eq!(status, StatusCode::METHOD_NOT_ALLOWED);
assert_eq!(headers.get(http::header::ALLOW).and_then(|value| value.to_str().ok()), Some("GET, HEAD"));
assert_eq!(
response
.headers
.get(http::header::ALLOW)
.and_then(|value| value.to_str().ok()),
Some("GET, HEAD")
);
assert_eq!(
response.output.collect().await.expect("error body should read").to_bytes(),
body.collect().await.expect("error body should read").to_bytes(),
bytes::Bytes::from_static(b"Method Not Allowed")
);
}
+4 -4
View File
@@ -1299,9 +1299,9 @@ mod pools_handler_tests {
let err = decommission_start_api_error(err);
assert_eq!(err.code, s3s::S3ErrorCode::ServiceUnavailable);
assert_eq!(err.code, rustfs_s3_types::S3ErrorCode::ServiceUnavailable.into());
assert!(err.message.contains("data movement gate (decommission activation)"), "{}", err.message);
let err = s3s::S3Error::from(contextualize_admin_pool_api_error(err, "start decommission", "pools [0]"));
let err = rustfs_s3_types::S3Error::from(contextualize_admin_pool_api_error(err, "start decommission", "pools [0]"));
assert_eq!(
err.headers().and_then(|headers| headers.get(http::header::RETRY_AFTER)),
Some(&http::HeaderValue::from_static("1")),
@@ -1313,8 +1313,8 @@ mod pools_handler_tests {
fn test_decommission_start_api_error_reports_a_refused_preflight_heal_as_retryable() {
let err = decommission_start_api_error(crate::storage_api::error::StorageError::SlowDown);
assert_eq!(err.code, s3s::S3ErrorCode::ServiceUnavailable);
let err = s3s::S3Error::from(contextualize_admin_pool_api_error(err, "start decommission", "pools [0]"));
assert_eq!(err.code, rustfs_s3_types::S3ErrorCode::ServiceUnavailable.into());
let err = rustfs_s3_types::S3Error::from(contextualize_admin_pool_api_error(err, "start decommission", "pools [0]"));
assert_eq!(err.status_code(), Some(http::StatusCode::SERVICE_UNAVAILABLE));
}
+45
View File
@@ -3233,6 +3233,51 @@ impl<T: Operation> S3Router<T> {
pub(crate) fn registered_routes(&self) -> &[String] {
&self.registered_routes
}
/// Runs an unsigned request (no presented credentials, no headers, empty
/// body) through this router's `check_access` gate, so handler tests can
/// pin the router's authentication with owned types only.
pub(crate) async fn check_unsigned_access(&self, method: Method, uri: Uri) -> rustfs_s3_types::S3Result<()> {
let mut req = unsigned_legacy_request(method, uri);
S3Route::check_access(self, &mut req)
.await
.map_err(rustfs_s3_types::S3Error::from)
}
/// Dispatches an unsigned request (no presented credentials, no headers,
/// empty body) through this router's `call`, returning the owned admin
/// response. Every dispatched response carries a status, so a missing one
/// fails the test.
pub(crate) async fn call_unsigned(
&self,
method: Method,
uri: Uri,
) -> rustfs_s3_types::S3Result<crate::admin::operation::AdminResponse> {
let resp = S3Route::call(self, unsigned_legacy_request(method, uri))
.await
.map_err(rustfs_s3_types::S3Error::from)?;
let status = resp.status.expect("a dispatched admin response carries a status");
Ok(crate::admin::operation::AdminResponse::with_headers(
status,
resp.output.into(),
resp.headers,
))
}
}
#[cfg(test)]
fn unsigned_legacy_request(method: Method, uri: Uri) -> S3Request<Body> {
S3Request {
input: Body::empty(),
method,
uri,
headers: HeaderMap::new(),
extensions: Extensions::new(),
credentials: None,
region: None,
service: None,
trailing_headers: None,
}
}
impl<T: Operation> Default for S3Router<T> {
+14 -8
View File
@@ -165,9 +165,10 @@ mod tests {
let payload = b"{\"ok\":true}".to_vec();
let encrypted = encrypt_data(b"secret", &payload).expect("encrypt payload");
let decoded = read_compatible_admin_body(s3s::Body::from(encrypted), 1024, "/minio/admin/v3/list-users", "secret")
.await
.expect("decode payload");
let decoded =
read_compatible_admin_body(rustfs_s3_types::Body::from(encrypted), 1024, "/minio/admin/v3/list-users", "secret")
.await
.expect("decode payload");
assert_eq!(decoded, payload);
}
@@ -191,12 +192,17 @@ mod tests {
assert_eq!(err.code(), &S3ErrorCode::InvalidRequest);
assert_eq!(err.message(), Some("failed to read request body: length limit exceeded"));
let legacy_body = s3s::Body::http_body(http_body_util::Full::new(bytes::Bytes::from_static(b"nine bytes!")));
let legacy_err = read_compatible_admin_body(legacy_body, 8, "/rustfs/admin/v3/list-users", "secret")
// A streaming body has no exact length up front, so the limit trips
// while frames accumulate rather than on the size hint.
let streaming_body = rustfs_s3_types::Body::from_stream(futures_util::stream::iter([
Ok::<_, std::io::Error>(bytes::Bytes::from_static(b"nine ")),
Ok(bytes::Bytes::from_static(b"bytes!")),
]));
let streaming_err = read_compatible_admin_body(streaming_body, 8, "/rustfs/admin/v3/list-users", "secret")
.await
.expect_err("legacy streaming bodies retain the same limit error");
assert_eq!(legacy_err.code(), err.code());
assert_eq!(legacy_err.message(), err.message());
.expect_err("streaming bodies retain the same limit error");
assert_eq!(streaming_err.code(), err.code());
assert_eq!(streaming_err.message(), err.message());
let exact =
read_compatible_admin_body(rustfs_s3_types::Body::from("12345678"), 8, "/rustfs/admin/v3/list-users", "secret")