Merge with default

This commit is contained in:
Rene Pfeuffer
2019-12-17 14:14:34 +01:00
43 changed files with 2400 additions and 1112 deletions

View File

@@ -450,7 +450,7 @@
<plugin> <plugin>
<groupId>sonia.scm.maven</groupId> <groupId>sonia.scm.maven</groupId>
<artifactId>smp-maven-plugin</artifactId> <artifactId>smp-maven-plugin</artifactId>
<version>1.0.0-alpha-8</version> <version>1.0.0-rc1</version>
</plugin> </plugin>
<plugin> <plugin>

View File

@@ -37,6 +37,8 @@ import com.google.common.base.Preconditions;
import com.google.common.base.Strings; import com.google.common.base.Strings;
import org.apache.shiro.authc.AuthenticationToken; import org.apache.shiro.authc.AuthenticationToken;
import javax.annotation.Nullable;
/** /**
* Token used for authentication with bearer tokens. * Token used for authentication with bearer tokens.
* *
@@ -45,14 +47,17 @@ import org.apache.shiro.authc.AuthenticationToken;
*/ */
public final class BearerToken implements AuthenticationToken { public final class BearerToken implements AuthenticationToken {
private final SessionId sessionId;
private final String raw; private final String raw;
/** /**
* Constructs a new instance. * Constructs a new instance.
* *
* @param sessionId session id of the client
* @param raw raw bearer token * @param raw raw bearer token
*/ */
private BearerToken(String raw) { private BearerToken(SessionId sessionId, String raw) {
this.sessionId = sessionId;
this.raw = raw; this.raw = raw;
} }
@@ -67,13 +72,13 @@ public final class BearerToken implements AuthenticationToken {
} }
/** /**
* Returns always {@code null}. * Returns the session id or {@code null}.
* *
* @return {@code null} * @return session id or {@code null}
*/ */
@Override @Override
public Object getPrincipal() { public SessionId getPrincipal() {
return null; return sessionId;
} }
/** /**
@@ -85,6 +90,23 @@ public final class BearerToken implements AuthenticationToken {
*/ */
public static BearerToken valueOf(String raw){ public static BearerToken valueOf(String raw){
Preconditions.checkArgument(!Strings.isNullOrEmpty(raw), "raw token is required"); Preconditions.checkArgument(!Strings.isNullOrEmpty(raw), "raw token is required");
return new BearerToken(raw); return new BearerToken(null, raw);
}
/**
* Creates a new {@link BearerToken} from raw string representation for the given ui session id.
*
* @param sessionId session id of the client
* @param rawToken bearer token string representation
*
* @return new bearer token
*/
public static BearerToken create(@Nullable String sessionId, String rawToken) {
Preconditions.checkArgument(!Strings.isNullOrEmpty(rawToken), "raw token is required");
SessionId session = null;
if (!Strings.isNullOrEmpty(sessionId)) {
session = SessionId.valueOf(sessionId);
}
return new BearerToken(session, rawToken);
} }
} }

View File

@@ -115,7 +115,7 @@ public final class DAORealmHelper {
UsernamePasswordToken upt = (UsernamePasswordToken) token; UsernamePasswordToken upt = (UsernamePasswordToken) token;
String principal = upt.getUsername(); String principal = upt.getUsername();
return getAuthenticationInfo(principal, null, null); return getAuthenticationInfo(principal, null, null, null);
} }
/** /**
@@ -129,8 +129,9 @@ public final class DAORealmHelper {
return new AuthenticationInfoBuilder(principal); return new AuthenticationInfoBuilder(principal);
} }
private SimpleAuthenticationInfo getAuthenticationInfo(
private AuthenticationInfo getAuthenticationInfo(String principal, String credentials, Scope scope) { String principal, String credentials, Scope scope, SessionId sessionId
) {
checkArgument(!Strings.isNullOrEmpty(principal), "username is required"); checkArgument(!Strings.isNullOrEmpty(principal), "username is required");
LOG.debug("try to authenticate {}", principal); LOG.debug("try to authenticate {}", principal);
@@ -150,6 +151,10 @@ public final class DAORealmHelper {
collection.add(user, realm); collection.add(user, realm);
collection.add(MoreObjects.firstNonNull(scope, Scope.empty()), realm); collection.add(MoreObjects.firstNonNull(scope, Scope.empty()), realm);
if (sessionId != null) {
collection.add(sessionId, realm);
}
String creds = credentials; String creds = credentials;
if (credentials == null) { if (credentials == null) {
@@ -170,7 +175,7 @@ public final class DAORealmHelper {
private String credentials; private String credentials;
private Scope scope; private Scope scope;
private Iterable<String> groups = Collections.emptySet(); private SessionId sessionId;
private AuthenticationInfoBuilder(String principal) { private AuthenticationInfoBuilder(String principal) {
this.principal = principal; this.principal = principal;
@@ -201,17 +206,17 @@ public final class DAORealmHelper {
return this; return this;
} }
// /** /**
// * With groups adds extra groups, besides those which come from the {@link GroupDAO}, to the authentication info. * With the session id.
// * *
// * @param groups extra groups * @param sessionId session id
// * *
// * @return {@code this} * @return {@code this}
// */ */
// public AuthenticationInfoBuilder withGroups(Iterable<String> groups) { public AuthenticationInfoBuilder withSessionId(SessionId sessionId) {
// this.groups = groups; this.sessionId = sessionId;
// return this; return this;
// } }
/** /**
* Build creates the authentication info from the given information. * Build creates the authentication info from the given information.
@@ -219,7 +224,7 @@ public final class DAORealmHelper {
* @return authentication info * @return authentication info
*/ */
public AuthenticationInfo build() { public AuthenticationInfo build() {
return getAuthenticationInfo(principal, credentials, scope); return getAuthenticationInfo(principal, credentials, scope, sessionId);
} }
} }

View File

@@ -0,0 +1,41 @@
package sonia.scm.security;
import com.google.common.base.Preconditions;
import com.google.common.base.Strings;
import java.util.Objects;
/**
* Client side session id.
*/
public final class SessionId {
private final String value;
private SessionId(String value) {
this.value = value;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
SessionId sessionID = (SessionId) o;
return Objects.equals(value, sessionID.value);
}
@Override
public int hashCode() {
return Objects.hash(value);
}
@Override
public String toString() {
return value;
}
public static SessionId valueOf(String value) {
Preconditions.checkArgument(!Strings.isNullOrEmpty(value), "session id could not be empty or null");
return new SessionId(value);
}
}

View File

@@ -116,6 +116,12 @@ public final class HttpUtil
*/ */
public static final String HEADER_SCM_CLIENT = "X-SCM-Client"; public static final String HEADER_SCM_CLIENT = "X-SCM-Client";
/**
* header for identifying the scm-manager client session
* @since 2.0.0
*/
public static final String HEADER_SCM_SESSION = "X-SCM-Session-ID";
/** Field description */ /** Field description */
public static final String HEADER_USERAGENT = "User-Agent"; public static final String HEADER_USERAGENT = "User-Agent";
@@ -698,8 +704,10 @@ public final class HttpUtil
String defaultValue) String defaultValue)
{ {
String value = request.getHeader(header); String value = request.getHeader(header);
if (value == null) {
return MoreObjects.firstNonNull(value, defaultValue); value = defaultValue;
}
return value;
} }
/** /**

View File

@@ -27,9 +27,6 @@ class DAORealmHelperTest {
@Mock @Mock
private UserDAO userDAO; private UserDAO userDAO;
@Mock
private GroupDAO groupDAO;
private DAORealmHelper helper; private DAORealmHelper helper;
@BeforeEach @BeforeEach
@@ -87,6 +84,21 @@ class DAORealmHelperTest {
assertThat(principals.oneByType(Scope.class)).isSameAs(scope); assertThat(principals.oneByType(Scope.class)).isSameAs(scope);
} }
@Test
void shouldReturnAuthenticationInfoWithSessionId() {
User user = new User("trillian");
when(userDAO.get("trillian")).thenReturn(user);
SessionId session = SessionId.valueOf("abc123");
AuthenticationInfo authenticationInfo = helper.authenticationInfoBuilder("trillian")
.withSessionId(session)
.build();
PrincipalCollection principals = authenticationInfo.getPrincipals();
assertThat(principals.oneByType(SessionId.class)).isSameAs(session);
}
@Test @Test
void shouldReturnAuthenticationInfoWithCredentials() { void shouldReturnAuthenticationInfoWithCredentials() {
User user = new User("trillian"); User user = new User("trillian");

View File

@@ -54,6 +54,31 @@ import javax.servlet.http.HttpServletRequest;
public class HttpUtilTest public class HttpUtilTest
{ {
@Test
public void testGetHeader() {
HttpServletRequest request = mock(HttpServletRequest.class);
when(request.getHeader("Test")).thenReturn("Value-One");
String value = HttpUtil.getHeader(request, "Test", "Fallback");
assertEquals("Value-One", value);
}
@Test
public void testGetHeaderWithDefaultValue() {
HttpServletRequest request = mock(HttpServletRequest.class);
String value = HttpUtil.getHeader(request, "Test", "Fallback");
assertEquals("Fallback", value);
}
@Test
public void testGetHeaderWithNullAsDefaultValue() {
HttpServletRequest request = mock(HttpServletRequest.class);
String value = HttpUtil.getHeader(request, "Test", null);
assertNull(value);
}
@Test @Test
public void concatenateTest() { public void concatenateTest() {
assertEquals( assertEquals(

View File

@@ -1,7 +1,7 @@
import React, { FormEvent } from "react"; import React, { FormEvent } from "react";
import { WithTranslation, withTranslation } from "react-i18next"; import { WithTranslation, withTranslation } from "react-i18next";
import { Branch, Repository, Link } from "@scm-manager/ui-types"; import { Branch, Repository, Link } from "@scm-manager/ui-types";
import { apiClient, BranchSelector, ErrorPage, Loading, Subtitle, SubmitButton } from "@scm-manager/ui-components"; import { apiClient, BranchSelector, ErrorPage, Loading, Subtitle, Level, SubmitButton } from "@scm-manager/ui-components";
type Props = WithTranslation & { type Props = WithTranslation & {
repository: Repository; repository: Repository;
@@ -143,11 +143,15 @@ class RepositoryConfig extends React.Component<Props, State> {
} }
const submitButton = disabled ? null : ( const submitButton = disabled ? null : (
<Level
right={
<SubmitButton <SubmitButton
label={t("scm-git-plugin.repo-config.submit")} label={t("scm-git-plugin.repo-config.submit")}
loading={submitPending} loading={submitPending}
disabled={!this.state.selectedBranchName} disabled={!this.state.selectedBranchName}
/> />
}
/>
); );
if (!(loadingBranches || loadingDefaultBranch)) { if (!(loadingBranches || loadingDefaultBranch)) {

View File

@@ -78,6 +78,7 @@
<systemProperties> <systemProperties>
<arg>java.awt.headless=true</arg> <arg>java.awt.headless=true</arg>
<arg>logback.configurationFile=logging.xml</arg> <arg>logback.configurationFile=logging.xml</arg>
<arg>ClassLoaderLeakPreventor.threadWaitMs=100</arg>
</systemProperties> </systemProperties>
</jvmSettings> </jvmSettings>

View File

@@ -18,6 +18,7 @@
<properties> <properties>
<build.script>build</build.script> <build.script>build</build.script>
<skipTests>false</skipTests>
<sonar.language>typescript</sonar.language> <sonar.language>typescript</sonar.language>
<sonar.sources>ui-extensions/src,ui-components/src,ui-webapp/src</sonar.sources> <sonar.sources>ui-extensions/src,ui-components/src,ui-webapp/src</sonar.sources>
<sonar.test.exclusions>**/*.test.js,src/tests/**</sonar.test.exclusions> <sonar.test.exclusions>**/*.test.js,src/tests/**</sonar.test.exclusions>

View File

@@ -49,6 +49,7 @@
"@scm-manager/ui-types": "^2.0.0-SNAPSHOT", "@scm-manager/ui-types": "^2.0.0-SNAPSHOT",
"classnames": "^2.2.6", "classnames": "^2.2.6",
"date-fns": "^2.4.1", "date-fns": "^2.4.1",
"event-source-polyfill": "^1.0.9",
"query-string": "5", "query-string": "5",
"react": "^16.8.6", "react": "^16.8.6",
"react-diff-view": "^1.8.1", "react-diff-view": "^1.8.1",

View File

@@ -336,7 +336,7 @@ exports[`Storyshots DateFromNow Default 1`] = `
exports[`Storyshots Forms|Checkbox Default 1`] = ` exports[`Storyshots Forms|Checkbox Default 1`] = `
<div <div
className="sc-fBuWsC ldmpJA" className="sc-caSCKo brLbbv"
> >
<div <div
className="field" className="field"
@@ -381,7 +381,7 @@ exports[`Storyshots Forms|Checkbox Default 1`] = `
exports[`Storyshots Forms|Checkbox Disabled 1`] = ` exports[`Storyshots Forms|Checkbox Disabled 1`] = `
<div <div
className="sc-fBuWsC ldmpJA" className="sc-caSCKo brLbbv"
> >
<div <div
className="field" className="field"
@@ -409,7 +409,7 @@ exports[`Storyshots Forms|Checkbox Disabled 1`] = `
exports[`Storyshots Forms|Radio Default 1`] = ` exports[`Storyshots Forms|Radio Default 1`] = `
<div <div
className="sc-fMiknA keSQNk" className="sc-gisBJw jHakbY"
> >
<label <label
className="radio" className="radio"
@@ -438,7 +438,7 @@ exports[`Storyshots Forms|Radio Default 1`] = `
exports[`Storyshots Forms|Radio Disabled 1`] = ` exports[`Storyshots Forms|Radio Disabled 1`] = `
<div <div
className="sc-fMiknA keSQNk" className="sc-gisBJw jHakbY"
> >
<label <label
className="radio" className="radio"
@@ -456,6 +456,83 @@ exports[`Storyshots Forms|Radio Disabled 1`] = `
</div> </div>
`; `;
exports[`Storyshots Forms|Textarea OnCancel 1`] = `
<div
className="sc-kjoXOD hVPZau"
>
<div
className="field"
>
<div
className="control"
>
<textarea
className="textarea"
disabled={false}
onChange={[Function]}
onKeyDown={[Function]}
value="Use the escape key to clear the textarea"
/>
</div>
</div>
</div>
`;
exports[`Storyshots Forms|Textarea OnChange 1`] = `
<div
className="sc-kjoXOD hVPZau"
>
<div
className="field"
>
<div
className="control"
>
<textarea
className="textarea"
disabled={false}
onChange={[Function]}
onKeyDown={[Function]}
value="Start typing"
/>
</div>
</div>
<hr />
<p>
Start typing
</p>
</div>
`;
exports[`Storyshots Forms|Textarea OnSubmit 1`] = `
<div
className="sc-kjoXOD hVPZau"
>
<div
className="field"
>
<div
className="control"
>
<textarea
className="textarea"
disabled={false}
onChange={[Function]}
onKeyDown={[Function]}
value="Use the ctrl/command + Enter to submit the textarea"
/>
</div>
</div>
<hr />
<p>
</p>
</div>
`;
exports[`Storyshots Loading Default 1`] = ` exports[`Storyshots Loading Default 1`] = `
<div> <div>
<div <div
@@ -2481,3 +2558,33 @@ exports[`Storyshots Table|Table TextColumn 1`] = `
</tbody> </tbody>
</table> </table>
`; `;
exports[`Storyshots Toast Click to close 1`] = `null`;
exports[`Storyshots Toast Danger 1`] = `null`;
exports[`Storyshots Toast Info 1`] = `null`;
exports[`Storyshots Toast Open/Close 1`] = `
<div
style={
Object {
"padding": "2rem",
}
}
>
<button
className="button is-primary"
onClick={[Function]}
>
Open
Toast
</button>
</div>
`;
exports[`Storyshots Toast Primary 1`] = `null`;
exports[`Storyshots Toast Success 1`] = `null`;
exports[`Storyshots Toast Warning 1`] = `null`;

View File

@@ -1,6 +1,43 @@
import { contextPath } from "./urls"; import { contextPath } from "./urls";
import { createBackendError, ForbiddenError, isBackendError, UnauthorizedError } from "./errors"; // @ts-ignore we have not types for event-source-polyfill
import { BackendErrorContent } from "./errors"; import { EventSourcePolyfill } from "event-source-polyfill";
import { createBackendError, ForbiddenError, isBackendError, UnauthorizedError, BackendErrorContent } from "./errors";
type SubscriptionEvent = {
type: string;
};
type OpenEvent = SubscriptionEvent;
type ErrorEvent = SubscriptionEvent & {
error: Error;
};
type MessageEvent = SubscriptionEvent & {
data: string;
lastEventId?: string;
};
type MessageListeners = {
[eventType: string]: (event: MessageEvent) => void;
};
type SubscriptionContext = {
onOpen?: OpenEvent;
onMessage: MessageListeners;
onError?: ErrorEvent;
};
type SubscriptionArgument = MessageListeners | SubscriptionContext;
type Cancel = () => void;
const sessionId = (
Date.now().toString(36) +
Math.random()
.toString(36)
.substr(2, 5)
).toUpperCase();
const extractXsrfTokenFromJwt = (jwt: string) => { const extractXsrfTokenFromJwt = (jwt: string) => {
const parts = jwt.split("."); const parts = jwt.split(".");
@@ -26,26 +63,34 @@ const extractXsrfToken = () => {
return extractXsrfTokenFromCookie(document.cookie); return extractXsrfTokenFromCookie(document.cookie);
}; };
const applyFetchOptions: (p: RequestInit) => RequestInit = o => { const createRequestHeaders = () => {
if (!o.headers) { const headers: { [key: string]: string } = {
o.headers = {}; // disable caching for now
} Cache: "no-cache",
// @ts-ignore We are sure that here we only get headers of type Record<string, string>
const headers: Record<string, string> = o.headers;
headers["Cache"] = "no-cache";
// identify the request as ajax request // identify the request as ajax request
headers["X-Requested-With"] = "XMLHttpRequest"; "X-Requested-With": "XMLHttpRequest",
// identify the web interface // identify the web interface
headers["X-SCM-Client"] = "WUI"; "X-SCM-Client": "WUI",
// identify the window session
"X-SCM-Session-ID": sessionId
};
const xsrf = extractXsrfToken(); const xsrf = extractXsrfToken();
if (xsrf) { if (xsrf) {
headers["X-XSRF-Token"] = xsrf; headers["X-XSRF-Token"] = xsrf;
} }
return headers;
};
const applyFetchOptions: (p: RequestInit) => RequestInit = o => {
if (o.headers) {
o.headers = {
...createRequestHeaders()
};
} else {
o.headers = createRequestHeaders();
}
o.credentials = "same-origin"; o.credentials = "same-origin";
o.headers = headers;
return o; return o;
}; };
@@ -165,12 +210,39 @@ class ApiClient {
if (!options.headers) { if (!options.headers) {
options.headers = {}; options.headers = {};
} }
// @ts-ignore We are sure that here we only get headers of type Record<string, string> // @ts-ignore We are sure that here we only get headers of type {[name:string]: string}
options.headers["Content-Type"] = contentType; options.headers["Content-Type"] = contentType;
} }
return fetch(createUrl(url), options).then(handleFailure); return fetch(createUrl(url), options).then(handleFailure);
} }
subscribe(url: string, argument: SubscriptionArgument): Cancel {
const es = new EventSourcePolyfill(createUrl(url), {
withCredentials: true,
headers: createRequestHeaders()
});
let listeners: MessageListeners;
// type guard, to identify that argument is of type SubscriptionContext
if ("onMessage" in argument) {
listeners = (argument as SubscriptionContext).onMessage;
if (argument.onError) {
es.onerror = argument.onError;
}
if (argument.onOpen) {
es.onopen = argument.onOpen;
}
} else {
listeners = argument;
}
for (const type in listeners) {
es.addEventListener(type, listeners[type]);
}
return () => es.close();
}
} }
export const apiClient = new ApiClient(); export const apiClient = new ApiClient();

View File

@@ -0,0 +1,56 @@
import React, {useState} from "react";
import { storiesOf } from "@storybook/react";
import styled from "styled-components";
import Textarea from "./Textarea";
const Spacing = styled.div`
padding: 2em;
`;
const OnChangeTextarea = () => {
const [value, setValue] = useState("Start typing");
return (
<Spacing>
<Textarea value={value} onChange={v => setValue(v)} />
<hr />
<p>{value}</p>
</Spacing>
);
};
const OnSubmitTextare = () => {
const [value, setValue] = useState("Use the ctrl/command + Enter to submit the textarea");
const [submitted, setSubmitted] = useState("");
const submit = () => {
setSubmitted(value);
setValue("");
};
return (
<Spacing>
<Textarea value={value} onChange={v => setValue(v)} onSubmit={submit} />
<hr />
<p>{submitted}</p>
</Spacing>
);
};
const OnCancelTextare = () => {
const [value, setValue] = useState("Use the escape key to clear the textarea");
const cancel = () => {
setValue("");
};
return (
<Spacing>
<Textarea value={value} onChange={v => setValue(v)} onCancel={cancel} />
</Spacing>
);
};
storiesOf("Forms|Textarea", module)
.add("OnChange", () => <OnChangeTextarea />)
.add("OnSubmit", () => <OnSubmitTextare />)
.add("OnCancel", () => <OnCancelTextare />);

View File

@@ -1,4 +1,4 @@
import React, { ChangeEvent } from "react"; import React, { ChangeEvent, KeyboardEvent } from "react";
import LabelWithHelpIcon from "./LabelWithHelpIcon"; import LabelWithHelpIcon from "./LabelWithHelpIcon";
type Props = { type Props = {
@@ -10,6 +10,8 @@ type Props = {
onChange: (value: string, name?: string) => void; onChange: (value: string, name?: string) => void;
helpText?: string; helpText?: string;
disabled?: boolean; disabled?: boolean;
onSubmit?: () => void;
onCancel?: () => void;
}; };
class Textarea extends React.Component<Props> { class Textarea extends React.Component<Props> {
@@ -25,6 +27,19 @@ class Textarea extends React.Component<Props> {
this.props.onChange(event.target.value, this.props.name); this.props.onChange(event.target.value, this.props.name);
}; };
onKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
const { onCancel } = this.props;
if (onCancel && event.key === "Escape") {
onCancel();
return;
}
const { onSubmit } = this.props;
if (onSubmit && event.key === "Enter" && (event.ctrlKey || event.metaKey)) {
onSubmit();
}
};
render() { render() {
const { placeholder, value, label, helpText, disabled } = this.props; const { placeholder, value, label, helpText, disabled } = this.props;
@@ -41,6 +56,7 @@ class Textarea extends React.Component<Props> {
onChange={this.handleInput} onChange={this.handleInput}
value={value} value={value}
disabled={!!disabled} disabled={!!disabled}
onKeyDown={this.onKeyDown}
/> />
</div> </div>
</div> </div>

View File

@@ -66,6 +66,7 @@ export * from "./modals";
export * from "./navigation"; export * from "./navigation";
export * from "./repos"; export * from "./repos";
export * from "./table"; export * from "./table";
export * from "./toast";
export { export {
File, File,

View File

@@ -1,14 +1,16 @@
import React from "react"; import React from "react";
import classNames from "classnames";
type Props = { type Props = {
subtitle?: string; subtitle?: string;
className?: string;
}; };
class Subtitle extends React.Component<Props> { class Subtitle extends React.Component<Props> {
render() { render() {
const { subtitle } = this.props; const { subtitle, className } = this.props;
if (subtitle) { if (subtitle) {
return <h2 className="subtitle">{subtitle}</h2>; return <h2 className={classNames("subtitle", className)}>{subtitle}</h2>;
} }
return null; return null;
} }

View File

@@ -1,17 +1,32 @@
import React from "react"; import React, { FC, useEffect } from "react";
import classNames from "classnames";
type Props = { type Props = {
title?: string; title?: string;
customPageTitle?: string;
preventRefreshingPageTitle?: boolean;
className?: string;
}; };
class Title extends React.Component<Props> { const Title: FC<Props> = ({ title, preventRefreshingPageTitle, customPageTitle, className }) => {
render() { useEffect(() => {
const { title } = this.props; if (!preventRefreshingPageTitle) {
if (customPageTitle) {
document.title = customPageTitle;
} else if (title) {
document.title = title;
}
}
},[title, preventRefreshingPageTitle, customPageTitle]);
if (title) { if (title) {
return <h1 className="title">{title}</h1>; return <h1 className={classNames("title", className)}>{title}</h1>;
} }
return null; return null;
} };
}
Title.defaultProps = {
preventRefreshingPageTitle: false
};
export default Title; export default Title;

View File

@@ -37,9 +37,9 @@ const Table: FC<Props> = ({ data, sortable, children, emptyMessage }) => {
} }
}); });
const mapDataToColumns = (row: any) => { const mapDataToColumns = (row: any, rowIndex: number) => {
return ( return (
<tr> <tr key={rowIndex}>
{React.Children.map(children, (child, columnIndex) => { {React.Children.map(children, (child, columnIndex) => {
return <td>{React.cloneElement(child, { ...child.props, columnIndex, row })}</td>; return <td>{React.cloneElement(child, { ...child.props, columnIndex, row })}</td>;
})} })}
@@ -93,6 +93,7 @@ const Table: FC<Props> = ({ data, sortable, children, emptyMessage }) => {
onClick={isSortable(child) ? () => tableSort(index) : undefined} onClick={isSortable(child) ? () => tableSort(index) : undefined}
onMouseEnter={() => setHoveredColumnIndex(index)} onMouseEnter={() => setHoveredColumnIndex(index)}
onMouseLeave={() => setHoveredColumnIndex(undefined)} onMouseLeave={() => setHoveredColumnIndex(undefined)}
key={index}
> >
{child.props.header} {child.props.header}
{isSortable(child) && renderSortIcon(child, ascending, shouldShowIcon(index))} {isSortable(child) && renderSortIcon(child, ascending, shouldShowIcon(index))}

View File

@@ -0,0 +1,62 @@
import React, { FC } from "react";
import { createPortal } from "react-dom";
import styled from "styled-components";
import { getTheme, Themeable, ToastThemeContext, Type } from "./themes";
import usePortalRootElement from "../usePortalRootElement";
type Props = {
type: Type;
title: string;
};
const Container = styled.div<Themeable>`
z-index: 99999;
position: fixed;
padding: 1.5rem;
right: 1.5rem;
bottom: 1.5rem;
color: ${props => props.theme.primary};
background-color: ${props => props.theme.secondary};
max-width: 18rem;
font-size: 0.75rem;
border-radius: 5px;
animation: 0.5s slide-up;
& > p {
margin-bottom: 0.5rem;
}
@keyframes slide-up {
from {
bottom: -10rem;
}
to {
bottom: 1.5rem;
}
}
`;
const Title = styled.h1<Themeable>`
margin-bottom: 0.25rem;
font-weight: bold;
`;
const Toast: FC<Props> = ({ children, title, type }) => {
const rootElement = usePortalRootElement("toastRoot");
if (!rootElement) {
// portal not yet ready
return null;
}
const theme = getTheme(type);
const content = (
<Container theme={theme}>
<Title theme={theme}>{title}</Title>
<ToastThemeContext.Provider value={theme}>{children}</ToastThemeContext.Provider>
</Container>
);
return createPortal(content, rootElement);
};
export default Toast;

View File

@@ -0,0 +1,46 @@
import React, { FC, useContext, MouseEvent } from "react";
import { ToastThemeContext, Themeable } from "./themes";
import styled from "styled-components";
type Props = {
icon?: string;
onClick?: () => void;
};
const ThemedButton = styled.button.attrs(props => ({
className: "button"
}))<Themeable>`
color: ${props => props.theme.primary};
border-color: ${props => props.theme.primary};
background-color: ${props => props.theme.secondary};
font-size: 0.75rem;
&:hover {
color: ${props => props.theme.primary};
border-color: ${props => props.theme.tertiary};
background-color: ${props => props.theme.tertiary};
}
`;
const ToastButtonIcon = styled.i`
margin-right: 0.25rem;
`;
const ToastButton: FC<Props> = ({ icon, onClick, children }) => {
const theme = useContext(ToastThemeContext);
const handleClick = (e: MouseEvent<HTMLButtonElement>) => {
if (onClick) {
e.preventDefault();
onClick();
}
};
return (
<ThemedButton theme={theme} onClick={handleClick}>
{icon && <ToastButtonIcon className={`fas fa-fw fa-${icon}`} />} {children}
</ThemedButton>
);
};
export default ToastButton;

View File

@@ -0,0 +1,20 @@
import React, { FC } from "react";
import styled from "styled-components";
const Buttons = styled.div`
display: flex;
padding-top: 0.5rem;
width: 100%;
& > * {
flex-grow: 1;
}
& > *:not(:last-child) {
margin-right: 0.5rem;
}
`;
const ToastButtons: FC = ({ children }) => <Buttons>{children}</Buttons>;
export default ToastButtons;

View File

@@ -0,0 +1,66 @@
import React, { useState } from "react";
import { storiesOf } from "@storybook/react";
import Toast from "./Toast";
import ToastButtons from "./ToastButtons";
import ToastButton from "./ToastButton";
import { types } from "./themes";
const toastStories = storiesOf("Toast", module);
const AnimatedToast = () => (
<Toast type="primary" title="Animated">
Awesome animated Toast
</Toast>
);
const Animator = () => {
const [display, setDisplay] = useState(false);
return (
<div style={{ padding: "2rem" }}>
{display && <AnimatedToast />}
<button className="button is-primary" onClick={() => setDisplay(!display)}>
{display ? "Close" : "Open"} Toast
</button>
</div>
);
};
const Closeable = () => {
const [show, setShow] = useState(true);
const hide = () => {
setShow(false);
};
if (!show) {
return null;
}
return (
<Toast type="success" title="Awesome feature">
<p>Close the message with a click</p>
<ToastButtons>
<ToastButton icon="times" onClick={hide}>
Click to close
</ToastButton>
</ToastButtons>
</Toast>
);
};
toastStories.add("Open/Close", () => <Animator />);
toastStories.add("Click to close", () => <Closeable />);
types.forEach(type => {
toastStories.add(type.charAt(0).toUpperCase() + type.slice(1), () => (
<Toast type={type} title="New Changes">
<p>The underlying Pull-Request has changed. Press reload to see the changes.</p>
<p>Warning: Non saved modification will be lost.</p>
<ToastButtons>
<ToastButton icon="redo">Reload</ToastButton>
<ToastButton icon="times">Ignore</ToastButton>
</ToastButtons>
</Toast>
));
});

View File

@@ -0,0 +1,3 @@
export { default as Toast } from "./Toast";
export { default as ToastButton } from "./ToastButton";
export { default as ToastButtons } from "./ToastButtons";

View File

@@ -0,0 +1,53 @@
import * as React from "react";
export type ToastTheme = {
primary: string;
secondary: string;
tertiary: string;
};
export type Themeable = {
theme: ToastTheme;
};
export type Type = "info" | "primary" | "success" | "warning" | "danger";
export const types: Type[] = ["info", "primary", "success", "warning", "danger"];
const themes: { [name in Type]: ToastTheme } = {
info: {
primary: "#363636",
secondary: "#99d8f3",
tertiary: "white"
},
primary: {
primary: "#363636",
secondary: "#7fe8ef",
tertiary: "white"
},
success: {
primary: "#363636",
secondary: "#7fe3cd",
tertiary: "white"
},
warning: {
primary: "#905515",
secondary: "#ffeeab",
tertiary: "white"
},
danger: {
primary: "#363636",
secondary: "#ff9baf",
tertiary: "white"
}
};
export const getTheme = (name: Type) => {
const theme = themes[name];
if (!theme) {
throw new Error(`could not find theme with name ${name}`);
}
return theme;
};
export const ToastThemeContext = React.createContext(themes.warning);

View File

@@ -0,0 +1,33 @@
import { useEffect, useState } from "react";
const createElement = (id: string) => {
const element = document.createElement("div");
element.setAttribute("id", id);
return element;
};
const appendRootElement = (rootElement: HTMLElement) => {
document.body.appendChild(rootElement);
};
const usePortalRootElement = (id: string) => {
const [rootElement, setRootElement] = useState<HTMLElement>();
useEffect(() => {
let element = document.getElementById(id);
if (!element) {
element = createElement(id);
appendRootElement(element);
}
setRootElement(element);
return () => {
if (element) {
element.remove();
}
setRootElement(undefined);
};
}, [id]);
return rootElement;
};
export default usePortalRootElement;

View File

@@ -7,6 +7,7 @@
"generalNavLink": "Generell" "generalNavLink": "Generell"
}, },
"info": { "info": {
"title": "Administration",
"currentAppVersion": "Aktuelle Software-Versionsnummer", "currentAppVersion": "Aktuelle Software-Versionsnummer",
"communityTitle": "Community Support", "communityTitle": "Community Support",
"communityIconAlt": "Community Support Icon", "communityIconAlt": "Community Support Icon",

View File

@@ -7,6 +7,7 @@
"generalNavLink": "General" "generalNavLink": "General"
}, },
"info": { "info": {
"title": "Administration",
"currentAppVersion": "Current Application Version", "currentAppVersion": "Current Application Version",
"communityTitle": "Community Support", "communityTitle": "Community Support",
"communityIconAlt": "Community Support Icon", "communityIconAlt": "Community Support Icon",

View File

@@ -7,6 +7,7 @@
"generalNavLink": "General" "generalNavLink": "General"
}, },
"info": { "info": {
"title": "Administración",
"currentAppVersion": "Versión actual de la aplicación", "currentAppVersion": "Versión actual de la aplicación",
"communityTitle": "Soporte de la comunidad", "communityTitle": "Soporte de la comunidad",
"communityIconAlt": "Icono del soporte de la comunidad", "communityIconAlt": "Icono del soporte de la comunidad",

View File

@@ -11,6 +11,14 @@ type Props = WithTranslation & {
version: string; version: string;
}; };
const NoBottomMarginSubtitle = styled(Subtitle)`
margin-bottom: 0.25rem !important;
`;
const BottomMarginDiv = styled.div`
margin-bottom: 1.5rem;
`;
const BoxShadowBox = styled.div` const BoxShadowBox = styled.div`
box-shadow: 0 2px 3px rgba(40, 177, 232, 0.1), 0 0 0 2px rgba(40, 177, 232, 0.2); box-shadow: 0 2px 3px rgba(40, 177, 232, 0.1), 0 0 0 2px rgba(40, 177, 232, 0.2);
`; `;
@@ -29,8 +37,9 @@ class AdminDetails extends React.Component<Props> {
return ( return (
<> <>
<Title title={t("admin.info.currentAppVersion")} /> <Title title={t("admin.info.title")} />
<Subtitle subtitle={this.props.version} /> <NoBottomMarginSubtitle subtitle={t("admin.info.currentAppVersion")} />
<BottomMarginDiv>{this.props.version}</BottomMarginDiv>
<BoxShadowBox className="box"> <BoxShadowBox className="box">
<article className="media"> <article className="media">
<ImageWrapper className="media-left"> <ImageWrapper className="media-left">

View File

@@ -1,7 +1,7 @@
import React from "react"; import React from "react";
import { WithTranslation, withTranslation } from "react-i18next"; import { WithTranslation, withTranslation } from "react-i18next";
import { Repository, Branch, BranchRequest } from "@scm-manager/ui-types"; import { Repository, Branch, BranchRequest } from "@scm-manager/ui-types";
import { Select, InputField, SubmitButton, validation as validator } from "@scm-manager/ui-components"; import { Select, InputField, Level, SubmitButton, validation as validator } from "@scm-manager/ui-components";
import { orderBranches } from "../util/orderBranches"; import { orderBranches } from "../util/orderBranches";
type Props = WithTranslation & { type Props = WithTranslation & {
@@ -83,11 +83,15 @@ class BranchForm extends React.Component<Props, State> {
</div> </div>
<div className="columns"> <div className="columns">
<div className="column"> <div className="column">
<Level
right={
<SubmitButton <SubmitButton
disabled={disabled || !this.isValid()} disabled={disabled || !this.isValid()}
loading={loading} loading={loading}
label={t("branches.create.submit")} label={t("branches.create.submit")}
/> />
}
/>
</div> </div>
</div> </div>
</form> </form>

View File

@@ -134,7 +134,7 @@ class FileTree extends React.Component<Props, State> {
<th>{t("sources.file-tree.name")}</th> <th>{t("sources.file-tree.name")}</th>
<th className="is-hidden-mobile">{t("sources.file-tree.length")}</th> <th className="is-hidden-mobile">{t("sources.file-tree.length")}</th>
<th className="is-hidden-mobile">{t("sources.file-tree.lastModified")}</th> <th className="is-hidden-mobile">{t("sources.file-tree.lastModified")}</th>
<th className="is-hidden-mobile">{t("sources.file-tree.description")}</th> <th className="is-hidden-touch">{t("sources.file-tree.description")}</th>
{binder.hasExtension("repos.sources.tree.row.right") && <th className="is-hidden-mobile" />} {binder.hasExtension("repos.sources.tree.row.right") && <th className="is-hidden-mobile" />}
</tr> </tr>
</thead> </thead>

View File

@@ -18,6 +18,10 @@ const MinWidthTd = styled.td`
min-width: 10em; min-width: 10em;
`; `;
const NoWrapTd = styled.td`
white-space: nowrap;
`;
export function createLink(base: string, file: File) { export function createLink(base: string, file: File) {
let link = base; let link = base;
if (file.path) { if (file.path) {
@@ -88,9 +92,9 @@ class FileTreeLeaf extends React.Component<Props> {
<tr> <tr>
<td>{this.createFileIcon(file)}</td> <td>{this.createFileIcon(file)}</td>
<MinWidthTd className="is-word-break">{this.createFileName(file)}</MinWidthTd> <MinWidthTd className="is-word-break">{this.createFileName(file)}</MinWidthTd>
<td className="is-hidden-mobile">{fileSize}</td> <NoWrapTd className="is-hidden-mobile">{fileSize}</NoWrapTd>
<td className="is-hidden-mobile">{this.contentIfPresent(file, <DateFromNow date={file.lastModified} />)}</td> <td className="is-hidden-mobile">{this.contentIfPresent(file, <DateFromNow date={file.lastModified} />)}</td>
<MinWidthTd className={classNames("is-word-break", "is-hidden-mobile")}> <MinWidthTd className={classNames("is-word-break", "is-hidden-touch")}>
{this.contentIfPresent(file, file.description)} {this.contentIfPresent(file, file.description)}
</MinWidthTd> </MinWidthTd>
{binder.hasExtension("repos.sources.tree.row.right") && ( {binder.hasExtension("repos.sources.tree.row.right") && (

View File

@@ -580,7 +580,7 @@
<jjwt.version>0.10.5</jjwt.version> <jjwt.version>0.10.5</jjwt.version>
<selenium.version>2.53.1</selenium.version> <selenium.version>2.53.1</selenium.version>
<wagon.version>1.0</wagon.version> <wagon.version>1.0</wagon.version>
<mustache.version>0.8.17</mustache.version> <mustache.version>0.9.6-scm1</mustache.version>
<netbeans.hint.deploy.server>Tomcat</netbeans.hint.deploy.server> <netbeans.hint.deploy.server>Tomcat</netbeans.hint.deploy.server>
<sonar.issue.ignore.multicriteria>e1</sonar.issue.ignore.multicriteria> <sonar.issue.ignore.multicriteria>e1</sonar.issue.ignore.multicriteria>
<sonar.issue.ignore.multicriteria.e1.ruleKey>javascript:S3827</sonar.issue.ignore.multicriteria.e1.ruleKey> <sonar.issue.ignore.multicriteria.e1.ruleKey>javascript:S3827</sonar.issue.ignore.multicriteria.e1.ruleKey>

View File

@@ -104,6 +104,7 @@ public class BearerRealm extends AuthenticatingRealm
return helper.authenticationInfoBuilder(accessToken.getSubject()) return helper.authenticationInfoBuilder(accessToken.getSubject())
.withCredentials(bt.getCredentials()) .withCredentials(bt.getCredentials())
.withScope(Scopes.fromClaims(accessToken.getClaims())) .withScope(Scopes.fromClaims(accessToken.getClaims()))
.withSessionId(bt.getPrincipal())
.build(); .build();
} }

View File

@@ -70,7 +70,8 @@ public class BearerWebTokenGenerator extends SchemeBasedWebTokenGenerator
if (HttpUtil.AUTHORIZATION_SCHEME_BEARER.equalsIgnoreCase(scheme)) if (HttpUtil.AUTHORIZATION_SCHEME_BEARER.equalsIgnoreCase(scheme))
{ {
token = BearerToken.valueOf(authorization); String sessionId = request.getHeader(HttpUtil.HEADER_SCM_SESSION);
token = BearerToken.create(sessionId, authorization);
} }
return token; return token;

View File

@@ -43,7 +43,7 @@ import javax.servlet.http.HttpServletRequest;
import sonia.scm.util.HttpUtil; import sonia.scm.util.HttpUtil;
/** /**
* Creates an {@link BearerToken} from the {@link #COOKIE_NAME} * Creates an {@link BearerToken} from the {@link HttpUtil#COOKIE_BEARER_AUTHENTICATION}
* cookie. * cookie.
* *
* @author Sebastian Sdorra * @author Sebastian Sdorra
@@ -54,7 +54,7 @@ public class CookieBearerWebTokenGenerator implements WebTokenGenerator
{ {
/** /**
* Creates an {@link BearerToken} from the {@link #COOKIE_NAME} * Creates an {@link BearerToken} from the {@link HttpUtil#COOKIE_BEARER_AUTHENTICATION}
* cookie. * cookie.
* *
* @param request http servlet request * @param request http servlet request
@@ -73,7 +73,8 @@ public class CookieBearerWebTokenGenerator implements WebTokenGenerator
{ {
if (HttpUtil.COOKIE_BEARER_AUTHENTICATION.equals(cookie.getName())) if (HttpUtil.COOKIE_BEARER_AUTHENTICATION.equals(cookie.getName()))
{ {
token = BearerToken.valueOf(cookie.getValue()); String sessionId = HttpUtil.getHeader(request, HttpUtil.HEADER_SCM_SESSION, null);
token = BearerToken.create(sessionId, cookie.getValue());
break; break;
} }

View File

@@ -177,45 +177,26 @@ public class DefaultAdministrationContext implements AdministrationContext
//J+ //J+
} }
/** private void doRunAsInNonWebSessionContext(PrivilegedAction action) {
* Method description
*
*
* @param action
*/
private void doRunAsInNonWebSessionContext(PrivilegedAction action)
{
if (logger.isTraceEnabled())
{
logger.trace("bind shiro security manager to current thread"); logger.trace("bind shiro security manager to current thread");
}
try try {
{
SecurityUtils.setSecurityManager(securityManager); SecurityUtils.setSecurityManager(securityManager);
Subject subject = createAdminSubject(); Subject subject = createAdminSubject();
ThreadState state = new SubjectThreadState(subject); ThreadState state = new SubjectThreadState(subject);
state.bind(); state.bind();
try try
{ {
if (logger.isInfoEnabled()) logger.info("execute action {} in administration context", action.getClass().getName());
{
logger.info("execute action {} in administration context",
action.getClass().getName());
}
action.run(); action.run();
} finally {
logger.trace("restore current thread state");
state.restore();
} }
finally } finally {
{
state.clear();
}
}
finally
{
SecurityUtils.setSecurityManager(null); SecurityUtils.setSecurityManager(null);
} }
} }

View File

@@ -84,11 +84,9 @@ class BearerRealmTest {
@Test @Test
void shouldDoGetAuthentication() { void shouldDoGetAuthentication() {
BearerToken bearerToken = BearerToken.valueOf("__bearer__"); BearerToken bearerToken = BearerToken.create("__session__", "__bearer__");
AccessToken accessToken = mock(AccessToken.class); AccessToken accessToken = mock(AccessToken.class);
Set<String> groups = ImmutableSet.of("HeartOfGold", "Puzzle42");
when(accessToken.getSubject()).thenReturn("trillian"); when(accessToken.getSubject()).thenReturn("trillian");
when(accessToken.getClaims()).thenReturn(new HashMap<>()); when(accessToken.getClaims()).thenReturn(new HashMap<>());
when(accessTokenResolver.resolve(bearerToken)).thenReturn(accessToken); when(accessTokenResolver.resolve(bearerToken)).thenReturn(accessToken);
@@ -96,6 +94,7 @@ class BearerRealmTest {
when(realmHelper.authenticationInfoBuilder("trillian")).thenReturn(builder); when(realmHelper.authenticationInfoBuilder("trillian")).thenReturn(builder);
when(builder.withCredentials("__bearer__")).thenReturn(builder); when(builder.withCredentials("__bearer__")).thenReturn(builder);
when(builder.withScope(any(Scope.class))).thenReturn(builder); when(builder.withScope(any(Scope.class))).thenReturn(builder);
when(builder.withSessionId(any(SessionId.class))).thenReturn(builder);
when(builder.build()).thenReturn(authenticationInfo); when(builder.build()).thenReturn(authenticationInfo);
AuthenticationInfo result = realm.doGetAuthenticationInfo(bearerToken); AuthenticationInfo result = realm.doGetAuthenticationInfo(bearerToken);

View File

@@ -33,47 +33,73 @@ package sonia.scm.web;
import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletRequest;
import org.apache.shiro.authc.AuthenticationToken; import org.apache.shiro.authc.AuthenticationToken;
import org.junit.Test;
import static org.junit.Assert.*;
import static org.hamcrest.Matchers.*; import org.junit.jupiter.api.Test;
import org.junit.runner.RunWith; import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock; import org.mockito.Mock;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.*; import static org.mockito.Mockito.*;
import org.mockito.junit.MockitoJUnitRunner; import org.mockito.junit.jupiter.MockitoExtension;
import sonia.scm.security.BearerToken; import sonia.scm.security.BearerToken;
import sonia.scm.security.SessionId;
import sonia.scm.util.HttpUtil;
/** /**
* *
* @author Sebastian Sdorra * @author Sebastian Sdorra
*/ */
@RunWith(MockitoJUnitRunner.class) @ExtendWith(MockitoExtension.class)
public class BearerWebTokenGeneratorTest { class BearerWebTokenGeneratorTest {
private final BearerWebTokenGenerator tokenGenerator = new BearerWebTokenGenerator();
@Mock @Mock
private HttpServletRequest request; private HttpServletRequest request;
private final BearerWebTokenGenerator tokenGenerator = new BearerWebTokenGenerator();
@Test @Test
public void testCreateTokenWithWrongScheme() void shouldNotCreateTokenWithWrongScheme() {
{
when(request.getHeader("Authorization")).thenReturn("BASIC ASD"); when(request.getHeader("Authorization")).thenReturn("BASIC ASD");
assertNull(tokenGenerator.createToken(request));
}
@Test
public void testCreateTokenWithoutAuthorizationHeader(){
assertNull(tokenGenerator.createToken(request));
}
@Test
public void testCreateToken(){
when(request.getHeader("Authorization")).thenReturn("Bearer asd");
AuthenticationToken token = tokenGenerator.createToken(request); AuthenticationToken token = tokenGenerator.createToken(request);
assertNotNull(token);
assertThat(token, instanceOf(BearerToken.class)); assertThat(token).isNull();
}
@Test
void shouldNotCreateTokenWithoutAuthorizationHeader(){
AuthenticationToken token = tokenGenerator.createToken(request);
assertThat(token).isNull();
}
@Test
void shouldCreateToken(){
when(request.getHeader("Authorization")).thenReturn("Bearer asd");
AuthenticationToken token = tokenGenerator.createToken(request);
assertThat(token)
.isNotNull()
.isInstanceOf(BearerToken.class);
BearerToken bt = (BearerToken) token; BearerToken bt = (BearerToken) token;
assertThat(bt.getCredentials(), equalTo("asd")); assertThat(bt.getCredentials()).isEqualTo("asd");
}
@Test
void shouldCreateTokenWithSessionId(){
doReturn("Bearer asd").when(request).getHeader("Authorization");
doReturn("bcd123").when(request).getHeader(HttpUtil.HEADER_SCM_SESSION);
AuthenticationToken token = tokenGenerator.createToken(request);
assertThat(token)
.isNotNull()
.isInstanceOf(BearerToken.class);
BearerToken bt = (BearerToken) token;
assertThat(bt.getPrincipal()).isEqualTo(SessionId.valueOf("bcd123"));
assertThat(bt.getCredentials()).isEqualTo("asd");
} }
} }

View File

@@ -35,82 +35,81 @@ package sonia.scm.web;
//~--- non-JDK imports -------------------------------------------------------- //~--- non-JDK imports --------------------------------------------------------
import org.junit.Test; import org.junit.jupiter.api.Test;
import org.junit.runner.RunWith; import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock; import org.mockito.Mock;
import org.mockito.junit.MockitoJUnitRunner; import org.mockito.junit.jupiter.MockitoExtension;
import sonia.scm.security.BearerToken; import sonia.scm.security.BearerToken;
import sonia.scm.security.SessionId;
import static org.junit.Assert.*; import sonia.scm.util.HttpUtil;
import static org.mockito.Mockito.*;
//~--- JDK imports ------------------------------------------------------------
import javax.servlet.http.Cookie; import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletRequest;
import sonia.scm.util.HttpUtil;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.*;
//~--- JDK imports ------------------------------------------------------------
/** /**
* *
* @author Sebastian Sdorra * @author Sebastian Sdorra
*/ */
@RunWith(MockitoJUnitRunner.class) @ExtendWith(MockitoExtension.class)
public class CookieBearerWebTokenGeneratorTest class CookieBearerWebTokenGeneratorTest {
{
private final CookieBearerWebTokenGenerator tokenGenerator = new CookieBearerWebTokenGenerator();
@Mock
private HttpServletRequest request;
/**
* Method description
*
*/
@Test @Test
public void testCreateToken() void shouldCreateToken() {
{ assignBearerCookie("value");
Cookie c = mock(Cookie.class);
when(c.getName()).thenReturn(HttpUtil.COOKIE_BEARER_AUTHENTICATION);
when(c.getValue()).thenReturn("value");
when(request.getCookies()).thenReturn(new Cookie[] { c });
BearerToken token = tokenGenerator.createToken(request); BearerToken token = tokenGenerator.createToken(request);
assertNotNull(token); assertThat(token).isNotNull();
assertEquals("value", token.getCredentials()); assertThat(token.getPrincipal()).isNull();
assertThat(token.getCredentials()).isEqualTo("value");
} }
/**
* Method description
*
*/
@Test @Test
public void testCreateTokenWithWrongCookie() void shouldCreateTokenWithSessionId() {
{ when(request.getHeader(HttpUtil.HEADER_SCM_SESSION)).thenReturn("abc123");
assignBearerCookie("authc");
BearerToken token = tokenGenerator.createToken(request);
assertThat(token).isNotNull();
assertThat(token.getPrincipal()).isEqualTo(SessionId.valueOf("abc123"));
assertThat(token.getCredentials()).isEqualTo("authc");
}
private void assignBearerCookie(String value) {
assignCookie(HttpUtil.COOKIE_BEARER_AUTHENTICATION, value);
}
private void assignCookie(String name, String value) {
Cookie c = mock(Cookie.class); Cookie c = mock(Cookie.class);
when(c.getName()).thenReturn("other-cookie"); when(c.getName()).thenReturn(name);
when(request.getCookies()).thenReturn(new Cookie[] { c }); lenient().when(c.getValue()).thenReturn(value);
assertNull(tokenGenerator.createToken(request)); when(request.getCookies()).thenReturn(new Cookie[]{c});
} }
/**
* Method description
*
*/
@Test @Test
public void testCreateTokenWithoutCookies() void shouldNotCreateTokenForWrongCookie() {
{ assignCookie("other-cookie", "with-some-value");
assertNull(tokenGenerator.createToken(request));
BearerToken token = tokenGenerator.createToken(request);
assertThat(token).isNull();
} }
//~--- fields --------------------------------------------------------------- @Test
void shouldNotCreateTokenWithoutCookies() {
/** Field description */ BearerToken token = tokenGenerator.createToken(request);
private final CookieBearerWebTokenGenerator tokenGenerator = assertThat(token).isNull();
new CookieBearerWebTokenGenerator(); }
/** Field description */
@Mock
private HttpServletRequest request;
} }

View File

@@ -0,0 +1,72 @@
package sonia.scm.web.security;
import com.google.inject.Guice;
import com.google.inject.Injector;
import org.apache.shiro.SecurityUtils;
import org.apache.shiro.mgt.DefaultSecurityManager;
import org.apache.shiro.mgt.SecurityManager;
import org.apache.shiro.subject.Subject;
import org.apache.shiro.util.ThreadContext;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class DefaultAdministrationContextTest {
private DefaultAdministrationContext context;
@Mock
private Subject subject;
@BeforeEach
void create() {
Injector injector = Guice.createInjector();
SecurityManager securityManager = new DefaultSecurityManager();
context = new DefaultAdministrationContext(injector, securityManager);
}
@Test
void shouldBindSubject() {
context.runAsAdmin(() -> {
Subject adminSubject = SecurityUtils.getSubject();
assertThat(adminSubject.getPrincipal()).isEqualTo("scmsystem");
});
}
@Test
void shouldBindSubjectEvenIfAlreadyBound() {
ThreadContext.bind(subject);
try {
context.runAsAdmin(() -> {
Subject adminSubject = SecurityUtils.getSubject();
assertThat(adminSubject.getPrincipal()).isEqualTo("scmsystem");
});
} finally {
ThreadContext.unbindSubject();
}
}
@Test
void shouldRestoreCurrentSubject() {
when(subject.getPrincipal()).thenReturn("tricia");
ThreadContext.bind(subject);
try {
context.runAsAdmin(() -> {});
Subject currentSubject = SecurityUtils.getSubject();
assertThat(currentSubject.getPrincipal()).isEqualTo("tricia");
} finally {
ThreadContext.unbindSubject();
}
}
}

2311
yarn.lock

File diff suppressed because it is too large Load Diff