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.
- 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 +2 -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/src/app/types/index.d.ts +0 -12
- package/templates/prisma/src/app/utils/otpGenerator.ts +0 -29
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import request from 'supertest';
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import app from '../../src/app.js';
|
|
4
|
+
import uploadCloud from '../../src/app/utils/cloudinary.js';
|
|
5
|
+
import { bearer, makeAccount } from '../helpers/fixtures.js';
|
|
6
|
+
import { prismaMock } from '../helpers/prisma.js';
|
|
7
|
+
|
|
8
|
+
vi.mock('../../src/app/utils/cloudinary.js', () => ({ default: vi.fn() }));
|
|
9
|
+
|
|
10
|
+
const account = makeAccount();
|
|
11
|
+
const PNG = Buffer.from('89504e470d0a1a0a', 'hex');
|
|
12
|
+
|
|
13
|
+
describe('PATCH /api/profile', () => {
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
prismaMock.account.findUnique.mockResolvedValue(account); // auth middleware
|
|
16
|
+
prismaMock.profile.update.mockImplementation(async ({ data }) => ({ id: 'p-1', ...data }));
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('requires a token', async () => {
|
|
20
|
+
const res = await request(app).patch('/api/profile').field('data', '{}');
|
|
21
|
+
expect(res.status).toBe(401);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('updates the signed-in user profile', async () => {
|
|
25
|
+
const res = await request(app)
|
|
26
|
+
.patch('/api/profile')
|
|
27
|
+
.set('Authorization', bearer(account))
|
|
28
|
+
.field('data', JSON.stringify({ fullName: ' Jane Doe ' }));
|
|
29
|
+
|
|
30
|
+
expect(res.status).toBe(200);
|
|
31
|
+
expect(prismaMock.profile.update).toHaveBeenCalledWith({
|
|
32
|
+
where: { accountId: 'acc-1' },
|
|
33
|
+
data: { fullName: 'Jane Doe', profilePhoto: undefined },
|
|
34
|
+
});
|
|
35
|
+
expect(uploadCloud).not.toHaveBeenCalled();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('ignores fields that are not in the schema', async () => {
|
|
39
|
+
const res = await request(app)
|
|
40
|
+
.patch('/api/profile')
|
|
41
|
+
.set('Authorization', bearer(account))
|
|
42
|
+
.field('data', JSON.stringify({ fullName: 'Jane', accountId: 'someone-else' }));
|
|
43
|
+
|
|
44
|
+
expect(res.status).toBe(200);
|
|
45
|
+
expect(prismaMock.profile.update.mock.calls[0]![0].data).not.toHaveProperty('accountId');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('uploads the photo and stores its url', async () => {
|
|
49
|
+
vi.mocked(uploadCloud).mockResolvedValue({ secure_url: 'https://cdn.test/p.png' } as never);
|
|
50
|
+
|
|
51
|
+
const res = await request(app)
|
|
52
|
+
.patch('/api/profile')
|
|
53
|
+
.set('Authorization', bearer(account))
|
|
54
|
+
.attach('file', PNG, { filename: 'me.png', contentType: 'image/png' });
|
|
55
|
+
|
|
56
|
+
expect(res.status).toBe(200);
|
|
57
|
+
expect(vi.mocked(uploadCloud).mock.calls[0]![0].buffer).toEqual(PNG);
|
|
58
|
+
expect(res.body.data.profilePhoto).toBe('https://cdn.test/p.png');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('rejects files that are not images', async () => {
|
|
62
|
+
const res = await request(app)
|
|
63
|
+
.patch('/api/profile')
|
|
64
|
+
.set('Authorization', bearer(account))
|
|
65
|
+
.attach('file', Buffer.from('#!/bin/sh'), { filename: 'x.sh', contentType: 'text/x-sh' });
|
|
66
|
+
|
|
67
|
+
expect(res.status).toBe(400);
|
|
68
|
+
expect(uploadCloud).not.toHaveBeenCalled();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('rejects invalid data without uploading anything', async () => {
|
|
72
|
+
const res = await request(app)
|
|
73
|
+
.patch('/api/profile')
|
|
74
|
+
.set('Authorization', bearer(account))
|
|
75
|
+
.field('data', JSON.stringify({ fullName: '' }))
|
|
76
|
+
.attach('file', PNG, { filename: 'me.png', contentType: 'image/png' });
|
|
77
|
+
|
|
78
|
+
expect(res.status).toBe(400);
|
|
79
|
+
expect(uploadCloud).not.toHaveBeenCalled();
|
|
80
|
+
expect(prismaMock.profile.update).not.toHaveBeenCalled();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('rejects malformed JSON in `data`', async () => {
|
|
84
|
+
const res = await request(app)
|
|
85
|
+
.patch('/api/profile')
|
|
86
|
+
.set('Authorization', bearer(account))
|
|
87
|
+
.field('data', '{not json');
|
|
88
|
+
|
|
89
|
+
expect(res.status).toBe(400);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import passport, { type Profile } from 'passport';
|
|
3
|
+
import request from 'supertest';
|
|
4
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
5
|
+
import app from '../../src/app.js';
|
|
6
|
+
import { hashLoginCode } from '../../src/app/passport-js/social-account.js';
|
|
7
|
+
import { makeAccount } from '../helpers/fixtures.js';
|
|
8
|
+
import { prismaMock } from '../helpers/prisma.js';
|
|
9
|
+
|
|
10
|
+
const FRONT_END_CALLBACK = 'http://localhost:5173/auth/social/callback';
|
|
11
|
+
|
|
12
|
+
type OAuth2Internals = {
|
|
13
|
+
_oauth2: { getOAuthAccessToken: (...args: unknown[]) => void };
|
|
14
|
+
userProfile: (token: string, done: (err: unknown, profile?: Profile) => void) => void;
|
|
15
|
+
};
|
|
16
|
+
const strategy = (name: string) =>
|
|
17
|
+
(passport as unknown as { _strategy: (n: string) => OAuth2Internals })._strategy(name);
|
|
18
|
+
|
|
19
|
+
const googleProfile = (overrides: Partial<Profile> = {}): Profile => ({
|
|
20
|
+
provider: 'google',
|
|
21
|
+
id: 'google-123',
|
|
22
|
+
displayName: 'Jane Doe',
|
|
23
|
+
emails: [{ value: 'Jane@Example.com', verified: true } as never],
|
|
24
|
+
photos: [{ value: 'https://photos.test/jane.png' }],
|
|
25
|
+
...overrides,
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
/** Stubs the provider's HTTP calls (code -> token, token -> profile). */
|
|
29
|
+
const stubGoogle = (profile: Profile) => {
|
|
30
|
+
const google = strategy('google');
|
|
31
|
+
const tokenCall = vi
|
|
32
|
+
.spyOn(google._oauth2, 'getOAuthAccessToken')
|
|
33
|
+
.mockImplementation((...args: unknown[]) => {
|
|
34
|
+
const cb = args[2] as (err: unknown, access: string, refresh: string, params: object) => void;
|
|
35
|
+
cb(null, 'access-token', 'refresh-token', {});
|
|
36
|
+
});
|
|
37
|
+
vi.spyOn(google, 'userProfile').mockImplementation((_token, done) => done(null, profile));
|
|
38
|
+
return tokenCall;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/** Starts a login and returns what the browser would carry back to the callback. */
|
|
42
|
+
const startGoogleLogin = async (query = '') => {
|
|
43
|
+
const res = await request(app).get(`/api/auth/social/google${query}`);
|
|
44
|
+
const location = new URL(res.headers.location);
|
|
45
|
+
const cookie = ([] as string[]).concat(res.headers['set-cookie'] ?? [])[0]!.split(';')[0]!;
|
|
46
|
+
return { res, location, cookie, state: location.searchParams.get('state')! };
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const finishGoogleLogin = (state: string, cookie: string) =>
|
|
50
|
+
request(app)
|
|
51
|
+
.get(`/api/auth/social/google/callback?code=auth-code&state=${state}`)
|
|
52
|
+
.set('Cookie', cookie);
|
|
53
|
+
|
|
54
|
+
const redirectParams = (res: request.Response) => {
|
|
55
|
+
const url = new URL(res.headers.location);
|
|
56
|
+
expect(`${url.origin}${url.pathname}`).toBe(FRONT_END_CALLBACK);
|
|
57
|
+
return url.searchParams;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
describe('GET /api/auth/social/providers', () => {
|
|
61
|
+
it('lists only the enabled providers', async () => {
|
|
62
|
+
const res = await request(app).get('/api/auth/social/providers');
|
|
63
|
+
|
|
64
|
+
expect(res.status).toBe(200);
|
|
65
|
+
expect(res.body.data.map((p: { name: string }) => p.name)).toEqual([
|
|
66
|
+
'google',
|
|
67
|
+
'github',
|
|
68
|
+
'twitter',
|
|
69
|
+
]);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('has no routes for disabled providers', async () => {
|
|
73
|
+
const res = await request(app).get('/api/auth/social/facebook');
|
|
74
|
+
expect(res.status).toBe(404);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe('GET /api/auth/social/<provider>', () => {
|
|
79
|
+
it('redirects to the provider with state and PKCE, keeping them in an httpOnly cookie', async () => {
|
|
80
|
+
const { res, location, cookie } = await startGoogleLogin();
|
|
81
|
+
|
|
82
|
+
expect(res.status).toBe(302);
|
|
83
|
+
expect(location.host).toBe('accounts.google.com');
|
|
84
|
+
expect(location.searchParams.get('state')).toBeTruthy();
|
|
85
|
+
expect(location.searchParams.get('code_challenge_method')).toBe('S256');
|
|
86
|
+
expect(location.searchParams.get('scope')).toBe('openid email profile');
|
|
87
|
+
expect(cookie).toMatch(/^oauth_google=/);
|
|
88
|
+
expect(res.headers['set-cookie']![0]).toMatch(/HttpOnly/);
|
|
89
|
+
// the cookie is encrypted: neither the state nor the verifier are readable
|
|
90
|
+
expect(cookie).not.toContain(location.searchParams.get('state'));
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('uses state (no PKCE) for GitHub', async () => {
|
|
94
|
+
const res = await request(app).get('/api/auth/social/github');
|
|
95
|
+
const location = new URL(res.headers.location);
|
|
96
|
+
|
|
97
|
+
expect(location.host).toBe('github.com');
|
|
98
|
+
expect(location.searchParams.get('state')).toBeTruthy();
|
|
99
|
+
expect(location.searchParams.get('code_challenge')).toBeNull();
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe('GET /api/auth/social/<provider>/callback', () => {
|
|
104
|
+
beforeEach(() => {
|
|
105
|
+
prismaMock.socialLoginCode.create.mockResolvedValue({});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('creates an account on first login and redirects with a single-use code', async () => {
|
|
109
|
+
const tokenCall = stubGoogle(googleProfile());
|
|
110
|
+
prismaMock.socialAccount.findUnique.mockResolvedValue(null);
|
|
111
|
+
prismaMock.account.findUnique.mockResolvedValue(null);
|
|
112
|
+
prismaMock.account.create.mockResolvedValue(makeAccount({ id: 'new-acc', password: null }));
|
|
113
|
+
|
|
114
|
+
const { location, cookie, state } = await startGoogleLogin('?returnTo=/dashboard');
|
|
115
|
+
const res = await finishGoogleLogin(state, cookie);
|
|
116
|
+
|
|
117
|
+
expect(res.status).toBe(302);
|
|
118
|
+
const params = redirectParams(res);
|
|
119
|
+
expect(params.get('returnTo')).toBe('/dashboard');
|
|
120
|
+
|
|
121
|
+
// the code in the URL is stored only as a hash, for the new account
|
|
122
|
+
const code = params.get('code')!;
|
|
123
|
+
expect(prismaMock.socialLoginCode.create.mock.calls[0]![0].data).toMatchObject({
|
|
124
|
+
codeHash: hashLoginCode(code),
|
|
125
|
+
accountId: 'new-acc',
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// PKCE: the verifier sent with the code matches the challenge from the start
|
|
129
|
+
const verifier = (tokenCall.mock.calls[0]![1] as { code_verifier: string }).code_verifier;
|
|
130
|
+
expect(createHash('sha256').update(verifier).digest('base64url')).toBe(
|
|
131
|
+
location.searchParams.get('code_challenge'),
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
const { data } = prismaMock.account.create.mock.calls[0]![0];
|
|
135
|
+
expect(data).toMatchObject({
|
|
136
|
+
email: 'jane@example.com',
|
|
137
|
+
password: null,
|
|
138
|
+
provider: 'GOOGLE',
|
|
139
|
+
isAccountVerified: true,
|
|
140
|
+
socialAccounts: { create: { provider: 'GOOGLE', providerUserId: 'google-123' } },
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('logs a linked identity into its account, even if the email changed', async () => {
|
|
145
|
+
stubGoogle(googleProfile({ emails: [] }));
|
|
146
|
+
prismaMock.socialAccount.findUnique.mockResolvedValue({ account: makeAccount() });
|
|
147
|
+
|
|
148
|
+
const { cookie, state } = await startGoogleLogin();
|
|
149
|
+
const res = await finishGoogleLogin(state, cookie);
|
|
150
|
+
|
|
151
|
+
expect(redirectParams(res).get('code')).toBeTruthy();
|
|
152
|
+
expect(prismaMock.account.create).not.toHaveBeenCalled();
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('links to an existing unverified account and drops its unproven password', async () => {
|
|
156
|
+
stubGoogle(googleProfile());
|
|
157
|
+
prismaMock.socialAccount.findUnique.mockResolvedValue(null);
|
|
158
|
+
prismaMock.account.findUnique.mockResolvedValue(makeAccount({ isAccountVerified: false }));
|
|
159
|
+
prismaMock.account.update.mockResolvedValue(makeAccount({ password: null }));
|
|
160
|
+
|
|
161
|
+
const { cookie, state } = await startGoogleLogin();
|
|
162
|
+
await finishGoogleLogin(state, cookie);
|
|
163
|
+
|
|
164
|
+
expect(prismaMock.account.update.mock.calls[0]![0].data).toMatchObject({
|
|
165
|
+
isAccountVerified: true,
|
|
166
|
+
password: null,
|
|
167
|
+
socialAccounts: { create: { provider: 'GOOGLE', providerUserId: 'google-123' } },
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('refuses unverified provider emails', async () => {
|
|
172
|
+
stubGoogle(
|
|
173
|
+
googleProfile({ emails: [{ value: 'jane@example.com', verified: false } as never] }),
|
|
174
|
+
);
|
|
175
|
+
prismaMock.socialAccount.findUnique.mockResolvedValue(null);
|
|
176
|
+
|
|
177
|
+
const { cookie, state } = await startGoogleLogin();
|
|
178
|
+
const res = await finishGoogleLogin(state, cookie);
|
|
179
|
+
|
|
180
|
+
expect(redirectParams(res).get('error')).toBe('email_unverified');
|
|
181
|
+
expect(prismaMock.account.create).not.toHaveBeenCalled();
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('refuses deleted accounts', async () => {
|
|
185
|
+
stubGoogle(googleProfile());
|
|
186
|
+
prismaMock.socialAccount.findUnique.mockResolvedValue({
|
|
187
|
+
account: makeAccount({ isDeleted: true }),
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
const { cookie, state } = await startGoogleLogin();
|
|
191
|
+
const res = await finishGoogleLogin(state, cookie);
|
|
192
|
+
|
|
193
|
+
expect(redirectParams(res).get('error')).toBe('account_disabled');
|
|
194
|
+
expect(prismaMock.socialLoginCode.create).not.toHaveBeenCalled();
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('rejects a callback whose state does not match (login CSRF)', async () => {
|
|
198
|
+
const tokenCall = stubGoogle(googleProfile());
|
|
199
|
+
const { cookie } = await startGoogleLogin();
|
|
200
|
+
|
|
201
|
+
const res = await finishGoogleLogin('attacker-state', cookie);
|
|
202
|
+
|
|
203
|
+
expect(redirectParams(res).get('error')).toBe('invalid_state');
|
|
204
|
+
expect(tokenCall).not.toHaveBeenCalled();
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('rejects a callback without the state cookie', async () => {
|
|
208
|
+
const tokenCall = stubGoogle(googleProfile());
|
|
209
|
+
const { state } = await startGoogleLogin();
|
|
210
|
+
|
|
211
|
+
const res = await request(app).get(
|
|
212
|
+
`/api/auth/social/google/callback?code=auth-code&state=${state}`,
|
|
213
|
+
);
|
|
214
|
+
|
|
215
|
+
expect(redirectParams(res).get('error')).toBe('invalid_state');
|
|
216
|
+
expect(tokenCall).not.toHaveBeenCalled();
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it('reports when the user cancels at the provider', async () => {
|
|
220
|
+
const res = await request(app).get('/api/auth/social/google/callback?error=access_denied');
|
|
221
|
+
|
|
222
|
+
expect(redirectParams(res).get('error')).toBe('access_denied');
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it('never redirects to another site through returnTo', async () => {
|
|
226
|
+
stubGoogle(googleProfile());
|
|
227
|
+
prismaMock.socialAccount.findUnique.mockResolvedValue({ account: makeAccount() });
|
|
228
|
+
|
|
229
|
+
const { cookie, state } = await startGoogleLogin(
|
|
230
|
+
`?returnTo=${encodeURIComponent('//evil.com')}`,
|
|
231
|
+
);
|
|
232
|
+
const res = await finishGoogleLogin(state, cookie);
|
|
233
|
+
|
|
234
|
+
const params = redirectParams(res);
|
|
235
|
+
expect(params.get('code')).toBeTruthy();
|
|
236
|
+
expect(params.get('returnTo')).toBeNull();
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
describe('X (Twitter) login, OAuth 1.0a', () => {
|
|
241
|
+
type OAuth1Internals = {
|
|
242
|
+
_oauth: {
|
|
243
|
+
getOAuthRequestToken: (...args: unknown[]) => void;
|
|
244
|
+
getOAuthAccessToken: (...args: unknown[]) => void;
|
|
245
|
+
};
|
|
246
|
+
userProfile: (...args: unknown[]) => void;
|
|
247
|
+
};
|
|
248
|
+
const twitter = () =>
|
|
249
|
+
(passport as unknown as { _strategy: (n: string) => OAuth1Internals })._strategy('twitter');
|
|
250
|
+
|
|
251
|
+
it('keeps the request token in an encrypted cookie and completes the login', async () => {
|
|
252
|
+
const t = twitter();
|
|
253
|
+
vi.spyOn(t._oauth, 'getOAuthRequestToken').mockImplementation((...args: unknown[]) => {
|
|
254
|
+
(args.at(-1) as (...a: unknown[]) => void)(null, 'req-token', 'req-secret', {});
|
|
255
|
+
});
|
|
256
|
+
const accessCall = vi
|
|
257
|
+
.spyOn(t._oauth, 'getOAuthAccessToken')
|
|
258
|
+
.mockImplementation((...args: unknown[]) => {
|
|
259
|
+
(args.at(-1) as (...a: unknown[]) => void)(null, 'access', 'access-secret', {});
|
|
260
|
+
});
|
|
261
|
+
vi.spyOn(t, 'userProfile').mockImplementation((...args: unknown[]) => {
|
|
262
|
+
(args.at(-1) as (...a: unknown[]) => void)(null, {
|
|
263
|
+
provider: 'twitter',
|
|
264
|
+
id: 'x-1',
|
|
265
|
+
displayName: 'Jane',
|
|
266
|
+
emails: [{ value: 'jane@example.com' }],
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
prismaMock.socialAccount.findUnique.mockResolvedValue({ account: makeAccount() });
|
|
270
|
+
prismaMock.socialLoginCode.create.mockResolvedValue({});
|
|
271
|
+
|
|
272
|
+
const start = await request(app).get('/api/auth/social/twitter?returnTo=/home');
|
|
273
|
+
expect(start.status).toBe(302);
|
|
274
|
+
const location = new URL(start.headers.location);
|
|
275
|
+
expect(`${location.origin}${location.pathname}`).toBe(
|
|
276
|
+
'https://api.twitter.com/oauth/authenticate',
|
|
277
|
+
);
|
|
278
|
+
expect(location.searchParams.get('oauth_token')).toBe('req-token');
|
|
279
|
+
const cookie = ([] as string[]).concat(start.headers['set-cookie'] ?? [])[0]!.split(';')[0]!;
|
|
280
|
+
expect(cookie).toMatch(/^oauth_twitter=/);
|
|
281
|
+
expect(cookie).not.toContain('req-secret');
|
|
282
|
+
|
|
283
|
+
const res = await request(app)
|
|
284
|
+
.get('/api/auth/social/twitter/callback?oauth_token=req-token&oauth_verifier=v')
|
|
285
|
+
.set('Cookie', cookie);
|
|
286
|
+
|
|
287
|
+
// the token secret from the cookie was used to get the access token
|
|
288
|
+
expect(accessCall.mock.calls[0]!.slice(0, 3)).toEqual(['req-token', 'req-secret', 'v']);
|
|
289
|
+
const params = redirectParams(res);
|
|
290
|
+
expect(params.get('code')).toBeTruthy();
|
|
291
|
+
expect(params.get('returnTo')).toBe('/home');
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it('rejects a callback whose token does not match the one in the cookie', async () => {
|
|
295
|
+
vi.spyOn(twitter()._oauth, 'getOAuthRequestToken').mockImplementation((...args: unknown[]) => {
|
|
296
|
+
(args.at(-1) as (...a: unknown[]) => void)(null, 'req-token', 'req-secret', {});
|
|
297
|
+
});
|
|
298
|
+
const accessCall = vi.spyOn(twitter()._oauth, 'getOAuthAccessToken');
|
|
299
|
+
const start = await request(app).get('/api/auth/social/twitter');
|
|
300
|
+
const cookie = ([] as string[]).concat(start.headers['set-cookie'] ?? [])[0]!.split(';')[0]!;
|
|
301
|
+
|
|
302
|
+
const res = await request(app)
|
|
303
|
+
.get('/api/auth/social/twitter/callback?oauth_token=other-token&oauth_verifier=v')
|
|
304
|
+
.set('Cookie', cookie);
|
|
305
|
+
|
|
306
|
+
expect(redirectParams(res).get('error')).toBe('invalid_state');
|
|
307
|
+
expect(accessCall).not.toHaveBeenCalled();
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it('rejects a callback for a request token it did not issue', async () => {
|
|
311
|
+
const res = await request(app).get(
|
|
312
|
+
'/api/auth/social/twitter/callback?oauth_token=forged&oauth_verifier=v',
|
|
313
|
+
);
|
|
314
|
+
|
|
315
|
+
expect(redirectParams(res).get('error')).toBe('invalid_state');
|
|
316
|
+
});
|
|
317
|
+
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
// external boundaries only: everything between the route and these runs for real
|
|
4
|
+
vi.mock('../src/app/lib/prisma.js', async () => ({
|
|
5
|
+
prisma: (await import('./helpers/prisma.js')).prismaMock,
|
|
6
|
+
}));
|
|
7
|
+
|
|
8
|
+
vi.mock('../src/app/queues/email.queue.js', () => ({
|
|
9
|
+
enqueueEmail: vi.fn(),
|
|
10
|
+
}));
|
|
@@ -1,23 +1,16 @@
|
|
|
1
|
-
{
|
|
2
|
-
"compilerOptions": {
|
|
3
|
-
"target": "ES2023",
|
|
4
|
-
"module": "
|
|
5
|
-
"moduleResolution": "
|
|
6
|
-
"rootDir": "./",
|
|
7
|
-
"outDir": "./dist",
|
|
8
|
-
"esModuleInterop": true,
|
|
9
|
-
"forceConsistentCasingInFileNames": true,
|
|
10
|
-
"
|
|
11
|
-
"
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
"exclude": [
|
|
18
|
-
"node_modules",
|
|
19
|
-
"dist",
|
|
20
|
-
"build",
|
|
21
|
-
"generated/prisma"
|
|
22
|
-
]
|
|
23
|
-
}
|
|
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,38 @@
|
|
|
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
|
+
setupFiles: ['./tests/setup.ts'],
|
|
9
|
+
// every test starts with fresh mocks
|
|
10
|
+
mockReset: true,
|
|
11
|
+
// set before the app reads its config; values in .env don't override these
|
|
12
|
+
env: {
|
|
13
|
+
NODE_ENV: 'test',
|
|
14
|
+
DATABASE_URL: 'postgresql://test:test@localhost:5432/test',
|
|
15
|
+
ACCESS_TOKEN: secret('access'),
|
|
16
|
+
VERIFIED_TOKEN: secret('verify'),
|
|
17
|
+
RESET_SECRET: secret('reset'),
|
|
18
|
+
FRONT_END_URL: 'http://localhost:5173',
|
|
19
|
+
CORS_ORIGINS: 'http://localhost:5173',
|
|
20
|
+
ENABLE_SWAGGER: 'false',
|
|
21
|
+
IS_GOOGLE_LOGIN: 'true',
|
|
22
|
+
GOOGLE_CLIENT_ID: 'google-client-id',
|
|
23
|
+
GOOGLE_CLIENT_SECRET: 'google-client-secret',
|
|
24
|
+
GOOGLE_CALLBACK_URL: 'http://localhost:5000/api/auth/social/google/callback',
|
|
25
|
+
IS_GITHUB_LOGIN: 'true',
|
|
26
|
+
GITHUB_CLIENT_ID: 'github-client-id',
|
|
27
|
+
GITHUB_CLIENT_SECRET: 'github-client-secret',
|
|
28
|
+
GITHUB_CALLBACK_URL: 'http://localhost:5000/api/auth/social/github/callback',
|
|
29
|
+
IS_FACEBOOK_LOGIN: 'false',
|
|
30
|
+
IS_TWITTER_LOGIN: 'true',
|
|
31
|
+
TWITTER_CONSUMER_KEY: 'twitter-key',
|
|
32
|
+
TWITTER_CONSUMER_SECRET: 'twitter-secret',
|
|
33
|
+
TWITTER_CALLBACK_URL: 'http://localhost:5000/api/auth/social/twitter/callback',
|
|
34
|
+
IS_LINKEDIN_LOGIN: 'false',
|
|
35
|
+
IS_DISCORD_LOGIN: 'false',
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
});
|
package/bin/cli.js
DELETED
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
import chalk from "chalk";
|
|
4
|
-
import { createProject } from "../src/createProject.js";
|
|
5
|
-
import { generateModule } from "../src/generateModule.js";
|
|
6
|
-
import { validateModuleName } from "../src/utils/validateModuleName.js";
|
|
7
|
-
import { validateProjectName } from "../src/utils/validateProjectName.js";
|
|
8
|
-
|
|
9
|
-
const args = process.argv.slice(2);
|
|
10
|
-
|
|
11
|
-
if (!args.length) {
|
|
12
|
-
console.log(chalk.yellow("Usage:"));
|
|
13
|
-
console.log(" npx express-server -c <project-name>");
|
|
14
|
-
console.log(" npx express-server -g <module-name>");
|
|
15
|
-
process.exit(0);
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
const command = args[0];
|
|
19
|
-
|
|
20
|
-
if (command === "-c") {
|
|
21
|
-
const projectName = validateProjectName(args[1]);
|
|
22
|
-
|
|
23
|
-
if (!projectName) {
|
|
24
|
-
console.log(chalk.red("❌ Project name required"));
|
|
25
|
-
process.exit(1);
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
await createProject(projectName);
|
|
29
|
-
} else if (command === "-g") {
|
|
30
|
-
const moduleName = validateModuleName(args[1]);
|
|
31
|
-
|
|
32
|
-
if (!moduleName) {
|
|
33
|
-
console.log(chalk.red("❌ Module name required"));
|
|
34
|
-
process.exit(1);
|
|
35
|
-
}
|
|
36
|
-
await generateModule(moduleName);
|
|
37
|
-
}
|
|
38
|
-
else {
|
|
39
|
-
console.log(chalk.red("❌ Invalid command, use -c or -g"));
|
|
40
|
-
process.exit(1);
|
|
41
|
-
}
|
package/src/createProject.js
DELETED
|
@@ -1,61 +0,0 @@
|
|
|
1
|
-
import chalk from "chalk";
|
|
2
|
-
import fs from "fs";
|
|
3
|
-
import inquirer from "inquirer";
|
|
4
|
-
import ora from "ora";
|
|
5
|
-
import path from "path";
|
|
6
|
-
import { fileURLToPath } from "url";
|
|
7
|
-
|
|
8
|
-
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
9
|
-
|
|
10
|
-
export async function createProject(projectName) {
|
|
11
|
-
|
|
12
|
-
// ask user template
|
|
13
|
-
const answers = await inquirer.prompt([
|
|
14
|
-
{
|
|
15
|
-
type: "list",
|
|
16
|
-
name: "template",
|
|
17
|
-
message: "Select a project template:",
|
|
18
|
-
choices: [
|
|
19
|
-
{ name: "Mongoose (MongoDB)", value: "mongoose" },
|
|
20
|
-
{ name: "Prisma (PostgreSQL)", value: "prisma" },
|
|
21
|
-
],
|
|
22
|
-
},
|
|
23
|
-
]);
|
|
24
|
-
|
|
25
|
-
const template = answers.template;
|
|
26
|
-
|
|
27
|
-
const templateDir = path.join(
|
|
28
|
-
__dirname,
|
|
29
|
-
`../templates/${template}`
|
|
30
|
-
);
|
|
31
|
-
|
|
32
|
-
const targetDir = path.join(process.cwd(), projectName);
|
|
33
|
-
|
|
34
|
-
if (fs.existsSync(targetDir)) {
|
|
35
|
-
console.log(chalk.red("❌ Folder already exists"));
|
|
36
|
-
process.exit(1);
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
const spinner = ora(`Creating ${template} project...`).start();
|
|
40
|
-
|
|
41
|
-
try {
|
|
42
|
-
|
|
43
|
-
fs.cpSync(templateDir, targetDir, {
|
|
44
|
-
recursive: true,
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
spinner.succeed("Project created successfully");
|
|
48
|
-
|
|
49
|
-
console.log(chalk.green(`\nNext steps:`));
|
|
50
|
-
console.log(chalk.cyan(`cd ${projectName}`));
|
|
51
|
-
console.log(chalk.cyan(`npm install`));
|
|
52
|
-
if (template === "prisma") {
|
|
53
|
-
console.log(chalk.cyan(`docker compose up -d`));
|
|
54
|
-
}
|
|
55
|
-
console.log(chalk.cyan(`npm run dev`));
|
|
56
|
-
|
|
57
|
-
} catch (err) {
|
|
58
|
-
spinner.fail("Failed to create project");
|
|
59
|
-
console.error(err);
|
|
60
|
-
}
|
|
61
|
-
}
|