@etus/bhono-app 0.1.6 → 0.1.7
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/package.json +3 -2
- package/templates/base/.claude/commands/check-skill-rules.md +112 -29
- package/templates/base/.claude/commands/linear/implement-issue.md +383 -55
- package/templates/base/.claude/commands/ship.md +77 -13
- package/templates/base/.claude/hooks/package-lock.json +0 -419
- package/templates/base/.claude/hooks/skill-activation-prompt.ts +185 -113
- package/templates/base/.claude/hooks/skill-tool-guard.sh +6 -0
- package/templates/base/.claude/hooks/skill-tool-guard.ts +198 -0
- package/templates/base/.claude/scripts/validate-skill-rules.sh +55 -32
- package/templates/base/.claude/settings.json +18 -11
- package/templates/base/.claude/skills/skill-rules.json +326 -173
- package/templates/base/.env.example +3 -0
- package/templates/base/README.md +9 -7
- package/templates/base/config/eslint.config.js +1 -0
- package/templates/base/config/wrangler.json +16 -17
- package/templates/base/docs/SETUP-GUIDE.md +566 -0
- package/templates/base/docs/architecture/README.md +162 -8
- package/templates/base/docs/architecture/api-catalog.md +575 -0
- package/templates/base/docs/architecture/c4-component.md +309 -0
- package/templates/base/docs/architecture/c4-container.md +183 -0
- package/templates/base/docs/architecture/c4-context.md +106 -0
- package/templates/base/docs/architecture/dependencies.md +327 -0
- package/templates/base/docs/architecture/tech-debt.md +184 -0
- package/templates/base/package.json +20 -15
- package/templates/base/scripts/capture-prod-session.ts +2 -2
- package/templates/base/scripts/sync-template.sh +104 -0
- package/templates/base/src/server/db/sql.ts +24 -4
- package/templates/base/src/server/index.ts +1 -0
- package/templates/base/src/server/lib/audited-db.ts +10 -10
- package/templates/base/src/server/middleware/account.ts +1 -1
- package/templates/base/src/server/middleware/auth.ts +11 -11
- package/templates/base/src/server/middleware/rate-limit.ts +3 -6
- package/templates/base/src/server/routes/auth/handlers.ts +5 -5
- package/templates/base/src/server/routes/auth/test-login.ts +9 -9
- package/templates/base/src/server/routes/index.ts +9 -0
- package/templates/base/src/server/routes/invitations/handlers.ts +6 -6
- package/templates/base/src/server/routes/openapi.ts +1 -1
- package/templates/base/src/server/services/accounts.ts +9 -9
- package/templates/base/src/server/services/audits.ts +12 -12
- package/templates/base/src/server/services/auth.ts +15 -15
- package/templates/base/src/server/services/invitations.ts +16 -16
- package/templates/base/src/server/services/users.ts +13 -13
- package/templates/base/src/shared/types/api.ts +66 -198
- package/templates/base/tests/e2e/auth.setup.ts +1 -1
- package/templates/base/tests/unit/server/auth/guards.test.ts +1 -1
- package/templates/base/tests/unit/server/middleware/auth.test.ts +273 -0
- package/templates/base/tests/unit/server/routes/auth/handlers.test.ts +111 -0
- package/templates/base/tests/unit/server/routes/users/handlers.test.ts +69 -5
- package/templates/base/tests/unit/server/services/accounts.test.ts +148 -0
- package/templates/base/tests/unit/server/services/audits.test.ts +219 -0
- package/templates/base/tests/unit/server/services/auth.test.ts +480 -3
- package/templates/base/tests/unit/server/services/invitations.test.ts +178 -0
- package/templates/base/tests/unit/server/services/users.test.ts +363 -8
- package/templates/base/tests/unit/shared/schemas.test.ts +1 -1
- package/templates/base/vite.config.ts +3 -1
- package/templates/base/.github/workflows/test.yml +0 -127
- package/templates/base/.husky/pre-push +0 -26
- package/templates/base/auth-setup-error.png +0 -0
- package/templates/base/pnpm-lock.yaml +0 -8052
- package/templates/base/tests/e2e/_auth/.gitkeep +0 -0
- package/templates/base/tsconfig.tsbuildinfo +0 -1
|
@@ -18,6 +18,17 @@ vi.mock('@server/lib/audit', () => ({
|
|
|
18
18
|
vi.mock('@server/db/sql', () => ({
|
|
19
19
|
queryOne: vi.fn(),
|
|
20
20
|
execute: vi.fn(),
|
|
21
|
+
toStringValue: (value: unknown) => {
|
|
22
|
+
if (typeof value === 'string') return value
|
|
23
|
+
if (typeof value === 'number' || typeof value === 'bigint') return String(value)
|
|
24
|
+
return ''
|
|
25
|
+
},
|
|
26
|
+
toNullableString: (value: unknown) => {
|
|
27
|
+
if (value === null || value === undefined) return null
|
|
28
|
+
if (typeof value === 'string') return value
|
|
29
|
+
if (typeof value === 'number' || typeof value === 'bigint') return String(value)
|
|
30
|
+
return null
|
|
31
|
+
},
|
|
21
32
|
}))
|
|
22
33
|
|
|
23
34
|
import { createAccessToken, generateRefreshToken, hashToken } from '@server/lib/tokens'
|
|
@@ -101,7 +112,7 @@ describe('authService', () => {
|
|
|
101
112
|
email: 'existing@gmail.com',
|
|
102
113
|
email_verified: true,
|
|
103
114
|
name: 'Existing User',
|
|
104
|
-
picture:
|
|
115
|
+
picture: undefined,
|
|
105
116
|
given_name: 'Existing',
|
|
106
117
|
family_name: 'User',
|
|
107
118
|
}
|
|
@@ -168,12 +179,478 @@ describe('authService', () => {
|
|
|
168
179
|
|
|
169
180
|
describe('revokeRefreshToken', () => {
|
|
170
181
|
it('should revoke refresh token and log logout', async () => {
|
|
171
|
-
const env = createMockEnv()
|
|
172
|
-
|
|
173
182
|
await authService.revokeRefreshToken(db, 'refresh-token', mockAuthContext, 'user-1')
|
|
174
183
|
|
|
175
184
|
expect(execute).toHaveBeenCalled()
|
|
176
185
|
expect(logAuthEvent).toHaveBeenCalled()
|
|
177
186
|
})
|
|
187
|
+
|
|
188
|
+
it('should revoke token without logging when userId is null', async () => {
|
|
189
|
+
await authService.revokeRefreshToken(db, 'refresh-token', mockAuthContext, null)
|
|
190
|
+
|
|
191
|
+
expect(execute).toHaveBeenCalled()
|
|
192
|
+
expect(logAuthEvent).not.toHaveBeenCalled()
|
|
193
|
+
})
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
describe('revokeAllUserTokens', () => {
|
|
197
|
+
it('should revoke all tokens for a user', async () => {
|
|
198
|
+
await authService.revokeAllUserTokens(db, 'user-1')
|
|
199
|
+
|
|
200
|
+
expect(execute).toHaveBeenCalled()
|
|
201
|
+
})
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
describe('getCurrentUser', () => {
|
|
205
|
+
it('should return user when found', async () => {
|
|
206
|
+
;(queryOne as Mock).mockResolvedValueOnce({
|
|
207
|
+
id: 'user-1',
|
|
208
|
+
googleId: 'google-1',
|
|
209
|
+
email: 'user@example.com',
|
|
210
|
+
name: 'User',
|
|
211
|
+
avatarUrl: null,
|
|
212
|
+
status: 'active',
|
|
213
|
+
providerIds: JSON.stringify(['google']),
|
|
214
|
+
isSuperAdmin: 0,
|
|
215
|
+
createdAt: new Date().toISOString(),
|
|
216
|
+
updatedAt: new Date().toISOString(),
|
|
217
|
+
deletedAt: null,
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
const result = await authService.getCurrentUser(db, 'user-1')
|
|
221
|
+
|
|
222
|
+
expect(result.email).toBe('user@example.com')
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
it('should throw UnauthorizedError when user not found', async () => {
|
|
226
|
+
;(queryOne as Mock).mockResolvedValueOnce(null)
|
|
227
|
+
|
|
228
|
+
await expect(authService.getCurrentUser(db, 'user-999')).rejects.toThrow(UnauthorizedError)
|
|
229
|
+
})
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
describe('findOrCreateUser edge cases', () => {
|
|
233
|
+
it('should not update user when info is unchanged', async () => {
|
|
234
|
+
const env = createMockEnv()
|
|
235
|
+
const googleUser: GoogleUserInfo = {
|
|
236
|
+
sub: 'existing_sub',
|
|
237
|
+
email: 'same@example.com',
|
|
238
|
+
email_verified: true,
|
|
239
|
+
name: 'Same Name',
|
|
240
|
+
picture: 'https://same-picture.jpg',
|
|
241
|
+
given_name: 'Same',
|
|
242
|
+
family_name: 'Name',
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const userRow = {
|
|
246
|
+
id: 'user-1',
|
|
247
|
+
googleId: googleUser.sub,
|
|
248
|
+
email: googleUser.email,
|
|
249
|
+
name: googleUser.name,
|
|
250
|
+
avatarUrl: googleUser.picture,
|
|
251
|
+
status: 'active',
|
|
252
|
+
providerIds: JSON.stringify(['google']),
|
|
253
|
+
isSuperAdmin: 0,
|
|
254
|
+
createdAt: new Date().toISOString(),
|
|
255
|
+
updatedAt: new Date().toISOString(),
|
|
256
|
+
deletedAt: null,
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
;(queryOne as Mock).mockResolvedValueOnce(userRow)
|
|
260
|
+
|
|
261
|
+
const result = await authService.findOrCreateUser(db, env, googleUser, mockAuthContext)
|
|
262
|
+
|
|
263
|
+
expect(result.isNewUser).toBe(false)
|
|
264
|
+
// Should only call execute for refresh token insert and login log
|
|
265
|
+
expect(execute).toHaveBeenCalled()
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
it('should update user when email changes', async () => {
|
|
269
|
+
const env = createMockEnv()
|
|
270
|
+
const googleUser: GoogleUserInfo = {
|
|
271
|
+
sub: 'existing_sub',
|
|
272
|
+
email: 'new-email@example.com',
|
|
273
|
+
email_verified: true,
|
|
274
|
+
name: 'Same Name',
|
|
275
|
+
picture: 'https://same-picture.jpg',
|
|
276
|
+
given_name: 'Same',
|
|
277
|
+
family_name: 'Name',
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const userRow = {
|
|
281
|
+
id: 'user-1',
|
|
282
|
+
googleId: googleUser.sub,
|
|
283
|
+
email: 'old@example.com',
|
|
284
|
+
name: googleUser.name,
|
|
285
|
+
avatarUrl: googleUser.picture,
|
|
286
|
+
status: 'active',
|
|
287
|
+
providerIds: JSON.stringify(['google']),
|
|
288
|
+
isSuperAdmin: 0,
|
|
289
|
+
createdAt: new Date().toISOString(),
|
|
290
|
+
updatedAt: new Date().toISOString(),
|
|
291
|
+
deletedAt: null,
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
;(queryOne as Mock)
|
|
295
|
+
.mockResolvedValueOnce(userRow)
|
|
296
|
+
.mockResolvedValueOnce({ ...userRow, email: googleUser.email })
|
|
297
|
+
|
|
298
|
+
const result = await authService.findOrCreateUser(db, env, googleUser, mockAuthContext)
|
|
299
|
+
|
|
300
|
+
expect(result.isNewUser).toBe(false)
|
|
301
|
+
expect(execute).toHaveBeenCalled()
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
it('should handle null picture', async () => {
|
|
305
|
+
const env = createMockEnv()
|
|
306
|
+
const googleUser: GoogleUserInfo = {
|
|
307
|
+
sub: 'new_google_sub',
|
|
308
|
+
email: 'user@example.com',
|
|
309
|
+
email_verified: true,
|
|
310
|
+
name: 'User',
|
|
311
|
+
picture: undefined,
|
|
312
|
+
given_name: 'User',
|
|
313
|
+
family_name: '',
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const userRow = {
|
|
317
|
+
id: 'user-new',
|
|
318
|
+
googleId: googleUser.sub,
|
|
319
|
+
email: googleUser.email,
|
|
320
|
+
name: googleUser.name,
|
|
321
|
+
avatarUrl: null,
|
|
322
|
+
status: 'active',
|
|
323
|
+
providerIds: JSON.stringify(['google']),
|
|
324
|
+
isSuperAdmin: 0,
|
|
325
|
+
createdAt: new Date().toISOString(),
|
|
326
|
+
updatedAt: new Date().toISOString(),
|
|
327
|
+
deletedAt: null,
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
;(queryOne as Mock)
|
|
331
|
+
.mockResolvedValueOnce(null)
|
|
332
|
+
.mockResolvedValueOnce(userRow)
|
|
333
|
+
|
|
334
|
+
const result = await authService.findOrCreateUser(db, env, googleUser, mockAuthContext)
|
|
335
|
+
|
|
336
|
+
expect(result.isNewUser).toBe(true)
|
|
337
|
+
})
|
|
338
|
+
})
|
|
339
|
+
|
|
340
|
+
describe('refreshAccessToken edge cases', () => {
|
|
341
|
+
it('should throw when user is inactive', async () => {
|
|
342
|
+
const env = createMockEnv()
|
|
343
|
+
|
|
344
|
+
;(queryOne as Mock)
|
|
345
|
+
.mockResolvedValueOnce({ id: 'token-1', userId: 'user-1', tokenHash: 'hashed-token' })
|
|
346
|
+
.mockResolvedValueOnce({
|
|
347
|
+
id: 'user-1',
|
|
348
|
+
googleId: 'google-1',
|
|
349
|
+
email: 'user@example.com',
|
|
350
|
+
name: 'User',
|
|
351
|
+
avatarUrl: null,
|
|
352
|
+
status: 'inactive',
|
|
353
|
+
providerIds: JSON.stringify(['google']),
|
|
354
|
+
isSuperAdmin: 0,
|
|
355
|
+
createdAt: new Date().toISOString(),
|
|
356
|
+
updatedAt: new Date().toISOString(),
|
|
357
|
+
deletedAt: null,
|
|
358
|
+
})
|
|
359
|
+
|
|
360
|
+
await expect(
|
|
361
|
+
authService.refreshAccessToken(db, env, 'refresh-token', mockAuthContext)
|
|
362
|
+
).rejects.toThrow('User not found or inactive')
|
|
363
|
+
})
|
|
364
|
+
|
|
365
|
+
it('should throw when user not found after token found', async () => {
|
|
366
|
+
const env = createMockEnv()
|
|
367
|
+
|
|
368
|
+
;(queryOne as Mock)
|
|
369
|
+
.mockResolvedValueOnce({ id: 'token-1', userId: 'user-1', tokenHash: 'hashed-token' })
|
|
370
|
+
.mockResolvedValueOnce(null)
|
|
371
|
+
|
|
372
|
+
await expect(
|
|
373
|
+
authService.refreshAccessToken(db, env, 'refresh-token', mockAuthContext)
|
|
374
|
+
).rejects.toThrow('User not found or inactive')
|
|
375
|
+
})
|
|
376
|
+
})
|
|
377
|
+
|
|
378
|
+
describe('mapUserRow edge cases', () => {
|
|
379
|
+
it('should handle isSuperAdmin as string "1"', async () => {
|
|
380
|
+
;(queryOne as Mock).mockResolvedValueOnce({
|
|
381
|
+
id: 'user-1',
|
|
382
|
+
googleId: 'google-1',
|
|
383
|
+
email: 'user@example.com',
|
|
384
|
+
name: 'User',
|
|
385
|
+
avatarUrl: null,
|
|
386
|
+
status: 'active',
|
|
387
|
+
providerIds: JSON.stringify(['google']),
|
|
388
|
+
isSuperAdmin: '1',
|
|
389
|
+
createdAt: new Date().toISOString(),
|
|
390
|
+
updatedAt: new Date().toISOString(),
|
|
391
|
+
deletedAt: null,
|
|
392
|
+
})
|
|
393
|
+
|
|
394
|
+
const result = await authService.getCurrentUser(db, 'user-1')
|
|
395
|
+
|
|
396
|
+
expect(result.isSuperAdmin).toBe(true)
|
|
397
|
+
})
|
|
398
|
+
|
|
399
|
+
it('should handle isSuperAdmin as string "true"', async () => {
|
|
400
|
+
;(queryOne as Mock).mockResolvedValueOnce({
|
|
401
|
+
id: 'user-1',
|
|
402
|
+
googleId: 'google-1',
|
|
403
|
+
email: 'user@example.com',
|
|
404
|
+
name: 'User',
|
|
405
|
+
avatarUrl: null,
|
|
406
|
+
status: 'active',
|
|
407
|
+
providerIds: JSON.stringify(['google']),
|
|
408
|
+
isSuperAdmin: 'true',
|
|
409
|
+
createdAt: new Date().toISOString(),
|
|
410
|
+
updatedAt: new Date().toISOString(),
|
|
411
|
+
deletedAt: null,
|
|
412
|
+
})
|
|
413
|
+
|
|
414
|
+
const result = await authService.getCurrentUser(db, 'user-1')
|
|
415
|
+
|
|
416
|
+
expect(result.isSuperAdmin).toBe(true)
|
|
417
|
+
})
|
|
418
|
+
|
|
419
|
+
it('should handle isSuperAdmin as boolean true', async () => {
|
|
420
|
+
;(queryOne as Mock).mockResolvedValueOnce({
|
|
421
|
+
id: 'user-1',
|
|
422
|
+
googleId: 'google-1',
|
|
423
|
+
email: 'user@example.com',
|
|
424
|
+
name: 'User',
|
|
425
|
+
avatarUrl: null,
|
|
426
|
+
status: 'active',
|
|
427
|
+
providerIds: JSON.stringify(['google']),
|
|
428
|
+
isSuperAdmin: true,
|
|
429
|
+
createdAt: new Date().toISOString(),
|
|
430
|
+
updatedAt: new Date().toISOString(),
|
|
431
|
+
deletedAt: null,
|
|
432
|
+
})
|
|
433
|
+
|
|
434
|
+
const result = await authService.getCurrentUser(db, 'user-1')
|
|
435
|
+
|
|
436
|
+
expect(result.isSuperAdmin).toBe(true)
|
|
437
|
+
})
|
|
438
|
+
|
|
439
|
+
it('should handle providerIds as array', async () => {
|
|
440
|
+
;(queryOne as Mock).mockResolvedValueOnce({
|
|
441
|
+
id: 'user-1',
|
|
442
|
+
googleId: 'google-1',
|
|
443
|
+
email: 'user@example.com',
|
|
444
|
+
name: 'User',
|
|
445
|
+
avatarUrl: null,
|
|
446
|
+
status: 'active',
|
|
447
|
+
providerIds: ['google', 'github'],
|
|
448
|
+
isSuperAdmin: 0,
|
|
449
|
+
createdAt: new Date().toISOString(),
|
|
450
|
+
updatedAt: new Date().toISOString(),
|
|
451
|
+
deletedAt: null,
|
|
452
|
+
})
|
|
453
|
+
|
|
454
|
+
const result = await authService.getCurrentUser(db, 'user-1')
|
|
455
|
+
|
|
456
|
+
expect(result.providerIds).toEqual(['google', 'github'])
|
|
457
|
+
})
|
|
458
|
+
|
|
459
|
+
it('should handle invalid JSON in providerIds', async () => {
|
|
460
|
+
;(queryOne as Mock).mockResolvedValueOnce({
|
|
461
|
+
id: 'user-1',
|
|
462
|
+
googleId: 'google-1',
|
|
463
|
+
email: 'user@example.com',
|
|
464
|
+
name: 'User',
|
|
465
|
+
avatarUrl: null,
|
|
466
|
+
status: 'active',
|
|
467
|
+
providerIds: 'not-valid-json',
|
|
468
|
+
isSuperAdmin: 0,
|
|
469
|
+
createdAt: new Date().toISOString(),
|
|
470
|
+
updatedAt: new Date().toISOString(),
|
|
471
|
+
deletedAt: null,
|
|
472
|
+
})
|
|
473
|
+
|
|
474
|
+
const result = await authService.getCurrentUser(db, 'user-1')
|
|
475
|
+
|
|
476
|
+
expect(result.providerIds).toEqual([])
|
|
477
|
+
})
|
|
478
|
+
|
|
479
|
+
it('should handle non-array JSON in providerIds', async () => {
|
|
480
|
+
;(queryOne as Mock).mockResolvedValueOnce({
|
|
481
|
+
id: 'user-1',
|
|
482
|
+
googleId: 'google-1',
|
|
483
|
+
email: 'user@example.com',
|
|
484
|
+
name: 'User',
|
|
485
|
+
avatarUrl: null,
|
|
486
|
+
status: 'active',
|
|
487
|
+
providerIds: JSON.stringify({ type: 'object' }),
|
|
488
|
+
isSuperAdmin: 0,
|
|
489
|
+
createdAt: new Date().toISOString(),
|
|
490
|
+
updatedAt: new Date().toISOString(),
|
|
491
|
+
deletedAt: null,
|
|
492
|
+
})
|
|
493
|
+
|
|
494
|
+
const result = await authService.getCurrentUser(db, 'user-1')
|
|
495
|
+
|
|
496
|
+
expect(result.providerIds).toEqual([])
|
|
497
|
+
})
|
|
498
|
+
|
|
499
|
+
it('should handle null providerIds', async () => {
|
|
500
|
+
;(queryOne as Mock).mockResolvedValueOnce({
|
|
501
|
+
id: 'user-1',
|
|
502
|
+
googleId: 'google-1',
|
|
503
|
+
email: 'user@example.com',
|
|
504
|
+
name: 'User',
|
|
505
|
+
avatarUrl: null,
|
|
506
|
+
status: 'active',
|
|
507
|
+
providerIds: null,
|
|
508
|
+
isSuperAdmin: 0,
|
|
509
|
+
createdAt: new Date().toISOString(),
|
|
510
|
+
updatedAt: new Date().toISOString(),
|
|
511
|
+
deletedAt: null,
|
|
512
|
+
})
|
|
513
|
+
|
|
514
|
+
const result = await authService.getCurrentUser(db, 'user-1')
|
|
515
|
+
|
|
516
|
+
expect(result.providerIds).toEqual([])
|
|
517
|
+
})
|
|
518
|
+
|
|
519
|
+
it('should handle avatarUrl present', async () => {
|
|
520
|
+
;(queryOne as Mock).mockResolvedValueOnce({
|
|
521
|
+
id: 'user-1',
|
|
522
|
+
googleId: 'google-1',
|
|
523
|
+
email: 'user@example.com',
|
|
524
|
+
name: 'User',
|
|
525
|
+
avatarUrl: 'https://example.com/avatar.jpg',
|
|
526
|
+
status: 'active',
|
|
527
|
+
providerIds: JSON.stringify(['google']),
|
|
528
|
+
isSuperAdmin: 0,
|
|
529
|
+
createdAt: new Date().toISOString(),
|
|
530
|
+
updatedAt: new Date().toISOString(),
|
|
531
|
+
deletedAt: null,
|
|
532
|
+
})
|
|
533
|
+
|
|
534
|
+
const result = await authService.getCurrentUser(db, 'user-1')
|
|
535
|
+
|
|
536
|
+
expect(result).toBeDefined()
|
|
537
|
+
})
|
|
538
|
+
|
|
539
|
+
it('should handle deletedAt present', async () => {
|
|
540
|
+
;(queryOne as Mock).mockResolvedValueOnce({
|
|
541
|
+
id: 'user-1',
|
|
542
|
+
googleId: 'google-1',
|
|
543
|
+
email: 'user@example.com',
|
|
544
|
+
name: 'User',
|
|
545
|
+
avatarUrl: null,
|
|
546
|
+
status: 'active',
|
|
547
|
+
providerIds: JSON.stringify(['google']),
|
|
548
|
+
isSuperAdmin: 0,
|
|
549
|
+
createdAt: new Date().toISOString(),
|
|
550
|
+
updatedAt: new Date().toISOString(),
|
|
551
|
+
deletedAt: new Date().toISOString(),
|
|
552
|
+
})
|
|
553
|
+
|
|
554
|
+
const result = await authService.getCurrentUser(db, 'user-1')
|
|
555
|
+
|
|
556
|
+
expect(result.deletedAt).not.toBeNull()
|
|
557
|
+
})
|
|
558
|
+
})
|
|
559
|
+
|
|
560
|
+
describe('findOrCreateUser failure cases', () => {
|
|
561
|
+
it('should throw when user creation fails', async () => {
|
|
562
|
+
const env = createMockEnv()
|
|
563
|
+
const googleUser: GoogleUserInfo = {
|
|
564
|
+
sub: 'new_google_sub',
|
|
565
|
+
email: 'user@example.com',
|
|
566
|
+
email_verified: true,
|
|
567
|
+
name: 'User',
|
|
568
|
+
picture: undefined,
|
|
569
|
+
given_name: 'User',
|
|
570
|
+
family_name: '',
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// First call: no existing user found
|
|
574
|
+
// Second call: insertUserSql returns null (user creation fails)
|
|
575
|
+
;(queryOne as Mock)
|
|
576
|
+
.mockResolvedValueOnce(null)
|
|
577
|
+
.mockResolvedValueOnce(null)
|
|
578
|
+
|
|
579
|
+
await expect(
|
|
580
|
+
authService.findOrCreateUser(db, env, googleUser, mockAuthContext)
|
|
581
|
+
).rejects.toThrow('Failed to create user')
|
|
582
|
+
})
|
|
583
|
+
|
|
584
|
+
it('should update user when name changes', async () => {
|
|
585
|
+
const env = createMockEnv()
|
|
586
|
+
const googleUser: GoogleUserInfo = {
|
|
587
|
+
sub: 'existing_sub',
|
|
588
|
+
email: 'same@example.com',
|
|
589
|
+
email_verified: true,
|
|
590
|
+
name: 'New Name',
|
|
591
|
+
picture: 'https://same-picture.jpg',
|
|
592
|
+
given_name: 'New',
|
|
593
|
+
family_name: 'Name',
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
const userRow = {
|
|
597
|
+
id: 'user-1',
|
|
598
|
+
googleId: googleUser.sub,
|
|
599
|
+
email: googleUser.email,
|
|
600
|
+
name: 'Old Name',
|
|
601
|
+
avatarUrl: googleUser.picture,
|
|
602
|
+
status: 'active',
|
|
603
|
+
providerIds: JSON.stringify(['google']),
|
|
604
|
+
isSuperAdmin: 0,
|
|
605
|
+
createdAt: new Date().toISOString(),
|
|
606
|
+
updatedAt: new Date().toISOString(),
|
|
607
|
+
deletedAt: null,
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
;(queryOne as Mock)
|
|
611
|
+
.mockResolvedValueOnce(userRow)
|
|
612
|
+
.mockResolvedValueOnce({ ...userRow, name: googleUser.name })
|
|
613
|
+
|
|
614
|
+
const result = await authService.findOrCreateUser(db, env, googleUser, mockAuthContext)
|
|
615
|
+
|
|
616
|
+
expect(result.isNewUser).toBe(false)
|
|
617
|
+
expect(execute).toHaveBeenCalled()
|
|
618
|
+
})
|
|
619
|
+
|
|
620
|
+
it('should update user when avatar changes', async () => {
|
|
621
|
+
const env = createMockEnv()
|
|
622
|
+
const googleUser: GoogleUserInfo = {
|
|
623
|
+
sub: 'existing_sub',
|
|
624
|
+
email: 'same@example.com',
|
|
625
|
+
email_verified: true,
|
|
626
|
+
name: 'Same Name',
|
|
627
|
+
picture: 'https://new-picture.jpg',
|
|
628
|
+
given_name: 'Same',
|
|
629
|
+
family_name: 'Name',
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
const userRow = {
|
|
633
|
+
id: 'user-1',
|
|
634
|
+
googleId: googleUser.sub,
|
|
635
|
+
email: googleUser.email,
|
|
636
|
+
name: googleUser.name,
|
|
637
|
+
avatarUrl: 'https://old-picture.jpg',
|
|
638
|
+
status: 'active',
|
|
639
|
+
providerIds: JSON.stringify(['google']),
|
|
640
|
+
isSuperAdmin: 0,
|
|
641
|
+
createdAt: new Date().toISOString(),
|
|
642
|
+
updatedAt: new Date().toISOString(),
|
|
643
|
+
deletedAt: null,
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
;(queryOne as Mock)
|
|
647
|
+
.mockResolvedValueOnce(userRow)
|
|
648
|
+
.mockResolvedValueOnce({ ...userRow, avatarUrl: googleUser.picture })
|
|
649
|
+
|
|
650
|
+
const result = await authService.findOrCreateUser(db, env, googleUser, mockAuthContext)
|
|
651
|
+
|
|
652
|
+
expect(result.isNewUser).toBe(false)
|
|
653
|
+
expect(execute).toHaveBeenCalled()
|
|
654
|
+
})
|
|
178
655
|
})
|
|
179
656
|
})
|
|
@@ -17,6 +17,17 @@ vi.mock('@server/db/sql', () => ({
|
|
|
17
17
|
queryOne: vi.fn(),
|
|
18
18
|
queryAll: vi.fn(),
|
|
19
19
|
execute: vi.fn(),
|
|
20
|
+
toStringValue: (value: unknown) => {
|
|
21
|
+
if (typeof value === 'string') return value
|
|
22
|
+
if (typeof value === 'number' || typeof value === 'bigint') return String(value)
|
|
23
|
+
return ''
|
|
24
|
+
},
|
|
25
|
+
toNullableString: (value: unknown) => {
|
|
26
|
+
if (value === null || value === undefined) return null
|
|
27
|
+
if (typeof value === 'string') return value
|
|
28
|
+
if (typeof value === 'number' || typeof value === 'bigint') return String(value)
|
|
29
|
+
return null
|
|
30
|
+
},
|
|
20
31
|
}))
|
|
21
32
|
|
|
22
33
|
import { sendInvitationEmail } from '@server/lib/email'
|
|
@@ -60,6 +71,60 @@ describe('invitationsService', () => {
|
|
|
60
71
|
})
|
|
61
72
|
|
|
62
73
|
describe('create', () => {
|
|
74
|
+
it('should throw ForbiddenError when user has no role', async () => {
|
|
75
|
+
const env = createMockEnv()
|
|
76
|
+
const ctxWithoutRole = createMockContext({ userRole: undefined })
|
|
77
|
+
|
|
78
|
+
await expect(
|
|
79
|
+
invitationsService.create(db, env, ctxWithoutRole, {
|
|
80
|
+
email: 'invite@example.com',
|
|
81
|
+
role: 'VIEWER',
|
|
82
|
+
})
|
|
83
|
+
).rejects.toThrow('User must have a role in this account to invite others')
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('should throw ForbiddenError when assigning higher role than own', async () => {
|
|
87
|
+
const env = createMockEnv()
|
|
88
|
+
const ctxAsViewer = createMockContext({ userRole: 'VIEWER' })
|
|
89
|
+
|
|
90
|
+
await expect(
|
|
91
|
+
invitationsService.create(db, env, ctxAsViewer, {
|
|
92
|
+
email: 'invite@example.com',
|
|
93
|
+
role: 'ADMIN',
|
|
94
|
+
})
|
|
95
|
+
).rejects.toThrow('Cannot assign a role higher than your own')
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('should throw ConflictError when user is already a member', async () => {
|
|
99
|
+
const env = createMockEnv()
|
|
100
|
+
|
|
101
|
+
;(queryOne as Mock).mockResolvedValueOnce({ ok: 1 })
|
|
102
|
+
|
|
103
|
+
await expect(
|
|
104
|
+
invitationsService.create(db, env, ctx, {
|
|
105
|
+
email: 'member@example.com',
|
|
106
|
+
role: 'VIEWER',
|
|
107
|
+
})
|
|
108
|
+
).rejects.toThrow('User is already a member of this account')
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('should throw Error when account not found', async () => {
|
|
112
|
+
const env = createMockEnv()
|
|
113
|
+
|
|
114
|
+
;(queryOne as Mock)
|
|
115
|
+
.mockResolvedValueOnce(null)
|
|
116
|
+
.mockResolvedValueOnce(null)
|
|
117
|
+
.mockResolvedValueOnce(null)
|
|
118
|
+
.mockResolvedValueOnce(null)
|
|
119
|
+
|
|
120
|
+
await expect(
|
|
121
|
+
invitationsService.create(db, env, ctx, {
|
|
122
|
+
email: 'invite@example.com',
|
|
123
|
+
role: 'VIEWER',
|
|
124
|
+
})
|
|
125
|
+
).rejects.toThrow('Account not found')
|
|
126
|
+
})
|
|
127
|
+
|
|
63
128
|
it('should link existing user instead of creating invitation', async () => {
|
|
64
129
|
const env = createMockEnv()
|
|
65
130
|
const existingUser = createUserFixture({ id: 'user-1', email: 'user@example.com' })
|
|
@@ -161,5 +226,118 @@ describe('invitationsService', () => {
|
|
|
161
226
|
expect(execute).toHaveBeenCalled()
|
|
162
227
|
expect(logAuthEvent).toHaveBeenCalled()
|
|
163
228
|
})
|
|
229
|
+
|
|
230
|
+
it('should throw NotFoundError when invitation not found', async () => {
|
|
231
|
+
;(queryOne as Mock).mockResolvedValueOnce(null)
|
|
232
|
+
|
|
233
|
+
await expect(
|
|
234
|
+
invitationsService.accept(db, 'inv-999', 'user-1', { transactionId: 'tx' })
|
|
235
|
+
).rejects.toThrow(NotFoundError)
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
it('should handle snake_case column names', async () => {
|
|
239
|
+
;(queryOne as Mock).mockResolvedValueOnce({ id: 'inv-1', account_id: 'account-456', role: 'EDITOR' })
|
|
240
|
+
|
|
241
|
+
await invitationsService.accept(db, 'inv-1', 'user-1', { transactionId: 'tx' })
|
|
242
|
+
|
|
243
|
+
expect(execute).toHaveBeenCalled()
|
|
244
|
+
})
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
describe('getByToken', () => {
|
|
248
|
+
it('should return null when token not found', async () => {
|
|
249
|
+
;(queryOne as Mock).mockResolvedValueOnce(null)
|
|
250
|
+
|
|
251
|
+
const result = await invitationsService.getByToken(db, 'invalid-token')
|
|
252
|
+
|
|
253
|
+
expect(result).toBeNull()
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
it('should return null when invitation already accepted', async () => {
|
|
257
|
+
;(queryOne as Mock).mockResolvedValueOnce({
|
|
258
|
+
id: 'inv-1',
|
|
259
|
+
accountId: 'account-123',
|
|
260
|
+
email: 'test@example.com',
|
|
261
|
+
role: 'VIEWER',
|
|
262
|
+
acceptedAt: new Date().toISOString(),
|
|
263
|
+
expiresAt: new Date(Date.now() + 86400000).toISOString(),
|
|
264
|
+
accountName: 'Test Account',
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
const result = await invitationsService.getByToken(db, 'accepted-token')
|
|
268
|
+
|
|
269
|
+
expect(result).toBeNull()
|
|
270
|
+
})
|
|
271
|
+
|
|
272
|
+
it('should return null when invitation expired', async () => {
|
|
273
|
+
;(queryOne as Mock).mockResolvedValueOnce({
|
|
274
|
+
id: 'inv-1',
|
|
275
|
+
accountId: 'account-123',
|
|
276
|
+
email: 'test@example.com',
|
|
277
|
+
role: 'VIEWER',
|
|
278
|
+
acceptedAt: null,
|
|
279
|
+
expiresAt: new Date(Date.now() - 86400000).toISOString(),
|
|
280
|
+
accountName: 'Test Account',
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
const result = await invitationsService.getByToken(db, 'expired-token')
|
|
284
|
+
|
|
285
|
+
expect(result).toBeNull()
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
it('should return invitation when valid', async () => {
|
|
289
|
+
;(queryOne as Mock).mockResolvedValueOnce({
|
|
290
|
+
id: 'inv-1',
|
|
291
|
+
accountId: 'account-123',
|
|
292
|
+
email: 'test@example.com',
|
|
293
|
+
role: 'VIEWER',
|
|
294
|
+
acceptedAt: null,
|
|
295
|
+
expiresAt: new Date(Date.now() + 86400000).toISOString(),
|
|
296
|
+
accountName: 'Test Account',
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
const result = await invitationsService.getByToken(db, 'valid-token')
|
|
300
|
+
|
|
301
|
+
expect(result).not.toBeNull()
|
|
302
|
+
expect(result?.email).toBe('test@example.com')
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
it('should handle snake_case column names from DB', async () => {
|
|
306
|
+
;(queryOne as Mock).mockResolvedValueOnce({
|
|
307
|
+
id: 'inv-1',
|
|
308
|
+
account_id: 'account-123',
|
|
309
|
+
email: 'test@example.com',
|
|
310
|
+
role: 'VIEWER',
|
|
311
|
+
accepted_at: null,
|
|
312
|
+
expires_at: new Date(Date.now() + 86400000).toISOString(),
|
|
313
|
+
account_name: 'Test Account',
|
|
314
|
+
})
|
|
315
|
+
|
|
316
|
+
const result = await invitationsService.getByToken(db, 'valid-token')
|
|
317
|
+
|
|
318
|
+
expect(result).not.toBeNull()
|
|
319
|
+
expect(result?.accountId).toBe('account-123')
|
|
320
|
+
})
|
|
321
|
+
})
|
|
322
|
+
|
|
323
|
+
describe('list with snake_case mapping', () => {
|
|
324
|
+
it('should handle snake_case column names', async () => {
|
|
325
|
+
;(queryAll as Mock).mockResolvedValueOnce([
|
|
326
|
+
{
|
|
327
|
+
id: 'inv-1',
|
|
328
|
+
email: 'invite@example.com',
|
|
329
|
+
role: 'VIEWER',
|
|
330
|
+
invited_by_id: 'user-1',
|
|
331
|
+
inviter_name: 'Inviter',
|
|
332
|
+
expires_at: new Date().toISOString(),
|
|
333
|
+
created_at: new Date().toISOString(),
|
|
334
|
+
},
|
|
335
|
+
])
|
|
336
|
+
|
|
337
|
+
const result = await invitationsService.list(db, ctx)
|
|
338
|
+
|
|
339
|
+
expect(result).toHaveLength(1)
|
|
340
|
+
expect(result[0].invitedBy.id).toBe('user-1')
|
|
341
|
+
})
|
|
164
342
|
})
|
|
165
343
|
})
|