diff --git a/.gitignore b/.gitignore index a8491e0f..5fbdcfed 100644 --- a/.gitignore +++ b/.gitignore @@ -44,3 +44,4 @@ yavsc-pre *.env generated/ +*.lscache diff --git a/.vscode/settings.json b/.vscode/settings.json index 79924b17..cf480807 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -13,5 +13,8 @@ "fr" ], "cSpell.reportUnknownWords": true, - "cSpell.language": "fr,fr-FR,en,en-GB" + "cSpell.language": "fr,fr-FR,en,en-GB", + "chat.tools.terminal.autoApprove": { + "dotnet test": true + } } diff --git a/Directory.Packages.props b/Directory.Packages.props index 076da407..3b2164c8 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -5,10 +5,10 @@ - - - - + + + + @@ -17,46 +17,47 @@ - - + + - + - + - - + + + - - + + - - - - - - - + + + + + + + - - + + - - - - - - + + + + + + - + \ No newline at end of file diff --git a/src/Api/Api.csproj b/src/Api/Api.csproj index 4edbeb59..e8d8bf71 100644 --- a/src/Api/Api.csproj +++ b/src/Api/Api.csproj @@ -1,6 +1,6 @@ - net9.0 + net10.0 enable 1c73094f-959f-4211-b1a1-6a69b236c283 Yavsc.Api diff --git a/src/Yavsc.Blogs/Yavsc.Blogs.csproj b/src/Yavsc.Blogs/Yavsc.Blogs.csproj index 4edbeb59..e8d8bf71 100644 --- a/src/Yavsc.Blogs/Yavsc.Blogs.csproj +++ b/src/Yavsc.Blogs/Yavsc.Blogs.csproj @@ -1,6 +1,6 @@ - net9.0 + net10.0 enable 1c73094f-959f-4211-b1a1-6a69b236c283 Yavsc.Api diff --git a/src/Yavsc.Org/Extensions/HostingExtensions.cs b/src/Yavsc.Org/Extensions/HostingExtensions.cs index 4af832b0..4eedb087 100644 --- a/src/Yavsc.Org/Extensions/HostingExtensions.cs +++ b/src/Yavsc.Org/Extensions/HostingExtensions.cs @@ -1,16 +1,18 @@ -using System.Diagnostics; using System.Globalization; +using System.IdentityModel.Tokens.Jwt; +using System.Reflection; using Google.Apis.Util.Store; +using IdentityModel; using IdentityServer8; -using Microsoft.Extensions.DependencyInjection; +using IdentityServer8.EntityFramework.Entities; +using IdentityServer8.EntityFramework.Services; +using IdentityServer8.EntityFramework.Stores; using IdentityServer8.Stores; -using IdentityServer8.EntityFramework; -using IdentityServer8.Extensions; - -using Microsoft.AspNetCore.Authentication; +using IdentityServer8.Validation; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Identity.EntityFrameworkCore; using Microsoft.AspNetCore.Localization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Razor; @@ -22,28 +24,12 @@ using Microsoft.Net.Http.Headers; using Newtonsoft.Json; using Yavsc.Helpers; using Yavsc.Interface; +using Yavsc.Interfaces; using Yavsc.Models; +using Yavsc.Server.Helpers; using Yavsc.Services; using Yavsc.Settings; using Yavsc.ViewModels.Auth; -using Yavsc.Server.Helpers; -using System.Security.Cryptography; -using Microsoft.IdentityModel.Tokens; -using Microsoft.IdentityModel.Protocols.Configuration; -using IdentityModel; -using Yavsc.Interfaces; -using Microsoft.AspNetCore.Identity.EntityFrameworkCore; -using Npgsql; -using System.Reflection; -using IdentityServer8.EntityFramework.DbContexts; -using IdentityServer8.EntityFramework.Mappers; -using System.IdentityModel.Tokens.Jwt; -using IdentityServer8.EntityFramework.Stores; -using IdentityServer8.EntityFramework.Services; -using IdentityServer8.EntityFramework.Interfaces; -using Microsoft.AspNetCore.Authentication.Cookies; -using IdentityServer8.Validation; -using IdentityServer8.EntityFramework.Entities; namespace Yavsc.Extensions; @@ -99,9 +85,20 @@ public static class HostingExtensions options.ResourcesPath = "Resources"; }).AddDataAnnotationsLocalization(); - services.AddTransient() - .AddTransient() - .AddTransient() + bool useTestEmailSender = builder.Configuration.GetValue("UseTestEmailSender", false); + + if (useTestEmailSender) + { + services.AddTransient() + .AddTransient(); + } + else + { + services.AddTransient() + .AddTransient(); + } + + services.AddTransient() .AddTransient() .AddTransient((sp) => new FileDataStore("googledatastore", false)) .AddTransient() @@ -150,13 +147,23 @@ public static class HostingExtensions public static IdentityBuilder AddIdentityDBAndStores(this WebApplicationBuilder builder) { IServiceCollection services = builder.Services; - services.AddDbContext(options => - { - options.UseNpgsql(builder.Configuration.GetConnectionString(Constants.YavscConnectionStringName), - options => options.MigrationsAssembly(typeof(Program).Assembly)); - }); + bool useInMemory = builder.Configuration.GetValue("UseInMemoryDatabase", false); - return services.AddIdentity( + if (useInMemory) + { + services.AddDbContext(options => + options.UseInMemoryDatabase("YavscInMemory")); + } + else + { + services.AddDbContext(options => + { + options.UseNpgsql(builder.Configuration.GetConnectionString(Constants.YavscConnectionStringName), + options => options.MigrationsAssembly(typeof(Program).Assembly)); + }); + } + + var identityBuilder = services.AddIdentity( options => { options.SignIn.RequireConfirmedAccount = builder.Environment.IsEnvironment( @@ -166,6 +173,10 @@ public static class HostingExtensions } ) .AddEntityFrameworkStores(); + + services.AddScoped, UserClaimsPrincipalFactory>(); + + return identityBuilder; } private static void AddYavscPolicies(IServiceCollection services) @@ -259,7 +270,7 @@ public static class HostingExtensions }); } - private static IIdentityServerBuilder AddIdentityServer(WebApplicationBuilder builder) + public static IIdentityServerBuilder AddIdentityServer(WebApplicationBuilder builder) { builder.Services.Configure(options => { @@ -269,6 +280,8 @@ public static class HostingExtensions }); var migrationsAssembly = typeof(Program).GetTypeInfo().Assembly.GetName().Name; var connectionString = builder.Configuration.GetConnectionString(Constants.YavscConnectionStringName); + bool useInMemory = builder.Configuration.GetValue("UseInMemoryDatabase", false); + string inMemoryDatabaseName = "YavscInMemory"; var identityServerBuilder = builder.Services.AddIdentityServer(options => { @@ -288,26 +301,40 @@ public static class HostingExtensions .AddResourceStore() .AddConfigurationStore(options => { - options.ConfigureDbContext = b => b.UseNpgsql(connectionString, - sql => sql.MigrationsAssembly(migrationsAssembly)) - .UseSeeding((context, _) => - { - foreach (String scope in new string[] { "blog", "admin", "contract", "com"}) - { - var testBlog = context.Set().FirstOrDefault(b => b.Name == scope); - if (testBlog == null) + if (useInMemory) { - context.Set().Add(new ApiScope { Name = scope }); - context.SaveChanges(); + options.ConfigureDbContext = b => b.UseInMemoryDatabase(inMemoryDatabaseName); } - } + else + { + options.ConfigureDbContext = b => b.UseNpgsql(connectionString, + sql => sql.MigrationsAssembly(migrationsAssembly)) + .UseSeeding((context, _) => + { + foreach (String scope in new string[] { "blog", "admin", "contract", "com"}) + { + var testBlog = context.Set().FirstOrDefault(b => b.Name == scope); + if (testBlog == null) + { + context.Set().Add(new ApiScope { Name = scope }); + context.SaveChanges(); + } + } - }); + }); + } }) .AddOperationalStore(options => { - options.ConfigureDbContext = b => b.UseNpgsql(connectionString, - sql => sql.MigrationsAssembly(migrationsAssembly)); + if (useInMemory) + { + options.ConfigureDbContext = b => b.UseInMemoryDatabase(inMemoryDatabaseName); + } + else + { + options.ConfigureDbContext = b => b.UseNpgsql(connectionString, + sql => sql.MigrationsAssembly(migrationsAssembly)); + } }); if (builder.Environment.IsDevelopment()) diff --git a/src/Yavsc.Org/Helpers/EventHelpers.cs b/src/Yavsc.Org/Helpers/EventHelpers.cs index d9f90dc3..48c23c1b 100644 --- a/src/Yavsc.Org/Helpers/EventHelpers.cs +++ b/src/Yavsc.Org/Helpers/EventHelpers.cs @@ -50,7 +50,7 @@ namespace Yavsc.Helpers } public static string GetSender(this ApplicationUser user) { - return user.UserName+" ["+user.Id+"@"+Config.Authority+"]"; + return user.UserName+" ["+user.Id+"@"+Config.AuthorityDomain+"]"; } public static HairCutQueryEvent CreateEvent(this HairMultiCutQuery query, IStringLocalizer SR, BrusherProfile bpr) diff --git a/src/Yavsc.Org/Yavsc.Org.csproj b/src/Yavsc.Org/Yavsc.Org.csproj index 43e927d6..790838ec 100644 --- a/src/Yavsc.Org/Yavsc.Org.csproj +++ b/src/Yavsc.Org/Yavsc.Org.csproj @@ -1,6 +1,6 @@ - net9.0 + net10.0 enable WTFPL 76e56fc2-1619-40d8-8393-365258b7a21d @@ -31,6 +31,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive all + diff --git a/src/Yavsc.Server/Config.cs b/src/Yavsc.Server/Config.cs index 7340770e..5d2bcdff 100644 --- a/src/Yavsc.Server/Config.cs +++ b/src/Yavsc.Server/Config.cs @@ -9,8 +9,24 @@ namespace Yavsc; public static class Config { + /// + /// Authority URL for IdentityServer, used for authentication and authorization. + /// public static string Authority { get; set; } + public static string AuthorityDomain + { + get + { + if (Uri.TryCreate(Authority, UriKind.Absolute, out var uri)) + { + return uri.GetLeftPart(UriPartial.Authority); + } + throw new InvalidOperationException("Invalid Authority URL"); + } + } + + public static IConfigurationRoot? GoogleWebClientConfiguration { get; set; } public static GoogleServiceAccount? GServiceAccount { get; set; } diff --git a/src/Yavsc.Server/Helpers/WorkflowHelpers.cs b/src/Yavsc.Server/Helpers/WorkflowHelpers.cs index a1180ed2..c0531ae6 100644 --- a/src/Yavsc.Server/Helpers/WorkflowHelpers.cs +++ b/src/Yavsc.Server/Helpers/WorkflowHelpers.cs @@ -1,5 +1,4 @@ - namespace Yavsc.Helpers { using System.Collections.Generic; @@ -16,6 +15,9 @@ namespace Yavsc.Helpers public static class WorkflowHelpers { + // Synchronization lock for billing service configuration + private static readonly object _billingLock = new object(); + public static async Task> ListPerformersAsync(this ApplicationDbContext context, IBillingService billing, @@ -41,54 +43,79 @@ namespace Yavsc.Helpers public static void RegisterBilling(string code, Func getter) where T : IBillable { - if (BillingService.Billing.ContainsKey(code) - || BillingService.GlobalBillingMap.ContainsKey(code)) + lock (_billingLock) { - throw new InvalidOperationException("Billing setup"); + string typeName = typeof(T).Name; + + // Only add if not already present (idempotent operation) + if (!BillingService.Billing.ContainsKey(code)) + { + BillingService.Billing.Add(code, getter); + } + else if (!BillingService.GlobalBillingMap.ContainsKey(typeName) || + BillingService.GlobalBillingMap[typeName] != code) + { + throw new InvalidOperationException($"Billing setup: code '{code}' already registered"); + } + + if (!BillingService.GlobalBillingMap.ContainsKey(typeName)) + { + BillingService.GlobalBillingMap.Add(typeName, code); + } + else if (BillingService.GlobalBillingMap[typeName] != code) + { + throw new InvalidOperationException($"Billing setup: type '{typeName}' already registered with different code"); + } } - BillingService.Billing.Add(code, getter); - BillingService.GlobalBillingMap.Add(typeof(T).Name, code); } public static void ConfigureBillingService() { - foreach (var a in System.AppDomain.CurrentDomain.GetAssemblies()) + lock (_billingLock) { - foreach (var c in a.GetTypes()) + BillingService.Billing.Clear(); + BillingService.GlobalBillingMap.Clear(); + BillingService.UserSettings.Clear(); + Config.ProfileTypes.Clear(); + + foreach (var a in System.AppDomain.CurrentDomain.GetAssemblies()) { - if (c.IsClass && !c.IsAbstract && - c.GetInterface("ISpecializationSettings") != null) + foreach (var c in a.GetTypes()) { - Config.ProfileTypes.Add(c); + if (c.IsClass && !c.IsAbstract && + c.GetInterface("ISpecializationSettings") != null) + { + Config.ProfileTypes.Add(c); + } } } - } - foreach (var propertyInfo in typeof(ApplicationDbContext).GetProperties()) - { - foreach (var attr in propertyInfo.CustomAttributes) + foreach (var propertyInfo in typeof(ApplicationDbContext).GetProperties()) { - // something like a DbSet? - if (typeof(Yavsc.Attributes.ActivitySettingsAttribute).IsAssignableFrom(attr.AttributeType)) + foreach (var attr in propertyInfo.CustomAttributes) { - BillingService.UserSettings.Add(propertyInfo); + // something like a DbSet? + if (typeof(Yavsc.Attributes.ActivitySettingsAttribute).IsAssignableFrom(attr.AttributeType)) + { + BillingService.UserSettings.Add(propertyInfo); + } } } + + RegisterBilling(BillingCodes.Brush, new Func + ((db, id) => + { + var query = db.HairCutQueries.Include(q => q.Prestation).Include(q => q.Regularisation).Single(q => q.Id == id); + query.SelectedProfile = db.BrusherProfile.Single(b => b.UserId == query.PerformerId); + return query; + })); + + RegisterBilling(BillingCodes.MBrush, new Func + ((db, id) => db.HairMultiCutQueries.Include(q => q.Regularisation).Single(q => q.Id == id))); + + RegisterBilling(BillingCodes.Rdv, new Func + ((db, id) => db.RdvQueries.Include(q => q.Regularisation).Single(q => q.Id == id))); } - - RegisterBilling(BillingCodes.Brush, new Func - ((db, id) => - { - var query = db.HairCutQueries.Include(q => q.Prestation).Include(q => q.Regularisation).Single(q => q.Id == id); - query.SelectedProfile = db.BrusherProfile.Single(b => b.UserId == query.PerformerId); - return query; - })); - - RegisterBilling(BillingCodes.MBrush, new Func - ((db, id) => db.HairMultiCutQueries.Include(q => q.Regularisation).Single(q => q.Id == id))); - - RegisterBilling(BillingCodes.Rdv, new Func - ((db, id) => db.RdvQueries.Include(q => q.Regularisation).Single(q => q.Id == id))); } } diff --git a/src/Yavsc.Server/Services/TestMailSender.cs b/src/Yavsc.Server/Services/TestMailSender.cs new file mode 100644 index 00000000..9e91dfef --- /dev/null +++ b/src/Yavsc.Server/Services/TestMailSender.cs @@ -0,0 +1,30 @@ +using System; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Identity.UI.Services; +using Microsoft.Extensions.Logging; +using Yavsc.Interface; + +namespace Yavsc.Services +{ + public class TestMailSender : ITrueEmailSender, IEmailSender + { + private readonly ILogger logger; + + public TestMailSender(ILoggerFactory loggerFactory) + { + logger = loggerFactory.CreateLogger(); + } + + public Task SendEmailAsync(string email, string subject, string htmlMessage) + { + logger.LogInformation("[TestMailSender] SendEmailAsync to {Email} subject={Subject}", email, subject); + return Task.CompletedTask; + } + + public Task SendEmailAsync(string name, string email, string subject, string htmlMessage) + { + logger.LogInformation("[TestMailSender] SendEmailAsync to {Email} subject={Subject} name={Name}", email, subject, name); + return Task.FromResult($"test-message-{Guid.NewGuid()}"); + } + } +} diff --git a/src/Yavsc.Server/Settings/SmtpSettings.cs b/src/Yavsc.Server/Settings/SmtpSettings.cs index c744cae6..ca0cdd1d 100644 --- a/src/Yavsc.Server/Settings/SmtpSettings.cs +++ b/src/Yavsc.Server/Settings/SmtpSettings.cs @@ -3,6 +3,12 @@ namespace Yavsc.Settings public class SmtpSettings { public string Server { get; set; } + public string Host + { + get => Server; + set => Server = value; + } + public int Port { get; set; } public string SenderName { get; set; } diff --git a/src/Yavsc.Server/Yavsc.Server.csproj b/src/Yavsc.Server/Yavsc.Server.csproj index b38a36a4..68524f68 100644 --- a/src/Yavsc.Server/Yavsc.Server.csproj +++ b/src/Yavsc.Server/Yavsc.Server.csproj @@ -1,6 +1,6 @@ - net9.0 + net10.0 enable 53bd70e8-ff81-497a-847f-a15fd8ea7a09 Yavsc.Server diff --git a/src/Yavsc.Web/Yavsc.Web.csproj b/src/Yavsc.Web/Yavsc.Web.csproj index 19035032..86f61a85 100644 --- a/src/Yavsc.Web/Yavsc.Web.csproj +++ b/src/Yavsc.Web/Yavsc.Web.csproj @@ -1,6 +1,6 @@ - net9.0 + net10.0 enable enable diff --git a/src/cli/cli.csproj b/src/cli/cli.csproj index 71f0e93e..c35b5e1e 100644 --- a/src/cli/cli.csproj +++ b/src/cli/cli.csproj @@ -1,7 +1,7 @@ Exe - net9.0 + net10.0 enable Yavsc.cli diff --git a/test/yavscTests/Mandatory/Remoting.cs b/test/yavscTests/Mandatory/Remoting.cs index c27122aa..3b53eb50 100644 --- a/test/yavscTests/Mandatory/Remoting.cs +++ b/test/yavscTests/Mandatory/Remoting.cs @@ -1,9 +1,8 @@ -using isnd.tests; +using System.Security.Cryptography.X509Certificates; +using System.Net.Security; +using isnd.tests; using Xunit.Abstractions; using IdentityModel.Client; -using System.Net; -using System.Security.Cryptography.X509Certificates; -using System.Net.Security; namespace yavscTests { @@ -20,64 +19,55 @@ namespace yavscTests [Fact] public async Task ObtainServiceToken() - { - var serverUrl = _serverFixture.Addresses.FirstOrDefault( - u => u.StartsWith("https:") - ); + { + var serverUrl = _serverFixture.Addresses.FirstOrDefault(u => u.StartsWith("https:")); + if (string.IsNullOrEmpty(serverUrl)) + throw new InvalidOperationException("No HTTPS server address found"); - String authority = _serverFixture.SiteSettings.Authority; - HttpClient client = NewHttpClient(); - var disco = await client.GetDiscoveryDocumentAsync(authority); - if (disco.IsError) throw new Exception(disco.Error); + HttpClient client = NewHttpClient(); + var disco = await client.GetDiscoveryDocumentAsync(serverUrl); + if (disco.IsError) throw new Exception(disco.Error); - var response = await client.RequestClientCredentialsTokenAsync(new ClientCredentialsTokenRequest - { - Address = disco.TokenEndpoint, - ClientId = _serverFixture.TestClientId, - ClientSecret = _serverFixture.TestClientSecret, - Scope = "test", - GrantType = "client_credentials" - }); - /*"mvc"; - options.ClientSecret = "49C1A7E1-0C79-4A89-A3D6-A37998FB86B0";*/ - if (response.IsError) throw new Exception(response.Error); + var response = await client.RequestClientCredentialsTokenAsync(new ClientCredentialsTokenRequest + { + Address = disco.TokenEndpoint, + ClientId = _serverFixture.TestClientId, + ClientSecret = _serverFixture.TestClientSecret, + Scope = "test", + GrantType = "client_credentials" + }); + if (response.IsError) throw new Exception(response.Error); + } - } + private static HttpClient NewHttpClient() + { + return new HttpClient(new BypassSslValidationHandler()); + } - private static HttpClient NewHttpClient() - { - return new HttpClient(new BypassSslValidationHandler()); - } - - [Fact] + [Fact] public async Task ObtainResourceOwnerPasswordToken() { - var serverUrl = _serverFixture.Addresses.FirstOrDefault( - u => u.StartsWith("https:") - ); + var serverUrl = _serverFixture.Addresses.FirstOrDefault(u => u.StartsWith("https:")); + if (string.IsNullOrEmpty(serverUrl)) + throw new InvalidOperationException("No HTTPS server address found"); - String authority = _serverFixture.SiteSettings.Authority; var client = NewHttpClient(); - var disco = await client.GetDiscoveryDocumentAsync(authority); + var disco = await client.GetDiscoveryDocumentAsync(serverUrl); if (disco.IsError) throw new Exception(disco.Error); var response = await client.RequestPasswordTokenAsync(new PasswordTokenRequest - { - Address = disco.TokenEndpoint, - - ClientId = _serverFixture.TestClientId, - ClientSecret = _serverFixture.TestClientSecret, - - UserName = _serverFixture.TestingUserName, - Password = _serverFixture.TestingUserPassword, - - Scope = "test", - - Parameters = + { + Address = disco.TokenEndpoint, + ClientId = _serverFixture.TestClientId, + ClientSecret = _serverFixture.TestClientSecret, + UserName = _serverFixture.TestingUserName, + Password = _serverFixture.TestingUserPassword, + Scope = "test", + Parameters = { { "acr_values", "tenant:custom_account_store1 foo bar quux" } } - }); + }); if (response.IsError) throw new Exception(response.Error); @@ -90,22 +80,22 @@ namespace yavscTests } - internal class BypassSslValidationHandler : HttpClientHandler -{ - public BypassSslValidationHandler() + internal class BypassSslValidationHandler : HttpClientHandler { - // Override validation for this handler only - ServerCertificateCustomValidationCallback = ValidateCertificate; - } - - private bool ValidateCertificate( - HttpRequestMessage request, - X509Certificate2 certificate, - X509Chain chain, - SslPolicyErrors errors) - { - // Accept all certificates (bypass validation) - return true; + public BypassSslValidationHandler() + { + // Override validation for this handler only + ServerCertificateCustomValidationCallback = ValidateCertificate; + } + + private bool ValidateCertificate( + HttpRequestMessage request, + X509Certificate2? certificate, + X509Chain? chain, + SslPolicyErrors errors) + { + // Accept all certificates (bypass validation) + return true; + } } } -} diff --git a/test/yavscTests/NonRegression/BillingServiceTests.cs b/test/yavscTests/NonRegression/BillingServiceTests.cs new file mode 100644 index 00000000..7b4f2705 --- /dev/null +++ b/test/yavscTests/NonRegression/BillingServiceTests.cs @@ -0,0 +1,48 @@ +using Microsoft.EntityFrameworkCore; +using Xunit; +using Yavsc; +using Yavsc.Abstract.Workflow; +using Yavsc.Helpers; +using Yavsc.Models; +using Yavsc.Models.Billing; +using Yavsc.Models.Haircut; +using Yavsc.Services; + +namespace yavscTests +{ + [Trait("regression", "II")] + public class BillingServiceTests + { + [Fact] + public void ConfigureBillingService_CanBeCalledTwiceWithoutThrowing() + { + // First initialization should populate the billing registry. + WorkflowHelpers.ConfigureBillingService(); + + int firstBillingCount = BillingService.Billing.Count; + int firstSettingsCount = BillingService.UserSettings.Count; + int firstProfileTypesCount = Config.ProfileTypes.Count; + + // Second call should be idempotent and not throw. + WorkflowHelpers.ConfigureBillingService(); + + Assert.Equal(firstBillingCount, BillingService.Billing.Count); + Assert.Equal(firstSettingsCount, BillingService.UserSettings.Count); + Assert.Equal(firstProfileTypesCount, Config.ProfileTypes.Count); + } + + [Fact] + public void RegisterBilling_DuplicateRegistrationThrowsInvalidOperationException() + { + WorkflowHelpers.ConfigureBillingService(); + + var firstRegistrar = new Func((db, id) => + db.HairCutQueries.Include(q => q.Prestation).Include(q => q.Regularisation).Single(q => q.Id == id)); + + const string testCode = "TestBrush"; + + Assert.Throws(() => + WorkflowHelpers.RegisterBilling(testCode, firstRegistrar)); + } + } +} diff --git a/test/yavscTests/WebServerFixture.cs b/test/yavscTests/WebServerFixture.cs index a822a342..da9c8076 100644 --- a/test/yavscTests/WebServerFixture.cs +++ b/test/yavscTests/WebServerFixture.cs @@ -1,20 +1,25 @@ +using IdentityServer8.EntityFramework.Entities; +using IdentityServer8.Models; using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Hosting.Server; using Microsoft.AspNetCore.Hosting.Server.Features; using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Server.Kestrel.Core; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; -using Yavsc; -using Yavsc.Models; -using Yavsc.Extensions; -using Microsoft.EntityFrameworkCore; using Serilog; using Serilog.Events; using Serilog.Sinks.SystemConsole.Themes; -using IdentityServer8.EntityFramework.Entities; -using IdentityServer8.Models; +using System.Net; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using Yavsc; +using Yavsc.Extensions; +using Yavsc.Models; using Client = IdentityServer8.EntityFramework.Entities.Client; namespace isnd.tests @@ -23,35 +28,92 @@ namespace isnd.tests [CollectionDefinition("Web server collection")] public class WebServerFixture : IDisposable { + private static readonly Lazy _selfSignedCertificate = new Lazy(CreateSelfSignedCertificate); + private static readonly object _sync = new object(); + private static WebApplication? _app; + private static bool _isInitialized = false; + private static int _instanceCount = 0; + private static readonly List _sharedAddresses = new List(); + private static string? _sharedTestClientId; + private static string? _sharedTestClientSecret; + private static string? _sharedTestingUserName; + private static string? _sharedTestingUserPassword; + private static string? _sharedTestingUserEmail; + private static IServiceProvider? _sharedServices; + private static IConfiguration? _sharedConfiguration; + private static SiteSettings? _sharedSiteSettings; + private static Microsoft.Extensions.Logging.ILogger? _sharedLogger; + public List Addresses { get; private set; } = new List(); - public Microsoft.Extensions.Logging.ILogger Logger { get; internal set; } + public Microsoft.Extensions.Logging.ILogger? Logger { get; internal set; } - private SiteSettings siteSettings; + private SiteSettings? siteSettings; - public IConfigurationRoot Configuration { get; private set; } + public IConfiguration? Configuration { get; private set; } - private WebApplication app; - public string TestClientId { get; private set; } + public string? TestClientId { get; private set; } - public IServiceProvider Services { get; private set; } - public string TestingUserName { get; private set; } - public string TestingUserPassword { get; private set; } + public IServiceProvider? Services { get; private set; } + public string? TestingUserName { get; private set; } + public string? TestingUserPassword { get; private set; } - public string ProtectedTestingApiKey { get; internal set; } - public ApplicationUser TestingUser { get; private set; } + public string? ProtectedTestingApiKey { get; internal set; } + public ApplicationUser? TestingUser { get; private set; } public bool DbCreated { get; internal set; } - public SiteSettings SiteSettings { get => siteSettings; set => siteSettings = value; } - public string TestClientSecret { get; set; } - + public SiteSettings? SiteSettings { get => siteSettings; set => siteSettings = value; } + public string? TestClientSecret { get; set; } + public string? TestingUserEmail { get; set; } public WebServerFixture() { - SetupHost().Wait(); + lock (_sync) + { + _instanceCount++; + if (!_isInitialized) + { + SetupHost().Wait(); + _isInitialized = true; + } + + CopySharedState(); + } } public void Dispose() { - if (app != null) - app.StopAsync().Wait(); + lock (_sync) + { + _instanceCount--; + if (_instanceCount == 0 && _app != null) + { + _app.StopAsync().Wait(); + _app = null; + _isInitialized = false; + _sharedAddresses.Clear(); + _sharedServices = null; + _sharedConfiguration = null; + _sharedSiteSettings = null; + _sharedLogger = null; + _sharedTestClientId = null; + _sharedTestClientSecret = null; + _sharedTestingUserName = null; + _sharedTestingUserPassword = null; + _sharedTestingUserEmail = null; + } + } + } + + private void CopySharedState() + { + Addresses = new List(_sharedAddresses); + Logger = _sharedLogger; + Configuration = _sharedConfiguration; + Services = _sharedServices; + SiteSettings = _sharedSiteSettings; + TestClientId = _sharedTestClientId; + TestClientSecret = _sharedTestClientSecret; + TestingUserName = _sharedTestingUserName; + TestingUserPassword = _sharedTestingUserPassword; + TestingUserEmail = _sharedTestingUserEmail; } void ConfigureLogger() => Log.Logger = new LoggerConfiguration() .MinimumLevel.Debug() @@ -73,18 +135,38 @@ namespace isnd.tests { var builder = WebApplication.CreateBuilder(); + builder.Environment.EnvironmentName = "Development"; ConfigureLogger(); - Configuration = builder.Configuration - .AddJsonFile("appsettings.json") - .AddJsonFile($"appsettings.{builder.Environment.EnvironmentName}.json", optional: true) + builder.Configuration + .AddJsonFile("appsettings.json", optional: false, reloadOnChange: false) + .AddJsonFile($"appsettings.{builder.Environment.EnvironmentName}.json", optional: true, reloadOnChange: false) .AddEnvironmentVariables() - .Build(); + .AddInMemoryCollection(new Dictionary + { + ["UseInMemoryDatabase"] = "true", + ["UseTestEmailSender"] = "true", + ["Smtp:Host"] = "localhost", + ["Smtp:Port"] = "25", + ["Smtp:SenderName"] = "Yavsc Test", + ["Smtp:SenderEmail"] = "test@example.com" + }); - this.app = builder.ConfigureWebAppServices(); - Services = app.Services; - SiteSettings = app.Services.GetRequiredService>().Value; - - using (var migrationScope = app.Services.CreateScope()) + // Configure Kestrel for HTTPS with self-signed certificate on a dynamic port + builder.WebHost.ConfigureKestrel(options => + { + options.Listen(IPAddress.Loopback, 0, listenOptions => + { + listenOptions.UseHttps(_selfSignedCertificate.Value); + }); + }); + + Configuration = builder.Configuration; + + _app = builder.ConfigureWebAppServices(); + Services = _app.Services; + SiteSettings = _app.Services.GetRequiredService>().Value; + + using (var migrationScope = _app.Services.CreateScope()) { var db = migrationScope.ServiceProvider.GetRequiredService(); db.Database.EnsureDeleted(); @@ -92,102 +174,110 @@ namespace isnd.tests TestingUserName = "Tester"; TestingUserPassword = "tesT456+*"; TestClientId = "testClientId"; + TestingUserEmail = "test@no-reply.com"; + TestingUser = null; TestClientSecret = Guid.CreateVersion7().ToString(); - EnsureUser(TestingUserName, TestingUserPassword); - AddAuthorizedClient(TestClientId, TestClientSecret); + EnsureUser(TestingUserName, TestingUserPassword, TestingUserEmail, migrationScope); + AddAuthorizedClient(migrationScope, TestClientId, TestClientSecret); TestingUser = await db.Users.FirstOrDefaultAsync(u => u.UserName == TestingUserName); } - await app.ConfigurePipeline(); - app.UseSession(); - await app.StartAsync(); + await _app!.ConfigurePipeline(); + _app.UseSession(); + await _app.StartAsync(); + _sharedServices = _app.Services; + _sharedConfiguration = Configuration; + _sharedSiteSettings = SiteSettings; + _sharedTestClientId = TestClientId; + _sharedTestClientSecret = TestClientSecret; + _sharedTestingUserName = TestingUserName; + _sharedTestingUserPassword = TestingUserPassword; + _sharedTestingUserEmail = TestingUserEmail; + _sharedLogger = _app.Services.GetRequiredService().CreateLogger(); + Logger = _sharedLogger; - - var logFactory = app.Services.GetRequiredService(); - Logger = logFactory.CreateLogger(); - - var server = app.Services.GetRequiredService(); - + var server = _app.Services.GetRequiredService(); var addressFeatures = server.Features.Get(); - foreach (var address in addressFeatures.Addresses) + if (addressFeatures?.Addresses != null) { - Addresses.Add(address); + _sharedAddresses.Clear(); + foreach (var address in addressFeatures.Addresses) + { + _sharedAddresses.Add(address); + Addresses.Add(address); + } } - } - private void AddAuthorizedClient(string testClientId, string testClientSecret) + private void AddAuthorizedClient(IServiceScope scope, string testClientId, string testClientSecret) { - using (IServiceScope scope = app.Services.CreateScope()) + + var db = scope.ServiceProvider.GetRequiredService(); + Client testingClient = new Client { - var db = scope.ServiceProvider.GetRequiredService(); - Client testingClient = new Client - { - ClientId = testClientId, - AccessTokenLifetime = 3600000, - AccessTokenType = 1, - BackChannelLogoutUri = SiteSettings.Audience, - ClientName = "Testing client", - Enabled = true - }; - db.Clients.Add(testingClient); - db.SaveChanges(); - ClientSecret secret = new ClientSecret - { - Value = testClientSecret.Sha256(), - ClientId = testingClient.Id - }; - db.ClientSecrets.Add(secret); + ClientId = testClientId, + AccessTokenLifetime = 3600000, + AccessTokenType = 1, + BackChannelLogoutUri = SiteSettings!.Audience, + ClientName = "Testing client", + Enabled = true + }; + db.Clients.Add(testingClient); + db.SaveChanges(); + ClientSecret secret = new ClientSecret + { + Value = testClientSecret.Sha256(), + ClientId = testingClient.Id + }; + db.ClientSecrets.Add(secret); - var testOrigin = new ClientCorsOrigin - { - ClientId = testingClient.Id, - Origin = SiteSettings.Audience + var testOrigin = new ClientCorsOrigin + { + ClientId = testingClient.Id, + Origin = SiteSettings!.Audience - }; - db.ClientCorsOrigins.Add(testOrigin); - db.ClientGrantTypes.Add(new ClientGrantType - { - ClientId = testingClient.Id, - GrantType = "client_credentials" - }); - db.ClientGrantTypes.Add(new ClientGrantType - { - ClientId = testingClient.Id, - GrantType = "password" - }); - db.ClientGrantTypes.Add(new ClientGrantType - { - ClientId = testingClient.Id, - GrantType = "code" - }); - db.ClientScopes.Add(new ClientScope - { - ClientId = testingClient.Id, - Scope = "test" - }); - db.ApiScopes.Add(new IdentityServer8.EntityFramework.Entities.ApiScope - { - Name = "test", - Enabled = true - }); - db.ClientRedirectUris.Add(new ClientRedirectUri - { - ClientId = testingClient.Id, - RedirectUri = SiteSettings.Audience + }; + db.ClientCorsOrigins.Add(testOrigin); + db.ClientGrantTypes.Add(new ClientGrantType + { + ClientId = testingClient.Id, + GrantType = "client_credentials" + }); + db.ClientGrantTypes.Add(new ClientGrantType + { + ClientId = testingClient.Id, + GrantType = "password" + }); + db.ClientGrantTypes.Add(new ClientGrantType + { + ClientId = testingClient.Id, + GrantType = "code" + }); + db.ClientScopes.Add(new ClientScope + { + ClientId = testingClient.Id, + Scope = "test" + }); + db.ApiScopes.Add(new IdentityServer8.EntityFramework.Entities.ApiScope + { + Name = "test", + Enabled = true + }); + db.ClientRedirectUris.Add(new ClientRedirectUri + { + ClientId = testingClient.Id, + RedirectUri = SiteSettings!.Audience - }); + }); - db.SaveChanges(); - } + db.SaveChanges(); } - public void EnsureUser(string testingUserName, string password) + public void EnsureUser(string testingUserName, string password, string email, IServiceScope scope) { if (TestingUser == null) { - using IServiceScope scope = app.Services.CreateScope(); var userManager = scope.ServiceProvider.GetRequiredService>(); @@ -208,5 +298,21 @@ namespace isnd.tests TestingUser = dbContext.Users.FirstOrDefault(u => u.UserName == testingUserName); } } + + private static X509Certificate2 CreateSelfSignedCertificate() + { + var rsa = RSA.Create(2048); + var certRequest = new CertificateRequest("CN=localhost", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + + certRequest.CertificateExtensions.Add( + new X509KeyUsageExtension(X509KeyUsageFlags.DataEncipherment | X509KeyUsageFlags.KeyEncipherment | X509KeyUsageFlags.DigitalSignature, false)); + + certRequest.CertificateExtensions.Add( + new X509EnhancedKeyUsageExtension( + new OidCollection { new Oid("1.3.6.1.5.5.7.3.1") }, false)); + + var certificate = certRequest.CreateSelfSigned(new DateTimeOffset(DateTime.UtcNow.AddDays(-1)), new DateTimeOffset(DateTime.UtcNow.AddDays(3650))); + return certificate; + } } } diff --git a/test/yavscTests/appsettings.json b/test/yavscTests/appsettings.json index e0b05b6e..28b36a57 100644 --- a/test/yavscTests/appsettings.json +++ b/test/yavscTests/appsettings.json @@ -1,6 +1,6 @@ { "Site": { - "Authority": "localhost", + "Authority": "https://localhost", "Title": "Yavsc dev", "Slogan": "Yavsc : WIP.", "Banner": "/images/yavsc.png", @@ -41,7 +41,7 @@ } }, "ConnectionStrings": { - "YavscConnection": "Server=lame-NpgsqlHostName;Port=5432;Database=lame-DataBase;Username=lame-Username;Password=lame-dbPassword;", + "YavscConnection": "Server=lame-NpgsqlHostName;Port=5432;Database=lame-DataBase;Username=lame-Username;Password=lame-dbPassword;" }, "DataProtection": { "Keys": { diff --git a/test/yavscTests/yavscTests.csproj b/test/yavscTests/yavscTests.csproj index 2b405e86..df1ff580 100644 --- a/test/yavscTests/yavscTests.csproj +++ b/test/yavscTests/yavscTests.csproj @@ -1,6 +1,6 @@ - net9.0 + net10.0 enable enable false @@ -24,6 +24,11 @@ + + + Always + +