diff --git a/.github/workflows/integration_tests.yml b/.github/workflows/integration_tests.yml deleted file mode 100644 index 65b45514730..00000000000 --- a/.github/workflows/integration_tests.yml +++ /dev/null @@ -1,67 +0,0 @@ -name: Integration Tests - -on: - # Manual trigger. - workflow_dispatch: - pull_request_review: - types: [submitted] - # Run it on main and release pushes too, in case we merge from a branch that's not up-to-date with the target branch - # and breaks something after merge (or if we push to main). - push: - paths-ignore: - - '**/*.md' - - 'mkdocs.yml' - - 'src/docs/**/*' - branches: [ main, release/** ] - -env: - DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true - DOTNET_CLI_TELEMETRY_OPTOUT: true - -permissions: - contents: read - -jobs: - test_s3: - name: Integration Tests - S3 - if: github.event_name == 'workflow_dispatch' || - github.event_name == 'push' || - github.event.review.state == 'APPROVED' || - github.event.review.state == 'CHANGES_REQUESTED' - runs-on: ubuntu-24.04 - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - uses: ./.github/actions/setup-dotnet - - name: Build - run: | - dotnet build ./test/OrchardCore.Tests.Integration/OrchardCore.Tests.Integration.csproj -c Release /p:NuGetAudit=false - - name: Cache S3Mock Docker image - id: cache-s3mock - uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 - with: - path: /tmp/s3mock-image.tar - key: docker-s3mock-latest - - name: Load or pull S3Mock image - run: | - if [ -f /tmp/s3mock-image.tar ]; then - docker load -i /tmp/s3mock-image.tar - else - docker pull adobe/s3mock - docker save adobe/s3mock -o /tmp/s3mock-image.tar - fi - - name: Start S3Mock - run: | - docker run -d --name s3mock -p 9090:9090 adobe/s3mock - echo "Waiting for S3Mock to be ready..." - for i in $(seq 1 30); do - if curl -sf http://127.0.0.1:9090/ > /dev/null 2>&1; then - echo "S3Mock is ready." - break - fi - sleep 1 - done - - name: S3 Integration Tests - env: - S3_EMULATOR_URL: http://127.0.0.1:9090 - run: | - dotnet test --project ./test/OrchardCore.Tests.Integration/OrchardCore.Tests.Integration.csproj -c Release --no-build diff --git a/.github/workflows/main_ci.yml b/.github/workflows/main_ci.yml index 4d275836593..9c22de2b284 100644 --- a/.github/workflows/main_ci.yml +++ b/.github/workflows/main_ci.yml @@ -63,3 +63,7 @@ jobs: src/OrchardCore.Mvc.Web/App_Data_Tests/logs test/OrchardCore.Tests.Functional/traces retention-days: 3 + - name: Integration Tests + if: matrix.os == 'ubuntu-24.04' + run: | + dotnet test --project ./test/OrchardCore.Tests.Integration/OrchardCore.Tests.Integration.csproj -c Release --no-build diff --git a/.github/workflows/pr_ci.yml b/.github/workflows/pr_ci.yml index 154e251ca97..7c98d5731f0 100644 --- a/.github/workflows/pr_ci.yml +++ b/.github/workflows/pr_ci.yml @@ -59,3 +59,7 @@ jobs: src/OrchardCore.Mvc.Web/App_Data_Tests/logs test/OrchardCore.Tests.Functional/traces retention-days: 3 + - name: Integration Tests + if: matrix.os == 'ubuntu-24.04' + run: | + dotnet test --project ./test/OrchardCore.Tests.Integration/OrchardCore.Tests.Integration.csproj -c Release --no-build diff --git a/.github/workflows/preview_ci.yml b/.github/workflows/preview_ci.yml index 6698339c952..86269786d57 100644 --- a/.github/workflows/preview_ci.yml +++ b/.github/workflows/preview_ci.yml @@ -66,6 +66,10 @@ jobs: src/OrchardCore.Mvc.Web/App_Data_Tests/logs test/OrchardCore.Tests.Functional/traces retention-days: 3 + - name: Integration Tests + if: matrix.os == 'ubuntu-24.04' + run: | + dotnet test --project ./test/OrchardCore.Tests.Integration/OrchardCore.Tests.Integration.csproj -c Release --no-build - name: Deploy preview NuGet packages if: steps.check-publish.outputs.should-publish == 'true' run: | diff --git a/Directory.Packages.props b/Directory.Packages.props index a78878dd3f8..5a223160524 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -64,6 +64,7 @@ + @@ -110,4 +111,4 @@ - + \ No newline at end of file diff --git a/test/OrchardCore.Tests.Integration/AmazonS3/AwsFileStoreTests.cs b/test/OrchardCore.Tests.Integration/AmazonS3/AwsFileStoreTests.cs index 1d935457f8d..cf1e837ecb6 100644 --- a/test/OrchardCore.Tests.Integration/AmazonS3/AwsFileStoreTests.cs +++ b/test/OrchardCore.Tests.Integration/AmazonS3/AwsFileStoreTests.cs @@ -1,4 +1,3 @@ -using Amazon; using Amazon.Runtime; using Amazon.S3; using Amazon.S3.Model; @@ -6,38 +5,37 @@ using OrchardCore.FileStorage; using OrchardCore.FileStorage.AmazonS3; using OrchardCore.Modules; +using Testcontainers.LocalStack; using Xunit; namespace OrchardCore.Tests.Integration.AmazonS3; -/// -/// Integration tests for that run against an S3-compatible emulator (e.g. Adobe S3Mock). -/// Set the S3_EMULATOR_URL environment variable (e.g. http://127.0.0.1:9090) to run these tests. -/// -public sealed class AwsFileStoreTests : IAsyncLifetime +public class AwsFileStoreTests : IAsyncLifetime { - private const string EnvVar = "S3_EMULATOR_URL"; - - private readonly ITestOutputHelper _output; private AwsFileStore _store; private AmazonS3Client _s3Client; private string _bucketName; - public AwsFileStoreTests(ITestOutputHelper output) => _output = output; + private readonly LocalStackContainer _localStackContainer = new LocalStackBuilder("localstack/localstack:2.0") + .Build(); - private static string GetServiceUrl() - => System.Environment.GetEnvironmentVariable(EnvVar); + static AwsFileStoreTests() + { + System.Environment.SetEnvironmentVariable("AWS_ACCESS_KEY_ID", CommonCredentials.AwsAccessKey); + System.Environment.SetEnvironmentVariable("AWS_SECRET_ACCESS_KEY", CommonCredentials.AwsSecretKey); + } public async ValueTask InitializeAsync() { - var serviceUrl = GetServiceUrl(); + await _localStackContainer.StartAsync() + .ConfigureAwait(false); + _bucketName = $"test-{Guid.NewGuid():N}"; _s3Client = new AmazonS3Client( - new BasicAWSCredentials("test", "test"), new AmazonS3Config { - ServiceURL = serviceUrl, + ServiceURL = _localStackContainer.GetConnectionString(), ForcePathStyle = true, AuthenticationRegion = "us-east-1", RequestChecksumCalculation = RequestChecksumCalculation.WHEN_REQUIRED, @@ -56,39 +54,6 @@ public async ValueTask InitializeAsync() _store = new AwsFileStore(clock, options, _s3Client); } - public async ValueTask DisposeAsync() - { - if (_s3Client is not null && _bucketName is not null) - { - try - { - // Delete all objects first (S3 requires empty bucket before deletion). - var listResponse = await _s3Client.ListObjectsV2Async(new ListObjectsV2Request - { - BucketName = _bucketName, - }); - - if (listResponse.S3Objects.Count > 0) - { - await _s3Client.DeleteObjectsAsync(new DeleteObjectsRequest - { - BucketName = _bucketName, - Objects = listResponse.S3Objects.Select(o => new KeyVersion { Key = o.Key }).ToList(), - }); - } - - await _s3Client.DeleteBucketAsync(_bucketName); - } - catch (Exception ex) - { - _output.WriteLine($"Best effort cleanup failed: {ex.Message}"); - } - - _s3Client.Dispose(); - } - - } - private async Task CreateTestFileAsync(string path, string content = "test content") { using var stream = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(content)); @@ -104,14 +69,14 @@ private async Task ReadFileContentAsync(string path) // -- File operations -- - [S3MockFact] + [Fact] public async Task CreateFile_ReturnsPath() { var result = await CreateTestFileAsync("folder/file.txt"); Assert.Equal("folder/file.txt", result); } - [S3MockFact] + [Fact] public async Task GetFileInfo_ReturnsCorrectMetadata() { var content = "hello world"; @@ -126,14 +91,14 @@ public async Task GetFileInfo_ReturnsCorrectMetadata() Assert.False(info.IsDirectory); } - [S3MockFact] + [Fact] public async Task GetFileInfo_NonExistent_ReturnsNull() { var info = await _store.GetFileInfoAsync("does-not-exist.txt"); Assert.Null(info); } - [S3MockFact] + [Fact] public async Task GetFileStream_ReturnsContent() { var expected = "stream content test"; @@ -144,7 +109,7 @@ public async Task GetFileStream_ReturnsContent() Assert.Equal(expected, actual); } - [S3MockFact] + [Fact] public async Task DeleteFile_ReturnsTrue() { await CreateTestFileAsync("delete-me.txt"); @@ -155,7 +120,7 @@ public async Task DeleteFile_ReturnsTrue() Assert.Null(await _store.GetFileInfoAsync("delete-me.txt")); } - [S3MockFact] + [Fact] public async Task CopyFile_CreatesNewFile() { var content = "copy me"; @@ -167,7 +132,7 @@ public async Task CopyFile_CreatesNewFile() Assert.Equal(content, await ReadFileContentAsync("copied.txt")); } - [S3MockFact] + [Fact] public async Task CopyFile_SamePath_Throws() { await CreateTestFileAsync("same.txt"); @@ -176,14 +141,14 @@ await Assert.ThrowsAnyAsync( () => _store.CopyFileAsync("same.txt", "same.txt")); } - [S3MockFact] + [Fact] public async Task CopyFile_SourceNotFound_Throws() { await Assert.ThrowsAsync( () => _store.CopyFileAsync("ghost.txt", "dest.txt")); } - [S3MockFact] + [Fact] public async Task CreateFile_OverwriteTrue_Succeeds() { await CreateTestFileAsync("overwrite.txt", "v1"); @@ -195,7 +160,7 @@ public async Task CreateFile_OverwriteTrue_Succeeds() Assert.Equal("v2", content); } - [S3MockFact] + [Fact] public async Task CreateFile_OverwriteFalse_Throws() { await CreateTestFileAsync("no-overwrite.txt"); @@ -207,7 +172,7 @@ await Assert.ThrowsAnyAsync( // -- Directory operations -- - [S3MockFact] + [Fact] public async Task GetDirectoryInfo_Root_ReturnsEntry() { var info = await _store.GetDirectoryInfoAsync(string.Empty); @@ -216,7 +181,7 @@ public async Task GetDirectoryInfo_Root_ReturnsEntry() Assert.True(info.IsDirectory); } - [S3MockFact] + [Fact] public async Task GetDirectoryInfo_Existing_ReturnsEntry() { await _store.TryCreateDirectoryAsync("my-folder"); @@ -227,14 +192,14 @@ public async Task GetDirectoryInfo_Existing_ReturnsEntry() Assert.True(info.IsDirectory); } - [S3MockFact] + [Fact] public async Task GetDirectoryInfo_NonExistent_ReturnsNull() { var info = await _store.GetDirectoryInfoAsync("no-such-folder"); Assert.Null(info); } - [S3MockFact] + [Fact] public async Task CreateDirectory_Succeeds() { var result = await _store.TryCreateDirectoryAsync("new-dir"); @@ -245,7 +210,7 @@ public async Task CreateDirectory_Succeeds() Assert.NotNull(info); } - [S3MockFact] + [Fact] public async Task DeleteDirectory_WithContents_DeletesAll() { await _store.TryCreateDirectoryAsync("dir-to-delete"); @@ -259,7 +224,7 @@ public async Task DeleteDirectory_WithContents_DeletesAll() Assert.Null(await _store.GetFileInfoAsync("dir-to-delete/file2.txt")); } - [S3MockFact] + [Fact] public async Task DeleteDirectory_Root_Throws() { await Assert.ThrowsAsync( @@ -268,7 +233,7 @@ await Assert.ThrowsAsync( // -- Move -- - [S3MockFact] + [Fact] public async Task MoveFile_MovesToNewPath() { var content = "move me"; @@ -280,7 +245,7 @@ public async Task MoveFile_MovesToNewPath() Assert.Equal(content, await ReadFileContentAsync("dst.txt")); } - [S3MockFact] + [Fact] public async Task MoveFile_AcrossDirectories() { await _store.TryCreateDirectoryAsync("dir-a"); @@ -295,7 +260,7 @@ public async Task MoveFile_AcrossDirectories() // -- Directory content listing -- - [S3MockFact] + [Fact] public async Task GetDirectoryContent_ListsFilesAndDirs() { await CreateTestFileAsync("root-file.txt"); @@ -311,23 +276,18 @@ public async Task GetDirectoryContent_ListsFilesAndDirs() Assert.Contains(entries, e => e.Name == "root-file.txt" && !e.IsDirectory); Assert.Contains(entries, e => e.Name == "sub-dir" && e.IsDirectory); } -} -/// -/// Skips the test when the S3 emulator URL is not configured. -/// Set the S3_EMULATOR_URL environment variable to run these tests. -/// -[AttributeUsage(AttributeTargets.Method, AllowMultiple = false)] -internal sealed class S3MockFactAttribute : FactAttribute -{ - public S3MockFactAttribute( - [System.Runtime.CompilerServices.CallerFilePath] string sourceFilePath = null, - [System.Runtime.CompilerServices.CallerLineNumber] int sourceLineNumber = -1) - : base(sourceFilePath, sourceLineNumber) + public async ValueTask DisposeAsync() { - if (string.IsNullOrEmpty(System.Environment.GetEnvironmentVariable("S3_EMULATOR_URL"))) - { - Skip = "S3 emulator is not configured. Set S3_EMULATOR_URL to run this test."; - } + await _localStackContainer.StopAsync(); + + GC.SuppressFinalize(this); } + + //public sealed class LocalStackDefaultConfiguration : AwsFileStoreTests + //{ + // public LocalStackDefaultConfiguration() : base(new LocalStackBuilder(TestSession.GetImageFromDockerfile()).Build()) + // { + // } + //} } diff --git a/test/OrchardCore.Tests.Integration/AmazonS3/CommonCredentials.cs b/test/OrchardCore.Tests.Integration/AmazonS3/CommonCredentials.cs new file mode 100644 index 00000000000..2ad41e9615d --- /dev/null +++ b/test/OrchardCore.Tests.Integration/AmazonS3/CommonCredentials.cs @@ -0,0 +1,8 @@ +namespace OrchardCore.Tests.Integration.AmazonS3; + +public static class CommonCredentials +{ + public const string AwsAccessKey = "AKIAIOSFODNN7EXAMPLE"; + + public const string AwsSecretKey = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"; +} diff --git a/test/OrchardCore.Tests.Integration/OrchardCore.Tests.Integration.csproj b/test/OrchardCore.Tests.Integration/OrchardCore.Tests.Integration.csproj index 7c8203ca812..f74a98ecfc2 100644 --- a/test/OrchardCore.Tests.Integration/OrchardCore.Tests.Integration.csproj +++ b/test/OrchardCore.Tests.Integration/OrchardCore.Tests.Integration.csproj @@ -13,6 +13,7 @@ +