Compare commits

..
101 changed files with 622 additions and 56865 deletions
-3
View File
@@ -1,3 +0,0 @@
{
"appHostPath": "../src/FiscalOS.AppHost/FiscalOS.AppHost.csproj"
}
-11
View File
@@ -1,11 +0,0 @@
{
"recommendations": [
"Vue.volar",
"vitest.explorer",
"ms-playwright.playwright",
"dbaeumer.vscode-eslint",
"EditorConfig.EditorConfig",
"oxc.oxc-vscode",
"esbenp.prettier-vscode"
]
}
-14
View File
@@ -1,14 +0,0 @@
{
"dotnet.defaultSolution": "FiscalOS.slnx",
"explorer.fileNesting.enabled": true,
"explorer.fileNesting.patterns": {
"tsconfig.json": "tsconfig.*.json, env.d.ts, typed-router.d.ts",
"vite.config.*": "jsconfig*, vitest.config.*, cypress.config.*, playwright.config.*",
"package.json": "package-lock.json, pnpm*, .yarnrc*, yarn*, .eslint*, eslint*, .oxlint*, oxlint*, .oxfmt*, .prettier*, prettier*, .editorconfig"
},
"editor.codeActionsOnSave": {
"source.fixAll": "explicit"
},
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode"
}
+131
View File
@@ -0,0 +1,131 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FiscalOS.API.Tests", "tests\FiscalOS.API.Tests\FiscalOS.API.Tests.csproj", "{1869E249-07F6-41CD-B143-AF86F022E4A6}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FiscalOS.API", "src\FiscalOS.API\FiscalOS.API.csproj", "{85E0C8F3-AE69-4012-836C-759E9D297762}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FiscalOS.AdminCLI", "src\FiscalOS.AdminCLI\FiscalOS.AdminCLI.csproj", "{C46818A0-1A92-4E4F-8390-48CBA9971745}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FiscalOS.Core", "src\FiscalOS.Core\FiscalOS.Core.csproj", "{612496CD-1F25-4804-8C52-49E62D01FFDC}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FiscalOS.Infra", "src\FiscalOS.Infra\FiscalOS.Infra.csproj", "{DAB42870-02A2-4ECB-B07F-6815C52D2435}"
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
Debug|x64 = Debug|x64
Debug|x86 = Debug|x86
Release|Any CPU = Release|Any CPU
Release|x64 = Release|x64
Release|x86 = Release|x86
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{1869E249-07F6-41CD-B143-AF86F022E4A6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{1869E249-07F6-41CD-B143-AF86F022E4A6}.Debug|Any CPU.Build.0 = Debug|Any CPU
{1869E249-07F6-41CD-B143-AF86F022E4A6}.Debug|x64.ActiveCfg = Debug|Any CPU
{1869E249-07F6-41CD-B143-AF86F022E4A6}.Debug|x64.Build.0 = Debug|Any CPU
{1869E249-07F6-41CD-B143-AF86F022E4A6}.Debug|x86.ActiveCfg = Debug|Any CPU
{1869E249-07F6-41CD-B143-AF86F022E4A6}.Debug|x86.Build.0 = Debug|Any CPU
{1869E249-07F6-41CD-B143-AF86F022E4A6}.Release|Any CPU.ActiveCfg = Release|Any CPU
{1869E249-07F6-41CD-B143-AF86F022E4A6}.Release|Any CPU.Build.0 = Release|Any CPU
{1869E249-07F6-41CD-B143-AF86F022E4A6}.Release|x64.ActiveCfg = Release|Any CPU
{1869E249-07F6-41CD-B143-AF86F022E4A6}.Release|x64.Build.0 = Release|Any CPU
{1869E249-07F6-41CD-B143-AF86F022E4A6}.Release|x86.ActiveCfg = Release|Any CPU
{1869E249-07F6-41CD-B143-AF86F022E4A6}.Release|x86.Build.0 = Release|Any CPU
{85E0C8F3-AE69-4012-836C-759E9D297762}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{85E0C8F3-AE69-4012-836C-759E9D297762}.Debug|Any CPU.Build.0 = Debug|Any CPU
{85E0C8F3-AE69-4012-836C-759E9D297762}.Debug|x64.ActiveCfg = Debug|Any CPU
{85E0C8F3-AE69-4012-836C-759E9D297762}.Debug|x64.Build.0 = Debug|Any CPU
{85E0C8F3-AE69-4012-836C-759E9D297762}.Debug|x86.ActiveCfg = Debug|Any CPU
{85E0C8F3-AE69-4012-836C-759E9D297762}.Debug|x86.Build.0 = Debug|Any CPU
{85E0C8F3-AE69-4012-836C-759E9D297762}.Release|Any CPU.ActiveCfg = Release|Any CPU
{85E0C8F3-AE69-4012-836C-759E9D297762}.Release|Any CPU.Build.0 = Release|Any CPU
{85E0C8F3-AE69-4012-836C-759E9D297762}.Release|x64.ActiveCfg = Release|Any CPU
{85E0C8F3-AE69-4012-836C-759E9D297762}.Release|x64.Build.0 = Release|Any CPU
{85E0C8F3-AE69-4012-836C-759E9D297762}.Release|x86.ActiveCfg = Release|Any CPU
{85E0C8F3-AE69-4012-836C-759E9D297762}.Release|x86.Build.0 = Release|Any CPU
{C46818A0-1A92-4E4F-8390-48CBA9971745}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{C46818A0-1A92-4E4F-8390-48CBA9971745}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C46818A0-1A92-4E4F-8390-48CBA9971745}.Debug|x64.ActiveCfg = Debug|Any CPU
{C46818A0-1A92-4E4F-8390-48CBA9971745}.Debug|x64.Build.0 = Debug|Any CPU
{C46818A0-1A92-4E4F-8390-48CBA9971745}.Debug|x86.ActiveCfg = Debug|Any CPU
{C46818A0-1A92-4E4F-8390-48CBA9971745}.Debug|x86.Build.0 = Debug|Any CPU
{C46818A0-1A92-4E4F-8390-48CBA9971745}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C46818A0-1A92-4E4F-8390-48CBA9971745}.Release|Any CPU.Build.0 = Release|Any CPU
{C46818A0-1A92-4E4F-8390-48CBA9971745}.Release|x64.ActiveCfg = Release|Any CPU
{C46818A0-1A92-4E4F-8390-48CBA9971745}.Release|x64.Build.0 = Release|Any CPU
{C46818A0-1A92-4E4F-8390-48CBA9971745}.Release|x86.ActiveCfg = Release|Any CPU
{C46818A0-1A92-4E4F-8390-48CBA9971745}.Release|x86.Build.0 = Release|Any CPU
{612496CD-1F25-4804-8C52-49E62D01FFDC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{612496CD-1F25-4804-8C52-49E62D01FFDC}.Debug|Any CPU.Build.0 = Debug|Any CPU
{612496CD-1F25-4804-8C52-49E62D01FFDC}.Debug|x64.ActiveCfg = Debug|Any CPU
{612496CD-1F25-4804-8C52-49E62D01FFDC}.Debug|x64.Build.0 = Debug|Any CPU
{612496CD-1F25-4804-8C52-49E62D01FFDC}.Debug|x86.ActiveCfg = Debug|Any CPU
{612496CD-1F25-4804-8C52-49E62D01FFDC}.Debug|x86.Build.0 = Debug|Any CPU
{612496CD-1F25-4804-8C52-49E62D01FFDC}.Release|Any CPU.ActiveCfg = Release|Any CPU
{612496CD-1F25-4804-8C52-49E62D01FFDC}.Release|Any CPU.Build.0 = Release|Any CPU
{612496CD-1F25-4804-8C52-49E62D01FFDC}.Release|x64.ActiveCfg = Release|Any CPU
{612496CD-1F25-4804-8C52-49E62D01FFDC}.Release|x64.Build.0 = Release|Any CPU
{612496CD-1F25-4804-8C52-49E62D01FFDC}.Release|x86.ActiveCfg = Release|Any CPU
{612496CD-1F25-4804-8C52-49E62D01FFDC}.Release|x86.Build.0 = Release|Any CPU
{DAB42870-02A2-4ECB-B07F-6815C52D2435}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{DAB42870-02A2-4ECB-B07F-6815C52D2435}.Debug|Any CPU.Build.0 = Debug|Any CPU
{DAB42870-02A2-4ECB-B07F-6815C52D2435}.Debug|x64.ActiveCfg = Debug|Any CPU
{DAB42870-02A2-4ECB-B07F-6815C52D2435}.Debug|x64.Build.0 = Debug|Any CPU
{DAB42870-02A2-4ECB-B07F-6815C52D2435}.Debug|x86.ActiveCfg = Debug|Any CPU
{DAB42870-02A2-4ECB-B07F-6815C52D2435}.Debug|x86.Build.0 = Debug|Any CPU
{DAB42870-02A2-4ECB-B07F-6815C52D2435}.Release|Any CPU.ActiveCfg = Release|Any CPU
{DAB42870-02A2-4ECB-B07F-6815C52D2435}.Release|Any CPU.Build.0 = Release|Any CPU
{DAB42870-02A2-4ECB-B07F-6815C52D2435}.Release|x64.ActiveCfg = Release|Any CPU
{DAB42870-02A2-4ECB-B07F-6815C52D2435}.Release|x64.Build.0 = Release|Any CPU
{DAB42870-02A2-4ECB-B07F-6815C52D2435}.Release|x86.ActiveCfg = Release|Any CPU
{DAB42870-02A2-4ECB-B07F-6815C52D2435}.Release|x86.Build.0 = Release|Any CPU
{48E0737E-0200-468E-ADA7-75893935DBC0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{48E0737E-0200-468E-ADA7-75893935DBC0}.Debug|Any CPU.Build.0 = Debug|Any CPU
{48E0737E-0200-468E-ADA7-75893935DBC0}.Debug|x64.ActiveCfg = Debug|Any CPU
{48E0737E-0200-468E-ADA7-75893935DBC0}.Debug|x64.Build.0 = Debug|Any CPU
{48E0737E-0200-468E-ADA7-75893935DBC0}.Debug|x86.ActiveCfg = Debug|Any CPU
{48E0737E-0200-468E-ADA7-75893935DBC0}.Debug|x86.Build.0 = Debug|Any CPU
{48E0737E-0200-468E-ADA7-75893935DBC0}.Release|Any CPU.ActiveCfg = Release|Any CPU
{48E0737E-0200-468E-ADA7-75893935DBC0}.Release|Any CPU.Build.0 = Release|Any CPU
{48E0737E-0200-468E-ADA7-75893935DBC0}.Release|x64.ActiveCfg = Release|Any CPU
{48E0737E-0200-468E-ADA7-75893935DBC0}.Release|x64.Build.0 = Release|Any CPU
{48E0737E-0200-468E-ADA7-75893935DBC0}.Release|x86.ActiveCfg = Release|Any CPU
{48E0737E-0200-468E-ADA7-75893935DBC0}.Release|x86.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
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{1869E249-07F6-41CD-B143-AF86F022E4A6} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
{85E0C8F3-AE69-4012-836C-759E9D297762} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{C46818A0-1A92-4E4F-8390-48CBA9971745} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{612496CD-1F25-4804-8C52-49E62D01FFDC} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{DAB42870-02A2-4ECB-B07F-6815C52D2435} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{48E0737E-0200-468E-ADA7-75893935DBC0} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
{2D9C02A9-C704-4275-88E9-1BBD3AC12700} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
EndGlobalSection
EndGlobal
-20
View File
@@ -1,20 +0,0 @@
<Solution>
<Configurations>
<Platform Name="Any CPU" />
<Platform Name="x64" />
<Platform Name="x86" />
</Configurations>
<Folder Name="/src/">
<Project Path="src/FiscalOS.AdminCLI/FiscalOS.AdminCLI.csproj" />
<Project Path="src/FiscalOS.API/FiscalOS.API.csproj" />
<Project Path="src/FiscalOS.Core/FiscalOS.Core.csproj" />
<Project Path="src/FiscalOS.Infra/FiscalOS.Infra.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/FiscalOS.API.Tests/FiscalOS.API.Tests.csproj" />
<Project Path="tests/FiscalOS.Core.Tests/FiscalOS.Core.Tests.csproj" />
<Project Path="tests/FiscalOS.Infra.Tests/FiscalOS.Infra.Tests.csproj" />
</Folder>
<Project Path="src/FiscalOS.AppHost/FiscalOS.AppHost.csproj" />
<Project Path="src/FiscalOS.ServiceDefaults/FiscalOS.ServiceDefaults.csproj" />
</Solution>
+1 -9
View File
@@ -3,7 +3,6 @@
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="Aspire.Hosting.JavaScript" Version="13.1.1" />
<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">
@@ -14,12 +13,5 @@
<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" />
<PackageVersion Include="Microsoft.Extensions.Http.Resilience" Version="10.1.0" />
<PackageVersion Include="Microsoft.Extensions.ServiceDiscovery" Version="10.1.0" />
<PackageVersion Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.14.0" />
<PackageVersion Include="OpenTelemetry.Extensions.Hosting" Version="1.14.0" />
<PackageVersion Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.14.0" />
<PackageVersion Include="OpenTelemetry.Instrumentation.Http" Version="1.14.0" />
<PackageVersion Include="OpenTelemetry.Instrumentation.Runtime" Version="1.14.0" />
</ItemGroup>
</Project>
</Project>
@@ -10,6 +10,7 @@ internal static class AccountsExtensions
.RequireAuthorization();
accountsGroup.MapAddEndpoint();
accountsGroup.MapConnectEndpoint();
return accountsGroup;
}
+2 -3
View File
@@ -1,3 +1,5 @@
using FiscalOS.Core.Accounts;
namespace FiscalOS.API.Accounts.Add;
internal static class Endpoint
@@ -44,9 +46,6 @@ internal static class Endpoint
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);
+1 -9
View File
@@ -1,3 +1,4 @@
namespace FiscalOS.API.Accounts.Add;
public record Request : IValidatableObject
@@ -5,9 +6,6 @@ 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)
{
@@ -28,11 +26,5 @@ public record Request : IValidatableObject
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]);
}
}
}
@@ -1,4 +1,6 @@
namespace FiscalOS.API.Institutions.Connect;
using FiscalOS.Core.Accounts;
namespace FiscalOS.API.Accounts.Connect;
internal static class Endpoint
{
@@ -1,4 +1,4 @@
namespace FiscalOS.API.Institutions.Connect;
namespace FiscalOS.API.Accounts.Connect;
public record Request : IValidatableObject
{
-18
View File
@@ -1,18 +0,0 @@
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
View File
@@ -10,7 +10,6 @@
<ItemGroup>
<ProjectReference Include="..\FiscalOS.Core\FiscalOS.Core.csproj" />
<ProjectReference Include="..\FiscalOS.Infra\FiscalOS.Infra.csproj" />
<ProjectReference Include="..\FiscalOS.ServiceDefaults\FiscalOS.ServiceDefaults.csproj" />
</ItemGroup>
</Project>
@@ -1,3 +1,5 @@
using FiscalOS.Core.Identity;
namespace FiscalOS.API.Http;
internal static class HttpContextExtensions
@@ -1,51 +0,0 @@
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));
}
}
@@ -1,45 +0,0 @@
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,
};
}
}
@@ -1,18 +0,0 @@
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;
}
}
@@ -1,31 +0,0 @@
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));
}
}
@@ -1,19 +0,0 @@
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,12 +1,12 @@
namespace FiscalOS.API.Auth.Login;
namespace FiscalOS.API.Login;
internal static class Endpoint
{
private const string Route = "/login";
public static RouteHandlerBuilder MapLoginEndpoint(this RouteGroupBuilder groupBuilder)
public static RouteHandlerBuilder MapLoginEndpoint(this WebApplication app)
{
return groupBuilder.MapPost(Route, HandleAsync);
return app.MapPost(Route, HandleAsync);
}
private static async Task<IResult> HandleAsync(
@@ -1,4 +1,4 @@
namespace FiscalOS.API.Auth.Login;
namespace FiscalOS.API.Login;
public record Request : IValidatableObject
{
@@ -1,4 +1,4 @@
namespace FiscalOS.API.Auth.Login;
namespace FiscalOS.API.Login;
internal sealed record Response
{
+4 -7
View File
@@ -1,9 +1,5 @@
using FiscalOS.ServiceDefaults;
var builder = WebApplication.CreateBuilder(args);
builder.AddServiceDefaults();
builder.Services.AddValidation();
builder.Services.AddProblemDetails();
@@ -36,10 +32,11 @@ app.UseAuthentication();
app.UseAuthorization();
app.UseStatusCodePages();
app.MapDefaultEndpoints();
app.MapLoginEndpoint();
app.MapRefreshEndpoint()
.RequireAuthorization(Schemes.AllowExpiredTokens);
app.MapAuthEndpoints();
app.MapAccountsEndpoints();
app.MapInstitutionsEndpoints();
app.Run();
@@ -18,6 +18,6 @@
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
},
}
}
@@ -1,12 +1,12 @@
namespace FiscalOS.API.Auth.Refresh;
namespace FiscalOS.API.Refresh;
internal static class Endpoint
{
private const string Route = "/refresh";
public static RouteHandlerBuilder MapRefreshEndpoint(this RouteGroupBuilder groupBuilder)
public static RouteHandlerBuilder MapRefreshEndpoint(this WebApplication app)
{
return groupBuilder.MapPost(Route, HandleAsync);
return app.MapPost(Route, HandleAsync);
}
private static async Task<IResult> HandleAsync(
@@ -1,4 +1,4 @@
namespace FiscalOS.API.Auth.Refresh;
namespace FiscalOS.API.Refresh;
internal sealed record Response
{
+3 -9
View File
@@ -4,17 +4,11 @@ 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.Accounts.Connect;
global using FiscalOS.API.Http;
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.API.Login;
global using FiscalOS.API.Refresh;
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;
-10
View File
@@ -1,10 +0,0 @@
var builder = DistributedApplication.CreateBuilder(args);
var api = builder.AddProject<Projects.FiscalOS_API>("API")
.WithHttpHealthCheck("/health");
builder.AddViteApp("web", "../FiscalOS.Web")
.WithReference(api)
.WithHttpsEndpoint(port: 7061, env: "PORT");
builder.Build().Run();
@@ -1,19 +0,0 @@
<Project Sdk="Aspire.AppHost.Sdk/13.1.1">
<ItemGroup>
<ProjectReference Include="..\FiscalOS.API\FiscalOS.API.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Aspire.Hosting.JavaScript" />
</ItemGroup>
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<UserSecretsId>80a27726-36cc-4a4d-a55d-11af527158bc</UserSecretsId>
</PropertyGroup>
</Project>
@@ -1,31 +0,0 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "https://localhost:17104;http://localhost:15068",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development",
"DOTNET_ENVIRONMENT": "Development",
"ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21226",
"ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "https://localhost:23228",
"ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22081"
}
},
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "http://localhost:15068",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development",
"DOTNET_ENVIRONMENT": "Development",
"ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19221",
"ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "http://localhost:18206",
"ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20097"
}
}
}
}
-10
View File
@@ -8,9 +8,6 @@ public sealed class Account : Entity
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()
@@ -20,11 +17,4 @@ public sealed class Account : Entity
Metadata = accountMetadata,
};
}
public void AddBalance(Balance balance)
{
ArgumentNullException.ThrowIfNull(balance);
_balances.Add(balance);
}
}
-27
View File
@@ -1,27 +0,0 @@
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,
};
}
}
@@ -1,15 +0,0 @@
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();
}
}
@@ -1,7 +1,3 @@
using System.Reflection;
namespace FiscalOS.Infra.Accounts.Plaid;
public sealed class PlaidService
@@ -19,31 +15,6 @@ public sealed class PlaidService
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()
@@ -59,21 +30,6 @@ public sealed class PlaidService
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()
@@ -88,4 +44,4 @@ public sealed class PlaidService
return ar.Item;
}
}
}
-13
View File
@@ -124,11 +124,6 @@ public sealed class AppDbContext(
.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 =>
@@ -147,13 +142,5 @@ public sealed class AppDbContext(
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);
});
}
}
@@ -5,7 +5,7 @@ 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>();
@@ -1,82 +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)
public partial class InstitutionAndMetadata : Migration
{
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);
});
/// <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.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_Institution_UserId",
table: "Institution",
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_InstitutionMetadata_InstitutionId",
table: "InstitutionMetadata",
column: "InstitutionId",
unique: true);
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");
}
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "InstitutionMetadata");
migrationBuilder.DropTable(
name: "Institution");
}
}
}
}
@@ -1,93 +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)
public partial class AccountAndMetadata : Migration
{
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);
});
/// <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.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_InstituionId",
table: "Account",
column: "InstituionId");
migrationBuilder.CreateIndex(
name: "IX_Account_UserId",
table: "Account",
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_Account_UserId",
table: "Account",
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_AccountMetadata_AccountId",
table: "AccountMetadata",
column: "AccountId",
unique: true);
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");
}
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "AccountMetadata");
migrationBuilder.DropTable(
name: "Account");
}
}
}
}
@@ -4,59 +4,59 @@
namespace FiscalOS.Infra.Migrations
{
/// <inheritdoc />
public partial class FixAccountInstitutionColumnName : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
public partial class FixAccountInstitutionColumnName : Migration
{
migrationBuilder.DropForeignKey(
name: "FK_Account_Institution_InstituionId",
table: "Account");
/// <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.RenameColumn(
name: "InstituionId",
table: "Account",
newName: "InstitutionId");
migrationBuilder.RenameIndex(
name: "IX_Account_InstituionId",
table: "Account",
newName: "IX_Account_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);
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);
}
}
/// <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);
}
}
}
}
@@ -1,364 +0,0 @@
// <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
}
}
}
@@ -1,50 +0,0 @@
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");
}
}
}
@@ -1,368 +0,0 @@
// <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
}
}
}
@@ -1,29 +0,0 @@
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");
}
}
}
@@ -80,38 +80,6 @@ namespace FiscalOS.Infra.Migrations
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")
@@ -301,15 +269,6 @@ namespace FiscalOS.Infra.Migrations
.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)
@@ -341,14 +300,14 @@ namespace FiscalOS.Infra.Migrations
modelBuilder.Entity("FiscalOS.Core.Accounts.Account", b =>
{
b.Navigation("Balances");
b.Navigation("Metadata");
b.Navigation("Metadata")
.IsRequired();
});
modelBuilder.Entity("FiscalOS.Core.Accounts.Institution", b =>
{
b.Navigation("Metadata");
b.Navigation("Metadata")
.IsRequired();
});
modelBuilder.Entity("FiscalOS.Core.Identity.User", b =>
@@ -1,22 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsAspireSharedProject>true</IsAspireSharedProject>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<PackageReference Include="Microsoft.Extensions.Http.Resilience" />
<PackageReference Include="Microsoft.Extensions.ServiceDiscovery" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" />
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" />
<PackageReference Include="OpenTelemetry.Instrumentation.Http" />
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" />
</ItemGroup>
</Project>
@@ -1,89 +0,0 @@
namespace FiscalOS.ServiceDefaults;
public static class ServiceDefaultsExtensions
{
private const string HealthEndpointPath = "/health";
private const string AlivenessEndpointPath = "/alive";
public static TBuilder AddServiceDefaults<TBuilder>(this TBuilder builder) where TBuilder : IHostApplicationBuilder
{
builder.ConfigureOpenTelemetry();
builder.AddDefaultHealthChecks();
builder.Services.AddServiceDiscovery();
builder.Services.ConfigureHttpClientDefaults(static http =>
{
http.AddStandardResilienceHandler();
http.AddServiceDiscovery();
});
return builder;
}
public static TBuilder ConfigureOpenTelemetry<TBuilder>(this TBuilder builder) where TBuilder : IHostApplicationBuilder
{
builder.Logging.AddOpenTelemetry(logging =>
{
logging.IncludeFormattedMessage = true;
logging.IncludeScopes = true;
});
builder.Services.AddOpenTelemetry()
.WithMetrics(metrics => metrics.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddRuntimeInstrumentation()
)
.WithTracing(tracing => tracing.AddSource(builder.Environment.ApplicationName)
.AddAspNetCoreInstrumentation(tracing =>
tracing.Filter = context =>
context.Request.Path.StartsWithSegments(HealthEndpointPath, StringComparison.InvariantCultureIgnoreCase) is false &&
context.Request.Path.StartsWithSegments(AlivenessEndpointPath, StringComparison.InvariantCultureIgnoreCase) is false
)
.AddHttpClientInstrumentation()
);
builder.AddOpenTelemetryExporters();
return builder;
}
public static TBuilder AddDefaultHealthChecks<TBuilder>(this TBuilder builder) where TBuilder : IHostApplicationBuilder
{
builder.Services.AddHealthChecks()
.AddCheck("self", static () => HealthCheckResult.Healthy(), ["live"]);
return builder;
}
public static WebApplication MapDefaultEndpoints(this WebApplication app)
{
ArgumentNullException.ThrowIfNull(app);
if (app.Environment.IsDevelopment())
{
app.MapHealthChecks(HealthEndpointPath);
app.MapHealthChecks(AlivenessEndpointPath, new HealthCheckOptions
{
Predicate = static r => r.Tags.Contains("live")
});
}
return app;
}
private static TBuilder AddOpenTelemetryExporters<TBuilder>(this TBuilder builder) where TBuilder : IHostApplicationBuilder
{
var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]);
if (useOtlpExporter)
{
builder.Services.AddOpenTelemetry().UseOtlpExporter();
}
return builder;
}
}
-10
View File
@@ -1,10 +0,0 @@
global using Microsoft.AspNetCore.Builder;
global using Microsoft.AspNetCore.Diagnostics.HealthChecks;
global using Microsoft.Extensions.DependencyInjection;
global using Microsoft.Extensions.Diagnostics.HealthChecks;
global using Microsoft.Extensions.Hosting;
global using Microsoft.Extensions.Logging;
global using OpenTelemetry;
global using OpenTelemetry.Metrics;
global using OpenTelemetry.Trace;
-8
View File
@@ -1,8 +0,0 @@
[*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue,css,scss,sass,less,styl}]
charset = utf-8
indent_size = 2
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
end_of_line = lf
max_line_length = 100
-1
View File
@@ -1 +0,0 @@
* text=auto eol=lf
-42
View File
@@ -1,42 +0,0 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
dist-ssr
coverage
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
*.tsbuildinfo
.eslintcache
# Cypress
/cypress/videos/
/cypress/screenshots/
# Vitest
__screenshots__/
# Vite
*.timestamp-*-*.mjs
test-results/
playwright-report/
-10
View File
@@ -1,10 +0,0 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["eslint", "typescript", "unicorn", "oxc", "vue", "vitest"],
"env": {
"browser": true
},
"categories": {
"correctness": "error"
}
}
-15
View File
@@ -1,15 +0,0 @@
{
"$schema": "https://json.schemastore.org/prettierrc",
"semi": true,
"tabWidth": 2,
"singleQuote": true,
"printWidth": 100,
"trailingComma": "es5",
"arrowParens": "avoid",
"bracketSameLine": false,
"jsxSingleQuote": true,
"embeddedLanguageFormatting": "auto",
"endOfLine": "auto",
"vueIndentScriptAndStyle": true,
"singleAttributePerLine": true
}
-4
View File
@@ -1,4 +0,0 @@
{
"extends": "@tsconfig/node24/tsconfig.json",
"include": ["./**/*"]
}
-6
View File
@@ -1,6 +0,0 @@
import { test, expect } from '@playwright/test'
test('visits the app root url', async ({ page }) => {
await page.goto('/')
await expect(page.locator('h1')).toHaveText('You did it!')
})
-1
View File
@@ -1 +0,0 @@
/// <reference types="vite/client" />
-27
View File
@@ -1,27 +0,0 @@
import { globalIgnores } from 'eslint/config'
import { defineConfigWithVueTs, vueTsConfigs } from '@vue/eslint-config-typescript'
import pluginVue from 'eslint-plugin-vue'
import pluginPlaywright from 'eslint-plugin-playwright'
import pluginVitest from '@vitest/eslint-plugin'
import pluginOxlint from 'eslint-plugin-oxlint'
import skipFormatting from 'eslint-config-prettier/flat'
export default defineConfigWithVueTs(
{
name: 'app/files-to-lint',
files: ['**/*.{vue,ts,mts,tsx}'],
},
globalIgnores(['**/dist/**', '**/dist-ssr/**', '**/coverage/**']),
...pluginVue.configs['flat/essential'],
vueTsConfigs.recommended,
{
...pluginPlaywright.configs['flat/recommended'],
files: ['e2e/**/*.{test,spec}.{js,ts,jsx,tsx}'],
},
{
...pluginVitest.configs.recommended,
files: ['src/**/__tests__/*'],
},
...pluginOxlint.buildFromOxlintConfigFile('.oxlintrc.json'),
skipFormatting,
)
-13
View File
@@ -1,13 +0,0 @@
<!DOCTYPE html>
<html lang="">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>FiscalOS</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
-7779
View File
File diff suppressed because it is too large Load Diff
-73
View File
@@ -1,73 +0,0 @@
{
"name": "fiscalos-web",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "run-p type-check \"build-only {@}\" --",
"preview": "vite preview",
"test:unit": "vitest",
"test:e2e": "playwright test",
"build-only": "vite build",
"type-check": "vue-tsc --build",
"lint": "run-s lint:*",
"lint:oxlint": "oxlint . --fix",
"lint:eslint": "eslint . --fix --cache",
"format": "prettier --write --experimental-cli src/"
},
"dependencies": {
"@jcss/vue-plaid-link": "^1.1.3",
"jwt-decode": "^4.0.0",
"pinia": "^3.0.4",
"ts-results": "^3.3.0",
"vue": "beta",
"vue-router": "^5.0.2"
},
"devDependencies": {
"@playwright/test": "^1.58.2",
"@tsconfig/node24": "^24.0.4",
"@types/jsdom": "^27.0.0",
"@types/node": "^24.10.13",
"@vitejs/plugin-vue": "^6.0.4",
"@vitejs/plugin-vue-jsx": "^5.1.4",
"@vitest/eslint-plugin": "^1.6.9",
"@vue/eslint-config-typescript": "^14.6.0",
"@vue/test-utils": "^2.4.6",
"@vue/tsconfig": "^0.8.1",
"eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-oxlint": "~1.46.0",
"eslint-plugin-playwright": "^2.5.1",
"eslint-plugin-vue": "~10.8.0",
"jiti": "^2.6.1",
"jsdom": "^28.1.0",
"npm-run-all2": "^8.0.4",
"oxlint": "~1.47.0",
"prettier": "3.8.1",
"typescript": "~5.9.3",
"vite": "beta",
"vite-plugin-mkcert": "^1.17.9",
"vite-plugin-vue-devtools": "^8.0.6",
"vitest": "^4.0.18",
"vue-tsc": "^3.2.4"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"overrides": {
"vue": "beta",
"@vue/compiler-core": "beta",
"@vue/compiler-dom": "beta",
"@vue/compiler-sfc": "beta",
"@vue/compiler-ssr": "beta",
"@vue/compiler-vapor": "beta",
"@vue/reactivity": "beta",
"@vue/runtime-core": "beta",
"@vue/runtime-dom": "beta",
"@vue/runtime-vapor": "beta",
"@vue/server-renderer": "beta",
"@vue/shared": "beta",
"@vue/compat": "beta"
}
}
-45
View File
@@ -1,45 +0,0 @@
import process from 'node:process'
import { defineConfig, devices } from '@playwright/test'
export default defineConfig({
testDir: './e2e',
timeout: 30 * 1000,
expect: {
timeout: 5000,
},
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: 'html',
use: {
actionTimeout: 0,
baseURL: process.env.CI ? 'http://localhost:4173' : 'http://localhost:5173',
trace: 'on-first-retry',
headless: !!process.env.CI,
},
projects: [
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
},
},
{
name: 'firefox',
use: {
...devices['Desktop Firefox'],
},
},
{
name: 'webkit',
use: {
...devices['Desktop Safari'],
},
},
],
webServer: {
command: process.env.CI ? 'npm run preview' : 'npm run dev',
port: process.env.CI ? 4173 : 5173,
reuseExistingServer: !process.env.CI,
},
})
-9
View File
@@ -1,9 +0,0 @@
<script setup lang="ts">
import { RouterView } from 'vue-router';
</script>
<template>
<RouterView />
</template>
<style scoped></style>
@@ -1,116 +0,0 @@
import { describe, it, expect } from 'vitest';
import { flushPromises, mount } from '@vue/test-utils';
import LoginForm from '@/components/LoginForm.vue';
describe('LoginForm', () => {
it('should have username field', () => {
const wrapper = mount(LoginForm);
const username = wrapper.get('#username');
expect(username.element.tagName).toBe('INPUT');
expect(username.attributes()['type']).toBe('text');
});
it('should have password field', () => {
const wrapper = mount(LoginForm);
const password = wrapper.get('#password');
expect(password.element.tagName).toBe('INPUT');
expect(password.attributes()['type']).toBe('password');
});
it('should require username field on submission', async () => {
const wrapper = mount(LoginForm, {
attachTo: document.body,
});
await wrapper.get('button').trigger('click');
expect(wrapper.text()).toContain('Username is required');
});
it('should require password field on submission', async () => {
const wrapper = mount(LoginForm, {
attachTo: document.body,
});
await wrapper.get('button').trigger('click');
expect(wrapper.text()).toContain('Password is required');
});
it('should reset error state when new input is entered', async () => {
const wrapper = mount(LoginForm, {
attachTo: document.body,
});
await wrapper.get('button').trigger('click');
expect(wrapper.text()).toContain('Username is required');
expect(wrapper.text()).toContain('Password is required');
const usernameInput = wrapper.get('#username');
const passwordInput = wrapper.get('#password');
await usernameInput.setValue('Stevan');
await passwordInput.setValue('password');
expect(wrapper.text()).not.toContain('Username is required');
expect(wrapper.text()).not.toContain('Password is required');
});
it('should disable login button while submitting', async () => {
let resolveSubmit;
const wrapper = mount(LoginForm, {
attachTo: document.body,
props: {
onValidSubmit: () => new Promise(resolve => {
resolveSubmit = resolve;
}),
},
});
const usernameInput = wrapper.get('#username');
const passwordInput = wrapper.get('#password');
const submitButton = wrapper.get('button');
await usernameInput.setValue('Stevan');
await passwordInput.setValue('password');
await submitButton.trigger('click');
expect('disabled' in submitButton.attributes()).toBe(true);
resolveSubmit!();
await flushPromises();
expect('disabled' in submitButton.attributes()).toBe(false);
});
it('should call the onValidSubmit function when submitted with valid state', async () => {
let wasCalled = false;
const wrapper = mount(LoginForm, {
attachTo: document.body,
props: {
onValidSubmit: () => new Promise(resolve => {
wasCalled = true;
resolve();
}),
},
});
const usernameInput = wrapper.get('#username');
const passwordInput = wrapper.get('#password');
const submitButton = wrapper.get('button');
await usernameInput.setValue('Stevan');
await passwordInput.setValue('password');
await submitButton.trigger('click');
expect(wasCalled).toBe(true);
});
});
-71
View File
@@ -1,71 +0,0 @@
@import './reset.css';
@font-face {
font-family: 'CaskaydiaCove NFM';
src: url('../fonts/CaskaydiaCoveNFM-Regular.eot');
src:
url('../fonts/CaskaydiaCoveNFM-Regular.eot?#iefix') format('embedded-opentype'),
url('../fonts/CaskaydiaCoveNFM-Regular.woff2') format('woff2'),
url('../fonts/CaskaydiaCoveNFM-Regular.woff') format('woff'),
url('../fonts/CaskaydiaCoveNFM-Regular.svg#CaskaydiaCoveNFM-Regular') format('svg');
font-weight: normal;
font-style: normal;
font-display: swap;
}
:root {
--palette-lavender: #b39cd0;
--palette-rose: #d09c9f;
--palette-sage: #b9d09c;
--palette-mint: #9cd0cd;
--neutral-white: #ffffff;
--neutral-100: #f5f5f5;
--neutral-200: #e5e5e5;
--neutral-800: #262626;
--neutral-900: #171717;
--neutral-black: #000000;
--bg-app: var(--neutral-white);
--bg-surface: var(--neutral-100);
--bg-element: var(--neutral-200);
--text-primary: var(--neutral-black);
--text-secondary: var(--neutral-800);
--border-subtle: var(--neutral-200);
--brand-primary: var(--palette-lavender);
--brand-secondary: var(--palette-rose);
--brand-accent: var(--palette-mint);
--state-success: var(--palette-sage);
--state-error: var(--palette-rose);
font-family: 'CaskaydiaCove NFM', monospace;
font-size: 16px;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
@media (prefers-color-scheme: dark) {
:root {
--bg-app: var(--neutral-black);
--bg-surface: var(--neutral-900);
--bg-element: var(--neutral-800);
--text-primary: var(--neutral-white);
--text-secondary: var(--neutral-200);
--border-subtle: var(--neutral-800);
}
}
body {
color: var(--text-primary);
background: var(--bg-app);
}
html,
body,
#app {
height: 100%;
}
-43
View File
@@ -1,43 +0,0 @@
* {
margin: 0;
padding: 0;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
button {
background: none;
border: none;
cursor: pointer;
font-family: inherit;
font-size: inherit;
}
input {
font-family: inherit;
font-size: inherit;
color: inherit;
}
a {
text-decoration: none;
color: inherit;
}
ul {
list-style-type: none;
}
img {
max-width: 100%;
display: block;
}
table {
width: 100%;
border-collapse: collapse;
}
File diff suppressed because it is too large Load Diff

Before

Width:  |  Height:  |  Size: 6.4 MiB

@@ -1,147 +0,0 @@
<script setup lang="ts">
import { ref } from 'vue';
export type LoginFormState = {
isLoggingIn: boolean;
username: {
value: string;
error: string;
};
password: {
value: string;
error: string;
};
};
const props = defineProps<{
onValidSubmit?: (formState: LoginFormState) => Promise<void> | void;
}>();
const formState = ref<LoginFormState>({
isLoggingIn: false,
username: {
value: '',
error: '',
},
password: {
value: '',
error: '',
},
});
function handleUsernameInput(e: Event) {
formState.value.username.value = (e.currentTarget as HTMLInputElement).value;
if (formState.value.username.error.trim()) {
formState.value.username.error = '';
}
}
function handlePasswordInput(e: Event) {
formState.value.password.value = (e.currentTarget as HTMLInputElement).value;
if (formState.value.password.error.trim()) {
formState.value.password.error = '';
}
}
function validateFormState() {
let isValid = true;
if (!formState.value.username.value.trim()) {
formState.value.username.error = 'Username is required';
isValid = false;
}
if (!formState.value.password.value.trim()) {
formState.value.password.error = 'Password is required';
isValid = false;
}
return isValid;
}
async function handleSubmit() {
formState.value.isLoggingIn = true;
try {
if (validateFormState() === false) {
return;
}
if (props.onValidSubmit !== undefined) {
await props.onValidSubmit(formState.value);
}
} finally {
formState.value.isLoggingIn = false;
}
}
</script>
<template>
<form @submit.prevent="handleSubmit">
<div>
<label for="username">Username</label>
<input type="text" name="username" id="username" :value="formState.username.value" @input="handleUsernameInput" />
<div class="error">{{ formState.username.error }}</div>
</div>
<div>
<label for="password">Password</label>
<input type="password" name="password" id="password" :value="formState.password.value"
@input="handlePasswordInput" />
<div class="error">{{ formState.password.error }}</div>
</div>
<div>
<button type="submit" :disabled="formState.isLoggingIn">
Login
</button>
</div>
</form>
</template>
<style scoped>
form,
form>div {
display: flex;
flex-direction: column;
background-color: var(--bg-surface);
}
form {
gap: 1rem;
padding: 1rem;
border-radius: 0.25rem;
}
form>div {
gap: 0.25rem;
}
form>div label {
font-weight: 700;
}
form>div input {
padding: 0.5rem;
border-radius: 0.25rem;
border: 1px solid black;
background-color: var(--bg-element);
}
form>div button {
background-color: var(--brand-primary);
padding: 0.5rem 0.25rem;
border-radius: 0.25rem;
}
form>div button:disabled {
opacity: 50%;
pointer-events: none;
}
form>div .error {
color: var(--state-error);
font-size: 0.85rem;
}
</style>
@@ -1,79 +0,0 @@
<script setup lang="ts">
import LeftArrowIcon from '@/components/icons/RightArrowIcon.vue';
import { computed, ref } from 'vue';
const isCollapsed = ref(false);
const asideClasses = computed(() => ({
collapsed: isCollapsed.value,
}));
function handleToggleButtonClick() {
isCollapsed.value = !isCollapsed.value;
}
</script>
<template>
<aside :class="asideClasses">
<button
@click="handleToggleButtonClick"
type="button"
class="toggle-button"
>
<LeftArrowIcon />
</button>
</aside>
</template>
<style scoped>
aside {
--sidebar-width: 15.625rem;
--button-size: 1.5rem;
--transition-duration: 0.5s;
--transition-function: ease-in-out;
position: relative;
width: var(--sidebar-width);
height: 100%;
z-index: 999;
background: var(--bg-surface);
transition-property: width, transform;
transition-duration: var(--transition-duration);
transition-timing-function: var(--transition-function);
}
@media screen and (max-width: 48rem) {
aside {
position: absolute;
right: 0;
}
}
.toggle-button {
display: flex;
align-items: center;
justify-content: center;
position: absolute;
top: 3rem;
left: calc(-1 * var(--button-size) / 2);
height: var(--button-size);
width: var(--button-size);
background: var(--bg-element);
border-radius: 50%;
transition-property: transform;
transition-duration: var(--transition-duration);
transition-timing-function: var(--transition-function);
}
aside.collapsed {
width: calc(0.125rem + var(--button-size) / 2);
}
aside.collapsed .toggle-button {
transform: rotate(180deg);
}
.toggle-button svg {
--size: 1rem;
width: var(--size);
height: var(--size);
}
</style>
@@ -1,26 +0,0 @@
<script setup lang="ts">
import { RouterView } from 'vue-router';
import NavSidebar from './NavSidebar.vue';
</script>
<template>
<div class="layout">
<main>
<RouterView />
</main>
<NavSidebar />
</div>
</template>
<style scoped>
.layout {
display: flex;
height: 100%;
position: relative;
}
main {
flex: 1;
border: 1px solid blue;
}
</style>
@@ -1,20 +0,0 @@
<script setup lang="ts">
import { RouterView } from 'vue-router';
</script>
<template>
<main>
<RouterView />
</main>
</template>
<style scoped>
main {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
</style>
@@ -1,6 +0,0 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640">
<path
d="M566.6 342.6C579.1 330.1 579.1 309.8 566.6 297.3L406.6 137.3C394.1 124.8 373.8 124.8 361.3 137.3C348.8 149.8 348.8 170.1 361.3 182.6L466.7 288L96 288C78.3 288 64 302.3 64 320C64 337.7 78.3 352 96 352L466.7 352L361.3 457.4C348.8 469.9 348.8 490.2 361.3 502.7C373.8 515.2 394.1 515.2 406.6 502.7L566.6 342.7z" />
</svg>
</template>
@@ -1,27 +0,0 @@
import { AuthServiceFactoryKey } from "@/services/authService";
import { ClientConfig, ClientFactoryKey } from "@/services/client";
import type { UserStore } from "@/stores/userStore";
import { inject } from "vue";
export function useAuthService(store: UserStore) {
const clientFactory = inject(ClientFactoryKey);
const authServiceFactory = inject(AuthServiceFactoryKey);
if (clientFactory === undefined) {
throw new Error("Failed to inject client factory.")
}
if (authServiceFactory === undefined) {
throw new Error("Failed to inject auth service factory.")
}
const clientConfig = new ClientConfig(
{ Authorization: `Bearer ${store.user?.token}`},
true,
store.refreshAccessToken
);
const client = clientFactory.create(clientConfig);
const authService = authServiceFactory.create(client);
return authService;
}
@@ -1,27 +0,0 @@
import { ClientConfig, ClientFactoryKey } from "@/services/client";
import { InstituionServiceFactoryKey } from "@/services/institutionService";
import type { UserStore } from "@/stores/userStore";
import { inject } from "vue";
export function useInstitutionService(store: UserStore) {
const clientFactory = inject(ClientFactoryKey);
const institutionServiceFactory = inject(InstituionServiceFactoryKey);
if (clientFactory === undefined) {
throw new Error("Failed to inject client factory.")
}
if (institutionServiceFactory === undefined) {
throw new Error("Failed to inject institution service factory.")
}
const clientConfig = new ClientConfig(
{ Authorization: `Bearer ${store.user?.token}`},
true,
store.refreshAccessToken
);
const client = clientFactory.create(clientConfig);
const institutionService = institutionServiceFactory.create(client);
return institutionService;
}
-21
View File
@@ -1,21 +0,0 @@
import './assets/css/main.css';
import { createApp } from "vue";
import { createPinia } from "pinia";
import App from "./App.vue";
import router from "./router";
import { ClientFactory, ClientFactoryKey } from "./services/client";
import { AuthServiceFactory, AuthServiceFactoryKey } from "./services/authService";
import { InstituionServiceFactoryKey, InstitutionServiceFactory } from './services/institutionService';
const app = createApp(App);
app.provide(ClientFactoryKey, new ClientFactory());
app.provide(AuthServiceFactoryKey, new AuthServiceFactory());
app.provide(InstituionServiceFactoryKey, new InstitutionServiceFactory());
app.use(createPinia());
app.use(router);
app.mount("#app");
-70
View File
@@ -1,70 +0,0 @@
import { AuthService } from '@/services/authService';
import { Client, ClientConfig } from '@/services/client';
import { useUserStore } from '@/stores/userStore';
import { createRouter, createWebHistory } from 'vue-router';
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/public',
component: () => import('../components/PublicLayout.vue'),
redirect: '/public/login',
beforeEnter: () => {
const userStore = useUserStore();
if (userStore.user) {
return { path: '/' };
}
return true;
},
children: [
{
path: 'login',
component: () => import('../views/LoginView.vue'),
},
],
},
{
path: '/',
component: () => import('../components/ProtectedLayout.vue'),
beforeEnter: async () => {
const userStore = useUserStore();
if (userStore.user === null) {
return { path: '/public/login' };
}
const isExpired = userStore.user.expiresAtInSeconds < Date.now() / 1000;
if (isExpired) {
const clientConfig = new ClientConfig(
{ Authorization: `Bearer ${userStore.user.token}` },
true
);
const client = new Client(clientConfig);
const authService = new AuthService(client);
const refreshResult = await authService.refreshToken();
if (refreshResult.err) {
userStore.logUserOut();
return { path: '/public/login' };
}
userStore.logUserIn(refreshResult.val.accessToken);
}
return true;
},
children: [
{
path: '/',
component: () => import('../views/HomeView.vue'),
},
],
},
],
});
export default router;
@@ -1,112 +0,0 @@
import { Err, Ok, Result } from 'ts-results';
import { type InjectionKey } from 'vue';
import { ClientRequestWithBody, type IClient } from './client';
type AuthServiceFactoryKeyType = InjectionKey<IAuthServiceFactory>;
export const AuthServiceFactoryKey: AuthServiceFactoryKeyType = Symbol('AuthServiceFactory');
export interface IAuthServiceFactory {
create: (client: IClient) => IAuthService;
}
export class AuthServiceFactory implements IAuthServiceFactory {
create(client: IClient): IAuthService {
return new AuthService(client);
}
}
export interface IAuthService {
login: (username: string, password: string) => Promise<Result<LoginResponse, Error[]>>;
refreshToken: () => Promise<Result<LoginResponse, Error[]>>;
}
export class AuthService implements IAuthService {
private readonly client: IClient;
private readonly endpoints = {
login: '/api/auth/login',
refreshToken: '/api/auth/refresh',
};
constructor(client: IClient) {
this.client = client;
}
async login(username: string, password: string) {
const request = new ClientRequestWithBody(
this.endpoints.login,
undefined,
new LoginRequest(username, password)
);
try {
const res = await this.client.post(request);
if (res.status === 400) {
const body = await res.json();
const validationErrors = body.errors as Record<string, string[]>;
const errors = Object.values(validationErrors)
.flat()
.map(e => new Error(e));
return Err(errors);
}
if (res.status === 401) {
return Err([new Error('Email/Password combination is not valid')]);
}
if (res.ok === false) {
return Err([new Error('Login failed. Please try again.')]);
}
const body = await res.json();
return Ok(body as LoginResponse);
} catch (e) {
console.error(e);
return Err([new Error('Login failed. Please try again.')]);
}
}
async refreshToken() {
const request = new ClientRequestWithBody(this.endpoints.refreshToken, undefined, undefined);
try {
const res = await this.client.post(request);
if (res.status === 401) {
return Err([new Error('Refresh and/or access token is not valid')]);
}
if (res.ok === false) {
return Err([new Error('Refreshing token failed')]);
}
const body = await res.json();
return Ok(body as LoginResponse);
} catch (e) {
console.error(e);
return Err([new Error('Refreshing token failed.')]);
}
}
}
class BaseAuthRequest {
readonly username: string;
readonly password: string;
constructor(username: string, password: string) {
this.username = username;
this.password = password;
}
}
class LoginRequest extends BaseAuthRequest {
constructor(username: string, password: string) {
super(username, password);
}
}
type LoginResponse = {
accessToken: string;
};
-151
View File
@@ -1,151 +0,0 @@
import type { InjectionKey } from 'vue';
type ClientKeyType = InjectionKey<IClientFactory>;
export const ClientFactoryKey: ClientKeyType = Symbol('AuthServiceFactory');
export interface IClientFactory {
create: (config?: ClientConfig) => IClient;
}
export class ClientFactory implements IClientFactory {
create(config?: ClientConfig): IClient {
return new Client(config);
}
}
export class ClientRequest {
readonly url: string;
readonly config?: RequestInit;
constructor(url: string, config?: RequestInit) {
this.url = url;
this.config = config;
}
}
export class ClientRequestWithBody<T> extends ClientRequest {
body: T | undefined;
constructor(url: string, config?: RequestInit, body?: T) {
super(url, config);
this.body = body;
}
}
export type UnauthorizedResponseHandler =
| ((originalRequest: Request) => Promise<{ response: Response; accessToken: string }>)
| undefined;
export class ClientConfig {
authHeader: Record<string, string> | undefined;
includeCredentials: boolean | undefined;
unauthorizedResponseHandler: UnauthorizedResponseHandler;
constructor(
authHeader?: Record<string, string> | undefined,
includeCredentials?: boolean | undefined,
unauthorizedResponseHandler?: UnauthorizedResponseHandler
) {
this.authHeader = authHeader;
this.includeCredentials = includeCredentials;
this.unauthorizedResponseHandler = unauthorizedResponseHandler;
}
}
export interface IClient {
get: (req: ClientRequest) => Promise<Response>;
post: <T>(req: ClientRequestWithBody<T>) => Promise<Response>;
put: <T>(req: ClientRequestWithBody<T>) => Promise<Response>;
patch: <T>(req: ClientRequestWithBody<T>) => Promise<Response>;
delete: (req: ClientRequest) => Promise<Response>;
}
export class Client implements IClient {
private readonly _clientConfig: ClientConfig;
constructor(clientConfig?: ClientConfig) {
this._clientConfig = clientConfig ?? new ClientConfig(undefined, true, undefined);
}
private async request(url: string, config?: RequestInit): Promise<Response> {
const headers = {
...config?.headers,
...this._clientConfig?.authHeader,
};
const credentials = this._clientConfig?.includeCredentials
? ('include' as RequestCredentials)
: ('omit' as RequestCredentials);
const requestConfig = {
...config,
headers: headers,
credentials: credentials,
};
const firstTryRequest = new Request(url, requestConfig);
const secondTryRequest = new Request(url, requestConfig);
let response = await fetch(firstTryRequest);
if (response.status === 401 && this._clientConfig?.unauthorizedResponseHandler) {
const retryResult = await this._clientConfig.unauthorizedResponseHandler(secondTryRequest);
this._clientConfig.authHeader = { Authorization: `Bearer ${retryResult.accessToken}` };
response = retryResult.response;
}
return response;
}
async get({ url, config }: { url: string; config?: RequestInit }) {
const requestConfig = { ...config, method: 'GET' };
return await this.request(url, requestConfig);
}
async post<T>(req: ClientRequestWithBody<T>) {
const requestConfig = {
...req?.config,
method: 'POST',
body: JSON.stringify(req?.body),
headers: {
...req?.config?.headers,
'Content-Type': 'application/json',
},
};
return await this.request(req?.url, requestConfig);
}
async put<T>(req: ClientRequestWithBody<T>) {
const requestConfig = {
...req?.config,
method: 'PUT',
body: JSON.stringify(req?.body),
headers: {
...req?.config?.headers,
'Content-Type': 'application/json',
},
};
return await this.request(req?.url, requestConfig);
}
async patch<T>(req: ClientRequestWithBody<T>) {
const requestConfig = {
...req?.config,
method: 'PATCH',
body: JSON.stringify(req?.body),
headers: {
...req?.config?.headers,
'Content-Type': 'application/json',
},
};
return await this.request(req?.url, requestConfig);
}
async delete(req: ClientRequest) {
const requestConfig = { ...req?.config, method: 'DELETE' };
return await this.request(req?.url, requestConfig);
}
}
@@ -1,91 +0,0 @@
import { Err, Ok, Result } from 'ts-results';
import { type InjectionKey } from 'vue';
import { ClientRequestWithBody, type IClient } from './client';
type InstitutionServiceFactoryKeyType = InjectionKey<IInstitutionServiceFactory>;
export const InstituionServiceFactoryKey: InstitutionServiceFactoryKeyType =
Symbol('AuthServiceFactory');
export interface IInstitutionServiceFactory {
create: (client: IClient) => IInstitutionService;
}
export class InstitutionServiceFactory implements InstitutionServiceFactory {
create(client: IClient): IInstitutionService {
return new InstitutionService(client);
}
}
export interface IInstitutionService {
createLinkToken: () => Promise<Result<LinkTokenResponse, Error[]>>;
connect: (publicToken: string, plaidInstitutionId: string) => Promise<Result<boolean, Error[]>>;
}
export class InstitutionService implements IInstitutionService {
private readonly client: IClient;
private readonly endpoints = {
link: '/api/institutions/link',
connect: '/api/institutions/connect',
};
constructor(client: IClient) {
this.client = client;
}
async createLinkToken() {
const request = new ClientRequestWithBody(this.endpoints.link);
try {
const res = await this.client.post(request);
if (res.ok === false) {
return Err([new Error('Failed to create link token')]);
}
const body = await res.json();
return Ok(body as LinkTokenResponse);
} catch (e) {
console.error(e);
return Err([new Error('Failed to create link token')]);
}
}
async connect(publicToken: string, plaidInstitutionId: string) {
const request = new ClientRequestWithBody(this.endpoints.connect, undefined, {
publicToken,
plaidInstitutionId,
});
try {
const res = await this.client.post(request);
if (res.status === 400) {
const body = await res.json();
const validationErrors = body.errors as Record<string, string[]>;
const errors = Object.values(validationErrors)
.flat()
.map(e => new Error(e));
return Err(errors);
}
if (res.status === 401) {
return Err([new Error('Please sign in and try again.')]);
}
if (res.status === 409) {
return Err([new Error('You have already linked this institution.')]);
}
return Ok(true);
} catch (e) {
console.error(e);
return Err([new Error('Failed to connect institution')]);
}
}
}
type LinkTokenResponse = {
linkToken: string;
};
-83
View File
@@ -1,83 +0,0 @@
import { AuthServiceFactoryKey } from '@/services/authService';
import { ClientConfig, ClientFactoryKey } from '@/services/client';
import { jwtDecode } from 'jwt-decode';
import { defineStore } from 'pinia';
import { inject, ref } from 'vue';
export const USER_KEY = 'fiscalos_auth';
export type User = {
id: string;
expiresAtInSeconds: number;
token: string;
};
type JwtTokenPayload = {
sub: string;
exp: number;
};
function getUserFromLocalStorage(): User | null {
const user = localStorage.getItem(USER_KEY);
return user === null ? null : JSON.parse(user);
}
export type UserStore = ReturnType<typeof useUserStore>;
export const useUserStore = defineStore('userStore', () => {
const user = ref<User | null>(getUserFromLocalStorage());
const clientFactory = inject(ClientFactoryKey);
const authServiceFactory = inject(AuthServiceFactoryKey);
function logUserIn(jwtToken: string) {
const { sub, exp } = jwtDecode<JwtTokenPayload>(jwtToken);
const loggedInUser: User = {
id: sub,
expiresAtInSeconds: exp,
token: jwtToken,
};
localStorage.setItem(USER_KEY, JSON.stringify(loggedInUser));
user.value = loggedInUser;
}
function logUserOut() {
localStorage.removeItem(USER_KEY);
user.value = null;
}
async function refreshAccessToken(originalRequest: Request) {
const unauthorizedResponse = new Response(null, { status: 401 });
if (user.value === null || !clientFactory || !authServiceFactory) {
logUserOut();
return { response: unauthorizedResponse, accessToken: '' };
}
const client = clientFactory.create(
new ClientConfig({ Authorization: `Bearer ${user.value.token}` }, true)
);
const authService = authServiceFactory.create(client);
const refreshResult = await authService.refreshToken();
if (refreshResult.err) {
logUserOut();
return { response: unauthorizedResponse, accessToken: '' };
}
logUserIn(refreshResult.val.accessToken);
originalRequest.headers.set('Authorization', `Bearer ${refreshResult.val.accessToken}`);
const response = await fetch(originalRequest);
return { response, accessToken: refreshResult.val.accessToken };
}
return {
user: user,
logUserIn,
logUserOut,
refreshAccessToken,
};
});
-82
View File
@@ -1,82 +0,0 @@
<script setup lang="ts">
import { useInstitutionService } from '@/composables/useInstitutionService';
import { useUserStore } from '@/stores/userStore';
import { usePlaidLink, type PlaidLinkOptions } from '@jcss/vue-plaid-link';
import { nextTick, ref } from 'vue';
import { useRouter } from 'vue-router';
const userStore = useUserStore();
const router = useRouter();
const institutionService = useInstitutionService(userStore);
const plaidOptions = ref<PlaidLinkOptions>({
token: '',
onSuccess: async (publicToken, metadata) => {
if (metadata.institution == null) {
alert('Institution information is missing from Plaid response. Please try again.');
return;
}
const connectResult = await institutionService.connect(
publicToken,
metadata.institution.institution_id
);
if (connectResult.err) {
alert(connectResult.val.map(e => e.message).join('\n'))
return;
}
},
onLoad: () => console.log('loaded'),
onExit: () => console.log('exit'),
});
const { open } = usePlaidLink(plaidOptions);
function handleLogout() {
// TODO: Also need to log user
// out on the server...which is
// basically just clearing
// refresh token cookie and revoking
// it in the database
userStore.logUserOut();
router.push({ path: '/public/login' });
}
async function handleAddInstitutionClick() {
const linkTokenResult = await institutionService.createLinkToken();
if (linkTokenResult.err) {
alert(linkTokenResult.val.map(e => e.message).join('\n'));
return;
}
plaidOptions.value = {
...plaidOptions.value,
token: linkTokenResult.val.linkToken,
};
await nextTick();
open();
}
</script>
<template>
<h1>Home View</h1>
<button class="logout-button" type="button" @click="handleLogout">
Logout
</button>
<div>
<button class="add-institution-button" type="button" @click="handleAddInstitutionClick">
Add Institution
</button>
</div>
</template>
<style scoped>
.logout-button,
.add-institution-button {
background: var(--bg-element);
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
}
</style>
-28
View File
@@ -1,28 +0,0 @@
<script setup lang="ts">
import LoginForm, { type LoginFormState } from '@/components/LoginForm.vue';
import { useAuthService } from '@/composables/useAuthService';
import { useUserStore } from '@/stores/userStore';
import { useRouter } from 'vue-router';
const router = useRouter();
const userStore = useUserStore();
const authService = useAuthService(userStore);
async function handleValideSubmit(formState: LoginFormState) {
const loginResult = await authService.login(formState.username.value, formState.password.value);
if (loginResult.err) {
alert(loginResult.val.map(e => e.message).join('\n'));
return;
}
userStore.logUserIn(loginResult.val.accessToken);
router.push('/');
}
</script>
<template>
<LoginForm :onValidSubmit="handleValideSubmit" />
</template>
<style scoped></style>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

-12
View File
@@ -1,12 +0,0 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
"exclude": ["src/**/__tests__/*"],
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"paths": {
"@/*": ["./src/*"]
}
}
}
-14
View File
@@ -1,14 +0,0 @@
{
"files": [],
"references": [
{
"path": "./tsconfig.node.json"
},
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.vitest.json"
}
]
}
-19
View File
@@ -1,19 +0,0 @@
{
"extends": "@tsconfig/node24/tsconfig.json",
"include": [
"vite.config.*",
"vitest.config.*",
"cypress.config.*",
"nightwatch.conf.*",
"playwright.config.*",
"eslint.config.*"
],
"compilerOptions": {
"noEmit": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"module": "ESNext",
"moduleResolution": "Bundler",
"types": ["node"]
}
}
-11
View File
@@ -1,11 +0,0 @@
{
"extends": "./tsconfig.app.json",
"include": ["src/**/__tests__/*", "env.d.ts"],
"exclude": [],
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.vitest.tsbuildinfo",
"lib": [],
"types": ["node", "jsdom"]
}
}
-34
View File
@@ -1,34 +0,0 @@
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vueJsx from '@vitejs/plugin-vue-jsx'
import vueDevTools from 'vite-plugin-vue-devtools'
import mkcert from 'vite-plugin-mkcert';
export default defineConfig({
publicDir: 'static',
plugins: [
vue(),
vueJsx(),
vueDevTools(),
mkcert(),
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
},
},
server: {
host: true,
port: parseInt(process.env.PORT ?? '5173'),
proxy: {
'/api': {
target: process.env.services__api__https__0 || process.env.services__api__http__0,
changeOrigin: true,
rewrite: path => path.replace(/^\/api/, ''),
secure: false,
},
},
},
})
-14
View File
@@ -1,14 +0,0 @@
import { fileURLToPath } from 'node:url'
import { mergeConfig, defineConfig, configDefaults } from 'vitest/config'
import viteConfig from './vite.config'
export default mergeConfig(
viteConfig,
defineConfig({
test: {
environment: 'jsdom',
exclude: [...configDefaults.exclude, 'e2e/**'],
root: fileURLToPath(new URL('./', import.meta.url)),
},
}),
)
@@ -21,19 +21,18 @@ internal sealed class HttpResponseMessageAssertions(
HttpStatusCode expectedStatusCode
)
{
_chain.ForCondition(Subject.StatusCode == expectedStatusCode)
.FailWith(
"Expected response status code to be {0}, but found {1}",
expectedStatusCode,
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
);
_chain.ForCondition(Subject.StatusCode == expectedStatusCode)
.FailWith(
"Expected response status code to be {0}, but found {1}",
expectedStatusCode,
Subject.StatusCode
);
var content = await Subject.Content.ReadFromJsonAsync<T>();
@@ -1,10 +1,10 @@
namespace FiscalOS.API.Tests.Infra;
namespace FiscalOS.API.Tests.Common;
public interface ISerializableDictionary<TKey, TValue> : IDictionary<TKey, TValue>, IXunitSerializable
{
}
public class SerializableDictionary<TKey, TValue>
public class SerializableDictionary<TKey, TValue>
: Dictionary<TKey, TValue>, ISerializableDictionary<TKey, TValue> where TKey : notnull
{
public SerializableDictionary() { }
@@ -1,128 +0,0 @@
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;
}
}
@@ -1,3 +1,5 @@
using Microsoft.Extensions.Configuration;
namespace FiscalOS.API.Tests.Infra;
public class TestApi : WebApplicationFactory<Program>
@@ -1,7 +1,7 @@
using Account = FiscalOS.Core.Accounts.Account;
using Institution = FiscalOS.Core.Accounts.Institution;
namespace FiscalOS.API.Tests.Integration.Accounts;
namespace FiscalOS.API.Tests.Integration;
public class AddTests(TestApi testApi) : IntegrationTest(testApi)
{
@@ -16,14 +16,19 @@ public class AddTests(TestApi testApi) : IntegrationTest(testApi)
}
[Fact]
public async Task Add_WhenCalledWithoutRequiredInformation_ItShouldReturn400WithProblemDetails()
public async Task Add_WhenCalledWithoutInstitutionIdOrAccountIdOrAccountName_ItShouldReturn400WithProblemDetails()
{
using var request = HttpRequestBuilder.New()
.Post(AddUri)
.WithUserId(Guid.NewGuid())
.WithBody(new { })
var jwt = JwtTokenBuilder.New()
.WithClaim(JwtRegisteredClaimNames.Sub, Guid.NewGuid().ToString())
.Build();
using var content = new StringContent(JsonSerializer.Serialize(new { }), Encoding.UTF8, "application/json");
using var request = new HttpRequestMessage(HttpMethod.Post, AddUri)
{
Content = content
};
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", jwt);
var response = await Client.SendAsync(request, TestContext.Current.CancellationToken);
await response.Should().BeValidationProblemDetails(new Dictionary<string, string[]>()
@@ -31,24 +36,28 @@ public class AddTests(TestApi testApi) : IntegrationTest(testApi)
["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",
})
var jwt = JwtTokenBuilder.New()
.WithClaim(JwtRegisteredClaimNames.Sub, Guid.NewGuid().ToString())
.Build();
var json = JsonSerializer.Serialize(new
{
plaidAccountId = "accountId",
plaidAccountName = "Some Account",
});
using var content = new StringContent(json, Encoding.UTF8, "application/json");
using var request = new HttpRequestMessage(HttpMethod.Post, AddUri)
{
Content = content
};
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", jwt);
var response = await Client.SendAsync(request, TestContext.Current.CancellationToken);
await response.Should().BeValidationProblemDetails(new Dictionary<string, string[]>()
@@ -60,17 +69,22 @@ public class AddTests(TestApi testApi) : IntegrationTest(testApi)
[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",
})
var jwt = JwtTokenBuilder.New()
.WithClaim(JwtRegisteredClaimNames.Sub, Guid.NewGuid().ToString())
.Build();
var json = JsonSerializer.Serialize(new
{
plaidInstitutionId = "institutionId",
plaidAccountName = "Some Account",
});
using var content = new StringContent(json, Encoding.UTF8, "application/json");
using var request = new HttpRequestMessage(HttpMethod.Post, AddUri)
{
Content = content
};
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", jwt);
var response = await Client.SendAsync(request, TestContext.Current.CancellationToken);
await response.Should().BeValidationProblemDetails(new Dictionary<string, string[]>()
@@ -82,17 +96,22 @@ public class AddTests(TestApi testApi) : IntegrationTest(testApi)
[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",
})
var jwt = JwtTokenBuilder.New()
.WithClaim(JwtRegisteredClaimNames.Sub, Guid.NewGuid().ToString())
.Build();
var json = JsonSerializer.Serialize(new
{
plaidInstitutionId = "institutionId",
plaidAccountId = "accountId",
});
using var content = new StringContent(json, Encoding.UTF8, "application/json");
using var request = new HttpRequestMessage(HttpMethod.Post, AddUri)
{
Content = content
};
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", jwt);
var response = await Client.SendAsync(request, TestContext.Current.CancellationToken);
await response.Should().BeValidationProblemDetails(new Dictionary<string, string[]>()
@@ -104,18 +123,23 @@ public class AddTests(TestApi testApi) : IntegrationTest(testApi)
[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",
})
var jwt = JwtTokenBuilder.New()
.WithClaim(JwtRegisteredClaimNames.Sub, Guid.NewGuid().ToString())
.Build();
var json = JsonSerializer.Serialize(new
{
plaidInstitutionId = "id",
plaidAccountId = "id",
plaidAccountName = "Some Account",
});
using var content = new StringContent(json, Encoding.UTF8, "application/json");
using var request = new HttpRequestMessage(HttpMethod.Post, AddUri)
{
Content = content,
};
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", jwt);
var response = await Client.SendAsync(request, TestContext.Current.CancellationToken);
await response.Should().BeProblemDetails(HttpStatusCode.Unauthorized);
@@ -138,18 +162,23 @@ public class AddTests(TestApi testApi) : IntegrationTest(testApi)
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",
})
var jwt = JwtTokenBuilder.New()
.WithClaim(JwtRegisteredClaimNames.Sub, user.Id.ToString())
.Build();
var json = JsonSerializer.Serialize(new
{
plaidInstitutionId = "id",
plaidAccountId = "id",
plaidAccountName = "Some Account",
});
using var content = new StringContent(json, Encoding.UTF8, "application/json");
using var request = new HttpRequestMessage(HttpMethod.Post, AddUri)
{
Content = content,
};
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", jwt);
var response = await Client.SendAsync(request, TestContext.Current.CancellationToken);
await response.Should().BeValidationProblemDetails(new Dictionary<string, string[]>()
@@ -187,18 +216,23 @@ public class AddTests(TestApi testApi) : IntegrationTest(testApi)
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",
})
var jwt = JwtTokenBuilder.New()
.WithClaim(JwtRegisteredClaimNames.Sub, user.Id.ToString())
.Build();
var json = JsonSerializer.Serialize(new
{
plaidInstitutionId = ((PlaidMetadata)institution.Metadata!).PlaidId,
plaidAccountId = ((PlaidAccountMetadata)account.Metadata!).PlaidId,
plaidAccountName = ((PlaidAccountMetadata)account.Metadata).PlaidName,
});
using var content = new StringContent(json, Encoding.UTF8, "application/json");
using var request = new HttpRequestMessage(HttpMethod.Post, AddUri)
{
Content = content,
};
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", jwt);
var response = await Client.SendAsync(request, TestContext.Current.CancellationToken);
await response.Should().BeProblemDetails(HttpStatusCode.Conflict);
@@ -227,24 +261,25 @@ public class AddTests(TestApi testApi) : IntegrationTest(testApi)
return (user, institution);
}, TestContext.Current.CancellationToken);
var jwt = JwtTokenBuilder.New()
.WithClaim(JwtRegisteredClaimNames.Sub, user.Id.ToString())
.Build();
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 json = JsonSerializer.Serialize(new
{
plaidInstitutionId = ((PlaidMetadata)institution.Metadata!).PlaidId,
plaidAccountId = newAccountId,
plaidAccountName = newAccountName,
});
using var content = new StringContent(json, Encoding.UTF8, "application/json");
using var request = new HttpRequestMessage(HttpMethod.Post, AddUri)
{
Content = content,
};
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", jwt);
var response = await Client.SendAsync(request, TestContext.Current.CancellationToken);
@@ -254,9 +289,6 @@ public class AddTests(TestApi testApi) : IntegrationTest(testApi)
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
);
@@ -268,13 +300,5 @@ public class AddTests(TestApi testApi) : IntegrationTest(testApi)
((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
);
}
}
@@ -1,20 +1,15 @@
using Institution = FiscalOS.Core.Accounts.Institution;
namespace FiscalOS.API.Tests.Integration.Institutions;
namespace FiscalOS.API.Tests.Integration;
public class ConnectTests(TestApi testApi) : IntegrationTest(testApi)
{
private static readonly Uri ConnectUri = new("/institutions/connect", UriKind.Relative);
private static readonly Uri ConnectUri = new("/accounts/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);
var res = await Client.PostAsJsonAsync(ConnectUri, new { }, TestContext.Current.CancellationToken);
await res.Should().BeProblemDetails(HttpStatusCode.Unauthorized);
}
@@ -22,12 +17,17 @@ public class ConnectTests(TestApi testApi) : IntegrationTest(testApi)
[Fact]
public async Task Connect_WhenCalledWithoutPublicTokenOrPlaidInstitutionId_ItShouldReturn400WithProblemDetails()
{
using var request = HttpRequestBuilder.New()
.Post(ConnectUri)
.WithUserId(Guid.NewGuid())
.WithBody(new { })
var jwt = JwtTokenBuilder.New()
.WithClaim(JwtRegisteredClaimNames.Sub, Guid.NewGuid().ToString())
.Build();
using var content = new StringContent(JsonSerializer.Serialize(new { }), Encoding.UTF8, "application/json");
using var request = new HttpRequestMessage(HttpMethod.Post, ConnectUri)
{
Content = content,
};
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", jwt);
var response = await Client.SendAsync(request, TestContext.Current.CancellationToken);
await response.Should().BeValidationProblemDetails(new Dictionary<string, string[]>()
@@ -40,12 +40,18 @@ public class ConnectTests(TestApi testApi) : IntegrationTest(testApi)
[Fact]
public async Task Connect_WhenCalledWithoutPublicToken_ItShouldReturn400WithProblemDetails()
{
using var request = HttpRequestBuilder.New()
.Post(ConnectUri)
.WithUserId(Guid.NewGuid())
.WithBody(new { plaidInstitutionId = "id" })
var jwt = JwtTokenBuilder.New()
.WithClaim(JwtRegisteredClaimNames.Sub, Guid.NewGuid().ToString())
.Build();
var json = JsonSerializer.Serialize(new { plaidInstitutionId = "id" });
using var content = new StringContent(json, Encoding.UTF8, "application/json");
using var request = new HttpRequestMessage(HttpMethod.Post, ConnectUri)
{
Content = content,
};
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", jwt);
var response = await Client.SendAsync(request, TestContext.Current.CancellationToken);
await response.Should().BeValidationProblemDetails(new Dictionary<string, string[]>()
@@ -57,12 +63,18 @@ public class ConnectTests(TestApi testApi) : IntegrationTest(testApi)
[Fact]
public async Task Connect_WhenCalledWithoutPlaidInstitutionId_ItShouldReturn400WithProblemDetails()
{
using var request = HttpRequestBuilder.New()
.Post(ConnectUri)
.WithUserId(Guid.NewGuid())
.WithBody(new { publicToken = "token" })
var jwt = JwtTokenBuilder.New()
.WithClaim(JwtRegisteredClaimNames.Sub, Guid.NewGuid().ToString())
.Build();
var json = JsonSerializer.Serialize(new { publicToken = "token" });
using var content = new StringContent(json, Encoding.UTF8, "application/json");
using var request = new HttpRequestMessage(HttpMethod.Post, ConnectUri)
{
Content = content,
};
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", jwt);
var response = await Client.SendAsync(request, TestContext.Current.CancellationToken);
await response.Should().BeValidationProblemDetails(new Dictionary<string, string[]>()
@@ -74,16 +86,22 @@ public class ConnectTests(TestApi testApi) : IntegrationTest(testApi)
[Fact]
public async Task Connect_WhenCalledWithNonExistentUser_ItShouldReturn401WithProblemDetails()
{
using var request = HttpRequestBuilder.New()
.Post(ConnectUri)
.WithUserId(Guid.NewGuid())
.WithBody(new
{
publicToken = "token",
plaidInstitutionId = "id",
})
var jwt = JwtTokenBuilder.New()
.WithClaim(JwtRegisteredClaimNames.Sub, Guid.NewGuid().ToString())
.Build();
var json = JsonSerializer.Serialize(new
{
publicToken = "token",
plaidInstitutionId = "id",
});
using var content = new StringContent(json, Encoding.UTF8, "application/json");
using var request = new HttpRequestMessage(HttpMethod.Post, ConnectUri)
{
Content = content,
};
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", jwt);
var response = await Client.SendAsync(request, TestContext.Current.CancellationToken);
await response.Should().BeProblemDetails(HttpStatusCode.Unauthorized);
@@ -111,16 +129,22 @@ public class ConnectTests(TestApi testApi) : IntegrationTest(testApi)
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,
})
var jwt = JwtTokenBuilder.New()
.WithClaim(JwtRegisteredClaimNames.Sub, user.Id.ToString())
.Build();
var json = JsonSerializer.Serialize(new
{
publicToken = "token",
plaidInstitutionId = ((PlaidMetadata)institution.Metadata!).PlaidId,
});
using var content = new StringContent(json, Encoding.UTF8, "application/json");
using var request = new HttpRequestMessage(HttpMethod.Post, ConnectUri)
{
Content = content,
};
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", jwt);
var response = await Client.SendAsync(request, TestContext.Current.CancellationToken);
await response.Should().BeProblemDetails(HttpStatusCode.Conflict);
@@ -152,16 +176,22 @@ public class ConnectTests(TestApi testApi) : IntegrationTest(testApi)
return (user, publicTokenResponse.PublicToken);
}, TestContext.Current.CancellationToken);
using var request = HttpRequestBuilder.New()
.Post(ConnectUri)
.WithUserId(user.Id)
.WithBody(new
{
publicToken,
plaidInstitutionId,
})
var jwt = JwtTokenBuilder.New()
.WithClaim(JwtRegisteredClaimNames.Sub, user.Id.ToString())
.Build();
var json = JsonSerializer.Serialize(new
{
publicToken,
plaidInstitutionId,
});
using var content = new StringContent(json, Encoding.UTF8, "application/json");
using var request = new HttpRequestMessage(HttpMethod.Post, ConnectUri)
{
Content = content,
};
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", jwt);
var response = await Client.SendAsync(request, TestContext.Current.CancellationToken);
response.StatusCode.Should().Be(HttpStatusCode.OK);
@@ -1,116 +0,0 @@
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));
}
}
@@ -1,58 +0,0 @@
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();
}
}
@@ -1,23 +1,22 @@
namespace FiscalOS.API.Tests.Integration.Auth;
using FiscalOS.API.Tests.Common;
namespace FiscalOS.API.Tests.Integration;
public class LoginTests(TestApi testApi) : IntegrationTest(testApi)
{
private static readonly Uri LoginUri = new("/auth/login", UriKind.Relative);
private static readonly Uri LoginUri = new("/login", UriKind.Relative);
[Theory]
[ClassData<LoginValidationTestCases>]
public async Task Login_WhenUserSubmitsInvalidRequest_ItShouldReturn400WithProblemDetails(LoginValidationTestCase tc)
{
using var request = HttpRequestBuilder.New()
.Post(LoginUri)
.WithBody(new
{
username = tc.Username,
password = tc.Password,
})
.Build();
var req = new
{
username = tc.Username,
password = tc.Password,
};
var res = await Client.SendAsync(request, TestContext.Current.CancellationToken);
var res = await Client.PostAsJsonAsync(LoginUri, req, TestContext.Current.CancellationToken);
await res.Should().BeValidationProblemDetails(tc.ExpectedErrors);
}
@@ -25,16 +24,13 @@ public class LoginTests(TestApi testApi) : IntegrationTest(testApi)
[Fact]
public async Task Login_WhenUserDoesNotExist_ItShouldReturn401WithProblemDetails()
{
using var request = HttpRequestBuilder.New()
.Post(LoginUri)
.WithBody(new
{
username = "Test",
password = "@Password2",
})
.Build();
var req = new
{
username = "Test",
password = "@Password2",
};
var res = await Client.SendAsync(request, TestContext.Current.CancellationToken);
var res = await Client.PostAsJsonAsync(LoginUri, req, TestContext.Current.CancellationToken);
await res.Should().BeProblemDetails(HttpStatusCode.Unauthorized);
}
@@ -53,16 +49,13 @@ public class LoginTests(TestApi testApi) : IntegrationTest(testApi)
await context.SaveChangesAsync(ct);
}, TestContext.Current.CancellationToken);
using var request = HttpRequestBuilder.New()
.Post(LoginUri)
.WithBody(new
{
username = "Stevan",
password = "@Password2",
})
.Build();
var req = new
{
username = "Stevan",
password = "@Password2",
};
var res = await Client.SendAsync(request, TestContext.Current.CancellationToken);
var res = await Client.PostAsJsonAsync(LoginUri, req, TestContext.Current.CancellationToken);
await res.Should().BeProblemDetails(HttpStatusCode.Unauthorized);
}
@@ -81,19 +74,16 @@ public class LoginTests(TestApi testApi) : IntegrationTest(testApi)
await context.SaveChangesAsync(ct);
}, TestContext.Current.CancellationToken);
using var request = HttpRequestBuilder.New()
.Post(LoginUri)
.WithBody(new
{
username = "Stevan",
password = "@Password1",
})
.Build();
var req = new
{
username = "Stevan",
password = "@Password1",
};
var res = await Client.SendAsync(request, TestContext.Current.CancellationToken);
var res = await Client.PostAsJsonAsync(LoginUri, req, TestContext.Current.CancellationToken);
res.Should().HaveSetCookieHeader("fiscalos_refresh_cookie");
await res.Should().BeJsonContentOfType<API.Auth.Login.Response>(HttpStatusCode.OK);
await res.Should().BeJsonContentOfType<Login.Response>(HttpStatusCode.OK);
}
}
@@ -1,8 +1,8 @@
namespace FiscalOS.API.Tests.Integration.Auth;
namespace FiscalOS.API.Tests.Integration;
public class RefreshTests(TestApi testApi) : IntegrationTest(testApi)
{
private static readonly Uri RefreshUri = new("/auth/refresh", UriKind.Relative);
private static readonly Uri RefreshUri = new("/refresh", UriKind.Relative);
[Fact]
public async Task Refresh_WhenCalledWithNoAccessToken_ItShouldReturn401WithProblemDetails()
@@ -15,12 +15,14 @@ public class RefreshTests(TestApi testApi) : IntegrationTest(testApi)
[Fact]
public async Task Refresh_WhenCalledWithNonExistentRefreshToken_ItShouldReturn400WithProblemDetails()
{
using var request = HttpRequestBuilder.New()
.Post(RefreshUri)
.WithUserId(Guid.NewGuid())
.WithRefreshCookie("nonexistenttoken")
var jwt = JwtTokenBuilder.New()
.WithClaim(JwtRegisteredClaimNames.Sub, Guid.NewGuid().ToString())
.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);
@@ -51,12 +53,14 @@ public class RefreshTests(TestApi testApi) : IntegrationTest(testApi)
return (new User[] { user1, user2 }, refreshToken1);
}, TestContext.Current.CancellationToken);
using var request = HttpRequestBuilder.New()
.Post(RefreshUri)
.WithUserId(users[0].Id)
.WithRefreshCookie(refreshToken.Token)
var jwt = JwtTokenBuilder.New()
.WithClaim(JwtRegisteredClaimNames.Sub, users[0].Id.ToString())
.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);
@@ -94,12 +98,14 @@ public class RefreshTests(TestApi testApi) : IntegrationTest(testApi)
return (user, refreshToken);
}, TestContext.Current.CancellationToken);
using var request = HttpRequestBuilder.New()
.Post(RefreshUri)
.WithUserId(user.Id)
.WithRefreshCookie(refreshToken.Token)
var jwt = JwtTokenBuilder.New()
.WithClaim(JwtRegisteredClaimNames.Sub, user.Id.ToString())
.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);
@@ -126,12 +132,14 @@ public class RefreshTests(TestApi testApi) : IntegrationTest(testApi)
return (user, refreshToken);
}, TestContext.Current.CancellationToken);
using var request = HttpRequestBuilder.New()
.Post(RefreshUri)
.WithUserId(user.Id)
.WithRefreshCookie(refreshToken.Token)
var jwt = JwtTokenBuilder.New()
.WithClaim(JwtRegisteredClaimNames.Sub, user.Id.ToString())
.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);
@@ -160,19 +168,19 @@ public class RefreshTests(TestApi testApi) : IntegrationTest(testApi)
return (user, refreshToken);
}, TestContext.Current.CancellationToken);
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)
var jwt = JwtTokenBuilder.New()
.WithClaim(JwtRegisteredClaimNames.Sub, user.Id.ToString())
.WithExpiresAt(DateTime.UtcNow.AddMinutes(accessTokenExpiresAtOffset))
.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<API.Auth.Refresh.Response>(HttpStatusCode.OK);
await response.Should().BeJsonContentOfType<Refresh.Response>(HttpStatusCode.OK);
var oldRefreshTokenInDb = await ExecuteAsync(
async (context, ct) => await context.Set<RefreshToken>()

Some files were not shown because too many files have changed in this diff Show More