mirror of
https://github.com/Sea-Haven-Industries/shoc-backend.git
synced 2026-09-30 03:43:11 +00:00
- Forgot Password is limited to 3 codes an hour and 10 a day per email, and an account gets 10 failed code checks a day across every code it is sent, so new client addresses and new codes no longer buy more guesses. Refused requests answer exactly like accepted ones. - The reset email is queued to a background sender, and unregistered addresses store a row no code can match, so both paths do the same work and return without waiting on the mail provider. Each request also clears expired codes. - Code hashes are HMAC-SHA256 under a key derived with HKDF from the JWT signing secret; rows in the previous unkeyed format stop matching. - Email and code are read only from the JSON body.
418 lines
20 KiB
C#
418 lines
20 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// The anonymous Forgot Password flow over HTTP, against the real controller,
|
|
/// services, Identity and a relational database.
|
|
/// </summary>
|
|
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<IForgetPasswordDataService>()
|
|
.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<bool> SendEmailAsync(string emailTo, string subject, string body) =>
|
|
throw new InvalidOperationException("smtp-internal-detail " + emailTo);
|
|
}
|
|
}
|