diff --git a/Directory.Packages.props b/Directory.Packages.props index ff3bd4bf..e4b09159 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -9,6 +9,8 @@ + + diff --git a/src/Yavsc.Blogs.Tests/BlogApiTests.cs b/src/Yavsc.Blogs.Tests/BlogApiTests.cs index b2ab171a..658f5107 100644 --- a/src/Yavsc.Blogs.Tests/BlogApiTests.cs +++ b/src/Yavsc.Blogs.Tests/BlogApiTests.cs @@ -12,9 +12,13 @@ namespace Yavsc.Blogs.Tests; /// /// Behavioural tests for BlogApiController. Built on the /// scaffold: in-memory -/// ApplicationDbContext, real BlogSpotService, -/// X-Test-Role for the [Authorize("BlogScope")] -/// attribute. +/// ApplicationDbContext, real BlogSpotService, and a +/// real AddJwtBearer validating HS256 tokens signed by +/// . The production BlogScope +/// policy runs unmodified — sending Authorization: Bearer … +/// with a valid token is what gets a request through, omitting the +/// header (or sending a token signed with the wrong key) gets a +/// 401 back from the framework. /// public sealed class BlogApiTests : IClassFixture { @@ -47,7 +51,13 @@ public sealed class BlogApiTests : IClassFixture private string BlogsUrl => _fixture.Addresses.First(a => a.StartsWith("https://")) + "/api/v1/blog"; - private HttpClient NewClient() + /// Build an authenticated client: a real + /// Authorization: Bearer <jwt> header where the JWT + /// is signed by and carries + /// sub = subject. The production BlogScope policy + /// reads scope=blogs off the same token, so + /// TestTokenIssuer.Issue's default scope is enough. + private HttpClient NewClient(string subject = "tester") { // The fixture's self-signed certificate is not in the user's // trust store, so we accept anything (same pattern as @@ -60,10 +70,27 @@ public sealed class BlogApiTests : IClassFixture { BaseAddress = new Uri(_fixture.Addresses.First(a => a.StartsWith("https://"))) }; - http.DefaultRequestHeaders.Add(TestAuthPolicyProvider.HeaderName, TestAuthPolicyProvider.AdminRole); + http.DefaultRequestHeaders.Authorization = + new System.Net.Http.Headers.AuthenticationHeaderValue( + "Bearer", TestTokenIssuer.Issue(subject)); return http; } + /// Build an unauthenticated client. Used to assert that + /// the BlogScope policy fails closed when no bearer + /// token is presented. + private HttpClient NewAnonymousClient() + { + var handler = new HttpClientHandler + { + ServerCertificateCustomValidationCallback = (_, _, _, _) => true + }; + return new HttpClient(handler) + { + BaseAddress = new Uri(_fixture.Addresses.First(a => a.StartsWith("https://"))) + }; + } + [Fact] public async Task GetBlogs_returns_200_with_empty_list_when_no_posts() { @@ -120,4 +147,99 @@ public sealed class BlogApiTests : IClassFixture Assert.Equal(1, doc.RootElement.GetArrayLength()); Assert.Equal(created.Id, doc.RootElement[0].GetProperty("id").GetInt64()); } + + [Fact] + public async Task GetBlog_returns_401_when_no_token_is_provided() + { + ResetDatabase(); + using var http = NewAnonymousClient(); + + // No Authorization header → the JwtBearer middleware + // produces an unauthenticated principal, the BlogScope + // policy's RequireAuthenticatedUser requirement fails, and + // the framework returns 401. This is the proof that the + // production policy is wired in the test host and not + // short-circuited by a test-only auth bypass. + var response = await http.GetAsync("/api/v1/blog"); + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task PutBlog_with_valid_token_and_owner_returns_204_and_Get_reflects_update() + { + ResetDatabase(); + // The JWT's sub must match the post's AuthorId: + // PermissionHandler.IsOwner checks blog.AuthorId == user.GetUserId(), + // and UserHelpers.GetUserId reads "sub" off the principal. + // A mismatched sub → AuthorizationFailureException → + // Challenge() (401) from the controller. The 204 in this + // test is the proof that the real authorization chain + // accepted the request, end-to-end. + using var http = NewClient(subject: "tester"); + + // Seed a post we can update. + var draft = new BlogPost + { + Id = 0, + Title = "Avant", + AuthorId = "tester", + Article = "Contenu initial.", + DateCreated = DateTime.UtcNow, + DateModified = DateTime.UtcNow + }; + var postResponse = await http.PostAsJsonAsync("/api/v1/blog", draft); + Assert.Equal(HttpStatusCode.Created, postResponse.StatusCode); + + var created = (await postResponse.Content.ReadFromJsonAsync())!; + + // PUT with the server-issued Id; the controller rejects + // mismatched id/blog.Id with 400, so we keep them aligned. + var update = new BlogPost + { + Id = created.Id, + Title = "Après", + AuthorId = created.AuthorId, + Article = created.Article, + DateCreated = created.DateCreated, + DateModified = DateTime.UtcNow + }; + var putResponse = await http.PutAsJsonAsync($"/api/v1/blog/{created.Id}", update); + Assert.Equal(HttpStatusCode.NoContent, putResponse.StatusCode); + + // The list should now reflect the new title. + var listResponse = await http.GetAsync("/api/v1/blog"); + Assert.Equal(HttpStatusCode.OK, listResponse.StatusCode); + using var doc = JsonDocument.Parse(await listResponse.Content.ReadAsStringAsync()); + Assert.Equal(JsonValueKind.Array, doc.RootElement.ValueKind); + Assert.Equal(1, doc.RootElement.GetArrayLength()); + Assert.Equal("Après", doc.RootElement[0].GetProperty("title").GetString()); + } + + [Fact] + public async Task DeleteBlog_removes_a_post_and_Get_returns_an_empty_list() + { + ResetDatabase(); + using var http = NewClient(); + + // Seed a post we can delete. + var draft = new BlogPost + { + Id = 0, + Title = "À supprimer", + AuthorId = "tester", + Article = "Contenu.", + DateCreated = DateTime.UtcNow, + DateModified = DateTime.UtcNow + }; + var postResponse = await http.PostAsJsonAsync("/api/v1/blog", draft); + var created = (await postResponse.Content.ReadFromJsonAsync())!; + + var deleteResponse = await http.DeleteAsync($"/api/v1/blog/{created.Id}"); + Assert.Equal(HttpStatusCode.OK, deleteResponse.StatusCode); + + // The list should now be empty. + var listResponse = await http.GetAsync("/api/v1/blog"); + using var doc = JsonDocument.Parse(await listResponse.Content.ReadAsStringAsync()); + Assert.Equal(0, doc.RootElement.GetArrayLength()); + } } diff --git a/src/Yavsc.Blogs.Tests/BlogsWebServerFixture.cs b/src/Yavsc.Blogs.Tests/BlogsWebServerFixture.cs index e40d0740..6e39fd00 100644 --- a/src/Yavsc.Blogs.Tests/BlogsWebServerFixture.cs +++ b/src/Yavsc.Blogs.Tests/BlogsWebServerFixture.cs @@ -1,8 +1,11 @@ -using System.Security.Claims; +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; @@ -22,12 +25,21 @@ namespace Yavsc.Blogs.Tests; /// A trivial /// stub: the GET index path doesn't read the file system, so any /// implementation is fine. -/// The default -/// from Microsoft.AspNetCore.Authorization. -/// The test auth bypass from -/// Yavsc.Tests.Shared so the [Authorize("BlogScope")] -/// attribute on BlogApiController is satisfied when the -/// test sends the X-Test-Role header. +/// 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 @@ -36,6 +48,8 @@ namespace Yavsc.Blogs.Tests; /// public sealed class BlogsWebServerFixture : WebHostFixture { + private InMemoryDatabaseRoot? _inMemoryRoot; + protected override WebApplication BuildApp(WebApplicationBuilder builder) { // Use the real ApplicationDbContext with an in-memory store. @@ -43,8 +57,16 @@ public sealed class BlogsWebServerFixture : WebHostFixture // 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")); + 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. @@ -53,28 +75,77 @@ public sealed class BlogsWebServerFixture : WebHostFixture // Real BlogSpotService — same instance the production host // builds (ApplicationDbContext, IAuthorizationService, - // IFileSystemAuthManager). + // 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 => { - // Mirror the production "BlogScope" policy: any - // authenticated user. The TestAuthPolicyProvider we - // register below short-circuits the role check via the - // X-Test-Role header. - opt.AddPolicy("BlogScope", p => p.RequireAssertion(_ => true)); + opt.AddPolicy("BlogScope", policy => + { + policy.RequireAuthenticatedUser() + .RequireClaim("scope", "blogs"); + }); }); - // Test auth bypass — swapped in BEFORE the host builds the - // service collection, so it overrides any production - // policy provider registered by AddAuthorization above. - builder.Services.AddSingleton(); + // 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(); } @@ -94,7 +165,7 @@ public sealed class BlogsWebServerFixture : WebHostFixture /// file system, so the implementation can be a no-op. private sealed class NoopFileSystemAuthManager : IFileSystemAuthManager { - public FileAccessRight GetFilePathAccess(ClaimsPrincipal user, string fileRelativePath) + public FileAccessRight GetFilePathAccess(System.Security.Claims.ClaimsPrincipal user, string fileRelativePath) => FileAccessRight.None; public void SetAccess(long circleId, string normalizedFullPath, FileAccessRight access) diff --git a/src/Yavsc.Tests.Shared/TestTokenIssuer.cs b/src/Yavsc.Tests.Shared/TestTokenIssuer.cs new file mode 100644 index 00000000..18aa2e00 --- /dev/null +++ b/src/Yavsc.Tests.Shared/TestTokenIssuer.cs @@ -0,0 +1,107 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; +using Microsoft.IdentityModel.Tokens; + +namespace Yavsc.Tests.Shared; + +/// +/// Mints HS256-signed JWTs for integration tests. The signing key is +/// held in a static field shared with the test host's +/// AddJwtBearer registration: whatever the host validates +/// against, this issuer signs with. +/// +/// +/// HS256 (symmetric) is the right choice for a unit-test issuer: +/// no key generation ceremony, no PEM round-trip, no asymmetric +/// crypto on the hot path. The key never leaves the test process. +/// Production continues to validate against the OIDC authority via +/// AddYavscJwtBearer — this issuer is *only* for the +/// in-process test host. +/// +/// +public static class TestTokenIssuer +{ + /// + /// Symmetric signing key shared with the test host's + /// TokenValidationParameters.IssuerSigningKey. + /// 32 bytes of zeros is enough entropy for HS256 *within the test + /// process*; the assertion we care about is "does the policy + /// evaluate a properly-signed token", not "is the key unguessable + /// by an attacker" (there is no attacker here). + /// + public static readonly SymmetricSecurityKey SigningKey = + new SymmetricSecurityKey(Encoding.UTF8.GetBytes(new string('k', 32))); + + /// + /// Issuer stamped into the iss claim and checked by the + /// test host. Must match + /// TokenValidationParameters.ValidIssuer. + /// + public const string Issuer = "yavsc-test-issuer"; + + /// + /// Audience stamped into the aud claim. The test host + /// does not validate audience (production may), so this is here + /// for shape only. + /// + public const string Audience = "yavsc-test"; + + private static bool _inboundClaimTypeMapCleared; + + /// + /// Mint a JWT carrying the given as + /// the sub claim, a single scope claim with value + /// , and any additional + /// . Token is valid for one hour + /// from now. + /// + /// Value of the sub claim. Read + /// back by UserHelpers.GetUserId, which is how + /// PermissionHandler.IsOwner identifies the author of a + /// BlogPost on PUT. + /// Value of the scope claim. The + /// production BlogScope policy requires + /// RequireClaim("scope", "blogs"). + /// Optional additional claims + /// (e.g. a role for an admin-bypass test). + public static string Issue( + string subject, + string scope = "blogs", + IEnumerable? extraClaims = null) + { + var now = DateTime.UtcNow; + var claims = new List + { + new("sub", subject), + new("scope", scope), + }; + if (extraClaims is not null) claims.AddRange(extraClaims); + + // JwtSecurityTokenHandler ships with a static + // DefaultInboundClaimTypeMap that rewrites short JWT claim + // names to their long Microsoft URIs at deserialisation + // time. The most relevant rewrite for us is + // "sub" → ClaimTypes.NameIdentifier. Without clearing the + // map, UserHelpers.GetUserId() — which reads the literal + // "sub" claim — would not find the value, PermissionHandler + // .IsOwner would compare against null, and the controller + // would return 401 on every PUT. Clearing is the standard + // way to opt out of the legacy mapping. + if (!_inboundClaimTypeMapCleared) + { + JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear(); + _inboundClaimTypeMapCleared = true; + } + + var creds = new SigningCredentials(SigningKey, SecurityAlgorithms.HmacSha256); + var token = new JwtSecurityToken( + issuer: Issuer, + audience: Audience, + claims: claims, + notBefore: now, + expires: now.AddHours(1), + signingCredentials: creds); + return new JwtSecurityTokenHandler().WriteToken(token); + } +} diff --git a/src/Yavsc.Tests.Shared/Yavsc.Tests.Shared.csproj b/src/Yavsc.Tests.Shared/Yavsc.Tests.Shared.csproj index f9dc0876..b8fa9e64 100644 --- a/src/Yavsc.Tests.Shared/Yavsc.Tests.Shared.csproj +++ b/src/Yavsc.Tests.Shared/Yavsc.Tests.Shared.csproj @@ -16,5 +16,7 @@ + + \ No newline at end of file