Fixes ACL backend support

This commit is contained in:
Paul Schneider 2026-08-28 20:10:32 +01:00
commit e62adedc17
Signed by: notazof
GPG key ID: 1DD5D838E5343B06
14 changed files with 205 additions and 56 deletions

32
.vscode/tasks.json vendored
View file

@ -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
},
{

View file

@ -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

View file

@ -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"},

View file

@ -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<BlogsWebServerFixture>
{
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}";
/// <summary>Delete any ACL rows tied to the fixture's seeded
/// <c>(CircleId, BlogPostId)</c> pair. The shared SQLite store
@ -120,7 +123,7 @@ public sealed class BlogAclApiTests : IClassFixture<BlogsWebServerFixture>
// 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<BlogsWebServerFixture>
[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<BlogsWebServerFixture>
);
}
[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<CircleAuthorizationToBlogPost>(
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<BlogPost>(
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());
}
}

View file

@ -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<BlogsWebServerFixture>
_fixture = fixture;
}
/// <summary>Reset the in-memory database to a known empty state.
/// <c>UseInMemoryDatabase</c> shares its store across the
/// lifetime of the <see cref="BlogsWebServerFixture"/> instance,
/// so without a per-test reset the test order would leak
/// state between tests.</summary>
private void ResetDatabase()
{
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
db.Database.EnsureDeleted();
db.Database.EnsureCreated();
}
/// <summary>Reset the database and seed the
/// <c>tester</c> <see cref="ApplicationUser"/> row. Required
@ -55,7 +42,7 @@ public sealed class BlogApiTests : IClassFixture<BlogsWebServerFixture>
/// at <c>SaveChanges</c> and the controller returns 500.</summary>
private void ResetAndSeedDefaultUser()
{
ResetDatabase();
_fixture.ResetDatabase();
_fixture.SeedUser("tester");
}
@ -111,7 +98,7 @@ public sealed class BlogApiTests : IClassFixture<BlogsWebServerFixture>
[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<BlogsWebServerFixture>
[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<BlogsWebServerFixture>
// 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

View file

@ -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;
}
/// <summary>Reset the in-memory database to a known empty state.
/// <c>UseInMemoryDatabase</c> shares its store across the
/// lifetime of the <see cref="BlogsWebServerFixture"/> instance,
/// so without a per-test reset the test order would leak
/// state between tests.</summary>
public void ResetDatabase()
{
using var scope = Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
db.Database.EnsureDeleted();
db.Database.EnsureCreated();
}
public void CleanupAcl()
{
using var scope = Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
db.CircleAuthorizationToBlogPost
.Where(a => a.CircleId == CircleId
&& a.BlogPostId == PostId)
.ExecuteDelete();
}
public override void Dispose()
{
@ -310,7 +333,8 @@ public sealed class BlogsWebServerFixture : WebHostFixture
/// <param name="configure">Optional hook to fill in fields
/// like <c>FullName</c> / <c>Avatar</c> / <c>EmailConfirmed</c>
/// that downstream tests assert on.</param>
public ApplicationUser SeedUser(string userName, Action<ApplicationUser>? configure = null)
public ApplicationUser SeedUser(string userName,
Action<ApplicationUser>? configure = null)
{
using var scope = Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
@ -351,20 +375,31 @@ public sealed class BlogsWebServerFixture : WebHostFixture
/// <summary>Create a circle owned by <paramref name="ownerId"/>
/// directly in the SQLite store and return its server-assigned
/// id.</summary>
private long SeedCircle(string ownerId, string name, bool isPublic = false)
public long SeedCircle(string ownerId, string name, bool isPublic = false,
ICollection<String> members = null
)
{
using var scope = Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
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;
}
/// <summary>Create a blog post owned by <paramref name="authorId"/>
/// directly in the SQLite store and return its server-assigned
/// id.</summary>
private long SeedBlogPost(string authorId, string title)
public long SeedBlogPost(string authorId, string title)
{
using var scope = Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();

View file

@ -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<IEnumerable<IBlogPost>> 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<IActionResult> 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<IActionResult> 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());
}
/// <summary>

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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()
};
}
}

View file

@ -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<string>();
}
[InverseProperty("Post")]
@ -106,6 +106,7 @@ namespace Yavsc.Models.Blog
[NotMapped]
public bool IsPublished { get; set; }
[JsonIgnore]
/// <summary>
/// Explicit interface implementation of
/// <see cref="IBlogPost.Author"/>. The underlying

View file

@ -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; }

View file

@ -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;
}