Add admin roles, user management, and campaign deletion

This commit is contained in:
2026-02-26 17:15:10 +01:00
parent 3026221cd6
commit 2e2f364c5e
26 changed files with 1127 additions and 31 deletions

View File

@@ -14,6 +14,7 @@ public sealed class AuthApiTests : ApiTestBase
var registerResult = await RegisterAsync(client, "alice", "Password123", "Alice");
Assert.Equal("alice", registerResult.Username);
Assert.Contains(registerResult.Roles, role => string.Equals(role, "admin", StringComparison.OrdinalIgnoreCase));
var duplicate = await client.PostAsJsonAsync("/api/auth/register", new RegisterRequest("alice", "Password123", "Alice 2"));
Assert.Equal(HttpStatusCode.BadRequest, duplicate.StatusCode);

View File

@@ -103,4 +103,52 @@ public sealed class CampaignApiTests : ApiTestBase
var receiverActivate = await receiverClient.PostAsync($"/api/characters/{character.Id}/activate", null);
Assert.Equal(HttpStatusCode.OK, receiverActivate.StatusCode);
}
[Fact]
public async Task AdminUserManagementAndCampaignDeletion_WorkThroughApi()
{
using var factory = CreateFactory(6, 5, 4);
using var adminClient = factory.CreateClient(new() { AllowAutoRedirect = false });
using var gmClient = factory.CreateClient(new() { AllowAutoRedirect = false });
using var playerClient = factory.CreateClient(new() { AllowAutoRedirect = false });
var admin = await RegisterAsync(adminClient, "admin3", "Password123", "Admin");
var gm = await RegisterAsync(gmClient, "gm3", "Password123", "GM");
var player = await RegisterAsync(playerClient, "player3", "Password123", "Player");
await LoginAsync(adminClient, "admin3", "Password123");
await LoginAsync(gmClient, "gm3", "Password123");
await LoginAsync(playerClient, "player3", "Password123");
var adminUsers = await GetAsync<IReadOnlyList<AdminUserSummary>>(adminClient, "/api/admin/users");
var adminEntry = adminUsers.Single(user => user.Id == admin.Id);
var playerEntry = adminUsers.Single(user => user.Id == player.Id);
Assert.Contains(adminEntry.Roles, role => string.Equals(role, "admin", StringComparison.OrdinalIgnoreCase));
Assert.Empty(playerEntry.Roles);
var promotedPlayer = await PutAsync<UpdateUserRolesRequest, AdminUserSummary>(adminClient, $"/api/admin/users/{player.Id}/roles", new([ "admin" ]));
Assert.Contains(promotedPlayer.Roles, role => string.Equals(role, "admin", StringComparison.OrdinalIgnoreCase));
var campaign = await PostAsync<CreateCampaignRequest, CampaignDetails>(gmClient, "/api/campaigns", new("Disposable Campaign", "d6"));
var character = await PostAsync<CreateCharacterRequest, CharacterSummary>(playerClient, "/api/characters", new("Disposable Hero", campaign.Id));
var skill = await PostAsync<CreateSkillRequest, SkillSummary>(playerClient, $"/api/characters/{character.Id}/skills", new("Stealth", "2D+1", 1, true));
_ = await PostAsync<RollSkillRequest, RollResult>(playerClient, $"/api/skills/{skill.Id}/roll", new("public"));
var deleteCampaign = await adminClient.DeleteAsync($"/api/campaigns/{campaign.Id}");
Assert.Equal(HttpStatusCode.OK, deleteCampaign.StatusCode);
var getDeletedCampaign = await gmClient.GetAsync($"/api/campaigns/{campaign.Id}");
Assert.Equal(HttpStatusCode.BadRequest, getDeletedCampaign.StatusCode);
var playerCharacters = await GetAsync<IReadOnlyList<CharacterSummary>>(playerClient, "/api/characters");
Assert.Single(playerCharacters);
Assert.Null(playerCharacters[0].CampaignId);
var deleteUser = await adminClient.DeleteAsync($"/api/admin/users/{player.Id}");
Assert.Equal(HttpStatusCode.OK, deleteUser.StatusCode);
var usersAfterDelete = await GetAsync<IReadOnlyList<AdminUserSummary>>(adminClient, "/api/admin/users");
Assert.DoesNotContain(usersAfterDelete, user => user.Id == player.Id);
Assert.Contains(usersAfterDelete, user => user.Id == gm.Id);
}
}

View File

@@ -82,6 +82,9 @@ public sealed class HostingCoverageTests
"Breakdown" TEXT NOT NULL,
"TimestampUtc" TEXT NOT NULL
);
INSERT INTO "Users" ("Id", "Username", "UsernameNormalized", "PasswordHash", "DisplayName", "ActiveCharacterId")
VALUES ('00000000-0000-0000-0000-000000000001', 'legacy-admin', 'LEGACY-ADMIN', 'hash', 'Legacy Admin', NULL);
""";
_ = command.ExecuteNonQuery();
}
@@ -115,6 +118,35 @@ public sealed class HostingCoverageTests
Assert.Contains("Dice", rollColumns);
using var usersTableInfoCommand = verifyConnection.CreateCommand();
usersTableInfoCommand.CommandText = "PRAGMA table_info('Users');";
using var usersTableInfoReader = usersTableInfoCommand.ExecuteReader();
var usersColumns = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
while (usersTableInfoReader.Read())
usersColumns.Add(usersTableInfoReader.GetString(1));
Assert.Contains("Roles", usersColumns);
using var usersRoleCommand = verifyConnection.CreateCommand();
usersRoleCommand.CommandText = "SELECT Roles FROM Users WHERE UsernameNormalized = 'LEGACY-ADMIN';";
var roles = Convert.ToString(usersRoleCommand.ExecuteScalar());
Assert.Equal("admin", roles);
using var charactersTableInfoCommand = verifyConnection.CreateCommand();
charactersTableInfoCommand.CommandText = "PRAGMA table_info('Characters');";
using var charactersTableInfoReader = charactersTableInfoCommand.ExecuteReader();
var campaignIdNotNull = true;
while (charactersTableInfoReader.Read())
{
if (!string.Equals(charactersTableInfoReader.GetString(1), "CampaignId", StringComparison.OrdinalIgnoreCase))
continue;
campaignIdNotNull = charactersTableInfoReader.GetInt32(3) == 1;
break;
}
Assert.False(campaignIdNotNull);
using var historyCommand = verifyConnection.CreateCommand();
historyCommand.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20260226084000_InitialSchema';";
var historyCount = Convert.ToInt32(historyCommand.ExecuteScalar());
@@ -129,5 +161,10 @@ public sealed class HostingCoverageTests
rollDiceHistoryCommand.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20260226100000_AddRollLogDice';";
var rollDiceHistoryCount = Convert.ToInt32(rollDiceHistoryCommand.ExecuteScalar());
Assert.Equal(1, rollDiceHistoryCount);
using var rolesHistoryCommand = verifyConnection.CreateCommand();
rolesHistoryCommand.CommandText = "SELECT COUNT(*) FROM \"__EFMigrationsHistory\" WHERE \"MigrationId\" = '20260226160859_AddAuthorizationRolesAndCampaignDeletion';";
var rolesHistoryCount = Convert.ToInt32(rolesHistoryCommand.ExecuteScalar());
Assert.Equal(1, rolesHistoryCount);
}
}
}

View File

@@ -0,0 +1,83 @@
using RpgRoller.Domain;
namespace RpgRoller.Tests;
public sealed class ServiceAdminAndCampaignDeletionTests
{
[Fact]
public void AdminRoleManagement_RequiresAdminAndProtectsSelf()
{
using var harness = ServiceTestSupport.CreateHarness();
var service = harness.Service;
var bootstrapAdmin = ServiceTestSupport.GetValue(service.Register("admin", "Password123", "Admin"));
_ = ServiceTestSupport.GetValue(service.Register("member", "Password123", "Member"));
Assert.Contains(bootstrapAdmin.Roles, role => string.Equals(role, UserRoles.Admin, StringComparison.OrdinalIgnoreCase));
var adminSession = ServiceTestSupport.GetValue(service.Login("admin", "Password123")).SessionToken;
var memberSession = ServiceTestSupport.GetValue(service.Login("member", "Password123")).SessionToken;
var forbiddenList = service.GetUsers(memberSession);
Assert.False(forbiddenList.Succeeded);
var users = ServiceTestSupport.GetValue(service.GetUsers(adminSession));
var memberUser = users.Single(user => string.Equals(user.Username, "member", StringComparison.OrdinalIgnoreCase));
Assert.Empty(memberUser.Roles);
var promoted = ServiceTestSupport.GetValue(service.UpdateUserRoles(adminSession, memberUser.Id, [UserRoles.Admin]));
Assert.Contains(promoted.Roles, role => string.Equals(role, UserRoles.Admin, StringComparison.OrdinalIgnoreCase));
var selfDemote = service.UpdateUserRoles(adminSession, bootstrapAdmin.Id, Array.Empty<string>());
Assert.False(selfDemote.Succeeded);
var selfDelete = service.DeleteUser(adminSession, bootstrapAdmin.Id);
Assert.False(selfDelete.Succeeded);
var deletedMember = ServiceTestSupport.GetValue(service.DeleteUser(adminSession, memberUser.Id));
Assert.True(deletedMember);
var remainingUsers = ServiceTestSupport.GetValue(service.GetUsers(adminSession));
Assert.Single(remainingUsers);
Assert.Equal(bootstrapAdmin.Id, remainingUsers[0].Id);
}
[Fact]
public void CampaignDeletion_ByOwnerOrAdmin_UnlinksCharactersAndClearsLog()
{
using var harness = ServiceTestSupport.CreateHarness(4, 5, 6);
var service = harness.Service;
_ = ServiceTestSupport.GetValue(service.Register("admin", "Password123", "Admin"));
_ = ServiceTestSupport.GetValue(service.Register("gm", "Password123", "GM"));
_ = ServiceTestSupport.GetValue(service.Register("player", "Password123", "Player"));
var adminSession = ServiceTestSupport.GetValue(service.Login("admin", "Password123")).SessionToken;
var gmSession = ServiceTestSupport.GetValue(service.Login("gm", "Password123")).SessionToken;
var playerSession = ServiceTestSupport.GetValue(service.Login("player", "Password123")).SessionToken;
var ownerDeletedCampaign = ServiceTestSupport.GetValue(service.CreateCampaign(gmSession, "Owner Delete", "d6"));
var ownerDeleteResult = ServiceTestSupport.GetValue(service.DeleteCampaign(gmSession, ownerDeletedCampaign.Id));
Assert.True(ownerDeleteResult);
Assert.False(service.GetCampaign(gmSession, ownerDeletedCampaign.Id).Succeeded);
var adminDeletedCampaign = ServiceTestSupport.GetValue(service.CreateCampaign(gmSession, "Admin Delete", "d6"));
var playerCharacter = ServiceTestSupport.GetValue(service.CreateCharacter(playerSession, "Scout", adminDeletedCampaign.Id));
var playerSkill = ServiceTestSupport.GetValue(service.CreateSkill(playerSession, playerCharacter.Id, "Stealth", "2D+1", 1, true));
_ = ServiceTestSupport.GetValue(service.RollSkill(playerSession, playerSkill.Id, "public"));
var forbiddenDelete = service.DeleteCampaign(playerSession, adminDeletedCampaign.Id);
Assert.False(forbiddenDelete.Succeeded);
var adminDelete = ServiceTestSupport.GetValue(service.DeleteCampaign(adminSession, adminDeletedCampaign.Id));
Assert.True(adminDelete);
Assert.False(service.GetCampaign(gmSession, adminDeletedCampaign.Id).Succeeded);
var playerCharacters = ServiceTestSupport.GetValue(service.GetOwnCharacters(playerSession));
Assert.Single(playerCharacters);
Assert.Null(playerCharacters[0].CampaignId);
using var db = harness.CreateDbContext();
Assert.Empty(db.RollLogEntries.Where(entry => entry.CampaignId == adminDeletedCampaign.Id));
}
}