Le journal d'audit voyait tout sauf le contenu

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 <noreply@anthropic.com>
This commit is contained in:
Thomas Fransolet 2026-08-12 14:53:21 +02:00
parent 269b3f6703
commit 805ca5cf3d
2 changed files with 184 additions and 5 deletions

View File

@ -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
{
/// <summary>
/// Le journal d'audit ne voyait aucune section : le filtre exigeait l'égalité exacte
/// de type, or <see cref="Section"/> est abstraite. Ces tests tiennent l'invariant —
/// un sous-type est journalisé, et il l'est sous le nom « Section ».
/// </summary>
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<Dictionary<string, JsonElement>>(
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<Dictionary<string, JsonElement>>(update.OldValues!);
var newValues = JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(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);
}
/// <summary>
/// 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é.
/// </summary>
[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 }));
}
}
}
}

View File

@ -108,12 +108,26 @@ namespace ManagerService.Data
}
}
private static readonly HashSet<Type> AuditedTypes = new()
private static readonly Type[] AuditedTypes =
{
typeof(Section), typeof(Resource), typeof(Configuration),
typeof(Device), typeof(User), typeof(Instance)
};
/// <summary>
/// Type journalisé dont l'entité relève, ou null si elle n'est pas journalisée.
/// </summary>
/// <remarks>
/// L'égalité exacte de type ne convient pas : <see cref="Section"/> 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 <c>EntityType</c> 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.
/// </remarks>
private static Type AuditedTypeOf(object entity) =>
AuditedTypes.FirstOrDefault(t => t.IsInstanceOfType(entity));
private List<AuditLog> 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,