test(blogs): real JwtBearer in fixture, drop X-Test-Role bypass
Wire the Blogs integration test host with a real AddJwtBearer
(HS256, IssuerSigningKey shared with the new TestTokenIssuer) and
the production BlogScope policy verbatim, instead of the
TestAuthPolicyProvider / AllowAllAuthorizationService /
NoopAuthHandler stack that short-circuited every authorization
check.
Why: BlogSpotService.Modify calls
IAuthorizationService.AuthorizeAsync(user, blog, EditPermission);
the previous AllowAllAuthorizationService stub made that a
no-op, so the tests could not exercise the real ownership chain
and any change in PermissionHandler would silently slip through.
The new test host registers the real PermissionHandler, so a PUT
that succeeds (204) is now proof that PermissionHandler.IsOwner
accepted the request — i.e. the JWT's sub matched the post's
AuthorId, end-to-end.
Notes for future-me:
- JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear() is
called once on the first Issue() to keep the 'sub' claim
literal; without it UserHelpers.GetUserId (which reads 'sub')
gets ClaimTypes.NameIdentifier instead, returns null, and the
owner check fails for every PUT. The companion
options.MapInboundClaims = false on the validation pipeline
keeps both sides in sync.
- Production still uses AddYavscJwtBearer against the OIDC
authority; the test-only HS256 path is local to the test
process and never crosses a network boundary.
Coverage:
- GetBlog_returns_401_when_no_token_is_provided — anonymous
request, real policy fails closed.
- PutBlog_with_valid_token_and_owner_returns_204_and_Get_
reflects_update — POST then PUT then GET, all behind a real
JWT, asserting 204 + list contains the updated title.
Packages added to Directory.Packages.props at 8.2.1 to match
what Microsoft.AspNetCore.Authentication.JwtBearer 10.0.9 already
transitively pulls in (no version drift).
2026-07-06 23:29:51 +01:00
|
|
|
using System.Text;
|
|
|
|
|
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
2026-07-06 21:49:53 +01:00
|
|
|
using Microsoft.AspNetCore.Authorization;
|
|
|
|
|
using Microsoft.AspNetCore.Builder;
|
|
|
|
|
using Microsoft.EntityFrameworkCore;
|
test(blogs): real JwtBearer in fixture, drop X-Test-Role bypass
Wire the Blogs integration test host with a real AddJwtBearer
(HS256, IssuerSigningKey shared with the new TestTokenIssuer) and
the production BlogScope policy verbatim, instead of the
TestAuthPolicyProvider / AllowAllAuthorizationService /
NoopAuthHandler stack that short-circuited every authorization
check.
Why: BlogSpotService.Modify calls
IAuthorizationService.AuthorizeAsync(user, blog, EditPermission);
the previous AllowAllAuthorizationService stub made that a
no-op, so the tests could not exercise the real ownership chain
and any change in PermissionHandler would silently slip through.
The new test host registers the real PermissionHandler, so a PUT
that succeeds (204) is now proof that PermissionHandler.IsOwner
accepted the request — i.e. the JWT's sub matched the post's
AuthorId, end-to-end.
Notes for future-me:
- JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear() is
called once on the first Issue() to keep the 'sub' claim
literal; without it UserHelpers.GetUserId (which reads 'sub')
gets ClaimTypes.NameIdentifier instead, returns null, and the
owner check fails for every PUT. The companion
options.MapInboundClaims = false on the validation pipeline
keeps both sides in sync.
- Production still uses AddYavscJwtBearer against the OIDC
authority; the test-only HS256 path is local to the test
process and never crosses a network boundary.
Coverage:
- GetBlog_returns_401_when_no_token_is_provided — anonymous
request, real policy fails closed.
- PutBlog_with_valid_token_and_owner_returns_204_and_Get_
reflects_update — POST then PUT then GET, all behind a real
JWT, asserting 204 + list contains the updated title.
Packages added to Directory.Packages.props at 8.2.1 to match
what Microsoft.AspNetCore.Authentication.JwtBearer 10.0.9 already
transitively pulls in (no version drift).
2026-07-06 23:29:51 +01:00
|
|
|
using Microsoft.EntityFrameworkCore.Storage;
|
2026-07-06 21:49:53 +01:00
|
|
|
using Microsoft.Extensions.DependencyInjection;
|
test(blogs): real JwtBearer in fixture, drop X-Test-Role bypass
Wire the Blogs integration test host with a real AddJwtBearer
(HS256, IssuerSigningKey shared with the new TestTokenIssuer) and
the production BlogScope policy verbatim, instead of the
TestAuthPolicyProvider / AllowAllAuthorizationService /
NoopAuthHandler stack that short-circuited every authorization
check.
Why: BlogSpotService.Modify calls
IAuthorizationService.AuthorizeAsync(user, blog, EditPermission);
the previous AllowAllAuthorizationService stub made that a
no-op, so the tests could not exercise the real ownership chain
and any change in PermissionHandler would silently slip through.
The new test host registers the real PermissionHandler, so a PUT
that succeeds (204) is now proof that PermissionHandler.IsOwner
accepted the request — i.e. the JWT's sub matched the post's
AuthorId, end-to-end.
Notes for future-me:
- JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear() is
called once on the first Issue() to keep the 'sub' claim
literal; without it UserHelpers.GetUserId (which reads 'sub')
gets ClaimTypes.NameIdentifier instead, returns null, and the
owner check fails for every PUT. The companion
options.MapInboundClaims = false on the validation pipeline
keeps both sides in sync.
- Production still uses AddYavscJwtBearer against the OIDC
authority; the test-only HS256 path is local to the test
process and never crosses a network boundary.
Coverage:
- GetBlog_returns_401_when_no_token_is_provided — anonymous
request, real policy fails closed.
- PutBlog_with_valid_token_and_owner_returns_204_and_Get_
reflects_update — POST then PUT then GET, all behind a real
JWT, asserting 204 + list contains the updated title.
Packages added to Directory.Packages.props at 8.2.1 to match
what Microsoft.AspNetCore.Authentication.JwtBearer 10.0.9 already
transitively pulls in (no version drift).
2026-07-06 23:29:51 +01:00
|
|
|
using Microsoft.IdentityModel.Tokens;
|
2026-07-06 21:58:14 +01:00
|
|
|
using Yavsc.Blogs.Controllers;
|
2026-07-06 21:49:53 +01:00
|
|
|
using Yavsc.Models;
|
|
|
|
|
using Yavsc.Services;
|
|
|
|
|
using Yavsc.Tests.Shared;
|
|
|
|
|
|
|
|
|
|
namespace Yavsc.Blogs.Tests;
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Test host for the Yavsc.Blogs API surface. Specialisation of
|
|
|
|
|
/// <see cref="WebHostFixture"/> that wires up only the bits the
|
|
|
|
|
/// blog API actually depends on:
|
|
|
|
|
///
|
|
|
|
|
/// <list type="bullet">
|
|
|
|
|
/// <item><description>An in-memory <see cref="ApplicationDbContext"/>
|
|
|
|
|
/// (the real one — no mock) so <c>BlogSpotService.Index</c> can run
|
|
|
|
|
/// against an empty table and return an empty list.</description></item>
|
|
|
|
|
/// <item><description>A trivial <see cref="IFileSystemAuthManager"/>
|
|
|
|
|
/// stub: the GET index path doesn't read the file system, so any
|
|
|
|
|
/// implementation is fine.</description></item>
|
test(blogs): real JwtBearer in fixture, drop X-Test-Role bypass
Wire the Blogs integration test host with a real AddJwtBearer
(HS256, IssuerSigningKey shared with the new TestTokenIssuer) and
the production BlogScope policy verbatim, instead of the
TestAuthPolicyProvider / AllowAllAuthorizationService /
NoopAuthHandler stack that short-circuited every authorization
check.
Why: BlogSpotService.Modify calls
IAuthorizationService.AuthorizeAsync(user, blog, EditPermission);
the previous AllowAllAuthorizationService stub made that a
no-op, so the tests could not exercise the real ownership chain
and any change in PermissionHandler would silently slip through.
The new test host registers the real PermissionHandler, so a PUT
that succeeds (204) is now proof that PermissionHandler.IsOwner
accepted the request — i.e. the JWT's sub matched the post's
AuthorId, end-to-end.
Notes for future-me:
- JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear() is
called once on the first Issue() to keep the 'sub' claim
literal; without it UserHelpers.GetUserId (which reads 'sub')
gets ClaimTypes.NameIdentifier instead, returns null, and the
owner check fails for every PUT. The companion
options.MapInboundClaims = false on the validation pipeline
keeps both sides in sync.
- Production still uses AddYavscJwtBearer against the OIDC
authority; the test-only HS256 path is local to the test
process and never crosses a network boundary.
Coverage:
- GetBlog_returns_401_when_no_token_is_provided — anonymous
request, real policy fails closed.
- PutBlog_with_valid_token_and_owner_returns_204_and_Get_
reflects_update — POST then PUT then GET, all behind a real
JWT, asserting 204 + list contains the updated title.
Packages added to Directory.Packages.props at 8.2.1 to match
what Microsoft.AspNetCore.Authentication.JwtBearer 10.0.9 already
transitively pulls in (no version drift).
2026-07-06 23:29:51 +01:00
|
|
|
/// <item><description>The real <c>BlogSpotService</c>, which calls
|
|
|
|
|
/// <c>IAuthorizationService.AuthorizeAsync(user, blog, new EditPermission())</c>
|
|
|
|
|
/// on PUT. The fixture registers the real
|
|
|
|
|
/// <see cref="PermissionHandler"/> so the resource-based ownership
|
|
|
|
|
/// check runs end-to-end; tests that want a 204 PUT must sign a
|
|
|
|
|
/// JWT whose <c>sub</c> matches the post's <c>AuthorId</c>.</description></item>
|
|
|
|
|
/// <item><description>A real <c>AddJwtBearer</c> with HS256,
|
|
|
|
|
/// sharing its <see cref="TestTokenIssuer.SigningKey"/> 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.</description></item>
|
|
|
|
|
/// <item><description>The production <c>BlogScope</c> policy
|
|
|
|
|
/// (RequireAuthenticatedUser + RequireClaim("scope", "blogs"))
|
|
|
|
|
/// registered verbatim. Tests that omit the bearer header exercise
|
|
|
|
|
/// the unauthenticated path and get 401.</description></item>
|
2026-07-06 21:49:53 +01:00
|
|
|
/// </list>
|
|
|
|
|
///
|
|
|
|
|
/// 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.
|
|
|
|
|
/// </summary>
|
|
|
|
|
public sealed class BlogsWebServerFixture : WebHostFixture
|
|
|
|
|
{
|
2026-07-12 05:48:47 +01:00
|
|
|
protected override int HttpsPort => 5103;
|
|
|
|
|
|
test(blogs): real JwtBearer in fixture, drop X-Test-Role bypass
Wire the Blogs integration test host with a real AddJwtBearer
(HS256, IssuerSigningKey shared with the new TestTokenIssuer) and
the production BlogScope policy verbatim, instead of the
TestAuthPolicyProvider / AllowAllAuthorizationService /
NoopAuthHandler stack that short-circuited every authorization
check.
Why: BlogSpotService.Modify calls
IAuthorizationService.AuthorizeAsync(user, blog, EditPermission);
the previous AllowAllAuthorizationService stub made that a
no-op, so the tests could not exercise the real ownership chain
and any change in PermissionHandler would silently slip through.
The new test host registers the real PermissionHandler, so a PUT
that succeeds (204) is now proof that PermissionHandler.IsOwner
accepted the request — i.e. the JWT's sub matched the post's
AuthorId, end-to-end.
Notes for future-me:
- JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear() is
called once on the first Issue() to keep the 'sub' claim
literal; without it UserHelpers.GetUserId (which reads 'sub')
gets ClaimTypes.NameIdentifier instead, returns null, and the
owner check fails for every PUT. The companion
options.MapInboundClaims = false on the validation pipeline
keeps both sides in sync.
- Production still uses AddYavscJwtBearer against the OIDC
authority; the test-only HS256 path is local to the test
process and never crosses a network boundary.
Coverage:
- GetBlog_returns_401_when_no_token_is_provided — anonymous
request, real policy fails closed.
- PutBlog_with_valid_token_and_owner_returns_204_and_Get_
reflects_update — POST then PUT then GET, all behind a real
JWT, asserting 204 + list contains the updated title.
Packages added to Directory.Packages.props at 8.2.1 to match
what Microsoft.AspNetCore.Authentication.JwtBearer 10.0.9 already
transitively pulls in (no version drift).
2026-07-06 23:29:51 +01:00
|
|
|
private InMemoryDatabaseRoot? _inMemoryRoot;
|
|
|
|
|
|
2026-07-06 21:49:53 +01:00
|
|
|
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.
|
test(blogs): real JwtBearer in fixture, drop X-Test-Role bypass
Wire the Blogs integration test host with a real AddJwtBearer
(HS256, IssuerSigningKey shared with the new TestTokenIssuer) and
the production BlogScope policy verbatim, instead of the
TestAuthPolicyProvider / AllowAllAuthorizationService /
NoopAuthHandler stack that short-circuited every authorization
check.
Why: BlogSpotService.Modify calls
IAuthorizationService.AuthorizeAsync(user, blog, EditPermission);
the previous AllowAllAuthorizationService stub made that a
no-op, so the tests could not exercise the real ownership chain
and any change in PermissionHandler would silently slip through.
The new test host registers the real PermissionHandler, so a PUT
that succeeds (204) is now proof that PermissionHandler.IsOwner
accepted the request — i.e. the JWT's sub matched the post's
AuthorId, end-to-end.
Notes for future-me:
- JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear() is
called once on the first Issue() to keep the 'sub' claim
literal; without it UserHelpers.GetUserId (which reads 'sub')
gets ClaimTypes.NameIdentifier instead, returns null, and the
owner check fails for every PUT. The companion
options.MapInboundClaims = false on the validation pipeline
keeps both sides in sync.
- Production still uses AddYavscJwtBearer against the OIDC
authority; the test-only HS256 path is local to the test
process and never crosses a network boundary.
Coverage:
- GetBlog_returns_401_when_no_token_is_provided — anonymous
request, real policy fails closed.
- PutBlog_with_valid_token_and_owner_returns_204_and_Get_
reflects_update — POST then PUT then GET, all behind a real
JWT, asserting 204 + list contains the updated title.
Packages added to Directory.Packages.props at 8.2.1 to match
what Microsoft.AspNetCore.Authentication.JwtBearer 10.0.9 already
transitively pulls in (no version drift).
2026-07-06 23:29:51 +01:00
|
|
|
//
|
|
|
|
|
// 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();
|
2026-07-06 21:49:53 +01:00
|
|
|
builder.Services.AddDbContext<ApplicationDbContext>(opt =>
|
test(blogs): real JwtBearer in fixture, drop X-Test-Role bypass
Wire the Blogs integration test host with a real AddJwtBearer
(HS256, IssuerSigningKey shared with the new TestTokenIssuer) and
the production BlogScope policy verbatim, instead of the
TestAuthPolicyProvider / AllowAllAuthorizationService /
NoopAuthHandler stack that short-circuited every authorization
check.
Why: BlogSpotService.Modify calls
IAuthorizationService.AuthorizeAsync(user, blog, EditPermission);
the previous AllowAllAuthorizationService stub made that a
no-op, so the tests could not exercise the real ownership chain
and any change in PermissionHandler would silently slip through.
The new test host registers the real PermissionHandler, so a PUT
that succeeds (204) is now proof that PermissionHandler.IsOwner
accepted the request — i.e. the JWT's sub matched the post's
AuthorId, end-to-end.
Notes for future-me:
- JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear() is
called once on the first Issue() to keep the 'sub' claim
literal; without it UserHelpers.GetUserId (which reads 'sub')
gets ClaimTypes.NameIdentifier instead, returns null, and the
owner check fails for every PUT. The companion
options.MapInboundClaims = false on the validation pipeline
keeps both sides in sync.
- Production still uses AddYavscJwtBearer against the OIDC
authority; the test-only HS256 path is local to the test
process and never crosses a network boundary.
Coverage:
- GetBlog_returns_401_when_no_token_is_provided — anonymous
request, real policy fails closed.
- PutBlog_with_valid_token_and_owner_returns_204_and_Get_
reflects_update — POST then PUT then GET, all behind a real
JWT, asserting 204 + list contains the updated title.
Packages added to Directory.Packages.props at 8.2.1 to match
what Microsoft.AspNetCore.Authentication.JwtBearer 10.0.9 already
transitively pulls in (no version drift).
2026-07-06 23:29:51 +01:00
|
|
|
opt.UseInMemoryDatabase("Yavsc.Blogs.Tests", _inMemoryRoot));
|
2026-07-06 21:49:53 +01:00
|
|
|
|
|
|
|
|
// Trivial file-system auth: the GET index path never calls
|
|
|
|
|
// into it, but the DI container needs an instance.
|
|
|
|
|
builder.Services.AddSingleton<IFileSystemAuthManager>(
|
|
|
|
|
new NoopFileSystemAuthManager());
|
|
|
|
|
|
|
|
|
|
// Real BlogSpotService — same instance the production host
|
|
|
|
|
// builds (ApplicationDbContext, IAuthorizationService,
|
test(blogs): real JwtBearer in fixture, drop X-Test-Role bypass
Wire the Blogs integration test host with a real AddJwtBearer
(HS256, IssuerSigningKey shared with the new TestTokenIssuer) and
the production BlogScope policy verbatim, instead of the
TestAuthPolicyProvider / AllowAllAuthorizationService /
NoopAuthHandler stack that short-circuited every authorization
check.
Why: BlogSpotService.Modify calls
IAuthorizationService.AuthorizeAsync(user, blog, EditPermission);
the previous AllowAllAuthorizationService stub made that a
no-op, so the tests could not exercise the real ownership chain
and any change in PermissionHandler would silently slip through.
The new test host registers the real PermissionHandler, so a PUT
that succeeds (204) is now proof that PermissionHandler.IsOwner
accepted the request — i.e. the JWT's sub matched the post's
AuthorId, end-to-end.
Notes for future-me:
- JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear() is
called once on the first Issue() to keep the 'sub' claim
literal; without it UserHelpers.GetUserId (which reads 'sub')
gets ClaimTypes.NameIdentifier instead, returns null, and the
owner check fails for every PUT. The companion
options.MapInboundClaims = false on the validation pipeline
keeps both sides in sync.
- Production still uses AddYavscJwtBearer against the OIDC
authority; the test-only HS256 path is local to the test
process and never crosses a network boundary.
Coverage:
- GetBlog_returns_401_when_no_token_is_provided — anonymous
request, real policy fails closed.
- PutBlog_with_valid_token_and_owner_returns_204_and_Get_
reflects_update — POST then PUT then GET, all behind a real
JWT, asserting 204 + list contains the updated title.
Packages added to Directory.Packages.props at 8.2.1 to match
what Microsoft.AspNetCore.Authentication.JwtBearer 10.0.9 already
transitively pulls in (no version drift).
2026-07-06 23:29:51 +01:00
|
|
|
// 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.
|
2026-07-06 21:49:53 +01:00
|
|
|
builder.Services.AddScoped<BlogSpotService>();
|
|
|
|
|
|
test(blogs): real JwtBearer in fixture, drop X-Test-Role bypass
Wire the Blogs integration test host with a real AddJwtBearer
(HS256, IssuerSigningKey shared with the new TestTokenIssuer) and
the production BlogScope policy verbatim, instead of the
TestAuthPolicyProvider / AllowAllAuthorizationService /
NoopAuthHandler stack that short-circuited every authorization
check.
Why: BlogSpotService.Modify calls
IAuthorizationService.AuthorizeAsync(user, blog, EditPermission);
the previous AllowAllAuthorizationService stub made that a
no-op, so the tests could not exercise the real ownership chain
and any change in PermissionHandler would silently slip through.
The new test host registers the real PermissionHandler, so a PUT
that succeeds (204) is now proof that PermissionHandler.IsOwner
accepted the request — i.e. the JWT's sub matched the post's
AuthorId, end-to-end.
Notes for future-me:
- JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear() is
called once on the first Issue() to keep the 'sub' claim
literal; without it UserHelpers.GetUserId (which reads 'sub')
gets ClaimTypes.NameIdentifier instead, returns null, and the
owner check fails for every PUT. The companion
options.MapInboundClaims = false on the validation pipeline
keeps both sides in sync.
- Production still uses AddYavscJwtBearer against the OIDC
authority; the test-only HS256 path is local to the test
process and never crosses a network boundary.
Coverage:
- GetBlog_returns_401_when_no_token_is_provided — anonymous
request, real policy fails closed.
- PutBlog_with_valid_token_and_owner_returns_204_and_Get_
reflects_update — POST then PUT then GET, all behind a real
JWT, asserting 204 + list contains the updated title.
Packages added to Directory.Packages.props at 8.2.1 to match
what Microsoft.AspNetCore.Authentication.JwtBearer 10.0.9 already
transitively pulls in (no version drift).
2026-07-06 23:29:51 +01:00
|
|
|
// 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<IAuthorizationHandler, PermissionHandler>();
|
|
|
|
|
|
2026-07-06 21:58:14 +01:00
|
|
|
// 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);
|
test(blogs): real JwtBearer in fixture, drop X-Test-Role bypass
Wire the Blogs integration test host with a real AddJwtBearer
(HS256, IssuerSigningKey shared with the new TestTokenIssuer) and
the production BlogScope policy verbatim, instead of the
TestAuthPolicyProvider / AllowAllAuthorizationService /
NoopAuthHandler stack that short-circuited every authorization
check.
Why: BlogSpotService.Modify calls
IAuthorizationService.AuthorizeAsync(user, blog, EditPermission);
the previous AllowAllAuthorizationService stub made that a
no-op, so the tests could not exercise the real ownership chain
and any change in PermissionHandler would silently slip through.
The new test host registers the real PermissionHandler, so a PUT
that succeeds (204) is now proof that PermissionHandler.IsOwner
accepted the request — i.e. the JWT's sub matched the post's
AuthorId, end-to-end.
Notes for future-me:
- JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear() is
called once on the first Issue() to keep the 'sub' claim
literal; without it UserHelpers.GetUserId (which reads 'sub')
gets ClaimTypes.NameIdentifier instead, returns null, and the
owner check fails for every PUT. The companion
options.MapInboundClaims = false on the validation pipeline
keeps both sides in sync.
- Production still uses AddYavscJwtBearer against the OIDC
authority; the test-only HS256 path is local to the test
process and never crosses a network boundary.
Coverage:
- GetBlog_returns_401_when_no_token_is_provided — anonymous
request, real policy fails closed.
- PutBlog_with_valid_token_and_owner_returns_204_and_Get_
reflects_update — POST then PUT then GET, all behind a real
JWT, asserting 204 + list contains the updated title.
Packages added to Directory.Packages.props at 8.2.1 to match
what Microsoft.AspNetCore.Authentication.JwtBearer 10.0.9 already
transitively pulls in (no version drift).
2026-07-06 23:29:51 +01:00
|
|
|
|
|
|
|
|
// 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.
|
2026-07-06 21:49:53 +01:00
|
|
|
builder.Services.AddAuthorization(opt =>
|
|
|
|
|
{
|
test(blogs): real JwtBearer in fixture, drop X-Test-Role bypass
Wire the Blogs integration test host with a real AddJwtBearer
(HS256, IssuerSigningKey shared with the new TestTokenIssuer) and
the production BlogScope policy verbatim, instead of the
TestAuthPolicyProvider / AllowAllAuthorizationService /
NoopAuthHandler stack that short-circuited every authorization
check.
Why: BlogSpotService.Modify calls
IAuthorizationService.AuthorizeAsync(user, blog, EditPermission);
the previous AllowAllAuthorizationService stub made that a
no-op, so the tests could not exercise the real ownership chain
and any change in PermissionHandler would silently slip through.
The new test host registers the real PermissionHandler, so a PUT
that succeeds (204) is now proof that PermissionHandler.IsOwner
accepted the request — i.e. the JWT's sub matched the post's
AuthorId, end-to-end.
Notes for future-me:
- JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear() is
called once on the first Issue() to keep the 'sub' claim
literal; without it UserHelpers.GetUserId (which reads 'sub')
gets ClaimTypes.NameIdentifier instead, returns null, and the
owner check fails for every PUT. The companion
options.MapInboundClaims = false on the validation pipeline
keeps both sides in sync.
- Production still uses AddYavscJwtBearer against the OIDC
authority; the test-only HS256 path is local to the test
process and never crosses a network boundary.
Coverage:
- GetBlog_returns_401_when_no_token_is_provided — anonymous
request, real policy fails closed.
- PutBlog_with_valid_token_and_owner_returns_204_and_Get_
reflects_update — POST then PUT then GET, all behind a real
JWT, asserting 204 + list contains the updated title.
Packages added to Directory.Packages.props at 8.2.1 to match
what Microsoft.AspNetCore.Authentication.JwtBearer 10.0.9 already
transitively pulls in (no version drift).
2026-07-06 23:29:51 +01:00
|
|
|
opt.AddPolicy("BlogScope", policy =>
|
|
|
|
|
{
|
|
|
|
|
policy.RequireAuthenticatedUser()
|
|
|
|
|
.RequireClaim("scope", "blogs");
|
|
|
|
|
});
|
2026-07-06 21:49:53 +01:00
|
|
|
});
|
|
|
|
|
|
test(blogs): real JwtBearer in fixture, drop X-Test-Role bypass
Wire the Blogs integration test host with a real AddJwtBearer
(HS256, IssuerSigningKey shared with the new TestTokenIssuer) and
the production BlogScope policy verbatim, instead of the
TestAuthPolicyProvider / AllowAllAuthorizationService /
NoopAuthHandler stack that short-circuited every authorization
check.
Why: BlogSpotService.Modify calls
IAuthorizationService.AuthorizeAsync(user, blog, EditPermission);
the previous AllowAllAuthorizationService stub made that a
no-op, so the tests could not exercise the real ownership chain
and any change in PermissionHandler would silently slip through.
The new test host registers the real PermissionHandler, so a PUT
that succeeds (204) is now proof that PermissionHandler.IsOwner
accepted the request — i.e. the JWT's sub matched the post's
AuthorId, end-to-end.
Notes for future-me:
- JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear() is
called once on the first Issue() to keep the 'sub' claim
literal; without it UserHelpers.GetUserId (which reads 'sub')
gets ClaimTypes.NameIdentifier instead, returns null, and the
owner check fails for every PUT. The companion
options.MapInboundClaims = false on the validation pipeline
keeps both sides in sync.
- Production still uses AddYavscJwtBearer against the OIDC
authority; the test-only HS256 path is local to the test
process and never crosses a network boundary.
Coverage:
- GetBlog_returns_401_when_no_token_is_provided — anonymous
request, real policy fails closed.
- PutBlog_with_valid_token_and_owner_returns_204_and_Get_
reflects_update — POST then PUT then GET, all behind a real
JWT, asserting 204 + list contains the updated title.
Packages added to Directory.Packages.props at 8.2.1 to match
what Microsoft.AspNetCore.Authentication.JwtBearer 10.0.9 already
transitively pulls in (no version drift).
2026-07-06 23:29:51 +01:00
|
|
|
// 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,
|
|
|
|
|
};
|
|
|
|
|
});
|
2026-07-06 21:49:53 +01:00
|
|
|
|
|
|
|
|
return builder.Build();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
protected override async Task<WebApplication> ConfigurePipelineAsync(WebApplication app)
|
|
|
|
|
{
|
feat(post): add Publish toggle for blog posts (no schema change)
Replaces the previous 'Visibility enum' approach (commit 33ecfa7e,
reverted in 42625f5d) with the existing BlogSpotPublication
mechanism. Paul pointed out that the system already had a
publication table and a Publish field on BlogPostEditViewModel;
we just didn't expose it through the API.
The toggle is its own action on the API surface — a dedicated
endpoint rather than a field on the existing BlogPost wire
DTO. This keeps the BlogPostDto contract unchanged and avoids
shoe-horning 'Publish' into the entity model alongside
Title/Article (where the existing BlogSpotService.Modify
already takes two overloads and a third felt like drift).
Server (Yavsc.Blogs / Yavsc.Server)
- PUT /api/BlogApi/{id}/publish body { publish: bool }
Returns 204 on success, 404 when the post doesn't exist,
Challenge() (401) when the caller is not the author
(EditPermission gate). Idempotent: PUT because the
resulting state matches the body, not the request.
- BlogSpotService.SetPublishAsync(user, postId, publish)
factored out of the existing
Modify(BlogPostEditViewModel) inline toggle, so the new
endpoint reuses the same BlogSpotPublication row logic
(add row if missing on publish=true, remove row if
present on publish=false).
- BlogPost.IsPublished (NotMapped) is now hydrated by the
service after each Index/Details fetch — a single bulk
lookup, not N+1 — and surfaces through the wire JSON
so PostIt can show the current state without a follow-up
request.
- ApplicationUser nav properties (Posts, Book,
DeviceDeclaration, Connections, Circles, BlackList,
Rooms, RoomAccess, Membership, BlogComments) now carry
BOTH [JsonIgnore] (Newtonsoft) and
[System.Text.Json.Serialization.JsonIgnore] so the
Yavsc.Blogs test fixture (System.Text.Json) stops
exploding on object cycles when serialising
BlogPost.Author.Posts.Author.Posts. Production
(Yavsc.Org, NewtonsoftJson) was already safe via the
Newtonsoft-only attribute; this commit just makes the
Yavsc.Blogs side consistent.
Client (Yavsc.Api.Client)
- BlogApiClient.SetPublishAsync(id, publish) → PUT to the
new endpoint.
DTO wire (Yavsc.Abstract.Blogspot.BlogPost)
- BlogPostDto.IsPublished added. Same shape as the entity
field; serialised as a plain bool in JSON.
UI (PostIt)
- MainPageViewModel.DraftIsPublished (ObservableProperty)
mirrors the existing DraftTitle/DraftArticle pattern;
hydrated from SelectedPost.IsPublished on selection
change. TogglePublish command pushes the new state to
SetPublishAsync and updates both the buffer and the
selected post locally so the UI reflects the change
without a full Refresh.
- MainPage.axaml: a CheckBox 'Publié' in the toolbar,
bound to DraftIsPublished TwoWay and wired to
TogglePublishCommand. The toggle is its own action
(not part of Save), matching the wire contract.
Tests (Yavsc.Blogs.Tests)
- PublishEndpointTests (4 [Fact]):
* PUT publish=true returns 204 and IsPublished is true
in the next GET
* PUT publish=false clears IsPublished
* PUT on an unknown post returns 404
* PUT by a non-author does not return 204 (Challenge)
- BlogsWebServerFixture now wires
app.UseDeveloperExceptionPage() so 500s in tests
surface a real stack trace instead of an empty
InternalServerError body — much easier to diagnose
future regressions.
Test totals: 24/24 Yavsc.Blogs.Tests (was 20, +4
PublishEndpoint), 51/51 PostIt.Tests (no change), 44/44
Yavsc.Org.Tests (no change).
Out of scope (tracked in MEMORY.md, 2026-08-18):
- i18n: only the new 'Publié' label is localised; the
rest of MainPage.axaml is still hard-coded French.
- BlogPostEditViewModel.Publish ↔ IsPublished reconciliation
in the admin web Yavsc (the Org UI already edits Publish
inline; no work needed there).
2026-08-18 16:10:45 +01:00
|
|
|
// UseDeveloperExceptionPage gives full stack traces on
|
|
|
|
|
// 500s during tests — much easier to debug than the
|
|
|
|
|
// default empty InternalServerError body. Production
|
|
|
|
|
// (Yavsc.Org) wires its own exception handler; this
|
|
|
|
|
// fixture is test-only.
|
|
|
|
|
app.UseDeveloperExceptionPage();
|
2026-07-06 21:49:53 +01:00
|
|
|
app.UseRouting();
|
|
|
|
|
app.UseAuthentication();
|
|
|
|
|
app.UseAuthorization();
|
|
|
|
|
app.MapControllers();
|
|
|
|
|
await Task.CompletedTask;
|
|
|
|
|
return app;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>Trivial <see cref="IFileSystemAuthManager"/> stub. The
|
|
|
|
|
/// blog API endpoints exercised by the first tests don't read the
|
|
|
|
|
/// file system, so the implementation can be a no-op.</summary>
|
|
|
|
|
private sealed class NoopFileSystemAuthManager : IFileSystemAuthManager
|
|
|
|
|
{
|
test(blogs): real JwtBearer in fixture, drop X-Test-Role bypass
Wire the Blogs integration test host with a real AddJwtBearer
(HS256, IssuerSigningKey shared with the new TestTokenIssuer) and
the production BlogScope policy verbatim, instead of the
TestAuthPolicyProvider / AllowAllAuthorizationService /
NoopAuthHandler stack that short-circuited every authorization
check.
Why: BlogSpotService.Modify calls
IAuthorizationService.AuthorizeAsync(user, blog, EditPermission);
the previous AllowAllAuthorizationService stub made that a
no-op, so the tests could not exercise the real ownership chain
and any change in PermissionHandler would silently slip through.
The new test host registers the real PermissionHandler, so a PUT
that succeeds (204) is now proof that PermissionHandler.IsOwner
accepted the request — i.e. the JWT's sub matched the post's
AuthorId, end-to-end.
Notes for future-me:
- JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear() is
called once on the first Issue() to keep the 'sub' claim
literal; without it UserHelpers.GetUserId (which reads 'sub')
gets ClaimTypes.NameIdentifier instead, returns null, and the
owner check fails for every PUT. The companion
options.MapInboundClaims = false on the validation pipeline
keeps both sides in sync.
- Production still uses AddYavscJwtBearer against the OIDC
authority; the test-only HS256 path is local to the test
process and never crosses a network boundary.
Coverage:
- GetBlog_returns_401_when_no_token_is_provided — anonymous
request, real policy fails closed.
- PutBlog_with_valid_token_and_owner_returns_204_and_Get_
reflects_update — POST then PUT then GET, all behind a real
JWT, asserting 204 + list contains the updated title.
Packages added to Directory.Packages.props at 8.2.1 to match
what Microsoft.AspNetCore.Authentication.JwtBearer 10.0.9 already
transitively pulls in (no version drift).
2026-07-06 23:29:51 +01:00
|
|
|
public FileAccessRight GetFilePathAccess(System.Security.Claims.ClaimsPrincipal user, string fileRelativePath)
|
2026-07-06 21:49:53 +01:00
|
|
|
=> FileAccessRight.None;
|
|
|
|
|
|
|
|
|
|
public void SetAccess(long circleId, string normalizedFullPath, FileAccessRight access)
|
|
|
|
|
{
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|