diff --git a/AGENTS.md b/AGENTS.md index 65e0638..fe02504 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 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`) diff --git a/src/AspNetCore.DataProtection.CustomStorage.Dapper.SQLServer/DapperDataProtectionConfigValidator.cs b/src/AspNetCore.DataProtection.CustomStorage.Dapper.SQLServer/DapperDataProtectionConfigValidator.cs index fbdde21..28a67d6 100644 --- a/src/AspNetCore.DataProtection.CustomStorage.Dapper.SQLServer/DapperDataProtectionConfigValidator.cs +++ b/src/AspNetCore.DataProtection.CustomStorage.Dapper.SQLServer/DapperDataProtectionConfigValidator.cs @@ -15,9 +15,11 @@ internal sealed partial class DapperDataProtectionConfigValidator : IValidateOpt internal const int MaxSchemaNameLength = 128; /// - /// Maximum table name length, which leaves room for the derived index name IX_{TableName}_FriendlyName. + /// Maximum table name length, which leaves room for the derived index name IX_{TableName}_FriendlyName, + /// the longest name derived from the table name. /// - internal const int MaxTableNameLength = MaxSchemaNameLength - 16; + internal static readonly int MaxTableNameLength = + MaxSchemaNameLength - SQLServerDataProtectionRepository.GetIndexName(string.Empty).Length; /// public ValidateOptionsResult Validate(string? name, DapperDataProtectionConfig options) diff --git a/src/AspNetCore.DataProtection.CustomStorage.Dapper.SQLServer/SQLServerDataProtectionRepository.cs b/src/AspNetCore.DataProtection.CustomStorage.Dapper.SQLServer/SQLServerDataProtectionRepository.cs index 1570694..b1e8417 100644 --- a/src/AspNetCore.DataProtection.CustomStorage.Dapper.SQLServer/SQLServerDataProtectionRepository.cs +++ b/src/AspNetCore.DataProtection.CustomStorage.Dapper.SQLServer/SQLServerDataProtectionRepository.cs @@ -115,7 +115,7 @@ private static List GetIds(IReadOnlyCollection keys) /// 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(); @@ -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("]", "]]")}]"; } \ No newline at end of file diff --git a/src/AspNetCore.DataProtection.CustomStorage/DeletableStorageWrapper.cs b/src/AspNetCore.DataProtection.CustomStorage/DeletableStorageWrapper.cs index 53f1a6e..66f6ea5 100644 --- a/src/AspNetCore.DataProtection.CustomStorage/DeletableStorageWrapper.cs +++ b/src/AspNetCore.DataProtection.CustomStorage/DeletableStorageWrapper.cs @@ -22,16 +22,9 @@ internal class DeletableStorageWrapper : StorageWrapper, IDe /// /// /// - /// does not implement 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)); - } } /// @@ -46,15 +39,7 @@ public bool DeleteElements(Action> choose using var scope = _services.CreateScope(); var storage = (IDeletableDataProtectionStorage)scope.ServiceProvider.GetRequiredService(); - var deletableElements = new List(); - 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); diff --git a/src/AspNetCore.DataProtection.CustomStorage/StorageWrapper.cs b/src/AspNetCore.DataProtection.CustomStorage/StorageWrapper.cs index 74cad67..80e5b1c 100644 --- a/src/AspNetCore.DataProtection.CustomStorage/StorageWrapper.cs +++ b/src/AspNetCore.DataProtection.CustomStorage/StorageWrapper.cs @@ -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; @@ -41,25 +42,27 @@ public IReadOnlyCollection GetAllElements() { using var scope = _services.CreateScope(); var storage = scope.ServiceProvider.GetRequiredService(); - var elements = ImmutableArray.CreateBuilder(); + return ParseKeys(storage).Select(k => k.Element).ToImmutableArray(); + } + + /// + /// read every stored key and parse its XML + /// + /// the storage to read + /// each stored key with its parsed element; keys with malformed XML are logged and skipped + 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(); } - /// - /// parse the XML of a stored key - /// - /// the stored key - /// the parsed element, or null when the XML is malformed (the key is logged and skipped) - protected XElement? TryParseKey(DataProtectionKey key) + private XElement? TryParseKey(DataProtectionKey key) { _logger.ReadingXmlFromKey(key.FriendlyName); try diff --git a/tests/AspNetCore.DataProtection.CustomStorage.Tests/AspNetCore.DataProtection.CustomStorage.Tests.csproj b/tests/AspNetCore.DataProtection.CustomStorage.Tests/AspNetCore.DataProtection.CustomStorage.Tests.csproj index ba0b3ea..abe1a57 100644 --- a/tests/AspNetCore.DataProtection.CustomStorage.Tests/AspNetCore.DataProtection.CustomStorage.Tests.csproj +++ b/tests/AspNetCore.DataProtection.CustomStorage.Tests/AspNetCore.DataProtection.CustomStorage.Tests.csproj @@ -11,13 +11,6 @@ - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - diff --git a/tests/AspNetCore.DataProtection.CustomStorage.Tests/DapperDataProtectionExtensionsTests.cs b/tests/AspNetCore.DataProtection.CustomStorage.Tests/DapperDataProtectionExtensionsTests.cs index 9c3b859..dbcc646 100644 --- a/tests/AspNetCore.DataProtection.CustomStorage.Tests/DapperDataProtectionExtensionsTests.cs +++ b/tests/AspNetCore.DataProtection.CustomStorage.Tests/DapperDataProtectionExtensionsTests.cs @@ -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(); } - } \ No newline at end of file diff --git a/tests/AspNetCore.DataProtection.CustomStorage.Tests/DeletableStorageWrapperTests.cs b/tests/AspNetCore.DataProtection.CustomStorage.Tests/DeletableStorageWrapperTests.cs index f15b0dc..5173704 100644 --- a/tests/AspNetCore.DataProtection.CustomStorage.Tests/DeletableStorageWrapperTests.cs +++ b/tests/AspNetCore.DataProtection.CustomStorage.Tests/DeletableStorageWrapperTests.cs @@ -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; diff --git a/tests/AspNetCore.DataProtection.CustomStorage.Tests/PostgreSQL/PostgreSqlContainerFixture.cs b/tests/AspNetCore.DataProtection.CustomStorage.Tests/PostgreSQL/PostgreSqlContainerFixture.cs index 4289d84..33b313c 100644 --- a/tests/AspNetCore.DataProtection.CustomStorage.Tests/PostgreSQL/PostgreSqlContainerFixture.cs +++ b/tests/AspNetCore.DataProtection.CustomStorage.Tests/PostgreSQL/PostgreSqlContainerFixture.cs @@ -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 diff --git a/tests/AspNetCore.DataProtection.CustomStorage.Tests/PostgreSQL/PostgreSqlDataProtectionRepositoryTests.cs b/tests/AspNetCore.DataProtection.CustomStorage.Tests/PostgreSQL/PostgreSqlDataProtectionRepositoryTests.cs index 8c58431..d894a1a 100644 --- a/tests/AspNetCore.DataProtection.CustomStorage.Tests/PostgreSQL/PostgreSqlDataProtectionRepositoryTests.cs +++ b/tests/AspNetCore.DataProtection.CustomStorage.Tests/PostgreSQL/PostgreSqlDataProtectionRepositoryTests.cs @@ -12,7 +12,7 @@ using Xunit; namespace AspNetCore.DataProtection.CustomStorage.Tests.PostgreSQL; -public class PostgreSqlDataProtectionRepositoryTests: IClassFixture +public class PostgreSqlDataProtectionRepositoryTests { private readonly PostgreSqlContainerFixture _fixture; private readonly PostgreSQLDataProtectionRepository _sut; @@ -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>(); - 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($"SELECT COUNT(*) FROM {schemaName}.{tableName};").Should().Be(1); - } + CountRows(schemaName, tableName).Should().Be(1); _sut.GetAll().Should().NotContain(x => x.FriendlyName == key.FriendlyName); } @@ -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>(); - 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); @@ -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(); @@ -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" }; @@ -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>(); - 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" }); @@ -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>(); diff --git a/tests/AspNetCore.DataProtection.CustomStorage.Tests/PostgreSQL/PostgreSqlEndToEndTests.cs b/tests/AspNetCore.DataProtection.CustomStorage.Tests/PostgreSQL/PostgreSqlEndToEndTests.cs index 8c9568f..cbdb3b6 100644 --- a/tests/AspNetCore.DataProtection.CustomStorage.Tests/PostgreSQL/PostgreSqlEndToEndTests.cs +++ b/tests/AspNetCore.DataProtection.CustomStorage.Tests/PostgreSQL/PostgreSqlEndToEndTests.cs @@ -18,7 +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. /// -public class PostgreSqlEndToEndTests : IClassFixture +public class PostgreSqlEndToEndTests { private const string ApplicationName = "AspNetCore.DataProtection.CustomStorage.Tests"; private const string Purpose = "end-to-end"; diff --git a/tests/AspNetCore.DataProtection.CustomStorage.Tests/SQLServer/SqlServerContainerFixture.cs b/tests/AspNetCore.DataProtection.CustomStorage.Tests/SQLServer/SqlServerContainerFixture.cs index 949a386..769038b 100644 --- a/tests/AspNetCore.DataProtection.CustomStorage.Tests/SQLServer/SqlServerContainerFixture.cs +++ b/tests/AspNetCore.DataProtection.CustomStorage.Tests/SQLServer/SqlServerContainerFixture.cs @@ -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 { diff --git a/tests/AspNetCore.DataProtection.CustomStorage.Tests/SQLServer/SqlServerDataProtectionRepositoryTests.cs b/tests/AspNetCore.DataProtection.CustomStorage.Tests/SQLServer/SqlServerDataProtectionRepositoryTests.cs index 731f152..56d6e9d 100644 --- a/tests/AspNetCore.DataProtection.CustomStorage.Tests/SQLServer/SqlServerDataProtectionRepositoryTests.cs +++ b/tests/AspNetCore.DataProtection.CustomStorage.Tests/SQLServer/SqlServerDataProtectionRepositoryTests.cs @@ -13,7 +13,7 @@ using Constants = AspNetCore.DataProtection.CustomStorage.Dapper.SQLServer.Constants; namespace AspNetCore.DataProtection.CustomStorage.Tests.SQLServer; -public class SqlServerDataProtectionRepositoryTests : IClassFixture +public class SqlServerDataProtectionRepositoryTests { private readonly SqlServerContainerFixture _fixture; private readonly SQLServerDataProtectionRepository _sut; diff --git a/tests/AspNetCore.DataProtection.CustomStorage.Tests/SQLServer/SqlServerEndToEndTests.cs b/tests/AspNetCore.DataProtection.CustomStorage.Tests/SQLServer/SqlServerEndToEndTests.cs index eea116e..b5c3bd2 100644 --- a/tests/AspNetCore.DataProtection.CustomStorage.Tests/SQLServer/SqlServerEndToEndTests.cs +++ b/tests/AspNetCore.DataProtection.CustomStorage.Tests/SQLServer/SqlServerEndToEndTests.cs @@ -18,7 +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. /// -public class SqlServerEndToEndTests : IClassFixture +public class SqlServerEndToEndTests { private const string ApplicationName = "AspNetCore.DataProtection.CustomStorage.Tests"; private const string Purpose = "end-to-end"; diff --git a/tests/AspNetCore.DataProtection.CustomStorage.Tests/StorageWrapperTests.cs b/tests/AspNetCore.DataProtection.CustomStorage.Tests/StorageWrapperTests.cs index 357201b..ca1ebac 100644 --- a/tests/AspNetCore.DataProtection.CustomStorage.Tests/StorageWrapperTests.cs +++ b/tests/AspNetCore.DataProtection.CustomStorage.Tests/StorageWrapperTests.cs @@ -11,72 +11,65 @@ namespace AspNetCore.DataProtection.CustomStorage.Tests; public class StorageWrapperTests { - private readonly StorageWrapper _sut; - private readonly IServiceProvider _services; - private readonly IServiceScopeFactory _scopeFactory; - private readonly IServiceScope _scope; - private readonly ILoggerFactory _loggerFactory; - private readonly ILogger _logger = Substitute.For(); + private readonly StorageWrapper _sut; + private readonly IServiceProvider _services; + private readonly IServiceScopeFactory _scopeFactory; + private readonly IServiceScope _scope; + private readonly CapturingLoggerFactory _loggerFactory = new(); private readonly IDataProtectionStorage _storage; - public StorageWrapperTests() - { - _services = Substitute.For(); - _loggerFactory = Substitute.For(); - _storage = Substitute.For(); - - // The storage is resolvable only from the scoped provider, not from the root one. - var scopedServices = Substitute.For(); - scopedServices.GetService(typeof(IDataProtectionStorage)).Returns(_storage); - - _scope = Substitute.For(); - _scope.ServiceProvider.Returns(scopedServices); - - _scopeFactory = Substitute.For(); - _scopeFactory.CreateScope().Returns(_scope); - - _services.GetService(typeof(IServiceScopeFactory)).Returns(_scopeFactory); - - _loggerFactory.CreateLogger(Arg.Any()).Returns(_logger); - _sut = new StorageWrapper(_services, _loggerFactory); - } - [Fact] - public void Constructor_AllArgumentsNull_ThrowArgumentNullException() - { - Assert.Throws(() => new StorageWrapper(null!, null!)); - } - [Fact] - public void Constructor_LoggerFactoryIsNull_ThrowArgumentNullException() - { - // Act - Action act = () => - { - var _ = new StorageWrapper(_services, null!); - }; - - // Assert - act.Should().Throw().WithMessage("*loggerFactory*"); - } - - [Fact] - public void Constructor_ServiceProviderIsNull_ThrowArgumentNullException() - { - // Act - var act = () => - { - var _ = new StorageWrapper(null!, _loggerFactory); - }; - - // Assert - act.Should().Throw().WithMessage("*services*"); - } + public StorageWrapperTests() + { + _services = Substitute.For(); + _storage = Substitute.For(); + + // The storage is resolvable only from the scoped provider, not from the root one. + var scopedServices = Substitute.For(); + scopedServices.GetService(typeof(IDataProtectionStorage)).Returns(_storage); + + _scope = Substitute.For(); + _scope.ServiceProvider.Returns(scopedServices); + + _scopeFactory = Substitute.For(); + _scopeFactory.CreateScope().Returns(_scope); + + _services.GetService(typeof(IServiceScopeFactory)).Returns(_scopeFactory); + + _sut = new StorageWrapper(_services, _loggerFactory); + } + + [Fact] + public void Constructor_LoggerFactoryIsNull_ThrowArgumentNullException() + { + // Act + Action act = () => + { + var _ = new StorageWrapper(_services, null!); + }; + + // Assert + act.Should().Throw().WithMessage("*loggerFactory*"); + } + + [Fact] + public void Constructor_ServiceProviderIsNull_ThrowArgumentNullException() + { + // Act + var act = () => + { + var _ = new StorageWrapper(null!, _loggerFactory); + }; + + // Assert + act.Should().Throw().WithMessage("*services*"); + } [Fact] public void StoreElement_ValidKey_CallsStorageSave() { _sut.StoreElement(TestConstants.XElement, TestConstants.FriendlyName); var key = new DataProtectionKey { - FriendlyName = TestConstants.FriendlyName.Trim(), + FriendlyName = TestConstants.FriendlyName, Xml = TestConstants.XElement.ToString(SaveOptions.DisableFormatting) }; _storage.Received(1).Insert(Arg.Is(key)); @@ -95,62 +88,43 @@ public void StoreElement_StorageFails_ThrowsKeyInsertException() _sut.Invoking(x => x.StoreElement(TestConstants.XElement,TestConstants.FriendlyName)) .Should().Throw(); } - [Fact] - public void StoreElement_ShouldCallInsertOnStorage() - { - // Arrange - var element = new XElement("TestElement"); - - // Act - _sut.StoreElement(element, "TestName"); - - // Assert - _storage.Received(1).Insert(Arg.Is(key => - key != null && key.FriendlyName == "TestName" && key.Xml == element.ToString(SaveOptions.DisableFormatting))); - } - private const string KeyMaterial = "c2VjcmV0LWtleS1tYXRlcmlhbA=="; + private const string KeyMaterial = TestConstants.KeyMaterial; private const string KeyFriendlyName = "key-6f1c2b9e"; private static readonly XElement KeyElement = new("key", new XElement("masterKey", KeyMaterial)); [Fact] public void GetAllElements_KeyRead_DoesNotLogKeyXml() { - var loggerFactory = new CapturingLoggerFactory(); - var sut = new StorageWrapper(_services, loggerFactory); _storage.GetAll().Returns([new DataProtectionKey { FriendlyName = KeyFriendlyName, Xml = KeyElement.ToString() }]); - sut.GetAllElements(); + _sut.GetAllElements(); - loggerFactory.Entries.Should().Contain(e => e.Contains(KeyFriendlyName)); - loggerFactory.Entries.Should().NotContain(e => e.Contains(KeyMaterial)); + _loggerFactory.Entries.Should().Contain(e => e.Contains(KeyFriendlyName)); + _loggerFactory.Entries.Should().NotContain(e => e.Contains(KeyMaterial)); } [Fact] public void StoreElement_KeySaved_DoesNotLogKeyXml() { - var loggerFactory = new CapturingLoggerFactory(); - var sut = new StorageWrapper(_services, loggerFactory); - sut.StoreElement(KeyElement, KeyFriendlyName); + _sut.StoreElement(KeyElement, KeyFriendlyName); - loggerFactory.Entries.Should().Contain(e => e.Contains(KeyFriendlyName)); - loggerFactory.Entries.Should().NotContain(e => e.Contains(KeyMaterial)); + _loggerFactory.Entries.Should().Contain(e => e.Contains(KeyFriendlyName)); + _loggerFactory.Entries.Should().NotContain(e => e.Contains(KeyMaterial)); } [Fact] public void StoreElement_StorageFails_LogsErrorWithStorageExceptionAndWithoutKeyXml() { - var loggerFactory = new CapturingLoggerFactory(); - var sut = new StorageWrapper(_services, loggerFactory); var storageException = new InvalidOperationException("insert failed"); _storage.WhenForAnyArgs(x => x.Insert(null!)).Throw(storageException); - sut.Invoking(x => x.StoreElement(KeyElement, KeyFriendlyName)).Should().Throw(); + _sut.Invoking(x => x.StoreElement(KeyElement, KeyFriendlyName)).Should().Throw(); - loggerFactory.Entries.Should().ContainSingle(e => e.Level == LogLevel.Error) + _loggerFactory.Entries.Should().ContainSingle(e => e.Level == LogLevel.Error) .Which.Exception.Should().BeSameAs(storageException); - loggerFactory.Entries.Should().NotContain(e => e.Contains(KeyMaterial)); + _loggerFactory.Entries.Should().NotContain(e => e.Contains(KeyMaterial)); } [Fact] @@ -206,15 +180,13 @@ public void GetAllElements_RowWithMalformedXml_SkipsRowAndReturnsValidKeys() [Fact] public void GetAllElements_RowWithMalformedXml_LogsErrorNamingRowWithoutKeyXml() { - var loggerFactory = new CapturingLoggerFactory(); - var sut = new StorageWrapper(_services, loggerFactory); _storage.GetAll().Returns([new DataProtectionKey { FriendlyName = MalformedFriendlyName, Xml = MalformedKeyXml }]); - sut.GetAllElements().Should().BeEmpty(); + _sut.GetAllElements().Should().BeEmpty(); - loggerFactory.Entries.Should().ContainSingle(e => e.Level == LogLevel.Error) + _loggerFactory.Entries.Should().ContainSingle(e => e.Level == LogLevel.Error) .Which.Should().Match(e => e.EventId.Name == "SkippingMalformedKey" && e.Contains(MalformedFriendlyName)); - loggerFactory.Entries.Should().NotContain(e => e.Contains(KeyMaterial)); + _loggerFactory.Entries.Should().NotContain(e => e.Contains(KeyMaterial)); } [Fact] diff --git a/tests/AspNetCore.DataProtection.CustomStorage.Tests/TestConstants.cs b/tests/AspNetCore.DataProtection.CustomStorage.Tests/TestConstants.cs index 6260cde..0331773 100644 --- a/tests/AspNetCore.DataProtection.CustomStorage.Tests/TestConstants.cs +++ b/tests/AspNetCore.DataProtection.CustomStorage.Tests/TestConstants.cs @@ -5,14 +5,7 @@ namespace AspNetCore.DataProtection.CustomStorage.Tests; public static class TestConstants { public static readonly string FriendlyName = "friendlyName"; - public static readonly string Xml = """ - - Tove - Jani - Reminder - Don't forget me this weekend! - - """; + public const string KeyMaterial = "c2VjcmV0LWtleS1tYXRlcmlhbA=="; public static readonly XElement XElement = new("root", new XElement("child", new XAttribute("id", "1"), diff --git a/tests/Directory.Build.props b/tests/Directory.Build.props index 9ef5c62..45cf016 100644 --- a/tests/Directory.Build.props +++ b/tests/Directory.Build.props @@ -7,7 +7,7 @@ - net10.0; + net10.0 latest disable enable