< Summary - Heimdall API

Information
Class: ArturRios.Heimdall.Data.EntityMaps.PersonDbMap
Assembly: ArturRios.Heimdall.Data
File(s): /home/runner/work/heimdall-api/heimdall-api/src/Infrastructure/ArturRios.Heimdall.Data/EntityMaps/PersonDbMap.cs
Line coverage
100%
Covered lines: 49
Uncovered lines: 0
Coverable lines: 49
Total lines: 127
Line coverage: 100%
Branch coverage
N/A
Covered branches: 0
Total branches: 0
Branch coverage: N/A
Method coverage
100%
Covered methods: 2
Fully covered methods: 2
Total methods: 2
Method coverage: 100%
Full method coverage: 100%

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
Configure(...)100%11100%
IgnoredAfterInsert(...)100%11100%

File(s)

/home/runner/work/heimdall-api/heimdall-api/src/Infrastructure/ArturRios.Heimdall.Data/EntityMaps/PersonDbMap.cs

#LineLine coverage
 1using ArturRios.Heimdall.Domain.Entities;
 2using Microsoft.EntityFrameworkCore;
 3using Microsoft.EntityFrameworkCore.Metadata;
 4using Microsoft.EntityFrameworkCore.Metadata.Builders;
 5
 6namespace ArturRios.Heimdall.Data.EntityMaps;
 7
 8internal static class PersonDbMap
 9{
 10    public static void Configure(this EntityTypeBuilder<Person> person)
 11    {
 312        person.ToTable("person");
 13
 314        person.HasKey(x => x.Id);
 15
 316        person.Property(x => x.PublicId).IsRequired();
 317        person.HasIndex(x => x.PublicId).IsUnique();
 18
 319        person.Property(x => x.Name).IsRequired();
 20
 321        person.Property(x => x.Email).IsRequired();
 22
 23        // Email uniqueness is conditional (FR-PE-09), and its two halves differ in whether a unique
 24        // index can express them at all.
 25        //
 26        // The admin half — unique system-wide across live ScopeAdmins and SystemAdmins — reads only
 27        // columns of this table, so it is a partial unique index over LOWER(email), created in raw
 28        // SQL by the AddPersonEmailUniqueness migration (EF models neither an expression index nor
 29        // one filtered on a role set). It is the real enforcement: the application's check-then-
 30        // insert cannot be, since two concurrent creates both read "free" before either writes.
 31        //
 32        // The User half — unique per scope — is now a partial unique index too, created by the
 33        // AddPersonScopeId migration over (scope_id, LOWER(email)) WHERE role_id = 3 AND
 34        // is_deleted = false. It reads the scope from Person.ScopeId, the copy of SCOPE_USER's
 35        // ScopeId that exists for exactly this purpose: the relationship itself is still SCOPE_USER,
 36        // but an index has to be written over one table's columns, and every other column the rule
 37        // needs already lives here. The condition matches the application's check term for term, so
 38        // the rule did not change — only who is able to enforce it under concurrency.
 39        //
 40        // This non-unique index remains for lookup speed: UC-11 and UC-12 both resolve a person by
 41        // address on every call.
 342        person.HasIndex(x => x.Email);
 43
 344        person.Property(x => x.PasswordHash).IsRequired();
 345        person.Property(x => x.Salt).IsRequired();
 46
 347        person.Property(x => x.IsDeleted).HasDefaultValue(false);
 48
 49        // NFR-20's retention window is measured from DeletedAt, so the anonymisation pass reads it
 50        // on every run: indexed, and filtered to the rows that can possibly be due, which is a small
 51        // fraction of the table.
 352        person.Property(x => x.DeletedAt);
 353        person.Property(x => x.DeletionKind);
 354        person.Property(x => x.AnonymisedAt);
 55
 56        // NFR-23: the basis is required and defaults to Unrecorded, so a row can never be silent
 57        // about it — a null would be indistinguishable from "nobody has looked at this yet".
 358        person.Property(x => x.LegalBasis).HasDefaultValue(0);
 359        person.Property(x => x.PrivacyNoticeVersion);
 360        person.Property(x => x.BasisRecordedAt);
 61
 62        // NFR-24: indexed because every listing filters on it, and the restricted set is a small
 63        // fraction of the table.
 364        person.Property(x => x.ProcessingRestrictedAt);
 365        person.Property(x => x.RestrictionGround);
 366        person.Property(x => x.RestrictionLiftNotifiedAt);
 67
 368        person.HasIndex(x => x.ProcessingRestrictedAt)
 369            .HasFilter("processing_restricted_at IS NOT NULL");
 70
 371        person.HasIndex(x => x.DeletedAt)
 372            .HasFilter("is_deleted = true AND anonymised_at IS NULL");
 373        person.Property(x => x.EmailVerified).HasDefaultValue(false);
 74
 75        // FR-AU-11: the database fills the stamp for a row inserted without one, as the migration
 76        // filled the rows that predate it. After insert a whole-row save ignores it: only the
 77        // targeted write in AtomicWrites replaces it, so a save of a copy read before a rotation
 78        // cannot write the revoked stamp back.
 379        person.Property(x => x.SecurityStamp)
 380            .IsRequired()
 381            .HasDefaultValueSql("gen_random_uuid()")
 382            .Metadata.SetAfterSaveBehavior(PropertySaveBehavior.Ignore);
 83
 84        // FR-AU-12 and FR-AU-13: the two per-account factor budgets. Like the stamp, written only by
 85        // the targeted writes in AtomicWrites. A whole-row save of a copy read before a failure was
 86        // counted — a name change, say, sent alongside a guess — would otherwise put the count, the
 87        // lock or the pause back as that copy read it.
 388        IgnoredAfterInsert(person.Property(x => x.FailedSecondFactorAttempts));
 389        IgnoredAfterInsert(person.Property(x => x.SecondFactorLockedOutUntil));
 390        IgnoredAfterInsert(person.Property(x => x.SecondFactorLockouts));
 391        IgnoredAfterInsert(person.Property(x => x.FailedReauthenticationAttempts));
 392        IgnoredAfterInsert(person.Property(x => x.ReauthenticationWindowStartedAt));
 393        IgnoredAfterInsert(person.Property(x => x.ReauthenticationPausedUntil));
 94
 95        // FR-PR-03 and FR-PR-08: the password hash and salt, for the reason the stamp is. Set at
 96        // insert; after that only the targeted writes in AtomicWrites change them — a reset and
 97        // anonymisation — so a whole-row save of a copy read before a reset, an administrator's
 98        // edit racing the person's own reset for one, cannot put the old password back.
 399        IgnoredAfterInsert(person.Property(x => x.PasswordHash));
 3100        IgnoredAfterInsert(person.Property(x => x.Salt));
 101
 102        // FR-PE-16: the account-email budget, for the same reason — a whole-row save of a copy read
 103        // before a send was reserved would hand the day's allowance back.
 3104        IgnoredAfterInsert(person.Property(x => x.AccountEmailsSent));
 3105        IgnoredAfterInsert(person.Property(x => x.AccountEmailWindowEndsAt));
 3106        IgnoredAfterInsert(person.Property(x => x.AccountEmailLastSentAt));
 107
 108        // Deliberately not a foreign key and not a navigation. It carries no relationship of its own
 109        // — SCOPE_USER is the relationship, and mapping this as a second one would give EF two ways
 110        // to describe the same association and two chances to disagree. It is a value the per-scope
 111        // uniqueness index is written over, kept in step by the handlers that write the membership.
 3112        person.Property(x => x.ScopeId);
 113
 3114        person.Property(x => x.CreatedAt).HasDefaultValueSql("now()");
 3115        person.Property(x => x.UpdatedAt).HasDefaultValueSql("now()");
 116
 117        // Role classifies Person (FR-RO-01). Roles are reference data, so deletion is restricted.
 3118        person.HasOne(x => x.Role)
 3119            .WithMany(r => r.Persons)
 3120            .HasForeignKey(x => x.RoleId)
 3121            .IsRequired()
 3122            .OnDelete(DeleteBehavior.Restrict);
 3123    }
 124
 125    private static void IgnoredAfterInsert<T>(PropertyBuilder<T> property) =>
 33126        property.Metadata.SetAfterSaveBehavior(PropertySaveBehavior.Ignore);
 127}