From ad5e9090ee22d8d8682a04b9f3ea880612d4821e Mon Sep 17 00:00:00 2001 From: Paul Schneider Date: Sun, 6 Sep 2026 19:16:41 +0100 Subject: [PATCH] fix(api): harden billing/blog validation and update rc14 changelog --- CHANGELOG.md | 15 +++++ .../Fixtures/ApiWebServerFixture.cs | 12 ++++ .../FrontOfficeApiControllerTests.cs | 3 +- .../RdvQueryApiControllerTests.cs | 30 +++++++++ .../Business/ActivityApiController.cs | 36 +++++++++-- .../Business/FrontOfficeApiController.cs | 9 +-- .../Business/HairCutQueryApiController.cs | 18 +++--- .../HairMultiCutQueryApiController.cs | 18 +++--- .../Business/RdvQueryApiController.cs | 64 ++++++++++++------- .../Controllers/BlogApiController.cs | 16 +++++ .../Communicating/CommentsController.cs | 3 +- .../Models/ApplicationDbContext.cs | 1 + src/Yavsc.Server/Models/Workflow/Activity.cs | 2 +- 13 files changed, 171 insertions(+), 56 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6df33e446..0befe9eb3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +## [1.0.8-rc14] - unstable + +### Added + +### Changed + +### Fixed + +* [Yavsc.Api] Correction d'un 500 sur le refresh du catalogue d'activites lorsque `Activity.Description` est `NULL` en base (nullabilite explicite + projection null-safe + gardes sur codes vides). +* [Yavsc.Api] Correction des erreurs 400/500 sur les routes billing (`Rdv`, `Brush`, `MBrush`) en imposant `ClientId` depuis l'utilisateur authentifie et en ignorant les champs server-owned lors de la validation modele. +* [Yavsc.Api] Correction du `PUT /api/v1/billing/Rdv/{id}`: mise a jour controlee de l'entite existante (et non remplacement brut du graphe JSON), ce qui supprime les `BadRequest` parasites. +* [Yavsc.Api] Correction du flux FrontOffice accept/reject de query: sauvegarde avec contexte utilisateur et fallback d'injection pour `IBillingService` afin d'eviter les erreurs serveur en environnement de test. +* [Yavsc.Blogs] Correction des `BadRequest` sur `POST/PUT /api/v1/blogspot` avec payload JSON (PostIt): les proprietes de navigation/serveur (`Author`, `Tags`, `Comments`, audit) ne bloquent plus la validation. +* [Yavsc.Org] Correction du flux MVC de creation de commentaire: `SaveChangesAsync(userId)` est utilise pour renseigner les champs d'audit requis (`UserCreated`/`UserModified`). +* [Yavsc.Api.Test] Stabilisation des fixtures de seed billing: remplissage des metadonnees d'audit (`UserCreated`, `UserModified`, dates) pour eviter les echecs SQLite `NOT NULL`. ## [1.0.8-rc13] - unstable diff --git a/src/Yavsc.Api.Test/Fixtures/ApiWebServerFixture.cs b/src/Yavsc.Api.Test/Fixtures/ApiWebServerFixture.cs index ca4ae70f9..e2a8b3376 100644 --- a/src/Yavsc.Api.Test/Fixtures/ApiWebServerFixture.cs +++ b/src/Yavsc.Api.Test/Fixtures/ApiWebServerFixture.cs @@ -204,6 +204,10 @@ public sealed class ApiWebServerFixture : WebHostFixture ClientId = "alice", PerformerId = "alice", Consent = true, + UserCreated = "alice", + UserModified = "alice", + DateCreated = DateTime.UtcNow, + DateModified = DateTime.UtcNow, EventDate = DateTime.UtcNow.AddDays(1), Location = location, Reason = "Initial rendez-vous", @@ -293,6 +297,10 @@ public sealed class ApiWebServerFixture : WebHostFixture ClientId = "alice", PerformerId = "alice", Consent = true, + UserCreated = "alice", + UserModified = "alice", + DateCreated = DateTime.UtcNow, + DateModified = DateTime.UtcNow, EventDate = DateTime.UtcNow.AddDays(3), Location = location, PrestationId = prestation1.Id, @@ -308,6 +316,10 @@ public sealed class ApiWebServerFixture : WebHostFixture ClientId = "alice", PerformerId = "alice", Consent = true, + UserCreated = "alice", + UserModified = "alice", + DateCreated = DateTime.UtcNow, + DateModified = DateTime.UtcNow, EventDate = DateTime.UtcNow.AddDays(4), Location = location, Prestations = new List diff --git a/src/Yavsc.Api.Test/FrontOfficeApiControllerTests.cs b/src/Yavsc.Api.Test/FrontOfficeApiControllerTests.cs index 676056d3f..97ed2e61c 100644 --- a/src/Yavsc.Api.Test/FrontOfficeApiControllerTests.cs +++ b/src/Yavsc.Api.Test/FrontOfficeApiControllerTests.cs @@ -49,8 +49,9 @@ public sealed class FrontOfficeApiControllerTests : IClassFixture(); diff --git a/src/Yavsc.Api.Test/RdvQueryApiControllerTests.cs b/src/Yavsc.Api.Test/RdvQueryApiControllerTests.cs index 8fe7e959e..fcd87f9a3 100644 --- a/src/Yavsc.Api.Test/RdvQueryApiControllerTests.cs +++ b/src/Yavsc.Api.Test/RdvQueryApiControllerTests.cs @@ -82,4 +82,34 @@ public sealed class RdvQueryApiControllerTests : IClassFixture(TestContext.Current.CancellationToken); + Assert.NotNull(created); + Assert.Equal("alice", created!.ClientId); + } } diff --git a/src/Yavsc.Api/Controllers/Business/ActivityApiController.cs b/src/Yavsc.Api/Controllers/Business/ActivityApiController.cs index a8d288063..aef393868 100644 --- a/src/Yavsc.Api/Controllers/Business/ActivityApiController.cs +++ b/src/Yavsc.Api/Controllers/Business/ActivityApiController.cs @@ -51,6 +51,14 @@ namespace Yavsc.Controllers .Distinct() .ToArray(); + // Some providers are brittle when translating Contains over an + // empty in-memory array. If there is no candidate activity code, + // the catalog is empty by definition. + if (codes.Length == 0) + { + return Ok(new List()); + } + var performerCounts = await ( from ua in _context.UserActivities.AsNoTracking() where !string.IsNullOrWhiteSpace(ua.DoesCode) && codes.Contains(ua.DoesCode) @@ -64,10 +72,10 @@ namespace Yavsc.Controllers var filteredActivities = activities .Where(a => - (performerCounts.TryGetValue(a.Code, out var ownCount) && ownCount > 0) + (TryGetPerformerCount(performerCounts, a.Code, out var ownCount) && ownCount > 0) || (a.Children ?? new List()) .Where(c => !c.Hidden) - .Any(c => performerCounts.TryGetValue(c.Code, out var childCount) && childCount > 0)) + .Any(c => TryGetPerformerCount(performerCounts, c.Code, out var childCount) && childCount > 0)) .ToList(); return Ok(filteredActivities.Select(a => ToBrowseItem(a, performerCounts)).ToList()); @@ -269,10 +277,10 @@ namespace Yavsc.Controllers Code = activity.Code, Name = activity.Name, ParentCode = activity.ParentCode, - Description = activity.Description, + Description = activity.Description ?? string.Empty, Photo = activity.Photo, Rate = activity.Rate, - PerformerCount = performerCounts.TryGetValue(activity.Code, out var count) ? count : 0, + PerformerCount = TryGetPerformerCount(performerCounts, activity.Code, out var count) ? count : 0, Forms = (activity.Forms ?? Enumerable.Empty()) .Select(f => new CommandFormSummary { @@ -283,17 +291,17 @@ namespace Yavsc.Controllers .ToList(), Children = (activity.Children ?? Enumerable.Empty()) .Where(c => !c.Hidden) - .Where(c => performerCounts.TryGetValue(c.Code, out var childCount) && childCount > 0) + .Where(c => TryGetPerformerCount(performerCounts, c.Code, out var childCount) && childCount > 0) .OrderByDescending(c => c.Rate) .Select(c => new ActivityInfo { Code = c.Code, Name = c.Name, ParentCode = c.ParentCode, - Description = c.Description, + Description = c.Description ?? string.Empty, Photo = c.Photo, Rate = c.Rate, - PerformerCount = performerCounts.TryGetValue(c.Code, out var childCount) ? childCount : 0, + PerformerCount = TryGetPerformerCount(performerCounts, c.Code, out var childCount) ? childCount : 0, Forms = (c.Forms ?? Enumerable.Empty()) .Select(f => new CommandFormSummary { @@ -306,5 +314,19 @@ namespace Yavsc.Controllers .ToList(), }; } + + private static bool TryGetPerformerCount( + IReadOnlyDictionary performerCounts, + string code, + out int count) + { + if (string.IsNullOrWhiteSpace(code)) + { + count = 0; + return false; + } + + return performerCounts.TryGetValue(code, out count); + } } } diff --git a/src/Yavsc.Api/Controllers/Business/FrontOfficeApiController.cs b/src/Yavsc.Api/Controllers/Business/FrontOfficeApiController.cs index a4ecbf2f8..3adac5b03 100644 --- a/src/Yavsc.Api/Controllers/Business/FrontOfficeApiController.cs +++ b/src/Yavsc.Api/Controllers/Business/FrontOfficeApiController.cs @@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Mvc; using Yavsc.Helpers; using Yavsc.Models; using Yavsc.Services; +using Yavsc.Server.Helpers; using Yavsc.ViewModels.FrontOffice; namespace Yavsc.ApiControllers @@ -15,10 +16,10 @@ namespace Yavsc.ApiControllers private IBillingService billing; - public FrontOfficeApiController(ApplicationDbContext context, IBillingService billing) + public FrontOfficeApiController(ApplicationDbContext context, IBillingService billing = null) { dbContext = context; - this.billing = billing; + this.billing = billing ?? new BillingService(context); } [HttpGet("profiles/{actCode}")] @@ -36,7 +37,7 @@ namespace Yavsc.ApiControllers if (billing == null) return BadRequest(); billing.Status = QueryStatus.Rejected; - dbContext.SaveChanges(); + dbContext.SaveChanges(User.GetUserId()); return Ok(); } @@ -48,7 +49,7 @@ namespace Yavsc.ApiControllers var billing = BillingService.GetBillable(dbContext, billingCode, queryId); if (billing == null) return BadRequest(); billing.Status = QueryStatus.Accepted; - dbContext.SaveChanges(); + dbContext.SaveChanges(User.GetUserId()); return Ok(); } } diff --git a/src/Yavsc.Api/Controllers/Business/HairCutQueryApiController.cs b/src/Yavsc.Api/Controllers/Business/HairCutQueryApiController.cs index 6c7c1ba68..7f445fdd9 100644 --- a/src/Yavsc.Api/Controllers/Business/HairCutQueryApiController.cs +++ b/src/Yavsc.Api/Controllers/Business/HairCutQueryApiController.cs @@ -82,19 +82,17 @@ public class HairCutQueryApiController : Controller public async Task PostQuery([FromBody] HairCutQuery query, CancellationToken cancellationToken) { var uid = User.GetUserId(); - if (string.IsNullOrWhiteSpace(query.ClientId)) - { - query.ClientId = uid; - } + query.ClientId = uid; + ModelState.Remove("Client"); ModelState.Remove("ClientId"); + ModelState.Remove("UserCreated"); + ModelState.Remove("UserModified"); + ModelState.Remove("SelectedProfile"); ModelState.Remove("Prestation"); - - if (query.ClientId != uid && !User.IsInRole(Constants.AdminGroupName)) - { - ModelState.AddModelError("ClientId", "You can only create your own HairCutQuery"); - return BadRequest(ModelState); - } + ModelState.Remove("PerformerProfile"); + ModelState.Remove("Context"); + ModelState.Remove("Regularization"); query.Prestation = await _context.HairPrestation .SingleOrDefaultAsync(p => p.Id == query.PrestationId, cancellationToken); diff --git a/src/Yavsc.Api/Controllers/Business/HairMultiCutQueryApiController.cs b/src/Yavsc.Api/Controllers/Business/HairMultiCutQueryApiController.cs index f850b3f41..3b33c4160 100644 --- a/src/Yavsc.Api/Controllers/Business/HairMultiCutQueryApiController.cs +++ b/src/Yavsc.Api/Controllers/Business/HairMultiCutQueryApiController.cs @@ -89,18 +89,16 @@ public class HairMultiCutQueryApiController : Controller public async Task PostQuery([FromBody] HairMultiCutQuery query, CancellationToken cancellationToken) { var uid = User.GetUserId(); - if (string.IsNullOrWhiteSpace(query.ClientId)) - { - query.ClientId = uid; - } + query.ClientId = uid; + ModelState.Remove("Client"); ModelState.Remove("ClientId"); - - if (query.ClientId != uid && !User.IsInRole(Constants.AdminGroupName)) - { - ModelState.AddModelError("ClientId", "You can only create your own HairMultiCutQuery"); - return BadRequest(ModelState); - } + ModelState.Remove("UserCreated"); + ModelState.Remove("UserModified"); + ModelState.Remove("SelectedProfile"); + ModelState.Remove("PerformerProfile"); + ModelState.Remove("Context"); + ModelState.Remove("Regularization"); if (query.Prestations is null || query.Prestations.Count == 0) { diff --git a/src/Yavsc.Api/Controllers/Business/RdvQueryApiController.cs b/src/Yavsc.Api/Controllers/Business/RdvQueryApiController.cs index fcfac2b22..dd2982138 100644 --- a/src/Yavsc.Api/Controllers/Business/RdvQueryApiController.cs +++ b/src/Yavsc.Api/Controllers/Business/RdvQueryApiController.cs @@ -65,18 +65,17 @@ public class RdvQueryApiController : Controller public async Task PostQuery([FromBody] RdvQuery query, CancellationToken cancellationToken) { var uid = User.GetUserId(); - if (string.IsNullOrWhiteSpace(query.ClientId)) - { - query.ClientId = uid; - } + // Security: the caller always posts for themselves. + query.ClientId = uid; + ModelState.Remove("Client"); ModelState.Remove("ClientId"); - - if (query.ClientId != uid && !User.IsInRole(Constants.AdminGroupName)) - { - ModelState.AddModelError("ClientId", "You can only create your own RdvQuery"); - return BadRequest(ModelState); - } + ModelState.Remove("UserCreated"); + ModelState.Remove("UserModified"); + ModelState.Remove("SelectedProfile"); + ModelState.Remove("PerformerProfile"); + ModelState.Remove("Context"); + ModelState.Remove("Regularization"); if (!ModelState.IsValid) { @@ -123,23 +122,44 @@ public class RdvQueryApiController : Controller [HttpPut("{id}")] public async Task PutQuery([FromRoute] long id, [FromBody] RdvQuery query, CancellationToken cancellationToken) { - if (!ModelState.IsValid) - { - return BadRequest(ModelState); - } - - if (id != query.Id) - { - return BadRequest(); - } - var uid = User.GetUserId(); - if (query.ClientId != uid && !User.IsInRole(Constants.AdminGroupName)) + var existing = await _context.RdvQueries + .Include(q => q.Location) + .SingleOrDefaultAsync(q => q.Id == id, cancellationToken); + + if (existing is null) + { + return NotFound(); + } + + if (existing.ClientId != uid && !User.IsInRole(Constants.AdminGroupName)) { return Forbid(); } - _context.Entry(query).State = EntityState.Modified; + existing.ActivityCode = query.ActivityCode; + existing.PerformerId = query.PerformerId; + existing.Consent = query.Consent; + existing.EventDate = query.EventDate; + existing.LocationType = query.LocationType; + existing.Reason = query.Reason; + existing.Status = query.Status; + existing.Provisional = query.Provisional; + + if (query.Location is not null) + { + var resolvedLocation = await _context.Locations.FirstOrDefaultAsync( + x => x.Address == query.Location.Address + && x.Longitude == query.Location.Longitude + && x.Latitude == query.Location.Latitude, + cancellationToken); + + existing.Location = resolvedLocation ?? query.Location; + if (resolvedLocation is null) + { + _context.Attach(query.Location); + } + } try { diff --git a/src/Yavsc.Blogs/Controllers/BlogApiController.cs b/src/Yavsc.Blogs/Controllers/BlogApiController.cs index 8c76f7eea..b757d711b 100644 --- a/src/Yavsc.Blogs/Controllers/BlogApiController.cs +++ b/src/Yavsc.Blogs/Controllers/BlogApiController.cs @@ -55,6 +55,14 @@ namespace Yavsc.Blogs.Controllers [HttpPut("{id}")] public async Task PutBlog(long id, [FromBody] Models.Blog.BlogPost blog) { + // These properties are server-managed or optional graph members and + // should not block JSON payloads coming from API clients. + ModelState.Remove(nameof(Models.Blog.BlogPost.Author)); + ModelState.Remove(nameof(Models.Blog.BlogPost.Tags)); + ModelState.Remove(nameof(Models.Blog.BlogPost.Comments)); + ModelState.Remove(nameof(Models.Blog.BlogPost.UserCreated)); + ModelState.Remove(nameof(Models.Blog.BlogPost.UserModified)); + if (!ModelState.IsValid) { return BadRequest(ModelState); @@ -87,6 +95,14 @@ namespace Yavsc.Blogs.Controllers [HttpPost] public IActionResult PostBlog([FromBody] Models.Blog.BlogPost blog) { + // These properties are server-managed or optional graph members and + // should not block JSON payloads coming from API clients. + ModelState.Remove(nameof(Models.Blog.BlogPost.Author)); + ModelState.Remove(nameof(Models.Blog.BlogPost.Tags)); + ModelState.Remove(nameof(Models.Blog.BlogPost.Comments)); + ModelState.Remove(nameof(Models.Blog.BlogPost.UserCreated)); + ModelState.Remove(nameof(Models.Blog.BlogPost.UserModified)); + if (!ModelState.IsValid) { return BadRequest(ModelState); diff --git a/src/Yavsc.Org/Controllers/Communicating/CommentsController.cs b/src/Yavsc.Org/Controllers/Communicating/CommentsController.cs index 3690671c5..02dda32a1 100644 --- a/src/Yavsc.Org/Controllers/Communicating/CommentsController.cs +++ b/src/Yavsc.Org/Controllers/Communicating/CommentsController.cs @@ -121,6 +121,7 @@ namespace Yavsc.Controllers public async Task Create(Comment comment) { comment.UserCreated = User.GetUserId(); + comment.UserModified = comment.UserCreated; // AuthorId/UserCreated is set server-side after model binding; // remove the stale binding error so a valid authenticated POST // does not fall into the invalid branch. @@ -129,7 +130,7 @@ namespace Yavsc.Controllers if (ModelState.IsValid) { _context.Comment.Add(comment); - await _context.SaveChangesAsync(); + await _context.SaveChangesAsync(comment.UserCreated); return RedirectToAction("Index"); } ViewBag.ReceiverId = new SelectList(_context.BlogSpot, "Id", "Title", comment.ReceiverId); diff --git a/src/Yavsc.Server/Models/ApplicationDbContext.cs b/src/Yavsc.Server/Models/ApplicationDbContext.cs index cb08023a3..ade093c15 100644 --- a/src/Yavsc.Server/Models/ApplicationDbContext.cs +++ b/src/Yavsc.Server/Models/ApplicationDbContext.cs @@ -108,6 +108,7 @@ namespace Yavsc.Models ; builder.Entity().Property(a => a.ParentCode).IsRequired(false); + builder.Entity().Property(a => a.Description).IsRequired(false); builder.Entity().HasKey(c => c.Code); builder.Entity() diff --git a/src/Yavsc.Server/Models/Workflow/Activity.cs b/src/Yavsc.Server/Models/Workflow/Activity.cs index e3303f5ac..e82ac9709 100644 --- a/src/Yavsc.Server/Models/Workflow/Activity.cs +++ b/src/Yavsc.Server/Models/Workflow/Activity.cs @@ -37,7 +37,7 @@ namespace Yavsc.Models.Workflow public virtual List Children { get; set; } [Display(Name = "Description")] - public string Description { get; set; } + public string? Description { get; set; } [Display(Name = "Photo")] public string? Photo { get; set; }