nodejs-quickstart-structure 2.1.2 → 2.2.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/CHANGELOG.md +11 -0
- package/README.md +12 -17
- package/bin/index.js +1 -0
- package/lib/generator.js +1 -1
- package/lib/modules/app-setup.js +16 -0
- package/lib/modules/auth-setup.js +46 -4
- package/lib/prompts.js +44 -4
- package/package.json +1 -1
- package/templates/clean-architecture/js/src/infrastructure/config/env.js.ejs +12 -2
- package/templates/clean-architecture/js/src/infrastructure/repositories/UserRepository.js.ejs +27 -0
- package/templates/clean-architecture/js/src/infrastructure/repositories/UserRepository.spec.js.ejs +24 -0
- package/templates/clean-architecture/js/src/infrastructure/webserver/server.js.ejs +3 -1
- package/templates/clean-architecture/ts/src/config/env.ts.ejs +12 -2
- package/templates/clean-architecture/ts/src/domain/user.ts.ejs +14 -0
- package/templates/clean-architecture/ts/src/infrastructure/repositories/UserRepository.spec.ts.ejs +24 -0
- package/templates/clean-architecture/ts/src/infrastructure/repositories/userRepository.ts.ejs +43 -45
- package/templates/clean-architecture/ts/src/interfaces/graphql/resolvers/user.resolvers.ts.ejs +5 -5
- package/templates/common/.env.example.ejs +10 -0
- package/templates/common/README.md.ejs +65 -14
- package/templates/common/auth/js/controllers/authController.js.ejs +326 -13
- package/templates/common/auth/js/controllers/authController.spec.js.ejs +237 -51
- package/templates/common/auth/js/middleware/authMiddleware.js.ejs +10 -6
- package/templates/common/auth/js/routes/authRoutes.js.ejs +11 -0
- package/templates/common/auth/js/services/jwtService.js.ejs +3 -3
- package/templates/common/auth/js/services/jwtService.spec.js.ejs +30 -0
- package/templates/common/auth/js/services/socialAuthService.js.ejs +175 -0
- package/templates/common/auth/js/services/socialAuthService.spec.js.ejs +194 -0
- package/templates/common/auth/js/usecases/SocialLoginUseCase.js.ejs +114 -0
- package/templates/common/auth/js/usecases/SocialLoginUseCase.spec.js.ejs +143 -0
- package/templates/common/auth/ts/controllers/authController.spec.ts.ejs +344 -64
- package/templates/common/auth/ts/controllers/authController.ts.ejs +341 -9
- package/templates/common/auth/ts/middleware/authMiddleware.ts.ejs +10 -6
- package/templates/common/auth/ts/routes/authRoutes.ts.ejs +11 -0
- package/templates/common/auth/ts/services/jwtService.spec.ts.ejs +18 -0
- package/templates/common/auth/ts/services/jwtService.ts.ejs +3 -3
- package/templates/common/auth/ts/services/socialAuthService.spec.ts.ejs +187 -0
- package/templates/common/auth/ts/services/socialAuthService.ts.ejs +189 -0
- package/templates/common/auth/ts/usecases/SocialLoginUseCase.spec.ts.ejs +143 -0
- package/templates/common/auth/ts/usecases/SocialLoginUseCase.ts.ejs +117 -0
- package/templates/common/database/js/models/User.js.ejs +13 -5
- package/templates/common/database/js/models/User.js.mongoose.ejs +15 -1
- package/templates/common/database/ts/models/User.ts.ejs +23 -7
- package/templates/common/database/ts/models/User.ts.mongoose.ejs +18 -2
- package/templates/common/docker-compose.yml.ejs +21 -0
- package/templates/common/ecosystem.config.js.ejs +10 -0
- package/templates/common/jest.config.js.ejs +1 -1
- package/templates/common/kafka/js/services/kafkaService.js.ejs +1 -1
- package/templates/common/kafka/ts/services/kafkaService.ts.ejs +1 -1
- package/templates/common/package.json.ejs +2 -0
- package/templates/common/src/tests/e2e/e2e.users.test.js.ejs +13 -1
- package/templates/common/src/tests/e2e/e2e.users.test.ts.ejs +13 -1
- package/templates/common/swagger.yml.ejs +62 -3
- package/templates/common/views/ejs/login.ejs.ejs +84 -0
- package/templates/common/views/ejs/signup.ejs.ejs +84 -0
- package/templates/common/views/pug/login.pug.ejs +78 -0
- package/templates/common/views/pug/signup.pug.ejs +78 -0
- package/templates/db/mysql/V1__Initial_Setup.sql.ejs +3 -1
- package/templates/db/postgres/V1__Initial_Setup.sql.ejs +3 -1
- package/templates/mvc/js/src/config/env.js.ejs +12 -2
- package/templates/mvc/js/src/controllers/userController.js.ejs +1 -1
- package/templates/mvc/js/src/graphql/resolvers/user.resolvers.js.ejs +4 -3
- package/templates/mvc/ts/src/config/env.ts.ejs +12 -2
- package/templates/mvc/ts/src/controllers/userController.ts.ejs +1 -0
- package/templates/mvc/ts/src/graphql/resolvers/user.resolvers.ts.ejs +5 -5
- package/templates/mvc/ts/src/index.ts.ejs +2 -1
- package/templates/clean-architecture/ts/src/domain/user.ts +0 -9
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import axios from 'axios';
|
|
2
|
+
<% if (architecture === 'MVC') { -%>
|
|
3
|
+
import { SocialAuthService } from '@/services/socialAuthService';
|
|
4
|
+
<% } else { -%>
|
|
5
|
+
import { GoogleProvider, GitHubProvider } from '@/infrastructure/auth/socialAuthService';
|
|
6
|
+
<% } -%>
|
|
7
|
+
|
|
8
|
+
jest.mock('axios');
|
|
9
|
+
jest.mock('<% if (architecture === "MVC") { %>@/utils/logger<% } else { %>@/infrastructure/log/logger<% } %>', () => ({
|
|
10
|
+
error: jest.fn(),
|
|
11
|
+
info: jest.fn(),
|
|
12
|
+
warn: jest.fn(),
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
// Mock environment variables for testing
|
|
16
|
+
process.env.GOOGLE_CLIENT_ID = 'test-google-id';
|
|
17
|
+
process.env.GOOGLE_CLIENT_SECRET = 'test-google-secret';
|
|
18
|
+
process.env.GITHUB_CLIENT_ID = 'test-github-id';
|
|
19
|
+
process.env.GITHUB_CLIENT_SECRET = 'test-github-secret';
|
|
20
|
+
|
|
21
|
+
describe('SocialAuthService', () => {
|
|
22
|
+
afterEach(() => {
|
|
23
|
+
jest.clearAllMocks();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
<% if (socialAuth.includes('Google')) { -%>
|
|
27
|
+
describe('Google Provider', () => {
|
|
28
|
+
it('should exchange code for profile', async () => {
|
|
29
|
+
const mockTokenResponse = { data: { access_token: 'mock_access_token' } };
|
|
30
|
+
const mockProfileResponse = {
|
|
31
|
+
data: {
|
|
32
|
+
id: '123',
|
|
33
|
+
email: 'test@gmail.com',
|
|
34
|
+
name: 'Test User',
|
|
35
|
+
picture: 'http://pic.com/123.jpg'
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
(axios.post as jest.Mock).mockResolvedValue(mockTokenResponse);
|
|
40
|
+
(axios.get as jest.Mock).mockResolvedValue(mockProfileResponse);
|
|
41
|
+
|
|
42
|
+
<%_ if (architecture === 'Clean Architecture') { _%>
|
|
43
|
+
const provider = new GoogleProvider();
|
|
44
|
+
const profile = await provider.getProfile('test_code', 'http://localhost/callback');
|
|
45
|
+
<%_ } else { _%>
|
|
46
|
+
const profile = await SocialAuthService.getGoogleProfile('test_code', 'http://localhost/callback');
|
|
47
|
+
<%_ } _%>
|
|
48
|
+
|
|
49
|
+
expect(axios.post).toHaveBeenCalled();
|
|
50
|
+
expect(axios.get).toHaveBeenCalledWith('https://www.googleapis.com/oauth2/v2/userinfo', {
|
|
51
|
+
headers: { Authorization: 'Bearer mock_access_token' },
|
|
52
|
+
});
|
|
53
|
+
expect(profile.email).toBe('test@gmail.com');
|
|
54
|
+
expect(profile.name).toBe('Test User');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('should throw error if token exchange fails', async () => {
|
|
58
|
+
(axios.post as jest.Mock).mockRejectedValue(new Error('Network error'));
|
|
59
|
+
<%_ if (architecture === 'Clean Architecture') { _%>
|
|
60
|
+
const provider = new GoogleProvider();
|
|
61
|
+
await expect(provider.getProfile('test_code', 'url')).rejects.toThrow('Failed to authenticate with Google');
|
|
62
|
+
<%_ } else { _%>
|
|
63
|
+
await expect(SocialAuthService.getGoogleProfile('test_code', 'url')).rejects.toThrow('Failed to authenticate with Google');
|
|
64
|
+
<%_ } _%>
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('should handle Axios errors', async () => {
|
|
68
|
+
const axiosError = new Error('Request failed');
|
|
69
|
+
(axiosError as any).isAxiosError = true;
|
|
70
|
+
(axiosError as any).response = { data: { message: 'OAuth Error' } };
|
|
71
|
+
(axios.isAxiosError as unknown as jest.Mock).mockReturnValue(true);
|
|
72
|
+
(axios.post as jest.Mock).mockRejectedValue(axiosError);
|
|
73
|
+
|
|
74
|
+
<%_ if (architecture === 'Clean Architecture') { _%>
|
|
75
|
+
const provider = new GoogleProvider();
|
|
76
|
+
await expect(provider.getProfile('test_code', 'url')).rejects.toThrow('Failed to authenticate with Google');
|
|
77
|
+
<%_ } else { _%>
|
|
78
|
+
await expect(SocialAuthService.getGoogleProfile('test_code', 'url')).rejects.toThrow('Failed to authenticate with Google');
|
|
79
|
+
<%_ } _%>
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('should handle invalid_grant hint in Google', async () => {
|
|
83
|
+
const axiosError = new Error('Request failed');
|
|
84
|
+
(axiosError as any).isAxiosError = true;
|
|
85
|
+
(axiosError as any).response = { data: { error: 'invalid_grant' } };
|
|
86
|
+
(axios.isAxiosError as unknown as jest.Mock).mockReturnValue(true);
|
|
87
|
+
(axios.post as jest.Mock).mockRejectedValue(axiosError);
|
|
88
|
+
|
|
89
|
+
<%_ if (architecture === 'Clean Architecture') { _%>
|
|
90
|
+
const provider = new GoogleProvider();
|
|
91
|
+
await expect(provider.getProfile('test_code', 'url')).rejects.toThrow('Failed to authenticate with Google');
|
|
92
|
+
<%_ } else { _%>
|
|
93
|
+
await expect(SocialAuthService.getGoogleProfile('test_code', 'url')).rejects.toThrow('Failed to authenticate with Google');
|
|
94
|
+
<%_ } _%>
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('should handle non-Axios errors', async () => {
|
|
98
|
+
(axios.isAxiosError as unknown as jest.Mock).mockReturnValue(false);
|
|
99
|
+
(axios.post as jest.Mock).mockRejectedValue(new Error('Unexpected error'));
|
|
100
|
+
|
|
101
|
+
<%_ if (architecture === 'Clean Architecture') { _%>
|
|
102
|
+
const provider = new GoogleProvider();
|
|
103
|
+
await expect(provider.getProfile('test_code', 'url')).rejects.toThrow('Failed to authenticate with Google');
|
|
104
|
+
<%_ } else { _%>
|
|
105
|
+
await expect(SocialAuthService.getGoogleProfile('test_code', 'url')).rejects.toThrow('Failed to authenticate with Google');
|
|
106
|
+
<%_ } _%>
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
<% } -%>
|
|
110
|
+
|
|
111
|
+
<% if (socialAuth.includes('GitHub')) { -%>
|
|
112
|
+
describe('GitHub Provider', () => {
|
|
113
|
+
it('should exchange code for profile', async () => {
|
|
114
|
+
const mockTokenResponse = { data: { access_token: 'mock_access_token' } };
|
|
115
|
+
const mockProfileResponse = {
|
|
116
|
+
data: { id: 456, login: 'testuser', name: 'Test Github User' }
|
|
117
|
+
};
|
|
118
|
+
const mockEmailsResponse = {
|
|
119
|
+
data: [{ email: 'github@test.com', primary: true }]
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
(axios.post as jest.Mock).mockResolvedValue(mockTokenResponse);
|
|
123
|
+
(axios.get as jest.Mock)
|
|
124
|
+
.mockResolvedValueOnce(mockProfileResponse)
|
|
125
|
+
.mockResolvedValueOnce(mockEmailsResponse);
|
|
126
|
+
|
|
127
|
+
<%_ if (architecture === 'Clean Architecture') { _%>
|
|
128
|
+
const provider = new GitHubProvider();
|
|
129
|
+
const profile = await provider.getProfile('test_code');
|
|
130
|
+
<%_ } else { _%>
|
|
131
|
+
const profile = await SocialAuthService.getGithubProfile('test_code');
|
|
132
|
+
<%_ } _%>
|
|
133
|
+
|
|
134
|
+
expect(axios.post).toHaveBeenCalled();
|
|
135
|
+
expect(profile.email).toBe('github@test.com');
|
|
136
|
+
expect(profile.id).toBe('456');
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('should throw error if token exchange fails', async () => {
|
|
140
|
+
(axios.post as jest.Mock).mockRejectedValue(new Error('Network error'));
|
|
141
|
+
<%_ if (architecture === 'Clean Architecture') { _%>
|
|
142
|
+
const provider = new GitHubProvider();
|
|
143
|
+
await expect(provider.getProfile('test_code')).rejects.toThrow('Failed to authenticate with GitHub');
|
|
144
|
+
<%_ } else { _%>
|
|
145
|
+
await expect(SocialAuthService.getGithubProfile('test_code')).rejects.toThrow('Failed to authenticate with GitHub');
|
|
146
|
+
<%_ } _%>
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('should handle Axios errors', async () => {
|
|
150
|
+
const axiosError = new Error('Request failed');
|
|
151
|
+
(axiosError as any).isAxiosError = true;
|
|
152
|
+
(axiosError as any).response = { data: { message: 'OAuth Error' } };
|
|
153
|
+
(axios.isAxiosError as unknown as jest.Mock).mockReturnValue(true);
|
|
154
|
+
(axios.post as jest.Mock).mockRejectedValue(axiosError);
|
|
155
|
+
|
|
156
|
+
<%_ if (architecture === 'Clean Architecture') { _%>
|
|
157
|
+
const provider = new GitHubProvider();
|
|
158
|
+
await expect(provider.getProfile('test_code')).rejects.toThrow('Failed to authenticate with GitHub');
|
|
159
|
+
<%_ } else { _%>
|
|
160
|
+
await expect(SocialAuthService.getGithubProfile('test_code')).rejects.toThrow('Failed to authenticate with GitHub');
|
|
161
|
+
<%_ } _%>
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('should throw error if access_token is missing from GitHub', async () => {
|
|
165
|
+
(axios.post as jest.Mock).mockResolvedValue({ data: {} });
|
|
166
|
+
<%_ if (architecture === 'Clean Architecture') { _%>
|
|
167
|
+
const provider = new GitHubProvider();
|
|
168
|
+
await expect(provider.getProfile('test_code')).rejects.toThrow('Failed to authenticate with GitHub');
|
|
169
|
+
<%_ } else { _%>
|
|
170
|
+
await expect(SocialAuthService.getGithubProfile('test_code')).rejects.toThrow('Failed to authenticate with GitHub');
|
|
171
|
+
<%_ } _%>
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('should handle non-Axios errors in GitHub', async () => {
|
|
175
|
+
(axios.isAxiosError as unknown as jest.Mock).mockReturnValue(false);
|
|
176
|
+
(axios.post as jest.Mock).mockRejectedValue(new Error('Unexpected error'));
|
|
177
|
+
|
|
178
|
+
<%_ if (architecture === 'Clean Architecture') { _%>
|
|
179
|
+
const provider = new GitHubProvider();
|
|
180
|
+
await expect(provider.getProfile('test_code')).rejects.toThrow('Failed to authenticate with GitHub');
|
|
181
|
+
<%_ } else { _%>
|
|
182
|
+
await expect(SocialAuthService.getGithubProfile('test_code')).rejects.toThrow('Failed to authenticate with GitHub');
|
|
183
|
+
<%_ } _%>
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
<% } -%>
|
|
187
|
+
});
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
<% if (architecture === 'MVC') { -%>
|
|
2
|
+
import logger from '@/utils/logger';
|
|
3
|
+
<% } else { -%>
|
|
4
|
+
import logger from '@/infrastructure/log/logger';
|
|
5
|
+
<% } -%>
|
|
6
|
+
import axios from 'axios';
|
|
7
|
+
|
|
8
|
+
<% if (architecture === 'Clean Architecture') { -%>
|
|
9
|
+
export interface ISocialProfile {
|
|
10
|
+
id: string;
|
|
11
|
+
email: string;
|
|
12
|
+
name: string;
|
|
13
|
+
picture?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface ISocialProvider {
|
|
17
|
+
name: string;
|
|
18
|
+
getProfile(code: string, redirectUri?: string): Promise<ISocialProfile>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
<% if (socialAuth.includes('Google')) { -%>
|
|
22
|
+
export class GoogleProvider implements ISocialProvider {
|
|
23
|
+
name = 'Google';
|
|
24
|
+
async getProfile(code: string, redirectUri: string): Promise<ISocialProfile> {
|
|
25
|
+
try {
|
|
26
|
+
const params = new URLSearchParams();
|
|
27
|
+
params.append('code', code);
|
|
28
|
+
params.append('client_id', process.env.GOOGLE_CLIENT_ID!);
|
|
29
|
+
params.append('client_secret', process.env.GOOGLE_CLIENT_SECRET!);
|
|
30
|
+
params.append('redirect_uri', redirectUri);
|
|
31
|
+
params.append('grant_type', 'authorization_code');
|
|
32
|
+
|
|
33
|
+
const tokenResponse = await axios.post('https://oauth2.googleapis.com/token', params.toString(), {
|
|
34
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const { access_token } = tokenResponse.data;
|
|
38
|
+
const profileResponse = await axios.get('https://www.googleapis.com/oauth2/v2/userinfo', {
|
|
39
|
+
headers: { Authorization: `Bearer ${access_token}` },
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
id: profileResponse.data.id,
|
|
44
|
+
email: profileResponse.data.email,
|
|
45
|
+
name: profileResponse.data.name,
|
|
46
|
+
picture: profileResponse.data.picture
|
|
47
|
+
};
|
|
48
|
+
} catch (error: unknown) {
|
|
49
|
+
if (axios.isAxiosError(error)) {
|
|
50
|
+
logger.error('Google OAuth error:', error.response?.data || error.message);
|
|
51
|
+
} else {
|
|
52
|
+
logger.error('Google OAuth error:', (error as Error).message);
|
|
53
|
+
}
|
|
54
|
+
throw new Error('Failed to authenticate with Google');
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
<% } -%>
|
|
59
|
+
|
|
60
|
+
<% if (socialAuth.includes('GitHub')) { -%>
|
|
61
|
+
export class GitHubProvider implements ISocialProvider {
|
|
62
|
+
name = 'GitHub';
|
|
63
|
+
async getProfile(code: string): Promise<ISocialProfile> {
|
|
64
|
+
try {
|
|
65
|
+
const tokenResponse = await axios.post(
|
|
66
|
+
'https://github.com/login/oauth/access_token',
|
|
67
|
+
{
|
|
68
|
+
client_id: process.env.GITHUB_CLIENT_ID,
|
|
69
|
+
client_secret: process.env.GITHUB_CLIENT_SECRET,
|
|
70
|
+
code,
|
|
71
|
+
},
|
|
72
|
+
{ headers: { Accept: 'application/json' } }
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
const { access_token } = tokenResponse.data;
|
|
76
|
+
if (!access_token) throw new Error('No access token returned from GitHub');
|
|
77
|
+
|
|
78
|
+
const [profileRes, emailsRes] = await Promise.all([
|
|
79
|
+
axios.get('https://api.github.com/user', { headers: { Authorization: `Bearer ${access_token}` } }),
|
|
80
|
+
axios.get('https://api.github.com/user/emails', { headers: { Authorization: `Bearer ${access_token}` } })
|
|
81
|
+
]);
|
|
82
|
+
|
|
83
|
+
const email = emailsRes.data.find((e: { primary: boolean; email: string }) => e.primary)?.email || emailsRes.data[0]?.email;
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
id: profileRes.data.id.toString(),
|
|
87
|
+
email,
|
|
88
|
+
name: profileRes.data.name || profileRes.data.login,
|
|
89
|
+
};
|
|
90
|
+
} catch (error: unknown) {
|
|
91
|
+
if (axios.isAxiosError(error)) {
|
|
92
|
+
logger.error('GitHub OAuth error:', error.response?.data || error.message);
|
|
93
|
+
} else {
|
|
94
|
+
logger.error('GitHub OAuth error:', (error as Error).message);
|
|
95
|
+
}
|
|
96
|
+
throw new Error('Failed to authenticate with GitHub');
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
<% } -%>
|
|
101
|
+
|
|
102
|
+
<% } else { -%>
|
|
103
|
+
export class SocialAuthService {
|
|
104
|
+
<% if (socialAuth.includes('Google')) { -%>
|
|
105
|
+
static async getGoogleProfile(code: string, redirectUri: string) {
|
|
106
|
+
try {
|
|
107
|
+
const params = new URLSearchParams();
|
|
108
|
+
params.append('code', code);
|
|
109
|
+
params.append('client_id', process.env.GOOGLE_CLIENT_ID!);
|
|
110
|
+
params.append('client_secret', process.env.GOOGLE_CLIENT_SECRET!);
|
|
111
|
+
params.append('redirect_uri', redirectUri);
|
|
112
|
+
params.append('grant_type', 'authorization_code');
|
|
113
|
+
|
|
114
|
+
const tokenResponse = await axios.post('https://oauth2.googleapis.com/token', params.toString(), {
|
|
115
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
const { access_token } = tokenResponse.data;
|
|
119
|
+
|
|
120
|
+
const profileResponse = await axios.get('https://www.googleapis.com/oauth2/v2/userinfo', {
|
|
121
|
+
headers: { Authorization: `Bearer ${access_token}` },
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
id: profileResponse.data.id,
|
|
126
|
+
email: profileResponse.data.email,
|
|
127
|
+
name: profileResponse.data.name,
|
|
128
|
+
picture: profileResponse.data.picture
|
|
129
|
+
};
|
|
130
|
+
} catch (error: unknown) {
|
|
131
|
+
if (axios.isAxiosError(error)) {
|
|
132
|
+
const detail = error.response?.data;
|
|
133
|
+
logger.error('Google OAuth error:', detail || error.message);
|
|
134
|
+
if (detail?.error === 'invalid_grant') {
|
|
135
|
+
logger.error('HINT: The code is likely expired or already used. Get a new one!');
|
|
136
|
+
}
|
|
137
|
+
} else {
|
|
138
|
+
logger.error('Google OAuth error:', (error as Error).message);
|
|
139
|
+
}
|
|
140
|
+
throw new Error('Failed to authenticate with Google');
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
<% } -%>
|
|
144
|
+
|
|
145
|
+
<% if (socialAuth.includes('GitHub')) { -%>
|
|
146
|
+
static async getGithubProfile(code: string) {
|
|
147
|
+
try {
|
|
148
|
+
const tokenResponse = await axios.post(
|
|
149
|
+
'https://github.com/login/oauth/access_token',
|
|
150
|
+
{
|
|
151
|
+
client_id: process.env.GITHUB_CLIENT_ID,
|
|
152
|
+
client_secret: process.env.GITHUB_CLIENT_SECRET,
|
|
153
|
+
code,
|
|
154
|
+
},
|
|
155
|
+
{ headers: { Accept: 'application/json' } }
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
const { access_token } = tokenResponse.data;
|
|
159
|
+
if (!access_token) {
|
|
160
|
+
throw new Error('No access token returned from GitHub');
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const profileResponse = await axios.get('https://api.github.com/user', {
|
|
164
|
+
headers: { Authorization: `Bearer ${access_token}` },
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
const emailsResponse = await axios.get('https://api.github.com/user/emails', {
|
|
168
|
+
headers: { Authorization: `Bearer ${access_token}` },
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
const primaryEmail = emailsResponse.data.find((e: { primary: boolean; email: string }) => e.primary)?.email || emailsResponse.data[0]?.email;
|
|
172
|
+
|
|
173
|
+
return {
|
|
174
|
+
id: profileResponse.data.id.toString(),
|
|
175
|
+
email: primaryEmail,
|
|
176
|
+
name: profileResponse.data.name || profileResponse.data.login,
|
|
177
|
+
};
|
|
178
|
+
} catch (error: unknown) {
|
|
179
|
+
if (axios.isAxiosError(error)) {
|
|
180
|
+
logger.error('GitHub OAuth error:', error.response?.data || error.message);
|
|
181
|
+
} else {
|
|
182
|
+
logger.error('GitHub OAuth error:', (error as Error).message);
|
|
183
|
+
}
|
|
184
|
+
throw new Error('Failed to authenticate with GitHub');
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
<% } -%>
|
|
188
|
+
}
|
|
189
|
+
<% } -%>
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
jest.mock('<%= (architecture === "MVC") ? "@/services/jwtService" : "@/infrastructure/auth/jwtService" %>');
|
|
2
|
+
<%_ if (architecture === 'Clean Architecture') { _%>
|
|
3
|
+
jest.mock('@/infrastructure/repositories/UserRepository', () => ({
|
|
4
|
+
UserRepository: jest.fn().mockImplementation(() => ({
|
|
5
|
+
findByEmail: jest.fn(),
|
|
6
|
+
save: jest.fn(),
|
|
7
|
+
update: jest.fn(),
|
|
8
|
+
})),
|
|
9
|
+
}));
|
|
10
|
+
import { UserRepository } from '@/infrastructure/repositories/UserRepository';
|
|
11
|
+
<%_ } else { _%>
|
|
12
|
+
jest.mock('<%= (architecture === "MVC") ? "@/models/User" : "@/infrastructure/database/models/User" %>', () => ({
|
|
13
|
+
findOne: jest.fn(),
|
|
14
|
+
create: jest.fn(),
|
|
15
|
+
update: jest.fn(),
|
|
16
|
+
}));
|
|
17
|
+
import User from '<%= (architecture === "MVC") ? "@/models/User" : "@/infrastructure/database/models/User" %>';
|
|
18
|
+
<%_ } _%>
|
|
19
|
+
|
|
20
|
+
import { SocialLoginUseCase } from '@/usecases/auth/socialLoginUseCase';
|
|
21
|
+
import { JwtService } from '<%= (architecture === "MVC") ? "@/services/jwtService" : "@/infrastructure/auth/jwtService" %>';
|
|
22
|
+
|
|
23
|
+
describe('SocialLoginUseCase', () => {
|
|
24
|
+
let useCase: SocialLoginUseCase;
|
|
25
|
+
let mockProvider: any;
|
|
26
|
+
<%_ if (architecture === 'Clean Architecture') { _%>
|
|
27
|
+
let mockRepo: any;
|
|
28
|
+
<%_ } _%>
|
|
29
|
+
|
|
30
|
+
beforeEach(() => {
|
|
31
|
+
mockProvider = {
|
|
32
|
+
name: 'Google',
|
|
33
|
+
getProfile: jest.fn(),
|
|
34
|
+
};
|
|
35
|
+
<%_ if (architecture === 'Clean Architecture') { _%>
|
|
36
|
+
mockRepo = new UserRepository();
|
|
37
|
+
useCase = new SocialLoginUseCase(mockProvider, mockRepo);
|
|
38
|
+
<%_ } else { _%>
|
|
39
|
+
useCase = new SocialLoginUseCase(mockProvider);
|
|
40
|
+
<%_ } _%>
|
|
41
|
+
jest.clearAllMocks();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('should find existing user and generate tokens', async () => {
|
|
45
|
+
const mockProfile = { id: 'google-123', email: 'test@test.com', name: 'Test User' };
|
|
46
|
+
const mockUser = { id: '1', email: 'test@test.com', googleId: null };
|
|
47
|
+
|
|
48
|
+
mockProvider.getProfile.mockResolvedValue(mockProfile);
|
|
49
|
+
<%_ if (architecture === 'Clean Architecture') { _%>
|
|
50
|
+
mockRepo.findByEmail.mockResolvedValue(mockUser);
|
|
51
|
+
<%_ } else { _%>
|
|
52
|
+
(User.findOne as jest.Mock).mockResolvedValue(mockUser);
|
|
53
|
+
<%_ } _%>
|
|
54
|
+
(JwtService.generateToken as jest.Mock).mockReturnValue('access-token');
|
|
55
|
+
(JwtService.generateRefreshToken as jest.Mock).mockReturnValue('refresh-token');
|
|
56
|
+
|
|
57
|
+
const result = await useCase.execute('test-code');
|
|
58
|
+
|
|
59
|
+
<%_ if (architecture === 'Clean Architecture') { _%>
|
|
60
|
+
expect(mockRepo.findByEmail).toHaveBeenCalled();
|
|
61
|
+
<%_ } else { _%>
|
|
62
|
+
expect(User.findOne).toHaveBeenCalled();
|
|
63
|
+
<%_ } _%>
|
|
64
|
+
expect(result.accessToken).toBe('access-token');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('should create new user if not exists', async () => {
|
|
68
|
+
const mockProfile = { id: 'google-456', email: 'new@test.com', name: 'New User' };
|
|
69
|
+
const mockUser = { id: '2', email: 'new@test.com' };
|
|
70
|
+
|
|
71
|
+
mockProvider.getProfile.mockResolvedValue(mockProfile);
|
|
72
|
+
<%_ if (architecture === 'Clean Architecture') { _%>
|
|
73
|
+
mockRepo.findByEmail.mockResolvedValue(null);
|
|
74
|
+
mockRepo.save.mockResolvedValue(mockUser);
|
|
75
|
+
<%_ } else { _%>
|
|
76
|
+
(User.findOne as jest.Mock).mockResolvedValue(null);
|
|
77
|
+
(User.create as jest.Mock).mockResolvedValue(mockUser);
|
|
78
|
+
<%_ } _%>
|
|
79
|
+
(JwtService.generateToken as jest.Mock).mockReturnValue('access-token');
|
|
80
|
+
(JwtService.generateRefreshToken as jest.Mock).mockReturnValue('refresh-token');
|
|
81
|
+
|
|
82
|
+
await useCase.execute('test-code');
|
|
83
|
+
|
|
84
|
+
<%_ if (architecture === 'Clean Architecture') { _%>
|
|
85
|
+
expect(mockRepo.save).toHaveBeenCalled();
|
|
86
|
+
<%_ } else { _%>
|
|
87
|
+
expect(User.create).toHaveBeenCalled();
|
|
88
|
+
<%_ } _%>
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('should link GitHub ID if existing user does not have it', async () => {
|
|
92
|
+
mockProvider.name = 'GitHub';
|
|
93
|
+
const mockProfile = { id: 'github-789', email: 'test@test.com', name: 'Test User' };
|
|
94
|
+
const mockUser = { id: '1', email: 'test@test.com', githubId: null };
|
|
95
|
+
|
|
96
|
+
mockProvider.getProfile.mockResolvedValue(mockProfile);
|
|
97
|
+
<%_ if (architecture === 'Clean Architecture') { _%>
|
|
98
|
+
mockRepo.findByEmail.mockResolvedValue(mockUser);
|
|
99
|
+
<%_ } else { _%>
|
|
100
|
+
(User.findOne as jest.Mock).mockResolvedValue(mockUser);
|
|
101
|
+
<%_ } _%>
|
|
102
|
+
(JwtService.generateToken as jest.Mock).mockReturnValue('access-token');
|
|
103
|
+
(JwtService.generateRefreshToken as jest.Mock).mockReturnValue('refresh-token');
|
|
104
|
+
|
|
105
|
+
await useCase.execute('test-code');
|
|
106
|
+
|
|
107
|
+
<%_ if (architecture === 'Clean Architecture') { _%>
|
|
108
|
+
expect(mockRepo.update).toHaveBeenCalledWith('1', expect.objectContaining({ githubId: 'github-789' }));
|
|
109
|
+
<%_ } else { _%>
|
|
110
|
+
<%_ if (database === 'MongoDB') { _%>
|
|
111
|
+
expect(mockUser.save || jest.fn()).toHaveBeenCalled();
|
|
112
|
+
<%_ } else { _%>
|
|
113
|
+
expect(User.update || jest.fn()).toHaveBeenCalled();
|
|
114
|
+
<%_ } _%>
|
|
115
|
+
<%_ } _%>
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('should link Google ID if existing user does not have it', async () => {
|
|
119
|
+
mockProvider.name = 'Google';
|
|
120
|
+
const mockProfile = { id: 'google-789', email: 'test@test.com', name: 'Test User' };
|
|
121
|
+
const mockUser = { id: '1', email: 'test@test.com', googleId: null };
|
|
122
|
+
|
|
123
|
+
mockProvider.getProfile.mockResolvedValue(mockProfile);
|
|
124
|
+
<%_ if (architecture === 'Clean Architecture') { _%>
|
|
125
|
+
mockRepo.findByEmail.mockResolvedValue(mockUser);
|
|
126
|
+
<%_ } else { _%>
|
|
127
|
+
(User.findOne as jest.Mock).mockResolvedValue(mockUser);
|
|
128
|
+
<%_ } _%>
|
|
129
|
+
(JwtService.generateToken as jest.Mock).mockReturnValue('access-token');
|
|
130
|
+
(JwtService.generateRefreshToken as jest.Mock).mockReturnValue('refresh-token');
|
|
131
|
+
|
|
132
|
+
await useCase.execute('test-code');
|
|
133
|
+
|
|
134
|
+
<%_ if (architecture === 'Clean Architecture') { _%>
|
|
135
|
+
expect(mockRepo.update).toHaveBeenCalledWith('1', expect.objectContaining({ googleId: 'google-789' }));
|
|
136
|
+
<%_ } _%>
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('should throw error if profile has no email', async () => {
|
|
140
|
+
mockProvider.getProfile.mockResolvedValue({ id: '123' }); // No email
|
|
141
|
+
await expect(useCase.execute('test-code')).rejects.toThrow('No email associated with this social account');
|
|
142
|
+
});
|
|
143
|
+
});
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { ISocialProvider } from '@/infrastructure/auth/socialAuthService';
|
|
2
|
+
import { JwtService } from '@/infrastructure/auth/jwtService';
|
|
3
|
+
<%_ if (architecture === 'Clean Architecture') { _%>
|
|
4
|
+
import { UserRepository } from '@/infrastructure/repositories/UserRepository';
|
|
5
|
+
import { User } from '@/domain/user';
|
|
6
|
+
<%_ } else { _%>
|
|
7
|
+
import User from '@/infrastructure/database/models/User';
|
|
8
|
+
<%_ } _%>
|
|
9
|
+
|
|
10
|
+
export class SocialLoginUseCase {
|
|
11
|
+
constructor(
|
|
12
|
+
private provider: ISocialProvider,
|
|
13
|
+
<%_ if (architecture === 'Clean Architecture') { _%>
|
|
14
|
+
private userRepository: UserRepository
|
|
15
|
+
<%_ } _%>
|
|
16
|
+
) {}
|
|
17
|
+
|
|
18
|
+
async execute(code: string, redirectUri?: string) {
|
|
19
|
+
const profile = await this.provider.getProfile(code, redirectUri);
|
|
20
|
+
|
|
21
|
+
if (!profile || !profile.email) {
|
|
22
|
+
throw new Error('No email associated with this social account');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// 1. Find or create user
|
|
26
|
+
<%_ if (architecture === 'Clean Architecture') { _%>
|
|
27
|
+
let user = await this.userRepository.findByEmail(profile.email);
|
|
28
|
+
|
|
29
|
+
if (!user) {
|
|
30
|
+
user = new User(
|
|
31
|
+
null,
|
|
32
|
+
profile.name,
|
|
33
|
+
profile.email,
|
|
34
|
+
null,
|
|
35
|
+
<%_ if (socialAuth.includes('Google')) { _%>
|
|
36
|
+
this.provider.name === 'Google' ? profile.id : null,
|
|
37
|
+
<%_ } _%>
|
|
38
|
+
<%_ if (socialAuth.includes('GitHub')) { _%>
|
|
39
|
+
this.provider.name === 'GitHub' ? profile.id : null,
|
|
40
|
+
<%_ } _%>
|
|
41
|
+
);
|
|
42
|
+
user = await this.userRepository.save(user);
|
|
43
|
+
} else {
|
|
44
|
+
// Link social ID if not already linked
|
|
45
|
+
let updated = false;
|
|
46
|
+
<%_ if (socialAuth.includes('Google')) { _%>
|
|
47
|
+
if (this.provider.name === 'Google' && !user.googleId) {
|
|
48
|
+
user.googleId = profile.id;
|
|
49
|
+
updated = true;
|
|
50
|
+
}
|
|
51
|
+
<%_ } _%>
|
|
52
|
+
<%_ if (socialAuth.includes('GitHub')) { _%>
|
|
53
|
+
if (this.provider.name === 'GitHub' && !user.githubId) {
|
|
54
|
+
user.githubId = profile.id;
|
|
55
|
+
updated = true;
|
|
56
|
+
}
|
|
57
|
+
<%_ } _%>
|
|
58
|
+
if (updated) {
|
|
59
|
+
await this.userRepository.update(user.id!, user);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
<%_ } else { _%>
|
|
63
|
+
let user = await User.findOne({
|
|
64
|
+
<%_ if (database === 'MongoDB' || database === 'None') { _%>
|
|
65
|
+
email: profile.email
|
|
66
|
+
<%_ } else { _%>
|
|
67
|
+
where: { email: profile.email }
|
|
68
|
+
<%_ } _%>
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
if (!user) {
|
|
72
|
+
user = await User.create({
|
|
73
|
+
email: profile.email,
|
|
74
|
+
name: profile.name,
|
|
75
|
+
password: null,
|
|
76
|
+
<%_ if (socialAuth.includes('Google')) { _%>
|
|
77
|
+
googleId: this.provider.name === 'Google' ? profile.id : null,
|
|
78
|
+
<%_ } _%>
|
|
79
|
+
<%_ if (socialAuth.includes('GitHub')) { _%>
|
|
80
|
+
githubId: this.provider.name === 'GitHub' ? profile.id : null,
|
|
81
|
+
<%_ } _%>
|
|
82
|
+
});
|
|
83
|
+
} else {
|
|
84
|
+
let updated = false;
|
|
85
|
+
<%_ if (socialAuth.includes('Google')) { _%>
|
|
86
|
+
if (this.provider.name === 'Google' && !user.googleId) {
|
|
87
|
+
user.googleId = profile.id;
|
|
88
|
+
updated = true;
|
|
89
|
+
}
|
|
90
|
+
<%_ } _%>
|
|
91
|
+
<%_ if (socialAuth.includes('GitHub')) { _%>
|
|
92
|
+
if (this.provider.name === 'GitHub' && !user.githubId) {
|
|
93
|
+
user.githubId = profile.id;
|
|
94
|
+
updated = true;
|
|
95
|
+
}
|
|
96
|
+
<%_ } _%>
|
|
97
|
+
if (updated) {
|
|
98
|
+
<%_ if (database === 'MongoDB') { _%>
|
|
99
|
+
await user.save();
|
|
100
|
+
<%_ } else if (database === 'None') { _%>
|
|
101
|
+
await User.update(user.id, { googleId: user.googleId, githubId: user.githubId });
|
|
102
|
+
<%_ } else { _%>
|
|
103
|
+
await user.save();
|
|
104
|
+
<%_ } _%>
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
<%_ } _%>
|
|
108
|
+
|
|
109
|
+
// 2. Generate tokens
|
|
110
|
+
const userId = user.id || (user as { _id?: { toString(): string } })._id?.toString();
|
|
111
|
+
const payload = { id: userId, email: user.email };
|
|
112
|
+
const accessToken = JwtService.generateToken(payload);
|
|
113
|
+
const refreshToken = JwtService.generateRefreshToken(payload);
|
|
114
|
+
|
|
115
|
+
return { user, accessToken, refreshToken };
|
|
116
|
+
}
|
|
117
|
+
}
|
|
@@ -67,13 +67,21 @@ User.init(
|
|
|
67
67
|
type: DataTypes.STRING,
|
|
68
68
|
allowNull: false,
|
|
69
69
|
unique: true,
|
|
70
|
-
}
|
|
71
|
-
<% if (auth.includes('JWT')) { %>
|
|
70
|
+
},<% if (auth.includes('JWT')) { %>
|
|
72
71
|
password: {
|
|
73
72
|
type: DataTypes.STRING,
|
|
74
|
-
allowNull:
|
|
75
|
-
}
|
|
76
|
-
|
|
73
|
+
allowNull: true,
|
|
74
|
+
},<% } %><% if (socialAuth && socialAuth.includes('Google')) { %>
|
|
75
|
+
googleId: {
|
|
76
|
+
type: DataTypes.STRING,
|
|
77
|
+
allowNull: true,
|
|
78
|
+
unique: true,
|
|
79
|
+
},<% } %><% if (socialAuth && socialAuth.includes('GitHub')) { %>
|
|
80
|
+
githubId: {
|
|
81
|
+
type: DataTypes.STRING,
|
|
82
|
+
allowNull: true,
|
|
83
|
+
unique: true,
|
|
84
|
+
},<% } %>
|
|
77
85
|
deletedAt: {
|
|
78
86
|
type: DataTypes.DATE,
|
|
79
87
|
allowNull: true,
|
|
@@ -13,7 +13,21 @@ const UserSchema = new mongoose.Schema({
|
|
|
13
13
|
<% if (auth.includes('JWT')) { %>
|
|
14
14
|
password: {
|
|
15
15
|
type: String,
|
|
16
|
-
required:
|
|
16
|
+
required: false
|
|
17
|
+
},
|
|
18
|
+
<% } %>
|
|
19
|
+
<% if (socialAuth && socialAuth.includes('Google')) { %>
|
|
20
|
+
googleId: {
|
|
21
|
+
type: String,
|
|
22
|
+
unique: true,
|
|
23
|
+
sparse: true
|
|
24
|
+
},
|
|
25
|
+
<% } %>
|
|
26
|
+
<% if (socialAuth && socialAuth.includes('GitHub')) { %>
|
|
27
|
+
githubId: {
|
|
28
|
+
type: String,
|
|
29
|
+
unique: true,
|
|
30
|
+
sparse: true
|
|
17
31
|
},
|
|
18
32
|
<% } %>
|
|
19
33
|
createdAt: {
|