Compare commits

...
Author SHA1 Message Date
Stevan Freeborn eeab35b773 feat(api): implement storing connected insitution in db 2026-02-12 20:49:12 -06:00
Stevan Freeborn 6bf317d480 feat(infra): implement plaid service 2026-02-12 20:48:49 -06:00
Stevan Freeborn 3b6bea1247 refactor(infra): remove unneeded qualifiers 2026-02-12 20:48:16 -06:00
Stevan Freeborn ba66c1396c tests(api.tests): pass cancellation token through delegate 2026-02-12 20:47:40 -06:00
Stevan Freeborn 5fa2d72cee chore(api.tests): add example test settings file 2026-02-12 20:46:57 -06:00
Stevan Freeborn 20ec4ac229 fix(core): pass name of null param 2026-02-12 20:46:32 -06:00
Stevan Freeborn ead6f534b9 chore: update example appsettings file 2026-02-12 20:45:56 -06:00
Stevan Freeborn e673ab4f24 feat(api): prevent user from connecting to same institution twice 2026-02-12 16:20:10 -06:00
Stevan Freeborn 2c17dc0662 feat(infra): update db context with modeling for institution and relationships 2026-02-12 16:19:51 -06:00
Stevan Freeborn d80ba9b24c feat(core): add models for institution 2026-02-12 16:19:21 -06:00
Stevan Freeborn 4164e53113 refactor(api.tests): check for correct status code first in assertion 2026-02-12 16:18:42 -06:00
Stevan Freeborn 09930a07fe fix(api.tests): use a serializable dictionary for xunit test case data 2026-02-12 16:18:25 -06:00
Stevan Freeborn 050bf0adba feat(api): add validation for connect account request 2026-02-12 13:39:38 -06:00
Stevan Freeborn 6f62d4ce11 refactor(api): derive field name from property being validated 2026-02-12 13:20:12 -06:00
Stevan Freeborn f2667fd330 refactor(api): rename type 2026-02-12 13:15:49 -06:00
Stevan Freeborn 92f336f274 refactor(api): remove whitespace 2026-02-12 13:14:20 -06:00
Stevan Freeborn 71d14e9fe3 feat(api): add protected connect account endpoint 2026-02-12 13:12:14 -06:00
37 changed files with 1099 additions and 83 deletions
+1
View File
@@ -3,6 +3,7 @@
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally> <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageVersion Include="Going.Plaid" Version="6.56.0" />
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.2" /> <PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.2" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.2"> <PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
@@ -0,0 +1,15 @@
namespace FiscalOS.API.Accounts;
internal static class AccountsExtensions
{
private const string RouteGroupPrefix = "/accounts";
public static RouteGroupBuilder MapAccountsEndpoints(this WebApplication app)
{
var accountsGroup = app.MapGroup(RouteGroupPrefix).RequireAuthorization();
accountsGroup.MapConnectEndpoint();
return accountsGroup;
}
}
@@ -0,0 +1,67 @@
using FiscalOS.Core.Accounts;
namespace FiscalOS.API.Accounts.Connect;
internal static class Endpoint
{
private const string Route = "/connect";
public static RouteHandlerBuilder MapConnectEndpoint(this RouteGroupBuilder groupBuilder)
{
return groupBuilder.MapPost(Route, HandleAsync);
}
private static async Task<IResult> HandleAsync(
HttpContext httpContext,
[FromBody] Request request,
[FromServices] AppDbContext appDbContext,
[FromServices] PlaidService plaidService,
[FromServices] IEncryptor encryptor,
CancellationToken ct
)
{
var userId = httpContext.GetUserId();
var user = await appDbContext.Users
.Include(u => u.Institutions
.Where(
i => i.Metadata is PlaidMetadata &&
((PlaidMetadata)i.Metadata).PlaidId == request.PlaidInstitutionId
)
)
.ThenInclude(i => i.Metadata)
.SingleOrDefaultAsync(u => u.Id == userId, ct);
if (user is null)
{
return Results.Problem(
statusCode: StatusCodes.Status401Unauthorized,
title: "Unauthorized",
detail: "You are not authorized to connect an institution. Please log in and try again."
);
}
if (user.Institutions.Any())
{
return Results.Problem(
statusCode: StatusCodes.Status409Conflict,
title: "Institution already connected",
detail: "The user has already connected an institution with the provided Plaid Institution Id."
);
}
var (itemId, accessToken) = await plaidService.ExchangeTokenAsync(request.PublicToken);
var item = await plaidService.GetItemAsync(accessToken);
var encryptedAccessToken = await encryptor.EncryptAsyncFor(user, accessToken, ct);
var plaidMetadata = PlaidMetadata.From(item.InstitutionId, item.InstitutionName, encryptedAccessToken);
var institution = Institution.From(item.InstitutionName, plaidMetadata);
user.AddInstitution(institution);
await appDbContext.SaveChangesAsync(ct);
return Results.Ok();
}
}
@@ -0,0 +1,22 @@
namespace FiscalOS.API.Accounts.Connect;
public record Request : IValidatableObject
{
public string PublicToken { get; init; } = string.Empty;
public string PlaidInstitutionId { get; init; } = string.Empty;
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
if (string.IsNullOrWhiteSpace(PublicToken))
{
var fieldName = nameof(PublicToken);
yield return new($"The {fieldName} field is required.", [fieldName]);
}
if (string.IsNullOrWhiteSpace(PlaidInstitutionId))
{
var fieldName = nameof(PlaidInstitutionId);
yield return new($"The {fieldName} field is required.", [fieldName]);
}
}
}
+1 -1
View File
@@ -11,7 +11,7 @@ internal static class Endpoint
private static async Task<IResult> HandleAsync( private static async Task<IResult> HandleAsync(
HttpContext httpContext, HttpContext httpContext,
[FromBody] LoginRequest loginRequest, [FromBody] Request loginRequest,
[FromServices] AppDbContext appDbContext, [FromServices] AppDbContext appDbContext,
[FromServices] IPasswordHasher passwordHasher, [FromServices] IPasswordHasher passwordHasher,
[FromServices] ITokenGenerator tokenService [FromServices] ITokenGenerator tokenService
+5 -4
View File
@@ -1,7 +1,6 @@
namespace FiscalOS.API.Login; namespace FiscalOS.API.Login;
public record LoginRequest : IValidatableObject public record Request : IValidatableObject
{ {
public string Username { get; init; } = string.Empty; public string Username { get; init; } = string.Empty;
public string Password { get; init; } = string.Empty; public string Password { get; init; } = string.Empty;
@@ -10,12 +9,14 @@ public record LoginRequest : IValidatableObject
{ {
if (string.IsNullOrWhiteSpace(Username)) if (string.IsNullOrWhiteSpace(Username))
{ {
yield return new("The Username field is required.", [nameof(Username)]); var fieldName = nameof(Username);
yield return new($"The {fieldName} field is required.", [fieldName]);
} }
if (string.IsNullOrWhiteSpace(Password)) if (string.IsNullOrWhiteSpace(Password))
{ {
yield return new("The Password field is required.", [nameof(Password)]); var fieldName = nameof(Password);
yield return new($"The {fieldName} field is required.", [fieldName]);
} }
} }
} }
+2
View File
@@ -37,4 +37,6 @@ app.MapLoginEndpoint();
app.MapRefreshEndpoint() app.MapRefreshEndpoint()
.RequireAuthorization(Schemes.AllowExpiredTokens); .RequireAuthorization(Schemes.AllowExpiredTokens);
app.MapAccountsEndpoints();
app.Run(); app.Run();
+4
View File
@@ -2,10 +2,14 @@ global using System.ComponentModel.DataAnnotations;
global using System.Security.Claims; global using System.Security.Claims;
global using System.Text.Json.Serialization; global using System.Text.Json.Serialization;
global using FiscalOS.API.Accounts;
global using FiscalOS.API.Accounts.Connect;
global using FiscalOS.API.Http; global using FiscalOS.API.Http;
global using FiscalOS.API.Login; global using FiscalOS.API.Login;
global using FiscalOS.API.Refresh; global using FiscalOS.API.Refresh;
global using FiscalOS.Core.Authentication; global using FiscalOS.Core.Authentication;
global using FiscalOS.Core.Security;
global using FiscalOS.Infra.Accounts.Plaid;
global using FiscalOS.Infra.Authentication; global using FiscalOS.Infra.Authentication;
global using FiscalOS.Infra.Data; global using FiscalOS.Infra.Data;
global using FiscalOS.Infra.DependencyInjection; global using FiscalOS.Infra.DependencyInjection;
+1 -1
View File
@@ -19,7 +19,7 @@
"KeysDirectoryPath": "KeysDirectoryPath", "KeysDirectoryPath": "KeysDirectoryPath",
"PrimaryKeyId": "PrimaryKeyId" "PrimaryKeyId": "PrimaryKeyId"
}, },
"PlaidOptions": { "PlaidClientOptions": {
"ClientId": "ClientId", "ClientId": "ClientId",
"Secret": "Secret" "Secret": "Secret"
} }
+24
View File
@@ -0,0 +1,24 @@
namespace FiscalOS.Core.Accounts;
public sealed class Institution : Entity
{
public Guid UserId { get; init; }
public string Name { get; init; } = string.Empty;
public InstitutionMetadata Metadata { get; init; } = null!;
private Institution()
{
}
public static Institution From(string? name, InstitutionMetadata metadata)
{
ArgumentNullException.ThrowIfNull(name, nameof(name));
ArgumentNullException.ThrowIfNull(metadata, nameof(metadata));
return new Institution()
{
Name = name,
Metadata = metadata
};
}
}
@@ -0,0 +1,15 @@
namespace FiscalOS.Core.Accounts;
public abstract class InstitutionMetadata : Entity
{
private readonly string _type;
public string Type => _type;
public Guid InstitutionId { get; init; }
protected InstitutionMetadata(string type)
{
_type = type;
}
}
+1 -1
View File
@@ -24,7 +24,7 @@ public sealed class RefreshToken : Entity
public static RefreshToken From(User user, string token, DateTimeOffset expiresAt) public static RefreshToken From(User user, string token, DateTimeOffset expiresAt)
{ {
ArgumentNullException.ThrowIfNull(user); ArgumentNullException.ThrowIfNull(user, nameof(user));
return new() return new()
{ {
+15 -6
View File
@@ -2,15 +2,17 @@ namespace FiscalOS.Core.Identity;
public sealed class User : Entity public sealed class User : Entity
{ {
private readonly List<RefreshToken> _refreshTokens = [];
public string Username { get; init; } = string.Empty; public string Username { get; init; } = string.Empty;
public string HashedPassword { get; init; } = string.Empty; public string HashedPassword { get; init; } = string.Empty;
public string EncryptionKeyId { get; init; } = string.Empty; public string EncryptionKeyId { get; init; } = string.Empty;
public string EncryptedDataKey { get; init; } = string.Empty; public string EncryptedDataKey { get; init; } = string.Empty;
private readonly List<RefreshToken> _refreshTokens = [];
public IEnumerable<RefreshToken> RefreshTokens => _refreshTokens; public IEnumerable<RefreshToken> RefreshTokens => _refreshTokens;
private readonly List<Institution> _institutions = [];
public IEnumerable<Institution> Institutions => _institutions;
private User() private User()
{ {
} }
@@ -22,9 +24,9 @@ public sealed class User : Entity
public static User From(string username, string hashedPassword, EncryptedDataKey encryptedDataKey) public static User From(string username, string hashedPassword, EncryptedDataKey encryptedDataKey)
{ {
ArgumentNullException.ThrowIfNull(username); ArgumentNullException.ThrowIfNull(username, nameof(username));
ArgumentNullException.ThrowIfNull(hashedPassword); ArgumentNullException.ThrowIfNull(hashedPassword, nameof(hashedPassword));
ArgumentNullException.ThrowIfNull(encryptedDataKey); ArgumentNullException.ThrowIfNull(encryptedDataKey, nameof(encryptedDataKey));
return new() return new()
{ {
@@ -37,8 +39,15 @@ public sealed class User : Entity
public void AddRefreshToken(RefreshToken refreshToken) public void AddRefreshToken(RefreshToken refreshToken)
{ {
ArgumentNullException.ThrowIfNull(refreshToken); ArgumentNullException.ThrowIfNull(refreshToken, nameof(refreshToken));
_refreshTokens.Add(refreshToken); _refreshTokens.Add(refreshToken);
} }
public void AddInstitution(Institution institution)
{
ArgumentNullException.ThrowIfNull(institution, nameof(institution));
_institutions.Add(institution);
}
} }
@@ -13,8 +13,8 @@ public sealed record EncryptedDataKey
public static EncryptedDataKey From(string keyIdUsed, string encryptedKey) public static EncryptedDataKey From(string keyIdUsed, string encryptedKey)
{ {
ArgumentNullException.ThrowIfNull(keyIdUsed); ArgumentNullException.ThrowIfNull(keyIdUsed, nameof(keyIdUsed));
ArgumentNullException.ThrowIfNull(encryptedKey); ArgumentNullException.ThrowIfNull(encryptedKey, nameof(encryptedKey));
return new(keyIdUsed, encryptedKey); return new(keyIdUsed, encryptedKey);
} }
+2 -2
View File
@@ -13,8 +13,8 @@ public sealed record KeyRingEntry
public static KeyRingEntry From(string keyId, string key) public static KeyRingEntry From(string keyId, string key)
{ {
ArgumentNullException.ThrowIfNull(keyId); ArgumentNullException.ThrowIfNull(keyId, nameof(keyId));
ArgumentNullException.ThrowIfNull(key); ArgumentNullException.ThrowIfNull(key, nameof(key));
return new(keyId, key); return new(keyId, key);
} }
+1
View File
@@ -1,3 +1,4 @@
global using FiscalOS.Core.Accounts;
global using FiscalOS.Core.Data; global using FiscalOS.Core.Data;
global using FiscalOS.Core.Identity; global using FiscalOS.Core.Identity;
global using FiscalOS.Core.Security; global using FiscalOS.Core.Security;
@@ -0,0 +1,32 @@
namespace FiscalOS.Infra.Accounts.Plaid;
public sealed record PlaidClientOptions
{
public string ClientId { get; init; } = string.Empty;
public string Secret { get; init; } = string.Empty;
public IOptions<PlaidOptions> ToPlaidOptions()
{
return Options.Create<PlaidOptions>(new()
{
ClientId = ClientId,
Secret = Secret
});
}
}
public sealed record PlaidClientOptionsSetup : IConfigureOptions<PlaidClientOptions>
{
private const string SectionName = nameof(PlaidClientOptions);
private readonly IConfiguration _configuration;
public PlaidClientOptionsSetup(IConfiguration configuration)
{
_configuration = configuration;
}
public void Configure(PlaidClientOptions options)
{
_configuration.GetSection(SectionName).Bind(options);
}
}
@@ -0,0 +1,16 @@
namespace FiscalOS.Infra.Accounts.Plaid;
public class PlaidException : Exception
{
public PlaidException()
{
}
public PlaidException(string message) : base(message)
{
}
public PlaidException(string message, Exception innerException) : base(message, innerException)
{
}
}
@@ -0,0 +1,31 @@
namespace FiscalOS.Infra.Accounts.Plaid;
public sealed class PlaidMetadata : InstitutionMetadata
{
public const string TypeValue = "Plaid";
public string PlaidId { get; init; } = string.Empty;
public string PlaidName { get; init; } = string.Empty;
public string EncryptedAccessToken { get; init; } = string.Empty;
private PlaidMetadata() : base(TypeValue)
{
}
public static PlaidMetadata From(
string? plaidId,
string? plaidName,
string encryptedAccessToken
)
{
ArgumentNullException.ThrowIfNull(plaidId, nameof(plaidId));
ArgumentNullException.ThrowIfNull(plaidName, nameof(plaidName));
ArgumentNullException.ThrowIfNull(encryptedAccessToken, nameof(encryptedAccessToken));
return new PlaidMetadata
{
PlaidId = plaidId,
PlaidName = plaidName,
EncryptedAccessToken = encryptedAccessToken
};
}
}
@@ -0,0 +1,47 @@
namespace FiscalOS.Infra.Accounts.Plaid;
public sealed class PlaidService
{
private readonly PlaidClient _client;
private PlaidService(PlaidClient client)
{
_client = client;
}
public static PlaidService From(IServiceProvider sp)
{
var client = sp.GetRequiredService<PlaidClient>();
return new(client);
}
public async Task<(string ItemId, string AccessToken)> ExchangeTokenAsync(string publicToken)
{
var ptr = await _client.ItemPublicTokenExchangeAsync(new()
{
PublicToken = publicToken,
}).ConfigureAwait(false);
if (ptr.IsSuccessStatusCode is false)
{
throw new PlaidException("Unable to exchange public token for access token");
}
return (ptr.ItemId, ptr.AccessToken);
}
public async Task<ItemWithConsentFields> GetItemAsync(string accessToken)
{
var ar = await _client.ItemGetAsync(new()
{
AccessToken = accessToken,
}).ConfigureAwait(false);
if (ar.IsSuccessStatusCode is false)
{
throw new PlaidException("Unable to retrieve item");
}
return ar.Item;
}
}
+39 -4
View File
@@ -1,3 +1,5 @@
using Institution = FiscalOS.Core.Accounts.Institution;
namespace FiscalOS.Infra.Data; namespace FiscalOS.Infra.Data;
public sealed class AppDbContext( public sealed class AppDbContext(
@@ -49,15 +51,20 @@ public sealed class AppDbContext(
modelBuilder.Entity<User>(static eb => modelBuilder.Entity<User>(static eb =>
{ {
eb.Property(static u => u.Username);
eb.HasIndex(static u => u.Username).IsUnique();
eb.Property(static u => u.HashedPassword);
eb.HasMany(static u => u.RefreshTokens) eb.HasMany(static u => u.RefreshTokens)
.WithOne(static t => t.User) .WithOne(static t => t.User)
.HasForeignKey(static t => t.UserId) .HasForeignKey(static t => t.UserId)
.OnDelete(DeleteBehavior.Cascade); .OnDelete(DeleteBehavior.Cascade);
eb.Property(static u => u.Username); eb.HasMany(static u => u.Institutions)
eb.HasIndex(static u => u.Username).IsUnique(); .WithOne()
.HasForeignKey(static i => i.UserId)
eb.Property(static u => u.HashedPassword); .OnDelete(DeleteBehavior.Cascade);
}); });
modelBuilder.Entity<RefreshToken>(static eb => modelBuilder.Entity<RefreshToken>(static eb =>
@@ -69,5 +76,33 @@ public sealed class AppDbContext(
eb.Property(static t => t.Token); eb.Property(static t => t.Token);
eb.HasIndex(static t => t.Token).IsUnique(); eb.HasIndex(static t => t.Token).IsUnique();
}); });
modelBuilder.Entity<Institution>(static eb =>
{
eb.Property(static i => i.Name);
eb.HasOne(static i => i.Metadata)
.WithOne()
.HasForeignKey<InstitutionMetadata>(static m => m.InstitutionId)
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity<InstitutionMetadata>(static eb =>
{
eb.HasDiscriminator(static m => m.Type)
.HasValue<PlaidMetadata>(PlaidMetadata.TypeValue);
eb.Property(static m => m.InstitutionId);
eb.Property(static m => m.Type);
});
modelBuilder.Entity<PlaidMetadata>(static eb =>
{
eb.HasBaseType<InstitutionMetadata>();
eb.Property(static m => m.PlaidId);
eb.Property(static m => m.PlaidName);
eb.Property(static m => m.EncryptedAccessToken);
});
} }
} }
@@ -6,6 +6,7 @@ public static class ServiceCollectionExtensions
{ {
services.AddSingleton<IAuthorizationMiddlewareResultHandler, ProblemDetailsAuthResultHandler>(); services.AddSingleton<IAuthorizationMiddlewareResultHandler, ProblemDetailsAuthResultHandler>();
services.AddHttpClient();
services.AddSingleton(TimeProvider.System); services.AddSingleton(TimeProvider.System);
services.AddSingleton<IFileSystem, FileSystem>(); services.AddSingleton<IFileSystem, FileSystem>();
@@ -23,6 +24,17 @@ public static class ServiceCollectionExtensions
services.AddDbContext<AppDbContext>(); services.AddDbContext<AppDbContext>();
services.AddHostedService<MigrationService>(); services.AddHostedService<MigrationService>();
services.ConfigureOptions<PlaidClientOptionsSetup>();
services.AddSingleton(static sp =>
{
var factory = sp.GetRequiredService<IHttpClientFactory>();
var logger = sp.GetRequiredService<ILogger<PlaidClient>>();
var clientOptions = sp.GetRequiredService<IOptions<PlaidClientOptions>>();
var options = clientOptions.Value.ToPlaidOptions();
return new PlaidClient(options, factory, logger);
});
services.AddSingleton(PlaidService.From);
return services; return services;
} }
} }
+1
View File
@@ -1,6 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<ItemGroup> <ItemGroup>
<PackageReference Include="Going.Plaid" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" /> <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" /> <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" />
<PackageReference Include="Microsoft.Extensions.Hosting" /> <PackageReference Include="Microsoft.Extensions.Hosting" />
@@ -0,0 +1,215 @@
// <auto-generated />
using System;
using FiscalOS.Infra.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace FiscalOS.Infra.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20260212215015_InstitutionAndMetadata")]
partial class InstitutionAndMetadata
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.2");
modelBuilder.Entity("FiscalOS.Core.Accounts.Institution", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("Institution");
});
modelBuilder.Entity("FiscalOS.Core.Accounts.InstitutionMetadata", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<Guid>("InstitutionId")
.HasColumnType("TEXT");
b.Property<string>("Type")
.IsRequired()
.HasMaxLength(21)
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("InstitutionId")
.IsUnique();
b.ToTable("InstitutionMetadata");
b.HasDiscriminator<string>("Type").HasValue("InstitutionMetadata");
b.UseTphMappingStrategy();
});
modelBuilder.Entity("FiscalOS.Core.Identity.RefreshToken", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("ExpiresAt")
.HasColumnType("TEXT");
b.Property<bool>("Revoked")
.HasColumnType("INTEGER");
b.Property<string>("Token")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Token")
.IsUnique();
b.HasIndex("UserId");
b.ToTable("RefreshTokens");
});
modelBuilder.Entity("FiscalOS.Core.Identity.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("EncryptedDataKey")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("EncryptionKeyId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("HashedPassword")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<string>("Username")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Username")
.IsUnique();
b.ToTable("Users");
});
modelBuilder.Entity("FiscalOS.Infra.Accounts.Plaid.PlaidMetadata", b =>
{
b.HasBaseType("FiscalOS.Core.Accounts.InstitutionMetadata");
b.Property<string>("EncryptedAccessToken")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("PlaidId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("PlaidName")
.IsRequired()
.HasColumnType("TEXT");
b.HasDiscriminator().HasValue("Plaid");
});
modelBuilder.Entity("FiscalOS.Core.Accounts.Institution", b =>
{
b.HasOne("FiscalOS.Core.Identity.User", null)
.WithMany("Institutions")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("FiscalOS.Core.Accounts.InstitutionMetadata", b =>
{
b.HasOne("FiscalOS.Core.Accounts.Institution", null)
.WithOne("Metadata")
.HasForeignKey("FiscalOS.Core.Accounts.InstitutionMetadata", "InstitutionId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("FiscalOS.Core.Identity.RefreshToken", b =>
{
b.HasOne("FiscalOS.Core.Identity.User", "User")
.WithMany("RefreshTokens")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("FiscalOS.Core.Accounts.Institution", b =>
{
b.Navigation("Metadata")
.IsRequired();
});
modelBuilder.Entity("FiscalOS.Core.Identity.User", b =>
{
b.Navigation("Institutions");
b.Navigation("RefreshTokens");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,81 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace FiscalOS.Infra.Migrations
{
/// <inheritdoc />
public partial class InstitutionAndMetadata : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Institution",
columns: table => new
{
Id = table.Column<Guid>(type: "TEXT", nullable: false),
UserId = table.Column<Guid>(type: "TEXT", nullable: false),
Name = table.Column<string>(type: "TEXT", nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "TEXT", nullable: false),
UpdatedAt = table.Column<DateTimeOffset>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Institution", x => x.Id);
table.ForeignKey(
name: "FK_Institution_Users_UserId",
column: x => x.UserId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "InstitutionMetadata",
columns: table => new
{
Id = table.Column<Guid>(type: "TEXT", nullable: false),
Type = table.Column<string>(type: "TEXT", maxLength: 21, nullable: false),
InstitutionId = table.Column<Guid>(type: "TEXT", nullable: false),
PlaidId = table.Column<string>(type: "TEXT", nullable: true),
PlaidName = table.Column<string>(type: "TEXT", nullable: true),
EncryptedAccessToken = table.Column<string>(type: "TEXT", nullable: true),
CreatedAt = table.Column<DateTimeOffset>(type: "TEXT", nullable: false),
UpdatedAt = table.Column<DateTimeOffset>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_InstitutionMetadata", x => x.Id);
table.ForeignKey(
name: "FK_InstitutionMetadata_Institution_InstitutionId",
column: x => x.InstitutionId,
principalTable: "Institution",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_Institution_UserId",
table: "Institution",
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_InstitutionMetadata_InstitutionId",
table: "InstitutionMetadata",
column: "InstitutionId",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "InstitutionMetadata");
migrationBuilder.DropTable(
name: "Institution");
}
}
}
@@ -17,6 +17,64 @@ namespace FiscalOS.Infra.Migrations
#pragma warning disable 612, 618 #pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.2"); modelBuilder.HasAnnotation("ProductVersion", "10.0.2");
modelBuilder.Entity("FiscalOS.Core.Accounts.Institution", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("TEXT");
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("Institution");
});
modelBuilder.Entity("FiscalOS.Core.Accounts.InstitutionMetadata", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<Guid>("InstitutionId")
.HasColumnType("TEXT");
b.Property<string>("Type")
.IsRequired()
.HasMaxLength(21)
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("InstitutionId")
.IsUnique();
b.ToTable("InstitutionMetadata");
b.HasDiscriminator<string>("Type").HasValue("InstitutionMetadata");
b.UseTphMappingStrategy();
});
modelBuilder.Entity("FiscalOS.Core.Identity.RefreshToken", b => modelBuilder.Entity("FiscalOS.Core.Identity.RefreshToken", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@@ -88,6 +146,43 @@ namespace FiscalOS.Infra.Migrations
b.ToTable("Users"); b.ToTable("Users");
}); });
modelBuilder.Entity("FiscalOS.Infra.Accounts.Plaid.PlaidMetadata", b =>
{
b.HasBaseType("FiscalOS.Core.Accounts.InstitutionMetadata");
b.Property<string>("EncryptedAccessToken")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("PlaidId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("PlaidName")
.IsRequired()
.HasColumnType("TEXT");
b.HasDiscriminator().HasValue("Plaid");
});
modelBuilder.Entity("FiscalOS.Core.Accounts.Institution", b =>
{
b.HasOne("FiscalOS.Core.Identity.User", null)
.WithMany("Institutions")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("FiscalOS.Core.Accounts.InstitutionMetadata", b =>
{
b.HasOne("FiscalOS.Core.Accounts.Institution", null)
.WithOne("Metadata")
.HasForeignKey("FiscalOS.Core.Accounts.InstitutionMetadata", "InstitutionId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("FiscalOS.Core.Identity.RefreshToken", b => modelBuilder.Entity("FiscalOS.Core.Identity.RefreshToken", b =>
{ {
b.HasOne("FiscalOS.Core.Identity.User", "User") b.HasOne("FiscalOS.Core.Identity.User", "User")
@@ -99,8 +194,16 @@ namespace FiscalOS.Infra.Migrations
b.Navigation("User"); b.Navigation("User");
}); });
modelBuilder.Entity("FiscalOS.Core.Accounts.Institution", b =>
{
b.Navigation("Metadata")
.IsRequired();
});
modelBuilder.Entity("FiscalOS.Core.Identity.User", b => modelBuilder.Entity("FiscalOS.Core.Identity.User", b =>
{ {
b.Navigation("Institutions");
b.Navigation("RefreshTokens"); b.Navigation("RefreshTokens");
}); });
#pragma warning restore 612, 618 #pragma warning restore 612, 618
+5
View File
@@ -5,15 +5,20 @@ global using System.Security.Cryptography;
global using System.Text; global using System.Text;
global using System.Text.Json; global using System.Text.Json;
global using FiscalOS.Core.Accounts;
global using FiscalOS.Core.Authentication; global using FiscalOS.Core.Authentication;
global using FiscalOS.Core.Data; global using FiscalOS.Core.Data;
global using FiscalOS.Core.Identity; global using FiscalOS.Core.Identity;
global using FiscalOS.Core.Security; global using FiscalOS.Core.Security;
global using FiscalOS.Infra.Accounts.Plaid;
global using FiscalOS.Infra.Authentication; global using FiscalOS.Infra.Authentication;
global using FiscalOS.Infra.Authorization; global using FiscalOS.Infra.Authorization;
global using FiscalOS.Infra.Data; global using FiscalOS.Infra.Data;
global using FiscalOS.Infra.Security; global using FiscalOS.Infra.Security;
global using Going.Plaid;
global using Going.Plaid.Entity;
global using Microsoft.AspNetCore.Authentication.JwtBearer; global using Microsoft.AspNetCore.Authentication.JwtBearer;
global using Microsoft.AspNetCore.Authorization; global using Microsoft.AspNetCore.Authorization;
global using Microsoft.AspNetCore.Authorization.Policy; global using Microsoft.AspNetCore.Authorization.Policy;
@@ -78,12 +78,6 @@ internal sealed class HttpResponseMessageAssertions(
private async Task<T> ValidateAndDeserialize<T>(HttpStatusCode expectedStatusCode) where T : ProblemDetails private async Task<T> ValidateAndDeserialize<T>(HttpStatusCode expectedStatusCode) where T : ProblemDetails
{ {
_chain.ForCondition(Subject.Content.Headers.ContentType?.MediaType is "application/problem+json")
.FailWith(
"Expected response to be application/problem+json, but found {0}",
Subject.Content.Headers.ContentType?.MediaType
);
_chain.ForCondition(Subject.StatusCode == expectedStatusCode) _chain.ForCondition(Subject.StatusCode == expectedStatusCode)
.FailWith( .FailWith(
"Expected response status code to be {0}, but found {1}", "Expected response status code to be {0}, but found {1}",
@@ -91,6 +85,12 @@ internal sealed class HttpResponseMessageAssertions(
Subject.StatusCode Subject.StatusCode
); );
_chain.ForCondition(Subject.Content.Headers.ContentType?.MediaType is "application/problem+json")
.FailWith(
"Expected response to be application/problem+json, but found {0}",
Subject.Content.Headers.ContentType?.MediaType
);
var problem = await Subject.Content.ReadFromJsonAsync<T>(); var problem = await Subject.Content.ReadFromJsonAsync<T>();
_chain.ForCondition(problem is not null) _chain.ForCondition(problem is not null)
@@ -0,0 +1,41 @@
namespace FiscalOS.API.Tests.Common;
public interface ISerializableDictionary<TKey, TValue> : IDictionary<TKey, TValue>, IXunitSerializable
{
}
public class SerializableDictionary<TKey, TValue>
: Dictionary<TKey, TValue>, ISerializableDictionary<TKey, TValue> where TKey : notnull
{
public SerializableDictionary() { }
public SerializableDictionary(IDictionary<TKey, TValue> dictionary) : base(dictionary)
{
}
public void Deserialize(IXunitSerializationInfo info)
{
Clear();
var keysJson = info.GetValue<string>("_DictKeys") ?? "[]";
var valuesJson = info.GetValue<string>("_DictValues") ?? "[]";
var keys = JsonSerializer.Deserialize<List<TKey>>(keysJson);
var values = JsonSerializer.Deserialize<List<TValue>>(valuesJson);
if (keys is null || values is null || keys.Count != values.Count)
{
return;
}
for (var i = 0; i < keys.Count; i++)
{
Add(keys[i], values[i]);
}
}
public void Serialize(IXunitSerializationInfo info)
{
info.AddValue("_DictKeys", JsonSerializer.Serialize(Keys));
info.AddValue("_DictValues", JsonSerializer.Serialize(Values));
}
}
@@ -15,6 +15,7 @@
<ItemGroup> <ItemGroup>
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" /> <Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
<Content Include="appsettings.Test.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@@ -1,3 +1,5 @@
using Microsoft.Extensions.Configuration;
namespace FiscalOS.API.Tests.Infra; namespace FiscalOS.API.Tests.Infra;
public class TestApi : WebApplicationFactory<Program> public class TestApi : WebApplicationFactory<Program>
@@ -8,6 +10,12 @@ public class TestApi : WebApplicationFactory<Program>
builder.ConfigureLogging(static c => c.ClearProviders()); builder.ConfigureLogging(static c => c.ClearProviders());
builder.ConfigureAppConfiguration(static c =>
{
var testConfigPath = Path.Combine(AppContext.BaseDirectory, "appsettings.Test.json");
c.AddJsonFile(testConfigPath, optional: false);
});
builder.ConfigureTestServices(static c => builder.ConfigureTestServices(static c =>
{ {
c.AddSingleton(Options.Create(new AppDbContextOptions() c.AddSingleton(Options.Create(new AppDbContextOptions()
@@ -0,0 +1,217 @@
using Institution = FiscalOS.Core.Accounts.Institution;
namespace FiscalOS.API.Tests.Integration;
public class ConnectTests(TestApi testApi) : IntegrationTest(testApi)
{
private static readonly Uri ConnectUri = new("/accounts/connect", UriKind.Relative);
[Fact]
public async Task Connect_WhenCalledWithoutValidToken_ItShouldReturn401WithProblemDetails()
{
var res = await Client.PostAsJsonAsync(ConnectUri, new { }, TestContext.Current.CancellationToken);
await res.Should().BeProblemDetails(HttpStatusCode.Unauthorized);
}
[Fact]
public async Task Connect_WhenCalledWithoutPublicTokenOrPlaidInstitutionId_ItShouldReturn400WithProblemDetails()
{
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[]>()
{
["PublicToken"] = ["The PublicToken field is required."],
["PlaidInstitutionId"] = ["The PlaidInstitutionId field is required."],
});
}
[Fact]
public async Task Connect_WhenCalledWithoutPublicToken_ItShouldReturn400WithProblemDetails()
{
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[]>()
{
["PublicToken"] = ["The PublicToken field is required."],
});
}
[Fact]
public async Task Connect_WhenCalledWithoutPlaidInstitutionId_ItShouldReturn400WithProblemDetails()
{
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[]>()
{
["PlaidInstitutionId"] = ["The PlaidInstitutionId field is required."],
});
}
[Fact]
public async Task Connect_WhenCalledWithNonExistentUser_ItShouldReturn401WithProblemDetails()
{
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);
}
[Fact]
public async Task Connect_WhenCalledWithPlaidInstitutionIdThatIsAlreadyConnected_ItShouldReturn409WithProblemDetails()
{
var (user, institution) = await ExecuteAsync(static async (context, ct, sp) =>
{
var passwordHasher = sp.GetRequiredService<IPasswordHasher>();
var encryptor = sp.GetRequiredService<IEncryptor>();
var userEncryptionKey = await encryptor.GenerateEncryptedKeyAsync(ct);
var user = User.From("User1", passwordHasher.Hash("@Password1"), userEncryptionKey);
var encryptedAccessToken = await encryptor.EncryptAsyncFor(user, "accessToken", ct);
var plaidMetadata = PlaidMetadata.From("alreadyExists", "Some Bank", encryptedAccessToken);
var institution = Institution.From("Some Bank", plaidMetadata);
user.AddInstitution(institution);
await context.AddAsync(user, ct);
await context.SaveChangesAsync(ct);
return (user, institution);
}, TestContext.Current.CancellationToken);
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);
}
[Fact]
public async Task Connect_WhenCalledWithInstitutionThatIsNotConnected_ItShouldReturn200()
{
var plaidInstitutionId = "ins_109508";
var (user, publicToken) = await ExecuteAsync(async (context, ct, sp) =>
{
var plaidClient = sp.GetRequiredService<PlaidClient>();
var passwordHasher = sp.GetRequiredService<IPasswordHasher>();
var encryptor = sp.GetRequiredService<IEncryptor>();
var userEncryptionKey = await encryptor.GenerateEncryptedKeyAsync(ct);
var user = User.From("User1", passwordHasher.Hash("@Password1"), userEncryptionKey);
await context.AddAsync(user, ct);
await context.SaveChangesAsync(ct);
var publicToken = await plaidClient.SandboxPublicTokenCreateAsync(new()
{
InstitutionId = plaidInstitutionId,
InitialProducts = [Products.Transactions],
});
return (user, publicToken.PublicToken);
}, TestContext.Current.CancellationToken);
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);
var updatedUser = await ExecuteAsync(
async (context, ct) => await context.Set<User>()
.Include(u => u.Institutions)
.ThenInclude(i => i.Metadata)
.SingleAsync(u => u.Id == user.Id, ct),
TestContext.Current.CancellationToken
);
updatedUser.Institutions.Should().HaveCount(1);
var institution = updatedUser.Institutions.First();
institution.Name.Should().NotBeNullOrEmpty();
var metadata = institution.Metadata.As<PlaidMetadata>();
metadata.PlaidId.Should().Be(plaidInstitutionId);
metadata.PlaidName.Should().Be(institution.Name);
metadata.EncryptedAccessToken.Should().NotBeNullOrEmpty();
}
}
@@ -6,48 +6,47 @@ public abstract class IntegrationTest(TestApi testApi) : IClassFixture<TestApi>,
public async ValueTask InitializeAsync() public async ValueTask InitializeAsync()
{ {
await ExecuteDbContextAsync(static async context => await ExecuteAsync(static async (context, ct) =>
{ {
await context.Database.EnsureCreatedAsync(); await context.Database.EnsureCreatedAsync(ct);
}); }, TestContext.Current.CancellationToken);
} }
protected async Task ExecuteDbContextAsync(Func<DbContext, Task> action) protected async Task ExecuteAsync(Func<DbContext, CancellationToken, Task> action, CancellationToken ct)
{ {
await using var scope = testApi.Services.CreateAsyncScope(); await using var scope = testApi.Services.CreateAsyncScope();
var context = scope.ServiceProvider.GetRequiredService<AppDbContext>(); var context = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await action(context); await action(context, ct);
} }
protected async Task ExecuteDbContextAsync(Func<DbContext, IServiceProvider, Task> action) protected async Task ExecuteAsync(Func<DbContext, CancellationToken, IServiceProvider, Task> action, CancellationToken ct)
{ {
await using var scope = testApi.Services.CreateAsyncScope(); await using var scope = testApi.Services.CreateAsyncScope();
var context = scope.ServiceProvider.GetRequiredService<AppDbContext>(); var context = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await action(context, scope.ServiceProvider); await action(context, ct, scope.ServiceProvider);
} }
protected async Task<T> ExecuteDbContextAsync<T>(Func<DbContext, Task<T>> action) protected async Task<T> ExecuteAsync<T>(Func<DbContext, CancellationToken, Task<T>> action, CancellationToken ct)
{ {
await using var scope = testApi.Services.CreateAsyncScope(); await using var scope = testApi.Services.CreateAsyncScope();
var context = scope.ServiceProvider.GetRequiredService<AppDbContext>(); var context = scope.ServiceProvider.GetRequiredService<AppDbContext>();
return await action(context); return await action(context, ct);
} }
protected async Task<T> ExecuteDbContextAsync<T>(Func<DbContext, IServiceProvider, Task<T>> action) protected async Task<T> ExecuteAsync<T>(Func<DbContext, CancellationToken, IServiceProvider, Task<T>> action, CancellationToken ct)
{ {
await using var scope = testApi.Services.CreateAsyncScope(); await using var scope = testApi.Services.CreateAsyncScope();
var context = scope.ServiceProvider.GetRequiredService<AppDbContext>(); var context = scope.ServiceProvider.GetRequiredService<AppDbContext>();
return await action(context, scope.ServiceProvider); return await action(context, ct, scope.ServiceProvider);
} }
public async ValueTask DisposeAsync() public async ValueTask DisposeAsync()
{ {
await ExecuteDbContextAsync(static async context => await ExecuteAsync(static async (context, ct) =>
{ {
await context.Database.EnsureDeletedAsync(); await context.Database.EnsureDeletedAsync(ct);
}); }, TestContext.Current.CancellationToken);
GC.SuppressFinalize(this); GC.SuppressFinalize(this);
} }
} }
@@ -1,3 +1,5 @@
using FiscalOS.API.Tests.Common;
namespace FiscalOS.API.Tests.Integration; namespace FiscalOS.API.Tests.Integration;
public class LoginTests(TestApi testApi) : IntegrationTest(testApi) public class LoginTests(TestApi testApi) : IntegrationTest(testApi)
@@ -36,16 +38,16 @@ public class LoginTests(TestApi testApi) : IntegrationTest(testApi)
[Fact] [Fact]
public async Task Login_WhenUserExistsButPasswordIsIncorrect_ItShouldReturn401WithProblemDetails() public async Task Login_WhenUserExistsButPasswordIsIncorrect_ItShouldReturn401WithProblemDetails()
{ {
await ExecuteDbContextAsync(static async (context, sp) => await ExecuteAsync(static async (context, ct, sp) =>
{ {
var passwordHasher = sp.GetRequiredService<IPasswordHasher>(); var passwordHasher = sp.GetRequiredService<IPasswordHasher>();
var encryptor = sp.GetRequiredService<IEncryptor>(); var encryptor = sp.GetRequiredService<IEncryptor>();
var userEncryptionKey = await encryptor.GenerateEncryptedKeyAsync(TestContext.Current.CancellationToken); var userEncryptionKey = await encryptor.GenerateEncryptedKeyAsync(ct);
context.Add(User.From("Stevan", passwordHasher.Hash("@Password1"), userEncryptionKey)); context.Add(User.From("Stevan", passwordHasher.Hash("@Password1"), userEncryptionKey));
await context.SaveChangesAsync(TestContext.Current.CancellationToken); await context.SaveChangesAsync(ct);
}); }, TestContext.Current.CancellationToken);
var req = new var req = new
{ {
@@ -61,16 +63,16 @@ public class LoginTests(TestApi testApi) : IntegrationTest(testApi)
[Fact] [Fact]
public async Task Login_WhenUserExistsAndPasswordIsCorrect_ItShouldReturn200WithJwtTokenAndSetRefreshCookie() public async Task Login_WhenUserExistsAndPasswordIsCorrect_ItShouldReturn200WithJwtTokenAndSetRefreshCookie()
{ {
await ExecuteDbContextAsync(static async (context, sp) => await ExecuteAsync(static async (context, ct, sp) =>
{ {
var passwordHasher = sp.GetRequiredService<IPasswordHasher>(); var passwordHasher = sp.GetRequiredService<IPasswordHasher>();
var encryptor = sp.GetRequiredService<IEncryptor>(); var encryptor = sp.GetRequiredService<IEncryptor>();
var userEncryptionKey = await encryptor.GenerateEncryptedKeyAsync(TestContext.Current.CancellationToken); var userEncryptionKey = await encryptor.GenerateEncryptedKeyAsync(ct);
context.Add(User.From("Stevan", passwordHasher.Hash("@Password1"), userEncryptionKey)); context.Add(User.From("Stevan", passwordHasher.Hash("@Password1"), userEncryptionKey));
await context.SaveChangesAsync(TestContext.Current.CancellationToken); await context.SaveChangesAsync(ct);
}); }, TestContext.Current.CancellationToken);
var req = new var req = new
{ {
@@ -94,7 +96,7 @@ public class LoginValidationTestCases : TheoryData<LoginValidationTestCase>
"No username or password", "No username or password",
string.Empty, string.Empty,
string.Empty, string.Empty,
new Dictionary<string, string[]>() new SerializableDictionary<string, string[]>()
{ {
["Username"] = ["The Username field is required."], ["Username"] = ["The Username field is required."],
["Password"] = ["The Password field is required."] ["Password"] = ["The Password field is required."]
@@ -105,7 +107,7 @@ public class LoginValidationTestCases : TheoryData<LoginValidationTestCase>
"No username", "No username",
string.Empty, string.Empty,
"@Password1", "@Password1",
new Dictionary<string, string[]>() new SerializableDictionary<string, string[]>()
{ {
["Username"] = ["The Username field is required."], ["Username"] = ["The Username field is required."],
} }
@@ -115,7 +117,7 @@ public class LoginValidationTestCases : TheoryData<LoginValidationTestCase>
"No password", "No password",
"Stevan", "Stevan",
string.Empty, string.Empty,
new Dictionary<string, string[]>() new SerializableDictionary<string, string[]>()
{ {
["Password"] = ["The Password field is required."] ["Password"] = ["The Password field is required."]
} }
@@ -128,7 +130,7 @@ public record LoginValidationTestCase : IXunitSerializable
public string Name { get; private set; } = string.Empty; public string Name { get; private set; } = string.Empty;
public string Username { get; private set; } = string.Empty; public string Username { get; private set; } = string.Empty;
public string Password { get; private set; } = string.Empty; public string Password { get; private set; } = string.Empty;
public Dictionary<string, string[]> ExpectedErrors { get; private set; } = []; public SerializableDictionary<string, string[]> ExpectedErrors { get; private set; } = [];
public override string ToString() public override string ToString()
{ {
@@ -143,7 +145,7 @@ public record LoginValidationTestCase : IXunitSerializable
string name, string name,
string username, string username,
string password, string password,
Dictionary<string, string[]> expectedErrors SerializableDictionary<string, string[]> expectedErrors
) )
{ {
Name = name; Name = name;
@@ -157,7 +159,7 @@ public record LoginValidationTestCase : IXunitSerializable
Name = info.GetValue<string>(nameof(Name)) ?? string.Empty; Name = info.GetValue<string>(nameof(Name)) ?? string.Empty;
Username = info.GetValue<string>(nameof(Username)) ?? string.Empty; Username = info.GetValue<string>(nameof(Username)) ?? string.Empty;
Password = info.GetValue<string>(nameof(Password)) ?? string.Empty; Password = info.GetValue<string>(nameof(Password)) ?? string.Empty;
ExpectedErrors = info.GetValue<Dictionary<string, string[]>>(nameof(ExpectedErrors)) ?? []; ExpectedErrors = info.GetValue<SerializableDictionary<string, string[]>>(nameof(ExpectedErrors)) ?? [];
} }
public void Serialize(IXunitSerializationInfo info) public void Serialize(IXunitSerializationInfo info)
@@ -31,14 +31,14 @@ public class RefreshTests(TestApi testApi) : IntegrationTest(testApi)
[Fact] [Fact]
public async Task Refresh_WhenCalledWithTokenBelongingToDifferentUser_ItShouldReturn403WithProblemDetails() public async Task Refresh_WhenCalledWithTokenBelongingToDifferentUser_ItShouldReturn403WithProblemDetails()
{ {
var (users, refreshToken) = await ExecuteDbContextAsync(static async (context, sp) => var (users, refreshToken) = await ExecuteAsync(static async (context, ct, sp) =>
{ {
var passwordHasher = sp.GetRequiredService<IPasswordHasher>(); var passwordHasher = sp.GetRequiredService<IPasswordHasher>();
var tokenGenerator = sp.GetRequiredService<ITokenGenerator>(); var tokenGenerator = sp.GetRequiredService<ITokenGenerator>();
var encryptor = sp.GetRequiredService<IEncryptor>(); var encryptor = sp.GetRequiredService<IEncryptor>();
var user1EncryptionKey = await encryptor.GenerateEncryptedKeyAsync(TestContext.Current.CancellationToken); var user1EncryptionKey = await encryptor.GenerateEncryptedKeyAsync(ct);
var user2EncryptionKey = await encryptor.GenerateEncryptedKeyAsync(TestContext.Current.CancellationToken); var user2EncryptionKey = await encryptor.GenerateEncryptedKeyAsync(ct);
var user1 = User.From("User1", passwordHasher.Hash("@Password1"), user1EncryptionKey); var user1 = User.From("User1", passwordHasher.Hash("@Password1"), user1EncryptionKey);
var user2 = User.From("User2", passwordHasher.Hash("@Password2"), user2EncryptionKey); var user2 = User.From("User2", passwordHasher.Hash("@Password2"), user2EncryptionKey);
var refreshToken1 = tokenGenerator.GenerateRefreshToken(user2); var refreshToken1 = tokenGenerator.GenerateRefreshToken(user2);
@@ -49,9 +49,9 @@ public class RefreshTests(TestApi testApi) : IntegrationTest(testApi)
context.Add(user1); context.Add(user1);
context.Add(user2); context.Add(user2);
await context.SaveChangesAsync(TestContext.Current.CancellationToken); await context.SaveChangesAsync(ct);
return (new User[] { user1, user2 }, refreshToken1); return (new User[] { user1, user2 }, refreshToken1);
}); }, TestContext.Current.CancellationToken);
var jwt = JwtTokenBuilder.New() var jwt = JwtTokenBuilder.New()
.WithClaim(JwtRegisteredClaimNames.Sub, users[0].Id.ToString()) .WithClaim(JwtRegisteredClaimNames.Sub, users[0].Id.ToString())
@@ -65,11 +65,12 @@ public class RefreshTests(TestApi testApi) : IntegrationTest(testApi)
await response.Should().BeProblemDetails(HttpStatusCode.Forbidden); await response.Should().BeProblemDetails(HttpStatusCode.Forbidden);
var unrevokedTokensCountForUser2 = await ExecuteDbContextAsync( var unrevokedTokensCountForUser2 = await ExecuteAsync(
async context => await context.Set<RefreshToken>() async (context, ct) => await context.Set<RefreshToken>()
.Include(t => t.User) .Include(t => t.User)
.Where(t => t.UserId == users[1].Id && t.Revoked == false) .Where(t => t.UserId == users[1].Id && t.Revoked == false)
.CountAsync() .CountAsync(ct),
TestContext.Current.CancellationToken
); );
unrevokedTokensCountForUser2.Should().Be(0); unrevokedTokensCountForUser2.Should().Be(0);
@@ -78,13 +79,13 @@ public class RefreshTests(TestApi testApi) : IntegrationTest(testApi)
[Fact] [Fact]
public async Task Refresh_WhenCalledWithRevokedRefreshToken_ItShouldReturn400WithProblemDetails() public async Task Refresh_WhenCalledWithRevokedRefreshToken_ItShouldReturn400WithProblemDetails()
{ {
var (user, refreshToken) = await ExecuteDbContextAsync(static async (context, sp) => var (user, refreshToken) = await ExecuteAsync(static async (context, ct, sp) =>
{ {
var passwordHasher = sp.GetRequiredService<IPasswordHasher>(); var passwordHasher = sp.GetRequiredService<IPasswordHasher>();
var tokenGenerator = sp.GetRequiredService<ITokenGenerator>(); var tokenGenerator = sp.GetRequiredService<ITokenGenerator>();
var encryptor = sp.GetRequiredService<IEncryptor>(); var encryptor = sp.GetRequiredService<IEncryptor>();
var userEncryptionKey = await encryptor.GenerateEncryptedKeyAsync(TestContext.Current.CancellationToken); var userEncryptionKey = await encryptor.GenerateEncryptedKeyAsync(ct);
var user = User.From("User1", passwordHasher.Hash("@Password1"), userEncryptionKey); var user = User.From("User1", passwordHasher.Hash("@Password1"), userEncryptionKey);
var refreshToken = tokenGenerator.GenerateRefreshToken(user); var refreshToken = tokenGenerator.GenerateRefreshToken(user);
refreshToken.Revoke(); refreshToken.Revoke();
@@ -93,9 +94,9 @@ public class RefreshTests(TestApi testApi) : IntegrationTest(testApi)
context.Add(user); context.Add(user);
await context.SaveChangesAsync(TestContext.Current.CancellationToken); await context.SaveChangesAsync(ct);
return (user, refreshToken); return (user, refreshToken);
}); }, TestContext.Current.CancellationToken);
var jwt = JwtTokenBuilder.New() var jwt = JwtTokenBuilder.New()
.WithClaim(JwtRegisteredClaimNames.Sub, user.Id.ToString()) .WithClaim(JwtRegisteredClaimNames.Sub, user.Id.ToString())
@@ -113,23 +114,23 @@ public class RefreshTests(TestApi testApi) : IntegrationTest(testApi)
[Fact] [Fact]
public async Task Refresh_WhenCalledWithExpiredRefreshToken_ItShouldReturn400WithProblemDetails() public async Task Refresh_WhenCalledWithExpiredRefreshToken_ItShouldReturn400WithProblemDetails()
{ {
var (user, refreshToken) = await ExecuteDbContextAsync(static async (context, sp) => var (user, refreshToken) = await ExecuteAsync(static async (context, ct, sp) =>
{ {
var passwordHasher = sp.GetRequiredService<IPasswordHasher>(); var passwordHasher = sp.GetRequiredService<IPasswordHasher>();
var tokenGenerator = sp.GetRequiredService<ITokenGenerator>(); var tokenGenerator = sp.GetRequiredService<ITokenGenerator>();
var timeProvider = sp.GetRequiredService<TimeProvider>(); var timeProvider = sp.GetRequiredService<TimeProvider>();
var encryptor = sp.GetRequiredService<IEncryptor>(); var encryptor = sp.GetRequiredService<IEncryptor>();
var userEncryptionKey = await encryptor.GenerateEncryptedKeyAsync(TestContext.Current.CancellationToken); var userEncryptionKey = await encryptor.GenerateEncryptedKeyAsync(ct);
var user = User.From("User1", passwordHasher.Hash("@Password1"), userEncryptionKey); var user = User.From("User1", passwordHasher.Hash("@Password1"), userEncryptionKey);
var refreshToken = RefreshToken.From(user.Id, "expiredtoken", timeProvider.GetUtcNow().AddHours(-1)); var refreshToken = RefreshToken.From(user.Id, "expiredtoken", timeProvider.GetUtcNow().AddHours(-1));
user.AddRefreshToken(refreshToken); user.AddRefreshToken(refreshToken);
context.Add(user); context.Add(user);
await context.SaveChangesAsync(TestContext.Current.CancellationToken); await context.SaveChangesAsync(ct);
return (user, refreshToken); return (user, refreshToken);
}); }, TestContext.Current.CancellationToken);
var jwt = JwtTokenBuilder.New() var jwt = JwtTokenBuilder.New()
.WithClaim(JwtRegisteredClaimNames.Sub, user.Id.ToString()) .WithClaim(JwtRegisteredClaimNames.Sub, user.Id.ToString())
@@ -149,23 +150,23 @@ public class RefreshTests(TestApi testApi) : IntegrationTest(testApi)
[InlineData(-5)] [InlineData(-5)]
public async Task Refresh_WhenCalledWithValidRefreshTokenAndExpiredOrNotExpiredAccessToken_ItShouldReturn200WithNewTokensAndSetRefreshCookie(int accessTokenExpiresAtOffset) public async Task Refresh_WhenCalledWithValidRefreshTokenAndExpiredOrNotExpiredAccessToken_ItShouldReturn200WithNewTokensAndSetRefreshCookie(int accessTokenExpiresAtOffset)
{ {
var (user, refreshToken) = await ExecuteDbContextAsync(static async (context, sp) => var (user, refreshToken) = await ExecuteAsync(static async (context, ct, sp) =>
{ {
var passwordHasher = sp.GetRequiredService<IPasswordHasher>(); var passwordHasher = sp.GetRequiredService<IPasswordHasher>();
var tokenGenerator = sp.GetRequiredService<ITokenGenerator>(); var tokenGenerator = sp.GetRequiredService<ITokenGenerator>();
var timeProvider = sp.GetRequiredService<TimeProvider>(); var timeProvider = sp.GetRequiredService<TimeProvider>();
var encryptor = sp.GetRequiredService<IEncryptor>(); var encryptor = sp.GetRequiredService<IEncryptor>();
var userEncryptionKey = await encryptor.GenerateEncryptedKeyAsync(TestContext.Current.CancellationToken); var userEncryptionKey = await encryptor.GenerateEncryptedKeyAsync(ct);
var user = User.From("User1", passwordHasher.Hash("@Password1"), userEncryptionKey); var user = User.From("User1", passwordHasher.Hash("@Password1"), userEncryptionKey);
var refreshToken = tokenGenerator.GenerateRefreshToken(user); var refreshToken = tokenGenerator.GenerateRefreshToken(user);
user.AddRefreshToken(refreshToken); user.AddRefreshToken(refreshToken);
context.Add(user); context.Add(user);
await context.SaveChangesAsync(TestContext.Current.CancellationToken); await context.SaveChangesAsync(ct);
return (user, refreshToken); return (user, refreshToken);
}); }, TestContext.Current.CancellationToken);
var jwt = JwtTokenBuilder.New() var jwt = JwtTokenBuilder.New()
.WithClaim(JwtRegisteredClaimNames.Sub, user.Id.ToString()) .WithClaim(JwtRegisteredClaimNames.Sub, user.Id.ToString())
@@ -181,20 +182,22 @@ public class RefreshTests(TestApi testApi) : IntegrationTest(testApi)
response.Should().HaveSetCookieHeader("fiscalos_refresh_cookie"); response.Should().HaveSetCookieHeader("fiscalos_refresh_cookie");
await response.Should().BeJsonContentOfType<Refresh.Response>(HttpStatusCode.OK); await response.Should().BeJsonContentOfType<Refresh.Response>(HttpStatusCode.OK);
var oldRefreshTokenInDb = await ExecuteDbContextAsync( var oldRefreshTokenInDb = await ExecuteAsync(
async context => await context.Set<RefreshToken>() async (context, ct) => await context.Set<RefreshToken>()
.Include(t => t.User) .Include(t => t.User)
.Where(t => t.UserId == user.Id && t.Token == refreshToken.Token && t.Revoked == true) .Where(t => t.UserId == user.Id && t.Token == refreshToken.Token && t.Revoked == true)
.SingleOrDefaultAsync() .SingleOrDefaultAsync(ct),
TestContext.Current.CancellationToken
); );
oldRefreshTokenInDb.Should().NotBeNull(); oldRefreshTokenInDb.Should().NotBeNull();
var newRefreshTokenInDb = await ExecuteDbContextAsync( var newRefreshTokenInDb = await ExecuteAsync(
async context => await context.Set<RefreshToken>() async (context, ct) => await context.Set<RefreshToken>()
.Include(t => t.User) .Include(t => t.User)
.Where(t => t.UserId == user.Id && t.Revoked == false && t.Token != refreshToken.Token) .Where(t => t.UserId == user.Id && t.Revoked == false && t.Token != refreshToken.Token)
.SingleOrDefaultAsync() .SingleOrDefaultAsync(ct),
TestContext.Current.CancellationToken
); );
newRefreshTokenInDb.Should().NotBeNull(); newRefreshTokenInDb.Should().NotBeNull();
+6
View File
@@ -4,6 +4,8 @@ global using System.Net.Http.Headers;
global using System.Net.Http.Json; global using System.Net.Http.Json;
global using System.Security.Claims; global using System.Security.Claims;
global using System.Security.Cryptography; global using System.Security.Cryptography;
global using System.Text;
global using System.Text.Json;
global using AwesomeAssertions.Execution; global using AwesomeAssertions.Execution;
global using AwesomeAssertions.Primitives; global using AwesomeAssertions.Primitives;
@@ -13,9 +15,13 @@ global using FiscalOS.API.Tests.Infra;
global using FiscalOS.Core.Authentication; global using FiscalOS.Core.Authentication;
global using FiscalOS.Core.Identity; global using FiscalOS.Core.Identity;
global using FiscalOS.Core.Security; global using FiscalOS.Core.Security;
global using FiscalOS.Infra.Accounts.Plaid;
global using FiscalOS.Infra.Authentication; global using FiscalOS.Infra.Authentication;
global using FiscalOS.Infra.Data; global using FiscalOS.Infra.Data;
global using Going.Plaid;
global using Going.Plaid.Entity;
global using Microsoft.AspNetCore.Hosting; global using Microsoft.AspNetCore.Hosting;
global using Microsoft.AspNetCore.Mvc; global using Microsoft.AspNetCore.Mvc;
global using Microsoft.AspNetCore.Mvc.Testing; global using Microsoft.AspNetCore.Mvc.Testing;