diff --git a/src/Dfe.SignIn.AppHost/AppHost.cs b/src/Dfe.SignIn.AppHost/AppHost.cs index ef020857..fa59f59b 100644 --- a/src/Dfe.SignIn.AppHost/AppHost.cs +++ b/src/Dfe.SignIn.AppHost/AppHost.cs @@ -27,6 +27,11 @@ var internalApiConfig = builder.Configuration.GetSection("InternalApiClient"); var redisConnectionString = redis.Resource.ConnectionStringExpression; +// EntityFramework configuration sections +var efOrganisationsConfig = builder.Configuration.GetSection("EntityFramework:Organisations"); +var efDirectoriesConfig = builder.Configuration.GetSection("EntityFramework:Directories"); +var efAuditConfig = builder.Configuration.GetSection("EntityFramework:Audit"); + builder.AddProject("app-help", launchProfileName: "http") .WithSharedConfiguration(builder.Configuration, frontend.GetEndpoint("http")) .WithEnvironment("InteractionsRedisCache__ConnectionString", redisConnectionString) @@ -64,8 +69,20 @@ .WithEnvironment("SelectOrganisation__SelectOrganisationBaseAddress", selectOrgConfig["SelectOrganisationBaseAddress"]) .WithEnvironment("InternalApiClient__Access__BaseAddress", internalApiConfig["Access:BaseAddress"]) .WithEnvironment("InternalApiClient__Organisations__BaseAddress", internalApiConfig["Organisations:BaseAddress"]) + .WithEnvironment("EntityFramework__Organisations__Username", efOrganisationsConfig["Username"]) + .WithEnvironment("EntityFramework__Organisations__Password", efOrganisationsConfig["Password"]) + .WithEnvironment("EntityFramework__Organisations__Name", efOrganisationsConfig["Name"]) + .WithEnvironment("EntityFramework__Organisations__Host", efOrganisationsConfig["Host"]) + .WithEnvironment("EntityFramework__Directories__Username", efDirectoriesConfig["Username"]) + .WithEnvironment("EntityFramework__Directories__Password", efDirectoriesConfig["Password"]) + .WithEnvironment("EntityFramework__Directories__Name", efDirectoriesConfig["Name"]) + .WithEnvironment("EntityFramework__Directories__Host", efDirectoriesConfig["Host"]) + .WithEnvironment("EntityFramework__Audit__Username", efAuditConfig["Username"]) + .WithEnvironment("EntityFramework__Audit__Password", efAuditConfig["Password"]) + .WithEnvironment("EntityFramework__Audit__Name", efAuditConfig["Name"]) + .WithEnvironment("EntityFramework__Audit__Host", efAuditConfig["Host"]) .WaitFor(redis); builder.AddExecutable("tool-tls-proxy", "pwsh", "../../", "-Command", "Start-DsiTlsProxy"); -builder.Build().Run(); +await builder.Build().RunAsync(); diff --git a/src/Dfe.SignIn.Base.Framework/Extensions/DateTimeExtensions.cs b/src/Dfe.SignIn.Base.Framework/Extensions/DateTimeExtensions.cs new file mode 100644 index 00000000..910b9973 --- /dev/null +++ b/src/Dfe.SignIn.Base.Framework/Extensions/DateTimeExtensions.cs @@ -0,0 +1,24 @@ +namespace Dfe.SignIn.Base.Framework.Extensions; + +/// +/// Extensions for DateTime to handle conversion to UTC, treating unspecified kinds as UTC by default. +/// +public static class DateTimeExtensions +{ + /// + /// Returns the UTC equivalent of the given DateTime. If the input DateTime has an + /// unspecified kind, it will be treated as UTC. + /// + /// The DateTime to convert to UTC. + /// The UTC equivalent of the given DateTime. + public static DateTime ToUtc(this DateTime date) + => date.Kind == DateTimeKind.Unspecified ? DateTime.SpecifyKind(date, DateTimeKind.Utc) : date.ToUniversalTime(); + + /// + /// Returns the UTC equivalent of the given DateTime, or null if the input is null. + /// + /// The nullable DateTime to convert to UTC. + /// The UTC equivalent of the given DateTime, or null if the input is null. + public static DateTime? ToUtc(this DateTime? date) + => date.HasValue ? ToUtc(date.Value) : null; +} diff --git a/src/Dfe.SignIn.Core.Contracts/Access/ApplicationRole.cs b/src/Dfe.SignIn.Core.Contracts/Access/ApplicationRole.cs index b619c393..dbbc7c57 100644 --- a/src/Dfe.SignIn.Core.Contracts/Access/ApplicationRole.cs +++ b/src/Dfe.SignIn.Core.Contracts/Access/ApplicationRole.cs @@ -30,7 +30,7 @@ public sealed record ApplicationRole /// /// The numeric identifier of the role. /// - public required int NumericId { get; init; } + public required long NumericId { get; init; } /// /// A value indicating the status of the role. diff --git a/src/Dfe.SignIn.Core.Contracts/Access/GetRolesOfApplication.cs b/src/Dfe.SignIn.Core.Contracts/Access/GetApplicationRolesRequest.cs similarity index 68% rename from src/Dfe.SignIn.Core.Contracts/Access/GetRolesOfApplication.cs rename to src/Dfe.SignIn.Core.Contracts/Access/GetApplicationRolesRequest.cs index a2ecc1a8..3a91ed4a 100644 --- a/src/Dfe.SignIn.Core.Contracts/Access/GetRolesOfApplication.cs +++ b/src/Dfe.SignIn.Core.Contracts/Access/GetApplicationRolesRequest.cs @@ -5,8 +5,8 @@ namespace Dfe.SignIn.Core.Contracts.Access; /// /// Request to get the roles that are associated with an application. /// -[AssociatedResponse(typeof(GetRolesOfApplicationResponse))] -public sealed record GetRolesOfApplicationRequest +[AssociatedResponse(typeof(GetApplicationRolesResponse))] +public sealed record GetApplicationRolesRequest { /// /// The unique identifier of the application. @@ -15,9 +15,9 @@ public sealed record GetRolesOfApplicationRequest } /// -/// Response model for request . +/// Response model for request . /// -public sealed record GetRolesOfApplicationResponse +public sealed record GetApplicationRolesResponse { /// /// An enumerable collection of application roles. diff --git a/src/Dfe.SignIn.Core.Contracts/Applications/Application.cs b/src/Dfe.SignIn.Core.Contracts/Applications/Application.cs index 05e737cb..2255686b 100644 --- a/src/Dfe.SignIn.Core.Contracts/Applications/Application.cs +++ b/src/Dfe.SignIn.Core.Contracts/Applications/Application.cs @@ -45,4 +45,14 @@ public sealed record Application /// page if possible. Role based services are not hidden. /// public required bool IsHiddenService { get; init; } + + /// + /// Parent application ID, if this application is a child of another application. + /// + public Guid? ParentId { get; init; } + + /// + /// The unique client identifier (e.g., "GIAS") of the parent application, if this application is a child of another application. + /// + public string? ParentClientId { get; init; } } diff --git a/src/Dfe.SignIn.Core.Contracts/Applications/ApplicationRole.cs b/src/Dfe.SignIn.Core.Contracts/Applications/ApplicationRole.cs new file mode 100644 index 00000000..4fadd6c7 --- /dev/null +++ b/src/Dfe.SignIn.Core.Contracts/Applications/ApplicationRole.cs @@ -0,0 +1,56 @@ +using System.ComponentModel.DataAnnotations; + +namespace Dfe.SignIn.Core.Contracts.Applications; + +/// +/// A model representing a role in DfE Sign-in. +/// +public sealed record ApplicationRole +{ + /// + /// The unique value that identifies the role. + /// + public required Guid Id { get; init; } + + /// + /// The parent role. + /// + public ApplicationRole? Parent { get; init; } = null; + + /// + /// The code of the role. + /// + public required string Code { get; init; } + + /// + /// The name of the role. + /// + public required string Name { get; init; } + + /// + /// The numeric identifier of the role. + /// + public required long NumericId { get; init; } + + /// + /// A value indicating the status of the role. + /// + [EnumDataType(typeof(ApplicationRoleStatus))] + public required ApplicationRoleStatus Status { get; init; } +} + +/// +/// Represents the status of a role. +/// +public enum ApplicationRoleStatus +{ + /// + /// Indicates that a role is inactive. + /// + Inactive = 0, + + /// + /// Indicates that a role is active. + /// + Active = 1, +} diff --git a/src/Dfe.SignIn.Core.Contracts/Applications/GetApplicationRolesRequest.cs b/src/Dfe.SignIn.Core.Contracts/Applications/GetApplicationRolesRequest.cs new file mode 100644 index 00000000..1b08647b --- /dev/null +++ b/src/Dfe.SignIn.Core.Contracts/Applications/GetApplicationRolesRequest.cs @@ -0,0 +1,26 @@ +using Dfe.SignIn.Base.Framework; + +namespace Dfe.SignIn.Core.Contracts.Applications; + +/// +/// Request to get the roles that are associated with an application. +/// +[AssociatedResponse(typeof(GetApplicationRolesResponse))] +public sealed record GetApplicationRolesRequest +{ + /// + /// The unique identifier of the application. + /// + public required Guid ApplicationId { get; init; } +} + +/// +/// Response model for request . +/// +public sealed record GetApplicationRolesResponse +{ + /// + /// An enumerable collection of application roles. + /// + public required IEnumerable Roles { get; init; } +} diff --git a/src/Dfe.SignIn.Core.Contracts/Organisations/OrganisationRole.cs b/src/Dfe.SignIn.Core.Contracts/Organisations/OrganisationRole.cs new file mode 100644 index 00000000..5503db9c --- /dev/null +++ b/src/Dfe.SignIn.Core.Contracts/Organisations/OrganisationRole.cs @@ -0,0 +1,40 @@ +namespace Dfe.SignIn.Core.Contracts.Organisations; + +/// +/// Represents an organisation role with an identifier and a display name. +/// +/// The unique short identifier for the role. +/// The display name of the role. +public sealed record OrganisationRole(short Id, string Name); + +/// +/// Provides static references and lookup methods for well-known organisation roles. +/// +public static class OrganisationRoles +{ + /// + /// The standard end user role (Id = 0, Name = "End user"). + /// + public static readonly OrganisationRole EndUser = new(0, "End user"); + + /// + /// The approver role (Id = 10000, Name = "Approver"). + /// + public static readonly OrganisationRole Approver = new(10000, "Approver"); + + /// + /// Internal lookup dictionary for roles by their short Id. + /// + private static readonly IReadOnlyDictionary ById = + new Dictionary { + [EndUser.Id] = EndUser, + [Approver.Id] = Approver + }; + + /// + /// Gets a known by its Id, or null if not found. + /// + /// The short Id of the role. + /// The matching , or null if not found. + public static OrganisationRole? FromId(short roleId) => ById.TryGetValue(roleId, out var role) ? role : null; +} diff --git a/src/Dfe.SignIn.Core.Contracts/Users/GetServiceUsersRequest.cs b/src/Dfe.SignIn.Core.Contracts/Users/GetServiceUsersRequest.cs new file mode 100644 index 00000000..708eb0a8 --- /dev/null +++ b/src/Dfe.SignIn.Core.Contracts/Users/GetServiceUsersRequest.cs @@ -0,0 +1,504 @@ +using System.Text.Json.Serialization; +using Dfe.SignIn.Base.Framework; + +namespace Dfe.SignIn.Core.Contracts.Users; + +/// +/// Request for retrieving service users with pagination and optional filters. +/// +[AssociatedResponse(typeof(GetServiceUsersResponse))] +public sealed record GetServiceUsersRequest +{ + /// + /// The unique identifier of the application for which to retrieve service users. + /// + public required Guid ApplicationId { get; set; } + + /// + /// The page number to retrieve (1-based). + /// + public required int PageNumber { get; init; } + + /// + /// The number of records per page. + /// + public required int PageSize { get; init; } +} + +/// +/// Represents an organisation associated with a service user. +/// +public sealed record ServiceUserOrganisationDto +{ + /// + /// The unique identifier of the organisation. + /// + [JsonPropertyName("id")] + public required Guid Id { get; init; } + + /// + /// The unique identifier of the organisation (alternate property). + /// + [JsonPropertyName("organisation_id")] + public required Guid OrganisationId { get; init; } + + /// + /// The unique identifier of the user associated with the organisation. + /// + [JsonPropertyName("user_id")] + public Guid? UserId { get; init; } + + /// + /// The status of the user associated with the organisation. + /// + [JsonPropertyName("user_status")] + public int? UserStatus { get; init; } + + /// + /// The date the user was created. + /// + [JsonPropertyName("user_createdAt")] + public DateTime? UserCreatedAt { get; init; } + + /// + /// The date the user was last updated. + /// + [JsonPropertyName("user_updatedAt")] + public DateTime? UserUpdatedAt { get; init; } + + /// + /// The date the organisation record was created. + /// + [JsonPropertyName("createdAt")] + public DateTime? CreatedAt { get; init; } + + /// + /// The date the organisation record was last updated. + /// + [JsonPropertyName("updatedAt")] + public DateTime? UpdatedAt { get; init; } + + /// + /// The name of the organisation. + /// + [JsonPropertyName("name")] + public required string Name { get; init; } + + /// + /// The region code of the organisation. + /// + [JsonPropertyName("regionCode")] + public string? RegionCode { get; set; } + + /// + /// The category of the organisation. + /// + [JsonPropertyName("Category")] + public string? Category { get; init; } + + /// + /// The alternate district administrative name. + /// + [JsonPropertyName("DistrictAdministrative_name")] + public string? DistrictAdministrativeNameAlt { get; init; } + + /// + /// The mastering code of the organisation. + /// + [JsonPropertyName("masteringCode")] + public string? MasteringCode { get; init; } + + /// + /// The provider profile ID. + /// + [JsonPropertyName("ProviderProfileID")] + public string? ProviderProfileID { get; init; } + + /// + /// The source system of the organisation data. + /// + [JsonPropertyName("SourceSystem")] + public string? SourceSystem { get; init; } + + /// + /// The UPIN (Unique Provider Identification Number). + /// + [JsonPropertyName("UPIN")] + public string? Upin { get; init; } + + /// + /// The provider type name from GIAS. + /// + [JsonPropertyName("ProviderTypeName")] + public string? ProviderTypeName { get; init; } + + /// + /// The provider type from GIAS. + /// + [JsonPropertyName("GIASProviderType")] + public string? GiasProviderType { get; init; } + + /// + /// The provider type from PIMS. + /// + [JsonPropertyName("PIMSProviderType")] + public string? PimsProviderType { get; init; } + + /// + /// The provider type code. + /// + [JsonPropertyName("ProviderTypeCode")] + public int? ProviderTypeCode { get; init; } + + /// + /// The provider type code from PIMS. + /// + [JsonPropertyName("PIMSProviderTypeCode")] + public int? PimsProviderTypeCode { get; init; } + + /// + /// The status from PIMS. + /// + [JsonPropertyName("PIMSStatus")] + public string? PimsStatus { get; init; } + + /// + /// The date the organisation was opened. + /// + [JsonPropertyName("OpenedOn")] + public string? OpenedOn { get; init; } + + /// + /// The district administrative name. + /// + [JsonPropertyName("DistrictAdministrativeName")] + public string? DistrictAdministrativeName { get; init; } + + /// + /// The district administrative code. + /// + [JsonPropertyName("DistrictAdministrativeCode")] + public string? DistrictAdministrativeCode { get; init; } + + /// + /// The alternate district administrative code. + /// + [JsonPropertyName("DistrictAdministrative_code")] + public string? DistrictAdministrativeCodeAlt { get; init; } + + /// + /// The status name from PIMS. + /// + [JsonPropertyName("PIMSStatusName")] + public string? PimsStatusName { get; init; } + + /// + /// The status from GIAS. + /// + [JsonPropertyName("GIASStatus")] + public int? GiasStatus { get; init; } + + /// + /// The status name from GIAS. + /// + [JsonPropertyName("GIASStatusName")] + public string? GiasStatusName { get; init; } + + /// + /// The master provider status code. + /// + [JsonPropertyName("MasterProviderStatusCode")] + public int? MasterProviderStatusCode { get; init; } + + /// + /// The master provider status name. + /// + [JsonPropertyName("MasterProviderStatusName")] + public string? MasterProviderStatusName { get; init; } + + /// + /// The legal name of the organisation. + /// + [JsonPropertyName("LegalName")] + public string? LegalName { get; init; } + + /// + /// Indicates if the organisation is on APAR. + /// + [JsonPropertyName("IsOnAPAR")] + public string? IsOnAPAR { get; init; } + + /// + /// The local authority ID. + /// + [JsonPropertyName("localAuthorityId")] + public Guid? LocalAuthorityId { get; init; } + + /// + /// The local authority code. + /// + [JsonPropertyName("localAuthorityCode")] + public string? LocalAuthorityCode { get; init; } + + /// + /// The local authority name. + /// + [JsonPropertyName("localAuthorityName")] + public string? LocalAuthorityName { get; init; } + + /// + /// The type of the organisation. + /// + [JsonPropertyName("Type")] + public string? Type { get; init; } + + /// + /// The URN (Unique Reference Number) of the organisation. + /// + [JsonPropertyName("URN")] + public string? Urn { get; init; } + + /// + /// The UID (Unique Identifier) of the organisation. + /// + [JsonPropertyName("UID")] + public string? Uid { get; init; } + + /// + /// The UKPRN (UK Provider Reference Number) of the organisation. + /// + [JsonPropertyName("UKPRN")] + public string? Ukprn { get; init; } + + /// + /// The establishment number of the organisation. + /// + [JsonPropertyName("EstablishmentNumber")] + public string? EstablishmentNumber { get; init; } + + /// + /// The status of the organisation. + /// + [JsonPropertyName("Status")] + public int Status { get; init; } + + /// + /// The date the organisation was closed. + /// + [JsonPropertyName("ClosedOn")] + public DateOnly? ClosedOn { get; init; } + + /// + /// The address of the organisation. + /// + [JsonPropertyName("Address")] + public string? Address { get; init; } + + /// + /// The telephone number of the organisation. + /// + [JsonPropertyName("telephone")] + public string? Telephone { get; init; } + + /// + /// The statutory low age for the organisation. + /// + [JsonPropertyName("statutoryLowAge")] + public int? StatutoryLowAge { get; init; } + + /// + /// The statutory high age for the organisation. + /// + [JsonPropertyName("statutoryHighAge")] + public int? StatutoryHighAge { get; init; } + + /// + /// The legacy ID of the organisation. + /// + [JsonPropertyName("legacyId")] + public string? LegacyId { get; init; } + + /// + /// The company registration number of the organisation. + /// + [JsonPropertyName("companyRegistrationNumber")] + public string? CompanyRegistrationNumber { get; init; } + + /// + /// The phase of education for the organisation. + /// + [JsonPropertyName("phaseOfEducation")] + public int? PhaseOfEducation { get; init; } +} + +/// +/// Represents a role associated with a service user. +/// +public sealed record ServiceUserRoleDto +{ + /// + /// The unique identifier of the role. + /// + /// + /// The unique identifier of the role. + /// + [JsonPropertyName("id")] + public required string Id { get; init; } + + /// + /// The name of the role. + /// + [JsonPropertyName("name")] + public required string Name { get; init; } + + /// + /// The code of the role. + /// + [JsonPropertyName("code")] + public required string Code { get; init; } + + /// + /// The numeric ID of the role. + /// + [JsonPropertyName("numericId")] + public required string NumericId { get; init; } + + /// + /// The status of the role. + /// + [JsonPropertyName("status")] + public required int Status { get; init; } +} + +/// +/// Represents a service user in the response. +/// +public sealed record ServiceUserDto +{ + /// + /// The unique identifier of the user. + /// + [JsonPropertyName("userId")] + public required Guid UserId { get; init; } + + /// + /// The user's email address. + /// + [JsonPropertyName("email")] + public required string Email { get; init; } + + /// + /// The user's family (last) name. + /// + [JsonPropertyName("familyName")] + public string? FamilyName { get; init; } + + /// + /// The user's given (first) name. + /// + [JsonPropertyName("givenName")] + public string? GivenName { get; init; } + + /// + /// The user's status (e.g., active, inactive). + /// + [JsonPropertyName("userStatus")] + public int? UserStatus { get; init; } + + /// + /// The organisation associated with the user. + /// + [JsonPropertyName("organisation")] + public ServiceUserOrganisationDto? Organisation { get; init; } + + /// + /// The name of the user's role. + /// + [JsonPropertyName("roleName")] + public string? RoleName { get; init; } + + /// + /// The identifier of the user's role. + /// + [JsonPropertyName("roleId")] + public short? RoleId { get; init; } + + /// + /// The date and time the user was approved (ISO 8601 format). + /// + [JsonPropertyName("approvedAt")] + public DateTime? ApprovedAt { get; init; } + + /// + /// The date and time the user was last updated (ISO 8601 format). + /// + [JsonPropertyName("updatedAt")] + public DateTime? UpdatedAt { get; init; } + + /// + /// Service roles for the user. + /// + [JsonPropertyName("roles")] + public IReadOnlyList? Roles { get; init; } +} + +/// +/// Response containing a paginated list of service users. +/// +public sealed record GetServiceUsersResponse() +{ + /// + /// The list of service users for the requested page. + /// + [JsonPropertyName("users")] + public required IReadOnlyList Users { get; init; } + + /// + /// The total number of records available. + /// + [JsonPropertyName("numberOfRecords")] + public required int NumberOfRecords { get; init; } + + /// + /// The current page number (1-based). + /// + [JsonPropertyName("page")] + public required int Page { get; init; } + + /// + /// The total number of pages available. + /// + [JsonPropertyName("numberOfPages")] + public required int NumberOfPages { get; init; } + + /// + /// Returns an empty response for a given page number, used when there are no records to return. + /// + /// The page number for which the empty response is generated. + /// An empty instance. + public static GetServiceUsersResponse Empty(int pageNumber) => new() { + Users = [], + NumberOfRecords = 0, + Page = pageNumber, + NumberOfPages = 0 + }; + + /// + /// Returns a response containing the provided list of service users and pagination details. + /// + /// The list of service users. + /// The total number of records available. + /// The current page number (1-based). + /// The number of records per page. + /// A instance containing the provided users and pagination details. + public static GetServiceUsersResponse FromUsers( + IReadOnlyList users, + int totalRecords, + int pageNumber, + int pageSize) => new() { + Users = users, + NumberOfRecords = totalRecords, + Page = pageNumber, + NumberOfPages = (int)Math.Ceiling(totalRecords / (double)pageSize) + }; +} diff --git a/src/Dfe.SignIn.Core.Entities/Organisations/RoleEntity.cs b/src/Dfe.SignIn.Core.Entities/Organisations/RoleEntity.cs index 4619c2c8..9ea58420 100644 --- a/src/Dfe.SignIn.Core.Entities/Organisations/RoleEntity.cs +++ b/src/Dfe.SignIn.Core.Entities/Organisations/RoleEntity.cs @@ -29,6 +29,8 @@ public partial class RoleEntity public virtual ICollection PolicyRoles { get; set; } = []; public virtual ICollection UserServiceRoles { get; set; } = []; + + public virtual RoleEntity? Parent { get; set; } } #pragma warning restore CS1591 diff --git a/src/Dfe.SignIn.Core.Entities/Organisations/UserServiceEntity.cs b/src/Dfe.SignIn.Core.Entities/Organisations/UserServiceEntity.cs index e6bb1970..72fcf1f6 100644 --- a/src/Dfe.SignIn.Core.Entities/Organisations/UserServiceEntity.cs +++ b/src/Dfe.SignIn.Core.Entities/Organisations/UserServiceEntity.cs @@ -1,4 +1,5 @@ using System.Diagnostics.CodeAnalysis; +using Dfe.SignIn.Core.Entities.Directories; namespace Dfe.SignIn.Core.Entities.Organisations; @@ -25,6 +26,8 @@ public partial class UserServiceEntity public virtual OrganisationEntity? Organisation { get; set; } public virtual ServiceEntity? Service { get; set; } + + public virtual UserEntity? User { get; set; } } #pragma warning restore CS1591 diff --git a/src/Dfe.SignIn.Core.UseCases/Applications/GetApplicationByClientIdUseCase.cs b/src/Dfe.SignIn.Core.UseCases/Applications/GetApplicationByClientIdUseCase.cs index 602c1177..b2d501dc 100644 --- a/src/Dfe.SignIn.Core.UseCases/Applications/GetApplicationByClientIdUseCase.cs +++ b/src/Dfe.SignIn.Core.UseCases/Applications/GetApplicationByClientIdUseCase.cs @@ -30,6 +30,8 @@ public override async Task InvokeAsync( x.IsExternalService, x.IsHiddenService, x.IsIdOnlyService, + x.ParentId, + ParentClientId = x.Parent != null ? x.Parent.ClientId : null, }) .SingleOrDefaultAsync( x => x.ClientId == context.Request.ClientId, @@ -50,6 +52,8 @@ public override async Task InvokeAsync( IsExternalService = serviceEntity.IsExternalService, IsHiddenService = serviceEntity.IsHiddenService, IsIdOnlyService = serviceEntity.IsIdOnlyService, + ParentId = serviceEntity.ParentId, + ParentClientId = serviceEntity.ParentClientId, } }; } diff --git a/src/Dfe.SignIn.Core.UseCases/Applications/GetApplicationRolesUseCase.cs b/src/Dfe.SignIn.Core.UseCases/Applications/GetApplicationRolesUseCase.cs new file mode 100644 index 00000000..cb9bb5b4 --- /dev/null +++ b/src/Dfe.SignIn.Core.UseCases/Applications/GetApplicationRolesUseCase.cs @@ -0,0 +1,55 @@ +using System.Data; +using Dfe.SignIn.Base.Framework; +using Dfe.SignIn.Core.Contracts.Applications; +using Dfe.SignIn.Core.Entities.Organisations; +using Dfe.SignIn.Core.Interfaces.DataAccess; +using Microsoft.EntityFrameworkCore; + +namespace Dfe.SignIn.Core.UseCases.Applications; + +/// +/// Use case responsible for obtaining information about an application. +/// +public sealed class GetApplicationRolesUseCase( + IUnitOfWorkOrganisations uowOrganisations +) : Interactor +{ + /// + public override async Task InvokeAsync( + InteractionContext context, + CancellationToken cancellationToken = default) + { + context.ThrowIfHasValidationErrors(); + + var roles = await uowOrganisations.Repository() + .Include(r => r.Parent) + .Where(r => r.ApplicationId == context.Request.ApplicationId) + .OrderBy(r => r.Name) + .Select(x => new { + x.Id, + x.Name, + x.Code, + x.NumericId, + x.Status, + x.Parent + }) + .ToListAsync(cancellationToken); + + return new GetApplicationRolesResponse { + Roles = roles?.Select(r => new ApplicationRole { + Id = r.Id, + Name = r.Name, + Code = r.Code, + NumericId = r.NumericId, + Status = (ApplicationRoleStatus)r.Status, + Parent = r.Parent != null ? new ApplicationRole { + Id = r.Parent.Id, + Name = r.Parent.Name, + Code = r.Parent.Code, + NumericId = r.Parent.NumericId, + Status = (ApplicationRoleStatus)r.Parent.Status + } : null + }) ?? [] + }; + } +} diff --git a/src/Dfe.SignIn.Core.UseCases/Users/GetServiceUsersUseCase.cs b/src/Dfe.SignIn.Core.UseCases/Users/GetServiceUsersUseCase.cs new file mode 100644 index 00000000..a1c74479 --- /dev/null +++ b/src/Dfe.SignIn.Core.UseCases/Users/GetServiceUsersUseCase.cs @@ -0,0 +1,199 @@ +using Dfe.SignIn.Base.Framework; +using Dfe.SignIn.Base.Framework.Extensions; +using Dfe.SignIn.Core.Contracts.Access; +using Dfe.SignIn.Core.Contracts.Organisations; +using Dfe.SignIn.Core.Contracts.Users; +using Dfe.SignIn.Core.Entities.Directories; +using Dfe.SignIn.Core.Entities.Organisations; +using Dfe.SignIn.Core.Interfaces.DataAccess; +using Microsoft.EntityFrameworkCore; + +namespace Dfe.SignIn.Core.UseCases.Users; + +/// +/// Get the service users for a given service (client). +/// +/// +public sealed class GetServiceUsersUseCase( + IUnitOfWorkOrganisations uowOrganisations +) : Interactor +{ + /// + public override async Task InvokeAsync( + InteractionContext context, + CancellationToken cancellationToken = default) + { + context.ThrowIfHasValidationErrors(); + + var applicationId = context.Request.ApplicationId; + var pageSize = context.Request.PageSize; + var pageNumber = context.Request.PageNumber; + + var query = uowOrganisations.Repository() + .AsNoTracking() + .Include(x => x.User) + .Include(x => x.Organisation) + .Where(x => x.ServiceId == applicationId) + .Where(x => x.User != null) + .Where(x => x.Organisation != null); + + var totalRecords = await query.CountAsync(cancellationToken); + if (totalRecords == 0) { + return GetServiceUsersResponse.Empty(pageNumber); + } + + var pagedEntities = await query + .OrderBy(x => x.UserId) + .ThenBy(x => x.OrganisationId) + .Skip((pageNumber - 1) * pageSize) + .Take(pageSize) + .AsSplitQuery() + .ToListAsync(cancellationToken); + + var userIds = pagedEntities.Select(us => us.UserId).Distinct().ToList(); + + var rolesLookup = await this.GetServiceRolesLookupAsync(applicationId, userIds, cancellationToken); + var orgRolesLookup = await this.GetOrgRolesLookupAsync(userIds, cancellationToken); + + var mappedUsers = pagedEntities.Select(entity => + MapToServiceUserDto(entity, rolesLookup, orgRolesLookup)).ToList(); + + // Added to make sure it is backwards compatible with existing data where email might be missing, + // and to prevent potential issues in the UI or other layers that expect an email address. + if (mappedUsers.All(u => string.IsNullOrEmpty(u.Email))) { + return GetServiceUsersResponse.Empty(pageNumber); + } + + return GetServiceUsersResponse.FromUsers( + mappedUsers, + totalRecords, + pageNumber, + pageSize + ); + } + + private async Task> GetServiceRolesLookupAsync( + Guid appId, List userIds, CancellationToken ct) + { + var roles = await uowOrganisations.Repository() + .AsNoTracking() + .Include(x => x.Role) + .Where(x => x.ServiceId == appId) + .Where(x => userIds.Contains(x.UserId)) + .ToListAsync(ct); + + return roles.ToLookup( + key => (key.UserId, key.OrganisationId), + val => new ServiceUserRoleDto { + Id = val.Role?.Id.ToString() ?? string.Empty, + Name = val.Role?.Name ?? string.Empty, + Code = val.Role?.Code ?? string.Empty, + NumericId = val.Role?.NumericId.ToString() ?? string.Empty, + Status = val.Role?.Status ?? (int)ApplicationRoleStatus.Inactive + }); + } + + private async Task> GetOrgRolesLookupAsync( + List userIds, CancellationToken ct) + { + var orgRoles = await uowOrganisations.Repository() + .AsNoTracking() + .Where(x => userIds.Contains(x.UserId)) + .Select(x => new { x.UserId, x.OrganisationId, x.RoleId }) + .ToListAsync(ct); + + return orgRoles.ToLookup(x => x.UserId, x => (x.OrganisationId, x.RoleId)); + } + + private static ServiceUserDto MapToServiceUserDto( + UserServiceEntity entity, + ILookup<(Guid, Guid), ServiceUserRoleDto> rolesLookup, + ILookup orgRolesLookup) + { + var user = entity.User; + var org = entity.Organisation; + + var orgRoleId = orgRolesLookup[entity.UserId] + .Where(x => x.OrgId == entity.OrganisationId) + .Select(x => (short?)x.RoleId) + .FirstOrDefault(); + + var userOrgRole = orgRoleId != null ? OrganisationRoles.FromId(orgRoleId.Value) : null; + + var userRoles = rolesLookup[(entity.UserId, entity.OrganisationId ?? Guid.Empty)].ToList(); + + return new ServiceUserDto { + UserId = entity.UserId, + Email = user?.Email ?? string.Empty, + FamilyName = user?.LastName ?? string.Empty, + GivenName = user?.FirstName ?? string.Empty, + UserStatus = user?.Status ?? 0, + ApprovedAt = user?.CreatedAt.ToUtc(), + UpdatedAt = user?.UpdatedAt.ToUtc(), + RoleName = userOrgRole?.Name ?? string.Empty, + RoleId = userOrgRole?.Id, + Roles = userRoles, + Organisation = MapOrganisationDto(org, entity.UserId, user) + }; + } + + private static ServiceUserOrganisationDto? MapOrganisationDto(OrganisationEntity? org, Guid userId, UserEntity? user) + { + if (org == null) { + return null; + } + + return new ServiceUserOrganisationDto { + Id = org.Id, + OrganisationId = org.Id, + UserId = userId, + UserStatus = user?.Status ?? 0, + UserCreatedAt = user?.CreatedAt.ToUtc(), + UserUpdatedAt = user?.UpdatedAt.ToUtc(), + CreatedAt = org.CreatedAt.ToUtc(), + UpdatedAt = org.UpdatedAt.ToUtc(), + Name = org.Name ?? string.Empty, + Category = org.Category, + Type = org.Type, + Urn = org.Urn, + Uid = org.Uid, + Ukprn = org.Ukprn, + EstablishmentNumber = org.EstablishmentNumber, + Status = org.Status, + ClosedOn = org.ClosedOn, + Address = org.Address, + PhaseOfEducation = org.PhaseOfEducation, + StatutoryLowAge = org.StatutoryLowAge, + StatutoryHighAge = org.StatutoryHighAge, + Telephone = org.Telephone, + RegionCode = org.RegionCode, + LegacyId = org.LegacyId.ToString(), + CompanyRegistrationNumber = org.CompanyRegistrationNumber, + DistrictAdministrativeNameAlt = org.DistrictAdministrativeName, + MasteringCode = org.MasteringCode, + ProviderProfileID = org.ProviderProfileId, + SourceSystem = org.SourceSystem, + Upin = org.Upin, + ProviderTypeName = org.ProviderTypeName, + GiasProviderType = org.GiasProviderType, + PimsProviderType = org.PimsProviderType, + ProviderTypeCode = org.ProviderTypeCode, + PimsProviderTypeCode = org.PimsProviderTypeCode, + PimsStatus = org.PimsStatus, + OpenedOn = org.OpenedOn, + DistrictAdministrativeName = org.DistrictAdministrativeName1, + DistrictAdministrativeCode = org.DistrictAdministrativeCode, + DistrictAdministrativeCodeAlt = org.DistrictAdministrativeCode1, + PimsStatusName = org.PimsStatusName, + GiasStatus = org.GiasStatus, + GiasStatusName = org.GiasStatusName, + MasterProviderStatusCode = org.MasterProviderStatusCode, + MasterProviderStatusName = org.MasterProviderStatusName, + LegalName = org.LegalName, + IsOnAPAR = org.IsOnApar, + LocalAuthorityId = org.LocalAuthorityId, + LocalAuthorityCode = org.LocalAuthorityCode, + LocalAuthorityName = org.LocalAuthorityName + }; + } +} diff --git a/src/Dfe.SignIn.Gateways.EntityFramework/Configuration/Organisations/RoleEntityConfiguration.cs b/src/Dfe.SignIn.Gateways.EntityFramework/Configuration/Organisations/RoleEntityConfiguration.cs index 836abd1a..5899c99f 100644 --- a/src/Dfe.SignIn.Gateways.EntityFramework/Configuration/Organisations/RoleEntityConfiguration.cs +++ b/src/Dfe.SignIn.Gateways.EntityFramework/Configuration/Organisations/RoleEntityConfiguration.cs @@ -22,5 +22,10 @@ public void Configure(EntityTypeBuilder builder) builder.Property(e => e.Name).HasMaxLength(125); builder.Property(e => e.Status).HasDefaultValue((short)1); + + builder.HasOne(d => d.Parent) + .WithMany() + .HasForeignKey(d => d.ParentId) + .HasConstraintName("FK_Role_Role_ParentId"); } } diff --git a/src/Dfe.SignIn.Gateways.EntityFramework/Configuration/Organisations/UserEntityConfiguration.cs b/src/Dfe.SignIn.Gateways.EntityFramework/Configuration/Organisations/UserEntityConfiguration.cs new file mode 100644 index 00000000..9c21d9d3 --- /dev/null +++ b/src/Dfe.SignIn.Gateways.EntityFramework/Configuration/Organisations/UserEntityConfiguration.cs @@ -0,0 +1,96 @@ +using System.Diagnostics.CodeAnalysis; +using Dfe.SignIn.Core.Entities.Directories; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Dfe.SignIn.Gateways.EntityFramework.Configuration.Organisations; + +internal sealed class UserEntityConfiguration : IEntityTypeConfiguration +{ + [ExcludeFromCodeCoverage] + public void Configure(EntityTypeBuilder builder) + { + builder + .ToTable("user") + .HasKey(e => e.Sub); + + builder.Property(e => e.Sub) + .ValueGeneratedNever() + .HasColumnName("sub"); + + builder.Property(e => e.CreatedAt) + .HasColumnName("createdAt"); + + builder.Property(e => e.Email) + .HasMaxLength(255) + .HasColumnName("email"); + + builder.Property(e => e.EntraDeferUntil) + .HasColumnType("datetime") + .HasColumnName("entra_defer_until"); + + builder.Property(e => e.EntraLinked) + .HasColumnType("datetime") + .HasColumnName("entra_linked"); + + builder.Property(e => e.EntraOid) + .HasColumnName("entra_oid"); + + builder.Property(e => e.LastName) + .HasMaxLength(255) + .IsUnicode(false) + .HasColumnName("family_name"); + + builder.Property(e => e.FirstName) + .HasMaxLength(255) + .HasColumnName("given_name"); + + builder.Property(e => e.IsEntra) + .HasColumnName("is_entra"); + + builder.Property(e => e.IsInternalUser) + .HasColumnName("is_internal_user"); + + builder.Property(e => e.IsMigrated) + .HasColumnName("isMigrated"); + + builder.Property("IsTestUser") + .HasColumnName("is_test_user"); + + builder.Property(e => e.JobTitle) + .HasMaxLength(255) + .HasColumnName("job_title"); + + builder.Property(e => e.LastLogin) + .HasColumnType("datetime") + .HasColumnName("last_login"); + + builder.Property(e => e.Password) + .HasMaxLength(5000) + .IsUnicode(false) + .HasColumnName("password"); + + builder.Property(e => e.PasswordResetRequired) + .HasColumnName("password_reset_required"); + + builder.Property(e => e.PhoneNumber) + .HasMaxLength(50) + .IsUnicode(false) + .HasColumnName("phone_number"); + + builder.Property(e => e.PrevLogin) + .HasColumnType("datetime") + .HasColumnName("prev_login"); + + builder.Property(e => e.Salt) + .HasMaxLength(500) + .IsUnicode(false) + .HasColumnName("salt"); + + builder.Property(e => e.Status) + .HasColumnName("status"); + + builder.Property(e => e.UpdatedAt) + .HasColumnName("updatedAt"); + } +} diff --git a/src/Dfe.SignIn.Gateways.EntityFramework/Configuration/Organisations/UserServiceEntityConfiguration.cs b/src/Dfe.SignIn.Gateways.EntityFramework/Configuration/Organisations/UserServiceEntityConfiguration.cs index 1fbd1ad2..47d35bb9 100644 --- a/src/Dfe.SignIn.Gateways.EntityFramework/Configuration/Organisations/UserServiceEntityConfiguration.cs +++ b/src/Dfe.SignIn.Gateways.EntityFramework/Configuration/Organisations/UserServiceEntityConfiguration.cs @@ -46,5 +46,10 @@ public void Configure(EntityTypeBuilder builder) builder.HasOne(d => d.Service).WithMany(p => p.UserServices) .HasForeignKey(d => d.ServiceId) .HasConstraintName("user_services_service_id_fk"); + + builder + .HasOne(d => d.User) + .WithMany() + .HasForeignKey(d => d.UserId); } } diff --git a/src/Dfe.SignIn.Gateways.EntityFramework/DbOrganisationsContext.cs b/src/Dfe.SignIn.Gateways.EntityFramework/DbOrganisationsContext.cs index 3632a1ba..0d6b962f 100644 --- a/src/Dfe.SignIn.Gateways.EntityFramework/DbOrganisationsContext.cs +++ b/src/Dfe.SignIn.Gateways.EntityFramework/DbOrganisationsContext.cs @@ -1,4 +1,5 @@ using System.Diagnostics.CodeAnalysis; +using Dfe.SignIn.Core.Entities.Directories; using Dfe.SignIn.Core.Entities.Organisations; using Dfe.SignIn.Gateways.EntityFramework.Configuration.Organisations; using Microsoft.EntityFrameworkCore; @@ -82,6 +83,8 @@ public DbOrganisationsContext(DbContextOptions options) public virtual DbSet UserServiceRoles { get; set; } + public virtual DbSet Users { get; set; } + protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.ApplyConfiguration(new CognitiveSearchEntityConfiguration()); @@ -118,6 +121,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) modelBuilder.ApplyConfiguration(new UserServiceIdentifierEntityConfiguration()); modelBuilder.ApplyConfiguration(new UserServiceRequestEntityConfiguration()); modelBuilder.ApplyConfiguration(new UserServiceRoleEntityConfiguration()); + modelBuilder.ApplyConfiguration(new UserEntityConfiguration()); modelBuilder.HasSequence("numeric_id_sequence") .StartsAt(50000L) diff --git a/src/Dfe.SignIn.NodeApi.Client/Access/Models/ApplicationDto.cs b/src/Dfe.SignIn.NodeApi.Client/Access/Models/ApplicationDto.cs index 127d0268..b69639d3 100644 --- a/src/Dfe.SignIn.NodeApi.Client/Access/Models/ApplicationDto.cs +++ b/src/Dfe.SignIn.NodeApi.Client/Access/Models/ApplicationDto.cs @@ -1,3 +1,5 @@ + +using System.Diagnostics.CodeAnalysis; using Dfe.SignIn.Core.Contracts.Access; namespace Dfe.SignIn.NodeApi.Client.Access.Models; @@ -12,19 +14,27 @@ internal sealed record ApplicationDto public required DateTime AccessGrantedOn { get; init; } } +// Excluded from code coverage because this is a pure data transfer object (DTO) with no logic. +[ExcludeFromCodeCoverage] internal sealed record RoleDto { public required Guid Id { get; init; } public required string Name { get; init; } public required string Code { get; init; } public required long NumericId { get; init; } + public required StatusDto Status { get; init; } + public RoleDto? Parent { get; init; } } +// Excluded from code coverage because this is a pure DTO with no logic. +[ExcludeFromCodeCoverage] internal sealed record StatusDto { public required ApplicationRoleStatus Id { get; init; } } +// Excluded from code coverage because this is a pure DTO with no logic. +[ExcludeFromCodeCoverage] internal sealed record IdentifiersDto { public required string Key { get; init; } diff --git a/src/Dfe.SignIn.NodeApi.Client/Applications/GetApplicationByClientIdNodeRequester.cs b/src/Dfe.SignIn.NodeApi.Client/Applications/GetApplicationByClientIdNodeRequester.cs index 7f7c5d07..a758c4cd 100644 --- a/src/Dfe.SignIn.NodeApi.Client/Applications/GetApplicationByClientIdNodeRequester.cs +++ b/src/Dfe.SignIn.NodeApi.Client/Applications/GetApplicationByClientIdNodeRequester.cs @@ -43,3 +43,4 @@ public override async Task InvokeAsync( }; } } + diff --git a/src/Dfe.SignIn.PublicApi.Contracts/Applications/ApplicationRoleDto.cs b/src/Dfe.SignIn.PublicApi.Contracts/Applications/ApplicationRoleDto.cs new file mode 100644 index 00000000..78465cfd --- /dev/null +++ b/src/Dfe.SignIn.PublicApi.Contracts/Applications/ApplicationRoleDto.cs @@ -0,0 +1,25 @@ +namespace Dfe.SignIn.PublicApi.Contracts.Applications; + +/// +/// Represents a role associated with an application. +/// +public sealed record ApplicationRoleDto +{ + /// + /// The role name. + /// + /// DSI Child One + public required string Name { get; init; } + + /// + /// The role code. + /// + /// DSI_Child_One + public required string Code { get; init; } + + /// + /// The status of the role. + /// + /// Active + public required string Status { get; init; } +} diff --git a/src/Dfe.SignIn.PublicApi/Configuration/ApplicationExtensions.cs b/src/Dfe.SignIn.PublicApi/Configuration/ApplicationExtensions.cs new file mode 100644 index 00000000..3a48b109 --- /dev/null +++ b/src/Dfe.SignIn.PublicApi/Configuration/ApplicationExtensions.cs @@ -0,0 +1,25 @@ +using System.Diagnostics.CodeAnalysis; +using Dfe.SignIn.Base.Framework; +using Dfe.SignIn.Core.UseCases.Applications; + +namespace Dfe.SignIn.PublicApi.Configuration; + +/// +/// Extension methods for setting up "application" features. +/// +public static class ApplicationExtensions +{ + /// + /// Registers all endpoints for the "select organisation" feature. + /// + /// + [ExcludeFromCodeCoverage] + public static IServiceCollection SetupApplicationInteractions(this IServiceCollection services) + { + ExceptionHelpers.ThrowIfArgumentNull(services, nameof(services)); + + services.AddInteractor(); + + return services; + } +} diff --git a/src/Dfe.SignIn.PublicApi/Configuration/SelectOrganisationExtensions.cs b/src/Dfe.SignIn.PublicApi/Configuration/SelectOrganisationExtensions.cs index 4e99a22c..771fca6c 100644 --- a/src/Dfe.SignIn.PublicApi/Configuration/SelectOrganisationExtensions.cs +++ b/src/Dfe.SignIn.PublicApi/Configuration/SelectOrganisationExtensions.cs @@ -15,11 +15,13 @@ public static class SelectOrganisationExtensions /// /// If is null. /// - public static void SetupSelectOrganisationInteractions(this IServiceCollection services) + public static IServiceCollection SetupSelectOrganisationInteractions(this IServiceCollection services) { ExceptionHelpers.ThrowIfArgumentNull(services, nameof(services)); services.AddInteractor(); services.AddInteractor(); + + return services; } } diff --git a/src/Dfe.SignIn.PublicApi/Configuration/UserExtensions.cs b/src/Dfe.SignIn.PublicApi/Configuration/UserExtensions.cs new file mode 100644 index 00000000..bbed3bc6 --- /dev/null +++ b/src/Dfe.SignIn.PublicApi/Configuration/UserExtensions.cs @@ -0,0 +1,25 @@ +using System.Diagnostics.CodeAnalysis; +using Dfe.SignIn.Base.Framework; +using Dfe.SignIn.Core.UseCases.Users; + +namespace Dfe.SignIn.PublicApi.Configuration; + +/// +/// Extension methods for setting up "user" features. +/// +public static class UserExtensions +{ + /// + /// Registers all endpoints for the "user" feature. + /// + /// + [ExcludeFromCodeCoverage] + public static IServiceCollection SetupUserInteractions(this IServiceCollection services) + { + ExceptionHelpers.ThrowIfArgumentNull(services, nameof(services)); + + services.AddInteractor(); + + return services; + } +} diff --git a/src/Dfe.SignIn.PublicApi/Dfe.SignIn.PublicApi.csproj b/src/Dfe.SignIn.PublicApi/Dfe.SignIn.PublicApi.csproj index 6dcfa83b..e3e9ed60 100644 --- a/src/Dfe.SignIn.PublicApi/Dfe.SignIn.PublicApi.csproj +++ b/src/Dfe.SignIn.PublicApi/Dfe.SignIn.PublicApi.csproj @@ -25,6 +25,7 @@ + diff --git a/src/Dfe.SignIn.PublicApi/Endpoints/Applications/ApplicationEndpoints.cs b/src/Dfe.SignIn.PublicApi/Endpoints/Applications/ApplicationEndpoints.cs new file mode 100644 index 00000000..81f61a24 --- /dev/null +++ b/src/Dfe.SignIn.PublicApi/Endpoints/Applications/ApplicationEndpoints.cs @@ -0,0 +1,25 @@ +using System.Diagnostics.CodeAnalysis; + +namespace Dfe.SignIn.PublicApi.Endpoints.Applications; + +/// +/// A collection of endpoints related to service applications, including retrieving application roles. +/// +public static partial class ApplicationEndpoints +{ + /// + /// Registers all endpoints for the "get application roles" feature. + /// + /// The application instance. + [ExcludeFromCodeCoverage] + public static void UseApplicationEndpoints(this WebApplication app) + { + app.MapGet("services/{clientId}/roles", GetApplicationRoles) + .WithName("GetApplicationRolesRequest") + .WithTags("Applications") + .Produces>(StatusCodes.Status200OK) + .Produces(StatusCodes.Status404NotFound) + .Produces(StatusCodes.Status403Forbidden) + .WithOpenApi(); + } +} diff --git a/src/Dfe.SignIn.PublicApi/Endpoints/Applications/GetApplicationRoles.cs b/src/Dfe.SignIn.PublicApi/Endpoints/Applications/GetApplicationRoles.cs new file mode 100644 index 00000000..3a9afd85 --- /dev/null +++ b/src/Dfe.SignIn.PublicApi/Endpoints/Applications/GetApplicationRoles.cs @@ -0,0 +1,88 @@ +using System.Diagnostics; +using Dfe.SignIn.Base.Framework; +using Dfe.SignIn.Core.Contracts.Applications; +using Dfe.SignIn.PublicApi.Authorization; +using Dfe.SignIn.PublicApi.Contracts.Applications; +using Microsoft.AspNetCore.Mvc; + +namespace Dfe.SignIn.PublicApi.Endpoints.Applications; + +/// +/// A collection of endpoints related to service applications, including retrieving application roles. +/// +public static partial class ApplicationEndpoints +{ + /// + /// Get the roles associated with a service application. + /// + /// The unique client identifier of the application. + /// The client session for the current request. + /// Service to dispatch interaction requests. + /// Factory to create loggers for logging request details. + /// The current HTTP context, used to access headers and request information. + /// The application roles. + public static async Task GetApplicationRoles( + [FromRoute] string clientId, + IClientSession clientSession, + IInteractionDispatcher interaction, + ILoggerFactory loggerFactory, + HttpContext httpContext) + { + var logger = loggerFactory.CreateLogger(nameof(ApplicationEndpoints)); + + var correlationId = Activity.Current?.TraceId.ToString(); + var clientCorrelationId = httpContext.Request.Headers["x-correlation-id"].FirstOrDefault(); + + if (logger.IsEnabled(LogLevel.Information)) { + logger.LogInformation( + "{ClientId} is attempting to get service roles for: {RequestedClientId} (correlationId: {CorrelationId}, clientCorrelationId: {ClientCorrelationId})", + clientSession.ClientId, + clientId, + correlationId, + clientCorrelationId + ); + } + + GetApplicationByClientIdResponse applicationResponse; + + try { + applicationResponse = await interaction.DispatchAsync( + new GetApplicationByClientIdRequest { + ClientId = clientId + } + ).To(); + } + catch (ApplicationNotFoundException) { + return Results.NotFound($"Application with clientId '{clientId}' not found."); + } + catch (Exception ex) { + logger.LogError(ex, "Unexpected error while retrieving roles for clientId {ClientId}", clientId); + return Results.Problem("An unexpected error occurred while retrieving application roles."); + } + + var application = applicationResponse.Application; + if (application == null) { + return Results.NotFound(); + } + + if (application.ClientId != clientSession.ClientId && + (application.ParentClientId == null || application.ParentClientId != clientSession.ClientId)) { + return Results.Forbid(); + } + + var rolesResponse = await interaction.DispatchAsync( + new GetApplicationRolesRequest { + ApplicationId = application.Id + } + ).To(); + + var roles = rolesResponse.Roles + .Select(r => new ApplicationRoleDto { + Name = r.Name, + Code = r.Code, + Status = r.Status.ToString() + }); + + return Results.Ok(roles); + } +} diff --git a/src/Dfe.SignIn.PublicApi/Endpoints/Users/GetServiceUsers.cs b/src/Dfe.SignIn.PublicApi/Endpoints/Users/GetServiceUsers.cs new file mode 100644 index 00000000..4cb8e9d6 --- /dev/null +++ b/src/Dfe.SignIn.PublicApi/Endpoints/Users/GetServiceUsers.cs @@ -0,0 +1,77 @@ +using System.Diagnostics; +using Dfe.SignIn.Base.Framework; +using Dfe.SignIn.Core.Contracts.Applications; +using Dfe.SignIn.Core.Contracts.Users; +using Dfe.SignIn.PublicApi.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Dfe.SignIn.PublicApi.Endpoints.Users; + +/// +/// Endpoints for service users, migrated from Node.js getServiceUsers. +/// +public static partial class UserEndpoints +{ + /// + /// Get the service users for a given service (client). + /// + /// + /// + /// The client session for the current request. + /// Service to dispatch interaction requests. + /// Factory to create loggers for logging request details. + /// The current HTTP context, used to access headers and request information. + /// The service users for the given service. + public static async Task GetServiceUsers( + IClientSession clientSession, + IInteractionDispatcher interaction, + ILoggerFactory loggerFactory, + HttpContext httpContext, + [FromQuery] int page = 1, + [FromQuery] int pageSize = 25) + { + var logger = loggerFactory.CreateLogger(nameof(UserEndpoints)); + var correlationId = Activity.Current?.TraceId.ToString(); + var clientCorrelationId = httpContext.Request.Headers["x-correlation-id"].FirstOrDefault(); + + logger.LogInformation( + "{ClientId} is attempting to get service users (correlationId: {CorrelationId}, clientCorrelationId: {ClientCorrelationId})", + clientSession.ClientId, + correlationId, + clientCorrelationId + ); + + var clientId = clientSession.ClientId; + + GetApplicationByClientIdResponse applicationResponse; + + try { + applicationResponse = await interaction.DispatchAsync( + new GetApplicationByClientIdRequest { + ClientId = clientId + } + ).To(); + } + catch (ApplicationNotFoundException) { + return Results.NotFound($"Application with clientId '{clientId}' not found."); + } + catch (Exception ex) { + logger.LogError(ex, "Unexpected error while retrieving roles for clientId {ClientId}", clientId); + return Results.Problem("An unexpected error occurred while retrieving application roles."); + } + + if (applicationResponse?.Application == null) { + return Results.NotFound(); + } + + var response = await interaction.DispatchAsync( + new GetServiceUsersRequest { + ApplicationId = applicationResponse.Application.Id, + PageNumber = page, + PageSize = pageSize, + } + ).To(); + + return Results.Ok(response); + } +} diff --git a/src/Dfe.SignIn.PublicApi/Endpoints/Users/UserEndpoints.cs b/src/Dfe.SignIn.PublicApi/Endpoints/Users/UserEndpoints.cs index 804fe144..221a819f 100644 --- a/src/Dfe.SignIn.PublicApi/Endpoints/Users/UserEndpoints.cs +++ b/src/Dfe.SignIn.PublicApi/Endpoints/Users/UserEndpoints.cs @@ -1,19 +1,25 @@ -using System.Diagnostics.CodeAnalysis; +using Dfe.SignIn.Core.Contracts.Users; namespace Dfe.SignIn.PublicApi.Endpoints.Users; /// -/// Endpoints for user feature. +/// Provides extension methods for configuring user-related API endpoints in an ASP.NET Core application. /// +/// This class contains methods to register user endpoints with the application's routing system. It is +/// intended to be used during application startup to enable user-related API routes. The methods are designed to be +/// called on an instance of IEndpointRouteBuilder, typically within the Program.cs or Startup.cs file. public static partial class UserEndpoints { - /// - /// Registers all endpoints for user features. - /// - /// The application instance. - [ExcludeFromCodeCoverage] - public static void UseUserEndpoints(this WebApplication app) + /// + public static void UseUserEndpoints(this IEndpointRouteBuilder app) { app.MapPost("v2/users/{userId}/organisations/{organisationId}/query", PostQueryUserOrganisation); + + app.MapGet("/users", GetServiceUsers) + .WithName("GetServiceUsersRequest") + .WithTags("Users") + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status400BadRequest) + .WithOpenApi(); } } diff --git a/src/Dfe.SignIn.PublicApi/Program.cs b/src/Dfe.SignIn.PublicApi/Program.cs index e895383f..545d1ad1 100644 --- a/src/Dfe.SignIn.PublicApi/Program.cs +++ b/src/Dfe.SignIn.PublicApi/Program.cs @@ -6,11 +6,13 @@ using Dfe.SignIn.Core.UseCases.SelectOrganisation; using Dfe.SignIn.Gateways.DistributedCache; using Dfe.SignIn.Gateways.DistributedCache.SelectOrganisation; +using Dfe.SignIn.Gateways.EntityFramework.Configuration; using Dfe.SignIn.Gateways.ServiceBus; using Dfe.SignIn.InternalApi.Client; using Dfe.SignIn.NodeApi.Client; using Dfe.SignIn.PublicApi.Authorization; using Dfe.SignIn.PublicApi.Configuration; +using Dfe.SignIn.PublicApi.Endpoints.Applications; using Dfe.SignIn.PublicApi.Endpoints.SelectOrganisation; using Dfe.SignIn.PublicApi.Endpoints.Users; using Dfe.SignIn.WebFramework.Configuration; @@ -89,7 +91,17 @@ builder.Configuration.GetRequiredSection("SelectOrganisationSessionRedisCache")) .AddSelectOrganisationSessionCache() .Configure(builder.Configuration.GetRequiredSection("SelectOrganisation")) - .SetupSelectOrganisationInteractions(); + .SetupSelectOrganisationInteractions() + .SetupApplicationInteractions() + .SetupUserInteractions(); + +builder.Services + .AddUnitOfWorkEntityFrameworkServices( + builder.Configuration.GetRequiredSection("EntityFramework"), + addDirectoriesUnitOfWork: true, + addOrganisationsUnitOfWork: true, + addAuditUnitOfWork: false + ); builder.Services.SetupApiSecretEncryption(builder.Configuration); @@ -109,5 +121,6 @@ app.UseSelectOrganisationEndpoints(); app.UseUserEndpoints(); +app.UseApplicationEndpoints(); await app.RunAsync(); diff --git a/tests/Dfe.SignIn.Core.UseCases.UnitTests/Applications/GetApplicationRolesUseCaseTests.cs b/tests/Dfe.SignIn.Core.UseCases.UnitTests/Applications/GetApplicationRolesUseCaseTests.cs new file mode 100644 index 00000000..5280113f --- /dev/null +++ b/tests/Dfe.SignIn.Core.UseCases.UnitTests/Applications/GetApplicationRolesUseCaseTests.cs @@ -0,0 +1,209 @@ +using Dfe.SignIn.Core.Contracts.Applications; +using Dfe.SignIn.Core.Entities.Organisations; +using Dfe.SignIn.Core.UseCases.Applications; +using Moq.AutoMock; + +namespace Dfe.SignIn.Core.UseCases.UnitTests.Applications; + +[TestClass] +public sealed class GetApplicationRolesUseCaseTests +{ + [TestMethod] + public Task Throws_WhenRequestIsInvalid() + { + return InteractionAssert.ThrowsWhenRequestIsInvalid< + GetApplicationRolesRequest, + GetApplicationRolesUseCase + >(); + } + + private static readonly Guid AppAId = Guid.Parse("01e876a1-a06d-4945-bbe0-599a3d73dd11"); + private static readonly Guid AppBId = Guid.Parse("33510512-33f3-491c-86ca-e036726584d0"); + private static readonly Guid AppCId = Guid.Parse("f8da743c-eeb7-4be4-8045-54fba2e13419"); + + private static async Task SetupFakeDatabaseAsync(AutoMocker autoMocker) + { + var ctx = autoMocker.UseInMemoryOrganisationsDb(); + + ctx.Services.Add(new ServiceEntity { + Id = AppAId, + Name = "Fake Application A", + ClientId = "fake-app-a", + ClientSecret = "fake-client-secret-a", + Description = "The first fake example application.", + ServiceHome = "https://fake-application-a.localhost", + IsExternalService = false, + IsHiddenService = false, + IsIdOnlyService = false, + }); + + ctx.Services.Add(new ServiceEntity { + Id = AppBId, + Name = "Fake Application B", + ClientId = "fake-app-b", + ClientSecret = "fake-client-secret-b", + Description = "The second fake example application.", + ServiceHome = "https://fake-application-b.localhost", + IsExternalService = true, + IsHiddenService = true, + IsIdOnlyService = true, + }); + + ctx.Services.Add(new ServiceEntity { + Id = AppCId, + Name = "Fake Application C", + ClientId = "fake-app-c", + ClientSecret = "fake-client-secret-c", + Description = "The third fake example application.", + ServiceHome = null, + IsExternalService = true, + IsHiddenService = false, + IsIdOnlyService = false, + }); + + // Add roles for AppA + ctx.Roles.Add(new RoleEntity { + Id = Guid.Parse("10000000-0000-0000-0000-000000000001"), + Name = "Role A1", + Code = "A1", + NumericId = 1, + Status = 1, + ApplicationId = AppAId + }); + ctx.Roles.Add(new RoleEntity { + Id = Guid.Parse("10000000-0000-0000-0000-000000000002"), + Name = "Role A2", + Code = "A2", + NumericId = 2, + Status = 2, + ApplicationId = AppAId + }); + // Add a role with a parent for AppA + ctx.Roles.Add(new RoleEntity { + Id = Guid.Parse("10000000-0000-0000-0000-000000000003"), + Name = "Role A2 Child", + Code = "A2C", + NumericId = 3, + Status = 1, + ApplicationId = AppAId, + ParentId = Guid.Parse("10000000-0000-0000-0000-000000000002") + }); + + // Add roles for AppB + ctx.Roles.Add(new RoleEntity { + Id = Guid.Parse("20000000-0000-0000-0000-000000000001"), + Name = "Role B1", + Code = "B1", + NumericId = 10, + Status = 1, + ApplicationId = AppBId + }); + + // AppC has no roles + + await ctx.SaveChangesAsync(); + } + + public static IEnumerable GetCasesForReturnsApplicationRoles() + { + // AppA: 2 top-level roles, 1 child role + yield return new object[] { + AppAId, + new GetApplicationRolesResponse { + Roles = [ + new() { + Id = Guid.Parse("10000000-0000-0000-0000-000000000001"), + Name = "Role A1", + Code = "A1", + NumericId = 1, + Status = (ApplicationRoleStatus)1, + Parent = null + }, + new() { + Id = Guid.Parse("10000000-0000-0000-0000-000000000002"), + Name = "Role A2", + Code = "A2", + NumericId = 2, + Status = (ApplicationRoleStatus)2, + Parent = null + }, + new() { + Id = Guid.Parse("10000000-0000-0000-0000-000000000003"), + Name = "Role A2 Child", + Code = "A2C", + NumericId = 3, + Status = (ApplicationRoleStatus)1, + Parent = new ApplicationRole { + Id = Guid.Parse("10000000-0000-0000-0000-000000000002"), + Name = "Role A2", + Code = "A2", + NumericId = 2, + Status = (ApplicationRoleStatus)2, + Parent = null + } + } + ] + } + }; + // AppB: 1 role + yield return new object[] { + AppBId, + new GetApplicationRolesResponse { + Roles = [ + new() { + Id = Guid.Parse("20000000-0000-0000-0000-000000000001"), + Name = "Role B1", + Code = "B1", + NumericId = 10, + Status = (ApplicationRoleStatus)1, + Parent = null + } + ] + } + }; + // AppC: no roles + yield return new object[] { + AppCId, + new GetApplicationRolesResponse { + Roles = [] + } + }; + } + + [TestMethod] + [DynamicData(nameof(GetCasesForReturnsApplicationRoles), DynamicDataSourceType.Method)] + public async Task ReturnsApplicationRoles(Guid applicationId, GetApplicationRolesResponse expectedResponse) + { + var autoMocker = new AutoMocker(); + await SetupFakeDatabaseAsync(autoMocker); + + var interactor = autoMocker.CreateInstance(); + + var response = await interactor.InvokeAsync( + new GetApplicationRolesRequest { + ApplicationId = applicationId, + } + ); + + // Compare roles deeply (order matters) + Assert.AreEqual(expectedResponse.Roles.Count(), response.Roles.Count(), "Role count mismatch"); + for (int i = 0 ; i < expectedResponse.Roles.Count() ; i++) { + AssertApplicationRoleEqual(expectedResponse.Roles.ElementAt(i), response.Roles.ElementAt(i)); + } + } + + private static void AssertApplicationRoleEqual(ApplicationRole expected, ApplicationRole actual) + { + Assert.AreEqual(expected.Id, actual.Id, "Role Id mismatch"); + Assert.AreEqual(expected.Name, actual.Name, "Role Name mismatch"); + Assert.AreEqual(expected.Code, actual.Code, "Role Code mismatch"); + Assert.AreEqual(expected.NumericId, actual.NumericId, "Role NumericId mismatch"); + Assert.AreEqual(expected.Status, actual.Status, "Role Status mismatch"); + if (expected.Parent == null) { + Assert.IsNull(actual.Parent, "Expected null parent"); + } + else { + AssertApplicationRoleEqual(expected.Parent, actual.Parent!); + } + } +} diff --git a/tests/Dfe.SignIn.Core.UseCases.UnitTests/Users/GetServiceUsersUseCaseTests.cs b/tests/Dfe.SignIn.Core.UseCases.UnitTests/Users/GetServiceUsersUseCaseTests.cs new file mode 100644 index 00000000..9c88fd3a --- /dev/null +++ b/tests/Dfe.SignIn.Core.UseCases.UnitTests/Users/GetServiceUsersUseCaseTests.cs @@ -0,0 +1,239 @@ + +using Dfe.SignIn.Core.Contracts.Users; +using Dfe.SignIn.Core.Entities.Directories; +using Dfe.SignIn.Core.Entities.Organisations; +using Dfe.SignIn.Core.UseCases.Users; +using Moq.AutoMock; + +namespace Dfe.SignIn.Core.UseCases.UnitTests.Users; + +[TestClass] +public class GetServiceUsersUseCaseTests +{ + private static readonly Guid ServiceId = Guid.Parse("b1e1e1e1-1111-1111-1111-111111111111"); + private static readonly Guid OrgId = Guid.Parse("a1a1a1a1-2222-2222-2222-222222222222"); + private static readonly Guid UserId = Guid.Parse("c1c1c1c1-3333-3333-3333-333333333333"); + + [TestMethod] + public Task Throws_WhenRequestIsInvalid() => InteractionAssert.ThrowsWhenRequestIsInvalid< + GetServiceUsersRequest, + GetServiceUsersUseCase + >(); + + private static OrganisationEntity CreateOrganisation(string name = "Org 1") + => new() { + Id = OrgId, + Name = name, + Status = 1, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow + }; + + private static UserEntity CreateUser(Guid? userId = null, string email = "user1@test.com") + => new() { + Sub = userId ?? UserId, + Email = email, + FirstName = "User", + LastName = "One", + Status = 1, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow, + Password = "pass", + Salt = "salt" + }; + + private static UserServiceEntity CreateUserService(Guid? userId = null, DateTime? createdAt = null) + => new() { + UserId = userId ?? UserId, + ServiceId = ServiceId, + OrganisationId = OrgId, + CreatedAt = createdAt ?? DateTime.UtcNow, + Status = 1, + UpdatedAt = createdAt ?? DateTime.UtcNow + }; + + private static RoleEntity CreateRole(Guid roleId, string name = "Test Role") + => new() { + Id = roleId, + Name = name, + Code = "TEST", + NumericId = 1, + Status = 1, + ApplicationId = Guid.NewGuid(), + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow + }; + + private static UserServiceRoleEntity CreateUserServiceRole(Guid roleId, RoleEntity? role = null) + { + var entity = new UserServiceRoleEntity { + UserId = UserId, + OrganisationId = OrgId, + ServiceId = ServiceId, + RoleId = roleId, + }; + + if (role != null) { + entity.Role = role; + } + + return entity; + } + + private static GetServiceUsersRequest CreateRequest(int pageNumber = 1, int pageSize = 25) + => new() { + ApplicationId = ServiceId, + PageNumber = pageNumber, + PageSize = pageSize + }; + + [TestMethod] + public async Task ReturnsEmpty_WhenNoServiceUsers() + { + var autoMocker = new AutoMocker(); + autoMocker.UseInMemoryOrganisationsDb(); + + var interactor = autoMocker.CreateInstance(); + var response = await interactor.InvokeAsync(new GetServiceUsersRequest { + ApplicationId = ServiceId, + PageNumber = 1, + PageSize = 25 + }); + + Assert.AreEqual(0, response.Users.Count); + Assert.AreEqual(0, response.NumberOfRecords); + Assert.AreEqual(1, response.Page); + Assert.AreEqual(0, response.NumberOfPages); + } + + [TestMethod] + public async Task ReturnsSingleUser_WithOrgAndRoles() + { + var autoMocker = new AutoMocker(); + var orgCtx = autoMocker.UseInMemoryOrganisationsDb(); + + orgCtx.Organisations.Add(CreateOrganisation()); + orgCtx.Users.Add(CreateUser()); + orgCtx.UserServices.Add(CreateUserService()); + + var roleId = Guid.NewGuid(); + var role = CreateRole(roleId); + + orgCtx.UserServiceRoles.Add(CreateUserServiceRole(roleId, role)); + + await orgCtx.SaveChangesAsync(); + + var interactor = autoMocker.CreateInstance(); + var response = await interactor.InvokeAsync(CreateRequest()); + + Assert.AreEqual(1, response.Users.Count); + + var user = response.Users[0]; + Assert.AreEqual("user1@test.com", user.Email); + Assert.AreEqual("Org 1", user.Organisation?.Name); + Assert.AreEqual("Test Role", user.Roles?[0].Name); + Assert.AreEqual(1, user.Roles?.Count); + Assert.AreEqual(1, response.NumberOfRecords); + Assert.AreEqual(1, response.NumberOfPages); + } + + [TestMethod] + public async Task Handles_NullRoleForeignKey() + { + var autoMocker = new AutoMocker(); + var orgCtx = autoMocker.UseInMemoryOrganisationsDb(); + + orgCtx.Organisations.Add(CreateOrganisation()); + orgCtx.Users.Add(CreateUser()); + orgCtx.UserServices.Add(CreateUserService()); + + var roleId = Guid.NewGuid(); + orgCtx.UserServiceRoles.Add(CreateUserServiceRole(roleId)); + + await orgCtx.SaveChangesAsync(); + + var interactor = autoMocker.CreateInstance(); + var response = await interactor.InvokeAsync(CreateRequest()); + + Assert.AreEqual(1, response.Users.Count); + Assert.AreEqual(0, response.Users[0].Roles?.Count); + } + + [TestMethod] + public async Task Handles_MissingUserOrganisationRole() + { + var autoMocker = new AutoMocker(); + var orgCtx = autoMocker.UseInMemoryOrganisationsDb(); + + orgCtx.Organisations.Add(CreateOrganisation("Test Org")); + orgCtx.Users.Add(CreateUser()); + orgCtx.UserServices.Add(CreateUserService()); + + await orgCtx.SaveChangesAsync(); + + var interactor = autoMocker.CreateInstance(); + var response = await interactor.InvokeAsync(CreateRequest()); + + Assert.AreEqual(1, response.Users.Count); + + var user = response.Users[0]; + Assert.AreEqual(string.Empty, user.RoleName); + Assert.IsNull(user.RoleId); + } + + [TestMethod] + public async Task Pagination_SkipTake_WithCorrectOrdering() + { + var autoMocker = new AutoMocker(); + var orgCtx = autoMocker.UseInMemoryOrganisationsDb(); + + for (int i = 0 ; i < 3 ; i++) { + var userId = Guid.Parse($"00000000-0000-0000-0000-00000000000{i + 1}"); + var user = CreateUser(userId, $"user{i + 1}@test.com"); + user.FirstName = $"User{i + 1}"; + user.LastName = "Test"; + + orgCtx.Users.Add(user); + orgCtx.UserServices.Add(new UserServiceEntity { + Id = Guid.NewGuid(), + UserId = userId, + ServiceId = ServiceId, + OrganisationId = OrgId, + CreatedAt = DateTime.UtcNow, + Status = 1, + UpdatedAt = DateTime.UtcNow + }); + } + + orgCtx.Organisations.Add(CreateOrganisation()); + await orgCtx.SaveChangesAsync(); + + var interactor = autoMocker.CreateInstance(); + var response = await interactor.InvokeAsync(CreateRequest(2, 2)); + + Assert.AreEqual(1, response.Users.Count); + Assert.AreEqual(3, response.NumberOfRecords); + Assert.AreEqual(2, response.NumberOfPages); + } + + [TestMethod] + public async Task ReturnsEmpty_WhenAllMappedUsersHaveEmptyEmail() + { + var autoMocker = new AutoMocker(); + var orgCtx = autoMocker.UseInMemoryOrganisationsDb(); + + orgCtx.Organisations.Add(CreateOrganisation()); + orgCtx.UserServices.Add(CreateUserService()); + orgCtx.Users.Add(CreateUser(email: string.Empty)); + + await orgCtx.SaveChangesAsync(); + + var interactor = autoMocker.CreateInstance(); + var response = await interactor.InvokeAsync(CreateRequest()); + + Assert.AreEqual(0, response.Users.Count); + Assert.AreEqual(0, response.NumberOfRecords); + Assert.AreEqual(1, response.Page); + Assert.AreEqual(0, response.NumberOfPages); + } +} diff --git a/tests/Dfe.SignIn.PublicApi.UnitTests/Endpoints/Applications/GetApplicationRolesTests.cs b/tests/Dfe.SignIn.PublicApi.UnitTests/Endpoints/Applications/GetApplicationRolesTests.cs new file mode 100644 index 00000000..19e8698b --- /dev/null +++ b/tests/Dfe.SignIn.PublicApi.UnitTests/Endpoints/Applications/GetApplicationRolesTests.cs @@ -0,0 +1,353 @@ +using Dfe.SignIn.Base.Framework; +using Dfe.SignIn.Core.Contracts.Applications; +using Dfe.SignIn.PublicApi.Authorization; +using Dfe.SignIn.PublicApi.Contracts.Applications; +using Dfe.SignIn.PublicApi.Endpoints.Applications; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +using Moq; +using Moq.AutoMock; + +namespace Dfe.SignIn.PublicApi.UnitTests.Endpoints.Applications; + +[TestClass] +public class GetApplicationRolesTests +{ + private const string FakeClientId = "test-client-id"; + private static readonly Guid FakeApplicationId = new("550e8400-e29b-41d4-a716-446655440000"); + + private static readonly Application FakeApplication = new() { + Id = FakeApplicationId, + ClientId = FakeClientId, + Name = "Test Application", + Description = "A test application", + IsExternalService = false, + IsHiddenService = false, + IsIdOnlyService = false + }; + + private static readonly ApplicationRole FakeCoreRole = new() { + Id = new Guid("a5a8e401-e29b-41d4-a716-446655440001"), + Code = "DSI_Child_One", + Name = "DSI Child One", + NumericId = 1, + Status = ApplicationRoleStatus.Active, + Parent = null + }; + + private static readonly GetApplicationRolesResponse FakeRolesResponse = new() { + Roles = [FakeCoreRole] + }; + + private static (AutoMocker, IClientSession, Mock, DefaultHttpContext) CreateMocks() + { + var autoMocker = new AutoMocker(); + var clientSession = autoMocker.GetMock(); + clientSession.SetupGet(x => x.ClientId).Returns(FakeClientId); + + var loggerFactory = new Mock(); + var httpContext = new DefaultHttpContext(); + httpContext.Request.Headers["X-Correlation-ID"] = "corr-123"; + return (autoMocker, clientSession.Object, loggerFactory, httpContext); + } + + [TestMethod] + public async Task ReturnsOkWithRoles_WhenRequesterIsParent() + { + var (autoMocker, clientSession, loggerFactory, httpContext) = CreateMocks(); + var logger = new Mock(); + loggerFactory.Setup(f => f.CreateLogger(It.IsAny())).Returns(logger.Object); + + // Simulate requester is parent of the application + var parentClientId = "parent-client-id"; + var parentId = new Guid("660e8400-e29b-41d4-a716-446655440000"); + var appWithParent = FakeApplication with { ClientId = "child-client-id", ParentClientId = parentClientId, ParentId = parentId }; + clientSession = autoMocker.GetMock().Object; + autoMocker.GetMock().SetupGet(x => x.ClientId).Returns(parentClientId); + + autoMocker.MockResponse( + new GetApplicationByClientIdResponse { Application = appWithParent } + ); + autoMocker.MockResponse(FakeRolesResponse); + + var result = await ApplicationEndpoints.GetApplicationRoles( + "child-client-id", + clientSession, + autoMocker.Get(), + loggerFactory.Object, + httpContext + ); + + Assert.IsInstanceOfType>>(result); + var okResult = result as Microsoft.AspNetCore.Http.HttpResults.Ok>; + Assert.IsNotNull(okResult); + Assert.AreEqual(1, okResult.Value!.Count()); + } + + [TestMethod] + public async Task ReturnsOkWithRoles_WhenRequesterIsSelf() + { + var (autoMocker, clientSession, loggerFactory, httpContext) = CreateMocks(); + var logger = new Mock(); + loggerFactory.Setup(f => f.CreateLogger(It.IsAny())).Returns(logger.Object); + + // Simulate requester is the application itself + autoMocker.GetMock().SetupGet(x => x.ClientId).Returns(FakeClientId); + autoMocker.MockResponse( + new GetApplicationByClientIdResponse { Application = FakeApplication } + ); + autoMocker.MockResponse(FakeRolesResponse); + + var result = await ApplicationEndpoints.GetApplicationRoles( + FakeClientId, + clientSession, + autoMocker.Get(), + loggerFactory.Object, + httpContext + ); + + Assert.IsInstanceOfType>>(result); + var okResult = result as Microsoft.AspNetCore.Http.HttpResults.Ok>; + Assert.IsNotNull(okResult); + Assert.AreEqual(1, okResult.Value!.Count()); + } + + [TestMethod] + public async Task ReturnsEmptyArray_WhenNoRoles() + { + var (autoMocker, clientSession, loggerFactory, httpContext) = CreateMocks(); + var logger = new Mock(); + loggerFactory.Setup(f => f.CreateLogger(It.IsAny())).Returns(logger.Object); + + autoMocker.MockResponse( + new GetApplicationByClientIdResponse { Application = FakeApplication } + ); + autoMocker.MockResponse(new GetApplicationRolesResponse { Roles = [] }); + + var result = await ApplicationEndpoints.GetApplicationRoles( + FakeClientId, + clientSession, + autoMocker.Get(), + loggerFactory.Object, + httpContext + ); + + Assert.IsInstanceOfType>>(result); + var okResult = result as Microsoft.AspNetCore.Http.HttpResults.Ok>; + Assert.IsNotNull(okResult); + Assert.AreEqual(0, okResult.Value!.Count()); + } + + [TestMethod] + public async Task ReturnsOnlyNameCodeStatusProperties_AsStrings() + { + var (autoMocker, clientSession, loggerFactory, httpContext) = CreateMocks(); + var logger = new Mock(); + loggerFactory.Setup(f => f.CreateLogger(It.IsAny())).Returns(logger.Object); + + var roles = new[] + { + new ApplicationRole { Id = Guid.NewGuid(), Name = "Role One", Code = "Role1", NumericId = 1, Status = ApplicationRoleStatus.Active }, + new ApplicationRole { Id = Guid.NewGuid(), Name = "Role Two", Code = "Role2", NumericId = 2, Status = ApplicationRoleStatus.Inactive } + }; + autoMocker.MockResponse( + new GetApplicationByClientIdResponse { Application = FakeApplication } + ); + autoMocker.MockResponse(new GetApplicationRolesResponse { Roles = roles }); + + var result = await ApplicationEndpoints.GetApplicationRoles( + FakeClientId, + clientSession, + autoMocker.Get(), + loggerFactory.Object, + httpContext + ); + + var okResult = result as Microsoft.AspNetCore.Http.HttpResults.Ok>; + Assert.IsNotNull(okResult); + foreach (var role in okResult.Value!) { + var props = role.GetType().GetProperties().Select(p => p.Name).OrderBy(x => x).ToArray(); + CollectionAssert.AreEqual(new[] { "Code", "Name", "Status" }, props); + Assert.IsInstanceOfType(role.Name); + Assert.IsInstanceOfType(role.Code); + Assert.IsInstanceOfType(role.Status); + } + } + + [TestMethod] + public async Task ReturnsCorrectNameAndCode_ForMultipleRoles() + { + var (autoMocker, clientSession, loggerFactory, httpContext) = CreateMocks(); + var logger = new Mock(); + loggerFactory.Setup(f => f.CreateLogger(It.IsAny())).Returns(logger.Object); + + var roles = new[] + { + new ApplicationRole { Id = Guid.NewGuid(), Name = "Role One", Code = "Role1", NumericId = 1, Status = ApplicationRoleStatus.Active }, + new ApplicationRole { Id = Guid.NewGuid(), Name = "Role Two", Code = "Role2", NumericId = 2, Status = ApplicationRoleStatus.Inactive } + }; + autoMocker.MockResponse( + new GetApplicationByClientIdResponse { Application = FakeApplication } + ); + autoMocker.MockResponse(new GetApplicationRolesResponse { Roles = roles }); + + var result = await ApplicationEndpoints.GetApplicationRoles( + FakeClientId, + clientSession, + autoMocker.Get(), + loggerFactory.Object, + httpContext + ); + + var okResult = result as Microsoft.AspNetCore.Http.HttpResults.Ok>; + Assert.IsNotNull(okResult); + var resultList = okResult.Value!.ToList(); + Assert.AreEqual("Role One", resultList[0].Name); + Assert.AreEqual("Role1", resultList[0].Code); + Assert.AreEqual("Role Two", resultList[1].Name); + Assert.AreEqual("Role2", resultList[1].Code); + } + + [TestMethod] + public async Task ReturnsStatusAsInactive_IfStatusIdIsZero() + { + var (autoMocker, clientSession, loggerFactory, httpContext) = CreateMocks(); + var logger = new Mock(); + loggerFactory.Setup(f => f.CreateLogger(It.IsAny())).Returns(logger.Object); + + var roles = new[] + { + new ApplicationRole { Id = Guid.NewGuid(), Name = "Role One", Code = "Role1", NumericId = 1, Status = 0 } + }; + autoMocker.MockResponse( + new GetApplicationByClientIdResponse { Application = FakeApplication } + ); + autoMocker.MockResponse(new GetApplicationRolesResponse { Roles = roles }); + + var result = await ApplicationEndpoints.GetApplicationRoles( + FakeClientId, + clientSession, + autoMocker.Get(), + loggerFactory.Object, + httpContext + ); + + var okResult = result as Microsoft.AspNetCore.Http.HttpResults.Ok>; + Assert.IsNotNull(okResult); + Assert.AreEqual("Inactive", okResult.Value!.First().Status); + } + + [TestMethod] + public async Task ReturnsStatusAsInactive_IfStatusPropertyMissing() + { + var (autoMocker, clientSession, loggerFactory, httpContext) = CreateMocks(); + var logger = new Mock(); + loggerFactory.Setup(f => f.CreateLogger(It.IsAny())).Returns(logger.Object); + + // Simulate missing status by using default value (0) + var roles = new[] + { + new ApplicationRole { Id = Guid.NewGuid(), Name = "Role One", Code = "Role1", NumericId = 1, Status = 0 } + }; + autoMocker.MockResponse( + new GetApplicationByClientIdResponse { Application = FakeApplication } + ); + autoMocker.MockResponse(new GetApplicationRolesResponse { Roles = roles }); + + var result = await ApplicationEndpoints.GetApplicationRoles( + FakeClientId, + clientSession, + autoMocker.Get(), + loggerFactory.Object, + httpContext + ); + + var okResult = result as Microsoft.AspNetCore.Http.HttpResults.Ok>; + Assert.IsNotNull(okResult); + Assert.AreEqual("Inactive", okResult.Value!.First().Status); + } + + [TestMethod] + public async Task ReturnsOkWithRoles_WhenAuthorized() + { + var (autoMocker, clientSession, loggerFactory, httpContext) = CreateMocks(); + var logger = new Mock(); + loggerFactory.Setup(f => f.CreateLogger(It.IsAny())).Returns(logger.Object); + + autoMocker.MockResponse( + new GetApplicationByClientIdResponse { Application = FakeApplication } + ); + autoMocker.MockResponse(FakeRolesResponse); + + var result = await ApplicationEndpoints.GetApplicationRoles( + FakeClientId, + clientSession, + autoMocker.Get(), + loggerFactory.Object, + httpContext + ); + + Assert.IsInstanceOfType(result); + var okResult = result as Microsoft.AspNetCore.Http.HttpResults.Ok>; + Assert.IsNotNull(okResult); + var roles = okResult.Value!.ToList(); + Assert.HasCount(1, roles); + Assert.AreEqual("DSI Child One", roles[0].Name); + Assert.AreEqual("DSI_Child_One", roles[0].Code); + Assert.AreEqual("Active", roles[0].Status); + } + + [TestMethod] + public async Task ReturnsForbid_WhenClientIdDoesNotMatch() + { + var (autoMocker, clientSession, loggerFactory, httpContext) = CreateMocks(); + var logger = new Mock(); + loggerFactory.Setup(f => f.CreateLogger(It.IsAny())).Returns(logger.Object); + + var otherApp = FakeApplication with { ClientId = "other-client-id" }; + autoMocker.MockResponse( + new GetApplicationByClientIdResponse { Application = otherApp } + ); + + var result = await ApplicationEndpoints.GetApplicationRoles( + FakeClientId, + clientSession, + autoMocker.Get(), + loggerFactory.Object, + httpContext + ); + + Assert.IsInstanceOfType(result); + } + + [TestMethod] + public async Task LogsRequest_WithCorrelationIds() + { + var (autoMocker, clientSession, loggerFactory, httpContext) = CreateMocks(); + + autoMocker.MockResponse( + new GetApplicationByClientIdResponse { Application = FakeApplication } + ); + autoMocker.MockResponse(FakeRolesResponse); + + var logger = new Mock(); + logger.Setup(l => l.IsEnabled(LogLevel.Information)).Returns(true); + loggerFactory.Setup(f => f.CreateLogger(It.IsAny())).Returns(logger.Object); + + await ApplicationEndpoints.GetApplicationRoles( + FakeClientId, + clientSession, + autoMocker.Get(), + loggerFactory.Object, + httpContext + ); + + // Assert log was called and contains expected values + var logInvocation = logger.Invocations.FirstOrDefault(inv => inv.Method.Name == "Log"); + Assert.IsNotNull(logInvocation, "Log was not called"); + var logMessage = logInvocation.Arguments[2]?.ToString(); + Assert.IsNotNull(logMessage); + Assert.Contains(FakeClientId, logMessage); + Assert.Contains("corr-123", logMessage); + } +} diff --git a/tests/Dfe.SignIn.PublicApi.UnitTests/Endpoints/Users/GetServiceUsersTests.cs b/tests/Dfe.SignIn.PublicApi.UnitTests/Endpoints/Users/GetServiceUsersTests.cs new file mode 100644 index 00000000..64ea2dff --- /dev/null +++ b/tests/Dfe.SignIn.PublicApi.UnitTests/Endpoints/Users/GetServiceUsersTests.cs @@ -0,0 +1,147 @@ +using Dfe.SignIn.Base.Framework; +using Dfe.SignIn.Core.Contracts.Applications; +using Dfe.SignIn.Core.Contracts.Users; +using Dfe.SignIn.PublicApi.Authorization; +using Dfe.SignIn.PublicApi.Endpoints.Users; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +using Moq; +using Moq.AutoMock; + +namespace Dfe.SignIn.PublicApi.UnitTests.Endpoints.Users; + +[TestClass] +public class GetServiceUsersTests +{ + private const string FakeClientId = "test-client-id"; + private static readonly Guid FakeServiceId = Guid.NewGuid(); + + private static Application CreateTestApplication() => new() { + Id = FakeServiceId, + ClientId = FakeClientId, + Name = "Test Application", + IsExternalService = true, + IsIdOnlyService = false, + IsHiddenService = false + }; + + private static (AutoMocker, IClientSession, Mock, DefaultHttpContext) CreateMocks() + { + var autoMocker = new AutoMocker(); + var clientSession = autoMocker.GetMock(); + clientSession.SetupGet(x => x.ClientId).Returns(FakeClientId); + + var loggerFactory = new Mock(); + var logger = new Mock(); + loggerFactory.Setup(f => f.CreateLogger(It.IsAny())).Returns(logger.Object); + + var httpContext = new DefaultHttpContext(); + httpContext.Request.Headers["X-Correlation-ID"] = "corr-123"; + return (autoMocker, clientSession.Object, loggerFactory, httpContext); + } + + private static void MockApplicationLookup(AutoMocker autoMocker, Application? application = null) + { + autoMocker.MockResponse(new GetApplicationByClientIdResponse { + Application = application ?? CreateTestApplication() + }); + } + + private static void MockGetServiceUsersResponse(AutoMocker autoMocker, int page = 1) + { + autoMocker.MockResponse(new GetServiceUsersResponse { + Users = [], + NumberOfRecords = 0, + Page = page, + NumberOfPages = 0 + }); + } + + [TestMethod] + public async Task Returns404_WhenApplicationNotFound() + { + var (autoMocker, clientSession, loggerFactory, httpContext) = CreateMocks(); + + var mockClientSession = autoMocker.GetMock(); + mockClientSession.SetupGet(x => x.ClientId).Returns("fake-client-id"); + + var result = await UserEndpoints.GetServiceUsers( + mockClientSession.Object, + autoMocker.Get(), + loggerFactory.Object, + httpContext + ); + + Assert.IsInstanceOfType(result, typeof(Microsoft.AspNetCore.Http.HttpResults.NotFound)); + } + + [TestMethod] + public async Task ReturnsOk_WhenValidRequest() + { + var (autoMocker, clientSession, loggerFactory, httpContext) = CreateMocks(); + + MockApplicationLookup(autoMocker); + var capturedRequest = (GetServiceUsersRequest?)null; + autoMocker.CaptureRequest(req => capturedRequest = req, new GetServiceUsersResponse { + Users = [], + NumberOfRecords = 0, + Page = 1, + NumberOfPages = 0 + }); + + var result = await UserEndpoints.GetServiceUsers( + clientSession, + autoMocker.Get(), + loggerFactory.Object, + httpContext + ); + + Assert.IsInstanceOfType(result, typeof(Microsoft.AspNetCore.Http.HttpResults.Ok)); + var okResult = result as Microsoft.AspNetCore.Http.HttpResults.Ok; + Assert.IsNotNull(okResult?.Value); + // Verify request forwarding + Assert.IsNotNull(capturedRequest); + Assert.AreEqual(FakeServiceId, capturedRequest.ApplicationId); + Assert.AreEqual(1, capturedRequest.PageNumber); + Assert.AreEqual(25, capturedRequest.PageSize); + } + + [TestMethod] + public async Task ExtractParam_IsCaseInsensitive() + { + var (autoMocker, clientSession, loggerFactory, httpContext) = CreateMocks(); + httpContext.Request.QueryString = new QueryString("?PaGe=2&PaGeSiZe=10"); + MockApplicationLookup(autoMocker); + MockGetServiceUsersResponse(autoMocker, page: 2); + + var result = await UserEndpoints.GetServiceUsers( + clientSession, + autoMocker.Get(), + loggerFactory.Object, + httpContext + ); + + Assert.IsInstanceOfType(result, typeof(Microsoft.AspNetCore.Http.HttpResults.Ok)); + } + + [TestMethod] + public async Task ReturnsOk_WithValidFilterParameters() + { + var (autoMocker, clientSession, loggerFactory, httpContext) = CreateMocks(); + httpContext.Request.QueryString = new QueryString("?from=2023-01-01&to=2023-01-05&page=2&pageSize=25&status=0"); + + MockApplicationLookup(autoMocker); + MockGetServiceUsersResponse(autoMocker, page: 2); + + var result = await UserEndpoints.GetServiceUsers( + clientSession, + autoMocker.Get(), + loggerFactory.Object, + httpContext + ); + + Assert.IsInstanceOfType(result, typeof(Microsoft.AspNetCore.Http.HttpResults.Ok)); + var okResult = result as Microsoft.AspNetCore.Http.HttpResults.Ok; + Assert.IsNotNull(okResult?.Value); + } +}