diff --git a/dashboard/package.json b/dashboard/package.json index 98aff3d6a99..3865b7deb8c 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -69,12 +69,15 @@ "@types/react-router-dom": "^4.2.3", "@types/react-router-redux": "^5.0.11", "@types/react-test-renderer": "^16.0.0", + "@types/redux-mock-store": "^1.0.0", "axios-mock-adapter": "^1.15.0", "husky": "0.14.3", + "jest-enzyme": "6.0.2", "lint-staged": "^6.1.0", "mock-socket": "^8.0.2", "prettier": "^1.10.2", "react-svg-loader": "^2.1.0", + "redux-mock-store": "^1.5.3", "tslint": "^5.9.1", "tslint-config-prettier": "^1.6.0", "tslint-react": "^3.4.0", diff --git a/dashboard/src/actions/auth.test.tsx b/dashboard/src/actions/auth.test.tsx new file mode 100644 index 00000000000..126eb097b5c --- /dev/null +++ b/dashboard/src/actions/auth.test.tsx @@ -0,0 +1,65 @@ +import { IAuthState } from "reducers/auth"; +import configureMockStore from "redux-mock-store"; +import thunk from "redux-thunk"; +import { getType } from "typesafe-actions"; +import actions from "."; +import { Auth } from "../shared/Auth"; + +const mockStore = configureMockStore([thunk]); +const token = "abcd"; +const validationErrorMsg = "Validation error"; + +let store: any; + +beforeEach(() => { + const state: IAuthState = { + authenticated: false, + authenticating: false, + }; + + Auth.validateToken = jest.fn(); + Auth.setAuthToken = jest.fn(); + + store = mockStore({ + auth: { + state, + }, + }); +}); + +describe("authenticate", () => { + it("dispatches authenticating and auth error if invalid", () => { + Auth.validateToken = jest.fn().mockImplementationOnce(() => { + throw new Error(validationErrorMsg); + }); + const expectedActions = [ + { + type: getType(actions.auth.authenticating), + }, + { + errorMsg: `Error: ${validationErrorMsg}`, + type: getType(actions.auth.authenticationError), + }, + ]; + + return store.dispatch(actions.auth.authenticate(token)).then(() => { + expect(store.getActions()).toEqual(expectedActions); + }); + }); + + it("dispatches authenticating and auth ok if valid", () => { + const expectedActions = [ + { + type: getType(actions.auth.authenticating), + }, + { + authenticated: true, + type: getType(actions.auth.setAuthenticated), + }, + ]; + + return store.dispatch(actions.auth.authenticate(token)).then(() => { + expect(store.getActions()).toEqual(expectedActions); + }); + }); +}); diff --git a/dashboard/src/actions/auth.ts b/dashboard/src/actions/auth.ts index 52e4118c165..d57f806b64a 100644 --- a/dashboard/src/actions/auth.ts +++ b/dashboard/src/actions/auth.ts @@ -9,14 +9,30 @@ export const setAuthenticated = createAction("SET_AUTHENTICATED", (authenticated type: "SET_AUTHENTICATED", })); -const allActions = [setAuthenticated].map(getReturnOfExpression); +export const authenticating = createAction("AUTHENTICATING", () => ({ + type: "AUTHENTICATING", +})); + +export const authenticationError = createAction("AUTHENTICATION_ERROR", (errorMsg: string) => ({ + errorMsg, + type: "AUTHENTICATION_ERROR", +})); + +const allActions = [setAuthenticated, authenticating, authenticationError].map( + getReturnOfExpression, +); export type AuthAction = typeof allActions[number]; export function authenticate(token: string) { return async (dispatch: Dispatch) => { - await Auth.validateToken(token); - Auth.setAuthToken(token); - return dispatch(setAuthenticated(true)); + dispatch(authenticating()); + try { + await Auth.validateToken(token); + Auth.setAuthToken(token); + return dispatch(setAuthenticated(true)); + } catch (e) { + return dispatch(authenticationError(e.toString())); + } }; } diff --git a/dashboard/src/backport.d.ts b/dashboard/src/backport.d.ts index 1f8039b644d..d858e5adfc8 100644 --- a/dashboard/src/backport.d.ts +++ b/dashboard/src/backport.d.ts @@ -1,5 +1,6 @@ // This file contains a set of backports from typescript 3.x // TODO(miguel) Remove backports once we upgrade typescript https://github.com/kubeapps/kubeapps/issues/534 +// @ts-ignore type EventHandlerNonNull = (event: Event) => any; // @ts-ignore diff --git a/dashboard/src/components/LoginForm/LoginForm.test.tsx b/dashboard/src/components/LoginForm/LoginForm.test.tsx index cf96bdee0d7..6093667ec0c 100644 --- a/dashboard/src/components/LoginForm/LoginForm.test.tsx +++ b/dashboard/src/components/LoginForm/LoginForm.test.tsx @@ -12,19 +12,25 @@ const emptyLocation: Location = { state: "", }; +const defaultProps = { + authenticate: jest.fn(), + authenticated: false, + authenticating: false, + authenticationError: undefined, + location: emptyLocation, +}; + +const authenticationError = "it's a trap"; + it("renders a token login form", () => { - const wrapper = shallow( - , - ); + const wrapper = shallow(); expect(wrapper.find("input#token").exists()).toBe(true); expect(wrapper.find(Redirect).exists()).toBe(false); expect(wrapper).toMatchSnapshot(); }); it("renders a link to the access control documentation", () => { - const wrapper = shallow( - , - ); + const wrapper = shallow(); expect(wrapper.find("a").props()).toMatchObject({ href: "https://github.com/kubeapps/kubeapps/blob/master/docs/user/access-control.md", target: "_blank", @@ -32,28 +38,24 @@ it("renders a link to the access control documentation", () => { }); it("updates the token in the state when the input is changed", () => { - const wrapper = shallow( - , - ); + const wrapper = shallow(); wrapper.find("input#token").simulate("change", { currentTarget: { value: "f00b4r" } }); expect(wrapper.state("token")).toBe("f00b4r"); }); describe("redirect if authenticated", () => { it("redirects to / if no current location", () => { - const wrapper = shallow( - , - ); + const wrapper = shallow(); const redirect = wrapper.find(Redirect); expect(redirect.exists()).toBe(true); expect(redirect.props()).toEqual({ push: false, to: { pathname: "/" } }); }); it("redirects to previous location", () => { - const location = emptyLocation; + const location = Object.assign({}, emptyLocation); location.state = { from: "/test" }; const wrapper = shallow( - , + , ); const redirect = wrapper.find(Redirect); expect(redirect.exists()).toBe(true); @@ -62,49 +64,25 @@ describe("redirect if authenticated", () => { }); it("calls the authenticate handler when the form is submitted", () => { - const authenticate = jest.fn(); - const wrapper = shallow( - , - ); + const wrapper = shallow(); wrapper.find("input#token").simulate("change", { currentTarget: { value: "f00b4r" } }); wrapper.find("form").simulate("submit", { preventDefault: jest.fn() }); - expect(authenticate).toBeCalledWith("f00b4r"); - expect(wrapper.state("authenticating")).toBe(true); + expect(defaultProps.authenticate).toBeCalledWith("f00b4r"); }); -it("displays an error if the authenticate handler throws an error", async () => { - const authenticate = async () => { - throw new Error("it's a trap"); - }; +it("displays an error if the authentication error is passed", () => { const wrapper = shallow( - , + , ); - wrapper.find("input#token").simulate("change", { currentTarget: { value: "f00b4r" } }); - wrapper.find("form").simulate("submit", { preventDefault: jest.fn() }); - - // wait for promise to resolve - try { - await authenticate(); - } catch (e) { - expect(wrapper.state()).toMatchObject({ - authenticating: false, - error: e.toString(), - }); - wrapper.update(); - expect(wrapper.find(".alert-error").exists()).toBe(true); - expect(wrapper).toMatchSnapshot(); - } + expect(wrapper.find(".alert-error").exists()).toBe(true); + expect(wrapper).toMatchSnapshot(); }); -it("allows you to dismiss the error alert", () => { - const wrapper = shallow( - , - ); - wrapper.setState({ error: "it's a trap" }); - expect(wrapper.find(".alert-error").exists()).toBe(true); +it("disables the input if authenticating", () => { + const wrapper = shallow(); - wrapper.find("button.alert__close").simulate("click"); - expect(wrapper.find(".alert-error").exists()).toBe(false); - expect(wrapper.state("error")).toBeUndefined(); + expect(wrapper.find(".button").props().disabled).toBe(false); + wrapper.setProps({ authenticating: true }); + expect(wrapper.find(".button")).toBeDisabled(); }); diff --git a/dashboard/src/components/LoginForm/LoginForm.tsx b/dashboard/src/components/LoginForm/LoginForm.tsx index ac0c4a285fc..e4ded462438 100644 --- a/dashboard/src/components/LoginForm/LoginForm.tsx +++ b/dashboard/src/components/LoginForm/LoginForm.tsx @@ -1,24 +1,24 @@ import { Location } from "history"; import * as React from "react"; -import { Lock, X } from "react-feather"; +import { Lock } from "react-feather"; import { Redirect } from "react-router"; import "./LoginForm.css"; interface ILoginFormProps { authenticated: boolean; + authenticating: boolean; + authenticationError: string | undefined; authenticate: (token: string) => any; location: Location; } interface ILoginFormState { - authenticating: boolean; token: string; - error?: string; } class LoginForm extends React.Component { - public state: ILoginFormState = { token: "", authenticating: false }; + public state: ILoginFormState = { token: "" }; public render() { if (this.props.authenticated) { const { from } = this.props.location.state || { from: { pathname: "/" } }; @@ -28,13 +28,10 @@ class LoginForm extends React.Component {
- {this.state.error && ( + {this.props.authenticationError && (
There was an error connecting to the Kubernetes API. Please check that your token is valid. -
)}
@@ -71,7 +68,7 @@ class LoginForm extends React.Component { @@ -87,22 +84,13 @@ class LoginForm extends React.Component { private handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); - this.setState({ authenticating: true }); const { token } = this.state; - try { - return token && (await this.props.authenticate(token)); - } catch (e) { - this.setState({ error: e.toString(), token: "", authenticating: false }); - } + return token && (await this.props.authenticate(token)); }; private handleTokenChange = (e: React.FormEvent) => { this.setState({ token: e.currentTarget.value }); }; - - private handleAlertClose = (e: React.FormEvent) => { - this.setState({ error: undefined }); - }; } export default LoginForm; diff --git a/dashboard/src/components/LoginForm/__snapshots__/LoginForm.test.tsx.snap b/dashboard/src/components/LoginForm/__snapshots__/LoginForm.test.tsx.snap index 40b74cccc55..c4201b5da4a 100644 --- a/dashboard/src/components/LoginForm/__snapshots__/LoginForm.test.tsx.snap +++ b/dashboard/src/components/LoginForm/__snapshots__/LoginForm.test.tsx.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`displays an error if the authenticate handler throws an error 1`] = ` +exports[`displays an error if the authentication error is passed 1`] = `
@@ -15,15 +15,6 @@ exports[`displays an error if the authenticate handler throws an error 1`] = ` role="alert" > There was an error connecting to the Kubernetes API. Please check that your token is valid. -
{ + const state: IAuthState = { + authenticated, + authenticating, + authenticationError, + }; + return mockStore({ auth: state }); +}; + +const emptyLocation: Location = { + hash: "", + pathname: "", + search: "", + state: "", +}; + +describe("LoginFormContainer props", () => { + it("maps authentication redux states to props", () => { + const store = makeStore(true, true, "It's a trap"); + const wrapper = shallow(); + const form = wrapper.find("LoginForm"); + expect(form).toHaveProp({ + authenticated: true, + authenticating: true, + authenticationError: "It's a trap", + }); + }); +}); diff --git a/dashboard/src/containers/LoginFormContainer/LoginFormContainer.ts b/dashboard/src/containers/LoginFormContainer/LoginFormContainer.ts index 96fab997422..b2bb1f10606 100644 --- a/dashboard/src/containers/LoginFormContainer/LoginFormContainer.ts +++ b/dashboard/src/containers/LoginFormContainer/LoginFormContainer.ts @@ -5,9 +5,13 @@ import actions from "../../actions"; import LoginForm from "../../components/LoginForm"; import { IStoreState } from "../../shared/types"; -function mapStateToProps({ auth: { authenticated } }: IStoreState) { +function mapStateToProps({ + auth: { authenticated, authenticating, authenticationError }, +}: IStoreState) { return { authenticated, + authenticating, + authenticationError, }; } diff --git a/dashboard/src/mocked-store.d.ts b/dashboard/src/mocked-store.d.ts new file mode 100644 index 00000000000..711e2723e67 --- /dev/null +++ b/dashboard/src/mocked-store.d.ts @@ -0,0 +1,9 @@ +// TODO(miguel) I have not managed to make the linter work and also +// enhance the global JSX namespace without disabling it. +/* tslint:disable no-namespace*/ +declare namespace JSX { + interface IntrinsicAttributes { + // Add store option for testing with mocked-store + store?: any; + } +} diff --git a/dashboard/src/reducers/auth.test.ts b/dashboard/src/reducers/auth.test.ts new file mode 100644 index 00000000000..4d933322a77 --- /dev/null +++ b/dashboard/src/reducers/auth.test.ts @@ -0,0 +1,63 @@ +import { getType } from "typesafe-actions"; +import actions from "../actions"; +import authReducer, { IAuthState } from "./auth"; + +describe("authReducer", () => { + let initialState: IAuthState; + const errMessage = "It's a trap"; + + const actionTypes = { + authenticating: getType(actions.auth.authenticating), + authenticationError: getType(actions.auth.authenticationError), + setAuthenticated: getType(actions.auth.setAuthenticated), + }; + + beforeEach(() => { + initialState = { + authenticated: false, + authenticating: false, + }; + }); + + describe("initial state", () => { + it("sets authenticated false if no key is found in local storage", () => { + expect(authReducer(undefined, {} as any)).toEqual(initialState); + }); + }); + + // TODO(miguel) doing type assertion `as any` because in typescript 2.7 it seems + // that the type gets lost during the creation of the map above. + // Remove `as any` once we upgrade typescript + describe("reducer actions", () => { + it(`sets value passed in ${actionTypes.setAuthenticated}`, () => { + [true, false].forEach(e => { + expect( + authReducer(undefined, { + authenticated: e, + type: actionTypes.setAuthenticated as any, + }), + ).toEqual({ ...initialState, authenticated: e }); + }); + }); + + it(`resets authenticated and authenticating if type ${actionTypes.authenticating}`, () => { + expect( + authReducer( + { ...initialState, authenticated: true }, + { type: actionTypes.authenticating as any }, + ), + ).toEqual({ ...initialState, authenticating: true, authenticated: false }); + }); + + it(`resets authenticated, authenticating and sets error if type ${ + actionTypes.authenticationError + }`, () => { + expect( + authReducer( + { authenticating: true, authenticated: true }, + { type: actionTypes.authenticationError as any, errorMsg: errMessage }, + ), + ).toEqual({ ...initialState, authenticationError: errMessage }); + }); + }); +}); diff --git a/dashboard/src/reducers/auth.ts b/dashboard/src/reducers/auth.ts index ec1b51a1bed..02f6077e8d6 100644 --- a/dashboard/src/reducers/auth.ts +++ b/dashboard/src/reducers/auth.ts @@ -5,16 +5,28 @@ import { AuthAction } from "../actions/auth"; export interface IAuthState { authenticated: boolean; + authenticating: boolean; + authenticationError?: string; } const initialState: IAuthState = { authenticated: !(localStorage.getItem("kubeapps_auth_token") === null), + authenticating: false, }; const authReducer = (state: IAuthState = initialState, action: AuthAction): IAuthState => { switch (action.type) { case getType(actions.auth.setAuthenticated): - return { authenticated: action.authenticated }; + return { ...state, authenticated: action.authenticated, authenticating: false }; + case getType(actions.auth.authenticating): + return { ...state, authenticated: false, authenticating: true }; + case getType(actions.auth.authenticationError): + return { + ...state, + authenticated: false, + authenticating: false, + authenticationError: action.errorMsg, + }; default: } return state; diff --git a/dashboard/src/setupTests.ts b/dashboard/src/setupTests.ts index 84d5d9939f7..cec2e618e81 100644 --- a/dashboard/src/setupTests.ts +++ b/dashboard/src/setupTests.ts @@ -10,7 +10,7 @@ configure({ adapter: new Adapter() }); // Mock browser specific APIs like localstorage or Websocket const localStorageMock = { clear: jest.fn(), - getItem: jest.fn(), + getItem: jest.fn(() => null), setItem: jest.fn(), }; diff --git a/dashboard/tsconfig.prod.json b/dashboard/tsconfig.prod.json index 7a1d46b1666..fc8520e7376 100644 --- a/dashboard/tsconfig.prod.json +++ b/dashboard/tsconfig.prod.json @@ -1,13 +1,3 @@ { - "extends": "./tsconfig.json", - "exclude": [ - "node_modules", - "build", - "coverage", - "scripts", - "acceptance-tests", - "webpack", - "jest", - "src/setupTests.ts" - ] + "extends": "./tsconfig.json" } diff --git a/dashboard/yarn.lock b/dashboard/yarn.lock index 5c73ebefa48..2ed9173a67e 100644 --- a/dashboard/yarn.lock +++ b/dashboard/yarn.lock @@ -176,6 +176,12 @@ version "16.0.36" resolved "https://registry.yarnpkg.com/@types/react/-/react-16.0.36.tgz#ceb5639013bdb92a94147883052e69bb2c22c69b" +"@types/redux-mock-store@^1.0.0": + version "1.0.0" + resolved "https://registry.yarnpkg.com/@types/redux-mock-store/-/redux-mock-store-1.0.0.tgz#e06bad2b4ca004bdd371f432c3e48a92c1857ed9" + dependencies: + redux "^4.0.0" + "@types/ws@^6.0.0": version "6.0.0" resolved "https://registry.yarnpkg.com/@types/ws/-/ws-6.0.0.tgz#4787c4194c29cc6361208dcf4e580e208794e012" @@ -7165,6 +7171,12 @@ redux-devtools-extension@^2.13.5: version "2.13.5" resolved "https://registry.yarnpkg.com/redux-devtools-extension/-/redux-devtools-extension-2.13.5.tgz#3ff34f7227acfeef3964194f5f7fc2765e5c5a39" +redux-mock-store@^1.5.3: + version "1.5.3" + resolved "https://registry.yarnpkg.com/redux-mock-store/-/redux-mock-store-1.5.3.tgz#1f10528949b7ce8056c2532624f7cafa98576c6d" + dependencies: + lodash.isplainobject "^4.0.6" + redux-thunk@^2.2.0: version "2.2.0" resolved "https://registry.yarnpkg.com/redux-thunk/-/redux-thunk-2.2.0.tgz#e615a16e16b47a19a515766133d1e3e99b7852e5" @@ -7178,6 +7190,13 @@ redux@^3.6.0, redux@^3.7.2: loose-envify "^1.1.0" symbol-observable "^1.0.3" +redux@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/redux/-/redux-4.0.0.tgz#aa698a92b729315d22b34a0553d7e6533555cc03" + dependencies: + loose-envify "^1.1.0" + symbol-observable "^1.2.0" + regenerate@^1.2.1: version "1.3.3" resolved "https://registry.yarnpkg.com/regenerate/-/regenerate-1.3.3.tgz#0c336d3980553d755c39b586ae3b20aa49c82b7f" @@ -8155,7 +8174,7 @@ symbol-observable@^0.2.2: version "0.2.4" resolved "https://registry.yarnpkg.com/symbol-observable/-/symbol-observable-0.2.4.tgz#95a83db26186d6af7e7a18dbd9760a2f86d08f40" -symbol-observable@^1.0.3: +symbol-observable@^1.0.3, symbol-observable@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/symbol-observable/-/symbol-observable-1.2.0.tgz#c22688aed4eab3cdc2dfeacbb561660560a00804"