diff --git a/Directory.Packages.props b/Directory.Packages.props
index e7f71232..021c5e53 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -9,10 +9,8 @@
-
-
diff --git a/doc/architecture/postit-oidc.md b/doc/architecture/postit-oidc.md
index ddedbfde..ee003b41 100644
--- a/doc/architecture/postit-oidc.md
+++ b/doc/architecture/postit-oidc.md
@@ -56,7 +56,6 @@ pas vers un serveur HTTP.
|---------------------------------|-------------------------------------------------------------------|
| `Services/OidcLoginPhase` | Enum des étapes du flow : `Idle / Discovering / OpeningBrowser / AwaitingCallback / ExchangingCode / Success / Error` |
| `Services/YavscApiClient` | Client HTTP de l'API Yavsc. Porte `LoginInteractiveAsync(IProgress)` et `TrySilentLoginAsync`. Refresh silencieux sur 401 et sur access-token bientôt expiré. |
-| `Services/BlogApiClient` | Mapper DTO↔path pour la sous-API blog. **Note** : `pathPrefix` est *relatif* à `/api/v1/` (que porte déjà `BaseAddress`) — ex. `"blog"` pour matcher `[Route(APIPrefix + "/blog")]`. Ne pas ré-inclure `api/`. |
| `Services/SingleInstance` | Named-pipe helper. `TryHandOffAsync` côté 2ᵉ instance, `StartServerAsync` côté instance vivante. |
| `Services/CustomSchemeBrowser` | `IBrowser` OidcClient qui ouvre le système + attend le pipe. |
| `Services/SchemeUrlDetector` | Détection pure, testable, du `postit://callback` dans argv. |
diff --git a/src/PostIt/PostIt/Services/BlogApiClient.cs b/src/PostIt/PostIt/Services/BlogApiClient.cs
index d489be60..dbe86ca3 100644
--- a/src/PostIt/PostIt/Services/BlogApiClient.cs
+++ b/src/PostIt/PostIt/Services/BlogApiClient.cs
@@ -15,16 +15,6 @@ namespace PostIt.Services;
/// . This class is a thin DTO↔path
/// mapper, nothing more.
///
-/// URL convention. 's
-/// BaseAddress already terminates with /api/v1/
-/// (see Settings.ApiUrl). The path prefix below is
-/// therefore relative to that version segment: a prefix of
-/// "blog" resolves to …/api/v1/blog, which matches
-/// the [Route(APIPrefix + "/blog")] attribute on
-/// Yavsc.Blogs.Controllers.BlogApiController. Do not
-/// re-include the api/ segment here — that produced 404s
-/// in the past (see commit "PostIt: fix blog API double-prefix").
-///
/// The class is intentionally non-IDisposable: it does not own the
/// it depends on. Lifetimes are managed
/// by the consumer (typically a singleton service registered with
@@ -32,7 +22,7 @@ namespace PostIt.Services;
///
public sealed class BlogApiClient
{
- private const string DefaultPathPrefix = "blog";
+ private const string DefaultPathPrefix = "api/blog";
private readonly YavscApiClient _api;
private readonly string _pathPrefix;
diff --git a/src/PostIt/PostIt/ViewModels/MainPageViewModel.cs b/src/PostIt/PostIt/ViewModels/MainPageViewModel.cs
index 073ebf7a..21b8b22b 100644
--- a/src/PostIt/PostIt/ViewModels/MainPageViewModel.cs
+++ b/src/PostIt/PostIt/ViewModels/MainPageViewModel.cs
@@ -128,28 +128,9 @@ public partial class MainPageViewModel : ViewModelBase
[RelayCommand]
internal async Task Save()
{
- // No selection means "create a new post from the editor".
- // The server is the source of truth, so we POST without an id
- // and let BlogApiController assign one. The local view-model
- // is then rebound to the server-issued record.
if (SelectedPost is null)
{
- var draft = new BlogPost
- {
- Title = string.Empty,
- Article = string.Empty,
- DateCreated = DateTime.UtcNow,
- DateModified = DateTime.UtcNow
- };
- await ExecuteAsync(async () =>
- {
- var created = await BlogClient.CreatePostAsync(draft);
- if (created is not null)
- {
- SelectedPost = created;
- StatusMessage = $"Created post {created.Id}.";
- }
- });
+ StatusMessage = "A post must be selected before saving.";
return;
}
@@ -195,6 +176,19 @@ public partial class MainPageViewModel : ViewModelBase
});
}
+ [RelayCommand]
+ internal void New()
+ {
+ SelectedPost = new BlogPost
+ {
+ Title = string.Empty,
+ Article = string.Empty,
+ DateCreated = DateTime.UtcNow,
+ DateModified = DateTime.UtcNow
+ };
+ StatusMessage = "New blog post ready.";
+ }
+
[RelayCommand]
internal void OpenSettings()
{
@@ -259,6 +253,7 @@ public partial class MainPageViewModel : ViewModelBase
LoadPostsCommand.NotifyCanExecuteChanged();
SaveCommand.NotifyCanExecuteChanged();
DeleteCommand.NotifyCanExecuteChanged();
+ NewCommand.NotifyCanExecuteChanged();
}
private bool CanSave() => SelectedPost is not null && !IsBusy;
diff --git a/src/PostIt/PostIt/Views/MainPage.axaml b/src/PostIt/PostIt/Views/MainPage.axaml
index 247bf28e..c09c2f7a 100644
--- a/src/PostIt/PostIt/Views/MainPage.axaml
+++ b/src/PostIt/PostIt/Views/MainPage.axaml
@@ -32,6 +32,7 @@
+
+
+
diff --git a/src/Yavsc.Tests.Shared/TestAuthPolicyProvider.cs b/src/Yavsc.Org.Tests/TestAuthPolicyProvider.cs
similarity index 62%
rename from src/Yavsc.Tests.Shared/TestAuthPolicyProvider.cs
rename to src/Yavsc.Org.Tests/TestAuthPolicyProvider.cs
index 8fd416c6..1391a3a2 100644
--- a/src/Yavsc.Tests.Shared/TestAuthPolicyProvider.cs
+++ b/src/Yavsc.Org.Tests/TestAuthPolicyProvider.cs
@@ -1,30 +1,24 @@
using Microsoft.AspNetCore.Authorization;
-using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Options;
-using System.Security.Claims;
-namespace Yavsc.Tests.Shared;
+namespace Yavsc.Org.Tests;
///
/// Authorization policy provider used by integration tests. Replaces the
-/// production provider in the test host so that any policy-protected
-/// controller can be exercised by sending a X-Test-Role: …
-/// header — no login roundtrip, no cookie, no database user.
+/// production provider in the WebApplicationFactory so that any
+/// policy-protected controller can be exercised by sending a
+/// X-Test-Role: Administrator header — no login roundtrip, no
+/// cookie, no database user.
///
-/// Any policy that requires a role short-circuits to success when the
-/// matching header is present; otherwise the production policy is
-/// preserved. The test does not perform a real login, so we attach an
-/// in-memory carrying the role claim to
-/// the request before the assertion
-/// fires, so claim-based requirements (e.g. RequireRole("Admin"))
-/// also pass.
+/// The role names accepted in the header are the same as the
+/// production . Any
+/// policy that requires one of those roles short-circuits to success
+/// when the matching header is present; otherwise the production
+/// policy is preserved.
///
public sealed class TestAuthPolicyProvider : IAuthorizationPolicyProvider
{
- /// HTTP header read by the test bypass to learn the role.
public const string HeaderName = "X-Test-Role";
-
- /// Conventional admin role name; the production default.
public const string AdminRole = "Administrator";
private readonly DefaultAuthorizationPolicyProvider _fallback;
@@ -48,21 +42,26 @@ public sealed class TestAuthPolicyProvider : IAuthorizationPolicyProvider
// ASP.NET Core sets ctx.Resource to the HttpContext when
// the authorization middleware invokes the policy. Use
// the request headers directly to honour X-Test-Role.
- var http = ctx.Resource as HttpContext;
+ var http = ctx.Resource as Microsoft.AspNetCore.Http.HttpContext;
if (http is null) return false;
var role = http.Request.Headers[HeaderName].ToString();
if (string.IsNullOrEmpty(role)) return false;
+ // The test does not perform a real login, so the
+ // authenticated user has no claims. Attach an
+ // in-memory identity carrying the role claim to the
+ // HttpContext (ctx.User is read-only) so the
+ // production policy's claim requirement is satisfied.
if (http.User.Identity is null || !http.User.Identity.IsAuthenticated)
{
- var identity = new ClaimsIdentity(
+ var identity = new System.Security.Claims.ClaimsIdentity(
new[]
{
- new Claim(
+ new System.Security.Claims.Claim(
"http://schemas.microsoft.com/ws/2008/06/identity/claims/role",
role),
},
authenticationType: "TestAuth");
- http.User = new ClaimsPrincipal(identity);
+ http.User = new System.Security.Claims.ClaimsPrincipal(identity);
}
return true;
})
diff --git a/src/Yavsc.Org.Tests/TestUserMiddleware.cs b/src/Yavsc.Org.Tests/TestUserMiddleware.cs
index b88a75e3..c6348625 100644
--- a/src/Yavsc.Org.Tests/TestUserMiddleware.cs
+++ b/src/Yavsc.Org.Tests/TestUserMiddleware.cs
@@ -2,7 +2,6 @@ using System.Linq;
using System.Security.Claims;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
-using Yavsc.Tests.Shared;
namespace Yavsc.Org.Tests;
diff --git a/src/Yavsc.Org.Tests/TestWebApplicationFactory.cs b/src/Yavsc.Org.Tests/TestWebApplicationFactory.cs
index c51f3f8e..49d3b663 100644
--- a/src/Yavsc.Org.Tests/TestWebApplicationFactory.cs
+++ b/src/Yavsc.Org.Tests/TestWebApplicationFactory.cs
@@ -3,7 +3,6 @@ using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.DependencyInjection;
-using Yavsc.Tests.Shared;
namespace Yavsc.Org.Tests;
diff --git a/src/Yavsc.Org.Tests/WebServerFixture.cs b/src/Yavsc.Org.Tests/WebServerFixture.cs
index ed0bd69d..402211f5 100644
--- a/src/Yavsc.Org.Tests/WebServerFixture.cs
+++ b/src/Yavsc.Org.Tests/WebServerFixture.cs
@@ -1,7 +1,9 @@
+
using IdentityServer8.EntityFramework.Entities;
using IdentityServer8.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Hosting.Server;
using Microsoft.AspNetCore.Hosting.Server.Features;
using Microsoft.AspNetCore.Identity;
@@ -10,272 +12,372 @@ using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
+using System.Net;
+using System.Security.Cryptography;
+using System.Security.Cryptography.X509Certificates;
using Yavsc;
using Yavsc.Extensions;
using Yavsc.Interfaces;
using Yavsc.Models;
using Yavsc.Server.Helpers;
-using Yavsc.Tests.Shared;
using Client = IdentityServer8.EntityFramework.Entities.Client;
using Yavsc.Org.Tests.Fakes;
-namespace Yavsc.Org.Tests;
-///
-/// Specialisation of for the Yavsc.Org
-/// host. Adds:
-///
-/// - In-memory configuration (ConnectionStrings,
-/// Smtp) before ConfigureWebAppServices runs.
-/// - Test-only
-/// (from Yavsc.Tests.Shared) swapped in for
-/// .
-/// - Recording
-/// fake for .
-/// - IdentityServer8 client + API scope + test user
-/// seeded into the in-memory database.
-///
-/// All cross-cutting Kestrel / cert / address plumbing is inherited
-/// from .
-///
-[CollectionDefinition("Yavsc Server")]
-public sealed class WebServerFixture : WebHostFixture
+namespace Yavsc.Org.Tests
+
{
- private static IConfiguration? _sharedConfiguration;
- private static SiteSettings? _sharedSiteSettings;
- private static ILogger? _sharedLogger;
- private static string? _sharedTestClientId;
- private static string? _sharedTestClientSecret;
- private static string? _sharedTestingUserName;
- private static string? _sharedTestingUserPassword;
- private static string? _sharedTestingUserEmail;
- private static RecordingSmtpClientFactory? _sharedSmtpClientFactory;
- public IConfiguration? Configuration { get; private set; }
- public string? TestClientId { get; private set; }
- public string? TestClientSecret { get; set; }
- public string? TestingUserName { get; private set; }
- public string? TestingUserPassword { get; private set; }
- public string? TestingUserEmail { get; set; }
- public string? ProtectedTestingApiKey { get; internal set; }
- public ApplicationUser? TestingUser { get; private set; }
- public bool DbCreated { get; internal set; }
- public SiteSettings? SiteSettings { get; set; }
- public RecordingSmtpClientFactory? SmtpClientFactory { get; private set; }
- public ILogger? Logger { get; internal set; }
-
- protected override WebApplication BuildApp(WebApplicationBuilder builder)
+ [CollectionDefinition("Yavsc Server")]
+ public class WebServerFixture : IDisposable
{
- // WebApplication.CreateBuilder defaults WebRootPath to
- // {ContentRoot}/wwwroot. The test assembly runs from
- // src/Yavsc.Org.Tests/bin/.../, which has no wwwroot of
- // its own — so point the host at the Yavsc.Org project's
- // wwwroot so that ConfigurePipeline's static-assets middleware
- // can resolve it. The AddConfiguration extension takes care of
- // that plus the in-memory overrides below.
- builder.AddConfiguration(null).AddInMemoryCollection(new Dictionary
- {
- [$"ConnectionStrings:{YavscConstants.YavscConnectionStringName}"] = "InMemory",
- // SMTP test config: UserName non-null so MailSender
- // exercises the Authenticate branch — the
- // RecordingSmtpClient captures it.
- ["Smtp:Host"] = "smtp.test.local",
- ["Smtp:Port"] = "465",
- ["Smtp:UserName"] = "test-user",
- ["Smtp:Password"] = "test-pass",
- });
+ 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 RecordingSmtpClientFactory? _sharedSmtpClientFactory;
+ private static IServiceProvider? _sharedServices;
+ private static IConfiguration? _sharedConfiguration;
+ private static SiteSettings? _sharedSiteSettings;
+ private static Microsoft.Extensions.Logging.ILogger? _sharedLogger;
- Configuration = builder.Configuration;
+ public List Addresses { get; private set; } = new List();
+ public Microsoft.Extensions.Logging.ILogger? Logger { get; internal set; }
- // Swap the production authorization policy provider for
- // TestAuthPolicyProvider BEFORE ConfigureWebAppServices
- // runs. ConfigureWebAppServices calls builder.Build() at
- // the end, which freezes the service collection. Tests
- // can satisfy [Authorize("AdministratorOnly")] (and any
- // other policy that requires a role) by sending an
- // X-Test-Role header; the production policy is replaced
- // by the test one via the last-write-wins semantics of
- // IServiceCollection.AddSingleton.
- builder.Services.AddSingleton();
+ private SiteSettings? siteSettings;
- // Replace the production ISmtpClientFactory (added later
- // by ConfigureWebAppServices via TryAddSingleton) with a
- // recording fake. By pre-registering here, the prod
- // TryAdd becomes a no-op and tests get a single shared
- // fake they can assert against.
- var smtpFactory = new RecordingSmtpClientFactory();
- builder.Services.AddSingleton(smtpFactory);
- _sharedSmtpClientFactory = smtpFactory;
+ public IConfiguration? Configuration { get; private set; }
- var app = builder.ConfigureWebAppServices();
- SiteSettings = app.Services.GetRequiredService>().Value;
+ public string? TestClientId { get; private set; }
- using (var migrationScope = app.Services.CreateScope())
+ 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 bool DbCreated { get; internal set; }
+ public SiteSettings? SiteSettings { get => siteSettings; set => siteSettings = value; }
+ public string? TestClientSecret { get; set; }
+ public string? TestingUserEmail { get; set; }
+ public RecordingSmtpClientFactory? SmtpClientFactory { get; private set; }
+ public WebServerFixture()
{
- var db = migrationScope.ServiceProvider.GetRequiredService();
- db.Database.EnsureDeleted();
- db.Database.EnsureCreated();
- TestingUserName = "Tester";
- TestingUserPassword = "Test123!";
- TestClientId = "testClientId";
- TestingUserEmail = "test@no-reply.com";
- TestingUser = null;
- TestClientSecret = Guid.CreateVersion7().ToString();
- EnsureUser(TestingUserName, TestingUserPassword, TestingUserEmail, migrationScope);
- AddAuthorizedClient(migrationScope, TestClientId, TestClientSecret);
- TestingUser = db.Users.FirstOrDefaultAsync(u => u.UserName == TestingUserName).Result;
-
- // Add test API scope if it doesn't exist
- var testScope = db.ApiScopes.FirstOrDefault(s => s.Name == "test");
- if (testScope == null)
+ lock (_sync)
{
- db.ApiScopes.Add(new IdentityServer8.EntityFramework.Entities.ApiScope
+ _instanceCount++;
+ if (!_isInitialized)
{
- Name = "test",
- Enabled = true,
- DisplayName = "Test API Scope",
- Description = "Scope for testing purposes",
- UserClaims = new List
- {
- new IdentityServer8.EntityFramework.Entities.ApiScopeClaim { Type = "role" },
- new IdentityServer8.EntityFramework.Entities.ApiScopeClaim { Type = "email" }
- }
- });
- // Add a basic API resource for the test scope
- var apiResource = new IdentityServer8.EntityFramework.Entities.ApiResource
- {
- Name = "testapi",
- DisplayName = "Test API",
- Enabled = true,
- Scopes = new List
- {
- new IdentityServer8.EntityFramework.Entities.ApiResourceScope { Scope = "test" }
- }
- };
- db.ApiResources.Add(apiResource);
- db.SaveChanges();
+ SetupHost().Wait();
+ _isInitialized = true;
+ }
+
+ CopySharedState();
}
}
- _sharedConfiguration = Configuration;
- _sharedSiteSettings = SiteSettings;
- _sharedTestClientId = TestClientId;
- _sharedTestClientSecret = TestClientSecret;
- _sharedTestingUserName = TestingUserName;
- _sharedTestingUserPassword = TestingUserPassword;
- _sharedTestingUserEmail = TestingUserEmail;
- _sharedLogger = app.Services.GetRequiredService().CreateLogger();
- Logger = _sharedLogger;
- SmtpClientFactory = smtpFactory;
-
- return app;
- }
-
- protected override async Task ConfigurePipelineAsync(WebApplication app)
- {
- // The MSBuild target CopyYavscOrgStaticAssets in
- // Yavsc.Org.Tests.csproj mirrors the Yavsc.Org static
- // assets manifest into the test bin directory. Call
- // MapStaticAssets() with the explicit path so the test
- // host resolves the manifest by file location rather
- // than by {AssemblyName}.staticwebassets.* convention
- // (which would look for Yavsc.Org.Tests.staticwebassets.*,
- // a file we don't produce).
- var testRuntimeManifest = Path.Combine(AppContext.BaseDirectory,
- "Yavsc.Org.staticwebassets.runtime.json");
- return await app.ConfigurePipeline(testRuntimeManifest);
- }
-
- protected override void CopySpecialisedSharedState()
- {
- TestClientId = _sharedTestClientId;
- TestClientSecret = _sharedTestClientSecret;
- TestingUserName = _sharedTestingUserName;
- TestingUserPassword = _sharedTestingUserPassword;
- TestingUserEmail = _sharedTestingUserEmail;
- SmtpClientFactory = _sharedSmtpClientFactory;
- Configuration = _sharedConfiguration;
- SiteSettings = _sharedSiteSettings;
- Logger = _sharedLogger;
- }
-
- private void AddAuthorizedClient(IServiceScope scope, string testClientId, string testClientSecret)
- {
- var configDb = scope.ServiceProvider.GetRequiredService();
- if (configDb == null)
- throw new InvalidOperationException("ConfigurationDbContext is not available for IdentityServer client seeding.");
-
- Client testingClient = new Client
+ public void Dispose()
{
- ClientId = testClientId,
- AccessTokenLifetime = 3600000,
- AccessTokenType = 1,
- ClientName = "Testing client",
- Enabled = true,
- RequireClientSecret = true
- };
- configDb.Set().Add(testingClient);
- configDb.SaveChanges();
-
- var apiScope = new IdentityServer8.EntityFramework.Entities.ApiScope
- {
- Name = "test",
- DisplayName = "Test Scope",
- Description = "Scope for testing",
- Enabled = true,
- Required = false,
- ShowInDiscoveryDocument = true,
- Emphasize = false
- };
- configDb.Set().Add(apiScope);
-
- ClientSecret secret = new ClientSecret
- {
- Value = testClientSecret.Sha256(),
- Type = IdentityServer8.IdentityServerConstants.SecretTypes.SharedSecret,
- ClientId = testingClient.Id
- };
- configDb.Set().Add(secret);
-
- configDb.Set().Add(new ClientGrantType
- {
- ClientId = testingClient.Id,
- GrantType = "client_credentials"
- });
- configDb.Set().Add(new ClientGrantType
- {
- ClientId = testingClient.Id,
- GrantType = "password"
- });
- configDb.Set().Add(new ClientScope
- {
- ClientId = testingClient.Id,
- Scope = "test"
- });
-
- configDb.SaveChanges();
- }
-
- public void EnsureUser(string testingUserName, string password, string email, IServiceScope scope)
- {
- if (TestingUser == null)
- {
- var userManager = scope.ServiceProvider.GetRequiredService>();
-
- TestingUser = new ApplicationUser
+ lock (_sync)
{
- UserName = testingUserName,
- Email = testingUserName + "@example.com",
- EmailConfirmed = true
+ _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;
+ _sharedSmtpClientFactory = 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;
+ SmtpClientFactory = _sharedSmtpClientFactory;
+ }
+
+ public async Task SetupHost()
+ {
+ var builder = WebApplication.CreateBuilder();
+
+ // WebApplication.CreateBuilder defaults WebRootPath to
+ // {ContentRoot}/wwwroot. The test assembly runs from
+ // src/Yavsc.Org.Tests/bin/.../, which has no wwwroot of
+ // its own — so point the host at the Yavsc.Org project's
+ // wwwroot so that ConfigurePipeline's
+
+
+ builder.AddConfiguration(null).AddInMemoryCollection(new Dictionary
+ {
+ [$"ConnectionStrings:{YavscConstants.YavscConnectionStringName}"] = "InMemory",
+ // SMTP test config: UserName non-null so MailSender
+ // exercises the Authenticate branch — the
+ // RecordingSmtpClient captures it.
+ ["Smtp:Host"] = "smtp.test.local",
+ ["Smtp:Port"] = "465",
+ ["Smtp:UserName"] = "test-user",
+ ["Smtp:Password"] = "test-pass",
+ });
+
+ // 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;
+
+ // Swap the production authorization policy provider for
+ // TestAuthPolicyProvider BEFORE ConfigureWebAppServices
+ // runs. ConfigureWebAppServices calls builder.Build() at
+ // the end, which freezes the service collection. Tests
+ // can satisfy [Authorize("AdministratorOnly")] (and any
+ // other policy that requires a role) by sending an
+ // X-Test-Role header; the production policy is replaced
+ // by the test one via the last-write-wins semantics of
+ // IServiceCollection.AddSingleton.
+ builder.Services.AddSingleton();
+
+ // Replace the production ISmtpClientFactory (added later
+ // by ConfigureWebAppServices via TryAddSingleton) with a
+ // recording fake. By pre-registering here, the prod
+ // TryAdd becomes a no-op and tests get a single shared
+ // fake they can assert against.
+ var smtpFactory = new RecordingSmtpClientFactory();
+ builder.Services.AddSingleton(smtpFactory);
+ _sharedSmtpClientFactory = smtpFactory;
+
+ _app = builder.ConfigureWebAppServices();
+
+ // Note: a recording ISmtpClientFactory is registered
+ // BEFORE ConfigureWebAppServices() above (further up in
+ // this method) so the production TryAddSingleton inside
+ // ConfigureWebAppServices becomes a no-op.
+ // _sharedSmtpClientFactory is captured there.
+
+ // The MSBuild target CopyYavscOrgStaticAssets in
+ // Yavsc.Org.Tests.csproj mirrors the Yavsc.Org static
+ // assets manifest into the test bin directory. Call
+ // MapStaticAssets() with the explicit path so the test
+ // host resolves the manifest by file location rather
+ // than by {AssemblyName}.staticwebassets.* convention
+ // (which would look for Yavsc.Org.Tests.staticwebassets.*,
+ // a file we don't produce).
+ var testRuntimeManifest = Path.Combine(AppContext.BaseDirectory,
+ "Yavsc.Org.staticwebassets.runtime.json");
+
+ Services = _app.Services;
+ SiteSettings = _app.Services.GetRequiredService>().Value;
+
+ using (var migrationScope = _app.Services.CreateScope())
+ {
+ var db = migrationScope.ServiceProvider.GetRequiredService();
+ db.Database.EnsureDeleted();
+ db.Database.EnsureCreated();
+ TestingUserName = "Tester";
+ TestingUserPassword = "Test123!";
+ TestClientId = "testClientId";
+ TestingUserEmail = "test@no-reply.com";
+ TestingUser = null;
+ TestClientSecret = Guid.CreateVersion7().ToString();
+ EnsureUser(TestingUserName, TestingUserPassword, TestingUserEmail, migrationScope);
+ AddAuthorizedClient(migrationScope, TestClientId, TestClientSecret);
+ TestingUser = await db.Users.FirstOrDefaultAsync(u => u.UserName == TestingUserName);
+
+ // Add test API scope if it doesn't exist
+ var testScope = db.ApiScopes.FirstOrDefault(s => s.Name == "test");
+ if (testScope == null)
+ {
+ db.ApiScopes.Add(new IdentityServer8.EntityFramework.Entities.ApiScope
+ {
+ Name = "test",
+ Enabled = true,
+ DisplayName = "Test API Scope",
+ Description = "Scope for testing purposes",
+ UserClaims = new List
+ {
+ new IdentityServer8.EntityFramework.Entities.ApiScopeClaim { Type = "role" },
+ new IdentityServer8.EntityFramework.Entities.ApiScopeClaim { Type = "email" }
+ }
+ });
+
+ // Add a basic API resource for the test scope
+ var apiResource = new IdentityServer8.EntityFramework.Entities.ApiResource
+ {
+ Name = "testapi",
+ DisplayName = "Test API",
+ Enabled = true,
+ Scopes = new List
+ {
+ new IdentityServer8.EntityFramework.Entities.ApiResourceScope
+ {
+ Scope = "test"
+ }
+ }
+ };
+ db.ApiResources.Add(apiResource);
+ db.SaveChanges();
+ }
+ }
+
+
+
+ _app = await _app.ConfigurePipeline(testRuntimeManifest);
+
+ 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 server = _app.Services.GetRequiredService();
+ var addressFeatures = server.Features.Get();
+
+ if (addressFeatures?.Addresses != null)
+ {
+ _sharedAddresses.Clear();
+ foreach (var address in addressFeatures.Addresses)
+ {
+ _sharedAddresses.Add(address);
+ Addresses.Add(address);
+ }
+ }
+ }
+
+ private void AddAuthorizedClient(IServiceScope scope, string testClientId, string testClientSecret)
+ {
+ var configDb = scope.ServiceProvider.GetRequiredService();
+ if (configDb == null)
+ throw new InvalidOperationException("ConfigurationDbContext is not available for IdentityServer client seeding.");
+
+ Client testingClient = new Client
+ {
+ ClientId = testClientId,
+ AccessTokenLifetime = 3600000,
+ AccessTokenType = 1,
+ ClientName = "Testing client",
+ Enabled = true,
+ RequireClientSecret = true
};
+ configDb.Set().Add(testingClient);
+ configDb.SaveChanges();
- var result = userManager.CreateAsync(TestingUser, password).Result;
+ var apiScope = new IdentityServer8.EntityFramework.Entities.ApiScope
+ {
+ Name = "test",
+ DisplayName = "Test Scope",
+ Description = "Scope for testing",
+ Enabled = true,
+ Required = false,
+ ShowInDiscoveryDocument = true,
+ Emphasize = false
+ };
+ configDb.Set().Add(apiScope);
- Assert.True(result.Succeeded);
+ ClientSecret secret = new ClientSecret
+ {
+ Value = testClientSecret.Sha256(),
+ Type = IdentityServer8.IdentityServerConstants.SecretTypes.SharedSecret,
+ ClientId = testingClient.Id
+ };
+ configDb.Set().Add(secret);
- ApplicationDbContext dbContext = scope.ServiceProvider.GetRequiredService();
- TestingUser = dbContext.Users.FirstOrDefault(u => u.UserName == testingUserName);
+ configDb.Set().Add(new ClientGrantType
+ {
+ ClientId = testingClient.Id,
+ GrantType = "client_credentials"
+ });
+ configDb.Set().Add(new ClientGrantType
+ {
+ ClientId = testingClient.Id,
+ GrantType = "password"
+ });
+ configDb.Set().Add(new ClientScope
+ {
+ ClientId = testingClient.Id,
+ Scope = "test"
+ });
+
+ configDb.SaveChanges();
+ }
+
+ public void EnsureUser(string testingUserName, string password, string email, IServiceScope scope)
+ {
+ if (TestingUser == null)
+ {
+
+ var userManager =
+ scope.ServiceProvider.GetRequiredService>();
+
+ TestingUser = new ApplicationUser
+ {
+ UserName = testingUserName,
+ Email = testingUserName + "@example.com",
+ EmailConfirmed = true
+ };
+
+ var result = userManager.CreateAsync(TestingUser, password).Result;
+
+ Assert.True(result.Succeeded);
+
+ ApplicationDbContext dbContext =
+ scope.ServiceProvider.GetRequiredService();
+ 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/src/Yavsc.Org.Tests/Yavsc.Org.Tests.csproj b/src/Yavsc.Org.Tests/Yavsc.Org.Tests.csproj
index 79a6bae1..68c17407 100644
--- a/src/Yavsc.Org.Tests/Yavsc.Org.Tests.csproj
+++ b/src/Yavsc.Org.Tests/Yavsc.Org.Tests.csproj
@@ -46,7 +46,6 @@
-
diff --git a/src/Yavsc.Tests.Shared/Directory.Packages.props b/src/Yavsc.Tests.Shared/Directory.Packages.props
deleted file mode 100644
index d2342f79..00000000
--- a/src/Yavsc.Tests.Shared/Directory.Packages.props
+++ /dev/null
@@ -1,8 +0,0 @@
-
-
-
-
diff --git a/src/Yavsc.Tests.Shared/WebHostFixture.cs b/src/Yavsc.Tests.Shared/WebHostFixture.cs
deleted file mode 100644
index fb2e5984..00000000
--- a/src/Yavsc.Tests.Shared/WebHostFixture.cs
+++ /dev/null
@@ -1,172 +0,0 @@
-using Microsoft.AspNetCore.Builder;
-using Microsoft.AspNetCore.Hosting;
-using Microsoft.AspNetCore.Hosting.Server;
-using Microsoft.AspNetCore.Hosting.Server.Features;
-using Microsoft.Extensions.DependencyInjection;
-using System.Net;
-using System.Security.Cryptography;
-using System.Security.Cryptography.X509Certificates;
-
-namespace Yavsc.Tests.Shared;
-
-///
-/// Base class for ASP.NET Core integration test hosts. Provides the
-/// cross-cutting plumbing shared by every test fixture in the
-/// repository:
-///
-///
-/// - Kestrel with a self-signed HTTPS certificate
-/// on a dynamically-allocated port (no port collisions between
-/// parallel xUnit test classes).
-/// - A per-process single-instance host initialised
-/// on first construction and torn down when the last fixture is
-/// disposed — same lazy + lock + count pattern as the original Org
-/// fixture, lifted out of the specialisation.
-/// - Address discovery via
-/// .
-///
-///
-/// The actual service registration, middleware pipeline and route
-/// mapping are the responsibility of the subclass, through
-/// .
-///
-public abstract class WebHostFixture : IDisposable
-{
- private static readonly Lazy _selfSignedCertificate =
- new Lazy(CreateSelfSignedCertificate);
- private static readonly object _sync = new object();
- private static WebApplication? _app;
- private static bool _isInitialized;
- private static int _instanceCount;
- private static readonly List _sharedAddresses = new();
- private static IServiceProvider? _sharedServices;
-
- /// HTTPS listen URLs the host bound to.
- public IReadOnlyList Addresses { get; private set; } = Array.Empty();
-
- /// The DI service provider of the running host. Read from
- /// the shared static slot so every fixture instance (xUnit creates
- /// one per IClassFixture) sees the same provider after the
- /// first initialisation. Throws if is
- /// false.
- public IServiceProvider Services => _sharedServices
- ?? throw new InvalidOperationException(
- "WebHostFixture has not been initialised. Call InitializeAsync first.");
-
- /// True once has completed
- /// successfully and the host is running.
- public bool IsInitialized { get; private set; }
-
- protected WebHostFixture()
- {
- lock (_sync)
- {
- _instanceCount++;
- if (!_isInitialized)
- {
- InitializeAsync().GetAwaiter().GetResult();
- _isInitialized = true;
- }
- CopySharedState();
- CopySpecialisedSharedState();
- }
- }
-
- private void CopySharedState()
- {
- Addresses = _sharedAddresses.ToArray();
- }
-
- /// Hook for specialisations to copy any other shared
- /// state (test client credentials, user names, factories, etc.)
- /// from the static slots exposed by the base class onto instance
- /// properties. Called once per fixture construction, after
- /// has populated the shared state
- /// the first time.
- protected virtual void CopySpecialisedSharedState() { }
-
- /// Specialisations register their services and middleware
- /// here. The base class has already configured Kestrel HTTPS on a
- /// dynamic port — do not bind additional listeners.
- /// The
- /// configured with Kestrel HTTPS on a dynamic port and the shared
- /// self-signed certificate.
- /// The fully built , ready
- /// for ConfigurePipeline + StartAsync.
- protected abstract WebApplication BuildApp(WebApplicationBuilder builder);
-
- /// Apply the production pipeline to .
- /// Defaults to identity + routing + auth + MapStaticAssets; override
- /// only if your host needs a different shape.
- protected virtual async Task ConfigurePipelineAsync(WebApplication app)
- {
- await Task.CompletedTask;
- return app;
- }
-
- private async Task InitializeAsync()
- {
- var builder = WebApplication.CreateBuilder();
-
- builder.WebHost.ConfigureKestrel(options =>
- {
- options.Listen(IPAddress.Loopback, 0, listenOptions =>
- {
- listenOptions.UseHttps(_selfSignedCertificate.Value);
- });
- });
-
- var app = BuildApp(builder);
- app = await ConfigurePipelineAsync(app);
- await app.StartAsync();
-
- _app = app;
- _sharedServices = app.Services;
-
- var server = app.Services.GetRequiredService();
- var addressFeatures = server.Features.Get();
- _sharedAddresses.Clear();
- if (addressFeatures?.Addresses is not null)
- {
- foreach (var address in addressFeatures.Addresses)
- {
- _sharedAddresses.Add(address);
- }
- }
- Addresses = _sharedAddresses.ToArray();
- IsInitialized = true;
- }
-
- public virtual void Dispose()
- {
- lock (_sync)
- {
- _instanceCount--;
- if (_instanceCount == 0 && _app is not null)
- {
- _app.StopAsync().GetAwaiter().GetResult();
- _app = null;
- _isInitialized = false;
- _sharedAddresses.Clear();
- _sharedServices = null;
- }
- }
- }
-
- 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));
-
- return certRequest.CreateSelfSigned(
- new DateTimeOffset(DateTime.UtcNow.AddDays(-1)),
- new DateTimeOffset(DateTime.UtcNow.AddDays(3650)));
- }
-}
diff --git a/src/Yavsc.Tests.Shared/Yavsc.Tests.Shared.csproj b/src/Yavsc.Tests.Shared/Yavsc.Tests.Shared.csproj
deleted file mode 100644
index b78ce03e..00000000
--- a/src/Yavsc.Tests.Shared/Yavsc.Tests.Shared.csproj
+++ /dev/null
@@ -1,21 +0,0 @@
-
-
- net10.0
- enable
- enable
- false
- Yavsc.Tests.Shared
-
-
-
-
-
-
-
-