diff --git a/src/Yavsc.Blogs.Tests/BlogAclApiTests.cs b/src/Yavsc.Blogs.Tests/BlogAclApiTests.cs index 49259d05..b1bfd525 100644 --- a/src/Yavsc.Blogs.Tests/BlogAclApiTests.cs +++ b/src/Yavsc.Blogs.Tests/BlogAclApiTests.cs @@ -1,6 +1,5 @@ using System.Net; using System.Net.Http.Json; -using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Yavsc.Abstract.BlogSpot; using Yavsc.Models; @@ -50,24 +49,6 @@ public sealed class BlogAclApiTests : IClassFixture private string BlogAclUrl() => $"{_fixture.Addresses.First(a => a.StartsWith("https://"))}/{APIPrefix}/blogacl"; - /// Delete any ACL rows tied to the fixture's seeded - /// (CircleId, BlogPostId) 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. - private void CleanupAcl() - { - using var scope = _fixture.Services.CreateScope(); - var db = scope.ServiceProvider.GetRequiredService(); - db.CircleAuthorizationToBlogPost - .Where(a => a.CircleId == _fixture.CircleId - && a.BlogPostId == _fixture.PostId) - .ExecuteDelete(); - } - private HttpClient NewClient(string subject) { var handler = new HttpClientHandler @@ -98,6 +79,30 @@ public sealed class BlogAclApiTests : IClassFixture return http; } + /// PostIt sends only the FK ids (CircleId + + /// BlogPostId) plus scalar fields, never the navigation + /// properties Target / Allowed. The controller + /// must accept that shape and persist the ACL row. + [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); + } + /// /// Reproduces the prod 500 logged on 2026-08-21 on mercure: /// InvalidOperationException: The value of @@ -121,9 +126,9 @@ public sealed class BlogAclApiTests : IClassFixture // 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, @@ -144,33 +149,52 @@ public sealed class BlogAclApiTests : IClassFixture /// rows before sending, so every shape lands against a real /// principal entity and the seeded fixtures are not dead. /// - public static IEnumerable BlogAclPayloadsForNever500() + public static IEnumerable BlogAclPayloadsForNever500() { // circleId only (the historical bug shape, 2026-08-21 mercure): // must be rejected, never 500. - return new object[][] - { - [ - new PostAccessControlRulePayload - { - BlogPostId = -2, - CircleId = -1 - } - ], - [new PostAccessControlRulePayload - { - BlogPostId = 1, - CircleId = -1 - } - ], - [new PostAccessControlRulePayload - { - BlogPostId = 1, - CircleId = 1 - } - ] - } ; + 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 + }; } /// @@ -178,7 +202,8 @@ public sealed class BlogAclApiTests : IClassFixture /// [Theory] [MemberData(nameof(BlogAclPayloadsForNever500))] - public async Task PostCircleAuthorization_never_returns_500(PostAccessControlRulePayload payload) + public async Task PostCircleAuthorization_never_returns_500( + Dictionary payload) { using var http = NewClient("alice"); @@ -188,34 +213,4 @@ public sealed class BlogAclApiTests : IClassFixture 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 - } - ); - - } } diff --git a/src/Yavsc.Blogs.Tests/BlogsWebServerFixture.cs b/src/Yavsc.Blogs.Tests/BlogsWebServerFixture.cs index 218904ef..12e0801e 100644 --- a/src/Yavsc.Blogs.Tests/BlogsWebServerFixture.cs +++ b/src/Yavsc.Blogs.Tests/BlogsWebServerFixture.cs @@ -200,66 +200,6 @@ public sealed class BlogsWebServerFixture : WebHostFixture app.UseAuthentication(); app.UseAuthorization(); app.MapControllers(); - - // EnsureCreated + seed alice, run once at host startup. - // EnsureCreated is idempotent (creates only the tables that - // don't exist yet) and runs against the shared - // SqliteConnection (Cache=Shared), so every DbContext that - // resolves through this fixture's host sees the same schema. - // We do NOT call EnsureDeleted: the SqliteConnection is held - // open at the static level and closing it destroys the - // :memory: store for every other DbContext — the org - // fixture can afford EnsureDeleted because its store is - // built fresh per fixture, but the blogs fixture's static - // connection outlives a single fixture instance. - using (var seedScope = app.Services.CreateScope()) - { - var db = seedScope.ServiceProvider - .GetRequiredService(); - db.Database.EnsureCreated(); - if (!db.Users.Any(u => u.Id == "alice")) - { - db.Users.Add(new ApplicationUser - { - Id = "alice", - UserName = "alice", - Email = "alice@example.com", - EmailConfirmed = true, - FullName = "Alice Dupont", - Avatar = "/avatars/alice.png", - }); - db.SaveChanges(); - - // Inline the seed of the circle + post. We don't - // call SeedCircle/SeedBlogPost (the instance helpers) - // because those resolve through this.Services, which - // is null until WebHostFixture.InitializeAsync has - // finished wiring the shared slot — i.e. after this - // method returns. Use app.Services directly. - var circle = new Circle - { - OwnerId = "alice", - Name = "test", - Public = true, - }; - db.Circle.Add(circle); - db.SaveChanges(); - CircleId = circle.Id; - - var post = new BlogPost - { - AuthorId = "alice", - Title = "Billet ACL test", - Article = "Test article body.", - DateCreated = DateTime.UtcNow, - DateModified = DateTime.UtcNow, - }; - db.BlogSpot.Add(post); - db.SaveChanges(); - PostId = post.Id; - } - } - await Task.CompletedTask; return app; } @@ -348,6 +288,30 @@ public sealed class BlogsWebServerFixture : WebHostFixture + /// Reset the in-memory database and seed alice. + /// The shared SQLite :memory: store persists across + /// requests, so each test starts from a clean slate. + private void ResetDatabaseWithAlice() + { + using var scope = Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + db.Database.EnsureDeleted(); + db.Database.EnsureCreated(); + + db.Users.Add(new ApplicationUser + { + Id = "alice", + UserName = "alice", + Email = "alice@example.com", + EmailConfirmed = true, + FullName = "Alice Dupont", + Avatar = "/avatars/alice.png", + }); + db.SaveChanges(); + CircleId = SeedCircle("alice", "test", isPublic: true); + PostId = SeedBlogPost("alice", "Billet ACL test"); + } + /// Create a circle owned by /// directly in the SQLite store and return its server-assigned /// id. diff --git a/src/Yavsc.Blogs/Controllers/BlogAclApiController.cs b/src/Yavsc.Blogs/Controllers/BlogAclApiController.cs index a33d75d8..7bedbcfc 100644 --- a/src/Yavsc.Blogs/Controllers/BlogAclApiController.cs +++ b/src/Yavsc.Blogs/Controllers/BlogAclApiController.cs @@ -70,7 +70,7 @@ namespace Yavsc.Blogs.Controllers return BadRequest(); } - if (!await CheckOwnerAsync(circleAuthorizationToBlogPost.CircleId)) + if (!CheckOwner(circleAuthorizationToBlogPost.CircleId)) { return new ChallengeResult(); } @@ -94,13 +94,13 @@ namespace Yavsc.Blogs.Controllers return new StatusCodeResult(StatusCodes.Status204NoContent); } - private async Task CheckOwnerAsync (long circleId) + private bool CheckOwner (long circleId) { + var uid = User.FindFirstValue(ClaimTypes.NameIdentifier); - if (uid==null) return false; - var circle = await _context.Circle.FirstOrDefaultAsync(c=>c.Id==circleId); - if (circle == null) return false; - return circle.OwnerId == uid; + var circle = _context.Circle.First(c=>c.Id==circleId); + _context.Entry(circle).State = EntityState.Detached; + return (circle.OwnerId == uid); } // POST: api/BlogAclApi [HttpPost] @@ -111,16 +111,7 @@ namespace Yavsc.Blogs.Controllers { return BadRequest(ModelState); } - // 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."); - } - if (!await CheckOwnerAsync(circleAuthorizationToBlogPost.CircleId)) + if (!CheckOwner(circleAuthorizationToBlogPost.CircleId)) { return new ChallengeResult(); }