From 7a80766d43cc29891290a9e70db1ae94fa5fb413 Mon Sep 17 00:00:00 2001 From: Thomas Fransolet Date: Wed, 12 Aug 2026 16:01:01 +0200 Subject: [PATCH] Le journal d'audit ne suit plus les ecritures de la machine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Regression introduite par le commit qui journalise les sections. WeatherSyncService ecrit `section.WeatherResult` sur cron, a 6 h et a 13 h. Le job existait et etait benin tant que les sections n'etaient pas auditees ; depuis, chaque rafraichissement produisait une ligne portant la prevision OpenWeather complete en avant ET en apres -- quelques dizaines de Ko, deux fois par jour, par section meteo, indefiniment. Le stockage est le moindre probleme : ce bruit noie les modifications humaines que l'ecran d'audit existe pour montrer. Correctif : une liste de colonnes machine (WeatherResult, WeatherUpdatedDate, DateUpdate) exclues du journal, et surtout aucune ligne produite quand une modification ne touche qu'elles -- plutot qu'une ligne au diff vide, qui aurait deplace le bruit sans le retirer. DateUpdate y est pour une raison distincte de la meteo : estampille a chaque SaveChanges, il figurait dans tous les diffs sans jamais rien y apprendre. Le signal etait deja la -- un test ecrit hier devait l'ecarter a la main pour rester lisible (`newValues.Keys.Where(k => k != "DateUpdate")`). Quand un test doit filtrer une donnee pour etre lisible, la donnee n'a rien a y faire. Verifie au passage : AgendaSyncService ecrit des EventAgenda, qui ne sont pas audites, et ne touche pas la ligne Section. Lui n'est pas concerne. ⚠️ Consequence sur la dette signalee hier : sans le flot meteo, la table ne grossit plus qu'au rythme des editions humaines -- de l'ordre de quelques dizaines de milliers de lignes par an sur 4 clients. L'index sur Timestamp evoque hier n'a plus lieu d'etre, donc pas de migration et le gel du lot B n'est pas remis en cause. La purge reste pertinente, mais comme sujet RGPD (lot J), plus comme sujet de volume. 3 tests. dotnet test 203/203. Co-Authored-By: Claude Opus 5 --- ManagerService.Tests/Data/AuditLogTests.cs | 76 +++++++++++++++++++++- ManagerService/Data/MyInfoMateDbContext.cs | 41 ++++++++++-- 2 files changed, 111 insertions(+), 6 deletions(-) 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