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 @@
+