From e62adedc1782df80b387bb0823a261a69cc26b58 Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Fri, 28 Aug 2026 20:10:32 +0100 Subject: [PATCH] Fixes ACL backend support --- .vscode/tasks.json | 32 +++---- CHANGELOG.md | 10 +++ src/Yavsc.Abstract/Constants.cs | 7 ++ src/Yavsc.Blogs.Tests/BlogAclApiTests.cs | 90 ++++++++++++++++++- src/Yavsc.Blogs.Tests/BlogApiTests.cs | 21 +---- .../BlogsWebServerFixture.cs | 43 ++++++++- .../Controllers/BlogApiController.cs | 17 ++-- .../Controllers/BlogTagsApiController.cs | 2 +- .../Controllers/CircleApiController.cs | 2 +- .../Controllers/CommentsApiController.cs | 2 +- src/Yavsc.Server/Helpers/PayloadHelpers.cs | 21 +++++ src/Yavsc.Server/Models/Blog/BlogPost.cs | 3 +- src/Yavsc.Server/Models/Blog/BlogTag.cs | 3 + src/Yavsc.Server/Models/Blog/Comment.cs | 8 +- 14 files changed, 205 insertions(+), 56 deletions(-) create mode 100644 src/Yavsc.Server/Helpers/PayloadHelpers.cs diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 3c0c0bf2..3faedae0 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -1,5 +1,21 @@ { "version": "2.0.0", + "isRoot": true, + "problemMatcher": [ + { + "owner": "dotnet", + "fileLocation": ["relative", "${workspaceFolder}"], + "source": "dotnet", + "pattern": { + "regexp": "^\\s+(.*)\\((\\d+):(\\d+)\\):\\s+(error|warning)\\s+(.*)$", + "file": 1, + "line": 2, + "column": 3, + "severity": 4, + "message": 5 + } + } + ], "tasks": [ { "label": "run-debug-android", @@ -20,21 +36,6 @@ "-p:AndroidAttachDebugger=true", "-p:AndroidSdbHostPort=55555", "-p:AndroidSdbTargetPort=55555" - ], - "problemMatcher": [ - { - "owner": "dotnet", - "fileLocation": ["relative", "${workspaceFolder}"], - "source": "dotnet", - "pattern": { - "regexp": "^(.*):(\\d+):(\\d+):\\s+(error):\\s+(.*)$", - "file": 1, - "line": 2, - "column": 3, - "severity": 4, - "message": 5 - } - } ] }, { @@ -45,7 +46,6 @@ "group": "build", "isBuildCommand": true, "isTestCommand": false, - "problemMatcher": ["$msCompile"], "isBackground": true }, { diff --git a/CHANGELOG.md b/CHANGELOG.md index 6adf462a..8425c2c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,16 @@ Cette convention est partagée avec le dépôt [`postit-debian`](https://forgejo.pschneider.fr/notazof/postit-debian) pour la production des paquets `.deb`. +## [1.0.8-rc4] - unstable + +### Added + +### Changed + +### Fixed + +* [TODO] bug loading a blog post from PostIt, ACL come along with and don't need any "Refresh" button. + ## [1.0.8-rc3] - unstable ### Added diff --git a/src/Yavsc.Abstract/Constants.cs b/src/Yavsc.Abstract/Constants.cs index 78ff0838..a2649491 100644 --- a/src/Yavsc.Abstract/Constants.cs +++ b/src/Yavsc.Abstract/Constants.cs @@ -7,6 +7,13 @@ namespace Yavsc { public const string APIPrefix = "api/v1"; + + public const string BlogSpotPath = "blogspot"; + public const string BlogAclPath = "blogacl"; + public const string BlogTagPath = "blogtag"; + public const string CirclePath = "circle"; + public const string CommentsPath = "blogcomments"; + public static readonly Scope[] SiteScopes = { new Scope { Id = "profile", Description = "Your profile informations" }, new Scope { Id = "book" , Description ="Your booking interface"}, diff --git a/src/Yavsc.Blogs.Tests/BlogAclApiTests.cs b/src/Yavsc.Blogs.Tests/BlogAclApiTests.cs index 92dd3b2c..c140c290 100644 --- a/src/Yavsc.Blogs.Tests/BlogAclApiTests.cs +++ b/src/Yavsc.Blogs.Tests/BlogAclApiTests.cs @@ -1,10 +1,12 @@ using System.Net; using System.Net.Http.Json; +using System.Text.Json; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Yavsc.Abstract.BlogSpot; using Yavsc.Models; using Yavsc.Models.Access; +using Yavsc.Models.Blog; using Yavsc.Tests.Shared; using static Yavsc.Constants; @@ -38,15 +40,16 @@ public sealed class BlogAclApiTests : IClassFixture { private readonly BlogsWebServerFixture _fixture; - public BlogAclApiTests(BlogsWebServerFixture fixture) { _fixture = fixture; } + private string BlogUrl() + => $"{_fixture.Addresses.First(a => a.StartsWith("https://"))}/{APIPrefix}/{BlogSpotPath}"; private string BlogAclUrl() - => $"{_fixture.Addresses.First(a => a.StartsWith("https://"))}/{APIPrefix}/blogacl"; + => $"{_fixture.Addresses.First(a => a.StartsWith("https://"))}/{APIPrefix}/{BlogAclPath}"; /// Delete any ACL rows tied to the fixture's seeded /// (CircleId, BlogPostId) pair. The shared SQLite store @@ -120,7 +123,7 @@ public sealed class BlogAclApiTests : IClassFixture // 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"); + using var http = NewClient(_fixture.DefaultUserLogin); var payload = new PostAccessControlRulePayload { @@ -178,7 +181,7 @@ public sealed class BlogAclApiTests : IClassFixture [MemberData(nameof(BlogAclPayloadsForNever500))] public async Task PostCircleAuthorization_never_returns_500(PostAccessControlRulePayload payload) { - using var http = NewClient("alice"); + using var http = NewClient(_fixture.DefaultUserLogin); var response = await http.PostAsJsonAsync( BlogAclUrl(), payload, @@ -216,4 +219,83 @@ public sealed class BlogAclApiTests : IClassFixture ); } + + [Fact] + public async Task PostBlog_with_ACL_creates_a_post_and_Get_returns_it_in_the_list() + { + CleanupAcl(); + _fixture.SeedUser(_fixture.DefaultUserLogin); + _fixture.SeedUser("tester"); + _fixture.SeedCircle(_fixture.DefaultUserLogin, "test", + false, + new String[] + { + _fixture.DefaultUserLogin, + "tester" + }); + using var http = NewClient(_fixture.DefaultUserLogin ); + + // Create a minimal BlogPost. The server assigns Id, so we + // send 0 + an explicit AuthorId; the production + // BlogSpotService.Create() tolerates that. + var draft = new BlogPost + { + Id = 0, + Title = "Premier billet", + AuthorId = "tester", + Article = "Contenu de test.", + DateCreated = DateTime.UtcNow, + DateModified = DateTime.UtcNow, + ACL = new List( + new CircleAuthorizationToBlogPost[] + { + new CircleAuthorizationToBlogPost + { + CircleId = _fixture.CircleId, + BlogPostId = _fixture.PostId + } + } + ) + }; + + var postResponse = await http.PostAsJsonAsync( + BlogUrl(), + draft, + TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.Created, postResponse.StatusCode); + + // The POST returns the server-issued post (with a real Id). + var created = await postResponse.Content.ReadFromJsonAsync( + TestContext.Current.CancellationToken + ); + Assert.NotNull(created); + Assert.NotEqual(0, created!.Id); + Assert.Equal(draft.Title, created.Title); + + // The list should now contain exactly one entry. + var listResponse = await http.GetAsync( + BlogUrl(), + TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.OK, listResponse.StatusCode); + + using var doc = JsonDocument.Parse(await listResponse.Content.ReadAsStringAsync( + TestContext.Current.CancellationToken + )); + Assert.Equal(JsonValueKind.Array, doc.RootElement.ValueKind); + Assert.Equal(2, doc.RootElement.GetArrayLength()); + Assert.Equal(created.Id, doc.RootElement[0].GetProperty("id").GetInt64()); + + // detail should return the same post, with ACL and tags. + var detailResponse = await http.GetAsync( + $"{BlogUrl()}/{created.Id}", + TestContext.Current.CancellationToken); + Assert.Equal(HttpStatusCode.OK, detailResponse.StatusCode); + using var detailDoc = JsonDocument.Parse(await detailResponse.Content.ReadAsStringAsync( + TestContext.Current.CancellationToken + )); + Assert.Equal(JsonValueKind.Object, detailDoc.RootElement.ValueKind); + Assert.Equal(created.Id, detailDoc.RootElement.GetProperty("id").GetInt64()); + Assert.Equal(1, detailDoc.RootElement.GetProperty("acl").GetArrayLength()); + } + } diff --git a/src/Yavsc.Blogs.Tests/BlogApiTests.cs b/src/Yavsc.Blogs.Tests/BlogApiTests.cs index 5a218316..e438e0b8 100644 --- a/src/Yavsc.Blogs.Tests/BlogApiTests.cs +++ b/src/Yavsc.Blogs.Tests/BlogApiTests.cs @@ -2,7 +2,6 @@ using System.Net; using System.Net.Http.Json; using System.Security.Claims; using System.Text.Json; -using Microsoft.Extensions.DependencyInjection; using Yavsc.Models; using Yavsc.Models.Blog; using Yavsc.Server.Helpers; @@ -31,18 +30,6 @@ public sealed class BlogApiTests : IClassFixture _fixture = fixture; } - /// Reset the in-memory database to a known empty state. - /// UseInMemoryDatabase shares its store across the - /// lifetime of the instance, - /// so without a per-test reset the test order would leak - /// state between tests. - private void ResetDatabase() - { - using var scope = _fixture.Services.CreateScope(); - var db = scope.ServiceProvider.GetRequiredService(); - db.Database.EnsureDeleted(); - db.Database.EnsureCreated(); - } /// Reset the database and seed the /// tester row. Required @@ -55,7 +42,7 @@ public sealed class BlogApiTests : IClassFixture /// at SaveChanges and the controller returns 500. private void ResetAndSeedDefaultUser() { - ResetDatabase(); + _fixture.ResetDatabase(); _fixture.SeedUser("tester"); } @@ -111,7 +98,7 @@ public sealed class BlogApiTests : IClassFixture [Fact] public async Task GetBlogs_returns_200_with_empty_list_when_no_posts() { - ResetDatabase(); + _fixture.ResetDatabase(); using var http = NewClient(); var response = await http.GetAsync("/api/v1/blog", @@ -266,7 +253,7 @@ public sealed class BlogApiTests : IClassFixture [Fact] public async Task GetBlog_returns_401_when_no_token_is_provided() { - ResetDatabase(); + _fixture.ResetDatabase(); using var http = NewAnonymousClient(); // No Authorization header → the JwtBearer middleware @@ -443,7 +430,7 @@ public sealed class BlogApiTests : IClassFixture // behaviour so a future change that, say, makes Title // nullable in the model or drops [Required], triggers a // conscious update of the test (and probably of the VM). - ResetDatabase(); + _fixture.ResetDatabase(); using var http = NewClient(subject: "tester"); var draft = new BlogPost diff --git a/src/Yavsc.Blogs.Tests/BlogsWebServerFixture.cs b/src/Yavsc.Blogs.Tests/BlogsWebServerFixture.cs index 218904ef..695af4c5 100644 --- a/src/Yavsc.Blogs.Tests/BlogsWebServerFixture.cs +++ b/src/Yavsc.Blogs.Tests/BlogsWebServerFixture.cs @@ -61,6 +61,7 @@ public sealed class BlogsWebServerFixture : WebHostFixture public long CircleId { get; private set; } public long PostId { get; private set; } + public string DefaultUserLogin { get => "alice"; } // A single SqliteConnection held open at the static level, // mirroring how Yavsc.Org.Tests.WebServerFixture hoists its @@ -169,7 +170,8 @@ public sealed class BlogsWebServerFixture : WebHostFixture // PermissionHandler ownership check sees a null // user id and rejects every PUT. options.MapInboundClaims = false; - options.TokenValidationParameters = new TokenValidationParameters + options.TokenValidationParameters + = new TokenValidationParameters { ValidateIssuer = true, ValidIssuer = TestTokenIssuer.Issuer, @@ -263,6 +265,27 @@ public sealed class BlogsWebServerFixture : WebHostFixture await Task.CompletedTask; return app; } +/// Reset the in-memory database to a known empty state. + /// UseInMemoryDatabase shares its store across the + /// lifetime of the instance, + /// so without a per-test reset the test order would leak + /// state between tests. + public void ResetDatabase() + { + using var scope = Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + db.Database.EnsureDeleted(); + db.Database.EnsureCreated(); + } + public void CleanupAcl() + { + using var scope = Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + db.CircleAuthorizationToBlogPost + .Where(a => a.CircleId == CircleId + && a.BlogPostId == PostId) + .ExecuteDelete(); + } public override void Dispose() { @@ -310,7 +333,8 @@ public sealed class BlogsWebServerFixture : WebHostFixture /// Optional hook to fill in fields /// like FullName / Avatar / EmailConfirmed /// that downstream tests assert on. - public ApplicationUser SeedUser(string userName, Action? configure = null) + public ApplicationUser SeedUser(string userName, + Action? configure = null) { using var scope = Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); @@ -351,20 +375,31 @@ public sealed class BlogsWebServerFixture : WebHostFixture /// Create a circle owned by /// directly in the SQLite store and return its server-assigned /// id. - private long SeedCircle(string ownerId, string name, bool isPublic = false) + public long SeedCircle(string ownerId, string name, bool isPublic = false, + ICollection members = null + ) { using var scope = Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); var circle = new Circle { OwnerId = ownerId, Name = name, Public = isPublic }; db.Circle.Add(circle); db.SaveChanges(); + if (members != null && members.Count > 0) + { + foreach (String memberId in members) + { + var member = new CircleMember { CircleId = circle.Id, MemberId = memberId }; + db.CircleMembers.Add(member); + } + db.SaveChanges(); + } return circle.Id; } /// Create a blog post owned by /// directly in the SQLite store and return its server-assigned /// id. - private long SeedBlogPost(string authorId, string title) + public long SeedBlogPost(string authorId, string title) { using var scope = Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); diff --git a/src/Yavsc.Blogs/Controllers/BlogApiController.cs b/src/Yavsc.Blogs/Controllers/BlogApiController.cs index fcd3a336..8c76f7ee 100644 --- a/src/Yavsc.Blogs/Controllers/BlogApiController.cs +++ b/src/Yavsc.Blogs/Controllers/BlogApiController.cs @@ -9,7 +9,7 @@ namespace Yavsc.Blogs.Controllers { [Authorize("BlogScope")] [Produces("application/json")] - [Route(APIPrefix + "/blog")] + [Route(APIPrefix + "/" + BlogSpotPath)] public class BlogApiController : Controller { private readonly BlogSpotService blogSpotService; @@ -19,14 +19,14 @@ namespace Yavsc.Blogs.Controllers this.blogSpotService = blogSpotService; } - // GET: api/BlogApi + // GET: api/v1/blogspot [HttpGet] public async Task> GetBlogspot(int start = 0, int take = 25) { return await blogSpotService.Index(User, null, start, take); } - // GET: api/BlogApi/5 + // GET: api/v1/blogspot/5 [HttpGet("{id}", Name = "GetBlog")] public async Task GetBlog([FromRoute] long id) { @@ -43,7 +43,7 @@ namespace Yavsc.Blogs.Controllers return NotFound(); } - return Ok(blog); + return Ok(blog.GetPayload()); } catch (AuthorizationFailureException) { @@ -51,7 +51,7 @@ namespace Yavsc.Blogs.Controllers } } - // PUT: api/BlogApi/5 + // PUT: api/v1/blogspot/5 [HttpPut("{id}")] public async Task PutBlog(long id, [FromBody] Models.Blog.BlogPost blog) { @@ -83,7 +83,7 @@ namespace Yavsc.Blogs.Controllers return new StatusCodeResult(StatusCodes.Status204NoContent); } - // POST: api/v1/blog + // POST: api/v1/blogspot [HttpPost] public IActionResult PostBlog([FromBody] Models.Blog.BlogPost blog) { @@ -116,7 +116,8 @@ namespace Yavsc.Blogs.Controllers : (IFormFileCollection)new FormFileCollection(); var uid = User.GetUserId(); var post = blogSpotService.Create(uid, blog, files); - return CreatedAtRoute("GetBlog", new { id = post.Id }, post); + return CreatedAtRoute("GetBlog", new { id = post.Id }, + post.GetPayload()); } // DELETE: api/BlogApi/5 @@ -135,7 +136,7 @@ namespace Yavsc.Blogs.Controllers } await blogSpotService.Delete(User, id); - return Ok(blog); + return Ok(blog.GetPayload()); } /// diff --git a/src/Yavsc.Blogs/Controllers/BlogTagsApiController.cs b/src/Yavsc.Blogs/Controllers/BlogTagsApiController.cs index ad6a0893..533e5594 100644 --- a/src/Yavsc.Blogs/Controllers/BlogTagsApiController.cs +++ b/src/Yavsc.Blogs/Controllers/BlogTagsApiController.cs @@ -6,7 +6,7 @@ using static Yavsc.Constants; namespace Yavsc.Blogs.Controllers { [Produces("application/json")] - [Route(APIPrefix + "/blogtags")] + [Route(APIPrefix + "/" + BlogTagPath )] public class BlogTagsApiController : Controller { private readonly ApplicationDbContext _context; diff --git a/src/Yavsc.Blogs/Controllers/CircleApiController.cs b/src/Yavsc.Blogs/Controllers/CircleApiController.cs index 524f0471..fafd00ac 100644 --- a/src/Yavsc.Blogs/Controllers/CircleApiController.cs +++ b/src/Yavsc.Blogs/Controllers/CircleApiController.cs @@ -8,7 +8,7 @@ using static Yavsc.Constants; namespace Yavsc.Blogs.Controllers { [Produces("application/json")] - [Route(APIPrefix +"/circle")] + [Route(APIPrefix +"/" + CirclePath)] public class CircleApiController : Controller { private readonly ApplicationDbContext _context; diff --git a/src/Yavsc.Blogs/Controllers/CommentsApiController.cs b/src/Yavsc.Blogs/Controllers/CommentsApiController.cs index d4c80f99..d0747c29 100644 --- a/src/Yavsc.Blogs/Controllers/CommentsApiController.cs +++ b/src/Yavsc.Blogs/Controllers/CommentsApiController.cs @@ -11,7 +11,7 @@ namespace Yavsc.Blogs.Controllers { [Authorize] [Produces("application/json")] - [Route(APIPrefix + "/blogcomments")] + [Route(APIPrefix + "/" + CommentsPath)] public class CommentsApiController : Controller { private readonly ApplicationDbContext _context; diff --git a/src/Yavsc.Server/Helpers/PayloadHelpers.cs b/src/Yavsc.Server/Helpers/PayloadHelpers.cs new file mode 100644 index 00000000..293cab02 --- /dev/null +++ b/src/Yavsc.Server/Helpers/PayloadHelpers.cs @@ -0,0 +1,21 @@ +using Yavsc.Models.Blog; + +public static class PayloadHelpers +{ + public static object GetPayload(this BlogPost post) + { + return new + { + post.Id, + post.Title, + post.Article, + post.DateCreated, + post.UserCreated, + post.DateModified, + post.UserModified, + post.AuthorId, + ACL = post.GetACL(), + Tags = post.GetTags() + }; + } +} \ No newline at end of file diff --git a/src/Yavsc.Server/Models/Blog/BlogPost.cs b/src/Yavsc.Server/Models/Blog/BlogPost.cs index 5a2dc58d..e5fd615d 100644 --- a/src/Yavsc.Server/Models/Blog/BlogPost.cs +++ b/src/Yavsc.Server/Models/Blog/BlogPost.cs @@ -85,7 +85,7 @@ namespace Yavsc.Models.Blog public string[] GetTags() { - return Tags.Select(t => t.Tag.Name).ToArray(); + return Tags?.Select(t => t.Tag.Name).ToArray() ?? Array.Empty(); } [InverseProperty("Post")] @@ -106,6 +106,7 @@ namespace Yavsc.Models.Blog [NotMapped] public bool IsPublished { get; set; } + [JsonIgnore] /// /// Explicit interface implementation of /// . The underlying diff --git a/src/Yavsc.Server/Models/Blog/BlogTag.cs b/src/Yavsc.Server/Models/Blog/BlogTag.cs index 69d428de..1f15a081 100644 --- a/src/Yavsc.Server/Models/Blog/BlogTag.cs +++ b/src/Yavsc.Server/Models/Blog/BlogTag.cs @@ -1,14 +1,17 @@ using System.ComponentModel.DataAnnotations.Schema; +using System.Text.Json.Serialization; using Yavsc.Models.Relationship; namespace Yavsc.Models.Blog { public partial class BlogTag { + [JsonIgnore] [ForeignKey("PostId")] public virtual BlogPost Post { get; set; } public long PostId { get; set; } + [JsonIgnore] [ForeignKey("TagId")] public virtual Tag Tag{ get; set; } public long TagId { get; set; } diff --git a/src/Yavsc.Server/Models/Blog/Comment.cs b/src/Yavsc.Server/Models/Blog/Comment.cs index acb5e003..4d39d0c2 100644 --- a/src/Yavsc.Server/Models/Blog/Comment.cs +++ b/src/Yavsc.Server/Models/Blog/Comment.cs @@ -13,15 +13,17 @@ namespace Yavsc.Models.Blog [YaStringLength(1024)] public string Article { get; set; } - - [ForeignKeyAttribute(nameof(ReceiverId))][JsonIgnore] + + [JsonIgnore] + [ForeignKeyAttribute(nameof(ReceiverId))] public virtual BlogPost Post { get; set; } [Required] public long ReceiverId { get; set; } public bool Visible { get; set; } - [ForeignKeyAttribute("AuthorId")][JsonIgnore] + [ForeignKeyAttribute("AuthorId")] + [JsonIgnore] public virtual ApplicationUser Author { get; set; }