< Summary - Heimdall API

Information
Class: ArturRios.Heimdall.Command.Services.AdminEmailOptions
Assembly: ArturRios.Heimdall.Command
File(s): /home/runner/work/heimdall-api/heimdall-api/src/Application/ArturRios.Heimdall.Command/Services/AdminEmailOptions.cs
Line coverage
100%
Covered lines: 17
Uncovered lines: 0
Coverable lines: 17
Total lines: 98
Line coverage: 100%
Branch coverage
100%
Covered branches: 10
Total branches: 10
Branch coverage: 100%
Method coverage
100%
Covered methods: 7
Fully covered methods: 7
Total methods: 7
Method coverage: 100%
Full method coverage: 100%

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
get_Cooldown()100%11100%
get_RejectedCooldown()100%11100%
CappedAt(...)100%22100%
FromEnvironment()100%11100%
From(...)100%88100%
CooldownThreshold(...)100%11100%
IsRunningAsync()100%11100%

File(s)

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

#LineLine coverage
 1using System.Globalization;
 2using ArturRios.Data.Relational.Core.Entities;
 3using ArturRios.Data.Relational.Core.Interfaces;
 4using ArturRios.Heimdall.Domain.Entities;
 5using Microsoft.EntityFrameworkCore;
 6
 7namespace ArturRios.Heimdall.Command.Services;
 8
 9/// <summary>
 10///     The per-person cooldown on account email (FR-EV-07, FR-PR-06, FR-PR-07, FR-PE-16): at most one
 11///     email of each kind per person per <see cref="Cooldown" />, whoever asks. Read from
 12///     <c>HEIMDALL_ADMIN_EMAIL_COOLDOWN_IN_SECONDS</c>; five minutes by default.
 13/// </summary>
 14/// <remarks>
 15///     <para>
 16///         The cooldown is derived, not stored. A token's issue time is its <c>ExpiresAt</c> minus its
 17///         kind's lifetime, so "an email of this kind went to this person within the window" is a
 18///         comparison on token rows that already exist — self-service sends (UC-12, UC-15) included,
 19///         and whether or not the token has since been used. Changing a token lifetime shifts the
 20///         derived times of tokens already issued; on a five-minute window that is accepted.
 21///     </para>
 22///     <para>
 23///         It began as the administrators' cooldown (UC-51, UC-52) and keeps that variable name, so no
 24///         deployment has to change its configuration. Anonymous password recovery (UC-12) asks it the
 25///         same way. A person's own sends — a verification link again, a setup code, an email-change
 26///         notice — use its <see cref="Cooldown" /> too, but through <see cref="AccountEmailBudget" />,
 27///         a conditional write on the person row, because a bearer token can send a burst of them.
 28///     </para>
 29///     <para>
 30///         Values from 0 to 86400 seconds are accepted, and 0 switches the cooldown off. Anything
 31///         else — unparsable, negative, larger — falls back to the default rather than to no cooldown
 32///         at all, because a typo should not remove a protection.
 33///     </para>
 34/// </remarks>
 35public class AdminEmailOptions
 36{
 37    public const string CooldownVariable = "HEIMDALL_ADMIN_EMAIL_COOLDOWN_IN_SECONDS";
 38
 39    /// <summary>The cooldown in seconds applied when none, or an unusable one, is configured.</summary>
 40    public const double DefaultCooldownSeconds = 300;
 41
 42    private const double MaxCooldownSeconds = 86400;
 43
 44    /// <summary>The window within which a second email of one kind to one person is refused.</summary>
 242645    public TimeSpan Cooldown { get; init; } = TimeSpan.FromSeconds(DefaultCooldownSeconds);
 46
 47    /// <summary>
 48    ///     The configured text when it was present but unusable and the default was applied instead;
 49    ///     <c>null</c> when the variable was absent or usable. Start-up warns on it, so a typo does not
 50    ///     pass for the value the operator meant.
 51    /// </summary>
 183152    public string? RejectedCooldown { get; init; }
 53
 54    /// <summary>These options with the cooldown at most <paramref name="limit" />.</summary>
 55    public AdminEmailOptions CappedAt(TimeSpan limit) =>
 3456        Cooldown <= limit ? this : new AdminEmailOptions { Cooldown = limit, RejectedCooldown = RejectedCooldown };
 57
 89958    public static AdminEmailOptions FromEnvironment() => From(Environment.GetEnvironmentVariable(CooldownVariable));
 59
 60    /// <summary>Reads a configured number of seconds, falling back to the default when it is unusable.</summary>
 61    public static AdminEmailOptions From(string? configured)
 62    {
 91963        var usable =
 91964            double.TryParse(configured, NumberStyles.Float, CultureInfo.InvariantCulture, out var value)
 91965            && value is >= 0 and <= MaxCooldownSeconds;
 66
 91967        return new AdminEmailOptions
 91968        {
 91969            Cooldown = TimeSpan.FromSeconds(usable ? value : DefaultCooldownSeconds),
 91970            RejectedCooldown = usable || string.IsNullOrWhiteSpace(configured) ? null : configured
 91971        };
 72    }
 73
 74    /// <summary>
 75    ///     For a token kind living <paramref name="tokenLifetime" />: a token of that kind was issued
 76    ///     inside the cooldown window if and only if its <c>ExpiresAt</c> is later than this.
 77    /// </summary>
 5878    public DateTime CooldownThreshold(DateTime now, TimeSpan tokenLifetime) => now + tokenLifetime - Cooldown;
 79
 80    /// <summary>
 81    ///     Whether any token of this kind (used, superseded or live, self-service or not) was issued for
 82    ///     the person with internal id <paramref name="personId" /> inside the cooldown window, so that
 83    ///     UC-51 and UC-52 ask the question the same way (AF-51e, AF-52e).
 84    /// </summary>
 85    public async Task<bool> IsRunningAsync<TToken>(
 86        IAsyncReadOnlyRepository<TToken, long> tokenReader,
 87        long personId,
 88        TimeSpan tokenLifetime,
 89        DateTime now,
 90        CancellationToken cancellationToken = default)
 91        where TToken : Entity<long>, IPersonToken
 92    {
 5793        var threshold = CooldownThreshold(now, tokenLifetime);
 94
 5795        return await tokenReader.Query()
 5796            .AnyAsync(x => x.PersonId == personId && x.ExpiresAt > threshold, cancellationToken);
 5797    }
 98}