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
+
+