Convert gr-auth to class and use new API for auth check

auth-check API is available since: https://gerrit-review.googlesource.com/c/gerrit/+/185990

Change-Id: Icd5e0183ee42e746c32bfd7929af9796ab752627
This commit is contained in:
Tao Zhou
2020-01-09 13:59:45 +01:00
parent a72096e146
commit 4761e3f3d0
10 changed files with 758 additions and 564 deletions

View File

@@ -23,6 +23,9 @@ limitations under the License.
<link rel="import" href="../../shared/gr-alert/gr-alert.html"> <link rel="import" href="../../shared/gr-alert/gr-alert.html">
<link rel="import" href="../../shared/gr-overlay/gr-overlay.html"> <link rel="import" href="../../shared/gr-overlay/gr-overlay.html">
<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html"> <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
<!-- Import to get Gerrit interface -->
<!-- TODO(taoalpha): decouple gr-gerrit from gr-js-api-interface -->
<link rel="import" href="../../shared/gr-js-api-interface/gr-js-api-interface.html">
<dom-module id="gr-error-manager"> <dom-module id="gr-error-manager">
<template> <template>
@@ -33,6 +36,13 @@ limitations under the License.
confirm-label="Dismiss" confirm-label="Dismiss"
confirm-on-enter></gr-error-dialog> confirm-on-enter></gr-error-dialog>
</gr-overlay> </gr-overlay>
<gr-overlay
id="noInteractionOverlay"
with-backdrop
always-on-top
no-cancel-on-esc-key
no-cancel-on-outside-click>
</gr-overlay>
<gr-rest-api-interface id="restAPI"></gr-rest-api-interface> <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
<gr-reporting id="reporting"></gr-reporting> <gr-reporting id="reporting"></gr-reporting>
</template> </template>

View File

@@ -64,15 +64,29 @@
}; };
} }
constructor() {
super();
/** @type {!Gerrit.Auth} */
this._authService = Gerrit.Auth;
/** @type {?Function} */
this._authErrorHandlerDeregistrationHook;
}
attached() { attached() {
super.attached(); super.attached();
this.listen(document, 'server-error', '_handleServerError'); this.listen(document, 'server-error', '_handleServerError');
this.listen(document, 'network-error', '_handleNetworkError'); this.listen(document, 'network-error', '_handleNetworkError');
this.listen(document, 'auth-error', '_handleAuthError');
this.listen(document, 'show-alert', '_handleShowAlert'); this.listen(document, 'show-alert', '_handleShowAlert');
this.listen(document, 'show-error', '_handleShowErrorDialog'); this.listen(document, 'show-error', '_handleShowErrorDialog');
this.listen(document, 'visibilitychange', '_handleVisibilityChange'); this.listen(document, 'visibilitychange', '_handleVisibilityChange');
this.listen(document, 'show-auth-required', '_handleAuthRequired'); this.listen(document, 'show-auth-required', '_handleAuthRequired');
this._authErrorHandlerDeregistrationHook = Gerrit.on('auth-error',
event => {
this._handleAuthError(event.message, event.action);
});
} }
detached() { detached() {
@@ -80,10 +94,11 @@
this._clearHideAlertHandle(); this._clearHideAlertHandle();
this.unlisten(document, 'server-error', '_handleServerError'); this.unlisten(document, 'server-error', '_handleServerError');
this.unlisten(document, 'network-error', '_handleNetworkError'); this.unlisten(document, 'network-error', '_handleNetworkError');
this.unlisten(document, 'auth-error', '_handleAuthError');
this.unlisten(document, 'show-auth-required', '_handleAuthRequired'); this.unlisten(document, 'show-auth-required', '_handleAuthRequired');
this.unlisten(document, 'visibilitychange', '_handleVisibilityChange'); this.unlisten(document, 'visibilitychange', '_handleVisibilityChange');
this.unlisten(document, 'show-error', '_handleShowErrorDialog'); this.unlisten(document, 'show-error', '_handleShowErrorDialog');
this._authErrorHandlerDeregistrationHook();
} }
_shouldSuppressError(msg) { _shouldSuppressError(msg) {
@@ -95,32 +110,41 @@
'Log in is required to perform that action.', 'Log in.'); 'Log in is required to perform that action.', 'Log in.');
} }
_handleAuthError() { _handleAuthError(msg, action) {
this._showAuthErrorAlert('Auth error', 'Refresh credentials.'); this.$.noInteractionOverlay.open().then(() => {
this._showAuthErrorAlert(msg, action);
});
} }
_handleServerError(e) { _handleServerError(e) {
const {request, response} = e.detail; const {request, response} = e.detail;
Promise.all([response.text(), this._getLoggedIn()]) response.text().then(errorText => {
.then(([errorText, loggedIn]) => { const url = request && (request.anonymizedUrl || request.url);
const url = request && (request.anonymizedUrl || request.url); const {status, statusText} = response;
const {status, statusText} = response; if (response.status === 403
if (response.status === 403 && && !this._authService.isAuthed
loggedIn && && errorText === AUTHENTICATION_REQUIRED) {
errorText === AUTHENTICATION_REQUIRED) { // if not authed previously, this is trying to access auth required APIs
// The app was logged at one point and is now getting auth errors. // show auth required alert
// This indicates the auth token is no longer valid. this._handleAuthRequired();
this._handleAuthError(); } else if (response.status === 403
} else if (!this._shouldSuppressError(errorText)) { && this._authService.isAuthed
this._showErrorDialog(this._constructServerErrorMsg({ && errorText === AUTHENTICATION_REQUIRED) {
status, // The app was logged at one point and is now getting auth errors.
statusText, // This indicates the auth token may no longer valid.
errorText, // Re-check on auth
url, this._authService.clearCache();
})); this.$.restAPI.getLoggedIn();
} } else if (!this._shouldSuppressError(errorText)) {
console.error(errorText); this._showErrorDialog(this._constructServerErrorMsg({
}); status,
statusText,
errorText,
url,
}));
}
console.log(`server error: ${errorText}`);
});
} }
_constructServerErrorMsg({errorText, status, statusText, url}) { _constructServerErrorMsg({errorText, status, statusText, url}) {
@@ -142,10 +166,6 @@
console.error(e.detail.error.message); console.error(e.detail.error.message);
} }
_getLoggedIn() {
return this.$.restAPI.getLoggedIn();
}
/** /**
* @param {string} text * @param {string} text
* @param {?string=} opt_actionText * @param {?string=} opt_actionText
@@ -222,7 +242,11 @@
this.knownAccountId !== undefined && this.knownAccountId !== undefined &&
timeSinceLastCheck > STALE_CREDENTIAL_THRESHOLD_MS) { timeSinceLastCheck > STALE_CREDENTIAL_THRESHOLD_MS) {
this._lastCredentialCheck = Date.now(); this._lastCredentialCheck = Date.now();
this.$.restAPI.checkCredentials();
// check auth status in case:
// - user signed out
// - user switched account
this._checkSignedIn();
} }
} }
@@ -232,22 +256,36 @@
} }
_checkSignedIn() { _checkSignedIn() {
this.$.restAPI.checkCredentials().then(account => { this._lastCredentialCheck = Date.now();
const isLoggedIn = !!account;
this._lastCredentialCheck = Date.now();
if (this._refreshingCredentials) {
if (isLoggedIn) {
// If the credentials were refreshed but the account is different
// then reload the page completely.
if (account._account_id !== this.knownAccountId) {
this._reloadPage();
return;
}
this._handleCredentialRefreshed(); // force to refetch account info
} else { this.$.restAPI.invalidateAccountsCache();
this._requestCheckLoggedIn(); this._authService.clearCache();
}
this.$.restAPI.getLoggedIn().then(isLoggedIn => {
// do nothing if its refreshing
if (!this._refreshingCredentials) return;
if (!isLoggedIn) {
// check later
// 1. guest mode
// 2. or signed out
// in case #2, auth-error is taken care of separately
this._requestCheckLoggedIn();
} else {
// check account
this.$.restAPI.getAccount().then(account => {
if (this._refreshingCredentials) {
// If the credentials were refreshed but the account is different
// then reload the page completely.
if (account._account_id !== this.knownAccountId) {
this._reloadPage();
return;
}
this._handleCredentialRefreshed();
}
});
} }
}); });
} }
@@ -277,6 +315,10 @@
this._refreshingCredentials = false; this._refreshingCredentials = false;
this._hideAlert(); this._hideAlert();
this._showAlert('Credentials refreshed.'); this._showAlert('Credentials refreshed.');
this.$.noInteractionOverlay.close();
// Clear the cache for auth
this._authService.clearCache();
} }
_handleWindowFocus() { _handleWindowFocus() {
@@ -299,4 +341,4 @@
} }
customElements.define(GrErrorManager.is, GrErrorManager); customElements.define(GrErrorManager.is, GrErrorManager);
})(); })();

View File

@@ -23,10 +23,10 @@ limitations under the License.
<script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script> <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
<script src="/bower_components/web-component-tester/browser.js"></script> <script src="/bower_components/web-component-tester/browser.js"></script>
<link rel="import" href="../../../test/common-test-setup.html"/> <link rel="import" href="../../../test/common-test-setup.html" />
<link rel="import" href="gr-error-manager.html"> <link rel="import" href="gr-error-manager.html">
<script>void(0);</script> <script>void (0);</script>
<test-fixture id="basic"> <test-fixture id="basic">
<template> <template>
@@ -41,285 +41,324 @@ limitations under the License.
setup(() => { setup(() => {
sandbox = sinon.sandbox.create(); sandbox = sinon.sandbox.create();
stub('gr-rest-api-interface', {
getLoggedIn() { return Promise.resolve(true); },
});
element = fixture('basic');
}); });
teardown(() => { teardown(() => {
sandbox.restore(); sandbox.restore();
}); });
test('does not show auth error on 403 by default', done => { suite('when authed', () => {
const showAuthErrorStub = sandbox.stub(element, '_showAuthErrorAlert'); setup(() => {
const responseText = Promise.resolve('server says no.'); sandbox.stub(window, 'fetch')
element.fire('server-error', .returns(Promise.resolve({ok: true, status: 204}));
{response: {status: 403, text() { return responseText; }}} element = fixture('basic');
);
Promise.all([
element.$.restAPI.getLoggedIn.lastCall.returnValue,
responseText,
]).then(() => {
assert.isFalse(showAuthErrorStub.calledOnce);
done();
}); });
});
test('shows auth error on 403 and Authentication required', done => { test('does not show auth error on 403 by default', done => {
const showAuthErrorStub = sandbox.stub(element, '_showAuthErrorAlert'); const showAuthErrorStub = sandbox.stub(element, '_showAuthErrorAlert');
const responseText = Promise.resolve('Authentication required\n'); const responseText = Promise.resolve('server says no.');
element.fire('server-error', element.fire('server-error',
{response: {status: 403, text() { return responseText; }}} {response: {status: 403, text() { return responseText; }}}
); );
Promise.all([
element.$.restAPI.getLoggedIn.lastCall.returnValue,
responseText,
]).then(() => {
assert.isTrue(showAuthErrorStub.calledOnce);
done();
});
});
test('show logged in error', () => {
sandbox.stub(element, '_showAuthErrorAlert');
element.fire('show-auth-required');
assert.isTrue(element._showAuthErrorAlert.calledWithExactly(
'Log in is required to perform that action.', 'Log in.'));
});
test('show normal Error', done => {
const showErrorStub = sandbox.stub(element, '_showErrorDialog');
const textSpy = sandbox.spy(() => { return Promise.resolve('ZOMG'); });
element.fire('server-error', {response: {status: 500, text: textSpy}});
assert.isTrue(textSpy.called);
Promise.all([
element.$.restAPI.getLoggedIn.lastCall.returnValue,
textSpy.lastCall.returnValue,
]).then(() => {
assert.isTrue(showErrorStub.calledOnce);
assert.isTrue(showErrorStub.lastCall.calledWithExactly(
'Error 500: ZOMG'));
done();
});
});
test('_constructServerErrorMsg', () => {
const errorText = 'change conflicts';
const status = 409;
const statusText = 'Conflict';
const url = '/my/test/url';
assert.equal(element._constructServerErrorMsg({status}),
'Error 409');
assert.equal(element._constructServerErrorMsg({status, url}),
'Error 409: \nEndpoint: /my/test/url');
assert.equal(element._constructServerErrorMsg({status, statusText, url}),
'Error 409 (Conflict): \nEndpoint: /my/test/url');
assert.equal(element._constructServerErrorMsg({
status,
statusText,
errorText,
url,
}), 'Error 409 (Conflict): change conflicts' +
'\nEndpoint: /my/test/url');
});
test('suppress TOO_MANY_FILES error', done => {
const showAlertStub = sandbox.stub(element, '_showAlert');
const textSpy = sandbox.spy(() => {
return Promise.resolve('too many files to find conflicts');
});
element.fire('server-error', {response: {status: 500, text: textSpy}});
assert.isTrue(textSpy.called);
Promise.all([
element.$.restAPI.getLoggedIn.lastCall.returnValue,
textSpy.lastCall.returnValue,
]).then(() => {
assert.isFalse(showAlertStub.called);
done();
});
});
test('show network error', done => {
const consoleErrorStub = sandbox.stub(console, 'error');
const showAlertStub = sandbox.stub(element, '_showAlert');
element.fire('network-error', {error: new Error('ZOMG')});
flush(() => {
assert.isTrue(showAlertStub.calledOnce);
assert.isTrue(showAlertStub.lastCall.calledWithExactly(
'Server unavailable'));
assert.isTrue(consoleErrorStub.calledOnce);
assert.isTrue(consoleErrorStub.lastCall.calledWithExactly('ZOMG'));
done();
});
});
test('show auth refresh toast', done => {
const refreshStub = sandbox.stub(element.$.restAPI, 'checkCredentials',
() => { return Promise.resolve(true); });
const toastSpy = sandbox.spy(element, '_createToastAlert');
const windowOpen = sandbox.stub(window, 'open');
const responseText = Promise.resolve('Authentication required\n');
element.fire('server-error',
{response: {status: 403, text() { return responseText; }}}
);
Promise.all([
element.$.restAPI.getLoggedIn.lastCall.returnValue,
responseText,
]).then(() => {
assert.isTrue(toastSpy.called);
let toast = toastSpy.lastCall.returnValue;
assert.isOk(toast);
assert.include(
Polymer.dom(toast.root).textContent, 'Auth error');
assert.include(
Polymer.dom(toast.root).textContent, 'Refresh credentials.');
assert.isFalse(windowOpen.called);
MockInteractions.tap(toast.$$('gr-button.action'));
assert.isTrue(windowOpen.called);
// @see Issue 5822: noopener breaks closeAfterLogin
assert.equal(windowOpen.lastCall.args[2].indexOf('noopener=yes'),
-1);
const hideToastSpy = sandbox.spy(toast, 'hide');
element._handleWindowFocus();
assert.isTrue(refreshStub.called);
element.flushDebouncer('checkLoggedIn');
flush(() => { flush(() => {
assert.isTrue(refreshStub.called); assert.isFalse(showAuthErrorStub.calledOnce);
assert.isTrue(hideToastSpy.called); done();
});
});
assert.notStrictEqual(toastSpy.lastCall.returnValue, toast); test('show auth required for 403 with auth error and not authed before',
toast = toastSpy.lastCall.returnValue; done => {
assert.isOk(toast); const showAuthErrorStub = sandbox.stub(
assert.include( element, '_showAuthErrorAlert'
Polymer.dom(toast.root).textContent, 'Credentials refreshed'); );
const responseText = Promise.resolve('Authentication required\n');
sinon.stub(element.$.restAPI, 'getLoggedIn')
.returns(Promise.resolve(true));
element.fire('server-error',
{response: {status: 403, text() { return responseText; }}}
);
flush(() => {
assert.isTrue(showAuthErrorStub.calledOnce);
done();
});
});
test('recheck auth for 403 with auth error if authed before', done => {
// starts with authed state
element.$.restAPI.getLoggedIn();
const responseText = Promise.resolve('Authentication required\n');
sinon.stub(element.$.restAPI, 'getLoggedIn')
.returns(Promise.resolve(true));
element.fire('server-error',
{response: {status: 403, text() { return responseText; }}}
);
flush(() => {
assert.isTrue(element.$.restAPI.getLoggedIn.calledOnce);
done();
});
});
test('show logged in error', () => {
sandbox.stub(element, '_showAuthErrorAlert');
element.fire('show-auth-required');
assert.isTrue(element._showAuthErrorAlert.calledWithExactly(
'Log in is required to perform that action.', 'Log in.'));
});
test('show normal Error', done => {
const showErrorStub = sandbox.stub(element, '_showErrorDialog');
const textSpy = sandbox.spy(() => { return Promise.resolve('ZOMG'); });
element.fire('server-error', {response: {status: 500, text: textSpy}});
assert.isTrue(textSpy.called);
flush(() => {
assert.isTrue(showErrorStub.calledOnce);
assert.isTrue(showErrorStub.lastCall.calledWithExactly(
'Error 500: ZOMG'));
done();
});
});
test('_constructServerErrorMsg', () => {
const errorText = 'change conflicts';
const status = 409;
const statusText = 'Conflict';
const url = '/my/test/url';
assert.equal(element._constructServerErrorMsg({status}),
'Error 409');
assert.equal(element._constructServerErrorMsg({status, url}),
'Error 409: \nEndpoint: /my/test/url');
assert.equal(element._constructServerErrorMsg({status, statusText, url}),
'Error 409 (Conflict): \nEndpoint: /my/test/url');
assert.equal(element._constructServerErrorMsg({
status,
statusText,
errorText,
url,
}), 'Error 409 (Conflict): change conflicts' +
'\nEndpoint: /my/test/url');
});
test('suppress TOO_MANY_FILES error', done => {
const showAlertStub = sandbox.stub(element, '_showAlert');
const textSpy = sandbox.spy(() => {
return Promise.resolve('too many files to find conflicts');
});
element.fire('server-error', {response: {status: 500, text: textSpy}});
assert.isTrue(textSpy.called);
flush(() => {
assert.isFalse(showAlertStub.called);
done();
});
});
test('show network error', done => {
const consoleErrorStub = sandbox.stub(console, 'error');
const showAlertStub = sandbox.stub(element, '_showAlert');
element.fire('network-error', {error: new Error('ZOMG')});
flush(() => {
assert.isTrue(showAlertStub.calledOnce);
assert.isTrue(showAlertStub.lastCall.calledWithExactly(
'Server unavailable'));
assert.isTrue(consoleErrorStub.calledOnce);
assert.isTrue(consoleErrorStub.lastCall.calledWithExactly('ZOMG'));
done();
});
});
test('show auth refresh toast', done => {
// starts with authed state
element.$.restAPI.getLoggedIn();
const refreshStub = sandbox.stub(element.$.restAPI, 'getAccount',
() => { return Promise.resolve({}); });
const toastSpy = sandbox.spy(element, '_createToastAlert');
const windowOpen = sandbox.stub(window, 'open');
const responseText = Promise.resolve('Authentication required\n');
// fake failed auth
window.fetch.returns(Promise.resolve({status: 403}));
element.fire('server-error',
{response: {status: 403, text() { return responseText; }}}
);
assert.equal(window.fetch.callCount, 1);
flush(() => {
// auth check again
assert.equal(window.fetch.callCount, 2);
flush(() => {
// auth-error fired
assert.isTrue(toastSpy.called);
// toast
let toast = toastSpy.lastCall.returnValue;
assert.isOk(toast);
assert.include(
Polymer.dom(toast.root).textContent, 'Credentails expired.');
assert.include(
Polymer.dom(toast.root).textContent, 'Refresh credentials');
// noInteractionOverlay
const noInteractionOverlay = element.$.noInteractionOverlay;
assert.isOk(noInteractionOverlay);
sinon.spy(noInteractionOverlay, 'close');
assert.equal(
noInteractionOverlay.backdropElement.getAttribute('opened'),
'');
assert.isFalse(windowOpen.called);
MockInteractions.tap(toast.$$('gr-button.action'));
assert.isTrue(windowOpen.called);
// @see Issue 5822: noopener breaks closeAfterLogin
assert.equal(windowOpen.lastCall.args[2].indexOf('noopener=yes'),
-1);
const hideToastSpy = sandbox.spy(toast, 'hide');
// now fake authed
window.fetch.returns(Promise.resolve({status: 204}));
element._handleWindowFocus();
element.flushDebouncer('checkLoggedIn');
flush(() => {
assert.isTrue(refreshStub.called);
assert.isTrue(hideToastSpy.called);
// toast update
assert.notStrictEqual(toastSpy.lastCall.returnValue, toast);
toast = toastSpy.lastCall.returnValue;
assert.isOk(toast);
assert.include(
Polymer.dom(toast.root).textContent, 'Credentials refreshed');
// close overlay
assert.isTrue(noInteractionOverlay.close.called);
done();
});
});
});
});
test('show alert', () => {
const alertObj = {message: 'foo'};
sandbox.stub(element, '_showAlert');
element.fire('show-alert', alertObj);
assert.isTrue(element._showAlert.calledOnce);
assert.equal(element._showAlert.lastCall.args[0], 'foo');
assert.isNotOk(element._showAlert.lastCall.args[1]);
assert.isNotOk(element._showAlert.lastCall.args[2]);
});
test('checks stale credentials on visibility change', () => {
const refreshStub = sandbox.stub(element,
'_checkSignedIn');
sandbox.stub(Date, 'now').returns(999999);
element._lastCredentialCheck = 0;
element._handleVisibilityChange();
// Since there is no known account, it should not test credentials.
assert.isFalse(refreshStub.called);
assert.equal(element._lastCredentialCheck, 0);
element.knownAccountId = 123;
element._handleVisibilityChange();
// Should test credentials, since there is a known account.
assert.isTrue(refreshStub.called);
assert.equal(element._lastCredentialCheck, 999999);
});
test('refreshes with same credentials', done => {
const accountPromise = Promise.resolve({_account_id: 1234});
sandbox.stub(element.$.restAPI, 'getAccount')
.returns(accountPromise);
const requestCheckStub = sandbox.stub(element, '_requestCheckLoggedIn');
const handleRefreshStub = sandbox.stub(element,
'_handleCredentialRefreshed');
const reloadStub = sandbox.stub(element, '_reloadPage');
element.knownAccountId = 1234;
element._refreshingCredentials = true;
element._checkSignedIn();
flush(() => {
assert.isFalse(requestCheckStub.called);
assert.isTrue(handleRefreshStub.called);
assert.isFalse(reloadStub.called);
done();
});
});
test('_showAlert hides existing alerts', () => {
element._alertElement = element._createToastAlert();
const hideStub = sandbox.stub(element, '_hideAlert');
element._showAlert();
assert.isTrue(hideStub.calledOnce);
});
test('show-error', () => {
const openStub = sandbox.stub(element.$.errorOverlay, 'open');
const closeStub = sandbox.stub(element.$.errorOverlay, 'close');
const reportStub = sandbox.stub(element.$.reporting, 'reportErrorDialog');
const message = 'test message';
element.fire('show-error', {message});
flushAsynchronousOperations();
assert.isTrue(openStub.called);
assert.isTrue(reportStub.called);
assert.equal(element.$.errorDialog.text, message);
element.$.errorDialog.fire('dismiss');
flushAsynchronousOperations();
assert.isTrue(closeStub.called);
});
test('reloads when refreshed credentials differ', done => {
const accountPromise = Promise.resolve({_account_id: 1234});
sandbox.stub(element.$.restAPI, 'getAccount')
.returns(accountPromise);
const requestCheckStub = sandbox.stub(element, '_requestCheckLoggedIn');
const handleRefreshStub = sandbox.stub(element,
'_handleCredentialRefreshed');
const reloadStub = sandbox.stub(element, '_reloadPage');
element.knownAccountId = 4321; // Different from 1234
element._refreshingCredentials = true;
element._checkSignedIn();
flush(() => {
assert.isFalse(requestCheckStub.called);
assert.isFalse(handleRefreshStub.called);
assert.isTrue(reloadStub.called);
done(); done();
}); });
}); });
}); });
test('show alert', () => { suite('when not authed', () => {
const alertObj = {message: 'foo'}; setup(() => {
sandbox.stub(element, '_showAlert'); stub('gr-rest-api-interface', {
element.fire('show-alert', alertObj); getLoggedIn() { return Promise.resolve(false); },
assert.isTrue(element._showAlert.calledOnce); });
assert.equal(element._showAlert.lastCall.args[0], 'foo'); element = fixture('basic');
assert.isNotOk(element._showAlert.lastCall.args[1]);
assert.isNotOk(element._showAlert.lastCall.args[2]);
});
test('checks stale credentials on visibility change', () => {
const refreshStub = sandbox.stub(element.$.restAPI,
'checkCredentials');
sandbox.stub(Date, 'now').returns(999999);
element._lastCredentialCheck = 0;
element._handleVisibilityChange();
// Since there is no known account, it should not test credentials.
assert.isFalse(refreshStub.called);
assert.equal(element._lastCredentialCheck, 0);
element.knownAccountId = 123;
element._handleVisibilityChange();
// Should test credentials, since there is a known account.
assert.isTrue(refreshStub.called);
assert.equal(element._lastCredentialCheck, 999999);
});
test('refresh loop continues on credential fail', done => {
const accountPromise = Promise.resolve(null);
sandbox.stub(element.$.restAPI, 'checkCredentials')
.returns(accountPromise);
const requestCheckStub = sandbox.stub(element, '_requestCheckLoggedIn');
const handleRefreshStub = sandbox.stub(element,
'_handleCredentialRefreshed');
const reloadStub = sandbox.stub(element, '_reloadPage');
element._refreshingCredentials = true;
element._checkSignedIn();
accountPromise.then(() => {
assert.isTrue(requestCheckStub.called);
assert.isFalse(handleRefreshStub.called);
assert.isFalse(reloadStub.called);
done();
}); });
});
test('refreshes with same credentials', done => { test('refresh loop continues on credential fail', done => {
const accountPromise = Promise.resolve({_account_id: 1234}); const requestCheckStub = sandbox.stub(element, '_requestCheckLoggedIn');
sandbox.stub(element.$.restAPI, 'checkCredentials') const handleRefreshStub = sandbox.stub(element,
.returns(accountPromise); '_handleCredentialRefreshed');
const requestCheckStub = sandbox.stub(element, '_requestCheckLoggedIn'); const reloadStub = sandbox.stub(element, '_reloadPage');
const handleRefreshStub = sandbox.stub(element,
'_handleCredentialRefreshed');
const reloadStub = sandbox.stub(element, '_reloadPage');
element.knownAccountId = 1234; element._refreshingCredentials = true;
element._refreshingCredentials = true; element._checkSignedIn();
element._checkSignedIn();
accountPromise.then(() => { flush(() => {
assert.isFalse(requestCheckStub.called); assert.isTrue(requestCheckStub.called);
assert.isTrue(handleRefreshStub.called); assert.isFalse(handleRefreshStub.called);
assert.isFalse(reloadStub.called); assert.isFalse(reloadStub.called);
done(); done();
});
}); });
}); });
test('reloads when refreshed credentials differ', done => {
const accountPromise = Promise.resolve({_account_id: 1234});
sandbox.stub(element.$.restAPI, 'checkCredentials')
.returns(accountPromise);
const requestCheckStub = sandbox.stub(element, '_requestCheckLoggedIn');
const handleRefreshStub = sandbox.stub(element,
'_handleCredentialRefreshed');
const reloadStub = sandbox.stub(element, '_reloadPage');
element.knownAccountId = 4321; // Different from 1234
element._refreshingCredentials = true;
element._checkSignedIn();
accountPromise.then(() => {
assert.isFalse(requestCheckStub.called);
assert.isFalse(handleRefreshStub.called);
assert.isTrue(reloadStub.called);
done();
});
});
test('_showAlert hides existing alerts', () => {
element._alertElement = element._createToastAlert();
const hideStub = sandbox.stub(element, '_hideAlert');
element._showAlert();
assert.isTrue(hideStub.calledOnce);
});
test('show-error', () => {
const openStub = sandbox.stub(element.$.errorOverlay, 'open');
const closeStub = sandbox.stub(element.$.errorOverlay, 'close');
const reportStub = sandbox.stub(element.$.reporting, 'reportErrorDialog');
const message = 'test message';
element.fire('show-error', {message});
flushAsynchronousOperations();
assert.isTrue(openStub.called);
assert.isTrue(reportStub.called);
assert.equal(element.$.errorDialog.text, message);
element.$.errorDialog.fire('dismiss');
flushAsynchronousOperations();
assert.isTrue(closeStub.called);
});
}); });
</script> </script>

View File

@@ -324,7 +324,11 @@ limitations under the License.
element.handleEvent(element.EventType.HIGHLIGHTJS_LOADED, {hljs: testHljs}); element.handleEvent(element.EventType.HIGHLIGHTJS_LOADED, {hljs: testHljs});
}); });
test('getAccount', done => { test('getLoggedIn', done => {
// fake fetch for authCheck
sandbox.stub(window, 'fetch', () => {
return Promise.resolve({status: 204});
});
plugin.restApi().getLoggedIn().then(loggedIn => { plugin.restApi().getLoggedIn().then(loggedIn => {
assert.isTrue(loggedIn); assert.isTrue(loggedIn);
done(); done();

View File

@@ -20,22 +20,86 @@
// Prevent redefinition. // Prevent redefinition.
if (window.Gerrit.Auth) { return; } if (window.Gerrit.Auth) { return; }
const MAX_AUTH_CHECK_WAIT_TIME_MS = 1000 * 30; // 30s
const MAX_GET_TOKEN_RETRIES = 2; const MAX_GET_TOKEN_RETRIES = 2;
Gerrit.Auth = { /**
TYPE: { * Auth class.
XSRF_TOKEN: 'xsrf_token', *
ACCESS_TOKEN: 'access_token', * Gerrit.Auth is an instance of this class.
}, */
class Auth {
constructor() {
this._type = null;
this._cachedTokenPromise = null;
this._defaultOptions = {};
this._retriesLeft = MAX_GET_TOKEN_RETRIES;
this._status = Auth.STATUS.UNDETERMINED;
this._authCheckPromise = null;
this._last_auth_check_time = Date.now();
}
_type: null, /**
_cachedTokenPromise: null, * Returns if user is authed or not.
_defaultOptions: {}, *
_retriesLeft: MAX_GET_TOKEN_RETRIES, * @returns {!Promise<boolean>}
*/
authCheck() {
if (!this._authCheckPromise ||
(Date.now() - this._last_auth_check_time > MAX_AUTH_CHECK_WAIT_TIME_MS)
) {
// Refetch after last check expired
this._authCheckPromise = fetch('/auth-check');
this._last_auth_check_time = Date.now();
}
return this._authCheckPromise.then(res => {
// auth-check will return 204 if authed
// treat the rest as unauthed
if (res.status === 204) {
this._setStatus(Auth.STATUS.AUTHED);
return true;
} else {
this._setStatus(Auth.STATUS.NOT_AUTHED);
return false;
}
}).catch(e => {
this._setStatus(Auth.STATUS.ERROR);
// Reset _authCheckPromise to avoid caching the failed promise
this._authCheckPromise = null;
return false;
});
}
clearCache() {
this._authCheckPromise = null;
}
/**
* @param {string} status
*/
_setStatus(status) {
if (this._status === status) return;
if (this._status === Auth.STATUS.AUTHED) {
Gerrit.emit('auth-error', {
message: Auth.CREDS_EXPIRED_MSG, action: 'Refresh credentials',
});
}
this._status = status;
}
get status() {
return this._status;
}
get isAuthed() {
return this._status === Auth.STATUS.AUTHED;
}
_getToken() { _getToken() {
return Promise.resolve(this._cachedTokenPromise); return Promise.resolve(this._cachedTokenPromise);
}, }
/** /**
* Enable cross-domain authentication using OAuth access token. * Enable cross-domain authentication using OAuth access token.
@@ -51,7 +115,7 @@
setup(getToken, defaultOptions) { setup(getToken, defaultOptions) {
this._retriesLeft = MAX_GET_TOKEN_RETRIES; this._retriesLeft = MAX_GET_TOKEN_RETRIES;
if (getToken) { if (getToken) {
this._type = Gerrit.Auth.TYPE.ACCESS_TOKEN; this._type = Auth.TYPE.ACCESS_TOKEN;
this._cachedTokenPromise = null; this._cachedTokenPromise = null;
this._getToken = getToken; this._getToken = getToken;
} }
@@ -61,7 +125,7 @@
this._defaultOptions[p] = defaultOptions[p]; this._defaultOptions[p] = defaultOptions[p];
} }
} }
}, }
/** /**
* Perform network fetch with authentication. * Perform network fetch with authentication.
@@ -74,7 +138,7 @@
const options = Object.assign({ const options = Object.assign({
headers: new Headers(), headers: new Headers(),
}, this._defaultOptions, opt_options); }, this._defaultOptions, opt_options);
if (this._type === Gerrit.Auth.TYPE.ACCESS_TOKEN) { if (this._type === Auth.TYPE.ACCESS_TOKEN) {
return this._getAccessToken().then( return this._getAccessToken().then(
accessToken => accessToken =>
this._fetchWithAccessToken(url, options, accessToken) this._fetchWithAccessToken(url, options, accessToken)
@@ -82,7 +146,7 @@
} else { } else {
return this._fetchWithXsrfToken(url, options); return this._fetchWithXsrfToken(url, options);
} }
}, }
_getCookie(name) { _getCookie(name) {
const key = name + '='; const key = name + '=';
@@ -95,7 +159,7 @@
} }
}); });
return result; return result;
}, }
_isTokenValid(token) { _isTokenValid(token) {
if (!token) { return false; } if (!token) { return false; }
@@ -105,7 +169,7 @@
if (Date.now() >= expiration.getTime()) { return false; } if (Date.now() >= expiration.getTime()) { return false; }
return true; return true;
}, }
_fetchWithXsrfToken(url, options) { _fetchWithXsrfToken(url, options) {
if (options.method && options.method !== 'GET') { if (options.method && options.method !== 'GET') {
@@ -116,7 +180,7 @@
} }
options.credentials = 'same-origin'; options.credentials = 'same-origin';
return fetch(url, options); return fetch(url, options);
}, }
/** /**
* @return {!Promise<string>} * @return {!Promise<string>}
@@ -138,7 +202,7 @@
// Fall back to anonymous access. // Fall back to anonymous access.
return null; return null;
}); });
}, }
_fetchWithAccessToken(url, options, accessToken) { _fetchWithAccessToken(url, options, accessToken) {
const params = []; const params = [];
@@ -180,8 +244,24 @@
url = url + (url.indexOf('?') === -1 ? '?' : '&') + params.join('&'); url = url + (url.indexOf('?') === -1 ? '?' : '&') + params.join('&');
} }
return fetch(url, options); return fetch(url, options);
}, }
}
Auth.TYPE = {
XSRF_TOKEN: 'xsrf_token',
ACCESS_TOKEN: 'access_token',
}; };
window.Gerrit.Auth = Gerrit.Auth; Auth.STATUS = {
UNDETERMINED: 0,
AUTHED: 1,
NOT_AUTHED: 2,
ERROR: 3,
};
Auth.CREDS_EXPIRED_MSG = 'Credentails expired.';
// TODO(taoalpha): this whole thing should be moved to a service
window.Auth = Auth;
Gerrit.Auth = new Auth();
})(window); })(window);

View File

@@ -35,7 +35,6 @@ limitations under the License.
setup(() => { setup(() => {
sandbox = sinon.sandbox.create(); sandbox = sinon.sandbox.create();
sandbox.stub(window, 'fetch').returns(Promise.resolve({ok: true}));
auth = Gerrit.Auth; auth = Gerrit.Auth;
}); });
@@ -43,29 +42,222 @@ limitations under the License.
sandbox.restore(); sandbox.restore();
}); });
suite('default (xsrf token header)', () => { suite('Auth class methods', () => {
test('GET', () => { let fakeFetch;
return auth.fetch('/url', {bar: 'bar'}).then(() => { setup(() => {
const [url, options] = fetch.lastCall.args; auth = new Auth();
assert.equal(url, '/url'); fakeFetch = sandbox.stub(window, 'fetch');
assert.equal(options.credentials, 'same-origin'); });
test('auth-check returns 403', done => {
fakeFetch.returns(Promise.resolve({status: 403}));
auth.authCheck().then(authed => {
assert.isFalse(authed);
assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
done();
}); });
}); });
test('POST', () => { test('auth-check returns 204', done => {
fakeFetch.returns(Promise.resolve({status: 204}));
auth.authCheck().then(authed => {
assert.isTrue(authed);
assert.equal(auth.status, Auth.STATUS.AUTHED);
done();
});
});
test('auth-check returns 502', done => {
fakeFetch.returns(Promise.resolve({status: 502}));
auth.authCheck().then(authed => {
assert.isFalse(authed);
assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
done();
});
});
test('auth-check failed', done => {
fakeFetch.returns(Promise.reject(new Error('random error')));
auth.authCheck().then(authed => {
assert.isFalse(authed);
assert.equal(auth.status, Auth.STATUS.ERROR);
done();
});
});
});
suite('cache and events behaivor', () => {
let fakeFetch;
let clock;
setup(() => {
auth = new Auth();
clock = sinon.useFakeTimers();
fakeFetch = sandbox.stub(window, 'fetch');
});
test('cache auth-check result', done => {
fakeFetch.returns(Promise.resolve({status: 403}));
auth.authCheck().then(authed => {
assert.isFalse(authed);
assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
fakeFetch.returns(Promise.resolve({status: 204}));
auth.authCheck().then(authed2 => {
assert.isFalse(authed);
assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
done();
});
});
});
test('clearCache should refetch auth-check result', done => {
fakeFetch.returns(Promise.resolve({status: 403}));
auth.authCheck().then(authed => {
assert.isFalse(authed);
assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
fakeFetch.returns(Promise.resolve({status: 204}));
auth.clearCache();
auth.authCheck().then(authed2 => {
assert.isTrue(authed2);
assert.equal(auth.status, Auth.STATUS.AUTHED);
done();
});
});
});
test('cache expired on auth-check after certain time', done => {
fakeFetch.returns(Promise.resolve({status: 403}));
auth.authCheck().then(authed => {
assert.isFalse(authed);
assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
clock.tick(1000 * 10000);
fakeFetch.returns(Promise.resolve({status: 204}));
auth.authCheck().then(authed2 => {
assert.isTrue(authed2);
assert.equal(auth.status, Auth.STATUS.AUTHED);
done();
});
});
});
test('no cache if auth-check failed', done => {
fakeFetch.returns(Promise.reject(new Error('random error')));
auth.authCheck().then(authed => {
assert.isFalse(authed);
assert.equal(auth.status, Auth.STATUS.ERROR);
assert.equal(fakeFetch.callCount, 1);
auth.authCheck().then(() => {
assert.equal(fakeFetch.callCount, 2);
done();
});
});
});
test('fire event when switch from authed to unauthed', done => {
fakeFetch.returns(Promise.resolve({status: 204}));
auth.authCheck().then(authed => {
assert.isTrue(authed);
assert.equal(auth.status, Auth.STATUS.AUTHED);
clock.tick(1000 * 10000);
fakeFetch.returns(Promise.resolve({status: 403}));
const emitStub = sinon.stub();
Gerrit.emit = emitStub;
auth.authCheck().then(authed2 => {
assert.isFalse(authed2);
assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
assert.isTrue(emitStub.called);
done();
});
});
});
test('fire event when switch from authed to error', done => {
fakeFetch.returns(Promise.resolve({status: 204}));
auth.authCheck().then(authed => {
assert.isTrue(authed);
assert.equal(auth.status, Auth.STATUS.AUTHED);
clock.tick(1000 * 10000);
fakeFetch.returns(Promise.reject(new Error('random error')));
const emitStub = sinon.stub();
Gerrit.emit = emitStub;
auth.authCheck().then(authed2 => {
assert.isFalse(authed2);
assert.isTrue(emitStub.called);
assert.equal(auth.status, Auth.STATUS.ERROR);
done();
});
});
});
test('no event from non-authed to other status', done => {
fakeFetch.returns(Promise.resolve({status: 403}));
auth.authCheck().then(authed => {
assert.isFalse(authed);
assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
clock.tick(1000 * 10000);
fakeFetch.returns(Promise.resolve({status: 204}));
const emitStub = sinon.stub();
Gerrit.emit = emitStub;
auth.authCheck().then(authed2 => {
assert.isTrue(authed2);
assert.isFalse(emitStub.called);
assert.equal(auth.status, Auth.STATUS.AUTHED);
done();
});
});
});
test('no event from non-authed to other status', done => {
fakeFetch.returns(Promise.resolve({status: 403}));
auth.authCheck().then(authed => {
assert.isFalse(authed);
assert.equal(auth.status, Auth.STATUS.NOT_AUTHED);
clock.tick(1000 * 10000);
fakeFetch.returns(Promise.reject(new Error('random error')));
const emitStub = sinon.stub();
Gerrit.emit = emitStub;
auth.authCheck().then(authed2 => {
assert.isFalse(authed2);
assert.isFalse(emitStub.called);
assert.equal(auth.status, Auth.STATUS.ERROR);
done();
});
});
});
});
suite('default (xsrf token header)', () => {
setup(() => {
sandbox.stub(window, 'fetch').returns(Promise.resolve({ok: true}));
});
test('GET', done => {
auth.fetch('/url', {bar: 'bar'}).then(() => {
const [url, options] = fetch.lastCall.args;
assert.equal(url, '/url');
assert.equal(options.credentials, 'same-origin');
done();
});
});
test('POST', done => {
sandbox.stub(auth, '_getCookie') sandbox.stub(auth, '_getCookie')
.withArgs('XSRF_TOKEN') .withArgs('XSRF_TOKEN')
.returns('foobar'); .returns('foobar');
return auth.fetch('/url', {method: 'POST'}).then(() => { auth.fetch('/url', {method: 'POST'}).then(() => {
const [url, options] = fetch.lastCall.args; const [url, options] = fetch.lastCall.args;
assert.equal(url, '/url'); assert.equal(url, '/url');
assert.equal(options.credentials, 'same-origin'); assert.equal(options.credentials, 'same-origin');
assert.equal(options.headers.get('X-Gerrit-Auth'), 'foobar'); assert.equal(options.headers.get('X-Gerrit-Auth'), 'foobar');
done();
}); });
}); });
}); });
suite('cors (access token)', () => { suite('cors (access token)', () => {
setup(() => {
sandbox.stub(window, 'fetch').returns(Promise.resolve({ok: true}));
});
let getToken; let getToken;
const makeToken = opt_accessToken => { const makeToken = opt_accessToken => {
@@ -81,62 +273,68 @@ limitations under the License.
auth.setup(getToken); auth.setup(getToken);
}); });
test('base url support', () => { test('base url support', done => {
const baseUrl = 'http://foo'; const baseUrl = 'http://foo';
sandbox.stub(Gerrit.BaseUrlBehavior, 'getBaseUrl').returns(baseUrl); sandbox.stub(Gerrit.BaseUrlBehavior, 'getBaseUrl').returns(baseUrl);
return auth.fetch(baseUrl + '/url', {bar: 'bar'}).then(() => { auth.fetch(baseUrl + '/url', {bar: 'bar'}).then(() => {
const [url] = fetch.lastCall.args; const [url] = fetch.lastCall.args;
assert.equal(url, 'http://foo/a/url?access_token=zbaz'); assert.equal(url, 'http://foo/a/url?access_token=zbaz');
done();
}); });
}); });
test('fetch not signed in', () => { test('fetch not signed in', done => {
getToken.returns(Promise.resolve()); getToken.returns(Promise.resolve());
return auth.fetch('/url', {bar: 'bar'}).then(() => { auth.fetch('/url', {bar: 'bar'}).then(() => {
const [url, options] = fetch.lastCall.args; const [url, options] = fetch.lastCall.args;
assert.equal(url, '/url'); assert.equal(url, '/url');
assert.equal(options.bar, 'bar'); assert.equal(options.bar, 'bar');
assert.equal(Object.keys(options.headers).length, 0); assert.equal(Object.keys(options.headers).length, 0);
done();
}); });
}); });
test('fetch signed in', () => { test('fetch signed in', done => {
return auth.fetch('/url', {bar: 'bar'}).then(() => { auth.fetch('/url', {bar: 'bar'}).then(() => {
const [url, options] = fetch.lastCall.args; const [url, options] = fetch.lastCall.args;
assert.equal(url, '/a/url?access_token=zbaz'); assert.equal(url, '/a/url?access_token=zbaz');
assert.equal(options.bar, 'bar'); assert.equal(options.bar, 'bar');
done();
}); });
}); });
test('getToken calls are cached', () => { test('getToken calls are cached', done => {
return Promise.all([ Promise.all([
auth.fetch('/url-one'), auth.fetch('/url-two')]).then(() => { auth.fetch('/url-one'), auth.fetch('/url-two')]).then(() => {
assert.equal(getToken.callCount, 1); assert.equal(getToken.callCount, 1);
done();
}); });
}); });
test('getToken refreshes token', () => { test('getToken refreshes token', done => {
sandbox.stub(auth, '_isTokenValid'); sandbox.stub(auth, '_isTokenValid');
auth._isTokenValid auth._isTokenValid
.onFirstCall().returns(true) .onFirstCall().returns(true)
.onSecondCall().returns(false) .onSecondCall().returns(false)
.onThirdCall().returns(true); .onThirdCall().returns(true);
return auth.fetch('/url-one').then(() => { auth.fetch('/url-one').then(() => {
getToken.returns(Promise.resolve(makeToken('bzzbb'))); getToken.returns(Promise.resolve(makeToken('bzzbb')));
return auth.fetch('/url-two'); return auth.fetch('/url-two');
}).then(() => { }).then(() => {
const [[firstUrl], [secondUrl]] = fetch.args; const [[firstUrl], [secondUrl]] = fetch.args;
assert.equal(firstUrl, '/a/url-one?access_token=zbaz'); assert.equal(firstUrl, '/a/url-one?access_token=zbaz');
assert.equal(secondUrl, '/a/url-two?access_token=bzzbb'); assert.equal(secondUrl, '/a/url-two?access_token=bzzbb');
done();
}); });
}); });
test('signed in token error falls back to anonymous', () => { test('signed in token error falls back to anonymous', done => {
getToken.returns(Promise.resolve('rubbish')); getToken.returns(Promise.resolve('rubbish'));
return auth.fetch('/url', {bar: 'bar'}).then(() => { auth.fetch('/url', {bar: 'bar'}).then(() => {
const [url, options] = fetch.lastCall.args; const [url, options] = fetch.lastCall.args;
assert.equal(url, '/url'); assert.equal(url, '/url');
assert.equal(options.bar, 'bar'); assert.equal(options.bar, 'bar');
done();
}); });
}); });
@@ -154,12 +352,12 @@ limitations under the License.
})); }));
}); });
test('HTTP PUT with content type', () => { test('HTTP PUT with content type', done => {
const originalOptions = { const originalOptions = {
method: 'PUT', method: 'PUT',
headers: new Headers({'Content-Type': 'mail/pigeon'}), headers: new Headers({'Content-Type': 'mail/pigeon'}),
}; };
return auth.fetch('/url', originalOptions).then(() => { auth.fetch('/url', originalOptions).then(() => {
assert.isTrue(getToken.called); assert.isTrue(getToken.called);
const [url, options] = fetch.lastCall.args; const [url, options] = fetch.lastCall.args;
assert.include(url, '$ct=mail%2Fpigeon'); assert.include(url, '$ct=mail%2Fpigeon');
@@ -167,14 +365,15 @@ limitations under the License.
assert.include(url, 'access_token=zbaz'); assert.include(url, 'access_token=zbaz');
assert.equal(options.method, 'POST'); assert.equal(options.method, 'POST');
assert.equal(options.headers.get('Content-Type'), 'text/plain'); assert.equal(options.headers.get('Content-Type'), 'text/plain');
done();
}); });
}); });
test('HTTP PUT without content type', () => { test('HTTP PUT without content type', done => {
const originalOptions = { const originalOptions = {
method: 'PUT', method: 'PUT',
}; };
return auth.fetch('/url', originalOptions).then(() => { auth.fetch('/url', originalOptions).then(() => {
assert.isTrue(getToken.called); assert.isTrue(getToken.called);
const [url, options] = fetch.lastCall.args; const [url, options] = fetch.lastCall.args;
assert.include(url, '$ct=text%2Fplain'); assert.include(url, '$ct=text%2Fplain');
@@ -182,6 +381,7 @@ limitations under the License.
assert.include(url, 'access_token=zbaz'); assert.include(url, 'access_token=zbaz');
assert.equal(options.method, 'POST'); assert.equal(options.method, 'POST');
assert.equal(options.headers.get('Content-Type'), 'text/plain'); assert.equal(options.headers.get('Content-Type'), 'text/plain');
done();
}); });
}); });
}); });

View File

@@ -66,12 +66,6 @@
* @event network-error * @event network-error
*/ */
/**
* Fired when credentials were rejected by server (e.g. expired).
*
* @event auth-error
*/
/** /**
* Fired after an RPC completes. * Fired after an RPC completes.
* *
@@ -89,10 +83,6 @@
type: Object, type: Object,
value: new SiteBasedCache(), // Shared across instances. value: new SiteBasedCache(), // Shared across instances.
}, },
_credentialCheck: {
type: Object,
value: {checking: false}, // Shared across instances.
},
_sharedFetchPromises: { _sharedFetchPromises: {
type: Object, type: Object,
value: new FetchPromisesCache(), // Shared across instances. value: new FetchPromisesCache(), // Shared across instances.
@@ -112,40 +102,12 @@
type: Object, type: Object,
value: {}, // Intentional to share the object across instances. value: {}, // Intentional to share the object across instances.
}, },
_auth: {
type: Object,
value: Gerrit.Auth, // Share across instances.
},
}; };
} }
created() { created() {
super.created(); super.created();
/* Polymer 1 and Polymer 2 have slightly different lifecycle. this._auth = Gerrit.Auth;
* Differences are not very well documented (see
* https://github.com/Polymer/old-docs-site/issues/2322).
* In Polymer 1, created() is called when properties values is not set
* and ready() is always called later, even if element is not added
* to a DOM. I.e. in Polymer 1 _cache and other properties are undefined,
* while in Polymer 2 they are set to default values.
* In Polymer 2, created() is called after properties values set and
* ready() is called only after element is attached to a DOM.
* There are several places in the code, where element is created with
* document.createElement('gr-rest-api-interface') and is not added
* to a DOM.
* In such cases, Polymer 1 calls both created() and ready() methods,
* but Polymer 2 calls only created() method.
* To workaround these differences, we should try to create _restApiHelper
* in both methods.
*/
//
this._initRestApiHelper();
}
ready() {
super.ready();
// See comments in created()
this._initRestApiHelper(); this._initRestApiHelper();
} }
@@ -153,10 +115,9 @@
if (this._restApiHelper) { if (this._restApiHelper) {
return; return;
} }
if (this._cache && this._auth && this._sharedFetchPromises && if (this._cache && this._auth && this._sharedFetchPromises) {
this._credentialCheck) {
this._restApiHelper = new GrRestApiHelper(this._cache, this._auth, this._restApiHelper = new GrRestApiHelper(this._cache, this._auth,
this._sharedFetchPromises, this._credentialCheck, this); this._sharedFetchPromises, this);
} }
} }
@@ -850,11 +811,7 @@
} }
getLoggedIn() { getLoggedIn() {
return this.getAccount().then(account => { return this._auth.authCheck();
return account != null;
}).catch(() => {
return false;
});
} }
getIsAdmin() { getIsAdmin() {
@@ -869,10 +826,6 @@
}); });
} }
checkCredentials() {
return this._restApiHelper.checkCredentials();
}
getDefaultPreferences() { getDefaultPreferences() {
return this._fetchSharedCacheURL({ return this._fetchSharedCacheURL({
url: '/config/server/preferences', url: '/config/server/preferences',
@@ -1347,6 +1300,10 @@
this._restApiHelper.invalidateFetchPromisesPrefix('/projects/?'); this._restApiHelper.invalidateFetchPromisesPrefix('/projects/?');
} }
invalidateAccountsCache() {
this._restApiHelper.invalidateFetchPromisesPrefix('/accounts/');
}
/** /**
* @param {string} filter * @param {string} filter
* @param {number} groupsPerPage * @param {number} groupsPerPage
@@ -2805,4 +2762,4 @@
} }
customElements.define(GrRestApiInterface.is, GrRestApiInterface); customElements.define(GrRestApiInterface.is, GrRestApiInterface);
})(); })();

View File

@@ -48,8 +48,6 @@ limitations under the License.
window.CANONICAL_PATH = `test${ctr}`; window.CANONICAL_PATH = `test${ctr}`;
sandbox = sinon.sandbox.create(); sandbox = sinon.sandbox.create();
element = fixture('basic');
element._projectLookup = {};
const testJSON = ')]}\'\n{"hello": "bonjour"}'; const testJSON = ')]}\'\n{"hello": "bonjour"}';
sandbox.stub(window, 'fetch').returns(Promise.resolve({ sandbox.stub(window, 'fetch').returns(Promise.resolve({
ok: true, ok: true,
@@ -57,6 +55,10 @@ limitations under the License.
return Promise.resolve(testJSON); return Promise.resolve(testJSON);
}, },
})); }));
// fake auth
sandbox.stub(Gerrit.Auth, 'authCheck').returns(Promise.resolve(true));
element = fixture('basic');
element._projectLookup = {};
}); });
teardown(() => { teardown(() => {
@@ -365,117 +367,6 @@ limitations under the License.
}); });
}); });
test('auth failure', done => {
const fakeAuthResponse = {
ok: false,
status: 403,
};
window.fetch.onFirstCall().returns(
Promise.reject(new Error('Failed to fetch')));
window.fetch.onSecondCall().returns(Promise.resolve(fakeAuthResponse));
// Emulate logged in.
element._restApiHelper._cache.set('/accounts/self/detail', {});
const serverErrorStub = sandbox.stub();
element.addEventListener('server-error', serverErrorStub);
const authErrorStub = sandbox.stub();
element.addEventListener('auth-error', authErrorStub);
element._restApiHelper.fetchJSON({url: '/bar'}).finally(r => {
flush(() => {
assert.isTrue(authErrorStub.called);
assert.isFalse(serverErrorStub.called);
assert.isFalse(element._cache.has('/accounts/self/detail'));
done();
});
});
});
test('auth failure - test all failed to fetch', done => {
window.fetch.returns(
Promise.reject(new Error('Failed to fetch')));
// Emulate logged in.
element._cache.set('/accounts/self/detail', {});
const serverErrorStub = sandbox.stub();
element.addEventListener('server-error', serverErrorStub);
const authErrorStub = sandbox.stub();
element.addEventListener('auth-error', authErrorStub);
element._restApiHelper.fetchJSON({url: '/bar'}).finally(r => {
flush(() => {
assert.isTrue(authErrorStub.called);
assert.isFalse(serverErrorStub.called);
assert.isFalse(element._cache.has('/accounts/self/detail'));
done();
});
});
});
test('getLoggedIn returns false when network/auth failure', done => {
window.fetch.returns(
Promise.reject(new Error('Failed to fetch')));
element.getLoggedIn().then(isLoggedIn => {
assert.isFalse(isLoggedIn);
done();
});
});
test('checkCredentials', done => {
const responses = [
{
ok: false,
status: 403,
text() { return Promise.resolve(); },
},
{
ok: true,
status: 200,
text() { return Promise.resolve(')]}\'{}'); },
},
];
window.fetch.restore();
sandbox.stub(window, 'fetch', url => {
if (url === window.CANONICAL_PATH + '/accounts/self/detail') {
return Promise.resolve(responses.shift());
}
});
element.getLoggedIn().then(account => {
assert.isNotOk(account);
element.checkCredentials().then(account => {
assert.isOk(account);
done();
});
});
});
test('checkCredentials promise rejection', () => {
window.fetch.restore();
element._cache.set('/accounts/self/detail', true);
const checkCredentialsSpy =
sandbox.spy(element._restApiHelper, 'checkCredentials');
sandbox.stub(window, 'fetch', url => {
return Promise.reject(new Error('Failed to fetch'));
});
return element.getConfig(true)
.catch(err => undefined)
.then(() => {
// When the top-level fetch call throws an error, it invokes
// checkCredentials, which in turn makes another fetch call.
// The second fetch call also fails, which leads to a second
// invocation of checkCredentials, which should immediately
// return instead of making further fetch calls.
assert.isTrue(checkCredentialsSpy .calledTwice);
assert.isTrue(window.fetch.calledTwice);
});
});
test('checkCredentials accepts only json', () => {
const authFetchStub = sandbox.stub(element._auth, 'fetch')
.returns(Promise.resolve());
element.checkCredentials();
assert.isTrue(authFetchStub.called);
assert.equal(authFetchStub.lastCall.args[1].headers.get('Accept'),
'application/json');
});
test('legacy n,z key in change url is replaced', () => { test('legacy n,z key in change url is replaced', () => {
const stub = sandbox.stub(element._restApiHelper, 'fetchJSON') const stub = sandbox.stub(element._restApiHelper, 'fetchJSON')
.returns(Promise.resolve([])); .returns(Promise.resolve([]));
@@ -922,6 +813,18 @@ limitations under the License.
assert.isFalse(element._cache.has(url)); assert.isFalse(element._cache.has(url));
}); });
test('invalidateAccountsCache', () => {
const url = '/accounts/self/detail';
element._cache.set(url, {});
element.invalidateAccountsCache();
assert.isUndefined(element._sharedFetchPromises[url]);
assert.isFalse(element._cache.has(url));
});
suite('getRepos', () => { suite('getRepos', () => {
const defaultQuery = 'state%3Aactive%20OR%20state%3Aread-only'; const defaultQuery = 'state%3Aactive%20OR%20state%3Aread-only';
let fetchCacheURLStub; let fetchCacheURLStub;

View File

@@ -18,7 +18,6 @@
'use strict'; 'use strict';
const JSON_PREFIX = ')]}\''; const JSON_PREFIX = ')]}\'';
const FAILED_TO_FETCH_ERROR = 'Failed to fetch';
/** /**
* Wrapper around Map for caching server responses. Site-based so that * Wrapper around Map for caching server responses. Site-based so that
@@ -107,15 +106,13 @@
* @param {SiteBasedCache} cache * @param {SiteBasedCache} cache
* @param {object} auth * @param {object} auth
* @param {FetchPromisesCache} fetchPromisesCache * @param {FetchPromisesCache} fetchPromisesCache
* @param {object} credentialCheck
* @param {object} restApiInterface * @param {object} restApiInterface
*/ */
constructor(cache, auth, fetchPromisesCache, credentialCheck, constructor(cache, auth, fetchPromisesCache,
restApiInterface) { restApiInterface) {
this._cache = cache;// TODO: make it public this._cache = cache;// TODO: make it public
this._auth = auth; this._auth = auth;
this._fetchPromisesCache = fetchPromisesCache; this._fetchPromisesCache = fetchPromisesCache;
this._credentialCheck = credentialCheck;
this._restApiInterface = restApiInterface; this._restApiInterface = restApiInterface;
} }
@@ -190,15 +187,10 @@
} }
return res; return res;
}).catch(err => { }).catch(err => {
const isLoggedIn = !!this._cache.get('/accounts/self/detail'); if (req.errFn) {
if (isLoggedIn && err && err.message === FAILED_TO_FETCH_ERROR) { req.errFn.call(undefined, null, err);
this.checkCredentials();
} else { } else {
if (req.errFn) { this.fire('network-error', {error: err});
req.errFn.call(undefined, null, err);
} else {
this.fire('network-error', {error: err});
}
} }
throw err; throw err;
}); });
@@ -384,37 +376,6 @@
return xhr; return xhr;
} }
checkCredentials() {
if (this._credentialCheck.checking) {
return;
}
this._credentialCheck.checking = true;
let req = {url: '/accounts/self/detail', reportUrlAsIs: true};
req = this.addAcceptJsonHeader(req);
// Skip the REST response cache.
return this.fetchRawJSON(req).then(res => {
if (!res) { return; }
if (res.status === 403) {
this.fire('auth-error');
this._cache.delete('/accounts/self/detail');
} else if (res.ok) {
return this.getResponseObject(res);
}
}).then(res => {
this._credentialCheck.checking = false;
if (res) {
this._cache.set('/accounts/self/detail', res);
}
return res;
}).catch(err => {
this._credentialCheck.checking = false;
if (err && err.message === FAILED_TO_FETCH_ERROR) {
this.fire('auth-error');
this._cache.delete('/accounts/self/detail');
}
});
}
/** /**
* @param {string} prefix * @param {string} prefix
*/ */
@@ -428,4 +389,3 @@
window.FetchPromisesCache = FetchPromisesCache; window.FetchPromisesCache = FetchPromisesCache;
window.GrRestApiHelper = GrRestApiHelper; window.GrRestApiHelper = GrRestApiHelper;
})(window); })(window);

View File

@@ -41,7 +41,6 @@ limitations under the License.
sandbox = sinon.sandbox.create(); sandbox = sinon.sandbox.create();
cache = new SiteBasedCache(); cache = new SiteBasedCache();
fetchPromisesCache = new FetchPromisesCache(); fetchPromisesCache = new FetchPromisesCache();
const credentialCheck = {checking: false};
window.CANONICAL_PATH = 'testhelper'; window.CANONICAL_PATH = 'testhelper';
@@ -59,7 +58,7 @@ limitations under the License.
})); }));
helper = new GrRestApiHelper(cache, Gerrit.Auth, fetchPromisesCache, helper = new GrRestApiHelper(cache, Gerrit.Auth, fetchPromisesCache,
credentialCheck, mockRestApiInterface); mockRestApiInterface);
}); });
teardown(() => { teardown(() => {