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 use an `IClassFixture` (`*ContainerFixture`) that starts a Testcontainers database, calls `InitializeDb()` with the default config, and exposes `ResetDatabaseAsync()` (Respawn) to clear data between tests.
- `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.
- `*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
Expand Up @@ -15,9 +15,11 @@ internal sealed partial class DapperDataProtectionConfigValidator : IValidateOpt
internal const int MaxSchemaNameLength = 128;

/// <summary>
/// Maximum table name length, which leaves room for the derived index name <c>IX_{TableName}_FriendlyName</c>.
/// Maximum table name length, which leaves room for the derived index name <c>IX_{TableName}_FriendlyName</c>,
/// the longest name derived from the table name.
/// </summary>
internal const int MaxTableNameLength = MaxSchemaNameLength - 16;
internal static readonly int MaxTableNameLength =
MaxSchemaNameLength - SQLServerDataProtectionRepository.GetIndexName(string.Empty).Length;

/// <inheritdoc />
public ValidateOptionsResult Validate(string? name, DapperDataProtectionConfig options)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ private static List<int> GetIds(IReadOnlyCollection<DataProtectionKey> keys)
/// </remarks>
public void InitializeDb()
{
var indexName = $"IX_{_config.TableName}_{nameof(DataProtectionKeyEntity.FriendlyName)}";
var indexName = GetIndexName(_config.TableName);
using var connection = _connectionFactory.CreateConnection();
connection.Open();
using var transaction = connection.BeginTransaction();
Expand Down Expand Up @@ -171,5 +171,7 @@ DECLARE @message nvarchar(2048) = N'Cannot create unique index ' + QUOTENAME(@In
transaction.Commit();
}

internal static string GetIndexName(string tableName) => $"IX_{tableName}_{nameof(DataProtectionKeyEntity.FriendlyName)}";

private static string QuoteIdentifier(string name) => $"[{name.Replace("]", "]]")}]";
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,16 +22,9 @@ internal class DeletableStorageWrapper<TStorage> : StorageWrapper<TStorage>, IDe
/// <param name="services"></param>
/// <param name="loggerFactory"></param>
/// <exception cref="ArgumentNullException"></exception>
/// <exception cref="ArgumentException"><typeparamref name="TStorage"/> does not implement <see cref="IDeletableDataProtectionStorage"/></exception>
public DeletableStorageWrapper(IServiceProvider services, ILoggerFactory loggerFactory)
: base(services, loggerFactory)
{
if (!typeof(TStorage).IsAssignableTo(typeof(IDeletableDataProtectionStorage)))
{
throw new ArgumentException(
$"'{typeof(TStorage).Name}' does not implement '{nameof(IDeletableDataProtectionStorage)}'.",
nameof(TStorage));
}
}

/// <inheritdoc />
Expand All @@ -46,15 +39,7 @@ public bool DeleteElements(Action<IReadOnlyCollection<IDeletableElement>> choose
using var scope = _services.CreateScope();
var storage = (IDeletableDataProtectionStorage)scope.ServiceProvider.GetRequiredService<TStorage>();

var deletableElements = new List<DeletableElement>();
foreach (var key in storage.GetAll())
{
var element = TryParseKey(key);
if (element is not null)
{
deletableElements.Add(new DeletableElement(key, element));
}
}
var deletableElements = ParseKeys(storage).Select(k => new DeletableElement(k.Key, k.Element)).ToList();

chooseElements(deletableElements);

Expand Down
23 changes: 13 additions & 10 deletions src/AspNetCore.DataProtection.CustomStorage/StorageWrapper.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Xml;
using System.Xml.Linq;
using Microsoft.AspNetCore.DataProtection.Repositories;
Expand Down Expand Up @@ -41,25 +42,27 @@ public IReadOnlyCollection<XElement> GetAllElements()
{
using var scope = _services.CreateScope();
var storage = scope.ServiceProvider.GetRequiredService<TStorage>();
var elements = ImmutableArray.CreateBuilder<XElement>();
return ParseKeys(storage).Select(k => k.Element).ToImmutableArray();
}

/// <summary>
/// read every stored key and parse its XML
/// </summary>
/// <param name="storage">the storage to read</param>
/// <returns>each stored key with its parsed element; keys with malformed XML are logged and skipped</returns>
protected IEnumerable<(DataProtectionKey Key, XElement Element)> ParseKeys(IDataProtectionStorage storage)
{
foreach (var key in storage.GetAll())
{
var element = TryParseKey(key);
if (element is not null)
{
elements.Add(element);
yield return (key, element);
}
}

return elements.ToImmutable();
}

/// <summary>
/// parse the XML of a stored key
/// </summary>
/// <param name="key">the stored key</param>
/// <returns>the parsed element, or <c>null</c> when the XML is malformed (the key is logged and skipped)</returns>
protected XElement? TryParseKey(DataProtectionKey key)
private XElement? TryParseKey(DataProtectionKey key)
{
_logger.ReadingXmlFromKey(key.FriendlyName);
try
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,6 @@

</ItemGroup>

<ItemGroup>
<PackageReference Update="xunit.runner.visualstudio">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>

<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
<PackageReference Include="Respawn" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,35 +30,21 @@ public DapperDataProtectionExtensionsTests()
_services.GetService(typeof(IDbDataProtectionStorage)).Returns(_provider);
_services.GetService(typeof(ILoggerFactory)).Returns(loggerFactory);
}
[Fact]
public void UseDapperDataProtection_InitializeTable_ExecInitializeDb()
{
var config = new DapperDataProtectionConfig()
{
SchemaName = "",
TableName = "",
InitializeTable = true
};
_options.Value.Returns(config);

_services.UseDapperDataProtection();

_provider.Received(1).InitializeDb();
}
[Fact]
public void UseDapperDataProtection_InitializeTableFalse_NotInitializeDb()
[Theory]
[InlineData(true, 1)]
[InlineData(false, 0)]
public void UseDapperDataProtection_InitializeTable_InitializesDbOnlyWhenEnabled(bool initializeTable, int expectedCalls)
{
var config = new DapperDataProtectionConfig()
_options.Value.Returns(new DapperDataProtectionConfig
{
SchemaName = "",
TableName = "",
InitializeTable = false
};
_options.Value.Returns(config);
InitializeTable = initializeTable
});

_services.UseDapperDataProtection();

_provider.DidNotReceiveWithAnyArgs().InitializeDb();
_provider.Received(expectedCalls).InitializeDb();
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ namespace AspNetCore.DataProtection.CustomStorage.Tests;

public class DeletableStorageWrapperTests
{
private const string KeyMaterial = "c2VjcmV0LWtleS1tYXRlcmlhbA==";
private const string KeyMaterial = TestConstants.KeyMaterial;

private readonly IServiceProvider _services;
private readonly IDeletableDataProtectionStorage _storage;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@
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;

public class PostgreSqlContainerFixture : IAsyncLifetime
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
using Xunit;

namespace AspNetCore.DataProtection.CustomStorage.Tests.PostgreSQL;
public class PostgreSqlDataProtectionRepositoryTests: IClassFixture<PostgreSqlContainerFixture>
public class PostgreSqlDataProtectionRepositoryTests
{
private readonly PostgreSqlContainerFixture _fixture;
private readonly PostgreSQLDataProtectionRepository _sut;
Expand Down Expand Up @@ -123,24 +123,19 @@ public async Task GetAllAsync_MatchNamesWithUnderscoresDisabled_MapsAllColumns()
public void InitializeDb_CustomSchemaAndTable_PersistsKeysInConfiguredTable()
{
const string schemaName = "custom_keys";
var tableName = $"keys_{Guid.NewGuid().ToString("N")[..12]}";
var tableName = NewTableName();
using (var connection = _fixture.NpgsqlDataSource.OpenConnection())
{
connection.Execute($"CREATE SCHEMA IF NOT EXISTS {schemaName};");
}
var options = Substitute.For<IOptionsSnapshot<DapperDataProtectionConfig>>();
options.Value.Returns(new DapperDataProtectionConfig { SchemaName = schemaName, TableName = tableName });
var sut = new PostgreSQLDataProtectionRepository(_fixture.NpgsqlDataSource, options);
var sut = CreateSut(schemaName, tableName);
var key = new DataProtectionKey { FriendlyName = "CustomSchemaKey", Xml = "Xml" };

sut.InitializeDb();
sut.Insert(key);

sut.GetAll().Should().ContainSingle(x => x.FriendlyName == key.FriendlyName && x.Xml == key.Xml);
using (var connection = _fixture.NpgsqlDataSource.OpenConnection())
{
connection.ExecuteScalar<long>($"SELECT COUNT(*) FROM {schemaName}.{tableName};").Should().Be(1);
}
CountRows(schemaName, tableName).Should().Be(1);
_sut.GetAll().Should().NotContain(x => x.FriendlyName == key.FriendlyName);
}

Expand All @@ -155,16 +150,10 @@ 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 = $"keys_{Guid.NewGuid().ToString("N")[..12]}";
var options = Substitute.For<IOptionsSnapshot<DapperDataProtectionConfig>>();
options.Value.Returns(new DapperDataProtectionConfig
{
SchemaName = Constants.DefaultSchema,
TableName = tableName
});
var tableName = NewTableName();
// One repository per call, as on separate nodes starting at the same time.
var suts = Enumerable.Range(0, callCount)
.Select(_ => new PostgreSQLDataProtectionRepository(_fixture.NpgsqlDataSource, options))
.Select(_ => CreateSut(Constants.DefaultSchema, tableName))
.ToList();
using var barrier = new Barrier(callCount);

Expand All @@ -187,7 +176,7 @@ public async Task InitializeDb_ConcurrentCallsOnNewTable_AllSucceed()
public void InitializeDb_SchemaMissing_CreatesSchemaAndTable()
{
var schemaName = $"schema_{Guid.NewGuid().ToString("N")[..12]}";
var tableName = $"keys_{Guid.NewGuid().ToString("N")[..12]}";
var tableName = NewTableName();
var sut = CreateSut(schemaName, tableName);

sut.InitializeDb();
Expand All @@ -202,7 +191,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 = $"keys_{Guid.NewGuid().ToString("N")[..12]}";
var tableName = NewTableName();
var sut = CreateSut(schemaName, tableName);
var key = new DataProtectionKey { FriendlyName = "ReservedWordKey", Xml = "Xml" };

Expand Down Expand Up @@ -264,14 +253,8 @@ public async Task Delete_KeyAlreadyDeleted_Succeeds()
[Fact]
public void Delete_LaterDeletionFails_RollsBackEarlierDeletions()
{
var tableName = $"keys_{Guid.NewGuid().ToString("N")[..12]}";
var options = Substitute.For<IOptionsSnapshot<DapperDataProtectionConfig>>();
options.Value.Returns(new DapperDataProtectionConfig
{
SchemaName = Constants.DefaultSchema,
TableName = tableName
});
var sut = new PostgreSQLDataProtectionRepository(_fixture.NpgsqlDataSource, options);
var tableName = NewTableName();
var sut = CreateSut(Constants.DefaultSchema, tableName);
sut.InitializeDb();
sut.Insert(new DataProtectionKey { FriendlyName = "DeleteFirst", Xml = "xml1" });
sut.Insert(new DataProtectionKey { FriendlyName = "DeleteFails", Xml = "xml2" });
Expand All @@ -295,6 +278,8 @@ 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,7 +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>
public class PostgreSqlEndToEndTests : IClassFixture<PostgreSqlContainerFixture>
public class PostgreSqlEndToEndTests
{
private const string ApplicationName = "AspNetCore.DataProtection.CustomStorage.Tests";
private const string Purpose = "end-to-end";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@
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;
public class SqlServerContainerFixture : IAsyncLifetime
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
using Constants = AspNetCore.DataProtection.CustomStorage.Dapper.SQLServer.Constants;

namespace AspNetCore.DataProtection.CustomStorage.Tests.SQLServer;
public class SqlServerDataProtectionRepositoryTests : IClassFixture<SqlServerContainerFixture>
public class SqlServerDataProtectionRepositoryTests
{
private readonly SqlServerContainerFixture _fixture;
private readonly SQLServerDataProtectionRepository _sut;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +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>
public class SqlServerEndToEndTests : IClassFixture<SqlServerContainerFixture>
public class SqlServerEndToEndTests
{
private const string ApplicationName = "AspNetCore.DataProtection.CustomStorage.Tests";
private const string Purpose = "end-to-end";
Expand Down
Loading
Loading