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
34 changes: 33 additions & 1 deletion src/Orbit.Application/Auth/Commands/SendCodeCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,22 @@
IMemoryCache cache,
IEmailService emailService) : IRequestHandler<SendCodeCommand, Result>
{
private const int MinSmokeCodeLength = 16;

public async Task<Result> Handle(SendCodeCommand request, CancellationToken cancellationToken)

Check warning on line 21 in src/Orbit.Application/Auth/Commands/SendCodeCommand.cs

View workflow job for this annotation

GitHub Actions / SonarCloud Analysis

Refactor this method to reduce its Cognitive Complexity from 18 to the 15 allowed.

Check warning on line 21 in src/Orbit.Application/Auth/Commands/SendCodeCommand.cs

View workflow job for this annotation

GitHub Actions / SonarCloud Analysis

Refactor this method to reduce its Cognitive Complexity from 18 to the 15 allowed.

Check failure on line 21 in src/Orbit.Application/Auth/Commands/SendCodeCommand.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this method to reduce its Cognitive Complexity from 18 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=thomasluizon_orbit-api&issues=AZ7xaQOMG0DGCih_W5J8&open=AZ7xaQOMG0DGCih_W5J8&pullRequest=217
{
var email = request.Email.Trim().ToLowerInvariant();
var cacheKey = $"verify:{email}";

var aspNetEnv = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT");
if (!string.Equals(aspNetEnv, "Production", StringComparison.OrdinalIgnoreCase))
var isProduction = string.Equals(aspNetEnv, "Production", StringComparison.OrdinalIgnoreCase);

if (isProduction)
{
if (TrySeedProductionSmokeCode(email, cacheKey))
return Result.Success();
}
else
{
var testAccountsEnv = Environment.GetEnvironmentVariable("TEST_ACCOUNTS");
if (!string.IsNullOrEmpty(testAccountsEnv))
Expand Down Expand Up @@ -62,4 +71,27 @@

return Result.Success();
}

private bool TrySeedProductionSmokeCode(string email, string cacheKey)
{
var smokeEmail = Environment.GetEnvironmentVariable("SMOKE_TEST_EMAIL");
var smokeCode = Environment.GetEnvironmentVariable("SMOKE_TEST_CODE");

if (string.IsNullOrEmpty(smokeEmail) || string.IsNullOrEmpty(smokeCode))
return false;

if (smokeCode.Length < MinSmokeCodeLength)
return false;

if (!string.Equals(smokeEmail.Trim(), email, StringComparison.OrdinalIgnoreCase))
return false;

var entry = new VerificationEntry(smokeCode, 0, DateTime.UtcNow);
cache.Set(cacheKey, entry, new MemoryCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(30)
});

return true;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
using FluentAssertions;
using MediatR;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using NSubstitute;
using Orbit.Application.Auth.Commands;
using Orbit.Domain.Common;
using Orbit.Domain.Entities;
using Orbit.Domain.Interfaces;
using Orbit.Domain.Models;

namespace Orbit.Application.Tests.Commands.Auth;

public class SendCodeCommandHandlerTests
{
private readonly MemoryCache _cache = new(new MemoryCacheOptions());
private readonly IEmailService _emailService = Substitute.For<IEmailService>();
private readonly SendCodeCommandHandler _handler;

private const string SmokeEmail = "smoke@useorbit.org";
private const string SmokeCode = "a7Q3-not-a-real-otp-zX9";

public SendCodeCommandHandlerTests()
{
_handler = new SendCodeCommandHandler(_cache, _emailService);
}

[Fact]
public async Task Production_PinnedEmail_SeedsFixedCode_AndSkipsEmail()
{
await WithEnv("Production", SmokeEmail, SmokeCode, async () =>
{
var result = await _handler.Handle(new SendCodeCommand(SmokeEmail), CancellationToken.None);

result.IsSuccess.Should().BeTrue();
_cache.TryGetValue($"verify:{SmokeEmail}", out VerificationEntry? entry).Should().BeTrue();
entry!.Code.Should().Be(SmokeCode);
await _emailService.DidNotReceive().SendVerificationCodeAsync(
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>());
});
}

[Fact]
public async Task Production_PinnedEmail_WrongSubmittedCode_VerifyFails()
{
await WithEnv("Production", SmokeEmail, SmokeCode, async () =>
{
await _handler.Handle(new SendCodeCommand(SmokeEmail), CancellationToken.None);

var verify = BuildVerifyHandler();
var result = await verify.Handle(new VerifyCodeCommand(SmokeEmail, "000000"), CancellationToken.None);

result.IsFailure.Should().BeTrue();
result.ErrorCode.Should().Be("INVALID_VERIFICATION_CODE");
});
}

[Fact]
public async Task Production_PinnedEmail_CorrectSubmittedCode_VerifySucceeds()
{
await WithEnv("Production", SmokeEmail, SmokeCode, async () =>
{
await _handler.Handle(new SendCodeCommand(SmokeEmail), CancellationToken.None);

var verify = BuildVerifyHandler();
var result = await verify.Handle(new VerifyCodeCommand(SmokeEmail, SmokeCode), CancellationToken.None);

result.IsSuccess.Should().BeTrue();
result.Value.Email.Should().Be(SmokeEmail);
});
}

[Fact]
public async Task Production_DifferentEmail_NoBypass_SendsEmail()
{
await WithEnv("Production", SmokeEmail, SmokeCode, async () =>
{
var result = await _handler.Handle(new SendCodeCommand("someone-else@useorbit.org"), CancellationToken.None);

result.IsSuccess.Should().BeTrue();
_cache.TryGetValue($"verify:someone-else@useorbit.org", out VerificationEntry? entry).Should().BeTrue();
entry!.Code.Should().NotBe(SmokeCode);
await _emailService.Received(1).SendVerificationCodeAsync(
"someone-else@useorbit.org", Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>());
});
}

[Fact]
public async Task NonProduction_PinnedEmail_NoBypass_SendsEmail()
{
await WithEnv("Development", SmokeEmail, SmokeCode, async () =>
{
var result = await _handler.Handle(new SendCodeCommand(SmokeEmail), CancellationToken.None);

result.IsSuccess.Should().BeTrue();
_cache.TryGetValue($"verify:{SmokeEmail}", out VerificationEntry? entry).Should().BeTrue();
entry!.Code.Should().NotBe(SmokeCode);
await _emailService.Received(1).SendVerificationCodeAsync(
SmokeEmail, Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>());
});
}

[Fact]
public async Task Production_UnsetSecret_NoBypass_SendsEmail()
{
await WithEnv("Production", SmokeEmail, null, async () =>
{
var result = await _handler.Handle(new SendCodeCommand(SmokeEmail), CancellationToken.None);

result.IsSuccess.Should().BeTrue();
_cache.TryGetValue($"verify:{SmokeEmail}", out VerificationEntry? entry).Should().BeTrue();
entry!.Code.Should().NotBe(SmokeCode);
await _emailService.Received(1).SendVerificationCodeAsync(
SmokeEmail, Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>());
});
}

[Fact]
public async Task Production_ShortSmokeCode_NoBypass_SendsEmail()
{
await WithEnv("Production", SmokeEmail, "short", async () =>
{
var result = await _handler.Handle(new SendCodeCommand(SmokeEmail), CancellationToken.None);

result.IsSuccess.Should().BeTrue();
_cache.TryGetValue($"verify:{SmokeEmail}", out VerificationEntry? entry).Should().BeTrue();
entry!.Code.Should().NotBe("short");
await _emailService.Received(1).SendVerificationCodeAsync(
SmokeEmail, Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>());
});
}

private VerifyCodeCommandHandler BuildVerifyHandler()
{
var userRepo = Substitute.For<IGenericRepository<User>>();
var unitOfWork = Substitute.For<IUnitOfWork>();
var authSessionService = Substitute.For<IAuthSessionService>();
var mediator = Substitute.For<IMediator>();

authSessionService.CreateSessionAsync(Arg.Any<Guid>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(Result.Success(new SessionTokens("jwt-token", "refresh-token")));

return new VerifyCodeCommandHandler(
_cache, userRepo, unitOfWork, authSessionService, _emailService, mediator,
Substitute.For<ILogger<VerifyCodeCommandHandler>>());
}

private static async Task WithEnv(string aspNetEnv, string? smokeEmail, string? smokeCode, Func<Task> body)
{
var priorEnv = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT");
var priorEmail = Environment.GetEnvironmentVariable("SMOKE_TEST_EMAIL");
var priorCode = Environment.GetEnvironmentVariable("SMOKE_TEST_CODE");
Environment.SetEnvironmentVariable("ASPNETCORE_ENVIRONMENT", aspNetEnv);
Environment.SetEnvironmentVariable("SMOKE_TEST_EMAIL", smokeEmail);
Environment.SetEnvironmentVariable("SMOKE_TEST_CODE", smokeCode);
try
{
await body();
}
finally
{
Environment.SetEnvironmentVariable("ASPNETCORE_ENVIRONMENT", priorEnv);
Environment.SetEnvironmentVariable("SMOKE_TEST_EMAIL", priorEmail);
Environment.SetEnvironmentVariable("SMOKE_TEST_CODE", priorCode);
}
}
}
Loading