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