1
0
mirror of https://github.com/bitwarden/server synced 2025-12-06 00:03:34 +00:00
Files
server/test/Identity.Test/IdentityServer/SendAccess/SendEmailOtpRequestValidatorTests.cs
Ike 9ce1ecba49 [PM-25240] Send Access OTP email in MJML format (#6411)
feat: Add MJML email templates for Send Email OTP
feat: Implement MJML-based email templates for Send OTP functionality
feat: Add feature flag support for Send Email OTP v2 emails
feat: Update email view models and call sites for Send Email OTP

fix: Modify the directory structure for MJML templates to have Auth directory for better team ownership
fix: Rename `hero.js` to `mj-bw-hero.js`

---
Co-authored-by: Todd Martin <106564991+trmartin4@users.noreply.github.com>
2025-10-22 15:13:31 -04:00

277 lines
11 KiB
C#

using Bit.Core.Auth.Identity;
using Bit.Core.Auth.Identity.TokenProviders;
using Bit.Core.Services;
using Bit.Core.Tools.Models.Data;
using Bit.Identity.IdentityServer.RequestValidators.SendAccess;
using Bit.Test.Common.AutoFixture;
using Bit.Test.Common.AutoFixture.Attributes;
using Duende.IdentityModel;
using Duende.IdentityServer.Validation;
using NSubstitute;
using Xunit;
namespace Bit.Identity.Test.IdentityServer.SendAccess;
[SutProviderCustomize]
public class SendEmailOtpRequestValidatorTests
{
[Theory, BitAutoData]
public async Task ValidateRequestAsync_MissingEmail_ReturnsInvalidRequest(
SutProvider<SendEmailOtpRequestValidator> sutProvider,
[AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
EmailOtp emailOtp,
Guid sendId)
{
// Arrange
tokenRequest.Raw = SendAccessTestUtilities.CreateValidatedTokenRequest(sendId);
var context = new ExtensionGrantValidationContext
{
Request = tokenRequest
};
// Act
var result = await sutProvider.Sut.ValidateRequestAsync(context, emailOtp, sendId);
// Assert
Assert.True(result.IsError);
Assert.Equal(OidcConstants.TokenErrors.InvalidRequest, result.Error);
Assert.Equal("email is required.", result.ErrorDescription);
// Verify no OTP generation or email sending occurred
await sutProvider.GetDependency<IOtpTokenProvider<DefaultOtpTokenProviderOptions>>()
.DidNotReceive()
.GenerateTokenAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>());
await sutProvider.GetDependency<IMailService>()
.DidNotReceive()
.SendSendEmailOtpEmailAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>());
}
[Theory, BitAutoData]
public async Task ValidateRequestAsync_EmailNotInList_ReturnsInvalidRequest(
SutProvider<SendEmailOtpRequestValidator> sutProvider,
[AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
EmailOtp emailOtp,
string email,
Guid sendId)
{
// Arrange
tokenRequest.Raw = SendAccessTestUtilities.CreateValidatedTokenRequest(sendId, email);
var context = new ExtensionGrantValidationContext
{
Request = tokenRequest
};
// Act
var result = await sutProvider.Sut.ValidateRequestAsync(context, emailOtp, sendId);
// Assert
Assert.True(result.IsError);
Assert.Equal(OidcConstants.TokenErrors.InvalidGrant, result.Error);
Assert.Equal("email is invalid.", result.ErrorDescription);
// Verify no OTP generation or email sending occurred
await sutProvider.GetDependency<IOtpTokenProvider<DefaultOtpTokenProviderOptions>>()
.DidNotReceive()
.GenerateTokenAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>());
await sutProvider.GetDependency<IMailService>()
.DidNotReceive()
.SendSendEmailOtpEmailAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>());
}
[Theory, BitAutoData]
public async Task ValidateRequestAsync_EmailWithoutOtp_GeneratesAndSendsOtp(
SutProvider<SendEmailOtpRequestValidator> sutProvider,
[AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
EmailOtp emailOtp,
Guid sendId,
string email,
string generatedToken)
{
// Arrange
tokenRequest.Raw = SendAccessTestUtilities.CreateValidatedTokenRequest(sendId, email);
var context = new ExtensionGrantValidationContext
{
Request = tokenRequest
};
var expectedUniqueId = string.Format(SendAccessConstants.OtpToken.TokenUniqueIdentifier, sendId, email);
sutProvider.GetDependency<IOtpTokenProvider<DefaultOtpTokenProviderOptions>>()
.GenerateTokenAsync(
SendAccessConstants.OtpToken.TokenProviderName,
SendAccessConstants.OtpToken.Purpose,
expectedUniqueId)
.Returns(generatedToken);
emailOtp = emailOtp with { Emails = [email] };
// Act
var result = await sutProvider.Sut.ValidateRequestAsync(context, emailOtp, sendId);
// Assert
Assert.True(result.IsError);
Assert.Equal(OidcConstants.TokenErrors.InvalidRequest, result.Error);
Assert.Equal("email otp sent.", result.ErrorDescription);
// Verify OTP generation
await sutProvider.GetDependency<IOtpTokenProvider<DefaultOtpTokenProviderOptions>>()
.Received(1)
.GenerateTokenAsync(
SendAccessConstants.OtpToken.TokenProviderName,
SendAccessConstants.OtpToken.Purpose,
expectedUniqueId);
// Verify email sending
await sutProvider.GetDependency<IMailService>()
.Received(1)
.SendSendEmailOtpEmailAsync(email, generatedToken, Arg.Any<string>());
}
[Theory, BitAutoData]
public async Task ValidateRequestAsync_OtpGenerationFails_ReturnsGenerationFailedError(
SutProvider<SendEmailOtpRequestValidator> sutProvider,
[AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
EmailOtp emailOtp,
Guid sendId,
string email)
{
// Arrange
tokenRequest.Raw = SendAccessTestUtilities.CreateValidatedTokenRequest(sendId, email);
var context = new ExtensionGrantValidationContext
{
Request = tokenRequest
};
emailOtp = emailOtp with { Emails = [email] };
sutProvider.GetDependency<IOtpTokenProvider<DefaultOtpTokenProviderOptions>>()
.GenerateTokenAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>())
.Returns((string)null); // Generation fails
// Act
var result = await sutProvider.Sut.ValidateRequestAsync(context, emailOtp, sendId);
// Assert
Assert.True(result.IsError);
Assert.Equal(OidcConstants.TokenErrors.InvalidRequest, result.Error);
// Verify no email was sent
await sutProvider.GetDependency<IMailService>()
.DidNotReceive()
.SendSendEmailOtpEmailAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>());
}
[Theory, BitAutoData]
public async Task ValidateRequestAsync_ValidOtp_ReturnsSuccess(
SutProvider<SendEmailOtpRequestValidator> sutProvider,
[AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
EmailOtp emailOtp,
Guid sendId,
string email,
string otp)
{
// Arrange
tokenRequest.Raw = SendAccessTestUtilities.CreateValidatedTokenRequest(sendId, email, otp);
var context = new ExtensionGrantValidationContext
{
Request = tokenRequest
};
emailOtp = emailOtp with { Emails = [email] };
var expectedUniqueId = string.Format(SendAccessConstants.OtpToken.TokenUniqueIdentifier, sendId, email);
sutProvider.GetDependency<IOtpTokenProvider<DefaultOtpTokenProviderOptions>>()
.ValidateTokenAsync(
otp,
SendAccessConstants.OtpToken.TokenProviderName,
SendAccessConstants.OtpToken.Purpose,
expectedUniqueId)
.Returns(true);
// Act
var result = await sutProvider.Sut.ValidateRequestAsync(context, emailOtp, sendId);
// Assert
Assert.False(result.IsError);
var sub = result.Subject;
Assert.Equal(sendId.ToString(), sub.Claims.First(c => c.Type == Claims.SendAccessClaims.SendId).Value);
// Verify claims
Assert.Contains(sub.Claims, c => c.Type == Claims.SendAccessClaims.SendId && c.Value == sendId.ToString());
Assert.Contains(sub.Claims, c => c.Type == Claims.SendAccessClaims.Email && c.Value == email);
Assert.Contains(sub.Claims, c => c.Type == Claims.Type && c.Value == IdentityClientType.Send.ToString());
// Verify OTP validation was called
await sutProvider.GetDependency<IOtpTokenProvider<DefaultOtpTokenProviderOptions>>()
.Received(1)
.ValidateTokenAsync(otp, SendAccessConstants.OtpToken.TokenProviderName, SendAccessConstants.OtpToken.Purpose, expectedUniqueId);
// Verify no email was sent (validation only)
await sutProvider.GetDependency<IMailService>()
.DidNotReceive()
.SendSendEmailOtpEmailAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>());
}
[Theory, BitAutoData]
public async Task ValidateRequestAsync_InvalidOtp_ReturnsInvalidGrant(
SutProvider<SendEmailOtpRequestValidator> sutProvider,
[AutoFixture.ValidatedTokenRequest] ValidatedTokenRequest tokenRequest,
EmailOtp emailOtp,
Guid sendId,
string email,
string invalidOtp)
{
// Arrange
tokenRequest.Raw = SendAccessTestUtilities.CreateValidatedTokenRequest(sendId, email, invalidOtp);
var context = new ExtensionGrantValidationContext
{
Request = tokenRequest
};
emailOtp = emailOtp with { Emails = [email] };
var expectedUniqueId = string.Format(SendAccessConstants.OtpToken.TokenUniqueIdentifier, sendId, email);
sutProvider.GetDependency<IOtpTokenProvider<DefaultOtpTokenProviderOptions>>()
.ValidateTokenAsync(invalidOtp,
SendAccessConstants.OtpToken.TokenProviderName,
SendAccessConstants.OtpToken.Purpose,
expectedUniqueId)
.Returns(false);
// Act
var result = await sutProvider.Sut.ValidateRequestAsync(context, emailOtp, sendId);
// Assert
Assert.True(result.IsError);
Assert.Equal(OidcConstants.TokenErrors.InvalidGrant, result.Error);
Assert.Equal("email otp is invalid.", result.ErrorDescription);
// Verify OTP validation was attempted
await sutProvider.GetDependency<IOtpTokenProvider<DefaultOtpTokenProviderOptions>>()
.Received(1)
.ValidateTokenAsync(invalidOtp,
SendAccessConstants.OtpToken.TokenProviderName,
SendAccessConstants.OtpToken.Purpose,
expectedUniqueId);
}
[Fact]
public void Constructor_WithValidParameters_CreatesInstance()
{
// Arrange
var otpTokenProvider = Substitute.For<IOtpTokenProvider<DefaultOtpTokenProviderOptions>>();
var mailService = Substitute.For<IMailService>();
var featureService = Substitute.For<IFeatureService>();
// Act
var validator = new SendEmailOtpRequestValidator(featureService, otpTokenProvider, mailService);
// Assert
Assert.NotNull(validator);
}
}