Compare commits

...
6 changed files with 139 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;
@@ -1,4 +1,7 @@
using System.Reflection;
namespace FiscalOS.Infra.Accounts.Plaid; namespace FiscalOS.Infra.Accounts.Plaid;
public sealed class PlaidService public sealed class PlaidService
@@ -16,6 +19,31 @@ public sealed class PlaidService
return new(client); return new(client);
} }
public async Task<string> CreateLinkTokenAsync(string id)
{
var assemblyName = Assembly.GetExecutingAssembly().GetName().FullName;
var environmentName = System.Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT");
var ltr = await _client.LinkTokenCreateAsync(new()
{
ClientName = $"{assemblyName}_{environmentName}",
Products = [Products.Transactions],
CountryCodes = [CountryCode.Us],
Language = Language.English,
User = new()
{
ClientUserId = id,
},
}).ConfigureAwait(false);
if (ltr.IsSuccessStatusCode is false)
{
throw new PlaidException("Unable to create link token");
}
return ltr.LinkToken;
}
public async Task<(string ItemId, string AccessToken)> ExchangeTokenAsync(string publicToken) public async Task<(string ItemId, string AccessToken)> ExchangeTokenAsync(string publicToken)
{ {
var ptr = await _client.ItemPublicTokenExchangeAsync(new() var ptr = await _client.ItemPublicTokenExchangeAsync(new()
@@ -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();
}
}