From 64547840e4a8e4b814a2eda73eda08dd20032d94 Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Wed, 5 Aug 2026 21:05:14 +0100 Subject: [PATCH 001/115] refacto BlogPost serialization --- src/Yavsc.Abstract/Blogspot/IBlogPost.cs | 3 +-- .../Identity/Security/ICircleAuthorized.cs | 8 ++++++-- src/Yavsc.Abstract/Interfaces/Models/ITaggable.cs | 4 ++-- src/Yavsc.Blogs.Tests/BlogApiTests.cs | 3 ++- src/Yavsc.Blogs/Controllers/BlogApiController.cs | 4 ++-- src/Yavsc.Org.Tests/appsettings.json | 1 + src/Yavsc.Org/Views/Blogspot/Index.cshtml | 12 ++++++------ src/Yavsc.Server/Models/Blog/BlogPost.cs | 4 ++-- 8 files changed, 22 insertions(+), 17 deletions(-) diff --git a/src/Yavsc.Abstract/Blogspot/IBlogPost.cs b/src/Yavsc.Abstract/Blogspot/IBlogPost.cs index 691e03f2..5287685d 100644 --- a/src/Yavsc.Abstract/Blogspot/IBlogPost.cs +++ b/src/Yavsc.Abstract/Blogspot/IBlogPost.cs @@ -7,9 +7,8 @@ using Yavsc.Interfaces; namespace Yavsc.Blogspot { - public interface IBlogPost : IBlogPostPayLoad, ICircleAuthorized, ITaggable, ITrackedEntity, IIdentified, ITitle + public interface IBlogPost : IBlogPostPayLoad, ICircleAuthorized, ITrackedEntity, ITitle { - string AuthorId { get; set; } IApplicationUser Author { get; } } } diff --git a/src/Yavsc.Abstract/Identity/Security/ICircleAuthorized.cs b/src/Yavsc.Abstract/Identity/Security/ICircleAuthorized.cs index cc108d2e..25c21961 100644 --- a/src/Yavsc.Abstract/Identity/Security/ICircleAuthorized.cs +++ b/src/Yavsc.Abstract/Identity/Security/ICircleAuthorized.cs @@ -1,10 +1,14 @@ +using Yavsc.Interfaces; + namespace Yavsc.Abstract.Identity.Security { - public interface ICircleAuthorized + public interface ICircleAuthorized : ITaggable { - long Id { get; set; } + string AuthorId { get; } + bool AuthorizeCircle(long circleId); + ICircleAuthorization [] GetACL(); } diff --git a/src/Yavsc.Abstract/Interfaces/Models/ITaggable.cs b/src/Yavsc.Abstract/Interfaces/Models/ITaggable.cs index 89a51d30..eb325b6f 100644 --- a/src/Yavsc.Abstract/Interfaces/Models/ITaggable.cs +++ b/src/Yavsc.Abstract/Interfaces/Models/ITaggable.cs @@ -1,9 +1,9 @@ namespace Yavsc.Interfaces { - public interface ITaggable + public interface ITaggable : IIdentified { string [] GetTags(); K Id { get; } } -} \ No newline at end of file +} diff --git a/src/Yavsc.Blogs.Tests/BlogApiTests.cs b/src/Yavsc.Blogs.Tests/BlogApiTests.cs index fd64555a..35d67fbe 100644 --- a/src/Yavsc.Blogs.Tests/BlogApiTests.cs +++ b/src/Yavsc.Blogs.Tests/BlogApiTests.cs @@ -239,7 +239,8 @@ public sealed class BlogApiTests : IClassFixture // The list should now be empty. var listResponse = await http.GetAsync("/api/v1/blog"); - using var doc = JsonDocument.Parse(await listResponse.Content.ReadAsStringAsync()); + String response = await listResponse.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(response); Assert.Equal(0, doc.RootElement.GetArrayLength()); } diff --git a/src/Yavsc.Blogs/Controllers/BlogApiController.cs b/src/Yavsc.Blogs/Controllers/BlogApiController.cs index 23d71742..ac7b59df 100644 --- a/src/Yavsc.Blogs/Controllers/BlogApiController.cs +++ b/src/Yavsc.Blogs/Controllers/BlogApiController.cs @@ -22,9 +22,9 @@ namespace Yavsc.Blogs.Controllers // GET: api/BlogApi [HttpGet] - public async Task> GetBlogspot(int start = 0, int take = 25) + public async Task> GetBlogspot(int start = 0, int take = 25) { - return await blogSpotService.Index(User, null, start, take); + return (await blogSpotService.Index(User, null, start, take)).Cast(); } // GET: api/BlogApi/5 diff --git a/src/Yavsc.Org.Tests/appsettings.json b/src/Yavsc.Org.Tests/appsettings.json index 5f353cd8..ef95fef6 100644 --- a/src/Yavsc.Org.Tests/appsettings.json +++ b/src/Yavsc.Org.Tests/appsettings.json @@ -1,6 +1,7 @@ { "Site": { "Authority": "https://localhost:5101", + "Audience": ["blogs"], "Title": "Yavsc dev", "Slogan": "Yavsc : WIP.", "Banner": "/images/yavsc.png", diff --git a/src/Yavsc.Org/Views/Blogspot/Index.cshtml b/src/Yavsc.Org/Views/Blogspot/Index.cshtml index d5dc27c9..52cf3b88 100644 --- a/src/Yavsc.Org/Views/Blogspot/Index.cshtml +++ b/src/Yavsc.Org/Views/Blogspot/Index.cshtml @@ -52,19 +52,19 @@
- +

@post.Title

- + @post.Article @Html.DisplayFor(m => post.Author) posté le @post.DateCreated.ToString("dddd d MMM yyyy à H:mm") @if ((post.DateModified - post.DateCreated).Minutes > 0){  @:- Modifié le @post.DateModified.ToString("dddd d MMM yyyy à H:mm") - }) + }
@@ -74,13 +74,13 @@ } else { - Details + Details } @if ((await AuthorizationService.AuthorizeAsync(User, post, new EditPermission())).Succeeded) { - Edit + Edit - Delete + Delete }
diff --git a/src/Yavsc.Server/Models/Blog/BlogPost.cs b/src/Yavsc.Server/Models/Blog/BlogPost.cs index e1b44603..442cbb1f 100644 --- a/src/Yavsc.Server/Models/Blog/BlogPost.cs +++ b/src/Yavsc.Server/Models/Blog/BlogPost.cs @@ -35,7 +35,7 @@ namespace Yavsc.Models.Blog public string? AuthorId { get; set; } [Display(Name = "Auteur")] - public virtual ApplicationUser? Author { set; get; } + public virtual ApplicationUser Author { set; get; } [Display(Name = "Date de création")] @@ -95,6 +95,6 @@ namespace Yavsc.Models.Blog [InverseProperty("Post")] public virtual List Comments { get; set; } - IApplicationUser IBlogPost.Author { get => this.Author; } + IApplicationUser IBlogPost.Author => Author; } } From cd03b04755cffda61179cded29ff74759c3a50f8 Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Wed, 5 Aug 2026 21:11:22 +0100 Subject: [PATCH 002/115] re-refacto BlogPost serialization --- src/Yavsc.Abstract/Interfaces/Models/ITaggable.cs | 2 -- src/Yavsc.Blogs/Controllers/BlogApiController.cs | 4 ++-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/Yavsc.Abstract/Interfaces/Models/ITaggable.cs b/src/Yavsc.Abstract/Interfaces/Models/ITaggable.cs index eb325b6f..bb4020a8 100644 --- a/src/Yavsc.Abstract/Interfaces/Models/ITaggable.cs +++ b/src/Yavsc.Abstract/Interfaces/Models/ITaggable.cs @@ -3,7 +3,5 @@ namespace Yavsc.Interfaces public interface ITaggable : IIdentified { string [] GetTags(); - - K Id { get; } } } diff --git a/src/Yavsc.Blogs/Controllers/BlogApiController.cs b/src/Yavsc.Blogs/Controllers/BlogApiController.cs index ac7b59df..23d71742 100644 --- a/src/Yavsc.Blogs/Controllers/BlogApiController.cs +++ b/src/Yavsc.Blogs/Controllers/BlogApiController.cs @@ -22,9 +22,9 @@ namespace Yavsc.Blogs.Controllers // GET: api/BlogApi [HttpGet] - public async Task> GetBlogspot(int start = 0, int take = 25) + public async Task> GetBlogspot(int start = 0, int take = 25) { - return (await blogSpotService.Index(User, null, start, take)).Cast(); + return await blogSpotService.Index(User, null, start, take); } // GET: api/BlogApi/5 From 44b391d496de0aa7bbb3521bbba13c5c1526791b Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Mon, 10 Aug 2026 18:12:59 +0100 Subject: [PATCH 003/115] Activity protection --- Directory.Build.props | 1 + .../Business/ActivityApiController.cs | 3 +- .../NativeConfidentialController.cs | 8 ++--- src/Yavsc.Blogs.Tests/BlogApiTests.cs | 32 +++++++++++++++++++ src/Yavsc.Org/Extensions/HostingExtensions.cs | 3 ++ .../Services/GoogleApis/CalendarManager.cs | 2 +- 6 files changed, 42 insertions(+), 7 deletions(-) diff --git a/Directory.Build.props b/Directory.Build.props index 873845c4..aec8c990 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -11,5 +11,6 @@ from without conflicting names. --> true + NU1701, NU1901, NU1902 diff --git a/src/Yavsc.Api/Controllers/Business/ActivityApiController.cs b/src/Yavsc.Api/Controllers/Business/ActivityApiController.cs index f6b215e7..d2da2ea7 100644 --- a/src/Yavsc.Api/Controllers/Business/ActivityApiController.cs +++ b/src/Yavsc.Api/Controllers/Business/ActivityApiController.cs @@ -15,7 +15,6 @@ namespace Yavsc.Controllers { [Produces("application/json")] [Route("api/activity")] - [AllowAnonymous] public class ActivityApiController : Controller { private ApplicationDbContext _context; @@ -88,7 +87,7 @@ namespace Yavsc.Controllers } // POST: api/ActivityApi - [HttpPost,Authorize("AdministratorOnly")] + [HttpPost, Authorize("AdministratorOnly")] public async Task PostActivity([FromBody] Activity activity) { if (!ModelState.IsValid) diff --git a/src/Yavsc.Api/Controllers/NativeConfidentialController.cs b/src/Yavsc.Api/Controllers/NativeConfidentialController.cs index 01cd8478..4e771830 100644 --- a/src/Yavsc.Api/Controllers/NativeConfidentialController.cs +++ b/src/Yavsc.Api/Controllers/NativeConfidentialController.cs @@ -1,15 +1,15 @@  -using System; -using System.Linq; + using System.Security.Claims; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Logging; -using Yavsc.Helpers; + using Yavsc.Models; using Yavsc.Models.Identity; using Yavsc.Server.Helpers; +#nullable enable + [Authorize, Route("~/api/gcm")] public class NativeConfidentialController : Controller { diff --git a/src/Yavsc.Blogs.Tests/BlogApiTests.cs b/src/Yavsc.Blogs.Tests/BlogApiTests.cs index 35d67fbe..bf0758c6 100644 --- a/src/Yavsc.Blogs.Tests/BlogApiTests.cs +++ b/src/Yavsc.Blogs.Tests/BlogApiTests.cs @@ -148,6 +148,38 @@ public sealed class BlogApiTests : IClassFixture Assert.Equal(created.Id, doc.RootElement[0].GetProperty("id").GetInt64()); } + [Fact] + public async Task PostBlog_sets_AuthorId_on_created_post_and_list_entry() + { + ResetDatabase(); + using var http = NewClient(subject: "tester"); + + var draft = new BlogPost + { + Id = 0, + Title = "Billet avec auteur", + AuthorId = "payload-attacker", + Article = "Contenu de test.", + DateCreated = DateTime.UtcNow, + DateModified = DateTime.UtcNow + }; + + var postResponse = await http.PostAsJsonAsync("/api/v1/blog", draft); + Assert.Equal(HttpStatusCode.Created, postResponse.StatusCode); + + var created = await postResponse.Content.ReadFromJsonAsync(); + Assert.NotNull(created); + Assert.Equal("tester", created!.AuthorId); + + var listResponse = await http.GetAsync("/api/v1/blog"); + Assert.Equal(HttpStatusCode.OK, listResponse.StatusCode); + + using var doc = JsonDocument.Parse(await listResponse.Content.ReadAsStringAsync()); + Assert.Equal(JsonValueKind.Array, doc.RootElement.ValueKind); + Assert.Equal(1, doc.RootElement.GetArrayLength()); + Assert.Equal("tester", doc.RootElement[0].GetProperty("authorId").GetString()); + } + [Fact] public async Task GetBlog_returns_401_when_no_token_is_provided() { diff --git a/src/Yavsc.Org/Extensions/HostingExtensions.cs b/src/Yavsc.Org/Extensions/HostingExtensions.cs index b3f90639..0cae23a7 100644 --- a/src/Yavsc.Org/Extensions/HostingExtensions.cs +++ b/src/Yavsc.Org/Extensions/HostingExtensions.cs @@ -1189,6 +1189,8 @@ ADD COLUMN IF NOT EXISTS ""Moderated"" boolean NOT NULL DEFAULT FALSE;"); } } +#nullable enable + static void LoadGoogleConfig(IConfigurationRoot configuration) { string? googleClientFile = configuration["Authentication:Google:GoogleWebClientJson"]; @@ -1204,6 +1206,7 @@ ADD COLUMN IF NOT EXISTS ""Moderated"" boolean NOT NULL DEFAULT FALSE;"); Config.GServiceAccount = JsonConvert.DeserializeObject(safile.OpenText().ReadToEnd()); } } +#nullable disable public static IApplicationBuilder ConfigureFileServerApp(this IApplicationBuilder app, bool enableDirectoryBrowsing = false) diff --git a/src/Yavsc.Server/Services/GoogleApis/CalendarManager.cs b/src/Yavsc.Server/Services/GoogleApis/CalendarManager.cs index 00f5a9b2..f91b3393 100644 --- a/src/Yavsc.Server/Services/GoogleApis/CalendarManager.cs +++ b/src/Yavsc.Server/Services/GoogleApis/CalendarManager.cs @@ -197,7 +197,7 @@ namespace Yavsc.Services if (credential.IsCreateScopedRequired) { credential = credential.CreateScoped(scopesCalendar); - }/* + }/* var credential = await GoogleHelpers.GetCredentialForApi(new string [] { scopeCalendar }); if (credential.IsCreateScopedRequired) { From 0d3fbf22c3e8e474b3464a39330522f80162781c Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Mon, 10 Aug 2026 18:34:01 +0100 Subject: [PATCH 004/115] GetUserId_reads_NameIdentifier_when_sub_was_mapped --- .../BlogApiMappedClaimsTests.cs | 156 ++++++++++++++++++ src/Yavsc.Blogs.Tests/BlogApiTests.cs | 14 ++ .../JwtClaimMappingCollection.cs | 8 + .../MappedClaimsBlogsWebServerFixture.cs | 109 ++++++++++++ src/Yavsc.Server/Helpers/UserHelpers.cs | 4 +- 5 files changed, 290 insertions(+), 1 deletion(-) create mode 100644 src/Yavsc.Blogs.Tests/BlogApiMappedClaimsTests.cs create mode 100644 src/Yavsc.Blogs.Tests/JwtClaimMappingCollection.cs create mode 100644 src/Yavsc.Blogs.Tests/MappedClaimsBlogsWebServerFixture.cs diff --git a/src/Yavsc.Blogs.Tests/BlogApiMappedClaimsTests.cs b/src/Yavsc.Blogs.Tests/BlogApiMappedClaimsTests.cs new file mode 100644 index 00000000..f02a1b99 --- /dev/null +++ b/src/Yavsc.Blogs.Tests/BlogApiMappedClaimsTests.cs @@ -0,0 +1,156 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Security.Claims; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.IdentityModel.Tokens; +using Yavsc.Models; +using Yavsc.Models.Blog; +using Yavsc.Tests.Shared; + +namespace Yavsc.Blogs.Tests; + +[Collection("JwtClaimMapping")] +public sealed class BlogApiMappedClaimsTests : IClassFixture +{ + private readonly MappedClaimsBlogsWebServerFixture _fixture; + + public BlogApiMappedClaimsTests(MappedClaimsBlogsWebServerFixture fixture) + { + _fixture = fixture; + } + + private void ResetDatabase() + { + using var scope = _fixture.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + db.Database.EnsureDeleted(); + db.Database.EnsureCreated(); + } + + private HttpClient NewClient(string subject = "tester") + { + var http = new HttpClient + { + BaseAddress = new Uri(_fixture.Addresses.First()) + }; + http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue( + "Bearer", + IssueMappedClaimsToken(subject)); + return http; + } + + private static string IssueMappedClaimsToken(string subject) + { + var now = DateTime.UtcNow; + var claims = new List + { + new("sub", subject), + new("scope", "blogs"), + }; + + var token = new JwtSecurityToken( + issuer: TestTokenIssuer.Issuer, + audience: TestTokenIssuer.Audience, + claims: claims, + notBefore: now, + expires: now.AddHours(1), + signingCredentials: new SigningCredentials( + TestTokenIssuer.SigningKey, + SecurityAlgorithms.HmacSha256)); + + return new JwtSecurityTokenHandler().WriteToken(token); + } + + [Fact] + public async Task PostBlog_with_mapped_sub_claim_sets_AuthorId_from_authenticated_user() + { + ResetDatabase(); + using var http = NewClient(subject: "mapped-user"); + + var draft = new BlogPost + { + Id = 0, + Title = "Billet JWT remappe", + AuthorId = "payload-attacker", + Article = "Contenu de test.", + DateCreated = DateTime.UtcNow, + DateModified = DateTime.UtcNow + }; + + var response = await http.PostAsJsonAsync("/api/v1/blog", draft); + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + + var created = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(created); + Assert.Equal("mapped-user", created!.AuthorId); + } + + [Fact] + public async Task PutBlog_with_mapped_sub_claim_allows_owner_to_update() + { + ResetDatabase(); + using var http = NewClient(subject: "mapped-owner"); + + var createdResponse = await http.PostAsJsonAsync("/api/v1/blog", new BlogPost + { + Id = 0, + Title = "Billet à modifier", + AuthorId = "payload-attacker", + Article = "Contenu initial.", + DateCreated = DateTime.UtcNow, + DateModified = DateTime.UtcNow + }); + + Assert.Equal(HttpStatusCode.Created, createdResponse.StatusCode); + var created = await createdResponse.Content.ReadFromJsonAsync(); + Assert.NotNull(created); + + var updateResponse = await http.PutAsJsonAsync($"/api/v1/blog/{created!.Id}", new BlogPost + { + Id = created.Id, + Title = "Billet modifié", + AuthorId = created.AuthorId, + Article = "Contenu mis à jour.", + DateCreated = created.DateCreated, + DateModified = DateTime.UtcNow + }); + + Assert.Equal(HttpStatusCode.NoContent, updateResponse.StatusCode); + } + + [Fact] + public async Task PutBlog_with_mapped_sub_claim_rejects_non_owner() + { + ResetDatabase(); + using var ownerHttp = NewClient(subject: "mapped-owner"); + + var createdResponse = await ownerHttp.PostAsJsonAsync("/api/v1/blog", new BlogPost + { + Id = 0, + Title = "Billet protégé", + AuthorId = "payload-attacker", + Article = "Contenu initial.", + DateCreated = DateTime.UtcNow, + DateModified = DateTime.UtcNow + }); + + Assert.Equal(HttpStatusCode.Created, createdResponse.StatusCode); + var created = await createdResponse.Content.ReadFromJsonAsync(); + Assert.NotNull(created); + + using var otherHttp = NewClient(subject: "mapped-other"); + var updateResponse = await otherHttp.PutAsJsonAsync($"/api/v1/blog/{created!.Id}", new BlogPost + { + Id = created.Id, + Title = "Tentative de modification", + AuthorId = created.AuthorId, + Article = "Contenu non autorisé.", + DateCreated = created.DateCreated, + DateModified = DateTime.UtcNow + }); + + Assert.Equal(HttpStatusCode.Unauthorized, updateResponse.StatusCode); + } +} diff --git a/src/Yavsc.Blogs.Tests/BlogApiTests.cs b/src/Yavsc.Blogs.Tests/BlogApiTests.cs index bf0758c6..7e725e4f 100644 --- a/src/Yavsc.Blogs.Tests/BlogApiTests.cs +++ b/src/Yavsc.Blogs.Tests/BlogApiTests.cs @@ -1,10 +1,12 @@ using System.Net; using System.Net.Http; 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; using Yavsc.Tests.Shared; namespace Yavsc.Blogs.Tests; @@ -20,6 +22,7 @@ namespace Yavsc.Blogs.Tests; /// header (or sending a token signed with the wrong key) gets a /// 401 back from the framework. /// +[Collection("JwtClaimMapping")] public sealed class BlogApiTests : IClassFixture { private readonly BlogsWebServerFixture _fixture; @@ -180,6 +183,17 @@ public sealed class BlogApiTests : IClassFixture Assert.Equal("tester", doc.RootElement[0].GetProperty("authorId").GetString()); } + [Fact] + public void GetUserId_reads_NameIdentifier_when_sub_was_mapped() + { + var principal = new ClaimsPrincipal( + new ClaimsIdentity( + [new Claim(ClaimTypes.NameIdentifier, "tester")], + authenticationType: "Bearer")); + + Assert.Equal("tester", principal.GetUserId()); + } + [Fact] public async Task GetBlog_returns_401_when_no_token_is_provided() { diff --git a/src/Yavsc.Blogs.Tests/JwtClaimMappingCollection.cs b/src/Yavsc.Blogs.Tests/JwtClaimMappingCollection.cs new file mode 100644 index 00000000..e141c0f3 --- /dev/null +++ b/src/Yavsc.Blogs.Tests/JwtClaimMappingCollection.cs @@ -0,0 +1,8 @@ +using Xunit; + +namespace Yavsc.Blogs.Tests; + +[CollectionDefinition("JwtClaimMapping", DisableParallelization = true)] +public sealed class JwtClaimMappingCollection +{ +} diff --git a/src/Yavsc.Blogs.Tests/MappedClaimsBlogsWebServerFixture.cs b/src/Yavsc.Blogs.Tests/MappedClaimsBlogsWebServerFixture.cs new file mode 100644 index 00000000..c9d95774 --- /dev/null +++ b/src/Yavsc.Blogs.Tests/MappedClaimsBlogsWebServerFixture.cs @@ -0,0 +1,109 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Storage; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.IdentityModel.Tokens; +using Yavsc.Blogs.Controllers; +using Yavsc.Models; +using Yavsc.Services; +using Yavsc.Tests.Shared; + +namespace Yavsc.Blogs.Tests; + +/// +/// Dedicated integration-test host that mirrors the production JWT +/// remapping behavior: MapInboundClaims remains enabled and the +/// default inbound map rewrites "sub" to ClaimTypes.NameIdentifier. +/// This is the closest in-process reproduction of the production +/// authentication surface for the blog API. +/// +public sealed class MappedClaimsBlogsWebServerFixture : IDisposable +{ + private readonly InMemoryDatabaseRoot _inMemoryRoot = new(); + private readonly Dictionary _savedInboundMap; + private readonly WebApplication _app; + + public MappedClaimsBlogsWebServerFixture() + { + _savedInboundMap = new Dictionary(JwtSecurityTokenHandler.DefaultInboundClaimTypeMap); + JwtSecurityTokenHandler.DefaultInboundClaimTypeMap["sub"] = ClaimTypes.NameIdentifier; + + var builder = WebApplication.CreateBuilder(); + builder.WebHost.UseUrls("http://127.0.0.1:5104"); + + builder.Services.AddDbContext(opt => + opt.UseInMemoryDatabase("Yavsc.Blogs.Tests.MappedClaims", _inMemoryRoot)); + + builder.Services.AddSingleton(new NoopFileSystemAuthManager()); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddControllers() + .AddApplicationPart(typeof(BlogApiController).Assembly); + builder.Services.AddAuthorization(opt => + { + opt.AddPolicy("BlogScope", policy => + { + policy.RequireAuthenticatedUser() + .RequireClaim("scope", "blogs"); + }); + }); + builder.Services.AddAuthentication("Bearer") + .AddJwtBearer("Bearer", options => + { + options.IncludeErrorDetails = true; + options.MapInboundClaims = true; + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidIssuer = TestTokenIssuer.Issuer, + ValidateAudience = false, + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + IssuerSigningKey = TestTokenIssuer.SigningKey, + RoleClaimType = YavscConstants.RoleClaimType, + NameClaimType = YavscConstants.NameClaimType, + }; + }); + + _app = builder.Build(); + _app.UseRouting(); + _app.UseAuthentication(); + _app.UseAuthorization(); + _app.MapControllers(); + _app.StartAsync().GetAwaiter().GetResult(); + + Addresses = ["http://127.0.0.1:5104"]; + Services = _app.Services; + } + + public IReadOnlyList Addresses { get; } + + public IServiceProvider Services { get; } + + public void Dispose() + { + _app.StopAsync().GetAwaiter().GetResult(); + _app.DisposeAsync().AsTask().GetAwaiter().GetResult(); + + JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear(); + foreach (var kvp in _savedInboundMap) + { + JwtSecurityTokenHandler.DefaultInboundClaimTypeMap[kvp.Key] = kvp.Value; + } + } + + private sealed class NoopFileSystemAuthManager : IFileSystemAuthManager + { + public FileAccessRight GetFilePathAccess(System.Security.Claims.ClaimsPrincipal user, string fileRelativePath) + => FileAccessRight.None; + + public void SetAccess(long circleId, string normalizedFullPath, FileAccessRight access) + { + } + } +} diff --git a/src/Yavsc.Server/Helpers/UserHelpers.cs b/src/Yavsc.Server/Helpers/UserHelpers.cs index 105c1beb..c3ee708d 100644 --- a/src/Yavsc.Server/Helpers/UserHelpers.cs +++ b/src/Yavsc.Server/Helpers/UserHelpers.cs @@ -32,7 +32,9 @@ namespace Yavsc.Server.Helpers public static string GetUserId(this ClaimsPrincipal user) { - return user.FindFirstValue("sub"); + return user.FindFirstValue("sub") + ?? user.FindFirstValue(ClaimTypes.NameIdentifier) + ?? user.FindFirstValue("nameid"); } public static string GetUserName(this ClaimsPrincipal user) From 0fd9e40d67db41055f16c192e930aafa0bad2fe5 Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Mon, 10 Aug 2026 21:48:03 +0100 Subject: [PATCH 005/115] Fix blog comment endpoint path and add regression test --- src/Yavsc.Blogs.Tests/BlogApiTests.cs | 36 +++++++++++++++++++ .../Communicating/BlogspotController.cs | 2 +- .../ViewComponents/CommentViewComponent.cs | 2 +- src/Yavsc.Org/Views/Blogspot/Details.cshtml | 6 ++-- 4 files changed, 41 insertions(+), 5 deletions(-) diff --git a/src/Yavsc.Blogs.Tests/BlogApiTests.cs b/src/Yavsc.Blogs.Tests/BlogApiTests.cs index 7e725e4f..cc7aaec8 100644 --- a/src/Yavsc.Blogs.Tests/BlogApiTests.cs +++ b/src/Yavsc.Blogs.Tests/BlogApiTests.cs @@ -183,6 +183,42 @@ public sealed class BlogApiTests : IClassFixture Assert.Equal("tester", doc.RootElement[0].GetProperty("authorId").GetString()); } + [Fact] + public async Task PostBlogComment_returns_201_for_existing_post() + { + ResetDatabase(); + using var http = NewClient(subject: "tester"); + + var draft = new BlogPost + { + Id = 0, + Title = "Billet commentable", + AuthorId = "payload-attacker", + Article = "Contenu de test.", + DateCreated = DateTime.UtcNow, + DateModified = DateTime.UtcNow + }; + + var postResponse = await http.PostAsJsonAsync("/api/v1/blog", draft); + Assert.Equal(HttpStatusCode.Created, postResponse.StatusCode); + + var createdPost = await postResponse.Content.ReadFromJsonAsync(); + Assert.NotNull(createdPost); + + var commentResponse = await http.PostAsJsonAsync("/api/v1/blogcomments", new + { + Article = "Premier commentaire", + ReceiverId = createdPost!.Id + }); + + Assert.Equal(HttpStatusCode.Created, commentResponse.StatusCode); + + using var doc = JsonDocument.Parse(await commentResponse.Content.ReadAsStringAsync()); + Assert.True(doc.RootElement.TryGetProperty("id", out var id)); + Assert.True(id.GetInt64() > 0); + Assert.True(doc.RootElement.TryGetProperty("dateCreated", out _)); + } + [Fact] public void GetUserId_reads_NameIdentifier_when_sub_was_mapped() { diff --git a/src/Yavsc.Org/Controllers/Communicating/BlogspotController.cs b/src/Yavsc.Org/Controllers/Communicating/BlogspotController.cs index de0c3c2d..8e2c9e5b 100644 --- a/src/Yavsc.Org/Controllers/Communicating/BlogspotController.cs +++ b/src/Yavsc.Org/Controllers/Communicating/BlogspotController.cs @@ -71,7 +71,7 @@ namespace Yavsc.Org.Controllers try { var blog = await blogSpotService.Details(User, id.Value); - ViewBag.apicmtctlr = "/api/blogcomments"; + ViewBag.apicmtctlr = "/api/v1/blogcomments"; ViewBag.moderatoFlag = User.IsInMsRole(YavscConstants.BlogModeratorGroupName); return View(blog); diff --git a/src/Yavsc.Org/ViewComponents/CommentViewComponent.cs b/src/Yavsc.Org/ViewComponents/CommentViewComponent.cs index 6752de94..62d3bb7a 100644 --- a/src/Yavsc.Org/ViewComponents/CommentViewComponent.cs +++ b/src/Yavsc.Org/ViewComponents/CommentViewComponent.cs @@ -23,7 +23,7 @@ namespace Yavsc.ViewComponents var comment = await context.Comment.Include(c=>c.Children).FirstOrDefaultAsync(c => c.Id==id); if (comment == null) throw new InvalidOperationException(); - ViewBag.apictlr = "/api/blogcomments"; + ViewBag.apictlr = "/api/v1/blogcomments"; return View("Default", comment); } diff --git a/src/Yavsc.Org/Views/Blogspot/Details.cshtml b/src/Yavsc.Org/Views/Blogspot/Details.cshtml index d7d5a791..dc0bea8a 100644 --- a/src/Yavsc.Org/Views/Blogspot/Details.cshtml +++ b/src/Yavsc.Org/Views/Blogspot/Details.cshtml @@ -7,7 +7,7 @@