Compare commits

...

2 Commits

Author SHA1 Message Date
overtrue 4e6ef3ca07 test(admin): port runtime source tests to owned admin types
Drop the s3s-shaped app_context_from_req helper; the replication metrics
route resolves its context through app_context_from_extensions directly.
The context probe route becomes an owned Operation driven through the
router's test-only call_unsigned helper, and envelopes are built from an
owned RequestHead instead of from_s3s.
2026-10-30 01:40:13 +08:00
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
7 changed files with 109 additions and 116 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=60
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));
}
+47 -2
View File
@@ -29,7 +29,7 @@ use crate::admin::console::{is_console_path, make_console_server};
use crate::admin::handlers::oidc::is_oidc_path;
use crate::admin::operation::{AdminRequest, Operation as OwnedOperation};
use crate::admin::runtime_sources::{
ServerContextSlot, app_context_from_req, current_boot_time, current_bucket_monitor_handle, current_deployment_id,
ServerContextSlot, app_context_from_extensions, current_boot_time, current_bucket_monitor_handle, current_deployment_id,
current_notification_system, current_object_store_handle, current_region, current_replication_pool_handle,
current_server_config, default_object_usecase,
};
@@ -3021,7 +3021,7 @@ async fn handle_replication_extension_request(
match ext_req.route {
ReplicationExtRoute::MetricsV1 | ReplicationExtRoute::MetricsV2 => {
ensure_replication_config_exists(&ext_req.bucket).await?;
build_replication_metrics_response(&ext_req.bucket, ext_req.route, app_context_from_req(req)).await
build_replication_metrics_response(&ext_req.bucket, ext_req.route, app_context_from_extensions(&req.extensions)).await
}
ReplicationExtRoute::Check => {
let (versioning, _) = metadata_sys::get_versioning_config(&ext_req.bucket)
@@ -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> {
+18 -32
View File
@@ -66,10 +66,6 @@ pub(crate) fn current_object_data_cache() -> Option<Arc<ObjectDataCacheAdapter>>
current_object_data_cache_handle_for_context(context.as_deref())
}
pub(crate) fn app_context_from_req<B>(req: &s3s::S3Request<B>) -> Option<Arc<AppContext>> {
app_context_from_extensions(&req.extensions)
}
pub(crate) fn app_context_from_envelope(envelope: &RequestEnvelope) -> Option<Arc<AppContext>> {
match envelope.server_context() {
Some(slot) => slot.installed_app_context(),
@@ -160,17 +156,17 @@ pub(crate) fn publish_storage_class_config(config: StorageClassConfig) {
mod tests {
use super::{
AppContext, IamInterface, KmsInterface, ServerContextSlot, app_context_from_envelope, app_context_from_extensions,
app_context_from_req, current_app_context, object_store_from_envelope, publish_test_app_context,
current_app_context, object_store_from_envelope, publish_test_app_context,
};
use crate::admin::router::{Operation, S3Router};
use crate::app::RequestEnvelope;
use crate::admin::operation::{AdminRequest, AdminResponse, Operation};
use crate::admin::router::S3Router;
use crate::app::{RequestEnvelope, RequestHead};
use hyper::{Method, StatusCode};
use matchit::Params;
use rustfs_iam::{store::object::ObjectStore, sys::IamSys};
use rustfs_kms::KmsServiceManager;
use rustfs_s3_types::Body;
use rustfs_s3_types::{S3ErrorCode, s3_error};
use s3s::route::S3Route;
use s3s::{Body, S3Request, S3Response};
use std::sync::Arc;
struct UnreadyIam;
@@ -213,22 +209,16 @@ mod tests {
Arc::new(AppContext::new(context.object_store(), Arc::new(UnreadyIam), Arc::new(TestKms)))
}
fn request(extensions: http::Extensions) -> S3Request<Body> {
S3Request {
input: Body::empty(),
fn envelope(extensions: http::Extensions) -> RequestEnvelope {
let head = RequestHead {
method: Method::GET,
uri: "/context-probe".parse().expect("test URI"),
headers: http::HeaderMap::new(),
extensions,
credentials: None,
region: None,
service: None,
trailing_headers: None,
}
}
fn envelope(extensions: http::Extensions) -> RequestEnvelope {
RequestEnvelope::from_s3s(&request(extensions))
};
RequestEnvelope::from_parts(head, extensions)
}
#[tokio::test]
@@ -240,8 +230,7 @@ mod tests {
extensions.insert(slot);
assert!(app_context_from_extensions(&extensions).is_none());
assert!(object_store_from_envelope(&envelope(extensions.clone())).is_none());
assert!(app_context_from_req(&request(extensions)).is_none());
assert!(object_store_from_envelope(&envelope(extensions)).is_none());
assert!(Arc::ptr_eq(
&current_app_context().expect("ambient context must remain available"),
&ambient
@@ -333,13 +322,10 @@ mod tests {
#[async_trait::async_trait]
impl Operation for ContextDependentAdminRoute {
async fn call(
&self,
req: S3Request<Body>,
_params: Params<'_, '_>,
) -> rustfs_s3_types::S3Result<S3Response<(StatusCode, Body)>> {
app_context_from_req(&req).ok_or_else(|| s3_error!(ServiceUnavailable, "server context is not ready"))?;
Ok(S3Response::new((StatusCode::NO_CONTENT, Body::empty())))
async fn call(&self, req: AdminRequest, _params: Params<'_, '_>) -> rustfs_s3_types::S3Result<AdminResponse> {
app_context_from_envelope(req.envelope())
.ok_or_else(|| s3_error!(ServiceUnavailable, "server context is not ready"))?;
Ok(AdminResponse::new(StatusCode::NO_CONTENT, Body::empty()))
}
}
@@ -355,16 +341,16 @@ mod tests {
.expect("register test route");
let err = router
.call(request(http::Extensions::new()))
.call_unsigned(Method::GET, "/context-probe".parse().expect("test URI"))
.await
.expect_err("empty slot must fail closed");
assert_eq!(err.code(), &S3ErrorCode::ServiceUnavailable.into());
assert_eq!(err.code(), &S3ErrorCode::ServiceUnavailable);
assert!(slot.install(distinct_context(&ambient)));
let response = router
.call(request(http::Extensions::new()))
.call_unsigned(Method::GET, "/context-probe".parse().expect("test URI"))
.await
.expect("installed slot must serve request");
assert_eq!(response.status, Some(StatusCode::NO_CONTENT));
assert_eq!(response.into_parts().0, StatusCode::NO_CONTENT);
}
}
+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")