Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Cid optout action #9952

Merged
merged 20 commits into from
Jun 30, 2017
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 32 additions & 4 deletions extensions/amp-user-notification/0.1/amp-user-notification.js
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ export class AmpUserNotification extends AMP.BaseElement {
.registerUserNotification(this.elementId_, this);

this.registerAction('dismiss', this.dismiss.bind(this));
this.registerAction('optoutOfCid', this.optoutOfCid_.bind(this));
}

/**
Expand Down Expand Up @@ -232,12 +233,27 @@ export class AmpUserNotification extends AMP.BaseElement {
}

/**
* Get async cid service.
* Opts the user out of cid issuance and dismisses the notification.
* @private
*/
optoutOfCid_() {
return this.getCidService_()
.then(cid => cid.optOut())
.then(this.dismiss.bind(this), reason => {
dev().error('amp-user-notification',
'Failed to opt out of Cid', reason);
// If optout fails, dismiss notification without persisting.
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seemed to me as the most reasonable thing to do if opting out fails, thoughts?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SGTM

this.dismiss(/*opt_forceNoPersist*/true);
});
}

/**
* Get async cid.
* @return {!Promise}
* @private
*/
getAsyncCid_() {
return cidForDoc(this.element).then(cid => {
return this.getCidService_().then(cid => {
// `amp-user-notification` is our cid scope, while we give it a resolved
// promise for the 2nd argument so that the 3rd argument (the
// persistentConsent) is the one used to resolve getting
Expand All @@ -252,6 +268,15 @@ export class AmpUserNotification extends AMP.BaseElement {
});
}

/**
* Get cid service.
* @return {!Promise}
* @private
*/
getCidService_() {
cidForDoc(this.element);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing return?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ugh, extension was not type-checked, added type checking (caught an unquoted JSON issues as well)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WAT

}

/** @override */
shouldShow() {
return this.isDismissed().then(dismissed => {
Expand Down Expand Up @@ -308,14 +333,17 @@ export class AmpUserNotification extends AMP.BaseElement {
/**
* Hides the current user notification and invokes the `dialogResolve_`
* method. Removes the `.amp-active` class from the element.
*
* @param {boolean=} opt_forceNoPersist If true, dismissal won't be persisted
* regardless of 'data-persist-dismissal''s value
*/
dismiss() {
dismiss(opt_forceNoPersist = false) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Danger! This requires auditing every call to dismiss, since there may have been a values passed into it through callbacks (the thens are already doing it in this PR).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

wow good catch Justin. Only issue was registerAction ( which actually was caught after I enabled type checking ).

Anyway now I am a bit worried about this. Should we start preferring explicit () => func() over bind?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Type checking will often have a hard time finding these since the type of the arg will often be ?

this.element.classList.remove('amp-active');
this.element.classList.add('amp-hidden');
this.dialogResolve_();
this.getViewport().removeFromFixedLayer(this.element);

if (this.persistDismissal_) {
if (this.persistDismissal_ && !opt_forceNoPersist) {
// Store and post.
this.storagePromise_.then(storage => {
storage.set(this.storageKey_, true);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -592,4 +592,50 @@ describe('amp-user-notification', () => {
expect(get().then).to.be.function;
});
});

describe('optOutOfCid', () => {
const cidMock = {
optOut() {
return optoutPromise;
},
};
let dismissSpy;
let optoutPromise;
let optOutOfCidStub;

beforeEach(() => {
optOutOfCidStub = sandbox.spy(cidMock, 'optOut');
});

it('should call cid.optOut() and dismiss', () => {
return getUserNotification({id: 'n1'}).then(el => {
const impl = el.implementation_;
impl.buildCallback();
optoutPromise = Promise.resolve();
impl.getCidService_ = () => { return Promise.resolve(cidMock); };
dismissSpy = sandbox.spy(impl, 'dismiss');

return impl.optoutOfCid_();
}).then(() => {
expect(dismissSpy).to.be.calledWithExactly(undefined);
expect(optOutOfCidStub).to.be.calledOnce;
});
});

it('should dissmiss without persistence if cid.optOut() fails', () => {
return getUserNotification({id: 'n1'}).then(el => {
const impl = el.implementation_;
impl.buildCallback();
optoutPromise = Promise.reject('failed');
impl.getCidService_ = () => { return Promise.resolve(cidMock); };
dismissSpy = sandbox.spy(impl, 'dismiss');

return impl.optoutOfCid_();
}).then(() => {
expect(dismissSpy).to.be.calledWithExactly(true);
expect(optOutOfCidStub).to.be.calledOnce;
});
});

});
});
71 changes: 64 additions & 7 deletions src/service/cid-impl.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ import {
import {dict} from '../utils/object';
import {isIframed} from '../dom';
import {getCryptoRandomBytesArray} from '../utils/bytes';
import {viewerForDoc} from '../services';
import {viewerForDoc, storageForDoc} from '../services';
import {cryptoFor} from '../crypto';
import {parseJson, tryParseJson} from '../json';
import {timerFor} from '../services';
Expand All @@ -50,6 +50,11 @@ const BASE_CID_MAX_AGE_MILLIS = 365 * ONE_DAY_MILLIS;

const SCOPE_NAME_VALIDATOR = /^[a-zA-Z0-9-_.]+$/;

const CID_OPTOUT_STORAGE_KEY = 'amp-cid-optout';

// TODO(aghassemi): replace with real viewer message when implemented.
const CID_OPTOUT_VIEWER_MESSAGE = 'cidOptout';
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@lannka Does viewer already have support for sending a message to inform user has opted out? For now I am just sending a message with this key

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, they're working on that. the key is cidOptOut

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed the key to match


/**
* A base cid string value and the time it was last read / stored.
* @typedef {{time: time, cid: string}}
Expand Down Expand Up @@ -107,7 +112,8 @@ export class Cid {
* consent, of course).
* @return {!Promise<?string>} A client identifier that should be used
* within the current source origin and externalCidScope. Might be
* null if no identifier was found or could be made.
* null if user has opted out of cid or no identifier was found
* or it could be made.
* This promise may take a long time to resolve if consent isn't
* given.
*/
Expand All @@ -118,13 +124,64 @@ export class Cid {
'The CID scope and cookie name must only use the characters ' +
'[a-zA-Z0-9-_.]+\nInstead found: %s',
getCidStruct.scope);
return consent.then(() => {
return viewerForDoc(this.ampdoc).whenFirstVisible();
}).then(() => {
return getExternalCid(this, getCidStruct,
opt_persistenceConsent || consent);
return isOptedOutOfCid(this.ampdoc).then(optedOut => {
// TODO(aghassemi): null or '' ?
if (optedOut) {
return null;
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

so, although get can already resolve to null, I have seen places that do not check for null (e.g extractClientIdFromGaCookie). Maybe '' is better? thoughts?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

'' is better

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done, also moved the check after consent check (since user can optout while consent is pending)

} else {
return consent.then(() => {
return viewerForDoc(this.ampdoc).whenFirstVisible();
}).then(() => {
return getExternalCid(this, getCidStruct,
opt_persistenceConsent || consent);
});
}
});
}

/**
* User will be opted out of Cid issuance for all scopes.
* When opted-out Cid service will reject all `get` requests.
*
* @return {!Promise}
*/
optOut() {
return optOutOfCid(this.ampdoc);
}
}

/**
* User will be opted out of Cid issuance for all scopes.
* When opted-out Cid service will reject all `get` requests.
*
* @return {!Promise}
* @visibleForTesting
*/
export function optOutOfCid(ampdoc) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

was this because you had some trouble with the tests?
if so, check out #10200


// Tell the viewer that user has opted out.
viewerForDoc(ampdoc).sendMessage(CID_OPTOUT_VIEWER_MESSAGE);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

does it make sense to move this little thing into cid-impl? so we have centralized place managing the CID APIs

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this whole thing is in amp-impl.js, did you mean somewhere else?


// Store the optout bit in storage
return storageForDoc(ampdoc).then(storage => {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we want localStorage or storage service here?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is right.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

will user be able to ever opt-in again? is there such an API?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nope, do we need that @cramforce?

return storage.set(CID_OPTOUT_STORAGE_KEY, true);
});
}

/**
* Whether user has opted out of Cid issuance for all scopes.
*
* @param {!./ampdoc-impl.AmpDoc} ampdoc
* @return {!Promise<boolean>}
* @visibleForTesting
*/
export function isOptedOutOfCid(ampdoc) {
return storageForDoc(ampdoc).then(storage => {
return storage.get(CID_OPTOUT_STORAGE_KEY).then(val => !!val);
}).catch(() => {
// If we fail to read the flag, assume not opted out.
return false;
});
}

/**
Expand Down
77 changes: 77 additions & 0 deletions test/functional/test-cid.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ import {
cidServiceForDocForTesting,
getProxySourceOrigin,
viewerBaseCid,
optOutOfCid,
isOptedOutOfCid,
} from '../../src/service/cid-impl';
import {installCryptoService, Crypto} from '../../src/service/crypto-impl';
import {cryptoFor} from '../../src/crypto';
Expand All @@ -33,12 +35,14 @@ import {parseUrl} from '../../src/url';
import {installPlatformService} from '../../src/service/platform-impl';
import {installViewerServiceForDoc} from '../../src/service/viewer-impl';
import {installTimerService} from '../../src/service/timer-impl';
import {installStorageServiceForDoc} from '../../src/service/storage-impl';
import {
installCryptoPolyfill,
} from '../../extensions/amp-crypto-polyfill/0.1/amp-crypto-polyfill';
import {
installExtensionsService,
} from '../../src/service/extensions-impl';
import {stubServiceForDoc} from '../../testing/test-helper';
import * as sinon from 'sinon';

const DAY = 24 * 3600 * 1000;
Expand All @@ -58,6 +62,7 @@ describe('cid', () => {
let whenFirstVisible;
let trustedViewer;
let shouldSendMessageTimeout;
let storageGetStub;

const hasConsent = Promise.resolve();
const timer = timerFor(window);
Expand Down Expand Up @@ -117,6 +122,7 @@ describe('cid', () => {
});

installViewerServiceForDoc(ampdoc);
storageGetStub = stubServiceForDoc(sandbox, ampdoc, 'storage', 'get');
viewer = viewerForDoc(ampdoc);
sandbox.stub(viewer, 'whenFirstVisible', function() {
return whenFirstVisible;
Expand Down Expand Up @@ -272,6 +278,18 @@ describe('cid', () => {
'sha384(YYYhttp://www.origin.come2)');
});

it('should return null if opted out', () => {
storageGetStub.withArgs('amp-cid-optout').returns(Promise.resolve(true));

storage['amp-cid'] = JSON.stringify({
cid: 'YYY',
time: Date.now(),
});
return compare(
'e2',
null);
});

it('should read from viewer storage if embedded', () => {
fakeWin.parent = {};
const expectedBaseCid = 'from-viewer';
Expand Down Expand Up @@ -390,6 +408,7 @@ describe('cid', () => {
installTimerService(win);
installPlatformService(win);
installViewerServiceForDoc(ampdoc2);
installStorageServiceForDoc(ampdoc2);
cidServiceForDocForTesting(ampdoc2);
installCryptoService(win);
return cidForDoc(ampdoc2).then(cid => {
Expand Down Expand Up @@ -719,3 +738,61 @@ describe('getProxySourceOrigin', () => {
}).to.throw(/Expected proxy origin/);
});
});

describes.fakeWin('cid optout:', {amp: true}, env => {
let storageGetStub;
let storageSetStub;
let viewerSendMessageStub;
let ampdoc;

beforeEach(() => {
ampdoc = env.ampdoc;
storageSetStub = stubServiceForDoc(sandbox, ampdoc, 'storage', 'set');
storageGetStub = stubServiceForDoc(sandbox, ampdoc, 'storage', 'get');
viewerSendMessageStub = stubServiceForDoc(sandbox, ampdoc,
'viewer', 'sendMessage');
});

describe('optOutOfCid()', () => {
it('should send a message to viewer', () => {
return optOutOfCid(ampdoc).then(() => {
expect(viewerSendMessageStub).to.be.calledWith('cidOptout');
});
});

it('should save bit in storage', () => {
optOutOfCid(ampdoc).then(() => {
expect(storageSetStub).to.be.calledWith('amp-cid-optout', true);
});
});

it('should reject promise if storage set fails', () => {
storageSetStub.returns(Promise.reject('failed!'));
return optOutOfCid(ampdoc).should.eventually.be.rejectedWith('failed!');
});
});

describe('isOptedOutOfCid()', () => {
it('should return true if bit is set in storage', () => {
storageGetStub.withArgs('amp-cid-optout').returns(Promise.resolve(true));
return isOptedOutOfCid(ampdoc).then(isOut => {
expect(isOut).to.be.true;
});
});

it('should return false if bit is not set in storage', () => {
storageGetStub.withArgs('amp-cid-optout').returns(Promise.resolve(null));
return isOptedOutOfCid(ampdoc).then(isOut => {
expect(isOut).to.be.false;
});
});

it('should return false if storage get fails', () => {
storageGetStub.withArgs('amp-cid-optout').returns(Promise.reject('Fail'));
return isOptedOutOfCid(ampdoc).then(isOut => {
expect(isOut).to.be.false;
});
});
});

});