yavsc/src/Yavsc.Blogs/Controllers/BlogAclApiController.cs

190 lines
6.7 KiB
C#
Raw Normal View History

2026-08-20 20:50:52 +01:00
2019-01-01 16:28:47 +00:00
using System.Security.Claims;
2023-03-19 17:57:55 +00:00
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Yavsc.Abstract.BlogSpot;
2019-01-01 16:28:47 +00:00
using Yavsc.Models;
using Yavsc.Models.Access;
using Yavsc.Server.Helpers;
2026-08-20 20:50:52 +01:00
using static Yavsc.Constants;
2019-01-01 16:28:47 +00:00
namespace Yavsc.Blogs.Controllers
2019-01-01 16:28:47 +00:00
{
[Produces("application/json")]
2026-08-20 20:50:52 +01:00
[Route(APIPrefix+"/blogacl")]
2019-01-01 16:28:47 +00:00
public class BlogAclApiController : Controller
{
2020-10-09 19:35:39 +01:00
private readonly ApplicationDbContext _context;
2019-01-01 16:28:47 +00:00
public BlogAclApiController(ApplicationDbContext context)
{
_context = context;
}
/// <summary>
/// Returns the ACL entries for the caller's own blog posts.
/// Blog posts (and therefore their ACLs) are private to their
/// author — the API never exposes another user's ACL.
/// </summary>
2026-08-20 20:50:52 +01:00
// GET: api/v1/blogacl
2019-01-01 16:28:47 +00:00
[HttpGet]
public IEnumerable<CircleAuthorizationToBlogPost> GetBlogACL()
{
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
return _context.CircleAuthorizationToBlogPost
.Include(a => a.Allowed)
.Where(a => a.Allowed.OwnerId == uid);
2019-01-01 16:28:47 +00:00
}
// GET: api/BlogAclApi/5
[HttpGet("{id}", Name = "GetCircleAuthorizationToBlogPost")]
public async Task<IActionResult> GetCircleAuthorizationToBlogPost([FromRoute] long id)
{
if (!ModelState.IsValid)
{
2023-03-19 17:57:55 +00:00
return BadRequest(ModelState);
2019-01-01 16:28:47 +00:00
}
2023-03-19 17:57:55 +00:00
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
2019-05-07 14:01:23 +01:00
CircleAuthorizationToBlogPost circleAuthorizationToBlogPost = await _context.CircleAuthorizationToBlogPost.SingleAsync(
2019-01-01 16:28:47 +00:00
m => m.CircleId == id && m.Allowed.OwnerId == uid );
if (circleAuthorizationToBlogPost == null)
{
2023-03-19 17:57:55 +00:00
return NotFound();
2019-01-01 16:28:47 +00:00
}
return Ok(circleAuthorizationToBlogPost);
}
// PUT: api/BlogAclApi/5
[HttpPut("{id}")]
public async Task<IActionResult> PutCircleAuthorizationToBlogPost([FromRoute] long id, [FromBody] CircleAuthorizationToBlogPost circleAuthorizationToBlogPost)
{
if (!ModelState.IsValid)
{
2023-03-19 17:57:55 +00:00
return BadRequest(ModelState);
2019-01-01 16:28:47 +00:00
}
if (id != circleAuthorizationToBlogPost.CircleId)
{
2023-03-19 17:57:55 +00:00
return BadRequest();
2019-01-01 16:28:47 +00:00
}
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.
2026-08-21 22:00:27 +01:00
if (!await CheckOwnerAsync(circleAuthorizationToBlogPost.CircleId))
2019-01-01 16:28:47 +00:00
{
return new ChallengeResult();
}
_context.Entry(circleAuthorizationToBlogPost).State = EntityState.Modified;
try
{
await _context.SaveChangesAsync(User.GetUserId());
}
catch (DbUpdateConcurrencyException)
{
if (!CircleAuthorizationToBlogPostExists(id))
{
2023-03-19 17:57:55 +00:00
return NotFound();
2019-01-01 16:28:47 +00:00
}
else
{
throw;
}
}
2023-03-19 17:57:55 +00:00
return new StatusCodeResult(StatusCodes.Status204NoContent);
2019-01-01 16:28:47 +00:00
}
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.
2026-08-21 22:00:27 +01:00
private async Task<bool> CheckOwnerAsync (long circleId)
2019-01-01 16:28:47 +00:00
{
2023-03-19 17:57:55 +00:00
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
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.
2026-08-21 22:00:27 +01:00
if (uid==null) return false;
var circle = await _context.Circle.FirstOrDefaultAsync(c=>c.Id==circleId);
if (circle == null) return false;
return circle.OwnerId == uid;
2019-01-01 16:28:47 +00:00
}
// POST: api/BlogAclApi
[HttpPost]
public async Task<IActionResult> PostCircleAuthorizationToBlogPost(
[FromBody] PostAccessControlRulePayload circleAuthorizationToBlogPost)
2019-01-01 16:28:47 +00:00
{
if (!ModelState.IsValid)
{
2023-03-19 17:57:55 +00:00
return BadRequest(ModelState);
2019-01-01 16:28:47 +00:00
}
// No 500: a missing or zero BlogPostId is a client
// error, not an EF Core FK violation waiting to happen.
// The 2026-08-21 prod 500 was this exact path (PostIt
// sent only circleId, server saw BlogPostId = 0 and
// SaveChangesAsync threw InvalidOperationException).
if (circleAuthorizationToBlogPost.BlogPostId <= 0)
{
return BadRequest("BlogPostId is required and must be > 0.");
}
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.
2026-08-21 22:00:27 +01:00
if (!await CheckOwnerAsync(circleAuthorizationToBlogPost.CircleId))
2019-01-01 16:28:47 +00:00
{
return new ChallengeResult();
}
CircleAuthorizationToBlogPost entity = new CircleAuthorizationToBlogPost
{
BlogPostId = circleAuthorizationToBlogPost.BlogPostId,
CircleId = circleAuthorizationToBlogPost.CircleId
};
_context.CircleAuthorizationToBlogPost.Add(entity);
2019-01-01 16:28:47 +00:00
try
{
await _context.SaveChangesAsync(User.GetUserId());
}
catch (DbUpdateException)
{
if (CircleAuthorizationToBlogPostExists(circleAuthorizationToBlogPost.CircleId))
{
2023-03-19 17:57:55 +00:00
return new StatusCodeResult(StatusCodes.Status409Conflict);
2019-01-01 16:28:47 +00:00
}
else
{
throw;
}
}
return CreatedAtRoute("GetCircleAuthorizationToBlogPost", new { id = circleAuthorizationToBlogPost.CircleId }, circleAuthorizationToBlogPost);
}
// DELETE: api/BlogAclApi/5
[HttpDelete("{id}")]
public async Task<IActionResult> DeleteCircleAuthorizationToBlogPost([FromRoute] long id)
{
if (!ModelState.IsValid)
{
2023-03-19 17:57:55 +00:00
return BadRequest(ModelState);
2019-01-01 16:28:47 +00:00
}
2023-03-19 17:57:55 +00:00
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
2019-01-01 16:28:47 +00:00
2019-05-07 14:01:23 +01:00
CircleAuthorizationToBlogPost circleAuthorizationToBlogPost = await _context.CircleAuthorizationToBlogPost.Include(
2019-01-01 16:28:47 +00:00
a=>a.Allowed
).SingleAsync(m => m.CircleId == id
&& m.Allowed.OwnerId == uid);
if (circleAuthorizationToBlogPost == null)
{
2023-03-19 17:57:55 +00:00
return NotFound();
2019-01-01 16:28:47 +00:00
}
2019-05-07 14:01:23 +01:00
_context.CircleAuthorizationToBlogPost.Remove(circleAuthorizationToBlogPost);
2019-01-01 16:28:47 +00:00
await _context.SaveChangesAsync(User.GetUserId());
return Ok(circleAuthorizationToBlogPost);
}
protected override void Dispose(bool disposing)
{
if (disposing)
{
_context.Dispose();
}
base.Dispose(disposing);
}
private bool CircleAuthorizationToBlogPostExists(long id)
{
2019-05-07 14:01:23 +01:00
return _context.CircleAuthorizationToBlogPost.Count(e => e.CircleId == id) > 0;
2019-01-01 16:28:47 +00:00
}
}
2020-10-09 19:35:39 +01:00
}