Add plugin wizard initialization step (#2045)

Adds a new initialization step after setting up the initial administration account that allows administrators to initialize the instance with a selection of plugin sets.

Co-authored-by: René Pfeuffer <rene.pfeuffer@cloudogu.com>
Co-authored-by: Eduard Heimbuch <eduard.heimbuch@cloudogu.com>
Co-authored-by: Matthias Thieroff <matthias.thieroff@cloudogu.com>
This commit is contained in:
Konstantin Schaper
2022-05-31 15:14:52 +02:00
committed by Eduard Heimbuch
parent 6216945f0d
commit 1b18191c57
63 changed files with 2294 additions and 135 deletions

View File

@@ -184,7 +184,7 @@ The following shows user as an example.
"configuration:read",
"configuration:write",
"plugin:read",
"plugin:manage",
"plugin:write",
"group:read",
"user:read",
"repository:read"
@@ -206,7 +206,7 @@ The following shows user as an example.
"configuration:read",
"configuration:write",
"plugin:read",
"plugin:manage",
"plugin:write",
"group:read",
"user:read",
"repository:read"

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

View File

@@ -34,3 +34,14 @@ For automated processes, you might want to bypass the initial user creation. To
in a system property `scm.initialPassword`. If this is present, a user `scmadmin` with this password will be created,
if it does not already exist. To change the name of this user, you can set this with the property `scm.initialUser`
in addition.
When set, this also causes the initialization to skip the Plugin Wizard.
# Plugin Wizard
Once an initial user is created, the Plugin Wizard is going to appear.
Here you can select a series of pre-defined sets of plugins to kickstart
your development experience with the SCM-Manager. To install the selected
plugins, the server has to restart once.
![Form to select plugin sets](assets/plugin-wizard.png)

View File

@@ -0,0 +1,2 @@
- type: added
description: Initialization step to install pre-defined plugin sets ([#2045](https://github.com/scm-manager/scm-manager/pull/2045))

View File

@@ -26,7 +26,11 @@ package sonia.scm.initialization;
import sonia.scm.plugin.ExtensionPoint;
/**
* @deprecated Limited use for Plugin Development, see as internal
*/
@ExtensionPoint
@Deprecated(since = "2.35.0", forRemoval = true)
public interface InitializationStep {
String name();

View File

@@ -28,9 +28,22 @@ import de.otto.edison.hal.Embedded;
import de.otto.edison.hal.Links;
import sonia.scm.plugin.ExtensionPoint;
import java.util.Locale;
/**
* @deprecated Limited use for Plugin Development, see as internal
*/
@ExtensionPoint
@Deprecated(since = "2.35.0", forRemoval = true)
public interface InitializationStepResource {
String name();
/**
* @since 2.35.0
*/
default void setupIndex(Links.Builder builder, Embedded.Builder embeddedBuilder, Locale locale) {
setupIndex(builder, embeddedBuilder);
}
void setupIndex(Links.Builder builder, Embedded.Builder embeddedBuilder);
}

View File

@@ -26,6 +26,7 @@ package sonia.scm.plugin;
import java.util.List;
import java.util.Optional;
import java.util.Set;
/**
* The plugin manager is responsible for plugin related tasks, such as install, uninstall or updating.
@@ -64,6 +65,23 @@ public interface PluginManager {
*/
List<AvailablePlugin> getAvailable();
/**
* Returns all available plugin sets from the plugin center.
*
* @return a list of available plugin sets
* @since 2.35.0
*/
Set<PluginSet> getPluginSets();
/**
* Collects and installs all plugins and their dependencies for the given plugin sets.
*
* @param pluginSets Ids of plugin sets to install
* @param restartAfterInstallation restart context after all plugins have been installed
* @since 2.35.0
*/
void installPluginSets(Set<String> pluginSets, boolean restartAfterInstallation);
/**
* Returns all updatable plugins.
*

View File

@@ -0,0 +1,55 @@
/*
* 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.plugin;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import java.util.List;
import java.util.Map;
import java.util.Set;
@AllArgsConstructor
@NoArgsConstructor
@Getter
@Setter
public class PluginSet {
private String id;
private int sequence;
private Set<String> plugins;
private Map<String, Description> descriptions;
private Map<String, String> images;
@AllArgsConstructor
@NoArgsConstructor
@Getter
@Setter
public static class Description {
private String name;
private List<String> features;
}
}

View File

@@ -43,6 +43,7 @@ public interface AccessTokenCookieIssuer {
* @param accessToken access token
*/
void authenticate(HttpServletRequest request, HttpServletResponse response, AccessToken accessToken);
/**
* Invalidates the authentication cookie.
*

View File

@@ -83,7 +83,7 @@ export const extractXsrfTokenFromCookie = (cookieString?: string) => {
const cookies = cookieString.split(";");
for (const c of cookies) {
const parts = c.trim().split("=");
if (parts[0] === "X-Bearer-Token") {
if (parts[0] === "X-Bearer-Token" || parts[0] === "X-SCM-Init-Token") {
return extractXsrfTokenFromJwt(parts[1]);
}
}

View File

@@ -64,6 +64,7 @@ export * from "./loginInfo";
export * from "./usePluginCenterAuthInfo";
export * from "./compare";
export * from "./utils";
export * from "./links";
export { default as ApiProvider } from "./ApiProvider";
export * from "./ApiProvider";

View File

@@ -34,7 +34,7 @@ type WaitForRestartOptions = {
timeout?: number;
};
const waitForRestartAfter = (
export const waitForRestartAfter = (
promise: Promise<any>,
{ initialDelay = 1000, timeout = 500 }: WaitForRestartOptions = {}
): Promise<void> => {

View File

@@ -21,17 +21,14 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React, { FC, ReactNode } from "react";
import React, { FC } from "react";
import Logo from "./../Logo";
import { Links } from "@scm-manager/ui-types";
type Props = {
links: Links;
authenticated: boolean;
children: ReactNode;
authenticated?: boolean;
};
const SmallHeader: FC<{ children: ReactNode }> = ({ children }) => {
const SmallHeader: FC = ({ children }) => {
return <div className="has-scm-background">{children}</div>;
};
@@ -51,7 +48,7 @@ const LargeHeader: FC = () => {
);
};
const Header: FC<Props> = ({ authenticated, children, links }) => {
const Header: FC<Props> = ({ authenticated, children }) => {
if (authenticated) {
return <SmallHeader>{children}</SmallHeader>;
} else {

View File

@@ -26,6 +26,15 @@ import { HalRepresentation, HalRepresentationWithEmbedded } from "./hal";
type PluginType = "SCM" | "CLOUDOGU";
export type PluginSet = HalRepresentation & {
id: string;
name: string;
sequence: number;
features: string[];
plugins: Plugin[];
images: Record<string, string>;
};
export type Plugin = HalRepresentation & {
name: string;
version: string;

View File

@@ -11,6 +11,20 @@
"password-confirmation": "Passwort Bestätigung",
"submit": "Absenden"
},
"pluginWizardStep": {
"title": "Plugin Sets",
"description": "Der SCM-Manager ist ein minimalistisches Werkzeug, um Git, SVN und mercurial Repositories zu verwalten. Mit Plugins vereinfachen Sie Ihren Workflow und erreichen mehr mit Ihrem SCM-Manager. Wählen Sie ein oder mehrere Plugin-Sets, um ihren Start zu beschleunigen.",
"submit": "Absenden",
"submitAndRestart": "Absenden und Neustarten",
"skip": {
"title": "Ohne weitere Plugins starten",
"subtitle": "Ausschließlich Core-Plugins werden installiert. Es können weiterhin jederzeit Plugins manuell über das Plugin-Center installiert werden."
},
"pluginSet": {
"expand": "Enthaltene Plugins anzeigen",
"collapse": "Enthaltene Plugins"
}
},
"error": {
"forbidden": "Falscher Token"
}

View File

@@ -11,6 +11,21 @@
"password-confirmation": "Confirm Password",
"submit": "Submit"
},
"pluginWizardStep": {
"title": "Plugin Sets",
"description": "Out of the box SCM-Manager is a streamlined tool to manage Git, SVN and mercurial repositories. Plugins enhance your workflow and help you to get more out of your SCM-Manager. Select one or more of our plugin-sets to jump-start your journey!",
"submit": "Submit",
"submitAndRestart": "Submit and restart",
"skip": {
"title": "Start without additional plugins",
"subtitle": "Only core-plugins will be installed. You may manually install plugins through the Plugin-Center any time."
},
"pluginSet": {
"expand": "View included Plugins",
"collapse": "Included Plugins"
}
},
"error": {
"forbidden": "Incorrect token"
}

View File

@@ -67,7 +67,7 @@ const App: FC = () => {
return (
<AppWrapper className="App">
{isAuthenticated ? <Feedback index={index} /> : null}
<Header authenticated={authenticatedOrAnonymous} links={index._links}>
<Header authenticated={authenticatedOrAnonymous}>
<NavigationBar links={index._links} />
</Header>
<div className="is-flex-grow-1">{content}</div>

View File

@@ -32,6 +32,7 @@ import { Link } from "@scm-manager/ui-types";
import i18next from "i18next";
import { binder, extensionPoints } from "@scm-manager/ui-extensions";
import InitializationAdminAccountStep from "./InitializationAdminAccountStep";
import InitializationPluginWizardStep from "./InitializationPluginWizardStep";
const Index: FC = () => {
const { isLoading, error, data } = useIndex();
@@ -39,7 +40,7 @@ const Index: FC = () => {
// TODO check componentDidUpdate method for anonymous user stuff
i18next.on("languageChanged", lng => {
i18next.on("languageChanged", (lng) => {
document.documentElement.setAttribute("lang", lng);
});
@@ -73,3 +74,8 @@ binder.bind<extensionPoints.InitializationStep<"adminAccount">>(
"initialization.step.adminAccount",
InitializationAdminAccountStep
);
binder.bind<extensionPoints.InitializationStep<"pluginWizard">>(
"initialization.step.pluginWizard",
InitializationPluginWizardStep
);

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.
*/
import React, { FC, useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { HalRepresentationWithEmbedded, PluginSet } from "@scm-manager/ui-types";
import { apiClient, requiredLink, waitForRestartAfter } from "@scm-manager/ui-api";
import { useMutation } from "react-query";
import { useForm } from "react-hook-form";
import { Checkbox, ErrorNotification, Icon, SubmitButton } from "@scm-manager/ui-components";
import styled from "styled-components";
const HighlightedImg = styled.img`
&:hover {
filter: drop-shadow(0px 2px 2px #00c79b);
}
`;
const HiddenInput = styled.input`
display: none;
`;
const HeroSection = styled.section`
padding-top: 2em;
`;
const BorderedDiv = styled.div`
border-radius: 4px;
border-width: 1px;
border-style: solid;
`;
type PluginSetsInstallation = {
pluginSetIds: string[];
};
const install = (link: string) => (data: PluginSetsInstallation) =>
waitForRestartAfter(apiClient.post(link, data, "application/json"));
const useInstallPluginSets = (link: string) => {
const { mutate, isLoading, error, isSuccess } = useMutation<unknown, Error, PluginSetsInstallation>(install(link));
return {
installPluginSets: mutate,
isLoading,
error,
isInstalled: isSuccess,
};
};
type PluginSetCardProps = {
pluginSet: PluginSet;
};
const PluginSetCard: FC<PluginSetCardProps> = ({ pluginSet, children }) => {
const [t] = useTranslation("initialization");
const [collapsed, setCollapsed] = useState(true);
return (
<div className="card block">
<div className="card-image pt-5">{children}</div>
<div className="card-content">
<h5 className="subtitle is-5">{pluginSet.name}</h5>
<div className="content">
<ul>
{pluginSet.features.map((feature) => (
<li>{feature}</li>
))}
</ul>
</div>
<div className="block pl-4">
<div
className="is-flex is-justify-content-space-between has-cursor-pointer"
onClick={() => setCollapsed((value) => !value)}
>
<span>{t(`pluginWizardStep.pluginSet.${collapsed ? "expand" : "collapse"}`)}</span>
<Icon color="inherit" name={collapsed ? "angle-down" : "angle-up"} />
</div>
{!collapsed ? (
<ul className="pt-2">
{pluginSet.plugins.map((plugin, idx) => (
<li key={idx} className="py-2">
<div className="is-size-6 has-text-weight-semibold">{plugin.displayName}</div>
<div className="is-size-6">
<a href={`https://scm-manager.org/plugins/${plugin.name}`} target="_blank">
{plugin.description}
</a>
</div>
</li>
))}
</ul>
) : null}
</div>
</div>
</div>
);
};
type Props = {
data: HalRepresentationWithEmbedded<{ pluginSets: PluginSet[] }>;
};
type FormValue = { [id: string]: boolean };
const InitializationPluginWizardStep: FC<Props> = ({ data: initializationContext }) => {
const {
installPluginSets,
isLoading: isInstalling,
isInstalled,
error: installationError,
} = useInstallPluginSets(requiredLink(initializationContext, "installPluginSets"));
const [skipInstallation, setSkipInstallation] = useState(false);
const { register, handleSubmit, watch } = useForm<FormValue>();
const data = initializationContext._embedded?.pluginSets;
const [t] = useTranslation("initialization");
const values = watch();
const pluginSetIds = useMemo(
() =>
Object.entries(values).reduce<string[]>((p, [id, flag]) => {
if (flag) {
p.push(id);
}
return p;
}, []),
[values]
);
const submit = useCallback(
() =>
installPluginSets({
pluginSetIds,
}),
[installPluginSets, pluginSetIds]
);
const isSelected = useCallback((pluginSetId: string) => pluginSetIds.includes(pluginSetId), [pluginSetIds]);
const hasPluginSets = useMemo(() => pluginSetIds.length > 0, [pluginSetIds]);
useEffect(() => {
if (hasPluginSets) {
setSkipInstallation(false);
}
}, [hasPluginSets]);
useEffect(() => {
if (isInstalled) {
window.location.reload();
}
}, [isInstalled]);
let content;
if (installationError) {
content = <ErrorNotification error={installationError} />;
} else {
content = (
<form onSubmit={handleSubmit(submit)} className="is-flex is-flex-direction-column">
<div className="block">
{data?.map((pluginSet, idx) => (
<PluginSetCard pluginSet={pluginSet} key={idx}>
<label htmlFor={`plugin-set-${idx}`} className="has-cursor-pointer">
<HighlightedImg
alt={pluginSet.name}
src={`data:image/svg+xml;base64,${pluginSet.images[isSelected(pluginSet.id) ? "check" : "standard"]}`}
/>
</label>
<HiddenInput type="checkbox" id={`plugin-set-${idx}`} {...register(pluginSet.id)} />
</PluginSetCard>
))}
<BorderedDiv className="card-block has-border-danger px-4 pt-3">
<Checkbox
disabled={hasPluginSets}
checked={skipInstallation}
onChange={setSkipInstallation}
title={t("pluginWizardStep.skip.title")}
label={t("pluginWizardStep.skip.subtitle")}
></Checkbox>
</BorderedDiv>
</div>
<SubmitButton
scrollToTop={false}
disabled={isInstalling || !(hasPluginSets || skipInstallation)}
loading={isInstalling}
className="is-align-self-flex-end"
>
{t(`pluginWizardStep.${hasPluginSets ? "submitAndRestart" : "submit"}`)}
</SubmitButton>
</form>
);
}
return (
<HeroSection className="hero">
<div className="hero-body">
<div className="container">
<div className="columns is-centered">
<div className="column is-8 box has-background-secondary-less">
<h3 className="title">{t("title")}</h3>
<h4 className="subtitle">{t("pluginWizardStep.title")}</h4>
<p className="is-size-6 block">{t("pluginWizardStep.description")}</p>
{content}
</div>
</div>
</div>
</div>
</HeroSection>
);
};
export default InitializationPluginWizardStep;

View File

@@ -27,14 +27,20 @@ package sonia.scm.api.v2.resources;
import de.otto.edison.hal.Embedded;
import de.otto.edison.hal.Links;
import lombok.Data;
import org.apache.shiro.SecurityUtils;
import org.apache.shiro.authz.UnauthenticatedException;
import sonia.scm.initialization.InitializationAuthenticationService;
import sonia.scm.initialization.InitializationStepResource;
import sonia.scm.lifecycle.AdminAccountStartupAction;
import sonia.scm.plugin.Extension;
import sonia.scm.security.AllowAnonymousAccess;
import sonia.scm.security.Tokens;
import sonia.scm.util.ValidationUtil;
import javax.inject.Inject;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.validation.Valid;
import javax.validation.constraints.Email;
import javax.validation.constraints.NotEmpty;
@@ -42,9 +48,12 @@ import javax.validation.constraints.Pattern;
import javax.ws.rs.Consumes;
import javax.ws.rs.POST;
import javax.ws.rs.Path;
import javax.ws.rs.core.Context;
import javax.ws.rs.core.Response;
import static de.otto.edison.hal.Link.link;
import static sonia.scm.ScmConstraintViolationException.Builder.doThrow;
import static sonia.scm.initialization.InitializationWebTokenGenerator.INIT_TOKEN_HEADER;
@AllowAnonymousAccess
@Extension
@@ -52,20 +61,34 @@ public class AdminAccountStartupResource implements InitializationStepResource {
private final AdminAccountStartupAction adminAccountStartupAction;
private final ResourceLinks resourceLinks;
private final InitializationAuthenticationService authenticationService;
@Inject
public AdminAccountStartupResource(AdminAccountStartupAction adminAccountStartupAction, ResourceLinks resourceLinks) {
public AdminAccountStartupResource(AdminAccountStartupAction adminAccountStartupAction, ResourceLinks resourceLinks, InitializationAuthenticationService authenticationService) {
this.adminAccountStartupAction = adminAccountStartupAction;
this.resourceLinks = resourceLinks;
this.authenticationService = authenticationService;
}
@POST
@Path("")
@Consumes("application/json")
public void postAdminInitializationData(@Valid AdminInitializationData data) {
public Response postAdminInitializationData(
@Context HttpServletRequest request,
@Context HttpServletResponse response,
@Valid AdminInitializationData data
) {
verifyInInitialization();
verifyToken(data);
createAdminUser(data);
// Invalidate old access token cookies to prevent conflicts during authentication
authenticationService.invalidateCookies(request, response);
SecurityUtils.getSubject().login(Tokens.createAuthenticationToken(request, data.userName, data.password));
// Create cookie which will be used for authentication during the initialization process
authenticationService.authenticate(request, response);
return Response.noContent().build();
}
private void verifyInInitialization() {

View File

@@ -168,7 +168,7 @@ public class AvailablePluginResource {
)
@ApiResponse(responseCode = "200", description = "success")
@ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials")
@ApiResponse(responseCode = "403", description = "not authorized, the current user does not have the \"plugin:manage\" privilege")
@ApiResponse(responseCode = "403", description = "not authorized, the current user does not have the \"plugin:write\" privilege")
@ApiResponse(
responseCode = "500",
description = "internal server error",

View File

@@ -47,6 +47,7 @@ import sonia.scm.web.EdisonHalAppender;
import javax.inject.Inject;
import java.util.List;
import java.util.Locale;
import java.util.stream.Collectors;
import static de.otto.edison.hal.Embedded.embeddedBuilder;
@@ -75,6 +76,10 @@ public class IndexDtoGenerator extends HalAppenderMapper {
}
public IndexDto generate() {
return generate(Locale.getDefault());
}
public IndexDto generate(Locale locale) {
Links.Builder builder = Links.linkingTo();
Embedded.Builder embeddedBuilder = embeddedBuilder();
@@ -84,7 +89,7 @@ public class IndexDtoGenerator extends HalAppenderMapper {
if (initializationFinisher.isFullyInitialized()) {
return handleNormalIndex(builder, embeddedBuilder);
} else {
return handleInitialization(builder, embeddedBuilder);
return handleInitialization(builder, embeddedBuilder, locale);
}
}
@@ -170,11 +175,11 @@ public class IndexDtoGenerator extends HalAppenderMapper {
.collect(Collectors.toList());
}
private IndexDto handleInitialization(Links.Builder builder, Embedded.Builder embeddedBuilder) {
private IndexDto handleInitialization(Links.Builder builder, Embedded.Builder embeddedBuilder, Locale locale) {
Links.Builder initializationLinkBuilder = Links.linkingTo();
Embedded.Builder initializationEmbeddedBuilder = embeddedBuilder();
InitializationStep initializationStep = initializationFinisher.missingInitialization();
initializationFinisher.getResource(initializationStep.name()).setupIndex(initializationLinkBuilder, initializationEmbeddedBuilder);
initializationFinisher.getResource(initializationStep.name()).setupIndex(initializationLinkBuilder, initializationEmbeddedBuilder, locale);
embeddedBuilder.with(initializationStep.name(), new InitializationDto(initializationLinkBuilder.build(), initializationEmbeddedBuilder.build()));
return new IndexDto(builder.build(), embeddedBuilder.build(), scmContextProvider.getVersion(), scmContextProvider.getInstanceId(), initializationStep.name());
}

View File

@@ -35,9 +35,11 @@ import sonia.scm.security.AllowAnonymousAccess;
import sonia.scm.web.VndMediaType;
import javax.inject.Inject;
import javax.servlet.http.HttpServletRequest;
import javax.ws.rs.GET;
import javax.ws.rs.Path;
import javax.ws.rs.Produces;
import javax.ws.rs.core.Context;
@OpenAPIDefinition(
security = {
@@ -80,7 +82,7 @@ public class IndexResource {
schema = @Schema(implementation = ErrorDto.class)
)
)
public IndexDto getIndex() {
return indexDtoGenerator.generate();
public IndexDto getIndex(@Context HttpServletRequest request) {
return indexDtoGenerator.generate(request.getLocale());
}
}

View File

@@ -112,7 +112,7 @@ public class InstalledPluginResource {
)
@ApiResponse(responseCode = "200", description = "success")
@ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials")
@ApiResponse(responseCode = "403", description = "not authorized, the current user does not have the \"plugin:manage\" privilege")
@ApiResponse(responseCode = "403", description = "not authorized, the current user does not have the \"plugin:write\" privilege")
@ApiResponse(
responseCode = "500",
description = "internal server error",
@@ -191,7 +191,7 @@ public class InstalledPluginResource {
)
@ApiResponse(responseCode = "200", description = "success")
@ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials")
@ApiResponse(responseCode = "403", description = "not authorized, the current user does not have the \"plugin:manage\" privilege")
@ApiResponse(responseCode = "403", description = "not authorized, the current user does not have the \"plugin:write\" privilege")
@ApiResponse(
responseCode = "500",
description = "internal server error",

View File

@@ -160,7 +160,7 @@ public class PendingPluginResource {
)
@ApiResponse(responseCode = "200", description = "success")
@ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials")
@ApiResponse(responseCode = "403", description = "not authorized, the current user does not have the \"plugin:manage\" privilege")
@ApiResponse(responseCode = "403", description = "not authorized, the current user does not have the \"plugin:write\" privilege")
@ApiResponse(
responseCode = "500",
description = "internal server error",
@@ -183,7 +183,7 @@ public class PendingPluginResource {
)
@ApiResponse(responseCode = "200", description = "success")
@ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials")
@ApiResponse(responseCode = "403", description = "not authorized, the current user does not have the \"plugin:manage\" privilege")
@ApiResponse(responseCode = "403", description = "not authorized, the current user does not have the \"plugin:write\" privilege")
@ApiResponse(
responseCode = "500",
description = "internal server error",

View File

@@ -0,0 +1,42 @@
/*
* 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.HalRepresentation;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import java.util.Set;
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@SuppressWarnings("squid:S2160") // we do not need equals for dto
public class PluginSetCollectionDto extends HalRepresentation {
Set<PluginSetDto> pluginSets;
}

View File

@@ -0,0 +1,49 @@
/*
* 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.HalRepresentation;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import java.util.List;
import java.util.Map;
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@SuppressWarnings("squid:S2160") // we do not need equals for dto
public class PluginSetDto extends HalRepresentation {
private String id;
private int sequence;
private List<PluginDto> plugins;
private String name;
private List<String> features;
private Map<String, String> images;
}

View File

@@ -0,0 +1,65 @@
/*
* 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 sonia.scm.plugin.AvailablePlugin;
import sonia.scm.plugin.PluginSet;
import javax.inject.Inject;
import java.util.Collection;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
import java.util.Optional;
import java.util.stream.Collectors;
public class PluginSetDtoMapper {
private final PluginDtoMapper pluginDtoMapper;
@Inject
protected PluginSetDtoMapper(PluginDtoMapper pluginDtoMapper) {
this.pluginDtoMapper = pluginDtoMapper;
}
public List<PluginSetDto> map(Collection<PluginSet> pluginSets, List<AvailablePlugin> availablePlugins, Locale locale) {
return pluginSets.stream()
.map(it -> map(it, availablePlugins, locale))
.sorted(Comparator.comparingInt(PluginSetDto::getSequence))
.collect(Collectors.toList());
}
private PluginSetDto map(PluginSet pluginSet, List<AvailablePlugin> availablePlugins, Locale locale) {
List<PluginDto> pluginDtos = pluginSet.getPlugins().stream()
.map(it -> availablePlugins.stream().filter(avail -> avail.getDescriptor().getInformation().getName().equals(it)).findFirst())
.filter(Optional::isPresent)
.map(Optional::get)
.map(pluginDtoMapper::mapAvailable)
.collect(Collectors.toList());
PluginSet.Description description = pluginSet.getDescriptions().get(locale.getLanguage());
return new PluginSetDto(pluginSet.getId(), pluginSet.getSequence(), pluginDtos, description.getName(), description.getFeatures(), pluginSet.getImages());
}
}

View File

@@ -0,0 +1,42 @@
/*
* 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 lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import javax.validation.constraints.NotNull;
import java.util.Set;
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class PluginSetsInstallDto {
@NotNull
private Set<String> pluginSetIds;
}

View File

@@ -0,0 +1,149 @@
/*
* 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.Links;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.parameters.RequestBody;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import sonia.scm.initialization.InitializationStepResource;
import sonia.scm.lifecycle.PluginWizardStartupAction;
import sonia.scm.lifecycle.PrivilegedStartupAction;
import sonia.scm.plugin.AvailablePlugin;
import sonia.scm.plugin.Extension;
import sonia.scm.plugin.PluginManager;
import sonia.scm.plugin.PluginSet;
import sonia.scm.security.AccessTokenCookieIssuer;
import sonia.scm.web.VndMediaType;
import sonia.scm.web.security.AdministrationContext;
import javax.inject.Inject;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.validation.Valid;
import javax.ws.rs.Consumes;
import javax.ws.rs.POST;
import javax.ws.rs.Path;
import javax.ws.rs.core.Context;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import static de.otto.edison.hal.Link.link;
import static sonia.scm.ScmConstraintViolationException.Builder.doThrow;
@Extension
public class PluginWizardStartupResource implements InitializationStepResource {
private final PluginWizardStartupAction pluginWizardStartupAction;
private final ResourceLinks resourceLinks;
private final PluginManager pluginManager;
private final AccessTokenCookieIssuer cookieIssuer;
private final PluginSetDtoMapper pluginSetDtoMapper;
private final AdministrationContext context;
@Inject
public PluginWizardStartupResource(PluginWizardStartupAction pluginWizardStartupAction, ResourceLinks resourceLinks, PluginManager pluginManager, AccessTokenCookieIssuer cookieIssuer, PluginSetDtoMapper pluginSetDtoMapper, AdministrationContext context) {
this.pluginWizardStartupAction = pluginWizardStartupAction;
this.resourceLinks = resourceLinks;
this.pluginManager = pluginManager;
this.cookieIssuer = cookieIssuer;
this.pluginSetDtoMapper = pluginSetDtoMapper;
this.context = context;
}
@Override
public void setupIndex(Links.Builder builder, Embedded.Builder embeddedBuilder) {
setupIndex(builder, embeddedBuilder, Locale.getDefault());
}
@Override
public void setupIndex(Links.Builder builder, Embedded.Builder embeddedBuilder, Locale locale) {
context.runAsAdmin((PrivilegedStartupAction)() -> {
Set<PluginSet> pluginSets = pluginManager.getPluginSets();
List<AvailablePlugin> availablePlugins = pluginManager.getAvailable();
List<PluginSetDto> pluginSetDtos = pluginSetDtoMapper.map(pluginSets, availablePlugins, locale);
embeddedBuilder.with("pluginSets", pluginSetDtos);
String link = resourceLinks.pluginWizard().indexLink(name());
builder.single(link("installPluginSets", link));
});
}
@Override
public String name() {
return pluginWizardStartupAction.name();
}
@POST
@Path("")
@Consumes("application/json")
@Operation(
summary = "Install plugin sets and restart",
description = "Installs all plugins contained in the provided plugin sets and restarts the server",
tags = "Plugin Management",
requestBody = @RequestBody(
content = @Content(
mediaType = MediaType.APPLICATION_JSON,
schema = @Schema(implementation = PluginSetsInstallDto.class)
)
)
)
@ApiResponse(responseCode = "200", description = "success")
@ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials")
@ApiResponse(responseCode = "403", description = "not authorized, the current user does not have the \"plugin:write\" privilege")
@ApiResponse(
responseCode = "500",
description = "internal server error",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
)
)
public Response installPluginSets(@Context HttpServletRequest request,
@Context HttpServletResponse response,
@Valid PluginSetsInstallDto dto) {
verifyInInitialization();
cookieIssuer.invalidate(request, response);
pluginManager.installPluginSets(dto.getPluginSetIds(), true);
return Response.ok().build();
}
private void verifyInInitialization() {
doThrow()
.violation("initialization not necessary")
.when(pluginWizardStartupAction.done());
}
}

View File

@@ -1207,6 +1207,25 @@ class ResourceLinks {
}
}
public PluginWizardLinks pluginWizard() {
return new PluginWizardLinks(new LinkBuilder(accessScmPathInfoStore().get(), InitializationResource.class, PluginWizardStartupResource.class));
}
public static class PluginWizardLinks {
private final LinkBuilder initializationLinkBuilder;
private PluginWizardLinks(LinkBuilder initializationLinkBuilder) {
this.initializationLinkBuilder = initializationLinkBuilder;
}
public String indexLink(String stepName) {
return initializationLinkBuilder
.method("step").parameters(stepName)
.method("installPluginSets").parameters()
.href();
}
}
public PluginCenterAuthLinks pluginCenterAuth() {
return new PluginCenterAuthLinks(scmPathInfoStore.get().get());
}

View File

@@ -0,0 +1,89 @@
/*
* 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.initialization;
import org.apache.shiro.authc.AuthenticationException;
import sonia.scm.security.AccessToken;
import sonia.scm.security.AccessTokenBuilderFactory;
import sonia.scm.security.AccessTokenCookieIssuer;
import sonia.scm.security.PermissionAssigner;
import sonia.scm.security.PermissionDescriptor;
import sonia.scm.web.security.AdministrationContext;
import javax.inject.Inject;
import javax.inject.Singleton;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.Set;
import java.util.concurrent.TimeUnit;
@Singleton
public class InitializationAuthenticationService {
private static final String INITIALIZATION_SUBJECT = "SCM-INIT";
private final AccessTokenBuilderFactory tokenBuilderFactory;
private final PermissionAssigner permissionAssigner;
private final AccessTokenCookieIssuer cookieIssuer;
private final InitializationCookieIssuer initializationCookieIssuer;
private final AdministrationContext administrationContext;
@Inject
public InitializationAuthenticationService(AccessTokenBuilderFactory tokenBuilderFactory, PermissionAssigner permissionAssigner, AccessTokenCookieIssuer cookieIssuer, InitializationCookieIssuer initializationCookieIssuer, AdministrationContext administrationContext) {
this.tokenBuilderFactory = tokenBuilderFactory;
this.permissionAssigner = permissionAssigner;
this.cookieIssuer = cookieIssuer;
this.initializationCookieIssuer = initializationCookieIssuer;
this.administrationContext = administrationContext;
}
public void validateToken(AccessToken token) {
if (token == null || !INITIALIZATION_SUBJECT.equals(token.getSubject())) {
throw new AuthenticationException("Could not authenticate to initialization realm because of missing or invalid token.");
}
}
public void setPermissions() {
administrationContext.runAsAdmin(() -> permissionAssigner.setPermissionsForUser(
InitializationRealm.INIT_PRINCIPAL,
Set.of(new PermissionDescriptor("plugin:read,write"))
));
}
public void authenticate(HttpServletRequest request, HttpServletResponse response) {
AccessToken initToken =
tokenBuilderFactory.create()
.subject(INITIALIZATION_SUBJECT)
.expiresIn(365, TimeUnit.DAYS)
.refreshableFor(0, TimeUnit.SECONDS)
.build();
initializationCookieIssuer.authenticateForInitialization(request, response, initToken);
}
public void invalidateCookies(HttpServletRequest request, HttpServletResponse response) {
cookieIssuer.invalidate(request, response);
}
}

View File

@@ -0,0 +1,48 @@
/*
* 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.initialization;
import sonia.scm.security.AccessToken;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
/**
* Generates cookies and invalidates initialization token cookies.
*
* @author Sebastian Sdorra
* @since 2.35.0
*/
public interface InitializationCookieIssuer {
/**
* Creates a cookie for token authentication and attaches it to the response.
*
* @param request http servlet request
* @param response http servlet response
* @param accessToken initialization access token
*/
void authenticateForInitialization(HttpServletRequest request, HttpServletResponse response, AccessToken accessToken);
}

View File

@@ -0,0 +1,78 @@
/*
* 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.initialization;
import org.apache.shiro.authc.AuthenticationException;
import org.apache.shiro.authc.AuthenticationInfo;
import org.apache.shiro.authc.AuthenticationToken;
import org.apache.shiro.authc.SimpleAuthenticationInfo;
import org.apache.shiro.authc.credential.AllowAllCredentialsMatcher;
import org.apache.shiro.realm.AuthenticatingRealm;
import org.apache.shiro.subject.SimplePrincipalCollection;
import sonia.scm.plugin.Extension;
import sonia.scm.security.AccessToken;
import sonia.scm.security.AccessTokenResolver;
import sonia.scm.security.BearerToken;
import sonia.scm.user.User;
import javax.inject.Inject;
import javax.inject.Singleton;
import static com.google.common.base.Preconditions.checkArgument;
@Extension
@Singleton
public class InitializationRealm extends AuthenticatingRealm {
private static final String REALM = "InitializationRealm";
public static final String INIT_PRINCIPAL = "__SCM_INIT__";
private final InitializationAuthenticationService authenticationService;
private final AccessTokenResolver accessTokenResolver;
@Inject
public InitializationRealm(InitializationAuthenticationService authenticationService, AccessTokenResolver accessTokenResolver) {
this.authenticationService = authenticationService;
this.accessTokenResolver = accessTokenResolver;
setAuthenticationTokenClass(InitializationToken.class);
setCredentialsMatcher(new AllowAllCredentialsMatcher());
}
@Override
protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {
checkArgument(token instanceof InitializationToken, "%s is required", InitializationToken.class);
AccessToken accessToken = accessTokenResolver.resolve(BearerToken.valueOf(token.getCredentials().toString()));
authenticationService.validateToken(accessToken);
SimplePrincipalCollection principalCollection = new SimplePrincipalCollection(INIT_PRINCIPAL, REALM);
principalCollection.add(new User(INIT_PRINCIPAL), REALM);
authenticationService.setPermissions();
return new SimpleAuthenticationInfo(principalCollection, token.getCredentials());
}
@Override
public boolean supports(AuthenticationToken token) {
return token instanceof InitializationToken;
}
}

View File

@@ -0,0 +1,49 @@
/*
* 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.initialization;
import org.apache.shiro.authc.AuthenticationToken;
public class InitializationToken implements AuthenticationToken {
private final String token;
private final String principal;
public InitializationToken(String token, String principal) {
this.token = token;
this.principal = principal;
}
@Override
public Object getPrincipal() {
return principal;
}
@Override
public Object getCredentials() {
return token;
}
}

View File

@@ -0,0 +1,52 @@
/*
* 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.initialization;
import org.apache.shiro.authc.AuthenticationToken;
import sonia.scm.plugin.Extension;
import sonia.scm.web.WebTokenGenerator;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
@Extension
public class InitializationWebTokenGenerator implements WebTokenGenerator {
public static final String INIT_TOKEN_HEADER = "X-SCM-Init-Token";
@Override
public AuthenticationToken createToken(HttpServletRequest request) {
Cookie[] cookies = request.getCookies();
AuthenticationToken token = null;
if (cookies != null) {
for (Cookie cookie : cookies) {
if (cookie.getName().equals(INIT_TOKEN_HEADER)) {
token = new InitializationToken(cookie.getValue(), "SCM_INIT");
}
}
}
return token;
}
}

View File

@@ -48,7 +48,7 @@ public class AdminAccountStartupAction implements InitializationStep {
private static final Logger LOG = LoggerFactory.getLogger(AdminAccountStartupAction.class);
private static final String INITIAL_PASSWORD_PROPERTY = "scm.initialPassword";
public static final String INITIAL_PASSWORD_PROPERTY = "scm.initialPassword";
private static final String INITIAL_USER_PROPERTY = "scm.initialUser";
private final PasswordService passwordService;

View File

@@ -0,0 +1,60 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package sonia.scm.lifecycle;
import sonia.scm.initialization.InitializationStep;
import sonia.scm.plugin.Extension;
import sonia.scm.plugin.PluginSetConfigStore;
import javax.inject.Inject;
import javax.inject.Singleton;
@Extension
@Singleton
public class PluginWizardStartupAction implements InitializationStep {
private final PluginSetConfigStore store;
@Inject
public PluginWizardStartupAction(PluginSetConfigStore pluginSetConfigStore) {
this.store = pluginSetConfigStore;
}
@Override
public String name() {
return "pluginWizard";
}
@Override
public int sequence() {
return 1;
}
@Override
public boolean done() {
return System.getProperty(AdminAccountStartupAction.INITIAL_PASSWORD_PROPERTY) != null || store.getPluginSets().isPresent();
}
}

View File

@@ -28,4 +28,4 @@ import sonia.scm.plugin.ExtensionPoint;
import sonia.scm.web.security.PrivilegedAction;
@ExtensionPoint
interface PrivilegedStartupAction extends PrivilegedAction {}
public interface PrivilegedStartupAction extends PrivilegedAction {}

View File

@@ -58,6 +58,7 @@ import sonia.scm.group.GroupManager;
import sonia.scm.group.GroupManagerProvider;
import sonia.scm.group.xml.XmlGroupDAO;
import sonia.scm.initialization.DefaultInitializationFinisher;
import sonia.scm.initialization.InitializationCookieIssuer;
import sonia.scm.initialization.InitializationFinisher;
import sonia.scm.io.ContentTypeResolver;
import sonia.scm.io.DefaultContentTypeResolver;
@@ -271,6 +272,7 @@ class ScmServletModule extends ServletModule {
// bind events
bind(AccessTokenCookieIssuer.class).to(DefaultAccessTokenCookieIssuer.class);
bind(InitializationCookieIssuer.class).to(DefaultAccessTokenCookieIssuer.class);
bind(PushStateDispatcher.class).toProvider(PushStateDispatcherProvider.class);
// bind api link provider

View File

@@ -39,6 +39,7 @@ import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashSet;
import java.util.List;
import java.util.Optional;
import java.util.Set;
@@ -65,6 +66,8 @@ public class DefaultPluginManager implements PluginManager {
private final Restarter restarter;
private final ScmEventBus eventBus;
private final PluginSetConfigStore pluginSetConfigStore;
private final Collection<PendingPluginInstallation> pendingInstallQueue = new ArrayList<>();
private final Collection<PendingPluginUninstallation> pendingUninstallQueue = new ArrayList<>();
private final PluginDependencyTracker dependencyTracker = new PluginDependencyTracker();
@@ -72,16 +75,17 @@ public class DefaultPluginManager implements PluginManager {
private final Function<List<AvailablePlugin>, PluginInstallationContext> contextFactory;
@Inject
public DefaultPluginManager(PluginLoader loader, PluginCenter center, PluginInstaller installer, Restarter restarter, ScmEventBus eventBus) {
this(loader, center, installer, restarter, eventBus, null);
public DefaultPluginManager(PluginLoader loader, PluginCenter center, PluginInstaller installer, Restarter restarter, ScmEventBus eventBus, PluginSetConfigStore pluginSetConfigStore) {
this(loader, center, installer, restarter, eventBus, null, pluginSetConfigStore);
}
DefaultPluginManager(PluginLoader loader, PluginCenter center, PluginInstaller installer, Restarter restarter, ScmEventBus eventBus, Function<List<AvailablePlugin>, PluginInstallationContext> contextFactory) {
DefaultPluginManager(PluginLoader loader, PluginCenter center, PluginInstaller installer, Restarter restarter, ScmEventBus eventBus, Function<List<AvailablePlugin>, PluginInstallationContext> contextFactory, PluginSetConfigStore pluginSetConfigStore) {
this.loader = loader;
this.center = center;
this.installer = installer;
this.restarter = restarter;
this.eventBus = eventBus;
this.pluginSetConfigStore = pluginSetConfigStore;
if (contextFactory != null) {
this.contextFactory = contextFactory;
@@ -109,7 +113,7 @@ public class DefaultPluginManager implements PluginManager {
@Override
public Optional<AvailablePlugin> getAvailable(String name) {
PluginPermissions.read().check();
return center.getAvailable()
return center.getAvailablePlugins()
.stream()
.filter(filterByName(name))
.filter(this::isNotInstalledOrMoreUpToDate)
@@ -143,13 +147,49 @@ public class DefaultPluginManager implements PluginManager {
@Override
public List<AvailablePlugin> getAvailable() {
PluginPermissions.read().check();
return center.getAvailable()
return center.getAvailablePlugins()
.stream()
.filter(this::isNotInstalledOrMoreUpToDate)
.map(p -> getPending(p.getDescriptor().getInformation().getName()).orElse(p))
.collect(Collectors.toList());
}
@Override
public Set<PluginSet> getPluginSets() {
PluginPermissions.read().check();
return center.getAvailablePluginSets();
}
@Override
public void installPluginSets(Set<String> pluginSetIds, boolean restartAfterInstallation) {
PluginPermissions.write().check();
Set<PluginSet> pluginSets = getPluginSets();
Set<PluginSet> pluginSetsToInstall = pluginSetIds.stream()
.map(id -> pluginSets.stream().filter(pluginSet -> pluginSet.getId().equals(id)).findFirst())
.filter(Optional::isPresent)
.map(Optional::get)
.collect(Collectors.toSet());
Set<AvailablePlugin> pluginsToInstall = pluginSetsToInstall
.stream()
.flatMap(pluginSet -> pluginSet
.getPlugins()
.stream()
.map(this::collectPluginsToInstall)
.flatMap(Collection::stream)
)
.collect(Collectors.toSet());
Set<String> newlyInstalledPluginSetIds = pluginSetsToInstall.stream().map(PluginSet::getId).collect(Collectors.toSet());
Set<String> installedPluginSetIds = pluginSetConfigStore.getPluginSets().map(PluginSetsConfig::getPluginSets).orElse(new HashSet<>());
installedPluginSetIds.addAll(newlyInstalledPluginSetIds);
pluginSetConfigStore.setPluginSets(new PluginSetsConfig(installedPluginSetIds));
installPlugins(new ArrayList<>(pluginsToInstall), restartAfterInstallation);
}
@Override
public List<InstalledPlugin> getUpdatable() {
return getInstalled()
@@ -184,6 +224,10 @@ public class DefaultPluginManager implements PluginManager {
);
List<AvailablePlugin> plugins = collectPluginsToInstall(name);
installPlugins(plugins, restartAfterInstallation);
}
private void installPlugins(List<AvailablePlugin> plugins, boolean restartAfterInstallation) {
List<PendingPluginInstallation> pendingInstallations = new ArrayList<>();
for (AvailablePlugin plugin : plugins) {

View File

@@ -42,52 +42,61 @@ import java.util.Set;
@Singleton
public class PluginCenter {
private static final String CACHE_NAME = "sonia.cache.plugins";
private static final String PLUGIN_CENTER_RESULT_CACHE_NAME = "sonia.cache.plugin-center";
private static final Logger LOG = LoggerFactory.getLogger(PluginCenter.class);
private final SCMContextProvider context;
private final ScmConfiguration configuration;
private final PluginCenterLoader loader;
private final Cache<String, Set<AvailablePlugin>> cache;
private final Cache<String, PluginCenterResult> pluginCenterResultCache;
@Inject
public PluginCenter(SCMContextProvider context, CacheManager cacheManager, ScmConfiguration configuration, PluginCenterLoader loader) {
this.context = context;
this.configuration = configuration;
this.loader = loader;
this.cache = cacheManager.getCache(CACHE_NAME);
this.pluginCenterResultCache = cacheManager.getCache(PLUGIN_CENTER_RESULT_CACHE_NAME);
}
@Subscribe
public void handle(PluginCenterAuthenticationEvent event) {
LOG.debug("clear plugin center cache, because of {}", event);
cache.clear();
pluginCenterResultCache.clear();
}
synchronized Set<AvailablePlugin> getAvailable() {
synchronized Set<AvailablePlugin> getAvailablePlugins() {
String url = buildPluginUrl(configuration.getPluginUrl());
Set<AvailablePlugin> plugins = cache.get(url);
if (plugins == null) {
LOG.debug("no cached available plugins found, start fetching");
plugins = fetchAvailablePlugins(url);
} else {
LOG.debug("return available plugins from cache");
return getPluginCenterResult(url).getPlugins();
}
return plugins;
synchronized Set<PluginSet> getAvailablePluginSets() {
String url = buildPluginUrl(configuration.getPluginUrl());
return getPluginCenterResult(url).getPluginSets();
}
private PluginCenterResult getPluginCenterResult(String url) {
PluginCenterResult pluginCenterResult = pluginCenterResultCache.get(url);
if (pluginCenterResult == null) {
LOG.debug("no cached plugin center result found, start fetching");
pluginCenterResult = fetchPluginCenter(url);
} else {
LOG.debug("return plugin center result from cache");
}
return pluginCenterResult;
}
@CanIgnoreReturnValue
private Set<AvailablePlugin> fetchAvailablePlugins(String url) {
Set<AvailablePlugin> plugins = loader.load(url);
cache.put(url, plugins);
return plugins;
private PluginCenterResult fetchPluginCenter(String url) {
PluginCenterResult pluginCenterResult = loader.load(url);
pluginCenterResultCache.put(url, pluginCenterResult);
return pluginCenterResult;
}
synchronized void refresh() {
LOG.debug("refresh plugin center cache");
String url = buildPluginUrl(configuration.getPluginUrl());
fetchAvailablePlugins(url);
fetchPluginCenter(url);
}
private String buildPluginUrl(String url) {

View File

@@ -24,7 +24,6 @@
package sonia.scm.plugin;
import com.google.common.collect.ImmutableList;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
@@ -56,12 +55,22 @@ public final class PluginCenterDto implements Serializable {
@XmlElement(name = "plugins")
private List<Plugin> plugins;
@XmlElement(name = "plugin-sets")
private List<PluginSet> pluginSets;
public List<Plugin> getPlugins() {
if (plugins == null) {
plugins = ImmutableList.of();
plugins = List.of();
}
return plugins;
}
public List<PluginSet> getPluginSets() {
if (pluginSets == null) {
pluginSets = List.of();
}
return pluginSets;
}
}
@XmlAccessorType(XmlAccessType.FIELD)
@@ -93,6 +102,36 @@ public final class PluginCenterDto implements Serializable {
private final Map<String, Link> links;
}
@XmlAccessorType(XmlAccessType.FIELD)
@XmlRootElement(name = "pluginSets")
@Getter
@AllArgsConstructor
public static class PluginSet {
private final String id;
private final String versions;
private final int sequence;
@XmlElement(name = "plugins")
private final Set<String> plugins;
@XmlElement(name = "descriptions")
private final Map<String, Description> descriptions;
@XmlElement(name = "images")
private final Map<String, String> images;
}
@XmlAccessorType(XmlAccessType.FIELD)
@Getter
@NoArgsConstructor
@AllArgsConstructor
public static class Description {
private String name;
@XmlElement(name = "features")
private List<String> features;
}
@XmlAccessorType(XmlAccessType.FIELD)
@XmlRootElement(name = "conditions")
@Getter

View File

@@ -29,18 +29,31 @@ import org.mapstruct.factory.Mappers;
import java.util.HashSet;
import java.util.Set;
import java.util.stream.Collectors;
@Mapper
public abstract class PluginCenterDtoMapper {
PluginCenterDtoMapper() {}
static final PluginCenterDtoMapper INSTANCE = Mappers.getMapper(PluginCenterDtoMapper.class);
abstract PluginInformation map(PluginCenterDto.Plugin plugin);
abstract PluginCondition map(PluginCenterDto.Condition condition);
Set<AvailablePlugin> map(PluginCenterDto pluginCenterDto) {
abstract PluginSet map(PluginCenterDto.PluginSet set);
abstract PluginSet.Description map(PluginCenterDto.Description description);
PluginCenterResult map(PluginCenterDto pluginCenterDto) {
Set<AvailablePlugin> plugins = new HashSet<>();
Set<PluginSet> pluginSets = pluginCenterDto
.getEmbedded()
.getPluginSets()
.stream()
.map(this::map)
.collect(Collectors.toSet());
for (PluginCenterDto.Plugin plugin : pluginCenterDto.getEmbedded().getPlugins()) {
// plugin center api returns always a download link,
// but for cloudogu plugin without authentication the href is an empty string
@@ -51,7 +64,7 @@ public abstract class PluginCenterDtoMapper {
);
plugins.add(new AvailablePlugin(descriptor));
}
return plugins;
return new PluginCenterResult(plugins, pluginSets);
}
private String getInstallLink(PluginCenterDto.Plugin plugin) {

View File

@@ -33,7 +33,6 @@ import sonia.scm.net.ahc.AdvancedHttpRequest;
import javax.inject.Inject;
import java.util.Collections;
import java.util.Set;
import static sonia.scm.plugin.Tracing.SPAN_KIND;
@@ -64,7 +63,7 @@ class PluginCenterLoader {
this.eventBus = eventBus;
}
Set<AvailablePlugin> load(String url) {
PluginCenterResult load(String url) {
try {
LOG.info("fetch plugins from {}", url);
AdvancedHttpRequest request = client.get(url).spanKind(SPAN_KIND);
@@ -76,7 +75,7 @@ class PluginCenterLoader {
} catch (Exception ex) {
LOG.error("failed to load plugins from plugin center, returning empty list", ex);
eventBus.post(new PluginCenterErrorEvent());
return Collections.emptySet();
return new PluginCenterResult(Collections.emptySet(), Collections.emptySet());
}
}
}

View File

@@ -0,0 +1,37 @@
/*
* 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.plugin;
import lombok.AllArgsConstructor;
import lombok.Getter;
import java.util.Set;
@AllArgsConstructor
@Getter
class PluginCenterResult {
private Set<AvailablePlugin> plugins;
private Set<PluginSet> pluginSets;
}

View File

@@ -0,0 +1,51 @@
/*
* 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.plugin;
import sonia.scm.store.ConfigurationStore;
import sonia.scm.store.ConfigurationStoreFactory;
import javax.inject.Inject;
import javax.inject.Singleton;
import java.util.Optional;
@Singleton
public class PluginSetConfigStore {
private final ConfigurationStore<PluginSetsConfig> pluginSets;
@Inject
PluginSetConfigStore(ConfigurationStoreFactory configurationStoreFactory) {
pluginSets = configurationStoreFactory.withType(PluginSetsConfig.class).withName("pluginSets").build();
}
public Optional<PluginSetsConfig> getPluginSets() {
return pluginSets.getOptional();
}
public void setPluginSets(PluginSetsConfig config) {
this.pluginSets.set(config);
}
}

View File

@@ -0,0 +1,45 @@
/*
* 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.plugin;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import javax.xml.bind.annotation.XmlAccessType;
import javax.xml.bind.annotation.XmlAccessorType;
import javax.xml.bind.annotation.XmlElement;
import javax.xml.bind.annotation.XmlRootElement;
import java.util.Set;
@Data
@XmlRootElement
@AllArgsConstructor
@NoArgsConstructor
@XmlAccessorType(XmlAccessType.FIELD)
public class PluginSetsConfig {
@XmlElement(name = "pluginSets")
Set<String> pluginSets;
}

View File

@@ -29,6 +29,7 @@ import com.google.common.base.Strings;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import sonia.scm.config.ScmConfiguration;
import sonia.scm.initialization.InitializationCookieIssuer;
import sonia.scm.util.HttpUtil;
import sonia.scm.util.Util;
@@ -36,16 +37,18 @@ import javax.inject.Inject;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.Date;
import java.util.Arrays;
import java.util.concurrent.TimeUnit;
import static sonia.scm.initialization.InitializationWebTokenGenerator.INIT_TOKEN_HEADER;
/**
* Generates cookies and invalidates access token cookies.
*
* @author Sebastian Sdorra
* @since 2.0.0
*/
public final class DefaultAccessTokenCookieIssuer implements AccessTokenCookieIssuer {
public final class DefaultAccessTokenCookieIssuer implements AccessTokenCookieIssuer, InitializationCookieIssuer {
/**
* the logger for DefaultAccessTokenCookieIssuer
@@ -87,6 +90,25 @@ public final class DefaultAccessTokenCookieIssuer implements AccessTokenCookieIs
response.addCookie(c);
}
/**
* Creates a cookie for authentication during the initialization process and attaches it to the response.
*
* @param request http servlet request
* @param response http servlet response
* @param accessToken initialization access token
*/
public void authenticateForInitialization(HttpServletRequest request, HttpServletResponse response, AccessToken accessToken) {
LOG.trace("create and attach cookie for initialization access token {}", accessToken.getId());
Cookie c = new Cookie(INIT_TOKEN_HEADER, accessToken.compact());
c.setPath(contextPath(request));
c.setMaxAge(999999999);
c.setHttpOnly(isHttpOnly());
c.setSecure(isSecure(request));
// attach cookie to response
response.addCookie(c);
}
/**
* Invalidates the authentication cookie.
*
@@ -95,8 +117,20 @@ public final class DefaultAccessTokenCookieIssuer implements AccessTokenCookieIs
*/
public void invalidate(HttpServletRequest request, HttpServletResponse response) {
LOG.trace("invalidates access token cookie");
invalidateCookie(request, response, HttpUtil.COOKIE_BEARER_AUTHENTICATION);
Cookie c = new Cookie(HttpUtil.COOKIE_BEARER_AUTHENTICATION, Util.EMPTY_STRING);
if (request.getCookies() != null && Arrays.stream(request.getCookies()).anyMatch(cookie -> cookie.getName().equals(INIT_TOKEN_HEADER))) {
LOG.trace("invalidates initialization token cookie");
invalidateInitTokenCookie(request, response);
}
}
private void invalidateInitTokenCookie(HttpServletRequest request, HttpServletResponse response) {
invalidateCookie(request, response, INIT_TOKEN_HEADER);
}
private void invalidateCookie(HttpServletRequest request, HttpServletResponse response, String cookieBearerAuthentication) {
Cookie c = new Cookie(cookieBearerAuthentication, Util.EMPTY_STRING);
c.setPath(contextPath(request));
c.setMaxAge(0);
c.setHttpOnly(isHttpOnly());

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.
*/
package sonia.scm.update.plugin;
import sonia.scm.migration.UpdateStep;
import sonia.scm.plugin.Extension;
import sonia.scm.plugin.PluginSetConfigStore;
import sonia.scm.plugin.PluginSetsConfig;
import sonia.scm.user.xml.XmlUserDAO;
import sonia.scm.version.Version;
import javax.inject.Inject;
import java.util.Collections;
@Extension
public class PluginSetsConfigInitializationUpdateStep implements UpdateStep {
private final PluginSetConfigStore pluginSetConfigStore;
private final XmlUserDAO userDAO;
@Inject
public PluginSetsConfigInitializationUpdateStep(PluginSetConfigStore pluginSetConfigStore, XmlUserDAO userDAO) {
this.pluginSetConfigStore = pluginSetConfigStore;
this.userDAO = userDAO;
}
@Override
public void doUpdate() throws Exception {
if (!userDAO.getAll().isEmpty() && pluginSetConfigStore.getPluginSets().isEmpty()) {
pluginSetConfigStore.setPluginSets(new PluginSetsConfig(Collections.emptySet()));
}
}
@Override
public Version getTargetVersion() {
return Version.parse("2.0.0");
}
@Override
public String getAffectedDataType() {
return "sonia.scm.plugin.PluginSetsConfig";
}
}

View File

@@ -24,6 +24,9 @@
package sonia.scm.api.v2.resources;
import com.github.sdorra.shiro.SubjectAware;
import org.apache.shiro.subject.Subject;
import org.apache.shiro.util.ThreadContext;
import org.jboss.resteasy.mock.MockHttpRequest;
import org.jboss.resteasy.mock.MockHttpResponse;
import org.junit.jupiter.api.BeforeEach;
@@ -33,10 +36,19 @@ import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import sonia.scm.config.ScmConfiguration;
import sonia.scm.initialization.InitializationAuthenticationService;
import sonia.scm.lifecycle.AdminAccountStartupAction;
import sonia.scm.security.AccessToken;
import sonia.scm.security.AccessTokenBuilder;
import sonia.scm.security.AccessTokenBuilderFactory;
import sonia.scm.security.AccessTokenCookieIssuer;
import sonia.scm.security.DefaultAccessTokenCookieIssuer;
import sonia.scm.web.RestDispatcher;
import javax.inject.Provider;
import javax.servlet.http.HttpServletRequest;
import java.net.URI;
import java.net.URISyntaxException;
import static java.lang.String.format;
@@ -46,9 +58,13 @@ import static org.assertj.core.api.Assertions.assertThat;
import static org.jboss.resteasy.mock.MockHttpRequest.post;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@SubjectAware(
configuration = "classpath:sonia/scm/repository/shiro.ini"
)
@ExtendWith(MockitoExtension.class)
class AdminAccountStartupResourceTest {
@@ -60,9 +76,16 @@ class AdminAccountStartupResourceTest {
@Mock
private Provider<ScmPathInfoStore> pathInfoStoreProvider;
@Mock
private InitializationAuthenticationService authenticationService;
@Mock
private ScmPathInfoStore pathInfoStore;
@Mock
private ScmPathInfo pathInfo;
@Mock
private AccessTokenBuilderFactory accessTokenBuilderFactory;
@Mock
private AccessTokenBuilder accessTokenBuilder;
private final AccessTokenCookieIssuer cookieIssuer = new DefaultAccessTokenCookieIssuer(mock(ScmConfiguration.class));
@InjectMocks
private AdminAccountStartupResource resource;
@@ -73,6 +96,9 @@ class AdminAccountStartupResourceTest {
lenient().when(pathInfoStore.get()).thenReturn(pathInfo);
dispatcher.addSingletonResource(new InitializationResource(singleton(resource)));
lenient().when(startupAction.name()).thenReturn("adminAccount");
lenient().when(accessTokenBuilderFactory.create()).thenReturn(accessTokenBuilder);
AccessToken accessToken = mock(AccessToken.class);
lenient().when(accessTokenBuilder.build()).thenReturn(accessToken);
}
@Test
@@ -121,10 +147,17 @@ class AdminAccountStartupResourceTest {
@Test
void shouldCreateAdminUser() throws URISyntaxException {
Subject subject = mock(Subject.class);
ThreadContext.bind(subject);
MockHttpRequest request =
post("/v2/initialization/adminAccount")
.contentType("application/json")
.content(createInput("initial-token", "trillian", "Tricia", "tricia@hitchhiker.com", "password", "password"));
HttpServletRequest servletRequest = mock(HttpServletRequest.class);
dispatcher.putDefaultContextObject(HttpServletRequest.class, servletRequest);
dispatcher.invoke(request, response);
assertThat(response.getStatus()).isEqualTo(204);

View File

@@ -183,7 +183,7 @@ class IndexDtoGeneratorTest {
Embedded.Builder initializationEmbeddedBuilder = invocationOnMock.getArgument(1, Embedded.Builder.class);
initializationLinkBuilder.single(link("init", "/init"));
return null;
}).when(initializationStepResource).setupIndex(any(), any());
}).when(initializationStepResource).setupIndex(any(), any(), any());
IndexDto dto = generator.generate();

View File

@@ -30,19 +30,24 @@ import org.assertj.core.api.Assertions;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.Mock;
import org.mockito.junit.MockitoJUnitRunner;
import sonia.scm.SCMContextProvider;
import sonia.scm.config.ScmConfiguration;
import sonia.scm.initialization.InitializationFinisher;
import sonia.scm.plugin.PluginCenterAuthenticator;
import sonia.scm.search.SearchEngine;
import javax.servlet.http.HttpServletRequest;
import java.net.URI;
import java.util.Locale;
import java.util.Optional;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
@SubjectAware(configuration = "classpath:sonia/scm/shiro-002.ini")
@RunWith(MockitoJUnitRunner.class)
public class IndexResourceTest {
@Rule
@@ -52,8 +57,12 @@ public class IndexResourceTest {
private SCMContextProvider scmContextProvider;
private IndexResource indexResource;
@Mock
private HttpServletRequest httpServletRequest;
@Before
public void setUpObjectUnderTest() {
when(httpServletRequest.getLocale()).thenReturn(Locale.ENGLISH);
this.configuration = new ScmConfiguration();
this.scmContextProvider = mock(SCMContextProvider.class);
InitializationFinisher initializationFinisher = mock(InitializationFinisher.class);
@@ -72,7 +81,7 @@ public class IndexResourceTest {
@Test
@SubjectAware(username = "dent", password = "secret")
public void shouldRenderPluginCenterAuthLink() {
IndexDto index = indexResource.getIndex();
IndexDto index = indexResource.getIndex(httpServletRequest);
Assertions.assertThat(index.getLinks().getLinkBy("pluginCenterAuth")).isPresent();
}
@@ -80,21 +89,21 @@ public class IndexResourceTest {
@Test
@SubjectAware(username = "trillian", password = "secret")
public void shouldNotRenderPluginCenterLoginLinkIfPermissionsAreMissing() {
IndexDto index = indexResource.getIndex();
IndexDto index = indexResource.getIndex(httpServletRequest);
Assertions.assertThat(index.getLinks().getLinkBy("pluginCenterAuth")).isNotPresent();
}
@Test
public void shouldRenderLoginUrlsForUnauthenticatedRequest() {
IndexDto index = indexResource.getIndex();
IndexDto index = indexResource.getIndex(httpServletRequest);
Assertions.assertThat(index.getLinks().getLinkBy("login")).matches(Optional::isPresent);
}
@Test
public void shouldRenderLoginInfoUrl() {
IndexDto index = indexResource.getIndex();
IndexDto index = indexResource.getIndex(httpServletRequest);
Assertions.assertThat(index.getLinks().getLinkBy("loginInfo")).isPresent();
}
@@ -103,21 +112,21 @@ public class IndexResourceTest {
public void shouldNotRenderLoginInfoUrlWhenNoUrlIsConfigured() {
configuration.setLoginInfoUrl("");
IndexDto index = indexResource.getIndex();
IndexDto index = indexResource.getIndex(httpServletRequest);
Assertions.assertThat(index.getLinks().getLinkBy("loginInfo")).isNotPresent();
}
@Test
public void shouldRenderSelfLinkForUnauthenticatedRequest() {
IndexDto index = indexResource.getIndex();
IndexDto index = indexResource.getIndex(httpServletRequest);
Assertions.assertThat(index.getLinks().getLinkBy("self")).matches(Optional::isPresent);
}
@Test
public void shouldRenderUiPluginsLinkForUnauthenticatedRequest() {
IndexDto index = indexResource.getIndex();
IndexDto index = indexResource.getIndex(httpServletRequest);
Assertions.assertThat(index.getLinks().getLinkBy("uiPlugins")).matches(Optional::isPresent);
}
@@ -125,7 +134,7 @@ public class IndexResourceTest {
@Test
@SubjectAware(username = "trillian", password = "secret")
public void shouldRenderSelfLinkForAuthenticatedRequest() {
IndexDto index = indexResource.getIndex();
IndexDto index = indexResource.getIndex(httpServletRequest);
Assertions.assertThat(index.getLinks().getLinkBy("self")).matches(Optional::isPresent);
}
@@ -133,7 +142,7 @@ public class IndexResourceTest {
@Test
@SubjectAware(username = "trillian", password = "secret")
public void shouldRenderUiPluginsLinkForAuthenticatedRequest() {
IndexDto index = indexResource.getIndex();
IndexDto index = indexResource.getIndex(httpServletRequest);
Assertions.assertThat(index.getLinks().getLinkBy("uiPlugins")).matches(Optional::isPresent);
}
@@ -141,7 +150,7 @@ public class IndexResourceTest {
@Test
@SubjectAware(username = "trillian", password = "secret")
public void shouldRenderMeUrlForAuthenticatedRequest() {
IndexDto index = indexResource.getIndex();
IndexDto index = indexResource.getIndex(httpServletRequest);
Assertions.assertThat(index.getLinks().getLinkBy("me")).matches(Optional::isPresent);
}
@@ -149,7 +158,7 @@ public class IndexResourceTest {
@Test
@SubjectAware(username = "trillian", password = "secret")
public void shouldRenderLogoutUrlForAuthenticatedRequest() {
IndexDto index = indexResource.getIndex();
IndexDto index = indexResource.getIndex(httpServletRequest);
Assertions.assertThat(index.getLinks().getLinkBy("logout")).matches(Optional::isPresent);
}
@@ -157,7 +166,7 @@ public class IndexResourceTest {
@Test
@SubjectAware(username = "trillian", password = "secret")
public void shouldRenderRepositoriesForAuthenticatedRequest() {
IndexDto index = indexResource.getIndex();
IndexDto index = indexResource.getIndex(httpServletRequest);
Assertions.assertThat(index.getLinks().getLinkBy("repositories")).matches(Optional::isPresent);
}
@@ -165,7 +174,7 @@ public class IndexResourceTest {
@Test
@SubjectAware(username = "trillian", password = "secret")
public void shouldNotRenderAdminLinksIfNotAuthorized() {
IndexDto index = indexResource.getIndex();
IndexDto index = indexResource.getIndex(httpServletRequest);
Assertions.assertThat(index.getLinks().getLinkBy("users")).matches(o -> !o.isPresent());
Assertions.assertThat(index.getLinks().getLinkBy("groups")).matches(o -> !o.isPresent());
@@ -175,7 +184,7 @@ public class IndexResourceTest {
@Test
@SubjectAware(username = "trillian", password = "secret")
public void shouldRenderAutoCompleteLinks() {
IndexDto index = indexResource.getIndex();
IndexDto index = indexResource.getIndex(httpServletRequest);
Assertions.assertThat(index.getLinks().getLinksBy("autocomplete"))
.extracting("name")
@@ -185,7 +194,7 @@ public class IndexResourceTest {
@Test
@SubjectAware(username = "user_without_autocomplete_permission", password = "secret")
public void userWithoutAutocompletePermissionShouldSeeAutoCompleteLinksOnlyForNamespaces() {
IndexDto index = indexResource.getIndex();
IndexDto index = indexResource.getIndex(httpServletRequest);
Assertions.assertThat(index.getLinks().getLinksBy("autocomplete"))
.extracting("name")
@@ -195,7 +204,7 @@ public class IndexResourceTest {
@Test
@SubjectAware(username = "dent", password = "secret")
public void shouldRenderAdminLinksIfAuthorized() {
IndexDto index = indexResource.getIndex();
IndexDto index = indexResource.getIndex(httpServletRequest);
Assertions.assertThat(index.getLinks().getLinkBy("users")).matches(Optional::isPresent);
Assertions.assertThat(index.getLinks().getLinkBy("groups")).matches(Optional::isPresent);
@@ -206,7 +215,7 @@ public class IndexResourceTest {
public void shouldGenerateVersion() {
when(scmContextProvider.getVersion()).thenReturn("v1");
IndexDto index = indexResource.getIndex();
IndexDto index = indexResource.getIndex(httpServletRequest);
Assertions.assertThat(index.getVersion()).isEqualTo("v1");
}

View File

@@ -0,0 +1,99 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package sonia.scm.api.v2.resources;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableSet;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import sonia.scm.plugin.AvailablePlugin;
import sonia.scm.plugin.PluginSet;
import java.util.List;
import java.util.Locale;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.when;
import static sonia.scm.plugin.PluginTestHelper.createAvailable;
@ExtendWith(MockitoExtension.class)
class PluginSetDtoMapperTest {
@Mock
private PluginDtoMapper pluginDtoMapper;
@InjectMocks
private PluginSetDtoMapper mapper;
@Test
void shouldMap() {
AvailablePlugin git = createAvailable("scm-git-plugin");
AvailablePlugin svn = createAvailable("scm-svn-plugin");
AvailablePlugin hg = createAvailable("scm-hg-plugin");
PluginDto gitDto = new PluginDto();
gitDto.setName("scm-git-plugin");
PluginDto svnDto = new PluginDto();
svnDto.setName("scm-svn-plugin");
PluginDto hgDto = new PluginDto();
hgDto.setName("scm-hg-plugin");
when(pluginDtoMapper.mapAvailable(git)).thenReturn(gitDto);
when(pluginDtoMapper.mapAvailable(svn)).thenReturn(svnDto);
when(pluginDtoMapper.mapAvailable(hg)).thenReturn(hgDto);
List<AvailablePlugin> availablePlugins = List.of(git, svn, hg);
PluginSet pluginSet = new PluginSet(
"my-plugin-set",
1,
ImmutableSet.of("scm-git-plugin", "scm-hg-plugin"),
ImmutableMap.of("en", new PluginSet.Description("My Plugin Set", List.of("this is awesome!"))),
ImmutableMap.of("standard", "base64image")
);
PluginSet pluginSet2 = new PluginSet(
"my-other-plugin-set",
0,
ImmutableSet.of("scm-svn-plugin", "scm-hg-plugin"),
ImmutableMap.of("en", new PluginSet.Description("My Plugin Set 2", List.of("this is also awesome!"))),
ImmutableMap.of("standard", "base64image")
);
ImmutableSet<PluginSet> pluginSets = ImmutableSet.of(pluginSet, pluginSet2);
List<PluginSetDto> dtos = mapper.map(pluginSets, availablePlugins, Locale.ENGLISH);
assertThat(dtos).hasSize(2);
PluginSetDto first = dtos.get(0);
assertThat(first.getSequence()).isZero();
assertThat(first.getName()).isEqualTo("My Plugin Set 2");
assertThat(first.getFeatures()).contains("this is also awesome!");
assertThat(first.getImages()).isNotEmpty();
assertThat(first.getPlugins()).hasSize(2);
assertThat(dtos.get(1).getSequence()).isEqualTo(1);
}
}

View File

@@ -0,0 +1,186 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package sonia.scm.api.v2.resources;
import com.github.sdorra.shiro.SubjectAware;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableSet;
import de.otto.edison.hal.Embedded;
import de.otto.edison.hal.Links;
import org.apache.commons.lang.StringUtils;
import org.jboss.resteasy.mock.MockHttpRequest;
import org.jboss.resteasy.mock.MockHttpResponse;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Answers;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import sonia.scm.lifecycle.PluginWizardStartupAction;
import sonia.scm.lifecycle.PrivilegedStartupAction;
import sonia.scm.plugin.AvailablePlugin;
import sonia.scm.plugin.PluginManager;
import sonia.scm.plugin.PluginSet;
import sonia.scm.security.AccessTokenCookieIssuer;
import sonia.scm.web.RestDispatcher;
import sonia.scm.web.security.AdministrationContext;
import java.net.URISyntaxException;
import java.util.List;
import java.util.Locale;
import static java.lang.String.format;
import static java.nio.charset.StandardCharsets.UTF_8;
import static java.util.Collections.emptyList;
import static java.util.Collections.singleton;
import static org.assertj.core.api.Assertions.assertThat;
import static org.jboss.resteasy.mock.MockHttpRequest.post;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static sonia.scm.plugin.PluginTestHelper.createAvailable;
@SubjectAware(
configuration = "classpath:sonia/scm/repository/shiro.ini"
)
@ExtendWith(MockitoExtension.class)
class PluginWizardStartupResourceTest {
@Mock
private PluginWizardStartupAction startupAction;
@Mock(answer = Answers.RETURNS_DEEP_STUBS)
private ResourceLinks resourceLinks;
@Mock
private PluginManager pluginManager;
@Mock
private AccessTokenCookieIssuer cookieIssuer;
@Mock
private PluginSetDtoMapper pluginSetDtoMapper;
@Mock
private AdministrationContext context;
private final RestDispatcher dispatcher = new RestDispatcher();
private final MockHttpResponse response = new MockHttpResponse();
@InjectMocks
private PluginWizardStartupResource resource;
@BeforeEach
void setUpMocks() {
dispatcher.addSingletonResource(new InitializationResource(singleton(resource)));
lenient().when(startupAction.name()).thenReturn("pluginWizard");
}
@Test
void shouldFailWhenActionIsDone() throws URISyntaxException {
when(startupAction.done()).thenReturn(true);
MockHttpRequest request =
post("/v2/initialization/pluginWizard")
.contentType("application/json")
.content(createInput("my-plugin-set"));
dispatcher.invoke(request, response);
assertThat(response.getStatus()).isEqualTo(400);
}
@Test
void shouldInstallPluginSets() throws URISyntaxException {
when(startupAction.done()).thenReturn(false);
MockHttpRequest request =
post("/v2/initialization/pluginWizard")
.contentType("application/json")
.content(createInput("my-plugin-set", "my-other-plugin-set"));
dispatcher.invoke(request, response);
verify(cookieIssuer).invalidate(any(), any());
verify(pluginManager).installPluginSets(ImmutableSet.of("my-plugin-set", "my-other-plugin-set"), true);
}
@Test
void shouldSetupIndex() {
AvailablePlugin git = createAvailable("scm-git-plugin");
AvailablePlugin svn = createAvailable("scm-svn-plugin");
AvailablePlugin hg = createAvailable("scm-hg-plugin");
List<AvailablePlugin> availablePlugins = List.of(git, svn, hg);
when(pluginManager.getAvailable()).thenReturn(availablePlugins);
doAnswer(invocation -> {
invocation.getArgument(0, PrivilegedStartupAction.class).run();
return null;
}).when(context).runAsAdmin(any(PrivilegedStartupAction.class));
PluginSet pluginSet = new PluginSet(
"my-plugin-set",
0,
ImmutableSet.of("scm-git-plugin", "scm-hg-plugin"),
ImmutableMap.of("en", new PluginSet.Description("My Plugin Set", List.of("this is awesome!"))),
ImmutableMap.of("standard", "base64image")
);
PluginSet pluginSet2 = new PluginSet(
"my-other-plugin-set",
0,
ImmutableSet.of("scm-svn-plugin", "scm-hg-plugin"),
ImmutableMap.of("en", new PluginSet.Description("My Plugin Set", List.of("this is awesome!"))),
ImmutableMap.of("standard", "base64image")
);
ImmutableSet<PluginSet> pluginSets = ImmutableSet.of(pluginSet, pluginSet2);
when(pluginManager.getPluginSets()).thenReturn(pluginSets);
when(pluginSetDtoMapper.map(pluginSets, availablePlugins, Locale.ENGLISH)).thenReturn(emptyList());
when(resourceLinks.pluginWizard().indexLink("pluginWizard")).thenReturn("http://index.link");
Embedded.Builder embeddedBuilder = new Embedded.Builder();
Links.Builder linksBuilder = new Links.Builder();
resource.setupIndex(linksBuilder, embeddedBuilder, Locale.ENGLISH);
Embedded embedded = embeddedBuilder.build();
Links links = linksBuilder.build();
assertThat(links.getLinkBy("installPluginSets")).isPresent();
assertThat(embedded.hasItem("pluginSets")).isTrue();
}
private byte[] createInput(String... pluginSetIds) {
String format = pluginSetIds.length > 0 ? "'%s'" : "%s";
return json(format("{'pluginSetIds': [" + format + "]}", StringUtils.join(pluginSetIds, "','")));
}
private byte[] json(String s) {
return s.replaceAll("'", "\"").getBytes(UTF_8);
}
}

View File

@@ -0,0 +1,115 @@
/*
* 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.initialization;
import org.apache.shiro.authc.AuthenticationException;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Answers;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import sonia.scm.security.AccessToken;
import sonia.scm.security.AccessTokenBuilder;
import sonia.scm.security.AccessTokenBuilderFactory;
import sonia.scm.security.AccessTokenCookieIssuer;
import sonia.scm.web.security.AdministrationContext;
import sonia.scm.web.security.PrivilegedAction;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class InitializationAuthenticationServiceTest {
@Mock
private AccessTokenBuilderFactory tokenBuilderFactory;
@Mock(answer = Answers.RETURNS_SELF)
private AccessTokenBuilder tokenBuilder;
@Mock
private AccessToken token;
@Mock
private AccessTokenCookieIssuer cookieIssuer;
@Mock
private InitializationCookieIssuer initializationCookieIssuer;
@Mock
private AdministrationContext administrationContext;
@InjectMocks
private InitializationAuthenticationService service;
@Test
void shouldNotThrowExceptionIfTokenIsValid() {
when(token.getSubject()).thenReturn("SCM-INIT");
service.validateToken(token);
}
@Test
void shouldThrowExceptionIfTokenIsInvalid() {
when(token.getSubject()).thenReturn("FAKE");
assertThrows(AuthenticationException.class, () -> service.validateToken(token));
}
@Test
void shouldSetPermissionForVirtualInitializationUserInAdminContext() {
service.setPermissions();
verify(administrationContext).runAsAdmin(any(PrivilegedAction.class));
}
@Test
void shouldAuthenticate() {
HttpServletRequest request = mock(HttpServletRequest.class);
HttpServletResponse response = mock(HttpServletResponse.class);
when(tokenBuilderFactory.create()).thenReturn(tokenBuilder);
AccessToken accessToken = mock(AccessToken.class);
when(tokenBuilder.build()).thenReturn(accessToken);
service.authenticate(request, response);
verify(initializationCookieIssuer)
.authenticateForInitialization(request, response, accessToken);
verify(tokenBuilder).subject("SCM-INIT");
}
@Test
void shouldInvalidateCookies() {
HttpServletRequest request = mock(HttpServletRequest.class);
HttpServletResponse response = mock(HttpServletResponse.class);
service.invalidateCookies(request, response);
verify(cookieIssuer).invalidate(request, response);
}
}

View File

@@ -0,0 +1,65 @@
/*
* 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.initialization;
import org.apache.shiro.authc.AuthenticationToken;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.junit.jupiter.MockitoExtension;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import static sonia.scm.initialization.InitializationWebTokenGenerator.INIT_TOKEN_HEADER;
@ExtendWith(MockitoExtension.class)
class InitializationWebTokenGeneratorTest {
private static final String INIT_TOKEN = "my_init_token";
private final InitializationWebTokenGenerator generator = new InitializationWebTokenGenerator();
@Test
void shouldReturnNullTokenIfCookieIsMissing() {
HttpServletRequest request = mock(HttpServletRequest.class);
AuthenticationToken token = generator.createToken(request);
assertThat(token).isNull();
}
@Test
void shouldGenerateCookieToken() {
HttpServletRequest request = mock(HttpServletRequest.class);
when(request.getCookies()).thenReturn(new Cookie[]{new Cookie(INIT_TOKEN_HEADER, INIT_TOKEN)});
AuthenticationToken token = generator.createToken(request);
assertThat(token.getCredentials()).isEqualTo(INIT_TOKEN);
assertThat(token.getPrincipal()).isEqualTo("SCM_INIT");
}
}

View File

@@ -0,0 +1,73 @@
/*
* 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.lifecycle;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.Mockito;
import org.mockito.junit.jupiter.MockitoExtension;
import sonia.scm.plugin.PluginSetConfigStore;
import sonia.scm.plugin.PluginSetsConfig;
import java.util.Optional;
@ExtendWith(MockitoExtension.class)
class PluginWizardStartupActionTest {
@Mock
private PluginSetConfigStore pluginSetConfigStore;
@InjectMocks
private PluginWizardStartupAction startupAction;
@BeforeEach
void setup() {
System.clearProperty(AdminAccountStartupAction.INITIAL_PASSWORD_PROPERTY);
}
@Test
void shouldNotBeDoneByDefault() {
Assertions.assertThat(startupAction.done()).isFalse();
}
@Test
void shouldBeDoneIfInitialPasswordIsSet() {
System.setProperty(AdminAccountStartupAction.INITIAL_PASSWORD_PROPERTY, "secret");
Assertions.assertThat(startupAction.done()).isTrue();
}
@Test
void shouldBeDoneIfConfigIsAlreadySet() {
Mockito.when(pluginSetConfigStore.getPluginSets()).thenReturn(Optional.of(new PluginSetsConfig()));
Assertions.assertThat(startupAction.done()).isTrue();
}
}

View File

@@ -25,6 +25,7 @@
package sonia.scm.plugin;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableSet;
import org.apache.shiro.authz.AuthorizationException;
import org.apache.shiro.subject.Subject;
@@ -38,6 +39,7 @@ import org.junit.jupiter.api.io.TempDir;
import org.mockito.ArgumentCaptor;
import org.mockito.Captor;
import org.mockito.Mock;
import org.mockito.Mockito;
import org.mockito.junit.jupiter.MockitoExtension;
import sonia.scm.NotFoundException;
import sonia.scm.ScmConstraintViolationException;
@@ -49,6 +51,7 @@ import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import static java.util.Arrays.asList;
import static java.util.Collections.singleton;
@@ -83,6 +86,9 @@ class DefaultPluginManagerTest {
@Mock
private Restarter restarter;
@Mock
private PluginSetConfigStore pluginSetConfigStore;
@Mock
private ScmEventBus eventBus;
@@ -110,7 +116,7 @@ class DefaultPluginManagerTest {
@BeforeEach
void setUpObjectUnderTest() {
manager = new DefaultPluginManager(
loader, center, installer, restarter, eventBus, plugins -> context
loader, center, installer, restarter, eventBus, plugins -> context, pluginSetConfigStore
);
}
@@ -162,7 +168,7 @@ class DefaultPluginManagerTest {
AvailablePlugin review = createAvailable("scm-review-plugin");
AvailablePlugin git = createAvailable("scm-git-plugin");
when(center.getAvailable()).thenReturn(ImmutableSet.of(review, git));
when(center.getAvailablePlugins()).thenReturn(ImmutableSet.of(review, git));
List<AvailablePlugin> available = manager.getAvailable();
assertThat(available).containsOnly(review, git);
@@ -175,7 +181,7 @@ class DefaultPluginManagerTest {
AvailablePlugin review = createAvailable("scm-review-plugin");
AvailablePlugin git = createAvailable("scm-git-plugin");
when(center.getAvailable()).thenReturn(ImmutableSet.of(review, git));
when(center.getAvailablePlugins()).thenReturn(ImmutableSet.of(review, git));
List<AvailablePlugin> available = manager.getAvailable();
assertThat(available).containsOnly(review);
@@ -185,7 +191,7 @@ class DefaultPluginManagerTest {
void shouldReturnAvailable() {
AvailablePlugin review = createAvailable("scm-review-plugin");
AvailablePlugin git = createAvailable("scm-git-plugin");
when(center.getAvailable()).thenReturn(ImmutableSet.of(review, git));
when(center.getAvailablePlugins()).thenReturn(ImmutableSet.of(review, git));
Optional<AvailablePlugin> available = manager.getAvailable("scm-git-plugin");
assertThat(available).contains(git);
@@ -194,7 +200,7 @@ class DefaultPluginManagerTest {
@Test
void shouldReturnEmptyForNonExistingAvailable() {
AvailablePlugin review = createAvailable("scm-review-plugin");
when(center.getAvailable()).thenReturn(ImmutableSet.of(review));
when(center.getAvailablePlugins()).thenReturn(ImmutableSet.of(review));
Optional<AvailablePlugin> available = manager.getAvailable("scm-git-plugin");
assertThat(available).isEmpty();
@@ -206,7 +212,7 @@ class DefaultPluginManagerTest {
when(loader.getInstalledPlugins()).thenReturn(ImmutableList.of(installedGit));
AvailablePlugin git = createAvailable("scm-git-plugin");
when(center.getAvailable()).thenReturn(ImmutableSet.of(git));
when(center.getAvailablePlugins()).thenReturn(ImmutableSet.of(git));
Optional<AvailablePlugin> available = manager.getAvailable("scm-git-plugin");
assertThat(available).isEmpty();
@@ -215,7 +221,7 @@ class DefaultPluginManagerTest {
@Test
void shouldInstallThePlugin() {
AvailablePlugin git = createAvailable("scm-git-plugin");
when(center.getAvailable()).thenReturn(ImmutableSet.of(git));
when(center.getAvailablePlugins()).thenReturn(ImmutableSet.of(git));
manager.install("scm-git-plugin", false);
@@ -228,7 +234,7 @@ class DefaultPluginManagerTest {
AvailablePlugin review = createAvailable("scm-review-plugin");
when(review.getDescriptor().getDependencies()).thenReturn(ImmutableSet.of("scm-mail-plugin"));
AvailablePlugin mail = createAvailable("scm-mail-plugin");
when(center.getAvailable()).thenReturn(ImmutableSet.of(review, mail));
when(center.getAvailablePlugins()).thenReturn(ImmutableSet.of(review, mail));
manager.install("scm-review-plugin", false);
@@ -241,7 +247,7 @@ class DefaultPluginManagerTest {
AvailablePlugin review = createAvailable("scm-review-plugin");
when(review.getDescriptor().getDependencies()).thenReturn(ImmutableSet.of("scm-mail-plugin"));
AvailablePlugin mail = createAvailable("scm-mail-plugin");
when(center.getAvailable()).thenReturn(ImmutableSet.of(review, mail));
when(center.getAvailablePlugins()).thenReturn(ImmutableSet.of(review, mail));
InstalledPlugin installedMail = createInstalled("scm-mail-plugin");
when(loader.getInstalledPlugins()).thenReturn(ImmutableList.of(installedMail));
@@ -259,7 +265,7 @@ class DefaultPluginManagerTest {
AvailablePlugin review = createAvailable("scm-review-plugin");
when(review.getDescriptor().getDependencies()).thenReturn(ImmutableSet.of("scm-mail-plugin"));
AvailablePlugin mail = createAvailable("scm-mail-plugin", "1.1.0");
when(center.getAvailable()).thenReturn(ImmutableSet.of(review, mail));
when(center.getAvailablePlugins()).thenReturn(ImmutableSet.of(review, mail));
InstalledPlugin installedMail = createInstalled("scm-mail-plugin", "1.0.0");
when(loader.getInstalledPlugins()).thenReturn(ImmutableList.of(installedMail));
@@ -275,7 +281,7 @@ class DefaultPluginManagerTest {
AvailablePlugin review = createAvailable("scm-review-plugin");
when(review.getDescriptor().getOptionalDependencies()).thenReturn(ImmutableSet.of("scm-mail-plugin"));
AvailablePlugin mail = createAvailable("scm-mail-plugin", "1.1.0");
when(center.getAvailable()).thenReturn(ImmutableSet.of(review, mail));
when(center.getAvailablePlugins()).thenReturn(ImmutableSet.of(review, mail));
InstalledPlugin installedMail = createInstalled("scm-mail-plugin", "1.0.0");
when(loader.getInstalledPlugins()).thenReturn(ImmutableList.of(installedMail));
@@ -291,7 +297,7 @@ class DefaultPluginManagerTest {
AvailablePlugin review = createAvailable("scm-review-plugin");
when(review.getDescriptor().getOptionalDependencies()).thenReturn(ImmutableSet.of("scm-mail-plugin"));
AvailablePlugin mail = createAvailable("scm-mail-plugin", "1.1.0");
when(center.getAvailable()).thenReturn(ImmutableSet.of(review, mail));
when(center.getAvailablePlugins()).thenReturn(ImmutableSet.of(review, mail));
manager.install("scm-review-plugin", false);
@@ -306,7 +312,7 @@ class DefaultPluginManagerTest {
AvailablePlugin mail = createAvailable("scm-mail-plugin");
when(mail.getDescriptor().getDependencies()).thenReturn(ImmutableSet.of("scm-notification-plugin"));
AvailablePlugin notification = createAvailable("scm-notification-plugin");
when(center.getAvailable()).thenReturn(ImmutableSet.of(review, mail, notification));
when(center.getAvailablePlugins()).thenReturn(ImmutableSet.of(review, mail, notification));
PendingPluginInstallation pendingNotification = mock(PendingPluginInstallation.class);
doReturn(pendingNotification).when(installer).install(context, notification);
@@ -328,7 +334,7 @@ class DefaultPluginManagerTest {
when(review.getDescriptor().getDependencies()).thenReturn(ImmutableSet.of("scm-mail-plugin"));
AvailablePlugin mail = createAvailable("scm-mail-plugin");
when(mail.getDescriptor().getDependencies()).thenReturn(ImmutableSet.of("scm-notification-plugin"));
when(center.getAvailable()).thenReturn(ImmutableSet.of(review, mail));
when(center.getAvailablePlugins()).thenReturn(ImmutableSet.of(review, mail));
assertThrows(NotFoundException.class, () -> manager.install("scm-review-plugin", false));
@@ -338,7 +344,7 @@ class DefaultPluginManagerTest {
@Test
void shouldSendRestartEventAfterInstallation() {
AvailablePlugin git = createAvailable("scm-git-plugin");
when(center.getAvailable()).thenReturn(ImmutableSet.of(git));
when(center.getAvailablePlugins()).thenReturn(ImmutableSet.of(git));
manager.install("scm-git-plugin", true);
@@ -358,7 +364,7 @@ class DefaultPluginManagerTest {
@Test
void shouldNotInstallAlreadyPendingPlugins() {
AvailablePlugin review = createAvailable("scm-review-plugin");
when(center.getAvailable()).thenReturn(ImmutableSet.of(review));
when(center.getAvailablePlugins()).thenReturn(ImmutableSet.of(review));
manager.install("scm-review-plugin", false);
manager.install("scm-review-plugin", false);
@@ -369,7 +375,7 @@ class DefaultPluginManagerTest {
@Test
void shouldSendRestartEvent() {
AvailablePlugin review = createAvailable("scm-review-plugin");
when(center.getAvailable()).thenReturn(ImmutableSet.of(review));
when(center.getAvailablePlugins()).thenReturn(ImmutableSet.of(review));
manager.install("scm-review-plugin", false);
manager.executePendingAndRestart();
@@ -387,7 +393,7 @@ class DefaultPluginManagerTest {
@Test
void shouldReturnSingleAvailableAsPending() {
AvailablePlugin review = createAvailable("scm-review-plugin");
when(center.getAvailable()).thenReturn(ImmutableSet.of(review));
when(center.getAvailablePlugins()).thenReturn(ImmutableSet.of(review));
manager.install("scm-review-plugin", false);
@@ -398,7 +404,7 @@ class DefaultPluginManagerTest {
@Test
void shouldReturnAvailableAsPending() {
AvailablePlugin review = createAvailable("scm-review-plugin");
when(center.getAvailable()).thenReturn(ImmutableSet.of(review));
when(center.getAvailablePlugins()).thenReturn(ImmutableSet.of(review));
manager.install("scm-review-plugin", false);
@@ -514,7 +520,7 @@ class DefaultPluginManagerTest {
when(reviewPlugin.getDescriptor().getDependencies()).thenReturn(singleton("scm-mail-plugin"));
when(loader.getInstalledPlugins()).thenReturn(singletonList(mailPlugin));
when(center.getAvailable()).thenReturn(singleton(reviewPlugin));
when(center.getAvailablePlugins()).thenReturn(singleton(reviewPlugin));
manager.computeInstallationDependencies();
@@ -546,7 +552,7 @@ class DefaultPluginManagerTest {
doNothing().when(mailPlugin).setMarkedForUninstall(uninstallCaptor.capture());
AvailablePlugin git = createAvailable("scm-git-plugin");
when(center.getAvailable()).thenReturn(ImmutableSet.of(git));
when(center.getAvailablePlugins()).thenReturn(ImmutableSet.of(git));
PendingPluginInstallation gitPendingPluginInformation = mock(PendingPluginInstallation.class);
when(installer.install(context, git)).thenReturn(gitPendingPluginInformation);
@@ -577,7 +583,7 @@ class DefaultPluginManagerTest {
AvailablePlugin newMailPlugin = createAvailable("scm-mail-plugin", "2.0.0");
AvailablePlugin newReviewPlugin = createAvailable("scm-review-plugin", "2.0.0");
when(center.getAvailable()).thenReturn(ImmutableSet.of(newMailPlugin, newReviewPlugin));
when(center.getAvailablePlugins()).thenReturn(ImmutableSet.of(newMailPlugin, newReviewPlugin));
manager.updateAll();
@@ -593,7 +599,7 @@ class DefaultPluginManagerTest {
when(loader.getInstalledPlugins()).thenReturn(ImmutableList.of(scriptPlugin));
AvailablePlugin oldScriptPlugin = createAvailable("scm-script-plugin", "0.9");
when(center.getAvailable()).thenReturn(ImmutableSet.of(oldScriptPlugin));
when(center.getAvailablePlugins()).thenReturn(ImmutableSet.of(oldScriptPlugin));
manager.updateAll();
@@ -603,7 +609,7 @@ class DefaultPluginManagerTest {
@Test
void shouldFirePluginEventOnInstallation() {
AvailablePlugin review = createAvailable("scm-review-plugin");
when(center.getAvailable()).thenReturn(ImmutableSet.of(review));
when(center.getAvailablePlugins()).thenReturn(ImmutableSet.of(review));
manager.install("scm-review-plugin", false);
@@ -616,7 +622,7 @@ class DefaultPluginManagerTest {
@Test
void shouldFirePluginEventOnFailedInstallation() {
AvailablePlugin review = createAvailable("scm-review-plugin");
when(center.getAvailable()).thenReturn(ImmutableSet.of(review));
when(center.getAvailablePlugins()).thenReturn(ImmutableSet.of(review));
doThrow(new PluginDownloadException(review, new IOException())).when(installer).install(context, review);
assertThrows(PluginDownloadException.class, () -> manager.install("scm-review-plugin", false));
@@ -637,7 +643,7 @@ class DefaultPluginManagerTest {
when(jenkins.getDescriptor().getDependencies()).thenReturn(ImmutableSet.of("scm-el-plugin"));
when(webhook.getDescriptor().getDependencies()).thenReturn(ImmutableSet.of("scm-el-plugin"));
AvailablePlugin el = createAvailable("scm-el-plugin");
when(center.getAvailable()).thenReturn(ImmutableSet.of(jenkins, el, webhook));
when(center.getAvailablePlugins()).thenReturn(ImmutableSet.of(jenkins, el, webhook));
manager.install("scm-jenkins-plugin", false);
manager.install("scm-webhook-plugin", false);
@@ -650,6 +656,55 @@ class DefaultPluginManagerTest {
assertThat(pluginInstallationContext.find("scm-webhook-plugin")).isPresent();
assertThat(pluginInstallationContext.find("scm-el-plugin")).isPresent();
}
@Test
void shouldGetPluginSets() {
PluginSet pluginSet = new PluginSet(
"my-plugin-set",
0,
ImmutableSet.of("scm-jenkins-plugin", "scm-webhook-plugin", "scm-el-plugin"),
ImmutableMap.of("en", new PluginSet.Description("My Plugin Set", List.of("this is awesome!"))),
ImmutableMap.of("standard", "base64image")
);
when(center.getAvailablePluginSets()).thenReturn(ImmutableSet.of(pluginSet));
Set<PluginSet> pluginSets = manager.getPluginSets();
assertThat(pluginSets).containsExactly(pluginSet);
}
@Test
void shouldInstallPluginSets() {
AvailablePlugin git = createAvailable("scm-git-plugin");
AvailablePlugin svn = createAvailable("scm-svn-plugin");
AvailablePlugin hg = createAvailable("scm-hg-plugin");
when(center.getAvailablePlugins()).thenReturn(ImmutableSet.of(git, svn, hg));
PluginSet pluginSet = new PluginSet(
"my-plugin-set",
0,
ImmutableSet.of("scm-git-plugin", "scm-hg-plugin"),
ImmutableMap.of("en", new PluginSet.Description("My Plugin Set", List.of("this is awesome!"))),
ImmutableMap.of("standard", "base64image")
);
PluginSet pluginSet2 = new PluginSet(
"my-other-plugin-set",
0,
ImmutableSet.of("scm-svn-plugin", "scm-hg-plugin"),
ImmutableMap.of("en", new PluginSet.Description("My Plugin Set", List.of("this is awesome!"))),
ImmutableMap.of("standard", "base64image")
);
when(center.getAvailablePluginSets()).thenReturn(ImmutableSet.of(pluginSet, pluginSet2));
manager.installPluginSets(ImmutableSet.of("my-plugin-set", "my-other-plugin-set"), false);
verify(pluginSetConfigStore).setPluginSets(new PluginSetsConfig(ImmutableSet.of("my-plugin-set", "my-other-plugin-set")));
verify(installer, Mockito.times(1)).install(context, git);
verify(installer, Mockito.times(1)).install(context, hg);
verify(installer, Mockito.times(1)).install(context, svn);
verify(restarter, never()).restart(any(), any());
}
}
@Nested
@@ -672,6 +727,7 @@ class DefaultPluginManagerTest {
assertThrows(AuthorizationException.class, () -> manager.getInstalled("test"));
assertThrows(AuthorizationException.class, () -> manager.getAvailable());
assertThrows(AuthorizationException.class, () -> manager.getAvailable("test"));
assertThrows(AuthorizationException.class, () -> manager.getPluginSets());
}
}
@@ -695,6 +751,12 @@ class DefaultPluginManagerTest {
assertThrows(AuthorizationException.class, () -> manager.install("test", false));
}
@Test
void shouldThrowAuthorizationExceptionsForInstallPluginSetsMethod() {
ImmutableSet<String> pluginSetIds = ImmutableSet.of("test");
assertThrows(AuthorizationException.class, () -> manager.installPluginSets(pluginSetIds, false));
}
@Test
void shouldThrowAuthorizationExceptionsForUninstallMethod() {
assertThrows(AuthorizationException.class, () -> manager.uninstall("test", false));

View File

@@ -33,17 +33,16 @@ import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.when;
import static sonia.scm.plugin.PluginCenterDto.Condition;
import static sonia.scm.plugin.PluginCenterDto.Link;
import static sonia.scm.plugin.PluginCenterDto.Plugin;
import static sonia.scm.plugin.PluginCenterDto.*;
@ExtendWith(MockitoExtension.class)
class PluginCenterDtoMapperTest {
@@ -72,8 +71,19 @@ class PluginCenterDtoMapperTest {
ImmutableMap.of("download", new Link("http://download.hitchhiker.com"))
);
PluginCenterDto.PluginSet pluginSet = new PluginCenterDto.PluginSet(
"my-plugin-set",
">2.0.0",
0,
ImmutableSet.of("scm-review-plugin"),
ImmutableMap.of("en", new PluginCenterDto.Description("My Plugin Set", List.of("hello world"))),
ImmutableMap.of("standard", "base64image")
);
when(dto.getEmbedded().getPlugins()).thenReturn(Collections.singletonList(plugin));
AvailablePluginDescriptor descriptor = mapper.map(dto).iterator().next().getDescriptor();
when(dto.getEmbedded().getPluginSets()).thenReturn(Collections.singletonList(pluginSet));
PluginCenterResult mapped = mapper.map(dto);
AvailablePluginDescriptor descriptor = mapped.getPlugins().iterator().next().getDescriptor();
PluginInformation information = descriptor.getInformation();
PluginCondition condition = descriptor.getCondition();
@@ -88,6 +98,14 @@ class PluginCenterDtoMapperTest {
assertThat(condition.getOs().iterator().next()).isEqualTo(plugin.getConditions().getOs().iterator().next());
assertThat(information.getDescription()).isEqualTo(plugin.getDescription());
assertThat(information.getName()).isEqualTo(plugin.getName());
PluginSet mappedPluginSet = mapped.getPluginSets().iterator().next();
assertThat(mappedPluginSet.getId()).isEqualTo(pluginSet.getId());
assertThat(mappedPluginSet.getSequence()).isEqualTo(pluginSet.getSequence());
assertThat(mappedPluginSet.getPlugins()).hasSize(pluginSet.getPlugins().size());
assertThat(mappedPluginSet.getImages()).isNotEmpty();
assertThat(mappedPluginSet.getDescriptions()).isNotEmpty();
}
@Test
@@ -126,7 +144,8 @@ class PluginCenterDtoMapperTest {
when(dto.getEmbedded().getPlugins()).thenReturn(Arrays.asList(plugin1, plugin2));
Set<AvailablePlugin> resultSet = mapper.map(dto);
PluginCenterResult pluginCenterResult = mapper.map(dto);
Set<AvailablePlugin> resultSet = pluginCenterResult.getPlugins();
PluginInformation pluginInformation1 = findPlugin(resultSet, plugin1.getName());
PluginInformation pluginInformation2 = findPlugin(resultSet, plugin2.getName());

View File

@@ -43,7 +43,6 @@ import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
import static sonia.scm.plugin.Tracing.SPAN_KIND;
@ExtendWith(MockitoExtension.class)
class PluginCenterLoaderTest {
@@ -71,12 +70,15 @@ class PluginCenterLoaderTest {
@Test
void shouldFetch() throws IOException {
Set<AvailablePlugin> plugins = Collections.emptySet();
Set<PluginSet> pluginSets = Collections.emptySet();
PluginCenterDto dto = new PluginCenterDto();
PluginCenterResult pluginCenterResult = new PluginCenterResult(plugins, pluginSets);
when(request().contentFromJson(PluginCenterDto.class)).thenReturn(dto);
when(mapper.map(dto)).thenReturn(plugins);
when(mapper.map(dto)).thenReturn(pluginCenterResult);
Set<AvailablePlugin> fetched = loader.load(PLUGIN_URL);
assertThat(fetched).isSameAs(plugins);
PluginCenterResult fetched = loader.load(PLUGIN_URL);
assertThat(fetched.getPlugins()).isSameAs(plugins);
assertThat(fetched.getPluginSets()).isSameAs(pluginSets);
}
private AdvancedHttpResponse request() throws IOException {
@@ -91,8 +93,9 @@ class PluginCenterLoaderTest {
when(client.get(PLUGIN_URL)).thenReturn(request);
when(request.request()).thenThrow(new IOException("failed to fetch"));
Set<AvailablePlugin> fetch = loader.load(PLUGIN_URL);
assertThat(fetch).isEmpty();
PluginCenterResult fetch = loader.load(PLUGIN_URL);
assertThat(fetch.getPlugins()).isEmpty();
assertThat(fetch.getPluginSets()).isEmpty();
}
@Test
@@ -119,8 +122,9 @@ class PluginCenterLoaderTest {
private Set<AvailablePlugin> mockResponse() throws IOException {
PluginCenterDto dto = new PluginCenterDto();
Set<AvailablePlugin> plugins = Collections.emptySet();
Set<PluginSet> pluginSets = Collections.emptySet();
when(request().contentFromJson(PluginCenterDto.class)).thenReturn(dto);
when(mapper.map(dto)).thenReturn(plugins);
when(mapper.map(dto)).thenReturn(new PluginCenterResult(plugins, pluginSets));
return plugins;
}

View File

@@ -24,21 +24,16 @@
package sonia.scm.plugin;
import com.google.common.collect.ImmutableSet;
import org.junit.jupiter.api.BeforeEach;
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.SCMContextProvider;
import sonia.scm.cache.CacheManager;
import sonia.scm.cache.MapCacheManager;
import sonia.scm.config.ScmConfiguration;
import sonia.scm.net.ahc.AdvancedHttpClient;
import sonia.scm.util.SystemUtil;
import java.io.IOException;
import java.util.Collections;
import java.util.HashSet;
import java.util.Set;
@@ -81,36 +76,52 @@ class PluginCenterTest {
@Test
void shouldFetchPlugins() {
Set<AvailablePlugin> plugins = new HashSet<>();
when(loader.load(PLUGIN_URL_BASE + "2.0.0")).thenReturn(plugins);
Set<PluginSet> pluginSets = new HashSet<>();
assertThat(pluginCenter.getAvailable()).isSameAs(plugins);
PluginCenterResult pluginCenterResult = new PluginCenterResult(plugins, pluginSets);
when(loader.load(PLUGIN_URL_BASE + "2.0.0")).thenReturn(pluginCenterResult);
assertThat(pluginCenter.getAvailablePlugins()).isSameAs(plugins);
assertThat(pluginCenter.getAvailablePluginSets()).isSameAs(pluginSets);
}
@Test
@SuppressWarnings("unchecked")
void shouldCache() {
Set<AvailablePlugin> first = new HashSet<>();
when(loader.load(anyString())).thenReturn(first, new HashSet<>());
Set<AvailablePlugin> plugins = new HashSet<>();
Set<PluginSet> pluginSets = new HashSet<>();
assertThat(pluginCenter.getAvailable()).isSameAs(first);
assertThat(pluginCenter.getAvailable()).isSameAs(first);
PluginCenterResult first = new PluginCenterResult(plugins, pluginSets);
when(loader.load(anyString())).thenReturn(first, new PluginCenterResult(Collections.emptySet(), Collections.emptySet()));
assertThat(pluginCenter.getAvailablePlugins()).isSameAs(plugins);
assertThat(pluginCenter.getAvailablePlugins()).isSameAs(plugins);
assertThat(pluginCenter.getAvailablePluginSets()).isSameAs(pluginSets);
}
@Test
@SuppressWarnings("unchecked")
void shouldClearCache() {
Set<AvailablePlugin> first = new HashSet<>();
when(loader.load(anyString())).thenReturn(first, new HashSet<>());
Set<AvailablePlugin> plugins = new HashSet<>();
Set<PluginSet> pluginSets = new HashSet<>();
assertThat(pluginCenter.getAvailable()).isSameAs(first);
PluginCenterResult first = new PluginCenterResult(plugins, pluginSets);
when(loader.load(anyString())).thenReturn(first, new PluginCenterResult(Collections.emptySet(), Collections.emptySet()));
assertThat(pluginCenter.getAvailablePlugins()).isSameAs(plugins);
assertThat(pluginCenter.getAvailablePluginSets()).isSameAs(pluginSets);
pluginCenter.handle(new PluginCenterLoginEvent(null));
assertThat(pluginCenter.getAvailable()).isNotSameAs(first);
assertThat(pluginCenter.getAvailablePlugins()).isNotSameAs(plugins);
assertThat(pluginCenter.getAvailablePluginSets()).isNotSameAs(pluginSets);
}
@Test
void shouldLoadOnRefresh() {
Set<AvailablePlugin> plugins = new HashSet<>();
when(loader.load(PLUGIN_URL_BASE + "2.0.0")).thenReturn(plugins);
Set<PluginSet> pluginSets = new HashSet<>();
PluginCenterResult pluginCenterResult = new PluginCenterResult(plugins, pluginSets);
when(loader.load(PLUGIN_URL_BASE + "2.0.0")).thenReturn(pluginCenterResult);
pluginCenter.refresh();