use axum::body::Body; use axum::http::{Method, Request, StatusCode}; use http_body_util::BodyExt; use tower::ServiceExt; const TEST_ACCESS_KEY: &str = "AKIAIOSFODNN7EXAMPLE"; const TEST_SECRET_KEY: &str = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"; fn test_app() -> (axum::Router, tempfile::TempDir) { let tmp = tempfile::TempDir::new().unwrap(); let iam_path = tmp.path().join(".myfsio.sys").join("config"); std::fs::create_dir_all(&iam_path).unwrap(); let iam_json = serde_json::json!({ "version": 2, "users": [{ "user_id": "u-test1234", "display_name": "admin", "enabled": true, "access_keys": [{ "access_key": TEST_ACCESS_KEY, "secret_key": TEST_SECRET_KEY, "status": "active" }], "policies": [{ "bucket": "*", "actions": ["*"], "prefix": "*" }] }] }); std::fs::write(iam_path.join("iam.json"), iam_json.to_string()).unwrap(); let config = myfsio_server::config::ServerConfig { bind_addr: "127.0.0.1:0".parse().unwrap(), storage_root: tmp.path().to_path_buf(), region: "us-east-1".to_string(), iam_config_path: iam_path.join("iam.json"), sigv4_timestamp_tolerance_secs: 900, presigned_url_min_expiry: 1, presigned_url_max_expiry: 604800, secret_key: None, encryption_enabled: false, kms_enabled: false, gc_enabled: false, integrity_enabled: false, metrics_enabled: false, lifecycle_enabled: false, }; let state = myfsio_server::state::AppState::new(config); let app = myfsio_server::create_router(state); (app, tmp) } fn signed_request(method: Method, uri: &str, body: Body) -> Request { Request::builder() .method(method) .uri(uri) .header("x-access-key", TEST_ACCESS_KEY) .header("x-secret-key", TEST_SECRET_KEY) .body(body) .unwrap() } #[tokio::test] async fn test_unauthenticated_request_rejected() { let (app, _tmp) = test_app(); let resp = app .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap()) .await .unwrap(); assert_eq!(resp.status(), StatusCode::FORBIDDEN); } #[tokio::test] async fn test_list_buckets_empty() { let (app, _tmp) = test_app(); let resp = app .oneshot(signed_request(Method::GET, "/", Body::empty())) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let body = String::from_utf8( resp.into_body().collect().await.unwrap().to_bytes().to_vec(), ) .unwrap(); assert!(body.contains("ListAllMyBucketsResult")); } #[tokio::test] async fn test_create_and_list_bucket() { let (app, _tmp) = test_app(); let resp = app .clone() .oneshot(signed_request(Method::PUT, "/test-bucket", Body::empty())) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let resp = app .oneshot(signed_request(Method::GET, "/", Body::empty())) .await .unwrap(); let body = String::from_utf8( resp.into_body().collect().await.unwrap().to_bytes().to_vec(), ) .unwrap(); assert!(body.contains("test-bucket")); } #[tokio::test] async fn test_head_bucket() { let (app, _tmp) = test_app(); app.clone() .oneshot(signed_request(Method::PUT, "/my-bucket", Body::empty())) .await .unwrap(); let resp = app .clone() .oneshot(signed_request(Method::HEAD, "/my-bucket", Body::empty())) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); assert_eq!( resp.headers().get("x-amz-bucket-region").unwrap(), "us-east-1" ); let resp = app .oneshot(signed_request(Method::HEAD, "/nonexistent", Body::empty())) .await .unwrap(); assert_eq!(resp.status(), StatusCode::NOT_FOUND); } #[tokio::test] async fn test_delete_bucket() { let (app, _tmp) = test_app(); app.clone() .oneshot(signed_request(Method::PUT, "/del-bucket", Body::empty())) .await .unwrap(); let resp = app .clone() .oneshot(signed_request(Method::DELETE, "/del-bucket", Body::empty())) .await .unwrap(); assert_eq!(resp.status(), StatusCode::NO_CONTENT); let resp = app .oneshot(signed_request(Method::HEAD, "/del-bucket", Body::empty())) .await .unwrap(); assert_eq!(resp.status(), StatusCode::NOT_FOUND); } #[tokio::test] async fn test_put_and_get_object() { let (app, _tmp) = test_app(); app.clone() .oneshot(signed_request(Method::PUT, "/data-bucket", Body::empty())) .await .unwrap(); let resp = app .clone() .oneshot( Request::builder() .method(Method::PUT) .uri("/data-bucket/hello.txt") .header("x-access-key", TEST_ACCESS_KEY) .header("x-secret-key", TEST_SECRET_KEY) .header("content-type", "text/plain") .body(Body::from("Hello, World!")) .unwrap(), ) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); assert!(resp.headers().get("etag").is_some()); let resp = app .oneshot(signed_request( Method::GET, "/data-bucket/hello.txt", Body::empty(), )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); assert_eq!(resp.headers().get("content-type").unwrap(), "text/plain"); assert_eq!(resp.headers().get("content-length").unwrap(), "13"); let body = resp.into_body().collect().await.unwrap().to_bytes(); assert_eq!(&body[..], b"Hello, World!"); } #[tokio::test] async fn test_head_object() { let (app, _tmp) = test_app(); app.clone() .oneshot(signed_request(Method::PUT, "/hd-bucket", Body::empty())) .await .unwrap(); app.clone() .oneshot( Request::builder() .method(Method::PUT) .uri("/hd-bucket/file.bin") .header("x-access-key", TEST_ACCESS_KEY) .header("x-secret-key", TEST_SECRET_KEY) .body(Body::from(vec![0u8; 256])) .unwrap(), ) .await .unwrap(); let resp = app .clone() .oneshot(signed_request(Method::HEAD, "/hd-bucket/file.bin", Body::empty())) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); assert_eq!(resp.headers().get("content-length").unwrap(), "256"); assert!(resp.headers().get("etag").is_some()); assert!(resp.headers().get("last-modified").is_some()); let resp = app .oneshot(signed_request( Method::HEAD, "/hd-bucket/nonexistent.txt", Body::empty(), )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::NOT_FOUND); } #[tokio::test] async fn test_delete_object() { let (app, _tmp) = test_app(); app.clone() .oneshot(signed_request(Method::PUT, "/rm-bucket", Body::empty())) .await .unwrap(); app.clone() .oneshot( Request::builder() .method(Method::PUT) .uri("/rm-bucket/removeme.txt") .header("x-access-key", TEST_ACCESS_KEY) .header("x-secret-key", TEST_SECRET_KEY) .body(Body::from("bye")) .unwrap(), ) .await .unwrap(); let resp = app .clone() .oneshot(signed_request( Method::DELETE, "/rm-bucket/removeme.txt", Body::empty(), )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::NO_CONTENT); let resp = app .oneshot(signed_request( Method::HEAD, "/rm-bucket/removeme.txt", Body::empty(), )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::NOT_FOUND); } #[tokio::test] async fn test_list_objects_v2() { let (app, _tmp) = test_app(); app.clone() .oneshot(signed_request(Method::PUT, "/list-bucket", Body::empty())) .await .unwrap(); for name in ["a.txt", "b.txt", "dir/c.txt"] { app.clone() .oneshot( Request::builder() .method(Method::PUT) .uri(format!("/list-bucket/{}", name)) .header("x-access-key", TEST_ACCESS_KEY) .header("x-secret-key", TEST_SECRET_KEY) .body(Body::from("data")) .unwrap(), ) .await .unwrap(); } let resp = app .clone() .oneshot(signed_request( Method::GET, "/list-bucket?list-type=2", Body::empty(), )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let body = String::from_utf8( resp.into_body().collect().await.unwrap().to_bytes().to_vec(), ) .unwrap(); assert!(body.contains("a.txt")); assert!(body.contains("b.txt")); assert!(body.contains("dir/c.txt")); assert!(body.contains("3")); let resp = app .clone() .oneshot(signed_request( Method::GET, "/list-bucket?list-type=2&delimiter=/", Body::empty(), )) .await .unwrap(); let body = String::from_utf8( resp.into_body().collect().await.unwrap().to_bytes().to_vec(), ) .unwrap(); assert!(body.contains("a.txt")); assert!(body.contains("b.txt")); assert!(body.contains("dir/")); let resp = app .oneshot(signed_request( Method::GET, "/list-bucket?list-type=2&prefix=dir/", Body::empty(), )) .await .unwrap(); let body = String::from_utf8( resp.into_body().collect().await.unwrap().to_bytes().to_vec(), ) .unwrap(); assert!(body.contains("dir/c.txt")); assert!(!body.contains("a.txt")); } #[tokio::test] async fn test_get_nonexistent_object_returns_404() { let (app, _tmp) = test_app(); app.clone() .oneshot(signed_request(Method::PUT, "/err-bucket", Body::empty())) .await .unwrap(); let resp = app .oneshot(signed_request( Method::GET, "/err-bucket/nope.txt", Body::empty(), )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::NOT_FOUND); let body = String::from_utf8( resp.into_body().collect().await.unwrap().to_bytes().to_vec(), ) .unwrap(); assert!(body.contains("NoSuchKey")); } #[tokio::test] async fn test_create_duplicate_bucket_returns_409() { let (app, _tmp) = test_app(); app.clone() .oneshot(signed_request(Method::PUT, "/dup-bucket", Body::empty())) .await .unwrap(); let resp = app .oneshot(signed_request(Method::PUT, "/dup-bucket", Body::empty())) .await .unwrap(); assert_eq!(resp.status(), StatusCode::CONFLICT); } #[tokio::test] async fn test_delete_nonempty_bucket_returns_409() { let (app, _tmp) = test_app(); app.clone() .oneshot(signed_request(Method::PUT, "/full-bucket", Body::empty())) .await .unwrap(); app.clone() .oneshot( Request::builder() .method(Method::PUT) .uri("/full-bucket/obj.txt") .header("x-access-key", TEST_ACCESS_KEY) .header("x-secret-key", TEST_SECRET_KEY) .body(Body::from("data")) .unwrap(), ) .await .unwrap(); let resp = app .oneshot(signed_request( Method::DELETE, "/full-bucket", Body::empty(), )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::CONFLICT); } #[tokio::test] async fn test_object_with_user_metadata() { let (app, _tmp) = test_app(); app.clone() .oneshot(signed_request(Method::PUT, "/meta-bucket", Body::empty())) .await .unwrap(); app.clone() .oneshot( Request::builder() .method(Method::PUT) .uri("/meta-bucket/tagged.txt") .header("x-access-key", TEST_ACCESS_KEY) .header("x-secret-key", TEST_SECRET_KEY) .header("x-amz-meta-author", "test-user") .header("x-amz-meta-version", "42") .body(Body::from("content")) .unwrap(), ) .await .unwrap(); let resp = app .oneshot(signed_request( Method::HEAD, "/meta-bucket/tagged.txt", Body::empty(), )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); assert_eq!( resp.headers().get("x-amz-meta-author").unwrap(), "test-user" ); assert_eq!(resp.headers().get("x-amz-meta-version").unwrap(), "42"); } #[tokio::test] async fn test_wrong_credentials_rejected() { let (app, _tmp) = test_app(); let resp = app .oneshot( Request::builder() .uri("/") .header("x-access-key", "WRONGKEY") .header("x-secret-key", "WRONGSECRET") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(resp.status(), StatusCode::FORBIDDEN); } #[tokio::test] async fn test_server_header_present() { let (app, _tmp) = test_app(); let resp = app .oneshot(signed_request(Method::GET, "/", Body::empty())) .await .unwrap(); let server = resp.headers().get("server").unwrap().to_str().unwrap(); assert!(server.starts_with("MyFSIO-Rust/")); } #[tokio::test] async fn test_range_request() { let (app, _tmp) = test_app(); app.clone() .oneshot(signed_request(Method::PUT, "/range-bucket", Body::empty())) .await .unwrap(); let data = "Hello, World! This is range test data."; app.clone() .oneshot( Request::builder() .method(Method::PUT) .uri("/range-bucket/range.txt") .header("x-access-key", TEST_ACCESS_KEY) .header("x-secret-key", TEST_SECRET_KEY) .body(Body::from(data)) .unwrap(), ) .await .unwrap(); let resp = app .clone() .oneshot( Request::builder() .uri("/range-bucket/range.txt") .header("x-access-key", TEST_ACCESS_KEY) .header("x-secret-key", TEST_SECRET_KEY) .header("range", "bytes=0-4") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(resp.status(), StatusCode::PARTIAL_CONTENT); assert_eq!(resp.headers().get("content-length").unwrap(), "5"); assert!(resp.headers().get("content-range").unwrap().to_str().unwrap().starts_with("bytes 0-4/")); let body = resp.into_body().collect().await.unwrap().to_bytes(); assert_eq!(&body[..], b"Hello"); let resp = app .oneshot( Request::builder() .uri("/range-bucket/range.txt") .header("x-access-key", TEST_ACCESS_KEY) .header("x-secret-key", TEST_SECRET_KEY) .header("range", "bytes=-5") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(resp.status(), StatusCode::PARTIAL_CONTENT); let body = resp.into_body().collect().await.unwrap().to_bytes(); assert_eq!(&body[..], b"data."); } #[tokio::test] async fn test_copy_object() { let (app, _tmp) = test_app(); app.clone() .oneshot(signed_request(Method::PUT, "/src-bucket", Body::empty())) .await .unwrap(); app.clone() .oneshot(signed_request(Method::PUT, "/dst-bucket", Body::empty())) .await .unwrap(); app.clone() .oneshot( Request::builder() .method(Method::PUT) .uri("/src-bucket/original.txt") .header("x-access-key", TEST_ACCESS_KEY) .header("x-secret-key", TEST_SECRET_KEY) .body(Body::from("copy me")) .unwrap(), ) .await .unwrap(); let resp = app .clone() .oneshot( Request::builder() .method(Method::PUT) .uri("/dst-bucket/copied.txt") .header("x-access-key", TEST_ACCESS_KEY) .header("x-secret-key", TEST_SECRET_KEY) .header("x-amz-copy-source", "/src-bucket/original.txt") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let body = String::from_utf8( resp.into_body().collect().await.unwrap().to_bytes().to_vec(), ) .unwrap(); assert!(body.contains("CopyObjectResult")); let resp = app .oneshot(signed_request( Method::GET, "/dst-bucket/copied.txt", Body::empty(), )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let body = resp.into_body().collect().await.unwrap().to_bytes(); assert_eq!(&body[..], b"copy me"); } #[tokio::test] async fn test_multipart_upload_http() { let (app, _tmp) = test_app(); app.clone() .oneshot(signed_request(Method::PUT, "/mp-bucket", Body::empty())) .await .unwrap(); let resp = app .clone() .oneshot(signed_request( Method::POST, "/mp-bucket/big-file.bin?uploads", Body::empty(), )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let body = String::from_utf8( resp.into_body().collect().await.unwrap().to_bytes().to_vec(), ) .unwrap(); assert!(body.contains("InitiateMultipartUploadResult")); assert!(body.contains("big-file.bin")); let upload_id = body .split("") .nth(1) .unwrap() .split("") .next() .unwrap(); let part1_data = vec![b'A'; 1024]; let resp = app .clone() .oneshot( Request::builder() .method(Method::PUT) .uri(format!( "/mp-bucket/big-file.bin?uploadId={}&partNumber=1", upload_id )) .header("x-access-key", TEST_ACCESS_KEY) .header("x-secret-key", TEST_SECRET_KEY) .body(Body::from(part1_data)) .unwrap(), ) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let etag1 = resp.headers().get("etag").unwrap().to_str().unwrap().trim_matches('"').to_string(); let part2_data = vec![b'B'; 512]; let resp = app .clone() .oneshot( Request::builder() .method(Method::PUT) .uri(format!( "/mp-bucket/big-file.bin?uploadId={}&partNumber=2", upload_id )) .header("x-access-key", TEST_ACCESS_KEY) .header("x-secret-key", TEST_SECRET_KEY) .body(Body::from(part2_data)) .unwrap(), ) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let etag2 = resp.headers().get("etag").unwrap().to_str().unwrap().trim_matches('"').to_string(); let complete_xml = format!( "1\"{etag1}\"2\"{etag2}\"" ); let resp = app .clone() .oneshot( Request::builder() .method(Method::POST) .uri(format!( "/mp-bucket/big-file.bin?uploadId={}", upload_id )) .header("x-access-key", TEST_ACCESS_KEY) .header("x-secret-key", TEST_SECRET_KEY) .body(Body::from(complete_xml)) .unwrap(), ) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let body = String::from_utf8( resp.into_body().collect().await.unwrap().to_bytes().to_vec(), ) .unwrap(); assert!(body.contains("CompleteMultipartUploadResult")); let resp = app .oneshot(signed_request( Method::HEAD, "/mp-bucket/big-file.bin", Body::empty(), )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); assert_eq!(resp.headers().get("content-length").unwrap(), "1536"); } #[tokio::test] async fn test_delete_objects_batch() { let (app, _tmp) = test_app(); app.clone() .oneshot(signed_request(Method::PUT, "/batch-bucket", Body::empty())) .await .unwrap(); for name in ["a.txt", "b.txt", "c.txt"] { app.clone() .oneshot( Request::builder() .method(Method::PUT) .uri(format!("/batch-bucket/{}", name)) .header("x-access-key", TEST_ACCESS_KEY) .header("x-secret-key", TEST_SECRET_KEY) .body(Body::from("data")) .unwrap(), ) .await .unwrap(); } let delete_xml = r#"a.txtb.txt"#; let resp = app .clone() .oneshot( Request::builder() .method(Method::POST) .uri("/batch-bucket?delete") .header("x-access-key", TEST_ACCESS_KEY) .header("x-secret-key", TEST_SECRET_KEY) .body(Body::from(delete_xml)) .unwrap(), ) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let body = String::from_utf8( resp.into_body().collect().await.unwrap().to_bytes().to_vec(), ) .unwrap(); assert!(body.contains("DeleteResult")); assert!(body.contains("a.txt")); assert!(body.contains("b.txt")); let resp = app .clone() .oneshot(signed_request( Method::HEAD, "/batch-bucket/a.txt", Body::empty(), )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::NOT_FOUND); let resp = app .oneshot(signed_request( Method::HEAD, "/batch-bucket/c.txt", Body::empty(), )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); } #[tokio::test] async fn test_bucket_versioning() { let (app, _tmp) = test_app(); app.clone() .oneshot(signed_request(Method::PUT, "/ver-bucket", Body::empty())) .await .unwrap(); let resp = app .clone() .oneshot(signed_request( Method::GET, "/ver-bucket?versioning", Body::empty(), )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let body = String::from_utf8( resp.into_body().collect().await.unwrap().to_bytes().to_vec(), ) .unwrap(); assert!(body.contains("VersioningConfiguration")); assert!(body.contains("Suspended")); app.clone() .oneshot( Request::builder() .method(Method::PUT) .uri("/ver-bucket?versioning") .header("x-access-key", TEST_ACCESS_KEY) .header("x-secret-key", TEST_SECRET_KEY) .body(Body::from( "Enabled", )) .unwrap(), ) .await .unwrap(); let resp = app .oneshot(signed_request( Method::GET, "/ver-bucket?versioning", Body::empty(), )) .await .unwrap(); let body = String::from_utf8( resp.into_body().collect().await.unwrap().to_bytes().to_vec(), ) .unwrap(); assert!(body.contains("Enabled")); } #[tokio::test] async fn test_bucket_tagging() { let (app, _tmp) = test_app(); app.clone() .oneshot(signed_request(Method::PUT, "/tag-bucket", Body::empty())) .await .unwrap(); app.clone() .oneshot( Request::builder() .method(Method::PUT) .uri("/tag-bucket?tagging") .header("x-access-key", TEST_ACCESS_KEY) .header("x-secret-key", TEST_SECRET_KEY) .body(Body::from( "envprod", )) .unwrap(), ) .await .unwrap(); let resp = app .clone() .oneshot(signed_request( Method::GET, "/tag-bucket?tagging", Body::empty(), )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let body = String::from_utf8( resp.into_body().collect().await.unwrap().to_bytes().to_vec(), ) .unwrap(); assert!(body.contains("env")); assert!(body.contains("prod")); let resp = app .oneshot(signed_request( Method::DELETE, "/tag-bucket?tagging", Body::empty(), )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::NO_CONTENT); } #[tokio::test] async fn test_bucket_location() { let (app, _tmp) = test_app(); app.clone() .oneshot(signed_request(Method::PUT, "/loc-bucket", Body::empty())) .await .unwrap(); let resp = app .oneshot(signed_request( Method::GET, "/loc-bucket?location", Body::empty(), )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let body = String::from_utf8( resp.into_body().collect().await.unwrap().to_bytes().to_vec(), ) .unwrap(); assert!(body.contains("LocationConstraint")); assert!(body.contains("us-east-1")); } #[tokio::test] async fn test_bucket_cors() { let (app, _tmp) = test_app(); app.clone() .oneshot(signed_request(Method::PUT, "/cors-bucket", Body::empty())) .await .unwrap(); let resp = app .clone() .oneshot(signed_request( Method::GET, "/cors-bucket?cors", Body::empty(), )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::NOT_FOUND); app.clone() .oneshot( Request::builder() .method(Method::PUT) .uri("/cors-bucket?cors") .header("x-access-key", TEST_ACCESS_KEY) .header("x-secret-key", TEST_SECRET_KEY) .body(Body::from("*")) .unwrap(), ) .await .unwrap(); let resp = app .clone() .oneshot(signed_request( Method::GET, "/cors-bucket?cors", Body::empty(), )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let resp = app .oneshot(signed_request( Method::DELETE, "/cors-bucket?cors", Body::empty(), )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::NO_CONTENT); } #[tokio::test] async fn test_bucket_acl() { let (app, _tmp) = test_app(); app.clone() .oneshot(signed_request(Method::PUT, "/acl-bucket", Body::empty())) .await .unwrap(); let resp = app .oneshot(signed_request( Method::GET, "/acl-bucket?acl", Body::empty(), )) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let body = String::from_utf8( resp.into_body().collect().await.unwrap().to_bytes().to_vec(), ) .unwrap(); assert!(body.contains("AccessControlPolicy")); assert!(body.contains("FULL_CONTROL")); } #[tokio::test] async fn test_object_tagging() { let (app, _tmp) = test_app(); let app = app.into_service(); let resp = tower::ServiceExt::oneshot( app.clone(), signed_request(Method::PUT, "/tag-bucket", Body::empty()), ) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let resp = tower::ServiceExt::oneshot( app.clone(), signed_request( Method::PUT, "/tag-bucket/myfile.txt", Body::from("file content"), ), ) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let resp = tower::ServiceExt::oneshot( app.clone(), signed_request(Method::GET, "/tag-bucket/myfile.txt?tagging", Body::empty()), ) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let body = String::from_utf8( resp.into_body().collect().await.unwrap().to_bytes().to_vec(), ) .unwrap(); assert!(body.contains("")); assert!(body.contains("")); let tag_xml = r#"envprod"#; let resp = tower::ServiceExt::oneshot( app.clone(), signed_request( Method::PUT, "/tag-bucket/myfile.txt?tagging", Body::from(tag_xml), ), ) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let resp = tower::ServiceExt::oneshot( app.clone(), signed_request(Method::GET, "/tag-bucket/myfile.txt?tagging", Body::empty()), ) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let body = String::from_utf8( resp.into_body().collect().await.unwrap().to_bytes().to_vec(), ) .unwrap(); assert!(body.contains("env")); assert!(body.contains("prod")); let resp = tower::ServiceExt::oneshot( app.clone(), signed_request(Method::DELETE, "/tag-bucket/myfile.txt?tagging", Body::empty()), ) .await .unwrap(); assert_eq!(resp.status(), StatusCode::NO_CONTENT); let resp = tower::ServiceExt::oneshot( app.clone(), signed_request(Method::GET, "/tag-bucket/myfile.txt?tagging", Body::empty()), ) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let body = String::from_utf8( resp.into_body().collect().await.unwrap().to_bytes().to_vec(), ) .unwrap(); assert!(!body.contains("env")); } #[tokio::test] async fn test_object_acl() { let (app, _tmp) = test_app(); let app = app.into_service(); let resp = tower::ServiceExt::oneshot( app.clone(), signed_request(Method::PUT, "/acl-obj-bucket", Body::empty()), ) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let resp = tower::ServiceExt::oneshot( app.clone(), signed_request( Method::PUT, "/acl-obj-bucket/myfile.txt", Body::from("content"), ), ) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let resp = tower::ServiceExt::oneshot( app.clone(), signed_request(Method::GET, "/acl-obj-bucket/myfile.txt?acl", Body::empty()), ) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let body = String::from_utf8( resp.into_body().collect().await.unwrap().to_bytes().to_vec(), ) .unwrap(); assert!(body.contains("AccessControlPolicy")); assert!(body.contains("FULL_CONTROL")); } #[tokio::test] async fn test_object_legal_hold() { let (app, _tmp) = test_app(); let app = app.into_service(); let resp = tower::ServiceExt::oneshot( app.clone(), signed_request(Method::PUT, "/lh-bucket", Body::empty()), ) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let resp = tower::ServiceExt::oneshot( app.clone(), signed_request( Method::PUT, "/lh-bucket/obj.txt", Body::from("data"), ), ) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let resp = tower::ServiceExt::oneshot( app.clone(), signed_request(Method::GET, "/lh-bucket/obj.txt?legal-hold", Body::empty()), ) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let body = String::from_utf8( resp.into_body().collect().await.unwrap().to_bytes().to_vec(), ) .unwrap(); assert!(body.contains("OFF")); } async fn test_app_encrypted() -> (axum::Router, tempfile::TempDir) { let tmp = tempfile::TempDir::new().unwrap(); let iam_path = tmp.path().join(".myfsio.sys").join("config"); std::fs::create_dir_all(&iam_path).unwrap(); let iam_json = serde_json::json!({ "version": 2, "users": [{ "user_id": "u-test1234", "display_name": "admin", "enabled": true, "access_keys": [{ "access_key": TEST_ACCESS_KEY, "secret_key": TEST_SECRET_KEY, "status": "active" }], "policies": [{ "bucket": "*", "actions": ["*"], "prefix": "*" }] }] }); std::fs::write(iam_path.join("iam.json"), iam_json.to_string()).unwrap(); let config = myfsio_server::config::ServerConfig { bind_addr: "127.0.0.1:0".parse().unwrap(), storage_root: tmp.path().to_path_buf(), region: "us-east-1".to_string(), iam_config_path: iam_path.join("iam.json"), sigv4_timestamp_tolerance_secs: 900, presigned_url_min_expiry: 1, presigned_url_max_expiry: 604800, secret_key: None, encryption_enabled: true, kms_enabled: true, gc_enabled: false, integrity_enabled: false, metrics_enabled: false, lifecycle_enabled: false, }; let state = myfsio_server::state::AppState::new_with_encryption(config).await; let app = myfsio_server::create_router(state); (app, tmp) } #[tokio::test] async fn test_sse_s3_encrypt_decrypt_roundtrip() { let (app, _tmp) = test_app_encrypted().await; let app = app.into_service(); let resp = tower::ServiceExt::oneshot( app.clone(), signed_request(Method::PUT, "/enc-bucket", Body::empty()), ) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let plaintext = "This is secret data that should be encrypted at rest!"; let req = Request::builder() .method(Method::PUT) .uri("/enc-bucket/secret.txt") .header("x-access-key", TEST_ACCESS_KEY) .header("x-secret-key", TEST_SECRET_KEY) .header("x-amz-server-side-encryption", "AES256") .header("content-type", "text/plain") .body(Body::from(plaintext)) .unwrap(); let resp = tower::ServiceExt::oneshot(app.clone(), req).await.unwrap(); assert_eq!(resp.status(), StatusCode::OK); assert_eq!( resp.headers().get("x-amz-server-side-encryption").unwrap(), "AES256" ); let resp = tower::ServiceExt::oneshot( app.clone(), signed_request(Method::GET, "/enc-bucket/secret.txt", Body::empty()), ) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); assert_eq!( resp.headers().get("x-amz-server-side-encryption").unwrap(), "AES256" ); let body = resp .into_body() .collect() .await .unwrap() .to_bytes() .to_vec(); assert_eq!(String::from_utf8(body).unwrap(), plaintext); } #[tokio::test] async fn test_kms_key_crud() { let (app, _tmp) = test_app_encrypted().await; let app = app.into_service(); let req = Request::builder() .method(Method::POST) .uri("/kms/keys") .header("x-access-key", TEST_ACCESS_KEY) .header("x-secret-key", TEST_SECRET_KEY) .header("content-type", "application/json") .body(Body::from(r#"{"Description": "test key"}"#)) .unwrap(); let resp = tower::ServiceExt::oneshot(app.clone(), req).await.unwrap(); assert_eq!(resp.status(), StatusCode::OK); let body: serde_json::Value = serde_json::from_slice( &resp.into_body().collect().await.unwrap().to_bytes(), ) .unwrap(); let key_id = body["KeyId"].as_str().unwrap().to_string(); assert!(!key_id.is_empty()); let resp = tower::ServiceExt::oneshot( app.clone(), signed_request(Method::GET, "/kms/keys", Body::empty()), ) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let body: serde_json::Value = serde_json::from_slice( &resp.into_body().collect().await.unwrap().to_bytes(), ) .unwrap(); assert_eq!(body["keys"].as_array().unwrap().len(), 1); let resp = tower::ServiceExt::oneshot( app.clone(), signed_request( Method::GET, &format!("/kms/keys/{}", key_id), Body::empty(), ), ) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); let resp = tower::ServiceExt::oneshot( app.clone(), signed_request( Method::DELETE, &format!("/kms/keys/{}", key_id), Body::empty(), ), ) .await .unwrap(); assert_eq!(resp.status(), StatusCode::NO_CONTENT); } #[tokio::test] async fn test_kms_encrypt_decrypt() { let (app, _tmp) = test_app_encrypted().await; let app = app.into_service(); let req = Request::builder() .method(Method::POST) .uri("/kms/keys") .header("x-access-key", TEST_ACCESS_KEY) .header("x-secret-key", TEST_SECRET_KEY) .body(Body::from(r#"{"Description": "enc key"}"#)) .unwrap(); let resp = tower::ServiceExt::oneshot(app.clone(), req).await.unwrap(); let body: serde_json::Value = serde_json::from_slice( &resp.into_body().collect().await.unwrap().to_bytes(), ) .unwrap(); let key_id = body["KeyId"].as_str().unwrap().to_string(); use base64::engine::general_purpose::STANDARD as B64; use base64::Engine; let plaintext = b"Hello KMS!"; let enc_req = serde_json::json!({ "KeyId": key_id, "Plaintext": B64.encode(plaintext), }); let req = Request::builder() .method(Method::POST) .uri("/kms/encrypt") .header("x-access-key", TEST_ACCESS_KEY) .header("x-secret-key", TEST_SECRET_KEY) .body(Body::from(enc_req.to_string())) .unwrap(); let resp = tower::ServiceExt::oneshot(app.clone(), req).await.unwrap(); assert_eq!(resp.status(), StatusCode::OK); let body: serde_json::Value = serde_json::from_slice( &resp.into_body().collect().await.unwrap().to_bytes(), ) .unwrap(); let ct_b64 = body["CiphertextBlob"].as_str().unwrap().to_string(); let dec_req = serde_json::json!({ "KeyId": key_id, "CiphertextBlob": ct_b64, }); let req = Request::builder() .method(Method::POST) .uri("/kms/decrypt") .header("x-access-key", TEST_ACCESS_KEY) .header("x-secret-key", TEST_SECRET_KEY) .body(Body::from(dec_req.to_string())) .unwrap(); let resp = tower::ServiceExt::oneshot(app.clone(), req).await.unwrap(); assert_eq!(resp.status(), StatusCode::OK); let body: serde_json::Value = serde_json::from_slice( &resp.into_body().collect().await.unwrap().to_bytes(), ) .unwrap(); let pt_b64 = body["Plaintext"].as_str().unwrap(); let result = B64.decode(pt_b64).unwrap(); assert_eq!(result, plaintext); }