diff --git a/Directory.Packages.props b/Directory.Packages.props
index 021c5e53..e7f71232 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -9,8 +9,10 @@
+
+
diff --git a/doc/architecture/postit-oidc.md b/doc/architecture/postit-oidc.md
index ee003b41..ddedbfde 100644
--- a/doc/architecture/postit-oidc.md
+++ b/doc/architecture/postit-oidc.md
@@ -56,6 +56,7 @@ 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 dbe86ca3..d489be60 100644
--- a/src/PostIt/PostIt/Services/BlogApiClient.cs
+++ b/src/PostIt/PostIt/Services/BlogApiClient.cs
@@ -15,6 +15,16 @@ 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
@@ -22,7 +32,7 @@ namespace PostIt.Services;
///
public sealed class BlogApiClient
{
- private const string DefaultPathPrefix = "api/blog";
+ private const string DefaultPathPrefix = "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 21b8b22b..073ebf7a 100644
--- a/src/PostIt/PostIt/ViewModels/MainPageViewModel.cs
+++ b/src/PostIt/PostIt/ViewModels/MainPageViewModel.cs
@@ -128,9 +128,28 @@ 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)
{
- StatusMessage = "A post must be selected before saving.";
+ 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}.";
+ }
+ });
return;
}
@@ -176,19 +195,6 @@ 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()
{
@@ -253,7 +259,6 @@ 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 c09c2f7a..247bf28e 100644
--- a/src/PostIt/PostIt/Views/MainPage.axaml
+++ b/src/PostIt/PostIt/Views/MainPage.axaml
@@ -32,7 +32,6 @@
-
-
-
diff --git a/src/Yavsc.Org.Tests/TestUserMiddleware.cs b/src/Yavsc.Org.Tests/TestUserMiddleware.cs
index c6348625..b88a75e3 100644
--- a/src/Yavsc.Org.Tests/TestUserMiddleware.cs
+++ b/src/Yavsc.Org.Tests/TestUserMiddleware.cs
@@ -2,6 +2,7 @@ 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 49d3b663..c51f3f8e 100644
--- a/src/Yavsc.Org.Tests/TestWebApplicationFactory.cs
+++ b/src/Yavsc.Org.Tests/TestWebApplicationFactory.cs
@@ -3,6 +3,7 @@ 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 402211f5..ed0bd69d 100644
--- a/src/Yavsc.Org.Tests/WebServerFixture.cs
+++ b/src/Yavsc.Org.Tests/WebServerFixture.cs
@@ -1,9 +1,7 @@
-
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;
@@ -12,372 +10,272 @@ 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;
-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
{
+ 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;
- [CollectionDefinition("Yavsc Server")]
- public class WebServerFixture : IDisposable
+ 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)
{
- 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;
-
- public List Addresses { get; private set; } = new List();
- public Microsoft.Extensions.Logging.ILogger? Logger { get; internal set; }
-
- private SiteSettings? siteSettings;
-
- public IConfiguration? Configuration { 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 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()
- {
- lock (_sync)
+ // 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
{
- _instanceCount++;
- if (!_isInitialized)
- {
+ [$"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",
+ });
- SetupHost().Wait();
- _isInitialized = true;
- }
+ Configuration = builder.Configuration;
- CopySharedState();
- }
- }
+ // 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();
- public void Dispose()
+ // 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;
+
+ var app = builder.ConfigureWebAppServices();
+ SiteSettings = app.Services.GetRequiredService>().Value;
+
+ using (var migrationScope = app.Services.CreateScope())
{
- lock (_sync)
+ 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)
{
- _instanceCount--;
- if (_instanceCount == 0 && _app != null)
+ db.ApiScopes.Add(new IdentityServer8.EntityFramework.Entities.ApiScope
{
- _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",
+ 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" }
+ }
});
- // Configure Kestrel for HTTPS with self-signed certificate on a dynamic port
- builder.WebHost.ConfigureKestrel(options =>
- {
- options.Listen(IPAddress.Loopback, 0, listenOptions =>
+ // Add a basic API resource for the test scope
+ var apiResource = new IdentityServer8.EntityFramework.Entities.ApiResource
{
- 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 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
- {
- UserName = testingUserName,
- Email = testingUserName + "@example.com",
- EmailConfirmed = true
+ Name = "testapi",
+ DisplayName = "Test API",
+ Enabled = true,
+ Scopes = new List
+ {
+ new IdentityServer8.EntityFramework.Entities.ApiResourceScope { Scope = "test" }
+ }
};
-
- var result = userManager.CreateAsync(TestingUser, password).Result;
-
- Assert.True(result.Succeeded);
-
- ApplicationDbContext dbContext =
- scope.ServiceProvider.GetRequiredService();
- TestingUser = dbContext.Users.FirstOrDefault(u => u.UserName == testingUserName);
+ db.ApiResources.Add(apiResource);
+ db.SaveChanges();
}
}
- private static X509Certificate2 CreateSelfSignedCertificate()
+ _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
{
- var rsa = RSA.Create(2048);
- var certRequest = new CertificateRequest("CN=localhost", rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
+ ClientId = testClientId,
+ AccessTokenLifetime = 3600000,
+ AccessTokenType = 1,
+ ClientName = "Testing client",
+ Enabled = true,
+ RequireClientSecret = true
+ };
+ configDb.Set().Add(testingClient);
+ configDb.SaveChanges();
- certRequest.CertificateExtensions.Add(
- new X509KeyUsageExtension(X509KeyUsageFlags.DataEncipherment | X509KeyUsageFlags.KeyEncipherment | X509KeyUsageFlags.DigitalSignature, false));
+ 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);
- certRequest.CertificateExtensions.Add(
- new X509EnhancedKeyUsageExtension(
- new OidCollection { new Oid("1.3.6.1.5.5.7.3.1") }, false));
+ ClientSecret secret = new ClientSecret
+ {
+ Value = testClientSecret.Sha256(),
+ Type = IdentityServer8.IdentityServerConstants.SecretTypes.SharedSecret,
+ ClientId = testingClient.Id
+ };
+ configDb.Set().Add(secret);
- var certificate = certRequest.CreateSelfSigned(new DateTimeOffset(DateTime.UtcNow.AddDays(-1)), new DateTimeOffset(DateTime.UtcNow.AddDays(3650)));
- return certificate;
+ 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);
}
}
}
diff --git a/src/Yavsc.Org.Tests/Yavsc.Org.Tests.csproj b/src/Yavsc.Org.Tests/Yavsc.Org.Tests.csproj
index 68c17407..79a6bae1 100644
--- a/src/Yavsc.Org.Tests/Yavsc.Org.Tests.csproj
+++ b/src/Yavsc.Org.Tests/Yavsc.Org.Tests.csproj
@@ -46,6 +46,7 @@
+
diff --git a/src/Yavsc.Tests.Shared/Directory.Packages.props b/src/Yavsc.Tests.Shared/Directory.Packages.props
new file mode 100644
index 00000000..d2342f79
--- /dev/null
+++ b/src/Yavsc.Tests.Shared/Directory.Packages.props
@@ -0,0 +1,8 @@
+
+
+
+
diff --git a/src/Yavsc.Org.Tests/TestAuthPolicyProvider.cs b/src/Yavsc.Tests.Shared/TestAuthPolicyProvider.cs
similarity index 62%
rename from src/Yavsc.Org.Tests/TestAuthPolicyProvider.cs
rename to src/Yavsc.Tests.Shared/TestAuthPolicyProvider.cs
index 1391a3a2..8fd416c6 100644
--- a/src/Yavsc.Org.Tests/TestAuthPolicyProvider.cs
+++ b/src/Yavsc.Tests.Shared/TestAuthPolicyProvider.cs
@@ -1,24 +1,30 @@
using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Options;
+using System.Security.Claims;
-namespace Yavsc.Org.Tests;
+namespace Yavsc.Tests.Shared;
///
/// Authorization policy provider used by integration tests. Replaces the
-/// 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.
+/// 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.
///
-/// 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.
+/// 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.
///
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;
@@ -42,26 +48,21 @@ 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 Microsoft.AspNetCore.Http.HttpContext;
+ var http = ctx.Resource as 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 System.Security.Claims.ClaimsIdentity(
+ var identity = new ClaimsIdentity(
new[]
{
- new System.Security.Claims.Claim(
+ new Claim(
"http://schemas.microsoft.com/ws/2008/06/identity/claims/role",
role),
},
authenticationType: "TestAuth");
- http.User = new System.Security.Claims.ClaimsPrincipal(identity);
+ http.User = new ClaimsPrincipal(identity);
}
return true;
})
diff --git a/src/Yavsc.Tests.Shared/WebHostFixture.cs b/src/Yavsc.Tests.Shared/WebHostFixture.cs
new file mode 100644
index 00000000..fb2e5984
--- /dev/null
+++ b/src/Yavsc.Tests.Shared/WebHostFixture.cs
@@ -0,0 +1,172 @@
+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
new file mode 100644
index 00000000..b78ce03e
--- /dev/null
+++ b/src/Yavsc.Tests.Shared/Yavsc.Tests.Shared.csproj
@@ -0,0 +1,21 @@
+
+
+ net10.0
+ enable
+ enable
+ false
+ Yavsc.Tests.Shared
+
+
+
+
+
+
+
+