feat(api): implement endpoint for creating link tokens

This commit is contained in:
Stevan Freeborn
2026-02-15 07:02:01 -06:00
parent 6b2b19cb5b
commit ab88656c1e
5 changed files with 111 additions and 1 deletions
@@ -11,6 +11,7 @@ internal static class InstitutionsExtensions
institutionsGroup.MapConnectEndpoint(); institutionsGroup.MapConnectEndpoint();
institutionsGroup.MapGetAvailableEndpoint(); institutionsGroup.MapGetAvailableEndpoint();
institutionsGroup.MapLinkEndpoint();
return institutionsGroup; return institutionsGroup;
} }
@@ -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<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));
}
}
@@ -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,
};
}
}
+2 -1
View File
@@ -2,7 +2,6 @@ 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.Core.Identity;
global using FiscalOS.API.Accounts; global using FiscalOS.API.Accounts;
global using FiscalOS.API.Accounts.Add; global using FiscalOS.API.Accounts.Add;
global using FiscalOS.API.Auth; global using FiscalOS.API.Auth;
@@ -12,8 +11,10 @@ global using FiscalOS.API.Http;
global using FiscalOS.API.Institutions; global using FiscalOS.API.Institutions;
global using FiscalOS.API.Institutions.Connect; global using FiscalOS.API.Institutions.Connect;
global using FiscalOS.API.Institutions.GetAvailable; global using FiscalOS.API.Institutions.GetAvailable;
global using FiscalOS.API.Institutions.Link;
global using FiscalOS.Core.Accounts; global using FiscalOS.Core.Accounts;
global using FiscalOS.Core.Authentication; global using FiscalOS.Core.Authentication;
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.Accounts.Plaid;
global using FiscalOS.Infra.Authentication; global using FiscalOS.Infra.Authentication;
@@ -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<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();
}
}