From 0695ca3bac708135dd39ff7b629bd43371b930d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Pfeuffer?= Date: Fri, 26 Feb 2021 13:52:29 +0100 Subject: [PATCH] Add import protocol (#1558) Adds a protocol for repository imports (either from an URL, a dump file or a SCM-Manager repository archive). This protocol documents single steps of an import, the time and the user and is accessible via a dedicated REST endpoint or a simple ui. The id of the log is added to the repository imported event, so that plugins like the landingpage or mail can link to these logs. --- gradle/changelog/import_protocol.yaml | 2 + .../scm/repository/RepositoryImportEvent.java | 12 +- .../scm/store/InMemoryBlobStoreFactory.java | 60 +++ scm-ui/ui-api/src/import.ts | 32 ++ scm-ui/ui-api/src/index.ts | 1 + scm-ui/ui-components/src/layout/Page.tsx | 2 +- .../ui-webapp/public/locales/de/commons.json | 3 + .../ui-webapp/public/locales/en/commons.json | 3 + scm-ui/ui-webapp/src/containers/Main.tsx | 2 + .../src/repos/importlog/ImportLog.tsx | 45 +++ .../api/v2/resources/IndexDtoGenerator.java | 1 + .../scm/api/v2/resources/MapperModule.java | 1 + .../resources/RepositoryExportResource.java | 4 +- ...DtoToRepositoryImportParametersMapper.java | 33 ++ .../resources/RepositoryImportResource.java | 143 ++------ .../scm/api/v2/resources/ResourceLinks.java | 4 + .../importexport/EnvironmentCheckStep.java | 1 + .../scm/importexport/FromBundleImporter.java | 138 +++++++ .../scm/importexport/FromUrlImporter.java | 132 +++++++ .../FullScmRepositoryImporter.java | 32 +- .../sonia/scm/importexport/ImportState.java | 13 +- .../scm/importexport/MetadataImportStep.java | 2 + .../importexport/RepositoryImportLogger.java | 131 +++++++ .../RepositoryImportLoggerFactory.java | 93 +++++ .../importexport/RepositoryImportStep.java | 7 +- .../RepositoryTypeSupportChecker.java | 26 +- .../scm/importexport/StoreImportStep.java | 7 +- .../TarArchiveRepositoryStoreImporter.java | 16 +- .../main/resources/locales/de/plugins.json | 4 + .../main/resources/locales/en/plugins.json | 4 + .../v2/resources/IndexDtoGeneratorTest.java | 1 + .../v2/resources/MultiPartRequestBuilder.java | 84 +++++ .../RepositoryImportResourceTest.java | 305 ++++++++++++++++ .../resources/RepositoryRootResourceTest.java | 341 +----------------- .../importexport/FromBundleImporterTest.java | 186 ++++++++++ .../scm/importexport/FromUrlImporterTest.java | 186 ++++++++++ .../FullScmRepositoryImporterTest.java | 86 ++++- .../RepositoryImportLoggerFactoryTest.java | 136 +++++++ ...TarArchiveRepositoryStoreImporterTest.java | 8 +- .../api/v2/import-repo-with-credentials.json | 1 + .../sonia/scm/importexport/importLog.blob | Bin 0 -> 325 bytes 41 files changed, 1808 insertions(+), 480 deletions(-) create mode 100644 gradle/changelog/import_protocol.yaml create mode 100644 scm-test/src/main/java/sonia/scm/store/InMemoryBlobStoreFactory.java create mode 100644 scm-ui/ui-api/src/import.ts create mode 100644 scm-ui/ui-webapp/src/repos/importlog/ImportLog.tsx create mode 100644 scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryImportDtoToRepositoryImportParametersMapper.java create mode 100644 scm-webapp/src/main/java/sonia/scm/importexport/FromBundleImporter.java create mode 100644 scm-webapp/src/main/java/sonia/scm/importexport/FromUrlImporter.java create mode 100644 scm-webapp/src/main/java/sonia/scm/importexport/RepositoryImportLogger.java create mode 100644 scm-webapp/src/main/java/sonia/scm/importexport/RepositoryImportLoggerFactory.java rename scm-webapp/src/main/java/sonia/scm/{api/v2/resources => importexport}/RepositoryTypeSupportChecker.java (77%) create mode 100644 scm-webapp/src/test/java/sonia/scm/api/v2/resources/MultiPartRequestBuilder.java create mode 100644 scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryImportResourceTest.java create mode 100644 scm-webapp/src/test/java/sonia/scm/importexport/FromBundleImporterTest.java create mode 100644 scm-webapp/src/test/java/sonia/scm/importexport/FromUrlImporterTest.java create mode 100644 scm-webapp/src/test/java/sonia/scm/importexport/RepositoryImportLoggerFactoryTest.java create mode 100644 scm-webapp/src/test/resources/sonia/scm/importexport/importLog.blob diff --git a/gradle/changelog/import_protocol.yaml b/gradle/changelog/import_protocol.yaml new file mode 100644 index 0000000000..1d68f0150f --- /dev/null +++ b/gradle/changelog/import_protocol.yaml @@ -0,0 +1,2 @@ +- type: added + description: Added import protocols ([#1558](https://github.com/scm-manager/scm-manager/pull/1558)) diff --git a/scm-core/src/main/java/sonia/scm/repository/RepositoryImportEvent.java b/scm-core/src/main/java/sonia/scm/repository/RepositoryImportEvent.java index a8fefefef0..186418b730 100644 --- a/scm-core/src/main/java/sonia/scm/repository/RepositoryImportEvent.java +++ b/scm-core/src/main/java/sonia/scm/repository/RepositoryImportEvent.java @@ -24,9 +24,7 @@ package sonia.scm.repository; -import lombok.EqualsAndHashCode; import lombok.Getter; -import sonia.scm.HandlerEventType; import sonia.scm.event.Event; /** @@ -36,13 +34,15 @@ import sonia.scm.event.Event; */ @Event @Getter -@EqualsAndHashCode(callSuper = true) -public class RepositoryImportEvent extends RepositoryEvent { +public class RepositoryImportEvent { + private final Repository item; + private final String logId; private final boolean failed; - public RepositoryImportEvent(HandlerEventType eventType, Repository repository, boolean failed) { - super(eventType, repository); + public RepositoryImportEvent(Repository item, boolean failed) { + this.item = item; + this.logId = item.getId(); this.failed = failed; } } diff --git a/scm-test/src/main/java/sonia/scm/store/InMemoryBlobStoreFactory.java b/scm-test/src/main/java/sonia/scm/store/InMemoryBlobStoreFactory.java new file mode 100644 index 0000000000..58ecc31ec0 --- /dev/null +++ b/scm-test/src/main/java/sonia/scm/store/InMemoryBlobStoreFactory.java @@ -0,0 +1,60 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * 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. + */ + +package sonia.scm.store; + +import java.util.HashMap; +import java.util.Map; + +public class InMemoryBlobStoreFactory implements BlobStoreFactory { + + private final Map stores = new HashMap<>(); + + private final BlobStore fixedStore; + + public InMemoryBlobStoreFactory() { + this(null); + } + + public InMemoryBlobStoreFactory(BlobStore fixedStore) { + this.fixedStore = fixedStore; + } + + @Override + public BlobStore getStore(StoreParameters storeParameters) { + if (fixedStore == null) { + return stores.computeIfAbsent(computeKey(storeParameters), key -> new InMemoryBlobStore()); + } else { + return fixedStore; + } + } + + private String computeKey(StoreParameters storeParameters) { + if (storeParameters.getRepositoryId() == null) { + return storeParameters.getName(); + } else { + return storeParameters.getName() + "/" + storeParameters.getRepositoryId(); + } + } +} diff --git a/scm-ui/ui-api/src/import.ts b/scm-ui/ui-api/src/import.ts new file mode 100644 index 0000000000..f9c4e531e9 --- /dev/null +++ b/scm-ui/ui-api/src/import.ts @@ -0,0 +1,32 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * 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. + */ + +import { ApiResult, useRequiredIndexLink } from "./base"; +import { useQuery } from "react-query"; +import { apiClient } from "./apiclient"; + +export const useImportLog = (logId: string) : ApiResult => { + const link = useRequiredIndexLink("importLog").replace("{logId}", logId); + return useQuery(["importLog", logId], () => apiClient.get(link).then(response => response.text())); +} diff --git a/scm-ui/ui-api/src/index.ts b/scm-ui/ui-api/src/index.ts index 5429990ba4..69ede48603 100644 --- a/scm-ui/ui-api/src/index.ts +++ b/scm-ui/ui-api/src/index.ts @@ -43,6 +43,7 @@ export * from "./plugins"; export * from "./repository-roles"; export * from "./permissions"; export * from "./sources"; +export * from "./import"; export { default as ApiProvider } from "./ApiProvider"; export * from "./ApiProvider"; diff --git a/scm-ui/ui-components/src/layout/Page.tsx b/scm-ui/ui-components/src/layout/Page.tsx index 9f1e749912..22f8dcc85f 100644 --- a/scm-ui/ui-components/src/layout/Page.tsx +++ b/scm-ui/ui-components/src/layout/Page.tsx @@ -39,7 +39,7 @@ type Props = { afterTitle?: ReactNode; subtitle?: string; loading?: boolean; - error?: Error; + error?: Error | null; showContentOnError?: boolean; children: ReactNode; }; diff --git a/scm-ui/ui-webapp/public/locales/de/commons.json b/scm-ui/ui-webapp/public/locales/de/commons.json index c0b9cc8222..1728043152 100644 --- a/scm-ui/ui-webapp/public/locales/de/commons.json +++ b/scm-ui/ui-webapp/public/locales/de/commons.json @@ -121,5 +121,8 @@ }, "fileUpload": { "label": "Datei hochladen" + }, + "importLog": { + "title": "Importprotokoll" } } diff --git a/scm-ui/ui-webapp/public/locales/en/commons.json b/scm-ui/ui-webapp/public/locales/en/commons.json index 1f76ce87bd..2b98ab016d 100644 --- a/scm-ui/ui-webapp/public/locales/en/commons.json +++ b/scm-ui/ui-webapp/public/locales/en/commons.json @@ -122,5 +122,8 @@ }, "fileUpload": { "label": "Upload File" + }, + "importLog": { + "title": "Import Log" } } diff --git a/scm-ui/ui-webapp/src/containers/Main.tsx b/scm-ui/ui-webapp/src/containers/Main.tsx index 965610d668..3cffe27d01 100644 --- a/scm-ui/ui-webapp/src/containers/Main.tsx +++ b/scm-ui/ui-webapp/src/containers/Main.tsx @@ -48,6 +48,7 @@ import Admin from "../admin/containers/Admin"; import Profile from "./Profile"; import NamespaceRoot from "../repos/namespaces/containers/NamespaceRoot"; import ImportRepository from "../repos/containers/ImportRepository"; +import ImportLog from "../repos/importlog/ImportLog"; type Props = { me: Me; @@ -96,6 +97,7 @@ class Main extends React.Component { + { + const {logId} = useParams(); + const {isLoading, data, error} = useImportLog(logId); + const [t] = useTranslation("commons"); + + return +
{data ? data : null}
+
; +} + +export default ImportLog; diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/IndexDtoGenerator.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/IndexDtoGenerator.java index 599af7c6ba..6928f1fd08 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/IndexDtoGenerator.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/IndexDtoGenerator.java @@ -116,6 +116,7 @@ public class IndexDtoGenerator extends HalAppenderMapper { builder.single(link("repositoryTypes", resourceLinks.repositoryTypeCollection().self())); builder.single(link("namespaceStrategies", resourceLinks.namespaceStrategies().self())); builder.single(link("repositoryRoles", resourceLinks.repositoryRoleCollection().self())); + builder.single(link("importLog", resourceLinks.repository().importLog("IMPORT_LOG_ID").replace("IMPORT_LOG_ID", "{logId}"))); } else { builder.single(link("login", resourceLinks.authentication().jsonLogin())); } diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MapperModule.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MapperModule.java index 0e3c56944c..06dbcff502 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MapperModule.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MapperModule.java @@ -90,5 +90,6 @@ public class MapperModule extends AbstractModule { bind(ApiKeyToApiKeyDtoMapper.class).to(Mappers.getMapperClass(ApiKeyToApiKeyDtoMapper.class)); bind(RepositoryExportInformationToDtoMapper.class).to(Mappers.getMapperClass(RepositoryExportInformationToDtoMapper.class)); + bind(RepositoryImportDtoToRepositoryImportParametersMapper.class).to(Mappers.getMapperClass(RepositoryImportDtoToRepositoryImportParametersMapper.class)); } } diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryExportResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryExportResource.java index 6485e40393..a8ed757954 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryExportResource.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryExportResource.java @@ -80,8 +80,8 @@ import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import static sonia.scm.ContextEntry.ContextBuilder.entity; -import static sonia.scm.api.v2.resources.RepositoryTypeSupportChecker.checkSupport; -import static sonia.scm.api.v2.resources.RepositoryTypeSupportChecker.type; +import static sonia.scm.importexport.RepositoryTypeSupportChecker.checkSupport; +import static sonia.scm.importexport.RepositoryTypeSupportChecker.type; public class RepositoryExportResource { diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryImportDtoToRepositoryImportParametersMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryImportDtoToRepositoryImportParametersMapper.java new file mode 100644 index 0000000000..3c73d97c9b --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryImportDtoToRepositoryImportParametersMapper.java @@ -0,0 +1,33 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * 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. + */ + +package sonia.scm.api.v2.resources; + +import org.mapstruct.Mapper; +import sonia.scm.importexport.FromUrlImporter; + +@Mapper +public interface RepositoryImportDtoToRepositoryImportParametersMapper { + FromUrlImporter.RepositoryImportParameters map(RepositoryImportResource.RepositoryImportFromUrlDto dto); +} diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryImportResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryImportResource.java index b87142c3a7..c0195c0a8b 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryImportResource.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryImportResource.java @@ -27,10 +27,8 @@ package sonia.scm.api.v2.resources; import com.fasterxml.jackson.core.JsonFactory; import com.fasterxml.jackson.core.JsonParser; import com.fasterxml.jackson.databind.ObjectMapper; -import com.google.common.annotations.VisibleForTesting; import com.google.common.base.Strings; import com.google.common.io.ByteSource; -import com.google.common.io.Files; import com.google.inject.Inject; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.media.Content; @@ -39,30 +37,20 @@ import io.swagger.v3.oas.annotations.responses.ApiResponse; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; -import org.apache.shiro.SecurityUtils; import org.jboss.resteasy.plugins.providers.multipart.InputPart; import org.jboss.resteasy.plugins.providers.multipart.MultipartFormDataInput; import org.jboss.resteasy.plugins.providers.multipart.MultipartInputImpl; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import sonia.scm.ContextEntry; -import sonia.scm.HandlerEventType; -import sonia.scm.Type; -import sonia.scm.event.ScmEventBus; +import sonia.scm.importexport.FromBundleImporter; +import sonia.scm.importexport.FromUrlImporter; import sonia.scm.importexport.FullScmRepositoryImporter; import sonia.scm.importexport.RepositoryImportExportEncryption; -import sonia.scm.repository.InternalRepositoryException; +import sonia.scm.importexport.RepositoryImportLoggerFactory; import sonia.scm.repository.Repository; -import sonia.scm.repository.RepositoryImportEvent; -import sonia.scm.repository.RepositoryManager; -import sonia.scm.repository.RepositoryPermission; -import sonia.scm.repository.RepositoryPermissions; import sonia.scm.repository.api.Command; import sonia.scm.repository.api.ImportFailedException; -import sonia.scm.repository.api.PullCommandBuilder; -import sonia.scm.repository.api.RepositoryService; -import sonia.scm.repository.api.RepositoryServiceFactory; -import sonia.scm.util.IOUtil; import sonia.scm.web.VndMediaType; import sonia.scm.web.api.DtoValidator; @@ -71,55 +59,55 @@ import javax.validation.constraints.NotEmpty; import javax.validation.constraints.Pattern; import javax.ws.rs.Consumes; import javax.ws.rs.DefaultValue; +import javax.ws.rs.GET; import javax.ws.rs.POST; import javax.ws.rs.Path; import javax.ws.rs.PathParam; +import javax.ws.rs.Produces; import javax.ws.rs.QueryParam; import javax.ws.rs.WebApplicationException; import javax.ws.rs.core.Context; import javax.ws.rs.core.MediaType; import javax.ws.rs.core.Response; +import javax.ws.rs.core.StreamingOutput; import javax.ws.rs.core.UriInfo; -import java.io.File; import java.io.IOException; import java.io.InputStream; import java.net.URI; import java.util.List; import java.util.Map; -import java.util.function.Consumer; import static java.nio.charset.StandardCharsets.UTF_8; -import static java.util.Collections.singletonList; -import static sonia.scm.api.v2.resources.RepositoryTypeSupportChecker.checkSupport; -import static sonia.scm.api.v2.resources.RepositoryTypeSupportChecker.type; public class RepositoryImportResource { private static final Logger logger = LoggerFactory.getLogger(RepositoryImportResource.class); - private final RepositoryManager manager; private final RepositoryDtoToRepositoryMapper mapper; - private final RepositoryServiceFactory serviceFactory; private final ResourceLinks resourceLinks; - private final ScmEventBus eventBus; private final FullScmRepositoryImporter fullScmRepositoryImporter; private final RepositoryImportExportEncryption repositoryImportExportEncryption; + private final RepositoryImportDtoToRepositoryImportParametersMapper importParametersMapper; + private final FromUrlImporter fromUrlImporter; + private final FromBundleImporter fromBundleImporter; + private final RepositoryImportLoggerFactory importLoggerFactory; @Inject - public RepositoryImportResource(RepositoryManager manager, - RepositoryDtoToRepositoryMapper mapper, - RepositoryServiceFactory serviceFactory, + public RepositoryImportResource(RepositoryDtoToRepositoryMapper mapper, ResourceLinks resourceLinks, - ScmEventBus eventBus, FullScmRepositoryImporter fullScmRepositoryImporter, - RepositoryImportExportEncryption repositoryImportExportEncryption) { - this.manager = manager; + RepositoryImportDtoToRepositoryImportParametersMapper importParametersMapper, + RepositoryImportExportEncryption repositoryImportExportEncryption, FromUrlImporter fromUrlImporter, + FromBundleImporter fromBundleImporter, + RepositoryImportLoggerFactory importLoggerFactory) { this.mapper = mapper; - this.serviceFactory = serviceFactory; this.resourceLinks = resourceLinks; - this.eventBus = eventBus; this.fullScmRepositoryImporter = fullScmRepositoryImporter; this.repositoryImportExportEncryption = repositoryImportExportEncryption; + this.importParametersMapper = importParametersMapper; + this.fromUrlImporter = fromUrlImporter; + this.fromBundleImporter = fromBundleImporter; + this.importLoggerFactory = importLoggerFactory; } /** @@ -166,49 +154,13 @@ public class RepositoryImportResource { public Response importFromUrl(@Context UriInfo uriInfo, @Pattern(regexp = "\\w{1,10}") @PathParam("type") String type, @Valid RepositoryImportResource.RepositoryImportFromUrlDto request) { - RepositoryPermissions.create().check(); - - Type t = type(manager, type); - if (!t.getName().equals(request.getType())) { + if (!type.equals(request.getType())) { throw new WebApplicationException("type of import url and repository does not match", Response.Status.BAD_REQUEST); } - checkSupport(t, Command.PULL); - logger.info("start {} import for external url {}", type, request.getImportUrl()); + Repository repository = fromUrlImporter.importFromUrl(importParametersMapper.map(request), mapper.map(request)); - Repository repository = mapper.map(request); - repository.setPermissions(singletonList(new RepositoryPermission(SecurityUtils.getSubject().getPrincipal().toString(), "OWNER", false))); - - try { - repository = manager.create( - repository, - pullChangesFromRemoteUrl(request) - ); - eventBus.post(new RepositoryImportEvent(HandlerEventType.MODIFY, repository, false)); - - return Response.created(URI.create(resourceLinks.repository().self(repository.getNamespace(), repository.getName()))).build(); - } catch (Exception e) { - eventBus.post(new RepositoryImportEvent(HandlerEventType.MODIFY, repository, true)); - throw e; - } - } - - @VisibleForTesting - Consumer pullChangesFromRemoteUrl(RepositoryImportFromUrlDto request) { - return repository -> { - try (RepositoryService service = serviceFactory.create(repository)) { - PullCommandBuilder pullCommand = service.getPullCommand(); - if (!Strings.isNullOrEmpty(request.getUsername()) && !Strings.isNullOrEmpty(request.getPassword())) { - pullCommand - .withUsername(request.getUsername()) - .withPassword(request.getPassword()); - } - - pullCommand.pull(request.getImportUrl()); - } catch (IOException e) { - throw new InternalRepositoryException(repository, "Failed to import from remote url", e); - } - }; + return Response.created(URI.create(resourceLinks.repository().self(repository.getNamespace(), repository.getName()))).build(); } /** @@ -253,7 +205,6 @@ public class RepositoryImportResource { @Pattern(regexp = "\\w{1,10}") @PathParam("type") String type, MultipartFormDataInput input, @QueryParam("compressed") @DefaultValue("false") boolean compressed) { - RepositoryPermissions.create().check(); Repository repository = doImportFromBundle(type, input, compressed); return Response.created(URI.create(resourceLinks.repository().self(repository.getNamespace(), repository.getName()))).build(); @@ -303,11 +254,18 @@ public class RepositoryImportResource { public Response importFullRepository(@Context UriInfo uriInfo, @Pattern(regexp = "\\w{1,10}") @PathParam("type") String type, MultipartFormDataInput input) { - RepositoryPermissions.create().check(); Repository createdRepository = importFullRepositoryFromInput(input); return Response.created(URI.create(resourceLinks.repository().self(createdRepository.getNamespace(), createdRepository.getName()))).build(); } + @GET + @Path("log/{logId}") + @Produces(MediaType.TEXT_PLAIN) + public StreamingOutput getImportLog(@PathParam("logId") String logId) throws IOException { + importLoggerFactory.checkCanReadLog(logId); + return out -> importLoggerFactory.getLog(logId, out); + } + private Repository importFullRepositoryFromInput(MultipartFormDataInput input) { Map> formParts = input.getFormDataMap(); InputStream inputStream = extractInputStream(formParts); @@ -332,25 +290,13 @@ public class RepositoryImportResource { inputStream = decryptInputStream(inputStream, repositoryDto.getPassword()); } - Type t = type(manager, type); - checkSupport(t, Command.UNBUNDLE); + if (!type.equals(repositoryDto.getType())) { + throw new WebApplicationException("type of import url and repository does not match", Response.Status.BAD_REQUEST); + } Repository repository = mapper.map(repositoryDto); - repository.setPermissions(singletonList( - new RepositoryPermission(SecurityUtils.getSubject().getPrincipal().toString(), "OWNER", false) - )); - try { - repository = manager.create( - repository, - unbundleImport(inputStream, compressed) - ); - eventBus.post(new RepositoryImportEvent(HandlerEventType.MODIFY, repository, false)); - - } catch (Exception e) { - eventBus.post(new RepositoryImportEvent(HandlerEventType.MODIFY, repository, true)); - throw e; - } + repository = fromBundleImporter.importFromBundle(compressed, inputStream, repository); return repository; } @@ -363,27 +309,6 @@ public class RepositoryImportResource { } } - @VisibleForTesting - Consumer unbundleImport(InputStream inputStream, boolean compressed) { - return repository -> { - File file = null; - try (RepositoryService service = serviceFactory.create(repository)) { - file = File.createTempFile("scm-import-", ".bundle"); - long length = Files.asByteSink(file).writeFrom(inputStream); - logger.info("copied {} bytes to temp, start bundle import", length); - service.getUnbundleCommand().setCompressed(compressed).unbundle(file); - } catch (IOException e) { - throw new InternalRepositoryException(repository, "Failed to import from bundle", e); - } finally { - try { - IOUtil.delete(file); - } catch (IOException ex) { - logger.warn("could not delete temporary file", ex); - } - } - }; - } - private RepositoryImportFromFileDto extractRepositoryDto(Map> formParts) { RepositoryImportFromFileDto repositoryDto = extractFromInputPart(formParts.get("repository"), RepositoryImportFromFileDto.class); checkNotNull(repositoryDto, "repository data is required"); diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ResourceLinks.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ResourceLinks.java index 3115ded1f7..eefb323998 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ResourceLinks.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ResourceLinks.java @@ -377,6 +377,10 @@ class ResourceLinks { return repositoryImportLinkBuilder.method("getRepositoryImportResource").parameters().method("importFullRepository").parameters(type).href(); } + String importLog(String importLogId) { + return repositoryImportLinkBuilder.method("getRepositoryImportResource").parameters().method("getImportLog").parameters(importLogId).href(); + } + String archive(String namespace, String name) { return repositoryLinkBuilder.method("getRepositoryResource").parameters(namespace, name).method("archive").parameters().href(); } diff --git a/scm-webapp/src/main/java/sonia/scm/importexport/EnvironmentCheckStep.java b/scm-webapp/src/main/java/sonia/scm/importexport/EnvironmentCheckStep.java index 6f70bc373c..34638c871d 100644 --- a/scm-webapp/src/main/java/sonia/scm/importexport/EnvironmentCheckStep.java +++ b/scm-webapp/src/main/java/sonia/scm/importexport/EnvironmentCheckStep.java @@ -60,6 +60,7 @@ class EnvironmentCheckStep implements ImportStep { if (!validEnvironment) { throw new IncompatibleEnvironmentForImportException(); } + state.getLogger().step("checked environment"); state.environmentChecked(); return true; } diff --git a/scm-webapp/src/main/java/sonia/scm/importexport/FromBundleImporter.java b/scm-webapp/src/main/java/sonia/scm/importexport/FromBundleImporter.java new file mode 100644 index 0000000000..b006168790 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/importexport/FromBundleImporter.java @@ -0,0 +1,138 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * 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. + */ + +package sonia.scm.importexport; + +import com.google.common.io.Files; +import org.apache.shiro.SecurityUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import sonia.scm.Type; +import sonia.scm.event.ScmEventBus; +import sonia.scm.repository.ImportRepositoryHookEvent; +import sonia.scm.repository.InternalRepositoryException; +import sonia.scm.repository.Repository; +import sonia.scm.repository.RepositoryHookEvent; +import sonia.scm.repository.RepositoryImportEvent; +import sonia.scm.repository.RepositoryManager; +import sonia.scm.repository.RepositoryPermission; +import sonia.scm.repository.RepositoryPermissions; +import sonia.scm.repository.api.Command; +import sonia.scm.repository.api.RepositoryService; +import sonia.scm.repository.api.RepositoryServiceFactory; +import sonia.scm.repository.work.WorkdirProvider; +import sonia.scm.util.IOUtil; + +import javax.inject.Inject; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; + +import static java.util.Collections.singletonList; +import static sonia.scm.importexport.RepositoryImportLogger.ImportType.DUMP; +import static sonia.scm.importexport.RepositoryTypeSupportChecker.checkSupport; +import static sonia.scm.importexport.RepositoryTypeSupportChecker.type; + +public class FromBundleImporter { + + private static final Logger LOG = LoggerFactory.getLogger(FromBundleImporter.class); + + private final RepositoryManager manager; + private final RepositoryServiceFactory serviceFactory; + private final ScmEventBus eventBus; + private final WorkdirProvider workdirProvider; + private final RepositoryImportLoggerFactory loggerFactory; + + @Inject + public FromBundleImporter(RepositoryManager manager, RepositoryServiceFactory serviceFactory, ScmEventBus eventBus, WorkdirProvider workdirProvider, RepositoryImportLoggerFactory loggerFactory) { + this.manager = manager; + this.serviceFactory = serviceFactory; + this.eventBus = eventBus; + this.workdirProvider = workdirProvider; + this.loggerFactory = loggerFactory; + } + + public Repository importFromBundle(boolean compressed, InputStream inputStream, Repository repository) { + RepositoryPermissions.create().check(); + + Type t = type(manager, repository.getType()); + checkSupport(t, Command.UNBUNDLE); + + repository.setPermissions(singletonList( + new RepositoryPermission(SecurityUtils.getSubject().getPrincipal().toString(), "OWNER", false) + )); + + RepositoryImportLogger logger = loggerFactory.createLogger(); + + try { + repository = manager.create(repository, unbundleImport(inputStream, compressed, logger)); + } catch (Exception e) { + logger.failed(e); + eventBus.post(new RepositoryImportEvent(repository, true)); + throw e; + } + + eventBus.post(new RepositoryImportEvent(repository, false)); + return repository; + } + + private Consumer unbundleImport(InputStream inputStream, boolean compressed, RepositoryImportLogger logger) { + return repository -> { + logger.start(DUMP, repository); + File workdir = workdirProvider.createNewWorkdir(repository.getId()); + try (RepositoryService service = serviceFactory.create(repository)) { + logger.step("writing temporary dump file"); + File file = File.createTempFile("scm-import-", ".bundle", workdir); + long length = Files.asByteSink(file).writeFrom(inputStream); + LOG.info("copied {} bytes to temp, start bundle import", length); + logger.step("importing repository data from dump file"); + runUnbundleCommand(compressed, service, file); + logger.finished(); + } catch (IOException e) { + logger.failed(e); + throw new InternalRepositoryException(repository, "Failed to import from bundle", e); + } finally { + try { + IOUtil.delete(workdir); + } catch (IOException ex) { + LOG.warn("could not delete temporary file", ex); + } + } + }; + } + + private void runUnbundleCommand(boolean compressed, RepositoryService service, File file) throws IOException { + AtomicReference eventSink = new AtomicReference<>(); + service.getUnbundleCommand() + .setCompressed(compressed) + .setPostEventSink(eventSink::set) + .unbundle(file); + RepositoryHookEvent repositoryHookEvent = eventSink.get(); + if (repositoryHookEvent != null) { + eventBus.post(new ImportRepositoryHookEvent(repositoryHookEvent)); + } + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/importexport/FromUrlImporter.java b/scm-webapp/src/main/java/sonia/scm/importexport/FromUrlImporter.java new file mode 100644 index 0000000000..649c6c9b9b --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/importexport/FromUrlImporter.java @@ -0,0 +1,132 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * 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. + */ + +package sonia.scm.importexport; + +import com.google.common.base.Strings; +import lombok.Getter; +import lombok.Setter; +import org.apache.shiro.SecurityUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import sonia.scm.AlreadyExistsException; +import sonia.scm.Type; +import sonia.scm.event.ScmEventBus; +import sonia.scm.repository.InternalRepositoryException; +import sonia.scm.repository.Repository; +import sonia.scm.repository.RepositoryImportEvent; +import sonia.scm.repository.RepositoryManager; +import sonia.scm.repository.RepositoryPermission; +import sonia.scm.repository.RepositoryPermissions; +import sonia.scm.repository.api.Command; +import sonia.scm.repository.api.ImportFailedException; +import sonia.scm.repository.api.PullCommandBuilder; +import sonia.scm.repository.api.RepositoryService; +import sonia.scm.repository.api.RepositoryServiceFactory; + +import javax.inject.Inject; +import java.io.IOException; +import java.util.function.Consumer; + +import static java.util.Collections.singletonList; +import static sonia.scm.ContextEntry.ContextBuilder.noContext; +import static sonia.scm.importexport.RepositoryImportLogger.ImportType.URL; +import static sonia.scm.importexport.RepositoryTypeSupportChecker.checkSupport; +import static sonia.scm.importexport.RepositoryTypeSupportChecker.type; + +public class FromUrlImporter { + + private static final Logger LOG = LoggerFactory.getLogger(FromUrlImporter.class); + + private final RepositoryManager manager; + private final RepositoryServiceFactory serviceFactory; + private final ScmEventBus eventBus; + private final RepositoryImportLoggerFactory loggerFactory; + + @Inject + public FromUrlImporter(RepositoryManager manager, RepositoryServiceFactory serviceFactory, ScmEventBus eventBus, RepositoryImportLoggerFactory loggerFactory) { + this.manager = manager; + this.serviceFactory = serviceFactory; + this.eventBus = eventBus; + this.loggerFactory = loggerFactory; + } + + public Repository importFromUrl(RepositoryImportParameters parameters, Repository repository) { + Type t = type(manager, repository.getType()); + RepositoryPermissions.create().check(); + checkSupport(t, Command.PULL); + + LOG.info("start {} import for external url {}", repository.getType(), parameters.getImportUrl()); + + repository.setPermissions(singletonList(new RepositoryPermission(SecurityUtils.getSubject().getPrincipal().toString(), "OWNER", false))); + + RepositoryImportLogger logger = loggerFactory.createLogger(); + Repository createdRepository; + try { + createdRepository = manager.create( + repository, + pullChangesFromRemoteUrl(parameters, logger) + ); + } catch (AlreadyExistsException e) { + throw e; + } catch (Exception e) { + if (logger.started()) { + logger.failed(e); + } + eventBus.post(new RepositoryImportEvent(repository, true)); + throw new ImportFailedException(noContext(), "Could not import repository from url " + parameters.getImportUrl(), e); + } + eventBus.post(new RepositoryImportEvent(createdRepository, false)); + return createdRepository; + } + + private Consumer pullChangesFromRemoteUrl(RepositoryImportParameters parameters, RepositoryImportLogger logger) { + return repository -> { + logger.start(URL, repository); + try (RepositoryService service = serviceFactory.create(repository)) { + PullCommandBuilder pullCommand = service.getPullCommand(); + if (!Strings.isNullOrEmpty(parameters.getUsername()) && !Strings.isNullOrEmpty(parameters.getPassword())) { + logger.step("setting username and password for pull"); + pullCommand + .withUsername(parameters.getUsername()) + .withPassword(parameters.getPassword()); + } + + logger.step("pulling repository from " + parameters.getImportUrl()); + pullCommand.pull(parameters.getImportUrl()); + logger.finished(); + } catch (IOException e) { + throw new InternalRepositoryException(repository, "Failed to import from remote url: " + e.getMessage(), e); + } + }; + } + + @Getter + @Setter + public static class RepositoryImportParameters { + private String importUrl; + private String username; + private String password; + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/importexport/FullScmRepositoryImporter.java b/scm-webapp/src/main/java/sonia/scm/importexport/FullScmRepositoryImporter.java index 0c90aba232..6223b62cb0 100644 --- a/scm-webapp/src/main/java/sonia/scm/importexport/FullScmRepositoryImporter.java +++ b/scm-webapp/src/main/java/sonia/scm/importexport/FullScmRepositoryImporter.java @@ -33,7 +33,9 @@ import org.slf4j.LoggerFactory; import sonia.scm.ContextEntry; import sonia.scm.event.ScmEventBus; import sonia.scm.repository.Repository; +import sonia.scm.repository.RepositoryImportEvent; import sonia.scm.repository.RepositoryManager; +import sonia.scm.repository.RepositoryPermissions; import sonia.scm.repository.api.ImportFailedException; import javax.inject.Inject; @@ -42,8 +44,9 @@ import java.io.IOException; import java.io.InputStream; import static java.util.Arrays.stream; -import static sonia.scm.util.Archives.createTarInputStream; import static sonia.scm.ContextEntry.ContextBuilder.noContext; +import static sonia.scm.importexport.RepositoryImportLogger.ImportType.FULL; +import static sonia.scm.util.Archives.createTarInputStream; public class FullScmRepositoryImporter { @@ -53,6 +56,7 @@ public class FullScmRepositoryImporter { private final RepositoryManager repositoryManager; private final RepositoryImportExportEncryption repositoryImportExportEncryption; private final ScmEventBus eventBus; + private final RepositoryImportLoggerFactory loggerFactory; @Inject public FullScmRepositoryImporter(EnvironmentCheckStep environmentCheckStep, @@ -61,15 +65,17 @@ public class FullScmRepositoryImporter { RepositoryImportStep repositoryImportStep, RepositoryManager repositoryManager, RepositoryImportExportEncryption repositoryImportExportEncryption, - ScmEventBus eventBus - ) { + RepositoryImportLoggerFactory loggerFactory, + ScmEventBus eventBus) { this.repositoryManager = repositoryManager; + this.loggerFactory = loggerFactory; this.repositoryImportExportEncryption = repositoryImportExportEncryption; - importSteps = new ImportStep[]{environmentCheckStep, metadataImportStep, storeImportStep, repositoryImportStep}; this.eventBus = eventBus; + importSteps = new ImportStep[]{environmentCheckStep, metadataImportStep, storeImportStep, repositoryImportStep}; } public Repository importFromStream(Repository repository, InputStream inputStream, String password) { + RepositoryPermissions.create().check(); try { if (inputStream.available() > 0) { try ( @@ -103,8 +109,17 @@ public class FullScmRepositoryImporter { } } + private RepositoryImportLogger startLogger(Repository repository) { + RepositoryImportLogger logger = loggerFactory.createLogger(); + logger.start(FULL, repository); + return logger; + } + private Repository run(Repository repository, TarArchiveInputStream tais) throws IOException { - ImportState state = new ImportState(repositoryManager.create(repository)); + Repository createdRepository = repositoryManager.create(repository); + RepositoryImportLogger logger = startLogger(repository); + ImportState state = new ImportState(createdRepository, logger); + logger.repositoryCreated(state.getRepository()); try { TarArchiveEntry tarArchiveEntry; while ((tarArchiveEntry = tais.getNextTarEntry()) != null) { @@ -112,21 +127,28 @@ public class FullScmRepositoryImporter { handle(tais, state, tarArchiveEntry); } stream(importSteps).forEach(step -> step.finish(state)); + state.getLogger().finished(); return state.getRepository(); + } catch (RuntimeException | IOException e) { + state.getLogger().failed(e); + throw e; } finally { stream(importSteps).forEach(step -> step.cleanup(state)); if (state.success()) { // send all pending events on successful import state.getPendingEvents().forEach(eventBus::post); + eventBus.post(new RepositoryImportEvent(repository, false)); } else { // Delete the repository if any error occurs during the import repositoryManager.delete(state.getRepository()); + eventBus.post(new RepositoryImportEvent(repository, true)); } } } private void handle(TarArchiveInputStream tais, ImportState state, TarArchiveEntry currentEntry) { + state.getLogger().step("inspecting file " + currentEntry.getName()); for (ImportStep step : importSteps) { if (step.handle(currentEntry, state, tais)) { return; diff --git a/scm-webapp/src/main/java/sonia/scm/importexport/ImportState.java b/scm-webapp/src/main/java/sonia/scm/importexport/ImportState.java index d16d220760..b0a80dafbe 100644 --- a/scm-webapp/src/main/java/sonia/scm/importexport/ImportState.java +++ b/scm-webapp/src/main/java/sonia/scm/importexport/ImportState.java @@ -36,6 +36,8 @@ import java.util.Optional; class ImportState { + private final RepositoryImportLogger logger; + private Repository repository; private boolean environmentChecked; @@ -48,11 +50,8 @@ class ImportState { private final List pendingEvents = new ArrayList<>(); - ImportState(Repository repository) { - this.repository = repository; - } - - public void setRepository(Repository repository) { + ImportState(Repository repository, RepositoryImportLogger logger) { + this.logger = logger; this.repository = repository; } @@ -104,6 +103,10 @@ class ImportState { this.pendingEvents.add(event); } + RepositoryImportLogger getLogger() { + return logger; + } + public Collection getPendingEvents() { return Collections.unmodifiableCollection(pendingEvents); } diff --git a/scm-webapp/src/main/java/sonia/scm/importexport/MetadataImportStep.java b/scm-webapp/src/main/java/sonia/scm/importexport/MetadataImportStep.java index 3ef642a420..a096795da4 100644 --- a/scm-webapp/src/main/java/sonia/scm/importexport/MetadataImportStep.java +++ b/scm-webapp/src/main/java/sonia/scm/importexport/MetadataImportStep.java @@ -58,6 +58,7 @@ class MetadataImportStep implements ImportStep { RepositoryMetadataXmlGenerator.RepositoryMetadata metadata = JAXB.unmarshal(new NoneClosingInputStream(inputStream), RepositoryMetadataXmlGenerator.RepositoryMetadata.class); if (metadata != null && metadata.getPermissions() != null) { state.setPermissions(new HashSet<>(metadata.getPermissions())); + state.getLogger().step("reading repository metadata with permissions"); } else { state.setPermissions(Collections.emptySet()); } @@ -69,6 +70,7 @@ class MetadataImportStep implements ImportStep { @Override public void finish(ImportState state) { LOG.trace("Saving permissions for imported repository"); + state.getLogger().step("setting permissions for repository from import"); importRepositoryPermissions(state.getRepository(), state.getRepositoryPermissions()); } diff --git a/scm-webapp/src/main/java/sonia/scm/importexport/RepositoryImportLogger.java b/scm-webapp/src/main/java/sonia/scm/importexport/RepositoryImportLogger.java new file mode 100644 index 0000000000..f78c26cfd7 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/importexport/RepositoryImportLogger.java @@ -0,0 +1,131 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * 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. + */ + +package sonia.scm.importexport; + +import org.apache.shiro.SecurityUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import sonia.scm.repository.Repository; +import sonia.scm.store.Blob; +import sonia.scm.store.BlobStore; +import sonia.scm.user.User; + +import java.io.IOException; +import java.io.OutputStream; +import java.io.PrintWriter; +import java.time.Instant; + +import static java.nio.charset.StandardCharsets.UTF_8; + +class RepositoryImportLogger { + + private static final Logger LOG = LoggerFactory.getLogger(RepositoryImportLogger.class); + + private final BlobStore logStore; + private PrintWriter print; + private Blob blob; + + RepositoryImportLogger(BlobStore logStore) { + this.logStore = logStore; + } + + void start(ImportType importType, Repository repository) { + User user = SecurityUtils.getSubject().getPrincipals().oneByType(User.class); + blob = logStore.create(repository.getId()); + OutputStream outputStream = getBlobOutputStream(); + writeUser(user, outputStream); + print = new PrintWriter(outputStream); + print.printf("Import of repository %s/%s%n", repository.getNamespace(), repository.getName()); + print.printf("Repository type: %s%n", repository.getType()); + print.printf("Imported from: %s%n", importType); + print.printf("Imported by %s (%s)%n", user.getId(), user.getName()); + print.println(); + + addLogEntry("import started"); + } + + private void writeUser(User user, OutputStream outputStream) { + try { + outputStream.write(user.getId().getBytes(UTF_8)); + outputStream.write(0); + } catch (IOException e) { + LOG.warn("Could not write user to import log blob", e); + } + } + + private OutputStream getBlobOutputStream() { + try { + return blob.getOutputStream(); + } catch (IOException e) { + LOG.warn("Could not create logger for import; failed to get output stream from blob", e); + return new OutputStream() { + @Override + public void write(int b) { + // this is a dummy + } + }; + } + } + + public void finished() { + step("import finished successfully"); + writeLog(); + } + + public void failed(Exception e) { + step("import failed (see next log entry)"); + print.println(e.getMessage()); + writeLog(); + } + + public void repositoryCreated(Repository createdRepository) { + step("created repository: " + createdRepository.getNamespaceAndName()); + } + + public void step(String message) { + addLogEntry(message); + } + + private void writeLog() { + print.flush(); + try { + blob.commit(); + } catch (IOException e) { + LOG.warn("Could not commit blob with import log", e); + } + } + + private void addLogEntry(String message) { + print.printf("%s - %s%n", Instant.now(), message); + } + + public boolean started() { + return blob != null; + } + + enum ImportType { + FULL, URL, DUMP + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/importexport/RepositoryImportLoggerFactory.java b/scm-webapp/src/main/java/sonia/scm/importexport/RepositoryImportLoggerFactory.java new file mode 100644 index 0000000000..2113d3e868 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/importexport/RepositoryImportLoggerFactory.java @@ -0,0 +1,93 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * 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. + */ + +package sonia.scm.importexport; + +import org.apache.shiro.SecurityUtils; +import org.apache.shiro.authz.AuthorizationException; +import org.apache.shiro.subject.Subject; +import sonia.scm.NotFoundException; +import sonia.scm.store.BlobStore; +import sonia.scm.store.BlobStoreFactory; +import sonia.scm.util.IOUtil; + +import javax.inject.Inject; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; + +import static java.nio.charset.StandardCharsets.UTF_8; + +public class RepositoryImportLoggerFactory { + + private final BlobStoreFactory blobStoreFactory; + + @Inject + RepositoryImportLoggerFactory(BlobStoreFactory blobStoreFactory) { + this.blobStoreFactory = blobStoreFactory; + } + + RepositoryImportLogger createLogger() { + return new RepositoryImportLogger(blobStoreFactory.withName("imports").build()); + } + + public void checkCanReadLog(String logId) throws IOException { + try (InputStream blob = getBlob(logId)) { + // nothing to read + } + } + + public void getLog(String logId, OutputStream out) throws IOException { + try (InputStream log = getBlob(logId)) { + IOUtil.copy(log, out); + } + } + + private InputStream getBlob(String logId) throws IOException { + BlobStore importStore = blobStoreFactory.withName("imports").build(); + InputStream log = importStore + .getOptional(logId).orElseThrow(() -> new NotFoundException("Log", logId)) + .getInputStream(); + checkPermission(log); + return log; + } + + private void checkPermission(InputStream log) throws IOException { + Subject subject = SecurityUtils.getSubject(); + String logUser = readUserFrom(log); + if (!subject.isPermitted("only:admin:allowed") && !subject.getPrincipal().toString().equals(logUser)) { + throw new AuthorizationException("not permitted"); + } + } + + private String readUserFrom(InputStream log) throws IOException { + ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + int b; + while ((b = log.read()) > 0) { + buffer.write(b); + } + return new String(buffer.toByteArray(), UTF_8); + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/importexport/RepositoryImportStep.java b/scm-webapp/src/main/java/sonia/scm/importexport/RepositoryImportStep.java index 983e397b4e..73d0517716 100644 --- a/scm-webapp/src/main/java/sonia/scm/importexport/RepositoryImportStep.java +++ b/scm-webapp/src/main/java/sonia/scm/importexport/RepositoryImportStep.java @@ -62,12 +62,14 @@ class RepositoryImportStep implements ImportStep { @Override public boolean handle(TarArchiveEntry currentEntry, ImportState state, InputStream inputStream) { - if (!currentEntry.isDirectory()) { + if (!currentEntry.isDirectory() && !currentEntry.getName().contains("/")) { if (state.isStoreImported()) { LOG.trace("Importing directly from tar stream (entry '{}')", currentEntry.getName()); + state.getLogger().step("directly importing repository data"); unbundleRepository(state, inputStream); } else { - LOG.debug("Temporally storing tar entry '{}' in work dir", currentEntry.getName()); + LOG.debug("Temporarily storing tar entry '{}' in work dir", currentEntry.getName()); + state.getLogger().step("temporarily storing repository data for later import"); Path path = saveRepositoryDataFromTarArchiveEntry(state.getRepository(), inputStream); state.setTemporaryRepositoryBundle(path); } @@ -90,6 +92,7 @@ class RepositoryImportStep implements ImportStep { private void importFromTemporaryPath(ImportState state, Path path) { LOG.debug("Importing repository from temporary location in work dir"); + state.getLogger().step("importing repository from temporary location"); try { unbundleRepository(state, Files.newInputStream(path)); } catch (IOException e) { diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryTypeSupportChecker.java b/scm-webapp/src/main/java/sonia/scm/importexport/RepositoryTypeSupportChecker.java similarity index 77% rename from scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryTypeSupportChecker.java rename to scm-webapp/src/main/java/sonia/scm/importexport/RepositoryTypeSupportChecker.java index 11759ea0c8..87f55a46b6 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryTypeSupportChecker.java +++ b/scm-webapp/src/main/java/sonia/scm/importexport/RepositoryTypeSupportChecker.java @@ -22,10 +22,11 @@ * SOFTWARE. */ -package sonia.scm.api.v2.resources; +package sonia.scm.importexport; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import sonia.scm.BadRequestException; import sonia.scm.Type; import sonia.scm.repository.RepositoryHandler; import sonia.scm.repository.RepositoryManager; @@ -36,7 +37,9 @@ import javax.ws.rs.WebApplicationException; import javax.ws.rs.core.Response; import java.util.Set; -class RepositoryTypeSupportChecker { +import static sonia.scm.ContextEntry.ContextBuilder.noContext; + +public class RepositoryTypeSupportChecker { private RepositoryTypeSupportChecker() { } @@ -49,7 +52,7 @@ class RepositoryTypeSupportChecker { * @param type repository type * @param cmd command */ - static void checkSupport(Type type, Command cmd) { + public static void checkSupport(Type type, Command cmd) { if (!(type instanceof RepositoryType)) { logger.warn("type {} is not a repository type", type.getName()); throw new WebApplicationException(Response.Status.BAD_REQUEST); @@ -60,17 +63,28 @@ class RepositoryTypeSupportChecker { logger.warn("type {} does not support this command {}", type.getName(), cmd.name()); - throw new WebApplicationException(Response.Status.BAD_REQUEST); + throw new IllegalTypeForImportException("type does not support command"); } } @SuppressWarnings("javasecurity:S5145") // the type parameter is validated in the resource to only contain valid characters (\w) - static Type type(RepositoryManager manager, String type) { + public static Type type(RepositoryManager manager, String type) { RepositoryHandler handler = manager.getHandler(type); if (handler == null) { logger.warn("no handler for type {} found", type); - throw new WebApplicationException(Response.Status.NOT_FOUND); + throw new IllegalTypeForImportException("unsupported repository type: " + type); } return handler.getType(); } + + private static class IllegalTypeForImportException extends BadRequestException { + public IllegalTypeForImportException(String message) { + super(noContext(), message); + } + + @Override + public String getCode() { + return "CISPvega31"; + } + } } diff --git a/scm-webapp/src/main/java/sonia/scm/importexport/StoreImportStep.java b/scm-webapp/src/main/java/sonia/scm/importexport/StoreImportStep.java index aa53449000..3ba952f325 100644 --- a/scm-webapp/src/main/java/sonia/scm/importexport/StoreImportStep.java +++ b/scm-webapp/src/main/java/sonia/scm/importexport/StoreImportStep.java @@ -52,17 +52,18 @@ class StoreImportStep implements ImportStep { public boolean handle(TarArchiveEntry entry, ImportState state, InputStream inputStream) { if (entry.getName().equals(STORE_DATA_FILE_NAME) && !entry.isDirectory()) { LOG.trace("Importing store from tar"); + state.getLogger().step("importing stores"); // Inside the repository tar archive stream is another tar archive. // The nested tar archive is wrapped in another TarArchiveInputStream inside the storeImporter - importStores(state.getRepository(), inputStream); + importStores(state.getRepository(), inputStream, state.getLogger()); state.storeImported(); return true; } return false; } - private void importStores(Repository repository, InputStream inputStream) { - storeImporter.importFromTarArchive(repository, inputStream); + private void importStores(Repository repository, InputStream inputStream, RepositoryImportLogger logger) { + storeImporter.importFromTarArchive(repository, inputStream, logger); updateEngine.update(repository.getId()); } } diff --git a/scm-webapp/src/main/java/sonia/scm/importexport/TarArchiveRepositoryStoreImporter.java b/scm-webapp/src/main/java/sonia/scm/importexport/TarArchiveRepositoryStoreImporter.java index 6a6bd702c4..a953f43603 100644 --- a/scm-webapp/src/main/java/sonia/scm/importexport/TarArchiveRepositoryStoreImporter.java +++ b/scm-webapp/src/main/java/sonia/scm/importexport/TarArchiveRepositoryStoreImporter.java @@ -47,36 +47,40 @@ public class TarArchiveRepositoryStoreImporter { this.repositoryStoreImporter = repositoryStoreImporter; } - public void importFromTarArchive(Repository repository, InputStream inputStream) { + public void importFromTarArchive(Repository repository, InputStream inputStream, RepositoryImportLogger logger) { try (TarArchiveInputStream tais = new NoneClosingTarArchiveInputStream(inputStream)) { ArchiveEntry entry = tais.getNextEntry(); while (entry != null) { String[] entryPathParts = entry.getName().split(File.separator); validateStorePath(repository, entryPathParts); - importStoreByType(repository, tais, entryPathParts); + importStoreByType(repository, tais, entryPathParts, logger); entry = tais.getNextEntry(); } } catch (IOException e) { - throw new ImportFailedException(ContextEntry.ContextBuilder.entity(repository).build(), "Could not import stores from metadata file.", e); + throw new ImportFailedException(ContextEntry.ContextBuilder.entity(repository).build(), "Could not import stores from metadata file.", e); } } - private void importStoreByType(Repository repository, TarArchiveInputStream tais, String[] entryPathParts) { + private void importStoreByType(Repository repository, TarArchiveInputStream tais, String[] entryPathParts, RepositoryImportLogger logger) { String storeType = entryPathParts[1]; + String storeName = entryPathParts[2]; if (isDataStore(storeType)) { + logger.step("importing data store entry for store " + storeName); repositoryStoreImporter .doImport(repository) .importStore(new StoreEntryMetaData(StoreType.DATA, entryPathParts[2])) .importEntry(entryPathParts[3], tais); } else if (isConfigStore(storeType)){ + logger.step("importing data store entry for store " + storeName); repositoryStoreImporter .doImport(repository) .importStore(new StoreEntryMetaData(StoreType.CONFIG, "")) - .importEntry(entryPathParts[2], tais); + .importEntry(storeName, tais); } else if(isBlobStore(storeType)) { + logger.step("importing blob store entry for store " + storeName); repositoryStoreImporter .doImport(repository) - .importStore(new StoreEntryMetaData(StoreType.BLOB, entryPathParts[2])) + .importStore(new StoreEntryMetaData(StoreType.BLOB, storeName)) .importEntry(entryPathParts[3], tais); } } diff --git a/scm-webapp/src/main/resources/locales/de/plugins.json b/scm-webapp/src/main/resources/locales/de/plugins.json index 57b446d24f..b9c8adfe8b 100644 --- a/scm-webapp/src/main/resources/locales/de/plugins.json +++ b/scm-webapp/src/main/resources/locales/de/plugins.json @@ -338,6 +338,10 @@ "5GSO9ZkzX1": { "displayName": "Inkompatible Umgebung", "description": "Die Version dieses SCM-Managers oder eines der installierten Plugins ist zu alt für den Import des Dumps. Bitte installieren Sie die neuesten Versionen. Nähere Informationen finden sich im Log." + }, + "CISPvega31": { + "displayName": "Ungültiger Repository-Typ für Import", + "description": "Der Import ist für den gegebenen Repository-Typen nicht möglich." } }, "namespaceStrategies": { diff --git a/scm-webapp/src/main/resources/locales/en/plugins.json b/scm-webapp/src/main/resources/locales/en/plugins.json index a3a2d7ea51..6a2487d09c 100644 --- a/scm-webapp/src/main/resources/locales/en/plugins.json +++ b/scm-webapp/src/main/resources/locales/en/plugins.json @@ -338,6 +338,10 @@ "8YR7aawFW1": { "displayName": "Wrong current password", "description": "The current password is wrong. Please try again." + }, + "CISPvega31": { + "displayName": "Illegal repository type for import", + "description": "The import is not possible for the given repository type." } }, "namespaceStrategies": { diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/IndexDtoGeneratorTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/IndexDtoGeneratorTest.java index 94c78f798b..118188dbfc 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/IndexDtoGeneratorTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/IndexDtoGeneratorTest.java @@ -134,5 +134,6 @@ class IndexDtoGeneratorTest { when(resourceLinks.namespaceStrategies()).thenReturn(new ResourceLinks.NamespaceStrategiesLinks(scmPathInfo)); when(resourceLinks.namespaceCollection()).thenReturn(new ResourceLinks.NamespaceCollectionLinks(scmPathInfo)); when(resourceLinks.me()).thenReturn(new ResourceLinks.MeLinks(scmPathInfo, new ResourceLinks.UserLinks(scmPathInfo))); + when(resourceLinks.repository()).thenReturn(new ResourceLinks.RepositoryLinks(scmPathInfo)); } } diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/MultiPartRequestBuilder.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/MultiPartRequestBuilder.java new file mode 100644 index 0000000000..07cc2b896a --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/MultiPartRequestBuilder.java @@ -0,0 +1,84 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * 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. + */ + +package sonia.scm.api.v2.resources; + +import com.fasterxml.jackson.core.JsonFactory; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.jboss.resteasy.mock.MockHttpRequest; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStreamWriter; +import java.io.StringWriter; +import java.util.Map; +import java.util.UUID; + +class MultiPartRequestBuilder { + + /** + * This method is a slightly adapted copy of Lin Zaho's gist at https://gist.github.com/lin-zhao/9985191 + */ + static void multipartRequest(MockHttpRequest request, Map files, RepositoryDto repository) throws IOException { + String boundary = UUID.randomUUID().toString(); + request.contentType("multipart/form-data; boundary=" + boundary); + + //Make sure this is deleted in afterTest() + ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + try (OutputStreamWriter formWriter = new OutputStreamWriter(buffer)) { + formWriter.append("--").append(boundary); + + for (Map.Entry entry : files.entrySet()) { + formWriter.append("\n"); + formWriter.append(String.format("Content-Disposition: form-data; name=\"%s\"; filename=\"%s\"", + entry.getKey(), entry.getKey())).append("\n"); + formWriter.append("Content-Type: application/octet-stream").append("\n\n"); + + InputStream stream = entry.getValue(); + int b = stream.read(); + while (b >= 0) { + formWriter.write(b); + b = stream.read(); + } + stream.close(); + formWriter.append("\n").append("--").append(boundary); + } + + if (repository != null) { + formWriter.append("\n"); + formWriter.append("Content-Disposition: form-data; name=\"repository\"").append("\n\n"); + StringWriter repositoryWriter = new StringWriter(); + new JsonFactory().createGenerator(repositoryWriter).setCodec(new ObjectMapper()).writeObject(repository); + formWriter.append(repositoryWriter.getBuffer().toString()).append("\n"); + formWriter.append("--").append(boundary); + } + + formWriter.append("--"); + formWriter.flush(); + } + request.setInputStream(new ByteArrayInputStream(buffer.toByteArray())); + } +} diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryImportResourceTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryImportResourceTest.java new file mode 100644 index 0000000000..6959738f1e --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryImportResourceTest.java @@ -0,0 +1,305 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * 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. + */ + +package sonia.scm.api.v2.resources; + +import com.google.common.io.Resources; +import org.jboss.resteasy.mock.MockHttpRequest; +import org.jboss.resteasy.mock.MockHttpResponse; +import org.junit.Before; +import org.junit.Test; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; +import sonia.scm.api.v2.resources.RepositoryImportResource.RepositoryImportFromFileDto; +import sonia.scm.importexport.FromBundleImporter; +import sonia.scm.importexport.FromUrlImporter; +import sonia.scm.importexport.FullScmRepositoryImporter; +import sonia.scm.importexport.RepositoryImportExportEncryption; +import sonia.scm.importexport.RepositoryImportLoggerFactory; +import sonia.scm.repository.Repository; +import sonia.scm.repository.RepositoryTestData; +import sonia.scm.web.RestDispatcher; +import sonia.scm.web.VndMediaType; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.URI; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.Collections; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static java.util.Collections.singletonMap; +import static javax.servlet.http.HttpServletResponse.SC_CREATED; +import static javax.servlet.http.HttpServletResponse.SC_OK; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@SuppressWarnings("UnstableApiUsage") +@RunWith(MockitoJUnitRunner.class) +public class RepositoryImportResourceTest extends RepositoryTestBase { + + private final RestDispatcher dispatcher = new RestDispatcher(); + + @Mock + private FullScmRepositoryImporter fullScmRepositoryImporter; + @Mock + private FromUrlImporter fromUrlImporter; + @Mock + private FromBundleImporter fromBundleImporter; + @Mock + private RepositoryImportLoggerFactory importLoggerFactory; + @Mock + private RepositoryImportExportEncryption repositoryImportExportEncryption; + + @Captor + private ArgumentCaptor parametersCaptor; + @Captor + private ArgumentCaptor repositoryCaptor; + + private final URI baseUri = URI.create("/"); + private final ResourceLinks resourceLinks = ResourceLinksMock.createMock(baseUri); + + @InjectMocks + private RepositoryDtoToRepositoryMapperImpl dtoToRepositoryMapper; + + private final MockHttpResponse response = new MockHttpResponse(); + + @Before + public void prepareEnvironment() { + super.repositoryImportResource = new RepositoryImportResource(dtoToRepositoryMapper, resourceLinks, fullScmRepositoryImporter, new RepositoryImportDtoToRepositoryImportParametersMapperImpl(), repositoryImportExportEncryption, fromUrlImporter, fromBundleImporter, importLoggerFactory); + dispatcher.addSingletonResource(getRepositoryRootResource()); + } + + @Test + public void shouldImportRepositoryFromUrl() throws Exception { + when(fromUrlImporter.importFromUrl(parametersCaptor.capture(), repositoryCaptor.capture())) + .thenReturn(RepositoryTestData.createHeartOfGold()); + + URL url = Resources.getResource("sonia/scm/api/v2/import-repo.json"); + byte[] importRequest = Resources.toByteArray(url); + + MockHttpRequest request = MockHttpRequest + .post("/" + RepositoryRootResource.REPOSITORIES_PATH_V2 + "import/git/url") + .contentType(VndMediaType.REPOSITORY) + .content(importRequest); + + dispatcher.invoke(request, response); + + assertThat(response.getStatus()).isEqualTo(SC_CREATED); + assertThat(response.getOutputHeaders().get("Location")).asString().contains("/v2/repositories/hitchhiker/HeartOfGold"); + + assertThat(parametersCaptor.getValue().getImportUrl()).isEqualTo("https://scm-manager-org/scm/repo/secret/puzzle42"); + assertThat(parametersCaptor.getValue().getUsername()).isNull(); + assertThat(parametersCaptor.getValue().getPassword()).isNull(); + + assertThat(repositoryCaptor.getValue().getName()).isEqualTo("HeartOfGold"); + assertThat(repositoryCaptor.getValue().getNamespace()).isEqualTo("hitchhiker"); + } + + @Test + public void shouldImportRepositoryFromUrlWithCredentials() throws Exception { + when(fromUrlImporter.importFromUrl(parametersCaptor.capture(), repositoryCaptor.capture())) + .thenReturn(RepositoryTestData.createHeartOfGold()); + + URL url = Resources.getResource("sonia/scm/api/v2/import-repo-with-credentials.json"); + byte[] importRequest = Resources.toByteArray(url); + + MockHttpRequest request = MockHttpRequest + .post("/" + RepositoryRootResource.REPOSITORIES_PATH_V2 + "import/git/url") + .contentType(VndMediaType.REPOSITORY) + .content(importRequest); + + dispatcher.invoke(request, response); + + assertThat(response.getStatus()).isEqualTo(SC_CREATED); + + assertThat(parametersCaptor.getValue().getUsername()).isEqualTo("trillian"); + assertThat(parametersCaptor.getValue().getPassword()).isEqualTo("secret"); + } + + @Test + public void shouldFailOnImportFromUrlWithDifferentTypes() throws Exception { + URL url = Resources.getResource("sonia/scm/api/v2/import-repo.json"); + byte[] importRequest = Resources.toByteArray(url); + + MockHttpRequest request = MockHttpRequest + .post("/" + RepositoryRootResource.REPOSITORIES_PATH_V2 + "import/svn/url") + .contentType(VndMediaType.REPOSITORY) + .content(importRequest); + + dispatcher.invoke(request, response); + + assertThat(response.getStatus()).isNotEqualTo(SC_CREATED); + + verify(fromUrlImporter, never()).importFromUrl(any(), any()); + } + + @Nested + class WithCorrectBundle { + + @BeforeEach + void mockImporter() { + when( + fromBundleImporter.importFromBundle( + eq(false), + argThat(argument -> streamHasContent(argument, "svn-dump")), + argThat(repository -> repository.getName().equals("HeartOfGold")) + ) + ).thenReturn(RepositoryTestData.createHeartOfGold()); + } + + @Test + public void shouldImportRepositoryFromBundle() throws Exception { + RepositoryImportFromFileDto importDto = createBasicImportDto(); + + MockHttpRequest request = MockHttpRequest + .post("/" + RepositoryRootResource.REPOSITORIES_PATH_V2 + "import/svn/bundle"); + + MultiPartRequestBuilder.multipartRequest(request, singletonMap("bundle", new ByteArrayInputStream("svn-dump".getBytes(UTF_8))), importDto); + + dispatcher.invoke(request, response); + + assertThat(response.getStatus()).isEqualTo(SC_CREATED); + assertThat(response.getOutputHeaders().get("Location")).asString().contains("/v2/repositories/hitchhiker/HeartOfGold"); + verify(repositoryImportExportEncryption, never()).decrypt(any(), any()); + } + + @Test + public void shouldImportRepositoryFromEncryptedBundle() throws Exception { + when(repositoryImportExportEncryption.decrypt(any(), eq("hgt2g"))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + RepositoryImportFromFileDto importDto = createBasicImportDto(); + importDto.setPassword("hgt2g"); + + MockHttpRequest request = MockHttpRequest + .post("/" + RepositoryRootResource.REPOSITORIES_PATH_V2 + "import/svn/bundle"); + + MultiPartRequestBuilder.multipartRequest(request, singletonMap("bundle", new ByteArrayInputStream("svn-dump".getBytes(UTF_8))), importDto); + + dispatcher.invoke(request, response); + + assertThat(response.getStatus()).isEqualTo(SC_CREATED); + assertThat(response.getOutputHeaders().get("Location")).asString().contains("/v2/repositories/hitchhiker/HeartOfGold"); + } + + private RepositoryImportFromFileDto createBasicImportDto() { + RepositoryImportFromFileDto importDto = new RepositoryImportFromFileDto(); + importDto.setName("HeartOfGold"); + importDto.setNamespace("hitchhiker"); + importDto.setType("svn"); + return importDto; + } + } + + @Test + public void shouldFailOnImportFromBundleWithDifferentTypes() throws Exception { + RepositoryDto repositoryDto = new RepositoryDto(); + repositoryDto.setName("HeartOfGold"); + repositoryDto.setNamespace("hitchhiker"); + repositoryDto.setType("svn"); + + MockHttpRequest request = MockHttpRequest + .post("/" + RepositoryRootResource.REPOSITORIES_PATH_V2 + "import/git/bundle"); + + MultiPartRequestBuilder.multipartRequest(request, Collections.singletonMap("bundle", new ByteArrayInputStream("svn-dump".getBytes(StandardCharsets.UTF_8))), repositoryDto); + + dispatcher.invoke(request, response); + + assertThat(response.getStatus()).isNotEqualTo(SC_CREATED); + verify(fromBundleImporter, never()).importFromBundle(any(Boolean.class), any(InputStream.class), any(Repository.class)); + } + + @Test + public void shouldImportFullRepository() throws Exception { + when( + fullScmRepositoryImporter.importFromStream( + argThat(repository -> repository.getName().equals("HeartOfGold")), + argThat(argument -> streamHasContent(argument, "svn-dump")), + isNull() + ) + ).thenReturn(RepositoryTestData.createHeartOfGold()); + + RepositoryDto repositoryDto = new RepositoryDto(); + repositoryDto.setName("HeartOfGold"); + repositoryDto.setNamespace("hitchhiker"); + repositoryDto.setType("svn"); + + MockHttpRequest request = MockHttpRequest + .post("/" + RepositoryRootResource.REPOSITORIES_PATH_V2 + "import/svn/full"); + + MultiPartRequestBuilder.multipartRequest(request, singletonMap("bundle", new ByteArrayInputStream("svn-dump".getBytes(UTF_8))), repositoryDto); + + dispatcher.invoke(request, response); + + assertThat(response.getStatus()).isEqualTo(SC_CREATED); + assertThat(response.getOutputHeaders().get("Location")).asString().contains("/v2/repositories/hitchhiker/HeartOfGold"); + } + + @Test + public void shouldFindImportLog() throws Exception { + doAnswer( + invocation -> { + invocation.getArgument(1, OutputStream.class).write("some log".getBytes(UTF_8)); + return null; + } + ).when(importLoggerFactory).getLog(eq("42"), any(OutputStream.class)); + + MockHttpRequest request = MockHttpRequest + .get("/" + RepositoryRootResource.REPOSITORIES_PATH_V2 + "import/log/42"); + + dispatcher.invoke(request, response); + + assertThat(response.getStatus()).isEqualTo(SC_OK); + assertThat(response.getContentAsString()).isEqualTo("some log"); + verify(importLoggerFactory).checkCanReadLog("42"); + } + + private boolean streamHasContent(InputStream argument, String expectedContent) { + try { + byte[] data = new byte[expectedContent.length()]; + argument.read(data); + return new String(data).equals(expectedContent); + } catch (IOException e) { + return false; + } + } + +} diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryRootResourceTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryRootResourceTest.java index da00c043ff..67fd42911e 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryRootResourceTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryRootResourceTest.java @@ -24,8 +24,6 @@ package sonia.scm.api.v2.resources; -import com.fasterxml.jackson.core.JsonFactory; -import com.fasterxml.jackson.databind.ObjectMapper; import com.github.sdorra.shiro.ShiroRule; import com.github.sdorra.shiro.SubjectAware; import com.google.common.collect.ImmutableSet; @@ -37,38 +35,36 @@ import org.jboss.resteasy.mock.MockHttpResponse; import org.junit.Before; import org.junit.Rule; import org.junit.Test; +import org.junit.runner.RunWith; import org.mockito.ArgumentCaptor; import org.mockito.Captor; import org.mockito.InjectMocks; import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; import sonia.scm.NotFoundException; import sonia.scm.PageResult; import sonia.scm.config.ScmConfiguration; -import sonia.scm.event.ScmEventBus; import sonia.scm.importexport.ExportFileExtensionResolver; import sonia.scm.importexport.ExportService; import sonia.scm.importexport.ExportStatus; +import sonia.scm.importexport.FromBundleImporter; +import sonia.scm.importexport.FromUrlImporter; import sonia.scm.importexport.FullScmRepositoryExporter; import sonia.scm.importexport.FullScmRepositoryImporter; import sonia.scm.importexport.RepositoryImportExportEncryption; +import sonia.scm.importexport.RepositoryImportLoggerFactory; import sonia.scm.repository.CustomNamespaceStrategy; import sonia.scm.repository.NamespaceAndName; import sonia.scm.repository.NamespaceStrategy; import sonia.scm.repository.Repository; import sonia.scm.repository.RepositoryHandler; -import sonia.scm.repository.RepositoryImportEvent; import sonia.scm.repository.RepositoryInitializer; import sonia.scm.repository.RepositoryManager; -import sonia.scm.repository.RepositoryTestData; import sonia.scm.repository.RepositoryType; import sonia.scm.repository.api.BundleCommandBuilder; import sonia.scm.repository.api.Command; -import sonia.scm.repository.api.ImportFailedException; -import sonia.scm.repository.api.PullCommandBuilder; import sonia.scm.repository.api.RepositoryService; import sonia.scm.repository.api.RepositoryServiceFactory; -import sonia.scm.repository.api.UnbundleCommandBuilder; -import sonia.scm.repository.api.UnbundleResponse; import sonia.scm.user.User; import sonia.scm.web.RestDispatcher; import sonia.scm.web.VndMediaType; @@ -76,23 +72,14 @@ import sonia.scm.web.VndMediaType; import javax.servlet.http.HttpServletResponse; import javax.ws.rs.core.MediaType; import java.io.ByteArrayInputStream; -import java.io.ByteArrayOutputStream; -import java.io.File; import java.io.IOException; -import java.io.InputStream; import java.io.OutputStream; -import java.io.OutputStreamWriter; -import java.io.StringWriter; import java.io.UnsupportedEncodingException; import java.net.URI; import java.net.URISyntaxException; import java.net.URL; import java.time.Instant; -import java.util.Collections; -import java.util.Map; import java.util.Set; -import java.util.UUID; -import java.util.function.Consumer; import java.util.function.Predicate; import static java.util.Collections.singletonList; @@ -100,27 +87,22 @@ import static java.util.stream.Stream.of; import static javax.servlet.http.HttpServletResponse.SC_ACCEPTED; import static javax.servlet.http.HttpServletResponse.SC_BAD_REQUEST; import static javax.servlet.http.HttpServletResponse.SC_CONFLICT; -import static javax.servlet.http.HttpServletResponse.SC_CREATED; import static javax.servlet.http.HttpServletResponse.SC_NOT_FOUND; import static javax.servlet.http.HttpServletResponse.SC_NO_CONTENT; import static javax.servlet.http.HttpServletResponse.SC_OK; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyMap; -import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.RETURNS_SELF; import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -import static org.mockito.MockitoAnnotations.openMocks; @SubjectAware( username = "trillian", @@ -128,6 +110,7 @@ import static org.mockito.MockitoAnnotations.openMocks; configuration = "classpath:sonia/scm/repository/shiro.ini" ) @SuppressWarnings("UnstableApiUsage") +@RunWith(MockitoJUnitRunner.class) public class RepositoryRootResourceTest extends RepositoryTestBase { private static final String REALM = "AdminRealm"; @@ -156,8 +139,6 @@ public class RepositoryRootResourceTest extends RepositoryTestBase { @Mock private Set strategies; @Mock - private ScmEventBus eventBus; - @Mock private FullScmRepositoryExporter fullScmRepositoryExporter; @Mock private RepositoryExportInformationToDtoMapper exportInformationToDtoMapper; @@ -166,8 +147,14 @@ public class RepositoryRootResourceTest extends RepositoryTestBase { @Mock private RepositoryImportExportEncryption repositoryImportExportEncryption; @Mock + private FromUrlImporter fromUrlImporter; + @Mock + private FromBundleImporter fromBundleImporter; + @Mock private ExportFileExtensionResolver fileExtensionResolver; @Mock + private RepositoryImportLoggerFactory importLoggerFactory; + @Mock private ExportService exportService; @Captor @@ -175,27 +162,25 @@ public class RepositoryRootResourceTest extends RepositoryTestBase { private final URI baseUri = URI.create("/"); private final ResourceLinks resourceLinks = ResourceLinksMock.createMock(baseUri); - private Repository repositoryMarkedAsExported; @InjectMocks private RepositoryToRepositoryDtoMapperImpl repositoryToDtoMapper; @InjectMocks private RepositoryDtoToRepositoryMapperImpl dtoToRepositoryMapper; + private final MockHttpResponse response = new MockHttpResponse(); + @Before public void prepareEnvironment() throws IOException { - openMocks(this); super.repositoryToDtoMapper = repositoryToDtoMapper; super.dtoToRepositoryMapper = dtoToRepositoryMapper; super.manager = repositoryManager; RepositoryCollectionToDtoMapper repositoryCollectionToDtoMapper = new RepositoryCollectionToDtoMapper(repositoryToDtoMapper, resourceLinks); super.repositoryCollectionResource = new RepositoryCollectionResource(repositoryManager, repositoryCollectionToDtoMapper, dtoToRepositoryMapper, resourceLinks, repositoryInitializer); - super.repositoryImportResource = new RepositoryImportResource(repositoryManager, dtoToRepositoryMapper, serviceFactory, resourceLinks, eventBus, fullScmRepositoryImporter, repositoryImportExportEncryption); + super.repositoryImportResource = new RepositoryImportResource(dtoToRepositoryMapper, resourceLinks, fullScmRepositoryImporter, new RepositoryImportDtoToRepositoryImportParametersMapperImpl(), repositoryImportExportEncryption, fromUrlImporter, fromBundleImporter, importLoggerFactory); super.repositoryExportResource = new RepositoryExportResource(repositoryManager, serviceFactory, fullScmRepositoryExporter, repositoryImportExportEncryption, exportService, exportInformationToDtoMapper, fileExtensionResolver, resourceLinks); dispatcher.addSingletonResource(getRepositoryRootResource()); when(serviceFactory.create(any(Repository.class))).thenReturn(service); - when(scmPathInfoStore.get()).thenReturn(uriInfo); - when(uriInfo.getApiRestUri()).thenReturn(URI.create("/x/y")); doReturn(ImmutableSet.of(new CustomNamespaceStrategy()).iterator()).when(strategies).iterator(); SimplePrincipalCollection trillian = new SimplePrincipalCollection("trillian", REALM); trillian.add(new User("trillian"), REALM); @@ -213,7 +198,6 @@ public class RepositoryRootResourceTest extends RepositoryTestBase { createRepository("space", "repo"); MockHttpRequest request = MockHttpRequest.get("/" + RepositoryRootResource.REPOSITORIES_PATH_V2 + "space/other"); - MockHttpResponse response = new MockHttpResponse(); dispatcher.invoke(request, response); @@ -226,7 +210,6 @@ public class RepositoryRootResourceTest extends RepositoryTestBase { when(configuration.getNamespaceStrategy()).thenReturn("CustomNamespaceStrategy"); MockHttpRequest request = MockHttpRequest.get("/" + RepositoryRootResource.REPOSITORIES_PATH_V2 + "space/repo"); - MockHttpResponse response = new MockHttpResponse(); dispatcher.invoke(request, response); @@ -241,7 +224,6 @@ public class RepositoryRootResourceTest extends RepositoryTestBase { when(configuration.getNamespaceStrategy()).thenReturn("CustomNamespaceStrategy"); MockHttpRequest request = MockHttpRequest.get("/" + RepositoryRootResource.REPOSITORIES_PATH_V2); - MockHttpResponse response = new MockHttpResponse(); dispatcher.invoke(request, response); @@ -256,7 +238,6 @@ public class RepositoryRootResourceTest extends RepositoryTestBase { when(configuration.getNamespaceStrategy()).thenReturn("CustomNamespaceStrategy"); MockHttpRequest request = MockHttpRequest.get("/" + RepositoryRootResource.REPOSITORIES_PATH_V2 + "?q=Rep"); - MockHttpResponse response = new MockHttpResponse(); dispatcher.invoke(request, response); @@ -273,7 +254,6 @@ public class RepositoryRootResourceTest extends RepositoryTestBase { when(configuration.getNamespaceStrategy()).thenReturn("CustomNamespaceStrategy"); MockHttpRequest request = MockHttpRequest.get("/" + RepositoryRootResource.REPOSITORIES_PATH_V2 + "space"); - MockHttpResponse response = new MockHttpResponse(); dispatcher.invoke(request, response); @@ -290,7 +270,6 @@ public class RepositoryRootResourceTest extends RepositoryTestBase { when(configuration.getNamespaceStrategy()).thenReturn("CustomNamespaceStrategy"); MockHttpRequest request = MockHttpRequest.get("/" + RepositoryRootResource.REPOSITORIES_PATH_V2 + "space?q=Rep"); - MockHttpResponse response = new MockHttpResponse(); dispatcher.invoke(request, response); @@ -310,7 +289,6 @@ public class RepositoryRootResourceTest extends RepositoryTestBase { .put("/" + RepositoryRootResource.REPOSITORIES_PATH_V2 + "space/repo") .contentType(VndMediaType.REPOSITORY) .content(repository); - MockHttpResponse response = new MockHttpResponse(); dispatcher.invoke(request, response); @@ -328,7 +306,6 @@ public class RepositoryRootResourceTest extends RepositoryTestBase { .put("/" + RepositoryRootResource.REPOSITORIES_PATH_V2 + "space/repo") .contentType(VndMediaType.REPOSITORY) .content(repository); - MockHttpResponse response = new MockHttpResponse(); dispatcher.invoke(request, response); @@ -347,7 +324,6 @@ public class RepositoryRootResourceTest extends RepositoryTestBase { .put("/" + RepositoryRootResource.REPOSITORIES_PATH_V2 + "space/repo") .contentType(VndMediaType.REPOSITORY) .content(repository); - MockHttpResponse response = new MockHttpResponse(); dispatcher.invoke(request, response); @@ -367,7 +343,6 @@ public class RepositoryRootResourceTest extends RepositoryTestBase { .put("/" + RepositoryRootResource.REPOSITORIES_PATH_V2 + "wrong/repo") .contentType(VndMediaType.REPOSITORY) .content(repository); - MockHttpResponse response = new MockHttpResponse(); dispatcher.invoke(request, response); @@ -380,7 +355,6 @@ public class RepositoryRootResourceTest extends RepositoryTestBase { createRepository("space", "repo"); MockHttpRequest request = MockHttpRequest.delete("/" + RepositoryRootResource.REPOSITORIES_PATH_V2 + "space/repo"); - MockHttpResponse response = new MockHttpResponse(); dispatcher.invoke(request, response); @@ -403,7 +377,6 @@ public class RepositoryRootResourceTest extends RepositoryTestBase { .post("/" + RepositoryRootResource.REPOSITORIES_PATH_V2) .contentType(VndMediaType.REPOSITORY) .content(repositoryJson); - MockHttpResponse response = new MockHttpResponse(); dispatcher.invoke(request, response); @@ -424,7 +397,6 @@ public class RepositoryRootResourceTest extends RepositoryTestBase { .post("/" + RepositoryRootResource.REPOSITORIES_PATH_V2 + "?initialize=true") .contentType(VndMediaType.REPOSITORY) .content(repositoryJson); - MockHttpResponse response = new MockHttpResponse(); dispatcher.invoke(request, response); @@ -454,7 +426,6 @@ public class RepositoryRootResourceTest extends RepositoryTestBase { .post("/" + RepositoryRootResource.REPOSITORIES_PATH_V2) .contentType(VndMediaType.REPOSITORY) .content(repositoryJson); - MockHttpResponse response = new MockHttpResponse(); dispatcher.invoke(request, response); @@ -473,7 +444,6 @@ public class RepositoryRootResourceTest extends RepositoryTestBase { when(configuration.getNamespaceStrategy()).thenReturn("CustomNamespaceStrategy"); MockHttpRequest request = MockHttpRequest.get("/" + RepositoryRootResource.REPOSITORIES_PATH_V2 + "space/repo"); - MockHttpResponse response = new MockHttpResponse(); dispatcher.invoke(request, response); @@ -495,7 +465,6 @@ public class RepositoryRootResourceTest extends RepositoryTestBase { .post("/" + RepositoryRootResource.REPOSITORIES_PATH_V2 + "space/repo/rename") .contentType(VndMediaType.REPOSITORY) .content(repository); - MockHttpResponse response = new MockHttpResponse(); dispatcher.invoke(request, response); @@ -503,202 +472,6 @@ public class RepositoryRootResourceTest extends RepositoryTestBase { verify(repositoryManager).rename(repository1, "space", "x"); } - @Test - public void shouldImportRepositoryFromUrl() throws URISyntaxException, IOException { - ArgumentCaptor captor = ArgumentCaptor.forClass(RepositoryImportEvent.class); - when(manager.getHandler("git")).thenReturn(repositoryHandler); - when(repositoryHandler.getType()).thenReturn(new RepositoryType("git", "git", ImmutableSet.of(Command.PULL))); - when(manager.create(any(Repository.class), any())).thenReturn(RepositoryTestData.create42Puzzle()); - - URL url = Resources.getResource("sonia/scm/api/v2/import-repo.json"); - byte[] importRequest = Resources.toByteArray(url); - - MockHttpRequest request = MockHttpRequest - .post("/" + RepositoryRootResource.REPOSITORIES_PATH_V2 + "import/git/url") - .contentType(VndMediaType.REPOSITORY) - .content(importRequest); - MockHttpResponse response = new MockHttpResponse(); - - dispatcher.invoke(request, response); - - assertEquals(SC_CREATED, response.getStatus()); - verify(eventBus).post(captor.capture()); - - assertThat(captor.getValue().isFailed()).isFalse(); - } - - @Test - public void shouldFailOnImportRepositoryFromUrl() throws URISyntaxException, IOException { - ArgumentCaptor captor = ArgumentCaptor.forClass(RepositoryImportEvent.class); - when(manager.getHandler("git")).thenReturn(repositoryHandler); - when(repositoryHandler.getType()).thenReturn(new RepositoryType("git", "git", ImmutableSet.of(Command.PULL))); - doThrow(ImportFailedException.class).when(manager).create(any(Repository.class), any()); - - URL url = Resources.getResource("sonia/scm/api/v2/import-repo.json"); - byte[] importRequest = Resources.toByteArray(url); - - MockHttpRequest request = MockHttpRequest - .post("/" + RepositoryRootResource.REPOSITORIES_PATH_V2 + "import/git/url") - .contentType(VndMediaType.REPOSITORY) - .content(importRequest); - MockHttpResponse response = new MockHttpResponse(); - - dispatcher.invoke(request, response); - - assertEquals(500, response.getStatus()); - verify(eventBus).post(captor.capture()); - - assertThat(captor.getValue().isFailed()).isTrue(); - } - - @Test - public void shouldPullChangesFromRemoteUrl() throws IOException { - PullCommandBuilder pullCommandBuilder = mock(PullCommandBuilder.class, RETURNS_SELF); - when(service.getPullCommand()).thenReturn(pullCommandBuilder); - - Repository repository = RepositoryTestData.createHeartOfGold(); - RepositoryImportResource.RepositoryImportFromUrlDto repositoryImportFromUrlDto = new RepositoryImportResource.RepositoryImportFromUrlDto(); - repositoryImportFromUrlDto.setImportUrl("https://scm-manager.org/scm/repo/scmadmin/scm-manager.git"); - repositoryImportFromUrlDto.setNamespace("scmadmin"); - repositoryImportFromUrlDto.setName("scm-manager"); - - Consumer repositoryConsumer = repositoryImportResource.pullChangesFromRemoteUrl(repositoryImportFromUrlDto); - repositoryConsumer.accept(repository); - - verify(pullCommandBuilder).pull("https://scm-manager.org/scm/repo/scmadmin/scm-manager.git"); - } - - @Test - public void shouldPullChangesFromRemoteUrlWithCredentials() { - PullCommandBuilder pullCommandBuilder = mock(PullCommandBuilder.class, RETURNS_SELF); - when(service.getPullCommand()).thenReturn(pullCommandBuilder); - - Repository repository = RepositoryTestData.createHeartOfGold(); - RepositoryImportResource.RepositoryImportFromUrlDto repositoryImportFromUrlDto = new RepositoryImportResource.RepositoryImportFromUrlDto(); - repositoryImportFromUrlDto.setImportUrl("https://scm-manager.org/scm/repo/scmadmin/scm-manager.git"); - repositoryImportFromUrlDto.setNamespace("scmadmin"); - repositoryImportFromUrlDto.setName("scm-manager"); - repositoryImportFromUrlDto.setUsername("trillian"); - repositoryImportFromUrlDto.setPassword("secret"); - - Consumer repositoryConsumer = repositoryImportResource.pullChangesFromRemoteUrl(repositoryImportFromUrlDto); - repositoryConsumer.accept(repository); - - verify(pullCommandBuilder).withUsername("trillian"); - verify(pullCommandBuilder).withPassword("secret"); - } - - @Test - public void shouldThrowImportFailedEvent() throws IOException { - PullCommandBuilder pullCommandBuilder = mock(PullCommandBuilder.class, RETURNS_SELF); - when(service.getPullCommand()).thenReturn(pullCommandBuilder); - doThrow(ImportFailedException.class).when(pullCommandBuilder).pull(anyString()); - - Repository repository = RepositoryTestData.createHeartOfGold(); - RepositoryImportResource.RepositoryImportFromUrlDto repositoryImportFromUrlDto = new RepositoryImportResource.RepositoryImportFromUrlDto(); - repositoryImportFromUrlDto.setImportUrl("https://scm-manager.org/scm/repo/scmadmin/scm-manager.git"); - repositoryImportFromUrlDto.setNamespace("scmadmin"); - repositoryImportFromUrlDto.setName("scm-manager"); - - Consumer repositoryConsumer = repositoryImportResource.pullChangesFromRemoteUrl(repositoryImportFromUrlDto); - assertThrows(ImportFailedException.class, () -> repositoryConsumer.accept(repository)); - } - - @Test - public void shouldImportRepositoryFromBundle() throws IOException, URISyntaxException { - when(manager.getHandler("svn")).thenReturn(repositoryHandler); - when(repositoryHandler.getType()).thenReturn(new RepositoryType("svn", "svn", ImmutableSet.of(Command.UNBUNDLE))); - when(repositoryManager.create(any(), any())).thenReturn(RepositoryTestData.createHeartOfGold()); - - RepositoryDto repositoryDto = new RepositoryDto(); - repositoryDto.setName("HeartOfGold"); - repositoryDto.setNamespace("hitchhiker"); - repositoryDto.setType("svn"); - - URL dumpUrl = Resources.getResource("sonia/scm/api/v2/svn.dump"); - byte[] svnDump = Resources.toByteArray(dumpUrl); - - MockHttpRequest request = MockHttpRequest - .post("/" + RepositoryRootResource.REPOSITORIES_PATH_V2 + "import/svn/bundle"); - MockHttpResponse response = new MockHttpResponse(); - - multipartRequest(request, Collections.singletonMap("bundle", new ByteArrayInputStream(svnDump)), repositoryDto); - - dispatcher.invoke(request, response); - - assertEquals(HttpServletResponse.SC_CREATED, response.getStatus()); - assertEquals("/v2/repositories/hitchhiker/HeartOfGold", response.getOutputHeaders().get("Location").get(0).toString()); - ArgumentCaptor event = ArgumentCaptor.forClass(RepositoryImportEvent.class); - verify(eventBus).post(event.capture()); - assertFalse(event.getValue().isFailed()); - } - - @Test - public void shouldThrowFailedEventOnImportRepositoryFromBundle() throws IOException, URISyntaxException { - when(manager.getHandler("svn")).thenReturn(repositoryHandler); - when(repositoryHandler.getType()).thenReturn(new RepositoryType("svn", "svn", ImmutableSet.of(Command.UNBUNDLE))); - doThrow(ImportFailedException.class).when(repositoryManager).create(any(), any()); - - RepositoryDto repositoryDto = new RepositoryDto(); - repositoryDto.setName("HeartOfGold"); - repositoryDto.setNamespace("hitchhiker"); - repositoryDto.setType("svn"); - - URL dumpUrl = Resources.getResource("sonia/scm/api/v2/svn.dump"); - byte[] svnDump = Resources.toByteArray(dumpUrl); - - MockHttpRequest request = MockHttpRequest - .post("/" + RepositoryRootResource.REPOSITORIES_PATH_V2 + "import/svn/bundle"); - MockHttpResponse response = new MockHttpResponse(); - - multipartRequest(request, Collections.singletonMap("bundle", new ByteArrayInputStream(svnDump)), repositoryDto); - - dispatcher.invoke(request, response); - - assertEquals(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, response.getStatus()); - ArgumentCaptor event = ArgumentCaptor.forClass(RepositoryImportEvent.class); - verify(eventBus).post(event.capture()); - assertTrue(event.getValue().isFailed()); - } - - @Test - public void shouldImportCompressedBundle() throws IOException { - URL dumpUrl = Resources.getResource("sonia/scm/api/v2/svn.dump.gz"); - byte[] svnDump = Resources.toByteArray(dumpUrl); - - UnbundleCommandBuilder ubc = mock(UnbundleCommandBuilder.class, RETURNS_SELF); - when(ubc.unbundle(any(File.class))).thenReturn(new UnbundleResponse(42)); - RepositoryService service = mock(RepositoryService.class); - when(serviceFactory.create(any(Repository.class))).thenReturn(service); - when(service.getUnbundleCommand()).thenReturn(ubc); - InputStream in = new ByteArrayInputStream(svnDump); - - Consumer repositoryConsumer = repositoryImportResource.unbundleImport(in, true); - repositoryConsumer.accept(RepositoryTestData.createHeartOfGold("svn")); - - verify(ubc).setCompressed(true); - verify(ubc).unbundle(any(File.class)); - } - - @Test - public void shouldImportNonCompressedBundle() throws IOException { - URL dumpUrl = Resources.getResource("sonia/scm/api/v2/svn.dump"); - byte[] svnDump = Resources.toByteArray(dumpUrl); - - UnbundleCommandBuilder ubc = mock(UnbundleCommandBuilder.class, RETURNS_SELF); - when(ubc.unbundle(any(File.class))).thenReturn(new UnbundleResponse(21)); - RepositoryService service = mock(RepositoryService.class); - when(serviceFactory.create(any(Repository.class))).thenReturn(service); - when(service.getUnbundleCommand()).thenReturn(ubc); - InputStream in = new ByteArrayInputStream(svnDump); - - Consumer repositoryConsumer = repositoryImportResource.unbundleImport(in, false); - repositoryConsumer.accept(RepositoryTestData.createHeartOfGold("svn")); - - verify(ubc, never()).setCompressed(true); - verify(ubc).unbundle(any(File.class)); - } - @Test public void shouldMarkRepositoryAsArchived() throws Exception { String namespace = "space"; @@ -709,7 +482,6 @@ public class RepositoryRootResourceTest extends RepositoryTestBase { MockHttpRequest request = MockHttpRequest .post("/" + RepositoryRootResource.REPOSITORIES_PATH_V2 + "space/repo/archive") .content(new byte[]{}); - MockHttpResponse response = new MockHttpResponse(); dispatcher.invoke(request, response); @@ -728,7 +500,6 @@ public class RepositoryRootResourceTest extends RepositoryTestBase { MockHttpRequest request = MockHttpRequest .post("/" + RepositoryRootResource.REPOSITORIES_PATH_V2 + "space/repo/unarchive") .content(new byte[]{}); - MockHttpResponse response = new MockHttpResponse(); dispatcher.invoke(request, response); @@ -746,11 +517,9 @@ public class RepositoryRootResourceTest extends RepositoryTestBase { BundleCommandBuilder bundleCommandBuilder = mock(BundleCommandBuilder.class); when(service.getBundleCommand()).thenReturn(bundleCommandBuilder); - when(bundleCommandBuilder.getFileExtension()).thenReturn(".bundle"); MockHttpRequest request = MockHttpRequest .get("/" + RepositoryRootResource.REPOSITORIES_PATH_V2 + "space/repo/export/svn"); - MockHttpResponse response = new MockHttpResponse(); dispatcher.invoke(request, response); @@ -769,11 +538,9 @@ public class RepositoryRootResourceTest extends RepositoryTestBase { BundleCommandBuilder bundleCommandBuilder = mock(BundleCommandBuilder.class); when(service.getBundleCommand()).thenReturn(bundleCommandBuilder); - when(bundleCommandBuilder.getFileExtension()).thenReturn(".bundle"); MockHttpRequest request = MockHttpRequest .get("/" + RepositoryRootResource.REPOSITORIES_PATH_V2 + "space/repo/export/svn?compressed=true"); - MockHttpResponse response = new MockHttpResponse(); dispatcher.invoke(request, response); @@ -790,12 +557,8 @@ public class RepositoryRootResourceTest extends RepositoryTestBase { when(manager.get(new NamespaceAndName(namespace, name))).thenReturn(repository); mockRepositoryHandler(ImmutableSet.of(Command.BUNDLE)); - BundleCommandBuilder bundleCommandBuilder = mock(BundleCommandBuilder.class); - when(service.getBundleCommand()).thenReturn(bundleCommandBuilder); - MockHttpRequest request = MockHttpRequest .get("/" + RepositoryRootResource.REPOSITORIES_PATH_V2 + "space/repo/export/full"); - MockHttpResponse response = new MockHttpResponse(); dispatcher.invoke(request, response); @@ -812,9 +575,6 @@ public class RepositoryRootResourceTest extends RepositoryTestBase { when(manager.get(new NamespaceAndName(namespace, name))).thenReturn(repository); mockRepositoryHandler(ImmutableSet.of(Command.BUNDLE)); - BundleCommandBuilder bundleCommandBuilder = mock(BundleCommandBuilder.class); - when(service.getBundleCommand()).thenReturn(bundleCommandBuilder); - MockHttpRequest request = MockHttpRequest .post("/" + RepositoryRootResource.REPOSITORIES_PATH_V2 + "space/repo/export/full") .contentType(VndMediaType.REPOSITORY_EXPORT) @@ -837,9 +597,6 @@ public class RepositoryRootResourceTest extends RepositoryTestBase { when(manager.get(new NamespaceAndName(namespace, name))).thenReturn(repository); mockRepositoryHandler(ImmutableSet.of(Command.BUNDLE)); - BundleCommandBuilder bundleCommandBuilder = mock(BundleCommandBuilder.class); - when(service.getBundleCommand()).thenReturn(bundleCommandBuilder); - MockHttpRequest request = MockHttpRequest .post("/" + RepositoryRootResource.REPOSITORIES_PATH_V2 + "space/repo/export/full") .contentType(VndMediaType.REPOSITORY_EXPORT) @@ -860,9 +617,6 @@ public class RepositoryRootResourceTest extends RepositoryTestBase { when(manager.get(new NamespaceAndName(namespace, name))).thenReturn(repository); mockRepositoryHandler(ImmutableSet.of(Command.BUNDLE)); - BundleCommandBuilder bundleCommandBuilder = mock(BundleCommandBuilder.class); - when(service.getBundleCommand()).thenReturn(bundleCommandBuilder); - when(exportService.isExporting(repository)).thenReturn(true); MockHttpRequest request = MockHttpRequest @@ -877,19 +631,13 @@ public class RepositoryRootResourceTest extends RepositoryTestBase { } @Test - public void shouldDeleteRepositoryExport() throws URISyntaxException, IOException { + public void shouldDeleteRepositoryExport() throws URISyntaxException { String namespace = "space"; String name = "repo"; Repository repository = createRepository(namespace, name, "svn"); when(manager.get(new NamespaceAndName(namespace, name))).thenReturn(repository); mockRepositoryHandler(ImmutableSet.of(Command.BUNDLE)); - BundleCommandBuilder bundleCommandBuilder = mock(BundleCommandBuilder.class); - when(service.getBundleCommand()).thenReturn(bundleCommandBuilder); - - when(exportService.isExporting(repository)).thenReturn(false); - when(exportService.getData(repository)).thenReturn(new ByteArrayInputStream("".getBytes())); - MockHttpRequest request = MockHttpRequest .delete("/" + RepositoryRootResource.REPOSITORIES_PATH_V2 + "space/repo/export"); MockHttpResponse response = new MockHttpResponse(); @@ -908,9 +656,6 @@ public class RepositoryRootResourceTest extends RepositoryTestBase { when(manager.get(new NamespaceAndName(namespace, name))).thenReturn(repository); mockRepositoryHandler(ImmutableSet.of(Command.BUNDLE)); - BundleCommandBuilder bundleCommandBuilder = mock(BundleCommandBuilder.class); - when(service.getBundleCommand()).thenReturn(bundleCommandBuilder); - when(exportService.isExporting(repository)).thenReturn(false); doThrow(NotFoundException.class).when(exportService).checkExportIsAvailable(repository); @@ -932,9 +677,6 @@ public class RepositoryRootResourceTest extends RepositoryTestBase { when(manager.get(new NamespaceAndName(namespace, name))).thenReturn(repository); mockRepositoryHandler(ImmutableSet.of(Command.BUNDLE)); - BundleCommandBuilder bundleCommandBuilder = mock(BundleCommandBuilder.class); - when(service.getBundleCommand()).thenReturn(bundleCommandBuilder); - when(exportService.isExporting(repository)).thenReturn(true); MockHttpRequest request = MockHttpRequest @@ -954,9 +696,6 @@ public class RepositoryRootResourceTest extends RepositoryTestBase { when(manager.get(new NamespaceAndName(namespace, name))).thenReturn(repository); mockRepositoryHandler(ImmutableSet.of(Command.BUNDLE)); - BundleCommandBuilder bundleCommandBuilder = mock(BundleCommandBuilder.class); - when(service.getBundleCommand()).thenReturn(bundleCommandBuilder); - when(exportService.isExporting(repository)).thenReturn(false); when(exportService.getData(repository)).thenReturn(new ByteArrayInputStream("content".getBytes())); when(exportService.getFileExtension(repository)).thenReturn("tar.gz.enc"); @@ -979,9 +718,6 @@ public class RepositoryRootResourceTest extends RepositoryTestBase { when(manager.get(new NamespaceAndName(namespace, name))).thenReturn(repository); mockRepositoryHandler(ImmutableSet.of(Command.BUNDLE)); - BundleCommandBuilder bundleCommandBuilder = mock(BundleCommandBuilder.class); - when(service.getBundleCommand()).thenReturn(bundleCommandBuilder); - RepositoryExportInformationDto dto = new RepositoryExportInformationDto(); dto.setExporterName("trillian"); dto.setCreated(Instant.ofEpochMilli(100)); @@ -1011,7 +747,6 @@ public class RepositoryRootResourceTest extends RepositoryTestBase { when(repositoryType.getSupportedCommands()).thenReturn(cmds); } - private PageResult createSingletonPageResult(Repository repository) { return new PageResult<>(singletonList(repository), 0); } @@ -1039,48 +774,4 @@ public class RepositoryRootResourceTest extends RepositoryTestBase { when(repositoryManager.get(id)).thenReturn(repository); return repository; } - - - /** - * This method is a slightly adapted copy of Lin Zaho's gist at https://gist.github.com/lin-zhao/9985191 - */ - private void multipartRequest(MockHttpRequest request, Map files, RepositoryDto repository) throws IOException { - String boundary = UUID.randomUUID().toString(); - request.contentType("multipart/form-data; boundary=" + boundary); - - //Make sure this is deleted in afterTest() - ByteArrayOutputStream buffer = new ByteArrayOutputStream(); - try (OutputStreamWriter formWriter = new OutputStreamWriter(buffer)) { - formWriter.append("--").append(boundary); - - for (Map.Entry entry : files.entrySet()) { - formWriter.append("\n"); - formWriter.append(String.format("Content-Disposition: form-data; name=\"%s\"; filename=\"%s\"", - entry.getKey(), entry.getKey())).append("\n"); - formWriter.append("Content-Type: application/octet-stream").append("\n\n"); - - InputStream stream = entry.getValue(); - int b = stream.read(); - while (b >= 0) { - formWriter.write(b); - b = stream.read(); - } - stream.close(); - formWriter.append("\n").append("--").append(boundary); - } - - if (repository != null) { - formWriter.append("\n"); - formWriter.append("Content-Disposition: form-data; name=\"repository\"").append("\n\n"); - StringWriter repositoryWriter = new StringWriter(); - new JsonFactory().createGenerator(repositoryWriter).setCodec(new ObjectMapper()).writeObject(repository); - formWriter.append(repositoryWriter.getBuffer().toString()).append("\n"); - formWriter.append("--").append(boundary); - } - - formWriter.append("--"); - formWriter.flush(); - } - request.setInputStream(new ByteArrayInputStream(buffer.toByteArray())); - } } diff --git a/scm-webapp/src/test/java/sonia/scm/importexport/FromBundleImporterTest.java b/scm-webapp/src/test/java/sonia/scm/importexport/FromBundleImporterTest.java new file mode 100644 index 0000000000..61114f118c --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/importexport/FromBundleImporterTest.java @@ -0,0 +1,186 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * 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. + */ + +package sonia.scm.importexport; + +import com.google.common.io.Resources; +import org.apache.shiro.authz.AuthorizationException; +import org.apache.shiro.subject.Subject; +import org.apache.shiro.util.ThreadContext; +import org.junit.Assert; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.Answers; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import sonia.scm.event.ScmEventBus; +import sonia.scm.repository.Repository; +import sonia.scm.repository.RepositoryHandler; +import sonia.scm.repository.RepositoryManager; +import sonia.scm.repository.RepositoryPermission; +import sonia.scm.repository.RepositoryTestData; +import sonia.scm.repository.RepositoryType; +import sonia.scm.repository.api.Command; +import sonia.scm.repository.api.RepositoryService; +import sonia.scm.repository.api.RepositoryServiceFactory; +import sonia.scm.repository.api.UnbundleCommandBuilder; +import sonia.scm.repository.api.UnbundleResponse; +import sonia.scm.repository.work.WorkdirProvider; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.net.URL; +import java.nio.file.Path; +import java.util.function.Consumer; + +import static java.util.Collections.singleton; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.Assert.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@SuppressWarnings("UnstableApiUsage") +class FromBundleImporterTest { + + public static final Repository REPOSITORY = RepositoryTestData.createHeartOfGold("svn"); + @Mock + private RepositoryManager manager; + @Mock + private RepositoryHandler repositoryHandler; + @Mock + private RepositoryServiceFactory serviceFactory; + @Mock + private ScmEventBus eventBus; + @Mock + private WorkdirProvider workdirProvider; + @Mock + private RepositoryImportLoggerFactory loggerFactory; + @Mock + private RepositoryImportLogger logger; + @Mock(answer = Answers.RETURNS_SELF) + private UnbundleCommandBuilder unbundleCommandBuilder; + @Mock + private Subject subject; + + @InjectMocks + private FromBundleImporter importer; + + @BeforeEach + void mockSubject() { + ThreadContext.bind(subject); + } + + @AfterEach + void cleanupSubject() { + ThreadContext.unbindSubject(); + } + + @Nested + class WithPermission { + + @BeforeEach + void initMocks(@TempDir Path temp) throws IOException { + when(subject.getPrincipal()).thenReturn("dent"); + when(workdirProvider.createNewWorkdir(REPOSITORY.getId())).thenReturn(temp.toFile()); + when(manager.create(eq(REPOSITORY), any())).thenAnswer( + invocation -> { + invocation.getArgument(1, Consumer.class).accept(REPOSITORY); + return REPOSITORY; + } + ); + when(manager.getHandler("svn")).thenReturn(repositoryHandler); + RepositoryType repositoryType = mock(RepositoryType.class); + when(repositoryHandler.getType()).thenReturn(repositoryType); + when(repositoryType.getSupportedCommands()).thenReturn(singleton(Command.UNBUNDLE)); + when(loggerFactory.createLogger()).thenReturn(logger); + + when(unbundleCommandBuilder.unbundle(any(File.class))).thenReturn(new UnbundleResponse(42)); + RepositoryService service = mock(RepositoryService.class); + when(serviceFactory.create(any(Repository.class))).thenReturn(service); + when(service.getUnbundleCommand()).thenReturn(unbundleCommandBuilder); + } + + @Test + void shouldImportCompressedBundle() throws IOException { + URL dumpUrl = Resources.getResource("sonia/scm/api/v2/svn.dump.gz"); + InputStream in = new ByteArrayInputStream(Resources.toByteArray(dumpUrl)); + + importer.importFromBundle(true, in, REPOSITORY); + + verify(unbundleCommandBuilder).setCompressed(true); + verify(unbundleCommandBuilder).unbundle(any(File.class)); + } + + @Test + void shouldImportNonCompressedBundle() throws IOException { + URL dumpUrl = Resources.getResource("sonia/scm/api/v2/svn.dump"); + InputStream in = new ByteArrayInputStream(Resources.toByteArray(dumpUrl)); + + importer.importFromBundle(false, in, REPOSITORY); + + verify(unbundleCommandBuilder, never()).setCompressed(true); + verify(unbundleCommandBuilder).unbundle(any(File.class)); + } + + @Test + void shouldSetPermissionForCurrentUser() throws IOException { + URL dumpUrl = Resources.getResource("sonia/scm/api/v2/svn.dump"); + InputStream in = new ByteArrayInputStream(Resources.toByteArray(dumpUrl)); + + Repository createdRepository = importer.importFromBundle(false, in, REPOSITORY); + + assertThat(createdRepository.getPermissions()) + .hasSize(1); + RepositoryPermission permission = createdRepository.getPermissions().iterator().next(); + assertThat(permission.getName()).isEqualTo("dent"); + assertThat(permission.isGroupPermission()).isFalse(); + assertThat(permission.getRole()).isEqualTo("OWNER"); + } + } + + @Test + void shouldFailWithoutPermission() throws IOException { + URL dumpUrl = Resources.getResource("sonia/scm/api/v2/svn.dump"); + InputStream in = new ByteArrayInputStream(Resources.toByteArray(dumpUrl)); + + doThrow(new AuthorizationException()).when(subject).checkPermission("repository:create"); + + assertThrows(AuthorizationException.class, () -> importer.importFromBundle(false, in, REPOSITORY)); + + verify(manager, never()).create(any(), any()); + } +} diff --git a/scm-webapp/src/test/java/sonia/scm/importexport/FromUrlImporterTest.java b/scm-webapp/src/test/java/sonia/scm/importexport/FromUrlImporterTest.java new file mode 100644 index 0000000000..1a3d719ae6 --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/importexport/FromUrlImporterTest.java @@ -0,0 +1,186 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * 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. + */ + +package sonia.scm.importexport; + +import org.apache.shiro.subject.Subject; +import org.apache.shiro.util.ThreadContext; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import sonia.scm.event.ScmEventBus; +import sonia.scm.repository.Repository; +import sonia.scm.repository.RepositoryHandler; +import sonia.scm.repository.RepositoryImportEvent; +import sonia.scm.repository.RepositoryManager; +import sonia.scm.repository.RepositoryTestData; +import sonia.scm.repository.RepositoryType; +import sonia.scm.repository.api.ImportFailedException; +import sonia.scm.repository.api.PullCommandBuilder; +import sonia.scm.repository.api.RepositoryService; +import sonia.scm.repository.api.RepositoryServiceFactory; + +import java.io.IOException; +import java.util.function.Consumer; + +import static java.util.Collections.singleton; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.Assert.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.Mockito.RETURNS_SELF; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static sonia.scm.repository.api.Command.PULL; + +@ExtendWith(MockitoExtension.class) +class FromUrlImporterTest { + + @Mock + private RepositoryManager manager; + @Mock + private RepositoryServiceFactory serviceFactory; + @Mock + private RepositoryService service; + @Mock + private ScmEventBus eventBus; + @Mock + private RepositoryImportLoggerFactory loggerFactory; + @Mock + private RepositoryImportLogger logger; + @Mock + private Subject subject; + + @InjectMocks + private FromUrlImporter importer; + + private final Repository repository = RepositoryTestData.createHeartOfGold("git"); + + @BeforeEach + void setUpMocks() { + when(serviceFactory.create(any(Repository.class))).thenReturn(service); + when(loggerFactory.createLogger()).thenReturn(logger); + when(manager.create(any(), any())).thenAnswer( + invocation -> { + Repository repository = invocation.getArgument(0, Repository.class); + Repository createdRepository = repository.clone(); + createdRepository.setNamespace("created"); + invocation.getArgument(1, Consumer.class).accept(createdRepository); + return createdRepository; + } + ); + } + + @BeforeEach + void setUpRepositoryType() { + RepositoryHandler repositoryHandler = mock(RepositoryHandler.class); + when(manager.getHandler(repository.getType())).thenReturn(repositoryHandler); + RepositoryType repositoryType = mock(RepositoryType.class); + when(repositoryHandler.getType()).thenReturn(repositoryType); + when(repositoryType.getSupportedCommands()).thenReturn(singleton(PULL)); + } + + @BeforeEach + void mockSubject() { + ThreadContext.bind(subject); + when(subject.getPrincipal()).thenReturn("trillian"); + } + + @AfterEach + void cleanupSubject() { + ThreadContext.unbindSubject(); + } + + @Test + void shouldPullChangesFromRemoteUrl() throws IOException { + PullCommandBuilder pullCommandBuilder = mock(PullCommandBuilder.class, RETURNS_SELF); + when(service.getPullCommand()).thenReturn(pullCommandBuilder); + + FromUrlImporter.RepositoryImportParameters parameters = new FromUrlImporter.RepositoryImportParameters(); + parameters.setImportUrl("https://scm-manager.org/scm/repo/scmadmin/scm-manager.git"); + + Repository createdRepository = importer.importFromUrl(parameters, repository); + + assertThat(createdRepository.getNamespace()).isEqualTo("created"); + verify(pullCommandBuilder).pull("https://scm-manager.org/scm/repo/scmadmin/scm-manager.git"); + verify(logger).finished(); + verify(eventBus).post(argThat( + event -> { + assertThat(event).isInstanceOf(RepositoryImportEvent.class); + RepositoryImportEvent repositoryImportEvent = (RepositoryImportEvent) event; + assertThat(repositoryImportEvent.getItem().getNamespace()).isEqualTo("created"); + assertThat(repositoryImportEvent.isFailed()).isFalse(); + return true; + } + )); + } + + @Test + void shouldPullChangesFromRemoteUrlWithCredentials() { + PullCommandBuilder pullCommandBuilder = mock(PullCommandBuilder.class, RETURNS_SELF); + when(service.getPullCommand()).thenReturn(pullCommandBuilder); + + FromUrlImporter.RepositoryImportParameters parameters = new FromUrlImporter.RepositoryImportParameters(); + parameters.setImportUrl("https://scm-manager.org/scm/repo/scmadmin/scm-manager.git"); + parameters.setUsername("trillian"); + parameters.setPassword("secret"); + + importer.importFromUrl(parameters, repository); + + verify(pullCommandBuilder).withUsername("trillian"); + verify(pullCommandBuilder).withPassword("secret"); + } + + @Test + void shouldThrowImportFailedEvent() throws IOException { + PullCommandBuilder pullCommandBuilder = mock(PullCommandBuilder.class, RETURNS_SELF); + when(service.getPullCommand()).thenReturn(pullCommandBuilder); + doThrow(TestException.class).when(pullCommandBuilder).pull(anyString()); + when(logger.started()).thenReturn(true); + + FromUrlImporter.RepositoryImportParameters parameters = new FromUrlImporter.RepositoryImportParameters(); + parameters.setImportUrl("https://scm-manager.org/scm/repo/scmadmin/scm-manager.git"); + + assertThrows(ImportFailedException.class, () -> importer.importFromUrl(parameters, repository)); + verify(logger).failed(argThat(e -> e instanceof TestException)); + verify(eventBus).post(argThat( + event -> { + assertThat(event).isInstanceOf(RepositoryImportEvent.class); + RepositoryImportEvent repositoryImportEvent = (RepositoryImportEvent) event; + assertThat(repositoryImportEvent.getItem()).isEqualTo(repository); + assertThat(repositoryImportEvent.isFailed()).isTrue(); + return true; + } + )); + } + + private static class TestException extends RuntimeException {} +} diff --git a/scm-webapp/src/test/java/sonia/scm/importexport/FullScmRepositoryImporterTest.java b/scm-webapp/src/test/java/sonia/scm/importexport/FullScmRepositoryImporterTest.java index 1c7d8f2d69..0bbeba37d9 100644 --- a/scm-webapp/src/test/java/sonia/scm/importexport/FullScmRepositoryImporterTest.java +++ b/scm-webapp/src/test/java/sonia/scm/importexport/FullScmRepositoryImporterTest.java @@ -25,6 +25,9 @@ package sonia.scm.importexport; import com.google.common.io.Resources; +import org.apache.shiro.authz.AuthorizationException; +import org.apache.shiro.subject.Subject; +import org.apache.shiro.util.ThreadContext; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -40,6 +43,7 @@ import sonia.scm.event.ScmEventBus; import sonia.scm.repository.ImportRepositoryHookEvent; import sonia.scm.repository.Repository; import sonia.scm.repository.RepositoryHookEvent; +import sonia.scm.repository.RepositoryImportEvent; import sonia.scm.repository.RepositoryManager; import sonia.scm.repository.RepositoryPermission; import sonia.scm.repository.RepositoryTestData; @@ -64,6 +68,8 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; @@ -94,6 +100,14 @@ class FullScmRepositoryImporterTest { private RepositoryImportExportEncryption repositoryImportExportEncryption; @Mock private WorkdirProvider workdirProvider; + @Mock + private RepositoryImportLogger logger; + @Mock + private RepositoryImportLoggerFactory loggerFactory; + @Mock + private Subject subject; + @Mock + private ScmEventBus eventBus; @InjectMocks private EnvironmentCheckStep environmentCheckStep; @@ -104,9 +118,6 @@ class FullScmRepositoryImporterTest { @InjectMocks private RepositoryImportStep repositoryImportStep; - @Mock - private ScmEventBus eventBus; - @Mock private RepositoryHookEvent event; @@ -124,14 +135,25 @@ class FullScmRepositoryImporterTest { repositoryImportStep, repositoryManager, repositoryImportExportEncryption, - eventBus - ); + loggerFactory, + eventBus); } @BeforeEach void initRepositoryService() { lenient().when(serviceFactory.create(REPOSITORY)).thenReturn(service); lenient().when(service.getUnbundleCommand()).thenReturn(unbundleCommandBuilder); + lenient().when(loggerFactory.createLogger()).thenReturn(logger); + } + + @BeforeEach + void initSubject() { + ThreadContext.bind(subject); + } + + @BeforeEach + void cleanupSubject() { + ThreadContext.unbindSubject(); } @Test @@ -154,6 +176,28 @@ class FullScmRepositoryImporterTest { IncompatibleEnvironmentForImportException.class, () -> fullImporter.importFromStream(REPOSITORY, importStream, "") ); + + verify(eventBus).post(argThat( + event -> { + assertThat(event).isInstanceOf(RepositoryImportEvent.class); + RepositoryImportEvent repositoryImportEvent = (RepositoryImportEvent) event; + assertThat(repositoryImportEvent.getItem()).isEqualTo(REPOSITORY); + assertThat(repositoryImportEvent.isFailed()).isTrue(); + return true; + } + )); + } + + @Test + void shouldNotImportRepositoryWithoutPermission() throws IOException { + doThrow(AuthorizationException.class).when(subject).checkPermission("repository:create"); + + InputStream stream = Resources.getResource("sonia/scm/repository/import/scm-import.tar.gz").openStream(); + + assertThrows(AuthorizationException.class, () -> fullImporter.importFromStream(REPOSITORY, stream, null)); + + verify(storeImporter, never()).importFromTarArchive(any(Repository.class), any(InputStream.class), any(RepositoryImportLogger.class)); + verify(repositoryManager, never()).modify(any()); } @Nested @@ -174,7 +218,7 @@ class FullScmRepositoryImporterTest { Repository repository = fullImporter.importFromStream(REPOSITORY, stream, ""); assertThat(repository).isEqualTo(REPOSITORY); - verify(storeImporter).importFromTarArchive(eq(REPOSITORY), any(InputStream.class)); + verify(storeImporter).importFromTarArchive(eq(REPOSITORY), any(InputStream.class), eq(logger)); verify(repositoryManager).modify(REPOSITORY); Collection updatedPermissions = REPOSITORY.getPermissions(); assertThat(updatedPermissions).hasSize(2); @@ -192,6 +236,33 @@ class FullScmRepositoryImporterTest { assertThat(workDirExists).isFalse(); } + @Test + void shouldSendImportedEventForImportedRepository() throws IOException { + InputStream stream = Resources.getResource("sonia/scm/repository/import/scm-import.tar.gz").openStream(); + when(unbundleCommandBuilder.setPostEventSink(any())).thenAnswer( + invocation -> { + invocation.getArgument(0, Consumer.class).accept(new RepositoryHookEvent(null, REPOSITORY, null)); + return null; + } + ); + ArgumentCaptor capturedEvents = ArgumentCaptor.forClass(Object.class); + doNothing().when(eventBus).post(capturedEvents.capture()); + + fullImporter.importFromStream(REPOSITORY, stream, null); + + assertThat(capturedEvents.getAllValues()).hasSize(2); + assertThat(capturedEvents.getAllValues()).anyMatch( + event -> + event instanceof ImportRepositoryHookEvent && + ((ImportRepositoryHookEvent) event).getRepository().equals(REPOSITORY) + ); + assertThat(capturedEvents.getAllValues()).anyMatch( + event -> + event instanceof RepositoryImportEvent && + ((RepositoryImportEvent) event).getItem().equals(REPOSITORY) + ); + } + @Test void shouldTriggerUpdateForImportedRepository() throws IOException { InputStream stream = Resources.getResource("sonia/scm/repository/import/scm-import.tar.gz").openStream(); @@ -207,7 +278,8 @@ class FullScmRepositoryImporterTest { Repository repository = fullImporter.importFromStream(REPOSITORY, stream, ""); assertThat(repository).isEqualTo(REPOSITORY); - verify(storeImporter).importFromTarArchive(eq(REPOSITORY), any(InputStream.class)); + verify(storeImporter).importFromTarArchive(eq(REPOSITORY), any(InputStream.class), eq(logger)); + verify(repositoryManager).create(REPOSITORY); verify(repositoryManager).modify(REPOSITORY); verify(unbundleCommandBuilder).unbundle((InputStream) argThat(argument -> argument.getClass().equals(NoneClosingInputStream.class))); verify(workdirProvider, never()).createNewWorkdir(REPOSITORY.getId()); diff --git a/scm-webapp/src/test/java/sonia/scm/importexport/RepositoryImportLoggerFactoryTest.java b/scm-webapp/src/test/java/sonia/scm/importexport/RepositoryImportLoggerFactoryTest.java new file mode 100644 index 0000000000..ff72cd1148 --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/importexport/RepositoryImportLoggerFactoryTest.java @@ -0,0 +1,136 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * 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. + */ + +package sonia.scm.importexport; + +import com.google.common.io.Resources; +import org.apache.shiro.authz.AuthorizationException; +import org.apache.shiro.subject.Subject; +import org.apache.shiro.util.ThreadContext; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import sonia.scm.NotFoundException; +import sonia.scm.store.Blob; +import sonia.scm.store.InMemoryBlobStore; +import sonia.scm.store.InMemoryBlobStoreFactory; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.OutputStream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class RepositoryImportLoggerFactoryTest { + + private final Subject subject = mock(Subject.class); + + private final InMemoryBlobStore store = new InMemoryBlobStore(); + private final RepositoryImportLoggerFactory factory = new RepositoryImportLoggerFactory(new InMemoryBlobStoreFactory(store)); + + @BeforeEach + void initSubject() { + ThreadContext.bind(subject); + } + + @AfterEach + void cleanupSubject() { + ThreadContext.unbindSubject(); + } + + @Test + void shouldReadLogForExportingUser() throws IOException { + when(subject.getPrincipal()).thenReturn("dent"); + + createLog(); + + ByteArrayOutputStream out = new ByteArrayOutputStream(); + + factory.getLog("42", out); + + assertLogReadCorrectly(out); + } + + @Test + void shouldReadLogForAdmin() throws IOException { + when(subject.getPrincipal()).thenReturn("trillian"); + when(subject.isPermitted(anyString())).thenReturn(true); + + createLog(); + + ByteArrayOutputStream out = new ByteArrayOutputStream(); + + factory.getLog("42", out); + + assertLogReadCorrectly(out); + } + + private void assertLogReadCorrectly(ByteArrayOutputStream out) { + assertThat(out).asString().contains( + "Import of repository hitchhiker/HeartOfGold", + "Repository type: git", + "Imported from: URL", + "Imported by dent (Arthur Dent)", + "", + "Thu Feb 25 11:11:07 CET 2021 - import started", + "Thu Feb 25 11:11:07 CET 2021 - pulling repository from https://github.com/scm-manager/scm-manager", + "Thu Feb 25 11:11:08 CET 2021 - import finished successfully" + ); + } + + @Test + void shouldThrowNotFoundExceptionForMissingLog() { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + + assertThrows(NotFoundException.class, () -> factory.getLog("42", out)); + } + + @Test + void shouldFailWithoutPermission() throws IOException { + when(subject.getPrincipal()).thenReturn("trillian"); + createLog(); + + doThrow(AuthorizationException.class).when(subject).checkPermission("only:admin:allowed"); + + ByteArrayOutputStream out = new ByteArrayOutputStream(); + + assertThrows(AuthorizationException.class, () -> factory.getLog("42", out)); + } + + @SuppressWarnings("UnstableApiUsage") + private void createLog() throws IOException { + Blob blob = store.create("42"); + try (OutputStream outputStream = blob.getOutputStream()) { + Resources.copy( + Resources.getResource("sonia/scm/importexport/importLog.blob"), + outputStream); + } + blob.commit(); + } +} diff --git a/scm-webapp/src/test/java/sonia/scm/importexport/TarArchiveRepositoryStoreImporterTest.java b/scm-webapp/src/test/java/sonia/scm/importexport/TarArchiveRepositoryStoreImporterTest.java index 9f07bd19d1..fcefea149c 100644 --- a/scm-webapp/src/test/java/sonia/scm/importexport/TarArchiveRepositoryStoreImporterTest.java +++ b/scm-webapp/src/test/java/sonia/scm/importexport/TarArchiveRepositoryStoreImporterTest.java @@ -53,6 +53,8 @@ class TarArchiveRepositoryStoreImporterTest { @Mock(answer = Answers.RETURNS_DEEP_STUBS) private RepositoryStoreImporter repositoryStoreImporter; + @Mock + private RepositoryImportLogger logger; @InjectMocks private TarArchiveRepositoryStoreImporter tarArchiveRepositoryStoreImporter; @@ -60,20 +62,20 @@ class TarArchiveRepositoryStoreImporterTest { @Test void shouldDoNothingIfNoEntries() { ByteArrayInputStream bais = new ByteArrayInputStream("".getBytes()); - tarArchiveRepositoryStoreImporter.importFromTarArchive(repository, bais); + tarArchiveRepositoryStoreImporter.importFromTarArchive(repository, bais, logger); verify(repositoryStoreImporter, never()).doImport(any(Repository.class)); } @Test void shouldImportEachEntry() throws IOException { InputStream inputStream = Resources.getResource("sonia/scm/repository/import/scm-metadata.tar").openStream(); - tarArchiveRepositoryStoreImporter.importFromTarArchive(repository, inputStream); + tarArchiveRepositoryStoreImporter.importFromTarArchive(repository, inputStream, logger); verify(repositoryStoreImporter, times(2)).doImport(repository); } @Test void shouldThrowImportFailedExceptionIfInvalidStorePath() throws IOException { InputStream inputStream = Resources.getResource("sonia/scm/repository/import/scm-metadata_invalid.tar").openStream(); - assertThrows(ImportFailedException.class, () -> tarArchiveRepositoryStoreImporter.importFromTarArchive(repository, inputStream)); + assertThrows(ImportFailedException.class, () -> tarArchiveRepositoryStoreImporter.importFromTarArchive(repository, inputStream, logger)); } } diff --git a/scm-webapp/src/test/resources/sonia/scm/api/v2/import-repo-with-credentials.json b/scm-webapp/src/test/resources/sonia/scm/api/v2/import-repo-with-credentials.json index 7b33dbdd06..e0dea12e07 100644 --- a/scm-webapp/src/test/resources/sonia/scm/api/v2/import-repo-with-credentials.json +++ b/scm-webapp/src/test/resources/sonia/scm/api/v2/import-repo-with-credentials.json @@ -1,6 +1,7 @@ { "namespace": "hitchhiker", "name": "HeartOfGold", + "type": "git", "importUrl": "https://scm-manager-org/scm/repo/secret/puzzle42", "username": "trillian", "password": "secret" diff --git a/scm-webapp/src/test/resources/sonia/scm/importexport/importLog.blob b/scm-webapp/src/test/resources/sonia/scm/importexport/importLog.blob new file mode 100644 index 0000000000000000000000000000000000000000..431af3678dc9f6a76013ab58253f11496ef6e217 GIT binary patch literal 325 zcmaiv%WA_g5Jj`rSKMW~a2y*-i+80>9}p-6_XBKcWQkgqkVXsteHF4u7JkmIGak?gW4b)+>EG`E~K}YX8{G5*0 z)wn_x2(~YgO^$dg!-EjL$#|y`?IW6|E46>Z>!C-xZyQvw7p267OzOXM$n$KM=~n-2 u0>d0qSJ%bS