using System.Net; using System.Security.Cryptography; using System.Text; using Api.SeaHavenIndustries.Controllers; using Api.SeaHavenIndustries.Infrastructure; using Microsoft.Extensions.DependencyInjection; using SeaHaven.DataServices.Interfaces; using SeaHaven.Services.Helpers; using SeaHaven.Services.Interfaces; namespace SeaHavenIndustries.Tests; /// /// The anonymous Forgot Password flow over HTTP, against the real controller, /// services, Identity and a relational database. /// public sealed class PasswordResetFlowTests { private const string Alice = "alice@example.com"; private const string Bob = "bob@example.com"; private const string OldPassword = "Old@12345"; private const string NewPassword = "New@67890"; private const string CodeNotMatched = "{\"status\":\"Error\",\"message\":\"Code Not Matched\"}"; private const string ResetFailed = "{\"status\":\"Error\",\"message\":\"Your email or code not found please check\"}"; [Fact] public async Task Registered_user_resets_password_end_to_end_and_signs_in_with_the_new_one() { await using var host = await PasswordResetTestHost.StartAsync(); await host.AddUserAsync(Alice, OldPassword); var requested = await host.ForgetPasswordAsync(Alice); Assert.Equal(HttpStatusCode.OK, requested.StatusCode); var code = host.Sent.LatestCodeFor(Alice); Assert.Matches("^[0-9]{6}$", code); var verified = await host.VerifyAsync(Alice, code); Assert.Equal(HttpStatusCode.OK, verified.StatusCode); Assert.Contains("Code Matched", await verified.Content.ReadAsStringAsync()); var reset = await host.ResetAsync(Alice, code, NewPassword); Assert.Equal(HttpStatusCode.OK, reset.StatusCode); Assert.Equal(HttpStatusCode.OK, (await host.LoginAsync(Alice, NewPassword)).StatusCode); Assert.Equal(HttpStatusCode.Unauthorized, (await host.LoginAsync(Alice, OldPassword)).StatusCode); // A used code cannot be replayed. Assert.Equal(ResetFailed, await (await host.ResetAsync(Alice, code, "Other@24680")).Content.ReadAsStringAsync()); Assert.Empty(await host.PendingCodesAsync()); } [Fact] public async Task ForgetPassword_answers_registered_and_unregistered_emails_identically_and_emails_only_the_registered_one() { await using var host = await PasswordResetTestHost.StartAsync(); await host.AddUserAsync(Alice, OldPassword); var registered = await host.ForgetPasswordAsync(Alice); var unregistered = await host.ForgetPasswordAsync("nobody@example.com"); Assert.Equal(HttpStatusCode.OK, registered.StatusCode); Assert.Equal(registered.StatusCode, unregistered.StatusCode); Assert.Equal(await registered.Content.ReadAsStringAsync(), await unregistered.Content.ReadAsStringAsync()); Assert.Contains(AuthenticationController.ForgetPasswordMessage, await registered.Content.ReadAsStringAsync()); var message = Assert.Single(host.Sent.Messages); Assert.Equal(Alice, message.To); Assert.Single(await host.PendingCodesAsync(), row => row.UserId.Length > 0); } [Fact] public async Task ForgetPassword_hides_an_email_delivery_failure_behind_the_same_response() { await using var host = await PasswordResetTestHost.StartAsync(new ThrowingEmailSender()); await host.AddUserAsync(Alice, OldPassword); var registered = await host.ForgetPasswordAsync(Alice); var unregistered = await host.ForgetPasswordAsync("nobody@example.com"); Assert.Equal(HttpStatusCode.OK, registered.StatusCode); var body = await registered.Content.ReadAsStringAsync(); Assert.Equal(await unregistered.Content.ReadAsStringAsync(), body); Assert.DoesNotContain("smtp-internal-detail", body); Assert.DoesNotContain(host.Logged.Lines, line => line.Contains(Alice, StringComparison.OrdinalIgnoreCase)); } [Fact] public async Task Stored_code_is_keyed_with_a_server_secret_and_the_plaintext_is_only_in_the_email() { await using var host = await PasswordResetTestHost.StartAsync(); await host.AddUserAsync(Alice, OldPassword); await host.ForgetPasswordAsync(Alice); var code = host.Sent.LatestCodeFor(Alice); var row = Assert.Single(await host.PendingCodesAsync()); Assert.Equal(string.Empty, row.Code); Assert.Matches("^[0-9a-f]{32}$", row.CodeSalt); Assert.Matches("^[0-9a-f]{64}$", row.CodeHash); // Salt and hash from the row alone are not enough to test a guess offline. Assert.NotEqual(Sha256Hex(row.CodeSalt + ":" + code), row.CodeHash); Assert.Equal( PasswordResetCodeSecrets.Hash(PasswordResetCodeSecrets.DeriveKey(PasswordResetTestHost.JwtSecret), row.CodeSalt, code), row.CodeHash); Assert.DoesNotContain(code, string.Join("|", row.Code, row.CodeHash, row.CodeSalt, row.Email, row.UserId)); } [Fact] public async Task Code_expires_fifteen_minutes_after_it_is_issued() { await using var host = await PasswordResetTestHost.StartAsync(); await host.AddUserAsync(Alice, OldPassword); await host.ForgetPasswordAsync(Alice); var code = host.Sent.LatestCodeFor(Alice); host.Time.Advance(TimeSpan.FromMinutes(15) - TimeSpan.FromSeconds(1)); Assert.Equal(HttpStatusCode.OK, (await host.VerifyAsync(Alice, code)).StatusCode); host.Time.Advance(TimeSpan.FromSeconds(1)); Assert.Equal(CodeNotMatched, await (await host.VerifyAsync(Alice, code)).Content.ReadAsStringAsync()); Assert.Equal(ResetFailed, await (await host.ResetAsync(Alice, code, NewPassword)).Content.ReadAsStringAsync()); Assert.Equal(HttpStatusCode.OK, (await host.LoginAsync(Alice, OldPassword)).StatusCode); } [Fact] public async Task Requesting_a_new_code_invalidates_the_previous_one() { await using var host = await PasswordResetTestHost.StartAsync(); await host.AddUserAsync(Alice, OldPassword); await host.ForgetPasswordAsync(Alice); var first = host.Sent.LatestCodeFor(Alice); string second; do { await host.ForgetPasswordAsync(Alice); second = host.Sent.LatestCodeFor(Alice); } while (second == first); Assert.Equal(CodeNotMatched, await (await host.VerifyAsync(Alice, first)).Content.ReadAsStringAsync()); Assert.Single(await host.PendingCodesAsync()); Assert.Equal(HttpStatusCode.OK, (await host.ResetAsync(Alice, second, NewPassword)).StatusCode); } [Fact] public async Task Five_wrong_attempts_invalidate_the_code_until_a_new_one_is_requested() { await using var host = await PasswordResetTestHost.StartAsync(); await host.AddUserAsync(Alice, OldPassword); await host.ForgetPasswordAsync(Alice); var code = host.Sent.LatestCodeFor(Alice); var wrong = WrongCode(code); // Verify and reset share one attempt budget. for (var attempt = 0; attempt < 3; attempt++) Assert.Equal(CodeNotMatched, await (await host.VerifyAsync(Alice, wrong)).Content.ReadAsStringAsync()); for (var attempt = 0; attempt < 2; attempt++) Assert.Equal(ResetFailed, await (await host.ResetAsync(Alice, wrong, NewPassword)).Content.ReadAsStringAsync()); Assert.Equal(CodeNotMatched, await (await host.VerifyAsync(Alice, code)).Content.ReadAsStringAsync()); Assert.Equal(ResetFailed, await (await host.ResetAsync(Alice, code, NewPassword)).Content.ReadAsStringAsync()); Assert.Empty(await host.PendingCodesAsync()); Assert.Equal(HttpStatusCode.OK, (await host.LoginAsync(Alice, OldPassword)).StatusCode); await host.ForgetPasswordAsync(Alice); Assert.Equal(HttpStatusCode.OK, (await host.ResetAsync(Alice, host.Sent.LatestCodeFor(Alice), NewPassword)).StatusCode); } [Fact] public async Task Exhausting_a_code_invalidates_every_pending_code_for_that_email() { await using var host = await PasswordResetTestHost.StartAsync(); var user = await host.AddUserAsync(Alice, OldPassword); await host.ForgetPasswordAsync(Alice); var older = host.Sent.LatestCodeFor(Alice); var newest = await host.AddSiblingCodeAsync(Alice, user.Id); for (var attempt = 0; attempt < 5; attempt++) await host.VerifyAsync(Alice, WrongCode(newest)); Assert.Empty(await host.PendingCodesAsync()); Assert.Equal(ResetFailed, await (await host.ResetAsync(Alice, older, NewPassword)).Content.ReadAsStringAsync()); } [Fact] public async Task A_successful_reset_invalidates_every_pending_code_for_that_email() { await using var host = await PasswordResetTestHost.StartAsync(); var user = await host.AddUserAsync(Alice, OldPassword); await host.ForgetPasswordAsync(Alice); var older = host.Sent.LatestCodeFor(Alice); var newest = await host.AddSiblingCodeAsync(Alice, user.Id); Assert.Equal(HttpStatusCode.OK, (await host.ResetAsync(Alice, newest, NewPassword)).StatusCode); Assert.Empty(await host.PendingCodesAsync()); Assert.Equal(ResetFailed, await (await host.ResetAsync(Alice, older, "Other@24680")).Content.ReadAsStringAsync()); } [Fact] public async Task Four_wrong_attempts_then_the_right_code_still_resets() { await using var host = await PasswordResetTestHost.StartAsync(); await host.AddUserAsync(Alice, OldPassword); await host.ForgetPasswordAsync(Alice); var code = host.Sent.LatestCodeFor(Alice); for (var attempt = 0; attempt < 4; attempt++) Assert.Equal(CodeNotMatched, await (await host.VerifyAsync(Alice, WrongCode(code))).Content.ReadAsStringAsync()); // A correct verify does not spend an attempt, so the reset after it still succeeds. Assert.Equal(HttpStatusCode.OK, (await host.VerifyAsync(Alice, code)).StatusCode); Assert.Equal(HttpStatusCode.OK, (await host.ResetAsync(Alice, code, NewPassword)).StatusCode); } [Fact] public async Task A_rejected_new_password_does_not_spend_an_attempt_or_the_code() { await using var host = await PasswordResetTestHost.StartAsync(); await host.AddUserAsync(Alice, OldPassword); await host.ForgetPasswordAsync(Alice); var code = host.Sent.LatestCodeFor(Alice); for (var attempt = 0; attempt < 6; attempt++) Assert.Equal(ResetFailed, await (await host.ResetAsync(Alice, code, "weakpassword")).Content.ReadAsStringAsync()); Assert.Equal(HttpStatusCode.OK, (await host.ResetAsync(Alice, code, NewPassword)).StatusCode); } [Fact] public async Task A_code_is_checked_only_against_the_email_it_was_issued_to() { await using var host = await PasswordResetTestHost.StartAsync(); await host.AddUserAsync(Alice, OldPassword); await host.AddUserAsync(Bob, OldPassword); await host.ForgetPasswordAsync(Alice); await host.ForgetPasswordAsync(Bob); var aliceCode = host.Sent.LatestCodeFor(Alice); var bobCode = host.Sent.LatestCodeFor(Bob); if (aliceCode != bobCode) { Assert.Equal(CodeNotMatched, await (await host.VerifyAsync(Bob, aliceCode)).Content.ReadAsStringAsync()); Assert.Equal(ResetFailed, await (await host.ResetAsync(Bob, aliceCode, NewPassword)).Content.ReadAsStringAsync()); } // Exhausting Bob's attempts leaves Alice's code and budget untouched. for (var attempt = 0; attempt < 5; attempt++) await host.VerifyAsync(Bob, WrongCode(bobCode)); var alice = Assert.Single(await host.PendingCodesAsync()); Assert.Equal(Alice, alice.Email); Assert.Equal(0, alice.FailedAttempts); Assert.Equal(HttpStatusCode.OK, (await host.ResetAsync(Alice, aliceCode, NewPassword)).StatusCode); Assert.Equal(HttpStatusCode.OK, (await host.LoginAsync(Bob, OldPassword)).StatusCode); } [Fact] public async Task Concurrent_checks_can_never_consume_more_than_five_attempts() { await using var host = await PasswordResetTestHost.StartAsync(); await host.AddUserAsync(Alice, OldPassword); await host.ForgetPasswordAsync(Alice); var row = Assert.Single(await host.PendingCodesAsync()); var now = host.Time.GetUtcNow().UtcDateTime; var results = await Task.WhenAll(Enumerable.Range(0, 20).Select(async _ => { await using var scope = host.Services.CreateAsyncScope(); return await scope.ServiceProvider.GetRequiredService() .TryConsumeAttemptAsync(row.Id, 5, now, CancellationToken.None); })); Assert.Equal(5, results.Count(consumed => consumed)); Assert.Equal(5, Assert.Single(await host.PendingCodesAsync()).FailedAttempts); } [Fact] public async Task VerificationCode_without_an_email_fails_generically_and_never_matches_another_users_code() { await using var host = await PasswordResetTestHost.StartAsync(); await host.AddUserAsync(Alice, OldPassword); await host.ForgetPasswordAsync(Alice); var code = host.Sent.LatestCodeFor(Alice); // The old client contract: code in the query string, no body, no content type. var legacy = await host.Client.SendAsync(PasswordResetTestHost.Post($"api/Authentication/VerificationCode?code={code}")); Assert.Equal(HttpStatusCode.BadRequest, legacy.StatusCode); Assert.Equal(CodeNotMatched, await legacy.Content.ReadAsStringAsync()); Assert.Equal(0, Assert.Single(await host.PendingCodesAsync()).FailedAttempts); } [Fact] public async Task Email_and_code_in_the_query_string_are_ignored() { await using var host = await PasswordResetTestHost.StartAsync(); await host.AddUserAsync(Alice, OldPassword); var queryRequest = await host.Client.SendAsync(PasswordResetTestHost.Post($"api/Authentication/ForgetPassword?Email={Uri.EscapeDataString(Alice)}")); await host.WaitForEmailDrainAsync(); var bodyRequest = await host.ForgetPasswordAsync("nobody@example.com"); Assert.Equal(HttpStatusCode.OK, queryRequest.StatusCode); Assert.Equal(await bodyRequest.Content.ReadAsStringAsync(), await queryRequest.Content.ReadAsStringAsync()); Assert.Empty(host.Sent.Messages); await host.ForgetPasswordAsync(Alice); var code = host.Sent.LatestCodeFor(Alice); var verified = await host.Client.SendAsync(PasswordResetTestHost.Post( $"api/Authentication/VerificationCode?email={Uri.EscapeDataString(Alice)}&code={code}")); Assert.Equal(HttpStatusCode.BadRequest, verified.StatusCode); Assert.Equal(CodeNotMatched, await verified.Content.ReadAsStringAsync()); } [Fact] public async Task Each_endpoint_allows_ten_requests_per_client_per_window_then_answers_429() { await using var host = await PasswordResetTestHost.StartAsync(); const string client = "203.0.113.5"; for (var request = 0; request < PasswordResetRateLimiting.PermitLimit; request++) Assert.Equal(HttpStatusCode.OK, (await host.ForgetPasswordAsync($"n{request}@example.com", client)).StatusCode); var limited = await host.ForgetPasswordAsync("n@example.com", client); Assert.Equal(HttpStatusCode.TooManyRequests, limited.StatusCode); Assert.Equal( "{\"status\":\"Error\",\"message\":\"Too many requests. Please try again later.\"}", await limited.Content.ReadAsStringAsync()); // A left-hand X-Forwarded-For entry written by the caller does not buy a new bucket. Assert.Equal(HttpStatusCode.TooManyRequests, (await host.ForgetPasswordAsync("n@example.com", "198.51.100.1, " + client)).StatusCode); // Another client, and the same client on another endpoint, are unaffected. Assert.Equal(HttpStatusCode.OK, (await host.ForgetPasswordAsync("n@example.com", "203.0.113.6")).StatusCode); Assert.Equal(HttpStatusCode.BadRequest, (await host.VerifyAsync("n@example.com", "000000", client)).StatusCode); } [Theory] [InlineData("api/Authentication/VerificationCode")] [InlineData("api/Authentication/ResetPassword")] public async Task Verify_and_reset_are_rate_limited_per_client(string path) { await using var host = await PasswordResetTestHost.StartAsync(); const string client = "203.0.113.9"; var body = new { email = Alice, code = "000000", password = NewPassword }; for (var request = 0; request < PasswordResetRateLimiting.PermitLimit; request++) Assert.Equal(HttpStatusCode.BadRequest, (await host.Client.SendAsync(PasswordResetTestHost.Post(path, body, client))).StatusCode); Assert.Equal(HttpStatusCode.TooManyRequests, (await host.Client.SendAsync(PasswordResetTestHost.Post(path, body, client))).StatusCode); } [Fact] public async Task No_code_or_email_reaches_the_logs_during_the_whole_flow() { await using var host = await PasswordResetTestHost.StartAsync(); await host.AddUserAsync(Alice, OldPassword); await host.ForgetPasswordAsync(Alice); await host.ForgetPasswordAsync("nobody@example.com"); var code = host.Sent.LatestCodeFor(Alice); await host.VerifyAsync(Alice, WrongCode(code)); await host.VerifyAsync(Alice, code); await host.ResetAsync(Alice, code, NewPassword); Assert.NotEmpty(host.Logged.Lines); Assert.DoesNotContain(host.Logged.Lines, line => line.Contains(code, StringComparison.Ordinal)); Assert.DoesNotContain(host.Logged.Lines, line => line.Contains(WrongCode(code), StringComparison.Ordinal)); Assert.DoesNotContain(host.Logged.Lines, line => line.Contains("@example.com", StringComparison.OrdinalIgnoreCase)); Assert.DoesNotContain(host.Logged.Lines, line => line.Contains(NewPassword, StringComparison.Ordinal)); } [Fact] public void Api_host_registers_forwarded_headers_and_rate_limiting_in_the_right_order() { var program = File.ReadAllText(Path.Combine(RepoRoot(), "Api.SeaHavenIndustries", "Program.cs")); Assert.Contains("builder.Services.AddPasswordResetRateLimiting();", program); Assert.Contains("builder.Services.AddPasswordResetEmailDelivery();", program); var build = program.IndexOf("builder.Build()", StringComparison.Ordinal); var forwarded = program.IndexOf("app.UseForwardedHeaders()", StringComparison.Ordinal); var firstMiddleware = program.IndexOf("app.Use", build, StringComparison.Ordinal); var routing = program.IndexOf("app.UseRouting()", StringComparison.Ordinal); var limiter = program.IndexOf("app.UseRateLimiter()", StringComparison.Ordinal); var controllers = program.IndexOf("app.MapControllers()", StringComparison.Ordinal); Assert.True(forwarded > build && forwarded == firstMiddleware, "UseForwardedHeaders must be the first middleware."); Assert.True(routing < limiter, "UseRateLimiter must run after UseRouting so endpoint policies apply."); Assert.True(limiter < controllers); } private static string WrongCode(string code) => ((int.Parse(code) + 1) % 1_000_000).ToString("D6"); private static string Sha256Hex(string value) => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value))).ToLowerInvariant(); private static string RepoRoot() { var directory = new DirectoryInfo(AppContext.BaseDirectory); while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "Api.SeaHavenIndustries", "Program.cs"))) { directory = directory.Parent; } return directory?.FullName ?? throw new InvalidOperationException("Could not locate the repository root from " + AppContext.BaseDirectory); } private sealed class ThrowingEmailSender : IEmailSender { public Task SendEmailAsync(string emailTo, string subject, string body) => throw new InvalidOperationException("smtp-internal-detail " + emailTo); } }