Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
a6ff7ce
Allow empty validation when email is null
tjementum Aug 4, 2025
ca8b69b
Move AddCrossServiceDataProtection from ApiDependencyConfiguration to…
tjementum Aug 12, 2025
2993cfc
Fix UseStringForEnums to handle nullable enum properties
tjementum Aug 15, 2025
b156bde
Remove error message from SharedValidation of phone numbers
tjementum Aug 19, 2025
d0d6344
Add StronglyTypedString for creating strongly typed IDs with strings …
tjementum Aug 23, 2025
3c50e5e
Add API tests for Logout and ChangeLocale commands
tjementum Oct 23, 2025
900498f
Add API tests for ChangeUserRole command
tjementum Oct 23, 2025
0646403
Fix NotSupportedException when copying avatar during tenant switch
tjementum Nov 25, 2025
f4786e6
Use MailAddress.TryCreate for email validation to match .NET email in…
tjementum Nov 25, 2025
d1dd4c5
Add Redirect to ApiResult and ApiResult<T>
tjementum Nov 25, 2025
848e3d3
Add IBlobStorageClient and CreateContainerIfNotExistsAsync
tjementum Nov 25, 2025
8b2dc15
Verify API response before asserting database state in CompleteLoginT…
tjementum Nov 25, 2025
b69f4b7
Replace Faker-generated user with deterministic test data to fix flak…
tjementum Nov 25, 2025
e182de4
Add Faker.Internet.UniqueEmail() to fix flaky tests from email collis…
tjementum Nov 25, 2025
7f4838e
Fix flaky tests by using unique emails and non-whitespace strings
tjementum Nov 25, 2025
07bf9fa
Exclude owned entities from global tenant query filters
tjementum Nov 25, 2025
6689294
Add bulk repository interfaces and rename BulkRemove to RemoveRange
tjementum Nov 25, 2025
0b6373c
Add OwnedNavigationBuilder overloads and value converter check to Mod…
tjementum Nov 25, 2025
46a2c86
Add User Delegation Key SAS support and DeleteIfExistsAsync to BlobSt…
tjementum Nov 25, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

namespace PlatformPlatform.AppGateway.Transformations;

public class SharedAccessSignatureRequestTransform([FromKeyedServices("account-management-storage")] BlobStorageClient accountManagementBlobStorageClient)
public class SharedAccessSignatureRequestTransform([FromKeyedServices("account-management-storage")] IBlobStorageClient accountManagementBlobStorageClient)
: RequestTransform
{
public override ValueTask ApplyAsync(RequestTransformContext context)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ public sealed class SwitchTenantHandler(
AuthenticationTokenService authenticationTokenService,
AvatarUpdater avatarUpdater,
[FromKeyedServices("account-management-storage")]
BlobStorageClient blobStorageClient,
IBlobStorageClient blobStorageClient,
IExecutionContext executionContext,
ITelemetryEventsCollector events,
ILogger<SwitchTenantHandler> logger
Expand Down Expand Up @@ -73,8 +73,14 @@ private async Task CopyProfileDataFromCurrentUser(User targetUser, CancellationT
var avatarData = await blobStorageClient.DownloadAsync("avatars", sourceBlobPath, cancellationToken);
if (avatarData is not null)
{
// Copy to MemoryStream since Azure's RetriableStream doesn't support seeking (Position reset)
await using var avatarStream = avatarData.Value.Stream;
using var memoryStream = new MemoryStream();
await avatarStream.CopyToAsync(memoryStream, cancellationToken);
memoryStream.Position = 0;

// Upload the avatar to the target tenant's storage location
await avatarUpdater.UpdateAvatar(targetUser, false, avatarData.Value.ContentType, avatarData.Value.Stream, cancellationToken);
await avatarUpdater.UpdateAvatar(targetUser, false, avatarData.Value.ContentType, memoryStream, cancellationToken);
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ public async Task<Result> Handle(DeleteTenantCommand command, CancellationToken
if (tenant is null) return Result.NotFound($"Tenant with id '{command.Id}' not found.");

var tenantUsers = await userRepository.GetTenantUsers(cancellationToken);
userRepository.BulkRemove(tenantUsers);
userRepository.RemoveRange(tenantUsers);

tenantRepository.Remove(tenant);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ public sealed class UpdateTenantLogoHandler(
ITenantRepository tenantRepository,
IExecutionContext executionContext,
[FromKeyedServices("account-management-storage")]
BlobStorageClient blobStorageClient,
IBlobStorageClient blobStorageClient,
ITelemetryEventsCollector events
)
: IRequestHandler<UpdateTenantLogoCommand, Result>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ public async Task<Result> Handle(BulkDeleteUsersCommand command, CancellationTok
return Result.NotFound($"Users with ids '{string.Join(", ", missingUserIds.Select(id => id.ToString()))}' not found.");
}

userRepository.BulkRemove(usersToDelete);
userRepository.RemoveRange(usersToDelete);

foreach (var userId in command.UserIds)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

namespace PlatformPlatform.AccountManagement.Features.Users.Shared;

public sealed class AvatarUpdater(IUserRepository userRepository, [FromKeyedServices("account-management-storage")] BlobStorageClient blobStorageClient)
public sealed class AvatarUpdater(IUserRepository userRepository, [FromKeyedServices("account-management-storage")] IBlobStorageClient blobStorageClient)
{
private const string ContainerName = "avatars";

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,7 @@ public async Task CompleteLogin_WhenUserInviteCompleted_ShouldTrackUserInviteAcc
[("Name", "Test Company")]
);

var email = Faker.Internet.Email();
var email = Faker.Internet.UniqueEmail();
var inviteUserCommand = new InviteUserCommand(email);
await AuthenticatedOwnerHttpClient.PostAsJsonAsync("/api/account-management/users/invite", inviteUserCommand);
TelemetryEventsCollectorSpy.Reset();
Expand All @@ -210,9 +210,11 @@ public async Task CompleteLogin_WhenUserInviteCompleted_ShouldTrackUserInviteAcc
var command = new CompleteLoginCommand(CorrectOneTimePassword);

// Act
await AnonymousHttpClient.PostAsJsonAsync($"/api/account-management/authentication/login/{loginId}/complete", command);
var response = await AnonymousHttpClient
.PostAsJsonAsync($"/api/account-management/authentication/login/{loginId}/complete", command);

// Assert
await response.ShouldBeSuccessfulPostRequest(hasLocation: false);
Connection.ExecuteScalar<long>(
"SELECT COUNT(*) FROM Users WHERE TenantId = @tenantId AND Email = @email AND EmailConfirmed = 1",
[new { tenantId = DatabaseSeeder.Tenant1.Id.ToString(), email = email.ToLower() }]
Expand Down
62 changes: 62 additions & 0 deletions application/account-management/Tests/Authentication/LogoutTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
using System.Net;
using System.Net.Http.Json;
using FluentAssertions;
using PlatformPlatform.AccountManagement.Database;
using PlatformPlatform.AccountManagement.Features.Authentication.Commands;
using PlatformPlatform.SharedKernel.Tests;
using Xunit;

namespace PlatformPlatform.AccountManagement.Tests.Authentication;

public sealed class LogoutTests : EndpointBaseTest<AccountManagementDbContext>
{
[Fact]
public async Task Logout_WhenAuthenticatedAsOwner_ShouldSucceedAndCollectLogoutEvent()
{
// Arrange
var command = new LogoutCommand();

// Act
var response = await AuthenticatedOwnerHttpClient.PostAsJsonAsync("/api/account-management/authentication/logout", command);

// Assert
await response.ShouldBeSuccessfulPostRequest(hasLocation: false);

TelemetryEventsCollectorSpy.CollectedEvents.Count.Should().Be(1);
TelemetryEventsCollectorSpy.CollectedEvents[0].GetType().Name.Should().Be("Logout");
TelemetryEventsCollectorSpy.AreAllEventsDispatched.Should().BeTrue();
}

[Fact]
public async Task Logout_WhenAuthenticatedAsMember_ShouldSucceedAndCollectLogoutEvent()
{
// Arrange
var command = new LogoutCommand();

// Act
var response = await AuthenticatedMemberHttpClient.PostAsJsonAsync("/api/account-management/authentication/logout", command);

// Assert
await response.ShouldBeSuccessfulPostRequest(hasLocation: false);

TelemetryEventsCollectorSpy.CollectedEvents.Count.Should().Be(1);
TelemetryEventsCollectorSpy.CollectedEvents[0].GetType().Name.Should().Be("Logout");
TelemetryEventsCollectorSpy.AreAllEventsDispatched.Should().BeTrue();
}

[Fact]
public async Task Logout_WhenNotAuthenticated_ShouldReturnUnauthorized()
{
// Arrange
var command = new LogoutCommand();

// Act
var response = await AnonymousHttpClient.PostAsJsonAsync("/api/account-management/authentication/logout", command);

// Assert
response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);

TelemetryEventsCollectorSpy.CollectedEvents.Should().BeEmpty();
TelemetryEventsCollectorSpy.AreAllEventsDispatched.Should().BeFalse();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,9 @@ public async Task StartLoginCommand_WhenEmailIsEmpty_ShouldFail()
[Theory]
[InlineData("Invalid Email Format", "invalid-email")]
[InlineData("Email Too Long", "abcdefghijklmnopqrstuvwyz0123456789-abcdefghijklmnopqrstuvwyz0123456789-abcdefghijklmnopqrstuvwyz0123456789@example.com")]
[InlineData("Double Dots In Domain", "neo@gmail..com")]
[InlineData("Comma Instead Of Dot", "q@q,com")]
[InlineData("Space In Domain", "tje@mentum .dk")]
public async Task StartLoginCommand_WhenEmailInvalid_ShouldFail(string scenario, string invalidEmail)
{
// Arrange
Expand All @@ -91,7 +94,7 @@ public async Task StartLoginCommand_WhenEmailInvalid_ShouldFail(string scenario,
public async Task StartLoginCommand_WhenUserDoesNotExist_ShouldReturnFakeLoginId()
{
// Arrange
var email = Faker.Internet.Email();
var email = Faker.Internet.UniqueEmail();
var command = new StartLoginCommand(email);

// Act
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ public async Task SwitchTenant_WhenUserDoesNotExistInTargetTenant_ShouldReturnFo
("Id", UserId.NewId().ToString()),
("CreatedAt", TimeProvider.System.GetUtcNow()),
("ModifiedAt", null),
("Email", Faker.Internet.Email()),
("Email", Faker.Internet.UniqueEmail()),
("EmailConfirmed", true),
("FirstName", Faker.Name.FirstName()),
("LastName", Faker.Name.LastName()),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ protected override void RegisterMockLoggers(IServiceCollection services)
public async Task CompleteSignup_WhenValid_ShouldCreateTenantAndOwnerUser()
{
// Arrange
var email = Faker.Internet.Email();
var email = Faker.Internet.UniqueEmail();
var emailConfirmationId = await StartSignup(email);

var command = new CompleteSignupCommand(CorrectOneTimePassword, "en-US");
Expand Down Expand Up @@ -72,7 +72,7 @@ public async Task CompleteSignup_WhenSignupNotFound_ShouldReturnNotFound()
public async Task CompleteSignup_WhenInvalidOneTimePassword_ShouldReturnBadRequest()
{
// Arrange
var emailConfirmationId = await StartSignup(Faker.Internet.Email());
var emailConfirmationId = await StartSignup(Faker.Internet.UniqueEmail());

var command = new CompleteSignupCommand(WrongOneTimePassword, "en-US");

Expand All @@ -93,7 +93,7 @@ public async Task CompleteSignup_WhenInvalidOneTimePassword_ShouldReturnBadReque
public async Task CompleteSignup_WhenSignupAlreadyCompleted_ShouldReturnBadRequest()
{
// Arrange
var emailConfirmationId = await StartSignup(Faker.Internet.Email());
var emailConfirmationId = await StartSignup(Faker.Internet.UniqueEmail());

var command = new CompleteSignupCommand(CorrectOneTimePassword, "en-US") { EmailConfirmationId = emailConfirmationId };
await AnonymousHttpClient.PostAsJsonAsync($"/api/account-management/signups/{emailConfirmationId}/complete", command);
Expand All @@ -110,7 +110,7 @@ public async Task CompleteSignup_WhenSignupAlreadyCompleted_ShouldReturnBadReque
public async Task CompleteSignup_WhenRetryCountExceeded_ShouldReturnForbidden()
{
// Arrange
var emailConfirmationId = await StartSignup(Faker.Internet.Email());
var emailConfirmationId = await StartSignup(Faker.Internet.UniqueEmail());

var command = new CompleteSignupCommand(WrongOneTimePassword, "en-US");
await AnonymousHttpClient.PostAsJsonAsync($"/api/account-management/signups/{emailConfirmationId}/complete", command);
Expand All @@ -137,7 +137,7 @@ public async Task CompleteSignup_WhenRetryCountExceeded_ShouldReturnForbidden()
public async Task CompleteSignup_WhenSignupExpired_ShouldReturnBadRequest()
{
// Arrange
var email = Faker.Internet.Email();
var email = Faker.Internet.UniqueEmail();

var emailConfirmationId = EmailConfirmationId.NewId();
Connection.Insert("EmailConfirmations", [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ public sealed class StartSignupTests : EndpointBaseTest<AccountManagementDbConte
public async Task StartSignup_WhenEmailIsValid_ShouldReturnSuccess()
{
// Arrange
var email = Faker.Internet.Email();
var email = Faker.Internet.UniqueEmail();
var command = new StartSignupCommand(email);

// Act
Expand Down Expand Up @@ -70,7 +70,7 @@ public async Task StartSignup_WhenInvalidEmail_ShouldReturnBadRequest()
public async Task StartSignup_WhenTooManyAttempts_ShouldReturnTooManyRequests()
{
// Arrange
var email = Faker.Internet.Email().ToLowerInvariant();
var email = Faker.Internet.UniqueEmail().ToLowerInvariant();

// Create 4 signups within the last hour for this email
for (var i = 1; i <= 4; i++)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ public async Task DeleteTenant_WhenTenantHasUsers_ShouldReturnBadRequest()
("Id", UserId.NewId().ToString()),
("CreatedAt", TimeProvider.System.GetUtcNow().AddMinutes(-10)),
("ModifiedAt", null),
("Email", Faker.Internet.Email()),
("Email", Faker.Internet.UniqueEmail()),
("FirstName", Faker.Person.FirstName),
("LastName", Faker.Person.LastName),
("Title", "Philanthropist & Innovator"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ public async Task GetTenants_CurrentTenantIncluded_VerifyCurrentTenantInResponse
public async Task GetTenants_UsersOnlySeeTheirOwnTenants_DoesNotReturnOtherUsersTenants()
{
// Arrange
var otherUserEmail = Faker.Internet.Email().ToLowerInvariant();
var otherUserEmail = Faker.Internet.UniqueEmail().ToLowerInvariant();
var otherUserTenantId = TenantId.NewId();
var otherUserId = UserId.NewId();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ public async Task BulkDeleteUsers_WhenUsersExist_ShouldDeleteUsers()
("Id", userId.ToString()),
("CreatedAt", TimeProvider.System.GetUtcNow().AddMinutes(-10)),
("ModifiedAt", null),
("Email", Faker.Internet.Email()),
("Email", Faker.Internet.UniqueEmail()),
("FirstName", Faker.Person.FirstName),
("LastName", Faker.Person.LastName),
("Title", "Test User"),
Expand Down
125 changes: 125 additions & 0 deletions application/account-management/Tests/Users/ChangeLocaleTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
using System.Net;
using System.Net.Http.Json;
using FluentAssertions;
using PlatformPlatform.AccountManagement.Database;
using PlatformPlatform.AccountManagement.Features.Users.Commands;
using PlatformPlatform.SharedKernel.Tests;
using PlatformPlatform.SharedKernel.Tests.Persistence;
using PlatformPlatform.SharedKernel.Validation;
using Xunit;

namespace PlatformPlatform.AccountManagement.Tests.Users;

public sealed class ChangeLocaleTests : EndpointBaseTest<AccountManagementDbContext>
{
[Fact]
public async Task ChangeLocale_WhenValidLocale_ShouldUpdateUserLocaleAndCollectEvent()
{
// Arrange
var newLocale = "da-DK";
var command = new ChangeLocaleCommand(newLocale);

// Act
var response = await AuthenticatedOwnerHttpClient.PutAsJsonAsync("/api/account-management/users/me/change-locale", command);

// Assert
response.ShouldHaveEmptyHeaderAndLocationOnSuccess();

var updatedLocale = Connection.ExecuteScalar<string>(
"SELECT Locale FROM Users WHERE Id = @id", [new { id = DatabaseSeeder.Tenant1Owner.Id.ToString() }]
);
updatedLocale.Should().Be(newLocale);

TelemetryEventsCollectorSpy.CollectedEvents.Count.Should().Be(1);
TelemetryEventsCollectorSpy.CollectedEvents[0].GetType().Name.Should().Be("UserLocaleChanged");
TelemetryEventsCollectorSpy.CollectedEvents[0].Properties["event.from_locale"].Should().Be(string.Empty);
TelemetryEventsCollectorSpy.CollectedEvents[0].Properties["event.to_locale"].Should().Be(newLocale);
TelemetryEventsCollectorSpy.AreAllEventsDispatched.Should().BeTrue();
}

[Fact]
public async Task ChangeLocale_WhenMemberChangesLocale_ShouldSucceed()
{
// Arrange
var newLocale = "da-DK";
var command = new ChangeLocaleCommand(newLocale);

// Act
var response = await AuthenticatedMemberHttpClient.PutAsJsonAsync("/api/account-management/users/me/change-locale", command);

// Assert
response.ShouldHaveEmptyHeaderAndLocationOnSuccess();

var updatedLocale = Connection.ExecuteScalar<string>(
"SELECT Locale FROM Users WHERE Id = @id", [new { id = DatabaseSeeder.Tenant1Member.Id.ToString() }]
);
updatedLocale.Should().Be(newLocale);

TelemetryEventsCollectorSpy.CollectedEvents.Count.Should().Be(1);
TelemetryEventsCollectorSpy.CollectedEvents[0].GetType().Name.Should().Be("UserLocaleChanged");
TelemetryEventsCollectorSpy.AreAllEventsDispatched.Should().BeTrue();
}

[Fact]
public async Task ChangeLocale_WhenInvalidLocale_ShouldReturnValidationError()
{
// Arrange
var invalidLocale = "fr-FR";
var command = new ChangeLocaleCommand(invalidLocale);

// Act
var response = await AuthenticatedOwnerHttpClient.PutAsJsonAsync("/api/account-management/users/me/change-locale", command);

// Assert
var expectedErrors = new[]
{
new ErrorDetail("locale", "Language must be one of the following: en-US, da-DK")
};
await response.ShouldHaveErrorStatusCode(HttpStatusCode.BadRequest, expectedErrors);

TelemetryEventsCollectorSpy.CollectedEvents.Should().BeEmpty();
TelemetryEventsCollectorSpy.AreAllEventsDispatched.Should().BeFalse();
}

[Fact]
public async Task ChangeLocale_WhenNotAuthenticated_ShouldReturnUnauthorized()
{
// Arrange
var command = new ChangeLocaleCommand("da-DK");

// Act
var response = await AnonymousHttpClient.PutAsJsonAsync("/api/account-management/users/me/change-locale", command);

// Assert
response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);

TelemetryEventsCollectorSpy.CollectedEvents.Should().BeEmpty();
TelemetryEventsCollectorSpy.AreAllEventsDispatched.Should().BeFalse();
}

[Fact]
public async Task ChangeLocale_WhenChangingToSameLocale_ShouldSucceed()
{
// Arrange
var locale = "en-US";
Connection.Update("Users", "Id", DatabaseSeeder.Tenant1Owner.Id.ToString(), [("Locale", locale)]);
var command = new ChangeLocaleCommand(locale);

// Act
var response = await AuthenticatedOwnerHttpClient.PutAsJsonAsync("/api/account-management/users/me/change-locale", command);

// Assert
response.ShouldHaveEmptyHeaderAndLocationOnSuccess();

var updatedLocale = Connection.ExecuteScalar<string>(
"SELECT Locale FROM Users WHERE Id = @id", [new { id = DatabaseSeeder.Tenant1Owner.Id.ToString() }]
);
updatedLocale.Should().Be(locale);

TelemetryEventsCollectorSpy.CollectedEvents.Count.Should().Be(1);
TelemetryEventsCollectorSpy.CollectedEvents[0].GetType().Name.Should().Be("UserLocaleChanged");
TelemetryEventsCollectorSpy.CollectedEvents[0].Properties["event.from_locale"].Should().Be(locale);
TelemetryEventsCollectorSpy.CollectedEvents[0].Properties["event.to_locale"].Should().Be(locale);
TelemetryEventsCollectorSpy.AreAllEventsDispatched.Should().BeTrue();
}
}
Loading
Loading