Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
57c40c86ad | ||
|
|
9f88ec4d67 |
@@ -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,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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user