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