refactor(server): mail service (#10934)

This commit is contained in:
forehalo
2025-03-19 17:00:19 +00:00
parent b3a245f47a
commit 21c4a29f55
47 changed files with 2076 additions and 2131 deletions
@@ -1,13 +1,8 @@
import { randomBytes } from 'node:crypto';
import {
getCurrentMailMessageCount,
getTokenFromLatestMailMessage,
} from '@affine-test/kit/utils/cloud';
import type { TestFn } from 'ava';
import ava from 'ava';
import { MailService } from '../../base/mailer';
import {
changeEmail,
changePassword,
@@ -21,14 +16,11 @@ import {
const test = ava as TestFn<{
app: TestingApp;
mail: MailService;
}>;
test.beforeEach(async t => {
const app = await createTestingApp();
const mail = app.get(MailService);
t.context.app = app;
t.context.mail = mail;
});
test.afterEach.always(async t => {
@@ -36,182 +28,106 @@ test.afterEach.always(async t => {
});
test('change email', async t => {
const { mail, app } = t.context;
if (mail.hasConfigured()) {
const u1Email = 'u1@affine.pro';
const u2Email = 'u2@affine.pro';
const { app } = t.context;
const u1Email = 'u1@affine.pro';
const u2Email = 'u2@affine.pro';
await app.signupV1(u1Email);
const primitiveMailCount = await getCurrentMailMessageCount();
await sendChangeEmail(app, u1Email, 'affine.pro');
const user = await app.signupV1(u1Email);
await sendChangeEmail(app, u1Email, 'affine.pro');
const afterSendChangeMailCount = await getCurrentMailMessageCount();
t.is(
primitiveMailCount + 1,
afterSendChangeMailCount,
'failed to send change email'
);
const changeMail = app.mails.last('ChangeEmail');
const changeEmailToken = await getTokenFromLatestMailMessage();
t.is(changeMail.to, u1Email);
t.not(
changeEmailToken,
null,
'fail to get change email token from email content'
);
let link = new URL(changeMail.props.url);
await sendVerifyChangeEmail(
app,
changeEmailToken as string,
u2Email,
'affine.pro'
);
const changeEmailToken = link.searchParams.get('token');
const afterSendVerifyMailCount = await getCurrentMailMessageCount();
t.not(
changeEmailToken,
null,
'fail to get change email token from email content'
);
t.is(
afterSendChangeMailCount + 1,
afterSendVerifyMailCount,
'failed to send verify email'
);
await sendVerifyChangeEmail(
app,
changeEmailToken as string,
u2Email,
'affine.pro'
);
const verifyEmailToken = await getTokenFromLatestMailMessage();
const verifyMail = app.mails.last('VerifyChangeEmail');
t.not(
verifyEmailToken,
null,
'fail to get verify change email token from email content'
);
t.is(verifyMail.to, u2Email);
await changeEmail(app, verifyEmailToken as string, u2Email);
link = new URL(verifyMail.props.url);
const afterNotificationMailCount = await getCurrentMailMessageCount();
const verifyEmailToken = link.searchParams.get('token');
t.is(
afterSendVerifyMailCount + 1,
afterNotificationMailCount,
'failed to send notification email'
);
}
t.pass();
t.not(
verifyEmailToken,
null,
'fail to get verify change email token from email content'
);
await changeEmail(app, verifyEmailToken as string, u2Email);
const changedMail = app.mails.last('EmailChanged');
t.is(changedMail.to, u2Email);
t.is(changedMail.props.to, u2Email);
await app.logout();
await app.login({
...user,
email: u2Email,
});
const me = await currentUser(app);
t.not(me, null, 'failed to get current user');
t.is(me?.email, u2Email, 'failed to get current user');
});
test('set and change password', async t => {
const { mail, app } = t.context;
if (mail.hasConfigured()) {
const u1Email = 'u1@affine.pro';
const { app } = t.context;
const u1Email = 'u1@affine.pro';
const u1 = await app.signupV1(u1Email);
const u1 = await app.signupV1(u1Email);
await sendSetPasswordEmail(app, u1Email, 'affine.pro');
const primitiveMailCount = await getCurrentMailMessageCount();
const setPasswordMail = app.mails.last('ChangePassword');
const link = new URL(setPasswordMail.props.url);
const setPasswordToken = link.searchParams.get('token');
await sendSetPasswordEmail(app, u1Email, 'affine.pro');
t.is(setPasswordMail.to, u1Email);
t.not(
setPasswordToken,
null,
'fail to get set password token from email content'
);
const afterSendSetMailCount = await getCurrentMailMessageCount();
const newPassword = randomBytes(16).toString('hex');
const success = await changePassword(
app,
u1.id,
setPasswordToken as string,
newPassword
);
t.is(
primitiveMailCount + 1,
afterSendSetMailCount,
'failed to send set email'
);
t.true(success, 'failed to change password');
const setPasswordToken = await getTokenFromLatestMailMessage();
let user = await currentUser(app);
t.not(
setPasswordToken,
null,
'fail to get set password token from email content'
);
t.is(user, null);
const newPassword = randomBytes(16).toString('hex');
const success = await changePassword(
app,
u1.id,
setPasswordToken as string,
newPassword
);
await app.login({
...u1,
password: newPassword,
});
t.true(success, 'failed to change password');
user = await currentUser(app);
await app.login({
...u1,
password: newPassword,
});
const user = await currentUser(app);
t.not(user, null, 'failed to get current user');
t.is(user?.email, u1Email, 'failed to get current user');
}
t.pass();
});
test('should revoke token after change user identify', async t => {
const { mail, app } = t.context;
if (mail.hasConfigured()) {
// change email
{
const u1Email = 'u1@affine.pro';
const u2Email = 'u2@affine.pro';
const u1 = await app.signupV1(u1Email);
{
const user = await currentUser(app);
t.is(user?.email, u1Email, 'failed to get current user');
}
await sendChangeEmail(app, u1Email, 'affine.pro');
const changeEmailToken = await getTokenFromLatestMailMessage();
await sendVerifyChangeEmail(
app,
changeEmailToken as string,
u2Email,
'affine.pro'
);
const verifyEmailToken = await getTokenFromLatestMailMessage();
await changeEmail(app, verifyEmailToken as string, u2Email);
let user = await currentUser(app);
t.is(user, null, 'token should be revoked');
await app.login({
...u1,
email: u2Email,
});
user = await currentUser(app);
t.is(user?.email, u2Email, 'failed to sign in with new email');
}
// change password
{
const u3Email = 'u3333@affine.pro';
await app.logout();
const u3 = await app.signupV1(u3Email);
{
const user = await currentUser(app);
t.is(user?.email, u3Email, 'failed to get current user');
}
await sendSetPasswordEmail(app, u3Email, 'affine.pro');
const token = await getTokenFromLatestMailMessage();
const newPassword = randomBytes(16).toString('hex');
await changePassword(app, u3.id, token as string, newPassword);
let user = await currentUser(app);
t.is(user, null, 'token should be revoked');
await app.login({
...u3,
password: newPassword,
});
user = await currentUser(app);
t.is(user?.email, u3Email, 'failed to sign in with new password');
}
}
t.pass();
t.not(user, null, 'failed to get current user');
t.is(user?.email, u1Email, 'failed to get current user');
});
@@ -5,7 +5,6 @@ import { PrismaClient } from '@prisma/client';
import ava, { TestFn } from 'ava';
import Sinon from 'sinon';
import { MailService } from '../../base';
import { AuthModule } from '../../core/auth';
import { AuthService } from '../../core/auth/service';
import { FeatureModule } from '../../core/features';
@@ -20,26 +19,16 @@ import {
const test = ava as TestFn<{
auth: AuthService;
db: PrismaClient;
mailer: Sinon.SinonStubbedInstance<MailService>;
app: TestingApp;
}>;
test.before(async t => {
const app = await createTestingApp({
imports: [FeatureModule, UserModule, AuthModule],
tapModule: m => {
m.overrideProvider(MailService).useValue(
Sinon.stub(
// @ts-expect-error safe
new MailService()
)
);
},
});
t.context.auth = app.get(AuthService);
t.context.db = app.get(PrismaClient);
t.context.mailer = app.get(MailService);
t.context.app = app;
});
@@ -67,11 +56,9 @@ test('should be able to sign in with credential', async t => {
});
test('should be able to sign in with email', async t => {
const { app, mailer } = t.context;
const { app } = t.context;
const u1 = await app.createUser('u1@affine.pro');
// @ts-expect-error mock
mailer.sendSignInMail.resolves({ rejected: [] });
const res = await app
.POST('/api/auth/sign-in')
@@ -79,10 +66,11 @@ test('should be able to sign in with email', async t => {
.expect(200);
t.is(res.body.email, u1.email);
t.true(mailer.sendSignInMail.calledOnce);
const signInMail = app.mails.last('SignIn');
const [, { url: signInLink }] = mailer.sendSignInMail.firstCall.args;
const url = new URL(signInLink);
t.is(signInMail.to, u1.email);
const url = new URL(signInMail.props.url);
const email = url.searchParams.get('email');
const token = url.searchParams.get('token');
@@ -93,10 +81,7 @@ test('should be able to sign in with email', async t => {
});
test('should be able to sign up with email', async t => {
const { app, mailer } = t.context;
// @ts-expect-error mock
mailer.sendSignUpMail.resolves({ rejected: [] });
const { app } = t.context;
const res = await app
.POST('/api/auth/sign-in')
@@ -104,10 +89,11 @@ test('should be able to sign up with email', async t => {
.expect(200);
t.is(res.body.email, 'u2@affine.pro');
t.true(mailer.sendSignUpMail.calledOnce);
const signUpMail = app.mails.last('SignUp');
const [, { url: signUpLink }] = mailer.sendSignUpMail.firstCall.args;
const url = new URL(signUpLink);
t.is(signUpMail.to, 'u2@affine.pro');
const url = new URL(signUpMail.props.url);
const email = url.searchParams.get('email');
const token = url.searchParams.get('token');
@@ -129,7 +115,7 @@ test('should not be able to sign in if email is invalid', async t => {
});
test('should not be able to sign in if forbidden', async t => {
const { app, auth, mailer } = t.context;
const { app, auth } = t.context;
const u1 = await app.createUser('u1@affine.pro');
const canSignInStub = Sinon.stub(auth, 'canSignIn').resolves(false);
@@ -139,9 +125,8 @@ test('should not be able to sign in if forbidden', async t => {
.send({ email: u1.email })
.expect(HttpStatus.FORBIDDEN);
t.true(mailer.sendSignInMail.notCalled);
canSignInStub.restore();
t.pass();
});
test('should be able to sign out', async t => {
@@ -253,12 +238,10 @@ test('should be able to sign out multiple accounts in one session', async t => {
});
test('should be able to sign in with email and client nonce', async t => {
const { app, mailer } = t.context;
const { app } = t.context;
const clientNonce = randomUUID();
const u1 = await app.createUser();
// @ts-expect-error mock
mailer.sendSignInMail.resolves({ rejected: [] });
const res = await app
.POST('/api/auth/sign-in')
@@ -266,10 +249,11 @@ test('should be able to sign in with email and client nonce', async t => {
.expect(200);
t.is(res.body.email, u1.email);
t.true(mailer.sendSignInMail.calledOnce);
const signInMail = app.mails.last('SignIn');
const [, { url: signInLink }] = mailer.sendSignInMail.firstCall.args;
const url = new URL(signInLink);
t.is(signInMail.to, u1.email);
const url = new URL(signInMail.props.url);
const email = url.searchParams.get('email');
const token = url.searchParams.get('token');
@@ -283,12 +267,10 @@ test('should be able to sign in with email and client nonce', async t => {
});
test('should not be able to sign in with email and client nonce if invalid', async t => {
const { app, mailer } = t.context;
const { app } = t.context;
const clientNonce = randomUUID();
const u1 = await app.createUser();
// @ts-expect-error mock
mailer.sendSignInMail.resolves({ rejected: [] });
const res = await app
.POST('/api/auth/sign-in')
@@ -296,10 +278,11 @@ test('should not be able to sign in with email and client nonce if invalid', asy
.expect(200);
t.is(res.body.email, u1.email);
t.true(mailer.sendSignInMail.calledOnce);
const signInMail = app.mails.last('SignIn');
const [, { url: signInLink }] = mailer.sendSignInMail.firstCall.args;
const url = new URL(signInLink);
t.is(signInMail.to, u1.email);
const url = new URL(signInMail.props.url);
const email = url.searchParams.get('email');
const token = url.searchParams.get('token');