Compare commits
No commits in common. "d2a0c263dd4a6b8bbdaa97325af99bed93cb43a9" and "495689023602ce67dca57c5fa2ada7e300a431f5" have entirely different histories.
d2a0c263dd
...
4956890236
3 changed files with 100 additions and 150 deletions
|
|
@ -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<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
|
||||
|
|
@ -98,6 +79,30 @@ 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
|
||||
|
|
@ -121,9 +126,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,
|
||||
|
|
@ -144,33 +149,52 @@ 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<object[]> BlogAclPayloadsForNever500()
|
||||
public static IEnumerable<PostAccessControlRulePayload?[]> BlogAclPayloadsForNever500()
|
||||
{
|
||||
|
||||
// circleId only (the historical bug shape, 2026-08-21 mercure):
|
||||
// must be rejected, never 500.
|
||||
return new object[][]
|
||||
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
|
||||
}
|
||||
],
|
||||
[new PostAccessControlRulePayload
|
||||
};
|
||||
|
||||
// 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?[]
|
||||
{
|
||||
BlogPostId = 1,
|
||||
CircleId = -1
|
||||
}
|
||||
],
|
||||
[new PostAccessControlRulePayload
|
||||
{
|
||||
BlogPostId = 1,
|
||||
CircleId = 1
|
||||
}
|
||||
]
|
||||
} ;
|
||||
new PostAccessControlRulePayload(),
|
||||
null
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -178,7 +202,8 @@ public sealed class BlogAclApiTests : IClassFixture<BlogsWebServerFixture>
|
|||
/// </summary>
|
||||
[Theory]
|
||||
[MemberData(nameof(BlogAclPayloadsForNever500))]
|
||||
public async Task PostCircleAuthorization_never_returns_500(PostAccessControlRulePayload payload)
|
||||
public async Task PostCircleAuthorization_never_returns_500(
|
||||
Dictionary<string, PostAccessControlRulePayload?> payload)
|
||||
{
|
||||
using var http = NewClient("alice");
|
||||
|
||||
|
|
@ -188,34 +213,4 @@ 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
|
||||
}
|
||||
);
|
||||
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<ApplicationDbContext>();
|
||||
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
|
|||
|
||||
|
||||
|
||||
/// <summary>Reset the in-memory database and seed <c>alice</c>.
|
||||
/// The shared SQLite <c>:memory:</c> store persists across
|
||||
/// requests, so each test starts from a clean slate.</summary>
|
||||
private void ResetDatabaseWithAlice()
|
||||
{
|
||||
using var scope = Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||
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");
|
||||
}
|
||||
|
||||
/// <summary>Create a circle owned by <paramref name="ownerId"/>
|
||||
/// directly in the SQLite store and return its server-assigned
|
||||
/// id.</summary>
|
||||
|
|
|
|||
|
|
@ -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<bool> 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();
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue