Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;

/// <summary>
/// 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.
/// </summary>
[CollectionDefinition(nameof(PostgreSqlCollection))]
public class PostgreSqlCollection : ICollectionFixture<PostgreSqlContainerFixture>;

public class PostgreSqlContainerFixture : IAsyncLifetime
{
private readonly PostgreSqlContainer _dbContainer = new PostgreSqlBuilder("postgres:16.4")
Expand All @@ -27,6 +33,11 @@ public class PostgreSqlContainerFixture : IAsyncLifetime

private Respawner _respawner = null!;

/// <summary>
/// Returns a unique table name, for tests that must not share the default table.
/// </summary>
public static string NewTableName() => $"keys_{Guid.NewGuid().ToString("N")[..12]}";

public PostgreSqlContainerFixture()
{
Options.Value.Returns(new DapperDataProtectionConfig()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
using Xunit;

namespace AspNetCore.DataProtection.CustomStorage.Tests.PostgreSQL;
[Collection(nameof(PostgreSqlCollection))]
public class PostgreSqlDataProtectionRepositoryTests
{
private readonly PostgreSqlContainerFixture _fixture;
Expand Down Expand Up @@ -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};");
Expand All @@ -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))
Expand All @@ -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();
Expand All @@ -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" };

Expand Down Expand Up @@ -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" });
Expand All @@ -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<IOptionsSnapshot<DapperDataProtectionConfig>>();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ namespace AspNetCore.DataProtection.CustomStorage.Tests.PostgreSQL;
/// <summary>
/// End-to-end tests that wire the PostgreSQL provider through the public extension methods and real DI.
/// </summary>
[Collection(nameof(PostgreSqlCollection))]
public class PostgreSqlEndToEndTests
{
private const string ApplicationName = "AspNetCore.DataProtection.CustomStorage.Tests";
Expand All @@ -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<IDataProtectionProvider>().CreateProtector(Purpose);

Expand All @@ -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);

Expand All @@ -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<IKeyManager>();
Expand All @@ -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)
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/// <summary>
/// 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.
/// </summary>
[CollectionDefinition(nameof(SqlServerCollection))]
public class SqlServerCollection : ICollectionFixture<SqlServerContainerFixture>;
public class SqlServerContainerFixture : IAsyncLifetime
{
private readonly MsSqlContainer _dbContainer = new MsSqlBuilder("mcr.microsoft.com/mssql/server:2022-CU27-GDR1-ubuntu-22.04")
Expand All @@ -27,6 +32,11 @@ public class SqlServerContainerFixture : IAsyncLifetime

private Respawner _respawner = null!;

/// <summary>
/// Returns a unique table name, for tests that must not share the default table.
/// </summary>
public static string NewTableName() => $"Keys_{Guid.NewGuid():N}";

public SqlServerContainerFixture()
{
Options.Value.Returns(new DapperDataProtectionConfig()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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" });
Expand All @@ -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);
Expand All @@ -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();

Expand All @@ -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);
Expand All @@ -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);
Expand All @@ -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();
Expand All @@ -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}]');");
Expand Down Expand Up @@ -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);
Expand All @@ -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();
Expand All @@ -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<IOptionsSnapshot<DapperDataProtectionConfig>>();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ namespace AspNetCore.DataProtection.CustomStorage.Tests.SQLServer;
/// <summary>
/// End-to-end tests that wire the SQL Server provider through the public extension methods and real DI.
/// </summary>
[Collection(nameof(SqlServerCollection))]
public class SqlServerEndToEndTests
{
private const string ApplicationName = "AspNetCore.DataProtection.CustomStorage.Tests";
Expand All @@ -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<IDataProtectionProvider>().CreateProtector(Purpose);

Expand All @@ -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);

Expand All @@ -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<IKeyManager>();
Expand All @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
Loading