From 89add3f7956d1ad4a6d6934f3ae6ad5c607c3f93 Mon Sep 17 00:00:00 2001 From: Eduard Heimbuch Date: Thu, 10 Dec 2020 10:27:03 +0100 Subject: [PATCH 1/9] Add bundle endpoint to repository import resource --- .../resources/RepositoryImportResource.java | 163 ++++++++++++++++++ .../resources/RepositoryRootResourceTest.java | 134 ++++++++++++++ .../scm/api/v2/import-repo-from-bundle.json | 7 + .../test/resources/sonia/scm/api/v2/svn.dump | 83 +++++++++ .../resources/sonia/scm/api/v2/svn.dump.gz | Bin 0 -> 487 bytes 5 files changed, 387 insertions(+) create mode 100644 scm-webapp/src/test/resources/sonia/scm/api/v2/import-repo-from-bundle.json create mode 100644 scm-webapp/src/test/resources/sonia/scm/api/v2/svn.dump create mode 100644 scm-webapp/src/test/resources/sonia/scm/api/v2/svn.dump.gz 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 e06c23528d..17dd47352e 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 @@ -24,8 +24,13 @@ 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 de.otto.edison.hal.Embedded; import de.otto.edison.hal.Links; @@ -37,8 +42,13 @@ 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.BadRequestException; +import sonia.scm.ContextEntry; import sonia.scm.HandlerEventType; import sonia.scm.Type; import sonia.scm.event.ScmEventBus; @@ -54,6 +64,7 @@ import sonia.scm.repository.api.Command; 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.util.ValidationUtil; import sonia.scm.web.VndMediaType; @@ -62,18 +73,30 @@ import javax.validation.constraints.Email; import javax.validation.constraints.NotEmpty; import javax.validation.constraints.Pattern; import javax.ws.rs.Consumes; +import javax.ws.rs.DefaultValue; import javax.ws.rs.POST; import javax.ws.rs.Path; import javax.ws.rs.PathParam; +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.UriInfo; +import java.io.ByteArrayInputStream; +import java.io.File; import java.io.IOException; +import java.io.InputStream; import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; import java.util.Set; import java.util.function.Consumer; +import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.base.Preconditions.checkNotNull; +import static java.nio.charset.StandardCharsets.UTF_8; import static java.util.Collections.singletonList; public class RepositoryImportResource { @@ -188,6 +211,139 @@ public class RepositoryImportResource { }; } + /** + * Imports a external repository via dump. The method can + * only be used, if the repository type supports the {@link Command#UNBUNDLE}. The + * method will return a location header with the url to the imported + * repository. + * + * @param uriInfo uri info + * @param type repository type + * @param input multi part form data which should contain a valid repository dto and the input stream of the bundle + * @param compressed true if the bundle is gzip compressed + * @return empty response with location header which points to the imported + * repository + * @since 2.12.0 + */ + @POST + @Path("{type}/bundle") + @Consumes(MediaType.MULTIPART_FORM_DATA) + @Operation(summary = "Import repository from bundle", description = "Imports the repository from the provided bundle.", tags = "Repository") + @ApiResponse( + responseCode = "201", + description = "Repository import was successful" + ) + @ApiResponse( + responseCode = "401", + description = "not authenticated / invalid credentials" + ) + @ApiResponse( + responseCode = "403", + description = "not authorized, the current user has no privileges to read the repository" + ) + @ApiResponse( + responseCode = "500", + description = "internal server error", + content = @Content( + mediaType = VndMediaType.ERROR_TYPE, + schema = @Schema(implementation = ErrorDto.class) + ) + ) + public Response importFromBundle(@Context UriInfo uriInfo, + @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(); + } + + /** + * Start bundle import. + * + * @param type repository type + * @param input multi part form data + * @param compressed true if the bundle is gzip compressed + * @return imported repository + */ + private Repository doImportFromBundle(String type, MultipartFormDataInput input, boolean compressed) { + Map> formParts = input.getFormDataMap(); + RepositoryDto repositoryDto = extractFromInputPart(formParts.get("repository"), RepositoryDto.class); + InputStream inputStream = extractFromInputPart(formParts.get("bundle"), InputStream.class); + + checkNotNull(repositoryDto, "repository data is required"); + checkNotNull(inputStream, "bundle inputStream is required"); + checkArgument(!Strings.isNullOrEmpty(repositoryDto.getName()), "request does not contain name of the repository"); + + Type t = type(type); + + checkSupport(t, Command.UNBUNDLE, "bundle"); + + 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; + } + + return repository; + } + + @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 T extractFromInputPart(List input, Class type) { + try { + if (input != null && !input.isEmpty()) { + String content = new ByteSource() { + @Override + public InputStream openStream() throws IOException { + return ((MultipartInputImpl.PartImpl) input.get(0)).getBody(); + } + }.asCharSource(UTF_8).read(); + if (type == InputStream.class) { + return (T) new ByteArrayInputStream(StandardCharsets.UTF_8.encode(content).array()); + } + try (JsonParser parser = new JsonFactory().createParser(content)) { + parser.setCodec(new ObjectMapper()); + return parser.readValueAs(type); + } + } + } catch (IOException ex) { + logger.debug("Could not extract repository from input"); + } + return null; + } + /** * Check repository type for support for the given command. * @@ -241,16 +397,23 @@ public class RepositoryImportResource { interface ImportRepositoryDto { String getNamespace(); + @Pattern(regexp = ValidationUtil.REGEX_REPOSITORYNAME) String getName(); + @NotEmpty String getType(); + @Email String getContact(); + String getDescription(); + @NotEmpty String getImportUrl(); + String getUsername(); + String getPassword(); } } 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 daabb9ae20..26f828571b 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,6 +24,8 @@ 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; @@ -58,17 +60,29 @@ 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; 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.OutputStreamWriter; +import java.io.StringWriter; import java.io.UnsupportedEncodingException; import java.net.URI; import java.net.URISyntaxException; import java.net.URL; +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; @@ -565,6 +579,81 @@ public class RepositoryRootResourceTest extends RepositoryTestBase { 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); + when(ubc.setCompressed(any())).thenReturn(ubc); + 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); + repositoryImportResource.unbundleImport(in, true); + + verify(ubc).setCompressed(true); + //TODO Enhance test + } + private PageResult createSingletonPageResult(Repository repository) { return new PageResult<>(singletonList(repository), 0); } @@ -586,4 +675,49 @@ 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 MockHttpRequest 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())); + return request; + } } diff --git a/scm-webapp/src/test/resources/sonia/scm/api/v2/import-repo-from-bundle.json b/scm-webapp/src/test/resources/sonia/scm/api/v2/import-repo-from-bundle.json new file mode 100644 index 0000000000..5bc573f2a8 --- /dev/null +++ b/scm-webapp/src/test/resources/sonia/scm/api/v2/import-repo-from-bundle.json @@ -0,0 +1,7 @@ +{ + "contact": "none@example.com", + "description": "Test repository", + "namespace": "space", + "name": "repo", + "type": "svn" +} diff --git a/scm-webapp/src/test/resources/sonia/scm/api/v2/svn.dump b/scm-webapp/src/test/resources/sonia/scm/api/v2/svn.dump new file mode 100644 index 0000000000..f339c8635f --- /dev/null +++ b/scm-webapp/src/test/resources/sonia/scm/api/v2/svn.dump @@ -0,0 +1,83 @@ +SVN-fs-dump-format-version: 2 + +UUID: dcaa635c-9a8d-4cd6-918f-250ca2f765ea + +Revision-number: 0 +Prop-content-length: 56 +Content-length: 56 + +K 8 +svn:date +V 27 +2020-12-09T13:42:16.879000Z +PROPS-END + +Revision-number: 1 +Prop-content-length: 124 +Content-length: 124 + +K 10 +svn:author +V 8 +scmadmin +K 8 +svn:date +V 27 +2020-12-09T13:42:18.270000Z +K 7 +svn:log +V 21 +initialize repository +PROPS-END + +Node-path: trunk +Node-kind: dir +Node-action: add +Prop-content-length: 10 +Content-length: 10 + +PROPS-END + + +Node-path: trunk/README.md +Node-kind: file +Node-action: add +Text-content-md5: fe6869009516b5517b13036294d05f83 +Text-content-sha1: 4e51754688703c31980541dbbb884671d92cf846 +Prop-content-length: 10 +Text-content-length: 9 +Content-length: 19 + +PROPS-END +# dump_me + +Revision-number: 2 +Prop-content-length: 106 +Content-length: 106 + +K 10 +svn:author +V 8 +scmadmin +K 8 +svn:date +V 27 +2020-12-09T13:42:38.170000Z +K 7 +svn:log +V 4 +test +PROPS-END + +Node-path: trunk/second_one.txt +Node-kind: file +Node-action: add +Text-content-md5: 098f6bcd4621d373cade4e832627b4f6 +Text-content-sha1: a94a8fe5ccb19ba61c4c0873d391e987982fbbd3 +Prop-content-length: 10 +Text-content-length: 4 +Content-length: 14 + +PROPS-END +test + diff --git a/scm-webapp/src/test/resources/sonia/scm/api/v2/svn.dump.gz b/scm-webapp/src/test/resources/sonia/scm/api/v2/svn.dump.gz new file mode 100644 index 0000000000000000000000000000000000000000..2ca3731608900f7763802acf7e000e7206a33bc0 GIT binary patch literal 487 zcmV)s; zO{GA8$h9{mfQO1mKoCI?6NvO3rZpoq7BeL^Lg+hKt-q~q;bJ+T;Nod6CTs*g)&??0 zC$!m@P1_CdL5XXOZ&Lm%S!zZkI?1}J#=iv?)+1AT<*svOl%6dgc1|u6d&Ids%Cg{Qy3_rFMryLt3C`e z{bQTMth|)}$qZ=~g>{|@f<2Yg86Q|sln5d%i()D>qnS}LaOW)6)(GsFLrPLlH4b`s djfzBTJ^i=LCLSU?&+Ie|@C&=F(D<#PZ4 literal 0 HcmV?d00001 From 097237734ecfa0d0ec19a16c59c58a84e43b8bf8 Mon Sep 17 00:00:00 2001 From: Eduard Heimbuch Date: Thu, 10 Dec 2020 10:36:08 +0100 Subject: [PATCH 2/9] Append import bundle link to repository type if unbundle command is supported --- ...positoryTypeToRepositoryTypeDtoMapper.java | 9 +++++-- .../scm/api/v2/resources/ResourceLinks.java | 4 +++ ...toryTypeToRepositoryTypeDtoMapperTest.java | 27 +++++++++++++++++++ 3 files changed, 38 insertions(+), 2 deletions(-) diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryTypeToRepositoryTypeDtoMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryTypeToRepositoryTypeDtoMapper.java index 7822589dd9..de58b7e153 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryTypeToRepositoryTypeDtoMapper.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/RepositoryTypeToRepositoryTypeDtoMapper.java @@ -47,8 +47,13 @@ public abstract class RepositoryTypeToRepositoryTypeDtoMapper extends BaseMapper void appendLinks(RepositoryType repositoryType, @MappingTarget RepositoryTypeDto target) { Links.Builder linksBuilder = linkingTo().self(resourceLinks.repositoryType().self(repositoryType.getName())); - if (RepositoryPermissions.create().isPermitted() && repositoryType.getSupportedCommands().contains(Command.PULL)) { - linksBuilder.array(Link.linkBuilder("import", resourceLinks.repository().importFromUrl(repositoryType.getName())).withName("url").build()); + if (RepositoryPermissions.create().isPermitted()) { + if (repositoryType.getSupportedCommands().contains(Command.PULL)) { + linksBuilder.array(Link.linkBuilder("import", resourceLinks.repository().importFromUrl(repositoryType.getName())).withName("url").build()); + } + if (repositoryType.getSupportedCommands().contains(Command.UNBUNDLE)) { + linksBuilder.array(Link.linkBuilder("import", resourceLinks.repository().importFromBundle(repositoryType.getName())).withName("bundle").build()); + } } target.add(linksBuilder.build()); 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 553a83ee2e..6aade4f134 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 @@ -360,6 +360,10 @@ class ResourceLinks { String importFromUrl(String type) { return repositoryImportLinkBuilder.method("getRepositoryImportResource").parameters().method("importFromUrl").parameters(type).href(); } + + String importFromBundle(String type) { + return repositoryImportLinkBuilder.method("getRepositoryImportResource").parameters().method("importFromBundle").parameters(type).href(); + } } RepositoryCollectionLinks repositoryCollection() { diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryTypeToRepositoryTypeDtoMapperTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryTypeToRepositoryTypeDtoMapperTest.java index ea49c9a933..0041ef1332 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryTypeToRepositoryTypeDtoMapperTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/RepositoryTypeToRepositoryTypeDtoMapperTest.java @@ -112,4 +112,31 @@ public class RepositoryTypeToRepositoryTypeDtoMapperTest { RepositoryTypeDto dto = mapper.map(type); assertFalse(dto.getLinks().getLinkBy("import").isPresent()); } + + @Test + public void shouldAppendImportFromBundleLink() { + RepositoryType type = new RepositoryType("hk", "Hitchhiker", ImmutableSet.of(Command.UNBUNDLE)); + when(subject.isPermitted("repository:create")).thenReturn(true); + + RepositoryTypeDto dto = mapper.map(type); + assertEquals( + "https://scm-manager.org/scm/v2/repositories/import/hk/bundle", + dto.getLinks().getLinkBy("import").get().getHref() + ); + } + + @Test + public void shouldNotAppendImportFromBundleLinkIfCommandNotSupported() { + when(subject.isPermitted("repository:create")).thenReturn(true); + RepositoryTypeDto dto = mapper.map(type); + assertFalse(dto.getLinks().getLinkBy("import").isPresent()); + } + + @Test + public void shouldNotAppendImportFromBundleLinkIfNotPermitted() { + RepositoryType type = new RepositoryType("hk", "Hitchhiker", ImmutableSet.of(Command.UNBUNDLE)); + + RepositoryTypeDto dto = mapper.map(type); + assertFalse(dto.getLinks().getLinkBy("import").isPresent()); + } } From 6886f09f2659967dc3f831911b0755c1d3d23125 Mon Sep 17 00:00:00 2001 From: Eduard Heimbuch Date: Thu, 10 Dec 2020 13:55:03 +0100 Subject: [PATCH 3/9] create fileUpload component --- scm-ui/ui-components/src/forms/FileUpload.tsx | 64 +++++++++++++++++++ scm-ui/ui-components/src/forms/index.ts | 1 + .../ui-webapp/public/locales/de/commons.json | 3 + .../ui-webapp/public/locales/en/commons.json | 3 + 4 files changed, 71 insertions(+) create mode 100644 scm-ui/ui-components/src/forms/FileUpload.tsx diff --git a/scm-ui/ui-components/src/forms/FileUpload.tsx b/scm-ui/ui-components/src/forms/FileUpload.tsx new file mode 100644 index 0000000000..44eeaf0000 --- /dev/null +++ b/scm-ui/ui-components/src/forms/FileUpload.tsx @@ -0,0 +1,64 @@ +/* + * 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 React, { FC, useState, ChangeEvent } from "react"; +import { useTranslation } from "react-i18next"; +import { File } from "@scm-manager/ui-types"; + +type Props = { + handleFile: (file: File) => void; +}; + +const FileUpload: FC = ({ handleFile }) => { + const [t] = useTranslation("commons"); + const [file, setFile] = useState(null); + + return ( +
+ +
+ ); +}; + +export default FileUpload; diff --git a/scm-ui/ui-components/src/forms/index.ts b/scm-ui/ui-components/src/forms/index.ts index de64d40aae..49f2eb2dc6 100644 --- a/scm-ui/ui-components/src/forms/index.ts +++ b/scm-ui/ui-components/src/forms/index.ts @@ -38,3 +38,4 @@ export { default as Textarea } from "./Textarea"; export { default as PasswordConfirmation } from "./PasswordConfirmation"; export { default as LabelWithHelpIcon } from "./LabelWithHelpIcon"; export { default as DropDown } from "./DropDown"; +export { default as FileUpload } from "./FileUpload"; diff --git a/scm-ui/ui-webapp/public/locales/de/commons.json b/scm-ui/ui-webapp/public/locales/de/commons.json index 3fbbe0e3b2..fb9f17c2cb 100644 --- a/scm-ui/ui-webapp/public/locales/de/commons.json +++ b/scm-ui/ui-webapp/public/locales/de/commons.json @@ -110,5 +110,8 @@ }, "commaSeparatedList": { "lastDivider": "und" + }, + "fileUpload": { + "label": "Datei hochladen" } } diff --git a/scm-ui/ui-webapp/public/locales/en/commons.json b/scm-ui/ui-webapp/public/locales/en/commons.json index ac98e39183..9ed828035f 100644 --- a/scm-ui/ui-webapp/public/locales/en/commons.json +++ b/scm-ui/ui-webapp/public/locales/en/commons.json @@ -111,5 +111,8 @@ }, "commaSeparatedList": { "lastDivider": "and" + }, + "fileUpload": { + "label": "Upload file" } } From 45bb6d3ae9b68a061e44a0263628b3a2983d2f3c Mon Sep 17 00:00:00 2001 From: Eduard Heimbuch Date: Thu, 10 Dec 2020 13:56:20 +0100 Subject: [PATCH 4/9] Make getter for archive in UnbundleCommandRequest public to resolve classloading errors between pluginClassLoader and webappClassLoader --- .../java/sonia/scm/repository/spi/UnbundleCommandRequest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scm-core/src/main/java/sonia/scm/repository/spi/UnbundleCommandRequest.java b/scm-core/src/main/java/sonia/scm/repository/spi/UnbundleCommandRequest.java index 31a99ad887..202fee4a5b 100644 --- a/scm-core/src/main/java/sonia/scm/repository/spi/UnbundleCommandRequest.java +++ b/scm-core/src/main/java/sonia/scm/repository/spi/UnbundleCommandRequest.java @@ -89,7 +89,7 @@ public final class UnbundleCommandRequest * * @return {@link ByteSource} archive */ - ByteSource getArchive() + public ByteSource getArchive() { return archive; } From 2e505d5cfb92283485186a3af6d8774ba37fff8e Mon Sep 17 00:00:00 2001 From: Eduard Heimbuch Date: Thu, 10 Dec 2020 13:57:21 +0100 Subject: [PATCH 5/9] fix reading inputstream from input form --- .../resources/RepositoryImportResource.java | 24 ++++++++----------- 1 file changed, 10 insertions(+), 14 deletions(-) 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 17dd47352e..e48ad3d000 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 @@ -47,8 +47,6 @@ 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.BadRequestException; -import sonia.scm.ContextEntry; import sonia.scm.HandlerEventType; import sonia.scm.Type; import sonia.scm.event.ScmEventBus; @@ -83,12 +81,10 @@ import javax.ws.rs.core.Context; import javax.ws.rs.core.MediaType; import javax.ws.rs.core.Response; import javax.ws.rs.core.UriInfo; -import java.io.ByteArrayInputStream; import java.io.File; import java.io.IOException; import java.io.InputStream; import java.net.URI; -import java.nio.charset.StandardCharsets; import java.util.List; import java.util.Map; import java.util.Set; @@ -217,10 +213,10 @@ public class RepositoryImportResource { * method will return a location header with the url to the imported * repository. * - * @param uriInfo uri info - * @param type repository type - * @param input multi part form data which should contain a valid repository dto and the input stream of the bundle - * @param compressed true if the bundle is gzip compressed + * @param uriInfo uri info + * @param type repository type + * @param input multi part form data which should contain a valid repository dto and the input stream of the bundle + * @param compressed true if the bundle is gzip compressed * @return empty response with location header which points to the imported * repository * @since 2.12.0 @@ -262,9 +258,9 @@ public class RepositoryImportResource { /** * Start bundle import. * - * @param type repository type - * @param input multi part form data - * @param compressed true if the bundle is gzip compressed + * @param type repository type + * @param input multi part form data + * @param compressed true if the bundle is gzip compressed * @return imported repository */ private Repository doImportFromBundle(String type, MultipartFormDataInput input, boolean compressed) { @@ -324,15 +320,15 @@ public class RepositoryImportResource { private T extractFromInputPart(List input, Class type) { try { if (input != null && !input.isEmpty()) { + if (type == InputStream.class) { + return (T) ((MultipartInputImpl.PartImpl) input.get(0)).getBody(); + } String content = new ByteSource() { @Override public InputStream openStream() throws IOException { return ((MultipartInputImpl.PartImpl) input.get(0)).getBody(); } }.asCharSource(UTF_8).read(); - if (type == InputStream.class) { - return (T) new ByteArrayInputStream(StandardCharsets.UTF_8.encode(content).array()); - } try (JsonParser parser = new JsonFactory().createParser(content)) { parser.setCodec(new ObjectMapper()); return parser.readValueAs(type); From ed940d5f23367f0e4f636d556ad72a928cb52756 Mon Sep 17 00:00:00 2001 From: Eduard Heimbuch Date: Thu, 10 Dec 2020 13:58:47 +0100 Subject: [PATCH 6/9] Create form for import from bundle --- scm-ui/ui-webapp/public/locales/de/repos.json | 4 + scm-ui/ui-webapp/public/locales/en/repos.json | 4 + .../components/ImportRepositoryFromBundle.tsx | 117 ++++++++++++++++++ .../src/repos/containers/ImportRepository.tsx | 19 ++- 4 files changed, 140 insertions(+), 4 deletions(-) create mode 100644 scm-ui/ui-webapp/src/repos/components/ImportRepositoryFromBundle.tsx diff --git a/scm-ui/ui-webapp/public/locales/de/repos.json b/scm-ui/ui-webapp/public/locales/de/repos.json index 50631133a4..da37603fdd 100644 --- a/scm-ui/ui-webapp/public/locales/de/repos.json +++ b/scm-ui/ui-webapp/public/locales/de/repos.json @@ -75,6 +75,10 @@ "url": { "label": "Import via URL", "helpText": "Das Repository wird über eine URL importiert." + }, + "bundle": { + "label": "Import aus Dump", + "helpText": "Das Repository wird aus einen Datei Dump importiert." } } }, diff --git a/scm-ui/ui-webapp/public/locales/en/repos.json b/scm-ui/ui-webapp/public/locales/en/repos.json index b44214b8e9..a795532bf2 100644 --- a/scm-ui/ui-webapp/public/locales/en/repos.json +++ b/scm-ui/ui-webapp/public/locales/en/repos.json @@ -76,6 +76,10 @@ "url": { "label": "Import via URL", "helpText": "The Repository will be imported via the provided URL." + }, + "bundle": { + "label": "Import from dump", + "helpText": "The repository will be imported from a dump file." } } }, diff --git a/scm-ui/ui-webapp/src/repos/components/ImportRepositoryFromBundle.tsx b/scm-ui/ui-webapp/src/repos/components/ImportRepositoryFromBundle.tsx new file mode 100644 index 0000000000..203ca9b243 --- /dev/null +++ b/scm-ui/ui-webapp/src/repos/components/ImportRepositoryFromBundle.tsx @@ -0,0 +1,117 @@ +/* + * 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 React, { FC, FormEvent, useState } from "react"; +import NamespaceAndNameFields from "./NamespaceAndNameFields"; +import {File, Repository} from "@scm-manager/ui-types"; +import RepositoryInformationForm from "./RepositoryInformationForm"; +import { apiClient, ErrorNotification, FileUpload, Level, SubmitButton } from "@scm-manager/ui-components"; +import { useTranslation } from "react-i18next"; +import { useHistory } from "react-router-dom"; + +type Props = { + url: string; + repositoryType: string; + setImportPending: (pending: boolean) => void; +}; + +const ImportRepositoryFromBundle: FC = ({ url, repositoryType, setImportPending }) => { + const [repo, setRepo] = useState({ + name: "", + namespace: "", + type: repositoryType, + contact: "", + description: "", + _links: {}, + }); + + const [valid, setValid] = useState({ namespaceAndName: false, contact: true, file: false }); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(); + const [file, setFile] = useState(null); + const history = useHistory(); + const [t] = useTranslation("repos"); + + const handleImportLoading = (loading: boolean) => { + setImportPending(loading); + setLoading(loading); + }; + + const isValid = () => Object.values(valid).every((v) => v); + + const submit = (event: FormEvent) => { + event.preventDefault(); + const currentPath = history.location.pathname; + setError(undefined); + handleImportLoading(true); + apiClient + .postBinary(url, (formdata) => { + formdata.append("bundle", file!, file?.name); + formdata.append("repository", JSON.stringify(repo)); + }) + .then((response) => { + const location = response.headers.get("Location"); + return apiClient.get(location!); + }) + .then((response) => response.json()) + .then((repo) => { + if (history.location.pathname === currentPath) { + history.push(`/repo/${repo.namespace}/${repo.name}/code/sources`); + } + }) + .catch((error) => { + setError(error); + handleImportLoading(false); + }); + }; + + return ( +
+ + { + setFile(file); + setValid({ ...valid, file: !!file }); + }} + /> +
+ >} + setValid={(namespaceAndName: boolean) => setValid({ ...valid, namespaceAndName })} + disabled={loading} + /> + >} + disabled={loading} + setValid={(contact: boolean) => setValid({ ...valid, contact })} + /> + } + /> + + ); +}; + +export default ImportRepositoryFromBundle; diff --git a/scm-ui/ui-webapp/src/repos/containers/ImportRepository.tsx b/scm-ui/ui-webapp/src/repos/containers/ImportRepository.tsx index b77ed1925b..730f207ec3 100644 --- a/scm-ui/ui-webapp/src/repos/containers/ImportRepository.tsx +++ b/scm-ui/ui-webapp/src/repos/containers/ImportRepository.tsx @@ -35,10 +35,11 @@ import { fetchRepositoryTypesIfNeeded, getFetchRepositoryTypesFailure, getRepositoryTypes, - isFetchRepositoryTypesPending + isFetchRepositoryTypesPending, } from "../modules/repositoryTypes"; import { connect } from "react-redux"; import { fetchNamespaceStrategiesIfNeeded } from "../../admin/modules/namespaceStrategies"; +import ImportRepositoryFromBundle from "../components/ImportRepositoryFromBundle"; type Props = { repositoryTypes: RepositoryType[]; @@ -67,7 +68,7 @@ const ImportRepository: FC = ({ pageLoading, error, fetchRepositoryTypesIfNeeded, - fetchNamespaceStrategiesIfNeeded + fetchNamespaceStrategiesIfNeeded, }) => { const [importPending, setImportPending] = useState(false); const [repositoryType, setRepositoryType] = useState(); @@ -95,6 +96,16 @@ const ImportRepository: FC = ({ ); } + if (importType === "bundle") { + return ( + link.name === "bundle") as Link).href} + repositoryType={repositoryType!.name} + setImportPending={setImportPending} + /> + ); + } + throw new Error("Unknown import type"); }; @@ -139,7 +150,7 @@ const mapStateToProps = (state: any) => { return { repositoryTypes, pageLoading, - error + error, }; }; @@ -150,7 +161,7 @@ const mapDispatchToProps = (dispatch: any) => { }, fetchNamespaceStrategiesIfNeeded: () => { dispatch(fetchNamespaceStrategiesIfNeeded()); - } + }, }; }; From e8dd7dc68d32aeae3b344a93aef7c70b2adc9dc3 Mon Sep 17 00:00:00 2001 From: Eduard Heimbuch Date: Thu, 10 Dec 2020 14:46:47 +0100 Subject: [PATCH 7/9] Add compress checkbox to ImportFromBundleForm --- scm-ui/ui-webapp/public/locales/de/repos.json | 8 +++ scm-ui/ui-webapp/public/locales/en/repos.json | 10 ++- .../repos/components/ImportFromBundleForm.tsx | 66 +++++++++++++++++++ .../components/ImportRepositoryFromBundle.tsx | 23 ++++--- 4 files changed, 96 insertions(+), 11 deletions(-) create mode 100644 scm-ui/ui-webapp/src/repos/components/ImportFromBundleForm.tsx diff --git a/scm-ui/ui-webapp/public/locales/de/repos.json b/scm-ui/ui-webapp/public/locales/de/repos.json index da37603fdd..9edd41b43c 100644 --- a/scm-ui/ui-webapp/public/locales/de/repos.json +++ b/scm-ui/ui-webapp/public/locales/de/repos.json @@ -66,6 +66,14 @@ "importUrl": "Remote Repository URL", "username": "Benutzername", "password": "Passwort", + "compressed": { + "label": "Komprimiert", + "helpText": "Anwählen, wenn die Datei komprimiert ist." + }, + "bundle": { + "title": "Wählen Sie Ihre Datei aus", + "helpText": "Wählen Sie die Datei aus der das Repository importiert werden soll." + }, "pending": { "subtitle": "Repository wird importiert...", "infoText": "Ihr Repository wird gerade importiert. Dies kann einen Moment dauern. Sie werden weitergeleitet, sobald der Import abgeschlossen ist. Wenn Sie diese Seite verlassen, können Sie nicht zurückkehren, um den Import-Status zu erfahren." diff --git a/scm-ui/ui-webapp/public/locales/en/repos.json b/scm-ui/ui-webapp/public/locales/en/repos.json index a795532bf2..cabb9ea440 100644 --- a/scm-ui/ui-webapp/public/locales/en/repos.json +++ b/scm-ui/ui-webapp/public/locales/en/repos.json @@ -67,6 +67,14 @@ "importUrl": "Remote repository url", "username": "Username", "password": "Password", + "compressed": { + "label": "Compressed", + "helpText": "Check if your dump file is compressed." + }, + "bundle": { + "title": "Select your dump file", + "helpText": "Select your dump file from which the repository should be imported." + }, "pending": { "subtitle": "Importing Repository...", "infoText": "Your repository is currently being imported. This may take a moment. You will be forwarded as soon as the import is finished. If you leave this page you cannot return to find out the import status." @@ -202,7 +210,7 @@ "sources": "Sources" }, "parents": { - "label" : "Parent", + "label": "Parent", "label_plural": "Parents" }, "contributors": { diff --git a/scm-ui/ui-webapp/src/repos/components/ImportFromBundleForm.tsx b/scm-ui/ui-webapp/src/repos/components/ImportFromBundleForm.tsx new file mode 100644 index 0000000000..ba5da91934 --- /dev/null +++ b/scm-ui/ui-webapp/src/repos/components/ImportFromBundleForm.tsx @@ -0,0 +1,66 @@ +/* + * 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 React, { FC } from "react"; +import { FileUpload, LabelWithHelpIcon, Checkbox } from "@scm-manager/ui-components"; +import { File } from "@scm-manager/ui-types"; +import { useTranslation } from "react-i18next"; + +type Props = { + setFile: (file: File) => void; + setValid: (valid: boolean) => void; + compressed: boolean; + setCompressed: (compressed: boolean) => void; + disabled: boolean; +}; + +const ImportFromBundleForm: FC = ({ setFile, setValid, compressed, setCompressed, disabled }) => { + const [t] = useTranslation("repos"); + + return ( +
+
+ + { + setFile(file); + setValid(!!file); + }} + /> +
+
+ setCompressed(value)} + label={t("import.compressed.label")} + disabled={disabled} + helpText={t("import.compressed.helpText")} + title={t("import.compressed.label")} + /> +
+
+ ); +}; + +export default ImportFromBundleForm; diff --git a/scm-ui/ui-webapp/src/repos/components/ImportRepositoryFromBundle.tsx b/scm-ui/ui-webapp/src/repos/components/ImportRepositoryFromBundle.tsx index 203ca9b243..e28eb0b894 100644 --- a/scm-ui/ui-webapp/src/repos/components/ImportRepositoryFromBundle.tsx +++ b/scm-ui/ui-webapp/src/repos/components/ImportRepositoryFromBundle.tsx @@ -23,11 +23,12 @@ */ import React, { FC, FormEvent, useState } from "react"; import NamespaceAndNameFields from "./NamespaceAndNameFields"; -import {File, Repository} from "@scm-manager/ui-types"; +import { File, Repository } from "@scm-manager/ui-types"; import RepositoryInformationForm from "./RepositoryInformationForm"; -import { apiClient, ErrorNotification, FileUpload, Level, SubmitButton } from "@scm-manager/ui-components"; +import { apiClient, ErrorNotification, Level, SubmitButton } from "@scm-manager/ui-components"; import { useTranslation } from "react-i18next"; import { useHistory } from "react-router-dom"; +import ImportFromBundleForm from "./ImportFromBundleForm"; type Props = { url: string; @@ -49,6 +50,7 @@ const ImportRepositoryFromBundle: FC = ({ url, repositoryType, setImportP const [loading, setLoading] = useState(false); const [error, setError] = useState(); const [file, setFile] = useState(null); + const [compressed, setCompressed] = useState(false); const history = useHistory(); const [t] = useTranslation("repos"); @@ -65,9 +67,9 @@ const ImportRepositoryFromBundle: FC = ({ url, repositoryType, setImportP setError(undefined); handleImportLoading(true); apiClient - .postBinary(url, (formdata) => { - formdata.append("bundle", file!, file?.name); - formdata.append("repository", JSON.stringify(repo)); + .postBinary(compressed ? url + "?compressed=true" : url, (formData) => { + formData.append("bundle", file, file?.name); + formData.append("repository", JSON.stringify(repo)); }) .then((response) => { const location = response.headers.get("Location"); @@ -88,11 +90,12 @@ const ImportRepositoryFromBundle: FC = ({ url, repositoryType, setImportP return (
- { - setFile(file); - setValid({ ...valid, file: !!file }); - }} + setValid({ ...valid, file })} + compressed={compressed} + setCompressed={setCompressed} + disabled={loading} />
Date: Thu, 10 Dec 2020 16:06:12 +0100 Subject: [PATCH 8/9] update Changelog --- CHANGELOG.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ecadfe5023..d05f1782a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,8 +5,11 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [2.11.1] - 2020-12-07 +## Unreleased +### Added +- Add repository import via dump file for Subversion ([#1471](https://github.com/scm-manager/scm-manager/pull/1471)) +## [2.11.1] - 2020-12-07 ### Fixed - Initialization of new git repository with master set as default branch ([#1467](https://github.com/scm-manager/scm-manager/issues/1467) and [#1470](https://github.com/scm-manager/scm-manager/pull/1470)) From 821cca2a06c65b727fbb812fd49f379f67df3e29 Mon Sep 17 00:00:00 2001 From: Eduard Heimbuch Date: Thu, 10 Dec 2020 16:22:39 +0100 Subject: [PATCH 9/9] cleanup --- scm-ui/ui-components/src/forms/FileUpload.tsx | 4 +-- .../resources/RepositoryRootResourceTest.java | 31 +++++++++++++++---- .../scm/api/v2/import-repo-from-bundle.json | 7 ----- 3 files changed, 27 insertions(+), 15 deletions(-) delete mode 100644 scm-webapp/src/test/resources/sonia/scm/api/v2/import-repo-from-bundle.json diff --git a/scm-ui/ui-components/src/forms/FileUpload.tsx b/scm-ui/ui-components/src/forms/FileUpload.tsx index 44eeaf0000..1a7dafcded 100644 --- a/scm-ui/ui-components/src/forms/FileUpload.tsx +++ b/scm-ui/ui-components/src/forms/FileUpload.tsx @@ -43,9 +43,9 @@ const FileUpload: FC = ({ handleFile }) => { name="resume" onChange={(event: ChangeEvent) => { const uploadedFile = event?.target?.files![0]; - // @ts-ignore + // @ts-ignore the uploaded file doesn't match our types setFile(uploadedFile); - // @ts-ignore + // @ts-ignore the uploaded file doesn't match our types handleFile(uploadedFile); }} /> 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 26f828571b..52301ef3a9 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 @@ -41,7 +41,6 @@ import org.mockito.ArgumentCaptor; import org.mockito.Captor; import org.mockito.InjectMocks; import org.mockito.Mock; -import sonia.scm.HandlerEventType; import sonia.scm.PageResult; import sonia.scm.config.ScmConfiguration; import sonia.scm.event.ScmEventBus; @@ -67,7 +66,6 @@ import sonia.scm.web.RestDispatcher; 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; @@ -100,6 +98,7 @@ 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.anyBoolean; import static org.mockito.ArgumentMatchers.anyMap; import static org.mockito.ArgumentMatchers.anyObject; import static org.mockito.ArgumentMatchers.anyString; @@ -641,17 +640,37 @@ public class RepositoryRootResourceTest extends RepositoryTestBase { URL dumpUrl = Resources.getResource("sonia/scm/api/v2/svn.dump.gz"); byte[] svnDump = Resources.toByteArray(dumpUrl); - UnbundleCommandBuilder ubc = mock(UnbundleCommandBuilder.class); - when(ubc.setCompressed(any())).thenReturn(ubc); + 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); - repositoryImportResource.unbundleImport(in, true); + + Consumer repositoryConsumer = repositoryImportResource.unbundleImport(in, true); + repositoryConsumer.accept(RepositoryTestData.createHeartOfGold("svn")); verify(ubc).setCompressed(true); - //TODO Enhance test + 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)); } private PageResult createSingletonPageResult(Repository repository) { diff --git a/scm-webapp/src/test/resources/sonia/scm/api/v2/import-repo-from-bundle.json b/scm-webapp/src/test/resources/sonia/scm/api/v2/import-repo-from-bundle.json deleted file mode 100644 index 5bc573f2a8..0000000000 --- a/scm-webapp/src/test/resources/sonia/scm/api/v2/import-repo-from-bundle.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "contact": "none@example.com", - "description": "Test repository", - "namespace": "space", - "name": "repo", - "type": "svn" -}