Skip to content
Draft
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
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,20 @@ private async Task<string> SeedTestRoleAsync(string? name = null)
return role.Id;
}

private async Task<ApplicationRole?> FindRoleByNameAsync(string name)
{
using var scope = _factory.Services.CreateScope();
var roleManager = scope.ServiceProvider.GetRequiredService<RoleManager<ApplicationRole>>();
return await roleManager.FindByNameAsync(name);
}

private async Task<ApplicationRole?> FindRoleByIdAsync(string id)
{
using var scope = _factory.Services.CreateScope();
var roleManager = scope.ServiceProvider.GetRequiredService<RoleManager<ApplicationRole>>();
return await roleManager.FindByIdAsync(id);
}

[Fact]
public async Task GetRoles_AsAdmin_Returns200()
{
Expand Down Expand Up @@ -86,21 +100,25 @@ public async Task GetRolesCreate_AsAdmin_Returns200()
}

[Fact]
public async Task CreateRole_ValidData_Redirects()
public async Task CreateRole_ValidData_PersistsRole()
{
var client = CreateAdminClient();
var roleName = $"NewRole-{Guid.NewGuid().ToString()[..8]}";

using var content = new FormUrlEncodedContent(
new Dictionary<string, string>
{
["name"] = $"NewRole-{Guid.NewGuid().ToString()[..8]}",
["name"] = roleName,
["description"] = "A new test role",
}
);

var response = await client.PostAsync("/admin/roles", content);

response.StatusCode.Should().Be(HttpStatusCode.Redirect);

var role = await FindRoleByNameAsync(roleName);
role.Should().NotBeNull();
role!.Description.Should().Be("A new test role");
}

[Fact]
Expand Down Expand Up @@ -140,33 +158,39 @@ public async Task GetRolesEdit_NonExistentRole_Returns404()
}

[Fact]
public async Task UpdateRole_ValidData_Redirects()
public async Task UpdateRole_ValidData_PersistsChanges()
{
var roleId = await SeedTestRoleAsync();
var newName = $"UpdatedRole-{Guid.NewGuid().ToString()[..8]}";
var client = CreateAdminClient();

using var content = new FormUrlEncodedContent(
new Dictionary<string, string>
{
["name"] = $"UpdatedRole-{Guid.NewGuid().ToString()[..8]}",
["name"] = newName,
["description"] = "Updated description",
}
);

var response = await client.PostAsync($"/admin/roles/{roleId}", content);

response.StatusCode.Should().Be(HttpStatusCode.Redirect);

var role = await FindRoleByIdAsync(roleId);
role.Should().NotBeNull();
role!.Name.Should().Be(newName);
role.Description.Should().Be("Updated description");
}

[Fact]
public async Task DeleteRole_NoUsers_Redirects()
public async Task DeleteRole_NoUsers_RemovesRole()
{
var roleId = await SeedTestRoleAsync();
var client = CreateAdminClient();

var response = await client.DeleteAsync($"/admin/roles/{roleId}");

response.StatusCode.Should().Be(HttpStatusCode.Redirect);

(await FindRoleByIdAsync(roleId)).Should().BeNull();
}

[Fact]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,15 @@ private async Task<string> SeedTestUserAsync()
return userId;
}

private async Task<ApplicationUser> FetchUserAsync(string userId)
{
using var scope = _factory.Services.CreateScope();
var userManager = scope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>();
var user = await userManager.FindByIdAsync(userId);
user.Should().NotBeNull($"user {userId} should exist for assertion");
return user!;
}

[Fact]
public async Task GetUsers_AsAdmin_Returns200()
{
Expand Down Expand Up @@ -88,19 +97,6 @@ public async Task GetUsersCreate_AsAdmin_Returns200()
response.StatusCode.Should().Be(HttpStatusCode.OK);
}

[Fact(
Skip = "UsersEditEndpoint depends on PermissionRegistry which requires full module initialization in test setup"
)]
public async Task GetUsersEdit_ExistingUser_Returns200()
{
var userId = await SeedTestUserAsync();
var client = CreateAdminClient();

var response = await client.GetAsync($"/admin/users/{userId}/edit");

response.StatusCode.Should().Be(HttpStatusCode.OK);
}

[Fact]
public async Task GetUsersEdit_NonExistentUser_Returns404()
{
Expand All @@ -112,83 +108,114 @@ public async Task GetUsersEdit_NonExistentUser_Returns404()
}

[Fact]
public async Task UpdateUser_ValidData_Redirects()
public async Task UpdateUser_ValidData_PersistsDisplayNameAndEmail()
{
var userId = await SeedTestUserAsync();
var newEmail = $"updated-{userId[..8]}@example.com";
var client = CreateAdminClient();

using var content = new FormUrlEncodedContent(
new Dictionary<string, string>
{
["displayName"] = "Updated Name",
["email"] = $"updated-{userId[..8]}@example.com",
["email"] = newEmail,
}
);

var response = await client.PostAsync($"/admin/users/{userId}", content);

response.StatusCode.Should().Be(HttpStatusCode.Redirect);

var user = await FetchUserAsync(userId);
user.DisplayName.Should().Be("Updated Name");
user.Email.Should().Be(newEmail);
}

[Fact]
public async Task LockUser_ValidUser_Redirects()
public async Task LockUser_SetsLockoutEndInFuture()
{
var userId = await SeedTestUserAsync();
var client = CreateAdminClient();

var response = await client.PostAsync($"/admin/users/{userId}/lock", null);

response.StatusCode.Should().Be(HttpStatusCode.Redirect);

var user = await FetchUserAsync(userId);
user.LockoutEnd.Should().NotBeNull();
user.LockoutEnd!.Value.Should().BeAfter(DateTimeOffset.UtcNow);
}

[Fact]
public async Task UnlockUser_ValidUser_Redirects()
public async Task UnlockUser_ClearsLockout()
{
var userId = await SeedTestUserAsync();
var client = CreateAdminClient();

var response = await client.PostAsync($"/admin/users/{userId}/unlock", null);
await client.PostAsync($"/admin/users/{userId}/lock", null);
(await FetchUserAsync(userId)).LockoutEnd.Should().NotBeNull();

var response = await client.PostAsync($"/admin/users/{userId}/unlock", null);
response.StatusCode.Should().Be(HttpStatusCode.Redirect);

var user = await FetchUserAsync(userId);
// UnlockAccountAsync sets LockoutEnd to null or to a past timestamp.
(user.LockoutEnd is null || user.LockoutEnd.Value <= DateTimeOffset.UtcNow)
.Should()
.BeTrue();
}

[Fact]
public async Task DeactivateUser_ValidUser_Redirects()
public async Task DeactivateUser_SetsDeactivatedAt()
{
var userId = await SeedTestUserAsync();
var client = CreateAdminClient();

var response = await client.PostAsync($"/admin/users/{userId}/deactivate", null);

response.StatusCode.Should().Be(HttpStatusCode.Redirect);

var user = await FetchUserAsync(userId);
user.DeactivatedAt.Should().NotBeNull();
user.DeactivatedAt!.Value.Should()
.BeCloseTo(DateTimeOffset.UtcNow, TimeSpan.FromMinutes(1));
}

[Fact]
public async Task ReactivateUser_ValidUser_Redirects()
public async Task ReactivateUser_ClearsDeactivatedAt()
{
var userId = await SeedTestUserAsync();
var client = CreateAdminClient();

await client.PostAsync($"/admin/users/{userId}/deactivate", null);
(await FetchUserAsync(userId)).DeactivatedAt.Should().NotBeNull();

var response = await client.PostAsync($"/admin/users/{userId}/reactivate", null);

response.StatusCode.Should().Be(HttpStatusCode.Redirect);

(await FetchUserAsync(userId)).DeactivatedAt.Should().BeNull();
}

[Fact]
public async Task ResetPassword_ValidData_Redirects()
public async Task ResetPassword_ReplacesPasswordHash_NewPasswordAuthenticates()
{
var userId = await SeedTestUserAsync();
var oldHash = (await FetchUserAsync(userId)).PasswordHash;
var client = CreateAdminClient();

using var content = new FormUrlEncodedContent(
new Dictionary<string, string> { ["newPassword"] = "NewTestPass456!" }
);

var response = await client.PostAsync($"/admin/users/{userId}/reset-password", content);

response.StatusCode.Should().Be(HttpStatusCode.Redirect);

using var scope = _factory.Services.CreateScope();
var userManager = scope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>();
var user = await userManager.FindByIdAsync(userId);
user.Should().NotBeNull();
user!.PasswordHash.Should().NotBe(oldHash, "password reset must replace the hash");
(await userManager.CheckPasswordAsync(user, "NewTestPass456!")).Should().BeTrue();
(await userManager.CheckPasswordAsync(user, "TestPass123!"))
.Should()
.BeFalse("the old password must no longer authenticate");
}

[Fact]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,17 +77,6 @@ public async Task GetById_Unauthenticated_Returns401()

// --- POST /api/jobs/{id}/cancel ---

[Fact]
public async Task Cancel_WithManagePermission_DoesNotReturn401()
{
var client = _factory.CreateAuthenticatedClient([BackgroundJobsPermissions.ManageJobs]);

var response = await client.PostAsync($"/api/jobs/{Guid.NewGuid()}/cancel", null);

// May fail with 404/500 since no real job exists, but should not be 401
response.StatusCode.Should().NotBe(HttpStatusCode.Unauthorized);
}

[Fact]
public async Task Cancel_WithViewOnlyPermission_Returns403()
{
Expand All @@ -101,13 +90,13 @@ public async Task Cancel_WithViewOnlyPermission_Returns403()
// --- POST /api/jobs/{id}/retry ---

[Fact]
public async Task Retry_WithManagePermission_DoesNotReturn401()
public async Task Retry_WithViewOnlyPermission_Returns403()
{
var client = _factory.CreateAuthenticatedClient([BackgroundJobsPermissions.ManageJobs]);
var client = _factory.CreateAuthenticatedClient([BackgroundJobsPermissions.ViewJobs]);

var response = await client.PostAsync($"/api/jobs/{Guid.NewGuid()}/retry", null);

response.StatusCode.Should().NotBe(HttpStatusCode.Unauthorized);
response.StatusCode.Should().Be(HttpStatusCode.Forbidden);
}

[Fact]
Expand Down Expand Up @@ -167,13 +156,13 @@ public async Task ToggleRecurring_ViewOnly_Returns403()
// --- DELETE /api/jobs/recurring/{id} ---

[Fact]
public async Task DeleteRecurring_WithManagePermission_DoesNotReturn401()
public async Task DeleteRecurring_WithViewOnlyPermission_Returns403()
{
var client = _factory.CreateAuthenticatedClient([BackgroundJobsPermissions.ManageJobs]);
var client = _factory.CreateAuthenticatedClient([BackgroundJobsPermissions.ViewJobs]);

var response = await client.DeleteAsync($"/api/jobs/recurring/{Guid.NewGuid()}");

response.StatusCode.Should().NotBe(HttpStatusCode.Unauthorized);
response.StatusCode.Should().Be(HttpStatusCode.Forbidden);
}

[Fact]
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
using System.Net;
using System.Net.Http.Json;
using System.Security.Claims;
using System.Text.Json;
using FluentAssertions;
using SimpleModule.Core.Broadcasting;
using SimpleModule.Core.Inertia;
using SimpleModule.Tests.Shared.Fixtures;

namespace SimpleModule.Dashboard.Tests.Integration;

[Collection(TestCollections.Integration)]
public class BroadcastingEndpointTests
{
private readonly SimpleModuleWebApplicationFactory _factory;

public BroadcastingEndpointTests(SimpleModuleWebApplicationFactory factory) =>
_factory = factory;

private static void AddInertiaHeaders(HttpClient client)
{
client.DefaultRequestHeaders.Add("X-Inertia", "true");
client.DefaultRequestHeaders.Add("X-Inertia-Version", InertiaMiddleware.Version);
}

[Fact]
public async Task Get_Authenticated_Renders_Channel_For_Current_User()
{
const string userId = "user-broadcast-1";
var client = _factory.CreateAuthenticatedClient(
new Claim(ClaimTypes.NameIdentifier, userId)
);
AddInertiaHeaders(client);

var response = await client.GetAsync("/broadcasting");

response.StatusCode.Should().Be(HttpStatusCode.OK);
var json = await response.Content.ReadFromJsonAsync<JsonElement>();
json.GetProperty("component").GetString().Should().Be("Dashboard/Broadcasting");

var props = json.GetProperty("props");
props.GetProperty("userId").GetString().Should().Be(userId);
props.GetProperty("channel").GetString().Should().Be(BroadcastChannels.ForUser(userId));
props.GetProperty("fireUrl").GetString().Should().Be("/api/dashboard/broadcasting/tick");
}

[Fact]
public async Task Tick_Anonymous_Returns_401()
{
var client = _factory.CreateClient();

var response = await client.PostAsync("/api/dashboard/broadcasting/tick", null);

response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
}

[Fact]
public async Task Tick_Authenticated_Returns_NoContent()
{
var client = _factory.CreateAuthenticatedClient(
new Claim(ClaimTypes.NameIdentifier, "user-broadcast-2")
);

var response = await client.PostAsync("/api/dashboard/broadcasting/tick", null);

response.StatusCode.Should().Be(HttpStatusCode.NoContent);
}
}
Loading
Loading