diff --git a/src/FiscalOS.API/Accounts/Connect/Endpoint.cs b/src/FiscalOS.API/Accounts/Connect/Endpoint.cs index 1085541..099fbdb 100644 --- a/src/FiscalOS.API/Accounts/Connect/Endpoint.cs +++ b/src/FiscalOS.API/Accounts/Connect/Endpoint.cs @@ -10,9 +10,41 @@ internal static class Endpoint } private static async Task HandleAsync( - [FromBody] Request request + HttpContext httpContext, + [FromBody] Request request, + [FromServices] AppDbContext appDbContext ) { + 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); + + 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." + ); + } + return Results.Ok(); } } \ No newline at end of file diff --git a/src/FiscalOS.API/Usings.cs b/src/FiscalOS.API/Usings.cs index cf0e489..3ff8349 100644 --- a/src/FiscalOS.API/Usings.cs +++ b/src/FiscalOS.API/Usings.cs @@ -8,6 +8,7 @@ global using FiscalOS.API.Http; global using FiscalOS.API.Login; global using FiscalOS.API.Refresh; global using FiscalOS.Core.Authentication; +global using FiscalOS.Infra.Accounts.Plaid; global using FiscalOS.Infra.Authentication; global using FiscalOS.Infra.Data; global using FiscalOS.Infra.DependencyInjection; diff --git a/tests/FiscalOS.API.Tests/Integration/ConnectTests.cs b/tests/FiscalOS.API.Tests/Integration/ConnectTests.cs index 57b2888..d3df9d7 100644 --- a/tests/FiscalOS.API.Tests/Integration/ConnectTests.cs +++ b/tests/FiscalOS.API.Tests/Integration/ConnectTests.cs @@ -1,3 +1,6 @@ +using FiscalOS.Core.Accounts; +using FiscalOS.Infra.Accounts.Plaid; + namespace FiscalOS.API.Tests.Integration; public class ConnectTests(TestApi testApi) : IntegrationTest(testApi) @@ -80,4 +83,71 @@ public class ConnectTests(TestApi testApi) : IntegrationTest(testApi) ["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 ExecuteDbContextAsync(static async (context, sp) => + { + var passwordHasher = sp.GetRequiredService(); + var encryptor = sp.GetRequiredService(); + + var userEncryptionKey = await encryptor.GenerateEncryptedKeyAsync(TestContext.Current.CancellationToken); + var user = User.From("User1", passwordHasher.Hash("@Password1"), userEncryptionKey); + + var encryptedAccessToken = await encryptor.EncryptAsyncFor(user, "accessToken", TestContext.Current.CancellationToken); + var plaidMetadata = PlaidMetadata.From("alreadyExists", "Some Bank", encryptedAccessToken); + var institution = Institution.From("Some Bank", plaidMetadata); + + user.AddInstitution(institution); + + await context.AddAsync(user, TestContext.Current.CancellationToken); + await context.SaveChangesAsync(TestContext.Current.CancellationToken); + return (user, institution); + }); + + 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); + } } \ No newline at end of file