Skip to content

refactor: convention cleanup, unify AddToRole/RemoveFromRole error contract - #144

Merged
andregoepel merged 3 commits into
mainfrom
feature/convention-cleanup
Jul 25, 2026
Merged

refactor: convention cleanup, unify AddToRole/RemoveFromRole error contract#144
andregoepel merged 3 commits into
mainfrom
feature/convention-cleanup

Conversation

@andregoepel

Copy link
Copy Markdown
Owner

Summary

Quick-fix cleanup pass from the convention audit (PR A of two — PR B, a
separate later effort, will normalize the test suite). Three commits:

  1. Convention cleanup — deleted narrative/decorative/dead/non-English
    comments across src/ and compressed genuine one-line "why" rationale
    (a changelog-style comment in Register.razor, dead commented-out
    navigation in Email.razor, German comments/HTML-comment labels in
    several Manage pages, a duplicated 4-line comment repeated verbatim
    across 5 Blazor pages, two duplicated blocks in UserProjection.cs).
    Added sealed to RoleProjection, UserRoleAssignmentProjection,
    SetupRedirectMiddleware, CookieLoginMiddleware; narrowed
    CurrentUserService to internal sealed after confirming no consumer
    outside the assembly references the concrete type. Converted
    UserRoleAssignment to a sealed record. Blazor: added
    <AppPageTitle> to AccessDenied.razor, removed StateHasChanged()
    calls that are redundant given Blazor's automatic post-handler render,
    and replaced a throw-then-immediately-catch in Disable2fa.razor with
    direct branching.

  2. Unify the AddToRole/RemoveFromRole error contract (the one
    behavior-affecting change to the public API)
    UserStore.AddToRoleAsync
    and RemoveFromRoleAsync used to throw IdentityAuthorizationException
    on an authorization-denied condition, while every sibling store method
    (CreateAsync, DeleteAsync, RestoreAsync) returns
    IdentityResult.Failed for the equivalent condition.

    UserStore<TUser> now exposes public Task<IdentityResult>-returning
    AddToRoleAsync/RemoveFromRoleAsync overloads matching that pattern.
    This could not be done on the IUserRoleStore<TUser> interface members
    themselves — verified empirically that the framework interface only
    permits a plain Task return with no IdentityResult channel back
    through UserManager — so the explicit IUserRoleStore<TUser>
    implementation adapts a Failed result back into a throw, but only for
    hosts that drive role assignment through UserManager directly.
    IdentityAuthorizationException's doc comment now describes this
    narrower, adapter-only scope.

    Call sites updated:

    • UserInvitationService.InviteAsync — now loops over
      UserStore.AddToRoleAsync and checks .Succeeded instead of
      catching IdentityAuthorizationException around
      UserManager.AddToRolesAsync.
    • RoleUserDialog.razor (admin role-removal UI) — now calls
      UserStore.RemoveFromRoleAsync directly instead of
      UserManager.RemoveFromRoleAsync, so an authorization failure
      surfaces as the existing DialogService.Alert instead of an
      unhandled exception.
    • UserRoleDialog.razor (admin role-assignment UI) — now calls
      UserStore.AddToRoleAsync/RemoveFromRoleAsync directly instead of
      UserManager.AddToRolesAsync/RemoveFromRolesAsync, and now
      surfaces a failure via DialogService.Alert (new
      UserRoleDialog.ErrorTitle/RolesCouldNotBeSaved resource strings,
      en+de) — this dialog previously had no error handling at all for
      this operation.
    • UserStoreAuthorizationTests — the three tests that asserted
      Assert.ThrowsAsync<IdentityAuthorizationException> on
      AddToRoleAsync/RemoveFromRoleAsync now assert on the returned
      IdentityResult instead (AddToRoleAsync_NonAdminActor_Throws
      ..._ReturnsNotAuthorized, etc.) — required by the contract change,
      scoped to only what item 6 needed.

    Also replaced the bare // IUserXxxStore section-header comments in
    UserStore.cs with #region/#endregion, matching the pattern
    already used in UserProjection.cs, and fixed the untyped
    throw new Exception("User not found") in the passkey read path to
    InvalidOperationException, matching the existing "role not found"
    pattern in the same file.

  3. Cancellation fixCleanupScheduleStartupService.StartAsync fired
    ApplyStoredScheduleAsync without awaiting it and without threading a
    CancellationToken, despite doing DB/scheduler I/O and the host
    already exposing IHostApplicationLifetime.ApplicationStopping.
    Threaded ApplicationStopping through, and wrapped the fire-and-forget
    invocation so an unobserved exception can no longer crash the process
    (existing log-and-swallow behavior preserved).

Notable judgment calls

  • User.Passkeys HashSet TODO: left as Dictionary<string, UserPasskey>
    and just deleted the stale comment. The dictionary is keyed by
    credential id and queried via LINQ-to-Marten
    (x.Passkeys.Keys.Contains(...)); switching collection types would
    change serialization shape and query semantics against existing
    production data — not a safe, trivial change. Flagging as a possible
    future follow-up.
  • PasskeyInputModel: evaluated for conversion to a record but kept
    as sealed class. No live usage was found anywhere in the codebase
    (looks like leftover/unused code), but its shape (two mutable string?
    properties, one of them an Error field meant to be set after the
    fact) matches this repo's documented "form models are mutable classes,
    not records" convention rather than an immutable DTO, so it was judged
    safer to keep as a class.

Test plan

  • dotnet csharpier format . — clean, no outstanding diffs
  • dotnet build — 0 errors, 91 pre-existing warnings (all in test
    files untouched by this PR — CS8625/xUnit1051), no new warnings
  • dotnet test --filter "FullyQualifiedName!~E2ETests" — 413/413
    passed (191 unit + 130 Blazor + 92 integration; Docker was
    available so the Postgres-backed integration tests ran too)
  • dotnet list package --vulnerable --include-transitive — no
    vulnerable packages
  • Targeted re-run of UserStoreAuthorizationTests +
    UserInvitationServiceTests (16 tests) — all passed

Closes #143

🤖 Generated with Claude Code

…Blazor UI

- Delete narrative/decorative/dead/non-English comments across src/ and
  compress genuine one-line "why" rationale (Register.razor changelog
  comment, dead commented-out navigation in Email.razor, German comments in
  DeletePersonalData.razor/ChangePassword.razor, decorative HTML-comment
  section labels, duplicated multi-line blocks in UserProjection.cs).
- Add `sealed` to RoleProjection, UserRoleAssignmentProjection,
  SetupRedirectMiddleware, CookieLoginMiddleware; narrow CurrentUserService
  to `internal sealed` (no external consumers of the concrete type,
  confirmed via a solution-wide search).
- Convert UserRoleAssignment to a `sealed record` (plain, never-mutated
  data holder); add `sealed` to PasskeyInputModel (kept as a class per the
  form-model convention — evaluated for record conversion but its shape
  matches a mutable bind-target, not an immutable DTO).
- Delete the stale "Todo: Use Hashset" comment on User.Passkeys — the
  dictionary is keyed by credential id and queried via LINQ-to-Marten, so
  switching collection types is not a safe, trivial change; flagged as a
  possible future follow-up instead.
- Blazor: add <AppPageTitle> to AccessDenied.razor; remove StateHasChanged()
  calls that are redundant given Blazor's automatic post-handler render
  (kept the one call preceded by an earlier await, e.g. after a confirm
  dialog, since the framework's single pre-first-await auto-render was
  already spent there); replace a throw-then-immediately-catch in
  Disable2fa.razor with direct branching.
…ore members with #region

UserStore.AddToRoleAsync/RemoveFromRoleAsync used to throw
IdentityAuthorizationException on an authorization-denied condition while
every sibling store method (CreateAsync, DeleteAsync, RestoreAsync) returns
IdentityResult.Failed for the equivalent condition. This is a real, public
API behavior change:

- UserStore<TUser> gains public Task<IdentityResult>-returning
  AddToRoleAsync/RemoveFromRoleAsync overloads (matching the
  Create/Delete/Restore pattern) that our own call sites use directly.
- IUserRoleStore<TUser>'s AddToRoleAsync/RemoveFromRoleAsync only permit a
  plain Task return per the framework interface (verified empirically —
  there is no IdentityResult channel through UserManager for this
  operation), so the explicit interface implementation adapts a Failed
  result back into a throw only for hosts driving role assignment through
  UserManager. IdentityAuthorizationException's doc comment is updated to
  describe this narrower, adapter-only scope.
- UserInvitationService.InviteAsync now loops over UserStore.AddToRoleAsync
  and checks .Succeeded instead of catching IdentityAuthorizationException
  around UserManager.AddToRolesAsync.
- RoleUserDialog.razor and UserRoleDialog.razor (Administration role
  assignment UI) now call UserStore.AddToRoleAsync/RemoveFromRoleAsync
  directly instead of the UserManager equivalents, so an authorization
  failure surfaces as a DialogService.Alert instead of an unhandled
  exception; UserRoleDialog gained the error-surfacing it was missing
  entirely for AddToRolesAsync/RemoveFromRolesAsync (new
  UserRoleDialog.ErrorTitle/RolesCouldNotBeSaved resource strings, en+de).
- UserStoreAuthorizationTests: the three tests asserting
  Assert.ThrowsAsync<IdentityAuthorizationException> on
  AddToRoleAsync/RemoveFromRoleAsync now assert on the returned
  IdentityResult instead, per the new contract.

Also replaces the bare "// IUserXxxStore" section-header comments in
UserStore.cs with #region/#endregion, matching the pattern already used in
UserProjection.cs, and fixes the untyped `throw new Exception("User not
found")` in the passkey read path to `InvalidOperationException`, matching
the "role not found" pattern already used in the same file.
…nd-forget

CleanupScheduleStartupService.StartAsync fired ApplyStoredScheduleAsync
without awaiting it and without threading any CancellationToken, even
though the method does DB and scheduler I/O and the host already exposes
IHostApplicationLifetime.ApplicationStopping. Capture ApplicationStopping
and pass it through to the DB load and scheduler calls, and wrap the
fire-and-forget invocation in a new RunApplyStoredScheduleAsync that keeps
the existing try/catch + log-and-swallow behavior, so an unobserved
exception from the discarded Task can no longer crash the process.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Quick-fix convention cleanup + error-contract unification + cancellation fixes

1 participant