From 57c40c86add387983832d97e313bed7620a0e4c4 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Sun, 15 Feb 2026 07:01:23 -0600 Subject: [PATCH] feat(api): implement endpoint for creating link tokens --- .../Institutions/InstitutionsExtensions.cs | 1 + .../Institutions/Link/Endpoint.cs | 31 ++++++++++ .../Institutions/Link/Response.cs | 19 ++++++ src/FiscalOS.API/Usings.cs | 3 +- .../Integration/Institutions/LinkTests.cs | 58 +++++++++++++++++++ 5 files changed, 111 insertions(+), 1 deletion(-) create mode 100644 src/FiscalOS.API/Institutions/Link/Endpoint.cs create mode 100644 src/FiscalOS.API/Institutions/Link/Response.cs create mode 100644 tests/FiscalOS.API.Tests/Integration/Institutions/LinkTests.cs diff --git a/src/FiscalOS.API/Institutions/InstitutionsExtensions.cs b/src/FiscalOS.API/Institutions/InstitutionsExtensions.cs index 3a6c855..ebb063e 100644 --- a/src/FiscalOS.API/Institutions/InstitutionsExtensions.cs +++ b/src/FiscalOS.API/Institutions/InstitutionsExtensions.cs @@ -11,6 +11,7 @@ internal static class InstitutionsExtensions institutionsGroup.MapConnectEndpoint(); institutionsGroup.MapGetAvailableEndpoint(); + institutionsGroup.MapLinkEndpoint(); return institutionsGroup; } diff --git a/src/FiscalOS.API/Institutions/Link/Endpoint.cs b/src/FiscalOS.API/Institutions/Link/Endpoint.cs new file mode 100644 index 0000000..9afb473 --- /dev/null +++ b/src/FiscalOS.API/Institutions/Link/Endpoint.cs @@ -0,0 +1,31 @@ +namespace FiscalOS.API.Institutions.Link; + +internal static class Endpoint +{ + private const string Route = "/link"; + + public static RouteHandlerBuilder MapLinkEndpoint(this RouteGroupBuilder groupBuilder) + { + return groupBuilder.MapPost(Route, HandleAsync); + } + + private static async Task 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)); + } +} \ No newline at end of file diff --git a/src/FiscalOS.API/Institutions/Link/Response.cs b/src/FiscalOS.API/Institutions/Link/Response.cs new file mode 100644 index 0000000..2de67ba --- /dev/null +++ b/src/FiscalOS.API/Institutions/Link/Response.cs @@ -0,0 +1,19 @@ +namespace FiscalOS.API.Institutions.Link; + +public sealed record Response +{ + public string LinkToken { get; init; } = string.Empty; + + [JsonConstructor] + private Response() + { + } + + public static Response From(string linkToken) + { + return new() + { + LinkToken = linkToken, + }; + } +} \ No newline at end of file diff --git a/src/FiscalOS.API/Usings.cs b/src/FiscalOS.API/Usings.cs index 57e7341..5135bcd 100644 --- a/src/FiscalOS.API/Usings.cs +++ b/src/FiscalOS.API/Usings.cs @@ -2,7 +2,6 @@ global using System.ComponentModel.DataAnnotations; global using System.Security.Claims; global using System.Text.Json.Serialization; -global using FiscalOS.Core.Identity; global using FiscalOS.API.Accounts; global using FiscalOS.API.Accounts.Add; global using FiscalOS.API.Auth; @@ -12,8 +11,10 @@ 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.Core.Authentication; +global using FiscalOS.Core.Identity; global using FiscalOS.Core.Security; global using FiscalOS.Infra.Accounts.Plaid; global using FiscalOS.Infra.Authentication; diff --git a/tests/FiscalOS.API.Tests/Integration/Institutions/LinkTests.cs b/tests/FiscalOS.API.Tests/Integration/Institutions/LinkTests.cs new file mode 100644 index 0000000..35af04a --- /dev/null +++ b/tests/FiscalOS.API.Tests/Integration/Institutions/LinkTests.cs @@ -0,0 +1,58 @@ +namespace FiscalOS.API.Tests.Integration.Institutions; + +public class LinkTests(TestApi testApi) : IntegrationTest(testApi) +{ + private static readonly Uri LinkUri = new("/institutions/link", UriKind.Relative); + + [Fact] + public async Task Link_WhenCalledAndUnauthenticated_ItShouldReturn401WithProblemDetails() + { + using var request = HttpRequestBuilder.New() + .Post(LinkUri) + .Build(); + + var res = await Client.SendAsync(request, TestContext.Current.CancellationToken); + + await res.Should().BeProblemDetails(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task Link_WhenCalledWithNonExistentUser_ItShouldReturn401WithProblemDetails() + { + using var request = HttpRequestBuilder.New() + .Post(LinkUri) + .WithUserId(Guid.NewGuid()) + .Build(); + + var res = await Client.SendAsync(request, TestContext.Current.CancellationToken); + + await res.Should().BeProblemDetails(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task Link_WhenCalledWithUserWhoExists_ItShouldReturnLinkToken() + { + var user = await ExecuteAsync(static async (context, ct, sp) => + { + var passwordHasher = sp.GetRequiredService(); + var encryptor = sp.GetRequiredService(); + + 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(HttpStatusCode.OK)) + .Which.LinkToken.Should().NotBeEmpty(); + } +} \ No newline at end of file