//! Minimal in-process S3-compatible object store for upload tests. //! //! The upload handler bails with 503 when `AppState.s3` is `None`, which put //! everything past that line (authz, rate limit, validation, EXIF strip, the //! insert-before-upload ordering) out of reach of the integration suite. This //! stub closes that gap without a container or a live bucket. //! //! It implements exactly what `s3_storage::S3Client` sends on the paths the //! forum uses: path-style PUT, GET, and DELETE of a single object. The SDK is //! built with `force_path_style(true)` and performs no connectivity probe at //! construction, so pointing `endpoint` at this server is enough. Requests are //! not signature-checked: the client signs them, and re-verifying SigV4 here //! would test the AWS SDK rather than the forum. use std::collections::HashMap; use std::sync::{Arc, Mutex}; use axum::{ Router, body::Bytes, extract::{Path, State}, http::{HeaderMap, StatusCode, header}, response::{IntoResponse, Response}, routing::any, }; /// An object as the stub holds it. #[derive(Clone)] pub(crate) struct StoredObject { pub data: Vec, pub content_type: String, } type Objects = Arc>>; /// A running stub. Dropping the harness drops the server task with it. pub(crate) struct S3Stub { pub endpoint: String, pub bucket: String, objects: Objects, } impl S3Stub { /// Bind on an ephemeral loopback port and serve until dropped. pub(crate) async fn start() -> Self { let objects: Objects = Arc::new(Mutex::new(HashMap::new())); let bucket = "test-bucket".to_string(); let app = Router::new() .route("/{bucket}/{*key}", any(object_handler)) .with_state(objects.clone()); let listener = tokio::net::TcpListener::bind("127.0.0.1:0") .await .expect("failed to bind S3 stub"); let addr = listener.local_addr().expect("no local addr for S3 stub"); tokio::spawn(async move { let _ = axum::serve(listener, app).await; }); S3Stub { endpoint: format!("http://{addr}"), bucket, objects, } } /// Number of objects currently stored. pub(crate) fn len(&self) -> usize { self.objects.lock().expect("S3 stub mutex poisoned").len() } /// Fetch a stored object by key. pub(crate) fn get(&self, key: &str) -> Option { self.objects .lock() .expect("S3 stub mutex poisoned") .get(key) .cloned() } } /// One handler for the three verbs, so an unexpected method surfaces as 405 /// rather than a routing miss that the SDK would report as a connection error. async fn object_handler( State(objects): State, Path((_bucket, key)): Path<(String, String)>, method: axum::http::Method, headers: HeaderMap, body: Bytes, ) -> Response { match method { axum::http::Method::PUT => { let content_type = headers .get(header::CONTENT_TYPE) .and_then(|v| v.to_str().ok()) .unwrap_or("application/octet-stream") .to_string(); objects.lock().expect("S3 stub mutex poisoned").insert( key, StoredObject { data: body.to_vec(), content_type, }, ); // A real PutObject answers 200 with an ETag and no body. ([(header::ETAG, "\"stub\"")], StatusCode::OK).into_response() } axum::http::Method::GET => { let found = objects .lock() .expect("S3 stub mutex poisoned") .get(&key) .cloned(); match found { Some(obj) => ( [ (header::CONTENT_TYPE, obj.content_type), (header::ETAG, "\"stub\"".to_string()), ], obj.data, ) .into_response(), None => not_found(), } } axum::http::Method::DELETE => { objects.lock().expect("S3 stub mutex poisoned").remove(&key); StatusCode::NO_CONTENT.into_response() } _ => StatusCode::METHOD_NOT_ALLOWED.into_response(), } } /// S3 reports a missing key as 404 carrying a `NoSuchKey` error document; the /// SDK parses the body to classify the error, so return the real shape. fn not_found() -> Response { ( StatusCode::NOT_FOUND, [(header::CONTENT_TYPE, "application/xml")], r#"NoSuchKeyThe specified key does not exist."#, ) .into_response() }