Ghost/ghost/core/test/e2e-api/members/send-magic-link.test.js
Chris Raible 01d0b2b304
Added new member signup flow behind labs flag (#19986)
ref https://linear.app/tryghost/issue/KTLO-1/members-spam-signups

- Some customers are seeing many spammy signups ("hundreds a day") — our
hypothesis is that bots and/or email link checkers are able to signup by
simply following the link in the email without even loading the page in
a browser.
- Currently new members signup by clicking a magic link in an email,
which is a simple GET request. When the user (or a bot) clicks that link, Ghost
creates the member and signs them in for the first time.
- This change, behind an alpha flag, requires a new member to click the
link in the email, which takes them to a new frontend route `/confirm_signup/`, then submit a form on the page which sends a POST request to the
server. If JavaScript is enabled, the form will be submitted
automatically so the only change to the user is an extra flash/redirect
before being signed in and redirected to the homepage.
- This change is behind the alpha flag `membersSpamPrevention` so we can
test it out on a few customer's sites and see if it helps reduce the
spam signups. With the flag off, the signup flow remains the same as
before.
2024-04-04 15:25:41 -07:00

296 lines
10 KiB
JavaScript

const {agentProvider, mockManager, fixtureManager, matchers, resetRateLimits} = require('../../utils/e2e-framework');
const {mockLabsDisabled} = require('../../utils/e2e-framework-mock-manager');
const should = require('should');
const settingsCache = require('../../../core/shared/settings-cache');
const DomainEvents = require('@tryghost/domain-events');
const {anyErrorId} = matchers;
let membersAgent, membersService;
describe('sendMagicLink', function () {
before(async function () {
const agents = await agentProvider.getAgentsForMembers();
membersAgent = agents.membersAgent;
membersService = require('../../../core/server/services/members');
await fixtureManager.init('members');
});
beforeEach(function () {
mockManager.mockMail();
resetRateLimits();
// Reset settings
settingsCache.set('members_signup_access', {value: 'all'});
});
afterEach(function () {
mockManager.restore();
});
it('Errors when passed multiple emails', async function () {
await membersAgent.post('/api/send-magic-link')
.body({
email: 'one@test.com,two@test.com',
emailType: 'signup'
})
.expectStatus(400);
});
it('Throws an error when logging in to a email that does not exist', async function () {
const email = 'this-member-does-not-exist@test.com';
await membersAgent.post('/api/send-magic-link')
.body({
email,
emailType: 'signin'
})
.expectStatus(400)
.matchBodySnapshot({
errors: [{
id: anyErrorId,
// Add this here because it is easy to be overlooked (we need a human readable error!)
// 'Please sign up first' should be included only when invite only is disabled.
message: 'No member exists with this e-mail address. Please sign up first.'
}]
});
});
it('Throws an error when logging in to a email that does not exist (invite only)', async function () {
settingsCache.set('members_signup_access', {value: 'invite'});
const email = 'this-member-does-not-exist@test.com';
await membersAgent.post('/api/send-magic-link')
.body({
email,
emailType: 'signin'
})
.expectStatus(400)
.matchBodySnapshot({
errors: [{
id: anyErrorId,
// Add this here because it is easy to be overlooked (we need a human readable error!)
// 'Please sign up first' should NOT be included
message: 'No member exists with this e-mail address.'
}]
});
});
it('Throws an error when trying to sign up on an invite only site', async function () {
settingsCache.set('members_signup_access', {value: 'invite'});
const email = 'this-member-does-not-exist@test.com';
await membersAgent.post('/api/send-magic-link')
.body({
email,
emailType: 'signup'
})
.expectStatus(400)
.matchBodySnapshot({
errors: [{
id: anyErrorId
}]
});
});
it('Creates a valid magic link with tokenData, and without urlHistory', async function () {
const email = 'newly-created-user-magic-link-test@test.com';
await membersAgent.post('/api/send-magic-link')
.body({
email,
emailType: 'signup'
})
.expectEmptyBody()
.expectStatus(201);
// Check email is sent
const mail = mockManager.assert.sentEmail({
to: email,
subject: /Complete your sign up to Ghost!/
});
// Get link from email
const [url] = mail.text.match(/https?:\/\/[^\s]+/);
const parsed = new URL(url);
const token = parsed.searchParams.get('token');
// Get data
const data = await membersService.api.getTokenDataFromMagicLinkToken(token);
should(data).match({
email,
attribution: {
id: null,
url: null,
type: null
}
});
});
it('Creates a valid magic link from custom signup with redirection', async function () {
const customSignupUrl = 'http://localhost:2368/custom-signup-form-page';
const email = 'newly-created-user-magic-link-test@test.com';
await membersAgent
.post('/api/send-magic-link')
.header('Referer', customSignupUrl)
.body({
email,
emailType: 'signup',
autoRedirect: true
})
.expectEmptyBody()
.expectStatus(201);
const mail = await mockManager.assert.sentEmail({
to: email,
subject: /Complete your sign up to Ghost!/
});
const [url] = mail.text.match(/https?:\/\/[^\s]+/);
const parsed = new URL(url);
const redirect = parsed.searchParams.get('r');
should(redirect).equal(customSignupUrl);
});
it('Creates a valid magic link from custom signup with redirection disabled', async function () {
const customSignupUrl = 'http://localhost:2368/custom-signup-form-page';
const email = 'newly-created-user-magic-link-test@test.com';
await membersAgent
.post('/api/send-magic-link')
.header('Referer', customSignupUrl)
.body({
email,
emailType: 'signup',
autoRedirect: false
})
.expectEmptyBody()
.expectStatus(201);
const mail = await mockManager.assert.sentEmail({
to: email,
subject: /Complete your sign up to Ghost!/
});
const [url] = mail.text.match(/https?:\/\/[^\s]+/);
const parsed = new URL(url);
const redirect = parsed.searchParams.get('r');
should(redirect).equal(null);
});
it('triggers email alert for free member signup', async function () {
mockLabsDisabled('membersSpamPrevention');
const email = 'newly-created-user-magic-link-test@test.com';
await membersAgent.post('/api/send-magic-link')
.body({
email,
emailType: 'signup'
})
.expectEmptyBody()
.expectStatus(201);
// Check email is sent
const mail = mockManager.assert.sentEmail({
to: email,
subject: /Complete your sign up to Ghost!/
});
// Get link from email
const [url] = mail.text.match(/https?:\/\/[^\s]+/);
const parsed = new URL(url);
const token = parsed.searchParams.get('token');
// Get member data from token
const data = await membersService.api.getMemberDataFromMagicLinkToken(token);
// Wait for the dispatched events (because this happens async)
await DomainEvents.allSettled();
// Check member alert is sent to site owners
mockManager.assert.sentEmail({
to: 'jbloggs@example.com',
subject: /🥳 Free member signup: newly-created-user-magic-link-test@test.com/
});
// Check member data is returned
should(data).match({
email
});
});
it('triggers email alert for free member signup with membersSpamPrevention enabled', async function () {
const email = 'newly-created-user-magic-link-test-spam@test.com';
await membersAgent.post('/api/send-magic-link')
.body({
email,
emailType: 'signup'
})
.expectEmptyBody()
.expectStatus(201);
// Check email is sent
const mail = mockManager.assert.sentEmail({
to: email,
subject: /Complete your sign up to Ghost!/
});
// Get link from email
const [url] = mail.text.match(/https?:\/\/[^\s]+/);
const parsed = new URL(url);
const token = parsed.searchParams.get('token');
// Get member data from token
const signinLink = await membersService.api.createMemberFromToken(token);
// Wait for the dispatched events (because this happens async)
await DomainEvents.allSettled();
// Check member alert is sent to site owners
mockManager.assert.sentEmail({
to: 'jbloggs@example.com',
subject: /🥳 Free member signup: newly-created-user-magic-link-test-spam@test.com/
});
// Check the signin link is returned correctly
const parsedSigninLink = new URL(signinLink);
const signinToken = parsedSigninLink.searchParams.get('token');
const action = parsedSigninLink.searchParams.get('action');
should(action).equal('signin');
should(signinToken.length).equal(32);
});
it('Converts the urlHistory to the attribution and stores it in the token', async function () {
const email = 'newly-created-user-magic-link-test-10@test.com';
await membersAgent.post('/api/send-magic-link')
.body({
email,
emailType: 'signup',
urlHistory: [
{
path: '/test-path',
time: Date.now()
}
]
})
.expectEmptyBody()
.expectStatus(201);
// Check email is sent
const mail = mockManager.assert.sentEmail({
to: email,
subject: /Complete your sign up to Ghost!/
});
// Get link from email
const [url] = mail.text.match(/https?:\/\/[^\s]+/);
const parsed = new URL(url);
const token = parsed.searchParams.get('token');
// Get data
const data = await membersService.api.getTokenDataFromMagicLinkToken(token);
should(data).match({
email,
attribution: {
id: null,
url: '/test-path',
type: 'url'
}
});
});
});