Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
8191ee1
UKPS-306 - Added authorisation logic to the deactivate membership met…
Jul 10, 2026
e745447
Merge branch 'main' of https://github.com/ditnice/aws-UKPS into featu…
Jul 10, 2026
6cf4426
UKPS-306 - Updated organisation service to include authorisation logic.
Jul 10, 2026
c7cfe04
UKPS-306 - Updated the UserService to have organisation based authori…
Jul 10, 2026
bf02f6b
UKPS-306 - Updated variable name to be descriptive.
Jul 10, 2026
c93b9fd
UKPS-306 - Implemented a service for pulling the current user informa…
Jul 10, 2026
f64e844
UKPS-306 - Tidied up test implementation with test harness.
Jul 13, 2026
b119fec
UKPS-306 - Fixed issue where current user was not being correctly moc…
Jul 13, 2026
d8c28c8
UKPS-306 - Tidied up implementation of WebApiCurrentUserInfoService.cs
Jul 13, 2026
fdbe877
UKPS-306 - Created a framework for running same tests on api and web …
Jul 13, 2026
01ca861
Merge branch 'main' of https://github.com/ditnice/aws-UKPS into chore…
Jul 13, 2026
ef31c55
UKPS-306 - Merged in main to get latest changes and fix merge conflicts.
Jul 13, 2026
278dc93
UKPS-306 - Added justification for the suppression of warning.
Jul 13, 2026
41964bb
UKPS-306 - Implemented the authorisation logic in the presentation la…
Jul 13, 2026
cee5c36
UKPS-306 - Merged in main and fixed merged conflicts.
Jul 14, 2026
b23e8a9
UKPS-306 - Removed old test services fixture class.
Jul 14, 2026
ab5de04
UKPS-306 - Merged in main to fix merge conflicts.
Jul 15, 2026
1b9aad6
UKPS-306 - Removed the integation tests as requested during the pull …
Jul 16, 2026
114028f
UKPS-306 - Tidied up tests removing unecessary tests.
Jul 16, 2026
5a2e177
UKPS-306 - Merged in master to fix merge conflicts.
Jul 16, 2026
7a0377c
UKPS-306 - Removed uneeded supression.
Jul 16, 2026
abc3898
UKPS-306 - Removed duplicate constructor on test class.
Jul 16, 2026
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
2 changes: 2 additions & 0 deletions backend/src/Controllers/OrganisationController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ CancellationToken cancellationToken
error switch
{
GetOrganisationByIdError.NotFound => NotFound(),
GetOrganisationByIdError.NotAllowed => Forbid(),
_ => throw new UnreachableException(
"Unhandled GetOrganisationByIdError variant."
),
Expand Down Expand Up @@ -60,6 +61,7 @@ CancellationToken cancellationToken
error switch
{
UpdateOrganisationDetailsError.NotFound => NotFound(),
UpdateOrganisationDetailsError.NotAllowed => Forbid(),
_ => throw new UnreachableException(
"Unhandled UpdateOrganisationDetailsError variant."
),
Expand Down
7 changes: 7 additions & 0 deletions backend/src/Controllers/Utilities/UkpsClaimTypes.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
namespace UKPS.Api.Controllers.Utilities;

public static class UkpsClaimTypes
{
public const string OrganisationId = "organisation_id";
public const string UserRole = "user_role";
}
44 changes: 44 additions & 0 deletions backend/src/Controllers/Utilities/WebApiCurrentUserInfoService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
using System.Globalization;
using System.Security.Claims;
using UKPS.Api.Enums;
using UKPS.Api.Services.Interfaces;

namespace UKPS.Api.Controllers.Utilities;

internal class WebApiCurrentUserInfoService : ICurrentUserInfoService
{
private readonly IHttpContextAccessor _httpContextAccessor;

private ClaimsPrincipal Principal =>
_httpContextAccessor.HttpContext?.User ?? new ClaimsPrincipal();

public WebApiCurrentUserInfoService(IHttpContextAccessor httpContextAccessor)
{
_httpContextAccessor = httpContextAccessor;
}

public CurrentUser GetCurrentUserInfo()
{
return new CurrentUser { OrganisationId = FindOrganisationId(), UserRole = FindUserRole() };
}

private int FindOrganisationId()
{
string? organisationIdClaim = Principal.FindFirstValue(UkpsClaimTypes.OrganisationId);
return int.TryParse(organisationIdClaim, CultureInfo.InvariantCulture, out var orgId)
? orgId
: throw new InvalidOperationException(
$"Invalid {UkpsClaimTypes.OrganisationId} claim value."
);
}

private UserRole FindUserRole()
{
string? userRoleClaim = Principal.FindFirstValue(UkpsClaimTypes.UserRole);
return Enum.TryParse<UserRole>(userRoleClaim, out var role) && Enum.IsDefined(role)
? role
: throw new InvalidOperationException(
$"Invalid {UkpsClaimTypes.UserRole} claim value."
);
}
}
4 changes: 4 additions & 0 deletions backend/src/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.OpenApi;
using Scalar.AspNetCore;
using UKPS.Api.Controllers.Utilities;
using UKPS.Api.Data;
using UKPS.Api.Services;
using UKPS.Api.Services.Interfaces;

var builder = WebApplication.CreateBuilder(args);

Expand All @@ -18,6 +20,8 @@
);

// Add services to the container.
builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<ICurrentUserInfoService, WebApiCurrentUserInfoService>();
builder.Services.AddUkpsServices();

builder.Services.ConfigureHttpJsonOptions(options =>
Expand Down
1 change: 0 additions & 1 deletion backend/src/Services/DependencyInjectionManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ internal static class DependencyInjectionManager
{
public static IServiceCollection AddUkpsServices(this IServiceCollection services)
{
services.TryAddScoped<ICurrentUserInfoService, MockCurrentUserInfoService>();
services.TryAddScoped<IOrganisationAuthoriser, OrganisationAuthoriser>();
services.TryAddScoped<IOrganisationService, OrganisationService>();
services.TryAddScoped<IOrganisationMembershipService, OrganisationMembershipService>();
Expand Down
43 changes: 43 additions & 0 deletions backend/tests/Controllers/OrganisationControllerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,25 @@ public async Task GetOrganisationById_OrganisationDoesNotExist_ReturnsNotFound()
result.Result.ShouldBeOfType<NotFoundResult>();
}

[Fact]
public async Task GetOrganisationById_ActionNotAllowed_ReturnsForbidden()
{
var sampleOrganisationId = 1;
_organisationServiceMock
.GetOrganisationById(sampleOrganisationId, TestContext.Current.CancellationToken)
.Returns(
Result<OrganisationDetailsDto, GetOrganisationByIdError>.Err(
new GetOrganisationByIdError.NotAllowed(sampleOrganisationId)
)
);
ActionResult<OrganisationDetailsDto> result = await _controller.GetOrganisationById(
1,
TestContext.Current.CancellationToken
);

result.Result.ShouldBeOfType<ForbidResult>();
}

[Fact]
public async Task GetOrganisationById_IdProvided_PassesIdToService()
{
Expand Down Expand Up @@ -120,6 +139,30 @@ public async Task UpdateOrganisationDetails_OrganisationExists_ReturnsOk()
ok.Value.ShouldBe(expected);
}

[Fact]
public async Task UpdateOrganisation_ActionNotAllowed_ReturnsForbidden()
{
var sampleOrganisationId = 1;
_organisationServiceMock
.UpdateOrganisationDetails(
sampleOrganisationId,
Arg.Any<UpdateOrganisationDetailsDto>(),
TestContext.Current.CancellationToken
)
.Returns(
Result<OrganisationDetailsDto, UpdateOrganisationDetailsError>.Err(
new UpdateOrganisationDetailsError.NotAllowed(sampleOrganisationId)
)
);
ActionResult<OrganisationDetailsDto> result = await _controller.UpdateOrganisationDetails(
sampleOrganisationId,
CreateUpdateOrganisationDetailsDto(),
TestContext.Current.CancellationToken
);

result.Result.ShouldBeOfType<ForbidResult>();
}

[Fact]
public async Task UpdateOrganisationDetails_OrganisationDoesNotExist_ReturnsNotFound()
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
using System.Security.Claims;
using Bogus;
using Microsoft.AspNetCore.Http;
using NSubstitute;
using Shouldly;
using UKPS.Api.Controllers.Utilities;
using UKPS.Api.Enums;
using UKPS.Api.Services.Interfaces;

namespace UKPS.Api.Tests.Controllers.Utilities;

public class WebApiCurrentUserInfoServiceTests
{
[Fact]
public void GetCurrentUserInfo_ShouldReturnCurrentUser_WhenClaimsAreValid()
{
Randomizer.Seed = new Random(0);
var faker = new Faker();
foreach (var userRole in Enum.GetValues<UserRole>())
{
var organisationId = faker.Random.Int(min: 0);
var service = CreateService(
new Claim(UkpsClaimTypes.OrganisationId, $"{organisationId}"),
new Claim(UkpsClaimTypes.UserRole, userRole.ToString())
);

CurrentUser result = service.GetCurrentUserInfo();

result.OrganisationId.ShouldBe(organisationId);
result.UserRole.ShouldBe(userRole);
}
}

[Fact]
public void GetCurrentUserInfo_ShouldThrow_WhenOrganisationIdClaimIsMissing()
{
var service = CreateService(
new Claim(UkpsClaimTypes.UserRole, UserRole.Champion.ToString())
);

var exception = Should.Throw<InvalidOperationException>(() => service.GetCurrentUserInfo());

exception.Message.ShouldBe($"Invalid {UkpsClaimTypes.OrganisationId} claim value.");
}

[Theory]
[InlineData("")]
[InlineData("abc")]
[InlineData("1.5")]
public void GetCurrentUserInfo_ShouldThrow_WhenOrganisationIdClaimIsInvalid(string claimValue)
{
var service = CreateService(
new Claim(UkpsClaimTypes.OrganisationId, claimValue),
new Claim(UkpsClaimTypes.UserRole, UserRole.Super.ToString())
);

var exception = Should.Throw<InvalidOperationException>(() => service.GetCurrentUserInfo());

exception.Message.ShouldBe($"Invalid {UkpsClaimTypes.OrganisationId} claim value.");
}

[Fact]
public void GetCurrentUserInfo_ShouldThrow_WhenUserRoleClaimIsMissing()
{
var service = CreateService(new Claim(UkpsClaimTypes.OrganisationId, "123"));

var exception = Should.Throw<InvalidOperationException>(() => service.GetCurrentUserInfo());

exception.Message.ShouldBe($"Invalid {UkpsClaimTypes.UserRole} claim value.");
}

[Theory]
[InlineData("123")]
public void GetCurrentUserInfo_ShouldThrow_WhenUserRoleClaimIsInvalid(string claimValue)
{
var service = CreateService(
new Claim(UkpsClaimTypes.OrganisationId, "123"),
new Claim(UkpsClaimTypes.UserRole, claimValue)
);

var exception = Should.Throw<InvalidOperationException>(() => service.GetCurrentUserInfo());

exception.Message.ShouldBe($"Invalid {UkpsClaimTypes.UserRole} claim value.");
}

[Fact]
public void GetCurrentUserInfo_ShouldThrow_WhenHttpContextIsNull()
{
var accessor = Substitute.For<IHttpContextAccessor>();
accessor.HttpContext.Returns((HttpContext?)null);

var service = new WebApiCurrentUserInfoService(accessor);

Should.Throw<InvalidOperationException>(() => service.GetCurrentUserInfo());
}

private static WebApiCurrentUserInfoService CreateService(params Claim[] claims)
{
var httpContext = new DefaultHttpContext
{
User = new ClaimsPrincipal(new ClaimsIdentity(claims, "Test")),
};

var accessor = Substitute.For<IHttpContextAccessor>();
accessor.HttpContext.Returns(httpContext);

return new WebApiCurrentUserInfoService(accessor);
}
}
17 changes: 17 additions & 0 deletions backend/tests/Fixtures/ApiFactory.cs
Original file line number Diff line number Diff line change
@@ -1,14 +1,31 @@
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.DependencyInjection;

namespace UKPS.Api.Tests.Fixtures;

public sealed class ApiFactory(string connectionString) : WebApplicationFactory<Program>
{
public TestAuthenticationOptions AuthOptions { get; } = new TestAuthenticationOptions();

protected override void ConfigureWebHost(IWebHostBuilder builder)
{
ArgumentNullException.ThrowIfNull(builder);

builder.ConfigureTestServices(services =>
{
services.AddSingleton(AuthOptions);

services
.AddAuthentication(TestAuthHandler.AuthenticationScheme)
.AddScheme<AuthenticationSchemeOptions, TestAuthHandler>(
TestAuthHandler.AuthenticationScheme,
_ => { }
);
});

builder.UseSetting("ConnectionStrings:DefaultConnection", connectionString);
}
}
33 changes: 33 additions & 0 deletions backend/tests/Fixtures/TestAuthHandler.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
using System.Security.Claims;
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;

namespace UKPS.Api.Tests.Fixtures;

public class TestAuthHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
public const string AuthenticationScheme = "TestScheme";
private readonly TestAuthenticationOptions _authOptions;

public TestAuthHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder,
TestAuthenticationOptions authOptions
)
: base(options, logger, encoder)
{
_authOptions = authOptions;
}

protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
var identity = new ClaimsIdentity(_authOptions.Claims);

var ticket = new AuthenticationTicket(new ClaimsPrincipal(identity), AuthenticationScheme);

return Task.FromResult(AuthenticateResult.Success(ticket));
}
}
16 changes: 16 additions & 0 deletions backend/tests/Fixtures/TestAuthenticationOptions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
using System.Security.Claims;
using UKPS.Api.Controllers.Utilities;
using UKPS.Api.Enums;

namespace UKPS.Api.Tests.Fixtures;

public class TestAuthenticationOptions
{
public static IReadOnlyCollection<Claim> DefaultClaims { get; } =
[
new Claim(UkpsClaimTypes.UserRole, UserRole.Super.ToString()),
new Claim(UkpsClaimTypes.OrganisationId, $"{1}"),
];

public ICollection<Claim> Claims { get; } = DefaultClaims.ToList();
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
using UKPS.Api.Tests.Utilities.AssertionHelpers;
using UKPS.Api.Tests.Utilities.Data;
using UKPS.Api.Tests.Utilities.Data.Fakers;
using UKPS.Api.Tests.Utilities.Harnesses;

namespace UKPS.Api.Tests.Services;

Expand Down
Loading