75bb53f065
fixes https://github.com/TryGhost/Product/issues/3738 https://www.notion.so/ghost/Member-Session-Invalidation-13254316f2244c34bcbc65c101eb5cc4 - Adds the transient_id column to the members table. This defaults to email, to keep it backwards compatible (not logging out all existing sessions) - Instead of using the email in the cookies, we now use the transient_id - Updating the transient_id means invalidating all sessions of a member - Adds an endpoint to the admin api to log out a member from all devices - Added the `all` body property to the DELETE session endpoint in the members API. Setting it to true will sign a member out from all devices. - Adds a UI button in Admin to sign a member out from all devices - Portal 'sign out of all devices' will not be added for now Related changes (added because these areas were affected by the code changes): - Adds a serializer to member events / activity feed endpoints - all member fields were returned here, so the transient_id would also be returned - which is not needed and bloats the API response size (`transient_id` is not a secret because the cookies are signed) - Removed `loadMemberSession` from public settings browse (not used anymore + bad pattern) Performance tests on site with 50.000 members (on Macbook M1 Pro): - Migrate: 6s (adding column 4s, setting to email is 1s, dropping nullable: 1s) - Rollback: 2s
195 lines
7.5 KiB
JavaScript
195 lines
7.5 KiB
JavaScript
const {agentProvider, mockManager, fixtureManager} = require('../../utils/e2e-framework');
|
|
const {stripeMocker} = require('../../utils/e2e-framework-mock-manager');
|
|
const models = require('../../../core/server/models');
|
|
const assert = require('assert/strict');
|
|
const urlService = require('../../../core/server/services/url');
|
|
const DomainEvents = require('@tryghost/domain-events');
|
|
|
|
let membersAgent, adminAgent;
|
|
|
|
async function getPost(id) {
|
|
// eslint-disable-next-line dot-notation
|
|
return await models['Post'].where('id', id).fetch({require: true});
|
|
}
|
|
|
|
describe('Create Stripe Checkout Session for Donations', function () {
|
|
before(async function () {
|
|
const agents = await agentProvider.getAgentsForMembers();
|
|
membersAgent = agents.membersAgent;
|
|
adminAgent = agents.adminAgent;
|
|
|
|
await fixtureManager.init('posts', 'members');
|
|
await adminAgent.loginAsOwner();
|
|
});
|
|
|
|
beforeEach(function () {
|
|
mockManager.mockStripe();
|
|
mockManager.mockMail();
|
|
});
|
|
|
|
afterEach(function () {
|
|
mockManager.restore();
|
|
});
|
|
|
|
it('Can create an anonymous checkout session for a donation', async function () {
|
|
// Fake a visit to a post
|
|
const post = await getPost(fixtureManager.get('posts', 0).id);
|
|
const url = urlService.getUrlByResourceId(post.id, {absolute: false});
|
|
|
|
await membersAgent.post('/api/create-stripe-checkout-session/')
|
|
.body({
|
|
customerEmail: 'paid@test.com',
|
|
type: 'donation',
|
|
successUrl: 'https://example.com/?type=success',
|
|
cancelUrl: 'https://example.com/?type=cancel',
|
|
metadata: {
|
|
test: 'hello',
|
|
urlHistory: [
|
|
{
|
|
path: url,
|
|
time: Date.now(),
|
|
referrerMedium: null,
|
|
referrerSource: 'ghost-explore',
|
|
referrerUrl: 'https://example.com/blog/'
|
|
}
|
|
]
|
|
}
|
|
})
|
|
.expectStatus(200)
|
|
.matchBodySnapshot();
|
|
|
|
// Send a webhook of a paid invoice for this session
|
|
await stripeMocker.sendWebhook({
|
|
type: 'invoice.payment_succeeded',
|
|
data: {
|
|
object: {
|
|
type: 'invoice',
|
|
paid: true,
|
|
amount_paid: 1200,
|
|
currency: 'usd',
|
|
customer: (stripeMocker.checkoutSessions[0].customer),
|
|
customer_name: 'Paid Test',
|
|
customer_email: 'exampledonation@example.com',
|
|
metadata: {
|
|
...(stripeMocker.checkoutSessions[0].invoice_creation?.invoice_data?.metadata ?? {})
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
// Check email received
|
|
mockManager.assert.sentEmail({
|
|
subject: '💰 One-time payment received: $12.00 from Paid Test',
|
|
to: 'jbloggs@example.com'
|
|
});
|
|
|
|
// Check stored in database
|
|
const lastDonation = await models.DonationPaymentEvent.findOne({
|
|
email: 'exampledonation@example.com'
|
|
}, {require: true});
|
|
assert.equal(lastDonation.get('amount'), 1200);
|
|
assert.equal(lastDonation.get('currency'), 'usd');
|
|
assert.equal(lastDonation.get('email'), 'exampledonation@example.com');
|
|
assert.equal(lastDonation.get('name'), 'Paid Test');
|
|
assert.equal(lastDonation.get('member_id'), null);
|
|
|
|
// Check referrer
|
|
assert.equal(lastDonation.get('referrer_url'), 'example.com');
|
|
assert.equal(lastDonation.get('referrer_medium'), 'Ghost Network');
|
|
assert.equal(lastDonation.get('referrer_source'), 'Ghost Explore');
|
|
|
|
// Check attributed correctly
|
|
assert.equal(lastDonation.get('attribution_id'), post.id);
|
|
assert.equal(lastDonation.get('attribution_type'), 'post');
|
|
assert.equal(lastDonation.get('attribution_url'), url);
|
|
});
|
|
|
|
it('Can create a member checkout session for a donation', async function () {
|
|
// Fake a visit to a post
|
|
const post = await getPost(fixtureManager.get('posts', 0).id);
|
|
const url = urlService.getUrlByResourceId(post.id, {absolute: false});
|
|
|
|
const email = 'test-member-create-donation-session@email.com';
|
|
|
|
const membersService = require('../../../core/server/services/members');
|
|
const member = await membersService.api.members.create({email, name: 'Member Test'});
|
|
const token = await membersService.api.getMemberIdentityToken(member.get('transient_id'));
|
|
|
|
await DomainEvents.allSettled();
|
|
|
|
// Check email received
|
|
mockManager.assert.sentEmail({
|
|
subject: '🥳 Free member signup: Member Test',
|
|
to: 'jbloggs@example.com'
|
|
});
|
|
|
|
await membersAgent.post('/api/create-stripe-checkout-session/')
|
|
.body({
|
|
customerEmail: email,
|
|
identity: token,
|
|
type: 'donation',
|
|
successUrl: 'https://example.com/?type=success',
|
|
cancelUrl: 'https://example.com/?type=cancel',
|
|
metadata: {
|
|
test: 'hello',
|
|
urlHistory: [
|
|
{
|
|
path: url,
|
|
time: Date.now(),
|
|
referrerMedium: null,
|
|
referrerSource: 'ghost-explore',
|
|
referrerUrl: 'https://example.com/blog/'
|
|
}
|
|
]
|
|
}
|
|
})
|
|
.expectStatus(200)
|
|
.matchBodySnapshot();
|
|
|
|
// Send a webhook of a paid invoice for this session
|
|
await stripeMocker.sendWebhook({
|
|
type: 'invoice.payment_succeeded',
|
|
data: {
|
|
object: {
|
|
type: 'invoice',
|
|
paid: true,
|
|
amount_paid: 1220,
|
|
currency: 'eur',
|
|
customer: (stripeMocker.checkoutSessions[0].customer),
|
|
customer_name: 'Member Test',
|
|
customer_email: email,
|
|
metadata: {
|
|
...(stripeMocker.checkoutSessions[0].invoice_creation?.invoice_data?.metadata ?? {})
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
// Check email received
|
|
mockManager.assert.sentEmail({
|
|
subject: '💰 One-time payment received: €12.20 from Member Test',
|
|
to: 'jbloggs@example.com'
|
|
});
|
|
|
|
// Check stored in database
|
|
const lastDonation = await models.DonationPaymentEvent.findOne({
|
|
email
|
|
}, {require: true});
|
|
assert.equal(lastDonation.get('amount'), 1220);
|
|
assert.equal(lastDonation.get('currency'), 'eur');
|
|
assert.equal(lastDonation.get('email'), email);
|
|
assert.equal(lastDonation.get('name'), 'Member Test');
|
|
assert.equal(lastDonation.get('member_id'), member.id);
|
|
|
|
// Check referrer
|
|
assert.equal(lastDonation.get('referrer_url'), 'example.com');
|
|
assert.equal(lastDonation.get('referrer_medium'), 'Ghost Network');
|
|
assert.equal(lastDonation.get('referrer_source'), 'Ghost Explore');
|
|
|
|
// Check attributed correctly
|
|
assert.equal(lastDonation.get('attribution_id'), post.id);
|
|
assert.equal(lastDonation.get('attribution_type'), 'post');
|
|
assert.equal(lastDonation.get('attribution_url'), url);
|
|
});
|
|
});
|