@flusys/nestjs-auth 7.0.2 → 7.1.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 (108) hide show
  1. package/README.md +67 -0
  2. package/cjs/config/auth.constants.js +8 -0
  3. package/cjs/config/event-actions.js +4 -2
  4. package/cjs/config/message-keys.js +27 -0
  5. package/cjs/controllers/index.js +2 -0
  6. package/cjs/controllers/social-auth-config.controller.js +107 -0
  7. package/cjs/controllers/social-auth.controller.js +175 -0
  8. package/cjs/controllers/social-auth.controller.spec.js +79 -0
  9. package/cjs/docs/auth-swagger.config.js +9 -1
  10. package/cjs/dtos/index.js +1 -0
  11. package/cjs/dtos/social-auth.dto.js +318 -0
  12. package/cjs/entities/index.js +3 -1
  13. package/cjs/entities/index.spec.js +12 -1
  14. package/cjs/entities/social-auth-config.entity.js +102 -0
  15. package/cjs/enums/index.js +1 -0
  16. package/cjs/enums/social-provider-type.enum.js +17 -0
  17. package/cjs/index.js +1 -0
  18. package/cjs/interfaces/index.js +1 -0
  19. package/cjs/interfaces/social-auth.interface.js +4 -0
  20. package/cjs/modules/auth.module.js +6 -0
  21. package/cjs/modules/auth.module.spec.js +15 -0
  22. package/cjs/providers/facebook.social-provider.js +73 -0
  23. package/cjs/providers/google.social-provider.js +68 -0
  24. package/cjs/providers/index.js +18 -0
  25. package/cjs/providers/linkedin.social-provider.js +66 -0
  26. package/cjs/providers/microsoft.social-provider.js +78 -0
  27. package/cjs/providers/oauth-http.util.js +107 -0
  28. package/cjs/providers/social-provider.registry.js +24 -0
  29. package/cjs/providers/social-provider.registry.spec.js +135 -0
  30. package/cjs/services/auth-config.service.js +3 -0
  31. package/cjs/services/auth-config.service.spec.js +3 -0
  32. package/cjs/services/auth-datasource.provider.js +4 -0
  33. package/cjs/services/auth-datasource.provider.spec.js +30 -0
  34. package/cjs/services/authentication.service.js +38 -8
  35. package/cjs/services/authentication.service.spec.js +58 -0
  36. package/cjs/services/index.js +2 -0
  37. package/cjs/services/social-auth-config.service.js +103 -0
  38. package/cjs/services/social-auth-config.service.spec.js +71 -0
  39. package/cjs/services/social-auth.service.js +310 -0
  40. package/cjs/services/social-auth.service.spec.js +310 -0
  41. package/config/auth.constants.d.ts +2 -0
  42. package/config/event-actions.d.ts +2 -0
  43. package/config/message-keys.d.ts +21 -0
  44. package/controllers/index.d.ts +2 -0
  45. package/controllers/social-auth-config.controller.d.ts +22 -0
  46. package/controllers/social-auth.controller.d.ts +11 -0
  47. package/dtos/index.d.ts +1 -0
  48. package/dtos/social-auth.dto.d.ts +42 -0
  49. package/entities/index.d.ts +3 -1
  50. package/entities/social-auth-config.entity.d.ts +9 -0
  51. package/enums/index.d.ts +1 -0
  52. package/enums/social-provider-type.enum.d.ts +6 -0
  53. package/fesm/config/auth.constants.js +3 -0
  54. package/fesm/config/event-actions.js +4 -2
  55. package/fesm/config/message-keys.js +21 -0
  56. package/fesm/controllers/index.js +2 -0
  57. package/fesm/controllers/social-auth-config.controller.js +97 -0
  58. package/fesm/controllers/social-auth.controller.js +165 -0
  59. package/fesm/controllers/social-auth.controller.spec.js +75 -0
  60. package/fesm/docs/auth-swagger.config.js +9 -1
  61. package/fesm/dtos/index.js +1 -0
  62. package/fesm/dtos/social-auth.dto.js +282 -0
  63. package/fesm/entities/index.js +4 -1
  64. package/fesm/entities/index.spec.js +12 -1
  65. package/fesm/entities/social-auth-config.entity.js +92 -0
  66. package/fesm/enums/index.js +1 -0
  67. package/fesm/enums/social-provider-type.enum.js +7 -0
  68. package/fesm/index.js +1 -0
  69. package/fesm/interfaces/index.js +1 -0
  70. package/fesm/interfaces/social-auth.interface.js +1 -0
  71. package/fesm/modules/auth.module.js +8 -2
  72. package/fesm/modules/auth.module.spec.js +17 -2
  73. package/fesm/providers/facebook.social-provider.js +63 -0
  74. package/fesm/providers/google.social-provider.js +58 -0
  75. package/fesm/providers/index.js +1 -0
  76. package/fesm/providers/linkedin.social-provider.js +56 -0
  77. package/fesm/providers/microsoft.social-provider.js +68 -0
  78. package/fesm/providers/oauth-http.util.js +77 -0
  79. package/fesm/providers/social-provider.registry.js +14 -0
  80. package/fesm/providers/social-provider.registry.spec.js +131 -0
  81. package/fesm/services/auth-config.service.js +3 -0
  82. package/fesm/services/auth-config.service.spec.js +3 -0
  83. package/fesm/services/auth-datasource.provider.js +4 -0
  84. package/fesm/services/auth-datasource.provider.spec.js +30 -0
  85. package/fesm/services/authentication.service.js +38 -8
  86. package/fesm/services/authentication.service.spec.js +58 -0
  87. package/fesm/services/index.js +2 -0
  88. package/fesm/services/social-auth-config.service.js +93 -0
  89. package/fesm/services/social-auth-config.service.spec.js +67 -0
  90. package/fesm/services/social-auth.service.js +300 -0
  91. package/fesm/services/social-auth.service.spec.js +265 -0
  92. package/index.d.ts +1 -0
  93. package/interfaces/index.d.ts +1 -0
  94. package/interfaces/social-auth.interface.d.ts +43 -0
  95. package/package.json +3 -3
  96. package/providers/facebook.social-provider.d.ts +10 -0
  97. package/providers/google.social-provider.d.ts +10 -0
  98. package/providers/index.d.ts +1 -0
  99. package/providers/linkedin.social-provider.d.ts +10 -0
  100. package/providers/microsoft.social-provider.d.ts +10 -0
  101. package/providers/oauth-http.util.d.ts +10 -0
  102. package/providers/social-provider.registry.d.ts +3 -0
  103. package/services/auth-config.service.d.ts +1 -0
  104. package/services/auth-datasource.provider.d.ts +1 -0
  105. package/services/authentication.service.d.ts +4 -0
  106. package/services/index.d.ts +2 -0
  107. package/services/social-auth-config.service.d.ts +21 -0
  108. package/services/social-auth.service.d.ts +24 -0
@@ -0,0 +1,131 @@
1
+ import { UnauthorizedException } from '@nestjs/common';
2
+ import { SocialProviderTypeEnum } from '../enums/social-provider-type.enum';
3
+ import { getSocialProvider } from './social-provider.registry';
4
+ const CREDENTIALS = {
5
+ clientId: 'client-id',
6
+ clientSecret: 'client-secret',
7
+ redirectUri: 'http://localhost:2001/auth/social/callback'
8
+ };
9
+ function mockFetchSequence(...responses) {
10
+ const fetchMock = jest.fn();
11
+ for (const response of responses){
12
+ fetchMock.mockResolvedValueOnce({
13
+ ok: response.ok ?? true,
14
+ status: response.status ?? 200,
15
+ text: async ()=>JSON.stringify(response.body)
16
+ });
17
+ }
18
+ globalThis.fetch = fetchMock;
19
+ return fetchMock;
20
+ }
21
+ describe('social providers', ()=>{
22
+ afterEach(()=>jest.restoreAllMocks());
23
+ describe('buildAuthorizationUrl', ()=>{
24
+ it.each(Object.values(SocialProviderTypeEnum))('carries the client id, redirect URI and state for %s', (provider)=>{
25
+ const url = new URL(getSocialProvider(provider).buildAuthorizationUrl(CREDENTIALS, 'state-token'));
26
+ expect(url.searchParams.get('client_id')).toBe('client-id');
27
+ expect(url.searchParams.get('redirect_uri')).toBe(CREDENTIALS.redirectUri);
28
+ expect(url.searchParams.get('state')).toBe('state-token');
29
+ expect(url.searchParams.get('scope')).toBeTruthy();
30
+ });
31
+ it('uses the configured scopes over the provider defaults', ()=>{
32
+ const url = new URL(getSocialProvider(SocialProviderTypeEnum.GOOGLE).buildAuthorizationUrl({
33
+ ...CREDENTIALS,
34
+ scopes: [
35
+ 'openid',
36
+ 'email'
37
+ ]
38
+ }, 'state-token'));
39
+ expect(url.searchParams.get('scope')).toBe('openid email');
40
+ });
41
+ it('targets the common Microsoft directory unless a tenant is configured', ()=>{
42
+ const provider = getSocialProvider(SocialProviderTypeEnum.MICROSOFT);
43
+ expect(provider.buildAuthorizationUrl(CREDENTIALS, 'state-token')).toContain('login.microsoftonline.com/common/');
44
+ expect(provider.buildAuthorizationUrl({
45
+ ...CREDENTIALS,
46
+ extraConfig: {
47
+ tenantId: 'contoso'
48
+ }
49
+ }, 'state-token')).toContain('login.microsoftonline.com/contoso/');
50
+ });
51
+ });
52
+ describe('fetchProfile', ()=>{
53
+ it('normalises a Google OpenID profile', async ()=>{
54
+ mockFetchSequence({
55
+ body: {
56
+ access_token: 'token'
57
+ }
58
+ }, {
59
+ body: {
60
+ sub: 'google-1',
61
+ email: 'user@example.com',
62
+ email_verified: true,
63
+ name: 'Test User',
64
+ picture: 'https://cdn.test/avatar.png'
65
+ }
66
+ });
67
+ const profile = await getSocialProvider(SocialProviderTypeEnum.GOOGLE).fetchProfile(CREDENTIALS, 'code');
68
+ expect(profile).toEqual({
69
+ providerUserId: 'google-1',
70
+ email: 'user@example.com',
71
+ emailVerified: true,
72
+ name: 'Test User',
73
+ avatarUrl: 'https://cdn.test/avatar.png'
74
+ });
75
+ });
76
+ it('reads the nested avatar Facebook returns', async ()=>{
77
+ mockFetchSequence({
78
+ body: {
79
+ access_token: 'token'
80
+ }
81
+ }, {
82
+ body: {
83
+ id: 'fb-1',
84
+ name: 'Test User',
85
+ email: 'user@example.com',
86
+ picture: {
87
+ data: {
88
+ url: 'https://cdn.test/fb.png'
89
+ }
90
+ }
91
+ }
92
+ });
93
+ const profile = await getSocialProvider(SocialProviderTypeEnum.FACEBOOK).fetchProfile(CREDENTIALS, 'code');
94
+ expect(profile.avatarUrl).toBe('https://cdn.test/fb.png');
95
+ expect(profile.emailVerified).toBe(true);
96
+ });
97
+ it('falls back to the Microsoft principal name when no mailbox is set', async ()=>{
98
+ mockFetchSequence({
99
+ body: {
100
+ access_token: 'token'
101
+ }
102
+ }, {
103
+ body: {
104
+ id: 'ms-1',
105
+ displayName: 'Test User',
106
+ userPrincipalName: 'user@contoso.com'
107
+ }
108
+ });
109
+ const profile = await getSocialProvider(SocialProviderTypeEnum.MICROSOFT).fetchProfile(CREDENTIALS, 'code');
110
+ expect(profile.email).toBe('user@contoso.com');
111
+ });
112
+ it('rejects when the token endpoint fails', async ()=>{
113
+ mockFetchSequence({
114
+ ok: false,
115
+ status: 400,
116
+ body: {
117
+ error: 'invalid_grant'
118
+ }
119
+ });
120
+ await expect(getSocialProvider(SocialProviderTypeEnum.LINKEDIN).fetchProfile(CREDENTIALS, 'code')).rejects.toThrow(UnauthorizedException);
121
+ });
122
+ it('rejects when the token response carries no access token', async ()=>{
123
+ mockFetchSequence({
124
+ body: {
125
+ token_type: 'bearer'
126
+ }
127
+ });
128
+ await expect(getSocialProvider(SocialProviderTypeEnum.GOOGLE).fetchProfile(CREDENTIALS, 'code')).rejects.toThrow(UnauthorizedException);
129
+ });
130
+ });
131
+ });
@@ -45,6 +45,9 @@ export class AuthConfigService {
45
45
  isEmailVerificationEnabled() {
46
46
  return this.options.bootstrapAppConfig?.enableEmailVerification ?? true;
47
47
  }
48
+ isSignUpEnabled() {
49
+ return this.options.bootstrapAppConfig?.enableSignUp ?? true;
50
+ }
48
51
  isPermissionModeFound() {
49
52
  return this.options.bootstrapAppConfig?.permissionMode !== undefined;
50
53
  }
@@ -17,6 +17,7 @@ describe('AuthConfigService', ()=>{
17
17
  expect(service.isMultiTenant()).toBe(false);
18
18
  expect(service.isCompanyFeatureEnabled()).toBe(false);
19
19
  expect(service.isEmailVerificationEnabled()).toBe(true);
20
+ expect(service.isSignUpEnabled()).toBe(true);
20
21
  expect(service.isPermissionModeFound()).toBe(false);
21
22
  });
22
23
  it('throws when reading the JWT secret without configuration', ()=>{
@@ -49,6 +50,7 @@ describe('AuthConfigService', ()=>{
49
50
  databaseMode: 'multi-tenant',
50
51
  enableCompanyFeature: true,
51
52
  enableEmailVerification: false,
53
+ enableSignUp: false,
52
54
  permissionMode: 'RBAC'
53
55
  },
54
56
  config: {
@@ -70,6 +72,7 @@ describe('AuthConfigService', ()=>{
70
72
  const service = new AuthConfigService(buildOptions());
71
73
  expect(service.isCompanyFeatureEnabled()).toBe(true);
72
74
  expect(service.isEmailVerificationEnabled()).toBe(false);
75
+ expect(service.isSignUpEnabled()).toBe(false);
73
76
  expect(service.isPermissionModeFound()).toBe(true);
74
77
  });
75
78
  it('returns configured JWT and refresh token values', ()=>{
@@ -48,6 +48,10 @@ export class AuthDataSourceProvider extends MultiTenantDataSourceService {
48
48
  const effectiveTenant = tenant ?? this.getCurrentTenant();
49
49
  return effectiveTenant?.enableEmailVerification ?? this.authOptions.bootstrapAppConfig?.enableEmailVerification ?? true;
50
50
  }
51
+ isSignUpEnabled(tenant) {
52
+ const effectiveTenant = tenant ?? this.getCurrentTenant();
53
+ return effectiveTenant?.enableSignUp ?? this.authOptions.bootstrapAppConfig?.enableSignUp ?? true;
54
+ }
51
55
  async getAuthEntities() {
52
56
  const { getAuthEntitiesByConfig } = await import('../entities');
53
57
  return getAuthEntitiesByConfig({
@@ -78,6 +78,36 @@ describe('AuthDataSourceProvider', ()=>{
78
78
  })).toBe(false);
79
79
  });
80
80
  });
81
+ describe('isSignUpEnabled', ()=>{
82
+ it('returns true by default (sign-up defaults to enabled)', ()=>{
83
+ const provider = new AuthDataSourceProvider(buildOptions({
84
+ bootstrapAppConfig: undefined
85
+ }));
86
+ expect(provider.isSignUpEnabled()).toBe(true);
87
+ });
88
+ it('returns false when bootstrap config disables it', ()=>{
89
+ const provider = new AuthDataSourceProvider(buildOptions({
90
+ bootstrapAppConfig: {
91
+ databaseMode: 'single',
92
+ enableCompanyFeature: false,
93
+ enableSignUp: false
94
+ }
95
+ }));
96
+ expect(provider.isSignUpEnabled()).toBe(false);
97
+ });
98
+ it('prefers the tenant override over bootstrap config', ()=>{
99
+ const provider = new AuthDataSourceProvider(buildOptions({
100
+ bootstrapAppConfig: {
101
+ databaseMode: 'multi-tenant',
102
+ enableCompanyFeature: true,
103
+ enableSignUp: true
104
+ }
105
+ }));
106
+ expect(provider.isSignUpEnabled({
107
+ enableSignUp: false
108
+ })).toBe(false);
109
+ });
110
+ });
81
111
  describe('getAuthEntities', ()=>{
82
112
  it('returns only base entities when company feature and email verification are disabled', async ()=>{
83
113
  const provider = new AuthDataSourceProvider(buildOptions({
@@ -57,6 +57,9 @@ export class AuthenticationService {
57
57
  const providerAvailable = this.authEmailService?.isEmailEnabled() ?? false;
58
58
  return featureEnabled && providerAvailable;
59
59
  }
60
+ isSignUpEnabled() {
61
+ return this.dataSourceProvider?.isSignUpEnabled() ?? this.authConfig.isSignUpEnabled();
62
+ }
60
63
  async getUserRepository() {
61
64
  if (!this.dataSourceProvider) {
62
65
  throw new InternalServerErrorException({
@@ -83,12 +86,7 @@ export class AuthenticationService {
83
86
  };
84
87
  }
85
88
  const user = await this.validateUser(email, password);
86
- if (!user.isActive) {
87
- throw new ForbiddenException({
88
- message: 'User account is deactivated',
89
- messageKey: AUTH_MESSAGES.LOGIN_ACCOUNT_DEACTIVATED
90
- });
91
- }
89
+ this.ensureUserActive(user);
92
90
  if (this.isEmailVerificationEnabled() && !user.emailVerified) {
93
91
  return {
94
92
  requiresEmailVerification: true,
@@ -97,11 +95,23 @@ export class AuthenticationService {
97
95
  messageKey: AUTH_MESSAGES.LOGIN_EMAIL_NOT_VERIFIED
98
96
  };
99
97
  }
98
+ return this.issueLoginForUser(user);
99
+ }
100
+ async issueLoginForUser(user) {
101
+ this.ensureUserActive(user);
100
102
  if (!this.isCompanyFeatureEnabled()) {
101
103
  return this.generateLoginResponse(user);
102
104
  }
103
105
  return await this.handleCompanyBasedLogin(user);
104
106
  }
107
+ ensureUserActive(user) {
108
+ if (!user.isActive) {
109
+ throw new ForbiddenException({
110
+ message: 'User account is deactivated',
111
+ messageKey: AUTH_MESSAGES.LOGIN_ACCOUNT_DEACTIVATED
112
+ });
113
+ }
114
+ }
105
115
  async handleCompanyBasedLogin(user) {
106
116
  const permissionRepository = await this.getCompanyScopedRepository(UserCompanyPermission);
107
117
  const branchRepository = await this.getCompanyScopedRepository(CompanyBranch);
@@ -149,8 +159,15 @@ export class AuthenticationService {
149
159
  const existingUser = await userRepository.findOne({
150
160
  where: {
151
161
  email: dto.email
152
- }
162
+ },
163
+ withDeleted: true
153
164
  });
165
+ if (existingUser?.deletedAt) {
166
+ throw new BadRequestException({
167
+ message: 'This account has been permanently deleted. Please contact your system administrator.',
168
+ messageKey: AUTH_MESSAGES.REGISTER_ACCOUNT_DELETED
169
+ });
170
+ }
154
171
  if (existingUser && this.isCompanyFeatureEnabled()) {
155
172
  return await this.handleExistingUserCompanyRegistration(existingUser, dto);
156
173
  }
@@ -160,6 +177,12 @@ export class AuthenticationService {
160
177
  messageKey: AUTH_MESSAGES.REGISTER_EMAIL_EXISTS
161
178
  });
162
179
  }
180
+ if (!this.isSignUpEnabled()) {
181
+ throw new BadRequestException({
182
+ message: 'New account registration is currently disabled.',
183
+ messageKey: AUTH_MESSAGES.REGISTER_SIGNUP_DISABLED
184
+ });
185
+ }
163
186
  if (this.isEmailVerificationEnabled() && verifyUrlBase) {
164
187
  const companyData = this.isCompanyFeatureEnabled() ? {
165
188
  companySlug: dto.companySlug,
@@ -287,8 +310,15 @@ export class AuthenticationService {
287
310
  const existingUser = await queryRunner.manager.findOne(AppUser, {
288
311
  where: {
289
312
  email: pending.email
290
- }
313
+ },
314
+ withDeleted: true
291
315
  });
316
+ if (existingUser?.deletedAt) {
317
+ throw new BadRequestException({
318
+ message: 'This account has been permanently deleted. Please contact your system administrator.',
319
+ messageKey: AUTH_MESSAGES.REGISTER_ACCOUNT_DELETED
320
+ });
321
+ }
292
322
  if (existingUser) {
293
323
  throw new BadRequestException({
294
324
  message: 'This email has already been registered.',
@@ -86,6 +86,7 @@ describe('AuthenticationService', ()=>{
86
86
  let mockUserEnricher;
87
87
  let companyFeatureEnabled;
88
88
  let emailVerificationEnabled;
89
+ let signUpEnabled;
89
90
  function buildService(overrides = {}) {
90
91
  return new AuthenticationService(mockJwtService, mockAuthConfig, 'cacheManager' in overrides ? overrides.cacheManager : mockCacheManager, 'dataSourceProvider' in overrides ? overrides.dataSourceProvider : mockDataSourceProvider, 'sessionService' in overrides ? overrides.sessionService : mockSessionService, 'authEmailService' in overrides ? overrides.authEmailService : mockAuthEmailService, 'userEnricher' in overrides ? overrides.userEnricher : mockUserEnricher, 'notificationAdapter' in overrides ? overrides.notificationAdapter : undefined);
91
92
  }
@@ -102,6 +103,7 @@ describe('AuthenticationService', ()=>{
102
103
  });
103
104
  companyFeatureEnabled = false;
104
105
  emailVerificationEnabled = false;
106
+ signUpEnabled = true;
105
107
  mockJwtService = {
106
108
  sign: jest.fn().mockReturnValue('signed-token'),
107
109
  verify: jest.fn()
@@ -109,6 +111,7 @@ describe('AuthenticationService', ()=>{
109
111
  mockAuthConfig = {
110
112
  isCompanyFeatureEnabled: jest.fn().mockImplementation(()=>companyFeatureEnabled),
111
113
  isEmailVerificationEnabled: jest.fn().mockImplementation(()=>emailVerificationEnabled),
114
+ isSignUpEnabled: jest.fn().mockImplementation(()=>signUpEnabled),
112
115
  getJwtSecret: jest.fn().mockReturnValue('jwt-secret'),
113
116
  getJwtExpiration: jest.fn().mockReturnValue('1h'),
114
117
  getRefreshTokenSecret: jest.fn().mockReturnValue('refresh-secret'),
@@ -122,6 +125,7 @@ describe('AuthenticationService', ()=>{
122
125
  mockDataSourceProvider = {
123
126
  isCompanyFeatureEnabled: jest.fn().mockImplementation(()=>companyFeatureEnabled),
124
127
  isEmailVerificationEnabled: jest.fn().mockImplementation(()=>emailVerificationEnabled),
128
+ isSignUpEnabled: jest.fn().mockImplementation(()=>signUpEnabled),
125
129
  getRepository: jest.fn().mockImplementation(async (entity)=>{
126
130
  if (entity === AppUser) return userRepo;
127
131
  if (entity === Company) return companyRepo;
@@ -296,6 +300,20 @@ describe('AuthenticationService', ()=>{
296
300
  password: 'Password1!'
297
301
  })).rejects.toThrow(BadRequestException);
298
302
  });
303
+ it('throws BadRequestException when the email belongs to a permanently deleted account', async ()=>{
304
+ userRepo.findOne.mockResolvedValue(buildUser({
305
+ deletedAt: new Date()
306
+ }));
307
+ const service = buildService();
308
+ await expect(service.register({
309
+ email: 'user@example.com',
310
+ name: 'Test',
311
+ password: 'Password1!'
312
+ })).rejects.toThrow(BadRequestException);
313
+ expect(userRepo.findOne).toHaveBeenCalledWith(expect.objectContaining({
314
+ withDeleted: true
315
+ }));
316
+ });
299
317
  it('routes existing users to company registration when the company feature is enabled', async ()=>{
300
318
  companyFeatureEnabled = true;
301
319
  userRepo.findOne.mockResolvedValue(buildUser());
@@ -317,6 +335,16 @@ describe('AuthenticationService', ()=>{
317
335
  accessToken: expect.any(String)
318
336
  }));
319
337
  });
338
+ it('throws BadRequestException for a new user when sign-up is disabled', async ()=>{
339
+ signUpEnabled = false;
340
+ userRepo.findOne.mockResolvedValue(null);
341
+ const service = buildService();
342
+ await expect(service.register({
343
+ email: 'new@example.com',
344
+ name: 'New User',
345
+ password: 'Password1!'
346
+ })).rejects.toThrow(BadRequestException);
347
+ });
320
348
  it('sends a verification email and returns requiresEmailVerification when email verification is enabled', async ()=>{
321
349
  emailVerificationEnabled = true;
322
350
  userRepo.findOne.mockResolvedValue(null);
@@ -823,6 +851,36 @@ describe('AuthenticationService', ()=>{
823
851
  })).rejects.toThrow(BadRequestException);
824
852
  expect(queryRunner.rollbackTransaction).toHaveBeenCalled();
825
853
  });
854
+ it('throws BadRequestException when the email belongs to a permanently deleted account', async ()=>{
855
+ const queryRunner = {
856
+ connect: jest.fn(),
857
+ startTransaction: jest.fn(),
858
+ commitTransaction: jest.fn(),
859
+ rollbackTransaction: jest.fn(),
860
+ release: jest.fn(),
861
+ manager: {
862
+ findOne: jest.fn().mockResolvedValue(buildUser({
863
+ deletedAt: new Date()
864
+ })),
865
+ create: jest.fn(),
866
+ save: jest.fn()
867
+ }
868
+ };
869
+ mockDataSourceProvider.getDataSource.mockResolvedValue({
870
+ createQueryRunner: ()=>queryRunner
871
+ });
872
+ const service = buildService();
873
+ await expect(service.createUserFromPendingRegistration({
874
+ email: 'user@example.com',
875
+ name: 'Test',
876
+ passwordHash: 'hash'
877
+ })).rejects.toThrow(BadRequestException);
878
+ expect(queryRunner.manager.findOne).toHaveBeenCalledWith(AppUser, expect.objectContaining({
879
+ withDeleted: true
880
+ }));
881
+ expect(queryRunner.manager.create).not.toHaveBeenCalled();
882
+ expect(queryRunner.rollbackTransaction).toHaveBeenCalled();
883
+ });
826
884
  it('creates the user with the email marked as verified', async ()=>{
827
885
  const queryRunner = {
828
886
  connect: jest.fn(),
@@ -1,6 +1,8 @@
1
1
  export * from './authentication.service';
2
2
  export * from './auth-email.service';
3
3
  export * from './company-selection-session.service';
4
+ export * from './social-auth.service';
5
+ export * from './social-auth-config.service';
4
6
  export * from './user.service';
5
7
  export * from './company.service';
6
8
  export * from './branch.service';
@@ -0,0 +1,93 @@
1
+ function _define_property(obj, key, value) {
2
+ if (key in obj) {
3
+ Object.defineProperty(obj, key, {
4
+ value: value,
5
+ enumerable: true,
6
+ configurable: true,
7
+ writable: true
8
+ });
9
+ } else {
10
+ obj[key] = value;
11
+ }
12
+ return obj;
13
+ }
14
+ function _ts_decorate(decorators, target, key, desc) {
15
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
16
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
17
+ else for(var i = decorators.length - 1; i >= 0; i--)if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
18
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
19
+ }
20
+ function _ts_metadata(k, v) {
21
+ if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
22
+ }
23
+ function _ts_param(paramIndex, decorator) {
24
+ return function(target, key) {
25
+ decorator(target, key, paramIndex);
26
+ };
27
+ }
28
+ import { ApiService, HybridCache } from '@flusys/nestjs-shared/classes';
29
+ import { UtilsService } from '@flusys/nestjs-shared/modules';
30
+ import { ConflictException, Inject, Injectable, Scope } from '@nestjs/common';
31
+ import { IsNull, Not } from 'typeorm';
32
+ import { AUTH_EVENT_ENTITIES, AUTH_EVENT_MODULE, SOCIAL_AUTH_CONFIG_MESSAGES } from '../config';
33
+ import { SocialAuthConfig } from '../entities/social-auth-config.entity';
34
+ import { AuthDataSourceProvider } from './auth-datasource.provider';
35
+ export class SocialAuthConfigService extends ApiService {
36
+ async beforeInsertOperation(dto, _user, _queryRunner) {
37
+ for (const entry of Array.isArray(dto) ? dto : [
38
+ dto
39
+ ]){
40
+ await this.ensureProviderUnclaimed(entry.provider);
41
+ }
42
+ }
43
+ async beforeUpdateOperation(dto, _user, _queryRunner) {
44
+ for (const entry of Array.isArray(dto) ? dto : [
45
+ dto
46
+ ]){
47
+ if (entry.provider) await this.ensureProviderUnclaimed(entry.provider, entry.id);
48
+ }
49
+ }
50
+ async ensureProviderUnclaimed(provider, exceptId) {
51
+ await this.ensureDataSourceRepository();
52
+ const existing = await this.repository.findOne({
53
+ where: {
54
+ provider,
55
+ deletedAt: IsNull(),
56
+ ...exceptId ? {
57
+ id: Not(exceptId)
58
+ } : {}
59
+ }
60
+ });
61
+ if (existing) {
62
+ throw new ConflictException({
63
+ message: `Social provider "${provider}" is already configured`,
64
+ messageKey: SOCIAL_AUTH_CONFIG_MESSAGES.PROVIDER_ALREADY_CONFIGURED,
65
+ messageVariables: {
66
+ provider
67
+ }
68
+ });
69
+ }
70
+ }
71
+ async getExtraManipulateQuery(query, filterDto, user) {
72
+ const result = await super.getExtraManipulateQuery(query, filterDto, user);
73
+ query.orderBy(`${this.entityName}.provider`, 'ASC');
74
+ return result;
75
+ }
76
+ constructor(cacheManager, utilsService, dataSourceProvider){
77
+ super(AUTH_EVENT_ENTITIES.SOCIAL_AUTH_CONFIG, cacheManager, utilsService, SocialAuthConfigService.name, true, AUTH_EVENT_MODULE, dataSourceProvider, SocialAuthConfig), _define_property(this, "cacheManager", void 0), _define_property(this, "utilsService", void 0), this.cacheManager = cacheManager, this.utilsService = utilsService;
78
+ }
79
+ }
80
+ SocialAuthConfigService = _ts_decorate([
81
+ Injectable({
82
+ scope: Scope.REQUEST
83
+ }),
84
+ _ts_param(0, Inject('CACHE_INSTANCE')),
85
+ _ts_param(1, Inject(UtilsService)),
86
+ _ts_param(2, Inject(AuthDataSourceProvider)),
87
+ _ts_metadata("design:type", Function),
88
+ _ts_metadata("design:paramtypes", [
89
+ typeof HybridCache === "undefined" ? Object : HybridCache,
90
+ typeof UtilsService === "undefined" ? Object : UtilsService,
91
+ typeof AuthDataSourceProvider === "undefined" ? Object : AuthDataSourceProvider
92
+ ])
93
+ ], SocialAuthConfigService);
@@ -0,0 +1,67 @@
1
+ import { ConflictException } from '@nestjs/common';
2
+ import { createMockRepository } from '@test-utils/mocks/repository.mock';
3
+ import { Not } from 'typeorm';
4
+ import { SocialProviderTypeEnum } from '../enums/social-provider-type.enum';
5
+ import { SocialAuthConfigService } from './social-auth-config.service';
6
+ describe('SocialAuthConfigService', ()=>{
7
+ let repo;
8
+ let service;
9
+ beforeEach(()=>{
10
+ repo = createMockRepository();
11
+ const dataSourceProvider = {
12
+ getRepository: jest.fn().mockResolvedValue(repo)
13
+ };
14
+ service = new SocialAuthConfigService({
15
+ get: jest.fn(),
16
+ set: jest.fn(),
17
+ del: jest.fn()
18
+ }, {}, dataSourceProvider);
19
+ });
20
+ const beforeInsert = (dto)=>service.beforeInsertOperation(dto, null, {});
21
+ const beforeUpdate = (dto)=>service.beforeUpdateOperation(dto, null, {});
22
+ it('allows the first configuration of a provider', async ()=>{
23
+ repo.findOne.mockResolvedValue(null);
24
+ await expect(beforeInsert({
25
+ provider: SocialProviderTypeEnum.GOOGLE
26
+ })).resolves.toBeUndefined();
27
+ });
28
+ it('rejects a second row for a provider that is already configured', async ()=>{
29
+ repo.findOne.mockResolvedValue({
30
+ id: 'existing'
31
+ });
32
+ await expect(beforeInsert({
33
+ provider: SocialProviderTypeEnum.GOOGLE
34
+ })).rejects.toThrow(ConflictException);
35
+ });
36
+ it('checks every provider in a batch insert', async ()=>{
37
+ repo.findOne.mockResolvedValue(null);
38
+ await beforeInsert([
39
+ {
40
+ provider: SocialProviderTypeEnum.GOOGLE
41
+ },
42
+ {
43
+ provider: SocialProviderTypeEnum.FACEBOOK
44
+ }
45
+ ]);
46
+ expect(repo.findOne).toHaveBeenCalledTimes(2);
47
+ });
48
+ it('lets a row keep its own provider on update', async ()=>{
49
+ repo.findOne.mockResolvedValue(null);
50
+ await beforeUpdate({
51
+ id: 'config-1',
52
+ provider: SocialProviderTypeEnum.GOOGLE
53
+ });
54
+ expect(repo.findOne).toHaveBeenCalledWith(expect.objectContaining({
55
+ where: expect.objectContaining({
56
+ id: Not('config-1')
57
+ })
58
+ }));
59
+ });
60
+ it('skips the check for an update that does not touch the provider', async ()=>{
61
+ await beforeUpdate({
62
+ id: 'config-1',
63
+ isEnabled: false
64
+ });
65
+ expect(repo.findOne).not.toHaveBeenCalled();
66
+ });
67
+ });