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>
456 lines
18 KiB
C#
456 lines
18 KiB
C#
using Hangfire;
|
|
using Manager.Services;
|
|
using Manager.Interfaces.Models;
|
|
using ManagerService.Controllers;
|
|
using ManagerService.Data;
|
|
using ManagerService.DTOs;
|
|
using ManagerService.Helpers;
|
|
using ManagerService.Services;
|
|
using ManagerService.Tests.Infrastructure;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Moq;
|
|
using Xunit;
|
|
|
|
namespace ManagerService.Tests.Controllers
|
|
{
|
|
public class InstanceControllerTests
|
|
{
|
|
private InstanceController BuildController(MyInfoMateDbContext db, Mock<IBackgroundJobClient> jobs = null)
|
|
{
|
|
var cfg = FakeMongoConfig.Create();
|
|
var instanceService = new InstanceDatabaseService(cfg);
|
|
var userService = new UserDatabaseService(cfg);
|
|
var profileLogic = new ProfileLogic(NullLogger<ProfileLogic>.Instance);
|
|
var apiKeyService = new ApiKeyDatabaseService(db);
|
|
|
|
return new InstanceController(
|
|
NullLogger<InstanceController>.Instance,
|
|
instanceService,
|
|
userService,
|
|
profileLogic,
|
|
db,
|
|
apiKeyService,
|
|
(jobs ?? new Mock<IBackgroundJobClient>()).Object);
|
|
}
|
|
|
|
// ── CREATE ───────────────────────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public void CreateInstance_DuplicateName_Returns409()
|
|
{
|
|
using var db = DbContextFactory.Create();
|
|
db.Instances.Add(new Instance { Id = "i1", Name = "Musée", DateCreation = DateTime.UtcNow });
|
|
db.SaveChanges();
|
|
|
|
var result = BuildController(db).CreateInstance(new InstanceDTO { name = "Musée" });
|
|
|
|
Assert.IsType<ConflictObjectResult>(result);
|
|
}
|
|
|
|
[Fact]
|
|
public void CreateInstance_ValidDto_PersistsAndReturnsDto()
|
|
{
|
|
using var db = DbContextFactory.Create();
|
|
|
|
var result = BuildController(db).CreateInstance(new InstanceDTO { name = "Nouveau" });
|
|
|
|
Assert.IsType<OkObjectResult>(result);
|
|
Assert.Equal(1, db.Instances.Count());
|
|
}
|
|
|
|
[Fact]
|
|
public void CreateInstance_NullDto_Returns400()
|
|
{
|
|
using var db = DbContextFactory.Create();
|
|
|
|
var result = BuildController(db).CreateInstance(null!);
|
|
|
|
Assert.IsType<BadRequestObjectResult>(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<OkObjectResult>(BuildController(db).GetInstanceBySlug("musee"));
|
|
var dto = Assert.IsType<InstanceDTO>(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<OkObjectResult>(BuildController(db).GetInstanceBySlug("musee"));
|
|
var dto = Assert.IsType<InstanceDTO>(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<OkObjectResult>(BuildController(db).GetInstanceByPinCode("4821"));
|
|
var dto = Assert.IsType<InstanceDTO>(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]
|
|
public void Updateinstance_ClearSubscriptionPlan_SetsNull()
|
|
{
|
|
using var db = DbContextFactory.Create();
|
|
db.SubscriptionPlans.Add(new SubscriptionPlan { Id = "p1", Name = "Starter" });
|
|
db.Instances.Add(new Instance { Id = "i1", Name = "Musée", SubscriptionPlanId = "p1", DateCreation = DateTime.UtcNow });
|
|
db.SaveChanges();
|
|
|
|
// subscriptionPlanId = "" → doit effacer le plan
|
|
var result = BuildController(db).Updateinstance(new InstanceDTO { id = "i1", subscriptionPlanId = "" });
|
|
|
|
Assert.IsType<OkObjectResult>(result);
|
|
Assert.Null(db.Instances.First().SubscriptionPlanId);
|
|
}
|
|
|
|
[Fact]
|
|
public void Updateinstance_SetSubscriptionPlan_Updates()
|
|
{
|
|
using var db = DbContextFactory.Create();
|
|
db.SubscriptionPlans.Add(new SubscriptionPlan { Id = "p1", Name = "Starter" });
|
|
db.Instances.Add(new Instance { Id = "i1", Name = "Musée", DateCreation = DateTime.UtcNow });
|
|
db.SaveChanges();
|
|
|
|
var result = BuildController(db).Updateinstance(new InstanceDTO { id = "i1", subscriptionPlanId = "p1" });
|
|
|
|
Assert.IsType<OkObjectResult>(result);
|
|
Assert.Equal("p1", db.Instances.First().SubscriptionPlanId);
|
|
}
|
|
|
|
[Fact]
|
|
public void Updateinstance_ChangingPlan_CopiesPlanQuotasOntoInstance()
|
|
{
|
|
using var db = DbContextFactory.Create();
|
|
db.SubscriptionPlans.Add(new SubscriptionPlan
|
|
{
|
|
Id = "p-premium", Name = "Premium",
|
|
StorageQuotaBytes = 50_000, AiTokensPerMonth = 20_000, HasStats = true, HasAdvancedStats = true
|
|
});
|
|
db.Instances.Add(new Instance { Id = "i1", Name = "Musée", DateCreation = DateTime.UtcNow });
|
|
db.SaveChanges();
|
|
|
|
BuildController(db).Updateinstance(new InstanceDTO { id = "i1", subscriptionPlanId = "p-premium" });
|
|
|
|
// CreateInstance recopiait les quotas du plan, pas Update : un client passé à un plan
|
|
// payant gardait 0 jeton IA, donc pas de guide, tout en payant pour.
|
|
var instance = db.Instances.First();
|
|
Assert.Equal(20_000, instance.AiTokensPerMonth);
|
|
Assert.Equal(50_000, instance.StorageQuotaBytes);
|
|
Assert.True(instance.HasAdvancedStats);
|
|
}
|
|
|
|
[Fact]
|
|
public void Updateinstance_GainingAiQuota_EnqueuesBackfill()
|
|
{
|
|
using var db = DbContextFactory.Create();
|
|
db.SubscriptionPlans.Add(new SubscriptionPlan { Id = "p-premium", Name = "Premium", AiTokensPerMonth = 20_000 });
|
|
db.Instances.Add(new Instance { Id = "i1", Name = "Musée", DateCreation = DateTime.UtcNow, AiTokensPerMonth = 0 });
|
|
db.SaveChanges();
|
|
|
|
var jobs = new Mock<IBackgroundJobClient>();
|
|
BuildController(db, jobs).Updateinstance(new InstanceDTO { id = "i1", subscriptionPlanId = "p-premium" });
|
|
|
|
// Rien n'a été indexé tant que l'instance n'avait pas droit à l'IA : sans ce
|
|
// rattrapage le client paie un guide qui ne connaît rien de son contenu.
|
|
jobs.Verify(j => j.Create(It.IsAny<Hangfire.Common.Job>(), It.IsAny<Hangfire.States.IState>()), Times.Once);
|
|
}
|
|
|
|
[Fact]
|
|
public void Updateinstance_PlanUnchanged_DoesNotEnqueueBackfill()
|
|
{
|
|
using var db = DbContextFactory.Create();
|
|
db.SubscriptionPlans.Add(new SubscriptionPlan { Id = "p-premium", Name = "Premium", AiTokensPerMonth = 20_000 });
|
|
db.Instances.Add(new Instance
|
|
{
|
|
Id = "i1", Name = "Musée", DateCreation = DateTime.UtcNow,
|
|
SubscriptionPlanId = "p-premium", AiTokensPerMonth = 20_000
|
|
});
|
|
db.SaveChanges();
|
|
|
|
var jobs = new Mock<IBackgroundJobClient>();
|
|
BuildController(db, jobs).Updateinstance(new InstanceDTO { id = "i1", name = "Musée d'Ixelles" });
|
|
|
|
jobs.Verify(j => j.Create(It.IsAny<Hangfire.Common.Job>(), It.IsAny<Hangfire.States.IState>()), Times.Never);
|
|
}
|
|
|
|
[Fact]
|
|
public void Updateinstance_UnknownId_Returns404()
|
|
{
|
|
using var db = DbContextFactory.Create();
|
|
|
|
var result = BuildController(db).Updateinstance(new InstanceDTO { id = "unknown", name = "X" });
|
|
|
|
Assert.IsType<NotFoundObjectResult>(result);
|
|
}
|
|
|
|
// ── GET QUOTA ────────────────────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public void GetQuota_UnknownInstance_Returns404()
|
|
{
|
|
using var db = DbContextFactory.Create();
|
|
|
|
var result = BuildController(db).GetQuota("unknown");
|
|
|
|
Assert.IsType<NotFoundObjectResult>(result);
|
|
}
|
|
|
|
[Fact]
|
|
public void GetQuota_NoSubscriptionPlan_ReturnsZeroQuotas()
|
|
{
|
|
using var db = DbContextFactory.Create();
|
|
db.Instances.Add(new Instance { Id = "i1", Name = "Musée", DateCreation = DateTime.UtcNow });
|
|
db.SaveChanges();
|
|
|
|
var result = BuildController(db).GetQuota("i1");
|
|
|
|
var ok = Assert.IsType<OkObjectResult>(result);
|
|
var dto = Assert.IsType<InstanceQuotaDTO>(ok.Value);
|
|
Assert.Equal(0, dto.storageQuotaBytes);
|
|
Assert.Equal(0, dto.aiTokensPerMonth);
|
|
}
|
|
|
|
[Fact]
|
|
public void GetQuota_WithPlan_ReturnsQuotaFromPlan()
|
|
{
|
|
using var db = DbContextFactory.Create();
|
|
db.SubscriptionPlans.Add(new SubscriptionPlan
|
|
{
|
|
Id = "p1", Name = "Standard",
|
|
StorageQuotaBytes = 10_000_000_000L,
|
|
AiTokensPerMonth = 100
|
|
});
|
|
db.Instances.Add(new Instance
|
|
{
|
|
Id = "i1", Name = "Musée", SubscriptionPlanId = "p1", DateCreation = DateTime.UtcNow
|
|
});
|
|
db.SaveChanges();
|
|
|
|
var result = BuildController(db).GetQuota("i1");
|
|
|
|
var ok = Assert.IsType<OkObjectResult>(result);
|
|
var dto = Assert.IsType<InstanceQuotaDTO>(ok.Value);
|
|
Assert.Equal(10_000_000_000L, dto.storageQuotaBytes);
|
|
Assert.Equal(100, dto.aiTokensPerMonth);
|
|
}
|
|
|
|
[Fact]
|
|
public void GetQuota_NoQuestionsYet_FallsBackToPricingAssumption()
|
|
{
|
|
using var db = DbContextFactory.Create();
|
|
db.Instances.Add(new Instance { Id = "i1", Name = "Musée", DateCreation = DateTime.UtcNow });
|
|
db.SaveChanges();
|
|
|
|
var result = BuildController(db).GetQuota("i1");
|
|
|
|
var ok = Assert.IsType<OkObjectResult>(result);
|
|
var dto = Assert.IsType<InstanceQuotaDTO>(ok.Value);
|
|
Assert.Equal(10_000, dto.aiTokensPerQuestion);
|
|
}
|
|
|
|
[Fact]
|
|
public void GetQuota_SmallSample_KeepsFallback()
|
|
{
|
|
using var db = DbContextFactory.Create();
|
|
db.Instances.Add(new Instance { Id = "i1", Name = "Musée", DateCreation = DateTime.UtcNow });
|
|
// 19 questions : sous le seuil, la moyenne réelle (500) est ignorée
|
|
for (var i = 0; i < 19; i++)
|
|
db.VisitorQuestions.Add(NewQuestion("i1", 500));
|
|
db.SaveChanges();
|
|
|
|
var result = BuildController(db).GetQuota("i1");
|
|
|
|
var ok = Assert.IsType<OkObjectResult>(result);
|
|
var dto = Assert.IsType<InstanceQuotaDTO>(ok.Value);
|
|
Assert.Equal(10_000, dto.aiTokensPerQuestion);
|
|
}
|
|
|
|
[Fact]
|
|
public void GetQuota_EnoughQuestions_ReturnsMeasuredAverage()
|
|
{
|
|
using var db = DbContextFactory.Create();
|
|
db.Instances.Add(new Instance { Id = "i1", Name = "Musée", DateCreation = DateTime.UtcNow });
|
|
for (var i = 0; i < 20; i++)
|
|
db.VisitorQuestions.Add(NewQuestion("i1", 500));
|
|
db.SaveChanges();
|
|
|
|
var result = BuildController(db).GetQuota("i1");
|
|
|
|
var ok = Assert.IsType<OkObjectResult>(result);
|
|
var dto = Assert.IsType<InstanceQuotaDTO>(ok.Value);
|
|
Assert.Equal(500, dto.aiTokensPerQuestion);
|
|
}
|
|
|
|
[Fact]
|
|
public void GetQuota_TokensPerQuestion_IgnoresOtherInstances()
|
|
{
|
|
using var db = DbContextFactory.Create();
|
|
db.Instances.Add(new Instance { Id = "i1", Name = "Musée", DateCreation = DateTime.UtcNow });
|
|
for (var i = 0; i < 20; i++)
|
|
db.VisitorQuestions.Add(NewQuestion("i1", 500));
|
|
for (var i = 0; i < 20; i++)
|
|
db.VisitorQuestions.Add(NewQuestion("other", 90_000));
|
|
db.SaveChanges();
|
|
|
|
var result = BuildController(db).GetQuota("i1");
|
|
|
|
var ok = Assert.IsType<OkObjectResult>(result);
|
|
var dto = Assert.IsType<InstanceQuotaDTO>(ok.Value);
|
|
Assert.Equal(500, dto.aiTokensPerQuestion);
|
|
}
|
|
|
|
private static VisitorQuestion NewQuestion(string instanceId, long tokensUsed) => new VisitorQuestion
|
|
{
|
|
ConversationId = Guid.NewGuid().ToString(),
|
|
InstanceId = instanceId,
|
|
Language = "fr",
|
|
Question = "Où sont les toilettes ?",
|
|
Reply = "Au fond à gauche.",
|
|
TokensUsed = tokensUsed,
|
|
HasAnswer = true,
|
|
CreatedAt = DateTime.UtcNow
|
|
};
|
|
|
|
[Fact]
|
|
public void GetQuota_SumsResourceSizeBytes()
|
|
{
|
|
using var db = DbContextFactory.Create();
|
|
db.Instances.Add(new Instance { Id = "i1", Name = "Musée", DateCreation = DateTime.UtcNow });
|
|
db.Resources.AddRange(
|
|
new Resource { Id = "r1", InstanceId = "i1", Label = "img1", Type = ResourceType.Image, SizeBytes = 1000 },
|
|
new Resource { Id = "r2", InstanceId = "i1", Label = "img2", Type = ResourceType.Image, SizeBytes = 2000 },
|
|
new Resource { Id = "r3", InstanceId = "other", Label = "img3", Type = ResourceType.Image, SizeBytes = 9999 }
|
|
);
|
|
db.SaveChanges();
|
|
|
|
var result = BuildController(db).GetQuota("i1");
|
|
|
|
var ok = Assert.IsType<OkObjectResult>(result);
|
|
var dto = Assert.IsType<InstanceQuotaDTO>(ok.Value);
|
|
Assert.Equal(3000, dto.storageUsedBytes);
|
|
}
|
|
|
|
[Fact]
|
|
public void GetQuota_AiUsage_CurrentMonth_ReturnsCount()
|
|
{
|
|
using var db = DbContextFactory.Create();
|
|
var monthKey = DateTime.UtcNow.ToString("yyyy-MM");
|
|
db.Instances.Add(new Instance
|
|
{
|
|
Id = "i1", Name = "Musée", DateCreation = DateTime.UtcNow,
|
|
AiTokensThisMonth = 7, AiUsageMonthKey = monthKey
|
|
});
|
|
db.SaveChanges();
|
|
|
|
var result = BuildController(db).GetQuota("i1");
|
|
|
|
var ok = Assert.IsType<OkObjectResult>(result);
|
|
var dto = Assert.IsType<InstanceQuotaDTO>(ok.Value);
|
|
Assert.Equal(7, dto.aiTokensUsed);
|
|
}
|
|
|
|
[Fact]
|
|
public void GetQuota_AiUsage_PreviousMonth_ReturnsZero()
|
|
{
|
|
using var db = DbContextFactory.Create();
|
|
db.Instances.Add(new Instance
|
|
{
|
|
Id = "i1", Name = "Musée", DateCreation = DateTime.UtcNow,
|
|
AiTokensThisMonth = 7, AiUsageMonthKey = "2020-01"
|
|
});
|
|
db.SaveChanges();
|
|
|
|
var result = BuildController(db).GetQuota("i1");
|
|
|
|
var ok = Assert.IsType<OkObjectResult>(result);
|
|
var dto = Assert.IsType<InstanceQuotaDTO>(ok.Value);
|
|
Assert.Equal(0, dto.aiTokensUsed);
|
|
}
|
|
|
|
// ── DELETE ───────────────────────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public void DeleteInstance_CascadesUsers()
|
|
{
|
|
using var db = DbContextFactory.Create();
|
|
db.Instances.Add(new Instance { Id = "i1", Name = "Musée", DateCreation = DateTime.UtcNow });
|
|
db.Users.AddRange(
|
|
new User { Id = "u1", Email = "a@test.be", Password = "x", LastName = "A", Token = "t1", InstanceId = "i1" },
|
|
new User { Id = "u2", Email = "b@test.be", Password = "x", LastName = "B", Token = "t2", InstanceId = "i1" }
|
|
);
|
|
db.SaveChanges();
|
|
|
|
var result = BuildController(db).DeleteInstance("i1");
|
|
|
|
var status = Assert.IsType<ObjectResult>(result);
|
|
Assert.Equal(202, status.StatusCode);
|
|
Assert.Equal(0, db.Instances.Count());
|
|
Assert.Equal(0, db.Users.Count());
|
|
}
|
|
|
|
[Fact]
|
|
public void DeleteInstance_UnknownId_Returns404()
|
|
{
|
|
using var db = DbContextFactory.Create();
|
|
|
|
var result = BuildController(db).DeleteInstance("unknown");
|
|
|
|
Assert.IsType<NotFoundObjectResult>(result);
|
|
}
|
|
}
|
|
}
|