diff --git a/eFormAPI/Plugins/ItemsPlanning.Pn/ItemsPlanning.Pn.Test/DeadReportsMenuCleanupTests.cs b/eFormAPI/Plugins/ItemsPlanning.Pn/ItemsPlanning.Pn.Test/DeadReportsMenuCleanupTests.cs new file mode 100644 index 00000000..202a4db9 --- /dev/null +++ b/eFormAPI/Plugins/ItemsPlanning.Pn/ItemsPlanning.Pn.Test/DeadReportsMenuCleanupTests.cs @@ -0,0 +1,249 @@ +/* +The MIT License (MIT) + +Copyright (c) 2007 - 2026 Microting A/S + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ + +namespace ItemsPlanning.Pn.Test; + +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using ItemsPlanning.Pn.Infrastructure.Helpers; +using Microsoft.AspNetCore.Builder; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microting.eFormApi.BasePn.Infrastructure.Models.Application.NavigationMenu; +using Microting.EformAngularFrontendBase.Infrastructure.Data; +using Microting.EformAngularFrontendBase.Infrastructure.Data.Entities.Menu; +using Microting.EformAngularFrontendBase.Infrastructure.Data.Entities.Permissions; +using NSubstitute; +using NUnit.Framework; +using Testcontainers.MariaDb; + +/// +/// Plugin start removes the dead "Reports" menu entry (#2136) from the host's Angular database: the item, +/// its template and their child rows go; a second start changes nothing; other menu entries are untouched. +/// Runs against a real MariaDB, the way the host calls it. +/// +[TestFixture] +public class DeadReportsMenuCleanupTests +{ + private const string PlanningLink = "/plugins/items-planning-pn/plannings"; + + private readonly MariaDbContainer _mariaDbContainer = new MariaDbBuilder("mariadb:11.2") + .WithDatabase("items-planning-dead-reports-menu") + .WithPassword("secretpassword") + .Build(); + + private DbContextOptions _dbContextOptions; + private ServiceProvider _serviceProvider; + private IApplicationBuilder _appBuilder; + + [OneTimeSetUp] + public async Task OneTimeSetUp() + { + await _mariaDbContainer.StartAsync(); + var connectionString = _mariaDbContainer.GetConnectionString(); + + _dbContextOptions = new DbContextOptionsBuilder() + .UseMySql(connectionString, ServerVersion.AutoDetect(connectionString)) + .Options; + // The plugin resolves BaseDbContext from the host's container; register the same options there. + _serviceProvider = new ServiceCollection() + .AddLogging() + .AddScoped(_ => new BaseDbContext(_dbContextOptions)) + .BuildServiceProvider(); + + _appBuilder = Substitute.For(); + _appBuilder.ApplicationServices.Returns(_serviceProvider); + } + + [OneTimeTearDown] + public async Task OneTimeTearDown() + { + await _serviceProvider.DisposeAsync(); + await _mariaDbContainer.DisposeAsync(); + } + + [SetUp] + public async Task SetUp() + { + await using var db = NewDbContext(); + await db.Database.EnsureDeletedAsync(); + await db.Database.EnsureCreatedAsync(); + } + + [Test] + public async Task PluginStart_RemovesTheDeadReportsEntry_AndASecondStartIsANoOp() + { + int planningItemId, planningTemplateId, reportsItemId, detachedItemId; + await using (var db = NewDbContext()) + { + var securityGroup = new SecurityGroup { Name = "Example group" }; + db.SecurityGroups.Add(securityGroup); + await db.SaveChangesAsync(); + + var dropdown = new MenuItem + { + Name = "Dropdown", E2EId = "items-planning-pn", Link = "", Type = MenuItemTypeEnum.Dropdown + }; + db.MenuItems.Add(dropdown); + await db.SaveChangesAsync(); + + var planning = AddLinkWithTemplate(db, dropdown, securityGroup, "Planning", + "items-planning-pn-plannings", PlanningLink); + var reports = AddLinkWithTemplate(db, dropdown, securityGroup, "Reports", + DeadReportsMenuCleaner.E2EId, DeadReportsMenuCleaner.Link); + // An entry someone re-pointed elsewhere but that still hangs on the dead template: it stays, + // detached from the template. + var detached = new MenuItem + { + Name = "Example link", + E2EId = "example-link", + Link = "/example", + Type = MenuItemTypeEnum.Link, + Parent = dropdown, + MenuTemplate = reports.MenuTemplate + }; + db.MenuItems.Add(detached); + await db.SaveChangesAsync(); + + planningItemId = planning.Id; + planningTemplateId = planning.MenuTemplateId!.Value; + reportsItemId = reports.Id; + detachedItemId = detached.Id; + } + + // BaseDbContext seeds its own default menu, templates and permissions, so assert against a snapshot. + var before = await SnapshotAsync(); + + new EformItemsPlanningPlugin().Configure(_appBuilder); + + var after = await SnapshotAsync(); + Assert.That(after, Is.EqualTo(before with + { + MenuItemIds = before.MenuItemIds.Where(x => x != reportsItemId).ToList(), + MenuItemTranslations = before.MenuItemTranslations - 1, + MenuItemSecurityGroups = before.MenuItemSecurityGroups - 1, + MenuTemplates = before.MenuTemplates - 1, + MenuTemplateTranslations = before.MenuTemplateTranslations - 1, + MenuTemplatePermissions = before.MenuTemplatePermissions - 1 + }).Using(SameSnapshot)); + + await using (var db = NewDbContext()) + { + Assert.That(await db.MenuItems.AnyAsync(x => x.Link == DeadReportsMenuCleaner.Link), Is.False); + Assert.That(await db.MenuTemplates.AnyAsync(x => x.DefaultLink == DeadReportsMenuCleaner.Link), + Is.False); + Assert.That((await db.MenuItems.SingleAsync(x => x.Id == planningItemId)).MenuTemplateId, + Is.EqualTo(planningTemplateId)); + Assert.That(await db.MenuItemTranslations.CountAsync(x => x.MenuItemId == planningItemId), + Is.EqualTo(1)); + Assert.That(await db.MenuItemSecurityGroups.CountAsync(x => x.MenuItemId == planningItemId), + Is.EqualTo(1)); + Assert.That((await db.MenuItems.SingleAsync(x => x.Id == detachedItemId)).MenuTemplateId, Is.Null); + } + + new EformItemsPlanningPlugin().Configure(_appBuilder); + + Assert.That(await SnapshotAsync(), Is.EqualTo(after).Using(SameSnapshot), + "a second start changes nothing"); + } + + [Test] + public void PluginStart_WithoutTheAngularDatabase_DoesNotThrow() + { + using var serviceProvider = new ServiceCollection().AddLogging().BuildServiceProvider(); + var appBuilder = Substitute.For(); + appBuilder.ApplicationServices.Returns(serviceProvider); + + Assert.DoesNotThrow(() => new EformItemsPlanningPlugin().Configure(appBuilder)); + } + + private BaseDbContext NewDbContext() => new(_dbContextOptions); + + private record Snapshot( + List MenuItemIds, + int MenuItemTranslations, + int MenuItemSecurityGroups, + int MenuTemplates, + int MenuTemplateTranslations, + int MenuTemplatePermissions, + int SecurityGroups, + int Permissions); + + private static bool SameSnapshot(Snapshot x, Snapshot y) => + x with { MenuItemIds = null } == y with { MenuItemIds = null } + && x.MenuItemIds.SequenceEqual(y.MenuItemIds); + + private async Task SnapshotAsync() + { + await using var db = NewDbContext(); + return new Snapshot( + await db.MenuItems.OrderBy(x => x.Id).Select(x => x.Id).ToListAsync(), + await db.MenuItemTranslations.CountAsync(), + await db.MenuItemSecurityGroups.CountAsync(), + await db.MenuTemplates.CountAsync(), + await db.MenuTemplateTranslations.CountAsync(), + await db.MenuTemplatePermissions.CountAsync(), + await db.SecurityGroups.CountAsync(), + await db.Permissions.CountAsync()); + } + + private static MenuItem AddLinkWithTemplate(BaseDbContext db, MenuItem parent, SecurityGroup securityGroup, + string name, string e2eId, string link) + { + var permissionType = new PermissionType { Name = $"{name} permissions" }; + var template = new MenuTemplate + { + Name = name, + E2EId = e2eId, + DefaultLink = link, + Translations = { new MenuTemplateTranslation { Name = name, LocaleName = "en-US", Language = "English" } }, + MenuTemplatePermissions = + { + new MenuTemplatePermission + { + Permission = new Permission + { + PermissionName = $"Obtain {name}", + ClaimName = $"{e2eId}_claim", + PermissionType = permissionType + } + } + } + }; + var item = new MenuItem + { + Name = name, + E2EId = e2eId, + Link = link, + Type = MenuItemTypeEnum.Link, + Parent = parent, + MenuTemplate = template, + Translations = { new MenuItemTranslation { Name = name, LocaleName = "en-US", Language = "English" } }, + MenuItemSecurityGroups = { new MenuItemSecurityGroup { SecurityGroup = securityGroup } } + }; + db.MenuItems.Add(item); + return item; + } +} diff --git a/eFormAPI/Plugins/ItemsPlanning.Pn/ItemsPlanning.Pn.Test/NavigationMenuTests.cs b/eFormAPI/Plugins/ItemsPlanning.Pn/ItemsPlanning.Pn.Test/NavigationMenuTests.cs new file mode 100644 index 00000000..198dbd54 --- /dev/null +++ b/eFormAPI/Plugins/ItemsPlanning.Pn/ItemsPlanning.Pn.Test/NavigationMenuTests.cs @@ -0,0 +1,45 @@ +using System; +using System.Linq; +using ItemsPlanning.Pn.Infrastructure.Helpers; +using ItemsPlanning.Pn.Services.ItemsPlanningLocalizationService; +using NSubstitute; +using NUnit.Framework; + +namespace ItemsPlanning.Pn.Test +{ + /// + /// The "reports" route was removed from the Angular module, so a menu entry linking to it matches no route + /// and the frontend's catch-all silently redirects to "My eForms" (#2136). The plugin must no longer offer it. + /// + [TestFixture] + public class NavigationMenuTests + { + [Test] + public void GetNavigationMenu_DoesNotOfferTheRemovedReportsPage() + { + var menu = new EformItemsPlanningPlugin().GetNavigationMenu(Substitute.For()); + + var items = menu.Concat(menu.SelectMany(x => x.ChildItems)).ToList(); + + Assert.That(items.Select(x => x.Link), Has.None.EqualTo(DeadReportsMenuCleaner.Link)); + Assert.That(items.Where(x => x.MenuTemplate != null).Select(x => x.MenuTemplate.DefaultLink), + Has.None.EqualTo(DeadReportsMenuCleaner.Link)); + Assert.That(items.Select(x => x.E2EId), Has.None.EqualTo(DeadReportsMenuCleaner.E2EId)); + } + + [Test] + public void HeaderMenu_DoesNotOfferTheRemovedReportsPage() + { + var localizationService = Substitute.For(); + localizationService.GetString(Arg.Any()).Returns(x => x.Arg()); + var serviceProvider = Substitute.For(); + serviceProvider.GetService(typeof(IItemsPlanningLocalizationService)).Returns(localizationService); + + var menu = new EformItemsPlanningPlugin().HeaderMenu(serviceProvider); + + var links = menu.LeftMenu.SelectMany(x => x.MenuItems).Select(x => x.Link).ToList(); + Assert.That(links, Has.None.EqualTo(DeadReportsMenuCleaner.Link)); + Assert.That(links, Does.Contain("/plugins/items-planning-pn/plannings")); + } + } +} diff --git a/eFormAPI/Plugins/ItemsPlanning.Pn/ItemsPlanning.Pn/EformItemsPlanningPlugin.cs b/eFormAPI/Plugins/ItemsPlanning.Pn/ItemsPlanning.Pn/EformItemsPlanningPlugin.cs index fed60a2e..e12789bf 100644 --- a/eFormAPI/Plugins/ItemsPlanning.Pn/ItemsPlanning.Pn/EformItemsPlanningPlugin.cs +++ b/eFormAPI/Plugins/ItemsPlanning.Pn/ItemsPlanning.Pn/EformItemsPlanningPlugin.cs @@ -41,6 +41,7 @@ namespace ItemsPlanning.Pn using System.Reflection; using Infrastructure.Data.Seed; using Infrastructure.Data.Seed.Data; + using Infrastructure.Helpers; using Infrastructure.Models.Settings; using Services.ItemsPlanningTagsService; using Microsoft.AspNetCore.Builder; @@ -49,6 +50,8 @@ namespace ItemsPlanning.Pn using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; + using Microsoft.Extensions.Logging; + using Microting.EformAngularFrontendBase.Infrastructure.Data; using Microting.eFormApi.BasePn; using Microting.eFormApi.BasePn.Infrastructure.Consts; using Microting.eFormApi.BasePn.Infrastructure.Database.Extensions; @@ -197,6 +200,21 @@ public void ConfigureDbContext(IServiceCollection services, string connectionStr public void Configure(IApplicationBuilder appBuilder) { + using var scope = appBuilder.ApplicationServices.CreateScope(); + var logger = scope.ServiceProvider.GetRequiredService>(); + try + { + var baseDbContext = scope.ServiceProvider.GetService(); + if (baseDbContext != null) + { + DeadReportsMenuCleaner.Remove(baseDbContext, logger); + } + } + catch (Exception e) + { + // A failed cleanup leaves a dead menu entry behind; it must never stop the host from starting. + logger.LogError(e, "Could not remove the dead items-planning Reports menu"); + } } public List GetNavigationMenu(IServiceProvider serviceProvider) @@ -317,75 +335,6 @@ public List GetNavigationMenu(IServiceProvider serviceProvi } }, new PluginMenuItemModel - { - Name = "Reports", - E2EId = "items-planning-pn-reports", - Link = "/plugins/items-planning-pn/reports", - Type = MenuItemTypeEnum.Link, - Position = 1, - MenuTemplate = new PluginMenuTemplateModel() - { - Name = "Reports", - E2EId = "items-planning-pn-reports", - DefaultLink = "/plugins/items-planning-pn/reports", - Permissions = new List(), - Translations = new List - { - new PluginMenuTranslationModel - { - LocaleName = LocaleNames.English, - Name = "Reports", - Language = LanguageNames.English - }, - new PluginMenuTranslationModel - { - LocaleName = LocaleNames.German, - Name = "Berichte", - Language = LanguageNames.German - }, - new PluginMenuTranslationModel - { - LocaleName = LocaleNames.Danish, - Name = "Rapporter", - Language = LanguageNames.Danish - }, - new PluginMenuTranslationModel - { - LocaleName = LocaleNames.Ukrainian, - Name = "Звіти", - Language = LanguageNames.Ukrainian - } - } - }, - Translations = new List - { - new PluginMenuTranslationModel - { - LocaleName = LocaleNames.English, - Name = "Reports", - Language = LanguageNames.English - }, - new PluginMenuTranslationModel - { - LocaleName = LocaleNames.German, - Name = "Berichte", - Language = LanguageNames.German - }, - new PluginMenuTranslationModel - { - LocaleName = LocaleNames.Danish, - Name = "Rapporter", - Language = LanguageNames.Danish - }, - new PluginMenuTranslationModel - { - LocaleName = LocaleNames.Ukrainian, - Name = "Звіти", - Language = LanguageNames.Ukrainian - } - } - }, - new PluginMenuItemModel { Name = "Pairing", E2EId = "items-planning-pn-pairing", @@ -482,13 +431,6 @@ public MenuModel HeaderMenu(IServiceProvider serviceProvider) Link = "/plugins/items-planning-pn/plannings", Guards = new List() { ItemsPlanningClaims.GetPlannings }, Position = 0 - }, - new MenuItemModel() - { - Name = localizationService.GetString("Reports"), - E2EId = "items-planning-pn-reports", - Link = "/plugins/items-planning-pn/reports", - Position = 2 } } }); diff --git a/eFormAPI/Plugins/ItemsPlanning.Pn/ItemsPlanning.Pn/Infrastructure/Helpers/DeadReportsMenuCleaner.cs b/eFormAPI/Plugins/ItemsPlanning.Pn/ItemsPlanning.Pn/Infrastructure/Helpers/DeadReportsMenuCleaner.cs new file mode 100644 index 00000000..926529fb --- /dev/null +++ b/eFormAPI/Plugins/ItemsPlanning.Pn/ItemsPlanning.Pn/Infrastructure/Helpers/DeadReportsMenuCleaner.cs @@ -0,0 +1,88 @@ +/* +The MIT License (MIT) + +Copyright (c) 2007 - 2026 Microting A/S + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +*/ + +namespace ItemsPlanning.Pn.Infrastructure.Helpers; + +using System.Linq; +using Microsoft.Extensions.Logging; +using Microting.EformAngularFrontendBase.Infrastructure.Data; + +/// +/// The "reports" page left the Angular module in 2023, but the plugin kept registering a "Reports" menu +/// entry for it. Clicking it matches no route and the frontend's catch-all silently lands on "My eForms" +/// (#2136). The host only ever adds plugin menu entries, so the plugin removes its own dead one on start. +/// Only rows that carry both the dead link and its E2E id are touched; a second run finds nothing. +/// +public static class DeadReportsMenuCleaner +{ + public const string Link = "/plugins/items-planning-pn/reports"; + public const string E2EId = "items-planning-pn-reports"; + + /// The number of menu items plus menu templates removed. + public static int Remove(BaseDbContext dbContext, ILogger logger) + { + var menuItems = dbContext.MenuItems + .Where(x => x.Link == Link && x.E2EId == E2EId) + .ToList(); + var menuTemplates = dbContext.MenuTemplates + .Where(x => x.DefaultLink == Link && x.E2EId == E2EId) + .ToList(); + + if (menuItems.Count == 0 && menuTemplates.Count == 0) + { + return 0; + } + + // Child rows are matched through their parent's link rather than an id list, so the queries need no + // IN (...) translation. + dbContext.MenuItemTranslations.RemoveRange(dbContext.MenuItemTranslations + .Where(x => x.MenuItem.Link == Link && x.MenuItem.E2EId == E2EId)); + dbContext.MenuItemSecurityGroups.RemoveRange(dbContext.MenuItemSecurityGroups + .Where(x => x.MenuItem.Link == Link && x.MenuItem.E2EId == E2EId)); + dbContext.MenuItems.RemoveRange(menuItems); + + // A menu item that still points at a removed template would block the delete; detach it the way + // the host does when it removes a plugin's templates. + foreach (var remaining in dbContext.MenuItems + .Where(x => x.MenuTemplate.DefaultLink == Link && x.MenuTemplate.E2EId == E2EId) + .Where(x => x.Link != Link || x.E2EId != E2EId)) + { + remaining.MenuTemplateId = null; + } + + dbContext.MenuTemplateTranslations.RemoveRange(dbContext.MenuTemplateTranslations + .Where(x => x.MenuTemplate.DefaultLink == Link && x.MenuTemplate.E2EId == E2EId)); + dbContext.MenuTemplatePermissions.RemoveRange(dbContext.MenuTemplatePermissions + .Where(x => x.MenuTemplate.DefaultLink == Link && x.MenuTemplate.E2EId == E2EId)); + dbContext.MenuTemplates.RemoveRange(menuTemplates); + + dbContext.SaveChanges(); + + logger.LogInformation( + "Removed the dead items-planning Reports menu: menu items [{MenuItemIds}], menu templates [{MenuTemplateIds}]", + string.Join(", ", menuItems.Select(x => x.Id)), string.Join(", ", menuTemplates.Select(x => x.Id))); + + return menuItems.Count + menuTemplates.Count; + } +} diff --git a/eFormAPI/Plugins/ItemsPlanning.Pn/ItemsPlanning.Pn/ItemsPlanning.Pn.csproj b/eFormAPI/Plugins/ItemsPlanning.Pn/ItemsPlanning.Pn/ItemsPlanning.Pn.csproj index f09aac69..d553e5eb 100644 --- a/eFormAPI/Plugins/ItemsPlanning.Pn/ItemsPlanning.Pn/ItemsPlanning.Pn.csproj +++ b/eFormAPI/Plugins/ItemsPlanning.Pn/ItemsPlanning.Pn/ItemsPlanning.Pn.csproj @@ -31,6 +31,7 @@ +