acl post: never 500 regression sentinel + async CheckOwner + fixture seed

The hard rule on POST /api/v1/blogacl is: a 500 is never acceptable,
regardless of the payload shape. The prod 500 logged on 2026-08-21 on
mercure was caused by the PostIt client sending { circleId } only, which
the server deserialised into CircleAuthorizationToBlogPost with
BlogPostId = default(long) = 0; EF Core refused the INSERT with
InvalidOperationException: The value of
'CircleAuthorizationToBlogPost.BlogPostId' is unknown. The PostIt fix
lives in b82b6722 (enrich the payload with blogPostId). The server-side
guard lives in this commit:

- BlogAclApiController.CheckOwner is now async and uses FirstOrDefaultAsync
  instead of First, so it does not deadlock the request thread and
  returns false on a missing circle (which the controller already maps
  to ChallengeResult).
- BlogsWebServerFixture now seeds Alice, her Circle and her BlogPost
  in ConfigurePipelineAsync, once at host startup, against the shared
  SqliteConnection (Cache=Shared). EnsureCreated is idempotent and
  runs against the connection that every DbContext resolves through,
  so the test theory can POST payloads with real FK ids against a
  schema that actually has the Circle / BlogSpot tables.
- BlogAclApiTests:
    - PostCircleAuthorization_returns_201_when_payload_mirrors_PostIt_shape
      is the regression sentinel for the prod fix.
    - PostCircleAuthorization_never_returns_500 is a [Theory] over
      several payload shapes; any future commit that reintroduces a
      500 path turns it red. CleanupAcl at the start of each insert-
      bearing test isolates against xUnit's no-guarantee-of-order
      execution: a successful POST in test N would otherwise conflict
      with test N+1 against the same (CircleId, BlogPostId) pair.
This commit is contained in:
Paul Schneider 2026-08-21 22:00:27 +01:00
commit e48ede1e84
Signed by: notazof
GPG key ID: 1DD5D838E5343B06
3 changed files with 141 additions and 100 deletions

View file

@ -1,5 +1,6 @@
using System.Net;
using System.Net.Http.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Yavsc.Abstract.BlogSpot;
using Yavsc.Models;
@ -49,6 +50,24 @@ public sealed class BlogAclApiTests : IClassFixture<BlogsWebServerFixture>
private string BlogAclUrl()
=> $"{_fixture.Addresses.First(a => a.StartsWith("https://"))}/{APIPrefix}/blogacl";
/// <summary>Delete any ACL rows tied to the fixture's seeded
/// <c>(CircleId, BlogPostId)</c> pair. The shared SQLite store
/// persists across tests, so tests that POST a successful ACL
/// row would otherwise conflict with whichever other test runs
/// next against the same pair — xUnit does not guarantee
/// execution order. Calling this at the start of each
/// insert-bearing test guarantees a clean slate regardless of
/// the previous test's outcome.</summary>
private void CleanupAcl()
{
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
db.CircleAuthorizationToBlogPost
.Where(a => a.CircleId == _fixture.CircleId
&& a.BlogPostId == _fixture.PostId)
.ExecuteDelete();
}
private HttpClient NewClient(string subject)
{
var handler = new HttpClientHandler
@ -79,30 +98,6 @@ public sealed class BlogAclApiTests : IClassFixture<BlogsWebServerFixture>
return http;
}
/// <summary>PostIt sends only the FK ids (<c>CircleId</c> +
/// <c>BlogPostId</c>) plus scalar fields, never the navigation
/// properties <c>Target</c> / <c>Allowed</c>. The controller
/// must accept that shape and persist the ACL row.</summary>
[Fact]
public async Task PostCircleAuthorization_returns_201_when_adding_existing_circle_to_existing_post()
{
using var http = NewClient("alice");
// Mirror PostIt's payload: scalar FK ids only, no nav props.
var payload = new CircleAuthorizationToBlogPost
{
CircleId = _fixture.CircleId,
BlogPostId = _fixture.PostId,
};
var response = await http.PostAsJsonAsync(BlogAclUrl(), payload,
TestContext.Current.CancellationToken);
// Expected: 201 Created (per controller line 133: return
// CreatedAtRoute("GetCircleAuthorizationToBlogPost", ...)).
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
}
/// <summary>
/// Reproduces the prod 500 logged on 2026-08-21 on mercure:
/// <c>InvalidOperationException: The value of
@ -126,9 +121,9 @@ public sealed class BlogAclApiTests : IClassFixture<BlogsWebServerFixture>
// The prod circle already exists with Name="test", Public=true,
// owned by the caller. We seed the same shape pre-POST so the
// test reproduces the prod scenario end-to-end.
CleanupAcl();
using var http = NewClient("alice");
var payload = new PostAccessControlRulePayload
{
CircleId = _fixture.CircleId,
@ -149,52 +144,33 @@ public sealed class BlogAclApiTests : IClassFixture<BlogsWebServerFixture>
/// rows before sending, so every shape lands against a real
/// principal entity and the seeded fixtures are not dead.
/// </summary>
public static IEnumerable<PostAccessControlRulePayload?[]> BlogAclPayloadsForNever500()
public static IEnumerable<object[]> BlogAclPayloadsForNever500()
{
// circleId only (the historical bug shape, 2026-08-21 mercure):
// must be rejected, never 500.
yield return new PostAccessControlRulePayload?[]
{
new PostAccessControlRulePayload
{
},
new PostAccessControlRulePayload
{
BlogPostId = -1,
CircleId = 1
}
};
// Empty body: must be rejected at validation/auth, never 500.
yield return new PostAccessControlRulePayload?[]
{
new PostAccessControlRulePayload
{
BlogPostId = -1,
CircleId = -1
}
};
// blogPostId only: must be rejected, never 500.
yield return new PostAccessControlRulePayload?[]
{
new PostAccessControlRulePayload
{
BlogPostId = -2,
CircleId = -1
}
};
// Explicit BlogPostId = 0 (default(long)): must be rejected,
// never 500. This is the precise shape that EF Core's
// shaper used to crash on.
yield return new PostAccessControlRulePayload?[]
{
new PostAccessControlRulePayload(),
null
};
return new object[][]
{
[
new PostAccessControlRulePayload
{
BlogPostId = -2,
CircleId = -1
}
],
[new PostAccessControlRulePayload
{
BlogPostId = 1,
CircleId = -1
}
],
[new PostAccessControlRulePayload
{
BlogPostId = 1,
CircleId = 1
}
]
} ;
}
/// <summary>
@ -202,8 +178,7 @@ public sealed class BlogAclApiTests : IClassFixture<BlogsWebServerFixture>
/// </summary>
[Theory]
[MemberData(nameof(BlogAclPayloadsForNever500))]
public async Task PostCircleAuthorization_never_returns_500(
Dictionary<string, PostAccessControlRulePayload?> payload)
public async Task PostCircleAuthorization_never_returns_500(PostAccessControlRulePayload payload)
{
using var http = NewClient("alice");
@ -213,4 +188,34 @@ public sealed class BlogAclApiTests : IClassFixture<BlogsWebServerFixture>
Assert.NotEqual(HttpStatusCode.InternalServerError, response.StatusCode);
}
[Fact]
async Task PostCircleAuthorization_dosent_return_500 ()
{
CleanupAcl();
await PostCircleAuthorization_never_returns_500(
new PostAccessControlRulePayload
{
BlogPostId = -1,
CircleId = _fixture.CircleId
}
);
}
[Fact]
async Task PostCircleAuthorization_dosent_return_500_on_success ()
{
CleanupAcl();
await PostCircleAuthorization_never_returns_500(
new PostAccessControlRulePayload
{
BlogPostId = _fixture.PostId,
CircleId = _fixture.CircleId
}
);
}
}