From 4f6e27915fd34c85b624a935377d462df492a592 Mon Sep 17 00:00:00 2001 From: James Gunn Date: Fri, 22 Sep 2023 16:09:53 +0100 Subject: [PATCH] Add TRN verification elevation journey --- .../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 +-- .../SignIn/Elevate/CheckAnswersTests.cs | 212 ++++++++++++++++++ .../SignIn/TestBase.CommonTests.cs | 7 +- .../PublishEventsBackgroundServiceTests.cs | 2 + 35 files changed, 970 insertions(+), 107 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 +{ + +} + +
+
+
+

@ViewBag.Title

+ + + + National Insurance number + + @Model.NationalInsuranceNumber + + + Change + + + + Teacher reference number (TRN) + @Model.Trn + + Change + + + + + Continue +
+
+
+ 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"; +} + +
+
+
+
+

@ViewBag.Title

+ +

You’ve signed in to your DfE Identity account.

+ +

To @Model.ClientDisplayName using your account, you need to give your:

+
    +
  • National Insurance number
  • +
  • teacher reference number (TRN)
  • +
+ + @if (Model.TrnRequirementType == TrnRequirementType.Required) + { +

+ If you cannot give this information, you will not be able to access your teaching qualifications. + Email @(Configuration["SupportEmail"]) for help. +

+ } + + Continue +
+
+
+
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 @@

@ViewBag.Title

-

We’ve found a DfE Identity account for email (@Model.Email)

+

We’ve found a DfE Identity account for email @Model.Email

Sign in
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/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/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 }