feat: restrict admin access to club operations and rollout test environment
CI Pipeline / Backend Build & Test (pull_request) Successful in 53s
CI Pipeline / Frontend Lint, Test & Build (pull_request) Failing after 16s
CI Pipeline / Infrastructure Validation (pull_request) Successful in 3s

This commit is contained in:
WorkClub Automation
2026-03-18 09:08:45 +01:00
parent 9cb80e4517
commit 821459966c
22 changed files with 507 additions and 203 deletions
@@ -85,7 +85,7 @@ public class ClubRoleClaimsTransformation : IClaimsTransformation
{
return clubRole switch
{
ClubRole.Admin => "Admin",
ClubRole.Manager => "Manager",
ClubRole.Member => "Member",
ClubRole.Viewer => "Viewer",
@@ -0,0 +1,67 @@
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
using WorkClub.Api.Services;
using WorkClub.Application.Clubs.DTOs;
namespace WorkClub.Api.Endpoints.Clubs;
public static class AdminClubEndpoints
{
public static void MapAdminClubEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/admin/clubs")
.RequireAuthorization("RequireGlobalAdmin")
.WithTags("AdminClubs");
group.MapGet("", GetClubs)
.WithName("AdminGetClubs");
group.MapPost("", CreateClub)
.WithName("AdminCreateClub");
group.MapPut("{id:guid}", UpdateClub)
.WithName("AdminUpdateClub");
group.MapDelete("{id:guid}", DeleteClub)
.WithName("AdminDeleteClub");
}
private static async Task<Ok<List<ClubDetailDto>>> GetClubs(AdminClubService adminClubService)
{
var result = await adminClubService.GetAllClubsAsync();
return TypedResults.Ok(result);
}
private static async Task<Created<ClubDetailDto>> CreateClub(
[FromBody] CreateClubRequest request,
AdminClubService adminClubService)
{
var result = await adminClubService.CreateClubAsync(request);
return TypedResults.Created($"/api/admin/clubs/{result.Id}", result);
}
private static async Task<Results<Ok<ClubDetailDto>, NotFound>> UpdateClub(
Guid id,
[FromBody] UpdateClubRequest request,
AdminClubService adminClubService)
{
var (result, error) = await adminClubService.UpdateClubAsync(id, request);
if (error != null)
return TypedResults.NotFound();
return TypedResults.Ok(result!);
}
private static async Task<Results<NoContent, NotFound>> DeleteClub(
Guid id,
AdminClubService adminClubService)
{
var success = await adminClubService.DeleteClubAsync(id);
if (!success)
return TypedResults.NotFound();
return TypedResults.NoContent();
}
}
@@ -28,7 +28,7 @@ public static class ShiftEndpoints
.WithName("UpdateShift");
group.MapDelete("{id:guid}", DeleteShift)
.RequireAuthorization("RequireAdmin")
.RequireAuthorization("RequireManager")
.WithName("DeleteShift");
group.MapPost("{id:guid}/signup", SignUpForShift)
@@ -28,7 +28,7 @@ public static class TaskEndpoints
.WithName("UpdateTask");
group.MapDelete("{id:guid}", DeleteTask)
.RequireAuthorization("RequireAdmin")
.RequireAuthorization("RequireManager")
.WithName("DeleteTask");
group.MapPost("{id:guid}/assign", AssignTaskToMe)
@@ -22,8 +22,9 @@ public class TenantValidationMiddleware
return;
}
// Exempt /api/clubs/me from tenant validation - this is the bootstrap endpoint
if (context.Request.Path.StartsWithSegments("/api/clubs/me"))
// Exempt bootstrap and admin endpoints from tenant validation
if (context.Request.Path.StartsWithSegments("/api/clubs/me") ||
context.Request.Path.StartsWithSegments("/api/admin"))
{
_logger.LogInformation("TenantValidationMiddleware: Exempting {Path} from tenant validation", context.Request.Path);
await _next(context);
+9 -3
View File
@@ -24,6 +24,7 @@ builder.Services.AddScoped<SeedDataService>();
builder.Services.AddScoped<TaskService>();
builder.Services.AddScoped<ShiftService>();
builder.Services.AddScoped<ClubService>();
builder.Services.AddScoped<AdminClubService>();
builder.Services.AddScoped<MemberService>();
builder.Services.AddScoped<MemberSyncService>();
@@ -49,9 +50,13 @@ builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
builder.Services.AddScoped<IClaimsTransformation, ClubRoleClaimsTransformation>();
builder.Services.AddAuthorizationBuilder()
.AddPolicy("RequireAdmin", policy => policy.RequireRole("Admin"))
.AddPolicy("RequireManager", policy => policy.RequireRole("Admin", "Manager"))
.AddPolicy("RequireMember", policy => policy.RequireRole("Admin", "Manager", "Member"))
.AddPolicy("RequireGlobalAdmin", policy => policy.RequireAssertion(context =>
{
var realmAccess = context.User.FindFirst("realm_access")?.Value;
return realmAccess != null && realmAccess.Contains("\"admin\"");
}))
.AddPolicy("RequireManager", policy => policy.RequireRole("Manager"))
.AddPolicy("RequireMember", policy => policy.RequireRole("Manager", "Member"))
.AddPolicy("RequireViewer", policy => policy.RequireAuthenticatedUser());
builder.Services.AddDbContext<AppDbContext>((sp, options) =>
@@ -122,6 +127,7 @@ app.MapGet("/api/test", () => Results.Ok(new { message = "Test endpoint" }))
app.MapTaskEndpoints();
app.MapShiftEndpoints();
app.MapClubEndpoints();
app.MapAdminClubEndpoints();
app.MapMemberEndpoints();
app.Run();
@@ -0,0 +1,113 @@
using Microsoft.EntityFrameworkCore;
using Npgsql;
using WorkClub.Application.Clubs.DTOs;
using WorkClub.Domain.Entities;
using WorkClub.Infrastructure.Data;
namespace WorkClub.Api.Services;
public class AdminClubService
{
private readonly AppDbContext _context;
public AdminClubService(AppDbContext context)
{
_context = context;
}
public async Task<List<ClubDetailDto>> GetAllClubsAsync()
{
var strategy = _context.Database.CreateExecutionStrategy();
return await strategy.ExecuteAsync(async () =>
{
await using var transaction = await _context.Database.BeginTransactionAsync();
await _context.Database.ExecuteSqlRawAsync("SET LOCAL ROLE app_admin");
var clubs = await _context.Clubs.ToListAsync();
await _context.Database.ExecuteSqlRawAsync("RESET ROLE");
await transaction.CommitAsync();
return clubs.Select(c => new ClubDetailDto(
c.Id, c.Name, c.SportType.ToString(), c.Description, c.CreatedAt, c.UpdatedAt)).ToList();
});
}
public async Task<ClubDetailDto> CreateClubAsync(CreateClubRequest request)
{
var tenantId = Guid.NewGuid().ToString();
var club = new Club
{
Id = Guid.NewGuid(),
TenantId = tenantId,
Name = request.Name,
SportType = request.SportType,
Description = request.Description,
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow
};
var strategy = _context.Database.CreateExecutionStrategy();
await strategy.ExecuteAsync(async () =>
{
await using var transaction = await _context.Database.BeginTransactionAsync();
await _context.Database.ExecuteSqlRawAsync("SET LOCAL ROLE app_admin");
_context.Clubs.Add(club);
await _context.SaveChangesAsync();
await _context.Database.ExecuteSqlRawAsync("RESET ROLE");
await transaction.CommitAsync();
});
return new ClubDetailDto(club.Id, club.Name, club.SportType.ToString(), club.Description, club.CreatedAt, club.UpdatedAt);
}
public async Task<(ClubDetailDto? club, string? error)> UpdateClubAsync(Guid id, UpdateClubRequest request)
{
var strategy = _context.Database.CreateExecutionStrategy();
return await strategy.ExecuteAsync<(ClubDetailDto? club, string? error)>(async () =>
{
await using var transaction = await _context.Database.BeginTransactionAsync();
await _context.Database.ExecuteSqlRawAsync("SET LOCAL ROLE app_admin");
var club = await _context.Clubs.FindAsync(id);
if (club == null)
{
await _context.Database.ExecuteSqlRawAsync("RESET ROLE");
return (null, "Club not found");
}
club.Name = request.Name;
club.SportType = request.SportType;
club.Description = request.Description;
club.UpdatedAt = DateTimeOffset.UtcNow;
await _context.SaveChangesAsync();
await _context.Database.ExecuteSqlRawAsync("RESET ROLE");
await transaction.CommitAsync();
return (new ClubDetailDto(club.Id, club.Name, club.SportType.ToString(), club.Description, club.CreatedAt, club.UpdatedAt), null);
});
}
public async Task<bool> DeleteClubAsync(Guid id)
{
var strategy = _context.Database.CreateExecutionStrategy();
return await strategy.ExecuteAsync<bool>(async () =>
{
await using var transaction = await _context.Database.BeginTransactionAsync();
await _context.Database.ExecuteSqlRawAsync("SET LOCAL ROLE app_admin");
var club = await _context.Clubs.FindAsync(id);
if (club == null)
{
await _context.Database.ExecuteSqlRawAsync("RESET ROLE");
return false;
}
_context.Clubs.Remove(club);
await _context.SaveChangesAsync();
await _context.Database.ExecuteSqlRawAsync("RESET ROLE");
await transaction.CommitAsync();
return true;
});
}
}
@@ -60,7 +60,6 @@ public class MemberSyncService
var roleClaim = httpContext.User.FindFirst(System.Security.Claims.ClaimTypes.Role)?.Value ?? "Member";
var clubRole = roleClaim.ToLowerInvariant() switch
{
"admin" => ClubRole.Admin,
"manager" => ClubRole.Manager,
"member" => ClubRole.Member,
"viewer" => ClubRole.Viewer,
@@ -0,0 +1,9 @@
using WorkClub.Domain.Enums;
namespace WorkClub.Application.Clubs.DTOs;
public record CreateClubRequest(
string Name,
SportType SportType,
string? Description
);
@@ -0,0 +1,9 @@
using WorkClub.Domain.Enums;
namespace WorkClub.Application.Clubs.DTOs;
public record UpdateClubRequest(
string Name,
SportType SportType,
string? Description
);
@@ -2,7 +2,6 @@ namespace WorkClub.Domain.Enums;
public enum ClubRole
{
Admin = 0,
Manager = 1,
Member = 2,
Viewer = 3
@@ -26,7 +26,7 @@ public class SeedDataService
using var transaction = await context.Database.BeginTransactionAsync();
// Enable RLS on all tenant tables
// Enable RLS on all tenant tables (Must be table owner, which 'workclub' is)
await context.Database.ExecuteSqlRawAsync(@"
ALTER TABLE clubs ENABLE ROW LEVEL SECURITY;
ALTER TABLE clubs FORCE ROW LEVEL SECURITY;
@@ -62,22 +62,6 @@ public class SeedDataService
");
// Create admin bypass policies (idempotent)
await context.Database.ExecuteSqlRawAsync(@"
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'app_admin') THEN
CREATE ROLE app_admin;
END IF;
END
$$;
GRANT app_admin TO app;
GRANT USAGE ON SCHEMA public TO app_admin;
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO app_admin;
GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO app_admin;
ALTER DEFAULT PRIVILEGES FOR ROLE app IN SCHEMA public GRANT ALL ON TABLES TO app_admin;
ALTER DEFAULT PRIVILEGES FOR ROLE app IN SCHEMA public GRANT ALL ON SEQUENCES TO app_admin;
");
await context.Database.ExecuteSqlRawAsync(@"
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_policies WHERE tablename='clubs' AND policyname='bypass_rls_policy') THEN
@@ -140,31 +124,7 @@ public class SeedDataService
{
var members = new List<Member>
{
// admin@test.com: Admin in Club 1, Member in Club 2
new Member
{
Id = Guid.NewGuid(),
TenantId = tennisClub.TenantId,
ExternalUserId = "admin-user-id",
DisplayName = "Admin User",
Email = "admin@test.com",
Role = ClubRole.Admin,
ClubId = tennisClub.Id,
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow
},
new Member
{
Id = Guid.NewGuid(),
TenantId = cyclingClub.TenantId,
ExternalUserId = "admin-user-id",
DisplayName = "Admin User",
Email = "admin@test.com",
Role = ClubRole.Member,
ClubId = cyclingClub.Id,
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow
},
// manager@test.com: Manager in Club 1
new Member
{
@@ -235,8 +195,7 @@ public class SeedDataService
await context.SaveChangesAsync();
}
// Get admin member IDs for work item creation
var adminMembers = context.Members.Where(m => m.Email == "admin@test.com").ToList();
var managerMember = context.Members.First(m => m.Email == "manager@test.com");
var member1Members = context.Members.Where(m => m.Email == "member1@test.com").ToList();
var member2Member = context.Members.First(m => m.Email == "member2@test.com");
@@ -255,7 +214,7 @@ public class SeedDataService
Description = "Resurface main court",
Status = WorkItemStatus.Open,
AssigneeId = null,
CreatedById = adminMembers.First(m => m.ClubId == tennisClub.Id).Id,
CreatedById = managerMember.Id,
ClubId = tennisClub.Id,
DueDate = DateTimeOffset.UtcNow.AddDays(14),
CreatedAt = DateTimeOffset.UtcNow,
@@ -269,7 +228,7 @@ public class SeedDataService
Description = "Purchase new tennis rackets and balls",
Status = WorkItemStatus.Assigned,
AssigneeId = managerMember.Id,
CreatedById = adminMembers.First(m => m.ClubId == tennisClub.Id).Id,
CreatedById = managerMember.Id,
ClubId = tennisClub.Id,
DueDate = DateTimeOffset.UtcNow.AddDays(7),
CreatedAt = DateTimeOffset.UtcNow,
@@ -283,7 +242,7 @@ public class SeedDataService
Description = "Organize annual summer tournament",
Status = WorkItemStatus.InProgress,
AssigneeId = member1Members.First(m => m.ClubId == tennisClub.Id).Id,
CreatedById = adminMembers.First(m => m.ClubId == tennisClub.Id).Id,
CreatedById = managerMember.Id,
ClubId = tennisClub.Id,
DueDate = DateTimeOffset.UtcNow.AddDays(30),
CreatedAt = DateTimeOffset.UtcNow,
@@ -297,7 +256,7 @@ public class SeedDataService
Description = "Update and review club rules handbook",
Status = WorkItemStatus.Review,
AssigneeId = member2Member.Id,
CreatedById = adminMembers.First(m => m.ClubId == tennisClub.Id).Id,
CreatedById = managerMember.Id,
ClubId = tennisClub.Id,
DueDate = DateTimeOffset.UtcNow.AddDays(21),
CreatedAt = DateTimeOffset.UtcNow,
@@ -311,7 +270,7 @@ public class SeedDataService
Description = "Update club website with new photos",
Status = WorkItemStatus.Done,
AssigneeId = managerMember.Id,
CreatedById = adminMembers.First(m => m.ClubId == tennisClub.Id).Id,
CreatedById = managerMember.Id,
ClubId = tennisClub.Id,
DueDate = DateTimeOffset.UtcNow.AddDays(-5),
CreatedAt = DateTimeOffset.UtcNow.AddDays(-10),
@@ -326,7 +285,7 @@ public class SeedDataService
Description = "Create new cycling routes for summer",
Status = WorkItemStatus.Open,
AssigneeId = null,
CreatedById = adminMembers.First(m => m.ClubId == cyclingClub.Id).Id,
CreatedById = member1Members.First(m => m.ClubId == cyclingClub.Id).Id,
ClubId = cyclingClub.Id,
DueDate = DateTimeOffset.UtcNow.AddDays(21),
CreatedAt = DateTimeOffset.UtcNow,
@@ -340,7 +299,7 @@ public class SeedDataService
Description = "Organize safety and maintenance training",
Status = WorkItemStatus.Assigned,
AssigneeId = member1Members.First(m => m.ClubId == cyclingClub.Id).Id,
CreatedById = adminMembers.First(m => m.ClubId == cyclingClub.Id).Id,
CreatedById = member1Members.First(m => m.ClubId == cyclingClub.Id).Id,
ClubId = cyclingClub.Id,
DueDate = DateTimeOffset.UtcNow.AddDays(14),
CreatedAt = DateTimeOffset.UtcNow,
@@ -353,8 +312,8 @@ public class SeedDataService
Title = "Group ride coordination",
Description = "Schedule and coordinate weekly group rides",
Status = WorkItemStatus.InProgress,
AssigneeId = adminMembers.First(m => m.ClubId == cyclingClub.Id).Id,
CreatedById = adminMembers.First(m => m.ClubId == cyclingClub.Id).Id,
AssigneeId = member1Members.First(m => m.ClubId == cyclingClub.Id).Id,
CreatedById = member1Members.First(m => m.ClubId == cyclingClub.Id).Id,
ClubId = cyclingClub.Id,
DueDate = DateTimeOffset.UtcNow.AddDays(7),
CreatedAt = DateTimeOffset.UtcNow,
@@ -384,7 +343,7 @@ public class SeedDataService
EndTime = now.AddDays(-1).Date.ToLocalTime().AddHours(12),
Capacity = 2,
ClubId = tennisClub.Id,
CreatedById = adminMembers.First(m => m.ClubId == tennisClub.Id).Id,
CreatedById = managerMember.Id,
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow
},
@@ -399,7 +358,7 @@ public class SeedDataService
EndTime = now.Date.ToLocalTime().AddHours(18),
Capacity = 3,
ClubId = tennisClub.Id,
CreatedById = adminMembers.First(m => m.ClubId == tennisClub.Id).Id,
CreatedById = managerMember.Id,
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow
},
@@ -414,7 +373,7 @@ public class SeedDataService
EndTime = now.AddDays(7).Date.ToLocalTime().AddHours(17),
Capacity = 5,
ClubId = tennisClub.Id,
CreatedById = adminMembers.First(m => m.ClubId == tennisClub.Id).Id,
CreatedById = managerMember.Id,
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow
},
@@ -430,7 +389,7 @@ public class SeedDataService
EndTime = now.Date.ToLocalTime().AddHours(9),
Capacity = 10,
ClubId = cyclingClub.Id,
CreatedById = adminMembers.First(m => m.ClubId == cyclingClub.Id).Id,
CreatedById = member1Members.First(m => m.ClubId == cyclingClub.Id).Id,
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow
},
@@ -445,7 +404,7 @@ public class SeedDataService
EndTime = now.AddDays(7).Date.ToLocalTime().AddHours(14),
Capacity = 4,
ClubId = cyclingClub.Id,
CreatedById = adminMembers.First(m => m.ClubId == cyclingClub.Id).Id,
CreatedById = member1Members.First(m => m.ClubId == cyclingClub.Id).Id,
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow
}
@@ -69,7 +69,7 @@ public class ClubEndpointsTests : IntegrationTestBase
ExternalUserId = adminUserId,
DisplayName = "Admin User",
Email = "admin@test.com",
Role = ClubRole.Admin,
Role = ClubRole.Manager,
ClubId = club1Id,
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow
@@ -60,7 +60,7 @@ public class MemberEndpointsTests : IntegrationTestBase
ExternalUserId = "admin-user-id",
DisplayName = "Admin User",
Email = "admin@test.com",
Role = ClubRole.Admin,
Role = ClubRole.Manager,
ClubId = club1Id,
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow
@@ -303,55 +303,7 @@ public class ShiftCrudTests : IntegrationTestBase
}
[Fact]
public async Task DeleteShift_AsAdmin_DeletesShift()
{
// Arrange
var shiftId = Guid.NewGuid();
var clubId = Guid.NewGuid();
var createdBy = Guid.NewGuid();
var now = DateTimeOffset.UtcNow;
using (var scope = Factory.Services.CreateScope())
{
var context = scope.ServiceProvider.GetRequiredService<AppDbContext>();
context.Shifts.Add(new Shift
{
Id = shiftId,
TenantId = "tenant1",
Title = "Test Shift",
StartTime = now.AddDays(1),
EndTime = now.AddDays(1).AddHours(4),
Capacity = 5,
ClubId = clubId,
CreatedById = createdBy,
CreatedAt = now,
UpdatedAt = now
});
await context.SaveChangesAsync();
}
SetTenant("tenant1");
AuthenticateAs("admin@test.com", new Dictionary<string, string> { ["tenant1"] = "Admin" });
// Act
var response = await Client.DeleteAsync($"/api/shifts/{shiftId}");
// Assert
Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);
// Verify shift is deleted
using (var scope = Factory.Services.CreateScope())
{
var context = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var shift = await context.Shifts.FindAsync(shiftId);
Assert.Null(shift);
}
}
[Fact]
public async Task DeleteShift_AsManager_ReturnsForbidden()
public async Task DeleteShift_AsManager_DeletesShift()
{
// Arrange
var shiftId = Guid.NewGuid();
@@ -387,7 +339,15 @@ public class ShiftCrudTests : IntegrationTestBase
var response = await Client.DeleteAsync($"/api/shifts/{shiftId}");
// Assert
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);
// Verify shift is deleted
using (var scope = Factory.Services.CreateScope())
{
var context = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var shift = await context.Shifts.FindAsync(shiftId);
Assert.Null(shift);
}
}
[Fact]
@@ -387,52 +387,7 @@ public class TaskCrudTests : IntegrationTestBase
}
[Fact]
public async Task DeleteTask_AsAdmin_DeletesTask()
{
// Arrange
var taskId = Guid.NewGuid();
var club1 = Guid.NewGuid();
var createdBy = Guid.NewGuid();
using (var scope = Factory.Services.CreateScope())
{
var context = scope.ServiceProvider.GetRequiredService<AppDbContext>();
context.WorkItems.Add(new WorkItem
{
Id = taskId,
TenantId = "tenant1",
Title = "Test Task",
Status = WorkItemStatus.Open,
ClubId = club1,
CreatedById = createdBy,
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow
});
await context.SaveChangesAsync();
}
SetTenant("tenant1");
AuthenticateAs("admin@test.com", new Dictionary<string, string> { ["tenant1"] = "Admin" });
// Act
var response = await Client.DeleteAsync($"/api/tasks/{taskId}");
// Assert
Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);
// Verify task is deleted
using (var scope = Factory.Services.CreateScope())
{
var context = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var task = await context.WorkItems.FindAsync(taskId);
Assert.Null(task);
}
}
[Fact]
public async Task DeleteTask_AsManager_ReturnsForbidden()
public async Task DeleteTask_AsManager_DeletesTask()
{
// Arrange
var taskId = Guid.NewGuid();
@@ -465,7 +420,15 @@ public class TaskCrudTests : IntegrationTestBase
var response = await Client.DeleteAsync($"/api/tasks/{taskId}");
// Assert
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);
// Verify task is deleted
using (var scope = Factory.Services.CreateScope())
{
var context = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var task = await context.WorkItems.FindAsync(taskId);
Assert.Null(task);
}
}
}