From cb76df5ddf2c5e6708010f3720dab824bf5ae8e5 Mon Sep 17 00:00:00 2001 From: Thomas Fransolet Date: Thu, 13 Aug 2026 16:29:35 +0200 Subject: [PATCH] =?UTF-8?q?Purge=20du=20journal=20d'audit=20=E2=80=94=20po?= =?UTF-8?q?ste=20du=20lot=20J=20oubli=C3=A9=20=C3=A0=20la=20premi=C3=A8re?= =?UTF-8?q?=20passe?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AuditLog porte un UserId, et les valeurs avant/après d'une modification de User contiennent e-mail, prénom et nom : conservés sans limite jusqu'ici, quand VisitEvent purge à 13 mois et VisitorQuestion à 90 jours. Le plan l'assignait explicitement au lot J, il n'avait pas été fait. 12 mois, uniforme. Pas 13 : les 13 mois des statistiques existent pour comparer une saison à la précédente, les recopier ici serait du mimétisme — un test fige l'écart voulu. Écarté aussi : garder les suppressions plus longtemps que les créations, ça double les règles pour un cas qu'une sauvegarde couvre déjà. Inerte tant qu'Audit:RetentionDays n'est pas défini, même verrou que les VisitEvent : c'est une suppression définitive et le pg_dump n'est pas en place. Volontairement différent de VisitorQuestionPurgeService, qui tourne sans condition parce que ses 90 jours sont un engagement des CGU, pas un réglage. La suppression se teste contre un vrai Postgres : ExecuteDeleteAsync n'est pas traduisible par le provider InMemory, et l'écrire en chargeant les lignes puis RemoveRange l'aurait rendue testable en mémoire au prix de charger un an de journal — dégrader le code de production pour satisfaire un provider de test. dotnet test : 213 passés, 16 sautés, 0 échec. Co-Authored-By: Claude Opus 5 --- .../Services/AuditLogPurgePostgresTests.cs | 68 +++++++++++++++++ .../Services/AuditLogPurgeServiceTests.cs | 71 +++++++++++++++++ .../Services/AuditLogPurgeService.cs | 76 +++++++++++++++++++ ManagerService/Startup.cs | 8 ++ 4 files changed, 223 insertions(+) create mode 100644 ManagerService.Tests/Services/AuditLogPurgePostgresTests.cs create mode 100644 ManagerService.Tests/Services/AuditLogPurgeServiceTests.cs create mode 100644 ManagerService/Services/AuditLogPurgeService.cs diff --git a/ManagerService.Tests/Services/AuditLogPurgePostgresTests.cs b/ManagerService.Tests/Services/AuditLogPurgePostgresTests.cs new file mode 100644 index 0000000..371941c --- /dev/null +++ b/ManagerService.Tests/Services/AuditLogPurgePostgresTests.cs @@ -0,0 +1,68 @@ +using ManagerService.Data; +using ManagerService.Services; +using ManagerService.Tests.Infrastructure; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging.Abstractions; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Xunit; + +namespace ManagerService.Tests.Services +{ + /// + /// La purge s'appuie sur ExecuteDeleteAsync, que le provider InMemory ne sait pas + /// traduire — un test là-bas ne dirait rien de ce qui se passe en production. Ceux-ci + /// s'exécutent contre un vrai PostgreSQL, et se sautent proprement sans démon Docker. + /// + [Collection(PostgresCollection.Name)] + public class AuditLogPurgePostgresTests + { + private readonly PostgresFixture _postgres; + + public AuditLogPurgePostgresTests(PostgresFixture postgres) + { + _postgres = postgres; + } + + private static AuditLogPurgeService BuildService(MyInfoMateDbContext db, int retentionDays) + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Audit:RetentionDays"] = retentionDays.ToString() + }) + .Build(); + + return new AuditLogPurgeService(db, configuration, + NullLogger.Instance); + } + + private static AuditLog Entry(string id, DateTime timestamp) => new AuditLog + { + Id = id, + EntityType = "User", + EntityId = "u1", + Action = "Update", + UserId = "u1", + Timestamp = timestamp + }; + + [SkippableFact] + public async Task PurgeAsync_DeletesOnlyBeyondTheWindow() + { + Skip.IfNot(_postgres.Available, _postgres.SkipReason); + + using var db = _postgres.CreateMigratedContext("audit_purge"); + db.AuditLogs.AddRange( + Entry("vieux", DateTime.UtcNow.AddDays(-400)), + Entry("recent", DateTime.UtcNow.AddDays(-30))); + db.SaveChanges(); + + await BuildService(db, AuditLogPurgeService.DefaultRetentionDays).PurgeAsync(); + + Assert.Equal("recent", Assert.Single(db.AuditLogs).Id); + } + } +} diff --git a/ManagerService.Tests/Services/AuditLogPurgeServiceTests.cs b/ManagerService.Tests/Services/AuditLogPurgeServiceTests.cs new file mode 100644 index 0000000..1da793e --- /dev/null +++ b/ManagerService.Tests/Services/AuditLogPurgeServiceTests.cs @@ -0,0 +1,71 @@ +using ManagerService.Data; +using ManagerService.Services; +using ManagerService.Tests.Infrastructure; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging.Abstractions; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Xunit; + +namespace ManagerService.Tests.Services +{ + public class AuditLogPurgeServiceTests + { + private static AuditLogPurgeService BuildService(MyInfoMateDbContext db, int? retentionDays) + { + var settings = new Dictionary(); + if (retentionDays != null) + settings["Audit:RetentionDays"] = retentionDays.ToString(); + + var configuration = new ConfigurationBuilder().AddInMemoryCollection(settings).Build(); + return new AuditLogPurgeService(db, configuration, + NullLogger.Instance); + } + + private static AuditLog Entry(string id, DateTime timestamp) => new AuditLog + { + Id = id, + EntityType = "User", + EntityId = "u1", + Action = "Update", + UserId = "u1", + Timestamp = timestamp + }; + + /// + /// Le verrou : sans configuration, rien n'est supprimé. C'est une suppression + /// définitive et le pg_dump quotidien n'est pas en place — un job de purge qui + /// partirait tout seul est exactement ce qu'on ne veut pas. + /// + [Fact] + public async Task PurgeAsync_WithoutConfiguration_DeletesNothing() + { + using var db = DbContextFactory.Create(); + db.AuditLogs.Add(Entry("a1", DateTime.UtcNow.AddYears(-5))); + db.SaveChanges(); + + await BuildService(db, null).PurgeAsync(); + + Assert.Single(db.AuditLogs); + } + + // ⚠️ La suppression elle-même se teste contre un vrai PostgreSQL, pas ici : + // `ExecuteDeleteAsync` n'est pas traduisible par le provider InMemory. Écrire la + // purge en chargeant les lignes puis `RemoveRange` la rendrait testable ici au prix + // de charger un an de journal en mémoire — dégrader le code de production pour + // satisfaire un provider de test. Voir AuditLogPurgePostgresTests. + + /// + /// 12 mois, pas 13 : les 13 mois des statistiques existent pour comparer une saison à + /// la précédente, les recopier ici serait du mimétisme. Ce test fige l'écart voulu. + /// + [Fact] + public void DefaultRetention_IsTwelveMonths_NotThirteen() + { + Assert.Equal(365, AuditLogPurgeService.DefaultRetentionDays); + Assert.NotEqual(MyInfoMateDbContext.StatsRetentionDays, AuditLogPurgeService.DefaultRetentionDays); + } + } +} diff --git a/ManagerService/Services/AuditLogPurgeService.cs b/ManagerService/Services/AuditLogPurgeService.cs new file mode 100644 index 0000000..69464af --- /dev/null +++ b/ManagerService/Services/AuditLogPurgeService.cs @@ -0,0 +1,76 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using ManagerService.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; + +namespace ManagerService.Services +{ + /// + /// Purge le journal d'audit au-delà de . + /// + /// ⚠️ **C'est un sujet RGPD, et rien d'autre.** `AuditLog` porte un `UserId`, et les + /// valeurs avant/après d'une modification de `User` contiennent e-mail, prénom et nom — + /// conservés sans limite jusqu'ici, alors que `VisitEvent` purge à 13 mois et + /// `VisitorQuestion` à 90 jours. **Ce n'est pas un sujet de volume** : depuis que le flot + /// machine de `WeatherSyncService` est coupé (2026-08-12), la table ne croît qu'au rythme + /// des éditions humaines. + /// + /// **12 mois, uniforme.** Pas 13 : les 13 mois des statistiques existent pour comparer une + /// saison à la précédente, et recopier ce chiffre ici serait du mimétisme. ⛔ Écarté aussi : + /// garder les suppressions plus longtemps que les créations et modifications — ça double + /// les règles pour un cas qu'une sauvegarde couvre déjà. + /// + /// ⚠️ **Inerte tant que Audit:RetentionDays n'est pas défini**, exactement comme + /// et pour la même raison : c'est une suppression + /// définitive, et le pg_dump quotidien n'est toujours pas en place. Supprimer des lignes + /// d'audit sans restauration fine est la pire combinaison possible. + /// + /// ⛔ Ne pas s'aligner sur , qui tourne sans + /// condition : ses 90 jours sont un engagement écrit dans les CGU, pas un réglage + /// d'exploitation. Ici, aucun contrat ne fixe la durée. + /// + public class AuditLogPurgeService + { + /// Durée recommandée. Voir le lot J du plan V1. + public const int DefaultRetentionDays = 365; + + private readonly MyInfoMateDbContext _dbContext; + private readonly IConfiguration _configuration; + private readonly ILogger _logger; + + public AuditLogPurgeService( + MyInfoMateDbContext dbContext, + IConfiguration configuration, + ILogger logger) + { + _dbContext = dbContext; + _configuration = configuration; + _logger = logger; + } + + public async Task PurgeAsync() + { + var retentionDays = _configuration.GetValue("Audit:RetentionDays"); + if (retentionDays <= 0) + { + _logger.LogInformation( + "Purge du journal d'audit ignorée : Audit:RetentionDays non défini. " + + "Valeur attendue une fois le pg_dump en place : {Expected}.", + DefaultRetentionDays); + return; + } + + var cutoff = DateTime.UtcNow.AddDays(-retentionDays); + var deleted = await _dbContext.AuditLogs + .Where(a => a.Timestamp < cutoff) + .ExecuteDeleteAsync(); + + _logger.LogInformation( + "Purge du journal d'audit : {Deleted} entrée(s) antérieure(s) au {Cutoff:yyyy-MM-dd} supprimée(s).", + deleted, cutoff); + } + } +} diff --git a/ManagerService/Startup.cs b/ManagerService/Startup.cs index 17e11a5..f74dbdb 100644 --- a/ManagerService/Startup.cs +++ b/ManagerService/Startup.cs @@ -217,6 +217,7 @@ namespace ManagerService services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); var connectionString = Configuration.GetConnectionString("PostgresConnection"); @@ -393,6 +394,13 @@ namespace ManagerService s => s.PurgeAsync(), "30 3 * * *"); + // Ne supprime rien tant qu'Audit:RetentionDays n'est pas défini — même verrou que + // les VisitEvent, et pour la même raison : pas de pg_dump, pas de suppression. + RecurringJob.AddOrUpdate( + "audit-log-purge", + s => s.PurgeAsync(), + "45 3 * * *"); + app.UseEndpoints(endpoints => { endpoints.MapControllers();