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(Section), typeof(Resource), typeof(Configuration),
|
||||||
typeof(Device), typeof(User), typeof(Instance)
|
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()
|
private List<AuditLog> BuildAuditEntries()
|
||||||
{
|
{
|
||||||
var userId = _httpContextAccessor?.HttpContext?.User?.FindFirstValue(ClaimTypes.NameIdentifier);
|
var userId = _httpContextAccessor?.HttpContext?.User?.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||||
@ -123,11 +137,12 @@ namespace ManagerService.Data
|
|||||||
// ChangeTracker, ce qui invaliderait l'énumération en cours
|
// ChangeTracker, ce qui invaliderait l'énumération en cours
|
||||||
// ("Collection was modified"). Les logs sont donc ajoutés après la boucle.
|
// ("Collection was modified"). Les logs sont donc ajoutés après la boucle.
|
||||||
var auditedEntries = ChangeTracker.Entries()
|
var auditedEntries = ChangeTracker.Entries()
|
||||||
.Where(e => AuditedTypes.Contains(e.Entity.GetType())
|
.Select(e => new { Entry = e, AuditedType = AuditedTypeOf(e.Entity) })
|
||||||
&& e.State is EntityState.Added or EntityState.Modified or EntityState.Deleted)
|
.Where(x => x.AuditedType != null
|
||||||
|
&& x.Entry.State is EntityState.Added or EntityState.Modified or EntityState.Deleted)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
foreach (var entry in auditedEntries)
|
foreach (var (entry, auditedType) in auditedEntries.Select(x => (x.Entry, x.AuditedType)))
|
||||||
{
|
{
|
||||||
var action = entry.State switch
|
var action = entry.State switch
|
||||||
{
|
{
|
||||||
@ -163,7 +178,7 @@ namespace ManagerService.Data
|
|||||||
|
|
||||||
var log = new AuditLog
|
var log = new AuditLog
|
||||||
{
|
{
|
||||||
EntityType = entry.Entity.GetType().Name,
|
EntityType = auditedType.Name,
|
||||||
EntityId = entityId ?? "",
|
EntityId = entityId ?? "",
|
||||||
Action = action!,
|
Action = action!,
|
||||||
UserId = userId,
|
UserId = userId,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user