fix review findings

This commit is contained in:
Eduard Heimbuch
2020-08-11 10:34:29 +02:00
parent a46d8c4749
commit c1cfff603b
63 changed files with 578 additions and 494 deletions

View File

@@ -21,7 +21,7 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE. * SOFTWARE.
*/ */
package sonia.scm.config; package sonia.scm.config;
@@ -312,10 +312,23 @@ public class ScmConfiguration implements Configuration {
return realmDescription; return realmDescription;
} }
/**
* since 2.4.0
* @return anonymousMode
*/
public AnonymousMode getAnonymousMode() { public AnonymousMode getAnonymousMode() {
return anonymousMode; return anonymousMode;
} }
/**
* @deprecated since 2.4.0
* @use {@link ScmConfiguration#getAnonymousMode} instead
*/
@Deprecated
public boolean isAnonymousAccessEnabled() {
return anonymousMode != AnonymousMode.OFF;
}
public boolean isDisableGroupingGrid() { public boolean isDisableGroupingGrid() {
return disableGroupingGrid; return disableGroupingGrid;
} }
@@ -361,6 +374,23 @@ public class ScmConfiguration implements Configuration {
return skipFailedAuthenticators; return skipFailedAuthenticators;
} }
/**
* @deprecated since 2.4.0
* @use {@link ScmConfiguration#setAnonymousMode(AnonymousMode)} instead
*/
@Deprecated
public void setAnonymousAccessEnabled(boolean anonymousAccessEnabled) {
if (anonymousAccessEnabled) {
this.anonymousMode = AnonymousMode.PROTOCOL_ONLY;
} else {
this.anonymousMode = AnonymousMode.OFF;
}
}
/**
* since 2.4.0
* @param mode
*/
public void setAnonymousMode(AnonymousMode mode) { public void setAnonymousMode(AnonymousMode mode) {
this.anonymousMode = mode; this.anonymousMode = mode;
} }

View File

@@ -24,6 +24,10 @@
package sonia.scm.security; package sonia.scm.security;
/**
* Available modes for anonymous access
* @since 2.4.0
*/
public enum AnonymousMode { public enum AnonymousMode {
FULL, PROTOCOL_ONLY, OFF FULL, PROTOCOL_ONLY, OFF
} }

View File

@@ -22,42 +22,34 @@
* SOFTWARE. * SOFTWARE.
*/ */
package sonia.scm.web.filter; package sonia.scm.security;
import java.time.Instant; import org.apache.shiro.authc.AuthenticationException;
import java.util.Base64;
public class JwtValidator { /**
* This exception is thrown if the session token is expired
* @since 2.4.0
*/
@SuppressWarnings("squid:MaximumInheritanceDepth") // exceptions have a deep inheritance depth themselves; therefore we accept this here
public class TokenExpiredException extends AuthenticationException {
private JwtValidator() { /**
* Constructs a new SessionExpiredException.
*
* @param message the reason for the exception
*/
public TokenExpiredException(String message) {
super(message);
} }
/** /**
* Checks if the jwt token is expired. * Constructs a new SessionExpiredException.
* *
* @return {@code true}if the token is expired * @param message the reason for the exception
* @param cause the underlying Throwable that caused this exception to be thrown.
*/ */
public static boolean isJwtTokenExpired(String raw) { public TokenExpiredException(String message, Throwable cause) {
super(message, cause);
boolean expired = false;
String[] parts = raw.split("\\.");
if (parts.length > 1) {
Base64.Decoder decoder = Base64.getUrlDecoder();
String payload = new String(decoder.decode(parts[1]));
String[] splitJwt = payload.split(",");
for (String entry : splitJwt) {
if (entry.contains("\"exp\"")) {
long expirationTime = Long.parseLong(entry.replaceAll("[^\\d.]", ""));
if (Instant.now().isAfter(Instant.ofEpochSecond(expirationTime))) {
expired = true;
}
}
}
}
return expired;
} }
} }

View File

@@ -38,7 +38,7 @@ import sonia.scm.SCMContext;
import sonia.scm.config.ScmConfiguration; import sonia.scm.config.ScmConfiguration;
import sonia.scm.security.AnonymousMode; import sonia.scm.security.AnonymousMode;
import sonia.scm.security.AnonymousToken; import sonia.scm.security.AnonymousToken;
import sonia.scm.security.BearerToken; import sonia.scm.security.TokenExpiredException;
import sonia.scm.util.HttpUtil; import sonia.scm.util.HttpUtil;
import sonia.scm.util.Util; import sonia.scm.util.Util;
import sonia.scm.web.WebTokenGenerator; import sonia.scm.web.WebTokenGenerator;
@@ -50,8 +50,6 @@ import javax.servlet.http.HttpServletResponse;
import java.io.IOException; import java.io.IOException;
import java.util.Set; import java.util.Set;
import static sonia.scm.web.filter.JwtValidator.isJwtTokenExpired;
/** /**
* Handles authentication, if a one of the {@link WebTokenGenerator} returns * Handles authentication, if a one of the {@link WebTokenGenerator} returns
* an {@link AuthenticationToken}. * an {@link AuthenticationToken}.
@@ -62,13 +60,12 @@ import static sonia.scm.web.filter.JwtValidator.isJwtTokenExpired;
@Singleton @Singleton
public class AuthenticationFilter extends HttpFilter { public class AuthenticationFilter extends HttpFilter {
private static final Logger logger = LoggerFactory.getLogger(AuthenticationFilter.class); private static final Logger logger = LoggerFactory.getLogger(AuthenticationFilter.class);
/** /**
* marker for failed authentication * marker for failed authentication
*/ */
private static final String ATTRIBUTE_FAILED_AUTH = "sonia.scm.auth.failed"; private static final String ATTRIBUTE_FAILED_AUTH = "sonia.scm.auth.failed";
private static final String HEADER_AUTHORIZATION = "Authorization";
private final Set<WebTokenGenerator> tokenGenerators; private final Set<WebTokenGenerator> tokenGenerators;
protected ScmConfiguration configuration; protected ScmConfiguration configuration;
@@ -102,9 +99,7 @@ public class AuthenticationFilter extends HttpFilter {
AuthenticationToken token = createToken(request); AuthenticationToken token = createToken(request);
if (token instanceof BearerToken && isJwtTokenExpired(((BearerToken) token).getCredentials())) { if (token != null) {
handleUnauthorized(request, response, chain);
} else if (token != null) {
logger.trace( logger.trace(
"found authentication token on request, start authentication"); "found authentication token on request, start authentication");
handleAuthentication(request, response, chain, subject, token); handleAuthentication(request, response, chain, subject, token);
@@ -213,6 +208,13 @@ public class AuthenticationFilter extends HttpFilter {
try { try {
subject.login(token); subject.login(token);
processChain(request, response, chain, subject); processChain(request, response, chain, subject);
} catch (TokenExpiredException ex) {
if (logger.isTraceEnabled()) {
logger.trace("{} expired", token.getClass(), ex);
} else {
logger.debug("{} expired", token.getClass());
}
handleUnauthorized(request, response, chain);
} 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

@@ -1,65 +0,0 @@
/*
* 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.web.filter;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static sonia.scm.web.filter.JwtValidator.isJwtTokenExpired;
class JwtValidatorTest {
@Test
void shouldReturnFalseIfNotJwtToken() {
String raw = "scmadmin.scmadmin.scmadmin";
boolean result = isJwtTokenExpired(raw);
assertThat(result).isFalse();
}
@Test
void shouldValidateExpiredJwtToken() {
String raw = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJzY21hZG1pbiIsImp0aSI6IjNqUzZ4TzMwUzEiLCJpYXQiOjE1OTY3ODA5Mjgs"
+ "ImV4cCI6MTU5Njc0NDUyOCwic2NtLW1hbmFnZXIucmVmcmVzaEV4cGlyYXRpb24iOjE1OTY4MjQxMjg2MDIsInNjbS1tYW5hZ2VyLnB"
+ "hcmVudFRva2VuSWQiOiIzalM2eE8zMFMxIn0.utZLmzGZr-M6MP19yrd0dgLPkJ0u1xojwHKQi36_QAs";
boolean result = isJwtTokenExpired(raw);
assertThat(result).isTrue();
}
@Test
void shouldValidateNotExpiredJwtToken() {
String raw = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJzY21hZG1pbiIsImp0aSI6IjNqUzZ4TzMwUzEiLCJpYXQiOjE1OTY3ODA5Mjgs"
+ "ImV4cCI6NTU5Njc0NDUyOCwic2NtLW1hbmFnZXIucmVmcmVzaEV4cGlyYXRpb24iOjE1OTY4MjQxMjg2MDIsInNjbS1tYW5hZ2VyLnB"
+ "hcmVudFRva2VuSWQiOiIzalM2eE8zMFMxIn0.cvK4E58734T2PqtEqqhYCInnX_uryUkMhRNX-94riY0";
boolean result = isJwtTokenExpired(raw);
assertThat(result).isFalse();
}
}

View File

@@ -1 +1,3 @@
{} {
"baseUrl": "http://localhost:8081/scm"
}

View File

@@ -1,5 +0,0 @@
{
"name": "Using fixtures to represent data",
"email": "hello@cypress.io",
"body": "Fixtures are a great way to mock data for responses to routes"
}

View File

@@ -23,145 +23,115 @@
*/ */
describe("With Anonymous mode disabled", () => { describe("With Anonymous mode disabled", () => {
it("Should show login page without primary navigation", () => { before("Disable anonymous access", () => {
loginUser("scmadmin", "scmadmin"); cy.login("scmadmin", "scmadmin");
setAnonymousMode("OFF"); setAnonymousMode("OFF");
cy.byTestId("primary-navigation-logout").click();
});
cy.get("li") it("Should show login page without primary navigation", () => {
.contains("Logout") cy.byTestId("login-button");
.click(); cy.containsNotByTestId("div", "primary-navigation-login");
cy.contains("Please login to proceed"); cy.containsNotByTestId("div", "primary-navigation-repositories");
cy.get("div").not("Login");
cy.get("div").not("Repositories");
}); });
it("Should redirect after login", () => { it("Should redirect after login", () => {
loginUser("scmadmin", "scmadmin"); cy.login("scmadmin", "scmadmin");
cy.visit("http://localhost:8081/scm/me"); cy.visit("/me");
cy.contains("Profile"); cy.byTestId("footer-user-profile");
cy.get("li") cy.byTestId("primary-navigation-logout").click();
.contains("Logout")
.click();
}); });
}); });
describe("With Anonymous mode protocol only enabled", () => { describe("With Anonymous mode protocol only enabled", () => {
it("Should show login page without primary navigation", () => { before("Set anonymous mode to protocol only", () => {
loginUser("scmadmin", "scmadmin"); cy.login("scmadmin", "scmadmin");
setAnonymousMode("PROTOCOL_ONLY"); setAnonymousMode("PROTOCOL_ONLY");
cy.byTestId("primary-navigation-logout").click();
});
// Give anonymous user permissions it("Should show login page without primary navigation", () => {
cy.get("li") cy.visit("/repos/");
.contains("Users") cy.byTestId("login-button");
.click(); cy.containsNotByTestId("div", "primary-navigation-login");
cy.get("td") cy.containsNotByTestId("div", "primary-navigation-repositories");
.contains("_anonymous") });
.click();
cy.get("a")
.contains("Settings")
.click();
cy.get("li")
.contains("Permissions")
.click();
cy.get("label")
.contains("Read all repositories")
.click();
cy.get("button")
.contains("Set permissions")
.click();
cy.get("li") after("Disable anonymous access", () => {
.contains("Logout") cy.login("scmadmin", "scmadmin");
.click(); setAnonymousMode("OFF");
cy.visit("http://localhost:8081/scm/repos/"); cy.byTestId("primary-navigation-logout").click();
cy.contains("Please login to proceed");
cy.get("div").not("Login");
cy.get("div").not("Repositories");
}); });
}); });
describe("With Anonymous mode fully enabled", () => { describe("With Anonymous mode fully enabled", () => {
it("Should show repositories overview with Login button in primary navigation", () => { before("Set anonymous mode to full", () => {
loginUser("scmadmin", "scmadmin"); cy.login("scmadmin", "scmadmin");
setAnonymousMode("FULL"); setAnonymousMode("FULL");
cy.get("li") // Give anonymous user permissions
.contains("Logout") cy.byTestId("primary-navigation-users").click();
.click(); cy.byTestId("_anonymous").click();
cy.visit("http://localhost:8081/scm/repos/"); cy.byTestId("user-settings-link").click();
cy.contains("Overview of available repositories"); cy.byTestId("user-permissions-link").click();
cy.contains("SCM Anonymous"); cy.byTestId("read-all-repositories").click();
cy.get("ul").contains("Login"); cy.byTestId("set-permissions-button").click();
cy.byTestId("primary-navigation-logout").click();
});
it("Should show repositories overview with Login button in primary navigation", () => {
cy.visit("/repos/");
cy.byTestId("repository-overview-filter");
cy.byTestId("SCM-Anonymous");
cy.byTestId("primary-navigation-login");
}); });
it("Should show login page on url", () => { it("Should show login page on url", () => {
cy.visit("http://localhost:8081/scm/login/"); cy.visit("/login/");
cy.byTestId("login-button");
}); });
it("Should show login page on link click", () => { it("Should show login page on link click", () => {
cy.visit("http://localhost:8081/scm/repos/"); cy.visit("/repos/");
cy.contains("Overview of available repositories"); cy.byTestId("repository-overview-filter");
cy.contains("Login").click(); cy.byTestId("primary-navigation-login").click();
cy.contains("Please login to proceed"); cy.byTestId("login-button");
}); });
it("Should login and direct to repositories overview", () => { it("Should login and direct to repositories overview", () => {
loginUser("scmadmin", "scmadmin"); cy.login("scmadmin", "scmadmin");
cy.visit("http://localhost:8081/scm/login"); cy.visit("/login");
cy.contains("SCM Administrator"); cy.byTestId("SCM-Administrator");
cy.get("li") cy.byTestId("primary-navigation-logout").click();
.contains("Logout")
.click();
}); });
it("Should logout and direct to login page", () => { it("Should logout and direct to login page", () => {
loginUser("scmadmin", "scmadmin"); cy.login("scmadmin", "scmadmin");
cy.visit("http://localhost:8081/scm/repos/"); cy.visit("/repos/");
cy.contains("Overview of available repositories"); cy.byTestId("repository-overview-filter");
cy.contains("SCM Administrator"); cy.byTestId("SCM-Administrator");
cy.contains("Logout").click(); cy.byTestId("primary-navigation-logout").click();
cy.contains("Please login to proceed"); cy.byTestId("login-button");
}); });
it("Anonymous user should not be able to change password", () => { it("Anonymous user should not be able to change password", () => {
cy.visit("http://localhost:8081/scm/repos/"); cy.visit("/repos/");
cy.contains("Profile").click(); cy.byTestId("footer-user-profile").click();
cy.contains("scm-anonymous@scm-manager.org"); cy.byTestId("SCM-Anonymous");
cy.get("ul").not("Settings"); cy.containsNotByTestId("ul", "user-settings-link");
cy.get("section").not("Change password"); cy.get("section").not("Change password");
}); });
});
describe("Disable anonymous mode after tests", () => { after("Disable anonymous access", () => {
it("Disable anonymous mode after tests", () => { cy.login("scmadmin", "scmadmin");
loginUser("scmadmin", "scmadmin");
setAnonymousMode("OFF"); setAnonymousMode("OFF");
cy.byTestId("primary-navigation-logout").click();
cy.get("li")
.contains("Logout")
.click();
}); });
}); });
const setAnonymousMode = anonymousMode => { const setAnonymousMode = anonymousMode => {
cy.get("li") cy.byTestId("primary-navigation-admin").click();
.contains("Administration") cy.byTestId("admin-settings-link").click();
.click(); cy.byTestId("anonymous-mode-select")
cy.get("li")
.contains("Settings")
.click();
cy.get("select")
.contains("Disabled")
.parent()
.select(anonymousMode) .select(anonymousMode)
.should("have.value", anonymousMode); .should("have.value", anonymousMode);
cy.get("button") cy.byTestId("submit-button").click();
.contains("Submit")
.click();
};
const loginUser = (username, password) => {
cy.visit("http://localhost:8081/scm/login");
cy.get("div.field.username > div > input").type(username);
cy.get("div.field.password > div > input").type(password);
cy.get("button")
.contains("Login")
.click();
}; };

View File

@@ -46,3 +46,16 @@
// //
// -- This will overwrite an existing command -- // -- This will overwrite an existing command --
// Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... }) // Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... })
const login = (username, password) => {
cy.visit( "/login");
cy.byTestId("username-input").type(username);
cy.byTestId("password-input").type(password);
cy.byTestId("login-button").click();
};
Cypress.Commands.add("login", login);
Cypress.Commands.add("byTestId", (testId) => cy.get("[data-testid=" + testId + "]"));
Cypress.Commands.add("containsNotByTestId", (container, testId) => cy.get(container).not("[data-testid=" + testId + "]"));

View File

@@ -23,6 +23,7 @@
*/ */
import React from "react"; import React from "react";
import classNames from "classnames"; import classNames from "classnames";
import { createAttributesForTesting } from "./devBuild";
type Props = { type Props = {
title?: string; title?: string;
@@ -31,6 +32,7 @@ type Props = {
color: string; color: string;
className?: string; className?: string;
onClick?: () => void; onClick?: () => void;
testId?: string;
}; };
export default class Icon extends React.Component<Props> { export default class Icon extends React.Component<Props> {
@@ -40,12 +42,23 @@ export default class Icon extends React.Component<Props> {
}; };
render() { render() {
const { title, iconStyle, name, color, className, onClick } = this.props; const { title, iconStyle, name, color, className, onClick, testId } = this.props;
if (title) { if (title) {
return ( return (
<i onClick={onClick} title={title} className={classNames(iconStyle, "fa-fw", "fa-" + name, `has-text-${color}`, className)} /> <i
onClick={onClick}
title={title}
className={classNames(iconStyle, "fa-fw", "fa-" + name, `has-text-${color}`, className)}
{...createAttributesForTesting(testId)}
/>
); );
} }
return <i onClick={onClick} className={classNames(iconStyle, "fa-" + name, `has-text-${color}`, className)} />; return (
<i
onClick={onClick}
className={classNames(iconStyle, "fa-" + name, `has-text-${color}`, className)}
{...createAttributesForTesting(testId)}
/>
);
} }
} }

View File

@@ -32,11 +32,12 @@ type Props = RouteComponentProps & {
showCreateButton: boolean; showCreateButton: boolean;
link: string; link: string;
label?: string; label?: string;
testId?: string;
}; };
class OverviewPageActions extends React.Component<Props> { class OverviewPageActions extends React.Component<Props> {
render() { render() {
const { history, location, link } = this.props; const { history, location, link, testId } = this.props;
return ( return (
<> <>
<FilterInput <FilterInput
@@ -44,6 +45,7 @@ class OverviewPageActions extends React.Component<Props> {
filter={filter => { filter={filter => {
history.push(`/${link}/?q=${filter}`); history.push(`/${link}/?q=${filter}`);
}} }}
testId={testId + "-filter"}
/> />
{this.renderCreateButton()} {this.renderCreateButton()}
</> </>

View File

@@ -39093,15 +39093,6 @@ exports[`Storyshots Layout|Footer Default 1`] = `
footer.user.profile footer.user.profile
</a> </a>
</li> </li>
<li>
<a
className=""
href="/me/settings/password"
onClick={[Function]}
>
profile.changePasswordNavLink
</a>
</li>
</ul> </ul>
</section> </section>
<section <section
@@ -39186,16 +39177,18 @@ exports[`Storyshots Layout|Footer Full 1`] = `
<div <div
className="FooterSection__Title-lx0ikb-0 gUQuRF" className="FooterSection__Title-lx0ikb-0 gUQuRF"
> >
<span <div>
className="Footer__AvatarContainer-k70cxq-1 faWKKx image is-rounded" <span
> className="Footer__AvatarContainer-k70cxq-1 faWKKx image is-rounded"
<img >
alt="trillian" <img
className="is-rounded Footer__VCenteredAvatar-k70cxq-0 btJmxP" alt="trillian"
src="test-file-stub" className="is-rounded Footer__VCenteredAvatar-k70cxq-0 btJmxP"
/> src="test-file-stub"
</span> />
Trillian McMillian </span>
Trillian McMillian
</div>
</div> </div>
<ul <ul
className="FooterSection__Menu-lx0ikb-1 idmusL" className="FooterSection__Menu-lx0ikb-1 idmusL"
@@ -39209,15 +39202,6 @@ exports[`Storyshots Layout|Footer Full 1`] = `
footer.user.profile footer.user.profile
</a> </a>
</li> </li>
<li>
<a
className=""
href="/me/settings/password"
onClick={[Function]}
>
profile.changePasswordNavLink
</a>
</li>
<li> <li>
<a <a
className="" className=""
@@ -39338,16 +39322,18 @@ exports[`Storyshots Layout|Footer With Avatar 1`] = `
<div <div
className="FooterSection__Title-lx0ikb-0 gUQuRF" className="FooterSection__Title-lx0ikb-0 gUQuRF"
> >
<span <div>
className="Footer__AvatarContainer-k70cxq-1 faWKKx image is-rounded" <span
> className="Footer__AvatarContainer-k70cxq-1 faWKKx image is-rounded"
<img >
alt="trillian" <img
className="is-rounded Footer__VCenteredAvatar-k70cxq-0 btJmxP" alt="trillian"
src="test-file-stub" className="is-rounded Footer__VCenteredAvatar-k70cxq-0 btJmxP"
/> src="test-file-stub"
</span> />
Trillian McMillian </span>
Trillian McMillian
</div>
</div> </div>
<ul <ul
className="FooterSection__Menu-lx0ikb-1 idmusL" className="FooterSection__Menu-lx0ikb-1 idmusL"
@@ -39361,15 +39347,6 @@ exports[`Storyshots Layout|Footer With Avatar 1`] = `
footer.user.profile footer.user.profile
</a> </a>
</li> </li>
<li>
<a
className=""
href="/me/settings/password"
onClick={[Function]}
>
profile.changePasswordNavLink
</a>
</li>
</ul> </ul>
</section> </section>
<section <section
@@ -39472,15 +39449,6 @@ exports[`Storyshots Layout|Footer With Plugin Links 1`] = `
footer.user.profile footer.user.profile
</a> </a>
</li> </li>
<li>
<a
className=""
href="/me/settings/password"
onClick={[Function]}
>
profile.changePasswordNavLink
</a>
</li>
<li> <li>
<a <a
className="" className=""

View File

@@ -25,6 +25,7 @@ import React, { MouseEvent, ReactNode } from "react";
import classNames from "classnames"; import classNames from "classnames";
import { withRouter, RouteComponentProps } from "react-router-dom"; import { withRouter, RouteComponentProps } from "react-router-dom";
import Icon from "../Icon"; import Icon from "../Icon";
import { createAttributesForTesting } from "../devBuild";
export type ButtonProps = { export type ButtonProps = {
label?: string; label?: string;
@@ -37,6 +38,7 @@ export type ButtonProps = {
fullWidth?: boolean; fullWidth?: boolean;
reducedMobile?: boolean; reducedMobile?: boolean;
children?: ReactNode; children?: ReactNode;
testId?: string;
}; };
type Props = ButtonProps & type Props = ButtonProps &
@@ -73,7 +75,8 @@ class Button extends React.Component<Props> {
icon, icon,
fullWidth, fullWidth,
reducedMobile, reducedMobile,
children children,
testId
} = this.props; } = this.props;
const loadingClass = loading ? "is-loading" : ""; const loadingClass = loading ? "is-loading" : "";
const fullWidthClass = fullWidth ? "is-fullwidth" : ""; const fullWidthClass = fullWidth ? "is-fullwidth" : "";
@@ -86,6 +89,7 @@ class Button extends React.Component<Props> {
disabled={disabled} disabled={disabled}
onClick={this.onClick} onClick={this.onClick}
className={classNames("button", "is-" + color, loadingClass, fullWidthClass, reducedMobileClass, className)} className={classNames("button", "is-" + color, loadingClass, fullWidthClass, reducedMobileClass, className)}
{...createAttributesForTesting(testId)}
> >
<span className="icon is-medium"> <span className="icon is-medium">
<Icon name={icon} color="inherit" /> <Icon name={icon} color="inherit" />
@@ -104,6 +108,7 @@ class Button extends React.Component<Props> {
disabled={disabled} disabled={disabled}
onClick={this.onClick} onClick={this.onClick}
className={classNames("button", "is-" + color, loadingClass, fullWidthClass, className)} className={classNames("button", "is-" + color, loadingClass, fullWidthClass, className)}
{...createAttributesForTesting(testId)}
> >
{label} {children} {label} {children}
</button> </button>

View File

@@ -26,6 +26,7 @@ import Button, { ButtonProps } from "./Button";
type SubmitButtonProps = ButtonProps & { type SubmitButtonProps = ButtonProps & {
scrollToTop: boolean; scrollToTop: boolean;
testId?: string;
}; };
class SubmitButton extends React.Component<SubmitButtonProps> { class SubmitButton extends React.Component<SubmitButtonProps> {
@@ -34,7 +35,7 @@ class SubmitButton extends React.Component<SubmitButtonProps> {
}; };
render() { render() {
const { action, scrollToTop } = this.props; const { action, scrollToTop, testId } = this.props;
return ( return (
<Button <Button
type="submit" type="submit"
@@ -48,6 +49,7 @@ class SubmitButton extends React.Component<SubmitButtonProps> {
window.scrollTo(0, 0); window.scrollTo(0, 0);
} }
}} }}
testId={testId ? testId : "submit-button"}
/> />
); );
} }

View File

@@ -0,0 +1,46 @@
/*
* 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.
*/
export const isDevBuild = () => (process.env.NODE_ENV === "development")
export const createAttributesForTesting = (testId?: string) => {
if (!testId || !isDevBuild()) {
return undefined;
}
return {
"data-testid": testId
}
};
export const replaceSpacesInTestId = (testId?: string) => {
if (!testId) {
return testId;
}
let id = testId;
while (id.includes(" ")) {
id = id.replace(" ", "-");
}
return id;
};

View File

@@ -35,6 +35,7 @@ type Props = {
title?: string; title?: string;
disabled?: boolean; disabled?: boolean;
helpText?: string; helpText?: string;
testId?: string;
}; };
export default class Checkbox extends React.Component<Props> { export default class Checkbox extends React.Component<Props> {
@@ -59,7 +60,7 @@ export default class Checkbox extends React.Component<Props> {
}; };
render() { render() {
const { label, checked, indeterminate, disabled } = this.props; const { label, checked, indeterminate, disabled, testId } = this.props;
return ( return (
<div className="field"> <div className="field">
{this.renderLabelWithHelp()} {this.renderLabelWithHelp()}
@@ -70,7 +71,7 @@ export default class Checkbox extends React.Component<Props> {
but bulma does. but bulma does.
// @ts-ignore */} // @ts-ignore */}
<label className="checkbox" disabled={disabled}> <label className="checkbox" disabled={disabled}>
<TriStateCheckbox checked={checked} indeterminate={indeterminate} disabled={disabled} /> <TriStateCheckbox checked={checked} indeterminate={indeterminate} disabled={disabled} testId={testId} />
{label} {label}
{this.renderHelp()} {this.renderHelp()}
</label> </label>

View File

@@ -24,10 +24,12 @@
import React, { ChangeEvent, FormEvent } from "react"; import React, { ChangeEvent, FormEvent } from "react";
import { WithTranslation, withTranslation } from "react-i18next"; import { WithTranslation, withTranslation } from "react-i18next";
import styled from "styled-components"; import styled from "styled-components";
import { createAttributesForTesting } from "../devBuild";
type Props = WithTranslation & { type Props = WithTranslation & {
filter: (p: string) => void; filter: (p: string) => void;
value?: string; value?: string;
testId?: string;
}; };
type State = { type State = {
@@ -58,9 +60,9 @@ class FilterInput extends React.Component<Props, State> {
}; };
render() { render() {
const { t } = this.props; const { t, testId } = this.props;
return ( return (
<form className="input-field" onSubmit={this.handleSubmit}> <form className="input-field" onSubmit={this.handleSubmit} {...createAttributesForTesting(testId)}>
<div className="control has-icons-left"> <div className="control has-icons-left">
<FixedHeightInput <FixedHeightInput
className="input" className="input"

View File

@@ -24,6 +24,7 @@
import React, { ChangeEvent, KeyboardEvent } from "react"; import React, { ChangeEvent, KeyboardEvent } from "react";
import classNames from "classnames"; import classNames from "classnames";
import LabelWithHelpIcon from "./LabelWithHelpIcon"; import LabelWithHelpIcon from "./LabelWithHelpIcon";
import { createAttributesForTesting } from "../devBuild";
type Props = { type Props = {
label?: string; label?: string;
@@ -39,6 +40,7 @@ type Props = {
disabled?: boolean; disabled?: boolean;
helpText?: string; helpText?: string;
className?: string; className?: string;
testId?: string;
}; };
class InputField extends React.Component<Props> { class InputField extends React.Component<Props> {
@@ -80,7 +82,8 @@ class InputField extends React.Component<Props> {
disabled, disabled,
label, label,
helpText, helpText,
className className,
testId
} = this.props; } = this.props;
const errorView = validationError ? "is-danger" : ""; const errorView = validationError ? "is-danger" : "";
const helper = validationError ? <p className="help is-danger">{errorMessage}</p> : ""; const helper = validationError ? <p className="help is-danger">{errorMessage}</p> : "";
@@ -99,6 +102,7 @@ class InputField extends React.Component<Props> {
onChange={this.handleInput} onChange={this.handleInput}
onKeyPress={this.handleKeyPress} onKeyPress={this.handleKeyPress}
disabled={disabled} disabled={disabled}
{...createAttributesForTesting(testId)}
/> />
</div> </div>
{helper} {helper}

View File

@@ -24,6 +24,7 @@
import React, { ChangeEvent } from "react"; import React, { ChangeEvent } from "react";
import classNames from "classnames"; import classNames from "classnames";
import LabelWithHelpIcon from "./LabelWithHelpIcon"; import LabelWithHelpIcon from "./LabelWithHelpIcon";
import {createAttributesForTesting} from "../devBuild";
export type SelectItem = { export type SelectItem = {
value: string; value: string;
@@ -39,6 +40,7 @@ type Props = {
loading?: boolean; loading?: boolean;
helpText?: string; helpText?: string;
disabled?: boolean; disabled?: boolean;
testId?: string;
}; };
class Select extends React.Component<Props> { class Select extends React.Component<Props> {
@@ -57,7 +59,7 @@ class Select extends React.Component<Props> {
}; };
render() { render() {
const { options, value, label, helpText, loading, disabled } = this.props; const { options, value, label, helpText, loading, disabled, testId } = this.props;
const loadingClass = loading ? "is-loading" : ""; const loadingClass = loading ? "is-loading" : "";
return ( return (
@@ -71,6 +73,7 @@ class Select extends React.Component<Props> {
value={value} value={value}
onChange={this.handleInput} onChange={this.handleInput}
disabled={disabled} disabled={disabled}
{...createAttributesForTesting(testId)}
> >
{options.map(opt => { {options.map(opt => {
return ( return (

View File

@@ -29,9 +29,10 @@ type Props = {
indeterminate?: boolean; indeterminate?: boolean;
disabled?: boolean; disabled?: boolean;
label?: string; label?: string;
testId?: string;
}; };
const TriStateCheckbox: FC<Props> = ({ checked, indeterminate, disabled, label }) => { const TriStateCheckbox: FC<Props> = ({ checked, indeterminate, disabled, label, testId }) => {
let icon; let icon;
if (indeterminate) { if (indeterminate) {
icon = "minus-square"; icon = "minus-square";
@@ -57,8 +58,11 @@ const TriStateCheckbox: FC<Props> = ({ checked, indeterminate, disabled, label }
color = "black"; color = "black";
} }
return <><Icon iconStyle={"is-outlined"} name={icon} className={className} color={color} />{" "} return (
{label}</>; <>
<Icon iconStyle={"is-outlined"} name={icon} className={className} color={color} testId={testId} /> {label}
</>
);
}; };
export default TriStateCheckbox; export default TriStateCheckbox;

View File

@@ -85,6 +85,7 @@ export { default as comparators } from "./comparators";
export { apiClient } from "./apiclient"; export { apiClient } from "./apiclient";
export * from "./errors"; export * from "./errors";
export { isDevBuild, createAttributesForTesting, replaceSpacesInTestId } from "./devBuild";
export * from "./avatar"; export * from "./avatar";
export * from "./buttons"; export * from "./buttons";

View File

@@ -22,8 +22,8 @@
* SOFTWARE. * SOFTWARE.
*/ */
import React, { FC } from "react"; import React, { FC } from "react";
import { Me, Links } from "@scm-manager/ui-types"; import { Links, Me } from "@scm-manager/ui-types";
import { useBinder, ExtensionPoint } from "@scm-manager/ui-extensions"; import { ExtensionPoint, useBinder } from "@scm-manager/ui-extensions";
import { AvatarImage } from "../avatar"; import { AvatarImage } from "../avatar";
import NavLink from "../navigation/NavLink"; import NavLink from "../navigation/NavLink";
import FooterSection from "./FooterSection"; import FooterSection from "./FooterSection";
@@ -31,6 +31,7 @@ import styled from "styled-components";
import { EXTENSION_POINT } from "../avatar/Avatar"; import { EXTENSION_POINT } from "../avatar/Avatar";
import ExternalNavLink from "../navigation/ExternalNavLink"; import ExternalNavLink from "../navigation/ExternalNavLink";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { createAttributesForTesting, replaceSpacesInTestId } from "../devBuild";
type Props = { type Props = {
me?: Me; me?: Me;
@@ -43,11 +44,13 @@ type TitleWithIconsProps = {
icon: string; icon: string;
}; };
const TitleWithIcon: FC<TitleWithIconsProps> = ({ icon, title }) => ( const TitleWithIcon: FC<TitleWithIconsProps> = ({ icon, title }) => {
<> return (
<i className={`fas fa-${icon} fa-fw`} /> {title} <>
</> <i className={`fas fa-${icon} fa-fw`} {...createAttributesForTesting(replaceSpacesInTestId(title))} /> {title}
); </>
);
};
type TitleWithAvatarProps = { type TitleWithAvatarProps = {
me: Me; me: Me;
@@ -66,12 +69,12 @@ const AvatarContainer = styled.span`
`; `;
const TitleWithAvatar: FC<TitleWithAvatarProps> = ({ me }) => ( const TitleWithAvatar: FC<TitleWithAvatarProps> = ({ me }) => (
<> <div {...createAttributesForTesting(replaceSpacesInTestId(me.displayName))}>
<AvatarContainer className="image is-rounded"> <AvatarContainer className="image is-rounded">
<VCenteredAvatar person={me} representation="rounded" /> <VCenteredAvatar person={me} representation="rounded" />
</AvatarContainer> </AvatarContainer>
{me.displayName} {me.displayName}
</> </div>
); );
const Footer: FC<Props> = ({ me, version, links }) => { const Footer: FC<Props> = ({ me, version, links }) => {
@@ -89,24 +92,14 @@ const Footer: FC<Props> = ({ me, version, links }) => {
meSectionTile = <TitleWithIcon title={me.displayName} icon="user-circle" />; meSectionTile = <TitleWithIcon title={me.displayName} icon="user-circle" />;
} }
let meSectionBody = <div />;
{
if (me.name !== "_anonymous")
meSectionBody = (
<>
<NavLink to="/me/settings/password" label={t("profile.changePasswordNavLink")} />
<ExtensionPoint name="profile.setting" props={extensionProps} renderAll={true} />
</>
);
}
return ( return (
<footer className="footer"> <footer className="footer">
<section className="section container"> <section className="section container">
<div className="columns is-size-7"> <div className="columns is-size-7">
<FooterSection title={meSectionTile}> <FooterSection title={meSectionTile}>
<NavLink to="/me" label={t("footer.user.profile")} /> <NavLink to="/me" label={t("footer.user.profile")} testId="footer-user-profile" />
{meSectionBody} {me?._links?.password && <NavLink to="/me/settings/password" label={t("profile.changePasswordNavLink")} />}
<ExtensionPoint name="profile.setting" props={extensionProps} renderAll={true} />
</FooterSection> </FooterSection>
<FooterSection title={<TitleWithIcon title={t("footer.information.title")} icon="info-circle" />}> <FooterSection title={<TitleWithIcon title={t("footer.information.title")} icon="info-circle" />}>
<ExternalNavLink to="https://www.scm-manager.org/" label={`SCM-Manager ${version}`} /> <ExternalNavLink to="https://www.scm-manager.org/" label={`SCM-Manager ${version}`} />

View File

@@ -28,15 +28,16 @@ import { RoutingProps } from "./RoutingProps";
import { FC } from "react"; import { FC } from "react";
import useMenuContext from "./MenuContext"; import useMenuContext from "./MenuContext";
import useActiveMatch from "./useActiveMatch"; import useActiveMatch from "./useActiveMatch";
import {createAttributesForTesting} from "../devBuild";
type Props = RoutingProps & { type Props = RoutingProps & {
label: string; label: string;
title?: string; title?: string;
icon?: string; icon?: string;
className?: string; testId?: string;
}; };
const NavLink: FC<Props> = ({ to, activeWhenMatch, activeOnlyWhenExact, icon, label, title, className }) => { const NavLink: FC<Props> = ({ to, activeWhenMatch, activeOnlyWhenExact, icon, label, title, testId }) => {
const active = useActiveMatch({ to, activeWhenMatch, activeOnlyWhenExact }); const active = useActiveMatch({ to, activeWhenMatch, activeOnlyWhenExact });
const context = useMenuContext(); const context = useMenuContext();
@@ -53,7 +54,11 @@ const NavLink: FC<Props> = ({ to, activeWhenMatch, activeOnlyWhenExact, icon, la
return ( return (
<li title={collapsed ? title : undefined}> <li title={collapsed ? title : undefined}>
<Link className={classNames(active ? "is-active" : "", collapsed ? "has-text-centered" : "", className)} to={to}> <Link
className={classNames(active ? "is-active" : "", collapsed ? "has-text-centered" : "")}
to={to}
{...createAttributesForTesting(testId)}
>
{showIcon} {showIcon}
{collapsed ? null : label} {collapsed ? null : label}
</Link> </Link>

View File

@@ -40,7 +40,7 @@ class PrimaryNavigation extends React.Component<Props> {
return (to: string, match: string, label: string, linkName: string) => { return (to: string, match: string, label: string, linkName: string) => {
const link = links[linkName]; const link = links[linkName];
if (link) { if (link) {
const navigationItem = <PrimaryNavigationLink className={t(label)} to={to} match={match} label={t(label)} key={linkName} />; const navigationItem = <PrimaryNavigationLink testId={label.replace(".", "-")} to={to} match={match} label={t(label)} key={linkName} />;
navigationItems.push(navigationItem); navigationItems.push(navigationItem);
} }
}; };

View File

@@ -23,30 +23,39 @@
*/ */
import * as React from "react"; import * as React from "react";
import { Route, Link } from "react-router-dom"; import { Route, Link } from "react-router-dom";
import classNames from "classnames"; import { createAttributesForTesting } from "../devBuild";
type Props = { type Props = {
to: string; to: string;
label: string; label: string;
match?: string; match?: string;
activeOnlyWhenExact?: boolean; activeOnlyWhenExact?: boolean;
className?: string; testId?: string;
}; };
class PrimaryNavigationLink extends React.Component<Props> { class PrimaryNavigationLink extends React.Component<Props> {
renderLink = (route: any) => { renderLink = (route: any) => {
const { to, label, className } = this.props; const { to, label, testId } = this.props;
return ( return (
<li className={classNames(route.match ? "is-active" : "", className)}> <li className={route.match ? "is-active" : ""}>
<Link to={to}>{label}</Link> <Link to={to} {...createAttributesForTesting(testId)}>
{label}
</Link>
</li> </li>
); );
}; };
render() { render() {
const { to, match, activeOnlyWhenExact } = this.props; const { to, match, activeOnlyWhenExact, testId } = this.props;
const path = match ? match : to; const path = match ? match : to;
return <Route path={path} exact={activeOnlyWhenExact} children={this.renderLink} />; return (
<Route
path={path}
exact={activeOnlyWhenExact}
children={this.renderLink}
{...createAttributesForTesting(testId)}
/>
);
} }
} }

View File

@@ -27,15 +27,25 @@ import classNames from "classnames";
import useMenuContext from "./MenuContext"; import useMenuContext from "./MenuContext";
import { RoutingProps } from "./RoutingProps"; import { RoutingProps } from "./RoutingProps";
import useActiveMatch from "./useActiveMatch"; import useActiveMatch from "./useActiveMatch";
import { createAttributesForTesting } from "../devBuild";
type Props = RoutingProps & { type Props = RoutingProps & {
label: string; label: string;
title?: string; title?: string;
icon?: string; icon?: string;
className?: string; testId?: string;
}; };
const SubNavigation: FC<Props> = ({ to, activeOnlyWhenExact, activeWhenMatch, icon, title, label, children, className }) => { const SubNavigation: FC<Props> = ({
to,
activeOnlyWhenExact,
activeWhenMatch,
icon,
title,
label,
children,
testId
}) => {
const context = useMenuContext(); const context = useMenuContext();
const collapsed = context.isCollapsed(); const collapsed = context.isCollapsed();
@@ -61,7 +71,11 @@ const SubNavigation: FC<Props> = ({ to, activeOnlyWhenExact, activeWhenMatch, ic
return ( return (
<li title={collapsed ? title : undefined}> <li title={collapsed ? title : undefined}>
<Link className={classNames(active ? "is-active" : "", collapsed ? "has-text-centered" : "", className)} to={to}> <Link
className={classNames(active ? "is-active" : "", collapsed ? "has-text-centered" : "")}
to={to}
{...createAttributesForTesting(testId)}
>
<i className={classNames(defaultIcon, "fa-fw")} /> {collapsed ? "" : label} <i className={classNames(defaultIcon, "fa-fw")} /> {collapsed ? "" : label}
</Link> </Link>
{childrenList} {childrenList}

View File

@@ -35,6 +35,7 @@ export type Config = {
realmDescription: string; realmDescription: string;
disableGroupingGrid: boolean; disableGroupingGrid: boolean;
dateFormat: string; dateFormat: string;
anonymousAccessEnabled: boolean;
anonymousMode: AnonymousMode; anonymousMode: AnonymousMode;
baseUrl: string; baseUrl: string;
forceBaseUrl: boolean; forceBaseUrl: boolean;

View File

@@ -122,6 +122,7 @@ class GeneralSettings extends React.Component<Props> {
{ label: t("general-settings.anonymousMode.off"), value: "OFF" } { label: t("general-settings.anonymousMode.off"), value: "OFF" }
]} ]}
helpText={t("help.allowAnonymousAccessHelpText")} helpText={t("help.allowAnonymousAccessHelpText")}
testId={"anonymous-mode-select"}
/> />
</div> </div>
</div> </div>

View File

@@ -133,7 +133,7 @@ class Admin extends React.Component<Props> {
icon="fas fa-info-circle" icon="fas fa-info-circle"
label={t("admin.menu.informationNavLink")} label={t("admin.menu.informationNavLink")}
title={t("admin.menu.informationNavLink")} title={t("admin.menu.informationNavLink")}
className={t("admin.menu.informationNavLink")} testId="admin-information-link"
/> />
{(availablePluginsLink || installedPluginsLink) && ( {(availablePluginsLink || installedPluginsLink) && (
<SubNavigation <SubNavigation
@@ -141,13 +141,21 @@ class Admin extends React.Component<Props> {
icon="fas fa-puzzle-piece" icon="fas fa-puzzle-piece"
label={t("plugins.menu.pluginsNavLink")} label={t("plugins.menu.pluginsNavLink")}
title={t("plugins.menu.pluginsNavLink")} title={t("plugins.menu.pluginsNavLink")}
className={t("plugins.menu.pluginsNavLink")} testId="admin-plugins-link"
> >
{installedPluginsLink && ( {installedPluginsLink && (
<NavLink to={`${url}/plugins/installed/`} label={t("plugins.menu.installedNavLink")} className={t("plugins.menu.installedNavLink")}/> <NavLink
to={`${url}/plugins/installed/`}
label={t("plugins.menu.installedNavLink")}
testId="admin-installed-plugins-link"
/>
)} )}
{availablePluginsLink && ( {availablePluginsLink && (
<NavLink to={`${url}/plugins/available/`} label={t("plugins.menu.availableNavLink")} className={t("plugins.menu.availableNavLink")} /> <NavLink
to={`${url}/plugins/available/`}
label={t("plugins.menu.availableNavLink")}
testId="admin-available-plugins-link"
/>
)} )}
</SubNavigation> </SubNavigation>
)} )}
@@ -156,7 +164,7 @@ class Admin extends React.Component<Props> {
icon="fas fa-user-shield" icon="fas fa-user-shield"
label={t("repositoryRole.navLink")} label={t("repositoryRole.navLink")}
title={t("repositoryRole.navLink")} title={t("repositoryRole.navLink")}
className={t("repositoryRole.navLink")} testId="admin-repository-role-link"
activeWhenMatch={this.matchesRoles} activeWhenMatch={this.matchesRoles}
activeOnlyWhenExact={false} activeOnlyWhenExact={false}
/> />
@@ -165,9 +173,13 @@ class Admin extends React.Component<Props> {
to={`${url}/settings/general`} to={`${url}/settings/general`}
label={t("admin.menu.settingsNavLink")} label={t("admin.menu.settingsNavLink")}
title={t("admin.menu.settingsNavLink")} title={t("admin.menu.settingsNavLink")}
className={t("admin.menu.settingsNavLink")} testId="admin-settings-link"
> >
<NavLink to={`${url}/settings/general`} label={t("admin.menu.generalNavLink")} className={t("admin.menu.generalNavLink")}/> <NavLink
to={`${url}/settings/general`}
label={t("admin.menu.generalNavLink")}
testId="admin-settings-general-link"
/>
<ExtensionPoint name="admin.setting" props={extensionProps} renderAll={true} /> <ExtensionPoint name="admin.setting" props={extensionProps} renderAll={true} />
</SubNavigation> </SubNavigation>
</SecondaryNavigation> </SecondaryNavigation>

View File

@@ -109,18 +109,18 @@ class LoginForm extends React.Component<Props, State> {
<ErrorNotification error={this.areCredentialsInvalid()} /> <ErrorNotification error={this.areCredentialsInvalid()} />
<form onSubmit={this.handleSubmit}> <form onSubmit={this.handleSubmit}>
<InputField <InputField
className="username" testId="username-input"
placeholder={t("login.username-placeholder")} placeholder={t("login.username-placeholder")}
autofocus={true} autofocus={true}
onChange={this.handleUsernameChange} onChange={this.handleUsernameChange}
/> />
<InputField <InputField
className="password" testId="password-input"
placeholder={t("login.password-placeholder")} placeholder={t("login.password-placeholder")}
type="password" type="password"
onChange={this.handlePasswordChange} onChange={this.handlePasswordChange}
/> />
<SubmitButton label={t("login.submit")} fullWidth={true} loading={loading} /> <SubmitButton label={t("login.submit")} fullWidth={true} loading={loading} testId="login-button" />
</form> </form>
</TopMarginBox> </TopMarginBox>
</div> </div>

View File

@@ -22,42 +22,37 @@
* SOFTWARE. * SOFTWARE.
*/ */
import React from "react"; import 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 { getLogoutFailure, logout } from "../modules/auth"; import {getLogoutFailure, logout} from "../modules/auth";
import { ErrorPage, Loading } from "@scm-manager/ui-components"; import {ErrorPage, Loading} from "@scm-manager/ui-components";
import { getLogoutLink } from "../modules/indexResource"; import {getLogoutLink} from "../modules/indexResource";
import { RouteComponentProps, withRouter } from "react-router-dom"; import {RouteComponentProps, withRouter} from "react-router-dom";
import { compose } from "redux"; import {compose} from "redux";
type Props = RouteComponentProps & type Props = RouteComponentProps &
WithTranslation & { WithTranslation & {
error: Error; error: Error;
logoutLink: string; logoutLink: string;
// dispatcher functions // dispatcher functions
logout: (link: string) => void; logout: (link: string, callback: () => void) => void;
}; };
class Logout extends React.Component<Props> { class Logout extends React.Component<Props> {
componentDidMount() { componentDidMount() {
new Promise((resolve, reject) => { if (this.props.logoutLink) {
setTimeout(() => { this.props.logout(this.props.logoutLink, () => this.props.history.push("/login"));
if (this.props.logoutLink) { }
this.props.logout(this.props.logoutLink);
resolve(this.props.history.push("/login"));
}
});
});
} }
render() { render() {
const { error, t } = this.props; const {error, t} = this.props;
if (error) { if (error) {
return <ErrorPage title={t("logout.error.title")} subtitle={t("logout.error.subtitle")} error={error} />; return <ErrorPage title={t("logout.error.title")} subtitle={t("logout.error.subtitle")} error={error}/>;
} else { } else {
return <Loading />; return <Loading/>;
} }
} }
} }
@@ -73,7 +68,7 @@ const mapStateToProps = (state: any) => {
const mapDispatchToProps = (dispatch: any) => { const mapDispatchToProps = (dispatch: any) => {
return { return {
logout: (link: string) => dispatch(logout(link)) logout: (link: string, callback: () => void) => dispatch(logout(link, callback))
}; };
}; };

View File

@@ -24,7 +24,13 @@
import React from "react"; import React from "react";
import { WithTranslation, withTranslation } from "react-i18next"; import { WithTranslation, withTranslation } from "react-i18next";
import { Me } from "@scm-manager/ui-types"; import { Me } from "@scm-manager/ui-types";
import { AvatarImage, AvatarWrapper, MailLink } from "@scm-manager/ui-components"; import {
AvatarImage,
AvatarWrapper,
MailLink,
createAttributesForTesting,
replaceSpacesInTestId
} from "@scm-manager/ui-components";
type Props = WithTranslation & { type Props = WithTranslation & {
me: Me; me: Me;
@@ -47,11 +53,11 @@ class ProfileInfo extends React.Component<Props> {
<tbody> <tbody>
<tr> <tr>
<th>{t("profile.username")}</th> <th>{t("profile.username")}</th>
<td>{me.name}</td> <td {...createAttributesForTesting(replaceSpacesInTestId(me.name))}>{me.name}</td>
</tr> </tr>
<tr> <tr>
<th>{t("profile.displayName")}</th> <th>{t("profile.displayName")}</th>
<td>{me.displayName}</td> <td {...createAttributesForTesting(replaceSpacesInTestId(me.displayName))}>{me.displayName}</td>
</tr> </tr>
<tr> <tr>
<th>{t("profile.mail")}</th> <th>{t("profile.mail")}</th>

View File

@@ -33,8 +33,7 @@ import {
OverviewPageActions, OverviewPageActions,
Page, Page,
PageActions, PageActions,
urls, urls
Loading
} from "@scm-manager/ui-components"; } from "@scm-manager/ui-components";
import { getGroupsLink } from "../../modules/indexResource"; import { getGroupsLink } from "../../modules/indexResource";
import { import {
@@ -88,11 +87,6 @@ class Groups extends React.Component<Props> {
render() { render() {
const { groups, loading, error, canAddGroups, t } = this.props; const { groups, loading, error, canAddGroups, t } = this.props;
if (loading) {
return <Loading />;
}
return ( return (
<Page title={t("groups.title")} subtitle={t("groups.subtitle")} loading={loading || !groups} error={error}> <Page title={t("groups.title")} subtitle={t("groups.subtitle")} loading={loading || !groups} error={error}>
{this.renderGroupTable()} {this.renderGroupTable()}

View File

@@ -118,7 +118,7 @@ describe("auth actions", () => {
fetchMock.postOnce("/api/v2/auth/access_token", { fetchMock.postOnce("/api/v2/auth/access_token", {
body: { body: {
cookie: true, cookie: true,
grantType: "password", grant_type: "password",
username: "tricia", username: "tricia",
password: "secret123" password: "secret123"
}, },

View File

@@ -32,7 +32,8 @@ import {
callFetchIndexResources, callFetchIndexResources,
fetchIndexResources, fetchIndexResources,
fetchIndexResourcesPending, fetchIndexResourcesPending,
fetchIndexResourcesSuccess, getLoginLink fetchIndexResourcesSuccess,
getLoginLink
} from "./indexResource"; } from "./indexResource";
import { AnyAction } from "redux"; import { AnyAction } from "redux";
@@ -176,7 +177,7 @@ const callFetchMe = (link: string): Promise<Me> => {
export const login = (loginLink: string, username: string, password: string) => { export const login = (loginLink: string, username: string, password: string) => {
const loginData = { const loginData = {
cookie: true, cookie: true,
grantType: "password", grant_type: "password",
username, username,
password password
}; };
@@ -219,7 +220,7 @@ export const fetchMe = (link: string) => {
}; };
}; };
export const logout = (link: string) => { export const logout = (link: string, callback: () => void) => {
return function(dispatch: any) { return function(dispatch: any) {
dispatch(logoutPending()); dispatch(logoutPending());
return apiClient return apiClient
@@ -247,6 +248,7 @@ export const logout = (link: string) => {
dispatch(fetchIndexResources()); dispatch(fetchIndexResources());
} }
}) })
.then(callback)
.catch(error => { .catch(error => {
dispatch(logoutFailure(error)); dispatch(logoutFailure(error));
}); });

View File

@@ -45,6 +45,9 @@ class PermissionCheckbox extends React.Component<Props> {
? t("verbs.repository." + name + ".description") ? t("verbs.repository." + name + ".description")
: this.translateOrDefault("permissions." + key + ".description", t("permissions.unknown")); : this.translateOrDefault("permissions." + key + ".description", t("permissions.unknown"));
// @ts-ignore we have to use the label here because cypress gets confused with asterix and dots
const testId = label.replaceAll(" ", "-").toLowerCase();
return ( return (
<Checkbox <Checkbox
key={name} key={name}
@@ -54,6 +57,7 @@ class PermissionCheckbox extends React.Component<Props> {
checked={checked} checked={checked}
onChange={onChange} onChange={onChange}
disabled={disabled} disabled={disabled}
testId={testId}
/> />
); );
} }

View File

@@ -142,6 +142,7 @@ class SetPermissions extends React.Component<Props, State> {
disabled={!this.state.permissionsChanged} disabled={!this.state.permissionsChanged}
loading={loading} loading={loading}
label={t("setPermissions.button")} label={t("setPermissions.button")}
testId="set-permissions-button"
/> />
} }
/> />

View File

@@ -25,7 +25,7 @@ import React from "react";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { RouteComponentProps, withRouter } from "react-router-dom"; import { RouteComponentProps, withRouter } from "react-router-dom";
import { WithTranslation, withTranslation } from "react-i18next"; import { WithTranslation, withTranslation } from "react-i18next";
import { Me, RepositoryCollection } from "@scm-manager/ui-types"; import { RepositoryCollection } from "@scm-manager/ui-types";
import { import {
CreateButton, CreateButton,
LinkPaginator, LinkPaginator,
@@ -33,8 +33,7 @@ import {
OverviewPageActions, OverviewPageActions,
Page, Page,
PageActions, PageActions,
urls, urls
Loading
} from "@scm-manager/ui-components"; } from "@scm-manager/ui-components";
import { getRepositoriesLink } from "../../modules/indexResource"; import { getRepositoriesLink } from "../../modules/indexResource";
import { import {
@@ -45,11 +44,9 @@ import {
isFetchReposPending isFetchReposPending
} from "../modules/repos"; } from "../modules/repos";
import RepositoryList from "../components/list"; import RepositoryList from "../components/list";
import { fetchMe, isFetchMePending } from "../../modules/auth";
type Props = WithTranslation & type Props = WithTranslation &
RouteComponentProps & { RouteComponentProps & {
me: Me;
loading: boolean; loading: boolean;
error: Error; error: Error;
showCreateButton: boolean; showCreateButton: boolean;
@@ -63,15 +60,13 @@ type Props = WithTranslation &
class Overview extends React.Component<Props> { class Overview extends React.Component<Props> {
componentDidMount() { componentDidMount() {
const { me, fetchReposByPage, reposLink, page, location } = this.props; const { fetchReposByPage, reposLink, page, location } = this.props;
if (me) { fetchReposByPage(reposLink, page, urls.getQueryStringFromLocation(location));
fetchReposByPage(reposLink, page, urls.getQueryStringFromLocation(location));
}
} }
componentDidUpdate = (prevProps: Props) => { componentDidUpdate = (prevProps: Props) => {
const { me, loading, collection, page, reposLink, location, fetchReposByPage } = this.props; const { loading, collection, page, reposLink, location, fetchReposByPage } = this.props;
if (collection && page && !loading && me) { if (collection && page && !loading) {
const statePage: number = collection.page + 1; const statePage: number = collection.page + 1;
if (page !== statePage || prevProps.location.search !== location.search) { if (page !== statePage || prevProps.location.search !== location.search) {
fetchReposByPage(reposLink, page, urls.getQueryStringFromLocation(location)); fetchReposByPage(reposLink, page, urls.getQueryStringFromLocation(location));
@@ -82,15 +77,16 @@ class Overview extends React.Component<Props> {
render() { render() {
const { error, loading, showCreateButton, t } = this.props; const { error, loading, showCreateButton, t } = this.props;
if (loading) {
return <Loading />;
}
return ( return (
<Page title={t("overview.title")} subtitle={t("overview.subtitle")} loading={loading} error={error}> <Page title={t("overview.title")} subtitle={t("overview.subtitle")} loading={loading} error={error}>
{this.renderOverview()} {this.renderOverview()}
<PageActions> <PageActions>
<OverviewPageActions showCreateButton={showCreateButton} link="repos" label={t("overview.createButton")} /> <OverviewPageActions
showCreateButton={showCreateButton}
link="repos"
label={t("overview.createButton")}
testId="repository-overview"
/>
</PageActions> </PageActions>
</Page> </Page>
); );
@@ -134,15 +130,13 @@ class Overview extends React.Component<Props> {
const mapStateToProps = (state: any, ownProps: Props) => { const mapStateToProps = (state: any, ownProps: Props) => {
const { match } = ownProps; const { match } = ownProps;
const me = fetchMe(state);
const collection = getRepositoryCollection(state); const collection = getRepositoryCollection(state);
const loading = isFetchReposPending(state) || isFetchMePending(state); const loading = isFetchReposPending(state);
const error = getFetchReposFailure(state); const error = getFetchReposFailure(state);
const page = urls.getPageFromMatch(match); const page = urls.getPageFromMatch(match);
const showCreateButton = isAbleToCreateRepos(state); const showCreateButton = isAbleToCreateRepos(state);
const reposLink = getRepositoriesLink(state); const reposLink = getRepositoriesLink(state);
return { return {
me,
collection, collection,
loading, loading,
error, error,

View File

@@ -42,7 +42,7 @@ class EditUserNavLink extends React.Component<Props> {
if (!this.isEditable()) { if (!this.isEditable()) {
return null; return null;
} }
return <NavLink to={editUrl} label={t("singleUser.menu.generalNavLink")} className={t("singleUser.menu.generalNavLink")}/>; return <NavLink to={editUrl} label={t("singleUser.menu.generalNavLink")} testId="user-edit-link" />;
} }
} }

View File

@@ -38,7 +38,7 @@ class ChangePasswordNavLink extends React.Component<Props> {
if (!this.hasPermissionToSetPassword()) { if (!this.hasPermissionToSetPassword()) {
return null; return null;
} }
return <NavLink to={passwordUrl} label={t("singleUser.menu.setPasswordNavLink")} className={t("singleUser.menu.setPasswordNavLink")}/>; return <NavLink to={passwordUrl} label={t("singleUser.menu.setPasswordNavLink")} testId="user-password-link"/>;
} }
hasPermissionToSetPassword = () => { hasPermissionToSetPassword = () => {

View File

@@ -38,7 +38,7 @@ class ChangePermissionNavLink extends React.Component<Props> {
if (!this.hasPermissionToSetPermission()) { if (!this.hasPermissionToSetPermission()) {
return null; return null;
} }
return <NavLink to={permissionsUrl} label={t("singleUser.menu.setPermissionsNavLink")} className={t("singleUser.menu.setPermissionsNavLink")}/>; return <NavLink to={permissionsUrl} label={t("singleUser.menu.setPermissionsNavLink")} testId="user-permissions-link"/>;
} }
hasPermissionToSetPermission = () => { hasPermissionToSetPermission = () => {

View File

@@ -24,7 +24,13 @@
import React from "react"; import React from "react";
import { WithTranslation, withTranslation } from "react-i18next"; import { WithTranslation, withTranslation } from "react-i18next";
import { User } from "@scm-manager/ui-types"; import { User } from "@scm-manager/ui-types";
import { Checkbox, DateFromNow, MailLink } from "@scm-manager/ui-components"; import {
Checkbox,
DateFromNow,
MailLink,
createAttributesForTesting,
replaceSpacesInTestId
} from "@scm-manager/ui-components";
type Props = WithTranslation & { type Props = WithTranslation & {
user: User; user: User;
@@ -38,11 +44,11 @@ class Details extends React.Component<Props> {
<tbody> <tbody>
<tr> <tr>
<th>{t("user.name")}</th> <th>{t("user.name")}</th>
<td>{user.name}</td> <td {...createAttributesForTesting(replaceSpacesInTestId(user.name))}>{user.name}</td>
</tr> </tr>
<tr> <tr>
<th>{t("user.displayName")}</th> <th>{t("user.displayName")}</th>
<td>{user.displayName}</td> <td {...createAttributesForTesting(replaceSpacesInTestId(user.displayName))}>{user.displayName}</td>
</tr> </tr>
<tr> <tr>
<th>{t("user.mail")}</th> <th>{t("user.mail")}</th>

View File

@@ -25,7 +25,7 @@ import React from "react";
import { WithTranslation, withTranslation } from "react-i18next"; import { WithTranslation, withTranslation } from "react-i18next";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { User } from "@scm-manager/ui-types"; import { User } from "@scm-manager/ui-types";
import { Icon } from "@scm-manager/ui-components"; import { Icon, createAttributesForTesting } from "@scm-manager/ui-components";
type Props = WithTranslation & { type Props = WithTranslation & {
user: User; user: User;
@@ -33,7 +33,11 @@ type Props = WithTranslation & {
class UserRow extends React.Component<Props> { class UserRow extends React.Component<Props> {
renderLink(to: string, label: string) { renderLink(to: string, label: string) {
return <Link to={to}>{label}</Link>; return (
<Link to={to} {...createAttributesForTesting(label)}>
{label}
</Link>
);
} }
render() { render() {

View File

@@ -114,13 +114,13 @@ class SingleUser extends React.Component<Props> {
icon="fas fa-info-circle" icon="fas fa-info-circle"
label={t("singleUser.menu.informationNavLink")} label={t("singleUser.menu.informationNavLink")}
title={t("singleUser.menu.informationNavLink")} title={t("singleUser.menu.informationNavLink")}
className={t("singleUser.menu.informationNavLink")} testId="user-information-link"
/> />
<SubNavigation <SubNavigation
to={`${url}/settings/general`} to={`${url}/settings/general`}
label={t("singleUser.menu.settingsNavLink")} label={t("singleUser.menu.settingsNavLink")}
title={t("singleUser.menu.settingsNavLink")} title={t("singleUser.menu.settingsNavLink")}
className={t("singleUser.menu.settingsNavLink")} testId="user-settings-link"
> >
<EditUserNavLink user={user} editUrl={`${url}/settings/general`} /> <EditUserNavLink user={user} editUrl={`${url}/settings/general`} />
<SetPasswordNavLink user={user} passwordUrl={`${url}/settings/password`} /> <SetPasswordNavLink user={user} passwordUrl={`${url}/settings/password`} />

View File

@@ -33,8 +33,7 @@ import {
OverviewPageActions, OverviewPageActions,
Page, Page,
PageActions, PageActions,
urls, urls
Loading
} from "@scm-manager/ui-components"; } from "@scm-manager/ui-components";
import { getUsersLink } from "../../modules/indexResource"; import { getUsersLink } from "../../modules/indexResource";
import { import {
@@ -89,10 +88,6 @@ class Users extends React.Component<Props> {
render() { render() {
const { users, loading, error, canAddUsers, t } = this.props; const { users, loading, error, canAddUsers, t } = this.props;
if (loading) {
return <Loading />;
}
return ( return (
<Page title={t("users.title")} subtitle={t("users.subtitle")} loading={loading || !users} error={error}> <Page title={t("users.title")} subtitle={t("users.subtitle")} loading={loading || !users} error={error}>
{this.renderUserTable()} {this.renderUserTable()}

View File

@@ -32,8 +32,8 @@ import java.util.List;
public class AuthenticationRequestDto { public class AuthenticationRequestDto {
@FormParam("grantType") @FormParam("grant_type")
@JsonProperty("grantType") @JsonProperty("grant_type")
private String grantType; private String grantType;
@FormParam("username") @FormParam("username")
@@ -69,7 +69,7 @@ public class AuthenticationRequestDto {
} }
public boolean isValid() { public boolean isValid() {
// password is currently the only valid grantType // password is currently the only valid grant_type
return "password".equals(grantType) && !Strings.isNullOrEmpty(username) && !Strings.isNullOrEmpty(password); return "password".equals(grantType) && !Strings.isNullOrEmpty(username) && !Strings.isNullOrEmpty(password);
} }
} }

View File

@@ -46,6 +46,7 @@ public class ConfigDto extends HalRepresentation {
private String realmDescription; private String realmDescription;
private boolean disableGroupingGrid; private boolean disableGroupingGrid;
private String dateFormat; private String dateFormat;
private boolean anonymousAccessEnabled;
private AnonymousMode anonymousMode; private AnonymousMode anonymousMode;
private String baseUrl; private String baseUrl;
private boolean forceBaseUrl; private boolean forceBaseUrl;

View File

@@ -21,11 +21,14 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE. * SOFTWARE.
*/ */
package sonia.scm.api.v2.resources; package sonia.scm.api.v2.resources;
import org.mapstruct.AfterMapping;
import org.mapstruct.Mapper; import org.mapstruct.Mapper;
import org.mapstruct.MappingTarget;
import sonia.scm.config.ScmConfiguration; import sonia.scm.config.ScmConfiguration;
import sonia.scm.security.AnonymousMode;
// Mapstruct does not support parameterized (i.e. non-default) constructors. Thus, we need to use field injection. // Mapstruct does not support parameterized (i.e. non-default) constructors. Thus, we need to use field injection.
@SuppressWarnings("squid:S3306") @SuppressWarnings("squid:S3306")
@@ -33,4 +36,15 @@ import sonia.scm.config.ScmConfiguration;
public abstract class ConfigDtoToScmConfigurationMapper { public abstract class ConfigDtoToScmConfigurationMapper {
public abstract ScmConfiguration map(ConfigDto dto); public abstract ScmConfiguration map(ConfigDto dto);
@AfterMapping // Should map anonymous mode from old flag if not send explicit
void mapAnonymousMode(@MappingTarget ScmConfiguration config, ConfigDto configDto) {
if (configDto.getAnonymousMode() == null) {
if (configDto.isAnonymousAccessEnabled()) {
config.setAnonymousMode(AnonymousMode.PROTOCOL_ONLY);
} else {
config.setAnonymousMode(AnonymousMode.OFF);
}
}
}
} }

View File

@@ -21,7 +21,7 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE. * SOFTWARE.
*/ */
package sonia.scm.api.v2.resources; package sonia.scm.api.v2.resources;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
@@ -29,7 +29,6 @@ import io.swagger.v3.oas.annotations.headers.Header;
import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponse;
import org.apache.shiro.SecurityUtils;
import sonia.scm.group.Group; import sonia.scm.group.Group;
import sonia.scm.group.GroupManager; import sonia.scm.group.GroupManager;
import sonia.scm.group.GroupPermissions; import sonia.scm.group.GroupPermissions;

View File

@@ -123,9 +123,17 @@ public class IndexDtoGenerator extends HalAppenderMapper {
} }
private boolean shouldAppendSubjectRelatedLinks() { private boolean shouldAppendSubjectRelatedLinks() {
return (SecurityUtils.getSubject().isAuthenticated() return isAuthenticatedSubjectNotAnonymous()
&& !Authentications.isAuthenticatedSubjectAnonymous()) || isAuthenticatedSubjectAllowedToBeAnonymous();
|| (Authentications.isAuthenticatedSubjectAnonymous() }
&& configuration.getAnonymousMode() == AnonymousMode.FULL);
private boolean isAuthenticatedSubjectAllowedToBeAnonymous() {
return Authentications.isAuthenticatedSubjectAnonymous()
&& configuration.getAnonymousMode() == AnonymousMode.FULL;
}
private boolean isAuthenticatedSubjectNotAnonymous() {
return SecurityUtils.getSubject().isAuthenticated()
&& !Authentications.isAuthenticatedSubjectAnonymous();
} }
} }

View File

@@ -21,7 +21,7 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE. * SOFTWARE.
*/ */
package sonia.scm.api.v2.resources; package sonia.scm.api.v2.resources;
import de.otto.edison.hal.Embedded; import de.otto.edison.hal.Embedded;
@@ -30,7 +30,6 @@ import org.apache.shiro.SecurityUtils;
import org.apache.shiro.subject.PrincipalCollection; import org.apache.shiro.subject.PrincipalCollection;
import org.apache.shiro.subject.Subject; import org.apache.shiro.subject.Subject;
import sonia.scm.group.GroupCollector; import sonia.scm.group.GroupCollector;
import sonia.scm.security.Authentications;
import sonia.scm.user.User; import sonia.scm.user.User;
import sonia.scm.user.UserManager; import sonia.scm.user.UserManager;
import sonia.scm.user.UserPermissions; import sonia.scm.user.UserPermissions;
@@ -83,16 +82,14 @@ public class MeDtoFactory extends HalAppenderMapper {
private MeDto createDto(User user) { private MeDto createDto(User user) {
Links.Builder linksBuilder = linkingTo().self(resourceLinks.me().self()); Links.Builder linksBuilder = linkingTo().self(resourceLinks.me().self());
if (isNotAnonymous(user)) { if (UserPermissions.delete(user).isPermitted()) {
if (UserPermissions.delete(user).isPermitted()) { linksBuilder.single(link("delete", resourceLinks.me().delete(user.getName())));
linksBuilder.single(link("delete", resourceLinks.me().delete(user.getName()))); }
} if (UserPermissions.modify(user).isPermitted()) {
if (UserPermissions.modify(user).isPermitted()) { linksBuilder.single(link("update", resourceLinks.me().update(user.getName())));
linksBuilder.single(link("update", resourceLinks.me().update(user.getName()))); }
} if (userManager.isTypeDefault(user) && UserPermissions.changePassword(user).isPermitted()) {
if (userManager.isTypeDefault(user) && UserPermissions.changePassword(user).isPermitted()) { linksBuilder.single(link("password", resourceLinks.me().passwordChange()));
linksBuilder.single(link("password", resourceLinks.me().passwordChange()));
}
} }
Embedded.Builder embeddedBuilder = embeddedBuilder(); Embedded.Builder embeddedBuilder = embeddedBuilder();
@@ -100,8 +97,4 @@ public class MeDtoFactory extends HalAppenderMapper {
return new MeDto(linksBuilder.build(), embeddedBuilder.build()); return new MeDto(linksBuilder.build(), embeddedBuilder.build());
} }
private boolean isNotAnonymous(User user) {
return !Authentications.isSubjectAnonymous(user.getName());
}
} }

View File

@@ -27,9 +27,12 @@ package sonia.scm.api.v2.resources;
import de.otto.edison.hal.Links; import de.otto.edison.hal.Links;
import org.mapstruct.AfterMapping; import org.mapstruct.AfterMapping;
import org.mapstruct.Mapper; import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
import org.mapstruct.MappingTarget; import org.mapstruct.MappingTarget;
import org.mapstruct.Named;
import sonia.scm.config.ConfigurationPermissions; import sonia.scm.config.ConfigurationPermissions;
import sonia.scm.config.ScmConfiguration; import sonia.scm.config.ScmConfiguration;
import sonia.scm.security.AnonymousMode;
import javax.inject.Inject; import javax.inject.Inject;
@@ -44,6 +47,15 @@ public abstract class ScmConfigurationToConfigDtoMapper extends BaseMapper<ScmCo
@Inject @Inject
private ResourceLinks resourceLinks; private ResourceLinks resourceLinks;
@Mapping(target = "anonymousAccessEnabled", source = "anonymousMode", qualifiedByName = "mapAnonymousAccess")
@Mapping(target = "attributes", ignore = true)
public abstract ConfigDto map(ScmConfiguration scmConfiguration);
@Named("mapAnonymousAccess")
boolean mapAnonymousAccess(AnonymousMode anonymousMode) {
return anonymousMode != AnonymousMode.OFF;
}
@AfterMapping @AfterMapping
void appendLinks(ScmConfiguration config, @MappingTarget ConfigDto target) { void appendLinks(ScmConfiguration config, @MappingTarget ConfigDto target) {
Links.Builder linksBuilder = linkingTo().self(resourceLinks.config().self()); Links.Builder linksBuilder = linkingTo().self(resourceLinks.config().self());

View File

@@ -21,7 +21,7 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE. * SOFTWARE.
*/ */
package sonia.scm.api.v2.resources; package sonia.scm.api.v2.resources;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
@@ -30,7 +30,6 @@ import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponse;
import org.apache.shiro.authc.credential.PasswordService; import org.apache.shiro.authc.credential.PasswordService;
import sonia.scm.group.GroupPermissions;
import sonia.scm.search.SearchRequest; import sonia.scm.search.SearchRequest;
import sonia.scm.search.SearchUtil; import sonia.scm.search.SearchUtil;
import sonia.scm.user.User; import sonia.scm.user.User;

View File

@@ -92,6 +92,7 @@ public class BearerRealm extends AuthenticatingRealm
checkArgument(token instanceof BearerToken, "%s is required", BearerToken.class); checkArgument(token instanceof BearerToken, "%s is required", BearerToken.class);
BearerToken bt = (BearerToken) token; BearerToken bt = (BearerToken) token;
AccessToken accessToken = tokenResolver.resolve(bt); AccessToken accessToken = tokenResolver.resolve(bt);
return helper.authenticationInfoBuilder(accessToken.getSubject()) return helper.authenticationInfoBuilder(accessToken.getSubject())

View File

@@ -21,22 +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.
*/ */
package sonia.scm.security; package sonia.scm.security;
import io.jsonwebtoken.Claims; import io.jsonwebtoken.Claims;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.JwtException; import io.jsonwebtoken.JwtException;
import io.jsonwebtoken.Jwts; import io.jsonwebtoken.Jwts;
import java.util.Set;
import javax.inject.Inject;
import org.apache.shiro.authc.AuthenticationException; import org.apache.shiro.authc.AuthenticationException;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import sonia.scm.plugin.Extension; import sonia.scm.plugin.Extension;
import javax.inject.Inject;
import java.util.Set;
/** /**
* Jwt implementation of {@link AccessTokenResolver}. * Jwt implementation of {@link AccessTokenResolver}.
* *
* @author Sebastian Sdorra * @author Sebastian Sdorra
* @since 2.0.0 * @since 2.0.0
*/ */
@@ -47,7 +49,7 @@ public final class JwtAccessTokenResolver implements AccessTokenResolver {
* the logger for JwtAccessTokenResolver * the logger for JwtAccessTokenResolver
*/ */
private static final Logger LOG = LoggerFactory.getLogger(JwtAccessTokenResolver.class); private static final Logger LOG = LoggerFactory.getLogger(JwtAccessTokenResolver.class);
private final SecureKeyResolver keyResolver; private final SecureKeyResolver keyResolver;
private final Set<AccessTokenValidator> validators; private final Set<AccessTokenValidator> validators;
@@ -56,7 +58,7 @@ public final class JwtAccessTokenResolver implements AccessTokenResolver {
this.keyResolver = keyResolver; this.keyResolver = keyResolver;
this.validators = validators; this.validators = validators;
} }
@Override @Override
public JwtAccessToken resolve(BearerToken bearerToken) { public JwtAccessToken resolve(BearerToken bearerToken) {
try { try {
@@ -71,6 +73,8 @@ public final class JwtAccessTokenResolver implements AccessTokenResolver {
validate(token); validate(token);
return token; return token;
} catch (ExpiredJwtException ex) {
throw new TokenExpiredException("The jwt token has been expired", ex);
} catch (JwtException ex) { } catch (JwtException ex) {
throw new AuthenticationException("signature is invalid", ex); throw new AuthenticationException("signature is invalid", ex);
} }
@@ -92,5 +96,5 @@ public final class JwtAccessTokenResolver implements AccessTokenResolver {
private String createValidationFailedMessage(AccessTokenValidator validator, AccessToken accessToken) { private String createValidationFailedMessage(AccessTokenValidator validator, AccessToken accessToken) {
return String.format("token %s is invalid, marked by validator %s", accessToken.getId(), validator.getClass()); return String.format("token %s is invalid, marked by validator %s", accessToken.getId(), validator.getClass());
} }
} }

View File

@@ -29,6 +29,7 @@ import lombok.NoArgsConstructor;
import sonia.scm.SCMContextProvider; import sonia.scm.SCMContextProvider;
import sonia.scm.config.ScmConfiguration; import sonia.scm.config.ScmConfiguration;
import sonia.scm.migration.UpdateStep; import sonia.scm.migration.UpdateStep;
import sonia.scm.plugin.Extension;
import sonia.scm.security.AnonymousMode; import sonia.scm.security.AnonymousMode;
import sonia.scm.store.ConfigurationStore; import sonia.scm.store.ConfigurationStore;
import sonia.scm.store.ConfigurationStoreFactory; import sonia.scm.store.ConfigurationStoreFactory;
@@ -41,11 +42,12 @@ import javax.xml.bind.JAXBException;
import javax.xml.bind.annotation.XmlAccessType; import javax.xml.bind.annotation.XmlAccessType;
import javax.xml.bind.annotation.XmlAccessorType; import javax.xml.bind.annotation.XmlAccessorType;
import javax.xml.bind.annotation.XmlRootElement; import javax.xml.bind.annotation.XmlRootElement;
import java.io.File;
import java.nio.file.Path; import java.nio.file.Path;
import java.nio.file.Paths;
import static sonia.scm.version.Version.parse; import static sonia.scm.version.Version.parse;
@Extension
public class AnonymousModeUpdateStep implements UpdateStep { public class AnonymousModeUpdateStep implements UpdateStep {
private final SCMContextProvider contextProvider; private final SCMContextProvider contextProvider;
@@ -62,12 +64,14 @@ public class AnonymousModeUpdateStep implements UpdateStep {
Path configFile = determineConfigDirectory().resolve("config" + StoreConstants.FILE_EXTENSION); Path configFile = determineConfigDirectory().resolve("config" + StoreConstants.FILE_EXTENSION);
if (configFile.toFile().exists()) { if (configFile.toFile().exists()) {
PreUpdateScmConfiguration oldConfig = getPreUpdateScmConfigurationFromOldConfig(configFile);
ScmConfiguration config = configStore.get(); ScmConfiguration config = configStore.get();
if (getPreUpdateScmConfigurationFromOldConfig(configFile).isAnonymousAccessEnabled()) { if (oldConfig.isAnonymousAccessEnabled()) {
config.setAnonymousMode(AnonymousMode.PROTOCOL_ONLY); config.setAnonymousMode(AnonymousMode.PROTOCOL_ONLY);
} else { } else {
config.setAnonymousMode(AnonymousMode.OFF); config.setAnonymousMode(AnonymousMode.OFF);
} }
configStore.set(config);
} }
} }
@@ -87,7 +91,7 @@ public class AnonymousModeUpdateStep implements UpdateStep {
} }
private Path determineConfigDirectory() { private Path determineConfigDirectory() {
return new File(contextProvider.getBaseDirectory(), StoreConstants.CONFIG_DIRECTORY_NAME).toPath(); return contextProvider.resolve(Paths.get(StoreConstants.CONFIG_DIRECTORY_NAME));
} }
@XmlRootElement(name = "scm-config") @XmlRootElement(name = "scm-config")

View File

@@ -81,37 +81,37 @@ public class AuthenticationResourceTest {
private static final String AUTH_JSON_TRILLIAN = "{\n" + private static final String AUTH_JSON_TRILLIAN = "{\n" +
"\t\"cookie\": true,\n" + "\t\"cookie\": true,\n" +
"\t\"grantType\": \"password\",\n" + "\t\"grant_type\": \"password\",\n" +
"\t\"username\": \"trillian\",\n" + "\t\"username\": \"trillian\",\n" +
"\t\"password\": \"secret\"\n" + "\t\"password\": \"secret\"\n" +
"}"; "}";
private static final String AUTH_FORMENCODED_TRILLIAN = "cookie=true&grantType=password&username=trillian&password=secret"; private static final String AUTH_FORMENCODED_TRILLIAN = "cookie=true&grant_type=password&username=trillian&password=secret";
private static final String AUTH_JSON_TRILLIAN_WRONG_PW = "{\n" + private static final String AUTH_JSON_TRILLIAN_WRONG_PW = "{\n" +
"\t\"cookie\": true,\n" + "\t\"cookie\": true,\n" +
"\t\"grantType\": \"password\",\n" + "\t\"grant_type\": \"password\",\n" +
"\t\"username\": \"trillian\",\n" + "\t\"username\": \"trillian\",\n" +
"\t\"password\": \"justWrong\"\n" + "\t\"password\": \"justWrong\"\n" +
"}"; "}";
private static final String AUTH_JSON_NOT_EXISTING_USER = "{\n" + private static final String AUTH_JSON_NOT_EXISTING_USER = "{\n" +
"\t\"cookie\": true,\n" + "\t\"cookie\": true,\n" +
"\t\"grantType\": \"password\",\n" + "\t\"grant_type\": \"password\",\n" +
"\t\"username\": \"iDoNotExist\",\n" + "\t\"username\": \"iDoNotExist\",\n" +
"\t\"password\": \"doesNotMatter\"\n" + "\t\"password\": \"doesNotMatter\"\n" +
"}"; "}";
private static final String AUTH_JSON_WITHOUT_USERNAME = String.join("\n", private static final String AUTH_JSON_WITHOUT_USERNAME = String.join("\n",
"{", "{",
"\"grantType\": \"password\",", "\"grant_type\": \"password\",",
"\"password\": \"tricia123\"", "\"password\": \"tricia123\"",
"}" "}"
); );
private static final String AUTH_JSON_WITHOUT_PASSWORD = String.join("\n", private static final String AUTH_JSON_WITHOUT_PASSWORD = String.join("\n",
"{", "{",
"\"grantType\": \"password\",", "\"grant_type\": \"password\",",
"\"username\": \"trillian\"", "\"username\": \"trillian\"",
"}" "}"
); );
@@ -125,7 +125,7 @@ public class AuthenticationResourceTest {
private static final String AUTH_JSON_WITH_INVALID_GRANT_TYPE = String.join("\n", private static final String AUTH_JSON_WITH_INVALID_GRANT_TYPE = String.join("\n",
"{", "{",
"\"grantType\": \"el speciale\",", "\"grant_type\": \"el speciale\",",
"\"username\": \"trillian\",", "\"username\": \"trillian\",",
"\"password\": \"tricia123\"", "\"password\": \"tricia123\"",
"}" "}"

View File

@@ -21,7 +21,7 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE. * SOFTWARE.
*/ */
package sonia.scm.api.v2.resources; package sonia.scm.api.v2.resources;
import org.junit.Before; import org.junit.Before;
@@ -42,9 +42,9 @@ public class ConfigDtoToScmConfigurationMapperTest {
@InjectMocks @InjectMocks
private ConfigDtoToScmConfigurationMapperImpl mapper; private ConfigDtoToScmConfigurationMapperImpl mapper;
private String[] expectedUsers = { "trillian", "arthur" }; private String[] expectedUsers = {"trillian", "arthur"};
private String[] expectedGroups = { "admin", "plebs" }; private String[] expectedGroups = {"admin", "plebs"};
private String[] expectedExcludes = { "ex", "clude" }; private String[] expectedExcludes = {"ex", "clude"};
@Before @Before
public void init() { public void init() {
@@ -56,27 +56,42 @@ public class ConfigDtoToScmConfigurationMapperTest {
ConfigDto dto = createDefaultDto(); ConfigDto dto = createDefaultDto();
ScmConfiguration config = mapper.map(dto); ScmConfiguration config = mapper.map(dto);
assertEquals("prPw" , config.getProxyPassword()); assertEquals("prPw", config.getProxyPassword());
assertEquals(42 , config.getProxyPort()); assertEquals(42, config.getProxyPort());
assertEquals("srvr" , config.getProxyServer()); assertEquals("srvr", config.getProxyServer());
assertEquals("user" , config.getProxyUser()); assertEquals("user", config.getProxyUser());
assertTrue(config.isEnableProxy()); assertTrue(config.isEnableProxy());
assertEquals("realm" , config.getRealmDescription()); assertEquals("realm", config.getRealmDescription());
assertTrue(config.isDisableGroupingGrid()); assertTrue(config.isDisableGroupingGrid());
assertEquals("yyyy" , config.getDateFormat()); assertEquals("yyyy", config.getDateFormat());
assertTrue(config.getAnonymousMode() == AnonymousMode.FULL); assertEquals(AnonymousMode.PROTOCOL_ONLY, config.getAnonymousMode());
assertEquals("baseurl" , config.getBaseUrl()); assertEquals("baseurl", config.getBaseUrl());
assertTrue(config.isForceBaseUrl()); assertTrue(config.isForceBaseUrl());
assertEquals(41 , config.getLoginAttemptLimit()); assertEquals(41, config.getLoginAttemptLimit());
assertTrue("proxyExcludes", config.getProxyExcludes().containsAll(Arrays.asList(expectedExcludes))); assertTrue("proxyExcludes", config.getProxyExcludes().containsAll(Arrays.asList(expectedExcludes)));
assertTrue(config.isSkipFailedAuthenticators()); assertTrue(config.isSkipFailedAuthenticators());
assertEquals("https://plug.ins" , config.getPluginUrl()); assertEquals("https://plug.ins", config.getPluginUrl());
assertEquals(40 , config.getLoginAttemptLimitTimeout()); assertEquals(40, config.getLoginAttemptLimitTimeout());
assertTrue(config.isEnabledXsrfProtection()); assertTrue(config.isEnabledXsrfProtection());
assertEquals("username", config.getNamespaceStrategy()); assertEquals("username", config.getNamespaceStrategy());
assertEquals("https://scm-manager.org/login-info", config.getLoginInfoUrl()); assertEquals("https://scm-manager.org/login-info", config.getLoginInfoUrl());
} }
@Test
public void shouldMapAnonymousAccessFieldToAnonymousMode() {
ConfigDto dto = createDefaultDto();
ScmConfiguration config = mapper.map(dto);
assertEquals(AnonymousMode.PROTOCOL_ONLY, config.getAnonymousMode());
dto.setAnonymousMode(null);
dto.setAnonymousAccessEnabled(false);
ScmConfiguration config2 = mapper.map(dto);
assertEquals(AnonymousMode.OFF, config2.getAnonymousMode());
}
private ConfigDto createDefaultDto() { private ConfigDto createDefaultDto() {
ConfigDto configDto = new ConfigDto(); ConfigDto configDto = new ConfigDto();
configDto.setProxyPassword("prPw"); configDto.setProxyPassword("prPw");
@@ -87,7 +102,7 @@ public class ConfigDtoToScmConfigurationMapperTest {
configDto.setRealmDescription("realm"); configDto.setRealmDescription("realm");
configDto.setDisableGroupingGrid(true); configDto.setDisableGroupingGrid(true);
configDto.setDateFormat("yyyy"); configDto.setDateFormat("yyyy");
configDto.setAnonymousMode(AnonymousMode.FULL); configDto.setAnonymousMode(AnonymousMode.PROTOCOL_ONLY);
configDto.setBaseUrl("baseurl"); configDto.setBaseUrl("baseurl");
configDto.setForceBaseUrl(true); configDto.setForceBaseUrl(true);
configDto.setLoginAttemptLimit(41); configDto.setLoginAttemptLimit(41);

View File

@@ -186,19 +186,6 @@ class MeDtoFactoryTest {
assertThat(dto.getLinks().getLinkBy("password")).isNotPresent(); assertThat(dto.getLinks().getLinkBy("password")).isNotPresent();
} }
@Test
void shouldNotGetPasswordLinkForAnonymousUser() {
User user = SCMContext.ANONYMOUS;
prepareSubject(user);
when(userManager.isTypeDefault(any())).thenReturn(true);
when(UserPermissions.changePassword(user).isPermitted()).thenReturn(true);
MeDto dto = meDtoFactory.create();
assertThat(dto.getLinks().getLinkBy("password")).isNotPresent();
}
@Test @Test
void shouldAppendOnlySelfLinkIfAnonymousUser() { void shouldAppendOnlySelfLinkIfAnonymousUser() {
User user = SCMContext.ANONYMOUS; User user = SCMContext.ANONYMOUS;

View File

@@ -21,7 +21,7 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE. * SOFTWARE.
*/ */
package sonia.scm.api.v2.resources; package sonia.scm.api.v2.resources;
import org.apache.shiro.subject.Subject; import org.apache.shiro.subject.Subject;
@@ -49,11 +49,11 @@ import static org.mockito.MockitoAnnotations.initMocks;
public class ScmConfigurationToConfigDtoMapperTest { public class ScmConfigurationToConfigDtoMapperTest {
private URI baseUri = URI.create("http://example.com/base/"); private URI baseUri = URI.create("http://example.com/base/");
private String[] expectedUsers = { "trillian", "arthur" }; private String[] expectedUsers = {"trillian", "arthur"};
private String[] expectedGroups = { "admin", "plebs" }; private String[] expectedGroups = {"admin", "plebs"};
private String[] expectedExcludes = { "ex", "clude" }; private String[] expectedExcludes = {"ex", "clude"};
@SuppressWarnings("unused") // Is injected @SuppressWarnings("unused") // Is injected
private ResourceLinks resourceLinks = ResourceLinksMock.createMock(baseUri); private ResourceLinks resourceLinks = ResourceLinksMock.createMock(baseUri);
@@ -87,22 +87,22 @@ public class ScmConfigurationToConfigDtoMapperTest {
when(subject.isPermitted("configuration:write:global")).thenReturn(true); when(subject.isPermitted("configuration:write:global")).thenReturn(true);
ConfigDto dto = mapper.map(config); ConfigDto dto = mapper.map(config);
assertEquals("heartOfGold" , dto.getProxyPassword()); assertEquals("heartOfGold", dto.getProxyPassword());
assertEquals(1234 , dto.getProxyPort()); assertEquals(1234, dto.getProxyPort());
assertEquals("proxyserver" , dto.getProxyServer()); assertEquals("proxyserver", dto.getProxyServer());
assertEquals("trillian" , dto.getProxyUser()); assertEquals("trillian", dto.getProxyUser());
assertTrue(dto.isEnableProxy()); assertTrue(dto.isEnableProxy());
assertEquals("description" , dto.getRealmDescription()); assertEquals("description", dto.getRealmDescription());
assertTrue(dto.isDisableGroupingGrid()); assertTrue(dto.isDisableGroupingGrid());
assertEquals("dd" , dto.getDateFormat()); assertEquals("dd", dto.getDateFormat());
assertSame(dto.getAnonymousMode(), AnonymousMode.FULL); assertSame(AnonymousMode.FULL, dto.getAnonymousMode());
assertEquals("baseurl" , dto.getBaseUrl()); assertEquals("baseurl", dto.getBaseUrl());
assertTrue(dto.isForceBaseUrl()); assertTrue(dto.isForceBaseUrl());
assertEquals(1 , dto.getLoginAttemptLimit()); assertEquals(1, dto.getLoginAttemptLimit());
assertTrue("proxyExcludes", dto.getProxyExcludes().containsAll(Arrays.asList(expectedExcludes))); assertTrue("proxyExcludes", dto.getProxyExcludes().containsAll(Arrays.asList(expectedExcludes)));
assertTrue(dto.isSkipFailedAuthenticators()); assertTrue(dto.isSkipFailedAuthenticators());
assertEquals("pluginurl" , dto.getPluginUrl()); assertEquals("pluginurl", dto.getPluginUrl());
assertEquals(2 , dto.getLoginAttemptLimitTimeout()); assertEquals(2, dto.getLoginAttemptLimitTimeout());
assertTrue(dto.isEnabledXsrfProtection()); assertTrue(dto.isEnabledXsrfProtection());
assertEquals("username", dto.getNamespaceStrategy()); assertEquals("username", dto.getNamespaceStrategy());
assertEquals("https://scm-manager.org/login-info", dto.getLoginInfoUrl()); assertEquals("https://scm-manager.org/login-info", dto.getLoginInfoUrl());
@@ -123,6 +123,21 @@ public class ScmConfigurationToConfigDtoMapperTest {
assertFalse(dto.getLinks().hasLink("update")); assertFalse(dto.getLinks().hasLink("update"));
} }
@Test
public void shouldMapAnonymousAccessField() {
ScmConfiguration config = createConfiguration();
when(subject.hasRole("configuration:write:global")).thenReturn(false);
ConfigDto dto = mapper.map(config);
assertTrue(dto.isAnonymousAccessEnabled());
config.setAnonymousMode(AnonymousMode.OFF);
ConfigDto secondDto = mapper.map(config);
assertFalse(secondDto.isAnonymousAccessEnabled());
}
private ScmConfiguration createConfiguration() { private ScmConfiguration createConfiguration() {
ScmConfiguration config = new ScmConfiguration(); ScmConfiguration config = new ScmConfiguration();
config.setProxyPassword("heartOfGold"); config.setProxyPassword("heartOfGold");

View File

@@ -21,7 +21,7 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE. * SOFTWARE.
*/ */
package sonia.scm.security; package sonia.scm.security;
import org.apache.shiro.authc.AuthenticationInfo; import org.apache.shiro.authc.AuthenticationInfo;

View File

@@ -130,7 +130,7 @@ public class JwtAccessTokenResolverTest {
String compact = createCompactToken("trillian", secureKey, exp, Scope.empty()); String compact = createCompactToken("trillian", secureKey, exp, Scope.empty());
// expect exception // expect exception
expectedException.expect(AuthenticationException.class); expectedException.expect(TokenExpiredException.class);
expectedException.expectCause(instanceOf(ExpiredJwtException.class)); expectedException.expectCause(instanceOf(ExpiredJwtException.class));
BearerToken bearer = BearerToken.valueOf(compact); BearerToken bearer = BearerToken.valueOf(compact);

View File

@@ -42,8 +42,10 @@ import java.io.IOException;
import java.net.URL; import java.net.URL;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.nio.file.Paths;
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.when; import static org.mockito.Mockito.when;
import static sonia.scm.store.InMemoryConfigurationStoreFactory.create; import static sonia.scm.store.InMemoryConfigurationStoreFactory.create;
@@ -60,8 +62,8 @@ class AnonymousModeUpdateStepTest {
@BeforeEach @BeforeEach
void initUpdateStep(@TempDir Path tempDir) throws IOException { void initUpdateStep(@TempDir Path tempDir) throws IOException {
when(contextProvider.getBaseDirectory()).thenReturn(tempDir.toFile()); when(contextProvider.resolve(any(Path.class))).thenReturn(tempDir.toAbsolutePath());
configDir = tempDir.resolve("config"); configDir = tempDir;
Files.createDirectories(configDir); Files.createDirectories(configDir);
InMemoryConfigurationStoreFactory inMemoryConfigurationStoreFactory = create(); InMemoryConfigurationStoreFactory inMemoryConfigurationStoreFactory = create();
configurationStore = inMemoryConfigurationStoreFactory.get("config", null); configurationStore = inMemoryConfigurationStoreFactory.get("config", null);