< Summary - Heimdall API

Information
Class: ArturRios.Heimdall.Command.Services.AccountEmailBudget
Assembly: ArturRios.Heimdall.Command
File(s): /home/runner/work/heimdall-api/heimdall-api/src/Application/ArturRios.Heimdall.Command/Services/AccountEmailBudget.cs
Line coverage
100%
Covered lines: 25
Uncovered lines: 0
Coverable lines: 25
Total lines: 112
Line coverage: 100%
Branch coverage
84%
Covered branches: 27
Total branches: 32
Branch coverage: 84.3%
Method coverage
100%
Covered methods: 5
Fully covered methods: 5
Total methods: 5
Method coverage: 100%
Full method coverage: 100%

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.cctor()100%11100%
TryReserveAsync(...)100%11100%
Peek(...)100%1010100%
RefusalOf(...)85.71%1414100%
Later(...)62.5%88100%

File(s)

/home/runner/work/heimdall-api/heimdall-api/src/Application/ArturRios.Heimdall.Command/Services/AccountEmailBudget.cs

#LineLine coverage
 1using ArturRios.Heimdall.Domain.Entities;
 2using ArturRios.Heimdall.Domain.Persistence;
 3
 4namespace ArturRios.Heimdall.Command.Services;
 5
 6/// <summary>
 7///     FR-PE-16: the per-person budget on the account emails a person's own requests can have sent —
 8///     a verification link asked for again (UC-15), a two-factor setup code (UC-36, email method) and
 9///     the notice an own email change sends to the previous address (UC-08, FR-PE-15). At most one per
 10///     <see cref="AdminEmailOptions.Cooldown" />, and at most <see cref="MaxPerDay" /> in each day-long window, which
 11///     starts at its first send.
 12/// </summary>
 13/// <remarks>
 14///     <para>
 15///         <b>Why it exists.</b> Each of these can be repeated with nothing but a bearer token (and, for
 16///         the email change, the account's password), and the address they go to is one the account
 17///         holder chose without proving it is theirs. A cooldown alone still let one account send an
 18///         address hundreds of mails a day, and an email change switched back and forth mails the
 19///         address it leaves every time. One budget across the three bounds the account, not each
 20///         endpoint.
 21///     </para>
 22///     <para>
 23///         <b>Atomic.</b> The send is reserved by one conditional write before anything is issued or
 24///         mailed, so a burst of simultaneous requests gets exactly what the budget has left — a
 25///         check that read the token rows first let every request of a burst through.
 26///     </para>
 27///     <para>
 28///         The window is fixed from its first send, so "a day" is a day-long window, not any 24 hours:
 29///         five just before a window ends and five just after it are ten within a day. A refusal says
 30///         when to come back through <see cref="IRetryAfterAdvisor" />. A send reserved and then not
 31///         made, because a later step failed, is not given back: the cost is one slot, and it fails
 32///         closed.
 33///     </para>
 34///     <para>
 35///         Administrator-initiated mail (UC-51, UC-52) is not charged here: it keeps
 36///         <see cref="AdminEmailOptions" />' own per-kind cooldown, and an administrator's mail must
 37///         stay possible when a person has spent their own day.
 38///     </para>
 39/// </remarks>
 40public static class AccountEmailBudget
 41{
 42    /// <summary>The self-service account emails one person may have sent in one <see cref="Window" />.</summary>
 43    public const int MaxPerDay = 5;
 44
 45    /// <summary>How long a window lasts, from the first send in it.</summary>
 246    public static readonly TimeSpan Window = TimeSpan.FromDays(1);
 47
 48    /// <summary>
 49    ///     Reserves one send for <paramref name="person" />. Anything but
 50    ///     <see cref="AccountEmailReservation.Reserved" /> means nothing may be sent.
 51    /// </summary>
 52    public static Task<AccountEmailReservation> TryReserveAsync(
 53        IAtomicWrites atomicWrites, Person person, AdminEmailOptions options)
 54    {
 8555        var now = DateTime.UtcNow;
 56
 8557        return atomicWrites.TryReserveAccountEmailAsync(person, MaxPerDay, now, now - options.Cooldown, now + Window);
 58    }
 59
 60    /// <summary>
 61    ///     What a reservation would answer, read from the instance the caller holds, without charging
 62    ///     anything — so a request that could not send is refused before it pays for a password check.
 63    ///     Advisory only: the reservation is what decides.
 64    /// </summary>
 65    public static AccountEmailReservation Peek(Person person, AdminEmailOptions options)
 66    {
 2067        var now = DateTime.UtcNow;
 68
 2069        if (person.AccountEmailWindowEndsAt > now && person.AccountEmailsSent >= MaxPerDay)
 70        {
 271            return AccountEmailReservation.DailyLimitReached;
 72        }
 73
 1874        return person.AccountEmailLastSentAt > now - options.Cooldown
 1875            ? AccountEmailReservation.CoolingDown
 1876            : AccountEmailReservation.Reserved;
 77    }
 78
 79    /// <summary>
 80    ///     The refusal a reservation answers with, or <c>null</c> when it was reserved. A refusal also
 81    ///     advises how long to wait: until the cooldown ends for the cooldown, and for the daily limit
 82    ///     until the window ends or the cooldown does, whichever is later, since a send is accepted
 83    ///     only once both have — read from <paramref name="person" />, which a refused reservation has
 84    ///     brought in line with the stored row.
 85    /// </summary>
 86    public static string? RefusalOf(
 87        AccountEmailReservation reservation, Person person, AdminEmailOptions options, IRetryAfterAdvisor retryAfter)
 88    {
 10989        var retryAt = reservation switch
 10990        {
 1491            AccountEmailReservation.DailyLimitReached => Later(
 1492                person.AccountEmailWindowEndsAt, person.AccountEmailLastSentAt + options.Cooldown),
 2893            AccountEmailReservation.CoolingDown => person.AccountEmailLastSentAt + options.Cooldown,
 6794            _ => null
 10995        };
 96
 10997        if (retryAt is { } at)
 98        {
 4299            retryAfter.Advise(at - DateTime.UtcNow);
 100        }
 101
 109102        return reservation switch
 109103        {
 28104            AccountEmailReservation.CoolingDown => Shared.Messages.AuthMessages.EmailSentRecently,
 14105            AccountEmailReservation.DailyLimitReached => Shared.Messages.AuthMessages.AccountEmailLimitReached,
 67106            _ => null
 109107        };
 108    }
 109
 110    private static DateTime? Later(DateTime? first, DateTime? second) =>
 14111        first is null ? second : second is null ? first : first > second ? first : second;
 112}