diff --git a/commons/src/main/java/com/deftdevs/bootstrapi/commons/service/DefaultApplicationLinksServiceImpl.java b/commons/src/main/java/com/deftdevs/bootstrapi/commons/service/DefaultApplicationLinksServiceImpl.java index 28a9b7ce..9969c000 100644 --- a/commons/src/main/java/com/deftdevs/bootstrapi/commons/service/DefaultApplicationLinksServiceImpl.java +++ b/commons/src/main/java/com/deftdevs/bootstrapi/commons/service/DefaultApplicationLinksServiceImpl.java @@ -2,8 +2,10 @@ import com.atlassian.applinks.api.ApplicationId; import com.atlassian.applinks.api.ApplicationLink; +import com.atlassian.applinks.api.ApplicationLinkRequestFactory; import com.atlassian.applinks.api.ApplicationType; import com.atlassian.applinks.api.TypeNotInstalledException; +import com.atlassian.applinks.api.auth.AuthenticationProvider; import com.atlassian.applinks.api.application.bamboo.BambooApplicationType; import com.atlassian.applinks.api.application.bitbucket.BitbucketApplicationType; import com.atlassian.applinks.api.application.confluence.ConfluenceApplicationType; @@ -12,10 +14,12 @@ import com.atlassian.applinks.api.application.jira.JiraApplicationType; import com.atlassian.applinks.core.ApplinkStatus; import com.atlassian.applinks.core.ApplinkStatusService; +import com.atlassian.applinks.internal.common.auth.oauth.ApplinksOAuth; import com.atlassian.applinks.internal.common.exception.ConsumerInformationUnavailableException; import com.atlassian.applinks.internal.common.exception.NoAccessException; import com.atlassian.applinks.internal.common.exception.NoSuchApplinkException; import com.atlassian.applinks.internal.common.status.oauth.OAuthConfig; +import com.atlassian.applinks.spi.application.ApplicationIdUtil; import com.atlassian.applinks.spi.link.ApplicationLinkDetails; import com.atlassian.applinks.spi.link.MutableApplicationLink; import com.atlassian.applinks.spi.link.MutatingApplicationLinkService; @@ -31,6 +35,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.net.URI; import java.util.Map; import java.util.UUID; import java.util.function.Function; @@ -46,6 +51,14 @@ public class DefaultApplicationLinksServiceImpl implements ApplicationLinksServi private static final Logger log = LoggerFactory.getLogger(DefaultApplicationLinksServiceImpl.class); + // applinks' DefaultApplicationLinkService throws TypeNotInstalledException with this + // literal as the type when a link's type property is missing entirely ("Link is + // corrupted"), as opposed to the actual type ID of a type that is just not installed; + // there is no public applinks constant for it + private static final String TYPE_ID_UNKNOWN = "unknown"; + + private static final String NOT_FOUND_MESSAGE = "Application link with ID '%s' was not found!"; + private final MutatingApplicationLinkService mutatingApplicationLinkService; private final ApplinkStatusService applinkStatusService; @@ -85,7 +98,7 @@ public ApplicationLinkModel getApplicationLink( final MutableApplicationLink applicationLink = mutatingApplicationLinkService.getApplicationLink(id); if (applicationLink == null) { - throw new NotFoundException(String.format("Application link with ID '%s' was not found!", id)); + throw new NotFoundException(String.format(NOT_FOUND_MESSAGE, id)); } return getApplicationLinkModel(applicationLink); @@ -132,27 +145,69 @@ public ApplicationLinkModel setApplicationLink( final ApplicationId applicationId = new ApplicationId(uuid.toString()); - try { - final MutableApplicationLink applicationLink = mutatingApplicationLinkService.getApplicationLink(applicationId); - final OAuthConfig outgoingOAuthConfig = ApplicationLinkModelUtil.toOAuthConfig(applicationLinkModel.getOutgoingAuthType()); - final OAuthConfig incomingOAuthConfig = ApplicationLinkModelUtil.toOAuthConfig(applicationLinkModel.getIncomingAuthType()); - final ApplicationType applicationType = buildApplicationType(applicationLinkModel.getType()); - final ApplicationLinkDetails applicationLinkDetails = ApplicationLinkModelUtil.toApplicationLinkDetails(applicationLinkModel); + final OAuthConfig outgoingOAuthConfig = ApplicationLinkModelUtil.toOAuthConfig(applicationLinkModel.getOutgoingAuthType()); + final OAuthConfig incomingOAuthConfig = ApplicationLinkModelUtil.toOAuthConfig(applicationLinkModel.getIncomingAuthType()); + final ApplicationType applicationType = buildApplicationType(applicationLinkModel.getType()); + final ApplicationLinkDetails applicationLinkDetails = ApplicationLinkModelUtil.toApplicationLinkDetails(applicationLinkModel); - // entity must be removed first (there is no update method that can change types) - mutatingApplicationLinkService.deleteApplicationLink(applicationLink); + MutableApplicationLink applicationLink = null; + boolean corrupted = false; - // then a new entity is added with the known existing application ID (UUID) - final MutableApplicationLink recreatedApplicationLink = mutatingApplicationLinkService.addApplicationLink(applicationId, applicationType, applicationLinkDetails); + try { + applicationLink = mutatingApplicationLinkService.getApplicationLink(applicationId); + } catch (TypeNotInstalledException e) { + // a valid link whose type module is just not installed must not be purged + if (!isCorrupted(e)) { + throw new BadRequestException(e.getMessage()); + } - // configuring authentication might fail if setup is incorrect or remote app is unavailable - setOutgoingOAuthConfig(applicationLink, outgoingOAuthConfig); - setIncomingOAuthConfig(applicationLink, incomingOAuthConfig, Boolean.TRUE.equals(applicationLinkModel.getIgnoreSetupErrors())); + corrupted = true; + } - return getApplicationLinkModel(recreatedApplicationLink); - } catch (TypeNotInstalledException e) { + // keep the original state so the link can be restored if the recreation below fails + ApplicationType originalApplicationType = null; + ApplicationLinkDetails originalApplicationLinkDetails = null; + OAuthConfig originalOutgoingOAuthConfig = null; + Object originalIncomingConsumerKey = null; + + // entity must be removed first (there is no update method that can change types) + if (applicationLink != null) { + originalApplicationType = applicationLink.getType(); + originalApplicationLinkDetails = ApplicationLinkDetails.builder() + .name(applicationLink.getName()) + .displayUrl(applicationLink.getDisplayUrl()) + .rpcUrl(applicationLink.getRpcUrl()) + .isPrimary(applicationLink.isPrimary()) + .build(); + originalOutgoingOAuthConfig = getOutgoingOAuthConfig(applicationLink); + originalIncomingConsumerKey = applicationLink.getProperty(ApplinksOAuth.PROPERTY_INCOMING_CONSUMER_KEY); + mutatingApplicationLinkService.deleteApplicationLink(applicationLink); + } else if (corrupted) { + log.warn("Removing corrupted application link '{}' before recreating it", applicationId); + deleteCorruptedApplicationLink(applicationId); + } else { + throw new NotFoundException(String.format(NOT_FOUND_MESSAGE, applicationId)); + } + + // then a new entity is added with the known existing application ID (UUID); + // if that fails, restore the original entity, otherwise the instance is left + // with a registered application link ID without properties ("Link is corrupted") + final MutableApplicationLink recreatedApplicationLink; + try { + recreatedApplicationLink = mutatingApplicationLinkService.addApplicationLink(applicationId, applicationType, applicationLinkDetails); + } catch (Exception e) { + if (originalApplicationLinkDetails != null) { + restoreApplicationLink(applicationId, originalApplicationType, originalApplicationLinkDetails, + originalOutgoingOAuthConfig, originalIncomingConsumerKey); + } throw new BadRequestException(e.getMessage()); } + + // configuring authentication might fail if setup is incorrect or remote app is unavailable + setOutgoingOAuthConfig(recreatedApplicationLink, outgoingOAuthConfig); + setIncomingOAuthConfig(recreatedApplicationLink, incomingOAuthConfig, Boolean.TRUE.equals(applicationLinkModel.getIgnoreSetupErrors())); + + return getApplicationLinkModel(recreatedApplicationLink); } @Override @@ -165,14 +220,31 @@ public ApplicationLinkModel addApplicationLink( final ApplicationType applicationType = buildApplicationType(applicationLinkModel.getType()); //check if there is already an application link of supplied type and if yes, remove it - Class appType = applicationType != null ? applicationType.getClass() : null; - ApplicationLink primaryApplicationLink = mutatingApplicationLinkService.getPrimaryApplicationLink(appType); + ApplicationLink primaryApplicationLink = mutatingApplicationLinkService.getPrimaryApplicationLink(applicationType.getClass()); if (primaryApplicationLink != null) { log.info("An existing application link configuration '{}' was found and is removed now before adding the new configuration", primaryApplicationLink.getName()); mutatingApplicationLinkService.deleteApplicationLink(primaryApplicationLink); } + // a corrupted remnant registered under the ID derived from the rpc URL would + // make the creation below fail, so it must be removed first + if (applicationLinkModel.getRpcUrl() != null) { + final ApplicationId generatedApplicationId = ApplicationIdUtil.generate(applicationLinkModel.getRpcUrl()); + + try { + mutatingApplicationLinkService.getApplicationLink(generatedApplicationId); + } catch (TypeNotInstalledException e) { + // a valid link whose type module is just not installed must not be purged; + // the creation below will then fail with a meaningful error message + if (isCorrupted(e)) { + log.warn("Removing corrupted application link '{}' before creating a new link for URL '{}'", + generatedApplicationId, applicationLinkModel.getRpcUrl()); + deleteCorruptedApplicationLink(generatedApplicationId); + } + } + } + //add new application link, this should always work - even if remote app is not accessible ApplicationLink applicationLink; try { @@ -204,29 +276,165 @@ public void deleteApplicationLink(UUID id) { ApplicationId applicationId = new ApplicationId(String.valueOf(id)); try { MutableApplicationLink applicationLink = mutatingApplicationLinkService.getApplicationLink(applicationId); + + if (applicationLink == null) { + throw new NotFoundException(String.format(NOT_FOUND_MESSAGE, applicationId)); + } + mutatingApplicationLinkService.deleteApplicationLink(applicationLink); } catch (TypeNotInstalledException e) { - throw new BadRequestException(e.getMessage()); + // the ID is registered, but the link cannot be retrieved because its type + // property is missing ("Link is corrupted") or its type module is not installed + log.warn("Deleting application link '{}' that cannot be retrieved: {}", applicationId, e.getMessage()); + deleteCorruptedApplicationLink(applicationId); + } + } + + // best-effort restore of a just-deleted application link after its recreation + // failed, including the authentication state managed by this service - properties + // of other authentication providers cannot be restored + private void restoreApplicationLink( + final ApplicationId applicationId, + final ApplicationType originalApplicationType, + final ApplicationLinkDetails originalApplicationLinkDetails, + final OAuthConfig originalOutgoingOAuthConfig, + final Object originalIncomingConsumerKey) { + + try { + final MutableApplicationLink restoredApplicationLink = mutatingApplicationLinkService.addApplicationLink(applicationId, originalApplicationType, originalApplicationLinkDetails); + + if (originalOutgoingOAuthConfig != null) { + setOutgoingOAuthConfig(restoredApplicationLink, originalOutgoingOAuthConfig); + } + + if (originalIncomingConsumerKey != null) { + restoredApplicationLink.putProperty(ApplinksOAuth.PROPERTY_INCOMING_CONSUMER_KEY, originalIncomingConsumerKey); + } + } catch (Exception restoreException) { + log.error("Failed to restore application link '{}' after its recreation failed; the link may be corrupted now", applicationId, restoreException); + } + } + + private static boolean isCorrupted(final TypeNotInstalledException e) { + return TYPE_ID_UNKNOWN.equals(e.getType()); + } + + // a corrupted link cannot be retrieved through the applinks API, but deletion only + // requires the ID, so a minimal link implementation is enough to get it removed + private void deleteCorruptedApplicationLink(ApplicationId applicationId) { + mutatingApplicationLinkService.deleteApplicationLink(new CorruptedApplicationLink(applicationId)); + } + + private static class CorruptedApplicationLink implements ApplicationLink { + + private final ApplicationId id; + + private CorruptedApplicationLink(final ApplicationId id) { + this.id = id; + } + + @Override + public ApplicationId getId() { + return id; + } + + @Override + public ApplicationType getType() { + return null; + } + + @Override + public String getName() { + return null; + } + + @Override + public URI getDisplayUrl() { + return null; + } + + @Override + public URI getRpcUrl() { + return null; + } + + @Override + public boolean isPrimary() { + return false; + } + + @Override + public boolean isSystem() { + return false; + } + + @Override + public Object getProperty(String key) { + return null; + } + + @Override + public Object putProperty(String key, Object value) { + return null; + } + + @Override + public Object removeProperty(String key) { + return null; + } + + @Override + public ApplicationLinkRequestFactory createAuthenticatedRequestFactory() { + return null; + } + + @Override + public ApplicationLinkRequestFactory createAuthenticatedRequestFactory(Class providerClass) { + return null; + } + + @Override + public ApplicationLinkRequestFactory createImpersonatingAuthenticatedRequestFactory() { + return null; + } + + @Override + public ApplicationLinkRequestFactory createNonImpersonatingAuthenticatedRequestFactory() { + return null; } } protected ApplicationType buildApplicationType(ApplicationLinkType linkType) { + final ApplicationType applicationType; + switch (linkType) { case BAMBOO: - return typeAccessor.getApplicationType(BambooApplicationType.class); + applicationType = typeAccessor.getApplicationType(BambooApplicationType.class); + break; case JIRA: - return typeAccessor.getApplicationType(JiraApplicationType.class); + applicationType = typeAccessor.getApplicationType(JiraApplicationType.class); + break; case BITBUCKET: - return typeAccessor.getApplicationType(BitbucketApplicationType.class); + applicationType = typeAccessor.getApplicationType(BitbucketApplicationType.class); + break; case CONFLUENCE: - return typeAccessor.getApplicationType(ConfluenceApplicationType.class); + applicationType = typeAccessor.getApplicationType(ConfluenceApplicationType.class); + break; case FISHEYE: - return typeAccessor.getApplicationType(FishEyeCrucibleApplicationType.class); + applicationType = typeAccessor.getApplicationType(FishEyeCrucibleApplicationType.class); + break; case CROWD: - return typeAccessor.getApplicationType(CrowdApplicationType.class); + applicationType = typeAccessor.getApplicationType(CrowdApplicationType.class); + break; default: throw new NotImplementedException("application type '" + linkType + "' not implemented"); } + + if (applicationType == null) { + throw new BadRequestException(String.format("application type '%s' is not installed", linkType)); + } + + return applicationType; } protected OAuthConfig getOutgoingOAuthConfig( diff --git a/commons/src/test/java/com/deftdevs/bootstrapi/commons/service/DefaultApplicationLinkServiceTest.java b/commons/src/test/java/com/deftdevs/bootstrapi/commons/service/DefaultApplicationLinkServiceTest.java index 8b5c4848..9c779839 100644 --- a/commons/src/test/java/com/deftdevs/bootstrapi/commons/service/DefaultApplicationLinkServiceTest.java +++ b/commons/src/test/java/com/deftdevs/bootstrapi/commons/service/DefaultApplicationLinkServiceTest.java @@ -7,16 +7,18 @@ import com.atlassian.applinks.core.ApplinkStatus; import com.atlassian.applinks.core.ApplinkStatusService; import com.atlassian.applinks.core.DefaultApplinkStatus; +import com.atlassian.applinks.internal.common.exception.ConsumerInformationUnavailableException; import com.atlassian.applinks.internal.common.exception.NoAccessException; import com.atlassian.applinks.internal.common.exception.NoSuchApplinkException; import com.atlassian.applinks.internal.common.status.oauth.OAuthConfig; import com.atlassian.applinks.internal.status.error.SimpleApplinkError; import com.atlassian.applinks.internal.status.oauth.ApplinkOAuthStatus; -import com.atlassian.applinks.internal.common.exception.ConsumerInformationUnavailableException; +import com.atlassian.applinks.spi.application.ApplicationIdUtil; import com.atlassian.applinks.spi.link.ApplicationLinkDetails; import com.atlassian.applinks.spi.link.MutatingApplicationLinkService; import com.atlassian.applinks.spi.util.TypeAccessor; import com.deftdevs.bootstrapi.commons.exception.web.BadRequestException; +import com.deftdevs.bootstrapi.commons.exception.web.NotFoundException; import com.deftdevs.bootstrapi.commons.helper.api.ApplicationLinksAuthConfigHelper; import com.deftdevs.bootstrapi.commons.model.ApplicationLinkModel; import com.deftdevs.bootstrapi.commons.model.ApplicationLinkModel.ApplicationLinkType; @@ -26,6 +28,7 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; @@ -50,7 +53,9 @@ import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; @ExtendWith(MockitoExtension.class) @@ -193,6 +198,142 @@ void testSetApplicationLinkUpdate() assertEquals(applicationLinkModel.getName(), applicationLinkResponse.getName()); } + @Test + void testSetApplicationLinkNotFound() throws TypeNotInstalledException { + doReturn(null).when(mutatingApplicationLinkService).getApplicationLink(any()); + doReturn(new DefaultApplicationType()).when(typeAccessor).getApplicationType(any()); + + assertThrows(NotFoundException.class, () -> { + applicationLinkService.setApplicationLink(UUID.randomUUID(), createApplicationLinkModel()); + }); + } + + @Test + void testSetApplicationLinkRestoresOriginalLinkWhenRecreationFails() throws URISyntaxException, TypeNotInstalledException { + final ApplicationLink applicationLink = createApplicationLink(); + final ApplicationLinkModel applicationLinkModel = createApplicationLinkModel(); + applicationLinkModel.setName("updated-name"); + final OAuthConfig outgoingOAuthConfig = OAuthConfig.createDefaultOAuthConfig(); + doReturn(applicationLink).when(mutatingApplicationLinkService).getApplicationLink(any()); + doReturn(new DefaultApplicationType()).when(typeAccessor).getApplicationType(any()); + doReturn(outgoingOAuthConfig).when(applicationLinksAuthConfigHelper).getOutgoingOAuthConfig(any()); + doThrow(new RuntimeException("recreation failed")).doReturn(applicationLink) + .when(mutatingApplicationLinkService).addApplicationLink(any(), any(), any()); + + assertThrows(BadRequestException.class, () -> { + applicationLinkService.setApplicationLink(UUID.randomUUID(), applicationLinkModel); + }); + + final ArgumentCaptor detailsCaptor = ArgumentCaptor.forClass(ApplicationLinkDetails.class); + verify(mutatingApplicationLinkService, times(2)).addApplicationLink(any(), any(), detailsCaptor.capture()); + assertEquals(applicationLinkModel.getName(), detailsCaptor.getAllValues().get(0).getName()); + assertEquals(applicationLink.getName(), detailsCaptor.getAllValues().get(1).getName()); + + // the restored link must also get its original outgoing auth configuration back + verify(applicationLinksAuthConfigHelper).setOutgoingOAuthConfig(applicationLink, outgoingOAuthConfig); + } + + @Test + void testSetApplicationLinkDoesNotPurgeLinkWithUninstalledType() throws URISyntaxException, TypeNotInstalledException { + final ApplicationLinkModel applicationLinkModel = createApplicationLinkModel(); + doThrow(new TypeNotInstalledException("jira")).when(mutatingApplicationLinkService).getApplicationLink(any()); + doReturn(new DefaultApplicationType()).when(typeAccessor).getApplicationType(any()); + + assertThrows(BadRequestException.class, () -> { + applicationLinkService.setApplicationLink(UUID.randomUUID(), applicationLinkModel); + }); + + verify(mutatingApplicationLinkService, never()).deleteApplicationLink(any()); + } + + @Test + void testAddApplicationLinkDoesNotPurgeLinkWithUninstalledType() throws Exception { + final ApplicationLink applicationLink = createApplicationLink(); + final ApplicationLinkModel applicationLinkModel = createApplicationLinkModel(); + doThrow(new TypeNotInstalledException("jira")).when(mutatingApplicationLinkService).getApplicationLink(any()); + doReturn(new DefaultApplicationType()).when(typeAccessor).getApplicationType(any()); + doReturn(applicationLink).when(mutatingApplicationLinkService).createApplicationLink( + any(ApplicationType.class), any(ApplicationLinkDetails.class)); + doReturn(OAuthConfig.createDisabledConfig()).when(applicationLinksAuthConfigHelper).getOutgoingOAuthConfig(any()); + doReturn(OAuthConfig.createDisabledConfig()).when(applicationLinksAuthConfigHelper).getIncomingOAuthConfig(any()); + doReturn(createApplinkStatus(applicationLink, AVAILABLE)).when(applinkStatusService).getApplinkStatus(any()); + + applicationLinkService.addApplicationLink(applicationLinkModel); + + verify(mutatingApplicationLinkService, never()).deleteApplicationLink(any()); + } + + @Test + void testSetApplicationLinkRecreatesCorruptedLink() + throws URISyntaxException, TypeNotInstalledException, NoAccessException, NoSuchApplinkException { + final ApplicationLink applicationLink = createApplicationLink(); + final ApplicationLinkModel applicationLinkModel = createApplicationLinkModel(); + final UUID uuid = UUID.randomUUID(); + doThrow(new TypeNotInstalledException("unknown")).when(mutatingApplicationLinkService).getApplicationLink(any()); + doReturn(new DefaultApplicationType()).when(typeAccessor).getApplicationType(any()); + doReturn(applicationLink).when(mutatingApplicationLinkService).addApplicationLink(any(), any(), any()); + doReturn(OAuthConfig.createDisabledConfig()).when(applicationLinksAuthConfigHelper).getOutgoingOAuthConfig(any()); + doReturn(OAuthConfig.createDisabledConfig()).when(applicationLinksAuthConfigHelper).getIncomingOAuthConfig(any()); + doReturn(createApplinkStatus(applicationLink, AVAILABLE)).when(applinkStatusService).getApplinkStatus(any()); + + final ApplicationLinkModel applicationLinkResponse = applicationLinkService.setApplicationLink(uuid, applicationLinkModel); + + assertEquals(applicationLinkModel.getName(), applicationLinkResponse.getName()); + final ArgumentCaptor deletedLinkCaptor = ArgumentCaptor.forClass(ApplicationLink.class); + verify(mutatingApplicationLinkService).deleteApplicationLink(deletedLinkCaptor.capture()); + assertEquals(uuid.toString(), deletedLinkCaptor.getValue().getId().get()); + } + + @Test + void testAddApplicationLinkRemovesCorruptedLinkWithSameGeneratedId() throws Exception { + final ApplicationLink applicationLink = createApplicationLink(); + final ApplicationLinkModel applicationLinkModel = createApplicationLinkModel(); + doThrow(new TypeNotInstalledException("unknown")).when(mutatingApplicationLinkService).getApplicationLink(any()); + doReturn(new DefaultApplicationType()).when(typeAccessor).getApplicationType(any()); + doReturn(applicationLink).when(mutatingApplicationLinkService).createApplicationLink( + any(ApplicationType.class), any(ApplicationLinkDetails.class)); + doReturn(OAuthConfig.createDisabledConfig()).when(applicationLinksAuthConfigHelper).getOutgoingOAuthConfig(any()); + doReturn(OAuthConfig.createDisabledConfig()).when(applicationLinksAuthConfigHelper).getIncomingOAuthConfig(any()); + doReturn(createApplinkStatus(applicationLink, AVAILABLE)).when(applinkStatusService).getApplinkStatus(any()); + + final ApplicationLinkModel applicationLinkResponse = applicationLinkService.addApplicationLink(applicationLinkModel); + + assertEquals(applicationLinkModel.getName(), applicationLinkResponse.getName()); + final ArgumentCaptor deletedLinkCaptor = ArgumentCaptor.forClass(ApplicationLink.class); + verify(mutatingApplicationLinkService).deleteApplicationLink(deletedLinkCaptor.capture()); + assertEquals(ApplicationIdUtil.generate(applicationLinkModel.getRpcUrl()), deletedLinkCaptor.getValue().getId()); + } + + @Test + void testDeleteApplicationLinkNotFound() throws TypeNotInstalledException { + doReturn(null).when(mutatingApplicationLinkService).getApplicationLink(any()); + + assertThrows(NotFoundException.class, () -> { + applicationLinkService.deleteApplicationLink(UUID.randomUUID()); + }); + } + + @Test + void testDeleteApplicationLinkCorrupted() throws TypeNotInstalledException { + final UUID uuid = UUID.randomUUID(); + doThrow(new TypeNotInstalledException("unknown")).when(mutatingApplicationLinkService).getApplicationLink(any()); + + applicationLinkService.deleteApplicationLink(uuid); + + final ArgumentCaptor deletedLinkCaptor = ArgumentCaptor.forClass(ApplicationLink.class); + verify(mutatingApplicationLinkService).deleteApplicationLink(deletedLinkCaptor.capture()); + assertEquals(uuid.toString(), deletedLinkCaptor.getValue().getId().get()); + } + + @Test + void testBuildApplicationTypeNotInstalled() { + doReturn(null).when(typeAccessor).getApplicationType(any()); + + assertThrows(BadRequestException.class, () -> { + applicationLinkService.buildApplicationType(CROWD); + }); + } + @Test void testAddApplicationLinkWithoutExistingTargetLink() throws Exception {