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:
parent
269b3f6703
commit
805ca5cf3d
164
ManagerService.Tests/Data/AuditLogTests.cs
Normal file
164
ManagerService.Tests/Data/AuditLogTests.cs
Normal 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 }));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user