diff --git a/src/FiscalOS.API/Accounts/AccountsExtensions.cs b/src/FiscalOS.API/Accounts/AccountsExtensions.cs index 9791923..fbb5daa 100644 --- a/src/FiscalOS.API/Accounts/AccountsExtensions.cs +++ b/src/FiscalOS.API/Accounts/AccountsExtensions.cs @@ -10,7 +10,6 @@ internal static class AccountsExtensions .RequireAuthorization(); accountsGroup.MapAddEndpoint(); - accountsGroup.MapConnectEndpoint(); return accountsGroup; } diff --git a/src/FiscalOS.API/Accounts/Add/Endpoint.cs b/src/FiscalOS.API/Accounts/Add/Endpoint.cs index e55cc09..7977be7 100644 --- a/src/FiscalOS.API/Accounts/Add/Endpoint.cs +++ b/src/FiscalOS.API/Accounts/Add/Endpoint.cs @@ -1,5 +1,3 @@ -using FiscalOS.Core.Accounts; - namespace FiscalOS.API.Accounts.Add; internal static class Endpoint diff --git a/src/FiscalOS.API/Accounts/Add/Request.cs b/src/FiscalOS.API/Accounts/Add/Request.cs index cb99492..e78fb3d 100644 --- a/src/FiscalOS.API/Accounts/Add/Request.cs +++ b/src/FiscalOS.API/Accounts/Add/Request.cs @@ -1,4 +1,3 @@ - namespace FiscalOS.API.Accounts.Add; public record Request : IValidatableObject diff --git a/src/FiscalOS.API/Auth/AuthExtensions.cs b/src/FiscalOS.API/Auth/AuthExtensions.cs new file mode 100644 index 0000000..2e55eaf --- /dev/null +++ b/src/FiscalOS.API/Auth/AuthExtensions.cs @@ -0,0 +1,18 @@ +namespace FiscalOS.API.Auth; + +internal static class AuthExtensions +{ + private const string RouteGroupPrefix = "/auth"; + + public static RouteGroupBuilder MapAuthEndpoints(this WebApplication app) + { + var authGroup = app.MapGroup(RouteGroupPrefix); + + authGroup.MapLoginEndpoint(); + + authGroup.MapRefreshEndpoint() + .RequireAuthorization(Schemes.AllowExpiredTokens); + + return authGroup; + } +} \ No newline at end of file diff --git a/src/FiscalOS.API/Login/Endpoint.cs b/src/FiscalOS.API/Auth/Login/Endpoint.cs similarity index 85% rename from src/FiscalOS.API/Login/Endpoint.cs rename to src/FiscalOS.API/Auth/Login/Endpoint.cs index 46cfe54..208cc4e 100644 --- a/src/FiscalOS.API/Login/Endpoint.cs +++ b/src/FiscalOS.API/Auth/Login/Endpoint.cs @@ -1,12 +1,12 @@ -namespace FiscalOS.API.Login; +namespace FiscalOS.API.Auth.Login; internal static class Endpoint { private const string Route = "/login"; - public static RouteHandlerBuilder MapLoginEndpoint(this WebApplication app) + public static RouteHandlerBuilder MapLoginEndpoint(this RouteGroupBuilder groupBuilder) { - return app.MapPost(Route, HandleAsync); + return groupBuilder.MapPost(Route, HandleAsync); } private static async Task HandleAsync( diff --git a/src/FiscalOS.API/Login/Request.cs b/src/FiscalOS.API/Auth/Login/Request.cs similarity index 94% rename from src/FiscalOS.API/Login/Request.cs rename to src/FiscalOS.API/Auth/Login/Request.cs index 9e410cb..a22634b 100644 --- a/src/FiscalOS.API/Login/Request.cs +++ b/src/FiscalOS.API/Auth/Login/Request.cs @@ -1,4 +1,4 @@ -namespace FiscalOS.API.Login; +namespace FiscalOS.API.Auth.Login; public record Request : IValidatableObject { diff --git a/src/FiscalOS.API/Login/Response.cs b/src/FiscalOS.API/Auth/Login/Response.cs similarity index 90% rename from src/FiscalOS.API/Login/Response.cs rename to src/FiscalOS.API/Auth/Login/Response.cs index 0a8115c..941fcd3 100644 --- a/src/FiscalOS.API/Login/Response.cs +++ b/src/FiscalOS.API/Auth/Login/Response.cs @@ -1,4 +1,4 @@ -namespace FiscalOS.API.Login; +namespace FiscalOS.API.Auth.Login; internal sealed record Response { diff --git a/src/FiscalOS.API/Refresh/Endpoint.cs b/src/FiscalOS.API/Auth/Refresh/Endpoint.cs similarity index 89% rename from src/FiscalOS.API/Refresh/Endpoint.cs rename to src/FiscalOS.API/Auth/Refresh/Endpoint.cs index 99660fd..686217a 100644 --- a/src/FiscalOS.API/Refresh/Endpoint.cs +++ b/src/FiscalOS.API/Auth/Refresh/Endpoint.cs @@ -1,12 +1,12 @@ -namespace FiscalOS.API.Refresh; +namespace FiscalOS.API.Auth.Refresh; internal static class Endpoint { private const string Route = "/refresh"; - public static RouteHandlerBuilder MapRefreshEndpoint(this WebApplication app) + public static RouteHandlerBuilder MapRefreshEndpoint(this RouteGroupBuilder groupBuilder) { - return app.MapPost(Route, HandleAsync); + return groupBuilder.MapPost(Route, HandleAsync); } private static async Task HandleAsync( diff --git a/src/FiscalOS.API/Refresh/Response.cs b/src/FiscalOS.API/Auth/Refresh/Response.cs similarity index 89% rename from src/FiscalOS.API/Refresh/Response.cs rename to src/FiscalOS.API/Auth/Refresh/Response.cs index 99a5c48..8afef78 100644 --- a/src/FiscalOS.API/Refresh/Response.cs +++ b/src/FiscalOS.API/Auth/Refresh/Response.cs @@ -1,4 +1,4 @@ -namespace FiscalOS.API.Refresh; +namespace FiscalOS.API.Auth.Refresh; internal sealed record Response { diff --git a/src/FiscalOS.API/Http/HttpContextExtensions.cs b/src/FiscalOS.API/Http/HttpContextExtensions.cs index 0f410cb..dd443f7 100644 --- a/src/FiscalOS.API/Http/HttpContextExtensions.cs +++ b/src/FiscalOS.API/Http/HttpContextExtensions.cs @@ -1,5 +1,3 @@ -using FiscalOS.Core.Identity; - namespace FiscalOS.API.Http; internal static class HttpContextExtensions diff --git a/src/FiscalOS.API/Accounts/Connect/Endpoint.cs b/src/FiscalOS.API/Institutions/Connect/Endpoint.cs similarity index 95% rename from src/FiscalOS.API/Accounts/Connect/Endpoint.cs rename to src/FiscalOS.API/Institutions/Connect/Endpoint.cs index 4dac128..8eecb10 100644 --- a/src/FiscalOS.API/Accounts/Connect/Endpoint.cs +++ b/src/FiscalOS.API/Institutions/Connect/Endpoint.cs @@ -1,6 +1,4 @@ -using FiscalOS.Core.Accounts; - -namespace FiscalOS.API.Accounts.Connect; +namespace FiscalOS.API.Institutions.Connect; internal static class Endpoint { diff --git a/src/FiscalOS.API/Accounts/Connect/Request.cs b/src/FiscalOS.API/Institutions/Connect/Request.cs similarity index 93% rename from src/FiscalOS.API/Accounts/Connect/Request.cs rename to src/FiscalOS.API/Institutions/Connect/Request.cs index 980369a..aeda999 100644 --- a/src/FiscalOS.API/Accounts/Connect/Request.cs +++ b/src/FiscalOS.API/Institutions/Connect/Request.cs @@ -1,4 +1,4 @@ -namespace FiscalOS.API.Accounts.Connect; +namespace FiscalOS.API.Institutions.Connect; public record Request : IValidatableObject { diff --git a/src/FiscalOS.API/Institutions/GetAvailable/Endpoint.cs b/src/FiscalOS.API/Institutions/GetAvailable/Endpoint.cs new file mode 100644 index 0000000..5401082 --- /dev/null +++ b/src/FiscalOS.API/Institutions/GetAvailable/Endpoint.cs @@ -0,0 +1,50 @@ +namespace FiscalOS.API.Institutions.GetAvailable; + +internal static class Endpoint +{ + private const string Route = "/{id}/available"; + + public static RouteHandlerBuilder MapGetAvailableEndpoint(this RouteGroupBuilder groupBuilder) + { + return groupBuilder.MapGet(Route, HandleAsync); + } + + private static async Task HandleAsync( + HttpContext httpContext, + [FromRoute] Guid id, + [FromServices] AppDbContext appDbContext, + [FromServices] PlaidService plaidService, + [FromServices] IEncryptor encryptor, + CancellationToken ct + ) + { + var userId = httpContext.GetUserId(); + + var user = await appDbContext.Users + .Include(u => u.Institutions.Where(i => i.Id == id)) + .ThenInclude(i => i.Metadata) + .SingleOrDefaultAsync(u => u.Id == userId, ct); + + if (user is null) + { + return Results.Unauthorized(); + } + + if (user.Institutions.Any() is false) + { + return Results.NotFound(); + } + + var institution = user.Institutions.First(); + + if (institution.Metadata is not PlaidMetadata plaidMetadata) + { + return Results.BadRequest(); + } + + var accessToken = await encryptor.DecryptAsyncFor(user, plaidMetadata.EncryptedAccessToken, ct); + var accounts = await plaidService.GetAccountsAsync(accessToken); + + return Results.Ok(accounts); + } +} \ No newline at end of file diff --git a/src/FiscalOS.API/Institutions/InstitutionsExtensions.cs b/src/FiscalOS.API/Institutions/InstitutionsExtensions.cs new file mode 100644 index 0000000..3a6c855 --- /dev/null +++ b/src/FiscalOS.API/Institutions/InstitutionsExtensions.cs @@ -0,0 +1,17 @@ +namespace FiscalOS.API.Institutions; + +internal static class InstitutionsExtensions +{ + private const string RouteGroupPrefix = "/institutions"; + + public static RouteGroupBuilder MapInstitutionsEndpoints(this WebApplication app) + { + var institutionsGroup = app.MapGroup(RouteGroupPrefix) + .RequireAuthorization(); + + institutionsGroup.MapConnectEndpoint(); + institutionsGroup.MapGetAvailableEndpoint(); + + return institutionsGroup; + } +} \ No newline at end of file diff --git a/src/FiscalOS.API/Program.cs b/src/FiscalOS.API/Program.cs index 6ce3b7d..c69fcce 100644 --- a/src/FiscalOS.API/Program.cs +++ b/src/FiscalOS.API/Program.cs @@ -32,11 +32,8 @@ app.UseAuthentication(); app.UseAuthorization(); app.UseStatusCodePages(); -app.MapLoginEndpoint(); - -app.MapRefreshEndpoint() - .RequireAuthorization(Schemes.AllowExpiredTokens); - +app.MapAuthEndpoints(); app.MapAccountsEndpoints(); +app.MapInstitutionsEndpoints(); app.Run(); \ No newline at end of file diff --git a/src/FiscalOS.API/Usings.cs b/src/FiscalOS.API/Usings.cs index f48fff1..57e7341 100644 --- a/src/FiscalOS.API/Usings.cs +++ b/src/FiscalOS.API/Usings.cs @@ -2,12 +2,17 @@ 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.Accounts.Connect; +global using FiscalOS.API.Auth; +global using FiscalOS.API.Auth.Login; +global using FiscalOS.API.Auth.Refresh; global using FiscalOS.API.Http; -global using FiscalOS.API.Login; -global using FiscalOS.API.Refresh; +global using FiscalOS.API.Institutions; +global using FiscalOS.API.Institutions.Connect; +global using FiscalOS.API.Institutions.GetAvailable; +global using FiscalOS.Core.Accounts; global using FiscalOS.Core.Authentication; global using FiscalOS.Core.Security; global using FiscalOS.Infra.Accounts.Plaid; diff --git a/src/FiscalOS.Infra/Accounts/Plaid/PlaidService.cs b/src/FiscalOS.Infra/Accounts/Plaid/PlaidService.cs index 7447c28..f2d9f47 100644 --- a/src/FiscalOS.Infra/Accounts/Plaid/PlaidService.cs +++ b/src/FiscalOS.Infra/Accounts/Plaid/PlaidService.cs @@ -1,3 +1,4 @@ + namespace FiscalOS.Infra.Accounts.Plaid; public sealed class PlaidService @@ -30,6 +31,21 @@ public sealed class PlaidService return (ptr.ItemId, ptr.AccessToken); } + public async Task> GetAccountsAsync(string accessToken) + { + var ar = await _client.AccountsGetAsync(new() + { + AccessToken = accessToken, + }).ConfigureAwait(false); + + if (ar.IsSuccessStatusCode is false) + { + throw new PlaidException("Unable to retrieve accounts"); + } + + return [.. ar.Accounts]; + } + public async Task GetItemAsync(string accessToken) { var ar = await _client.ItemGetAsync(new() @@ -44,4 +60,4 @@ public sealed class PlaidService return ar.Item; } -} +} \ No newline at end of file diff --git a/tests/FiscalOS.API.Tests/Assertions/HttpResponseMessageAssertions.cs b/tests/FiscalOS.API.Tests/Assertions/HttpResponseMessageAssertions.cs index abd5b98..9fd6b78 100644 --- a/tests/FiscalOS.API.Tests/Assertions/HttpResponseMessageAssertions.cs +++ b/tests/FiscalOS.API.Tests/Assertions/HttpResponseMessageAssertions.cs @@ -21,12 +21,6 @@ internal sealed class HttpResponseMessageAssertions( HttpStatusCode expectedStatusCode ) { - _chain.ForCondition(Subject.Content.Headers.ContentType?.MediaType is "application/json") - .FailWith( - "Expected response to be application/json, but found {0}", - Subject.Content.Headers.ContentType?.MediaType - ); - _chain.ForCondition(Subject.StatusCode == expectedStatusCode) .FailWith( "Expected response status code to be {0}, but found {1}", @@ -34,6 +28,13 @@ internal sealed class HttpResponseMessageAssertions( Subject.StatusCode ); + _chain.ForCondition(Subject.Content.Headers.ContentType?.MediaType is "application/json") + .FailWith( + "Expected response to be application/json, but found {0}", + Subject.Content.Headers.ContentType?.MediaType + ); + + var content = await Subject.Content.ReadFromJsonAsync(); _chain.ForCondition(content is not null) diff --git a/tests/FiscalOS.API.Tests/Infra/HttpRequestBuilder.cs b/tests/FiscalOS.API.Tests/Infra/HttpRequestBuilder.cs new file mode 100644 index 0000000..e88b4dc --- /dev/null +++ b/tests/FiscalOS.API.Tests/Infra/HttpRequestBuilder.cs @@ -0,0 +1,128 @@ +namespace FiscalOS.API.Tests.Infra; + +internal sealed class HttpRequestBuilder +{ + private HttpMethod _method = HttpMethod.Get; + private Uri? _uri; + private object? _body; + private string? _bearerToken; + private readonly Dictionary _cookies = []; + private readonly Dictionary _headers = []; + + private HttpRequestBuilder() + { + } + + public static HttpRequestBuilder New() => new(); + + public HttpRequestBuilder WithMethod(HttpMethod method) + { + _method = method; + return this; + } + + public HttpRequestBuilder WithUri(Uri uri) + { + _uri = uri; + return this; + } + + public HttpRequestBuilder WithBody(T body) + { + _body = body; + return this; + } + + public HttpRequestBuilder WithBearerToken(string token) + { + _bearerToken = token; + return this; + } + + public HttpRequestBuilder WithUserId(Guid userId) + { + _bearerToken = JwtTokenBuilder.New() + .WithClaim(JwtRegisteredClaimNames.Sub, userId.ToString()) + .Build(); + + return this; + } + + public HttpRequestBuilder WithCookie(string name, string value) + { + _cookies[name] = value; + return this; + } + + public HttpRequestBuilder WithRefreshCookie(string token) + { + return WithCookie("fiscalos_refresh_cookie", token); + } + + public HttpRequestBuilder WithHeader(string name, string value) + { + _headers[name] = value; + return this; + } + + public HttpRequestBuilder Post(Uri uri) + { + _method = HttpMethod.Post; + _uri = uri; + return this; + } + + public HttpRequestBuilder Get(Uri uri) + { + _method = HttpMethod.Get; + _uri = uri; + return this; + } + + public HttpRequestBuilder Put(Uri uri) + { + _method = HttpMethod.Put; + _uri = uri; + return this; + } + + public HttpRequestBuilder Delete(Uri uri) + { + _method = HttpMethod.Delete; + _uri = uri; + return this; + } + + public HttpRequestMessage Build() + { + if (_uri is null) + { + throw new InvalidOperationException("URI must be set before building the request."); + } + + var request = new HttpRequestMessage(_method, _uri); + + if (_body is not null) + { + var json = JsonSerializer.Serialize(_body); + request.Content = new StringContent(json, Encoding.UTF8, "application/json"); + } + + if (_bearerToken is not null) + { + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _bearerToken); + } + + foreach (var (key, value) in _cookies) + { + request.Headers.Add("Cookie", $"{key}={value}"); + } + + foreach (var (key, value) in _headers) + { + request.Headers.Add(key, value); + } + + return request; + } +} \ No newline at end of file diff --git a/tests/FiscalOS.API.Tests/Common/SerializableDictionary.cs b/tests/FiscalOS.API.Tests/Infra/SerializableDictionary.cs similarity index 96% rename from tests/FiscalOS.API.Tests/Common/SerializableDictionary.cs rename to tests/FiscalOS.API.Tests/Infra/SerializableDictionary.cs index 767a59a..9b36319 100644 --- a/tests/FiscalOS.API.Tests/Common/SerializableDictionary.cs +++ b/tests/FiscalOS.API.Tests/Infra/SerializableDictionary.cs @@ -1,4 +1,4 @@ -namespace FiscalOS.API.Tests.Common; +namespace FiscalOS.API.Tests.Infra; public interface ISerializableDictionary : IDictionary, IXunitSerializable { diff --git a/tests/FiscalOS.API.Tests/Infra/TestApi.cs b/tests/FiscalOS.API.Tests/Infra/TestApi.cs index 4b25baf..096fb52 100644 --- a/tests/FiscalOS.API.Tests/Infra/TestApi.cs +++ b/tests/FiscalOS.API.Tests/Infra/TestApi.cs @@ -1,5 +1,3 @@ -using Microsoft.Extensions.Configuration; - namespace FiscalOS.API.Tests.Infra; public class TestApi : WebApplicationFactory diff --git a/tests/FiscalOS.API.Tests/Integration/AddTests.cs b/tests/FiscalOS.API.Tests/Integration/Accounts/AddTests.cs similarity index 61% rename from tests/FiscalOS.API.Tests/Integration/AddTests.cs rename to tests/FiscalOS.API.Tests/Integration/Accounts/AddTests.cs index c4d2a97..d4d8412 100644 --- a/tests/FiscalOS.API.Tests/Integration/AddTests.cs +++ b/tests/FiscalOS.API.Tests/Integration/Accounts/AddTests.cs @@ -1,7 +1,7 @@ using Account = FiscalOS.Core.Accounts.Account; using Institution = FiscalOS.Core.Accounts.Institution; -namespace FiscalOS.API.Tests.Integration; +namespace FiscalOS.API.Tests.Integration.Accounts; public class AddTests(TestApi testApi) : IntegrationTest(testApi) { @@ -18,17 +18,12 @@ public class AddTests(TestApi testApi) : IntegrationTest(testApi) [Fact] public async Task Add_WhenCalledWithoutInstitutionIdOrAccountIdOrAccountName_ItShouldReturn400WithProblemDetails() { - var jwt = JwtTokenBuilder.New() - .WithClaim(JwtRegisteredClaimNames.Sub, Guid.NewGuid().ToString()) + using var request = HttpRequestBuilder.New() + .Post(AddUri) + .WithUserId(Guid.NewGuid()) + .WithBody(new { }) .Build(); - using var content = new StringContent(JsonSerializer.Serialize(new { }), Encoding.UTF8, "application/json"); - using var request = new HttpRequestMessage(HttpMethod.Post, AddUri) - { - Content = content - }; - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", jwt); - var response = await Client.SendAsync(request, TestContext.Current.CancellationToken); await response.Should().BeValidationProblemDetails(new Dictionary() @@ -42,22 +37,16 @@ public class AddTests(TestApi testApi) : IntegrationTest(testApi) [Fact] public async Task Add_WhenCalledWithoutInstitutionId_ItShouldReturn400WithProblemDetails() { - var jwt = JwtTokenBuilder.New() - .WithClaim(JwtRegisteredClaimNames.Sub, Guid.NewGuid().ToString()) + using var request = HttpRequestBuilder.New() + .Post(AddUri) + .WithUserId(Guid.NewGuid()) + .WithBody(new + { + plaidAccountId = "accountId", + plaidAccountName = "Some Account", + }) .Build(); - var json = JsonSerializer.Serialize(new - { - plaidAccountId = "accountId", - plaidAccountName = "Some Account", - }); - using var content = new StringContent(json, Encoding.UTF8, "application/json"); - using var request = new HttpRequestMessage(HttpMethod.Post, AddUri) - { - Content = content - }; - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", jwt); - var response = await Client.SendAsync(request, TestContext.Current.CancellationToken); await response.Should().BeValidationProblemDetails(new Dictionary() @@ -69,22 +58,16 @@ public class AddTests(TestApi testApi) : IntegrationTest(testApi) [Fact] public async Task Add_WhenCalledWithoutAccountId_ItShouldReturn400WithProblemDetails() { - var jwt = JwtTokenBuilder.New() - .WithClaim(JwtRegisteredClaimNames.Sub, Guid.NewGuid().ToString()) + using var request = HttpRequestBuilder.New() + .Post(AddUri) + .WithUserId(Guid.NewGuid()) + .WithBody(new + { + plaidInstitutionId = "institutionId", + plaidAccountName = "Some Account", + }) .Build(); - var json = JsonSerializer.Serialize(new - { - plaidInstitutionId = "institutionId", - plaidAccountName = "Some Account", - }); - using var content = new StringContent(json, Encoding.UTF8, "application/json"); - using var request = new HttpRequestMessage(HttpMethod.Post, AddUri) - { - Content = content - }; - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", jwt); - var response = await Client.SendAsync(request, TestContext.Current.CancellationToken); await response.Should().BeValidationProblemDetails(new Dictionary() @@ -96,22 +79,16 @@ public class AddTests(TestApi testApi) : IntegrationTest(testApi) [Fact] public async Task Add_WhenCalledWithoutAccountName_ItShouldReturn400WithProblemDetails() { - var jwt = JwtTokenBuilder.New() - .WithClaim(JwtRegisteredClaimNames.Sub, Guid.NewGuid().ToString()) + using var request = HttpRequestBuilder.New() + .Post(AddUri) + .WithUserId(Guid.NewGuid()) + .WithBody(new + { + plaidInstitutionId = "institutionId", + plaidAccountId = "accountId", + }) .Build(); - var json = JsonSerializer.Serialize(new - { - plaidInstitutionId = "institutionId", - plaidAccountId = "accountId", - }); - using var content = new StringContent(json, Encoding.UTF8, "application/json"); - using var request = new HttpRequestMessage(HttpMethod.Post, AddUri) - { - Content = content - }; - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", jwt); - var response = await Client.SendAsync(request, TestContext.Current.CancellationToken); await response.Should().BeValidationProblemDetails(new Dictionary() @@ -123,23 +100,17 @@ public class AddTests(TestApi testApi) : IntegrationTest(testApi) [Fact] public async Task Add_WhenCalledWithNonExistentUser_ItShouldReturn401WithProblemDetails() { - var jwt = JwtTokenBuilder.New() - .WithClaim(JwtRegisteredClaimNames.Sub, Guid.NewGuid().ToString()) + using var request = HttpRequestBuilder.New() + .Post(AddUri) + .WithUserId(Guid.NewGuid()) + .WithBody(new + { + plaidInstitutionId = "id", + plaidAccountId = "id", + plaidAccountName = "Some Account", + }) .Build(); - var json = JsonSerializer.Serialize(new - { - plaidInstitutionId = "id", - plaidAccountId = "id", - plaidAccountName = "Some Account", - }); - using var content = new StringContent(json, Encoding.UTF8, "application/json"); - using var request = new HttpRequestMessage(HttpMethod.Post, AddUri) - { - Content = content, - }; - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", jwt); - var response = await Client.SendAsync(request, TestContext.Current.CancellationToken); await response.Should().BeProblemDetails(HttpStatusCode.Unauthorized); @@ -162,23 +133,17 @@ public class AddTests(TestApi testApi) : IntegrationTest(testApi) return user; }, TestContext.Current.CancellationToken); - var jwt = JwtTokenBuilder.New() - .WithClaim(JwtRegisteredClaimNames.Sub, user.Id.ToString()) + using var request = HttpRequestBuilder.New() + .Post(AddUri) + .WithUserId(user.Id) + .WithBody(new + { + plaidInstitutionId = "id", + plaidAccountId = "id", + plaidAccountName = "Some Account", + }) .Build(); - var json = JsonSerializer.Serialize(new - { - plaidInstitutionId = "id", - plaidAccountId = "id", - plaidAccountName = "Some Account", - }); - using var content = new StringContent(json, Encoding.UTF8, "application/json"); - using var request = new HttpRequestMessage(HttpMethod.Post, AddUri) - { - Content = content, - }; - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", jwt); - var response = await Client.SendAsync(request, TestContext.Current.CancellationToken); await response.Should().BeValidationProblemDetails(new Dictionary() @@ -216,23 +181,17 @@ public class AddTests(TestApi testApi) : IntegrationTest(testApi) return (user, institution, account); }, TestContext.Current.CancellationToken); - var jwt = JwtTokenBuilder.New() - .WithClaim(JwtRegisteredClaimNames.Sub, user.Id.ToString()) + using var request = HttpRequestBuilder.New() + .Post(AddUri) + .WithUserId(user.Id) + .WithBody(new + { + plaidInstitutionId = ((PlaidMetadata)institution.Metadata!).PlaidId, + plaidAccountId = ((PlaidAccountMetadata)account.Metadata!).PlaidId, + plaidAccountName = ((PlaidAccountMetadata)account.Metadata).PlaidName, + }) .Build(); - var json = JsonSerializer.Serialize(new - { - plaidInstitutionId = ((PlaidMetadata)institution.Metadata!).PlaidId, - plaidAccountId = ((PlaidAccountMetadata)account.Metadata!).PlaidId, - plaidAccountName = ((PlaidAccountMetadata)account.Metadata).PlaidName, - }); - using var content = new StringContent(json, Encoding.UTF8, "application/json"); - using var request = new HttpRequestMessage(HttpMethod.Post, AddUri) - { - Content = content, - }; - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", jwt); - var response = await Client.SendAsync(request, TestContext.Current.CancellationToken); await response.Should().BeProblemDetails(HttpStatusCode.Conflict); @@ -261,25 +220,19 @@ public class AddTests(TestApi testApi) : IntegrationTest(testApi) return (user, institution); }, TestContext.Current.CancellationToken); - var jwt = JwtTokenBuilder.New() - .WithClaim(JwtRegisteredClaimNames.Sub, user.Id.ToString()) - .Build(); - var newAccountId = "newAccountId"; var newAccountName = "New Account"; - var json = JsonSerializer.Serialize(new - { - plaidInstitutionId = ((PlaidMetadata)institution.Metadata!).PlaidId, - plaidAccountId = newAccountId, - plaidAccountName = newAccountName, - }); - using var content = new StringContent(json, Encoding.UTF8, "application/json"); - using var request = new HttpRequestMessage(HttpMethod.Post, AddUri) - { - Content = content, - }; - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", jwt); + using var request = HttpRequestBuilder.New() + .Post(AddUri) + .WithUserId(user.Id) + .WithBody(new + { + plaidInstitutionId = ((PlaidMetadata)institution.Metadata!).PlaidId, + plaidAccountId = newAccountId, + plaidAccountName = newAccountName, + }) + .Build(); var response = await Client.SendAsync(request, TestContext.Current.CancellationToken); diff --git a/tests/FiscalOS.API.Tests/Integration/LoginTests.cs b/tests/FiscalOS.API.Tests/Integration/Auth/LoginTests.cs similarity index 75% rename from tests/FiscalOS.API.Tests/Integration/LoginTests.cs rename to tests/FiscalOS.API.Tests/Integration/Auth/LoginTests.cs index 439de77..d0432b6 100644 --- a/tests/FiscalOS.API.Tests/Integration/LoginTests.cs +++ b/tests/FiscalOS.API.Tests/Integration/Auth/LoginTests.cs @@ -1,22 +1,23 @@ -using FiscalOS.API.Tests.Common; - -namespace FiscalOS.API.Tests.Integration; +namespace FiscalOS.API.Tests.Integration.Auth; public class LoginTests(TestApi testApi) : IntegrationTest(testApi) { - private static readonly Uri LoginUri = new("/login", UriKind.Relative); + private static readonly Uri LoginUri = new("/auth/login", UriKind.Relative); [Theory] [ClassData] public async Task Login_WhenUserSubmitsInvalidRequest_ItShouldReturn400WithProblemDetails(LoginValidationTestCase tc) { - var req = new - { - username = tc.Username, - password = tc.Password, - }; + using var request = HttpRequestBuilder.New() + .Post(LoginUri) + .WithBody(new + { + username = tc.Username, + password = tc.Password, + }) + .Build(); - var res = await Client.PostAsJsonAsync(LoginUri, req, TestContext.Current.CancellationToken); + var res = await Client.SendAsync(request, TestContext.Current.CancellationToken); await res.Should().BeValidationProblemDetails(tc.ExpectedErrors); } @@ -24,13 +25,16 @@ public class LoginTests(TestApi testApi) : IntegrationTest(testApi) [Fact] public async Task Login_WhenUserDoesNotExist_ItShouldReturn401WithProblemDetails() { - var req = new - { - username = "Test", - password = "@Password2", - }; + using var request = HttpRequestBuilder.New() + .Post(LoginUri) + .WithBody(new + { + username = "Test", + password = "@Password2", + }) + .Build(); - var res = await Client.PostAsJsonAsync(LoginUri, req, TestContext.Current.CancellationToken); + var res = await Client.SendAsync(request, TestContext.Current.CancellationToken); await res.Should().BeProblemDetails(HttpStatusCode.Unauthorized); } @@ -49,13 +53,16 @@ public class LoginTests(TestApi testApi) : IntegrationTest(testApi) await context.SaveChangesAsync(ct); }, TestContext.Current.CancellationToken); - var req = new - { - username = "Stevan", - password = "@Password2", - }; + using var request = HttpRequestBuilder.New() + .Post(LoginUri) + .WithBody(new + { + username = "Stevan", + password = "@Password2", + }) + .Build(); - var res = await Client.PostAsJsonAsync(LoginUri, req, TestContext.Current.CancellationToken); + var res = await Client.SendAsync(request, TestContext.Current.CancellationToken); await res.Should().BeProblemDetails(HttpStatusCode.Unauthorized); } @@ -74,16 +81,19 @@ public class LoginTests(TestApi testApi) : IntegrationTest(testApi) await context.SaveChangesAsync(ct); }, TestContext.Current.CancellationToken); - var req = new - { - username = "Stevan", - password = "@Password1", - }; + using var request = HttpRequestBuilder.New() + .Post(LoginUri) + .WithBody(new + { + username = "Stevan", + password = "@Password1", + }) + .Build(); - var res = await Client.PostAsJsonAsync(LoginUri, req, TestContext.Current.CancellationToken); + var res = await Client.SendAsync(request, TestContext.Current.CancellationToken); res.Should().HaveSetCookieHeader("fiscalos_refresh_cookie"); - await res.Should().BeJsonContentOfType(HttpStatusCode.OK); + await res.Should().BeJsonContentOfType(HttpStatusCode.OK); } } diff --git a/tests/FiscalOS.API.Tests/Integration/RefreshTests.cs b/tests/FiscalOS.API.Tests/Integration/Auth/RefreshTests.cs similarity index 76% rename from tests/FiscalOS.API.Tests/Integration/RefreshTests.cs rename to tests/FiscalOS.API.Tests/Integration/Auth/RefreshTests.cs index 72b224c..be66c66 100644 --- a/tests/FiscalOS.API.Tests/Integration/RefreshTests.cs +++ b/tests/FiscalOS.API.Tests/Integration/Auth/RefreshTests.cs @@ -1,8 +1,8 @@ -namespace FiscalOS.API.Tests.Integration; +namespace FiscalOS.API.Tests.Integration.Auth; public class RefreshTests(TestApi testApi) : IntegrationTest(testApi) { - private static readonly Uri RefreshUri = new("/refresh", UriKind.Relative); + private static readonly Uri RefreshUri = new("/auth/refresh", UriKind.Relative); [Fact] public async Task Refresh_WhenCalledWithNoAccessToken_ItShouldReturn401WithProblemDetails() @@ -15,14 +15,12 @@ public class RefreshTests(TestApi testApi) : IntegrationTest(testApi) [Fact] public async Task Refresh_WhenCalledWithNonExistentRefreshToken_ItShouldReturn400WithProblemDetails() { - var jwt = JwtTokenBuilder.New() - .WithClaim(JwtRegisteredClaimNames.Sub, Guid.NewGuid().ToString()) + using var request = HttpRequestBuilder.New() + .Post(RefreshUri) + .WithUserId(Guid.NewGuid()) + .WithRefreshCookie("nonexistenttoken") .Build(); - using var request = new HttpRequestMessage(HttpMethod.Post, RefreshUri); - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", jwt); - request.Headers.Add("Cookie", "fiscalos_refresh_cookie=nonexistenttoken"); - var response = await Client.SendAsync(request, TestContext.Current.CancellationToken); await response.Should().BeProblemDetails(HttpStatusCode.BadRequest); @@ -53,14 +51,12 @@ public class RefreshTests(TestApi testApi) : IntegrationTest(testApi) return (new User[] { user1, user2 }, refreshToken1); }, TestContext.Current.CancellationToken); - var jwt = JwtTokenBuilder.New() - .WithClaim(JwtRegisteredClaimNames.Sub, users[0].Id.ToString()) + using var request = HttpRequestBuilder.New() + .Post(RefreshUri) + .WithUserId(users[0].Id) + .WithRefreshCookie(refreshToken.Token) .Build(); - using var request = new HttpRequestMessage(HttpMethod.Post, RefreshUri); - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", jwt); - request.Headers.Add("Cookie", $"fiscalos_refresh_cookie={refreshToken.Token}"); - var response = await Client.SendAsync(request, TestContext.Current.CancellationToken); await response.Should().BeProblemDetails(HttpStatusCode.Forbidden); @@ -98,14 +94,12 @@ public class RefreshTests(TestApi testApi) : IntegrationTest(testApi) return (user, refreshToken); }, TestContext.Current.CancellationToken); - var jwt = JwtTokenBuilder.New() - .WithClaim(JwtRegisteredClaimNames.Sub, user.Id.ToString()) + using var request = HttpRequestBuilder.New() + .Post(RefreshUri) + .WithUserId(user.Id) + .WithRefreshCookie(refreshToken.Token) .Build(); - using var request = new HttpRequestMessage(HttpMethod.Post, RefreshUri); - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", jwt); - request.Headers.Add("Cookie", $"fiscalos_refresh_cookie={refreshToken.Token}"); - var response = await Client.SendAsync(request, TestContext.Current.CancellationToken); await response.Should().BeProblemDetails(HttpStatusCode.BadRequest); @@ -132,14 +126,12 @@ public class RefreshTests(TestApi testApi) : IntegrationTest(testApi) return (user, refreshToken); }, TestContext.Current.CancellationToken); - var jwt = JwtTokenBuilder.New() - .WithClaim(JwtRegisteredClaimNames.Sub, user.Id.ToString()) + using var request = HttpRequestBuilder.New() + .Post(RefreshUri) + .WithUserId(user.Id) + .WithRefreshCookie(refreshToken.Token) .Build(); - using var request = new HttpRequestMessage(HttpMethod.Post, RefreshUri); - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", jwt); - request.Headers.Add("Cookie", $"fiscalos_refresh_cookie={refreshToken.Token}"); - var response = await Client.SendAsync(request, TestContext.Current.CancellationToken); await response.Should().BeProblemDetails(HttpStatusCode.BadRequest); @@ -168,19 +160,19 @@ public class RefreshTests(TestApi testApi) : IntegrationTest(testApi) return (user, refreshToken); }, TestContext.Current.CancellationToken); - var jwt = JwtTokenBuilder.New() - .WithClaim(JwtRegisteredClaimNames.Sub, user.Id.ToString()) - .WithExpiresAt(DateTime.UtcNow.AddMinutes(accessTokenExpiresAtOffset)) + using var request = HttpRequestBuilder.New() + .Post(RefreshUri) + .WithBearerToken(JwtTokenBuilder.New() + .WithClaim(JwtRegisteredClaimNames.Sub, user.Id.ToString()) + .WithExpiresAt(DateTime.UtcNow.AddMinutes(accessTokenExpiresAtOffset)) + .Build()) + .WithRefreshCookie(refreshToken.Token) .Build(); - using var request = new HttpRequestMessage(HttpMethod.Post, RefreshUri); - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", jwt); - request.Headers.Add("Cookie", $"fiscalos_refresh_cookie={refreshToken.Token}"); - var response = await Client.SendAsync(request, TestContext.Current.CancellationToken); response.Should().HaveSetCookieHeader("fiscalos_refresh_cookie"); - await response.Should().BeJsonContentOfType(HttpStatusCode.OK); + await response.Should().BeJsonContentOfType(HttpStatusCode.OK); var oldRefreshTokenInDb = await ExecuteAsync( async (context, ct) => await context.Set() diff --git a/tests/FiscalOS.API.Tests/Integration/ConnectTests.cs b/tests/FiscalOS.API.Tests/Integration/Institutions/ConnectTests.cs similarity index 61% rename from tests/FiscalOS.API.Tests/Integration/ConnectTests.cs rename to tests/FiscalOS.API.Tests/Integration/Institutions/ConnectTests.cs index 9f5036b..55f71e4 100644 --- a/tests/FiscalOS.API.Tests/Integration/ConnectTests.cs +++ b/tests/FiscalOS.API.Tests/Integration/Institutions/ConnectTests.cs @@ -1,15 +1,20 @@ using Institution = FiscalOS.Core.Accounts.Institution; -namespace FiscalOS.API.Tests.Integration; +namespace FiscalOS.API.Tests.Integration.Institutions; public class ConnectTests(TestApi testApi) : IntegrationTest(testApi) { - private static readonly Uri ConnectUri = new("/accounts/connect", UriKind.Relative); + private static readonly Uri ConnectUri = new("/institutions/connect", UriKind.Relative); [Fact] public async Task Connect_WhenCalledWithoutValidToken_ItShouldReturn401WithProblemDetails() { - var res = await Client.PostAsJsonAsync(ConnectUri, new { }, TestContext.Current.CancellationToken); + using var request = HttpRequestBuilder.New() + .Post(ConnectUri) + .WithBody(new { }) + .Build(); + + var res = await Client.SendAsync(request, TestContext.Current.CancellationToken); await res.Should().BeProblemDetails(HttpStatusCode.Unauthorized); } @@ -17,17 +22,12 @@ public class ConnectTests(TestApi testApi) : IntegrationTest(testApi) [Fact] public async Task Connect_WhenCalledWithoutPublicTokenOrPlaidInstitutionId_ItShouldReturn400WithProblemDetails() { - var jwt = JwtTokenBuilder.New() - .WithClaim(JwtRegisteredClaimNames.Sub, Guid.NewGuid().ToString()) + using var request = HttpRequestBuilder.New() + .Post(ConnectUri) + .WithUserId(Guid.NewGuid()) + .WithBody(new { }) .Build(); - using var content = new StringContent(JsonSerializer.Serialize(new { }), 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().BeValidationProblemDetails(new Dictionary() @@ -40,18 +40,12 @@ public class ConnectTests(TestApi testApi) : IntegrationTest(testApi) [Fact] public async Task Connect_WhenCalledWithoutPublicToken_ItShouldReturn400WithProblemDetails() { - var jwt = JwtTokenBuilder.New() - .WithClaim(JwtRegisteredClaimNames.Sub, Guid.NewGuid().ToString()) + using var request = HttpRequestBuilder.New() + .Post(ConnectUri) + .WithUserId(Guid.NewGuid()) + .WithBody(new { plaidInstitutionId = "id" }) .Build(); - var json = JsonSerializer.Serialize(new { 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().BeValidationProblemDetails(new Dictionary() @@ -63,18 +57,12 @@ public class ConnectTests(TestApi testApi) : IntegrationTest(testApi) [Fact] public async Task Connect_WhenCalledWithoutPlaidInstitutionId_ItShouldReturn400WithProblemDetails() { - var jwt = JwtTokenBuilder.New() - .WithClaim(JwtRegisteredClaimNames.Sub, Guid.NewGuid().ToString()) + using var request = HttpRequestBuilder.New() + .Post(ConnectUri) + .WithUserId(Guid.NewGuid()) + .WithBody(new { publicToken = "token" }) .Build(); - var json = JsonSerializer.Serialize(new { publicToken = "token" }); - 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().BeValidationProblemDetails(new Dictionary() @@ -86,22 +74,16 @@ public class ConnectTests(TestApi testApi) : IntegrationTest(testApi) [Fact] public async Task Connect_WhenCalledWithNonExistentUser_ItShouldReturn401WithProblemDetails() { - var jwt = JwtTokenBuilder.New() - .WithClaim(JwtRegisteredClaimNames.Sub, Guid.NewGuid().ToString()) + using var request = HttpRequestBuilder.New() + .Post(ConnectUri) + .WithUserId(Guid.NewGuid()) + .WithBody(new + { + publicToken = "token", + plaidInstitutionId = "id", + }) .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); @@ -129,22 +111,16 @@ public class ConnectTests(TestApi testApi) : IntegrationTest(testApi) return (user, institution); }, TestContext.Current.CancellationToken); - var jwt = JwtTokenBuilder.New() - .WithClaim(JwtRegisteredClaimNames.Sub, user.Id.ToString()) + using var request = HttpRequestBuilder.New() + .Post(ConnectUri) + .WithUserId(user.Id) + .WithBody(new + { + publicToken = "token", + plaidInstitutionId = ((PlaidMetadata)institution.Metadata!).PlaidId, + }) .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); @@ -176,22 +152,16 @@ public class ConnectTests(TestApi testApi) : IntegrationTest(testApi) return (user, publicTokenResponse.PublicToken); }, TestContext.Current.CancellationToken); - var jwt = JwtTokenBuilder.New() - .WithClaim(JwtRegisteredClaimNames.Sub, user.Id.ToString()) + using var request = HttpRequestBuilder.New() + .Post(ConnectUri) + .WithUserId(user.Id) + .WithBody(new + { + publicToken, + plaidInstitutionId, + }) .Build(); - var json = JsonSerializer.Serialize(new - { - publicToken, - plaidInstitutionId, - }); - 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); response.StatusCode.Should().Be(HttpStatusCode.OK); diff --git a/tests/FiscalOS.API.Tests/Integration/Institutions/GetAvailableTests.cs b/tests/FiscalOS.API.Tests/Integration/Institutions/GetAvailableTests.cs new file mode 100644 index 0000000..95e069e --- /dev/null +++ b/tests/FiscalOS.API.Tests/Integration/Institutions/GetAvailableTests.cs @@ -0,0 +1,114 @@ +using Institution = FiscalOS.Core.Accounts.Institution; + +namespace FiscalOS.API.Tests.Integration.Institutions; + +public class GetAvailableTests(TestApi testApi) : IntegrationTest(testApi) +{ + private static Uri GetAvailableUri(Guid id) => new($"/institutions/{id}/available", UriKind.Relative); + + [Fact] + public async Task GetAvailable_WhenCalledAndUnauthenticated_ItShouldReturn401WithProblemDetails() + { + using var request = HttpRequestBuilder.New() + .WithUri(GetAvailableUri(Guid.NewGuid())) + .Build(); + + var response = await Client.SendAsync(request, TestContext.Current.CancellationToken); + + await response.Should().BeProblemDetails(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task GetAvailable_WhenCalledByNonExistentUser_ItShouldReturn401WithProblemDetails() + { + using var request = HttpRequestBuilder.New() + .WithUri(GetAvailableUri(Guid.NewGuid())) + .WithUserId(Guid.NewGuid()) + .Build(); + + var response = await Client.SendAsync(request, TestContext.Current.CancellationToken); + + await response.Should().BeProblemDetails(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task GetAvailable_WhenCalledWithNonExistentInstitutionId_ItShouldReturn404WithProblemDetails() + { + 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("Stevan", passwordHasher.Hash("@Password1"), userEncryptionKey); + + context.Add(user); + await context.SaveChangesAsync(ct); + + return user; + }, TestContext.Current.CancellationToken); + + using var request = HttpRequestBuilder.New() + .WithUri(GetAvailableUri(Guid.NewGuid())) + .WithUserId(user.Id) + .Build(); + + var response = await Client.SendAsync(request, TestContext.Current.CancellationToken); + + await response.Should().BeProblemDetails(HttpStatusCode.NotFound); + } + + [Fact] + public async Task GetAvailable_WhenCalledWithConnectedInstitution_ItShouldReturn200WithAvailableAccounts() + { + var (user, institution, expectedAccounts) = await ExecuteAsync(static async (context, ct, sp) => + { + var passwordHasher = sp.GetRequiredService(); + var encryptor = sp.GetRequiredService(); + var plaidClient = sp.GetRequiredService(); + + var userEncryptionKey = await encryptor.GenerateEncryptedKeyAsync(ct); + var user = User.From("Stevan", passwordHasher.Hash("@Password1"), userEncryptionKey); + + var institutionId = "ins_109508"; + + var publicTokenResponse = await plaidClient.SandboxPublicTokenCreateAsync(new() + { + InstitutionId = institutionId, + InitialProducts = [Products.Transactions], + }); + + var exchangeTokenResponse = await plaidClient.ItemPublicTokenExchangeAsync(new() + { + PublicToken = publicTokenResponse.PublicToken, + }); + + var accountsResponse = await plaidClient.AccountsGetAsync(new() + { + AccessToken = exchangeTokenResponse.AccessToken, + }); + + var encryptedAccessToken = await encryptor.EncryptAsyncFor(user, exchangeTokenResponse.AccessToken, ct); + var institutionMetadata = PlaidMetadata.From(institutionId, "Some Bank", encryptedAccessToken); + var institution = Institution.From("Some Bank", institutionMetadata); + + user.AddInstitution(institution); + + context.Add(user); + context.Add(institution); + await context.SaveChangesAsync(ct); + + return (user, institution, accountsResponse.Accounts); + }, TestContext.Current.CancellationToken); + + using var request = HttpRequestBuilder.New() + .WithUri(GetAvailableUri(institution.Id)) + .WithUserId(user.Id) + .Build(); + + var response = await Client.SendAsync(request, TestContext.Current.CancellationToken); + + (await response.Should().BeJsonContentOfType>(HttpStatusCode.OK)) + .Which.Should().BeEquivalentTo(expectedAccounts); + } +} \ No newline at end of file diff --git a/tests/FiscalOS.API.Tests/Usings.cs b/tests/FiscalOS.API.Tests/Usings.cs index b0e17c9..a6e01c0 100644 --- a/tests/FiscalOS.API.Tests/Usings.cs +++ b/tests/FiscalOS.API.Tests/Usings.cs @@ -27,6 +27,7 @@ global using Microsoft.AspNetCore.Mvc; global using Microsoft.AspNetCore.Mvc.Testing; global using Microsoft.AspNetCore.TestHost; global using Microsoft.EntityFrameworkCore; +global using Microsoft.Extensions.Configuration; global using Microsoft.Extensions.DependencyInjection; global using Microsoft.Extensions.Logging; global using Microsoft.Extensions.Options;