add repository import via dump for subversion

Subversion repositories can be imported from dump files (backups). Just upload your dump file and check if your file is compressed on the import form for Subversion. The repository will be imported synchronously and you will be redirected to the new repository after the import is finished.
This commit is contained in:
Konstantin Schaper
2020-12-14 11:46:35 +01:00
committed by GitHub
18 changed files with 733 additions and 10 deletions

View File

@@ -7,10 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## Unreleased
### Added
- Add repository import via dump file for Subversion ([#1471](https://github.com/scm-manager/scm-manager/pull/1471))
- Add support for permalinks to lines in source code view ([#1472](https://github.com/scm-manager/scm-manager/pull/1472))
## [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))

View File

@@ -89,7 +89,7 @@ public final class UnbundleCommandRequest
*
* @return {@link ByteSource} archive
*/
ByteSource getArchive()
public ByteSource getArchive()
{
return archive;
}

View File

@@ -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<Props> = ({ handleFile }) => {
const [t] = useTranslation("commons");
const [file, setFile] = useState<File | null>(null);
return (
<div className="file is-info has-name is-fullwidth">
<label className="file-label">
<input
className="file-input"
type="file"
name="resume"
onChange={(event: ChangeEvent<HTMLInputElement>) => {
const uploadedFile = event?.target?.files![0];
// @ts-ignore the uploaded file doesn't match our types
setFile(uploadedFile);
// @ts-ignore the uploaded file doesn't match our types
handleFile(uploadedFile);
}}
/>
<span className="file-cta">
<span className="file-icon">
<i className="fas fa-upload" />
</span>
<span className="file-label">{t("fileUpload.label")}</span>
</span>
<span className="file-name">{file?.name || ""}</span>
</label>
</div>
);
};
export default FileUpload;

View File

@@ -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";

View File

@@ -110,5 +110,8 @@
},
"commaSeparatedList": {
"lastDivider": "und"
},
"fileUpload": {
"label": "Datei hochladen"
}
}

View File

@@ -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."
@@ -75,6 +83,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."
}
}
},

View File

@@ -111,5 +111,8 @@
},
"commaSeparatedList": {
"lastDivider": "and"
},
"fileUpload": {
"label": "Upload file"
}
}

View File

@@ -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."
@@ -76,6 +84,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."
}
}
},
@@ -198,7 +210,7 @@
"sources": "Sources"
},
"parents": {
"label" : "Parent",
"label": "Parent",
"label_plural": "Parents"
},
"contributors": {

View File

@@ -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<Props> = ({ setFile, setValid, compressed, setCompressed, disabled }) => {
const [t] = useTranslation("repos");
return (
<div className="columns">
<div className="column is-half is-vcentered">
<LabelWithHelpIcon label={t("import.bundle.title")} helpText={t("import.bundle.helpText")} />
<FileUpload
handleFile={(file: File) => {
setFile(file);
setValid(!!file);
}}
/>
</div>
<div className="column is-half is-vcentered">
<Checkbox
checked={compressed}
onChange={(value, name) => setCompressed(value)}
label={t("import.compressed.label")}
disabled={disabled}
helpText={t("import.compressed.helpText")}
title={t("import.compressed.label")}
/>
</div>
</div>
);
};
export default ImportFromBundleForm;

View File

@@ -0,0 +1,120 @@
/*
* 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, 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;
repositoryType: string;
setImportPending: (pending: boolean) => void;
};
const ImportRepositoryFromBundle: FC<Props> = ({ url, repositoryType, setImportPending }) => {
const [repo, setRepo] = useState<Repository>({
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<Error | undefined>();
const [file, setFile] = useState<File | null>(null);
const [compressed, setCompressed] = useState(false);
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<HTMLFormElement>) => {
event.preventDefault();
const currentPath = history.location.pathname;
setError(undefined);
handleImportLoading(true);
apiClient
.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");
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 (
<form onSubmit={submit}>
<ErrorNotification error={error} />
<ImportFromBundleForm
setFile={setFile}
setValid={(file: boolean) => setValid({ ...valid, file })}
compressed={compressed}
setCompressed={setCompressed}
disabled={loading}
/>
<hr />
<NamespaceAndNameFields
repository={repo}
onChange={setRepo as React.Dispatch<React.SetStateAction<Repository>>}
setValid={(namespaceAndName: boolean) => setValid({ ...valid, namespaceAndName })}
disabled={loading}
/>
<RepositoryInformationForm
repository={repo}
onChange={setRepo as React.Dispatch<React.SetStateAction<Repository>>}
disabled={loading}
setValid={(contact: boolean) => setValid({ ...valid, contact })}
/>
<Level
right={<SubmitButton disabled={!isValid()} loading={loading} label={t("repositoryForm.submitImport")} />}
/>
</form>
);
};
export default ImportRepositoryFromBundle;

View File

@@ -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<Props> = ({
pageLoading,
error,
fetchRepositoryTypesIfNeeded,
fetchNamespaceStrategiesIfNeeded
fetchNamespaceStrategiesIfNeeded,
}) => {
const [importPending, setImportPending] = useState(false);
const [repositoryType, setRepositoryType] = useState<RepositoryType | undefined>();
@@ -95,6 +96,16 @@ const ImportRepository: FC<Props> = ({
);
}
if (importType === "bundle") {
return (
<ImportRepositoryFromBundle
url={((repositoryType!._links.import as Link[])!.find((link: Link) => 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());
}
},
};
};

View File

@@ -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,6 +42,9 @@ 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.HandlerEventType;
@@ -54,6 +62,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 +71,28 @@ 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.File;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
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 +207,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<String, List<InputPart>> 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<Repository> 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> T extractFromInputPart(List<InputPart> input, Class<T> 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();
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 +393,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();
}
}

View File

@@ -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());

View File

@@ -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() {

View File

@@ -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;
@@ -39,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;
@@ -58,17 +59,28 @@ 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 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;
@@ -86,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;
@@ -565,6 +578,101 @@ 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<RepositoryImportEvent> 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<RepositoryImportEvent> 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<Repository> 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<Repository> repositoryConsumer = repositoryImportResource.unbundleImport(in, false);
repositoryConsumer.accept(RepositoryTestData.createHeartOfGold("svn"));
verify(ubc, never()).setCompressed(true);
verify(ubc).unbundle(any(File.class));
}
private PageResult<Repository> createSingletonPageResult(Repository repository) {
return new PageResult<>(singletonList(repository), 0);
}
@@ -586,4 +694,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<String, InputStream> 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<String, InputStream> 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;
}
}

View File

@@ -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());
}
}

View File

@@ -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