diff options
| author | Chris Lu <chrislusf@users.noreply.github.com> | 2025-07-15 00:23:54 -0700 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2025-07-15 00:23:54 -0700 |
| commit | 4b040e8a8701199d4c680bb6f241c4751c8210a2 (patch) | |
| tree | 45d76546220c8d6f3287e3f5498ddf598079cc8e /test | |
| parent | 548fa0b50a2a57de538d6f6961bfe819128d0ee5 (diff) | |
| download | seaweedfs-4b040e8a8701199d4c680bb6f241c4751c8210a2.tar.xz seaweedfs-4b040e8a8701199d4c680bb6f241c4751c8210a2.zip | |
adding cors support (#6987)
* adding cors support
* address some comments
* optimize matchesWildcard
* address comments
* fix for tests
* address comments
* address comments
* address comments
* path building
* refactor
* Update weed/s3api/s3api_bucket_config.go
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
* address comment
Service-level responses need both Access-Control-Allow-Methods and Access-Control-Allow-Headers. After setting Access-Control-Allow-Origin and Access-Control-Expose-Headers, also set Access-Control-Allow-Methods: * and Access-Control-Allow-Headers: * so service endpoints satisfy CORS preflight requirements.
* Update weed/s3api/s3api_bucket_config.go
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
* Update weed/s3api/s3api_object_handlers.go
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
* Update weed/s3api/s3api_object_handlers.go
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
* fix
* refactor
* Update weed/s3api/s3api_bucket_config.go
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
* Update weed/s3api/s3api_object_handlers.go
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
* Update weed/s3api/s3api_server.go
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
* simplify
* add cors tests
* fix tests
* fix tests
---------
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Diffstat (limited to 'test')
| -rw-r--r-- | test/s3/cors/Makefile | 337 | ||||
| -rw-r--r-- | test/s3/cors/README.md | 362 | ||||
| -rw-r--r-- | test/s3/cors/go.mod | 36 | ||||
| -rw-r--r-- | test/s3/cors/go.sum | 63 | ||||
| -rw-r--r-- | test/s3/cors/s3_cors_http_test.go | 536 | ||||
| -rw-r--r-- | test/s3/cors/s3_cors_test.go | 600 |
6 files changed, 1934 insertions, 0 deletions
diff --git a/test/s3/cors/Makefile b/test/s3/cors/Makefile new file mode 100644 index 000000000..e59124a6a --- /dev/null +++ b/test/s3/cors/Makefile @@ -0,0 +1,337 @@ +# CORS Integration Tests Makefile +# This Makefile provides comprehensive targets for running CORS integration tests + +.PHONY: help build-weed setup-server start-server stop-server test-cors test-cors-quick test-cors-comprehensive test-all clean logs check-deps + +# Configuration +WEED_BINARY := ../../../weed/weed_binary +S3_PORT := 8333 +MASTER_PORT := 9333 +VOLUME_PORT := 8080 +FILER_PORT := 8888 +TEST_TIMEOUT := 10m +TEST_PATTERN := TestCORS + +# Default target +help: + @echo "CORS Integration Tests Makefile" + @echo "" + @echo "Available targets:" + @echo " help - Show this help message" + @echo " build-weed - Build the SeaweedFS binary" + @echo " check-deps - Check dependencies and build binary if needed" + @echo " start-server - Start SeaweedFS server for testing" + @echo " start-server-simple - Start server without process cleanup (for CI)" + @echo " stop-server - Stop SeaweedFS server" + @echo " test-cors - Run all CORS tests" + @echo " test-cors-quick - Run core CORS tests only" + @echo " test-cors-simple - Run tests without server management" + @echo " test-cors-comprehensive - Run comprehensive CORS tests" + @echo " test-with-server - Start server, run tests, stop server" + @echo " logs - Show server logs" + @echo " clean - Clean up test artifacts and stop server" + @echo " health-check - Check if server is accessible" + @echo "" + @echo "Configuration:" + @echo " S3_PORT=${S3_PORT}" + @echo " TEST_TIMEOUT=${TEST_TIMEOUT}" + +# Build the SeaweedFS binary +build-weed: + @echo "Building SeaweedFS binary..." + @cd ../../../weed && go build -o weed_binary . + @chmod +x $(WEED_BINARY) + @echo "✅ SeaweedFS binary built at $(WEED_BINARY)" + +check-deps: build-weed + @echo "Checking dependencies..." + @echo "🔍 DEBUG: Checking Go installation..." + @command -v go >/dev/null 2>&1 || (echo "Go is required but not installed" && exit 1) + @echo "🔍 DEBUG: Go version: $$(go version)" + @echo "🔍 DEBUG: Checking binary at $(WEED_BINARY)..." + @test -f $(WEED_BINARY) || (echo "SeaweedFS binary not found at $(WEED_BINARY)" && exit 1) + @echo "🔍 DEBUG: Binary size: $$(ls -lh $(WEED_BINARY) | awk '{print $$5}')" + @echo "🔍 DEBUG: Binary permissions: $$(ls -la $(WEED_BINARY) | awk '{print $$1}')" + @echo "🔍 DEBUG: Checking Go module dependencies..." + @go list -m github.com/aws/aws-sdk-go-v2 >/dev/null 2>&1 || (echo "AWS SDK Go v2 not found. Run 'go mod tidy'." && exit 1) + @go list -m github.com/stretchr/testify >/dev/null 2>&1 || (echo "Testify not found. Run 'go mod tidy'." && exit 1) + @echo "✅ All dependencies are available" + +# Start SeaweedFS server for testing +start-server: check-deps + @echo "Starting SeaweedFS server..." + @echo "🔍 DEBUG: Current working directory: $$(pwd)" + @echo "🔍 DEBUG: Checking for existing weed processes..." + @ps aux | grep weed | grep -v grep || echo "No existing weed processes found" + @echo "🔍 DEBUG: Cleaning up any existing PID file..." + @rm -f weed-server.pid + @echo "🔍 DEBUG: Checking for port conflicts..." + @if netstat -tlnp 2>/dev/null | grep $(S3_PORT) >/dev/null; then \ + echo "⚠️ Port $(S3_PORT) is already in use, trying to find the process..."; \ + netstat -tlnp 2>/dev/null | grep $(S3_PORT) || true; \ + else \ + echo "✅ Port $(S3_PORT) is available"; \ + fi + @echo "🔍 DEBUG: Checking binary at $(WEED_BINARY)" + @ls -la $(WEED_BINARY) || (echo "❌ Binary not found!" && exit 1) + @echo "🔍 DEBUG: Checking config file at ../../../docker/compose/s3.json" + @ls -la ../../../docker/compose/s3.json || echo "⚠️ Config file not found, continuing without it" + @echo "🔍 DEBUG: Creating volume directory..." + @mkdir -p ./test-volume-data + @echo "🔍 DEBUG: Launching SeaweedFS server in background..." + @echo "🔍 DEBUG: Command: $(WEED_BINARY) server -debug -s3 -s3.port=$(S3_PORT) -s3.allowEmptyFolder=false -s3.allowDeleteBucketNotEmpty=true -s3.config=../../../docker/compose/s3.json -filer -filer.maxMB=64 -master.volumeSizeLimitMB=50 -volume.max=100 -dir=./test-volume-data -volume.preStopSeconds=1 -metricsPort=9324" + @$(WEED_BINARY) server \ + -debug \ + -s3 \ + -s3.port=$(S3_PORT) \ + -s3.allowEmptyFolder=false \ + -s3.allowDeleteBucketNotEmpty=true \ + -s3.config=../../../docker/compose/s3.json \ + -filer \ + -filer.maxMB=64 \ + -master.volumeSizeLimitMB=50 \ + -volume.max=100 \ + -dir=./test-volume-data \ + -volume.preStopSeconds=1 \ + -metricsPort=9324 \ + > weed-test.log 2>&1 & echo $$! > weed-server.pid + @echo "🔍 DEBUG: Server PID: $$(cat weed-server.pid 2>/dev/null || echo 'PID file not found')" + @echo "🔍 DEBUG: Checking if PID is still running..." + @sleep 2 + @if [ -f weed-server.pid ]; then \ + SERVER_PID=$$(cat weed-server.pid); \ + ps -p $$SERVER_PID || echo "⚠️ Server PID $$SERVER_PID not found after 2 seconds"; \ + else \ + echo "⚠️ PID file not found"; \ + fi + @echo "🔍 DEBUG: Waiting for server to start (up to 90 seconds)..." + @for i in $$(seq 1 90); do \ + echo "🔍 DEBUG: Attempt $$i/90 - checking port $(S3_PORT)"; \ + if curl -s http://localhost:$(S3_PORT) >/dev/null 2>&1; then \ + echo "✅ SeaweedFS server started successfully on port $(S3_PORT) after $$i seconds"; \ + exit 0; \ + fi; \ + if [ $$i -eq 5 ]; then \ + echo "🔍 DEBUG: After 5 seconds, checking process and logs..."; \ + ps aux | grep weed | grep -v grep || echo "No weed processes found"; \ + if [ -f weed-test.log ]; then \ + echo "=== First server logs ==="; \ + head -20 weed-test.log; \ + fi; \ + fi; \ + if [ $$i -eq 15 ]; then \ + echo "🔍 DEBUG: After 15 seconds, checking port bindings..."; \ + netstat -tlnp 2>/dev/null | grep $(S3_PORT) || echo "Port $(S3_PORT) not bound"; \ + netstat -tlnp 2>/dev/null | grep 9333 || echo "Port 9333 not bound"; \ + netstat -tlnp 2>/dev/null | grep 8080 || echo "Port 8080 not bound"; \ + fi; \ + if [ $$i -eq 30 ]; then \ + echo "⚠️ Server taking longer than expected (30s), checking logs..."; \ + if [ -f weed-test.log ]; then \ + echo "=== Recent server logs ==="; \ + tail -20 weed-test.log; \ + fi; \ + fi; \ + sleep 1; \ + done; \ + echo "❌ Server failed to start within 90 seconds"; \ + echo "🔍 DEBUG: Final process check:"; \ + ps aux | grep weed | grep -v grep || echo "No weed processes found"; \ + echo "🔍 DEBUG: Final port check:"; \ + netstat -tlnp 2>/dev/null | grep -E "(8333|9333|8080)" || echo "No ports bound"; \ + echo "=== Full server logs ==="; \ + if [ -f weed-test.log ]; then \ + cat weed-test.log; \ + else \ + echo "No log file found"; \ + fi; \ + exit 1 + +# Stop SeaweedFS server +stop-server: + @echo "Stopping SeaweedFS server..." + @if [ -f weed-server.pid ]; then \ + SERVER_PID=$$(cat weed-server.pid); \ + echo "Killing server PID $$SERVER_PID"; \ + if ps -p $$SERVER_PID >/dev/null 2>&1; then \ + kill -TERM $$SERVER_PID 2>/dev/null || true; \ + sleep 2; \ + if ps -p $$SERVER_PID >/dev/null 2>&1; then \ + echo "Process still running, sending KILL signal..."; \ + kill -KILL $$SERVER_PID 2>/dev/null || true; \ + sleep 1; \ + fi; \ + else \ + echo "Process $$SERVER_PID not found (already stopped)"; \ + fi; \ + rm -f weed-server.pid; \ + else \ + echo "No PID file found, checking for running processes..."; \ + echo "⚠️ Skipping automatic process cleanup to avoid CI issues"; \ + echo "Note: Any remaining weed processes should be cleaned up by the CI environment"; \ + fi + @echo "✅ SeaweedFS server stopped" + +# Show server logs +logs: + @if test -f weed-test.log; then \ + echo "=== SeaweedFS Server Logs ==="; \ + tail -f weed-test.log; \ + else \ + echo "No log file found. Server may not be running."; \ + fi + +# Core CORS tests (basic functionality) +test-cors-quick: check-deps + @echo "Running core CORS tests..." + @go test -v -timeout=$(TEST_TIMEOUT) -run "TestCORSConfigurationManagement|TestCORSPreflightRequest|TestCORSActualRequest" . + @echo "✅ Core CORS tests completed" + +# All CORS tests (comprehensive) +test-cors: check-deps + @echo "Running all CORS tests..." + @go test -v -timeout=$(TEST_TIMEOUT) -run "$(TEST_PATTERN)" . + @echo "✅ All CORS tests completed" + +# Comprehensive CORS tests (all features) +test-cors-comprehensive: check-deps + @echo "Running comprehensive CORS tests..." + @go test -v -timeout=$(TEST_TIMEOUT) -run "TestCORS" . + @echo "✅ Comprehensive CORS tests completed" + +# All tests without server management +test-cors-simple: check-deps + @echo "Running CORS tests (assuming server is already running)..." + @go test -v -timeout=$(TEST_TIMEOUT) . + @echo "✅ All CORS tests completed" + +# Start server, run tests, stop server +test-with-server: start-server + @echo "Running CORS tests with managed server..." + @sleep 5 # Give server time to fully start + @make test-cors-comprehensive || (echo "Tests failed, stopping server..." && make stop-server && exit 1) + @make stop-server + @echo "✅ All tests completed with managed server" + +# Health check +health-check: + @echo "Checking server health..." + @if curl -s http://localhost:$(S3_PORT) >/dev/null 2>&1; then \ + echo "✅ Server is accessible on port $(S3_PORT)"; \ + else \ + echo "❌ Server is not accessible on port $(S3_PORT)"; \ + exit 1; \ + fi + +# Clean up +clean: + @echo "Cleaning up test artifacts..." + @make stop-server + @rm -f weed-test.log + @rm -f weed-server.pid + @rm -rf ./test-volume-data + @rm -f cors.test + @go clean -testcache + @echo "✅ Cleanup completed" + +# Individual test targets for specific functionality +test-basic-cors: + @echo "Running basic CORS tests..." + @go test -v -timeout=$(TEST_TIMEOUT) -run "TestCORSConfigurationManagement" . + +test-preflight-cors: + @echo "Running preflight CORS tests..." + @go test -v -timeout=$(TEST_TIMEOUT) -run "TestCORSPreflightRequest" . + +test-actual-cors: + @echo "Running actual CORS request tests..." + @go test -v -timeout=$(TEST_TIMEOUT) -run "TestCORSActualRequest" . + +test-origin-matching: + @echo "Running origin matching tests..." + @go test -v -timeout=$(TEST_TIMEOUT) -run "TestCORSOriginMatching" . + +test-header-matching: + @echo "Running header matching tests..." + @go test -v -timeout=$(TEST_TIMEOUT) -run "TestCORSHeaderMatching" . + +test-method-matching: + @echo "Running method matching tests..." + @go test -v -timeout=$(TEST_TIMEOUT) -run "TestCORSMethodMatching" . + +test-multiple-rules: + @echo "Running multiple rules tests..." + @go test -v -timeout=$(TEST_TIMEOUT) -run "TestCORSMultipleRulesMatching" . + +test-validation: + @echo "Running validation tests..." + @go test -v -timeout=$(TEST_TIMEOUT) -run "TestCORSValidation" . + +test-caching: + @echo "Running caching tests..." + @go test -v -timeout=$(TEST_TIMEOUT) -run "TestCORSCaching" . + +test-error-handling: + @echo "Running error handling tests..." + @go test -v -timeout=$(TEST_TIMEOUT) -run "TestCORSErrorHandling" . + +# Development targets +dev-start: start-server + @echo "Development server started. Access S3 API at http://localhost:$(S3_PORT)" + @echo "To stop: make stop-server" + +dev-test: check-deps + @echo "Running tests in development mode..." + @go test -v -timeout=$(TEST_TIMEOUT) -run "TestCORSConfigurationManagement" . + +# CI targets +ci-test: check-deps + @echo "Running tests in CI mode..." + @go test -v -timeout=$(TEST_TIMEOUT) -race . + +# All targets +test-all: test-cors test-cors-comprehensive + @echo "✅ All CORS tests completed" + +# Benchmark targets +benchmark-cors: + @echo "Running CORS performance benchmarks..." + @go test -v -timeout=$(TEST_TIMEOUT) -bench=. -benchmem . + +# Coverage targets +coverage: + @echo "Running tests with coverage..." + @go test -v -timeout=$(TEST_TIMEOUT) -coverprofile=coverage.out . + @go tool cover -html=coverage.out -o coverage.html + @echo "Coverage report generated: coverage.html" + +# Format and lint +fmt: + @echo "Formatting Go code..." + @go fmt . + +lint: + @echo "Running linter..." + @golint . || echo "golint not available, skipping..." + +# Install dependencies for development +install-deps: + @echo "Installing Go dependencies..." + @go mod tidy + @go mod download + +# Show current configuration +show-config: + @echo "Current configuration:" + @echo " WEED_BINARY: $(WEED_BINARY)" + @echo " S3_PORT: $(S3_PORT)" + @echo " TEST_TIMEOUT: $(TEST_TIMEOUT)" + @echo " TEST_PATTERN: $(TEST_PATTERN)" + +# Legacy targets for backward compatibility +test: test-with-server +test-verbose: test-cors-comprehensive +test-single: test-basic-cors +test-clean: clean +build: check-deps +setup: check-deps
\ No newline at end of file diff --git a/test/s3/cors/README.md b/test/s3/cors/README.md new file mode 100644 index 000000000..1b93d9ccc --- /dev/null +++ b/test/s3/cors/README.md @@ -0,0 +1,362 @@ +# CORS Integration Tests for SeaweedFS S3 API + +This directory contains comprehensive integration tests for the CORS (Cross-Origin Resource Sharing) functionality in SeaweedFS S3 API. + +## Overview + +The CORS integration tests validate the complete CORS implementation including: +- CORS configuration management (PUT/GET/DELETE) +- CORS rule validation +- CORS middleware behavior +- Caching functionality +- Error handling +- Real-world CORS scenarios + +## Prerequisites + +1. **Go 1.19+**: For building SeaweedFS and running tests +2. **Network Access**: Tests use `localhost:8333` by default +3. **System Dependencies**: `curl` and `netstat` for health checks + +## Quick Start + +The tests now automatically start their own SeaweedFS server, so you don't need to manually start one. + +### 1. Run All Tests with Managed Server + +```bash +# Run all tests with automatic server management +make test-with-server + +# Run core CORS tests only +make test-cors-quick + +# Run comprehensive CORS tests +make test-cors-comprehensive +``` + +### 2. Manual Server Management + +If you prefer to manage the server manually: + +```bash +# Start server +make start-server + +# Run tests (assuming server is running) +make test-cors-simple + +# Stop server +make stop-server +``` + +### 3. Individual Test Categories + +```bash +# Run specific test types +make test-basic-cors # Basic CORS configuration +make test-preflight-cors # Preflight OPTIONS requests +make test-actual-cors # Actual CORS request handling +make test-origin-matching # Origin matching logic +make test-header-matching # Header matching logic +make test-method-matching # Method matching logic +make test-multiple-rules # Multiple CORS rules +make test-validation # CORS validation +make test-caching # CORS caching behavior +make test-error-handling # Error handling +``` + +## Test Server Management + +The tests use a comprehensive server management system similar to other SeaweedFS integration tests: + +### Server Configuration + +- **S3 Port**: 8333 (configurable via `S3_PORT`) +- **Master Port**: 9333 +- **Volume Port**: 8080 +- **Filer Port**: 8888 +- **Metrics Port**: 9324 +- **Data Directory**: `./test-volume-data` (auto-created) +- **Log File**: `weed-test.log` + +### Server Lifecycle + +1. **Build**: Automatically builds `../../../weed/weed_binary` +2. **Start**: Launches SeaweedFS with S3 API enabled +3. **Health Check**: Waits up to 90 seconds for server to be ready +4. **Test**: Runs the requested tests +5. **Stop**: Gracefully shuts down the server +6. **Cleanup**: Removes temporary files and data + +### Available Commands + +```bash +# Server management +make start-server # Start SeaweedFS server +make stop-server # Stop SeaweedFS server +make health-check # Check server health +make logs # View server logs + +# Test execution +make test-with-server # Full test cycle with server management +make test-cors-simple # Run tests without server management +make test-cors-quick # Run core tests only +make test-cors-comprehensive # Run all tests + +# Development +make dev-start # Start server for development +make dev-test # Run development tests +make build-weed # Build SeaweedFS binary +make check-deps # Check dependencies + +# Maintenance +make clean # Clean up all artifacts +make coverage # Generate coverage report +make fmt # Format code +make lint # Run linter +``` + +## Test Configuration + +### Default Configuration + +The tests use these default settings (configurable via environment variables): + +```bash +WEED_BINARY=../../../weed/weed_binary +S3_PORT=8333 +TEST_TIMEOUT=10m +TEST_PATTERN=TestCORS +``` + +### Configuration File + +The `test_config.json` file contains S3 client configuration: + +```json +{ + "endpoint": "http://localhost:8333", + "access_key": "some_access_key1", + "secret_key": "some_secret_key1", + "region": "us-east-1", + "bucket_prefix": "test-cors-", + "use_ssl": false, + "skip_verify_ssl": true +} +``` + +## Troubleshooting + +### Compilation Issues + +If you encounter compilation errors, the most common issues are: + +1. **AWS SDK v2 Type Mismatches**: The `MaxAgeSeconds` field in `types.CORSRule` expects `int32`, not `*int32`. Use direct values like `3600` instead of `aws.Int32(3600)`. + +2. **Field Name Issues**: The `GetBucketCorsOutput` type has a `CORSRules` field directly, not a `CORSConfiguration` field. + +Example fix: +```go +// ❌ Incorrect +MaxAgeSeconds: aws.Int32(3600), +assert.Len(t, getResp.CORSConfiguration.CORSRules, 1) + +// ✅ Correct +MaxAgeSeconds: 3600, +assert.Len(t, getResp.CORSRules, 1) +``` + +### Server Issues + +1. **Server Won't Start** + ```bash + # Check for port conflicts + netstat -tlnp | grep 8333 + + # View server logs + make logs + + # Force cleanup + make clean + ``` + +2. **Test Failures** + ```bash + # Run with server management + make test-with-server + + # Run specific test + make test-basic-cors + + # Check server health + make health-check + ``` + +3. **Connection Issues** + ```bash + # Verify server is running + curl -s http://localhost:8333 + + # Check server logs + tail -f weed-test.log + ``` + +### Performance Issues + +If tests are slow or timing out: + +```bash +# Increase timeout +export TEST_TIMEOUT=30m +make test-with-server + +# Run quick tests only +make test-cors-quick + +# Check server resources +make debug-status +``` + +## Test Coverage + +### Core Functionality Tests + +#### 1. CORS Configuration Management (`TestCORSConfigurationManagement`) +- PUT CORS configuration +- GET CORS configuration +- DELETE CORS configuration +- Configuration updates +- Error handling for non-existent configurations + +#### 2. Multiple CORS Rules (`TestCORSMultipleRules`) +- Multiple rules in single configuration +- Rule precedence and ordering +- Complex rule combinations + +#### 3. CORS Validation (`TestCORSValidation`) +- Invalid HTTP methods +- Empty origins validation +- Negative MaxAge validation +- Rule limit validation + +#### 4. Wildcard Support (`TestCORSWithWildcards`) +- Wildcard origins (`*`, `https://*.example.com`) +- Wildcard headers (`*`) +- Wildcard expose headers + +#### 5. Rule Limits (`TestCORSRuleLimit`) +- Maximum 100 rules per configuration +- Rule limit enforcement +- Large configuration handling + +#### 6. Error Handling (`TestCORSErrorHandling`) +- Non-existent bucket operations +- Invalid configurations +- Malformed requests + +### HTTP-Level Tests + +#### 1. Preflight Requests (`TestCORSPreflightRequest`) +- OPTIONS request handling +- CORS headers in preflight responses +- Access-Control-Request-Method validation +- Access-Control-Request-Headers validation + +#### 2. Actual Requests (`TestCORSActualRequest`) +- CORS headers in actual responses +- Origin validation for real requests +- Proper expose headers handling + +#### 3. Origin Matching (`TestCORSOriginMatching`) +- Exact origin matching +- Wildcard origin matching (`*`) +- Subdomain wildcard matching (`https://*.example.com`) +- Non-matching origins (should be rejected) + +#### 4. Header Matching (`TestCORSHeaderMatching`) +- Wildcard header matching (`*`) +- Specific header matching +- Case-insensitive matching +- Disallowed headers + +#### 5. Method Matching (`TestCORSMethodMatching`) +- Allowed methods verification +- Disallowed methods rejection +- Method-specific CORS behavior + +#### 6. Multiple Rules (`TestCORSMultipleRulesMatching`) +- Rule precedence and selection +- Multiple rules with different configurations +- Complex rule interactions + +### Integration Tests + +#### 1. Caching (`TestCORSCaching`) +- CORS configuration caching +- Cache invalidation +- Cache performance + +#### 2. Object Operations (`TestCORSObjectOperations`) +- CORS with actual S3 operations +- PUT/GET/DELETE objects with CORS +- CORS headers in object responses + +#### 3. Without Configuration (`TestCORSWithoutConfiguration`) +- Behavior when no CORS configuration exists +- Default CORS behavior +- Graceful degradation + +## Development + +### Running Tests During Development + +```bash +# Start server for development +make dev-start + +# Run quick test +make dev-test + +# View logs in real-time +make logs +``` + +### Adding New Tests + +1. Follow the existing naming convention (`TestCORSXxxYyy`) +2. Use the helper functions (`getS3Client`, `createTestBucket`, etc.) +3. Add cleanup with `defer cleanupTestBucket(t, client, bucketName)` +4. Include proper error checking with `require.NoError(t, err)` +5. Use assertions with `assert.Equal(t, expected, actual)` +6. Add the test to the appropriate Makefile target + +### Code Quality + +```bash +# Format code +make fmt + +# Run linter +make lint + +# Generate coverage report +make coverage +``` + +## Performance Notes + +- Tests create and destroy buckets for each test case +- Large configuration tests may take several minutes +- Server startup typically takes 15-30 seconds +- Tests run in parallel where possible for efficiency + +## Integration with SeaweedFS + +These tests validate the CORS implementation in: +- `weed/s3api/cors/` - Core CORS package +- `weed/s3api/s3api_bucket_cors_handlers.go` - HTTP handlers +- `weed/s3api/s3api_server.go` - Router integration +- `weed/s3api/s3api_bucket_config.go` - Configuration management + +The tests ensure AWS S3 API compatibility and proper CORS behavior across all supported scenarios.
\ No newline at end of file diff --git a/test/s3/cors/go.mod b/test/s3/cors/go.mod new file mode 100644 index 000000000..a5c91a9c6 --- /dev/null +++ b/test/s3/cors/go.mod @@ -0,0 +1,36 @@ +module github.com/seaweedfs/seaweedfs/test/s3/cors + +go 1.19 + +require ( + github.com/aws/aws-sdk-go-v2 v1.21.0 + github.com/aws/aws-sdk-go-v2/config v1.18.42 + github.com/aws/aws-sdk-go-v2/credentials v1.13.40 + github.com/aws/aws-sdk-go-v2/service/s3 v1.40.0 + github.com/k0kubun/pp v3.0.1+incompatible + github.com/stretchr/testify v1.8.4 +) + +require ( + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.13 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.13.11 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.41 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.35 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.3.43 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.1.4 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.14 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.36 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.35 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.15.4 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.14.1 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.17.1 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.22.0 // indirect + github.com/aws/smithy-go v1.14.2 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/k0kubun/colorstring v0.0.0-20150214042306-9440f1994b88 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.16 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/test/s3/cors/go.sum b/test/s3/cors/go.sum new file mode 100644 index 000000000..1c9f2a9c8 --- /dev/null +++ b/test/s3/cors/go.sum @@ -0,0 +1,63 @@ +github.com/aws/aws-sdk-go-v2 v1.21.0 h1:gMT0IW+03wtYJhRqTVYn0wLzwdnK9sRMcxmtfGzRdJc= +github.com/aws/aws-sdk-go-v2 v1.21.0/go.mod h1:/RfNgGmRxI+iFOB1OeJUyxiU+9s88k3pfHvDagGEp0M= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.13 h1:OPLEkmhXf6xFPiz0bLeDArZIDx1NNS4oJyG4nv3Gct0= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.13/go.mod h1:gpAbvyDGQFozTEmlTFO8XcQKHzubdq0LzRyJpG6MiXM= +github.com/aws/aws-sdk-go-v2/config v1.18.42 h1:28jHROB27xZwU0CB88giDSjz7M1Sba3olb5JBGwina8= +github.com/aws/aws-sdk-go-v2/config v1.18.42/go.mod h1:4AZM3nMMxwlG+eZlxvBKqwVbkDLlnN2a4UGTL6HjaZI= +github.com/aws/aws-sdk-go-v2/credentials v1.13.40 h1:s8yOkDh+5b1jUDhMBtngF6zKWLDs84chUk2Vk0c38Og= +github.com/aws/aws-sdk-go-v2/credentials v1.13.40/go.mod h1:VtEHVAAqDWASwdOqj/1huyT6uHbs5s8FUHfDQdky/Rs= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.13.11 h1:uDZJF1hu0EVT/4bogChk8DyjSF6fof6uL/0Y26Ma7Fg= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.13.11/go.mod h1:TEPP4tENqBGO99KwVpV9MlOX4NSrSLP8u3KRy2CDwA8= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.41 h1:22dGT7PneFMx4+b3pz7lMTRyN8ZKH7M2cW4GP9yUS2g= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.41/go.mod h1:CrObHAuPneJBlfEJ5T3szXOUkLEThaGfvnhTf33buas= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.35 h1:SijA0mgjV8E+8G45ltVHs0fvKpTj8xmZJ3VwhGKtUSI= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.35/go.mod h1:SJC1nEVVva1g3pHAIdCp7QsRIkMmLAgoDquQ9Rr8kYw= +github.com/aws/aws-sdk-go-v2/internal/ini v1.3.43 h1:g+qlObJH4Kn4n21g69DjspU0hKTjWtq7naZ9OLCv0ew= +github.com/aws/aws-sdk-go-v2/internal/ini v1.3.43/go.mod h1:rzfdUlfA+jdgLDmPKjd3Chq9V7LVLYo1Nz++Wb91aRo= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.1.4 h1:6lJvvkQ9HmbHZ4h/IEwclwv2mrTW8Uq1SOB/kXy0mfw= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.1.4/go.mod h1:1PrKYwxTM+zjpw9Y41KFtoJCQrJ34Z47Y4VgVbfndjo= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.14 h1:m0QTSI6pZYJTk5WSKx3fm5cNW/DCicVzULBgU/6IyD0= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.9.14/go.mod h1:dDilntgHy9WnHXsh7dDtUPgHKEfTJIBUTHM8OWm0f/0= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.36 h1:eev2yZX7esGRjqRbnVk1UxMLw4CyVZDpZXRCcy75oQk= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.1.36/go.mod h1:lGnOkH9NJATw0XEPcAknFBj3zzNTEGRHtSw+CwC1YTg= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.35 h1:CdzPW9kKitgIiLV1+MHobfR5Xg25iYnyzWZhyQuSlDI= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.35/go.mod h1:QGF2Rs33W5MaN9gYdEQOBBFPLwTZkEhRwI33f7KIG0o= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.15.4 h1:v0jkRigbSD6uOdwcaUQmgEwG1BkPfAPDqaeNt/29ghg= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.15.4/go.mod h1:LhTyt8J04LL+9cIt7pYJ5lbS/U98ZmXovLOR/4LUsk8= +github.com/aws/aws-sdk-go-v2/service/s3 v1.40.0 h1:wl5dxN1NONhTDQD9uaEvNsDRX29cBmGED/nl0jkWlt4= +github.com/aws/aws-sdk-go-v2/service/s3 v1.40.0/go.mod h1:rDGMZA7f4pbmTtPOk5v5UM2lmX6UAbRnMDJeDvnH7AM= +github.com/aws/aws-sdk-go-v2/service/sso v1.14.1 h1:YkNzx1RLS0F5qdf9v1Q8Cuv9NXCL2TkosOxhzlUPV64= +github.com/aws/aws-sdk-go-v2/service/sso v1.14.1/go.mod h1:fIAwKQKBFu90pBxx07BFOMJLpRUGu8VOzLJakeY+0K4= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.17.1 h1:8lKOidPkmSmfUtiTgtdXWgaKItCZ/g75/jEk6Ql6GsA= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.17.1/go.mod h1:yygr8ACQRY2PrEcy3xsUI357stq2AxnFM6DIsR9lij4= +github.com/aws/aws-sdk-go-v2/service/sts v1.22.0 h1:s4bioTgjSFRwOoyEFzAVCmFmoowBgjTR8gkrF/sQ4wk= +github.com/aws/aws-sdk-go-v2/service/sts v1.22.0/go.mod h1:VC7JDqsqiwXukYEDjoHh9U0fOJtNWh04FPQz4ct4GGU= +github.com/aws/smithy-go v1.14.2 h1:MJU9hqBGbvWZdApzpvoF2WAIJDbtjK2NDJSiJP7HblQ= +github.com/aws/smithy-go v1.14.2/go.mod h1:Tg+OJXh4MB2R/uN61Ko2f6hTZwB/ZYGOtib8J3gBHzA= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg= +github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= +github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= +github.com/k0kubun/colorstring v0.0.0-20150214042306-9440f1994b88 h1:uC1QfSlInpQF+M0ao65imhwqKnz3Q2z/d8PWZRMQvDM= +github.com/k0kubun/colorstring v0.0.0-20150214042306-9440f1994b88/go.mod h1:3w7q1U84EfirKl04SVQ/s7nPm1ZPhiXd34z40TNz36k= +github.com/k0kubun/pp v3.0.1+incompatible h1:3tqvf7QgUnZ5tXO6pNAZlrvHgl6DvifjDrd9g2S9Z40= +github.com/k0kubun/pp v3.0.1+incompatible/go.mod h1:GWse8YhT0p8pT4ir3ZgBbfZild3tgzSScAn6HmfYukg= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab h1:2QkjZIsXupsJbJIdSjjUOgWK3aEtzyuh2mPt3l/CkeU= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/test/s3/cors/s3_cors_http_test.go b/test/s3/cors/s3_cors_http_test.go new file mode 100644 index 000000000..b94caef27 --- /dev/null +++ b/test/s3/cors/s3_cors_http_test.go @@ -0,0 +1,536 @@ +package cors + +import ( + "context" + "fmt" + "net/http" + "strings" + "testing" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/s3" + "github.com/aws/aws-sdk-go-v2/service/s3/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestCORSPreflightRequest tests CORS preflight OPTIONS requests +func TestCORSPreflightRequest(t *testing.T) { + client := getS3Client(t) + bucketName := createTestBucket(t, client) + defer cleanupTestBucket(t, client, bucketName) + + // Set up CORS configuration + corsConfig := &types.CORSConfiguration{ + CORSRules: []types.CORSRule{ + { + AllowedHeaders: []string{"Content-Type", "Authorization"}, + AllowedMethods: []string{"GET", "POST", "PUT", "DELETE"}, + AllowedOrigins: []string{"https://example.com"}, + ExposeHeaders: []string{"ETag", "Content-Length"}, + MaxAgeSeconds: 3600, + }, + }, + } + + _, err := client.PutBucketCors(context.TODO(), &s3.PutBucketCorsInput{ + Bucket: aws.String(bucketName), + CORSConfiguration: corsConfig, + }) + require.NoError(t, err, "Should be able to put CORS configuration") + + // Test preflight request with raw HTTP + httpClient := &http.Client{Timeout: 10 * time.Second} + + // Create OPTIONS request + req, err := http.NewRequest("OPTIONS", fmt.Sprintf("%s/%s/test-object", getDefaultConfig().Endpoint, bucketName), nil) + require.NoError(t, err, "Should be able to create OPTIONS request") + + // Add CORS preflight headers + req.Header.Set("Origin", "https://example.com") + req.Header.Set("Access-Control-Request-Method", "PUT") + req.Header.Set("Access-Control-Request-Headers", "Content-Type, Authorization") + + // Send the request + resp, err := httpClient.Do(req) + require.NoError(t, err, "Should be able to send OPTIONS request") + defer resp.Body.Close() + + // Verify CORS headers in response + assert.Equal(t, "https://example.com", resp.Header.Get("Access-Control-Allow-Origin"), "Should have correct Allow-Origin header") + assert.Contains(t, resp.Header.Get("Access-Control-Allow-Methods"), "PUT", "Should allow PUT method") + assert.Contains(t, resp.Header.Get("Access-Control-Allow-Headers"), "Content-Type", "Should allow Content-Type header") + assert.Contains(t, resp.Header.Get("Access-Control-Allow-Headers"), "Authorization", "Should allow Authorization header") + assert.Equal(t, "3600", resp.Header.Get("Access-Control-Max-Age"), "Should have correct Max-Age header") + assert.Contains(t, resp.Header.Get("Access-Control-Expose-Headers"), "ETag", "Should expose ETag header") + assert.Equal(t, http.StatusOK, resp.StatusCode, "OPTIONS request should return 200") +} + +// TestCORSActualRequest tests CORS behavior with actual requests +func TestCORSActualRequest(t *testing.T) { + client := getS3Client(t) + bucketName := createTestBucket(t, client) + defer cleanupTestBucket(t, client, bucketName) + + // Set up CORS configuration + corsConfig := &types.CORSConfiguration{ + CORSRules: []types.CORSRule{ + { + AllowedHeaders: []string{"*"}, + AllowedMethods: []string{"GET", "PUT"}, + AllowedOrigins: []string{"https://example.com"}, + ExposeHeaders: []string{"ETag", "Content-Length"}, + MaxAgeSeconds: 3600, + }, + }, + } + + _, err := client.PutBucketCors(context.TODO(), &s3.PutBucketCorsInput{ + Bucket: aws.String(bucketName), + CORSConfiguration: corsConfig, + }) + require.NoError(t, err, "Should be able to put CORS configuration") + + // First, put an object using S3 client + objectKey := "test-cors-object" + _, err = client.PutObject(context.TODO(), &s3.PutObjectInput{ + Bucket: aws.String(bucketName), + Key: aws.String(objectKey), + Body: strings.NewReader("Test CORS content"), + }) + require.NoError(t, err, "Should be able to put object") + + // Test GET request with CORS headers using raw HTTP + httpClient := &http.Client{Timeout: 10 * time.Second} + + req, err := http.NewRequest("GET", fmt.Sprintf("%s/%s/%s", getDefaultConfig().Endpoint, bucketName, objectKey), nil) + require.NoError(t, err, "Should be able to create GET request") + + // Add Origin header to simulate CORS request + req.Header.Set("Origin", "https://example.com") + + // Send the request + resp, err := httpClient.Do(req) + require.NoError(t, err, "Should be able to send GET request") + defer resp.Body.Close() + + // Verify CORS headers in response + assert.Equal(t, "https://example.com", resp.Header.Get("Access-Control-Allow-Origin"), "Should have correct Allow-Origin header") + assert.Contains(t, resp.Header.Get("Access-Control-Expose-Headers"), "ETag", "Should expose ETag header") + assert.Equal(t, http.StatusOK, resp.StatusCode, "GET request should return 200") +} + +// TestCORSOriginMatching tests origin matching with different patterns +func TestCORSOriginMatching(t *testing.T) { + client := getS3Client(t) + bucketName := createTestBucket(t, client) + defer cleanupTestBucket(t, client, bucketName) + + testCases := []struct { + name string + allowedOrigins []string + requestOrigin string + shouldAllow bool + }{ + { + name: "exact match", + allowedOrigins: []string{"https://example.com"}, + requestOrigin: "https://example.com", + shouldAllow: true, + }, + { + name: "wildcard match", + allowedOrigins: []string{"*"}, + requestOrigin: "https://example.com", + shouldAllow: true, + }, + { + name: "subdomain wildcard match", + allowedOrigins: []string{"https://*.example.com"}, + requestOrigin: "https://api.example.com", + shouldAllow: true, + }, + { + name: "no match", + allowedOrigins: []string{"https://example.com"}, + requestOrigin: "https://malicious.com", + shouldAllow: false, + }, + { + name: "subdomain wildcard no match", + allowedOrigins: []string{"https://*.example.com"}, + requestOrigin: "https://example.com", + shouldAllow: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + // Set up CORS configuration for this test case + corsConfig := &types.CORSConfiguration{ + CORSRules: []types.CORSRule{ + { + AllowedHeaders: []string{"*"}, + AllowedMethods: []string{"GET"}, + AllowedOrigins: tc.allowedOrigins, + ExposeHeaders: []string{"ETag"}, + MaxAgeSeconds: 3600, + }, + }, + } + + _, err := client.PutBucketCors(context.TODO(), &s3.PutBucketCorsInput{ + Bucket: aws.String(bucketName), + CORSConfiguration: corsConfig, + }) + require.NoError(t, err, "Should be able to put CORS configuration") + + // Test preflight request + httpClient := &http.Client{Timeout: 10 * time.Second} + + req, err := http.NewRequest("OPTIONS", fmt.Sprintf("%s/%s/test-object", getDefaultConfig().Endpoint, bucketName), nil) + require.NoError(t, err, "Should be able to create OPTIONS request") + + req.Header.Set("Origin", tc.requestOrigin) + req.Header.Set("Access-Control-Request-Method", "GET") + + resp, err := httpClient.Do(req) + require.NoError(t, err, "Should be able to send OPTIONS request") + defer resp.Body.Close() + + if tc.shouldAllow { + assert.Equal(t, tc.requestOrigin, resp.Header.Get("Access-Control-Allow-Origin"), "Should have correct Allow-Origin header") + assert.Contains(t, resp.Header.Get("Access-Control-Allow-Methods"), "GET", "Should allow GET method") + } else { + assert.Empty(t, resp.Header.Get("Access-Control-Allow-Origin"), "Should not have Allow-Origin header for disallowed origin") + } + }) + } +} + +// TestCORSHeaderMatching tests header matching with different patterns +func TestCORSHeaderMatching(t *testing.T) { + client := getS3Client(t) + bucketName := createTestBucket(t, client) + defer cleanupTestBucket(t, client, bucketName) + + testCases := []struct { + name string + allowedHeaders []string + requestHeaders string + shouldAllow bool + expectedHeaders string + }{ + { + name: "wildcard headers", + allowedHeaders: []string{"*"}, + requestHeaders: "Content-Type, Authorization", + shouldAllow: true, + expectedHeaders: "Content-Type, Authorization", + }, + { + name: "specific headers match", + allowedHeaders: []string{"Content-Type", "Authorization"}, + requestHeaders: "Content-Type, Authorization", + shouldAllow: true, + expectedHeaders: "Content-Type, Authorization", + }, + { + name: "partial header match", + allowedHeaders: []string{"Content-Type"}, + requestHeaders: "Content-Type", + shouldAllow: true, + expectedHeaders: "Content-Type", + }, + { + name: "case insensitive match", + allowedHeaders: []string{"content-type"}, + requestHeaders: "Content-Type", + shouldAllow: true, + expectedHeaders: "Content-Type", + }, + { + name: "disallowed header", + allowedHeaders: []string{"Content-Type"}, + requestHeaders: "Authorization", + shouldAllow: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + // Set up CORS configuration for this test case + corsConfig := &types.CORSConfiguration{ + CORSRules: []types.CORSRule{ + { + AllowedHeaders: tc.allowedHeaders, + AllowedMethods: []string{"GET", "POST"}, + AllowedOrigins: []string{"https://example.com"}, + ExposeHeaders: []string{"ETag"}, + MaxAgeSeconds: 3600, + }, + }, + } + + _, err := client.PutBucketCors(context.TODO(), &s3.PutBucketCorsInput{ + Bucket: aws.String(bucketName), + CORSConfiguration: corsConfig, + }) + require.NoError(t, err, "Should be able to put CORS configuration") + + // Test preflight request + httpClient := &http.Client{Timeout: 10 * time.Second} + + req, err := http.NewRequest("OPTIONS", fmt.Sprintf("%s/%s/test-object", getDefaultConfig().Endpoint, bucketName), nil) + require.NoError(t, err, "Should be able to create OPTIONS request") + + req.Header.Set("Origin", "https://example.com") + req.Header.Set("Access-Control-Request-Method", "POST") + req.Header.Set("Access-Control-Request-Headers", tc.requestHeaders) + + resp, err := httpClient.Do(req) + require.NoError(t, err, "Should be able to send OPTIONS request") + defer resp.Body.Close() + + if tc.shouldAllow { + assert.Equal(t, "https://example.com", resp.Header.Get("Access-Control-Allow-Origin"), "Should have correct Allow-Origin header") + allowedHeaders := resp.Header.Get("Access-Control-Allow-Headers") + for _, header := range strings.Split(tc.expectedHeaders, ", ") { + assert.Contains(t, allowedHeaders, header, "Should allow header: %s", header) + } + } else { + // Even if headers are not allowed, the origin should still be in the response + // but the headers should not be echoed back + assert.Equal(t, "https://example.com", resp.Header.Get("Access-Control-Allow-Origin"), "Should have correct Allow-Origin header") + allowedHeaders := resp.Header.Get("Access-Control-Allow-Headers") + assert.NotContains(t, allowedHeaders, "Authorization", "Should not allow Authorization header") + } + }) + } +} + +// TestCORSWithoutConfiguration tests CORS behavior when no configuration is set +func TestCORSWithoutConfiguration(t *testing.T) { + client := getS3Client(t) + bucketName := createTestBucket(t, client) + defer cleanupTestBucket(t, client, bucketName) + + // Test preflight request without CORS configuration + httpClient := &http.Client{Timeout: 10 * time.Second} + + req, err := http.NewRequest("OPTIONS", fmt.Sprintf("%s/%s/test-object", getDefaultConfig().Endpoint, bucketName), nil) + require.NoError(t, err, "Should be able to create OPTIONS request") + + req.Header.Set("Origin", "https://example.com") + req.Header.Set("Access-Control-Request-Method", "GET") + + resp, err := httpClient.Do(req) + require.NoError(t, err, "Should be able to send OPTIONS request") + defer resp.Body.Close() + + // Without CORS configuration, CORS headers should not be present + assert.Empty(t, resp.Header.Get("Access-Control-Allow-Origin"), "Should not have Allow-Origin header without CORS config") + assert.Empty(t, resp.Header.Get("Access-Control-Allow-Methods"), "Should not have Allow-Methods header without CORS config") + assert.Empty(t, resp.Header.Get("Access-Control-Allow-Headers"), "Should not have Allow-Headers header without CORS config") +} + +// TestCORSMethodMatching tests method matching +func TestCORSMethodMatching(t *testing.T) { + client := getS3Client(t) + bucketName := createTestBucket(t, client) + defer cleanupTestBucket(t, client, bucketName) + + // Set up CORS configuration with limited methods + corsConfig := &types.CORSConfiguration{ + CORSRules: []types.CORSRule{ + { + AllowedHeaders: []string{"*"}, + AllowedMethods: []string{"GET", "POST"}, + AllowedOrigins: []string{"https://example.com"}, + ExposeHeaders: []string{"ETag"}, + MaxAgeSeconds: 3600, + }, + }, + } + + _, err := client.PutBucketCors(context.TODO(), &s3.PutBucketCorsInput{ + Bucket: aws.String(bucketName), + CORSConfiguration: corsConfig, + }) + require.NoError(t, err, "Should be able to put CORS configuration") + + testCases := []struct { + method string + shouldAllow bool + }{ + {"GET", true}, + {"POST", true}, + {"PUT", false}, + {"DELETE", false}, + {"HEAD", false}, + } + + for _, tc := range testCases { + t.Run(fmt.Sprintf("method_%s", tc.method), func(t *testing.T) { + httpClient := &http.Client{Timeout: 10 * time.Second} + + req, err := http.NewRequest("OPTIONS", fmt.Sprintf("%s/%s/test-object", getDefaultConfig().Endpoint, bucketName), nil) + require.NoError(t, err, "Should be able to create OPTIONS request") + + req.Header.Set("Origin", "https://example.com") + req.Header.Set("Access-Control-Request-Method", tc.method) + + resp, err := httpClient.Do(req) + require.NoError(t, err, "Should be able to send OPTIONS request") + defer resp.Body.Close() + + if tc.shouldAllow { + assert.Equal(t, "https://example.com", resp.Header.Get("Access-Control-Allow-Origin"), "Should have correct Allow-Origin header") + assert.Contains(t, resp.Header.Get("Access-Control-Allow-Methods"), tc.method, "Should allow method: %s", tc.method) + } else { + // Even if method is not allowed, the origin should still be in the response + // but the method should not be in the allowed methods + assert.Equal(t, "https://example.com", resp.Header.Get("Access-Control-Allow-Origin"), "Should have correct Allow-Origin header") + allowedMethods := resp.Header.Get("Access-Control-Allow-Methods") + assert.NotContains(t, allowedMethods, tc.method, "Should not allow method: %s", tc.method) + } + }) + } +} + +// TestCORSMultipleRulesMatching tests CORS with multiple rules +func TestCORSMultipleRulesMatching(t *testing.T) { + client := getS3Client(t) + bucketName := createTestBucket(t, client) + defer cleanupTestBucket(t, client, bucketName) + + // Set up CORS configuration with multiple rules + corsConfig := &types.CORSConfiguration{ + CORSRules: []types.CORSRule{ + { + AllowedHeaders: []string{"Content-Type"}, + AllowedMethods: []string{"GET"}, + AllowedOrigins: []string{"https://example.com"}, + ExposeHeaders: []string{"ETag"}, + MaxAgeSeconds: 3600, + }, + { + AllowedHeaders: []string{"Authorization"}, + AllowedMethods: []string{"POST", "PUT"}, + AllowedOrigins: []string{"https://api.example.com"}, + ExposeHeaders: []string{"Content-Length"}, + MaxAgeSeconds: 7200, + }, + }, + } + + _, err := client.PutBucketCors(context.TODO(), &s3.PutBucketCorsInput{ + Bucket: aws.String(bucketName), + CORSConfiguration: corsConfig, + }) + require.NoError(t, err, "Should be able to put CORS configuration") + + // Test first rule + httpClient := &http.Client{Timeout: 10 * time.Second} + + req, err := http.NewRequest("OPTIONS", fmt.Sprintf("%s/%s/test-object", getDefaultConfig().Endpoint, bucketName), nil) + require.NoError(t, err, "Should be able to create OPTIONS request") + + req.Header.Set("Origin", "https://example.com") + req.Header.Set("Access-Control-Request-Method", "GET") + req.Header.Set("Access-Control-Request-Headers", "Content-Type") + + resp, err := httpClient.Do(req) + require.NoError(t, err, "Should be able to send OPTIONS request") + defer resp.Body.Close() + + assert.Equal(t, "https://example.com", resp.Header.Get("Access-Control-Allow-Origin"), "Should match first rule") + assert.Contains(t, resp.Header.Get("Access-Control-Allow-Methods"), "GET", "Should allow GET method") + assert.Contains(t, resp.Header.Get("Access-Control-Allow-Headers"), "Content-Type", "Should allow Content-Type header") + assert.Equal(t, "3600", resp.Header.Get("Access-Control-Max-Age"), "Should have first rule's max age") + + // Test second rule + req2, err := http.NewRequest("OPTIONS", fmt.Sprintf("%s/%s/test-object", getDefaultConfig().Endpoint, bucketName), nil) + require.NoError(t, err, "Should be able to create OPTIONS request") + + req2.Header.Set("Origin", "https://api.example.com") + req2.Header.Set("Access-Control-Request-Method", "POST") + req2.Header.Set("Access-Control-Request-Headers", "Authorization") + + resp2, err := httpClient.Do(req2) + require.NoError(t, err, "Should be able to send OPTIONS request") + defer resp2.Body.Close() + + assert.Equal(t, "https://api.example.com", resp2.Header.Get("Access-Control-Allow-Origin"), "Should match second rule") + assert.Contains(t, resp2.Header.Get("Access-Control-Allow-Methods"), "POST", "Should allow POST method") + assert.Contains(t, resp2.Header.Get("Access-Control-Allow-Headers"), "Authorization", "Should allow Authorization header") + assert.Equal(t, "7200", resp2.Header.Get("Access-Control-Max-Age"), "Should have second rule's max age") +} + +// TestServiceLevelCORS tests that service-level endpoints (like /status) get proper CORS headers +func TestServiceLevelCORS(t *testing.T) { + assert := assert.New(t) + + endpoints := []string{ + "/", + "/status", + "/healthz", + } + + for _, endpoint := range endpoints { + t.Run(fmt.Sprintf("endpoint_%s", strings.ReplaceAll(endpoint, "/", "_")), func(t *testing.T) { + req, err := http.NewRequest("OPTIONS", fmt.Sprintf("%s%s", getDefaultConfig().Endpoint, endpoint), nil) + assert.NoError(err) + + // Add Origin header to trigger CORS + req.Header.Set("Origin", "http://example.com") + + client := &http.Client{} + resp, err := client.Do(req) + assert.NoError(err) + defer resp.Body.Close() + + // Should return 200 OK + assert.Equal(http.StatusOK, resp.StatusCode) + + // Should have CORS headers set + assert.Equal("*", resp.Header.Get("Access-Control-Allow-Origin")) + assert.Equal("*", resp.Header.Get("Access-Control-Expose-Headers")) + assert.Equal("*", resp.Header.Get("Access-Control-Allow-Methods")) + assert.Equal("*", resp.Header.Get("Access-Control-Allow-Headers")) + }) + } +} + +// TestServiceLevelCORSWithoutOrigin tests that service-level endpoints without Origin header don't get CORS headers +func TestServiceLevelCORSWithoutOrigin(t *testing.T) { + assert := assert.New(t) + + req, err := http.NewRequest("OPTIONS", fmt.Sprintf("%s/status", getDefaultConfig().Endpoint), nil) + assert.NoError(err) + + // No Origin header + + client := &http.Client{} + resp, err := client.Do(req) + assert.NoError(err) + defer resp.Body.Close() + + // Should return 200 OK + assert.Equal(http.StatusOK, resp.StatusCode) + + // Should not have CORS headers set (or have empty values) + corsHeaders := []string{ + "Access-Control-Allow-Origin", + "Access-Control-Expose-Headers", + "Access-Control-Allow-Methods", + "Access-Control-Allow-Headers", + } + + for _, header := range corsHeaders { + value := resp.Header.Get(header) + // Headers should either be empty or not present + assert.True(value == "" || value == "*", "Header %s should be empty or wildcard, got: %s", header, value) + } +} diff --git a/test/s3/cors/s3_cors_test.go b/test/s3/cors/s3_cors_test.go new file mode 100644 index 000000000..8f1745adf --- /dev/null +++ b/test/s3/cors/s3_cors_test.go @@ -0,0 +1,600 @@ +package cors + +import ( + "context" + "fmt" + "strings" + "testing" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/credentials" + "github.com/aws/aws-sdk-go-v2/service/s3" + "github.com/aws/aws-sdk-go-v2/service/s3/types" + "github.com/k0kubun/pp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// S3TestConfig holds configuration for S3 tests +type S3TestConfig struct { + Endpoint string + AccessKey string + SecretKey string + Region string + BucketPrefix string + UseSSL bool + SkipVerifySSL bool +} + +// getDefaultConfig returns a fresh instance of the default test configuration +// to avoid parallel test issues with global mutable state +func getDefaultConfig() *S3TestConfig { + return &S3TestConfig{ + Endpoint: "http://localhost:8333", // Default SeaweedFS S3 port + AccessKey: "some_access_key1", + SecretKey: "some_secret_key1", + Region: "us-east-1", + BucketPrefix: "test-cors-", + UseSSL: false, + SkipVerifySSL: true, + } +} + +// getS3Client creates an AWS S3 client for testing +func getS3Client(t *testing.T) *s3.Client { + defaultConfig := getDefaultConfig() + cfg, err := config.LoadDefaultConfig(context.TODO(), + config.WithRegion(defaultConfig.Region), + config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider( + defaultConfig.AccessKey, + defaultConfig.SecretKey, + "", + )), + config.WithEndpointResolverWithOptions(aws.EndpointResolverWithOptionsFunc( + func(service, region string, options ...interface{}) (aws.Endpoint, error) { + return aws.Endpoint{ + URL: defaultConfig.Endpoint, + SigningRegion: defaultConfig.Region, + }, nil + })), + ) + require.NoError(t, err) + + client := s3.NewFromConfig(cfg, func(o *s3.Options) { + o.UsePathStyle = true + }) + return client +} + +// createTestBucket creates a test bucket with a unique name +func createTestBucket(t *testing.T, client *s3.Client) string { + defaultConfig := getDefaultConfig() + bucketName := fmt.Sprintf("%s%d", defaultConfig.BucketPrefix, time.Now().UnixNano()) + + _, err := client.CreateBucket(context.TODO(), &s3.CreateBucketInput{ + Bucket: aws.String(bucketName), + }) + require.NoError(t, err) + + return bucketName +} + +// cleanupTestBucket removes the test bucket and all its contents +func cleanupTestBucket(t *testing.T, client *s3.Client, bucketName string) { + // First, delete all objects in the bucket + listResp, err := client.ListObjectsV2(context.TODO(), &s3.ListObjectsV2Input{ + Bucket: aws.String(bucketName), + }) + if err == nil { + for _, obj := range listResp.Contents { + _, err := client.DeleteObject(context.TODO(), &s3.DeleteObjectInput{ + Bucket: aws.String(bucketName), + Key: obj.Key, + }) + if err != nil { + t.Logf("Warning: failed to delete object %s: %v", *obj.Key, err) + } + } + } + + // Then delete the bucket + _, err = client.DeleteBucket(context.TODO(), &s3.DeleteBucketInput{ + Bucket: aws.String(bucketName), + }) + if err != nil { + t.Logf("Warning: failed to delete bucket %s: %v", bucketName, err) + } +} + +// TestCORSConfigurationManagement tests basic CORS configuration CRUD operations +func TestCORSConfigurationManagement(t *testing.T) { + client := getS3Client(t) + bucketName := createTestBucket(t, client) + defer cleanupTestBucket(t, client, bucketName) + + // Test 1: Get CORS configuration when none exists (should return error) + _, err := client.GetBucketCors(context.TODO(), &s3.GetBucketCorsInput{ + Bucket: aws.String(bucketName), + }) + assert.Error(t, err, "Should get error when no CORS configuration exists") + + // Test 2: Put CORS configuration + corsConfig := &types.CORSConfiguration{ + CORSRules: []types.CORSRule{ + { + AllowedHeaders: []string{"*"}, + AllowedMethods: []string{"GET", "POST", "PUT"}, + AllowedOrigins: []string{"https://example.com"}, + ExposeHeaders: []string{"ETag"}, + MaxAgeSeconds: 3600, + }, + }, + } + + _, err = client.PutBucketCors(context.TODO(), &s3.PutBucketCorsInput{ + Bucket: aws.String(bucketName), + CORSConfiguration: corsConfig, + }) + assert.NoError(t, err, "Should be able to put CORS configuration") + + // Test 3: Get CORS configuration + getResp, err := client.GetBucketCors(context.TODO(), &s3.GetBucketCorsInput{ + Bucket: aws.String(bucketName), + }) + assert.NoError(t, err, "Should be able to get CORS configuration") + assert.NotNil(t, getResp.CORSRules, "CORS configuration should not be nil") + assert.Len(t, getResp.CORSRules, 1, "Should have one CORS rule") + + rule := getResp.CORSRules[0] + assert.Equal(t, []string{"*"}, rule.AllowedHeaders, "Allowed headers should match") + assert.Equal(t, []string{"GET", "POST", "PUT"}, rule.AllowedMethods, "Allowed methods should match") + assert.Equal(t, []string{"https://example.com"}, rule.AllowedOrigins, "Allowed origins should match") + assert.Equal(t, []string{"ETag"}, rule.ExposeHeaders, "Expose headers should match") + assert.Equal(t, int32(3600), rule.MaxAgeSeconds, "Max age should match") + + // Test 4: Update CORS configuration + updatedCorsConfig := &types.CORSConfiguration{ + CORSRules: []types.CORSRule{ + { + AllowedHeaders: []string{"Content-Type"}, + AllowedMethods: []string{"GET", "POST"}, + AllowedOrigins: []string{"https://example.com", "https://another.com"}, + ExposeHeaders: []string{"ETag", "Content-Length"}, + MaxAgeSeconds: 7200, + }, + }, + } + + _, err = client.PutBucketCors(context.TODO(), &s3.PutBucketCorsInput{ + Bucket: aws.String(bucketName), + CORSConfiguration: updatedCorsConfig, + }) + assert.NoError(t, err, "Should be able to update CORS configuration") + + // Verify the update + getResp, err = client.GetBucketCors(context.TODO(), &s3.GetBucketCorsInput{ + Bucket: aws.String(bucketName), + }) + assert.NoError(t, err, "Should be able to get updated CORS configuration") + rule = getResp.CORSRules[0] + assert.Equal(t, []string{"Content-Type"}, rule.AllowedHeaders, "Updated allowed headers should match") + assert.Equal(t, []string{"https://example.com", "https://another.com"}, rule.AllowedOrigins, "Updated allowed origins should match") + + // Test 5: Delete CORS configuration + _, err = client.DeleteBucketCors(context.TODO(), &s3.DeleteBucketCorsInput{ + Bucket: aws.String(bucketName), + }) + assert.NoError(t, err, "Should be able to delete CORS configuration") + + // Verify deletion + _, err = client.GetBucketCors(context.TODO(), &s3.GetBucketCorsInput{ + Bucket: aws.String(bucketName), + }) + assert.Error(t, err, "Should get error after deleting CORS configuration") +} + +// TestCORSMultipleRules tests CORS configuration with multiple rules +func TestCORSMultipleRules(t *testing.T) { + client := getS3Client(t) + bucketName := createTestBucket(t, client) + defer cleanupTestBucket(t, client, bucketName) + + // Create CORS configuration with multiple rules + corsConfig := &types.CORSConfiguration{ + CORSRules: []types.CORSRule{ + { + AllowedHeaders: []string{"*"}, + AllowedMethods: []string{"GET", "HEAD"}, + AllowedOrigins: []string{"https://example.com"}, + ExposeHeaders: []string{"ETag"}, + MaxAgeSeconds: 3600, + }, + { + AllowedHeaders: []string{"Content-Type", "Authorization"}, + AllowedMethods: []string{"POST", "PUT", "DELETE"}, + AllowedOrigins: []string{"https://app.example.com"}, + ExposeHeaders: []string{"ETag", "Content-Length"}, + MaxAgeSeconds: 7200, + }, + { + AllowedHeaders: []string{"*"}, + AllowedMethods: []string{"GET"}, + AllowedOrigins: []string{"*"}, + ExposeHeaders: []string{"ETag"}, + MaxAgeSeconds: 1800, + }, + }, + } + + _, err := client.PutBucketCors(context.TODO(), &s3.PutBucketCorsInput{ + Bucket: aws.String(bucketName), + CORSConfiguration: corsConfig, + }) + assert.NoError(t, err, "Should be able to put CORS configuration with multiple rules") + + // Get and verify the configuration + getResp, err := client.GetBucketCors(context.TODO(), &s3.GetBucketCorsInput{ + Bucket: aws.String(bucketName), + }) + assert.NoError(t, err, "Should be able to get CORS configuration") + assert.Len(t, getResp.CORSRules, 3, "Should have three CORS rules") + + // Verify first rule + rule1 := getResp.CORSRules[0] + assert.Equal(t, []string{"*"}, rule1.AllowedHeaders) + assert.Equal(t, []string{"GET", "HEAD"}, rule1.AllowedMethods) + assert.Equal(t, []string{"https://example.com"}, rule1.AllowedOrigins) + + // Verify second rule + rule2 := getResp.CORSRules[1] + assert.Equal(t, []string{"Content-Type", "Authorization"}, rule2.AllowedHeaders) + assert.Equal(t, []string{"POST", "PUT", "DELETE"}, rule2.AllowedMethods) + assert.Equal(t, []string{"https://app.example.com"}, rule2.AllowedOrigins) + + // Verify third rule + rule3 := getResp.CORSRules[2] + assert.Equal(t, []string{"*"}, rule3.AllowedHeaders) + assert.Equal(t, []string{"GET"}, rule3.AllowedMethods) + assert.Equal(t, []string{"*"}, rule3.AllowedOrigins) +} + +// TestCORSValidation tests CORS configuration validation +func TestCORSValidation(t *testing.T) { + client := getS3Client(t) + bucketName := createTestBucket(t, client) + defer cleanupTestBucket(t, client, bucketName) + + // Test invalid HTTP method + invalidMethodConfig := &types.CORSConfiguration{ + CORSRules: []types.CORSRule{ + { + AllowedHeaders: []string{"*"}, + AllowedMethods: []string{"INVALID_METHOD"}, + AllowedOrigins: []string{"https://example.com"}, + }, + }, + } + + _, err := client.PutBucketCors(context.TODO(), &s3.PutBucketCorsInput{ + Bucket: aws.String(bucketName), + CORSConfiguration: invalidMethodConfig, + }) + assert.Error(t, err, "Should get error for invalid HTTP method") + + // Test empty origins + emptyOriginsConfig := &types.CORSConfiguration{ + CORSRules: []types.CORSRule{ + { + AllowedHeaders: []string{"*"}, + AllowedMethods: []string{"GET"}, + AllowedOrigins: []string{}, + }, + }, + } + + _, err = client.PutBucketCors(context.TODO(), &s3.PutBucketCorsInput{ + Bucket: aws.String(bucketName), + CORSConfiguration: emptyOriginsConfig, + }) + assert.Error(t, err, "Should get error for empty origins") + + // Test negative MaxAge + negativeMaxAgeConfig := &types.CORSConfiguration{ + CORSRules: []types.CORSRule{ + { + AllowedHeaders: []string{"*"}, + AllowedMethods: []string{"GET"}, + AllowedOrigins: []string{"https://example.com"}, + MaxAgeSeconds: -1, + }, + }, + } + + _, err = client.PutBucketCors(context.TODO(), &s3.PutBucketCorsInput{ + Bucket: aws.String(bucketName), + CORSConfiguration: negativeMaxAgeConfig, + }) + assert.Error(t, err, "Should get error for negative MaxAge") +} + +// TestCORSWithWildcards tests CORS configuration with wildcard patterns +func TestCORSWithWildcards(t *testing.T) { + client := getS3Client(t) + bucketName := createTestBucket(t, client) + defer cleanupTestBucket(t, client, bucketName) + + // Create CORS configuration with wildcard patterns + corsConfig := &types.CORSConfiguration{ + CORSRules: []types.CORSRule{ + { + AllowedHeaders: []string{"*"}, + AllowedMethods: []string{"GET", "POST"}, + AllowedOrigins: []string{"https://*.example.com"}, + ExposeHeaders: []string{"*"}, + MaxAgeSeconds: 3600, + }, + }, + } + + _, err := client.PutBucketCors(context.TODO(), &s3.PutBucketCorsInput{ + Bucket: aws.String(bucketName), + CORSConfiguration: corsConfig, + }) + assert.NoError(t, err, "Should be able to put CORS configuration with wildcards") + + // Get and verify the configuration + getResp, err := client.GetBucketCors(context.TODO(), &s3.GetBucketCorsInput{ + Bucket: aws.String(bucketName), + }) + assert.NoError(t, err, "Should be able to get CORS configuration") + assert.Len(t, getResp.CORSRules, 1, "Should have one CORS rule") + + rule := getResp.CORSRules[0] + assert.Equal(t, []string{"*"}, rule.AllowedHeaders, "Wildcard headers should be preserved") + assert.Equal(t, []string{"https://*.example.com"}, rule.AllowedOrigins, "Wildcard origins should be preserved") + assert.Equal(t, []string{"*"}, rule.ExposeHeaders, "Wildcard expose headers should be preserved") +} + +// TestCORSRuleLimit tests the maximum number of CORS rules +func TestCORSRuleLimit(t *testing.T) { + client := getS3Client(t) + bucketName := createTestBucket(t, client) + defer cleanupTestBucket(t, client, bucketName) + + // Create CORS configuration with maximum allowed rules (100) + rules := make([]types.CORSRule, 100) + for i := 0; i < 100; i++ { + rules[i] = types.CORSRule{ + AllowedHeaders: []string{"*"}, + AllowedMethods: []string{"GET"}, + AllowedOrigins: []string{fmt.Sprintf("https://example%d.com", i)}, + MaxAgeSeconds: 3600, + } + } + + corsConfig := &types.CORSConfiguration{ + CORSRules: rules, + } + + _, err := client.PutBucketCors(context.TODO(), &s3.PutBucketCorsInput{ + Bucket: aws.String(bucketName), + CORSConfiguration: corsConfig, + }) + assert.NoError(t, err, "Should be able to put CORS configuration with 100 rules") + + // Try to add one more rule (should fail) + rules = append(rules, types.CORSRule{ + AllowedHeaders: []string{"*"}, + AllowedMethods: []string{"GET"}, + AllowedOrigins: []string{"https://example101.com"}, + MaxAgeSeconds: 3600, + }) + + corsConfig.CORSRules = rules + + _, err = client.PutBucketCors(context.TODO(), &s3.PutBucketCorsInput{ + Bucket: aws.String(bucketName), + CORSConfiguration: corsConfig, + }) + assert.Error(t, err, "Should get error when exceeding maximum number of rules") +} + +// TestCORSNonExistentBucket tests CORS operations on non-existent bucket +func TestCORSNonExistentBucket(t *testing.T) { + client := getS3Client(t) + nonExistentBucket := "non-existent-bucket-cors-test" + + // Test Get CORS on non-existent bucket + _, err := client.GetBucketCors(context.TODO(), &s3.GetBucketCorsInput{ + Bucket: aws.String(nonExistentBucket), + }) + assert.Error(t, err, "Should get error for non-existent bucket") + + // Test Put CORS on non-existent bucket + corsConfig := &types.CORSConfiguration{ + CORSRules: []types.CORSRule{ + { + AllowedHeaders: []string{"*"}, + AllowedMethods: []string{"GET"}, + AllowedOrigins: []string{"https://example.com"}, + }, + }, + } + + _, err = client.PutBucketCors(context.TODO(), &s3.PutBucketCorsInput{ + Bucket: aws.String(nonExistentBucket), + CORSConfiguration: corsConfig, + }) + assert.Error(t, err, "Should get error for non-existent bucket") + + // Test Delete CORS on non-existent bucket + _, err = client.DeleteBucketCors(context.TODO(), &s3.DeleteBucketCorsInput{ + Bucket: aws.String(nonExistentBucket), + }) + assert.Error(t, err, "Should get error for non-existent bucket") +} + +// TestCORSObjectOperations tests CORS behavior with object operations +func TestCORSObjectOperations(t *testing.T) { + client := getS3Client(t) + bucketName := createTestBucket(t, client) + defer cleanupTestBucket(t, client, bucketName) + + // Set up CORS configuration + corsConfig := &types.CORSConfiguration{ + CORSRules: []types.CORSRule{ + { + AllowedHeaders: []string{"*"}, + AllowedMethods: []string{"GET", "POST", "PUT", "DELETE"}, + AllowedOrigins: []string{"https://example.com"}, + ExposeHeaders: []string{"ETag", "Content-Length"}, + MaxAgeSeconds: 3600, + }, + }, + } + + _, err := client.PutBucketCors(context.TODO(), &s3.PutBucketCorsInput{ + Bucket: aws.String(bucketName), + CORSConfiguration: corsConfig, + }) + assert.NoError(t, err, "Should be able to put CORS configuration") + + // Test putting an object (this should work normally) + objectKey := "test-object.txt" + objectContent := "Hello, CORS World!" + + _, err = client.PutObject(context.TODO(), &s3.PutObjectInput{ + Bucket: aws.String(bucketName), + Key: aws.String(objectKey), + Body: strings.NewReader(objectContent), + }) + assert.NoError(t, err, "Should be able to put object in CORS-enabled bucket") + + // Test getting the object + getResp, err := client.GetObject(context.TODO(), &s3.GetObjectInput{ + Bucket: aws.String(bucketName), + Key: aws.String(objectKey), + }) + assert.NoError(t, err, "Should be able to get object from CORS-enabled bucket") + assert.NotNil(t, getResp.Body, "Object body should not be nil") + + // Test deleting the object + _, err = client.DeleteObject(context.TODO(), &s3.DeleteObjectInput{ + Bucket: aws.String(bucketName), + Key: aws.String(objectKey), + }) + assert.NoError(t, err, "Should be able to delete object from CORS-enabled bucket") +} + +// TestCORSCaching tests CORS configuration caching behavior +func TestCORSCaching(t *testing.T) { + client := getS3Client(t) + bucketName := createTestBucket(t, client) + defer cleanupTestBucket(t, client, bucketName) + + // Set up initial CORS configuration + corsConfig1 := &types.CORSConfiguration{ + CORSRules: []types.CORSRule{ + { + AllowedHeaders: []string{"*"}, + AllowedMethods: []string{"GET"}, + AllowedOrigins: []string{"https://example.com"}, + MaxAgeSeconds: 3600, + }, + }, + } + + _, err := client.PutBucketCors(context.TODO(), &s3.PutBucketCorsInput{ + Bucket: aws.String(bucketName), + CORSConfiguration: corsConfig1, + }) + assert.NoError(t, err, "Should be able to put initial CORS configuration") + + // Get the configuration + getResp1, err := client.GetBucketCors(context.TODO(), &s3.GetBucketCorsInput{ + Bucket: aws.String(bucketName), + }) + assert.NoError(t, err, "Should be able to get initial CORS configuration") + assert.Len(t, getResp1.CORSRules, 1, "Should have one CORS rule") + + // Update the configuration + corsConfig2 := &types.CORSConfiguration{ + CORSRules: []types.CORSRule{ + { + AllowedHeaders: []string{"Content-Type"}, + AllowedMethods: []string{"GET", "POST"}, + AllowedOrigins: []string{"https://example.com", "https://another.com"}, + MaxAgeSeconds: 7200, + }, + }, + } + + _, err = client.PutBucketCors(context.TODO(), &s3.PutBucketCorsInput{ + Bucket: aws.String(bucketName), + CORSConfiguration: corsConfig2, + }) + assert.NoError(t, err, "Should be able to update CORS configuration") + + // Get the updated configuration (should reflect the changes) + getResp2, err := client.GetBucketCors(context.TODO(), &s3.GetBucketCorsInput{ + Bucket: aws.String(bucketName), + }) + assert.NoError(t, err, "Should be able to get updated CORS configuration") + assert.Len(t, getResp2.CORSRules, 1, "Should have one CORS rule") + + rule := getResp2.CORSRules[0] + assert.Equal(t, []string{"Content-Type"}, rule.AllowedHeaders, "Should have updated headers") + assert.Equal(t, []string{"GET", "POST"}, rule.AllowedMethods, "Should have updated methods") + assert.Equal(t, []string{"https://example.com", "https://another.com"}, rule.AllowedOrigins, "Should have updated origins") + assert.Equal(t, int32(7200), rule.MaxAgeSeconds, "Should have updated max age") +} + +// TestCORSErrorHandling tests various error conditions +func TestCORSErrorHandling(t *testing.T) { + client := getS3Client(t) + bucketName := createTestBucket(t, client) + defer cleanupTestBucket(t, client, bucketName) + + // Test empty CORS configuration + emptyCorsConfig := &types.CORSConfiguration{ + CORSRules: []types.CORSRule{}, + } + + _, err := client.PutBucketCors(context.TODO(), &s3.PutBucketCorsInput{ + Bucket: aws.String(bucketName), + CORSConfiguration: emptyCorsConfig, + }) + assert.Error(t, err, "Should get error for empty CORS configuration") + + // Test nil CORS configuration + _, err = client.PutBucketCors(context.TODO(), &s3.PutBucketCorsInput{ + Bucket: aws.String(bucketName), + CORSConfiguration: nil, + }) + assert.Error(t, err, "Should get error for nil CORS configuration") + + // Test CORS rule with empty methods + emptyMethodsConfig := &types.CORSConfiguration{ + CORSRules: []types.CORSRule{ + { + AllowedHeaders: []string{"*"}, + AllowedMethods: []string{}, + AllowedOrigins: []string{"https://example.com"}, + }, + }, + } + + _, err = client.PutBucketCors(context.TODO(), &s3.PutBucketCorsInput{ + Bucket: aws.String(bucketName), + CORSConfiguration: emptyMethodsConfig, + }) + assert.Error(t, err, "Should get error for empty methods") +} + +// Debugging helper to pretty print responses +func debugResponse(t *testing.T, title string, response interface{}) { + t.Logf("=== %s ===", title) + pp.Println(response) +} |
