0
Fork 0
mirror of https://github.com/TryGhost/Ghost.git synced 2025-04-15 03:01:37 -05:00

Added support for gating content by member labels and products (#12946)

refs https://github.com/TryGhost/Team/issues/581
closes https://github.com/TryGhost/Team/issues/582

Emails can now be sent to members with specific associated labels or products by specifying an NQL string. We want to bring the same members segment feature to content by allowing `visibility` to be an NQL filter string on top of the `public/members/paid` special-case strings.

As an example it's possible to set `posts.visibility` to `label:vip` to make a post available only to those members with the `vip` label.

- removed enum validations for `visibility` so it now accepts any string or `null`
    - bumped `@tryghost/admin-api-schema` for API-level validation changes
- added nql validation to API input validators by running the visibility query against the members model
- added transform of NQL to special-case visibility values when saving post model
    - ensures there's a single way of representing "members" and "paid" where NQL gives multiple ways of representing the same segment
    - useful for keeping theme-level checks such as `{{#has visibility="paid"}}` working as expected
- updated content-gating to parse nql from post's visibility and use it to query the currently logged in member to see if there's a match
    - bumped @tryghost/members-api to include label and product data when loading member
This commit is contained in:
Kevin Ansfield 2021-05-10 19:32:11 +01:00 committed by GitHub
parent cfaddf82e8
commit c36e749820
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
25 changed files with 728 additions and 137 deletions

View file

@ -1,6 +1,42 @@
const jsonSchema = require('../utils/json-schema');
const models = require('../../../../../models');
const {ValidationError} = require('@tryghost/errors');
const i18n = require('../../../../../../shared/i18n');
const validateVisibility = async function (frame) {
if (!frame.data.pages || !frame.data.pages[0]) {
return Promise.resolve();
}
// validate visibility - not done at schema level because this can be an NQL query so needs model access
const visibility = frame.data.pages[0].visibility;
if (visibility) {
if (!['public', 'members', 'paid'].includes(visibility)) {
// check filter is valid
try {
await models.Member.findPage({filter: visibility, limit: 1});
return Promise.resolve();
} catch (err) {
return Promise.reject(new ValidationError({
message: i18n.t('errors.api.pages.invalidVisibilityFilter'),
property: 'visibility'
}));
}
}
return Promise.resolve();
}
};
module.exports = {
add: jsonSchema.validate,
edit: jsonSchema.validate
add(apiConfig, frame) {
return jsonSchema.validate(...arguments).then(() => {
return validateVisibility(frame);
});
},
edit(apiConfig, frame) {
return jsonSchema.validate(...arguments).then(() => {
return validateVisibility(frame);
});
}
};

View file

@ -1,6 +1,42 @@
const jsonSchema = require('../utils/json-schema');
const models = require('../../../../../models');
const {ValidationError} = require('@tryghost/errors');
const i18n = require('../../../../../../shared/i18n');
const validateVisibility = async function (frame) {
if (!frame.data.posts || !frame.data.posts[0]) {
return Promise.resolve();
}
// validate visibility - not done at schema level because this can be an NQL query so needs model access
const visibility = frame.data.posts[0].visibility;
if (visibility) {
if (!['public', 'members', 'paid'].includes(visibility)) {
// check filter is valid
try {
await models.Member.findPage({filter: visibility, limit: 1});
return Promise.resolve();
} catch (err) {
return Promise.reject(new ValidationError({
message: i18n.t('errors.api.posts.invalidVisibilityFilter'),
property: 'visibility'
}));
}
}
return Promise.resolve();
}
};
module.exports = {
add: jsonSchema.validate,
edit: jsonSchema.validate
add(apiConfig, frame) {
return jsonSchema.validate(...arguments).then(() => {
return validateVisibility(frame);
});
},
edit(apiConfig, frame) {
return jsonSchema.validate(...arguments).then(() => {
return validateVisibility(frame);
});
}
};

View file

@ -1,6 +1,42 @@
const jsonSchema = require('../utils/json-schema');
const models = require('../../../../../models');
const {ValidationError} = require('@tryghost/errors');
const i18n = require('../../../../../../shared/i18n');
const validateVisibility = async function (frame) {
if (!frame.data.pages || !frame.data.pages[0]) {
return Promise.resolve();
}
// validate visibility - not done at schema level because this can be an NQL query so needs model access
const visibility = frame.data.pages[0].visibility;
if (visibility) {
if (!['public', 'members', 'paid'].includes(visibility)) {
// check filter is valid
try {
await models.Member.findPage({filter: visibility, limit: 1});
return Promise.resolve();
} catch (err) {
return Promise.reject(new ValidationError({
message: i18n.t('errors.api.pages.invalidVisibilityFilter'),
property: 'visibility'
}));
}
}
return Promise.resolve();
}
};
module.exports = {
add: jsonSchema.validate,
edit: jsonSchema.validate
add(apiConfig, frame) {
return jsonSchema.validate(...arguments).then(() => {
return validateVisibility(frame);
});
},
edit(apiConfig, frame) {
return jsonSchema.validate(...arguments).then(() => {
return validateVisibility(frame);
});
}
};

View file

@ -1,6 +1,42 @@
const jsonSchema = require('../utils/json-schema');
const models = require('../../../../../models');
const {ValidationError} = require('@tryghost/errors');
const i18n = require('../../../../../../shared/i18n');
const validateVisibility = async function (frame) {
if (!frame.data.posts || !frame.data.posts[0]) {
return Promise.resolve();
}
// validate visibility - not done at schema level because this can be an NQL query so needs model access
const visibility = frame.data.posts[0].visibility;
if (visibility) {
if (!['public', 'members', 'paid'].includes(visibility)) {
// check filter is valid
try {
await models.Member.findPage({filter: visibility, limit: 1});
return Promise.resolve();
} catch (err) {
return Promise.reject(new ValidationError({
message: i18n.t('errors.api.posts.invalidVisibilityFilter'),
property: 'visibility'
}));
}
}
return Promise.resolve();
}
};
module.exports = {
add: jsonSchema.validate,
edit: jsonSchema.validate
add(apiConfig, frame) {
return jsonSchema.validate(...arguments).then(() => {
return validateVisibility(frame);
});
},
edit(apiConfig, frame) {
return jsonSchema.validate(...arguments).then(() => {
return validateVisibility(frame);
});
}
};

View file

@ -1,6 +1,42 @@
const jsonSchema = require('../utils/json-schema');
const models = require('../../../../../models');
const {ValidationError} = require('@tryghost/errors');
const i18n = require('../../../../../../shared/i18n');
const validateVisibility = async function (frame) {
if (!frame.data.pages || !frame.data.pages[0]) {
return Promise.resolve();
}
// validate visibility - not done at schema level because this can be an NQL query so needs model access
const visibility = frame.data.pages[0].visibility;
if (visibility) {
if (!['public', 'members', 'paid'].includes(visibility)) {
// check filter is valid
try {
await models.Member.findPage({filter: visibility, limit: 1});
return Promise.resolve();
} catch (err) {
return Promise.reject(new ValidationError({
message: i18n.t('errors.api.pages.invalidVisibilityFilter'),
property: 'visibility'
}));
}
}
return Promise.resolve();
}
};
module.exports = {
add: jsonSchema.validate,
edit: jsonSchema.validate
add(apiConfig, frame) {
return jsonSchema.validate(...arguments).then(() => {
return validateVisibility(frame);
});
},
edit(apiConfig, frame) {
return jsonSchema.validate(...arguments).then(() => {
return validateVisibility(frame);
});
}
};

View file

@ -1,6 +1,42 @@
const jsonSchema = require('../utils/json-schema');
const models = require('../../../../../models');
const {ValidationError} = require('@tryghost/errors');
const i18n = require('../../../../../../shared/i18n');
const validateVisibility = async function (frame) {
if (!frame.data.posts || !frame.data.posts[0]) {
return Promise.resolve();
}
// validate visibility - not done at schema level because this can be an NQL query so needs model access
const visibility = frame.data.posts[0].visibility;
if (visibility) {
if (!['public', 'members', 'paid'].includes(visibility)) {
// check filter is valid
try {
await models.Member.findPage({filter: visibility, limit: 1});
return Promise.resolve();
} catch (err) {
return Promise.reject(new ValidationError({
message: i18n.t('errors.api.posts.invalidVisibilityFilter'),
property: 'visibility'
}));
}
}
return Promise.resolve();
}
};
module.exports = {
add: jsonSchema.validate,
edit: jsonSchema.validate
add(apiConfig, frame) {
return jsonSchema.validate(...arguments).then(() => {
return validateVisibility(frame);
});
},
edit(apiConfig, frame) {
return jsonSchema.validate(...arguments).then(() => {
return validateVisibility(frame);
});
}
};

View file

@ -27,8 +27,7 @@ module.exports = {
type: 'string',
maxlength: 50,
nullable: false,
defaultTo: 'public',
validations: {isIn: [['public', 'members', 'paid']]}
defaultTo: 'public'
},
email_recipient_filter: {
type: 'string',

View file

@ -6,6 +6,7 @@ const Promise = require('bluebird');
const {sequence} = require('@tryghost/promise');
const i18n = require('../../shared/i18n');
const errors = require('@tryghost/errors');
const nql = require('@nexes/nql');
const htmlToPlaintext = require('../../shared/html-to-plaintext');
const ghostBookshelf = require('./base');
const config = require('../../shared/config');
@ -155,6 +156,20 @@ Post = ghostBookshelf.Model.extend({
attrs.email_recipient_filter = 'status:-free';
}
// transform visibility NQL queries to special-case values where necessary
// ensures checks against special-case values such as `{{#has visibility="paid"}}` continue working
if (attrs.visibility && !['public', 'members', 'paid'].includes(attrs.visibility)) {
if (attrs.visibility === 'status:-free') {
attrs.visibility = 'paid';
} else {
const visibilityNql = nql(attrs.visibility);
if (visibilityNql.queryJSON({status: 'free'}) && visibilityNql.queryJSON({status: '-free'})) {
attrs.visibility = 'members';
}
}
}
return attrs;
},

View file

@ -1,9 +1,26 @@
const nql = require('@nexes/nql');
// @ts-check
/** @typedef { boolean } AccessFlag */
const PERMIT_ACCESS = true;
const BLOCK_ACCESS = false;
// TODO: better place to store this?
const MEMBER_NQL_EXPANSIONS = [{
key: 'labels',
replacement: 'labels.slug'
}, {
key: 'label',
replacement: 'labels.slug'
}, {
key: 'products',
replacement: 'products.slug'
}, {
key: 'product',
replacement: 'products.slug'
}];
/**
* @param {object} post - A post object to check access to
* @param {object} member - The member whos access should be checked
@ -23,7 +40,9 @@ function checkPostAccess(post, member) {
return PERMIT_ACCESS;
}
if (post.visibility === 'paid' && (member.status === 'paid' || member.status === 'comped' || member.comped)) {
const visibility = post.visibility === 'paid' ? 'status:-free' : post.visibility;
if (visibility && member.status && nql(visibility, {expansions: MEMBER_NQL_EXPANSIONS}).queryJSON(member)) {
return PERMIT_ACCESS;
}

View file

@ -356,13 +356,15 @@
},
"posts": {
"postNotFound": "Post not found.",
"invalidEmailRecipientFilter": "Invalid filter in email_recipient_filter param."
"invalidEmailRecipientFilter": "Invalid filter in email_recipient_filter param.",
"invalidVisibilityFilter": "Invalid filter in visibility property"
},
"authors": {
"notFound": "Author not found."
},
"pages": {
"pageNotFound": "Page not found."
"pageNotFound": "Page not found.",
"invalidVisibilityFilter": "Invalid filter in visibility property"
},
"job": {
"notFound": "Job not found.",

View file

@ -43,7 +43,7 @@
"@nexes/nql": "0.5.2",
"@sentry/node": "6.3.6",
"@tryghost/adapter-manager": "0.2.12",
"@tryghost/admin-api-schema": "2.1.1",
"@tryghost/admin-api-schema": "2.2.1",
"@tryghost/bootstrap-socket": "0.2.8",
"@tryghost/constants": "0.1.7",
"@tryghost/email-analytics-provider-mailgun": "1.0.0",
@ -59,7 +59,7 @@
"@tryghost/kg-mobiledoc-html-renderer": "4.0.0",
"@tryghost/limit-service": "0.5.0",
"@tryghost/magic-link": "1.0.2",
"@tryghost/members-api": "1.4.0",
"@tryghost/members-api": "1.5.0",
"@tryghost/members-csv": "1.0.0",
"@tryghost/members-ssr": "1.0.2",
"@tryghost/mw-session-from-token": "0.1.20",

View file

@ -35,7 +35,7 @@ describe('Members API', function () {
const jsonResponse = res.body;
should.exist(jsonResponse);
should.exist(jsonResponse.members);
jsonResponse.members.should.have.length(5);
jsonResponse.members.should.have.length(8);
localUtils.API.checkResponse(jsonResponse.members[0], 'member', 'subscriptions');
testUtils.API.isISO8601(jsonResponse.members[0].created_at).should.be.true();
@ -44,7 +44,7 @@ describe('Members API', function () {
jsonResponse.meta.pagination.should.have.property('page', 1);
jsonResponse.meta.pagination.should.have.property('limit', 15);
jsonResponse.meta.pagination.should.have.property('pages', 1);
jsonResponse.meta.pagination.should.have.property('total', 5);
jsonResponse.meta.pagination.should.have.property('total', 8);
jsonResponse.meta.pagination.should.have.property('next', null);
jsonResponse.meta.pagination.should.have.property('prev', null);
});
@ -98,7 +98,7 @@ describe('Members API', function () {
const jsonResponse = res.body;
should.exist(jsonResponse);
should.exist(jsonResponse.members);
jsonResponse.members.should.have.length(2);
jsonResponse.members.should.have.length(4);
jsonResponse.members[0].email.should.equal('paid@test.com');
jsonResponse.members[1].email.should.equal('trialing@test.com');
localUtils.API.checkResponse(jsonResponse, 'members');

View file

@ -12,6 +12,26 @@ const settingsCache = require('../../core/server/services/settings/cache');
describe('Front-end members behaviour', function () {
let request;
async function loginAsMember(email) {
// membersService needs to be required after Ghost start so that settings
// are pre-populated with defaults
const membersService = require('../../core/server/services/members');
const signinLink = await membersService.api.getMagicLink(email);
const signinURL = new URL(signinLink);
// request needs a relative path rather than full url with host
const signinPath = `${signinURL.pathname}${signinURL.search}`;
// perform a sign-in request to set members cookies on superagent
await request.get(signinPath)
.expect(302)
.then((res) => {
const redirectUrl = new URL(res.headers.location, testUtils.API.getURL());
should.exist(redirectUrl.searchParams.get('success'));
redirectUrl.searchParams.get('success').should.eql('true');
});
}
before(async function () {
const originalSettingsCacheGetFn = settingsCache.get;
@ -114,6 +134,8 @@ describe('Front-end members behaviour', function () {
let membersPost;
let paidPost;
let membersPostWithPaywallCard;
let labelPost;
let productPost;
before(function () {
publicPost = testUtils.DataGenerator.forKnex.createPost({
@ -142,11 +164,25 @@ describe('Front-end members behaviour', function () {
published_at: moment().add(5, 'seconds').toDate()
});
labelPost = testUtils.DataGenerator.forKnex.createPost({
slug: 'thou-must-be-labelled-vip',
visibility: 'label:vip',
published_at: moment().toDate()
});
productPost = testUtils.DataGenerator.forKnex.createPost({
slug: 'thou-must-have-default-product',
visibility: 'product:default-product',
published_at: moment().toDate()
});
return testUtils.fixtures.insertPosts([
publicPost,
membersPost,
paidPost,
membersPostWithPaywallCard
membersPostWithPaywallCard,
labelPost,
productPost
]);
});
@ -168,6 +204,7 @@ describe('Front-end members behaviour', function () {
res.text.should.not.containEql('<h2 id="markdown">markdown</h2>');
});
});
it('cannot read paid post content', function () {
return request
.get('/thou-shalt-be-paid-for/')
@ -176,27 +213,29 @@ describe('Front-end members behaviour', function () {
res.text.should.not.containEql('<h2 id="markdown">markdown</h2>');
});
});
it('cannot read label-only post content', function () {
return request
.get('/thou-must-be-labelled-vip/')
.expect(200)
.then((res) => {
res.text.should.not.containEql('<h2 id="markdown">markdown</h2>');
});
});
it('cannot read product-only post content', function () {
return request
.get('/thou-must-have-default-product/')
.expect(200)
.then((res) => {
res.text.should.not.containEql('<h2 id="markdown">markdown</h2>');
});
});
});
describe('as free member', function () {
before(async function () {
// membersService needs to be required after Ghost start so that settings
// are pre-populated with defaults
const membersService = require('../../core/server/services/members');
const signinLink = await membersService.api.getMagicLink('member1@test.com');
const signinURL = new URL(signinLink);
// request needs a relative path rather than full url with host
const signinPath = `${signinURL.pathname}${signinURL.search}`;
// perform a sign-in request to set members cookies on superagent
await request.get(signinPath)
.expect(302)
.then((res) => {
const redirectUrl = new URL(res.headers.location, testUtils.API.getURL());
should.exist(redirectUrl.searchParams.get('success'));
redirectUrl.searchParams.get('success').should.eql('true');
});
await loginAsMember('member1@test.com');
});
it('can read public post content', function () {
@ -225,6 +264,39 @@ describe('Front-end members behaviour', function () {
res.text.should.not.containEql('<h2 id="markdown">markdown</h2>');
});
});
it('cannot read label-only post content', function () {
return request
.get('/thou-must-be-labelled-vip/')
.expect(200)
.then((res) => {
res.text.should.not.containEql('<h2 id="markdown">markdown</h2>');
});
});
it('cannot read product-only post content', function () {
return request
.get('/thou-must-have-default-product/')
.expect(200)
.then((res) => {
res.text.should.not.containEql('<h2 id="markdown">markdown</h2>');
});
});
});
describe('as free member with vip label', function () {
before(async function () {
await loginAsMember('vip@test.com');
});
it('can read label-only post content', function () {
return request
.get('/thou-must-be-labelled-vip/')
.expect(200)
.then((res) => {
res.text.should.containEql('<h2 id="markdown">markdown</h2>');
});
});
});
describe('as paid member', function () {
@ -274,27 +346,44 @@ describe('Front-end members behaviour', function () {
res.text.should.containEql('<h2 id="markdown">markdown</h2>');
});
});
it('cannot read label-only post content', function () {
return request
.get('/thou-must-be-labelled-vip/')
.expect(200)
.then((res) => {
res.text.should.not.containEql('<h2 id="markdown">markdown</h2>');
});
});
it('cannot read product-only post content', function () {
return request
.get('/thou-must-have-default-product/')
.expect(200)
.then((res) => {
res.text.should.not.containEql('<h2 id="markdown">markdown</h2>');
});
});
});
describe('as paid member with vip label', function () {
before(async function () {
await loginAsMember('vip-paid@test.com');
});
it('can read label-only post content', function () {
return request
.get('/thou-must-be-labelled-vip/')
.expect(200)
.then((res) => {
res.text.should.containEql('<h2 id="markdown">markdown</h2>');
});
});
});
describe('as comped member', function () {
before(async function () {
// membersService needs to be required after Ghost start so that settings
// are pre-populated with defaults
const membersService = require('../../core/server/services/members');
const signinLink = await membersService.api.getMagicLink('comped@test.com');
const signinURL = new URL(signinLink);
// request needs a relative path rather than full url with host
const signinPath = `${signinURL.pathname}${signinURL.search}`;
// perform a sign-in request to set members cookies on superagent
await request.get(signinPath)
.expect(302)
.then((res) => {
const redirectUrl = new URL(res.headers.location, testUtils.API.getURL());
should.exist(redirectUrl.searchParams.get('success'));
redirectUrl.searchParams.get('success').should.eql('true');
});
await loginAsMember('comped@test.com');
});
it('can read public post content', function () {
@ -323,6 +412,39 @@ describe('Front-end members behaviour', function () {
res.text.should.containEql('<h2 id="markdown">markdown</h2>');
});
});
it('cannot read label-only post content', function () {
return request
.get('/thou-must-be-labelled-vip/')
.expect(200)
.then((res) => {
res.text.should.not.containEql('<h2 id="markdown">markdown</h2>');
});
});
it('cannot read product-only post content', function () {
return request
.get('/thou-must-have-default-product/')
.expect(200)
.then((res) => {
res.text.should.not.containEql('<h2 id="markdown">markdown</h2>');
});
});
});
describe('as member with product', function () {
before(async function () {
await loginAsMember('with-product@test.com');
});
it('can read product-only post content', function () {
return request
.get('/thou-must-have-default-product/')
.expect(200)
.then((res) => {
res.text.should.containEql('<h2 id="markdown">markdown</h2>');
});
});
});
});
});

View file

@ -13,7 +13,7 @@ const ghost = testUtils.startGhost;
let request;
describe('Members API', function () {
describe('Members API (canary)', function () {
before(function () {
sinon.stub(labs, 'isSet').withArgs('members').returns(true);
});
@ -96,7 +96,7 @@ describe('Members API', function () {
const jsonResponse = res.body;
should.exist(jsonResponse.members);
localUtils.API.checkResponse(jsonResponse, 'members');
jsonResponse.members.should.have.length(5);
jsonResponse.members.should.have.length(8);
jsonResponse.members[0].email.should.equal('paid@test.com');
jsonResponse.members[0].email_open_rate.should.equal(80);
@ -117,7 +117,7 @@ describe('Members API', function () {
.then((res) => {
const jsonResponse = res.body;
localUtils.API.checkResponse(jsonResponse, 'members');
jsonResponse.members.should.have.length(5);
jsonResponse.members.should.have.length(8);
jsonResponse.members[0].email.should.equal('member2@test.com');
jsonResponse.members[0].email_open_rate.should.equal(50);

View file

@ -96,7 +96,7 @@ describe('Members API', function () {
const jsonResponse = res.body;
should.exist(jsonResponse.members);
localUtils.API.checkResponse(jsonResponse, 'members');
jsonResponse.members.should.have.length(5);
jsonResponse.members.should.have.length(8);
jsonResponse.members[0].email.should.equal('paid@test.com');
jsonResponse.members[0].email_open_rate.should.equal(80);
@ -117,7 +117,7 @@ describe('Members API', function () {
.then((res) => {
const jsonResponse = res.body;
localUtils.API.checkResponse(jsonResponse, 'members');
jsonResponse.members.should.have.length(5);
jsonResponse.members.should.have.length(8);
jsonResponse.members[0].email.should.equal('member2@test.com');
jsonResponse.members[0].email_open_rate.should.equal(50);
@ -635,8 +635,8 @@ describe('Members API', function () {
should.exist(jsonResponse.total_on_date);
should.exist(jsonResponse.new_today);
// 5 from fixtures and 6 imported in previous tests
jsonResponse.total.should.equal(11);
// 8 from fixtures and 6 imported in previous tests
jsonResponse.total.should.equal(14);
});
});
@ -659,8 +659,8 @@ describe('Members API', function () {
should.exist(jsonResponse.total_on_date);
should.exist(jsonResponse.new_today);
// 5 from fixtures and 6 imported in previous tests
jsonResponse.total.should.equal(11);
// 8 from fixtures and 6 imported in previous tests
jsonResponse.total.should.equal(14);
});
});
@ -683,8 +683,8 @@ describe('Members API', function () {
should.exist(jsonResponse.total_on_date);
should.exist(jsonResponse.new_today);
// 5 from fixtures and 6 imported in previous tests
jsonResponse.total.should.equal(11);
// 8 from fixtures and 6 imported in previous tests
jsonResponse.total.should.equal(14);
});
});

View file

@ -733,6 +733,44 @@ describe('Post Model', function () {
done();
}).catch(done);
});
it('transforms special-case visibility values on save', function (done) {
// status:-free === paid
// status:-free,status:free (+variations) === members
const postId = testUtils.DataGenerator.Content.posts[3].id;
models.Post.findOne({id: postId}).then(() => {
return models.Post.edit({
visibility: 'status:-free'
}, _.extend({}, context, {id: postId}));
}).then((edited) => {
edited.attributes.visibility.should.equal('paid');
return db.knex('posts').where({id: edited.id});
}).then((knexResult) => {
const [knexPost] = knexResult;
knexPost.visibility.should.equal('paid');
}).then(() => {
return models.Post.edit({
visibility: 'status:-free,status:free'
}, _.extend({}, context, {id: postId}));
}).then((edited) => {
edited.attributes.visibility.should.equal('members');
return models.Post.edit({
visibility: 'status:free,status:-free'
}, _.extend({}, context, {id: postId}));
}).then((edited) => {
edited.attributes.visibility.should.equal('members');
return models.Post.edit({
visibility: 'status:free,status:-free,label:vip'
}, _.extend({}, context, {id: postId}));
}).then((edited) => {
edited.attributes.visibility.should.equal('members');
done();
}).catch(done);
});
});
describe('add', function () {

View file

@ -4,8 +4,18 @@ const should = require('should');
const sinon = require('sinon');
const Promise = require('bluebird');
const validators = require('../../../../../../../core/server/api/canary/utils/validators');
const models = require('../../../../../../../core/server/models');
describe('Unit: canary/utils/validators/input/pages', function () {
before(function () {
return models.init();
});
beforeEach(function () {
const memberFindPageStub = sinon.stub(models.Member, 'findPage').returns(Promise.reject());
memberFindPageStub.withArgs({filter: 'label:vip', limit: 1}).returns(Promise.resolve());
});
afterEach(function () {
sinon.restore();
});
@ -180,6 +190,21 @@ describe('Unit: canary/utils/validators/input/pages', function () {
return Promise.all(checks);
});
});
it('should pass for valid NQL visibility', function () {
const frame = {
options: {},
data: {
pages: [{
title: 'pass',
authors: [{id: 'correct'}],
visibility: 'label:vip'
}]
}
};
return validators.input.pages.add(apiConfig, frame);
});
});
describe('authors structure', function () {

View file

@ -4,8 +4,18 @@ const should = require('should');
const sinon = require('sinon');
const Promise = require('bluebird');
const validators = require('../../../../../../../core/server/api/canary/utils/validators');
const models = require('../../../../../../../core/server/models');
describe('Unit: canary/utils/validators/input/posts', function () {
before(function () {
models.init();
});
beforeEach(function () {
const memberFindPageStub = sinon.stub(models.Member, 'findPage').returns(Promise.reject());
memberFindPageStub.withArgs({filter: 'label:vip', limit: 1}).returns(Promise.resolve());
});
afterEach(function () {
sinon.restore();
});
@ -180,6 +190,21 @@ describe('Unit: canary/utils/validators/input/posts', function () {
return Promise.all(checks);
});
});
it('should pass for valid NQL visibility', function () {
const frame = {
options: {},
data: {
posts: [{
title: 'pass',
authors: [{id: 'correct'}],
visibility: 'label:vip'
}]
}
};
return validators.input.posts.add(apiConfig, frame);
});
});
describe('authors structure', function () {

View file

@ -4,8 +4,18 @@ const should = require('should');
const sinon = require('sinon');
const Promise = require('bluebird');
const validators = require('../../../../../../../core/server/api/v2/utils/validators');
const models = require('../../../../../../../core/server/models');
describe('Unit: v2/utils/validators/input/pages', function () {
before(function () {
return models.init();
});
beforeEach(function () {
const memberFindPageStub = sinon.stub(models.Member, 'findPage').returns(Promise.reject());
memberFindPageStub.withArgs({filter: 'label:vip', limit: 1}).returns(Promise.resolve());
});
afterEach(function () {
sinon.restore();
});
@ -180,6 +190,21 @@ describe('Unit: v2/utils/validators/input/pages', function () {
return Promise.all(checks);
});
});
it('should pass for valid NQL visibility', function () {
const frame = {
options: {},
data: {
pages: [{
title: 'pass',
authors: [{id: 'correct'}],
visibility: 'label:vip'
}]
}
};
return validators.input.pages.add(apiConfig, frame);
});
});
describe('authors structure', function () {

View file

@ -4,8 +4,18 @@ const should = require('should');
const sinon = require('sinon');
const Promise = require('bluebird');
const validators = require('../../../../../../../core/server/api/v2/utils/validators');
const models = require('../../../../../../../core/server/models');
describe('Unit: v2/utils/validators/input/posts', function () {
before(function () {
return models.init();
});
beforeEach(function () {
const memberFindPageStub = sinon.stub(models.Member, 'findPage').returns(Promise.reject());
memberFindPageStub.withArgs({filter: 'label:vip', limit: 1}).returns(Promise.resolve());
});
afterEach(function () {
sinon.restore();
});
@ -180,6 +190,21 @@ describe('Unit: v2/utils/validators/input/posts', function () {
return Promise.all(checks);
});
});
it('should pass for valid NQL visibility', function () {
const frame = {
options: {},
data: {
posts: [{
title: 'pass',
authors: [{id: 'correct'}],
visibility: 'label:vip'
}]
}
};
return validators.input.posts.add(apiConfig, frame);
});
});
describe('authors structure', function () {

View file

@ -4,8 +4,18 @@ const should = require('should');
const sinon = require('sinon');
const Promise = require('bluebird');
const validators = require('../../../../../../../core/server/api/v3/utils/validators');
const models = require('../../../../../../../core/server/models');
describe('Unit: v3/utils/validators/input/pages', function () {
before(function () {
return models.init();
});
beforeEach(function () {
const memberFindPageStub = sinon.stub(models.Member, 'findPage').returns(Promise.reject());
memberFindPageStub.withArgs({filter: 'label:vip', limit: 1}).returns(Promise.resolve());
});
afterEach(function () {
sinon.restore();
});
@ -180,6 +190,21 @@ describe('Unit: v3/utils/validators/input/pages', function () {
return Promise.all(checks);
});
});
it('should pass for valid NQL visibility', function () {
const frame = {
options: {},
data: {
pages: [{
title: 'pass',
authors: [{id: 'correct'}],
visibility: 'label:vip'
}]
}
};
return validators.input.pages.add(apiConfig, frame);
});
});
describe('authors structure', function () {

View file

@ -4,8 +4,18 @@ const should = require('should');
const sinon = require('sinon');
const Promise = require('bluebird');
const validators = require('../../../../../../../core/server/api/v3/utils/validators');
const models = require('../../../../../../../core/server/models');
describe('Unit: v3/utils/validators/input/posts', function () {
before(function () {
return models.init();
});
beforeEach(function () {
const memberFindPageStub = sinon.stub(models.Member, 'findPage').returns(Promise.reject());
memberFindPageStub.withArgs({filter: 'label:vip', limit: 1}).returns(Promise.resolve());
});
afterEach(function () {
sinon.restore();
});
@ -180,6 +190,21 @@ describe('Unit: v3/utils/validators/input/posts', function () {
return Promise.all(checks);
});
});
it('should pass for valid NQL visibility', function () {
const frame = {
options: {},
data: {
posts: [{
title: 'pass',
authors: [{id: 'correct'}],
visibility: 'label:vip'
}]
}
};
return validators.input.posts.add(apiConfig, frame);
});
});
describe('authors structure', function () {

View file

@ -457,34 +457,40 @@ const fixtures = {
});
},
insertMembersAndLabels: function insertMembersAndLabels() {
insertMembersAndLabelsAndProducts: function insertMembersAndLabelsAndProducts() {
return Promise.map(DataGenerator.forKnex.labels, function (label) {
return models.Label.add(label, context.internal);
}).then(function () {
return Promise.each(_.cloneDeep(DataGenerator.forKnex.members), function (member) {
let memberLabelRelations = _.filter(DataGenerator.forKnex.members_labels, {member_id: member.id});
memberLabelRelations = _.map(memberLabelRelations, function (memberLabelRelation) {
return _.find(DataGenerator.forKnex.labels, {id: memberLabelRelation.label_id});
});
member.labels = memberLabelRelations;
return models.Member.add(member, context.internal);
});
}).then(function () {
return Promise.each(_.cloneDeep(DataGenerator.forKnex.members_stripe_customers), function (customer) {
return models.MemberStripeCustomer.add(customer, context.internal);
});
}).then(function () {
let productsToInsert = fixtureUtils.findModelFixtures('Product').entries;
return Promise.map(productsToInsert, product => models.Product.add(product, context.internal));
}).then(function () {
return models.Product.findOne({}, context.internal);
}).then(function (product) {
return Promise.each(_.cloneDeep(DataGenerator.forKnex.stripe_products), function (stripeProduct) {
stripeProduct.product_id = product.id;
return models.StripeProduct.add(stripeProduct, context.internal);
return Promise.props({
stripeProducts: Promise.each(_.cloneDeep(DataGenerator.forKnex.stripe_products), function (stripeProduct) {
stripeProduct.product_id = product.id;
return models.StripeProduct.add(stripeProduct, context.internal);
}),
members: Promise.each(_.cloneDeep(DataGenerator.forKnex.members), function (member) {
let memberLabelRelations = _.filter(DataGenerator.forKnex.members_labels, {member_id: member.id});
memberLabelRelations = _.map(memberLabelRelations, function (memberLabelRelation) {
return _.find(DataGenerator.forKnex.labels, {id: memberLabelRelation.label_id});
});
member.labels = memberLabelRelations;
// TODO: replace with full member/product associations
if (member.email === 'with-product@test.com') {
member.products = [{slug: product.get('slug')}];
}
return models.Member.add(member, context.internal);
})
});
}).then(function () {
return Promise.each(_.cloneDeep(DataGenerator.forKnex.members_stripe_customers), function (customer) {
return models.MemberStripeCustomer.add(customer, context.internal);
});
}).then(function () {
return Promise.each(_.cloneDeep(DataGenerator.forKnex.stripe_prices), function (stripePrice) {
@ -541,8 +547,8 @@ const toDoList = {
member: function insertMember() {
return fixtures.insertOne('Member', 'members', 'createMember');
},
members: function insertMembersAndLabels() {
return fixtures.insertMembersAndLabels();
members: function insertMembersAndLabelsAndProducts() {
return fixtures.insertMembersAndLabelsAndProducts();
},
'members:emails': function insertEmailsAndRecipients() {
return fixtures.insertEmailsAndRecipients();

View file

@ -341,6 +341,27 @@ DataGenerator.Content = {
name: 'Vinz Clortho',
uuid: 'f6f91461-d7d8-4a3f-aa5d-8e582c40b344',
status: 'comped'
},
{
id: ObjectId().toHexString(),
email: 'vip@test.com',
name: 'Winston Zeddemore',
uuid: 'f6f91461-d7d8-4a3f-aa5d-8e582c40b345',
status: 'free'
},
{
id: ObjectId().toHexString(),
email: 'vip-paid@test.com',
name: 'Peter Venkman',
uuid: 'f6f91461-d7d8-4a3f-aa5d-8e582c40b346',
status: 'paid'
},
{
id: ObjectId().toHexString(),
email: 'with-product@test.com',
name: 'Dana Barrett',
uuid: 'f6f91461-d7d8-4a3f-aa5d-8e582c40b347',
status: 'paid'
}
],
@ -362,6 +383,11 @@ DataGenerator.Content = {
id: ObjectId().toHexString(),
name: 'Label 2',
slug: 'label-2'
},
{
id: ObjectId().toHexString(),
name: 'VIP',
slug: 'vip'
}
],
@ -381,10 +407,25 @@ DataGenerator.Content = {
email: 'trialing@test.com'
},
{
id: ObjectId().toHexString(),
member_id: null, // relation added later
customer_id: 'cus_HR3tBmNhx4QsZ0',
name: 'Vinz Clortho',
email: 'comped@test.com'
},
{
id: ObjectId().toHexString(),
member_id: null, // relation added later
customer_id: 'cus_HR3tBmNhx4QsZ1',
name: 'Peter Venkman',
email: 'vip-paid@test.com'
},
{
id: ObjectId().toHexString(),
member_id: null, // relation added later
customer_id: 'cus_HR3tBmNhx4QsZ2',
name: 'Dana Barrett',
email: 'with-product@test.com'
}
],
@ -643,6 +684,8 @@ DataGenerator.Content.email_recipients[3].member_id = DataGenerator.Content.memb
DataGenerator.Content.members_stripe_customers[0].member_id = DataGenerator.Content.members[2].id;
DataGenerator.Content.members_stripe_customers[1].member_id = DataGenerator.Content.members[3].id;
DataGenerator.Content.members_stripe_customers[2].member_id = DataGenerator.Content.members[4].id;
DataGenerator.Content.members_stripe_customers[3].member_id = DataGenerator.Content.members[6].id;
DataGenerator.Content.members_stripe_customers[4].member_id = DataGenerator.Content.members[7].id;
DataGenerator.forKnex = (function () {
function createBasic(overrides) {
@ -1166,17 +1209,29 @@ DataGenerator.forKnex = (function () {
createMember(DataGenerator.Content.members[1]),
createMember(DataGenerator.Content.members[2]),
createMember(DataGenerator.Content.members[3]),
createMember(DataGenerator.Content.members[4])
createMember(DataGenerator.Content.members[4]),
createMember(DataGenerator.Content.members[5]),
createMember(DataGenerator.Content.members[6]),
createMember(DataGenerator.Content.members[7])
];
const labels = [
createLabel(DataGenerator.Content.labels[0])
createLabel(DataGenerator.Content.labels[0]),
createLabel(DataGenerator.Content.labels[2])
];
const members_labels = [
createMembersLabels(
DataGenerator.Content.members[0].id,
DataGenerator.Content.labels[0].id
),
createMembersLabels(
DataGenerator.Content.members[5].id,
DataGenerator.Content.labels[2].id
),
createMembersLabels(
DataGenerator.Content.members[6].id,
DataGenerator.Content.labels[2].id
)
];
@ -1187,7 +1242,9 @@ DataGenerator.forKnex = (function () {
const members_stripe_customers = [
createBasic(DataGenerator.Content.members_stripe_customers[0]),
createBasic(DataGenerator.Content.members_stripe_customers[1]),
createBasic(DataGenerator.Content.members_stripe_customers[2])
createBasic(DataGenerator.Content.members_stripe_customers[2]),
createBasic(DataGenerator.Content.members_stripe_customers[3]),
createBasic(DataGenerator.Content.members_stripe_customers[4])
];
const stripe_products = [

View file

@ -564,10 +564,10 @@
dependencies:
"@tryghost/errors" "^0.2.11"
"@tryghost/admin-api-schema@2.1.1":
version "2.1.1"
resolved "https://registry.yarnpkg.com/@tryghost/admin-api-schema/-/admin-api-schema-2.1.1.tgz#98670e9b1dfa028b14abf542a57b3d7caf26cab6"
integrity sha512-UsYEggVR4X7oje+fMfizkSv+ThcAC/S9LtOcqbShhgI+xqNwQkueoQAQVpcHfA6y9kK/9wwGpJXaT5rM89WRjw==
"@tryghost/admin-api-schema@2.2.1":
version "2.2.1"
resolved "https://registry.yarnpkg.com/@tryghost/admin-api-schema/-/admin-api-schema-2.2.1.tgz#5d31abd194a5742d30b17ca230438a353b05b1aa"
integrity sha512-FDNYefBGsCdJ0Y/Suil8snye+cchl5B/sU5gJ25rLBRrN2AD9zAJM0N27R1+6R93MUlwsggEKM7T/6GxNhMudQ==
dependencies:
"@tryghost/errors" "^0.2.10"
bluebird "^3.5.3"
@ -732,10 +732,10 @@
jsonwebtoken "^8.5.1"
lodash "^4.17.15"
"@tryghost/members-api@1.4.0":
version "1.4.0"
resolved "https://registry.yarnpkg.com/@tryghost/members-api/-/members-api-1.4.0.tgz#4e801156e2bf1fa1f2917ab73ec6a14afefad183"
integrity sha512-Wi0yKY1XHuYNR9CFsQD6Iro4gjHC++iZkQt5hYTCUrsTni7wK2B1dAtVcbq7/HdOoRJn2t4t7n1WmN3jsKbT9A==
"@tryghost/members-api@1.5.0":
version "1.5.0"
resolved "https://registry.yarnpkg.com/@tryghost/members-api/-/members-api-1.5.0.tgz#f58fe7184283bda21f63f881f03a36cac8e1a7b9"
integrity sha512-MLYRNE/BAG5pz7wAYY1PIzHTMG1pJ0SEzUhtI2ylAwq+QZWIvN/Hr62pMTXpxzCe1kCCI+Irw9lZlYnrbQDt8Q==
dependencies:
"@tryghost/errors" "^0.2.9"
"@tryghost/magic-link" "^1.0.2"
@ -748,7 +748,7 @@
jsonwebtoken "^8.5.1"
leaky-bucket "2.2.0"
lodash "^4.17.11"
node-jose "^1.1.3"
node-jose "^2.0.0"
stripe "^8.142.0"
"@tryghost/members-csv@1.0.0":
@ -1640,13 +1640,6 @@ browser-stdout@1.3.1:
resolved "https://registry.yarnpkg.com/browser-stdout/-/browser-stdout-1.3.1.tgz#baa559ee14ced73452229bad7326467c61fabd60"
integrity sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==
browserify-zlib@^0.2.0:
version "0.2.0"
resolved "https://registry.yarnpkg.com/browserify-zlib/-/browserify-zlib-0.2.0.tgz#2869459d9aa3be245fe8fe2ca1f46e2e7f54d73f"
integrity sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==
dependencies:
pako "~1.0.5"
browserslist@^4.0.0, browserslist@^4.14.5, browserslist@^4.16.0:
version "4.16.6"
resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.16.6.tgz#d7901277a5a88e554ed305b183ec9b0c08f66fa2"
@ -6877,11 +6870,6 @@ node-forge@^0.10.0:
resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-0.10.0.tgz#32dea2afb3e9926f02ee5ce8794902691a676bf3"
integrity sha512-PPmu8eEeG9saEUvI97fm4OYxXVB6bFvyNTyiUOBichBpFG8A1Ljw3bY62+5oOjDEMHRnd0Y7HQ+x7uzxOzC6JA==
node-forge@^0.8.5:
version "0.8.5"
resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-0.8.5.tgz#57906f07614dc72762c84cef442f427c0e1b86ee"
integrity sha512-vFMQIWt+J/7FLNyKouZ9TazT74PRV3wgv9UT4cRjC8BffxFbKXkgIWR42URCPSnHm/QDz6BOlb2Q0U4+VQT67Q==
node-gyp@3.x:
version "3.8.0"
resolved "https://registry.yarnpkg.com/node-gyp/-/node-gyp-3.8.0.tgz#540304261c330e80d0d5edce253a68cb3964218c"
@ -6916,7 +6904,7 @@ node-gyp@^7.1.2:
tar "^6.0.2"
which "^2.0.2"
node-jose@2.0.0:
node-jose@2.0.0, node-jose@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/node-jose/-/node-jose-2.0.0.tgz#541c6b52c387a3f18fc06cd502baad759af9c470"
integrity sha512-j8zoFze1gijl8+DK/dSXXqX7+o2lMYv1XS+ptnXgGV/eloQaqq1YjNtieepbKs9jBS4WTnMOqyKSaQuunJzx0A==
@ -6931,22 +6919,6 @@ node-jose@2.0.0:
process "^0.11.10"
uuid "^3.3.3"
node-jose@^1.1.3:
version "1.1.4"
resolved "https://registry.yarnpkg.com/node-jose/-/node-jose-1.1.4.tgz#af3f44a392e586d26b123b0e12dc09bef1e9863b"
integrity sha512-L31IFwL3pWWcMHxxidCY51ezqrDXMkvlT/5pLTfNw5sXmmOLJuN6ug7txzF/iuZN55cRpyOmoJrotwBQIoo5Lw==
dependencies:
base64url "^3.0.1"
browserify-zlib "^0.2.0"
buffer "^5.5.0"
es6-promise "^4.2.8"
lodash "^4.17.15"
long "^4.0.0"
node-forge "^0.8.5"
process "^0.11.10"
react-zlib-js "^1.0.4"
uuid "^3.3.3"
node-loggly-bulk@^2.2.4:
version "2.2.5"
resolved "https://registry.yarnpkg.com/node-loggly-bulk/-/node-loggly-bulk-2.2.5.tgz#6f41136f91b363d1b50612e8be0063859226967e"
@ -7347,7 +7319,7 @@ pac-resolver@^3.0.0:
netmask "^1.0.6"
thunkify "^2.1.2"
pako@^1.0.11, pako@~1.0.5:
pako@^1.0.11:
version "1.0.11"
resolved "https://registry.yarnpkg.com/pako/-/pako-1.0.11.tgz#6c9599d340d54dfd3946380252a35705a6b992bf"
integrity sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==
@ -8145,11 +8117,6 @@ re2@^1.15.9:
nan "^2.14.2"
node-gyp "^7.1.2"
react-zlib-js@^1.0.4:
version "1.0.5"
resolved "https://registry.yarnpkg.com/react-zlib-js/-/react-zlib-js-1.0.5.tgz#7bb433e1a4ae53a8e6f361b3d36166baf5bbc60f"
integrity sha512-TLcPdmqhIl+ylwOwlfm1WUuI7NVvhAv3L74d1AabhjyaAbmLOROTA/Q4EQ/UMCFCOjIkVim9fT3UZOQSFk/mlA==
read-pkg-up@^7.0.1:
version "7.0.1"
resolved "https://registry.yarnpkg.com/read-pkg-up/-/read-pkg-up-7.0.1.tgz#f3a6135758459733ae2b95638056e1854e7ef507"