import expect from 'app/test/unexpected'; import sinon, { SinonStub } from 'sinon'; import AuthFlow from 'app/services/authFlow/AuthFlow'; import AbstractState from 'app/services/authFlow/AbstractState'; import localStorage from 'app/services/localStorage'; import OAuthState from 'app/services/authFlow/OAuthState'; import RegisterState from 'app/services/authFlow/RegisterState'; import RecoverPasswordState from 'app/services/authFlow/RecoverPasswordState'; import ForgotPasswordState from 'app/services/authFlow/ForgotPasswordState'; import ActivationState from 'app/services/authFlow/ActivationState'; import ResendActivationState from 'app/services/authFlow/ResendActivationState'; import LoginState from 'app/services/authFlow/LoginState'; import CompleteState from 'app/services/authFlow/CompleteState'; import ChooseAccountState from 'app/services/authFlow/ChooseAccountState'; import { Store } from 'redux'; describe('AuthFlow', () => { let flow: AuthFlow; let actions: { test: SinonStub }; beforeEach(() => { actions = { test: sinon.stub().named('actions.test'), }; actions.test.returns('passed'); // @ts-ignore flow = new AuthFlow(actions); }); it('should not allow to mutate actions', () => { expect( // @ts-ignore () => (flow.actions.foo = 'bar'), 'to throw', /readonly|not extensible/, ); // @ts-ignore expect(() => (flow.actions.test = 'hacked'), 'to throw', /read ?only/); }); describe('#setStore', () => { it('should create #navigate, #getState, #dispatch', () => { flow.setStore({ // @ts-ignore getState() {}, // @ts-ignore dispatch() {}, }); expect(flow.getState, 'to be defined'); expect(flow.dispatch, 'to be defined'); expect(flow.navigate, 'to be defined'); }); }); describe('#restoreOAuthState', () => { let oauthData: Record; beforeEach(() => { oauthData = { foo: 'bar' }; localStorage.setItem( 'oauthData', JSON.stringify({ timestamp: Date.now() - 10, payload: oauthData, }), ); sinon.stub(flow, 'run').named('flow.run'); const promiseLike = { then: (fn: Function) => fn() || promiseLike }; // @ts-ignore flow.run.returns(promiseLike); sinon.stub(flow, 'setState').named('flow.setState'); }); afterEach(() => { localStorage.removeItem('oauthData'); }); it('should call to restoreOAuthState', () => { // @ts-ignore sinon.stub(flow, 'restoreOAuthState').named('flow.restoreOAuthState'); flow.handleRequest( { path: '/', query: new URLSearchParams(''), params: {} }, () => {}, () => {}, ); // @ts-ignore expect(flow.restoreOAuthState, 'was called'); }); it('should restore oauth state from localStorage', () => { flow.handleRequest( { path: '/', query: new URLSearchParams(''), params: {} }, () => {}, () => {}, ); expect(flow.run, 'to have a call satisfying', ['oAuthValidate', oauthData]); }); it('should transition to CompleteState', () => { flow.handleRequest( { path: '/', query: new URLSearchParams(''), params: {} }, () => {}, () => {}, ); expect(flow.setState, 'to have a call satisfying', [expect.it('to be a', CompleteState)]); }); it('should not handle current request', () => { flow.handleRequest( { path: '/', query: new URLSearchParams(''), params: {} }, () => {}, () => {}, ); expect(flow.setState, 'was called once'); }); it('should call onReady after state restoration', () => { const onReady = sinon.stub().named('onReady'); flow.handleRequest( { path: '/login', query: new URLSearchParams(''), params: {} }, // @ts-ignore null, onReady, ); expect(onReady, 'was called'); }); it('should not restore oauth state for /register route', () => { flow.handleRequest( { path: '/register', query: new URLSearchParams(''), params: {} }, () => {}, () => {}, ); expect(flow.run, 'was not called'); // this.run('oAuthValidate'... }); it('should not restore outdated (>1h) oauth state', () => { localStorage.setItem( 'oauthData', JSON.stringify({ timestamp: Date.now() - 2 * 60 * 60 * 1000, payload: oauthData, }), ); flow.handleRequest( { path: '/', query: new URLSearchParams(''), params: {} }, () => {}, () => {}, ); expect(flow.run, 'was not called'); }); }); describe('#setState', () => { it('should change state', () => { const state = new AbstractState(); flow.setState(state); expect(flow.state, 'to be', state); }); it('should call #enter() on new state and pass reference to itself', () => { const state = new AbstractState(); const spy = sinon.spy(state, 'enter').named('state.enter'); flow.setState(state); expect(spy, 'was called once'); expect(spy, 'to have a call satisfying', [flow]); }); it('should call `leave` on previous state if any', () => { class State1 extends AbstractState {} class State2 extends AbstractState {} const state1 = new State1(); const state2 = new State2(); const spy1 = sinon.spy(state1, 'leave'); const spy2 = sinon.spy(state2, 'leave'); flow.setState(state1); flow.setState(state2); expect(spy1, 'was called once'); expect(spy1, 'to have a call satisfying', [flow]); expect(spy2, 'was not called'); }); it('should return promise, if #enter returns it', () => { const state = new AbstractState(); const expected = Promise.resolve(); state.enter = () => expected; const actual = flow.setState(state); expect(actual, 'to be', expected); }); it('should throw if no state', () => { // @ts-ignore expect(() => flow.setState(), 'to throw', 'State is required'); }); }); describe('#run', () => { let store: Store; beforeEach(() => { // @ts-ignore store = { getState() {}, dispatch: sinon.stub().named('store.dispatch'), }; flow.setStore(store); }); it('should dispatch an action', () => { // @ts-ignore flow.run('test'); expect(store.dispatch, 'was called once'); expect(store.dispatch, 'to have a call satisfying', ['passed']); }); it('should dispatch an action with payload given', () => { // @ts-ignore flow.run('test', 'arg'); expect(actions.test, 'was called once'); expect(actions.test, 'to have a call satisfying', ['arg']); }); it('should resolve to action dispatch result', () => { const expected = 'dispatch called'; // @ts-ignore store.dispatch.returns(expected); // @ts-ignore return expect(flow.run('test'), 'to be fulfilled with', expected); }); it('throws when running unexisted action', () => { // @ts-ignore expect(() => flow.run('123'), 'to throw', 'Action 123 does not exists'); }); }); describe('#goBack', () => { it('should call goBack on state passing itself as argument', () => { const state = new AbstractState(); sinon.stub(state, 'goBack').named('state.goBack'); flow.setState(state); flow.goBack(); expect(state.goBack, 'was called once'); expect(state.goBack, 'to have a call satisfying', [flow]); }); }); describe('#resolve', () => { it('should call resolve on state passing itself and payload as arguments', () => { const state = new AbstractState(); sinon.stub(state, 'resolve').named('state.resolve'); flow.setState(state); const expectedPayload = { foo: 'bar' }; flow.resolve(expectedPayload); expect(state.resolve, 'was called once'); expect(state.resolve, 'to have a call satisfying', [flow, expectedPayload]); }); }); describe('#reject', () => { it('should call reject on state passing itself and payload as arguments', () => { const state = new AbstractState(); sinon.stub(state, 'reject').named('state.reject'); flow.setState(state); const expectedPayload = { foo: 'bar' }; flow.reject(expectedPayload); expect(state.reject, 'was called once'); expect(state.reject, 'to have a call satisfying', [flow, expectedPayload]); }); }); describe('#handleRequest()', () => { beforeEach(() => { sinon.stub(flow, 'setState').named('flow.setState'); sinon.stub(flow, 'run').named('flow.run'); }); Object.entries({ '/': LoginState, '/login': LoginState, '/password': LoginState, '/accept-rules': LoginState, '/oauth/permissions': LoginState, '/oauth/choose-account': LoginState, '/oauth/finish': LoginState, '/oauth2/v1/foo': OAuthState, '/oauth2/v1': OAuthState, '/oauth2': OAuthState, '/register': RegisterState, '/choose-account': ChooseAccountState, '/recover-password': RecoverPasswordState, '/recover-password/key123': RecoverPasswordState, '/forgot-password': ForgotPasswordState, '/activation': ActivationState, '/resend-activation': ResendActivationState, }).forEach(([path, type]) => { it(`should transition to ${type.name} if ${path}`, () => { flow.handleRequest( { path, query: new URLSearchParams({}), params: {} }, () => {}, () => {}, ); expect(flow.setState, 'was called once'); expect(flow.setState, 'to have a call satisfying', [expect.it('to be a', type)]); }); }); it('should call callback', () => { const callback = sinon.stub().named('callback'); flow.handleRequest({ path: '/', query: new URLSearchParams({}), params: {} }, () => {}, callback); expect(callback, 'was called once'); }); it('should not call callback till returned from #enter() promise will be resolved', () => { let resolve: Function; const promise: Promise = { // @ts-ignore then: (cb: Function) => { resolve = cb; }, }; const callback = sinon.stub().named('callback'); const state = new AbstractState(); state.enter = () => promise; flow.setState = AuthFlow.prototype.setState.bind(flow, state); flow.handleRequest({ path: '/', query: new URLSearchParams({}), params: {} }, () => {}, callback); // @ts-ignore expect(resolve, 'to be', callback); expect(callback, 'was not called'); // @ts-ignore resolve(); expect(callback, 'was called once'); }); it('should not handle the same request twice', () => { const path = '/oauth2'; const callback = sinon.stub(); flow.handleRequest({ path, query: new URLSearchParams({}), params: {} }, () => {}, callback); flow.handleRequest({ path, query: new URLSearchParams({}), params: {} }, () => {}, callback); expect(flow.setState, 'was called once'); expect(flow.setState, 'to have a call satisfying', [expect.it('to be a', OAuthState)]); expect(callback, 'was called twice'); }); it('throws if unsupported request', () => { const replace = sinon.stub().named('replace'); flow.handleRequest({ path: '/foo/bar', query: new URLSearchParams({}), params: {} }, replace); expect(replace, 'to have a call satisfying', ['/404']); }); }); describe('#getRequest()', () => { beforeEach(() => { sinon.stub(flow, 'setState').named('flow.setState'); sinon.stub(flow, 'run').named('flow.run'); }); it('should return request with path, query, params', () => { const request = { path: '/', query: new URLSearchParams({}), params: {} }; flow.handleRequest( request, () => {}, () => {}, ); expect(flow.getRequest(), 'to satisfy', { ...request, query: expect.it('to be an', URLSearchParams), params: {}, }); }); it('should return a copy of current request', () => { const request = { path: '/', query: new URLSearchParams({ foo: 'bar' }), params: { baz: 'bud' }, }; flow.handleRequest( request, () => {}, () => {}, ); expect(flow.getRequest(), 'to equal', request); expect(flow.getRequest(), 'not to be', request); }); }); });