mirror of
https://github.com/TryGhost/Ghost.git
synced 2025-02-03 23:00:14 -05:00
c36e749820
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
450 lines
17 KiB
JavaScript
450 lines
17 KiB
JavaScript
const should = require('should');
|
|
const sinon = require('sinon');
|
|
const supertest = require('supertest');
|
|
const moment = require('moment');
|
|
const testUtils = require('../utils');
|
|
const configUtils = require('../utils/configUtils');
|
|
const settingsCache = require('../../core/server/services/settings/cache');
|
|
|
|
// @TODO: if only this suite is run some of the tests will fail due to
|
|
// wrong template loading issues which would need to be investigated
|
|
// As a workaround run it with some of other tests e.g. "frontend_spec"
|
|
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;
|
|
|
|
sinon.stub(settingsCache, 'get').callsFake(function (key, options) {
|
|
if (key === 'labs') {
|
|
return {members: true};
|
|
}
|
|
|
|
if (key === 'active_theme') {
|
|
return 'price-data-test-theme';
|
|
}
|
|
|
|
return originalSettingsCacheGetFn(key, options);
|
|
});
|
|
await testUtils.startGhost();
|
|
await testUtils.initFixtures('members');
|
|
request = supertest.agent(configUtils.config.get('url'));
|
|
});
|
|
|
|
after(function () {
|
|
sinon.restore();
|
|
});
|
|
|
|
describe('Member routes', function () {
|
|
it('should error serving webhook endpoint without any parameters', async function () {
|
|
await request.post('/members/webhooks/stripe')
|
|
.expect(400);
|
|
});
|
|
|
|
it('should error when invalid member token is passed into session', async function () {
|
|
await request.get('/members/api/session')
|
|
.expect(400);
|
|
});
|
|
|
|
it('should return no content when removing member sessions', async function () {
|
|
await request.del('/members/api/session')
|
|
.expect(204);
|
|
});
|
|
|
|
it('should error for invalid member token on member data endpoint', async function () {
|
|
await request.get('/members/api/member')
|
|
.expect(401);
|
|
});
|
|
|
|
it('should serve member site endpoint', async function () {
|
|
await request.get('/members/api/site')
|
|
.expect(200);
|
|
});
|
|
|
|
it('should error for invalid data on member magic link endpoint', async function () {
|
|
await request.post('/members/api/send-magic-link')
|
|
.expect(400);
|
|
});
|
|
|
|
it('should error for invalid data on members create checkout session endpoint', async function () {
|
|
await request.post('/members/api/create-stripe-checkout-session')
|
|
.expect(400);
|
|
});
|
|
|
|
it('should error for invalid data on members create update session endpoint', async function () {
|
|
await request.post('/members/api/create-stripe-update-session')
|
|
.expect(400);
|
|
});
|
|
|
|
it('should error for invalid data on members subscription endpoint', async function () {
|
|
await request.put('/members/api/subscriptions/123')
|
|
.expect(400);
|
|
});
|
|
|
|
it('should serve theme 404 on members endpoint', async function () {
|
|
await request.get('/members/')
|
|
.expect(404)
|
|
.expect('Content-Type', 'text/html; charset=utf-8');
|
|
});
|
|
|
|
it('should redirect invalid token on members endpoint', async function () {
|
|
await request.get('/members/?token=abc&action=signup')
|
|
.expect(302)
|
|
.expect('Location', '/?action=signup&success=false');
|
|
});
|
|
});
|
|
|
|
describe('Price data', function () {
|
|
it('Can be used as a number, and with the price helper', async function () {
|
|
const res = await request.get('/');
|
|
|
|
// Check out test/utils/fixtures/themes/price-data-test-theme/index.hbs
|
|
// To see where this is coming from.
|
|
//
|
|
const legacyUse = /You can use the price data as a number: 5/;
|
|
const withPriceHelper = /You can pass price data to the price helper: \$5/;
|
|
|
|
should.exist(res.text.match(legacyUse));
|
|
should.exist(res.text.match(withPriceHelper));
|
|
});
|
|
});
|
|
|
|
describe('Content gating', function () {
|
|
let publicPost;
|
|
let membersPost;
|
|
let paidPost;
|
|
let membersPostWithPaywallCard;
|
|
let labelPost;
|
|
let productPost;
|
|
|
|
before(function () {
|
|
publicPost = testUtils.DataGenerator.forKnex.createPost({
|
|
slug: 'free-to-see',
|
|
visibility: 'public',
|
|
published_at: moment().add(15, 'seconds').toDate() // here to ensure sorting is not modified
|
|
});
|
|
|
|
membersPost = testUtils.DataGenerator.forKnex.createPost({
|
|
slug: 'thou-shalt-not-be-seen',
|
|
visibility: 'members',
|
|
published_at: moment().add(45, 'seconds').toDate() // here to ensure sorting is not modified
|
|
});
|
|
|
|
paidPost = testUtils.DataGenerator.forKnex.createPost({
|
|
slug: 'thou-shalt-be-paid-for',
|
|
visibility: 'paid',
|
|
published_at: moment().add(30, 'seconds').toDate() // here to ensure sorting is not modified
|
|
});
|
|
|
|
membersPostWithPaywallCard = testUtils.DataGenerator.forKnex.createPost({
|
|
slug: 'thou-shalt-have-a-taste',
|
|
visibility: 'members',
|
|
mobiledoc: '{"version":"0.3.1","markups":[],"atoms":[],"cards":[["paywall",{}]],"sections":[[1,"p",[[0,[],0,"Free content"]]],[10,0],[1,"p",[[0,[],0,"Members content"]]]]}',
|
|
html: '<p>Free content</p><!--members-only--><p>Members content</p>',
|
|
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,
|
|
labelPost,
|
|
productPost
|
|
]);
|
|
});
|
|
|
|
describe('as non-member', function () {
|
|
it('can read public post content', function () {
|
|
return request
|
|
.get('/free-to-see/')
|
|
.expect(200)
|
|
.then((res) => {
|
|
res.text.should.containEql('<h2 id="markdown">markdown</h2>');
|
|
});
|
|
});
|
|
|
|
it('cannot read members post content', function () {
|
|
return request
|
|
.get('/thou-shalt-not-be-seen/')
|
|
.expect(200)
|
|
.then((res) => {
|
|
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/')
|
|
.expect(200)
|
|
.then((res) => {
|
|
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 () {
|
|
await loginAsMember('member1@test.com');
|
|
});
|
|
|
|
it('can read public post content', function () {
|
|
return request
|
|
.get('/free-to-see/')
|
|
.expect(200)
|
|
.then((res) => {
|
|
res.text.should.containEql('<h2 id="markdown">markdown</h2>');
|
|
});
|
|
});
|
|
|
|
it('can read members post content', function () {
|
|
return request
|
|
.get('/thou-shalt-not-be-seen/')
|
|
.expect(200)
|
|
.then((res) => {
|
|
res.text.should.containEql('<h2 id="markdown">markdown</h2>');
|
|
});
|
|
});
|
|
|
|
it('cannot read paid post content', function () {
|
|
return request
|
|
.get('/thou-shalt-be-paid-for/')
|
|
.expect(200)
|
|
.then((res) => {
|
|
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 () {
|
|
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('paid@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');
|
|
});
|
|
});
|
|
|
|
it('can read public post content', function () {
|
|
return request
|
|
.get('/free-to-see/')
|
|
.expect(200)
|
|
.then((res) => {
|
|
res.text.should.containEql('<h2 id="markdown">markdown</h2>');
|
|
});
|
|
});
|
|
|
|
it('can read members post content', function () {
|
|
return request
|
|
.get('/thou-shalt-not-be-seen/')
|
|
.expect(200)
|
|
.then((res) => {
|
|
res.text.should.containEql('<h2 id="markdown">markdown</h2>');
|
|
});
|
|
});
|
|
|
|
it('can read paid post content', function () {
|
|
return request
|
|
.get('/thou-shalt-be-paid-for/')
|
|
.expect(200)
|
|
.then((res) => {
|
|
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 () {
|
|
await loginAsMember('comped@test.com');
|
|
});
|
|
|
|
it('can read public post content', function () {
|
|
return request
|
|
.get('/free-to-see/')
|
|
.expect(200)
|
|
.then((res) => {
|
|
res.text.should.containEql('<h2 id="markdown">markdown</h2>');
|
|
});
|
|
});
|
|
|
|
it('can read members post content', function () {
|
|
return request
|
|
.get('/thou-shalt-not-be-seen/')
|
|
.expect(200)
|
|
.then((res) => {
|
|
res.text.should.containEql('<h2 id="markdown">markdown</h2>');
|
|
});
|
|
});
|
|
|
|
it('can read paid post content', function () {
|
|
return request
|
|
.get('/thou-shalt-be-paid-for/')
|
|
.expect(200)
|
|
.then((res) => {
|
|
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>');
|
|
});
|
|
});
|
|
});
|
|
});
|
|
});
|