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

@@ -312,10 +312,23 @@ public class ScmConfiguration implements Configuration {
return realmDescription;
}
/**
* since 2.4.0
* @return anonymousMode
*/
public AnonymousMode getAnonymousMode() {
return anonymousMode;
}
/**
* @deprecated since 2.4.0
* @use {@link ScmConfiguration#getAnonymousMode} instead
*/
@Deprecated
public boolean isAnonymousAccessEnabled() {
return anonymousMode != AnonymousMode.OFF;
}
public boolean isDisableGroupingGrid() {
return disableGroupingGrid;
}
@@ -361,6 +374,23 @@ public class ScmConfiguration implements Configuration {
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) {
this.anonymousMode = mode;
}

View File

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

View File

@@ -22,42 +22,34 @@
* SOFTWARE.
*/
package sonia.scm.web.filter;
package sonia.scm.security;
import java.time.Instant;
import java.util.Base64;
import org.apache.shiro.authc.AuthenticationException;
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.security.AnonymousMode;
import sonia.scm.security.AnonymousToken;
import sonia.scm.security.BearerToken;
import sonia.scm.security.TokenExpiredException;
import sonia.scm.util.HttpUtil;
import sonia.scm.util.Util;
import sonia.scm.web.WebTokenGenerator;
@@ -50,8 +50,6 @@ import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.Set;
import static sonia.scm.web.filter.JwtValidator.isJwtTokenExpired;
/**
* Handles authentication, if a one of the {@link WebTokenGenerator} returns
* an {@link AuthenticationToken}.
@@ -68,7 +66,6 @@ public class AuthenticationFilter extends HttpFilter {
* marker for failed authentication
*/
private static final String ATTRIBUTE_FAILED_AUTH = "sonia.scm.auth.failed";
private static final String HEADER_AUTHORIZATION = "Authorization";
private final Set<WebTokenGenerator> tokenGenerators;
protected ScmConfiguration configuration;
@@ -102,9 +99,7 @@ public class AuthenticationFilter extends HttpFilter {
AuthenticationToken token = createToken(request);
if (token instanceof BearerToken && isJwtTokenExpired(((BearerToken) token).getCredentials())) {
handleUnauthorized(request, response, chain);
} else if (token != null) {
if (token != null) {
logger.trace(
"found authentication token on request, start authentication");
handleAuthentication(request, response, chain, subject, token);
@@ -213,6 +208,13 @@ public class AuthenticationFilter extends HttpFilter {
try {
subject.login(token);
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) {
logger.warn("authentication failed", ex);
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", () => {
it("Should show login page without primary navigation", () => {
loginUser("scmadmin", "scmadmin");
before("Disable anonymous access", () => {
cy.login("scmadmin", "scmadmin");
setAnonymousMode("OFF");
cy.byTestId("primary-navigation-logout").click();
});
cy.get("li")
.contains("Logout")
.click();
cy.contains("Please login to proceed");
cy.get("div").not("Login");
cy.get("div").not("Repositories");
it("Should show login page without primary navigation", () => {
cy.byTestId("login-button");
cy.containsNotByTestId("div", "primary-navigation-login");
cy.containsNotByTestId("div", "primary-navigation-repositories");
});
it("Should redirect after login", () => {
loginUser("scmadmin", "scmadmin");
cy.login("scmadmin", "scmadmin");
cy.visit("http://localhost:8081/scm/me");
cy.contains("Profile");
cy.get("li")
.contains("Logout")
.click();
cy.visit("/me");
cy.byTestId("footer-user-profile");
cy.byTestId("primary-navigation-logout").click();
});
});
describe("With Anonymous mode protocol only enabled", () => {
it("Should show login page without primary navigation", () => {
loginUser("scmadmin", "scmadmin");
before("Set anonymous mode to protocol only", () => {
cy.login("scmadmin", "scmadmin");
setAnonymousMode("PROTOCOL_ONLY");
cy.byTestId("primary-navigation-logout").click();
});
// Give anonymous user permissions
cy.get("li")
.contains("Users")
.click();
cy.get("td")
.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();
it("Should show login page without primary navigation", () => {
cy.visit("/repos/");
cy.byTestId("login-button");
cy.containsNotByTestId("div", "primary-navigation-login");
cy.containsNotByTestId("div", "primary-navigation-repositories");
});
cy.get("li")
.contains("Logout")
.click();
cy.visit("http://localhost:8081/scm/repos/");
cy.contains("Please login to proceed");
cy.get("div").not("Login");
cy.get("div").not("Repositories");
after("Disable anonymous access", () => {
cy.login("scmadmin", "scmadmin");
setAnonymousMode("OFF");
cy.byTestId("primary-navigation-logout").click();
});
});
describe("With Anonymous mode fully enabled", () => {
it("Should show repositories overview with Login button in primary navigation", () => {
loginUser("scmadmin", "scmadmin");
before("Set anonymous mode to full", () => {
cy.login("scmadmin", "scmadmin");
setAnonymousMode("FULL");
cy.get("li")
.contains("Logout")
.click();
cy.visit("http://localhost:8081/scm/repos/");
cy.contains("Overview of available repositories");
cy.contains("SCM Anonymous");
cy.get("ul").contains("Login");
// Give anonymous user permissions
cy.byTestId("primary-navigation-users").click();
cy.byTestId("_anonymous").click();
cy.byTestId("user-settings-link").click();
cy.byTestId("user-permissions-link").click();
cy.byTestId("read-all-repositories").click();
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", () => {
cy.visit("http://localhost:8081/scm/login/");
cy.visit("/login/");
cy.byTestId("login-button");
});
it("Should show login page on link click", () => {
cy.visit("http://localhost:8081/scm/repos/");
cy.contains("Overview of available repositories");
cy.contains("Login").click();
cy.contains("Please login to proceed");
cy.visit("/repos/");
cy.byTestId("repository-overview-filter");
cy.byTestId("primary-navigation-login").click();
cy.byTestId("login-button");
});
it("Should login and direct to repositories overview", () => {
loginUser("scmadmin", "scmadmin");
cy.login("scmadmin", "scmadmin");
cy.visit("http://localhost:8081/scm/login");
cy.contains("SCM Administrator");
cy.get("li")
.contains("Logout")
.click();
cy.visit("/login");
cy.byTestId("SCM-Administrator");
cy.byTestId("primary-navigation-logout").click();
});
it("Should logout and direct to login page", () => {
loginUser("scmadmin", "scmadmin");
cy.login("scmadmin", "scmadmin");
cy.visit("http://localhost:8081/scm/repos/");
cy.contains("Overview of available repositories");
cy.contains("SCM Administrator");
cy.contains("Logout").click();
cy.contains("Please login to proceed");
cy.visit("/repos/");
cy.byTestId("repository-overview-filter");
cy.byTestId("SCM-Administrator");
cy.byTestId("primary-navigation-logout").click();
cy.byTestId("login-button");
});
it("Anonymous user should not be able to change password", () => {
cy.visit("http://localhost:8081/scm/repos/");
cy.contains("Profile").click();
cy.contains("scm-anonymous@scm-manager.org");
cy.get("ul").not("Settings");
cy.visit("/repos/");
cy.byTestId("footer-user-profile").click();
cy.byTestId("SCM-Anonymous");
cy.containsNotByTestId("ul", "user-settings-link");
cy.get("section").not("Change password");
});
});
describe("Disable anonymous mode after tests", () => {
it("Disable anonymous mode after tests", () => {
loginUser("scmadmin", "scmadmin");
after("Disable anonymous access", () => {
cy.login("scmadmin", "scmadmin");
setAnonymousMode("OFF");
cy.get("li")
.contains("Logout")
.click();
cy.byTestId("primary-navigation-logout").click();
});
});
const setAnonymousMode = anonymousMode => {
cy.get("li")
.contains("Administration")
.click();
cy.get("li")
.contains("Settings")
.click();
cy.get("select")
.contains("Disabled")
.parent()
cy.byTestId("primary-navigation-admin").click();
cy.byTestId("admin-settings-link").click();
cy.byTestId("anonymous-mode-select")
.select(anonymousMode)
.should("have.value", anonymousMode);
cy.get("button")
.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();
cy.byTestId("submit-button").click();
};

View File

@@ -46,3 +46,16 @@
//
// -- This will overwrite an existing command --
// 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 classNames from "classnames";
import { createAttributesForTesting } from "./devBuild";
type Props = {
title?: string;
@@ -31,6 +32,7 @@ type Props = {
color: string;
className?: string;
onClick?: () => void;
testId?: string;
};
export default class Icon extends React.Component<Props> {
@@ -40,12 +42,23 @@ export default class Icon extends React.Component<Props> {
};
render() {
const { title, iconStyle, name, color, className, onClick } = this.props;
const { title, iconStyle, name, color, className, onClick, testId } = this.props;
if (title) {
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;
link: string;
label?: string;
testId?: string;
};
class OverviewPageActions extends React.Component<Props> {
render() {
const { history, location, link } = this.props;
const { history, location, link, testId } = this.props;
return (
<>
<FilterInput
@@ -44,6 +45,7 @@ class OverviewPageActions extends React.Component<Props> {
filter={filter => {
history.push(`/${link}/?q=${filter}`);
}}
testId={testId + "-filter"}
/>
{this.renderCreateButton()}
</>

View File

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

View File

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

View File

@@ -26,6 +26,7 @@ import Button, { ButtonProps } from "./Button";
type SubmitButtonProps = ButtonProps & {
scrollToTop: boolean;
testId?: string;
};
class SubmitButton extends React.Component<SubmitButtonProps> {
@@ -34,7 +35,7 @@ class SubmitButton extends React.Component<SubmitButtonProps> {
};
render() {
const { action, scrollToTop } = this.props;
const { action, scrollToTop, testId } = this.props;
return (
<Button
type="submit"
@@ -48,6 +49,7 @@ class SubmitButton extends React.Component<SubmitButtonProps> {
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;
disabled?: boolean;
helpText?: string;
testId?: string;
};
export default class Checkbox extends React.Component<Props> {
@@ -59,7 +60,7 @@ export default class Checkbox extends React.Component<Props> {
};
render() {
const { label, checked, indeterminate, disabled } = this.props;
const { label, checked, indeterminate, disabled, testId } = this.props;
return (
<div className="field">
{this.renderLabelWithHelp()}
@@ -70,7 +71,7 @@ export default class Checkbox extends React.Component<Props> {
but bulma does.
// @ts-ignore */}
<label className="checkbox" disabled={disabled}>
<TriStateCheckbox checked={checked} indeterminate={indeterminate} disabled={disabled} />
<TriStateCheckbox checked={checked} indeterminate={indeterminate} disabled={disabled} testId={testId} />
{label}
{this.renderHelp()}
</label>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -22,8 +22,8 @@
* SOFTWARE.
*/
import React, { FC } from "react";
import { Me, Links } from "@scm-manager/ui-types";
import { useBinder, ExtensionPoint } from "@scm-manager/ui-extensions";
import { Links, Me } from "@scm-manager/ui-types";
import { ExtensionPoint, useBinder } from "@scm-manager/ui-extensions";
import { AvatarImage } from "../avatar";
import NavLink from "../navigation/NavLink";
import FooterSection from "./FooterSection";
@@ -31,6 +31,7 @@ import styled from "styled-components";
import { EXTENSION_POINT } from "../avatar/Avatar";
import ExternalNavLink from "../navigation/ExternalNavLink";
import { useTranslation } from "react-i18next";
import { createAttributesForTesting, replaceSpacesInTestId } from "../devBuild";
type Props = {
me?: Me;
@@ -43,11 +44,13 @@ type TitleWithIconsProps = {
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 = {
me: Me;
@@ -66,12 +69,12 @@ const AvatarContainer = styled.span`
`;
const TitleWithAvatar: FC<TitleWithAvatarProps> = ({ me }) => (
<>
<div {...createAttributesForTesting(replaceSpacesInTestId(me.displayName))}>
<AvatarContainer className="image is-rounded">
<VCenteredAvatar person={me} representation="rounded" />
</AvatarContainer>
{me.displayName}
</>
</div>
);
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" />;
}
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 (
<footer className="footer">
<section className="section container">
<div className="columns is-size-7">
<FooterSection title={meSectionTile}>
<NavLink to="/me" label={t("footer.user.profile")} />
{meSectionBody}
<NavLink to="/me" label={t("footer.user.profile")} testId="footer-user-profile" />
{me?._links?.password && <NavLink to="/me/settings/password" label={t("profile.changePasswordNavLink")} />}
<ExtensionPoint name="profile.setting" props={extensionProps} renderAll={true} />
</FooterSection>
<FooterSection title={<TitleWithIcon title={t("footer.information.title")} icon="info-circle" />}>
<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 useMenuContext from "./MenuContext";
import useActiveMatch from "./useActiveMatch";
import {createAttributesForTesting} from "../devBuild";
type Props = RoutingProps & {
label: string;
title?: 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 context = useMenuContext();
@@ -53,7 +54,11 @@ const NavLink: FC<Props> = ({ to, activeWhenMatch, activeOnlyWhenExact, icon, la
return (
<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}
{collapsed ? null : label}
</Link>

View File

@@ -40,7 +40,7 @@ class PrimaryNavigation extends React.Component<Props> {
return (to: string, match: string, label: string, linkName: string) => {
const link = links[linkName];
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);
}
};

View File

@@ -23,30 +23,39 @@
*/
import * as React from "react";
import { Route, Link } from "react-router-dom";
import classNames from "classnames";
import { createAttributesForTesting } from "../devBuild";
type Props = {
to: string;
label: string;
match?: string;
activeOnlyWhenExact?: boolean;
className?: string;
testId?: string;
};
class PrimaryNavigationLink extends React.Component<Props> {
renderLink = (route: any) => {
const { to, label, className } = this.props;
const { to, label, testId } = this.props;
return (
<li className={classNames(route.match ? "is-active" : "", className)}>
<Link to={to}>{label}</Link>
<li className={route.match ? "is-active" : ""}>
<Link to={to} {...createAttributesForTesting(testId)}>
{label}
</Link>
</li>
);
};
render() {
const { to, match, activeOnlyWhenExact } = this.props;
const { to, match, activeOnlyWhenExact, testId } = this.props;
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 { RoutingProps } from "./RoutingProps";
import useActiveMatch from "./useActiveMatch";
import { createAttributesForTesting } from "../devBuild";
type Props = RoutingProps & {
label: string;
title?: 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 collapsed = context.isCollapsed();
@@ -61,7 +71,11 @@ const SubNavigation: FC<Props> = ({ to, activeOnlyWhenExact, activeWhenMatch, ic
return (
<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}
</Link>
{childrenList}

View File

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

View File

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

View File

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

View File

@@ -109,18 +109,18 @@ class LoginForm extends React.Component<Props, State> {
<ErrorNotification error={this.areCredentialsInvalid()} />
<form onSubmit={this.handleSubmit}>
<InputField
className="username"
testId="username-input"
placeholder={t("login.username-placeholder")}
autofocus={true}
onChange={this.handleUsernameChange}
/>
<InputField
className="password"
testId="password-input"
placeholder={t("login.password-placeholder")}
type="password"
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>
</TopMarginBox>
</div>

View File

@@ -37,19 +37,14 @@ type Props = RouteComponentProps &
logoutLink: string;
// dispatcher functions
logout: (link: string) => void;
logout: (link: string, callback: () => void) => void;
};
class Logout extends React.Component<Props> {
componentDidMount() {
new Promise((resolve, reject) => {
setTimeout(() => {
if (this.props.logoutLink) {
this.props.logout(this.props.logoutLink);
resolve(this.props.history.push("/login"));
this.props.logout(this.props.logoutLink, () => this.props.history.push("/login"));
}
});
});
}
render() {
@@ -73,7 +68,7 @@ const mapStateToProps = (state: any) => {
const mapDispatchToProps = (dispatch: any) => {
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 { WithTranslation, withTranslation } from "react-i18next";
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 & {
me: Me;
@@ -47,11 +53,11 @@ class ProfileInfo extends React.Component<Props> {
<tbody>
<tr>
<th>{t("profile.username")}</th>
<td>{me.name}</td>
<td {...createAttributesForTesting(replaceSpacesInTestId(me.name))}>{me.name}</td>
</tr>
<tr>
<th>{t("profile.displayName")}</th>
<td>{me.displayName}</td>
<td {...createAttributesForTesting(replaceSpacesInTestId(me.displayName))}>{me.displayName}</td>
</tr>
<tr>
<th>{t("profile.mail")}</th>

View File

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

View File

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

View File

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

View File

@@ -45,6 +45,9 @@ class PermissionCheckbox extends React.Component<Props> {
? t("verbs.repository." + name + ".description")
: 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 (
<Checkbox
key={name}
@@ -54,6 +57,7 @@ class PermissionCheckbox extends React.Component<Props> {
checked={checked}
onChange={onChange}
disabled={disabled}
testId={testId}
/>
);
}

View File

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

View File

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

View File

@@ -42,7 +42,7 @@ class EditUserNavLink extends React.Component<Props> {
if (!this.isEditable()) {
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()) {
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 = () => {

View File

@@ -38,7 +38,7 @@ class ChangePermissionNavLink extends React.Component<Props> {
if (!this.hasPermissionToSetPermission()) {
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 = () => {

View File

@@ -24,7 +24,13 @@
import React from "react";
import { WithTranslation, withTranslation } from "react-i18next";
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 & {
user: User;
@@ -38,11 +44,11 @@ class Details extends React.Component<Props> {
<tbody>
<tr>
<th>{t("user.name")}</th>
<td>{user.name}</td>
<td {...createAttributesForTesting(replaceSpacesInTestId(user.name))}>{user.name}</td>
</tr>
<tr>
<th>{t("user.displayName")}</th>
<td>{user.displayName}</td>
<td {...createAttributesForTesting(replaceSpacesInTestId(user.displayName))}>{user.displayName}</td>
</tr>
<tr>
<th>{t("user.mail")}</th>

View File

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

View File

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

View File

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

View File

@@ -32,8 +32,8 @@ import java.util.List;
public class AuthenticationRequestDto {
@FormParam("grantType")
@JsonProperty("grantType")
@FormParam("grant_type")
@JsonProperty("grant_type")
private String grantType;
@FormParam("username")
@@ -69,7 +69,7 @@ public class AuthenticationRequestDto {
}
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);
}
}

View File

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

View File

@@ -24,8 +24,11 @@
package sonia.scm.api.v2.resources;
import org.mapstruct.AfterMapping;
import org.mapstruct.Mapper;
import org.mapstruct.MappingTarget;
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.
@SuppressWarnings("squid:S3306")
@@ -33,4 +36,15 @@ import sonia.scm.config.ScmConfiguration;
public abstract class ConfigDtoToScmConfigurationMapper {
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

@@ -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.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import org.apache.shiro.SecurityUtils;
import sonia.scm.group.Group;
import sonia.scm.group.GroupManager;
import sonia.scm.group.GroupPermissions;

View File

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

View File

@@ -30,7 +30,6 @@ import org.apache.shiro.SecurityUtils;
import org.apache.shiro.subject.PrincipalCollection;
import org.apache.shiro.subject.Subject;
import sonia.scm.group.GroupCollector;
import sonia.scm.security.Authentications;
import sonia.scm.user.User;
import sonia.scm.user.UserManager;
import sonia.scm.user.UserPermissions;
@@ -83,7 +82,6 @@ public class MeDtoFactory extends HalAppenderMapper {
private MeDto createDto(User user) {
Links.Builder linksBuilder = linkingTo().self(resourceLinks.me().self());
if (isNotAnonymous(user)) {
if (UserPermissions.delete(user).isPermitted()) {
linksBuilder.single(link("delete", resourceLinks.me().delete(user.getName())));
}
@@ -93,15 +91,10 @@ public class MeDtoFactory extends HalAppenderMapper {
if (userManager.isTypeDefault(user) && UserPermissions.changePassword(user).isPermitted()) {
linksBuilder.single(link("password", resourceLinks.me().passwordChange()));
}
}
Embedded.Builder embeddedBuilder = embeddedBuilder();
applyEnrichers(new EdisonHalAppender(linksBuilder, embeddedBuilder), new Me(), user);
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 org.mapstruct.AfterMapping;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
import org.mapstruct.MappingTarget;
import org.mapstruct.Named;
import sonia.scm.config.ConfigurationPermissions;
import sonia.scm.config.ScmConfiguration;
import sonia.scm.security.AnonymousMode;
import javax.inject.Inject;
@@ -44,6 +47,15 @@ public abstract class ScmConfigurationToConfigDtoMapper extends BaseMapper<ScmCo
@Inject
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
void appendLinks(ScmConfiguration config, @MappingTarget ConfigDto target) {
Links.Builder linksBuilder = linkingTo().self(resourceLinks.config().self());

View File

@@ -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.responses.ApiResponse;
import org.apache.shiro.authc.credential.PasswordService;
import sonia.scm.group.GroupPermissions;
import sonia.scm.search.SearchRequest;
import sonia.scm.search.SearchUtil;
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);
BearerToken bt = (BearerToken) token;
AccessToken accessToken = tokenResolver.resolve(bt);
return helper.authenticationInfoBuilder(accessToken.getSubject())

View File

@@ -25,15 +25,17 @@
package sonia.scm.security;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.JwtException;
import io.jsonwebtoken.Jwts;
import java.util.Set;
import javax.inject.Inject;
import org.apache.shiro.authc.AuthenticationException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import sonia.scm.plugin.Extension;
import javax.inject.Inject;
import java.util.Set;
/**
* Jwt implementation of {@link AccessTokenResolver}.
*
@@ -71,6 +73,8 @@ public final class JwtAccessTokenResolver implements AccessTokenResolver {
validate(token);
return token;
} catch (ExpiredJwtException ex) {
throw new TokenExpiredException("The jwt token has been expired", ex);
} catch (JwtException ex) {
throw new AuthenticationException("signature is invalid", ex);
}

View File

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

View File

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

View File

@@ -64,7 +64,7 @@ public class ConfigDtoToScmConfigurationMapperTest {
assertEquals("realm", config.getRealmDescription());
assertTrue(config.isDisableGroupingGrid());
assertEquals("yyyy", config.getDateFormat());
assertTrue(config.getAnonymousMode() == AnonymousMode.FULL);
assertEquals(AnonymousMode.PROTOCOL_ONLY, config.getAnonymousMode());
assertEquals("baseurl", config.getBaseUrl());
assertTrue(config.isForceBaseUrl());
assertEquals(41, config.getLoginAttemptLimit());
@@ -77,6 +77,21 @@ public class ConfigDtoToScmConfigurationMapperTest {
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() {
ConfigDto configDto = new ConfigDto();
configDto.setProxyPassword("prPw");
@@ -87,7 +102,7 @@ public class ConfigDtoToScmConfigurationMapperTest {
configDto.setRealmDescription("realm");
configDto.setDisableGroupingGrid(true);
configDto.setDateFormat("yyyy");
configDto.setAnonymousMode(AnonymousMode.FULL);
configDto.setAnonymousMode(AnonymousMode.PROTOCOL_ONLY);
configDto.setBaseUrl("baseurl");
configDto.setForceBaseUrl(true);
configDto.setLoginAttemptLimit(41);

View File

@@ -186,19 +186,6 @@ class MeDtoFactoryTest {
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
void shouldAppendOnlySelfLinkIfAnonymousUser() {
User user = SCMContext.ANONYMOUS;

View File

@@ -95,7 +95,7 @@ public class ScmConfigurationToConfigDtoMapperTest {
assertEquals("description", dto.getRealmDescription());
assertTrue(dto.isDisableGroupingGrid());
assertEquals("dd", dto.getDateFormat());
assertSame(dto.getAnonymousMode(), AnonymousMode.FULL);
assertSame(AnonymousMode.FULL, dto.getAnonymousMode());
assertEquals("baseurl", dto.getBaseUrl());
assertTrue(dto.isForceBaseUrl());
assertEquals(1, dto.getLoginAttemptLimit());
@@ -123,6 +123,21 @@ public class ScmConfigurationToConfigDtoMapperTest {
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() {
ScmConfiguration config = new ScmConfiguration();
config.setProxyPassword("heartOfGold");

View File

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

View File

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