🐛 Fixed commenting on tier-only posts (#15333)

fixes https://github.com/TryGhost/Team/issues/1860

**Problem:**
Members were not able to comment on a post that was only visible for members with a specific tier.

**Causes:**
Content gating was done on models with missing relations.
- The products relation was not loaded on the member when doing content gating
- The tiers relation was not loaded on the post when doing content gating

**Tests:**
- Added for tier-only posts
- Added for paid-only commenting
This commit is contained in:
Simon Backx 2022-08-30 17:38:58 +02:00 committed by GitHub
parent e7786ca482
commit 4282ead3a7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 2643 additions and 670 deletions

View File

@ -160,7 +160,8 @@ class CommentsService {
id: member
}, {
require: true,
...options
...options,
withRelated: ['products']
});
this.checkCommentAccess(memberModel);
@ -169,7 +170,8 @@ class CommentsService {
id: post
}, {
require: true,
...options
...options,
withRelated: ['tiers']
});
this.checkPostAccess(postModel, memberModel);
@ -208,7 +210,8 @@ class CommentsService {
id: member
}, {
require: true,
...options
...options,
withRelated: ['products']
});
this.checkCommentAccess(memberModel);
@ -229,7 +232,8 @@ class CommentsService {
id: parentComment.get('post_id')
}, {
require: true,
...options
...options,
withRelated: ['tiers']
});
this.checkPostAccess(postModel, memberModel);

View File

@ -7,7 +7,11 @@ const moment = require('moment-timezone');
const settingsCache = require('../../../core/shared/settings-cache');
const sinon = require('sinon');
let membersAgent, membersAgent2, member, postId, postTitle, commentId;
let membersAgent, membersAgent2, postId, postTitle, commentId;
async function getPaidProduct() {
return await models.Product.findOne({type: 'paid'});
}
const commentMatcher = {
id: anyObjectId,
@ -54,7 +58,128 @@ function escapeRegExp(string) {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
async function testCanCommentOnPost(member) {
await models.Member.edit({last_seen_at: null, last_commented_at: null}, {id: member.get('id')});
const {body} = await membersAgent
.post(`/api/comments/`)
.body({comments: [{
post_id: postId,
html: '<div></div><p></p><p>This is a <strong>message</strong></p><p></p><p></p><p>New line</p><p></p>'
}]})
.expectStatus(201)
.matchHeaderSnapshot({
etag: anyEtag,
location: anyLocationFor('comments')
})
.matchBodySnapshot({
comments: [commentMatcher]
});
// Save for other tests
commentId = body.comments[0].id;
// Check if author got an email
mockManager.assert.sentEmailCount(1);
mockManager.assert.sentEmail({
subject: '💬 New comment on your post: ' + postTitle,
to: fixtureManager.get('users', 0).email,
// Note that the <strong> tag is removed by the sanitizer
html: new RegExp(escapeRegExp('<p>This is a message</p><p></p><p>New line</p>'))
});
// Wait for the dispatched events (because this happens async)
await sleep(200);
// Check last_updated_at changed?
member = await models.Member.findOne({id: member.id});
should.notEqual(member.get('last_seen_at'), null, 'The member should have a `last_seen_at` property after posting a comment.');
// Check last_commented_at changed?
should.notEqual(member.get('last_commented_at'), null, 'The member should have a `last_commented_at` property after posting a comment.');
}
async function testCanReply(member) {
const date = new Date(0);
await models.Member.edit({last_seen_at: date, last_commented_at: date}, {id: member.get('id')});
const {body} = await membersAgent
.post(`/api/comments/`)
.body({comments: [{
post_id: postId,
parent_id: fixtureManager.get('comments', 0).id,
html: 'This is a reply'
}]})
.expectStatus(201)
.matchHeaderSnapshot({
etag: anyEtag,
location: anyLocationFor('comments')
})
.matchBodySnapshot({
comments: [commentMatcher]
});
mockManager.assert.sentEmailCount(2);
mockManager.assert.sentEmail({
subject: '💬 New comment on your post: ' + postTitle,
to: fixtureManager.get('users', 0).email
});
mockManager.assert.sentEmail({
subject: '↪️ New reply to your comment on Ghost',
to: fixtureManager.get('members', 0).email
});
// Wait for the dispatched events (because this happens async)
await sleep(250);
// Check last_updated_at changed?
member = await models.Member.findOne({id: member.id});
should.notEqual(member.get('last_seen_at').getTime(), date.getTime(), 'Should update `last_seen_at` property after posting a comment.');
// Check last_commented_at changed?
should.notEqual(member.get('last_commented_at').getTime(), date.getTime(), 'Should update `last_commented_at` property after posting a comment.');
}
async function testCannotCommentOnPost() {
await membersAgent
.post(`/api/comments/`)
.body({comments: [{
post_id: postId,
html: '<div></div><p></p><p>This is a <strong>message</strong></p><p></p><p></p><p>New line</p><p></p>'
}]})
.expectStatus(403)
.matchHeaderSnapshot({
etag: anyEtag
})
.matchBodySnapshot({
errors: [{
id: anyErrorId
}]
});
}
async function testCannotReply() {
await membersAgent
.post(`/api/comments/`)
.body({comments: [{
post_id: postId,
parent_id: fixtureManager.get('comments', 0).id,
html: 'This is a reply'
}]})
.expectStatus(403)
.matchHeaderSnapshot({
etag: anyEtag
})
.matchBodySnapshot({
errors: [{
id: anyErrorId
}]
});
}
describe('Comments API', function () {
let member;
before(async function () {
membersAgent = await agentProvider.getMembersAPIAgent();
membersAgent2 = await agentProvider.getMembersAPIAgent();
@ -73,7 +198,8 @@ describe('Comments API', function () {
mockManager.restore();
});
describe('when not authenticated but enabled', function () {
describe('when commenting enabled for all', function () {
describe('when not authenticated', function () {
beforeEach(function () {
const getStub = sinon.stub(settingsCache, 'get');
getStub.callsFake((key, options) => {
@ -118,33 +244,6 @@ describe('Comments API', function () {
});
});
describe('when not enabled', function () {
beforeEach(async function () {
await membersAgent.loginAs('member@example.com');
const getStub = sinon.stub(settingsCache, 'get');
getStub.callsFake((key, options) => {
if (key === 'comments_enabled') {
return 'off';
}
return getStub.wrappedMethod.call(settingsCache, key, options);
});
});
afterEach(async function () {
sinon.restore();
});
it('Can comment on a post', async function () {
const {body} = await membersAgent
.post(`/api/comments/`)
.body({comments: [{
post_id: postId,
html: '<p>This is a <strong>message</strong></p><p>New line</p>'
}]})
.expectStatus(405);
});
});
describe('when authenticated', function () {
before(async function () {
await membersAgent.loginAs('member@example.com');
@ -166,43 +265,7 @@ describe('Comments API', function () {
});
it('Can comment on a post', async function () {
await models.Member.edit({last_seen_at: null, last_commented_at: null}, {id: member.get('id')});
const {body} = await membersAgent
.post(`/api/comments/`)
.body({comments: [{
post_id: postId,
html: '<div></div><p></p><p>This is a <strong>message</strong></p><p></p><p></p><p>New line</p><p></p>'
}]})
.expectStatus(201)
.matchHeaderSnapshot({
etag: anyEtag,
location: anyLocationFor('comments')
})
.matchBodySnapshot({
comments: [commentMatcher]
});
// Save for other tests
commentId = body.comments[0].id;
// Check if author got an email
mockManager.assert.sentEmailCount(1);
mockManager.assert.sentEmail({
subject: '💬 New comment on your post: ' + postTitle,
to: fixtureManager.get('users', 0).email,
// Note that the <strong> tag is removed by the sanitizer
html: new RegExp(escapeRegExp('<p>This is a message</p><p></p><p>New line</p>'))
});
// Wait for the dispatched events (because this happens async)
await sleep(200);
// Check last_updated_at changed?
member = await models.Member.findOne({id: member.id});
should.notEqual(member.get('last_seen_at'), null, 'The member should have a `last_seen_at` property after posting a comment.');
// Check last_commented_at changed?
should.notEqual(member.get('last_commented_at'), null, 'The member should have a `last_commented_at` property after posting a comment.');
await testCanCommentOnPost(member);
});
it('Can browse all comments of a post', async function () {
@ -258,45 +321,7 @@ describe('Comments API', function () {
});
it('Can reply to a comment', async function () {
const date = new Date(0);
await models.Member.edit({last_seen_at: date, last_commented_at: date}, {id: member.get('id')});
const {body} = await membersAgent
.post(`/api/comments/`)
.body({comments: [{
post_id: postId,
parent_id: fixtureManager.get('comments', 0).id,
html: 'This is a reply'
}]})
.expectStatus(201)
.matchHeaderSnapshot({
etag: anyEtag,
location: anyLocationFor('comments')
})
.matchBodySnapshot({
comments: [commentMatcher]
});
mockManager.assert.sentEmailCount(2);
mockManager.assert.sentEmail({
subject: '💬 New comment on your post: ' + postTitle,
to: fixtureManager.get('users', 0).email
});
mockManager.assert.sentEmail({
subject: '↪️ New reply to your comment on Ghost',
to: fixtureManager.get('members', 0).email
});
// Wait for the dispatched events (because this happens async)
await sleep(250);
// Check last_updated_at changed?
member = await models.Member.findOne({id: member.id});
should.notEqual(member.get('last_seen_at').getTime(), date.getTime(), 'Should update `last_seen_at` property after posting a comment.');
// Check last_commented_at changed?
should.notEqual(member.get('last_commented_at').getTime(), date.getTime(), 'Should update `last_commented_at` property after posting a comment.');
await testCanReply(member);
});
let testReplyId;
@ -755,9 +780,9 @@ describe('Comments API', function () {
.post(`api/comments/counts`)
.body({
ids: [
postId = fixtureManager.get('posts', 0).id,
postId = fixtureManager.get('posts', 1).id,
postId = fixtureManager.get('posts', 2).id
fixtureManager.get('posts', 0).id,
fixtureManager.get('posts', 1).id,
fixtureManager.get('posts', 2).id
]
})
.expectStatus(200)
@ -795,3 +820,163 @@ describe('Comments API', function () {
});
});
});
describe('when commenting disabled', function () {
beforeEach(async function () {
await membersAgent.loginAs('member@example.com');
const getStub = sinon.stub(settingsCache, 'get');
getStub.callsFake((key, options) => {
if (key === 'comments_enabled') {
return 'off';
}
return getStub.wrappedMethod.call(settingsCache, key, options);
});
});
afterEach(async function () {
sinon.restore();
});
it('Can not comment on a post', async function () {
const {body} = await membersAgent
.post(`/api/comments/`)
.body({comments: [{
post_id: postId,
html: '<p>This is a <strong>message</strong></p><p>New line</p>'
}]})
.expectStatus(405);
});
});
describe('when paid only commenting', function () {
beforeEach(async function () {
const getStub = sinon.stub(settingsCache, 'get');
getStub.callsFake((key, options) => {
if (key === 'comments_enabled') {
return 'paid';
}
return getStub.wrappedMethod.call(settingsCache, key, options);
});
});
afterEach(async function () {
sinon.restore();
});
describe('Members with access', function () {
before(async function () {
await membersAgent.loginAs('paid@example.com');
member = await models.Member.findOne({email: 'paid@example.com'}, {require: true});
const product = await getPaidProduct();
// Attach comped subscription to this member
await models.Member.edit({
status: 'comped',
products: [
{
id: product.id
}
]
}, {id: member.id});
});
it('Can comment on a post', async function () {
await testCanCommentOnPost(member);
});
it('Can reply to a comment', async function () {
await testCanReply(member);
});
});
describe('Members without access', function () {
before(async function () {
await membersAgent.loginAs('free@example.com');
});
it('Can not comment on a post', async function () {
await testCannotCommentOnPost();
});
it('Can not reply to a comment', async function () {
await testCannotReply();
});
});
});
// Only allow members with access to a given post to comment on that post
describe('Tier-only posts', function () {
let post;
let product;
before(async function () {
product = await getPaidProduct();
// Limit post access
post = await models.Post.findOne({id: postId}, {require: true});
await models.Post.edit({
visibility: 'tiers',
tiers: [
{
id: product.id
}
]
}, {id: post.id});
});
beforeEach(function () {
const getStub = sinon.stub(settingsCache, 'get');
getStub.callsFake((key, options) => {
if (key === 'comments_enabled') {
return 'all';
}
return getStub.wrappedMethod.call(settingsCache, key, options);
});
});
afterEach(async function () {
sinon.restore();
});
describe('Members with access', function () {
before(async function () {
await membersAgent.loginAs('member-premium@example.com');
member = await models.Member.findOne({email: 'member-premium@example.com'}, {require: true});
// Attach comped subscription to this member
await models.Member.edit({
status: 'comped',
products: [
{
id: product.id
}
]
}, {id: member.id});
});
it('Can comment on a post', async function () {
await testCanCommentOnPost(member);
});
it('Can reply to a comment', async function () {
await testCanReply(member);
});
});
describe('Members without access', function () {
before(async function () {
await membersAgent.loginAs('member-not-premium@example.com');
});
it('Can not comment on a post', async function () {
await testCannotCommentOnPost();
});
it('Can not reply to a comment', async function () {
await testCannotReply();
});
});
});
});