diff --git a/ManagerService.Tests/Controllers/ConfigurationExportTests.cs b/ManagerService.Tests/Controllers/ConfigurationExportTests.cs
index 1961e86..4884399 100644
--- a/ManagerService.Tests/Controllers/ConfigurationExportTests.cs
+++ b/ManagerService.Tests/Controllers/ConfigurationExportTests.cs
@@ -43,6 +43,67 @@ namespace ManagerService.Tests.Controllers
},
};
+ ///
+ /// ⚠️ Le second trou du même endroit, trouvé le 12/09. Les ressources
+ /// partaient bien, mais pas les champs qui les référencent :
+ /// Section.ToDTO() n'est pas virtuelle, et l'export appelait
+ /// s.ToDTO() sur une variable de type Section. Le JSON ne portait
+ /// donc que les champs communs — une galerie sans ses images, une carte sans ses
+ /// points, une maquette sans son modèle. L'export est censé être « tout le
+ /// contenu en un appel » ; il l'est depuis que SectionFactory.ToDTO fait
+ /// le sous-type réel.
+ ///
+ [Theory]
+ [InlineData(typeof(SectionMap), typeof(MapDTO))]
+ [InlineData(typeof(SectionSlider), typeof(SliderDTO))]
+ [InlineData(typeof(SectionVideo), typeof(VideoDTO))]
+ [InlineData(typeof(SectionScene3D), typeof(Scene3DDTO))]
+ [InlineData(typeof(SectionArticle), typeof(ArticleDTO))]
+ public void Export_ProducesTheRealSubTypeDto(System.Type sectionType, System.Type expectedDto)
+ {
+ var section = (Section)System.Activator.CreateInstance(sectionType)!;
+ section.Title = new List();
+ section.Description = new List();
+
+ var dto = ManagerService.Services.SectionFactory.ToDTO(section);
+
+ Assert.IsType(expectedDto, dto);
+ Assert.IsAssignableFrom(dto);
+ }
+
+ [Fact]
+ public void Export_KeepsModel3DFields()
+ {
+ var section = new SectionScene3D
+ {
+ Id = "s1",
+ Title = new List(),
+ Description = new List(),
+ Model3DResourceId = "res-glb",
+ Model3DSource = "https://cdn/model.glb",
+ Points = new List
+ {
+ new GeoPoint
+ {
+ Id = 1,
+ Title = new List(),
+ Description = new List(),
+ Contents = new List(),
+ LocalTransform = new Position3D { x = 1.5f, y = 0.2f, z = -3f }
+ }
+ }
+ };
+
+ var dto = Assert.IsType(ManagerService.Services.SectionFactory.ToDTO(section));
+
+ Assert.Equal("res-glb", dto.model3DResourceId);
+ Assert.Equal("https://cdn/model.glb", dto.model3DSource);
+
+ var point = Assert.Single(dto.points);
+ Assert.Equal(1.5f, point.localTransform.x);
+ Assert.Equal(-3f, point.localTransform.z);
+ }
+
[Fact]
public void Article_ExposesItsContentAndAudioResources()
{
diff --git a/ManagerService.Tests/Controllers/DeviceControllerTests.cs b/ManagerService.Tests/Controllers/DeviceControllerTests.cs
index 1926228..b6b89ee 100644
--- a/ManagerService.Tests/Controllers/DeviceControllerTests.cs
+++ b/ManagerService.Tests/Controllers/DeviceControllerTests.cs
@@ -28,9 +28,91 @@ namespace ManagerService.Tests.Controllers
{
db.Configurations.Add(new Configuration { Id = "conf-1", InstanceId = "inst-test", Label = "Conf", Title = new System.Collections.Generic.List() });
db.ApplicationInstances.Add(new ApplicationInstance { Id = "ai-1", InstanceId = "inst-test", AppType = AppType.Tablet });
+ db.ApplicationInstances.Add(new ApplicationInstance { Id = "ai-vr", InstanceId = "inst-test", AppType = AppType.VR });
db.SaveChanges();
}
+ // ── HEARTBEAT (XR-5) ─────────────────────────────────────────────────
+
+ private static Device SeedDevice(MyInfoMateDbContext db, string instanceId = "inst-test")
+ {
+ var device = new Device
+ {
+ Id = "dev-1",
+ Identifier = "headset-abc",
+ InstanceId = instanceId,
+ ConfigurationId = "conf-1",
+ AppType = AppType.VR,
+ Connected = false
+ };
+ db.Devices.Add(device);
+ db.SaveChanges();
+ return device;
+ }
+
+ [Fact]
+ public void Heartbeat_WritesBatteryVersionAndLastSeen()
+ {
+ using var db = DbContextFactory.Create();
+ SeedPrerequisites(db);
+ SeedDevice(db);
+
+ var result = BuildController(db).Heartbeat("dev-1", new DeviceHeartbeatDTO
+ {
+ batteryLevel = "87",
+ appVersion = "1.4.2"
+ });
+
+ Assert.IsType(result);
+
+ var device = db.Devices.First();
+ Assert.Equal("87", device.BatteryLevel);
+ Assert.Equal("1.4.2", device.AppVersion);
+ Assert.NotNull(device.LastSeen);
+
+ // Recevoir le battement est la preuve que l'appareil est en ligne : l'app
+ // n'a pas à le déclarer.
+ Assert.True(device.Connected);
+ }
+
+ [Fact]
+ public void Heartbeat_CannotRenameOrMoveTheDevice()
+ {
+ using var db = DbContextFactory.Create();
+ SeedPrerequisites(db);
+ SeedDevice(db);
+
+ BuildController(db).Heartbeat("dev-1", new DeviceHeartbeatDTO { batteryLevel = "50" });
+
+ var device = db.Devices.First();
+ Assert.Equal("inst-test", device.InstanceId);
+ Assert.Equal("conf-1", device.ConfigurationId);
+ Assert.Equal("headset-abc", device.Identifier);
+ }
+
+ [Fact]
+ public void Heartbeat_OtherInstance_Returns403()
+ {
+ using var db = DbContextFactory.Create();
+ SeedPrerequisites(db);
+ SeedDevice(db, instanceId: "autre-instance");
+
+ var result = BuildController(db).Heartbeat("dev-1", new DeviceHeartbeatDTO { batteryLevel = "10" });
+
+ Assert.Equal(403, result.StatusCode);
+ }
+
+ [Fact]
+ public void Heartbeat_UnknownDevice_Returns404()
+ {
+ using var db = DbContextFactory.Create();
+ SeedPrerequisites(db);
+
+ var result = BuildController(db).Heartbeat("nope", new DeviceHeartbeatDTO());
+
+ Assert.IsType(result);
+ }
+
// ── CREATE ───────────────────────────────────────────────────────────
[Fact]
@@ -132,5 +214,84 @@ namespace ManagerService.Tests.Controllers
Assert.IsType(result);
}
+
+ // ── CANAL (AppType) ──────────────────────────────────────────────────
+
+ [Fact]
+ public void Create_VrDevice_LinksToVrApplicationInstance()
+ {
+ using var db = DbContextFactory.Create();
+ SeedPrerequisites(db);
+
+ var result = BuildController(db).Create(new DeviceDetailDTO
+ {
+ identifier = "quest-1",
+ instanceId = "inst-test",
+ configurationId = "conf-1",
+ name = "Casque 1",
+ appType = AppType.VR
+ });
+
+ Assert.IsType(result);
+ Assert.Equal(AppType.VR, db.Devices.Single().AppType);
+ // Le rattachement se faisait sur AppType.Tablet en dur : un casque atterrissait
+ // dans l'onglet Kiosk.
+ Assert.Equal("ai-vr", db.AppConfigurationLinks.Single().ApplicationInstanceId);
+ }
+
+ [Fact]
+ public void Create_WithoutAppType_DefaultsToTablet()
+ {
+ using var db = DbContextFactory.Create();
+ SeedPrerequisites(db);
+
+ BuildController(db).Create(new DeviceDetailDTO
+ {
+ identifier = "device-abc",
+ instanceId = "inst-test",
+ configurationId = "conf-1",
+ name = "Tablet 1"
+ });
+
+ Assert.Equal(AppType.Tablet, db.Devices.Single().AppType);
+ Assert.Equal("ai-1", db.AppConfigurationLinks.Single().ApplicationInstanceId);
+ }
+
+ [Fact]
+ public void Create_VrDevice_WithoutVrApplicationInstance_Returns404()
+ {
+ using var db = DbContextFactory.Create();
+ db.Configurations.Add(new Configuration { Id = "conf-1", InstanceId = "inst-test", Label = "Conf", Title = new System.Collections.Generic.List() });
+ db.ApplicationInstances.Add(new ApplicationInstance { Id = "ai-1", InstanceId = "inst-test", AppType = AppType.Tablet });
+ db.SaveChanges();
+
+ var result = BuildController(db).Create(new DeviceDetailDTO
+ {
+ identifier = "quest-1",
+ instanceId = "inst-test",
+ configurationId = "conf-1",
+ appType = AppType.VR
+ });
+
+ Assert.IsType(result);
+ Assert.Equal(0, db.Devices.Count());
+ }
+
+ [Fact]
+ public void Get_FiltersByAppType()
+ {
+ using var db = DbContextFactory.Create();
+ SeedPrerequisites(db);
+ db.Devices.Add(new Device { Id = "d1", Identifier = "tab-1", InstanceId = "inst-test", ConfigurationId = "conf-1", AppType = AppType.Tablet });
+ db.Devices.Add(new Device { Id = "d2", Identifier = "quest-1", InstanceId = "inst-test", ConfigurationId = "conf-1", AppType = AppType.VR });
+ db.SaveChanges();
+
+ var vr = Assert.IsType(BuildController(db).Get(null, AppType.VR));
+ Assert.Equal("quest-1", Assert.Single((System.Collections.Generic.IEnumerable)vr.Value).identifier);
+
+ // Sans filtre : comportement historique, tous les appareils.
+ var all = Assert.IsType(BuildController(db).Get(null));
+ Assert.Equal(2, ((System.Collections.Generic.IEnumerable)all.Value).Count());
+ }
}
}
diff --git a/ManagerService.Tests/Controllers/InstanceAddonTests.cs b/ManagerService.Tests/Controllers/InstanceAddonTests.cs
new file mode 100644
index 0000000..48ebb27
--- /dev/null
+++ b/ManagerService.Tests/Controllers/InstanceAddonTests.cs
@@ -0,0 +1,170 @@
+using Hangfire;
+using Manager.Services;
+using Manager.Interfaces.Models;
+using ManagerService.Controllers;
+using ManagerService.Helpers;
+using ManagerService.Data;
+using ManagerService.DTOs;
+using ManagerService.Services;
+using ManagerService.Tests.Infrastructure;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Xunit;
+
+namespace ManagerService.Tests.Controllers
+{
+ ///
+ /// L'add-on « Contenu immersif » — §6 de DOCS/v2/vr-quest-unity-plan.md.
+ ///
+ /// ⚠️ Ces tests existent pour une raison précise : la migration pose un
+ /// defaultValue, ce qui amène EF Core à traiter la colonne comme
+ /// ValueGeneratedOnAdd — read-only après insert. Le piège est déjà
+ /// documenté dans MyInfoMateDbContext pour les quotas, et il a coûté une
+ /// migration corrigée à la main sur Device.AppType. Sans
+ /// ValueGeneratedNever, l'add-on ne s'activerait jamais — silencieusement.
+ ///
+ public class InstanceAddonTests
+ {
+ private InstanceController BuildController(MyInfoMateDbContext db)
+ {
+ var cfg = FakeMongoConfig.Create();
+ return new InstanceController(
+ NullLogger.Instance,
+ new InstanceDatabaseService(cfg),
+ new UserDatabaseService(cfg),
+ new ProfileLogic(NullLogger.Instance),
+ db,
+ new ApiKeyDatabaseService(db),
+ new Mock().Object);
+ }
+
+ [Fact]
+ public void ImmersiveContent_IsOffByDefault()
+ {
+ using var db = DbContextFactory.Create();
+ db.Instances.Add(new Instance { Id = "i1", Name = "Musée", DateCreation = DateTime.UtcNow });
+ db.SaveChanges();
+
+ Assert.False(db.Instances.First().HasImmersiveContent);
+ }
+
+ [Fact]
+ public void ImmersiveContent_CanBeTurnedOn()
+ {
+ using var db = DbContextFactory.Create();
+ db.Instances.Add(new Instance { Id = "i1", Name = "Musée", DateCreation = DateTime.UtcNow });
+ db.SaveChanges();
+
+ var result = BuildController(db).Updateinstance(
+ new InstanceDTO { id = "i1", hasImmersiveContent = true });
+
+ Assert.IsType(result);
+ Assert.True(db.Instances.First().HasImmersiveContent);
+ }
+
+ [Fact]
+ public void ImmersiveContent_AbsentFromPayload_KeepsPreviousValue()
+ {
+ using var db = DbContextFactory.Create();
+ db.Instances.Add(new Instance
+ {
+ Id = "i1",
+ Name = "Musée",
+ DateCreation = DateTime.UtcNow,
+ HasImmersiveContent = true
+ });
+ db.SaveChanges();
+
+ // Un écran qui ne parle pas de l'add-on ne doit pas le désactiver au passage :
+ // le contrôleur applique `?? valeur actuelle`, comme pour les autres drapeaux.
+ BuildController(db).Updateinstance(new InstanceDTO { id = "i1", name = "Musée" });
+
+ Assert.True(db.Instances.First().HasImmersiveContent);
+ }
+
+ private const long Envelope = 100L * 1024 * 1024 * 1024;
+ private const long PlanQuota = 10L * 1024 * 1024 * 1024;
+
+ private static MyInfoMateDbContext WithPlan(long quota = PlanQuota)
+ {
+ var db = DbContextFactory.Create();
+ db.SubscriptionPlans.Add(new SubscriptionPlan
+ {
+ Id = "pro",
+ Name = "Pro",
+ StorageQuotaBytes = quota
+ });
+ db.Instances.Add(new Instance
+ {
+ Id = "i1",
+ Name = "Musée",
+ DateCreation = DateTime.UtcNow,
+ StorageQuotaBytes = quota
+ });
+ db.SaveChanges();
+ return db;
+ }
+
+ [Fact]
+ public void ActivatingImmersive_RaisesStorageQuota()
+ {
+ using var db = WithPlan();
+
+ BuildController(db).Updateinstance(
+ new InstanceDTO { id = "i1", hasImmersiveContent = true });
+
+ // Sans ce relèvement, un client Pro activait la 360 et saturait son quota à la
+ // première vidéo : c'est le point de marge du §6 du plan.
+ Assert.Equal(PlanQuota + Envelope, db.Instances.First().StorageQuotaBytes);
+ }
+
+ [Fact]
+ public void DeactivatingImmersive_LowersStorageQuotaBack()
+ {
+ using var db = WithPlan();
+ var controller = BuildController(db);
+
+ controller.Updateinstance(new InstanceDTO { id = "i1", hasImmersiveContent = true });
+ controller.Updateinstance(new InstanceDTO { id = "i1", hasImmersiveContent = false });
+
+ Assert.Equal(PlanQuota, db.Instances.First().StorageQuotaBytes);
+ }
+
+ [Fact]
+ public void ActivatingImmersiveTwice_DoesNotStackEnvelopes()
+ {
+ using var db = WithPlan();
+ var controller = BuildController(db);
+
+ controller.Updateinstance(new InstanceDTO { id = "i1", hasImmersiveContent = true });
+ controller.Updateinstance(new InstanceDTO { id = "i1", hasImmersiveContent = true });
+
+ // Un écran qui renvoie tout le formulaire à chaque enregistrement repasse `true`
+ // sans rien changer : le quota ne doit pas grimper d'une enveloppe par clic.
+ Assert.Equal(PlanQuota + Envelope, db.Instances.First().StorageQuotaBytes);
+ }
+
+ [Fact]
+ public void ChangingPlan_KeepsTheImmersiveEnvelope()
+ {
+ using var db = WithPlan();
+ db.SubscriptionPlans.Add(new SubscriptionPlan
+ {
+ Id = "premium",
+ Name = "Premium",
+ StorageQuotaBytes = 50L * 1024 * 1024 * 1024
+ });
+ db.SaveChanges();
+
+ var controller = BuildController(db);
+ controller.Updateinstance(new InstanceDTO { id = "i1", hasImmersiveContent = true });
+ controller.Updateinstance(new InstanceDTO { id = "i1", subscriptionPlanId = "premium" });
+
+ // ApplyPlanQuotas réécrit le quota depuis le plan : sans reposer l'enveloppe
+ // par-dessus, faire monter un client en gamme lui retirerait son add-on.
+ Assert.Equal(50L * 1024 * 1024 * 1024 + Envelope,
+ db.Instances.First().StorageQuotaBytes);
+ }
+ }
+}
diff --git a/ManagerService.Tests/Controllers/InstanceControllerTests.cs b/ManagerService.Tests/Controllers/InstanceControllerTests.cs
index 83c8409..4d7a682 100644
--- a/ManagerService.Tests/Controllers/InstanceControllerTests.cs
+++ b/ManagerService.Tests/Controllers/InstanceControllerTests.cs
@@ -69,6 +69,77 @@ namespace ManagerService.Tests.Controllers
Assert.IsType(result);
}
+ // ── ROUTES ANONYMES ──────────────────────────────────────────────────
+ // Le slug est public : c'est l'URL du site visiteur. Ce qui sort d'ici sort
+ // donc pour tout le monde, et un ajout au DTO ne doit plus jamais fuiter en
+ // silence — c'est l'objet de ces trois tests.
+
+ private static Instance BuildInstanceWithSecrets() => new Instance
+ {
+ Id = "i1",
+ Name = "Musée",
+ DateCreation = DateTime.UtcNow,
+ WebSlug = "musee",
+ PinCode = "4821",
+ PublicApiKey = "ap_xxx",
+ BillingAddress = "12 rue des Tests",
+ VatNumber = "BE0123456789",
+ StorageQuotaBytes = 42,
+ IsTrialActive = true
+ };
+
+ [Fact]
+ public void GetInstanceBySlug_DoesNotLeakPinCodeOrBilling()
+ {
+ using var db = DbContextFactory.Create();
+ db.Instances.Add(BuildInstanceWithSecrets());
+ db.SaveChanges();
+
+ var result = Assert.IsType(BuildController(db).GetInstanceBySlug("musee"));
+ var dto = Assert.IsType(result.Value);
+
+ // Le pinCode ouvre l'appairage des tablettes et des casques.
+ Assert.Null(dto.pinCode);
+ Assert.Null(dto.billingAddress);
+ Assert.Null(dto.vatNumber);
+ Assert.Null(dto.storageQuotaBytes);
+ Assert.Null(dto.subscriptionPlan);
+ }
+
+ [Fact]
+ public void GetInstanceBySlug_KeepsWhatTheWebAppNeeds()
+ {
+ using var db = DbContextFactory.Create();
+ db.Instances.Add(BuildInstanceWithSecrets());
+ db.SaveChanges();
+
+ var result = Assert.IsType(BuildController(db).GetInstanceBySlug("musee"));
+ var dto = Assert.IsType(result.Value);
+
+ // visitapp-web s'amorce sur cette route : sans la clé il n'a rien, et sans
+ // isTrialActive le filigrane d'essai disparaît ([slug]/layout.tsx:41).
+ Assert.Equal("ap_xxx", dto.publicApiKey);
+ Assert.Equal("musee", dto.webSlug);
+ Assert.True(dto.isTrialActive);
+ }
+
+ [Fact]
+ public void GetInstanceByPinCode_KeepsPinButDropsBilling()
+ {
+ using var db = DbContextFactory.Create();
+ db.Instances.Add(BuildInstanceWithSecrets());
+ db.SaveChanges();
+
+ var result = Assert.IsType(BuildController(db).GetInstanceByPinCode("4821"));
+ var dto = Assert.IsType(result.Value);
+
+ // L'appelant a fourni le pinCode : le rendre ne lui apprend rien. Le reste,
+ // si.
+ Assert.Equal("4821", dto.pinCode);
+ Assert.Null(dto.billingAddress);
+ Assert.Null(dto.vatNumber);
+ }
+
// ── UPDATE ───────────────────────────────────────────────────────────
[Fact]
diff --git a/ManagerService.Tests/Data/AuditLogTests.cs b/ManagerService.Tests/Data/AuditLogTests.cs
index 4d17993..f6e832c 100644
--- a/ManagerService.Tests/Data/AuditLogTests.cs
+++ b/ManagerService.Tests/Data/AuditLogTests.cs
@@ -222,7 +222,10 @@ namespace ManagerService.Tests.Data
.Where(t => typeof(Section).IsAssignableFrom(t) && !t.IsAbstract)
.ToList();
- Assert.Equal(13, subTypes.Count);
+ // 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)!;
diff --git a/ManagerService.Tests/Data/ResourceTypeTests.cs b/ManagerService.Tests/Data/ResourceTypeTests.cs
new file mode 100644
index 0000000..007ddb1
--- /dev/null
+++ b/ManagerService.Tests/Data/ResourceTypeTests.cs
@@ -0,0 +1,35 @@
+using ManagerService.Data;
+using Xunit;
+
+namespace ManagerService.Tests.Data
+{
+ ///
+ /// ResourceType est persisté en int dans une colonne integer :
+ /// réordonner l'enum ou insérer une valeur au milieu réécrirait silencieusement le
+ /// type de toutes les ressources déjà en base — les PDF deviendraient des JSON, et
+ /// rien ne le signalerait. Ce test est le garde-fou : il échoue à la moindre
+ /// insertion ailleurs qu'à la fin.
+ ///
+ public class ResourceTypeTests
+ {
+ [Theory]
+ [InlineData(ResourceType.Image, 0)]
+ [InlineData(ResourceType.Video, 1)]
+ [InlineData(ResourceType.ImageUrl, 2)]
+ [InlineData(ResourceType.VideoUrl, 3)]
+ [InlineData(ResourceType.Audio, 4)]
+ [InlineData(ResourceType.PDF, 5)]
+ [InlineData(ResourceType.JSON, 6)]
+ [InlineData(ResourceType.JSONUrl, 7)]
+ [InlineData(ResourceType.Word, 8)]
+ [InlineData(ResourceType.PowerPoint, 9)]
+ [InlineData(ResourceType.Text, 10)]
+ [InlineData(ResourceType.Image360, 11)]
+ [InlineData(ResourceType.Video360, 12)]
+ [InlineData(ResourceType.Model3D, 13)]
+ public void Values_AreStable(ResourceType type, int expected)
+ {
+ Assert.Equal(expected, (int)type);
+ }
+ }
+}
diff --git a/ManagerService.Tests/Security/RequireAppKeyAttributeTests.cs b/ManagerService.Tests/Security/RequireAppKeyAttributeTests.cs
new file mode 100644
index 0000000..ad9655f
--- /dev/null
+++ b/ManagerService.Tests/Security/RequireAppKeyAttributeTests.cs
@@ -0,0 +1,101 @@
+using System.Security.Claims;
+using ManagerService.Security;
+using ManagerService.Tests.Infrastructure;
+using Microsoft.AspNetCore.Authentication;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.Abstractions;
+using Microsoft.AspNetCore.Mvc.Filters;
+using Microsoft.AspNetCore.Routing;
+using Microsoft.Extensions.DependencyInjection;
+using Moq;
+using Xunit;
+
+namespace ManagerService.Tests.Security
+{
+ ///
+ /// Les tests des contrôleurs appellent les méthodes directement : aucun filtre
+ /// d'autorisation ne s'y exécute, et ils ne prouvent donc rien sur la fermeture des
+ /// routes. C'est ce trou-là que ces tests comblent — ils visent le filtre lui-même.
+ ///
+ public class RequireAppKeyAttributeTests
+ {
+ private static AuthorizationFilterContext BuildContext(
+ bool apiKeyValid, ClaimsPrincipal user = null)
+ {
+ var authentication = new Mock();
+ authentication
+ .Setup(a => a.AuthenticateAsync(It.IsAny(),
+ RequireAppKeyAttribute.ApiKeyScheme))
+ .ReturnsAsync(apiKeyValid
+ ? AuthenticateResult.Success(new AuthenticationTicket(
+ new ClaimsPrincipal(new ClaimsIdentity("ApiKey")),
+ RequireAppKeyAttribute.ApiKeyScheme))
+ : AuthenticateResult.NoResult());
+
+ var services = new ServiceCollection();
+ services.AddSingleton(authentication.Object);
+
+ var http = new DefaultHttpContext
+ {
+ RequestServices = services.BuildServiceProvider(),
+ User = user ?? new ClaimsPrincipal(new ClaimsIdentity())
+ };
+
+ return new AuthorizationFilterContext(
+ new ActionContext(http, new RouteData(), new ActionDescriptor()),
+ new List());
+ }
+
+ [Fact]
+ public async Task NoKeyNoUser_Returns401()
+ {
+ var context = BuildContext(apiKeyValid: false);
+
+ await new RequireAppKeyAttribute().OnAuthorizationAsync(context);
+
+ var result = Assert.IsType(context.Result);
+ Assert.Equal(401, result.StatusCode);
+ }
+
+ [Fact]
+ public async Task ValidApiKey_LetsThrough()
+ {
+ var context = BuildContext(apiKeyValid: true);
+
+ await new RequireAppKeyAttribute().OnAuthorizationAsync(context);
+
+ Assert.Null(context.Result);
+ }
+
+ [Fact]
+ public async Task ManagerUser_LetsThrough()
+ {
+ // Le back-office appelle les mêmes routes avec son jeton, sans clé d'API.
+ var manager = new ClaimsPrincipal(new ClaimsIdentity(
+ new[] { new Claim(ClaimTypeKeys.Permission, Permissions.Viewer) },
+ authenticationType: "Bearer"));
+
+ var context = BuildContext(apiKeyValid: false, user: manager);
+
+ await new RequireAppKeyAttribute().OnAuthorizationAsync(context);
+
+ Assert.Null(context.Result);
+ }
+
+ [Fact]
+ public async Task AuthenticatedWithoutPermission_Returns401()
+ {
+ // Authentifié ne suffit pas : il faut la permission de lecture.
+ var stranger = new ClaimsPrincipal(new ClaimsIdentity(
+ new[] { new Claim("sub", "someone") }, authenticationType: "Bearer"));
+
+ var context = BuildContext(apiKeyValid: false, user: stranger);
+
+ await new RequireAppKeyAttribute().OnAuthorizationAsync(context);
+
+ var result = Assert.IsType(context.Result);
+ Assert.Equal(401, result.StatusCode);
+ }
+ }
+}
diff --git a/ManagerService/Controllers/ApplicationInstanceController.cs b/ManagerService/Controllers/ApplicationInstanceController.cs
index 46f114d..f9cda54 100644
--- a/ManagerService/Controllers/ApplicationInstanceController.cs
+++ b/ManagerService/Controllers/ApplicationInstanceController.cs
@@ -7,6 +7,7 @@ using ManagerService.DTOs;
using ManagerService.Helpers;
using ManagerService.Services;
using Microsoft.AspNetCore.Authorization;
+using ManagerService.Security;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
@@ -224,6 +225,7 @@ namespace ManagerService.Controllers
///
/// application instance id
[AllowAnonymous]
+ [RequireAppKey]
[ProducesResponseType(typeof(List), 200)]
[ProducesResponseType(typeof(string), 500)]
[HttpGet("{applicationInstanceId}/application-link")]
diff --git a/ManagerService/Controllers/ConfigurationController.cs b/ManagerService/Controllers/ConfigurationController.cs
index c89c7fb..73780d3 100644
--- a/ManagerService/Controllers/ConfigurationController.cs
+++ b/ManagerService/Controllers/ConfigurationController.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
@@ -14,7 +14,9 @@ using ManagerService.DTOs;
using ManagerService.Services;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authorization;
+using ManagerService.Security;
using Microsoft.AspNetCore.Mvc;
+using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Mqtt.Client.AspNetCore.Services;
@@ -51,6 +53,7 @@ namespace ManagerService.Controllers
///
/// id instance
[AllowAnonymous]
+ [RequireAppKey]
[ProducesResponseType(typeof(List), 200)]
[ProducesResponseType(typeof(string), 500)]
[HttpGet]
@@ -116,6 +119,7 @@ namespace ManagerService.Controllers
///
/// id configuration
[AllowAnonymous]
+ [RequireAppKey]
[ProducesResponseType(typeof(ConfigurationDTO), 200)]
[ProducesResponseType(typeof(string), 404)]
[ProducesResponseType(typeof(string), 500)]
@@ -296,6 +300,8 @@ namespace ManagerService.Controllers
configuration.IsOffline = updatedConfiguration.isOffline;
configuration.LoaderImageId = updatedConfiguration.loaderImageId;
configuration.LoaderImageUrl = updatedConfiguration.loaderImageUrl;
+ configuration.ImmersiveBackground =
+ ImmersiveBackground.FromDTO(updatedConfiguration.immersiveBackground);
//Configuration configurationModified = _configurationService.Update(updatedConfiguration.id, configuration);
_myInfoMateDbContext.SaveChanges();
@@ -436,7 +442,29 @@ namespace ManagerService.Controllers
// Les entités, pas seulement leurs DTO : la collecte des ressources passe
// par GetReferencedResourceIds, qui vit sur le sous-type.
List sections = _myInfoMateDbContext.Sections.Where(s => s.ConfigurationId == configuration.Id).ToList();
- List sectionDTOs = sections.Select(s => s.ToDTO()).ToList();
+
+ // Les collections des sous-types sont dans d'autres tables : sans ces
+ // chargements, une Map exporte zéro point et une maquette zéro POI. EF
+ // rattache les entités à celles déjà suivies, il n'y a rien à réaffecter.
+ var configurationId = configuration.Id;
+
+ _myInfoMateDbContext.Sections.OfType()
+ .Include(s => s.MapPoints)
+ .Where(s => s.ConfigurationId == configurationId).ToList();
+
+ _myInfoMateDbContext.Sections.OfType()
+ .Include(s => s.Points)
+ .Where(s => s.ConfigurationId == configurationId).ToList();
+
+ // ⚠️ `Section.ToDTO()` n'est pas virtuelle : appelée sur une variable de
+ // type `Section`, elle ne rend que les champs communs. L'export ne portait
+ // donc **aucun champ spécifique** — ni les contenus d'un Slider, ni les
+ // points d'une Map, ni le programme d'un Event — alors qu'il est censé
+ // être « tout le contenu en un appel ». `SectionFactory.ToDTO` fait le
+ // bon sous-type, et la sérialisation suit le type réel.
+ List sectionDTOs = sections
+ .Select(s => (SectionDTO)SectionFactory.ToDTO(s))
+ .ToList();
List resourceDTOs = new List();
if (configuration.ImageId != null)
@@ -449,6 +477,15 @@ namespace ManagerService.Controllers
addResourceToList(resourceDTOs, configuration.LoaderImageId);
}
+ // Le fond immersif est une ressource comme une autre : oubliée ici, elle
+ // n'existerait pas dans la visite hors ligne, et le casque afficherait son
+ // menu dans le noir dès la première coupure de réseau.
+ if (configuration.ImmersiveBackground != null)
+ {
+ addResourceToList(resourceDTOs, configuration.ImmersiveBackground.ResourceId);
+ addResourceToList(resourceDTOs, configuration.ImmersiveBackground.FallbackResourceId);
+ }
+
foreach (var section in sections)
{
// Remplace 157 lignes de `switch` commenté qui n'ont jamais tourné :
@@ -462,6 +499,19 @@ namespace ManagerService.Controllers
addResourceToList(resourceDTOs, resourceId);
}
ExportConfigurationDTO toDownload = configuration.ToExportDTO(sectionDTOs, resourceDTOs);
+
+ // Les URL sont posées ici parce que c'est le seul endroit qui a les
+ // ressources sous la main. Le casque lit ce JSON sans client généré et
+ // souvent sans réseau : lui faire résoudre un id de plus serait un appel
+ // qu'il ne peut pas passer.
+ if (toDownload.immersiveBackground != null)
+ {
+ toDownload.immersiveBackground.resourceUrl = resourceDTOs
+ .FirstOrDefault(r => r.id == toDownload.immersiveBackground.resourceId)?.url;
+ toDownload.immersiveBackground.fallbackUrl = resourceDTOs
+ .FirstOrDefault(r => r.id == toDownload.immersiveBackground.fallbackResourceId)?.url;
+ }
+
string jsonString = JsonConvert.SerializeObject(toDownload);
var fileName = $"{configuration.Label}.json";
var mimeType = "application/json";
@@ -534,6 +584,17 @@ namespace ManagerService.Controllers
createResource(exportConfiguration.resources.Where(r => r.id == configuration.LoaderImageId).FirstOrDefault());
}
+ configuration.ImmersiveBackground =
+ ImmersiveBackground.FromDTO(exportConfiguration.immersiveBackground);
+
+ if (configuration.ImmersiveBackground != null)
+ {
+ createResource(exportConfiguration.resources.FirstOrDefault(
+ r => r.id == configuration.ImmersiveBackground.ResourceId));
+ createResource(exportConfiguration.resources.FirstOrDefault(
+ r => r.id == configuration.ImmersiveBackground.FallbackResourceId));
+ }
+
_myInfoMateDbContext.Configurations.Add(configuration);
//_configurationService.Create(configuration);
diff --git a/ManagerService/Controllers/DeviceController.cs b/ManagerService/Controllers/DeviceController.cs
index 96e5f30..6cf62fe 100644
--- a/ManagerService/Controllers/DeviceController.cs
+++ b/ManagerService/Controllers/DeviceController.cs
@@ -43,11 +43,12 @@ namespace ManagerService.Controllers
///
/// Get a list of all devices
///
- /// id instance
+ /// id instance
+ /// Canal à filtrer. Absent = tous les appareils, comportement historique.
[ProducesResponseType(typeof(List), 200)]
[ProducesResponseType(typeof(string), 500)]
[HttpGet]
- public ObjectResult Get([FromQuery] string instanceId)
+ public ObjectResult Get([FromQuery] string instanceId, [FromQuery] AppType? appType = null)
{
try
{
@@ -56,6 +57,8 @@ namespace ManagerService.Controllers
var query = _myInfoMateDbContext.Devices.Include(d => d.Configuration).AsQueryable();
if (scopedInstanceId != null)
query = query.Where(d => d.InstanceId == scopedInstanceId);
+ if (appType != null)
+ query = query.Where(d => d.AppType == appType.Value);
return new OkObjectResult(query.ToList().Select(d => d.ToDTO()));
}
@@ -70,6 +73,19 @@ namespace ManagerService.Controllers
/// Get a specific device
///
/// id device
+ ///
+ /// ⚠️ C'est le premier appel d'une tablette qui démarre :
+ /// tablet-app/lib/main.dart:38 reconstruit son client avec l'hôte mémorisé,
+ /// puis demande son propre détail pour savoir quelle configuration afficher. Sans
+ /// exception à la policy InstanceAdmin de la classe, cet appel répondait
+ /// 403 et la tablette ne retrouvait plus son contenu au démarrage — même
+ /// cause que l'appairage cassé depuis le 13/03/2026 (voir ).
+ ///
+ /// Le cloisonnement est déjà écrit plus bas : une clé ne voit que les appareils
+ /// de son instance, et un appareil d'ailleurs ressort en 404.
+ ///
+ [AllowAnonymous]
+ [Security.RequireAppKey]
[ProducesResponseType(typeof(DeviceDetailDTO), 200)]
[ProducesResponseType(typeof(string), 404)]
[ProducesResponseType(typeof(string), 500)]
@@ -100,6 +116,21 @@ namespace ManagerService.Controllers
/// Create a new device
///
/// New device info
+ ///
+ /// ⚠️ C'est une app qui appelle cette route, pas un humain : une tablette
+ /// qui s'appaire avec un code PIN, et bientôt un casque. Or le contrôleur exige
+ /// InstanceAdmin, qu'une clé d'API n'a pas — elle ne porte que
+ /// AppRead et Viewer — et tablet-app ne s'authentifie
+ /// jamais autrement. Depuis que la policy a été posée sur la classe (commit
+ /// a452f4a, 13/03/2026, « need to be tested »), l'appairage d'une
+ /// nouvelle tablette répondait 403 ; les tablettes déjà appairées ne
+ /// rappellent pas cette route, donc rien ne le signalait.
+ ///
+ /// Le cloisonnement, lui, était déjà écrit juste en dessous : une clé ne peut
+ /// créer un appareil que dans son instance.
+ ///
+ [AllowAnonymous]
+ [Security.RequireAppKey]
[ProducesResponseType(typeof(DeviceDetailDTO), 200)]
[ProducesResponseType(typeof(string), 400)]
[ProducesResponseType(typeof(string), 404)]
@@ -151,11 +182,14 @@ namespace ManagerService.Controllers
device.LastConnectionLevel = newDevice.lastConnectionLevel;
device.BatteryLevel = newDevice.batteryLevel;
device.LastBatteryLevel = newDevice.lastBatteryLevel;
+ device.AppType = newDevice.appType;
- ApplicationInstance applicationInstance = _myInfoMateDbContext.ApplicationInstances.FirstOrDefault(ai => ai.InstanceId == newDevice.instanceId && ai.AppType == AppType.Tablet);
+ // Était hardcodé sur AppType.Tablet : un casque enregistré par ce chemin était
+ // rattaché à l'instance kiosk et apparaissait dans l'onglet Kiosk.
+ ApplicationInstance applicationInstance = _myInfoMateDbContext.ApplicationInstances.FirstOrDefault(ai => ai.InstanceId == newDevice.instanceId && ai.AppType == newDevice.appType);
if (applicationInstance == null)
- throw new KeyNotFoundException("Application instance does not exist");
+ throw new KeyNotFoundException($"Application instance does not exist for app type {newDevice.appType}");
//OldDevice deviceCreated = _deviceService.IsExistIdentifier(newDevice.identifier) ? _deviceService.Update(device.Id, device) : _deviceService.Create(device);
if (deviceDB != null)
@@ -263,10 +297,86 @@ namespace ManagerService.Controllers
}
}
+ ///
+ /// Heartbeat sent by a running app: battery, app version, last seen.
+ ///
+ /// Device id
+ /// What the app knows about itself
+ ///
+ /// Lot XR-5. L'onglet XR affiche batterie, version et dernier vu depuis
+ /// le 12/09 — mais rien ne les alimentait : Update n'écrit ni
+ /// AppVersion ni LastSeen, et exige de toute façon un compte admin.
+ ///
+ /// Volontairement étroit : une app en fonctionnement ne doit pas pouvoir se
+ /// renommer, changer d'instance ni se réassigner une configuration. Elle dit
+ /// seulement comment elle va.
+ ///
+ [AllowAnonymous]
+ [Security.RequireAppKey]
+ [ProducesResponseType(typeof(DeviceDTO), 200)]
+ [ProducesResponseType(typeof(string), 403)]
+ [ProducesResponseType(typeof(string), 404)]
+ [ProducesResponseType(typeof(string), 500)]
+ [HttpPut("{id}/heartbeat")]
+ public ObjectResult Heartbeat(string id, [FromBody] DeviceHeartbeatDTO beat)
+ {
+ try
+ {
+ Device device = _myInfoMateDbContext.Devices.FirstOrDefault(d => d.Id == id);
+
+ if (device == null)
+ throw new KeyNotFoundException("Device does not exist");
+
+ // Une clé ne parle que des appareils de son instance. Sans ce contrôle,
+ // n'importe quelle app pourrait écrire l'état des casques d'un autre lieu.
+ if (!IsSuperAdmin() && device.InstanceId != GetCallerInstanceId())
+ throw new UnauthorizedAccessException("This key does not grant access to this device");
+
+ var now = DateTime.Now.ToUniversalTime();
+
+ if (beat?.batteryLevel != null)
+ {
+ device.BatteryLevel = beat.batteryLevel;
+ device.LastBatteryLevel = now;
+ }
+
+ if (!string.IsNullOrEmpty(beat?.appVersion))
+ device.AppVersion = beat.appVersion;
+
+ if (beat?.connectionLevel != null)
+ {
+ device.ConnectionLevel = beat.connectionLevel;
+ device.LastConnectionLevel = now;
+ }
+
+ // Recevoir un battement **est** la preuve que l'appareil est en ligne :
+ // on ne demande pas à l'app de nous dire qu'elle est connectée.
+ device.Connected = true;
+ device.LastSeen = now;
+ device.DateUpdate = now;
+
+ _myInfoMateDbContext.SaveChanges();
+
+ return new OkObjectResult(device.ToDTO());
+ }
+ catch (UnauthorizedAccessException ex)
+ {
+ return new ObjectResult(ex.Message) { StatusCode = 403 };
+ }
+ catch (KeyNotFoundException ex)
+ {
+ return new NotFoundObjectResult(ex.Message) { };
+ }
+ catch (Exception ex)
+ {
+ return new ObjectResult(ex.Message) { StatusCode = 500 };
+ }
+ }
+
///
/// Update device main info
///
- /// Device to update
+ /// Device to update
[ProducesResponseType(typeof(DeviceDTO), 200)]
[ProducesResponseType(typeof(string), 400)]
[ProducesResponseType(typeof(string), 404)]
diff --git a/ManagerService/Controllers/InstanceController.cs b/ManagerService/Controllers/InstanceController.cs
index edf2654..49f94e1 100644
--- a/ManagerService/Controllers/InstanceController.cs
+++ b/ManagerService/Controllers/InstanceController.cs
@@ -161,6 +161,36 @@ namespace ManagerService.Controllers
instance.HasAdvancedStats = plan.HasAdvancedStats;
}
+ ///
+ /// Enveloppe de stockage de l'add-on « Contenu immersif ».
+ ///
+ /// C'est le point de marge du §6 du plan VR : une vidéo 360 de 5 minutes pèse des
+ /// gigaoctets, et un client Pro a un quota dimensionné pour du Pro. Sans enveloppe
+ /// propre, le premier client immersif sur un petit palier mange la marge de l'add-on.
+ ///
+ /// ⚠️ Elle s'ajoute au quota du plan, elle ne le remplace pas — et elle se retire à
+ /// la désactivation, sinon désactiver puis réactiver empilerait les enveloppes.
+ ///
+ private const long ImmersiveStorageBytes = 100L * 1024 * 1024 * 1024;
+
+ private void ApplyImmersiveStorage(Instance instance, bool hadImmersive, bool planQuotasReapplied)
+ {
+ // Le changement de plan vient d'écraser le quota par celui du plan : l'enveloppe
+ // doit être reposée par-dessus, même si l'add-on n'a pas bougé.
+ if (planQuotasReapplied)
+ {
+ if (instance.HasImmersiveContent)
+ instance.StorageQuotaBytes += ImmersiveStorageBytes;
+ return;
+ }
+
+ if (!hadImmersive && instance.HasImmersiveContent)
+ instance.StorageQuotaBytes += ImmersiveStorageBytes;
+ else if (hadImmersive && !instance.HasImmersiveContent)
+ instance.StorageQuotaBytes =
+ Math.Max(0, instance.StorageQuotaBytes - ImmersiveStorageBytes);
+ }
+
///
/// Create an instance
///
@@ -252,6 +282,9 @@ namespace ManagerService.Controllers
instance.IsVR = updatedInstance.isVR ?? instance.IsVR;
instance.IsAssistant = updatedInstance.isAssistant ?? instance.IsAssistant;
+ var hadImmersive = instance.HasImmersiveContent;
+ instance.HasImmersiveContent = updatedInstance.hasImmersiveContent ?? instance.HasImmersiveContent;
+
if (updatedInstance.guideName != null)
instance.GuideName = updatedInstance.guideName;
if (updatedInstance.guidePersonaPrompt != null)
@@ -276,9 +309,14 @@ namespace ManagerService.Controllers
// L'endpoint /quota masquait la moitié du problème en retombant sur le plan à la
// lecture, mais AiController lit `instance.AiTokensPerMonth` — un client passé à
// un plan payant restait à 0, donc sans IA.
- if (instance.SubscriptionPlanId != previousPlanId)
+ var planQuotasReapplied = instance.SubscriptionPlanId != previousPlanId;
+ if (planQuotasReapplied)
ApplyPlanQuotas(instance);
+ // Après ApplyPlanQuotas, jamais avant : celui-ci réécrit le quota depuis le
+ // plan, et reposerait donc l'enveloppe immersive à zéro en silence.
+ ApplyImmersiveStorage(instance, hadImmersive, planQuotasReapplied);
+
//OldInstance instanceModified = _instanceService.Update(updatedInstance.Id, instance);
_myInfoMateDbContext.SaveChanges();
@@ -328,7 +366,22 @@ namespace ManagerService.Controllers
var applicationInstances = _myInfoMateDbContext.ApplicationInstances.Where(ai => ai.InstanceId == instance.Id).ToList();
- return new OkObjectResult(instance.ToDTO(applicationInstances.Select(ai => ai.ToDTO(_myInfoMateDbContext)).ToList()));
+ var dto = instance.ToDTO(applicationInstances.Select(ai => ai.ToDTO(_myInfoMateDbContext)).ToList());
+
+ // Cette route est anonyme **et** le slug est public : c'est l'URL du site
+ // visiteur (app.myinfomate.be/{slug}). Sans ce filtrage elle rendait le
+ // pinCode — qui ouvre l'appairage des tablettes et des casques — ainsi que
+ // l'adresse de facturation, le numéro de TVA et les quotas du client, à
+ // qui lit une URL. Le filtrage existait déjà pour GetDetail ; il manquait ici.
+ var isTrialActive = dto.isTrialActive;
+ StripCommercialFields(dto);
+
+ // Seule exception : le filigrane d'essai de visitapp-web
+ // (`[slug]/layout.tsx:41`) s'affiche au visiteur, donc ce n'est pas un
+ // secret — et sans ce drapeau il disparaîtrait.
+ dto.isTrialActive = isTrialActive;
+
+ return new OkObjectResult(dto);
}
catch (KeyNotFoundException ex)
{
@@ -343,7 +396,7 @@ namespace ManagerService.Controllers
///
/// Get Instance by pincode
///
- /// Code pin
+ /// Code pin
[AllowAnonymous]
[ProducesResponseType(typeof(InstanceDTO), 200)]
[ProducesResponseType(typeof(string), 404)]
@@ -361,7 +414,18 @@ namespace ManagerService.Controllers
var applicationInstances = _myInfoMateDbContext.ApplicationInstances.Where(ai => ai.InstanceId == instance.Id).ToList();
- return new OkObjectResult(instance.ToDTO(applicationInstances.Select(ai => ai.ToDTO(_myInfoMateDbContext)).ToList()));
+ var dto = instance.ToDTO(applicationInstances.Select(ai => ai.ToDTO(_myInfoMateDbContext)).ToList());
+
+ // Anonyme aussi, mais il faut connaître le pinCode pour arriver ici : le
+ // rendre n'apprend donc rien à l'appelant. Le reste — facturation, TVA,
+ // quotas, plan — n'a jamais concerné une app visiteur.
+ var knownPinCode = dto.pinCode;
+ var isTrialActive = dto.isTrialActive;
+ StripCommercialFields(dto);
+ dto.pinCode = knownPinCode;
+ dto.isTrialActive = isTrialActive;
+
+ return new OkObjectResult(dto);
}
catch (KeyNotFoundException ex)
{
diff --git a/ManagerService/Controllers/ResourceController.cs b/ManagerService/Controllers/ResourceController.cs
index 99782f0..6b54197 100644
--- a/ManagerService/Controllers/ResourceController.cs
+++ b/ManagerService/Controllers/ResourceController.cs
@@ -1,4 +1,4 @@
-using System;
+using System;
using System.Collections.Generic;
using System.Drawing;
using System.IO;
@@ -13,6 +13,7 @@ using ManagerService.DTOs;
using ManagerService.Helpers;
using ManagerService.Services;
using Microsoft.AspNetCore.Authorization;
+using ManagerService.Security;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
@@ -100,6 +101,7 @@ namespace ManagerService.Controllers
///
/// id resource
[AllowAnonymous]
+ [RequireAppKey]
[ProducesResponseType(typeof(ResourceDTO), 200)]
[ProducesResponseType(typeof(string), 404)]
[ProducesResponseType(typeof(string), 500)]
@@ -161,6 +163,7 @@ namespace ManagerService.Controllers
///
/// id resource
[AllowAnonymous]
+ [RequireAppKey]
[ProducesResponseType(typeof(FileResult), 200)]
[ProducesResponseType(typeof(string), 404)]
[ProducesResponseType(typeof(string), 500)]
diff --git a/ManagerService/Controllers/SectionAgendaController.cs b/ManagerService/Controllers/SectionAgendaController.cs
index 7ce643b..a3819ba 100644
--- a/ManagerService/Controllers/SectionAgendaController.cs
+++ b/ManagerService/Controllers/SectionAgendaController.cs
@@ -2,6 +2,7 @@ using ManagerService.Data;
using ManagerService.Data.SubSection;
using ManagerService.Services;
using Microsoft.AspNetCore.Authorization;
+using ManagerService.Security;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
@@ -38,6 +39,7 @@ namespace ManagerService.Controllers
///
/// Section id
[AllowAnonymous]
+ [RequireAppKey]
[ProducesResponseType(typeof(List), 200)]
[ProducesResponseType(typeof(string), 404)]
[ProducesResponseType(typeof(string), 500)]
@@ -80,6 +82,7 @@ namespace ManagerService.Controllers
///
/// Section id
[AllowAnonymous]
+ [RequireAppKey]
[ProducesResponseType(typeof(List), 200)]
[ProducesResponseType(typeof(string), 404)]
[ProducesResponseType(typeof(string), 500)]
diff --git a/ManagerService/Controllers/SectionController.cs b/ManagerService/Controllers/SectionController.cs
index d21f734..84eb872 100644
--- a/ManagerService/Controllers/SectionController.cs
+++ b/ManagerService/Controllers/SectionController.cs
@@ -9,6 +9,7 @@ using ManagerService.DTOs;
using ManagerService.Helpers;
using ManagerService.Services;
using Microsoft.AspNetCore.Authorization;
+using ManagerService.Security;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
@@ -134,6 +135,7 @@ namespace ManagerService.Controllers
///
/// configuration id
[AllowAnonymous]
+ [RequireAppKey]
[ProducesResponseType(typeof(List), 200)]
[ProducesResponseType(typeof(string), 500)]
[ProducesResponseType(typeof(string), 400)]
@@ -172,6 +174,7 @@ namespace ManagerService.Controllers
///
/// configuration id
[AllowAnonymous]
+ [RequireAppKey]
[ProducesResponseType(typeof(List