Merge with develop

This commit is contained in:
Sebastian Sdorra
2020-09-02 07:45:44 +02:00
64 changed files with 1134 additions and 485 deletions

View File

@@ -8,11 +8,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### Added
- Add support for scroll anchors in url hash of diff page ([#1304](https://github.com/scm-manager/scm-manager/pull/1304)) - Add support for scroll anchors in url hash of diff page ([#1304](https://github.com/scm-manager/scm-manager/pull/1304))
## [2.4.1] - 2020-09-01
### Added
- Add "sonia.scm.restart-migration.wait" to set wait in milliseconds before restarting scm-server after migration ([#1308](https://github.com/scm-manager/scm-manager/pull/1308))
### Fixed ### Fixed
- Fix detection of markdown files for files having content does not start with '#' ([#1306](https://github.com/scm-manager/scm-manager/pull/1306))
- Fix broken markdown rendering ([#1303](https://github.com/scm-manager/scm-manager/pull/1303)) - Fix broken markdown rendering ([#1303](https://github.com/scm-manager/scm-manager/pull/1303))
- JWT token timeout is now handled properly ([#1297](https://github.com/scm-manager/scm-manager/pull/1297)) - JWT token timeout is now handled properly ([#1297](https://github.com/scm-manager/scm-manager/pull/1297))
- Fix text-overflow in danger zone ([#1298](https://github.com/scm-manager/scm-manager/pull/1298)) - Fix text-overflow in danger zone ([#1298](https://github.com/scm-manager/scm-manager/pull/1298))
- Fix plugin installation error if previously a plugin was installed with the same dependency which is still pending. ([#1300](https://github.com/scm-manager/scm-manager/pull/1300)) - Fix plugin installation error if previously a plugin was installed with the same dependency which is still pending. ([#1300](https://github.com/scm-manager/scm-manager/pull/1300))
- Fix layout overflow on changesets with multiple tags ([#1314](https://github.com/scm-manager/scm-manager/pull/1314))
- Make checkbox accessible from keyboard ([#1309](https://github.com/scm-manager/scm-manager/pull/1309))
- Fix logging of large stacktrace for unknown language ([#1313](https://github.com/scm-manager/scm-manager/pull/1313))
- Fix incorrect word breaking behaviour in markdown ([#1317](https://github.com/scm-manager/scm-manager/pull/1317))
- Remove obsolete revision encoding on sources ([#1315](https://github.com/scm-manager/scm-manager/pull/1315))
- Map generic JaxRS 'web application exceptions' to appropriate response instead of "internal server error" ([#1318](https://github.com/scm-manager/scm-manager/pull/1312))
## [2.4.0] - 2020-08-14 ## [2.4.0] - 2020-08-14
### Added ### Added
@@ -284,3 +296,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
[2.3.0]: https://www.scm-manager.org/download/2.3.0 [2.3.0]: https://www.scm-manager.org/download/2.3.0
[2.3.1]: https://www.scm-manager.org/download/2.3.1 [2.3.1]: https://www.scm-manager.org/download/2.3.1
[2.4.0]: https://www.scm-manager.org/download/2.4.0 [2.4.0]: https://www.scm-manager.org/download/2.4.0
[2.4.1]: https://www.scm-manager.org/download/2.4.1

View File

@@ -5,5 +5,5 @@
], ],
"npmClient": "yarn", "npmClient": "yarn",
"useWorkspaces": true, "useWorkspaces": true,
"version": "2.4.0" "version": "2.4.1"
} }

View File

@@ -166,6 +166,11 @@ public class AuthenticationFilter extends HttpFilter {
HttpUtil.sendUnauthorized(request, response, configuration.getRealmDescription()); HttpUtil.sendUnauthorized(request, response, configuration.getRealmDescription());
} }
protected void handleTokenExpiredException(HttpServletRequest request, HttpServletResponse response,
FilterChain chain, TokenExpiredException tokenExpiredException) throws IOException, ServletException {
throw tokenExpiredException;
}
/** /**
* Iterates all {@link WebTokenGenerator} and creates an * Iterates all {@link WebTokenGenerator} and creates an
* {@link AuthenticationToken} from the given request. * {@link AuthenticationToken} from the given request.
@@ -211,7 +216,7 @@ public class AuthenticationFilter extends HttpFilter {
processChain(request, response, chain, subject); processChain(request, response, chain, subject);
} catch (TokenExpiredException ex) { } catch (TokenExpiredException ex) {
// Rethrow to be caught by TokenExpiredFilter // Rethrow to be caught by TokenExpiredFilter
throw ex; handleTokenExpiredException(request, response, chain, ex);
} catch (AuthenticationException ex) { } catch (AuthenticationException ex) {
logger.warn("authentication failed", ex); logger.warn("authentication failed", ex);
handleUnauthorized(request, response, chain); handleUnauthorized(request, response, chain);

View File

@@ -25,6 +25,7 @@
package sonia.scm.web.filter; package sonia.scm.web.filter;
import sonia.scm.config.ScmConfiguration; import sonia.scm.config.ScmConfiguration;
import sonia.scm.security.TokenExpiredException;
import sonia.scm.util.HttpUtil; import sonia.scm.util.HttpUtil;
import sonia.scm.web.UserAgent; import sonia.scm.web.UserAgent;
import sonia.scm.web.UserAgentParser; import sonia.scm.web.UserAgentParser;
@@ -59,4 +60,15 @@ public class HttpProtocolServletAuthenticationFilterBase extends AuthenticationF
HttpUtil.sendUnauthorized(request, response); HttpUtil.sendUnauthorized(request, response);
} }
} }
@Override
protected void handleTokenExpiredException(HttpServletRequest request, HttpServletResponse response, FilterChain chain, TokenExpiredException tokenExpiredException) throws IOException, ServletException {
UserAgent userAgent = userAgentParser.parse(request);
if (userAgent.isBrowser()) {
// we can proceed the filter chain because the HttpProtocolServlet will render the ui if the client is a browser
chain.doFilter(request, response);
} else {
super.handleTokenExpiredException(request, response, chain, tokenExpiredException);
}
}
} }

View File

@@ -30,6 +30,7 @@ import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock; import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoExtension;
import sonia.scm.config.ScmConfiguration; import sonia.scm.config.ScmConfiguration;
import sonia.scm.security.TokenExpiredException;
import sonia.scm.util.HttpUtil; import sonia.scm.util.HttpUtil;
import sonia.scm.web.UserAgent; import sonia.scm.web.UserAgent;
import sonia.scm.web.UserAgentParser; import sonia.scm.web.UserAgentParser;
@@ -43,6 +44,7 @@ import java.io.IOException;
import java.util.Collections; import java.util.Collections;
import java.util.Set; import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
@@ -93,4 +95,23 @@ class HttpProtocolServletAuthenticationFilterBaseTest {
verify(filterChain).doFilter(request, response); verify(filterChain).doFilter(request, response);
} }
@Test
void shouldIgnoreTokenExpiredExceptionForBrowserCall() throws IOException, ServletException {
when(userAgentParser.parse(request)).thenReturn(browser);
authenticationFilter.handleTokenExpiredException(request, response, filterChain, new TokenExpiredException("Nothing ever expired so much"));
verify(filterChain).doFilter(request, response);
}
@Test
void shouldRethrowTokenExpiredExceptionForApiCall() {
when(userAgentParser.parse(request)).thenReturn(nonBrowser);
final TokenExpiredException tokenExpiredException = new TokenExpiredException("Nothing ever expired so much");
assertThrows(TokenExpiredException.class,
() -> authenticationFilter.handleTokenExpiredException(request, response, filterChain, tokenExpiredException));
}
} }

View File

@@ -1,7 +1,7 @@
{ {
"name": "@scm-manager/scm-git-plugin", "name": "@scm-manager/scm-git-plugin",
"private": true, "private": true,
"version": "2.4.0", "version": "2.4.1",
"license": "MIT", "license": "MIT",
"main": "./src/main/js/index.ts", "main": "./src/main/js/index.ts",
"scripts": { "scripts": {
@@ -20,6 +20,6 @@
}, },
"prettier": "@scm-manager/prettier-config", "prettier": "@scm-manager/prettier-config",
"dependencies": { "dependencies": {
"@scm-manager/ui-plugins": "^2.4.0" "@scm-manager/ui-plugins": "^2.4.1"
} }
} }

View File

@@ -1,7 +1,7 @@
{ {
"name": "@scm-manager/scm-hg-plugin", "name": "@scm-manager/scm-hg-plugin",
"private": true, "private": true,
"version": "2.4.0", "version": "2.4.1",
"license": "MIT", "license": "MIT",
"main": "./src/main/js/index.ts", "main": "./src/main/js/index.ts",
"scripts": { "scripts": {
@@ -19,6 +19,6 @@
}, },
"prettier": "@scm-manager/prettier-config", "prettier": "@scm-manager/prettier-config",
"dependencies": { "dependencies": {
"@scm-manager/ui-plugins": "^2.4.0" "@scm-manager/ui-plugins": "^2.4.1"
} }
} }

View File

@@ -1,7 +1,7 @@
{ {
"name": "@scm-manager/scm-legacy-plugin", "name": "@scm-manager/scm-legacy-plugin",
"private": true, "private": true,
"version": "2.4.0", "version": "2.4.1",
"license": "MIT", "license": "MIT",
"main": "./src/main/js/index.tsx", "main": "./src/main/js/index.tsx",
"scripts": { "scripts": {
@@ -19,6 +19,6 @@
}, },
"prettier": "@scm-manager/prettier-config", "prettier": "@scm-manager/prettier-config",
"dependencies": { "dependencies": {
"@scm-manager/ui-plugins": "^2.4.0" "@scm-manager/ui-plugins": "^2.4.1"
} }
} }

View File

@@ -1,7 +1,7 @@
{ {
"name": "@scm-manager/scm-svn-plugin", "name": "@scm-manager/scm-svn-plugin",
"private": true, "private": true,
"version": "2.4.0", "version": "2.4.1",
"license": "MIT", "license": "MIT",
"main": "./src/main/js/index.ts", "main": "./src/main/js/index.ts",
"scripts": { "scripts": {
@@ -19,6 +19,6 @@
}, },
"prettier": "@scm-manager/prettier-config", "prettier": "@scm-manager/prettier-config",
"dependencies": { "dependencies": {
"@scm-manager/ui-plugins": "^2.4.0" "@scm-manager/ui-plugins": "^2.4.1"
} }
} }

View File

@@ -1,6 +1,6 @@
{ {
"name": "@scm-manager/e2e-tests", "name": "@scm-manager/e2e-tests",
"version": "2.4.0", "version": "2.4.1",
"description": "End to end Tests for SCM-Manager", "description": "End to end Tests for SCM-Manager",
"main": "index.js", "main": "index.js",
"author": "Eduard Heimbuch <eduard.heimbuch@cloudogu.com>", "author": "Eduard Heimbuch <eduard.heimbuch@cloudogu.com>",

View File

@@ -29,6 +29,7 @@ import { withI18next } from "storybook-addon-i18next";
import "!style-loader!css-loader!sass-loader!../../ui-styles/src/scm.scss"; import "!style-loader!css-loader!sass-loader!../../ui-styles/src/scm.scss";
import React from "react"; import React from "react";
import { MemoryRouter } from "react-router-dom"; import { MemoryRouter } from "react-router-dom";
import withRedux from "./withRedux";
let i18n = i18next; let i18n = i18next;
@@ -70,4 +71,6 @@ addDecorator(
}) })
); );
addDecorator(withRedux);
configure(require.context("../src", true, /\.stories\.tsx?$/), module); configure(require.context("../src", true, /\.stories\.tsx?$/), module);

View File

@@ -0,0 +1,41 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import React from "react";
import {createStore} from "redux";
import { Provider } from 'react-redux'
const reducer = (state, action) => {
return state;
};
const withRedux = (storyFn) => {
return React.createElement(Provider, {
store: createStore(reducer, {}),
children: storyFn()
});
}
export default withRedux;

View File

@@ -1,6 +1,6 @@
{ {
"name": "@scm-manager/ui-components", "name": "@scm-manager/ui-components",
"version": "2.4.0", "version": "2.4.1",
"description": "UI Components for SCM-Manager and its plugins", "description": "UI Components for SCM-Manager and its plugins",
"main": "src/index.ts", "main": "src/index.ts",
"files": [ "files": [

View File

@@ -21,14 +21,24 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE. * SOFTWARE.
*/ */
import React, { ReactNode } from "react"; import React, { ComponentType, ReactNode } from "react";
import ErrorNotification from "./ErrorNotification"; import ErrorNotification from "./ErrorNotification";
import { MissingLinkError } from "./errors";
import { withContextPath } from "./urls";
import { withRouter, RouteComponentProps } from "react-router-dom";
import ErrorPage from "./ErrorPage";
import { WithTranslation, withTranslation } from "react-i18next";
import { compose } from "redux";
import { connect } from "react-redux";
type Props = { type ExportedProps = {
fallback?: React.ComponentType<any>; fallback?: React.ComponentType<any>;
children: ReactNode; children: ReactNode;
loginLink?: string;
}; };
type Props = WithTranslation & RouteComponentProps & ExportedProps;
type ErrorInfo = { type ErrorInfo = {
componentStack: string; componentStack: string;
}; };
@@ -44,16 +54,44 @@ class ErrorBoundary extends React.Component<Props, State> {
this.state = {}; this.state = {};
} }
componentDidCatch(error: Error, errorInfo: ErrorInfo) { componentDidUpdate(prevProps: Readonly<Props>) {
// Catch errors in any components below and re-render with error message // we must reset the error if the url has changed
this.setState({ if (this.state.error && prevProps.location !== this.props.location) {
error, this.setState({ error: undefined, errorInfo: undefined });
errorInfo }
});
} }
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
this.setState(
{
error,
errorInfo
},
() => this.redirectToLogin(error)
);
}
redirectToLogin = (error: Error) => {
const { loginLink } = this.props;
if (error instanceof MissingLinkError) {
if (loginLink) {
window.location.assign(withContextPath("/login"));
}
}
};
renderError = () => { renderError = () => {
const { t } = this.props;
const { error } = this.state;
let FallbackComponent = this.props.fallback; let FallbackComponent = this.props.fallback;
if (error instanceof MissingLinkError) {
return (
<ErrorPage error={error} title={t("errorNotification.prefix")} subtitle={t("errorNotification.forbidden")} />
);
}
if (!FallbackComponent) { if (!FallbackComponent) {
FallbackComponent = ErrorNotification; FallbackComponent = ErrorNotification;
} }
@@ -69,4 +107,17 @@ class ErrorBoundary extends React.Component<Props, State> {
return this.props.children; return this.props.children;
} }
} }
export default ErrorBoundary;
const mapStateToProps = (state: any) => {
const loginLink = state.indexResources?.links?.login?.href;
return {
loginLink
};
};
export default compose<ComponentType<ExportedProps>>(
withRouter,
withTranslation("commons"),
connect(mapStateToProps)
)(ErrorBoundary);

View File

@@ -21,16 +21,26 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE. * SOFTWARE.
*/ */
import React from "react"; import React, { FC } from "react";
import { WithTranslation, withTranslation } from "react-i18next"; import { useTranslation, WithTranslation, withTranslation } from "react-i18next";
import { BackendError, ForbiddenError, UnauthorizedError } from "./errors"; import { BackendError, ForbiddenError, UnauthorizedError } from "./errors";
import Notification from "./Notification"; import Notification from "./Notification";
import BackendErrorNotification from "./BackendErrorNotification"; import BackendErrorNotification from "./BackendErrorNotification";
import { useLocation } from "react-router-dom";
import { withContextPath } from "./urls";
type Props = WithTranslation & { type Props = WithTranslation & {
error?: Error; error?: Error;
}; };
const LoginLink: FC = () => {
const [t] = useTranslation("commons");
const location = useLocation();
const from = encodeURIComponent(location.pathname);
return <a href={withContextPath(`/login?from=${from}`)}>{t("errorNotification.loginLink")}</a>;
};
class ErrorNotification extends React.Component<Props> { class ErrorNotification extends React.Component<Props> {
render() { render() {
const { t, error } = this.props; const { t, error } = this.props;
@@ -40,8 +50,7 @@ class ErrorNotification extends React.Component<Props> {
} else if (error instanceof UnauthorizedError) { } else if (error instanceof UnauthorizedError) {
return ( return (
<Notification type="danger"> <Notification type="danger">
<strong>{t("errorNotification.prefix")}:</strong> {t("errorNotification.timeout")}{" "} <strong>{t("errorNotification.prefix")}:</strong> {t("errorNotification.timeout")} <LoginLink />
<a href="javascript:window.location.reload(true)">{t("errorNotification.loginLink")}</a>
</Notification> </Notification>
); );
} else if (error instanceof ForbiddenError) { } else if (error instanceof ForbiddenError) {

View File

@@ -22,7 +22,7 @@
* SOFTWARE. * SOFTWARE.
*/ */
import React, { Component } from "react"; import React, { Component } from "react";
import { Route, Redirect, withRouter, RouteComponentProps, RouteProps } from "react-router-dom"; import { Redirect, Route, RouteComponentProps, RouteProps, withRouter } from "react-router-dom";
type Props = RouteComponentProps & type Props = RouteComponentProps &
RouteProps & { RouteProps & {
@@ -30,7 +30,16 @@ type Props = RouteComponentProps &
}; };
class ProtectedRoute extends Component<Props> { class ProtectedRoute extends Component<Props> {
renderRoute = (Component: any, authenticated?: boolean) => { constructor(props: Props) {
super(props);
this.state = {
error: undefined
};
}
renderRoute = (Component: any) => {
const { authenticated } = this.props;
return (routeProps: any) => { return (routeProps: any) => {
if (authenticated) { if (authenticated) {
return <Component {...routeProps} />; return <Component {...routeProps} />;
@@ -50,8 +59,8 @@ class ProtectedRoute extends Component<Props> {
}; };
render() { render() {
const { component, authenticated, ...routeProps } = this.props; const { component, ...routeProps } = this.props;
return <Route {...routeProps} render={this.renderRoute(component, authenticated)} />; return <Route {...routeProps} render={this.renderRoute(component)} />;
} }
} }

View File

@@ -22,12 +22,12 @@
* SOFTWARE. * SOFTWARE.
*/ */
import React, { ReactNode } from "react"; import React, { ReactNode } from "react";
import classNames from "classnames";
type Props = { type Props = {
message: string; message: string;
className?: string; className?: string;
location: string; location: string;
multiline?: boolean;
children: ReactNode; children: ReactNode;
}; };
@@ -37,9 +37,17 @@ class Tooltip extends React.Component<Props> {
}; };
render() { render() {
const { className, message, location, children } = this.props; const { className, message, location, multiline, children } = this.props;
let classes = `tooltip has-tooltip-${location}`;
if (multiline) {
classes += " has-tooltip-multiline";
}
if (className) {
classes += " " + className;
}
return ( return (
<span className={classNames("tooltip", "has-tooltip-" + location, className)} data-tooltip={message}> <span className={classes} data-tooltip={message}>
{children} {children}
</span> </span>
); );

View File

@@ -44140,14 +44140,20 @@ exports[`Storyshots Forms|Checkbox Default 1`] = `
<div <div
className="control" className="control"
onClick={[Function]} onClick={[Function]}
onKeyDown={[Function]}
> >
<label <label
className="checkbox" className="checkbox"
>
<span
className="gwt-Anchor"
tabIndex={0}
> >
<i <i
className="is-outlined fa-square has-text-black far" className="is-outlined fa-square has-text-black far"
/> />
</span>
Not checked Not checked
</label> </label>
</div> </div>
@@ -44158,14 +44164,20 @@ exports[`Storyshots Forms|Checkbox Default 1`] = `
<div <div
className="control" className="control"
onClick={[Function]} onClick={[Function]}
onKeyDown={[Function]}
> >
<label <label
className="checkbox" className="checkbox"
>
<span
className="gwt-Anchor"
tabIndex={0}
> >
<i <i
className="is-outlined fa-check-square has-text-link fa" className="is-outlined fa-check-square has-text-link fa"
/> />
</span>
Checked Checked
</label> </label>
</div> </div>
@@ -44176,14 +44188,20 @@ exports[`Storyshots Forms|Checkbox Default 1`] = `
<div <div
className="control" className="control"
onClick={[Function]} onClick={[Function]}
onKeyDown={[Function]}
> >
<label <label
className="checkbox" className="checkbox"
>
<span
className="gwt-Anchor"
tabIndex={0}
> >
<i <i
className="is-outlined fa-minus-square has-text-link far" className="is-outlined fa-minus-square has-text-link far"
/> />
</span>
Indeterminate Indeterminate
</label> </label>
</div> </div>
@@ -44201,15 +44219,21 @@ exports[`Storyshots Forms|Checkbox Disabled 1`] = `
<div <div
className="control" className="control"
onClick={[Function]} onClick={[Function]}
onKeyDown={[Function]}
> >
<label <label
className="checkbox" className="checkbox"
disabled={true} disabled={true}
>
<span
className="gwt-Anchor"
tabIndex={0}
> >
<i <i
className="is-outlined fa-check-square has-text-grey-light fa" className="is-outlined fa-check-square has-text-grey-light fa"
/> />
</span>
Checked but disabled Checked but disabled
</label> </label>
</div> </div>
@@ -44227,14 +44251,20 @@ exports[`Storyshots Forms|Checkbox With HelpText 1`] = `
<div <div
className="control" className="control"
onClick={[Function]} onClick={[Function]}
onKeyDown={[Function]}
> >
<label <label
className="checkbox" className="checkbox"
>
<span
className="gwt-Anchor"
tabIndex={0}
> >
<i <i
className="is-outlined fa-square has-text-black far" className="is-outlined fa-square has-text-black far"
/> />
</span>
Classic helpText Classic helpText
<span <span
className="tooltip has-tooltip-right Help__HelpTooltip-ykmmew-0 cYhfno is-inline-block has-tooltip-multiline" className="tooltip has-tooltip-right Help__HelpTooltip-ykmmew-0 cYhfno is-inline-block has-tooltip-multiline"
@@ -44253,14 +44283,20 @@ exports[`Storyshots Forms|Checkbox With HelpText 1`] = `
<div <div
className="control" className="control"
onClick={[Function]} onClick={[Function]}
onKeyDown={[Function]}
> >
<label <label
className="checkbox" className="checkbox"
>
<span
className="gwt-Anchor"
tabIndex={0}
> >
<i <i
className="is-outlined fa-check-square has-text-link fa" className="is-outlined fa-check-square has-text-link fa"
/> />
</span>
Long helpText Long helpText
<span <span
className="tooltip has-tooltip-right Help__HelpTooltip-ykmmew-0 cYhfno is-inline-block has-tooltip-multiline" className="tooltip has-tooltip-right Help__HelpTooltip-ykmmew-0 cYhfno is-inline-block has-tooltip-multiline"
@@ -47632,14 +47668,20 @@ exports[`Storyshots Modal|Modal With long tooltips 1`] = `
<div <div
className="control" className="control"
onClick={[Function]} onClick={[Function]}
onKeyDown={[Function]}
> >
<label <label
className="checkbox" className="checkbox"
>
<span
className="gwt-Anchor"
tabIndex={0}
> >
<i <i
className="is-outlined fa-check-square has-text-link fa" className="is-outlined fa-check-square has-text-link fa"
/> />
</span>
Checkbox Checkbox
<span <span
className="tooltip has-tooltip-right Help__HelpTooltip-ykmmew-0 cYhfno is-inline-block has-tooltip-multiline" className="tooltip has-tooltip-right Help__HelpTooltip-ykmmew-0 cYhfno is-inline-block has-tooltip-multiline"

View File

@@ -125,23 +125,15 @@ const applyFetchOptions: (p: RequestInit) => RequestInit = o => {
function handleFailure(response: Response) { function handleFailure(response: Response) {
if (!response.ok) { if (!response.ok) {
if (isBackendError(response)) {
return response.json().then((content: BackendErrorContent) => {
if (content.errorCode === TOKEN_EXPIRED_ERROR_CODE) {
window.location.replace(`${contextPath}/login`);
// Throw error because if redirect is not instantaneous, we want to display something senseful
throw new UnauthorizedError("Unauthorized", 401);
} else {
throw createBackendError(content, response.status);
}
});
} else {
if (response.status === 401) { if (response.status === 401) {
throw new UnauthorizedError("Unauthorized", 401); throw new UnauthorizedError("Unauthorized", 401);
} else if (response.status === 403) { } else if (response.status === 403) {
throw new ForbiddenError("Forbidden", 403); throw new ForbiddenError("Forbidden", 403);
} } else if (isBackendError(response)) {
return response.json().then((content: BackendErrorContent) => {
throw createBackendError(content, response.status);
});
} else {
throw new Error("server returned status code " + response.status); throw new Error("server returned status code " + response.status);
} }
} }
@@ -163,24 +155,35 @@ export function createUrlWithIdentifiers(url: string): string {
return createUrl(url) + "?X-SCM-Client=WUI&X-SCM-Session-ID=" + sessionId; return createUrl(url) + "?X-SCM-Client=WUI&X-SCM-Session-ID=" + sessionId;
} }
type ErrorListener = (error: Error) => void;
class ApiClient { class ApiClient {
get(url: string): Promise<Response> { errorListeners: ErrorListener[] = [];
return fetch(createUrl(url), applyFetchOptions({})).then(handleFailure);
}
post(url: string, payload?: any, contentType = "application/json", additionalHeaders: Record<string, string> = {}) { get = (url: string): Promise<Response> => {
return fetch(createUrl(url), applyFetchOptions({}))
.then(handleFailure)
.catch(this.notifyAndRethrow);
};
post = (
url: string,
payload?: any,
contentType = "application/json",
additionalHeaders: Record<string, string> = {}
) => {
return this.httpRequestWithJSONBody("POST", url, contentType, additionalHeaders, payload); return this.httpRequestWithJSONBody("POST", url, contentType, additionalHeaders, payload);
} };
postText(url: string, payload: string, additionalHeaders: Record<string, string> = {}) { postText = (url: string, payload: string, additionalHeaders: Record<string, string> = {}) => {
return this.httpRequestWithTextBody("POST", url, additionalHeaders, payload); return this.httpRequestWithTextBody("POST", url, additionalHeaders, payload);
} };
putText(url: string, payload: string, additionalHeaders: Record<string, string> = {}) { putText = (url: string, payload: string, additionalHeaders: Record<string, string> = {}) => {
return this.httpRequestWithTextBody("PUT", url, additionalHeaders, payload); return this.httpRequestWithTextBody("PUT", url, additionalHeaders, payload);
} };
postBinary(url: string, fileAppender: (p: FormData) => void, additionalHeaders: Record<string, string> = {}) { postBinary = (url: string, fileAppender: (p: FormData) => void, additionalHeaders: Record<string, string> = {}) => {
const formData = new FormData(); const formData = new FormData();
fileAppender(formData); fileAppender(formData);
@@ -190,35 +193,39 @@ class ApiClient {
headers: additionalHeaders headers: additionalHeaders
}; };
return this.httpRequestWithBinaryBody(options, url); return this.httpRequestWithBinaryBody(options, url);
} };
put(url: string, payload: any, contentType = "application/json", additionalHeaders: Record<string, string> = {}) { put(url: string, payload: any, contentType = "application/json", additionalHeaders: Record<string, string> = {}) {
return this.httpRequestWithJSONBody("PUT", url, contentType, additionalHeaders, payload); return this.httpRequestWithJSONBody("PUT", url, contentType, additionalHeaders, payload);
} }
head(url: string) { head = (url: string) => {
let options: RequestInit = { let options: RequestInit = {
method: "HEAD" method: "HEAD"
}; };
options = applyFetchOptions(options); options = applyFetchOptions(options);
return fetch(createUrl(url), options).then(handleFailure); return fetch(createUrl(url), options)
} .then(handleFailure)
.catch(this.notifyAndRethrow);
};
delete(url: string): Promise<Response> { delete = (url: string): Promise<Response> => {
let options: RequestInit = { let options: RequestInit = {
method: "DELETE" method: "DELETE"
}; };
options = applyFetchOptions(options); options = applyFetchOptions(options);
return fetch(createUrl(url), options).then(handleFailure); return fetch(createUrl(url), options)
} .then(handleFailure)
.catch(this.notifyAndRethrow);
};
httpRequestWithJSONBody( httpRequestWithJSONBody = (
method: string, method: string,
url: string, url: string,
contentType: string, contentType: string,
additionalHeaders: Record<string, string>, additionalHeaders: Record<string, string>,
payload?: any payload?: any
): Promise<Response> { ): Promise<Response> => {
const options: RequestInit = { const options: RequestInit = {
method: method, method: method,
headers: additionalHeaders headers: additionalHeaders
@@ -227,23 +234,23 @@ class ApiClient {
options.body = JSON.stringify(payload); options.body = JSON.stringify(payload);
} }
return this.httpRequestWithBinaryBody(options, url, contentType); return this.httpRequestWithBinaryBody(options, url, contentType);
} };
httpRequestWithTextBody( httpRequestWithTextBody = (
method: string, method: string,
url: string, url: string,
additionalHeaders: Record<string, string> = {}, additionalHeaders: Record<string, string> = {},
payload: string payload: string
) { ) => {
const options: RequestInit = { const options: RequestInit = {
method: method, method: method,
headers: additionalHeaders headers: additionalHeaders
}; };
options.body = payload; options.body = payload;
return this.httpRequestWithBinaryBody(options, url, "text/plain"); return this.httpRequestWithBinaryBody(options, url, "text/plain");
} };
httpRequestWithBinaryBody(options: RequestInit, url: string, contentType?: string) { httpRequestWithBinaryBody = (options: RequestInit, url: string, contentType?: string) => {
options = applyFetchOptions(options); options = applyFetchOptions(options);
if (contentType) { if (contentType) {
if (!options.headers) { if (!options.headers) {
@@ -253,8 +260,10 @@ class ApiClient {
options.headers["Content-Type"] = contentType; options.headers["Content-Type"] = contentType;
} }
return fetch(createUrl(url), options).then(handleFailure); return fetch(createUrl(url), options)
} .then(handleFailure)
.catch(this.notifyAndRethrow);
};
subscribe(url: string, argument: SubscriptionArgument): Cancel { subscribe(url: string, argument: SubscriptionArgument): Cancel {
const es = new EventSource(createUrlWithIdentifiers(url), { const es = new EventSource(createUrlWithIdentifiers(url), {
@@ -284,6 +293,15 @@ class ApiClient {
return () => es.close(); return () => es.close();
} }
onError = (errorListener: ErrorListener) => {
this.errorListeners.push(errorListener);
};
private notifyAndRethrow = (error: Error): never => {
this.errorListeners.forEach(errorListener => errorListener(error));
throw error;
};
} }
export const apiClient = new ApiClient(); export const apiClient = new ApiClient();

View File

@@ -89,6 +89,10 @@ export class ConflictError extends BackendError {
} }
} }
export class MissingLinkError extends Error {
name = "MissingLinkError";
}
export function createBackendError(content: BackendErrorContent, statusCode: number) { export function createBackendError(content: BackendErrorContent, statusCode: number) {
switch (statusCode) { switch (statusCode) {
case 404: case 404:

View File

@@ -45,6 +45,13 @@ export default class Checkbox extends React.Component<Props> {
} }
}; };
onKeyDown = (event: React.KeyboardEvent) => {
const SPACE = 32;
if (event.keyCode === SPACE) {
this.onCheckboxChange();
}
};
renderHelp = () => { renderHelp = () => {
const { title, helpText } = this.props; const { title, helpText } = this.props;
if (helpText && !title) { if (helpText && !title) {
@@ -64,7 +71,7 @@ export default class Checkbox extends React.Component<Props> {
return ( return (
<div className="field"> <div className="field">
{this.renderLabelWithHelp()} {this.renderLabelWithHelp()}
<div className="control" onClick={this.onCheckboxChange}> <div className="control" onClick={this.onCheckboxChange} onKeyDown={this.onKeyDown}>
{/* {/*
we have to ignore the next line, we have to ignore the next line,
because jsx label does not the custom disabled attribute because jsx label does not the custom disabled attribute

View File

@@ -58,10 +58,13 @@ const TriStateCheckbox: FC<Props> = ({ checked, indeterminate, disabled, label,
color = "black"; color = "black";
} }
// We need a tabIndex to make the checkbox accessible from keyboard.
// We also add the gwt-Anchor css class to support the key-jump browser extension
// https://github.com/KennethSundqvist/key-jump-chrome-extension/blob/master/src/content.js#L365
return ( return (
<> <span tabIndex={0} className="gwt-Anchor">
<Icon iconStyle={"is-outlined"} name={icon} className={className} color={color} testId={testId} /> {label} <Icon iconStyle={"is-outlined"} name={icon} className={className} color={color} testId={testId} /> {label}
</> </span>
); );
}; };

View File

@@ -26,8 +26,10 @@ import { WithTranslation, withTranslation } from "react-i18next";
import PrimaryNavigationLink from "./PrimaryNavigationLink"; import PrimaryNavigationLink from "./PrimaryNavigationLink";
import { Links } from "@scm-manager/ui-types"; import { Links } from "@scm-manager/ui-types";
import { binder, ExtensionPoint } from "@scm-manager/ui-extensions"; import { binder, ExtensionPoint } from "@scm-manager/ui-extensions";
import {withContextPath} from "../urls";
import { withRouter, RouteComponentProps } from "react-router-dom";
type Props = WithTranslation & { type Props = RouteComponentProps & WithTranslation & {
links: Links; links: Links;
}; };
@@ -64,11 +66,17 @@ class PrimaryNavigation extends React.Component<Props> {
}; };
appendLogin = (navigationItems: ReactNode[], append: Appender) => { appendLogin = (navigationItems: ReactNode[], append: Appender) => {
const { t, links } = this.props; const { t, links, location } = this.props;
const from = location.pathname;
const loginPath = "/login";
const to = `${loginPath}?from=${encodeURIComponent(from)}`;
const props = { const props = {
links, links,
label: t("primary-navigation.login") label: t("primary-navigation.login"),
loginUrl: withContextPath(loginPath),
from
}; };
if (binder.hasExtension("primary-navigation.login", props)) { if (binder.hasExtension("primary-navigation.login", props)) {
@@ -76,7 +84,7 @@ class PrimaryNavigation extends React.Component<Props> {
<ExtensionPoint key="primary-navigation.login" name="primary-navigation.login" props={props} /> <ExtensionPoint key="primary-navigation.login" name="primary-navigation.login" props={props} />
); );
} else { } else {
append("/login", "/login", "primary-navigation.login", "login"); append(to, "/login", "primary-navigation.login", "login");
} }
}; };
@@ -128,4 +136,4 @@ class PrimaryNavigation extends React.Component<Props> {
} }
} }
export default withTranslation("commons")(PrimaryNavigation); export default withTranslation("commons")(withRouter(PrimaryNavigation));

View File

@@ -36,7 +36,7 @@ class ChangesetTagsCollapsed extends React.Component<Props> {
const { tags, t } = this.props; const { tags, t } = this.props;
const message = tags.map(tag => tag.name).join(", "); const message = tags.map(tag => tag.name).join(", ");
return ( return (
<Tooltip location="top" message={message}> <Tooltip location="top" message={message} multiline={true}>
<ChangesetTagBase icon="tags" label={tags.length + " " + t("changeset.tags")} /> <ChangesetTagBase icon="tags" label={tags.length + " " + t("changeset.tags")} />
</Tooltip> </Tooltip>
); );

View File

@@ -1,12 +1,12 @@
{ {
"name": "@scm-manager/ui-plugins", "name": "@scm-manager/ui-plugins",
"version": "2.4.0", "version": "2.4.1",
"license": "MIT", "license": "MIT",
"bin": { "bin": {
"ui-plugins": "./bin/ui-plugins.js" "ui-plugins": "./bin/ui-plugins.js"
}, },
"dependencies": { "dependencies": {
"@scm-manager/ui-components": "^2.4.0", "@scm-manager/ui-components": "^2.4.1",
"@scm-manager/ui-extensions": "^2.1.0", "@scm-manager/ui-extensions": "^2.1.0",
"classnames": "^2.2.6", "classnames": "^2.2.6",
"query-string": "^5.0.1", "query-string": "^5.0.1",
@@ -23,7 +23,7 @@
"@scm-manager/jest-preset": "^2.1.0", "@scm-manager/jest-preset": "^2.1.0",
"@scm-manager/prettier-config": "^2.1.0", "@scm-manager/prettier-config": "^2.1.0",
"@scm-manager/tsconfig": "^2.1.0", "@scm-manager/tsconfig": "^2.1.0",
"@scm-manager/ui-scripts": "^2.1.0", "@scm-manager/ui-scripts": "^2.4.1",
"@scm-manager/ui-tests": "^2.1.0", "@scm-manager/ui-tests": "^2.1.0",
"@scm-manager/ui-types": "^2.4.0", "@scm-manager/ui-types": "^2.4.0",
"@types/classnames": "^2.2.9", "@types/classnames": "^2.2.9",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@scm-manager/ui-scripts", "name": "@scm-manager/ui-scripts",
"version": "2.1.0", "version": "2.4.1",
"description": "Build scripts for SCM-Manager", "description": "Build scripts for SCM-Manager",
"main": "src/index.js", "main": "src/index.js",
"author": "Sebastian Sdorra <sebastian.sdorra@cloudogu.com>", "author": "Sebastian Sdorra <sebastian.sdorra@cloudogu.com>",
@@ -14,7 +14,7 @@
"babel-loader": "^8.0.6", "babel-loader": "^8.0.6",
"css-loader": "^3.2.0", "css-loader": "^3.2.0",
"file-loader": "^4.2.0", "file-loader": "^4.2.0",
"mini-css-extract-plugin": "^0.9.0", "mini-css-extract-plugin": "^0.10.0",
"mustache": "^3.1.0", "mustache": "^3.1.0",
"optimize-css-assets-webpack-plugin": "^5.0.3", "optimize-css-assets-webpack-plugin": "^5.0.3",
"react-refresh": "^0.8.0", "react-refresh": "^0.8.0",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@scm-manager/ui-styles", "name": "@scm-manager/ui-styles",
"version": "2.4.0", "version": "2.4.1",
"description": "Styles for SCM-Manager", "description": "Styles for SCM-Manager",
"main": "src/scm.scss", "main": "src/scm.scss",
"license": "MIT", "license": "MIT",

View File

@@ -15,11 +15,11 @@ $family-monospace: "Courier New", Monaco, Menlo, "Ubuntu Mono", "source-code-pro
} }
.is-word-break { .is-word-break {
-webkit-hyphens: auto; -webkit-hyphens: none;
-moz-hyphens: auto; -moz-hyphens: none;
-ms-hyphens: auto; -ms-hyphens: none;
hyphens: auto; hyphens: none;
word-break: break-all; word-break: break-word;
} }
.has-rounded-border { .has-rounded-border {

View File

@@ -1,9 +1,9 @@
{ {
"name": "@scm-manager/ui-webapp", "name": "@scm-manager/ui-webapp",
"version": "2.4.0", "version": "2.4.1",
"private": true, "private": true,
"dependencies": { "dependencies": {
"@scm-manager/ui-components": "^2.4.0", "@scm-manager/ui-components": "^2.4.1",
"@scm-manager/ui-extensions": "^2.1.0", "@scm-manager/ui-extensions": "^2.1.0",
"classnames": "^2.2.5", "classnames": "^2.2.5",
"history": "^4.10.1", "history": "^4.10.1",
@@ -39,6 +39,7 @@
"@types/react-dom": "^16.9.2", "@types/react-dom": "^16.9.2",
"@types/react-redux": "5.0.7", "@types/react-redux": "5.0.7",
"@types/react-router-dom": "^5.1.0", "@types/react-router-dom": "^5.1.0",
"@types/redux-logger": "^3.0.8",
"@types/styled-components": "^5.1.0", "@types/styled-components": "^5.1.0",
"@types/systemjs": "^0.20.6", "@types/systemjs": "^0.20.6",
"fetch-mock": "^7.5.1", "fetch-mock": "^7.5.1",

View File

@@ -29,12 +29,13 @@ import { Redirect, Route, RouteComponentProps, Switch } from "react-router-dom";
import { ExtensionPoint } from "@scm-manager/ui-extensions"; import { ExtensionPoint } from "@scm-manager/ui-extensions";
import { Links } from "@scm-manager/ui-types"; import { Links } from "@scm-manager/ui-types";
import { import {
CustomQueryFlexWrappedColumns,
NavLink, NavLink,
Page, Page,
CustomQueryFlexWrappedColumns,
PrimaryContentColumn, PrimaryContentColumn,
SecondaryNavigationColumn,
SecondaryNavigation, SecondaryNavigation,
SecondaryNavigationColumn,
StateMenuContextProvider,
SubNavigation SubNavigation
} from "@scm-manager/ui-components"; } from "@scm-manager/ui-components";
import { getAvailablePluginsLink, getInstalledPluginsLink, getLinks } from "../../modules/indexResource"; import { getAvailablePluginsLink, getInstalledPluginsLink, getLinks } from "../../modules/indexResource";
@@ -44,7 +45,6 @@ import GlobalConfig from "./GlobalConfig";
import RepositoryRoles from "../roles/containers/RepositoryRoles"; import RepositoryRoles from "../roles/containers/RepositoryRoles";
import SingleRepositoryRole from "../roles/containers/SingleRepositoryRole"; import SingleRepositoryRole from "../roles/containers/SingleRepositoryRole";
import CreateRepositoryRole from "../roles/containers/CreateRepositoryRole"; import CreateRepositoryRole from "../roles/containers/CreateRepositoryRole";
import { StateMenuContextProvider } from "@scm-manager/ui-components";
type Props = RouteComponentProps & type Props = RouteComponentProps &
WithTranslation & { WithTranslation & {

View File

@@ -26,7 +26,7 @@ import { WithTranslation, withTranslation } from "react-i18next";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { Config, NamespaceStrategies } from "@scm-manager/ui-types"; import { Config, NamespaceStrategies } from "@scm-manager/ui-types";
import { ErrorNotification, Loading, Title } from "@scm-manager/ui-components"; import { ErrorNotification, Loading, Title } from "@scm-manager/ui-components";
import { getConfigLink } from "../../modules/indexResource"; import { mustGetConfigLink } from "../../modules/indexResource";
import { import {
fetchConfig, fetchConfig,
getConfig, getConfig,
@@ -186,7 +186,7 @@ const mapStateToProps = (state: any) => {
const config = getConfig(state); const config = getConfig(state);
const configUpdatePermission = getConfigUpdatePermission(state); const configUpdatePermission = getConfigUpdatePermission(state);
const configLink = getConfigLink(state); const configLink = mustGetConfigLink(state);
const namespaceStrategies = getNamespaceStrategies(state); const namespaceStrategies = getNamespaceStrategies(state);
return { return {

View File

@@ -25,7 +25,7 @@ import * as React from "react";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { WithTranslation, withTranslation } from "react-i18next"; import { WithTranslation, withTranslation } from "react-i18next";
import { compose } from "redux"; import { compose } from "redux";
import { PendingPlugins, PluginCollection } from "@scm-manager/ui-types"; import { PendingPlugins, Plugin, PluginCollection } from "@scm-manager/ui-types";
import { import {
Button, Button,
ButtonGroup, ButtonGroup,
@@ -45,16 +45,15 @@ import {
} from "../modules/plugins"; } from "../modules/plugins";
import PluginsList from "../components/PluginList"; import PluginsList from "../components/PluginList";
import { import {
getAvailablePluginsLink, getPendingPluginsLink,
getInstalledPluginsLink, mustGetAvailablePluginsLink,
getPendingPluginsLink mustGetInstalledPluginsLink
} from "../../../modules/indexResource"; } from "../../../modules/indexResource";
import PluginTopActions from "../components/PluginTopActions"; import PluginTopActions from "../components/PluginTopActions";
import PluginBottomActions from "../components/PluginBottomActions"; import PluginBottomActions from "../components/PluginBottomActions";
import ExecutePendingActionModal from "../components/ExecutePendingActionModal"; import ExecutePendingActionModal from "../components/ExecutePendingActionModal";
import CancelPendingActionModal from "../components/CancelPendingActionModal"; import CancelPendingActionModal from "../components/CancelPendingActionModal";
import UpdateAllActionModal from "../components/UpdateAllActionModal"; import UpdateAllActionModal from "../components/UpdateAllActionModal";
import { Plugin } from "@scm-manager/ui-types";
import ShowPendingModal from "../components/ShowPendingModal"; import ShowPendingModal from "../components/ShowPendingModal";
type Props = WithTranslation & { type Props = WithTranslation & {
@@ -319,8 +318,8 @@ const mapStateToProps = (state: any) => {
const collection = getPluginCollection(state); const collection = getPluginCollection(state);
const loading = isFetchPluginsPending(state); const loading = isFetchPluginsPending(state);
const error = getFetchPluginsFailure(state); const error = getFetchPluginsFailure(state);
const availablePluginsLink = getAvailablePluginsLink(state); const availablePluginsLink = mustGetAvailablePluginsLink(state);
const installedPluginsLink = getInstalledPluginsLink(state); const installedPluginsLink = mustGetInstalledPluginsLink(state);
const pendingPluginsLink = getPendingPluginsLink(state); const pendingPluginsLink = getPendingPluginsLink(state);
const pendingPlugins = getPendingPlugins(state); const pendingPlugins = getPendingPlugins(state);

View File

@@ -29,7 +29,7 @@ import { History } from "history";
import { ExtensionPoint } from "@scm-manager/ui-extensions"; import { ExtensionPoint } from "@scm-manager/ui-extensions";
import { RepositoryRole } from "@scm-manager/ui-types"; import { RepositoryRole } from "@scm-manager/ui-types";
import { ErrorPage, Loading, Title } from "@scm-manager/ui-components"; import { ErrorPage, Loading, Title } from "@scm-manager/ui-components";
import { getRepositoryRolesLink } from "../../../modules/indexResource"; import { mustGetRepositoryRolesLink } from "../../../modules/indexResource";
import { fetchRoleByName, getFetchRoleFailure, getRoleByName, isFetchRolePending } from "../modules/roles"; import { fetchRoleByName, getFetchRoleFailure, getRoleByName, isFetchRolePending } from "../modules/roles";
import PermissionRoleDetail from "../components/PermissionRoleDetails"; import PermissionRoleDetail from "../components/PermissionRoleDetails";
import EditRepositoryRole from "./EditRepositoryRole"; import EditRepositoryRole from "./EditRepositoryRole";
@@ -107,7 +107,7 @@ const mapStateToProps = (state: any, ownProps: Props) => {
const role = getRoleByName(state, roleName); const role = getRoleByName(state, roleName);
const loading = isFetchRolePending(state, roleName); const loading = isFetchRolePending(state, roleName);
const error = getFetchRoleFailure(state, roleName); const error = getFetchRoleFailure(state, roleName);
const repositoryRolesLink = getRepositoryRolesLink(state); const repositoryRolesLink = mustGetRepositoryRolesLink(state);
return { return {
repositoryRolesLink, repositoryRolesLink,
roleName, roleName,

View File

@@ -64,6 +64,15 @@ class Index extends Component<Props, State> {
this.props.fetchIndexResources(); this.props.fetchIndexResources();
} }
componentDidUpdate() {
const { indexResources, loading, error } = this.props;
const { pluginsLoaded } = this.state;
if (!indexResources && !loading && !error && pluginsLoaded) {
this.props.fetchIndexResources();
this.setState({ pluginsLoaded: false });
}
}
pluginLoaderCallback = () => { pluginLoaderCallback = () => {
this.setState({ this.setState({
pluginsLoaded: true pluginsLoaded: true

View File

@@ -0,0 +1,62 @@
/*
* 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 { from } from "./Login";
describe("from tests", () => {
it("should use default location", () => {
const path = from("", {});
expect(path).toBe("/");
});
it("should use default location without params", () => {
const path = from();
expect(path).toBe("/");
});
it("should use default location with null params", () => {
const path = from("", null);
expect(path).toBe("/");
});
it("should use location from query parameter", () => {
const path = from("from=/repos", {});
expect(path).toBe("/repos");
});
it("should use location from state", () => {
const path = from("", { from: "/users" });
expect(path).toBe("/users");
});
it("should prefer location from query parameter", () => {
const path = from("from=/groups", { from: "/users" });
expect(path).toBe("/groups");
});
it("should decode query param", () => {
const path = from(`from=${encodeURIComponent("/admin/plugins/installed")}`);
expect(path).toBe("/admin/plugins/installed");
});
});

View File

@@ -30,6 +30,7 @@ import { getLoginFailure, getMe, isAnonymous, isLoginPending, login } from "../m
import { getLoginInfoLink, getLoginLink } from "../modules/indexResource"; import { getLoginInfoLink, getLoginLink } from "../modules/indexResource";
import LoginInfo from "../components/LoginInfo"; import LoginInfo from "../components/LoginInfo";
import { Me } from "@scm-manager/ui-types"; import { Me } from "@scm-manager/ui-types";
import { parse } from "query-string";
type Props = RouteComponentProps & { type Props = RouteComponentProps & {
authenticated: boolean; authenticated: boolean;
@@ -47,6 +48,18 @@ const HeroSection = styled.section`
padding-top: 2em; padding-top: 2em;
`; `;
interface FromObject {
from?: string;
}
/**
* @visibleForTesting
*/
export const from = (queryString?: string, stateParams?: FromObject | null): string => {
const queryParams = parse(queryString || "");
return queryParams?.from || stateParams?.from || "/";
};
class Login extends React.Component<Props> { class Login extends React.Component<Props> {
handleLogin = (username: string, password: string): void => { handleLogin = (username: string, password: string): void => {
const { link, login } = this.props; const { link, login } = this.props;
@@ -54,12 +67,8 @@ class Login extends React.Component<Props> {
}; };
renderRedirect = () => { renderRedirect = () => {
const { from } = this.props.location.state || { const to = from(window.location.search, this.props.location.state);
from: { return <Redirect to={to} />;
pathname: "/"
}
};
return <Redirect to={from} />;
}; };
render() { render() {

View File

@@ -31,7 +31,7 @@ import Users from "../users/containers/Users";
import Login from "../containers/Login"; import Login from "../containers/Login";
import Logout from "../containers/Logout"; import Logout from "../containers/Logout";
import { ProtectedRoute } from "@scm-manager/ui-components"; import { ProtectedRoute, ErrorBoundary } from "@scm-manager/ui-components";
import { binder, ExtensionPoint } from "@scm-manager/ui-extensions"; import { binder, ExtensionPoint } from "@scm-manager/ui-extensions";
import CreateUser from "../users/containers/CreateUser"; import CreateUser from "../users/containers/CreateUser";
@@ -68,6 +68,7 @@ class Main extends React.Component<Props> {
url = "/login"; url = "/login";
} }
return ( return (
<ErrorBoundary>
<div className="main"> <div className="main">
<Switch> <Switch>
<Redirect exact from="/" to={url} /> <Redirect exact from="/" to={url} />
@@ -80,13 +81,13 @@ class Main extends React.Component<Props> {
<ProtectedRoute path="/repo/:namespace/:name" component={RepositoryRoot} authenticated={authenticated} /> <ProtectedRoute path="/repo/:namespace/:name" component={RepositoryRoot} authenticated={authenticated} />
<Redirect exact strict from="/users" to="/users/" /> <Redirect exact strict from="/users" to="/users/" />
<ProtectedRoute exact path="/users/" component={Users} authenticated={authenticated} /> <ProtectedRoute exact path="/users/" component={Users} authenticated={authenticated} />
<ProtectedRoute authenticated={authenticated} path="/users/create" component={CreateUser} /> <ProtectedRoute path="/users/create" component={CreateUser} authenticated={authenticated} />
<ProtectedRoute exact path="/users/:page" component={Users} authenticated={authenticated} /> <ProtectedRoute exact path="/users/:page" component={Users} authenticated={authenticated} />
<ProtectedRoute authenticated={authenticated} path="/user/:name" component={SingleUser} /> <ProtectedRoute path="/user/:name" component={SingleUser} authenticated={authenticated} />
<Redirect exact strict from="/groups" to="/groups/" /> <Redirect exact strict from="/groups" to="/groups/" />
<ProtectedRoute exact path="/groups/" component={Groups} authenticated={authenticated} /> <ProtectedRoute exact path="/groups/" component={Groups} authenticated={authenticated} />
<ProtectedRoute authenticated={authenticated} path="/group/:name" component={SingleGroup} /> <ProtectedRoute path="/group/:name" component={SingleGroup} authenticated={authenticated} />
<ProtectedRoute authenticated={authenticated} path="/groups/create" component={CreateGroup} /> <ProtectedRoute path="/groups/create" component={CreateGroup} authenticated={authenticated} />
<ProtectedRoute exact path="/groups/:page" component={Groups} authenticated={authenticated} /> <ProtectedRoute exact path="/groups/:page" component={Groups} authenticated={authenticated} />
<ProtectedRoute path="/admin" component={Admin} authenticated={authenticated} /> <ProtectedRoute path="/admin" component={Admin} authenticated={authenticated} />
<ProtectedRoute path="/me" component={Profile} authenticated={authenticated} /> <ProtectedRoute path="/me" component={Profile} authenticated={authenticated} />
@@ -94,13 +95,14 @@ class Main extends React.Component<Props> {
name="main.route" name="main.route"
renderAll={true} renderAll={true}
props={{ props={{
authenticated,
me, me,
links links,
authenticated
}} }}
/> />
</Switch> </Switch>
</div> </div>
</ErrorBoundary>
); );
} }
} }

View File

@@ -24,14 +24,14 @@
import thunk from "redux-thunk"; import thunk from "redux-thunk";
import logger from "redux-logger"; import logger from "redux-logger";
import { applyMiddleware, combineReducers, compose, createStore } from "redux"; import { AnyAction, applyMiddleware, combineReducers, compose, createStore } from "redux";
import users from "./users/modules/users"; import users from "./users/modules/users";
import repos from "./repos/modules/repos"; import repos from "./repos/modules/repos";
import repositoryTypes from "./repos/modules/repositoryTypes"; import repositoryTypes from "./repos/modules/repositoryTypes";
import changesets from "./repos/modules/changesets"; import changesets from "./repos/modules/changesets";
import sources from "./repos/sources/modules/sources"; import sources from "./repos/sources/modules/sources";
import groups from "./groups/modules/groups"; import groups from "./groups/modules/groups";
import auth from "./modules/auth"; import auth, { isAuthenticated } from "./modules/auth";
import pending from "./modules/pending"; import pending from "./modules/pending";
import failure from "./modules/failure"; import failure from "./modules/failure";
import permissions from "./repos/permissions/modules/permissions"; import permissions from "./repos/permissions/modules/permissions";
@@ -40,13 +40,16 @@ import roles from "./admin/roles/modules/roles";
import namespaceStrategies from "./admin/modules/namespaceStrategies"; import namespaceStrategies from "./admin/modules/namespaceStrategies";
import indexResources from "./modules/indexResource"; import indexResources from "./modules/indexResource";
import plugins from "./admin/plugins/modules/plugins"; import plugins from "./admin/plugins/modules/plugins";
import { apiClient, UnauthorizedError } from "@scm-manager/ui-components";
import branches from "./repos/branches/modules/branches"; import branches from "./repos/branches/modules/branches";
const EMPTY_STATE = {} as any;
function createReduxStore() { function createReduxStore() {
// @ts-ignore __REDUX_DEVTOOLS_EXTENSION_COMPOSE__ is defined by react dev tools
const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose; const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
const reducer = combineReducers({ const appReducer = combineReducers({
pending, pending,
failure, failure,
indexResources, indexResources,
@@ -65,7 +68,39 @@ function createReduxStore() {
plugins plugins
}); });
return createStore(reducer, composeEnhancers(applyMiddleware(thunk, logger))); // We assume that an UnauthorizedError means that the access token is expired.
// If the token is expired we want to show an error with the login link.
// This error should be displayed with the state (e.g. navigation) of the previous logged in user.
// But if the user navigates away, we want to reset the state to an anonymous one.
const reducer = (state: any, action: AnyAction) => {
// Reset the state if the token is expired and a new action is dispatched (e.g. navigation).
// We exclude failures, because the fetch which had triggered the unauthorized error
// will likely end with an failure action.
if (state.tokenExpired && !action.type.includes("FAILURE")) {
// reset state by passing an empty state down to the app reducer
// we do not use the captured action, because the data is derived from the old state
return appReducer(EMPTY_STATE, { type: "_" });
}
// If the user is authenticated and response is an unauthorized error,
// we assume that the token is expired.
if (action.type === "API_CLIENT_UNAUTHORIZED" && isAuthenticated(state)) {
return { ...state, tokenExpired: true };
}
// Keep the tokenExpired after calling appReducer,
// this is required because the appReducer would remove any unknown property.
return { ...appReducer(state, action), tokenExpired: state.tokenExpired };
};
const store = createStore(reducer, EMPTY_STATE, composeEnhancers(applyMiddleware(thunk, logger)));
apiClient.onError(error => {
if (error instanceof UnauthorizedError) {
store.dispatch({ type: "API_CLIENT_UNAUTHORIZED", error });
}
});
return store;
} }
export default createReduxStore; export default createReduxStore;

View File

@@ -27,11 +27,10 @@ import { compose } from "redux";
import { WithTranslation, withTranslation } from "react-i18next"; import { WithTranslation, withTranslation } from "react-i18next";
import { History } from "history"; import { History } from "history";
import { DisplayedUser, Group } from "@scm-manager/ui-types"; import { DisplayedUser, Group } from "@scm-manager/ui-types";
import { Page } from "@scm-manager/ui-components"; import { apiClient, Page } from "@scm-manager/ui-components";
import { getGroupsLink, getUserAutoCompleteLink } from "../../modules/indexResource"; import { getUserAutoCompleteLink, mustGetGroupsLink } from "../../modules/indexResource";
import { createGroup, createGroupReset, getCreateGroupFailure, isCreateGroupPending } from "../modules/groups"; import { createGroup, createGroupReset, getCreateGroupFailure, isCreateGroupPending } from "../modules/groups";
import GroupForm from "../components/GroupForm"; import GroupForm from "../components/GroupForm";
import { apiClient } from "@scm-manager/ui-components";
type Props = WithTranslation & { type Props = WithTranslation & {
createGroup: (link: string, group: Group, callback?: () => void) => void; createGroup: (link: string, group: Group, callback?: () => void) => void;
@@ -97,7 +96,7 @@ const mapDispatchToProps = (dispatch: any) => {
const mapStateToProps = (state: any) => { const mapStateToProps = (state: any) => {
const loading = isCreateGroupPending(state); const loading = isCreateGroupPending(state);
const error = getCreateGroupFailure(state); const error = getCreateGroupFailure(state);
const createLink = getGroupsLink(state); const createLink = mustGetGroupsLink(state);
const autocompleteLink = getUserAutoCompleteLink(state); const autocompleteLink = getUserAutoCompleteLink(state);
return { return {
createLink, createLink,

View File

@@ -35,7 +35,7 @@ import {
PageActions, PageActions,
urls urls
} from "@scm-manager/ui-components"; } from "@scm-manager/ui-components";
import { getGroupsLink } from "../../modules/indexResource"; import { mustGetGroupsLink } from "../../modules/indexResource";
import { import {
fetchGroupsByPage, fetchGroupsByPage,
getFetchGroupsFailure, getFetchGroupsFailure,
@@ -128,7 +128,7 @@ const mapStateToProps = (state: any, ownProps: Props) => {
const page = urls.getPageFromMatch(match); const page = urls.getPageFromMatch(match);
const canAddGroups = isPermittedToCreateGroups(state); const canAddGroups = isPermittedToCreateGroups(state);
const list = selectListAsCollection(state); const list = selectListAsCollection(state);
const groupLink = getGroupsLink(state); const groupLink = mustGetGroupsLink(state);
return { return {
groups, groups,

View File

@@ -39,7 +39,7 @@ import {
SubNavigation, SubNavigation,
StateMenuContextProvider StateMenuContextProvider
} from "@scm-manager/ui-components"; } from "@scm-manager/ui-components";
import { getGroupsLink } from "../../modules/indexResource"; import { getGroupsLink, mustGetGroupsLink } from "../../modules/indexResource";
import { fetchGroupByName, getFetchGroupFailure, getGroupByName, isFetchGroupPending } from "../modules/groups"; import { fetchGroupByName, getFetchGroupFailure, getGroupByName, isFetchGroupPending } from "../modules/groups";
import { Details } from "./../components/table"; import { Details } from "./../components/table";
import { EditGroupNavLink, SetPermissionsNavLink } from "./../components/navLinks"; import { EditGroupNavLink, SetPermissionsNavLink } from "./../components/navLinks";
@@ -138,7 +138,7 @@ const mapStateToProps = (state: any, ownProps: Props) => {
const group = getGroupByName(state, name); const group = getGroupByName(state, name);
const loading = isFetchGroupPending(state, name); const loading = isFetchGroupPending(state, name);
const error = getFetchGroupFailure(state, name); const error = getFetchGroupFailure(state, name);
const groupLink = getGroupsLink(state); const groupLink = mustGetGroupsLink(state);
return { return {
name, name,

View File

@@ -117,7 +117,7 @@ const indexResourcesAuthenticated = {
describe("index resource", () => { describe("index resource", () => {
describe("fetch index resource", () => { describe("fetch index resource", () => {
const index_url = "/api/v2/"; const indexUrl = "/api/v2/";
const mockStore = configureMockStore([thunk]); const mockStore = configureMockStore([thunk]);
afterEach(() => { afterEach(() => {
@@ -126,7 +126,7 @@ describe("index resource", () => {
}); });
it("should successfully fetch index resources when unauthenticated", () => { it("should successfully fetch index resources when unauthenticated", () => {
fetchMock.getOnce(index_url, indexResourcesUnauthenticated); fetchMock.getOnce(indexUrl, indexResourcesUnauthenticated);
const expectedActions = [ const expectedActions = [
{ {
@@ -145,7 +145,7 @@ describe("index resource", () => {
}); });
it("should successfully fetch index resources when authenticated", () => { it("should successfully fetch index resources when authenticated", () => {
fetchMock.getOnce(index_url, indexResourcesAuthenticated); fetchMock.getOnce(indexUrl, indexResourcesAuthenticated);
const expectedActions = [ const expectedActions = [
{ {
@@ -164,7 +164,7 @@ describe("index resource", () => {
}); });
it("should dispatch FETCH_INDEX_RESOURCES_FAILURE if request fails", () => { it("should dispatch FETCH_INDEX_RESOURCES_FAILURE if request fails", () => {
fetchMock.getOnce(index_url, { fetchMock.getOnce(indexUrl, {
status: 500 status: 500
}); });
@@ -176,6 +176,50 @@ describe("index resource", () => {
expect(actions[1].payload).toBeDefined(); expect(actions[1].payload).toBeDefined();
}); });
}); });
it("should retry to fetch index resource on unauthorized error", () => {
fetchMock.getOnce(indexUrl, { status: 401 }).getOnce(indexUrl, indexResourcesAuthenticated, {
overwriteRoutes: false
});
const expectedActions = [
{
type: FETCH_INDEXRESOURCES_PENDING
},
{
type: FETCH_INDEXRESOURCES_SUCCESS,
payload: indexResourcesAuthenticated
}
];
const store = mockStore({});
return store.dispatch(fetchIndexResources()).then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
it("should only retry to fetch index resource on unauthorized error once", () => {
fetchMock
.getOnce(indexUrl, { status: 401 })
.getOnce(
indexUrl,
{ status: 401 },
{
overwriteRoutes: false
}
)
.getOnce(indexUrl, indexResourcesAuthenticated, {
overwriteRoutes: false
});
const store = mockStore({});
return store.dispatch(fetchIndexResources()).then(() => {
const actions = store.getActions();
expect(actions[0].type).toEqual(FETCH_INDEXRESOURCES_PENDING);
expect(actions[1].type).toEqual(FETCH_INDEXRESOURCES_FAILURE);
expect(actions[1].payload).toBeDefined();
});
});
}); });
describe("index resources reducer", () => { describe("index resources reducer", () => {

View File

@@ -24,7 +24,7 @@
import * as types from "./types"; import * as types from "./types";
import { apiClient } from "@scm-manager/ui-components"; import { apiClient, MissingLinkError, UnauthorizedError } from "@scm-manager/ui-components";
import { Action, IndexResources, Link } from "@scm-manager/ui-types"; import { Action, IndexResources, Link } from "@scm-manager/ui-types";
import { isPending } from "./pending"; import { isPending } from "./pending";
import { getFailure } from "./failure"; import { getFailure } from "./failure";
@@ -46,14 +46,27 @@ export const callFetchIndexResources = (): Promise<IndexResources> => {
export function fetchIndexResources() { export function fetchIndexResources() {
return function(dispatch: any) { return function(dispatch: any) {
/*
* The index resource returns a 401 if the access token expired.
* This only happens once because the error response automatically invalidates the cookie.
* In this event, we have to try the request once again.
*/
let shouldRetry = true;
dispatch(fetchIndexResourcesPending()); dispatch(fetchIndexResourcesPending());
return callFetchIndexResources() const run = (): Promise<any> =>
callFetchIndexResources()
.then(resources => { .then(resources => {
dispatch(fetchIndexResourcesSuccess(resources)); dispatch(fetchIndexResourcesSuccess(resources));
}) })
.catch(err => { .catch(err => {
if (err instanceof UnauthorizedError && shouldRetry) {
shouldRetry = false;
return run();
} else {
dispatch(fetchIndexResourcesFailure(err)); dispatch(fetchIndexResourcesFailure(err));
}
}); });
return run();
}; };
} }
@@ -123,6 +136,14 @@ export function getLink(state: object, name: string) {
} }
} }
export function mustGetLink(state: object, name: string) {
const link = getLink(state, name);
if (link) {
return link;
}
throw new MissingLinkError(`No link in state for link name: '${name}'`);
}
export function getLinkCollection(state: object, name: string): Link[] { export function getLinkCollection(state: object, name: string): Link[] {
// @ts-ignore Right types not available // @ts-ignore Right types not available
if (state.indexResources.links && state.indexResources.links[name]) { if (state.indexResources.links && state.indexResources.links[name]) {
@@ -145,10 +166,18 @@ export function getAvailablePluginsLink(state: object) {
return getLink(state, "availablePlugins"); return getLink(state, "availablePlugins");
} }
export function mustGetAvailablePluginsLink(state: object) {
return mustGetLink(state, "availablePlugins");
}
export function getInstalledPluginsLink(state: object) { export function getInstalledPluginsLink(state: object) {
return getLink(state, "installedPlugins"); return getLink(state, "installedPlugins");
} }
export function mustGetInstalledPluginsLink(state: object) {
return mustGetLink(state, "installedPlugins");
}
export function getPendingPluginsLink(state: object) { export function getPendingPluginsLink(state: object) {
return getLink(state, "pendingPlugins"); return getLink(state, "pendingPlugins");
} }
@@ -169,10 +198,18 @@ export function getUsersLink(state: object) {
return getLink(state, "users"); return getLink(state, "users");
} }
export function mustGetUsersLink(state: object) {
return mustGetLink(state, "users");
}
export function getRepositoryRolesLink(state: object) { export function getRepositoryRolesLink(state: object) {
return getLink(state, "repositoryRoles"); return getLink(state, "repositoryRoles");
} }
export function mustGetRepositoryRolesLink(state: object) {
return mustGetLink(state, "repositoryRoles");
}
export function getRepositoryVerbsLink(state: object) { export function getRepositoryVerbsLink(state: object) {
return getLink(state, "repositoryVerbs"); return getLink(state, "repositoryVerbs");
} }
@@ -181,10 +218,18 @@ export function getGroupsLink(state: object) {
return getLink(state, "groups"); return getLink(state, "groups");
} }
export function mustGetGroupsLink(state: object) {
return mustGetLink(state, "groups");
}
export function getConfigLink(state: object) { export function getConfigLink(state: object) {
return getLink(state, "config"); return getLink(state, "config");
} }
export function mustGetConfigLink(state: object) {
return mustGetLink(state, "config");
}
export function getRepositoriesLink(state: object) { export function getRepositoriesLink(state: object) {
return getLink(state, "repositories"); return getLink(state, "repositories");
} }

View File

@@ -26,7 +26,7 @@ import { Trans, useTranslation, WithTranslation, withTranslation } from "react-i
import classNames from "classnames"; import classNames from "classnames";
import styled from "styled-components"; import styled from "styled-components";
import { ExtensionPoint } from "@scm-manager/ui-extensions"; import { ExtensionPoint } from "@scm-manager/ui-extensions";
import { Changeset, ParentChangeset, Repository, Tag } from "@scm-manager/ui-types"; import { Changeset, ParentChangeset, Repository } from "@scm-manager/ui-types";
import { import {
AvatarImage, AvatarImage,
AvatarWrapper, AvatarWrapper,
@@ -36,14 +36,15 @@ import {
ChangesetDiff, ChangesetDiff,
ChangesetId, ChangesetId,
changesets, changesets,
ChangesetTag, ChangesetTags,
DateFromNow, DateFromNow,
FileControlFactory,
Icon, Icon,
Level Level,
SignatureIcon
} from "@scm-manager/ui-components"; } from "@scm-manager/ui-components";
import ContributorTable from "./ContributorTable"; import ContributorTable from "./ContributorTable";
import { Link as ReactLink } from "react-router-dom"; import { Link as ReactLink } from "react-router-dom";
import { FileControlFactory, SignatureIcon } from "@scm-manager/ui-components";
type Props = WithTranslation & { type Props = WithTranslation & {
changeset: Changeset; changeset: Changeset;
@@ -59,16 +60,6 @@ const RightMarginP = styled.p`
margin-right: 1em; margin-right: 1em;
`; `;
const TagsWrapper = styled.div`
& .tag {
margin-left: 0.25rem;
}
`;
const SignedIcon = styled(SignatureIcon)`
padding-left: 1rem;
`;
const BottomMarginLevel = styled(Level)` const BottomMarginLevel = styled(Level)`
margin-bottom: 1rem !important; margin-bottom: 1rem !important;
`; `;
@@ -224,7 +215,9 @@ class ChangesetDetails extends React.Component<Props, State> {
)} )}
</ChangesetSummary> </ChangesetSummary>
</div> </div>
<div className="media-right">{this.renderTags()}</div> <div className="media-right">
<ChangesetTags changeset={changeset} />
</div>
</article> </article>
<p> <p>
{description.message.split("\n").map((item, key) => { {description.message.split("\n").map((item, key) => {
@@ -264,24 +257,6 @@ class ChangesetDetails extends React.Component<Props, State> {
); );
} }
getTags = () => {
return this.props.changeset._embedded.tags || [];
};
renderTags = () => {
const tags = this.getTags();
if (tags.length > 0) {
return (
<TagsWrapper className="level-item">
{tags.map((tag: Tag) => {
return <ChangesetTag key={tag.name} tag={tag} />;
})}
</TagsWrapper>
);
}
return null;
};
collapseDiffs = () => { collapseDiffs = () => {
this.setState(state => ({ this.setState(state => ({
collapsed: !state.collapsed collapsed: !state.collapsed

View File

@@ -87,7 +87,7 @@ class SourcesView extends React.Component<Props, State> {
const basePath = this.createBasePath(); const basePath = this.createBasePath();
if (contentType.startsWith("image/")) { if (contentType.startsWith("image/")) {
return <ImageViewer file={file} />; return <ImageViewer file={file} />;
} else if (contentType.includes("markdown")) { } else if (contentType.includes("markdown") || (language && language.toLowerCase() === "markdown")) {
return <SwitchableMarkdownViewer file={file} basePath={basePath} />; return <SwitchableMarkdownViewer file={file} basePath={basePath} />;
} else if (language) { } else if (language) {
return <SourcecodeViewer file={file} language={language} />; return <SourcecodeViewer file={file} language={language} />;

View File

@@ -28,7 +28,7 @@ import { WithTranslation, withTranslation } from "react-i18next";
import { History } from "history"; import { History } from "history";
import { User } from "@scm-manager/ui-types"; import { User } from "@scm-manager/ui-types";
import { Page } from "@scm-manager/ui-components"; import { Page } from "@scm-manager/ui-components";
import { getUsersLink } from "../../modules/indexResource"; import { mustGetUsersLink } from "../../modules/indexResource";
import { createUser, createUserReset, getCreateUserFailure, isCreateUserPending } from "../modules/users"; import { createUser, createUserReset, getCreateUserFailure, isCreateUserPending } from "../modules/users";
import UserForm from "../components/UserForm"; import UserForm from "../components/UserForm";
@@ -84,7 +84,7 @@ const mapDispatchToProps = (dispatch: any) => {
const mapStateToProps = (state: any) => { const mapStateToProps = (state: any) => {
const loading = isCreateUserPending(state); const loading = isCreateUserPending(state);
const error = getCreateUserFailure(state); const error = getCreateUserFailure(state);
const usersLink = getUsersLink(state); const usersLink = mustGetUsersLink(state);
return { return {
usersLink, usersLink,
loading, loading,

View File

@@ -43,10 +43,9 @@ import EditUser from "./EditUser";
import { fetchUserByName, getFetchUserFailure, getUserByName, isFetchUserPending } from "../modules/users"; import { fetchUserByName, getFetchUserFailure, getUserByName, isFetchUserPending } from "../modules/users";
import { EditUserNavLink, SetPasswordNavLink, SetPermissionsNavLink, SetPublicKeysNavLink } from "./../components/navLinks"; import { EditUserNavLink, SetPasswordNavLink, SetPermissionsNavLink, SetPublicKeysNavLink } from "./../components/navLinks";
import { WithTranslation, withTranslation } from "react-i18next"; import { WithTranslation, withTranslation } from "react-i18next";
import { getUsersLink } from "../../modules/indexResource"; import { mustGetUsersLink } from "../../modules/indexResource";
import SetUserPassword from "../components/SetUserPassword"; import SetUserPassword from "../components/SetUserPassword";
import SetPermissions from "../../permissions/components/SetPermissions"; import SetPermissions from "../../permissions/components/SetPermissions";
import AddPublicKey from "../components/publicKeys/AddPublicKey";
import SetPublicKeys from "../components/publicKeys/SetPublicKeys"; import SetPublicKeys from "../components/publicKeys/SetPublicKeys";
type Props = RouteComponentProps & type Props = RouteComponentProps &
@@ -148,7 +147,7 @@ const mapStateToProps = (state: any, ownProps: Props) => {
const user = getUserByName(state, name); const user = getUserByName(state, name);
const loading = isFetchUserPending(state, name); const loading = isFetchUserPending(state, name);
const error = getFetchUserFailure(state, name); const error = getFetchUserFailure(state, name);
const usersLink = getUsersLink(state); const usersLink = mustGetUsersLink(state);
return { return {
usersLink, usersLink,
name, name,

View File

@@ -35,7 +35,7 @@ import {
PageActions, PageActions,
urls urls
} from "@scm-manager/ui-components"; } from "@scm-manager/ui-components";
import { getUsersLink } from "../../modules/indexResource"; import { mustGetUsersLink } from "../../modules/indexResource";
import { import {
fetchUsersByPage, fetchUsersByPage,
getFetchUsersFailure, getFetchUsersFailure,
@@ -129,7 +129,7 @@ const mapStateToProps = (state: any, ownProps: Props) => {
const page = urls.getPageFromMatch(match); const page = urls.getPageFromMatch(match);
const canAddUsers = isPermittedToCreateUsers(state); const canAddUsers = isPermittedToCreateUsers(state);
const list = selectListAsCollection(state); const list = selectListAsCollection(state);
const usersLink = getUsersLink(state); const usersLink = mustGetUsersLink(state);
return { return {
users, users,

View File

@@ -315,7 +315,7 @@
<dependency> <dependency>
<groupId>com.github.sdorra</groupId> <groupId>com.github.sdorra</groupId>
<artifactId>spotter-core</artifactId> <artifactId>spotter-core</artifactId>
<version>2.1.2</version> <version>3.0.0</version>
</dependency> </dependency>
<dependency> <dependency>

View File

@@ -0,0 +1,63 @@
/*
* 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;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.slf4j.MDC;
import sonia.scm.api.v2.resources.ErrorDto;
import sonia.scm.web.VndMediaType;
import javax.ws.rs.WebApplicationException;
import javax.ws.rs.core.Response;
import javax.ws.rs.ext.ExceptionMapper;
import javax.ws.rs.ext.Provider;
import java.util.Collections;
@Provider
public class WebApplicationExceptionMapper implements ExceptionMapper<WebApplicationException> {
private static final Logger LOG = LoggerFactory.getLogger(WebApplicationExceptionMapper.class);
private static final String ERROR_CODE = "FVS9JY1T21";
@Override
public Response toResponse(WebApplicationException exception) {
LOG.trace("caught web application exception", exception);
ErrorDto errorDto = new ErrorDto();
errorDto.setMessage(exception.getMessage());
errorDto.setContext(Collections.emptyList());
errorDto.setErrorCode(ERROR_CODE);
errorDto.setTransactionId(MDC.get("transaction_id"));
Response originalResponse = exception.getResponse();
return Response.fromResponse(originalResponse)
.entity(errorDto)
.type(VndMediaType.ERROR_TYPE)
.build();
}
}

View File

@@ -0,0 +1,51 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package sonia.scm.api.v2;
import com.github.sdorra.spotter.ContentType;
import com.github.sdorra.spotter.ContentTypeDetector;
import com.github.sdorra.spotter.Language;
public final class ContentTypeResolver {
private static final ContentTypeDetector PATH_BASED = ContentTypeDetector.builder()
.defaultPathBased().boost(Language.MARKDOWN)
.bestEffortMatch();
private static final ContentTypeDetector PATH_AND_CONTENT_BASED = ContentTypeDetector.builder()
.defaultPathAndContentBased().boost(Language.MARKDOWN)
.bestEffortMatch();
private ContentTypeResolver() {
}
public static ContentType resolve(String path) {
return PATH_BASED.detect(path);
}
public static ContentType resolve(String path, byte[] contentPrefix) {
return PATH_AND_CONTENT_BASED.detect(path, contentPrefix);
}
}

View File

@@ -25,7 +25,6 @@
package sonia.scm.api.v2.resources; package sonia.scm.api.v2.resources;
import com.github.sdorra.spotter.ContentType; import com.github.sdorra.spotter.ContentType;
import com.github.sdorra.spotter.ContentTypes;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Content;
@@ -34,6 +33,7 @@ import io.swagger.v3.oas.annotations.responses.ApiResponse;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import sonia.scm.NotFoundException; import sonia.scm.NotFoundException;
import sonia.scm.api.v2.ContentTypeResolver;
import sonia.scm.repository.NamespaceAndName; import sonia.scm.repository.NamespaceAndName;
import sonia.scm.repository.api.RepositoryService; import sonia.scm.repository.api.RepositoryService;
import sonia.scm.repository.api.RepositoryServiceFactory; import sonia.scm.repository.api.RepositoryServiceFactory;
@@ -204,7 +204,7 @@ public class ContentResource {
} }
private void appendContentHeader(String path, byte[] head, Response.ResponseBuilder responseBuilder) { private void appendContentHeader(String path, byte[] head, Response.ResponseBuilder responseBuilder) {
ContentType contentType = ContentTypes.detect(path, head); ContentType contentType = ContentTypeResolver.resolve(path, head);
responseBuilder.header("Content-Type", contentType.getRaw()); responseBuilder.header("Content-Type", contentType.getRaw());
contentType.getLanguage().ifPresent( contentType.getLanguage().ifPresent(
language -> responseBuilder.header(ProgrammingLanguages.HEADER, ProgrammingLanguages.getValue(language)) language -> responseBuilder.header(ProgrammingLanguages.HEADER, ProgrammingLanguages.getValue(language))

View File

@@ -24,10 +24,10 @@
package sonia.scm.api.v2.resources; package sonia.scm.api.v2.resources;
import com.github.sdorra.spotter.ContentTypes;
import com.github.sdorra.spotter.Language; import com.github.sdorra.spotter.Language;
import com.google.inject.Inject; import com.google.inject.Inject;
import de.otto.edison.hal.Links; import de.otto.edison.hal.Links;
import sonia.scm.api.v2.ContentTypeResolver;
import sonia.scm.repository.Repository; import sonia.scm.repository.Repository;
import sonia.scm.repository.api.DiffFile; import sonia.scm.repository.api.DiffFile;
import sonia.scm.repository.api.DiffLine; import sonia.scm.repository.api.DiffLine;
@@ -120,7 +120,7 @@ class DiffResultToDiffResultDtoMapper {
dto.setOldRevision(file.getOldRevision()); dto.setOldRevision(file.getOldRevision());
Optional<Language> language = ContentTypes.detect(path).getLanguage(); Optional<Language> language = ContentTypeResolver.resolve(path).getLanguage();
language.ifPresent(value -> dto.setLanguage(ProgrammingLanguages.getValue(value))); language.ifPresent(value -> dto.setLanguage(ProgrammingLanguages.getValue(value)));
List<DiffResultDto.HunkDto> hunks = new ArrayList<>(); List<DiffResultDto.HunkDto> hunks = new ArrayList<>();

View File

@@ -40,7 +40,6 @@ import javax.ws.rs.PathParam;
import javax.ws.rs.Produces; import javax.ws.rs.Produces;
import javax.ws.rs.QueryParam; import javax.ws.rs.QueryParam;
import java.io.IOException; import java.io.IOException;
import java.net.URLDecoder;
import static sonia.scm.ContextEntry.ContextBuilder.entity; import static sonia.scm.ContextEntry.ContextBuilder.entity;
import static sonia.scm.NotFoundException.notFound; import static sonia.scm.NotFoundException.notFound;
@@ -88,7 +87,7 @@ public class SourceRootResource {
browseCommand.setPath(path); browseCommand.setPath(path);
browseCommand.setOffset(offset); browseCommand.setOffset(offset);
if (revision != null && !revision.isEmpty()) { if (revision != null && !revision.isEmpty()) {
browseCommand.setRevision(URLDecoder.decode(revision, "UTF-8")); browseCommand.setRevision(revision);
} }
BrowserResult browserResult = browseCommand.getBrowserResult(); BrowserResult browserResult = browseCommand.getBrowserResult();

View File

@@ -31,9 +31,8 @@ import javax.inject.Singleton;
@Singleton @Singleton
public class DefaultRestarter implements Restarter { public class DefaultRestarter implements Restarter {
private final ScmEventBus eventBus;
private ScmEventBus eventBus; private final RestartStrategy strategy;
private RestartStrategy strategy;
@Inject @Inject
public DefaultRestarter() { public DefaultRestarter() {

View File

@@ -61,6 +61,7 @@ import static java.util.Comparator.comparing;
class MigrationWizardServlet extends HttpServlet { class MigrationWizardServlet extends HttpServlet {
private static final Logger LOG = LoggerFactory.getLogger(MigrationWizardServlet.class); private static final Logger LOG = LoggerFactory.getLogger(MigrationWizardServlet.class);
static final String PROPERTY_WAIT_TIME = "sonia.scm.restart-migration.wait";
private final XmlRepositoryV1UpdateStep repositoryV1UpdateStep; private final XmlRepositoryV1UpdateStep repositoryV1UpdateStep;
private final DefaultMigrationStrategyDAO migrationStrategyDao; private final DefaultMigrationStrategyDAO migrationStrategyDao;
@@ -129,7 +130,7 @@ class MigrationWizardServlet extends HttpServlet {
repositoryLineEntries.stream() repositoryLineEntries.stream()
.forEach( .forEach(
entry-> { entry -> {
String id = entry.getId(); String id = entry.getId();
String protocol = entry.getType(); String protocol = entry.getType();
String originalName = entry.getOriginalName(); String originalName = entry.getOriginalName();
@@ -145,7 +146,7 @@ class MigrationWizardServlet extends HttpServlet {
if (restarter.isSupported()) { if (restarter.isSupported()) {
respondWithTemplate(resp, model, "templates/repository-migration-restart.mustache"); respondWithTemplate(resp, model, "templates/repository-migration-restart.mustache");
restarter.restart(MigrationWizardServlet.class, "wrote migration data"); new Thread(this::restart).start();
} else { } else {
respondWithTemplate(resp, model, "templates/repository-migration-manual-restart.mustache"); respondWithTemplate(resp, model, "templates/repository-migration-manual-restart.mustache");
LOG.error("Restarting is not supported on this platform."); LOG.error("Restarting is not supported on this platform.");
@@ -153,6 +154,19 @@ class MigrationWizardServlet extends HttpServlet {
} }
} }
private void restart() {
String wait = System.getProperty(PROPERTY_WAIT_TIME);
if (!Strings.isNullOrEmpty(wait)) {
try {
Thread.sleep(Long.parseLong(wait));
} catch (InterruptedException e) {
LOG.error("error on waiting before restart", e);
Thread.currentThread().interrupt();
}
}
restarter.restart(MigrationWizardServlet.class, "wrote migration data");
}
private List<RepositoryLineEntry> getRepositoryLineEntries() { private List<RepositoryLineEntry> getRepositoryLineEntries() {
List<V1Repository> repositoriesWithoutMigrationStrategies = List<V1Repository> repositoriesWithoutMigrationStrategies =
repositoryV1UpdateStep.getRepositoriesWithoutMigrationStrategies(); repositoryV1UpdateStep.getRepositoriesWithoutMigrationStrategies();

View File

@@ -31,14 +31,14 @@ import com.fasterxml.jackson.databind.node.ObjectNode;
import com.github.legman.Subscribe; import com.github.legman.Subscribe;
import com.google.common.annotations.VisibleForTesting; import com.google.common.annotations.VisibleForTesting;
import com.google.inject.Singleton; import com.google.inject.Singleton;
import lombok.extern.slf4j.Slf4j; import org.slf4j.Logger;
import sonia.scm.NotFoundException; import org.slf4j.LoggerFactory;
import sonia.scm.SCMContext; import sonia.scm.SCMContextProvider;
import sonia.scm.Stage; import sonia.scm.Stage;
import sonia.scm.lifecycle.RestartEvent;
import sonia.scm.cache.Cache; import sonia.scm.cache.Cache;
import sonia.scm.cache.CacheManager; import sonia.scm.cache.CacheManager;
import sonia.scm.filter.WebElement; import sonia.scm.filter.WebElement;
import sonia.scm.lifecycle.RestartEvent;
import sonia.scm.plugin.PluginLoader; import sonia.scm.plugin.PluginLoader;
import javax.inject.Inject; import javax.inject.Inject;
@@ -51,11 +51,6 @@ import java.net.URL;
import java.util.Enumeration; import java.util.Enumeration;
import java.util.Iterator; import java.util.Iterator;
import java.util.Optional; import java.util.Optional;
import java.util.function.BiConsumer;
import java.util.function.Function;
import static sonia.scm.ContextEntry.ContextBuilder.entity;
import static sonia.scm.NotFoundException.notFound;
/** /**
@@ -63,86 +58,90 @@ import static sonia.scm.NotFoundException.notFound;
*/ */
@Singleton @Singleton
@WebElement(value = I18nServlet.PATTERN, regex = true) @WebElement(value = I18nServlet.PATTERN, regex = true)
@Slf4j
public class I18nServlet extends HttpServlet { public class I18nServlet extends HttpServlet {
private static final Logger LOG = LoggerFactory.getLogger(I18nServlet.class);
public static final String PLUGINS_JSON = "plugins.json"; public static final String PLUGINS_JSON = "plugins.json";
public static final String PATTERN = "/locales/[a-z\\-A-Z]*/" + PLUGINS_JSON; public static final String PATTERN = "/locales/[a-z\\-A-Z]*/" + PLUGINS_JSON;
public static final String CACHE_NAME = "sonia.cache.plugins.translations"; public static final String CACHE_NAME = "sonia.cache.plugins.translations";
private final SCMContextProvider context;
private final ClassLoader classLoader; private final ClassLoader classLoader;
private final Cache<String, JsonNode> cache; private final Cache<String, JsonNode> cache;
private static ObjectMapper objectMapper = new ObjectMapper(); private final ObjectMapper objectMapper = new ObjectMapper();
@Inject @Inject
public I18nServlet(PluginLoader pluginLoader, CacheManager cacheManager) { public I18nServlet(SCMContextProvider context, PluginLoader pluginLoader, CacheManager cacheManager) {
this.context = context;
this.classLoader = pluginLoader.getUberClassLoader(); this.classLoader = pluginLoader.getUberClassLoader();
this.cache = cacheManager.getCache(CACHE_NAME); this.cache = cacheManager.getCache(CACHE_NAME);
} }
@Subscribe(async = false) @Subscribe(async = false)
public void handleRestartEvent(RestartEvent event) { public void handleRestartEvent(RestartEvent event) {
log.debug("Clear cache on restart event with reason {}", event.getReason()); LOG.debug("Clear cache on restart event with reason {}", event.getReason());
cache.clear(); cache.clear();
} }
private JsonNode getCollectedJson(String path,
Function<String, Optional<JsonNode>> jsonFileProvider,
BiConsumer<String, JsonNode> createdJsonFileConsumer) {
return Optional.ofNullable(jsonFileProvider.apply(path)
.orElseGet(() -> {
Optional<JsonNode> createdFile = collectJsonFile(path);
createdFile.ifPresent(map -> createdJsonFileConsumer.accept(path, map));
return createdFile.orElse(null);
}
)).orElseThrow(() -> notFound(entity("jsonprovider", path)));
}
@VisibleForTesting @VisibleForTesting
@Override @Override
protected void doGet(HttpServletRequest req, HttpServletResponse response) { protected void doGet(HttpServletRequest request, HttpServletResponse response) {
String path = request.getServletPath();
try {
Optional<JsonNode> json = findJson(path);
if (json.isPresent()) {
write(response, json.get());
} else {
LOG.debug("could not find translation at {}", path);
response.setStatus(HttpServletResponse.SC_NOT_FOUND);
}
} catch (IOException ex) {
LOG.error("Error on getting the translation of the plugins", ex);
response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
}
}
private void write(HttpServletResponse response, JsonNode jsonNode) throws IOException {
response.setCharacterEncoding("UTF-8"); response.setCharacterEncoding("UTF-8");
response.setContentType("application/json"); response.setContentType("application/json");
response.setHeader("Cache-Control", "no-cache"); response.setHeader("Cache-Control", "no-cache");
try (PrintWriter out = response.getWriter()) {
String path = req.getServletPath(); try (PrintWriter writer = response.getWriter()) {
Function<String, Optional<JsonNode>> jsonFileProvider = usedPath -> Optional.empty(); objectMapper.writeValue(writer, jsonNode);
BiConsumer<String, JsonNode> createdJsonFileConsumer = (usedPath, jsonNode) -> log.debug("A json File is created from the path {}", usedPath); }
}
public Optional<JsonNode> findJson(String path) throws IOException {
if (isProductionStage()) { if (isProductionStage()) {
log.debug("In Production Stage get the plugin translations from the cache"); return findJsonCached(path);
jsonFileProvider = usedPath -> Optional.ofNullable(
cache.get(usedPath));
createdJsonFileConsumer = createdJsonFileConsumer
.andThen((usedPath, jsonNode) -> log.debug("Put the created json File in the cache with the key {}", usedPath))
.andThen(cache::put);
} }
objectMapper.writeValue(out, getCollectedJson(path, jsonFileProvider, createdJsonFileConsumer)); return collectJsonFile(path);
} catch (IOException e) {
log.error("Error on getting the translation of the plugins", e);
response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
} catch (NotFoundException e) {
log.error("Plugin translations are not found", e);
response.setStatus(HttpServletResponse.SC_NOT_FOUND);
} }
private Optional<JsonNode> findJsonCached(String path) throws IOException {
JsonNode jsonNode = cache.get(path);
if (jsonNode != null) {
LOG.debug("return json node from cache for path {}", path);
return Optional.of(jsonNode);
}
LOG.debug("collect json for path {}", path);
Optional<JsonNode> collected = collectJsonFile(path);
collected.ifPresent(node -> cache.put(path, node));
return collected;
} }
@VisibleForTesting @VisibleForTesting
protected boolean isProductionStage() { protected boolean isProductionStage() {
return SCMContext.getContext().getStage() == Stage.PRODUCTION; return context.getStage() == Stage.PRODUCTION;
} }
/**
* Return a collected Json File as JsonNode from the given path from all plugins in the class path
*
* @param path the searched resource path
* @return a collected Json File as JsonNode from the given path from all plugins in the class path
*/
@VisibleForTesting @VisibleForTesting
protected Optional<JsonNode> collectJsonFile(String path) { private Optional<JsonNode> collectJsonFile(String path) throws IOException {
log.debug("Collect plugin translations from path {} for every plugin", path); LOG.debug("Collect plugin translations from path {} for every plugin", path);
JsonNode mergedJsonNode = null; JsonNode mergedJsonNode = null;
try {
Enumeration<URL> resources = classLoader.getResources(path.replaceFirst("/", "")); Enumeration<URL> resources = classLoader.getResources(path.replaceFirst("/", ""));
while (resources.hasMoreElements()) { while (resources.hasMoreElements()) {
URL url = resources.nextElement(); URL url = resources.nextElement();
@@ -153,32 +152,15 @@ public class I18nServlet extends HttpServlet {
mergedJsonNode = jsonNode; mergedJsonNode = jsonNode;
} }
} }
} catch (IOException e) {
log.error("Error on loading sources from {}", path, e);
return Optional.empty();
}
return Optional.ofNullable(mergedJsonNode); return Optional.ofNullable(mergedJsonNode);
} }
private JsonNode merge(JsonNode mainNode, JsonNode updateNode) {
/**
* Merge the <code>updateNode</code> into the <code>mainNode</code> and return it.
*
* This is not a deep merge.
*
* @param mainNode the main node
* @param updateNode the update node
* @return the merged mainNode
*/
@VisibleForTesting
protected JsonNode merge(JsonNode mainNode, JsonNode updateNode) {
Iterator<String> fieldNames = updateNode.fieldNames(); Iterator<String> fieldNames = updateNode.fieldNames();
while (fieldNames.hasNext()) { while (fieldNames.hasNext()) {
String fieldName = fieldNames.next(); String fieldName = fieldNames.next();
JsonNode jsonNode = mainNode.get(fieldName); JsonNode jsonNode = mainNode.get(fieldName);
if (jsonNode != null) { if (jsonNode != null) {
mergeNode(updateNode, fieldName, jsonNode); mergeNode(updateNode, fieldName, jsonNode);
} else { } else {

View File

@@ -268,6 +268,10 @@
"BxS5wX2v71": { "BxS5wX2v71": {
"displayName": "Inkorrekter Schlüssel", "displayName": "Inkorrekter Schlüssel",
"description": "Der bereitgestellte Schlüssel ist kein korrekt formartierter öffentlicher Schlüssel." "description": "Der bereitgestellte Schlüssel ist kein korrekt formartierter öffentlicher Schlüssel."
},
"FVS9JY1T21": {
"displayName": "Fehler bei der Anfrage",
"description": "Bei der Anfrage trat ein Fehler auf. Prüfen Sie bitte den Status der HTTP Antwort und die konkrete Meldung."
} }
}, },
"namespaceStrategies": { "namespaceStrategies": {

View File

@@ -268,6 +268,10 @@
"BxS5wX2v71": { "BxS5wX2v71": {
"displayName": "Invalid key", "displayName": "Invalid key",
"description": "The provided key is not a valid public key." "description": "The provided key is not a valid public key."
},
"FVS9JY1T21": {
"displayName": "Error in the request",
"description": "While processing the request there was an error. Please check the http return status and the concrete error message."
} }
}, },
"namespaceStrategies": { "namespaceStrategies": {

View File

@@ -0,0 +1,65 @@
/*
* MIT License
*
* Copyright (c) 2020-present Cloudogu GmbH and Contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
package sonia.scm.api.v2;
import com.github.sdorra.spotter.ContentType;
import com.github.sdorra.spotter.Language;
import org.junit.jupiter.api.Test;
import java.nio.charset.StandardCharsets;
import static org.assertj.core.api.Assertions.assertThat;
class ContentTypeResolverTest {
@Test
void shouldResolveMarkdown() {
String content = String.join("\n",
"% Markdown content",
"% Which does not start with markdown"
);
ContentType contentType = ContentTypeResolver.resolve("somedoc.md", content.getBytes(StandardCharsets.UTF_8));
assertThat(contentType.getLanguage()).contains(Language.MARKDOWN);
}
@Test
void shouldResolveMarkdownWithoutContent() {
ContentType contentType = ContentTypeResolver.resolve("somedoc.md");
assertThat(contentType.getLanguage()).contains(Language.MARKDOWN);
}
@Test
void shouldResolveMarkdownEvenWithDotsInFilename() {
ContentType contentType = ContentTypeResolver.resolve("somedoc.1.1.md");
assertThat(contentType.getLanguage()).contains(Language.MARKDOWN);
}
@Test
void shouldResolveDockerfile() {
ContentType contentType = ContentTypeResolver.resolve("Dockerfile");
assertThat(contentType.getLanguage()).contains(Language.DOCKERFILE);
}
}

View File

@@ -23,7 +23,6 @@
*/ */
package sonia.scm.lifecycle; package sonia.scm.lifecycle;
import com.github.legman.Subscribe;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor; import org.mockito.ArgumentCaptor;
@@ -32,8 +31,6 @@ import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoExtension;
import sonia.scm.event.ScmEventBus; import sonia.scm.event.ScmEventBus;
import javax.swing.*;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
@@ -72,7 +69,7 @@ class DefaultRestarterTest {
@Test @Test
void shouldThrowRestartNotSupportedException() { void shouldThrowRestartNotSupportedException() {
DefaultRestarter restarter = new DefaultRestarter(eventBus,null); DefaultRestarter restarter = new DefaultRestarter(eventBus, null);
assertThrows( assertThrows(
RestartNotSupportedException.class, () -> restarter.restart(DefaultRestarterTest.class, "test") RestartNotSupportedException.class, () -> restarter.restart(DefaultRestarterTest.class, "test")
); );

View File

@@ -24,6 +24,7 @@
package sonia.scm.update; package sonia.scm.update;
import com.google.common.base.Stopwatch;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.extension.ExtendWith;
@@ -37,13 +38,19 @@ import sonia.scm.update.repository.XmlRepositoryV1UpdateStep;
import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse; import javax.servlet.http.HttpServletResponse;
import java.time.Duration;
import java.util.Collections; import java.util.Collections;
import java.util.Map; import java.util.Map;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
import static sonia.scm.update.MigrationWizardServlet.PROPERTY_WAIT_TIME;
@ExtendWith(MockitoExtension.class) @ExtendWith(MockitoExtension.class)
class MigrationWizardServletTest { class MigrationWizardServletTest {
@@ -262,4 +269,58 @@ class MigrationWizardServletTest {
verify(migrationStrategyDao).set("id", "git", "name", MigrationStrategy.COPY, "namespace", "name"); verify(migrationStrategyDao).set("id", "git", "name", MigrationStrategy.COPY, "namespace", "name");
} }
@Test
void shouldRestartAfterValidMigration() throws InterruptedException {
when(updateStep.getRepositoriesWithoutMigrationStrategies()).thenReturn(
Collections.singletonList(new V1Repository("id", "git", "name"))
);
doReturn("namespace").when(request).getParameter("namespace-id");
doReturn("name").when(request).getParameter("name-id");
doReturn("COPY").when(request).getParameter("strategy-id");
final CountDownLatch countDownLatch = new CountDownLatch(1);
when(restarter.isSupported()).thenReturn(true);
doAnswer(ic -> {
countDownLatch.countDown();
return null;
}).when(restarter).restart(any(), any());
servlet.doPost(request, response);
boolean restarted = countDownLatch.await(500L, TimeUnit.MILLISECONDS);
assertThat(restarted).isTrue();
}
@Test
void shouldRestartAfterValidMigrationWithSystemProperty() throws InterruptedException {
System.setProperty(PROPERTY_WAIT_TIME, "100");
Stopwatch stopwatch = Stopwatch.createStarted();
when(updateStep.getRepositoriesWithoutMigrationStrategies()).thenReturn(
Collections.singletonList(new V1Repository("id", "git", "name"))
);
doReturn("namespace").when(request).getParameter("namespace-id");
doReturn("name").when(request).getParameter("name-id");
doReturn("COPY").when(request).getParameter("strategy-id");
final CountDownLatch countDownLatch = new CountDownLatch(1);
when(restarter.isSupported()).thenReturn(true);
doAnswer(ic -> {
stopwatch.stop();
countDownLatch.countDown();
return null;
}).when(restarter).restart(any(), any());
servlet.doPost(request, response);
boolean restarted = countDownLatch.await(750L, TimeUnit.MILLISECONDS);
assertThat(stopwatch.elapsed()).isGreaterThanOrEqualTo(Duration.ofMillis(100L));
assertThat(restarted).isTrue();
System.clearProperty(PROPERTY_WAIT_TIME);
}
} }

View File

@@ -26,44 +26,38 @@ package sonia.scm.web.i18n;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.common.base.Charsets; import com.github.legman.EventBus;
import com.google.common.io.Files;
import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.StringUtils;
import org.assertj.core.util.Lists; import org.junit.jupiter.api.BeforeEach;
import org.junit.Before; import org.junit.jupiter.api.Test;
import org.junit.Rule; import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.Test; import org.junit.jupiter.api.io.TempDir;
import org.junit.rules.TemporaryFolder;
import org.junit.runner.RunWith;
import org.mockito.Mock; import org.mockito.Mock;
import org.mockito.MockSettings; import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.internal.creation.MockSettingsImpl; import sonia.scm.SCMContextProvider;
import org.mockito.invocation.InvocationOnMock; import sonia.scm.Stage;
import org.mockito.junit.MockitoJUnitRunner;
import sonia.scm.lifecycle.RestartEvent;
import sonia.scm.cache.Cache; import sonia.scm.cache.Cache;
import sonia.scm.cache.CacheManager; import sonia.scm.cache.CacheManager;
import sonia.scm.event.ScmEventBus;
import sonia.scm.lifecycle.RestartEventFactory; import sonia.scm.lifecycle.RestartEventFactory;
import sonia.scm.plugin.PluginLoader; import sonia.scm.plugin.PluginLoader;
import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse; import javax.servlet.http.HttpServletResponse;
import java.io.File; import java.io.ByteArrayOutputStream;
import java.io.FileOutputStream;
import java.io.IOException; import java.io.IOException;
import java.io.PrintWriter; import java.io.PrintWriter;
import java.net.URL; import java.net.URL;
import java.nio.charset.Charset; import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.util.Arrays;
import java.util.Collections; import java.util.Collections;
import java.util.Enumeration; import java.util.Enumeration;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.*; import static org.mockito.Mockito.*;
@RunWith(MockitoJUnitRunner.Silent.class) @ExtendWith(MockitoExtension.class)
public class I18nServletTest { class I18nServletTest {
private static final String GIT_PLUGIN_JSON = json( private static final String GIT_PLUGIN_JSON = json(
"{", "{",
@@ -76,6 +70,7 @@ public class I18nServletTest {
"}", "}",
"}" "}"
); );
private static final String HG_PLUGIN_JSON = json( private static final String HG_PLUGIN_JSON = json(
"{", "{",
"'scm-hg-plugin': {", "'scm-hg-plugin': {",
@@ -87,7 +82,8 @@ public class I18nServletTest {
"}", "}",
"}" "}"
); );
private static String SVN_PLUGIN_JSON = json(
private static final String SVN_PLUGIN_JSON = json(
"{", "{",
"'scm-svn-plugin': {", "'scm-svn-plugin': {",
"'information': {", "'information': {",
@@ -101,59 +97,49 @@ public class I18nServletTest {
return String.join("\n", parts ).replaceAll("'", "\""); return String.join("\n", parts ).replaceAll("'", "\"");
} }
@Rule @Mock
public TemporaryFolder temporaryFolder = new TemporaryFolder(); private SCMContextProvider context;
@Mock @Mock
private PluginLoader pluginLoader; private PluginLoader pluginLoader;
@Mock
private ClassLoader classLoader;
@Mock @Mock
private CacheManager cacheManager; private CacheManager cacheManager;
@Mock @Mock
private ClassLoader classLoader; private Cache<String, JsonNode> cache;
private I18nServlet servlet; private I18nServlet servlet;
@Mock
private Cache cache;
private Enumeration<URL> resources;
@Before @BeforeEach
@SuppressWarnings("unchecked") void init() {
public void init() throws IOException {
resources = Collections.enumeration(Lists.newArrayList(
createFileFromString(SVN_PLUGIN_JSON).toURI().toURL(),
createFileFromString(GIT_PLUGIN_JSON).toURI().toURL(),
createFileFromString(HG_PLUGIN_JSON).toURI().toURL()
));
when(pluginLoader.getUberClassLoader()).thenReturn(classLoader); when(pluginLoader.getUberClassLoader()).thenReturn(classLoader);
when(cacheManager.getCache(I18nServlet.CACHE_NAME)).thenReturn(cache); when(cacheManager.<String, JsonNode>getCache(I18nServlet.CACHE_NAME)).thenReturn(cache);
MockSettings settings = new MockSettingsImpl<>(); servlet = new I18nServlet(context, pluginLoader, cacheManager);
settings.useConstructor(pluginLoader, cacheManager);
settings.defaultAnswer(InvocationOnMock::callRealMethod);
servlet = mock(I18nServlet.class, settings);
} }
@Test @Test
public void shouldCleanCacheOnRestartEvent() { void shouldCleanCacheOnRestartEvent() {
ScmEventBus.getInstance().register(servlet); EventBus eventBus = new EventBus("forTestingOnly");
eventBus.register(servlet);
ScmEventBus.getInstance().post(RestartEventFactory.create(I18nServlet.class, "Restart to reload the plugin resources")); eventBus.post(RestartEventFactory.create(I18nServlet.class, "Restart to reload the plugin resources"));
verify(cache).clear(); verify(cache).clear();
} }
@Test @Test
@SuppressWarnings("unchecked") void shouldFailWith404OnMissingResources() throws IOException {
public void shouldFailWith404OnMissingResources() throws IOException {
String path = "/locales/de/plugins.json"; String path = "/locales/de/plugins.json";
HttpServletRequest request = mock(HttpServletRequest.class); HttpServletRequest request = mock(HttpServletRequest.class);
HttpServletResponse response = mock(HttpServletResponse.class); HttpServletResponse response = mock(HttpServletResponse.class);
PrintWriter writer = mock(PrintWriter.class);
when(response.getWriter()).thenReturn(writer);
when(request.getServletPath()).thenReturn(path); when(request.getServletPath()).thenReturn(path);
when(classLoader.getResources("locales/de/plugins.json")).thenThrow(IOException.class); when(classLoader.getResources("locales/de/plugins.json")).thenReturn(
I18nServlet.class.getClassLoader().getResources("something/not/available")
);
servlet.doGet(request, response); servlet.doGet(request, response);
@@ -161,98 +147,96 @@ public class I18nServletTest {
} }
@Test @Test
@SuppressWarnings("unchecked") void shouldFailWith500OnIOException() throws IOException {
public void shouldFailWith500OnIOException() throws IOException { stage(Stage.DEVELOPMENT);
HttpServletRequest request = mock(HttpServletRequest.class); HttpServletRequest request = mock(HttpServletRequest.class);
when(request.getServletPath()).thenReturn("/locales/de/plugins.json");
HttpServletResponse response = mock(HttpServletResponse.class); HttpServletResponse response = mock(HttpServletResponse.class);
doThrow(IOException.class).when(response).getWriter(); when(classLoader.getResources("locales/de/plugins.json")).thenThrow(new IOException("failed"));
servlet.doGet(request, response); servlet.doGet(request, response);
verify(response).setStatus(500); verify(response).setStatus(500);
} }
private void stage(Stage stage) {
when(context.getStage()).thenReturn(stage);
}
@Test @Test
@SuppressWarnings("unchecked") void inDevelopmentStageShouldNotUseCache(@TempDir Path temp) throws IOException {
public void inDevelopmentStageShouldNotUseCache() throws IOException { stage(Stage.DEVELOPMENT);
String path = "/locales/de/plugins.json"; mockResources(temp, "locales/de/plugins.json");
when(servlet.isProductionStage()).thenReturn(false);
HttpServletRequest request = mock(HttpServletRequest.class); HttpServletRequest request = mock(HttpServletRequest.class);
when(request.getServletPath()).thenReturn("/locales/de/plugins.json");
HttpServletResponse response = mock(HttpServletResponse.class); HttpServletResponse response = mock(HttpServletResponse.class);
File file = temporaryFolder.newFile(); String json = doGetString(request, response);
PrintWriter writer = new PrintWriter(new FileOutputStream(file));
when(response.getWriter()).thenReturn(writer);
when(request.getServletPath()).thenReturn(path);
when(classLoader.getResources("locales/de/plugins.json")).thenReturn(resources);
servlet.doGet(request, response);
String json = Files.readLines(file, Charset.defaultCharset()).get(0);
assertJson(json); assertJson(json);
verify(cache, never()).get(any()); verify(cache, never()).get(any());
} }
@Test private String doGetString(HttpServletRequest request, HttpServletResponse response) throws IOException {
@SuppressWarnings("unchecked") ByteArrayOutputStream baos = new ByteArrayOutputStream();
public void inProductionStageShouldUseCache() throws IOException { PrintWriter writer = new PrintWriter(baos);
String path = "/locales/de/plugins.json";
when(servlet.isProductionStage()).thenReturn(true);
HttpServletRequest request = mock(HttpServletRequest.class);
HttpServletResponse response = mock(HttpServletResponse.class);
File file = temporaryFolder.newFile();
PrintWriter writer = new PrintWriter(new FileOutputStream(file));
when(response.getWriter()).thenReturn(writer); when(response.getWriter()).thenReturn(writer);
when(request.getServletPath()).thenReturn(path);
when(classLoader.getResources("locales/de/plugins.json")).thenReturn(resources);
servlet.doGet(request, response); servlet.doGet(request, response);
String json = Files.readLines(file, Charset.defaultCharset()).get(0); writer.flush();
assertJson(json); return baos.toString(StandardCharsets.UTF_8.name());
verify(cache).get(path); }
verify(cache).put(eq(path), any());
private void mockResources(Path directory, String resourcePath) throws IOException {
Enumeration<URL> resources = Collections.enumeration(
Arrays.asList(
toURL(directory, "git.json", GIT_PLUGIN_JSON),
toURL(directory, "hg.json", HG_PLUGIN_JSON),
toURL(directory, "svn.json", SVN_PLUGIN_JSON)
)
);
when(classLoader.getResources(resourcePath)).thenReturn(resources);
}
private URL toURL(Path directory, String name, String content) throws IOException {
Path file = directory.resolve(name);
java.nio.file.Files.write(file, content.getBytes(StandardCharsets.UTF_8));
return file.toUri().toURL();
}
@Test
void shouldGetFromCacheInProductionStage() throws IOException {
String path = "/locales/de/plugins.json";
stage(Stage.PRODUCTION);
HttpServletRequest request = mock(HttpServletRequest.class);
when(request.getServletPath()).thenReturn(path);
HttpServletResponse response = mock(HttpServletResponse.class);
ObjectMapper mapper = new ObjectMapper();
JsonNode jsonNode = mapper.readTree(GIT_PLUGIN_JSON);
when(cache.get(path)).thenReturn(jsonNode);
String json = doGetString(request, response);
assertThat(json).contains("scm-git-plugin").doesNotContain("scm-hg-plugin");
verifyHeaders(response); verifyHeaders(response);
} }
@Test @Test
@SuppressWarnings("unchecked") void shouldStoreToCacheInProductionStage(@TempDir Path temp) throws IOException {
public void inProductionStageShouldGetFromCache() throws IOException {
String path = "/locales/de/plugins.json"; String path = "/locales/de/plugins.json";
when(servlet.isProductionStage()).thenReturn(true); mockResources(temp, "locales/de/plugins.json");
stage(Stage.PRODUCTION);
HttpServletRequest request = mock(HttpServletRequest.class); HttpServletRequest request = mock(HttpServletRequest.class);
HttpServletResponse response = mock(HttpServletResponse.class);
File file = temporaryFolder.newFile();
PrintWriter writer = new PrintWriter(new FileOutputStream(file));
when(response.getWriter()).thenReturn(writer);
when(request.getServletPath()).thenReturn(path); when(request.getServletPath()).thenReturn(path);
when(classLoader.getResources("locales/de/plugins.json")).thenReturn(resources); HttpServletResponse response = mock(HttpServletResponse.class);
ObjectMapper objectMapper = new ObjectMapper();
JsonNode node = objectMapper.readTree(GIT_PLUGIN_JSON);
node = servlet.merge(node, objectMapper.readTree(HG_PLUGIN_JSON));
node = servlet.merge(node, objectMapper.readTree(SVN_PLUGIN_JSON));
when(cache.get(path)).thenReturn(node);
servlet.doGet(request, response); String json = doGetString(request, response);
String json = Files.readLines(file, Charset.defaultCharset()).get(0); verify(cache).put(any(String.class), any(JsonNode.class));
verify(servlet, never()).collectJsonFile(path);
verify(cache, never()).put(eq(path), any());
verify(cache).get(path);
assertJson(json);
verifyHeaders(response); verifyHeaders(response);
} assertJson(json);
@Test
@SuppressWarnings("unchecked")
public void shouldCollectJsonFile() throws IOException {
String path = "locales/de/plugins.json";
when(classLoader.getResources(path)).thenReturn(resources);
Optional<JsonNode> jsonNodeOptional = servlet.collectJsonFile("/" + path);
assertJson(jsonNodeOptional.orElse(null));
} }
private void verifyHeaders(HttpServletResponse response) { private void verifyHeaders(HttpServletResponse response) {
@@ -261,22 +245,11 @@ public class I18nServletTest {
verify(response).setHeader("Cache-Control", "no-cache"); verify(response).setHeader("Cache-Control", "no-cache");
} }
public void assertJson(JsonNode actual) throws IOException { private void assertJson(String actual) {
assertJson(actual.toString());
}
private void assertJson(String actual) throws IOException {
assertThat(actual) assertThat(actual)
.isNotEmpty() .isNotEmpty()
.contains(StringUtils.deleteWhitespace(GIT_PLUGIN_JSON.substring(1, GIT_PLUGIN_JSON.length() - 1))) .contains(StringUtils.deleteWhitespace(GIT_PLUGIN_JSON.substring(1, GIT_PLUGIN_JSON.length() - 1)))
.contains(StringUtils.deleteWhitespace(HG_PLUGIN_JSON.substring(1, HG_PLUGIN_JSON.length() - 1))) .contains(StringUtils.deleteWhitespace(HG_PLUGIN_JSON.substring(1, HG_PLUGIN_JSON.length() - 1)))
.contains(StringUtils.deleteWhitespace(SVN_PLUGIN_JSON.substring(1, SVN_PLUGIN_JSON.length() - 1))); .contains(StringUtils.deleteWhitespace(SVN_PLUGIN_JSON.substring(1, SVN_PLUGIN_JSON.length() - 1)));
} }
private File createFileFromString(String json) throws IOException {
File file = temporaryFolder.newFile();
Files.write(json.getBytes(Charsets.UTF_8), file);
return file;
}
} }

View File

@@ -3397,6 +3397,13 @@
"@types/prop-types" "*" "@types/prop-types" "*"
csstype "^2.2.0" csstype "^2.2.0"
"@types/redux-logger@^3.0.8":
version "3.0.8"
resolved "https://registry.yarnpkg.com/@types/redux-logger/-/redux-logger-3.0.8.tgz#1fb6d26917bb198792bb1cf57feb31cae1532c5d"
integrity sha512-zM+cxiSw6nZtRbxpVp9SE3x/X77Z7e7YAfHD1NkxJyJbAGSXJGF0E9aqajZfPOa/sTYnuwutmlCldveExuCeLw==
dependencies:
redux "^4.0.0"
"@types/sinonjs__fake-timers@^6.0.1": "@types/sinonjs__fake-timers@^6.0.1":
version "6.0.1" version "6.0.1"
resolved "https://registry.yarnpkg.com/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-6.0.1.tgz#681df970358c82836b42f989188d133e218c458e" resolved "https://registry.yarnpkg.com/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-6.0.1.tgz#681df970358c82836b42f989188d133e218c458e"
@@ -4287,6 +4294,11 @@ babel-code-frame@^6.22.0:
esutils "^2.0.2" esutils "^2.0.2"
js-tokens "^3.0.2" js-tokens "^3.0.2"
babel-core@7.0.0-bridge.0:
version "7.0.0-bridge.0"
resolved "https://registry.yarnpkg.com/babel-core/-/babel-core-7.0.0-bridge.0.tgz#95a492ddd90f9b4e9a4a1da14eb335b87b634ece"
integrity sha512-poPX9mZH/5CSanm50Q+1toVci6pv5KSRv/5TWCwtzQS5XEwn40BcCrgIeMFWP9CKKIniKXNxoIOnOq4VVlGXhg==
babel-eslint@^10.0.3: babel-eslint@^10.0.3:
version "10.1.0" version "10.1.0"
resolved "https://registry.yarnpkg.com/babel-eslint/-/babel-eslint-10.1.0.tgz#6968e568a910b78fb3779cdd8b6ac2f479943232" resolved "https://registry.yarnpkg.com/babel-eslint/-/babel-eslint-10.1.0.tgz#6968e568a910b78fb3779cdd8b6ac2f479943232"
@@ -7786,7 +7798,7 @@ fast-levenshtein@^2.0.6, fast-levenshtein@~2.0.6:
resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917" resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917"
integrity sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc= integrity sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=
fault@^1.0.0, fault@^1.0.2: fault@^1.0.0:
version "1.0.4" version "1.0.4"
resolved "https://registry.yarnpkg.com/fault/-/fault-1.0.4.tgz#eafcfc0a6d214fc94601e170df29954a4f842f13" resolved "https://registry.yarnpkg.com/fault/-/fault-1.0.4.tgz#eafcfc0a6d214fc94601e170df29954a4f842f13"
integrity sha512-CJ0HCB5tL5fYTEA7ToAq5+kTwd++Borf1/bifxd9iT70QcXr4MRrO3Llf8Ifs70q+SJcGHFtnIE/Nw6giCtECA== integrity sha512-CJ0HCB5tL5fYTEA7ToAq5+kTwd++Borf1/bifxd9iT70QcXr4MRrO3Llf8Ifs70q+SJcGHFtnIE/Nw6giCtECA==
@@ -8362,7 +8374,7 @@ gitconfiglocal@^1.0.0:
dependencies: dependencies:
ini "^1.3.2" ini "^1.3.2"
gitdiff-parser@^0.1.2: gitdiff-parser@^0.1.2, "gitdiff-parser@https://github.com/scm-manager/gitdiff-parser#617747460280bf4522bb84d217a9064ac8eb6d3d":
version "0.1.2" version "0.1.2"
resolved "https://github.com/scm-manager/gitdiff-parser#617747460280bf4522bb84d217a9064ac8eb6d3d" resolved "https://github.com/scm-manager/gitdiff-parser#617747460280bf4522bb84d217a9064ac8eb6d3d"
@@ -11350,7 +11362,7 @@ lower-case@^2.0.1:
dependencies: dependencies:
tslib "^1.10.0" tslib "^1.10.0"
lowlight@^1.13.0: lowlight@1.13.1, lowlight@^1.13.0, lowlight@~1.11.0:
version "1.13.1" version "1.13.1"
resolved "https://registry.yarnpkg.com/lowlight/-/lowlight-1.13.1.tgz#c4f0e03906ebd23fedf2d258f6ab2f6324cf90eb" resolved "https://registry.yarnpkg.com/lowlight/-/lowlight-1.13.1.tgz#c4f0e03906ebd23fedf2d258f6ab2f6324cf90eb"
integrity sha512-kQ71/T6RksEVz9AlPq07/2m+SU/1kGvt9k39UtvHX760u4SaWakaYH7hYgH5n6sTsCWk4MVYzUzLU59aN5CSmQ== integrity sha512-kQ71/T6RksEVz9AlPq07/2m+SU/1kGvt9k39UtvHX760u4SaWakaYH7hYgH5n6sTsCWk4MVYzUzLU59aN5CSmQ==
@@ -11358,14 +11370,6 @@ lowlight@^1.13.0:
fault "^1.0.0" fault "^1.0.0"
highlight.js "~9.16.0" highlight.js "~9.16.0"
lowlight@~1.11.0:
version "1.11.0"
resolved "https://registry.yarnpkg.com/lowlight/-/lowlight-1.11.0.tgz#1304d83005126d4e8b1dc0f07981e9b689ec2efc"
integrity sha512-xrGGN6XLL7MbTMdPD6NfWPwY43SNkjf/d0mecSx/CW36fUZTjRHEq0/Cdug3TWKtRXLWi7iMl1eP0olYxj/a4A==
dependencies:
fault "^1.0.2"
highlight.js "~9.13.0"
lru-cache@^5.1.1: lru-cache@^5.1.1:
version "5.1.1" version "5.1.1"
resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-5.1.1.tgz#1da27e6710271947695daf6848e847f01d84b920" resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-5.1.1.tgz#1da27e6710271947695daf6848e847f01d84b920"
@@ -11715,20 +11719,20 @@ mini-create-react-context@^0.4.0:
"@babel/runtime" "^7.5.5" "@babel/runtime" "^7.5.5"
tiny-warning "^1.0.3" tiny-warning "^1.0.3"
mini-css-extract-plugin@^0.7.0: mini-css-extract-plugin@^0.10.0:
version "0.7.0" version "0.10.1"
resolved "https://registry.yarnpkg.com/mini-css-extract-plugin/-/mini-css-extract-plugin-0.7.0.tgz#5ba8290fbb4179a43dd27cca444ba150bee743a0" resolved "https://registry.yarnpkg.com/mini-css-extract-plugin/-/mini-css-extract-plugin-0.10.1.tgz#2592c891f965e15750da6a6c0b60740b5b0cb62d"
integrity sha512-RQIw6+7utTYn8DBGsf/LpRgZCJMpZt+kuawJ/fju0KiOL6nAaTBNmCJwS7HtwSCXfS47gCkmtBFS7HdsquhdxQ== integrity sha512-9B10gZixtNjHerADBrMxPXM5G0uL0CRGMcLRV67I8nd1SKbwJrI0okKUzD+PxKsUZ9Dxt8/hPvtzF0DrRnrOyA==
dependencies: dependencies:
loader-utils "^1.1.0" loader-utils "^1.1.0"
normalize-url "1.9.1" normalize-url "1.9.1"
schema-utils "^1.0.0" schema-utils "^1.0.0"
webpack-sources "^1.1.0" webpack-sources "^1.1.0"
mini-css-extract-plugin@^0.9.0: mini-css-extract-plugin@^0.7.0:
version "0.9.0" version "0.7.0"
resolved "https://registry.yarnpkg.com/mini-css-extract-plugin/-/mini-css-extract-plugin-0.9.0.tgz#47f2cf07aa165ab35733b1fc97d4c46c0564339e" resolved "https://registry.yarnpkg.com/mini-css-extract-plugin/-/mini-css-extract-plugin-0.7.0.tgz#5ba8290fbb4179a43dd27cca444ba150bee743a0"
integrity sha512-lp3GeY7ygcgAmVIcRPBVhIkf8Us7FZjA+ILpal44qLdSu11wmjKQ3d9k15lfD7pO4esu9eUIAW7qiYIBppv40A== integrity sha512-RQIw6+7utTYn8DBGsf/LpRgZCJMpZt+kuawJ/fju0KiOL6nAaTBNmCJwS7HtwSCXfS47gCkmtBFS7HdsquhdxQ==
dependencies: dependencies:
loader-utils "^1.1.0" loader-utils "^1.1.0"
normalize-url "1.9.1" normalize-url "1.9.1"