From 25790488bedcf303e9432e95ac6a8c30e0d3f428 Mon Sep 17 00:00:00 2001
From: James Gunn
Date: Wed, 27 Sep 2023 09:56:33 +0100
Subject: [PATCH] Add TRN verification elevation journey (#728)
---
.../AuthenticationState.cs | 61 +++--
.../TeacherIdentity.AuthServer/Events/User.cs | 4 +
.../Events/UserUpdatedEvent.cs | 4 +-
.../IdentityLinkGenerator.cs | 4 +
.../CoreSignInJourneyWithTrnLookup.cs | 29 ++-
.../ElevateTrnVerificationLevelJourney.cs | 82 +++++++
.../Journeys/ServiceCollectionExtensions.cs | 2 +
.../Journeys/SignInJourney.cs | 2 +-
.../Journeys/SignInJourneyProvider.cs | 12 +-
.../Journeys/TrnLookupHelper.cs | 22 +-
.../Journeys/UserHelper.cs | 69 +++++-
.../Models/Mappings/UserMapping.cs | 1 +
.../TeacherIdentity.AuthServer/Models/User.cs | 28 ++-
.../Pages/SignIn/Complete.cshtml | 31 ++-
.../Pages/SignIn/Complete.cshtml.cs | 6 +
.../Pages/SignIn/Elevate/CheckAnswers.cshtml | 40 ++++
.../SignIn/Elevate/CheckAnswers.cshtml.cs | 35 +++
.../Pages/SignIn/Elevate/Landing.cshtml | 34 +++
.../Pages/SignIn/Elevate/Landing.cshtml.cs | 43 ++++
.../Pages/SignIn/Register/EmailExists.cshtml | 2 +-
.../SignIn/Register/NiNumberPage.cshtml.cs | 6 +-
.../Pages/SignIn/Register/TrnPage.cshtml | 21 +-
.../Pages/SignIn/Register/TrnPage.cshtml.cs | 16 +-
.../UserClaimHelper.cs | 7 +-
.../Controllers/HomeController.cs | 3 +-
.../Models/ProfileModel.cs | 1 +
.../Views/Home/Index.cshtml | 7 +-
.../Views/Home/Profile.cshtml | 1 +
.../BrowserContextExtensions.cs | 29 +++
.../Elevate.cs | 203 +++++++++++++++++
.../PageExtensions.cs | 25 ++-
.../SignIn.cs | 26 +--
.../EndpointTests/SignIn/CompleteTests.cs | 2 +-
.../SignIn/Elevate/CheckAnswersTests.cs | 212 ++++++++++++++++++
.../SignIn/TestBase.CommonTests.cs | 7 +-
.../EndpointTests/SignIn/TestBase.cs | 2 +-
.../PublishEventsBackgroundServiceTests.cs | 2 +
37 files changed, 972 insertions(+), 109 deletions(-)
create mode 100644 dotnet-authserver/src/TeacherIdentity.AuthServer/Journeys/ElevateTrnVerificationLevelJourney.cs
create mode 100644 dotnet-authserver/src/TeacherIdentity.AuthServer/Pages/SignIn/Elevate/CheckAnswers.cshtml
create mode 100644 dotnet-authserver/src/TeacherIdentity.AuthServer/Pages/SignIn/Elevate/CheckAnswers.cshtml.cs
create mode 100644 dotnet-authserver/src/TeacherIdentity.AuthServer/Pages/SignIn/Elevate/Landing.cshtml
create mode 100644 dotnet-authserver/src/TeacherIdentity.AuthServer/Pages/SignIn/Elevate/Landing.cshtml.cs
create mode 100644 dotnet-authserver/tests/TeacherIdentity.AuthServer.EndToEndTests/BrowserContextExtensions.cs
create mode 100644 dotnet-authserver/tests/TeacherIdentity.AuthServer.EndToEndTests/Elevate.cs
create mode 100644 dotnet-authserver/tests/TeacherIdentity.AuthServer.Tests/EndpointTests/SignIn/Elevate/CheckAnswersTests.cs
diff --git a/dotnet-authserver/src/TeacherIdentity.AuthServer/AuthenticationState.cs b/dotnet-authserver/src/TeacherIdentity.AuthServer/AuthenticationState.cs
index f60143b4c..78c701a64 100644
--- a/dotnet-authserver/src/TeacherIdentity.AuthServer/AuthenticationState.cs
+++ b/dotnet-authserver/src/TeacherIdentity.AuthServer/AuthenticationState.cs
@@ -130,6 +130,17 @@ public AuthenticationState(
[JsonInclude]
public bool HaveResumedCompletedJourney { get; private set; }
+ ///
+ /// Whether the signed in user requires elevating to the higher TrnVerificationLevel.
+ ///
+ ///
+ /// This should be set when the user is signed in and remain un-changed for the duration of the journey.
+ /// The property tracks whether elevation has completed, successfully or not.
+ ///
+ [JsonInclude]
+ public bool? RequiresTrnVerificationLevelElevation { get; private set; }
+ public bool? TrnVerificationElevationSuccessful { get; set; }
+
[JsonIgnore]
public bool EmailAddressSet => EmailAddress is not null;
[JsonIgnore]
@@ -240,6 +251,8 @@ public void Reset(DateTime utcNow)
InstitutionEmailChosen = default;
PreferredName = default;
HaveResumedCompletedJourney = default;
+ RequiresTrnVerificationLevelElevation = default;
+ TrnVerificationElevationSuccessful = default;
}
public void OnEmailSet(string email, bool isInstitutionEmail = false)
@@ -359,6 +372,7 @@ public void OnTrnLookupCompletedAndUserRegistered(User user)
FirstTimeSignInForEmail = true;
Trn = user.Trn;
TrnLookup = TrnLookupState.Complete;
+ RequiresTrnVerificationLevelElevation = false;
UserType = user.UserType;
StaffRoles = user.StaffRoles;
TrnLookupStatus = user.TrnLookupStatus;
@@ -594,6 +608,10 @@ public void OnSignedInUserProvided(User? user)
LastName = user?.LastName;
DateOfBirth = user?.DateOfBirth;
Trn = user?.Trn;
+ RequiresTrnVerificationLevelElevation =
+ user is not null && TryGetOAuthState(out var oAuthState) && oAuthState.TrnMatchPolicy == TrnMatchPolicy.Strict ?
+ user.EffectiveVerificationLevel != TrnVerificationLevel.Medium :
+ null;
HaveCompletedTrnLookup = user?.CompletedTrnLookup is not null;
TrnLookup = user?.CompletedTrnLookup is not null ? TrnLookupState.Complete : TrnLookupState.None;
UserType = user?.UserType;
@@ -605,6 +623,7 @@ public void OnTrnTokenProvided(EnhancedTrnToken trnToken)
{
TrnToken = trnToken.TrnToken;
Trn = trnToken.Trn;
+ RequiresTrnVerificationLevelElevation = false;
TrnLookupStatus = AuthServer.TrnLookupStatus.Found;
FirstName ??= trnToken.FirstName;
MiddleName ??= trnToken.MiddleName;
@@ -636,6 +655,11 @@ public void OnTrnLookupCompleted(FindTeachersResponseResult? findTeachersResult,
Trn = trn;
TrnLookupStatus = trnLookupStatus;
+ if (RequiresTrnVerificationLevelElevation == true)
+ {
+ TrnVerificationElevationSuccessful = Trn is not null;
+ }
+
if (findTeachersResult is not null && !string.IsNullOrEmpty(findTeachersResult.FirstName) && !string.IsNullOrEmpty(findTeachersResult.LastName))
{
DqtFirstName = findTeachersResult.FirstName;
@@ -652,21 +676,6 @@ public async Task SignIn(HttpContext httpContext)
return await httpContext.SignInCookies(claims, resetIssued: true, AuthCookieLifetime);
}
- public enum HasPreviousNameOption
- {
- Yes,
- No,
- PreferNotToSay
- }
-
- public enum TrnLookupState
- {
- None = 0,
- Complete = 1,
- ExistingTrnFound = 3,
- EmailOfExistingAccountForTrnVerified = 4
- }
-
private void UpdateAuthenticationStateWithUserDetails(User user)
{
UserId = user.UserId;
@@ -689,8 +698,30 @@ private void UpdateAuthenticationStateWithUserDetails(User user)
if (HaveCompletedTrnLookup || Trn is not null)
{
TrnLookup = TrnLookupState.Complete;
+ RequiresTrnVerificationLevelElevation =
+ TryGetOAuthState(out var oAuthState) && oAuthState.TrnMatchPolicy == TrnMatchPolicy.Strict &&
+ user.EffectiveVerificationLevel != TrnVerificationLevel.Medium;
}
}
+ else
+ {
+ RequiresTrnVerificationLevelElevation = false;
+ }
+ }
+
+ public enum HasPreviousNameOption
+ {
+ Yes,
+ No,
+ PreferNotToSay
+ }
+
+ public enum TrnLookupState
+ {
+ None = 0,
+ Complete = 1,
+ ExistingTrnFound = 3,
+ EmailOfExistingAccountForTrnVerified = 4
}
}
diff --git a/dotnet-authserver/src/TeacherIdentity.AuthServer/Events/User.cs b/dotnet-authserver/src/TeacherIdentity.AuthServer/Events/User.cs
index 1c6ed75b4..9238c72cd 100644
--- a/dotnet-authserver/src/TeacherIdentity.AuthServer/Events/User.cs
+++ b/dotnet-authserver/src/TeacherIdentity.AuthServer/Events/User.cs
@@ -17,6 +17,8 @@ public record User
public required TrnAssociationSource? TrnAssociationSource { get; init; }
public required string[] StaffRoles { get; init; } = Array.Empty();
public required TrnLookupStatus? TrnLookupStatus { get; init; }
+ public required TrnVerificationLevel? TrnVerificationLevel { get; init; }
+ public required string? NationalInsuranceNumber { get; init; }
public static User FromModel(Models.User user) => new()
{
@@ -29,8 +31,10 @@ public record User
StaffRoles = user.StaffRoles,
Trn = user.Trn,
MobileNumber = user.MobileNumber,
+ NationalInsuranceNumber = user.NationalInsuranceNumber,
TrnAssociationSource = user.TrnAssociationSource,
TrnLookupStatus = user.TrnLookupStatus,
+ TrnVerificationLevel = user.TrnVerificationLevel,
UserId = user.UserId,
UserType = user.UserType
};
diff --git a/dotnet-authserver/src/TeacherIdentity.AuthServer/Events/UserUpdatedEvent.cs b/dotnet-authserver/src/TeacherIdentity.AuthServer/Events/UserUpdatedEvent.cs
index 93e01ad18..568ff4445 100644
--- a/dotnet-authserver/src/TeacherIdentity.AuthServer/Events/UserUpdatedEvent.cs
+++ b/dotnet-authserver/src/TeacherIdentity.AuthServer/Events/UserUpdatedEvent.cs
@@ -21,7 +21,9 @@ public enum UserUpdatedEventChanges
TrnLookupStatus = 1 << 5,
MobileNumber = 1 << 6,
MiddleName = 1 << 7,
- PreferredName = 1 << 8
+ PreferredName = 1 << 8,
+ TrnVerificationLevel = 1 << 9,
+ NationalInsuranceNumber = 1 << 10,
}
public enum UserUpdatedEventSource
diff --git a/dotnet-authserver/src/TeacherIdentity.AuthServer/IdentityLinkGenerator.cs b/dotnet-authserver/src/TeacherIdentity.AuthServer/IdentityLinkGenerator.cs
index 42e4d3438..16d825952 100644
--- a/dotnet-authserver/src/TeacherIdentity.AuthServer/IdentityLinkGenerator.cs
+++ b/dotnet-authserver/src/TeacherIdentity.AuthServer/IdentityLinkGenerator.cs
@@ -122,6 +122,10 @@ protected virtual string Page(string pageName, bool authenticationJourneyRequire
public string RegisterNoAccount() => Page("/SignIn/Register/NoAccount");
+ public string ElevateLanding() => Page("/SignIn/Elevate/Landing");
+
+ public string ElevateCheckAnswers() => Page("/SignIn/Elevate/CheckAnswers");
+
public string Account(ClientRedirectInfo? clientRedirectInfo) =>
Page("/Account/Index", authenticationJourneyRequired: false)
.SetQueryParam(ClientRedirectInfo.QueryParameterName, clientRedirectInfo);
diff --git a/dotnet-authserver/src/TeacherIdentity.AuthServer/Journeys/CoreSignInJourneyWithTrnLookup.cs b/dotnet-authserver/src/TeacherIdentity.AuthServer/Journeys/CoreSignInJourneyWithTrnLookup.cs
index 9bcb6c39c..04aba7230 100644
--- a/dotnet-authserver/src/TeacherIdentity.AuthServer/Journeys/CoreSignInJourneyWithTrnLookup.cs
+++ b/dotnet-authserver/src/TeacherIdentity.AuthServer/Journeys/CoreSignInJourneyWithTrnLookup.cs
@@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
+using TeacherIdentity.AuthServer.Models;
using TeacherIdentity.AuthServer.Oidc;
using TeacherIdentity.AuthServer.Services.BackgroundJobs;
using User = TeacherIdentity.AuthServer.Models.User;
@@ -57,7 +58,7 @@ await _backgroundJobScheduler.Enqueue(
AuthenticationState.IttProviderName,
AuthenticationState.StatedTrn,
client.DisplayName,
- AuthenticationState.OAuthState.TrnRequirementType == Models.TrnRequirementType.Required));
+ AuthenticationState.OAuthState.TrnRequirementType == TrnRequirementType.Required));
}
}
}
@@ -98,6 +99,18 @@ protected override bool IsFinished() =>
AuthenticationState.TrnLookupStatus.HasValue &&
AuthenticationState.TrnLookup == AuthenticationState.TrnLookupState.Complete;
+ public override bool IsCompleted()
+ {
+ var finished = IsFinished();
+
+ if (finished && AuthenticationState.RequiresTrnVerificationLevelElevation == true)
+ {
+ return false;
+ }
+
+ return finished;
+ }
+
public override bool CanAccessStep(string step) => step switch
{
CoreSignInJourney.Steps.CheckAnswers => (AreAllQuestionsAnswered() || FoundATrn) && AuthenticationState.ContactDetailsVerified,
@@ -113,8 +126,22 @@ protected override bool IsFinished() =>
_ => base.CanAccessStep(step)
};
+ public override string GetNextStepUrl(string currentStep) =>
+ currentStep switch
+ {
+ ElevateTrnVerificationLevelJourney.Steps.Landing => ElevateTrnVerificationLevelJourney.GetStartStepUrl(LinkGenerator),
+ _ => base.GetNextStepUrl(currentStep)
+ };
+
protected override string? GetNextStep(string currentStep)
{
+ // If we've signed a user in successfully and the TrnMatchPolicy is Strict
+ // but the user's TrnVerificationLevel is Low (or null) we need to switch to the 'elevate' journey
+ if (IsFinished() && AuthenticationState.RequiresTrnVerificationLevelElevation == true)
+ {
+ return ElevateTrnVerificationLevelJourney.GetStartStepUrl(LinkGenerator);
+ }
+
var shouldCheckAnswers = (AreAllQuestionsAnswered() || FoundATrn) && !AuthenticationState.ExistingAccountFound;
return (currentStep, AuthenticationState) switch
diff --git a/dotnet-authserver/src/TeacherIdentity.AuthServer/Journeys/ElevateTrnVerificationLevelJourney.cs b/dotnet-authserver/src/TeacherIdentity.AuthServer/Journeys/ElevateTrnVerificationLevelJourney.cs
new file mode 100644
index 000000000..30a0a6b30
--- /dev/null
+++ b/dotnet-authserver/src/TeacherIdentity.AuthServer/Journeys/ElevateTrnVerificationLevelJourney.cs
@@ -0,0 +1,82 @@
+using System.Diagnostics;
+
+namespace TeacherIdentity.AuthServer.Journeys;
+
+public class ElevateTrnVerificationLevelJourney : SignInJourney
+{
+ private readonly TrnLookupHelper _trnLookupHelper;
+
+ public ElevateTrnVerificationLevelJourney(
+ TrnLookupHelper trnLookupHelper,
+ HttpContext httpContext,
+ IdentityLinkGenerator linkGenerator,
+ UserHelper userHelper) :
+ base(httpContext, linkGenerator, userHelper)
+ {
+ _trnLookupHelper = trnLookupHelper;
+ }
+
+ public static string GetStartStepUrl(IdentityLinkGenerator linkGenerator) => linkGenerator.ElevateLanding();
+
+ public async Task LookupTrn()
+ {
+ var trn = await _trnLookupHelper.LookupTrn(AuthenticationState);
+ Debug.Assert(AuthenticationState.TrnVerificationElevationSuccessful.HasValue);
+
+ if (trn is not null)
+ {
+ Debug.Assert(AuthenticationState.TrnVerificationElevationSuccessful == true);
+ await UserHelper.ElevateTrnVerificationLevel(AuthenticationState.UserId!.Value, trn, AuthenticationState.NationalInsuranceNumber!);
+ }
+ else
+ {
+ Debug.Assert(AuthenticationState.TrnVerificationElevationSuccessful == false);
+ await UserHelper.SetNationalInsuranceNumber(AuthenticationState.UserId!.Value, AuthenticationState.NationalInsuranceNumber!);
+ }
+ }
+
+ public override bool CanAccessStep(string step) => step switch
+ {
+ Steps.Landing => true,
+ CoreSignInJourneyWithTrnLookup.Steps.NiNumber => true,
+ CoreSignInJourneyWithTrnLookup.Steps.Trn => AuthenticationState.HasNationalInsuranceNumber == true,
+ Steps.CheckAnswers => AuthenticationState.HasNationalInsuranceNumber == true && AuthenticationState.StatedTrn is not null,
+ _ => false
+ };
+
+ protected override string? GetNextStep(string currentStep) => currentStep switch
+ {
+ Steps.Landing => CoreSignInJourneyWithTrnLookup.Steps.NiNumber,
+ CoreSignInJourneyWithTrnLookup.Steps.NiNumber => CoreSignInJourneyWithTrnLookup.Steps.Trn,
+ CoreSignInJourneyWithTrnLookup.Steps.Trn => Steps.CheckAnswers,
+ _ => null
+ };
+
+ protected override string? GetPreviousStep(string currentStep) => currentStep switch
+ {
+ CoreSignInJourneyWithTrnLookup.Steps.NiNumber => Steps.Landing,
+ CoreSignInJourneyWithTrnLookup.Steps.Trn => CoreSignInJourneyWithTrnLookup.Steps.NiNumber,
+ Steps.CheckAnswers => CoreSignInJourneyWithTrnLookup.Steps.Trn,
+ _ => null
+ };
+
+ protected override string GetStartStep() => Steps.Landing;
+
+ protected override string GetStepUrl(string step) => step switch
+ {
+ Steps.Landing => LinkGenerator.ElevateLanding(),
+ CoreSignInJourneyWithTrnLookup.Steps.NiNumber => LinkGenerator.RegisterNiNumber(),
+ CoreSignInJourneyWithTrnLookup.Steps.Trn => LinkGenerator.RegisterTrn(),
+ Steps.CheckAnswers => LinkGenerator.ElevateCheckAnswers(),
+ _ => throw new ArgumentException($"Unknown step: '{step}'.")
+ };
+
+ // We're done when we've done a lookup, successful or not, using the Strict TrnMatchPolicy
+ protected override bool IsFinished() => AuthenticationState.TrnVerificationElevationSuccessful.HasValue;
+
+ public new static class Steps
+ {
+ public const string Landing = $"{nameof(ElevateTrnVerificationLevelJourney)}.{nameof(Landing)}";
+ public const string CheckAnswers = $"{nameof(ElevateTrnVerificationLevelJourney)}.{nameof(CheckAnswers)}";
+ }
+}
diff --git a/dotnet-authserver/src/TeacherIdentity.AuthServer/Journeys/ServiceCollectionExtensions.cs b/dotnet-authserver/src/TeacherIdentity.AuthServer/Journeys/ServiceCollectionExtensions.cs
index fcceb528b..d64438a54 100644
--- a/dotnet-authserver/src/TeacherIdentity.AuthServer/Journeys/ServiceCollectionExtensions.cs
+++ b/dotnet-authserver/src/TeacherIdentity.AuthServer/Journeys/ServiceCollectionExtensions.cs
@@ -17,6 +17,8 @@ public static IServiceCollection AddSignInJourneyStateProvider(this IServiceColl
return provider.GetSignInJourney(authenticationState, httpContext);
});
+ services.AddTransient(sp => (ElevateTrnVerificationLevelJourney)sp.GetRequiredService());
+
services
.AddTransient()
.AddTransient()
diff --git a/dotnet-authserver/src/TeacherIdentity.AuthServer/Journeys/SignInJourney.cs b/dotnet-authserver/src/TeacherIdentity.AuthServer/Journeys/SignInJourney.cs
index 9b7f0434e..736a76c46 100644
--- a/dotnet-authserver/src/TeacherIdentity.AuthServer/Journeys/SignInJourney.cs
+++ b/dotnet-authserver/src/TeacherIdentity.AuthServer/Journeys/SignInJourney.cs
@@ -154,7 +154,7 @@ public virtual string GetNextStepUrl(string currentStep)
if (!CanAccessStep(nextStep))
{
- throw new InvalidOperationException($"Next step is not accessible (step: '{nextStep}', EmailAddressVerified: {AuthenticationState.EmailAddressVerified}, MobileNumberVerified: {AuthenticationState.MobileNumberVerified}).");
+ throw new InvalidOperationException($"Next step is not accessible (step: '{nextStep}').");
}
return GetStepUrl(nextStep);
diff --git a/dotnet-authserver/src/TeacherIdentity.AuthServer/Journeys/SignInJourneyProvider.cs b/dotnet-authserver/src/TeacherIdentity.AuthServer/Journeys/SignInJourneyProvider.cs
index 12a2ffd7d..595a5238b 100644
--- a/dotnet-authserver/src/TeacherIdentity.AuthServer/Journeys/SignInJourneyProvider.cs
+++ b/dotnet-authserver/src/TeacherIdentity.AuthServer/Journeys/SignInJourneyProvider.cs
@@ -6,6 +6,8 @@ public class SignInJourneyProvider
{
public SignInJourney GetSignInJourney(AuthenticationState authenticationState, HttpContext httpContext)
{
+ var signInJourneyType = typeof(CoreSignInJourney);
+
if (authenticationState.TryGetOAuthState(out var oAuthState) && authenticationState.UserRequirements.RequiresTrnLookup())
{
#pragma warning disable CS0612 // Type or member is obsolete
@@ -15,16 +17,16 @@ public SignInJourney GetSignInJourney(AuthenticationState authenticationState, H
}
#pragma warning restore CS0612 // Type or member is obsolete
- return authenticationState.HasTrnToken ?
- ActivatorUtilities.CreateInstance(httpContext.RequestServices, httpContext) :
- ActivatorUtilities.CreateInstance(httpContext.RequestServices, httpContext);
+ signInJourneyType = authenticationState.HasTrnToken ? typeof(TrnTokenSignInJourney) :
+ authenticationState.RequiresTrnVerificationLevelElevation == true ? typeof(ElevateTrnVerificationLevelJourney) :
+ typeof(CoreSignInJourneyWithTrnLookup);
}
if (authenticationState.UserRequirements.HasFlag(UserRequirements.StaffUserType))
{
- return ActivatorUtilities.CreateInstance(httpContext.RequestServices, httpContext);
+ signInJourneyType = typeof(StaffSignInJourney);
}
- return ActivatorUtilities.CreateInstance(httpContext.RequestServices, httpContext);
+ return (SignInJourney)ActivatorUtilities.CreateInstance(httpContext.RequestServices, signInJourneyType, httpContext);
}
}
diff --git a/dotnet-authserver/src/TeacherIdentity.AuthServer/Journeys/TrnLookupHelper.cs b/dotnet-authserver/src/TeacherIdentity.AuthServer/Journeys/TrnLookupHelper.cs
index cb7d2b1e2..bb4f52962 100644
--- a/dotnet-authserver/src/TeacherIdentity.AuthServer/Journeys/TrnLookupHelper.cs
+++ b/dotnet-authserver/src/TeacherIdentity.AuthServer/Journeys/TrnLookupHelper.cs
@@ -1,6 +1,7 @@
using System.Text;
using System.Text.Json;
using Azure.Storage.Blobs;
+using TeacherIdentity.AuthServer.Models;
using TeacherIdentity.AuthServer.Services.DqtApi;
namespace TeacherIdentity.AuthServer.Journeys;
@@ -36,9 +37,10 @@ public TrnLookupHelper(
FindTeachersResponseResult? findTeachersResult;
FindTeachersResponseResult[] findTeachersResults = Array.Empty();
+ var trnMatchPolicy = (authenticationState.TryGetOAuthState(out var oAuthState) ? oAuthState.TrnMatchPolicy : null) ?? TrnMatchPolicy.Default;
+
try
{
- authenticationState.TryGetOAuthState(out var oAuthState);
var lookupResponse = await _dqtApiClient.FindTeachers(
new FindTeachersRequest()
@@ -48,9 +50,9 @@ public TrnLookupHelper(
FirstName = authenticationState.FirstName,
LastName = authenticationState.LastName,
IttProviderName = authenticationState.IttProviderName,
- NationalInsuranceNumber = NormalizeNino(authenticationState.NationalInsuranceNumber),
+ NationalInsuranceNumber = User.NormalizeNationalInsuranceNumber(authenticationState.NationalInsuranceNumber),
Trn = NormalizeTrn(authenticationState.StatedTrn),
- TrnMatchPolicy = oAuthState?.TrnMatchPolicy
+ TrnMatchPolicy = trnMatchPolicy
},
cts.Token);
@@ -69,7 +71,7 @@ public TrnLookupHelper(
(findTeachersResult, trnLookupStatus) = ResolveTrn(findTeachersResults, authenticationState);
if (findTeachersResult is not null)
{
- await CheckDqtTeacherNames(findTeachersResult);
+ await LogMissingNamesOnMatchedDqtRecord(findTeachersResult);
}
authenticationState.OnTrnLookupCompleted(findTeachersResult, trnLookupStatus);
@@ -89,16 +91,6 @@ public TrnLookupHelper(
_ => (null, TrnLookupStatus.None)
};
- private static string? NormalizeNino(string? nino)
- {
- if (string.IsNullOrEmpty(nino))
- {
- return null;
- }
-
- return new string(nino.Where(char.IsAsciiLetterOrDigit).ToArray()).ToUpper();
- }
-
private static string? NormalizeTrn(string? trn)
{
if (string.IsNullOrEmpty(trn))
@@ -109,7 +101,7 @@ public TrnLookupHelper(
return new string(trn.Where(char.IsAsciiDigit).ToArray());
}
- private async Task CheckDqtTeacherNames(FindTeachersResponseResult teacher)
+ private async Task LogMissingNamesOnMatchedDqtRecord(FindTeachersResponseResult teacher)
{
if (string.IsNullOrEmpty(teacher.FirstName) || string.IsNullOrEmpty(teacher.LastName))
{
diff --git a/dotnet-authserver/src/TeacherIdentity.AuthServer/Journeys/UserHelper.cs b/dotnet-authserver/src/TeacherIdentity.AuthServer/Journeys/UserHelper.cs
index 0724f1f08..f5a49b821 100644
--- a/dotnet-authserver/src/TeacherIdentity.AuthServer/Journeys/UserHelper.cs
+++ b/dotnet-authserver/src/TeacherIdentity.AuthServer/Journeys/UserHelper.cs
@@ -3,12 +3,14 @@
using Azure.Storage.Blobs;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
+using TeacherIdentity.AuthServer.Events;
using TeacherIdentity.AuthServer.Helpers;
using TeacherIdentity.AuthServer.Models;
using TeacherIdentity.AuthServer.Services.DqtApi;
using TeacherIdentity.AuthServer.Services.UserVerification;
using TeacherIdentity.AuthServer.Services.Zendesk;
using ZendeskApi.Client.Models;
+using User = TeacherIdentity.AuthServer.Models.User;
namespace TeacherIdentity.AuthServer.Journeys;
@@ -66,7 +68,7 @@ public async Task CreateUser(AuthenticationState authenticationState)
_dbContext.Users.Add(user);
- _dbContext.AddEvent(new Events.UserRegisteredEvent()
+ _dbContext.AddEvent(new UserRegisteredEvent()
{
ClientId = authenticationState.OAuthState?.ClientId,
CreatedUtc = _clock.UtcNow,
@@ -115,7 +117,7 @@ public async Task CreateUserWithTrnLookup(AuthenticationState authenticati
await _trnTokenHelper.InvalidateTrnToken(authenticationState.TrnToken!, user.UserId);
}
- _dbContext.AddEvent(new Events.UserRegisteredEvent()
+ _dbContext.AddEvent(new UserRegisteredEvent()
{
ClientId = authenticationState.OAuthState?.ClientId,
CreatedUtc = _clock.UtcNow,
@@ -157,7 +159,7 @@ public async Task CreateUserWithTrnToken(AuthenticationState authenticatio
await _trnTokenHelper.InvalidateTrnToken(authenticationState.TrnToken!, user.UserId);
- _dbContext.AddEvent(new Events.UserRegisteredEvent()
+ _dbContext.AddEvent(new UserRegisteredEvent()
{
ClientId = authenticationState.OAuthState?.ClientId,
CreatedUtc = _clock.UtcNow,
@@ -246,7 +248,7 @@ public async Task CreateTrnResolutionZendeskTicket(
var user = await _dbContext.Users.Where(u => u.UserId == userId).SingleAsync();
user.TrnLookupSupportTicketCreated = true;
- _dbContext.AddEvent(new Events.TrnLookupSupportTicketCreatedEvent()
+ _dbContext.AddEvent(new TrnLookupSupportTicketCreatedEvent()
{
TicketId = ticketResponse.Ticket.Id,
TicketComment = ticketComment,
@@ -270,21 +272,70 @@ public async Task EnsureDqtUserNameMatch(User user, AuthenticationState authenti
}
}
+ public async Task ElevateTrnVerificationLevel(Guid userId, string trn, string nationalInsuranceNumber)
+ {
+ var user = await _dbContext.Users.SingleAsync(u => u.UserId == userId);
+ user.Trn = trn;
+ user.TrnVerificationLevel = TrnVerificationLevel.Medium;
+
+ var changes = UserUpdatedEventChanges.TrnVerificationLevel;
+
+ if (nationalInsuranceNumber != user.NationalInsuranceNumber)
+ {
+ user.NationalInsuranceNumber = nationalInsuranceNumber;
+ changes |= UserUpdatedEventChanges.NationalInsuranceNumber;
+ }
+
+ _dbContext.AddEvent(new UserUpdatedEvent()
+ {
+ Changes = changes,
+ CreatedUtc = _clock.UtcNow,
+ Source = UserUpdatedEventSource.ChangedByUser,
+ UpdatedByClientId = null,
+ UpdatedByUserId = null,
+ User = user
+ });
+
+ await _dbContext.SaveChangesAsync();
+ }
+
+ public async Task SetNationalInsuranceNumber(Guid userId, string? nationalInsuranceNumber)
+ {
+ var user = await _dbContext.Users.SingleAsync(u => u.UserId == userId);
+
+ if (User.NormalizeNationalInsuranceNumber(nationalInsuranceNumber) != User.NormalizeNationalInsuranceNumber(user.NationalInsuranceNumber))
+ {
+ user.NationalInsuranceNumber = nationalInsuranceNumber;
+
+ _dbContext.AddEvent(new UserUpdatedEvent()
+ {
+ Changes = UserUpdatedEventChanges.NationalInsuranceNumber,
+ CreatedUtc = _clock.UtcNow,
+ Source = UserUpdatedEventSource.ChangedByUser,
+ UpdatedByClientId = null,
+ UpdatedByUserId = null,
+ User = user
+ });
+
+ await _dbContext.SaveChangesAsync();
+ }
+ }
+
private async Task AssignDqtUserName(Guid userId, TeacherInfo dqtUser)
{
var existingUser = await _dbContext.Users.SingleAsync(u => u.UserId == userId);
- var changes = (existingUser.FirstName != dqtUser.FirstName ? Events.UserUpdatedEventChanges.FirstName : Events.UserUpdatedEventChanges.None) |
- ((existingUser.MiddleName ?? string.Empty) != dqtUser.MiddleName ? Events.UserUpdatedEventChanges.MiddleName : Events.UserUpdatedEventChanges.None) |
- (existingUser.LastName != dqtUser.LastName ? Events.UserUpdatedEventChanges.LastName : Events.UserUpdatedEventChanges.None);
+ var changes = (existingUser.FirstName != dqtUser.FirstName ? UserUpdatedEventChanges.FirstName : UserUpdatedEventChanges.None) |
+ ((existingUser.MiddleName ?? string.Empty) != dqtUser.MiddleName ? UserUpdatedEventChanges.MiddleName : UserUpdatedEventChanges.None) |
+ (existingUser.LastName != dqtUser.LastName ? UserUpdatedEventChanges.LastName : UserUpdatedEventChanges.None);
existingUser.FirstName = dqtUser.FirstName;
existingUser.MiddleName = dqtUser.MiddleName;
existingUser.LastName = dqtUser.LastName;
- _dbContext.AddEvent(new Events.UserUpdatedEvent()
+ _dbContext.AddEvent(new UserUpdatedEvent()
{
- Source = Events.UserUpdatedEventSource.DqtSynchronization,
+ Source = UserUpdatedEventSource.DqtSynchronization,
CreatedUtc = _clock.UtcNow,
Changes = changes,
User = Events.User.FromModel(existingUser),
diff --git a/dotnet-authserver/src/TeacherIdentity.AuthServer/Models/Mappings/UserMapping.cs b/dotnet-authserver/src/TeacherIdentity.AuthServer/Models/Mappings/UserMapping.cs
index 80af0ac90..ac757829f 100644
--- a/dotnet-authserver/src/TeacherIdentity.AuthServer/Models/Mappings/UserMapping.cs
+++ b/dotnet-authserver/src/TeacherIdentity.AuthServer/Models/Mappings/UserMapping.cs
@@ -30,6 +30,7 @@ public void Configure(EntityTypeBuilder builder)
builder.Property(u => u.MergedWithUserId);
builder.Property(u => u.MobileNumber).HasMaxLength(100);
builder.Property(u => u.NormalizedMobileNumber).HasMaxLength(15);
+ builder.Ignore(u => u.EffectiveVerificationLevel);
builder.HasIndex(u => u.NormalizedMobileNumber).IsUnique().HasDatabaseName(User.MobileNumberUniqueIndexName).HasFilter("is_deleted = false and normalized_mobile_number is not null");
builder.HasOne(u => u.MergedWithUser).WithMany(u => u.MergedUsers).HasForeignKey(u => u.MergedWithUserId);
builder.HasOne(u => u.RegisteredWithClient).WithMany().HasForeignKey(u => u.RegisteredWithClientId).HasPrincipalKey(a => a.ClientId);
diff --git a/dotnet-authserver/src/TeacherIdentity.AuthServer/Models/User.cs b/dotnet-authserver/src/TeacherIdentity.AuthServer/Models/User.cs
index 6f6d076f2..3d15c0654 100644
--- a/dotnet-authserver/src/TeacherIdentity.AuthServer/Models/User.cs
+++ b/dotnet-authserver/src/TeacherIdentity.AuthServer/Models/User.cs
@@ -36,7 +36,33 @@ public class User
public string? MobileNumber { get; set; }
public MobileNumber? NormalizedMobileNumber { get; set; }
public bool TrnLookupSupportTicketCreated { get; set; }
-
public TrnVerificationLevel? TrnVerificationLevel { get; set; }
public string? NationalInsuranceNumber { get; set; }
+
+ public TrnVerificationLevel? EffectiveVerificationLevel
+ {
+ get
+ {
+ if (Trn is null)
+ {
+ return null;
+ }
+
+ if (TrnVerificationLevel == Models.TrnVerificationLevel.Medium)
+ {
+ return Models.TrnVerificationLevel.Medium;
+ }
+
+ if (TrnAssociationSource == Models.TrnAssociationSource.TrnToken ||
+ TrnAssociationSource == Models.TrnAssociationSource.SupportUi)
+ {
+ return Models.TrnVerificationLevel.Medium;
+ }
+
+ return Models.TrnVerificationLevel.Low;
+ }
+ }
+
+ public static string NormalizeNationalInsuranceNumber(string? nationalInsuranceNumber) =>
+ new string((nationalInsuranceNumber ?? string.Empty).Where(char.IsAsciiLetterOrDigit).ToArray()).ToUpper();
}
diff --git a/dotnet-authserver/src/TeacherIdentity.AuthServer/Pages/SignIn/Complete.cshtml b/dotnet-authserver/src/TeacherIdentity.AuthServer/Pages/SignIn/Complete.cshtml
index a5d6db89e..fa9ea7ad8 100644
--- a/dotnet-authserver/src/TeacherIdentity.AuthServer/Pages/SignIn/Complete.cshtml
+++ b/dotnet-authserver/src/TeacherIdentity.AuthServer/Pages/SignIn/Complete.cshtml
@@ -3,7 +3,9 @@
@inject IConfiguration Configuration
@model TeacherIdentity.AuthServer.Pages.SignIn.CompleteModel
@{
- ViewBag.Title = !Model.CanAccessService ? "You cannot access this service yet" :
+ ViewBag.Title = Model.TrnVerificationElevationSuccessful == true ? "The information you gave has been verified" :
+ Model.TrnVerificationElevationSuccessful == false ? "The information you gave could not be verified" :
+ !Model.CanAccessService ? "You cannot access this service yet" :
Model.FirstTimeSignInForEmail ? "You’ve created a DfE Identity account" :
"You’ve signed in to your DfE Identity account";
@@ -30,7 +32,25 @@
@ViewBag.Title
- @if (Model.TrnRequirementType == TrnRequirementType.Required)
+ @if (Model.TrnVerificationElevationSuccessful == true)
+ {
+ You can now @Model.ClientDisplayName using your DfE Identity account.
+ }
+ else if (Model.TrnVerificationElevationSuccessful == false && Model.TrnRequirementType == TrnRequirementType.Required)
+ {
+ You’ve signed in to your DfE Identity account but some of the additional information you gave could not be verified.
+ Email @(Configuration["SupportEmail"]) for help.
+ }
+ else if (Model.TrnVerificationElevationSuccessful == false && Model.TrnRequirementType == TrnRequirementType.Optional)
+ {
+ You can still @Model.ClientDisplayName.
+ }
+ else if (Model.TrnMatchPolicy == TrnMatchPolicy.Strict && Model.Trn is null && Model.TrnRequirementType == TrnRequirementType.Required)
+ {
+ You’ve created a DfE Identity account but some of the information you gave could not be verified.
+ Email @(Configuration["SupportEmail"]) for help.
+ }
+ else if (Model.TrnRequirementType == TrnRequirementType.Required)
{
if (Model.TrnLookupStatus == TrnLookupStatus.Found)
{
@@ -61,7 +81,7 @@
else
{
We need to find your details in our records so you can use this service.
- To fix this problem, please email our support team @(Configuration["SupportEmail"])
+ To fix this problem, please email our support team @(Configuration["SupportEmail"]).
}
}
else
@@ -106,7 +126,10 @@
Continue to @Model.ClientDisplayName
}
- Continue
+ @if (Model.CanAccessService)
+ {
+ Continue
+ }
}
diff --git a/dotnet-authserver/src/TeacherIdentity.AuthServer/Pages/SignIn/Complete.cshtml.cs b/dotnet-authserver/src/TeacherIdentity.AuthServer/Pages/SignIn/Complete.cshtml.cs
index 071f2fa05..7b45ab2f1 100644
--- a/dotnet-authserver/src/TeacherIdentity.AuthServer/Pages/SignIn/Complete.cshtml.cs
+++ b/dotnet-authserver/src/TeacherIdentity.AuthServer/Pages/SignIn/Complete.cshtml.cs
@@ -32,6 +32,8 @@ public CompleteModel(
public string? Trn { get; set; }
+ public bool? TrnVerificationElevationSuccessful { get; set; }
+
public string? RedirectUri { get; set; }
public string? ResponseMode { get; set; }
@@ -42,6 +44,8 @@ public CompleteModel(
public TrnRequirementType? TrnRequirementType { get; set; }
+ public TrnMatchPolicy? TrnMatchPolicy { get; set; }
+
public string? ClientDisplayName { get; set; }
public bool TrnLookupSupportTicketCreated { get; set; }
@@ -60,8 +64,10 @@ public async Task OnGet()
Email = authenticationState.EmailAddress;
FirstTimeSignInForEmail = authenticationState.FirstTimeSignInForEmail!.Value;
Trn = authenticationState.Trn;
+ TrnVerificationElevationSuccessful = authenticationState.TrnVerificationElevationSuccessful;
TrnLookupStatus = authenticationState.TrnLookupStatus;
TrnRequirementType = authenticationState.OAuthState?.TrnRequirementType;
+ TrnMatchPolicy = authenticationState.OAuthState?.TrnMatchPolicy;
var user = await _dbContext.Users.SingleAsync(u => u.UserId == authenticationState.UserId);
TrnLookupSupportTicketCreated = user?.TrnLookupSupportTicketCreated == true;
diff --git a/dotnet-authserver/src/TeacherIdentity.AuthServer/Pages/SignIn/Elevate/CheckAnswers.cshtml b/dotnet-authserver/src/TeacherIdentity.AuthServer/Pages/SignIn/Elevate/CheckAnswers.cshtml
new file mode 100644
index 000000000..68313e2a0
--- /dev/null
+++ b/dotnet-authserver/src/TeacherIdentity.AuthServer/Pages/SignIn/Elevate/CheckAnswers.cshtml
@@ -0,0 +1,40 @@
+@page "/sign-in/elevate/check-answers"
+@model TeacherIdentity.AuthServer.Pages.SignIn.Elevate.CheckAnswers
+@{
+ ViewBag.Title = "Check your answers";
+}
+
+@section BeforeContent
+{
+
+}
+
+
+
diff --git a/dotnet-authserver/src/TeacherIdentity.AuthServer/Pages/SignIn/Elevate/CheckAnswers.cshtml.cs b/dotnet-authserver/src/TeacherIdentity.AuthServer/Pages/SignIn/Elevate/CheckAnswers.cshtml.cs
new file mode 100644
index 000000000..0aaab4553
--- /dev/null
+++ b/dotnet-authserver/src/TeacherIdentity.AuthServer/Pages/SignIn/Elevate/CheckAnswers.cshtml.cs
@@ -0,0 +1,35 @@
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+using TeacherIdentity.AuthServer.Journeys;
+
+namespace TeacherIdentity.AuthServer.Pages.SignIn.Elevate;
+
+[CheckJourneyType(typeof(ElevateTrnVerificationLevelJourney))]
+[CheckCanAccessStep(CurrentStep)]
+public class CheckAnswers : PageModel
+{
+ private const string CurrentStep = ElevateTrnVerificationLevelJourney.Steps.CheckAnswers;
+
+ private readonly ElevateTrnVerificationLevelJourney _journey;
+
+ public CheckAnswers(ElevateTrnVerificationLevelJourney journey)
+ {
+ _journey = journey;
+ }
+
+ public string BackLink => _journey.GetPreviousStepUrl(CurrentStep);
+
+ public string Trn => _journey.AuthenticationState.StatedTrn!;
+
+ public string? NationalInsuranceNumber => _journey.AuthenticationState.NationalInsuranceNumber;
+
+ public void OnGet()
+ {
+ }
+
+ public async Task OnPost()
+ {
+ await _journey.LookupTrn();
+ return await _journey.Advance(CurrentStep);
+ }
+}
diff --git a/dotnet-authserver/src/TeacherIdentity.AuthServer/Pages/SignIn/Elevate/Landing.cshtml b/dotnet-authserver/src/TeacherIdentity.AuthServer/Pages/SignIn/Elevate/Landing.cshtml
new file mode 100644
index 000000000..d33e82b11
--- /dev/null
+++ b/dotnet-authserver/src/TeacherIdentity.AuthServer/Pages/SignIn/Elevate/Landing.cshtml
@@ -0,0 +1,34 @@
+@page "/sign-in/elevate/landing"
+@inject IConfiguration Configuration
+@model TeacherIdentity.AuthServer.Pages.SignIn.Elevate.Landing
+@{
+ ViewBag.Title = "You need to give more information";
+}
+
+
diff --git a/dotnet-authserver/src/TeacherIdentity.AuthServer/Pages/SignIn/Elevate/Landing.cshtml.cs b/dotnet-authserver/src/TeacherIdentity.AuthServer/Pages/SignIn/Elevate/Landing.cshtml.cs
new file mode 100644
index 000000000..151864cf6
--- /dev/null
+++ b/dotnet-authserver/src/TeacherIdentity.AuthServer/Pages/SignIn/Elevate/Landing.cshtml.cs
@@ -0,0 +1,43 @@
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.Filters;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+using TeacherIdentity.AuthServer.Journeys;
+using TeacherIdentity.AuthServer.Models;
+using TeacherIdentity.AuthServer.Oidc;
+
+namespace TeacherIdentity.AuthServer.Pages.SignIn.Elevate;
+
+[CheckCanAccessStep(CurrentStep)]
+public class Landing : PageModel
+{
+ private const string CurrentStep = ElevateTrnVerificationLevelJourney.Steps.Landing;
+
+ private readonly SignInJourney _journey;
+ private readonly ICurrentClientProvider _currentClientProvider;
+
+ public Landing(
+ SignInJourney journey,
+ ICurrentClientProvider currentClientProvider)
+ {
+ _journey = journey;
+ _currentClientProvider = currentClientProvider;
+ }
+
+ public string? ClientDisplayName { get; set; }
+
+ public TrnRequirementType TrnRequirementType { get; set; }
+
+ public void OnGet()
+ {
+ }
+
+ public async Task OnPost() => await _journey.Advance(CurrentStep);
+
+ public async override Task OnPageHandlerExecutionAsync(PageHandlerExecutingContext context, PageHandlerExecutionDelegate next)
+ {
+ ClientDisplayName = (await _currentClientProvider.GetCurrentClient())!.DisplayName;
+ TrnRequirementType = _journey.AuthenticationState.OAuthState!.TrnRequirementType!.Value;
+
+ await base.OnPageHandlerExecutionAsync(context, next);
+ }
+}
diff --git a/dotnet-authserver/src/TeacherIdentity.AuthServer/Pages/SignIn/Register/EmailExists.cshtml b/dotnet-authserver/src/TeacherIdentity.AuthServer/Pages/SignIn/Register/EmailExists.cshtml
index bd828fe50..269bc43bf 100644
--- a/dotnet-authserver/src/TeacherIdentity.AuthServer/Pages/SignIn/Register/EmailExists.cshtml
+++ b/dotnet-authserver/src/TeacherIdentity.AuthServer/Pages/SignIn/Register/EmailExists.cshtml
@@ -13,7 +13,7 @@
diff --git a/dotnet-authserver/src/TeacherIdentity.AuthServer/Pages/SignIn/Register/NiNumberPage.cshtml.cs b/dotnet-authserver/src/TeacherIdentity.AuthServer/Pages/SignIn/Register/NiNumberPage.cshtml.cs
index 1cd1398d3..dd3dbfc4a 100644
--- a/dotnet-authserver/src/TeacherIdentity.AuthServer/Pages/SignIn/Register/NiNumberPage.cshtml.cs
+++ b/dotnet-authserver/src/TeacherIdentity.AuthServer/Pages/SignIn/Register/NiNumberPage.cshtml.cs
@@ -5,7 +5,7 @@
namespace TeacherIdentity.AuthServer.Pages.SignIn.Register;
-[CheckJourneyType(typeof(CoreSignInJourneyWithTrnLookup))]
+[CheckJourneyType(typeof(CoreSignInJourneyWithTrnLookup), typeof(ElevateTrnVerificationLevelJourney))]
[CheckCanAccessStep(CurrentStep)]
public class NiNumberPage : PageModel
{
@@ -35,7 +35,7 @@ public async Task OnPost(string submit)
{
if (submit == "ni_number_not_known")
{
- HttpContext.GetAuthenticationState().OnHasNationalInsuranceNumberSet(false);
+ _journey.AuthenticationState.OnHasNationalInsuranceNumberSet(false);
}
else
{
@@ -44,7 +44,7 @@ public async Task OnPost(string submit)
return this.PageWithErrors();
}
- HttpContext.GetAuthenticationState().OnNationalInsuranceNumberSet(NiNumber!);
+ _journey.AuthenticationState.OnNationalInsuranceNumberSet(NiNumber!);
}
return await _journey.Advance(CurrentStep);
diff --git a/dotnet-authserver/src/TeacherIdentity.AuthServer/Pages/SignIn/Register/TrnPage.cshtml b/dotnet-authserver/src/TeacherIdentity.AuthServer/Pages/SignIn/Register/TrnPage.cshtml
index 3ccae5237..4f1515cdd 100644
--- a/dotnet-authserver/src/TeacherIdentity.AuthServer/Pages/SignIn/Register/TrnPage.cshtml
+++ b/dotnet-authserver/src/TeacherIdentity.AuthServer/Pages/SignIn/Register/TrnPage.cshtml
@@ -25,15 +25,18 @@
-
- I do not know my TRN
-
- You can continue without it
-
- Continue without TRN
-
-
-
+ @if (Model.ShowContinueWithoutTrnButton)
+ {
+
+ I do not know my TRN
+
+ You can continue without it
+
+ Continue without TRN
+
+
+
+ }
Continue
diff --git a/dotnet-authserver/src/TeacherIdentity.AuthServer/Pages/SignIn/Register/TrnPage.cshtml.cs b/dotnet-authserver/src/TeacherIdentity.AuthServer/Pages/SignIn/Register/TrnPage.cshtml.cs
index 49041b296..e618567cc 100644
--- a/dotnet-authserver/src/TeacherIdentity.AuthServer/Pages/SignIn/Register/TrnPage.cshtml.cs
+++ b/dotnet-authserver/src/TeacherIdentity.AuthServer/Pages/SignIn/Register/TrnPage.cshtml.cs
@@ -1,11 +1,12 @@
using System.ComponentModel.DataAnnotations;
using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.AspNetCore.Mvc.RazorPages;
using TeacherIdentity.AuthServer.Journeys;
namespace TeacherIdentity.AuthServer.Pages.SignIn.Register;
-[CheckJourneyType(typeof(CoreSignInJourneyWithTrnLookup))]
+[CheckJourneyType(typeof(CoreSignInJourneyWithTrnLookup), typeof(ElevateTrnVerificationLevelJourney))]
[CheckCanAccessStep(CurrentStep)]
public class TrnPage : PageModel
{
@@ -26,6 +27,8 @@ public TrnPage(SignInJourney journey)
[RegularExpression(@"\A\D*(\d{1}\D*){7}\D*\Z", ErrorMessage = "Your TRN number should contain 7 digits")]
public string? StatedTrn { get; set; }
+ public bool ShowContinueWithoutTrnButton { get; set; }
+
public void OnGet()
{
SetDefaultInputValues();
@@ -33,9 +36,9 @@ public void OnGet()
public async Task OnPost(string submit)
{
- if (submit == "trn_not_known")
+ if (submit == "trn_not_known" && ShowContinueWithoutTrnButton)
{
- HttpContext.GetAuthenticationState().OnHasTrnSet(false);
+ _journey.AuthenticationState.OnHasTrnSet(false);
}
else
{
@@ -44,12 +47,17 @@ public async Task OnPost(string submit)
return this.PageWithErrors();
}
- HttpContext.GetAuthenticationState().OnTrnSet(StatedTrn);
+ _journey.AuthenticationState.OnTrnSet(StatedTrn);
}
return await _journey.Advance(CurrentStep);
}
+ public override void OnPageHandlerExecuting(PageHandlerExecutingContext context)
+ {
+ ShowContinueWithoutTrnButton = _journey.GetType() != typeof(ElevateTrnVerificationLevelJourney);
+ }
+
private void SetDefaultInputValues()
{
StatedTrn ??= _journey.AuthenticationState.StatedTrn;
diff --git a/dotnet-authserver/src/TeacherIdentity.AuthServer/UserClaimHelper.cs b/dotnet-authserver/src/TeacherIdentity.AuthServer/UserClaimHelper.cs
index cdc3d1094..10ec1fe9d 100644
--- a/dotnet-authserver/src/TeacherIdentity.AuthServer/UserClaimHelper.cs
+++ b/dotnet-authserver/src/TeacherIdentity.AuthServer/UserClaimHelper.cs
@@ -90,10 +90,7 @@ public async Task> GetPublicClaims(Guid userId, TrnMa
if (trnMatchPolicy is not null)
{
var haveSufficientTrnMatch = user.Trn is not null &&
- (trnMatchPolicy == TrnMatchPolicy.Default ||
- user.TrnVerificationLevel == TrnVerificationLevel.Medium ||
- user.TrnAssociationSource == TrnAssociationSource.TrnToken ||
- user.TrnAssociationSource == TrnAssociationSource.SupportUi);
+ (trnMatchPolicy == TrnMatchPolicy.Default || user.EffectiveVerificationLevel == TrnVerificationLevel.Medium);
if (haveSufficientTrnMatch)
{
@@ -106,7 +103,7 @@ public async Task> GetPublicClaims(Guid userId, TrnMa
{
var dqtPerson = await _dqtApiClient.GetTeacherByTrn(user.Trn!) ?? throw new Exception($"Could not find teacher with TRN: '{user.Trn}'.");
var dqtRecordHasNino = !string.IsNullOrEmpty(dqtPerson.NationalInsuranceNumber);
- var niNumber = dqtRecordHasNino ? dqtPerson.NationalInsuranceNumber : user.NationalInsuranceNumber;
+ var niNumber = User.NormalizeNationalInsuranceNumber(dqtRecordHasNino ? dqtPerson.NationalInsuranceNumber : user.NationalInsuranceNumber);
AddClaimIfHaveValue(claims, CustomClaims.NiNumber, niNumber);
claims.Add(new Claim(CustomClaims.TrnMatchNiNumber, dqtRecordHasNino.ToString()));
}
diff --git a/dotnet-authserver/src/TeacherIdentity.TestClient/Controllers/HomeController.cs b/dotnet-authserver/src/TeacherIdentity.TestClient/Controllers/HomeController.cs
index 7149de75e..9d7feb138 100644
--- a/dotnet-authserver/src/TeacherIdentity.TestClient/Controllers/HomeController.cs
+++ b/dotnet-authserver/src/TeacherIdentity.TestClient/Controllers/HomeController.cs
@@ -22,7 +22,8 @@ public IActionResult Profile()
FirstName = User.FindFirstValue("given_name"),
LastName = User.FindFirstValue("family_name"),
PreferredName = User.FindFirstValue("preferred-name"),
- Trn = User.FindFirstValue("trn")
+ Trn = User.FindFirstValue("trn"),
+ NiNumber = User.FindFirstValue("ni_number")
};
return View(model);
diff --git a/dotnet-authserver/src/TeacherIdentity.TestClient/Models/ProfileModel.cs b/dotnet-authserver/src/TeacherIdentity.TestClient/Models/ProfileModel.cs
index c6a38e5d4..dd6065815 100644
--- a/dotnet-authserver/src/TeacherIdentity.TestClient/Models/ProfileModel.cs
+++ b/dotnet-authserver/src/TeacherIdentity.TestClient/Models/ProfileModel.cs
@@ -8,4 +8,5 @@ public class ProfileModel
public string? LastName { get; set; }
public string? PreferredName { get; set; }
public string? Trn { get; set; }
+ public string? NiNumber { get; set; }
}
diff --git a/dotnet-authserver/src/TeacherIdentity.TestClient/Views/Home/Index.cshtml b/dotnet-authserver/src/TeacherIdentity.TestClient/Views/Home/Index.cshtml
index 6218a51ec..f72063c1e 100644
--- a/dotnet-authserver/src/TeacherIdentity.TestClient/Views/Home/Index.cshtml
+++ b/dotnet-authserver/src/TeacherIdentity.TestClient/Views/Home/Index.cshtml
@@ -7,7 +7,7 @@
-
+
Core + TRN lookup (Access your teaching qualifications)
@@ -16,6 +16,11 @@
Core + TRN lookup (NPQ)
+
+
+ Core + TRN lookup (Claim)
+
+
diff --git a/dotnet-authserver/src/TeacherIdentity.TestClient/Views/Home/Profile.cshtml b/dotnet-authserver/src/TeacherIdentity.TestClient/Views/Home/Profile.cshtml
index 1fd599d60..178d83df7 100644
--- a/dotnet-authserver/src/TeacherIdentity.TestClient/Views/Home/Profile.cshtml
+++ b/dotnet-authserver/src/TeacherIdentity.TestClient/Views/Home/Profile.cshtml
@@ -11,4 +11,5 @@
User ID: @Model.UserId
TRN: @Model.Trn
Preferred name: @Model.PreferredName
+ NI number: @Model.NiNumber
diff --git a/dotnet-authserver/tests/TeacherIdentity.AuthServer.EndToEndTests/BrowserContextExtensions.cs b/dotnet-authserver/tests/TeacherIdentity.AuthServer.EndToEndTests/BrowserContextExtensions.cs
new file mode 100644
index 000000000..3bf3c1112
--- /dev/null
+++ b/dotnet-authserver/tests/TeacherIdentity.AuthServer.EndToEndTests/BrowserContextExtensions.cs
@@ -0,0 +1,29 @@
+using Microsoft.Playwright;
+
+namespace TeacherIdentity.AuthServer.EndToEndTests;
+
+public static class BrowserContextExtensions
+{
+ public static async Task ClearCookiesForTestClient(this IBrowserContext context)
+ {
+ var cookies = await context.CookiesAsync();
+
+ await context.ClearCookiesAsync();
+
+ // All the Auth server cookies start with 'tis-'; assume the rest are for TestClient
+ await context.AddCookiesAsync(
+ cookies
+ .Where(c => c.Name.StartsWith("tis-"))
+ .Select(c => new Cookie()
+ {
+ Domain = c.Domain,
+ Expires = c.Expires,
+ HttpOnly = c.HttpOnly,
+ Name = c.Name,
+ Path = c.Path,
+ SameSite = c.SameSite,
+ Secure = c.Secure,
+ Value = c.Value
+ }));
+ }
+}
diff --git a/dotnet-authserver/tests/TeacherIdentity.AuthServer.EndToEndTests/Elevate.cs b/dotnet-authserver/tests/TeacherIdentity.AuthServer.EndToEndTests/Elevate.cs
new file mode 100644
index 000000000..be6e99dea
--- /dev/null
+++ b/dotnet-authserver/tests/TeacherIdentity.AuthServer.EndToEndTests/Elevate.cs
@@ -0,0 +1,203 @@
+using Microsoft.EntityFrameworkCore;
+using Moq;
+using TeacherIdentity.AuthServer.Models;
+using TeacherIdentity.AuthServer.Oidc;
+using TeacherIdentity.AuthServer.Services.DqtApi;
+
+namespace TeacherIdentity.AuthServer.EndToEndTests;
+
+public class Elevate : IClassFixture
+{
+ private readonly HostFixture _hostFixture;
+
+ public Elevate(HostFixture hostFixture)
+ {
+ _hostFixture = hostFixture;
+ _hostFixture.OnTestStarting();
+ }
+
+ [Fact]
+ public async Task UserSignsInWithLowVerificationLevel_IsRedirectedToElevateJourneyAndCompletesSuccessfully()
+ {
+ var user = await _hostFixture.TestData.CreateUser(hasTrn: true, trnVerificationLevel: TrnVerificationLevel.Low);
+ var nino = Faker.Identification.UkNationalInsuranceNumber();
+
+ await using var context = await _hostFixture.CreateBrowserContext();
+ var page = await context.NewPageAsync();
+
+ await page.StartOAuthJourney(additionalScope: CustomScopes.DqtRead, trnMatchPolicy: TrnMatchPolicy.Strict);
+
+ await page.SignInFromLandingPage();
+
+ await page.SubmitEmailPage(user.EmailAddress);
+
+ await page.SubmitEmailConfirmationPage();
+
+ await page.SubmitElevateLandingPage();
+
+ await page.SubmitRegisterNiNumberPage(nino);
+
+ await page.SubmitRegisterTrnPage(user.Trn!);
+
+ ConfigureDqtApiFindTeachersRequest(new FindTeachersResponseResult()
+ {
+ DateOfBirth = user.DateOfBirth,
+ EmailAddresses = new[] { user.EmailAddress },
+ FirstName = user.FirstName,
+ MiddleName = user.MiddleName,
+ LastName = user.LastName,
+ HasActiveSanctions = false,
+ NationalInsuranceNumber = nino,
+ Trn = user.Trn!,
+ Uid = Guid.NewGuid().ToString()
+ });
+
+ ConfigureDqtApiGetTeacherByTrnRequest(user.Trn!, new TeacherInfo()
+ {
+ FirstName = user.FirstName,
+ MiddleName = user.MiddleName ?? string.Empty,
+ LastName = user.LastName,
+ DateOfBirth = user.DateOfBirth,
+ Email = user.EmailAddress,
+ NationalInsuranceNumber = nino,
+ PendingDateOfBirthChange = false,
+ PendingNameChange = false,
+ Trn = user.Trn!
+ });
+
+ await page.SubmitElevateCheckAnswersPage();
+
+ await page.SubmitCompletePageForExistingUser();
+
+ user = await _hostFixture.TestData.WithDbContext(dbContext => dbContext.Users.SingleAsync(u => u.UserId == user.UserId));
+ await page.AssertSignedInOnTestClient(user, expectTrn: true, expectNiNumber: true);
+ }
+
+ [Theory]
+ [InlineData(TrnRequirementType.Optional, true)]
+ [InlineData(TrnRequirementType.Required, false)]
+ public async Task UserSignsInWithLowVerificationLevel_IsRedirectedToElevateJourneyButTrnNotFound(
+ TrnRequirementType trnRequirementType,
+ bool expectContinueButtonOnCompletePage)
+ {
+ var user = await _hostFixture.TestData.CreateUser(hasTrn: true, trnVerificationLevel: TrnVerificationLevel.Low);
+ var nino = Faker.Identification.UkNationalInsuranceNumber();
+
+ await using var context = await _hostFixture.CreateBrowserContext();
+ var page = await context.NewPageAsync();
+
+ await page.StartOAuthJourney(additionalScope: CustomScopes.DqtRead, trnMatchPolicy: TrnMatchPolicy.Strict, trnRequirement: trnRequirementType);
+
+ await page.SignInFromLandingPage();
+
+ await page.SubmitEmailPage(user.EmailAddress);
+
+ await page.SubmitEmailConfirmationPage();
+
+ await page.SubmitElevateLandingPage();
+
+ await page.SubmitRegisterNiNumberPage(nino);
+
+ await page.SubmitRegisterTrnPage(user.Trn!);
+
+ ConfigureDqtApiFindTeachersRequest(result: null);
+
+ await page.SubmitElevateCheckAnswersPage();
+
+ if (expectContinueButtonOnCompletePage)
+ {
+ await page.SubmitCompletePageForExistingUser();
+
+ user = await _hostFixture.TestData.WithDbContext(dbContext => dbContext.Users.SingleAsync(u => u.UserId == user.UserId));
+ await page.AssertSignedInOnTestClient(user, expectTrn: false, expectNiNumber: false);
+ }
+ else
+ {
+ await page.AssertOnCompletePageWithNoContinueButton();
+ }
+ }
+
+ [Fact]
+ public async Task AlreadySignedInUserWithLowVerificationLevel_IsRedirectedToElevateJourney()
+ {
+ var user = await _hostFixture.TestData.CreateUser(hasTrn: true, trnVerificationLevel: TrnVerificationLevel.Low);
+ var nino = Faker.Identification.UkNationalInsuranceNumber();
+
+ await using var context = await _hostFixture.CreateBrowserContext();
+ var page = await context.NewPageAsync();
+
+ await page.StartOAuthJourney(additionalScope: CustomScopes.DqtRead, trnMatchPolicy: TrnMatchPolicy.Default);
+
+ await page.SignInFromLandingPage();
+
+ await page.SubmitEmailPage(user.EmailAddress);
+
+ await page.SubmitEmailConfirmationPage();
+
+ await page.SubmitCompletePageForExistingUser();
+
+ await page.AssertSignedInOnTestClient(user, expectNiNumber: false);
+
+ await context.ClearCookiesForTestClient();
+
+ await page.StartOAuthJourney(additionalScope: CustomScopes.DqtRead, trnMatchPolicy: TrnMatchPolicy.Strict);
+
+ await page.SubmitElevateLandingPage();
+
+ await page.SubmitRegisterNiNumberPage(nino);
+
+ await page.SubmitRegisterTrnPage(user.Trn!);
+
+ ConfigureDqtApiFindTeachersRequest(new FindTeachersResponseResult()
+ {
+ DateOfBirth = user.DateOfBirth,
+ EmailAddresses = new[] { user.EmailAddress },
+ FirstName = user.FirstName,
+ MiddleName = user.MiddleName,
+ LastName = user.LastName,
+ HasActiveSanctions = false,
+ NationalInsuranceNumber = nino,
+ Trn = user.Trn!,
+ Uid = Guid.NewGuid().ToString()
+ });
+
+ ConfigureDqtApiGetTeacherByTrnRequest(user.Trn!, new TeacherInfo()
+ {
+ FirstName = user.FirstName,
+ MiddleName = user.MiddleName ?? string.Empty,
+ LastName = user.LastName,
+ DateOfBirth = user.DateOfBirth,
+ Email = user.EmailAddress,
+ NationalInsuranceNumber = nino,
+ PendingDateOfBirthChange = false,
+ PendingNameChange = false,
+ Trn = user.Trn!
+ });
+
+ await page.SubmitElevateCheckAnswersPage();
+
+ await page.SubmitCompletePageForExistingUser();
+
+ user = await _hostFixture.TestData.WithDbContext(dbContext => dbContext.Users.SingleAsync(u => u.UserId == user.UserId));
+ await page.AssertSignedInOnTestClient(user, expectTrn: true, expectNiNumber: true);
+ }
+
+ private void ConfigureDqtApiFindTeachersRequest(FindTeachersResponseResult? result)
+ {
+ var results = result is not null ? new[] { result } : Array.Empty();
+
+ _hostFixture.DqtApiClient
+ .Setup(mock => mock.FindTeachers(It.IsAny(), It.IsAny()))
+ .ReturnsAsync(new FindTeachersResponse()
+ {
+ Results = results
+ });
+ }
+
+ private void ConfigureDqtApiGetTeacherByTrnRequest(string trn, TeacherInfo? result)
+ {
+ _hostFixture.DqtApiClient
+ .Setup(mock => mock.GetTeacherByTrn(trn, It.IsAny()))
+ .ReturnsAsync(result);
+ }
+}
diff --git a/dotnet-authserver/tests/TeacherIdentity.AuthServer.EndToEndTests/PageExtensions.cs b/dotnet-authserver/tests/TeacherIdentity.AuthServer.EndToEndTests/PageExtensions.cs
index 80dae0f3d..72d1119b2 100644
--- a/dotnet-authserver/tests/TeacherIdentity.AuthServer.EndToEndTests/PageExtensions.cs
+++ b/dotnet-authserver/tests/TeacherIdentity.AuthServer.EndToEndTests/PageExtensions.cs
@@ -76,10 +76,10 @@ public static async Task AssertOnTestClient(this IPage page)
await page.WaitForURLAsync(url => url.StartsWith(HostFixture.ClientBaseUrl));
}
- public static Task AssertSignedInOnTestClient(this IPage page, User user, bool? expectTrn = null) =>
- AssertSignedInOnTestClient(page, user.EmailAddress, expectTrn != false ? user.Trn : null, user.FirstName, user.LastName);
+ public static Task AssertSignedInOnTestClient(this IPage page, User user, bool? expectTrn = null, bool? expectNiNumber = null) =>
+ AssertSignedInOnTestClient(page, user.EmailAddress, expectTrn != false ? user.Trn : null, user.FirstName, user.LastName, expectNiNumber == true ? user.NationalInsuranceNumber : null);
- public static async Task AssertSignedInOnTestClient(this IPage page, string email, string? trn, string firstName, string lastName)
+ public static async Task AssertSignedInOnTestClient(this IPage page, string email, string? trn, string firstName, string lastName, string? niNumber = null)
{
await page.AssertOnTestClient();
@@ -88,6 +88,7 @@ public static async Task AssertSignedInOnTestClient(this IPage page, string emai
Assert.Equal(trn ?? string.Empty, await page.InnerTextAsync("data-testid=trn"));
Assert.Equal(firstName, await page.InnerTextAsync("data-testid=first-name"));
Assert.Equal(lastName, await page.InnerTextAsync("data-testid=last-name"));
+ Assert.Equal(niNumber ?? string.Empty, await page.InnerTextAsync("data-testid=ni-number"));
}
public static async Task AssertSignedOutOnTestClient(this IPage page)
@@ -130,6 +131,12 @@ public static async Task SubmitCompletePageForExistingUser(this IPage page)
await page.ClickContinueButton();
}
+ public static async Task AssertOnCompletePageWithNoContinueButton(this IPage page)
+ {
+ await page.WaitForUrlPathAsync("/sign-in/complete");
+ Assert.Equal(0, await page.Locator(".govuk-button:text-is('Continue')").CountAsync());
+ }
+
public static async Task SignOutFromTestClient(this IPage page)
{
await page.ClickAsync("a:text-is('Sign out')");
@@ -403,4 +410,16 @@ public static async Task SignInFromRegisterPhoneExistsPage(this IPage page)
await page.WaitForUrlPathAsync("/sign-in/register/phone-exists");
await page.ClickButton("Sign in");
}
+
+ public static async Task SubmitElevateLandingPage(this IPage page)
+ {
+ await page.WaitForUrlPathAsync("/sign-in/elevate/landing");
+ await page.ClickButton("Continue");
+ }
+
+ public static async Task SubmitElevateCheckAnswersPage(this IPage page)
+ {
+ await page.WaitForUrlPathAsync("/sign-in/elevate/check-answers");
+ await page.ClickButton("Continue");
+ }
}
diff --git a/dotnet-authserver/tests/TeacherIdentity.AuthServer.EndToEndTests/SignIn.cs b/dotnet-authserver/tests/TeacherIdentity.AuthServer.EndToEndTests/SignIn.cs
index e91a33781..b8da43d8a 100644
--- a/dotnet-authserver/tests/TeacherIdentity.AuthServer.EndToEndTests/SignIn.cs
+++ b/dotnet-authserver/tests/TeacherIdentity.AuthServer.EndToEndTests/SignIn.cs
@@ -1,4 +1,3 @@
-using Microsoft.Playwright;
using TeacherIdentity.AuthServer.Models;
namespace TeacherIdentity.AuthServer.EndToEndTests;
@@ -33,36 +32,13 @@ public async Task ExistingTeacherUser_AlreadySignedIn_SkipsEmailAndPinAndShowsCo
await page.AssertSignedInOnTestClient(user);
- await ClearCookiesForTestClient();
+ await context.ClearCookiesForTestClient();
await page.StartOAuthJourney(additionalScope: null);
await page.SubmitCompletePageForExistingUser();
await page.AssertSignedInOnTestClient(user);
-
- async Task ClearCookiesForTestClient()
- {
- var cookies = await context.CookiesAsync();
-
- await context.ClearCookiesAsync();
-
- // All the Auth server cookies start with 'tis-'
- await context.AddCookiesAsync(
- cookies
- .Where(c => c.Name.StartsWith("tis-"))
- .Select(c => new Cookie()
- {
- Domain = c.Domain,
- Expires = c.Expires,
- HttpOnly = c.HttpOnly,
- Name = c.Name,
- Path = c.Path,
- SameSite = c.SameSite,
- Secure = c.Secure,
- Value = c.Value
- }));
- }
}
[Fact]
diff --git a/dotnet-authserver/tests/TeacherIdentity.AuthServer.Tests/EndpointTests/SignIn/CompleteTests.cs b/dotnet-authserver/tests/TeacherIdentity.AuthServer.Tests/EndpointTests/SignIn/CompleteTests.cs
index 11f788d51..d6475ac40 100644
--- a/dotnet-authserver/tests/TeacherIdentity.AuthServer.Tests/EndpointTests/SignIn/CompleteTests.cs
+++ b/dotnet-authserver/tests/TeacherIdentity.AuthServer.Tests/EndpointTests/SignIn/CompleteTests.cs
@@ -299,7 +299,7 @@ public async Task Get_ValidRequest_RendersExpectedContent(
new[]
{
"We need to find your details in our records so you can use this service.",
- "To fix this problem, please email our support team qts.enquiries@education.gov.uk",
+ "To fix this problem, please email our support team",
}
},
{
diff --git a/dotnet-authserver/tests/TeacherIdentity.AuthServer.Tests/EndpointTests/SignIn/Elevate/CheckAnswersTests.cs b/dotnet-authserver/tests/TeacherIdentity.AuthServer.Tests/EndpointTests/SignIn/Elevate/CheckAnswersTests.cs
new file mode 100644
index 000000000..aa152fb85
--- /dev/null
+++ b/dotnet-authserver/tests/TeacherIdentity.AuthServer.Tests/EndpointTests/SignIn/Elevate/CheckAnswersTests.cs
@@ -0,0 +1,212 @@
+using Microsoft.EntityFrameworkCore;
+using TeacherIdentity.AuthServer.Models;
+using TeacherIdentity.AuthServer.Oidc;
+using TeacherIdentity.AuthServer.Services.DqtApi;
+
+namespace TeacherIdentity.AuthServer.Tests.EndpointTests.SignIn.Elevate;
+
+[Collection(nameof(DisableParallelization))]
+public class CheckAnswersTests : TestBase
+{
+ public CheckAnswersTests(HostFixture hostFixture) : base(hostFixture)
+ {
+ }
+
+ [Fact]
+ public async Task Get_InvalidAuthenticationStateProvided_ReturnsBadRequest()
+ {
+ await InvalidAuthenticationState_ReturnsBadRequest(HttpMethod.Get, "/sign-in/elevate/check-answers");
+ }
+
+ [Fact]
+ public async Task Get_MissingAuthenticationStateProvided_ReturnsBadRequest()
+ {
+ await InvalidAuthenticationState_ReturnsBadRequest(HttpMethod.Get, "/sign-in/elevate/check-answers");
+ }
+
+ [Fact]
+ public async Task Get_JourneyIsAlreadyCompleted_RedirectsToPostSignInUrl()
+ {
+ await JourneyIsAlreadyCompleted_RedirectsToPostSignInUrl(CustomScopes.DqtRead, trnRequirementType: null, HttpMethod.Get, "/sign-in/elevate/check-answers");
+ }
+
+ [Fact]
+ public async Task Get_JourneyHasExpired_RendersErrorPage()
+ {
+ var user = await TestData.CreateUser(hasTrn: true, trnVerificationLevel: TrnVerificationLevel.Low);
+ var nino = Faker.Identification.UkNationalInsuranceNumber();
+
+ await JourneyHasExpired_RendersErrorPage(CreateConfigureAuthenticationState(user, nino, user.Trn!), CustomScopes.DqtRead, trnRequirementType: null, HttpMethod.Get, "/sign-in/elevate/check-answers", trnMatchPolicy: TrnMatchPolicy.Strict);
+ }
+
+ [Fact]
+ public async Task Get_ValidRequest_ReturnsExpectedContent()
+ {
+ // Arrange
+ var user = await TestData.CreateUser(hasTrn: true, trnVerificationLevel: TrnVerificationLevel.Low);
+ var nino = Faker.Identification.UkNationalInsuranceNumber();
+
+ var authStateHelper = await CreateAuthenticationStateHelper(
+ CreateConfigureAuthenticationState(user, nino, user.Trn!),
+ additionalScopes: CustomScopes.DqtRead,
+ trnMatchPolicy: TrnMatchPolicy.Strict,
+ client: TestClients.DefaultClient);
+
+ var authState = authStateHelper.AuthenticationState;
+
+ var request = new HttpRequestMessage(HttpMethod.Get, $"/sign-in/elevate/check-answers?{authStateHelper.ToQueryParam()}");
+
+ // Act
+ var response = await HttpClient.SendAsync(request);
+
+ // Assert
+ var doc = await response.GetDocument();
+ Assert.Equal(authState.Trn, doc.GetSummaryListValueForKey("Teacher reference number (TRN)"));
+ Assert.Equal(authState.NationalInsuranceNumber, doc.GetSummaryListValueForKey("National Insurance number"));
+ }
+
+ [Fact]
+ public async Task Post_InvalidAuthenticationStateProvided_ReturnsBadRequest()
+ {
+ await InvalidAuthenticationState_ReturnsBadRequest(HttpMethod.Post, "/sign-in/elevate/check-answers");
+ }
+
+ [Fact]
+ public async Task Post_MissingAuthenticationStateProvided_ReturnsBadRequest()
+ {
+ await InvalidAuthenticationState_ReturnsBadRequest(HttpMethod.Post, "/sign-in/elevate/check-answers");
+ }
+
+ [Fact]
+ public async Task Post_JourneyIsAlreadyCompleted_RedirectsToPostSignInUrl()
+ {
+ await JourneyIsAlreadyCompleted_RedirectsToPostSignInUrl(CustomScopes.DqtRead, trnRequirementType: null, HttpMethod.Post, "/sign-in/elevate/check-answers");
+ }
+
+ [Fact]
+ public async Task Post_JourneyHasExpired_RendersErrorPage()
+ {
+ var user = await TestData.CreateUser(hasTrn: true, trnVerificationLevel: TrnVerificationLevel.Low);
+ var nino = Faker.Identification.UkNationalInsuranceNumber();
+
+ await JourneyHasExpired_RendersErrorPage(CreateConfigureAuthenticationState(user, nino, user.Trn!), CustomScopes.DqtRead, trnRequirementType: null, HttpMethod.Post, "/sign-in/elevate/check-answers", trnMatchPolicy: TrnMatchPolicy.Strict);
+ }
+
+ [Fact]
+ public async Task Post_TrnLookupFailed_UpdatesUserNinoAndRedirects()
+ {
+ // Arrange
+ var user = await TestData.CreateUser(hasTrn: true, trnVerificationLevel: TrnVerificationLevel.Low);
+ var nino = Faker.Identification.UkNationalInsuranceNumber();
+
+ var authStateHelper = await CreateAuthenticationStateHelper(
+ CreateConfigureAuthenticationState(user, nino, user.Trn!),
+ additionalScopes: CustomScopes.DqtRead,
+ trnMatchPolicy: TrnMatchPolicy.Strict,
+ client: TestClients.DefaultClient);
+
+ var authState = authStateHelper.AuthenticationState;
+
+ HostFixture.DqtApiClient
+ .Setup(mock => mock.FindTeachers(It.Is(req =>
+ req.DateOfBirth == authState.DateOfBirth &&
+ req.FirstName == authState.FirstName &&
+ req.LastName == authState.LastName &&
+ req.NationalInsuranceNumber == authState.NationalInsuranceNumber &&
+ req.Trn == authState.StatedTrn),
+ It.IsAny()))
+ .ReturnsAsync(new FindTeachersResponse()
+ {
+ Results = Array.Empty()
+ });
+
+ var request = new HttpRequestMessage(HttpMethod.Post, $"/sign-in/elevate/check-answers?{authStateHelper.ToQueryParam()}")
+ {
+ Content = new FormUrlEncodedContentBuilder()
+ };
+
+ // Act
+ var response = await HttpClient.SendAsync(request);
+
+ // Assert
+ Assert.Equal(StatusCodes.Status302Found, (int)response.StatusCode);
+ Assert.StartsWith(authState.PostSignInUrl, response.Headers.Location?.OriginalString);
+
+ await TestData.WithDbContext(async dbContext =>
+ {
+ user = await dbContext.Users.SingleAsync(u => u.UserId == user.UserId);
+ Assert.Equal(nino, user.NationalInsuranceNumber);
+ Assert.Equal(TrnVerificationLevel.Low, user.TrnVerificationLevel);
+ });
+ }
+
+ [Fact]
+ public async Task Post_TrnLookupSuccessful_UpdatesUserTrnVerificationLevelAndRedirects()
+ {
+ // Arrange
+ var user = await TestData.CreateUser(hasTrn: true, trnVerificationLevel: TrnVerificationLevel.Low);
+ var nino = Faker.Identification.UkNationalInsuranceNumber();
+
+ var authStateHelper = await CreateAuthenticationStateHelper(
+ CreateConfigureAuthenticationState(user, nino, user.Trn!),
+ additionalScopes: CustomScopes.DqtRead,
+ trnMatchPolicy: TrnMatchPolicy.Strict,
+ client: TestClients.DefaultClient);
+
+ var authState = authStateHelper.AuthenticationState;
+
+ HostFixture.DqtApiClient
+ .Setup(mock => mock.FindTeachers(It.Is(req =>
+ req.DateOfBirth == authState.DateOfBirth &&
+ req.FirstName == authState.FirstName &&
+ req.LastName == authState.LastName &&
+ req.NationalInsuranceNumber == authState.NationalInsuranceNumber &&
+ req.Trn == authState.StatedTrn),
+ It.IsAny()))
+ .ReturnsAsync(new FindTeachersResponse()
+ {
+ Results = new[]
+ {
+ new FindTeachersResponseResult()
+ {
+ DateOfBirth = authState.DateOfBirth,
+ EmailAddresses = new[] { authState.EmailAddress! },
+ FirstName = authState.FirstName!,
+ MiddleName = authState.MiddleName!,
+ LastName = authState.LastName!,
+ HasActiveSanctions = false,
+ NationalInsuranceNumber = authState.NationalInsuranceNumber,
+ Trn = user.Trn!,
+ Uid = Guid.NewGuid().ToString()
+ }
+ }
+ });
+
+ var request = new HttpRequestMessage(HttpMethod.Post, $"/sign-in/elevate/check-answers?{authStateHelper.ToQueryParam()}")
+ {
+ Content = new FormUrlEncodedContentBuilder()
+ };
+
+ // Act
+ var response = await HttpClient.SendAsync(request);
+
+ // Assert
+ Assert.Equal(StatusCodes.Status302Found, (int)response.StatusCode);
+ Assert.StartsWith(authState.PostSignInUrl, response.Headers.Location?.OriginalString);
+
+ await TestData.WithDbContext(async dbContext =>
+ {
+ user = await dbContext.Users.SingleAsync(u => u.UserId == user.UserId);
+ Assert.Equal(nino, user.NationalInsuranceNumber);
+ Assert.Equal(TrnVerificationLevel.Medium, user.TrnVerificationLevel);
+ });
+ }
+
+ private AuthenticationStateConfiguration CreateConfigureAuthenticationState(User user, string nino, string statedTrn) =>
+ c => async s =>
+ {
+ await c.EmailVerified(user.EmailAddress, user: user)(s);
+ s.OnNationalInsuranceNumberSet(nino);
+ s.OnTrnSet(statedTrn);
+ };
+}
diff --git a/dotnet-authserver/tests/TeacherIdentity.AuthServer.Tests/EndpointTests/SignIn/TestBase.CommonTests.cs b/dotnet-authserver/tests/TeacherIdentity.AuthServer.Tests/EndpointTests/SignIn/TestBase.CommonTests.cs
index 12d515f7f..3fc6844db 100644
--- a/dotnet-authserver/tests/TeacherIdentity.AuthServer.Tests/EndpointTests/SignIn/TestBase.CommonTests.cs
+++ b/dotnet-authserver/tests/TeacherIdentity.AuthServer.Tests/EndpointTests/SignIn/TestBase.CommonTests.cs
@@ -102,10 +102,10 @@ public async Task JourneyHasExpired_RendersErrorPage(
TrnRequirementType? trnRequirementType,
HttpMethod method,
string url,
- HttpContent? content = null)
+ HttpContent? content = null,
+ TrnMatchPolicy? trnMatchPolicy = null)
{
// Arrange
- var user = await TestData.CreateUser(hasTrn: true);
var authStateHelper = await CreateAuthenticationStateHelper(
c => async s =>
{
@@ -113,7 +113,8 @@ public async Task JourneyHasExpired_RendersErrorPage(
await configureAuthenticationHelper(c)(s);
},
additionalScopes,
- trnRequirementType);
+ trnRequirementType,
+ trnMatchPolicy);
var fullUrl = new Url(url).SetQueryParam(AuthenticationStateMiddleware.IdQueryParameterName, authStateHelper.AuthenticationState.JourneyId);
var request = new HttpRequestMessage(method, fullUrl);
diff --git a/dotnet-authserver/tests/TeacherIdentity.AuthServer.Tests/EndpointTests/SignIn/TestBase.cs b/dotnet-authserver/tests/TeacherIdentity.AuthServer.Tests/EndpointTests/SignIn/TestBase.cs
index 8027a9ae9..5acb1d015 100644
--- a/dotnet-authserver/tests/TeacherIdentity.AuthServer.Tests/EndpointTests/SignIn/TestBase.cs
+++ b/dotnet-authserver/tests/TeacherIdentity.AuthServer.Tests/EndpointTests/SignIn/TestBase.cs
@@ -51,7 +51,7 @@ public void ConfigureDqtApiClientToReturnSingleMatch(AuthenticationStateHelper a
req.EmailAddress == authState.EmailAddress &&
req.FirstName == authState.FirstName &&
req.LastName == authState.LastName &&
- req.NationalInsuranceNumber == authState.NationalInsuranceNumber &&
+ req.NationalInsuranceNumber == (authState.NationalInsuranceNumber ?? string.Empty) &&
req.IttProviderName == authState.IttProviderName),
It.IsAny()))
.ReturnsAsync(new FindTeachersResponse()
diff --git a/dotnet-authserver/tests/TeacherIdentity.AuthServer.Tests/Services/PublishEventsBackgroundServiceTests.cs b/dotnet-authserver/tests/TeacherIdentity.AuthServer.Tests/Services/PublishEventsBackgroundServiceTests.cs
index b495f2b7d..76b9320db 100644
--- a/dotnet-authserver/tests/TeacherIdentity.AuthServer.Tests/Services/PublishEventsBackgroundServiceTests.cs
+++ b/dotnet-authserver/tests/TeacherIdentity.AuthServer.Tests/Services/PublishEventsBackgroundServiceTests.cs
@@ -118,7 +118,9 @@ public async Task PublishEvents_EventObserverThrows_DoesNotThrow()
Trn = null,
TrnAssociationSource = null,
TrnLookupStatus = null,
+ TrnVerificationLevel = null,
MobileNumber = _dbFixture.TestData.GenerateUniqueMobileNumber(),
+ NationalInsuranceNumber = null,
UserId = Guid.NewGuid(),
UserType = UserType.Default
}