diff --git a/examples/article-access.amp.html b/examples/article-access.amp.html index ffe4ce38ebb4..2a1f0013c300 100644 --- a/examples/article-access.amp.html +++ b/examples/article-access.amp.html @@ -5,11 +5,12 @@ Lorem Ipsum | PublisherName + diff --git a/extensions/amp-access/0.1/amp-access-client.js b/extensions/amp-access/0.1/amp-access-client.js new file mode 100644 index 000000000000..d7da52892ae4 --- /dev/null +++ b/extensions/amp-access/0.1/amp-access-client.js @@ -0,0 +1,107 @@ +/** + * Copyright 2016 The AMP HTML Authors. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {assertHttpsUrl} from '../../../src/url'; +import {dev, user} from '../../../src/log'; +import {timer} from '../../../src/timer'; +import {xhrFor} from '../../../src/xhr'; + +/** @const {string} */ +const TAG = 'amp-access-client'; + +/** @const {number} */ +const AUTHORIZATION_TIMEOUT = 3000; + + +/** @implements {AccessTypeAdapterDef} */ +export class AccessClientAdapter { + + /** + * @param {!Window} win + * @param {!JSONObject} configJson + * @param {!AccessTypeAdapterContextDef} context + */ + constructor(win, configJson, context) { + /** @const {!Window} */ + this.win = win; + + /** @const @private {!AccessTypeAdapterContextDef} */ + this.context_ = context; + + /** @const @private {string} */ + this.authorizationUrl_ = user.assert(configJson['authorization'], + '"authorization" URL must be specified'); + assertHttpsUrl(this.authorizationUrl_, '"authorization"'); + + /** @const @private {string} */ + this.pingbackUrl_ = user.assert(configJson['pingback'], + '"pingback" URL must be specified'); + assertHttpsUrl(this.pingbackUrl_, '"pingback"'); + + /** @const @private {!Xhr} */ + this.xhr_ = xhrFor(win); + + /** @const @private {!Timer} */ + this.timer_ = timer; + } + + /** @override */ + getConfig() { + return { + 'authorizationUrl': this.authorizationUrl_, + 'pingbackUrl': this.pingbackUrl_, + }; + } + + /** @override */ + isAuthorizationEnabled() { + return true; + } + + /** @override */ + authorize() { + dev.fine(TAG, 'Start authorization via ', this.authorizationUrl_); + const urlPromise = this.context_.buildUrl(this.authorizationUrl_, + /* useAuthData */ false); + return urlPromise.then(url => { + dev.fine(TAG, 'Authorization URL: ', url); + return this.timer_.timeoutPromise( + AUTHORIZATION_TIMEOUT, + this.xhr_.fetchJson(url, { + credentials: 'include', + requireAmpResponseSourceOrigin: true, + })); + }); + } + + /** @override */ + pingback() { + const promise = this.context_.buildUrl(this.pingbackUrl_, + /* useAuthData */ true); + return promise.then(url => { + dev.fine(TAG, 'Pingback URL: ', url); + return this.xhr_.sendSignal(url, { + method: 'POST', + credentials: 'include', + requireAmpResponseSourceOrigin: true, + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: '', + }); + }); + } +} diff --git a/extensions/amp-access/0.1/amp-access-other.js b/extensions/amp-access/0.1/amp-access-other.js new file mode 100644 index 000000000000..52b9a848c724 --- /dev/null +++ b/extensions/amp-access/0.1/amp-access-other.js @@ -0,0 +1,74 @@ +/** + * Copyright 2016 The AMP HTML Authors. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {dev} from '../../../src/log'; +import {isProxyOrigin} from '../../../src/url'; + +/** @const {string} */ +const TAG = 'amp-access-other'; + + +/** @implements {AccessTypeAdapterDef} */ +export class AccessOtherAdapter { + + /** + * @param {!Window} win + * @param {!JSONObject} configJson + * @param {!AccessTypeAdapterContextDef} context + */ + constructor(win, configJson, context) { + /** @const {!Window} */ + this.win = win; + + /** @const @private {!AccessTypeAdapterContextDef} */ + this.context_ = context; + + /** @private {?JSONObject} */ + this.authorizationResponse_ = + configJson['authorizationFallbackResponse'] || null; + + /** @const @private {boolean} */ + this.isProxyOrigin_ = isProxyOrigin(win.location); + } + + /** @override */ + getConfig() { + return { + 'authorizationResponse': this.authorizationResponse_, + }; + } + + /** @override */ + isAuthorizationEnabled() { + // The `type=other` is allowed to use the authorization fallback, but + // only if it's not on `cdn.ampproject.org`. + return (!!this.authorizationResponse_ && !this.isProxyOrigin_); + } + + /** @override */ + authorize() { + dev.fine(TAG, 'Use the authorization fallback for type=other'); + // Only allowed for proxy origin (`cdn.ampproject.org`). + dev.assert(!this.isProxyOrigin_); + return Promise.resolve(dev.assert(this.authorizationResponse_)); + } + + /** @override */ + pingback() { + dev.fine(TAG, 'Ignore pingback'); + return Promise.resolve(); + } +} diff --git a/extensions/amp-access/0.1/amp-access.js b/extensions/amp-access/0.1/amp-access.js index f51098b644c2..669711552806 100644 --- a/extensions/amp-access/0.1/amp-access.js +++ b/extensions/amp-access/0.1/amp-access.js @@ -14,10 +14,12 @@ * limitations under the License. */ +import {AccessClientAdapter} from './amp-access-client'; +import {AccessOtherAdapter} from './amp-access-other'; import {CSS} from '../../../build/amp-access-0.1.css'; import {actionServiceFor} from '../../../src/action'; import {analyticsFor} from '../../../src/analytics'; -import {assertHttpsUrl, getSourceOrigin, isProxyOrigin} from '../../../src/url'; +import {assertHttpsUrl, getSourceOrigin} from '../../../src/url'; import {cancellation} from '../../../src/error'; import {cidFor} from '../../../src/cid'; import {evaluateAccessExpr} from './access-expr'; @@ -37,25 +39,10 @@ import {urlReplacementsFor} from '../../../src/url-replacements'; import {viewerFor} from '../../../src/viewer'; import {viewportFor} from '../../../src/viewport'; import {vsyncFor} from '../../../src/vsync'; -import {xhrFor} from '../../../src/xhr'; -/** - * The configuration properties are: - * - type: The type of access workflow: client, server or other. - * - authorization: The URL of the Authorization endpoint. - * - pingback: The URL of the Pingback endpoint. - * - loginMap: The URL of the Login Page or a map of URLs. - * - * @typedef {{ - * type: !AccessType, - * authorization: (string|undefined), - * pingback: (string|undefined), - * loginMap: !Object, - * authorizationFallbackResponse: !JSONObject - * }} - */ -let AccessConfigDef; +/** @const */ +const TAG = 'amp-access'; /** * The type of access flow. @@ -67,12 +54,6 @@ const AccessType = { OTHER: 'other', }; -/** @const */ -const TAG = 'amp-access'; - -/** @const {number} */ -const AUTHORIZATION_TIMEOUT = 3000; - /** @const {number} */ const VIEW_TIMEOUT = 2000; @@ -103,24 +84,35 @@ export class AccessService { /** @const @private {!Element} */ this.accessElement_ = accessElement; - /** @const @private {!AccessConfigDef} */ - this.config_ = this.buildConfig_(); + let configJson; + try { + configJson = JSON.parse(this.accessElement_.textContent); + } catch (e) { + throw user.createError('Failed to parse "amp-access" JSON: ' + e); + } + + /** @const @private {!AccessType} */ + this.type_ = this.buildConfigType_(configJson); + + /** @const @private {!Object} */ + this.loginConfig_ = this.buildConfigLoginMap_(configJson); + + /** @const @private {!JSONObject} */ + this.authorizationFallbackResponse_ = + configJson['authorizationFallbackResponse']; + + /** @const @private {!AccessTypeAdapterDef} */ + this.adapter_ = this.createAdapter_(configJson); /** @const @private {string} */ this.pubOrigin_ = getSourceOrigin(win.location); - /** @const @private {boolean} */ - this.isProxyOrigin_ = isProxyOrigin(win.location); - /** @const @private {!Timer} */ this.timer_ = timer; /** @const @private {!Vsync} */ this.vsync_ = vsyncFor(win); - /** @const @private {!Xhr} */ - this.xhr_ = xhrFor(win); - /** @const @private {!UrlReplacements} */ this.urlReplacements_ = urlReplacementsFor(win); @@ -178,57 +170,42 @@ export class AccessService { } /** - * @return {!AccessConfigDef} + * @param {!JSONObject} configJson + * @return {!AccessTypeAdapterDef} * @private */ - buildConfig_() { - let configJson; - try { - configJson = JSON.parse(this.accessElement_.textContent); - } catch (e) { - throw user.createError('Failed to parse "amp-access" JSON: ' + e); + createAdapter_(configJson) { + const context = /** @type {!AccessTypeAdapterContextDef} */ ({ + buildUrl: this.buildUrl_.bind(this), + }); + switch (this.type_) { + case AccessType.CLIENT: + case AccessType.SERVER: + return new AccessClientAdapter(this.win, configJson, context); + case AccessType.OTHER: + return new AccessOtherAdapter(this.win, configJson, context); } + throw dev.createError('Unsuported access type: ', this.type_); + } - // Access type. + /** + * @param {!JSONObject} configJson + * @return {!AccessType} + */ + buildConfigType_(configJson) { const type = configJson['type'] ? user.assertEnumValue(AccessType, configJson['type'], 'access type') : AccessType.CLIENT; - const config = { - type: type, - authorization: configJson['authorization'], - pingback: configJson['pingback'], - loginMap: this.buildConfigLoginMap_(configJson['login']), - authorizationFallbackResponse: - configJson['authorizationFallbackResponse'], - }; - - // Check that all URLs are valid. - if (config.authorization) { - assertHttpsUrl(config.authorization); - } - if (config.pingback) { - assertHttpsUrl(config.pingback); - } - for (const k in config.loginMap) { - assertHttpsUrl(config.loginMap[k]); - } - - // Validate type = client/server. - if (type == AccessType.CLIENT || type == AccessType.SERVER) { - user.assert(config.authorization, - '"authorization" URL must be specified'); - user.assert(config.pingback, '"pingback" URL must be specified'); - user.assert(Object.keys(config.loginMap).length > 0, - 'At least one "login" URL must be specified'); - } - return config; + return type; } /** + * @param {!JSONObject} configJson * @return {?Object} * @private */ - buildConfigLoginMap_(loginConfig) { + buildConfigLoginMap_(configJson) { + const loginConfig = configJson['login']; const loginMap = {}; if (!loginConfig) { // Ignore: in some cases login config is not necessary. @@ -242,6 +219,11 @@ export class AccessService { user.assert(false, '"login" must be either a single URL or a map of URLs'); } + + // Check that all URLs are valid. + for (const k in loginMap) { + assertHttpsUrl(loginMap[k]); + } return loginMap; } @@ -277,7 +259,8 @@ export class AccessService { /** @private */ startInternal_() { - dev.fine(TAG, 'config:', this.config_); + dev.fine(TAG, 'config:', this.type_, this.loginConfig_, + this.adapter_.getConfig()); actionServiceFor(this.win).installActionHandler( this.accessElement_, this.handleAction_.bind(this)); @@ -362,48 +345,24 @@ export class AccessService { * @private */ runAuthorization_(opt_disableFallback) { - if (this.config_.type == AccessType.OTHER && - (!this.config_.authorizationFallbackResponse || this.isProxyOrigin_)) { - // The `type=other` is allowed to use the authorization fallback, but - // only if it's not on `cdn.ampproject.org`. - dev.fine(TAG, 'Ignore authorization due to type=other'); + if (!this.adapter_.isAuthorizationEnabled()) { + dev.fine(TAG, 'Ignore authorization for type=', this.type_); this.firstAuthorizationResolver_(); return Promise.resolve(); } this.toggleTopClass_('amp-access-loading', true); - let responsePromise; - if (this.config_.authorization) { - dev.fine(TAG, 'Start authorization via ', this.config_.authorization); - const urlPromise = this.buildUrl_( - this.config_.authorization, /* useAuthData */ false); - responsePromise = urlPromise.then(url => { - dev.fine(TAG, 'Authorization URL: ', url); - return this.timer_.timeoutPromise( - AUTHORIZATION_TIMEOUT, - this.xhr_.fetchJson(url, { - credentials: 'include', - requireAmpResponseSourceOrigin: true, - })); - }).catch(error => { - this.analyticsEvent_('access-authorization-failed'); - if (this.config_.authorizationFallbackResponse && - !opt_disableFallback) { - // Use fallback. - user.error(TAG, 'Authorization failed: ', error); - return this.config_.authorizationFallbackResponse; - } else { - // Rethrow the error, it will be processed in the bottom `catch`. - throw error; - } - }); - } else { - dev.fine(TAG, 'Use the authorization fallback for type=other'); - dev.assert(this.config_.type == AccessType.OTHER); - dev.assert(!this.isProxyOrigin_); - responsePromise = Promise.resolve(dev.assert( - this.config_.authorizationFallbackResponse)); - } + const responsePromise = this.adapter_.authorize().catch(error => { + this.analyticsEvent_('access-authorization-failed'); + if (this.authorizationFallbackResponse_ && !opt_disableFallback) { + // Use fallback. + user.error(TAG, 'Authorization failed: ', error); + return this.authorizationFallbackResponse_; + } else { + // Rethrow the error, it will be processed in the bottom `catch`. + throw error; + } + }); const promise = responsePromise.then(response => { dev.fine(TAG, 'Authorization response: ', response); this.setAuthResponse_(response); @@ -684,24 +643,7 @@ export class AccessService { * @private */ reportViewToServer_() { - if (!this.config_.pingback) { - dev.fine(TAG, 'Ignore pingback'); - return Promise.resolve(); - } - const promise = this.buildUrl_( - this.config_.pingback, /* useAuthData */ true); - return promise.then(url => { - dev.fine(TAG, 'Pingback URL: ', url); - return this.xhr_.sendSignal(url, { - method: 'POST', - credentials: 'include', - requireAmpResponseSourceOrigin: true, - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - }, - body: '', - }); - }).then(() => { + return this.adapter_.pingback().then(() => { dev.fine(TAG, 'Pingback complete'); this.analyticsEvent_('access-pingback-sent'); }).catch(error => { @@ -762,7 +704,7 @@ export class AccessService { } dev.fine(TAG, 'Start login: ', type); - user.assert(this.config_.loginMap[type], + user.assert(this.loginConfig_[type], 'Login URL is not configured: %s', type); // Login URL should always be available at this time. const loginUrl = user.assert(this.loginUrlMap_[type], @@ -820,23 +762,58 @@ export class AccessService { * @private */ buildLoginUrls_() { - const loginMap = this.config_.loginMap; - if (Object.keys(loginMap).length == 0) { + if (Object.keys(this.loginConfig_).length == 0) { return null; } const promises = []; - for (const k in loginMap) { + for (const k in this.loginConfig_) { promises.push( - this.buildUrl_(loginMap[k], /* useAuthData */ true).then(url => { - this.loginUrlMap_[k] = url; - return {type: k, url: url}; - })); + this.buildUrl_(this.loginConfig_[k], /* useAuthData */ true) + .then(url => { + this.loginUrlMap_[k] = url; + return {type: k, url: url}; + })); } return Promise.all(promises); } } +/** + * @typedef {{ + * buildUrl: function(url:string, useAuthData:boolean):!Promise + * }} + */ +let AccessTypeAdapterContextDef; + + +/** + * @interface + */ +class AccessTypeAdapterDef { + + /** + * @return {!JSONObject} + */ + getConfig() {} + + /** + * @return {boolean} + */ + isAuthorizationEnabled() {} + + /** + * @return {!Promise} + */ + authorize() {} + + /** + * @return {!Promise<>} + */ + pingback() {} +} + + /** * @param {!Window} win * @return {!AccessService} diff --git a/extensions/amp-access/0.1/test/test-amp-access-client.js b/extensions/amp-access/0.1/test/test-amp-access-client.js new file mode 100644 index 000000000000..5234de088c05 --- /dev/null +++ b/extensions/amp-access/0.1/test/test-amp-access-client.js @@ -0,0 +1,221 @@ +/** + * Copyright 2016 The AMP HTML Authors. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {AccessClientAdapter} from '../amp-access-client'; +import * as sinon from 'sinon'; + +describe('AccessClientAdapter', () => { + + let sandbox; + let clock; + let validConfig; + let context; + let contextMock; + + beforeEach(() => { + sandbox = sinon.sandbox.create(); + clock = sandbox.useFakeTimers(); + + validConfig = { + 'authorization': 'https://acme.com/a?rid=READER_ID', + 'pingback': 'https://acme.com/p?rid=READER_ID', + }; + + context = { + buildUrl: () => {}, + }; + contextMock = sandbox.mock(context); + }); + + afterEach(() => { + contextMock.verify(); + sandbox.restore(); + }); + + + describe('config', () => { + it('should load valid config', () => { + const adapter = new AccessClientAdapter(window, validConfig, context); + expect(adapter.authorizationUrl_).to + .equal('https://acme.com/a?rid=READER_ID'); + expect(adapter.pingbackUrl_).to + .equal('https://acme.com/p?rid=READER_ID'); + expect(adapter.getConfig()).to.deep.equal({ + authorizationUrl: 'https://acme.com/a?rid=READER_ID', + pingbackUrl: 'https://acme.com/p?rid=READER_ID', + }); + expect(adapter.isAuthorizationEnabled()).to.be.true; + }); + + it('should fail if config authorization is missing or malformed', () => { + delete validConfig['authorization']; + expect(() => { + new AccessClientAdapter(window, validConfig, context); + }).to.throw(/"authorization" URL must be specified/); + + validConfig['authorization'] = 'http://acme.com/a'; + expect(() => { + new AccessClientAdapter(window, validConfig, context); + }).to.throw(/"authorization".*https\:/); + }); + + it('should fail if config pingback is missing or malformed', () => { + delete validConfig['pingback']; + expect(() => { + new AccessClientAdapter(window, validConfig, context); + }).to.throw(/"pingback" URL must be specified/); + + validConfig['pingback'] = 'http://acme.com/p'; + expect(() => { + new AccessClientAdapter(window, validConfig, context); + }).to.throw(/"pingback".*https\:/); + }); + }); + + + describe('runtime', () => { + + let adapter; + let xhrMock; + + beforeEach(() => { + adapter = new AccessClientAdapter(window, validConfig, context); + xhrMock = sandbox.mock(adapter.xhr_); + }); + + afterEach(() => { + xhrMock.verify(); + }); + + describe('authorize', () => { + it('should issue XHR fetch', () => { + contextMock.expects('buildUrl') + .withExactArgs( + 'https://acme.com/a?rid=READER_ID', + /* useAuthData */ false) + .returns(Promise.resolve('https://acme.com/a?rid=reader1')) + .once(); + xhrMock.expects('fetchJson') + .withExactArgs('https://acme.com/a?rid=reader1', { + credentials: 'include', + requireAmpResponseSourceOrigin: true, + }) + .returns(Promise.resolve({access: 'A'})) + .once(); + return adapter.authorize().then(response => { + expect(response).to.exist; + expect(response.access).to.equal('A'); + }); + }); + + it('should fail when XHR fails', () => { + contextMock.expects('buildUrl') + .withExactArgs( + 'https://acme.com/a?rid=READER_ID', + /* useAuthData */ false) + .returns(Promise.resolve('https://acme.com/a?rid=reader1')) + .once(); + xhrMock.expects('fetchJson') + .withExactArgs('https://acme.com/a?rid=reader1', { + credentials: 'include', + requireAmpResponseSourceOrigin: true, + }) + .returns(Promise.reject('intentional')) + .once(); + return adapter.authorize().then(() => { + throw new Error('must never happen'); + }, error => { + expect(error).to.match(/intentional/); + }); + }); + + it('should time out XHR fetch', () => { + contextMock.expects('buildUrl') + .withExactArgs( + 'https://acme.com/a?rid=READER_ID', + /* useAuthData */ false) + .returns(Promise.resolve('https://acme.com/a?rid=reader1')) + .once(); + xhrMock.expects('fetchJson') + .withExactArgs('https://acme.com/a?rid=reader1', { + credentials: 'include', + requireAmpResponseSourceOrigin: true, + }) + .returns(new Promise(() => {})) // Never resolved. + .once(); + const promise = adapter.authorize(); + return Promise.resolve().then(() => { + clock.tick(3001); + return promise; + }).then(() => { + throw new Error('must never happen'); + }, error => { + expect(error).to.match(/timeout/); + }); + }); + }); + + describe('pingback', () => { + it('should send POST pingback', () => { + contextMock.expects('buildUrl') + .withExactArgs( + 'https://acme.com/p?rid=READER_ID', + /* useAuthData */ true) + .returns(Promise.resolve('https://acme.com/p?rid=reader1')) + .once(); + xhrMock.expects('sendSignal') + .withExactArgs('https://acme.com/p?rid=reader1', + sinon.match(init => { + return (init.method == 'POST' && + init.credentials == 'include' && + init.requireAmpResponseSourceOrigin == true && + init.body == '' && + init.headers['Content-Type'] == + 'application/x-www-form-urlencoded'); + })) + .returns(Promise.resolve()) + .once(); + return adapter.pingback(); + }); + + it('should fail when POST fails', () => { + contextMock.expects('buildUrl') + .withExactArgs( + 'https://acme.com/p?rid=READER_ID', + /* useAuthData */ true) + .returns(Promise.resolve('https://acme.com/p?rid=reader1')) + .once(); + xhrMock.expects('sendSignal') + .withExactArgs('https://acme.com/p?rid=reader1', + sinon.match(init => { + return (init.method == 'POST' && + init.credentials == 'include' && + init.requireAmpResponseSourceOrigin == true && + init.body == '' && + init.headers['Content-Type'] == + 'application/x-www-form-urlencoded'); + })) + .returns(Promise.reject('intentional')) + .once(); + return adapter.pingback().then(() => { + throw new Error('must never happen'); + }, error => { + expect(error).to.match(/intentional/); + }); + }); + }); + }); +}); diff --git a/extensions/amp-access/0.1/test/test-amp-access-other.js b/extensions/amp-access/0.1/test/test-amp-access-other.js new file mode 100644 index 000000000000..8375dc4fb241 --- /dev/null +++ b/extensions/amp-access/0.1/test/test-amp-access-other.js @@ -0,0 +1,127 @@ +/** + * Copyright 2016 The AMP HTML Authors. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {AccessOtherAdapter} from '../amp-access-other'; +import * as sinon from 'sinon'; + +describe('AccessOtherAdapter', () => { + + let sandbox; + let validConfig; + let context; + let contextMock; + + beforeEach(() => { + sandbox = sinon.sandbox.create(); + + validConfig = {}; + + context = { + buildUrl: () => {}, + }; + contextMock = sandbox.mock(context); + }); + + afterEach(() => { + contextMock.verify(); + sandbox.restore(); + }); + + + describe('config', () => { + it('should load valid config', () => { + const adapter = new AccessOtherAdapter(window, validConfig, context); + expect(adapter.authorizationResponse_).to.be.null; + expect(adapter.getConfig()).to.deep.equal({ + authorizationResponse: null, + }); + expect(adapter.isProxyOrigin_).to.be.false; + }); + + it('should load valid config with fallback object', () => { + const obj = {'access': 'A'}; + validConfig['authorizationFallbackResponse'] = obj; + const adapter = new AccessOtherAdapter(window, validConfig, context); + expect(adapter.authorizationResponse_).to.be.equal(obj); + expect(adapter.getConfig()).to.deep.equal({ + authorizationResponse: obj, + }); + expect(adapter.isProxyOrigin_).to.be.false; + }); + }); + + + describe('runtime', () => { + let adapter; + + beforeEach(() => { + adapter = new AccessOtherAdapter(window, {}, context); + }); + + afterEach(() => { + }); + + it('should disable authorization without fallback object', () => { + adapter.authorizationResponse_ = null; + + adapter.isProxyOrigin_ = false; + expect(adapter.isAuthorizationEnabled()).to.be.false; + + adapter.isProxyOrigin_ = true; + expect(adapter.isAuthorizationEnabled()).to.be.false; + }); + + it('should disable authorization on proxy', () => { + adapter.isProxyOrigin_ = true; + + adapter.authorizationResponse_ = null; + expect(adapter.isAuthorizationEnabled()).to.be.false; + + adapter.authorizationResponse_ = {}; + expect(adapter.isAuthorizationEnabled()).to.be.false; + }); + + it('should enable authorization when not on proxy and with auth', () => { + adapter.isProxyOrigin_ = false; + adapter.authorizationResponse_ = {}; + expect(adapter.isAuthorizationEnabled()).to.be.true; + }); + + it('should fail authorization on proxy', () => { + adapter.isProxyOrigin_ = true; + adapter.authorizationResponse_ = {}; + contextMock.expects('buildUrl').never(); + expect(() => { + adapter.authorize(); + }).to.throw(); + }); + + it('should respond to authorization when not on proxy proxy', () => { + adapter.isProxyOrigin_ = false; + const obj = {'access': 'A'}; + adapter.authorizationResponse_ = obj; + contextMock.expects('buildUrl').never(); + return adapter.authorize().then(response => { + expect(response).to.equal(obj); + }); + }); + + it('should short-circuit pingback flow', () => { + contextMock.expects('buildUrl').never(); + return adapter.pingback(); + }); + }); +}); diff --git a/extensions/amp-access/0.1/test/test-amp-access.js b/extensions/amp-access/0.1/test/test-amp-access.js index e8be957a8558..c7f1bc48b186 100644 --- a/extensions/amp-access/0.1/test/test-amp-access.js +++ b/extensions/amp-access/0.1/test/test-amp-access.js @@ -14,6 +14,8 @@ * limitations under the License. */ +import {AccessClientAdapter} from '../amp-access-client'; +import {AccessOtherAdapter} from '../amp-access-other'; import {AccessService} from '../amp-access'; import {Observable} from '../../../../src/observable'; import {installCidService} from '../../../../src/service/cid-impl'; @@ -50,7 +52,6 @@ describe('AccessService', () => { const service = new AccessService(window); expect(service.isEnabled()).to.be.false; expect(service.accessElement_).to.be.undefined; - expect(service.config_).to.be.undefined; }); it('should fail if config is malformed', () => { @@ -59,51 +60,21 @@ describe('AccessService', () => { }).to.throw(Error); }); - it('should fail if config authorization is missing or malformed', () => { - const config = { - 'login': 'https://acme.org/l', - }; + it('should default to "client" and fail if authorization is missing', () => { + const config = {}; element.textContent = JSON.stringify(config); expect(() => { new AccessService(window); }).to.throw(/"authorization" URL must be specified/); - - config['authorization'] = 'http://acme.com/a'; - element.textContent = JSON.stringify(config); - expect(() => { - new AccessService(window); - }).to.throw(/https\:/); }); - it('should fail if config pingback is missing or malformed', () => { - const config = { - 'authorization': 'https://acme.com/a', - 'login': 'https://acme.org/l', - }; - element.textContent = JSON.stringify(config); - expect(() => { - new AccessService(window); - }).to.throw(/"pingback" URL must be specified/); - - config['pingback'] = 'http://acme.com/p'; - element.textContent = JSON.stringify(config); - expect(() => { - new AccessService(window); - }).to.throw(/https\:/); - }); - - it('should fail if config login is missing or malformed', () => { + it('should fail if config login is malformed', () => { const config = { 'authorization': 'https://acme.com/a', 'pingback': 'https://acme.com/p', + 'login': 'http://acme.com/l', }; element.textContent = JSON.stringify(config); - expect(() => { - new AccessService(window); - }).to.throw(/At least one "login" URL must be specified/); - - config['login'] = 'http://acme.com/l'; - element.textContent = JSON.stringify(config); expect(() => { new AccessService(window); }).to.throw(/https\:/); @@ -119,9 +90,10 @@ describe('AccessService', () => { const service = new AccessService(window); expect(service.isEnabled()).to.be.true; expect(service.accessElement_).to.equal(element); - expect(service.config_.authorization).to.equal('https://acme.com/a'); - expect(service.config_.pingback).to.equal('https://acme.com/p'); - expect(service.config_.loginMap).to.deep.equal({'': 'https://acme.com/l'}); + expect(service.type_).to.equal('client'); + expect(service.loginConfig_).to.deep.equal({'': 'https://acme.com/l'}); + expect(service.adapter_).to.be.instanceOf(AccessClientAdapter); + expect(service.adapter_.authorizationUrl_).to.equal('https://acme.com/a'); }); it('should parse multiple login URLs', () => { @@ -136,40 +108,40 @@ describe('AccessService', () => { element.textContent = JSON.stringify(config); const service = new AccessService(window); expect(service.isEnabled()).to.be.true; - expect(service.config_.loginMap).to.deep.equal({ + expect(service.loginConfig_).to.deep.equal({ 'login1': 'https://acme.com/l1', 'login2': 'https://acme.com/l2', }); }); - it('should default type to "client"', () => { + it('should parse type', () => { const config = { 'authorization': 'https://acme.com/a', 'pingback': 'https://acme.com/p', 'login': 'https://acme.com/l', }; element.textContent = JSON.stringify(config); - const service = new AccessService(window); - expect(service.config_.type).to.equal('client'); - }); + expect(new AccessService(window).type_).to.equal('client'); + expect(new AccessService(window).adapter_).to.be + .instanceOf(AccessClientAdapter); - it('should parse type', () => { - const config = { - 'type': 'client', - 'authorization': 'https://acme.com/a', - 'pingback': 'https://acme.com/p', - 'login': 'https://acme.com/l', - }; + config['type'] = 'client'; element.textContent = JSON.stringify(config); - expect(new AccessService(window).config_.type).to.equal('client'); + expect(new AccessService(window).type_).to.equal('client'); + expect(new AccessService(window).adapter_).to.be + .instanceOf(AccessClientAdapter); config['type'] = 'server'; element.textContent = JSON.stringify(config); - expect(new AccessService(window).config_.type).to.equal('server'); + expect(new AccessService(window).type_).to.equal('server'); + expect(new AccessService(window).adapter_).to.be + .instanceOf(AccessClientAdapter); config['type'] = 'other'; element.textContent = JSON.stringify(config); - expect(new AccessService(window).config_.type).to.equal('other'); + expect(new AccessService(window).type_).to.equal('other'); + expect(new AccessService(window).adapter_).to.be + .instanceOf(AccessOtherAdapter); }); it('should fail if type is unknown', () => { @@ -236,20 +208,97 @@ describe('AccessService', () => { 'authorizationFallbackResponse': {'error': true}, }); const service = new AccessService(window); - expect(service.config_.authorizationFallbackResponse).to.deep.equal( + expect(service.authorizationFallbackResponse_).to.deep.equal( {'error': true}); }); }); +describe('AccessService adapter context', () => { + + let sandbox; + let clock; + let configElement; + let service; + let context; + + beforeEach(() => { + sandbox = sinon.sandbox.create(); + clock = sandbox.useFakeTimers(); + clock.tick(0); + + markElementScheduledForTesting(window, 'amp-analytics'); + installCidService(window); + + configElement = document.createElement('script'); + configElement.setAttribute('id', 'amp-access'); + configElement.setAttribute('type', 'application/json'); + configElement.textContent = JSON.stringify({ + 'authorization': 'https://acme.com/a?rid=READER_ID', + 'pingback': 'https://acme.com/p?rid=READER_ID', + }); + document.body.appendChild(configElement); + + service = new AccessService(window); + service.readerIdPromise_ = Promise.resolve('reader1'); + context = service.adapter_.context_; + }); + + afterEach(() => { + if (configElement.parentElement) { + configElement.parentElement.removeChild(configElement); + } + sandbox.restore(); + }); + + it('should resolve URL without auth response and no authdata vars', () => { + return context.buildUrl('?rid=READER_ID&type=AUTHDATA(child.type)', + /* useAuthData */ false).then(url => { + expect(url).to.equal('?rid=reader1&type='); + }); + }); + + it('should resolve URL without auth response and with authdata vars', () => { + return context.buildUrl('?rid=READER_ID&type=AUTHDATA(child.type)', + /* useAuthData */ true).then(url => { + expect(url).to.equal('?rid=reader1&type='); + }); + }); + + it('should resolve URL with auth response and no authdata vars', () => { + service.setAuthResponse_({child: {type: 'premium'}}); + return context.buildUrl('?rid=READER_ID&type=AUTHDATA(child.type)', + /* useAuthData */ false).then(url => { + expect(url).to.equal('?rid=reader1&type='); + }); + }); + + it('should resolve URL with auth response and with authdata vars', () => { + service.setAuthResponse_({child: {type: 'premium'}}); + return context.buildUrl('?rid=READER_ID&type=AUTHDATA(child.type)', + /* useAuthData */ true).then(url => { + expect(url).to.equal('?rid=reader1&type=premium'); + }); + }); + + it('should resolve URL with unknown authdata var', () => { + service.setAuthResponse_({child: {type: 'premium'}}); + return context.buildUrl('?rid=READER_ID&type=AUTHDATA(child.type2)', + /* useAuthData */ true).then(url => { + expect(url).to.equal('?rid=reader1&type='); + }); + }); +}); + + describe('AccessService authorization', () => { let sandbox; let clock; let configElement, elementOn, elementOff, elementError; - let xhrMock; let cidMock; let analyticsMock; + let adapterMock; beforeEach(() => { sandbox = sinon.sandbox.create(); @@ -285,6 +334,14 @@ describe('AccessService authorization', () => { service = new AccessService(window); + const adapter = { + getConfig: () => {}, + isAuthorizationEnabled: () => true, + authorize: () => {}, + }; + service.adapter_ = adapter; + adapterMock = sandbox.mock(adapter); + sandbox.stub(service.resources_, 'mutateElement', (unusedElement, mutator) => { mutator(); @@ -299,7 +356,6 @@ describe('AccessService authorization', () => { return Promise.resolve(); }, }; - xhrMock = sandbox.mock(service.xhr_); const cid = { get: () => {}, }; @@ -326,6 +382,7 @@ describe('AccessService authorization', () => { if (elementError.parentElement) { elementError.parentElement.removeChild(elementError); } + adapterMock.verify(); analyticsMock.verify(); sandbox.restore(); }); @@ -339,13 +396,32 @@ describe('AccessService authorization', () => { .once(); } + it('should short-circuit authorization flow when disabled', () => { + adapterMock.expects('isAuthorizationEnabled') + .withExactArgs() + .returns(false) + .once(); + adapterMock.expects('authorize').never(); + cidMock.expects('get').never(); + const promise = service.runAuthorization_(); + expect(document.documentElement).not.to.have.class('amp-access-loading'); + expect(document.documentElement).not.to.have.class('amp-access-error'); + return promise.then(() => { + expect(document.documentElement).not.to.have.class('amp-access-loading'); + expect(document.documentElement).not.to.have.class('amp-access-error'); + expect(service.firstAuthorizationPromise_).to.exist; + return service.firstAuthorizationPromise_; + }).then(() => { + expect(service.lastAuthorizationPromise_).to.equal( + service.firstAuthorizationPromise_); + expect(service.authResponse_).to.be.null; + }); + }); + it('should run authorization flow', () => { expectGetReaderId('reader1'); - xhrMock.expects('fetchJson') - .withExactArgs('https://acme.com/a?rid=reader1', { - credentials: 'include', - requireAmpResponseSourceOrigin: true, - }) + adapterMock.expects('authorize') + .withExactArgs() .returns(Promise.resolve({access: true})) .once(); service.buildLoginUrls_ = sandbox.spy(); @@ -372,11 +448,8 @@ describe('AccessService authorization', () => { it('should recover from authorization failure', () => { expectGetReaderId('reader1'); - xhrMock.expects('fetchJson') - .withExactArgs('https://acme.com/a?rid=reader1', { - credentials: 'include', - requireAmpResponseSourceOrigin: true, - }) + adapterMock.expects('authorize') + .withExactArgs() .returns(Promise.reject('intentional')) .once(); const promise = service.runAuthorization_(); @@ -392,11 +465,8 @@ describe('AccessService authorization', () => { it('should NOT resolve last promise until first success', () => { expectGetReaderId('reader1'); - xhrMock.expects('fetchJson') - .withExactArgs('https://acme.com/a?rid=reader1', { - credentials: 'include', - requireAmpResponseSourceOrigin: true, - }) + adapterMock.expects('authorize') + .withExactArgs() .returns(Promise.reject('intentional')) .once(); const promise = service.runAuthorization_(); @@ -422,42 +492,13 @@ describe('AccessService authorization', () => { }); }); - it('should time out authorization flow', () => { - expectGetReaderId('reader1'); - xhrMock.expects('fetchJson') - .withExactArgs('https://acme.com/a?rid=reader1', { - credentials: 'include', - requireAmpResponseSourceOrigin: true, - }) - .returns(new Promise(() => {})) - .once(); - service.buildLoginUrls_ = sandbox.spy(); - let actualTimeoutDelay; - sandbox.stub(service.timer_, 'delay', (callback, delay) => { - actualTimeoutDelay = delay; - callback(); - }); - const promise = service.runAuthorization_(); - expect(document.documentElement).to.have.class('amp-access-loading'); - expect(document.documentElement).not.to.have.class('amp-access-error'); - return promise.then(() => { - expect(document.documentElement).not.to.have.class('amp-access-loading'); - expect(document.documentElement).to.have.class('amp-access-error'); - expect(service.authResponse_).to.not.exist; - expect(actualTimeoutDelay).to.equal(3000); - }); - }); - it('should use fallback on authorization failure when available', () => { expectGetReaderId('reader1'); - xhrMock.expects('fetchJson') - .withExactArgs('https://acme.com/a?rid=reader1', { - credentials: 'include', - requireAmpResponseSourceOrigin: true, - }) + adapterMock.expects('authorize') + .withExactArgs() .returns(Promise.reject('intentional')) .once(); - service.config_.authorizationFallbackResponse = {'error': true}; + service.authorizationFallbackResponse_ = {'error': true}; const promise = service.runAuthorization_(); expect(document.documentElement).to.have.class('amp-access-loading'); expect(document.documentElement).not.to.have.class('amp-access-error'); @@ -472,14 +513,11 @@ describe('AccessService authorization', () => { it('should NOT fallback on authorization failure when disabled', () => { expectGetReaderId('reader1'); - xhrMock.expects('fetchJson') - .withExactArgs('https://acme.com/a?rid=reader1', { - credentials: 'include', - requireAmpResponseSourceOrigin: true, - }) + adapterMock.expects('authorize') + .withExactArgs() .returns(Promise.reject('intentional')) .once(); - service.config_.authorizationFallbackResponse = {'error': true}; + service.authorizationFallbackResponse_ = {'error': true}; const promise = service.runAuthorization_(/* disableFallback */ true); expect(document.documentElement).to.have.class('amp-access-loading'); expect(document.documentElement).not.to.have.class('amp-access-error'); @@ -490,11 +528,8 @@ describe('AccessService authorization', () => { it('should resolve first-authorization promise after success', () => { expectGetReaderId('reader1'); - xhrMock.expects('fetchJson') - .withExactArgs('https://acme.com/a?rid=reader1', { - credentials: 'include', - requireAmpResponseSourceOrigin: true, - }) + adapterMock.expects('authorize') + .withExactArgs() .returns(Promise.resolve({access: true})) .once(); analyticsMock.expects('triggerEvent') @@ -508,11 +543,8 @@ describe('AccessService authorization', () => { it('should NOT resolve first-authorization promise after failure', () => { expectGetReaderId('reader1'); - xhrMock.expects('fetchJson') - .withExactArgs('https://acme.com/a?rid=reader1', { - credentials: 'include', - requireAmpResponseSourceOrigin: true, - }) + adapterMock.expects('authorize') + .withExactArgs() .returns(Promise.reject('intentional')) .once(); analyticsMock.expects('triggerEvent') @@ -703,7 +735,7 @@ describe('AccessService pingback', () => { let sandbox; let clock; let configElement; - let xhrMock; + let adapterMock; let cidMock; let analytics; let analyticsMock; @@ -730,7 +762,11 @@ describe('AccessService pingback', () => { service = new AccessService(window); - xhrMock = sandbox.mock(service.xhr_); + const adapter = { + pingback: () => {}, + }; + service.adapter_ = adapter; + adapterMock = sandbox.mock(adapter); const cid = { get: () => {}, @@ -769,6 +805,7 @@ describe('AccessService pingback', () => { if (configElement.parentElement) { configElement.parentElement.removeChild(configElement); } + adapterMock.verify(); analyticsMock.verify(); sandbox.restore(); }); @@ -974,16 +1011,8 @@ describe('AccessService pingback', () => { it('should send POST pingback', () => { expectGetReaderId('reader1'); - xhrMock.expects('sendSignal') - .withExactArgs('https://acme.com/p?rid=reader1&type=', - sinon.match(init => { - return (init.method == 'POST' && - init.credentials == 'include' && - init.requireAmpResponseSourceOrigin == true && - init.body == '' && - init.headers['Content-Type'] == - 'application/x-www-form-urlencoded'); - })) + adapterMock.expects('pingback') + .withExactArgs() .returns(Promise.resolve()) .once(); analyticsMock.expects('triggerEvent') @@ -998,34 +1027,10 @@ describe('AccessService pingback', () => { }); }); - it('should resolve AUTH vars in POST pingback', () => { - expectGetReaderId('reader1'); - service.setAuthResponse_({child: {type: 'premium'}}); - xhrMock.expects('sendSignal') - .withArgs('https://acme.com/p?rid=reader1&type=premium') - .returns(Promise.resolve()) - .once(); - return service.reportViewToServer_().then(() => { - return 'SUCCESS'; - }, error => { - return 'ERROR ' + error; - }).then(result => { - expect(result).to.equal('SUCCESS'); - }); - }); - it('should NOT send analytics event if postback failed', () => { expectGetReaderId('reader1'); - xhrMock.expects('sendSignal') - .withExactArgs('https://acme.com/p?rid=reader1&type=', - sinon.match(init => { - return (init.method == 'POST' && - init.credentials == 'include' && - init.requireAmpResponseSourceOrigin == true && - init.body == '' && - init.headers['Content-Type'] == - 'application/x-www-form-urlencoded'); - })) + adapterMock.expects('pingback') + .withExactArgs() .returns(Promise.reject('intentional')) .once(); analyticsMock.expects('triggerEvent') @@ -1149,7 +1154,7 @@ describe('AccessService login', () => { }); it('should build multiple login url', () => { - service.config_.loginMap = { + service.loginConfig_ = { 'login1': 'https://acme.com/l1?rid=READER_ID', 'login2': 'https://acme.com/l2?rid=READER_ID', }; @@ -1185,7 +1190,7 @@ describe('AccessService login', () => { }); it('should build login url with RETURN_URL', () => { - service.config_.loginMap[''] = + service.loginConfig_[''] = 'https://acme.com/l?rid=READER_ID&ret=RETURN_URL'; cidMock.expects('get') .withExactArgs( @@ -1315,7 +1320,7 @@ describe('AccessService login', () => { }); it('should succeed login with success=true with multiple logins', () => { - service.config_.loginMap = { + service.loginConfig_ = { 'login1': 'https://acme.com/l1?rid=READER_ID', 'login2': 'https://acme.com/l2?rid=READER_ID', }; @@ -1488,122 +1493,3 @@ describe('AccessService analytics', () => { }); }); }); - - -describe('AccessService type=other', () => { - - let sandbox; - let configElement; - let xhrMock; - let cidMock; - - beforeEach(() => { - sandbox = sinon.sandbox.create(); - - markElementScheduledForTesting(window, 'amp-analytics'); - installCidService(window); - - configElement = document.createElement('script'); - configElement.setAttribute('id', 'amp-access'); - configElement.setAttribute('type', 'application/json'); - configElement.textContent = JSON.stringify({'type': 'other'}); - document.body.appendChild(configElement); - document.documentElement.classList.remove('amp-access-error'); - - service = new AccessService(window); - - service.vsync_ = { - mutate: callback => { - callback(); - }, - mutatePromise: callback => { - callback(); - return Promise.resolve(); - }, - }; - xhrMock = sandbox.mock(service.xhr_); - const cid = { - get: () => {}, - }; - cidMock = sandbox.mock(cid); - service.cid_ = Promise.resolve(cid); - }); - - afterEach(() => { - if (configElement.parentElement) { - configElement.parentElement.removeChild(configElement); - } - sandbox.restore(); - }); - - it('should short-circuit authorization flow', () => { - cidMock.expects('get').never(); - xhrMock.expects('fetchJson').never(); - const promise = service.runAuthorization_(); - expect(document.documentElement).not.to.have.class('amp-access-loading'); - expect(document.documentElement).not.to.have.class('amp-access-error'); - return promise.then(() => { - expect(document.documentElement).not.to.have.class('amp-access-loading'); - expect(document.documentElement).not.to.have.class('amp-access-error'); - expect(service.firstAuthorizationPromise_).to.exist; - return service.firstAuthorizationPromise_; - }).then(() => { - expect(service.lastAuthorizationPromise_).to.equal( - service.firstAuthorizationPromise_); - expect(service.authResponse_).to.be.null; - }); - }); - - it('should ignore fallback in a proxy case', () => { - expect(service.isProxyOrigin_).to.be.false; // Normally `false` in tests. - service.isProxyOrigin_ = true; - const fallback = {}; - service.config_.authorizationFallbackResponse = fallback; - cidMock.expects('get').never(); - xhrMock.expects('fetchJson').never(); - const promise = service.runAuthorization_(); - expect(document.documentElement).not.to.have.class('amp-access-loading'); - expect(document.documentElement).not.to.have.class('amp-access-error'); - return promise.then(() => { - expect(document.documentElement).not.to.have.class('amp-access-loading'); - expect(document.documentElement).not.to.have.class('amp-access-error'); - expect(service.firstAuthorizationPromise_).to.exist; - return service.firstAuthorizationPromise_; - }).then(() => { - expect(service.lastAuthorizationPromise_).to.equal( - service.firstAuthorizationPromise_); - expect(service.authResponse_).to.be.null; - }); - }); - - it('should allow fallback use in a non-proxy case', () => { - service.isProxyOrigin_ = false; - const fallback = {}; - service.config_.authorizationFallbackResponse = fallback; - cidMock.expects('get').never(); - xhrMock.expects('fetchJson').never(); - const promise = service.runAuthorization_(); - expect(document.documentElement).to.have.class('amp-access-loading'); - expect(document.documentElement).not.to.have.class('amp-access-error'); - const lastPromise = service.lastAuthorizationPromise_; - return promise.then(() => { - expect(document.documentElement).not.to.have.class('amp-access-loading'); - expect(document.documentElement).not.to.have.class('amp-access-error'); - expect(service.firstAuthorizationPromise_).to.exist; - return service.firstAuthorizationPromise_; - }).then(() => { - expect(service.lastAuthorizationPromise_).to.equal(lastPromise); - expect(service.authResponse_).to.equal(fallback); - }); - }); - - it('should short-circuit pingback flow', () => { - cidMock.expects('get').never(); - xhrMock.expects('fetchJson').never(); - return service.reportViewToServer_(); - }); - - it('should short-circuit login flow', () => { - expect(() => service.login('')).to.throw(/Login URL is not configured/); - }); -});