Use Admin API v2 with session auth (#1046)

refs #9865
- removed all `oauth2` and token-based ESA auth
- added new `cookie` authenticator which handles session creation
- updated the session store to extend from the `ephemeral` in-memory store and to restore by fetching the currently logged in user and using the success/failure state to indicate authentication state
  - ESA automatically calls this `.restore()` method on app boot
  - the `session` service caches the current-user query so there's no unnecessary requests being made for the "logged in" state
- removed the now-unnecessary token refresh and logout routines from the `application` route
- removed the now-unnecessary token refresh routines from the `ajax` service
- removed `access_token` query param from iframe file downloaders
- changed Ember Data adapters and `ghost-paths` to use the `/ghost/api/v2/admin/` namespace
This commit is contained in:
Fabien O'Carroll 2018-10-06 01:46:33 +07:00 committed by Kevin Ansfield
parent 656a20272a
commit 3e5a62309f
35 changed files with 210 additions and 452 deletions

View File

@ -14,14 +14,9 @@ export default RESTAdapter.extend(DataAdapterMixin, AjaxServiceSupport, {
return false; return false;
}, },
/* eslint-disable camelcase */ authorize(/*xhr*/) {
authorize(xhr) { // noop - we're using server-side session cookies
if (this.get('session.isAuthenticated')) {
let {access_token} = this.get('session.data.authenticated');
xhr.setRequestHeader('Authorization', `Bearer ${access_token}`);
}
}, },
/* eslint-enable camelcase */
query(store, type, query) { query(store, type, query) {
let id; let id;

View File

@ -0,0 +1,41 @@
import Authenticator from 'ember-simple-auth/authenticators/base';
import RSVP from 'rsvp';
import {computed} from '@ember/object';
import {inject as service} from '@ember/service';
export default Authenticator.extend({
ajax: service(),
ghostPaths: service(),
sessionEndpoint: computed('ghostPaths.apiRoot', function () {
return `${this.ghostPaths.apiRoot}/session`;
}),
restore: function () {
return RSVP.resolve();
},
authenticate(identification, password) {
const data = {username: identification, password};
const options = {
data,
contentType: 'application/json;charset=utf-8',
// ember-ajax will try and parse the response as JSON if not explicitly set
dataType: 'text'
};
return this.ajax.post(this.sessionEndpoint, options);
},
invalidate() {
// if we're invalidating because of a 401 we can end up in an infinite
// loop if we then try to perform a DELETE /session/ request
// TODO: find a more elegant way to handle this
if (this.ajax.skipSessionDeletion) {
this.ajax.skipSessionDeletion = false;
return RSVP.resolve();
}
return this.ajax.del(this.sessionEndpoint);
}
});

View File

@ -1,91 +0,0 @@
import Authenticator from 'ember-simple-auth/authenticators/oauth2-password-grant';
import RSVP from 'rsvp';
import {assign} from '@ember/polyfills';
import {computed} from '@ember/object';
import {isEmpty} from '@ember/utils';
import {run} from '@ember/runloop';
import {inject as service} from '@ember/service';
import {makeArray as wrap} from '@ember/array';
export default Authenticator.extend({
ajax: service(),
session: service(),
config: service(),
ghostPaths: service(),
init() {
this._super(...arguments);
let handler = run.bind(this, () => {
this.onOnline();
});
window.addEventListener('online', handler);
},
serverTokenEndpoint: computed('ghostPaths.apiRoot', function () {
return `${this.get('ghostPaths.apiRoot')}/authentication/token`;
}),
// disable general token revocation because the requests will always 401
// (revocation is triggered by invalid access token so it's already invalid)
// we have a separate logout procedure that sends revocation requests
serverTokenRevocationEndpoint: null,
makeRequest(url, data) {
/* eslint-disable camelcase */
data.client_id = this.get('config.clientId');
data.client_secret = this.get('config.clientSecret');
/* eslint-enable camelcase */
let options = {
data,
dataType: 'json',
contentType: 'application/x-www-form-urlencoded'
};
return this.get('ajax').post(url, options);
},
/**
* Invoked when "navigator.online" event is trigerred.
* This is a helper function to handle intermittent internet connectivity. Token is refreshed
* when browser status becomes "online".
*/
onOnline() {
if (this.get('session.isAuthenticated')) {
let autoRefresh = this.get('refreshAccessTokens');
if (autoRefresh) {
let expiresIn = this.get('session.data.authenticated.expires_in');
let token = this.get('session.data.authenticated.refresh_token');
return this._refreshAccessToken(expiresIn, token);
}
}
},
authenticate(identification, password, scope = [], headers = {}) {
return new RSVP.Promise((resolve, reject) => {
let data = {grant_type: 'password', username: identification, password};
let serverTokenEndpoint = this.get('serverTokenEndpoint');
let scopesString = wrap(scope).join(' ');
if (!isEmpty(scopesString)) {
data.scope = scopesString;
}
this.makeRequest(serverTokenEndpoint, data, headers).then((response) => {
run(() => {
/* eslint-disable camelcase */
let expiresAt = this._absolutizeExpirationTime(response.expires_in);
this._scheduleAccessTokenRefresh(response.expires_in, expiresAt, response.refresh_token);
/* eslint-enable camelcase */
if (!isEmpty(expiresAt)) {
response = assign(response, {expires_at: expiresAt});
}
resolve(response);
});
}, (error) => {
reject(error);
});
});
}
});

View File

@ -28,7 +28,7 @@ export default ModalComponent.extend(ValidationEngine, {
_authenticate() { _authenticate() {
let session = this.get('session'); let session = this.get('session');
let authStrategy = 'authenticator:oauth2'; let authStrategy = 'authenticator:cookie';
let identification = this.get('identification'); let identification = this.get('identification');
let password = this.get('password'); let password = this.get('password');

View File

@ -56,7 +56,7 @@ export default Controller.extend(ValidationEngine, {
} }
}); });
this.get('notifications').showAlert(resp.passwordreset[0].message, {type: 'warn', delayed: true, key: 'password.reset'}); this.get('notifications').showAlert(resp.passwordreset[0].message, {type: 'warn', delayed: true, key: 'password.reset'});
this.get('session').authenticate('authenticator:oauth2', this.get('email'), credentials.newPassword); this.get('session').authenticate('authenticator:cookie', this.get('email'), credentials.newPassword);
return true; return true;
} catch (error) { } catch (error) {
this.get('notifications').showAPIError(error, {key: 'password.reset'}); this.get('notifications').showAPIError(error, {key: 'password.reset'});

View File

@ -171,9 +171,7 @@ export default Controller.extend({
}, },
downloadTheme(theme) { downloadTheme(theme) {
let themeURL = `${this.get('ghostPaths.apiRoot')}/themes/${theme.name}`; let downloadURL = `${this.get('ghostPaths.apiRoot')}/themes/${theme.name}`;
let accessToken = this.get('session.data.authenticated.access_token');
let downloadURL = `${themeURL}/download/?access_token=${accessToken}`;
let iframe = $('#iframeDownload'); let iframe = $('#iframeDownload');
if (iframe.length === 0) { if (iframe.length === 0) {

View File

@ -129,9 +129,7 @@ export default Controller.extend({
}, },
downloadFile(endpoint) { downloadFile(endpoint) {
let url = this.get('ghostPaths.url').api(endpoint); let downloadURL = this.get('ghostPaths.url').api(endpoint);
let accessToken = this.get('session.data.authenticated.access_token');
let downloadURL = `${url}?access_token=${accessToken}`;
let iframe = $('#iframeDownload'); let iframe = $('#iframeDownload');
if (iframe.length === 0) { if (iframe.length === 0) {

View File

@ -140,7 +140,7 @@ export default Controller.extend(ValidationEngine, {
// Don't call the success handler, otherwise we will be redirected to admin // Don't call the success handler, otherwise we will be redirected to admin
this.set('session.skipAuthSuccessHandler', true); this.set('session.skipAuthSuccessHandler', true);
return this.get('session').authenticate('authenticator:oauth2', this.get('email'), this.get('password')).then(() => { return this.get('session').authenticate('authenticator:cookie', this.get('email'), this.get('password')).then(() => {
this.set('blogCreated', true); this.set('blogCreated', true);
return this._afterAuthentication(result); return this._afterAuthentication(result);
}).catch((error) => { }).catch((error) => {

View File

@ -79,7 +79,7 @@ export default Controller.extend(ValidationEngine, {
validateAndAuthenticate: task(function* () { validateAndAuthenticate: task(function* () {
let signin = this.get('signin'); let signin = this.get('signin');
let authStrategy = 'authenticator:oauth2'; let authStrategy = 'authenticator:cookie';
this.set('flowErrors', ''); this.set('flowErrors', '');
// Manually trigger events for input fields, ensuring legacy compatibility with // Manually trigger events for input fields, ensuring legacy compatibility with

View File

@ -133,7 +133,7 @@ export default Controller.extend({
let password = this.get('signupDetails.password'); let password = this.get('signupDetails.password');
return this.get('session') return this.get('session')
.authenticate('authenticator:oauth2', email, password); .authenticate('authenticator:cookie', email, password);
}, },
_sendImage: task(function* () { _sendImage: task(function* () {

View File

@ -4,7 +4,6 @@ import RSVP from 'rsvp';
import Route from '@ember/routing/route'; import Route from '@ember/routing/route';
import ShortcutsRoute from 'ghost-admin/mixins/shortcuts-route'; import ShortcutsRoute from 'ghost-admin/mixins/shortcuts-route';
import ctrlOrCmd from 'ghost-admin/utils/ctrl-or-cmd'; import ctrlOrCmd from 'ghost-admin/utils/ctrl-or-cmd';
import moment from 'moment';
import windowProxy from 'ghost-admin/utils/window-proxy'; import windowProxy from 'ghost-admin/utils/window-proxy';
import {htmlSafe} from '@ember/string'; import {htmlSafe} from '@ember/string';
import { import {
@ -56,23 +55,6 @@ export default Route.extend(ApplicationRouteMixin, ShortcutsRoute, {
transition.send('loadServerNotifications'); transition.send('loadServerNotifications');
transition.send('checkForOutdatedDesktopApp'); transition.send('checkForOutdatedDesktopApp');
// trigger a background token refresh to enable "infinite" sessions
// NOTE: we only do this if the last refresh was > 1 day ago to avoid
// potential issues with multiple tabs and concurrent admin loads/refreshes.
// see https://github.com/TryGhost/Ghost/issues/8616
let session = this.get('session.session');
let expiresIn = session.get('authenticated.expires_in') * 1000;
let expiresAt = session.get('authenticated.expires_at');
let lastRefresh = moment(expiresAt - expiresIn);
let oneDayAgo = moment().subtract(1, 'day');
if (lastRefresh.isBefore(oneDayAgo)) {
let authenticator = session._lookupAuthenticator(session.authenticator);
if (authenticator && authenticator.onOnline) {
authenticator.onOnline();
}
}
let featurePromise = this.get('feature').fetch(); let featurePromise = this.get('feature').fetch();
let settingsPromise = this.get('settings').fetch(); let settingsPromise = this.get('settings').fetch();
let privateConfigPromise = this.get('config').fetchPrivate(); let privateConfigPromise = this.get('config').fetchPrivate();
@ -109,35 +91,6 @@ export default Route.extend(ApplicationRouteMixin, ShortcutsRoute, {
this.send('loadServerNotifications', true); this.send('loadServerNotifications', true);
}, },
// this is only called by the `signout` route at present.
// it's separate to the normal ESA session invalidadition because it will
// actually send the token revocation requests whereas we have to avoid
// those most of the time because they will fail if we have invalid tokens
logout() {
let session = this.get('session');
// revoke keys on the server
if (session.get('isAuthenticated')) {
let auth = session.get('data.authenticated');
let revokeEndpoint = `${this.get('ghostPaths.apiRoot')}/authentication/revoke`;
let authenticator = session.get('session')._lookupAuthenticator(session.get('session.authenticator'));
let requests = [];
['refresh_token', 'access_token'].forEach((tokenType) => {
let data = {
token_type_hint: tokenType,
token: auth[tokenType]
};
authenticator.makeRequest(revokeEndpoint, data);
});
RSVP.all(requests).finally(() => {
// remove local keys and refresh
session.invalidate();
});
} else {
// remove local keys and refresh
session.invalidate();
}
},
authorizationFailed() { authorizationFailed() {
windowProxy.replaceLocation(AuthConfiguration.baseURL); windowProxy.replaceLocation(AuthConfiguration.baseURL);
}, },

View File

@ -1,11 +1,7 @@
import AuthenticatedRoute from 'ghost-admin/routes/authenticated'; import AuthenticatedRoute from 'ghost-admin/routes/authenticated';
import Ember from 'ember';
import styleBody from 'ghost-admin/mixins/style-body'; import styleBody from 'ghost-admin/mixins/style-body';
import {inject as service} from '@ember/service'; import {inject as service} from '@ember/service';
// ember-cli-shims doesn't export canInvoke
const {canInvoke} = Ember;
export default AuthenticatedRoute.extend(styleBody, { export default AuthenticatedRoute.extend(styleBody, {
notifications: service(), notifications: service(),
@ -13,12 +9,8 @@ export default AuthenticatedRoute.extend(styleBody, {
classNames: ['ghost-signout'], classNames: ['ghost-signout'],
afterModel(model, transition) { afterModel(/*model, transition*/) {
this.get('notifications').clearAll(); this.notifications.clearAll();
if (canInvoke(transition, 'send')) { this.session.invalidate();
transition.send('logout');
} else {
this.send('logout');
}
} }
}); });

View File

@ -9,7 +9,6 @@ import {inject as service} from '@ember/service';
const JSON_CONTENT_TYPE = 'application/json'; const JSON_CONTENT_TYPE = 'application/json';
const GHOST_REQUEST = /\/ghost\/api\//; const GHOST_REQUEST = /\/ghost\/api\//;
const TOKEN_REQUEST = /authentication\/(?:token|ghost|revoke)/;
function isJSONContentType(header) { function isJSONContentType(header) {
if (!header || isNone(header)) { if (!header || isNone(header)) {
@ -119,57 +118,33 @@ export function isThemeValidationError(errorOrStatus, payload) {
let ajaxService = AjaxService.extend({ let ajaxService = AjaxService.extend({
session: service(), session: service(),
// flag to tell our ESA authenticator not to try an invalidate DELETE request
// because it's been triggered by this service's 401 handling which means the
// DELETE would fail and get stuck in an infinite loop
// TODO: find a more elegant way to handle this
skipSessionDeletion: false,
headers: computed('session.isAuthenticated', function () { headers: computed('session.isAuthenticated', function () {
let session = this.get('session');
let headers = {}; let headers = {};
headers['X-Ghost-Version'] = config.APP.version; headers['X-Ghost-Version'] = config.APP.version;
headers['App-Pragma'] = 'no-cache'; headers['App-Pragma'] = 'no-cache';
if (session.get('isAuthenticated')) {
/* eslint-disable camelcase */
let {access_token} = session.get('data.authenticated');
headers.Authorization = `Bearer ${access_token}`;
/* eslint-enable camelcase */
}
return headers; return headers;
}).volatile(), }).volatile(),
// ember-ajax recognises `application/vnd.api+json` as a JSON-API request // ember-ajax recognises `application/vnd.api+json` as a JSON-API request
// and formats appropriately, we want to handle `application/json` the same // and formats appropriately, we want to handle `application/json` the same
_makeRequest(hash) { _makeRequest(hash) {
let isAuthenticated = this.get('session.isAuthenticated');
let isGhostRequest = GHOST_REQUEST.test(hash.url);
let isTokenRequest = isGhostRequest && TOKEN_REQUEST.test(hash.url);
let tokenExpiry = this.get('session.authenticated.expires_at');
let isTokenExpired = tokenExpiry < (new Date()).getTime();
if (isJSONContentType(hash.contentType) && hash.type !== 'GET') { if (isJSONContentType(hash.contentType) && hash.type !== 'GET') {
if (typeof hash.data === 'object') { if (typeof hash.data === 'object') {
hash.data = JSON.stringify(hash.data); hash.data = JSON.stringify(hash.data);
} }
} }
// we can get into a situation where the app is left open without a hash.withCredentials = true;
// network connection and the token subsequently expires, this will
// result in the next network request returning a 401 and killing the
// session. This is an attempt to detect that and restore the session
// using the stored refresh token before continuing with the request
//
// TODO:
// - this might be quite blunt, if we have a lot of requests at once
// we probably want to queue the requests until the restore completes
// BUG:
// - the original caller gets a rejected promise with `undefined` instead
// of the AjaxError object when session restore fails. This isn't a
// huge deal because the session will be invalidated and app reloaded
// but it would be nice to be consistent
if (isAuthenticated && isGhostRequest && !isTokenRequest && isTokenExpired) {
return this.get('session').restore().then(() => this._makeRequest(hash));
}
return this._super(...arguments); return this._super(hash);
}, },
handleResponse(status, headers, payload, request) { handleResponse(status, headers, payload, request) {
@ -192,7 +167,8 @@ let ajaxService = AjaxService.extend({
let isUnauthorized = this.isUnauthorizedError(status, headers, payload); let isUnauthorized = this.isUnauthorizedError(status, headers, payload);
if (isAuthenticated && isGhostRequest && isUnauthorized) { if (isAuthenticated && isGhostRequest && isUnauthorized) {
this.get('session').invalidate(); this.skipSessionDeletion = true;
this.session.invalidate();
} }
return this._super(...arguments); return this._super(...arguments);

View File

@ -1,10 +1,28 @@
import AdaptiveStore from 'ember-simple-auth/session-stores/adaptive'; import EphemeralStore from 'ember-simple-auth/session-stores/ephemeral';
import ghostPaths from 'ghost-admin/utils/ghost-paths'; import RSVP from 'rsvp';
import {inject as service} from '@ember/service';
const paths = ghostPaths(); // Ghost already uses a cookie to store it's session so we don't need to keep
const keyName = `ghost${(paths.subdir.indexOf('/') === 0 ? `-${paths.subdir.substr(1)}` : '') }:session`; // track of any other peristent login state separately in Ember Simple Auth
export default EphemeralStore.extend({
session: service(),
export default AdaptiveStore.extend({ // when loading the app we want ESA to try fetching the currently logged
localStorageKey: keyName, // in user. This will succeed/fail depending on whether we have a valid
cookieName: keyName // session cookie or not so we can use that as an indication of the session
// being authenticated
restore() {
return this.session.user.then(() => {
// provide the necessary data for internal-session to mark the
// session as authenticated
let data = {authenticated: {authenticator: 'authenticator:cookie'}};
this.persist(data);
return data;
}).catch(() => {
// ensure the session.user doesn't return the same rejected promise
// after a succussful login
this.session.notifyPropertyChange('user');
return RSVP.reject();
});
}
}); });

View File

@ -18,7 +18,7 @@ export default function () {
let subdir = path.substr(0, path.search('/ghost/')); let subdir = path.substr(0, path.search('/ghost/'));
let adminRoot = `${subdir}/ghost/`; let adminRoot = `${subdir}/ghost/`;
let assetRoot = `${subdir}/ghost/assets/`; let assetRoot = `${subdir}/ghost/assets/`;
let apiRoot = `${subdir}/ghost/api/v0.1`; let apiRoot = `${subdir}/ghost/api/v2/admin`;
function assetUrl(src) { function assetUrl(src) {
return subdir + src; return subdir + src;

View File

@ -35,7 +35,7 @@ export default function () {
export function testConfig() { export function testConfig() {
this.passthrough('/write-coverage'); // For code coverage this.passthrough('/write-coverage'); // For code coverage
// this.urlPrefix = ''; // make this `http://localhost:8080`, for example, if your API is on a different server // this.urlPrefix = ''; // make this `http://localhost:8080`, for example, if your API is on a different server
this.namespace = '/ghost/api/v0.1'; // make this `api`, for example, if your API is namespaced this.namespace = '/ghost/api/v2/admin'; // make this `api`, for example, if your API is namespaced
// this.timing = 400; // delay for each request, automatically set to 0 during testing // this.timing = 400; // delay for each request, automatically set to 0 during testing
// this.logging = true; // this.logging = true;

View File

@ -3,14 +3,9 @@ import {Response} from 'ember-cli-mirage';
import {isBlank} from '@ember/utils'; import {isBlank} from '@ember/utils';
export default function mockAuthentication(server) { export default function mockAuthentication(server) {
server.post('/authentication/token', function () { server.post('/session', function () {
// Password sign-in // Password sign-in
return { return new Response(201);
access_token: 'MirageAccessToken',
expires_in: 172800,
refresh_token: 'MirageRefreshToken',
token_type: 'Bearer'
};
}); });
server.post('/authentication/passwordreset', function (schema, request) { server.post('/authentication/passwordreset', function (schema, request) {

View File

@ -41,7 +41,6 @@
"coveralls": "3.0.2", "coveralls": "3.0.2",
"csscomb": "4.2.0", "csscomb": "4.2.0",
"current-device": "0.7.8", "current-device": "0.7.8",
"deparam": "1.0.5",
"element-resize-detector": "^1.1.14", "element-resize-detector": "^1.1.14",
"ember-ajax": "3.1.1", "ember-ajax": "3.1.1",
"ember-assign-helper": "0.1.2", "ember-assign-helper": "0.1.2",

View File

@ -1,5 +1,3 @@
import OAuth2Authenticator from 'ghost-admin/authenticators/oauth2';
import deparam from 'npm:deparam';
import destroyApp from '../helpers/destroy-app'; import destroyApp from '../helpers/destroy-app';
import startApp from '../helpers/start-app'; import startApp from '../helpers/start-app';
import windowProxy from 'ghost-admin/utils/window-proxy'; import windowProxy from 'ghost-admin/utils/window-proxy';
@ -25,88 +23,16 @@ describe('Acceptance: Authentication', function () {
beforeEach(function () { beforeEach(function () {
// ensure the /users/me route doesn't error // ensure the /users/me route doesn't error
server.create('user'); server.create('user');
server.get('authentication/setup', function () { server.get('authentication/setup', function () {
return {setup: [{status: false}]}; return {setup: [{status: false}]};
}); });
}); });
it('redirects to setup when setup isn\'t complete', async function () { it('redirects to setup when setup isn\'t complete', async function () {
await visit('settings/labs'); await visit('settings/labs');
expect(currentURL()).to.equal('/setup/one'); expect(currentURL()).to.equal('/setup/one');
}); });
}); });
describe('token handling', function () {
beforeEach(function () {
// replace the default test authenticator with our own authenticator
application.register('authenticator:test', OAuth2Authenticator);
let role = server.create('role', {name: 'Administrator'});
server.create('user', {roles: [role], slug: 'test-user'});
});
it('refreshes tokens on boot if last refreshed > 24hrs ago', async function () {
/* eslint-disable camelcase */
// the tokens here don't matter, we're using the actual oauth
// authenticator so we get the tokens back from the mirage endpoint
await authenticateSession(application, {
access_token: 'access_token',
refresh_token: 'refresh_token'
});
// authenticating the session above will trigger a token refresh
// request so we need to clear it to ensure we aren't testing the
// test behaviour instead of application behaviour
server.pretender.handledRequests = [];
// fake a longer session so it appears that we last refreshed > 24hrs ago
let {__container__: container} = application;
let {session} = container.lookup('service:session');
let newSession = session.get('content');
newSession.authenticated.expires_in = 172800 * 2;
session.get('store').persist(newSession);
/* eslint-enable camelcase */
await visit('/');
let requests = server.pretender.handledRequests;
let refreshRequest = requests.findBy('url', '/ghost/api/v0.1/authentication/token');
expect(refreshRequest, 'token refresh request').to.exist;
expect(refreshRequest.method, 'method').to.equal('POST');
let requestBody = deparam(refreshRequest.requestBody);
expect(requestBody.grant_type, 'grant_type').to.equal('refresh_token');
expect(requestBody.refresh_token, 'refresh_token').to.equal('MirageRefreshToken');
});
it('doesn\'t refresh tokens on boot if last refreshed < 24hrs ago', async function () {
/* eslint-disable camelcase */
// the tokens here don't matter, we're using the actual oauth
// authenticator so we get the tokens back from the mirage endpoint
await authenticateSession(application, {
access_token: 'access_token',
refresh_token: 'refresh_token'
});
/* eslint-enable camelcase */
// authenticating the session above will trigger a token refresh
// request so we need to clear it to ensure we aren't testing the
// test behaviour instead of application behaviour
server.pretender.handledRequests = [];
// we've only just refreshed tokens above so we should always be < 24hrs
await visit('/');
let requests = server.pretender.handledRequests;
let refreshRequest = requests.findBy('url', '/ghost/api/v0.1/authentication/token');
expect(refreshRequest, 'refresh request').to.not.exist;
});
});
describe('general page', function () { describe('general page', function () {
let newLocation; let newLocation;

View File

@ -100,7 +100,7 @@ describe('Acceptance: Error Handling', function () {
describe('logged out', function () { describe('logged out', function () {
it('displays alert', async function () { it('displays alert', async function () {
server.post('/authentication/token', versionMismatchResponse); server.post('/session', versionMismatchResponse);
await visit('/signin'); await visit('/signin');
await fillIn('[name="identification"]', 'test@example.com'); await fillIn('[name="identification"]', 'test@example.com');

View File

@ -200,7 +200,7 @@ describe('Acceptance: Setup', function () {
it('handles invalid origin error on step 2', async function () { it('handles invalid origin error on step 2', async function () {
// mimick the API response for an invalid origin // mimick the API response for an invalid origin
server.post('/authentication/token', function () { server.post('/session', function () {
return new Response(401, {}, { return new Response(401, {}, {
errors: [ errors: [
{ {

View File

@ -1,4 +1,3 @@
import deparam from 'npm:deparam';
import destroyApp from '../helpers/destroy-app'; import destroyApp from '../helpers/destroy-app';
import startApp from '../helpers/start-app'; import startApp from '../helpers/start-app';
import {Response} from 'ember-cli-mirage'; import {Response} from 'ember-cli-mirage';
@ -37,26 +36,16 @@ describe('Acceptance: Signin', function () {
let role = server.create('role', {name: 'Administrator'}); let role = server.create('role', {name: 'Administrator'});
server.create('user', {roles: [role], slug: 'test-user'}); server.create('user', {roles: [role], slug: 'test-user'});
server.post('/authentication/token', function (schema, {requestBody}) { server.post('/session', function (schema, {requestBody}) {
/* eslint-disable camelcase */
let { let {
grant_type: grantType,
username, username,
password, password
client_id: clientId } = JSON.parse(requestBody);
} = deparam(requestBody);
expect(grantType, 'grant type').to.equal('password'); expect(username).to.equal('test@example.com');
expect(username, 'username').to.equal('test@example.com');
expect(clientId, 'client id').to.equal('ghost-admin');
if (password === 'thisissupersafe') { if (password === 'thisissupersafe') {
return { return new Response(201);
access_token: 'MirageAccessToken',
expires_in: 3600,
refresh_token: 'MirageRefreshToken',
token_type: 'Bearer'
};
} else { } else {
return new Response(401, {}, { return new Response(401, {}, {
errors: [{ errors: [{
@ -65,7 +54,6 @@ describe('Acceptance: Signin', function () {
}] }]
}); });
} }
/* eslint-enable camelcase */
}); });
}); });

View File

@ -20,7 +20,7 @@ describe('Integration: Adapter: tag', function () {
}); });
it('loads tags from regular endpoint when all are fetched', function (done) { it('loads tags from regular endpoint when all are fetched', function (done) {
server.get('/ghost/api/v0.1/tags/', function () { server.get('/ghost/api/v2/admin/tags/', function () {
return [200, {'Content-Type': 'application/json'}, JSON.stringify({tags: [ return [200, {'Content-Type': 'application/json'}, JSON.stringify({tags: [
{ {
id: 1, id: 1,
@ -42,7 +42,7 @@ describe('Integration: Adapter: tag', function () {
}); });
it('loads tag from slug endpoint when single tag is queried and slug is passed in', function (done) { it('loads tag from slug endpoint when single tag is queried and slug is passed in', function (done) {
server.get('/ghost/api/v0.1/tags/slug/tag-1/', function () { server.get('/ghost/api/v2/admin/tags/slug/tag-1/', function () {
return [200, {'Content-Type': 'application/json'}, JSON.stringify({tags: [ return [200, {'Content-Type': 'application/json'}, JSON.stringify({tags: [
{ {
id: 1, id: 1,

View File

@ -20,7 +20,7 @@ describe('Integration: Adapter: user', function () {
}); });
it('loads users from regular endpoint when all are fetched', function (done) { it('loads users from regular endpoint when all are fetched', function (done) {
server.get('/ghost/api/v0.1/users/', function () { server.get('/ghost/api/v2/admin/users/', function () {
return [200, {'Content-Type': 'application/json'}, JSON.stringify({users: [ return [200, {'Content-Type': 'application/json'}, JSON.stringify({users: [
{ {
id: 1, id: 1,
@ -42,7 +42,7 @@ describe('Integration: Adapter: user', function () {
}); });
it('loads user from slug endpoint when single user is queried and slug is passed in', function (done) { it('loads user from slug endpoint when single user is queried and slug is passed in', function (done) {
server.get('/ghost/api/v0.1/users/slug/user-1/', function () { server.get('/ghost/api/v2/admin/users/slug/user-1/', function () {
return [200, {'Content-Type': 'application/json'}, JSON.stringify({users: [ return [200, {'Content-Type': 'application/json'}, JSON.stringify({users: [
{ {
id: 1, id: 1,
@ -60,7 +60,7 @@ describe('Integration: Adapter: user', function () {
}); });
it('handles "include" parameter when querying single user via slug', function (done) { it('handles "include" parameter when querying single user via slug', function (done) {
server.get('/ghost/api/v0.1/users/slug/user-1/', (request) => { server.get('/ghost/api/v2/admin/users/slug/user-1/', (request) => {
let params = request.queryParams; let params = request.queryParams;
expect(params.include, 'include query').to.equal('roles,count.posts'); expect(params.include, 'include query').to.equal('roles,count.posts');

View File

@ -18,13 +18,13 @@ const notificationsStub = Service.extend({
}); });
const stubSuccessfulUpload = function (server, delay = 0) { const stubSuccessfulUpload = function (server, delay = 0) {
server.post('/ghost/api/v0.1/uploads/', function () { server.post('/ghost/api/v2/admin/uploads/', function () {
return [200, {'Content-Type': 'application/json'}, '"/content/images/test.png"']; return [200, {'Content-Type': 'application/json'}, '"/content/images/test.png"'];
}, delay); }, delay);
}; };
const stubFailedUpload = function (server, code, error, delay = 0) { const stubFailedUpload = function (server, code, error, delay = 0) {
server.post('/ghost/api/v0.1/uploads/', function () { server.post('/ghost/api/v2/admin/uploads/', function () {
return [code, {'Content-Type': 'application/json'}, JSON.stringify({ return [code, {'Content-Type': 'application/json'}, JSON.stringify({
errors: [{ errors: [{
errorType: error, errorType: error,
@ -43,7 +43,7 @@ describe('Integration: Component: gh-file-uploader', function () {
beforeEach(function () { beforeEach(function () {
server = new Pretender(); server = new Pretender();
this.set('uploadUrl', '/ghost/api/v0.1/uploads/'); this.set('uploadUrl', '/ghost/api/v2/admin/uploads/');
this.register('service:notifications', notificationsStub); this.register('service:notifications', notificationsStub);
this.inject.service('notifications', {as: 'notifications'}); this.inject.service('notifications', {as: 'notifications'});
@ -90,7 +90,7 @@ describe('Integration: Component: gh-file-uploader', function () {
wait().then(() => { wait().then(() => {
expect(server.handledRequests.length).to.equal(1); expect(server.handledRequests.length).to.equal(1);
expect(server.handledRequests[0].url).to.equal('/ghost/api/v0.1/uploads/'); expect(server.handledRequests[0].url).to.equal('/ghost/api/v2/admin/uploads/');
done(); done();
}); });
}); });
@ -214,7 +214,7 @@ describe('Integration: Component: gh-file-uploader', function () {
}); });
it('handles file too large error directly from the web server', function (done) { it('handles file too large error directly from the web server', function (done) {
server.post('/ghost/api/v0.1/uploads/', function () { server.post('/ghost/api/v2/admin/uploads/', function () {
return [413, {}, '']; return [413, {}, ''];
}); });
this.render(hbs`{{gh-file-uploader url=uploadUrl}}`); this.render(hbs`{{gh-file-uploader url=uploadUrl}}`);
@ -240,7 +240,7 @@ describe('Integration: Component: gh-file-uploader', function () {
}); });
it('handles unknown failure', function (done) { it('handles unknown failure', function (done) {
server.post('/ghost/api/v0.1/uploads/', function () { server.post('/ghost/api/v2/admin/uploads/', function () {
return [500, {'Content-Type': 'application/json'}, '']; return [500, {'Content-Type': 'application/json'}, ''];
}); });
this.render(hbs`{{gh-file-uploader url=uploadUrl}}`); this.render(hbs`{{gh-file-uploader url=uploadUrl}}`);

View File

@ -29,13 +29,13 @@ const sessionStub = Service.extend({
}); });
const stubSuccessfulUpload = function (server, delay = 0) { const stubSuccessfulUpload = function (server, delay = 0) {
server.post('/ghost/api/v0.1/uploads/', function () { server.post('/ghost/api/v2/admin/uploads/', function () {
return [200, {'Content-Type': 'application/json'}, '"/content/images/test.png"']; return [200, {'Content-Type': 'application/json'}, '"/content/images/test.png"'];
}, delay); }, delay);
}; };
const stubFailedUpload = function (server, code, error, delay = 0) { const stubFailedUpload = function (server, code, error, delay = 0) {
server.post('/ghost/api/v0.1/uploads/', function () { server.post('/ghost/api/v2/admin/uploads/', function () {
return [code, {'Content-Type': 'application/json'}, JSON.stringify({ return [code, {'Content-Type': 'application/json'}, JSON.stringify({
errors: [{ errors: [{
errorType: error, errorType: error,
@ -89,27 +89,12 @@ describe('Integration: Component: gh-image-uploader', function () {
wait().then(() => { wait().then(() => {
expect(server.handledRequests.length).to.equal(1); expect(server.handledRequests.length).to.equal(1);
expect(server.handledRequests[0].url).to.equal('/ghost/api/v0.1/uploads/'); expect(server.handledRequests[0].url).to.equal('/ghost/api/v2/admin/uploads/');
expect(server.handledRequests[0].requestHeaders.Authorization).to.be.undefined; expect(server.handledRequests[0].requestHeaders.Authorization).to.be.undefined;
done(); done();
}); });
}); });
it('adds authentication headers to request', function (done) {
stubSuccessfulUpload(server);
this.get('sessionService').set('isAuthenticated', true);
this.render(hbs`{{gh-image-uploader image=image update=(action update)}}`);
fileUpload(this.$('input[type="file"]'), ['test'], {name: 'test.png'});
wait().then(() => {
let [request] = server.handledRequests;
expect(request.requestHeaders.Authorization).to.equal('Bearer AccessMe123');
done();
});
});
it('fires update action on successful upload', function (done) { it('fires update action on successful upload', function (done) {
let update = sinon.spy(); let update = sinon.spy();
this.set('update', update); this.set('update', update);
@ -229,7 +214,7 @@ describe('Integration: Component: gh-image-uploader', function () {
}); });
it('handles file too large error directly from the web server', function (done) { it('handles file too large error directly from the web server', function (done) {
server.post('/ghost/api/v0.1/uploads/', function () { server.post('/ghost/api/v2/admin/uploads/', function () {
return [413, {}, '']; return [413, {}, ''];
}); });
this.render(hbs`{{gh-image-uploader image=image update=(action update)}}`); this.render(hbs`{{gh-image-uploader image=image update=(action update)}}`);
@ -255,7 +240,7 @@ describe('Integration: Component: gh-image-uploader', function () {
}); });
it('handles unknown failure', function (done) { it('handles unknown failure', function (done) {
server.post('/ghost/api/v0.1/uploads/', function () { server.post('/ghost/api/v2/admin/uploads/', function () {
return [500, {'Content-Type': 'application/json'}, '']; return [500, {'Content-Type': 'application/json'}, ''];
}); });
this.render(hbs`{{gh-image-uploader image=image update=(action update)}}`); this.render(hbs`{{gh-image-uploader image=image update=(action update)}}`);

View File

@ -10,13 +10,13 @@ import {run} from '@ember/runloop';
import {setupComponentTest} from 'ember-mocha'; import {setupComponentTest} from 'ember-mocha';
const stubSuccessfulUpload = function (server, delay = 0) { const stubSuccessfulUpload = function (server, delay = 0) {
server.post('/ghost/api/v0.1/uploads/', function () { server.post('/ghost/api/v2/admin/uploads/', function () {
return [200, {'Content-Type': 'application/json'}, '"/content/images/test.png"']; return [200, {'Content-Type': 'application/json'}, '"/content/images/test.png"'];
}, delay); }, delay);
}; };
const stubFailedUpload = function (server, code, error, delay = 0) { const stubFailedUpload = function (server, code, error, delay = 0) {
server.post('/ghost/api/v0.1/uploads/', function () { server.post('/ghost/api/v2/admin/uploads/', function () {
return [code, {'Content-Type': 'application/json'}, JSON.stringify({ return [code, {'Content-Type': 'application/json'}, JSON.stringify({
errors: [{ errors: [{
errorType: error, errorType: error,
@ -54,7 +54,7 @@ describe('Integration: Component: gh-uploader', function () {
let [lastRequest] = server.handledRequests; let [lastRequest] = server.handledRequests;
expect(server.handledRequests.length).to.equal(1); expect(server.handledRequests.length).to.equal(1);
expect(lastRequest.url).to.equal('/ghost/api/v0.1/uploads/'); expect(lastRequest.url).to.equal('/ghost/api/v2/admin/uploads/');
// requestBody is a FormData object // requestBody is a FormData object
// this will fail in anything other than Chrome and Firefox // this will fail in anything other than Chrome and Firefox
// https://developer.mozilla.org/en-US/docs/Web/API/FormData#Browser_compatibility // https://developer.mozilla.org/en-US/docs/Web/API/FormData#Browser_compatibility
@ -139,7 +139,7 @@ describe('Integration: Component: gh-uploader', function () {
it('onComplete returns results in same order as selected', async function () { it('onComplete returns results in same order as selected', async function () {
// first request has a delay to simulate larger file // first request has a delay to simulate larger file
server.post('/ghost/api/v0.1/uploads/', function () { server.post('/ghost/api/v2/admin/uploads/', function () {
// second request has no delay to simulate small file // second request has no delay to simulate small file
stubSuccessfulUpload(server, 0); stubSuccessfulUpload(server, 0);
@ -268,7 +268,7 @@ describe('Integration: Component: gh-uploader', function () {
}); });
it('uploads to supplied `uploadUrl`', async function () { it('uploads to supplied `uploadUrl`', async function () {
server.post('/ghost/api/v0.1/images/', function () { server.post('/ghost/api/v2/admin/images/', function () {
return [200, {'Content-Type': 'application/json'}, '"/content/images/test.png"']; return [200, {'Content-Type': 'application/json'}, '"/content/images/test.png"'];
}); });
@ -277,7 +277,7 @@ describe('Integration: Component: gh-uploader', function () {
await wait(); await wait();
let [lastRequest] = server.handledRequests; let [lastRequest] = server.handledRequests;
expect(lastRequest.url).to.equal('/ghost/api/v0.1/images/'); expect(lastRequest.url).to.equal('/ghost/api/v2/admin/images/');
}); });
it('passes supplied paramName in request', async function () { it('passes supplied paramName in request', async function () {

View File

@ -1,6 +1,4 @@
import Pretender from 'pretender'; import Pretender from 'pretender';
import RSVP from 'rsvp';
import Service from '@ember/service';
import config from 'ghost-admin/config/environment'; import config from 'ghost-admin/config/environment';
import {describe, it} from 'mocha'; import {describe, it} from 'mocha';
import {expect} from 'chai'; import {expect} from 'chai';
@ -9,6 +7,7 @@ import {
isUnauthorizedError isUnauthorizedError
} from 'ember-ajax/errors'; } from 'ember-ajax/errors';
import { import {
isMaintenanceError,
isRequestEntityTooLargeError, isRequestEntityTooLargeError,
isUnsupportedMediaTypeError, isUnsupportedMediaTypeError,
isVersionMismatchError isVersionMismatchError
@ -175,96 +174,16 @@ describe('Integration: Service: ajax', function () {
}); });
}); });
/* eslint-disable camelcase */ it('handles error checking for MaintenanceError on 503 errors', function (done) {
describe('session handling', function () { stubAjaxEndpoint(server, {}, 503);
let sessionStub = Service.extend({
isAuthenticated: true,
restoreCalled: false,
authenticated: null,
init() {
this._super(...arguments);
let authenticated = {
expires_at: (new Date()).getTime() - 10000,
access_token: 'AccessMe123',
refresh_token: 'RefreshMe123'
};
this.authenticated = authenticated;
this.data = {authenticated};
},
restore() {
this.restoreCalled = true;
this.authenticated.expires_at = (new Date()).getTime() + 10000;
return RSVP.resolve();
},
authorize() {
}
});
beforeEach(function () {
server.get('/ghost/api/v0.1/test/', function () {
return [
200,
{'Content-Type': 'application/json'},
JSON.stringify({
success: true
})
];
});
server.post('/ghost/api/v0.1/authentication/token', function () {
return [
401,
{'Content-Type': 'application/json'},
JSON.stringify({})
];
});
});
it('can restore an expired session', function (done) {
let ajax = this.subject(); let ajax = this.subject();
ajax.set('session', sessionStub.create());
ajax.request('/ghost/api/v0.1/test/'); ajax.request('/test/').then(() => {
expect(false).to.be.true;
ajax.request('/ghost/api/v0.1/test/').then((result) => { }).catch((error) => {
expect(ajax.get('session.restoreCalled'), 'restoreCalled').to.be.true; expect(isMaintenanceError(error)).to.be.true;
expect(result.success, 'result.success').to.be.true;
done(); done();
}).catch(() => {
expect(true, 'request failed').to.be.false;
done();
});
});
it('errors correctly when session restoration fails', function (done) {
let ajax = this.subject();
let invalidateCalled = false;
ajax.set('session', sessionStub.create());
ajax.set('session.restore', function () {
this.set('restoreCalled', true);
return ajax.post('/ghost/api/v0.1/authentication/token');
});
ajax.set('session.invalidate', function () {
invalidateCalled = true;
});
stubAjaxEndpoint(server, {}, 401);
ajax.request('/ghost/api/v0.1/test/').then(() => {
expect(true, 'request was successful').to.be.false;
done();
}).catch(() => {
// TODO: fix the error return when a session restore fails
// expect(isUnauthorizedError(error)).to.be.true;
expect(ajax.get('session.restoreCalled'), 'restoreCalled').to.be.true;
expect(invalidateCalled, 'invalidateCalled').to.be.true;
done();
});
}); });
}); });
}); });

View File

@ -5,7 +5,7 @@ import {expect} from 'chai';
import {setupTest} from 'ember-mocha'; import {setupTest} from 'ember-mocha';
function stubAvailableTimezonesEndpoint(server) { function stubAvailableTimezonesEndpoint(server) {
server.get('/ghost/api/v0.1/configuration/timezones', function () { server.get('/ghost/api/v2/admin/configuration/timezones', function () {
return [ return [
200, 200,
{'Content-Type': 'application/json'}, {'Content-Type': 'application/json'},
@ -58,7 +58,7 @@ describe('Integration: Service: config', function () {
it('normalizes blogUrl to non-trailing-slash', function (done) { it('normalizes blogUrl to non-trailing-slash', function (done) {
let stubBlogUrl = function stubBlogUrl(blogUrl) { let stubBlogUrl = function stubBlogUrl(blogUrl) {
server.get('/ghost/api/v0.1/configuration/', function () { server.get('/ghost/api/v2/admin/configuration/', function () {
return [ return [
200, 200,
{'Content-Type': 'application/json'}, {'Content-Type': 'application/json'},

View File

@ -16,11 +16,11 @@ function stubSettings(server, labs, validSave = true) {
} }
]; ];
server.get('/ghost/api/v0.1/settings/', function () { server.get('/ghost/api/v2/admin/settings/', function () {
return [200, {'Content-Type': 'application/json'}, JSON.stringify({settings})]; return [200, {'Content-Type': 'application/json'}, JSON.stringify({settings})];
}); });
server.put('/ghost/api/v0.1/settings/', function (request) { server.put('/ghost/api/v2/admin/settings/', function (request) {
let statusCode = (validSave) ? 200 : 400; let statusCode = (validSave) ? 200 : 400;
let response = (validSave) ? request.requestBody : JSON.stringify({ let response = (validSave) ? request.requestBody : JSON.stringify({
errors: [{ errors: [{
@ -46,11 +46,11 @@ function stubUser(server, accessibility, validSave = true) {
}] }]
}]; }];
server.get('/ghost/api/v0.1/users/me/', function () { server.get('/ghost/api/v2/admin/users/me/', function () {
return [200, {'Content-Type': 'application/json'}, JSON.stringify({users})]; return [200, {'Content-Type': 'application/json'}, JSON.stringify({users})];
}); });
server.put('/ghost/api/v0.1/users/1/', function (request) { server.put('/ghost/api/v2/admin/users/1/', function (request) {
let statusCode = (validSave) ? 200 : 400; let statusCode = (validSave) ? 200 : 400;
let response = (validSave) ? request.requestBody : JSON.stringify({ let response = (validSave) ? request.requestBody : JSON.stringify({
errors: [{ errors: [{

View File

@ -5,7 +5,7 @@ import {expect} from 'chai';
import {setupTest} from 'ember-mocha'; import {setupTest} from 'ember-mocha';
function stubSlugEndpoint(server, type, slug) { function stubSlugEndpoint(server, type, slug) {
server.get('/ghost/api/v0.1/slugs/:type/:slug/', function (request) { server.get('/ghost/api/v2/admin/slugs/:type/:slug/', function (request) {
expect(request.params.type).to.equal(type); expect(request.params.type).to.equal(type);
expect(request.params.slug).to.equal(slug); expect(request.params.slug).to.equal(slug);

View File

@ -23,7 +23,7 @@ describe('Integration: Service: store', function () {
let {version} = config.APP; let {version} = config.APP;
let store = this.subject(); let store = this.subject();
server.get('/ghost/api/v0.1/posts/1/', function () { server.get('/ghost/api/v2/admin/posts/1/', function () {
return [ return [
404, 404,
{'Content-Type': 'application/json'}, {'Content-Type': 'application/json'},

View File

@ -0,0 +1,71 @@
import RSVP from 'rsvp';
import Service from '@ember/service';
import sinon from 'sinon';
import {beforeEach, describe, it} from 'mocha';
import {expect} from 'chai';
import {setupTest} from 'ember-mocha';
const mockAjax = Service.extend({
skipSessionDeletion: false,
init() {
this._super(...arguments);
this.post = sinon.stub().resolves();
this.del = sinon.stub().resolves();
}
});
const mockGhostPaths = Service.extend({
apiRoot: '/ghost/api/v2/admin'
});
describe('Unit: Authenticator: cookie', () => {
setupTest('authenticator:cookie', {});
beforeEach(function () {
this.register('service:ajax', mockAjax);
this.inject.service('ajax', {as: 'ajax'});
this.register('service:ghost-paths', mockGhostPaths);
this.inject.service('ghost-paths', {as: 'ghostPaths'});
});
describe('#restore', function () {
it('returns a resolving promise', function () {
return this.subject().restore();
});
});
describe('#authenticate', function () {
it('posts the username and password to the sessionEndpoint and returns the promise', function () {
let authenticator = this.subject();
let post = authenticator.ajax.post;
return authenticator.authenticate('AzureDiamond', 'hunter2').then(() => {
expect(post.args[0][0]).to.equal('/ghost/api/v2/admin/session');
expect(post.args[0][1]).to.deep.include({
data: {
username: 'AzureDiamond',
password: 'hunter2'
}
});
expect(post.args[0][1]).to.deep.include({
dataType: 'text'
});
expect(post.args[0][1]).to.deep.include({
contentType: 'application/json;charset=utf-8'
});
});
});
});
describe('#invalidate', function () {
it('makes a delete request to the sessionEndpoint', function () {
let authenticator = this.subject();
let del = authenticator.ajax.del;
return authenticator.invalidate().then(() => {
expect(del.args[0][0]).to.equal('/ghost/api/v2/admin/session');
});
});
});
});

View File

@ -34,7 +34,7 @@ describe('Unit: Model: invite', function () {
let model = this.subject(); let model = this.subject();
let role; let role;
server.post('/ghost/api/v0.1/invites/', function () { server.post('/ghost/api/v2/admin/invites/', function () {
return [200, {}, '{}']; return [200, {}, '{}'];
}); });

View File

@ -3483,11 +3483,6 @@ delegates@^1.0.0:
resolved "https://registry.yarnpkg.com/delegates/-/delegates-1.0.0.tgz#84c6e159b81904fdca59a0ef44cd870d31250f9a" resolved "https://registry.yarnpkg.com/delegates/-/delegates-1.0.0.tgz#84c6e159b81904fdca59a0ef44cd870d31250f9a"
integrity sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o= integrity sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o=
deparam@1.0.5:
version "1.0.5"
resolved "https://registry.yarnpkg.com/deparam/-/deparam-1.0.5.tgz#74011bbabd26b40f860c3e3cc2cd61bed4eb43f2"
integrity sha1-dAEbur0mtA+GDD48ws1hvtTrQ/I=
depd@1.1.1: depd@1.1.1:
version "1.1.1" version "1.1.1"
resolved "https://registry.yarnpkg.com/depd/-/depd-1.1.1.tgz#5783b4e1c459f06fa5ca27f991f3d06e7a310359" resolved "https://registry.yarnpkg.com/depd/-/depd-1.1.1.tgz#5783b4e1c459f06fa5ca27f991f3d06e7a310359"