diff --git a/ManagerService.Tests/Data/AuditLogTests.cs b/ManagerService.Tests/Data/AuditLogTests.cs index 1472832..4d17993 100644 --- a/ManagerService.Tests/Data/AuditLogTests.cs +++ b/ManagerService.Tests/Data/AuditLogTests.cs @@ -79,11 +79,85 @@ namespace ManagerService.Tests.Data var oldValues = JsonSerializer.Deserialize>(update.OldValues!); var newValues = JsonSerializer.Deserialize>(update.NewValues!); - Assert.Equal("Label", Assert.Single(newValues!.Keys.Where(k => k != "DateUpdate"))); + // DateUpdate est estampillé à chaque SaveChanges : il n'a rien à faire dans le + // diff, et son absence est ce qui rend cette assertion lisible. + Assert.Equal("Label", Assert.Single(newValues!.Keys)); Assert.Equal("Label", oldValues!["Label"].GetString()); Assert.Equal("Renommée", newValues["Label"].GetString()); } + // ── COLONNES MACHINE ───────────────────────────────────────────────── + // + // WeatherSyncService réécrit WeatherResult deux fois par jour, sur cron. Depuis que + // les sections sont journalisées, chaque passage produisait une ligne portant la + // prévision complète en avant ET en après — du bruit qui noie les modifications + // humaines que l'écran d'audit existe pour montrer. + + private static SectionWeather NewWeatherSection() => + new SectionWeather + { + Id = "w1", + InstanceId = "i1", + Label = "Météo", + ConfigurationId = "c1", + Type = SectionType.Weather, + Title = new List(), + Description = new List(), + WeatherCity = "Namur" + }; + + [Fact] + public void WeatherRefresh_ProducesNoAuditEntry() + { + using var db = DbContextFactory.Create(); + db.Sections.Add(NewWeatherSection()); + db.SaveChanges(); + + var section = db.Sections.OfType().Single(); + section.WeatherResult = "{\"list\":[/* 30 Ko de prévisions */]}"; + section.WeatherUpdatedDate = DateTimeOffset.UtcNow; + db.SaveChanges(); + + Assert.Empty(db.AuditLogs.Where(a => a.Action == "Update").ToList()); + } + + [Fact] + public void HumanEditOnAWeatherSection_IsStillAudited_WithoutTheForecast() + { + using var db = DbContextFactory.Create(); + db.Sections.Add(NewWeatherSection()); + db.SaveChanges(); + + var section = db.Sections.OfType().Single(); + section.WeatherCity = "Liège"; + section.WeatherResult = "{\"list\":[/* rafraîchi dans la foulée */]}"; + db.SaveChanges(); + + var update = db.AuditLogs.Single(a => a.Action == "Update"); + var newValues = JsonSerializer.Deserialize>(update.NewValues!); + + Assert.Equal("WeatherCity", Assert.Single(newValues!.Keys)); + Assert.Equal("Liège", newValues["WeatherCity"].GetString()); + } + + [Fact] + public void SectionCreation_DoesNotCarryTheForecastBlob() + { + using var db = DbContextFactory.Create(); + + var section = NewWeatherSection(); + section.WeatherResult = "{\"list\":[/* déjà rempli */]}"; + db.Sections.Add(section); + db.SaveChanges(); + + var values = JsonSerializer.Deserialize>( + db.AuditLogs.Single().NewValues!); + + Assert.DoesNotContain("WeatherResult", values!.Keys); + Assert.DoesNotContain("DateUpdate", values.Keys); + Assert.Equal("Namur", values["WeatherCity"].GetString()); + } + [Fact] public void SectionDelete_IsAudited() { diff --git a/ManagerService/Data/MyInfoMateDbContext.cs b/ManagerService/Data/MyInfoMateDbContext.cs index 300ef6d..ac87d3a 100644 --- a/ManagerService/Data/MyInfoMateDbContext.cs +++ b/ManagerService/Data/MyInfoMateDbContext.cs @@ -128,6 +128,28 @@ namespace ManagerService.Data private static Type AuditedTypeOf(object entity) => AuditedTypes.FirstOrDefault(t => t.IsInstanceOfType(entity)); + /// + /// Colonnes écrites par la machine, pas par un utilisateur. Elles n'apparaissent pas + /// dans le journal, et une modification qui ne touche qu'elles n'y produit aucune ligne. + /// + /// + /// WeatherSyncService réécrit WeatherResult sur cron, à 6 h et à 13 h. + /// Les sections étant désormais journalisées, chaque rafraîchissement produisait une + /// ligne portant la prévision OpenWeather complète en avant ET en après — quelques + /// dizaines de Ko, deux fois par jour, par section météo. Le coût de stockage est le + /// moindre problème : ce bruit noie les modifications humaines que l'écran d'audit + /// existe pour montrer. + /// + /// DateUpdate y est pour une raison distincte : il est estampillé à chaque + /// SaveChanges, donc présent dans tous les diffs sans jamais rien y apprendre. + /// + private static readonly HashSet AuditIgnoredProperties = new() + { + nameof(IAuditableEntity.DateUpdate), + nameof(SectionWeather.WeatherResult), + nameof(SectionWeather.WeatherUpdatedDate) + }; + private List BuildAuditEntries() { var userId = _httpContextAccessor?.HttpContext?.User?.FindFirstValue(ClaimTypes.NameIdentifier); @@ -158,22 +180,31 @@ namespace ManagerService.Data var instanceId = entry.Properties .FirstOrDefault(p => p.Metadata.Name == "InstanceId")?.CurrentValue?.ToString(); + var reportable = entry.Properties + .Where(p => !AuditIgnoredProperties.Contains(p.Metadata.Name)) + .ToList(); + string? oldValues = null; string? newValues = null; if (entry.State == EntityState.Modified) { + var changed = reportable.Where(p => p.IsModified).ToList(); + + // Une écriture qui ne touche que des colonnes machine n'est pas un + // événement : pas de ligne du tout, plutôt qu'une ligne au diff vide. + if (changed.Count == 0) + continue; + oldValues = JsonSerializer.Serialize( - entry.Properties.Where(p => p.IsModified) - .ToDictionary(p => p.Metadata.Name, p => p.OriginalValue)); + changed.ToDictionary(p => p.Metadata.Name, p => p.OriginalValue)); newValues = JsonSerializer.Serialize( - entry.Properties.Where(p => p.IsModified) - .ToDictionary(p => p.Metadata.Name, p => p.CurrentValue)); + changed.ToDictionary(p => p.Metadata.Name, p => p.CurrentValue)); } else if (entry.State == EntityState.Added) { newValues = JsonSerializer.Serialize( - entry.Properties.ToDictionary(p => p.Metadata.Name, p => p.CurrentValue)); + reportable.ToDictionary(p => p.Metadata.Name, p => p.CurrentValue)); } var log = new AuditLog