0
Fork 0
mirror of https://github.com/TryGhost/Ghost.git synced 2025-03-11 02:12:21 -05:00

🐛 Fixed complimentary members' content gating (#12761)

no issue

Comped members were not able to view paid-member content because content gating was only looking for `member.status === 'paid'` which doesn't take into consideration members on a "complimentary" plan.

- added front-end acceptance tests for member access to posts
- updated content-gating check to take comped members into consideration
This commit is contained in:
Kevin Ansfield 2021-03-15 06:13:48 +00:00 committed by GitHub
parent e87ded8d72
commit 19d5448101
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 279 additions and 28 deletions

View file

@ -23,7 +23,7 @@ function checkPostAccess(post, member) {
return PERMIT_ACCESS;
}
if (post.visibility === 'paid' && member.status === 'paid') {
if (post.visibility === 'paid' && (member.status === 'paid' || member.status === 'comped' || member.comped)) {
return PERMIT_ACCESS;
}

View file

@ -36,7 +36,7 @@ describe('Members API', function () {
const jsonResponse = res.body;
should.exist(jsonResponse);
should.exist(jsonResponse.members);
jsonResponse.members.should.have.length(4);
jsonResponse.members.should.have.length(5);
localUtils.API.checkResponse(jsonResponse.members[0], 'member', 'subscriptions');
testUtils.API.isISO8601(jsonResponse.members[0].created_at).should.be.true();
@ -45,7 +45,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', 4);
jsonResponse.meta.pagination.should.have.property('total', 5);
jsonResponse.meta.pagination.should.have.property('next', null);
jsonResponse.meta.pagination.should.have.property('prev', null);
});
@ -393,8 +393,8 @@ describe('Members API', function () {
should.exist(jsonResponse.total_on_date);
should.exist(jsonResponse.new_today);
// 4 from fixtures, 2 from above posts, 2 from above import
jsonResponse.total.should.equal(8);
// 5 from fixtures, 2 from above posts, 2 from above import
jsonResponse.total.should.equal(9);
return jsonResponse;
}
@ -427,8 +427,8 @@ describe('Members API', function () {
dateStr = currentRangeDate.format('YYYY-MM-DD');
// set the end date to match the number of members added from fixtures posts and imports
// 4 from fixtures, 2 from above posts, 2 from above import
output[dateStr] = 8;
// 5 from fixtures, 2 from above posts, 2 from above import
output[dateStr] = 9;
// deep equality check that the objects match...
jsonResponse.total_on_date.should.eql(output);

View file

@ -1,6 +1,7 @@
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');
@ -8,11 +9,12 @@ 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('Basic Members Routes', function () {
describe('Front-end members behaviour', function () {
let request;
before(async function () {
await testUtils.startGhost();
await testUtils.initFixtures('members');
request = supertest.agent(configUtils.config.get('url'));
});
@ -32,7 +34,7 @@ describe('Basic Members Routes', function () {
sinon.restore();
});
describe('Routes', function () {
describe('Member routes', function () {
it('should error serving webhook endpoint without any parameters', async function () {
await request.post('/members/webhooks/stripe')
.expect(400);
@ -90,4 +92,221 @@ describe('Basic Members Routes', function () {
.expect('Location', '/?action=signup&success=false');
});
});
describe('Content gating', function () {
let publicPost;
let membersPost;
let paidPost;
let membersPostWithPaywallCard;
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()
});
return testUtils.fixtures.insertPosts([
publicPost,
membersPost,
paidPost,
membersPostWithPaywallCard
]);
});
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>');
});
});
});
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');
});
});
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>');
});
});
});
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>');
});
});
});
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');
});
});
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>');
});
});
});
});
});

View file

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

View file

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

View file

@ -334,6 +334,13 @@ DataGenerator.Content = {
name: 'Ray Stantz',
uuid: 'f6f91461-d7d8-4a3f-aa5d-8e582c40b343',
status: 'paid'
},
{
id: ObjectId.generate(),
email: 'comped@test.com',
name: 'Vinz Clortho',
uuid: 'f6f91461-d7d8-4a3f-aa5d-8e582c40b344',
status: 'comped'
}
],
@ -364,6 +371,12 @@ DataGenerator.Content = {
customer_id: 'cus_HR3tBmNhx4QsZZ',
name: 'Ray Stantz',
email: 'trialing@test.com'
},
{
member_id: null, // relation added later
customer_id: 'cus_HR3tBmNhx4QsZ0',
name: 'Vinz Clortho',
email: 'comped@test.com'
}
],
@ -397,6 +410,21 @@ DataGenerator.Content = {
plan_interval: 'month',
plan_amount: '1000',
plan_currency: 'usd'
},
{
id: ObjectId.generate(),
customer_id: 'cus_HR3tBmNhx4QsZ0',
subscription_id: 'sub_HR3tLNgGAHsa7d',
plan_id: '173e16a1fffa7d232b398e4a9b08d266a456ae8f3d23e5f11cc608ced6730ba0',
status: 'active',
cancel_at_period_end: true,
current_period_end: '2025-07-09 19:01:20',
start_date: '2020-06-09 19:01:20',
default_payment_card_last4: '4242',
plan_nickname: 'Complimentary',
plan_interval: 'year',
plan_amount: '0',
plan_currency: 'usd'
}
],
@ -562,6 +590,7 @@ DataGenerator.Content.email_recipients[3].email_id = DataGenerator.Content.email
DataGenerator.Content.email_recipients[3].member_id = DataGenerator.Content.members[3].id;
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.forKnex = (function () {
function createBasic(overrides) {
@ -1076,7 +1105,8 @@ DataGenerator.forKnex = (function () {
createMember(DataGenerator.Content.members[0]),
createMember(DataGenerator.Content.members[1]),
createMember(DataGenerator.Content.members[2]),
createMember(DataGenerator.Content.members[3])
createMember(DataGenerator.Content.members[3]),
createMember(DataGenerator.Content.members[4])
];
const labels = [
@ -1092,12 +1122,14 @@ 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[1]),
createBasic(DataGenerator.Content.members_stripe_customers[2])
];
const stripe_customer_subscriptions = [
createBasic(DataGenerator.Content.members_stripe_customers_subscriptions[0]),
createBasic(DataGenerator.Content.members_stripe_customers_subscriptions[1])
createBasic(DataGenerator.Content.members_stripe_customers_subscriptions[1]),
createBasic(DataGenerator.Content.members_stripe_customers_subscriptions[2])
];
const snippets = [