Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Tharga.Team.MongoDB/TeamServiceRepositoryBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ protected override Task SetTeamMemberNameAsync(string teamKey, string userKey, s

protected override IAsyncEnumerable<ITeam> GetTeamsAsync(IUser user)
{
if (user == null) return AsyncEnumerable.Empty<ITeam>();
return _teamRepository.GetTeamsByUserAsync(user.Key);
}

Expand Down
69 changes: 69 additions & 0 deletions Tharga.Team.Service.Tests/UnauthenticatedTeamServiceTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
namespace Tharga.Team.Service.Tests;

/// <summary>
/// Verifies that <see cref="TeamServiceBase"/> handles a null current user gracefully:
/// read paths return empty, side-effect paths throw <see cref="UnauthorizedAccessException"/>.
/// </summary>
public class UnauthenticatedTeamServiceTests
{
private readonly TestTeamService _sut;

public UnauthenticatedTeamServiceTests()
{
var userService = Substitute.For<IUserService>();
userService.GetCurrentUserAsync().Returns((IUser)null);

_sut = new TestTeamService(userService);
_sut.AddTeam("team-1", "Test Team",
new TestMember { Key = "user-1", AccessLevel = AccessLevel.Owner, State = MembershipState.Member },
new TestMember { Key = "user-2", AccessLevel = AccessLevel.User, State = MembershipState.Member });
}

[Fact]
public async Task GetTeamsAsync_Unauthenticated_ReturnsEmpty()
{
var teams = new List<ITeam>();
await foreach (var t in _sut.GetTeamsAsync()) teams.Add(t);

Assert.Empty(teams);
}

[Fact]
public async Task GetTeamsAsync_Generic_Unauthenticated_ReturnsEmpty()
{
var teams = new List<ITeam<TestMember>>();
await foreach (var t in _sut.GetTeamsAsync<TestMember>()) teams.Add(t);

Assert.Empty(teams);
}

[Fact]
public async Task CreateTeamAsync_Unauthenticated_Throws()
{
await Assert.ThrowsAsync<UnauthorizedAccessException>(
() => _sut.CreateTeamAsync("New Team"));
}

[Fact]
public async Task RemoveMemberAsync_Unauthenticated_Throws()
{
// user-2 is a non-owner; attempting to remove them as an unauthenticated caller
// should hit the RequireCurrentUserAsync guard before any DB write.
await Assert.ThrowsAsync<UnauthorizedAccessException>(
() => _sut.RemoveMemberAsync("team-1", "user-2"));
}

[Fact]
public async Task SetMemberLastSeenAsync_Unauthenticated_DoesNotThrow()
{
var ex = await Record.ExceptionAsync(() => _sut.SetMemberLastSeenAsync("team-1"));
Assert.Null(ex);
}

[Fact]
public async Task TransferOwnershipAsync_Unauthenticated_Throws()
{
await Assert.ThrowsAsync<UnauthorizedAccessException>(
() => _sut.TransferOwnershipAsync<TestMember>("team-1", "user-1"));
}
}
18 changes: 14 additions & 4 deletions Tharga.Team/TeamServiceBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ protected TeamServiceBase(IUserService userService)
public async IAsyncEnumerable<ITeam> GetTeamsAsync()
{
var user = await GetCurrentUserAsync();
if (user == null) yield break;

await foreach (var team in GetTeamsAsync(user))
{
Expand All @@ -46,6 +47,7 @@ public async IAsyncEnumerable<ITeam> GetTeamsAsync()
public async IAsyncEnumerable<ITeam<TMember>> GetTeamsAsync<TMember>() where TMember : ITeamMember
{
var user = await GetCurrentUserAsync();
if (user == null) yield break;

await foreach (var team in GetTeamsAsync(user))
{
Expand All @@ -61,7 +63,7 @@ public async Task<ITeam<TMember>> GetTeamAsync<TMember>(string teamKey) where TM

public async Task<ITeam> CreateTeamAsync(string name)
{
var user = await GetCurrentUserAsync();
var user = await RequireCurrentUserAsync();

var displayName = ResolveDisplayName(user);
name ??= $"{displayName}'s team";
Expand Down Expand Up @@ -124,7 +126,7 @@ public async Task RemoveMemberAsync(string teamKey, string userKey)
if (member.AccessLevel == AccessLevel.Owner)
throw new InvalidOperationException("The owner cannot leave the team. Transfer ownership first.");

var user = await GetCurrentUserAsync();
var user = await RequireCurrentUserAsync();
if (member.Key == user.Key && member.AccessLevel == AccessLevel.Administrator)
{
var otherAdminsOrOwners = members.Count(x =>
Expand Down Expand Up @@ -211,13 +213,14 @@ protected virtual Task<string> GetInvitedMemberNameAsync(string teamKey, string
public async Task SetMemberLastSeenAsync(string teamKey)
{
var user = await GetCurrentUserAsync();
if (user == null) return;
await SetTeamMemberLastSeenAsync(teamKey, user.Key);
_teamMemberCache.TryRemove($"{teamKey}.{user.Key}", out _);
}

public async Task TransferOwnershipAsync<TMember>(string teamKey, string newOwnerUserKey) where TMember : ITeamMember
{
var user = await GetCurrentUserAsync();
var user = await RequireCurrentUserAsync();
var team = await GetTeamAsync<TMember>(teamKey);
var currentOwner = team.Members.SingleOrDefault(x => x.Key == user.Key);
if (currentOwner == null || currentOwner.AccessLevel != AccessLevel.Owner)
Expand Down Expand Up @@ -262,7 +265,7 @@ private async Task<string> GetRandomUnsusedTeamKey()

private async Task AssureAccessLevel<TMember>(string teamKey, AccessLevel accessLevel) where TMember : ITeamMember
{
var user = await GetCurrentUserAsync();
var user = await RequireCurrentUserAsync();
var team = await GetTeamAsync<TMember>(teamKey);
var member = team.Members.Single(x => x.Key == user.Key);
if (member.State != MembershipState.Member) throw new InvalidOperationException("User is not a member.");
Expand All @@ -275,6 +278,13 @@ private async Task<IUser> GetCurrentUserAsync()
return user;
}

private async Task<IUser> RequireCurrentUserAsync()
{
var user = await GetCurrentUserAsync();
if (user == null) throw new UnauthorizedAccessException("Authentication required.");
return user;
}

public static string ResolveDisplayName(IUser user)
{
if (user == null) return "Unknown";
Expand Down
118 changes: 0 additions & 118 deletions plan/feature.md

This file was deleted.

Loading
Loading