diff --git a/AGENTS.md b/AGENTS.md index fe02504..4065728 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -77,7 +77,7 @@ Both repositories use raw SQL via Dapper. They differ in naming conventions: Single test project: `tests/AspNetCore.DataProtection.CustomStorage.Tests` (xUnit v3, FluentAssertions, NSubstitute, Bogus). - `StorageWrapperTests`, `DeletableStorageWrapperTests` and `DapperDataProtectionExtensionsTests` are pure unit tests. `DataProtectionStorageExtensionsTests` runs the real DI container and `XmlKeyManager` against in-memory storages. -- `SQLServer/` and `PostgreSQL/` tests share one xUnit v3 assembly fixture per provider (`*ContainerFixture`, `[assembly: AssemblyFixture]`) that starts a Testcontainers database, calls `InitializeDb()` with the default config, and exposes `ResetDatabaseAsync()` (Respawn) to clear the default table between tests. Respawn captures its table list at fixture start, so tests that create their own tables are unaffected; any test that does not reset must use its own table. +- `SQLServer/` and `PostgreSQL/` tests share one xUnit collection fixture per provider (`*ContainerFixture` via `[Collection(nameof(*Collection))]`; not an assembly fixture, which xUnit v3 starts on every run, even unit-only ones) that starts a Testcontainers database, calls `InitializeDb()` with the default config, and exposes `ResetDatabaseAsync()` (Respawn) to clear the default table between tests. Respawn captures its table list at fixture start, so tests that create their own tables are unaffected; any test that does not reset must use its own table (`*ContainerFixture.NewTableName()`). - `*EndToEndTests` wire each provider through the public extension methods and a real DI container (`AddDataProtection().PersistKeysWithDapperIn...()`, `UseDapperDataProtection()`), then protect/unprotect, including across two service providers sharing one table, and delete keys through `XmlKeyManager.DeleteKeys`. Each test uses its own table, so it does not depend on Respawn. ### CI/CD (`.github/workflows`) diff --git a/tests/AspNetCore.DataProtection.CustomStorage.Tests/PostgreSQL/PostgreSqlContainerFixture.cs b/tests/AspNetCore.DataProtection.CustomStorage.Tests/PostgreSQL/PostgreSqlContainerFixture.cs index 33b313c..bf0c532 100644 --- a/tests/AspNetCore.DataProtection.CustomStorage.Tests/PostgreSQL/PostgreSqlContainerFixture.cs +++ b/tests/AspNetCore.DataProtection.CustomStorage.Tests/PostgreSQL/PostgreSqlContainerFixture.cs @@ -1,4 +1,5 @@ -using System.Threading.Tasks; +using System; +using System.Threading.Tasks; using AspNetCore.DataProtection.CustomStorage.Dapper; using AspNetCore.DataProtection.CustomStorage.Dapper.PostgreSQL; using DotNet.Testcontainers.Builders; @@ -9,11 +10,16 @@ using Testcontainers.PostgreSql; using Xunit; -// One container for the whole assembly: tests that do not use the default table work on their own tables. -[assembly: AssemblyFixture(typeof(AspNetCore.DataProtection.CustomStorage.Tests.PostgreSQL.PostgreSqlContainerFixture))] - namespace AspNetCore.DataProtection.CustomStorage.Tests.PostgreSQL; +/// +/// Shares one container among the PostgreSQL test classes. A collection fixture, unlike an assembly fixture, +/// is created only when tests of the collection are selected, so unit-only runs do not need Docker. +/// Tests that do not use the default table work on their own tables. +/// +[CollectionDefinition(nameof(PostgreSqlCollection))] +public class PostgreSqlCollection : ICollectionFixture; + public class PostgreSqlContainerFixture : IAsyncLifetime { private readonly PostgreSqlContainer _dbContainer = new PostgreSqlBuilder("postgres:16.4") @@ -27,6 +33,11 @@ public class PostgreSqlContainerFixture : IAsyncLifetime private Respawner _respawner = null!; + /// + /// Returns a unique table name, for tests that must not share the default table. + /// + public static string NewTableName() => $"keys_{Guid.NewGuid().ToString("N")[..12]}"; + public PostgreSqlContainerFixture() { Options.Value.Returns(new DapperDataProtectionConfig() diff --git a/tests/AspNetCore.DataProtection.CustomStorage.Tests/PostgreSQL/PostgreSqlDataProtectionRepositoryTests.cs b/tests/AspNetCore.DataProtection.CustomStorage.Tests/PostgreSQL/PostgreSqlDataProtectionRepositoryTests.cs index d894a1a..eb334a9 100644 --- a/tests/AspNetCore.DataProtection.CustomStorage.Tests/PostgreSQL/PostgreSqlDataProtectionRepositoryTests.cs +++ b/tests/AspNetCore.DataProtection.CustomStorage.Tests/PostgreSQL/PostgreSqlDataProtectionRepositoryTests.cs @@ -12,6 +12,7 @@ using Xunit; namespace AspNetCore.DataProtection.CustomStorage.Tests.PostgreSQL; +[Collection(nameof(PostgreSqlCollection))] public class PostgreSqlDataProtectionRepositoryTests { private readonly PostgreSqlContainerFixture _fixture; @@ -123,7 +124,7 @@ public async Task GetAllAsync_MatchNamesWithUnderscoresDisabled_MapsAllColumns() public void InitializeDb_CustomSchemaAndTable_PersistsKeysInConfiguredTable() { const string schemaName = "custom_keys"; - var tableName = NewTableName(); + var tableName = PostgreSqlContainerFixture.NewTableName(); using (var connection = _fixture.NpgsqlDataSource.OpenConnection()) { connection.Execute($"CREATE SCHEMA IF NOT EXISTS {schemaName};"); @@ -150,7 +151,7 @@ public async Task InitializeDb_ConcurrentCallsOnNewTable_AllSucceed() // Several rounds, because a single round does not always hit the race. for (var round = 0; round < 5; round++) { - var tableName = NewTableName(); + var tableName = PostgreSqlContainerFixture.NewTableName(); // One repository per call, as on separate nodes starting at the same time. var suts = Enumerable.Range(0, callCount) .Select(_ => CreateSut(Constants.DefaultSchema, tableName)) @@ -176,7 +177,7 @@ public async Task InitializeDb_ConcurrentCallsOnNewTable_AllSucceed() public void InitializeDb_SchemaMissing_CreatesSchemaAndTable() { var schemaName = $"schema_{Guid.NewGuid().ToString("N")[..12]}"; - var tableName = NewTableName(); + var tableName = PostgreSqlContainerFixture.NewTableName(); var sut = CreateSut(schemaName, tableName); sut.InitializeDb(); @@ -191,7 +192,7 @@ public void InitializeDb_ReservedWordSchemaName_PersistsKeys() { // A reserved word is accepted unquoted after a schema qualifier, but not as the schema name itself. const string schemaName = "order"; - var tableName = NewTableName(); + var tableName = PostgreSqlContainerFixture.NewTableName(); var sut = CreateSut(schemaName, tableName); var key = new DataProtectionKey { FriendlyName = "ReservedWordKey", Xml = "Xml" }; @@ -253,7 +254,7 @@ public async Task Delete_KeyAlreadyDeleted_Succeeds() [Fact] public void Delete_LaterDeletionFails_RollsBackEarlierDeletions() { - var tableName = NewTableName(); + var tableName = PostgreSqlContainerFixture.NewTableName(); var sut = CreateSut(Constants.DefaultSchema, tableName); sut.InitializeDb(); sut.Insert(new DataProtectionKey { FriendlyName = "DeleteFirst", Xml = "xml1" }); @@ -278,8 +279,6 @@ CREATE TRIGGER tr_{tableName}_fail_delete BEFORE DELETE ON {Constants.DefaultSch sut.GetAll().Select(x => x.FriendlyName).Should().BeEquivalentTo("DeleteFirst", "DeleteFails"); } - private static string NewTableName() => $"keys_{Guid.NewGuid().ToString("N")[..12]}"; - private PostgreSQLDataProtectionRepository CreateSut(string schemaName, string tableName) { var options = Substitute.For>(); diff --git a/tests/AspNetCore.DataProtection.CustomStorage.Tests/PostgreSQL/PostgreSqlEndToEndTests.cs b/tests/AspNetCore.DataProtection.CustomStorage.Tests/PostgreSQL/PostgreSqlEndToEndTests.cs index cbdb3b6..92da33f 100644 --- a/tests/AspNetCore.DataProtection.CustomStorage.Tests/PostgreSQL/PostgreSqlEndToEndTests.cs +++ b/tests/AspNetCore.DataProtection.CustomStorage.Tests/PostgreSQL/PostgreSqlEndToEndTests.cs @@ -18,6 +18,7 @@ namespace AspNetCore.DataProtection.CustomStorage.Tests.PostgreSQL; /// /// End-to-end tests that wire the PostgreSQL provider through the public extension methods and real DI. /// +[Collection(nameof(PostgreSqlCollection))] public class PostgreSqlEndToEndTests { private const string ApplicationName = "AspNetCore.DataProtection.CustomStorage.Tests"; @@ -32,7 +33,7 @@ public PostgreSqlEndToEndTests(PostgreSqlContainerFixture fixture) [Fact] public void Protect_UnprotectWithSameServiceProvider_RoundTrips() { - var tableName = NewTableName(); + var tableName = PostgreSqlContainerFixture.NewTableName(); using var services = BuildServiceProvider(tableName); var protector = services.GetRequiredService().CreateProtector(Purpose); @@ -45,7 +46,7 @@ public void Protect_UnprotectWithSameServiceProvider_RoundTrips() [Fact] public void Protect_UnprotectWithSecondServiceProviderSharingDatabase_RoundTrips() { - var tableName = NewTableName(); + var tableName = PostgreSqlContainerFixture.NewTableName(); using var first = BuildServiceProvider(tableName); using var second = BuildServiceProvider(tableName); @@ -60,7 +61,7 @@ public void Protect_UnprotectWithSecondServiceProviderSharingDatabase_RoundTrips [Fact] public void DeleteKeys_KeySelected_DeletesItFromTable() { - var tableName = NewTableName(); + var tableName = PostgreSqlContainerFixture.NewTableName(); using var services = BuildServiceProvider(tableName); // XmlKeyManager exposes DeleteKeys but does not implement IDeletableKeyManager. var keyManager = (XmlKeyManager)services.GetRequiredService(); @@ -79,7 +80,7 @@ public void DeleteKeys_KeySelected_DeletesItFromTable() [InlineData(false)] public void Protect_ApplicationRegistersOwnDataSource_DataSourcesStaySeparate(bool applicationRegistersFirst) { - var tableName = NewTableName(); + var tableName = PostgreSqlContainerFixture.NewTableName(); // The application data source targets a database that does not exist, so that any key // operation routed through it fails. var applicationConnectionString = new NpgsqlConnectionStringBuilder(_fixture.ConnectionString) @@ -110,8 +111,6 @@ public void Protect_MissingReservedWordSchemaAndTable_StoresKeysInConfiguredTabl } // Names must be lowercase; keep the name short enough for derived index names. - private static string NewTableName() => $"keys_{Guid.NewGuid().ToString("N")[..12]}"; - private ServiceProvider BuildServiceProvider(string tableName, string schemaName = Constants.DefaultSchema, NpgsqlDataSource? applicationDataSource = null, diff --git a/tests/AspNetCore.DataProtection.CustomStorage.Tests/SQLServer/SqlServerContainerFixture.cs b/tests/AspNetCore.DataProtection.CustomStorage.Tests/SQLServer/SqlServerContainerFixture.cs index 769038b..cb480fe 100644 --- a/tests/AspNetCore.DataProtection.CustomStorage.Tests/SQLServer/SqlServerContainerFixture.cs +++ b/tests/AspNetCore.DataProtection.CustomStorage.Tests/SQLServer/SqlServerContainerFixture.cs @@ -10,10 +10,15 @@ using Xunit; using Constants = AspNetCore.DataProtection.CustomStorage.Dapper.SQLServer.Constants; -// One container for the whole assembly: tests that do not use the default table work on their own tables. -[assembly: AssemblyFixture(typeof(AspNetCore.DataProtection.CustomStorage.Tests.SQLServer.SqlServerContainerFixture))] - namespace AspNetCore.DataProtection.CustomStorage.Tests.SQLServer; + +/// +/// Shares one container among the SQLServer test classes. A collection fixture, unlike an assembly fixture, +/// is created only when tests of the collection are selected, so unit-only runs do not need Docker. +/// Tests that do not use the default table work on their own tables. +/// +[CollectionDefinition(nameof(SqlServerCollection))] +public class SqlServerCollection : ICollectionFixture; public class SqlServerContainerFixture : IAsyncLifetime { private readonly MsSqlContainer _dbContainer = new MsSqlBuilder("mcr.microsoft.com/mssql/server:2022-CU27-GDR1-ubuntu-22.04") @@ -27,6 +32,11 @@ public class SqlServerContainerFixture : IAsyncLifetime private Respawner _respawner = null!; + /// + /// Returns a unique table name, for tests that must not share the default table. + /// + public static string NewTableName() => $"Keys_{Guid.NewGuid():N}"; + public SqlServerContainerFixture() { Options.Value.Returns(new DapperDataProtectionConfig() diff --git a/tests/AspNetCore.DataProtection.CustomStorage.Tests/SQLServer/SqlServerDataProtectionRepositoryTests.cs b/tests/AspNetCore.DataProtection.CustomStorage.Tests/SQLServer/SqlServerDataProtectionRepositoryTests.cs index 56d6e9d..7ff9963 100644 --- a/tests/AspNetCore.DataProtection.CustomStorage.Tests/SQLServer/SqlServerDataProtectionRepositoryTests.cs +++ b/tests/AspNetCore.DataProtection.CustomStorage.Tests/SQLServer/SqlServerDataProtectionRepositoryTests.cs @@ -13,6 +13,7 @@ using Constants = AspNetCore.DataProtection.CustomStorage.Dapper.SQLServer.Constants; namespace AspNetCore.DataProtection.CustomStorage.Tests.SQLServer; +[Collection(nameof(SqlServerCollection))] public class SqlServerDataProtectionRepositoryTests { private readonly SqlServerContainerFixture _fixture; @@ -153,7 +154,7 @@ public async Task Delete_KeyAlreadyDeleted_Succeeds() [Fact] public void Delete_LaterDeletionFails_RollsBackEarlierDeletions() { - var tableName = NewTableName(); + var tableName = SqlServerContainerFixture.NewTableName(); var sut = CreateSut(tableName); sut.InitializeDb(); sut.Insert(new DataProtectionKey { FriendlyName = "DeleteFirst", Xml = "xml1" }); @@ -178,7 +179,7 @@ IF EXISTS(SELECT 1 FROM deleted WHERE [FriendlyName] = N'DeleteFails') [Fact] public void InitializeDb_TableExistsWithoutIndex_CreatesIndex() { - var tableName = NewTableName(); + var tableName = SqlServerContainerFixture.NewTableName(); var sut = CreateSut(tableName); sut.InitializeDb(); DropFriendlyNameIndex(tableName); @@ -195,7 +196,7 @@ public void InitializeDb_TableExistsWithoutIndex_CreatesIndex() [Fact] public void InitializeDb_TableAndIndexExist_Succeeds() { - var tableName = NewTableName(); + var tableName = SqlServerContainerFixture.NewTableName(); var sut = CreateSut(tableName); sut.InitializeDb(); @@ -207,7 +208,7 @@ public void InitializeDb_TableAndIndexExist_Succeeds() [Fact] public void InitializeDb_TableWithoutIndexHasDuplicateFriendlyNames_ThrowsClearError() { - var tableName = NewTableName(); + var tableName = SqlServerContainerFixture.NewTableName(); var sut = CreateSut(tableName); sut.InitializeDb(); DropFriendlyNameIndex(tableName); @@ -225,7 +226,7 @@ public void InitializeDb_TableWithoutIndexHasDuplicateFriendlyNames_ThrowsClearE [Fact] public void InitializeDb_TableWithoutIndexHasDuplicateNullFriendlyNames_CreatesIndex() { - var tableName = NewTableName(); + var tableName = SqlServerContainerFixture.NewTableName(); var sut = CreateSut(tableName); sut.InitializeDb(); DropFriendlyNameIndex(tableName); @@ -240,7 +241,7 @@ public void InitializeDb_TableWithoutIndexHasDuplicateNullFriendlyNames_CreatesI [Fact] public void InitializeDb_NewTable_InsertDateIsDatetime2WithUtcDefault() { - var tableName = NewTableName(); + var tableName = SqlServerContainerFixture.NewTableName(); var sut = CreateSut(tableName); sut.InitializeDb(); @@ -263,7 +264,7 @@ FROM [sys].[columns] c public void InitializeDb_CustomSchemaAndTable_PersistsKeysInConfiguredTable() { const string schemaName = "custom_keys"; - var tableName = NewTableName(); + var tableName = SqlServerContainerFixture.NewTableName(); using (var connection = _fixture.SqlConnectionFactory.CreateConnection()) { connection.Execute($"IF SCHEMA_ID(N'{schemaName}') IS NULL EXEC(N'CREATE SCHEMA [{schemaName}]');"); @@ -291,7 +292,7 @@ public async Task InitializeDb_ConcurrentCallsOnNewTable_AllSucceed() // Several rounds, because a single round does not always hit the race. for (var round = 0; round < 5; round++) { - var tableName = NewTableName(); + var tableName = SqlServerContainerFixture.NewTableName(); // One repository per call, as on separate nodes starting at the same time. var suts = Enumerable.Range(0, callCount).Select(_ => CreateSut(tableName)).ToList(); using var barrier = new Barrier(callCount); @@ -311,7 +312,7 @@ public async Task InitializeDb_ConcurrentCallsOnNewTable_AllSucceed() public void InitializeDb_SchemaMissing_CreatesSchemaAndTable() { var schemaName = $"Schema_{Guid.NewGuid():N}"; - var tableName = NewTableName(); + var tableName = SqlServerContainerFixture.NewTableName(); var sut = CreateSut(tableName, schemaName); sut.InitializeDb(); @@ -321,8 +322,6 @@ public void InitializeDb_SchemaMissing_CreatesSchemaAndTable() sut.Invoking(x => x.InitializeDb()).Should().NotThrow(); } - private static string NewTableName() => $"Keys_{Guid.NewGuid():N}"; - private SQLServerDataProtectionRepository CreateSut(string tableName, string schemaName = Constants.DefaultSchema) { var options = Substitute.For>(); diff --git a/tests/AspNetCore.DataProtection.CustomStorage.Tests/SQLServer/SqlServerEndToEndTests.cs b/tests/AspNetCore.DataProtection.CustomStorage.Tests/SQLServer/SqlServerEndToEndTests.cs index b5c3bd2..b6f6171 100644 --- a/tests/AspNetCore.DataProtection.CustomStorage.Tests/SQLServer/SqlServerEndToEndTests.cs +++ b/tests/AspNetCore.DataProtection.CustomStorage.Tests/SQLServer/SqlServerEndToEndTests.cs @@ -18,6 +18,7 @@ namespace AspNetCore.DataProtection.CustomStorage.Tests.SQLServer; /// /// End-to-end tests that wire the SQL Server provider through the public extension methods and real DI. /// +[Collection(nameof(SqlServerCollection))] public class SqlServerEndToEndTests { private const string ApplicationName = "AspNetCore.DataProtection.CustomStorage.Tests"; @@ -32,7 +33,7 @@ public SqlServerEndToEndTests(SqlServerContainerFixture fixture) [Fact] public void Protect_UnprotectWithSameServiceProvider_RoundTrips() { - var tableName = NewTableName(); + var tableName = SqlServerContainerFixture.NewTableName(); using var services = BuildServiceProvider(tableName); var protector = services.GetRequiredService().CreateProtector(Purpose); @@ -45,7 +46,7 @@ public void Protect_UnprotectWithSameServiceProvider_RoundTrips() [Fact] public void Protect_UnprotectWithSecondServiceProviderSharingDatabase_RoundTrips() { - var tableName = NewTableName(); + var tableName = SqlServerContainerFixture.NewTableName(); using var first = BuildServiceProvider(tableName); using var second = BuildServiceProvider(tableName); @@ -60,7 +61,7 @@ public void Protect_UnprotectWithSecondServiceProviderSharingDatabase_RoundTrips [Fact] public void DeleteKeys_KeySelected_DeletesItFromTable() { - var tableName = NewTableName(); + var tableName = SqlServerContainerFixture.NewTableName(); using var services = BuildServiceProvider(tableName); // XmlKeyManager exposes DeleteKeys but does not implement IDeletableKeyManager. var keyManager = (XmlKeyManager)services.GetRequiredService(); @@ -86,8 +87,6 @@ public void Protect_MissingCustomSchemaAndReservedWordTable_StoresKeysInConfigur GetStoredFriendlyNames("Order", schemaName).Should().ContainSingle(); } - private static string NewTableName() => $"Keys_{Guid.NewGuid():N}"; - private ServiceProvider BuildServiceProvider(string tableName, string schemaName = Constants.DefaultSchema) { var services = new ServiceCollection(); diff --git a/tests/AspNetCore.DataProtection.CustomStorage.Tests/StorageWrapperTests.cs b/tests/AspNetCore.DataProtection.CustomStorage.Tests/StorageWrapperTests.cs index ca1ebac..7ae9008 100644 --- a/tests/AspNetCore.DataProtection.CustomStorage.Tests/StorageWrapperTests.cs +++ b/tests/AspNetCore.DataProtection.CustomStorage.Tests/StorageWrapperTests.cs @@ -107,7 +107,6 @@ public void GetAllElements_KeyRead_DoesNotLogKeyXml() [Fact] public void StoreElement_KeySaved_DoesNotLogKeyXml() { - _sut.StoreElement(KeyElement, KeyFriendlyName); _loggerFactory.Entries.Should().Contain(e => e.Contains(KeyFriendlyName));