Home Reference Source Test Repository

unit/controller/authorization.js

import chai from 'chai';
import { ACCESS_TOKEN_RESPONSE, DEVICE_CODE_RESPONSE, DEVICE_CODE_POLL_RESPONSE, DEVICE_CODE_DENIED_RESPONSE, DEVICE_CODE_EXPIRED_RESPONSE } from '../../test-data';
import mockery from 'mockery';
import fetchMock from 'fetch-mock';
import Authorization from './../../../src/controller/authorization';
import NprOne from './../../../src/index';
import { testConfig } from '../../test';

const should = chai.should();


/** @test {Authorization} */
describe('Authorization', () => {
    let authorization;
    let refreshTokenUrl;
    let logoutUrl;
    let newDeviceCodeUrl;
    let deviceCodePollUrl;


    beforeEach(() => {
        authorization = new Authorization();
        NprOne.config = testConfig;

        refreshTokenUrl = `^${testConfig.authProxyBaseUrl}${NprOne.config.refreshTokenPath}`;
        logoutUrl = `^${testConfig.authProxyBaseUrl}${NprOne.config.logoutPath}`;
        newDeviceCodeUrl = `${NprOne.config.authProxyBaseUrl}${NprOne.config.newDeviceCodePath}`;
        deviceCodePollUrl = `${NprOne.config.authProxyBaseUrl}${NprOne.config.pollDeviceCodePath}`;
    });

    afterEach(() => {
        fetchMock.restore();
        mockery.deregisterMock('fetch');
    });


    /** @test {Authorization.refreshExistingAccessToken} */
    describe('refreshExistingAccessToken', function () { // intentionally can't use arrow function here
        // see: https://github.com/mochajs/mocha/issues/1763
        this.timeout(16000);

        // @TODO this should not be necessary - quick-fix to deal with implicit dependency on Listening controller - see #8
        const adsWizzWwwUrl = 'https://adswizz.com';
        const adsWizzCdnUrl = '^https://delivery-s3.adswizz.com';

        it('should call the refresh token endpoint in the auth proxy', (done) => {
            mockery.registerMock('fetch', fetchMock
                .mock(refreshTokenUrl, 'POST', ACCESS_TOKEN_RESPONSE)
                .mock(adsWizzWwwUrl, 'GET', 200) // @TODO remove as part of fix for #8
                .mock(adsWizzCdnUrl, 'GET', 200) // @TODO remove as part of fix for #8
                .getMock());

            Authorization.refreshExistingAccessToken()
                .then(() => {
                    fetchMock.called(refreshTokenUrl).should.be.true;
                    fetchMock.calls().unmatched.length.should.equal(0);
                    NprOne.accessToken.should.equal(ACCESS_TOKEN_RESPONSE.access_token);
                    done();
                })
                .catch(done);
        });

        it('should retry the call to the refresh token endpoint in the auth proxy if it receives a bad response the first time', (done) => {
            let numTries = 0;

            const responses = () => {
                numTries += 1;

                if (numTries === 1) {
                    return 500;
                } else {
                    return ACCESS_TOKEN_RESPONSE;
                }
            };

            mockery.registerMock('fetch', fetchMock
                .mock(refreshTokenUrl, 'POST', responses)
                .mock(adsWizzWwwUrl, 'GET', 200) // @TODO remove as part of fix for #8
                .mock(adsWizzCdnUrl, 'GET', 200) // @TODO remove as part of fix for #8
                .getMock());

            Authorization.refreshExistingAccessToken()
                .then(() => {
                    fetchMock.called(refreshTokenUrl).should.be.true;
                    fetchMock.calls().matched.length.should.be.greaterThan(1);
                    fetchMock.calls().unmatched.length.should.equal(0);
                    NprOne.accessToken.should.equal(ACCESS_TOKEN_RESPONSE.access_token);
                    done();
                })
                .catch(done);
        });

        it('should retry the call to the refresh token endpoint in the auth proxy up to 3 times, but then error out', (done) => {
            let numTries = 0;

            const responses = () => {
                numTries += 1;

                if (numTries < 5) {
                    return 500;
                } else {
                    return ACCESS_TOKEN_RESPONSE;
                }
            };

            mockery.registerMock('fetch', fetchMock
                .mock(refreshTokenUrl, 'POST', responses)
                .mock(adsWizzWwwUrl, 'GET', 200) // @TODO remove as part of fix for #8
                .mock(adsWizzCdnUrl, 'GET', 200) // @TODO remove as part of fix for #8
                .getMock());

            Authorization.refreshExistingAccessToken()
                .then(() => {
                    done('Should not be here, call should have ended on a throw');
                })
                .catch(() => {
                    fetchMock.called(refreshTokenUrl).should.be.true;
                    fetchMock.calls().matched.length.should.be.greaterThan(1);
                    fetchMock.calls().unmatched.length.should.equal(0);
                    done();
                });
        });

        describe('if no auth proxy URL is set', () => {
            beforeEach(() => {
                NprOne.config = { authProxyBaseUrl: '' };
            });

            it('should throw a TypeError', () => {
                chai.expect(() => {
                    Authorization.refreshExistingAccessToken();
                }).to.throw('OAuth proxy not configured. Unable to refresh the access token.');
            });
        });

        describe('if no access token is set', () => {
            beforeEach(() => {
                NprOne.config = { accessToken: '' };
            });

            it('should throw a TypeError', () => {
                chai.expect(() => {
                    Authorization.refreshExistingAccessToken();
                }).to.throw('An access token must be set in order to attempt a refresh.');
            });
        });
    });


    /** @test {Authorization#logout} */
    describe('logout', () => {
        it('should call the logout endpoint in the auth proxy', (done) => {
            const oldAccessToken = NprOne.accessToken;

            mockery.registerMock('fetch', fetchMock
                .mock(logoutUrl, 'POST', JSON.stringify(''))
                .getMock());

            authorization.logout()
                .then(() => {
                    fetchMock.called(logoutUrl).should.be.true;
                    fetchMock.calls().unmatched.length.should.equal(0);
                    const options = fetchMock.lastOptions(logoutUrl);
                    options.body.should.equal(`token=${oldAccessToken}`);
                    NprOne.accessToken.should.equal('');
                    done();
                })
                .catch(done);
        });

        it('should throw an error if the response is not \'ok\'', (done) => {
            mockery.registerMock('fetch', fetchMock
                .mock(logoutUrl, 'POST', 400)
                .getMock());

            authorization.logout().should.be.rejected.notify(() => {
                fetchMock.called(logoutUrl).should.be.true;
                fetchMock.calls().unmatched.length.should.equal(0);
                done();
            });
        });

        describe('if no access token is set', () => {
            beforeEach(() => {
                NprOne.config = { accessToken: '' };
            });

            it('should throw a TypeError', () => {
                chai.expect(() => {
                    authorization.logout();
                }).to.throw('An access token must be set in order to attempt a logout.');
            });
        });

        describe('if no auth proxy URL is set', () => {
            beforeEach(() => {
                NprOne.config = { authProxyBaseUrl: '' };
            });

            it('should throw a TypeError', () => {
                chai.expect(() => {
                    authorization.logout();
                }).to.throw('OAuth proxy not configured. Unable to securely log out the user.');
            });
        });
    });


    /** @test {Authorization#getDeviceCode} */
    describe('getDeviceCode', () => {
        it('should throw an error if not configured properly', () => {
            NprOne.config = { authProxyBaseUrl: '' };
            chai.expect(() => {
                authorization.getDeviceCode();
            }).to.throw('OAuth proxy not configured. Unable to use the device code.');
        });

        it('should make a request to authProxyUrl when called', (done) => {
            mockery.registerMock('fetch', fetchMock
                .mock(newDeviceCodeUrl, 'POST', DEVICE_CODE_RESPONSE)
                .getMock());

            authorization.getDeviceCode()
                .then((deviceCode) => {
                    fetchMock.called(newDeviceCodeUrl).should.be.true;
                    fetchMock.calls().unmatched.length.should.equal(0);
                    deviceCode.userCode.should.equal(DEVICE_CODE_RESPONSE.user_code);
                    done();
                })
                .catch(done);
        });

        it('should make a request to authProxyUrl with additional scopes when scopes are passed in', (done) => {
            mockery.registerMock('fetch', fetchMock
                .mock(newDeviceCodeUrl, 'POST', DEVICE_CODE_RESPONSE)
                .getMock());

            authorization.getDeviceCode(['listening.readonly', 'identity.readonly'])
                .then(() => {
                    fetchMock.called(newDeviceCodeUrl).should.be.true;
                    const options = fetchMock.lastOptions(newDeviceCodeUrl);
                    options.body.indexOf('listening.readonly').should.not.equal(-1);
                    options.body.indexOf('identity.readonly').should.not.equal(-1);
                    done();
                })
                .catch(done);
        });
    });


    /** @test {Authorization#pollDeviceCode} */
    describe('pollDeviceCode', function () { // intentionally can't use arrow function here
        // see: https://github.com/mochajs/mocha/issues/1763
        this.timeout(16000);

        it('should throw an error if not configured properly', () => {
            NprOne.config = { authProxyBaseUrl: '' };
            chai.expect(() => {
                authorization.pollDeviceCode();
            }).to.throw('OAuth proxy not configured. Unable to use the device code.');
        });

        it('should throw an error if no active device code exists', () => {
            chai.expect(() => {
                authorization.pollDeviceCode();
            }).to.throw('No active device code set. Please call getDeviceCode() before calling this function.');
        });

        it('should return a Promise that rejects if the active device code is expired', (done) => {
            const deviceCodeClone = JSON.parse(JSON.stringify(DEVICE_CODE_RESPONSE));
            deviceCodeClone.expires_in = 0;

            mockery.registerMock('fetch', fetchMock
                .mock(newDeviceCodeUrl, 'POST', deviceCodeClone)
                .getMock());

            authorization.getDeviceCode()
                .then(() => {
                    authorization.pollDeviceCode().should.be.rejectedWith('The device code has expired. Please generate a new one before continuing.').notify(done);
                });
        });

        it('should return a Promise that resolves to a valid access token if device code is valid', (done) => {
            mockery.registerMock('fetch', fetchMock
                .mock(newDeviceCodeUrl, 'POST', DEVICE_CODE_RESPONSE)
                .mock(deviceCodePollUrl, 'POST', ACCESS_TOKEN_RESPONSE)
                .getMock());

            authorization.getDeviceCode()
                .then(() => {
                    return authorization.pollDeviceCode();
                })
                .then((accessToken) => {
                    NprOne.accessToken.should.equal(ACCESS_TOKEN_RESPONSE.access_token);
                    accessToken.toString().should.equal(ACCESS_TOKEN_RESPONSE.access_token);
                })
                .then(done)
                .catch(done);
        });

        it('should return a Promise that rejects if the active device code is invalid (or possibly expired)', (done) => {
            mockery.registerMock('fetch', fetchMock
                .mock(newDeviceCodeUrl, 'POST', DEVICE_CODE_RESPONSE)
                .mock(deviceCodePollUrl, 'POST', {
                    status: 400,
                    body: DEVICE_CODE_EXPIRED_RESPONSE,
                })
                .getMock());

            authorization.getDeviceCode()
                .then(() => {
                    authorization.pollDeviceCode().should.be.rejectedWith('Response status: 400 Bad Request').notify(done);
                });
        });

        it('should return a Promise that rejects if the active device code is valid but the user went to log in and denied the app access', (done) => {
            mockery.registerMock('fetch', fetchMock
                .mock(newDeviceCodeUrl, 'POST', DEVICE_CODE_RESPONSE)
                .mock(deviceCodePollUrl, 'POST', {
                    status: 401,
                    body: DEVICE_CODE_DENIED_RESPONSE,
                })
                .getMock());

            authorization.getDeviceCode()
                .then(() => {
                    authorization.pollDeviceCode().should.be.rejectedWith('Response status: 401 Unauthorized').notify(done);
                });
        });

        it('should return a Promise that resolves to valid access token after polling a number of times if the user logs in', (done) => {
            let numTries = 0;

            const responses = () => {
                numTries += 1;

                if (numTries < 3) {
                    return {
                        status: 401,
                        body: DEVICE_CODE_POLL_RESPONSE,
                    };
                } else {
                    return ACCESS_TOKEN_RESPONSE; // a.k.a. the user logged in
                }
            };

            mockery.registerMock('fetch', fetchMock
                .mock(newDeviceCodeUrl, 'POST', DEVICE_CODE_RESPONSE)
                .mock(deviceCodePollUrl, 'POST', responses)
                .getMock());

            authorization.getDeviceCode()
                .then(() => {
                    return authorization.pollDeviceCode();
                })
                .then((accessToken) => {
                    fetchMock.called(deviceCodePollUrl).should.be.true;
                    fetchMock.calls().matched.length.should.be.greaterThan(1);
                    fetchMock.calls().unmatched.length.should.equal(0);
                    NprOne.accessToken.should.equal(ACCESS_TOKEN_RESPONSE.access_token);
                    accessToken.toString().should.equal(ACCESS_TOKEN_RESPONSE.access_token);
                })
                .then(done)
                .catch(done);
        });

        it('should return a Promise that rejects if the returned access token is invalid', (done) => {
            const accessTokenClone = JSON.parse(JSON.stringify(ACCESS_TOKEN_RESPONSE));
            delete accessTokenClone.expires_in;

            mockery.registerMock('fetch', fetchMock
                .mock(newDeviceCodeUrl, 'POST', DEVICE_CODE_RESPONSE)
                .mock(deviceCodePollUrl, 'POST', accessTokenClone)
                .getMock());

            authorization.getDeviceCode()
                .then(() => {
                    authorization.pollDeviceCode().should.be.rejectedWith(`TypeError: 'expires_in' is missing and is required. :${JSON.stringify(accessTokenClone)}`).notify(done);
                });
        });
    });
});