< Summary - Heimdall API

Information
Class: ArturRios.Heimdall.Data.Seeding.DatabaseSeeder
Assembly: ArturRios.Heimdall.Data
File(s): /home/runner/work/heimdall-api/heimdall-api/src/Infrastructure/ArturRios.Heimdall.Data/Seeding/DatabaseSeeder.cs
Line coverage
78%
Covered lines: 44
Uncovered lines: 12
Coverable lines: 56
Total lines: 124
Line coverage: 78.5%
Branch coverage
65%
Covered branches: 13
Total branches: 20
Branch coverage: 65%
Method coverage
100%
Covered methods: 6
Fully covered methods: 3
Total methods: 6
Method coverage: 100%
Full method coverage: 50%

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
SeedAsync()100%11100%
EnsureSchemaIsUpToDateAsync()50%3244.44%
EnsureRolesAsync()75%9878.57%
EnsureSystemAdminAsync()75%4481.81%
DescriptionOf(...)50%66100%

File(s)

/home/runner/work/heimdall-api/heimdall-api/src/Infrastructure/ArturRios.Heimdall.Data/Seeding/DatabaseSeeder.cs

#LineLine coverage
 1using System.ComponentModel;
 2using System.Reflection;
 3using ArturRios.Heimdall.Data.Configuration;
 4using ArturRios.Heimdall.Domain.Entities;
 5using ArturRios.Heimdall.Domain.Enums;
 6using ArturRios.Util.Hashing;
 7using Microsoft.EntityFrameworkCore;
 8using Microsoft.Extensions.Logging;
 9
 10namespace ArturRios.Heimdall.Data.Seeding;
 11
 12/// <summary>
 13///     Brings a migrated database to the state the application assumes: every <see cref="Roles" />
 14///     member present as a row, and at least one system administrator to sign in as. Idempotent, so
 15///     it runs on every startup. It never applies migrations — that is <c>scripts/migrations.py</c>'s
 16///     job — and refuses to seed a schema that is behind.
 17/// </summary>
 90118public class DatabaseSeeder(
 90119    AppDbContext context,
 90120    MasterUserOptions masterUser,
 90121    ILogger<DatabaseSeeder> logger)
 22{
 23    public async Task SeedAsync(CancellationToken cancellationToken = default)
 24    {
 90125        await EnsureSchemaIsUpToDateAsync(cancellationToken);
 90126        await EnsureRolesAsync(cancellationToken);
 90127        await EnsureSystemAdminAsync(cancellationToken);
 90128    }
 29
 30    private async Task EnsureSchemaIsUpToDateAsync(CancellationToken cancellationToken)
 31    {
 90132        var pending = (await context.Database.GetPendingMigrationsAsync(cancellationToken)).ToList();
 33
 90134        if (pending.Count == 0)
 35        {
 90136            return;
 37        }
 38
 039        var names = string.Join(", ", pending);
 40
 041        logger.LogCritical("Database is behind by {Count} migration(s): {Migrations}", pending.Count, names);
 42
 043        throw new InvalidOperationException(
 044            $"The database is missing {pending.Count} migration(s): {names}. Apply them with " +
 045            "scripts/migrations.py before starting the API.");
 90146    }
 47
 48    private async Task EnsureRolesAsync(CancellationToken cancellationToken)
 49    {
 360150        var stored = await context.Roles.ToDictionaryAsync(x => x.Id, cancellationToken);
 51
 720852        foreach (var role in Enum.GetValues<Roles>())
 53        {
 270354            var id = (long)role;
 270355            var name = role.ToString();
 270356            var description = DescriptionOf(role);
 57
 270358            if (!stored.TryGetValue(id, out var existing))
 59            {
 360                context.Roles.Add(new Role { Id = id, Name = name, Description = description });
 61
 362                logger.LogInformation("Seeding role {RoleName} with id {RoleId}", name, id);
 63
 364                continue;
 65            }
 66
 270067            if (existing.Name == name && existing.Description == description)
 68            {
 69                continue;
 70            }
 71
 072            existing.Name = name;
 073            existing.Description = description;
 74
 075            logger.LogInformation("Realigning role {RoleId} with the Roles enum", id);
 76        }
 77
 90178        await context.SaveChangesAsync(cancellationToken);
 90179    }
 80
 81    private async Task EnsureSystemAdminAsync(CancellationToken cancellationToken)
 82    {
 83        const long systemAdminRoleId = (long)Roles.SystemAdmin;
 84
 90185        var exists = await context.Persons
 90186            .AnyAsync(x => x.RoleId == systemAdminRoleId && !x.IsDeleted, cancellationToken);
 87
 90188        if (exists)
 89        {
 90090            return;
 91        }
 92
 193        if (!masterUser.IsComplete)
 94        {
 095            throw new InvalidOperationException(
 096                "The database has no system administrator and the master user is not configured. Set " +
 097                $"{MasterUserOptions.NameVariable}, {MasterUserOptions.EmailVariable} and " +
 098                $"{MasterUserOptions.PasswordVariable} before starting the API.");
 99        }
 100
 1101        var passwordHash = Hash.EncodeWithRandomSalt(masterUser.Password, out var salt);
 102
 1103        context.Persons.Add(new Person
 1104        {
 1105            Name = masterUser.Name,
 1106            Email = masterUser.Email,
 1107            PasswordHash = passwordHash,
 1108            Salt = salt,
 1109            EmailVerified = true,
 1110            RoleId = systemAdminRoleId
 1111        });
 112
 1113        await context.SaveChangesAsync(cancellationToken);
 114
 115        // The address is deliberately not logged (NFR-22). It came from the environment the
 116        // operator set, so the line tells them nothing they do not already know, and it would
 117        // otherwise be written on every start-up for the life of the deployment.
 1118        logger.LogInformation("Seeded the master system administrator");
 901119    }
 120
 121    private static string DescriptionOf(Roles role) =>
 2703122        typeof(Roles).GetField(role.ToString())?.GetCustomAttribute<DescriptionAttribute>()?.Description
 2703123        ?? role.ToString();
 124}