use axum::{
body::Body,
http::{Request, StatusCode},
};
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
use hmac::{Hmac, KeyInit, Mac};
use http_body_util::BodyExt;
use image::{ImageBuffer, Rgba};
use imgforge::app::AppState;
use imgforge::caching::cache::ImgforgeCache;
use imgforge::config::Config;
use imgforge::handlers::image_forge_handler;
use imgforge::middleware::request_id_middleware;
use imgforge::processing::{options::ProcessingOption, presets::parse_options_string};
use lazy_static::lazy_static;
use libvips::{VipsApp, VipsImage};
use sha2::Sha256;
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::{OnceCell, Semaphore};
use tower::ServiceExt;
use wiremock::{
matchers::{method, path},
Mock, MockServer, ResponseTemplate,
};
type HmacSha256 = Hmac<Sha256>;
lazy_static! {
static ref VIPS_APP: Arc<VipsApp> =
Arc::new(VipsApp::new("imgforge-test", false).expect("Failed to initialize libvips"));
}
fn create_test_image(width: u32, height: u32, color: [u8; 4]) -> Vec<u8> {
let mut img: ImageBuffer<Rgba<u8>, Vec<u8>> = ImageBuffer::new(width, height);
for (_x, _y, pixel) in img.enumerate_pixels_mut() {
*pixel = Rgba(color);
}
let mut bytes: Vec<u8> = Vec::new();
img.write_to(&mut std::io::Cursor::new(&mut bytes), image::ImageFormat::Png)
.unwrap();
bytes
}
fn generate_signature(key: &[u8], salt: &[u8], path: &str) -> String {
let mut mac = HmacSha256::new_from_slice(key).expect("HMAC can take key of any size");
mac.update(salt);
mac.update(path.as_bytes());
let signature_bytes = mac.finalize().into_bytes();
URL_SAFE_NO_PAD.encode(signature_bytes)
}
fn create_test_config(
key: Vec<u8>,
salt: Vec<u8>,
allow_unsigned: bool,
presets: HashMap<String, Vec<ProcessingOption>>,
only_presets: bool,
) -> Config {
let mut config = Config::new(key, salt);
config.workers = 4;
config.allow_unsigned = allow_unsigned;
config.allow_security_options = true;
config.presets = presets;
config.only_presets = only_presets;
config
}
async fn create_test_state(config: Config) -> Arc<AppState> {
let cache = ImgforgeCache::None;
let metadata_cache = imgforge::caching::cache::MetadataCache::None;
let http_client = reqwest::Client::builder()
.timeout(Duration::from_secs(config.download_timeout))
.build()
.expect("client builds");
Arc::new(AppState {
semaphore: Arc::new(Semaphore::new(config.workers)),
cache,
metadata_cache,
rate_limiter: None,
config,
vips_app: VIPS_APP.clone(),
http_client,
watermark_cache: OnceCell::new(),
})
}
async fn make_request(app: axum::Router, uri: &str) -> (StatusCode, Vec<u8>) {
let req = Request::builder().uri(uri).body(Body::empty()).unwrap();
let response = app.oneshot(req).await.unwrap();
let status = response.status();
let body = response.into_body().collect().await.unwrap().to_bytes();
(status, body.to_vec())
}
#[tokio::test]
async fn test_preset_basic() {
let mock_server = MockServer::start().await;
let test_image = create_test_image(800, 600, [255, 0, 0, 255]);
Mock::given(method("GET"))
.and(path("/test.png"))
.respond_with(
ResponseTemplate::new(200)
.set_body_bytes(test_image)
.insert_header("content-type", "image/png"),
)
.mount(&mock_server)
.await;
let key = b"test_key".to_vec();
let salt = b"test_salt".to_vec();
let mut presets = HashMap::new();
presets.insert(
"thumbnail".to_string(),
parse_options_string("resize:fit:150:150/quality:80").unwrap(),
);
let config = create_test_config(key.clone(), salt.clone(), false, presets, false);
let state = create_test_state(config).await;
let app = axum::Router::new()
.route("/{*path}", axum::routing::get(image_forge_handler))
.layer(axum::middleware::from_fn(request_id_middleware))
.with_state(state);
let source_url = format!("{}/test.png", mock_server.uri());
let encoded_url = URL_SAFE_NO_PAD.encode(source_url.as_bytes());
let path_to_sign = format!("/preset:thumbnail/{}", encoded_url);
let signature = generate_signature(&key, &salt, &path_to_sign);
let uri = format!("/{}{}", signature, path_to_sign);
let (status, body) = make_request(app, &uri).await;
assert_eq!(status, StatusCode::OK);
assert!(!body.is_empty());
let img = VipsImage::new_from_buffer(&body, "").unwrap();
assert_eq!(img.get_width(), 150);
assert_eq!(img.get_height(), 113);
}
#[tokio::test]
async fn test_preset_with_default() {
let mock_server = MockServer::start().await;
let test_image = create_test_image(800, 600, [0, 255, 0, 255]);
Mock::given(method("GET"))
.and(path("/test.png"))
.respond_with(
ResponseTemplate::new(200)
.set_body_bytes(test_image)
.insert_header("content-type", "image/png"),
)
.mount(&mock_server)
.await;
let key = b"test_key".to_vec();
let salt = b"test_salt".to_vec();
let mut presets = HashMap::new();
presets.insert("default".to_string(), parse_options_string("quality:90/dpr:1").unwrap());
presets.insert("small".to_string(), parse_options_string("resize:fit:200:200").unwrap());
let config = create_test_config(key.clone(), salt.clone(), false, presets, false);
let state = create_test_state(config).await;
let app = axum::Router::new()
.route("/{*path}", axum::routing::get(image_forge_handler))
.layer(axum::middleware::from_fn(request_id_middleware))
.with_state(state);
let source_url = format!("{}/test.png", mock_server.uri());
let encoded_url = URL_SAFE_NO_PAD.encode(source_url.as_bytes());
let path_to_sign = format!("/preset:small/{}", encoded_url);
let signature = generate_signature(&key, &salt, &path_to_sign);
let uri = format!("/{}{}", signature, path_to_sign);
let (status, body) = make_request(app, &uri).await;
assert_eq!(status, StatusCode::OK);
assert!(!body.is_empty());
let img = VipsImage::new_from_buffer(&body, "").unwrap();
assert_eq!(img.get_width(), 200);
assert_eq!(img.get_height(), 150);
}
#[tokio::test]
async fn test_preset_default_only() {
let mock_server = MockServer::start().await;
let test_image = create_test_image(400, 300, [0, 0, 255, 255]);
Mock::given(method("GET"))
.and(path("/test.png"))
.respond_with(
ResponseTemplate::new(200)
.set_body_bytes(test_image)
.insert_header("content-type", "image/png"),
)
.mount(&mock_server)
.await;
let key = b"test_key".to_vec();
let salt = b"test_salt".to_vec();
let mut presets = HashMap::new();
presets.insert(
"default".to_string(),
parse_options_string("resize:fit:100:100").unwrap(),
);
let config = create_test_config(key.clone(), salt.clone(), false, presets, false);
let state = create_test_state(config).await;
let app = axum::Router::new()
.route("/{*path}", axum::routing::get(image_forge_handler))
.layer(axum::middleware::from_fn(request_id_middleware))
.with_state(state);
let source_url = format!("{}/test.png", mock_server.uri());
let encoded_url = URL_SAFE_NO_PAD.encode(source_url.as_bytes());
let path_to_sign = format!("/{}", encoded_url);
let signature = generate_signature(&key, &salt, &path_to_sign);
let uri = format!("/{}{}", signature, path_to_sign);
let (status, body) = make_request(app, &uri).await;
assert_eq!(status, StatusCode::OK);
assert!(!body.is_empty());
let img = VipsImage::new_from_buffer(&body, "").unwrap();
assert_eq!(img.get_width(), 100);
assert_eq!(img.get_height(), 75);
}
#[tokio::test]
async fn test_preset_unknown_preset_error() {
let mock_server = MockServer::start().await;
let test_image = create_test_image(400, 300, [255, 255, 0, 255]);
Mock::given(method("GET"))
.and(path("/test.png"))
.respond_with(
ResponseTemplate::new(200)
.set_body_bytes(test_image)
.insert_header("content-type", "image/png"),
)
.mount(&mock_server)
.await;
let key = b"test_key".to_vec();
let salt = b"test_salt".to_vec();
let presets = HashMap::new();
let config = create_test_config(key.clone(), salt.clone(), false, presets, false);
let state = create_test_state(config).await;
let app = axum::Router::new()
.route("/{*path}", axum::routing::get(image_forge_handler))
.layer(axum::middleware::from_fn(request_id_middleware))
.with_state(state);
let source_url = format!("{}/test.png", mock_server.uri());
let encoded_url = URL_SAFE_NO_PAD.encode(source_url.as_bytes());
let path_to_sign = format!("/preset:nonexistent/{}", encoded_url);
let signature = generate_signature(&key, &salt, &path_to_sign);
let uri = format!("/{}{}", signature, path_to_sign);
let (status, _body) = make_request(app, &uri).await;
assert_eq!(status, StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn test_only_presets_mode_allows_presets() {
let mock_server = MockServer::start().await;
let test_image = create_test_image(800, 600, [255, 0, 255, 255]);
Mock::given(method("GET"))
.and(path("/test.png"))
.respond_with(
ResponseTemplate::new(200)
.set_body_bytes(test_image)
.insert_header("content-type", "image/png"),
)
.mount(&mock_server)
.await;
let key = b"test_key".to_vec();
let salt = b"test_salt".to_vec();
let mut presets = HashMap::new();
presets.insert(
"thumbnail".to_string(),
parse_options_string("resize:fit:150:150").unwrap(),
);
let config = create_test_config(key.clone(), salt.clone(), false, presets, true);
let state = create_test_state(config).await;
let app = axum::Router::new()
.route("/{*path}", axum::routing::get(image_forge_handler))
.layer(axum::middleware::from_fn(request_id_middleware))
.with_state(state);
let source_url = format!("{}/test.png", mock_server.uri());
let encoded_url = URL_SAFE_NO_PAD.encode(source_url.as_bytes());
let path_to_sign = format!("/preset:thumbnail/{}", encoded_url);
let signature = generate_signature(&key, &salt, &path_to_sign);
let uri = format!("/{}{}", signature, path_to_sign);
let (status, body) = make_request(app, &uri).await;
assert_eq!(status, StatusCode::OK);
assert!(!body.is_empty());
let img = VipsImage::new_from_buffer(&body, "").unwrap();
assert_eq!(img.get_width(), 150);
assert_eq!(img.get_height(), 113);
}
#[tokio::test]
async fn test_only_presets_mode_rejects_non_presets() {
let mock_server = MockServer::start().await;
let test_image = create_test_image(800, 600, [128, 128, 128, 255]);
Mock::given(method("GET"))
.and(path("/test.png"))
.respond_with(
ResponseTemplate::new(200)
.set_body_bytes(test_image)
.insert_header("content-type", "image/png"),
)
.mount(&mock_server)
.await;
let key = b"test_key".to_vec();
let salt = b"test_salt".to_vec();
let presets = HashMap::new();
let config = create_test_config(key.clone(), salt.clone(), false, presets, true);
let state = create_test_state(config).await;
let app = axum::Router::new()
.route("/{*path}", axum::routing::get(image_forge_handler))
.layer(axum::middleware::from_fn(request_id_middleware))
.with_state(state);
let source_url = format!("{}/test.png", mock_server.uri());
let encoded_url = URL_SAFE_NO_PAD.encode(source_url.as_bytes());
let path_to_sign = format!("/resize:fit:300:300/{}", encoded_url);
let signature = generate_signature(&key, &salt, &path_to_sign);
let uri = format!("/{}{}", signature, path_to_sign);
let (status, _body) = make_request(app, &uri).await;
assert_eq!(status, StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn test_only_presets_mode_allows_default() {
let mock_server = MockServer::start().await;
let test_image = create_test_image(400, 300, [200, 100, 50, 255]);
Mock::given(method("GET"))
.and(path("/test.png"))
.respond_with(
ResponseTemplate::new(200)
.set_body_bytes(test_image)
.insert_header("content-type", "image/png"),
)
.mount(&mock_server)
.await;
let key = b"test_key".to_vec();
let salt = b"test_salt".to_vec();
let mut presets = HashMap::new();
presets.insert("default".to_string(), parse_options_string("resize:fit:50:50").unwrap());
let config = create_test_config(key.clone(), salt.clone(), false, presets, true);
let state = create_test_state(config).await;
let app = axum::Router::new()
.route("/{*path}", axum::routing::get(image_forge_handler))
.layer(axum::middleware::from_fn(request_id_middleware))
.with_state(state);
let source_url = format!("{}/test.png", mock_server.uri());
let encoded_url = URL_SAFE_NO_PAD.encode(source_url.as_bytes());
let path_to_sign = format!("/{}", encoded_url);
let signature = generate_signature(&key, &salt, &path_to_sign);
let uri = format!("/{}{}", signature, path_to_sign);
let (status, body) = make_request(app, &uri).await;
assert_eq!(status, StatusCode::OK);
assert!(!body.is_empty());
let img = VipsImage::new_from_buffer(&body, "").unwrap();
assert_eq!(img.get_width(), 50);
assert_eq!(img.get_height(), 38);
}
#[tokio::test]
async fn test_preset_short_form() {
let mock_server = MockServer::start().await;
let test_image = create_test_image(800, 600, [100, 200, 50, 255]);
Mock::given(method("GET"))
.and(path("/test.png"))
.respond_with(
ResponseTemplate::new(200)
.set_body_bytes(test_image)
.insert_header("content-type", "image/png"),
)
.mount(&mock_server)
.await;
let key = b"test_key".to_vec();
let salt = b"test_salt".to_vec();
let mut presets = HashMap::new();
presets.insert("thumb".to_string(), parse_options_string("resize:fit:100:100").unwrap());
let config = create_test_config(key.clone(), salt.clone(), false, presets, false);
let state = create_test_state(config).await;
let app = axum::Router::new()
.route("/{*path}", axum::routing::get(image_forge_handler))
.layer(axum::middleware::from_fn(request_id_middleware))
.with_state(state);
let source_url = format!("{}/test.png", mock_server.uri());
let encoded_url = URL_SAFE_NO_PAD.encode(source_url.as_bytes());
let path_to_sign = format!("/pr:thumb/{}", encoded_url);
let signature = generate_signature(&key, &salt, &path_to_sign);
let uri = format!("/{}{}", signature, path_to_sign);
let (status, body) = make_request(app, &uri).await;
assert_eq!(status, StatusCode::OK);
assert!(!body.is_empty());
let img = VipsImage::new_from_buffer(&body, "").unwrap();
assert_eq!(img.get_width(), 100);
assert_eq!(img.get_height(), 75);
}
#[tokio::test]
async fn test_preset_with_url_override() {
let mock_server = MockServer::start().await;
let test_image = create_test_image(800, 600, [255, 128, 0, 255]);
Mock::given(method("GET"))
.and(path("/test.png"))
.respond_with(
ResponseTemplate::new(200)
.set_body_bytes(test_image)
.insert_header("content-type", "image/png"),
)
.mount(&mock_server)
.await;
let key = b"test_key".to_vec();
let salt = b"test_salt".to_vec();
let mut presets = HashMap::new();
presets.insert("default".to_string(), parse_options_string("quality:80").unwrap());
let config = create_test_config(key.clone(), salt.clone(), false, presets, false);
let state = create_test_state(config).await;
let app = axum::Router::new()
.route("/{*path}", axum::routing::get(image_forge_handler))
.layer(axum::middleware::from_fn(request_id_middleware))
.with_state(state);
let source_url = format!("{}/test.png", mock_server.uri());
let encoded_url = URL_SAFE_NO_PAD.encode(source_url.as_bytes());
let path_to_sign = format!("/resize:fit:200:200/quality:95/{}", encoded_url);
let signature = generate_signature(&key, &salt, &path_to_sign);
let uri = format!("/{}{}", signature, path_to_sign);
let (status, body) = make_request(app, &uri).await;
assert_eq!(status, StatusCode::OK);
assert!(!body.is_empty());
let img = VipsImage::new_from_buffer(&body, "").unwrap();
assert_eq!(img.get_width(), 200);
assert_eq!(img.get_height(), 150);
}