diff --git a/src/Orbit.Application/Auth/Commands/SendCodeCommand.cs b/src/Orbit.Application/Auth/Commands/SendCodeCommand.cs index d891f5ab..12191e42 100644 --- a/src/Orbit.Application/Auth/Commands/SendCodeCommand.cs +++ b/src/Orbit.Application/Auth/Commands/SendCodeCommand.cs @@ -16,13 +16,22 @@ public class SendCodeCommandHandler( IMemoryCache cache, IEmailService emailService) : IRequestHandler { + private const int MinSmokeCodeLength = 16; + public async Task Handle(SendCodeCommand request, CancellationToken cancellationToken) { 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)) @@ -62,4 +71,27 @@ public async Task Handle(SendCodeCommand request, CancellationToken canc 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; + } } diff --git a/tests/Orbit.Application.Tests/Commands/Auth/SendCodeCommandHandlerTests.cs b/tests/Orbit.Application.Tests/Commands/Auth/SendCodeCommandHandlerTests.cs new file mode 100644 index 00000000..b8fb7892 --- /dev/null +++ b/tests/Orbit.Application.Tests/Commands/Auth/SendCodeCommandHandlerTests.cs @@ -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(); + 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(), Arg.Any(), Arg.Any(), Arg.Any()); + }); + } + + [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(), Arg.Any(), Arg.Any()); + }); + } + + [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(), Arg.Any(), Arg.Any()); + }); + } + + [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(), Arg.Any(), Arg.Any()); + }); + } + + [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(), Arg.Any(), Arg.Any()); + }); + } + + private VerifyCodeCommandHandler BuildVerifyHandler() + { + var userRepo = Substitute.For>(); + var unitOfWork = Substitute.For(); + var authSessionService = Substitute.For(); + var mediator = Substitute.For(); + + authSessionService.CreateSessionAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Result.Success(new SessionTokens("jwt-token", "refresh-token"))); + + return new VerifyCodeCommandHandler( + _cache, userRepo, unitOfWork, authSessionService, _emailService, mediator, + Substitute.For>()); + } + + private static async Task WithEnv(string aspNetEnv, string? smokeEmail, string? smokeCode, Func 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); + } + } +}