Add ConfigurationAdapterBase and extension points for trash bin

Adds the new abstract class ConfigurationAdapterBase to simplify the creation of global configuration views. In addition there is some cleanup, interfaces and extension points for the repository trash bin plugin.

Committed-by: Eduard Heimbuch <eduard.heimbuch@cloudogu.com>
Co-authored-by: Eduard Heimbuch <eduard.heimbuch@cloudogu.com>
Co-authored-by: René Pfeuffer <rene.pfeuffer@cloudogu.com>
Co-authored-by: Konstantin Schaper <konstantin.schaper@cloudogu.com>
This commit is contained in:
Eduard Heimbuch
2023-01-12 14:01:04 +01:00
parent 5c4c759bd2
commit ac419daa3f
34 changed files with 879 additions and 84 deletions

2
Jenkinsfile vendored
View File

@@ -88,7 +88,7 @@ pipeline {
sh 'git fetch origin develop'
script {
withSonarQubeEnv('sonarcloud.io-scm') {
String parameters = ' -Dsonar.organization=scm-manager'
String parameters = ' -Dsonar.organization=scm-manager -Dsonar.analysis.scmm-repo=scm-manager/scm-manager'
if (env.CHANGE_ID) {
parameters += ' -Dsonar.pullrequest.provider=GitHub'
parameters += ' -Dsonar.pullrequest.github.repository=scm-manager/scm-manager'

View File

@@ -0,0 +1,2 @@
- type: added
description: Add abstract configuration adapter to simply creating new global configurations

View File

@@ -0,0 +1,2 @@
- type: fixed
description: The 'revision to merge' in merge results

View File

@@ -0,0 +1,228 @@
/*
* 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.github.sdorra.ssp.PermissionCheck;
import com.google.common.annotations.Beta;
import de.otto.edison.hal.HalRepresentation;
import de.otto.edison.hal.Link;
import de.otto.edison.hal.Links;
import lombok.extern.slf4j.Slf4j;
import sonia.scm.config.ConfigurationPermissions;
import sonia.scm.store.ConfigurationStore;
import sonia.scm.store.ConfigurationStoreFactory;
import javax.inject.Provider;
import javax.validation.Valid;
import javax.validation.constraints.NotNull;
import javax.ws.rs.Consumes;
import javax.ws.rs.GET;
import javax.ws.rs.PUT;
import javax.ws.rs.Path;
import javax.ws.rs.Produces;
import javax.ws.rs.core.Context;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.UriInfo;
/**
* This can be used as a base class for configuration resources.
*
* @param <DAO> The class of the data access object used to persist the configuration.
* @param <DTO> The class of the data transfer objects used in the rest api.
* @since 2.41.0
*/
@Beta
@Slf4j
public abstract class ConfigurationAdapterBase<DAO, DTO extends HalRepresentation> implements HalEnricher {
protected final ConfigurationStoreFactory configurationStoreFactory;
protected final Provider<ScmPathInfoStore> scmPathInfoStoreProvider;
private final Class<DAO> daoClass;
private final DtoToDaoMapper<DTO, DAO> dtoToDaoMapper;
private final DaoToDtoMapper<DAO, DTO> daoToDtoMapper;
/**
* Creates the resource. To do so, you have to provide the {@link ConfigurationStoreFactory} and
* the {@link ScmPathInfoStore} as a {@link Provider} and implementations for
* the DAO and the DTO.
* <br>
* The DAO class has to have a default constructor that creates the default (initial)
* configuration.
* <br>
* The DTO class should be created with <code>@GenerateDto</code> annotation using Conveyor.
* If the implementation is done manually, it has to provide two methods:
* <ul>
* <li>A static method <code>DTO from(DAO, Links)</code> creating the DTO instance
* for the given DAO with the provided links.</li>
* <li>A method <code>DAO toEntity()</code> creating the DAO from the DTO.</li>
* </ul>
* If either one is missing, you will see {@link IllegalDaoClassException}s on your way.
* <br>
* The implementation may look like this:
* <pre>
* @Path("/v2/test")
* private static class TestConfigurationAdapter extends ConfigurationAdapterBase<TestConfiguration, TestConfigurationDto> {
*
* @Inject
* public TestConfigurationResource(ConfigurationStoreFactory configurationStoreFactory, Provider<ScmPathInfoStore> scmPathInfoStoreProvider) {
* super(configurationStoreFactory, scmPathInfoStoreProvider, TestConfiguration.class, TestConfigurationDto.class);
* }
*
* @Override
* protected String getName() {
* return "testConfig";
* }
* }
* </pre>
*
* @param configurationStoreFactory The configuration store factory provided from injection.
* @param scmPathInfoStoreProvider The path info store provider provided from injection.
* @param daoClass The DAO class instance.
* @param dtoClass The DTO class instance.
*/
@SuppressWarnings("unchecked")
protected ConfigurationAdapterBase(ConfigurationStoreFactory configurationStoreFactory,
Provider<ScmPathInfoStore> scmPathInfoStoreProvider,
Class<DAO> daoClass,
Class<DTO> dtoClass) {
this.configurationStoreFactory = configurationStoreFactory;
this.scmPathInfoStoreProvider = scmPathInfoStoreProvider;
this.daoClass = daoClass;
this.dtoToDaoMapper = (DTO dto) -> {
try {
return (DAO) dtoClass.getDeclaredMethod("toEntity").invoke(dto);
} catch (Exception e) {
throw new IllegalDtoClassException(e);
}
};
this.daoToDtoMapper = (DAO entity, Links.Builder linkBuilder) -> {
try {
return (DTO) dtoClass.getMethod("from", daoClass, Links.class)
.invoke(null, entity, linkBuilder.build());
} catch (Exception e) {
throw new IllegalDtoClassException(e);
}
};
}
public DAO getConfiguration() {
return getConfigStore().getOptional().orElse(createDefaultDaoInstance());
}
protected abstract String getName();
protected String getStoreName() {
return toKebap(getName());
}
@GET
@Produces(MediaType.APPLICATION_JSON)
@Path("")
public DTO get(@Context UriInfo uriInfo) {
getReadPermission().check();
return daoToDtoMapper.mapDaoToDto(getConfiguration(), createDtoLinks());
}
private DAO createDefaultDaoInstance() {
try {
return daoClass.getConstructor().newInstance();
} catch (Exception e) {
throw new IllegalDaoClassException(e);
}
}
private ConfigurationStore<DAO> getConfigStore() {
return configurationStoreFactory.withType(daoClass).withName(toKebap(getName())).build();
}
@PUT
@Consumes(MediaType.APPLICATION_JSON)
@Path("")
public void update(@NotNull @Valid DTO payload) {
getWritePermission().check();
getConfigStore().set(dtoToDaoMapper.mapDtoToDao(payload));
}
private Links.Builder createDtoLinks() {
Links.Builder builder = Links.linkingTo();
builder.single(Link.link("self", getReadLink()));
if (getWritePermission().isPermitted()) {
builder.single(Link.link("update", getUpdateLink()));
}
return builder;
}
private String getReadLink() {
LinkBuilder linkBuilder = new LinkBuilder(scmPathInfoStoreProvider.get().get(), this.getClass());
return linkBuilder.method("get").parameters().href();
}
private String getUpdateLink() {
LinkBuilder linkBuilder = new LinkBuilder(scmPathInfoStoreProvider.get().get(), this.getClass());
return linkBuilder.method("update").parameters().href();
}
@Override
public final void enrich(HalEnricherContext context, HalAppender appender) {
if (getReadPermission().isPermitted()) {
appender.appendLink(getName(), getReadLink());
}
}
protected PermissionCheck getReadPermission() {
return ConfigurationPermissions.read(getName());
}
protected PermissionCheck getWritePermission() {
return ConfigurationPermissions.write(getName());
}
private static class IllegalDtoClassException extends RuntimeException {
public IllegalDtoClassException(Throwable cause) {
super("Missing method #from(DAO, Links) or #toEntity() in DTO class; see JavaDoc for ConfigurationResourceBase", cause);
}
}
private static class IllegalDaoClassException extends RuntimeException {
public IllegalDaoClassException(Throwable cause) {
super("Missing default constructor in DAO class; see JavaDoc for ConfigurationResourceBase", cause);
}
}
private String toKebap(String other) {
return other.replaceAll("([a-z])([A-Z]+)", "$1-$2").toLowerCase();
}
private interface DaoToDtoMapper<DAO, DTO> {
DTO mapDaoToDto(DAO entity, Links.Builder linkBuilder);
}
private interface DtoToDaoMapper<DTO, DAO> {
DAO mapDtoToDao(DTO dto);
}
}

View File

@@ -0,0 +1,31 @@
/*
* 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.repository;
import java.io.OutputStream;
public interface FullRepositoryExporter {
void export(Repository repository, OutputStream outputStream, String password);
}

View File

@@ -0,0 +1,31 @@
/*
* 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.repository;
import java.io.InputStream;
public interface FullRepositoryImporter {
Repository importFromStream(Repository repository, InputStream inputStream, String password);
}

View File

@@ -87,7 +87,7 @@ public class GitMergeRebase extends GitMergeStrategy {
.include(branchToMerge, sourceRevision)
.call();
push();
return MergeCommandResult.success(getTargetRevision().name(), branchToMerge, sourceRevision.name());
return createSuccessResult(sourceRevision.name());
} catch (GitAPIException e) {
return MergeCommandResult.failure(branchToMerge, targetBranch, result.getConflicts());
}

View File

@@ -21,7 +21,7 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package sonia.scm.repository.spi;
import org.eclipse.jgit.api.Git;
@@ -51,7 +51,7 @@ class GitMergeWithSquash extends GitMergeStrategy {
if (result.getMergeStatus().isSuccessful()) {
RevCommit revCommit = doCommit().orElseThrow(() -> new NoChangesMadeException(getRepository()));
push();
return MergeCommandResult.success(getTargetRevision().name(), revCommit.name(), extractRevisionFromRevCommit(revCommit));
return createSuccessResult(extractRevisionFromRevCommit(revCommit));
} else {
return analyseFailure(result);
}

View File

@@ -296,7 +296,7 @@ public class GitMergeCommandTest extends AbstractGitCommandTestBase {
Repository repository = createContext().open();
assertThat(mergeCommandResult.isSuccess()).isTrue();
assertThat(mergeCommandResult.getRevisionToMerge()).isEqualTo(mergeCommandResult.getNewHeadRevision());
assertThat(mergeCommandResult.getRevisionToMerge()).isEqualTo("35597e9e98fe53167266583848bfef985c2adb27");
assertThat(mergeCommandResult.getTargetRevision()).isEqualTo("fcd0ef1831e4002ac43ea539f4094334c79ea9ec");
Iterable<RevCommit> commits = new Git(repository).log().add(repository.resolve("master")).setMaxCount(1).call();

View File

@@ -51,7 +51,7 @@ export const useConfigLink = <C extends HalRepresentation>(link: string) => {
const {
isLoading: isUpdating,
error: mutationError,
mutate,
mutateAsync,
data: updateResponse,
} = useMutation<Response, Error, MutationVariables<C>>(
(vars: MutationVariables<C>) => apiClient.put(vars.link, vars.configuration, vars.contentType),
@@ -67,7 +67,7 @@ export const useConfigLink = <C extends HalRepresentation>(link: string) => {
const update = useCallback(
(configuration: C) => {
if (data && !isReadOnly) {
mutate({
return mutateAsync({
configuration,
contentType: data.contentType,
link: (data.configuration._links.update as Link).href,
@@ -76,7 +76,7 @@ export const useConfigLink = <C extends HalRepresentation>(link: string) => {
},
// eslint means we should add C to the dependency array, but C is only a type
// eslint-disable-next-line react-hooks/exhaustive-deps
[mutate, data, isReadOnly]
[mutateAsync, data, isReadOnly]
);
return {

View File

@@ -1,7 +1,7 @@
{
"name": "@scm-manager/ui-buttons",
"version": "2.40.2-SNAPSHOT",
"private": true,
"private": false,
"main": "build/index.js",
"module": "build/index.mjs",
"types": "build/index.d.ts",

View File

@@ -27,6 +27,7 @@ import { Link as ReactRouterLink, LinkProps as ReactRouterLinkProps } from "reac
import classNames from "classnames";
import { createAttributesForTesting } from "@scm-manager/ui-components";
/** @Beta */
export const ButtonVariants = {
PRIMARY: "primary",
SECONDARY: "secondary",
@@ -57,6 +58,7 @@ type ButtonProps = BaseButtonProps & ButtonHTMLAttributes<HTMLButtonElement>;
/**
* Styled html button
* @Beta
*/
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, isLoading, testId, children, ...props }, ref) => (
@@ -75,6 +77,7 @@ type LinkButtonProps = BaseButtonProps & ReactRouterLinkProps;
/**
* Styled react router link
* @Beta
*/
export const LinkButton = React.forwardRef<HTMLAnchorElement, LinkButtonProps>(
({ className, variant, isLoading, testId, children, ...props }, ref) => (
@@ -93,6 +96,7 @@ type ExternalLinkButtonProps = BaseButtonProps & AnchorHTMLAttributes<HTMLAnchor
/**
* Styled html anchor
* @Beta
*/
export const ExternalLinkButton = React.forwardRef<HTMLAnchorElement, ExternalLinkButtonProps>(
({ className, variant, isLoading, testId, children, ...props }, ref) => (

View File

@@ -21,16 +21,16 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React, { FC } from "react";
import React, { ComponentProps, FC } from "react";
import { BackendError } from "@scm-manager/ui-api";
import Notification from "./Notification";
import { useTranslation } from "react-i18next";
type Props = {
type Props = Omit<ComponentProps<typeof Notification>, "type" | "role"> & {
error: BackendError;
};
const BackendErrorNotification: FC<Props> = ({ error }) => {
const BackendErrorNotification: FC<Props> = ({ error, ...props }) => {
const [t] = useTranslation("plugins");
const renderErrorName = () => {
@@ -141,7 +141,7 @@ const BackendErrorNotification: FC<Props> = ({ error }) => {
};
return (
<Notification type="danger" role="alert">
<Notification type="danger" role="alert" {...props}>
<div className="content">
<p className="subtitle">
{t("error.subtitle")}

View File

@@ -21,14 +21,14 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React, { FC } from "react";
import React, { ComponentProps, FC } from "react";
import { useTranslation } from "react-i18next";
import { BackendError, ForbiddenError, UnauthorizedError, urls } from "@scm-manager/ui-api";
import Notification from "./Notification";
import BackendErrorNotification from "./BackendErrorNotification";
import { useLocation } from "react-router-dom";
type Props = {
type Props = ComponentProps<typeof BasicErrorMessage> & {
error?: Error | null;
};
@@ -40,31 +40,31 @@ const LoginLink: FC = () => {
return <a href={urls.withContextPath(`/login?from=${from}`)}>{t("errorNotification.loginLink")}</a>;
};
const BasicErrorMessage: FC = ({ children }) => {
const BasicErrorMessage: FC<Omit<ComponentProps<typeof Notification>, "type" | "role">> = ({ children, ...props }) => {
const [t] = useTranslation("commons");
return (
<Notification type="danger" role="alert">
<Notification type="danger" role="alert" {...props}>
<strong>{t("errorNotification.prefix")}:</strong> {children}
</Notification>
);
};
const ErrorNotification: FC<Props> = ({ error }) => {
const ErrorNotification: FC<Props> = ({ error, ...props }) => {
const [t] = useTranslation("commons");
if (error) {
if (error instanceof BackendError) {
return <BackendErrorNotification error={error} />;
return <BackendErrorNotification error={error} {...props} />;
} else if (error instanceof UnauthorizedError) {
return (
<BasicErrorMessage>
<BasicErrorMessage {...props}>
{t("errorNotification.timeout")} <LoginLink />
</BasicErrorMessage>
);
} else if (error instanceof ForbiddenError) {
return <BasicErrorMessage>{t("errorNotification.forbidden")}</BasicErrorMessage>;
return <BasicErrorMessage {...props}>{t("errorNotification.forbidden")}</BasicErrorMessage>;
} else {
return <BasicErrorMessage>{error.message}</BasicErrorMessage>;
return <BasicErrorMessage {...props}>{error.message}</BasicErrorMessage>;
}
}
return null;

View File

@@ -21,12 +21,12 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React from "react";
import { binder } from "@scm-manager/ui-extensions";
import React, { ComponentProps } from "react";
import { binder, extensionPoints } from "@scm-manager/ui-extensions";
import { NavLink } from "../navigation";
import { Route } from "react-router-dom";
import { WithTranslation, withTranslation } from "react-i18next";
import { Repository, Links, Link } from "@scm-manager/ui-types";
import { Link, Links, Repository } from "@scm-manager/ui-types";
import { urls } from "@scm-manager/ui-api";
type GlobalRouteProps = {
@@ -44,8 +44,13 @@ type RepositoryNavProps = WithTranslation & { url: string };
class ConfigurationBinder {
i18nNamespace = "plugins";
navLink(to: string, labelI18nKey: string, t: any) {
return <NavLink to={to} label={t(labelI18nKey)} />;
navLink(
to: string,
labelI18nKey: string,
t: any,
options: Omit<ComponentProps<typeof NavLink>, "label" | "to"> = {}
) {
return <NavLink to={to} label={t(labelI18nKey)} {...options} />;
}
route(path: string, Component: any) {
@@ -86,6 +91,27 @@ class ConfigurationBinder {
binder.bind("admin.route", ConfigRoute, configPredicate);
}
bindAdmin(
to: string,
labelI18nKey: string,
icon: string,
linkName: string,
Component: React.ComponentType<{ link: string }>
) {
const predicate = ({ links }: extensionPoints.AdminRoute["props"]) => links[linkName];
const AdminNavLink = withTranslation(this.i18nNamespace)(
({ t, url }: WithTranslation & extensionPoints.AdminNavigation["props"]) =>
this.navLink(url + to, labelI18nKey, t, { icon })
);
const AdminRoute: extensionPoints.AdminRoute["type"] = ({ links, url }) =>
this.route(url + to, <Component link={(links[linkName] as Link).href} />);
binder.bind<extensionPoints.AdminRoute>("admin.route", AdminRoute, predicate);
binder.bind<extensionPoints.AdminNavigation>("admin.navigation", AdminNavLink, predicate);
}
bindRepository(to: string, labelI18nKey: string, linkName: string, RepositoryComponent: any) {
// create predicate based on the link name of the current repository route
// if the linkname is not available, the navigation link and the route are not bound to the extension points

View File

@@ -660,3 +660,8 @@ export type FileViewActionBarOverflowMenu = ExtensionPointDefinition<
>;
export type LoginForm = RenderableExtensionPointDefinition<"login.form">;
export type RepositoryDeleteButton = RenderableExtensionPointDefinition<
"repository.deleteButton",
{ repository: Repository }
>;

View File

@@ -1,6 +1,6 @@
{
"name": "@scm-manager/ui-forms",
"private": true,
"private": false,
"version": "2.40.2-SNAPSHOT",
"main": "build/index.js",
"types": "build/index.d.ts",
@@ -40,7 +40,8 @@
"react-query": "3"
},
"dependencies": {
"@scm-manager/ui-buttons": "^2.40.2-SNAPSHOT"
"@scm-manager/ui-buttons": "^2.40.2-SNAPSHOT",
"@scm-manager/ui-api": "^2.40.2-SNAPSHOT"
},
"prettier": "@scm-manager/prettier-config",
"eslintConfig": {

View File

@@ -0,0 +1,54 @@
/*
* 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 {useConfigLink} from "@scm-manager/ui-api";
import {Loading} from "@scm-manager/ui-components";
import React, {ComponentProps} from "react";
import {HalRepresentation} from "@scm-manager/ui-types";
import Form from "./Form";
type Props<T extends HalRepresentation> = Pick<ComponentProps<typeof Form<T, T>>, "translationPath" | "children"> & {
link: string;
};
/** @Beta */
export function ConfigurationForm<T extends HalRepresentation>({link, translationPath, children}: Props<T>) {
const {initialConfiguration, isReadOnly, update, isLoading} = useConfigLink<T>(link);
if (isLoading || !initialConfiguration) {
return <Loading/>;
}
return (
<Form<T, T>
onSubmit={update}
translationPath={translationPath}
defaultValues={initialConfiguration}
readOnly={isReadOnly}
>
{children}
</Form>
);
}
export default ConfigurationForm;

View File

@@ -33,6 +33,7 @@ import ControlledInputField from "./input/ControlledInputField";
import ControlledCheckboxField from "./checkbox/ControlledCheckboxField";
import ControlledSecretConfirmationField from "./input/ControlledSecretConfirmationField";
import { HalRepresentation } from "@scm-manager/ui-types";
import ControlledSelectField from "./select/ControlledSelectField";
type RenderProps<T extends Record<string, unknown>> = Omit<
UseFormReturn<T>,
@@ -61,6 +62,7 @@ type Props<FormType extends Record<string, unknown>, DefaultValues extends FormT
submitButtonTestId?: string;
};
/** @Beta */
function Form<FormType extends Record<string, unknown>, DefaultValues extends FormType>({
children,
onSubmit,
@@ -84,11 +86,11 @@ function Form<FormType extends Record<string, unknown>, DefaultValues extends Fo
useEffect(() => {
if (isSubmitSuccessful) {
setShowSuccessNotification(true);
reset(defaultValues as never);
}
/* eslint-disable-next-line react-hooks/exhaustive-deps */
}, [isSubmitSuccessful]);
useEffect(() => reset(defaultValues as never), [defaultValues, reset]);
useEffect(() => {
if (isDirty) {
setShowSuccessNotification(false);
@@ -158,4 +160,5 @@ export default Object.assign(Form, {
Input: ControlledInputField,
Checkbox: ControlledCheckboxField,
SecretConfirmation: ControlledSecretConfirmationField,
Select: ControlledSelectField,
});

View File

@@ -23,4 +23,5 @@
*/
export { default as Form } from "./Form";
export { default as ConfigurationForm } from "./ConfigurationForm";
export * from "./resourceHooks";

View File

@@ -66,6 +66,7 @@ const createResource = <I, O = never>(link: string, contentType: string) => {
type CreateResourceOptions = MutatingResourceOptions;
/** @Beta */
export const useCreateResource = <I, O>(
link: string,
[entityKey, collectionName]: QueryKeyPair,
@@ -91,6 +92,7 @@ type UpdateResourceOptions = MutatingResourceOptions & {
collectionName?: QueryKeyPair;
};
/** @Beta */
export const useUpdateResource = <T>(
link: LinkOrHalLink,
idFactory: (createdResource: T) => string,
@@ -123,6 +125,7 @@ type DeleteResourceOptions = {
collectionName?: QueryKeyPair;
};
/** @Beta */
export const useDeleteResource = <T extends HalRepresentation>(
idFactory: (createdResource: T) => string,
{ collectionName: [entityQueryKey, collectionName] = ["", ""] }: DeleteResourceOptions = {}

View File

@@ -0,0 +1,77 @@
/*
* 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, { ComponentProps } from "react";
import { Controller, ControllerRenderProps, Path } from "react-hook-form";
import classNames from "classnames";
import { useScmFormContext } from "../ScmFormContext";
import SelectField from "./SelectField";
type Props<T extends Record<string, unknown>> = Omit<
ComponentProps<typeof SelectField>,
"error" | "label" | "required" | keyof ControllerRenderProps
> & {
rules?: ComponentProps<typeof Controller>["rules"];
name: Path<T>;
label?: string;
};
function ControlledSelectField<T extends Record<string, unknown>>({
name,
label,
helpText,
rules,
className,
testId,
defaultValue,
readOnly,
...props
}: Props<T>) {
const { control, t, readOnly: formReadonly } = useScmFormContext();
const labelTranslation = label || t(`${name}.label`) || "";
const helpTextTranslation = helpText || t(`${name}.helpText`);
return (
<Controller
control={control}
name={name}
rules={rules}
defaultValue={defaultValue as never}
render={({ field, fieldState }) => (
<SelectField
className={classNames("column", className)}
readOnly={readOnly ?? formReadonly}
required={rules?.required as boolean}
{...props}
{...field}
label={labelTranslation}
helpText={helpTextTranslation}
error={fieldState.error ? fieldState.error.message || t(`${name}.error.${fieldState.error.type}`) : undefined}
testId={testId ?? `select-${name}`}
/>
)}
/>
);
}
export default ControlledSelectField;

View File

@@ -0,0 +1,43 @@
/*
* 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, { InputHTMLAttributes } from "react";
import classNames from "classnames";
import { createVariantClass, Variant } from "../variants";
import { createAttributesForTesting } from "@scm-manager/ui-components";
type Props = {
variant?: Variant;
testId?: string;
} & InputHTMLAttributes<HTMLSelectElement>;
const Select = React.forwardRef<HTMLSelectElement, Props>(({ variant, children, className, testId, ...props }, ref) => (
<div className={classNames("select", { "is-multiple": props.multiple }, createVariantClass(variant), className)}>
<select ref={ref} {...props} {...createAttributesForTesting(testId)}>
{children}
</select>
</div>
));
export default Select;

View File

@@ -0,0 +1,59 @@
/*
* 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 from "react";
import Field from "../base/Field";
import Control from "../base/Control";
import Label from "../base/label/Label";
import FieldMessage from "../base/field-message/FieldMessage";
import Help from "../base/help/Help";
import Select from "./Select";
type Props = {
label: string;
helpText?: string;
error?: string;
} & React.ComponentProps<typeof Select>;
/**
* @see https://bulma.io/documentation/form/select/
*/
const SelectField = React.forwardRef<HTMLSelectElement, Props>(
({ label, helpText, error, className, ...props }, ref) => {
const variant = error ? "danger" : undefined;
return (
<Field className={className}>
<Label>
{label}
{helpText ? <Help className="ml-1" text={helpText} /> : null}
</Label>
<Control>
<Select variant={variant} ref={ref} {...props}></Select>
</Control>
{error ? <FieldMessage variant={variant}>{error}</FieldMessage> : null}
</Field>
);
}
);
export default SelectField;

View File

@@ -8,6 +8,8 @@
"dependencies": {
"@scm-manager/ui-components": "2.40.2-SNAPSHOT",
"@scm-manager/ui-extensions": "2.40.2-SNAPSHOT",
"@scm-manager/ui-forms": "2.40.2-SNAPSHOT",
"@scm-manager/ui-buttons": "2.40.2-SNAPSHOT",
"classnames": "^2.2.6",
"query-string": "6.14.1",
"react": "^17.0.1",

View File

@@ -35,11 +35,11 @@ if (orgaIndex > 0) {
name = name.substring(orgaIndex + 1);
}
module.exports = function(mode) {
module.exports = function (mode) {
return {
context: root,
entry: {
[name]: [path.resolve(__dirname, "webpack-public-path.js"), packageJSON.main || "src/main/js/index.js"]
[name]: [path.resolve(__dirname, "webpack-public-path.js"), packageJSON.main || "src/main/js/index.js"],
},
mode,
stats: "minimal",
@@ -48,7 +48,7 @@ module.exports = function(mode) {
node: {
fs: "empty",
net: "empty",
tls: "empty"
tls: "empty",
},
externals: [
"react",
@@ -59,11 +59,13 @@ module.exports = function(mode) {
"@scm-manager/ui-types",
"@scm-manager/ui-extensions",
"@scm-manager/ui-components",
"@scm-manager/ui-forms",
"@scm-manager/ui-buttons",
"classnames",
"query-string",
"redux",
"react-redux",
/^@scm-manager\/scm-.*-plugin$/i
/^@scm-manager\/scm-.*-plugin$/i,
],
module: {
rules: [
@@ -73,29 +75,29 @@ module.exports = function(mode) {
use: {
loader: "babel-loader",
options: {
presets: ["@scm-manager/babel-preset"]
}
}
presets: ["@scm-manager/babel-preset"],
},
},
},
{
test: /\.(css|scss|sass)$/i,
use: ["style-loader", "css-loader", "sass-loader"]
use: ["style-loader", "css-loader", "sass-loader"],
},
{
test: /\.(png|svg|jpg|gif|woff2?|eot|ttf)$/,
use: ["file-loader"]
}
]
use: ["file-loader"],
},
],
},
resolve: {
extensions: [".ts", ".tsx", ".js", ".jsx", ".css", ".scss", ".json"]
extensions: [".ts", ".tsx", ".js", ".jsx", ".css", ".scss", ".json"],
},
output: {
path: path.join(root, "target", `${name}-${packageJSON.version}`, "webapp", "assets"),
filename: "[name].bundle.js",
chunkFilename: `${name}.[name].chunk.js`,
library: name,
libraryTarget: "amd"
}
libraryTarget: "amd",
},
};
};

View File

@@ -13,6 +13,7 @@
"@scm-manager/ui-shortcuts": "2.40.2-SNAPSHOT",
"@scm-manager/ui-legacy": "2.40.2-SNAPSHOT",
"@scm-manager/ui-forms": "2.40.2-SNAPSHOT",
"@scm-manager/ui-buttons": "2.40.2-SNAPSHOT",
"classnames": "^2.2.5",
"history": "^4.10.1",
"i18next": "21",

View File

@@ -29,6 +29,7 @@ import RenameRepository from "./RenameRepository";
import DeleteRepo from "./DeleteRepo";
import ArchiveRepo from "./ArchiveRepo";
import UnarchiveRepo from "./UnarchiveRepo";
import { ExtensionPoint, extensionPoints, useBinder } from "@scm-manager/ui-extensions";
type Props = {
repository: Repository;
@@ -36,12 +37,18 @@ type Props = {
const RepositoryDangerZone: FC<Props> = ({ repository }) => {
const [t] = useTranslation("repos");
const binder = useBinder();
const dangerZone = [];
if (repository?._links?.rename || repository?._links?.renameWithNamespace) {
dangerZone.push(<RenameRepository repository={repository} />);
}
if (repository?._links?.delete) {
if (binder.hasExtension<extensionPoints.RepositoryDeleteButton>("repository.deleteButton", { repository })) {
dangerZone.push(
<ExtensionPoint<extensionPoints.RepositoryDeleteButton> name="repository.deleteButton" props={{ repository }} />
);
} else if (repository?._links?.delete) {
dangerZone.push(<DeleteRepo repository={repository} />);
}
if (repository?._links?.archive) {

View File

@@ -27,7 +27,7 @@ package sonia.scm.importexport;
import org.apache.commons.compress.archivers.tar.TarArchiveEntry;
import org.apache.commons.compress.archivers.tar.TarArchiveOutputStream;
import org.apache.commons.compress.compressors.gzip.GzipCompressorOutputStream;
import sonia.scm.ContextEntry;
import sonia.scm.repository.FullRepositoryExporter;
import sonia.scm.repository.Repository;
import sonia.scm.repository.RepositoryExportingCheck;
import sonia.scm.repository.api.ExportFailedException;
@@ -36,6 +36,7 @@ import sonia.scm.repository.api.RepositoryServiceFactory;
import sonia.scm.repository.work.WorkdirProvider;
import sonia.scm.util.Archives;
import sonia.scm.util.IOUtil;
import sonia.scm.web.security.AdministrationContext;
import javax.inject.Inject;
import java.io.BufferedOutputStream;
@@ -46,8 +47,10 @@ import java.io.OutputStream;
import java.nio.file.Files;
import java.nio.file.Paths;
import static sonia.scm.ContextEntry.ContextBuilder.entity;
public class FullScmRepositoryExporter {
public class FullScmRepositoryExporter implements FullRepositoryExporter {
static final String SCM_ENVIRONMENT_FILE_NAME = "scm-environment.xml";
static final String METADATA_FILE_NAME = "metadata.xml";
@@ -61,6 +64,8 @@ public class FullScmRepositoryExporter {
private final RepositoryImportExportEncryption repositoryImportExportEncryption;
private final ExportNotificationHandler notificationHandler;
private final AdministrationContext administrationContext;
@Inject
public FullScmRepositoryExporter(EnvironmentInformationXmlGenerator environmentGenerator,
RepositoryMetadataXmlGenerator metadataGenerator,
@@ -68,7 +73,7 @@ public class FullScmRepositoryExporter {
TarArchiveRepositoryStoreExporter storeExporter,
WorkdirProvider workdirProvider,
RepositoryExportingCheck repositoryExportingCheck,
RepositoryImportExportEncryption repositoryImportExportEncryption, ExportNotificationHandler notificationHandler) {
RepositoryImportExportEncryption repositoryImportExportEncryption, ExportNotificationHandler notificationHandler, AdministrationContext administrationContext) {
this.environmentGenerator = environmentGenerator;
this.metadataGenerator = metadataGenerator;
this.serviceFactory = serviceFactory;
@@ -77,6 +82,7 @@ public class FullScmRepositoryExporter {
this.repositoryExportingCheck = repositoryExportingCheck;
this.repositoryImportExportEncryption = repositoryImportExportEncryption;
this.notificationHandler = notificationHandler;
this.administrationContext = administrationContext;
}
public void export(Repository repository, OutputStream outputStream, String password) {
@@ -99,27 +105,33 @@ public class FullScmRepositoryExporter {
GzipCompressorOutputStream gzos = new GzipCompressorOutputStream(cos);
TarArchiveOutputStream taos = Archives.createTarOutputStream(gzos);
) {
writeEnvironmentData(taos);
writeEnvironmentData(repository, taos);
writeMetadata(repository, taos);
writeStoreData(repository, taos);
writeRepository(service, taos);
taos.finish();
} catch (IOException e) {
throw new ExportFailedException(
ContextEntry.ContextBuilder.entity(repository).build(),
entity(repository).build(),
"Could not export repository with metadata",
e
);
}
}
private void writeEnvironmentData(TarArchiveOutputStream taos) throws IOException {
byte[] envBytes = environmentGenerator.generate();
TarArchiveEntry entry = new TarArchiveEntry(SCM_ENVIRONMENT_FILE_NAME);
entry.setSize(envBytes.length);
taos.putArchiveEntry(entry);
taos.write(envBytes);
taos.closeArchiveEntry();
private void writeEnvironmentData(Repository repository, TarArchiveOutputStream taos) {
administrationContext.runAsAdmin(() -> {
byte[] envBytes = environmentGenerator.generate();
TarArchiveEntry entry = new TarArchiveEntry(SCM_ENVIRONMENT_FILE_NAME);
entry.setSize(envBytes.length);
try {
taos.putArchiveEntry(entry);
taos.write(envBytes);
taos.closeArchiveEntry();
} catch (IOException e) {
throw new ExportFailedException(entity(repository).build(), "Failed to collect instance environment for repository export", e);
}
});
}
private void writeMetadata(Repository repository, TarArchiveOutputStream taos) throws IOException {

View File

@@ -32,10 +32,7 @@ import org.slf4j.Logger;
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.*;
import sonia.scm.repository.api.ImportFailedException;
import javax.inject.Inject;
@@ -48,7 +45,7 @@ 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 {
public class FullScmRepositoryImporter implements FullRepositoryImporter {
private static final Logger LOG = LoggerFactory.getLogger(FullScmRepositoryImporter.class);

View File

@@ -57,6 +57,8 @@ import sonia.scm.group.GroupDisplayManager;
import sonia.scm.group.GroupManager;
import sonia.scm.group.GroupManagerProvider;
import sonia.scm.group.xml.XmlGroupDAO;
import sonia.scm.importexport.FullScmRepositoryExporter;
import sonia.scm.importexport.FullScmRepositoryImporter;
import sonia.scm.initialization.DefaultInitializationFinisher;
import sonia.scm.initialization.InitializationCookieIssuer;
import sonia.scm.initialization.InitializationFinisher;
@@ -76,24 +78,7 @@ import sonia.scm.notifications.NotificationSender;
import sonia.scm.plugin.DefaultPluginManager;
import sonia.scm.plugin.PluginLoader;
import sonia.scm.plugin.PluginManager;
import sonia.scm.repository.DefaultHealthCheckService;
import sonia.scm.repository.DefaultNamespaceManager;
import sonia.scm.repository.DefaultRepositoryManager;
import sonia.scm.repository.DefaultRepositoryProvider;
import sonia.scm.repository.DefaultRepositoryRoleManager;
import sonia.scm.repository.HealthCheckContextListener;
import sonia.scm.repository.HealthCheckService;
import sonia.scm.repository.NamespaceManager;
import sonia.scm.repository.NamespaceStrategy;
import sonia.scm.repository.NamespaceStrategyProvider;
import sonia.scm.repository.PermissionProvider;
import sonia.scm.repository.Repository;
import sonia.scm.repository.RepositoryDAO;
import sonia.scm.repository.RepositoryManager;
import sonia.scm.repository.RepositoryManagerProvider;
import sonia.scm.repository.RepositoryProvider;
import sonia.scm.repository.RepositoryRoleDAO;
import sonia.scm.repository.RepositoryRoleManager;
import sonia.scm.repository.*;
import sonia.scm.repository.api.HookContextFactory;
import sonia.scm.repository.api.RepositoryServiceFactory;
import sonia.scm.repository.spi.HookEventFacade;
@@ -297,6 +282,9 @@ class ScmServletModule extends ServletModule {
bind(CentralWorkQueue.class, DefaultCentralWorkQueue.class);
bind(ContentTypeResolver.class).to(DefaultContentTypeResolver.class);
bind(FullRepositoryImporter.class).to(FullScmRepositoryImporter.class);
bind(FullRepositoryExporter.class).to(FullScmRepositoryExporter.class);
}
private <T> void bind(Class<T> clazz, Class<? extends T> defaultImplementation) {

View File

@@ -0,0 +1,213 @@
/*
* 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 de.otto.edison.hal.Embedded;
import de.otto.edison.hal.HalRepresentation;
import de.otto.edison.hal.Links;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.github.sdorra.jse.ShiroExtension;
import org.github.sdorra.jse.SubjectAware;
import org.jboss.resteasy.mock.MockHttpRequest;
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.mockito.Answers;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import sonia.scm.store.ConfigurationStore;
import sonia.scm.store.ConfigurationStoreFactory;
import sonia.scm.web.JsonMockHttpRequest;
import sonia.scm.web.JsonMockHttpResponse;
import sonia.scm.web.RestDispatcher;
import javax.inject.Inject;
import javax.inject.Provider;
import javax.validation.constraints.Max;
import javax.validation.constraints.Min;
import javax.validation.constraints.NotBlank;
import javax.ws.rs.Path;
import javax.ws.rs.core.MediaType;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.when;
@ExtendWith({MockitoExtension.class, ShiroExtension.class})
@SubjectAware(value = "trillian")
class ConfigurationAdapterBaseTest {
@Mock
private ConfigurationStore<TestConfiguration> configStore;
@Mock(answer = Answers.RETURNS_DEEP_STUBS)
private ConfigurationStoreFactory configurationStoreFactory;
@Mock
private Provider<ScmPathInfoStore> scmPathInfoStore;
private RestDispatcher dispatcher;
private final JsonMockHttpResponse response = new JsonMockHttpResponse();
@BeforeEach
void initResource() {
when(configurationStoreFactory.withType(TestConfiguration.class).withName("test-config").build()).thenReturn(configStore);
ScmPathInfoStore pathInfoStore = new ScmPathInfoStore();
pathInfoStore.set(() -> URI.create("v2/test"));
lenient().when(scmPathInfoStore.get()).thenReturn(pathInfoStore);
TestConfigurationAdapter resource = new TestConfigurationAdapter(configurationStoreFactory, scmPathInfoStore);
dispatcher = new RestDispatcher();
dispatcher.addSingletonResource(resource);
}
@Test
void shouldThrowBadRequestErrorWhenUpdatingWithNullPayload() throws URISyntaxException {
MockHttpRequest request = MockHttpRequest.put("/v2/test")
.contentType(MediaType.APPLICATION_JSON);
dispatcher.invoke(request, response);
assertThat(response.getStatus()).isEqualTo(400);
}
@Test
void shouldThrowAuthorizationExceptionWithoutPermissionOnRead() throws URISyntaxException {
MockHttpRequest request = MockHttpRequest.get("/v2/test");
dispatcher.invoke(request, response);
assertThat(response.getStatus()).isEqualTo(403);
}
@Test
void shouldThrowAuthorizationExceptionWithoutPermissionOnUpdate() throws URISyntaxException {
JsonMockHttpRequest request = JsonMockHttpRequest.put("/v2/test")
.contentType(MediaType.APPLICATION_JSON)
.json("{\"number\": 3, \"text\" : \"https://scm-manager.org/\"}");
dispatcher.invoke(request, response);
assertThat(response.getStatus()).isEqualTo(403);
}
@Nested
@SubjectAware(permissions = "configuration:read,write:testConfig")
class WithPermission {
@Test
void shouldGetDefaultDao() throws URISyntaxException {
when(configStore.getOptional()).thenReturn(Optional.empty());
MockHttpRequest request = MockHttpRequest.get("/v2/test");
dispatcher.invoke(request, response);
assertThat(response.getStatus()).isEqualTo(200);
assertThat(response.getContentAsJson().get("number").asInt()).isEqualTo(4);
assertThat(response.getContentAsJson().get("text").textValue()).isEqualTo("myTest");
}
@Test
void shouldGetStoredDao() throws URISyntaxException {
when(configStore.getOptional()).thenReturn(Optional.of(new TestConfiguration(3, "secret")));
MockHttpRequest request = MockHttpRequest.get("/v2/test");
dispatcher.invoke(request, response);
assertThat(response.getStatus()).isEqualTo(200);
assertThat(response.getContentAsJson().get("number").asInt()).isEqualTo(3);
assertThat(response.getContentAsJson().get("text").textValue()).isEqualTo("secret");
}
@Test
void shouldThrowBadRequestErrorWhenUpdatingWithInvalidPayload() throws URISyntaxException {
JsonMockHttpRequest request = JsonMockHttpRequest.put("/v2/test")
.contentType(MediaType.APPLICATION_JSON)
.json("{\"number\": 42, \"text\" : \"https://scm-manager.org/\"}");
dispatcher.invoke(request, response);
assertThat(response.getStatus()).isEqualTo(400);
}
}
@AllArgsConstructor
@NoArgsConstructor
@Getter
@Setter
static class TestConfiguration {
private int number = 4;
private String text = "myTest";
}
@NoArgsConstructor
@Getter
@Setter
static class TestConfigurationDto extends HalRepresentation {
@Min(2)
@Max(6)
private int number;
@NotBlank
private String text;
public TestConfigurationDto(int number, String text) {
super(Links.emptyLinks(), Embedded.emptyEmbedded());
this.number = number;
this.text = text;
}
public static TestConfigurationDto from(TestConfiguration configuration, Links links) {
return new TestConfigurationDto(configuration.number, configuration.text);
}
public TestConfiguration toEntity() {
return new TestConfiguration(number, text);
}
}
@Path("/v2/test")
private static class TestConfigurationAdapter extends ConfigurationAdapterBase<TestConfiguration, TestConfigurationDto> {
@Inject
public TestConfigurationAdapter(ConfigurationStoreFactory configurationStoreFactory, Provider<ScmPathInfoStore> scmPathInfoStoreProvider) {
super(configurationStoreFactory, scmPathInfoStoreProvider, TestConfiguration.class, TestConfigurationDto.class);
}
@Override
protected String getName() {
return "testConfig";
}
}
}

View File

@@ -39,6 +39,8 @@ import sonia.scm.repository.api.BundleCommandBuilder;
import sonia.scm.repository.api.RepositoryService;
import sonia.scm.repository.api.RepositoryServiceFactory;
import sonia.scm.repository.work.WorkdirProvider;
import sonia.scm.web.security.AdministrationContext;
import sonia.scm.web.security.PrivilegedAction;
import java.io.ByteArrayOutputStream;
import java.io.File;
@@ -79,6 +81,8 @@ class FullScmRepositoryExporterTest {
@Mock
private RepositoryExportingCheck repositoryExportingCheck;
@Mock
private AdministrationContext administrationContext;
@Mock
private RepositoryImportExportEncryption repositoryImportExportEncryption;
@InjectMocks
@@ -89,14 +93,13 @@ class FullScmRepositoryExporterTest {
@BeforeEach
void initRepoService() throws IOException {
when(serviceFactory.create(REPOSITORY)).thenReturn(repositoryService);
when(environmentGenerator.generate()).thenReturn(new byte[0]);
when(metadataGenerator.generate(REPOSITORY)).thenReturn(new byte[0]);
when(repositoryExportingCheck.withExportingLock(any(), any())).thenAnswer(invocation -> invocation.getArgument(1, Supplier.class).get());
when(repositoryImportExportEncryption.optionallyEncrypt(any(), any())).thenAnswer(invocation -> invocation.getArgument(0));
}
@Test
void shouldExportEverythingAsTarArchive(@TempDir Path temp) throws IOException {
void shouldExportEverythingAsTarArchive(@TempDir Path temp) {
BundleCommandBuilder bundleCommandBuilder = mock(BundleCommandBuilder.class);
when(repositoryService.getBundleCommand()).thenReturn(bundleCommandBuilder);
when(repositoryService.getRepository()).thenReturn(REPOSITORY);
@@ -105,7 +108,7 @@ class FullScmRepositoryExporterTest {
exporter.export(REPOSITORY, baos, "");
verify(storeExporter, times(1)).export(eq(REPOSITORY), any(OutputStream.class));
verify(environmentGenerator, times(1)).generate();
verify(administrationContext, times(1)).runAsAdmin(any(PrivilegedAction.class));
verify(metadataGenerator, times(1)).generate(REPOSITORY);
verify(bundleCommandBuilder, times(1)).bundle(any(OutputStream.class));
verify(repositoryExportingCheck).withExportingLock(eq(REPOSITORY), any());