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