diff --git a/TeachingRecordSystem/src/TeachingRecordSystem.AuthorizeAccess/ClaimTypes.cs b/TeachingRecordSystem/src/TeachingRecordSystem.AuthorizeAccess/ClaimTypes.cs new file mode 100644 index 000000000..4e839a0ec --- /dev/null +++ b/TeachingRecordSystem/src/TeachingRecordSystem.AuthorizeAccess/ClaimTypes.cs @@ -0,0 +1,7 @@ +namespace TeachingRecordSystem.AuthorizeAccess; + +public static class ClaimTypes +{ + public const string Trn = "trn"; + public const string PersonId = "person_id"; +} diff --git a/TeachingRecordSystem/src/TeachingRecordSystem.AuthorizeAccess/Infrastructure/FormFlow/FormFlowHelper.cs b/TeachingRecordSystem/src/TeachingRecordSystem.AuthorizeAccess/Infrastructure/FormFlow/Extensions.cs similarity index 52% rename from TeachingRecordSystem/src/TeachingRecordSystem.AuthorizeAccess/Infrastructure/FormFlow/FormFlowHelper.cs rename to TeachingRecordSystem/src/TeachingRecordSystem.AuthorizeAccess/Infrastructure/FormFlow/Extensions.cs index 3ed4df471..4bec63198 100644 --- a/TeachingRecordSystem/src/TeachingRecordSystem.AuthorizeAccess/Infrastructure/FormFlow/FormFlowHelper.cs +++ b/TeachingRecordSystem/src/TeachingRecordSystem.AuthorizeAccess/Infrastructure/FormFlow/Extensions.cs @@ -1,39 +1,29 @@ using Microsoft.AspNetCore.Mvc.ModelBinding; -using Microsoft.Extensions.Options; using TeachingRecordSystem.FormFlow; using TeachingRecordSystem.FormFlow.State; namespace TeachingRecordSystem.AuthorizeAccess.Infrastructure.FormFlow; -public class SignInJourneyHelper +public static class Extensions { - private readonly IUserInstanceStateProvider _userInstanceStateProvider; - private readonly JourneyDescriptor _journeyDescriptor; - - public SignInJourneyHelper(IUserInstanceStateProvider userInstanceStateProvider, IOptions formFlowOptionsAccessor) - { - _userInstanceStateProvider = userInstanceStateProvider; - - _journeyDescriptor = formFlowOptionsAccessor.Value.JourneyRegistry.GetJourneyByName(SignInJourneyState.JourneyName) ?? - throw new InvalidOperationException($"Cannot find {SignInJourneyState.JourneyName} journey."); - } - - public async Task?> GetInstanceAsync(HttpContext httpContext, JourneyInstanceId? instanceIdHint = null) + public static async Task?> GetSignInJourneyInstanceAsync( + this IUserInstanceStateProvider userInstanceStateProvider, + HttpContext httpContext, + JourneyInstanceId? instanceIdHint = null) { if (httpContext.Items.TryGetValue(typeof(JourneyInstance), out var journeyInstanceObj) && journeyInstanceObj is JourneyInstance instance) { return instance; } - var valueProvider = CreateValueProvider(httpContext); - if (!JourneyInstanceId.TryResolve(_journeyDescriptor, valueProvider, out var instanceId) && instanceIdHint is null) + if (!JourneyInstanceId.TryResolve(SignInJourneyState.JourneyDescriptor, valueProvider, out var instanceId) && instanceIdHint is null) { return null; } - if (await _userInstanceStateProvider.GetInstanceAsync(instanceIdHint ?? instanceId, typeof(SignInJourneyState)) + if (await userInstanceStateProvider.GetInstanceAsync(instanceIdHint ?? instanceId, typeof(SignInJourneyState)) is not JourneyInstance persistedInstance) { return null; @@ -44,12 +34,13 @@ public SignInJourneyHelper(IUserInstanceStateProvider userInstanceStateProvider, return persistedInstance; } - public async Task> GetOrCreateInstanceAsync( + public static async Task> GetOrCreateSignInJourneyInstanceAsync( + this IUserInstanceStateProvider userInstanceStateProvider, HttpContext httpContext, Func createState, Action updateState) { - var existingInstance = await GetInstanceAsync(httpContext); + var existingInstance = await GetSignInJourneyInstanceAsync(userInstanceStateProvider, httpContext); if (existingInstance is not null) { @@ -58,10 +49,10 @@ public async Task> GetOrCreateInstanceAsync( } var valueProvider = CreateValueProvider(httpContext); - var instanceId = JourneyInstanceId.Create(_journeyDescriptor, valueProvider); + var instanceId = JourneyInstanceId.Create(SignInJourneyState.JourneyDescriptor, valueProvider); var newState = createState(); - var instance = (JourneyInstance)await _userInstanceStateProvider.CreateInstanceAsync(instanceId, typeof(SignInJourneyState), newState, properties: null); + var instance = (JourneyInstance)await userInstanceStateProvider.CreateInstanceAsync(instanceId, typeof(SignInJourneyState), newState, properties: null); httpContext.Items[typeof(JourneyInstance)] = instance; diff --git a/TeachingRecordSystem/src/TeachingRecordSystem.AuthorizeAccess/Infrastructure/Security/FormFlowJourneySignInHandler.cs b/TeachingRecordSystem/src/TeachingRecordSystem.AuthorizeAccess/Infrastructure/Security/FormFlowJourneySignInHandler.cs index 3f3f7074c..4f8f7d2f3 100644 --- a/TeachingRecordSystem/src/TeachingRecordSystem.AuthorizeAccess/Infrastructure/Security/FormFlowJourneySignInHandler.cs +++ b/TeachingRecordSystem/src/TeachingRecordSystem.AuthorizeAccess/Infrastructure/Security/FormFlowJourneySignInHandler.cs @@ -10,7 +10,7 @@ namespace TeachingRecordSystem.AuthorizeAccess.Infrastructure.Security; /// An that persists an to /// the current FormFlow instance's state. /// -public class FormFlowJourneySignInHandler(SignInJourneyHelper signInJourneyHelper) : IAuthenticationSignInHandler +public class FormFlowJourneySignInHandler(SignInJourneyHelper helper) : IAuthenticationSignInHandler { private AuthenticationScheme? _scheme; private HttpContext? _context; @@ -19,7 +19,7 @@ public async Task AuthenticateAsync() { EnsureInitialized(); - var journeyInstance = await signInJourneyHelper.GetInstanceAsync(_context); + var journeyInstance = await helper.UserInstanceStateProvider.GetSignInJourneyInstanceAsync(_context); if (journeyInstance is null || journeyInstance.State.OneLoginAuthenticationTicket is null) { @@ -58,19 +58,19 @@ public async Task SignInAsync(ClaimsPrincipal user, AuthenticationProperties? pr var journeyInstanceId = JourneyInstanceId.Deserialize(serializedInstanceId); - var journeyInstance = await signInJourneyHelper.GetInstanceAsync(_context, journeyInstanceId) ?? + var journeyInstance = await helper.UserInstanceStateProvider.GetSignInJourneyInstanceAsync(_context, journeyInstanceId) ?? throw new InvalidOperationException("No FormFlow journey."); var ticket = new AuthenticationTicket(user, properties, _scheme.Name); - await journeyInstance.UpdateStateAsync(state => state.OnSignedInWithOneLogin(ticket)); + await journeyInstance.UpdateStateAsync(async state => await helper.OnSignedInWithOneLogin(state, ticket)); } public async Task SignOutAsync(AuthenticationProperties? properties) { EnsureInitialized(); - var journeyInstance = await signInJourneyHelper.GetInstanceAsync(_context) ?? + var journeyInstance = await helper.UserInstanceStateProvider.GetSignInJourneyInstanceAsync(_context) ?? throw new InvalidOperationException("No FormFlow journey."); await journeyInstance.UpdateStateAsync(state => state.Reset()); diff --git a/TeachingRecordSystem/src/TeachingRecordSystem.AuthorizeAccess/Infrastructure/Security/MatchToTeachingRecordAuthenticationHandler.cs b/TeachingRecordSystem/src/TeachingRecordSystem.AuthorizeAccess/Infrastructure/Security/MatchToTeachingRecordAuthenticationHandler.cs index e30c233b0..354358f70 100644 --- a/TeachingRecordSystem/src/TeachingRecordSystem.AuthorizeAccess/Infrastructure/Security/MatchToTeachingRecordAuthenticationHandler.cs +++ b/TeachingRecordSystem/src/TeachingRecordSystem.AuthorizeAccess/Infrastructure/Security/MatchToTeachingRecordAuthenticationHandler.cs @@ -6,7 +6,7 @@ namespace TeachingRecordSystem.AuthorizeAccess.Infrastructure.Security; public class MatchToTeachingRecordAuthenticationHandler( - SignInJourneyHelper signInJourneyHelper, + SignInJourneyHelper helper, AuthorizeAccessLinkGenerator linkGenerator) : IAuthenticationHandler { private AuthenticationScheme? _scheme; @@ -16,7 +16,7 @@ public async Task AuthenticateAsync() { EnsureInitialized(); - var journeyInstance = await signInJourneyHelper.GetInstanceAsync(_context); + var journeyInstance = await helper.UserInstanceStateProvider.GetSignInJourneyInstanceAsync(_context); if (journeyInstance is null) { @@ -40,14 +40,15 @@ public async Task ChallengeAsync(AuthenticationProperties? properties) properties ??= new(); properties.RedirectUri ??= "/"; - var journeyInstance = await signInJourneyHelper.GetOrCreateInstanceAsync( + var journeyInstance = await helper.UserInstanceStateProvider.GetOrCreateSignInJourneyInstanceAsync( _context, - createState: () => new SignInJourneyState(properties.RedirectUri!, OneLoginDefaults.AuthenticationScheme), + createState: () => new SignInJourneyState(properties.RedirectUri!, properties), updateState: state => state.Reset()); - properties.RedirectUri = linkGenerator.Start(journeyInstance.InstanceId); - properties.Items.Add(FormFlowJourneySignInHandler.PropertyKeys.JourneyInstanceId, journeyInstance.InstanceId.Serialize()); - await _context!.ChallengeAsync(OneLoginDefaults.AuthenticationScheme, properties); + var delegatedProperties = new AuthenticationProperties(); + delegatedProperties.RedirectUri = linkGenerator.Start(journeyInstance.InstanceId); + delegatedProperties.Items.Add(FormFlowJourneySignInHandler.PropertyKeys.JourneyInstanceId, journeyInstance.InstanceId.Serialize()); + await _context!.ChallengeAsync(OneLoginDefaults.AuthenticationScheme, delegatedProperties); } public Task ForbidAsync(AuthenticationProperties? properties) diff --git a/TeachingRecordSystem/src/TeachingRecordSystem.AuthorizeAccess/Program.cs b/TeachingRecordSystem/src/TeachingRecordSystem.AuthorizeAccess/Program.cs index b9cf29e93..f86069065 100644 --- a/TeachingRecordSystem/src/TeachingRecordSystem.AuthorizeAccess/Program.cs +++ b/TeachingRecordSystem/src/TeachingRecordSystem.AuthorizeAccess/Program.cs @@ -70,17 +70,16 @@ .AddRazorPages(); builder.Services - .AddSingleton() + .AddTrsBaseServices() .AddTransient() .AddTransient() .AddTransient() .AddFormFlow(options => { - options.JourneyRegistry.RegisterJourney( - new JourneyDescriptor(SignInJourneyState.JourneyName, typeof(SignInJourneyState), requestDataKeys: [], appendUniqueKey: true)); + options.JourneyRegistry.RegisterJourney(SignInJourneyState.JourneyDescriptor); }) .AddSingleton() - .AddSingleton(); + .AddTransient(); var app = builder.Build(); @@ -128,3 +127,5 @@ app.MapControllers(); app.Run(); + +public partial class Program { } diff --git a/TeachingRecordSystem/src/TeachingRecordSystem.AuthorizeAccess/SignInJourneyHelper.cs b/TeachingRecordSystem/src/TeachingRecordSystem.AuthorizeAccess/SignInJourneyHelper.cs new file mode 100644 index 000000000..915eee349 --- /dev/null +++ b/TeachingRecordSystem/src/TeachingRecordSystem.AuthorizeAccess/SignInJourneyHelper.cs @@ -0,0 +1,84 @@ +using System.Security.Claims; +using System.Text.Json; +using GovUk.OneLogin.AspNetCore; +using Microsoft.AspNetCore.Authentication; +using Microsoft.EntityFrameworkCore; +using TeachingRecordSystem.AuthorizeAccess.Infrastructure.Security; +using TeachingRecordSystem.Core; +using TeachingRecordSystem.Core.DataStore.Postgres; +using TeachingRecordSystem.FormFlow.State; + +namespace TeachingRecordSystem.AuthorizeAccess; + +public class SignInJourneyHelper(TrsDbContext dbContext, IUserInstanceStateProvider userInstanceStateProvider, IClock clock) +{ + public IUserInstanceStateProvider UserInstanceStateProvider { get; } = userInstanceStateProvider; + + public async Task OnSignedInWithOneLogin(SignInJourneyState state, AuthenticationTicket ticket) + { + var subject = ticket.Principal.FindFirstValue("sub") ?? throw new InvalidOperationException("No sub claim."); + var email = ticket.Principal.FindFirstValue("email") ?? throw new InvalidOperationException("No email claim."); + var vc = ticket.Principal.FindFirstValue("vc") is string vcStr ? JsonDocument.Parse(vcStr) : null; + + state.Reset(); + state.OneLoginAuthenticationTicket = ticket; + + if (vc is not null) + { + state.VerifiedNames = ticket.Principal.GetCoreIdentityNames().Select(n => n.NameParts.Select(part => part.Value).ToArray()).ToArray(); + state.VerifiedDatesOfBirth = ticket.Principal.GetCoreIdentityBirthDates().Select(d => d.Value).ToArray(); + } + + var oneLoginUser = await dbContext.OneLoginUsers + .Include(o => o.Person) + .SingleOrDefaultAsync(o => o.Subject == subject); + + if (oneLoginUser is not null) + { + oneLoginUser.CoreIdentityVc = vc; + oneLoginUser.LastOneLoginSignIn = clock.UtcNow; + oneLoginUser.Email = email; + + if (oneLoginUser.Person is not null) + { + oneLoginUser.LastSignIn = clock.UtcNow; + + CreateAndAssignPrincipal(state, oneLoginUser.Person.PersonId, oneLoginUser.Person.Trn!); + } + } + else + { + oneLoginUser = new() + { + Subject = subject, + Email = email, + FirstOneLoginSignIn = clock.UtcNow, + LastOneLoginSignIn = clock.UtcNow, + CoreIdentityVc = vc + }; + dbContext.OneLoginUsers.Add(oneLoginUser); + } + + await dbContext.SaveChangesAsync(); + } + + private static void CreateAndAssignPrincipal(SignInJourneyState state, Guid personId, string trn) + { + if (state.OneLoginAuthenticationTicket is null) + { + throw new InvalidOperationException("User is not authenticated with One Login."); + } + + var oneLoginIdentity = (ClaimsIdentity)state.OneLoginAuthenticationTicket.Principal.Identity!; + + var teachingRecordIdentity = new ClaimsIdentity(new[] + { + new Claim(ClaimTypes.Trn, trn), + new Claim(ClaimTypes.PersonId, personId.ToString()) + }); + + var principal = new ClaimsPrincipal(new[] { oneLoginIdentity, teachingRecordIdentity }); + + state.AuthenticationTicket = new AuthenticationTicket(principal, state.AuthenticationProperties, AuthenticationSchemes.MatchToTeachingRecord); + } +} diff --git a/TeachingRecordSystem/src/TeachingRecordSystem.AuthorizeAccess/SignInJourneyState.cs b/TeachingRecordSystem/src/TeachingRecordSystem.AuthorizeAccess/SignInJourneyState.cs index cb8ee4217..b2d2cb18f 100644 --- a/TeachingRecordSystem/src/TeachingRecordSystem.AuthorizeAccess/SignInJourneyState.cs +++ b/TeachingRecordSystem/src/TeachingRecordSystem.AuthorizeAccess/SignInJourneyState.cs @@ -1,46 +1,64 @@ +using System.Text.Json; using System.Text.Json.Serialization; using Microsoft.AspNetCore.Authentication; +using TeachingRecordSystem.FormFlow; namespace TeachingRecordSystem.AuthorizeAccess; -public class SignInJourneyState +[method: JsonConstructor] +public class SignInJourneyState(string redirectUri, AuthenticationProperties? authenticationProperties) { public const string JourneyName = "SignInJourney"; - private readonly TicketSerializer _ticketSerializer = TicketSerializer.Default; + public static JourneyDescriptor JourneyDescriptor { get; } = + new JourneyDescriptor(JourneyName, typeof(SignInJourneyState), requestDataKeys: [], appendUniqueKey: true); - [JsonInclude] - private byte[]? _oneLoginAuthenticationTicket; + public string RedirectUri { get; } = redirectUri; - [JsonConstructor] - public SignInJourneyState(string redirectUri, string oneLoginAuthenticationScheme) - { - RedirectUri = redirectUri; - OneLoginAuthenticationScheme = oneLoginAuthenticationScheme; - } + [JsonConverter(typeof(AuthenticationTicketJsonConverter))] + public AuthenticationTicket? AuthenticationTicket { get; set; } - public AuthenticationTicket? AuthenticationTicket { get; private set; } + [JsonConverter(typeof(AuthenticationTicketJsonConverter))] + public AuthenticationTicket? OneLoginAuthenticationTicket { get; set; } - [JsonIgnore] - public AuthenticationTicket? OneLoginAuthenticationTicket => - _oneLoginAuthenticationTicket is not null ? _ticketSerializer.Deserialize(_oneLoginAuthenticationTicket) : null; + public string[][]? VerifiedNames { get; set; } - [JsonIgnore] - public bool AuthenticatedWithOneLogin => OneLoginAuthenticationTicket is not null; + public DateOnly[]? VerifiedDatesOfBirth { get; set; } - public string RedirectUri { get; } + public AuthenticationProperties? AuthenticationProperties { get; } = authenticationProperties; - public string OneLoginAuthenticationScheme { get; } + public void Reset() + { + AuthenticationTicket = null; + OneLoginAuthenticationTicket = null; + VerifiedNames = null; + VerifiedDatesOfBirth = null; + } +} - public void OnSignedInWithOneLogin(AuthenticationTicket ticket) +public class AuthenticationTicketJsonConverter : JsonConverter +{ + private readonly TicketSerializer _ticketSerializer = TicketSerializer.Default; + + public override AuthenticationTicket? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { - _oneLoginAuthenticationTicket = _ticketSerializer.Serialize(ticket); - // TODO Should we reset all other state here? + if (reader.TokenType == JsonTokenType.Null) + { + return null; + } + + if (reader.TokenType == JsonTokenType.String) + { + var bytes = reader.GetBytesFromBase64(); + return _ticketSerializer.Deserialize(bytes); + } + + throw new JsonException($"Unknown TokenType: '{reader.TokenType}'."); } - public void Reset() + public override void Write(Utf8JsonWriter writer, AuthenticationTicket value, JsonSerializerOptions options) { - AuthenticationTicket = null; - _oneLoginAuthenticationTicket = null; + var bytes = _ticketSerializer.Serialize(value); + writer.WriteBase64StringValue(bytes); } } diff --git a/TeachingRecordSystem/src/TeachingRecordSystem.Core/DataStore/Postgres/Mappings/OneLoginUserMapping.cs b/TeachingRecordSystem/src/TeachingRecordSystem.Core/DataStore/Postgres/Mappings/OneLoginUserMapping.cs new file mode 100644 index 000000000..8974948da --- /dev/null +++ b/TeachingRecordSystem/src/TeachingRecordSystem.Core/DataStore/Postgres/Mappings/OneLoginUserMapping.cs @@ -0,0 +1,16 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using TeachingRecordSystem.Core.DataStore.Postgres.Models; + +namespace TeachingRecordSystem.Core.DataStore.Postgres.Mappings; + +public class OneLoginUserMapping : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(o => o.Subject); + builder.Property(o => o.Subject).HasMaxLength(200); + builder.Property(o => o.Email).HasMaxLength(200); + builder.HasOne(o => o.Person).WithOne().HasForeignKey(o => o.PersonId); + } +} diff --git a/TeachingRecordSystem/src/TeachingRecordSystem.Core/DataStore/Postgres/Migrations/20240131130101_OneLoginUser.Designer.cs b/TeachingRecordSystem/src/TeachingRecordSystem.Core/DataStore/Postgres/Migrations/20240131130101_OneLoginUser.Designer.cs new file mode 100644 index 000000000..654b9129f --- /dev/null +++ b/TeachingRecordSystem/src/TeachingRecordSystem.Core/DataStore/Postgres/Migrations/20240131130101_OneLoginUser.Designer.cs @@ -0,0 +1,1087 @@ +// +using System; +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using TeachingRecordSystem.Core.DataStore.Postgres; + +#nullable disable + +namespace TeachingRecordSystem.Core.DataStore.Postgres.Migrations +{ + [DbContext(typeof(TrsDbContext))] + [Migration("20240131130101_OneLoginUser")] + partial class OneLoginUser + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("TeachingRecordSystem.Core.DataStore.Postgres.Models.ApiKey", b => + { + b.Property("ApiKeyId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("api_key_id"); + + b.Property("ApplicationUserId") + .HasColumnType("uuid") + .HasColumnName("application_user_id"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("Expires") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("key"); + + b.Property("UpdatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_on"); + + b.HasKey("ApiKeyId") + .HasName("pk_api_keys"); + + b.HasIndex("ApplicationUserId") + .HasDatabaseName("ix_api_keys_application_user_id"); + + b.HasIndex("Key") + .IsUnique() + .HasDatabaseName("ix_api_keys_key"); + + b.ToTable("api_keys", (string)null); + }); + + modelBuilder.Entity("TeachingRecordSystem.Core.DataStore.Postgres.Models.EntityChangesJournal", b => + { + b.Property("Key") + .HasColumnType("text") + .HasColumnName("key"); + + b.Property("EntityLogicalName") + .HasColumnType("text") + .HasColumnName("entity_logical_name"); + + b.Property("DataToken") + .HasColumnType("text") + .HasColumnName("data_token"); + + b.Property("LastUpdated") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_updated"); + + b.Property("LastUpdatedBy") + .HasColumnType("text") + .HasColumnName("last_updated_by"); + + b.Property("NextQueryPageNumber") + .HasColumnType("integer") + .HasColumnName("next_query_page_number"); + + b.Property("NextQueryPageSize") + .HasColumnType("integer") + .HasColumnName("next_query_page_size"); + + b.Property("NextQueryPagingCookie") + .HasColumnType("text") + .HasColumnName("next_query_paging_cookie"); + + b.HasKey("Key", "EntityLogicalName") + .HasName("pk_entity_changes_journals"); + + b.ToTable("entity_changes_journals", (string)null); + }); + + modelBuilder.Entity("TeachingRecordSystem.Core.DataStore.Postgres.Models.Event", b => + { + b.Property("EventId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("event_id"); + + b.Property("Created") + .HasColumnType("timestamp with time zone") + .HasColumnName("created"); + + b.Property("EventName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("event_name"); + + b.Property("Inserted") + .HasColumnType("timestamp with time zone") + .HasColumnName("inserted"); + + b.Property("Key") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("key"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("payload"); + + b.Property("Published") + .HasColumnType("boolean") + .HasColumnName("published"); + + b.HasKey("EventId") + .HasName("pk_events"); + + b.HasIndex("Key") + .IsUnique() + .HasDatabaseName("ix_events_key") + .HasFilter("key is not null"); + + b.HasIndex("Payload") + .HasDatabaseName("ix_events_payload"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Payload"), "gin"); + + b.ToTable("events", (string)null); + }); + + modelBuilder.Entity("TeachingRecordSystem.Core.DataStore.Postgres.Models.EytsAwardedEmailsJob", b => + { + b.Property("EytsAwardedEmailsJobId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("eyts_awarded_emails_job_id"); + + b.Property("AwardedToUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("awarded_to_utc"); + + b.Property("ExecutedUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("executed_utc"); + + b.HasKey("EytsAwardedEmailsJobId") + .HasName("pk_eyts_awarded_emails_jobs"); + + b.HasIndex("ExecutedUtc") + .HasDatabaseName("ix_eyts_awarded_emails_jobs_executed_utc"); + + b.ToTable("eyts_awarded_emails_jobs", (string)null); + }); + + modelBuilder.Entity("TeachingRecordSystem.Core.DataStore.Postgres.Models.EytsAwardedEmailsJobItem", b => + { + b.Property("EytsAwardedEmailsJobId") + .HasColumnType("uuid") + .HasColumnName("eyts_awarded_emails_job_id"); + + b.Property("PersonId") + .HasColumnType("uuid") + .HasColumnName("person_id"); + + b.Property("EmailAddress") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("email_address"); + + b.Property("EmailSent") + .HasColumnType("boolean") + .HasColumnName("email_sent"); + + b.Property("Personalization") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("personalization"); + + b.Property("Trn") + .IsRequired() + .HasMaxLength(7) + .HasColumnType("character(7)") + .HasColumnName("trn") + .IsFixedLength(); + + b.HasKey("EytsAwardedEmailsJobId", "PersonId") + .HasName("pk_eyts_awarded_emails_job_items"); + + b.HasIndex("Personalization") + .HasDatabaseName("ix_eyts_awarded_emails_job_items_personalization"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Personalization"), "gin"); + + b.ToTable("eyts_awarded_emails_job_items", (string)null); + }); + + modelBuilder.Entity("TeachingRecordSystem.Core.DataStore.Postgres.Models.InductionCompletedEmailsJob", b => + { + b.Property("InductionCompletedEmailsJobId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("induction_completed_emails_job_id"); + + b.Property("AwardedToUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("awarded_to_utc"); + + b.Property("ExecutedUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("executed_utc"); + + b.HasKey("InductionCompletedEmailsJobId") + .HasName("pk_induction_completed_emails_jobs"); + + b.HasIndex("ExecutedUtc") + .HasDatabaseName("ix_induction_completed_emails_jobs_executed_utc"); + + b.ToTable("induction_completed_emails_jobs", (string)null); + }); + + modelBuilder.Entity("TeachingRecordSystem.Core.DataStore.Postgres.Models.InductionCompletedEmailsJobItem", b => + { + b.Property("InductionCompletedEmailsJobId") + .HasColumnType("uuid") + .HasColumnName("induction_completed_emails_job_id"); + + b.Property("PersonId") + .HasColumnType("uuid") + .HasColumnName("person_id"); + + b.Property("EmailAddress") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("email_address"); + + b.Property("EmailSent") + .HasColumnType("boolean") + .HasColumnName("email_sent"); + + b.Property("Personalization") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("personalization"); + + b.Property("Trn") + .IsRequired() + .HasMaxLength(7) + .HasColumnType("character(7)") + .HasColumnName("trn") + .IsFixedLength(); + + b.HasKey("InductionCompletedEmailsJobId", "PersonId") + .HasName("pk_induction_completed_emails_job_items"); + + b.HasIndex("Personalization") + .HasDatabaseName("ix_induction_completed_emails_job_items_personalization"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Personalization"), "gin"); + + b.ToTable("induction_completed_emails_job_items", (string)null); + }); + + modelBuilder.Entity("TeachingRecordSystem.Core.DataStore.Postgres.Models.InternationalQtsAwardedEmailsJob", b => + { + b.Property("InternationalQtsAwardedEmailsJobId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("international_qts_awarded_emails_job_id"); + + b.Property("AwardedToUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("awarded_to_utc"); + + b.Property("ExecutedUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("executed_utc"); + + b.HasKey("InternationalQtsAwardedEmailsJobId") + .HasName("pk_international_qts_awarded_emails_jobs"); + + b.HasIndex("ExecutedUtc") + .HasDatabaseName("ix_international_qts_awarded_emails_jobs_executed_utc"); + + b.ToTable("international_qts_awarded_emails_jobs", (string)null); + }); + + modelBuilder.Entity("TeachingRecordSystem.Core.DataStore.Postgres.Models.InternationalQtsAwardedEmailsJobItem", b => + { + b.Property("InternationalQtsAwardedEmailsJobId") + .HasColumnType("uuid") + .HasColumnName("international_qts_awarded_emails_job_id"); + + b.Property("PersonId") + .HasColumnType("uuid") + .HasColumnName("person_id"); + + b.Property("EmailAddress") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("email_address"); + + b.Property("EmailSent") + .HasColumnType("boolean") + .HasColumnName("email_sent"); + + b.Property("Personalization") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("personalization"); + + b.Property("Trn") + .IsRequired() + .HasMaxLength(7) + .HasColumnType("character(7)") + .HasColumnName("trn") + .IsFixedLength(); + + b.HasKey("InternationalQtsAwardedEmailsJobId", "PersonId") + .HasName("pk_international_qts_awarded_emails_job_items"); + + b.HasIndex("Personalization") + .HasDatabaseName("ix_international_qts_awarded_emails_job_items_personalization"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Personalization"), "gin"); + + b.ToTable("international_qts_awarded_emails_job_items", (string)null); + }); + + modelBuilder.Entity("TeachingRecordSystem.Core.DataStore.Postgres.Models.JourneyState", b => + { + b.Property("InstanceId") + .HasMaxLength(300) + .HasColumnType("character varying(300)") + .HasColumnName("instance_id"); + + b.Property("Completed") + .HasColumnType("timestamp with time zone") + .HasColumnName("completed"); + + b.Property("Created") + .HasColumnType("timestamp with time zone") + .HasColumnName("created"); + + b.Property("State") + .IsRequired() + .HasColumnType("text") + .HasColumnName("state"); + + b.Property("Updated") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated"); + + b.Property("UserId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("user_id"); + + b.HasKey("InstanceId") + .HasName("pk_journey_states"); + + b.ToTable("journey_states", (string)null); + }); + + modelBuilder.Entity("TeachingRecordSystem.Core.DataStore.Postgres.Models.MandatoryQualificationProvider", b => + { + b.Property("MandatoryQualificationProviderId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("mandatory_qualification_provider_id"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.HasKey("MandatoryQualificationProviderId") + .HasName("pk_mandatory_qualification_providers"); + + b.ToTable("mandatory_qualification_providers", (string)null); + + b.HasData( + new + { + MandatoryQualificationProviderId = new Guid("e28ea41d-408d-4c89-90cc-8b9b04ac68f5"), + Name = "University of Birmingham" + }, + new + { + MandatoryQualificationProviderId = new Guid("89f9a1aa-3d68-4985-a4ce-403b6044c18c"), + Name = "University of Leeds" + }, + new + { + MandatoryQualificationProviderId = new Guid("aa5c300e-3b7c-456c-8183-3520b3d55dca"), + Name = "University of Manchester" + }, + new + { + MandatoryQualificationProviderId = new Guid("f417e73e-e2ad-40eb-85e3-55865be7f6be"), + Name = "Mary Hare School / University of Hertfordshire" + }, + new + { + MandatoryQualificationProviderId = new Guid("fbf22e04-b274-4c80-aba8-79fb6a7a32ce"), + Name = "University of Edinburgh" + }, + new + { + MandatoryQualificationProviderId = new Guid("26204149-349c-4ad6-9466-bb9b83723eae"), + Name = "Liverpool John Moores University" + }, + new + { + MandatoryQualificationProviderId = new Guid("0c30f666-647c-4ea8-8883-0fc6010b56be"), + Name = "University of Oxford/Oxford Polytechnic" + }, + new + { + MandatoryQualificationProviderId = new Guid("d0e6d54c-5e90-438a-945d-f97388c2b352"), + Name = "University of Cambridge" + }, + new + { + MandatoryQualificationProviderId = new Guid("aec32252-ef25-452e-a358-34a04e03369c"), + Name = "University of Newcastle-upon-Tyne" + }, + new + { + MandatoryQualificationProviderId = new Guid("d9ee7054-7fde-4cfd-9a5e-4b99511d1b3d"), + Name = "University of Plymouth" + }, + new + { + MandatoryQualificationProviderId = new Guid("707d58ca-1953-413b-9a46-41e9b0be885e"), + Name = "University of Hertfordshire" + }, + new + { + MandatoryQualificationProviderId = new Guid("3fc648a7-18e4-49e7-8a4b-1612616b72d5"), + Name = "University of London" + }, + new + { + MandatoryQualificationProviderId = new Guid("374dceb8-8224-45b8-b7dc-a6b0282b1065"), + Name = "Bristol Polytechnic" + }, + new + { + MandatoryQualificationProviderId = new Guid("d4fc958b-21de-47ec-9f03-36ae237a1b11"), + Name = "University College, Swansea" + }); + }); + + modelBuilder.Entity("TeachingRecordSystem.Core.DataStore.Postgres.Models.OneLoginUser", b => + { + b.Property("Subject") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("subject"); + + b.Property("CoreIdentityVc") + .HasColumnType("jsonb") + .HasColumnName("core_identity_vc"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("email"); + + b.Property("FirstOneLoginSignIn") + .HasColumnType("timestamp with time zone") + .HasColumnName("first_one_login_sign_in"); + + b.Property("FirstSignIn") + .HasColumnType("timestamp with time zone") + .HasColumnName("first_sign_in"); + + b.Property("LastOneLoginSignIn") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_one_login_sign_in"); + + b.Property("LastSignIn") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_sign_in"); + + b.Property("PersonId") + .HasColumnType("uuid") + .HasColumnName("person_id"); + + b.HasKey("Subject") + .HasName("pk_one_login_users"); + + b.ToTable("one_login_users", (string)null); + }); + + modelBuilder.Entity("TeachingRecordSystem.Core.DataStore.Postgres.Models.Person", b => + { + b.Property("PersonId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("person_id"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("DateOfBirth") + .HasColumnType("date") + .HasColumnName("date_of_birth"); + + b.Property("DeletedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_on"); + + b.Property("DqtContactId") + .HasColumnType("uuid") + .HasColumnName("dqt_contact_id"); + + b.Property("DqtCreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("dqt_created_on"); + + b.Property("DqtFirstName") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("dqt_first_name") + .UseCollation("case_insensitive"); + + b.Property("DqtFirstSync") + .HasColumnType("timestamp with time zone") + .HasColumnName("dqt_first_sync"); + + b.Property("DqtLastName") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("dqt_last_name") + .UseCollation("case_insensitive"); + + b.Property("DqtLastSync") + .HasColumnType("timestamp with time zone") + .HasColumnName("dqt_last_sync"); + + b.Property("DqtMiddleName") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("dqt_middle_name") + .UseCollation("case_insensitive"); + + b.Property("DqtModifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("dqt_modified_on"); + + b.Property("DqtState") + .HasColumnType("integer") + .HasColumnName("dqt_state"); + + b.Property("EmailAddress") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("email_address") + .UseCollation("case_insensitive"); + + b.Property("FirstName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("first_name") + .UseCollation("case_insensitive"); + + b.Property("LastName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("last_name") + .UseCollation("case_insensitive"); + + b.Property("MiddleName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("middle_name") + .UseCollation("case_insensitive"); + + b.Property("NationalInsuranceNumber") + .HasMaxLength(9) + .HasColumnType("character(9)") + .HasColumnName("national_insurance_number") + .IsFixedLength(); + + b.Property("Trn") + .HasMaxLength(7) + .HasColumnType("character(7)") + .HasColumnName("trn") + .IsFixedLength(); + + b.Property("UpdatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_on"); + + b.HasKey("PersonId") + .HasName("pk_persons"); + + b.HasIndex("DqtContactId") + .IsUnique() + .HasDatabaseName("ix_persons_dqt_contact_id") + .HasFilter("dqt_contact_id is not null"); + + b.HasIndex("Trn") + .IsUnique() + .HasDatabaseName("ix_persons_trn") + .HasFilter("trn is not null"); + + b.ToTable("persons", (string)null); + }); + + modelBuilder.Entity("TeachingRecordSystem.Core.DataStore.Postgres.Models.QtsAwardedEmailsJob", b => + { + b.Property("QtsAwardedEmailsJobId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("qts_awarded_emails_job_id"); + + b.Property("AwardedToUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("awarded_to_utc"); + + b.Property("ExecutedUtc") + .HasColumnType("timestamp with time zone") + .HasColumnName("executed_utc"); + + b.HasKey("QtsAwardedEmailsJobId") + .HasName("pk_qts_awarded_emails_jobs"); + + b.HasIndex("ExecutedUtc") + .HasDatabaseName("ix_qts_awarded_emails_jobs_executed_utc"); + + b.ToTable("qts_awarded_emails_jobs", (string)null); + }); + + modelBuilder.Entity("TeachingRecordSystem.Core.DataStore.Postgres.Models.QtsAwardedEmailsJobItem", b => + { + b.Property("QtsAwardedEmailsJobId") + .HasColumnType("uuid") + .HasColumnName("qts_awarded_emails_job_id"); + + b.Property("PersonId") + .HasColumnType("uuid") + .HasColumnName("person_id"); + + b.Property("EmailAddress") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("email_address"); + + b.Property("EmailSent") + .HasColumnType("boolean") + .HasColumnName("email_sent"); + + b.Property("Personalization") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("personalization"); + + b.Property("Trn") + .IsRequired() + .HasMaxLength(7) + .HasColumnType("character(7)") + .HasColumnName("trn") + .IsFixedLength(); + + b.HasKey("QtsAwardedEmailsJobId", "PersonId") + .HasName("pk_qts_awarded_emails_job_items"); + + b.HasIndex("Personalization") + .HasDatabaseName("ix_qts_awarded_emails_job_items_personalization"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("Personalization"), "gin"); + + b.ToTable("qts_awarded_emails_job_items", (string)null); + }); + + modelBuilder.Entity("TeachingRecordSystem.Core.DataStore.Postgres.Models.Qualification", b => + { + b.Property("QualificationId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("qualification_id"); + + b.Property("CreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_on"); + + b.Property("DeletedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_on"); + + b.Property("DqtCreatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("dqt_created_on"); + + b.Property("DqtFirstSync") + .HasColumnType("timestamp with time zone") + .HasColumnName("dqt_first_sync"); + + b.Property("DqtLastSync") + .HasColumnType("timestamp with time zone") + .HasColumnName("dqt_last_sync"); + + b.Property("DqtModifiedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("dqt_modified_on"); + + b.Property("DqtQualificationId") + .HasColumnType("uuid") + .HasColumnName("dqt_qualification_id"); + + b.Property("DqtState") + .HasColumnType("integer") + .HasColumnName("dqt_state"); + + b.Property("PersonId") + .HasColumnType("uuid") + .HasColumnName("person_id"); + + b.Property("QualificationType") + .HasColumnType("integer") + .HasColumnName("qualification_type"); + + b.Property("UpdatedOn") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_on"); + + b.HasKey("QualificationId") + .HasName("pk_qualifications"); + + b.HasIndex("DqtQualificationId") + .IsUnique() + .HasDatabaseName("ix_qualifications_dqt_qualification_id") + .HasFilter("dqt_qualification_id is not null"); + + b.HasIndex("PersonId") + .HasDatabaseName("ix_qualifications_person_id"); + + b.ToTable("qualifications", (string)null); + + b.HasDiscriminator("QualificationType"); + + b.UseTphMappingStrategy(); + }); + + modelBuilder.Entity("TeachingRecordSystem.Core.DataStore.Postgres.Models.TrnRequest", b => + { + b.Property("TrnRequestId") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("trn_request_id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("TrnRequestId")); + + b.Property("ClientId") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("client_id"); + + b.Property("IdentityUserId") + .HasColumnType("uuid") + .HasColumnName("identity_user_id"); + + b.Property("LinkedToIdentity") + .HasColumnType("boolean") + .HasColumnName("linked_to_identity"); + + b.Property("RequestId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("request_id"); + + b.Property("TeacherId") + .HasColumnType("uuid") + .HasColumnName("teacher_id"); + + b.Property("TrnToken") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("trn_token"); + + b.HasKey("TrnRequestId") + .HasName("pk_trn_requests"); + + b.HasIndex("ClientId", "RequestId") + .IsUnique() + .HasDatabaseName("ix_trn_requests_client_id_request_id"); + + b.ToTable("trn_requests", (string)null); + }); + + modelBuilder.Entity("TeachingRecordSystem.Core.DataStore.Postgres.Models.UserBase", b => + { + b.Property("UserId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("Active") + .HasColumnType("boolean") + .HasColumnName("active"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("name"); + + b.Property("UserType") + .HasColumnType("integer") + .HasColumnName("user_type"); + + b.HasKey("UserId") + .HasName("pk_users"); + + b.ToTable("users", (string)null); + + b.HasDiscriminator("UserType"); + + b.UseTphMappingStrategy(); + }); + + modelBuilder.Entity("TeachingRecordSystem.Core.DataStore.Postgres.Models.MandatoryQualification", b => + { + b.HasBaseType("TeachingRecordSystem.Core.DataStore.Postgres.Models.Qualification"); + + b.Property("DqtMqEstablishmentId") + .HasColumnType("uuid") + .HasColumnName("dqt_mq_establishment_id"); + + b.Property("DqtSpecialismId") + .HasColumnType("uuid") + .HasColumnName("dqt_specialism_id"); + + b.Property("EndDate") + .HasColumnType("date") + .HasColumnName("end_date"); + + b.Property("ProviderId") + .HasColumnType("uuid") + .HasColumnName("mq_provider_id"); + + b.Property("Specialism") + .HasColumnType("integer") + .HasColumnName("mq_specialism"); + + b.Property("StartDate") + .HasColumnType("date") + .HasColumnName("start_date"); + + b.Property("Status") + .HasColumnType("integer") + .HasColumnName("mq_status"); + + b.HasDiscriminator().HasValue(0); + }); + + modelBuilder.Entity("TeachingRecordSystem.Core.DataStore.Postgres.Models.ApplicationUser", b => + { + b.HasBaseType("TeachingRecordSystem.Core.DataStore.Postgres.Models.UserBase"); + + b.Property("ApiRoles") + .IsRequired() + .HasColumnType("varchar[]") + .HasColumnName("api_roles"); + + b.Property("OneLoginClientId") + .HasMaxLength(50) + .HasColumnType("character varying(50)") + .HasColumnName("one_login_client_id"); + + b.Property("OneLoginPrivateKeyPem") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)") + .HasColumnName("one_login_private_key_pem"); + + b.HasDiscriminator().HasValue(2); + }); + + modelBuilder.Entity("TeachingRecordSystem.Core.DataStore.Postgres.Models.SystemUser", b => + { + b.HasBaseType("TeachingRecordSystem.Core.DataStore.Postgres.Models.UserBase"); + + b.HasDiscriminator().HasValue(3); + + b.HasData( + new + { + UserId = new Guid("a81394d1-a498-46d8-af3e-e077596ab303"), + Active = true, + Name = "System", + UserType = 0 + }); + }); + + modelBuilder.Entity("TeachingRecordSystem.Core.DataStore.Postgres.Models.User", b => + { + b.HasBaseType("TeachingRecordSystem.Core.DataStore.Postgres.Models.UserBase"); + + b.Property("AzureAdUserId") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("azure_ad_user_id"); + + b.Property("DqtUserId") + .HasColumnType("uuid") + .HasColumnName("dqt_user_id"); + + b.Property("Email") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("email") + .UseCollation("case_insensitive"); + + b.Property("Roles") + .IsRequired() + .HasColumnType("varchar[]") + .HasColumnName("roles"); + + b.HasIndex("AzureAdUserId") + .IsUnique() + .HasDatabaseName("ix_users_azure_ad_user_id"); + + b.HasDiscriminator().HasValue(1); + }); + + modelBuilder.Entity("TeachingRecordSystem.Core.DataStore.Postgres.Models.ApiKey", b => + { + b.HasOne("TeachingRecordSystem.Core.DataStore.Postgres.Models.ApplicationUser", "ApplicationUser") + .WithMany("ApiKeys") + .HasForeignKey("ApplicationUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_api_key_application_user"); + + b.Navigation("ApplicationUser"); + }); + + modelBuilder.Entity("TeachingRecordSystem.Core.DataStore.Postgres.Models.EytsAwardedEmailsJobItem", b => + { + b.HasOne("TeachingRecordSystem.Core.DataStore.Postgres.Models.EytsAwardedEmailsJob", "EytsAwardedEmailsJob") + .WithMany("JobItems") + .HasForeignKey("EytsAwardedEmailsJobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_eyts_awarded_emails_job_items_eyts_awarded_emails_jobs_eyts"); + + b.Navigation("EytsAwardedEmailsJob"); + }); + + modelBuilder.Entity("TeachingRecordSystem.Core.DataStore.Postgres.Models.InductionCompletedEmailsJobItem", b => + { + b.HasOne("TeachingRecordSystem.Core.DataStore.Postgres.Models.InductionCompletedEmailsJob", "InductionCompletedEmailsJob") + .WithMany("JobItems") + .HasForeignKey("InductionCompletedEmailsJobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_induction_completed_emails_job_items_induction_completed_em"); + + b.Navigation("InductionCompletedEmailsJob"); + }); + + modelBuilder.Entity("TeachingRecordSystem.Core.DataStore.Postgres.Models.InternationalQtsAwardedEmailsJobItem", b => + { + b.HasOne("TeachingRecordSystem.Core.DataStore.Postgres.Models.InternationalQtsAwardedEmailsJob", "InternationalQtsAwardedEmailsJob") + .WithMany("JobItems") + .HasForeignKey("InternationalQtsAwardedEmailsJobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_international_qts_awarded_emails_job_items_international_qt"); + + b.Navigation("InternationalQtsAwardedEmailsJob"); + }); + + modelBuilder.Entity("TeachingRecordSystem.Core.DataStore.Postgres.Models.OneLoginUser", b => + { + b.HasOne("TeachingRecordSystem.Core.DataStore.Postgres.Models.Person", "Person") + .WithOne() + .HasForeignKey("TeachingRecordSystem.Core.DataStore.Postgres.Models.OneLoginUser", "PersonId") + .HasConstraintName("fk_one_login_users_persons_person_id"); + + b.Navigation("Person"); + }); + + modelBuilder.Entity("TeachingRecordSystem.Core.DataStore.Postgres.Models.QtsAwardedEmailsJobItem", b => + { + b.HasOne("TeachingRecordSystem.Core.DataStore.Postgres.Models.QtsAwardedEmailsJob", "QtsAwardedEmailsJob") + .WithMany("JobItems") + .HasForeignKey("QtsAwardedEmailsJobId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_qts_awarded_emails_job_items_qts_awarded_emails_jobs_qts_aw"); + + b.Navigation("QtsAwardedEmailsJob"); + }); + + modelBuilder.Entity("TeachingRecordSystem.Core.DataStore.Postgres.Models.Qualification", b => + { + b.HasOne("TeachingRecordSystem.Core.DataStore.Postgres.Models.Person", null) + .WithMany() + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_qualifications_person"); + }); + + modelBuilder.Entity("TeachingRecordSystem.Core.DataStore.Postgres.Models.MandatoryQualification", b => + { + b.HasOne("TeachingRecordSystem.Core.DataStore.Postgres.Models.MandatoryQualificationProvider", null) + .WithMany() + .HasForeignKey("ProviderId") + .HasConstraintName("fk_qualifications_mandatory_qualification_provider"); + }); + + modelBuilder.Entity("TeachingRecordSystem.Core.DataStore.Postgres.Models.EytsAwardedEmailsJob", b => + { + b.Navigation("JobItems"); + }); + + modelBuilder.Entity("TeachingRecordSystem.Core.DataStore.Postgres.Models.InductionCompletedEmailsJob", b => + { + b.Navigation("JobItems"); + }); + + modelBuilder.Entity("TeachingRecordSystem.Core.DataStore.Postgres.Models.InternationalQtsAwardedEmailsJob", b => + { + b.Navigation("JobItems"); + }); + + modelBuilder.Entity("TeachingRecordSystem.Core.DataStore.Postgres.Models.QtsAwardedEmailsJob", b => + { + b.Navigation("JobItems"); + }); + + modelBuilder.Entity("TeachingRecordSystem.Core.DataStore.Postgres.Models.ApplicationUser", b => + { + b.Navigation("ApiKeys"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/TeachingRecordSystem/src/TeachingRecordSystem.Core/DataStore/Postgres/Migrations/20240131130101_OneLoginUser.cs b/TeachingRecordSystem/src/TeachingRecordSystem.Core/DataStore/Postgres/Migrations/20240131130101_OneLoginUser.cs new file mode 100644 index 000000000..d94fa19d1 --- /dev/null +++ b/TeachingRecordSystem/src/TeachingRecordSystem.Core/DataStore/Postgres/Migrations/20240131130101_OneLoginUser.cs @@ -0,0 +1,46 @@ +using System; +using System.Text.Json; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace TeachingRecordSystem.Core.DataStore.Postgres.Migrations +{ + /// + public partial class OneLoginUser : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "one_login_users", + columns: table => new + { + subject = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + email = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + core_identity_vc = table.Column(type: "jsonb", nullable: true), + first_one_login_sign_in = table.Column(type: "timestamp with time zone", nullable: false), + last_one_login_sign_in = table.Column(type: "timestamp with time zone", nullable: false), + first_sign_in = table.Column(type: "timestamp with time zone", nullable: true), + last_sign_in = table.Column(type: "timestamp with time zone", nullable: true), + person_id = table.Column(type: "uuid", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_one_login_users", x => x.subject); + table.ForeignKey( + name: "fk_one_login_users_persons_person_id", + column: x => x.person_id, + principalTable: "persons", + principalColumn: "person_id"); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "one_login_users"); + } + } +} diff --git a/TeachingRecordSystem/src/TeachingRecordSystem.Core/DataStore/Postgres/Migrations/TrsDbContextModelSnapshot.cs b/TeachingRecordSystem/src/TeachingRecordSystem.Core/DataStore/Postgres/Migrations/TrsDbContextModelSnapshot.cs index 6d879e504..c699d5e56 100644 --- a/TeachingRecordSystem/src/TeachingRecordSystem.Core/DataStore/Postgres/Migrations/TrsDbContextModelSnapshot.cs +++ b/TeachingRecordSystem/src/TeachingRecordSystem.Core/DataStore/Postgres/Migrations/TrsDbContextModelSnapshot.cs @@ -1,5 +1,6 @@ // using System; +using System.Text.Json; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; @@ -483,6 +484,49 @@ protected override void BuildModel(ModelBuilder modelBuilder) }); }); + modelBuilder.Entity("TeachingRecordSystem.Core.DataStore.Postgres.Models.OneLoginUser", b => + { + b.Property("Subject") + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("subject"); + + b.Property("CoreIdentityVc") + .HasColumnType("jsonb") + .HasColumnName("core_identity_vc"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("email"); + + b.Property("FirstOneLoginSignIn") + .HasColumnType("timestamp with time zone") + .HasColumnName("first_one_login_sign_in"); + + b.Property("FirstSignIn") + .HasColumnType("timestamp with time zone") + .HasColumnName("first_sign_in"); + + b.Property("LastOneLoginSignIn") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_one_login_sign_in"); + + b.Property("LastSignIn") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_sign_in"); + + b.Property("PersonId") + .HasColumnType("uuid") + .HasColumnName("person_id"); + + b.HasKey("Subject") + .HasName("pk_one_login_users"); + + b.ToTable("one_login_users", (string)null); + }); + modelBuilder.Entity("TeachingRecordSystem.Core.DataStore.Postgres.Models.Person", b => { b.Property("PersonId") @@ -970,6 +1014,16 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("InternationalQtsAwardedEmailsJob"); }); + modelBuilder.Entity("TeachingRecordSystem.Core.DataStore.Postgres.Models.OneLoginUser", b => + { + b.HasOne("TeachingRecordSystem.Core.DataStore.Postgres.Models.Person", "Person") + .WithOne() + .HasForeignKey("TeachingRecordSystem.Core.DataStore.Postgres.Models.OneLoginUser", "PersonId") + .HasConstraintName("fk_one_login_users_persons_person_id"); + + b.Navigation("Person"); + }); + modelBuilder.Entity("TeachingRecordSystem.Core.DataStore.Postgres.Models.QtsAwardedEmailsJobItem", b => { b.HasOne("TeachingRecordSystem.Core.DataStore.Postgres.Models.QtsAwardedEmailsJob", "QtsAwardedEmailsJob") diff --git a/TeachingRecordSystem/src/TeachingRecordSystem.Core/DataStore/Postgres/Models/OneLoginUser.cs b/TeachingRecordSystem/src/TeachingRecordSystem.Core/DataStore/Postgres/Models/OneLoginUser.cs new file mode 100644 index 000000000..9b8924676 --- /dev/null +++ b/TeachingRecordSystem/src/TeachingRecordSystem.Core/DataStore/Postgres/Models/OneLoginUser.cs @@ -0,0 +1,16 @@ +using System.Text.Json; + +namespace TeachingRecordSystem.Core.DataStore.Postgres.Models; + +public class OneLoginUser +{ + public required string Subject { get; init; } + public required string Email { get; set; } + public required JsonDocument? CoreIdentityVc { get; set; } + public required DateTime FirstOneLoginSignIn { get; init; } + public required DateTime LastOneLoginSignIn { get; set; } + public DateTime? FirstSignIn { get; init; } + public DateTime? LastSignIn { get; set; } + public Guid? PersonId { get; set; } + public Person? Person { get; } +} diff --git a/TeachingRecordSystem/src/TeachingRecordSystem.Core/DataStore/Postgres/TrsDbContext.cs b/TeachingRecordSystem/src/TeachingRecordSystem.Core/DataStore/Postgres/TrsDbContext.cs index 404d7e874..e29a9d764 100644 --- a/TeachingRecordSystem/src/TeachingRecordSystem.Core/DataStore/Postgres/TrsDbContext.cs +++ b/TeachingRecordSystem/src/TeachingRecordSystem.Core/DataStore/Postgres/TrsDbContext.cs @@ -53,6 +53,8 @@ public static TrsDbContext Create(string connectionString, int? commandTimeout = public DbSet ApiKeys => Set(); + public DbSet OneLoginUsers => Set(); + public static void ConfigureOptions(DbContextOptionsBuilder optionsBuilder, string connectionString, int? commandTimeout = null) { Action configureOptions = o => o.CommandTimeout(commandTimeout); diff --git a/TeachingRecordSystem/src/TeachingRecordSystem.FormFlow/JourneyInstance.cs b/TeachingRecordSystem/src/TeachingRecordSystem.FormFlow/JourneyInstance.cs index 1cb69da0a..642bf2cb6 100644 --- a/TeachingRecordSystem/src/TeachingRecordSystem.FormFlow/JourneyInstance.cs +++ b/TeachingRecordSystem/src/TeachingRecordSystem.FormFlow/JourneyInstance.cs @@ -139,6 +139,12 @@ public async Task UpdateStateAsync(Action update) await UpdateStateAsync(State); } + public async Task UpdateStateAsync(Func update) + { + await update(State); + await UpdateStateAsync(State); + } + public async Task UpdateStateAsync(Func update) { var newState = update(State); diff --git a/TeachingRecordSystem/tests/TeachingRecordSystem.AuthorizeAccess.Tests/HostFixture.cs b/TeachingRecordSystem/tests/TeachingRecordSystem.AuthorizeAccess.Tests/HostFixture.cs new file mode 100644 index 000000000..9b65b7a8a --- /dev/null +++ b/TeachingRecordSystem/tests/TeachingRecordSystem.AuthorizeAccess.Tests/HostFixture.cs @@ -0,0 +1,121 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ApplicationModels; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.EntityFrameworkCore.Infrastructure; +using TeachingRecordSystem.AuthorizeAccess.Tests.Infrastructure.FormFlow; +using TeachingRecordSystem.Core; +using TeachingRecordSystem.Core.DataStore.Postgres; +using TeachingRecordSystem.Core.Events; +using TeachingRecordSystem.Core.Events.Processing; +using TeachingRecordSystem.Core.Services.TrsDataSync; +using TeachingRecordSystem.FormFlow.State; +using TeachingRecordSystem.TestCommon; +using TeachingRecordSystem.TestCommon.Infrastructure; + +namespace TeachingRecordSystem.AuthorizeAccess.Tests; + +public class HostFixture : WebApplicationFactory +{ + private readonly IConfiguration _configuration; + + public HostFixture(IConfiguration configuration) + { + _configuration = configuration; + _ = base.Services; // Start the host + } + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.UseEnvironment("Testing"); + + // N.B. Don't use builder.ConfigureAppConfiguration here since it runs *after* the entry point + // i.e. Program.cs and that has a dependency on IConfiguration + builder.UseConfiguration(_configuration); + + builder.ConfigureServices((context, services) => + { + DbHelper.ConfigureDbServices(services, context.Configuration.GetRequiredConnectionString("DefaultConnection")); + + // Remove the built-in antiforgery filters + // (we want to be able to POST directly from a test without having to set antiforgery cookies etc.) + services.AddSingleton(); + + // Publish events synchronously + services.AddSingleton(); + services.Decorate>((inner, sp) => + { + var coreOptionsExtension = inner.GetExtension(); + + return (DbContextOptions)inner.WithExtension( + coreOptionsExtension.WithInterceptors(new IInterceptor[] + { + sp.GetRequiredService(), + })); + }); + + services.AddSingleton(_ => new ForwardToTestScopedEventObserver()); + services.AddTestScoped(tss => tss.Clock); + services.AddSingleton( + sp => ActivatorUtilities.CreateInstance( + sp, + (IClock)new ForwardToTestScopedClock(), + TestDataSyncConfiguration.Sync(sp.GetRequiredService()))); + services.AddFakeXrm(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + }); + } + + protected override IHost CreateHost(IHostBuilder builder) + { + // Ensure we can flow AsyncLocals from tests to the server + builder.ConfigureServices(services => services.Configure(o => o.PreserveExecutionContext = true)); + + return base.CreateHost(builder); + } + + private class RemoveAutoValidateAntiforgeryPageApplicationModelProvider : IPageApplicationModelProvider + { + public int Order => int.MaxValue; + + public void OnProvidersExecuted(PageApplicationModelProviderContext context) + { + } + + public void OnProvidersExecuting(PageApplicationModelProviderContext context) + { + var pageApplicationModel = context.PageApplicationModel; + + var autoValidateAttribute = pageApplicationModel.Filters.OfType().SingleOrDefault(); + if (autoValidateAttribute is not null) + { + pageApplicationModel.Filters.Remove(autoValidateAttribute); + } + } + } + + // IEventObserver needs to be a singleton but we want it to resolve to a test-scoped CaptureEventObserver. + // This provides a wrapper that can be registered as a singleon that delegates to the test-scoped IEventObserver instance. + private class ForwardToTestScopedEventObserver : IEventObserver + { + public Task OnEventSaved(EventBase @event) => TestScopedServices.GetCurrent().EventObserver.OnEventSaved(@event); + } + + private class ForwardToTestScopedClock : IClock + { + public DateTime UtcNow => TestScopedServices.GetCurrent().Clock.UtcNow; + } +} + +file static class ServiceCollectionExtensions +{ + public static IServiceCollection AddTestScoped(this IServiceCollection services, Func resolveService) + where T : class + { + return services.AddTransient(_ => resolveService(TestScopedServices.GetCurrent())); + } +} diff --git a/TeachingRecordSystem/tests/TeachingRecordSystem.AuthorizeAccess.Tests/Infrastructure/FormFlow/InMemoryInstanceStateProvider.cs b/TeachingRecordSystem/tests/TeachingRecordSystem.AuthorizeAccess.Tests/Infrastructure/FormFlow/InMemoryInstanceStateProvider.cs new file mode 100644 index 000000000..f065c5b50 --- /dev/null +++ b/TeachingRecordSystem/tests/TeachingRecordSystem.AuthorizeAccess.Tests/Infrastructure/FormFlow/InMemoryInstanceStateProvider.cs @@ -0,0 +1,76 @@ +using TeachingRecordSystem.FormFlow; +using TeachingRecordSystem.FormFlow.State; + +namespace TeachingRecordSystem.AuthorizeAccess.Tests.Infrastructure.FormFlow; + +public class InMemoryInstanceStateProvider : IUserInstanceStateProvider +{ + private readonly Dictionary _instances; + + public InMemoryInstanceStateProvider() + { + _instances = new(); + } + + public void Clear() => _instances.Clear(); + + public Task CreateInstanceAsync( + JourneyInstanceId instanceId, + Type stateType, + object state, + IReadOnlyDictionary? properties) + { + _instances.Add(instanceId, new Entry() + { + StateType = stateType, + State = state, + Properties = properties + }); + + var instance = JourneyInstance.Create( + this, + instanceId, + stateType, + state, + properties ?? PropertiesBuilder.CreateEmpty()); + + return Task.FromResult(instance); + } + + public Task CompleteInstanceAsync(JourneyInstanceId instanceId, Type stateType) + { + _instances[instanceId].Completed = true; + return Task.CompletedTask; + } + + public Task DeleteInstanceAsync(JourneyInstanceId instanceId, Type stateType) + { + _instances.Remove(instanceId); + return Task.CompletedTask; + } + + public Task GetInstanceAsync(JourneyInstanceId instanceId, Type stateType) + { + _instances.TryGetValue(instanceId, out var entry); + + var instance = entry != null ? + JourneyInstance.Create(this, instanceId, entry.StateType!, entry.State!, entry.Properties!, entry.Completed) : + null; + + return Task.FromResult(instance); + } + + public Task UpdateInstanceStateAsync(JourneyInstanceId instanceId, Type stateType, object state) + { + _instances[instanceId].State = state; + return Task.CompletedTask; + } + + private class Entry + { + public IReadOnlyDictionary? Properties { get; set; } + public object? State { get; set; } + public Type? StateType { get; set; } + public bool Completed { get; set; } + } +} diff --git a/TeachingRecordSystem/tests/TeachingRecordSystem.AuthorizeAccess.Tests/SignInJourneyHelperTests.cs b/TeachingRecordSystem/tests/TeachingRecordSystem.AuthorizeAccess.Tests/SignInJourneyHelperTests.cs new file mode 100644 index 000000000..bf28472d2 --- /dev/null +++ b/TeachingRecordSystem/tests/TeachingRecordSystem.AuthorizeAccess.Tests/SignInJourneyHelperTests.cs @@ -0,0 +1,211 @@ +using System.Security.Claims; +using System.Text.Json; +using Microsoft.AspNetCore.Authentication; +using Microsoft.EntityFrameworkCore; +using TeachingRecordSystem.AuthorizeAccess.Tests.Infrastructure.FormFlow; +using TeachingRecordSystem.Core.DataStore.Postgres.Models; +using TeachingRecordSystem.TestCommon; + +namespace TeachingRecordSystem.AuthorizeAccess.Tests; + +public class SignInJourneyHelperTests(HostFixture hostFixture) : TestBase(hostFixture) +{ + [Fact] + public Task OnSignedInWithOneLogin_WithCoreIdentityVc_SetsOneLoginAuthenticationTicketAndVerifiedPropertiesOnState() => + WithDbContext(async dbContext => + { + // Arrange + var userInstanceStateProvider = new InMemoryInstanceStateProvider(); + var clock = new TestableClock(); + var helper = new SignInJourneyHelper(dbContext, userInstanceStateProvider, clock); + + var state = new SignInJourneyState(redirectUri: "/", authenticationProperties: null); + + var firstName = Faker.Name.First(); + var lastName = Faker.Name.Last(); + var dateOfBirth = DateOnly.FromDateTime(Faker.Identification.DateOfBirth()); + var ticket = CreateOneLoginAuthenticationTicket(firstName: firstName, lastName: lastName, dateOfBirth: dateOfBirth); + + // Act + await helper.OnSignedInWithOneLogin(state, ticket); + + // Assert + Assert.NotNull(state.OneLoginAuthenticationTicket); + Assert.NotNull(state.VerifiedNames); + Assert.Collection(state.VerifiedNames, nameParts => Assert.Collection(nameParts, name => Assert.Equal(firstName, name), name => Assert.Equal(lastName, name))); + Assert.NotNull(state.VerifiedDatesOfBirth); + Assert.Collection(state.VerifiedDatesOfBirth, dob => Assert.Equal(dateOfBirth, dob)); + }); + + [Fact] + public Task OnSignedInWithOneLogin_WithoutCoreIdentityVc_SetsOneLoginAuthenticationTicketOnState() => + WithDbContext(async dbContext => + { + // Arrange + var userInstanceStateProvider = new InMemoryInstanceStateProvider(); + var clock = new TestableClock(); + var helper = new SignInJourneyHelper(dbContext, userInstanceStateProvider, clock); + + var state = new SignInJourneyState(redirectUri: "/", authenticationProperties: null); + + var ticket = CreateOneLoginAuthenticationTicket(createCoreIdentityVc: false); + + // Act + await helper.OnSignedInWithOneLogin(state, ticket); + + // Assert + Assert.NotNull(state.OneLoginAuthenticationTicket); + Assert.Null(state.VerifiedNames); + Assert.Null(state.VerifiedDatesOfBirth); + }); + + [Fact] + public Task OnSignedInWithOneLogin_OneLoginUserAlreadyExistsWithKnownPerson_UpdatesLastSignInAndSetsAuthenticationTicketOnState() => + WithDbContext(async dbContext => + { + // Arrange + var userInstanceStateProvider = new InMemoryInstanceStateProvider(); + var clock = new TestableClock(); + var helper = new SignInJourneyHelper(dbContext, userInstanceStateProvider, clock); + + var person = await TestData.CreatePerson(b => b.WithTrn(true)); + var user = await TestData.CreateOneLoginUser(personId: person.PersonId); + clock.Advance(); + + var state = new SignInJourneyState(redirectUri: "/", authenticationProperties: null); + + var ticket = CreateOneLoginAuthenticationTicket(user); + + // Act + await helper.OnSignedInWithOneLogin(state, ticket); + + // Assert + user = await dbContext.OneLoginUsers.SingleAsync(u => u.Subject == user.Subject); + Assert.Equal(clock.UtcNow, user.LastOneLoginSignIn); + Assert.Equal(clock.UtcNow, user.LastSignIn); + Assert.NotNull(state.AuthenticationTicket); + Assert.Equal(person.Trn, state.AuthenticationTicket.Principal.FindFirstValue(ClaimTypes.Trn)); + Assert.Equal(person.PersonId.ToString(), state.AuthenticationTicket.Principal.FindFirstValue(ClaimTypes.PersonId)); + }); + + [Fact] + public Task OnSignedInWithOneLogin_OneLoginUserAlreadyExistsButNotKnownPerson_UpdatesOneLoginLastSignInButDoesNotSetAuthenticationTicketOnState() => + WithDbContext(async dbContext => + { + // Arrange + var userInstanceStateProvider = new InMemoryInstanceStateProvider(); + var clock = new TestableClock(); + var helper = new SignInJourneyHelper(dbContext, userInstanceStateProvider, clock); + + var user = await TestData.CreateOneLoginUser(personId: null); + clock.Advance(); + + var state = new SignInJourneyState(redirectUri: "/", authenticationProperties: null); + + var ticket = CreateOneLoginAuthenticationTicket(user); + + // Act + await helper.OnSignedInWithOneLogin(state, ticket); + + // Assert + user = await dbContext.OneLoginUsers.SingleAsync(u => u.Subject == user.Subject); + Assert.Equal(clock.UtcNow, user.LastOneLoginSignIn); + Assert.Null(user.FirstSignIn); + Assert.Null(user.LastSignIn); + Assert.Null(state.AuthenticationTicket); + }); + + [Fact] + public Task OnSignedInWithOneLogin_UserDoesNotExist_CreatesUserInDbButDoesNotAssignAuthenticationTicketOnState() => + WithDbContext(async dbContext => + { + // Arrange + var userInstanceStateProvider = new InMemoryInstanceStateProvider(); + var clock = new TestableClock(); + var helper = new SignInJourneyHelper(dbContext, userInstanceStateProvider, clock); + + var state = new SignInJourneyState(redirectUri: "/", authenticationProperties: null); + + var subject = Faker.Internet.UserName(); + var email = Faker.Internet.Email(); + var firstName = Faker.Name.First(); + var lastName = Faker.Name.Last(); + var dateOfBirth = DateOnly.FromDateTime(Faker.Identification.DateOfBirth()); + var ticket = CreateOneLoginAuthenticationTicket(sub: subject, email, firstName, lastName, dateOfBirth); + + // Act + await helper.OnSignedInWithOneLogin(state, ticket); + + // Assert + var user = await dbContext.OneLoginUsers.SingleOrDefaultAsync(u => u.Subject == subject); + Assert.NotNull(user); + Assert.Equal(email, user.Email); + Assert.Equal(clock.UtcNow, user.FirstOneLoginSignIn); + Assert.Equal(clock.UtcNow, user.LastOneLoginSignIn); + Assert.Null(user.FirstSignIn); + Assert.Null(user.LastSignIn); + Assert.NotNull(user.CoreIdentityVc); + + Assert.Null(state.AuthenticationTicket); + }); + + private AuthenticationTicket CreateOneLoginAuthenticationTicket(OneLoginUser user) + { + bool createCoreIdentityVc = false; + string? firstName = null; + string? lastName = null; + DateOnly? dateOfBirth = null; + + if (user.CoreIdentityVc is JsonDocument vc) + { + var credentialSubject = vc.RootElement.GetProperty("credentialSubject"); + var nameParts = credentialSubject.GetProperty("name").EnumerateArray().Single().GetProperty("nameParts").EnumerateArray(); + firstName = nameParts.First().GetProperty("value").GetString(); + lastName = nameParts.Last().GetProperty("value").GetString(); + dateOfBirth = DateOnly.FromDateTime(credentialSubject.GetProperty("birthDate").EnumerateArray().Single().GetProperty("value").GetDateTime()); + createCoreIdentityVc = true; + } + + return CreateOneLoginAuthenticationTicket( + user.Subject, + user.Email, + firstName, + lastName, + dateOfBirth, + createCoreIdentityVc); + } + + private AuthenticationTicket CreateOneLoginAuthenticationTicket( + string? sub = null, + string? email = null, + string? firstName = null, + string? lastName = null, + DateOnly? dateOfBirth = null, + bool createCoreIdentityVc = true) + { + sub ??= Faker.Internet.UserName(); + email ??= Faker.Internet.Email(); + + var claims = new List() + { + new("sub", sub), + new("email", email) + }; + + if (createCoreIdentityVc) + { + firstName ??= Faker.Name.First(); + lastName ??= Faker.Name.Last(); + dateOfBirth ??= DateOnly.FromDateTime(Faker.Identification.DateOfBirth()); + + var vc = TestData.CreateOneLoginCoreIdentityVc(firstName, lastName, dateOfBirth.Value); + claims.Add(new Claim("vc", vc.RootElement.ToString(), "JSON")); + } + + var identity = new ClaimsIdentity(claims, authenticationType: "OneLogin", nameType: "sub", roleType: null); + + var principal = new ClaimsPrincipal(identity); + + return new AuthenticationTicket(principal, authenticationScheme: "OneLogin"); + } +} diff --git a/TeachingRecordSystem/tests/TeachingRecordSystem.AuthorizeAccess.Tests/Startup.cs b/TeachingRecordSystem/tests/TeachingRecordSystem.AuthorizeAccess.Tests/Startup.cs new file mode 100644 index 000000000..5d2b1962b --- /dev/null +++ b/TeachingRecordSystem/tests/TeachingRecordSystem.AuthorizeAccess.Tests/Startup.cs @@ -0,0 +1,14 @@ +namespace TeachingRecordSystem.AuthorizeAccess.Tests; + +public class Startup +{ + public void ConfigureHost(IHostBuilder hostBuilder) => + hostBuilder + .ConfigureHostConfiguration(builder => builder + .AddUserSecrets(optional: true) + .AddEnvironmentVariables()) + .ConfigureServices((context, services) => + { + services.AddSingleton(); + }); +} diff --git a/TeachingRecordSystem/tests/TeachingRecordSystem.AuthorizeAccess.Tests/TeachingRecordSystem.AuthorizeAccess.Tests.csproj b/TeachingRecordSystem/tests/TeachingRecordSystem.AuthorizeAccess.Tests/TeachingRecordSystem.AuthorizeAccess.Tests.csproj index c9d2d7b0e..3ba6291cc 100644 --- a/TeachingRecordSystem/tests/TeachingRecordSystem.AuthorizeAccess.Tests/TeachingRecordSystem.AuthorizeAccess.Tests.csproj +++ b/TeachingRecordSystem/tests/TeachingRecordSystem.AuthorizeAccess.Tests/TeachingRecordSystem.AuthorizeAccess.Tests.csproj @@ -1,15 +1,18 @@ - + net8.0 false true + true + + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/TeachingRecordSystem/tests/TeachingRecordSystem.AuthorizeAccess.Tests/TestBase.cs b/TeachingRecordSystem/tests/TeachingRecordSystem.AuthorizeAccess.Tests/TestBase.cs new file mode 100644 index 000000000..0ccd2d8ee --- /dev/null +++ b/TeachingRecordSystem/tests/TeachingRecordSystem.AuthorizeAccess.Tests/TestBase.cs @@ -0,0 +1,66 @@ +using System.Reactive.Linq; +using FakeXrmEasy.Abstractions; +using Microsoft.EntityFrameworkCore; +using TeachingRecordSystem.Core.DataStore.Postgres; +using TeachingRecordSystem.Core.Events; +using TeachingRecordSystem.Core.Services.TrsDataSync; +using TeachingRecordSystem.TestCommon; + +namespace TeachingRecordSystem.AuthorizeAccess.Tests; + +public abstract class TestBase : IDisposable +{ + private readonly TestScopedServices _testServices; + private readonly IDisposable _trsSyncSubscription; + + protected TestBase(HostFixture hostFixture) + { + HostFixture = hostFixture; + + _testServices = TestScopedServices.Reset(); + + HttpClient = hostFixture.CreateClient(new() + { + AllowAutoRedirect = false + }); + + _trsSyncSubscription = hostFixture.Services.GetRequiredService().GetSyncedEntitiesObservable() + .Subscribe(onNext: static (synced) => + { + var events = synced.OfType(); + foreach (var e in events) + { + TestScopedServices.GetCurrent().EventObserver.OnEventSaved(e); + } + }); + } + + public HostFixture HostFixture { get; } + + public TestableClock Clock => _testServices.Clock; + + public HttpClient HttpClient { get; } + + public TestData TestData => HostFixture.Services.GetRequiredService(); + + public IXrmFakedContext XrmFakedContext => HostFixture.Services.GetRequiredService(); + + public virtual void Dispose() + { + _trsSyncSubscription.Dispose(); + } + + public virtual async Task WithDbContext(Func> action) + { + var dbContextFactory = HostFixture.Services.GetRequiredService>(); + await using var dbContext = await dbContextFactory.CreateDbContextAsync(); + return await action(dbContext); + } + + public virtual Task WithDbContext(Func action) => + WithDbContext(async dbContext => + { + await action(dbContext); + return 0; + }); +} diff --git a/TeachingRecordSystem/tests/TeachingRecordSystem.AuthorizeAccess.Tests/TestScopedServices.cs b/TeachingRecordSystem/tests/TeachingRecordSystem.AuthorizeAccess.Tests/TestScopedServices.cs new file mode 100644 index 000000000..81ed3a3f4 --- /dev/null +++ b/TeachingRecordSystem/tests/TeachingRecordSystem.AuthorizeAccess.Tests/TestScopedServices.cs @@ -0,0 +1,31 @@ +using TeachingRecordSystem.TestCommon; + +namespace TeachingRecordSystem.AuthorizeAccess.Tests; + +public class TestScopedServices +{ + private static readonly AsyncLocal _current = new(); + + public TestScopedServices() + { + Clock = new(); + EventObserver = new(); + } + + public static TestScopedServices GetCurrent() => + _current.Value ?? throw new InvalidOperationException("No current instance has been set."); + + public static TestScopedServices Reset() + { + if (_current.Value is not null) + { + throw new InvalidOperationException("Current instance has already been set."); + } + + return _current.Value = new(); + } + + public TestableClock Clock { get; } + + public CaptureEventObserver EventObserver { get; } +} diff --git a/TeachingRecordSystem/tests/TeachingRecordSystem.AuthorizeAccess.Tests/UnitTest1.cs b/TeachingRecordSystem/tests/TeachingRecordSystem.AuthorizeAccess.Tests/UnitTest1.cs deleted file mode 100644 index 026669645..000000000 --- a/TeachingRecordSystem/tests/TeachingRecordSystem.AuthorizeAccess.Tests/UnitTest1.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace TeachingRecordSystem.AuthorizeAccess.Tests; - -public class UnitTest1 -{ - [Fact] - public void Test1() - { - - } -} diff --git a/TeachingRecordSystem/tests/TeachingRecordSystem.SupportUi.Tests/TestBase.cs b/TeachingRecordSystem/tests/TeachingRecordSystem.SupportUi.Tests/TestBase.cs index 8bc7cd0bd..dcc878120 100644 --- a/TeachingRecordSystem/tests/TeachingRecordSystem.SupportUi.Tests/TestBase.cs +++ b/TeachingRecordSystem/tests/TeachingRecordSystem.SupportUi.Tests/TestBase.cs @@ -9,7 +9,6 @@ using TeachingRecordSystem.Core.Events; using TeachingRecordSystem.Core.Services.TrsDataSync; using TeachingRecordSystem.FormFlow.State; -using TeachingRecordSystem.SupportUi.Tests.Infrastructure; using TeachingRecordSystem.SupportUi.Tests.Infrastructure.Security; namespace TeachingRecordSystem.SupportUi.Tests; diff --git a/TeachingRecordSystem/tests/TeachingRecordSystem.SupportUi.Tests/TestScopedServices.cs b/TeachingRecordSystem/tests/TeachingRecordSystem.SupportUi.Tests/TestScopedServices.cs index 8bb27dfe1..74de6b28f 100644 --- a/TeachingRecordSystem/tests/TeachingRecordSystem.SupportUi.Tests/TestScopedServices.cs +++ b/TeachingRecordSystem/tests/TeachingRecordSystem.SupportUi.Tests/TestScopedServices.cs @@ -1,6 +1,5 @@ using TeachingRecordSystem.Core.Dqt; using TeachingRecordSystem.SupportUi.Services.AzureActiveDirectory; -using TeachingRecordSystem.SupportUi.Tests.Infrastructure; namespace TeachingRecordSystem.SupportUi.Tests; diff --git a/TeachingRecordSystem/tests/TeachingRecordSystem.SupportUi.Tests/Infrastructure/CaptureEventObserver.cs b/TeachingRecordSystem/tests/TeachingRecordSystem.TestCommon/CaptureEventObserver.cs similarity index 93% rename from TeachingRecordSystem/tests/TeachingRecordSystem.SupportUi.Tests/Infrastructure/CaptureEventObserver.cs rename to TeachingRecordSystem/tests/TeachingRecordSystem.TestCommon/CaptureEventObserver.cs index 9756d6241..eae9b8edc 100644 --- a/TeachingRecordSystem/tests/TeachingRecordSystem.SupportUi.Tests/Infrastructure/CaptureEventObserver.cs +++ b/TeachingRecordSystem/tests/TeachingRecordSystem.TestCommon/CaptureEventObserver.cs @@ -1,8 +1,9 @@ using System.Diagnostics.CodeAnalysis; using TeachingRecordSystem.Core.Events; using TeachingRecordSystem.Core.Events.Processing; +using Xunit; -namespace TeachingRecordSystem.SupportUi.Tests.Infrastructure; +namespace TeachingRecordSystem.TestCommon; public class CaptureEventObserver : IEventObserver { diff --git a/TeachingRecordSystem/tests/TeachingRecordSystem.TestCommon/TestData.CreateOneLoginUser.cs b/TeachingRecordSystem/tests/TeachingRecordSystem.TestCommon/TestData.CreateOneLoginUser.cs new file mode 100644 index 000000000..980fd3697 --- /dev/null +++ b/TeachingRecordSystem/tests/TeachingRecordSystem.TestCommon/TestData.CreateOneLoginUser.cs @@ -0,0 +1,75 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using TeachingRecordSystem.Core.DataStore.Postgres.Models; + +namespace TeachingRecordSystem.TestCommon; + +public partial class TestData +{ + public Task CreateOneLoginUser( + Guid? personId, + string? subject = null, + string? email = null, + string? firstName = null, + string? lastName = null, + DateOnly? dateOfBirth = null) + { + return WithDbContext(async dbContext => + { + subject ??= Faker.Internet.UserName(); + email ??= Faker.Internet.Email(); + firstName ??= Faker.Name.First(); + lastName ??= Faker.Name.Last(); + dateOfBirth ??= DateOnly.FromDateTime(Faker.Identification.DateOfBirth()); + + var user = new OneLoginUser() + { + Subject = subject, + Email = email, + FirstOneLoginSignIn = Clock.UtcNow, + LastOneLoginSignIn = Clock.UtcNow, + CoreIdentityVc = CreateOneLoginCoreIdentityVc(firstName, lastName, dateOfBirth.Value), + PersonId = personId + }; + + dbContext.OneLoginUsers.Add(user); + + await dbContext.SaveChangesAsync(); + + return user; + }); + } + + public JsonDocument CreateOneLoginCoreIdentityVc(string firstName, string lastName, DateOnly dateOfBirth) => + JsonDocument.Parse( + new JsonObject + { + ["type"] = new JsonArray( + JsonValue.Create("VerifiableCredential"), + JsonValue.Create("IdentityCheckCredential")), + ["aud"] = "test_client_id", + ["credentialSubject"] = new JsonObject + { + ["name"] = new JsonArray( + new JsonObject + { + ["nameParts"] = new JsonArray( + new JsonObject + { + ["value"] = firstName, + ["type"] = "GivenName" + }, + new JsonObject + { + ["value"] = lastName, + ["type"] = "FamilyName" + }) + }), + ["birthDate"] = new JsonArray( + new JsonObject + { + ["value"] = dateOfBirth.ToString("yyyy-MM-dd") + }) + } + }.ToString()); +}