Thomas Fransolet b87e49d808 Cle d'API sur les routes visiteur, canal VR et contenu immersif
Securite
- RequireAppKey : filtre qui exige une cle d'API valide ou un utilisateur du
  manager. Pose sur les routes de contenu consommees par les apps visiteur
  (Configuration, Section, Resource, SectionMap, SectionEvent, SectionAgenda,
  SectionParcours, SectionQuiz, ApplicationInstance). Un [Authorize] d'action
  ne peut pas assouplir celui de la classe ; seul [AllowAnonymous] le
  court-circuite, et le filtre redevient le controle d'acces. Il ferme
  l'enumeration par identifiant, pas la confidentialite : la cle s'obtient
  par le slug ou le pincode.
- Instance/slug/{slug} rendait le pinCode, l'adresse de facturation, la TVA et
  les quotas a qui lit l'URL du site visiteur. StripCommercialFields est
  desormais applique sur slug et byPin ; isTrialActive reste expose pour le
  filigrane d'essai.
- Device.Create et Device/{id}/detail repondaient 403 a toute tablette depuis
  a452f4a (13/03) : la classe exige InstanceAdmin, une cle ne porte que
  AppRead. Ouverts a la cle, le cloisonnement par instance etait deja ecrit.

Canal VR
- Device.AppType (defaut Tablet, backfill a 1) ; Create resout
  l'ApplicationInstance sur ce type au lieu de Tablet en dur.
- Get filtre optionnellement par appType ; DeviceDetailDTO expose appVersion
  et lastSeen.
- PUT Device/{id}/heartbeat : batterie, version, connexion. Volontairement
  etroit, une app ne peut ni se renommer ni changer d'instance.
- ApiKeyAppType.VrApp en fin d'enum.

Contenu immersif
- ResourceType : Image360 (11), Video360 (12), Model3D (13), en fin d'enum.
- Section Scene3D : un modele GLB et ses points d'interet, en objet manipule
  ou en decor habite. Les points sont des GeoPoint, avec une LocalTransform
  en jsonb (convention glTF) ; CRUD dans SectionScene3DController.
- Instance.HasImmersiveContent : l'add-on ajoute 100 Go au quota de stockage,
  repose apres un changement de plan et retire a la desactivation.
- ImmersiveBackground (owned) sur Configuration et ApplicationInstance, avec
  une image de repli pour les canaux qui ne rendent pas l'immersif.

Export de configuration
- exportVersion (1) et generatedAt : le JSON devient un contrat, lu tel quel
  par l'app Unity.
- Section.ToDTO() n'etant pas virtuelle, l'export ne portait aucun champ
  specifique de sous-type. Passe par SectionFactory.ToDTO, et charge les
  points des Map et des Scene3D.
- Le fond immersif et son repli partent avec les ressources, URL resolues.

Migrations : AddAppTypeToDevice, AddScene3DSectionAndImmersiveAddon,
AddImmersiveBackground.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-13 16:54:10 +02:00

242 lines
8.9 KiB
C#

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!);
// 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<TranslationDTO>(),
Description = new List<TranslationDTO>(),
WeatherCity = "Namur"
};
[Fact]
public void WeatherRefresh_ProducesNoAuditEntry()
{
using var db = DbContextFactory.Create();
db.Sections.Add(NewWeatherSection());
db.SaveChanges();
var section = db.Sections.OfType<SectionWeather>().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<SectionWeather>().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<Dictionary<string, JsonElement>>(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<Dictionary<string, JsonElement>>(
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()
{
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();
// 14 depuis le 12/09 : SectionScene3D (maquette 3D, item E7 du lot XR-4).
// Ce chiffre est volontairement en dur — il force à passer ici, donc à
// vérifier que le nouveau sous-type est bien journalisé.
Assert.Equal(14, 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 }));
}
}
}
}