Bundled end-of-branch commit on feat/postit-acl-members. PostIt UI for circles + per-post ACL - Reorganise PostIt.Tests into Auth/ and Blogs/ subfolders (Bearer/OIDC scope tests vs. blog API fakes live where they belong) and introduces PostItHeadlessCollection so the Avalonia.Headless tests share a single xUnit collection instead of contending with the EF-Core test host. - Adds BlogAclApiTests (a brand-new behavioural layer over POST /api/v1/blogacl) and the fakes it relies on (BlogApiTestFakes, BlogPostAuthorDtoTests, AddCircleMember DialogTests); pulls UserId-through-OIDC-sub path into BearerScopeTests / FakeAuthorizingBrowser / OidcStubAuthority. - App.axaml.cs gets a small PushPageAsync touch-up the new tests rely on. - Drops UnitTest1.cs (xUnit scaffold, never used). Yavsc.Blogs.Tests — SQLite instead of InMemory - Bumps Yavsc.Blogs.Tests.csproj on Microsoft.EntityFrameworkCore.Sqlite and rewrites BlogsWebServerFixture to hold a single shared SqliteConnection (Cache=Shared) for the fixture lifetime, with a sync Dispose close to dodge async teardown hangs. Reason: the EF Core InMemory provider silently ignores FKs, which masked the kind of bug we are about to pin in the ACL tests. SQLite enforces them, so any future INSERT that forgets to seed its parent rows fails loudly here instead of passing the test and breaking prod. - PublishEndpointTests and BlogApiSmokeTests get a one-line tweak to follow the new connection lifecycle. Foreign-key fallout: seed the default user in the fixture - Adds BlogsWebServerFixture.SeedUser(userName). Now that SQLite enforces BlogPost.AuthorId → AspNetUsers.Id, every test that POST/PUT/DELETE a BlogPost and sends AuthorId= 'tester' in the payload needs an AspNetUsers row to satisfy the FK or it returns 500 with SQLite Error 19. - BlogApiTests wraps the existing ResetDatabase with a ResetAndSeedDefaultUser helper for the six mutating tests; the four GET-only and ModelState-only tests keep the bare ResetDatabase. - Side benefit: every test in Yavsc.Blogs.Tests now finishes cleanly instead of hanging at teardown — previously a stuck test held the shared SqliteConnection open and the next tests waited indefinitely. Verified: dotnet test src/Yavsc.Blogs.Tests passes 25/25 green from a clean run, no fixture teardown hang.
285 lines
13 KiB
C#
285 lines
13 KiB
C#
using System.Text;
|
|
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
|
using Microsoft.AspNetCore.Authorization;
|
|
using Microsoft.AspNetCore.Builder;
|
|
using Microsoft.Data.Sqlite;
|
|
using Microsoft.EntityFrameworkCore;
|
|
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>
|
|
/// Shared integration-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>A SQLite <c>:memory:</c> database
|
|
/// (<see cref="Microsoft.EntityFrameworkCore.Sqlite"/>) backed
|
|
/// by a single shared <see cref="SqliteConnection"/> held open
|
|
/// for the lifetime of the host. SQLite enforces real foreign
|
|
/// keys and real transactional semantics, so the tests see the
|
|
/// same INSERT-time FK validation a production Postgres host
|
|
/// would — unlike the EF Core InMemory provider, which silently
|
|
/// ignores FKs and masks bugs that surface only against a real
|
|
/// relational engine.</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. Marked <see cref="CollectionDefinitionAttribute"/> so the
|
|
/// host is shared across every <c>[Collection("Yavsc Blogs")]</c>
|
|
/// test class: one host, one SQLite DB, one Kestrel port.
|
|
/// </summary>
|
|
[CollectionDefinition("Yavsc Blogs")]
|
|
public sealed class BlogsWebServerFixture : WebHostFixture
|
|
{
|
|
protected override int HttpsPort => 5103;
|
|
|
|
// A single SqliteConnection held open at the static level,
|
|
// mirroring how Yavsc.Org.Tests.WebServerFixture hoists its
|
|
// shared configuration into static slots. Closing the
|
|
// connection destroys the in-memory database — so we close
|
|
// it only when the last fixture instance is disposed (see
|
|
// Dispose below), exactly when WebHostFixture tears down the
|
|
// host.
|
|
private static SqliteConnection? _sharedSqliteConnection;
|
|
private static readonly object _sqliteLock = new();
|
|
|
|
protected override WebApplication BuildApp(WebApplicationBuilder builder)
|
|
{
|
|
// Open the shared in-memory connection lazily on the first
|
|
// fixture construction. Subsequent constructions (xUnit
|
|
// creates one fixture instance per IClassFixture) reuse
|
|
// the same connection so all DbContexts across all tests
|
|
// see the same database.
|
|
SqliteConnection sharedConnection;
|
|
lock (_sqliteLock)
|
|
{
|
|
if (_sharedSqliteConnection is null)
|
|
{
|
|
// Mode=Memory + Cache=Shared gives us a named
|
|
// in-memory database that every connection string
|
|
// referencing "File:YavscBlogsTests?mode=memory&cache=shared"
|
|
// will resolve to the same backing store, as long
|
|
// as at least one SqliteConnection stays open
|
|
// against it.
|
|
_sharedSqliteConnection = new SqliteConnection(
|
|
"Data Source=YavscBlogsTests;Mode=Memory;Cache=Shared");
|
|
_sharedSqliteConnection.Open();
|
|
}
|
|
sharedConnection = _sharedSqliteConnection;
|
|
}
|
|
|
|
builder.Services.AddDbContext<ApplicationDbContext>(opt =>
|
|
// UseSqlite(DbConnection) keeps the connection we just
|
|
// opened alive for the DbContext's lifetime, instead of
|
|
// letting EF open and close its own. Without this,
|
|
// each DbContext would get a fresh connection pointing
|
|
// at an empty :memory: store and nothing would persist
|
|
// across requests.
|
|
opt.UseSqlite(sharedConnection));
|
|
|
|
// 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 = Yavsc.Constants.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;
|
|
}
|
|
|
|
public override void Dispose()
|
|
{
|
|
try
|
|
{
|
|
base.Dispose();
|
|
}
|
|
finally
|
|
{
|
|
// Close the shared SQLite connection only when the
|
|
// last fixture instance goes away, matching the
|
|
// lifetime contract of WebHostFixture.Dispose. We
|
|
// rely on base.Dispose's _instanceCount decrement
|
|
// having run, so we close only if the host is gone
|
|
// (base already nulled _app when count==0).
|
|
lock (_sqliteLock)
|
|
{
|
|
if (_sharedSqliteConnection is not null)
|
|
{
|
|
// Synchronous close: SQLite's Close() is
|
|
// documented as safe to call from a sync
|
|
// context and avoids the GetAwaiter().GetResult()
|
|
// pattern that's historically caused teardown
|
|
// hangs in this repo's async pipeline.
|
|
_sharedSqliteConnection.Close();
|
|
_sharedSqliteConnection.Dispose();
|
|
_sharedSqliteConnection = null;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>Seed an <see cref="ApplicationUser"/> in the shared
|
|
/// SQLite store, so tests that POST/PUT/DELETE a
|
|
/// <c>BlogPost</c> (whose <c>AuthorId</c> is a FK to
|
|
/// <c>AspNetUsers.Id</c>) don't trip the FK constraint that
|
|
/// SQLite enforces but the EF Core InMemory provider silently
|
|
/// ignored. Idempotent on <paramref name="userName"/>: a
|
|
/// second call for the same id is a no-op (the user already
|
|
/// exists).</summary>
|
|
/// <param name="userName">Both the PK id and the login name.
|
|
/// The JWT subject in tests is this same string, so seeding
|
|
/// this id is enough to make the FK from a
|
|
/// <c>BlogPost.AuthorId</c> resolve.</param>
|
|
/// <param name="configure">Optional hook to fill in fields
|
|
/// like <c>FullName</c> / <c>Avatar</c> / <c>EmailConfirmed</c>
|
|
/// that downstream tests assert on.</param>
|
|
public ApplicationUser SeedUser(string userName, Action<ApplicationUser>? configure = null)
|
|
{
|
|
using var scope = Services.CreateScope();
|
|
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
|
var existing = db.Users.SingleOrDefault(u => u.Id == userName);
|
|
if (existing != null) return existing;
|
|
|
|
// Email is an alternate key on ApplicationUser; seeding
|
|
// it explicitly avoids the InMemory provider's null-claim
|
|
// tracking quirk (cf. PublishEndpointTests.ResetDatabase)
|
|
// and keeps the column shape realistic for prod.
|
|
var user = new ApplicationUser
|
|
{
|
|
Id = userName,
|
|
UserName = userName,
|
|
Email = $"{userName}@example.test",
|
|
};
|
|
configure?.Invoke(user);
|
|
db.Users.Add(user);
|
|
db.SaveChanges();
|
|
return user;
|
|
}
|
|
|
|
/// <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)
|
|
{
|
|
}
|
|
}
|
|
}
|