From 805ca5cf3d28ca2593c0916aa5c68856fbdc351f Mon Sep 17 00:00:00 2001 From: Thomas Fransolet Date: Wed, 12 Aug 2026 14:53:21 +0200 Subject: [PATCH] Le journal d'audit voyait tout sauf le contenu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AuditedTypes.Contains(entry.Entity.GetType()) exigeait l'egalite exacte de type, or Section est abstraite : le type runtime est toujours SectionMap, SectionQuiz... Aucune des 13 sortes de section n'etait journalisee -- soit precisement ce que l'ecran d'audit livre le 12/08 devait tracer. Resource, Configuration, Device, User et Instance, eux, passaient : ils sont concrets, et c'est ce qui rendait le trou invisible. Remplace par une remontee a la classe de base auditee (IsInstanceOfType). EntityType porte « Section », pas « SectionMap ». Des deux sorties possibles, c'est la normalisation cote serveur qui est retenue : - le filtre « Section » est deja dans l'ecran et se met a rendre des lignes sans toucher manager-app, donc sans coordonner deux repos ; - elargir le filtre front aurait coute 13 entrees de liste et 39 cles i18n, et surtout aurait laisse tout futur sous-type sortir du filtre en silence -- la meme classe de panne que celle qu'on ferme ici ; - le sous-type concret n'est pas perdu : le discriminateur TPH est une propriete du modele, donc serialisee dans NewValues (« Discriminator »: « Article »). Un test le tient. 8 tests, dont un par reflexion qui affirme que les 13 sous-types concrets resolvent bien vers Section : la liste de types d'origine n'obligeait personne a la suivre, c'est ce qui l'a laissee devenir fausse. dotnet test 171/171. Co-Authored-By: Claude Opus 5 --- ManagerService.Tests/Data/AuditLogTests.cs | 164 +++++++++++++++++++++ ManagerService/Data/MyInfoMateDbContext.cs | 25 +++- 2 files changed, 184 insertions(+), 5 deletions(-) create mode 100644 ManagerService.Tests/Data/AuditLogTests.cs diff --git a/ManagerService.Tests/Data/AuditLogTests.cs b/ManagerService.Tests/Data/AuditLogTests.cs new file mode 100644 index 0000000..1472832 --- /dev/null +++ b/ManagerService.Tests/Data/AuditLogTests.cs @@ -0,0 +1,164 @@ +using Manager.DTOs; +using ManagerService.Data; +using ManagerService.Data.SubSection; +using ManagerService.DTOs; +using ManagerService.Tests.Infrastructure; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Text.Json; +using Xunit; + +namespace ManagerService.Tests.Data +{ + /// + /// Le journal d'audit ne voyait aucune section : le filtre exigeait l'égalité exacte + /// de type, or est abstraite. Ces tests tiennent l'invariant — + /// un sous-type est journalisé, et il l'est sous le nom « Section ». + /// + public class AuditLogTests + { + private static SectionArticle NewArticle(string id = "s1") => + TestSection.Article(id, "i1", "Label", "c1"); + + [Fact] + public void SectionSubType_IsAudited() + { + using var db = DbContextFactory.Create(); + + db.Sections.Add(NewArticle()); + db.SaveChanges(); + + var log = Assert.Single(db.AuditLogs.ToList()); + Assert.Equal("Create", log.Action); + Assert.Equal("s1", log.EntityId); + Assert.Equal("i1", log.InstanceId); + } + + [Fact] + public void SectionSubType_IsLoggedUnderBaseTypeName() + { + using var db = DbContextFactory.Create(); + + db.Sections.Add(NewArticle()); + db.SaveChanges(); + + // Le filtre « Section » de l'écran d'audit interroge ce nom : s'il portait + // « SectionArticle », l'écran ne rendrait toujours aucune ligne. + Assert.Equal("Section", db.AuditLogs.Single().EntityType); + } + + [Fact] + public void SectionSubType_KeepsConcreteTypeInValues() + { + using var db = DbContextFactory.Create(); + + db.Sections.Add(NewArticle()); + db.SaveChanges(); + + // Normaliser EntityType ne perd pas le sous-type : le discriminateur TPH est + // une propriété du modèle, donc sérialisée avec les autres. + var values = JsonSerializer.Deserialize>( + db.AuditLogs.Single().NewValues!); + Assert.Equal("Article", values!["Discriminator"].GetString()); + } + + [Fact] + public void SectionUpdate_RecordsOnlyChangedFields() + { + using var db = DbContextFactory.Create(); + db.Sections.Add(NewArticle()); + db.SaveChanges(); + + var section = db.Sections.Single(); + section.Label = "Renommée"; + db.SaveChanges(); + + var update = db.AuditLogs.Single(a => a.Action == "Update"); + var oldValues = JsonSerializer.Deserialize>(update.OldValues!); + var newValues = JsonSerializer.Deserialize>(update.NewValues!); + + Assert.Equal("Label", Assert.Single(newValues!.Keys.Where(k => k != "DateUpdate"))); + Assert.Equal("Label", oldValues!["Label"].GetString()); + Assert.Equal("Renommée", newValues["Label"].GetString()); + } + + [Fact] + public void SectionDelete_IsAudited() + { + using var db = DbContextFactory.Create(); + db.Sections.Add(NewArticle()); + db.SaveChanges(); + + db.Sections.Remove(db.Sections.Single()); + db.SaveChanges(); + + var delete = db.AuditLogs.Single(a => a.Action == "Delete"); + Assert.Equal("Section", delete.EntityType); + Assert.Equal("s1", delete.EntityId); + } + + [Fact] + public void NonAuditedEntity_IsNotLogged() + { + using var db = DbContextFactory.Create(); + + db.VisitEvents.Add(new VisitEvent + { + Id = "v1", + InstanceId = "i1", + SessionId = "sess", + EventType = VisitEventType.SectionView, + Timestamp = DateTime.UtcNow + }); + db.SaveChanges(); + + Assert.Empty(db.AuditLogs.ToList()); + } + + [Fact] + public void OtherAuditedTypes_KeepTheirOwnName() + { + using var db = DbContextFactory.Create(); + + db.Users.Add(new User + { + Id = "u1", + InstanceId = "i1", + Email = "a@b.c", + FirstName = "A", + LastName = "B", + Password = "x", + Token = "t" + }); + db.SaveChanges(); + + Assert.Equal("User", db.AuditLogs.Single().EntityType); + } + + /// + /// Le trou d'origine venait d'une liste de types que rien n'obligeait à suivre les + /// sous-classes. Par réflexion : tout sous-type concret de Section doit être journalisé. + /// + [Fact] + public void EverySectionSubType_ResolvesToSection() + { + var subTypes = typeof(Section).Assembly.GetTypes() + .Where(t => typeof(Section).IsAssignableFrom(t) && !t.IsAbstract) + .ToList(); + + Assert.Equal(13, subTypes.Count); + + var auditedTypeOf = typeof(MyInfoMateDbContext) + .GetMethod("AuditedTypeOf", BindingFlags.NonPublic | BindingFlags.Static)!; + + foreach (var subType in subTypes) + { + var instance = System.Runtime.CompilerServices.RuntimeHelpers + .GetUninitializedObject(subType); + Assert.Equal(typeof(Section), auditedTypeOf.Invoke(null, new[] { instance })); + } + } + } +} diff --git a/ManagerService/Data/MyInfoMateDbContext.cs b/ManagerService/Data/MyInfoMateDbContext.cs index df20721..300ef6d 100644 --- a/ManagerService/Data/MyInfoMateDbContext.cs +++ b/ManagerService/Data/MyInfoMateDbContext.cs @@ -108,12 +108,26 @@ namespace ManagerService.Data } } - private static readonly HashSet AuditedTypes = new() + private static readonly Type[] AuditedTypes = { typeof(Section), typeof(Resource), typeof(Configuration), typeof(Device), typeof(User), typeof(Instance) }; + /// + /// Type journalisé dont l'entité relève, ou null si elle n'est pas journalisée. + /// + /// + /// L'égalité exacte de type ne convient pas : est abstraite, + /// le type runtime est toujours un des 13 sous-types (SectionMap, SectionQuiz…), + /// et aucune section n'était donc journalisée. On remonte à la classe de base pour + /// que EntityType porte « Section » — le filtre de l'écran d'audit interroge + /// ce nom-là, et un nouveau sous-type y entre sans que personne n'ait à l'inscrire. + /// Le sous-type concret reste lisible dans les valeurs, via le discriminateur TPH. + /// + private static Type AuditedTypeOf(object entity) => + AuditedTypes.FirstOrDefault(t => t.IsInstanceOfType(entity)); + private List BuildAuditEntries() { var userId = _httpContextAccessor?.HttpContext?.User?.FindFirstValue(ClaimTypes.NameIdentifier); @@ -123,11 +137,12 @@ namespace ManagerService.Data // ChangeTracker, ce qui invaliderait l'énumération en cours // ("Collection was modified"). Les logs sont donc ajoutés après la boucle. var auditedEntries = ChangeTracker.Entries() - .Where(e => AuditedTypes.Contains(e.Entity.GetType()) - && e.State is EntityState.Added or EntityState.Modified or EntityState.Deleted) + .Select(e => new { Entry = e, AuditedType = AuditedTypeOf(e.Entity) }) + .Where(x => x.AuditedType != null + && x.Entry.State is EntityState.Added or EntityState.Modified or EntityState.Deleted) .ToList(); - foreach (var entry in auditedEntries) + foreach (var (entry, auditedType) in auditedEntries.Select(x => (x.Entry, x.AuditedType))) { var action = entry.State switch { @@ -163,7 +178,7 @@ namespace ManagerService.Data var log = new AuditLog { - EntityType = entry.Entity.GetType().Name, + EntityType = auditedType.Name, EntityId = entityId ?? "", Action = action!, UserId = userId,