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