From 1b18191c577ef8a98e94251e935f32b9cfafad0e Mon Sep 17 00:00:00 2001 From: Konstantin Schaper Date: Tue, 31 May 2022 15:14:52 +0200 Subject: [PATCH] Add plugin wizard initialization step (#2045) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Co-authored-by: Eduard Heimbuch Co-authored-by: Matthias Thieroff --- .../development/permission-concept/index.md | 4 +- .../en/first-startup/assets/plugin-wizard.png | Bin 0 -> 41399 bytes docs/en/first-startup/index.md | 13 +- gradle/changelog/plugin_wizard.yaml | 2 + .../initialization/InitializationStep.java | 4 + .../InitializationStepResource.java | 13 + .../java/sonia/scm/plugin/PluginManager.java | 18 ++ .../main/java/sonia/scm/plugin/PluginSet.java | 55 +++++ .../scm/security/AccessTokenCookieIssuer.java | 3 +- scm-ui/ui-api/src/apiclient.ts | 2 +- scm-ui/ui-api/src/index.ts | 1 + scm-ui/ui-api/src/plugins.ts | 2 +- scm-ui/ui-components/src/layout/Header.tsx | 11 +- scm-ui/ui-types/src/Plugin.ts | 9 + .../public/locales/de/initialization.json | 14 ++ .../public/locales/en/initialization.json | 15 ++ scm-ui/ui-webapp/src/containers/App.tsx | 2 +- scm-ui/ui-webapp/src/containers/Index.tsx | 8 +- .../InitializationPluginWizardStep.tsx | 228 ++++++++++++++++++ .../AdminAccountStartupResource.java | 27 ++- .../v2/resources/AvailablePluginResource.java | 2 +- .../api/v2/resources/IndexDtoGenerator.java | 11 +- .../scm/api/v2/resources/IndexResource.java | 6 +- .../v2/resources/InstalledPluginResource.java | 4 +- .../v2/resources/PendingPluginResource.java | 4 +- .../v2/resources/PluginSetCollectionDto.java | 42 ++++ .../scm/api/v2/resources/PluginSetDto.java | 49 ++++ .../api/v2/resources/PluginSetDtoMapper.java | 65 +++++ .../v2/resources/PluginSetsInstallDto.java | 42 ++++ .../PluginWizardStartupResource.java | 149 ++++++++++++ .../scm/api/v2/resources/ResourceLinks.java | 19 ++ .../InitializationAuthenticationService.java | 89 +++++++ .../InitializationCookieIssuer.java | 48 ++++ .../initialization/InitializationRealm.java | 78 ++++++ .../initialization/InitializationToken.java | 49 ++++ .../InitializationWebTokenGenerator.java | 52 ++++ .../lifecycle/AdminAccountStartupAction.java | 2 +- .../lifecycle/PluginWizardStartupAction.java | 60 +++++ .../lifecycle/PrivilegedStartupAction.java | 2 +- .../lifecycle/modules/ScmServletModule.java | 2 + .../scm/plugin/DefaultPluginManager.java | 54 ++++- .../java/sonia/scm/plugin/PluginCenter.java | 41 ++-- .../sonia/scm/plugin/PluginCenterDto.java | 45 +++- .../scm/plugin/PluginCenterDtoMapper.java | 17 +- .../sonia/scm/plugin/PluginCenterLoader.java | 5 +- .../sonia/scm/plugin/PluginCenterResult.java | 37 +++ .../scm/plugin/PluginSetConfigStore.java | 51 ++++ .../sonia/scm/plugin/PluginSetsConfig.java | 45 ++++ .../DefaultAccessTokenCookieIssuer.java | 40 ++- ...ginSetsConfigInitializationUpdateStep.java | 64 +++++ .../AdminAccountStartupResourceTest.java | 33 +++ .../AvailablePluginResourceTest.java | 2 +- .../v2/resources/IndexDtoGeneratorTest.java | 2 +- .../api/v2/resources/IndexResourceTest.java | 45 ++-- .../v2/resources/PluginSetDtoMapperTest.java | 99 ++++++++ .../PluginWizardStartupResourceTest.java | 186 ++++++++++++++ ...itializationAuthenticationServiceTest.java | 115 +++++++++ .../InitializationWebTokenGeneratorTest.java | 65 +++++ .../PluginWizardStartupActionTest.java | 73 ++++++ .../scm/plugin/DefaultPluginManagerTest.java | 114 +++++++-- .../scm/plugin/PluginCenterDtoMapperTest.java | 29 ++- .../scm/plugin/PluginCenterLoaderTest.java | 18 +- .../sonia/scm/plugin/PluginCenterTest.java | 43 ++-- 63 files changed, 2294 insertions(+), 135 deletions(-) create mode 100644 docs/en/first-startup/assets/plugin-wizard.png create mode 100644 gradle/changelog/plugin_wizard.yaml create mode 100644 scm-core/src/main/java/sonia/scm/plugin/PluginSet.java create mode 100644 scm-ui/ui-webapp/src/containers/InitializationPluginWizardStep.tsx create mode 100644 scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginSetCollectionDto.java create mode 100644 scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginSetDto.java create mode 100644 scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginSetDtoMapper.java create mode 100644 scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginSetsInstallDto.java create mode 100644 scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginWizardStartupResource.java create mode 100644 scm-webapp/src/main/java/sonia/scm/initialization/InitializationAuthenticationService.java create mode 100644 scm-webapp/src/main/java/sonia/scm/initialization/InitializationCookieIssuer.java create mode 100644 scm-webapp/src/main/java/sonia/scm/initialization/InitializationRealm.java create mode 100644 scm-webapp/src/main/java/sonia/scm/initialization/InitializationToken.java create mode 100644 scm-webapp/src/main/java/sonia/scm/initialization/InitializationWebTokenGenerator.java create mode 100644 scm-webapp/src/main/java/sonia/scm/lifecycle/PluginWizardStartupAction.java create mode 100644 scm-webapp/src/main/java/sonia/scm/plugin/PluginCenterResult.java create mode 100644 scm-webapp/src/main/java/sonia/scm/plugin/PluginSetConfigStore.java create mode 100644 scm-webapp/src/main/java/sonia/scm/plugin/PluginSetsConfig.java create mode 100644 scm-webapp/src/main/java/sonia/scm/update/plugin/PluginSetsConfigInitializationUpdateStep.java create mode 100644 scm-webapp/src/test/java/sonia/scm/api/v2/resources/PluginSetDtoMapperTest.java create mode 100644 scm-webapp/src/test/java/sonia/scm/api/v2/resources/PluginWizardStartupResourceTest.java create mode 100644 scm-webapp/src/test/java/sonia/scm/initialization/InitializationAuthenticationServiceTest.java create mode 100644 scm-webapp/src/test/java/sonia/scm/initialization/InitializationWebTokenGeneratorTest.java create mode 100644 scm-webapp/src/test/java/sonia/scm/lifecycle/PluginWizardStartupActionTest.java diff --git a/docs/en/development/permission-concept/index.md b/docs/en/development/permission-concept/index.md index 20f190a48d..12df588aca 100644 --- a/docs/en/development/permission-concept/index.md +++ b/docs/en/development/permission-concept/index.md @@ -184,7 +184,7 @@ The following shows user as an example. "configuration:read", "configuration:write", "plugin:read", - "plugin:manage", + "plugin:write", "group:read", "user:read", "repository:read" @@ -206,7 +206,7 @@ The following shows user as an example. "configuration:read", "configuration:write", "plugin:read", - "plugin:manage", + "plugin:write", "group:read", "user:read", "repository:read" diff --git a/docs/en/first-startup/assets/plugin-wizard.png b/docs/en/first-startup/assets/plugin-wizard.png new file mode 100644 index 0000000000000000000000000000000000000000..cac4feb6fa61e605e2e4f7b033c9f19e4c4538c6 GIT binary patch literal 41399 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 Cp;kly literal 0 HcmV?d00001 diff --git a/docs/en/first-startup/index.md b/docs/en/first-startup/index.md index 549cb4699a..b5efcce48d 100644 --- a/docs/en/first-startup/index.md +++ b/docs/en/first-startup/index.md @@ -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 in a system property `scm.initialPassword`. If this is present, a user `scmadmin` with this password will be created, if it does not already exist. To change the name of this user, you can set this with the property `scm.initialUser` -in addition. +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) diff --git a/gradle/changelog/plugin_wizard.yaml b/gradle/changelog/plugin_wizard.yaml new file mode 100644 index 0000000000..2e9c55fded --- /dev/null +++ b/gradle/changelog/plugin_wizard.yaml @@ -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)) diff --git a/scm-core/src/main/java/sonia/scm/initialization/InitializationStep.java b/scm-core/src/main/java/sonia/scm/initialization/InitializationStep.java index 6a8c285366..13876befc4 100644 --- a/scm-core/src/main/java/sonia/scm/initialization/InitializationStep.java +++ b/scm-core/src/main/java/sonia/scm/initialization/InitializationStep.java @@ -26,7 +26,11 @@ package sonia.scm.initialization; import sonia.scm.plugin.ExtensionPoint; +/** + * @deprecated Limited use for Plugin Development, see as internal + */ @ExtensionPoint +@Deprecated(since = "2.35.0", forRemoval = true) public interface InitializationStep { String name(); diff --git a/scm-core/src/main/java/sonia/scm/initialization/InitializationStepResource.java b/scm-core/src/main/java/sonia/scm/initialization/InitializationStepResource.java index 1aa6e68db8..7d18ce981c 100644 --- a/scm-core/src/main/java/sonia/scm/initialization/InitializationStepResource.java +++ b/scm-core/src/main/java/sonia/scm/initialization/InitializationStepResource.java @@ -28,9 +28,22 @@ import de.otto.edison.hal.Embedded; import de.otto.edison.hal.Links; import sonia.scm.plugin.ExtensionPoint; +import java.util.Locale; + +/** + * @deprecated Limited use for Plugin Development, see as internal + */ @ExtensionPoint +@Deprecated(since = "2.35.0", forRemoval = true) public interface InitializationStepResource { String name(); + /** + * @since 2.35.0 + */ + default void setupIndex(Links.Builder builder, Embedded.Builder embeddedBuilder, Locale locale) { + setupIndex(builder, embeddedBuilder); + } + void setupIndex(Links.Builder builder, Embedded.Builder embeddedBuilder); } diff --git a/scm-core/src/main/java/sonia/scm/plugin/PluginManager.java b/scm-core/src/main/java/sonia/scm/plugin/PluginManager.java index 8521b1d40f..977867ad24 100644 --- a/scm-core/src/main/java/sonia/scm/plugin/PluginManager.java +++ b/scm-core/src/main/java/sonia/scm/plugin/PluginManager.java @@ -26,6 +26,7 @@ package sonia.scm.plugin; import java.util.List; import java.util.Optional; +import java.util.Set; /** * The plugin manager is responsible for plugin related tasks, such as install, uninstall or updating. @@ -64,6 +65,23 @@ public interface PluginManager { */ List getAvailable(); + /** + * Returns all available plugin sets from the plugin center. + * + * @return a list of available plugin sets + * @since 2.35.0 + */ + Set 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 pluginSets, boolean restartAfterInstallation); + /** * Returns all updatable plugins. * diff --git a/scm-core/src/main/java/sonia/scm/plugin/PluginSet.java b/scm-core/src/main/java/sonia/scm/plugin/PluginSet.java new file mode 100644 index 0000000000..e0566a0469 --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/plugin/PluginSet.java @@ -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 plugins; + private Map descriptions; + private Map images; + + @AllArgsConstructor + @NoArgsConstructor + @Getter + @Setter + public static class Description { + private String name; + private List features; + } +} diff --git a/scm-core/src/main/java/sonia/scm/security/AccessTokenCookieIssuer.java b/scm-core/src/main/java/sonia/scm/security/AccessTokenCookieIssuer.java index e2766a3736..b6fc8a042e 100644 --- a/scm-core/src/main/java/sonia/scm/security/AccessTokenCookieIssuer.java +++ b/scm-core/src/main/java/sonia/scm/security/AccessTokenCookieIssuer.java @@ -21,7 +21,7 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ - + package sonia.scm.security; import javax.servlet.http.HttpServletRequest; @@ -43,6 +43,7 @@ public interface AccessTokenCookieIssuer { * @param accessToken access token */ void authenticate(HttpServletRequest request, HttpServletResponse response, AccessToken accessToken); + /** * Invalidates the authentication cookie. * diff --git a/scm-ui/ui-api/src/apiclient.ts b/scm-ui/ui-api/src/apiclient.ts index b4914262a0..4d4a0f0a80 100644 --- a/scm-ui/ui-api/src/apiclient.ts +++ b/scm-ui/ui-api/src/apiclient.ts @@ -83,7 +83,7 @@ export const extractXsrfTokenFromCookie = (cookieString?: string) => { const cookies = cookieString.split(";"); for (const c of cookies) { const parts = c.trim().split("="); - if (parts[0] === "X-Bearer-Token") { + if (parts[0] === "X-Bearer-Token" || parts[0] === "X-SCM-Init-Token") { return extractXsrfTokenFromJwt(parts[1]); } } diff --git a/scm-ui/ui-api/src/index.ts b/scm-ui/ui-api/src/index.ts index 2f4c795887..855037195a 100644 --- a/scm-ui/ui-api/src/index.ts +++ b/scm-ui/ui-api/src/index.ts @@ -64,6 +64,7 @@ export * from "./loginInfo"; export * from "./usePluginCenterAuthInfo"; export * from "./compare"; export * from "./utils"; +export * from "./links"; export { default as ApiProvider } from "./ApiProvider"; export * from "./ApiProvider"; diff --git a/scm-ui/ui-api/src/plugins.ts b/scm-ui/ui-api/src/plugins.ts index 9f9251896d..d6e27e1ec2 100644 --- a/scm-ui/ui-api/src/plugins.ts +++ b/scm-ui/ui-api/src/plugins.ts @@ -34,7 +34,7 @@ type WaitForRestartOptions = { timeout?: number; }; -const waitForRestartAfter = ( +export const waitForRestartAfter = ( promise: Promise, { initialDelay = 1000, timeout = 500 }: WaitForRestartOptions = {} ): Promise => { diff --git a/scm-ui/ui-components/src/layout/Header.tsx b/scm-ui/ui-components/src/layout/Header.tsx index 8648ef4d52..c7020e5998 100644 --- a/scm-ui/ui-components/src/layout/Header.tsx +++ b/scm-ui/ui-components/src/layout/Header.tsx @@ -21,17 +21,14 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ -import React, { FC, ReactNode } from "react"; +import React, { FC } from "react"; import Logo from "./../Logo"; -import { Links } from "@scm-manager/ui-types"; type Props = { - links: Links; - authenticated: boolean; - children: ReactNode; + authenticated?: boolean; }; -const SmallHeader: FC<{ children: ReactNode }> = ({ children }) => { +const SmallHeader: FC = ({ children }) => { return
{children}
; }; @@ -51,7 +48,7 @@ const LargeHeader: FC = () => { ); }; -const Header: FC = ({ authenticated, children, links }) => { +const Header: FC = ({ authenticated, children }) => { if (authenticated) { return {children}; } else { diff --git a/scm-ui/ui-types/src/Plugin.ts b/scm-ui/ui-types/src/Plugin.ts index f4b657c1a2..75fcd57eaf 100644 --- a/scm-ui/ui-types/src/Plugin.ts +++ b/scm-ui/ui-types/src/Plugin.ts @@ -26,6 +26,15 @@ import { HalRepresentation, HalRepresentationWithEmbedded } from "./hal"; type PluginType = "SCM" | "CLOUDOGU"; +export type PluginSet = HalRepresentation & { + id: string; + name: string; + sequence: number; + features: string[]; + plugins: Plugin[]; + images: Record; +}; + export type Plugin = HalRepresentation & { name: string; version: string; diff --git a/scm-ui/ui-webapp/public/locales/de/initialization.json b/scm-ui/ui-webapp/public/locales/de/initialization.json index edc49f61a3..aa30e2670f 100644 --- a/scm-ui/ui-webapp/public/locales/de/initialization.json +++ b/scm-ui/ui-webapp/public/locales/de/initialization.json @@ -11,6 +11,20 @@ "password-confirmation": "Passwort Bestätigung", "submit": "Absenden" }, + "pluginWizardStep": { + "title": "Plugin Sets", + "description": "Der SCM-Manager ist ein minimalistisches Werkzeug, um Git, SVN und mercurial Repositories zu verwalten. Mit Plugins vereinfachen Sie Ihren Workflow und erreichen mehr mit Ihrem SCM-Manager. Wählen Sie ein oder mehrere Plugin-Sets, um ihren Start zu beschleunigen.", + "submit": "Absenden", + "submitAndRestart": "Absenden und Neustarten", + "skip": { + "title": "Ohne weitere Plugins starten", + "subtitle": "AusschlieĂŸlich Core-Plugins werden installiert. Es können weiterhin jederzeit Plugins manuell Ă¼ber das Plugin-Center installiert werden." + }, + "pluginSet": { + "expand": "Enthaltene Plugins anzeigen", + "collapse": "Enthaltene Plugins" + } + }, "error": { "forbidden": "Falscher Token" } diff --git a/scm-ui/ui-webapp/public/locales/en/initialization.json b/scm-ui/ui-webapp/public/locales/en/initialization.json index 3b05afd8e1..042f0fb074 100644 --- a/scm-ui/ui-webapp/public/locales/en/initialization.json +++ b/scm-ui/ui-webapp/public/locales/en/initialization.json @@ -11,6 +11,21 @@ "password-confirmation": "Confirm Password", "submit": "Submit" }, + "pluginWizardStep": { + "title": "Plugin Sets", + "description": "Out of the box SCM-Manager is a streamlined tool to manage Git, SVN and mercurial repositories. Plugins enhance your workflow and help you to get more out of your SCM-Manager. Select one or more of our plugin-sets to jump-start your journey!", + "submit": "Submit", + "submitAndRestart": "Submit and restart", + "skip": { + "title": "Start without additional plugins", + "subtitle": "Only core-plugins will be installed. You may manually install plugins through the Plugin-Center any time." + }, + "pluginSet": { + "expand": "View included Plugins", + "collapse": "Included Plugins" + } + + }, "error": { "forbidden": "Incorrect token" } diff --git a/scm-ui/ui-webapp/src/containers/App.tsx b/scm-ui/ui-webapp/src/containers/App.tsx index def12cb8e1..13ea69d7a3 100644 --- a/scm-ui/ui-webapp/src/containers/App.tsx +++ b/scm-ui/ui-webapp/src/containers/App.tsx @@ -67,7 +67,7 @@ const App: FC = () => { return ( {isAuthenticated ? : null} -
+
{content}
diff --git a/scm-ui/ui-webapp/src/containers/Index.tsx b/scm-ui/ui-webapp/src/containers/Index.tsx index e95533cda8..b11a54ed5d 100644 --- a/scm-ui/ui-webapp/src/containers/Index.tsx +++ b/scm-ui/ui-webapp/src/containers/Index.tsx @@ -32,6 +32,7 @@ import { Link } from "@scm-manager/ui-types"; import i18next from "i18next"; import { binder, extensionPoints } from "@scm-manager/ui-extensions"; import InitializationAdminAccountStep from "./InitializationAdminAccountStep"; +import InitializationPluginWizardStep from "./InitializationPluginWizardStep"; const Index: FC = () => { const { isLoading, error, data } = useIndex(); @@ -39,7 +40,7 @@ const Index: FC = () => { // TODO check componentDidUpdate method for anonymous user stuff - i18next.on("languageChanged", lng => { + i18next.on("languageChanged", (lng) => { document.documentElement.setAttribute("lang", lng); }); @@ -73,3 +74,8 @@ binder.bind>( "initialization.step.adminAccount", InitializationAdminAccountStep ); + +binder.bind>( + "initialization.step.pluginWizard", + InitializationPluginWizardStep +); diff --git a/scm-ui/ui-webapp/src/containers/InitializationPluginWizardStep.tsx b/scm-ui/ui-webapp/src/containers/InitializationPluginWizardStep.tsx new file mode 100644 index 0000000000..5ab82f7048 --- /dev/null +++ b/scm-ui/ui-webapp/src/containers/InitializationPluginWizardStep.tsx @@ -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(install(link)); + return { + installPluginSets: mutate, + isLoading, + error, + isInstalled: isSuccess, + }; +}; + +type PluginSetCardProps = { + pluginSet: PluginSet; +}; +const PluginSetCard: FC = ({ pluginSet, children }) => { + const [t] = useTranslation("initialization"); + const [collapsed, setCollapsed] = useState(true); + + return ( +
+ ); +}; + +type Props = { + data: HalRepresentationWithEmbedded<{ pluginSets: PluginSet[] }>; +}; + +type FormValue = { [id: string]: boolean }; + +const InitializationPluginWizardStep: FC = ({ data: initializationContext }) => { + const { + installPluginSets, + isLoading: isInstalling, + isInstalled, + error: installationError, + } = useInstallPluginSets(requiredLink(initializationContext, "installPluginSets")); + const [skipInstallation, setSkipInstallation] = useState(false); + + const { register, handleSubmit, watch } = useForm(); + const data = initializationContext._embedded?.pluginSets; + const [t] = useTranslation("initialization"); + const values = watch(); + const pluginSetIds = useMemo( + () => + Object.entries(values).reduce((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 = ; + } else { + content = ( +
+
+ {data?.map((pluginSet, idx) => ( + + + + + ))} + + + +
+ + {t(`pluginWizardStep.${hasPluginSets ? "submitAndRestart" : "submit"}`)} + +
+ ); + } + + return ( + +
+
+
+
+

{t("title")}

+

{t("pluginWizardStep.title")}

+

{t("pluginWizardStep.description")}

+ {content} +
+
+
+
+
+ ); +}; + +export default InitializationPluginWizardStep; diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/AdminAccountStartupResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/AdminAccountStartupResource.java index 45ad79bf37..7ae9df2f33 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/AdminAccountStartupResource.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/AdminAccountStartupResource.java @@ -27,14 +27,20 @@ package sonia.scm.api.v2.resources; import de.otto.edison.hal.Embedded; import de.otto.edison.hal.Links; import lombok.Data; +import org.apache.shiro.SecurityUtils; import org.apache.shiro.authz.UnauthenticatedException; +import sonia.scm.initialization.InitializationAuthenticationService; import sonia.scm.initialization.InitializationStepResource; import sonia.scm.lifecycle.AdminAccountStartupAction; import sonia.scm.plugin.Extension; import sonia.scm.security.AllowAnonymousAccess; +import sonia.scm.security.Tokens; import sonia.scm.util.ValidationUtil; import javax.inject.Inject; +import javax.servlet.http.Cookie; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; import javax.validation.Valid; import javax.validation.constraints.Email; import javax.validation.constraints.NotEmpty; @@ -42,9 +48,12 @@ import javax.validation.constraints.Pattern; import javax.ws.rs.Consumes; import javax.ws.rs.POST; import javax.ws.rs.Path; +import javax.ws.rs.core.Context; +import javax.ws.rs.core.Response; import static de.otto.edison.hal.Link.link; import static sonia.scm.ScmConstraintViolationException.Builder.doThrow; +import static sonia.scm.initialization.InitializationWebTokenGenerator.INIT_TOKEN_HEADER; @AllowAnonymousAccess @Extension @@ -52,20 +61,34 @@ public class AdminAccountStartupResource implements InitializationStepResource { private final AdminAccountStartupAction adminAccountStartupAction; private final ResourceLinks resourceLinks; + private final InitializationAuthenticationService authenticationService; @Inject - public AdminAccountStartupResource(AdminAccountStartupAction adminAccountStartupAction, ResourceLinks resourceLinks) { + public AdminAccountStartupResource(AdminAccountStartupAction adminAccountStartupAction, ResourceLinks resourceLinks, InitializationAuthenticationService authenticationService) { this.adminAccountStartupAction = adminAccountStartupAction; this.resourceLinks = resourceLinks; + this.authenticationService = authenticationService; } @POST @Path("") @Consumes("application/json") - public void postAdminInitializationData(@Valid AdminInitializationData data) { + public Response postAdminInitializationData( + @Context HttpServletRequest request, + @Context HttpServletResponse response, + @Valid AdminInitializationData data + ) { verifyInInitialization(); verifyToken(data); createAdminUser(data); + + // Invalidate old access token cookies to prevent conflicts during authentication + authenticationService.invalidateCookies(request, response); + + SecurityUtils.getSubject().login(Tokens.createAuthenticationToken(request, data.userName, data.password)); + // Create cookie which will be used for authentication during the initialization process + authenticationService.authenticate(request, response); + return Response.noContent().build(); } private void verifyInInitialization() { diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/AvailablePluginResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/AvailablePluginResource.java index 66c7d0f90d..ee9ca92229 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/AvailablePluginResource.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/AvailablePluginResource.java @@ -168,7 +168,7 @@ public class AvailablePluginResource { ) @ApiResponse(responseCode = "200", description = "success") @ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials") - @ApiResponse(responseCode = "403", description = "not authorized, the current user does not have the \"plugin:manage\" privilege") + @ApiResponse(responseCode = "403", description = "not authorized, the current user does not have the \"plugin:write\" privilege") @ApiResponse( responseCode = "500", description = "internal server error", diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/IndexDtoGenerator.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/IndexDtoGenerator.java index 05be8cd7b5..c3db533e49 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/IndexDtoGenerator.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/IndexDtoGenerator.java @@ -47,6 +47,7 @@ import sonia.scm.web.EdisonHalAppender; import javax.inject.Inject; import java.util.List; +import java.util.Locale; import java.util.stream.Collectors; import static de.otto.edison.hal.Embedded.embeddedBuilder; @@ -75,6 +76,10 @@ public class IndexDtoGenerator extends HalAppenderMapper { } public IndexDto generate() { + return generate(Locale.getDefault()); + } + + public IndexDto generate(Locale locale) { Links.Builder builder = Links.linkingTo(); Embedded.Builder embeddedBuilder = embeddedBuilder(); @@ -84,7 +89,7 @@ public class IndexDtoGenerator extends HalAppenderMapper { if (initializationFinisher.isFullyInitialized()) { return handleNormalIndex(builder, embeddedBuilder); } else { - return handleInitialization(builder, embeddedBuilder); + return handleInitialization(builder, embeddedBuilder, locale); } } @@ -170,11 +175,11 @@ public class IndexDtoGenerator extends HalAppenderMapper { .collect(Collectors.toList()); } - private IndexDto handleInitialization(Links.Builder builder, Embedded.Builder embeddedBuilder) { + private IndexDto handleInitialization(Links.Builder builder, Embedded.Builder embeddedBuilder, Locale locale) { Links.Builder initializationLinkBuilder = Links.linkingTo(); Embedded.Builder initializationEmbeddedBuilder = embeddedBuilder(); InitializationStep initializationStep = initializationFinisher.missingInitialization(); - initializationFinisher.getResource(initializationStep.name()).setupIndex(initializationLinkBuilder, initializationEmbeddedBuilder); + initializationFinisher.getResource(initializationStep.name()).setupIndex(initializationLinkBuilder, initializationEmbeddedBuilder, locale); embeddedBuilder.with(initializationStep.name(), new InitializationDto(initializationLinkBuilder.build(), initializationEmbeddedBuilder.build())); return new IndexDto(builder.build(), embeddedBuilder.build(), scmContextProvider.getVersion(), scmContextProvider.getInstanceId(), initializationStep.name()); } diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/IndexResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/IndexResource.java index 00e5cd468a..7af2daa8d7 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/IndexResource.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/IndexResource.java @@ -35,9 +35,11 @@ import sonia.scm.security.AllowAnonymousAccess; import sonia.scm.web.VndMediaType; import javax.inject.Inject; +import javax.servlet.http.HttpServletRequest; import javax.ws.rs.GET; import javax.ws.rs.Path; import javax.ws.rs.Produces; +import javax.ws.rs.core.Context; @OpenAPIDefinition( security = { @@ -80,7 +82,7 @@ public class IndexResource { schema = @Schema(implementation = ErrorDto.class) ) ) - public IndexDto getIndex() { - return indexDtoGenerator.generate(); + public IndexDto getIndex(@Context HttpServletRequest request) { + return indexDtoGenerator.generate(request.getLocale()); } } diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/InstalledPluginResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/InstalledPluginResource.java index d3ff6ff347..c1c598d962 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/InstalledPluginResource.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/InstalledPluginResource.java @@ -112,7 +112,7 @@ public class InstalledPluginResource { ) @ApiResponse(responseCode = "200", description = "success") @ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials") - @ApiResponse(responseCode = "403", description = "not authorized, the current user does not have the \"plugin:manage\" privilege") + @ApiResponse(responseCode = "403", description = "not authorized, the current user does not have the \"plugin:write\" privilege") @ApiResponse( responseCode = "500", description = "internal server error", @@ -191,7 +191,7 @@ public class InstalledPluginResource { ) @ApiResponse(responseCode = "200", description = "success") @ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials") - @ApiResponse(responseCode = "403", description = "not authorized, the current user does not have the \"plugin:manage\" privilege") + @ApiResponse(responseCode = "403", description = "not authorized, the current user does not have the \"plugin:write\" privilege") @ApiResponse( responseCode = "500", description = "internal server error", diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PendingPluginResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PendingPluginResource.java index 0d59e4ef28..e5b2551b0c 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PendingPluginResource.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PendingPluginResource.java @@ -160,7 +160,7 @@ public class PendingPluginResource { ) @ApiResponse(responseCode = "200", description = "success") @ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials") - @ApiResponse(responseCode = "403", description = "not authorized, the current user does not have the \"plugin:manage\" privilege") + @ApiResponse(responseCode = "403", description = "not authorized, the current user does not have the \"plugin:write\" privilege") @ApiResponse( responseCode = "500", description = "internal server error", @@ -183,7 +183,7 @@ public class PendingPluginResource { ) @ApiResponse(responseCode = "200", description = "success") @ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials") - @ApiResponse(responseCode = "403", description = "not authorized, the current user does not have the \"plugin:manage\" privilege") + @ApiResponse(responseCode = "403", description = "not authorized, the current user does not have the \"plugin:write\" privilege") @ApiResponse( responseCode = "500", description = "internal server error", diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginSetCollectionDto.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginSetCollectionDto.java new file mode 100644 index 0000000000..7ffc4b16dc --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginSetCollectionDto.java @@ -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 pluginSets; +} diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginSetDto.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginSetDto.java new file mode 100644 index 0000000000..f590cbc723 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginSetDto.java @@ -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 plugins; + + private String name; + private List features; + private Map images; +} diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginSetDtoMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginSetDtoMapper.java new file mode 100644 index 0000000000..38327bb012 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginSetDtoMapper.java @@ -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 map(Collection pluginSets, List 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 availablePlugins, Locale locale) { + List 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()); + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginSetsInstallDto.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginSetsInstallDto.java new file mode 100644 index 0000000000..833b9881ab --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginSetsInstallDto.java @@ -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 pluginSetIds; +} diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginWizardStartupResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginWizardStartupResource.java new file mode 100644 index 0000000000..25eee7de36 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/PluginWizardStartupResource.java @@ -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 pluginSets = pluginManager.getPluginSets(); + List availablePlugins = pluginManager.getAvailable(); + List 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()); + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ResourceLinks.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ResourceLinks.java index 8fbcfe5132..f4de560a12 100644 --- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ResourceLinks.java +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ResourceLinks.java @@ -1207,6 +1207,25 @@ class ResourceLinks { } } + public PluginWizardLinks pluginWizard() { + return new PluginWizardLinks(new LinkBuilder(accessScmPathInfoStore().get(), InitializationResource.class, PluginWizardStartupResource.class)); + } + + public static class PluginWizardLinks { + private final LinkBuilder initializationLinkBuilder; + + private PluginWizardLinks(LinkBuilder initializationLinkBuilder) { + this.initializationLinkBuilder = initializationLinkBuilder; + } + + public String indexLink(String stepName) { + return initializationLinkBuilder + .method("step").parameters(stepName) + .method("installPluginSets").parameters() + .href(); + } + } + public PluginCenterAuthLinks pluginCenterAuth() { return new PluginCenterAuthLinks(scmPathInfoStore.get().get()); } diff --git a/scm-webapp/src/main/java/sonia/scm/initialization/InitializationAuthenticationService.java b/scm-webapp/src/main/java/sonia/scm/initialization/InitializationAuthenticationService.java new file mode 100644 index 0000000000..ae656655eb --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/initialization/InitializationAuthenticationService.java @@ -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); + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/initialization/InitializationCookieIssuer.java b/scm-webapp/src/main/java/sonia/scm/initialization/InitializationCookieIssuer.java new file mode 100644 index 0000000000..a9e7061be4 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/initialization/InitializationCookieIssuer.java @@ -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); +} diff --git a/scm-webapp/src/main/java/sonia/scm/initialization/InitializationRealm.java b/scm-webapp/src/main/java/sonia/scm/initialization/InitializationRealm.java new file mode 100644 index 0000000000..fe71442c77 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/initialization/InitializationRealm.java @@ -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; + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/initialization/InitializationToken.java b/scm-webapp/src/main/java/sonia/scm/initialization/InitializationToken.java new file mode 100644 index 0000000000..49d9d6c13a --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/initialization/InitializationToken.java @@ -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; + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/initialization/InitializationWebTokenGenerator.java b/scm-webapp/src/main/java/sonia/scm/initialization/InitializationWebTokenGenerator.java new file mode 100644 index 0000000000..0f6db0ccfd --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/initialization/InitializationWebTokenGenerator.java @@ -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; + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/lifecycle/AdminAccountStartupAction.java b/scm-webapp/src/main/java/sonia/scm/lifecycle/AdminAccountStartupAction.java index 8446c352b1..2fc93a04e9 100644 --- a/scm-webapp/src/main/java/sonia/scm/lifecycle/AdminAccountStartupAction.java +++ b/scm-webapp/src/main/java/sonia/scm/lifecycle/AdminAccountStartupAction.java @@ -48,7 +48,7 @@ public class AdminAccountStartupAction implements InitializationStep { private static final Logger LOG = LoggerFactory.getLogger(AdminAccountStartupAction.class); - private static final String INITIAL_PASSWORD_PROPERTY = "scm.initialPassword"; + public static final String INITIAL_PASSWORD_PROPERTY = "scm.initialPassword"; private static final String INITIAL_USER_PROPERTY = "scm.initialUser"; private final PasswordService passwordService; diff --git a/scm-webapp/src/main/java/sonia/scm/lifecycle/PluginWizardStartupAction.java b/scm-webapp/src/main/java/sonia/scm/lifecycle/PluginWizardStartupAction.java new file mode 100644 index 0000000000..6ef9a6334d --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/lifecycle/PluginWizardStartupAction.java @@ -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(); + } + +} diff --git a/scm-webapp/src/main/java/sonia/scm/lifecycle/PrivilegedStartupAction.java b/scm-webapp/src/main/java/sonia/scm/lifecycle/PrivilegedStartupAction.java index f2344393d0..23ac8d7796 100644 --- a/scm-webapp/src/main/java/sonia/scm/lifecycle/PrivilegedStartupAction.java +++ b/scm-webapp/src/main/java/sonia/scm/lifecycle/PrivilegedStartupAction.java @@ -28,4 +28,4 @@ import sonia.scm.plugin.ExtensionPoint; import sonia.scm.web.security.PrivilegedAction; @ExtensionPoint -interface PrivilegedStartupAction extends PrivilegedAction {} +public interface PrivilegedStartupAction extends PrivilegedAction {} diff --git a/scm-webapp/src/main/java/sonia/scm/lifecycle/modules/ScmServletModule.java b/scm-webapp/src/main/java/sonia/scm/lifecycle/modules/ScmServletModule.java index fa52c95594..59a8d07f47 100644 --- a/scm-webapp/src/main/java/sonia/scm/lifecycle/modules/ScmServletModule.java +++ b/scm-webapp/src/main/java/sonia/scm/lifecycle/modules/ScmServletModule.java @@ -58,6 +58,7 @@ import sonia.scm.group.GroupManager; import sonia.scm.group.GroupManagerProvider; import sonia.scm.group.xml.XmlGroupDAO; import sonia.scm.initialization.DefaultInitializationFinisher; +import sonia.scm.initialization.InitializationCookieIssuer; import sonia.scm.initialization.InitializationFinisher; import sonia.scm.io.ContentTypeResolver; import sonia.scm.io.DefaultContentTypeResolver; @@ -271,6 +272,7 @@ class ScmServletModule extends ServletModule { // bind events bind(AccessTokenCookieIssuer.class).to(DefaultAccessTokenCookieIssuer.class); + bind(InitializationCookieIssuer.class).to(DefaultAccessTokenCookieIssuer.class); bind(PushStateDispatcher.class).toProvider(PushStateDispatcherProvider.class); // bind api link provider diff --git a/scm-webapp/src/main/java/sonia/scm/plugin/DefaultPluginManager.java b/scm-webapp/src/main/java/sonia/scm/plugin/DefaultPluginManager.java index 49cc367365..1af6b177cc 100644 --- a/scm-webapp/src/main/java/sonia/scm/plugin/DefaultPluginManager.java +++ b/scm-webapp/src/main/java/sonia/scm/plugin/DefaultPluginManager.java @@ -39,6 +39,7 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; import java.util.Collection; +import java.util.HashSet; import java.util.List; import java.util.Optional; import java.util.Set; @@ -65,6 +66,8 @@ public class DefaultPluginManager implements PluginManager { private final Restarter restarter; private final ScmEventBus eventBus; + private final PluginSetConfigStore pluginSetConfigStore; + private final Collection pendingInstallQueue = new ArrayList<>(); private final Collection pendingUninstallQueue = new ArrayList<>(); private final PluginDependencyTracker dependencyTracker = new PluginDependencyTracker(); @@ -72,16 +75,17 @@ public class DefaultPluginManager implements PluginManager { private final Function, PluginInstallationContext> contextFactory; @Inject - public DefaultPluginManager(PluginLoader loader, PluginCenter center, PluginInstaller installer, Restarter restarter, ScmEventBus eventBus) { - this(loader, center, installer, restarter, eventBus, null); + public DefaultPluginManager(PluginLoader loader, PluginCenter center, PluginInstaller installer, Restarter restarter, ScmEventBus eventBus, PluginSetConfigStore pluginSetConfigStore) { + this(loader, center, installer, restarter, eventBus, null, pluginSetConfigStore); } - DefaultPluginManager(PluginLoader loader, PluginCenter center, PluginInstaller installer, Restarter restarter, ScmEventBus eventBus, Function, PluginInstallationContext> contextFactory) { + DefaultPluginManager(PluginLoader loader, PluginCenter center, PluginInstaller installer, Restarter restarter, ScmEventBus eventBus, Function, PluginInstallationContext> contextFactory, PluginSetConfigStore pluginSetConfigStore) { this.loader = loader; this.center = center; this.installer = installer; this.restarter = restarter; this.eventBus = eventBus; + this.pluginSetConfigStore = pluginSetConfigStore; if (contextFactory != null) { this.contextFactory = contextFactory; @@ -109,7 +113,7 @@ public class DefaultPluginManager implements PluginManager { @Override public Optional getAvailable(String name) { PluginPermissions.read().check(); - return center.getAvailable() + return center.getAvailablePlugins() .stream() .filter(filterByName(name)) .filter(this::isNotInstalledOrMoreUpToDate) @@ -143,13 +147,49 @@ public class DefaultPluginManager implements PluginManager { @Override public List getAvailable() { PluginPermissions.read().check(); - return center.getAvailable() + return center.getAvailablePlugins() .stream() .filter(this::isNotInstalledOrMoreUpToDate) .map(p -> getPending(p.getDescriptor().getInformation().getName()).orElse(p)) .collect(Collectors.toList()); } + @Override + public Set getPluginSets() { + PluginPermissions.read().check(); + return center.getAvailablePluginSets(); + } + + @Override + public void installPluginSets(Set pluginSetIds, boolean restartAfterInstallation) { + PluginPermissions.write().check(); + + Set pluginSets = getPluginSets(); + Set pluginSetsToInstall = pluginSetIds.stream() + .map(id -> pluginSets.stream().filter(pluginSet -> pluginSet.getId().equals(id)).findFirst()) + .filter(Optional::isPresent) + .map(Optional::get) + .collect(Collectors.toSet()); + + Set pluginsToInstall = pluginSetsToInstall + .stream() + .flatMap(pluginSet -> pluginSet + .getPlugins() + .stream() + .map(this::collectPluginsToInstall) + .flatMap(Collection::stream) + ) + .collect(Collectors.toSet()); + + Set newlyInstalledPluginSetIds = pluginSetsToInstall.stream().map(PluginSet::getId).collect(Collectors.toSet()); + + Set installedPluginSetIds = pluginSetConfigStore.getPluginSets().map(PluginSetsConfig::getPluginSets).orElse(new HashSet<>()); + installedPluginSetIds.addAll(newlyInstalledPluginSetIds); + pluginSetConfigStore.setPluginSets(new PluginSetsConfig(installedPluginSetIds)); + + installPlugins(new ArrayList<>(pluginsToInstall), restartAfterInstallation); + } + @Override public List getUpdatable() { return getInstalled() @@ -184,6 +224,10 @@ public class DefaultPluginManager implements PluginManager { ); List plugins = collectPluginsToInstall(name); + installPlugins(plugins, restartAfterInstallation); + } + + private void installPlugins(List plugins, boolean restartAfterInstallation) { List pendingInstallations = new ArrayList<>(); for (AvailablePlugin plugin : plugins) { diff --git a/scm-webapp/src/main/java/sonia/scm/plugin/PluginCenter.java b/scm-webapp/src/main/java/sonia/scm/plugin/PluginCenter.java index dd1fcd86ee..fa5eb3d7d1 100644 --- a/scm-webapp/src/main/java/sonia/scm/plugin/PluginCenter.java +++ b/scm-webapp/src/main/java/sonia/scm/plugin/PluginCenter.java @@ -42,52 +42,61 @@ import java.util.Set; @Singleton public class PluginCenter { - private static final String CACHE_NAME = "sonia.cache.plugins"; + private static final String PLUGIN_CENTER_RESULT_CACHE_NAME = "sonia.cache.plugin-center"; private static final Logger LOG = LoggerFactory.getLogger(PluginCenter.class); private final SCMContextProvider context; private final ScmConfiguration configuration; private final PluginCenterLoader loader; - private final Cache> cache; + private final Cache pluginCenterResultCache; @Inject public PluginCenter(SCMContextProvider context, CacheManager cacheManager, ScmConfiguration configuration, PluginCenterLoader loader) { this.context = context; this.configuration = configuration; this.loader = loader; - this.cache = cacheManager.getCache(CACHE_NAME); + this.pluginCenterResultCache = cacheManager.getCache(PLUGIN_CENTER_RESULT_CACHE_NAME); } @Subscribe public void handle(PluginCenterAuthenticationEvent event) { LOG.debug("clear plugin center cache, because of {}", event); - cache.clear(); + pluginCenterResultCache.clear(); } - synchronized Set getAvailable() { + synchronized Set getAvailablePlugins() { String url = buildPluginUrl(configuration.getPluginUrl()); - Set plugins = cache.get(url); - if (plugins == null) { - LOG.debug("no cached available plugins found, start fetching"); - plugins = fetchAvailablePlugins(url); + return getPluginCenterResult(url).getPlugins(); + } + + synchronized Set getAvailablePluginSets() { + String url = buildPluginUrl(configuration.getPluginUrl()); + return getPluginCenterResult(url).getPluginSets(); + } + + private PluginCenterResult getPluginCenterResult(String url) { + PluginCenterResult pluginCenterResult = pluginCenterResultCache.get(url); + if (pluginCenterResult == null) { + LOG.debug("no cached plugin center result found, start fetching"); + pluginCenterResult = fetchPluginCenter(url); } else { - LOG.debug("return available plugins from cache"); + LOG.debug("return plugin center result from cache"); } - return plugins; + return pluginCenterResult; } @CanIgnoreReturnValue - private Set fetchAvailablePlugins(String url) { - Set plugins = loader.load(url); - cache.put(url, plugins); - return plugins; + private PluginCenterResult fetchPluginCenter(String url) { + PluginCenterResult pluginCenterResult = loader.load(url); + pluginCenterResultCache.put(url, pluginCenterResult); + return pluginCenterResult; } synchronized void refresh() { LOG.debug("refresh plugin center cache"); String url = buildPluginUrl(configuration.getPluginUrl()); - fetchAvailablePlugins(url); + fetchPluginCenter(url); } private String buildPluginUrl(String url) { diff --git a/scm-webapp/src/main/java/sonia/scm/plugin/PluginCenterDto.java b/scm-webapp/src/main/java/sonia/scm/plugin/PluginCenterDto.java index a14e629628..5bad3327e5 100644 --- a/scm-webapp/src/main/java/sonia/scm/plugin/PluginCenterDto.java +++ b/scm-webapp/src/main/java/sonia/scm/plugin/PluginCenterDto.java @@ -21,10 +21,9 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ - + package sonia.scm.plugin; -import com.google.common.collect.ImmutableList; import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; @@ -56,12 +55,22 @@ public final class PluginCenterDto implements Serializable { @XmlElement(name = "plugins") private List plugins; + @XmlElement(name = "plugin-sets") + private List pluginSets; + public List getPlugins() { if (plugins == null) { - plugins = ImmutableList.of(); + plugins = List.of(); } return plugins; } + + public List getPluginSets() { + if (pluginSets == null) { + pluginSets = List.of(); + } + return pluginSets; + } } @XmlAccessorType(XmlAccessType.FIELD) @@ -93,6 +102,36 @@ public final class PluginCenterDto implements Serializable { private final Map 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 plugins; + + @XmlElement(name = "descriptions") + private final Map descriptions; + + @XmlElement(name = "images") + private final Map images; + } + + @XmlAccessorType(XmlAccessType.FIELD) + @Getter + @NoArgsConstructor + @AllArgsConstructor + public static class Description { + private String name; + + @XmlElement(name = "features") + private List features; + } + @XmlAccessorType(XmlAccessType.FIELD) @XmlRootElement(name = "conditions") @Getter diff --git a/scm-webapp/src/main/java/sonia/scm/plugin/PluginCenterDtoMapper.java b/scm-webapp/src/main/java/sonia/scm/plugin/PluginCenterDtoMapper.java index 32092ba4b9..c3337576aa 100644 --- a/scm-webapp/src/main/java/sonia/scm/plugin/PluginCenterDtoMapper.java +++ b/scm-webapp/src/main/java/sonia/scm/plugin/PluginCenterDtoMapper.java @@ -29,18 +29,31 @@ import org.mapstruct.factory.Mappers; import java.util.HashSet; import java.util.Set; +import java.util.stream.Collectors; @Mapper public abstract class PluginCenterDtoMapper { + PluginCenterDtoMapper() {} + static final PluginCenterDtoMapper INSTANCE = Mappers.getMapper(PluginCenterDtoMapper.class); abstract PluginInformation map(PluginCenterDto.Plugin plugin); abstract PluginCondition map(PluginCenterDto.Condition condition); - Set map(PluginCenterDto pluginCenterDto) { + abstract PluginSet map(PluginCenterDto.PluginSet set); + abstract PluginSet.Description map(PluginCenterDto.Description description); + + PluginCenterResult map(PluginCenterDto pluginCenterDto) { Set plugins = new HashSet<>(); + Set pluginSets = pluginCenterDto + .getEmbedded() + .getPluginSets() + .stream() + .map(this::map) + .collect(Collectors.toSet()); + for (PluginCenterDto.Plugin plugin : pluginCenterDto.getEmbedded().getPlugins()) { // plugin center api returns always a download link, // but for cloudogu plugin without authentication the href is an empty string @@ -51,7 +64,7 @@ public abstract class PluginCenterDtoMapper { ); plugins.add(new AvailablePlugin(descriptor)); } - return plugins; + return new PluginCenterResult(plugins, pluginSets); } private String getInstallLink(PluginCenterDto.Plugin plugin) { diff --git a/scm-webapp/src/main/java/sonia/scm/plugin/PluginCenterLoader.java b/scm-webapp/src/main/java/sonia/scm/plugin/PluginCenterLoader.java index 3b92a962bc..e519657568 100644 --- a/scm-webapp/src/main/java/sonia/scm/plugin/PluginCenterLoader.java +++ b/scm-webapp/src/main/java/sonia/scm/plugin/PluginCenterLoader.java @@ -33,7 +33,6 @@ import sonia.scm.net.ahc.AdvancedHttpRequest; import javax.inject.Inject; import java.util.Collections; -import java.util.Set; import static sonia.scm.plugin.Tracing.SPAN_KIND; @@ -64,7 +63,7 @@ class PluginCenterLoader { this.eventBus = eventBus; } - Set load(String url) { + PluginCenterResult load(String url) { try { LOG.info("fetch plugins from {}", url); AdvancedHttpRequest request = client.get(url).spanKind(SPAN_KIND); @@ -76,7 +75,7 @@ class PluginCenterLoader { } catch (Exception ex) { LOG.error("failed to load plugins from plugin center, returning empty list", ex); eventBus.post(new PluginCenterErrorEvent()); - return Collections.emptySet(); + return new PluginCenterResult(Collections.emptySet(), Collections.emptySet()); } } } diff --git a/scm-webapp/src/main/java/sonia/scm/plugin/PluginCenterResult.java b/scm-webapp/src/main/java/sonia/scm/plugin/PluginCenterResult.java new file mode 100644 index 0000000000..e23e44e814 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/plugin/PluginCenterResult.java @@ -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 plugins; + private Set pluginSets; +} diff --git a/scm-webapp/src/main/java/sonia/scm/plugin/PluginSetConfigStore.java b/scm-webapp/src/main/java/sonia/scm/plugin/PluginSetConfigStore.java new file mode 100644 index 0000000000..106894d92c --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/plugin/PluginSetConfigStore.java @@ -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 pluginSets; + + @Inject + PluginSetConfigStore(ConfigurationStoreFactory configurationStoreFactory) { + pluginSets = configurationStoreFactory.withType(PluginSetsConfig.class).withName("pluginSets").build(); + } + + public Optional getPluginSets() { + return pluginSets.getOptional(); + } + + public void setPluginSets(PluginSetsConfig config) { + this.pluginSets.set(config); + } +} diff --git a/scm-webapp/src/main/java/sonia/scm/plugin/PluginSetsConfig.java b/scm-webapp/src/main/java/sonia/scm/plugin/PluginSetsConfig.java new file mode 100644 index 0000000000..11c3ec5735 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/plugin/PluginSetsConfig.java @@ -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 pluginSets; +} diff --git a/scm-webapp/src/main/java/sonia/scm/security/DefaultAccessTokenCookieIssuer.java b/scm-webapp/src/main/java/sonia/scm/security/DefaultAccessTokenCookieIssuer.java index 7fa6e4c427..cf033a460f 100644 --- a/scm-webapp/src/main/java/sonia/scm/security/DefaultAccessTokenCookieIssuer.java +++ b/scm-webapp/src/main/java/sonia/scm/security/DefaultAccessTokenCookieIssuer.java @@ -29,6 +29,7 @@ import com.google.common.base.Strings; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import sonia.scm.config.ScmConfiguration; +import sonia.scm.initialization.InitializationCookieIssuer; import sonia.scm.util.HttpUtil; import sonia.scm.util.Util; @@ -36,16 +37,18 @@ import javax.inject.Inject; import javax.servlet.http.Cookie; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; -import java.util.Date; +import java.util.Arrays; import java.util.concurrent.TimeUnit; +import static sonia.scm.initialization.InitializationWebTokenGenerator.INIT_TOKEN_HEADER; + /** * Generates cookies and invalidates access token cookies. * * @author Sebastian Sdorra * @since 2.0.0 */ -public final class DefaultAccessTokenCookieIssuer implements AccessTokenCookieIssuer { +public final class DefaultAccessTokenCookieIssuer implements AccessTokenCookieIssuer, InitializationCookieIssuer { /** * the logger for DefaultAccessTokenCookieIssuer @@ -87,6 +90,25 @@ public final class DefaultAccessTokenCookieIssuer implements AccessTokenCookieIs response.addCookie(c); } + /** + * Creates a cookie for authentication during the initialization process and attaches it to the response. + * + * @param request http servlet request + * @param response http servlet response + * @param accessToken initialization access token + */ + public void authenticateForInitialization(HttpServletRequest request, HttpServletResponse response, AccessToken accessToken) { + LOG.trace("create and attach cookie for initialization access token {}", accessToken.getId()); + Cookie c = new Cookie(INIT_TOKEN_HEADER, accessToken.compact()); + c.setPath(contextPath(request)); + c.setMaxAge(999999999); + c.setHttpOnly(isHttpOnly()); + c.setSecure(isSecure(request)); + + // attach cookie to response + response.addCookie(c); + } + /** * Invalidates the authentication cookie. * @@ -95,8 +117,20 @@ public final class DefaultAccessTokenCookieIssuer implements AccessTokenCookieIs */ public void invalidate(HttpServletRequest request, HttpServletResponse response) { LOG.trace("invalidates access token cookie"); + invalidateCookie(request, response, HttpUtil.COOKIE_BEARER_AUTHENTICATION); - Cookie c = new Cookie(HttpUtil.COOKIE_BEARER_AUTHENTICATION, Util.EMPTY_STRING); + if (request.getCookies() != null && Arrays.stream(request.getCookies()).anyMatch(cookie -> cookie.getName().equals(INIT_TOKEN_HEADER))) { + LOG.trace("invalidates initialization token cookie"); + invalidateInitTokenCookie(request, response); + } + } + + private void invalidateInitTokenCookie(HttpServletRequest request, HttpServletResponse response) { + invalidateCookie(request, response, INIT_TOKEN_HEADER); + } + + private void invalidateCookie(HttpServletRequest request, HttpServletResponse response, String cookieBearerAuthentication) { + Cookie c = new Cookie(cookieBearerAuthentication, Util.EMPTY_STRING); c.setPath(contextPath(request)); c.setMaxAge(0); c.setHttpOnly(isHttpOnly()); diff --git a/scm-webapp/src/main/java/sonia/scm/update/plugin/PluginSetsConfigInitializationUpdateStep.java b/scm-webapp/src/main/java/sonia/scm/update/plugin/PluginSetsConfigInitializationUpdateStep.java new file mode 100644 index 0000000000..fcaf7b2ea1 --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/update/plugin/PluginSetsConfigInitializationUpdateStep.java @@ -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"; + } +} diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/AdminAccountStartupResourceTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/AdminAccountStartupResourceTest.java index 583f20f0ee..24d306c08a 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/AdminAccountStartupResourceTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/AdminAccountStartupResourceTest.java @@ -24,6 +24,9 @@ package sonia.scm.api.v2.resources; +import com.github.sdorra.shiro.SubjectAware; +import org.apache.shiro.subject.Subject; +import org.apache.shiro.util.ThreadContext; import org.jboss.resteasy.mock.MockHttpRequest; import org.jboss.resteasy.mock.MockHttpResponse; import org.junit.jupiter.api.BeforeEach; @@ -33,10 +36,19 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import sonia.scm.config.ScmConfiguration; +import sonia.scm.initialization.InitializationAuthenticationService; import sonia.scm.lifecycle.AdminAccountStartupAction; +import sonia.scm.security.AccessToken; +import sonia.scm.security.AccessTokenBuilder; +import sonia.scm.security.AccessTokenBuilderFactory; +import sonia.scm.security.AccessTokenCookieIssuer; +import sonia.scm.security.DefaultAccessTokenCookieIssuer; import sonia.scm.web.RestDispatcher; import javax.inject.Provider; +import javax.servlet.http.HttpServletRequest; +import java.net.URI; import java.net.URISyntaxException; import static java.lang.String.format; @@ -46,9 +58,13 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.jboss.resteasy.mock.MockHttpRequest.post; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +@SubjectAware( + configuration = "classpath:sonia/scm/repository/shiro.ini" +) @ExtendWith(MockitoExtension.class) class AdminAccountStartupResourceTest { @@ -60,9 +76,16 @@ class AdminAccountStartupResourceTest { @Mock private Provider pathInfoStoreProvider; @Mock + private InitializationAuthenticationService authenticationService; + @Mock private ScmPathInfoStore pathInfoStore; @Mock private ScmPathInfo pathInfo; + @Mock + private AccessTokenBuilderFactory accessTokenBuilderFactory; + @Mock + private AccessTokenBuilder accessTokenBuilder; + private final AccessTokenCookieIssuer cookieIssuer = new DefaultAccessTokenCookieIssuer(mock(ScmConfiguration.class)); @InjectMocks private AdminAccountStartupResource resource; @@ -73,6 +96,9 @@ class AdminAccountStartupResourceTest { lenient().when(pathInfoStore.get()).thenReturn(pathInfo); dispatcher.addSingletonResource(new InitializationResource(singleton(resource))); lenient().when(startupAction.name()).thenReturn("adminAccount"); + lenient().when(accessTokenBuilderFactory.create()).thenReturn(accessTokenBuilder); + AccessToken accessToken = mock(AccessToken.class); + lenient().when(accessTokenBuilder.build()).thenReturn(accessToken); } @Test @@ -121,10 +147,17 @@ class AdminAccountStartupResourceTest { @Test void shouldCreateAdminUser() throws URISyntaxException { + Subject subject = mock(Subject.class); + ThreadContext.bind(subject); + MockHttpRequest request = post("/v2/initialization/adminAccount") .contentType("application/json") .content(createInput("initial-token", "trillian", "Tricia", "tricia@hitchhiker.com", "password", "password")); + + HttpServletRequest servletRequest = mock(HttpServletRequest.class); + dispatcher.putDefaultContextObject(HttpServletRequest.class, servletRequest); + dispatcher.invoke(request, response); assertThat(response.getStatus()).isEqualTo(204); diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/AvailablePluginResourceTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/AvailablePluginResourceTest.java index dd5626910d..8718dd10c4 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/AvailablePluginResourceTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/AvailablePluginResourceTest.java @@ -21,7 +21,7 @@ * 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; diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/IndexDtoGeneratorTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/IndexDtoGeneratorTest.java index 926d429d2d..51bb031d57 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/IndexDtoGeneratorTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/IndexDtoGeneratorTest.java @@ -183,7 +183,7 @@ class IndexDtoGeneratorTest { Embedded.Builder initializationEmbeddedBuilder = invocationOnMock.getArgument(1, Embedded.Builder.class); initializationLinkBuilder.single(link("init", "/init")); return null; - }).when(initializationStepResource).setupIndex(any(), any()); + }).when(initializationStepResource).setupIndex(any(), any(), any()); IndexDto dto = generator.generate(); diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/IndexResourceTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/IndexResourceTest.java index 0ef6046564..28cdd986af 100644 --- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/IndexResourceTest.java +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/IndexResourceTest.java @@ -30,19 +30,24 @@ import org.assertj.core.api.Assertions; import org.junit.Before; import org.junit.Rule; import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; import sonia.scm.SCMContextProvider; import sonia.scm.config.ScmConfiguration; import sonia.scm.initialization.InitializationFinisher; -import sonia.scm.plugin.PluginCenterAuthenticator; import sonia.scm.search.SearchEngine; +import javax.servlet.http.HttpServletRequest; import java.net.URI; +import java.util.Locale; import java.util.Optional; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @SubjectAware(configuration = "classpath:sonia/scm/shiro-002.ini") +@RunWith(MockitoJUnitRunner.class) public class IndexResourceTest { @Rule @@ -52,8 +57,12 @@ public class IndexResourceTest { private SCMContextProvider scmContextProvider; private IndexResource indexResource; + @Mock + private HttpServletRequest httpServletRequest; + @Before public void setUpObjectUnderTest() { + when(httpServletRequest.getLocale()).thenReturn(Locale.ENGLISH); this.configuration = new ScmConfiguration(); this.scmContextProvider = mock(SCMContextProvider.class); InitializationFinisher initializationFinisher = mock(InitializationFinisher.class); @@ -72,7 +81,7 @@ public class IndexResourceTest { @Test @SubjectAware(username = "dent", password = "secret") public void shouldRenderPluginCenterAuthLink() { - IndexDto index = indexResource.getIndex(); + IndexDto index = indexResource.getIndex(httpServletRequest); Assertions.assertThat(index.getLinks().getLinkBy("pluginCenterAuth")).isPresent(); } @@ -80,21 +89,21 @@ public class IndexResourceTest { @Test @SubjectAware(username = "trillian", password = "secret") public void shouldNotRenderPluginCenterLoginLinkIfPermissionsAreMissing() { - IndexDto index = indexResource.getIndex(); + IndexDto index = indexResource.getIndex(httpServletRequest); Assertions.assertThat(index.getLinks().getLinkBy("pluginCenterAuth")).isNotPresent(); } @Test public void shouldRenderLoginUrlsForUnauthenticatedRequest() { - IndexDto index = indexResource.getIndex(); + IndexDto index = indexResource.getIndex(httpServletRequest); Assertions.assertThat(index.getLinks().getLinkBy("login")).matches(Optional::isPresent); } @Test public void shouldRenderLoginInfoUrl() { - IndexDto index = indexResource.getIndex(); + IndexDto index = indexResource.getIndex(httpServletRequest); Assertions.assertThat(index.getLinks().getLinkBy("loginInfo")).isPresent(); } @@ -103,21 +112,21 @@ public class IndexResourceTest { public void shouldNotRenderLoginInfoUrlWhenNoUrlIsConfigured() { configuration.setLoginInfoUrl(""); - IndexDto index = indexResource.getIndex(); + IndexDto index = indexResource.getIndex(httpServletRequest); Assertions.assertThat(index.getLinks().getLinkBy("loginInfo")).isNotPresent(); } @Test public void shouldRenderSelfLinkForUnauthenticatedRequest() { - IndexDto index = indexResource.getIndex(); + IndexDto index = indexResource.getIndex(httpServletRequest); Assertions.assertThat(index.getLinks().getLinkBy("self")).matches(Optional::isPresent); } @Test public void shouldRenderUiPluginsLinkForUnauthenticatedRequest() { - IndexDto index = indexResource.getIndex(); + IndexDto index = indexResource.getIndex(httpServletRequest); Assertions.assertThat(index.getLinks().getLinkBy("uiPlugins")).matches(Optional::isPresent); } @@ -125,7 +134,7 @@ public class IndexResourceTest { @Test @SubjectAware(username = "trillian", password = "secret") public void shouldRenderSelfLinkForAuthenticatedRequest() { - IndexDto index = indexResource.getIndex(); + IndexDto index = indexResource.getIndex(httpServletRequest); Assertions.assertThat(index.getLinks().getLinkBy("self")).matches(Optional::isPresent); } @@ -133,7 +142,7 @@ public class IndexResourceTest { @Test @SubjectAware(username = "trillian", password = "secret") public void shouldRenderUiPluginsLinkForAuthenticatedRequest() { - IndexDto index = indexResource.getIndex(); + IndexDto index = indexResource.getIndex(httpServletRequest); Assertions.assertThat(index.getLinks().getLinkBy("uiPlugins")).matches(Optional::isPresent); } @@ -141,7 +150,7 @@ public class IndexResourceTest { @Test @SubjectAware(username = "trillian", password = "secret") public void shouldRenderMeUrlForAuthenticatedRequest() { - IndexDto index = indexResource.getIndex(); + IndexDto index = indexResource.getIndex(httpServletRequest); Assertions.assertThat(index.getLinks().getLinkBy("me")).matches(Optional::isPresent); } @@ -149,7 +158,7 @@ public class IndexResourceTest { @Test @SubjectAware(username = "trillian", password = "secret") public void shouldRenderLogoutUrlForAuthenticatedRequest() { - IndexDto index = indexResource.getIndex(); + IndexDto index = indexResource.getIndex(httpServletRequest); Assertions.assertThat(index.getLinks().getLinkBy("logout")).matches(Optional::isPresent); } @@ -157,7 +166,7 @@ public class IndexResourceTest { @Test @SubjectAware(username = "trillian", password = "secret") public void shouldRenderRepositoriesForAuthenticatedRequest() { - IndexDto index = indexResource.getIndex(); + IndexDto index = indexResource.getIndex(httpServletRequest); Assertions.assertThat(index.getLinks().getLinkBy("repositories")).matches(Optional::isPresent); } @@ -165,7 +174,7 @@ public class IndexResourceTest { @Test @SubjectAware(username = "trillian", password = "secret") public void shouldNotRenderAdminLinksIfNotAuthorized() { - IndexDto index = indexResource.getIndex(); + IndexDto index = indexResource.getIndex(httpServletRequest); Assertions.assertThat(index.getLinks().getLinkBy("users")).matches(o -> !o.isPresent()); Assertions.assertThat(index.getLinks().getLinkBy("groups")).matches(o -> !o.isPresent()); @@ -175,7 +184,7 @@ public class IndexResourceTest { @Test @SubjectAware(username = "trillian", password = "secret") public void shouldRenderAutoCompleteLinks() { - IndexDto index = indexResource.getIndex(); + IndexDto index = indexResource.getIndex(httpServletRequest); Assertions.assertThat(index.getLinks().getLinksBy("autocomplete")) .extracting("name") @@ -185,7 +194,7 @@ public class IndexResourceTest { @Test @SubjectAware(username = "user_without_autocomplete_permission", password = "secret") public void userWithoutAutocompletePermissionShouldSeeAutoCompleteLinksOnlyForNamespaces() { - IndexDto index = indexResource.getIndex(); + IndexDto index = indexResource.getIndex(httpServletRequest); Assertions.assertThat(index.getLinks().getLinksBy("autocomplete")) .extracting("name") @@ -195,7 +204,7 @@ public class IndexResourceTest { @Test @SubjectAware(username = "dent", password = "secret") public void shouldRenderAdminLinksIfAuthorized() { - IndexDto index = indexResource.getIndex(); + IndexDto index = indexResource.getIndex(httpServletRequest); Assertions.assertThat(index.getLinks().getLinkBy("users")).matches(Optional::isPresent); Assertions.assertThat(index.getLinks().getLinkBy("groups")).matches(Optional::isPresent); @@ -206,7 +215,7 @@ public class IndexResourceTest { public void shouldGenerateVersion() { when(scmContextProvider.getVersion()).thenReturn("v1"); - IndexDto index = indexResource.getIndex(); + IndexDto index = indexResource.getIndex(httpServletRequest); Assertions.assertThat(index.getVersion()).isEqualTo("v1"); } diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/PluginSetDtoMapperTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/PluginSetDtoMapperTest.java new file mode 100644 index 0000000000..365c257982 --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/PluginSetDtoMapperTest.java @@ -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 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 pluginSets = ImmutableSet.of(pluginSet, pluginSet2); + + List 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); + } +} diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/PluginWizardStartupResourceTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/PluginWizardStartupResourceTest.java new file mode 100644 index 0000000000..b023a13bb2 --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/PluginWizardStartupResourceTest.java @@ -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 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 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); + } + +} diff --git a/scm-webapp/src/test/java/sonia/scm/initialization/InitializationAuthenticationServiceTest.java b/scm-webapp/src/test/java/sonia/scm/initialization/InitializationAuthenticationServiceTest.java new file mode 100644 index 0000000000..a6e847d88a --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/initialization/InitializationAuthenticationServiceTest.java @@ -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); + } +} diff --git a/scm-webapp/src/test/java/sonia/scm/initialization/InitializationWebTokenGeneratorTest.java b/scm-webapp/src/test/java/sonia/scm/initialization/InitializationWebTokenGeneratorTest.java new file mode 100644 index 0000000000..3c166c165b --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/initialization/InitializationWebTokenGeneratorTest.java @@ -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"); + } +} diff --git a/scm-webapp/src/test/java/sonia/scm/lifecycle/PluginWizardStartupActionTest.java b/scm-webapp/src/test/java/sonia/scm/lifecycle/PluginWizardStartupActionTest.java new file mode 100644 index 0000000000..cc06be0cfe --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/lifecycle/PluginWizardStartupActionTest.java @@ -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(); + } + +} diff --git a/scm-webapp/src/test/java/sonia/scm/plugin/DefaultPluginManagerTest.java b/scm-webapp/src/test/java/sonia/scm/plugin/DefaultPluginManagerTest.java index d52bd6e51e..74ec7a0d14 100644 --- a/scm-webapp/src/test/java/sonia/scm/plugin/DefaultPluginManagerTest.java +++ b/scm-webapp/src/test/java/sonia/scm/plugin/DefaultPluginManagerTest.java @@ -25,6 +25,7 @@ package sonia.scm.plugin; import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; import com.google.common.collect.ImmutableSet; import org.apache.shiro.authz.AuthorizationException; import org.apache.shiro.subject.Subject; @@ -38,6 +39,7 @@ import org.junit.jupiter.api.io.TempDir; import org.mockito.ArgumentCaptor; import org.mockito.Captor; import org.mockito.Mock; +import org.mockito.Mockito; import org.mockito.junit.jupiter.MockitoExtension; import sonia.scm.NotFoundException; import sonia.scm.ScmConstraintViolationException; @@ -49,6 +51,7 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.List; import java.util.Optional; +import java.util.Set; import static java.util.Arrays.asList; import static java.util.Collections.singleton; @@ -83,6 +86,9 @@ class DefaultPluginManagerTest { @Mock private Restarter restarter; + @Mock + private PluginSetConfigStore pluginSetConfigStore; + @Mock private ScmEventBus eventBus; @@ -110,7 +116,7 @@ class DefaultPluginManagerTest { @BeforeEach void setUpObjectUnderTest() { manager = new DefaultPluginManager( - loader, center, installer, restarter, eventBus, plugins -> context + loader, center, installer, restarter, eventBus, plugins -> context, pluginSetConfigStore ); } @@ -162,7 +168,7 @@ class DefaultPluginManagerTest { AvailablePlugin review = createAvailable("scm-review-plugin"); AvailablePlugin git = createAvailable("scm-git-plugin"); - when(center.getAvailable()).thenReturn(ImmutableSet.of(review, git)); + when(center.getAvailablePlugins()).thenReturn(ImmutableSet.of(review, git)); List available = manager.getAvailable(); assertThat(available).containsOnly(review, git); @@ -175,7 +181,7 @@ class DefaultPluginManagerTest { AvailablePlugin review = createAvailable("scm-review-plugin"); AvailablePlugin git = createAvailable("scm-git-plugin"); - when(center.getAvailable()).thenReturn(ImmutableSet.of(review, git)); + when(center.getAvailablePlugins()).thenReturn(ImmutableSet.of(review, git)); List available = manager.getAvailable(); assertThat(available).containsOnly(review); @@ -185,7 +191,7 @@ class DefaultPluginManagerTest { void shouldReturnAvailable() { AvailablePlugin review = createAvailable("scm-review-plugin"); AvailablePlugin git = createAvailable("scm-git-plugin"); - when(center.getAvailable()).thenReturn(ImmutableSet.of(review, git)); + when(center.getAvailablePlugins()).thenReturn(ImmutableSet.of(review, git)); Optional available = manager.getAvailable("scm-git-plugin"); assertThat(available).contains(git); @@ -194,7 +200,7 @@ class DefaultPluginManagerTest { @Test void shouldReturnEmptyForNonExistingAvailable() { AvailablePlugin review = createAvailable("scm-review-plugin"); - when(center.getAvailable()).thenReturn(ImmutableSet.of(review)); + when(center.getAvailablePlugins()).thenReturn(ImmutableSet.of(review)); Optional available = manager.getAvailable("scm-git-plugin"); assertThat(available).isEmpty(); @@ -206,7 +212,7 @@ class DefaultPluginManagerTest { when(loader.getInstalledPlugins()).thenReturn(ImmutableList.of(installedGit)); AvailablePlugin git = createAvailable("scm-git-plugin"); - when(center.getAvailable()).thenReturn(ImmutableSet.of(git)); + when(center.getAvailablePlugins()).thenReturn(ImmutableSet.of(git)); Optional available = manager.getAvailable("scm-git-plugin"); assertThat(available).isEmpty(); @@ -215,7 +221,7 @@ class DefaultPluginManagerTest { @Test void shouldInstallThePlugin() { AvailablePlugin git = createAvailable("scm-git-plugin"); - when(center.getAvailable()).thenReturn(ImmutableSet.of(git)); + when(center.getAvailablePlugins()).thenReturn(ImmutableSet.of(git)); manager.install("scm-git-plugin", false); @@ -228,7 +234,7 @@ class DefaultPluginManagerTest { AvailablePlugin review = createAvailable("scm-review-plugin"); when(review.getDescriptor().getDependencies()).thenReturn(ImmutableSet.of("scm-mail-plugin")); AvailablePlugin mail = createAvailable("scm-mail-plugin"); - when(center.getAvailable()).thenReturn(ImmutableSet.of(review, mail)); + when(center.getAvailablePlugins()).thenReturn(ImmutableSet.of(review, mail)); manager.install("scm-review-plugin", false); @@ -241,7 +247,7 @@ class DefaultPluginManagerTest { AvailablePlugin review = createAvailable("scm-review-plugin"); when(review.getDescriptor().getDependencies()).thenReturn(ImmutableSet.of("scm-mail-plugin")); AvailablePlugin mail = createAvailable("scm-mail-plugin"); - when(center.getAvailable()).thenReturn(ImmutableSet.of(review, mail)); + when(center.getAvailablePlugins()).thenReturn(ImmutableSet.of(review, mail)); InstalledPlugin installedMail = createInstalled("scm-mail-plugin"); when(loader.getInstalledPlugins()).thenReturn(ImmutableList.of(installedMail)); @@ -259,7 +265,7 @@ class DefaultPluginManagerTest { AvailablePlugin review = createAvailable("scm-review-plugin"); when(review.getDescriptor().getDependencies()).thenReturn(ImmutableSet.of("scm-mail-plugin")); AvailablePlugin mail = createAvailable("scm-mail-plugin", "1.1.0"); - when(center.getAvailable()).thenReturn(ImmutableSet.of(review, mail)); + when(center.getAvailablePlugins()).thenReturn(ImmutableSet.of(review, mail)); InstalledPlugin installedMail = createInstalled("scm-mail-plugin", "1.0.0"); when(loader.getInstalledPlugins()).thenReturn(ImmutableList.of(installedMail)); @@ -275,7 +281,7 @@ class DefaultPluginManagerTest { AvailablePlugin review = createAvailable("scm-review-plugin"); when(review.getDescriptor().getOptionalDependencies()).thenReturn(ImmutableSet.of("scm-mail-plugin")); AvailablePlugin mail = createAvailable("scm-mail-plugin", "1.1.0"); - when(center.getAvailable()).thenReturn(ImmutableSet.of(review, mail)); + when(center.getAvailablePlugins()).thenReturn(ImmutableSet.of(review, mail)); InstalledPlugin installedMail = createInstalled("scm-mail-plugin", "1.0.0"); when(loader.getInstalledPlugins()).thenReturn(ImmutableList.of(installedMail)); @@ -291,7 +297,7 @@ class DefaultPluginManagerTest { AvailablePlugin review = createAvailable("scm-review-plugin"); when(review.getDescriptor().getOptionalDependencies()).thenReturn(ImmutableSet.of("scm-mail-plugin")); AvailablePlugin mail = createAvailable("scm-mail-plugin", "1.1.0"); - when(center.getAvailable()).thenReturn(ImmutableSet.of(review, mail)); + when(center.getAvailablePlugins()).thenReturn(ImmutableSet.of(review, mail)); manager.install("scm-review-plugin", false); @@ -306,7 +312,7 @@ class DefaultPluginManagerTest { AvailablePlugin mail = createAvailable("scm-mail-plugin"); when(mail.getDescriptor().getDependencies()).thenReturn(ImmutableSet.of("scm-notification-plugin")); AvailablePlugin notification = createAvailable("scm-notification-plugin"); - when(center.getAvailable()).thenReturn(ImmutableSet.of(review, mail, notification)); + when(center.getAvailablePlugins()).thenReturn(ImmutableSet.of(review, mail, notification)); PendingPluginInstallation pendingNotification = mock(PendingPluginInstallation.class); doReturn(pendingNotification).when(installer).install(context, notification); @@ -328,7 +334,7 @@ class DefaultPluginManagerTest { when(review.getDescriptor().getDependencies()).thenReturn(ImmutableSet.of("scm-mail-plugin")); AvailablePlugin mail = createAvailable("scm-mail-plugin"); when(mail.getDescriptor().getDependencies()).thenReturn(ImmutableSet.of("scm-notification-plugin")); - when(center.getAvailable()).thenReturn(ImmutableSet.of(review, mail)); + when(center.getAvailablePlugins()).thenReturn(ImmutableSet.of(review, mail)); assertThrows(NotFoundException.class, () -> manager.install("scm-review-plugin", false)); @@ -338,7 +344,7 @@ class DefaultPluginManagerTest { @Test void shouldSendRestartEventAfterInstallation() { AvailablePlugin git = createAvailable("scm-git-plugin"); - when(center.getAvailable()).thenReturn(ImmutableSet.of(git)); + when(center.getAvailablePlugins()).thenReturn(ImmutableSet.of(git)); manager.install("scm-git-plugin", true); @@ -358,7 +364,7 @@ class DefaultPluginManagerTest { @Test void shouldNotInstallAlreadyPendingPlugins() { AvailablePlugin review = createAvailable("scm-review-plugin"); - when(center.getAvailable()).thenReturn(ImmutableSet.of(review)); + when(center.getAvailablePlugins()).thenReturn(ImmutableSet.of(review)); manager.install("scm-review-plugin", false); manager.install("scm-review-plugin", false); @@ -369,7 +375,7 @@ class DefaultPluginManagerTest { @Test void shouldSendRestartEvent() { AvailablePlugin review = createAvailable("scm-review-plugin"); - when(center.getAvailable()).thenReturn(ImmutableSet.of(review)); + when(center.getAvailablePlugins()).thenReturn(ImmutableSet.of(review)); manager.install("scm-review-plugin", false); manager.executePendingAndRestart(); @@ -387,7 +393,7 @@ class DefaultPluginManagerTest { @Test void shouldReturnSingleAvailableAsPending() { AvailablePlugin review = createAvailable("scm-review-plugin"); - when(center.getAvailable()).thenReturn(ImmutableSet.of(review)); + when(center.getAvailablePlugins()).thenReturn(ImmutableSet.of(review)); manager.install("scm-review-plugin", false); @@ -398,7 +404,7 @@ class DefaultPluginManagerTest { @Test void shouldReturnAvailableAsPending() { AvailablePlugin review = createAvailable("scm-review-plugin"); - when(center.getAvailable()).thenReturn(ImmutableSet.of(review)); + when(center.getAvailablePlugins()).thenReturn(ImmutableSet.of(review)); manager.install("scm-review-plugin", false); @@ -514,7 +520,7 @@ class DefaultPluginManagerTest { when(reviewPlugin.getDescriptor().getDependencies()).thenReturn(singleton("scm-mail-plugin")); when(loader.getInstalledPlugins()).thenReturn(singletonList(mailPlugin)); - when(center.getAvailable()).thenReturn(singleton(reviewPlugin)); + when(center.getAvailablePlugins()).thenReturn(singleton(reviewPlugin)); manager.computeInstallationDependencies(); @@ -546,7 +552,7 @@ class DefaultPluginManagerTest { doNothing().when(mailPlugin).setMarkedForUninstall(uninstallCaptor.capture()); AvailablePlugin git = createAvailable("scm-git-plugin"); - when(center.getAvailable()).thenReturn(ImmutableSet.of(git)); + when(center.getAvailablePlugins()).thenReturn(ImmutableSet.of(git)); PendingPluginInstallation gitPendingPluginInformation = mock(PendingPluginInstallation.class); when(installer.install(context, git)).thenReturn(gitPendingPluginInformation); @@ -577,7 +583,7 @@ class DefaultPluginManagerTest { AvailablePlugin newMailPlugin = createAvailable("scm-mail-plugin", "2.0.0"); AvailablePlugin newReviewPlugin = createAvailable("scm-review-plugin", "2.0.0"); - when(center.getAvailable()).thenReturn(ImmutableSet.of(newMailPlugin, newReviewPlugin)); + when(center.getAvailablePlugins()).thenReturn(ImmutableSet.of(newMailPlugin, newReviewPlugin)); manager.updateAll(); @@ -593,7 +599,7 @@ class DefaultPluginManagerTest { when(loader.getInstalledPlugins()).thenReturn(ImmutableList.of(scriptPlugin)); AvailablePlugin oldScriptPlugin = createAvailable("scm-script-plugin", "0.9"); - when(center.getAvailable()).thenReturn(ImmutableSet.of(oldScriptPlugin)); + when(center.getAvailablePlugins()).thenReturn(ImmutableSet.of(oldScriptPlugin)); manager.updateAll(); @@ -603,7 +609,7 @@ class DefaultPluginManagerTest { @Test void shouldFirePluginEventOnInstallation() { AvailablePlugin review = createAvailable("scm-review-plugin"); - when(center.getAvailable()).thenReturn(ImmutableSet.of(review)); + when(center.getAvailablePlugins()).thenReturn(ImmutableSet.of(review)); manager.install("scm-review-plugin", false); @@ -616,7 +622,7 @@ class DefaultPluginManagerTest { @Test void shouldFirePluginEventOnFailedInstallation() { AvailablePlugin review = createAvailable("scm-review-plugin"); - when(center.getAvailable()).thenReturn(ImmutableSet.of(review)); + when(center.getAvailablePlugins()).thenReturn(ImmutableSet.of(review)); doThrow(new PluginDownloadException(review, new IOException())).when(installer).install(context, review); assertThrows(PluginDownloadException.class, () -> manager.install("scm-review-plugin", false)); @@ -637,7 +643,7 @@ class DefaultPluginManagerTest { when(jenkins.getDescriptor().getDependencies()).thenReturn(ImmutableSet.of("scm-el-plugin")); when(webhook.getDescriptor().getDependencies()).thenReturn(ImmutableSet.of("scm-el-plugin")); AvailablePlugin el = createAvailable("scm-el-plugin"); - when(center.getAvailable()).thenReturn(ImmutableSet.of(jenkins, el, webhook)); + when(center.getAvailablePlugins()).thenReturn(ImmutableSet.of(jenkins, el, webhook)); manager.install("scm-jenkins-plugin", false); manager.install("scm-webhook-plugin", false); @@ -650,6 +656,55 @@ class DefaultPluginManagerTest { assertThat(pluginInstallationContext.find("scm-webhook-plugin")).isPresent(); assertThat(pluginInstallationContext.find("scm-el-plugin")).isPresent(); } + + @Test + void shouldGetPluginSets() { + PluginSet pluginSet = new PluginSet( + "my-plugin-set", + 0, + ImmutableSet.of("scm-jenkins-plugin", "scm-webhook-plugin", "scm-el-plugin"), + ImmutableMap.of("en", new PluginSet.Description("My Plugin Set", List.of("this is awesome!"))), + ImmutableMap.of("standard", "base64image") + ); + when(center.getAvailablePluginSets()).thenReturn(ImmutableSet.of(pluginSet)); + Set pluginSets = manager.getPluginSets(); + assertThat(pluginSets).containsExactly(pluginSet); + } + + @Test + void shouldInstallPluginSets() { + AvailablePlugin git = createAvailable("scm-git-plugin"); + AvailablePlugin svn = createAvailable("scm-svn-plugin"); + AvailablePlugin hg = createAvailable("scm-hg-plugin"); + + when(center.getAvailablePlugins()).thenReturn(ImmutableSet.of(git, svn, hg)); + + PluginSet pluginSet = new PluginSet( + "my-plugin-set", + 0, + ImmutableSet.of("scm-git-plugin", "scm-hg-plugin"), + ImmutableMap.of("en", new PluginSet.Description("My Plugin Set", List.of("this is awesome!"))), + ImmutableMap.of("standard", "base64image") + ); + + PluginSet pluginSet2 = new PluginSet( + "my-other-plugin-set", + 0, + ImmutableSet.of("scm-svn-plugin", "scm-hg-plugin"), + ImmutableMap.of("en", new PluginSet.Description("My Plugin Set", List.of("this is awesome!"))), + ImmutableMap.of("standard", "base64image") + ); + when(center.getAvailablePluginSets()).thenReturn(ImmutableSet.of(pluginSet, pluginSet2)); + + manager.installPluginSets(ImmutableSet.of("my-plugin-set", "my-other-plugin-set"), false); + + verify(pluginSetConfigStore).setPluginSets(new PluginSetsConfig(ImmutableSet.of("my-plugin-set", "my-other-plugin-set"))); + verify(installer, Mockito.times(1)).install(context, git); + verify(installer, Mockito.times(1)).install(context, hg); + verify(installer, Mockito.times(1)).install(context, svn); + + verify(restarter, never()).restart(any(), any()); + } } @Nested @@ -672,6 +727,7 @@ class DefaultPluginManagerTest { assertThrows(AuthorizationException.class, () -> manager.getInstalled("test")); assertThrows(AuthorizationException.class, () -> manager.getAvailable()); assertThrows(AuthorizationException.class, () -> manager.getAvailable("test")); + assertThrows(AuthorizationException.class, () -> manager.getPluginSets()); } } @@ -695,6 +751,12 @@ class DefaultPluginManagerTest { assertThrows(AuthorizationException.class, () -> manager.install("test", false)); } + @Test + void shouldThrowAuthorizationExceptionsForInstallPluginSetsMethod() { + ImmutableSet pluginSetIds = ImmutableSet.of("test"); + assertThrows(AuthorizationException.class, () -> manager.installPluginSets(pluginSetIds, false)); + } + @Test void shouldThrowAuthorizationExceptionsForUninstallMethod() { assertThrows(AuthorizationException.class, () -> manager.uninstall("test", false)); diff --git a/scm-webapp/src/test/java/sonia/scm/plugin/PluginCenterDtoMapperTest.java b/scm-webapp/src/test/java/sonia/scm/plugin/PluginCenterDtoMapperTest.java index abd6277489..d627c721c9 100644 --- a/scm-webapp/src/test/java/sonia/scm/plugin/PluginCenterDtoMapperTest.java +++ b/scm-webapp/src/test/java/sonia/scm/plugin/PluginCenterDtoMapperTest.java @@ -33,17 +33,16 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; -import java.util.HashMap; import java.util.List; import java.util.Set; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.when; +import static sonia.scm.plugin.PluginCenterDto.Condition; +import static sonia.scm.plugin.PluginCenterDto.Link; import static sonia.scm.plugin.PluginCenterDto.Plugin; -import static sonia.scm.plugin.PluginCenterDto.*; @ExtendWith(MockitoExtension.class) class PluginCenterDtoMapperTest { @@ -72,8 +71,19 @@ class PluginCenterDtoMapperTest { ImmutableMap.of("download", new Link("http://download.hitchhiker.com")) ); + PluginCenterDto.PluginSet pluginSet = new PluginCenterDto.PluginSet( + "my-plugin-set", + ">2.0.0", + 0, + ImmutableSet.of("scm-review-plugin"), + ImmutableMap.of("en", new PluginCenterDto.Description("My Plugin Set", List.of("hello world"))), + ImmutableMap.of("standard", "base64image") + ); + when(dto.getEmbedded().getPlugins()).thenReturn(Collections.singletonList(plugin)); - AvailablePluginDescriptor descriptor = mapper.map(dto).iterator().next().getDescriptor(); + when(dto.getEmbedded().getPluginSets()).thenReturn(Collections.singletonList(pluginSet)); + PluginCenterResult mapped = mapper.map(dto); + AvailablePluginDescriptor descriptor = mapped.getPlugins().iterator().next().getDescriptor(); PluginInformation information = descriptor.getInformation(); PluginCondition condition = descriptor.getCondition(); @@ -88,6 +98,14 @@ class PluginCenterDtoMapperTest { assertThat(condition.getOs().iterator().next()).isEqualTo(plugin.getConditions().getOs().iterator().next()); assertThat(information.getDescription()).isEqualTo(plugin.getDescription()); assertThat(information.getName()).isEqualTo(plugin.getName()); + + PluginSet mappedPluginSet = mapped.getPluginSets().iterator().next(); + + assertThat(mappedPluginSet.getId()).isEqualTo(pluginSet.getId()); + assertThat(mappedPluginSet.getSequence()).isEqualTo(pluginSet.getSequence()); + assertThat(mappedPluginSet.getPlugins()).hasSize(pluginSet.getPlugins().size()); + assertThat(mappedPluginSet.getImages()).isNotEmpty(); + assertThat(mappedPluginSet.getDescriptions()).isNotEmpty(); } @Test @@ -126,7 +144,8 @@ class PluginCenterDtoMapperTest { when(dto.getEmbedded().getPlugins()).thenReturn(Arrays.asList(plugin1, plugin2)); - Set resultSet = mapper.map(dto); + PluginCenterResult pluginCenterResult = mapper.map(dto); + Set resultSet = pluginCenterResult.getPlugins(); PluginInformation pluginInformation1 = findPlugin(resultSet, plugin1.getName()); PluginInformation pluginInformation2 = findPlugin(resultSet, plugin2.getName()); diff --git a/scm-webapp/src/test/java/sonia/scm/plugin/PluginCenterLoaderTest.java b/scm-webapp/src/test/java/sonia/scm/plugin/PluginCenterLoaderTest.java index 7adb763d10..6d60d7e9af 100644 --- a/scm-webapp/src/test/java/sonia/scm/plugin/PluginCenterLoaderTest.java +++ b/scm-webapp/src/test/java/sonia/scm/plugin/PluginCenterLoaderTest.java @@ -43,7 +43,6 @@ import java.util.Set; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.*; -import static sonia.scm.plugin.Tracing.SPAN_KIND; @ExtendWith(MockitoExtension.class) class PluginCenterLoaderTest { @@ -71,12 +70,15 @@ class PluginCenterLoaderTest { @Test void shouldFetch() throws IOException { Set plugins = Collections.emptySet(); + Set pluginSets = Collections.emptySet(); PluginCenterDto dto = new PluginCenterDto(); + PluginCenterResult pluginCenterResult = new PluginCenterResult(plugins, pluginSets); when(request().contentFromJson(PluginCenterDto.class)).thenReturn(dto); - when(mapper.map(dto)).thenReturn(plugins); + when(mapper.map(dto)).thenReturn(pluginCenterResult); - Set fetched = loader.load(PLUGIN_URL); - assertThat(fetched).isSameAs(plugins); + PluginCenterResult fetched = loader.load(PLUGIN_URL); + assertThat(fetched.getPlugins()).isSameAs(plugins); + assertThat(fetched.getPluginSets()).isSameAs(pluginSets); } private AdvancedHttpResponse request() throws IOException { @@ -91,8 +93,9 @@ class PluginCenterLoaderTest { when(client.get(PLUGIN_URL)).thenReturn(request); when(request.request()).thenThrow(new IOException("failed to fetch")); - Set fetch = loader.load(PLUGIN_URL); - assertThat(fetch).isEmpty(); + PluginCenterResult fetch = loader.load(PLUGIN_URL); + assertThat(fetch.getPlugins()).isEmpty(); + assertThat(fetch.getPluginSets()).isEmpty(); } @Test @@ -119,8 +122,9 @@ class PluginCenterLoaderTest { private Set mockResponse() throws IOException { PluginCenterDto dto = new PluginCenterDto(); Set plugins = Collections.emptySet(); + Set pluginSets = Collections.emptySet(); when(request().contentFromJson(PluginCenterDto.class)).thenReturn(dto); - when(mapper.map(dto)).thenReturn(plugins); + when(mapper.map(dto)).thenReturn(new PluginCenterResult(plugins, pluginSets)); return plugins; } diff --git a/scm-webapp/src/test/java/sonia/scm/plugin/PluginCenterTest.java b/scm-webapp/src/test/java/sonia/scm/plugin/PluginCenterTest.java index 5aa202049a..ad03202b5a 100644 --- a/scm-webapp/src/test/java/sonia/scm/plugin/PluginCenterTest.java +++ b/scm-webapp/src/test/java/sonia/scm/plugin/PluginCenterTest.java @@ -24,21 +24,16 @@ package sonia.scm.plugin; -import com.google.common.collect.ImmutableSet; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Answers; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import sonia.scm.SCMContextProvider; import sonia.scm.cache.CacheManager; import sonia.scm.cache.MapCacheManager; import sonia.scm.config.ScmConfiguration; -import sonia.scm.net.ahc.AdvancedHttpClient; -import sonia.scm.util.SystemUtil; -import java.io.IOException; import java.util.Collections; import java.util.HashSet; import java.util.Set; @@ -81,36 +76,52 @@ class PluginCenterTest { @Test void shouldFetchPlugins() { Set plugins = new HashSet<>(); - when(loader.load(PLUGIN_URL_BASE + "2.0.0")).thenReturn(plugins); + Set pluginSets = new HashSet<>(); - assertThat(pluginCenter.getAvailable()).isSameAs(plugins); + PluginCenterResult pluginCenterResult = new PluginCenterResult(plugins, pluginSets); + when(loader.load(PLUGIN_URL_BASE + "2.0.0")).thenReturn(pluginCenterResult); + + assertThat(pluginCenter.getAvailablePlugins()).isSameAs(plugins); + assertThat(pluginCenter.getAvailablePluginSets()).isSameAs(pluginSets); } @Test @SuppressWarnings("unchecked") void shouldCache() { - Set first = new HashSet<>(); - when(loader.load(anyString())).thenReturn(first, new HashSet<>()); + Set plugins = new HashSet<>(); + Set pluginSets = new HashSet<>(); - assertThat(pluginCenter.getAvailable()).isSameAs(first); - assertThat(pluginCenter.getAvailable()).isSameAs(first); + PluginCenterResult first = new PluginCenterResult(plugins, pluginSets); + when(loader.load(anyString())).thenReturn(first, new PluginCenterResult(Collections.emptySet(), Collections.emptySet())); + + assertThat(pluginCenter.getAvailablePlugins()).isSameAs(plugins); + assertThat(pluginCenter.getAvailablePlugins()).isSameAs(plugins); + assertThat(pluginCenter.getAvailablePluginSets()).isSameAs(pluginSets); } @Test @SuppressWarnings("unchecked") void shouldClearCache() { - Set first = new HashSet<>(); - when(loader.load(anyString())).thenReturn(first, new HashSet<>()); + Set plugins = new HashSet<>(); + Set pluginSets = new HashSet<>(); - assertThat(pluginCenter.getAvailable()).isSameAs(first); + PluginCenterResult first = new PluginCenterResult(plugins, pluginSets); + when(loader.load(anyString())).thenReturn(first, new PluginCenterResult(Collections.emptySet(), Collections.emptySet())); + + assertThat(pluginCenter.getAvailablePlugins()).isSameAs(plugins); + assertThat(pluginCenter.getAvailablePluginSets()).isSameAs(pluginSets); pluginCenter.handle(new PluginCenterLoginEvent(null)); - assertThat(pluginCenter.getAvailable()).isNotSameAs(first); + assertThat(pluginCenter.getAvailablePlugins()).isNotSameAs(plugins); + assertThat(pluginCenter.getAvailablePluginSets()).isNotSameAs(pluginSets); } @Test void shouldLoadOnRefresh() { Set plugins = new HashSet<>(); - when(loader.load(PLUGIN_URL_BASE + "2.0.0")).thenReturn(plugins); + Set pluginSets = new HashSet<>(); + + PluginCenterResult pluginCenterResult = new PluginCenterResult(plugins, pluginSets); + when(loader.load(PLUGIN_URL_BASE + "2.0.0")).thenReturn(pluginCenterResult); pluginCenter.refresh();