Skip to content

fix: guard TeamServiceBase against null current user#63

Merged
poxet merged 3 commits into
masterfrom
feature/unauthenticated-team-service-guards
May 10, 2026
Merged

fix: guard TeamServiceBase against null current user#63
poxet merged 3 commits into
masterfrom
feature/unauthenticated-team-service-guards

Conversation

@poxet
Copy link
Copy Markdown
Contributor

@poxet poxet commented May 10, 2026

Summary

Loading a <TeamComponent /> page before authentication (or after session expiry) crashed the Blazor circuit with NullReferenceException. TeamServiceBase.GetTeamsAsync<TMember>() took the result of GetCurrentUserAsync() and passed it straight to the MongoDB GetTeamsAsync(IUser) override, which dereferenced user.Key. The same unguarded GetCurrentUserAsync pattern existed at 7 call sites in TeamServiceBase.

Each call site now handles a null current user according to its semantic:

Method Behaviour on null user
GetTeamsAsync() (non-generic) yield break (empty stream)
GetTeamsAsync<TMember>() yield break
CreateTeamAsync(string name) throw UnauthorizedAccessException("Authentication required.")
RemoveMemberAsync throw
SetMemberLastSeenAsync early-return (touch op, benign no-op)
TransferOwnershipAsync<TMember> throw
AssureAccessLevel<TMember> (private gate) throw

A new private RequireCurrentUserAsync() helper centralises the throw; the existing GetCurrentUserAsync() is kept for read paths that handle null themselves.

A defensive null guard at the data layer was also added: TeamServiceRepositoryBase.GetTeamsAsync(IUser user) returns AsyncEnumerable.Empty<ITeam>() for null input. Belt-and-suspenders so the same bug class can't be reintroduced from a future caller that forgets the upstream guard.

Consumer impact

  • No public API change. Both helpers are private to TeamServiceBase.
  • UnauthorizedAccessException is System.UnauthorizedAccessException (not a custom Tharga type) so it integrates cleanly with ASP.NET pipelines that map it to 401.
  • Pages hosting <TeamComponent /> rendered to an unauthenticated principal now show the empty state ("You are not member of a team.") instead of crashing the circuit. Authenticated flow is unchanged.

Branch contents

This branch carries three commits to master:

  • fa36d13 feat: invited-member-edit-fix complete — close-out for #62.
  • ed7346f fix: guard TeamServiceBase against null current user — this fix.
  • 4e9aeb8 feat: unauthenticated-team-service-guards complete — finalize commit removing the feature's working plan/ so master never carries it.

Test plan

  • dotnet build -c Release -- 0 warnings, 0 errors.
  • dotnet test -c Release -- 280 / 280 passing (was 274, +6 new in UnauthenticatedTeamServiceTests):
    • GetTeamsAsync_Unauthenticated_ReturnsEmpty
    • GetTeamsAsync_Generic_Unauthenticated_ReturnsEmpty
    • CreateTeamAsync_Unauthenticated_Throws
    • RemoveMemberAsync_Unauthenticated_Throws (targets a non-owner row -- the owner-protection check fires first for the owner row)
    • SetMemberLastSeenAsync_Unauthenticated_DoesNotThrow
    • TransferOwnershipAsync_Unauthenticated_Throws
  • Manual: load the sample app team page in an incognito session; confirm no NRE in the host console and the empty state renders. Sign in and confirm authenticated flow is unchanged.

poxet added 3 commits May 10, 2026 19:49
Loading a <TeamComponent /> page before authentication (or after
session expiry) crashed the Blazor circuit with NullReferenceException
because TeamServiceBase.GetTeamsAsync took the result of
GetCurrentUserAsync() and passed it straight to the MongoDB
GetTeamsAsync(IUser) override, which dereferenced user.Key. The same
unguarded GetCurrentUserAsync pattern existed at 7 call sites in
TeamServiceBase.

Each call site now handles null per its semantic:

- Read paths (GetTeamsAsync, GetTeamsAsync<TMember>) yield break
  silently. Empty result is the correct semantic for "no current user,
  no teams to show".
- Touch path (SetMemberLastSeenAsync) returns silently. It's a
  passive heartbeat; throwing would surprise no-op callers.
- Side-effect paths (CreateTeamAsync, RemoveMemberAsync,
  TransferOwnershipAsync, AssureAccessLevel) call a new private
  RequireCurrentUserAsync() helper that throws UnauthorizedAccessException
  with "Authentication required." Surfaces as 401 in pipelines that
  map UnauthorizedAccessException, otherwise propagates.

Also adds a defensive null guard at the data-layer boundary in
TeamServiceRepositoryBase.GetTeamsAsync(IUser user) — returns
AsyncEnumerable.Empty<ITeam>() for null input. Belt-and-suspenders so
the same bug class can't be reintroduced from a future caller that
forgets the upstream guard.

6 new tests in UnauthenticatedTeamServiceTests cover each public API
path. 280 / 280 tests pass; build clean.
@codecov
Copy link
Copy Markdown

codecov Bot commented May 10, 2026

Codecov Report

❌ Patch coverage is 90.90909% with 1 line in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
Tharga.Team/TeamServiceBase.cs 90.90% 0 Missing and 1 partial ⚠️

📢 Thoughts on this report? Let us know!

@poxet poxet merged commit 47d257a into master May 10, 2026
5 of 6 checks passed
@poxet poxet deleted the feature/unauthenticated-team-service-guards branch May 10, 2026 19:14
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.

1 participant