use codetether_agent::event_stream::s3_sink::{S3Sink, S3SinkConfig, S3SinkError};
use std::io::Write;
use std::sync::{LazyLock, Mutex, MutexGuard};
use tempfile::NamedTempFile;
static ENV_LOCK: LazyLock<Mutex<()>> = LazyLock::new(|| Mutex::new(()));
const MANAGED_ENV_KEYS: &[&str] = &[
"S3_BUCKET",
"S3_ACCESS_KEY",
"S3_SECRET_KEY",
"S3_ENDPOINT",
"S3_REGION",
"S3_STUB_MODE",
"CODETETHER_S3_BUCKET",
"CODETETHER_S3_ACCESS_KEY",
"CODETETHER_S3_SECRET_KEY",
"CODETETHER_S3_ENDPOINT",
"CODETETHER_S3_REGION",
];
struct EnvVarGuard {
_lock: MutexGuard<'static, ()>,
snapshot: Vec<(String, Option<String>)>,
}
impl EnvVarGuard {
fn lock() -> Self {
let lock = ENV_LOCK.lock().expect("S3 env lock poisoned");
let snapshot = MANAGED_ENV_KEYS
.iter()
.map(|k| ((*k).to_string(), std::env::var(k).ok()))
.collect();
Self {
_lock: lock,
snapshot,
}
}
fn set(&self, key: &str, value: &str) {
unsafe {
std::env::set_var(key, value);
}
}
fn remove(&self, key: &str) {
unsafe {
std::env::remove_var(key);
}
}
}
impl Drop for EnvVarGuard {
fn drop(&mut self) {
for (key, value) in &self.snapshot {
unsafe {
match value {
Some(v) => std::env::set_var(key, v),
None => std::env::remove_var(key),
}
}
}
}
}
#[tokio::test]
async fn test_s3_upload_makes_http_request() {
let _env = EnvVarGuard::lock();
if std::env::var("S3_BUCKET").is_err() && std::env::var("CODETETHER_S3_BUCKET").is_err() {
eprintln!("Skipping test: S3_BUCKET (or CODETETHER_S3_BUCKET) not set");
return;
}
let config = S3SinkConfig::from_env().expect("S3 config required");
let sink = S3Sink::from_config(config)
.await
.expect("Failed to create S3 sink");
let mut temp_file = NamedTempFile::new().expect("Failed to create temp file");
let test_data = br#"{"test": "data"}
{"more": "data"}
"#;
temp_file
.write_all(test_data)
.expect("Failed to write test data");
temp_file.flush().expect("Failed to flush");
let path = temp_file.path().to_path_buf();
let result = sink.upload_file(&path, "test-session").await;
match result {
Ok(url) => {
assert!(
url.starts_with("s3://")
|| url.contains(".s3.")
|| url.contains(".cloudflarestorage.com"),
"Expected S3 URL, got: {url}"
);
println!("Upload succeeded: {url}");
}
Err(S3SinkError::Http(e)) => {
println!("Network error (expected in test env): {e}");
}
Err(e) => {
panic!("Unexpected error: {e:?}");
}
}
}
#[tokio::test]
async fn test_s3_upload_bytes_makes_http_request() {
let _env = EnvVarGuard::lock();
if std::env::var("S3_BUCKET").is_err() && std::env::var("CODETETHER_S3_BUCKET").is_err() {
eprintln!("Skipping test: S3_BUCKET (or CODETETHER_S3_BUCKET) not set");
return;
}
let config = S3SinkConfig::from_env().expect("S3 config required");
let sink = S3Sink::from_config(config)
.await
.expect("Failed to create S3 sink");
let test_data = b"{\"event\": \"test\"}\n";
let s3_key = "test/upload_test.json";
let result = sink
.upload_bytes(test_data, s3_key, "application/json")
.await;
match result {
Ok(url) => {
assert!(url.contains(s3_key), "URL should contain the S3 key: {url}");
println!("Upload succeeded: {url}");
}
Err(S3SinkError::Http(e)) => {
println!("Network error (expected in test env): {e}");
}
Err(e) => {
panic!("Unexpected error: {e:?}");
}
}
}
#[test]
fn test_s3_config_from_env() {
let env = EnvVarGuard::lock();
env.set("S3_BUCKET", "test-bucket");
env.set("S3_ACCESS_KEY", "test-key");
env.set("S3_SECRET_KEY", "test-secret");
env.set("S3_ENDPOINT", "https://test.r2.cloudflarestorage.com");
let config = S3SinkConfig::from_env();
assert!(config.is_some(), "Config should be loaded from env");
let cfg = config.unwrap();
assert_eq!(cfg.bucket, "test-bucket");
assert_eq!(cfg.access_key, Some("test-key".to_string()));
assert_eq!(cfg.secret_key, Some("test-secret".to_string()));
}
#[tokio::test]
async fn test_s3_error_handling_missing_credentials() {
let env = EnvVarGuard::lock();
env.remove("S3_BUCKET");
env.remove("CODETETHER_S3_BUCKET");
let result = S3Sink::from_env().await;
assert!(result.is_err(), "Should fail without S3_BUCKET");
match result {
Err(S3SinkError::MissingConfig(msg)) => {
assert!(msg.contains("S3_BUCKET"), "Error should mention S3_BUCKET");
assert!(
msg.contains("CODETETHER_S3_BUCKET"),
"Error should mention CODETETHER_S3_BUCKET"
);
}
_ => panic!("Expected MissingConfig error"),
}
}
#[test]
fn test_stub_mode_configuration() {
let env = EnvVarGuard::lock();
env.set("S3_STUB_MODE", "true");
env.set("S3_BUCKET", "stub-bucket");
let stub_mode = std::env::var("S3_STUB_MODE").unwrap_or_default();
assert_eq!(stub_mode, "true", "Stub mode should be configurable");
}
#[test]
fn test_s3_env_vars_take_precedence() {
let env = EnvVarGuard::lock();
env.set("S3_BUCKET", "new-bucket");
env.set("CODETETHER_S3_BUCKET", "legacy-bucket");
env.set("S3_ACCESS_KEY", "new-key");
env.set("CODETETHER_S3_ACCESS_KEY", "legacy-key");
let config = S3SinkConfig::from_env();
assert!(config.is_some(), "Config should be loaded from env");
let cfg = config.unwrap();
assert_eq!(
cfg.bucket, "new-bucket",
"Should prefer S3_BUCKET over CODETETHER_S3_BUCKET"
);
assert_eq!(
cfg.access_key,
Some("new-key".to_string()),
"Should prefer S3_ACCESS_KEY over CODETETHER_S3_ACCESS_KEY"
);
}
#[test]
fn test_codetether_s3_env_vars_as_fallback() {
let env = EnvVarGuard::lock();
env.set("CODETETHER_S3_BUCKET", "legacy-bucket");
env.set("CODETETHER_S3_ACCESS_KEY", "legacy-key");
env.set("CODETETHER_S3_SECRET_KEY", "legacy-secret");
env.set(
"CODETETHER_S3_ENDPOINT",
"https://legacy.r2.cloudflarestorage.com",
);
env.set("CODETETHER_S3_REGION", "legacy-region");
let config = S3SinkConfig::from_env();
assert!(
config.is_some(),
"Config should be loaded from legacy env vars"
);
let cfg = config.unwrap();
assert_eq!(
cfg.bucket, "legacy-bucket",
"Should use CODETETHER_S3_BUCKET as fallback"
);
assert_eq!(
cfg.access_key,
Some("legacy-key".to_string()),
"Should use CODETETHER_S3_ACCESS_KEY as fallback"
);
assert_eq!(
cfg.secret_key,
Some("legacy-secret".to_string()),
"Should use CODETETHER_S3_SECRET_KEY as fallback"
);
assert_eq!(
cfg.endpoint,
Some("https://legacy.r2.cloudflarestorage.com".to_string()),
"Should use CODETETHER_S3_ENDPOINT as fallback"
);
assert_eq!(
cfg.region, "legacy-region",
"Should use CODETETHER_S3_REGION as fallback"
);
}
#[tokio::test]
async fn test_error_message_mentions_both_conventions() {
let env = EnvVarGuard::lock();
env.remove("S3_BUCKET");
env.remove("CODETETHER_S3_BUCKET");
let result = S3Sink::from_env().await;
assert!(result.is_err(), "Should fail without S3_BUCKET");
match result {
Err(S3SinkError::MissingConfig(msg)) => {
assert!(msg.contains("S3_BUCKET"), "Error should mention S3_BUCKET");
assert!(
msg.contains("CODETETHER_S3_BUCKET"),
"Error should mention CODETETHER_S3_BUCKET"
);
}
_ => panic!("Expected MissingConfig error"),
}
}