From a4a0aca2b4da9860c7e5d0e3d02bbe9ce527f614 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Fri, 13 Feb 2026 11:05:57 -0600 Subject: [PATCH] feat(api): add validation to prevent duplicate accounts from being added --- .../Accounts/AccountsExtensions.cs | 4 +- src/FiscalOS.API/Accounts/Add/Endpoint.cs | 41 +++++ src/FiscalOS.API/Accounts/Add/Request.cs | 23 +++ src/FiscalOS.API/Usings.cs | 1 + .../Integration/AddTests.cs | 158 ++++++++++++++++++ 5 files changed, 226 insertions(+), 1 deletion(-) create mode 100644 src/FiscalOS.API/Accounts/Add/Endpoint.cs create mode 100644 src/FiscalOS.API/Accounts/Add/Request.cs create mode 100644 tests/FiscalOS.API.Tests/Integration/AddTests.cs diff --git a/src/FiscalOS.API/Accounts/AccountsExtensions.cs b/src/FiscalOS.API/Accounts/AccountsExtensions.cs index d5cfdb7..9791923 100644 --- a/src/FiscalOS.API/Accounts/AccountsExtensions.cs +++ b/src/FiscalOS.API/Accounts/AccountsExtensions.cs @@ -6,8 +6,10 @@ internal static class AccountsExtensions public static RouteGroupBuilder MapAccountsEndpoints(this WebApplication app) { - var accountsGroup = app.MapGroup(RouteGroupPrefix).RequireAuthorization(); + var accountsGroup = app.MapGroup(RouteGroupPrefix) + .RequireAuthorization(); + accountsGroup.MapAddEndpoint(); accountsGroup.MapConnectEndpoint(); return accountsGroup; diff --git a/src/FiscalOS.API/Accounts/Add/Endpoint.cs b/src/FiscalOS.API/Accounts/Add/Endpoint.cs new file mode 100644 index 0000000..80d915e --- /dev/null +++ b/src/FiscalOS.API/Accounts/Add/Endpoint.cs @@ -0,0 +1,41 @@ +namespace FiscalOS.API.Accounts.Add; + +internal static class Endpoint +{ + private const string Route = "/"; + + public static RouteHandlerBuilder MapAddEndpoint(this RouteGroupBuilder groupBuilder) + { + return groupBuilder.MapPost(Route, HandleAsync); + } + + private static async Task HandleAsync( + HttpContext httpContext, + [FromBody] Request request, + [FromServices] AppDbContext appDbContext, + CancellationToken ct + ) + { + var userId = httpContext.GetUserId(); + + var user = await appDbContext.Users + .Include(u => u.Accounts.Where(a => a.Metadata is PlaidAccountMetadata && ((PlaidAccountMetadata)a.Metadata).PlaidId == request.PlaidAccountId)) + .ThenInclude(a => a.Metadata) + .SingleOrDefaultAsync(u => u.Id == userId, ct); + + if (user is null) + { + return Results.Unauthorized(); + } + + if (user.Accounts.Any()) + { + return Results.Conflict(); + } + + // TODO: This is new account + // so we should add to the database + + return Results.Ok(); + } +} \ No newline at end of file diff --git a/src/FiscalOS.API/Accounts/Add/Request.cs b/src/FiscalOS.API/Accounts/Add/Request.cs new file mode 100644 index 0000000..13c8f56 --- /dev/null +++ b/src/FiscalOS.API/Accounts/Add/Request.cs @@ -0,0 +1,23 @@ + +namespace FiscalOS.API.Accounts.Add; + +public record Request : IValidatableObject +{ + public string PlaidInstitutionId { get; init; } = string.Empty; + public string PlaidAccountId { get; init; } = string.Empty; + + public IEnumerable Validate(ValidationContext validationContext) + { + if (string.IsNullOrWhiteSpace(PlaidInstitutionId)) + { + var fieldName = nameof(PlaidInstitutionId); + yield return new($"The {fieldName} field is required.", [fieldName]); + } + + if (string.IsNullOrWhiteSpace(PlaidAccountId)) + { + var fieldName = nameof(PlaidAccountId); + yield return new($"The {fieldName} field is required.", [fieldName]); + } + } +} \ No newline at end of file diff --git a/src/FiscalOS.API/Usings.cs b/src/FiscalOS.API/Usings.cs index ca2eeef..f48fff1 100644 --- a/src/FiscalOS.API/Usings.cs +++ b/src/FiscalOS.API/Usings.cs @@ -3,6 +3,7 @@ global using System.Security.Claims; global using System.Text.Json.Serialization; global using FiscalOS.API.Accounts; +global using FiscalOS.API.Accounts.Add; global using FiscalOS.API.Accounts.Connect; global using FiscalOS.API.Http; global using FiscalOS.API.Login; diff --git a/tests/FiscalOS.API.Tests/Integration/AddTests.cs b/tests/FiscalOS.API.Tests/Integration/AddTests.cs new file mode 100644 index 0000000..81e8827 --- /dev/null +++ b/tests/FiscalOS.API.Tests/Integration/AddTests.cs @@ -0,0 +1,158 @@ +using Institution = FiscalOS.Core.Accounts.Institution; +using Account = FiscalOS.Core.Accounts.Account; + +namespace FiscalOS.API.Tests.Integration; + +public class AddTests(TestApi testApi) : IntegrationTest(testApi) +{ + private static readonly Uri AddUri = new("/accounts", UriKind.Relative); + + [Fact] + public async Task Add_WhenCalledWhenNotLoggedIn_ItShouldReturn401WithProblemDetails() + { + var response = await Client.PostAsync(AddUri, null, TestContext.Current.CancellationToken); + + await response.Should().BeProblemDetails(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task Add_WhenCalledWithoutInstitutionIdOrAccountId_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, 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() + { + ["PlaidInstitutionId"] = ["The PlaidInstitutionId field is required."], + ["PlaidAccountId"] = ["The PlaidAccountId field is required."], + }); + } + + [Fact] + public async Task Add_WhenCalledWithoutInstitutionId_ItShouldReturn400WithProblemDetails() + { + var jwt = JwtTokenBuilder.New() + .WithClaim(JwtRegisteredClaimNames.Sub, Guid.NewGuid().ToString()) + .Build(); + + using var content = new StringContent(JsonSerializer.Serialize(new { plaidAccountId = "accountId" }), 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() + { + ["PlaidInstitutionId"] = ["The PlaidInstitutionId field is required."], + }); + } + + [Fact] + public async Task Add_WhenCalledWithoutAccountId_ItShouldReturn400WithProblemDetails() + { + var jwt = JwtTokenBuilder.New() + .WithClaim(JwtRegisteredClaimNames.Sub, Guid.NewGuid().ToString()) + .Build(); + + using var content = new StringContent(JsonSerializer.Serialize(new { plaidInstitutionId = "institutionId" }), 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() + { + ["PlaidAccountId"] = ["The PlaidAccountId field is required."], + }); + } + + [Fact] + public async Task Add_WhenCalledWithNonExistentUser_ItShouldReturn401WithProblemDetails() + { + var jwt = JwtTokenBuilder.New() + .WithClaim(JwtRegisteredClaimNames.Sub, Guid.NewGuid().ToString()) + .Build(); + + var json = JsonSerializer.Serialize(new + { + plaidInstitutionId = "id", + plaidAccountId = "id", + }); + 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); + } + + [Fact] + public async Task Add_WhenCalledWithPlaidAccountIdThatHasAlreadyBeenAdded_ItShouldReturn409WithProblemDetails() + { + var (user, institution, account) = await ExecuteAsync(static async (context, ct, sp) => + { + var passwordHasher = sp.GetRequiredService(); + var encryptor = sp.GetRequiredService(); + + var userEncryptionKey = await encryptor.GenerateEncryptedKeyAsync(ct); + var user = User.From("User1", passwordHasher.Hash("@Password1"), userEncryptionKey); + + await context.AddAsync(user, ct); + + var encryptedAccessToken = await encryptor.EncryptAsyncFor(user, "accessToken", ct); + var plaidMetadata = PlaidMetadata.From("alreadyExists", "Some Bank", encryptedAccessToken); + var institution = Institution.From("Some Bank", plaidMetadata); + + await context.AddAsync(institution, ct); + + var plaidAccountMetadata = PlaidAccountMetadata.From("accountId", "Some Account"); + var account = Account.From(institution.Id, "Some Account", plaidAccountMetadata); + + user.AddInstitution(institution); + user.AddAccount(account); + + await context.SaveChangesAsync(ct); + return (user, institution, account); + }, TestContext.Current.CancellationToken); + + 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, + }); + 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); + } +} \ No newline at end of file