From 73b2e3f33eabb3585e1c929a278cb1ee24368f8b Mon Sep 17 00:00:00 2001 From: Jostein Taklo Date: Fri, 6 Sep 2024 14:51:33 +0200 Subject: [PATCH] feat: Add remove portal endpoint (#713) Co-authored-by: Jossilainen --- .changeset/pr-713-2027570183.md | 5 + .../RemoveOnboardedAppCommand.cs | 6 ++ .../RemovePortal/RemovePortalCommand.cs | 50 ++++++++++ .../Entities/OnboardedApp.cs | 2 + .../Controllers/OnboardedAppController.cs | 4 + .../Controllers/OnboardedContextController.cs | 2 +- .../Controllers/PortalController.cs | 30 ++++++ .../Portal/ApiRemovePortalRequest.cs | 13 +++ .../ViewModels/PortalApp/ApiPortalApp.cs | 4 + .../Data/FusionContextData.cs | 3 + .../IntegrationTests/PortalControllerTests.cs | 93 ++++++++++++++++--- .../Misc/ApplicationDbContextExtension.cs | 5 +- .../TestFactory.cs | 6 ++ 13 files changed, 204 insertions(+), 19 deletions(-) create mode 100644 .changeset/pr-713-2027570183.md create mode 100644 backend/src/Equinor.ProjectExecutionPortal.Application/Commands/Portals/RemovePortal/RemovePortalCommand.cs create mode 100644 backend/src/Equinor.ProjectExecutionPortal.WebApi/ViewModels/Portal/ApiRemovePortalRequest.cs diff --git a/.changeset/pr-713-2027570183.md b/.changeset/pr-713-2027570183.md new file mode 100644 index 000000000..b54c8e1ee --- /dev/null +++ b/.changeset/pr-713-2027570183.md @@ -0,0 +1,5 @@ + +--- +"fusion-project-portal": patch +--- +Added endpoint to delete a portal diff --git a/backend/src/Equinor.ProjectExecutionPortal.Application/Commands/OnboardedApps/RemoveOnboardedApp/RemoveOnboardedAppCommand.cs b/backend/src/Equinor.ProjectExecutionPortal.Application/Commands/OnboardedApps/RemoveOnboardedApp/RemoveOnboardedAppCommand.cs index 6c6275675..d7b67d727 100644 --- a/backend/src/Equinor.ProjectExecutionPortal.Application/Commands/OnboardedApps/RemoveOnboardedApp/RemoveOnboardedAppCommand.cs +++ b/backend/src/Equinor.ProjectExecutionPortal.Application/Commands/OnboardedApps/RemoveOnboardedApp/RemoveOnboardedAppCommand.cs @@ -27,6 +27,7 @@ public Handler(IReadWriteContext context) public async Task Handle(RemoveOnboardedAppCommand command, CancellationToken cancellationToken) { var entity = await _context.Set() + .Include(x => x.Apps) .FirstOrDefaultAsync(onboardedApp => onboardedApp.AppKey == command.AppKey, cancellationToken); if (entity == null) @@ -34,6 +35,11 @@ public async Task Handle(RemoveOnboardedAppCommand command, CancellationToken ca throw new NotFoundException(nameof(OnboardedApp), command.AppKey); } + if (entity.Apps.Any()) + { + throw new InvalidOperationException("Cannot remove onboarded app. App is in use in a portal."); + } + _context.Set().Remove(entity); await _context.SaveChangesAsync(cancellationToken); diff --git a/backend/src/Equinor.ProjectExecutionPortal.Application/Commands/Portals/RemovePortal/RemovePortalCommand.cs b/backend/src/Equinor.ProjectExecutionPortal.Application/Commands/Portals/RemovePortal/RemovePortalCommand.cs new file mode 100644 index 000000000..a0c495b8b --- /dev/null +++ b/backend/src/Equinor.ProjectExecutionPortal.Application/Commands/Portals/RemovePortal/RemovePortalCommand.cs @@ -0,0 +1,50 @@ +using Equinor.ProjectExecutionPortal.Domain.Common.Exceptions; +using Equinor.ProjectExecutionPortal.Domain.Entities; +using Equinor.ProjectExecutionPortal.Infrastructure; +using MediatR; +using Microsoft.EntityFrameworkCore; + +namespace Equinor.ProjectExecutionPortal.Application.Commands.Portals.RemovePortal +{ + public class RemovePortalCommand : IRequest + { + public RemovePortalCommand(Guid id) + { + Id = id; + } + + public Guid Id { get; } + + public class Handler : IRequestHandler + { + private readonly IReadWriteContext _context; + + public Handler(IReadWriteContext context) + { + _context = context; + } + + public async Task Handle(RemovePortalCommand command, CancellationToken cancellationToken) + { + var entity = await _context.Set() + .Include(x => x.Apps) + .FirstOrDefaultAsync(portal => portal.Id == command.Id, cancellationToken); + + if (entity == null) + { + throw new NotFoundException(nameof(Portal), command.Id); + } + + if (entity.Apps.Any()) + { + throw new InvalidOperationException("Cannot remove Portal, portal has onboarded apps"); + } + + _context.Set().Remove(entity); + + await _context.SaveChangesAsync(cancellationToken); + + } + } + } +} diff --git a/backend/src/Equinor.ProjectExecutionPortal.Domain/Entities/OnboardedApp.cs b/backend/src/Equinor.ProjectExecutionPortal.Domain/Entities/OnboardedApp.cs index b8df6b70d..67f9c1e04 100644 --- a/backend/src/Equinor.ProjectExecutionPortal.Domain/Entities/OnboardedApp.cs +++ b/backend/src/Equinor.ProjectExecutionPortal.Domain/Entities/OnboardedApp.cs @@ -10,6 +10,7 @@ public class OnboardedApp : AuditableEntityBase, ICreationAuditable, IModificati { public const int AppKeyLengthMax = 200; private readonly List _contextTypes = new(); + private readonly List _apps = new(); public OnboardedApp(string appKey) { @@ -22,6 +23,7 @@ public OnboardedApp(string appKey) public string AppKey { get; set; } public IReadOnlyCollection ContextTypes => _contextTypes.AsReadOnly(); + public IReadOnlyCollection Apps => _apps.AsReadOnly(); public void AddContextTypes(IList contextTypes) { diff --git a/backend/src/Equinor.ProjectExecutionPortal.WebApi/Controllers/OnboardedAppController.cs b/backend/src/Equinor.ProjectExecutionPortal.WebApi/Controllers/OnboardedAppController.cs index 1c317c756..11aa9edd3 100644 --- a/backend/src/Equinor.ProjectExecutionPortal.WebApi/Controllers/OnboardedAppController.cs +++ b/backend/src/Equinor.ProjectExecutionPortal.WebApi/Controllers/OnboardedAppController.cs @@ -122,6 +122,10 @@ public async Task RemoveOnboardedApp([FromRoute] string appKey) { return FusionApiError.NotFound(appKey, ex.Message); } + catch (InvalidOperationException ex) + { + return FusionApiError.InvalidOperation("400", ex.Message); + } catch (Exception) { return FusionApiError.InvalidOperation("500", "An error occurred while removing the onboarded app"); diff --git a/backend/src/Equinor.ProjectExecutionPortal.WebApi/Controllers/OnboardedContextController.cs b/backend/src/Equinor.ProjectExecutionPortal.WebApi/Controllers/OnboardedContextController.cs index 804cdac1d..ad3fada9a 100644 --- a/backend/src/Equinor.ProjectExecutionPortal.WebApi/Controllers/OnboardedContextController.cs +++ b/backend/src/Equinor.ProjectExecutionPortal.WebApi/Controllers/OnboardedContextController.cs @@ -53,7 +53,7 @@ public async Task> OnboardedContext([FromRoute return Ok(new ApiOnboardedContext(onboardedContext)); } - + [HttpPost("")] [Authorize(Policy = Policies.ProjectPortal.Admin)] [Consumes(MediaTypeNames.Application.Json)] diff --git a/backend/src/Equinor.ProjectExecutionPortal.WebApi/Controllers/PortalController.cs b/backend/src/Equinor.ProjectExecutionPortal.WebApi/Controllers/PortalController.cs index b75d416ef..fdf70e41f 100644 --- a/backend/src/Equinor.ProjectExecutionPortal.WebApi/Controllers/PortalController.cs +++ b/backend/src/Equinor.ProjectExecutionPortal.WebApi/Controllers/PortalController.cs @@ -139,6 +139,36 @@ public async Task> UpdatePortalConfiguration([FromRoute] Guid return Ok(); } + [HttpDelete("{portalId:guid}")] + [Authorize(Policy = Policies.ProjectPortal.Admin)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(typeof(void), StatusCodes.Status401Unauthorized)] + [ProducesResponseType(typeof(void), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(void), StatusCodes.Status400BadRequest)] + public async Task RemovePortalApp([FromRoute] Guid portalId) + { + var request = new ApiRemovePortalRequest(); + + try + { + await Mediator.Send(request.ToCommand(portalId)); + } + catch (NotFoundException ex) + { + return FusionApiError.NotFound(portalId, ex.Message); + } + catch (InvalidOperationException ex) + { + return FusionApiError.Forbidden(ex.Message); + } + catch (Exception) + { + return FusionApiError.InvalidOperation("500", "An error occurred while removing portal"); + } + + return Ok(); + } + // Apps [HttpGet("{portalId:guid}/apps")] diff --git a/backend/src/Equinor.ProjectExecutionPortal.WebApi/ViewModels/Portal/ApiRemovePortalRequest.cs b/backend/src/Equinor.ProjectExecutionPortal.WebApi/ViewModels/Portal/ApiRemovePortalRequest.cs new file mode 100644 index 000000000..1562f516f --- /dev/null +++ b/backend/src/Equinor.ProjectExecutionPortal.WebApi/ViewModels/Portal/ApiRemovePortalRequest.cs @@ -0,0 +1,13 @@ +using Equinor.ProjectExecutionPortal.Application.Commands.Portals.RemovePortal; + +namespace Equinor.ProjectExecutionPortal.WebApi.ViewModels.Portal +{ + public class ApiRemovePortalRequest + { + public RemovePortalCommand ToCommand(Guid id) + { + return new RemovePortalCommand(id); + } + + } +} diff --git a/backend/src/Equinor.ProjectExecutionPortal.WebApi/ViewModels/PortalApp/ApiPortalApp.cs b/backend/src/Equinor.ProjectExecutionPortal.WebApi/ViewModels/PortalApp/ApiPortalApp.cs index de1299fef..a47d81fc3 100644 --- a/backend/src/Equinor.ProjectExecutionPortal.WebApi/ViewModels/PortalApp/ApiPortalApp.cs +++ b/backend/src/Equinor.ProjectExecutionPortal.WebApi/ViewModels/PortalApp/ApiPortalApp.cs @@ -6,6 +6,10 @@ namespace Equinor.ProjectExecutionPortal.WebApi.ViewModels.PortalApp { public class ApiPortalApp { + public ApiPortalApp() + { + + } public ApiPortalApp(PortalAppDto portalAppDto) { Key = portalAppDto.OnboardedApp.AppKey; diff --git a/backend/src/tests/Equinor.ProjectExecutionPortal.Tests.WebApi/Data/FusionContextData.cs b/backend/src/tests/Equinor.ProjectExecutionPortal.Tests.WebApi/Data/FusionContextData.cs index 9db7d2084..5efe93381 100644 --- a/backend/src/tests/Equinor.ProjectExecutionPortal.Tests.WebApi/Data/FusionContextData.cs +++ b/backend/src/tests/Equinor.ProjectExecutionPortal.Tests.WebApi/Data/FusionContextData.cs @@ -7,8 +7,11 @@ internal class FusionContextData public class InitialSeedData { public static string JcaContextExternalId = "fc5ffcbc-392f-4d7e-bb14-79a006579337"; + public static Guid JcaContextId = new Guid("94dd5f4d-17f1-4312-bf75-ad75f4d9572c"); public static string OgpContextExternalId = "91dd6653-a364-40c7-af26-7af516d66c42"; + public static Guid OgpContextId = new Guid("ce31b83a-b6cd-4267-89f3-db308edf721e"); public static string InvalidContextExternalId = "11111111-1111-1111-1111-111111111111"; + public static Guid InvalidContextId = new Guid("11111111-1111-1111-1111-111111111111"); public static string ContextType = "ProjectMaster"; } diff --git a/backend/src/tests/Equinor.ProjectExecutionPortal.Tests.WebApi/IntegrationTests/PortalControllerTests.cs b/backend/src/tests/Equinor.ProjectExecutionPortal.Tests.WebApi/IntegrationTests/PortalControllerTests.cs index ba39ab0a3..9ad1edaf2 100644 --- a/backend/src/tests/Equinor.ProjectExecutionPortal.Tests.WebApi/IntegrationTests/PortalControllerTests.cs +++ b/backend/src/tests/Equinor.ProjectExecutionPortal.Tests.WebApi/IntegrationTests/PortalControllerTests.cs @@ -279,27 +279,28 @@ public async Task Get_OnlyGlobalAppsForPortal_WithoutContext_AsAuthenticatedUser var portalToTest = portals?.FirstOrDefault(); // Act - var apps = await AssertGetAppsForPortal(portalToTest!.Id, null, null, UserType.Authenticated, HttpStatusCode.OK); + var apps = await AssertGetAppsForPortal(portalToTest!.Id, null, UserType.Authenticated, HttpStatusCode.OK); // Assert Assert.IsNotNull(apps); Assert.AreEqual(apps.Count, 2); } - [Ignore] // TODO: Need to perform clean up after each test + [Ignore]// TODO: Need to perform clean up after each test [TestMethod] // Limitation: Invalid context not currently tested public async Task Get_BothGlobalAndContextAppsForPortal_WithValidContext_AsAuthenticatedUser_ShouldReturnOk() { // Arrange var portals = await AssertGetAllPortals(UserType.Authenticated, HttpStatusCode.OK); - var portalToTest = portals?.FirstOrDefault(); + var portalToTest = portals?.SingleOrDefault(x => x.Key == PortalData.InitialSeedData.Portal2.Key); // Act - var apps = await AssertGetAppsForPortal(portalToTest!.Id, FusionContextData.InitialSeedData.JcaContextExternalId, FusionContextData.InitialSeedData.ContextType, UserType.Authenticated, HttpStatusCode.OK); + var apps = await AssertGetAppsForPortal(portalToTest!.Id, FusionContextData.InitialSeedData.JcaContextId, UserType.Authenticated, HttpStatusCode.OK); // Assert Assert.IsNotNull(apps); - Assert.AreEqual(apps.Count, 3); + Assert.AreEqual(apps.Count, 6); + } [Ignore] @@ -311,7 +312,7 @@ public async Task Get_BothGlobalAndContextAppsForPortal_WithInvalidContext_AsAut var portalToTest = portals?.FirstOrDefault(); // Act - await AssertGetAppsForPortal(portalToTest!.Id, FusionContextData.InitialSeedData.InvalidContextExternalId, FusionContextData.InitialSeedData.ContextType, UserType.Authenticated, HttpStatusCode.OK); + var apps = await AssertGetAppsForPortal(portalToTest!.Id, FusionContextData.InitialSeedData.InvalidContextId,UserType.Authenticated, HttpStatusCode.OK); // Assert // TODO Fusion 404 returned @@ -321,7 +322,7 @@ public async Task Get_BothGlobalAndContextAppsForPortal_WithInvalidContext_AsAut public async Task Get_AppsForNonExistentPortal_AsAuthenticatedUser_ShouldReturnNotFound() { // Act & Assert - var response = await GetAppsForPortal(Guid.NewGuid(), null, null, UserType.Authenticated); + var response = await GetAppsForPortal(Guid.NewGuid(), null, UserType.Authenticated); Assert.AreEqual(HttpStatusCode.NotFound, response.StatusCode); } @@ -330,7 +331,7 @@ public async Task Get_AppsForNonExistentPortal_AsAuthenticatedUser_ShouldReturnN public async Task Get_AppsForPortal_WithoutContext_AsAnonymousUser_ShouldReturnUnauthorized() { // Act - var apps = await AssertGetAppsForPortal(Guid.NewGuid(), null, null, UserType.Anonymous, HttpStatusCode.Unauthorized); + var apps = await AssertGetAppsForPortal(Guid.NewGuid(), null, UserType.Anonymous, HttpStatusCode.Unauthorized); // Assert Assert.IsNull(apps); @@ -340,12 +341,65 @@ public async Task Get_AppsForPortal_WithoutContext_AsAnonymousUser_ShouldReturnU public async Task Get_AppsForPortal_WithValidContext_AsAnonymousUser_ShouldReturnUnauthorized() { // Act - var apps = await AssertGetAppsForPortal(Guid.NewGuid(), FusionContextData.InitialSeedData.JcaContextExternalId, FusionContextData.InitialSeedData.ContextType, UserType.Anonymous, HttpStatusCode.Unauthorized); + var apps = await AssertGetAppsForPortal(Guid.NewGuid(), FusionContextData.InitialSeedData.JcaContextId, UserType.Anonymous, HttpStatusCode.Unauthorized); // Assert Assert.IsNull(apps); } + [TestMethod] + public async Task Delete_Portal_AsAdministrator_ShouldReturnOk() + { + // Arrange + var payload = new ApiCreatePortalRequest + { + Name = "Portal to be deleted", + Description = "", + ShortName = "Created short name", + Subtext = "Created subtext", + Icon = "Created icon", + ContextTypes = new List { "ProjectMaster" } + }; + + await CreatePortal(UserType.Administrator, payload); + var getAllAfterCreation = await AssertGetAllPortals(UserType.Administrator, HttpStatusCode.OK); + var theOneCreatedToBeDeleted = getAllAfterCreation!.Last(); + + // Act + var response = await DeletePortal(theOneCreatedToBeDeleted!.Id, UserType.Administrator); + + // Assert + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode); + + // Verify the portal is actually deleted + var deletedPortal = await AssertGetPortal(theOneCreatedToBeDeleted.Id, UserType.Authenticated, HttpStatusCode.NotFound); + Assert.IsNull(deletedPortal); + } + + [TestMethod] + public async Task Delete_PortalWithApps_AsAdministrator_ShouldReturnForbidden() + { + // Arrange + var portals = await AssertGetAllPortals(UserType.Administrator, HttpStatusCode.OK); + var portalToDelete = portals?.SingleOrDefault(x => x.Key == PortalData.InitialSeedData.Portal2.Key); + + // Ensure the portal has apps + var apps = await AssertGetAppsForPortal(portalToDelete!.Id, FusionContextData.InitialSeedData.JcaContextId, UserType.Administrator, HttpStatusCode.OK); + + Assert.IsNotNull(apps); + Assert.IsTrue(apps.Count > 0); + + // Act + var response = await DeletePortal(portalToDelete.Id, UserType.Administrator); + + // Assert + Assert.AreEqual(HttpStatusCode.Forbidden, response.StatusCode); + + // Verify the portal is not deleted + var deletedPortal = await AssertGetPortal(portalToDelete.Id, UserType.Administrator, HttpStatusCode.OK); + Assert.IsNotNull(deletedPortal); + } + #region Helpers private static async Task?> AssertGetAllPortals(UserType userType, HttpStatusCode expectedStatusCode) @@ -387,7 +441,7 @@ public async Task Get_AppsForPortal_WithValidContext_AsAnonymousUser_ShouldRetur if (response.StatusCode != HttpStatusCode.OK) { - return portal; + return null; } Assert.IsNotNull(content); @@ -453,11 +507,11 @@ private static async Task UpdatePortalConfiguration(UserTyp return response; } - - private static async Task?> AssertGetAppsForPortal(Guid portalId, string? contextExternalId, string? contextType, UserType userType, HttpStatusCode expectedStatusCode) + + private static async Task?> AssertGetAppsForPortal(Guid portalId, Guid? contextId, UserType userType, HttpStatusCode expectedStatusCode) { // Act - var response = await GetAppsForPortal(portalId, contextExternalId, contextType, userType); + var response = await GetAppsForPortal(portalId, contextId, userType); var content = await response.Content.ReadAsStringAsync(); var apps = JsonConvert.DeserializeObject>(content); @@ -504,15 +558,24 @@ private static async Task GetPortalConfiguration(Guid porta return response; } - private static async Task GetAppsForPortal(Guid portalId, string? contextExternalId, string? contextType, UserType userType) + private static async Task GetAppsForPortal(Guid portalId, Guid? contextId, UserType userType) { - var route = contextExternalId != null ? $"{Route}/{portalId}/contexts/{contextExternalId}/apps" : $"{Route}/{portalId}/apps"; + var route = contextId != null ? $"{Route}/{portalId}/contexts/{contextId}/apps" : $"{Route}/{portalId}/apps"; var client = TestFactory.Instance.GetHttpClient(userType); var response = await client.GetAsync(route); return response; } + private static async Task DeletePortal(Guid portalId, UserType userType) + { + var route = $"{Route}/{portalId}"; + var client = TestFactory.Instance.GetHttpClient(userType); + var response = await client.DeleteAsync(route); + + return response; + } + #endregion Helpers } } diff --git a/backend/src/tests/Equinor.ProjectExecutionPortal.Tests.WebApi/Misc/ApplicationDbContextExtension.cs b/backend/src/tests/Equinor.ProjectExecutionPortal.Tests.WebApi/Misc/ApplicationDbContextExtension.cs index f9ef32bb0..6ae7dbc5d 100644 --- a/backend/src/tests/Equinor.ProjectExecutionPortal.Tests.WebApi/Misc/ApplicationDbContextExtension.cs +++ b/backend/src/tests/Equinor.ProjectExecutionPortal.Tests.WebApi/Misc/ApplicationDbContextExtension.cs @@ -3,7 +3,6 @@ using Equinor.ProjectExecutionPortal.Tests.WebApi.Data; using Equinor.ProjectExecutionPortal.WebApi.Misc; using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Diagnostics; using Microsoft.Extensions.DependencyInjection; namespace Equinor.ProjectExecutionPortal.Tests.WebApi.Misc @@ -68,8 +67,8 @@ private static void SeedPortal(DbContext dbContext) dbContext.AddRange(jcaContext); dbContext.SaveChanges(); - - // Add apps to work surface + + // Add apps to portal var globalMeetingsApp = new PortalApp(meetingsApp.Id, portalWithApps.Id); var globalReviewsApp = new PortalApp(reviewsApp.Id, portalWithApps.Id); diff --git a/backend/src/tests/Equinor.ProjectExecutionPortal.Tests.WebApi/TestFactory.cs b/backend/src/tests/Equinor.ProjectExecutionPortal.Tests.WebApi/TestFactory.cs index 4d6a05b99..ef0c352c5 100644 --- a/backend/src/tests/Equinor.ProjectExecutionPortal.Tests.WebApi/TestFactory.cs +++ b/backend/src/tests/Equinor.ProjectExecutionPortal.Tests.WebApi/TestFactory.cs @@ -209,6 +209,12 @@ private void SetupServiceMock() { return Task.FromResult(FusionContextData.ValidFusionContexts.FirstOrDefault(x => x.ExternalId == contextIdentifier.Identifier)); }); + + _fusionContextResolverMock.Setup(service => service.GetContextAsync(It.IsAny())).Returns((Guid contextId) => + { + return Task.FromResult(FusionContextData.ValidFusionContexts.First(x => x.Id == contextId)); + }); + } private void SetupTestUsers()