Skip to content

Commit

Permalink
Add One Login config to Edit Application User UI + tests (#1119)
Browse files Browse the repository at this point in the history
  • Loading branch information
hortha authored Jan 30, 2024
1 parent 1554d10 commit 60a0209
Show file tree
Hide file tree
Showing 15 changed files with 1,415 additions and 12 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ public class ApplicationUserMapping : IEntityTypeConfiguration<ApplicationUser>
public void Configure(EntityTypeBuilder<ApplicationUser> builder)
{
builder.Property(e => e.ApiRoles).HasColumnType("varchar[]");
builder.Property(e => e.OneLoginClientId).HasMaxLength(50);
builder.Property(e => e.OneLoginPrivateKeyPem).HasMaxLength(2000);
}
}

Expand Down

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
using Microsoft.EntityFrameworkCore.Migrations;

#nullable disable

namespace TeachingRecordSystem.Core.DataStore.Postgres.Migrations
{
/// <inheritdoc />
public partial class ApplicationUserOneLoginClientIdAndPem : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "one_login_client_id",
table: "users",
type: "character varying(50)",
maxLength: 50,
nullable: true);

migrationBuilder.AddColumn<string>(
name: "one_login_private_key_pem",
table: "users",
type: "character varying(2000)",
maxLength: 2000,
nullable: true);
}

/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "one_login_client_id",
table: "users");

migrationBuilder.DropColumn(
name: "one_login_private_key_pem",
table: "users");
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -862,6 +862,16 @@ protected override void BuildModel(ModelBuilder modelBuilder)
.HasColumnType("varchar[]")
.HasColumnName("api_roles");

b.Property<string>("OneLoginClientId")
.HasMaxLength(50)
.HasColumnType("character varying(50)")
.HasColumnName("one_login_client_id");

b.Property<string>("OneLoginPrivateKeyPem")
.HasMaxLength(2000)
.HasColumnType("character varying(2000)")
.HasColumnName("one_login_private_key_pem");

b.HasDiscriminator().HasValue(2);
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,13 @@ public class User : UserBase

public class ApplicationUser : UserBase
{
public const int OneLoginClientIdMaxLength = 50;
public const string NameUniqueIndexName = "ix_users_application_user_name";

public required string[] ApiRoles { get; set; }
public ICollection<ApiKey> ApiKeys { get; } = null!;
public string? OneLoginClientId { get; set; }
public string? OneLoginPrivateKeyPem { get; set; }
}

public class SystemUser : UserBase
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,6 @@ public enum ApplicationUserUpdatedEventChanges
None = 0,
Name = 1 << 0,
ApiRoles = 1 << 1,
OneLoginClientId = 1 << 2,
OneLoginPrivateKeyPem = 1 << 3
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,15 @@ public record ApplicationUser
public required Guid UserId { get; init; }
public required string Name { get; init; }
public required string[] ApiRoles { get; set; }
public string? OneLoginClientId { get; set; }
public string? OneLoginPrivateKeyPem { get; set; }

public static ApplicationUser FromModel(DataStore.Postgres.Models.ApplicationUser user) => new()
{
UserId = user.UserId,
Name = user.Name,
ApiRoles = user.ApiRoles
ApiRoles = user.ApiRoles,
OneLoginClientId = user.OneLoginClientId,
OneLoginPrivateKeyPem = user.OneLoginPrivateKeyPem
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@
{
<tr class="govuk-table__row">
<td class="govuk-table__cell">
<a href="@LinkGenerator.EditApiKey(key.ApiKeyId)" class="govuk-link">@key.ApiKeyId</a>
<a href="@LinkGenerator.EditApiKey(key.ApiKeyId)" class="govuk-link" data-testid="[email protected]">@key.ApiKeyId</a>
</td>
<td class="govuk-table__cell" data-testid="Expiry">
@if (key.Expires is DateTime expires)
Expand Down Expand Up @@ -72,9 +72,13 @@
</table>

<div class="govuk-!-margin-bottom-1">
<govuk-button-link href="@LinkGenerator.AddApiKey(Model.UserId)" class="govuk-button--secondary">Add API key</govuk-button-link>
<govuk-button-link href="@LinkGenerator.AddApiKey(Model.UserId)" class="govuk-button--secondary" data-testid="AddApiKey">Add API key</govuk-button-link>
</div>

<h2 class="govuk-heading-m">One Login</h2>
<govuk-input asp-for="OneLoginClientId" label-class="govuk-label--s" autocomplete="off" />
<govuk-textarea asp-for="OneLoginPrivateKeyPem" label-class="govuk-label--s" />

<govuk-button type="submit">Save changes</govuk-button>
</form>
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.ComponentModel.DataAnnotations;
using System.Security.Cryptography;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
Expand Down Expand Up @@ -32,17 +33,50 @@ public class EditApplicationUserModel(TrsDbContext dbContext, TrsLinkGenerator l
[Display(Name = "API keys")]
public ApiKeyInfo[]? ApiKeys { get; set; }

[BindProperty]
[Display(Name = "Client ID")]
[MaxLength(ApplicationUser.OneLoginClientIdMaxLength, ErrorMessage = "One Login Client ID must be 50 characters or less")]
public string? OneLoginClientId { get; set; }

[BindProperty]
[Display(Name = "Private Key PEM")]
public string? OneLoginPrivateKeyPem { get; set; }

public void OnGet()
{
Name = _user!.Name;
ApiRoles = _user.ApiRoles;
OneLoginClientId = _user.OneLoginClientId;
OneLoginPrivateKeyPem = _user.OneLoginPrivateKeyPem;
}

public async Task<IActionResult> OnPost()
{
// Sanitize roles
var newApiRoles = ApiRoles!.Where(r => Core.ApiRoles.All.Contains(r)).ToArray();

if (OneLoginClientId is not null && OneLoginPrivateKeyPem is null)
{
ModelState.AddModelError(nameof(OneLoginPrivateKeyPem), "One Login Private Key PEM is required if One Login Client ID is set");
}

if (OneLoginPrivateKeyPem is not null && OneLoginClientId is null)
{
ModelState.AddModelError(nameof(OneLoginClientId), "One Login Client ID is required if One Login Private Key PEM is set");
}

if (OneLoginPrivateKeyPem is not null && OneLoginClientId is not null)
{
try
{
RSA.Create().ImportFromPem(OneLoginPrivateKeyPem);
}
catch (ArgumentException)
{
ModelState.AddModelError(nameof(OneLoginPrivateKeyPem), "One Login Private Key PEM is invalid");
}
}

if (!ModelState.IsValid)
{
return this.PageWithErrors();
Expand All @@ -52,14 +86,18 @@ public async Task<IActionResult> OnPost()

var changes = ApplicationUserUpdatedEventChanges.None |
(Name != applicationUser.Name ? ApplicationUserUpdatedEventChanges.Name : 0) |
(!new HashSet<string>(applicationUser.ApiRoles).SetEquals(new HashSet<string>(newApiRoles)) ? ApplicationUserUpdatedEventChanges.ApiRoles : 0);
(!new HashSet<string>(applicationUser.ApiRoles).SetEquals(new HashSet<string>(newApiRoles)) ? ApplicationUserUpdatedEventChanges.ApiRoles : 0) |
(OneLoginClientId != applicationUser.OneLoginClientId ? ApplicationUserUpdatedEventChanges.OneLoginClientId : 0) |
(OneLoginPrivateKeyPem != applicationUser.OneLoginPrivateKeyPem ? ApplicationUserUpdatedEventChanges.OneLoginPrivateKeyPem : 0);

if (changes != ApplicationUserUpdatedEventChanges.None)
{
var oldApplicationUser = Core.Events.Models.ApplicationUser.FromModel(applicationUser);

applicationUser.Name = Name!;
applicationUser.ApiRoles = newApiRoles;
applicationUser.OneLoginClientId = OneLoginClientId;
applicationUser.OneLoginPrivateKeyPem = OneLoginPrivateKeyPem;

var @event = new ApplicationUserUpdatedEvent()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
{
<tr class="govuk-table__row" data-testid="[email protected]">
<td class="govuk-table__cell">
<a href="@LinkGenerator.EditApplicationUser(user.UserId)" class="govuk-link" data-testid="[email protected]">@user.Name</a>
<a href="@LinkGenerator.EditApplicationUser(user.UserId)" class="govuk-link" data-testid="edit-application-[email protected]">@user.Name</a>
</td>
</tr>
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
using System.Security.Cryptography;
using Microsoft.EntityFrameworkCore;
using TeachingRecordSystem.Core;

namespace TeachingRecordSystem.SupportUi.EndToEndTests;

public class ApplicationUserTests(HostFixture hostFixture) : TestBase(hostFixture)
{
[Fact]
public async Task AddApplicationUser()
{
var applicationUserName = TestData.GenerateApplicationUserName();

await using var context = await HostFixture.CreateBrowserContext();
var page = await context.NewPageAsync();

await page.GoToApplicationUsersPage();

await page.AssertOnApplicationUsersPage();

await page.ClickLinkForElementWithTestId("add-application-user");

await page.AssertOnAddApplicationUserPage();

await page.FillAsync("text=Name", applicationUserName);

await page.ClickButton("Save");

var applicationUserId = await WithDbContext(async dbContext =>
{
var applicationUser = await dbContext.ApplicationUsers.Where(u => u.Name == applicationUserName).SingleOrDefaultAsync();
return applicationUser!.UserId;
});

await page.AssertOnEditApplicationUserPage(applicationUserId);

await page.AssertFlashMessage("Application user added");
}

[Fact]
public async Task EditApplicationUser()
{
var applicationUser = await TestData.CreateApplicationUser();
var applicationUserId = applicationUser.UserId;
var newApplicationUserName = TestData.GenerateChangedApplicationUserName(applicationUser.Name);
var newOneLoginClientId = Guid.NewGuid().ToString();
var newOneLoginPrivateKeyPem = TestCommon.TestData.GeneratePrivateKeyPem();

await using var context = await HostFixture.CreateBrowserContext();
var page = await context.NewPageAsync();

await page.GoToApplicationUsersPage();

await page.AssertOnApplicationUsersPage();

await page.ClickLinkForElementWithTestId($"edit-application-user-{applicationUserId}");

await page.AssertOnEditApplicationUserPage(applicationUserId);

await page.FillAsync("text=Name", newApplicationUserName);
await page.SetCheckedAsync($"label:text-is('{ApiRoles.GetPerson}')", true);
await page.SetCheckedAsync($"label:text-is('{ApiRoles.UpdatePerson}')", true);
await page.FillAsync("text=Client ID", newOneLoginClientId);
await page.FillAsync("text=Private Key PEM", newOneLoginPrivateKeyPem);

await page.ClickButton("Save changes");

await page.AssertOnApplicationUsersPage();

await page.AssertFlashMessage("Application user updated");
}

[Fact]
public async Task AddApiKey()
{
var applicationUser = await TestData.CreateApplicationUser();
var applicationUserId = applicationUser.UserId;
var apiKey = Convert.ToHexString(RandomNumberGenerator.GetBytes(32));

await using var context = await HostFixture.CreateBrowserContext();
var page = await context.NewPageAsync();

await page.GoToApplicationUsersPage();

await page.AssertOnApplicationUsersPage();

await page.ClickLinkForElementWithTestId($"edit-application-user-{applicationUserId}");

await page.AssertOnEditApplicationUserPage(applicationUserId);

await page.ClickLinkForElementWithTestId("AddApiKey");

await page.AssertOnAddApiKeyPage();

await page.FillAsync("label:text-is('Key')", apiKey);

await page.ClickButton("Save");

await page.AssertOnEditApplicationUserPage(applicationUserId);

await page.AssertFlashMessage("API key added");
}

[Fact]
public async Task EditApiKey()
{
var applicationUser = await TestData.CreateApplicationUser();
var applicationUserId = applicationUser.UserId;
var apiKey = await TestData.CreateApiKey(applicationUser.UserId);

await using var context = await HostFixture.CreateBrowserContext();
var page = await context.NewPageAsync();

await page.GoToApplicationUsersPage();

await page.AssertOnApplicationUsersPage();

await page.ClickLinkForElementWithTestId($"edit-application-user-{applicationUserId}");

await page.AssertOnEditApplicationUserPage(applicationUserId);

await page.ClickLinkForElementWithTestId($"EditApiKey-{apiKey.ApiKeyId}");

await page.AssertOnEditApiKeyPage(apiKey.ApiKeyId);

await page.ClickButton("Expire");

await page.AssertOnEditApplicationUserPage(applicationUserId);

await page.AssertFlashMessage("API key expired");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,11 @@ public static async Task GoToUsersPage(this IPage page)
await page.GotoAsync($"/users");
}

public static async Task GoToApplicationUsersPage(this IPage page)
{
await page.GotoAsync($"/application-users");
}

public static Task ClickLinkForElementWithTestId(this IPage page, string testId) =>
page.GetByTestId(testId).ClickAsync();

Expand Down Expand Up @@ -265,6 +270,31 @@ public static async Task AssertOnEditUserPage(this IPage page, Guid userId)
await page.WaitForUrlPathAsync($"/users/{userId}");
}

public static async Task AssertOnApplicationUsersPage(this IPage page)
{
await page.WaitForUrlPathAsync($"/application-users");
}

public static async Task AssertOnAddApplicationUserPage(this IPage page)
{
await page.WaitForUrlPathAsync($"/application-users/add");
}

public static async Task AssertOnEditApplicationUserPage(this IPage page, Guid applicationUserId)
{
await page.WaitForUrlPathAsync($"/application-users/{applicationUserId}");
}

public static async Task AssertOnAddApiKeyPage(this IPage page)
{
await page.WaitForUrlPathAsync($"/api-keys/add");
}

public static async Task AssertOnEditApiKeyPage(this IPage page, Guid apiKeyId)
{
await page.WaitForUrlPathAsync($"/api-keys/{apiKeyId}");
}

public static async Task AssertFlashMessage(this IPage page, string expectedHeader)
{
Assert.Equal(expectedHeader, await page.InnerTextAsync($".govuk-notification-banner__heading:text-is('{expectedHeader}')"));
Expand Down
Loading

0 comments on commit 60a0209

Please sign in to comment.