Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 2 additions & 1 deletion .agent/rules/backend/backend.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,8 @@ Guidelines for C# backend development, including code style, naming, exceptions,
- Use `user?.IsActive == true` over `user != null && user.IsActive == true`
- Avoid try-catch unless we cannot fix the root cause—global exception handling covers unknown exceptions
- Use `SharedInfrastructureConfiguration.IsRunningInAzure` to determine if running in Azure
- Use `TimeProvider.System.GetUtcNow()` instead of `DateTime.UtcNow()`
- Inject `TimeProvider` into services and handlers, use `timeProvider.GetUtcNow()` instead of `DateTimeOffset.UtcNow`
- Pass `DateTimeOffset` values (not `TimeProvider`) to domain methods and aggregates to maintain clean boundaries (e.g., `entity.HasExpired(timeProvider.GetUtcNow())`)
- Naming rules:
- Never use acronyms or abbreviations (e.g., use `SharedAccessSignature` not `Sas`, `Context` not `Ctx`)
- Prefer long variable names for readability (e.g., `gravatarHttpClient` not `httpClient`)
Expand Down
3 changes: 2 additions & 1 deletion .claude/rules/backend/backend.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,8 @@ Guidelines for C# backend development, including code style, naming, exceptions,
- Use `user?.IsActive == true` over `user != null && user.IsActive == true`
- Avoid try-catch unless we cannot fix the root cause—global exception handling covers unknown exceptions
- Use `SharedInfrastructureConfiguration.IsRunningInAzure` to determine if running in Azure
- Use `TimeProvider.System.GetUtcNow()` instead of `DateTime.UtcNow()`
- Inject `TimeProvider` into services and handlers, use `timeProvider.GetUtcNow()` instead of `DateTimeOffset.UtcNow`
- Pass `DateTimeOffset` values (not `TimeProvider`) to domain methods and aggregates to maintain clean boundaries (e.g., `entity.HasExpired(timeProvider.GetUtcNow())`)
- Naming rules:
- Never use acronyms or abbreviations (e.g., use `SharedAccessSignature` not `Sas`, `Context` not `Ctx`)
- Prefer long variable names for readability (e.g., `gravatarHttpClient` not `httpClient`)
Expand Down
3 changes: 2 additions & 1 deletion .cursor/rules/backend/backend.mdc
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,8 @@ Guidelines for C# backend development, including code style, naming, exceptions,
- Use `user?.IsActive == true` over `user != null && user.IsActive == true`
- Avoid try-catch unless we cannot fix the root cause—global exception handling covers unknown exceptions
- Use `SharedInfrastructureConfiguration.IsRunningInAzure` to determine if running in Azure
- Use `TimeProvider.System.GetUtcNow()` instead of `DateTime.UtcNow()`
- Inject `TimeProvider` into services and handlers, use `timeProvider.GetUtcNow()` instead of `DateTimeOffset.UtcNow`
- Pass `DateTimeOffset` values (not `TimeProvider`) to domain methods and aggregates to maintain clean boundaries (e.g., `entity.HasExpired(timeProvider.GetUtcNow())`)
- Naming rules:
- Never use acronyms or abbreviations (e.g., use `SharedAccessSignature` not `Sas`, `Context` not `Ctx`)
- Prefer long variable names for readability (e.g., `gravatarHttpClient` not `httpClient`)
Expand Down
3 changes: 2 additions & 1 deletion .github/copilot/rules/backend/backend.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,8 @@ Guidelines for C# backend development, including code style, naming, exceptions,
- Use `user?.IsActive == true` over `user != null && user.IsActive == true`
- Avoid try-catch unless we cannot fix the root cause—global exception handling covers unknown exceptions
- Use `SharedInfrastructureConfiguration.IsRunningInAzure` to determine if running in Azure
- Use `TimeProvider.System.GetUtcNow()` instead of `DateTime.UtcNow()`
- Inject `TimeProvider` into services and handlers, use `timeProvider.GetUtcNow()` instead of `DateTimeOffset.UtcNow`
- Pass `DateTimeOffset` values (not `TimeProvider`) to domain methods and aggregates to maintain clean boundaries (e.g., `entity.HasExpired(timeProvider.GetUtcNow())`)
- Naming rules:
- Never use acronyms or abbreviations (e.g., use `SharedAccessSignature` not `Sas`, `Context` not `Ctx`)
- Prefer long variable names for readability (e.g., `gravatarHttpClient` not `httpClient`)
Expand Down
3 changes: 2 additions & 1 deletion .windsurf/rules/backend/backend.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,8 @@ Guidelines for C# backend development, including code style, naming, exceptions,
- Use `user?.IsActive == true` over `user != null && user.IsActive == true`
- Avoid try-catch unless we cannot fix the root cause—global exception handling covers unknown exceptions
- Use `SharedInfrastructureConfiguration.IsRunningInAzure` to determine if running in Azure
- Use `TimeProvider.System.GetUtcNow()` instead of `DateTime.UtcNow()`
- Inject `TimeProvider` into services and handlers, use `timeProvider.GetUtcNow()` instead of `DateTimeOffset.UtcNow`
- Pass `DateTimeOffset` values (not `TimeProvider`) to domain methods and aggregates to maintain clean boundaries (e.g., `entity.HasExpired(timeProvider.GetUtcNow())`)
- Naming rules:
- Never use acronyms or abbreviations (e.g., use `SharedAccessSignature` not `Sas`, `Context` not `Ctx`)
- Prefer long variable names for readability (e.g., `gravatarHttpClient` not `httpClient`)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@ namespace PlatformPlatform.AppGateway.Middleware;
public class AuthenticationCookieMiddleware(
ITokenSigningClient tokenSigningClient,
IHttpClientFactory httpClientFactory,
TimeProvider timeProvider,
ILogger<AuthenticationCookieMiddleware> logger
)
: IMiddleware
) : IMiddleware
{
private const string? RefreshAuthenticationTokensEndpoint = "/internal-api/account-management/authentication/refresh-authentication-tokens";

Expand Down Expand Up @@ -51,9 +51,9 @@ private async Task ValidateAuthenticationCookieAndConvertToHttpBearerHeader(Http

try
{
if (accessToken is null || ExtractExpirationFromToken(accessToken) < TimeProvider.System.GetUtcNow())
if (accessToken is null || ExtractExpirationFromToken(accessToken) < timeProvider.GetUtcNow())
{
if (ExtractExpirationFromToken(refreshToken) < TimeProvider.System.GetUtcNow())
if (ExtractExpirationFromToken(refreshToken) < timeProvider.GetUtcNow())
{
context.Response.Cookies.Delete(AuthenticationTokenHttpKeys.RefreshTokenCookieName);
context.Response.Cookies.Delete(AuthenticationTokenHttpKeys.AccessTokenCookieName);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,5 @@

namespace PlatformPlatform.AccountManagement.Database;

public sealed class AccountManagementDbContext(DbContextOptions<AccountManagementDbContext> options, IExecutionContext executionContext)
: SharedKernelDbContext<AccountManagementDbContext>(options, executionContext);
public sealed class AccountManagementDbContext(DbContextOptions<AccountManagementDbContext> options, IExecutionContext executionContext, TimeProvider timeProvider)
: SharedKernelDbContext<AccountManagementDbContext>(options, executionContext, timeProvider);
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ public sealed class CompleteLoginHandler(
AvatarUpdater avatarUpdater,
GravatarClient gravatarClient,
ITelemetryEventsCollector events,
TimeProvider timeProvider,
ILogger<CompleteLoginHandler> logger
) : IRequestHandler<CompleteLoginCommand, Result>
{
Expand Down Expand Up @@ -98,7 +99,7 @@ private void CompleteUserInvite(User user)
{
user.ConfirmEmail();
userRepository.Update(user);
var inviteAcceptedTimeInMinutes = (int)(TimeProvider.System.GetUtcNow() - user.CreatedAt).TotalMinutes;
var inviteAcceptedTimeInMinutes = (int)(timeProvider.GetUtcNow() - user.CreatedAt).TotalMinutes;
events.CollectEvent(new UserInviteAccepted(user.Id, inviteAcceptedTimeInMinutes));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ public sealed class SwitchTenantHandler(
IBlobStorageClient blobStorageClient,
IExecutionContext executionContext,
ITelemetryEventsCollector events,
TimeProvider timeProvider,
ILogger<SwitchTenantHandler> logger
) : IRequestHandler<SwitchTenantCommand, Result>
{
Expand Down Expand Up @@ -88,7 +89,7 @@ private async Task CopyProfileDataFromCurrentUser(User targetUser, CancellationT
userRepository.Update(targetUser);

// Calculate how long it took to accept the invitation
var inviteAcceptedTimeInMinutes = (int)(DateTimeOffset.UtcNow - targetUser.CreatedAt).TotalMinutes;
var inviteAcceptedTimeInMinutes = (int)(timeProvider.GetUtcNow() - targetUser.CreatedAt).TotalMinutes;
events.CollectEvent(new UserInviteAccepted(targetUser.Id, inviteAcceptedTimeInMinutes));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ public sealed class CompleteEmailConfirmationHandler(
IEmailConfirmationRepository emailConfirmationRepository,
OneTimePasswordHelper oneTimePasswordHelper,
ITelemetryEventsCollector events,
TimeProvider timeProvider,
ILogger<CompleteEmailConfirmationHandler> logger
) : IRequestHandler<CompleteEmailConfirmationCommand, Result<CompleteEmailConfirmationResponse>>
{
Expand Down Expand Up @@ -51,14 +52,14 @@ public async Task<Result<CompleteEmailConfirmationResponse>> Handle(CompleteEmai
return Result<CompleteEmailConfirmationResponse>.BadRequest("The code is wrong or no longer valid.", true);
}

var confirmationTimeInSeconds = (int)(TimeProvider.System.GetUtcNow() - emailConfirmation.CreatedAt).TotalSeconds;
if (emailConfirmation.HasExpired())
var confirmationTimeInSeconds = (int)(timeProvider.GetUtcNow() - emailConfirmation.CreatedAt).TotalSeconds;
if (emailConfirmation.HasExpired(timeProvider.GetUtcNow()))
{
events.CollectEvent(new EmailConfirmationExpired(emailConfirmation.Id, emailConfirmation.Type, confirmationTimeInSeconds));
return Result<CompleteEmailConfirmationResponse>.BadRequest("The code is no longer valid, please request a new code.", true);
}

emailConfirmation.MarkAsCompleted();
emailConfirmation.MarkAsCompleted(timeProvider.GetUtcNow());
emailConfirmationRepository.Update(emailConfirmation);

return new CompleteEmailConfirmationResponse(emailConfirmation.Email, confirmationTimeInSeconds);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ public sealed class ResendEmailConfirmationCodeHandler(
IEmailClient emailClient,
IPasswordHasher<object> passwordHasher,
ITelemetryEventsCollector events,
TimeProvider timeProvider,
ILogger<ResendEmailConfirmationCodeHandler> logger
) : IRequestHandler<ResendEmailConfirmationCodeCommand, Result<ResendEmailConfirmationCodeResponse>>
{
Expand All @@ -45,10 +46,10 @@ public async Task<Result<ResendEmailConfirmationCodeResponse>> Handle(ResendEmai

var oneTimePassword = OneTimePasswordHelper.GenerateOneTimePassword(6);
var oneTimePasswordHash = passwordHasher.HashPassword(this, oneTimePassword);
emailConfirmation.UpdateVerificationCode(oneTimePasswordHash);
emailConfirmation.UpdateVerificationCode(oneTimePasswordHash, timeProvider.GetUtcNow());
emailConfirmationRepository.Update(emailConfirmation);

var secondsSinceSignupStarted = (TimeProvider.System.GetUtcNow() - emailConfirmation.CreatedAt).TotalSeconds;
var secondsSinceSignupStarted = (timeProvider.GetUtcNow() - emailConfirmation.CreatedAt).TotalSeconds;
events.CollectEvent(new EmailConfirmationResend((int)secondsSinceSignupStarted));

await emailClient.SendAsync(emailConfirmation.Email, "Your verification code (resend)",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,15 +29,16 @@ public StartEmailConfirmationValidator()
public sealed class StartEmailConfirmationHandler(
IEmailConfirmationRepository emailConfirmationRepository,
IEmailClient emailClient,
IPasswordHasher<object> passwordHasher
IPasswordHasher<object> passwordHasher,
TimeProvider timeProvider
) : IRequestHandler<StartEmailConfirmationCommand, Result<StartEmailConfirmationResponse>>
{
public async Task<Result<StartEmailConfirmationResponse>> Handle(StartEmailConfirmationCommand command, CancellationToken cancellationToken)
{
var existingConfirmations = emailConfirmationRepository.GetByEmail(command.Email).ToArray();

var lockoutMinutes = command.Type == EmailConfirmationType.Signup ? -60 : -15;
if (existingConfirmations.Count(r => r.CreatedAt > TimeProvider.System.GetUtcNow().AddMinutes(lockoutMinutes)) >= 3)
if (existingConfirmations.Count(r => r.CreatedAt > timeProvider.GetUtcNow().AddMinutes(lockoutMinutes)) >= 3)
{
return Result<StartEmailConfirmationResponse>.TooManyRequests("Too many attempts to confirm this email address. Please try again later.");
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,9 @@ private EmailConfirmation(string email, EmailConfirmationType type, string oneTi

public bool Completed { get; private set; }

public bool HasExpired()
public bool HasExpired(DateTimeOffset now)
{
return ValidUntil < TimeProvider.System.GetUtcNow();
return ValidUntil < now;
}

public static EmailConfirmation Create(string email, string oneTimePasswordHash, EmailConfirmationType type)
Expand All @@ -49,9 +49,9 @@ public void RegisterInvalidPasswordAttempt()
RetryCount++;
}

public void MarkAsCompleted()
public void MarkAsCompleted(DateTimeOffset now)
{
if (HasExpired() || RetryCount >= MaxAttempts)
if (HasExpired(now) || RetryCount >= MaxAttempts)
{
throw new UnreachableException("This email confirmation has expired.");
}
Expand All @@ -61,7 +61,7 @@ public void MarkAsCompleted()
Completed = true;
}

public void UpdateVerificationCode(string oneTimePasswordHash)
public void UpdateVerificationCode(string oneTimePasswordHash, DateTimeOffset now)
{
if (Completed)
{
Expand All @@ -73,7 +73,7 @@ public void UpdateVerificationCode(string oneTimePasswordHash)
throw new UnreachableException("Cannot regenerate verification code for email confirmation that has been resent too many times.");
}

ValidUntil = TimeProvider.System.GetUtcNow().AddSeconds(ValidForSeconds);
ValidUntil = now.AddSeconds(ValidForSeconds);
OneTimePasswordHash = oneTimePasswordHash;
ResendCount++;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ public sealed record DeclineInvitationCommand(TenantId TenantId) : ICommand, IRe
public sealed class DeclineInvitationHandler(
IUserRepository userRepository,
IExecutionContext executionContext,
ITelemetryEventsCollector events
ITelemetryEventsCollector events,
TimeProvider timeProvider
) : IRequestHandler<DeclineInvitationCommand, Result>
{
public async Task<Result> Handle(DeclineInvitationCommand command, CancellationToken cancellationToken)
Expand All @@ -34,7 +35,7 @@ public async Task<Result> Handle(DeclineInvitationCommand command, CancellationT
}

// Calculate how long the invitation existed
var inviteExistedTimeInMinutes = (int)(TimeProvider.System.GetUtcNow() - user.CreatedAt).TotalMinutes;
var inviteExistedTimeInMinutes = (int)(timeProvider.GetUtcNow() - user.CreatedAt).TotalMinutes;

// Delete the user to decline the invitation
userRepository.Remove(user);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ CancellationToken cancellationToken
Task<User[]> GetUsersByEmailUnfilteredAsync(string email, CancellationToken cancellationToken);
}

internal sealed class UserRepository(AccountManagementDbContext accountManagementDbContext, IExecutionContext executionContext)
internal sealed class UserRepository(AccountManagementDbContext accountManagementDbContext, IExecutionContext executionContext, TimeProvider timeProvider)
: RepositoryBase<User, UserId>(accountManagementDbContext), IUserRepository
{
/// <summary>
Expand Down Expand Up @@ -89,7 +89,7 @@ public async Task<User[]> GetByIdsAsync(UserId[] ids, CancellationToken cancella

public async Task<(int TotalUsers, int ActiveUsers, int PendingUsers)> GetUserSummaryAsync(CancellationToken cancellationToken)
{
var thirtyDaysAgo = TimeProvider.System.GetUtcNow().AddDays(-30);
var thirtyDaysAgo = timeProvider.GetUtcNow().AddDays(-30);

var summary = await DbSet
.GroupBy(_ => 1) // Group all records into a single group to calculate multiple COUNT aggregates in one query
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -159,20 +159,20 @@ public async Task CompleteLogin_WhenLoginExpired_ShouldReturnBadRequest()
("TenantId", DatabaseSeeder.Tenant1Owner.TenantId.ToString()),
("UserId", DatabaseSeeder.Tenant1Owner.Id.ToString()),
("Id", loginId.ToString()),
("CreatedAt", TimeProvider.System.GetUtcNow().AddMinutes(-10)),
("CreatedAt", TimeProvider.GetUtcNow().AddMinutes(-10)),
("ModifiedAt", null),
("EmailConfirmationId", emailConfirmationId.ToString()),
("Completed", false)
]
);
Connection.Insert("EmailConfirmations", [
("Id", emailConfirmationId.ToString()),
("CreatedAt", TimeProvider.System.GetUtcNow().AddMinutes(-10)),
("CreatedAt", TimeProvider.GetUtcNow().AddMinutes(-10)),
("ModifiedAt", null),
("Email", DatabaseSeeder.Tenant1Owner.Email),
("Type", EmailConfirmationType.Signup),
("OneTimePasswordHash", new PasswordHasher<object>().HashPassword(this, CorrectOneTimePassword)),
("ValidUntil", TimeProvider.System.GetUtcNow().AddMinutes(-10)),
("ValidUntil", TimeProvider.GetUtcNow().AddMinutes(-10)),
("RetryCount", 0),
("ResendCount", 0),
("Completed", false)
Expand Down Expand Up @@ -236,7 +236,7 @@ public async Task CompleteLogin_WithValidPreferredTenant_ShouldLoginToPreferredT

Connection.Insert("Tenants", [
("Id", tenant2Id.Value),
("CreatedAt", TimeProvider.System.GetUtcNow()),
("CreatedAt", TimeProvider.GetUtcNow()),
("ModifiedAt", null),
("Name", Faker.Company.CompanyName()),
("State", nameof(TenantState.Active)),
Expand All @@ -247,7 +247,7 @@ public async Task CompleteLogin_WithValidPreferredTenant_ShouldLoginToPreferredT
Connection.Insert("Users", [
("TenantId", tenant2Id.Value),
("Id", user2Id.ToString()),
("CreatedAt", TimeProvider.System.GetUtcNow()),
("CreatedAt", TimeProvider.GetUtcNow()),
("ModifiedAt", null),
("Email", DatabaseSeeder.Tenant1Owner.Email),
("EmailConfirmed", true),
Expand Down Expand Up @@ -309,7 +309,7 @@ public async Task CompleteLogin_WithPreferredTenantUserDoesNotHaveAccess_ShouldL

Connection.Insert("Tenants", [
("Id", tenant2Id.Value),
("CreatedAt", TimeProvider.System.GetUtcNow()),
("CreatedAt", TimeProvider.GetUtcNow()),
("ModifiedAt", null),
("Name", Faker.Company.CompanyName()),
("State", nameof(TenantState.Active)),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -127,12 +127,12 @@ public async Task StartLogin_WhenTooManyAttempts_ShouldReturnTooManyRequests()
var oneTimePasswordHash = new PasswordHasher<object>().HashPassword(this, OneTimePasswordHelper.GenerateOneTimePassword(6));
Connection.Insert("EmailConfirmations", [
("Id", EmailConfirmationId.NewId().ToString()),
("CreatedAt", TimeProvider.System.GetUtcNow().AddMinutes(-i)),
("CreatedAt", TimeProvider.GetUtcNow().AddMinutes(-i)),
("ModifiedAt", null),
("Email", email.ToLower()),
("Type", nameof(EmailConfirmationType.Login)),
("OneTimePasswordHash", oneTimePasswordHash),
("ValidUntil", TimeProvider.System.GetUtcNow().AddMinutes(-i - 1)), // All should be expired
("ValidUntil", TimeProvider.GetUtcNow().AddMinutes(-i - 1)), // All should be expired
("RetryCount", 0),
("ResendCount", 0),
("Completed", false)
Expand Down
Loading