yavsc/src/Yavsc.Blogs.Tests/BlogsWebServerFixture.cs
Paul Schneider 3fb5f40acb
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

183 lines
8.5 KiB
C#

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;
/// <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>
/// <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>
/// </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
{
protected override int HttpsPort => 5103;
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<ApplicationDbContext>(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<IFileSystemAuthManager>(
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<BlogSpotService>();
// 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>();
// 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<WebApplication> ConfigurePipelineAsync(WebApplication app)
{
// 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();
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
{
public FileAccessRight GetFilePathAccess(System.Security.Claims.ClaimsPrincipal user, string fileRelativePath)
=> FileAccessRight.None;
public void SetAccess(long circleId, string normalizedFullPath, FileAccessRight access)
{
}
}
}