Compare commits
52
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
57c40c86ad | ||
|
|
9f88ec4d67 | ||
|
|
9c0f642f90 | ||
|
|
701b89d704 | ||
|
|
49e4288eba | ||
|
|
497dedf6ac | ||
|
|
29d8758ede | ||
|
|
6190470fe2 | ||
|
|
ed7e25961c | ||
|
|
e331030bb0 | ||
|
|
3b7830b1bc | ||
|
|
dad2ceb450 | ||
|
|
32cf48bf36 | ||
|
|
502f6f61c1 | ||
|
|
6ca90ed25a | ||
|
|
5192c06627 | ||
|
|
2fd55a55cd | ||
|
|
a2e9fc3f30 | ||
|
|
a83dbbcc54 | ||
|
|
a07fd24ee9 | ||
|
|
2fdb849142 | ||
|
|
1a41c3c0b6 | ||
|
|
c4a8aada6b | ||
|
|
dd08eee5a3 | ||
|
|
7cdf87b860 | ||
|
|
8663daabae | ||
|
|
0b7c33a024 | ||
|
|
823ba4dc5a | ||
|
|
1d661ac409 | ||
|
|
069d019d80 | ||
|
|
3f62a246ee | ||
|
|
5da1f1decf | ||
|
|
1f5e4a319a | ||
|
|
683ca3fac4 | ||
|
|
e88adfd2a7 | ||
|
|
6fc8bd0366 | ||
|
|
ab23593359 | ||
|
|
87a68669cf | ||
|
|
b4744076b7 | ||
|
|
5b1a5f003e | ||
|
|
b2086d8185 | ||
|
|
9b4f0c6b9e | ||
|
|
259daf1e5b | ||
|
|
e02a18df00 | ||
|
|
c1932246f3 | ||
|
|
4eb833d2c8 | ||
|
|
af29df5d19 | ||
|
|
04e0a31b27 | ||
|
|
97c7847a89 | ||
|
|
f34fb5ce41 | ||
|
|
38c311bdd1 | ||
|
|
e9b1bd11bb |
@@ -19,6 +19,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FiscalOS.Infra", "src\Fisca
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FiscalOS.Infra.Tests", "tests\FiscalOS.Infra.Tests\FiscalOS.Infra.Tests.csproj", "{48E0737E-0200-468E-ADA7-75893935DBC0}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FiscalOS.Core.Tests", "tests\FiscalOS.Core.Tests\FiscalOS.Core.Tests.csproj", "{2D9C02A9-C704-4275-88E9-1BBD3AC12700}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
@@ -101,6 +103,18 @@ Global
|
||||
{48E0737E-0200-468E-ADA7-75893935DBC0}.Release|x64.Build.0 = Release|Any CPU
|
||||
{48E0737E-0200-468E-ADA7-75893935DBC0}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{48E0737E-0200-468E-ADA7-75893935DBC0}.Release|x86.Build.0 = Release|Any CPU
|
||||
{2D9C02A9-C704-4275-88E9-1BBD3AC12700}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{2D9C02A9-C704-4275-88E9-1BBD3AC12700}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{2D9C02A9-C704-4275-88E9-1BBD3AC12700}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{2D9C02A9-C704-4275-88E9-1BBD3AC12700}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{2D9C02A9-C704-4275-88E9-1BBD3AC12700}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{2D9C02A9-C704-4275-88E9-1BBD3AC12700}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{2D9C02A9-C704-4275-88E9-1BBD3AC12700}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{2D9C02A9-C704-4275-88E9-1BBD3AC12700}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{2D9C02A9-C704-4275-88E9-1BBD3AC12700}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{2D9C02A9-C704-4275-88E9-1BBD3AC12700}.Release|x64.Build.0 = Release|Any CPU
|
||||
{2D9C02A9-C704-4275-88E9-1BBD3AC12700}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{2D9C02A9-C704-4275-88E9-1BBD3AC12700}.Release|x86.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
@@ -112,5 +126,6 @@ Global
|
||||
{612496CD-1F25-4804-8C52-49E62D01FFDC} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||
{DAB42870-02A2-4ECB-B07F-6815C52D2435} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||
{48E0737E-0200-468E-ADA7-75893935DBC0} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
|
||||
{2D9C02A9-C704-4275-88E9-1BBD3AC12700} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageVersion Include="Going.Plaid" Version="6.56.0" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.2" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.2">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
@@ -11,5 +12,6 @@
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.2" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="10.0.2" />
|
||||
<PackageVersion Include="Spectre.Console" Version="0.54.0" />
|
||||
<PackageVersion Include="TestableIO.System.IO.Abstractions.Wrappers" Version="22.1.0" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
</Project>
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace FiscalOS.API.Accounts;
|
||||
|
||||
internal static class AccountsExtensions
|
||||
{
|
||||
private const string RouteGroupPrefix = "/accounts";
|
||||
|
||||
public static RouteGroupBuilder MapAccountsEndpoints(this WebApplication app)
|
||||
{
|
||||
var accountsGroup = app.MapGroup(RouteGroupPrefix)
|
||||
.RequireAuthorization();
|
||||
|
||||
accountsGroup.MapAddEndpoint();
|
||||
|
||||
return accountsGroup;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
namespace FiscalOS.API.Accounts.Add;
|
||||
|
||||
internal static class Endpoint
|
||||
{
|
||||
private const string Route = "/";
|
||||
|
||||
public static RouteHandlerBuilder MapAddEndpoint(this RouteGroupBuilder groupBuilder)
|
||||
{
|
||||
return groupBuilder.MapPost(Route, HandleAsync);
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleAsync(
|
||||
HttpContext httpContext,
|
||||
[FromBody] Request request,
|
||||
[FromServices] AppDbContext appDbContext,
|
||||
CancellationToken ct
|
||||
)
|
||||
{
|
||||
var userId = httpContext.GetUserId();
|
||||
|
||||
var user = await appDbContext.Users
|
||||
.Include(u => u.Institutions.Where(i => i.Metadata is PlaidMetadata && ((PlaidMetadata)i.Metadata).PlaidId == request.PlaidInstitutionId))
|
||||
.Include(u => u.Accounts.Where(a => a.Metadata is PlaidAccountMetadata && ((PlaidAccountMetadata)a.Metadata).PlaidId == request.PlaidAccountId))
|
||||
.ThenInclude(a => a.Metadata)
|
||||
.SingleOrDefaultAsync(u => u.Id == userId, ct);
|
||||
|
||||
if (user is null)
|
||||
{
|
||||
return Results.Unauthorized();
|
||||
}
|
||||
|
||||
if (user.Institutions.Any() is false)
|
||||
{
|
||||
return Results.ValidationProblem(new Dictionary<string, string[]>
|
||||
{
|
||||
["PlaidInstitutionId"] = ["The PlaidInstitutionId field is invalid. No institution connected with the given PlaidInstitutionId was found for the user."],
|
||||
});
|
||||
}
|
||||
|
||||
if (user.Accounts.Any())
|
||||
{
|
||||
return Results.Conflict();
|
||||
}
|
||||
|
||||
var accountMetadata = PlaidAccountMetadata.From(request.PlaidAccountId, request.PlaidAccountName);
|
||||
var account = Account.From(user.Institutions.First().Id, request.PlaidAccountName, accountMetadata);
|
||||
var balance = Balance.From(request.AccountCurrentBalance, request.AccountAvailableBalance, request.AccountCurrencyCode);
|
||||
|
||||
account.AddBalance(balance);
|
||||
user.AddAccount(account);
|
||||
|
||||
await appDbContext.SaveChangesAsync(ct);
|
||||
|
||||
return Results.Ok();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
namespace FiscalOS.API.Accounts.Add;
|
||||
|
||||
public record Request : IValidatableObject
|
||||
{
|
||||
public string PlaidInstitutionId { get; init; } = string.Empty;
|
||||
public string PlaidAccountId { get; init; } = string.Empty;
|
||||
public string PlaidAccountName { get; init; } = string.Empty;
|
||||
public decimal AccountCurrentBalance { get; init; }
|
||||
public decimal AccountAvailableBalance { get; init; }
|
||||
public string AccountCurrencyCode { get; init; } = string.Empty;
|
||||
|
||||
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(PlaidInstitutionId))
|
||||
{
|
||||
var fieldName = nameof(PlaidInstitutionId);
|
||||
yield return new($"The {fieldName} field is required.", [fieldName]);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(PlaidAccountId))
|
||||
{
|
||||
var fieldName = nameof(PlaidAccountId);
|
||||
yield return new($"The {fieldName} field is required.", [fieldName]);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(PlaidAccountName))
|
||||
{
|
||||
var fieldName = nameof(PlaidAccountName);
|
||||
yield return new($"The {fieldName} field is required.", [fieldName]);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(AccountCurrencyCode))
|
||||
{
|
||||
var fieldName = nameof(AccountCurrencyCode);
|
||||
yield return new($"The {fieldName} field is required.", [fieldName]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace FiscalOS.API.Auth;
|
||||
|
||||
internal static class AuthExtensions
|
||||
{
|
||||
private const string RouteGroupPrefix = "/auth";
|
||||
|
||||
public static RouteGroupBuilder MapAuthEndpoints(this WebApplication app)
|
||||
{
|
||||
var authGroup = app.MapGroup(RouteGroupPrefix);
|
||||
|
||||
authGroup.MapLoginEndpoint();
|
||||
|
||||
authGroup.MapRefreshEndpoint()
|
||||
.RequireAuthorization(Schemes.AllowExpiredTokens);
|
||||
|
||||
return authGroup;
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,17 @@
|
||||
namespace FiscalOS.API.Login;
|
||||
namespace FiscalOS.API.Auth.Login;
|
||||
|
||||
internal static class Endpoint
|
||||
{
|
||||
private const string Route = "/login";
|
||||
|
||||
public static RouteHandlerBuilder MapLoginEndpoint(this WebApplication app)
|
||||
public static RouteHandlerBuilder MapLoginEndpoint(this RouteGroupBuilder groupBuilder)
|
||||
{
|
||||
return app.MapPost(Route, HandleAsync);
|
||||
return groupBuilder.MapPost(Route, HandleAsync);
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleAsync(
|
||||
HttpContext httpContext,
|
||||
[FromBody] LoginRequest loginRequest,
|
||||
[FromBody] Request loginRequest,
|
||||
[FromServices] AppDbContext appDbContext,
|
||||
[FromServices] IPasswordHasher passwordHasher,
|
||||
[FromServices] ITokenGenerator tokenService
|
||||
@@ -1,7 +1,6 @@
|
||||
namespace FiscalOS.API.Auth.Login;
|
||||
|
||||
namespace FiscalOS.API.Login;
|
||||
|
||||
public record LoginRequest : IValidatableObject
|
||||
public record Request : IValidatableObject
|
||||
{
|
||||
public string Username { get; init; } = string.Empty;
|
||||
public string Password { get; init; } = string.Empty;
|
||||
@@ -10,12 +9,14 @@ public record LoginRequest : IValidatableObject
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(Username))
|
||||
{
|
||||
yield return new("The Username field is required.", [nameof(Username)]);
|
||||
var fieldName = nameof(Username);
|
||||
yield return new($"The {fieldName} field is required.", [fieldName]);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(Password))
|
||||
{
|
||||
yield return new("The Password field is required.", [nameof(Password)]);
|
||||
var fieldName = nameof(Password);
|
||||
yield return new($"The {fieldName} field is required.", [fieldName]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace FiscalOS.API.Refresh;
|
||||
namespace FiscalOS.API.Auth.Login;
|
||||
|
||||
internal sealed record Response
|
||||
{
|
||||
@@ -1,12 +1,12 @@
|
||||
namespace FiscalOS.API.Refresh;
|
||||
namespace FiscalOS.API.Auth.Refresh;
|
||||
|
||||
internal static class Endpoint
|
||||
{
|
||||
private const string Route = "/refresh";
|
||||
|
||||
public static RouteHandlerBuilder MapRefreshEndpoint(this WebApplication app)
|
||||
public static RouteHandlerBuilder MapRefreshEndpoint(this RouteGroupBuilder groupBuilder)
|
||||
{
|
||||
return app.MapPost(Route, HandleAsync);
|
||||
return groupBuilder.MapPost(Route, HandleAsync);
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleAsync(
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace FiscalOS.API.Login;
|
||||
namespace FiscalOS.API.Auth.Refresh;
|
||||
|
||||
internal sealed record Response
|
||||
{
|
||||
@@ -1,5 +1,3 @@
|
||||
using FiscalOS.Core.Identity;
|
||||
|
||||
namespace FiscalOS.API.Http;
|
||||
|
||||
internal static class HttpContextExtensions
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
namespace FiscalOS.API.Institutions.Connect;
|
||||
|
||||
internal static class Endpoint
|
||||
{
|
||||
private const string Route = "/connect";
|
||||
|
||||
public static RouteHandlerBuilder MapConnectEndpoint(this RouteGroupBuilder groupBuilder)
|
||||
{
|
||||
return groupBuilder.MapPost(Route, HandleAsync);
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleAsync(
|
||||
HttpContext httpContext,
|
||||
[FromBody] Request request,
|
||||
[FromServices] AppDbContext appDbContext,
|
||||
[FromServices] PlaidService plaidService,
|
||||
[FromServices] IEncryptor encryptor,
|
||||
CancellationToken ct
|
||||
)
|
||||
{
|
||||
var userId = httpContext.GetUserId();
|
||||
|
||||
var user = await appDbContext.Users
|
||||
.Include(u => u.Institutions
|
||||
.Where(
|
||||
i => i.Metadata is PlaidMetadata &&
|
||||
((PlaidMetadata)i.Metadata).PlaidId == request.PlaidInstitutionId
|
||||
)
|
||||
)
|
||||
.ThenInclude(i => i.Metadata)
|
||||
.SingleOrDefaultAsync(u => u.Id == userId, ct);
|
||||
|
||||
if (user is null)
|
||||
{
|
||||
return Results.Unauthorized();
|
||||
}
|
||||
|
||||
if (user.Institutions.Any())
|
||||
{
|
||||
return Results.Conflict();
|
||||
}
|
||||
|
||||
var (itemId, accessToken) = await plaidService.ExchangeTokenAsync(request.PublicToken);
|
||||
var item = await plaidService.GetItemAsync(accessToken);
|
||||
|
||||
var encryptedAccessToken = await encryptor.EncryptAsyncFor(user, accessToken, ct);
|
||||
|
||||
var plaidMetadata = PlaidMetadata.From(item.InstitutionId, item.InstitutionName, encryptedAccessToken);
|
||||
var institution = Institution.From(item.InstitutionName, plaidMetadata);
|
||||
|
||||
user.AddInstitution(institution);
|
||||
|
||||
await appDbContext.SaveChangesAsync(ct);
|
||||
|
||||
return Results.Ok();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace FiscalOS.API.Institutions.Connect;
|
||||
|
||||
public record Request : IValidatableObject
|
||||
{
|
||||
public string PublicToken { get; init; } = string.Empty;
|
||||
public string PlaidInstitutionId { get; init; } = string.Empty;
|
||||
|
||||
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(PublicToken))
|
||||
{
|
||||
var fieldName = nameof(PublicToken);
|
||||
yield return new($"The {fieldName} field is required.", [fieldName]);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(PlaidInstitutionId))
|
||||
{
|
||||
var fieldName = nameof(PlaidInstitutionId);
|
||||
yield return new($"The {fieldName} field is required.", [fieldName]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
namespace FiscalOS.API.Institutions.GetAvailable;
|
||||
|
||||
internal static class Endpoint
|
||||
{
|
||||
private const string Route = "/{id}/available";
|
||||
|
||||
public static RouteHandlerBuilder MapGetAvailableEndpoint(this RouteGroupBuilder groupBuilder)
|
||||
{
|
||||
return groupBuilder.MapGet(Route, HandleAsync);
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleAsync(
|
||||
HttpContext httpContext,
|
||||
[FromRoute] Guid id,
|
||||
[FromServices] AppDbContext appDbContext,
|
||||
[FromServices] PlaidService plaidService,
|
||||
[FromServices] IEncryptor encryptor,
|
||||
CancellationToken ct
|
||||
)
|
||||
{
|
||||
var userId = httpContext.GetUserId();
|
||||
|
||||
var user = await appDbContext.Users
|
||||
.Include(u => u.Institutions.Where(i => i.Id == id))
|
||||
.ThenInclude(i => i.Metadata)
|
||||
.SingleOrDefaultAsync(u => u.Id == userId, ct);
|
||||
|
||||
if (user is null)
|
||||
{
|
||||
return Results.Unauthorized();
|
||||
}
|
||||
|
||||
if (user.Institutions.Any() is false)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var institution = user.Institutions.First();
|
||||
|
||||
if (institution.Metadata is not PlaidMetadata plaidMetadata)
|
||||
{
|
||||
return Results.BadRequest();
|
||||
}
|
||||
|
||||
var accessToken = await encryptor.DecryptAsyncFor(user, plaidMetadata.EncryptedAccessToken, ct);
|
||||
var accounts = await plaidService.GetAccountsAsync(accessToken);
|
||||
var accountsDtos = accounts.Select(AccountDto.FromPlaidAccount);
|
||||
|
||||
return Results.Ok(Response.From(accountsDtos));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
namespace FiscalOS.API.Institutions.GetAvailable;
|
||||
|
||||
internal sealed record Response
|
||||
{
|
||||
public IEnumerable<AccountDto> Accounts { get; init; } = [];
|
||||
|
||||
[JsonConstructor]
|
||||
private Response()
|
||||
{
|
||||
}
|
||||
|
||||
public static Response From(IEnumerable<AccountDto> accounts)
|
||||
{
|
||||
return new Response
|
||||
{
|
||||
Accounts = accounts
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record AccountDto
|
||||
{
|
||||
public string ProviderId { get; init; } = string.Empty;
|
||||
public string ProviderName { get; init; } = string.Empty;
|
||||
public decimal CurrentBalance { get; init; }
|
||||
public decimal AvailableBalance { get; init; }
|
||||
public string CurrencyCode { get; init; } = string.Empty;
|
||||
|
||||
[JsonConstructor]
|
||||
private AccountDto()
|
||||
{
|
||||
}
|
||||
|
||||
public static AccountDto FromPlaidAccount(Going.Plaid.Entity.Account plaidAccount)
|
||||
{
|
||||
return new AccountDto
|
||||
{
|
||||
ProviderId = plaidAccount.AccountId,
|
||||
ProviderName = plaidAccount.Name,
|
||||
AvailableBalance = plaidAccount.Available,
|
||||
CurrentBalance = plaidAccount.Current,
|
||||
CurrencyCode = plaidAccount.CurrencyCode,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace FiscalOS.API.Institutions;
|
||||
|
||||
internal static class InstitutionsExtensions
|
||||
{
|
||||
private const string RouteGroupPrefix = "/institutions";
|
||||
|
||||
public static RouteGroupBuilder MapInstitutionsEndpoints(this WebApplication app)
|
||||
{
|
||||
var institutionsGroup = app.MapGroup(RouteGroupPrefix)
|
||||
.RequireAuthorization();
|
||||
|
||||
institutionsGroup.MapConnectEndpoint();
|
||||
institutionsGroup.MapGetAvailableEndpoint();
|
||||
institutionsGroup.MapLinkEndpoint();
|
||||
|
||||
return institutionsGroup;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
namespace FiscalOS.API.Institutions.Link;
|
||||
|
||||
internal static class Endpoint
|
||||
{
|
||||
private const string Route = "/link";
|
||||
|
||||
public static RouteHandlerBuilder MapLinkEndpoint(this RouteGroupBuilder groupBuilder)
|
||||
{
|
||||
return groupBuilder.MapPost(Route, HandleAsync);
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleAsync(
|
||||
HttpContext httpContext,
|
||||
[FromServices] AppDbContext appDbContext,
|
||||
[FromServices] PlaidService plaidService
|
||||
)
|
||||
{
|
||||
var userId = httpContext.GetUserId();
|
||||
|
||||
var user = await appDbContext.Users.SingleOrDefaultAsync(u => u.Id == userId);
|
||||
|
||||
if (user is null)
|
||||
{
|
||||
return Results.Unauthorized();
|
||||
}
|
||||
|
||||
var linkToken = await plaidService.CreateLinkTokenAsync(user.Id.ToString());
|
||||
|
||||
return Results.Ok(Response.From(linkToken));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace FiscalOS.API.Institutions.Link;
|
||||
|
||||
public sealed record Response
|
||||
{
|
||||
public string LinkToken { get; init; } = string.Empty;
|
||||
|
||||
[JsonConstructor]
|
||||
private Response()
|
||||
{
|
||||
}
|
||||
|
||||
public static Response From(string linkToken)
|
||||
{
|
||||
return new()
|
||||
{
|
||||
LinkToken = linkToken,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
using FiscalOS.Infra.Authentication;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.Services.AddValidation();
|
||||
@@ -34,9 +32,8 @@ app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
app.UseStatusCodePages();
|
||||
|
||||
app.MapLoginEndpoint();
|
||||
|
||||
app.MapRefreshEndpoint()
|
||||
.RequireAuthorization(Schemes.AllowExpiredTokens);
|
||||
app.MapAuthEndpoints();
|
||||
app.MapAccountsEndpoints();
|
||||
app.MapInstitutionsEndpoints();
|
||||
|
||||
app.Run();
|
||||
@@ -2,12 +2,24 @@ global using System.ComponentModel.DataAnnotations;
|
||||
global using System.Security.Claims;
|
||||
global using System.Text.Json.Serialization;
|
||||
|
||||
global using FiscalOS.API.Accounts;
|
||||
global using FiscalOS.API.Accounts.Add;
|
||||
global using FiscalOS.API.Auth;
|
||||
global using FiscalOS.API.Auth.Login;
|
||||
global using FiscalOS.API.Auth.Refresh;
|
||||
global using FiscalOS.API.Http;
|
||||
global using FiscalOS.API.Login;
|
||||
global using FiscalOS.API.Refresh;
|
||||
global using FiscalOS.API.Institutions;
|
||||
global using FiscalOS.API.Institutions.Connect;
|
||||
global using FiscalOS.API.Institutions.GetAvailable;
|
||||
global using FiscalOS.API.Institutions.Link;
|
||||
global using FiscalOS.Core.Accounts;
|
||||
global using FiscalOS.Core.Authentication;
|
||||
global using FiscalOS.Core.Identity;
|
||||
global using FiscalOS.Core.Security;
|
||||
global using FiscalOS.Infra.Accounts.Plaid;
|
||||
global using FiscalOS.Infra.Authentication;
|
||||
global using FiscalOS.Infra.Data;
|
||||
global using FiscalOS.Infra.DependencyInjection;
|
||||
|
||||
global using Microsoft.AspNetCore.Mvc;
|
||||
global using Microsoft.EntityFrameworkCore;
|
||||
global using Microsoft.EntityFrameworkCore;
|
||||
@@ -14,5 +14,13 @@
|
||||
"Issuer": "Issuer",
|
||||
"Secret": "Secret",
|
||||
"ExpiryInMinutes": "ExpiryInMinutes"
|
||||
},
|
||||
"FileKeyRingOptions": {
|
||||
"KeysDirectoryPath": "KeysDirectoryPath",
|
||||
"PrimaryKeyId": "PrimaryKeyId"
|
||||
},
|
||||
"PlaidClientOptions": {
|
||||
"ClientId": "ClientId",
|
||||
"Secret": "Secret"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,9 @@ namespace FiscalOS.AdminCLI;
|
||||
internal sealed class App(
|
||||
IAnsiConsole console,
|
||||
IPasswordHasher passwordHasher,
|
||||
IServiceScopeFactory serviceScopeFactory
|
||||
IServiceScopeFactory serviceScopeFactory,
|
||||
IEncryptor encryptor,
|
||||
IKeyRing keyRing
|
||||
) : IHostedService
|
||||
{
|
||||
private readonly IAnsiConsole _console = console;
|
||||
@@ -32,13 +34,19 @@ internal sealed class App(
|
||||
);
|
||||
|
||||
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.SaveChangesAsync(cancellationToken);
|
||||
|
||||
_console.MarkupLine($"User [green]{username}[/] created successfully.");
|
||||
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:
|
||||
default:
|
||||
break;
|
||||
|
||||
@@ -3,7 +3,12 @@ namespace FiscalOS.AdminCLI;
|
||||
internal static class Commands
|
||||
{
|
||||
public const string CreateUser = "Create User";
|
||||
public const string GenerateKey = "Generate Key";
|
||||
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.Core.Authentication;
|
||||
global using FiscalOS.Core.Identity;
|
||||
global using FiscalOS.Core.Security;
|
||||
global using FiscalOS.Infra.Data;
|
||||
global using FiscalOS.Infra.DependencyInjection;
|
||||
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
{
|
||||
"AppDbContextOptions": {
|
||||
"DatabaseFilePath": "DatabaseFilePath"
|
||||
},
|
||||
"FileKeyRingOptions": {
|
||||
"KeysDirectoryPath": "KeysDirectoryPath",
|
||||
"PrimaryKeyId": "PrimaryKeyId"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
namespace FiscalOS.Core.Accounts;
|
||||
|
||||
public sealed class Account : Entity
|
||||
{
|
||||
public Guid UserId { get; init; }
|
||||
public Guid InstitutionId { get; init; }
|
||||
public Institution? Institution { get; init; }
|
||||
public string Name { get; init; } = string.Empty;
|
||||
public AccountMetadata? Metadata { get; init; }
|
||||
|
||||
private readonly List<Balance> _balances = [];
|
||||
public IEnumerable<Balance> Balances => _balances;
|
||||
|
||||
public static Account From(Guid institutionId, string name, AccountMetadata accountMetadata)
|
||||
{
|
||||
return new()
|
||||
{
|
||||
InstitutionId = institutionId,
|
||||
Name = name,
|
||||
Metadata = accountMetadata,
|
||||
};
|
||||
}
|
||||
|
||||
public void AddBalance(Balance balance)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(balance);
|
||||
|
||||
_balances.Add(balance);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace FiscalOS.Core.Accounts;
|
||||
|
||||
public abstract class AccountMetadata : Entity
|
||||
{
|
||||
private readonly string _type;
|
||||
|
||||
public string Type => _type;
|
||||
|
||||
public Guid AccountId { get; init; }
|
||||
|
||||
protected AccountMetadata(string type)
|
||||
{
|
||||
_type = type;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
namespace FiscalOS.Core.Accounts;
|
||||
|
||||
public sealed class Balance : Entity
|
||||
{
|
||||
public Guid AccountId { get; init; }
|
||||
public decimal Current { get; init; }
|
||||
public decimal Available { get; init; }
|
||||
public string CurrencyCode { get; init; } = string.Empty;
|
||||
|
||||
private Balance()
|
||||
{
|
||||
}
|
||||
|
||||
public static Balance From(
|
||||
decimal current,
|
||||
decimal available,
|
||||
string currencyCode
|
||||
)
|
||||
{
|
||||
return new()
|
||||
{
|
||||
Current = current,
|
||||
Available = available,
|
||||
CurrencyCode = currencyCode,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
namespace FiscalOS.Core.Accounts;
|
||||
|
||||
public sealed class Institution : Entity
|
||||
{
|
||||
public Guid UserId { get; init; }
|
||||
public string Name { get; init; } = string.Empty;
|
||||
public InstitutionMetadata? Metadata { get; init; }
|
||||
|
||||
private Institution()
|
||||
{
|
||||
}
|
||||
|
||||
public static Institution From(string? name, InstitutionMetadata metadata)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(name, nameof(name));
|
||||
ArgumentNullException.ThrowIfNull(metadata, nameof(metadata));
|
||||
|
||||
return new Institution()
|
||||
{
|
||||
Name = name,
|
||||
Metadata = metadata
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace FiscalOS.Core.Accounts;
|
||||
|
||||
public abstract class InstitutionMetadata : Entity
|
||||
{
|
||||
private readonly string _type;
|
||||
|
||||
public string Type => _type;
|
||||
|
||||
public Guid InstitutionId { get; init; }
|
||||
|
||||
protected InstitutionMetadata(string type)
|
||||
{
|
||||
_type = type;
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,7 @@ public sealed class RefreshToken : Entity
|
||||
|
||||
public static RefreshToken From(User user, string token, DateTimeOffset expiresAt)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(user);
|
||||
ArgumentNullException.ThrowIfNull(user, nameof(user));
|
||||
|
||||
return new()
|
||||
{
|
||||
|
||||
@@ -2,13 +2,20 @@ namespace FiscalOS.Core.Identity;
|
||||
|
||||
public sealed class User : Entity
|
||||
{
|
||||
private readonly List<RefreshToken> _refreshTokens = [];
|
||||
|
||||
public string Username { 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;
|
||||
|
||||
private readonly List<RefreshToken> _refreshTokens = [];
|
||||
public IEnumerable<RefreshToken> RefreshTokens => _refreshTokens;
|
||||
|
||||
private readonly List<Institution> _institutions = [];
|
||||
public IEnumerable<Institution> Institutions => _institutions;
|
||||
|
||||
private readonly List<Account> _accounts = [];
|
||||
public IEnumerable<Account> Accounts => _accounts;
|
||||
|
||||
private User()
|
||||
{
|
||||
}
|
||||
@@ -18,19 +25,39 @@ public sealed class User : Entity
|
||||
return new();
|
||||
}
|
||||
|
||||
public static User From(string username, string hashedPassword)
|
||||
public static User From(string username, string hashedPassword, EncryptedDataKey encryptedDataKey)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(username, nameof(username));
|
||||
ArgumentNullException.ThrowIfNull(hashedPassword, nameof(hashedPassword));
|
||||
ArgumentNullException.ThrowIfNull(encryptedDataKey, nameof(encryptedDataKey));
|
||||
|
||||
return new()
|
||||
{
|
||||
Username = username,
|
||||
HashedPassword = hashedPassword,
|
||||
EncryptedDataKey = encryptedDataKey.EncryptedKey,
|
||||
EncryptionKeyId = encryptedDataKey.KeyIdUsed,
|
||||
};
|
||||
}
|
||||
|
||||
public void AddRefreshToken(RefreshToken refreshToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(refreshToken);
|
||||
ArgumentNullException.ThrowIfNull(refreshToken, nameof(refreshToken));
|
||||
|
||||
_refreshTokens.Add(refreshToken);
|
||||
}
|
||||
|
||||
public void AddInstitution(Institution institution)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(institution, nameof(institution));
|
||||
|
||||
_institutions.Add(institution);
|
||||
}
|
||||
|
||||
public void AddAccount(Account account)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(account, nameof(account));
|
||||
|
||||
_accounts.Add(account);
|
||||
}
|
||||
}
|
||||
@@ -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, nameof(keyIdUsed));
|
||||
ArgumentNullException.ThrowIfNull(encryptedKey, nameof(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, nameof(keyId));
|
||||
ArgumentNullException.ThrowIfNull(key, nameof(key));
|
||||
|
||||
return new(keyId, key);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
global using FiscalOS.Core.Accounts;
|
||||
global using FiscalOS.Core.Data;
|
||||
|
||||
global using FiscalOS.Core.Identity;
|
||||
global using FiscalOS.Core.Identity;
|
||||
global using FiscalOS.Core.Security;
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace FiscalOS.Infra.Accounts.Plaid;
|
||||
|
||||
public sealed class PlaidAccountMetadata : AccountMetadata
|
||||
{
|
||||
public const string TypeValue = Providers.Plaid;
|
||||
public string PlaidId { get; init; } = string.Empty;
|
||||
public string PlaidName { get; init; } = string.Empty;
|
||||
|
||||
private PlaidAccountMetadata() : base(TypeValue)
|
||||
{
|
||||
}
|
||||
|
||||
public static PlaidAccountMetadata From(string plaidId, string plaidName)
|
||||
{
|
||||
return new()
|
||||
{
|
||||
PlaidId = plaidId,
|
||||
PlaidName = plaidName,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
namespace FiscalOS.Infra.Accounts.Plaid;
|
||||
|
||||
public sealed record PlaidClientOptions
|
||||
{
|
||||
public string ClientId { get; init; } = string.Empty;
|
||||
public string Secret { get; init; } = string.Empty;
|
||||
|
||||
public IOptions<PlaidOptions> ToPlaidOptions()
|
||||
{
|
||||
return Options.Create<PlaidOptions>(new()
|
||||
{
|
||||
ClientId = ClientId,
|
||||
Secret = Secret
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record PlaidClientOptionsSetup : IConfigureOptions<PlaidClientOptions>
|
||||
{
|
||||
private const string SectionName = nameof(PlaidClientOptions);
|
||||
private readonly IConfiguration _configuration;
|
||||
|
||||
public PlaidClientOptionsSetup(IConfiguration configuration)
|
||||
{
|
||||
_configuration = configuration;
|
||||
}
|
||||
|
||||
public void Configure(PlaidClientOptions options)
|
||||
{
|
||||
_configuration.GetSection(SectionName).Bind(options);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace FiscalOS.Infra.Accounts.Plaid;
|
||||
|
||||
public class PlaidException : Exception
|
||||
{
|
||||
public PlaidException()
|
||||
{
|
||||
}
|
||||
|
||||
public PlaidException(string message) : base(message)
|
||||
{
|
||||
}
|
||||
|
||||
public PlaidException(string message, Exception innerException) : base(message, innerException)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace FiscalOS.Infra.Accounts.Plaid;
|
||||
|
||||
public static class PlaidExtensions
|
||||
{
|
||||
#pragma warning disable CA1034
|
||||
extension(Going.Plaid.Entity.Account act)
|
||||
{
|
||||
public string CurrencyCode => act.Balances.IsoCurrencyCode
|
||||
?? act.Balances.UnofficialCurrencyCode
|
||||
?? "Unknown";
|
||||
|
||||
public decimal Available => act.Balances.Available.GetValueOrDefault();
|
||||
public decimal Current => act.Balances.Current.GetValueOrDefault();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
namespace FiscalOS.Infra.Accounts.Plaid;
|
||||
|
||||
public sealed class PlaidMetadata : InstitutionMetadata
|
||||
{
|
||||
public const string TypeValue = Providers.Plaid;
|
||||
public string PlaidId { get; init; } = string.Empty;
|
||||
public string PlaidName { get; init; } = string.Empty;
|
||||
public string EncryptedAccessToken { get; init; } = string.Empty;
|
||||
|
||||
private PlaidMetadata() : base(TypeValue)
|
||||
{
|
||||
}
|
||||
|
||||
public static PlaidMetadata From(
|
||||
string? plaidId,
|
||||
string? plaidName,
|
||||
string encryptedAccessToken
|
||||
)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(plaidId, nameof(plaidId));
|
||||
ArgumentNullException.ThrowIfNull(plaidName, nameof(plaidName));
|
||||
ArgumentNullException.ThrowIfNull(encryptedAccessToken, nameof(encryptedAccessToken));
|
||||
|
||||
return new PlaidMetadata
|
||||
{
|
||||
PlaidId = plaidId,
|
||||
PlaidName = plaidName,
|
||||
EncryptedAccessToken = encryptedAccessToken
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
|
||||
|
||||
using System.Reflection;
|
||||
|
||||
namespace FiscalOS.Infra.Accounts.Plaid;
|
||||
|
||||
public sealed class PlaidService
|
||||
{
|
||||
private readonly PlaidClient _client;
|
||||
|
||||
private PlaidService(PlaidClient client)
|
||||
{
|
||||
_client = client;
|
||||
}
|
||||
|
||||
public static PlaidService From(IServiceProvider sp)
|
||||
{
|
||||
var client = sp.GetRequiredService<PlaidClient>();
|
||||
return new(client);
|
||||
}
|
||||
|
||||
public async Task<string> CreateLinkTokenAsync(string id)
|
||||
{
|
||||
var assemblyName = Assembly.GetExecutingAssembly().GetName().FullName;
|
||||
var environmentName = System.Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT");
|
||||
|
||||
var ltr = await _client.LinkTokenCreateAsync(new()
|
||||
{
|
||||
ClientName = $"{assemblyName}_{environmentName}",
|
||||
Products = [Products.Transactions],
|
||||
CountryCodes = [CountryCode.Us],
|
||||
Language = Language.English,
|
||||
User = new()
|
||||
{
|
||||
ClientUserId = id,
|
||||
},
|
||||
}).ConfigureAwait(false);
|
||||
|
||||
if (ltr.IsSuccessStatusCode is false)
|
||||
{
|
||||
throw new PlaidException("Unable to create link token");
|
||||
}
|
||||
|
||||
return ltr.LinkToken;
|
||||
}
|
||||
|
||||
public async Task<(string ItemId, string AccessToken)> ExchangeTokenAsync(string publicToken)
|
||||
{
|
||||
var ptr = await _client.ItemPublicTokenExchangeAsync(new()
|
||||
{
|
||||
PublicToken = publicToken,
|
||||
}).ConfigureAwait(false);
|
||||
|
||||
if (ptr.IsSuccessStatusCode is false)
|
||||
{
|
||||
throw new PlaidException("Unable to exchange public token for access token");
|
||||
}
|
||||
|
||||
return (ptr.ItemId, ptr.AccessToken);
|
||||
}
|
||||
|
||||
public async Task<List<Going.Plaid.Entity.Account>> GetAccountsAsync(string accessToken)
|
||||
{
|
||||
var ar = await _client.AccountsGetAsync(new()
|
||||
{
|
||||
AccessToken = accessToken,
|
||||
}).ConfigureAwait(false);
|
||||
|
||||
if (ar.IsSuccessStatusCode is false)
|
||||
{
|
||||
throw new PlaidException("Unable to retrieve accounts");
|
||||
}
|
||||
|
||||
return [.. ar.Accounts];
|
||||
}
|
||||
|
||||
public async Task<ItemWithConsentFields> GetItemAsync(string accessToken)
|
||||
{
|
||||
var ar = await _client.ItemGetAsync(new()
|
||||
{
|
||||
AccessToken = accessToken,
|
||||
}).ConfigureAwait(false);
|
||||
|
||||
if (ar.IsSuccessStatusCode is false)
|
||||
{
|
||||
throw new PlaidException("Unable to retrieve item");
|
||||
}
|
||||
|
||||
return ar.Item;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace FiscalOS.Infra.Accounts;
|
||||
|
||||
public static class Providers
|
||||
{
|
||||
public const string Plaid = nameof(Plaid);
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
|
||||
namespace FiscalOS.Infra.Authentication;
|
||||
|
||||
public sealed record JwtOptions
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
|
||||
namespace FiscalOS.Infra.Authentication;
|
||||
|
||||
public static class Schemes
|
||||
|
||||
@@ -1,21 +1,28 @@
|
||||
using Account = FiscalOS.Core.Accounts.Account;
|
||||
using Institution = FiscalOS.Core.Accounts.Institution;
|
||||
|
||||
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 readonly AppDbContextOptions _ctxOptions = ctxOptions.Value;
|
||||
private readonly IFileSystem _fileSystem = fileSystem;
|
||||
|
||||
public DbSet<User> Users => Set<User>();
|
||||
public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>();
|
||||
|
||||
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
||||
{
|
||||
var dbPath = _ctxOptions.GetFullyQualifiedDatabasePath();
|
||||
var dbDirectory = Path.GetDirectoryName(dbPath) ?? throw new InvalidOperationException("Database directory path could not be determined.");
|
||||
var dbPath = _fileSystem.Path.GetFullPath(_ctxOptions.DatabaseFilePath, AppContext.BaseDirectory);
|
||||
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}";
|
||||
@@ -45,15 +52,25 @@ public sealed class AppDbContext(IOptions<AppDbContextOptions> ctxOptions) : DbC
|
||||
|
||||
modelBuilder.Entity<User>(static eb =>
|
||||
{
|
||||
eb.Property(static u => u.Username);
|
||||
eb.HasIndex(static u => u.Username).IsUnique();
|
||||
|
||||
eb.Property(static u => u.HashedPassword);
|
||||
|
||||
eb.HasMany(static u => u.RefreshTokens)
|
||||
.WithOne(static t => t.User)
|
||||
.HasForeignKey(static t => t.UserId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
eb.Property(static u => u.Username);
|
||||
eb.HasIndex(static u => u.Username).IsUnique();
|
||||
eb.HasMany(static u => u.Institutions)
|
||||
.WithOne()
|
||||
.HasForeignKey(static i => i.UserId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
eb.Property(static u => u.HashedPassword);
|
||||
eb.HasMany(static u => u.Accounts)
|
||||
.WithOne()
|
||||
.HasForeignKey(static a => a.UserId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<RefreshToken>(static eb =>
|
||||
@@ -65,5 +82,78 @@ public sealed class AppDbContext(IOptions<AppDbContextOptions> ctxOptions) : DbC
|
||||
eb.Property(static t => t.Token);
|
||||
eb.HasIndex(static t => t.Token).IsUnique();
|
||||
});
|
||||
|
||||
modelBuilder.Entity<Institution>(static eb =>
|
||||
{
|
||||
eb.Property(static i => i.Name);
|
||||
|
||||
eb.HasOne(static i => i.Metadata)
|
||||
.WithOne()
|
||||
.HasForeignKey<InstitutionMetadata>(static m => m.InstitutionId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<InstitutionMetadata>(static eb =>
|
||||
{
|
||||
eb.HasDiscriminator(static m => m.Type)
|
||||
.HasValue<PlaidMetadata>(PlaidMetadata.TypeValue);
|
||||
|
||||
eb.Property(static m => m.InstitutionId);
|
||||
eb.Property(static m => m.Type);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<PlaidMetadata>(static eb =>
|
||||
{
|
||||
eb.HasBaseType<InstitutionMetadata>();
|
||||
|
||||
eb.Property(static m => m.PlaidId);
|
||||
eb.Property(static m => m.PlaidName);
|
||||
eb.Property(static m => m.EncryptedAccessToken);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<Account>(static eb =>
|
||||
{
|
||||
eb.Property(static a => a.Name);
|
||||
|
||||
eb.HasOne(static a => a.Institution)
|
||||
.WithMany()
|
||||
.HasForeignKey(static a => a.InstitutionId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
eb.HasOne(static a => a.Metadata)
|
||||
.WithOne()
|
||||
.HasForeignKey<AccountMetadata>(static m => m.AccountId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
eb.HasMany(static a => a.Balances)
|
||||
.WithOne()
|
||||
.HasForeignKey(static b => b.AccountId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<AccountMetadata>(static eb =>
|
||||
{
|
||||
eb.HasDiscriminator(static m => m.Type)
|
||||
.HasValue<PlaidAccountMetadata>(PlaidAccountMetadata.TypeValue);
|
||||
|
||||
eb.Property(static m => m.AccountId);
|
||||
eb.Property(static m => m.Type);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<PlaidAccountMetadata>(static eb =>
|
||||
{
|
||||
eb.HasBaseType<AccountMetadata>();
|
||||
|
||||
eb.Property(static m => m.PlaidId);
|
||||
eb.Property(static m => m.PlaidName);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<Balance>(static eb =>
|
||||
{
|
||||
eb.Property(static b => b.AccountId);
|
||||
eb.Property(static b => b.Current);
|
||||
eb.Property(static b => b.Available);
|
||||
eb.Property(static b => b.CurrencyCode);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -3,11 +3,6 @@ namespace FiscalOS.Infra.Data;
|
||||
public sealed record AppDbContextOptions
|
||||
{
|
||||
public string DatabaseFilePath { get; init; } = string.Empty;
|
||||
|
||||
public string GetFullyQualifiedDatabasePath()
|
||||
{
|
||||
return Path.GetFullPath(DatabaseFilePath, AppContext.BaseDirectory);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record AppDbContextOptionsSetup : IConfigureOptions<AppDbContextOptions>
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
using FiscalOS.Core.Data;
|
||||
|
||||
namespace FiscalOS.Infra.Data;
|
||||
|
||||
internal sealed class TimestampInterceptor : SaveChangesInterceptor
|
||||
|
||||
@@ -5,19 +5,36 @@ public static class ServiceCollectionExtensions
|
||||
public static IServiceCollection AddInfrastructure(this IServiceCollection services)
|
||||
{
|
||||
services.AddSingleton<IAuthorizationMiddlewareResultHandler, ProblemDetailsAuthResultHandler>();
|
||||
|
||||
|
||||
services.AddHttpClient();
|
||||
services.AddSingleton(TimeProvider.System);
|
||||
services.AddSingleton<IFileSystem, FileSystem>();
|
||||
|
||||
services.ConfigureOptions<JwtOptionsSetup>();
|
||||
services.ConfigureOptions<JwtBearerOptionsSetup>();
|
||||
|
||||
services.AddSingleton<ITokenGenerator, TokenGenerator>(TokenGenerator.From);
|
||||
services.AddSingleton<IPasswordHasher, PasswordHasher>(PasswordHasher.From);
|
||||
services.AddSingleton<ITokenGenerator>(TokenGenerator.From);
|
||||
services.AddSingleton<IPasswordHasher>(PasswordHasher.From);
|
||||
|
||||
services.ConfigureOptions<FileKeyRingOptionsSetup>();
|
||||
services.AddSingleton<IKeyRing>(FileKeyRing.From);
|
||||
services.AddSingleton<IEncryptor>(Encryptor.From);
|
||||
|
||||
services.ConfigureOptions<AppDbContextOptionsSetup>();
|
||||
services.AddDbContext<AppDbContext>();
|
||||
services.AddHostedService<MigrationService>();
|
||||
|
||||
services.ConfigureOptions<PlaidClientOptionsSetup>();
|
||||
services.AddSingleton(static sp =>
|
||||
{
|
||||
var factory = sp.GetRequiredService<IHttpClientFactory>();
|
||||
var logger = sp.GetRequiredService<ILogger<PlaidClient>>();
|
||||
var clientOptions = sp.GetRequiredService<IOptions<PlaidClientOptions>>();
|
||||
var options = clientOptions.Value.ToPlaidOptions();
|
||||
return new PlaidClient(options, factory, logger);
|
||||
});
|
||||
services.AddSingleton(PlaidService.From);
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Going.Plaid" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" />
|
||||
<PackageReference Include="TestableIO.System.IO.Abstractions.Wrappers" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
using System;
|
||||
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
using System;
|
||||
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
using System;
|
||||
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
using System;
|
||||
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
|
||||
@@ -4,19 +4,19 @@
|
||||
|
||||
namespace FiscalOS.Infra.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddUserIdPropToRefreshToken : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddUserIdPropToRefreshToken : Migration
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,26 +4,26 @@
|
||||
|
||||
namespace FiscalOS.Infra.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddRevokedPropertyToRefreshToken : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddRevokedPropertyToRefreshToken : Migration
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "Revoked",
|
||||
table: "RefreshTokens",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Revoked",
|
||||
table: "RefreshTokens");
|
||||
}
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "Revoked",
|
||||
table: "RefreshTokens",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Revoked",
|
||||
table: "RefreshTokens");
|
||||
}
|
||||
}
|
||||
}
|
||||
+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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+215
@@ -0,0 +1,215 @@
|
||||
// <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("20260212215015_InstitutionAndMetadata")]
|
||||
partial class InstitutionAndMetadata
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "10.0.2");
|
||||
|
||||
modelBuilder.Entity("FiscalOS.Core.Accounts.Institution", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("Institution");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FiscalOS.Core.Accounts.InstitutionMetadata", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("InstitutionId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Type")
|
||||
.IsRequired()
|
||||
.HasMaxLength(21)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("InstitutionId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("InstitutionMetadata");
|
||||
|
||||
b.HasDiscriminator<string>("Type").HasValue("InstitutionMetadata");
|
||||
|
||||
b.UseTphMappingStrategy();
|
||||
});
|
||||
|
||||
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.Infra.Accounts.Plaid.PlaidMetadata", b =>
|
||||
{
|
||||
b.HasBaseType("FiscalOS.Core.Accounts.InstitutionMetadata");
|
||||
|
||||
b.Property<string>("EncryptedAccessToken")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PlaidId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PlaidName")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasDiscriminator().HasValue("Plaid");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FiscalOS.Core.Accounts.Institution", b =>
|
||||
{
|
||||
b.HasOne("FiscalOS.Core.Identity.User", null)
|
||||
.WithMany("Institutions")
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FiscalOS.Core.Accounts.InstitutionMetadata", b =>
|
||||
{
|
||||
b.HasOne("FiscalOS.Core.Accounts.Institution", null)
|
||||
.WithOne("Metadata")
|
||||
.HasForeignKey("FiscalOS.Core.Accounts.InstitutionMetadata", "InstitutionId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
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.Accounts.Institution", b =>
|
||||
{
|
||||
b.Navigation("Metadata")
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FiscalOS.Core.Identity.User", b =>
|
||||
{
|
||||
b.Navigation("Institutions");
|
||||
|
||||
b.Navigation("RefreshTokens");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace FiscalOS.Infra.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class InstitutionAndMetadata : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Institution",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
UserId = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
Name = table.Column<string>(type: "TEXT", nullable: false),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "TEXT", nullable: false),
|
||||
UpdatedAt = table.Column<DateTimeOffset>(type: "TEXT", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Institution", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_Institution_Users_UserId",
|
||||
column: x => x.UserId,
|
||||
principalTable: "Users",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "InstitutionMetadata",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
Type = table.Column<string>(type: "TEXT", maxLength: 21, nullable: false),
|
||||
InstitutionId = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
PlaidId = table.Column<string>(type: "TEXT", nullable: true),
|
||||
PlaidName = table.Column<string>(type: "TEXT", nullable: true),
|
||||
EncryptedAccessToken = table.Column<string>(type: "TEXT", nullable: true),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "TEXT", nullable: false),
|
||||
UpdatedAt = table.Column<DateTimeOffset>(type: "TEXT", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_InstitutionMetadata", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_InstitutionMetadata_Institution_InstitutionId",
|
||||
column: x => x.InstitutionId,
|
||||
principalTable: "Institution",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Institution_UserId",
|
||||
table: "Institution",
|
||||
column: "UserId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_InstitutionMetadata_InstitutionId",
|
||||
table: "InstitutionMetadata",
|
||||
column: "InstitutionId",
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "InstitutionMetadata");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Institution");
|
||||
}
|
||||
}
|
||||
}
|
||||
+327
@@ -0,0 +1,327 @@
|
||||
// <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("20260213130218_AccountAndMetadata")]
|
||||
partial class AccountAndMetadata
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "10.0.2");
|
||||
|
||||
modelBuilder.Entity("FiscalOS.Core.Accounts.Account", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("InstituionId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("InstituionId");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("Account");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FiscalOS.Core.Accounts.AccountMetadata", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("AccountId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Type")
|
||||
.IsRequired()
|
||||
.HasMaxLength(21)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AccountId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("AccountMetadata");
|
||||
|
||||
b.HasDiscriminator<string>("Type").HasValue("AccountMetadata");
|
||||
|
||||
b.UseTphMappingStrategy();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FiscalOS.Core.Accounts.Institution", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("Institution");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FiscalOS.Core.Accounts.InstitutionMetadata", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("InstitutionId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Type")
|
||||
.IsRequired()
|
||||
.HasMaxLength(21)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("InstitutionId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("InstitutionMetadata");
|
||||
|
||||
b.HasDiscriminator<string>("Type").HasValue("InstitutionMetadata");
|
||||
|
||||
b.UseTphMappingStrategy();
|
||||
});
|
||||
|
||||
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.Infra.Accounts.Plaid.PlaidAccountMetadata", b =>
|
||||
{
|
||||
b.HasBaseType("FiscalOS.Core.Accounts.AccountMetadata");
|
||||
|
||||
b.Property<string>("PlaidId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PlaidName")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasDiscriminator().HasValue("Plaid");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FiscalOS.Infra.Accounts.Plaid.PlaidMetadata", b =>
|
||||
{
|
||||
b.HasBaseType("FiscalOS.Core.Accounts.InstitutionMetadata");
|
||||
|
||||
b.Property<string>("EncryptedAccessToken")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PlaidId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PlaidName")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasDiscriminator().HasValue("Plaid");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FiscalOS.Core.Accounts.Account", b =>
|
||||
{
|
||||
b.HasOne("FiscalOS.Core.Accounts.Institution", "Institution")
|
||||
.WithMany()
|
||||
.HasForeignKey("InstituionId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("FiscalOS.Core.Identity.User", null)
|
||||
.WithMany("Accounts")
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Institution");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FiscalOS.Core.Accounts.AccountMetadata", b =>
|
||||
{
|
||||
b.HasOne("FiscalOS.Core.Accounts.Account", null)
|
||||
.WithOne("Metadata")
|
||||
.HasForeignKey("FiscalOS.Core.Accounts.AccountMetadata", "AccountId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FiscalOS.Core.Accounts.Institution", b =>
|
||||
{
|
||||
b.HasOne("FiscalOS.Core.Identity.User", null)
|
||||
.WithMany("Institutions")
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FiscalOS.Core.Accounts.InstitutionMetadata", b =>
|
||||
{
|
||||
b.HasOne("FiscalOS.Core.Accounts.Institution", null)
|
||||
.WithOne("Metadata")
|
||||
.HasForeignKey("FiscalOS.Core.Accounts.InstitutionMetadata", "InstitutionId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
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.Accounts.Account", b =>
|
||||
{
|
||||
b.Navigation("Metadata")
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FiscalOS.Core.Accounts.Institution", b =>
|
||||
{
|
||||
b.Navigation("Metadata")
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FiscalOS.Core.Identity.User", b =>
|
||||
{
|
||||
b.Navigation("Accounts");
|
||||
|
||||
b.Navigation("Institutions");
|
||||
|
||||
b.Navigation("RefreshTokens");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace FiscalOS.Infra.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AccountAndMetadata : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Account",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
UserId = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
InstituionId = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
Name = table.Column<string>(type: "TEXT", nullable: false),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "TEXT", nullable: false),
|
||||
UpdatedAt = table.Column<DateTimeOffset>(type: "TEXT", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Account", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_Account_Institution_InstituionId",
|
||||
column: x => x.InstituionId,
|
||||
principalTable: "Institution",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_Account_Users_UserId",
|
||||
column: x => x.UserId,
|
||||
principalTable: "Users",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "AccountMetadata",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
Type = table.Column<string>(type: "TEXT", maxLength: 21, nullable: false),
|
||||
AccountId = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
PlaidId = table.Column<string>(type: "TEXT", nullable: true),
|
||||
PlaidName = table.Column<string>(type: "TEXT", nullable: true),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "TEXT", nullable: false),
|
||||
UpdatedAt = table.Column<DateTimeOffset>(type: "TEXT", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_AccountMetadata", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_AccountMetadata_Account_AccountId",
|
||||
column: x => x.AccountId,
|
||||
principalTable: "Account",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Account_InstituionId",
|
||||
table: "Account",
|
||||
column: "InstituionId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Account_UserId",
|
||||
table: "Account",
|
||||
column: "UserId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_AccountMetadata_AccountId",
|
||||
table: "AccountMetadata",
|
||||
column: "AccountId",
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "AccountMetadata");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Account");
|
||||
}
|
||||
}
|
||||
}
|
||||
+327
@@ -0,0 +1,327 @@
|
||||
// <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("20260213180002_FixAccountInstitutionColumnName")]
|
||||
partial class FixAccountInstitutionColumnName
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "10.0.2");
|
||||
|
||||
modelBuilder.Entity("FiscalOS.Core.Accounts.Account", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("InstitutionId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("InstitutionId");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("Account");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FiscalOS.Core.Accounts.AccountMetadata", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("AccountId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Type")
|
||||
.IsRequired()
|
||||
.HasMaxLength(21)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AccountId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("AccountMetadata");
|
||||
|
||||
b.HasDiscriminator<string>("Type").HasValue("AccountMetadata");
|
||||
|
||||
b.UseTphMappingStrategy();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FiscalOS.Core.Accounts.Institution", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("Institution");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FiscalOS.Core.Accounts.InstitutionMetadata", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("InstitutionId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Type")
|
||||
.IsRequired()
|
||||
.HasMaxLength(21)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("InstitutionId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("InstitutionMetadata");
|
||||
|
||||
b.HasDiscriminator<string>("Type").HasValue("InstitutionMetadata");
|
||||
|
||||
b.UseTphMappingStrategy();
|
||||
});
|
||||
|
||||
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.Infra.Accounts.Plaid.PlaidAccountMetadata", b =>
|
||||
{
|
||||
b.HasBaseType("FiscalOS.Core.Accounts.AccountMetadata");
|
||||
|
||||
b.Property<string>("PlaidId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PlaidName")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasDiscriminator().HasValue("Plaid");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FiscalOS.Infra.Accounts.Plaid.PlaidMetadata", b =>
|
||||
{
|
||||
b.HasBaseType("FiscalOS.Core.Accounts.InstitutionMetadata");
|
||||
|
||||
b.Property<string>("EncryptedAccessToken")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PlaidId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PlaidName")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasDiscriminator().HasValue("Plaid");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FiscalOS.Core.Accounts.Account", b =>
|
||||
{
|
||||
b.HasOne("FiscalOS.Core.Accounts.Institution", "Institution")
|
||||
.WithMany()
|
||||
.HasForeignKey("InstitutionId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("FiscalOS.Core.Identity.User", null)
|
||||
.WithMany("Accounts")
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Institution");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FiscalOS.Core.Accounts.AccountMetadata", b =>
|
||||
{
|
||||
b.HasOne("FiscalOS.Core.Accounts.Account", null)
|
||||
.WithOne("Metadata")
|
||||
.HasForeignKey("FiscalOS.Core.Accounts.AccountMetadata", "AccountId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FiscalOS.Core.Accounts.Institution", b =>
|
||||
{
|
||||
b.HasOne("FiscalOS.Core.Identity.User", null)
|
||||
.WithMany("Institutions")
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FiscalOS.Core.Accounts.InstitutionMetadata", b =>
|
||||
{
|
||||
b.HasOne("FiscalOS.Core.Accounts.Institution", null)
|
||||
.WithOne("Metadata")
|
||||
.HasForeignKey("FiscalOS.Core.Accounts.InstitutionMetadata", "InstitutionId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
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.Accounts.Account", b =>
|
||||
{
|
||||
b.Navigation("Metadata")
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FiscalOS.Core.Accounts.Institution", b =>
|
||||
{
|
||||
b.Navigation("Metadata")
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FiscalOS.Core.Identity.User", b =>
|
||||
{
|
||||
b.Navigation("Accounts");
|
||||
|
||||
b.Navigation("Institutions");
|
||||
|
||||
b.Navigation("RefreshTokens");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace FiscalOS.Infra.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class FixAccountInstitutionColumnName : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_Account_Institution_InstituionId",
|
||||
table: "Account");
|
||||
|
||||
migrationBuilder.RenameColumn(
|
||||
name: "InstituionId",
|
||||
table: "Account",
|
||||
newName: "InstitutionId");
|
||||
|
||||
migrationBuilder.RenameIndex(
|
||||
name: "IX_Account_InstituionId",
|
||||
table: "Account",
|
||||
newName: "IX_Account_InstitutionId");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_Account_Institution_InstitutionId",
|
||||
table: "Account",
|
||||
column: "InstitutionId",
|
||||
principalTable: "Institution",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_Account_Institution_InstitutionId",
|
||||
table: "Account");
|
||||
|
||||
migrationBuilder.RenameColumn(
|
||||
name: "InstitutionId",
|
||||
table: "Account",
|
||||
newName: "InstituionId");
|
||||
|
||||
migrationBuilder.RenameIndex(
|
||||
name: "IX_Account_InstitutionId",
|
||||
table: "Account",
|
||||
newName: "IX_Account_InstituionId");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_Account_Institution_InstituionId",
|
||||
table: "Account",
|
||||
column: "InstituionId",
|
||||
principalTable: "Institution",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+364
@@ -0,0 +1,364 @@
|
||||
// <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("20260214112234_AddBalancesWithConnectionToAccounts")]
|
||||
partial class AddBalancesWithConnectionToAccounts
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "10.0.2");
|
||||
|
||||
modelBuilder.Entity("FiscalOS.Core.Accounts.Account", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("InstitutionId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("InstitutionId");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("Account");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FiscalOS.Core.Accounts.AccountMetadata", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("AccountId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Type")
|
||||
.IsRequired()
|
||||
.HasMaxLength(21)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AccountId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("AccountMetadata");
|
||||
|
||||
b.HasDiscriminator<string>("Type").HasValue("AccountMetadata");
|
||||
|
||||
b.UseTphMappingStrategy();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FiscalOS.Core.Accounts.Balance", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("AccountId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<decimal>("Available")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<decimal>("Current")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AccountId");
|
||||
|
||||
b.ToTable("Balance");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FiscalOS.Core.Accounts.Institution", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("Institution");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FiscalOS.Core.Accounts.InstitutionMetadata", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("InstitutionId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Type")
|
||||
.IsRequired()
|
||||
.HasMaxLength(21)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("InstitutionId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("InstitutionMetadata");
|
||||
|
||||
b.HasDiscriminator<string>("Type").HasValue("InstitutionMetadata");
|
||||
|
||||
b.UseTphMappingStrategy();
|
||||
});
|
||||
|
||||
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.Infra.Accounts.Plaid.PlaidAccountMetadata", b =>
|
||||
{
|
||||
b.HasBaseType("FiscalOS.Core.Accounts.AccountMetadata");
|
||||
|
||||
b.Property<string>("PlaidId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PlaidName")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasDiscriminator().HasValue("Plaid");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FiscalOS.Infra.Accounts.Plaid.PlaidMetadata", b =>
|
||||
{
|
||||
b.HasBaseType("FiscalOS.Core.Accounts.InstitutionMetadata");
|
||||
|
||||
b.Property<string>("EncryptedAccessToken")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PlaidId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PlaidName")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasDiscriminator().HasValue("Plaid");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FiscalOS.Core.Accounts.Account", b =>
|
||||
{
|
||||
b.HasOne("FiscalOS.Core.Accounts.Institution", "Institution")
|
||||
.WithMany()
|
||||
.HasForeignKey("InstitutionId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("FiscalOS.Core.Identity.User", null)
|
||||
.WithMany("Accounts")
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Institution");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FiscalOS.Core.Accounts.AccountMetadata", b =>
|
||||
{
|
||||
b.HasOne("FiscalOS.Core.Accounts.Account", null)
|
||||
.WithOne("Metadata")
|
||||
.HasForeignKey("FiscalOS.Core.Accounts.AccountMetadata", "AccountId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FiscalOS.Core.Accounts.Balance", b =>
|
||||
{
|
||||
b.HasOne("FiscalOS.Core.Accounts.Account", null)
|
||||
.WithMany("Balances")
|
||||
.HasForeignKey("AccountId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FiscalOS.Core.Accounts.Institution", b =>
|
||||
{
|
||||
b.HasOne("FiscalOS.Core.Identity.User", null)
|
||||
.WithMany("Institutions")
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FiscalOS.Core.Accounts.InstitutionMetadata", b =>
|
||||
{
|
||||
b.HasOne("FiscalOS.Core.Accounts.Institution", null)
|
||||
.WithOne("Metadata")
|
||||
.HasForeignKey("FiscalOS.Core.Accounts.InstitutionMetadata", "InstitutionId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
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.Accounts.Account", b =>
|
||||
{
|
||||
b.Navigation("Balances");
|
||||
|
||||
b.Navigation("Metadata");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FiscalOS.Core.Accounts.Institution", b =>
|
||||
{
|
||||
b.Navigation("Metadata");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FiscalOS.Core.Identity.User", b =>
|
||||
{
|
||||
b.Navigation("Accounts");
|
||||
|
||||
b.Navigation("Institutions");
|
||||
|
||||
b.Navigation("RefreshTokens");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace FiscalOS.Infra.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddBalancesWithConnectionToAccounts : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Balance",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
AccountId = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
Current = table.Column<decimal>(type: "TEXT", nullable: false),
|
||||
Available = table.Column<decimal>(type: "TEXT", nullable: false),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "TEXT", nullable: false),
|
||||
UpdatedAt = table.Column<DateTimeOffset>(type: "TEXT", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Balance", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_Balance_Account_AccountId",
|
||||
column: x => x.AccountId,
|
||||
principalTable: "Account",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Balance_AccountId",
|
||||
table: "Balance",
|
||||
column: "AccountId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "Balance");
|
||||
}
|
||||
}
|
||||
}
|
||||
+368
@@ -0,0 +1,368 @@
|
||||
// <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("20260214122622_AddCurrencyCodeToBalanceModel")]
|
||||
partial class AddCurrencyCodeToBalanceModel
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "10.0.2");
|
||||
|
||||
modelBuilder.Entity("FiscalOS.Core.Accounts.Account", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("InstitutionId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("InstitutionId");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("Account");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FiscalOS.Core.Accounts.AccountMetadata", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("AccountId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Type")
|
||||
.IsRequired()
|
||||
.HasMaxLength(21)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AccountId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("AccountMetadata");
|
||||
|
||||
b.HasDiscriminator<string>("Type").HasValue("AccountMetadata");
|
||||
|
||||
b.UseTphMappingStrategy();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FiscalOS.Core.Accounts.Balance", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("AccountId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<decimal>("Available")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("CurrencyCode")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<decimal>("Current")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AccountId");
|
||||
|
||||
b.ToTable("Balance");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FiscalOS.Core.Accounts.Institution", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("Institution");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FiscalOS.Core.Accounts.InstitutionMetadata", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("InstitutionId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Type")
|
||||
.IsRequired()
|
||||
.HasMaxLength(21)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("InstitutionId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("InstitutionMetadata");
|
||||
|
||||
b.HasDiscriminator<string>("Type").HasValue("InstitutionMetadata");
|
||||
|
||||
b.UseTphMappingStrategy();
|
||||
});
|
||||
|
||||
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.Infra.Accounts.Plaid.PlaidAccountMetadata", b =>
|
||||
{
|
||||
b.HasBaseType("FiscalOS.Core.Accounts.AccountMetadata");
|
||||
|
||||
b.Property<string>("PlaidId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PlaidName")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasDiscriminator().HasValue("Plaid");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FiscalOS.Infra.Accounts.Plaid.PlaidMetadata", b =>
|
||||
{
|
||||
b.HasBaseType("FiscalOS.Core.Accounts.InstitutionMetadata");
|
||||
|
||||
b.Property<string>("EncryptedAccessToken")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PlaidId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PlaidName")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasDiscriminator().HasValue("Plaid");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FiscalOS.Core.Accounts.Account", b =>
|
||||
{
|
||||
b.HasOne("FiscalOS.Core.Accounts.Institution", "Institution")
|
||||
.WithMany()
|
||||
.HasForeignKey("InstitutionId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("FiscalOS.Core.Identity.User", null)
|
||||
.WithMany("Accounts")
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Institution");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FiscalOS.Core.Accounts.AccountMetadata", b =>
|
||||
{
|
||||
b.HasOne("FiscalOS.Core.Accounts.Account", null)
|
||||
.WithOne("Metadata")
|
||||
.HasForeignKey("FiscalOS.Core.Accounts.AccountMetadata", "AccountId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FiscalOS.Core.Accounts.Balance", b =>
|
||||
{
|
||||
b.HasOne("FiscalOS.Core.Accounts.Account", null)
|
||||
.WithMany("Balances")
|
||||
.HasForeignKey("AccountId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FiscalOS.Core.Accounts.Institution", b =>
|
||||
{
|
||||
b.HasOne("FiscalOS.Core.Identity.User", null)
|
||||
.WithMany("Institutions")
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FiscalOS.Core.Accounts.InstitutionMetadata", b =>
|
||||
{
|
||||
b.HasOne("FiscalOS.Core.Accounts.Institution", null)
|
||||
.WithOne("Metadata")
|
||||
.HasForeignKey("FiscalOS.Core.Accounts.InstitutionMetadata", "InstitutionId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
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.Accounts.Account", b =>
|
||||
{
|
||||
b.Navigation("Balances");
|
||||
|
||||
b.Navigation("Metadata");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FiscalOS.Core.Accounts.Institution", b =>
|
||||
{
|
||||
b.Navigation("Metadata");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FiscalOS.Core.Identity.User", b =>
|
||||
{
|
||||
b.Navigation("Accounts");
|
||||
|
||||
b.Navigation("Institutions");
|
||||
|
||||
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 AddCurrencyCodeToBalanceModel : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "CurrencyCode",
|
||||
table: "Balance",
|
||||
type: "TEXT",
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "CurrencyCode",
|
||||
table: "Balance");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,159 @@ namespace FiscalOS.Infra.Migrations
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "10.0.2");
|
||||
|
||||
modelBuilder.Entity("FiscalOS.Core.Accounts.Account", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("InstitutionId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("InstitutionId");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("Account");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FiscalOS.Core.Accounts.AccountMetadata", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("AccountId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Type")
|
||||
.IsRequired()
|
||||
.HasMaxLength(21)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AccountId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("AccountMetadata");
|
||||
|
||||
b.HasDiscriminator<string>("Type").HasValue("AccountMetadata");
|
||||
|
||||
b.UseTphMappingStrategy();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FiscalOS.Core.Accounts.Balance", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("AccountId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<decimal>("Available")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("CurrencyCode")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<decimal>("Current")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AccountId");
|
||||
|
||||
b.ToTable("Balance");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FiscalOS.Core.Accounts.Institution", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("Institution");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FiscalOS.Core.Accounts.InstitutionMetadata", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("InstitutionId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Type")
|
||||
.IsRequired()
|
||||
.HasMaxLength(21)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("InstitutionId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("InstitutionMetadata");
|
||||
|
||||
b.HasDiscriminator<string>("Type").HasValue("InstitutionMetadata");
|
||||
|
||||
b.UseTphMappingStrategy();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FiscalOS.Core.Identity.RefreshToken", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -61,6 +214,14 @@ namespace FiscalOS.Infra.Migrations
|
||||
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");
|
||||
@@ -80,6 +241,93 @@ namespace FiscalOS.Infra.Migrations
|
||||
b.ToTable("Users");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FiscalOS.Infra.Accounts.Plaid.PlaidAccountMetadata", b =>
|
||||
{
|
||||
b.HasBaseType("FiscalOS.Core.Accounts.AccountMetadata");
|
||||
|
||||
b.Property<string>("PlaidId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PlaidName")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasDiscriminator().HasValue("Plaid");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FiscalOS.Infra.Accounts.Plaid.PlaidMetadata", b =>
|
||||
{
|
||||
b.HasBaseType("FiscalOS.Core.Accounts.InstitutionMetadata");
|
||||
|
||||
b.Property<string>("EncryptedAccessToken")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PlaidId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PlaidName")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasDiscriminator().HasValue("Plaid");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FiscalOS.Core.Accounts.Account", b =>
|
||||
{
|
||||
b.HasOne("FiscalOS.Core.Accounts.Institution", "Institution")
|
||||
.WithMany()
|
||||
.HasForeignKey("InstitutionId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("FiscalOS.Core.Identity.User", null)
|
||||
.WithMany("Accounts")
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Institution");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FiscalOS.Core.Accounts.AccountMetadata", b =>
|
||||
{
|
||||
b.HasOne("FiscalOS.Core.Accounts.Account", null)
|
||||
.WithOne("Metadata")
|
||||
.HasForeignKey("FiscalOS.Core.Accounts.AccountMetadata", "AccountId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FiscalOS.Core.Accounts.Balance", b =>
|
||||
{
|
||||
b.HasOne("FiscalOS.Core.Accounts.Account", null)
|
||||
.WithMany("Balances")
|
||||
.HasForeignKey("AccountId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FiscalOS.Core.Accounts.Institution", b =>
|
||||
{
|
||||
b.HasOne("FiscalOS.Core.Identity.User", null)
|
||||
.WithMany("Institutions")
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FiscalOS.Core.Accounts.InstitutionMetadata", b =>
|
||||
{
|
||||
b.HasOne("FiscalOS.Core.Accounts.Institution", null)
|
||||
.WithOne("Metadata")
|
||||
.HasForeignKey("FiscalOS.Core.Accounts.InstitutionMetadata", "InstitutionId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FiscalOS.Core.Identity.RefreshToken", b =>
|
||||
{
|
||||
b.HasOne("FiscalOS.Core.Identity.User", "User")
|
||||
@@ -91,8 +339,24 @@ namespace FiscalOS.Infra.Migrations
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FiscalOS.Core.Accounts.Account", b =>
|
||||
{
|
||||
b.Navigation("Balances");
|
||||
|
||||
b.Navigation("Metadata");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FiscalOS.Core.Accounts.Institution", b =>
|
||||
{
|
||||
b.Navigation("Metadata");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FiscalOS.Core.Identity.User", b =>
|
||||
{
|
||||
b.Navigation("Accounts");
|
||||
|
||||
b.Navigation("Institutions");
|
||||
|
||||
b.Navigation("RefreshTokens");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
|
||||
@@ -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,16 +1,25 @@
|
||||
global using System.IdentityModel.Tokens.Jwt;
|
||||
global using System.IO.Abstractions;
|
||||
global using System.Security.Claims;
|
||||
global using System.Security.Cryptography;
|
||||
global using System.Text;
|
||||
global using System.Text.Json;
|
||||
|
||||
global using FiscalOS.Core.Accounts;
|
||||
global using FiscalOS.Core.Authentication;
|
||||
global using FiscalOS.Core.Data;
|
||||
global using FiscalOS.Core.Identity;
|
||||
global using FiscalOS.Core.Security;
|
||||
global using FiscalOS.Infra.Accounts.Plaid;
|
||||
global using FiscalOS.Infra.Authentication;
|
||||
global using FiscalOS.Infra.Authorization;
|
||||
global using FiscalOS.Infra.Data;
|
||||
global using FiscalOS.Infra.Security;
|
||||
|
||||
global using Going.Plaid;
|
||||
global using Going.Plaid.Entity;
|
||||
|
||||
global using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
global using Microsoft.AspNetCore.Authorization;
|
||||
global using Microsoft.AspNetCore.Authorization.Policy;
|
||||
global using Microsoft.AspNetCore.Http;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
[*.cs]
|
||||
|
||||
dotnet_diagnostic.CA1303.severity = none
|
||||
dotnet_diagnostic.CA1707.severity = none
|
||||
dotnet_diagnostic.CA1062.severity = none
|
||||
dotnet_diagnostic.CA2007.severity = none
|
||||
|
||||
@@ -21,12 +21,6 @@ internal sealed class HttpResponseMessageAssertions(
|
||||
HttpStatusCode expectedStatusCode
|
||||
)
|
||||
{
|
||||
_chain.ForCondition(Subject.Content.Headers.ContentType?.MediaType is "application/json")
|
||||
.FailWith(
|
||||
"Expected response to be application/json, but found {0}",
|
||||
Subject.Content.Headers.ContentType?.MediaType
|
||||
);
|
||||
|
||||
_chain.ForCondition(Subject.StatusCode == expectedStatusCode)
|
||||
.FailWith(
|
||||
"Expected response status code to be {0}, but found {1}",
|
||||
@@ -34,6 +28,13 @@ internal sealed class HttpResponseMessageAssertions(
|
||||
Subject.StatusCode
|
||||
);
|
||||
|
||||
_chain.ForCondition(Subject.Content.Headers.ContentType?.MediaType is "application/json")
|
||||
.FailWith(
|
||||
"Expected response to be application/json, but found {0}",
|
||||
Subject.Content.Headers.ContentType?.MediaType
|
||||
);
|
||||
|
||||
|
||||
var content = await Subject.Content.ReadFromJsonAsync<T>();
|
||||
|
||||
_chain.ForCondition(content is not null)
|
||||
@@ -78,12 +79,6 @@ internal sealed class HttpResponseMessageAssertions(
|
||||
|
||||
private async Task<T> ValidateAndDeserialize<T>(HttpStatusCode expectedStatusCode) where T : ProblemDetails
|
||||
{
|
||||
_chain.ForCondition(Subject.Content.Headers.ContentType?.MediaType is "application/problem+json")
|
||||
.FailWith(
|
||||
"Expected response to be application/problem+json, but found {0}",
|
||||
Subject.Content.Headers.ContentType?.MediaType
|
||||
);
|
||||
|
||||
_chain.ForCondition(Subject.StatusCode == expectedStatusCode)
|
||||
.FailWith(
|
||||
"Expected response status code to be {0}, but found {1}",
|
||||
@@ -91,6 +86,12 @@ internal sealed class HttpResponseMessageAssertions(
|
||||
Subject.StatusCode
|
||||
);
|
||||
|
||||
_chain.ForCondition(Subject.Content.Headers.ContentType?.MediaType is "application/problem+json")
|
||||
.FailWith(
|
||||
"Expected response to be application/problem+json, but found {0}",
|
||||
Subject.Content.Headers.ContentType?.MediaType
|
||||
);
|
||||
|
||||
var problem = await Subject.Content.ReadFromJsonAsync<T>();
|
||||
|
||||
_chain.ForCondition(problem is not null)
|
||||
|
||||
@@ -13,9 +13,9 @@
|
||||
<PackageReference Include="xunit.v3.mtp-v2" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
|
||||
<Content Include="appsettings.Test.json" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
namespace FiscalOS.API.Tests.Infra;
|
||||
|
||||
internal sealed class HttpRequestBuilder
|
||||
{
|
||||
private HttpMethod _method = HttpMethod.Get;
|
||||
private Uri? _uri;
|
||||
private object? _body;
|
||||
private string? _bearerToken;
|
||||
private readonly Dictionary<string, string> _cookies = [];
|
||||
private readonly Dictionary<string, string> _headers = [];
|
||||
|
||||
private HttpRequestBuilder()
|
||||
{
|
||||
}
|
||||
|
||||
public static HttpRequestBuilder New() => new();
|
||||
|
||||
public HttpRequestBuilder WithMethod(HttpMethod method)
|
||||
{
|
||||
_method = method;
|
||||
return this;
|
||||
}
|
||||
|
||||
public HttpRequestBuilder WithUri(Uri uri)
|
||||
{
|
||||
_uri = uri;
|
||||
return this;
|
||||
}
|
||||
|
||||
public HttpRequestBuilder WithBody<T>(T body)
|
||||
{
|
||||
_body = body;
|
||||
return this;
|
||||
}
|
||||
|
||||
public HttpRequestBuilder WithBearerToken(string token)
|
||||
{
|
||||
_bearerToken = token;
|
||||
return this;
|
||||
}
|
||||
|
||||
public HttpRequestBuilder WithUserId(Guid userId)
|
||||
{
|
||||
_bearerToken = JwtTokenBuilder.New()
|
||||
.WithClaim(JwtRegisteredClaimNames.Sub, userId.ToString())
|
||||
.Build();
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
public HttpRequestBuilder WithCookie(string name, string value)
|
||||
{
|
||||
_cookies[name] = value;
|
||||
return this;
|
||||
}
|
||||
|
||||
public HttpRequestBuilder WithRefreshCookie(string token)
|
||||
{
|
||||
return WithCookie("fiscalos_refresh_cookie", token);
|
||||
}
|
||||
|
||||
public HttpRequestBuilder WithHeader(string name, string value)
|
||||
{
|
||||
_headers[name] = value;
|
||||
return this;
|
||||
}
|
||||
|
||||
public HttpRequestBuilder Post(Uri uri)
|
||||
{
|
||||
_method = HttpMethod.Post;
|
||||
_uri = uri;
|
||||
return this;
|
||||
}
|
||||
|
||||
public HttpRequestBuilder Get(Uri uri)
|
||||
{
|
||||
_method = HttpMethod.Get;
|
||||
_uri = uri;
|
||||
return this;
|
||||
}
|
||||
|
||||
public HttpRequestBuilder Put(Uri uri)
|
||||
{
|
||||
_method = HttpMethod.Put;
|
||||
_uri = uri;
|
||||
return this;
|
||||
}
|
||||
|
||||
public HttpRequestBuilder Delete(Uri uri)
|
||||
{
|
||||
_method = HttpMethod.Delete;
|
||||
_uri = uri;
|
||||
return this;
|
||||
}
|
||||
|
||||
public HttpRequestMessage Build()
|
||||
{
|
||||
if (_uri is null)
|
||||
{
|
||||
throw new InvalidOperationException("URI must be set before building the request.");
|
||||
}
|
||||
|
||||
var request = new HttpRequestMessage(_method, _uri);
|
||||
|
||||
if (_body is not null)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(_body);
|
||||
request.Content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
}
|
||||
|
||||
if (_bearerToken is not null)
|
||||
{
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _bearerToken);
|
||||
}
|
||||
|
||||
foreach (var (key, value) in _cookies)
|
||||
{
|
||||
request.Headers.Add("Cookie", $"{key}={value}");
|
||||
}
|
||||
|
||||
foreach (var (key, value) in _headers)
|
||||
{
|
||||
request.Headers.Add(key, value);
|
||||
}
|
||||
|
||||
return request;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
namespace FiscalOS.API.Tests.Infra;
|
||||
|
||||
public interface ISerializableDictionary<TKey, TValue> : IDictionary<TKey, TValue>, IXunitSerializable
|
||||
{
|
||||
}
|
||||
|
||||
public class SerializableDictionary<TKey, TValue>
|
||||
: Dictionary<TKey, TValue>, ISerializableDictionary<TKey, TValue> where TKey : notnull
|
||||
{
|
||||
public SerializableDictionary() { }
|
||||
|
||||
public SerializableDictionary(IDictionary<TKey, TValue> dictionary) : base(dictionary)
|
||||
{
|
||||
}
|
||||
|
||||
public void Deserialize(IXunitSerializationInfo info)
|
||||
{
|
||||
Clear();
|
||||
|
||||
var keysJson = info.GetValue<string>("_DictKeys") ?? "[]";
|
||||
var valuesJson = info.GetValue<string>("_DictValues") ?? "[]";
|
||||
var keys = JsonSerializer.Deserialize<List<TKey>>(keysJson);
|
||||
var values = JsonSerializer.Deserialize<List<TValue>>(valuesJson);
|
||||
|
||||
if (keys is null || values is null || keys.Count != values.Count)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
for (var i = 0; i < keys.Count; i++)
|
||||
{
|
||||
Add(keys[i], values[i]);
|
||||
}
|
||||
}
|
||||
|
||||
public void Serialize(IXunitSerializationInfo info)
|
||||
{
|
||||
info.AddValue("_DictKeys", JsonSerializer.Serialize(Keys));
|
||||
info.AddValue("_DictValues", JsonSerializer.Serialize(Values));
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,12 @@ public class TestApi : WebApplicationFactory<Program>
|
||||
|
||||
builder.ConfigureLogging(static c => c.ClearProviders());
|
||||
|
||||
builder.ConfigureAppConfiguration(static c =>
|
||||
{
|
||||
var testConfigPath = Path.Combine(AppContext.BaseDirectory, "appsettings.Test.json");
|
||||
c.AddJsonFile(testConfigPath, optional: false);
|
||||
});
|
||||
|
||||
builder.ConfigureTestServices(static c =>
|
||||
{
|
||||
c.AddSingleton(Options.Create(new AppDbContextOptions()
|
||||
@@ -16,6 +22,8 @@ public class TestApi : WebApplicationFactory<Program>
|
||||
}));
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
using Account = FiscalOS.Core.Accounts.Account;
|
||||
using Institution = FiscalOS.Core.Accounts.Institution;
|
||||
|
||||
namespace FiscalOS.API.Tests.Integration.Accounts;
|
||||
|
||||
public class AddTests(TestApi testApi) : IntegrationTest(testApi)
|
||||
{
|
||||
private static readonly Uri AddUri = new("/accounts", UriKind.Relative);
|
||||
|
||||
[Fact]
|
||||
public async Task Add_WhenCalledWhenNotLoggedIn_ItShouldReturn401WithProblemDetails()
|
||||
{
|
||||
var response = await Client.PostAsync(AddUri, null, TestContext.Current.CancellationToken);
|
||||
|
||||
await response.Should().BeProblemDetails(HttpStatusCode.Unauthorized);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Add_WhenCalledWithoutRequiredInformation_ItShouldReturn400WithProblemDetails()
|
||||
{
|
||||
using var request = HttpRequestBuilder.New()
|
||||
.Post(AddUri)
|
||||
.WithUserId(Guid.NewGuid())
|
||||
.WithBody(new { })
|
||||
.Build();
|
||||
|
||||
var response = await Client.SendAsync(request, TestContext.Current.CancellationToken);
|
||||
|
||||
await response.Should().BeValidationProblemDetails(new Dictionary<string, string[]>()
|
||||
{
|
||||
["PlaidInstitutionId"] = ["The PlaidInstitutionId field is required."],
|
||||
["PlaidAccountId"] = ["The PlaidAccountId field is required."],
|
||||
["PlaidAccountName"] = ["The PlaidAccountName field is required."],
|
||||
["AccountCurrencyCode"] = ["The AccountCurrencyCode field is required."],
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Add_WhenCalledWithoutInstitutionId_ItShouldReturn400WithProblemDetails()
|
||||
{
|
||||
using var request = HttpRequestBuilder.New()
|
||||
.Post(AddUri)
|
||||
.WithUserId(Guid.NewGuid())
|
||||
.WithBody(new
|
||||
{
|
||||
plaidAccountId = "accountId",
|
||||
plaidAccountName = "Some Account",
|
||||
accountCurrencyCode = "USD",
|
||||
})
|
||||
.Build();
|
||||
|
||||
var response = await Client.SendAsync(request, TestContext.Current.CancellationToken);
|
||||
|
||||
await response.Should().BeValidationProblemDetails(new Dictionary<string, string[]>()
|
||||
{
|
||||
["PlaidInstitutionId"] = ["The PlaidInstitutionId field is required."],
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Add_WhenCalledWithoutAccountId_ItShouldReturn400WithProblemDetails()
|
||||
{
|
||||
using var request = HttpRequestBuilder.New()
|
||||
.Post(AddUri)
|
||||
.WithUserId(Guid.NewGuid())
|
||||
.WithBody(new
|
||||
{
|
||||
plaidInstitutionId = "institutionId",
|
||||
plaidAccountName = "Some Account",
|
||||
accountCurrencyCode = "USD",
|
||||
})
|
||||
.Build();
|
||||
|
||||
var response = await Client.SendAsync(request, TestContext.Current.CancellationToken);
|
||||
|
||||
await response.Should().BeValidationProblemDetails(new Dictionary<string, string[]>()
|
||||
{
|
||||
["PlaidAccountId"] = ["The PlaidAccountId field is required."],
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Add_WhenCalledWithoutAccountName_ItShouldReturn400WithProblemDetails()
|
||||
{
|
||||
using var request = HttpRequestBuilder.New()
|
||||
.Post(AddUri)
|
||||
.WithUserId(Guid.NewGuid())
|
||||
.WithBody(new
|
||||
{
|
||||
plaidInstitutionId = "institutionId",
|
||||
plaidAccountId = "accountId",
|
||||
accountCurrencyCode = "USD",
|
||||
})
|
||||
.Build();
|
||||
|
||||
var response = await Client.SendAsync(request, TestContext.Current.CancellationToken);
|
||||
|
||||
await response.Should().BeValidationProblemDetails(new Dictionary<string, string[]>()
|
||||
{
|
||||
["PlaidAccountName"] = ["The PlaidAccountName field is required."],
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Add_WhenCalledWithNonExistentUser_ItShouldReturn401WithProblemDetails()
|
||||
{
|
||||
using var request = HttpRequestBuilder.New()
|
||||
.Post(AddUri)
|
||||
.WithUserId(Guid.NewGuid())
|
||||
.WithBody(new
|
||||
{
|
||||
plaidInstitutionId = "id",
|
||||
plaidAccountId = "id",
|
||||
plaidAccountName = "Some Account",
|
||||
accountCurrencyCode = "USD",
|
||||
})
|
||||
.Build();
|
||||
|
||||
var response = await Client.SendAsync(request, TestContext.Current.CancellationToken);
|
||||
|
||||
await response.Should().BeProblemDetails(HttpStatusCode.Unauthorized);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Add_WhenCalledWithPlaidInstitutionIdThatHasNotBeenAdded_ItShouldReturn400WithProblemDetails()
|
||||
{
|
||||
var user = await ExecuteAsync(static async (context, ct, sp) =>
|
||||
{
|
||||
var passwordHasher = sp.GetRequiredService<IPasswordHasher>();
|
||||
var encryptor = sp.GetRequiredService<IEncryptor>();
|
||||
|
||||
var userEncryptionKey = await encryptor.GenerateEncryptedKeyAsync(ct);
|
||||
var user = User.From("User1", passwordHasher.Hash("@Password1"), userEncryptionKey);
|
||||
|
||||
|
||||
await context.AddAsync(user, ct);
|
||||
await context.SaveChangesAsync(ct);
|
||||
return user;
|
||||
}, TestContext.Current.CancellationToken);
|
||||
|
||||
using var request = HttpRequestBuilder.New()
|
||||
.Post(AddUri)
|
||||
.WithUserId(user.Id)
|
||||
.WithBody(new
|
||||
{
|
||||
plaidInstitutionId = "id",
|
||||
plaidAccountId = "id",
|
||||
plaidAccountName = "Some Account",
|
||||
accountCurrencyCode = "USD",
|
||||
})
|
||||
.Build();
|
||||
|
||||
var response = await Client.SendAsync(request, TestContext.Current.CancellationToken);
|
||||
|
||||
await response.Should().BeValidationProblemDetails(new Dictionary<string, string[]>()
|
||||
{
|
||||
["PlaidInstitutionId"] = ["The PlaidInstitutionId field is invalid. No institution connected with the given PlaidInstitutionId was found for the user."],
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Add_WhenCalledWithPlaidAccountIdThatHasAlreadyBeenAdded_ItShouldReturn409WithProblemDetails()
|
||||
{
|
||||
var (user, institution, account) = await ExecuteAsync(static async (context, ct, sp) =>
|
||||
{
|
||||
var passwordHasher = sp.GetRequiredService<IPasswordHasher>();
|
||||
var encryptor = sp.GetRequiredService<IEncryptor>();
|
||||
|
||||
var userEncryptionKey = await encryptor.GenerateEncryptedKeyAsync(ct);
|
||||
var user = User.From("User1", passwordHasher.Hash("@Password1"), userEncryptionKey);
|
||||
|
||||
await context.AddAsync(user, ct);
|
||||
|
||||
var encryptedAccessToken = await encryptor.EncryptAsyncFor(user, "accessToken", ct);
|
||||
var plaidMetadata = PlaidMetadata.From("alreadyExists", "Some Bank", encryptedAccessToken);
|
||||
var institution = Institution.From("Some Bank", plaidMetadata);
|
||||
|
||||
await context.AddAsync(institution, ct);
|
||||
|
||||
var plaidAccountMetadata = PlaidAccountMetadata.From("accountId", "Some Account");
|
||||
var account = Account.From(institution.Id, "Some Account", plaidAccountMetadata);
|
||||
|
||||
user.AddInstitution(institution);
|
||||
user.AddAccount(account);
|
||||
|
||||
await context.SaveChangesAsync(ct);
|
||||
return (user, institution, account);
|
||||
}, TestContext.Current.CancellationToken);
|
||||
|
||||
using var request = HttpRequestBuilder.New()
|
||||
.Post(AddUri)
|
||||
.WithUserId(user.Id)
|
||||
.WithBody(new
|
||||
{
|
||||
plaidInstitutionId = ((PlaidMetadata)institution.Metadata!).PlaidId,
|
||||
plaidAccountId = ((PlaidAccountMetadata)account.Metadata!).PlaidId,
|
||||
plaidAccountName = ((PlaidAccountMetadata)account.Metadata).PlaidName,
|
||||
accountCurrencyCode = "USD",
|
||||
})
|
||||
.Build();
|
||||
|
||||
var response = await Client.SendAsync(request, TestContext.Current.CancellationToken);
|
||||
|
||||
await response.Should().BeProblemDetails(HttpStatusCode.Conflict);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Add_WhenCalledWithNewAccount_ItShouldReturn200()
|
||||
{
|
||||
var (user, institution) = await ExecuteAsync(async (context, ct, sp) =>
|
||||
{
|
||||
var passwordHasher = sp.GetRequiredService<IPasswordHasher>();
|
||||
var encryptor = sp.GetRequiredService<IEncryptor>();
|
||||
var plaidClient = sp.GetRequiredService<PlaidClient>();
|
||||
|
||||
var userEncryptionKey = await encryptor.GenerateEncryptedKeyAsync(ct);
|
||||
var user = User.From("User1", passwordHasher.Hash("@Password1"), userEncryptionKey);
|
||||
|
||||
var encryptedAccessToken = await encryptor.EncryptAsyncFor(user, "accessToken", ct);
|
||||
var plaidMetadata = PlaidMetadata.From("id", "Some Bank", encryptedAccessToken);
|
||||
var institution = Institution.From("Some Bank", plaidMetadata);
|
||||
|
||||
user.AddInstitution(institution);
|
||||
|
||||
await context.AddAsync(user, ct);
|
||||
await context.SaveChangesAsync(ct);
|
||||
return (user, institution);
|
||||
}, TestContext.Current.CancellationToken);
|
||||
|
||||
var newAccountId = "newAccountId";
|
||||
var newAccountName = "New Account";
|
||||
var expectedBalance = 100;
|
||||
var expectedCurrencyCode = "USD";
|
||||
|
||||
using var request = HttpRequestBuilder.New()
|
||||
.Post(AddUri)
|
||||
.WithUserId(user.Id)
|
||||
.WithBody(new
|
||||
{
|
||||
plaidInstitutionId = ((PlaidMetadata)institution.Metadata!).PlaidId,
|
||||
plaidAccountId = newAccountId,
|
||||
plaidAccountName = newAccountName,
|
||||
accountCurrentBalance = expectedBalance,
|
||||
accountAvailableBalance = expectedBalance,
|
||||
accountCurrencyCode = expectedCurrencyCode,
|
||||
})
|
||||
.Build();
|
||||
|
||||
var response = await Client.SendAsync(request, TestContext.Current.CancellationToken);
|
||||
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
|
||||
var updatedUser = await ExecuteAsync(
|
||||
async (context, ct) => await context.Set<User>()
|
||||
.Include(u => u.Accounts)
|
||||
.ThenInclude(a => a.Metadata)
|
||||
.Include(u => u.Accounts)
|
||||
.ThenInclude(a => a.Balances)
|
||||
.AsSplitQuery()
|
||||
.FirstAsync(u => u.Id == user.Id, ct),
|
||||
TestContext.Current.CancellationToken
|
||||
);
|
||||
|
||||
updatedUser.Accounts.Should().ContainSingle(
|
||||
a => a.Name == newAccountName &&
|
||||
a.InstitutionId == institution.Id &&
|
||||
a.Metadata is PlaidAccountMetadata &&
|
||||
((PlaidAccountMetadata)a.Metadata).PlaidId == newAccountId &&
|
||||
((PlaidAccountMetadata)a.Metadata).PlaidName == newAccountName
|
||||
);
|
||||
|
||||
updatedUser.Accounts.First()
|
||||
.Balances.Should().ContainSingle(
|
||||
b => b.AccountId == updatedUser.Accounts.First().Id &&
|
||||
b.Current == expectedBalance &&
|
||||
b.Available == expectedBalance &&
|
||||
b.CurrencyCode == expectedCurrencyCode
|
||||
);
|
||||
}
|
||||
}
|
||||
+57
-41
@@ -1,20 +1,23 @@
|
||||
namespace FiscalOS.API.Tests.Integration;
|
||||
namespace FiscalOS.API.Tests.Integration.Auth;
|
||||
|
||||
public class LoginTests(TestApi testApi) : IntegrationTest(testApi)
|
||||
{
|
||||
private static readonly Uri LoginUri = new("/login", UriKind.Relative);
|
||||
private static readonly Uri LoginUri = new("/auth/login", UriKind.Relative);
|
||||
|
||||
[Theory]
|
||||
[ClassData<LoginValidationTestCases>]
|
||||
public async Task Login_WhenUserSubmitsInvalidRequest_ItShouldReturn400WithProblemDetails(LoginValidationTestCase tc)
|
||||
{
|
||||
var req = new
|
||||
{
|
||||
username = tc.Username,
|
||||
password = tc.Password,
|
||||
};
|
||||
using var request = HttpRequestBuilder.New()
|
||||
.Post(LoginUri)
|
||||
.WithBody(new
|
||||
{
|
||||
username = tc.Username,
|
||||
password = tc.Password,
|
||||
})
|
||||
.Build();
|
||||
|
||||
var res = await Client.PostAsJsonAsync(LoginUri, req, TestContext.Current.CancellationToken);
|
||||
var res = await Client.SendAsync(request, TestContext.Current.CancellationToken);
|
||||
|
||||
await res.Should().BeValidationProblemDetails(tc.ExpectedErrors);
|
||||
}
|
||||
@@ -22,13 +25,16 @@ public class LoginTests(TestApi testApi) : IntegrationTest(testApi)
|
||||
[Fact]
|
||||
public async Task Login_WhenUserDoesNotExist_ItShouldReturn401WithProblemDetails()
|
||||
{
|
||||
var req = new
|
||||
{
|
||||
username = "Test",
|
||||
password = "@Password2",
|
||||
};
|
||||
using var request = HttpRequestBuilder.New()
|
||||
.Post(LoginUri)
|
||||
.WithBody(new
|
||||
{
|
||||
username = "Test",
|
||||
password = "@Password2",
|
||||
})
|
||||
.Build();
|
||||
|
||||
var res = await Client.PostAsJsonAsync(LoginUri, req, TestContext.Current.CancellationToken);
|
||||
var res = await Client.SendAsync(request, TestContext.Current.CancellationToken);
|
||||
|
||||
await res.Should().BeProblemDetails(HttpStatusCode.Unauthorized);
|
||||
}
|
||||
@@ -36,22 +42,27 @@ public class LoginTests(TestApi testApi) : IntegrationTest(testApi)
|
||||
[Fact]
|
||||
public async Task Login_WhenUserExistsButPasswordIsIncorrect_ItShouldReturn401WithProblemDetails()
|
||||
{
|
||||
await ExecuteDbContextAsync(static async (context, sp) =>
|
||||
await ExecuteAsync(static async (context, ct, sp) =>
|
||||
{
|
||||
var passwordHasher = sp.GetRequiredService<IPasswordHasher>();
|
||||
var encryptor = sp.GetRequiredService<IEncryptor>();
|
||||
|
||||
context.Add(User.From("Stevan", passwordHasher.Hash("@Password1")));
|
||||
var userEncryptionKey = await encryptor.GenerateEncryptedKeyAsync(ct);
|
||||
context.Add(User.From("Stevan", passwordHasher.Hash("@Password1"), userEncryptionKey));
|
||||
|
||||
await context.SaveChangesAsync(TestContext.Current.CancellationToken);
|
||||
});
|
||||
await context.SaveChangesAsync(ct);
|
||||
}, TestContext.Current.CancellationToken);
|
||||
|
||||
var req = new
|
||||
{
|
||||
username = "Stevan",
|
||||
password = "@Password2",
|
||||
};
|
||||
using var request = HttpRequestBuilder.New()
|
||||
.Post(LoginUri)
|
||||
.WithBody(new
|
||||
{
|
||||
username = "Stevan",
|
||||
password = "@Password2",
|
||||
})
|
||||
.Build();
|
||||
|
||||
var res = await Client.PostAsJsonAsync(LoginUri, req, TestContext.Current.CancellationToken);
|
||||
var res = await Client.SendAsync(request, TestContext.Current.CancellationToken);
|
||||
|
||||
await res.Should().BeProblemDetails(HttpStatusCode.Unauthorized);
|
||||
}
|
||||
@@ -59,25 +70,30 @@ public class LoginTests(TestApi testApi) : IntegrationTest(testApi)
|
||||
[Fact]
|
||||
public async Task Login_WhenUserExistsAndPasswordIsCorrect_ItShouldReturn200WithJwtTokenAndSetRefreshCookie()
|
||||
{
|
||||
await ExecuteDbContextAsync(static async (context, sp) =>
|
||||
await ExecuteAsync(static async (context, ct, sp) =>
|
||||
{
|
||||
var passwordHasher = sp.GetRequiredService<IPasswordHasher>();
|
||||
var encryptor = sp.GetRequiredService<IEncryptor>();
|
||||
|
||||
context.Add(User.From("Stevan", passwordHasher.Hash("@Password1")));
|
||||
var userEncryptionKey = await encryptor.GenerateEncryptedKeyAsync(ct);
|
||||
context.Add(User.From("Stevan", passwordHasher.Hash("@Password1"), userEncryptionKey));
|
||||
|
||||
await context.SaveChangesAsync(TestContext.Current.CancellationToken);
|
||||
});
|
||||
await context.SaveChangesAsync(ct);
|
||||
}, TestContext.Current.CancellationToken);
|
||||
|
||||
var req = new
|
||||
{
|
||||
username = "Stevan",
|
||||
password = "@Password1",
|
||||
};
|
||||
using var request = HttpRequestBuilder.New()
|
||||
.Post(LoginUri)
|
||||
.WithBody(new
|
||||
{
|
||||
username = "Stevan",
|
||||
password = "@Password1",
|
||||
})
|
||||
.Build();
|
||||
|
||||
var res = await Client.PostAsJsonAsync(LoginUri, req, TestContext.Current.CancellationToken);
|
||||
var res = await Client.SendAsync(request, TestContext.Current.CancellationToken);
|
||||
|
||||
res.Should().HaveSetCookieHeader("fiscalos_refresh_cookie");
|
||||
await res.Should().BeJsonContentOfType<Login.Response>(HttpStatusCode.OK);
|
||||
await res.Should().BeJsonContentOfType<API.Auth.Login.Response>(HttpStatusCode.OK);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,7 +106,7 @@ public class LoginValidationTestCases : TheoryData<LoginValidationTestCase>
|
||||
"No username or password",
|
||||
string.Empty,
|
||||
string.Empty,
|
||||
new Dictionary<string, string[]>()
|
||||
new SerializableDictionary<string, string[]>()
|
||||
{
|
||||
["Username"] = ["The Username field is required."],
|
||||
["Password"] = ["The Password field is required."]
|
||||
@@ -101,7 +117,7 @@ public class LoginValidationTestCases : TheoryData<LoginValidationTestCase>
|
||||
"No username",
|
||||
string.Empty,
|
||||
"@Password1",
|
||||
new Dictionary<string, string[]>()
|
||||
new SerializableDictionary<string, string[]>()
|
||||
{
|
||||
["Username"] = ["The Username field is required."],
|
||||
}
|
||||
@@ -111,7 +127,7 @@ public class LoginValidationTestCases : TheoryData<LoginValidationTestCase>
|
||||
"No password",
|
||||
"Stevan",
|
||||
string.Empty,
|
||||
new Dictionary<string, string[]>()
|
||||
new SerializableDictionary<string, string[]>()
|
||||
{
|
||||
["Password"] = ["The Password field is required."]
|
||||
}
|
||||
@@ -124,7 +140,7 @@ public record LoginValidationTestCase : IXunitSerializable
|
||||
public string Name { get; private set; } = string.Empty;
|
||||
public string Username { get; private set; } = string.Empty;
|
||||
public string Password { get; private set; } = string.Empty;
|
||||
public Dictionary<string, string[]> ExpectedErrors { get; private set; } = [];
|
||||
public SerializableDictionary<string, string[]> ExpectedErrors { get; private set; } = [];
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
@@ -139,7 +155,7 @@ public record LoginValidationTestCase : IXunitSerializable
|
||||
string name,
|
||||
string username,
|
||||
string password,
|
||||
Dictionary<string, string[]> expectedErrors
|
||||
SerializableDictionary<string, string[]> expectedErrors
|
||||
)
|
||||
{
|
||||
Name = name;
|
||||
@@ -153,7 +169,7 @@ public record LoginValidationTestCase : IXunitSerializable
|
||||
Name = info.GetValue<string>(nameof(Name)) ?? string.Empty;
|
||||
Username = info.GetValue<string>(nameof(Username)) ?? string.Empty;
|
||||
Password = info.GetValue<string>(nameof(Password)) ?? string.Empty;
|
||||
ExpectedErrors = info.GetValue<Dictionary<string, string[]>>(nameof(ExpectedErrors)) ?? [];
|
||||
ExpectedErrors = info.GetValue<SerializableDictionary<string, string[]>>(nameof(ExpectedErrors)) ?? [];
|
||||
}
|
||||
|
||||
public void Serialize(IXunitSerializationInfo info)
|
||||
+64
-60
@@ -1,8 +1,8 @@
|
||||
namespace FiscalOS.API.Tests.Integration;
|
||||
namespace FiscalOS.API.Tests.Integration.Auth;
|
||||
|
||||
public class RefreshTests(TestApi testApi) : IntegrationTest(testApi)
|
||||
{
|
||||
private static readonly Uri RefreshUri = new("/refresh", UriKind.Relative);
|
||||
private static readonly Uri RefreshUri = new("/auth/refresh", UriKind.Relative);
|
||||
|
||||
[Fact]
|
||||
public async Task Refresh_WhenCalledWithNoAccessToken_ItShouldReturn401WithProblemDetails()
|
||||
@@ -15,14 +15,12 @@ public class RefreshTests(TestApi testApi) : IntegrationTest(testApi)
|
||||
[Fact]
|
||||
public async Task Refresh_WhenCalledWithNonExistentRefreshToken_ItShouldReturn400WithProblemDetails()
|
||||
{
|
||||
var jwt = JwtTokenBuilder.New()
|
||||
.WithClaim(JwtRegisteredClaimNames.Sub, Guid.NewGuid().ToString())
|
||||
using var request = HttpRequestBuilder.New()
|
||||
.Post(RefreshUri)
|
||||
.WithUserId(Guid.NewGuid())
|
||||
.WithRefreshCookie("nonexistenttoken")
|
||||
.Build();
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, RefreshUri);
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", jwt);
|
||||
request.Headers.Add("Cookie", "fiscalos_refresh_cookie=nonexistenttoken");
|
||||
|
||||
var response = await Client.SendAsync(request, TestContext.Current.CancellationToken);
|
||||
|
||||
await response.Should().BeProblemDetails(HttpStatusCode.BadRequest);
|
||||
@@ -31,13 +29,16 @@ public class RefreshTests(TestApi testApi) : IntegrationTest(testApi)
|
||||
[Fact]
|
||||
public async Task Refresh_WhenCalledWithTokenBelongingToDifferentUser_ItShouldReturn403WithProblemDetails()
|
||||
{
|
||||
var (users, refreshToken) = await ExecuteDbContextAsync(static async (context, sp) =>
|
||||
var (users, refreshToken) = await ExecuteAsync(static async (context, ct, sp) =>
|
||||
{
|
||||
var passwordHasher = sp.GetRequiredService<IPasswordHasher>();
|
||||
var tokenGenerator = sp.GetRequiredService<ITokenGenerator>();
|
||||
var encryptor = sp.GetRequiredService<IEncryptor>();
|
||||
|
||||
var user1 = User.From("User1", passwordHasher.Hash("@Password1"));
|
||||
var user2 = User.From("User2", passwordHasher.Hash("@Password2"));
|
||||
var user1EncryptionKey = await encryptor.GenerateEncryptedKeyAsync(ct);
|
||||
var user2EncryptionKey = await encryptor.GenerateEncryptedKeyAsync(ct);
|
||||
var user1 = User.From("User1", passwordHasher.Hash("@Password1"), user1EncryptionKey);
|
||||
var user2 = User.From("User2", passwordHasher.Hash("@Password2"), user2EncryptionKey);
|
||||
var refreshToken1 = tokenGenerator.GenerateRefreshToken(user2);
|
||||
var refreshToken2 = tokenGenerator.GenerateRefreshToken(user2);
|
||||
user2.AddRefreshToken(refreshToken1);
|
||||
@@ -46,27 +47,26 @@ public class RefreshTests(TestApi testApi) : IntegrationTest(testApi)
|
||||
context.Add(user1);
|
||||
context.Add(user2);
|
||||
|
||||
await context.SaveChangesAsync(TestContext.Current.CancellationToken);
|
||||
await context.SaveChangesAsync(ct);
|
||||
return (new User[] { user1, user2 }, refreshToken1);
|
||||
});
|
||||
}, TestContext.Current.CancellationToken);
|
||||
|
||||
var jwt = JwtTokenBuilder.New()
|
||||
.WithClaim(JwtRegisteredClaimNames.Sub, users[0].Id.ToString())
|
||||
using var request = HttpRequestBuilder.New()
|
||||
.Post(RefreshUri)
|
||||
.WithUserId(users[0].Id)
|
||||
.WithRefreshCookie(refreshToken.Token)
|
||||
.Build();
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, RefreshUri);
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", jwt);
|
||||
request.Headers.Add("Cookie", $"fiscalos_refresh_cookie={refreshToken.Token}");
|
||||
|
||||
var response = await Client.SendAsync(request, TestContext.Current.CancellationToken);
|
||||
|
||||
await response.Should().BeProblemDetails(HttpStatusCode.Forbidden);
|
||||
|
||||
var unrevokedTokensCountForUser2 = await ExecuteDbContextAsync(
|
||||
async context => await context.Set<RefreshToken>()
|
||||
var unrevokedTokensCountForUser2 = await ExecuteAsync(
|
||||
async (context, ct) => await context.Set<RefreshToken>()
|
||||
.Include(t => t.User)
|
||||
.Where(t => t.UserId == users[1].Id && t.Revoked == false)
|
||||
.CountAsync()
|
||||
.CountAsync(ct),
|
||||
TestContext.Current.CancellationToken
|
||||
);
|
||||
|
||||
unrevokedTokensCountForUser2.Should().Be(0);
|
||||
@@ -75,12 +75,14 @@ public class RefreshTests(TestApi testApi) : IntegrationTest(testApi)
|
||||
[Fact]
|
||||
public async Task Refresh_WhenCalledWithRevokedRefreshToken_ItShouldReturn400WithProblemDetails()
|
||||
{
|
||||
var (user, refreshToken) = await ExecuteDbContextAsync(static async (context, sp) =>
|
||||
var (user, refreshToken) = await ExecuteAsync(static async (context, ct, sp) =>
|
||||
{
|
||||
var passwordHasher = sp.GetRequiredService<IPasswordHasher>();
|
||||
var tokenGenerator = sp.GetRequiredService<ITokenGenerator>();
|
||||
var encryptor = sp.GetRequiredService<IEncryptor>();
|
||||
|
||||
var user = User.From("User1", passwordHasher.Hash("@Password1"));
|
||||
var userEncryptionKey = await encryptor.GenerateEncryptedKeyAsync(ct);
|
||||
var user = User.From("User1", passwordHasher.Hash("@Password1"), userEncryptionKey);
|
||||
var refreshToken = tokenGenerator.GenerateRefreshToken(user);
|
||||
refreshToken.Revoke();
|
||||
|
||||
@@ -88,18 +90,16 @@ public class RefreshTests(TestApi testApi) : IntegrationTest(testApi)
|
||||
|
||||
context.Add(user);
|
||||
|
||||
await context.SaveChangesAsync(TestContext.Current.CancellationToken);
|
||||
await context.SaveChangesAsync(ct);
|
||||
return (user, refreshToken);
|
||||
});
|
||||
}, TestContext.Current.CancellationToken);
|
||||
|
||||
var jwt = JwtTokenBuilder.New()
|
||||
.WithClaim(JwtRegisteredClaimNames.Sub, user.Id.ToString())
|
||||
using var request = HttpRequestBuilder.New()
|
||||
.Post(RefreshUri)
|
||||
.WithUserId(user.Id)
|
||||
.WithRefreshCookie(refreshToken.Token)
|
||||
.Build();
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, RefreshUri);
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", jwt);
|
||||
request.Headers.Add("Cookie", $"fiscalos_refresh_cookie={refreshToken.Token}");
|
||||
|
||||
var response = await Client.SendAsync(request, TestContext.Current.CancellationToken);
|
||||
|
||||
await response.Should().BeProblemDetails(HttpStatusCode.BadRequest);
|
||||
@@ -108,30 +108,30 @@ public class RefreshTests(TestApi testApi) : IntegrationTest(testApi)
|
||||
[Fact]
|
||||
public async Task Refresh_WhenCalledWithExpiredRefreshToken_ItShouldReturn400WithProblemDetails()
|
||||
{
|
||||
var (user, refreshToken) = await ExecuteDbContextAsync(static async (context, sp) =>
|
||||
var (user, refreshToken) = await ExecuteAsync(static async (context, ct, sp) =>
|
||||
{
|
||||
var passwordHasher = sp.GetRequiredService<IPasswordHasher>();
|
||||
var tokenGenerator = sp.GetRequiredService<ITokenGenerator>();
|
||||
var timeProvider = sp.GetRequiredService<TimeProvider>();
|
||||
var encryptor = sp.GetRequiredService<IEncryptor>();
|
||||
|
||||
var user = User.From("User1", passwordHasher.Hash("@Password1"));
|
||||
var userEncryptionKey = await encryptor.GenerateEncryptedKeyAsync(ct);
|
||||
var user = User.From("User1", passwordHasher.Hash("@Password1"), userEncryptionKey);
|
||||
var refreshToken = RefreshToken.From(user.Id, "expiredtoken", timeProvider.GetUtcNow().AddHours(-1));
|
||||
user.AddRefreshToken(refreshToken);
|
||||
|
||||
context.Add(user);
|
||||
|
||||
await context.SaveChangesAsync(TestContext.Current.CancellationToken);
|
||||
await context.SaveChangesAsync(ct);
|
||||
return (user, refreshToken);
|
||||
});
|
||||
}, TestContext.Current.CancellationToken);
|
||||
|
||||
var jwt = JwtTokenBuilder.New()
|
||||
.WithClaim(JwtRegisteredClaimNames.Sub, user.Id.ToString())
|
||||
using var request = HttpRequestBuilder.New()
|
||||
.Post(RefreshUri)
|
||||
.WithUserId(user.Id)
|
||||
.WithRefreshCookie(refreshToken.Token)
|
||||
.Build();
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, RefreshUri);
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", jwt);
|
||||
request.Headers.Add("Cookie", $"fiscalos_refresh_cookie={refreshToken.Token}");
|
||||
|
||||
var response = await Client.SendAsync(request, TestContext.Current.CancellationToken);
|
||||
|
||||
await response.Should().BeProblemDetails(HttpStatusCode.BadRequest);
|
||||
@@ -142,50 +142,54 @@ public class RefreshTests(TestApi testApi) : IntegrationTest(testApi)
|
||||
[InlineData(-5)]
|
||||
public async Task Refresh_WhenCalledWithValidRefreshTokenAndExpiredOrNotExpiredAccessToken_ItShouldReturn200WithNewTokensAndSetRefreshCookie(int accessTokenExpiresAtOffset)
|
||||
{
|
||||
var (user, refreshToken) = await ExecuteDbContextAsync(static async (context, sp) =>
|
||||
var (user, refreshToken) = await ExecuteAsync(static async (context, ct, sp) =>
|
||||
{
|
||||
var passwordHasher = sp.GetRequiredService<IPasswordHasher>();
|
||||
var tokenGenerator = sp.GetRequiredService<ITokenGenerator>();
|
||||
var timeProvider = sp.GetRequiredService<TimeProvider>();
|
||||
var encryptor = sp.GetRequiredService<IEncryptor>();
|
||||
|
||||
var user = User.From("User1", passwordHasher.Hash("@Password1"));
|
||||
var userEncryptionKey = await encryptor.GenerateEncryptedKeyAsync(ct);
|
||||
var user = User.From("User1", passwordHasher.Hash("@Password1"), userEncryptionKey);
|
||||
var refreshToken = tokenGenerator.GenerateRefreshToken(user);
|
||||
user.AddRefreshToken(refreshToken);
|
||||
|
||||
context.Add(user);
|
||||
|
||||
await context.SaveChangesAsync(TestContext.Current.CancellationToken);
|
||||
await context.SaveChangesAsync(ct);
|
||||
return (user, refreshToken);
|
||||
});
|
||||
}, TestContext.Current.CancellationToken);
|
||||
|
||||
var jwt = JwtTokenBuilder.New()
|
||||
.WithClaim(JwtRegisteredClaimNames.Sub, user.Id.ToString())
|
||||
.WithExpiresAt(DateTime.UtcNow.AddMinutes(accessTokenExpiresAtOffset))
|
||||
using var request = HttpRequestBuilder.New()
|
||||
.Post(RefreshUri)
|
||||
.WithBearerToken(JwtTokenBuilder.New()
|
||||
.WithClaim(JwtRegisteredClaimNames.Sub, user.Id.ToString())
|
||||
.WithExpiresAt(DateTime.UtcNow.AddMinutes(accessTokenExpiresAtOffset))
|
||||
.Build())
|
||||
.WithRefreshCookie(refreshToken.Token)
|
||||
.Build();
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, RefreshUri);
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", jwt);
|
||||
request.Headers.Add("Cookie", $"fiscalos_refresh_cookie={refreshToken.Token}");
|
||||
|
||||
var response = await Client.SendAsync(request, TestContext.Current.CancellationToken);
|
||||
|
||||
response.Should().HaveSetCookieHeader("fiscalos_refresh_cookie");
|
||||
await response.Should().BeJsonContentOfType<Refresh.Response>(HttpStatusCode.OK);
|
||||
await response.Should().BeJsonContentOfType<API.Auth.Refresh.Response>(HttpStatusCode.OK);
|
||||
|
||||
var oldRefreshTokenInDb = await ExecuteDbContextAsync(
|
||||
async context => await context.Set<RefreshToken>()
|
||||
var oldRefreshTokenInDb = await ExecuteAsync(
|
||||
async (context, ct) => await context.Set<RefreshToken>()
|
||||
.Include(t => t.User)
|
||||
.Where(t => t.UserId == user.Id && t.Token == refreshToken.Token && t.Revoked == true)
|
||||
.SingleOrDefaultAsync()
|
||||
.SingleOrDefaultAsync(ct),
|
||||
TestContext.Current.CancellationToken
|
||||
);
|
||||
|
||||
oldRefreshTokenInDb.Should().NotBeNull();
|
||||
|
||||
var newRefreshTokenInDb = await ExecuteDbContextAsync(
|
||||
async context => await context.Set<RefreshToken>()
|
||||
var newRefreshTokenInDb = await ExecuteAsync(
|
||||
async (context, ct) => await context.Set<RefreshToken>()
|
||||
.Include(t => t.User)
|
||||
.Where(t => t.UserId == user.Id && t.Revoked == false && t.Token != refreshToken.Token)
|
||||
.SingleOrDefaultAsync()
|
||||
.SingleOrDefaultAsync(ct),
|
||||
TestContext.Current.CancellationToken
|
||||
);
|
||||
|
||||
newRefreshTokenInDb.Should().NotBeNull();
|
||||
@@ -0,0 +1,187 @@
|
||||
using Institution = FiscalOS.Core.Accounts.Institution;
|
||||
|
||||
namespace FiscalOS.API.Tests.Integration.Institutions;
|
||||
|
||||
public class ConnectTests(TestApi testApi) : IntegrationTest(testApi)
|
||||
{
|
||||
private static readonly Uri ConnectUri = new("/institutions/connect", UriKind.Relative);
|
||||
|
||||
[Fact]
|
||||
public async Task Connect_WhenCalledWithoutValidToken_ItShouldReturn401WithProblemDetails()
|
||||
{
|
||||
using var request = HttpRequestBuilder.New()
|
||||
.Post(ConnectUri)
|
||||
.WithBody(new { })
|
||||
.Build();
|
||||
|
||||
var res = await Client.SendAsync(request, TestContext.Current.CancellationToken);
|
||||
|
||||
await res.Should().BeProblemDetails(HttpStatusCode.Unauthorized);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Connect_WhenCalledWithoutPublicTokenOrPlaidInstitutionId_ItShouldReturn400WithProblemDetails()
|
||||
{
|
||||
using var request = HttpRequestBuilder.New()
|
||||
.Post(ConnectUri)
|
||||
.WithUserId(Guid.NewGuid())
|
||||
.WithBody(new { })
|
||||
.Build();
|
||||
|
||||
var response = await Client.SendAsync(request, TestContext.Current.CancellationToken);
|
||||
|
||||
await response.Should().BeValidationProblemDetails(new Dictionary<string, string[]>()
|
||||
{
|
||||
["PublicToken"] = ["The PublicToken field is required."],
|
||||
["PlaidInstitutionId"] = ["The PlaidInstitutionId field is required."],
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Connect_WhenCalledWithoutPublicToken_ItShouldReturn400WithProblemDetails()
|
||||
{
|
||||
using var request = HttpRequestBuilder.New()
|
||||
.Post(ConnectUri)
|
||||
.WithUserId(Guid.NewGuid())
|
||||
.WithBody(new { plaidInstitutionId = "id" })
|
||||
.Build();
|
||||
|
||||
var response = await Client.SendAsync(request, TestContext.Current.CancellationToken);
|
||||
|
||||
await response.Should().BeValidationProblemDetails(new Dictionary<string, string[]>()
|
||||
{
|
||||
["PublicToken"] = ["The PublicToken field is required."],
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Connect_WhenCalledWithoutPlaidInstitutionId_ItShouldReturn400WithProblemDetails()
|
||||
{
|
||||
using var request = HttpRequestBuilder.New()
|
||||
.Post(ConnectUri)
|
||||
.WithUserId(Guid.NewGuid())
|
||||
.WithBody(new { publicToken = "token" })
|
||||
.Build();
|
||||
|
||||
var response = await Client.SendAsync(request, TestContext.Current.CancellationToken);
|
||||
|
||||
await response.Should().BeValidationProblemDetails(new Dictionary<string, string[]>()
|
||||
{
|
||||
["PlaidInstitutionId"] = ["The PlaidInstitutionId field is required."],
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Connect_WhenCalledWithNonExistentUser_ItShouldReturn401WithProblemDetails()
|
||||
{
|
||||
using var request = HttpRequestBuilder.New()
|
||||
.Post(ConnectUri)
|
||||
.WithUserId(Guid.NewGuid())
|
||||
.WithBody(new
|
||||
{
|
||||
publicToken = "token",
|
||||
plaidInstitutionId = "id",
|
||||
})
|
||||
.Build();
|
||||
|
||||
var response = await Client.SendAsync(request, TestContext.Current.CancellationToken);
|
||||
|
||||
await response.Should().BeProblemDetails(HttpStatusCode.Unauthorized);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Connect_WhenCalledWithPlaidInstitutionIdThatIsAlreadyConnected_ItShouldReturn409WithProblemDetails()
|
||||
{
|
||||
var (user, institution) = await ExecuteAsync(static async (context, ct, sp) =>
|
||||
{
|
||||
var passwordHasher = sp.GetRequiredService<IPasswordHasher>();
|
||||
var encryptor = sp.GetRequiredService<IEncryptor>();
|
||||
|
||||
var userEncryptionKey = await encryptor.GenerateEncryptedKeyAsync(ct);
|
||||
var user = User.From("User1", passwordHasher.Hash("@Password1"), userEncryptionKey);
|
||||
|
||||
var encryptedAccessToken = await encryptor.EncryptAsyncFor(user, "accessToken", ct);
|
||||
var plaidMetadata = PlaidMetadata.From("alreadyExists", "Some Bank", encryptedAccessToken);
|
||||
var institution = Institution.From("Some Bank", plaidMetadata);
|
||||
|
||||
user.AddInstitution(institution);
|
||||
|
||||
await context.AddAsync(user, ct);
|
||||
await context.SaveChangesAsync(ct);
|
||||
return (user, institution);
|
||||
}, TestContext.Current.CancellationToken);
|
||||
|
||||
using var request = HttpRequestBuilder.New()
|
||||
.Post(ConnectUri)
|
||||
.WithUserId(user.Id)
|
||||
.WithBody(new
|
||||
{
|
||||
publicToken = "token",
|
||||
plaidInstitutionId = ((PlaidMetadata)institution.Metadata!).PlaidId,
|
||||
})
|
||||
.Build();
|
||||
|
||||
var response = await Client.SendAsync(request, TestContext.Current.CancellationToken);
|
||||
|
||||
await response.Should().BeProblemDetails(HttpStatusCode.Conflict);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Connect_WhenCalledWithInstitutionThatIsNotConnected_ItShouldReturn200()
|
||||
{
|
||||
var plaidInstitutionId = "ins_109508";
|
||||
|
||||
var (user, publicToken) = await ExecuteAsync(async (context, ct, sp) =>
|
||||
{
|
||||
var plaidClient = sp.GetRequiredService<PlaidClient>();
|
||||
var passwordHasher = sp.GetRequiredService<IPasswordHasher>();
|
||||
var encryptor = sp.GetRequiredService<IEncryptor>();
|
||||
|
||||
var userEncryptionKey = await encryptor.GenerateEncryptedKeyAsync(ct);
|
||||
var user = User.From("User1", passwordHasher.Hash("@Password1"), userEncryptionKey);
|
||||
|
||||
await context.AddAsync(user, ct);
|
||||
await context.SaveChangesAsync(ct);
|
||||
|
||||
var publicTokenResponse = await plaidClient.SandboxPublicTokenCreateAsync(new()
|
||||
{
|
||||
InstitutionId = plaidInstitutionId,
|
||||
InitialProducts = [Products.Transactions],
|
||||
});
|
||||
|
||||
return (user, publicTokenResponse.PublicToken);
|
||||
}, TestContext.Current.CancellationToken);
|
||||
|
||||
using var request = HttpRequestBuilder.New()
|
||||
.Post(ConnectUri)
|
||||
.WithUserId(user.Id)
|
||||
.WithBody(new
|
||||
{
|
||||
publicToken,
|
||||
plaidInstitutionId,
|
||||
})
|
||||
.Build();
|
||||
|
||||
var response = await Client.SendAsync(request, TestContext.Current.CancellationToken);
|
||||
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
|
||||
var updatedUser = await ExecuteAsync(
|
||||
async (context, ct) => await context.Set<User>()
|
||||
.Include(u => u.Institutions)
|
||||
.ThenInclude(i => i.Metadata)
|
||||
.SingleAsync(u => u.Id == user.Id, ct),
|
||||
TestContext.Current.CancellationToken
|
||||
);
|
||||
|
||||
updatedUser.Institutions.Should().HaveCount(1);
|
||||
|
||||
var institution = updatedUser.Institutions.First();
|
||||
institution.Name.Should().NotBeNullOrEmpty();
|
||||
|
||||
var metadata = institution.Metadata.As<PlaidMetadata>();
|
||||
metadata.PlaidId.Should().Be(plaidInstitutionId);
|
||||
metadata.PlaidName.Should().Be(institution.Name);
|
||||
metadata.EncryptedAccessToken.Should().NotBeNullOrEmpty();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
using FiscalOS.API.Institutions.GetAvailable;
|
||||
|
||||
using Institution = FiscalOS.Core.Accounts.Institution;
|
||||
|
||||
namespace FiscalOS.API.Tests.Integration.Institutions;
|
||||
|
||||
public class GetAvailableTests(TestApi testApi) : IntegrationTest(testApi)
|
||||
{
|
||||
private static Uri GetAvailableUri(Guid id)
|
||||
{
|
||||
return new($"/institutions/{id}/available", UriKind.Relative);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAvailable_WhenCalledAndUnauthenticated_ItShouldReturn401WithProblemDetails()
|
||||
{
|
||||
using var request = HttpRequestBuilder.New()
|
||||
.WithUri(GetAvailableUri(Guid.NewGuid()))
|
||||
.Build();
|
||||
|
||||
var response = await Client.SendAsync(request, TestContext.Current.CancellationToken);
|
||||
|
||||
await response.Should().BeProblemDetails(HttpStatusCode.Unauthorized);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAvailable_WhenCalledByNonExistentUser_ItShouldReturn401WithProblemDetails()
|
||||
{
|
||||
using var request = HttpRequestBuilder.New()
|
||||
.WithUri(GetAvailableUri(Guid.NewGuid()))
|
||||
.WithUserId(Guid.NewGuid())
|
||||
.Build();
|
||||
|
||||
var response = await Client.SendAsync(request, TestContext.Current.CancellationToken);
|
||||
|
||||
await response.Should().BeProblemDetails(HttpStatusCode.Unauthorized);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAvailable_WhenCalledWithNonExistentInstitutionId_ItShouldReturn404WithProblemDetails()
|
||||
{
|
||||
var user = await ExecuteAsync(static async (context, ct, sp) =>
|
||||
{
|
||||
var passwordHasher = sp.GetRequiredService<IPasswordHasher>();
|
||||
var encryptor = sp.GetRequiredService<IEncryptor>();
|
||||
|
||||
var userEncryptionKey = await encryptor.GenerateEncryptedKeyAsync(ct);
|
||||
var user = User.From("Stevan", passwordHasher.Hash("@Password1"), userEncryptionKey);
|
||||
|
||||
context.Add(user);
|
||||
await context.SaveChangesAsync(ct);
|
||||
return user;
|
||||
}, TestContext.Current.CancellationToken);
|
||||
|
||||
using var request = HttpRequestBuilder.New()
|
||||
.WithUri(GetAvailableUri(Guid.NewGuid()))
|
||||
.WithUserId(user.Id)
|
||||
.Build();
|
||||
|
||||
var response = await Client.SendAsync(request, TestContext.Current.CancellationToken);
|
||||
|
||||
await response.Should().BeProblemDetails(HttpStatusCode.NotFound);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAvailable_WhenCalledWithConnectedInstitution_ItShouldReturn200WithAvailableAccounts()
|
||||
{
|
||||
var (user, institution, expectedAccounts) = await ExecuteAsync(static async (context, ct, sp) =>
|
||||
{
|
||||
var passwordHasher = sp.GetRequiredService<IPasswordHasher>();
|
||||
var encryptor = sp.GetRequiredService<IEncryptor>();
|
||||
var plaidClient = sp.GetRequiredService<PlaidClient>();
|
||||
|
||||
var userEncryptionKey = await encryptor.GenerateEncryptedKeyAsync(ct);
|
||||
var user = User.From("Stevan", passwordHasher.Hash("@Password1"), userEncryptionKey);
|
||||
|
||||
var institutionId = "ins_109508";
|
||||
|
||||
var publicTokenResponse = await plaidClient.SandboxPublicTokenCreateAsync(new()
|
||||
{
|
||||
InstitutionId = institutionId,
|
||||
InitialProducts = [Products.Transactions],
|
||||
});
|
||||
|
||||
var exchangeTokenResponse = await plaidClient.ItemPublicTokenExchangeAsync(new()
|
||||
{
|
||||
PublicToken = publicTokenResponse.PublicToken,
|
||||
});
|
||||
|
||||
var accountsResponse = await plaidClient.AccountsGetAsync(new()
|
||||
{
|
||||
AccessToken = exchangeTokenResponse.AccessToken,
|
||||
});
|
||||
var encryptedAccessToken = await encryptor.EncryptAsyncFor(user, exchangeTokenResponse.AccessToken, ct);
|
||||
var institutionMetadata = PlaidMetadata.From(institutionId, "Some Bank", encryptedAccessToken);
|
||||
var institution = Institution.From("Some Bank", institutionMetadata);
|
||||
|
||||
user.AddInstitution(institution);
|
||||
|
||||
context.Add(user);
|
||||
context.Add(institution);
|
||||
await context.SaveChangesAsync(ct);
|
||||
return (user, institution, accountsResponse.Accounts);
|
||||
}, TestContext.Current.CancellationToken);
|
||||
|
||||
using var request = HttpRequestBuilder.New()
|
||||
.WithUri(GetAvailableUri(institution.Id))
|
||||
.WithUserId(user.Id)
|
||||
.Build();
|
||||
|
||||
var response = await Client.SendAsync(request, TestContext.Current.CancellationToken);
|
||||
|
||||
(await response.Should().BeJsonContentOfType<Response>(HttpStatusCode.OK))
|
||||
.Which.Accounts.Should().BeEquivalentTo(expectedAccounts.Select(AccountDto.FromPlaidAccount));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
namespace FiscalOS.API.Tests.Integration.Institutions;
|
||||
|
||||
public class LinkTests(TestApi testApi) : IntegrationTest(testApi)
|
||||
{
|
||||
private static readonly Uri LinkUri = new("/institutions/link", UriKind.Relative);
|
||||
|
||||
[Fact]
|
||||
public async Task Link_WhenCalledAndUnauthenticated_ItShouldReturn401WithProblemDetails()
|
||||
{
|
||||
using var request = HttpRequestBuilder.New()
|
||||
.Post(LinkUri)
|
||||
.Build();
|
||||
|
||||
var res = await Client.SendAsync(request, TestContext.Current.CancellationToken);
|
||||
|
||||
await res.Should().BeProblemDetails(HttpStatusCode.Unauthorized);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Link_WhenCalledWithNonExistentUser_ItShouldReturn401WithProblemDetails()
|
||||
{
|
||||
using var request = HttpRequestBuilder.New()
|
||||
.Post(LinkUri)
|
||||
.WithUserId(Guid.NewGuid())
|
||||
.Build();
|
||||
|
||||
var res = await Client.SendAsync(request, TestContext.Current.CancellationToken);
|
||||
|
||||
await res.Should().BeProblemDetails(HttpStatusCode.Unauthorized);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Link_WhenCalledWithUserWhoExists_ItShouldReturnLinkToken()
|
||||
{
|
||||
var user = await ExecuteAsync(static async (context, ct, sp) =>
|
||||
{
|
||||
var passwordHasher = sp.GetRequiredService<IPasswordHasher>();
|
||||
var encryptor = sp.GetRequiredService<IEncryptor>();
|
||||
|
||||
var userEncryptionKey = await encryptor.GenerateEncryptedKeyAsync(ct);
|
||||
var user = User.From("User1", passwordHasher.Hash("@Password1"), userEncryptionKey);
|
||||
|
||||
await context.AddAsync(user, ct);
|
||||
await context.SaveChangesAsync(ct);
|
||||
return user;
|
||||
}, TestContext.Current.CancellationToken);
|
||||
|
||||
using var request = HttpRequestBuilder.New()
|
||||
.Post(LinkUri)
|
||||
.WithUserId(user.Id)
|
||||
.Build();
|
||||
|
||||
var res = await Client.SendAsync(request, TestContext.Current.CancellationToken);
|
||||
|
||||
(await res.Should().BeJsonContentOfType<API.Institutions.Link.Response>(HttpStatusCode.OK))
|
||||
.Which.LinkToken.Should().NotBeEmpty();
|
||||
}
|
||||
}
|
||||
@@ -6,48 +6,47 @@ public abstract class IntegrationTest(TestApi testApi) : IClassFixture<TestApi>,
|
||||
|
||||
public async ValueTask InitializeAsync()
|
||||
{
|
||||
await ExecuteDbContextAsync(static async context =>
|
||||
await ExecuteAsync(static async (context, ct) =>
|
||||
{
|
||||
await context.Database.EnsureCreatedAsync();
|
||||
});
|
||||
await context.Database.EnsureCreatedAsync(ct);
|
||||
}, TestContext.Current.CancellationToken);
|
||||
}
|
||||
|
||||
protected async Task ExecuteDbContextAsync(Func<DbContext, Task> action)
|
||||
protected async Task ExecuteAsync(Func<DbContext, CancellationToken, Task> action, CancellationToken ct)
|
||||
{
|
||||
await using var scope = testApi.Services.CreateAsyncScope();
|
||||
var context = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
await action(context);
|
||||
await action(context, ct);
|
||||
}
|
||||
|
||||
protected async Task ExecuteDbContextAsync(Func<DbContext, IServiceProvider, Task> action)
|
||||
protected async Task ExecuteAsync(Func<DbContext, CancellationToken, IServiceProvider, Task> action, CancellationToken ct)
|
||||
{
|
||||
await using var scope = testApi.Services.CreateAsyncScope();
|
||||
var context = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
await action(context, scope.ServiceProvider);
|
||||
await action(context, ct, scope.ServiceProvider);
|
||||
}
|
||||
|
||||
protected async Task<T> ExecuteDbContextAsync<T>(Func<DbContext, Task<T>> action)
|
||||
protected async Task<T> ExecuteAsync<T>(Func<DbContext, CancellationToken, Task<T>> action, CancellationToken ct)
|
||||
{
|
||||
await using var scope = testApi.Services.CreateAsyncScope();
|
||||
var context = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
return await action(context);
|
||||
return await action(context, ct);
|
||||
}
|
||||
|
||||
protected async Task<T> ExecuteDbContextAsync<T>(Func<DbContext, IServiceProvider, Task<T>> action)
|
||||
protected async Task<T> ExecuteAsync<T>(Func<DbContext, CancellationToken, IServiceProvider, Task<T>> action, CancellationToken ct)
|
||||
{
|
||||
await using var scope = testApi.Services.CreateAsyncScope();
|
||||
var context = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
return await action(context, scope.ServiceProvider);
|
||||
return await action(context, ct, scope.ServiceProvider);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await ExecuteDbContextAsync(static async context =>
|
||||
await ExecuteAsync(static async (context, ct) =>
|
||||
{
|
||||
await context.Database.EnsureDeletedAsync();
|
||||
});
|
||||
await context.Database.EnsureDeletedAsync(ct);
|
||||
}, TestContext.Current.CancellationToken);
|
||||
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -4,21 +4,30 @@ global using System.Net.Http.Headers;
|
||||
global using System.Net.Http.Json;
|
||||
global using System.Security.Claims;
|
||||
global using System.Security.Cryptography;
|
||||
global using System.Text;
|
||||
global using System.Text.Json;
|
||||
|
||||
global using AwesomeAssertions.Execution;
|
||||
global using AwesomeAssertions.Primitives;
|
||||
|
||||
global using FiscalOS.API.Tests.Assertions;
|
||||
global using FiscalOS.API.Tests.Infra;
|
||||
global using FiscalOS.Core.Authentication;
|
||||
global using FiscalOS.Core.Identity;
|
||||
global using FiscalOS.Core.Security;
|
||||
global using FiscalOS.Infra.Accounts.Plaid;
|
||||
global using FiscalOS.Infra.Authentication;
|
||||
global using FiscalOS.Infra.Data;
|
||||
|
||||
global using Going.Plaid;
|
||||
global using Going.Plaid.Entity;
|
||||
|
||||
global using Microsoft.AspNetCore.Hosting;
|
||||
global using Microsoft.AspNetCore.Mvc;
|
||||
global using Microsoft.AspNetCore.Mvc.Testing;
|
||||
global using Microsoft.AspNetCore.TestHost;
|
||||
global using Microsoft.EntityFrameworkCore;
|
||||
global using Microsoft.Extensions.Configuration;
|
||||
global using Microsoft.Extensions.DependencyInjection;
|
||||
global using Microsoft.Extensions.Logging;
|
||||
global using Microsoft.Extensions.Options;
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user