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
115 changes: 115 additions & 0 deletions backend.Tests/ClubVisibilityTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
using ClubHub.Api.Data;
using ClubHub.Api.Data.Entities;
using ClubHub.Api.Services;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;

namespace ClubHub.Api.Tests;
Expand Down Expand Up @@ -128,4 +129,118 @@ public async Task MemberClubList_ExcludesDissolvedClubFromOperationalWorkspaces(
Assert.Contains("成员可见运营社团", names);
Assert.DoesNotContain("成员不可见解散社团", names);
}

[Fact]
public async Task SessionRoles_ExcludeRolesScopedToInactiveClubs()
{
var baseId = 9_302_000 + Interlocked.Increment(ref _sequence) * 10;
await using var scope = _factory.Services.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<ClubHubDbContext>();
var authService = scope.ServiceProvider.GetRequiredService<AuthService>();
await authService.InitializeBaseRolesAsync();

var now = DateTime.UtcNow;
var activeClubId = baseId + 1;
var inactiveClubId = baseId + 2;
var user = new User
{
UserId = baseId,
Username = $"session-role-user-{baseId}",
PasswordHash = "unused",
RealName = "会话角色测试用户",
AccountStatus = "normal",
CreatedAt = now
};
db.Users.Add(user);
db.Clubs.AddRange(
new Club
{
ClubId = activeClubId,
ClubName = "会话运营社团",
AuditStatus = "approved",
ClubStatus = "active",
CreatedAt = now
},
new Club
{
ClubId = inactiveClubId,
ClubName = "会话已解散社团",
AuditStatus = "approved",
ClubStatus = "inactive",
CreatedAt = now
});

var memberRole = await db.Roles.SingleAsync(role => role.RoleCode == "CLUB_MEMBER");
var leaderRole = await db.Roles.SingleAsync(role => role.RoleCode == "CLUB_LEADER");
db.UserRoles.AddRange(
new UserRole
{
UserRoleId = baseId + 3,
UserId = user.UserId,
RoleId = memberRole.RoleId,
ClubId = activeClubId,
AssignedAt = now
},
new UserRole
{
UserRoleId = baseId + 4,
UserId = user.UserId,
RoleId = leaderRole.RoleId,
ClubId = inactiveClubId,
AssignedAt = now
});
await db.SaveChangesAsync();

var session = await authService.GetSessionAsync(user.UserId, "test-token");

Assert.True(session.Succeeded, session.ErrorMessage);
Assert.Contains(session.Value!.Roles, role => role.ClubId == activeClubId);
Assert.DoesNotContain(session.Value.Roles, role => role.ClubId == inactiveClubId);

var permissionRoles = await authService.GetPermissionRolesAsync(user.UserId);
Assert.Contains(permissionRoles, role => role.ClubId == activeClubId);
Assert.DoesNotContain(permissionRoles, role => role.ClubId == inactiveClubId);

Assert.Equal(
2,
await db.UserRoles.CountAsync(role => role.UserId == user.UserId));

var cachedInactiveRole = new AuthRole(
leaderRole.RoleId,
leaderRole.RoleCode,
leaderRole.RoleName,
"会话已解散社团负责人",
leaderRole.RoleScope,
inactiveClubId,
[inactiveClubId],
AuthService.GetRolePermissions(leaderRole.RoleCode),
leaderRole.PermissionDesc);
var cachedAuthService = new AuthService(
db,
scope.ServiceProvider.GetRequiredService<AuthTokenService>(),
scope.ServiceProvider.GetRequiredService<IAuthSessionService>(),
new FixedPermissionSnapshotCache(new PermissionSnapshot(user.UserId, [cachedInactiveRole])));

var filteredCachedRoles = await cachedAuthService.GetPermissionRolesAsync(user.UserId);
Assert.DoesNotContain(filteredCachedRoles, role => role.ClubId == inactiveClubId);
}

private sealed class FixedPermissionSnapshotCache(PermissionSnapshot snapshot)
: IPermissionSnapshotCache
{
public Task<PermissionSnapshot> GetOrCreateAsync(
int userId,
Func<Task<PermissionSnapshot>> factory,
CancellationToken cancellationToken = default) => Task.FromResult(snapshot);

public Task<AccountStatusSnapshot> GetAccountStatusAsync(
int userId,
Func<Task<AccountStatusSnapshot>> factory,
CancellationToken cancellationToken = default) => factory();

public Task InvalidateAsync(
int userId,
bool requiredForSafety,
CancellationToken cancellationToken = default) => Task.CompletedTask;
}
}
42 changes: 40 additions & 2 deletions backend/Services/AuthService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ public class AuthService
private const string ClubOfficerRole = "CLUB_OFFICER";
private const string ClubLeaderRole = "CLUB_LEADER";
private const string AdvisorRole = "ADVISOR";
private const string AuditApproved = "approved";
private const string ClubActive = "active";
private const string VenueAdminRole = "VENUE_ADMIN";
private const string SystemAdminRole = "SYSTEM_ADMIN";
private const int StudentNoLength = 7;
Expand Down Expand Up @@ -648,24 +650,50 @@ public async Task<IReadOnlyList<AuthRole>> GetPermissionRolesAsync(int userId)
async () => new PermissionSnapshot(
userId,
await GetRawAuthRolesAsync(userId)));
return BuildPermissionRoles(snapshot.Roles);
var currentRoles = await FilterOperationalClubRolesAsync(snapshot.Roles);
return BuildPermissionRoles(currentRoles);
}

private async Task<IReadOnlyList<AuthRole>> GetRawAuthRolesAsync(int userId)
{
var userRoles = await _db.UserRoles
.Include(ur => ur.Role)
.Include(ur => ur.Club)
.Where(ur => ur.UserId == userId)
.OrderBy(ur => ur.RoleId)
.ThenBy(ur => ur.ClubId)
.ToListAsync();

return userRoles
.Where(ur => ur.Role is not null)
.Where(ur => ur.Role is not null &&
(ur.ClubId is null || IsOperationalClub(ur.Club)))
Comment thread
coderabbitai[bot] marked this conversation as resolved.
.Select(ur => ToAuthRole(ur.Role!, ur.ClubId))
.ToList();
}

private async Task<IReadOnlyList<AuthRole>> FilterOperationalClubRolesAsync(
IReadOnlyList<AuthRole> roles)
{
var clubIds = roles
.Where(role => role.ClubId is not null)
.Select(role => role.ClubId!.Value)
.Distinct()
.ToList();
if (clubIds.Count == 0) return roles;

var operationalClubIds = (await _db.Clubs
.AsNoTracking()
.Where(club => clubIds.Contains(club.ClubId))
.ToListAsync())
.Where(IsOperationalClub)
.Select(club => club.ClubId)
.ToHashSet();

return roles
.Where(role => role.ClubId is null || operationalClubIds.Contains(role.ClubId.Value))
.ToList();
}

private async Task<IReadOnlyList<AuthRole>> BuildDisplayRolesAsync(IReadOnlyList<AuthRole> rawRoles)
{
var clubIds = rawRoles
Expand Down Expand Up @@ -1033,6 +1061,16 @@ private static bool IsNormalAccount(User user)
return normalized is "active" or NormalStatus or "enabled" or "在任" or "正常";
}

private static bool IsOperationalClub(ClubHub.Api.Data.Entities.Club? club)
{
if (club is null) return false;

var status = NormalizeText(club.ClubStatus).ToLowerInvariant();
var auditStatus = NormalizeText(club.AuditStatus).ToLowerInvariant();
return (status is ClubActive or "normal" or "enabled" or "运营中" or "正常") &&
(auditStatus is "" or AuditApproved or "已通过");
}

private static bool IsUniqueConstraintViolation(DbUpdateException ex)
{
var message = ex.InnerException?.Message ?? ex.Message;
Expand Down
Loading