using System.Text; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Builder; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Storage; using Microsoft.Extensions.DependencyInjection; using Microsoft.IdentityModel.Tokens; using Yavsc.Blogs.Controllers; using Yavsc.Models; using Yavsc.Services; using Yavsc.Tests.Shared; namespace Yavsc.Blogs.Tests; /// /// Test host for the Yavsc.Blogs API surface. Specialisation of /// that wires up only the bits the /// blog API actually depends on: /// /// /// An in-memory /// (the real one — no mock) so BlogSpotService.Index can run /// against an empty table and return an empty list. /// A trivial /// stub: the GET index path doesn't read the file system, so any /// implementation is fine. /// The real BlogSpotService, which calls /// IAuthorizationService.AuthorizeAsync(user, blog, new EditPermission()) /// on PUT. The fixture registers the real /// so the resource-based ownership /// check runs end-to-end; tests that want a 204 PUT must sign a /// JWT whose sub matches the post's AuthorId. /// A real AddJwtBearer with HS256, /// sharing its with the /// token issuer. The production OIDC discovery path is bypassed: /// the test host validates tokens locally, against the static /// signing key, so no IdP is required to exercise auth. /// The production BlogScope policy /// (RequireAuthenticatedUser + RequireClaim("scope", "blogs")) /// registered verbatim. Tests that omit the bearer header exercise /// the unauthenticated path and get 401. /// /// /// No IdentityServer, no SMTP, no static assets — the Org fixture /// owns all of that and we don't need any of it for blog integration /// tests. /// public sealed class BlogsWebServerFixture : WebHostFixture { private InMemoryDatabaseRoot? _inMemoryRoot; protected override WebApplication BuildApp(WebApplicationBuilder builder) { // Use the real ApplicationDbContext with an in-memory store. // BlogSpotService reads _context.BlogSpot directly, so any // attempt to mock it would be wasted work; the real service // against an empty table returns an empty list, which is // exactly what the first test wants to assert. // // Share a single InMemoryDatabaseRoot across the test // lifetime so POST + GET on the same fixture see the same // store. Without the root, EF Core's In-Memory provider // creates independent stores per DbContext in some // configurations, and the second request would see an // empty list even after the first wrote a row. _inMemoryRoot = new InMemoryDatabaseRoot(); builder.Services.AddDbContext(opt => opt.UseInMemoryDatabase("Yavsc.Blogs.Tests", _inMemoryRoot)); // Trivial file-system auth: the GET index path never calls // into it, but the DI container needs an instance. builder.Services.AddSingleton( new NoopFileSystemAuthManager()); // Real BlogSpotService — same instance the production host // builds (ApplicationDbContext, IAuthorizationService, // IFileSystemAuthManager). With PermissionHandler registered // below, Modify() now answers "is the caller the author of // the post?" for real, which is exactly what we want to // assert in the PUT tests. builder.Services.AddScoped(); // The real PermissionHandler: BlogSpotService calls // IAuthorizationService.AuthorizeAsync(user, blog, new // EditPermission()) on Modify, and PermissionHandler // resolves it via IsOwner(user, blog) — i.e. blog.AuthorId // == user.GetUserId(). To PUT a post, the test JWT must // carry sub == post.AuthorId. builder.Services.AddScoped(); // The BlogApiController is reached through MVC. AddControllers() // by default scans the test assembly only; we explicitly add the // Yavsc.Blogs application part so the controller is discovered // and routed. builder.Services.AddControllers() .AddApplicationPart(typeof(BlogApiController).Assembly); // Production BlogScope policy, verbatim. Two requirements: // 1. RequireAuthenticatedUser: a request with no bearer // token (or an invalid one) will be rejected. // 2. RequireClaim("scope", "blogs"): the JWT must carry a // "scope" claim whose value is "blogs". // TestTokenIssuer.Issue() defaults to scope=blogs; the // GetBlog_returns_401_when_no_token test omits the token // entirely and asserts the policy fails closed. builder.Services.AddAuthorization(opt => { opt.AddPolicy("BlogScope", policy => { policy.RequireAuthenticatedUser() .RequireClaim("scope", "blogs"); }); }); // Real JWT Bearer authentication, sharing the signing key // with TestTokenIssuer. No Authority → no OIDC discovery, // no IdP roundtrip; the middleware validates the signature // and the standard claims against the static configuration // below. Production uses AddYavscJwtBearer with an IdP, but // for the unit-test host that path is unwanted coupling. builder.Services.AddAuthentication("Bearer") .AddJwtBearer("Bearer", options => { options.IncludeErrorDetails = true; // MapInboundClaims = false here mirrors the // JwtSecurityTokenHandler.DefaultInboundClaimTypeMap // .Clear() in TestTokenIssuer: the validation // pipeline must not rewrite "sub" to // ClaimTypes.NameIdentifier, otherwise the // PermissionHandler ownership check sees a null // user id and rejects every PUT. options.MapInboundClaims = false; options.TokenValidationParameters = new TokenValidationParameters { ValidateIssuer = true, ValidIssuer = TestTokenIssuer.Issuer, ValidateAudience = false, ValidateLifetime = true, ValidateIssuerSigningKey = true, IssuerSigningKey = TestTokenIssuer.SigningKey, // "sub" stays "sub" (MapInboundClaims only // remaps long Microsoft claim URIs, not sub). // UserHelpers.GetUserId reads sub directly. NameClaimType = "sub", RoleClaimType = YavscConstants.RoleClaimType, }; }); return builder.Build(); } protected override async Task ConfigurePipelineAsync(WebApplication app) { app.UseRouting(); app.UseAuthentication(); app.UseAuthorization(); app.MapControllers(); await Task.CompletedTask; return app; } /// Trivial stub. The /// blog API endpoints exercised by the first tests don't read the /// file system, so the implementation can be a no-op. private sealed class NoopFileSystemAuthManager : IFileSystemAuthManager { public FileAccessRight GetFilePathAccess(System.Security.Claims.ClaimsPrincipal user, string fileRelativePath) => FileAccessRight.None; public void SetAccess(long circleId, string normalizedFullPath, FileAccessRight access) { } } }