Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add remove portal endpoint #713

Merged
merged 10 commits into from
Sep 6, 2024
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/pr-713-2027570183.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@

---
"fusion-project-portal": patch
---
Added endpoint to delete a portal
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,19 @@ public Handler(IReadWriteContext context)
public async Task Handle(RemoveOnboardedAppCommand command, CancellationToken cancellationToken)
{
var entity = await _context.Set<OnboardedApp>()
.Include(x => x.Apps)
.FirstOrDefaultAsync(onboardedApp => onboardedApp.AppKey == command.AppKey, cancellationToken);

if (entity == null)
{
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<OnboardedApp>().Remove(entity);

await _context.SaveChangesAsync(cancellationToken);
Expand Down
Original file line number Diff line number Diff line change
@@ -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<RemovePortalCommand>
{
private readonly IReadWriteContext _context;

public Handler(IReadWriteContext context)
{
_context = context;
}

public async Task Handle(RemovePortalCommand command, CancellationToken cancellationToken)
{
var entity = await _context.Set<Portal>()
.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<Portal>().Remove(entity);

await _context.SaveChangesAsync(cancellationToken);

}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ public class OnboardedApp : AuditableEntityBase, ICreationAuditable, IModificati
{
public const int AppKeyLengthMax = 200;
private readonly List<ContextType> _contextTypes = new();
private readonly List<PortalApp> _apps = new();

public OnboardedApp(string appKey)
{
Expand All @@ -22,6 +23,7 @@ public OnboardedApp(string appKey)
public string AppKey { get; set; }

public IReadOnlyCollection<ContextType> ContextTypes => _contextTypes.AsReadOnly();
public IReadOnlyCollection<PortalApp> Apps => _apps.AsReadOnly();

public void AddContextTypes(IList<ContextType> contextTypes)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,10 @@ public async Task<ActionResult> 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");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ public async Task<ActionResult<ApiOnboardedContext>> OnboardedContextByContextId
[ProducesResponseType(typeof(void), StatusCodes.Status404NotFound)]
public async Task<ActionResult<ApiOnboardedContext>> OnboardedContext([FromRoute] string contextExternalId, string type)
{
//TODO: Can be deleted
Noggling marked this conversation as resolved.
Show resolved Hide resolved
var onboardedContext = await Mediator.Send(new GetOnboardedContextByExternalIdContextTypeQuery(contextExternalId, type));

if (onboardedContext == null)
Expand All @@ -53,7 +54,7 @@ public async Task<ActionResult<ApiOnboardedContext>> OnboardedContext([FromRoute

return Ok(new ApiOnboardedContext(onboardedContext));
}

[HttpPost("")]
[Authorize(Policy = Policies.ProjectPortal.Admin)]
[Consumes(MediaTypeNames.Application.Json)]
Expand All @@ -65,6 +66,7 @@ public async Task<ActionResult<ApiOnboardedContext>> OnboardedContext([FromRoute
[ProducesResponseType(typeof(void), StatusCodes.Status400BadRequest)]
public async Task<ActionResult<string>> OnboardContext([FromBody] ApiOnboardContextRequest request)
{
//TODO: Refactor to use contextId instead of externalId
Jossilainen marked this conversation as resolved.
Show resolved Hide resolved
var contextIdentifier = ContextIdentifier.FromExternalId(request.ExternalId);

var context = await ContextResolver.ResolveContextAsync(contextIdentifier, request.Type);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,36 @@ public async Task<ActionResult<Guid>> 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<ActionResult> RemovePortalApp([FromRoute] Guid portalId)
{
var request = new ApiRemovePortalRequest() { Id = portalId };

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")]
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
using Equinor.ProjectExecutionPortal.Application.Commands.Portals.RemovePortal;
using FluentValidation;

namespace Equinor.ProjectExecutionPortal.WebApi.ViewModels.Portal
{
public class ApiRemovePortalRequest
{
public Guid Id { get; set; }
Jossilainen marked this conversation as resolved.
Show resolved Hide resolved

public RemovePortalCommand ToCommand(Guid id)
{
return new RemovePortalCommand(Id);
}

public class RemovePortalRequestValidator : AbstractValidator<ApiRemovePortalRequest>
{
public RemovePortalRequestValidator()
{
RuleFor(x => x.Id)
.NotEmpty()
.WithMessage("Id required");
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ namespace Equinor.ProjectExecutionPortal.WebApi.ViewModels.PortalApp
{
public class ApiPortalApp
{
public ApiPortalApp()
{

}
public ApiPortalApp(PortalAppDto portalAppDto)
{
Key = portalAppDto.OnboardedApp.AppKey;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 string OgpContextId = "ce31b83a-b6cd-4267-89f3-db308edf721e";
Jossilainen marked this conversation as resolved.
Show resolved Hide resolved
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";
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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
Expand All @@ -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);
}
Expand All @@ -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);
Expand All @@ -340,12 +341,55 @@ 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]
[Ignore] // TODO: Need to perform clean up after each test
public async Task Delete_Portal_AsAdministrator_ShouldReturnOk()
{
// Arrange
var portals = await AssertGetAllPortals(UserType.Administrator, HttpStatusCode.OK);
var portalToDelete = portals?.SingleOrDefault(x => x.Key == PortalData.InitialSeedData.Portal1.Key);

// Act
var response = await DeletePortal(portalToDelete!.Id, UserType.Administrator);

// Assert
Assert.AreEqual(HttpStatusCode.OK, response.StatusCode);

// Verify the portal is actually deleted
var deletedPortal = await AssertGetPortal(portalToDelete.Id, UserType.Authenticated, HttpStatusCode.NotFound);
Assert.IsNull(deletedPortal);
}

[TestMethod]
public async Task Delete_PortalWithApps_AsAuthenticatedUser_ShouldReturnForbidden()
{
// Arrange
var portals = await AssertGetAllPortals(UserType.Authenticated, 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.Authenticated, 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.Authenticated, HttpStatusCode.OK);
Assert.IsNotNull(deletedPortal);
}

#region Helpers

private static async Task<IList<ApiPortal>?> AssertGetAllPortals(UserType userType, HttpStatusCode expectedStatusCode)
Expand Down Expand Up @@ -387,7 +431,7 @@ public async Task Get_AppsForPortal_WithValidContext_AsAnonymousUser_ShouldRetur

if (response.StatusCode != HttpStatusCode.OK)
{
return portal;
return null;
}

Assert.IsNotNull(content);
Expand Down Expand Up @@ -453,11 +497,11 @@ private static async Task<HttpResponseMessage> UpdatePortalConfiguration(UserTyp

return response;
}

private static async Task<IList<ApiPortalApp>?> AssertGetAppsForPortal(Guid portalId, string? contextExternalId, string? contextType, UserType userType, HttpStatusCode expectedStatusCode)
private static async Task<IList<ApiPortalApp>?> 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<IList<ApiPortalApp>>(content);

Expand Down Expand Up @@ -504,15 +548,24 @@ private static async Task<HttpResponseMessage> GetPortalConfiguration(Guid porta
return response;
}

private static async Task<HttpResponseMessage> GetAppsForPortal(Guid portalId, string? contextExternalId, string? contextType, UserType userType)
private static async Task<HttpResponseMessage> 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<HttpResponseMessage> 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
}
}
Loading
Loading