Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
61af52c6a4 | ||
|
|
5772e81110 | ||
|
|
e93fed6e67 | ||
|
|
b78f50d39d | ||
|
|
2e574a2925 | ||
|
|
615314c01e | ||
|
|
90a4c39fa7 | ||
|
|
060ac50cba | ||
|
|
f8506981be | ||
|
|
88b950fea3 | ||
|
|
7f356e71a1 | ||
|
|
daab5ae8e7 | ||
|
|
6fdbe146c9 | ||
|
|
34102e451d |
@@ -19,6 +19,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FiscalOS.Infra", "src\Fisca
|
|||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FiscalOS.Infra.Tests", "tests\FiscalOS.Infra.Tests\FiscalOS.Infra.Tests.csproj", "{48E0737E-0200-468E-ADA7-75893935DBC0}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FiscalOS.Infra.Tests", "tests\FiscalOS.Infra.Tests\FiscalOS.Infra.Tests.csproj", "{48E0737E-0200-468E-ADA7-75893935DBC0}"
|
||||||
EndProject
|
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
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
Debug|Any CPU = Debug|Any CPU
|
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|x64.Build.0 = Release|Any CPU
|
||||||
{48E0737E-0200-468E-ADA7-75893935DBC0}.Release|x86.ActiveCfg = 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
|
{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
|
EndGlobalSection
|
||||||
GlobalSection(SolutionProperties) = preSolution
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
HideSolutionNode = FALSE
|
HideSolutionNode = FALSE
|
||||||
@@ -112,5 +126,6 @@ Global
|
|||||||
{612496CD-1F25-4804-8C52-49E62D01FFDC} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
{612496CD-1F25-4804-8C52-49E62D01FFDC} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||||
{DAB42870-02A2-4ECB-B07F-6815C52D2435} = {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}
|
{48E0737E-0200-468E-ADA7-75893935DBC0} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
|
||||||
|
{2D9C02A9-C704-4275-88E9-1BBD3AC12700} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
EndGlobal
|
EndGlobal
|
||||||
|
|||||||
@@ -11,5 +11,6 @@
|
|||||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.2" />
|
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.2" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="10.0.2" />
|
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="10.0.2" />
|
||||||
<PackageVersion Include="Spectre.Console" Version="0.54.0" />
|
<PackageVersion Include="Spectre.Console" Version="0.54.0" />
|
||||||
|
<PackageVersion Include="TestableIO.System.IO.Abstractions.Wrappers" Version="22.1.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
using FiscalOS.Infra.Authentication;
|
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
builder.Services.AddValidation();
|
builder.Services.AddValidation();
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ global using FiscalOS.API.Http;
|
|||||||
global using FiscalOS.API.Login;
|
global using FiscalOS.API.Login;
|
||||||
global using FiscalOS.API.Refresh;
|
global using FiscalOS.API.Refresh;
|
||||||
global using FiscalOS.Core.Authentication;
|
global using FiscalOS.Core.Authentication;
|
||||||
|
global using FiscalOS.Infra.Authentication;
|
||||||
global using FiscalOS.Infra.Data;
|
global using FiscalOS.Infra.Data;
|
||||||
global using FiscalOS.Infra.DependencyInjection;
|
global using FiscalOS.Infra.DependencyInjection;
|
||||||
|
|
||||||
|
|||||||
@@ -14,5 +14,13 @@
|
|||||||
"Issuer": "Issuer",
|
"Issuer": "Issuer",
|
||||||
"Secret": "Secret",
|
"Secret": "Secret",
|
||||||
"ExpiryInMinutes": "ExpiryInMinutes"
|
"ExpiryInMinutes": "ExpiryInMinutes"
|
||||||
|
},
|
||||||
|
"FileKeyRingOptions": {
|
||||||
|
"KeysDirectoryPath": "KeysDirectoryPath",
|
||||||
|
"PrimaryKeyId": "PrimaryKeyId"
|
||||||
|
},
|
||||||
|
"PlaidOptions": {
|
||||||
|
"ClientId": "ClientId",
|
||||||
|
"Secret": "Secret"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ namespace FiscalOS.AdminCLI;
|
|||||||
internal sealed class App(
|
internal sealed class App(
|
||||||
IAnsiConsole console,
|
IAnsiConsole console,
|
||||||
IPasswordHasher passwordHasher,
|
IPasswordHasher passwordHasher,
|
||||||
IServiceScopeFactory serviceScopeFactory
|
IServiceScopeFactory serviceScopeFactory,
|
||||||
|
IEncryptor encryptor,
|
||||||
|
IKeyRing keyRing
|
||||||
) : IHostedService
|
) : IHostedService
|
||||||
{
|
{
|
||||||
private readonly IAnsiConsole _console = console;
|
private readonly IAnsiConsole _console = console;
|
||||||
@@ -32,13 +34,19 @@ internal sealed class App(
|
|||||||
);
|
);
|
||||||
|
|
||||||
var hashedPassword = passwordHasher.Hash(password);
|
var hashedPassword = passwordHasher.Hash(password);
|
||||||
var user = User.From(username, hashedPassword);
|
var userEncryptionKey = await encryptor.GenerateEncryptedKeyAsync(cancellationToken);
|
||||||
|
var user = User.From(username, hashedPassword, userEncryptionKey);
|
||||||
|
|
||||||
await appDbContext.Users.AddAsync(user, cancellationToken);
|
await appDbContext.Users.AddAsync(user, cancellationToken);
|
||||||
await appDbContext.SaveChangesAsync(cancellationToken);
|
await appDbContext.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
_console.MarkupLine($"User [green]{username}[/] created successfully.");
|
_console.MarkupLine($"User [green]{username}[/] created successfully.");
|
||||||
break;
|
break;
|
||||||
|
case Commands.GenerateKey:
|
||||||
|
var key = encryptor.GenerateKey();
|
||||||
|
var keyRingEntry = await keyRing.SaveKeyAsync(key);
|
||||||
|
_console.MarkupLine($"Key generated with id [green]{keyRingEntry.KeyId}[/] generated successfully.");
|
||||||
|
break;
|
||||||
case Commands.Exit:
|
case Commands.Exit:
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -3,7 +3,12 @@ namespace FiscalOS.AdminCLI;
|
|||||||
internal static class Commands
|
internal static class Commands
|
||||||
{
|
{
|
||||||
public const string CreateUser = "Create User";
|
public const string CreateUser = "Create User";
|
||||||
|
public const string GenerateKey = "Generate Key";
|
||||||
public const string Exit = "Exit";
|
public const string Exit = "Exit";
|
||||||
|
|
||||||
public static readonly string[] All = [CreateUser, Exit];
|
public static readonly string[] All = [
|
||||||
|
CreateUser,
|
||||||
|
GenerateKey,
|
||||||
|
Exit
|
||||||
|
];
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
global using FiscalOS.AdminCLI;
|
global using FiscalOS.AdminCLI;
|
||||||
global using FiscalOS.Core.Authentication;
|
global using FiscalOS.Core.Authentication;
|
||||||
global using FiscalOS.Core.Identity;
|
global using FiscalOS.Core.Identity;
|
||||||
|
global using FiscalOS.Core.Security;
|
||||||
global using FiscalOS.Infra.Data;
|
global using FiscalOS.Infra.Data;
|
||||||
global using FiscalOS.Infra.DependencyInjection;
|
global using FiscalOS.Infra.DependencyInjection;
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
{
|
{
|
||||||
"AppDbContextOptions": {
|
"AppDbContextOptions": {
|
||||||
"DatabaseFilePath": "DatabaseFilePath"
|
"DatabaseFilePath": "DatabaseFilePath"
|
||||||
|
},
|
||||||
|
"FileKeyRingOptions": {
|
||||||
|
"KeysDirectoryPath": "KeysDirectoryPath",
|
||||||
|
"PrimaryKeyId": "PrimaryKeyId"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ public sealed class User : Entity
|
|||||||
|
|
||||||
public string Username { get; init; } = string.Empty;
|
public string Username { get; init; } = string.Empty;
|
||||||
public string HashedPassword { get; init; } = string.Empty;
|
public string HashedPassword { get; init; } = string.Empty;
|
||||||
|
public string EncryptionKeyId { get; init; } = string.Empty;
|
||||||
|
public string EncryptedDataKey { get; init; } = string.Empty;
|
||||||
|
|
||||||
public IEnumerable<RefreshToken> RefreshTokens => _refreshTokens;
|
public IEnumerable<RefreshToken> RefreshTokens => _refreshTokens;
|
||||||
|
|
||||||
@@ -18,12 +20,18 @@ public sealed class User : Entity
|
|||||||
return new();
|
return new();
|
||||||
}
|
}
|
||||||
|
|
||||||
public static User From(string username, string hashedPassword)
|
public static User From(string username, string hashedPassword, EncryptedDataKey encryptedDataKey)
|
||||||
{
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(username);
|
||||||
|
ArgumentNullException.ThrowIfNull(hashedPassword);
|
||||||
|
ArgumentNullException.ThrowIfNull(encryptedDataKey);
|
||||||
|
|
||||||
return new()
|
return new()
|
||||||
{
|
{
|
||||||
Username = username,
|
Username = username,
|
||||||
HashedPassword = hashedPassword,
|
HashedPassword = hashedPassword,
|
||||||
|
EncryptedDataKey = encryptedDataKey.EncryptedKey,
|
||||||
|
EncryptionKeyId = encryptedDataKey.KeyIdUsed,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
namespace FiscalOS.Core.Security;
|
||||||
|
|
||||||
|
public sealed record EncryptedDataKey
|
||||||
|
{
|
||||||
|
public string KeyIdUsed { get; init; }
|
||||||
|
public string EncryptedKey { get; init; }
|
||||||
|
|
||||||
|
private EncryptedDataKey(string keyIdUsed, string encryptedKey)
|
||||||
|
{
|
||||||
|
KeyIdUsed = keyIdUsed;
|
||||||
|
EncryptedKey = encryptedKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static EncryptedDataKey From(string keyIdUsed, string encryptedKey)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(keyIdUsed);
|
||||||
|
ArgumentNullException.ThrowIfNull(encryptedKey);
|
||||||
|
|
||||||
|
return new(keyIdUsed, encryptedKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
namespace FiscalOS.Core.Security;
|
||||||
|
|
||||||
|
public interface IEncryptor
|
||||||
|
{
|
||||||
|
string GenerateKey();
|
||||||
|
Task<EncryptedDataKey> GenerateEncryptedKeyAsync(CancellationToken ct);
|
||||||
|
Task<string> EncryptAsync(string plainText, CancellationToken ct);
|
||||||
|
Task<string> EncryptAsyncFor(User user, string plainText, CancellationToken ct);
|
||||||
|
Task<string> DecryptAsync(string cipherText, CancellationToken ct);
|
||||||
|
Task<string> DecryptAsyncFor(User user, string plainText, CancellationToken ct);
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
namespace FiscalOS.Core.Security;
|
||||||
|
|
||||||
|
public interface IKeyRing
|
||||||
|
{
|
||||||
|
KeyRingEntry GetKey(string keyId);
|
||||||
|
KeyRingEntry GetPrimaryKey();
|
||||||
|
Task<KeyRingEntry> SaveKeyAsync(string key);
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
namespace FiscalOS.Core.Security;
|
||||||
|
|
||||||
|
public sealed record KeyRingEntry
|
||||||
|
{
|
||||||
|
public string KeyId { get; init; }
|
||||||
|
public string Key { get; init; }
|
||||||
|
|
||||||
|
private KeyRingEntry(string keyId, string key)
|
||||||
|
{
|
||||||
|
KeyId = keyId;
|
||||||
|
Key = key;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static KeyRingEntry From(string keyId, string key)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(keyId);
|
||||||
|
ArgumentNullException.ThrowIfNull(key);
|
||||||
|
|
||||||
|
return new(keyId, key);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
global using FiscalOS.Core.Data;
|
global using FiscalOS.Core.Data;
|
||||||
|
|
||||||
global using FiscalOS.Core.Identity;
|
global using FiscalOS.Core.Identity;
|
||||||
|
global using FiscalOS.Core.Security;
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
|
||||||
|
|
||||||
namespace FiscalOS.Infra.Authentication;
|
namespace FiscalOS.Infra.Authentication;
|
||||||
|
|
||||||
public sealed record JwtOptions
|
public sealed record JwtOptions
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
|
||||||
|
|
||||||
namespace FiscalOS.Infra.Authentication;
|
namespace FiscalOS.Infra.Authentication;
|
||||||
|
|
||||||
public static class Schemes
|
public static class Schemes
|
||||||
|
|||||||
@@ -1,21 +1,25 @@
|
|||||||
namespace FiscalOS.Infra.Data;
|
namespace FiscalOS.Infra.Data;
|
||||||
|
|
||||||
public sealed class AppDbContext(IOptions<AppDbContextOptions> ctxOptions) : DbContext
|
public sealed class AppDbContext(
|
||||||
|
IOptions<AppDbContextOptions> ctxOptions,
|
||||||
|
IFileSystem fileSystem
|
||||||
|
) : DbContext
|
||||||
{
|
{
|
||||||
private const string DataSourceKey = "Data Source=";
|
private const string DataSourceKey = "Data Source=";
|
||||||
private readonly AppDbContextOptions _ctxOptions = ctxOptions.Value;
|
private readonly AppDbContextOptions _ctxOptions = ctxOptions.Value;
|
||||||
|
private readonly IFileSystem _fileSystem = fileSystem;
|
||||||
|
|
||||||
public DbSet<User> Users => Set<User>();
|
public DbSet<User> Users => Set<User>();
|
||||||
public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>();
|
public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>();
|
||||||
|
|
||||||
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
||||||
{
|
{
|
||||||
var dbPath = _ctxOptions.GetFullyQualifiedDatabasePath();
|
var dbPath = _fileSystem.Path.GetFullPath(_ctxOptions.DatabaseFilePath, AppContext.BaseDirectory);
|
||||||
var dbDirectory = Path.GetDirectoryName(dbPath) ?? throw new InvalidOperationException("Database directory path could not be determined.");
|
var dbDirectory = _fileSystem.Path.GetDirectoryName(dbPath) ?? throw new InvalidOperationException("Database directory path could not be determined.");
|
||||||
|
|
||||||
if (Directory.Exists(dbDirectory) is false)
|
if (_fileSystem.Directory.Exists(dbDirectory) is false)
|
||||||
{
|
{
|
||||||
Directory.CreateDirectory(dbDirectory);
|
_fileSystem.Directory.CreateDirectory(dbDirectory);
|
||||||
}
|
}
|
||||||
|
|
||||||
var connectionString = $"{DataSourceKey}{dbPath}";
|
var connectionString = $"{DataSourceKey}{dbPath}";
|
||||||
|
|||||||
@@ -3,11 +3,6 @@ namespace FiscalOS.Infra.Data;
|
|||||||
public sealed record AppDbContextOptions
|
public sealed record AppDbContextOptions
|
||||||
{
|
{
|
||||||
public string DatabaseFilePath { get; init; } = string.Empty;
|
public string DatabaseFilePath { get; init; } = string.Empty;
|
||||||
|
|
||||||
public string GetFullyQualifiedDatabasePath()
|
|
||||||
{
|
|
||||||
return Path.GetFullPath(DatabaseFilePath, AppContext.BaseDirectory);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed record AppDbContextOptionsSetup : IConfigureOptions<AppDbContextOptions>
|
public sealed record AppDbContextOptionsSetup : IConfigureOptions<AppDbContextOptions>
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
using FiscalOS.Core.Data;
|
|
||||||
|
|
||||||
namespace FiscalOS.Infra.Data;
|
namespace FiscalOS.Infra.Data;
|
||||||
|
|
||||||
internal sealed class TimestampInterceptor : SaveChangesInterceptor
|
internal sealed class TimestampInterceptor : SaveChangesInterceptor
|
||||||
|
|||||||
@@ -7,12 +7,17 @@ public static class ServiceCollectionExtensions
|
|||||||
services.AddSingleton<IAuthorizationMiddlewareResultHandler, ProblemDetailsAuthResultHandler>();
|
services.AddSingleton<IAuthorizationMiddlewareResultHandler, ProblemDetailsAuthResultHandler>();
|
||||||
|
|
||||||
services.AddSingleton(TimeProvider.System);
|
services.AddSingleton(TimeProvider.System);
|
||||||
|
services.AddSingleton<IFileSystem, FileSystem>();
|
||||||
|
|
||||||
services.ConfigureOptions<JwtOptionsSetup>();
|
services.ConfigureOptions<JwtOptionsSetup>();
|
||||||
services.ConfigureOptions<JwtBearerOptionsSetup>();
|
services.ConfigureOptions<JwtBearerOptionsSetup>();
|
||||||
|
|
||||||
services.AddSingleton<ITokenGenerator, TokenGenerator>(TokenGenerator.From);
|
services.AddSingleton<ITokenGenerator>(TokenGenerator.From);
|
||||||
services.AddSingleton<IPasswordHasher, PasswordHasher>(PasswordHasher.From);
|
services.AddSingleton<IPasswordHasher>(PasswordHasher.From);
|
||||||
|
|
||||||
|
services.ConfigureOptions<FileKeyRingOptionsSetup>();
|
||||||
|
services.AddSingleton<IKeyRing>(FileKeyRing.From);
|
||||||
|
services.AddSingleton<IEncryptor>(Encryptor.From);
|
||||||
|
|
||||||
services.ConfigureOptions<AppDbContextOptionsSetup>();
|
services.ConfigureOptions<AppDbContextOptionsSetup>();
|
||||||
services.AddDbContext<AppDbContext>();
|
services.AddDbContext<AppDbContext>();
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" />
|
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Hosting" />
|
<PackageReference Include="Microsoft.Extensions.Hosting" />
|
||||||
|
<PackageReference Include="TestableIO.System.IO.Abstractions.Wrappers" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
using System;
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
|
||||||
|
|
||||||
#nullable disable
|
#nullable disable
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
using System;
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
|
||||||
|
|
||||||
#nullable disable
|
#nullable disable
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
using System;
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
|
||||||
|
|
||||||
#nullable disable
|
#nullable disable
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
using System;
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
|
||||||
|
|
||||||
#nullable disable
|
#nullable disable
|
||||||
|
|
||||||
|
|||||||
+108
@@ -0,0 +1,108 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using FiscalOS.Infra.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace FiscalOS.Infra.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(AppDbContext))]
|
||||||
|
[Migration("20260205192220_AddEncryptionKeyToUserModel")]
|
||||||
|
partial class AddEncryptionKeyToUserModel
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder.HasAnnotation("ProductVersion", "10.0.2");
|
||||||
|
|
||||||
|
modelBuilder.Entity("FiscalOS.Core.Identity.RefreshToken", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("ExpiresAt")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("Revoked")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Token")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("UpdatedAt")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Token")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.HasIndex("UserId");
|
||||||
|
|
||||||
|
b.ToTable("RefreshTokens");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("FiscalOS.Core.Identity.User", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("EncryptedDataKey")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("HashedPassword")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("UpdatedAt")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Username")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Username")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Users");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("FiscalOS.Core.Identity.RefreshToken", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("FiscalOS.Core.Identity.User", "User")
|
||||||
|
.WithMany("RefreshTokens")
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("User");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("FiscalOS.Core.Identity.User", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("RefreshTokens");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace FiscalOS.Infra.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddEncryptionKeyToUserModel : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "EncryptedDataKey",
|
||||||
|
table: "Users",
|
||||||
|
type: "TEXT",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "EncryptedDataKey",
|
||||||
|
table: "Users");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+112
@@ -0,0 +1,112 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using FiscalOS.Infra.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace FiscalOS.Infra.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(AppDbContext))]
|
||||||
|
[Migration("20260205222656_AddEncryptionKeyIdToUserModel")]
|
||||||
|
partial class AddEncryptionKeyIdToUserModel
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder.HasAnnotation("ProductVersion", "10.0.2");
|
||||||
|
|
||||||
|
modelBuilder.Entity("FiscalOS.Core.Identity.RefreshToken", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("ExpiresAt")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("Revoked")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Token")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("UpdatedAt")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Token")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.HasIndex("UserId");
|
||||||
|
|
||||||
|
b.ToTable("RefreshTokens");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("FiscalOS.Core.Identity.User", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("EncryptedDataKey")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("EncryptionKeyId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("HashedPassword")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("UpdatedAt")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Username")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Username")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Users");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("FiscalOS.Core.Identity.RefreshToken", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("FiscalOS.Core.Identity.User", "User")
|
||||||
|
.WithMany("RefreshTokens")
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("User");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("FiscalOS.Core.Identity.User", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("RefreshTokens");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace FiscalOS.Infra.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddEncryptionKeyIdToUserModel : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "EncryptionKeyId",
|
||||||
|
table: "Users",
|
||||||
|
type: "TEXT",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "EncryptionKeyId",
|
||||||
|
table: "Users");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -61,6 +61,14 @@ namespace FiscalOS.Infra.Migrations
|
|||||||
b.Property<DateTimeOffset>("CreatedAt")
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("EncryptedDataKey")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("EncryptionKeyId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
b.Property<string>("HashedPassword")
|
b.Property<string>("HashedPassword")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
|
||||||
|
namespace FiscalOS.Infra.Security;
|
||||||
|
|
||||||
|
public sealed class Encryptor : IEncryptor
|
||||||
|
{
|
||||||
|
private readonly IKeyRing _keyRing;
|
||||||
|
|
||||||
|
private Encryptor(IKeyRing keyRing)
|
||||||
|
{
|
||||||
|
_keyRing = keyRing;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Encryptor From(IServiceProvider serviceProvider)
|
||||||
|
{
|
||||||
|
var keyRing = serviceProvider.GetRequiredService<IKeyRing>();
|
||||||
|
return new Encryptor(keyRing);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Encryptor From(IKeyRing keyRing)
|
||||||
|
{
|
||||||
|
return new Encryptor(keyRing);
|
||||||
|
}
|
||||||
|
|
||||||
|
private KeyRingEntry PrimaryKey => _keyRing.GetPrimaryKey();
|
||||||
|
|
||||||
|
private static async Task<string> DecryptCoreAsync(string key, string cipherText, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var cipherTextBytes = Convert.FromBase64String(cipherText);
|
||||||
|
|
||||||
|
using var aes = Aes.Create();
|
||||||
|
aes.Key = Convert.FromBase64String(key);
|
||||||
|
|
||||||
|
var iv = new byte[16];
|
||||||
|
Array.Copy(cipherTextBytes, 0, iv, 0, 16);
|
||||||
|
aes.IV = iv;
|
||||||
|
|
||||||
|
var decryptor = aes.CreateDecryptor();
|
||||||
|
|
||||||
|
using var memoryStream = new MemoryStream(cipherTextBytes, 16, cipherTextBytes.Length - 16);
|
||||||
|
using var cryptoStream = new CryptoStream(memoryStream, decryptor, CryptoStreamMode.Read);
|
||||||
|
using var streamReader = new StreamReader(cryptoStream);
|
||||||
|
|
||||||
|
return await streamReader.ReadToEndAsync(ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<string> DecryptAsync(string cipherText, CancellationToken ct)
|
||||||
|
{
|
||||||
|
return await DecryptCoreAsync(PrimaryKey.Key, cipherText, ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<string> DecryptAsyncFor(User user, string plainText, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var decryptedKey = await DecryptCoreAsync(PrimaryKey.Key, user.EncryptedDataKey, ct).ConfigureAwait(false);
|
||||||
|
return await DecryptCoreAsync(decryptedKey, plainText, ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<string> EncryptCoreAsync(string key, string plainText, CancellationToken ct)
|
||||||
|
{
|
||||||
|
using var aes = Aes.Create();
|
||||||
|
aes.Key = Convert.FromBase64String(key);
|
||||||
|
|
||||||
|
using var memoryStream = new MemoryStream();
|
||||||
|
aes.GenerateIV();
|
||||||
|
await memoryStream.WriteAsync(aes.IV, ct).ConfigureAwait(false);
|
||||||
|
|
||||||
|
var encryptor = aes.CreateEncryptor();
|
||||||
|
|
||||||
|
using var cryptoStream = new CryptoStream(memoryStream, encryptor, CryptoStreamMode.Write);
|
||||||
|
using var streamWriter = new StreamWriter(cryptoStream);
|
||||||
|
|
||||||
|
await streamWriter.WriteAsync(plainText.ToCharArray(), ct).ConfigureAwait(false);
|
||||||
|
await streamWriter.FlushAsync(ct).ConfigureAwait(false);
|
||||||
|
streamWriter.Close();
|
||||||
|
|
||||||
|
return Convert.ToBase64String(memoryStream.ToArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<string> EncryptAsync(string plainText, CancellationToken ct)
|
||||||
|
{
|
||||||
|
return await EncryptCoreAsync(PrimaryKey.Key, plainText, ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<string> EncryptAsyncFor(User user, string plainText, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var decryptedKey = await DecryptCoreAsync(PrimaryKey.Key, user.EncryptedDataKey, ct).ConfigureAwait(false);
|
||||||
|
return await EncryptCoreAsync(decryptedKey, plainText, ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public string GenerateKey()
|
||||||
|
{
|
||||||
|
using var aes = Aes.Create();
|
||||||
|
aes.GenerateKey();
|
||||||
|
|
||||||
|
return Convert.ToBase64String(aes.Key);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<EncryptedDataKey> GenerateEncryptedKeyAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
var key = GenerateKey();
|
||||||
|
var keyUsed = PrimaryKey;
|
||||||
|
var encryptedKey = await EncryptCoreAsync(keyUsed.Key, key, ct).ConfigureAwait(false);
|
||||||
|
return EncryptedDataKey.From(keyUsed.KeyId, encryptedKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
|
||||||
|
namespace FiscalOS.Infra.Security;
|
||||||
|
|
||||||
|
public sealed class FileKeyRing : IKeyRing, IDisposable
|
||||||
|
{
|
||||||
|
private const string KeyFileExtension = ".key";
|
||||||
|
private FileKeyRingOptions _options;
|
||||||
|
private readonly IFileSystem _fileSystem;
|
||||||
|
private readonly IDisposable? _optionsChangeHandler;
|
||||||
|
private readonly Dictionary<string, KeyRingEntry> _keys = [];
|
||||||
|
private string KeyRingPath => _fileSystem.Path.GetFullPath(_options.KeysDirectoryPath, AppContext.BaseDirectory);
|
||||||
|
|
||||||
|
private FileKeyRing(IOptionsMonitor<FileKeyRingOptions> options, IFileSystem fileSystem)
|
||||||
|
{
|
||||||
|
_fileSystem = fileSystem;
|
||||||
|
_options = options.CurrentValue;
|
||||||
|
|
||||||
|
LoadKeys();
|
||||||
|
|
||||||
|
_optionsChangeHandler = options.OnChange(options =>
|
||||||
|
{
|
||||||
|
_options = options;
|
||||||
|
LoadKeys();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public static FileKeyRing From(IServiceProvider serviceProvider)
|
||||||
|
{
|
||||||
|
var options = serviceProvider.GetRequiredService<IOptionsMonitor<FileKeyRingOptions>>();
|
||||||
|
var fileSystem = serviceProvider.GetRequiredService<IFileSystem>();
|
||||||
|
|
||||||
|
return new(options, fileSystem);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static FileKeyRing From(IOptionsMonitor<FileKeyRingOptions> options, IFileSystem fileSystem)
|
||||||
|
{
|
||||||
|
return new(options, fileSystem);
|
||||||
|
}
|
||||||
|
|
||||||
|
public KeyRingEntry GetKey(string keyId)
|
||||||
|
{
|
||||||
|
if (_keys.TryGetValue(keyId, out var key))
|
||||||
|
{
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new KeyNotFoundException($"Key with ID '{keyId}' not found in the key ring.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public KeyRingEntry GetPrimaryKey()
|
||||||
|
{
|
||||||
|
return GetKey(_options.PrimaryKeyId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void LoadKeys()
|
||||||
|
{
|
||||||
|
_keys.Clear();
|
||||||
|
|
||||||
|
if (_fileSystem.Directory.Exists(KeyRingPath))
|
||||||
|
{
|
||||||
|
var keyFiles = _fileSystem.Directory.GetFiles(KeyRingPath, $"*{KeyFileExtension}");
|
||||||
|
|
||||||
|
foreach (var keyFile in keyFiles)
|
||||||
|
{
|
||||||
|
var keyId = _fileSystem.Path.GetFileNameWithoutExtension(keyFile);
|
||||||
|
var key = _fileSystem.File.ReadAllText(keyFile).Trim();
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(key) is false)
|
||||||
|
{
|
||||||
|
_keys[keyId] = KeyRingEntry.From(keyId, key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_optionsChangeHandler?.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<KeyRingEntry> SaveKeyAsync(string key)
|
||||||
|
{
|
||||||
|
var id = Guid.NewGuid().ToString();
|
||||||
|
var entry = KeyRingEntry.From(id, key);
|
||||||
|
var filename = id + KeyFileExtension;
|
||||||
|
var entryPath = _fileSystem.Path.Combine(KeyRingPath, filename);
|
||||||
|
await _fileSystem.File.WriteAllTextAsync(entryPath, key).ConfigureAwait(false);
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
namespace FiscalOS.Infra.Security;
|
||||||
|
|
||||||
|
public sealed record FileKeyRingOptions
|
||||||
|
{
|
||||||
|
public string KeysDirectoryPath { get; init; } = string.Empty;
|
||||||
|
public string PrimaryKeyId { get; init; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record class FileKeyRingOptionsSetup : IConfigureOptions<FileKeyRingOptions>
|
||||||
|
{
|
||||||
|
private const string SectionName = nameof(FileKeyRingOptions);
|
||||||
|
private readonly IConfiguration _configuration;
|
||||||
|
|
||||||
|
public FileKeyRingOptionsSetup(IConfiguration configuration)
|
||||||
|
{
|
||||||
|
_configuration = configuration;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Configure(FileKeyRingOptions options)
|
||||||
|
{
|
||||||
|
_configuration.GetSection(SectionName).Bind(options);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
global using System.IdentityModel.Tokens.Jwt;
|
global using System.IdentityModel.Tokens.Jwt;
|
||||||
|
global using System.IO.Abstractions;
|
||||||
global using System.Security.Claims;
|
global using System.Security.Claims;
|
||||||
global using System.Security.Cryptography;
|
global using System.Security.Cryptography;
|
||||||
global using System.Text;
|
global using System.Text;
|
||||||
@@ -7,10 +8,13 @@ global using System.Text.Json;
|
|||||||
global using FiscalOS.Core.Authentication;
|
global using FiscalOS.Core.Authentication;
|
||||||
global using FiscalOS.Core.Data;
|
global using FiscalOS.Core.Data;
|
||||||
global using FiscalOS.Core.Identity;
|
global using FiscalOS.Core.Identity;
|
||||||
|
global using FiscalOS.Core.Security;
|
||||||
global using FiscalOS.Infra.Authentication;
|
global using FiscalOS.Infra.Authentication;
|
||||||
global using FiscalOS.Infra.Authorization;
|
global using FiscalOS.Infra.Authorization;
|
||||||
global using FiscalOS.Infra.Data;
|
global using FiscalOS.Infra.Data;
|
||||||
|
global using FiscalOS.Infra.Security;
|
||||||
|
|
||||||
|
global using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||||
global using Microsoft.AspNetCore.Authorization;
|
global using Microsoft.AspNetCore.Authorization;
|
||||||
global using Microsoft.AspNetCore.Authorization.Policy;
|
global using Microsoft.AspNetCore.Authorization.Policy;
|
||||||
global using Microsoft.AspNetCore.Http;
|
global using Microsoft.AspNetCore.Http;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
[*.cs]
|
[*.cs]
|
||||||
|
|
||||||
|
dotnet_diagnostic.CA1303.severity = none
|
||||||
dotnet_diagnostic.CA1707.severity = none
|
dotnet_diagnostic.CA1707.severity = none
|
||||||
dotnet_diagnostic.CA1062.severity = none
|
dotnet_diagnostic.CA1062.severity = none
|
||||||
dotnet_diagnostic.CA2007.severity = none
|
dotnet_diagnostic.CA2007.severity = none
|
||||||
|
|||||||
@@ -13,7 +13,6 @@
|
|||||||
<PackageReference Include="xunit.v3.mtp-v2" />
|
<PackageReference Include="xunit.v3.mtp-v2" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
|
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ public class TestApi : WebApplicationFactory<Program>
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
c.AddSingleton(Options.Create(JwtTokenBuilder.DefaultJwtOptions));
|
c.AddSingleton(Options.Create(JwtTokenBuilder.DefaultJwtOptions));
|
||||||
|
|
||||||
|
c.AddSingleton<IKeyRing>(TestKeyRing.From);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
|
||||||
|
namespace FiscalOS.API.Tests.Infra;
|
||||||
|
|
||||||
|
internal sealed class TestKeyRing : IKeyRing
|
||||||
|
{
|
||||||
|
private 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<KeyRingEntry> SaveKeyAsync(string key)
|
||||||
|
{
|
||||||
|
_key = key;
|
||||||
|
var entry = KeyRingEntry.From(Guid.NewGuid().ToString(), key);
|
||||||
|
return Task.FromResult(entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -39,8 +39,10 @@ public class LoginTests(TestApi testApi) : IntegrationTest(testApi)
|
|||||||
await ExecuteDbContextAsync(static async (context, sp) =>
|
await ExecuteDbContextAsync(static async (context, sp) =>
|
||||||
{
|
{
|
||||||
var passwordHasher = sp.GetRequiredService<IPasswordHasher>();
|
var passwordHasher = sp.GetRequiredService<IPasswordHasher>();
|
||||||
|
var encryptor = sp.GetRequiredService<IEncryptor>();
|
||||||
|
|
||||||
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);
|
await context.SaveChangesAsync(TestContext.Current.CancellationToken);
|
||||||
});
|
});
|
||||||
@@ -62,8 +64,10 @@ public class LoginTests(TestApi testApi) : IntegrationTest(testApi)
|
|||||||
await ExecuteDbContextAsync(static async (context, sp) =>
|
await ExecuteDbContextAsync(static async (context, sp) =>
|
||||||
{
|
{
|
||||||
var passwordHasher = sp.GetRequiredService<IPasswordHasher>();
|
var passwordHasher = sp.GetRequiredService<IPasswordHasher>();
|
||||||
|
var encryptor = sp.GetRequiredService<IEncryptor>();
|
||||||
|
|
||||||
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);
|
await context.SaveChangesAsync(TestContext.Current.CancellationToken);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -35,9 +35,12 @@ public class RefreshTests(TestApi testApi) : IntegrationTest(testApi)
|
|||||||
{
|
{
|
||||||
var passwordHasher = sp.GetRequiredService<IPasswordHasher>();
|
var passwordHasher = sp.GetRequiredService<IPasswordHasher>();
|
||||||
var tokenGenerator = sp.GetRequiredService<ITokenGenerator>();
|
var tokenGenerator = sp.GetRequiredService<ITokenGenerator>();
|
||||||
|
var encryptor = sp.GetRequiredService<IEncryptor>();
|
||||||
|
|
||||||
var user1 = User.From("User1", passwordHasher.Hash("@Password1"));
|
var user1EncryptionKey = await encryptor.GenerateEncryptedKeyAsync(TestContext.Current.CancellationToken);
|
||||||
var user2 = User.From("User2", passwordHasher.Hash("@Password2"));
|
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 refreshToken1 = tokenGenerator.GenerateRefreshToken(user2);
|
||||||
var refreshToken2 = tokenGenerator.GenerateRefreshToken(user2);
|
var refreshToken2 = tokenGenerator.GenerateRefreshToken(user2);
|
||||||
user2.AddRefreshToken(refreshToken1);
|
user2.AddRefreshToken(refreshToken1);
|
||||||
@@ -79,8 +82,10 @@ public class RefreshTests(TestApi testApi) : IntegrationTest(testApi)
|
|||||||
{
|
{
|
||||||
var passwordHasher = sp.GetRequiredService<IPasswordHasher>();
|
var passwordHasher = sp.GetRequiredService<IPasswordHasher>();
|
||||||
var tokenGenerator = sp.GetRequiredService<ITokenGenerator>();
|
var tokenGenerator = sp.GetRequiredService<ITokenGenerator>();
|
||||||
|
var encryptor = sp.GetRequiredService<IEncryptor>();
|
||||||
|
|
||||||
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);
|
var refreshToken = tokenGenerator.GenerateRefreshToken(user);
|
||||||
refreshToken.Revoke();
|
refreshToken.Revoke();
|
||||||
|
|
||||||
@@ -113,8 +118,10 @@ public class RefreshTests(TestApi testApi) : IntegrationTest(testApi)
|
|||||||
var passwordHasher = sp.GetRequiredService<IPasswordHasher>();
|
var passwordHasher = sp.GetRequiredService<IPasswordHasher>();
|
||||||
var tokenGenerator = sp.GetRequiredService<ITokenGenerator>();
|
var tokenGenerator = sp.GetRequiredService<ITokenGenerator>();
|
||||||
var timeProvider = sp.GetRequiredService<TimeProvider>();
|
var timeProvider = sp.GetRequiredService<TimeProvider>();
|
||||||
|
var encryptor = sp.GetRequiredService<IEncryptor>();
|
||||||
|
|
||||||
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));
|
var refreshToken = RefreshToken.From(user.Id, "expiredtoken", timeProvider.GetUtcNow().AddHours(-1));
|
||||||
user.AddRefreshToken(refreshToken);
|
user.AddRefreshToken(refreshToken);
|
||||||
|
|
||||||
@@ -147,8 +154,10 @@ public class RefreshTests(TestApi testApi) : IntegrationTest(testApi)
|
|||||||
var passwordHasher = sp.GetRequiredService<IPasswordHasher>();
|
var passwordHasher = sp.GetRequiredService<IPasswordHasher>();
|
||||||
var tokenGenerator = sp.GetRequiredService<ITokenGenerator>();
|
var tokenGenerator = sp.GetRequiredService<ITokenGenerator>();
|
||||||
var timeProvider = sp.GetRequiredService<TimeProvider>();
|
var timeProvider = sp.GetRequiredService<TimeProvider>();
|
||||||
|
var encryptor = sp.GetRequiredService<IEncryptor>();
|
||||||
|
|
||||||
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);
|
var refreshToken = tokenGenerator.GenerateRefreshToken(user);
|
||||||
user.AddRefreshToken(refreshToken);
|
user.AddRefreshToken(refreshToken);
|
||||||
|
|
||||||
|
|||||||
@@ -7,10 +7,12 @@ global using System.Security.Cryptography;
|
|||||||
|
|
||||||
global using AwesomeAssertions.Execution;
|
global using AwesomeAssertions.Execution;
|
||||||
global using AwesomeAssertions.Primitives;
|
global using AwesomeAssertions.Primitives;
|
||||||
|
|
||||||
global using FiscalOS.API.Tests.Assertions;
|
global using FiscalOS.API.Tests.Assertions;
|
||||||
global using FiscalOS.API.Tests.Infra;
|
global using FiscalOS.API.Tests.Infra;
|
||||||
global using FiscalOS.Core.Authentication;
|
global using FiscalOS.Core.Authentication;
|
||||||
global using FiscalOS.Core.Identity;
|
global using FiscalOS.Core.Identity;
|
||||||
|
global using FiscalOS.Core.Security;
|
||||||
global using FiscalOS.Infra.Authentication;
|
global using FiscalOS.Infra.Authentication;
|
||||||
global using FiscalOS.Infra.Data;
|
global using FiscalOS.Infra.Data;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<RootNamespace>FiscalOS.Core.Tests</RootNamespace>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="AwesomeAssertions" />
|
||||||
|
<PackageReference Include="Microsoft.Testing.Extensions.CodeCoverage" />
|
||||||
|
<PackageReference Include="Moq" />
|
||||||
|
<PackageReference Include="xunit.v3.mtp-v2" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\src\FiscalOS.Core\FiscalOS.Core.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -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<ArgumentNullException>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void From_WhenCalledWithNullEncryptedKey_ItShouldThrowArgumentNullException()
|
||||||
|
{
|
||||||
|
var keyId = "key-id";
|
||||||
|
|
||||||
|
var creatingDataKeyWithNullEncryptedKey = () => EncryptedDataKey.From(keyId, null!);
|
||||||
|
|
||||||
|
creatingDataKeyWithNullEncryptedKey.Should().Throw<ArgumentNullException>();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<ArgumentNullException>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void From_WhenCalledWithNullKey_ItShouldThrowArgumentNullException()
|
||||||
|
{
|
||||||
|
var keyId = "test-key-id";
|
||||||
|
|
||||||
|
var createEntryWithNullKey = () => KeyRingEntry.From(keyId, null!);
|
||||||
|
|
||||||
|
createEntryWithNullKey.Should().Throw<ArgumentNullException>();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<ArgumentNullException>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<ArgumentNullException>();
|
||||||
|
|
||||||
|
var creatingUserWithNullHashedPassword = () => User.From("testuser", null!, encryptedDataKey);
|
||||||
|
creatingUserWithNullHashedPassword.Should().Throw<ArgumentNullException>();
|
||||||
|
|
||||||
|
var creatingUserWithNullDataKey = () => User.From("testuser", "hashedpassword", null!);
|
||||||
|
creatingUserWithNullDataKey.Should().Throw<ArgumentNullException>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[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<ArgumentNullException>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
global using FiscalOS.Core.Identity;
|
||||||
|
global using FiscalOS.Core.Security;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://xunit.net/schema/current/xunit.runner.schema.json"
|
||||||
|
}
|
||||||
@@ -20,9 +20,4 @@
|
|||||||
<ProjectReference Include="..\..\src\FiscalOS.Infra\FiscalOS.Infra.csproj" />
|
<ProjectReference Include="..\..\src\FiscalOS.Infra\FiscalOS.Infra.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<Target Name="GenerateHtmlCoverageReport" AfterTargets="Test">
|
|
||||||
<Exec
|
|
||||||
Command="reportgenerator -reports:./TestResults/Coverage/*.xml -targetdir:./TestResults/Coverage/Report/ -reporttypes:Html_Dark" />
|
|
||||||
</Target>
|
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
namespace FiscalOS.Infra.Tests.Mocks;
|
||||||
|
|
||||||
|
internal sealed class MockOptionsMonitor<T> : IOptionsMonitor<T> where T : new()
|
||||||
|
{
|
||||||
|
private Action<T, string>? _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<T, string> listener)
|
||||||
|
{
|
||||||
|
_listener = listener;
|
||||||
|
return new Mock<IDisposable>().Object;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
namespace FiscalOS.Infra.Tests.Unit;
|
||||||
|
|
||||||
|
public class EncryptorTests
|
||||||
|
{
|
||||||
|
private readonly Mock<IKeyRing> _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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string, string?>
|
||||||
|
{
|
||||||
|
{ "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<string, string?>
|
||||||
|
{
|
||||||
|
{ "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<string, string?>
|
||||||
|
{
|
||||||
|
{ "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<string, string?>
|
||||||
|
{
|
||||||
|
{ "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<string, string?>
|
||||||
|
{
|
||||||
|
{ "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<string, string?>
|
||||||
|
{
|
||||||
|
{ "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<string, string?>
|
||||||
|
{
|
||||||
|
{ "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<string, string?>
|
||||||
|
{
|
||||||
|
{ "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<string, string?>
|
||||||
|
{
|
||||||
|
{ "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<string, string?>
|
||||||
|
{
|
||||||
|
{ "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<string, string?>
|
||||||
|
{
|
||||||
|
{ "FileKeyRingOptions:KeysDirectoryPath", whitespaceValue }
|
||||||
|
});
|
||||||
|
var configuration = configBuilder.Build();
|
||||||
|
var setup = new FileKeyRingOptionsSetup(configuration);
|
||||||
|
var options = new FileKeyRingOptions();
|
||||||
|
|
||||||
|
setup.Configure(options);
|
||||||
|
|
||||||
|
options.KeysDirectoryPath.Should().Be(whitespaceValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,495 @@
|
|||||||
|
namespace FiscalOS.Infra.Tests.Unit;
|
||||||
|
|
||||||
|
public class FileKeyRingTests
|
||||||
|
{
|
||||||
|
private readonly MockOptionsMonitor<FileKeyRingOptions> _mockOptionsMonitor = new();
|
||||||
|
private readonly Mock<IFileSystem> _mockFileSystem = new();
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void From_WhenCalledWithOptionsMonitorAndFileSystem_ItShouldCreateFileKeyRing()
|
||||||
|
{
|
||||||
|
var mockPath = new Mock<IPath>();
|
||||||
|
var mockDirectory = new Mock<IDirectory>();
|
||||||
|
|
||||||
|
_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<IKeyRing>();
|
||||||
|
sut.Should().BeAssignableTo<IDisposable>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void From_WhenCalledWithServiceProvider_ItShouldCreateFileKeyRing()
|
||||||
|
{
|
||||||
|
var mockPath = new Mock<IPath>();
|
||||||
|
var mockDirectory = new Mock<IDirectory>();
|
||||||
|
var mockFileSystem = new Mock<IFileSystem>();
|
||||||
|
var mockServiceProvider = new Mock<IServiceProvider>();
|
||||||
|
var mockOptionsMonitor = new MockOptionsMonitor<FileKeyRingOptions>();
|
||||||
|
|
||||||
|
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<FileKeyRingOptions>)))
|
||||||
|
.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<IKeyRing>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetKey_WhenCalledWithValidKeyId_ItShouldReturnKey()
|
||||||
|
{
|
||||||
|
var keyId = "test-key";
|
||||||
|
var keyContent = "test-key-content";
|
||||||
|
var mockPath = new Mock<IPath>();
|
||||||
|
var mockDirectory = new Mock<IDirectoryInfoFactory>();
|
||||||
|
var mockFile = new Mock<IFileInfoFactory>();
|
||||||
|
var mockDirectoryInfo = new Mock<IDirectoryInfo>();
|
||||||
|
var mockFileInfo = new Mock<IFileInfo>();
|
||||||
|
|
||||||
|
_mockOptionsMonitor.CurrentValue = new FileKeyRingOptions
|
||||||
|
{
|
||||||
|
KeysDirectoryPath = "/keys",
|
||||||
|
PrimaryKeyId = keyId
|
||||||
|
};
|
||||||
|
|
||||||
|
mockPath
|
||||||
|
.Setup(p => p.GetFullPath("/keys", It.IsAny<string>()))
|
||||||
|
.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<IPath>();
|
||||||
|
_mockOptionsMonitor.CurrentValue = new FileKeyRingOptions
|
||||||
|
{
|
||||||
|
KeysDirectoryPath = "/keys",
|
||||||
|
PrimaryKeyId = "valid-key"
|
||||||
|
};
|
||||||
|
|
||||||
|
mockPath
|
||||||
|
.Setup(p => p.GetFullPath(It.IsAny<string>(), It.IsAny<string>()))
|
||||||
|
.Returns("/full/keys");
|
||||||
|
|
||||||
|
_mockFileSystem.Setup(fs => fs.Path).Returns(mockPath.Object);
|
||||||
|
|
||||||
|
_mockFileSystem
|
||||||
|
.Setup(fs => fs.Directory.Exists(It.IsAny<string>()))
|
||||||
|
.Returns(false);
|
||||||
|
|
||||||
|
var sut = FileKeyRing.From(_mockOptionsMonitor, _mockFileSystem.Object);
|
||||||
|
|
||||||
|
var action = () => sut.GetKey("non-existent-key");
|
||||||
|
|
||||||
|
action.Should().Throw<KeyNotFoundException>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetPrimaryKey_WhenPrimaryKeyExists_ItShouldReturnPrimaryKey()
|
||||||
|
{
|
||||||
|
var primaryKeyId = "primary-key";
|
||||||
|
var keyContent = "primary-key-content";
|
||||||
|
var mockPath = new Mock<IPath>();
|
||||||
|
|
||||||
|
_mockOptionsMonitor.CurrentValue = new FileKeyRingOptions
|
||||||
|
{
|
||||||
|
KeysDirectoryPath = "/keys",
|
||||||
|
PrimaryKeyId = primaryKeyId
|
||||||
|
};
|
||||||
|
|
||||||
|
mockPath
|
||||||
|
.Setup(p => p.GetFullPath("/keys", It.IsAny<string>()))
|
||||||
|
.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<IPath>();
|
||||||
|
|
||||||
|
_mockOptionsMonitor.CurrentValue = new FileKeyRingOptions
|
||||||
|
{
|
||||||
|
KeysDirectoryPath = "/keys",
|
||||||
|
PrimaryKeyId = "non-existent-primary-key"
|
||||||
|
};
|
||||||
|
|
||||||
|
mockPath
|
||||||
|
.Setup(static p => p.GetFullPath(It.IsAny<string>(), It.IsAny<string>()))
|
||||||
|
.Returns("/full/keys");
|
||||||
|
|
||||||
|
_mockFileSystem.Setup(static fs => fs.Path).Returns(mockPath.Object);
|
||||||
|
_mockFileSystem
|
||||||
|
.Setup(static fs => fs.Directory.Exists(It.IsAny<string>()))
|
||||||
|
.Returns(false);
|
||||||
|
|
||||||
|
var sut = FileKeyRing.From(_mockOptionsMonitor, _mockFileSystem.Object);
|
||||||
|
|
||||||
|
var action = sut.GetPrimaryKey;
|
||||||
|
|
||||||
|
action.Should().Throw<KeyNotFoundException>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[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<IPath>();
|
||||||
|
|
||||||
|
_mockOptionsMonitor.CurrentValue = new FileKeyRingOptions
|
||||||
|
{
|
||||||
|
KeysDirectoryPath = "/keys",
|
||||||
|
PrimaryKeyId = initialKeyId
|
||||||
|
};
|
||||||
|
|
||||||
|
mockPath
|
||||||
|
.Setup(p => p.GetFullPath("/keys", It.IsAny<string>()))
|
||||||
|
.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<IPath>();
|
||||||
|
|
||||||
|
_mockOptionsMonitor.CurrentValue = new FileKeyRingOptions
|
||||||
|
{
|
||||||
|
KeysDirectoryPath = "/non-existent-keys",
|
||||||
|
PrimaryKeyId = "any-key"
|
||||||
|
};
|
||||||
|
|
||||||
|
mockPath
|
||||||
|
.Setup(p => p.GetFullPath("/non-existent-keys", It.IsAny<string>()))
|
||||||
|
.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<KeyNotFoundException>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FileKeyRing_WhenKeyFileContainsOnlyWhitespace_ItShouldSkipKeyAndNotLoad()
|
||||||
|
{
|
||||||
|
var validKeyId = "valid-key";
|
||||||
|
var validKeyContent = "valid-key-content";
|
||||||
|
var whitespaceKeyId = "whitespace-key";
|
||||||
|
var mockPath = new Mock<IPath>();
|
||||||
|
|
||||||
|
_mockOptionsMonitor.CurrentValue = new FileKeyRingOptions
|
||||||
|
{
|
||||||
|
KeysDirectoryPath = "/keys",
|
||||||
|
PrimaryKeyId = validKeyId
|
||||||
|
};
|
||||||
|
|
||||||
|
mockPath
|
||||||
|
.Setup(p => p.GetFullPath("/keys", It.IsAny<string>()))
|
||||||
|
.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<KeyNotFoundException>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[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<IPath>();
|
||||||
|
|
||||||
|
_mockOptionsMonitor.CurrentValue = new FileKeyRingOptions
|
||||||
|
{
|
||||||
|
KeysDirectoryPath = "/keys",
|
||||||
|
PrimaryKeyId = key1Id
|
||||||
|
};
|
||||||
|
|
||||||
|
mockPath
|
||||||
|
.Setup(p => p.GetFullPath("/keys", It.IsAny<string>()))
|
||||||
|
.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<IPath>();
|
||||||
|
|
||||||
|
_mockOptionsMonitor.CurrentValue = new FileKeyRingOptions
|
||||||
|
{
|
||||||
|
KeysDirectoryPath = "/keys",
|
||||||
|
PrimaryKeyId = "key"
|
||||||
|
};
|
||||||
|
|
||||||
|
mockPath
|
||||||
|
.Setup(static p => p.GetFullPath(It.IsAny<string>(), It.IsAny<string>()))
|
||||||
|
.Returns("/full/keys");
|
||||||
|
|
||||||
|
_mockFileSystem.Setup(static fs => fs.Path).Returns(mockPath.Object);
|
||||||
|
|
||||||
|
_mockFileSystem
|
||||||
|
.Setup(static fs => fs.Directory.Exists(It.IsAny<string>()))
|
||||||
|
.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<IPath>();
|
||||||
|
|
||||||
|
_mockOptionsMonitor.CurrentValue = new FileKeyRingOptions
|
||||||
|
{
|
||||||
|
KeysDirectoryPath = "/keys",
|
||||||
|
PrimaryKeyId = keyId
|
||||||
|
};
|
||||||
|
|
||||||
|
mockPath
|
||||||
|
.Setup(p => p.GetFullPath("/keys", It.IsAny<string>()))
|
||||||
|
.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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<SymmetricSecurityKey>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[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<string, string?>
|
||||||
|
{
|
||||||
|
{ "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<string, string?>
|
||||||
|
{
|
||||||
|
{ "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<string, string?>
|
||||||
|
{
|
||||||
|
{ "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<string, string?>
|
||||||
|
{
|
||||||
|
{ "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<string, string?>
|
||||||
|
{
|
||||||
|
{ "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<string, string?>
|
||||||
|
{
|
||||||
|
{ "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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
using FiscalOS.Infra.Authentication;
|
|
||||||
|
|
||||||
namespace FiscalOS.Infra.Tests.Unit;
|
namespace FiscalOS.Infra.Tests.Unit;
|
||||||
|
|
||||||
public class PasswordHasherTests
|
public class PasswordHasherTests
|
||||||
|
|||||||
@@ -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<ProblemDetails>(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<ProblemDetails>(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<ProblemDetails>(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<ProblemDetails>(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<ProblemDetails>(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<ProblemDetails>(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<ProblemDetails>(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
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,7 +17,8 @@ public class TokenGeneratorTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void GenerateAccessToken_WhenCalled_ItShouldReturnValidJwtToken()
|
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);
|
var madeUp256BitSecret = RandomNumberGenerator.GetBytes(32);
|
||||||
|
|
||||||
@@ -58,7 +59,8 @@ public class TokenGeneratorTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void GenerateRefreshToken_WhenCalled_ItShouldReturnRefreshTokenWithCorrectProperties()
|
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;
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,25 @@
|
|||||||
global using System.Globalization;
|
global using System.Globalization;
|
||||||
global using System.IdentityModel.Tokens.Jwt;
|
global using System.IdentityModel.Tokens.Jwt;
|
||||||
|
global using System.IO.Abstractions;
|
||||||
global using System.Security.Cryptography;
|
global using System.Security.Cryptography;
|
||||||
|
global using System.Text;
|
||||||
|
global using System.Text.Json;
|
||||||
|
|
||||||
global using AwesomeAssertions.Primitives;
|
global using AwesomeAssertions.Primitives;
|
||||||
|
|
||||||
global using FiscalOS.Core.Identity;
|
global using FiscalOS.Core.Identity;
|
||||||
|
global using FiscalOS.Core.Security;
|
||||||
global using FiscalOS.Infra.Authentication;
|
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.Assertions;
|
||||||
|
global using FiscalOS.Infra.Tests.Mocks;
|
||||||
|
|
||||||
|
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.Extensions.Options;
|
||||||
|
global using Microsoft.IdentityModel.Tokens;
|
||||||
Reference in New Issue
Block a user