Replaces the previous 'Visibility enum' approach (commit33ecfa7e, reverted in42625f5d) 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).
183 lines
8.5 KiB
C#
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)
|
|
{
|
|
}
|
|
}
|
|
}
|