Merged branches

This commit is contained in:
Philipp Czora
2018-07-27 13:24:28 +02:00
21 changed files with 789 additions and 523 deletions

View File

@@ -28,5 +28,25 @@
},
"user-form": {
"submit": "Submit"
},
"add-user": {
"title": "Create User",
"subtitle": "Create a new user"
},
"single-user": {
"error-title": "Error",
"error-subtitle": "Unknown user error",
"navigation-label": "Navigation",
"actions-label": "Actions",
"information-label": "Information",
"back-label": "Back"
},
"validation": {
"mail-invalid": "This email is invalid",
"name-invalid": "This name is invalid",
"displayname-invalid": "This displayname is invalid",
"password-invalid": "Password has to be at least six characters",
"passwordValidation-invalid": "Passwords have to be the same",
"validatePassword": "Please validate password here"
}
}

View File

@@ -1,5 +1,6 @@
//@flow
import React from "react";
import classNames from "classnames";
type Props = {
label?: string,
@@ -7,7 +8,9 @@ type Props = {
value?: string,
type?: string,
autofocus?: boolean,
onChange: string => void
onChange: string => void,
validationError: boolean,
errorMessage: string
};
class InputField extends React.Component<Props> {
@@ -37,8 +40,9 @@ class InputField extends React.Component<Props> {
};
render() {
const { type, placeholder, value } = this.props;
const { type, placeholder, value, validationError, errorMessage } = this.props;
const errorView = validationError ? "is-danger" : "";
const helper = validationError ? <p className="help is-danger">{errorMessage}</p> : "";
return (
<div className="field">
{this.renderLabel()}
@@ -47,13 +51,17 @@ class InputField extends React.Component<Props> {
ref={input => {
this.field = input;
}}
className="input"
className={ classNames(
"input",
errorView
)}
type={type}
placeholder={placeholder}
value={value}
onChange={this.handleInput}
/>
</div>
{helper}
</div>
);
}

View File

@@ -2,7 +2,7 @@
import React from "react";
import {translate} from "react-i18next";
import type {User} from "../types/User";
import { InputField, Checkbox } from "../../components/forms";
import {Checkbox, InputField} from "../../components/forms";
import {SubmitButton} from "../../components/buttons";
type Props = {
@@ -11,10 +11,23 @@ type Props = {
t: string => string
};
class UserForm extends React.Component<Props, User> {
type State = {
user: User,
mailValidationError: boolean,
nameValidationError: boolean,
displayNameValidationError: boolean,
passwordValidationError: boolean,
validatePasswordError: boolean,
validatePassword: string
};
class UserForm extends React.Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
user: {
name: "",
displayName: "",
mail: "",
@@ -22,22 +35,30 @@ class UserForm extends React.Component<Props, User> {
admin: false,
active: false,
_links: {}
},
mailValidationError: false,
displayNameValidationError: false,
nameValidationError: false,
passwordValidationError: false,
validatePasswordError: false,
validatePassword: ""
};
}
componentDidMount() {
this.setState({ ...this.props.user });
this.setState({ user: {...this.props.user} });
}
submit = (event: Event) => {
event.preventDefault();
this.props.submitForm(this.state);
this.props.submitForm(this.state.user);
};
render() {
const { t } = this.props;
const user = this.state;
const user = this.state.user;
const ButtonClickable = (this.state.validatePasswordError || this.state.nameValidationError || this.state.mailValidationError || this.state.validatePasswordError
|| this.state.displayNameValidationError || user.name === undefined|| user.displayName === undefined);
let nameField = null;
if (!this.props.user) {
nameField = (
@@ -45,6 +66,8 @@ class UserForm extends React.Component<Props, User> {
label={t("user.name")}
onChange={this.handleUsernameChange}
value={user ? user.name : ""}
validationError= {this.state.nameValidationError}
errorMessage= {t("validation.name-invalid")}
/>
);
}
@@ -55,17 +78,31 @@ class UserForm extends React.Component<Props, User> {
label={t("user.displayName")}
onChange={this.handleDisplayNameChange}
value={user ? user.displayName : ""}
validationError={this.state.displayNameValidationError}
errorMessage={t("validation.displayname-invalid")}
/>
<InputField
label={t("user.mail")}
onChange={this.handleEmailChange}
value={user ? user.mail : ""}
validationError= {this.state.mailValidationError}
errorMessage={t("validation.mail-invalid")}
/>
<InputField
label={t("user.password")}
type="password"
onChange={this.handlePasswordChange}
value={user ? user.password : ""}
validationError={this.state.validatePasswordError}
errorMessage={t("validation.password-invalid")}
/>
<InputField
label={t("validation.validatePassword")}
type="password"
onChange={this.handlePasswordValidationChange}
value={this.state ? this.state.validatePassword : ""}
validationError={this.state.passwordValidationError}
errorMessage={t("validation.passwordValidation-invalid")}
/>
<Checkbox
label={t("user.admin")}
@@ -77,33 +114,47 @@ class UserForm extends React.Component<Props, User> {
onChange={this.handleActiveChange}
checked={user ? user.active : false}
/>
<SubmitButton label={t("user-form.submit")} />
<SubmitButton disabled={ButtonClickable} label={t("user-form.submit")} />
</form>
);
}
handleUsernameChange = (name: string) => {
this.setState({ name });
const REGEX_NAME = /^[^ ][A-z0-9\\.\-_@ ]*[^ ]$/;
this.setState( {nameValidationError: !REGEX_NAME.test(name), user : {...this.state.user, name} } );
};
handleDisplayNameChange = (displayName: string) => {
this.setState({ displayName });
const REGEX_NAME = /^[^ ][A-z0-9\\.\-_@ ]*[^ ]$/;
this.setState({displayNameValidationError: !REGEX_NAME.test(displayName), user : {...this.state.user, displayName} } );
};
handleEmailChange = (mail: string) => {
this.setState({ mail });
const REGEX_MAIL = /^[A-z0-9][\w.-]*@[A-z0-9][\w\-\\.]*\.[A-z0-9][A-z0-9-]+$/;
this.setState( {mailValidationError: !REGEX_MAIL.test(mail), user : {...this.state.user, mail} } );
};
handlePasswordChange = (password: string) => {
this.setState({ password });
const validatePasswordError = !this.checkPasswords(password, this.state.validatePassword);
this.setState( {validatePasswordError: (password.length < 6) || (password.length > 32), passwordValidationError: validatePasswordError, user : {...this.state.user, password} } );
};
handlePasswordValidationChange = (validatePassword: string) => {
const validatePasswordError = this.checkPasswords(this.state.user.password, validatePassword)
this.setState({ validatePassword, passwordValidationError: !validatePasswordError });
};
checkPasswords = (password1: string, password2: string) => {
return (password1 === password2);
}
handleAdminChange = (admin: boolean) => {
this.setState({ admin });
this.setState({ user : {...this.state.user, admin} });
};
handleActiveChange = (active: boolean) => {
this.setState({ active });
this.setState({ user : {...this.state.user, active} });
};
}

View File

@@ -1,34 +0,0 @@
//@flow
import React from "react";
import { translate } from "react-i18next";
import { EditButton } from "../../../components/buttons";
import type { UserEntry } from "../../types/UserEntry";
type Props = {
t: string => string,
entry: UserEntry
};
class EditUserButton extends React.Component<Props> {
render() {
const { entry, t } = this.props;
const link = "/users/edit/" + entry.entry.name;
if (!this.isEditable()) {
return "";
}
return (
<EditButton
label={t("edit-user-button.label")}
link={link}
loading={entry.loading}
/>
);
}
isEditable = () => {
return this.props.entry.entry._links.update;
};
}
export default translate("users")(EditUserButton);

View File

@@ -1,31 +0,0 @@
import React from "react";
import { shallow } from "enzyme";
import "../../../tests/enzyme";
import "../../../tests/i18n";
import EditUserButton from "./EditUserButton";
it("should render nothing, if the edit link is missing", () => {
const entry = {
entry: {
_links: {}
}
};
const button = shallow(<EditUserButton entry={entry} />);
expect(button.text()).toBe("");
});
it("should render the button", () => {
const entry = {
entry: {
_links: {
update: {
href: "/users"
}
}
}
};
const button = shallow(<EditUserButton entry={entry} />);
expect(button.text()).not.toBe("");
});

View File

@@ -1,2 +0,0 @@
export { default as DeleteUserButton } from "./DeleteUserButton";
export { default as EditUserButton } from "./EditUserButton";

View File

@@ -12,7 +12,7 @@ type Props = {
deleteUser: (user: User) => void
};
class DeleteUserButton extends React.Component<Props> {
class DeleteUserNavLink extends React.Component<Props> {
static defaultProps = {
confirmDialog: true
};
@@ -54,4 +54,4 @@ class DeleteUserButton extends React.Component<Props> {
}
}
export default translate("users")(DeleteUserButton);
export default translate("users")(DeleteUserNavLink);

View File

@@ -2,24 +2,24 @@ import React from "react";
import { mount, shallow } from "enzyme";
import "../../../tests/enzyme";
import "../../../tests/i18n";
import DeleteUserButton from "./DeleteUserButton";
import DeleteUserNavLink from "./DeleteUserNavLink";
import { confirmAlert } from "../../../components/modals/ConfirmAlert";
jest.mock("../../../components/modals/ConfirmAlert");
describe("DeleteUserButton", () => {
describe("DeleteUserNavLink", () => {
it("should render nothing, if the delete link is missing", () => {
const user = {
_links: {}
};
const button = shallow(
<DeleteUserButton user={user} deleteUser={() => {}} />
const navLink = shallow(
<DeleteUserNavLink user={user} deleteUser={() => {}} />
);
expect(button.text()).toBe("");
expect(navLink.text()).toBe("");
});
it("should render the button", () => {
it("should render the navLink", () => {
const user = {
_links: {
delete: {
@@ -28,13 +28,13 @@ describe("DeleteUserButton", () => {
}
};
const button = mount(
<DeleteUserButton user={user} deleteUser={() => {}} />
const navLink = mount(
<DeleteUserNavLink user={user} deleteUser={() => {}} />
);
expect(button.text()).not.toBe("");
expect(navLink.text()).not.toBe("");
});
it("should open the confirm dialog on button click", () => {
it("should open the confirm dialog on navLink click", () => {
const user = {
_links: {
delete: {
@@ -43,10 +43,10 @@ describe("DeleteUserButton", () => {
}
};
const button = mount(
<DeleteUserButton user={user} deleteUser={() => {}} />
const navLink = mount(
<DeleteUserNavLink user={user} deleteUser={() => {}} />
);
button.find("a").simulate("click");
navLink.find("a").simulate("click");
expect(confirmAlert.mock.calls.length).toBe(1);
});
@@ -65,14 +65,14 @@ describe("DeleteUserButton", () => {
calledUrl = user._links.delete.href;
}
const button = mount(
<DeleteUserButton
const navLink = mount(
<DeleteUserNavLink
user={user}
confirmDialog={false}
deleteUser={capture}
/>
);
button.find("a").simulate("click");
navLink.find("a").simulate("click");
expect(calledUrl).toBe("/users");
});

View File

@@ -0,0 +1,28 @@
//@flow
import React from "react";
import { translate } from "react-i18next";
import type { UserEntry } from "../../types/UserEntry";
import { NavLink } from "../../../components/navigation";
type Props = {
t: string => string,
user: UserEntry,
editUrl: String
};
class EditUserNavLink extends React.Component<Props> {
render() {
const { t, editUrl } = this.props;
if (!this.isEditable()) {
return null;
}
return <NavLink label={t("edit-user-button.label")} to={editUrl} />;
}
isEditable = () => {
return this.props.user._links.update;
};
}
export default translate("users")(EditUserNavLink);

View File

@@ -0,0 +1,27 @@
import React from "react";
import { shallow } from "enzyme";
import "../../../tests/enzyme";
import "../../../tests/i18n";
import EditUserNavLink from "./EditUserNavLink";
it("should render nothing, if the edit link is missing", () => {
const user = {
_links: {}
};
const navLink = shallow(<EditUserNavLink user={user} editUrl='/user/edit'/>);
expect(navLink.text()).toBe("");
});
it("should render the navLink", () => {
const user = {
_links: {
update: {
href: "/users"
}
}
};
const navLink = shallow(<EditUserNavLink user={user} editUrl='/user/edit'/>);
expect(navLink.text()).not.toBe("");
});

View File

@@ -0,0 +1,2 @@
export { default as DeleteUserNavLink } from "./DeleteUserNavLink";
export { default as EditUserNavLink } from "./EditUserNavLink";

View File

@@ -6,8 +6,10 @@ import type { User } from "../types/User";
import type { History } from "history";
import { createUser } from "../modules/users";
import { Page } from "../../components/layout";
import { translate } from "react-i18next";
type Props = {
t: string => string,
addUser: (user: User, callback?: () => void) => void,
loading?: boolean,
error?: Error,
@@ -25,13 +27,12 @@ class AddUser extends React.Component<Props> {
};
render() {
const { loading, error } = this.props;
const { t, loading, error } = this.props;
// TODO i18n
return (
<Page
title="Create User"
subtitle="Create a new user"
title={t("add-user.title")}
subtitle={t("add-user.subtitle")}
loading={loading}
error={error}
>
@@ -59,4 +60,4 @@ const mapStateToProps = (state, ownProps) => {
export default connect(
mapStateToProps,
mapDispatchToProps
)(AddUser);
)(translate("users")(AddUser));

View File

@@ -12,10 +12,13 @@ import { fetchUser, deleteUser } from "../modules/users";
import Loading from "../../components/Loading";
import { Navigation, Section, NavLink } from "../../components/navigation";
import { DeleteUserButton } from "./../components/buttons";
import { DeleteUserNavLink, EditUserNavLink } from "./../components/navLinks";
import ErrorPage from "../../components/ErrorPage";
import { translate } from "react-i18next";
type Props = {
t: string => string,
name: string,
userEntry?: UserEntry,
match: any,
@@ -49,7 +52,7 @@ class SingleUser extends React.Component<Props> {
};
render() {
const { userEntry } = this.props;
const { t, userEntry } = this.props;
if (!userEntry || userEntry.loading) {
return <Loading />;
@@ -58,8 +61,8 @@ class SingleUser extends React.Component<Props> {
if (userEntry.error) {
return (
<ErrorPage
title="Error"
subtitle="Unknown user error"
title={t("single-user.error-title")}
subtitle={t("single-user.error-subtitle")}
error={userEntry.error}
/>
);
@@ -68,8 +71,6 @@ class SingleUser extends React.Component<Props> {
const user = userEntry.entry;
const url = this.matchedUrl();
// TODO i18n
return (
<Page title={user.displayName}>
<div className="columns">
@@ -82,13 +83,13 @@ class SingleUser extends React.Component<Props> {
</div>
<div className="column">
<Navigation>
<Section label="Navigation">
<NavLink to={`${url}`} label="Information" />
<NavLink to={`${url}/edit`} label="Edit" />
<Section label={t("single-user.navigation-label")}>
<NavLink to={`${url}`} label={t("single-user.information-label")} />
<EditUserNavLink user={user} editUrl={`${url}/edit`} />
</Section>
<Section label="Actions">
<DeleteUserButton user={user} deleteUser={this.deleteUser} />
<NavLink to="/users" label="Back" />
<Section label={t("single-user.actions-label")}>
<DeleteUserNavLink user={user} deleteUser={this.deleteUser} />
<NavLink to="/users" label={t("single-user.back-label")} />
</Section>
</Navigation>
</div>
@@ -125,4 +126,4 @@ const mapDispatchToProps = dispatch => {
export default connect(
mapStateToProps,
mapDispatchToProps
)(SingleUser);
)(translate("users")(SingleUser));

View File

@@ -2,7 +2,7 @@
import { apiClient } from "../../apiclient";
import type { User } from "../types/User";
import type { UserEntry } from "../types/UserEntry";
import { Dispatch } from "redux";
import { combineReducers, Dispatch } from "redux";
import type { Action } from "../../types/Action";
import type { PageCollectionStateSlice } from "../../types/Collection";
@@ -22,7 +22,7 @@ export const MODIFY_USER_PENDING = "scm/users/MODIFY_USER_PENDING";
export const MODIFY_USER_SUCCESS = "scm/users/MODIFY_USER_SUCCESS";
export const MODIFY_USER_FAILURE = "scm/users/MODIFY_USER_FAILURE";
export const DELETE_USER = "scm/users/DELETE";
export const DELETE_USER_PENDING = "scm/users/DELETE_PENDING";
export const DELETE_USER_SUCCESS = "scm/users/DELETE_SUCCESS";
export const DELETE_USER_FAILURE = "scm/users/DELETE_FAILURE";
@@ -30,6 +30,8 @@ const USERS_URL = "users";
const CONTENT_TYPE_USER = "application/vnd.scmm-user+json;v=2";
//TODO i18n
//fetch users
export function fetchUsers() {
@@ -241,7 +243,7 @@ export function deleteUser(user: User, callback?: () => void) {
export function deleteUserPending(user: User): Action {
return {
type: DELETE_USER,
type: DELETE_USER_PENDING,
payload: user
};
}
@@ -300,7 +302,8 @@ function extractUsersByNames(
}
return usersByNames;
}
function deleteUserInUsersByNames(users: {}, userName: any) {
function deleteUserInUsersByNames(users: {}, userName: string) {
let newUsers = {};
for (let username in users) {
if (username !== userName) newUsers[username] = users[username];
@@ -308,7 +311,7 @@ function deleteUserInUsersByNames(users: {}, userName: any) {
return newUsers;
}
function deleteUserInEntries(users: [], userName: any) {
function deleteUserInEntries(users: [], userName: string) {
let newUsers = [];
for (let user of users) {
if (user !== userName) newUsers.push(user);
@@ -318,130 +321,87 @@ function deleteUserInEntries(users: [], userName: any) {
const reducerByName = (state: any, username: string, newUserState: any) => {
const newUsersByNames = {
...state.byNames,
...state,
[username]: newUserState
};
return {
...state,
byNames: newUsersByNames
};
return newUsersByNames;
};
export default function reducer(state: any = {}, action: any = {}) {
function listReducer(state: any = {}, action: any = {}) {
switch (action.type) {
// fetch user list cases
// Fetch all users actions
case FETCH_USERS_PENDING:
return {
...state,
list: {
loading: true
}
};
case FETCH_USERS_SUCCESS:
const users = action.payload._embedded.users;
const userNames = users.map(user => user.name);
const byNames = extractUsersByNames(users, userNames, state.byNames);
return {
...state,
list: {
error: null,
loading: false,
entries: userNames,
loading: false,
entry: {
userCreatePermission: action.payload._links.create ? true : false,
page: action.payload.page,
pageTotal: action.payload.pageTotal,
_links: action.payload._links
}
},
byNames
};
case FETCH_USERS_FAILURE:
return {
...state,
list: {
...state.users,
loading: false,
error: action.payload.error
}
};
// Fetch single user cases
// Delete single user actions
case DELETE_USER_SUCCESS:
const newUserEntries = deleteUserInEntries(
state.entries,
action.payload.name
);
return {
...state,
entries: newUserEntries
};
default:
return state;
}
}
function byNamesReducer(state: any = {}, action: any = {}) {
switch (action.type) {
// Fetch all users actions
case FETCH_USERS_SUCCESS:
const users = action.payload._embedded.users;
const userNames = users.map(user => user.name);
const byNames = extractUsersByNames(users, userNames, state.byNames);
return {
...byNames
};
// Fetch single user actions
case FETCH_USER_PENDING:
return reducerByName(state, action.payload.name, {
loading: true,
error: null
});
case FETCH_USER_SUCCESS:
return reducerByName(state, action.payload.name, {
loading: false,
error: null,
entry: action.payload
});
case FETCH_USER_FAILURE:
return reducerByName(state, action.payload.username, {
loading: false,
error: action.payload.error
});
// Delete single user cases
case DELETE_USER:
return reducerByName(state, action.payload.name, {
loading: true,
error: null,
entry: action.payload
});
case DELETE_USER_SUCCESS:
const newUserByNames = deleteUserInUsersByNames(state.byNames, [
action.payload.name
]);
const newUserEntries = deleteUserInEntries(state.list.entries, [
action.payload.name
]);
return {
...state,
list: {
...state.list,
entries: newUserEntries
},
byNames: newUserByNames
};
case DELETE_USER_FAILURE:
return reducerByName(state, action.payload.user.name, {
loading: false,
error: action.payload.error,
entry: action.payload.user
});
// Add single user cases
case CREATE_USER_PENDING:
return {
...state,
create: {
loading: true
}
};
case CREATE_USER_SUCCESS:
return {
...state,
create: {
loading: false
}
};
case CREATE_USER_FAILURE:
return {
...state,
create: {
loading: false,
error: action.payload
}
};
// Update single user cases
// Update single user actions
case MODIFY_USER_PENDING:
return reducerByName(state, action.payload.name, {
loading: true
@@ -454,6 +414,27 @@ export default function reducer(state: any = {}, action: any = {}) {
return reducerByName(state, action.payload.user.name, {
error: action.payload.error
});
// Delete single user actions
case DELETE_USER_PENDING:
return reducerByName(state, action.payload.name, {
loading: true,
error: null,
entry: action.payload
});
case DELETE_USER_SUCCESS:
const newUserByNames = deleteUserInUsersByNames(
state,
action.payload.name
);
return newUserByNames;
case DELETE_USER_FAILURE:
return reducerByName(state, action.payload.user.name, {
loading: false,
error: action.payload.error,
entry: action.payload.user
});
default:
return state;
}
@@ -489,3 +470,31 @@ export const isPermittedToCreateUsers = (state: Object): boolean => {
}
return false;
};
function createReducer(state: any = {}, action: any = {}) {
switch (action.type) {
case CREATE_USER_PENDING:
return {
...state,
loading: true
};
case CREATE_USER_SUCCESS:
return {
...state,
loading: false
};
case CREATE_USER_FAILURE:
return {
...state,
loading: false,
error: action.payload
};
default:
return state;
}
}
export default combineReducers({
list: listReducer,
byNames: byNamesReducer,
create: createReducer
});

View File

@@ -3,48 +3,46 @@ import configureMockStore from "redux-mock-store";
import thunk from "redux-thunk";
import fetchMock from "fetch-mock";
import {
FETCH_USERS_PENDING,
FETCH_USERS_SUCCESS,
fetchUsers,
FETCH_USERS_FAILURE,
createUserPending,
import reducer, {
CREATE_USER_FAILURE,
CREATE_USER_PENDING,
CREATE_USER_SUCCESS,
CREATE_USER_FAILURE,
modifyUser,
MODIFY_USER_PENDING,
MODIFY_USER_FAILURE,
MODIFY_USER_SUCCESS,
deleteUserPending,
deleteUserFailure,
DELETE_USER,
DELETE_USER_SUCCESS,
createUser,
createUserFailure,
createUserPending,
createUserSuccess,
DELETE_USER_FAILURE,
DELETE_USER_PENDING,
DELETE_USER_SUCCESS,
deleteUser,
fetchUsersFailure,
fetchUsersSuccess,
fetchUser,
deleteUserFailure,
deleteUserPending,
deleteUserSuccess,
FETCH_USER_FAILURE,
FETCH_USER_PENDING,
FETCH_USER_SUCCESS,
FETCH_USER_FAILURE,
createUser,
createUserSuccess,
createUserFailure,
modifyUserPending,
modifyUserSuccess,
modifyUserFailure,
fetchUserSuccess,
deleteUserSuccess,
fetchUsersPending,
fetchUserPending,
FETCH_USERS_FAILURE,
FETCH_USERS_PENDING,
FETCH_USERS_SUCCESS,
fetchUser,
fetchUserFailure,
fetchUserPending,
fetchUsers,
fetchUsersFailure,
fetchUsersPending,
fetchUsersSuccess,
fetchUserSuccess,
selectListAsCollection,
isPermittedToCreateUsers
isPermittedToCreateUsers,
MODIFY_USER_FAILURE,
MODIFY_USER_PENDING,
MODIFY_USER_SUCCESS,
modifyUser,
modifyUserFailure,
modifyUserPending,
modifyUserSuccess
} from "./users";
import reducer from "./users";
const userZaphod = {
active: true,
admin: true,
@@ -91,28 +89,6 @@ const userFord = {
}
};
const responseBodyZaphod = {
page: 0,
pageTotal: 1,
_links: {
self: {
href: "http://localhost:3000/scm/api/rest/v2/users/?page=0&pageSize=10"
},
first: {
href: "http://localhost:3000/scm/api/rest/v2/users/?page=0&pageSize=10"
},
last: {
href: "http://localhost:3000/scm/api/rest/v2/users/?page=0&pageSize=10"
},
create: {
href: "http://localhost:3000/scm/api/rest/v2/users/"
}
},
_embedded: {
users: [userZaphod]
}
};
const responseBody = {
page: 0,
pageTotal: 1,
@@ -309,7 +285,7 @@ describe("users fetch()", () => {
return store.dispatch(deleteUser(userZaphod)).then(() => {
const actions = store.getActions();
expect(actions.length).toBe(2);
expect(actions[0].type).toEqual(DELETE_USER);
expect(actions[0].type).toEqual(DELETE_USER_PENDING);
expect(actions[0].payload).toBe(userZaphod);
expect(actions[1].type).toEqual(DELETE_USER_SUCCESS);
});
@@ -339,7 +315,7 @@ describe("users fetch()", () => {
const store = mockStore({});
return store.dispatch(deleteUser(userZaphod)).then(() => {
const actions = store.getActions();
expect(actions[0].type).toEqual(DELETE_USER);
expect(actions[0].type).toEqual(DELETE_USER_PENDING);
expect(actions[0].payload).toBe(userZaphod);
expect(actions[1].type).toEqual(DELETE_USER_FAILURE);
expect(actions[1].payload).toBeDefined();
@@ -381,77 +357,25 @@ describe("users reducer", () => {
expect(newState.list.entry.userCreatePermission).toBeTruthy();
});
test("should update state correctly according to DELETE_USER action", () => {
const state = {
usersByNames: {
zaphod: {
loading: false,
error: null,
entry: userZaphod
}
it("should set error when fetching users failed", () => {
const oldState = {
list: {
loading: true
}
};
const newState = reducer(state, deleteUserPending(userZaphod));
const zaphod = newState.byNames["zaphod"];
expect(zaphod.loading).toBeTruthy();
expect(zaphod.entry).toBe(userZaphod);
const error = new Error("kaputt");
const newState = reducer(oldState, fetchUsersFailure("url.com", error));
expect(newState.list.loading).toBeFalsy();
expect(newState.list.error).toEqual(error);
});
it("should not effect other users if one user will be deleted", () => {
const state = {
usersByNames: {
zaphod: {
loading: false,
error: null,
entry: userZaphod
},
ford: {
loading: false
}
}
};
it("should set userCreatePermission to true if update link is present", () => {
const newState = reducer({}, fetchUsersSuccess(responseBody));
const newState = reducer(state, deleteUserPending(userZaphod));
const ford = newState.usersByNames["ford"];
expect(ford.loading).toBeFalsy();
expect(newState.list.entry.userCreatePermission).toBeTruthy();
});
it("should set the error of user which could not be deleted", () => {
const state = {
usersByNames: {
zaphod: {
loading: true,
entry: userZaphod
}
}
};
const error = new Error("error");
const newState = reducer(state, deleteUserFailure(userZaphod, error));
const zaphod = newState.byNames["zaphod"];
expect(zaphod.loading).toBeFalsy();
expect(zaphod.error).toBe(error);
});
it("should not effect other users if one user could not be deleted", () => {
const state = {
usersByNames: {
zaphod: {
loading: false,
error: null,
entry: userZaphod
},
ford: {
loading: false
}
}
};
const error = new Error("error");
const newState = reducer(state, deleteUserFailure(userZaphod, error));
const ford = newState.usersByNames["ford"];
expect(ford.loading).toBeFalsy();
});
it("should not replace whole byNames map when fetching users", () => {
@@ -468,10 +392,111 @@ describe("users reducer", () => {
expect(newState.byNames["ford"]).toBeDefined();
});
test("should update state correctly according to DELETE_USER_PENDING action", () => {
const state = {
byNames: {
zaphod: {
loading: false,
error: null,
entry: userZaphod
}
}
};
const newState = reducer(state, deleteUserPending(userZaphod));
const zaphod = newState.byNames["zaphod"];
expect(zaphod.loading).toBeTruthy();
expect(zaphod.entry).toBe(userZaphod);
});
it("should not effect other users if one user will be deleted", () => {
const state = {
byNames: {
zaphod: {
loading: false,
error: null,
entry: userZaphod
},
ford: {
loading: false
}
}
};
const newState = reducer(state, deleteUserPending(userZaphod));
const ford = newState.byNames["ford"];
expect(ford.loading).toBeFalsy();
});
it("should set the error of user which could not be deleted", () => {
const state = {
byNames: {
zaphod: {
loading: true,
entry: userZaphod
}
}
};
const error = new Error("error");
const newState = reducer(state, deleteUserFailure(userZaphod, error));
const zaphod = newState.byNames["zaphod"];
expect(zaphod.loading).toBeFalsy();
expect(zaphod.error).toBe(error);
});
it("should not effect other users if one user could not be deleted", () => {
const state = {
byNames: {
zaphod: {
loading: false,
error: null,
entry: userZaphod
},
ford: {
loading: false
}
}
};
const error = new Error("error");
const newState = reducer(state, deleteUserFailure(userZaphod, error));
const ford = newState.byNames["ford"];
expect(ford.loading).toBeFalsy();
});
it("should remove user from state when delete succeeds", () => {
const state = {
list: {
entries: ["ford", "zaphod"]
},
byNames: {
zaphod: {
loading: true,
error: null,
entry: userZaphod
},
ford: {
loading: true,
error: null,
entry: userFord
}
}
};
const newState = reducer(state, deleteUserSuccess(userFord));
expect(newState.byNames["zaphod"]).toBeDefined();
expect(newState.byNames["ford"]).toBeFalsy();
expect(newState.list.entries).toEqual(["zaphod"]);
});
it("should set userCreatePermission to true if create link is present", () => {
const newState = reducer({}, fetchUsersSuccess(responseBody));
expect(newState.list.entry.userCreatePermission).toBeTruthy();
expect(newState.list.entries).toEqual(["zaphod", "ford"]);
expect(newState.byNames["ford"]).toBeTruthy();
expect(newState.byNames["zaphod"]).toBeTruthy();
});
it("should update state correctly according to CREATE_USER_PENDING action", () => {
@@ -481,14 +506,14 @@ describe("users reducer", () => {
});
it("should update state correctly according to CREATE_USER_SUCCESS action", () => {
const newState = reducer({ loading: true }, createUserSuccess());
const newState = reducer({ create: { loading: true } }, createUserSuccess());
expect(newState.create.loading).toBeFalsy();
expect(newState.create.error).toBeFalsy();
});
it("should set the loading to false and the error if user could not be created", () => {
const newState = reducer(
{ loading: true, error: null },
{ create: { loading: true, error: null } },
createUserFailure(userFord, new Error("kaputt kaputt"))
);
expect(newState.create.loading).toBeFalsy();
@@ -500,17 +525,17 @@ describe("users reducer", () => {
expect(newState.byNames["zaphod"].loading).toBeTruthy();
});
it("should not affect users state", () => {
it("should not affect list state", () => {
const newState = reducer(
{
users: {
list: {
entries: ["ford"]
}
},
fetchUserPending("zaphod")
);
expect(newState.byNames["zaphod"].loading).toBeTruthy();
expect(newState.users.entries).toEqual(["ford"]);
expect(newState.list.entries).toEqual(["ford"]);
});
it("should update state according to FETCH_USER_FAILURE action", () => {
@@ -543,8 +568,12 @@ describe("users reducer", () => {
it("should update state according to MODIFY_USER_PENDING action", () => {
const newState = reducer(
{
byNames: {
ford: {
error: new Error("something"),
entry: {}
}
}
},
modifyUserPending(userFord)
);
@@ -556,9 +585,13 @@ describe("users reducer", () => {
it("should update state according to MODIFY_USER_SUCCESS action", () => {
const newState = reducer(
{
byNames: {
ford: {
loading: true,
error: new Error("something"),
entry: {}
}
}
},
modifyUserSuccess(userFord)
);
@@ -571,8 +604,12 @@ describe("users reducer", () => {
const error = new Error("something went wrong");
const newState = reducer(
{
byNames: {
ford: {
loading: true,
entry: {}
}
}
},
modifyUserFailure(userFord, error)
);
@@ -580,7 +617,6 @@ describe("users reducer", () => {
expect(newState.byNames["ford"].error).toBe(error);
expect(newState.byNames["ford"].entry).toBeFalsy();
});
});
describe("selector tests", () => {
it("should return an empty object", () => {

View File

@@ -0,0 +1,53 @@
package sonia.scm.api.v2.resources;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.google.common.base.Preconditions;
import com.google.common.base.Strings;
import javax.ws.rs.FormParam;
import java.util.List;
public class AuthenticationRequestDto {
@FormParam("grant_type")
@JsonProperty("grant_type")
private String grantType;
@FormParam("username")
private String username;
@FormParam("password")
private String password;
@FormParam("cookie")
private boolean cookie;
@FormParam("scope")
private List<String> scope;
public String getGrantType() {
return grantType;
}
public String getUsername() {
return username;
}
public String getPassword() {
return password;
}
public boolean isCookie() {
return cookie;
}
public List<String> getScope() {
return scope;
}
public void validate() {
Preconditions.checkArgument(!Strings.isNullOrEmpty(grantType), "grant_type parameter is required");
Preconditions.checkArgument(!Strings.isNullOrEmpty(username), "username parameter is required");
Preconditions.checkArgument(!Strings.isNullOrEmpty(password), "password parameter is required");
}
}

View File

@@ -1,8 +1,5 @@
package sonia.scm.api.v2.resources;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.google.common.base.Preconditions;
import com.google.common.base.Strings;
import com.google.inject.Inject;
import com.webcohesion.enunciate.metadata.rs.ResponseCode;
import com.webcohesion.enunciate.metadata.rs.StatusCodes;
@@ -26,11 +23,7 @@ import javax.ws.rs.core.Response;
import javax.xml.bind.annotation.XmlAccessType;
import javax.xml.bind.annotation.XmlAccessorType;
import javax.xml.bind.annotation.XmlRootElement;
import java.util.List;
/**
* Created by masuewer on 04.07.18.
*/
@Path(AuthenticationResource.PATH)
public class AuthenticationResource {
@@ -61,7 +54,7 @@ public class AuthenticationResource {
public Response authenticateViaForm(
@Context HttpServletRequest request,
@Context HttpServletResponse response,
@BeanParam AuthenticationRequest authentication
@BeanParam AuthenticationRequestDto authentication
) {
return authenticate(request, response, authentication);
}
@@ -78,7 +71,7 @@ public class AuthenticationResource {
public Response authenticateViaJSONBody(
@Context HttpServletRequest request,
@Context HttpServletResponse response,
AuthenticationRequest authentication
AuthenticationRequestDto authentication
) {
return authenticate(request, response, authentication);
}
@@ -86,7 +79,7 @@ public class AuthenticationResource {
private Response authenticate(
HttpServletRequest request,
HttpServletResponse response,
AuthenticationRequest authentication
AuthenticationRequestDto authentication
) {
authentication.validate();
@@ -180,51 +173,6 @@ public class AuthenticationResource {
return Response.noContent().build();
}
public static class AuthenticationRequest {
@FormParam("grant_type")
@JsonProperty("grant_type")
private String grantType;
@FormParam("username")
private String username;
@FormParam("password")
private String password;
@FormParam("cookie")
private boolean cookie;
@FormParam("scope")
private List<String> scope;
public String getGrantType() {
return grantType;
}
public String getUsername() {
return username;
}
public String getPassword() {
return password;
}
public boolean isCookie() {
return cookie;
}
public List<String> getScope() {
return scope;
}
public void validate() {
Preconditions.checkArgument(!Strings.isNullOrEmpty(grantType), "grant_type parameter is required");
Preconditions.checkArgument(!Strings.isNullOrEmpty(username), "username parameter is required");
Preconditions.checkArgument(!Strings.isNullOrEmpty(password), "password parameter is required");
}
}
private Response handleFailedAuthentication(HttpServletRequest request,
AuthenticationException ex, Response.Status status,

View File

@@ -1,5 +1,6 @@
package sonia.scm.api.v2.resources;
import com.google.common.annotations.VisibleForTesting;
import de.otto.edison.hal.Links;
import org.mapstruct.AfterMapping;
import org.mapstruct.Mapper;
@@ -21,6 +22,11 @@ public abstract class UserToUserDtoMapper extends BaseMapper<User, UserDto> {
@Inject
private ResourceLinks resourceLinks;
@VisibleForTesting
void setResourceLinks(ResourceLinks resourceLinks) {
this.resourceLinks = resourceLinks;
}
@AfterMapping
void removePassword(@MappingTarget UserDto target) {
target.setPassword(UserResource.DUMMY_PASSWORT);

View File

@@ -0,0 +1,142 @@
package sonia.scm.api.v2.resources;
import com.github.sdorra.shiro.ShiroRule;
import com.github.sdorra.shiro.SubjectAware;
import org.jboss.resteasy.core.Dispatcher;
import org.jboss.resteasy.mock.MockDispatcherFactory;
import org.jboss.resteasy.mock.MockHttpRequest;
import org.jboss.resteasy.mock.MockHttpResponse;
import org.jboss.resteasy.spi.ResteasyProviderFactory;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.runners.MockitoJUnitRunner;
import sonia.scm.config.ScmConfiguration;
import sonia.scm.security.AccessToken;
import sonia.scm.security.AccessTokenBuilder;
import sonia.scm.security.AccessTokenBuilderFactory;
import sonia.scm.security.AccessTokenCookieIssuer;
import sonia.scm.user.User;
import sonia.scm.user.UserException;
import sonia.scm.user.UserManager;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.UriInfo;
import java.io.IOException;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.Date;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import static org.mockito.Matchers.any;
import static org.mockito.Matchers.eq;
import static org.mockito.Mockito.*;
@SubjectAware(
configuration = "classpath:sonia/scm/repository/shiro.ini"
)
@RunWith(MockitoJUnitRunner.class)
public class AuthenticationResourceTest {
@Rule
public ShiroRule shiro = new ShiroRule();
private Dispatcher dispatcher = MockDispatcherFactory.createDispatcher();
@Mock
private AccessTokenBuilderFactory accessTokenBuilderFactory;
@Mock
private AccessTokenBuilder accessTokenBuilder;
private AccessTokenCookieIssuer cookieIssuer = new AccessTokenCookieIssuer(mock(ScmConfiguration.class));
private static final String AUTH_JSON_TRILLIAN = "{\n" +
"\t\"cookie\": true,\n" +
"\t\"grant_type\": \"password\",\n" +
"\t\"username\": \"trillian\",\n" +
"\t\"password\": \"secret\"\n" +
"}";
private static final String AUTH_JSON_TRILLIAN_WRONG_PW = "{\n" +
"\t\"cookie\": true,\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\"grant_type\": \"password\",\n" +
"\t\"username\": \"iDoNotExist\",\n" +
"\t\"password\": \"doesNotMatter\"\n" +
"}";
@Before
public void prepareEnvironment() {
AuthenticationResource authenticationResource = new AuthenticationResource(accessTokenBuilderFactory, cookieIssuer);
dispatcher.getRegistry().addSingletonResource(authenticationResource);
AccessToken accessToken = mock(AccessToken.class);
when(accessToken.getExpiration()).thenReturn(new Date(Long.MAX_VALUE));
when(accessTokenBuilder.build()).thenReturn(accessToken);
when(accessTokenBuilderFactory.create()).thenReturn(accessTokenBuilder);
HttpServletRequest servletRequest = mock(HttpServletRequest.class);
ResteasyProviderFactory.getContextDataMap().put(HttpServletRequest.class, servletRequest);
HttpServletResponse servletResponse = mock(HttpServletResponse.class);
ResteasyProviderFactory.getContextDataMap().put(HttpServletResponse.class, servletResponse);
}
@Test
public void shouldAuthCorrectly() throws URISyntaxException {
MockHttpRequest request = getMockHttpRequest(AUTH_JSON_TRILLIAN);
MockHttpResponse response = new MockHttpResponse();
dispatcher.invoke(request, response);
assertEquals(HttpServletResponse.SC_NO_CONTENT, response.getStatus());
}
@Test
public void shouldNotAuthUserWithWrongPassword() throws URISyntaxException {
MockHttpRequest request = getMockHttpRequest(AUTH_JSON_TRILLIAN_WRONG_PW);
MockHttpResponse response = new MockHttpResponse();
dispatcher.invoke(request, response);
assertEquals(HttpServletResponse.SC_UNAUTHORIZED, response.getStatus());
}
@Test
public void shouldNotAuthNonexistingUser() throws URISyntaxException {
MockHttpRequest request = getMockHttpRequest(AUTH_JSON_NOT_EXISTING_USER);
MockHttpResponse response = new MockHttpResponse();
dispatcher.invoke(request, response);
assertEquals(HttpServletResponse.SC_UNAUTHORIZED, response.getStatus());
}
private MockHttpRequest getMockHttpRequest(String jsonPayload) throws URISyntaxException {
MockHttpRequest request = MockHttpRequest.post("/" + AuthenticationResource.PATH + "/access_token");
request.content(jsonPayload.getBytes());
request.contentType(MediaType.APPLICATION_JSON_TYPE);
return request;
}
}

View File

@@ -39,8 +39,6 @@ import static org.mockito.Mockito.*;
import static org.mockito.MockitoAnnotations.initMocks;
@SubjectAware(
// username = "trillian",
// password = "secret",
configuration = "classpath:sonia/scm/repository/shiro.ini"
)
public class MeResourceTest {
@@ -50,6 +48,8 @@ public class MeResourceTest {
private Dispatcher dispatcher = MockDispatcherFactory.createDispatcher();
private final ResourceLinks resourceLinks = ResourceLinksMock.createMock(URI.create("/"));
@Mock
private UriInfo uriInfo;
@Mock
@@ -67,9 +67,10 @@ public class MeResourceTest {
public void prepareEnvironment() throws IOException, UserException {
initMocks(this);
createDummyUser("trillian");
doNothing().when(userManager).create(userCaptor.capture());
when(userManager.create(userCaptor.capture())).thenAnswer(invocation -> invocation.getArguments()[0]);
doNothing().when(userManager).modify(userCaptor.capture());
doNothing().when(userManager).delete(userCaptor.capture());
userToDtoMapper.setResourceLinks(resourceLinks);
MeResource meResource = new MeResource(userToDtoMapper, userManager);
dispatcher.getRegistry().addSingletonResource(meResource);
when(uriInfo.getBaseUri()).thenReturn(URI.create("/"));