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,95 @@
1
+ import request from 'supertest';
2
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
3
+ import app from '../../src/app.js';
4
+ import { profile_model } from '../../src/app/modules/profile/profile.schema.js';
5
+ import uploadCloud from '../../src/app/utils/cloudinary.js';
6
+ import { bearer, createAccount } from '../helpers/fixtures.js';
7
+
8
+ vi.mock('../../src/app/utils/cloudinary.js', () => ({ default: vi.fn() }));
9
+
10
+ const PNG = Buffer.from('89504e470d0a1a0a', 'hex');
11
+
12
+ describe('PATCH /api/profile', () => {
13
+ let auth: string;
14
+ let accountId: string;
15
+
16
+ beforeEach(async () => {
17
+ const account = await createAccount();
18
+ auth = bearer(account);
19
+ accountId = String(account._id);
20
+ });
21
+
22
+ const storedProfile = () => profile_model.findOne({ accountId }).lean();
23
+
24
+ it('requires a token', async () => {
25
+ const res = await request(app).patch('/api/profile').field('data', '{}');
26
+ expect(res.status).toBe(401);
27
+ });
28
+
29
+ it('updates the signed-in user profile', async () => {
30
+ const res = await request(app)
31
+ .patch('/api/profile')
32
+ .set('Authorization', auth)
33
+ .field('data', JSON.stringify({ fullName: ' Jane Doe ' }));
34
+
35
+ expect(res.status).toBe(200);
36
+ expect(res.body.data.fullName).toBe('Jane Doe');
37
+ expect((await storedProfile())!.fullName).toBe('Jane Doe');
38
+ expect(uploadCloud).not.toHaveBeenCalled();
39
+ });
40
+
41
+ it('ignores fields that are not in the schema', async () => {
42
+ const res = await request(app)
43
+ .patch('/api/profile')
44
+ .set('Authorization', auth)
45
+ .field('data', JSON.stringify({ fullName: 'Jane', accountId: '507f1f77bcf86cd799439011' }));
46
+
47
+ expect(res.status).toBe(200);
48
+ expect(String((await storedProfile())!.accountId)).toBe(accountId);
49
+ });
50
+
51
+ it('uploads the photo and stores its url', async () => {
52
+ vi.mocked(uploadCloud).mockResolvedValue({ secure_url: 'https://cdn.test/p.png' } as never);
53
+
54
+ const res = await request(app)
55
+ .patch('/api/profile')
56
+ .set('Authorization', auth)
57
+ .attach('file', PNG, { filename: 'me.png', contentType: 'image/png' });
58
+
59
+ expect(res.status).toBe(200);
60
+ expect(vi.mocked(uploadCloud).mock.calls[0]![0].buffer).toEqual(PNG);
61
+ expect((await storedProfile())!.profilePhoto).toBe('https://cdn.test/p.png');
62
+ expect((await storedProfile())!.fullName).toBe('Test User'); // untouched
63
+ });
64
+
65
+ it('rejects files that are not images', async () => {
66
+ const res = await request(app)
67
+ .patch('/api/profile')
68
+ .set('Authorization', auth)
69
+ .attach('file', Buffer.from('#!/bin/sh'), { filename: 'x.sh', contentType: 'text/x-sh' });
70
+
71
+ expect(res.status).toBe(400);
72
+ expect(uploadCloud).not.toHaveBeenCalled();
73
+ });
74
+
75
+ it('rejects invalid data without uploading anything', async () => {
76
+ const res = await request(app)
77
+ .patch('/api/profile')
78
+ .set('Authorization', auth)
79
+ .field('data', JSON.stringify({ fullName: '' }))
80
+ .attach('file', PNG, { filename: 'me.png', contentType: 'image/png' });
81
+
82
+ expect(res.status).toBe(400);
83
+ expect(uploadCloud).not.toHaveBeenCalled();
84
+ expect((await storedProfile())!.fullName).toBe('Test User');
85
+ });
86
+
87
+ it('rejects malformed JSON in `data`', async () => {
88
+ const res = await request(app)
89
+ .patch('/api/profile')
90
+ .set('Authorization', auth)
91
+ .field('data', '{not json');
92
+
93
+ expect(res.status).toBe(400);
94
+ });
95
+ });
@@ -0,0 +1,310 @@
1
+ import { createHash } from 'node:crypto';
2
+ import passport, { type Profile } from 'passport';
3
+ import request from 'supertest';
4
+ import { describe, expect, it, vi } from 'vitest';
5
+ import app from '../../src/app.js';
6
+ import {
7
+ account_model,
8
+ social_account_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 { hashLoginCode } from '../../src/app/passport-js/social-account.js';
13
+ import { createAccount, findAccount } from '../helpers/fixtures.js';
14
+
15
+ const FRONT_END_CALLBACK = 'http://localhost:5173/auth/social/callback';
16
+
17
+ type Callback = (...args: unknown[]) => void;
18
+ type OAuth2Internals = {
19
+ _oauth2: { getOAuthAccessToken: Callback };
20
+ userProfile: Callback;
21
+ };
22
+ type OAuth1Internals = {
23
+ _oauth: { getOAuthRequestToken: Callback; getOAuthAccessToken: Callback };
24
+ userProfile: Callback;
25
+ };
26
+ const strategy = <T>(name: string) =>
27
+ (passport as unknown as { _strategy: (n: string) => T })._strategy(name);
28
+
29
+ /** Calls the node-style callback passed as the last argument. */
30
+ const reply =
31
+ (...result: unknown[]) =>
32
+ (...args: unknown[]) =>
33
+ (args.at(-1) as Callback)(null, ...result);
34
+
35
+ const googleProfile = (overrides: Partial<Profile> = {}): Profile => ({
36
+ provider: 'google',
37
+ id: 'google-123',
38
+ displayName: 'Jane Doe',
39
+ emails: [{ value: 'Jane@Example.com', verified: true } as never],
40
+ photos: [{ value: 'https://photos.test/jane.png' }],
41
+ ...overrides,
42
+ });
43
+
44
+ /** Stubs the provider's HTTP calls (code -> token, token -> profile). */
45
+ const stubGoogle = (profile: Profile) => {
46
+ const google = strategy<OAuth2Internals>('google');
47
+ const tokenCall = vi
48
+ .spyOn(google._oauth2, 'getOAuthAccessToken')
49
+ .mockImplementation(reply('access-token', 'refresh-token', {}));
50
+ vi.spyOn(google, 'userProfile').mockImplementation(reply(profile));
51
+ return tokenCall;
52
+ };
53
+
54
+ const firstCookie = (res: request.Response) =>
55
+ ([] as string[]).concat(res.headers['set-cookie'] ?? [])[0]!.split(';')[0]!;
56
+
57
+ /** Starts a login and returns what the browser would carry back to the callback. */
58
+ const startGoogleLogin = async (query = '') => {
59
+ const res = await request(app).get(`/api/auth/social/google${query}`);
60
+ const location = new URL(res.headers.location);
61
+ return { res, location, cookie: firstCookie(res), state: location.searchParams.get('state')! };
62
+ };
63
+
64
+ const finishGoogleLogin = (state: string, cookie: string) =>
65
+ request(app)
66
+ .get(`/api/auth/social/google/callback?code=auth-code&state=${state}`)
67
+ .set('Cookie', cookie);
68
+
69
+ const loginWithGoogle = async (query = '') => {
70
+ const { cookie, state } = await startGoogleLogin(query);
71
+ return finishGoogleLogin(state, cookie);
72
+ };
73
+
74
+ const redirectParams = (res: request.Response) => {
75
+ const url = new URL(res.headers.location);
76
+ expect(`${url.origin}${url.pathname}`).toBe(FRONT_END_CALLBACK);
77
+ return url.searchParams;
78
+ };
79
+
80
+ describe('GET /api/auth/social/providers', () => {
81
+ it('lists only the enabled providers', async () => {
82
+ const res = await request(app).get('/api/auth/social/providers');
83
+
84
+ expect(res.status).toBe(200);
85
+ expect(res.body.data.map((p: { name: string }) => p.name)).toEqual([
86
+ 'google',
87
+ 'github',
88
+ 'twitter',
89
+ ]);
90
+ });
91
+
92
+ it('has no routes for disabled providers', async () => {
93
+ const res = await request(app).get('/api/auth/social/facebook');
94
+ expect(res.status).toBe(404);
95
+ });
96
+ });
97
+
98
+ describe('GET /api/auth/social/<provider>', () => {
99
+ it('redirects to the provider with state and PKCE, kept in an httpOnly cookie', async () => {
100
+ const { res, location, cookie } = await startGoogleLogin();
101
+
102
+ expect(res.status).toBe(302);
103
+ expect(location.host).toBe('accounts.google.com');
104
+ expect(location.searchParams.get('state')).toBeTruthy();
105
+ expect(location.searchParams.get('code_challenge_method')).toBe('S256');
106
+ expect(location.searchParams.get('scope')).toBe('openid email profile');
107
+ expect(cookie).toMatch(/^oauth_google=/);
108
+ expect(res.headers['set-cookie']![0]).toMatch(/HttpOnly/);
109
+ // the cookie is encrypted: the state isn't readable in it
110
+ expect(cookie).not.toContain(location.searchParams.get('state'));
111
+ });
112
+
113
+ it('uses state (no PKCE) for GitHub', async () => {
114
+ const res = await request(app).get('/api/auth/social/github');
115
+ const location = new URL(res.headers.location);
116
+
117
+ expect(location.host).toBe('github.com');
118
+ expect(location.searchParams.get('state')).toBeTruthy();
119
+ expect(location.searchParams.get('code_challenge')).toBeNull();
120
+ });
121
+ });
122
+
123
+ describe('GET /api/auth/social/<provider>/callback', () => {
124
+ it('creates an account on first login and redirects with a single-use code', async () => {
125
+ const tokenCall = stubGoogle(googleProfile());
126
+
127
+ const { location, cookie, state } = await startGoogleLogin('?returnTo=/dashboard');
128
+ const res = await finishGoogleLogin(state, cookie);
129
+
130
+ expect(res.status).toBe(302);
131
+ const params = redirectParams(res);
132
+ expect(params.get('returnTo')).toBe('/dashboard');
133
+
134
+ const account = await findAccount('jane@example.com');
135
+ expect(account).toMatchObject({ provider: 'GOOGLE', password: null, isAccountVerified: true });
136
+ expect(await profile_model.findOne({ accountId: account!._id }).lean()).toMatchObject({
137
+ fullName: 'Jane Doe',
138
+ profilePhoto: 'https://photos.test/jane.png',
139
+ });
140
+ expect(
141
+ await social_account_model.exists({ provider: 'GOOGLE', providerUserId: 'google-123' }),
142
+ ).toBeTruthy();
143
+
144
+ // the code in the URL is stored only as a hash, for the new account
145
+ const code = await social_login_code_model.findById(hashLoginCode(params.get('code')!)).lean();
146
+ expect(String(code!.accountId)).toBe(String(account!._id));
147
+
148
+ // PKCE: the verifier sent with the code matches the challenge from the start
149
+ const verifier = (tokenCall.mock.calls[0]![1] as { code_verifier: string }).code_verifier;
150
+ expect(createHash('sha256').update(verifier).digest('base64url')).toBe(
151
+ location.searchParams.get('code_challenge'),
152
+ );
153
+ });
154
+
155
+ it('logs a linked identity into its account, even if the email changed', async () => {
156
+ const account = await createAccount();
157
+ await social_account_model.create({
158
+ accountId: account._id,
159
+ provider: 'GOOGLE',
160
+ providerUserId: 'google-123',
161
+ });
162
+ stubGoogle(googleProfile({ emails: [] }));
163
+
164
+ const res = await loginWithGoogle();
165
+
166
+ const code = redirectParams(res).get('code')!;
167
+ const row = await social_login_code_model.findById(hashLoginCode(code)).lean();
168
+ expect(String(row!.accountId)).toBe(String(account._id));
169
+ expect(await account_model.countDocuments()).toBe(1);
170
+ });
171
+
172
+ it('links to an existing unverified account and drops its unproven password', async () => {
173
+ await createAccount({ email: 'jane@example.com', isAccountVerified: false });
174
+ stubGoogle(googleProfile());
175
+
176
+ const res = await loginWithGoogle();
177
+
178
+ expect(redirectParams(res).get('code')).toBeTruthy();
179
+ expect(await findAccount('jane@example.com')).toMatchObject({
180
+ isAccountVerified: true,
181
+ password: null,
182
+ });
183
+ expect(await social_account_model.countDocuments()).toBe(1);
184
+ });
185
+
186
+ it('refuses unverified provider emails', async () => {
187
+ stubGoogle(
188
+ googleProfile({ emails: [{ value: 'jane@example.com', verified: false } as never] }),
189
+ );
190
+
191
+ const res = await loginWithGoogle();
192
+
193
+ expect(redirectParams(res).get('error')).toBe('email_unverified');
194
+ expect(await account_model.countDocuments()).toBe(0);
195
+ });
196
+
197
+ it('refuses deleted accounts', async () => {
198
+ await createAccount({ email: 'jane@example.com', isDeleted: true });
199
+ stubGoogle(googleProfile());
200
+
201
+ const res = await loginWithGoogle();
202
+
203
+ expect(redirectParams(res).get('error')).toBe('account_disabled');
204
+ expect(await social_login_code_model.countDocuments()).toBe(0);
205
+ });
206
+
207
+ it('rejects a callback whose state does not match (login CSRF)', async () => {
208
+ const tokenCall = stubGoogle(googleProfile());
209
+ const { cookie } = await startGoogleLogin();
210
+
211
+ const res = await finishGoogleLogin('attacker-state', cookie);
212
+
213
+ expect(redirectParams(res).get('error')).toBe('invalid_state');
214
+ expect(tokenCall).not.toHaveBeenCalled();
215
+ });
216
+
217
+ it('rejects a callback without the state cookie', async () => {
218
+ const tokenCall = stubGoogle(googleProfile());
219
+ const { state } = await startGoogleLogin();
220
+
221
+ const res = await request(app).get(
222
+ `/api/auth/social/google/callback?code=auth-code&state=${state}`,
223
+ );
224
+
225
+ expect(redirectParams(res).get('error')).toBe('invalid_state');
226
+ expect(tokenCall).not.toHaveBeenCalled();
227
+ });
228
+
229
+ it('reports when the user cancels at the provider', async () => {
230
+ const res = await request(app).get('/api/auth/social/google/callback?error=access_denied');
231
+
232
+ expect(redirectParams(res).get('error')).toBe('access_denied');
233
+ });
234
+
235
+ it('never redirects to another site through returnTo', async () => {
236
+ stubGoogle(googleProfile());
237
+
238
+ const res = await loginWithGoogle(`?returnTo=${encodeURIComponent('//evil.com')}`);
239
+
240
+ const params = redirectParams(res);
241
+ expect(params.get('code')).toBeTruthy();
242
+ expect(params.get('returnTo')).toBeNull();
243
+ });
244
+ });
245
+
246
+ describe('X (Twitter) login, OAuth 1.0a', () => {
247
+ const twitter = () => strategy<OAuth1Internals>('twitter');
248
+
249
+ const startTwitterLogin = async (query = '') => {
250
+ vi.spyOn(twitter()._oauth, 'getOAuthRequestToken').mockImplementation(
251
+ reply('req-token', 'req-secret', {}),
252
+ );
253
+ const res = await request(app).get(`/api/auth/social/twitter${query}`);
254
+ return { res, cookie: firstCookie(res) };
255
+ };
256
+
257
+ it('keeps the request token in an encrypted cookie and completes the login', async () => {
258
+ const accessCall = vi
259
+ .spyOn(twitter()._oauth, 'getOAuthAccessToken')
260
+ .mockImplementation(reply('access', 'access-secret', {}));
261
+ vi.spyOn(twitter(), 'userProfile').mockImplementation(
262
+ reply({
263
+ provider: 'twitter',
264
+ id: 'x-1',
265
+ displayName: 'Jane',
266
+ emails: [{ value: 'jane@x.com' }],
267
+ }),
268
+ );
269
+
270
+ const { res: start, cookie } = await startTwitterLogin('?returnTo=/home');
271
+ const location = new URL(start.headers.location);
272
+ expect(`${location.origin}${location.pathname}`).toBe(
273
+ 'https://api.twitter.com/oauth/authenticate',
274
+ );
275
+ expect(location.searchParams.get('oauth_token')).toBe('req-token');
276
+ expect(cookie).toMatch(/^oauth_twitter=/);
277
+ expect(cookie).not.toContain('req-secret');
278
+
279
+ const res = await request(app)
280
+ .get('/api/auth/social/twitter/callback?oauth_token=req-token&oauth_verifier=v')
281
+ .set('Cookie', cookie);
282
+
283
+ // the token secret from the cookie was used to get the access token
284
+ expect(accessCall.mock.calls[0]!.slice(0, 3)).toEqual(['req-token', 'req-secret', 'v']);
285
+ const params = redirectParams(res);
286
+ expect(params.get('code')).toBeTruthy();
287
+ expect(params.get('returnTo')).toBe('/home');
288
+ expect(await findAccount('jane@x.com')).toMatchObject({ provider: 'TWITTER' });
289
+ });
290
+
291
+ it('rejects a callback whose token does not match the one in the cookie', async () => {
292
+ const accessCall = vi.spyOn(twitter()._oauth, 'getOAuthAccessToken');
293
+ const { cookie } = await startTwitterLogin();
294
+
295
+ const res = await request(app)
296
+ .get('/api/auth/social/twitter/callback?oauth_token=other-token&oauth_verifier=v')
297
+ .set('Cookie', cookie);
298
+
299
+ expect(redirectParams(res).get('error')).toBe('invalid_state');
300
+ expect(accessCall).not.toHaveBeenCalled();
301
+ });
302
+
303
+ it('rejects a callback for a request token it did not issue', async () => {
304
+ const res = await request(app).get(
305
+ '/api/auth/social/twitter/callback?oauth_token=forged&oauth_verifier=v',
306
+ );
307
+
308
+ expect(redirectParams(res).get('error')).toBe('invalid_state');
309
+ });
310
+ });
@@ -0,0 +1,25 @@
1
+ import mongoose from 'mongoose';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { afterAll, beforeAll, beforeEach, inject, vi } from 'vitest';
4
+
5
+ // external boundaries only: everything between the route and MongoDB runs for real
6
+ vi.mock('../src/app/queues/email.queue.js', () => ({
7
+ enqueueEmail: vi.fn(),
8
+ }));
9
+
10
+ beforeAll(async () => {
11
+ await mongoose.connect(inject('mongoUri'), { dbName: `test-${randomUUID()}` });
12
+ // unique and TTL indexes are part of the behaviour under test
13
+ await mongoose.connection.syncIndexes();
14
+ });
15
+
16
+ beforeEach(async () => {
17
+ // a clean database, and clean rate limit counters, for every test
18
+ const collections = await mongoose.connection.db!.collections();
19
+ await Promise.all(collections.map((c) => c.deleteMany({})));
20
+ });
21
+
22
+ afterAll(async () => {
23
+ await mongoose.connection.dropDatabase();
24
+ await mongoose.disconnect();
25
+ });
@@ -1,20 +1,16 @@
1
- {
2
- "compilerOptions": {
3
- "target": "es2016",
4
- "module": "commonjs",
5
- "rootDir": "./src",
6
- "outDir": "./dist",
7
- "esModuleInterop": true,
8
- "forceConsistentCasingInFileNames": true,
9
- "strict": true,
10
- "skipLibCheck": true,
11
- },
12
- "include": [
13
- "src/**/*.ts"
14
- ],
15
- "exclude": [
16
- "node_modules",
17
- "dist",
18
- "build"
19
- ]
20
- }
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2023",
4
+ "module": "nodenext",
5
+ "moduleResolution": "nodenext",
6
+ "rootDir": "./src",
7
+ "outDir": "./dist",
8
+ "esModuleInterop": true,
9
+ "forceConsistentCasingInFileNames": true,
10
+ "skipLibCheck": true,
11
+ "strict": true,
12
+ "noImplicitAny": true
13
+ },
14
+ "include": ["src/**/*"],
15
+ "exclude": ["node_modules", "dist", "build", "generated/prisma"]
16
+ }
@@ -0,0 +1,43 @@
1
+ import { defineConfig } from 'vitest/config';
2
+
3
+ const secret = (name: string) => `${name}-test-secret-0123456789abcdefghijklmnop`;
4
+
5
+ export default defineConfig({
6
+ test: {
7
+ include: ['tests/**/*.test.ts'],
8
+ // starts one in-memory MongoDB for the whole run
9
+ globalSetup: ['./tests/global-setup.ts'],
10
+ setupFiles: ['./tests/setup.ts'],
11
+ // every test starts with fresh mocks
12
+ mockReset: true,
13
+ // the first run downloads a MongoDB binary
14
+ hookTimeout: 120_000,
15
+ // set before the app reads its config; values in .env don't override these
16
+ env: {
17
+ NODE_ENV: 'test',
18
+ // placeholder for config validation; tests connect to the in-memory server
19
+ DATABASE_URL: 'mongodb://localhost:27017/unused',
20
+ ACCESS_TOKEN: secret('access'),
21
+ VERIFIED_TOKEN: secret('verify'),
22
+ RESET_SECRET: secret('reset'),
23
+ FRONT_END_URL: 'http://localhost:5173',
24
+ CORS_ORIGINS: 'http://localhost:5173',
25
+ ENABLE_SWAGGER: 'false',
26
+ IS_GOOGLE_LOGIN: 'true',
27
+ GOOGLE_CLIENT_ID: 'google-client-id',
28
+ GOOGLE_CLIENT_SECRET: 'google-client-secret',
29
+ GOOGLE_CALLBACK_URL: 'http://localhost:5000/api/auth/social/google/callback',
30
+ IS_GITHUB_LOGIN: 'true',
31
+ GITHUB_CLIENT_ID: 'github-client-id',
32
+ GITHUB_CLIENT_SECRET: 'github-client-secret',
33
+ GITHUB_CALLBACK_URL: 'http://localhost:5000/api/auth/social/github/callback',
34
+ IS_TWITTER_LOGIN: 'true',
35
+ TWITTER_CONSUMER_KEY: 'twitter-key',
36
+ TWITTER_CONSUMER_SECRET: 'twitter-secret',
37
+ TWITTER_CALLBACK_URL: 'http://localhost:5000/api/auth/social/twitter/callback',
38
+ IS_FACEBOOK_LOGIN: 'false',
39
+ IS_LINKEDIN_LOGIN: 'false',
40
+ IS_DISCORD_LOGIN: 'false',
41
+ },
42
+ },
43
+ });
@@ -6,3 +6,5 @@ Dockerfile
6
6
  README.md
7
7
  .env
8
8
  dist
9
+ docker-compose.ymltests
10
+ coverage
@@ -1,20 +1,76 @@
1
- DATABASE_URL="postgresql://postgres:postgres123@localhost:5432/server_db?sslmode=disable"
2
-
1
+ # Default Node config
3
2
  NODE_ENV=development
4
3
  PORT=5000
4
+ # Number of reverse proxies in front of the app (1 behind Caddy/Nginx). Keep 0 if exposed directly.
5
+ TRUST_PROXY=0
6
+
7
+ # Database config
8
+ DATABASE_URL="postgresql://postgres:change-me@localhost:5432/server_db?sslmode=disable"
9
+ # Used by docker-compose for the postgres container
10
+ POSTGRES_USER=postgres
11
+ POSTGRES_PASSWORD=change-me
12
+ POSTGRES_DB=server_db
13
+
5
14
  # JWT Configuration
15
+ # Each secret must be a different random value of 32+ characters: `openssl rand -hex 32`
6
16
  ACCESS_TOKEN=
7
- ACCESS_EXPIRES=
8
- RESET_SECRET=
9
- RESET_EXPIRES=
17
+ # Access tokens are short-lived; refresh tokens (opaque, rotated on every use) last longer
18
+ ACCESS_EXPIRES=15m
19
+ REFRESH_EXPIRES_DAYS=30
10
20
  VERIFIED_TOKEN=
11
- FRONT_END_URL=
21
+ RESET_SECRET=
22
+ RESET_EXPIRES=15m
23
+ FRONT_END_URL=http://localhost:5173
24
+ # Comma separated list of allowed origins. Defaults to FRONT_END_URL.
25
+ CORS_ORIGINS=
12
26
 
13
27
  # Email Configuration
14
28
  APP_USER_EMAIL=
15
29
  APP_PASSWORD=
30
+ # "From" address. Defaults to APP_USER_EMAIL.
31
+ MAIL_FROM=
16
32
 
17
33
  # Cloudinary Configuration
18
34
  CLOUD_NAME=
19
35
  CLOUD_API_KEY=
20
36
  CLOUD_API_SECRET=
37
+
38
+ # Swagger Config (docs are disabled in production unless ENABLE_SWAGGER=true)
39
+ ENABLE_SWAGGER=
40
+ DEVELOPMENT_BASE_URL=http://localhost:5000
41
+ PRODUCTION_BASE_URL=
42
+
43
+ # Social login: set IS_<PROVIDER>_LOGIN=true and fill in its credentials.
44
+ # After the provider, the browser is sent here with `?code=` (trade it via
45
+ # POST /api/auth/social/exchange) or `?error=`. Defaults to FRONT_END_URL/auth/social/callback
46
+ SOCIAL_REDIRECT_URL=
47
+
48
+ IS_GOOGLE_LOGIN=false
49
+ GOOGLE_CLIENT_ID=
50
+ GOOGLE_CLIENT_SECRET=
51
+ GOOGLE_CALLBACK_URL=http://localhost:5000/api/auth/social/google/callback
52
+
53
+ IS_FACEBOOK_LOGIN=false
54
+ FACEBOOK_APP_ID=
55
+ FACEBOOK_APP_SECRET=
56
+ FACEBOOK_CALLBACK_URL=http://localhost:5000/api/auth/social/facebook/callback
57
+
58
+ IS_GITHUB_LOGIN=false
59
+ GITHUB_CLIENT_ID=
60
+ GITHUB_CLIENT_SECRET=
61
+ GITHUB_CALLBACK_URL=http://localhost:5000/api/auth/social/github/callback
62
+
63
+ IS_TWITTER_LOGIN=false
64
+ TWITTER_CONSUMER_KEY=
65
+ TWITTER_CONSUMER_SECRET=
66
+ TWITTER_CALLBACK_URL=http://localhost:5000/api/auth/social/twitter/callback
67
+
68
+ IS_LINKEDIN_LOGIN=false
69
+ LINKEDIN_CLIENT_ID=
70
+ LINKEDIN_CLIENT_SECRET=
71
+ LINKEDIN_CALLBACK_URL=http://localhost:5000/api/auth/social/linkedin/callback
72
+
73
+ IS_DISCORD_LOGIN=false
74
+ DISCORD_CLIENT_ID=
75
+ DISCORD_CLIENT_SECRET=
76
+ DISCORD_CALLBACK_URL=http://localhost:5000/api/auth/social/discord/callback
@@ -0,0 +1,3 @@
1
+ node_modules
2
+ dist
3
+ prisma/generated
@@ -0,0 +1,7 @@
1
+ {
2
+ "semi": true,
3
+ "singleQuote": true,
4
+ "tabWidth": 2,
5
+ "trailingComma": "all",
6
+ "printWidth": 100
7
+ }
@@ -1,3 +1,3 @@
1
1
  {
2
2
  "database": "prisma"
3
- }
3
+ }
@@ -1,27 +1,41 @@
1
1
  # ---------- BUILD STAGE ----------
2
- FROM node:18-alpine AS builder
2
+ FROM node:22-alpine AS builder
3
3
 
4
4
  WORKDIR /app
5
5
 
6
+ # Only install deps first (cache friendly)
6
7
  COPY package*.json ./
7
- RUN npm install
8
+ RUN npm ci
8
9
 
10
+ # Copy source
9
11
  COPY . .
12
+
13
+ # Generate prisma + build
14
+ RUN npx prisma generate
10
15
  RUN npm run build
11
16
 
12
17
 
13
18
  # ---------- PRODUCTION STAGE ----------
14
- FROM node:18-alpine
19
+ FROM node:22-alpine
15
20
 
16
21
  WORKDIR /app
17
22
 
23
+ ENV NODE_ENV=production
24
+
25
+ # Only install production deps
18
26
  COPY package*.json ./
19
- RUN npm install && npm cache clean --force
27
+ RUN npm ci --omit=dev && npm cache clean --force
20
28
 
29
+ # Copy Prisma generated client + schema
30
+ COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
31
+ COPY --from=builder /app/node_modules/@prisma ./node_modules/@prisma
32
+ COPY --from=builder /app/prisma ./prisma
33
+
34
+ # Copy built app
21
35
  COPY --from=builder /app/dist ./dist
22
-
23
- RUN mkdir -p /app/uploads
36
+
37
+ USER node
24
38
 
25
39
  EXPOSE 5000
26
40
 
27
- CMD ["node", "dist/server.js"]
41
+ CMD ["node", "dist/server.js"]