diff --git a/docs/en/framework/infrastructure/blob-storing/aws.md b/docs/en/framework/infrastructure/blob-storing/aws.md index 35293ec922..1ee55bbb74 100644 --- a/docs/en/framework/infrastructure/blob-storing/aws.md +++ b/docs/en/framework/infrastructure/blob-storing/aws.md @@ -7,7 +7,7 @@ # BLOB Storing Aws Provider -BLOB Storing Aws Provider can store BLOBs in [Amazon Simple Storage Service](https://aws.amazon.com/s3/). +BLOB Storing Aws Provider can store BLOBs in [Amazon Simple Storage Service](https://aws.amazon.com/s3/) and **S3-compatible storage services** like MinIO, DigitalOcean Spaces, Cloudflare R2, and others. > Read the [BLOB Storing document](../blob-storing) to understand how to use the BLOB storing system. This document only covers how to configure containers to use a Aws BLOB as the storage provider. @@ -41,7 +41,8 @@ Configure(options => Aws.UseTemporaryFederatedCredentials = "set true to use temporary federated credentials"; Aws.ProfileName = "the name of the profile to get credentials from"; Aws.ProfilesLocation = "the path to the aws credentials file to look at"; - Aws.Region = "the system name of the service"; + Aws.Region = "the AWS region system name, e.g. us-east-1"; + Aws.ServiceURL = "custom service URL for S3-compatible APIs (optional)"; Aws.Name = "the name of the federated user"; Aws.Policy = "policy"; Aws.DurationSeconds = "expiration date"; @@ -64,7 +65,9 @@ Configure(options => * **UseTemporaryFederatedCredentials** (bool): Use [federated user temporary credentials](https://docs.aws.amazon.com/AmazonS3/latest/dev/AuthUsingTempFederationToken.html) to access AWS services, default : `false`. * **ProfileName** (string): The [name of the profile](https://docs.aws.amazon.com/sdk-for-net/v3/developer-guide/net-dg-config-creds.html) to get credentials from. * **ProfilesLocation** (string): The path to the aws credentials file to look at. -* **Region** (string): The system name of the service. +* **Region** (string): The system name of the AWS region (e.g., `us-east-1`). **Required** for real AWS S3. Optional when `ServiceURL` is configured for an S3-compatible service; some services accept any value (or `auto` for Cloudflare R2). +* **ServiceURL** (string): Custom service URL for S3-compatible APIs (e.g., MinIO, DigitalOcean Spaces, Cloudflare R2). If not specified, the default AWS S3 service URL will be used based on the region. When using S3-compatible services, this should point to your service endpoint (e.g., `https://minio.example.com:9000`). The AWS SDK automatically appends a trailing slash to the configured value. +* **DisablePayloadSigning** (bool): Default `false`. When set to `true`, the provider sends `x-amz-content-sha256: UNSIGNED-PAYLOAD` on `PutObject` requests instead of the streaming chunked signature (`STREAMING-AWS4-HMAC-SHA256-PAYLOAD`) that the AWS SDK v4 uses by default. Required for Cloudflare R2 and other S3-compatible services that do not implement streaming signing. The endpoint must be HTTPS when this option is enabled. Leave as `false` for real AWS S3. * **Policy** (string): An IAM policy in JSON format that you want to use as an inline session policy. * **DurationSeconds** (int): Validity period(s) of a temporary access certificate,minimum is 900 and the maximum is 3600. **note**: Using sub-accounts operated OSS,if the value is 0. * **ContainerName** (string): You can specify the container name in Aws. If this is not specified, it uses the name of the BLOB container defined with the `BlobContainerName` attribute (see the [BLOB storing document](../blob-storing)). Please note that Aws has some **rules for naming containers**. A container name must be a valid DNS name, conforming to the [following naming rules](https://docs.aws.amazon.com/AmazonS3/latest/dev/BucketRestrictions.html): @@ -77,6 +80,75 @@ Configure(options => * Buckets used with Amazon S3 Transfer Acceleration can't have dots (.) in their names. For more information about transfer acceleration, see Amazon S3 Transfer Acceleration. * **CreateContainerIfNotExists** (bool): Default value is `false`, If a container does not exist in Aws, `AwsBlobProvider` will try to create it. +## S3-Compatible Services + +The AWS provider supports S3-compatible storage services by configuring the `ServiceURL` property. Here are some examples: + +### MinIO Configuration + +````csharp +Configure(options => +{ + options.Containers.ConfigureDefault(container => + { + container.UseAws(aws => + { + aws.AccessKeyId = "your-minio-access-key"; + aws.SecretAccessKey = "your-minio-secret-key"; + aws.ServiceURL = "https://minio.example.com:9000"; + aws.Region = "us-east-1"; // MinIO region (can be any valid region) + aws.ContainerName = "my-bucket"; + aws.CreateContainerIfNotExists = true; + }); + }); +}); +```` + +### DigitalOcean Spaces Configuration + +````csharp +Configure(options => +{ + options.Containers.ConfigureDefault(container => + { + container.UseAws(aws => + { + aws.AccessKeyId = "your-spaces-access-key"; + aws.SecretAccessKey = "your-spaces-secret-key"; + aws.ServiceURL = "https://nyc3.digitaloceanspaces.com"; + aws.Region = "us-east-1"; // DigitalOcean Spaces region + aws.ContainerName = "my-space"; + aws.CreateContainerIfNotExists = true; + }); + }); +}); +```` + +### Cloudflare R2 Configuration + +````csharp +Configure(options => +{ + options.Containers.ConfigureDefault(container => + { + container.UseAws(aws => + { + aws.AccessKeyId = "your-r2-access-key"; + aws.SecretAccessKey = "your-r2-secret-key"; + aws.ServiceURL = "https://your-account-id.r2.cloudflarestorage.com"; + aws.Region = "auto"; // Cloudflare R2 uses 'auto' as region + aws.DisablePayloadSigning = true; // R2 does not implement streaming chunked payload signing + aws.ContainerName = "my-bucket"; + aws.CreateContainerIfNotExists = true; + }); + }); +}); +```` + +> **Note**: When using S3-compatible services, the provider automatically enables path-style requests which are required by most S3-compatible implementations. + +> **Note on `DisablePayloadSigning`**: AWS SDK v4 sends `PutObject` requests with `x-amz-content-sha256: STREAMING-AWS4-HMAC-SHA256-PAYLOAD`. Cloudflare R2 (and some other S3-compatible services) return `501 NotImplemented` for this signing mode. Setting `DisablePayloadSigning = true` switches to `UNSIGNED-PAYLOAD`, which these services accept. The endpoint must be HTTPS. Leave it `false` for real AWS S3. + ## Aws Blob Name Calculator Aws Blob Provider organizes BLOB name and implements some conventions. The full name of a BLOB is determined by the following rules by default: diff --git a/docs/en/framework/infrastructure/blob-storing/index.md b/docs/en/framework/infrastructure/blob-storing/index.md index 3672e43387..a00ad62ec8 100644 --- a/docs/en/framework/infrastructure/blob-storing/index.md +++ b/docs/en/framework/infrastructure/blob-storing/index.md @@ -36,6 +36,20 @@ More providers will be implemented by the time. You can [request](https://github Multiple providers **can be used together** by the help of the **container system**, where each container can uses a different provider. +### S3 Compatibility + +The [AWS provider](./aws.md) supports not only Amazon S3 but also **S3-compatible APIs** from various cloud providers and self-hosted solutions. This means you can use the same AWS provider to connect to: + +* **Amazon S3** - The original AWS S3 service +* **MinIO** - Self-hosted S3-compatible object storage +* **Cloudflare R2** - Cloudflare's S3-compatible object storage +* **DigitalOcean Spaces** - DigitalOcean's S3-compatible object storage +* **Wasabi** - S3-compatible cloud storage +* **Backblaze B2** - S3-compatible cloud storage +* **Any other S3-compatible storage** - Including private cloud solutions + +To use S3-compatible services, configure the `ServiceURL` property in the AWS provider configuration to point to your S3-compatible endpoint. Some services (e.g., Cloudflare R2) also require `DisablePayloadSigning = true` because they do not implement the streaming chunked payload signing that AWS SDK v4 uses by default. See the [AWS provider document](aws.md) for full configuration examples. + > BLOB storing system can not work unless you **configure a storage provider**. Refer to the linked documents for the storage provider configurations. ## Installation diff --git a/framework/src/Volo.Abp.BlobStoring.Aws/Volo/Abp/BlobStoring/Aws/AwsBlobProvider.cs b/framework/src/Volo.Abp.BlobStoring.Aws/Volo/Abp/BlobStoring/Aws/AwsBlobProvider.cs index 7c520da471..b81d722bae 100644 --- a/framework/src/Volo.Abp.BlobStoring.Aws/Volo/Abp/BlobStoring/Aws/AwsBlobProvider.cs +++ b/framework/src/Volo.Abp.BlobStoring.Aws/Volo/Abp/BlobStoring/Aws/AwsBlobProvider.cs @@ -47,7 +47,8 @@ public class AwsBlobProvider : BlobProviderBase, ITransientDependency { BucketName = containerName, Key = blobName, - InputStream = args.BlobStream + InputStream = args.BlobStream, + DisablePayloadSigning = configuration.DisablePayloadSigning }); } } diff --git a/framework/src/Volo.Abp.BlobStoring.Aws/Volo/Abp/BlobStoring/Aws/AwsBlobProviderConfiguration.cs b/framework/src/Volo.Abp.BlobStoring.Aws/Volo/Abp/BlobStoring/Aws/AwsBlobProviderConfiguration.cs index 1b31d176c8..fa318bdcb8 100644 --- a/framework/src/Volo.Abp.BlobStoring.Aws/Volo/Abp/BlobStoring/Aws/AwsBlobProviderConfiguration.cs +++ b/framework/src/Volo.Abp.BlobStoring.Aws/Volo/Abp/BlobStoring/Aws/AwsBlobProviderConfiguration.cs @@ -57,9 +57,53 @@ public class AwsBlobProviderConfiguration set => _containerConfiguration.SetConfiguration(AwsBlobProviderConfigurationNames.Policy, value); } - public string Region { - get => _containerConfiguration.GetConfiguration(AwsBlobProviderConfigurationNames.Region); - set => _containerConfiguration.SetConfiguration(AwsBlobProviderConfigurationNames.Region, Check.NotNull(value, nameof(value))); + /// + /// The system name of the AWS region (e.g., us-east-1). Required for real AWS S3. + /// Optional when is configured for an S3-compatible service; + /// some services accept any value (or auto for Cloudflare R2). + /// + public string? Region { + get => _containerConfiguration.GetConfigurationOrDefault(AwsBlobProviderConfigurationNames.Region); + set + { + if (value == null) + { + _containerConfiguration.ClearConfiguration(AwsBlobProviderConfigurationNames.Region); + return; + } + _containerConfiguration.SetConfiguration(AwsBlobProviderConfigurationNames.Region, value); + } + } + + /// + /// Custom service URL for S3-compatible APIs (e.g., MinIO, DigitalOcean Spaces, Cloudflare R2). + /// When omitted, becomes required and the default AWS S3 endpoint for that + /// region is used. The AWS SDK automatically appends a trailing slash to the configured value. + /// + public string? ServiceURL { + get => _containerConfiguration.GetConfigurationOrDefault(AwsBlobProviderConfigurationNames.ServiceURL); + set + { + if (value == null) + { + _containerConfiguration.ClearConfiguration(AwsBlobProviderConfigurationNames.ServiceURL); + return; + } + _containerConfiguration.SetConfiguration(AwsBlobProviderConfigurationNames.ServiceURL, value); + } + } + + /// + /// When true, payload signing is disabled on PutObject upload requests so the SDK sends + /// x-amz-content-sha256: UNSIGNED-PAYLOAD instead of the streaming chunked signature + /// (STREAMING-AWS4-HMAC-SHA256-PAYLOAD) that AWS SDK v4 uses by default. Required for + /// Cloudflare R2 and other S3-compatible services that do not implement streaming signing. + /// The endpoint must be HTTPS when this option is enabled (AWS SDK rejects unsigned payloads + /// over HTTP). Default: false (keep AWS SDK defaults for real AWS S3 and MinIO). + /// + public bool DisablePayloadSigning { + get => _containerConfiguration.GetConfigurationOrDefault(AwsBlobProviderConfigurationNames.DisablePayloadSigning, false); + set => _containerConfiguration.SetConfiguration(AwsBlobProviderConfigurationNames.DisablePayloadSigning, value); } /// diff --git a/framework/src/Volo.Abp.BlobStoring.Aws/Volo/Abp/BlobStoring/Aws/AwsBlobProviderConfigurationNames.cs b/framework/src/Volo.Abp.BlobStoring.Aws/Volo/Abp/BlobStoring/Aws/AwsBlobProviderConfigurationNames.cs index d1b775c812..b30d6813ff 100644 --- a/framework/src/Volo.Abp.BlobStoring.Aws/Volo/Abp/BlobStoring/Aws/AwsBlobProviderConfigurationNames.cs +++ b/framework/src/Volo.Abp.BlobStoring.Aws/Volo/Abp/BlobStoring/Aws/AwsBlobProviderConfigurationNames.cs @@ -14,6 +14,8 @@ public static class AwsBlobProviderConfigurationNames public const string Name = "Aws.Name"; public const string Policy = "Aws.Policy"; public const string Region = "Aws.Region"; + public const string ServiceURL = "Aws.ServiceURL"; + public const string DisablePayloadSigning = "Aws.DisablePayloadSigning"; public const string ContainerName = "Aws.ContainerName"; public const string CreateContainerIfNotExists = "Aws.CreateContainerIfNotExists"; } diff --git a/framework/src/Volo.Abp.BlobStoring.Aws/Volo/Abp/BlobStoring/Aws/DefaultAmazonS3ClientFactory.cs b/framework/src/Volo.Abp.BlobStoring.Aws/Volo/Abp/BlobStoring/Aws/DefaultAmazonS3ClientFactory.cs index dee0bf5096..7d3d0b3869 100644 --- a/framework/src/Volo.Abp.BlobStoring.Aws/Volo/Abp/BlobStoring/Aws/DefaultAmazonS3ClientFactory.cs +++ b/framework/src/Volo.Abp.BlobStoring.Aws/Volo/Abp/BlobStoring/Aws/DefaultAmazonS3ClientFactory.cs @@ -30,31 +30,60 @@ public class DefaultAmazonS3ClientFactory : IAmazonS3ClientFactory, ITransientDe public virtual async Task GetAmazonS3Client( AwsBlobProviderConfiguration configuration) { - var region = RegionEndpoint.GetBySystemName(configuration.Region); + if (configuration.Region.IsNullOrWhiteSpace() && configuration.ServiceURL.IsNullOrWhiteSpace()) + { + throw new AbpException( + $"Either {nameof(AwsBlobProviderConfiguration.Region)} or {nameof(AwsBlobProviderConfiguration.ServiceURL)} must be configured on {nameof(AwsBlobProviderConfiguration)}."); + } + + var region = !configuration.Region.IsNullOrWhiteSpace() + ? RegionEndpoint.GetBySystemName(configuration.Region) + : null; + var clientConfig = await CreateS3ClientConfigAsync(configuration, region); if (configuration.UseCredentials) { var awsCredentials = GetAwsCredentials(configuration); return awsCredentials == null - ? new AmazonS3Client(region) - : new AmazonS3Client(awsCredentials, region); + ? new AmazonS3Client(clientConfig) + : new AmazonS3Client(awsCredentials, clientConfig); } if (configuration.UseTemporaryCredentials) { - return new AmazonS3Client(await GetTemporaryCredentialsAsync(configuration), region); + return new AmazonS3Client(await GetTemporaryCredentialsAsync(configuration), clientConfig); } if (configuration.UseTemporaryFederatedCredentials) { return new AmazonS3Client(await GetTemporaryFederatedCredentialsAsync(configuration), - region); + clientConfig); } Check.NotNullOrWhiteSpace(configuration.AccessKeyId, nameof(configuration.AccessKeyId)); Check.NotNullOrWhiteSpace(configuration.SecretAccessKey, nameof(configuration.SecretAccessKey)); - return new AmazonS3Client(configuration.AccessKeyId, configuration.SecretAccessKey, region); + return new AmazonS3Client(configuration.AccessKeyId, configuration.SecretAccessKey, clientConfig); + } + + protected virtual Task CreateS3ClientConfigAsync(AwsBlobProviderConfiguration configuration, RegionEndpoint? region) + { + var clientConfig = new AmazonS3Config(); + + if (region != null) + { + clientConfig.RegionEndpoint = region; + } + + if (!configuration.ServiceURL.IsNullOrWhiteSpace()) + { + clientConfig.ServiceURL = configuration.ServiceURL; + clientConfig.ForcePathStyle = true; + clientConfig.RequestChecksumCalculation = RequestChecksumCalculation.WHEN_REQUIRED; + clientConfig.ResponseChecksumValidation = ResponseChecksumValidation.WHEN_REQUIRED; + } + + return Task.FromResult(clientConfig); } protected virtual AWSCredentials? GetAwsCredentials( diff --git a/framework/test/Volo.Abp.BlobStoring.Aws.Tests/Volo/Abp/BlobStoring/Aws/AbpBlobStoringAwsTestModule.cs b/framework/test/Volo.Abp.BlobStoring.Aws.Tests/Volo/Abp/BlobStoring/Aws/AbpBlobStoringAwsTestModule.cs index 6edd0fc669..bd7b07c40c 100644 --- a/framework/test/Volo.Abp.BlobStoring.Aws.Tests/Volo/Abp/BlobStoring/Aws/AbpBlobStoringAwsTestModule.cs +++ b/framework/test/Volo.Abp.BlobStoring.Aws.Tests/Volo/Abp/BlobStoring/Aws/AbpBlobStoringAwsTestModule.cs @@ -1,11 +1,15 @@ using System; +using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using Amazon.S3; using Amazon.S3.Model; using Amazon.S3.Util; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; using Volo.Abp.Modularity; +using Volo.Abp.MultiTenancy; using Volo.Abp.Threading; namespace Volo.Abp.BlobStoring.Aws; @@ -28,9 +32,19 @@ public class AbpBlobStoringAwsTestModule : AbpModule { private const string UserSecretsId = "9f0d2c00-80c1-435b-bfab-2c39c8249091"; - private readonly string _randomContainerName = "abp-aws-test-container-" + Guid.NewGuid().ToString("N"); + private readonly string _runId = Guid.NewGuid().ToString("N"); + private readonly string _randomContainerName; + private readonly string _externalRunPrefix; private AwsBlobProviderConfiguration _configuration; + private string _actualContainerName; + private bool _externalContainer; + + public AbpBlobStoringAwsTestModule() + { + _randomContainerName = "abp-aws-test-container-" + _runId; + _externalRunPrefix = "abp-aws-test-run-" + _runId + "/"; + } public override void ConfigureServices(ServiceConfigurationContext context) { @@ -42,7 +56,30 @@ public class AbpBlobStoringAwsTestModule : AbpModule var configuration = context.Services.GetConfiguration(); var accessKeyId = configuration["Aws:AccessKeyId"]; var secretAccessKey = configuration["Aws:SecretAccessKey"]; + + // No credentials configured (e.g., CI without user secrets) → skip container wiring. + // `BlobContainerConfiguration.SetConfiguration` rejects nulls, so any attempt to resolve + // a container with null values from configuration would throw at runtime. + if (string.IsNullOrEmpty(accessKeyId) || string.IsNullOrEmpty(secretAccessKey)) + { + return; + } + var region = configuration["Aws:Region"]; + var serviceUrl = configuration["Aws:ServiceURL"]; + var disablePayloadSigning = bool.TryParse(configuration["Aws:DisablePayloadSigning"], out var dps) && dps; + var configuredContainerName = configuration["Aws:ContainerName"]; + var createContainerIfNotExists = !bool.TryParse(configuration["Aws:CreateContainerIfNotExists"], out var cci) || cci; + + _externalContainer = !string.IsNullOrWhiteSpace(configuredContainerName); + _actualContainerName = _externalContainer ? configuredContainerName : _randomContainerName; + + if (_externalContainer) + { + var prefix = _externalRunPrefix; + context.Services.Replace(ServiceDescriptor.Transient(sp => + new TestRunPrefixedAwsBlobNameCalculator(sp.GetRequiredService(), prefix))); + } Configure(options => { @@ -53,8 +90,10 @@ public class AbpBlobStoringAwsTestModule : AbpModule aws.AccessKeyId = accessKeyId; aws.SecretAccessKey = secretAccessKey; aws.Region = region; - aws.CreateContainerIfNotExists = true; - aws.ContainerName = _randomContainerName; + aws.ServiceURL = serviceUrl; + aws.DisablePayloadSigning = disablePayloadSigning; + aws.CreateContainerIfNotExists = createContainerIfNotExists; + aws.ContainerName = _actualContainerName; _configuration = aws; }); @@ -64,28 +103,107 @@ public class AbpBlobStoringAwsTestModule : AbpModule public override void OnApplicationShutdown(ApplicationShutdownContext context) { - AsyncHelper.RunSync(() => DeleteBucketAsync(context)); + AsyncHelper.RunSync(() => CleanupAsync(context)); } - private async Task DeleteBucketAsync(ApplicationShutdownContext context) + private async Task CleanupAsync(ApplicationShutdownContext context) { - var amazonS3Client = await context.ServiceProvider.GetRequiredService() - .GetAmazonS3Client(_configuration); + if (_configuration == null || + string.IsNullOrWhiteSpace(_configuration.AccessKeyId) || + string.IsNullOrWhiteSpace(_configuration.SecretAccessKey) || + (string.IsNullOrWhiteSpace(_configuration.Region) && string.IsNullOrWhiteSpace(_configuration.ServiceURL))) + { + return; + } + + try + { + using var amazonS3Client = await context.ServiceProvider.GetRequiredService() + .GetAmazonS3Client(_configuration); + + if (!await AmazonS3Util.DoesS3BucketExistV2Async(amazonS3Client, _actualContainerName)) + { + return; + } + + await DeleteObjectsAsync(amazonS3Client, _externalContainer ? _externalRunPrefix : null); + + if (!_externalContainer) + { + await amazonS3Client.DeleteBucketAsync(_actualContainerName); + } + } + catch + { + // Ignore errors during test cleanup + } + } - if (await AmazonS3Util.DoesS3BucketExistV2Async(amazonS3Client, _randomContainerName)) + private async Task DeleteObjectsAsync(AmazonS3Client client, string? prefix) + { + string? continuationToken = null; + do { - var blobs = await amazonS3Client.ListObjectsAsync(_randomContainerName); + var listResponse = await client.ListObjectsV2Async(new ListObjectsV2Request + { + BucketName = _actualContainerName, + Prefix = prefix, + ContinuationToken = continuationToken, + MaxKeys = 1000 + }); - if (blobs.S3Objects.Any()) + if (listResponse.S3Objects.Any()) { - await amazonS3Client.DeleteObjectsAsync(new DeleteObjectsRequest + foreach (var batch in Chunk(listResponse.S3Objects, 1000)) { - BucketName = _randomContainerName, - Objects = blobs.S3Objects.Select(o => new KeyVersion { Key = o.Key }).ToList() - }); + await client.DeleteObjectsAsync(new DeleteObjectsRequest + { + BucketName = _actualContainerName, + Objects = batch.Select(o => new KeyVersion { Key = o.Key }).ToList() + }); + } } - await amazonS3Client.DeleteBucketAsync(_randomContainerName); + continuationToken = listResponse.IsTruncated == true ? listResponse.NextContinuationToken : null; + } while (continuationToken != null); + } + + private static IEnumerable> Chunk(IEnumerable source, int size) + { + var bucket = new List(size); + foreach (var item in source) + { + bucket.Add(item); + if (bucket.Count == size) + { + yield return bucket; + bucket = new List(size); + } } + if (bucket.Count > 0) + { + yield return bucket; + } + } +} + +internal sealed class TestRunPrefixedAwsBlobNameCalculator : IAwsBlobNameCalculator +{ + private readonly ICurrentTenant _currentTenant; + private readonly string _runPrefix; + + public TestRunPrefixedAwsBlobNameCalculator(ICurrentTenant currentTenant, string runPrefix) + { + _currentTenant = currentTenant; + _runPrefix = runPrefix; + } + + public string Calculate(BlobProviderArgs args) + { + var baseName = _currentTenant.Id == null + ? $"host/{args.BlobName}" + : $"tenants/{_currentTenant.Id.Value:D}/{args.BlobName}"; + + return _runPrefix + baseName; } } diff --git a/framework/test/Volo.Abp.BlobStoring.Aws.Tests/Volo/Abp/BlobStoring/Aws/AwsBlobContainer_Tests.cs b/framework/test/Volo.Abp.BlobStoring.Aws.Tests/Volo/Abp/BlobStoring/Aws/AwsBlobContainer_Tests.cs index db1aef2be2..a750bb5678 100644 --- a/framework/test/Volo.Abp.BlobStoring.Aws.Tests/Volo/Abp/BlobStoring/Aws/AwsBlobContainer_Tests.cs +++ b/framework/test/Volo.Abp.BlobStoring.Aws.Tests/Volo/Abp/BlobStoring/Aws/AwsBlobContainer_Tests.cs @@ -1 +1,25 @@ -namespace Volo.Abp.BlobStoring.Aws; +namespace Volo.Abp.BlobStoring.Aws; + +/* + To run this integration test, populate user secrets (id `9f0d2c00-80c1-435b-bfab-2c39c8249091`) + with credentials for real AWS S3 or an S3-compatible service (Cloudflare R2, MinIO, etc.): + + { + "Aws:AccessKeyId": "...", + "Aws:SecretAccessKey": "...", + "Aws:Region": "us-east-1", // "auto" for Cloudflare R2 + "Aws:ServiceURL": "", // e.g. https://.r2.cloudflarestorage.com for R2, http://localhost:9000 for MinIO + "Aws:DisablePayloadSigning": "false", // set "true" for R2 (requires HTTPS); leave "false" for MinIO and AWS S3 + "Aws:ContainerName": "", // leave empty to let the test create a unique bucket; set to a pre-existing bucket for services that disallow bucket creation + "Aws:CreateContainerIfNotExists": "true" // set "false" when using an existing bucket + } + + When `ContainerName` is supplied, the test prefixes every object with `abp-aws-test-run-{guid}/` + so cleanup deletes only what this run created (the bucket itself is left untouched). + + Then uncomment the class below: + +public class AwsBlobContainer_Tests : BlobContainer_Tests +{ +} +*/ diff --git a/framework/test/Volo.Abp.BlobStoring.Aws.Tests/Volo/Abp/BlobStoring/Aws/AwsBlobProviderConfiguration_Tests.cs b/framework/test/Volo.Abp.BlobStoring.Aws.Tests/Volo/Abp/BlobStoring/Aws/AwsBlobProviderConfiguration_Tests.cs new file mode 100644 index 0000000000..a48c7cb6be --- /dev/null +++ b/framework/test/Volo.Abp.BlobStoring.Aws.Tests/Volo/Abp/BlobStoring/Aws/AwsBlobProviderConfiguration_Tests.cs @@ -0,0 +1,72 @@ +using Shouldly; +using Xunit; + +namespace Volo.Abp.BlobStoring.Aws; + +public class AwsBlobProviderConfiguration_Tests : AbpBlobStoringAwsTestCommonBase +{ + [Fact] + public void Should_Set_And_Get_ServiceURL() + { + // Arrange + var containerConfiguration = new BlobContainerConfiguration(); + var awsConfiguration = new AwsBlobProviderConfiguration(containerConfiguration); + const string serviceUrl = "https://minio.example.com:9000"; + + // Act + awsConfiguration.ServiceURL = serviceUrl; + + // Assert + awsConfiguration.ServiceURL.ShouldBe(serviceUrl); + } + + [Fact] + public void Should_Return_Null_When_ServiceURL_Not_Set() + { + // Arrange + var containerConfiguration = new BlobContainerConfiguration(); + var awsConfiguration = new AwsBlobProviderConfiguration(containerConfiguration); + + // Act & Assert + awsConfiguration.ServiceURL.ShouldBeNull(); + } + + [Fact] + public void Should_Configure_ServiceURL_Using_UseAws_Extension() + { + // Arrange + var containerConfiguration = new BlobContainerConfiguration(); + const string serviceUrl = "https://spaces.digitalocean.com"; + + // Act + containerConfiguration.UseAws(config => + { + config.ServiceURL = serviceUrl; + config.Region = "us-east-1"; + }); + + // Assert + var awsConfig = containerConfiguration.GetAwsConfiguration(); + awsConfig.ServiceURL.ShouldBe(serviceUrl); + } + + [Fact] + public void Should_Default_DisablePayloadSigning_To_False() + { + var containerConfiguration = new BlobContainerConfiguration(); + var awsConfiguration = new AwsBlobProviderConfiguration(containerConfiguration); + + awsConfiguration.DisablePayloadSigning.ShouldBeFalse(); + } + + [Fact] + public void Should_Set_And_Get_DisablePayloadSigning() + { + var containerConfiguration = new BlobContainerConfiguration(); + var awsConfiguration = new AwsBlobProviderConfiguration(containerConfiguration); + + awsConfiguration.DisablePayloadSigning = true; + + awsConfiguration.DisablePayloadSigning.ShouldBeTrue(); + } +} diff --git a/framework/test/Volo.Abp.BlobStoring.Aws.Tests/Volo/Abp/BlobStoring/Aws/DefaultAmazonS3ClientFactory_Tests.cs b/framework/test/Volo.Abp.BlobStoring.Aws.Tests/Volo/Abp/BlobStoring/Aws/DefaultAmazonS3ClientFactory_Tests.cs new file mode 100644 index 0000000000..e4886f602b --- /dev/null +++ b/framework/test/Volo.Abp.BlobStoring.Aws.Tests/Volo/Abp/BlobStoring/Aws/DefaultAmazonS3ClientFactory_Tests.cs @@ -0,0 +1,114 @@ +using System.Threading.Tasks; +using Amazon.Runtime; +using Amazon.S3; +using Microsoft.Extensions.DependencyInjection; +using Shouldly; +using Xunit; + +namespace Volo.Abp.BlobStoring.Aws; + +public class DefaultAmazonS3ClientFactory_Tests : AbpBlobStoringAwsTestBase +{ + private readonly IAmazonS3ClientFactory _amazonS3ClientFactory; + + public DefaultAmazonS3ClientFactory_Tests() + { + _amazonS3ClientFactory = GetRequiredService(); + } + + [Fact] + public async Task Should_Create_S3Client_With_Custom_ServiceURL() + { + // Arrange + var containerConfiguration = new BlobContainerConfiguration(); + const string serviceUrl = "https://minio.example.com:9000"; + + var awsConfiguration = new AwsBlobProviderConfiguration(containerConfiguration) + { + AccessKeyId = "test-access-key", + SecretAccessKey = "test-secret-key", + Region = "us-east-1", + ServiceURL = serviceUrl + }; + + // Act + using var s3Client = await _amazonS3ClientFactory.GetAmazonS3Client(awsConfiguration); + + // Assert + s3Client.ShouldNotBeNull(); + s3Client.Config.ServiceURL?.TrimEnd('/').ShouldBe(serviceUrl); + ((AmazonS3Config)s3Client.Config).ForcePathStyle.ShouldBeTrue(); + } + + [Fact] + public async Task Should_Create_S3Client_Without_Custom_ServiceURL() + { + // Arrange + var containerConfiguration = new BlobContainerConfiguration(); + + var awsConfiguration = new AwsBlobProviderConfiguration(containerConfiguration) + { + AccessKeyId = "test-access-key", + SecretAccessKey = "test-secret-key", + Region = "us-east-1" + // ServiceURL not set + }; + + // Act + using var s3Client = await _amazonS3ClientFactory.GetAmazonS3Client(awsConfiguration); + + // Assert + s3Client.ShouldNotBeNull(); + s3Client.Config.ServiceURL.ShouldBeNull(); // Should use default AWS S3 service + ((AmazonS3Config)s3Client.Config).ForcePathStyle.ShouldBeFalse(); // Should be false for AWS S3 + } + + [Fact] + public async Task Should_Create_S3Client_Without_Region_For_S3Compatible_Services() + { + // Arrange + var containerConfiguration = new BlobContainerConfiguration(); + + var awsConfiguration = new AwsBlobProviderConfiguration(containerConfiguration) + { + AccessKeyId = "test-access-key", + SecretAccessKey = "test-secret-key", + ServiceURL = "https://minio.example.com:9000" + // Region not set - should work for S3-compatible services + }; + + // Act + using var s3Client = await _amazonS3ClientFactory.GetAmazonS3Client(awsConfiguration); + + // Assert + s3Client.ShouldNotBeNull(); + s3Client.Config.ServiceURL?.TrimEnd('/').ShouldBe("https://minio.example.com:9000"); + ((AmazonS3Config)s3Client.Config).ForcePathStyle.ShouldBeTrue(); + } + + [Fact] + public async Task Should_Set_Checksum_Properties_For_S3Compatible_Services() + { + // Arrange + var containerConfiguration = new BlobContainerConfiguration(); + + var awsConfiguration = new AwsBlobProviderConfiguration(containerConfiguration) + { + AccessKeyId = "test-access-key", + SecretAccessKey = "test-secret-key", + ServiceURL = "https://r2.cloudflarestorage.com", + Region = "auto" + }; + + // Act + using var s3Client = await _amazonS3ClientFactory.GetAmazonS3Client(awsConfiguration); + + // Assert + s3Client.ShouldNotBeNull(); + var config = (AmazonS3Config)s3Client.Config; + config.ServiceURL?.TrimEnd('/').ShouldBe("https://r2.cloudflarestorage.com"); + config.ForcePathStyle.ShouldBeTrue(); + config.RequestChecksumCalculation.ShouldBe(RequestChecksumCalculation.WHEN_REQUIRED); + config.ResponseChecksumValidation.ShouldBe(ResponseChecksumValidation.WHEN_REQUIRED); + } +}