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

@ -70,7 +70,7 @@ namespace Yavsc.Blogs.Controllers
return BadRequest();
}
if (!CheckOwner(circleAuthorizationToBlogPost.CircleId))
if (!await CheckOwnerAsync(circleAuthorizationToBlogPost.CircleId))
{
return new ChallengeResult();
}
@ -94,13 +94,13 @@ namespace Yavsc.Blogs.Controllers
return new StatusCodeResult(StatusCodes.Status204NoContent);
}
private bool CheckOwner (long circleId)
private async Task<bool> CheckOwnerAsync (long circleId)
{
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
var circle = _context.Circle.First(c=>c.Id==circleId);
_context.Entry(circle).State = EntityState.Detached;
return (circle.OwnerId == uid);
if (uid==null) return false;
var circle = await _context.Circle.FirstOrDefaultAsync(c=>c.Id==circleId);
if (circle == null) return false;
return circle.OwnerId == uid;
}
// POST: api/BlogAclApi
[HttpPost]
@ -111,7 +111,7 @@ namespace Yavsc.Blogs.Controllers
{
return BadRequest(ModelState);
}
if (!CheckOwner(circleAuthorizationToBlogPost.CircleId))
if (!await CheckOwnerAsync(circleAuthorizationToBlogPost.CircleId))
{
return new ChallengeResult();
}