diff --git a/FiscalOS.sln b/FiscalOS.sln index d24e8f2..d67874e 100644 --- a/FiscalOS.sln +++ b/FiscalOS.sln @@ -19,6 +19,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FiscalOS.Infra", "src\Fisca EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FiscalOS.Infra.Tests", "tests\FiscalOS.Infra.Tests\FiscalOS.Infra.Tests.csproj", "{48E0737E-0200-468E-ADA7-75893935DBC0}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FiscalOS.Core.Tests", "tests\FiscalOS.Core.Tests\FiscalOS.Core.Tests.csproj", "{2D9C02A9-C704-4275-88E9-1BBD3AC12700}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -101,6 +103,18 @@ Global {48E0737E-0200-468E-ADA7-75893935DBC0}.Release|x64.Build.0 = Release|Any CPU {48E0737E-0200-468E-ADA7-75893935DBC0}.Release|x86.ActiveCfg = Release|Any CPU {48E0737E-0200-468E-ADA7-75893935DBC0}.Release|x86.Build.0 = Release|Any CPU + {2D9C02A9-C704-4275-88E9-1BBD3AC12700}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2D9C02A9-C704-4275-88E9-1BBD3AC12700}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2D9C02A9-C704-4275-88E9-1BBD3AC12700}.Debug|x64.ActiveCfg = Debug|Any CPU + {2D9C02A9-C704-4275-88E9-1BBD3AC12700}.Debug|x64.Build.0 = Debug|Any CPU + {2D9C02A9-C704-4275-88E9-1BBD3AC12700}.Debug|x86.ActiveCfg = Debug|Any CPU + {2D9C02A9-C704-4275-88E9-1BBD3AC12700}.Debug|x86.Build.0 = Debug|Any CPU + {2D9C02A9-C704-4275-88E9-1BBD3AC12700}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2D9C02A9-C704-4275-88E9-1BBD3AC12700}.Release|Any CPU.Build.0 = Release|Any CPU + {2D9C02A9-C704-4275-88E9-1BBD3AC12700}.Release|x64.ActiveCfg = Release|Any CPU + {2D9C02A9-C704-4275-88E9-1BBD3AC12700}.Release|x64.Build.0 = Release|Any CPU + {2D9C02A9-C704-4275-88E9-1BBD3AC12700}.Release|x86.ActiveCfg = Release|Any CPU + {2D9C02A9-C704-4275-88E9-1BBD3AC12700}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -112,5 +126,6 @@ Global {612496CD-1F25-4804-8C52-49E62D01FFDC} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {DAB42870-02A2-4ECB-B07F-6815C52D2435} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {48E0737E-0200-468E-ADA7-75893935DBC0} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + {2D9C02A9-C704-4275-88E9-1BBD3AC12700} = {0AB3BF05-4346-4AA6-1389-037BE0695223} EndGlobalSection EndGlobal diff --git a/tests/.editorconfig b/tests/.editorconfig index 23d43b0..6c14f2f 100644 --- a/tests/.editorconfig +++ b/tests/.editorconfig @@ -1,5 +1,6 @@ [*.cs] +dotnet_diagnostic.CA1303.severity = none dotnet_diagnostic.CA1707.severity = none dotnet_diagnostic.CA1062.severity = none dotnet_diagnostic.CA2007.severity = none diff --git a/tests/FiscalOS.API.Tests/FiscalOS.API.Tests.csproj b/tests/FiscalOS.API.Tests/FiscalOS.API.Tests.csproj index c8ad21e..b3e51ef 100644 --- a/tests/FiscalOS.API.Tests/FiscalOS.API.Tests.csproj +++ b/tests/FiscalOS.API.Tests/FiscalOS.API.Tests.csproj @@ -13,7 +13,6 @@ - diff --git a/tests/FiscalOS.API.Tests/Infra/TestApi.cs b/tests/FiscalOS.API.Tests/Infra/TestApi.cs index 1cafdb7..8df1981 100644 --- a/tests/FiscalOS.API.Tests/Infra/TestApi.cs +++ b/tests/FiscalOS.API.Tests/Infra/TestApi.cs @@ -16,6 +16,8 @@ public class TestApi : WebApplicationFactory })); c.AddSingleton(Options.Create(JwtTokenBuilder.DefaultJwtOptions)); + + c.AddSingleton(TestKeyRing.From); }); } } \ No newline at end of file diff --git a/tests/FiscalOS.API.Tests/Infra/TestKeyRing.cs b/tests/FiscalOS.API.Tests/Infra/TestKeyRing.cs new file mode 100644 index 0000000..52cbb9c --- /dev/null +++ b/tests/FiscalOS.API.Tests/Infra/TestKeyRing.cs @@ -0,0 +1,31 @@ + +namespace FiscalOS.API.Tests.Infra; + +internal sealed class TestKeyRing : IKeyRing +{ + private readonly string _key = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)); + + private TestKeyRing() + { + } + + public static TestKeyRing From(IServiceProvider serviceProvider) + { + return new(); + } + + public KeyRingEntry GetKey(string keyId) + { + return KeyRingEntry.From(keyId, _key); + } + + public KeyRingEntry GetPrimaryKey() + { + return KeyRingEntry.From("primary-key-id", _key); + } + + public Task SaveKeyAsync(string key) + { + throw new NotImplementedException(); + } +} \ No newline at end of file diff --git a/tests/FiscalOS.API.Tests/Integration/LoginTests.cs b/tests/FiscalOS.API.Tests/Integration/LoginTests.cs index f9dbc04..bbae1cb 100644 --- a/tests/FiscalOS.API.Tests/Integration/LoginTests.cs +++ b/tests/FiscalOS.API.Tests/Integration/LoginTests.cs @@ -39,8 +39,10 @@ public class LoginTests(TestApi testApi) : IntegrationTest(testApi) await ExecuteDbContextAsync(static async (context, sp) => { var passwordHasher = sp.GetRequiredService(); + var encryptor = sp.GetRequiredService(); - context.Add(User.From("Stevan", passwordHasher.Hash("@Password1"))); + var userEncryptionKey = await encryptor.GenerateEncryptedKeyAsync(TestContext.Current.CancellationToken); + context.Add(User.From("Stevan", passwordHasher.Hash("@Password1"), userEncryptionKey)); await context.SaveChangesAsync(TestContext.Current.CancellationToken); }); @@ -62,8 +64,10 @@ public class LoginTests(TestApi testApi) : IntegrationTest(testApi) await ExecuteDbContextAsync(static async (context, sp) => { var passwordHasher = sp.GetRequiredService(); + var encryptor = sp.GetRequiredService(); - context.Add(User.From("Stevan", passwordHasher.Hash("@Password1"))); + var userEncryptionKey = await encryptor.GenerateEncryptedKeyAsync(TestContext.Current.CancellationToken); + context.Add(User.From("Stevan", passwordHasher.Hash("@Password1"), userEncryptionKey)); await context.SaveChangesAsync(TestContext.Current.CancellationToken); }); diff --git a/tests/FiscalOS.API.Tests/Integration/RefreshTests.cs b/tests/FiscalOS.API.Tests/Integration/RefreshTests.cs index 2dbf49c..e589297 100644 --- a/tests/FiscalOS.API.Tests/Integration/RefreshTests.cs +++ b/tests/FiscalOS.API.Tests/Integration/RefreshTests.cs @@ -35,9 +35,12 @@ public class RefreshTests(TestApi testApi) : IntegrationTest(testApi) { var passwordHasher = sp.GetRequiredService(); var tokenGenerator = sp.GetRequiredService(); + var encryptor = sp.GetRequiredService(); - var user1 = User.From("User1", passwordHasher.Hash("@Password1")); - var user2 = User.From("User2", passwordHasher.Hash("@Password2")); + var user1EncryptionKey = await encryptor.GenerateEncryptedKeyAsync(TestContext.Current.CancellationToken); + var user2EncryptionKey = await encryptor.GenerateEncryptedKeyAsync(TestContext.Current.CancellationToken); + var user1 = User.From("User1", passwordHasher.Hash("@Password1"), user1EncryptionKey); + var user2 = User.From("User2", passwordHasher.Hash("@Password2"), user2EncryptionKey); var refreshToken1 = tokenGenerator.GenerateRefreshToken(user2); var refreshToken2 = tokenGenerator.GenerateRefreshToken(user2); user2.AddRefreshToken(refreshToken1); @@ -79,8 +82,10 @@ public class RefreshTests(TestApi testApi) : IntegrationTest(testApi) { var passwordHasher = sp.GetRequiredService(); var tokenGenerator = sp.GetRequiredService(); + var encryptor = sp.GetRequiredService(); - var user = User.From("User1", passwordHasher.Hash("@Password1")); + var userEncryptionKey = await encryptor.GenerateEncryptedKeyAsync(TestContext.Current.CancellationToken); + var user = User.From("User1", passwordHasher.Hash("@Password1"), userEncryptionKey); var refreshToken = tokenGenerator.GenerateRefreshToken(user); refreshToken.Revoke(); @@ -113,8 +118,10 @@ public class RefreshTests(TestApi testApi) : IntegrationTest(testApi) var passwordHasher = sp.GetRequiredService(); var tokenGenerator = sp.GetRequiredService(); var timeProvider = sp.GetRequiredService(); + var encryptor = sp.GetRequiredService(); - var user = User.From("User1", passwordHasher.Hash("@Password1")); + var userEncryptionKey = await encryptor.GenerateEncryptedKeyAsync(TestContext.Current.CancellationToken); + var user = User.From("User1", passwordHasher.Hash("@Password1"), userEncryptionKey); var refreshToken = RefreshToken.From(user.Id, "expiredtoken", timeProvider.GetUtcNow().AddHours(-1)); user.AddRefreshToken(refreshToken); @@ -147,8 +154,10 @@ public class RefreshTests(TestApi testApi) : IntegrationTest(testApi) var passwordHasher = sp.GetRequiredService(); var tokenGenerator = sp.GetRequiredService(); var timeProvider = sp.GetRequiredService(); + var encryptor = sp.GetRequiredService(); - var user = User.From("User1", passwordHasher.Hash("@Password1")); + var userEncryptionKey = await encryptor.GenerateEncryptedKeyAsync(TestContext.Current.CancellationToken); + var user = User.From("User1", passwordHasher.Hash("@Password1"), userEncryptionKey); var refreshToken = tokenGenerator.GenerateRefreshToken(user); user.AddRefreshToken(refreshToken); diff --git a/tests/FiscalOS.API.Tests/Usings.cs b/tests/FiscalOS.API.Tests/Usings.cs index 910a3c1..d403688 100644 --- a/tests/FiscalOS.API.Tests/Usings.cs +++ b/tests/FiscalOS.API.Tests/Usings.cs @@ -7,10 +7,12 @@ global using System.Security.Cryptography; global using AwesomeAssertions.Execution; global using AwesomeAssertions.Primitives; + global using FiscalOS.API.Tests.Assertions; global using FiscalOS.API.Tests.Infra; global using FiscalOS.Core.Authentication; global using FiscalOS.Core.Identity; +global using FiscalOS.Core.Security; global using FiscalOS.Infra.Authentication; global using FiscalOS.Infra.Data; diff --git a/tests/FiscalOS.Core.Tests/FiscalOS.Core.Tests.csproj b/tests/FiscalOS.Core.Tests/FiscalOS.Core.Tests.csproj new file mode 100644 index 0000000..7ebc16c --- /dev/null +++ b/tests/FiscalOS.Core.Tests/FiscalOS.Core.Tests.csproj @@ -0,0 +1,23 @@ + + + + Exe + FiscalOS.Core.Tests + + + + + + + + + + + + + + + + + + diff --git a/tests/FiscalOS.Core.Tests/Unit/EncryptedDataKeyTests.cs b/tests/FiscalOS.Core.Tests/Unit/EncryptedDataKeyTests.cs new file mode 100644 index 0000000..8536015 --- /dev/null +++ b/tests/FiscalOS.Core.Tests/Unit/EncryptedDataKeyTests.cs @@ -0,0 +1,36 @@ +namespace FiscalOS.Core.Tests.Unit; + +public class EncryptedDataKeyTests +{ + [Fact] + public void From_WhenCalled_ItShouldReturnAnInstance() + { + var keyId = "key-id"; + var encryptedKey = "encrypted-key"; + + var encryptedDataKey = EncryptedDataKey.From(keyId, encryptedKey); + + encryptedDataKey.KeyIdUsed.Should().Be(keyId); + encryptedDataKey.EncryptedKey.Should().Be(encryptedKey); + } + + [Fact] + public void From_WhenCalledWithNullKeyId_ItShouldThrowArgumentNullException() + { + var encryptedKey = "encrypted-key"; + + var creatingDataKeyWithNullKeyId = () => EncryptedDataKey.From(null!, encryptedKey); + + creatingDataKeyWithNullKeyId.Should().Throw(); + } + + [Fact] + public void From_WhenCalledWithNullEncryptedKey_ItShouldThrowArgumentNullException() + { + var keyId = "key-id"; + + var creatingDataKeyWithNullEncryptedKey = () => EncryptedDataKey.From(keyId, null!); + + creatingDataKeyWithNullEncryptedKey.Should().Throw(); + } +} \ No newline at end of file diff --git a/tests/FiscalOS.Core.Tests/Unit/KeyRingEntryTests.cs b/tests/FiscalOS.Core.Tests/Unit/KeyRingEntryTests.cs new file mode 100644 index 0000000..a3b725e --- /dev/null +++ b/tests/FiscalOS.Core.Tests/Unit/KeyRingEntryTests.cs @@ -0,0 +1,36 @@ +namespace FiscalOS.Core.Tests.Unit; + +public class KeyRingEntryTests +{ + [Fact] + public void From_WhenCalled_ItShouldReturnAnInstance() + { + var keyId = "test-key-id"; + var key = "key"; + + var keyRingEntry = KeyRingEntry.From(keyId, key); + + keyRingEntry.KeyId.Should().Be(keyId); + keyRingEntry.Key.Should().Be(key); + } + + [Fact] + public void From_WhenCalledWithNullKeyId_ItShouldThrowArgumentNullException() + { + var key = "key"; + + var createEntryWithNullKeyId = () => KeyRingEntry.From(null!, key); + + createEntryWithNullKeyId.Should().Throw(); + } + + [Fact] + public void From_WhenCalledWithNullKey_ItShouldThrowArgumentNullException() + { + var keyId = "test-key-id"; + + var createEntryWithNullKey = () => KeyRingEntry.From(keyId, null!); + + createEntryWithNullKey.Should().Throw(); + } +} \ No newline at end of file diff --git a/tests/FiscalOS.Core.Tests/Unit/RefreshTokenTests.cs b/tests/FiscalOS.Core.Tests/Unit/RefreshTokenTests.cs new file mode 100644 index 0000000..de8109f --- /dev/null +++ b/tests/FiscalOS.Core.Tests/Unit/RefreshTokenTests.cs @@ -0,0 +1,82 @@ +namespace FiscalOS.Core.Tests.Unit; + +public class RefreshTokenTests +{ + [Fact] + public void From_WhenCalledWithUserId_ItShouldReturnARefreshTokenInstance() + { + var userId = Guid.NewGuid(); + var token = "token"; + var expiresAt = DateTime.UtcNow.AddDays(7); + + var refreshToken = RefreshToken.From(userId, token, expiresAt); + + refreshToken.UserId.Should().Be(userId); + refreshToken.User.Should().BeNull(); + refreshToken.Token.Should().Be(token); + refreshToken.ExpiresAt.Should().Be(expiresAt); + refreshToken.Revoked.Should().BeFalse(); + } + + [Fact] + public void From_WhenCalledWithUser_ItShouldReturnARefreshTokenInstance() + { + var user = User.From( + "testuser", + "hashedpassword", + EncryptedDataKey.From("keyId", "encryptedKey") + ); + + var token = "token"; + var expiresAt = DateTime.UtcNow.AddDays(7); + + var refreshToken = RefreshToken.From(user, token, expiresAt); + + refreshToken.UserId.Should().Be(user.Id); + refreshToken.User.Should().Be(user); + refreshToken.Token.Should().Be(token); + refreshToken.ExpiresAt.Should().Be(expiresAt); + refreshToken.Revoked.Should().BeFalse(); + } + + [Fact] + public void From_WhenCalledWithNullUser_ItShouldThrowArgumentNullException() + { + var token = "token"; + var expiresAt = DateTime.UtcNow.AddDays(7); + + var createRefreshTokenWithNullUser = () => RefreshToken.From(null!, token, expiresAt); + + createRefreshTokenWithNullUser.Should().Throw(); + } + + [Fact] + public void Revoke_WhenCalled_ItShouldSetRevokedToTrue() + { + var refreshToken = RefreshToken.From(Guid.NewGuid(), "token", DateTime.UtcNow.AddDays(7)); + + refreshToken.Revoke(); + + refreshToken.Revoked.Should().BeTrue(); + } + + [Fact] + public void IsExpired_WhenCalledOnExpiredToken_ItShouldReturnTrue() + { + var refreshToken = RefreshToken.From(Guid.NewGuid(), "token", DateTime.UtcNow.AddDays(-1)); + + var isExpired = refreshToken.IsExpired(DateTimeOffset.UtcNow); + + isExpired.Should().BeTrue(); + } + + [Fact] + public void IsExpired_WhenCalledOnNonExpiredToken_ItShouldReturnFalse() + { + var refreshToken = RefreshToken.From(Guid.NewGuid(), "token", DateTime.UtcNow.AddDays(1)); + + var isExpired = refreshToken.IsExpired(DateTimeOffset.UtcNow); + + isExpired.Should().BeFalse(); + } +} \ No newline at end of file diff --git a/tests/FiscalOS.Core.Tests/Unit/UserTests.cs b/tests/FiscalOS.Core.Tests/Unit/UserTests.cs new file mode 100644 index 0000000..e6c3cb9 --- /dev/null +++ b/tests/FiscalOS.Core.Tests/Unit/UserTests.cs @@ -0,0 +1,90 @@ +namespace FiscalOS.Core.Tests.Unit; + +public class UserTests +{ + [Fact] + public void New_WhenCalled_ItShouldReturnNewUserInstance() + { + var user = User.New(); + + user.Should().NotBeNull(); + user.Username.Should().BeEmpty(); + user.HashedPassword.Should().BeEmpty(); + user.EncryptionKeyId.Should().BeEmpty(); + user.EncryptedDataKey.Should().BeEmpty(); + user.RefreshTokens.Should().BeEmpty(); + } + + [Fact] + public void From_WhenCalledWithValidParameters_ItShouldReturnUserInstance() + { + var encryptedDataKey = EncryptedDataKey.From("keyId", "encryptedKey"); + var user = User.From("testuser", "hashedpassword", encryptedDataKey); + + user.Should().NotBeNull(); + user.Username.Should().Be("testuser"); + user.HashedPassword.Should().Be("hashedpassword"); + user.EncryptionKeyId.Should().Be("keyId"); + user.EncryptedDataKey.Should().Be("encryptedKey"); + user.RefreshTokens.Should().BeEmpty(); + } + + [Fact] + public void From_WhenCalledWithNullParameters_ItShouldThrowArgumentNullException() + { + var encryptedDataKey = EncryptedDataKey.From("keyId", "encryptedKey"); + + var creatingUserWithNullUsername = () => User.From(null!, "hashedpassword", encryptedDataKey); + creatingUserWithNullUsername.Should().Throw(); + + var creatingUserWithNullHashedPassword = () => User.From("testuser", null!, encryptedDataKey); + creatingUserWithNullHashedPassword.Should().Throw(); + + var creatingUserWithNullDataKey = () => User.From("testuser", "hashedpassword", null!); + creatingUserWithNullDataKey.Should().Throw(); + } + + [Fact] + public void AddRefreshToken_WhenCalled_ItShouldAddRefreshTokenToUser() + { + var encryptedDataKey = EncryptedDataKey.From("keyId", "encryptedKey"); + var user = User.From("testuser", "hashedpassword", encryptedDataKey); + var refreshToken = RefreshToken.From(user.Id, "tokenvalue", DateTimeOffset.UtcNow.AddDays(7)); + + user.AddRefreshToken(refreshToken); + + user.RefreshTokens.Should().ContainSingle().Which.Should().Be(refreshToken); + } + + [Fact] + public void AddRefreshToken_WhenCalledWithNull_ItShouldThrowArgumentNullException() + { + var user = User.New(); + + var addingNullRefreshToken = () => user.AddRefreshToken(null!); + + addingNullRefreshToken.Should().Throw(); + } + + [Fact] + public void SetCreatedAt_WhenCalled_ItShouldSetCreatedAtProperty() + { + var user = User.New(); + var createdAt = DateTimeOffset.UtcNow; + + user.SetCreatedAt(createdAt); + + user.CreatedAt.Should().Be(createdAt); + } + + [Fact] + public void SetUpdatedAt_WhenCalled_ItShouldSetUpdatedAtProperty() + { + var user = User.New(); + var updatedAt = DateTimeOffset.UtcNow; + + user.SetUpdatedAt(updatedAt); + + user.UpdatedAt.Should().Be(updatedAt); + } +} \ No newline at end of file diff --git a/tests/FiscalOS.Core.Tests/Usings.cs b/tests/FiscalOS.Core.Tests/Usings.cs new file mode 100644 index 0000000..99c3d62 --- /dev/null +++ b/tests/FiscalOS.Core.Tests/Usings.cs @@ -0,0 +1,2 @@ +global using FiscalOS.Core.Identity; +global using FiscalOS.Core.Security; \ No newline at end of file diff --git a/tests/FiscalOS.Core.Tests/xunit.runner.json b/tests/FiscalOS.Core.Tests/xunit.runner.json new file mode 100644 index 0000000..86c7ea0 --- /dev/null +++ b/tests/FiscalOS.Core.Tests/xunit.runner.json @@ -0,0 +1,3 @@ +{ + "$schema": "https://xunit.net/schema/current/xunit.runner.schema.json" +} diff --git a/tests/FiscalOS.Infra.Tests/FiscalOS.Infra.Tests.csproj b/tests/FiscalOS.Infra.Tests/FiscalOS.Infra.Tests.csproj index 0f8d837..e6ca626 100644 --- a/tests/FiscalOS.Infra.Tests/FiscalOS.Infra.Tests.csproj +++ b/tests/FiscalOS.Infra.Tests/FiscalOS.Infra.Tests.csproj @@ -20,9 +20,4 @@ - - - - diff --git a/tests/FiscalOS.Infra.Tests/Mocks/MockOptionsMonitor.cs b/tests/FiscalOS.Infra.Tests/Mocks/MockOptionsMonitor.cs new file mode 100644 index 0000000..d570157 --- /dev/null +++ b/tests/FiscalOS.Infra.Tests/Mocks/MockOptionsMonitor.cs @@ -0,0 +1,27 @@ +namespace FiscalOS.Infra.Tests.Mocks; + +internal sealed class MockOptionsMonitor : IOptionsMonitor where T : new() +{ + private Action? _listener; + + public T CurrentValue + { + get; + set + { + field = value; + _listener?.Invoke(field, string.Empty); + } + } = new(); + + public T Get(string? name) + { + return CurrentValue; + } + + public IDisposable? OnChange(Action listener) + { + _listener = listener; + return new Mock().Object; + } +} \ No newline at end of file diff --git a/tests/FiscalOS.Infra.Tests/Unit/EncryptorTests.cs b/tests/FiscalOS.Infra.Tests/Unit/EncryptorTests.cs new file mode 100644 index 0000000..b84974c --- /dev/null +++ b/tests/FiscalOS.Infra.Tests/Unit/EncryptorTests.cs @@ -0,0 +1,74 @@ +namespace FiscalOS.Infra.Tests.Unit; + +public class EncryptorTests +{ + private readonly Mock _mockKeyRing = new(); + private readonly Encryptor _sut; + + public EncryptorTests() + { + var key = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)); + _mockKeyRing.Setup(static kr => kr.GetPrimaryKey()).Returns(KeyRingEntry.From("primary-key-id", key)); + _sut = Encryptor.From(_mockKeyRing.Object); + } + + [Fact] + public void GenerateKey_WhenCalled_ItShouldReturnKey() + { + var key = _sut.GenerateKey(); + key.Should().NotBeNull(); + } + + [Fact] + public async Task GenerateEncryptedKeyAsync_WhenCalled_ItShouldReturnEncryptedKey() + { + var encryptedKey = await _sut.GenerateEncryptedKeyAsync(TestContext.Current.CancellationToken); + encryptedKey.Should().NotBeNull(); + } + + [Fact] + public async Task Encrypt_WhenCalledWithPlainText_ItShouldReturnCipherText() + { + var plainText = "Hello, World"; + + var cipherText = await _sut.EncryptAsync(plainText, TestContext.Current.CancellationToken); + + cipherText.Should().NotBe(plainText); + } + + [Fact] + public async Task Decrypt_WhenCalledWithCipherText_ItShouldReturnPlainText() + { + var plainText = "Hello, World"; + var cipherText = await _sut.EncryptAsync(plainText, TestContext.Current.CancellationToken); + + var decryptedData = await _sut.DecryptAsync(cipherText, TestContext.Current.CancellationToken); + + decryptedData.Should().Be(plainText); + } + + [Fact] + public async Task EncryptForUser_WhenCalledWithPlainText_ItShouldReturnCipherText() + { + var plainText = "Hello, World"; + var userEncryptionKey = await _sut.GenerateEncryptedKeyAsync(TestContext.Current.CancellationToken); + var user = User.From("Stevan", "HashedPassword", userEncryptionKey); + + var cipherText = await _sut.EncryptAsyncFor(user, plainText, TestContext.Current.CancellationToken); + + cipherText.Should().NotBe(plainText); + } + + [Fact] + public async Task DecryptForUser_WhenCalledWithCipherText_ItShouldReturnPlainText() + { + var plainText = "Hello, World"; + var encryptedDataKey = await _sut.GenerateEncryptedKeyAsync(TestContext.Current.CancellationToken); + var user = User.From("Stevan", "HashedPassword", encryptedDataKey); + var cipherText = await _sut.EncryptAsyncFor(user, plainText, TestContext.Current.CancellationToken); + + var decryptedData = await _sut.DecryptAsyncFor(user, cipherText, TestContext.Current.CancellationToken); + + decryptedData.Should().Be(plainText); + } +} \ No newline at end of file diff --git a/tests/FiscalOS.Infra.Tests/Unit/FileKeyRingOptionsTests.cs b/tests/FiscalOS.Infra.Tests/Unit/FileKeyRingOptionsTests.cs new file mode 100644 index 0000000..9fb9510 --- /dev/null +++ b/tests/FiscalOS.Infra.Tests/Unit/FileKeyRingOptionsTests.cs @@ -0,0 +1,304 @@ +namespace FiscalOS.Infra.Tests.Unit; + +public class FileKeyRingOptionsTests +{ + [Fact] + public void FileKeyRingOptions_WhenCreatedWithDefaults_ItShouldHaveCorrectDefaultValues() + { + var options = new FileKeyRingOptions(); + + options.KeysDirectoryPath.Should().Be(string.Empty); + options.PrimaryKeyId.Should().Be(string.Empty); + } + + [Fact] + public void FileKeyRingOptions_WhenInitializedWithValues_ItShouldHaveCorrectValues() + { + var keysDirectoryPath = "/secure/keys"; + var primaryKeyId = "primary-key-2024"; + + var options = new FileKeyRingOptions + { + KeysDirectoryPath = keysDirectoryPath, + PrimaryKeyId = primaryKeyId + }; + + options.KeysDirectoryPath.Should().Be(keysDirectoryPath); + options.PrimaryKeyId.Should().Be(primaryKeyId); + } + + [Fact] + public void FileKeyRingOptions_WhenUsedAsRecord_ItShouldSupportEquality() + { + var options1 = new FileKeyRingOptions + { + KeysDirectoryPath = "/keys", + PrimaryKeyId = "key-1" + }; + + var options2 = new FileKeyRingOptions + { + KeysDirectoryPath = "/keys", + PrimaryKeyId = "key-1" + }; + + options1.Should().Be(options2); + } + + [Fact] + public void FileKeyRingOptions_WhenCreatedWithDifferentValues_ItShouldNotBeEqual() + { + var options1 = new FileKeyRingOptions { KeysDirectoryPath = "/keys1" }; + var options2 = new FileKeyRingOptions { KeysDirectoryPath = "/keys2" }; + + options1.Should().NotBe(options2); + } + + [Fact] + public void FileKeyRingOptions_WhenInitializedWithLongPath_ItShouldStoreFullPath() + { + var longPath = "/secure/keys/directory/with/multiple/levels"; + var options = new FileKeyRingOptions { KeysDirectoryPath = longPath }; + + options.KeysDirectoryPath.Should().Be(longPath); + } + + [Fact] + public void FileKeyRingOptions_WhenInitializedWithRelativePath_ItShouldStoreRelativePath() + { + var relativePath = "./keys"; + var options = new FileKeyRingOptions { KeysDirectoryPath = relativePath }; + + options.KeysDirectoryPath.Should().Be(relativePath); + } + + [Fact] + public void FileKeyRingOptions_WhenUsedWithModification_ItShouldSupportWith() + { + var originalOptions = new FileKeyRingOptions + { + KeysDirectoryPath = "/original", + PrimaryKeyId = "original-key" + }; + + var modifiedOptions = originalOptions with { PrimaryKeyId = "modified-key" }; + + originalOptions.PrimaryKeyId.Should().Be("original-key"); + modifiedOptions.PrimaryKeyId.Should().Be("modified-key"); + modifiedOptions.KeysDirectoryPath.Should().Be("/original"); + } + + [Fact] + public void FileKeyRingOptionsSetup_WhenConstructed_ItShouldStoreConfiguration() + { + var configuration = new ConfigurationBuilder().Build(); + + var setup = new FileKeyRingOptionsSetup(configuration); + + setup.Should().NotBeNull(); + } + + [Fact] + public void Configure_WhenCalledWithValidConfiguration_ItShouldBindFileKeyRingOptionsCorrectly() + { + var configBuilder = new ConfigurationBuilder(); + configBuilder.AddInMemoryCollection(new Dictionary + { + { "FileKeyRingOptions:KeysDirectoryPath", "/secure/keys" }, + { "FileKeyRingOptions:PrimaryKeyId", "primary-key-id" } + }); + var configuration = configBuilder.Build(); + var setup = new FileKeyRingOptionsSetup(configuration); + var options = new FileKeyRingOptions(); + + setup.Configure(options); + + options.KeysDirectoryPath.Should().Be("/secure/keys"); + options.PrimaryKeyId.Should().Be("primary-key-id"); + } + + [Fact] + public void Configure_WhenCalledWithPartialConfiguration_ItShouldBindAvailableValuesAndKeepDefaults() + { + var configBuilder = new ConfigurationBuilder(); + configBuilder.AddInMemoryCollection(new Dictionary + { + { "FileKeyRingOptions:KeysDirectoryPath", "/keys" } + }); + var configuration = configBuilder.Build(); + var setup = new FileKeyRingOptionsSetup(configuration); + var options = new FileKeyRingOptions(); + + setup.Configure(options); + + options.KeysDirectoryPath.Should().Be("/keys"); + options.PrimaryKeyId.Should().Be(string.Empty); + } + + [Fact] + public void Configure_WhenCalledWithMissingSection_ItShouldLeaveOptionsUnchanged() + { + var configuration = new ConfigurationBuilder().Build(); + var setup = new FileKeyRingOptionsSetup(configuration); + var options = new FileKeyRingOptions(); + + setup.Configure(options); + + options.KeysDirectoryPath.Should().Be(string.Empty); + options.PrimaryKeyId.Should().Be(string.Empty); + } + + [Fact] + public void Configure_WhenCalledWithEmptyStringValues_ItShouldBindEmptyStrings() + { + var configBuilder = new ConfigurationBuilder(); + configBuilder.AddInMemoryCollection(new Dictionary + { + { "FileKeyRingOptions:KeysDirectoryPath", "" }, + { "FileKeyRingOptions:PrimaryKeyId", "" } + }); + var configuration = configBuilder.Build(); + var setup = new FileKeyRingOptionsSetup(configuration); + var options = new FileKeyRingOptions(); + + setup.Configure(options); + + options.KeysDirectoryPath.Should().Be(string.Empty); + options.PrimaryKeyId.Should().Be(string.Empty); + } + + [Fact] + public void Configure_WhenCalledWithComplexDirectoryPath_ItShouldBindFullPath() + { + var complexPath = "/var/lib/app/secure/encryption/keys/storage"; + var configBuilder = new ConfigurationBuilder(); + configBuilder.AddInMemoryCollection(new Dictionary + { + { "FileKeyRingOptions:KeysDirectoryPath", complexPath } + }); + var configuration = configBuilder.Build(); + var setup = new FileKeyRingOptionsSetup(configuration); + var options = new FileKeyRingOptions(); + + setup.Configure(options); + + options.KeysDirectoryPath.Should().Be(complexPath); + } + + [Fact] + public void Configure_WhenCalledWithRelativeDirectoryPath_ItShouldBindRelativePath() + { + var relativePath = "./keys"; + var configBuilder = new ConfigurationBuilder(); + configBuilder.AddInMemoryCollection(new Dictionary + { + { "FileKeyRingOptions:KeysDirectoryPath", relativePath } + }); + var configuration = configBuilder.Build(); + var setup = new FileKeyRingOptionsSetup(configuration); + var options = new FileKeyRingOptions(); + + setup.Configure(options); + + options.KeysDirectoryPath.Should().Be(relativePath); + } + + [Fact] + public void Configure_WhenCalledWithGuidKeyId_ItShouldBindKeyId() + { + var guidKeyId = "550e8400-e29b-41d4-a716-446655440000"; + var configBuilder = new ConfigurationBuilder(); + configBuilder.AddInMemoryCollection(new Dictionary + { + { "FileKeyRingOptions:PrimaryKeyId", guidKeyId } + }); + var configuration = configBuilder.Build(); + var setup = new FileKeyRingOptionsSetup(configuration); + var options = new FileKeyRingOptions(); + + setup.Configure(options); + + options.PrimaryKeyId.Should().Be(guidKeyId); + } + + [Fact] + public void Configure_WhenCalledMultipleTimes_ItShouldUpdateOptionsEachTime() + { + var configBuilder1 = new ConfigurationBuilder(); + configBuilder1.AddInMemoryCollection(new Dictionary + { + { "FileKeyRingOptions:KeysDirectoryPath", "/keys1" } + }); + var configuration1 = configBuilder1.Build(); + var setup1 = new FileKeyRingOptionsSetup(configuration1); + var options = new FileKeyRingOptions(); + + setup1.Configure(options); + options.KeysDirectoryPath.Should().Be("/keys1"); + + var configBuilder2 = new ConfigurationBuilder(); + configBuilder2.AddInMemoryCollection(new Dictionary + { + { "FileKeyRingOptions:KeysDirectoryPath", "/keys2" } + }); + var configuration2 = configBuilder2.Build(); + var setup2 = new FileKeyRingOptionsSetup(configuration2); + + setup2.Configure(options); + options.KeysDirectoryPath.Should().Be("/keys2"); + } + + [Fact] + public void Configure_WhenCalledWithWindowsPath_ItShouldBindWindowsPath() + { + var windowsPath = "C:\\secure\\keys"; + var configBuilder = new ConfigurationBuilder(); + configBuilder.AddInMemoryCollection(new Dictionary + { + { "FileKeyRingOptions:KeysDirectoryPath", windowsPath } + }); + var configuration = configBuilder.Build(); + var setup = new FileKeyRingOptionsSetup(configuration); + var options = new FileKeyRingOptions(); + + setup.Configure(options); + + options.KeysDirectoryPath.Should().Be(windowsPath); + } + + [Fact] + public void Configure_WhenCalledWithOnlyPrimaryKeyId_ItShouldBindKeyIdAndLeavePathDefault() + { + var configBuilder = new ConfigurationBuilder(); + configBuilder.AddInMemoryCollection(new Dictionary + { + { "FileKeyRingOptions:PrimaryKeyId", "my-key-id" } + }); + var configuration = configBuilder.Build(); + var setup = new FileKeyRingOptionsSetup(configuration); + var options = new FileKeyRingOptions(); + + setup.Configure(options); + + options.PrimaryKeyId.Should().Be("my-key-id"); + options.KeysDirectoryPath.Should().Be(string.Empty); + } + + [Fact] + public void Configure_WhenCalledWithWhitespaceValues_ItShouldBindWhitespace() + { + var whitespaceValue = " "; + var configBuilder = new ConfigurationBuilder(); + configBuilder.AddInMemoryCollection(new Dictionary + { + { "FileKeyRingOptions:KeysDirectoryPath", whitespaceValue } + }); + var configuration = configBuilder.Build(); + var setup = new FileKeyRingOptionsSetup(configuration); + var options = new FileKeyRingOptions(); + + setup.Configure(options); + + options.KeysDirectoryPath.Should().Be(whitespaceValue); + } +} \ No newline at end of file diff --git a/tests/FiscalOS.Infra.Tests/Unit/FileKeyRingTests.cs b/tests/FiscalOS.Infra.Tests/Unit/FileKeyRingTests.cs new file mode 100644 index 0000000..83b93f5 --- /dev/null +++ b/tests/FiscalOS.Infra.Tests/Unit/FileKeyRingTests.cs @@ -0,0 +1,495 @@ +namespace FiscalOS.Infra.Tests.Unit; + +public class FileKeyRingTests +{ + private readonly MockOptionsMonitor _mockOptionsMonitor = new(); + private readonly Mock _mockFileSystem = new(); + + [Fact] + public void From_WhenCalledWithOptionsMonitorAndFileSystem_ItShouldCreateFileKeyRing() + { + var mockPath = new Mock(); + var mockDirectory = new Mock(); + + _mockFileSystem + .Setup(static fs => fs.Path) + .Returns(mockPath.Object); + + _mockFileSystem + .Setup(static fs => fs.Directory) + .Returns(mockDirectory.Object); + + var sut = FileKeyRing.From(_mockOptionsMonitor, _mockFileSystem.Object); + + sut.Should().NotBeNull(); + sut.Should().BeAssignableTo(); + sut.Should().BeAssignableTo(); + } + + [Fact] + public void From_WhenCalledWithServiceProvider_ItShouldCreateFileKeyRing() + { + var mockPath = new Mock(); + var mockDirectory = new Mock(); + var mockFileSystem = new Mock(); + var mockServiceProvider = new Mock(); + var mockOptionsMonitor = new MockOptionsMonitor(); + + mockFileSystem + .Setup(static fs => fs.Path) + .Returns(mockPath.Object); + + mockFileSystem + .Setup(static fs => fs.Directory) + .Returns(mockDirectory.Object); + + mockServiceProvider + .Setup(static sp => sp.GetService(typeof(IOptionsMonitor))) + .Returns(mockOptionsMonitor); + + mockServiceProvider + .Setup(static sp => sp.GetService(typeof(IFileSystem))) + .Returns(mockFileSystem.Object); + + var sut = FileKeyRing.From(mockServiceProvider.Object); + + sut.Should().NotBeNull(); + sut.Should().BeAssignableTo(); + } + + [Fact] + public void GetKey_WhenCalledWithValidKeyId_ItShouldReturnKey() + { + var keyId = "test-key"; + var keyContent = "test-key-content"; + var mockPath = new Mock(); + var mockDirectory = new Mock(); + var mockFile = new Mock(); + var mockDirectoryInfo = new Mock(); + var mockFileInfo = new Mock(); + + _mockOptionsMonitor.CurrentValue = new FileKeyRingOptions + { + KeysDirectoryPath = "/keys", + PrimaryKeyId = keyId + }; + + mockPath + .Setup(p => p.GetFullPath("/keys", It.IsAny())) + .Returns("/full/keys"); + + mockPath + .Setup(p => p.GetFileNameWithoutExtension($"/full/keys/{keyId}.key")) + .Returns(keyId); + + _mockFileSystem.Setup(fs => fs.Path).Returns(mockPath.Object); + + _mockFileSystem + .Setup(fs => fs.Directory.Exists("/full/keys")) + .Returns(true); + + _mockFileSystem + .Setup(fs => fs.Directory.GetFiles("/full/keys", "*.key")) + .Returns([$"/full/keys/{keyId}.key"]); + + _mockFileSystem + .Setup(fs => fs.File.ReadAllText($"/full/keys/{keyId}.key")) + .Returns(keyContent); + + var sut = FileKeyRing.From(_mockOptionsMonitor, _mockFileSystem.Object); + + var result = sut.GetKey(keyId); + + result.Should().NotBeNull(); + result.KeyId.Should().Be(keyId); + } + + [Fact] + public void GetKey_WhenCalledWithInvalidKeyId_ItShouldThrowKeyNotFoundException() + { + var mockPath = new Mock(); + _mockOptionsMonitor.CurrentValue = new FileKeyRingOptions + { + KeysDirectoryPath = "/keys", + PrimaryKeyId = "valid-key" + }; + + mockPath + .Setup(p => p.GetFullPath(It.IsAny(), It.IsAny())) + .Returns("/full/keys"); + + _mockFileSystem.Setup(fs => fs.Path).Returns(mockPath.Object); + + _mockFileSystem + .Setup(fs => fs.Directory.Exists(It.IsAny())) + .Returns(false); + + var sut = FileKeyRing.From(_mockOptionsMonitor, _mockFileSystem.Object); + + var action = () => sut.GetKey("non-existent-key"); + + action.Should().Throw(); + } + + [Fact] + public void GetPrimaryKey_WhenPrimaryKeyExists_ItShouldReturnPrimaryKey() + { + var primaryKeyId = "primary-key"; + var keyContent = "primary-key-content"; + var mockPath = new Mock(); + + _mockOptionsMonitor.CurrentValue = new FileKeyRingOptions + { + KeysDirectoryPath = "/keys", + PrimaryKeyId = primaryKeyId + }; + + mockPath + .Setup(p => p.GetFullPath("/keys", It.IsAny())) + .Returns("/full/keys"); + + mockPath + .Setup(p => p.GetFileNameWithoutExtension($"/full/keys/{primaryKeyId}.key")) + .Returns(primaryKeyId); + + _mockFileSystem.Setup(fs => fs.Path).Returns(mockPath.Object); + + _mockFileSystem + .Setup(fs => fs.Directory.Exists("/full/keys")) + .Returns(true); + + _mockFileSystem + .Setup(fs => fs.Directory.GetFiles("/full/keys", "*.key")) + .Returns([$"/full/keys/{primaryKeyId}.key"]); + + _mockFileSystem + .Setup(fs => fs.File.ReadAllText($"/full/keys/{primaryKeyId}.key")) + .Returns(keyContent); + + var sut = FileKeyRing.From(_mockOptionsMonitor, _mockFileSystem.Object); + + var result = sut.GetPrimaryKey(); + + result.Should().NotBeNull(); + result.KeyId.Should().Be(primaryKeyId); + } + + [Fact] + public void GetPrimaryKey_WhenPrimaryKeyDoesNotExist_ItShouldThrowKeyNotFoundException() + { + var mockPath = new Mock(); + + _mockOptionsMonitor.CurrentValue = new FileKeyRingOptions + { + KeysDirectoryPath = "/keys", + PrimaryKeyId = "non-existent-primary-key" + }; + + mockPath + .Setup(static p => p.GetFullPath(It.IsAny(), It.IsAny())) + .Returns("/full/keys"); + + _mockFileSystem.Setup(static fs => fs.Path).Returns(mockPath.Object); + _mockFileSystem + .Setup(static fs => fs.Directory.Exists(It.IsAny())) + .Returns(false); + + var sut = FileKeyRing.From(_mockOptionsMonitor, _mockFileSystem.Object); + + var action = sut.GetPrimaryKey; + + action.Should().Throw(); + } + + [Fact] + public void FileKeyRing_WhenOptionsChangeHandlerInvoked_ItShouldReloadKeys() + { + var initialKeyId = "initial-key"; + var initialKeyContent = "initial-key-content"; + var updatedKeyId = "updated-key"; + var updatedKeyContent = "updated-key-content"; + var mockPath = new Mock(); + + _mockOptionsMonitor.CurrentValue = new FileKeyRingOptions + { + KeysDirectoryPath = "/keys", + PrimaryKeyId = initialKeyId + }; + + mockPath + .Setup(p => p.GetFullPath("/keys", It.IsAny())) + .Returns("/full/keys"); + + mockPath + .Setup(p => p.GetFileNameWithoutExtension($"/full/keys/{initialKeyId}.key")) + .Returns(initialKeyId); + + mockPath + .Setup(p => p.GetFileNameWithoutExtension($"/full/keys/{updatedKeyId}.key")) + .Returns(updatedKeyId); + + _mockFileSystem.Setup(fs => fs.Path).Returns(mockPath.Object); + + _mockFileSystem + .Setup(fs => fs.Directory.Exists("/full/keys")) + .Returns(true); + + _mockFileSystem + .SetupSequence(fs => fs.Directory.GetFiles("/full/keys", "*.key")) + .Returns([$"/full/keys/{initialKeyId}.key"]); + + _mockFileSystem + .Setup(fs => fs.File.ReadAllText($"/full/keys/{initialKeyId}.key")) + .Returns(initialKeyContent); + + var sut = FileKeyRing.From(_mockOptionsMonitor, _mockFileSystem.Object); + + var initialKey = sut.GetKey(initialKeyId); + initialKey.Should().NotBeNull(); + + _mockFileSystem + .Setup(fs => fs.Directory.GetFiles("/full/keys", "*.key")) + .Returns([$"/full/keys/{updatedKeyId}.key"]); + + _mockFileSystem + .Setup(fs => fs.File.ReadAllText($"/full/keys/{updatedKeyId}.key")) + .Returns(updatedKeyContent); + + _mockOptionsMonitor.CurrentValue = new FileKeyRingOptions + { + KeysDirectoryPath = "/keys", + PrimaryKeyId = updatedKeyId + }; + + var updatedKey = sut.GetKey(updatedKeyId); + updatedKey.Should().NotBeNull(); + updatedKey.KeyId.Should().Be(updatedKeyId); + } + + [Fact] + public void FileKeyRing_WhenDirectoryDoesNotExist_ItShouldNotThrowAndLoadKeysReturnsEmpty() + { + var mockPath = new Mock(); + + _mockOptionsMonitor.CurrentValue = new FileKeyRingOptions + { + KeysDirectoryPath = "/non-existent-keys", + PrimaryKeyId = "any-key" + }; + + mockPath + .Setup(p => p.GetFullPath("/non-existent-keys", It.IsAny())) + .Returns("/full/non-existent-keys"); + + _mockFileSystem.Setup(fs => fs.Path).Returns(mockPath.Object); + + _mockFileSystem + .Setup(fs => fs.Directory.Exists("/full/non-existent-keys")) + .Returns(false); + + var sut = FileKeyRing.From(_mockOptionsMonitor, _mockFileSystem.Object); + + var action = () => sut.GetKey("any-key"); + action.Should().Throw(); + } + + [Fact] + public void FileKeyRing_WhenKeyFileContainsOnlyWhitespace_ItShouldSkipKeyAndNotLoad() + { + var validKeyId = "valid-key"; + var validKeyContent = "valid-key-content"; + var whitespaceKeyId = "whitespace-key"; + var mockPath = new Mock(); + + _mockOptionsMonitor.CurrentValue = new FileKeyRingOptions + { + KeysDirectoryPath = "/keys", + PrimaryKeyId = validKeyId + }; + + mockPath + .Setup(p => p.GetFullPath("/keys", It.IsAny())) + .Returns("/full/keys"); + + mockPath + .Setup(p => p.GetFileNameWithoutExtension($"/full/keys/{validKeyId}.key")) + .Returns(validKeyId); + + mockPath + .Setup(p => p.GetFileNameWithoutExtension($"/full/keys/{whitespaceKeyId}.key")) + .Returns(whitespaceKeyId); + + _mockFileSystem.Setup(fs => fs.Path).Returns(mockPath.Object); + + _mockFileSystem + .Setup(fs => fs.Directory.Exists("/full/keys")) + .Returns(true); + + _mockFileSystem + .Setup(fs => fs.Directory.GetFiles("/full/keys", "*.key")) + .Returns( + [ + $"/full/keys/{validKeyId}.key", + $"/full/keys/{whitespaceKeyId}.key" + ]); + + _mockFileSystem + .Setup(fs => fs.File.ReadAllText($"/full/keys/{validKeyId}.key")) + .Returns(validKeyContent); + + _mockFileSystem + .Setup(fs => fs.File.ReadAllText($"/full/keys/{whitespaceKeyId}.key")) + .Returns(" \n\t "); + + var sut = FileKeyRing.From(_mockOptionsMonitor, _mockFileSystem.Object); + + var validKey = sut.GetKey(validKeyId); + validKey.Should().NotBeNull(); + + var action = () => sut.GetKey(whitespaceKeyId); + action.Should().Throw(); + } + + [Fact] + public void FileKeyRing_WhenMultipleKeysExist_ItShouldLoadAllKeys() + { + var key1Id = "key-1"; + var key1Content = "key-1-content"; + var key2Id = "key-2"; + var key2Content = "key-2-content"; + var key3Id = "key-3"; + var key3Content = "key-3-content"; + var mockPath = new Mock(); + + _mockOptionsMonitor.CurrentValue = new FileKeyRingOptions + { + KeysDirectoryPath = "/keys", + PrimaryKeyId = key1Id + }; + + mockPath + .Setup(p => p.GetFullPath("/keys", It.IsAny())) + .Returns("/full/keys"); + + mockPath + .Setup(p => p.GetFileNameWithoutExtension($"/full/keys/{key1Id}.key")) + .Returns(key1Id); + + mockPath + .Setup(p => p.GetFileNameWithoutExtension($"/full/keys/{key2Id}.key")) + .Returns(key2Id); + + mockPath + .Setup(p => p.GetFileNameWithoutExtension($"/full/keys/{key3Id}.key")) + .Returns(key3Id); + + _mockFileSystem.Setup(fs => fs.Path).Returns(mockPath.Object); + _mockFileSystem + .Setup(fs => fs.Directory.Exists("/full/keys")) + .Returns(true); + + _mockFileSystem + .Setup(fs => fs.Directory.GetFiles("/full/keys", "*.key")) + .Returns( + [ + $"/full/keys/{key1Id}.key", + $"/full/keys/{key2Id}.key", + $"/full/keys/{key3Id}.key" + ]); + + _mockFileSystem + .Setup(fs => fs.File.ReadAllText($"/full/keys/{key1Id}.key")) + .Returns(key1Content); + + _mockFileSystem + .Setup(fs => fs.File.ReadAllText($"/full/keys/{key2Id}.key")) + .Returns(key2Content); + + _mockFileSystem + .Setup(fs => fs.File.ReadAllText($"/full/keys/{key3Id}.key")) + .Returns(key3Content); + + var sut = FileKeyRing.From(_mockOptionsMonitor, _mockFileSystem.Object); + + var retrievedKey1 = sut.GetKey(key1Id); + var retrievedKey2 = sut.GetKey(key2Id); + var retrievedKey3 = sut.GetKey(key3Id); + + retrievedKey1.Should().NotBeNull(); + retrievedKey2.Should().NotBeNull(); + retrievedKey3.Should().NotBeNull(); + retrievedKey1.KeyId.Should().Be(key1Id); + retrievedKey2.KeyId.Should().Be(key2Id); + retrievedKey3.KeyId.Should().Be(key3Id); + } + + [Fact] + public void Dispose_WhenCalled_ItShouldDisposeOptionsChangeHandler() + { + var mockPath = new Mock(); + + _mockOptionsMonitor.CurrentValue = new FileKeyRingOptions + { + KeysDirectoryPath = "/keys", + PrimaryKeyId = "key" + }; + + mockPath + .Setup(static p => p.GetFullPath(It.IsAny(), It.IsAny())) + .Returns("/full/keys"); + + _mockFileSystem.Setup(static fs => fs.Path).Returns(mockPath.Object); + + _mockFileSystem + .Setup(static fs => fs.Directory.Exists(It.IsAny())) + .Returns(false); + + using var sut = FileKeyRing.From(_mockOptionsMonitor, _mockFileSystem.Object); + + var action = sut.Dispose; + action.Should().NotThrow(); + } + + [Fact] + public void FileKeyRing_WhenKeyTrimsWhitespace_ItShouldLoadKeyWithTrimmedContent() + { + var keyId = "test-key"; + var keyContentWithWhitespace = " \n test-key-content \t "; + var mockPath = new Mock(); + + _mockOptionsMonitor.CurrentValue = new FileKeyRingOptions + { + KeysDirectoryPath = "/keys", + PrimaryKeyId = keyId + }; + + mockPath + .Setup(p => p.GetFullPath("/keys", It.IsAny())) + .Returns("/full/keys"); + + mockPath + .Setup(p => p.GetFileNameWithoutExtension($"/full/keys/{keyId}.key")) + .Returns(keyId); + + _mockFileSystem.Setup(fs => fs.Path).Returns(mockPath.Object); + + _mockFileSystem + .Setup(fs => fs.Directory.Exists("/full/keys")) + .Returns(true); + + _mockFileSystem + .Setup(fs => fs.Directory.GetFiles("/full/keys", "*.key")) + .Returns([$"/full/keys/{keyId}.key"]); + + _mockFileSystem + .Setup(fs => fs.File.ReadAllText($"/full/keys/{keyId}.key")) + .Returns(keyContentWithWhitespace); + + var sut = FileKeyRing.From(_mockOptionsMonitor, _mockFileSystem.Object); + + var result = sut.GetKey(keyId); + + result.Should().NotBeNull(); + result.Key.Should().Be("test-key-content"); + } +} \ No newline at end of file diff --git a/tests/FiscalOS.Infra.Tests/Unit/JwtOptionsTests.cs b/tests/FiscalOS.Infra.Tests/Unit/JwtOptionsTests.cs new file mode 100644 index 0000000..2c741c2 --- /dev/null +++ b/tests/FiscalOS.Infra.Tests/Unit/JwtOptionsTests.cs @@ -0,0 +1,380 @@ +namespace FiscalOS.Infra.Tests.Unit; + +public class JwtOptionsTests +{ + [Fact] + public void JwtOptions_WhenCreatedWithDefaults_ItShouldHaveCorrectDefaultValues() + { + var options = new JwtOptions(); + + options.Issuer.Should().Be(string.Empty); + options.Audience.Should().Be(string.Empty); + options.Secret.Should().Be(string.Empty); + options.ExpiryInMinutes.Should().Be(5); + } + + [Fact] + public void JwtOptions_WhenInitializedWithValues_ItShouldHaveCorrectValues() + { + var issuer = "test-issuer"; + var audience = "test-audience"; + var secret = "test-secret-key"; + var expiryInMinutes = 30; + + var options = new JwtOptions + { + Issuer = issuer, + Audience = audience, + Secret = secret, + ExpiryInMinutes = expiryInMinutes + }; + + options.Issuer.Should().Be(issuer); + options.Audience.Should().Be(audience); + options.Secret.Should().Be(secret); + options.ExpiryInMinutes.Should().Be(expiryInMinutes); + } + + [Fact] + public void JwtOptions_WhenSecretIsSet_ItShouldReturnSymmetricSecurityKey() + { + var secret = "my-super-secret-key-12345"; + var options = new JwtOptions { Secret = secret }; + + var result = options.Key; + + result.Should().NotBeNull(); + result.Should().BeOfType(); + } + + [Fact] + public void JwtOptions_WhenSecretIsSet_ItShouldEncodeSecretAsUtf8Bytes() + { + var secret = "test-secret"; + var options = new JwtOptions { Secret = secret }; + + var result = options.Key; + var expectedKey = Encoding.UTF8.GetBytes(secret); + + result.Key.Should().BeEquivalentTo(expectedKey); + } + + [Fact] + public void JwtOptions_WhenSecretChanges_ItShouldReturnUpdatedSymmetricSecurityKey() + { + var initialSecret = "initial-secret"; + var newSecret = "new-secret"; + var options = new JwtOptions { Secret = initialSecret }; + + var initialKey = options.Key; + var updatedOptions = options with { Secret = newSecret }; + var updatedKey = updatedOptions.Key; + + initialKey.Key.Should().NotBeEquivalentTo(updatedKey.Key); + } + + [Fact] + public void JwtOptions_WhenUsedAsRecord_ItShouldSupportEquality() + { + var options1 = new JwtOptions + { + Issuer = "issuer", + Audience = "audience", + Secret = "secret", + ExpiryInMinutes = 30 + }; + + var options2 = new JwtOptions + { + Issuer = "issuer", + Audience = "audience", + Secret = "secret", + ExpiryInMinutes = 30 + }; + + options1.Should().Be(options2); + } + + [Fact] + public void JwtOptions_WhenCreatedWithDifferentValues_ItShouldNotBeEqual() + { + var options1 = new JwtOptions { Secret = "secret1" }; + var options2 = new JwtOptions { Secret = "secret2" }; + + options1.Should().NotBe(options2); + } + + [Fact] + public void JwtOptionsSetup_WhenConstructed_ItShouldStoreConfiguration() + { + var configuration = new ConfigurationBuilder().Build(); + + var setup = new JwtOptionsSetup(configuration); + + setup.Should().NotBeNull(); + } + + [Fact] + public void Configure_WhenCalledWithValidConfiguration_ItShouldBindJwtOptionsCorrectly() + { + var configBuilder = new ConfigurationBuilder(); + configBuilder.AddInMemoryCollection(new Dictionary + { + { "JwtOptions:Issuer", "test-issuer" }, + { "JwtOptions:Audience", "test-audience" }, + { "JwtOptions:Secret", "test-secret" }, + { "JwtOptions:ExpiryInMinutes", "60" } + }); + var configuration = configBuilder.Build(); + var setup = new JwtOptionsSetup(configuration); + var options = new JwtOptions(); + + setup.Configure(options); + + options.Issuer.Should().Be("test-issuer"); + options.Audience.Should().Be("test-audience"); + options.Secret.Should().Be("test-secret"); + options.ExpiryInMinutes.Should().Be(60); + } + + [Fact] + public void Configure_WhenCalledWithPartialConfiguration_ItShouldBindAvailableValuesAndKeepDefaults() + { + var configBuilder = new ConfigurationBuilder(); + configBuilder.AddInMemoryCollection(new Dictionary + { + { "JwtOptions:Issuer", "test-issuer" } + }); + var configuration = configBuilder.Build(); + var setup = new JwtOptionsSetup(configuration); + var options = new JwtOptions(); + + setup.Configure(options); + + options.Issuer.Should().Be("test-issuer"); + options.Audience.Should().Be(string.Empty); + options.Secret.Should().Be(string.Empty); + options.ExpiryInMinutes.Should().Be(5); + } + + [Fact] + public void Configure_WhenCalledWithMissingSection_ItShouldLeaveOptionsUnchanged() + { + var configuration = new ConfigurationBuilder().Build(); + var setup = new JwtOptionsSetup(configuration); + var options = new JwtOptions(); + + setup.Configure(options); + + options.Issuer.Should().Be(string.Empty); + options.Audience.Should().Be(string.Empty); + options.Secret.Should().Be(string.Empty); + options.ExpiryInMinutes.Should().Be(5); + } + + [Fact] + public void Configure_WhenCalledWithEmptyStringValues_ItShouldBindEmptyStrings() + { + var configBuilder = new ConfigurationBuilder(); + configBuilder.AddInMemoryCollection(new Dictionary + { + { "JwtOptions:Issuer", "" }, + { "JwtOptions:Audience", "" }, + { "JwtOptions:Secret", "" } + }); + var configuration = configBuilder.Build(); + var setup = new JwtOptionsSetup(configuration); + var options = new JwtOptions(); + + setup.Configure(options); + + options.Issuer.Should().Be(string.Empty); + options.Audience.Should().Be(string.Empty); + options.Secret.Should().Be(string.Empty); + } + + [Fact] + public void Configure_WhenCalledWithNumericString_ItShouldBindExpiryInMinutesAsInteger() + { + var configBuilder = new ConfigurationBuilder(); + configBuilder.AddInMemoryCollection(new Dictionary + { + { "JwtOptions:ExpiryInMinutes", "120" } + }); + var configuration = configBuilder.Build(); + var setup = new JwtOptionsSetup(configuration); + var options = new JwtOptions(); + + setup.Configure(options); + + options.ExpiryInMinutes.Should().Be(120); + } + + [Fact] + public void Configure_WhenCalledMultipleTimes_ItShouldUpdateOptionsEachTime() + { + var configBuilder = new ConfigurationBuilder(); + configBuilder.AddInMemoryCollection(new Dictionary + { + { "JwtOptions:Issuer", "issuer-1" } + }); + var configuration = configBuilder.Build(); + var setup = new JwtOptionsSetup(configuration); + var options = new JwtOptions(); + + setup.Configure(options); + options.Issuer.Should().Be("issuer-1"); + + var configBuilder2 = new ConfigurationBuilder(); + configBuilder2.AddInMemoryCollection(new Dictionary + { + { "JwtOptions:Issuer", "issuer-2" } + }); + var configuration2 = configBuilder2.Build(); + var setup2 = new JwtOptionsSetup(configuration2); + + setup2.Configure(options); + options.Issuer.Should().Be("issuer-2"); + } + + [Fact] + public void Configure_WhenCalledWithDefaultScheme_ItShouldSetupJwtBearerOptionsWithValidation() + { + var jwtOptions = new JwtOptions + { + Issuer = "test-issuer", + Audience = "test-audience", + Secret = "test-secret-key-for-jwt-validation" + }; + var options = Options.Create(jwtOptions); + var setup = new JwtBearerOptionsSetup(options); + var bearerOptions = new JwtBearerOptions(); + + setup.Configure(JwtBearerDefaults.AuthenticationScheme, bearerOptions); + + bearerOptions.TokenValidationParameters.Should().NotBeNull(); + bearerOptions.TokenValidationParameters!.ValidIssuer.Should().Be("test-issuer"); + bearerOptions.TokenValidationParameters.ValidAudience.Should().Be("test-audience"); + bearerOptions.TokenValidationParameters.ValidateIssuer.Should().BeTrue(); + bearerOptions.TokenValidationParameters.ValidateAudience.Should().BeTrue(); + bearerOptions.TokenValidationParameters.ValidateIssuerSigningKey.Should().BeTrue(); + bearerOptions.TokenValidationParameters.ValidateLifetime.Should().BeTrue(); + } + + [Fact] + public void Configure_WhenCalledWithAllowExpiredTokensScheme_ItShouldDisableLifetimeValidation() + { + var jwtOptions = new JwtOptions + { + Issuer = "test-issuer", + Audience = "test-audience", + Secret = "test-secret-key-for-jwt-validation" + }; + var options = Options.Create(jwtOptions); + var setup = new JwtBearerOptionsSetup(options); + var bearerOptions = new JwtBearerOptions(); + + setup.Configure(Schemes.AllowExpiredTokens, bearerOptions); + + bearerOptions.TokenValidationParameters.Should().NotBeNull(); + bearerOptions.TokenValidationParameters!.ValidateLifetime.Should().BeFalse(); + } + + [Fact] + public void Configure_WhenCalledWithNullSchemeName_ItShouldSetupWithValidation() + { + var jwtOptions = new JwtOptions + { + Issuer = "test-issuer", + Audience = "test-audience", + Secret = "test-secret-key-for-jwt-validation" + }; + var options = Options.Create(jwtOptions); + var setup = new JwtBearerOptionsSetup(options); + var bearerOptions = new JwtBearerOptions(); + + setup.Configure(null, bearerOptions); + + bearerOptions.TokenValidationParameters.Should().NotBeNull(); + bearerOptions.TokenValidationParameters!.ValidateLifetime.Should().BeTrue(); + } + + [Fact] + public void Configure_WhenCalledWithoutNameParameter_ItShouldSetupWithValidation() + { + var jwtOptions = new JwtOptions + { + Issuer = "test-issuer", + Audience = "test-audience", + Secret = "test-secret-key-for-jwt-validation" + }; + var options = Options.Create(jwtOptions); + var setup = new JwtBearerOptionsSetup(options); + var bearerOptions = new JwtBearerOptions(); + + setup.Configure(bearerOptions); + + bearerOptions.TokenValidationParameters.Should().NotBeNull(); + bearerOptions.TokenValidationParameters!.ValidateLifetime.Should().BeTrue(); + } + + [Fact] + public void Configure_WhenCalledWithDifferentSchemeNames_ItShouldOnlyDisableLifetimeForAllowExpiredTokens() + { + var jwtOptions = new JwtOptions + { + Issuer = "test-issuer", + Audience = "test-audience", + Secret = "test-secret-key-for-jwt-validation" + }; + var options = Options.Create(jwtOptions); + var setup = new JwtBearerOptionsSetup(options); + + var defaultBearerOptions = new JwtBearerOptions(); + setup.Configure(Schemes.Default, defaultBearerOptions); + + var allowExpiredBearerOptions = new JwtBearerOptions(); + setup.Configure(Schemes.AllowExpiredTokens, allowExpiredBearerOptions); + + defaultBearerOptions.TokenValidationParameters!.ValidateLifetime.Should().BeTrue(); + allowExpiredBearerOptions.TokenValidationParameters!.ValidateLifetime.Should().BeFalse(); + } + + [Fact] + public void Configure_WhenCalledWithCustomSchemeName_ItShouldUseDefaultValidationBehavior() + { + var jwtOptions = new JwtOptions + { + Issuer = "test-issuer", + Audience = "test-audience", + Secret = "test-secret-key-for-jwt-validation" + }; + var options = Options.Create(jwtOptions); + var setup = new JwtBearerOptionsSetup(options); + var bearerOptions = new JwtBearerOptions(); + + setup.Configure("CustomScheme", bearerOptions); + + bearerOptions.TokenValidationParameters!.ValidateLifetime.Should().BeTrue(); + } + + [Fact] + public void Configure_WhenCalled_ItShouldSetIssuerSigningKeyFromJwtOptions() + { + var secret = "test-secret-key-for-jwt-validation"; + var jwtOptions = new JwtOptions + { + Issuer = "test-issuer", + Audience = "test-audience", + Secret = secret + }; + var options = Options.Create(jwtOptions); + var setup = new JwtBearerOptionsSetup(options); + var bearerOptions = new JwtBearerOptions(); + + setup.Configure(bearerOptions); + + var expectedKey = jwtOptions.Key; + bearerOptions.TokenValidationParameters!.IssuerSigningKey.Should().BeEquivalentTo(expectedKey); + } +} \ No newline at end of file diff --git a/tests/FiscalOS.Infra.Tests/Unit/PasswordHasherTests.cs b/tests/FiscalOS.Infra.Tests/Unit/PasswordHasherTests.cs index a731e08..19f9de7 100644 --- a/tests/FiscalOS.Infra.Tests/Unit/PasswordHasherTests.cs +++ b/tests/FiscalOS.Infra.Tests/Unit/PasswordHasherTests.cs @@ -1,5 +1,3 @@ -using FiscalOS.Infra.Authentication; - namespace FiscalOS.Infra.Tests.Unit; public class PasswordHasherTests diff --git a/tests/FiscalOS.Infra.Tests/Unit/ProblemDetailsAuthResultHandlerTests.cs b/tests/FiscalOS.Infra.Tests/Unit/ProblemDetailsAuthResultHandlerTests.cs new file mode 100644 index 0000000..5b74d3c --- /dev/null +++ b/tests/FiscalOS.Infra.Tests/Unit/ProblemDetailsAuthResultHandlerTests.cs @@ -0,0 +1,325 @@ +namespace FiscalOS.Infra.Tests.Unit; + +public class ProblemDetailsAuthResultHandlerTests +{ + private readonly ProblemDetailsAuthResultHandler _sut = new(); + + [Fact] + public async Task HandleAsync_WhenAuthorizationSucceeds_ItShouldCallNextDelegate() + { + var nextCalled = false; + RequestDelegate next = _ => + { + nextCalled = true; + return Task.CompletedTask; + }; + + var context = new DefaultHttpContext(); + var policy = CreateValidAuthorizationPolicy(); + var authorizeResult = PolicyAuthorizationResult.Success(); + + await _sut.HandleAsync(next, context, policy, authorizeResult); + + nextCalled.Should().BeTrue(); + } + + [Fact] + public async Task HandleAsync_WhenAuthorizationSucceeds_ItShouldNotModifyResponseStatusCode() + { + var originalStatusCode = StatusCodes.Status200OK; + RequestDelegate next = _ => Task.CompletedTask; + + var context = new DefaultHttpContext { Response = { StatusCode = originalStatusCode } }; + var policy = CreateValidAuthorizationPolicy(); + var authorizeResult = PolicyAuthorizationResult.Success(); + + await _sut.HandleAsync(next, context, policy, authorizeResult); + + context.Response.StatusCode.Should().Be(originalStatusCode); + } + + [Fact] + public async Task HandleAsync_WhenAuthorizationSucceeds_ItShouldNotWriteToResponseBody() + { + RequestDelegate next = _ => Task.CompletedTask; + + var context = new DefaultHttpContext(); + var policy = CreateValidAuthorizationPolicy(); + var authorizeResult = PolicyAuthorizationResult.Success(); + + await _sut.HandleAsync(next, context, policy, authorizeResult); + + context.Response.Body.Length.Should().Be(0); + } + + [Fact] + public async Task HandleAsync_WhenAuthorizationResultIsForbidden_ItShouldSetStatusCodeTo403() + { + RequestDelegate next = _ => Task.CompletedTask; + + var context = new DefaultHttpContext(); + var policy = CreateValidAuthorizationPolicy(); + var authorizeResult = PolicyAuthorizationResult.Forbid(); + + await _sut.HandleAsync(next, context, policy, authorizeResult); + + context.Response.StatusCode.Should().Be(StatusCodes.Status403Forbidden); + } + + [Fact] + public async Task HandleAsync_WhenAuthorizationResultIsForbidden_ItShouldSetContentTypeToApplicationProblemJson() + { + RequestDelegate next = _ => Task.CompletedTask; + + var context = new DefaultHttpContext(); + var policy = CreateValidAuthorizationPolicy(); + var authorizeResult = PolicyAuthorizationResult.Forbid(); + + await _sut.HandleAsync(next, context, policy, authorizeResult); + + context.Response.ContentType.Should().Be("application/problem+json"); + } + + [Fact] + public async Task HandleAsync_WhenAuthorizationResultIsForbidden_ItShouldWriteProblemDetailsWithForbiddenTitle() + { + RequestDelegate next = _ => Task.CompletedTask; + + var context = new DefaultHttpContext(); + context.Response.Body = new MemoryStream(); + + var policy = CreateValidAuthorizationPolicy(); + var authorizeResult = PolicyAuthorizationResult.Forbid(); + + await _sut.HandleAsync(next, context, policy, authorizeResult); + + context.Response.Body.Seek(0, SeekOrigin.Begin); + using var stream = new StreamReader(context.Response.Body); + var responseBody = await stream.ReadToEndAsync(TestContext.Current.CancellationToken); + var problemDetails = JsonSerializer.Deserialize(responseBody); + + problemDetails!.Title.Should().Be("Forbidden"); + } + + [Fact] + public async Task HandleAsync_WhenAuthorizationResultIsForbidden_ItShouldWriteProblemDetailsWithCorrectStatus() + { + RequestDelegate next = _ => Task.CompletedTask; + + var context = new DefaultHttpContext(); + context.Response.Body = new MemoryStream(); + + var policy = CreateValidAuthorizationPolicy(); + var authorizeResult = PolicyAuthorizationResult.Forbid(); + + await _sut.HandleAsync(next, context, policy, authorizeResult); + + context.Response.Body.Seek(0, SeekOrigin.Begin); + using var stream = new StreamReader(context.Response.Body); + var responseBody = await stream.ReadToEndAsync(TestContext.Current.CancellationToken); + var problemDetails = JsonSerializer.Deserialize(responseBody); + + problemDetails!.Status.Should().Be(StatusCodes.Status403Forbidden); + } + + [Fact] + public async Task HandleAsync_WhenAuthorizationResultIsForbidden_ItShouldWriteProblemDetailsWithCorrectDetail() + { + RequestDelegate next = _ => Task.CompletedTask; + + var context = new DefaultHttpContext(); + context.Response.Body = new MemoryStream(); + + var policy = CreateValidAuthorizationPolicy(); + var authorizeResult = PolicyAuthorizationResult.Forbid(); + + await _sut.HandleAsync(next, context, policy, authorizeResult); + + context.Response.Body.Seek(0, SeekOrigin.Begin); + using var stream = new StreamReader(context.Response.Body); + var responseBody = await stream.ReadToEndAsync(TestContext.Current.CancellationToken); + var problemDetails = JsonSerializer.Deserialize(responseBody); + + problemDetails!.Detail.Should().Be("You do not have permission to access this resource."); + } + + [Fact] + public async Task HandleAsync_WhenAuthorizationResultIsForbidden_ItShouldNotCallNextDelegate() + { + var nextCalled = false; + RequestDelegate next = _ => + { + nextCalled = true; + return Task.CompletedTask; + }; + + var context = new DefaultHttpContext(); + context.Response.Body = new MemoryStream(); + + var policy = CreateValidAuthorizationPolicy(); + var authorizeResult = PolicyAuthorizationResult.Forbid(); + + await _sut.HandleAsync(next, context, policy, authorizeResult); + + nextCalled.Should().BeFalse(); + } + + [Fact] + public async Task HandleAsync_WhenAuthorizationResultIsUnauthorized_ItShouldSetStatusCodeTo401() + { + RequestDelegate next = _ => Task.CompletedTask; + + var context = new DefaultHttpContext(); + var policy = CreateValidAuthorizationPolicy(); + var authorizeResult = PolicyAuthorizationResult.Challenge(); + + await _sut.HandleAsync(next, context, policy, authorizeResult); + + context.Response.StatusCode.Should().Be(StatusCodes.Status401Unauthorized); + } + + [Fact] + public async Task HandleAsync_WhenAuthorizationResultIsUnauthorized_ItShouldSetContentTypeToApplicationProblemJson() + { + RequestDelegate next = _ => Task.CompletedTask; + + var context = new DefaultHttpContext(); + var policy = CreateValidAuthorizationPolicy(); + var unauthorizedResult = PolicyAuthorizationResult.Challenge(); + + await _sut.HandleAsync(next, context, policy, unauthorizedResult); + + context.Response.ContentType.Should().Be("application/problem+json"); + } + + [Fact] + public async Task HandleAsync_WhenAuthorizationResultIsUnauthorized_ItShouldWriteProblemDetailsWithUnauthorizedTitle() + { + RequestDelegate next = _ => Task.CompletedTask; + + var context = new DefaultHttpContext(); + context.Response.Body = new MemoryStream(); + + var policy = CreateValidAuthorizationPolicy(); + var unauthorizedResult = PolicyAuthorizationResult.Challenge(); + + await _sut.HandleAsync(next, context, policy, unauthorizedResult); + + context.Response.Body.Seek(0, SeekOrigin.Begin); + using var stream = new StreamReader(context.Response.Body); + var responseBody = await stream.ReadToEndAsync(TestContext.Current.CancellationToken); + var problemDetails = JsonSerializer.Deserialize(responseBody); + + problemDetails!.Title.Should().Be("Unauthorized"); + } + + [Fact] + public async Task HandleAsync_WhenAuthorizationResultIsUnauthorized_ItShouldWriteProblemDetailsWithCorrectStatus() + { + RequestDelegate next = _ => Task.CompletedTask; + + var context = new DefaultHttpContext(); + context.Response.Body = new MemoryStream(); + + var policy = CreateValidAuthorizationPolicy(); + var unauthorizedResult = PolicyAuthorizationResult.Challenge(); + + await _sut.HandleAsync(next, context, policy, unauthorizedResult); + + context.Response.Body.Seek(0, SeekOrigin.Begin); + using var stream = new StreamReader(context.Response.Body); + var responseBody = await stream.ReadToEndAsync(TestContext.Current.CancellationToken); + var problemDetails = JsonSerializer.Deserialize(responseBody); + + problemDetails!.Status.Should().Be(StatusCodes.Status401Unauthorized); + } + + [Fact] + public async Task HandleAsync_WhenAuthorizationResultIsUnauthorized_ItShouldWriteProblemDetailsWithCorrectDetail() + { + RequestDelegate next = _ => Task.CompletedTask; + + var context = new DefaultHttpContext(); + context.Response.Body = new MemoryStream(); + + var policy = CreateValidAuthorizationPolicy(); + var unauthorizedResult = PolicyAuthorizationResult.Challenge(); + + await _sut.HandleAsync(next, context, policy, unauthorizedResult); + + context.Response.Body.Seek(0, SeekOrigin.Begin); + using var stream = new StreamReader(context.Response.Body); + var responseBody = await stream.ReadToEndAsync(TestContext.Current.CancellationToken); + var problemDetails = JsonSerializer.Deserialize(responseBody); + + problemDetails!.Detail.Should().Be("Authentication is required to access this resource."); + } + + [Fact] + public async Task HandleAsync_WhenAuthorizationResultIsUnauthorized_ItShouldNotCallNextDelegate() + { + var nextCalled = false; + RequestDelegate next = _ => + { + nextCalled = true; + return Task.CompletedTask; + }; + + var context = new DefaultHttpContext(); + context.Response.Body = new MemoryStream(); + + var policy = CreateValidAuthorizationPolicy(); + var unauthorizedResult = PolicyAuthorizationResult.Challenge(); + + await _sut.HandleAsync(next, context, policy, unauthorizedResult); + + nextCalled.Should().BeFalse(); + } + + [Fact] + public async Task HandleAsync_WhenAuthorizationFails_ItShouldWriteValidJsonToProblemDetails() + { + RequestDelegate next = _ => Task.CompletedTask; + + var context = new DefaultHttpContext(); + context.Response.Body = new MemoryStream(); + + var policy = CreateValidAuthorizationPolicy(); + var authorizeResult = PolicyAuthorizationResult.Challenge(); + + await _sut.HandleAsync(next, context, policy, authorizeResult); + + context.Response.Body.Seek(0, SeekOrigin.Begin); + using var stream = new StreamReader(context.Response.Body); + var responseBody = await stream.ReadToEndAsync(TestContext.Current.CancellationToken); + + var action = () => JsonSerializer.Deserialize(responseBody); + action.Should().NotThrow(); + } + + [Fact] + public async Task HandleAsync_WhenAuthorizationFails_ItShouldWriteResponseBodyWithContent() + { + RequestDelegate next = _ => Task.CompletedTask; + + var context = new DefaultHttpContext(); + context.Response.Body = new MemoryStream(); + + var policy = CreateValidAuthorizationPolicy(); + var authorizeResult = PolicyAuthorizationResult.Challenge(); + + await _sut.HandleAsync(next, context, policy, authorizeResult); + + context.Response.Body.Length.Should().BeGreaterThan(0); + } + + private static AuthorizationPolicy CreateValidAuthorizationPolicy() + { + var requirement = new DummyAuthorizationRequirement(); + return new AuthorizationPolicy([requirement], []); + } + + private sealed class DummyAuthorizationRequirement : IAuthorizationRequirement + { + } +} \ No newline at end of file diff --git a/tests/FiscalOS.Infra.Tests/Unit/SchemesTests.cs b/tests/FiscalOS.Infra.Tests/Unit/SchemesTests.cs new file mode 100644 index 0000000..a63c7e5 --- /dev/null +++ b/tests/FiscalOS.Infra.Tests/Unit/SchemesTests.cs @@ -0,0 +1,16 @@ +namespace FiscalOS.Infra.Tests.Unit; + +public class SchemesTests +{ + [Fact] + public void Default_WhenCalled_ItShouldHaveExpectedValue() + { + Schemes.Default.Should().Be(JwtBearerDefaults.AuthenticationScheme); + } + + [Fact] + public void AllowExpiredTokens_WhenCalled_ItShouldHaveExpectedValue() + { + Schemes.AllowExpiredTokens.Should().Be("AllowExpiredTokens"); + } +} \ No newline at end of file diff --git a/tests/FiscalOS.Infra.Tests/Unit/TokenGeneratorTests.cs b/tests/FiscalOS.Infra.Tests/Unit/TokenGeneratorTests.cs index c308f3d..fe1c98b 100644 --- a/tests/FiscalOS.Infra.Tests/Unit/TokenGeneratorTests.cs +++ b/tests/FiscalOS.Infra.Tests/Unit/TokenGeneratorTests.cs @@ -17,7 +17,8 @@ public class TokenGeneratorTests [Fact] public void GenerateAccessToken_WhenCalled_ItShouldReturnValidJwtToken() { - var user = User.From("testuser", "hashedpassword"); + var encryptedDataKey = EncryptedDataKey.From("keyId", "encryptedKey"); + var user = User.From("testuser", "hashedpassword", encryptedDataKey); var madeUp256BitSecret = RandomNumberGenerator.GetBytes(32); @@ -58,7 +59,8 @@ public class TokenGeneratorTests [Fact] public void GenerateRefreshToken_WhenCalled_ItShouldReturnRefreshTokenWithCorrectProperties() { - var user = User.From("testuser", "hashedpassword"); + var encryptedDataKey = EncryptedDataKey.From("keyId", "encryptedKey"); + var user = User.From("testuser", "hashedpassword", encryptedDataKey); var now = DateTimeOffset.UtcNow; diff --git a/tests/FiscalOS.Infra.Tests/Usings.cs b/tests/FiscalOS.Infra.Tests/Usings.cs index fe94bed..03815c3 100644 --- a/tests/FiscalOS.Infra.Tests/Usings.cs +++ b/tests/FiscalOS.Infra.Tests/Usings.cs @@ -1,11 +1,25 @@ global using System.Globalization; global using System.IdentityModel.Tokens.Jwt; +global using System.IO.Abstractions; global using System.Security.Cryptography; +global using System.Text; +global using System.Text.Json; global using AwesomeAssertions.Primitives; global using FiscalOS.Core.Identity; +global using FiscalOS.Core.Security; global using FiscalOS.Infra.Authentication; +global using FiscalOS.Infra.Authorization; +global using FiscalOS.Infra.Security; global using FiscalOS.Infra.Tests.Assertions; +global using FiscalOS.Infra.Tests.Mocks; -global using Microsoft.Extensions.Options; \ No newline at end of file +global using Microsoft.AspNetCore.Authentication.JwtBearer; +global using Microsoft.AspNetCore.Authorization; +global using Microsoft.AspNetCore.Authorization.Policy; +global using Microsoft.AspNetCore.Http; +global using Microsoft.AspNetCore.Mvc; +global using Microsoft.Extensions.Configuration; +global using Microsoft.Extensions.Options; +global using Microsoft.IdentityModel.Tokens; \ No newline at end of file