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:read",
"configuration:write", "configuration:write",
"plugin:read", "plugin:read",
"plugin:manage", "plugin:write",
"group:read", "group:read",
"user:read", "user:read",
"repository:read" "repository:read"
@@ -206,7 +206,7 @@ The following shows user as an example.
"configuration:read", "configuration:read",
"configuration:write", "configuration:write",
"plugin:read", "plugin:read",
"plugin:manage", "plugin:write",
"group:read", "group:read",
"user:read", "user:read",
"repository:read" "repository:read"

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

View File

@@ -33,4 +33,15 @@ The password of the administration user cannot be recovered.
For automated processes, you might want to bypass the initial user creation. To do so, you can set the initial password For automated processes, you might want to bypass the initial user creation. To do so, you can set the initial password
in a system property `scm.initialPassword`. If this is present, a user `scmadmin` with this password will be created, 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` if it does not already exist. To change the name of this user, you can set this with the property `scm.initialUser`
in addition. 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; import sonia.scm.plugin.ExtensionPoint;
/**
* @deprecated Limited use for Plugin Development, see as internal
*/
@ExtensionPoint @ExtensionPoint
@Deprecated(since = "2.35.0", forRemoval = true)
public interface InitializationStep { public interface InitializationStep {
String name(); String name();

View File

@@ -28,9 +28,22 @@ import de.otto.edison.hal.Embedded;
import de.otto.edison.hal.Links; import de.otto.edison.hal.Links;
import sonia.scm.plugin.ExtensionPoint; import sonia.scm.plugin.ExtensionPoint;
import java.util.Locale;
/**
* @deprecated Limited use for Plugin Development, see as internal
*/
@ExtensionPoint @ExtensionPoint
@Deprecated(since = "2.35.0", forRemoval = true)
public interface InitializationStepResource { public interface InitializationStepResource {
String name(); 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); 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.List;
import java.util.Optional; import java.util.Optional;
import java.util.Set;
/** /**
* The plugin manager is responsible for plugin related tasks, such as install, uninstall or updating. * 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(); 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. * 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

@@ -21,7 +21,7 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE. * SOFTWARE.
*/ */
package sonia.scm.security; package sonia.scm.security;
import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletRequest;
@@ -43,6 +43,7 @@ public interface AccessTokenCookieIssuer {
* @param accessToken access token * @param accessToken access token
*/ */
void authenticate(HttpServletRequest request, HttpServletResponse response, AccessToken accessToken); void authenticate(HttpServletRequest request, HttpServletResponse response, AccessToken accessToken);
/** /**
* Invalidates the authentication cookie. * Invalidates the authentication cookie.
* *

View File

@@ -83,7 +83,7 @@ export const extractXsrfTokenFromCookie = (cookieString?: string) => {
const cookies = cookieString.split(";"); const cookies = cookieString.split(";");
for (const c of cookies) { for (const c of cookies) {
const parts = c.trim().split("="); 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]); return extractXsrfTokenFromJwt(parts[1]);
} }
} }

View File

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

View File

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

View File

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

View File

@@ -26,6 +26,15 @@ import { HalRepresentation, HalRepresentationWithEmbedded } from "./hal";
type PluginType = "SCM" | "CLOUDOGU"; 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 & { export type Plugin = HalRepresentation & {
name: string; name: string;
version: string; version: string;

View File

@@ -11,6 +11,20 @@
"password-confirmation": "Passwort Bestätigung", "password-confirmation": "Passwort Bestätigung",
"submit": "Absenden" "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": { "error": {
"forbidden": "Falscher Token" "forbidden": "Falscher Token"
} }

View File

@@ -11,6 +11,21 @@
"password-confirmation": "Confirm Password", "password-confirmation": "Confirm Password",
"submit": "Submit" "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": { "error": {
"forbidden": "Incorrect token" "forbidden": "Incorrect token"
} }

View File

@@ -67,7 +67,7 @@ const App: FC = () => {
return ( return (
<AppWrapper className="App"> <AppWrapper className="App">
{isAuthenticated ? <Feedback index={index} /> : null} {isAuthenticated ? <Feedback index={index} /> : null}
<Header authenticated={authenticatedOrAnonymous} links={index._links}> <Header authenticated={authenticatedOrAnonymous}>
<NavigationBar links={index._links} /> <NavigationBar links={index._links} />
</Header> </Header>
<div className="is-flex-grow-1">{content}</div> <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 i18next from "i18next";
import { binder, extensionPoints } from "@scm-manager/ui-extensions"; import { binder, extensionPoints } from "@scm-manager/ui-extensions";
import InitializationAdminAccountStep from "./InitializationAdminAccountStep"; import InitializationAdminAccountStep from "./InitializationAdminAccountStep";
import InitializationPluginWizardStep from "./InitializationPluginWizardStep";
const Index: FC = () => { const Index: FC = () => {
const { isLoading, error, data } = useIndex(); const { isLoading, error, data } = useIndex();
@@ -39,7 +40,7 @@ const Index: FC = () => {
// TODO check componentDidUpdate method for anonymous user stuff // TODO check componentDidUpdate method for anonymous user stuff
i18next.on("languageChanged", lng => { i18next.on("languageChanged", (lng) => {
document.documentElement.setAttribute("lang", lng); document.documentElement.setAttribute("lang", lng);
}); });
@@ -73,3 +74,8 @@ binder.bind<extensionPoints.InitializationStep<"adminAccount">>(
"initialization.step.adminAccount", "initialization.step.adminAccount",
InitializationAdminAccountStep 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.Embedded;
import de.otto.edison.hal.Links; import de.otto.edison.hal.Links;
import lombok.Data; import lombok.Data;
import org.apache.shiro.SecurityUtils;
import org.apache.shiro.authz.UnauthenticatedException; import org.apache.shiro.authz.UnauthenticatedException;
import sonia.scm.initialization.InitializationAuthenticationService;
import sonia.scm.initialization.InitializationStepResource; import sonia.scm.initialization.InitializationStepResource;
import sonia.scm.lifecycle.AdminAccountStartupAction; import sonia.scm.lifecycle.AdminAccountStartupAction;
import sonia.scm.plugin.Extension; import sonia.scm.plugin.Extension;
import sonia.scm.security.AllowAnonymousAccess; import sonia.scm.security.AllowAnonymousAccess;
import sonia.scm.security.Tokens;
import sonia.scm.util.ValidationUtil; import sonia.scm.util.ValidationUtil;
import javax.inject.Inject; 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.Valid;
import javax.validation.constraints.Email; import javax.validation.constraints.Email;
import javax.validation.constraints.NotEmpty; import javax.validation.constraints.NotEmpty;
@@ -42,9 +48,12 @@ import javax.validation.constraints.Pattern;
import javax.ws.rs.Consumes; import javax.ws.rs.Consumes;
import javax.ws.rs.POST; import javax.ws.rs.POST;
import javax.ws.rs.Path; 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 de.otto.edison.hal.Link.link;
import static sonia.scm.ScmConstraintViolationException.Builder.doThrow; import static sonia.scm.ScmConstraintViolationException.Builder.doThrow;
import static sonia.scm.initialization.InitializationWebTokenGenerator.INIT_TOKEN_HEADER;
@AllowAnonymousAccess @AllowAnonymousAccess
@Extension @Extension
@@ -52,20 +61,34 @@ public class AdminAccountStartupResource implements InitializationStepResource {
private final AdminAccountStartupAction adminAccountStartupAction; private final AdminAccountStartupAction adminAccountStartupAction;
private final ResourceLinks resourceLinks; private final ResourceLinks resourceLinks;
private final InitializationAuthenticationService authenticationService;
@Inject @Inject
public AdminAccountStartupResource(AdminAccountStartupAction adminAccountStartupAction, ResourceLinks resourceLinks) { public AdminAccountStartupResource(AdminAccountStartupAction adminAccountStartupAction, ResourceLinks resourceLinks, InitializationAuthenticationService authenticationService) {
this.adminAccountStartupAction = adminAccountStartupAction; this.adminAccountStartupAction = adminAccountStartupAction;
this.resourceLinks = resourceLinks; this.resourceLinks = resourceLinks;
this.authenticationService = authenticationService;
} }
@POST @POST
@Path("") @Path("")
@Consumes("application/json") @Consumes("application/json")
public void postAdminInitializationData(@Valid AdminInitializationData data) { public Response postAdminInitializationData(
@Context HttpServletRequest request,
@Context HttpServletResponse response,
@Valid AdminInitializationData data
) {
verifyInInitialization(); verifyInInitialization();
verifyToken(data); verifyToken(data);
createAdminUser(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() { private void verifyInInitialization() {

View File

@@ -168,7 +168,7 @@ public class AvailablePluginResource {
) )
@ApiResponse(responseCode = "200", description = "success") @ApiResponse(responseCode = "200", description = "success")
@ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials") @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( @ApiResponse(
responseCode = "500", responseCode = "500",
description = "internal server error", description = "internal server error",

View File

@@ -47,6 +47,7 @@ import sonia.scm.web.EdisonHalAppender;
import javax.inject.Inject; import javax.inject.Inject;
import java.util.List; import java.util.List;
import java.util.Locale;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import static de.otto.edison.hal.Embedded.embeddedBuilder; import static de.otto.edison.hal.Embedded.embeddedBuilder;
@@ -75,6 +76,10 @@ public class IndexDtoGenerator extends HalAppenderMapper {
} }
public IndexDto generate() { public IndexDto generate() {
return generate(Locale.getDefault());
}
public IndexDto generate(Locale locale) {
Links.Builder builder = Links.linkingTo(); Links.Builder builder = Links.linkingTo();
Embedded.Builder embeddedBuilder = embeddedBuilder(); Embedded.Builder embeddedBuilder = embeddedBuilder();
@@ -84,7 +89,7 @@ public class IndexDtoGenerator extends HalAppenderMapper {
if (initializationFinisher.isFullyInitialized()) { if (initializationFinisher.isFullyInitialized()) {
return handleNormalIndex(builder, embeddedBuilder); return handleNormalIndex(builder, embeddedBuilder);
} else { } else {
return handleInitialization(builder, embeddedBuilder); return handleInitialization(builder, embeddedBuilder, locale);
} }
} }
@@ -170,11 +175,11 @@ public class IndexDtoGenerator extends HalAppenderMapper {
.collect(Collectors.toList()); .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(); Links.Builder initializationLinkBuilder = Links.linkingTo();
Embedded.Builder initializationEmbeddedBuilder = embeddedBuilder(); Embedded.Builder initializationEmbeddedBuilder = embeddedBuilder();
InitializationStep initializationStep = initializationFinisher.missingInitialization(); 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())); embeddedBuilder.with(initializationStep.name(), new InitializationDto(initializationLinkBuilder.build(), initializationEmbeddedBuilder.build()));
return new IndexDto(builder.build(), embeddedBuilder.build(), scmContextProvider.getVersion(), scmContextProvider.getInstanceId(), initializationStep.name()); 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 sonia.scm.web.VndMediaType;
import javax.inject.Inject; import javax.inject.Inject;
import javax.servlet.http.HttpServletRequest;
import javax.ws.rs.GET; import javax.ws.rs.GET;
import javax.ws.rs.Path; import javax.ws.rs.Path;
import javax.ws.rs.Produces; import javax.ws.rs.Produces;
import javax.ws.rs.core.Context;
@OpenAPIDefinition( @OpenAPIDefinition(
security = { security = {
@@ -80,7 +82,7 @@ public class IndexResource {
schema = @Schema(implementation = ErrorDto.class) schema = @Schema(implementation = ErrorDto.class)
) )
) )
public IndexDto getIndex() { public IndexDto getIndex(@Context HttpServletRequest request) {
return indexDtoGenerator.generate(); return indexDtoGenerator.generate(request.getLocale());
} }
} }

View File

@@ -112,7 +112,7 @@ public class InstalledPluginResource {
) )
@ApiResponse(responseCode = "200", description = "success") @ApiResponse(responseCode = "200", description = "success")
@ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials") @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( @ApiResponse(
responseCode = "500", responseCode = "500",
description = "internal server error", description = "internal server error",
@@ -191,7 +191,7 @@ public class InstalledPluginResource {
) )
@ApiResponse(responseCode = "200", description = "success") @ApiResponse(responseCode = "200", description = "success")
@ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials") @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( @ApiResponse(
responseCode = "500", responseCode = "500",
description = "internal server error", description = "internal server error",

View File

@@ -160,7 +160,7 @@ public class PendingPluginResource {
) )
@ApiResponse(responseCode = "200", description = "success") @ApiResponse(responseCode = "200", description = "success")
@ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials") @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( @ApiResponse(
responseCode = "500", responseCode = "500",
description = "internal server error", description = "internal server error",
@@ -183,7 +183,7 @@ public class PendingPluginResource {
) )
@ApiResponse(responseCode = "200", description = "success") @ApiResponse(responseCode = "200", description = "success")
@ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials") @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( @ApiResponse(
responseCode = "500", responseCode = "500",
description = "internal server error", 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() { public PluginCenterAuthLinks pluginCenterAuth() {
return new PluginCenterAuthLinks(scmPathInfoStore.get().get()); 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 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 static final String INITIAL_USER_PROPERTY = "scm.initialUser";
private final PasswordService passwordService; 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; import sonia.scm.web.security.PrivilegedAction;
@ExtensionPoint @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.GroupManagerProvider;
import sonia.scm.group.xml.XmlGroupDAO; import sonia.scm.group.xml.XmlGroupDAO;
import sonia.scm.initialization.DefaultInitializationFinisher; import sonia.scm.initialization.DefaultInitializationFinisher;
import sonia.scm.initialization.InitializationCookieIssuer;
import sonia.scm.initialization.InitializationFinisher; import sonia.scm.initialization.InitializationFinisher;
import sonia.scm.io.ContentTypeResolver; import sonia.scm.io.ContentTypeResolver;
import sonia.scm.io.DefaultContentTypeResolver; import sonia.scm.io.DefaultContentTypeResolver;
@@ -271,6 +272,7 @@ class ScmServletModule extends ServletModule {
// bind events // bind events
bind(AccessTokenCookieIssuer.class).to(DefaultAccessTokenCookieIssuer.class); bind(AccessTokenCookieIssuer.class).to(DefaultAccessTokenCookieIssuer.class);
bind(InitializationCookieIssuer.class).to(DefaultAccessTokenCookieIssuer.class);
bind(PushStateDispatcher.class).toProvider(PushStateDispatcherProvider.class); bind(PushStateDispatcher.class).toProvider(PushStateDispatcherProvider.class);
// bind api link provider // bind api link provider

View File

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

View File

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

View File

@@ -21,10 +21,9 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE. * SOFTWARE.
*/ */
package sonia.scm.plugin; package sonia.scm.plugin;
import com.google.common.collect.ImmutableList;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.Getter; import lombok.Getter;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
@@ -56,12 +55,22 @@ public final class PluginCenterDto implements Serializable {
@XmlElement(name = "plugins") @XmlElement(name = "plugins")
private List<Plugin> plugins; private List<Plugin> plugins;
@XmlElement(name = "plugin-sets")
private List<PluginSet> pluginSets;
public List<Plugin> getPlugins() { public List<Plugin> getPlugins() {
if (plugins == null) { if (plugins == null) {
plugins = ImmutableList.of(); plugins = List.of();
} }
return plugins; return plugins;
} }
public List<PluginSet> getPluginSets() {
if (pluginSets == null) {
pluginSets = List.of();
}
return pluginSets;
}
} }
@XmlAccessorType(XmlAccessType.FIELD) @XmlAccessorType(XmlAccessType.FIELD)
@@ -93,6 +102,36 @@ public final class PluginCenterDto implements Serializable {
private final Map<String, Link> links; 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) @XmlAccessorType(XmlAccessType.FIELD)
@XmlRootElement(name = "conditions") @XmlRootElement(name = "conditions")
@Getter @Getter

View File

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

View File

@@ -33,7 +33,6 @@ import sonia.scm.net.ahc.AdvancedHttpRequest;
import javax.inject.Inject; import javax.inject.Inject;
import java.util.Collections; import java.util.Collections;
import java.util.Set;
import static sonia.scm.plugin.Tracing.SPAN_KIND; import static sonia.scm.plugin.Tracing.SPAN_KIND;
@@ -64,7 +63,7 @@ class PluginCenterLoader {
this.eventBus = eventBus; this.eventBus = eventBus;
} }
Set<AvailablePlugin> load(String url) { PluginCenterResult load(String url) {
try { try {
LOG.info("fetch plugins from {}", url); LOG.info("fetch plugins from {}", url);
AdvancedHttpRequest request = client.get(url).spanKind(SPAN_KIND); AdvancedHttpRequest request = client.get(url).spanKind(SPAN_KIND);
@@ -76,7 +75,7 @@ class PluginCenterLoader {
} catch (Exception ex) { } catch (Exception ex) {
LOG.error("failed to load plugins from plugin center, returning empty list", ex); LOG.error("failed to load plugins from plugin center, returning empty list", ex);
eventBus.post(new PluginCenterErrorEvent()); 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.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import sonia.scm.config.ScmConfiguration; import sonia.scm.config.ScmConfiguration;
import sonia.scm.initialization.InitializationCookieIssuer;
import sonia.scm.util.HttpUtil; import sonia.scm.util.HttpUtil;
import sonia.scm.util.Util; import sonia.scm.util.Util;
@@ -36,16 +37,18 @@ import javax.inject.Inject;
import javax.servlet.http.Cookie; import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse; import javax.servlet.http.HttpServletResponse;
import java.util.Date; import java.util.Arrays;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import static sonia.scm.initialization.InitializationWebTokenGenerator.INIT_TOKEN_HEADER;
/** /**
* Generates cookies and invalidates access token cookies. * Generates cookies and invalidates access token cookies.
* *
* @author Sebastian Sdorra * @author Sebastian Sdorra
* @since 2.0.0 * @since 2.0.0
*/ */
public final class DefaultAccessTokenCookieIssuer implements AccessTokenCookieIssuer { public final class DefaultAccessTokenCookieIssuer implements AccessTokenCookieIssuer, InitializationCookieIssuer {
/** /**
* the logger for DefaultAccessTokenCookieIssuer * the logger for DefaultAccessTokenCookieIssuer
@@ -87,6 +90,25 @@ public final class DefaultAccessTokenCookieIssuer implements AccessTokenCookieIs
response.addCookie(c); 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. * Invalidates the authentication cookie.
* *
@@ -95,8 +117,20 @@ public final class DefaultAccessTokenCookieIssuer implements AccessTokenCookieIs
*/ */
public void invalidate(HttpServletRequest request, HttpServletResponse response) { public void invalidate(HttpServletRequest request, HttpServletResponse response) {
LOG.trace("invalidates access token cookie"); 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.setPath(contextPath(request));
c.setMaxAge(0); c.setMaxAge(0);
c.setHttpOnly(isHttpOnly()); 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; 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.MockHttpRequest;
import org.jboss.resteasy.mock.MockHttpResponse; import org.jboss.resteasy.mock.MockHttpResponse;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
@@ -33,10 +36,19 @@ import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks; import org.mockito.InjectMocks;
import org.mockito.Mock; import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoExtension;
import sonia.scm.config.ScmConfiguration;
import sonia.scm.initialization.InitializationAuthenticationService;
import sonia.scm.lifecycle.AdminAccountStartupAction; 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 sonia.scm.web.RestDispatcher;
import javax.inject.Provider; import javax.inject.Provider;
import javax.servlet.http.HttpServletRequest;
import java.net.URI;
import java.net.URISyntaxException; import java.net.URISyntaxException;
import static java.lang.String.format; 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.jboss.resteasy.mock.MockHttpRequest.post;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
@SubjectAware(
configuration = "classpath:sonia/scm/repository/shiro.ini"
)
@ExtendWith(MockitoExtension.class) @ExtendWith(MockitoExtension.class)
class AdminAccountStartupResourceTest { class AdminAccountStartupResourceTest {
@@ -60,9 +76,16 @@ class AdminAccountStartupResourceTest {
@Mock @Mock
private Provider<ScmPathInfoStore> pathInfoStoreProvider; private Provider<ScmPathInfoStore> pathInfoStoreProvider;
@Mock @Mock
private InitializationAuthenticationService authenticationService;
@Mock
private ScmPathInfoStore pathInfoStore; private ScmPathInfoStore pathInfoStore;
@Mock @Mock
private ScmPathInfo pathInfo; private ScmPathInfo pathInfo;
@Mock
private AccessTokenBuilderFactory accessTokenBuilderFactory;
@Mock
private AccessTokenBuilder accessTokenBuilder;
private final AccessTokenCookieIssuer cookieIssuer = new DefaultAccessTokenCookieIssuer(mock(ScmConfiguration.class));
@InjectMocks @InjectMocks
private AdminAccountStartupResource resource; private AdminAccountStartupResource resource;
@@ -73,6 +96,9 @@ class AdminAccountStartupResourceTest {
lenient().when(pathInfoStore.get()).thenReturn(pathInfo); lenient().when(pathInfoStore.get()).thenReturn(pathInfo);
dispatcher.addSingletonResource(new InitializationResource(singleton(resource))); dispatcher.addSingletonResource(new InitializationResource(singleton(resource)));
lenient().when(startupAction.name()).thenReturn("adminAccount"); lenient().when(startupAction.name()).thenReturn("adminAccount");
lenient().when(accessTokenBuilderFactory.create()).thenReturn(accessTokenBuilder);
AccessToken accessToken = mock(AccessToken.class);
lenient().when(accessTokenBuilder.build()).thenReturn(accessToken);
} }
@Test @Test
@@ -121,10 +147,17 @@ class AdminAccountStartupResourceTest {
@Test @Test
void shouldCreateAdminUser() throws URISyntaxException { void shouldCreateAdminUser() throws URISyntaxException {
Subject subject = mock(Subject.class);
ThreadContext.bind(subject);
MockHttpRequest request = MockHttpRequest request =
post("/v2/initialization/adminAccount") post("/v2/initialization/adminAccount")
.contentType("application/json") .contentType("application/json")
.content(createInput("initial-token", "trillian", "Tricia", "tricia@hitchhiker.com", "password", "password")); .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); dispatcher.invoke(request, response);
assertThat(response.getStatus()).isEqualTo(204); assertThat(response.getStatus()).isEqualTo(204);

View File

@@ -21,7 +21,7 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE. * SOFTWARE.
*/ */
package sonia.scm.api.v2.resources; package sonia.scm.api.v2.resources;
import de.otto.edison.hal.HalRepresentation; import de.otto.edison.hal.HalRepresentation;

View File

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

View File

@@ -30,19 +30,24 @@ import org.assertj.core.api.Assertions;
import org.junit.Before; import org.junit.Before;
import org.junit.Rule; import org.junit.Rule;
import org.junit.Test; 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.SCMContextProvider;
import sonia.scm.config.ScmConfiguration; import sonia.scm.config.ScmConfiguration;
import sonia.scm.initialization.InitializationFinisher; import sonia.scm.initialization.InitializationFinisher;
import sonia.scm.plugin.PluginCenterAuthenticator;
import sonia.scm.search.SearchEngine; import sonia.scm.search.SearchEngine;
import javax.servlet.http.HttpServletRequest;
import java.net.URI; import java.net.URI;
import java.util.Locale;
import java.util.Optional; import java.util.Optional;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
@SubjectAware(configuration = "classpath:sonia/scm/shiro-002.ini") @SubjectAware(configuration = "classpath:sonia/scm/shiro-002.ini")
@RunWith(MockitoJUnitRunner.class)
public class IndexResourceTest { public class IndexResourceTest {
@Rule @Rule
@@ -52,8 +57,12 @@ public class IndexResourceTest {
private SCMContextProvider scmContextProvider; private SCMContextProvider scmContextProvider;
private IndexResource indexResource; private IndexResource indexResource;
@Mock
private HttpServletRequest httpServletRequest;
@Before @Before
public void setUpObjectUnderTest() { public void setUpObjectUnderTest() {
when(httpServletRequest.getLocale()).thenReturn(Locale.ENGLISH);
this.configuration = new ScmConfiguration(); this.configuration = new ScmConfiguration();
this.scmContextProvider = mock(SCMContextProvider.class); this.scmContextProvider = mock(SCMContextProvider.class);
InitializationFinisher initializationFinisher = mock(InitializationFinisher.class); InitializationFinisher initializationFinisher = mock(InitializationFinisher.class);
@@ -72,7 +81,7 @@ public class IndexResourceTest {
@Test @Test
@SubjectAware(username = "dent", password = "secret") @SubjectAware(username = "dent", password = "secret")
public void shouldRenderPluginCenterAuthLink() { public void shouldRenderPluginCenterAuthLink() {
IndexDto index = indexResource.getIndex(); IndexDto index = indexResource.getIndex(httpServletRequest);
Assertions.assertThat(index.getLinks().getLinkBy("pluginCenterAuth")).isPresent(); Assertions.assertThat(index.getLinks().getLinkBy("pluginCenterAuth")).isPresent();
} }
@@ -80,21 +89,21 @@ public class IndexResourceTest {
@Test @Test
@SubjectAware(username = "trillian", password = "secret") @SubjectAware(username = "trillian", password = "secret")
public void shouldNotRenderPluginCenterLoginLinkIfPermissionsAreMissing() { public void shouldNotRenderPluginCenterLoginLinkIfPermissionsAreMissing() {
IndexDto index = indexResource.getIndex(); IndexDto index = indexResource.getIndex(httpServletRequest);
Assertions.assertThat(index.getLinks().getLinkBy("pluginCenterAuth")).isNotPresent(); Assertions.assertThat(index.getLinks().getLinkBy("pluginCenterAuth")).isNotPresent();
} }
@Test @Test
public void shouldRenderLoginUrlsForUnauthenticatedRequest() { public void shouldRenderLoginUrlsForUnauthenticatedRequest() {
IndexDto index = indexResource.getIndex(); IndexDto index = indexResource.getIndex(httpServletRequest);
Assertions.assertThat(index.getLinks().getLinkBy("login")).matches(Optional::isPresent); Assertions.assertThat(index.getLinks().getLinkBy("login")).matches(Optional::isPresent);
} }
@Test @Test
public void shouldRenderLoginInfoUrl() { public void shouldRenderLoginInfoUrl() {
IndexDto index = indexResource.getIndex(); IndexDto index = indexResource.getIndex(httpServletRequest);
Assertions.assertThat(index.getLinks().getLinkBy("loginInfo")).isPresent(); Assertions.assertThat(index.getLinks().getLinkBy("loginInfo")).isPresent();
} }
@@ -103,21 +112,21 @@ public class IndexResourceTest {
public void shouldNotRenderLoginInfoUrlWhenNoUrlIsConfigured() { public void shouldNotRenderLoginInfoUrlWhenNoUrlIsConfigured() {
configuration.setLoginInfoUrl(""); configuration.setLoginInfoUrl("");
IndexDto index = indexResource.getIndex(); IndexDto index = indexResource.getIndex(httpServletRequest);
Assertions.assertThat(index.getLinks().getLinkBy("loginInfo")).isNotPresent(); Assertions.assertThat(index.getLinks().getLinkBy("loginInfo")).isNotPresent();
} }
@Test @Test
public void shouldRenderSelfLinkForUnauthenticatedRequest() { public void shouldRenderSelfLinkForUnauthenticatedRequest() {
IndexDto index = indexResource.getIndex(); IndexDto index = indexResource.getIndex(httpServletRequest);
Assertions.assertThat(index.getLinks().getLinkBy("self")).matches(Optional::isPresent); Assertions.assertThat(index.getLinks().getLinkBy("self")).matches(Optional::isPresent);
} }
@Test @Test
public void shouldRenderUiPluginsLinkForUnauthenticatedRequest() { public void shouldRenderUiPluginsLinkForUnauthenticatedRequest() {
IndexDto index = indexResource.getIndex(); IndexDto index = indexResource.getIndex(httpServletRequest);
Assertions.assertThat(index.getLinks().getLinkBy("uiPlugins")).matches(Optional::isPresent); Assertions.assertThat(index.getLinks().getLinkBy("uiPlugins")).matches(Optional::isPresent);
} }
@@ -125,7 +134,7 @@ public class IndexResourceTest {
@Test @Test
@SubjectAware(username = "trillian", password = "secret") @SubjectAware(username = "trillian", password = "secret")
public void shouldRenderSelfLinkForAuthenticatedRequest() { public void shouldRenderSelfLinkForAuthenticatedRequest() {
IndexDto index = indexResource.getIndex(); IndexDto index = indexResource.getIndex(httpServletRequest);
Assertions.assertThat(index.getLinks().getLinkBy("self")).matches(Optional::isPresent); Assertions.assertThat(index.getLinks().getLinkBy("self")).matches(Optional::isPresent);
} }
@@ -133,7 +142,7 @@ public class IndexResourceTest {
@Test @Test
@SubjectAware(username = "trillian", password = "secret") @SubjectAware(username = "trillian", password = "secret")
public void shouldRenderUiPluginsLinkForAuthenticatedRequest() { public void shouldRenderUiPluginsLinkForAuthenticatedRequest() {
IndexDto index = indexResource.getIndex(); IndexDto index = indexResource.getIndex(httpServletRequest);
Assertions.assertThat(index.getLinks().getLinkBy("uiPlugins")).matches(Optional::isPresent); Assertions.assertThat(index.getLinks().getLinkBy("uiPlugins")).matches(Optional::isPresent);
} }
@@ -141,7 +150,7 @@ public class IndexResourceTest {
@Test @Test
@SubjectAware(username = "trillian", password = "secret") @SubjectAware(username = "trillian", password = "secret")
public void shouldRenderMeUrlForAuthenticatedRequest() { public void shouldRenderMeUrlForAuthenticatedRequest() {
IndexDto index = indexResource.getIndex(); IndexDto index = indexResource.getIndex(httpServletRequest);
Assertions.assertThat(index.getLinks().getLinkBy("me")).matches(Optional::isPresent); Assertions.assertThat(index.getLinks().getLinkBy("me")).matches(Optional::isPresent);
} }
@@ -149,7 +158,7 @@ public class IndexResourceTest {
@Test @Test
@SubjectAware(username = "trillian", password = "secret") @SubjectAware(username = "trillian", password = "secret")
public void shouldRenderLogoutUrlForAuthenticatedRequest() { public void shouldRenderLogoutUrlForAuthenticatedRequest() {
IndexDto index = indexResource.getIndex(); IndexDto index = indexResource.getIndex(httpServletRequest);
Assertions.assertThat(index.getLinks().getLinkBy("logout")).matches(Optional::isPresent); Assertions.assertThat(index.getLinks().getLinkBy("logout")).matches(Optional::isPresent);
} }
@@ -157,7 +166,7 @@ public class IndexResourceTest {
@Test @Test
@SubjectAware(username = "trillian", password = "secret") @SubjectAware(username = "trillian", password = "secret")
public void shouldRenderRepositoriesForAuthenticatedRequest() { public void shouldRenderRepositoriesForAuthenticatedRequest() {
IndexDto index = indexResource.getIndex(); IndexDto index = indexResource.getIndex(httpServletRequest);
Assertions.assertThat(index.getLinks().getLinkBy("repositories")).matches(Optional::isPresent); Assertions.assertThat(index.getLinks().getLinkBy("repositories")).matches(Optional::isPresent);
} }
@@ -165,7 +174,7 @@ public class IndexResourceTest {
@Test @Test
@SubjectAware(username = "trillian", password = "secret") @SubjectAware(username = "trillian", password = "secret")
public void shouldNotRenderAdminLinksIfNotAuthorized() { 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("users")).matches(o -> !o.isPresent());
Assertions.assertThat(index.getLinks().getLinkBy("groups")).matches(o -> !o.isPresent()); Assertions.assertThat(index.getLinks().getLinkBy("groups")).matches(o -> !o.isPresent());
@@ -175,7 +184,7 @@ public class IndexResourceTest {
@Test @Test
@SubjectAware(username = "trillian", password = "secret") @SubjectAware(username = "trillian", password = "secret")
public void shouldRenderAutoCompleteLinks() { public void shouldRenderAutoCompleteLinks() {
IndexDto index = indexResource.getIndex(); IndexDto index = indexResource.getIndex(httpServletRequest);
Assertions.assertThat(index.getLinks().getLinksBy("autocomplete")) Assertions.assertThat(index.getLinks().getLinksBy("autocomplete"))
.extracting("name") .extracting("name")
@@ -185,7 +194,7 @@ public class IndexResourceTest {
@Test @Test
@SubjectAware(username = "user_without_autocomplete_permission", password = "secret") @SubjectAware(username = "user_without_autocomplete_permission", password = "secret")
public void userWithoutAutocompletePermissionShouldSeeAutoCompleteLinksOnlyForNamespaces() { public void userWithoutAutocompletePermissionShouldSeeAutoCompleteLinksOnlyForNamespaces() {
IndexDto index = indexResource.getIndex(); IndexDto index = indexResource.getIndex(httpServletRequest);
Assertions.assertThat(index.getLinks().getLinksBy("autocomplete")) Assertions.assertThat(index.getLinks().getLinksBy("autocomplete"))
.extracting("name") .extracting("name")
@@ -195,7 +204,7 @@ public class IndexResourceTest {
@Test @Test
@SubjectAware(username = "dent", password = "secret") @SubjectAware(username = "dent", password = "secret")
public void shouldRenderAdminLinksIfAuthorized() { 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("users")).matches(Optional::isPresent);
Assertions.assertThat(index.getLinks().getLinkBy("groups")).matches(Optional::isPresent); Assertions.assertThat(index.getLinks().getLinkBy("groups")).matches(Optional::isPresent);
@@ -206,7 +215,7 @@ public class IndexResourceTest {
public void shouldGenerateVersion() { public void shouldGenerateVersion() {
when(scmContextProvider.getVersion()).thenReturn("v1"); when(scmContextProvider.getVersion()).thenReturn("v1");
IndexDto index = indexResource.getIndex(); IndexDto index = indexResource.getIndex(httpServletRequest);
Assertions.assertThat(index.getVersion()).isEqualTo("v1"); 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; package sonia.scm.plugin;
import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableSet; import com.google.common.collect.ImmutableSet;
import org.apache.shiro.authz.AuthorizationException; import org.apache.shiro.authz.AuthorizationException;
import org.apache.shiro.subject.Subject; import org.apache.shiro.subject.Subject;
@@ -38,6 +39,7 @@ import org.junit.jupiter.api.io.TempDir;
import org.mockito.ArgumentCaptor; import org.mockito.ArgumentCaptor;
import org.mockito.Captor; import org.mockito.Captor;
import org.mockito.Mock; import org.mockito.Mock;
import org.mockito.Mockito;
import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoExtension;
import sonia.scm.NotFoundException; import sonia.scm.NotFoundException;
import sonia.scm.ScmConstraintViolationException; import sonia.scm.ScmConstraintViolationException;
@@ -49,6 +51,7 @@ import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.Set;
import static java.util.Arrays.asList; import static java.util.Arrays.asList;
import static java.util.Collections.singleton; import static java.util.Collections.singleton;
@@ -83,6 +86,9 @@ class DefaultPluginManagerTest {
@Mock @Mock
private Restarter restarter; private Restarter restarter;
@Mock
private PluginSetConfigStore pluginSetConfigStore;
@Mock @Mock
private ScmEventBus eventBus; private ScmEventBus eventBus;
@@ -110,7 +116,7 @@ class DefaultPluginManagerTest {
@BeforeEach @BeforeEach
void setUpObjectUnderTest() { void setUpObjectUnderTest() {
manager = new DefaultPluginManager( 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 review = createAvailable("scm-review-plugin");
AvailablePlugin git = createAvailable("scm-git-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(); List<AvailablePlugin> available = manager.getAvailable();
assertThat(available).containsOnly(review, git); assertThat(available).containsOnly(review, git);
@@ -175,7 +181,7 @@ class DefaultPluginManagerTest {
AvailablePlugin review = createAvailable("scm-review-plugin"); AvailablePlugin review = createAvailable("scm-review-plugin");
AvailablePlugin git = createAvailable("scm-git-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(); List<AvailablePlugin> available = manager.getAvailable();
assertThat(available).containsOnly(review); assertThat(available).containsOnly(review);
@@ -185,7 +191,7 @@ class DefaultPluginManagerTest {
void shouldReturnAvailable() { void shouldReturnAvailable() {
AvailablePlugin review = createAvailable("scm-review-plugin"); AvailablePlugin review = createAvailable("scm-review-plugin");
AvailablePlugin git = createAvailable("scm-git-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"); Optional<AvailablePlugin> available = manager.getAvailable("scm-git-plugin");
assertThat(available).contains(git); assertThat(available).contains(git);
@@ -194,7 +200,7 @@ class DefaultPluginManagerTest {
@Test @Test
void shouldReturnEmptyForNonExistingAvailable() { void shouldReturnEmptyForNonExistingAvailable() {
AvailablePlugin review = createAvailable("scm-review-plugin"); 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"); Optional<AvailablePlugin> available = manager.getAvailable("scm-git-plugin");
assertThat(available).isEmpty(); assertThat(available).isEmpty();
@@ -206,7 +212,7 @@ class DefaultPluginManagerTest {
when(loader.getInstalledPlugins()).thenReturn(ImmutableList.of(installedGit)); when(loader.getInstalledPlugins()).thenReturn(ImmutableList.of(installedGit));
AvailablePlugin git = createAvailable("scm-git-plugin"); 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"); Optional<AvailablePlugin> available = manager.getAvailable("scm-git-plugin");
assertThat(available).isEmpty(); assertThat(available).isEmpty();
@@ -215,7 +221,7 @@ class DefaultPluginManagerTest {
@Test @Test
void shouldInstallThePlugin() { void shouldInstallThePlugin() {
AvailablePlugin git = createAvailable("scm-git-plugin"); 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); manager.install("scm-git-plugin", false);
@@ -228,7 +234,7 @@ class DefaultPluginManagerTest {
AvailablePlugin review = createAvailable("scm-review-plugin"); AvailablePlugin review = createAvailable("scm-review-plugin");
when(review.getDescriptor().getDependencies()).thenReturn(ImmutableSet.of("scm-mail-plugin")); when(review.getDescriptor().getDependencies()).thenReturn(ImmutableSet.of("scm-mail-plugin"));
AvailablePlugin mail = createAvailable("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); manager.install("scm-review-plugin", false);
@@ -241,7 +247,7 @@ class DefaultPluginManagerTest {
AvailablePlugin review = createAvailable("scm-review-plugin"); AvailablePlugin review = createAvailable("scm-review-plugin");
when(review.getDescriptor().getDependencies()).thenReturn(ImmutableSet.of("scm-mail-plugin")); when(review.getDescriptor().getDependencies()).thenReturn(ImmutableSet.of("scm-mail-plugin"));
AvailablePlugin mail = createAvailable("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"); InstalledPlugin installedMail = createInstalled("scm-mail-plugin");
when(loader.getInstalledPlugins()).thenReturn(ImmutableList.of(installedMail)); when(loader.getInstalledPlugins()).thenReturn(ImmutableList.of(installedMail));
@@ -259,7 +265,7 @@ class DefaultPluginManagerTest {
AvailablePlugin review = createAvailable("scm-review-plugin"); AvailablePlugin review = createAvailable("scm-review-plugin");
when(review.getDescriptor().getDependencies()).thenReturn(ImmutableSet.of("scm-mail-plugin")); when(review.getDescriptor().getDependencies()).thenReturn(ImmutableSet.of("scm-mail-plugin"));
AvailablePlugin mail = createAvailable("scm-mail-plugin", "1.1.0"); 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"); InstalledPlugin installedMail = createInstalled("scm-mail-plugin", "1.0.0");
when(loader.getInstalledPlugins()).thenReturn(ImmutableList.of(installedMail)); when(loader.getInstalledPlugins()).thenReturn(ImmutableList.of(installedMail));
@@ -275,7 +281,7 @@ class DefaultPluginManagerTest {
AvailablePlugin review = createAvailable("scm-review-plugin"); AvailablePlugin review = createAvailable("scm-review-plugin");
when(review.getDescriptor().getOptionalDependencies()).thenReturn(ImmutableSet.of("scm-mail-plugin")); when(review.getDescriptor().getOptionalDependencies()).thenReturn(ImmutableSet.of("scm-mail-plugin"));
AvailablePlugin mail = createAvailable("scm-mail-plugin", "1.1.0"); 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"); InstalledPlugin installedMail = createInstalled("scm-mail-plugin", "1.0.0");
when(loader.getInstalledPlugins()).thenReturn(ImmutableList.of(installedMail)); when(loader.getInstalledPlugins()).thenReturn(ImmutableList.of(installedMail));
@@ -291,7 +297,7 @@ class DefaultPluginManagerTest {
AvailablePlugin review = createAvailable("scm-review-plugin"); AvailablePlugin review = createAvailable("scm-review-plugin");
when(review.getDescriptor().getOptionalDependencies()).thenReturn(ImmutableSet.of("scm-mail-plugin")); when(review.getDescriptor().getOptionalDependencies()).thenReturn(ImmutableSet.of("scm-mail-plugin"));
AvailablePlugin mail = createAvailable("scm-mail-plugin", "1.1.0"); 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); manager.install("scm-review-plugin", false);
@@ -306,7 +312,7 @@ class DefaultPluginManagerTest {
AvailablePlugin mail = createAvailable("scm-mail-plugin"); AvailablePlugin mail = createAvailable("scm-mail-plugin");
when(mail.getDescriptor().getDependencies()).thenReturn(ImmutableSet.of("scm-notification-plugin")); when(mail.getDescriptor().getDependencies()).thenReturn(ImmutableSet.of("scm-notification-plugin"));
AvailablePlugin notification = createAvailable("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); PendingPluginInstallation pendingNotification = mock(PendingPluginInstallation.class);
doReturn(pendingNotification).when(installer).install(context, notification); doReturn(pendingNotification).when(installer).install(context, notification);
@@ -328,7 +334,7 @@ class DefaultPluginManagerTest {
when(review.getDescriptor().getDependencies()).thenReturn(ImmutableSet.of("scm-mail-plugin")); when(review.getDescriptor().getDependencies()).thenReturn(ImmutableSet.of("scm-mail-plugin"));
AvailablePlugin mail = createAvailable("scm-mail-plugin"); AvailablePlugin mail = createAvailable("scm-mail-plugin");
when(mail.getDescriptor().getDependencies()).thenReturn(ImmutableSet.of("scm-notification-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)); assertThrows(NotFoundException.class, () -> manager.install("scm-review-plugin", false));
@@ -338,7 +344,7 @@ class DefaultPluginManagerTest {
@Test @Test
void shouldSendRestartEventAfterInstallation() { void shouldSendRestartEventAfterInstallation() {
AvailablePlugin git = createAvailable("scm-git-plugin"); 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); manager.install("scm-git-plugin", true);
@@ -358,7 +364,7 @@ class DefaultPluginManagerTest {
@Test @Test
void shouldNotInstallAlreadyPendingPlugins() { void shouldNotInstallAlreadyPendingPlugins() {
AvailablePlugin review = createAvailable("scm-review-plugin"); 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);
manager.install("scm-review-plugin", false); manager.install("scm-review-plugin", false);
@@ -369,7 +375,7 @@ class DefaultPluginManagerTest {
@Test @Test
void shouldSendRestartEvent() { void shouldSendRestartEvent() {
AvailablePlugin review = createAvailable("scm-review-plugin"); 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);
manager.executePendingAndRestart(); manager.executePendingAndRestart();
@@ -387,7 +393,7 @@ class DefaultPluginManagerTest {
@Test @Test
void shouldReturnSingleAvailableAsPending() { void shouldReturnSingleAvailableAsPending() {
AvailablePlugin review = createAvailable("scm-review-plugin"); 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);
@@ -398,7 +404,7 @@ class DefaultPluginManagerTest {
@Test @Test
void shouldReturnAvailableAsPending() { void shouldReturnAvailableAsPending() {
AvailablePlugin review = createAvailable("scm-review-plugin"); 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);
@@ -514,7 +520,7 @@ class DefaultPluginManagerTest {
when(reviewPlugin.getDescriptor().getDependencies()).thenReturn(singleton("scm-mail-plugin")); when(reviewPlugin.getDescriptor().getDependencies()).thenReturn(singleton("scm-mail-plugin"));
when(loader.getInstalledPlugins()).thenReturn(singletonList(mailPlugin)); when(loader.getInstalledPlugins()).thenReturn(singletonList(mailPlugin));
when(center.getAvailable()).thenReturn(singleton(reviewPlugin)); when(center.getAvailablePlugins()).thenReturn(singleton(reviewPlugin));
manager.computeInstallationDependencies(); manager.computeInstallationDependencies();
@@ -546,7 +552,7 @@ class DefaultPluginManagerTest {
doNothing().when(mailPlugin).setMarkedForUninstall(uninstallCaptor.capture()); doNothing().when(mailPlugin).setMarkedForUninstall(uninstallCaptor.capture());
AvailablePlugin git = createAvailable("scm-git-plugin"); 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); PendingPluginInstallation gitPendingPluginInformation = mock(PendingPluginInstallation.class);
when(installer.install(context, git)).thenReturn(gitPendingPluginInformation); when(installer.install(context, git)).thenReturn(gitPendingPluginInformation);
@@ -577,7 +583,7 @@ class DefaultPluginManagerTest {
AvailablePlugin newMailPlugin = createAvailable("scm-mail-plugin", "2.0.0"); AvailablePlugin newMailPlugin = createAvailable("scm-mail-plugin", "2.0.0");
AvailablePlugin newReviewPlugin = createAvailable("scm-review-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(); manager.updateAll();
@@ -593,7 +599,7 @@ class DefaultPluginManagerTest {
when(loader.getInstalledPlugins()).thenReturn(ImmutableList.of(scriptPlugin)); when(loader.getInstalledPlugins()).thenReturn(ImmutableList.of(scriptPlugin));
AvailablePlugin oldScriptPlugin = createAvailable("scm-script-plugin", "0.9"); AvailablePlugin oldScriptPlugin = createAvailable("scm-script-plugin", "0.9");
when(center.getAvailable()).thenReturn(ImmutableSet.of(oldScriptPlugin)); when(center.getAvailablePlugins()).thenReturn(ImmutableSet.of(oldScriptPlugin));
manager.updateAll(); manager.updateAll();
@@ -603,7 +609,7 @@ class DefaultPluginManagerTest {
@Test @Test
void shouldFirePluginEventOnInstallation() { void shouldFirePluginEventOnInstallation() {
AvailablePlugin review = createAvailable("scm-review-plugin"); 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);
@@ -616,7 +622,7 @@ class DefaultPluginManagerTest {
@Test @Test
void shouldFirePluginEventOnFailedInstallation() { void shouldFirePluginEventOnFailedInstallation() {
AvailablePlugin review = createAvailable("scm-review-plugin"); 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); doThrow(new PluginDownloadException(review, new IOException())).when(installer).install(context, review);
assertThrows(PluginDownloadException.class, () -> manager.install("scm-review-plugin", false)); 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(jenkins.getDescriptor().getDependencies()).thenReturn(ImmutableSet.of("scm-el-plugin"));
when(webhook.getDescriptor().getDependencies()).thenReturn(ImmutableSet.of("scm-el-plugin")); when(webhook.getDescriptor().getDependencies()).thenReturn(ImmutableSet.of("scm-el-plugin"));
AvailablePlugin el = createAvailable("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-jenkins-plugin", false);
manager.install("scm-webhook-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-webhook-plugin")).isPresent();
assertThat(pluginInstallationContext.find("scm-el-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 @Nested
@@ -672,6 +727,7 @@ class DefaultPluginManagerTest {
assertThrows(AuthorizationException.class, () -> manager.getInstalled("test")); assertThrows(AuthorizationException.class, () -> manager.getInstalled("test"));
assertThrows(AuthorizationException.class, () -> manager.getAvailable()); assertThrows(AuthorizationException.class, () -> manager.getAvailable());
assertThrows(AuthorizationException.class, () -> manager.getAvailable("test")); assertThrows(AuthorizationException.class, () -> manager.getAvailable("test"));
assertThrows(AuthorizationException.class, () -> manager.getPluginSets());
} }
} }
@@ -695,6 +751,12 @@ class DefaultPluginManagerTest {
assertThrows(AuthorizationException.class, () -> manager.install("test", false)); assertThrows(AuthorizationException.class, () -> manager.install("test", false));
} }
@Test
void shouldThrowAuthorizationExceptionsForInstallPluginSetsMethod() {
ImmutableSet<String> pluginSetIds = ImmutableSet.of("test");
assertThrows(AuthorizationException.class, () -> manager.installPluginSets(pluginSetIds, false));
}
@Test @Test
void shouldThrowAuthorizationExceptionsForUninstallMethod() { void shouldThrowAuthorizationExceptionsForUninstallMethod() {
assertThrows(AuthorizationException.class, () -> manager.uninstall("test", false)); assertThrows(AuthorizationException.class, () -> manager.uninstall("test", false));

View File

@@ -33,17 +33,16 @@ import org.mockito.InjectMocks;
import org.mockito.Mock; import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoExtension;
import java.util.ArrayList;
import java.util.Arrays; import java.util.Arrays;
import java.util.Collections; import java.util.Collections;
import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Set; import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.when; 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.Plugin;
import static sonia.scm.plugin.PluginCenterDto.*;
@ExtendWith(MockitoExtension.class) @ExtendWith(MockitoExtension.class)
class PluginCenterDtoMapperTest { class PluginCenterDtoMapperTest {
@@ -72,8 +71,19 @@ class PluginCenterDtoMapperTest {
ImmutableMap.of("download", new Link("http://download.hitchhiker.com")) 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)); 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(); PluginInformation information = descriptor.getInformation();
PluginCondition condition = descriptor.getCondition(); PluginCondition condition = descriptor.getCondition();
@@ -88,6 +98,14 @@ class PluginCenterDtoMapperTest {
assertThat(condition.getOs().iterator().next()).isEqualTo(plugin.getConditions().getOs().iterator().next()); assertThat(condition.getOs().iterator().next()).isEqualTo(plugin.getConditions().getOs().iterator().next());
assertThat(information.getDescription()).isEqualTo(plugin.getDescription()); assertThat(information.getDescription()).isEqualTo(plugin.getDescription());
assertThat(information.getName()).isEqualTo(plugin.getName()); 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 @Test
@@ -126,7 +144,8 @@ class PluginCenterDtoMapperTest {
when(dto.getEmbedded().getPlugins()).thenReturn(Arrays.asList(plugin1, plugin2)); 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 pluginInformation1 = findPlugin(resultSet, plugin1.getName());
PluginInformation pluginInformation2 = findPlugin(resultSet, plugin2.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.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*; import static org.mockito.Mockito.*;
import static sonia.scm.plugin.Tracing.SPAN_KIND;
@ExtendWith(MockitoExtension.class) @ExtendWith(MockitoExtension.class)
class PluginCenterLoaderTest { class PluginCenterLoaderTest {
@@ -71,12 +70,15 @@ class PluginCenterLoaderTest {
@Test @Test
void shouldFetch() throws IOException { void shouldFetch() throws IOException {
Set<AvailablePlugin> plugins = Collections.emptySet(); Set<AvailablePlugin> plugins = Collections.emptySet();
Set<PluginSet> pluginSets = Collections.emptySet();
PluginCenterDto dto = new PluginCenterDto(); PluginCenterDto dto = new PluginCenterDto();
PluginCenterResult pluginCenterResult = new PluginCenterResult(plugins, pluginSets);
when(request().contentFromJson(PluginCenterDto.class)).thenReturn(dto); 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); PluginCenterResult fetched = loader.load(PLUGIN_URL);
assertThat(fetched).isSameAs(plugins); assertThat(fetched.getPlugins()).isSameAs(plugins);
assertThat(fetched.getPluginSets()).isSameAs(pluginSets);
} }
private AdvancedHttpResponse request() throws IOException { private AdvancedHttpResponse request() throws IOException {
@@ -91,8 +93,9 @@ class PluginCenterLoaderTest {
when(client.get(PLUGIN_URL)).thenReturn(request); when(client.get(PLUGIN_URL)).thenReturn(request);
when(request.request()).thenThrow(new IOException("failed to fetch")); when(request.request()).thenThrow(new IOException("failed to fetch"));
Set<AvailablePlugin> fetch = loader.load(PLUGIN_URL); PluginCenterResult fetch = loader.load(PLUGIN_URL);
assertThat(fetch).isEmpty(); assertThat(fetch.getPlugins()).isEmpty();
assertThat(fetch.getPluginSets()).isEmpty();
} }
@Test @Test
@@ -119,8 +122,9 @@ class PluginCenterLoaderTest {
private Set<AvailablePlugin> mockResponse() throws IOException { private Set<AvailablePlugin> mockResponse() throws IOException {
PluginCenterDto dto = new PluginCenterDto(); PluginCenterDto dto = new PluginCenterDto();
Set<AvailablePlugin> plugins = Collections.emptySet(); Set<AvailablePlugin> plugins = Collections.emptySet();
Set<PluginSet> pluginSets = Collections.emptySet();
when(request().contentFromJson(PluginCenterDto.class)).thenReturn(dto); when(request().contentFromJson(PluginCenterDto.class)).thenReturn(dto);
when(mapper.map(dto)).thenReturn(plugins); when(mapper.map(dto)).thenReturn(new PluginCenterResult(plugins, pluginSets));
return plugins; return plugins;
} }

View File

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