@hed-hog/core 0.0.371 → 0.0.374

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 (107) hide show
  1. package/README.md +83 -1
  2. package/dist/integration-profile/integration-profile.secrets.d.ts.map +1 -1
  3. package/dist/integration-profile/integration-profile.secrets.js +4 -0
  4. package/dist/integration-profile/integration-profile.secrets.js.map +1 -1
  5. package/dist/integration-profile/integration-profile.utils.d.ts +6 -0
  6. package/dist/integration-profile/integration-profile.utils.d.ts.map +1 -1
  7. package/dist/integration-profile/integration-profile.utils.js +1 -1
  8. package/dist/integration-profile/integration-profile.utils.js.map +1 -1
  9. package/dist/oauth/oauth.controller.d.ts +11 -3
  10. package/dist/oauth/oauth.controller.d.ts.map +1 -1
  11. package/dist/oauth/oauth.controller.js +52 -14
  12. package/dist/oauth/oauth.controller.js.map +1 -1
  13. package/dist/oauth/oauth.module.d.ts.map +1 -1
  14. package/dist/oauth/oauth.module.js +4 -0
  15. package/dist/oauth/oauth.module.js.map +1 -1
  16. package/dist/oauth/oauth.service.d.ts +38 -2
  17. package/dist/oauth/oauth.service.d.ts.map +1 -1
  18. package/dist/oauth/oauth.service.js +122 -5
  19. package/dist/oauth/oauth.service.js.map +1 -1
  20. package/dist/oauth/providers/apple.provider.d.ts +34 -0
  21. package/dist/oauth/providers/apple.provider.d.ts.map +1 -0
  22. package/dist/oauth/providers/apple.provider.js +197 -0
  23. package/dist/oauth/providers/apple.provider.js.map +1 -0
  24. package/dist/oauth/providers/facebook.provider.d.ts +1 -1
  25. package/dist/oauth/providers/facebook.provider.d.ts.map +1 -1
  26. package/dist/oauth/providers/facebook.provider.js +3 -2
  27. package/dist/oauth/providers/facebook.provider.js.map +1 -1
  28. package/dist/oauth/providers/google.provider.d.ts +1 -1
  29. package/dist/oauth/providers/google.provider.d.ts.map +1 -1
  30. package/dist/oauth/providers/google.provider.js +3 -2
  31. package/dist/oauth/providers/google.provider.js.map +1 -1
  32. package/dist/oauth/providers/linkedin.provider.d.ts +25 -0
  33. package/dist/oauth/providers/linkedin.provider.d.ts.map +1 -0
  34. package/dist/oauth/providers/linkedin.provider.js +99 -0
  35. package/dist/oauth/providers/linkedin.provider.js.map +1 -0
  36. package/dist/oauth/providers/microsoft-entra-id.provider.js +1 -1
  37. package/dist/oauth/providers/microsoft-entra-id.provider.js.map +1 -1
  38. package/dist/oauth/providers/microsoft.provider.d.ts +1 -1
  39. package/dist/oauth/providers/microsoft.provider.d.ts.map +1 -1
  40. package/dist/oauth/providers/microsoft.provider.js +3 -2
  41. package/dist/oauth/providers/microsoft.provider.js.map +1 -1
  42. package/dist/pdf/pdf.service.d.ts.map +1 -1
  43. package/dist/pdf/pdf.service.js +19 -2
  44. package/dist/pdf/pdf.service.js.map +1 -1
  45. package/dist/setting/setting.service.d.ts.map +1 -1
  46. package/dist/setting/setting.service.js +4 -1
  47. package/dist/setting/setting.service.js.map +1 -1
  48. package/hedhog/data/integration_provider.yaml +16 -0
  49. package/hedhog/data/setting_group.yaml +98 -0
  50. package/hedhog/data/setting_subgroup.yaml +16 -0
  51. package/hedhog/frontend/app/configurations/[slug]/components/setting-field.tsx.ejs +2 -0
  52. package/hedhog/frontend/app/dashboard/dashboard.css.ejs +11 -11
  53. package/hedhog/frontend/app/integration/profiles/page.tsx.ejs +57 -9
  54. package/hedhog/frontend/app/menu/page.tsx.ejs +1 -1
  55. package/hedhog/frontend/app/users/user-sheet.tsx.ejs +8 -0
  56. package/hedhog/frontend/messages/en.json +35 -0
  57. package/hedhog/frontend/messages/pt.json +35 -0
  58. package/hedhog/table/user_account.yaml +1 -0
  59. package/package.json +4 -4
  60. package/src/access-log/access-log.service.spec.ts +235 -0
  61. package/src/ai-instruction/ai-prompt-builder.service.spec.ts +250 -0
  62. package/src/auth/auth.service.spec.ts +891 -0
  63. package/src/auth/guards/auth.guard.spec.ts +177 -0
  64. package/src/challenge/challenge.service.spec.ts +334 -0
  65. package/src/dashboard/dashboard-core/dashboard-core.service.spec.ts +193 -0
  66. package/src/file/image-optimization.service.spec.ts +283 -0
  67. package/src/file/provider/provider.factory.spec.ts +111 -0
  68. package/src/install/install.service.spec.ts +139 -0
  69. package/src/integration/services/event-subscriber.registry.spec.ts +143 -0
  70. package/src/integration/services/inbox.service.spec.ts +183 -0
  71. package/src/integration/services/integration-link.service.spec.ts +217 -0
  72. package/src/integration/services/outbox.service.spec.ts +290 -0
  73. package/src/integration-profile/integration-credential-crypto.service.spec.ts +289 -0
  74. package/src/integration-profile/integration-profile.secrets.ts +4 -0
  75. package/src/integration-profile/integration-profile.utils.spec.ts +332 -0
  76. package/src/integration-profile/integration-profile.utils.ts +14 -1
  77. package/src/locale/locale.service.spec.ts +78 -0
  78. package/src/mail/mail.service.spec.ts +257 -0
  79. package/src/mcp/guards/mcp-auth.guard.spec.ts +87 -0
  80. package/src/mcp/guards/mcp-permission.guard.spec.ts +64 -0
  81. package/src/mcp/mcp-api-key.service.spec.ts +125 -0
  82. package/src/mcp/mcp-registry.service.spec.ts +96 -0
  83. package/src/mcp/mcp-server.service.spec.ts +187 -0
  84. package/src/menu/menu.service.spec.ts +76 -0
  85. package/src/notification/notification.service.spec.ts +297 -0
  86. package/src/oauth/oauth.controller.ts +53 -10
  87. package/src/oauth/oauth.module.ts +4 -0
  88. package/src/oauth/oauth.service.spec.ts +332 -0
  89. package/src/oauth/oauth.service.ts +156 -1
  90. package/src/oauth/providers/apple.provider.ts +175 -0
  91. package/src/oauth/providers/facebook.provider.ts +3 -2
  92. package/src/oauth/providers/google.provider.ts +3 -2
  93. package/src/oauth/providers/linkedin.provider.ts +96 -0
  94. package/src/oauth/providers/microsoft-entra-id.provider.ts +1 -1
  95. package/src/oauth/providers/microsoft.provider.ts +3 -2
  96. package/src/pdf/pdf.service.ts +20 -2
  97. package/src/role/guards/role.guard.spec.ts +157 -0
  98. package/src/role/role.service.spec.ts +179 -0
  99. package/src/session/session.service.spec.ts +376 -0
  100. package/src/setting/setting.service.spec.ts +316 -0
  101. package/src/setting/setting.service.ts +4 -1
  102. package/src/task/task.service.spec.ts +47 -0
  103. package/src/token/token.service.spec.ts +351 -0
  104. package/src/user/user.service.spec.ts +256 -0
  105. package/src/utils/locale-context.spec.ts +22 -0
  106. package/src/utils/webauthn-origin.spec.ts +73 -0
  107. package/src/webhook-integration/integration-event-catalog.service.spec.ts +149 -0
@@ -11,6 +11,7 @@ import {
11
11
  Ip,
12
12
  Logger,
13
13
  Param,
14
+ Post,
14
15
  Query,
15
16
  Res,
16
17
  } from '@nestjs/common';
@@ -62,9 +63,37 @@ export class OAuthController {
62
63
  @Query('state') state: string,
63
64
  @Res() res,
64
65
  ) {
65
- const type = state || 'login';
66
+ const forward = await this.service.resolveWebForward(state);
67
+ const type = forward?.flow ?? (state || 'login');
66
68
  const { url: frontendURL = '' } = await this.settingService.getSettingValues(['url']);
67
- const redirectURL = `${frontendURL}/callback/github/${type}?code=${encodeURIComponent(code)}`;
69
+ const base = forward?.origin || frontendURL;
70
+ // Preserve the signed state on web-hub flows so the initiating app's callback
71
+ // can pass it back for origin binding at the code exchange.
72
+ const stateParam = forward ? `&state=${encodeURIComponent(state)}` : '';
73
+ const redirectURL = `${base}/callback/github/${type}?code=${encodeURIComponent(code)}${stateParam}`;
74
+ return res.redirect(redirectURL);
75
+ }
76
+
77
+ /**
78
+ * Apple mandates `response_mode=form_post` whenever `scope` is requested, so it
79
+ * POSTs `code`/`state` to the single registered "Return URL" instead of a GET
80
+ * redirect. This bounces it back to a GET, mirroring the GitHub callback above,
81
+ * so the rest of the pipeline (hub forwarding, code exchange) stays identical
82
+ * across every provider.
83
+ */
84
+ @Public()
85
+ @Post('apple/callback')
86
+ async appleCallback(
87
+ @Body('code') code: string,
88
+ @Body('state') state: string,
89
+ @Res() res,
90
+ ) {
91
+ const forward = await this.service.resolveWebForward(state);
92
+ const type = forward?.flow ?? (state || 'login');
93
+ const { url: frontendURL = '' } = await this.settingService.getSettingValues(['url']);
94
+ const base = forward?.origin || frontendURL;
95
+ const stateParam = forward ? `&state=${encodeURIComponent(state)}` : '';
96
+ const redirectURL = `${base}/callback/apple/${type}?code=${encodeURIComponent(code)}${stateParam}`;
68
97
  return res.redirect(redirectURL);
69
98
  }
70
99
 
@@ -75,13 +104,15 @@ export class OAuthController {
75
104
  @Headers('referer') referer: string,
76
105
  @Headers('host') host: string,
77
106
  @Param('provider') provider: string,
107
+ @Query('redirectApp') redirectApp: string,
78
108
  @Res() res,
79
109
  ) {
80
110
  this.logOAuthRequest('login-init', provider, { origin, referer, host });
81
111
 
82
- const url = await this.service.getAuthUrl(
112
+ const url = await this.service.getWebAuthUrl(
83
113
  provider as user_account_provider_52222e2ecb_enum,
84
- `/callback/${provider}/login`,
114
+ 'login',
115
+ redirectApp,
85
116
  );
86
117
  return res.redirect(url);
87
118
  }
@@ -104,10 +135,15 @@ export class OAuthController {
104
135
 
105
136
  @Public()
106
137
  @Get(':provider/register')
107
- async register(@Param('provider') provider: string, @Res() res) {
108
- const url = await this.service.getAuthUrl(
138
+ async register(
139
+ @Param('provider') provider: string,
140
+ @Query('redirectApp') redirectApp: string,
141
+ @Res() res,
142
+ ) {
143
+ const url = await this.service.getWebAuthUrl(
109
144
  provider as user_account_provider_52222e2ecb_enum,
110
- `/callback/${provider}/register`,
145
+ 'register',
146
+ redirectApp,
111
147
  );
112
148
  return res.redirect(url);
113
149
  }
@@ -115,10 +151,15 @@ export class OAuthController {
115
151
 
116
152
  @Public()
117
153
  @Get(':provider/connect')
118
- async connect(@Param('provider') provider: string, @Res() res) {
119
- const url = await this.service.getAuthUrl(
154
+ async connect(
155
+ @Param('provider') provider: string,
156
+ @Query('redirectApp') redirectApp: string,
157
+ @Res() res,
158
+ ) {
159
+ const url = await this.service.getWebAuthUrl(
120
160
  provider as user_account_provider_52222e2ecb_enum,
121
- `/callback/${provider}/connect`,
161
+ 'connect',
162
+ redirectApp,
122
163
  );
123
164
  return res.redirect(url);
124
165
  }
@@ -161,6 +202,8 @@ export class OAuthController {
161
202
  type: 'login',
162
203
  redirectUri,
163
204
  includeRefreshTokenInBody: Boolean(redirectUri),
205
+ requestOrigin: origin,
206
+ requestReferer: referer,
164
207
  });
165
208
  }
166
209
 
@@ -12,9 +12,11 @@ import { UserModule } from '../user/user.module';
12
12
  import { OAuthCallbackCoordinatorService } from './oauth-callback-coordinator.service';
13
13
  import { OAuthController } from './oauth.controller';
14
14
  import { OAuthService } from './oauth.service';
15
+ import { AppleProvider } from './providers/apple.provider';
15
16
  import { FacebookProvider } from './providers/facebook.provider';
16
17
  import { GithubProvider } from './providers/github.provider';
17
18
  import { GoogleProvider } from './providers/google.provider';
19
+ import { LinkedinProvider } from './providers/linkedin.provider';
18
20
  import { MicrosoftEntraIdProvider } from './providers/microsoft-entra-id.provider';
19
21
  import { MicrosoftProvider } from './providers/microsoft.provider';
20
22
 
@@ -40,6 +42,8 @@ import { MicrosoftProvider } from './providers/microsoft.provider';
40
42
  GithubProvider,
41
43
  MicrosoftProvider,
42
44
  MicrosoftEntraIdProvider,
45
+ AppleProvider,
46
+ LinkedinProvider,
43
47
  ],
44
48
  exports: [OAuthService],
45
49
  })
@@ -0,0 +1,332 @@
1
+ import { describe, it, expect, beforeEach, afterEach, jest } from '@jest/globals';
2
+ import {
3
+ BadRequestException,
4
+ ConflictException,
5
+ ServiceUnavailableException,
6
+ } from '@nestjs/common';
7
+ import { OAuthService } from './oauth.service';
8
+ import {
9
+ OAuthCallbackAlreadyProcessedError,
10
+ OAuthCallbackInProgressError,
11
+ OAuthProviderException,
12
+ } from './oauth.errors';
13
+
14
+ const makeProvider = (type: string) => ({
15
+ getProviderType: () => type,
16
+ getAuthUrl: jest.fn(),
17
+ getProfile: jest.fn(),
18
+ });
19
+
20
+ const makeDeps = () => {
21
+ const google = makeProvider('GOOGLE');
22
+ const facebook = makeProvider('FACEBOOK');
23
+ const microsoft = makeProvider('MICROSOFT');
24
+ const github = makeProvider('GITHUB');
25
+ const microsoftEntraId = makeProvider('MICROSOFT_ENTRA_ID');
26
+ const apple = makeProvider('APPLE');
27
+ const linkedin = makeProvider('LINKEDIN');
28
+ const callbackCoordinator = { execute: jest.fn() };
29
+ const auth = { getAuthenticationPayload: jest.fn() };
30
+ const file = { upload: jest.fn() };
31
+ const mail = { sendTemplatedMail: jest.fn() };
32
+ const security = {
33
+ hashSha256: jest.fn().mockReturnValue('HASH'),
34
+ randomOpaque: jest.fn().mockReturnValue('TOK'),
35
+ signHmacSha256: jest.fn().mockReturnValue('SIG'),
36
+ verifyHmacSha256: jest.fn().mockReturnValue(true),
37
+ };
38
+ const prisma = {
39
+ $executeRaw: jest.fn(),
40
+ oauth_mobile_state_token: { findFirst: jest.fn() },
41
+ };
42
+ const token = { setRefreshTokenCookie: jest.fn() };
43
+ const setting = { getSettingValues: jest.fn().mockResolvedValue({}) };
44
+ const user = { registerUserActivity: jest.fn() };
45
+ return {
46
+ google, facebook, microsoft, github, microsoftEntraId, apple, linkedin,
47
+ callbackCoordinator, auth, file, mail, security, prisma, token, setting, user,
48
+ };
49
+ };
50
+
51
+ const makeService = (deps = makeDeps()) => ({
52
+ service: new OAuthService(
53
+ deps.google as any,
54
+ deps.facebook as any,
55
+ deps.microsoft as any,
56
+ deps.github as any,
57
+ deps.microsoftEntraId as any,
58
+ deps.apple as any,
59
+ deps.linkedin as any,
60
+ deps.callbackCoordinator as any,
61
+ deps.auth as any,
62
+ deps.file as any,
63
+ deps.mail as any,
64
+ deps.security as any,
65
+ deps.prisma as any,
66
+ deps.token as any,
67
+ deps.setting as any,
68
+ deps.user as any,
69
+ ),
70
+ deps,
71
+ });
72
+
73
+ describe('OAuthService', () => {
74
+ afterEach(() => jest.clearAllMocks());
75
+
76
+ describe('getProvider', () => {
77
+ it('resolve provider por chave uppercase', () => {
78
+ const { service, deps } = makeService();
79
+ expect((service as any).getProvider('google')).toBe(deps.google);
80
+ });
81
+ it('lança para provider não suportado', () => {
82
+ const { service } = makeService();
83
+ expect(() => (service as any).getProvider('unknown')).toThrow(
84
+ BadRequestException,
85
+ );
86
+ });
87
+ });
88
+
89
+ describe('isProviderEnabled', () => {
90
+ it('false quando slug desconhecido', async () => {
91
+ const { service } = makeService();
92
+ await expect(service.isProviderEnabled('nope' as any)).resolves.toBe(false);
93
+ });
94
+ it('true quando setting boolean true', async () => {
95
+ const { service, deps } = makeService();
96
+ deps.setting.getSettingValues.mockResolvedValue({ 'oauth-google-enabled': true });
97
+ await expect(service.isProviderEnabled('google' as any)).resolves.toBe(true);
98
+ });
99
+ it('true quando setting string "true"', async () => {
100
+ const { service, deps } = makeService();
101
+ deps.setting.getSettingValues.mockResolvedValue({ 'oauth-google-enabled': 'true' });
102
+ await expect(service.isProviderEnabled('google' as any)).resolves.toBe(true);
103
+ });
104
+ it('false quando setting desligado', async () => {
105
+ const { service, deps } = makeService();
106
+ deps.setting.getSettingValues.mockResolvedValue({ 'oauth-google-enabled': false });
107
+ await expect(service.isProviderEnabled('google' as any)).resolves.toBe(false);
108
+ });
109
+ });
110
+
111
+ describe('getWebAuthUrl', () => {
112
+ it('assina o app iniciador e o fluxo no state', async () => {
113
+ const { service, deps } = makeService();
114
+ deps.setting.getSettingValues.mockResolvedValue({ 'oauth-google-enabled': true });
115
+ deps.google.getAuthUrl.mockResolvedValue(
116
+ 'https://accounts.google.com/o/oauth2/v2/auth?client_id=x',
117
+ );
118
+
119
+ const url = await service.getWebAuthUrl('google' as any, 'login', 'training');
120
+
121
+ expect(url).toContain('state=hhweb.training.login.SIG');
122
+ });
123
+
124
+ it('usa o sentinel "self" no state quando redirectApp ausente', async () => {
125
+ const { service, deps } = makeService();
126
+ deps.setting.getSettingValues.mockResolvedValue({ 'oauth-google-enabled': true });
127
+ deps.google.getAuthUrl.mockResolvedValue('https://accounts.google.com/auth?client_id=x');
128
+
129
+ const url = await service.getWebAuthUrl('google' as any, 'register');
130
+
131
+ expect(url).toContain('state=hhweb.self.register.SIG');
132
+ });
133
+
134
+ it('cai para "self" quando redirectApp tem formato inválido', async () => {
135
+ const { service, deps } = makeService();
136
+ deps.setting.getSettingValues.mockResolvedValue({ 'oauth-google-enabled': true });
137
+ deps.google.getAuthUrl.mockResolvedValue('https://accounts.google.com/auth?client_id=x');
138
+
139
+ const url = await service.getWebAuthUrl('google' as any, 'login', 'app inválido!!');
140
+
141
+ expect(url).toContain('state=hhweb.self.login.SIG');
142
+ });
143
+ });
144
+
145
+ describe('resolveWebForward', () => {
146
+ it('resolve app e flow do state assinado válido', async () => {
147
+ const { service, deps } = makeService();
148
+ deps.setting.getSettingValues.mockResolvedValue({
149
+ 'app-urls': ['training=http://localhost:3001'],
150
+ });
151
+
152
+ await expect(
153
+ service.resolveWebForward('hhweb.training.login.SIG'),
154
+ ).resolves.toEqual({ origin: 'http://localhost:3001', flow: 'login' });
155
+ });
156
+
157
+ it('retorna null para state que não é hhweb', async () => {
158
+ const { service } = makeService();
159
+ await expect(service.resolveWebForward('login')).resolves.toBeNull();
160
+ });
161
+
162
+ it('retorna null quando a assinatura é inválida', async () => {
163
+ const { service, deps } = makeService();
164
+ deps.security.verifyHmacSha256.mockReturnValue(false);
165
+ await expect(
166
+ service.resolveWebForward('hhweb.training.login.SIG'),
167
+ ).resolves.toBeNull();
168
+ });
169
+ });
170
+
171
+ describe('getProviderScopes', () => {
172
+ it('vazio quando provider sem slug', async () => {
173
+ const { service } = makeService();
174
+ await expect((service as any).getProviderScopes('nope')).resolves.toBe('');
175
+ });
176
+ it('junta array por vírgula', async () => {
177
+ const { service, deps } = makeService();
178
+ deps.setting.getSettingValues.mockResolvedValue({
179
+ 'oauth-google-scopes': ['a', 'b'],
180
+ });
181
+ await expect((service as any).getProviderScopes('google')).resolves.toBe('a,b');
182
+ });
183
+ it('retorna string bruta', async () => {
184
+ const { service, deps } = makeService();
185
+ deps.setting.getSettingValues.mockResolvedValue({ 'oauth-google-scopes': 'x' });
186
+ await expect((service as any).getProviderScopes('google')).resolves.toBe('x');
187
+ });
188
+ });
189
+
190
+ describe('buildCallbackKey', () => {
191
+ it('formata provider:type:hash', () => {
192
+ const { service, deps } = makeService();
193
+ expect((service as any).buildCallbackKey('Google', 'login', 'code')).toBe(
194
+ 'google:login:HASH',
195
+ );
196
+ expect(deps.security.hashSha256).toHaveBeenCalledWith('code');
197
+ });
198
+ });
199
+
200
+ describe('summarizeUserAgent', () => {
201
+ let service: OAuthService;
202
+ beforeEach(() => {
203
+ service = makeService().service;
204
+ });
205
+ const call = (v?: string) => (service as any).summarizeUserAgent(v);
206
+
207
+ it('n/a quando ausente', () => {
208
+ expect(call(undefined)).toBe('n/a');
209
+ });
210
+ it('mantém curto', () => {
211
+ expect(call('short')).toBe('short');
212
+ });
213
+ it('trunca > 120 chars', () => {
214
+ const long = 'a'.repeat(200);
215
+ const result = call(long);
216
+ expect(result.endsWith('...')).toBe(true);
217
+ expect(result).toHaveLength(120);
218
+ });
219
+ });
220
+
221
+ describe('formatAuthResponse', () => {
222
+ let service: OAuthService;
223
+ beforeEach(() => {
224
+ service = makeService().service;
225
+ });
226
+
227
+ it('inclui refresh quando solicitado', () => {
228
+ expect((service as any).formatAuthResponse('a', 'r', true)).toEqual({
229
+ accessToken: 'a',
230
+ refreshToken: 'r',
231
+ });
232
+ });
233
+ it('omite refresh por padrão', () => {
234
+ expect((service as any).formatAuthResponse('a', 'r')).toEqual({
235
+ accessToken: 'a',
236
+ });
237
+ });
238
+ });
239
+
240
+ describe('resolveProfileScopes', () => {
241
+ it('usa oauth_scopes do profile quando string', () => {
242
+ const { service } = makeService();
243
+ expect(
244
+ (service as any).resolveProfileScopes('google', { oauth_scopes: 's1' }),
245
+ ).toBe('s1');
246
+ });
247
+ it('cai para settings quando ausente', async () => {
248
+ const { service, deps } = makeService();
249
+ deps.setting.getSettingValues.mockResolvedValue({ 'oauth-google-scopes': 's2' });
250
+ await expect(
251
+ (service as any).resolveProfileScopes('google', {}),
252
+ ).resolves.toBe('s2');
253
+ });
254
+ });
255
+
256
+ describe('mapCallbackError', () => {
257
+ let service: OAuthService;
258
+ beforeEach(() => {
259
+ service = makeService().service;
260
+ });
261
+ const map = (err: unknown) =>
262
+ (service as any).mapCallbackError('en', 'google', 'login', err);
263
+
264
+ it('repassa exceções HTTP conhecidas', () => {
265
+ const err = new BadRequestException('x');
266
+ expect(map(err)).toBe(err);
267
+ });
268
+ it('mapeia AlreadyProcessed para BadRequest', () => {
269
+ expect(map(new OAuthCallbackAlreadyProcessedError())).toBeInstanceOf(
270
+ BadRequestException,
271
+ );
272
+ });
273
+ it('mapeia InProgress para Conflict', () => {
274
+ expect(map(new OAuthCallbackInProgressError())).toBeInstanceOf(
275
+ ConflictException,
276
+ );
277
+ });
278
+ it('mapeia OAuthProviderException invalid_callback para BadRequest', () => {
279
+ expect(
280
+ map(new OAuthProviderException('google', 'invalid_callback', 'msg')),
281
+ ).toBeInstanceOf(BadRequestException);
282
+ });
283
+ it('mapeia OAuthProviderException upstream para ServiceUnavailable', () => {
284
+ expect(
285
+ map(new OAuthProviderException('google', 'upstream_failure', 'msg', 500)),
286
+ ).toBeInstanceOf(ServiceUnavailableException);
287
+ });
288
+ it('mapeia erro genérico para ServiceUnavailable', () => {
289
+ expect(map(new Error('boom'))).toBeInstanceOf(ServiceUnavailableException);
290
+ });
291
+ });
292
+
293
+ describe('consumeMobileState', () => {
294
+ it('false sem state ou não-hhmob', async () => {
295
+ const { service } = makeService();
296
+ await expect((service as any).consumeMobileState(undefined, 'google', 'app://x')).resolves.toBe(false);
297
+ await expect((service as any).consumeMobileState('plain', 'google', 'app://x')).resolves.toBe(false);
298
+ });
299
+ it('false quando partes faltando', async () => {
300
+ const { service } = makeService();
301
+ await expect((service as any).consumeMobileState('hhmob.tok', 'google', 'app://x')).resolves.toBe(false);
302
+ });
303
+ it('false quando flow inválido', async () => {
304
+ const { service } = makeService();
305
+ await expect((service as any).consumeMobileState('hhmob.tok.sig.badflow', 'google', 'app://x')).resolves.toBe(false);
306
+ });
307
+ it('false quando assinatura inválida', async () => {
308
+ const { service, deps } = makeService();
309
+ deps.security.verifyHmacSha256.mockReturnValue(false);
310
+ await expect((service as any).consumeMobileState('hhmob.tok.sig.login', 'google', 'app://x')).resolves.toBe(false);
311
+ });
312
+ it('true quando consome linha no banco', async () => {
313
+ const { service, deps } = makeService();
314
+ deps.prisma.$executeRaw.mockResolvedValue(1);
315
+ await expect((service as any).consumeMobileState('hhmob.tok.sig.login', 'google', 'app://x')).resolves.toBe(true);
316
+ });
317
+ it('false quando nenhuma linha consumida', async () => {
318
+ const { service, deps } = makeService();
319
+ deps.prisma.$executeRaw.mockResolvedValue(0);
320
+ await expect((service as any).consumeMobileState('hhmob.tok.sig.login', 'google', 'app://x')).resolves.toBe(false);
321
+ });
322
+ });
323
+
324
+ describe('getMobileAuthUrl', () => {
325
+ it('lança para redirect URI inválido', async () => {
326
+ const { service } = makeService();
327
+ await expect(
328
+ service.getMobileAuthUrl('google' as any, 'not-a-uri'),
329
+ ).rejects.toThrow(BadRequestException);
330
+ });
331
+ });
332
+ });