exp-node-server 1.1.6 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +82 -20
- package/dist/cli.js +525 -0
- package/package.json +29 -9
- package/templates/mongoose/.dockerignore +10 -0
- package/templates/mongoose/.env.example +56 -7
- package/templates/mongoose/.prettierignore +3 -0
- package/templates/mongoose/.prettierrc +7 -0
- package/templates/mongoose/Dockerfile +33 -0
- package/templates/mongoose/_gitignore +32 -0
- package/templates/mongoose/docker-compose.yml +21 -0
- package/templates/mongoose/eslint.config.js +35 -0
- package/templates/mongoose/package-lock.json +6377 -0
- package/templates/mongoose/package.json +49 -10
- package/templates/mongoose/readme.md +85 -0
- package/templates/mongoose/src/app/configs/index.ts +178 -26
- package/templates/mongoose/src/app/errors/zodError.ts +16 -16
- package/templates/mongoose/src/app/lib/db.ts +18 -0
- package/templates/mongoose/src/app/lib/logger.ts +12 -0
- package/templates/mongoose/src/app/middlewares/auth.ts +48 -44
- package/templates/mongoose/src/app/middlewares/global_error_handler.ts +59 -65
- package/templates/mongoose/src/app/middlewares/not_found_api.ts +7 -7
- package/templates/mongoose/src/app/middlewares/rate_limiter.ts +153 -0
- package/templates/mongoose/src/app/middlewares/request_validator.ts +11 -10
- package/templates/mongoose/src/app/middlewares/uploader.ts +17 -13
- package/templates/mongoose/src/app/modules/account/account.controller.ts +156 -0
- package/templates/mongoose/src/app/modules/account/account.interface.ts +65 -0
- package/templates/mongoose/src/app/modules/account/account.route.ts +101 -0
- package/templates/mongoose/src/app/modules/account/account.schema.ts +97 -0
- package/templates/mongoose/src/app/modules/account/account.service.ts +422 -0
- package/templates/mongoose/src/app/modules/account/account.swagger.ts +278 -0
- package/templates/mongoose/src/app/modules/account/account.validation.ts +63 -0
- package/templates/mongoose/src/app/modules/profile/profile.controller.ts +17 -0
- package/templates/mongoose/src/app/modules/profile/profile.interface.ts +7 -0
- package/templates/mongoose/src/app/modules/profile/profile.route.ts +28 -0
- package/templates/mongoose/src/app/modules/profile/profile.schema.ts +13 -0
- package/templates/mongoose/src/app/modules/profile/profile.service.ts +28 -0
- package/templates/mongoose/src/app/modules/profile/profile.swagger.ts +34 -0
- package/templates/mongoose/src/app/modules/profile/profile.validation.ts +8 -0
- package/templates/mongoose/src/app/passport-js/index.ts +87 -0
- package/templates/mongoose/src/app/passport-js/oauth-state.ts +150 -0
- package/templates/mongoose/src/app/passport-js/providers.ts +258 -0
- package/templates/mongoose/src/app/passport-js/social-account.ts +141 -0
- package/templates/mongoose/src/app/queues/agenda.ts +24 -0
- package/templates/mongoose/src/app/queues/email.queue.ts +53 -0
- package/templates/mongoose/src/app/queues/index.ts +13 -0
- package/templates/mongoose/src/app/templates/otpEmailTemplate.ts +185 -0
- package/templates/mongoose/src/app/templates/resetPasswordEmailTemplate.ts +157 -0
- package/templates/mongoose/src/app/types/error.d.ts +7 -7
- package/templates/mongoose/src/app/types/express.d.ts +11 -0
- package/templates/mongoose/src/app/types/passport-oauth1.d.ts +51 -0
- package/templates/mongoose/src/app/utils/JWT.ts +38 -18
- package/templates/mongoose/src/app/utils/app_error.ts +10 -10
- package/templates/mongoose/src/app/utils/auth_cookie.ts +27 -0
- package/templates/mongoose/src/app/utils/catch_async.ts +8 -8
- package/templates/mongoose/src/app/utils/cloudinary.ts +26 -59
- package/templates/mongoose/src/app/utils/mail_sender.ts +26 -92
- package/templates/mongoose/src/app/utils/manage_response.ts +19 -19
- package/templates/mongoose/src/app/utils/otp.ts +20 -0
- package/templates/mongoose/src/app/utils/session.ts +55 -0
- package/templates/mongoose/src/app.ts +73 -42
- package/templates/mongoose/src/routes.ts +13 -13
- package/templates/mongoose/src/server.ts +38 -18
- package/templates/mongoose/src/swaggerOptions.ts +23 -23
- package/templates/mongoose/tests/app.test.ts +97 -0
- package/templates/mongoose/tests/global-setup.ts +15 -0
- package/templates/mongoose/tests/helpers/fixtures.ts +42 -0
- package/templates/mongoose/tests/routes/account.test.ts +614 -0
- package/templates/mongoose/tests/routes/profile.test.ts +95 -0
- package/templates/mongoose/tests/routes/social.test.ts +310 -0
- package/templates/mongoose/tests/setup.ts +25 -0
- package/templates/mongoose/tsconfig.json +16 -20
- package/templates/mongoose/vitest.config.ts +43 -0
- package/templates/prisma/.dockerignore +10 -0
- package/templates/prisma/.env.example +62 -6
- package/templates/prisma/.prettierignore +3 -0
- package/templates/prisma/.prettierrc +7 -0
- package/templates/prisma/.projectrc.json +1 -1
- package/templates/prisma/Dockerfile +21 -7
- package/templates/prisma/_gitignore +35 -0
- package/templates/prisma/docker-compose.yml +8 -7
- package/templates/prisma/eslint.config.js +35 -0
- package/templates/prisma/package-lock.json +7327 -0
- package/templates/prisma/package.json +47 -9
- package/templates/prisma/prisma/schema/account.schema.prisma +92 -10
- package/templates/prisma/prisma/schema/profile.schema.prisma +8 -3
- package/templates/prisma/prisma/schema/schema.prisma +1 -2
- package/templates/prisma/prisma.config.ts +5 -5
- package/templates/prisma/readme.md +81 -0
- package/templates/prisma/src/app/configs/index.ts +175 -26
- package/templates/prisma/src/app/errors/zodError.ts +16 -16
- package/templates/prisma/src/app/lib/logger.ts +12 -0
- package/templates/prisma/src/app/lib/prisma.ts +4 -6
- package/templates/prisma/src/app/middlewares/auth.ts +37 -16
- package/templates/prisma/src/app/middlewares/global_error_handler.ts +42 -35
- package/templates/prisma/src/app/middlewares/not_found_api.ts +7 -7
- package/templates/prisma/src/app/middlewares/rate_limiter.ts +124 -0
- package/templates/prisma/src/app/middlewares/request_validator.ts +4 -3
- package/templates/prisma/src/app/middlewares/uploader.ts +17 -13
- package/templates/prisma/src/app/modules/account/account.controller.ts +74 -34
- package/templates/prisma/src/app/modules/account/account.route.ts +68 -22
- package/templates/prisma/src/app/modules/account/account.service.ts +329 -309
- package/templates/prisma/src/app/modules/account/account.swagger.ts +167 -76
- package/templates/prisma/src/app/modules/account/account.validation.ts +41 -19
- package/templates/prisma/src/app/modules/profile/profile.controller.ts +4 -4
- package/templates/prisma/src/app/modules/profile/profile.route.ts +18 -12
- package/templates/prisma/src/app/modules/profile/profile.service.ts +11 -18
- package/templates/prisma/src/app/modules/profile/profile.swagger.ts +11 -11
- package/templates/prisma/src/app/modules/profile/profile.validation.ts +2 -2
- package/templates/prisma/src/app/passport-js/index.ts +87 -0
- package/templates/prisma/src/app/passport-js/oauth-state.ts +150 -0
- package/templates/prisma/src/app/passport-js/providers.ts +263 -0
- package/templates/prisma/src/app/passport-js/social-account.ts +147 -0
- package/templates/prisma/src/app/queues/boss.ts +8 -0
- package/templates/prisma/src/app/queues/email.queue.ts +56 -0
- package/templates/prisma/src/app/queues/index.ts +14 -0
- package/templates/prisma/src/app/queues/maintenance.queue.ts +30 -0
- package/templates/prisma/src/app/templates/otpEmailTemplate.ts +185 -0
- package/templates/prisma/src/app/templates/resetPasswordEmailTemplate.ts +157 -0
- package/templates/prisma/src/app/types/error.d.ts +7 -7
- package/templates/prisma/src/app/types/express.d.ts +11 -0
- package/templates/prisma/src/app/types/passport-oauth1.d.ts +51 -0
- package/templates/prisma/src/app/utils/JWT.ts +36 -17
- package/templates/prisma/src/app/utils/app_error.ts +10 -10
- package/templates/prisma/src/app/utils/auth_cookie.ts +27 -0
- package/templates/prisma/src/app/utils/catch_async.ts +8 -8
- package/templates/prisma/src/app/utils/cloudinary.ts +26 -59
- package/templates/prisma/src/app/utils/mail_sender.ts +26 -92
- package/templates/prisma/src/app/utils/manage_response.ts +19 -19
- package/templates/prisma/src/app/utils/otp.ts +20 -0
- package/templates/prisma/src/app/utils/session.ts +57 -0
- package/templates/prisma/src/app.ts +73 -42
- package/templates/prisma/src/routes.ts +12 -10
- package/templates/prisma/src/server.ts +37 -19
- package/templates/prisma/src/swaggerOptions.ts +22 -26
- package/templates/prisma/tests/app.test.ts +86 -0
- package/templates/prisma/tests/helpers/fixtures.ts +35 -0
- package/templates/prisma/tests/helpers/prisma.ts +32 -0
- package/templates/prisma/tests/routes/account.test.ts +627 -0
- package/templates/prisma/tests/routes/profile.test.ts +91 -0
- package/templates/prisma/tests/routes/social.test.ts +317 -0
- package/templates/prisma/tests/setup.ts +10 -0
- package/templates/prisma/tsconfig.json +16 -23
- package/templates/prisma/vitest.config.ts +38 -0
- package/bin/cli.js +0 -41
- package/src/createProject.js +0 -61
- package/src/generateModule.js +0 -93
- package/src/utils/getProjectType.js +0 -13
- package/src/utils/mongoose/controllerGenerator.js +0 -70
- package/src/utils/mongoose/routeGenerator.js +0 -27
- package/src/utils/mongoose/schemaGenerator.js +0 -11
- package/src/utils/mongoose/serviceGenerator.js +0 -49
- package/src/utils/mongoose/swaggerGenerator.js +0 -112
- package/src/utils/mongoose/validationGenerator.js +0 -13
- package/src/utils/prisma/prismaSchemaGenerator.js +0 -10
- package/src/utils/prisma/prismaServiceGenerator.js +0 -47
- package/src/utils/routeInjector.js +0 -40
- package/src/utils/swaggerInjector.js +0 -52
- package/src/utils/validateModuleName.js +0 -17
- package/src/utils/validateProjectName.js +0 -15
- package/src/utils.js +0 -26
- package/templates/mongoose/src/app/errors/castError.ts +0 -23
- package/templates/mongoose/src/app/errors/duplicateError.ts +0 -26
- package/templates/mongoose/src/app/errors/validationError.ts +0 -25
- package/templates/mongoose/src/app/modules/auth/auth.controller.ts +0 -127
- package/templates/mongoose/src/app/modules/auth/auth.interface.ts +0 -24
- package/templates/mongoose/src/app/modules/auth/auth.route.ts +0 -46
- package/templates/mongoose/src/app/modules/auth/auth.schema.ts +0 -19
- package/templates/mongoose/src/app/modules/auth/auth.service.ts +0 -324
- package/templates/mongoose/src/app/modules/auth/auth.swagger.ts +0 -214
- package/templates/mongoose/src/app/modules/auth/auth.validation.ts +0 -37
- package/templates/mongoose/src/app/modules/user/user.controller.ts +0 -17
- package/templates/mongoose/src/app/modules/user/user.interface.ts +0 -15
- package/templates/mongoose/src/app/modules/user/user.route.ts +0 -20
- package/templates/mongoose/src/app/modules/user/user.schema.ts +0 -22
- package/templates/mongoose/src/app/modules/user/user.service.ts +0 -22
- package/templates/mongoose/src/app/modules/user/user.swagger.ts +0 -75
- package/templates/mongoose/src/app/modules/user/user.validation.ts +0 -18
- package/templates/mongoose/src/app/types/index.d.ts +0 -12
- package/templates/mongoose/src/app/utils/isAccountExist.ts +0 -31
- package/templates/prisma/dockerignore +0 -0
- package/templates/prisma/src/app/types/index.d.ts +0 -12
- package/templates/prisma/src/app/utils/otpGenerator.ts +0 -29
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import bcrypt from 'bcrypt';
|
|
2
|
+
import { configs } from '../../src/app/configs/index.js';
|
|
3
|
+
import type { T_account } from '../../src/app/modules/account/account.interface.js';
|
|
4
|
+
import { account_model } from '../../src/app/modules/account/account.schema.js';
|
|
5
|
+
import { profile_model } from '../../src/app/modules/profile/profile.schema.js';
|
|
6
|
+
import { jwtHelpers } from '../../src/app/utils/JWT.js';
|
|
7
|
+
|
|
8
|
+
export const PASSWORD = 'correct-horse-battery';
|
|
9
|
+
const passwordHash = bcrypt.hashSync(PASSWORD, 4);
|
|
10
|
+
|
|
11
|
+
/** Inserts a verified, active account with a password, plus its profile. */
|
|
12
|
+
export const createAccount = async (overrides: Partial<T_account> = {}) => {
|
|
13
|
+
const account = await account_model.create({
|
|
14
|
+
email: 'user@example.com',
|
|
15
|
+
password: passwordHash,
|
|
16
|
+
isAccountVerified: true,
|
|
17
|
+
...overrides,
|
|
18
|
+
});
|
|
19
|
+
await profile_model.create({ accountId: account._id, fullName: 'Test User' });
|
|
20
|
+
return account;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/** The account as stored, including the fields that are hidden by default. */
|
|
24
|
+
export const findAccount = (email = 'user@example.com') =>
|
|
25
|
+
account_model.findOne({ email }).select('+password +lastOtp').lean();
|
|
26
|
+
|
|
27
|
+
type TokenSubject = Pick<T_account, 'email' | 'role' | 'tokenVersion'> & { _id: unknown };
|
|
28
|
+
|
|
29
|
+
/** A signed access token, as the app would issue it for `account`. */
|
|
30
|
+
export const accessTokenFor = (account: TokenSubject) =>
|
|
31
|
+
jwtHelpers.generateToken(
|
|
32
|
+
{
|
|
33
|
+
accountId: String(account._id),
|
|
34
|
+
email: account.email,
|
|
35
|
+
role: account.role,
|
|
36
|
+
tv: account.tokenVersion,
|
|
37
|
+
},
|
|
38
|
+
configs.jwt.access_token,
|
|
39
|
+
'1h',
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
export const bearer = (account: TokenSubject) => `Bearer ${accessTokenFor(account)}`;
|
|
@@ -0,0 +1,614 @@
|
|
|
1
|
+
import bcrypt from 'bcrypt';
|
|
2
|
+
import request from 'supertest';
|
|
3
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import app from '../../src/app.js';
|
|
5
|
+
import { configs } from '../../src/app/configs/index.js';
|
|
6
|
+
import {
|
|
7
|
+
account_model,
|
|
8
|
+
refresh_token_model,
|
|
9
|
+
social_login_code_model,
|
|
10
|
+
} from '../../src/app/modules/account/account.schema.js';
|
|
11
|
+
import { profile_model } from '../../src/app/modules/profile/profile.schema.js';
|
|
12
|
+
import { createSocialLoginCode, hashLoginCode } from '../../src/app/passport-js/social-account.js';
|
|
13
|
+
import { enqueueEmail } from '../../src/app/queues/email.queue.js';
|
|
14
|
+
import { jwtHelpers } from '../../src/app/utils/JWT.js';
|
|
15
|
+
import { hashOtp } from '../../src/app/utils/otp.js';
|
|
16
|
+
import { hashRefreshToken } from '../../src/app/utils/session.js';
|
|
17
|
+
import { bearer, createAccount, findAccount, PASSWORD } from '../helpers/fixtures.js';
|
|
18
|
+
|
|
19
|
+
/** The `access_token` cookie from a response, if one was set. */
|
|
20
|
+
const accessCookie = (res: request.Response) =>
|
|
21
|
+
([] as string[])
|
|
22
|
+
.concat(res.headers['set-cookie'] ?? [])
|
|
23
|
+
.find((c) => c.startsWith('access_token='));
|
|
24
|
+
|
|
25
|
+
/** The last email the app queued. */
|
|
26
|
+
const lastEmail = () => vi.mocked(enqueueEmail).mock.calls.at(-1)![0];
|
|
27
|
+
|
|
28
|
+
const signIn = (email: string, password: string) =>
|
|
29
|
+
request(app).post('/api/auth/sign-in').send({ email, password });
|
|
30
|
+
|
|
31
|
+
describe('POST /api/auth/sign-up', () => {
|
|
32
|
+
const body = { email: 'New@Example.com', password: 'a-strong-password', fullName: 'New User' };
|
|
33
|
+
|
|
34
|
+
it('creates the account and profile, and emails a verification OTP', async () => {
|
|
35
|
+
const res = await request(app).post('/api/auth/sign-up').send(body);
|
|
36
|
+
|
|
37
|
+
expect(res.status).toBe(201);
|
|
38
|
+
expect(res.body.data).toMatchObject({ email: 'new@example.com', isAccountVerified: false });
|
|
39
|
+
expect(res.body.data.password).toBeUndefined();
|
|
40
|
+
|
|
41
|
+
const account = await findAccount('new@example.com');
|
|
42
|
+
expect(await bcrypt.compare(body.password, account!.password!)).toBe(true);
|
|
43
|
+
expect(await profile_model.findOne({ accountId: account!._id }).lean()).toMatchObject({
|
|
44
|
+
fullName: 'New User',
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// the OTP is emailed, but only its HMAC is stored
|
|
48
|
+
const job = lastEmail();
|
|
49
|
+
if (job.type !== 'verify-account') throw new Error('unexpected job');
|
|
50
|
+
expect(job.email).toBe('new@example.com');
|
|
51
|
+
expect(job.otp).toMatch(/^\d{6}$/);
|
|
52
|
+
expect(account!.lastOtp).toBe(hashOtp(job.otp));
|
|
53
|
+
expect(job.verificationLink).toContain(`${configs.jwt.front_end_url}/verify?token=`);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('rejects invalid input', async () => {
|
|
57
|
+
const res = await request(app)
|
|
58
|
+
.post('/api/auth/sign-up')
|
|
59
|
+
.send({ email: 'not-an-email', password: 'short', fullName: '' });
|
|
60
|
+
|
|
61
|
+
expect(res.status).toBe(400);
|
|
62
|
+
expect(res.body.message).toBe('Validation Error');
|
|
63
|
+
expect(res.body.errorSources.map((e: { path: string }) => e.path)).toEqual(
|
|
64
|
+
expect.arrayContaining(['email', 'password', 'fullName']),
|
|
65
|
+
);
|
|
66
|
+
expect(await account_model.countDocuments()).toBe(0);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('rejects an email that is already registered, whatever its case', async () => {
|
|
70
|
+
await createAccount({ email: 'taken@example.com' });
|
|
71
|
+
|
|
72
|
+
const res = await request(app)
|
|
73
|
+
.post('/api/auth/sign-up')
|
|
74
|
+
.send({ ...body, email: 'TAKEN@example.com' });
|
|
75
|
+
|
|
76
|
+
expect(res.status).toBe(409);
|
|
77
|
+
expect(await account_model.countDocuments()).toBe(1);
|
|
78
|
+
expect(enqueueEmail).not.toHaveBeenCalled();
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe('POST /api/auth/sign-in', () => {
|
|
83
|
+
it('returns an access token and sets it as an httpOnly cookie', async () => {
|
|
84
|
+
const account = await createAccount();
|
|
85
|
+
|
|
86
|
+
const res = await signIn('user@example.com', PASSWORD);
|
|
87
|
+
|
|
88
|
+
expect(res.status).toBe(200);
|
|
89
|
+
const payload = jwtHelpers.verifyToken(res.body.data.accessToken, configs.jwt.access_token);
|
|
90
|
+
expect(payload).toMatchObject({ accountId: String(account._id), role: 'USER', tv: 0 });
|
|
91
|
+
expect(accessCookie(res)).toMatch(/HttpOnly/);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it.each([
|
|
95
|
+
['a wrong password', {}, 'wrong-password'],
|
|
96
|
+
['an unknown email', { email: 'other@example.com' }, PASSWORD],
|
|
97
|
+
['a social account without password', { password: null }, PASSWORD],
|
|
98
|
+
])('gives the same 401 for %s', async (_case, overrides, password) => {
|
|
99
|
+
await createAccount(overrides);
|
|
100
|
+
|
|
101
|
+
const res = await signIn('user@example.com', password);
|
|
102
|
+
|
|
103
|
+
expect(res.status).toBe(401);
|
|
104
|
+
expect(res.body.message).toBe('Invalid email or password');
|
|
105
|
+
expect(accessCookie(res)).toBeUndefined();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('rejects unverified accounts', async () => {
|
|
109
|
+
await createAccount({ isAccountVerified: false });
|
|
110
|
+
|
|
111
|
+
const res = await signIn('user@example.com', PASSWORD);
|
|
112
|
+
|
|
113
|
+
expect(res.status).toBe(401);
|
|
114
|
+
expect(res.body.message).toBe('Account is not verified');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('rejects query operators instead of passing them to MongoDB', async () => {
|
|
118
|
+
await createAccount();
|
|
119
|
+
|
|
120
|
+
const res = await request(app)
|
|
121
|
+
.post('/api/auth/sign-in')
|
|
122
|
+
.send({ email: { $gt: '' }, password: PASSWORD });
|
|
123
|
+
|
|
124
|
+
expect(res.status).toBe(400);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
describe('PUT /api/auth/verify-otp', () => {
|
|
129
|
+
const unverified = (otp: string, overrides = {}) =>
|
|
130
|
+
createAccount({
|
|
131
|
+
isAccountVerified: false,
|
|
132
|
+
lastOtp: hashOtp(otp),
|
|
133
|
+
lastOtpSendingTime: new Date(),
|
|
134
|
+
...overrides,
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
const verify = (otp: string) =>
|
|
138
|
+
request(app).put('/api/auth/verify-otp').send({ email: 'user@example.com', otp });
|
|
139
|
+
|
|
140
|
+
it('verifies the account with the right OTP', async () => {
|
|
141
|
+
await unverified('123456');
|
|
142
|
+
|
|
143
|
+
const res = await verify('123456');
|
|
144
|
+
|
|
145
|
+
expect(res.status).toBe(200);
|
|
146
|
+
expect(await findAccount()).toMatchObject({
|
|
147
|
+
isAccountVerified: true,
|
|
148
|
+
lastOtp: null,
|
|
149
|
+
otpAttempts: 0,
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('rejects a wrong OTP and counts the attempt', async () => {
|
|
154
|
+
await unverified('123456');
|
|
155
|
+
|
|
156
|
+
const res = await verify('654321');
|
|
157
|
+
|
|
158
|
+
expect(res.status).toBe(401);
|
|
159
|
+
expect(await findAccount()).toMatchObject({ isAccountVerified: false, otpAttempts: 1 });
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('rejects an expired OTP', async () => {
|
|
163
|
+
await unverified('123456', { lastOtpSendingTime: new Date(Date.now() - 10 * 60 * 1000) });
|
|
164
|
+
|
|
165
|
+
const res = await verify('123456');
|
|
166
|
+
|
|
167
|
+
expect(res.status).toBe(401);
|
|
168
|
+
expect((await findAccount())!.isAccountVerified).toBe(false);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('locks the OTP after too many attempts, even with the right code', async () => {
|
|
172
|
+
await unverified('123456', { otpAttempts: 5 });
|
|
173
|
+
|
|
174
|
+
const res = await verify('123456');
|
|
175
|
+
|
|
176
|
+
expect(res.status).toBe(429);
|
|
177
|
+
expect((await findAccount())!.isAccountVerified).toBe(false);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('never allows more than 5 guesses, even in parallel', async () => {
|
|
181
|
+
await unverified('123456');
|
|
182
|
+
|
|
183
|
+
const results = await Promise.all(Array.from({ length: 10 }, () => verify('000000')));
|
|
184
|
+
|
|
185
|
+
expect(results.filter((r) => r.status === 401)).toHaveLength(5);
|
|
186
|
+
expect(results.filter((r) => r.status === 429)).toHaveLength(5);
|
|
187
|
+
expect((await findAccount())!.otpAttempts).toBe(5);
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
describe('PUT /api/auth/verify-link', () => {
|
|
192
|
+
it('verifies the account with a valid link token', async () => {
|
|
193
|
+
const account = await createAccount({ isAccountVerified: false });
|
|
194
|
+
const token = jwtHelpers.generateToken(
|
|
195
|
+
{ purpose: 'verify-account', accountId: String(account._id) },
|
|
196
|
+
configs.jwt.verified_token,
|
|
197
|
+
'5m',
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
const res = await request(app).put('/api/auth/verify-link').send({ token });
|
|
201
|
+
|
|
202
|
+
expect(res.status).toBe(200);
|
|
203
|
+
expect((await findAccount())!.isAccountVerified).toBe(true);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('rejects tokens signed for another purpose', async () => {
|
|
207
|
+
const account = await createAccount({ isAccountVerified: false });
|
|
208
|
+
const accessToken = jwtHelpers.generateToken(
|
|
209
|
+
{ accountId: String(account._id) },
|
|
210
|
+
configs.jwt.access_token,
|
|
211
|
+
'5m',
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
const res = await request(app).put('/api/auth/verify-link').send({ token: accessToken });
|
|
215
|
+
|
|
216
|
+
expect(res.status).toBe(401);
|
|
217
|
+
expect((await findAccount())!.isAccountVerified).toBe(false);
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
describe('GET /api/auth/me', () => {
|
|
222
|
+
it('requires a token', async () => {
|
|
223
|
+
const res = await request(app).get('/api/auth/me');
|
|
224
|
+
expect(res.status).toBe(401);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it('returns the signed-in account with its profile, never secrets', async () => {
|
|
228
|
+
const account = await createAccount({ lastOtp: 'secret-hash' });
|
|
229
|
+
|
|
230
|
+
const res = await request(app).get('/api/auth/me').set('Authorization', bearer(account));
|
|
231
|
+
|
|
232
|
+
expect(res.status).toBe(200);
|
|
233
|
+
expect(res.body.data).toMatchObject({
|
|
234
|
+
id: String(account._id),
|
|
235
|
+
email: 'user@example.com',
|
|
236
|
+
profile: { fullName: 'Test User' },
|
|
237
|
+
socialAccounts: [],
|
|
238
|
+
});
|
|
239
|
+
expect(res.body.data).not.toHaveProperty('password');
|
|
240
|
+
expect(res.body.data).not.toHaveProperty('lastOtp');
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it('accepts the token from the cookie', async () => {
|
|
244
|
+
const account = await createAccount();
|
|
245
|
+
|
|
246
|
+
const res = await request(app)
|
|
247
|
+
.get('/api/auth/me')
|
|
248
|
+
.set('Cookie', `access_token=${bearer(account).slice(7)}`);
|
|
249
|
+
|
|
250
|
+
expect(res.status).toBe(200);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it('rejects revoked tokens (token version changed)', async () => {
|
|
254
|
+
const account = await createAccount();
|
|
255
|
+
await account_model.updateOne({ _id: account._id }, { $inc: { tokenVersion: 1 } });
|
|
256
|
+
|
|
257
|
+
const res = await request(app).get('/api/auth/me').set('Authorization', bearer(account));
|
|
258
|
+
|
|
259
|
+
expect(res.status).toBe(401);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it('rejects deleted accounts', async () => {
|
|
263
|
+
const account = await createAccount({ isDeleted: true });
|
|
264
|
+
|
|
265
|
+
const res = await request(app).get('/api/auth/me').set('Authorization', bearer(account));
|
|
266
|
+
|
|
267
|
+
expect(res.status).toBe(401);
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
describe('PUT /api/auth/change-password', () => {
|
|
272
|
+
it('changes the password, revokes other sessions and issues a new token', async () => {
|
|
273
|
+
const account = await createAccount();
|
|
274
|
+
const oldToken = bearer(account);
|
|
275
|
+
|
|
276
|
+
const res = await request(app)
|
|
277
|
+
.put('/api/auth/change-password')
|
|
278
|
+
.set('Authorization', oldToken)
|
|
279
|
+
.send({ oldPassword: PASSWORD, newPassword: 'another-strong-password' });
|
|
280
|
+
|
|
281
|
+
expect(res.status).toBe(200);
|
|
282
|
+
const stored = await findAccount();
|
|
283
|
+
expect(stored!.tokenVersion).toBe(1);
|
|
284
|
+
expect(await bcrypt.compare('another-strong-password', stored!.password!)).toBe(true);
|
|
285
|
+
|
|
286
|
+
const fresh = await request(app)
|
|
287
|
+
.get('/api/auth/me')
|
|
288
|
+
.set('Authorization', `Bearer ${res.body.data.accessToken}`);
|
|
289
|
+
const stale = await request(app).get('/api/auth/me').set('Authorization', oldToken);
|
|
290
|
+
expect(fresh.status).toBe(200);
|
|
291
|
+
expect(stale.status).toBe(401);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it('rejects a wrong old password', async () => {
|
|
295
|
+
const account = await createAccount();
|
|
296
|
+
|
|
297
|
+
const res = await request(app)
|
|
298
|
+
.put('/api/auth/change-password')
|
|
299
|
+
.set('Authorization', bearer(account))
|
|
300
|
+
.send({ oldPassword: 'not-my-password', newPassword: 'another-strong-password' });
|
|
301
|
+
|
|
302
|
+
expect(res.status).toBe(401);
|
|
303
|
+
expect((await findAccount())!.tokenVersion).toBe(0);
|
|
304
|
+
});
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
describe('PUT /api/auth/resend-otp', () => {
|
|
308
|
+
it('answers the same for unknown emails, without sending anything', async () => {
|
|
309
|
+
const res = await request(app)
|
|
310
|
+
.put('/api/auth/resend-otp')
|
|
311
|
+
.send({ email: 'nobody@example.com' });
|
|
312
|
+
|
|
313
|
+
expect(res.status).toBe(200);
|
|
314
|
+
expect(enqueueEmail).not.toHaveBeenCalled();
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
it('sends a new OTP to unverified accounts', async () => {
|
|
318
|
+
await createAccount({ isAccountVerified: false, lastOtp: 'old-hash' });
|
|
319
|
+
|
|
320
|
+
const res = await request(app).put('/api/auth/resend-otp').send({ email: 'user@example.com' });
|
|
321
|
+
|
|
322
|
+
expect(res.status).toBe(200);
|
|
323
|
+
const job = lastEmail();
|
|
324
|
+
if (job.type !== 'verify-account') throw new Error('unexpected job');
|
|
325
|
+
expect((await findAccount())!.lastOtp).toBe(hashOtp(job.otp));
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it('enforces the resend cooldown', async () => {
|
|
329
|
+
await createAccount({ isAccountVerified: false, lastOtpSendingTime: new Date() });
|
|
330
|
+
|
|
331
|
+
const res = await request(app).put('/api/auth/resend-otp').send({ email: 'user@example.com' });
|
|
332
|
+
|
|
333
|
+
expect(res.status).toBe(429);
|
|
334
|
+
expect(enqueueEmail).not.toHaveBeenCalled();
|
|
335
|
+
});
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
describe('password reset', () => {
|
|
339
|
+
const requestReset = async (email = 'user@example.com') => {
|
|
340
|
+
await request(app).put('/api/auth/forget-password').send({ email }).expect(200);
|
|
341
|
+
const job = lastEmail();
|
|
342
|
+
if (job.type !== 'reset-password') throw new Error('unexpected job');
|
|
343
|
+
return decodeURIComponent(new URL(job.resetLink).searchParams.get('token')!);
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
const reset = (token: string, newPass = 'brand-new-password') =>
|
|
347
|
+
request(app).put('/api/auth/reset-password').send({ token, newPass });
|
|
348
|
+
|
|
349
|
+
it('answers the same for unknown emails, without sending anything', async () => {
|
|
350
|
+
const res = await request(app)
|
|
351
|
+
.put('/api/auth/forget-password')
|
|
352
|
+
.send({ email: 'nobody@example.com' });
|
|
353
|
+
|
|
354
|
+
expect(res.status).toBe(200);
|
|
355
|
+
expect(enqueueEmail).not.toHaveBeenCalled();
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
it('resets the password through the emailed link, which works only once', async () => {
|
|
359
|
+
const account = await createAccount({ isAccountVerified: false });
|
|
360
|
+
const oldToken = bearer(account);
|
|
361
|
+
const token = await requestReset();
|
|
362
|
+
|
|
363
|
+
expect((await reset(token)).status).toBe(200);
|
|
364
|
+
expect((await signIn('user@example.com', 'brand-new-password')).status).toBe(200);
|
|
365
|
+
|
|
366
|
+
// the link is used up, the account is verified, and old sessions are revoked
|
|
367
|
+
expect((await reset(token, 'yet-another-password')).status).toBe(401);
|
|
368
|
+
expect((await findAccount())!.isAccountVerified).toBe(true);
|
|
369
|
+
expect((await request(app).get('/api/auth/me').set('Authorization', oldToken)).status).toBe(
|
|
370
|
+
401,
|
|
371
|
+
);
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
it('only ever resets the account the link was issued for', async () => {
|
|
375
|
+
await createAccount();
|
|
376
|
+
await createAccount({ email: 'victim@example.com' });
|
|
377
|
+
const token = await requestReset();
|
|
378
|
+
|
|
379
|
+
// an extra `email` in the body (as the old API accepted) is ignored
|
|
380
|
+
const res = await request(app)
|
|
381
|
+
.put('/api/auth/reset-password')
|
|
382
|
+
.send({ token, newPass: 'attacker-password', email: 'victim@example.com' });
|
|
383
|
+
|
|
384
|
+
expect(res.status).toBe(200);
|
|
385
|
+
expect((await signIn('victim@example.com', 'attacker-password')).status).toBe(401);
|
|
386
|
+
expect((await signIn('victim@example.com', PASSWORD)).status).toBe(200);
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
it('rejects a forged token', async () => {
|
|
390
|
+
await createAccount();
|
|
391
|
+
|
|
392
|
+
expect((await reset('not.a.jwt')).status).toBe(401);
|
|
393
|
+
expect((await signIn('user@example.com', PASSWORD)).status).toBe(200);
|
|
394
|
+
});
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
describe('sign out', () => {
|
|
398
|
+
it('POST /sign-out clears the cookie', async () => {
|
|
399
|
+
const res = await request(app).post('/api/auth/sign-out');
|
|
400
|
+
|
|
401
|
+
expect(res.status).toBe(200);
|
|
402
|
+
expect(accessCookie(res)).toMatch(/access_token=;.*Expires=Thu, 01 Jan 1970/);
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
it('POST /sign-out-all revokes every token of the account', async () => {
|
|
406
|
+
const account = await createAccount();
|
|
407
|
+
const token = bearer(account);
|
|
408
|
+
|
|
409
|
+
const res = await request(app).post('/api/auth/sign-out-all').set('Authorization', token);
|
|
410
|
+
|
|
411
|
+
expect(res.status).toBe(200);
|
|
412
|
+
expect((await request(app).get('/api/auth/me').set('Authorization', token)).status).toBe(401);
|
|
413
|
+
});
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
describe('POST /api/auth/social/exchange', () => {
|
|
417
|
+
const exchange = (code: string) => request(app).post('/api/auth/social/exchange').send({ code });
|
|
418
|
+
|
|
419
|
+
it('trades a valid code for an access token, once', async () => {
|
|
420
|
+
const account = await createAccount();
|
|
421
|
+
const code = await createSocialLoginCode(String(account._id));
|
|
422
|
+
|
|
423
|
+
const res = await exchange(code);
|
|
424
|
+
|
|
425
|
+
expect(res.status).toBe(200);
|
|
426
|
+
const payload = jwtHelpers.verifyToken(res.body.data.accessToken, configs.jwt.access_token);
|
|
427
|
+
expect(payload.accountId).toBe(String(account._id));
|
|
428
|
+
expect(accessCookie(res)).toBeDefined();
|
|
429
|
+
expect((await exchange(code)).status).toBe(401);
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
it('rejects unknown codes', async () => {
|
|
433
|
+
expect((await exchange('made-up')).status).toBe(401);
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
it('rejects expired codes', async () => {
|
|
437
|
+
const account = await createAccount();
|
|
438
|
+
await social_login_code_model.create({
|
|
439
|
+
_id: hashLoginCode('old'),
|
|
440
|
+
accountId: account._id,
|
|
441
|
+
expiresAt: new Date(Date.now() - 1000),
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
expect((await exchange('old')).status).toBe(401);
|
|
445
|
+
});
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
describe('refresh tokens', () => {
|
|
449
|
+
const refreshCookie = (res: request.Response) =>
|
|
450
|
+
([] as string[])
|
|
451
|
+
.concat(res.headers['set-cookie'] ?? [])
|
|
452
|
+
.find((c) => c.startsWith('refresh_token='));
|
|
453
|
+
|
|
454
|
+
const refresh = (token: string) =>
|
|
455
|
+
request(app).post('/api/auth/refresh-token').set('Cookie', `refresh_token=${token}`);
|
|
456
|
+
|
|
457
|
+
/** Signs in (a new device / session) and returns its refresh token. */
|
|
458
|
+
const startSession = async () => {
|
|
459
|
+
const res = await signIn('user@example.com', PASSWORD);
|
|
460
|
+
expect(res.status).toBe(200);
|
|
461
|
+
return res.body.data.refreshToken as string;
|
|
462
|
+
};
|
|
463
|
+
|
|
464
|
+
/** Pretends the token was rotated `ms` ago. */
|
|
465
|
+
const usedAgo = (token: string, ms: number) =>
|
|
466
|
+
refresh_token_model.updateOne(
|
|
467
|
+
{ _id: hashRefreshToken(token) },
|
|
468
|
+
{ $set: { usedAt: new Date(Date.now() - ms) } },
|
|
469
|
+
);
|
|
470
|
+
|
|
471
|
+
it('sign-in starts a session: refresh token in an httpOnly cookie, stored hashed', async () => {
|
|
472
|
+
await createAccount();
|
|
473
|
+
|
|
474
|
+
const res = await signIn('user@example.com', PASSWORD);
|
|
475
|
+
|
|
476
|
+
const { refreshToken } = res.body.data;
|
|
477
|
+
expect(refreshCookie(res)).toMatch(/HttpOnly/);
|
|
478
|
+
expect(refreshCookie(res)).toMatch(/Path=\/api\/auth/);
|
|
479
|
+
expect(await refresh_token_model.exists({ _id: hashRefreshToken(refreshToken) })).toBeTruthy();
|
|
480
|
+
expect(await refresh_token_model.exists({ _id: refreshToken })).toBeNull();
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
it('rotates the token on every refresh, within the same session', async () => {
|
|
484
|
+
await createAccount();
|
|
485
|
+
const first = await startSession();
|
|
486
|
+
|
|
487
|
+
const res = await refresh(first);
|
|
488
|
+
|
|
489
|
+
expect(res.status).toBe(200);
|
|
490
|
+
const second = res.body.data.refreshToken;
|
|
491
|
+
expect(second).not.toBe(first);
|
|
492
|
+
expect(refreshCookie(res)).toContain(second);
|
|
493
|
+
const [a, b] = await Promise.all([
|
|
494
|
+
refresh_token_model.findById(hashRefreshToken(first)).lean(),
|
|
495
|
+
refresh_token_model.findById(hashRefreshToken(second)).lean(),
|
|
496
|
+
]);
|
|
497
|
+
expect(a!.usedAt).not.toBeNull();
|
|
498
|
+
expect(b!.familyId).toBe(a!.familyId);
|
|
499
|
+
|
|
500
|
+
// the new access token works, and so does the next refresh
|
|
501
|
+
const me = await request(app)
|
|
502
|
+
.get('/api/auth/me')
|
|
503
|
+
.set('Authorization', `Bearer ${res.body.data.accessToken}`);
|
|
504
|
+
expect(me.status).toBe(200);
|
|
505
|
+
expect((await refresh(second)).status).toBe(200);
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
it('accepts the refresh token in the body (non-browser clients)', async () => {
|
|
509
|
+
await createAccount();
|
|
510
|
+
const token = await startSession();
|
|
511
|
+
|
|
512
|
+
const res = await request(app).post('/api/auth/refresh-token').send({ refreshToken: token });
|
|
513
|
+
|
|
514
|
+
expect(res.status).toBe(200);
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
it('ends the whole session when a rotated token is reused (theft)', async () => {
|
|
518
|
+
await createAccount();
|
|
519
|
+
const stolen = await startSession();
|
|
520
|
+
const current = (await refresh(stolen)).body.data.refreshToken;
|
|
521
|
+
await usedAgo(stolen, 60_000);
|
|
522
|
+
|
|
523
|
+
expect((await refresh(stolen)).status).toBe(401);
|
|
524
|
+
// the legitimate user's token from the same session is revoked too
|
|
525
|
+
expect((await refresh(current)).status).toBe(401);
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
it('does not end the session for a parallel refresh within the grace window', async () => {
|
|
529
|
+
await createAccount();
|
|
530
|
+
const first = await startSession();
|
|
531
|
+
const second = (await refresh(first)).body.data.refreshToken;
|
|
532
|
+
|
|
533
|
+
expect((await refresh(first)).status).toBe(401);
|
|
534
|
+
expect((await refresh(second)).status).toBe(200);
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
it('gives a new token to only one of several parallel refreshes', async () => {
|
|
538
|
+
await createAccount();
|
|
539
|
+
const token = await startSession();
|
|
540
|
+
|
|
541
|
+
const results = await Promise.all(Array.from({ length: 5 }, () => refresh(token)));
|
|
542
|
+
|
|
543
|
+
expect(results.filter((r) => r.status === 200)).toHaveLength(1);
|
|
544
|
+
expect(await refresh_token_model.countDocuments({ usedAt: null })).toBe(1);
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
it('rejects expired tokens', async () => {
|
|
548
|
+
await createAccount();
|
|
549
|
+
const token = await startSession();
|
|
550
|
+
await refresh_token_model.updateOne(
|
|
551
|
+
{ _id: hashRefreshToken(token) },
|
|
552
|
+
{ $set: { expiresAt: new Date(Date.now() - 1000) } },
|
|
553
|
+
);
|
|
554
|
+
|
|
555
|
+
expect((await refresh(token)).status).toBe(401);
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
it('rejects unknown or missing tokens', async () => {
|
|
559
|
+
expect((await refresh('made-up')).status).toBe(401);
|
|
560
|
+
expect((await request(app).post('/api/auth/refresh-token')).status).toBe(401);
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
it('a password change revokes every session but starts a new one', async () => {
|
|
564
|
+
const account = await createAccount();
|
|
565
|
+
const otherDevice = await startSession();
|
|
566
|
+
|
|
567
|
+
const res = await request(app)
|
|
568
|
+
.put('/api/auth/change-password')
|
|
569
|
+
.set('Authorization', bearer(account))
|
|
570
|
+
.send({ oldPassword: PASSWORD, newPassword: 'another-strong-password' });
|
|
571
|
+
|
|
572
|
+
expect(res.status).toBe(200);
|
|
573
|
+
expect((await refresh(otherDevice)).status).toBe(401);
|
|
574
|
+
expect((await refresh(res.body.data.refreshToken)).status).toBe(200);
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
it('sign-out ends only this device, and clears both cookies', async () => {
|
|
578
|
+
await createAccount();
|
|
579
|
+
const thisDevice = await startSession();
|
|
580
|
+
const otherDevice = await startSession();
|
|
581
|
+
|
|
582
|
+
const res = await request(app)
|
|
583
|
+
.post('/api/auth/sign-out')
|
|
584
|
+
.set('Cookie', `refresh_token=${thisDevice}`);
|
|
585
|
+
|
|
586
|
+
expect(res.status).toBe(200);
|
|
587
|
+
expect(refreshCookie(res)).toMatch(/refresh_token=;.*Expires=Thu, 01 Jan 1970/);
|
|
588
|
+
expect((await refresh(thisDevice)).status).toBe(401);
|
|
589
|
+
expect((await refresh(otherDevice)).status).toBe(200);
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
it('sign-out-all ends every session', async () => {
|
|
593
|
+
const account = await createAccount();
|
|
594
|
+
const one = await startSession();
|
|
595
|
+
const two = await startSession();
|
|
596
|
+
|
|
597
|
+
await request(app)
|
|
598
|
+
.post('/api/auth/sign-out-all')
|
|
599
|
+
.set('Authorization', bearer(account))
|
|
600
|
+
.expect(200);
|
|
601
|
+
|
|
602
|
+
expect((await refresh(one)).status).toBe(401);
|
|
603
|
+
expect((await refresh(two)).status).toBe(401);
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
it('a social login code exchange starts a session too', async () => {
|
|
607
|
+
const account = await createAccount();
|
|
608
|
+
const code = await createSocialLoginCode(String(account._id));
|
|
609
|
+
|
|
610
|
+
const res = await request(app).post('/api/auth/social/exchange').send({ code });
|
|
611
|
+
|
|
612
|
+
expect((await refresh(res.body.data.refreshToken)).status).toBe(200);
|
|
613
|
+
});
|
|
614
|
+
});
|