feat(api,core,infra): refactor endpoint routing and add institution account discovery
- Move login and refresh endpoints under a unified /auth route group
- Relocate institution connection logic from /accounts to /institutions
- Implement GET /institutions/{id}/available to fetch real-time Plaid
accounts
- Introduce HttpRequestBuilder utility to streamline integration testing
- Enhance PlaidService with GetAccountsAsync to support account fetching
- Clean up global usings and project structure for better domain
isolation
This commit is contained in:
@@ -0,0 +1,182 @@
|
||||
namespace FiscalOS.API.Tests.Integration.Auth;
|
||||
|
||||
public class LoginTests(TestApi testApi) : IntegrationTest(testApi)
|
||||
{
|
||||
private static readonly Uri LoginUri = new("/auth/login", UriKind.Relative);
|
||||
|
||||
[Theory]
|
||||
[ClassData<LoginValidationTestCases>]
|
||||
public async Task Login_WhenUserSubmitsInvalidRequest_ItShouldReturn400WithProblemDetails(LoginValidationTestCase tc)
|
||||
{
|
||||
using var request = HttpRequestBuilder.New()
|
||||
.Post(LoginUri)
|
||||
.WithBody(new
|
||||
{
|
||||
username = tc.Username,
|
||||
password = tc.Password,
|
||||
})
|
||||
.Build();
|
||||
|
||||
var res = await Client.SendAsync(request, TestContext.Current.CancellationToken);
|
||||
|
||||
await res.Should().BeValidationProblemDetails(tc.ExpectedErrors);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Login_WhenUserDoesNotExist_ItShouldReturn401WithProblemDetails()
|
||||
{
|
||||
using var request = HttpRequestBuilder.New()
|
||||
.Post(LoginUri)
|
||||
.WithBody(new
|
||||
{
|
||||
username = "Test",
|
||||
password = "@Password2",
|
||||
})
|
||||
.Build();
|
||||
|
||||
var res = await Client.SendAsync(request, TestContext.Current.CancellationToken);
|
||||
|
||||
await res.Should().BeProblemDetails(HttpStatusCode.Unauthorized);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Login_WhenUserExistsButPasswordIsIncorrect_ItShouldReturn401WithProblemDetails()
|
||||
{
|
||||
await ExecuteAsync(static async (context, ct, sp) =>
|
||||
{
|
||||
var passwordHasher = sp.GetRequiredService<IPasswordHasher>();
|
||||
var encryptor = sp.GetRequiredService<IEncryptor>();
|
||||
|
||||
var userEncryptionKey = await encryptor.GenerateEncryptedKeyAsync(ct);
|
||||
context.Add(User.From("Stevan", passwordHasher.Hash("@Password1"), userEncryptionKey));
|
||||
|
||||
await context.SaveChangesAsync(ct);
|
||||
}, TestContext.Current.CancellationToken);
|
||||
|
||||
using var request = HttpRequestBuilder.New()
|
||||
.Post(LoginUri)
|
||||
.WithBody(new
|
||||
{
|
||||
username = "Stevan",
|
||||
password = "@Password2",
|
||||
})
|
||||
.Build();
|
||||
|
||||
var res = await Client.SendAsync(request, TestContext.Current.CancellationToken);
|
||||
|
||||
await res.Should().BeProblemDetails(HttpStatusCode.Unauthorized);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Login_WhenUserExistsAndPasswordIsCorrect_ItShouldReturn200WithJwtTokenAndSetRefreshCookie()
|
||||
{
|
||||
await ExecuteAsync(static async (context, ct, sp) =>
|
||||
{
|
||||
var passwordHasher = sp.GetRequiredService<IPasswordHasher>();
|
||||
var encryptor = sp.GetRequiredService<IEncryptor>();
|
||||
|
||||
var userEncryptionKey = await encryptor.GenerateEncryptedKeyAsync(ct);
|
||||
context.Add(User.From("Stevan", passwordHasher.Hash("@Password1"), userEncryptionKey));
|
||||
|
||||
await context.SaveChangesAsync(ct);
|
||||
}, TestContext.Current.CancellationToken);
|
||||
|
||||
using var request = HttpRequestBuilder.New()
|
||||
.Post(LoginUri)
|
||||
.WithBody(new
|
||||
{
|
||||
username = "Stevan",
|
||||
password = "@Password1",
|
||||
})
|
||||
.Build();
|
||||
|
||||
var res = await Client.SendAsync(request, TestContext.Current.CancellationToken);
|
||||
|
||||
res.Should().HaveSetCookieHeader("fiscalos_refresh_cookie");
|
||||
await res.Should().BeJsonContentOfType<API.Auth.Login.Response>(HttpStatusCode.OK);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public class LoginValidationTestCases : TheoryData<LoginValidationTestCase>
|
||||
{
|
||||
public LoginValidationTestCases()
|
||||
{
|
||||
Add(new LoginValidationTestCase(
|
||||
"No username or password",
|
||||
string.Empty,
|
||||
string.Empty,
|
||||
new SerializableDictionary<string, string[]>()
|
||||
{
|
||||
["Username"] = ["The Username field is required."],
|
||||
["Password"] = ["The Password field is required."]
|
||||
}
|
||||
));
|
||||
|
||||
Add(new LoginValidationTestCase(
|
||||
"No username",
|
||||
string.Empty,
|
||||
"@Password1",
|
||||
new SerializableDictionary<string, string[]>()
|
||||
{
|
||||
["Username"] = ["The Username field is required."],
|
||||
}
|
||||
));
|
||||
|
||||
Add(new LoginValidationTestCase(
|
||||
"No password",
|
||||
"Stevan",
|
||||
string.Empty,
|
||||
new SerializableDictionary<string, string[]>()
|
||||
{
|
||||
["Password"] = ["The Password field is required."]
|
||||
}
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
public record LoginValidationTestCase : IXunitSerializable
|
||||
{
|
||||
public string Name { get; private set; } = string.Empty;
|
||||
public string Username { get; private set; } = string.Empty;
|
||||
public string Password { get; private set; } = string.Empty;
|
||||
public SerializableDictionary<string, string[]> ExpectedErrors { get; private set; } = [];
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
return Name;
|
||||
}
|
||||
|
||||
public LoginValidationTestCase()
|
||||
{
|
||||
}
|
||||
|
||||
public LoginValidationTestCase(
|
||||
string name,
|
||||
string username,
|
||||
string password,
|
||||
SerializableDictionary<string, string[]> expectedErrors
|
||||
)
|
||||
{
|
||||
Name = name;
|
||||
Username = username;
|
||||
Password = password;
|
||||
ExpectedErrors = expectedErrors;
|
||||
}
|
||||
|
||||
public void Deserialize(IXunitSerializationInfo info)
|
||||
{
|
||||
Name = info.GetValue<string>(nameof(Name)) ?? string.Empty;
|
||||
Username = info.GetValue<string>(nameof(Username)) ?? string.Empty;
|
||||
Password = info.GetValue<string>(nameof(Password)) ?? string.Empty;
|
||||
ExpectedErrors = info.GetValue<SerializableDictionary<string, string[]>>(nameof(ExpectedErrors)) ?? [];
|
||||
}
|
||||
|
||||
public void Serialize(IXunitSerializationInfo info)
|
||||
{
|
||||
info.AddValue(nameof(Name), Name);
|
||||
info.AddValue(nameof(Username), Username);
|
||||
info.AddValue(nameof(Password), Password);
|
||||
info.AddValue(nameof(ExpectedErrors), ExpectedErrors);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
namespace FiscalOS.API.Tests.Integration.Auth;
|
||||
|
||||
public class RefreshTests(TestApi testApi) : IntegrationTest(testApi)
|
||||
{
|
||||
private static readonly Uri RefreshUri = new("/auth/refresh", UriKind.Relative);
|
||||
|
||||
[Fact]
|
||||
public async Task Refresh_WhenCalledWithNoAccessToken_ItShouldReturn401WithProblemDetails()
|
||||
{
|
||||
var response = await Client.PostAsync(RefreshUri, null, TestContext.Current.CancellationToken);
|
||||
|
||||
await response.Should().BeProblemDetails(HttpStatusCode.Unauthorized);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Refresh_WhenCalledWithNonExistentRefreshToken_ItShouldReturn400WithProblemDetails()
|
||||
{
|
||||
using var request = HttpRequestBuilder.New()
|
||||
.Post(RefreshUri)
|
||||
.WithUserId(Guid.NewGuid())
|
||||
.WithRefreshCookie("nonexistenttoken")
|
||||
.Build();
|
||||
|
||||
var response = await Client.SendAsync(request, TestContext.Current.CancellationToken);
|
||||
|
||||
await response.Should().BeProblemDetails(HttpStatusCode.BadRequest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Refresh_WhenCalledWithTokenBelongingToDifferentUser_ItShouldReturn403WithProblemDetails()
|
||||
{
|
||||
var (users, refreshToken) = await ExecuteAsync(static async (context, ct, sp) =>
|
||||
{
|
||||
var passwordHasher = sp.GetRequiredService<IPasswordHasher>();
|
||||
var tokenGenerator = sp.GetRequiredService<ITokenGenerator>();
|
||||
var encryptor = sp.GetRequiredService<IEncryptor>();
|
||||
|
||||
var user1EncryptionKey = await encryptor.GenerateEncryptedKeyAsync(ct);
|
||||
var user2EncryptionKey = await encryptor.GenerateEncryptedKeyAsync(ct);
|
||||
var user1 = User.From("User1", passwordHasher.Hash("@Password1"), user1EncryptionKey);
|
||||
var user2 = User.From("User2", passwordHasher.Hash("@Password2"), user2EncryptionKey);
|
||||
var refreshToken1 = tokenGenerator.GenerateRefreshToken(user2);
|
||||
var refreshToken2 = tokenGenerator.GenerateRefreshToken(user2);
|
||||
user2.AddRefreshToken(refreshToken1);
|
||||
user2.AddRefreshToken(refreshToken2);
|
||||
|
||||
context.Add(user1);
|
||||
context.Add(user2);
|
||||
|
||||
await context.SaveChangesAsync(ct);
|
||||
return (new User[] { user1, user2 }, refreshToken1);
|
||||
}, TestContext.Current.CancellationToken);
|
||||
|
||||
using var request = HttpRequestBuilder.New()
|
||||
.Post(RefreshUri)
|
||||
.WithUserId(users[0].Id)
|
||||
.WithRefreshCookie(refreshToken.Token)
|
||||
.Build();
|
||||
|
||||
var response = await Client.SendAsync(request, TestContext.Current.CancellationToken);
|
||||
|
||||
await response.Should().BeProblemDetails(HttpStatusCode.Forbidden);
|
||||
|
||||
var unrevokedTokensCountForUser2 = await ExecuteAsync(
|
||||
async (context, ct) => await context.Set<RefreshToken>()
|
||||
.Include(t => t.User)
|
||||
.Where(t => t.UserId == users[1].Id && t.Revoked == false)
|
||||
.CountAsync(ct),
|
||||
TestContext.Current.CancellationToken
|
||||
);
|
||||
|
||||
unrevokedTokensCountForUser2.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Refresh_WhenCalledWithRevokedRefreshToken_ItShouldReturn400WithProblemDetails()
|
||||
{
|
||||
var (user, refreshToken) = await ExecuteAsync(static async (context, ct, sp) =>
|
||||
{
|
||||
var passwordHasher = sp.GetRequiredService<IPasswordHasher>();
|
||||
var tokenGenerator = sp.GetRequiredService<ITokenGenerator>();
|
||||
var encryptor = sp.GetRequiredService<IEncryptor>();
|
||||
|
||||
var userEncryptionKey = await encryptor.GenerateEncryptedKeyAsync(ct);
|
||||
var user = User.From("User1", passwordHasher.Hash("@Password1"), userEncryptionKey);
|
||||
var refreshToken = tokenGenerator.GenerateRefreshToken(user);
|
||||
refreshToken.Revoke();
|
||||
|
||||
user.AddRefreshToken(refreshToken);
|
||||
|
||||
context.Add(user);
|
||||
|
||||
await context.SaveChangesAsync(ct);
|
||||
return (user, refreshToken);
|
||||
}, TestContext.Current.CancellationToken);
|
||||
|
||||
using var request = HttpRequestBuilder.New()
|
||||
.Post(RefreshUri)
|
||||
.WithUserId(user.Id)
|
||||
.WithRefreshCookie(refreshToken.Token)
|
||||
.Build();
|
||||
|
||||
var response = await Client.SendAsync(request, TestContext.Current.CancellationToken);
|
||||
|
||||
await response.Should().BeProblemDetails(HttpStatusCode.BadRequest);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Refresh_WhenCalledWithExpiredRefreshToken_ItShouldReturn400WithProblemDetails()
|
||||
{
|
||||
var (user, refreshToken) = await ExecuteAsync(static async (context, ct, sp) =>
|
||||
{
|
||||
var passwordHasher = sp.GetRequiredService<IPasswordHasher>();
|
||||
var tokenGenerator = sp.GetRequiredService<ITokenGenerator>();
|
||||
var timeProvider = sp.GetRequiredService<TimeProvider>();
|
||||
var encryptor = sp.GetRequiredService<IEncryptor>();
|
||||
|
||||
var userEncryptionKey = await encryptor.GenerateEncryptedKeyAsync(ct);
|
||||
var user = User.From("User1", passwordHasher.Hash("@Password1"), userEncryptionKey);
|
||||
var refreshToken = RefreshToken.From(user.Id, "expiredtoken", timeProvider.GetUtcNow().AddHours(-1));
|
||||
user.AddRefreshToken(refreshToken);
|
||||
|
||||
context.Add(user);
|
||||
|
||||
await context.SaveChangesAsync(ct);
|
||||
return (user, refreshToken);
|
||||
}, TestContext.Current.CancellationToken);
|
||||
|
||||
using var request = HttpRequestBuilder.New()
|
||||
.Post(RefreshUri)
|
||||
.WithUserId(user.Id)
|
||||
.WithRefreshCookie(refreshToken.Token)
|
||||
.Build();
|
||||
|
||||
var response = await Client.SendAsync(request, TestContext.Current.CancellationToken);
|
||||
|
||||
await response.Should().BeProblemDetails(HttpStatusCode.BadRequest);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(5)]
|
||||
[InlineData(-5)]
|
||||
public async Task Refresh_WhenCalledWithValidRefreshTokenAndExpiredOrNotExpiredAccessToken_ItShouldReturn200WithNewTokensAndSetRefreshCookie(int accessTokenExpiresAtOffset)
|
||||
{
|
||||
var (user, refreshToken) = await ExecuteAsync(static async (context, ct, sp) =>
|
||||
{
|
||||
var passwordHasher = sp.GetRequiredService<IPasswordHasher>();
|
||||
var tokenGenerator = sp.GetRequiredService<ITokenGenerator>();
|
||||
var timeProvider = sp.GetRequiredService<TimeProvider>();
|
||||
var encryptor = sp.GetRequiredService<IEncryptor>();
|
||||
|
||||
var userEncryptionKey = await encryptor.GenerateEncryptedKeyAsync(ct);
|
||||
var user = User.From("User1", passwordHasher.Hash("@Password1"), userEncryptionKey);
|
||||
var refreshToken = tokenGenerator.GenerateRefreshToken(user);
|
||||
user.AddRefreshToken(refreshToken);
|
||||
|
||||
context.Add(user);
|
||||
|
||||
await context.SaveChangesAsync(ct);
|
||||
return (user, refreshToken);
|
||||
}, TestContext.Current.CancellationToken);
|
||||
|
||||
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();
|
||||
|
||||
var response = await Client.SendAsync(request, TestContext.Current.CancellationToken);
|
||||
|
||||
response.Should().HaveSetCookieHeader("fiscalos_refresh_cookie");
|
||||
await response.Should().BeJsonContentOfType<API.Auth.Refresh.Response>(HttpStatusCode.OK);
|
||||
|
||||
var oldRefreshTokenInDb = await ExecuteAsync(
|
||||
async (context, ct) => await context.Set<RefreshToken>()
|
||||
.Include(t => t.User)
|
||||
.Where(t => t.UserId == user.Id && t.Token == refreshToken.Token && t.Revoked == true)
|
||||
.SingleOrDefaultAsync(ct),
|
||||
TestContext.Current.CancellationToken
|
||||
);
|
||||
|
||||
oldRefreshTokenInDb.Should().NotBeNull();
|
||||
|
||||
var newRefreshTokenInDb = await ExecuteAsync(
|
||||
async (context, ct) => await context.Set<RefreshToken>()
|
||||
.Include(t => t.User)
|
||||
.Where(t => t.UserId == user.Id && t.Revoked == false && t.Token != refreshToken.Token)
|
||||
.SingleOrDefaultAsync(ct),
|
||||
TestContext.Current.CancellationToken
|
||||
);
|
||||
|
||||
newRefreshTokenInDb.Should().NotBeNull();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user