From 91556ffcfa8a0d6fc6010bbe61e535bff114813b Mon Sep 17 00:00:00 2001 From: Patrick Hobusch Date: Mon, 10 Aug 2026 11:46:01 +0200 Subject: [PATCH] Prevent and clean up corrupted application links Overriding an application link is a non-atomic delete and recreate. If the recreation failed, the instance was left with a registered application link ID without properties, causing repeated 'Link is corrupted' warnings. Now the original link is restored when the recreation fails, a missing application type module aborts the request before the delete, the OAuth configs are applied to the recreated link instead of the deleted instance, and an unknown UUID returns 404 instead of an NPE. Already corrupted links cannot be retrieved through the applinks API: enumeration skips them, retrieval by ID throws TypeNotInstalledException, and creating a link whose URL-derived ID collides with the remnant fails with 'already exists'. Deletion, however, only requires the ID, so a minimal ApplicationLink implementation carrying just the ID is enough to purge the remnant. Corrupted links are now cleaned up in all three paths: deleting by UUID purges an unretrievable link instead of failing with a 400, creating a link first removes a corrupted remnant registered under the ID derived from the rpc URL, and overriding by UUID purges and recreates the link from the supplied configuration. --- .../DefaultApplicationLinksServiceImpl.java | 258 ++++++++++++++++-- .../DefaultApplicationLinkServiceTest.java | 143 +++++++++- 2 files changed, 375 insertions(+), 26 deletions(-) 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 {