// Copyright 2024 RustFS Team // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. use crate::common::{RustFSTestClusterEnvironment, RustFSTestEnvironment, init_logging, local_http_client}; use aws_sdk_s3::primitives::ByteStream; use http::header::{CONTENT_TYPE, HOST}; use reqwest::StatusCode; use rustfs_signer::constants::UNSIGNED_PAYLOAD; use rustfs_signer::{pre_sign_v4, sign_v4}; use s3s::Body; use serial_test::serial; use std::collections::HashMap; use std::error::Error; use time::OffsetDateTime; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::TcpListener; use tokio::sync::{mpsc, oneshot}; use tokio::time::{Duration, timeout}; #[derive(Debug)] struct CapturedWebhookRequest { headers: HashMap, payload: serde_json::Value, } struct WebhookResponseSpec { status_line: String, body: Vec, headers: Vec<(String, String)>, include_auth_headers: bool, auth_route_override: Option, auth_token_override: Option, } fn find_header_terminator(buf: &[u8]) -> Option { buf.windows(4).position(|window| window == b"\r\n\r\n") } async fn read_http_request( stream: &mut tokio::net::TcpStream, ) -> Result<(HashMap, Vec), Box> { let mut buffer = Vec::new(); let mut chunk = [0_u8; 4096]; let header_end = loop { let read = stream.read(&mut chunk).await?; if read == 0 { return Err("webhook request ended before headers were fully received".into()); } buffer.extend_from_slice(&chunk[..read]); if let Some(pos) = find_header_terminator(&buffer) { break pos; } }; let header_bytes = &buffer[..header_end]; let header_text = std::str::from_utf8(header_bytes)?; let mut lines = header_text.split("\r\n"); let _request_line = lines.next().ok_or("missing request line")?; let mut headers = HashMap::new(); for line in lines { if line.is_empty() { continue; } let (name, value) = line.split_once(':').ok_or("invalid header line")?; headers.insert(name.trim().to_ascii_lowercase(), value.trim().to_string()); } let content_length = headers .get("content-length") .ok_or("missing content-length header")? .parse::()?; let body_offset = header_end + 4; while buffer.len().saturating_sub(body_offset) < content_length { let read = stream.read(&mut chunk).await?; if read == 0 { return Err("webhook request ended before body was fully received".into()); } buffer.extend_from_slice(&chunk[..read]); } Ok((headers, buffer[body_offset..body_offset + content_length].to_vec())) } async fn spawn_object_lambda_webhook_server() -> Result< ( String, oneshot::Receiver, tokio::task::JoinHandle>>, ), Box, > { spawn_object_lambda_webhook_server_with_response(WebhookResponseSpec { status_line: "200 OK".to_string(), body: b"transformed through object lambda".to_vec(), headers: vec![("content-type".to_string(), "text/plain".to_string())], include_auth_headers: true, auth_route_override: None, auth_token_override: None, }) .await } async fn spawn_object_lambda_webhook_server_with_response( response_spec: WebhookResponseSpec, ) -> Result< ( String, oneshot::Receiver, tokio::task::JoinHandle>>, ), Box, > { let listener = TcpListener::bind("127.0.0.1:0").await?; let address = listener.local_addr()?; let webhook_url = format!("http://{address}/transform"); let (request_tx, request_rx) = oneshot::channel(); let handle = tokio::spawn(async move { loop { let (mut stream, _) = listener.accept().await?; let Ok(Ok((headers, body))) = timeout(Duration::from_secs(2), read_http_request(&mut stream)).await else { continue; }; let payload: serde_json::Value = serde_json::from_slice(&body)?; let output_route = payload["getObjectContext"]["outputRoute"] .as_str() .ok_or("missing outputRoute in webhook payload")? .to_string(); let output_token = payload["getObjectContext"]["outputToken"] .as_str() .ok_or("missing outputToken in webhook payload")? .to_string(); let _ = request_tx.send(CapturedWebhookRequest { headers, payload }); let mut response_head = format!( "HTTP/1.1 {}\r\ncontent-length: {}\r\nconnection: close\r\n", response_spec.status_line, response_spec.body.len() ); for (name, value) in &response_spec.headers { response_head.push_str(&format!("{name}: {value}\r\n")); } if response_spec.include_auth_headers { let auth_route = response_spec.auth_route_override.as_deref().unwrap_or(&output_route); let auth_token = response_spec.auth_token_override.as_deref().unwrap_or(&output_token); response_head.push_str(&format!("x-amz-request-route: {auth_route}\r\n")); response_head.push_str(&format!("x-amz-request-token: {auth_token}\r\n")); } response_head.push_str("\r\n"); stream.write_all(response_head.as_bytes()).await?; stream.write_all(&response_spec.body).await?; stream.shutdown().await?; return Ok(()); } }); Ok((webhook_url, request_rx, handle)) } async fn read_request_path(stream: &mut tokio::net::TcpStream) -> Result> { let mut buffer = Vec::new(); let mut chunk = [0_u8; 4096]; let header_end = loop { let read = stream.read(&mut chunk).await?; if read == 0 { return Err("request ended before headers were fully received".into()); } buffer.extend_from_slice(&chunk[..read]); if let Some(pos) = find_header_terminator(&buffer) { break pos; } }; let header_text = std::str::from_utf8(&buffer[..header_end])?; let request_line = header_text.lines().next().ok_or("missing request line")?; let path = request_line.split_whitespace().nth(1).ok_or("missing request path")?; Ok(path.to_string()) } async fn presigned_get_request( url: &str, access_key: &str, secret_key: &str, ) -> Result> { let uri = url.parse::()?; let authority = uri.authority().ok_or("request URL missing authority")?.to_string(); let signed = pre_sign_v4( http::Request::builder() .method(http::Method::GET) .uri(uri) .header(HOST, authority) .body(Body::empty())?, access_key, secret_key, "", "us-east-1", 600, OffsetDateTime::now_utc(), ); Ok(local_http_client().get(signed.uri().to_string()).send().await?) } async fn signed_request( method: http::Method, url: &str, access_key: &str, secret_key: &str, body: Option>, content_type: Option<&str>, ) -> Result> { let uri = url.parse::()?; let authority = uri.authority().ok_or("request URL missing authority")?.to_string(); let mut request = http::Request::builder().method(method.clone()).uri(uri); request = request.header(HOST, authority); request = request.header("x-amz-content-sha256", UNSIGNED_PAYLOAD); if let Some(content_type) = content_type { request = request.header(CONTENT_TYPE, content_type); } let content_len = body.as_ref().map(|body| body.len() as i64).unwrap_or_default(); let signed = sign_v4(request.body(Body::empty())?, content_len, access_key, secret_key, "", "us-east-1"); let reqwest_method = reqwest::Method::from_bytes(method.as_str().as_bytes())?; let client = local_http_client(); let mut request_builder = client.request(reqwest_method, url); for (name, value) in signed.headers() { request_builder = request_builder.header(name, value); } if let Some(body) = body { request_builder = request_builder.body(body); } Ok(request_builder.send().await?) } async fn configure_webhook_target( env: &RustFSTestEnvironment, target_name: &str, endpoint: &str, auth_token: &str, ) -> Result<(), Box> { configure_webhook_target_with_key_values( env, target_name, vec![ ("endpoint", endpoint.to_string()), ("auth_token", auth_token.to_string()), ("queue_dir", format!("{}/notify-queue", env.temp_dir)), ], ) .await } async fn configure_webhook_target_with_key_values( env: &RustFSTestEnvironment, target_name: &str, key_values: Vec<(&str, String)>, ) -> Result<(), Box> { let queue_dir = format!("{}/notify-queue", env.temp_dir); tokio::fs::create_dir_all(&queue_dir).await?; let mut key_values = key_values .into_iter() .map(|(key, value)| serde_json::json!({ "key": key, "value": value })) .collect::>(); if !key_values.iter().any(|entry| entry["key"].as_str() == Some("queue_dir")) { key_values.push(serde_json::json!({ "key": "queue_dir", "value": queue_dir })); } let response = send_configure_webhook_target_request(env, target_name, key_values).await?; if response.status() != StatusCode::OK { let status = response.status(); let body = response.text().await.unwrap_or_default(); return Err(format!("failed to configure object lambda webhook target: {status} {body}").into()); } Ok(()) } async fn send_configure_webhook_target_request( env: &RustFSTestEnvironment, target_name: &str, key_values: Vec, ) -> Result> { let payload = serde_json::json!({ "key_values": key_values }); let url = format!("{}/rustfs/admin/v3/target/notify_webhook/{}", env.url, target_name); signed_request( http::Method::PUT, &url, &env.access_key, &env.secret_key, Some(payload.to_string().into_bytes()), Some("application/json"), ) .await } async fn list_notification_targets(env: &RustFSTestEnvironment) -> Result> { let url = format!("{}/rustfs/admin/v3/target/list", env.url); let response = signed_request(http::Method::GET, &url, &env.access_key, &env.secret_key, None, None).await?; let status = response.status(); let body = response.bytes().await?; if status != StatusCode::OK { return Err(format!("failed to list notification targets: {status} {}", String::from_utf8_lossy(body.as_ref())).into()); } Ok(serde_json::from_slice(&body)?) } async fn list_target_arns(env: &RustFSTestEnvironment) -> Result, Box> { let url = format!("{}/rustfs/admin/v3/target/arns", env.url); let response = signed_request(http::Method::GET, &url, &env.access_key, &env.secret_key, None, None).await?; let status = response.status(); let body = response.bytes().await?; if status != StatusCode::OK { return Err(format!("failed to list target arns: {status} {}", String::from_utf8_lossy(body.as_ref())).into()); } Ok(serde_json::from_slice(&body)?) } async fn delete_webhook_target(env: &RustFSTestEnvironment, target_name: &str) -> Result<(), Box> { let url = format!("{}/rustfs/admin/v3/target/notify_webhook/{target_name}/reset", env.url); let response = signed_request(http::Method::DELETE, &url, &env.access_key, &env.secret_key, None, None).await?; let status = response.status(); let body = response.text().await.unwrap_or_default(); if status != StatusCode::OK { return Err(format!("failed to delete webhook target {target_name}: {status} {body}").into()); } Ok(()) } fn notification_target_is_listed(targets: &serde_json::Value, target_name: &str) -> bool { notification_target_entry(targets, target_name).is_some() } fn notification_target_entry<'a>(targets: &'a serde_json::Value, target_name: &str) -> Option<&'a serde_json::Value> { targets["notification_endpoints"] .as_array() .into_iter() .flatten() .find(|entry| { entry["account_id"].as_str() == Some(target_name) && entry["service"] .as_str() .is_some_and(|service| service == "webhook" || service.starts_with("webhook-")) }) } fn notification_target_status<'a>(targets: &'a serde_json::Value, target_name: &str) -> Option<&'a str> { notification_target_entry(targets, target_name).and_then(|entry| entry["status"].as_str()) } async fn wait_for_target_visibility( env: &RustFSTestEnvironment, target_name: &str, ) -> Result<(serde_json::Value, Vec), Box> { let mut last_targets = serde_json::Value::Null; let mut last_arns = Vec::new(); for _ in 0..20 { last_targets = list_notification_targets(env).await?; last_arns = list_target_arns(env).await?; if notification_target_is_listed(&last_targets, target_name) { return Ok((last_targets, last_arns)); } tokio::time::sleep(Duration::from_millis(250)).await; } Err(format!("target {target_name} did not become visible in admin APIs; targets={last_targets}, arns={last_arns:?}").into()) } async fn wait_for_target_absence( env: &RustFSTestEnvironment, target_name: &str, ) -> Result<(serde_json::Value, Vec), Box> { let mut last_targets = serde_json::Value::Null; let mut last_arns = Vec::new(); for _ in 0..20 { last_targets = list_notification_targets(env).await?; last_arns = list_target_arns(env).await?; let listed = notification_target_is_listed(&last_targets, target_name); let arn_listed = last_arns.iter().any(|arn| arn.ends_with(&format!(":{target_name}:webhook"))); if !listed && !arn_listed { return Ok((last_targets, last_arns)); } tokio::time::sleep(Duration::from_millis(250)).await; } Err(format!("target {target_name} remained visible in admin APIs; targets={last_targets}, arns={last_arns:?}").into()) } async fn restart_rustfs_server(env: &mut RustFSTestEnvironment) -> Result<(), Box> { env.stop_server(); env.start_rustfs_server_without_cleanup(vec![]).await } async fn spawn_http_origin_probe_server() -> Result< ( String, mpsc::Receiver, tokio::task::JoinHandle>>, ), Box, > { let listener = TcpListener::bind("127.0.0.1:0").await?; let address = listener.local_addr()?; let webhook_url = format!("http://{address}/hook"); let (path_tx, path_rx) = mpsc::channel(1); let handle = tokio::spawn(async move { loop { let (mut stream, _) = listener.accept().await?; let path = timeout(Duration::from_secs(2), read_request_path(&mut stream)).await??; let _ = path_tx.try_send(path.clone()); if path == "/" { let response = b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"; stream.write_all(response).await?; } } }); Ok((webhook_url, path_rx, handle)) } async fn read_persisted_server_config(env: &RustFSTestEnvironment) -> String { let path = format!("{}/.rustfs.sys/config/config.json", env.temp_dir); match tokio::fs::read_to_string(&path).await { Ok(content) => content, Err(err) if err.kind() == std::io::ErrorKind::IsADirectory => { let mut entries = Vec::new(); match tokio::fs::read_dir(&path).await { Ok(mut dir) => { while let Ok(Some(entry)) = dir.next_entry().await { entries.push(entry.file_name().to_string_lossy().to_string()); } entries.sort(); format!("persisted config stored as object directory at {path}; entries={entries:?}") } Err(dir_err) => format!("persisted config directory exists at {path} but could not be listed: {dir_err}"), } } Err(err) => format!("failed to read persisted config at {path}: {err}"), } } async fn read_listen_notification_event( response: reqwest::Response, expected_key: &str, ) -> Result> { let mut response = response; let mut pending = String::new(); loop { let chunk = timeout(Duration::from_secs(12), response.chunk()).await??; let Some(chunk) = chunk else { return Err("listen_notification stream ended before payload".into()); }; if chunk.is_empty() { continue; } pending.push_str(&String::from_utf8(chunk.to_vec())?); while let Some(newline) = pending.find('\n') { let line = pending.drain(..=newline).collect::(); let payload = line.trim(); if payload.is_empty() { continue; } let json: serde_json::Value = serde_json::from_str(payload)?; let Some(records) = json["Records"].as_array() else { continue; }; if records.is_empty() { continue; } let has_expected_key = records.iter().any(|record| { let Some(object_key) = record["s3"]["object"]["key"].as_str() else { return false; }; let decoded = urlencoding::decode(object_key) .map(|decoded| decoded.into_owned()) .unwrap_or_else(|_| object_key.to_string()); decoded == expected_key }); if has_expected_key { return Ok(payload.to_string()); } } } } #[tokio::test] #[serial] async fn test_notification_target_persists_across_restart_and_delete() -> Result<(), Box> { init_logging(); let (webhook_url, _request_rx, webhook_handle) = spawn_object_lambda_webhook_server().await?; let mut env = RustFSTestEnvironment::new().await?; env.start_rustfs_server(vec![]).await?; let target_name = "restart-target"; configure_webhook_target(&env, target_name, &webhook_url, "secret-token").await?; let (visible_targets, visible_arns) = wait_for_target_visibility(&env, target_name).await?; assert!(notification_target_is_listed(&visible_targets, target_name)); assert!( visible_arns .iter() .any(|arn| arn.ends_with(&format!(":{target_name}:webhook"))), "target ARN missing after initial configure: {visible_arns:?}" ); restart_rustfs_server(&mut env).await?; let (targets_after_restart, arns_after_restart) = wait_for_target_visibility(&env, target_name).await?; assert!(notification_target_is_listed(&targets_after_restart, target_name)); assert!( arns_after_restart .iter() .any(|arn| arn.ends_with(&format!(":{target_name}:webhook"))), "target ARN missing after restart: {arns_after_restart:?}" ); delete_webhook_target(&env, target_name).await?; let (targets_after_delete, arns_after_delete) = wait_for_target_absence(&env, target_name).await?; assert!(!notification_target_is_listed(&targets_after_delete, target_name)); assert!( !arns_after_delete .iter() .any(|arn| arn.ends_with(&format!(":{target_name}:webhook"))), "target ARN still visible after delete: {arns_after_delete:?}" ); restart_rustfs_server(&mut env).await?; let (targets_after_delete_restart, arns_after_delete_restart) = wait_for_target_absence(&env, target_name).await?; assert!(!notification_target_is_listed(&targets_after_delete_restart, target_name)); assert!( !arns_after_delete_restart .iter() .any(|arn| arn.ends_with(&format!(":{target_name}:webhook"))), "target ARN still visible after delete + restart: {arns_after_delete_restart:?}" ); webhook_handle.abort(); let _ = webhook_handle.await; Ok(()) } #[tokio::test] #[serial] async fn test_notification_target_with_path_is_online_via_transport_probe() -> Result<(), Box> { init_logging(); let (webhook_url, mut probe_rx, probe_handle) = spawn_http_origin_probe_server().await?; let mut env = RustFSTestEnvironment::new().await?; env.start_rustfs_server_with_env(vec![], &[("RUSTFS_NOTIFY_ENABLE", "true")]) .await?; let target_name = "path-probe"; configure_webhook_target(&env, target_name, &webhook_url, "secret-token").await?; let (visible_targets, visible_arns) = wait_for_target_visibility(&env, target_name).await?; assert_eq!(notification_target_status(&visible_targets, target_name), Some("online")); let observed_path = timeout(Duration::from_secs(10), probe_rx.recv()) .await .map_err(|_| "probe server timed out waiting for a request")? .ok_or("probe server did not observe a request")?; assert_eq!(observed_path, "/"); assert!( visible_arns .iter() .any(|arn| arn.ends_with(&format!(":{target_name}:webhook"))), "target ARN missing for reachable path endpoint: {visible_arns:?}" ); probe_handle.abort(); let _ = probe_handle.await; Ok(()) } #[tokio::test] #[serial] async fn test_get_object_lambda_accepts_presigned_requests() -> Result<(), Box> { init_logging(); let (webhook_url, request_rx, webhook_handle) = spawn_object_lambda_webhook_server().await?; let mut env = RustFSTestEnvironment::new().await?; env.start_rustfs_server(vec![]).await?; let bucket = "object-lambda-e2e-presigned"; let key = "input.txt"; let object_body = b"hello presigned object lambda"; let lambda_arn = "arn:rustfs:s3-object-lambda:us-east-1:transformer:webhook"; let client = env.create_s3_client(); client.create_bucket().bucket(bucket).send().await?; client .put_object() .bucket(bucket) .key(key) .body(ByteStream::from_static(object_body)) .send() .await?; configure_webhook_target(&env, "transformer", &webhook_url, "secret-token").await?; wait_for_target_visibility(&env, "transformer").await?; let lambda_url = format!("{}/{}/{}?lambdaArn={}", env.url, bucket, key, urlencoding::encode(lambda_arn)); let response = presigned_get_request(&lambda_url, &env.access_key, &env.secret_key).await?; assert_eq!(response.status(), StatusCode::OK); assert_eq!(response.text().await?, "transformed through object lambda"); let captured = timeout(Duration::from_secs(10), request_rx).await??; assert_eq!(captured.payload["configuration"]["accessPointArn"].as_str(), Some(lambda_arn)); webhook_handle.await??; Ok(()) } #[tokio::test] #[serial] async fn test_get_object_lambda_accepts_named_webhook_target_arn() -> Result<(), Box> { init_logging(); let (webhook_url, request_rx, webhook_handle) = spawn_object_lambda_webhook_server().await?; let mut env = RustFSTestEnvironment::new().await?; env.start_rustfs_server(vec![]).await?; let bucket = "object-lambda-e2e-named-target"; let key = "input.txt"; let lambda_arn = "arn:rustfs:s3-object-lambda:us-east-1:transformer:webhook-preview"; let client = env.create_s3_client(); client.create_bucket().bucket(bucket).send().await?; client .put_object() .bucket(bucket) .key(key) .body(ByteStream::from_static(b"hello object lambda")) .send() .await?; configure_webhook_target(&env, "transformer", &webhook_url, "secret-token").await?; wait_for_target_visibility(&env, "transformer").await?; let lambda_url = format!("{}/{}/{}?lambdaArn={}", env.url, bucket, key, urlencoding::encode(lambda_arn)); let response = signed_request(http::Method::GET, &lambda_url, &env.access_key, &env.secret_key, None, None).await?; assert_eq!(response.status(), StatusCode::OK); assert_eq!(response.text().await?, "transformed through object lambda"); let captured = timeout(Duration::from_secs(10), request_rx).await??; assert_eq!(captured.payload["configuration"]["accessPointArn"].as_str(), Some(lambda_arn)); webhook_handle.await??; Ok(()) } #[tokio::test] #[serial] async fn test_get_object_lambda_invokes_runtime_webhook_target() -> Result<(), Box> { init_logging(); let (webhook_url, request_rx, webhook_handle) = spawn_object_lambda_webhook_server().await?; let mut env = RustFSTestEnvironment::new().await?; env.start_rustfs_server(vec![]).await?; let bucket = "object-lambda-e2e"; let key = "input.txt"; let object_body = b"hello object lambda"; let lambda_arn = "arn:rustfs:s3-object-lambda:us-east-1:transformer:webhook"; let client = env.create_s3_client(); client.create_bucket().bucket(bucket).send().await?; client .put_object() .bucket(bucket) .key(key) .body(ByteStream::from_static(object_body)) .send() .await?; configure_webhook_target(&env, "transformer", &webhook_url, "secret-token").await?; let (visible_targets, visible_arns) = wait_for_target_visibility(&env, "transformer").await?; let lambda_url = format!("{}/{}/{}?lambdaArn={}", env.url, bucket, key, urlencoding::encode(lambda_arn)); let response = signed_request(http::Method::GET, &lambda_url, &env.access_key, &env.secret_key, None, None).await?; if response.status() != StatusCode::OK { let status = response.status(); let body = response.text().await.unwrap_or_default(); let persisted_config = read_persisted_server_config(&env).await; return Err(format!( "object lambda request failed: {status} {body}; visible_targets={visible_targets}; visible_arns={visible_arns:?}; persisted_config={persisted_config}" ) .into()); } assert_eq!( response.headers().get(CONTENT_TYPE).and_then(|value| value.to_str().ok()), Some("text/plain") ); assert_eq!(response.text().await?, "transformed through object lambda"); let captured = timeout(Duration::from_secs(10), request_rx).await??; assert_eq!(captured.headers.get("authorization").map(String::as_str), Some("Bearer secret-token")); assert_eq!(captured.headers.get("x-rustfs-object-lambda-bucket").map(String::as_str), Some(bucket)); assert_eq!(captured.headers.get("x-rustfs-object-lambda-key").map(String::as_str), Some(key)); assert_eq!(captured.payload["configuration"]["accessPointArn"].as_str(), Some(lambda_arn)); let expected_request_url = format!("/{bucket}/{key}?lambdaArn={}", urlencoding::encode(lambda_arn)); assert_eq!(captured.payload["userRequest"]["url"].as_str(), Some(expected_request_url.as_str())); let input_s3_url = captured.payload["getObjectContext"]["inputS3Url"] .as_str() .ok_or("missing inputS3Url in object lambda payload")?; assert!(!input_s3_url.contains("lambdaArn=")); let source_response = local_http_client().get(input_s3_url).send().await?; assert_eq!(source_response.status(), StatusCode::OK); assert_eq!(source_response.bytes().await?.as_ref(), object_body); webhook_handle.await??; Ok(()) } #[tokio::test] #[serial] async fn test_get_object_lambda_passthroughs_non_success_webhook_response() -> Result<(), Box> { init_logging(); let (webhook_url, _request_rx, webhook_handle) = spawn_object_lambda_webhook_server_with_response(WebhookResponseSpec { status_line: "418 I'm a teapot".to_string(), body: b"lambda upstream rejected".to_vec(), headers: vec![ ("content-type".to_string(), "text/plain".to_string()), ("x-rustfs-debug".to_string(), "passthrough".to_string()), ("x-amz-request-route".to_string(), "should-not-leak".to_string()), ("x-amz-request-token".to_string(), "should-not-leak".to_string()), ], include_auth_headers: false, auth_route_override: None, auth_token_override: None, }) .await?; let mut env = RustFSTestEnvironment::new().await?; env.start_rustfs_server(vec![]).await?; let bucket = "object-lambda-e2e-failure"; let key = "input.txt"; let lambda_arn = "arn:rustfs:s3-object-lambda:us-east-1:transformer:webhook"; let client = env.create_s3_client(); client.create_bucket().bucket(bucket).send().await?; client .put_object() .bucket(bucket) .key(key) .body(ByteStream::from_static(b"hello object lambda")) .send() .await?; configure_webhook_target(&env, "transformer", &webhook_url, "secret-token").await?; wait_for_target_visibility(&env, "transformer").await?; let lambda_url = format!("{}/{}/{}?lambdaArn={}", env.url, bucket, key, urlencoding::encode(lambda_arn)); let response = signed_request(http::Method::GET, &lambda_url, &env.access_key, &env.secret_key, None, None).await?; assert_eq!(response.status(), StatusCode::IM_A_TEAPOT); assert_eq!( response.headers().get("content-type").and_then(|value| value.to_str().ok()), Some("text/plain") ); assert_eq!( response.headers().get("x-rustfs-debug").and_then(|value| value.to_str().ok()), Some("passthrough") ); assert!(response.headers().get("x-amz-request-route").is_none()); assert!(response.headers().get("x-amz-request-token").is_none()); assert_eq!(response.text().await?, "lambda upstream rejected"); webhook_handle.await??; Ok(()) } #[tokio::test] #[serial] async fn test_get_object_lambda_rejects_success_response_without_auth_headers() -> Result<(), Box> { init_logging(); let (webhook_url, _request_rx, webhook_handle) = spawn_object_lambda_webhook_server_with_response(WebhookResponseSpec { status_line: "200 OK".to_string(), body: b"missing auth headers".to_vec(), headers: vec![("content-type".to_string(), "text/plain".to_string())], include_auth_headers: false, auth_route_override: None, auth_token_override: None, }) .await?; let mut env = RustFSTestEnvironment::new().await?; env.start_rustfs_server(vec![]).await?; let bucket = "object-lambda-e2e-missing-auth"; let key = "input.txt"; let lambda_arn = "arn:rustfs:s3-object-lambda:us-east-1:transformer:webhook"; let client = env.create_s3_client(); client.create_bucket().bucket(bucket).send().await?; client .put_object() .bucket(bucket) .key(key) .body(ByteStream::from_static(b"hello object lambda")) .send() .await?; configure_webhook_target(&env, "transformer", &webhook_url, "secret-token").await?; wait_for_target_visibility(&env, "transformer").await?; let lambda_url = format!("{}/{}/{}?lambdaArn={}", env.url, bucket, key, urlencoding::encode(lambda_arn)); let response = signed_request(http::Method::GET, &lambda_url, &env.access_key, &env.secret_key, None, None).await?; assert_eq!(response.status(), StatusCode::BAD_REQUEST); let body = response.text().await?; assert!(body.contains("authorization headers"), "unexpected error body: {body}"); webhook_handle.await??; Ok(()) } #[tokio::test] #[serial] async fn test_get_object_lambda_rejects_success_response_with_mismatched_auth_headers() -> Result<(), Box> { init_logging(); let (webhook_url, _request_rx, webhook_handle) = spawn_object_lambda_webhook_server_with_response(WebhookResponseSpec { status_line: "200 OK".to_string(), body: b"mismatched auth headers".to_vec(), headers: vec![("content-type".to_string(), "text/plain".to_string())], include_auth_headers: true, auth_route_override: Some("wrong-route".to_string()), auth_token_override: Some("wrong-token".to_string()), }) .await?; let mut env = RustFSTestEnvironment::new().await?; env.start_rustfs_server(vec![]).await?; let bucket = "object-lambda-e2e-mismatched-auth"; let key = "input.txt"; let lambda_arn = "arn:rustfs:s3-object-lambda:us-east-1:transformer:webhook"; let client = env.create_s3_client(); client.create_bucket().bucket(bucket).send().await?; client .put_object() .bucket(bucket) .key(key) .body(ByteStream::from_static(b"hello object lambda")) .send() .await?; configure_webhook_target(&env, "transformer", &webhook_url, "secret-token").await?; wait_for_target_visibility(&env, "transformer").await?; let lambda_url = format!("{}/{}/{}?lambdaArn={}", env.url, bucket, key, urlencoding::encode(lambda_arn)); let response = signed_request(http::Method::GET, &lambda_url, &env.access_key, &env.secret_key, None, None).await?; assert_eq!(response.status(), StatusCode::BAD_REQUEST); let body = response.text().await?; assert!(body.contains("authorization headers"), "unexpected error body: {body}"); webhook_handle.await??; Ok(()) } #[tokio::test] #[serial] async fn test_get_object_lambda_rejects_unsupported_target_type() -> Result<(), Box> { init_logging(); let mut env = RustFSTestEnvironment::new().await?; env.start_rustfs_server(vec![]).await?; let bucket = "object-lambda-e2e-unsupported-target"; let key = "input.txt"; let lambda_arn = "arn:rustfs:s3-object-lambda:us-east-1:transformer:mqtt"; let client = env.create_s3_client(); client.create_bucket().bucket(bucket).send().await?; client .put_object() .bucket(bucket) .key(key) .body(ByteStream::from_static(b"hello object lambda")) .send() .await?; let lambda_url = format!("{}/{}/{}?lambdaArn={}", env.url, bucket, key, urlencoding::encode(lambda_arn)); let response = signed_request(http::Method::GET, &lambda_url, &env.access_key, &env.secret_key, None, None).await?; let status = response.status(); let body = response.text().await?; assert_eq!(status, StatusCode::NOT_IMPLEMENTED); assert!(body.contains("NotImplemented"), "unexpected error body: {body}"); assert!( body.to_ascii_lowercase().contains("target type is not supported"), "unexpected error body: {body}" ); Ok(()) } #[tokio::test] #[serial] async fn test_get_object_lambda_rejects_unconfigured_target() -> Result<(), Box> { init_logging(); let mut env = RustFSTestEnvironment::new().await?; env.start_rustfs_server(vec![]).await?; let bucket = "object-lambda-e2e-missing-target"; let key = "input.txt"; let lambda_arn = "arn:rustfs:s3-object-lambda:us-east-1:transformer:webhook"; let client = env.create_s3_client(); client.create_bucket().bucket(bucket).send().await?; client .put_object() .bucket(bucket) .key(key) .body(ByteStream::from_static(b"hello object lambda")) .send() .await?; let lambda_url = format!("{}/{}/{}?lambdaArn={}", env.url, bucket, key, urlencoding::encode(lambda_arn)); let response = signed_request(http::Method::GET, &lambda_url, &env.access_key, &env.secret_key, None, None).await?; let status = response.status(); let body = response.text().await?; assert_eq!(status, StatusCode::BAD_REQUEST); assert!(body.contains("InvalidRequest"), "unexpected error body: {body}"); assert!( body.to_ascii_lowercase().contains("target is not configured"), "unexpected error body: {body}" ); Ok(()) } #[tokio::test] #[serial] async fn test_get_object_lambda_rejects_disabled_target() -> Result<(), Box> { init_logging(); let mut env = RustFSTestEnvironment::new().await?; env.start_rustfs_server(vec![]).await?; let bucket = "object-lambda-e2e-disabled-target"; let key = "input.txt"; let lambda_arn = "arn:rustfs:s3-object-lambda:us-east-1:transformer:webhook"; let client = env.create_s3_client(); client.create_bucket().bucket(bucket).send().await?; client .put_object() .bucket(bucket) .key(key) .body(ByteStream::from_static(b"hello object lambda")) .send() .await?; configure_webhook_target_with_key_values( &env, "transformer", vec![ ("endpoint", "http://127.0.0.1:9/transform".to_string()), ("auth_token", "secret-token".to_string()), ("enable", "off".to_string()), ], ) .await?; wait_for_target_visibility(&env, "transformer").await?; let lambda_url = format!("{}/{}/{}?lambdaArn={}", env.url, bucket, key, urlencoding::encode(lambda_arn)); let response = signed_request(http::Method::GET, &lambda_url, &env.access_key, &env.secret_key, None, None).await?; let status = response.status(); let body = response.text().await?; assert_eq!(status, StatusCode::BAD_REQUEST); assert!(body.contains("InvalidRequest"), "unexpected error body: {body}"); assert!(body.to_ascii_lowercase().contains("target is disabled"), "unexpected error body: {body}"); Ok(()) } #[tokio::test] #[serial] async fn test_configure_object_lambda_target_rejects_invalid_endpoint() -> Result<(), Box> { init_logging(); let mut env = RustFSTestEnvironment::new().await?; env.start_rustfs_server(vec![]).await?; let bucket = "object-lambda-e2e-invalid-endpoint"; let client = env.create_s3_client(); client.create_bucket().bucket(bucket).send().await?; client .put_object() .bucket(bucket) .key("input.txt") .body(ByteStream::from_static(b"hello object lambda")) .send() .await?; let response = send_configure_webhook_target_request( &env, "transformer", vec![ serde_json::json!({ "key": "endpoint", "value": "://invalid-endpoint" }), serde_json::json!({ "key": "auth_token", "value": "secret-token" }), serde_json::json!({ "key": "queue_dir", "value": format!("{}/notify-queue", env.temp_dir) }), ], ) .await?; let status = response.status(); let body = response.text().await?; assert_eq!(status, StatusCode::BAD_REQUEST); assert!(body.contains("InvalidArgument"), "unexpected error body: {body}"); assert!( body.to_ascii_lowercase().contains("invalid endpoint url"), "unexpected error body: {body}" ); Ok(()) } #[tokio::test] #[serial] async fn test_configure_object_lambda_notify_webhook_rejects_response_header_timeout_key() -> Result<(), Box> { init_logging(); let mut env = RustFSTestEnvironment::new().await?; env.start_rustfs_server(vec![]).await?; let response = send_configure_webhook_target_request( &env, "transformer", vec![ serde_json::json!({ "key": "endpoint", "value": "http://127.0.0.1:9/transform" }), serde_json::json!({ "key": "auth_token", "value": "secret-token" }), serde_json::json!({ "key": "response_header_timeout", "value": "not-a-duration" }), serde_json::json!({ "key": "queue_dir", "value": format!("{}/notify-queue", env.temp_dir) }), ], ) .await?; let status = response.status(); let body = response.text().await?; assert_eq!(status, StatusCode::BAD_REQUEST); assert!(body.contains("InvalidArgument"), "unexpected error body: {body}"); assert!( body.to_ascii_lowercase().contains("response_header_timeout"), "unexpected error body: {body}" ); assert!(body.to_ascii_lowercase().contains("not allowed"), "unexpected error body: {body}"); Ok(()) } #[tokio::test] #[serial] async fn test_listen_notification_emits_after_put_object() -> Result<(), Box> { init_logging(); let mut env = RustFSTestEnvironment::new().await?; env.start_rustfs_server(vec![]).await?; let bucket = "listen-notification-e2e"; let key = "logs/app.json"; let client = env.create_s3_client(); client.create_bucket().bucket(bucket).send().await?; let listen_url = format!( "{}/{bucket}?events={}&prefix={}&suffix={}&ping=1", env.url, urlencoding::encode("s3:ObjectCreated:Put"), urlencoding::encode("logs/"), urlencoding::encode(".json"), ); let response = signed_request(http::Method::GET, &listen_url, &env.access_key, &env.secret_key, None, None).await?; assert_eq!(response.status(), StatusCode::OK); assert_eq!( response.headers().get("content-type").and_then(|value| value.to_str().ok()), Some("text/event-stream") ); let read_task = tokio::spawn(read_listen_notification_event(response, key)); client .put_object() .bucket(bucket) .key(key) .body(ByteStream::from_static(b"listen notification body")) .send() .await?; let payload = timeout(Duration::from_secs(12), read_task).await???; assert!(!payload.is_empty(), "listen_notification payload should not be empty"); Ok(()) } #[tokio::test] #[serial] async fn test_listen_notification_emits_on_empty_bucket_when_notify_disabled() -> Result<(), Box> { init_logging(); let mut env = RustFSTestEnvironment::new().await?; env.start_rustfs_server_with_env(vec![], &[("RUSTFS_NOTIFY_ENABLE", "false")]) .await?; let bucket = "listen-empty-bucket-e2e"; let key = "seed/object.txt"; let client = env.create_s3_client(); client.create_bucket().bucket(bucket).send().await?; let listen_url = format!("{}/{bucket}?events={}&ping=1", env.url, urlencoding::encode("s3:ObjectCreated:*"),); let response = signed_request(http::Method::GET, &listen_url, &env.access_key, &env.secret_key, None, None).await?; assert_eq!(response.status(), StatusCode::OK); let read_task = tokio::spawn(read_listen_notification_event(response, key)); client .put_object() .bucket(bucket) .key(key) .body(ByteStream::from_static(b"empty bucket watch body")) .send() .await?; let payload = timeout(Duration::from_secs(12), read_task).await???; assert!(!payload.is_empty(), "listen_notification payload should not be empty"); Ok(()) } #[tokio::test] #[serial] async fn test_listen_notification_fans_in_remote_node_events() -> Result<(), Box> { init_logging(); let mut cluster = RustFSTestClusterEnvironment::new(2).await?; cluster.start().await?; let bucket = "listen-notification-cluster"; let key = "logs/cluster.json"; let node0_client = cluster.create_s3_client(0)?; let node1_client = cluster.create_s3_client(1)?; node0_client.create_bucket().bucket(bucket).send().await?; let listen_url = format!( "{}/{bucket}?events={}&prefix={}&suffix={}&ping=1", cluster.nodes[0].url, urlencoding::encode("s3:ObjectCreated:Put"), urlencoding::encode("logs/"), urlencoding::encode(".json"), ); let response = signed_request(http::Method::GET, &listen_url, &cluster.access_key, &cluster.secret_key, None, None).await?; assert_eq!(response.status(), StatusCode::OK); let read_task = tokio::spawn(read_listen_notification_event(response, key)); node1_client .put_object() .bucket(bucket) .key(key) .body(ByteStream::from_static(b"cluster listen notification body")) .send() .await?; let payload = timeout(Duration::from_secs(12), read_task).await???; assert!(!payload.is_empty(), "listen_notification cluster payload should not be empty"); Ok(()) }